Files
Uni-Lab-OS/unilabos/devices/laiyu_liquid/controllers/xyz_controller.py
Xuwznln 9aeffebde1 0.10.7 Update (#101)
* Cleanup registry to be easy-understanding (#76)

* delete deprecated mock devices

* rename categories

* combine chromatographic devices

* rename rviz simulation nodes

* organic virtual devices

* parse vessel_id

* run registry completion before merge

---------

Co-authored-by: Xuwznln <18435084+Xuwznln@users.noreply.github.com>

* fix: workstation handlers and vessel_id parsing

* fix: working dir error when input config path
feat: report publish topic when error

* modify default discovery_interval to 15s

* feat: add trace log level

* feat: 添加ChinWe设备控制类,支持串口通信和电机控制功能 (#79)

* fix: drop_tips not using auto resource select

* fix: discard_tips error

* fix: discard_tips

* fix: prcxi_res

* add: prcxi res
fix: startup slow

* feat: workstation example

* fix pumps and liquid_handler handle

* feat: 优化protocol node节点运行日志

* fix all protocol_compilers and remove deprecated devices

* feat: 新增use_remote_resource参数

* fix and remove redundant info

* bugfixes on organic protocols

* fix filter protocol

* fix protocol node

* 临时兼容错误的driver写法

* fix: prcxi import error

* use call_async in all service to avoid deadlock

* fix: figure_resource

* Update recipe.yaml

* add workstation template and battery example

* feat: add sk & ak

* update workstation base

* Create workstation_architecture.md

* refactor: workstation_base 重构为仅含业务逻辑,通信和子设备管理交给 ProtocolNode

* refactor: ProtocolNode→WorkstationNode

* Add:msgs.action (#83)

* update: Workstation dev 将版本号从 0.10.3 更新为 0.10.4 (#84)

* Add:msgs.action

* update: 将版本号从 0.10.3 更新为 0.10.4

* simplify resource system

* uncompleted refactor

* example for use WorkstationBase

* feat: websocket

* feat: websocket test

* feat: workstation example

* feat: action status

* fix: station自己的方法注册错误

* fix: 还原protocol node处理方法

* fix: build

* fix: missing job_id key

* ws test version 1

* ws test version 2

* ws protocol

* 增加物料关系上传日志

* 增加物料关系上传日志

* 修正物料关系上传

* 修复工站的tracker实例追踪失效问题

* 增加handle检测,增加material edge关系上传

* 修复event loop错误

* 修复edge上报错误

* 修复async错误

* 更新schema的title字段

* 主机节点信息等支持自动刷新

* 注册表编辑器

* 修复status密集发送时,消息出错

* 增加addr参数

* fix: addr param

* fix: addr param

* 取消labid 和 强制config输入

* Add action definitions for LiquidHandlerSetGroup and LiquidHandlerTransferGroup

- Created LiquidHandlerSetGroup.action with fields for group name, wells, and volumes.
- Created LiquidHandlerTransferGroup.action with fields for source and target group names and unit volume.
- Both actions include response fields for return information and success status.

* Add LiquidHandlerSetGroup and LiquidHandlerTransferGroup actions to CMakeLists

* Add set_group and transfer_group methods to PRCXI9300Handler and update liquid_handler.yaml

* result_info改为字典类型

* 新增uat的地址替换

* runze multiple pump support

(cherry picked from commit 49354fcf39)

* remove runze multiple software obtainer

(cherry picked from commit 8bcc92a394)

* support multiple backbone

(cherry picked from commit 4771ff2347)

* Update runze pump format

* Correct runze multiple backbone

* Update runze_multiple_backbone

* Correct runze pump multiple receive method.

* Correct runze pump multiple receive method.

* 对于PRCXI9320的transfer_group,一对多和多对多

* 移除MQTT,更新launch文档,提供注册表示例文件,更新到0.10.5

* fix import error

* fix dupe upload registry

* refactor ws client

* add server timeout

* Fix: run-column with correct vessel id (#86)

* fix run_column

* Update run_column_protocol.py

(cherry picked from commit e5aa4d940a)

* resource_update use resource_add

* 新增版位推荐功能

* 重新规定了版位推荐的入参

* update registry with nested obj

* fix protocol node log_message, added create_resource return value

* fix protocol node log_message, added create_resource return value

* try fix add protocol

* fix resource_add

* 修复移液站错误的aspirate注册表

* Feature/xprbalance-zhida (#80)

* feat(devices): add Zhida GC/MS pretreatment automation workstation

* feat(devices): add mettler_toledo xpr balance

* balance

* 重新补全zhida注册表

* PRCXI9320 json

* PRCXI9320 json

* PRCXI9320 json

* fix resource download

* remove class for resource

* bump version to 0.10.6

* 更新所有注册表

* 修复protocolnode的兼容性

* 修复protocolnode的兼容性

* Update install md

* Add Defaultlayout

* 更新物料接口

* fix dict to tree/nested-dict converter

* coin_cell_station draft

* refactor: rename "station_resource" to "deck"

* add standardized BIOYOND resources: bottle_carrier, bottle

* refactor and add BIOYOND resources tests

* add BIOYOND deck assignment and pass all tests

* fix: update resource with correct structure; remove deprecated liquid_handler set_group action

* feat: 将新威电池测试系统驱动与配置文件并入 workstation_dev_YB2 (#92)

* feat: 新威电池测试系统驱动与注册文件

* feat: bring neware driver & battery.json into workstation_dev_YB2

* add bioyond studio draft

* bioyond station with communication init and resource sync

* fix bioyond station and registry

* fix: update resource with correct structure; remove deprecated liquid_handler set_group action

* frontend_docs

* create/update resources with POST/PUT for big amount/ small amount data

* create/update resources with POST/PUT for big amount/ small amount data

* refactor: add itemized_carrier instead of carrier consists of ResourceHolder

* create warehouse by factory func

* update bioyond launch json

* add child_size for itemized_carrier

* fix bioyond resource io

* Workstation templates: Resources and its CRUD, and workstation tasks (#95)

* coin_cell_station draft

* refactor: rename "station_resource" to "deck"

* add standardized BIOYOND resources: bottle_carrier, bottle

* refactor and add BIOYOND resources tests

* add BIOYOND deck assignment and pass all tests

* fix: update resource with correct structure; remove deprecated liquid_handler set_group action

* feat: 将新威电池测试系统驱动与配置文件并入 workstation_dev_YB2 (#92)

* feat: 新威电池测试系统驱动与注册文件

* feat: bring neware driver & battery.json into workstation_dev_YB2

* add bioyond studio draft

* bioyond station with communication init and resource sync

* fix bioyond station and registry

* create/update resources with POST/PUT for big amount/ small amount data

* refactor: add itemized_carrier instead of carrier consists of ResourceHolder

* create warehouse by factory func

* update bioyond launch json

* add child_size for itemized_carrier

* fix bioyond resource io

---------

Co-authored-by: h840473807 <47357934+h840473807@users.noreply.github.com>
Co-authored-by: Xie Qiming <97236197+Andy6M@users.noreply.github.com>

* 更新物料接口

* Workstation dev yb2 (#100)

* Refactor and extend reaction station action messages

* Refactor dispensing station tasks to enhance parameter clarity and add batch processing capabilities

- Updated `create_90_10_vial_feeding_task` to include detailed parameters for 90%/10% vial feeding, improving clarity and usability.
- Introduced `create_batch_90_10_vial_feeding_task` for batch processing of 90%/10% vial feeding tasks with JSON formatted input.
- Added `create_batch_diamine_solution_task` for batch preparation of diamine solution, also utilizing JSON formatted input.
- Refined `create_diamine_solution_task` to include additional parameters for better task configuration.
- Enhanced schema descriptions and default values for improved user guidance.

* 修复to_plr_resources

* add update remove

* 支持选择器注册表自动生成
支持转运物料

* 修复资源添加

* 修复transfer_resource_to_another生成

* 更新transfer_resource_to_another参数,支持spot入参

* 新增test_resource动作

* fix host_node error

* fix host_node test_resource error

* fix host_node test_resource error

* 过滤本地动作

* 移动内部action以兼容host node

* 修复同步任务报错不显示的bug

* feat: 允许返回非本节点物料,后面可以通过decoration进行区分,就不进行warning了

* update todo

* modify bioyond/plr converter, bioyond resource registry, and tests

* pass the tests

* update todo

* add conda-pack-build.yml

* add auto install script for conda-pack-build.yml

(cherry picked from commit 172599adcf)

* update conda-pack-build.yml

* update conda-pack-build.yml

* update conda-pack-build.yml

* update conda-pack-build.yml

* update conda-pack-build.yml

* Add version in __init__.py
Update conda-pack-build.yml
Add create_zip_archive.py

* Update conda-pack-build.yml

* Update conda-pack-build.yml (with mamba)

* Update conda-pack-build.yml

* Fix FileNotFoundError

* Try fix 'charmap' codec can't encode characters in position 16-23: character maps to <undefined>

* Fix unilabos msgs search error

* Fix environment_check.py

* Update recipe.yaml

* Update registry. Update uuid loop figure method. Update install docs.

* Fix nested conda pack

* Fix one-key installation path error

* Bump version to 0.10.7

* Workshop bj (#99)

* Add LaiYu Liquid device integration and tests

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

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

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

* add

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

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

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

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

* 修复大小写文件夹名字

* 电池装配工站二次开发教程(带目录)上传至dev (#94)

* 电池装配工站二次开发教程

* Update intro.md

* 物料教程

* 更新物料教程,json格式注释

* Update prcxi driver & fix transfer_liquid mix_times (#90)

* Update prcxi driver & fix transfer_liquid mix_times

* fix: correct mix_times type

* Update liquid_handler registry

* test: prcxi.py

* Update registry from pr

* fix ony-key script not exist

* clean files

---------

Co-authored-by: Junhan Chang <changjh@dp.tech>
Co-authored-by: ZiWei <131428629+ZiWei09@users.noreply.github.com>
Co-authored-by: Guangxin Zhang <guangxin.zhang.bio@gmail.com>
Co-authored-by: Xie Qiming <97236197+Andy6M@users.noreply.github.com>
Co-authored-by: h840473807 <47357934+h840473807@users.noreply.github.com>
Co-authored-by: LccLink <1951855008@qq.com>
Co-authored-by: lixinyu1011 <61094742+lixinyu1011@users.noreply.github.com>
Co-authored-by: shiyubo0410 <shiyubo@dp.tech>
2025-10-12 23:34:26 +08:00

1183 lines
48 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
XYZ三轴步进电机控制器
支持坐标系管理、限位开关回零、工作原点设定等功能
主要功能:
- 坐标系转换层(步数↔毫米)
- 限位开关回零功能
- 工作原点示教和保存
- 安全限位检查
- 运动控制接口
"""
import json
import os
import time
from typing import Optional, Dict, Tuple, Union
from dataclasses import dataclass, asdict
from pathlib import Path
import logging
# 添加项目根目录到Python路径以解决模块导入问题
import sys
import os
# 无论如何都添加项目根目录到路径
current_file = os.path.abspath(__file__)
# 从 .../Uni-Lab-OS/unilabos/devices/LaiYu_Liquid/controllers/xyz_controller.py
# 向上5级到 .../Uni-Lab-OS
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(current_file)))))
# 强制添加项目根目录到sys.path的开头
sys.path.insert(0, project_root)
# 导入原有的驱动
from unilabos.devices.laiyu_liquid.drivers.xyz_stepper_driver import XYZStepperController, MotorAxis, MotorStatus
logger = logging.getLogger(__name__)
@dataclass
class MachineConfig:
"""机械配置参数"""
# 步距配置 (基于16384步/圈的步进电机)
steps_per_mm_x: float = 204.8 # X轴步距 (16384步/圈 ÷ 80mm导程)
steps_per_mm_y: float = 204.8 # Y轴步距 (16384步/圈 ÷ 80mm导程)
steps_per_mm_z: float = 3276.8 # Z轴步距 (16384步/圈 ÷ 5mm导程)
# 行程限制
max_travel_x: float = 340.0 # X轴最大行程
max_travel_y: float = 250.0 # Y轴最大行程
max_travel_z: float = 160.0 # Z轴最大行程
# 安全移动参数
safe_z_height: float = 0.0 # Z轴安全移动高度 (mm) - 液体处理工作站安全高度
z_approach_height: float = 5.0 # Z轴接近高度 (mm) - 在目标位置上方的预备高度
# 回零参数
homing_speed: int = 100 # 回零速度 (rpm)
homing_timeout: float = 30.0 # 回零超时时间
safe_clearance: float = 1.0 # 安全间隙 (mm)
position_stable_time: float = 3.0 # 位置稳定检测时间(秒)
position_check_interval: float = 0.2 # 位置检查间隔(秒)
# 运动参数
default_speed: int = 100 # 默认运动速度 (rpm)
default_acceleration: int = 1000 # 默认加速度
@dataclass
class CoordinateOrigin:
"""坐标原点信息"""
machine_origin_steps: Dict[str, int] = None # 机械原点步数位置
work_origin_steps: Dict[str, int] = None # 工作原点步数位置
is_homed: bool = False # 是否已回零
timestamp: str = "" # 设定时间戳
def __post_init__(self):
if self.machine_origin_steps is None:
self.machine_origin_steps = {"x": 0, "y": 0, "z": 0}
if self.work_origin_steps is None:
self.work_origin_steps = {"x": 0, "y": 0, "z": 0}
class CoordinateSystemError(Exception):
"""坐标系统异常"""
pass
class XYZController(XYZStepperController):
"""XYZ三轴控制器"""
def __init__(self, port: str, baudrate: int = 115200,
machine_config: Optional[MachineConfig] = None,
config_file: str = "machine_config.json",
auto_connect: bool = True):
"""
初始化XYZ控制器
Args:
port: 串口端口
baudrate: 波特率
machine_config: 机械配置参数
config_file: 配置文件路径
auto_connect: 是否自动连接设备
"""
super().__init__(port, baudrate)
# 机械配置
self.machine_config = machine_config or MachineConfig()
self.config_file = config_file
# 坐标系统
self.coordinate_origin = CoordinateOrigin()
self.origin_file = "coordinate_origin.json"
# 连接状态
self.is_connected = False
# 加载配置
self._load_config()
self._load_coordinate_origin()
# 自动连接设备
if auto_connect:
self.connect_device()
def connect_device(self) -> bool:
"""
连接设备并初始化
Returns:
bool: 连接是否成功
"""
try:
logger.info(f"正在连接设备: {self.port}")
# 连接硬件
if not self.connect():
logger.error("硬件连接失败")
return False
self.is_connected = True
logger.info("设备连接成功")
# 使能所有轴
enable_results = self.enable_all_axes(True)
success_count = sum(1 for result in enable_results.values() if result)
logger.info(f"轴使能结果: {success_count}/{len(enable_results)} 成功")
# 获取系统状态
try:
status = self.get_system_status()
logger.info(f"系统状态获取成功: {len(status)} 项信息")
except Exception as e:
logger.warning(f"获取系统状态失败: {e}")
return True
except Exception as e:
logger.error(f"设备连接失败: {e}")
self.is_connected = False
return False
def disconnect_device(self):
"""断开设备连接"""
try:
if self.is_connected:
self.disconnect() # 使用父类的disconnect方法
self.is_connected = False
logger.info("设备连接已断开")
except Exception as e:
logger.error(f"断开连接失败: {e}")
def _load_config(self):
"""加载机械配置"""
try:
if os.path.exists(self.config_file):
with open(self.config_file, 'r', encoding='utf-8') as f:
config_data = json.load(f)
# 更新配置参数
for key, value in config_data.items():
if hasattr(self.machine_config, key):
setattr(self.machine_config, key, value)
logger.info("机械配置加载完成")
except Exception as e:
logger.warning(f"加载机械配置失败: {e},使用默认配置")
def _save_config(self):
"""保存机械配置"""
try:
with open(self.config_file, 'w', encoding='utf-8') as f:
json.dump(asdict(self.machine_config), f, indent=2, ensure_ascii=False)
logger.info("机械配置保存完成")
except Exception as e:
logger.error(f"保存机械配置失败: {e}")
def _load_coordinate_origin(self):
"""加载坐标原点信息"""
try:
if os.path.exists(self.origin_file):
with open(self.origin_file, 'r', encoding='utf-8') as f:
origin_data = json.load(f)
self.coordinate_origin = CoordinateOrigin(**origin_data)
logger.info("坐标原点信息加载完成")
except Exception as e:
logger.warning(f"加载坐标原点失败: {e},使用默认设置")
def _save_coordinate_origin(self):
"""保存坐标原点信息"""
try:
# 更新时间戳
from datetime import datetime
self.coordinate_origin.timestamp = datetime.now().isoformat()
with open(self.origin_file, 'w', encoding='utf-8') as f:
json.dump(asdict(self.coordinate_origin), f, indent=2, ensure_ascii=False)
logger.info("坐标原点信息保存完成")
except Exception as e:
logger.error(f"保存坐标原点失败: {e}")
# ==================== 坐标转换方法 ====================
def mm_to_steps(self, axis: MotorAxis, mm: float) -> int:
"""毫米转步数"""
if axis == MotorAxis.X:
return int(mm * self.machine_config.steps_per_mm_x)
elif axis == MotorAxis.Y:
return int(mm * self.machine_config.steps_per_mm_y)
elif axis == MotorAxis.Z:
return int(mm * self.machine_config.steps_per_mm_z)
else:
raise ValueError(f"未知轴: {axis}")
def steps_to_mm(self, axis: MotorAxis, steps: int) -> float:
"""步数转毫米"""
if axis == MotorAxis.X:
return steps / self.machine_config.steps_per_mm_x
elif axis == MotorAxis.Y:
return steps / self.machine_config.steps_per_mm_y
elif axis == MotorAxis.Z:
return steps / self.machine_config.steps_per_mm_z
else:
raise ValueError(f"未知轴: {axis}")
def work_to_machine_steps(self, x: float = None, y: float = None, z: float = None) -> Dict[str, int]:
"""工作坐标转机械坐标步数"""
machine_steps = {}
if x is not None:
work_steps = self.mm_to_steps(MotorAxis.X, x)
machine_steps['x'] = self.coordinate_origin.work_origin_steps['x'] + work_steps
if y is not None:
work_steps = self.mm_to_steps(MotorAxis.Y, y)
machine_steps['y'] = self.coordinate_origin.work_origin_steps['y'] + work_steps
if z is not None:
work_steps = self.mm_to_steps(MotorAxis.Z, z)
machine_steps['z'] = self.coordinate_origin.work_origin_steps['z'] + work_steps
return machine_steps
def machine_to_work_coords(self, machine_steps: Dict[str, int]) -> Dict[str, float]:
"""机械坐标步数转工作坐标"""
work_coords = {}
for axis_name, steps in machine_steps.items():
axis = MotorAxis[axis_name.upper()]
work_origin_steps = self.coordinate_origin.work_origin_steps[axis_name]
relative_steps = steps - work_origin_steps
work_coords[axis_name] = self.steps_to_mm(axis, relative_steps)
return work_coords
def check_travel_limits(self, x: float = None, y: float = None, z: float = None) -> bool:
"""检查行程限制"""
if x is not None and (x < 0 or x > self.machine_config.max_travel_x):
raise CoordinateSystemError(f"X轴超出行程范围: {x}mm (0 ~ {self.machine_config.max_travel_x}mm)")
if y is not None and (y < 0 or y > self.machine_config.max_travel_y):
raise CoordinateSystemError(f"Y轴超出行程范围: {y}mm (0 ~ {self.machine_config.max_travel_y}mm)")
if z is not None and (z < 0 or z > self.machine_config.max_travel_z):
raise CoordinateSystemError(f"Z轴超出行程范围: {z}mm (0 ~ {self.machine_config.max_travel_z}mm)")
return True
# ==================== 回零和原点设定方法 ====================
def home_axis(self, axis: MotorAxis, direction: int = -1) -> bool:
"""
单轴回零到限位开关 - 使用步数变化检测
Args:
axis: 要回零的轴
direction: 回零方向 (-1负方向, 1正方向)
Returns:
bool: 回零是否成功
"""
if not self.is_connected:
logger.error("设备未连接,无法执行回零操作")
return False
try:
logger.info(f"开始{axis.name}轴回零")
# 使能电机
if not self.enable_motor(axis, True):
raise CoordinateSystemError(f"{axis.name}轴使能失败")
# 设置回零速度模式,根据方向设置正负
speed = self.machine_config.homing_speed * direction
if not self.set_speed_mode(axis, speed):
raise CoordinateSystemError(f"{axis.name}轴设置回零速度失败")
# 智能回零检测 - 基于步数变化
start_time = time.time()
limit_detected = False
final_position = None
# 步数变化检测参数(从配置获取)
position_stable_time = self.machine_config.position_stable_time
check_interval = self.machine_config.position_check_interval
last_position = None
stable_start_time = None
logger.info(f"{axis.name}轴开始移动,监测步数变化...")
while time.time() - start_time < self.machine_config.homing_timeout:
status = self.get_motor_status(axis)
current_position = status.steps
# 检查是否明确触碰限位开关
if (direction < 0 and status.status == MotorStatus.REVERSE_LIMIT_STOP) or \
(direction > 0 and status.status == MotorStatus.FORWARD_LIMIT_STOP):
# 停止运动
self.emergency_stop(axis)
time.sleep(0.5)
# 记录机械原点位置
final_position = current_position
limit_detected = True
logger.info(f"{axis.name}轴检测到限位开关信号,位置: {final_position}")
break
# 检查是否发生碰撞
if status.status == MotorStatus.COLLISION_STOP:
raise CoordinateSystemError(f"{axis.name}轴回零时发生碰撞")
# 步数变化检测逻辑
if last_position is not None:
# 检查位置是否发生变化
if abs(current_position - last_position) <= 1: # 允许1步的误差
# 位置基本没有变化
if stable_start_time is None:
stable_start_time = time.time()
logger.debug(f"{axis.name}轴位置开始稳定在 {current_position}")
elif time.time() - stable_start_time >= position_stable_time:
# 位置稳定超过指定时间,认为已到达限位
self.emergency_stop(axis)
time.sleep(0.5)
final_position = current_position
limit_detected = True
logger.info(f"{axis.name}轴位置稳定{position_stable_time}秒,假设已到达限位开关,位置: {final_position}")
break
else:
# 位置发生变化,重置稳定计时
stable_start_time = None
logger.debug(f"{axis.name}轴位置变化: {last_position} -> {current_position}")
last_position = current_position
time.sleep(check_interval)
# 超时处理
if not limit_detected:
logger.warning(f"{axis.name}轴回零超时({self.machine_config.homing_timeout}秒),强制停止")
self.emergency_stop(axis)
time.sleep(0.5)
# 获取当前位置作为机械原点
try:
status = self.get_motor_status(axis)
final_position = status.steps
logger.info(f"{axis.name}轴超时后位置: {final_position}")
except Exception as e:
logger.error(f"获取{axis.name}轴位置失败: {e}")
return False
# 记录机械原点位置
self.coordinate_origin.machine_origin_steps[axis.name.lower()] = final_position
# 从限位开关退出安全距离
try:
clearance_steps = self.mm_to_steps(axis, self.machine_config.safe_clearance)
safe_position = final_position + (clearance_steps * -direction) # 反方向退出
if not self.move_to_position(axis, safe_position,
self.machine_config.default_speed):
logger.warning(f"{axis.name}轴无法退出到安全位置")
else:
self.wait_for_completion(axis, 10.0)
logger.info(f"{axis.name}轴已退出到安全位置: {safe_position}")
except Exception as e:
logger.warning(f"{axis.name}轴退出安全位置时出错: {e}")
status_msg = "限位检测成功" if limit_detected else "超时假设成功"
logger.info(f"{axis.name}轴回零完成 ({status_msg}),机械原点: {final_position}")
return True
except Exception as e:
logger.error(f"{axis.name}轴回零失败: {e}")
self.emergency_stop(axis)
return False
def home_all_axes(self, sequence: list = None) -> bool:
"""
全轴回零 (液体处理工作站安全回零)
液体处理工作站回零策略:
1. Z轴必须首先回零避免与容器、试管架等碰撞
2. 然后XY轴回零确保移动路径安全
3. 严格按照Z->X->Y顺序执行不允许更改
Args:
sequence: 回零顺序液体处理工作站固定为Z->X->Y不建议修改
Returns:
bool: 全轴回零是否成功
"""
if not self.is_connected:
logger.error("设备未连接,无法执行回零操作")
return False
# 液体处理工作站安全回零序列Z轴绝对优先
safe_sequence = [MotorAxis.Z, MotorAxis.X, MotorAxis.Y]
if sequence is not None and sequence != safe_sequence:
logger.warning(f"液体处理工作站不建议修改回零序列,使用安全序列: {[axis.name for axis in safe_sequence]}")
sequence = safe_sequence # 强制使用安全序列
logger.info("开始全轴回零")
try:
for axis in sequence:
if not self.home_axis(axis):
logger.error(f"全轴回零失败,停止在{axis.name}")
return False
# 轴间等待时间
time.sleep(0.5)
# 标记为已回零
self.coordinate_origin.is_homed = True
self._save_coordinate_origin()
logger.info("全轴回零完成")
return True
except Exception as e:
logger.error(f"全轴回零异常: {e}")
return False
def set_work_origin_here(self) -> bool:
"""将当前位置设置为工作原点"""
if not self.is_connected:
logger.error("设备未连接,无法设置工作原点")
return False
try:
if not self.coordinate_origin.is_homed:
logger.warning("建议先执行回零操作再设置工作原点")
# 获取当前各轴位置
positions = self.get_all_positions()
for axis in MotorAxis:
axis_name = axis.name.lower()
current_steps = positions[axis].steps
self.coordinate_origin.work_origin_steps[axis_name] = current_steps
logger.info(f"{axis.name}轴工作原点设置为: {current_steps}"
f"({self.steps_to_mm(axis, current_steps):.2f}mm)")
self._save_coordinate_origin()
logger.info("工作原点设置完成")
return True
except Exception as e:
logger.error(f"设置工作原点失败: {e}")
return False
# ==================== 高级运动控制方法 ====================
def move_to_work_coord_safe(self, x: float = None, y: float = None, z: float = None,
speed: int = None, acceleration: int = None) -> bool:
"""
安全移动到工作坐标系指定位置 (液体处理工作站专用)
移动策略Z轴先上升到安全高度 -> XY轴移动到目标位置 -> Z轴下降到目标位置
Args:
x, y, z: 工作坐标系下的目标位置 (mm)
speed: 运动速度 (rpm)
acceleration: 加速度 (rpm/s)
Returns:
bool: 移动是否成功
"""
if not self.is_connected:
logger.error("设备未连接,无法执行移动操作")
return False
try:
# 检查坐标系是否已设置
if not self.coordinate_origin.work_origin_steps:
raise CoordinateSystemError("工作原点未设置请先调用set_work_origin_here()")
# 检查行程限制
self.check_travel_limits(x, y, z)
# 设置运动参数
speed = speed or self.machine_config.default_speed
acceleration = acceleration or self.machine_config.default_acceleration
# 步骤1: Z轴先上升到安全高度
if z is not None:
safe_z_steps = self.work_to_machine_steps(None, None, self.machine_config.safe_z_height)
if not self.move_to_position(MotorAxis.Z, safe_z_steps['z'], speed, acceleration):
logger.error("Z轴上升到安全高度失败")
return False
logger.info(f"Z轴上升到安全高度: {self.machine_config.safe_z_height} mm")
# 等待Z轴移动完成
self.wait_for_completion(MotorAxis.Z, 10.0)
# 步骤2: XY轴移动到目标位置
xy_success = True
if x is not None:
machine_steps = self.work_to_machine_steps(x, None, None)
if not self.move_to_position(MotorAxis.X, machine_steps['x'], speed, acceleration):
xy_success = False
if y is not None:
machine_steps = self.work_to_machine_steps(None, y, None)
if not self.move_to_position(MotorAxis.Y, machine_steps['y'], speed, acceleration):
xy_success = False
if not xy_success:
logger.error("XY轴移动失败")
return False
if x is not None or y is not None:
logger.info(f"XY轴移动到目标位置: X:{x} Y:{y} mm")
# 等待XY轴移动完成
if x is not None:
self.wait_for_completion(MotorAxis.X, 10.0)
if y is not None:
self.wait_for_completion(MotorAxis.Y, 10.0)
# 步骤3: Z轴下降到目标位置
if z is not None:
machine_steps = self.work_to_machine_steps(None, None, z)
if not self.move_to_position(MotorAxis.Z, machine_steps['z'], speed, acceleration):
logger.error("Z轴下降到目标位置失败")
return False
logger.info(f"Z轴下降到目标位置: {z} mm")
self.wait_for_completion(MotorAxis.Z, 10.0)
logger.info(f"安全移动到工作坐标 X:{x} Y:{y} Z:{z} (mm) 完成")
return True
except Exception as e:
logger.error(f"安全移动失败: {e}")
return False
def move_to_work_coord(self, x: float = None, y: float = None, z: float = None,
speed: int = None, acceleration: int = None) -> bool:
"""
移动到工作坐标 (已禁用)
此方法已被禁用,请使用 move_to_work_coord_safe() 方法。
Raises:
RuntimeError: 方法已禁用
"""
error_msg = "Method disabled, use move_to_work_coord_safe instead"
logger.error(error_msg)
raise RuntimeError(error_msg)
def move_relative_work_coord(self, dx: float = 0, dy: float = 0, dz: float = 0,
speed: int = None, acceleration: int = None) -> bool:
"""
相对当前位置移动
Args:
dx, dy, dz: 相对移动距离 (mm)
speed: 运动速度 (rpm)
acceleration: 加速度 (rpm/s)
Returns:
bool: 移动是否成功
"""
if not self.is_connected:
logger.error("设备未连接,无法执行移动操作")
return False
try:
# 获取当前工作坐标
current_work = self.get_current_work_coords()
# 计算目标坐标
target_x = current_work['x'] + dx if dx != 0 else None
target_y = current_work['y'] + dy if dy != 0 else None
target_z = current_work['z'] + dz if dz != 0 else None
return self.move_to_work_coord_safe(target_x, target_y, target_z, speed, acceleration)
except Exception as e:
logger.error(f"相对移动失败: {e}")
return False
def get_current_work_coords(self) -> Dict[str, float]:
"""获取当前工作坐标"""
if not self.is_connected:
logger.error("设备未连接,无法获取当前坐标")
return {'x': 0.0, 'y': 0.0, 'z': 0.0}
try:
# 获取当前机械坐标
positions = self.get_all_positions()
machine_steps = {axis.name.lower(): pos.steps for axis, pos in positions.items()}
# 转换为工作坐标
return self.machine_to_work_coords(machine_steps)
except Exception as e:
logger.error(f"获取工作坐标失败: {e}")
return {'x': 0.0, 'y': 0.0, 'z': 0.0}
def get_current_position_mm(self) -> Dict[str, float]:
"""获取当前位置坐标(毫米单位)"""
return self.get_current_work_coords()
def wait_for_move_completion(self, timeout: float = 30.0) -> bool:
"""等待所有轴运动完成"""
if not self.is_connected:
return False
for axis in MotorAxis:
if not self.wait_for_completion(axis, timeout):
return False
return True
# ==================== 系统状态和配置方法 ====================
def get_system_status(self) -> Dict:
"""获取系统状态信息"""
status = {
"connection": {
"is_connected": self.is_connected,
"port": self.port,
"baudrate": self.baudrate
},
"coordinate_system": {
"is_homed": self.coordinate_origin.is_homed,
"machine_origin": self.coordinate_origin.machine_origin_steps,
"work_origin": self.coordinate_origin.work_origin_steps,
"timestamp": self.coordinate_origin.timestamp
},
"machine_config": asdict(self.machine_config),
"current_position": {}
}
if self.is_connected:
try:
# 获取当前位置
positions = self.get_all_positions()
for axis, pos in positions.items():
axis_name = axis.name.lower()
status["current_position"][axis_name] = {
"steps": pos.steps,
"mm": self.steps_to_mm(axis, pos.steps),
"status": pos.status.name if hasattr(pos.status, 'name') else str(pos.status)
}
# 获取工作坐标
work_coords = self.get_current_work_coords()
status["current_work_coords"] = work_coords
except Exception as e:
status["position_error"] = str(e)
return status
def update_machine_config(self, **kwargs):
"""更新机械配置参数"""
for key, value in kwargs.items():
if hasattr(self.machine_config, key):
setattr(self.machine_config, key, value)
logger.info(f"更新配置参数 {key}: {value}")
else:
logger.warning(f"未知配置参数: {key}")
# 保存配置
self._save_config()
def reset_coordinate_system(self):
"""重置坐标系统"""
self.coordinate_origin = CoordinateOrigin()
self._save_coordinate_origin()
logger.info("坐标系统已重置")
def __enter__(self):
"""上下文管理器入口"""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""上下文管理器出口"""
self.disconnect_device()
def interactive_control(controller: XYZController):
"""
交互式控制模式
Args:
controller: 已连接的控制器实例
"""
print("\n" + "="*60)
print("进入交互式控制模式")
print("="*60)
# 显示当前状态
def show_status():
try:
current_pos = controller.get_current_position_mm()
print(f"\n当前位置: X={current_pos['x']:.2f}mm, Y={current_pos['y']:.2f}mm, Z={current_pos['z']:.2f}mm")
except Exception as e:
print(f"获取位置失败: {e}")
# 显示帮助信息
def show_help():
print("\n可用命令:")
print(" move <轴> <距离> - 相对移动,例: move x 10.5")
print(" goto <x> <y> <z> - 绝对移动到指定坐标,例: goto 10 20 5")
print(" home [轴] - 回零操作,例: home 或 home x")
print(" origin - 设置当前位置为工作原点")
print(" status - 显示当前状态")
print(" speed <速度> - 设置运动速度(rpm),例: speed 2000")
print(" limits - 显示行程限制")
print(" config - 显示机械配置")
print(" help - 显示此帮助信息")
print(" quit/exit - 退出交互模式")
print("\n提示:")
print(" - 轴名称: x, y, z")
print(" - 距离单位: 毫米(mm)")
print(" - 正数向正方向移动,负数向负方向移动")
# 安全回零操作
def safe_homing():
print("\n系统安全初始化...")
print("为确保操作安全,系统将执行回零操作")
print("提示: 已安装限位开关,超时后将假设回零成功")
# 询问用户是否继续
while True:
user_choice = input("是否继续执行回零操作? (y/n/skip): ").strip().lower()
if user_choice in ['y', 'yes', '']:
print("\n开始执行全轴回零...")
print("回零过程可能需要一些时间,请耐心等待...")
# 执行回零操作
homing_success = controller.home_all_axes()
if homing_success:
print("回零操作完成,系统已就绪")
# 设置当前位置为工作原点
if controller.set_work_origin_here():
print("工作原点已设置为回零位置")
else:
print("工作原点设置失败,但可以继续操作")
return True
else:
print("回零操作失败")
print("这可能是由于通信问题,但限位开关应该已经起作用")
# 询问是否继续
retry_choice = input("是否仍要继续操作? (y/n): ").strip().lower()
if retry_choice in ['y', 'yes', '']:
print("继续操作,请手动确认设备位置安全")
return True
else:
return False
elif user_choice in ['n', 'no', '']:
print("用户取消回零操作,退出交互模式")
return False
elif user_choice in ['skip', 's', '跳过']:
print("跳过回零操作,请注意安全!")
print("建议在开始操作前手动执行 'home' 命令")
return True
else:
print("请输入 y(继续)/n(取消)/skip(跳过)")
# 安全回原点操作
def safe_return_home():
print("\n系统安全关闭...")
print("正在将所有轴移动到安全位置...")
try:
# 移动到工作原点 (0,0,0) - 使用安全移动方法
if controller.move_to_work_coord_safe(0, 0, 0, speed=500):
print("已安全返回工作原点")
show_status()
else:
print("返回原点失败,请手动检查设备位置")
except Exception as e:
print(f"返回原点时出错: {e}")
# 当前运动速度
current_speed = controller.machine_config.default_speed
try:
# 1. 首先执行安全回零
if not safe_homing():
return
# 2. 显示初始状态和帮助
show_status()
show_help()
while True:
try:
# 获取用户输入
user_input = input("\n请输入命令 (输入 help 查看帮助): ").strip().lower()
if not user_input:
continue
# 解析命令
parts = user_input.split()
command = parts[0]
if command in ['quit', 'exit', 'q']:
print("准备退出交互模式...")
# 执行安全回原点操作
safe_return_home()
print("退出交互模式")
break
elif command == 'help' or command == 'h':
show_help()
elif command == 'status' or command == 's':
show_status()
print(f"当前速度: {current_speed} rpm")
print(f"是否已回零: {controller.coordinate_origin.is_homed}")
elif command == 'move' or command == 'm':
if len(parts) != 3:
print("格式错误,正确格式: move <轴> <距离>")
print(" 例如: move x 10.5")
continue
axis = parts[1].lower()
try:
distance = float(parts[2])
except ValueError:
print("距离必须是数字")
continue
if axis not in ['x', 'y', 'z']:
print("轴名称必须是 x, y 或 z")
continue
print(f"{axis.upper()}轴移动 {distance:+.2f}mm...")
# 执行移动
kwargs = {f'd{axis}': distance, 'speed': current_speed}
if controller.move_relative_work_coord(**kwargs):
print(f"{axis.upper()}轴移动完成")
show_status()
else:
print(f"{axis.upper()}轴移动失败")
elif command == 'goto' or command == 'g':
if len(parts) != 4:
print("格式错误,正确格式: goto <x> <y> <z>")
print(" 例如: goto 10 20 5")
continue
try:
x = float(parts[1])
y = float(parts[2])
z = float(parts[3])
except ValueError:
print("坐标必须是数字")
continue
print(f"移动到坐标 ({x}, {y}, {z})...")
print("使用安全移动策略: Z轴先上升 → XY移动 → Z轴下降")
if controller.move_to_work_coord_safe(x, y, z, speed=current_speed):
print("安全移动到目标位置完成")
show_status()
else:
print("移动失败")
elif command == 'home':
if len(parts) == 1:
# 全轴回零
print("开始全轴回零...")
if controller.home_all_axes():
print("全轴回零完成")
show_status()
else:
print("回零失败")
elif len(parts) == 2:
# 单轴回零
axis_name = parts[1].lower()
if axis_name not in ['x', 'y', 'z']:
print("轴名称必须是 x, y 或 z")
continue
axis = MotorAxis[axis_name.upper()]
print(f"{axis_name.upper()}轴回零...")
if controller.home_axis(axis):
print(f"{axis_name.upper()}轴回零完成")
show_status()
else:
print(f"{axis_name.upper()}轴回零失败")
else:
print("格式错误,正确格式: home 或 home <轴>")
elif command == 'origin' or command == 'o':
print("设置当前位置为工作原点...")
if controller.set_work_origin_here():
print("工作原点设置完成")
show_status()
else:
print("工作原点设置失败")
elif command == 'speed':
if len(parts) != 2:
print("格式错误,正确格式: speed <速度>")
print(" 例如: speed 2000")
continue
try:
new_speed = int(parts[1])
if new_speed <= 0:
print("速度必须大于0")
continue
if new_speed > 10000:
print("速度不能超过10000 rpm")
continue
current_speed = new_speed
print(f"运动速度设置为: {current_speed} rpm")
except ValueError:
print("速度必须是整数")
elif command == 'limits' or command == 'l':
config = controller.machine_config
print("\n行程限制:")
print(f" X轴: 0 ~ {config.max_travel_x} mm")
print(f" Y轴: 0 ~ {config.max_travel_y} mm")
print(f" Z轴: 0 ~ {config.max_travel_z} mm")
elif command == 'config' or command == 'c':
config = controller.machine_config
print("\n机械配置:")
print(f" X轴步距: {config.steps_per_mm_x:.1f} 步/mm")
print(f" Y轴步距: {config.steps_per_mm_y:.1f} 步/mm")
print(f" Z轴步距: {config.steps_per_mm_z:.1f} 步/mm")
print(f" 回零速度: {config.homing_speed} rpm")
print(f" 默认速度: {config.default_speed} rpm")
print(f" 安全间隙: {config.safe_clearance} mm")
else:
print(f"未知命令: {command}")
print("输入 help 查看可用命令")
except KeyboardInterrupt:
print("\n\n用户中断,退出交互模式")
break
except Exception as e:
print(f"命令执行错误: {e}")
print("输入 help 查看正确的命令格式")
finally:
# 确保正确断开连接
try:
controller.disconnect_device()
print("设备连接已断开")
except Exception as e:
print(f"断开连接时出错: {e}")
def run_tests():
"""运行测试函数"""
print("=== XYZ控制器测试 ===")
# 1. 测试机械配置
print("\n1. 测试机械配置")
config = MachineConfig(
steps_per_mm_x=204.8, # 16384步/圈 ÷ 80mm导程
steps_per_mm_y=204.8, # 16384步/圈 ÷ 80mm导程
steps_per_mm_z=3276.8, # 16384步/圈 ÷ 5mm导程
max_travel_x=340.0,
max_travel_y=250.0,
max_travel_z=160.0,
homing_speed=100,
default_speed=100
)
print(f"X轴步距: {config.steps_per_mm_x} 步/mm")
print(f"Y轴步距: {config.steps_per_mm_y} 步/mm")
print(f"Z轴步距: {config.steps_per_mm_z} 步/mm")
print(f"行程限制: X={config.max_travel_x}mm, Y={config.max_travel_y}mm, Z={config.max_travel_z}mm")
# 2. 测试坐标原点数据结构
print("\n2. 测试坐标原点数据结构")
origin = CoordinateOrigin()
print(f"初始状态: 已回零={origin.is_homed}")
print(f"机械原点: {origin.machine_origin_steps}")
print(f"工作原点: {origin.work_origin_steps}")
# 设置示例数据
origin.machine_origin_steps = {'x': 0, 'y': 0, 'z': 0}
origin.work_origin_steps = {'x': 16384, 'y': 16384, 'z': 13107} # 5mm, 5mm, 2mm (基于16384步/圈)
origin.is_homed = True
origin.timestamp = "2024-09-26 12:00:00"
print(f"设置后: 已回零={origin.is_homed}")
print(f"机械原点: {origin.machine_origin_steps}")
print(f"工作原点: {origin.work_origin_steps}")
# 3. 测试离线功能
print("\n3. 测试离线功能")
# 创建离线控制器(不自动连接)
offline_controller = XYZController(
port='/dev/tty.usbserial-3130',
machine_config=config,
auto_connect=False
)
# 测试单位转换
print("\n单位转换测试:")
test_distances = [1.0, 5.0, 10.0, 25.5]
for distance in test_distances:
x_steps = offline_controller.mm_to_steps(MotorAxis.X, distance)
y_steps = offline_controller.mm_to_steps(MotorAxis.Y, distance)
z_steps = offline_controller.mm_to_steps(MotorAxis.Z, distance)
print(f"{distance}mm -> X:{x_steps}步, Y:{y_steps}步, Z:{z_steps}")
# 反向转换验证
x_mm = offline_controller.steps_to_mm(MotorAxis.X, x_steps)
y_mm = offline_controller.steps_to_mm(MotorAxis.Y, y_steps)
z_mm = offline_controller.steps_to_mm(MotorAxis.Z, z_steps)
print(f"反向转换: X:{x_mm:.2f}mm, Y:{y_mm:.2f}mm, Z:{z_mm:.2f}mm")
# 测试坐标系转换
print("\n坐标系转换测试:")
offline_controller.coordinate_origin = origin # 使用示例原点
work_coords = [(0, 0, 0), (10, 15, 5), (50, 30, 20)]
for x, y, z in work_coords:
try:
machine_steps = offline_controller.work_to_machine_steps(x, y, z)
print(f"工作坐标 ({x}, {y}, {z}) -> 机械步数 {machine_steps}")
# 反向转换验证
work_coords_back = offline_controller.machine_to_work_coords(machine_steps)
print(f"反向转换: ({work_coords_back['x']:.2f}, {work_coords_back['y']:.2f}, {work_coords_back['z']:.2f})")
except Exception as e:
print(f"转换失败: {e}")
# 测试行程限制检查
print("\n行程限制检查测试:")
test_positions = [
(50, 50, 25, "正常位置"),
(250, 50, 25, "X轴超限"),
(50, 350, 25, "Y轴超限"),
(50, 50, 150, "Z轴超限"),
(-10, 50, 25, "X轴负超限"),
(50, -10, 25, "Y轴负超限"),
(50, 50, -5, "Z轴负超限")
]
for x, y, z, desc in test_positions:
try:
offline_controller.check_travel_limits(x, y, z)
print(f"{desc} ({x}, {y}, {z}): 有效")
except CoordinateSystemError as e:
print(f"{desc} ({x}, {y}, {z}): 超限 - {e}")
print("\n=== 离线功能测试完成 ===")
# 4. 硬件连接测试
print("\n4. 硬件连接测试")
print("尝试连接真实设备...")
# 可能的串口列表
possible_ports = [
'/dev/ttyCH341USB0' # CH340 USB串口转换器
]
connected_controller = None
for port in possible_ports:
try:
print(f"尝试连接端口: {port}")
controller = XYZController(
port=port,
machine_config=config,
auto_connect=True
)
if controller.is_connected:
print(f"成功连接到 {port}")
connected_controller = controller
# 获取系统状态
status = controller.get_system_status()
print("\n系统状态:")
print(f" 连接状态: {status['connection']['is_connected']}")
print(f" 是否已回零: {status['coordinate_system']['is_homed']}")
if 'current_position' in status:
print(" 当前位置:")
for axis, pos_info in status['current_position'].items():
print(f" {axis.upper()}轴: {pos_info['steps']}步 ({pos_info['mm']:.2f}mm)")
# 测试基本移动功能
print("\n测试基本移动功能:")
try:
# 获取当前位置
current_pos = controller.get_current_position_mm()
print(f"当前工作坐标: {current_pos}")
# 小幅移动测试
print("执行小幅移动测试 (X+1mm)...")
if controller.move_relative_work_coord(dx=1.0, speed=500):
print("移动成功")
time.sleep(1)
new_pos = controller.get_current_position_mm()
print(f"移动后坐标: {new_pos}")
else:
print("移动失败")
except Exception as e:
print(f"移动测试失败: {e}")
break
except Exception as e:
print(f"连接 {port} 失败: {e}")
continue
if not connected_controller:
print("未找到可用的设备端口")
print("请检查:")
print(" 1. 设备是否正确连接")
print(" 2. 串口端口是否正确")
print(" 3. 设备驱动是否安装")
else:
# 进入交互式控制模式
interactive_control(connected_controller)
print("\n=== XYZ控制器测试完成 ===")
# ==================== 测试和示例代码 ====================
if __name__ == "__main__":
run_tests()