diff --git a/.conda/recipe.yaml b/.conda/recipe.yaml index 62b0633..2b041c8 100644 --- a/.conda/recipe.yaml +++ b/.conda/recipe.yaml @@ -1,6 +1,6 @@ package: name: unilabos - version: 0.10.12 + version: 0.10.15 source: path: ../unilabos @@ -9,7 +9,7 @@ source: build: python: entry_points: - - unilab = unilabos.app.main:main + - unilab = unilabos.app.main:main script: - set PIP_NO_INDEX= - if: win @@ -25,7 +25,6 @@ build: - cp $RECIPE_DIR/../setup.py $SRC_DIR - $PYTHON -m pip install $SRC_DIR - requirements: host: - python ==3.11.11 @@ -87,6 +86,6 @@ requirements: - uni-lab::ros-humble-unilabos-msgs about: - repository: https://github.com/dptech-corp/Uni-Lab-OS + repository: https://github.com/deepmodeling/Uni-Lab-OS license: GPL-3.0-only description: "Uni-Lab-OS" diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 0000000..7b0d4f9 --- /dev/null +++ b/.cursorignore @@ -0,0 +1,26 @@ +.conda +# .github +.idea +# .vscode +output +pylabrobot_repo +recipes +scripts +service +temp +# unilabos/test +# unilabos/app/web +unilabos/device_mesh +unilabos_data +unilabos_msgs +unilabos.egg-info +CONTRIBUTORS +# LICENSE +MANIFEST.in +pyrightconfig.json +# README.md +# README_zh.md +setup.py +setup.cfg +.gitattrubutes +**/__pycache__ diff --git a/.github/workflows/conda-pack-build.yml b/.github/workflows/conda-pack-build.yml index 7e278a9..3a379fa 100644 --- a/.github/workflows/conda-pack-build.yml +++ b/.github/workflows/conda-pack-build.yml @@ -24,7 +24,7 @@ jobs: platform: linux-64 env_file: unilabos-linux-64.yaml script_ext: sh - - os: macos-13 # Intel + - os: macos-15 # Intel (via Rosetta) platform: osx-64 env_file: unilabos-osx-64.yaml script_ext: sh diff --git a/.github/workflows/multi-platform-build.yml b/.github/workflows/multi-platform-build.yml index a8785da..bcba6db 100644 --- a/.github/workflows/multi-platform-build.yml +++ b/.github/workflows/multi-platform-build.yml @@ -27,7 +27,7 @@ jobs: - os: ubuntu-latest platform: linux-64 env_file: unilabos-linux-64.yaml - - os: macos-13 # Intel + - os: macos-15 # Intel (via Rosetta) platform: osx-64 env_file: unilabos-osx-64.yaml - os: macos-latest # ARM64 diff --git a/.github/workflows/unilabos-conda-build.yml b/.github/workflows/unilabos-conda-build.yml index 65f6fba..214f9bf 100644 --- a/.github/workflows/unilabos-conda-build.yml +++ b/.github/workflows/unilabos-conda-build.yml @@ -26,7 +26,7 @@ jobs: include: - os: ubuntu-latest platform: linux-64 - - os: macos-13 # Intel + - os: macos-15 # Intel (via Rosetta) platform: osx-64 - os: macos-latest # ARM64 platform: osx-arm64 diff --git a/.gitignore b/.gitignore index 71d8d0d..610be61 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +cursor_docs/ configs/ temp/ output/ diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..7198cb9 --- /dev/null +++ b/NOTICE @@ -0,0 +1,17 @@ +# Uni-Lab-OS Licensing Notice + +This project uses a dual licensing structure: + +## 1. Main Framework - GPL-3.0 + +- unilabos/ (except unilabos/devices/) +- docs/ +- tests/ + +See [LICENSE](LICENSE) for details. + +## 2. Device Drivers - DP Technology Proprietary License + +- unilabos/devices/ + +See [unilabos/devices/LICENSE](unilabos/devices/LICENSE) for details. diff --git a/README.md b/README.md index 2e0288f..f10cc0f 100644 --- a/README.md +++ b/README.md @@ -8,17 +8,13 @@ **English** | [中文](README_zh.md) -[![GitHub Stars](https://img.shields.io/github/stars/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/stargazers) -[![GitHub Forks](https://img.shields.io/github/forks/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/network/members) -[![GitHub Issues](https://img.shields.io/github/issues/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/issues) -[![GitHub License](https://img.shields.io/github/license/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/blob/main/LICENSE) +[![GitHub Stars](https://img.shields.io/github/stars/dptech-corp/Uni-Lab-OS.svg)](https://github.com/deepmodeling/Uni-Lab-OS/stargazers) +[![GitHub Forks](https://img.shields.io/github/forks/dptech-corp/Uni-Lab-OS.svg)](https://github.com/deepmodeling/Uni-Lab-OS/network/members) +[![GitHub Issues](https://img.shields.io/github/issues/dptech-corp/Uni-Lab-OS.svg)](https://github.com/deepmodeling/Uni-Lab-OS/issues) +[![GitHub License](https://img.shields.io/github/license/dptech-corp/Uni-Lab-OS.svg)](https://github.com/deepmodeling/Uni-Lab-OS/blob/main/LICENSE) Uni-Lab-OS is a platform for laboratory automation, designed to connect and control various experimental equipment, enabling automation and standardization of experimental workflows. -## 🏆 Competition - -Join the [Intelligent Organic Chemistry Synthesis Competition](https://bohrium.dp.tech/competitions/1451645258) to explore automated synthesis with Uni-Lab-OS! - ## Key Features - Multi-device integration management @@ -31,11 +27,13 @@ Join the [Intelligent Organic Chemistry Synthesis Competition](https://bohrium.d Detailed documentation can be found at: -- [Online Documentation](https://xuwznln.github.io/Uni-Lab-OS-Doc/) +- [Online Documentation](https://deepmodeling.github.io/Uni-Lab-OS/) ## Quick Start -Uni-Lab-OS recommends using `mamba` for environment management. Choose the appropriate environment file for your operating system: +1. Setup Conda Environment + +Uni-Lab-OS recommends using `mamba` for environment management: ```bash # Create new environment @@ -44,28 +42,54 @@ mamba activate unilab mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge ``` -## Install Dev Uni-Lab-OS +2. Install Dev Uni-Lab-OS ```bash # Clone the repository -git clone https://github.com/dptech-corp/Uni-Lab-OS.git +git clone https://github.com/deepmodeling/Uni-Lab-OS.git cd Uni-Lab-OS # Install Uni-Lab-OS pip install . ``` -3. Start Uni-Lab System: +3. Start Uni-Lab System -Please refer to [Documentation - Boot Examples](https://xuwznln.github.io/Uni-Lab-OS-Doc/boot_examples/index.html) +Please refer to [Documentation - Boot Examples](https://deepmodeling.github.io/Uni-Lab-OS/boot_examples/index.html) + +4. Best Practice + +See [Best Practice Guide](https://deepmodeling.github.io/Uni-Lab-OS/user_guide/best_practice.html) ## Message Format -Uni-Lab-OS uses pre-built `unilabos_msgs` for system communication. You can find the built versions on the [GitHub Releases](https://github.com/dptech-corp/Uni-Lab-OS/releases) page. +Uni-Lab-OS uses pre-built `unilabos_msgs` for system communication. You can find the built versions on the [GitHub Releases](https://github.com/deepmodeling/Uni-Lab-OS/releases) page. + +## Citation + +If you use [Uni-Lab-OS](https://arxiv.org/abs/2512.21766) in academic research, please cite: + +```bibtex +@article{gao2025unilabos, + title = {UniLabOS: An AI-Native Operating System for Autonomous Laboratories}, + doi = {10.48550/arXiv.2512.21766}, + publisher = {arXiv}, + author = {Gao, Jing and Chang, Junhan and Que, Haohui and Xiong, Yanfei and + Zhang, Shixiang and Qi, Xianwei and Liu, Zhen and Wang, Jun-Jie and + Ding, Qianjun and Li, Xinyu and Pan, Ziwei and Xie, Qiming and + Yan, Zhuang and Yan, Junchi and Zhang, Linfeng}, + year = {2025} +} +``` ## License -This project is licensed under GPL-3.0 - see the [LICENSE](LICENSE) file for details. +This project uses a dual licensing structure: + +- **Main Framework**: GPL-3.0 - see [LICENSE](LICENSE) +- **Device Drivers** (`unilabos/devices/`): DP Technology Proprietary License + +See [NOTICE](NOTICE) for complete licensing details. ## Project Statistics @@ -77,4 +101,4 @@ This project is licensed under GPL-3.0 - see the [LICENSE](LICENSE) file for det ## Contact Us -- GitHub Issues: [https://github.com/dptech-corp/Uni-Lab-OS/issues](https://github.com/dptech-corp/Uni-Lab-OS/issues) +- GitHub Issues: [https://github.com/deepmodeling/Uni-Lab-OS/issues](https://github.com/deepmodeling/Uni-Lab-OS/issues) diff --git a/README_zh.md b/README_zh.md index 76976eb..c4dba7d 100644 --- a/README_zh.md +++ b/README_zh.md @@ -8,17 +8,13 @@ [English](README.md) | **中文** -[![GitHub Stars](https://img.shields.io/github/stars/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/stargazers) -[![GitHub Forks](https://img.shields.io/github/forks/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/network/members) -[![GitHub Issues](https://img.shields.io/github/issues/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/issues) -[![GitHub License](https://img.shields.io/github/license/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/blob/main/LICENSE) +[![GitHub Stars](https://img.shields.io/github/stars/dptech-corp/Uni-Lab-OS.svg)](https://github.com/deepmodeling/Uni-Lab-OS/stargazers) +[![GitHub Forks](https://img.shields.io/github/forks/dptech-corp/Uni-Lab-OS.svg)](https://github.com/deepmodeling/Uni-Lab-OS/network/members) +[![GitHub Issues](https://img.shields.io/github/issues/dptech-corp/Uni-Lab-OS.svg)](https://github.com/deepmodeling/Uni-Lab-OS/issues) +[![GitHub License](https://img.shields.io/github/license/dptech-corp/Uni-Lab-OS.svg)](https://github.com/deepmodeling/Uni-Lab-OS/blob/main/LICENSE) Uni-Lab-OS 是一个用于实验室自动化的综合平台,旨在连接和控制各种实验设备,实现实验流程的自动化和标准化。 -## 🏆 比赛 - -欢迎参加[有机化学合成智能实验大赛](https://bohrium.dp.tech/competitions/1451645258),使用 Uni-Lab-OS 探索自动化合成! - ## 核心特点 - 多设备集成管理 @@ -31,7 +27,7 @@ Uni-Lab-OS 是一个用于实验室自动化的综合平台,旨在连接和控 详细文档可在以下位置找到: -- [在线文档](https://xuwznln.github.io/Uni-Lab-OS-Doc/) +- [在线文档](https://deepmodeling.github.io/Uni-Lab-OS/) ## 快速开始 @@ -50,24 +46,50 @@ mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge ```bash # 克隆仓库 -git clone https://github.com/dptech-corp/Uni-Lab-OS.git +git clone https://github.com/deepmodeling/Uni-Lab-OS.git cd Uni-Lab-OS # 安装 Uni-Lab-OS pip install . ``` -3. 启动 Uni-Lab 系统: +3. 启动 Uni-Lab 系统 -请见[文档-启动样例](https://xuwznln.github.io/Uni-Lab-OS-Doc/boot_examples/index.html) +请见[文档-启动样例](https://deepmodeling.github.io/Uni-Lab-OS/boot_examples/index.html) + +4. 最佳实践 + +请见[最佳实践指南](https://deepmodeling.github.io/Uni-Lab-OS/user_guide/best_practice.html) ## 消息格式 -Uni-Lab-OS 使用预构建的 `unilabos_msgs` 进行系统通信。您可以在 [GitHub Releases](https://github.com/dptech-corp/Uni-Lab-OS/releases) 页面找到已构建的版本。 +Uni-Lab-OS 使用预构建的 `unilabos_msgs` 进行系统通信。您可以在 [GitHub Releases](https://github.com/deepmodeling/Uni-Lab-OS/releases) 页面找到已构建的版本。 + +## 引用 + +如果您在学术研究中使用 [Uni-Lab-OS](https://arxiv.org/abs/2512.21766),请引用: + +```bibtex +@article{gao2025unilabos, + title = {UniLabOS: An AI-Native Operating System for Autonomous Laboratories}, + doi = {10.48550/arXiv.2512.21766}, + publisher = {arXiv}, + author = {Gao, Jing and Chang, Junhan and Que, Haohui and Xiong, Yanfei and + Zhang, Shixiang and Qi, Xianwei and Liu, Zhen and Wang, Jun-Jie and + Ding, Qianjun and Li, Xinyu and Pan, Ziwei and Xie, Qiming and + Yan, Zhuang and Yan, Junchi and Zhang, Linfeng}, + year = {2025} +} +``` ## 许可证 -此项目采用 GPL-3.0 许可 - 详情请参阅 [LICENSE](LICENSE) 文件。 +本项目采用双许可证结构: + +- **主框架**:GPL-3.0 - 详见 [LICENSE](LICENSE) +- **设备驱动** (`unilabos/devices/`):深势科技专有许可证 + +完整许可证说明请参阅 [NOTICE](NOTICE)。 ## 项目统计 @@ -79,4 +101,4 @@ Uni-Lab-OS 使用预构建的 `unilabos_msgs` 进行系统通信。您可以在 ## 联系我们 -- GitHub Issues: [https://github.com/dptech-corp/Uni-Lab-OS/issues](https://github.com/dptech-corp/Uni-Lab-OS/issues) +- GitHub Issues: [https://github.com/deepmodeling/Uni-Lab-OS/issues](https://github.com/deepmodeling/Uni-Lab-OS/issues) diff --git a/docs/conf.py b/docs/conf.py index f15f0e6..60a22b6 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -24,7 +24,7 @@ extensions = [ "sphinx.ext.autodoc", "sphinx.ext.napoleon", # 如果您使用 Google 或 NumPy 风格的 docstrings "sphinx_rtd_theme", - "sphinxcontrib.mermaid" + "sphinxcontrib.mermaid", ] source_suffix = { @@ -58,7 +58,7 @@ html_theme = "sphinx_rtd_theme" # sphinx-book-theme 主题选项 html_theme_options = { - "repository_url": "https://github.com/用户名/Uni-Lab", + "repository_url": "https://github.com/deepmodeling/Uni-Lab-OS", "use_repository_button": True, "use_issues_button": True, "use_edit_page_button": True, diff --git a/docs/developer_guide/action_includes.md b/docs/developer_guide/action_includes.md index 206f94e..be1316c 100644 --- a/docs/developer_guide/action_includes.md +++ b/docs/developer_guide/action_includes.md @@ -1,4 +1,5 @@ -## 基础通用操作 +## 简单单变量动作函数 + ### `SendCmd` @@ -6,343 +7,49 @@ :language: yaml ``` ---- +---- +## 常量有机化学操作 -### `FloatSingleInput` +Uni-Lab 常量有机化学指令集多数来自 [XDL](https://croningroup.gitlab.io/chemputer/xdl/standard/full_steps_specification.html#),包含有机合成实验中常见的操作,如加热、搅拌、冷却等。 -```{literalinclude} ../../unilabos_msgs/action/FloatSingleInput.action -:language: yaml -``` ---- -### `IntSingleInput` - -```{literalinclude} ../../unilabos_msgs/action/IntSingleInput.action -:language: yaml -``` - ---- - -### `Point3DSeparateInput` - -```{literalinclude} ../../unilabos_msgs/action/Point3DSeparateInput.action -:language: yaml -``` - ---- - -### `StrSingleInput` - -```{literalinclude} ../../unilabos_msgs/action/StrSingleInput.action -:language: yaml -``` - ---- - -### `Wait` - -```{literalinclude} ../../unilabos_msgs/action/Wait.action -:language: yaml -``` - ---- - -## 化学实验操作 - -Uni-Lab 化学操作指令集多数来自 [XDL](https://croningroup.gitlab.io/chemputer/xdl/standard/full_steps_specification.html#),包含有机合成实验中常见的操作。 - -### 物料添加 - -#### `Add` - -```{literalinclude} ../../unilabos_msgs/action/Add.action -:language: yaml -``` - ---- - -#### `AddSolid` - -```{literalinclude} ../../unilabos_msgs/action/AddSolid.action -:language: yaml -``` - ---- - -### 液体转移与泵控制 - -#### `PumpTransfer` - -```{literalinclude} ../../unilabos_msgs/action/PumpTransfer.action -:language: yaml -``` - ---- - -#### `SetPumpPosition` - -```{literalinclude} ../../unilabos_msgs/action/SetPumpPosition.action -:language: yaml -``` - ---- - -#### `Transfer` - -```{literalinclude} ../../unilabos_msgs/action/Transfer.action -:language: yaml -``` - ---- - -### 温度控制 - -#### `HeatChill` - -```{literalinclude} ../../unilabos_msgs/action/HeatChill.action -:language: yaml -``` - ---- - -#### `HeatChillStart` - -```{literalinclude} ../../unilabos_msgs/action/HeatChillStart.action -:language: yaml -``` - ---- - -#### `HeatChillStop` - -```{literalinclude} ../../unilabos_msgs/action/HeatChillStop.action -:language: yaml -``` - ---- - -### 搅拌控制 - -#### `StartStir` - -```{literalinclude} ../../unilabos_msgs/action/StartStir.action -:language: yaml -``` - ---- - -#### `Stir` - -```{literalinclude} ../../unilabos_msgs/action/Stir.action -:language: yaml -``` - ---- - -#### `StopStir` - -```{literalinclude} ../../unilabos_msgs/action/StopStir.action -:language: yaml -``` - ---- - -### 气体与真空控制 - -#### `EvacuateAndRefill` - -```{literalinclude} ../../unilabos_msgs/action/EvacuateAndRefill.action -:language: yaml -``` - ---- - -#### `Purge` - -```{literalinclude} ../../unilabos_msgs/action/Purge.action -:language: yaml -``` - ---- - -#### `StartPurge` - -```{literalinclude} ../../unilabos_msgs/action/StartPurge.action -:language: yaml -``` - ---- - -#### `StopPurge` - -```{literalinclude} ../../unilabos_msgs/action/StopPurge.action -:language: yaml -``` - ---- - -### 分离与过滤 - -#### `Centrifuge` - -```{literalinclude} ../../unilabos_msgs/action/Centrifuge.action -:language: yaml -``` - ---- - -#### `Filter` - -```{literalinclude} ../../unilabos_msgs/action/Filter.action -:language: yaml -``` - ---- - -#### `FilterThrough` - -```{literalinclude} ../../unilabos_msgs/action/FilterThrough.action -:language: yaml -``` - ---- - -#### `RunColumn` - -```{literalinclude} ../../unilabos_msgs/action/RunColumn.action -:language: yaml -``` - ---- - -#### `Separate` - -```{literalinclude} ../../unilabos_msgs/action/Separate.action -:language: yaml -``` - ---- - -### 化学处理 - -#### `AdjustPH` - -```{literalinclude} ../../unilabos_msgs/action/AdjustPH.action -:language: yaml -``` - ---- - -#### `Crystallize` - -```{literalinclude} ../../unilabos_msgs/action/Crystallize.action -:language: yaml -``` - ---- - -#### `Dissolve` - -```{literalinclude} ../../unilabos_msgs/action/Dissolve.action -:language: yaml -``` - ---- - -#### `Dry` - -```{literalinclude} ../../unilabos_msgs/action/Dry.action -:language: yaml -``` - ---- - -#### `Evaporate` - -```{literalinclude} ../../unilabos_msgs/action/Evaporate.action -:language: yaml -``` - ---- - -#### `Hydrogenate` - -```{literalinclude} ../../unilabos_msgs/action/Hydrogenate.action -:language: yaml -``` - ---- - -#### `Recrystallize` - -```{literalinclude} ../../unilabos_msgs/action/Recrystallize.action -:language: yaml -``` - ---- - -#### `WashSolid` - -```{literalinclude} ../../unilabos_msgs/action/WashSolid.action -:language: yaml -``` - ---- - -### 清洁与维护 - -#### `Clean` +### `Clean` ```{literalinclude} ../../unilabos_msgs/action/Clean.action :language: yaml ``` ---- +---- -#### `CleanVessel` +### `HeatChillStart` -```{literalinclude} ../../unilabos_msgs/action/CleanVessel.action +```{literalinclude} ../../unilabos_msgs/action/HeatChillStart.action :language: yaml ``` ---- +---- -#### `EmptyIn` +### `HeatChillStop` -```{literalinclude} ../../unilabos_msgs/action/EmptyIn.action +```{literalinclude} ../../unilabos_msgs/action/HeatChillStop.action :language: yaml ``` ---- +---- -#### `ResetHandling` +### `PumpTransfer` -```{literalinclude} ../../unilabos_msgs/action/ResetHandling.action +```{literalinclude} ../../unilabos_msgs/action/PumpTransfer.action :language: yaml ``` ---- +---- +## 移液工作站及相关生物自动化设备操作 -## 生物自动化操作 +Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.org/user_guide/index.html),包含生物实验中常见的操作,如移液、混匀、离心等。 -Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.org/user_guide/index.html),包含移液工作站的各类操作。 -### `LiquidHandlerAdd` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerAdd.action -:language: yaml -``` - ---- - -### `LiquidHandlerAspirate` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerAspirate.action -:language: yaml -``` - ---- ### `LiquidHandlerDiscardTips` @@ -350,15 +57,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o :language: yaml ``` ---- - -### `LiquidHandlerDispense` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerDispense.action -:language: yaml -``` - ---- +---- ### `LiquidHandlerDropTips` @@ -366,7 +65,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o :language: yaml ``` ---- +---- ### `LiquidHandlerDropTips96` @@ -374,31 +73,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o :language: yaml ``` ---- - -### `LiquidHandlerIncubateBiomek` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerIncubateBiomek.action -:language: yaml -``` - ---- - -### `LiquidHandlerMix` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerMix.action -:language: yaml -``` - ---- - -### `LiquidHandlerMoveBiomek` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerMoveBiomek.action -:language: yaml -``` - ---- +---- ### `LiquidHandlerMoveLid` @@ -406,7 +81,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o :language: yaml ``` ---- +---- ### `LiquidHandlerMovePlate` @@ -414,7 +89,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o :language: yaml ``` ---- +---- ### `LiquidHandlerMoveResource` @@ -422,23 +97,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o :language: yaml ``` ---- - -### `LiquidHandlerMoveTo` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerMoveTo.action -:language: yaml -``` - ---- - -### `LiquidHandlerOscillateBiomek` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerOscillateBiomek.action -:language: yaml -``` - ---- +---- ### `LiquidHandlerPickUpTips` @@ -446,7 +105,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o :language: yaml ``` ---- +---- ### `LiquidHandlerPickUpTips96` @@ -454,23 +113,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o :language: yaml ``` ---- - -### `LiquidHandlerProtocolCreation` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerProtocolCreation.action -:language: yaml -``` - ---- - -### `LiquidHandlerRemove` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerRemove.action -:language: yaml -``` - ---- +---- ### `LiquidHandlerReturnTips` @@ -478,7 +121,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o :language: yaml ``` ---- +---- ### `LiquidHandlerReturnTips96` @@ -486,31 +129,7 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o :language: yaml ``` ---- - -### `LiquidHandlerSetGroup` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerSetGroup.action -:language: yaml -``` - ---- - -### `LiquidHandlerSetLiquid` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerSetLiquid.action -:language: yaml -``` - ---- - -### `LiquidHandlerSetTipRack` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerSetTipRack.action -:language: yaml -``` - ---- +---- ### `LiquidHandlerStamp` @@ -518,597 +137,22 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o :language: yaml ``` ---- +---- +## 多工作站及小车运行、物料转移 -### `LiquidHandlerTransfer` -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerTransfer.action -:language: yaml -``` - ---- - -### `LiquidHandlerTransferBiomek` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerTransferBiomek.action -:language: yaml -``` - ---- - -### `LiquidHandlerTransferGroup` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerTransferGroup.action -:language: yaml -``` - ---- - -## 专用工作站操作 - -### 反应工作站 - -#### `ReactionStationDripBack` - -```{literalinclude} ../../unilabos_msgs/action/ReactionStationDripBack.action -:language: yaml -``` - ---- - -#### `ReactionStationLiquidFeedBeaker` - -```{literalinclude} ../../unilabos_msgs/action/ReactionStationLiquidFeedBeaker.action -:language: yaml -``` - ---- - -#### `ReactionStationLiquidFeedSolvents` - -```{literalinclude} ../../unilabos_msgs/action/ReactionStationLiquidFeedSolvents.action -:language: yaml -``` - ---- - -#### `ReactionStationLiquidFeedTitration` - -```{literalinclude} ../../unilabos_msgs/action/ReactionStationLiquidFeedTitration.action -:language: yaml -``` - ---- - -#### `ReactionStationLiquidFeedVialsNonTitration` - -```{literalinclude} ../../unilabos_msgs/action/ReactionStationLiquidFeedVialsNonTitration.action -:language: yaml -``` - ---- - -#### `ReactionStationProExecu` - -```{literalinclude} ../../unilabos_msgs/action/ReactionStationProExecu.action -:language: yaml -``` - ---- - -#### `ReactionStationReactorTakenOut` - -```{literalinclude} ../../unilabos_msgs/action/ReactionStationReactorTakenOut.action -:language: yaml -``` - ---- - -#### `ReactionStationReaTackIn` - -```{literalinclude} ../../unilabos_msgs/action/ReactionStationReaTackIn.action -:language: yaml -``` - ---- - -#### `ReactionStationSolidFeedVial` - -```{literalinclude} ../../unilabos_msgs/action/ReactionStationSolidFeedVial.action -:language: yaml -``` - ---- - -### 固体分配站 - -#### `SolidDispenseAddPowderTube` - -```{literalinclude} ../../unilabos_msgs/action/SolidDispenseAddPowderTube.action -:language: yaml -``` - ---- - -### 分液工作站 - -#### `DispenStationSolnPrep` - -```{literalinclude} ../../unilabos_msgs/action/DispenStationSolnPrep.action -:language: yaml -``` - ---- - -#### `DispenStationVialFeed` - -```{literalinclude} ../../unilabos_msgs/action/DispenStationVialFeed.action -:language: yaml -``` - ---- - -### 后处理工作站 - -#### `PostProcessGrab` - -```{literalinclude} ../../unilabos_msgs/action/PostProcessGrab.action -:language: yaml -``` - ---- - -#### `PostProcessTriggerClean` - -```{literalinclude} ../../unilabos_msgs/action/PostProcessTriggerClean.action -:language: yaml -``` - ---- - -#### `PostProcessTriggerPostPro` - -```{literalinclude} ../../unilabos_msgs/action/PostProcessTriggerPostPro.action -:language: yaml -``` - ---- - -## 系统管理与资源调度 - -### 资源与布局管理 - -#### `DefaultLayoutRecommendLayout` - -```{literalinclude} ../../unilabos_msgs/action/DefaultLayoutRecommendLayout.action -:language: yaml -``` - ---- - -#### `ResourceCreateFromOuter` - -```{literalinclude} ../../unilabos_msgs/action/ResourceCreateFromOuter.action -:language: yaml -``` - ---- - -#### `ResourceCreateFromOuterEasy` - -```{literalinclude} ../../unilabos_msgs/action/ResourceCreateFromOuterEasy.action -:language: yaml -``` - ---- - -### 多工作站协调 - -#### `AGVTransfer` +### `AGVTransfer` ```{literalinclude} ../../unilabos_msgs/action/AGVTransfer.action :language: yaml ``` ---- +---- -#### `WorkStationRun` +### `WorkStationRun` ```{literalinclude} ../../unilabos_msgs/action/WorkStationRun.action :language: yaml ``` ---- - -## 机器人控制(ROS2 标准) - -Uni-Lab 机械臂、机器人、夹爪和导航指令集沿用 ROS2 的 `control_msgs` 和 `nav2_msgs`。 - -### 机械臂与关节控制 - -#### `FollowJointTrajectory` - -```yaml -# The trajectory for all revolute, continuous or prismatic joints -trajectory_msgs/JointTrajectory trajectory -# The trajectory for all planar or floating joints (i.e. individual joints with more than one DOF) -trajectory_msgs/MultiDOFJointTrajectory multi_dof_trajectory - -# Tolerances for the trajectory. If the measured joint values fall -# outside the tolerances the trajectory goal is aborted. Any -# tolerances that are not specified (by being omitted or set to 0) are -# set to the defaults for the action server (often taken from the -# parameter server). - -# Tolerances applied to the joints as the trajectory is executed. If -# violated, the goal aborts with error_code set to -# PATH_TOLERANCE_VIOLATED. -JointTolerance[] path_tolerance -JointComponentTolerance[] component_path_tolerance - -# To report success, the joints must be within goal_tolerance of the -# final trajectory value. The goal must be achieved by time the -# trajectory ends plus goal_time_tolerance. (goal_time_tolerance -# allows some leeway in time, so that the trajectory goal can still -# succeed even if the joints reach the goal some time after the -# precise end time of the trajectory). -# -# If the joints are not within goal_tolerance after "trajectory finish -# time" + goal_time_tolerance, the goal aborts with error_code set to -# GOAL_TOLERANCE_VIOLATED -JointTolerance[] goal_tolerance -JointComponentTolerance[] component_goal_tolerance -builtin_interfaces/Duration goal_time_tolerance - ---- -int32 error_code -int32 SUCCESSFUL = 0 -int32 INVALID_GOAL = -1 -int32 INVALID_JOINTS = -2 -int32 OLD_HEADER_TIMESTAMP = -3 -int32 PATH_TOLERANCE_VIOLATED = -4 -int32 GOAL_TOLERANCE_VIOLATED = -5 - -# Human readable description of the error code. Contains complementary -# information that is especially useful when execution fails, for instance: -# - INVALID_GOAL: The reason for the invalid goal (e.g., the requested -# trajectory is in the past). -# - INVALID_JOINTS: The mismatch between the expected controller joints -# and those provided in the goal. -# - PATH_TOLERANCE_VIOLATED and GOAL_TOLERANCE_VIOLATED: Which joint -# violated which tolerance, and by how much. -string error_string - ---- -std_msgs/Header header -string[] joint_names -trajectory_msgs/JointTrajectoryPoint desired -trajectory_msgs/JointTrajectoryPoint actual -trajectory_msgs/JointTrajectoryPoint error - -string[] multi_dof_joint_names -trajectory_msgs/MultiDOFJointTrajectoryPoint multi_dof_desired -trajectory_msgs/MultiDOFJointTrajectoryPoint multi_dof_actual -trajectory_msgs/MultiDOFJointTrajectoryPoint multi_dof_error - -``` - ---- - -#### `JointTrajectory` - -```yaml -trajectory_msgs/JointTrajectory trajectory ---- - ---- -``` - ---- - -#### `PointHead` - -```yaml -geometry_msgs/PointStamped target -geometry_msgs/Vector3 pointing_axis -string pointing_frame -builtin_interfaces/Duration min_duration -float64 max_velocity ---- - ---- -float64 pointing_angle_error -``` - ---- - -#### `SingleJointPosition` - -```yaml -float64 position -builtin_interfaces/Duration min_duration -float64 max_velocity ---- - ---- -std_msgs/Header header -float64 position -float64 velocity -float64 error -``` - ---- - -### 夹爪控制 - -#### `GripperCommand` - -```yaml -GripperCommand command ---- -float64 position # The current gripper gap size (in meters) -float64 effort # The current effort exerted (in Newtons) -bool stalled # True iff the gripper is exerting max effort and not moving -bool reached_goal # True iff the gripper position has reached the commanded setpoint ---- -float64 position # The current gripper gap size (in meters) -float64 effort # The current effort exerted (in Newtons) -bool stalled # True iff the gripper is exerting max effort and not moving -bool reached_goal # True iff the gripper position has reached the commanded setpoint - -``` - ---- - -#### `ParallelGripperCommand` - -```yaml -# Parallel grippers refer to an end effector where two opposing fingers grasp an object from opposite sides. -sensor_msgs/JointState command -# name: the name(s) of the joint this command is requesting -# position: desired position of each gripper joint (radians or meters) -# velocity: (optional, not used if empty) max velocity of the joint allowed while moving (radians or meters / second) -# effort: (optional, not used if empty) max effort of the joint allowed while moving (Newtons or Newton-meters) ---- -sensor_msgs/JointState state # The current gripper state. -# position of each joint (radians or meters) -# optional: velocity of each joint (radians or meters / second) -# optional: effort of each joint (Newtons or Newton-meters) -bool stalled # True if the gripper is exerting max effort and not moving -bool reached_goal # True if the gripper position has reached the commanded setpoint ---- -sensor_msgs/JointState state # The current gripper state. -# position of each joint (radians or meters) -# optional: velocity of each joint (radians or meters / second) -# optional: effort of each joint (Newtons or Newton-meters) - -``` - ---- - -### 导航与路径规划 - -#### `AssistedTeleop` - -```yaml -#goal definition -builtin_interfaces/Duration time_allowance ---- -#result definition -builtin_interfaces/Duration total_elapsed_time ---- -#feedback -builtin_interfaces/Duration current_teleop_duration -``` - ---- - -#### `BackUp` - -```yaml -#goal definition -geometry_msgs/Point target -float32 speed -builtin_interfaces/Duration time_allowance ---- -#result definition -builtin_interfaces/Duration total_elapsed_time ---- -#feedback definition -float32 distance_traveled -``` - ---- - -#### `ComputePathThroughPoses` - -```yaml -#goal definition -geometry_msgs/PoseStamped[] goals -geometry_msgs/PoseStamped start -string planner_id -bool use_start # If false, use current robot pose as path start, if true, use start above instead ---- -#result definition -nav_msgs/Path path -builtin_interfaces/Duration planning_time ---- -#feedback definition -``` - ---- - -#### `ComputePathToPose` - -```yaml -#goal definition -geometry_msgs/PoseStamped goal -geometry_msgs/PoseStamped start -string planner_id -bool use_start # If false, use current robot pose as path start, if true, use start above instead ---- -#result definition -nav_msgs/Path path -builtin_interfaces/Duration planning_time ---- -#feedback definition -``` - ---- - -#### `DriveOnHeading` - -```yaml -#goal definition -geometry_msgs/Point target -float32 speed -builtin_interfaces/Duration time_allowance ---- -#result definition -builtin_interfaces/Duration total_elapsed_time ---- -#feedback definition -float32 distance_traveled -``` - ---- - -#### `DummyBehavior` - -```yaml -#goal definition -std_msgs/String command ---- -#result definition -builtin_interfaces/Duration total_elapsed_time ---- -#feedback definition -``` - ---- - -#### `FollowPath` - -```yaml -#goal definition -nav_msgs/Path path -string controller_id -string goal_checker_id ---- -#result definition -std_msgs/Empty result ---- -#feedback definition -float32 distance_to_goal -float32 speed -``` - ---- - -#### `FollowWaypoints` - -```yaml -#goal definition -geometry_msgs/PoseStamped[] poses ---- -#result definition -int32[] missed_waypoints ---- -#feedback definition -uint32 current_waypoint -``` - ---- - -#### `NavigateThroughPoses` - -```yaml -#goal definition -geometry_msgs/PoseStamped[] poses -string behavior_tree ---- -#result definition -std_msgs/Empty result ---- -#feedback definition -geometry_msgs/PoseStamped current_pose -builtin_interfaces/Duration navigation_time -builtin_interfaces/Duration estimated_time_remaining -int16 number_of_recoveries -float32 distance_remaining -int16 number_of_poses_remaining -``` - ---- - -#### `NavigateToPose` - -```yaml -#goal definition -geometry_msgs/PoseStamped pose -string behavior_tree ---- -#result definition -std_msgs/Empty result ---- -#feedback definition -geometry_msgs/PoseStamped current_pose -builtin_interfaces/Duration navigation_time -builtin_interfaces/Duration estimated_time_remaining -int16 number_of_recoveries -float32 distance_remaining -``` - ---- - -#### `SmoothPath` - -```yaml -#goal definition -nav_msgs/Path path -string smoother_id -builtin_interfaces/Duration max_smoothing_duration -bool check_for_collisions ---- -#result definition -nav_msgs/Path path -builtin_interfaces/Duration smoothing_duration -bool was_completed ---- -#feedback definition -``` - ---- - -#### `Spin` - -```yaml -#goal definition -float32 target_yaw -builtin_interfaces/Duration time_allowance ---- -#result definition -builtin_interfaces/Duration total_elapsed_time ---- -#feedback definition -float32 angular_distance_traveled -``` - ---- - -#### `Wait` (Nav2) - -> **注意**:这是 ROS2 nav2_msgs 的标准 Wait action,与 unilabos_msgs 的 Wait action 不同。 - -```yaml -#goal definition -builtin_interfaces/Duration time ---- -#result definition -builtin_interfaces/Duration total_elapsed_time ---- -#feedback definition -builtin_interfaces/Duration time_left -``` - ---- +---- diff --git a/docs/requirements.txt b/docs/requirements.txt index 1cc9247..591cc07 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -12,3 +12,7 @@ sphinx-copybutton>=0.5.0 # 用于自动摘要生成 sphinx-autobuild>=2024.2.4 + +# 用于PDF导出 (rinohtype方案,纯Python无需LaTeX) +rinohtype>=0.5.4 +sphinx-simplepdf>=1.6.0 \ No newline at end of file diff --git a/docs/user_guide/best_practice.md b/docs/user_guide/best_practice.md index 5b960e8..e1ffc24 100644 --- a/docs/user_guide/best_practice.md +++ b/docs/user_guide/best_practice.md @@ -1807,7 +1807,7 @@ unilab --ak your_ak --sk your_sk -g graph.json \ #### 14.5 社区支持 -- **GitHub Issues**:[https://github.com/dptech-corp/Uni-Lab-OS/issues](https://github.com/dptech-corp/Uni-Lab-OS/issues) +- **GitHub Issues**:[https://github.com/deepmodeling/Uni-Lab-OS/issues](https://github.com/deepmodeling/Uni-Lab-OS/issues) - **官方网站**:[https://uni-lab.bohrium.com](https://uni-lab.bohrium.com) --- diff --git a/docs/user_guide/graph_files.md b/docs/user_guide/graph_files.md index a8f86aa..d690282 100644 --- a/docs/user_guide/graph_files.md +++ b/docs/user_guide/graph_files.md @@ -463,7 +463,7 @@ Uni-Lab 使用 `ResourceDictInstance.get_resource_instance_from_dict()` 方法 ### 使用示例 ```python -from unilabos.ros.nodes.resource_tracker import ResourceDictInstance +from unilabos.resources.resource_tracker import ResourceDictInstance # 旧格式节点 old_format_node = { @@ -477,10 +477,10 @@ old_format_node = { instance = ResourceDictInstance.get_resource_instance_from_dict(old_format_node) # 访问标准化后的数据 -print(instance.res_content.id) # "pump_1" -print(instance.res_content.uuid) # 自动生成的 UUID +print(instance.res_content.id) # "pump_1" +print(instance.res_content.uuid) # 自动生成的 UUID print(instance.res_content.config) # {} -print(instance.res_content.data) # {} +print(instance.res_content.data) # {} ``` ### 格式迁移建议 @@ -857,4 +857,4 @@ class ResourceDictPosition(BaseModel): - 在 Web 界面中使用模板创建 - 参考示例文件:`test/experiments/` 目录 - 查看 ResourceDict 源码了解完整定义 -- [GitHub 讨论区](https://github.com/dptech-corp/Uni-Lab-OS/discussions) +- [GitHub 讨论区](https://github.com/deepmodeling/Uni-Lab-OS/discussions) diff --git a/docs/user_guide/installation.md b/docs/user_guide/installation.md index d3fd498..3f94f2f 100644 --- a/docs/user_guide/installation.md +++ b/docs/user_guide/installation.md @@ -37,7 +37,7 @@ #### 第一步:下载预打包环境 -1. 访问 [GitHub Actions - Conda Pack Build](https://github.com/dptech-corp/Uni-Lab-OS/actions/workflows/conda-pack-build.yml) +1. 访问 [GitHub Actions - Conda Pack Build](https://github.com/deepmodeling/Uni-Lab-OS/actions/workflows/conda-pack-build.yml) 2. 选择最新的成功构建记录(绿色勾号 ✓) @@ -189,13 +189,13 @@ conda activate unilab ### 第一步:克隆仓库 ```bash -git clone https://github.com/dptech-corp/Uni-Lab-OS.git +git clone https://github.com/deepmodeling/Uni-Lab-OS.git cd Uni-Lab-OS ``` 如果您需要贡献代码,建议先 Fork 仓库: -1. 访问 https://github.com/dptech-corp/Uni-Lab-OS +1. 访问 https://github.com/deepmodeling/Uni-Lab-OS 2. 点击右上角的 "Fork" 按钮 3. Clone 您的 Fork 版本: ```bash @@ -240,7 +240,7 @@ pip uninstall unilabos -y # 克隆 dev 分支(如果还未克隆) cd /path/to/your/workspace -git clone -b dev https://github.com/dptech-corp/Uni-Lab-OS.git +git clone -b dev https://github.com/deepmodeling/Uni-Lab-OS.git # 或者如果已经克隆,切换到 dev 分支 cd Uni-Lab-OS git checkout dev @@ -503,9 +503,9 @@ mamba update ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-f ## 需要帮助? - **故障排查**: 查看更详细的故障排查信息 -- **GitHub Issues**: [报告问题](https://github.com/dptech-corp/Uni-Lab-OS/issues) +- **GitHub Issues**: [报告问题](https://github.com/deepmodeling/Uni-Lab-OS/issues) - **开发者文档**: 查看开发者指南获取更多技术细节 -- **社区讨论**: [GitHub Discussions](https://github.com/dptech-corp/Uni-Lab-OS/discussions) +- **社区讨论**: [GitHub Discussions](https://github.com/deepmodeling/Uni-Lab-OS/discussions) --- diff --git a/recipes/msgs/recipe.yaml b/recipes/msgs/recipe.yaml index 86150f0..6d32908 100644 --- a/recipes/msgs/recipe.yaml +++ b/recipes/msgs/recipe.yaml @@ -1,6 +1,6 @@ package: name: ros-humble-unilabos-msgs - version: 0.10.12 + version: 0.10.15 source: path: ../../unilabos_msgs target_directory: src @@ -17,7 +17,7 @@ build: - bash $SRC_DIR/build_ament_cmake.sh about: - repository: https://github.com/dptech-corp/Uni-Lab-OS + repository: https://github.com/deepmodeling/Uni-Lab-OS license: BSD-3-Clause description: "ros-humble-unilabos-msgs is a package that provides message definitions for Uni-Lab-OS." diff --git a/recipes/unilabos/recipe.yaml b/recipes/unilabos/recipe.yaml index 0f79b26..be3f1a1 100644 --- a/recipes/unilabos/recipe.yaml +++ b/recipes/unilabos/recipe.yaml @@ -1,6 +1,6 @@ package: name: unilabos - version: "0.10.12" + version: "0.10.15" source: path: ../.. diff --git a/scripts/create_readme.py b/scripts/create_readme.py index 708f000..c4f3933 100644 --- a/scripts/create_readme.py +++ b/scripts/create_readme.py @@ -126,7 +126,7 @@ If installation fails: For more help: - Documentation: docs/user_guide/installation.md - Quick Start: QUICK_START_CONDA_PACK.md - - Issues: https://github.com/dptech-corp/Uni-Lab-OS/issues + - Issues: https://github.com/deepmodeling/Uni-Lab-OS/issues License: -------- @@ -134,7 +134,7 @@ License: UniLabOS is licensed under GPL-3.0-only. See LICENSE file for details. -Repository: https://github.com/dptech-corp/Uni-Lab-OS +Repository: https://github.com/deepmodeling/Uni-Lab-OS """ return readme diff --git a/setup.py b/setup.py index 4f733d0..b6ae5ed 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ package_name = 'unilabos' setup( name=package_name, - version='0.10.12', + version='0.10.15', packages=find_packages(), include_package_data=True, install_requires=['setuptools'], diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..5c26f48 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,7 @@ +""" +测试包根目录。 + +让 `tests.*` 模块可以被正常 import(例如给 `unilabos` 下的测试入口使用)。 +""" + + diff --git a/tests/devices/__init__.py b/tests/devices/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/devices/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/devices/liquid_handling/__init__.py b/tests/devices/liquid_handling/__init__.py new file mode 100644 index 0000000..b16b30e --- /dev/null +++ b/tests/devices/liquid_handling/__init__.py @@ -0,0 +1,5 @@ +""" +液体处理设备相关测试。 +""" + + diff --git a/tests/devices/liquid_handling/test_transfer_liquid.py b/tests/devices/liquid_handling/test_transfer_liquid.py new file mode 100644 index 0000000..9896aac --- /dev/null +++ b/tests/devices/liquid_handling/test_transfer_liquid.py @@ -0,0 +1,505 @@ +import asyncio +from dataclasses import dataclass +from typing import Any, Iterable, List, Optional, Sequence, Tuple + +import pytest + +from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract + + +@dataclass(frozen=True) +class DummyContainer: + name: str + + def __repr__(self) -> str: # pragma: no cover + return f"DummyContainer({self.name})" + + +@dataclass(frozen=True) +class DummyTipSpot: + name: str + + def __repr__(self) -> str: # pragma: no cover + return f"DummyTipSpot({self.name})" + + +def make_tip_iter(n: int = 256) -> Iterable[List[DummyTipSpot]]: + """Yield lists so code can safely call `tip.extend(next(self.current_tip))`.""" + for i in range(n): + yield [DummyTipSpot(f"tip_{i}")] + + +class FakeLiquidHandler(LiquidHandlerAbstract): + """不初始化真实 backend/deck;仅用来记录 transfer_liquid 内部调用序列。""" + + def __init__(self, channel_num: int = 8): + # 不调用 super().__init__,避免真实硬件/后端依赖 + self.channel_num = channel_num + self.support_touch_tip = True + self.current_tip = iter(make_tip_iter()) + self.calls: List[Tuple[str, Any]] = [] + + async def pick_up_tips(self, tip_spots, use_channels=None, offsets=None, **backend_kwargs): + self.calls.append(("pick_up_tips", {"tips": list(tip_spots), "use_channels": use_channels})) + + async def aspirate( + self, + resources: Sequence[Any], + vols: List[float], + use_channels: Optional[List[int]] = None, + flow_rates: Optional[List[Optional[float]]] = None, + offsets: Any = None, + liquid_height: Any = None, + blow_out_air_volume: Any = None, + spread: str = "wide", + **backend_kwargs, + ): + self.calls.append( + ( + "aspirate", + { + "resources": list(resources), + "vols": list(vols), + "use_channels": list(use_channels) if use_channels is not None else None, + "flow_rates": list(flow_rates) if flow_rates is not None else None, + "offsets": list(offsets) if offsets is not None else None, + "liquid_height": list(liquid_height) if liquid_height is not None else None, + "blow_out_air_volume": list(blow_out_air_volume) if blow_out_air_volume is not None else None, + }, + ) + ) + + async def dispense( + self, + resources: Sequence[Any], + vols: List[float], + use_channels: Optional[List[int]] = None, + flow_rates: Optional[List[Optional[float]]] = None, + offsets: Any = None, + liquid_height: Any = None, + blow_out_air_volume: Any = None, + spread: str = "wide", + **backend_kwargs, + ): + self.calls.append( + ( + "dispense", + { + "resources": list(resources), + "vols": list(vols), + "use_channels": list(use_channels) if use_channels is not None else None, + "flow_rates": list(flow_rates) if flow_rates is not None else None, + "offsets": list(offsets) if offsets is not None else None, + "liquid_height": list(liquid_height) if liquid_height is not None else None, + "blow_out_air_volume": list(blow_out_air_volume) if blow_out_air_volume is not None else None, + }, + ) + ) + + async def discard_tips(self, use_channels=None, *args, **kwargs): + # 有的分支是 discard_tips(use_channels=[0]),有的分支是 discard_tips([0..7])(位置参数) + self.calls.append(("discard_tips", {"use_channels": list(use_channels) if use_channels is not None else None})) + + async def custom_delay(self, seconds=0, msg=None): + self.calls.append(("custom_delay", {"seconds": seconds, "msg": msg})) + + async def touch_tip(self, targets): + # 原实现会访问 targets.get_size_x() 等;测试里只记录调用 + self.calls.append(("touch_tip", {"targets": targets})) + + async def mix(self, targets, mix_time=None, mix_vol=None, height_to_bottom=None, offsets=None, mix_rate=None, none_keys=None): + self.calls.append( + ( + "mix", + { + "targets": targets, + "mix_time": mix_time, + "mix_vol": mix_vol, + }, + ) + ) + + +def run(coro): + return asyncio.run(coro) + + +def test_one_to_one_single_channel_basic_calls(): + lh = FakeLiquidHandler(channel_num=1) + lh.current_tip = iter(make_tip_iter(64)) + + sources = [DummyContainer(f"S{i}") for i in range(3)] + targets = [DummyContainer(f"T{i}") for i in range(3)] + + run( + lh.transfer_liquid( + sources=sources, + targets=targets, + tip_racks=[], + use_channels=[0], + asp_vols=[1, 2, 3], + dis_vols=[4, 5, 6], + mix_times=None, # 应该仍能执行(不 mix) + ) + ) + + assert [c[0] for c in lh.calls].count("pick_up_tips") == 3 + assert [c[0] for c in lh.calls].count("aspirate") == 3 + assert [c[0] for c in lh.calls].count("dispense") == 3 + assert [c[0] for c in lh.calls].count("discard_tips") == 3 + + # 每次 aspirate/dispense 都是单孔列表 + aspirates = [payload for name, payload in lh.calls if name == "aspirate"] + assert aspirates[0]["resources"] == [sources[0]] + assert aspirates[0]["vols"] == [1.0] + + dispenses = [payload for name, payload in lh.calls if name == "dispense"] + assert dispenses[2]["resources"] == [targets[2]] + assert dispenses[2]["vols"] == [6.0] + + +def test_one_to_one_single_channel_before_stage_mixes_prior_to_aspirate(): + lh = FakeLiquidHandler(channel_num=1) + lh.current_tip = iter(make_tip_iter(16)) + + source = DummyContainer("S0") + target = DummyContainer("T0") + + run( + lh.transfer_liquid( + sources=[source], + targets=[target], + tip_racks=[], + use_channels=[0], + asp_vols=[5], + dis_vols=[5], + mix_stage="before", + mix_times=1, + mix_vol=3, + ) + ) + + names = [name for name, _ in lh.calls] + assert names.count("mix") == 1 + assert names.index("mix") < names.index("aspirate") + + +def test_one_to_one_eight_channel_groups_by_8(): + lh = FakeLiquidHandler(channel_num=8) + lh.current_tip = iter(make_tip_iter(256)) + + sources = [DummyContainer(f"S{i}") for i in range(16)] + targets = [DummyContainer(f"T{i}") for i in range(16)] + asp_vols = list(range(1, 17)) + dis_vols = list(range(101, 117)) + + run( + lh.transfer_liquid( + sources=sources, + targets=targets, + tip_racks=[], + use_channels=list(range(8)), + asp_vols=asp_vols, + dis_vols=dis_vols, + mix_times=0, # 触发逻辑但不 mix + ) + ) + + # 16 个任务 -> 2 组,每组 8 通道一起做 + assert [c[0] for c in lh.calls].count("pick_up_tips") == 2 + aspirates = [payload for name, payload in lh.calls if name == "aspirate"] + dispenses = [payload for name, payload in lh.calls if name == "dispense"] + assert len(aspirates) == 2 + assert len(dispenses) == 2 + + assert aspirates[0]["resources"] == sources[0:8] + assert aspirates[0]["vols"] == [float(v) for v in asp_vols[0:8]] + assert dispenses[1]["resources"] == targets[8:16] + assert dispenses[1]["vols"] == [float(v) for v in dis_vols[8:16]] + + +def test_one_to_one_eight_channel_requires_multiple_of_8_targets(): + lh = FakeLiquidHandler(channel_num=8) + lh.current_tip = iter(make_tip_iter(64)) + + sources = [DummyContainer(f"S{i}") for i in range(9)] + targets = [DummyContainer(f"T{i}") for i in range(9)] + + with pytest.raises(ValueError, match="multiple of 8"): + run( + lh.transfer_liquid( + sources=sources, + targets=targets, + tip_racks=[], + use_channels=list(range(8)), + asp_vols=[1] * 9, + dis_vols=[1] * 9, + mix_times=0, + ) + ) + + +def test_one_to_one_eight_channel_parameter_lists_are_chunked_per_8(): + lh = FakeLiquidHandler(channel_num=8) + lh.current_tip = iter(make_tip_iter(512)) + + sources = [DummyContainer(f"S{i}") for i in range(16)] + targets = [DummyContainer(f"T{i}") for i in range(16)] + asp_vols = [i + 1 for i in range(16)] + dis_vols = [200 + i for i in range(16)] + asp_flow_rates = [0.1 * (i + 1) for i in range(16)] + dis_flow_rates = [0.2 * (i + 1) for i in range(16)] + offsets = [f"offset_{i}" for i in range(16)] + liquid_heights = [i * 0.5 for i in range(16)] + blow_out_air_volume = [i + 0.05 for i in range(16)] + + run( + lh.transfer_liquid( + sources=sources, + targets=targets, + tip_racks=[], + use_channels=list(range(8)), + asp_vols=asp_vols, + dis_vols=dis_vols, + asp_flow_rates=asp_flow_rates, + dis_flow_rates=dis_flow_rates, + offsets=offsets, + liquid_height=liquid_heights, + blow_out_air_volume=blow_out_air_volume, + mix_times=0, + ) + ) + + aspirates = [payload for name, payload in lh.calls if name == "aspirate"] + dispenses = [payload for name, payload in lh.calls if name == "dispense"] + assert len(aspirates) == len(dispenses) == 2 + + for batch_idx in range(2): + start = batch_idx * 8 + end = start + 8 + asp_call = aspirates[batch_idx] + dis_call = dispenses[batch_idx] + assert asp_call["resources"] == sources[start:end] + assert asp_call["flow_rates"] == asp_flow_rates[start:end] + assert asp_call["offsets"] == offsets[start:end] + assert asp_call["liquid_height"] == liquid_heights[start:end] + assert asp_call["blow_out_air_volume"] == blow_out_air_volume[start:end] + assert dis_call["flow_rates"] == dis_flow_rates[start:end] + assert dis_call["offsets"] == offsets[start:end] + assert dis_call["liquid_height"] == liquid_heights[start:end] + assert dis_call["blow_out_air_volume"] == blow_out_air_volume[start:end] + + +def test_one_to_one_eight_channel_handles_32_tasks_four_batches(): + lh = FakeLiquidHandler(channel_num=8) + lh.current_tip = iter(make_tip_iter(1024)) + + sources = [DummyContainer(f"S{i}") for i in range(32)] + targets = [DummyContainer(f"T{i}") for i in range(32)] + asp_vols = [i + 1 for i in range(32)] + dis_vols = [300 + i for i in range(32)] + + run( + lh.transfer_liquid( + sources=sources, + targets=targets, + tip_racks=[], + use_channels=list(range(8)), + asp_vols=asp_vols, + dis_vols=dis_vols, + mix_times=0, + ) + ) + + pick_calls = [name for name, _ in lh.calls if name == "pick_up_tips"] + aspirates = [payload for name, payload in lh.calls if name == "aspirate"] + dispenses = [payload for name, payload in lh.calls if name == "dispense"] + assert len(pick_calls) == 4 + assert len(aspirates) == len(dispenses) == 4 + assert aspirates[0]["resources"] == sources[0:8] + assert aspirates[-1]["resources"] == sources[24:32] + assert dispenses[0]["resources"] == targets[0:8] + assert dispenses[-1]["resources"] == targets[24:32] + + +def test_one_to_many_single_channel_aspirates_total_when_asp_vol_too_small(): + lh = FakeLiquidHandler(channel_num=1) + lh.current_tip = iter(make_tip_iter(64)) + + source = DummyContainer("SRC") + targets = [DummyContainer(f"T{i}") for i in range(3)] + dis_vols = [10, 20, 30] # sum=60 + + run( + lh.transfer_liquid( + sources=[source], + targets=targets, + tip_racks=[], + use_channels=[0], + asp_vols=10, # 小于 sum(dis_vols) -> 应吸 60 + dis_vols=dis_vols, + mix_times=0, + ) + ) + + aspirates = [payload for name, payload in lh.calls if name == "aspirate"] + assert len(aspirates) == 1 + assert aspirates[0]["resources"] == [source] + assert aspirates[0]["vols"] == [60.0] + assert aspirates[0]["use_channels"] == [0] + dispenses = [payload for name, payload in lh.calls if name == "dispense"] + assert [d["vols"][0] for d in dispenses] == [10.0, 20.0, 30.0] + + +def test_one_to_many_eight_channel_basic(): + lh = FakeLiquidHandler(channel_num=8) + lh.current_tip = iter(make_tip_iter(128)) + + source = DummyContainer("SRC") + targets = [DummyContainer(f"T{i}") for i in range(8)] + dis_vols = [i + 1 for i in range(8)] + + run( + lh.transfer_liquid( + sources=[source], + targets=targets, + tip_racks=[], + use_channels=list(range(8)), + asp_vols=999, # one-to-many 8ch 会按 dis_vols 吸(每通道各自) + dis_vols=dis_vols, + mix_times=0, + ) + ) + + aspirates = [payload for name, payload in lh.calls if name == "aspirate"] + assert aspirates[0]["resources"] == [source] * 8 + assert aspirates[0]["vols"] == [float(v) for v in dis_vols] + dispenses = [payload for name, payload in lh.calls if name == "dispense"] + assert dispenses[0]["resources"] == targets + assert dispenses[0]["vols"] == [float(v) for v in dis_vols] + + +def test_many_to_one_single_channel_standard_dispense_equals_asp_by_default(): + lh = FakeLiquidHandler(channel_num=1) + lh.current_tip = iter(make_tip_iter(128)) + + sources = [DummyContainer(f"S{i}") for i in range(3)] + target = DummyContainer("T") + asp_vols = [5, 6, 7] + + run( + lh.transfer_liquid( + sources=sources, + targets=[target], + tip_racks=[], + use_channels=[0], + asp_vols=asp_vols, + dis_vols=1, # many-to-one 允许标量;非比例模式下实际每次分液=对应 asp_vol + mix_times=0, + ) + ) + + dispenses = [payload for name, payload in lh.calls if name == "dispense"] + assert [d["vols"][0] for d in dispenses] == [float(v) for v in asp_vols] + assert all(d["resources"] == [target] for d in dispenses) + + +def test_many_to_one_single_channel_before_stage_mixes_target_once(): + lh = FakeLiquidHandler(channel_num=1) + lh.current_tip = iter(make_tip_iter(128)) + + sources = [DummyContainer("S0"), DummyContainer("S1")] + target = DummyContainer("T") + + run( + lh.transfer_liquid( + sources=sources, + targets=[target], + tip_racks=[], + use_channels=[0], + asp_vols=[5, 6], + dis_vols=1, + mix_stage="before", + mix_times=2, + mix_vol=4, + ) + ) + + names = [name for name, _ in lh.calls] + assert names[0] == "mix" + assert names.count("mix") == 1 + + +def test_many_to_one_single_channel_proportional_mixing_uses_dis_vols_per_source(): + lh = FakeLiquidHandler(channel_num=1) + lh.current_tip = iter(make_tip_iter(128)) + + sources = [DummyContainer(f"S{i}") for i in range(3)] + target = DummyContainer("T") + asp_vols = [5, 6, 7] + dis_vols = [1, 2, 3] + + run( + lh.transfer_liquid( + sources=sources, + targets=[target], + tip_racks=[], + use_channels=[0], + asp_vols=asp_vols, + dis_vols=dis_vols, # 比例模式 + mix_times=0, + ) + ) + + dispenses = [payload for name, payload in lh.calls if name == "dispense"] + assert [d["vols"][0] for d in dispenses] == [float(v) for v in dis_vols] + + +def test_many_to_one_eight_channel_basic(): + lh = FakeLiquidHandler(channel_num=8) + lh.current_tip = iter(make_tip_iter(256)) + + sources = [DummyContainer(f"S{i}") for i in range(8)] + target = DummyContainer("T") + asp_vols = [10 + i for i in range(8)] + + run( + lh.transfer_liquid( + sources=sources, + targets=[target], + tip_racks=[], + use_channels=list(range(8)), + asp_vols=asp_vols, + dis_vols=999, # 非比例模式下每通道分液=对应 asp_vol + mix_times=0, + ) + ) + + aspirates = [payload for name, payload in lh.calls if name == "aspirate"] + dispenses = [payload for name, payload in lh.calls if name == "dispense"] + assert aspirates[0]["resources"] == sources + assert aspirates[0]["vols"] == [float(v) for v in asp_vols] + assert dispenses[0]["resources"] == [target] * 8 + assert dispenses[0]["vols"] == [float(v) for v in asp_vols] + + +def test_transfer_liquid_mode_detection_unsupported_shape_raises(): + lh = FakeLiquidHandler(channel_num=8) + lh.current_tip = iter(make_tip_iter(64)) + + sources = [DummyContainer("S0"), DummyContainer("S1")] + targets = [DummyContainer("T0"), DummyContainer("T1"), DummyContainer("T2")] + + with pytest.raises(ValueError, match="Unsupported transfer mode"): + run( + lh.transfer_liquid( + sources=sources, + targets=targets, + tip_racks=[], + use_channels=[0], + asp_vols=[1, 1], + dis_vols=[1, 1, 1], + mix_times=0, + ) + ) + diff --git a/test/resources/__init__.py b/tests/resources/__init__.py similarity index 100% rename from test/resources/__init__.py rename to tests/resources/__init__.py diff --git a/test/resources/bioyond_materials_liquidhandling_1.json b/tests/resources/bioyond_materials_liquidhandling_1.json similarity index 100% rename from test/resources/bioyond_materials_liquidhandling_1.json rename to tests/resources/bioyond_materials_liquidhandling_1.json diff --git a/test/resources/bioyond_materials_liquidhandling_2.json b/tests/resources/bioyond_materials_liquidhandling_2.json similarity index 100% rename from test/resources/bioyond_materials_liquidhandling_2.json rename to tests/resources/bioyond_materials_liquidhandling_2.json diff --git a/test/resources/bioyond_materials_reaction.json b/tests/resources/bioyond_materials_reaction.json similarity index 100% rename from test/resources/bioyond_materials_reaction.json rename to tests/resources/bioyond_materials_reaction.json diff --git a/test/resources/test_bottle_carrier.py b/tests/resources/test_bottle_carrier.py similarity index 100% rename from test/resources/test_bottle_carrier.py rename to tests/resources/test_bottle_carrier.py diff --git a/test/resources/test_converter_bioyond.py b/tests/resources/test_converter_bioyond.py similarity index 100% rename from test/resources/test_converter_bioyond.py rename to tests/resources/test_converter_bioyond.py diff --git a/test/resources/test_itemized_carrier.py b/tests/resources/test_itemized_carrier.py similarity index 100% rename from test/resources/test_itemized_carrier.py rename to tests/resources/test_itemized_carrier.py diff --git a/test/resources/test_resourcetreeset.py b/tests/resources/test_resourcetreeset.py similarity index 95% rename from test/resources/test_resourcetreeset.py rename to tests/resources/test_resourcetreeset.py index 1ba9ab2..cd1ff91 100644 --- a/test/resources/test_resourcetreeset.py +++ b/tests/resources/test_resourcetreeset.py @@ -2,9 +2,8 @@ import pytest import json import os -from pylabrobot.resources import Resource as ResourcePLR from unilabos.resources.graphio import resource_bioyond_to_plr -from unilabos.ros.nodes.resource_tracker import ResourceTreeSet +from unilabos.resources.resource_tracker import ResourceTreeSet from unilabos.registry.registry import lab_registry from unilabos.resources.bioyond.decks import BIOYOND_PolymerReactionStation_Deck diff --git a/test/ros/__init__.py b/tests/ros/__init__.py similarity index 100% rename from test/ros/__init__.py rename to tests/ros/__init__.py diff --git a/test/ros/msgs/__init__.py b/tests/ros/msgs/__init__.py similarity index 100% rename from test/ros/msgs/__init__.py rename to tests/ros/msgs/__init__.py diff --git a/test/ros/msgs/test_basic.py b/tests/ros/msgs/test_basic.py similarity index 100% rename from test/ros/msgs/test_basic.py rename to tests/ros/msgs/test_basic.py diff --git a/test/ros/msgs/test_conversion.py b/tests/ros/msgs/test_conversion.py similarity index 100% rename from test/ros/msgs/test_conversion.py rename to tests/ros/msgs/test_conversion.py diff --git a/test/ros/msgs/test_mapping.py b/tests/ros/msgs/test_mapping.py similarity index 100% rename from test/ros/msgs/test_mapping.py rename to tests/ros/msgs/test_mapping.py diff --git a/test/ros/msgs/test_runner.py b/tests/ros/msgs/test_runner.py similarity index 79% rename from test/ros/msgs/test_runner.py rename to tests/ros/msgs/test_runner.py index fe4cb09..02d352d 100644 --- a/test/ros/msgs/test_runner.py +++ b/tests/ros/msgs/test_runner.py @@ -11,10 +11,10 @@ import os # 添加项目根目录到路径 sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))) -# 导入测试模块 -from test.ros.msgs.test_basic import TestBasicFunctionality -from test.ros.msgs.test_conversion import TestBasicConversion, TestMappingConversion -from test.ros.msgs.test_mapping import TestTypeMapping, TestFieldMapping +# 导入测试模块(统一从 tests 包获取) +from tests.ros.msgs.test_basic import TestBasicFunctionality +from tests.ros.msgs.test_conversion import TestBasicConversion, TestMappingConversion +from tests.ros.msgs.test_mapping import TestTypeMapping, TestFieldMapping def run_tests(): diff --git a/test/workflow/__init__.py b/tests/workflow/__init__.py similarity index 100% rename from test/workflow/__init__.py rename to tests/workflow/__init__.py diff --git a/test/workflow/example_bio.json b/tests/workflow/example_bio.json similarity index 100% rename from test/workflow/example_bio.json rename to tests/workflow/example_bio.json diff --git a/test/workflow/example_bio_graph.png b/tests/workflow/example_bio_graph.png similarity index 100% rename from test/workflow/example_bio_graph.png rename to tests/workflow/example_bio_graph.png diff --git a/test/workflow/example_prcxi.json b/tests/workflow/example_prcxi.json similarity index 100% rename from test/workflow/example_prcxi.json rename to tests/workflow/example_prcxi.json diff --git a/test/workflow/example_prcxi_graph.png b/tests/workflow/example_prcxi_graph.png similarity index 100% rename from test/workflow/example_prcxi_graph.png rename to tests/workflow/example_prcxi_graph.png diff --git a/test/workflow/example_prcxi_graph_20251022_1359.png b/tests/workflow/example_prcxi_graph_20251022_1359.png similarity index 100% rename from test/workflow/example_prcxi_graph_20251022_1359.png rename to tests/workflow/example_prcxi_graph_20251022_1359.png diff --git a/test/workflow/merge_workflow.py b/tests/workflow/merge_workflow.py similarity index 100% rename from test/workflow/merge_workflow.py rename to tests/workflow/merge_workflow.py diff --git a/unilabos/__init__.py b/unilabos/__init__.py index a37fec7..d5ac10a 100644 --- a/unilabos/__init__.py +++ b/unilabos/__init__.py @@ -1 +1 @@ -__version__ = "0.10.12" +__version__ = "0.10.15" diff --git a/unilabos/app/backend.py b/unilabos/app/backend.py index b2bc0af..4af03cb 100644 --- a/unilabos/app/backend.py +++ b/unilabos/app/backend.py @@ -1,6 +1,6 @@ import threading -from unilabos.ros.nodes.resource_tracker import ResourceTreeSet +from unilabos.resources.resource_tracker import ResourceTreeSet from unilabos.utils import logger diff --git a/unilabos/app/main.py b/unilabos/app/main.py index 2a2facd..3ad7310 100644 --- a/unilabos/app/main.py +++ b/unilabos/app/main.py @@ -156,6 +156,11 @@ def parse_args(): default=False, help="Complete registry information", ) + parser.add_argument( + "--no_update_feedback", + action="store_true", + help="Disable sending update feedback to server", + ) # workflow upload subcommand workflow_parser = subparsers.add_parser( "workflow_upload", @@ -297,6 +302,7 @@ def main(): BasicConfig.is_host_mode = not args_dict.get("is_slave", False) BasicConfig.slave_no_host = args_dict.get("slave_no_host", False) BasicConfig.upload_registry = args_dict.get("upload_registry", False) + BasicConfig.no_update_feedback = args_dict.get("no_update_feedback", False) BasicConfig.communication_protocol = "websocket" machine_name = os.popen("hostname").read().strip() machine_name = "".join([c if c.isalnum() or c == "_" else "_" for c in machine_name]) @@ -315,7 +321,7 @@ def main(): from unilabos.app.web import start_server from unilabos.app.register import register_devices_and_resources from unilabos.resources.graphio import modify_to_backend_format - from unilabos.ros.nodes.resource_tracker import ResourceTreeSet, ResourceDict + from unilabos.resources.resource_tracker import ResourceTreeSet, ResourceDict # 显示启动横幅 print_unilab_banner(args_dict) @@ -418,7 +424,7 @@ def main(): # 如果从远端获取了物料信息,则与本地物料进行同步 if request_startup_json and "nodes" in request_startup_json: print_status("开始同步远端物料到本地...", "info") - remote_tree_set = ResourceTreeSet.from_raw_list(request_startup_json["nodes"]) + remote_tree_set = ResourceTreeSet.from_raw_dict_list(request_startup_json["nodes"]) resource_tree_set.merge_remote_resources(remote_tree_set) print_status("远端物料同步完成", "info") diff --git a/unilabos/app/web/client.py b/unilabos/app/web/client.py index 8968f03..64a9418 100644 --- a/unilabos/app/web/client.py +++ b/unilabos/app/web/client.py @@ -6,12 +6,10 @@ HTTP客户端模块 import json import os -import time -from threading import Thread from typing import List, Dict, Any, Optional import requests -from unilabos.ros.nodes.resource_tracker import ResourceTreeSet +from unilabos.resources.resource_tracker import ResourceTreeSet from unilabos.utils.log import info from unilabos.config.config import HTTPConfig, BasicConfig from unilabos.utils import logger diff --git a/unilabos/app/ws_client.py b/unilabos/app/ws_client.py index 8c44712..4933b61 100644 --- a/unilabos/app/ws_client.py +++ b/unilabos/app/ws_client.py @@ -359,7 +359,7 @@ class MessageProcessor: self.device_manager = device_manager self.queue_processor = None # 延迟设置 self.websocket_client = None # 延迟设置 - self.session_id = "" + self.session_id = str(uuid.uuid4())[:6] # 产生一个随机的session_id # WebSocket连接 self.websocket = None @@ -488,7 +488,11 @@ class MessageProcessor: async for message in self.websocket: try: data = json.loads(message) - await self._process_message(data) + if self.session_id and self.session_id == data.get("edge_session"): + await self._process_message(data) + else: + logger.trace(f"[MessageProcessor] 收到一条归属 {data.get('edge_session')} 的旧消息:{data}") + logger.debug(f"[MessageProcessor] 跳过了一条归属 {data.get('edge_session')} 的旧消息: {data.get('action')}") except json.JSONDecodeError: logger.error(f"[MessageProcessor] Invalid JSON received: {message}") except Exception as e: @@ -576,9 +580,11 @@ class MessageProcessor: await self._handle_resource_tree_update(message_data, "update") elif message_type == "remove_material": await self._handle_resource_tree_update(message_data, "remove") - elif message_type == "session_id": - self.session_id = message_data.get("session_id") - logger.info(f"[MessageProcessor] Session ID: {self.session_id}") + # elif message_type == "session_id": + # self.session_id = message_data.get("session_id") + # logger.info(f"[MessageProcessor] Session ID: {self.session_id}") + elif message_type == "request_reload": + await self._handle_request_reload(message_data) else: logger.debug(f"[MessageProcessor] Unknown message type: {message_type}") @@ -888,6 +894,20 @@ class MessageProcessor: ) thread.start() + async def _handle_request_reload(self, data: Dict[str, Any]): + """ + 处理重载请求 + + 当LabGo发送request_reload时,重新发送设备注册信息 + """ + reason = data.get("reason", "unknown") + logger.info(f"[MessageProcessor] Received reload request, reason: {reason}") + + # 重新发送host_node_ready信息 + if self.websocket_client: + self.websocket_client.publish_host_ready() + logger.info("[MessageProcessor] Re-sent host_node_ready after reload request") + async def _send_action_state_response( self, device_id: str, action_name: str, task_id: str, job_id: str, typ: str, free: bool, need_more: int ): @@ -1240,7 +1260,7 @@ class WebSocketClient(BaseCommunicationClient): }, } self.message_processor.send_message(message) - logger.debug(f"[WebSocketClient] Device status published: {device_id}.{property_name}") + logger.trace(f"[WebSocketClient] Device status published: {device_id}.{property_name}") def publish_job_status( self, feedback_data: dict, item: QueueItem, status: str, return_info: Optional[dict] = None @@ -1282,7 +1302,7 @@ class WebSocketClient(BaseCommunicationClient): self.message_processor.send_message(message) job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name) - logger.debug(f"[WebSocketClient] Job status published: {job_log} - {status}") + logger.trace(f"[WebSocketClient] Job status published: {job_log} - {status}") def send_ping(self, ping_id: str, timestamp: float) -> None: """发送ping消息""" @@ -1313,17 +1333,55 @@ class WebSocketClient(BaseCommunicationClient): logger.warning(f"[WebSocketClient] Failed to cancel job {job_log}") def publish_host_ready(self) -> None: - """发布host_node ready信号""" + """发布host_node ready信号,包含设备和动作信息""" if self.is_disabled or not self.is_connected(): logger.debug("[WebSocketClient] Not connected, cannot publish host ready signal") return + # 收集设备信息 + devices = [] + machine_name = BasicConfig.machine_name + + try: + host_node = HostNode.get_instance(0) + if host_node: + # 获取设备信息 + for device_id, namespace in host_node.devices_names.items(): + device_key = f"{namespace}/{device_id}" if namespace.startswith("/") else f"/{namespace}/{device_id}" + is_online = device_key in host_node._online_devices + + # 获取设备的动作信息 + actions = {} + for action_id, client in host_node._action_clients.items(): + # action_id 格式: /namespace/device_id/action_name + if device_id in action_id: + action_name = action_id.split("/")[-1] + actions[action_name] = { + "action_path": action_id, + "action_type": str(type(client).__name__), + } + + devices.append({ + "device_id": device_id, + "namespace": namespace, + "device_key": device_key, + "is_online": is_online, + "machine_name": host_node.device_machine_names.get(device_id, machine_name), + "actions": actions, + }) + + logger.info(f"[WebSocketClient] Collected {len(devices)} devices for host_ready") + except Exception as e: + logger.warning(f"[WebSocketClient] Error collecting device info: {e}") + message = { "action": "host_node_ready", "data": { "status": "ready", "timestamp": time.time(), + "machine_name": machine_name, + "devices": devices, }, } self.message_processor.send_message(message) - logger.info("[WebSocketClient] Host node ready signal published") + logger.info(f"[WebSocketClient] Host node ready signal published with {len(devices)} devices") diff --git a/unilabos/config/config.py b/unilabos/config/config.py index 1e40966..f3dba5d 100644 --- a/unilabos/config/config.py +++ b/unilabos/config/config.py @@ -16,6 +16,7 @@ class BasicConfig: upload_registry = False machine_name = "undefined" vis_2d_enable = False + no_update_feedback = False enable_resource_load = True communication_protocol = "websocket" startup_json_path = None # 填写绝对路径 diff --git a/unilabos/device_comms/coin_cell_assembly_workstation.py b/unilabos/device_comms/coin_cell_assembly_workstation.py index 62d9b09..187f411 100644 --- a/unilabos/device_comms/coin_cell_assembly_workstation.py +++ b/unilabos/device_comms/coin_cell_assembly_workstation.py @@ -6,7 +6,7 @@ Coin Cell Assembly Workstation """ from typing import Dict, Any, List, Optional, Union -from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker +from unilabos.resources.resource_tracker import DeviceNodeResourceTracker from unilabos.device_comms.workstation_base import WorkstationBase, WorkflowInfo from unilabos.device_comms.workstation_communication import ( WorkstationCommunicationBase, CommunicationConfig, CommunicationProtocol, CoinCellCommunication @@ -61,7 +61,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase): # 创建资源跟踪器(如果没有提供) if resource_tracker is None: - from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker + from unilabos.resources.resource_tracker import DeviceNodeResourceTracker resource_tracker = DeviceNodeResourceTracker() # 初始化基类 diff --git a/unilabos/device_mesh/resource_visalization.py b/unilabos/device_mesh/resource_visalization.py index fc37150..ee58d67 100644 --- a/unilabos/device_mesh/resource_visalization.py +++ b/unilabos/device_mesh/resource_visalization.py @@ -128,14 +128,21 @@ class ResourceVisualization: new_dev.set("device_name", node["id"]+"_") # if node["parent"] is not None: # new_dev.set("station_name", node["parent"]+'_') - - 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 "position" in node: + 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"]))) new_dev.set("r",str(float(node["config"]["rotation"]["z"]))) + if "pose" in node: + new_dev.set("x",str(float(node["pose"]["position"]["x"])/1000)) + new_dev.set("y",str(float(node["pose"]["position"]["y"])/1000)) + new_dev.set("z",str(float(node["pose"]["position"]["z"])/1000)) + new_dev.set("rx",str(float(node["pose"]["rotation"]["x"]))) + new_dev.set("ry",str(float(node["pose"]["rotation"]["y"]))) + new_dev.set("r",str(float(node["pose"]["rotation"]["z"]))) if "device_config" in node["config"]: for key, value in node["config"]["device_config"].items(): new_dev.set(key, str(value)) diff --git a/unilabos/devices/LICENSE b/unilabos/devices/LICENSE new file mode 100644 index 0000000..f72c497 --- /dev/null +++ b/unilabos/devices/LICENSE @@ -0,0 +1,73 @@ +Uni-Lab-OS软件许可使用准则 + + +本软件使用准则(以下简称"本准则")旨在规范用户在使用Uni-Lab-OS软件(以下简称"本软件")过程中的行为和义务。在下载、安装、使用或以任何方式访问本软件之前,请务必仔细阅读并理解以下条款和条件。若您不同意本准则的全部或部分内容,请您立即停止使用本软件。一旦您开始访问、下载、安装、使用本软件,即表示您已阅读、理解并同意接受本准则的约束。 + +1、使用许可 +1.1 本软件的所有权及版权归北京深势科技有限公司(以下简称"深势科技")所有。在遵守本准则的前提下,深势科技特此授予学术用户(以下简称"您")一个全球范围内的、非排他性的、免版权费用的使用许可,可为了满足学术目的而使用本软件。 + +1.2 本准则下授予的许可仅适用于本软件的二进制代码版本。您不对本软件源代码拥有任何权利。 + +2、使用限制 +2.1 本准则仅授予学术用户出于学术目的使用本软件,任何商业组织、商业机构或其他非学术用户不得使用本软件,如果违反本条款,深势科技将保留一切追诉的权利。 +2.2 您将本软件用于任何商业行为,应取得深势科技的商业许可。 +2.3 您不得将本软件或任何形式的衍生作品用于任何商业目的,也不得将其出售、出租、转让、分发或以其他方式提供给任何第三方。您必须确保本软件的使用仅限于您个人学术研究,禁止您为任何其他实体的利益使用本软件(无论是否收费)。 +2.4 您不得以任何方式修改、破解、反编译、反汇编、反向工程、隔离、分离或以其他方式从任何程序或文档中提取源代码或试图发现本软件的源代码。您不得以任何方式去除、修改或屏蔽本软件中的任何版权、商标或其他专有权利声明。您不得使用本软件进行任何非法活动,包括但不限于侵犯他人的知识产权、隐私权等。 +2.5 您同意将本软件仅用于合法的学术目的,且遵守您所在国家或地区的法律法规,您将承担因违反法律法规而产生的一切法律责任。 + +3、软件所有权 +本软件在此仅作使用许可,并非出售。本软件及与软件有关的全部文档的所有权及其他所有权利(包括但不限于知识产权和商业秘密),始终是深势科技的专有财产,您不拥有任何权利,但本准则下被明确授予的有限的使用许可权利除外。 + +4、衍生作品传播规范 +若您传播基于Uni-Lab-OS程序修改形成的作品,须同时满足以下全部条件: +4.1 作品必须包含显著声明,明确标注修改内容及修改日期; +4.2 作品必须声明本作品依据本许可协议发布; +4.3 必须将整个作品(包括修改部分)作为整体授予获取副本者本许可协议的保障,且该许可将自动延伸适用于作品全组件(无论其以何种形式打包); +4.4 若衍生作品含交互式用户界面:每个界面均须显示合规法律声明,若原始Uni-Lab-OS程序的交互界面未展示法律声明,您的衍生作品可免除此义务。 + +5、提出建议 +您可以对本软件提出建议,前提是: +(i)您声明并保证,该建议未侵害任何第三方的任何知识产权; +(ii)您承认,深势科技有权使用该建议,但无使用该建议的义务; +(iii)您授予深势科技一项非独占的、不可撤销的、可分许可的、无版权费的、全球范围的著作权许可,以复制、分发、传播、公开展示、公开表演、修改、翻译、基于其制作衍生作品、生产、制作、推销、销售、提供销售和/或以其他方式整体或部分地使用该建议和基于其的衍生作品,包括但不限于,通过将该建议整体或部分地纳入深势科技的软件和/或其他软件,以及在现存的或将来任何时候存在的任何媒介中或通过该媒介体现,以及为从事上述活动而授予多个分许可; +(iv)您特此授予深势科技一项永久的、全球范围的、非独占性的、免费的、免特许权使用费的、不可撤销的专利许可,许可其制造、委托制造、使用、要约销售、销售、进口及以其他方式转让该建议和基于其的衍生专利。上述专利许可的适用范围仅限于以下专利权利要求:您有权许可的、且仅因您的建议本身,或因您的建议与所提交的本软件结合而必然构成侵权的专利权利要求。若任何实体针对您或其他实体提起专利诉讼(包括诉讼中的交叉诉讼或反诉),主张该建议或您所贡献的软件构成直接或间接专利侵权,则依据本协议授予的、针对该建议或软件的任何专利许可,自该诉讼提起之日起终止。 +(v)您放弃对该建议的任何权利或主张,深势科技无需承担任何义务、版税或基于知识产权或其他方面的限制。 + +6、引用要求 +如您使用本软件获得的成果发表在出版物上,您应在成果中承认对Uni-Lab-OS软件的使用并标注权利人名称。引用 Uni-Lab-OS时请使用以下内容: +@article{gao2025unilabos, + title = {UniLabOS: An AI-Native Operating System for Autonomous Laboratories}, + doi = {10.48550/arXiv.2512.21766}, + publisher = {arXiv}, + author = {Gao, Jing and Chang, Junhan and Que, Haohui and Xiong, Yanfei and Zhang, Shixiang and Qi, Xianwei and Liu, Zhen and Wang, Jun-Jie and Ding, Qianjun and Li, Xinyu and Pan, Ziwei and Xie, Qiming and Yan, Zhuang and Yan, Junchi and Zhang, Linfeng}, + year = {2025} +} + +7、保留权利 +您认可,所有未被明确授予您的本软件的权利,无论是当前或今后存在的,均由深势科技予以保留,任何未经深势科技明确授权而使用本软件的行为将被视为侵权,深势科技有权追究侵权者的一切法律责任。 + +8、保密信息 +您同意将本软件代码及相关文档视为深势科技的机密信息,您不会向任何第三方提供相关代码,并将采取合理审慎的使用态度来防止本软件代码及相关文档被泄露。 + +9、无保证 +该软件是"按原样"提供的,没有任何明示或暗示的保证,不包含任何代码或规范没有缺陷、适销性、适用于特定目的或不侵犯第三方权利的保证。您同意您自主承担使用本软件或与本准则有关的全部风险。 + +10、免责条款 +在任何情况下,无论基于侵权(包括过失)、合同或其他法律理论,除非适用法律强制规定(如故意或重大过失行为)或另有书面协议,深势科技不对被许可人因软件许可、使用或无法使用软件所致损害承担责任(包括任何性质的直接、间接、特殊、偶发或后果性损害,例如但不限于商誉损失、停工损失、计算机故障或失灵造成的损害,以及其他一切商业损害或损失),即使深势科技已被告知发生此类损害的可能性亦不例外。 +被许可人在再分发软件或其衍生作品时,仅能以自身名义独立承担责任进行操作,不得代表深势科技或其他被许可人。 + +11、终止 +如果您以任何方式违反本准则或未能遵守本准则的任何重要条款或条件,则您被授予的所有权利将自动终止。 + +12、举报 +如果您认为有人违反了本准则,请向深势科技进行举报,深势科技将对您的身份进行严格保密,举报邮箱changjh@dp.tech。 + +13、法律管辖 +本准则中的任何内容均不得解释为通过暗示、禁止反悔或其他方式授予本准则中授予的许可或权利以外的任何许可或权利。如果本准则的任何条款被认定为不可执行,则仅在必要的范围内对该条款进行修改,使其可执行。本准则应受中华人民共和国法律管辖,不适用法律冲突条款及《联合国国际货物销售合同公约》,因本准则产生的一切争议由北京市海淀区人民法院管辖。 + +14、未来版本 +深势科技保留不经事先通知随时变更或停止本软件或本准则的权利。 + +15、语言优先 +本准则同时具有中文版本和英文版本,如果英文版本和中文版本有冲突,以中文版本为准。 + diff --git a/unilabos/devices/LICENSE_eng b/unilabos/devices/LICENSE_eng new file mode 100644 index 0000000..33699a3 --- /dev/null +++ b/unilabos/devices/LICENSE_eng @@ -0,0 +1,73 @@ +Uni-Lab-OS License Agreement + +Preamble +This License Agreement (the "Agreement") is instituted to govern user conduct and obligations in relation to the utilization of the Uni-Lab-OS (the "Software"). By accessing, downloading, installing, or utilizing the Software in any manner, you hereby acknowledge that you have meticulously reviewed, comprehended, and consented to be legally bound by the terms herein. If you dissent from any provision of this Agreement, you must forthwith cease all interaction with the Software. + +1. Grant of License +1.1 The proprietary rights to the Software are exclusively retained by Beijing DP Technology Co., Ltd. ("DP Technology"). Subject to full compliance with this Agreement, DP Technology hereby grants academic users ("Licensee") a worldwide, non-exclusive, royalty-free license to untilise the Software solely for non-commercial academic pursuits. + +1.2 The foregoing license applies exclusively to the Software's executable binary code. No rights whatsoever are conferred to the Software's source code. + +2. Usage Restrictions +2.1 This license is restricted to academic users engaging in scholastic activities. Commercial entities, institutions, or any non-academic parties are expressly prohibited from utilizing the Software. Violations of this clause shall entitle DP Technology to pursue all available legal remedies. +2.2 The Licensee shall obtain a commercial license from DP Technology for any commercial use of the Software. +2.3 The Licensee shall not utilise the Software or any derivative works for commercial purposes, nor distribute, sublicense, lease, transfer, or otherwise disseminate the Software to third parties. The Licensee is strictly prohibited from utilizing the Software for the benefit of any third-party entity, whether gratuitously or otherwise. +2.4 Reverse engineering, decompilation, disassembly, code isolation, or any attempt to derive source code from the Software is strictly prohibited. The Licensee shall not alter, circumvent, or remove copyright notices, trademarks, or proprietary legends embedded in the Software. Use of the Software for unlawful activities—including but not limited to intellectual property infringement or privacy violations—is categorically barred. +2.5 The Licensee warrants that the Software shall be utilised solely for lawful academic purposes in compliance with applicable jurisdictional statutes. All legal liabilities arising from noncompliance shall be borne exclusively by the Licensee. + +3. Proprietary Rights +This Agreement confers a license to utilise the Software, not a transfer of ownership. All intellectual property rights—including copyrights, patents, trade secrets, and documentation—remain the exclusive dominion of DP Technology. The Licensee acquires no entitlements beyond the limited usage privileges expressly delineated herein. + +4. Derivative Work +You may convey a work based on the Software, or the modifications to produce it from the Software, provided that you meet all of these conditions: +4.1 The work must carry prominent notices stating that you modified it, and giving a relevant date. +4.2 The work must carry prominent notices stating that it is released under this License. +4.3 You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. +4.4 If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Software has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. + +5. Feedback and Proposals +Licensees may submit proposals, suggestions, or improvements pertaining to the Software ("Feedback") under the following conditions: +(a) Licensee represents and warrants that such Feedback does not infringe upon any third-party intellectual property rights; +(b) Licensee acknowledges that DP Technology reserves the right, but assumes no obligation, to utilize such Feedback; +(c) Licensee irrevocably grants DP Technology a non-exclusive, royalty-free, perpetual, worldwide, sublicensable copyright license to reproduce, distribute, modify, publicly perform or display, translate, create derivative works of, commercialize, and otherwise exploit the Feedback in any medium or format, whether now known or hereafter devised, including the right to grant multiple tiers of sublicenses to enable such activities; +(d) Licensee hereby grants DP Technology a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Feedback and such Derivative Works, where such license applies only to those patent claimss licensable by Licensee that are necessarily infringed by the Feedback(s) alone or by comibination of the Feedback(s) with the Software to which such Feedback(s) were submitted. If any entity institutes patent litigation against Licensee or any other entity (including a cross-claim orcounterclaim in a lawsuit) alleging that the Feedback, or the Software to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted under this Agreement for the Feedback or Software shall terminate as of the date such litigation is filed. +(e) Licensee hereby waives all claims, proprietary rights, or restrictions related to DP Technology's use of such Feedback. + +6. Citation Requirement +If academic or research output generated using the Software is published, Licensee must explicitly acknowledge the use of Uni-Lab-OS and attribute ownership to DP Technology. The following citation must be included: +@article{gao2025unilabos, + title = {UniLabOS: An AI-Native Operating System for Autonomous Laboratories}, + doi = {10.48550/arXiv.2512.21766}, + publisher = {arXiv}, + author = {Gao, Jing and Chang, Junhan and Que, Haohui and Xiong, Yanfei and Zhang, Shixiang and Qi, Xianwei and Liu, Zhen and Wang, Jun-Jie and Ding, Qianjun and Li, Xinyu and Pan, Ziwei and Xie, Qiming and Yan, Zhuang and Yan, Junchi and Zhang, Linfeng}, + year = {2025} +} + +7. Reservation of Rights +All rights not expressly granted herein, whether existing now or arising in the future, are exclusively reserved by DP Technology. Any unauthorized use of the Software beyond the scope of this Agreement constitutes infringement, and DP Technology reserves all legal rights to pursue remedies against violators. + +8. Confidentiality +Licensee agrees to treat the Software's code, documentation, and related materials as confidential information. Licensee shall not disclose such materials to third parties and shall employ reasonable safeguards to prevent unauthorized access, dissemination, or misuse. + +9. Disclaimer of Warranties +The software is provided "as is," without warranties of any kind, express or implied, including but not limited to warranties of merchantability, fitness for a particular purpose, non-infringement, or error-free operation. Licensee accepts all risks associated with the use of the software. + +10. Limitation of Liability +In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall DP Technology be liable to Licensee for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the software (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if DP Technology has been advised of the possibility of such damages. +While redistributing the Software or Derivative Works thereof, Licensee may act only on Licensee's own behalf and on Licensee's sole responsibility, not on behalf of DP Technology or any other Licensee. + +11. Termination +All rights granted herein shall terminate immediately and automatically if Licensee materially breaches any provision of this Agreement. + +12. Reporting Violations +To report suspected violations of this Agreement, notify DP Technology via the designated email address: changjh@dp.tech. DP Technology shall maintain the confidentiality of the reporter's identity. + +13. Governing Law and Dispute Resolution +This Agreement shall be governed by the laws of the People's Republic of China, excluding its conflict of laws principles and the United Nations Convention on Contracts for the International Sale of Goods. Any dispute arising from this Agreement shall be exclusively adjudicated by the Haidian District People's Court in Beijing. + +14. Amendments and Updates +DP Technology reserves the right to modify, suspend, or terminate the Software or this Agreement at any time without prior notice. + +15. Language Priority +This Agreement is provided in both Chinese and English. In the event of any discrepancy, the Chinese version shall prevail. + diff --git a/unilabos/devices/laiyu_liquid/__init__.py b/unilabos/devices/laiyu_liquid/__init__.py deleted file mode 100644 index 8935252..0000000 --- a/unilabos/devices/laiyu_liquid/__init__.py +++ /dev/null @@ -1,307 +0,0 @@ -""" -LaiYu_Liquid 液体处理工作站集成模块 - -该模块提供了 LaiYu_Liquid 工作站与 UniLabOS 的完整集成,包括: -- 硬件后端和抽象接口 -- 资源定义和管理 -- 协议执行和液体传输 -- 工作台配置和布局 - -主要组件: -- LaiYuLiquidBackend: 硬件后端实现 -- LaiYuLiquid: 液体处理器抽象接口 -- 各种资源类:枪头架、板、容器等 -- 便捷创建函数和配置管理 - -使用示例: - from unilabos.devices.laiyu_liquid import ( - LaiYuLiquid, - LaiYuLiquidBackend, - create_standard_deck, - create_tip_rack_1000ul - ) - - # 创建后端和液体处理器 - backend = LaiYuLiquidBackend() - lh = LaiYuLiquid(backend=backend) - - # 创建工作台 - deck = create_standard_deck() - lh.deck = deck - - # 设置和运行 - await lh.setup() -""" - -# 版本信息 -__version__ = "1.0.0" -__author__ = "LaiYu_Liquid Integration Team" -__description__ = "LaiYu_Liquid 液体处理工作站 UniLabOS 集成模块" - -# 驱动程序导入 -from .drivers import ( - XYZStepperController, - SOPAPipette, - MotorAxis, - MotorStatus, - SOPAConfig, - SOPAStatusCode, - StepperMotorDriver -) - -# 控制器导入 -from .controllers import ( - XYZController, - PipetteController, -) - -# 后端导入 -from .backend.rviz_backend import ( - LiquidHandlerRvizBackend, -) - -# 资源类和创建函数导入 -from .core.laiyu_liquid_res import ( - LaiYuLiquidDeck, - LaiYuLiquidContainer, - LaiYuLiquidTipRack -) - -# 主设备类和配置 -from .core.laiyu_liquid_main import ( - LaiYuLiquid, - LaiYuLiquidConfig, - LaiYuLiquidDeck, - LaiYuLiquidContainer, - LaiYuLiquidTipRack, - create_quick_setup -) - -# 后端创建函数导入 -from .backend import ( - LaiYuLiquidBackend, - create_laiyu_backend, -) - -# 导出所有公共接口 -__all__ = [ - # 版本信息 - "__version__", - "__author__", - "__description__", - - # 驱动程序 - "SOPAPipette", - "SOPAConfig", - "StepperMotorDriver", - "XYZStepperController", - - # 控制器 - "PipetteController", - "XYZController", - - # 后端 - "LiquidHandlerRvizBackend", - - # 资源创建函数 - "create_tip_rack_1000ul", - "create_tip_rack_200ul", - "create_96_well_plate", - "create_deep_well_plate", - "create_8_tube_rack", - "create_standard_deck", - "create_waste_container", - "create_wash_container", - "create_reagent_container", - "load_deck_config", - - # 后端创建函数 - "create_laiyu_backend", - - # 主要类 - "LaiYuLiquid", - "LaiYuLiquidConfig", - "LaiYuLiquidBackend", - "LaiYuLiquidDeck", - - # 工具函数 - "get_version", - "get_supported_resources", - "create_quick_setup", - "validate_installation", - "print_module_info", - "setup_logging", -] - -# 别名定义,为了向后兼容 -LaiYuLiquidDevice = LaiYuLiquid # 主设备类别名 -LaiYuLiquidController = XYZController # 控制器别名 -LaiYuLiquidDriver = XYZStepperController # 驱动器别名 - -# 模块级别的便捷函数 - -def get_version() -> str: - """ - 获取模块版本 - - Returns: - str: 版本号 - """ - return __version__ - - -def get_supported_resources() -> dict: - """ - 获取支持的资源类型 - - Returns: - dict: 支持的资源类型字典 - """ - return { - "tip_racks": { - "LaiYuLiquidTipRack": LaiYuLiquidTipRack, - }, - "containers": { - "LaiYuLiquidContainer": LaiYuLiquidContainer, - }, - "decks": { - "LaiYuLiquidDeck": LaiYuLiquidDeck, - }, - "devices": { - "LaiYuLiquid": LaiYuLiquid, - } - } - - -def create_quick_setup() -> tuple: - """ - 快速创建基本设置 - - Returns: - tuple: (backend, controllers, resources) 的元组 - """ - # 创建后端 - backend = LiquidHandlerRvizBackend() - - # 创建控制器(使用默认端口进行演示) - pipette_controller = PipetteController(port="/dev/ttyUSB0", address=4) - xyz_controller = XYZController(port="/dev/ttyUSB1", auto_connect=False) - - # 创建测试资源 - tip_rack_1000 = create_tip_rack_1000ul("tip_rack_1000") - tip_rack_200 = create_tip_rack_200ul("tip_rack_200") - well_plate = create_96_well_plate("96_well_plate") - - controllers = { - 'pipette': pipette_controller, - 'xyz': xyz_controller - } - - resources = { - 'tip_rack_1000': tip_rack_1000, - 'tip_rack_200': tip_rack_200, - 'well_plate': well_plate - } - - return backend, controllers, resources - - -def validate_installation() -> bool: - """ - 验证模块安装是否正确 - - Returns: - bool: 安装是否正确 - """ - try: - # 检查核心类是否可以导入 - from .core.laiyu_liquid_main import LaiYuLiquid, LaiYuLiquidConfig - from .backend import LaiYuLiquidBackend - from .controllers import XYZController, PipetteController - from .drivers import XYZStepperController, SOPAPipette - - # 尝试创建基本对象 - config = LaiYuLiquidConfig() - backend = create_laiyu_backend("validation_test") - - print("模块安装验证成功") - return True - - except Exception as e: - print(f"模块安装验证失败: {e}") - return False - - -def print_module_info(): - """打印模块信息""" - print(f"LaiYu_Liquid 集成模块") - print(f"版本: {__version__}") - print(f"作者: {__author__}") - print(f"描述: {__description__}") - print(f"") - print(f"支持的资源类型:") - - resources = get_supported_resources() - for category, types in resources.items(): - print(f" {category}:") - for type_name, type_class in types.items(): - print(f" - {type_name}: {type_class.__name__}") - - print(f"") - print(f"主要功能:") - print(f" - 硬件集成: LaiYuLiquidBackend") - print(f" - 抽象接口: LaiYuLiquid") - print(f" - 资源管理: 各种资源类和创建函数") - print(f" - 协议执行: transfer_liquid 和相关函数") - print(f" - 配置管理: deck.json 和加载函数") - - -# 模块初始化时的检查 -def _check_dependencies(): - """检查依赖项""" - try: - import pylabrobot - import asyncio - import json - import logging - return True - except ImportError as e: - import logging - logging.warning(f"缺少依赖项 {e}") - return False - - -# 执行依赖检查 -_dependencies_ok = _check_dependencies() - -if not _dependencies_ok: - import logging - logging.warning("某些依赖项缺失,模块功能可能受限") - - -# 模块级别的日志配置 -import logging - -def setup_logging(level: str = "INFO"): - """ - 设置模块日志 - - Args: - level: 日志级别 (DEBUG, INFO, WARNING, ERROR) - """ - logger = logging.getLogger("LaiYu_Liquid") - logger.setLevel(getattr(logging, level.upper())) - - if not logger.handlers: - handler = logging.StreamHandler() - formatter = logging.Formatter( - '%(asctime)s - %(name)s - %(levelname)s - %(message)s' - ) - handler.setFormatter(formatter) - logger.addHandler(handler) - - return logger - - -# 默认日志设置 -_logger = setup_logging() \ No newline at end of file diff --git a/unilabos/devices/laiyu_liquid/backend/__init__.py b/unilabos/devices/laiyu_liquid/backend/__init__.py deleted file mode 100644 index 4bf2939..0000000 --- a/unilabos/devices/laiyu_liquid/backend/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -""" -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/laiyu_liquid/backend/laiyu_backend.py b/unilabos/devices/laiyu_liquid/backend/laiyu_backend.py deleted file mode 100644 index 5e8041c..0000000 --- a/unilabos/devices/laiyu_liquid/backend/laiyu_backend.py +++ /dev/null @@ -1,334 +0,0 @@ -""" -LaiYu液体处理设备后端实现 - -提供设备的后端接口和控制逻辑 -""" - -import logging -from typing import Dict, Any, Optional, List -from abc import ABC, abstractmethod - -# 尝试导入PyLabRobot后端 -try: - from pylabrobot.liquid_handling.backends import LiquidHandlerBackend - PYLABROBOT_AVAILABLE = True -except ImportError: - PYLABROBOT_AVAILABLE = False - # 创建模拟后端基类 - class LiquidHandlerBackend: - def __init__(self, name: str): - self.name = name - self.is_connected = False - - def connect(self): - """连接设备""" - pass - - def disconnect(self): - """断开连接""" - pass - - -class LaiYuLiquidBackend(LiquidHandlerBackend): - """LaiYu液体处理设备后端""" - - def __init__(self, name: str = "LaiYu_Liquid_Backend"): - """ - 初始化LaiYu液体处理设备后端 - - Args: - name: 后端名称 - """ - if PYLABROBOT_AVAILABLE: - # PyLabRobot 的 LiquidHandlerBackend 不接受参数 - super().__init__() - else: - # 模拟版本接受 name 参数 - super().__init__(name) - - self.name = name - self.logger = logging.getLogger(__name__) - self.is_connected = False - self.device_info = { - "name": "LaiYu液体处理设备", - "version": "1.0.0", - "manufacturer": "LaiYu", - "model": "LaiYu_Liquid_Handler" - } - - def connect(self) -> bool: - """ - 连接到LaiYu液体处理设备 - - Returns: - bool: 连接是否成功 - """ - try: - self.logger.info("正在连接到LaiYu液体处理设备...") - # 这里应该实现实际的设备连接逻辑 - # 目前返回模拟连接成功 - self.is_connected = True - self.logger.info("成功连接到LaiYu液体处理设备") - return True - except Exception as e: - self.logger.error(f"连接LaiYu液体处理设备失败: {e}") - self.is_connected = False - return False - - def disconnect(self) -> bool: - """ - 断开与LaiYu液体处理设备的连接 - - Returns: - bool: 断开连接是否成功 - """ - try: - self.logger.info("正在断开与LaiYu液体处理设备的连接...") - # 这里应该实现实际的设备断开连接逻辑 - self.is_connected = False - self.logger.info("成功断开与LaiYu液体处理设备的连接") - return True - except Exception as e: - self.logger.error(f"断开LaiYu液体处理设备连接失败: {e}") - return False - - def is_device_connected(self) -> bool: - """ - 检查设备是否已连接 - - Returns: - bool: 设备是否已连接 - """ - return self.is_connected - - def get_device_info(self) -> Dict[str, Any]: - """ - 获取设备信息 - - Returns: - Dict[str, Any]: 设备信息字典 - """ - return self.device_info.copy() - - def home_device(self) -> bool: - """ - 设备归零操作 - - Returns: - bool: 归零是否成功 - """ - if not self.is_connected: - self.logger.error("设备未连接,无法执行归零操作") - return False - - try: - self.logger.info("正在执行设备归零操作...") - # 这里应该实现实际的设备归零逻辑 - self.logger.info("设备归零操作完成") - return True - except Exception as e: - self.logger.error(f"设备归零操作失败: {e}") - return False - - def aspirate(self, volume: float, location: Dict[str, Any]) -> bool: - """ - 吸液操作 - - Args: - volume: 吸液体积 (微升) - location: 吸液位置信息 - - Returns: - bool: 吸液是否成功 - """ - if not self.is_connected: - self.logger.error("设备未连接,无法执行吸液操作") - return False - - try: - self.logger.info(f"正在执行吸液操作: 体积={volume}μL, 位置={location}") - # 这里应该实现实际的吸液逻辑 - self.logger.info("吸液操作完成") - return True - except Exception as e: - self.logger.error(f"吸液操作失败: {e}") - return False - - def dispense(self, volume: float, location: Dict[str, Any]) -> bool: - """ - 排液操作 - - Args: - volume: 排液体积 (微升) - location: 排液位置信息 - - Returns: - bool: 排液是否成功 - """ - if not self.is_connected: - self.logger.error("设备未连接,无法执行排液操作") - return False - - try: - self.logger.info(f"正在执行排液操作: 体积={volume}μL, 位置={location}") - # 这里应该实现实际的排液逻辑 - self.logger.info("排液操作完成") - return True - except Exception as e: - self.logger.error(f"排液操作失败: {e}") - return False - - def pick_up_tip(self, location: Dict[str, Any]) -> bool: - """ - 取枪头操作 - - Args: - location: 枪头位置信息 - - Returns: - bool: 取枪头是否成功 - """ - if not self.is_connected: - self.logger.error("设备未连接,无法执行取枪头操作") - return False - - try: - self.logger.info(f"正在执行取枪头操作: 位置={location}") - # 这里应该实现实际的取枪头逻辑 - self.logger.info("取枪头操作完成") - return True - except Exception as e: - self.logger.error(f"取枪头操作失败: {e}") - return False - - def drop_tip(self, location: Dict[str, Any]) -> bool: - """ - 丢弃枪头操作 - - Args: - location: 丢弃位置信息 - - Returns: - bool: 丢弃枪头是否成功 - """ - if not self.is_connected: - self.logger.error("设备未连接,无法执行丢弃枪头操作") - return False - - try: - self.logger.info(f"正在执行丢弃枪头操作: 位置={location}") - # 这里应该实现实际的丢弃枪头逻辑 - self.logger.info("丢弃枪头操作完成") - return True - except Exception as e: - self.logger.error(f"丢弃枪头操作失败: {e}") - return False - - def move_to(self, location: Dict[str, Any]) -> bool: - """ - 移动到指定位置 - - Args: - location: 目标位置信息 - - Returns: - bool: 移动是否成功 - """ - if not self.is_connected: - self.logger.error("设备未连接,无法执行移动操作") - return False - - try: - self.logger.info(f"正在移动到位置: {location}") - # 这里应该实现实际的移动逻辑 - self.logger.info("移动操作完成") - return True - except Exception as e: - self.logger.error(f"移动操作失败: {e}") - return False - - def get_status(self) -> Dict[str, Any]: - """ - 获取设备状态 - - Returns: - Dict[str, Any]: 设备状态信息 - """ - return { - "connected": self.is_connected, - "device_info": self.device_info, - "status": "ready" if self.is_connected else "disconnected" - } - - # PyLabRobot 抽象方法实现 - def stop(self): - """停止所有操作""" - self.logger.info("停止所有操作") - pass - - @property - def num_channels(self) -> int: - """返回通道数量""" - return 1 # 单通道移液器 - - def can_pick_up_tip(self, tip_rack, tip_position) -> bool: - """检查是否可以拾取吸头""" - return True # 简化实现,总是返回True - - def pick_up_tips(self, tip_rack, tip_positions): - """拾取多个吸头""" - self.logger.info(f"拾取吸头: {tip_positions}") - pass - - def drop_tips(self, tip_rack, tip_positions): - """丢弃多个吸头""" - self.logger.info(f"丢弃吸头: {tip_positions}") - pass - - def pick_up_tips96(self, tip_rack): - """拾取96个吸头""" - self.logger.info("拾取96个吸头") - pass - - def drop_tips96(self, tip_rack): - """丢弃96个吸头""" - self.logger.info("丢弃96个吸头") - pass - - def aspirate96(self, volume, plate, well_positions): - """96通道吸液""" - self.logger.info(f"96通道吸液: 体积={volume}") - pass - - def dispense96(self, volume, plate, well_positions): - """96通道排液""" - self.logger.info(f"96通道排液: 体积={volume}") - pass - - def pick_up_resource(self, resource, location): - """拾取资源""" - self.logger.info(f"拾取资源: {resource}") - pass - - def drop_resource(self, resource, location): - """放置资源""" - self.logger.info(f"放置资源: {resource}") - pass - - def move_picked_up_resource(self, resource, location): - """移动已拾取的资源""" - self.logger.info(f"移动资源: {resource} 到 {location}") - pass - - -def create_laiyu_backend(name: str = "LaiYu_Liquid_Backend") -> LaiYuLiquidBackend: - """ - 创建LaiYu液体处理设备后端实例 - - Args: - name: 后端名称 - - Returns: - LaiYuLiquidBackend: 后端实例 - """ - return LaiYuLiquidBackend(name) \ No newline at end of file diff --git a/unilabos/devices/laiyu_liquid/backend/rviz_backend.py b/unilabos/devices/laiyu_liquid/backend/rviz_backend.py deleted file mode 100644 index 44e7be4..0000000 --- a/unilabos/devices/laiyu_liquid/backend/rviz_backend.py +++ /dev/null @@ -1,209 +0,0 @@ - -import json -from typing import List, Optional, Union - -from pylabrobot.liquid_handling.backends.backend import ( - LiquidHandlerBackend, -) -from pylabrobot.liquid_handling.standard import ( - Drop, - DropTipRack, - MultiHeadAspirationContainer, - MultiHeadAspirationPlate, - MultiHeadDispenseContainer, - MultiHeadDispensePlate, - Pickup, - PickupTipRack, - ResourceDrop, - ResourceMove, - ResourcePickup, - SingleChannelAspiration, - SingleChannelDispense, -) -from pylabrobot.resources import Resource, Tip - -import rclpy -from rclpy.node import Node -from sensor_msgs.msg import JointState -import time -from rclpy.action import ActionClient -from unilabos_msgs.action import SendCmd -import re - -from unilabos.devices.ros_dev.liquid_handler_joint_publisher import JointStatePublisher - - -class LiquidHandlerRvizBackend(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): - """Initialize a chatter box backend.""" - super().__init__() - self._num_channels = num_channels -# rclpy.init() - if not rclpy.ok(): - rclpy.init() - self.joint_state_publisher = None - - async def setup(self): - self.joint_state_publisher = JointStatePublisher() - await super().setup() - async def stop(self): - pass - - def serialize(self) -> dict: - return {**super().serialize(), "num_channels": self.num_channels} - - @property - def num_channels(self) -> int: - return self._num_channels - - async def assigned_resource_callback(self, resource: Resource): - pass - - async def unassigned_resource_callback(self, name: str): - pass - - async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int], **backend_kwargs): - - 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:]:<{LiquidHandlerRvizBackend._resource_length}} " - f"{offset:<{LiquidHandlerRvizBackend._offset_length}} " - f"{op.tip.__class__.__name__:<{LiquidHandlerRvizBackend._tip_type_length}} " - f"{op.tip.maximal_volume:<{LiquidHandlerRvizBackend._max_volume_length}} " - f"{op.tip.fitting_depth:<{LiquidHandlerRvizBackend._fitting_depth_length}} " - f"{op.tip.total_tip_length:<{LiquidHandlerRvizBackend._tip_length_length}} " - # f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} " - f"{'Yes' if op.tip.has_filter else 'No':<{LiquidHandlerRvizBackend._filter_length}}" - ) - coordinate = ops[0].resource.get_absolute_location(x="c",y="c") - x = coordinate.x - y = coordinate.y - z = coordinate.z + 70 - self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "pick") - # goback() - - - - - async def drop_tips(self, ops: List[Drop], use_channels: List[int], **backend_kwargs): - - coordinate = ops[0].resource.get_absolute_location(x="c",y="c") - x = coordinate.x - y = coordinate.y - z = coordinate.z + 70 - self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "drop_trash") - # goback() - - async def aspirate( - self, - ops: List[SingleChannelAspiration], - use_channels: List[int], - **backend_kwargs, - ): - # 执行吸液操作 - pass - - 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:<{LiquidHandlerRvizBackend._vol_length}} " - f"{o.resource.name[-20:]:<{LiquidHandlerRvizBackend._resource_length}} " - f"{offset:<{LiquidHandlerRvizBackend._offset_length}} " - f"{str(o.flow_rate):<{LiquidHandlerRvizBackend._flow_rate_length}} " - f"{str(o.blow_out_air_volume):<{LiquidHandlerRvizBackend._blowout_length}} " - f"{str(o.liquid_height):<{LiquidHandlerRvizBackend._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}" - coordinate = ops[0].resource.get_absolute_location(x="c",y="c") - x = coordinate.x - y = coordinate.y - z = coordinate.z + 70 - self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "") - - - async def dispense( - self, - ops: List[SingleChannelDispense], - use_channels: List[int], - **backend_kwargs, - ): - - 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:<{LiquidHandlerRvizBackend._vol_length}} " - f"{o.resource.name[-20:]:<{LiquidHandlerRvizBackend._resource_length}} " - f"{offset:<{LiquidHandlerRvizBackend._offset_length}} " - f"{str(o.flow_rate):<{LiquidHandlerRvizBackend._flow_rate_length}} " - f"{str(o.blow_out_air_volume):<{LiquidHandlerRvizBackend._blowout_length}} " - f"{str(o.liquid_height):<{LiquidHandlerRvizBackend._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:<{LiquidHandlerRvizBackend._kwargs_length}}" - coordinate = ops[0].resource.get_absolute_location(x="c",y="c") - x = coordinate.x - y = coordinate.y - z = coordinate.z + 70 - self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "") - - async def pick_up_tips96(self, pickup: PickupTipRack, **backend_kwargs): - pass - - async def drop_tips96(self, drop: DropTipRack, **backend_kwargs): - pass - - async def aspirate96( - self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer] - ): - pass - - async def dispense96(self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]): - pass - - async def pick_up_resource(self, pickup: ResourcePickup): - # 执行资源拾取操作 - pass - - async def move_picked_up_resource(self, move: ResourceMove): - # 执行资源移动操作 - pass - - async def drop_resource(self, drop: ResourceDrop): - # 执行资源放置操作 - pass - - def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: - return True - \ No newline at end of file diff --git a/unilabos/devices/laiyu_liquid/config/deckconfig.json b/unilabos/devices/laiyu_liquid/config/deckconfig.json deleted file mode 100644 index ddda7e0..0000000 --- a/unilabos/devices/laiyu_liquid/config/deckconfig.json +++ /dev/null @@ -1,2620 +0,0 @@ -{ - "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/laiyu_liquid/config/deckconfig.md b/unilabos/devices/laiyu_liquid/config/deckconfig.md deleted file mode 100644 index 7359e62..0000000 --- a/unilabos/devices/laiyu_liquid/config/deckconfig.md +++ /dev/null @@ -1,14 +0,0 @@ - goto 171 178 57 H1 - goto 171 117 57 A1 - goto 172 178 130 - goto 173 179 133 - goto 173 180 133 -goto 173 180 138 -goto 173 180 125 (+10mm,在空的上面边缘) -goto 173 180 130 取不到 -goto 173 180 133 取不到 -goto 173 180 135 -goto 173 180 137 取到了!!!! -goto 173 180 131 弹出枪头 H1 - -goto 173 117 137 A1 (+10mm,可以取到新枪头了!!!!) \ No newline at end of file diff --git a/unilabos/devices/laiyu_liquid/controllers/__init__.py b/unilabos/devices/laiyu_liquid/controllers/__init__.py deleted file mode 100644 index d50b1ec..0000000 --- a/unilabos/devices/laiyu_liquid/controllers/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -""" -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/laiyu_liquid/controllers/pipette_controller.py b/unilabos/devices/laiyu_liquid/controllers/pipette_controller.py deleted file mode 100644 index 6c314a3..0000000 --- a/unilabos/devices/laiyu_liquid/controllers/pipette_controller.py +++ /dev/null @@ -1,1073 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -移液控制器模块 -封装SOPA移液器的高级控制功能 -""" - -# 添加项目根目录到Python路径以解决模块导入问题 -import sys -import os - -# 无论如何都添加项目根目录到路径 -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.laiyu_liquid.drivers.sopa_pipette_driver import ( - SOPAPipette, - SOPAConfig, - SOPAStatusCode, - DetectionMode, - create_sopa_pipette, -) -from unilabos.devices.laiyu_liquid.drivers.xyz_stepper_driver import ( - XYZStepperController, - MotorAxis, - MotorStatus, - ModbusException -) - -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[XYZStepperController] = None - self.xyz_port = xyz_port - self.xyz_connected = False - - # 统计信息 - 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 = XYZStepperController(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() - 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: - 是否成功 - """ - 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.tip_status = TipStatus.TIP_ATTACHED - self.tip_count += 1 - self.current_volume = 0.0 - logger.info("枪头装载成功") - return True - else: - logger.error("枪头装载失败 - Z轴移动失败") - return False - - def eject_tip(self) -> bool: - """ - 弹出枪头 - - Returns: - 是否成功 - """ - if self.tip_status == TipStatus.NO_TIP: - logger.warning("无枪头可弹出") - return True - - try: - if self.pipette.eject_tip(): - 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: - 是否成功 - """ - 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: - 是否成功 - """ - 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: - """获取状态信息""" - 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/ttyUSB0): ").strip() or "/dev/ttyUSB0" - 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 in ['y', 'yes']: - xyz_port = input("请输入 XYZ 控制器串口端口 (默认: /dev/ttyUSB1): ").strip() or "/dev/ttyUSB1" - - 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/laiyu_liquid/controllers/xyz_controller.py b/unilabos/devices/laiyu_liquid/controllers/xyz_controller.py deleted file mode 100644 index 2526f48..0000000 --- a/unilabos/devices/laiyu_liquid/controllers/xyz_controller.py +++ /dev/null @@ -1,1183 +0,0 @@ -#!/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 - 绝对移动到指定坐标,例: 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 ") - 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() \ No newline at end of file diff --git a/unilabos/devices/laiyu_liquid/core/__init__.py b/unilabos/devices/laiyu_liquid/core/__init__.py deleted file mode 100644 index 87214f8..0000000 --- a/unilabos/devices/laiyu_liquid/core/__init__.py +++ /dev/null @@ -1,44 +0,0 @@ -#!/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_main import ( - LaiYuLiquid, - LaiYuLiquidConfig, - LaiYuLiquidBackend, - LaiYuLiquidDeck, - LaiYuLiquidContainer, - LaiYuLiquidTipRack, - create_quick_setup -) - -from .laiyu_liquid_res import ( - LaiYuLiquidDeck, - LaiYuLiquidContainer, - LaiYuLiquidTipRack -) - -__all__ = [ - # 主设备类 - 'LaiYuLiquid', - 'LaiYuLiquidConfig', - 'LaiYuLiquidBackend', - - # 设备资源 - 'LaiYuLiquidDeck', - 'LaiYuLiquidContainer', - 'LaiYuLiquidTipRack', - - # 工具函数 - 'create_quick_setup' -] \ No newline at end of file diff --git a/unilabos/devices/laiyu_liquid/core/abstract_protocol.py b/unilabos/devices/laiyu_liquid/core/abstract_protocol.py deleted file mode 100644 index 9959c36..0000000 --- a/unilabos/devices/laiyu_liquid/core/abstract_protocol.py +++ /dev/null @@ -1,529 +0,0 @@ -""" -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/laiyu_liquid/core/laiyu_liquid_main.py b/unilabos/devices/laiyu_liquid/core/laiyu_liquid_main.py deleted file mode 100644 index f369a20..0000000 --- a/unilabos/devices/laiyu_liquid/core/laiyu_liquid_main.py +++ /dev/null @@ -1,888 +0,0 @@ -""" -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 - -from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode - -# 基础导入 -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 硬件通信后端""" - - _ros_node: BaseROS2DeviceNode - - 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 post_init(self, ros_node: BaseROS2DeviceNode): - self._ros_node = ros_node - - 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 self._ros_node.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 self._ros_node.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 self._ros_node.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 self._ros_node.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 self._ros_node.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 self._ros_node.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 self._ros_node.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", -] diff --git a/unilabos/devices/laiyu_liquid/core/laiyu_liquid_res.py b/unilabos/devices/laiyu_liquid/core/laiyu_liquid_res.py deleted file mode 100644 index f6adcb1..0000000 --- a/unilabos/devices/laiyu_liquid/core/laiyu_liquid_res.py +++ /dev/null @@ -1,954 +0,0 @@ -""" -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_main 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/laiyu_liquid/docs/CHANGELOG.md b/unilabos/devices/laiyu_liquid/docs/CHANGELOG.md deleted file mode 100644 index a0f2b63..0000000 --- a/unilabos/devices/laiyu_liquid/docs/CHANGELOG.md +++ /dev/null @@ -1,69 +0,0 @@ -# 更新日志 - -本文档记录了 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/laiyu_liquid/docs/hardware/SOPA气动式移液器RS485控制指令.md b/unilabos/devices/laiyu_liquid/docs/hardware/SOPA气动式移液器RS485控制指令.md deleted file mode 100644 index 6db19eb..0000000 --- a/unilabos/devices/laiyu_liquid/docs/hardware/SOPA气动式移液器RS485控制指令.md +++ /dev/null @@ -1,267 +0,0 @@ -# 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/laiyu_liquid/docs/hardware/步进电机控制指令.md b/unilabos/devices/laiyu_liquid/docs/hardware/步进电机控制指令.md deleted file mode 100644 index e701348..0000000 --- a/unilabos/devices/laiyu_liquid/docs/hardware/步进电机控制指令.md +++ /dev/null @@ -1,162 +0,0 @@ -# 步进电机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/laiyu_liquid/docs/hardware/硬件连接配置指南.md b/unilabos/devices/laiyu_liquid/docs/hardware/硬件连接配置指南.md deleted file mode 100644 index 6452909..0000000 --- a/unilabos/devices/laiyu_liquid/docs/hardware/硬件连接配置指南.md +++ /dev/null @@ -1,1281 +0,0 @@ -# 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/laiyu_liquid/docs/readme.md b/unilabos/devices/laiyu_liquid/docs/readme.md deleted file mode 100644 index 4927138..0000000 --- a/unilabos/devices/laiyu_liquid/docs/readme.md +++ /dev/null @@ -1,269 +0,0 @@ -# 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导出和版本管理 - - 依赖检查和安装验证 - - 专业的模块信息展示 - - - -## 🚀 核心功能特性 - -### 液体处理能力 -- **精密体积控制**: 支持1-1000μL精确分配 -- **多种液体类型**: 水性、有机溶剂、粘稠液体等 -- **智能检测**: 液位检测、气泡检测、堵塞检测 -- **自动化流程**: 完整的吸取-转移-分配工作流 - -### 运动控制系统 -- **三轴精密定位**: 微米级精度控制 -- **路径优化**: 智能运动规划和碰撞避免 -- **安全机制**: 限位保护、紧急停止、错误恢复 -- **坐标系管理**: 工作坐标与机械坐标的自动转换 - -### 资源管理 -- **标准化器具**: 支持96孔板、离心管架、枪头架等 -- **状态跟踪**: 实时监控液体体积、枪头状态等 -- **自动配置**: 基于JSON的灵活配置系统 -- **扩展性**: 易于添加新的器具类型 - -## 📁 目录结构 - -``` -LaiYu_Liquid/ -├── __init__.py # 模块初始化和API导出 -├── readme.md # 本文档 -├── 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设计 - - diff --git a/unilabos/devices/laiyu_liquid/drivers/__init__.py b/unilabos/devices/laiyu_liquid/drivers/__init__.py deleted file mode 100644 index cedd47a..0000000 --- a/unilabos/devices/laiyu_liquid/drivers/__init__.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -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/laiyu_liquid/drivers/sopa_pipette_driver.py b/unilabos/devices/laiyu_liquid/drivers/sopa_pipette_driver.py deleted file mode 100644 index 3cfed55..0000000 --- a/unilabos/devices/laiyu_liquid/drivers/sopa_pipette_driver.py +++ /dev/null @@ -1,1079 +0,0 @@ -#!/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: - # 解析响应中的枪头状态 - status_char = response[10] if len(response) > 10 else '0' - self._tip_present = (status_char == '1') - return self._tip_present - 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/laiyu_liquid/drivers/xyz_stepper_driver.py b/unilabos/devices/laiyu_liquid/drivers/xyz_stepper_driver.py deleted file mode 100644 index 146cbb4..0000000 --- a/unilabos/devices/laiyu_liquid/drivers/xyz_stepper_driver.py +++ /dev/null @@ -1,663 +0,0 @@ -#!/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("CRC校验失败") - - 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/laiyu_liquid/tests/__init__.py b/unilabos/devices/laiyu_liquid/tests/__init__.py deleted file mode 100644 index 7ff58fe..0000000 --- a/unilabos/devices/laiyu_liquid/tests/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -#!/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/laiyu_liquid/tests/test_deck_config.py b/unilabos/devices/laiyu_liquid/tests/test_deck_config.py deleted file mode 100644 index 0468830..0000000 --- a/unilabos/devices/laiyu_liquid/tests/test_deck_config.py +++ /dev/null @@ -1,315 +0,0 @@ -#!/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/laiyu_liquid_test/__init__.py b/unilabos/devices/laiyu_liquid_test/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/unilabos/devices/laiyu_liquid_test/driver_enable_move_test.py b/unilabos/devices/laiyu_liquid_test/driver_enable_move_test.py deleted file mode 100644 index a3c5797..0000000 --- a/unilabos/devices/laiyu_liquid_test/driver_enable_move_test.py +++ /dev/null @@ -1,138 +0,0 @@ - -import os -import time -import json -import logging -from xyz_stepper_driver import ModbusRTUTransport, ModbusClient, XYZStepperController, MotorStatus - -# ========== 日志配置 ========== -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger("XYZ_Debug") - - -def create_controller(port: str = "/dev/ttyUSB1", baudrate: int = 115200) -> XYZStepperController: - """ - 初始化通信层与三轴控制器 - """ - logger.info(f"🔧 初始化控制器: {port} @ {baudrate}bps") - transport = ModbusRTUTransport(port=port, baudrate=baudrate) - transport.open() - client = ModbusClient(transport) - return XYZStepperController(client=client, port=port, baudrate=baudrate) - - -def load_existing_soft_zero(ctrl: XYZStepperController, path: str = "work_origin.json") -> bool: - """ - 如果已存在软零点文件则加载,否则返回 False - """ - if not os.path.exists(path): - logger.warning("⚠ 未找到已有软零点文件,将等待人工定义新零点。") - return False - - try: - with open(path, "r", encoding="utf-8") as f: - data = json.load(f) - origin = data.get("work_origin_steps", {}) - ctrl.work_origin_steps = origin - ctrl.is_homed = True - logger.info(f"✔ 已加载软零点文件:{path}") - logger.info(f"当前软零点步数: {origin}") - return True - except Exception as e: - logger.error(f"读取软零点文件失败: {e}") - return False - - -def test_enable_axis(ctrl: XYZStepperController): - """ - 依次使能 X / Y / Z 三轴 - """ - logger.info("=== 测试各轴使能 ===") - for axis in ["X", "Y", "Z"]: - try: - result = ctrl.enable(axis, True) - if result: - vals = ctrl.get_status(axis) - st = MotorStatus(vals[3]) - logger.info(f"{axis} 轴使能成功,当前状态: {st.name}") - else: - logger.error(f"{axis} 轴使能失败") - except Exception as e: - logger.error(f"{axis} 轴使能异常: {e}") - time.sleep(0.5) - - -def test_status_read(ctrl: XYZStepperController): - """ - 读取各轴当前状态(调试) - """ - logger.info("=== 当前各轴状态 ===") - for axis in ["X", "Y", "Z"]: - try: - vals = ctrl.get_status(axis) - st = MotorStatus(vals[3]) - logger.info( - f"{axis}: steps={vals[0]}, speed={vals[1]}, " - f"current={vals[2]}, status={st.name}" - ) - except Exception as e: - logger.error(f"获取 {axis} 状态失败: {e}") - time.sleep(0.2) - - -def redefine_soft_zero(ctrl: XYZStepperController): - """ - 手动重新定义软零点 - """ - logger.info("=== ⚙️ 重新定义软零点 ===") - ctrl.define_current_as_zero("work_origin.json") - logger.info("✅ 新软零点已写入 work_origin.json") - - -def test_soft_zero_move(ctrl: XYZStepperController): - """ - 以软零点为基准执行三轴运动测试 - """ - logger.info("=== 测试软零点相对运动 ===") - ctrl.move_xyz_work(x=100.0, y=100.0, z=40.0, speed=100, acc=800) - - for axis in ["X", "Y", "Z"]: - ctrl.wait_complete(axis) - - test_status_read(ctrl) - logger.info("✅ 软零点运动测试完成") - - -def main(): - ctrl = create_controller(port="/dev/ttyUSB1", baudrate=115200) - - try: - test_enable_axis(ctrl) - test_status_read(ctrl) - - # === 初始化或加载软零点 === - loaded = load_existing_soft_zero(ctrl) - if not loaded: - logger.info("👣 首次运行,定义软零点并保存。") - ctrl.define_current_as_zero("work_origin.json") - - # === 软零点回归动作 === - ctrl.return_to_work_origin() - - # === 可选软零点运动测试 === - # test_soft_zero_move(ctrl) - - except KeyboardInterrupt: - logger.info("🛑 手动中断退出") - - except Exception as e: - logger.exception(f"❌ 调试出错: {e}") - - finally: - if hasattr(ctrl.client, "transport"): - ctrl.client.transport.close() - logger.info("串口已安全关闭 ✅") - - -if __name__ == "__main__": - main() diff --git a/unilabos/devices/laiyu_liquid_test/driver_status_test.py b/unilabos/devices/laiyu_liquid_test/driver_status_test.py deleted file mode 100644 index f6960d5..0000000 --- a/unilabos/devices/laiyu_liquid_test/driver_status_test.py +++ /dev/null @@ -1,58 +0,0 @@ - -import logging -from xyz_stepper_driver import ( - ModbusRTUTransport, - ModbusClient, - XYZStepperController, - MotorAxis, -) - -logger = logging.getLogger("XYZStepperCommTest") -logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") - - -def test_xyz_stepper_comm(): - """仅测试 Modbus 通信是否正常(并输出寄存器数据,不做电机运动)""" - port = "/dev/ttyUSB1" - baudrate = 115200 - timeout = 1.2 # 略长避免响应被截断 - - logger.info(f"尝试连接 Modbus 设备 {port} ...") - transport = ModbusRTUTransport(port, baudrate=baudrate, timeout=timeout) - transport.open() - - client = ModbusClient(transport) - ctrl = XYZStepperController(client) - - try: - logger.info("✅ 串口已打开,开始读取三个轴状态(打印寄存器内容) ...") - for axis in [MotorAxis.X, MotorAxis.Y, MotorAxis.Z]: - addr = ctrl.axis_addr[axis] - - try: - # # 在 get_status 前打印原始寄存器内容 - # regs = client.read_registers(addr, ctrl.REG_STATUS, 6) - # hex_regs = [f"0x{val:04X}" for val in regs] - # logger.info(f"[{axis.name}] 原始寄存器 ({len(regs)} 个): {regs} -> {hex_regs}") - - # 调用 get_status() 正常解析 - status = ctrl.get_status(axis) - logger.info( - f"[{axis.name}] ✅ 通信正常: steps={status.steps}, speed={status.speed}, " - f"current={status.current}, status={status.status.name}" - ) - - except Exception as e_axis: - logger.error(f"[{axis.name}] ❌ 通信失败: {e_axis}") - - - except Exception as e: - logger.error(f"❌ 通讯测试失败: {e}") - - finally: - transport.close() - logger.info("🔌 串口已关闭") - - -if __name__ == "__main__": - test_xyz_stepper_comm() diff --git a/unilabos/devices/laiyu_liquid_test/work_origin.json b/unilabos/devices/laiyu_liquid_test/work_origin.json deleted file mode 100644 index 935c3e3..0000000 --- a/unilabos/devices/laiyu_liquid_test/work_origin.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "work_origin_steps": { - "x": 11799, - "y": 11476, - "z": 3312 - }, - "timestamp": "2025-11-04T15:31:09.802155" -} \ No newline at end of file diff --git a/unilabos/devices/laiyu_liquid_test/xyz_stepper_driver.py b/unilabos/devices/laiyu_liquid_test/xyz_stepper_driver.py deleted file mode 100644 index 6ad37ed..0000000 --- a/unilabos/devices/laiyu_liquid_test/xyz_stepper_driver.py +++ /dev/null @@ -1,336 +0,0 @@ - -""" -XYZ 三轴步进电机驱动(统一字符串参数版) -基于 Modbus RTU 协议 -Author: Xiuyu Chen (Modified by Assistant) -""" - -import serial # type: ignore -import struct -import time -import logging -from enum import Enum -from dataclasses import dataclass -from typing import Optional, List, Dict - -# ========== 日志配置 ========== -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger("XYZStepper") - - -# ========== 层 1:Modbus RTU ========== -class ModbusException(Exception): - pass - - -class ModbusRTUTransport: - """底层串口通信层""" - - def __init__(self, port: str, baudrate: int = 115200, timeout: float = 1.2): - self.port = port - self.baudrate = baudrate - self.timeout = timeout - self.ser: Optional[serial.Serial] = None - - def open(self): - try: - self.ser = serial.Serial( - port=self.port, - baudrate=self.baudrate, - bytesize=serial.EIGHTBITS, - parity=serial.PARITY_NONE, - stopbits=serial.STOPBITS_ONE, - timeout=0.02, - write_timeout=0.5, - ) - logger.info(f"[RTU] 串口连接成功: {self.port}") - except Exception as e: - raise ModbusException(f"无法打开串口 {self.port}: {e}") - - def close(self): - if self.ser and self.ser.is_open: - self.ser.close() - logger.info("[RTU] 串口已关闭") - - def send(self, frame: bytes): - if not self.ser or not self.ser.is_open: - raise ModbusException("串口未连接") - - self.ser.reset_input_buffer() - self.ser.write(frame) - self.ser.flush() - logger.debug(f"[TX] {frame.hex(' ').upper()}") - - def receive(self, expected_len: int) -> bytes: - if not self.ser or not self.ser.is_open: - raise ModbusException("串口未连接") - - start = time.time() - buf = bytearray() - while len(buf) < expected_len and (time.time() - start) < self.timeout: - chunk = self.ser.read(expected_len - len(buf)) - if chunk: - buf.extend(chunk) - else: - time.sleep(0.01) - return bytes(buf) - - -# ========== 层 2:Modbus 协议 ========== -class ModbusFunction(Enum): - READ_HOLDING_REGISTERS = 0x03 - WRITE_SINGLE_REGISTER = 0x06 - WRITE_MULTIPLE_REGISTERS = 0x10 - - -class ModbusClient: - """Modbus RTU 客户端""" - - def __init__(self, transport: ModbusRTUTransport): - self.transport = transport - - @staticmethod - def calc_crc(data: bytes) -> bytes: - crc = 0xFFFF - for b in data: - crc ^= b - for _ in range(8): - crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1 - return struct.pack(" bytes: - frame = bytes([addr, func]) + payload - full = frame + self.calc_crc(frame) - self.transport.send(full) - time.sleep(0.01) - resp = self.transport.ser.read(256) - if not resp: - raise ModbusException("未收到响应") - - start = resp.find(bytes([addr, func])) - if start > 0: - resp = resp[start:] - if len(resp) < 5: - raise ModbusException(f"响应长度不足: {resp.hex(' ').upper()}") - if self.calc_crc(resp[:-2]) != resp[-2:]: - raise ModbusException("CRC 校验失败") - return resp - - def read_registers(self, addr: int, start: int, count: int) -> List[int]: - payload = struct.pack(">HH", start, count) - resp = self.send_request(addr, ModbusFunction.READ_HOLDING_REGISTERS.value, payload) - byte_count = resp[2] - regs = [struct.unpack(">H", resp[3 + i:5 + i])[0] for i in range(0, byte_count, 2)] - return regs - - def write_single_register(self, addr: int, reg: int, val: int) -> bool: - payload = struct.pack(">HH", reg, val) - resp = self.send_request(addr, ModbusFunction.WRITE_SINGLE_REGISTER.value, payload) - return resp[1] == ModbusFunction.WRITE_SINGLE_REGISTER.value - - def write_multiple_registers(self, addr: int, start: int, values: List[int]) -> bool: - byte_count = len(values) * 2 - payload = struct.pack(">HHB", start, len(values), byte_count) - payload += b"".join(struct.pack(">H", v & 0xFFFF) for v in values) - resp = self.send_request(addr, ModbusFunction.WRITE_MULTIPLE_REGISTERS.value, payload) - return resp[1] == ModbusFunction.WRITE_MULTIPLE_REGISTERS.value - - -# ========== 层 3:业务逻辑 ========== -class MotorAxis(Enum): - X = 1 - Y = 2 - Z = 3 - - -class MotorStatus(Enum): - STANDBY = 0 - RUNNING = 1 - COLLISION_STOP = 2 - FORWARD_LIMIT_STOP = 3 - REVERSE_LIMIT_STOP = 4 - - -@dataclass -class MotorPosition: - steps: int - speed: int - current: int - status: MotorStatus - - -class XYZStepperController: - """XYZ 三轴步进控制器(字符串接口版)""" - - STEPS_PER_REV = 16384 - LEAD_MM_X, LEAD_MM_Y, LEAD_MM_Z = 80.0, 80.0, 5.0 - STEPS_PER_MM_X = STEPS_PER_REV / LEAD_MM_X - STEPS_PER_MM_Y = STEPS_PER_REV / LEAD_MM_Y - STEPS_PER_MM_Z = STEPS_PER_REV / LEAD_MM_Z - - REG_STATUS, REG_POS_HIGH, REG_POS_LOW = 0x00, 0x01, 0x02 - REG_ACTUAL_SPEED, REG_CURRENT, REG_ENABLE = 0x03, 0x05, 0x06 - REG_ZERO_CMD, REG_TARGET_HIGH, REG_TARGET_LOW = 0x0F, 0x10, 0x11 - REG_SPEED, REG_ACCEL, REG_PRECISION, REG_START = 0x13, 0x14, 0x15, 0x16 - REG_COMMAND = 0x60 - - def __init__(self, client: Optional[ModbusClient] = None, - port="/dev/ttyUSB0", baudrate=115200, - origin_path="unilabos/devices/laiyu_liquid_test/work_origin.json"): - if client is None: - transport = ModbusRTUTransport(port, baudrate) - transport.open() - self.client = ModbusClient(transport) - else: - self.client = client - - self.axis_addr = {MotorAxis.X: 1, MotorAxis.Y: 2, MotorAxis.Z: 3} - self.work_origin_steps = {"x": 0, "y": 0, "z": 0} - self.is_homed = False - self._load_work_origin(origin_path) - - # ========== 基础工具 ========== - @staticmethod - def s16(v: int) -> int: - return v - 0x10000 if v & 0x8000 else v - - @staticmethod - def s32(h: int, l: int) -> int: - v = (h << 16) | l - return v - 0x100000000 if v & 0x80000000 else v - - @classmethod - def mm_to_steps(cls, axis: str, mm: float = 0.0) -> int: - axis = axis.upper() - if axis == "X": - return int(mm * cls.STEPS_PER_MM_X) - elif axis == "Y": - return int(mm * cls.STEPS_PER_MM_Y) - elif axis == "Z": - return int(mm * cls.STEPS_PER_MM_Z) - raise ValueError(f"未知轴: {axis}") - - @classmethod - def steps_to_mm(cls, axis: str, steps: int) -> float: - axis = axis.upper() - if axis == "X": - return steps / cls.STEPS_PER_MM_X - elif axis == "Y": - return steps / cls.STEPS_PER_MM_Y - elif axis == "Z": - return steps / cls.STEPS_PER_MM_Z - raise ValueError(f"未知轴: {axis}") - - # ========== 状态与控制 ========== - def get_status(self, axis: str = "Z") -> list: - """返回简化数组格式: [steps, speed, current, status_value]""" - if isinstance(axis, MotorAxis): - axis_enum = axis - elif isinstance(axis, str): - axis_enum = MotorAxis[axis.upper()] - else: - raise TypeError("axis 参数必须为 str 或 MotorAxis") - - vals = self.client.read_registers(self.axis_addr[axis_enum], self.REG_STATUS, 6) - return [ - self.s32(vals[1], vals[2]), - self.s16(vals[3]), - vals[4], - int(MotorStatus(vals[0]).value) - ] - - def enable(self, axis: str, state: bool) -> bool: - a = MotorAxis[axis.upper()] - return self.client.write_single_register(self.axis_addr[a], self.REG_ENABLE, 1 if state else 0) - - def wait_complete(self, axis: str, timeout=30.0) -> bool: - a = axis.upper() - start = time.time() - while time.time() - start < timeout: - vals = self.get_status(a) - st = MotorStatus(vals[3]) # 第4个元素是状态值 - if st == MotorStatus.STANDBY: - return True - if st in (MotorStatus.COLLISION_STOP, MotorStatus.FORWARD_LIMIT_STOP, MotorStatus.REVERSE_LIMIT_STOP): - logger.warning(f"{a} 轴异常停止: {st.name}") - return False - time.sleep(0.1) - logger.warning(f"{a} 轴运动超时") - return False - - # ========== 控制命令 ========== - def move_to(self, axis: str, steps: int, speed: int = 2000, acc: int = 500, precision: int = 50) -> bool: - a = MotorAxis[axis.upper()] - addr = self.axis_addr[a] - hi, lo = (steps >> 16) & 0xFFFF, steps & 0xFFFF - values = [hi, lo, speed, acc, precision] - ok = self.client.write_multiple_registers(addr, self.REG_TARGET_HIGH, values) - if ok: - self.client.write_single_register(addr, self.REG_START, 1) - return ok - - def move_xyz_work(self, x: float = 0.0, y: float = 0.0, z: float = 0.0, speed: int = 100, acc: int = 1500): - logger.info("🧭 执行安全多轴运动:Z→XY→Z") - if z is not None: - safe_z = self._to_machine_steps("Z", 0.0) - self.move_to("Z", safe_z, speed, acc) - self.wait_complete("Z") - - if x is not None or y is not None: - if x is not None: - self.move_to("X", self._to_machine_steps("X", x), speed, acc) - if y is not None: - self.move_to("Y", self._to_machine_steps("Y", y), speed, acc) - if x is not None: - self.wait_complete("X") - if y is not None: - self.wait_complete("Y") - - if z is not None: - self.move_to("Z", self._to_machine_steps("Z", z), speed, acc) - self.wait_complete("Z") - logger.info("✅ 多轴顺序运动完成") - - # ========== 坐标与零点 ========== - def _to_machine_steps(self, axis: str, mm: float) -> int: - base = self.work_origin_steps.get(axis.lower(), 0) - return base + self.mm_to_steps(axis, mm) - - def define_current_as_zero(self, save_path="work_origin.json"): - import json - from datetime import datetime - - origin = {} - for axis in ["X", "Y", "Z"]: - vals = self.get_status(axis) - origin[axis.lower()] = int(vals[0]) # 第1个是步数 - with open(save_path, "w", encoding="utf-8") as f: - json.dump({"work_origin_steps": origin, "timestamp": datetime.now().isoformat()}, f, indent=2) - self.work_origin_steps = origin - self.is_homed = True - logger.info(f"✅ 零点已定义并保存至 {save_path}") - - def _load_work_origin(self, path: str) -> bool: - import json, os - - if not os.path.exists(path): - logger.warning("⚠️ 未找到软零点文件") - return False - with open(path, "r", encoding="utf-8") as f: - data = json.load(f) - self.work_origin_steps = data.get("work_origin_steps", {"x": 0, "y": 0, "z": 0}) - self.is_homed = True - logger.info(f"📂 软零点已加载: {self.work_origin_steps}") - return True - - def return_to_work_origin(self, speed: int = 200, acc: int = 800): - logger.info("🏁 回工件软零点") - self.move_to("Z", self._to_machine_steps("Z", 0.0), speed, acc) - self.wait_complete("Z") - self.move_to("X", self.work_origin_steps.get("x", 0), speed, acc) - self.move_to("Y", self.work_origin_steps.get("y", 0), speed, acc) - self.wait_complete("X") - self.wait_complete("Y") - self.move_to("Z", self.work_origin_steps.get("z", 0), speed, acc) - self.wait_complete("Z") - logger.info("🎯 回软零点完成 ✅") diff --git a/unilabos/devices/liquid_handling/biomek.py b/unilabos/devices/liquid_handling/biomek.py index 3fe3049..7fbafd7 100644 --- a/unilabos/devices/liquid_handling/biomek.py +++ b/unilabos/devices/liquid_handling/biomek.py @@ -13,7 +13,7 @@ from pylabrobot.resources import ( import copy from unilabos_msgs.msg import Resource -from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker # type: ignore +from unilabos.resources.resource_tracker import DeviceNodeResourceTracker # type: ignore class LiquidHandlerBiomek: diff --git a/unilabos/devices/liquid_handling/laiyu/backend/laiyu_v_backend.py b/unilabos/devices/liquid_handling/laiyu/backend/laiyu_v_backend.py index d5636b2..9e824e1 100644 --- a/unilabos/devices/liquid_handling/laiyu/backend/laiyu_v_backend.py +++ b/unilabos/devices/liquid_handling/laiyu/backend/laiyu_v_backend.py @@ -153,7 +153,7 @@ class UniLiquidHandlerLaiyuBackend(LiquidHandlerBackend): 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(x=x, y=-y, z=z,speed=200) self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height,speed=100) # self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "pick",channels=use_channels) # goback() @@ -202,7 +202,7 @@ class UniLiquidHandlerLaiyuBackend(LiquidHandlerBackend): 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.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200) self.hardware_interface.eject_tip self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height) @@ -267,7 +267,7 @@ class UniLiquidHandlerLaiyuBackend(LiquidHandlerBackend): return # 移动到吸液位置 - self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z) + self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200) self.pipette_aspirate(volume=ops[0].volume, flow_rate=flow_rate) @@ -340,7 +340,7 @@ class UniLiquidHandlerLaiyuBackend(LiquidHandlerBackend): # 移动到排液位置 - self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z) + self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200) self.pipette_dispense(volume=ops[0].volume, flow_rate=flow_rate) diff --git a/unilabos/devices/liquid_handling/laiyu/controllers/pipette_controller.py b/unilabos/devices/liquid_handling/laiyu/controllers/pipette_controller.py index e6ddd4f..17a47df 100644 --- a/unilabos/devices/liquid_handling/laiyu/controllers/pipette_controller.py +++ b/unilabos/devices/liquid_handling/laiyu/controllers/pipette_controller.py @@ -128,6 +128,7 @@ class PipetteController: baudrate=115200 ) self.pipette = SOPAPipette(self.config) + self.pipette_port = port self.tip_status = TipStatus.NO_TIP self.current_volume = 0.0 self.max_volume = 1000.0 # 默认1000ul @@ -154,7 +155,7 @@ class PipetteController: logger.info("移液器连接成功") # 连接XYZ步进电机控制器(如果提供了端口) - if self.xyz_port: + if self.xyz_port != self.pipette_port: try: self.xyz_controller = XYZController(self.xyz_port) if self.xyz_controller.connect(): @@ -168,7 +169,12 @@ class PipetteController: self.xyz_controller = None self.xyz_connected = False else: - logger.info("未配置XYZ步进电机端口,跳过运动控制器连接") + try: + self.xyz_controller = XYZController(self.xyz_port, auto_connect=False) + self.xyz_controller.serial_conn = self.pipette.serial_port + self.xyz_controller.is_connected = True + except Exception as e: + logger.info("未配置XYZ步进电机端口,跳过运动控制器连接") return True except Exception as e: diff --git a/unilabos/devices/liquid_handling/liquid_handler_abstract.py b/unilabos/devices/liquid_handling/liquid_handler_abstract.py index e849ffb..d02129c 100644 --- a/unilabos/devices/liquid_handling/liquid_handler_abstract.py +++ b/unilabos/devices/liquid_handling/liquid_handler_abstract.py @@ -6,6 +6,7 @@ import traceback from collections import Counter from typing import List, Sequence, Optional, Literal, Union, Iterator, Dict, Any, Callable, Set, cast +from typing_extensions import TypedDict 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 @@ -28,12 +29,15 @@ from pylabrobot.resources import ( ) from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode - +class SimpleReturn(TypedDict): + samples: list + volumes: list class LiquidHandlerMiddleware(LiquidHandler): def __init__(self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool = False, channel_num: int = 8, **kwargs): self._simulator = simulator self.channel_num = channel_num + self.pending_liquids_dict = {} joint_config = kwargs.get("joint_config", None) if simulator: if joint_config: @@ -131,7 +135,9 @@ class LiquidHandlerMiddleware(LiquidHandler): return await self._simulate_handler.drop_tips( tip_spots, use_channels, offsets, allow_nonzero_volume, **backend_kwargs ) - return await super().drop_tips(tip_spots, use_channels, offsets, allow_nonzero_volume, **backend_kwargs) + await super().drop_tips(tip_spots, use_channels, offsets, allow_nonzero_volume, **backend_kwargs) + self.pending_liquids_dict = {} + return async def return_tips( self, use_channels: Optional[list[int]] = None, allow_nonzero_volume: bool = False, **backend_kwargs @@ -154,8 +160,10 @@ class LiquidHandlerMiddleware(LiquidHandler): offsets = [Coordinate.zero()] * len(use_channels) if self._simulator: return await self._simulate_handler.discard_tips(use_channels, allow_nonzero_volume, offsets, **backend_kwargs) - return await super().discard_tips(use_channels, allow_nonzero_volume, offsets, **backend_kwargs) - + await super().discard_tips(use_channels, allow_nonzero_volume, offsets, **backend_kwargs) + self.pending_liquids_dict = {} + return + def _check_containers(self, resources: Sequence[Resource]): super()._check_containers(resources) @@ -171,6 +179,8 @@ class LiquidHandlerMiddleware(LiquidHandler): spread: Literal["wide", "tight", "custom"] = "wide", **backend_kwargs, ): + + if self._simulator: return await self._simulate_handler.aspirate( resources, @@ -183,7 +193,7 @@ class LiquidHandlerMiddleware(LiquidHandler): spread, **backend_kwargs, ) - return await super().aspirate( + await super().aspirate( resources, vols, use_channels, @@ -195,6 +205,18 @@ class LiquidHandlerMiddleware(LiquidHandler): **backend_kwargs, ) + res_samples = [] + res_volumes = [] + for resource, volume, channel in zip(resources, vols, use_channels): + res_samples.append({"name": resource.name, "sample_uuid": resource.unilabos_extra.get("sample_uuid", None)}) + res_volumes.append(volume) + self.pending_liquids_dict[channel] = { + "sample_uuid": resource.unilabos_extra.get("sample_uuid", None), + "volume": volume + } + return SimpleReturn(samples=res_samples, volumes=res_volumes) + + async def dispense( self, resources: Sequence[Container], @@ -206,7 +228,7 @@ class LiquidHandlerMiddleware(LiquidHandler): blow_out_air_volume: Optional[List[Optional[float]]] = None, spread: Literal["wide", "tight", "custom"] = "wide", **backend_kwargs, - ): + ) -> SimpleReturn: if self._simulator: return await self._simulate_handler.dispense( resources, @@ -219,7 +241,7 @@ class LiquidHandlerMiddleware(LiquidHandler): spread, **backend_kwargs, ) - return await super().dispense( + await super().dispense( resources, vols, use_channels, @@ -229,7 +251,17 @@ class LiquidHandlerMiddleware(LiquidHandler): blow_out_air_volume, **backend_kwargs, ) + res_samples = [] + res_volumes = [] + for resource, volume, channel in zip(resources, vols, use_channels): + res_uuid = self.pending_liquids_dict[channel]["sample_uuid"] + self.pending_liquids_dict[channel]["volume"] -= volume + resource.unilabos_extra["sample_uuid"] = res_uuid + res_samples.append({"name": resource.name, "sample_uuid": res_uuid}) + res_volumes.append(volume) + return SimpleReturn(samples=res_samples, volumes=res_volumes) + async def transfer( self, source: Well, @@ -549,25 +581,66 @@ 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): """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) def post_init(self, ros_node: BaseROS2DeviceNode): self._ros_node = ros_node @classmethod - def set_liquid(cls, wells: list[Well], liquid_names: list[str], volumes: list[float]): + def set_liquid(cls, wells: list[Well], liquid_names: list[str], volumes: list[float]) -> SimpleReturn: """Set the liquid in a well.""" + res_samples = [] + res_volumes = [] for well, liquid_name, volume in zip(wells, liquid_names, volumes): well.set_liquids([(liquid_name, volume)]) # type: ignore + res_samples.append({"name": well.name, "sample_uuid": well.unilabos_extra.get("sample_uuid", None)}) + res_volumes.append(volume) + + return SimpleReturn(samples=res_samples, volumes=res_volumes) # --------------------------------------------------------------- # REMOVE LIQUID -------------------------------------------------- # --------------------------------------------------------------- @@ -969,11 +1042,19 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): One or more TipRacks providing fresh tips. is_96_well Set *True* to use the 96‑channel head. + mix_stage + When to mix the target wells relative to dispensing. Default "none" means + no mixing occurs even if mix_times is provided. Use "before", "after", or + "both" to mix at the corresponding stage(s). + mix_times + Number of mix cycles. If *None* (default) no mixing occurs regardless of + mix_stage. """ # 确保 use_channels 有默认值 if use_channels is None: - use_channels = [0] if self.channel_num >= 1 else list(range(self.channel_num)) + # 默认使用设备所有通道(例如 8 通道移液站默认就是 0-7) + use_channels = list(range(self.channel_num)) if self.channel_num > 0 else [0] if is_96_well: pass # This mode is not verified. @@ -1001,42 +1082,42 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): if mix_times is not None: mix_times = int(mix_times) - # 识别传输模式 - num_sources = len(sources) - num_targets = len(targets) - - if num_sources == 1 and num_targets > 1: - # 模式1: 一对多 (1 source -> N targets) - await self._transfer_one_to_many( - sources[0], targets, tip_racks, use_channels, - asp_vols, dis_vols, asp_flow_rates, dis_flow_rates, - offsets, touch_tip, liquid_height, blow_out_air_volume, - spread, mix_stage, mix_times, mix_vol, mix_rate, - mix_liquid_height, delays - ) - elif num_sources > 1 and num_targets == 1: - # 模式2: 多对一 (N sources -> 1 target) - await self._transfer_many_to_one( - sources, targets[0], tip_racks, use_channels, - asp_vols, dis_vols, asp_flow_rates, dis_flow_rates, - offsets, touch_tip, liquid_height, blow_out_air_volume, - spread, mix_stage, mix_times, mix_vol, mix_rate, - mix_liquid_height, delays - ) - elif num_sources == num_targets: - # 模式3: 一对一 (N sources -> N targets) - 原有逻辑 - await self._transfer_one_to_one( - sources, targets, tip_racks, use_channels, - asp_vols, dis_vols, asp_flow_rates, dis_flow_rates, - offsets, touch_tip, liquid_height, blow_out_air_volume, - spread, mix_stage, mix_times, mix_vol, mix_rate, - mix_liquid_height, delays - ) - else: - raise ValueError( - f"Unsupported transfer mode: {num_sources} sources -> {num_targets} targets. " - "Supported modes: 1->N, N->1, or N->N." - ) + # 识别传输模式(mix_times 为 None 也应该能正常移液,只是不做 mix) + num_sources = len(sources) + num_targets = len(targets) + + if num_sources == 1 and num_targets > 1: + # 模式1: 一对多 (1 source -> N targets) + await self._transfer_one_to_many( + sources[0], targets, tip_racks, use_channels, + asp_vols, dis_vols, asp_flow_rates, dis_flow_rates, + offsets, touch_tip, liquid_height, blow_out_air_volume, + spread, mix_stage, mix_times, mix_vol, mix_rate, + mix_liquid_height, delays + ) + elif num_sources > 1 and num_targets == 1: + # 模式2: 多对一 (N sources -> 1 target) + await self._transfer_many_to_one( + sources, targets[0], tip_racks, use_channels, + asp_vols, dis_vols, asp_flow_rates, dis_flow_rates, + offsets, touch_tip, liquid_height, blow_out_air_volume, + spread, mix_stage, mix_times, mix_vol, mix_rate, + mix_liquid_height, delays + ) + elif num_sources == num_targets: + # 模式3: 一对一 (N sources -> N targets) + await self._transfer_one_to_one( + sources, targets, tip_racks, use_channels, + asp_vols, dis_vols, asp_flow_rates, dis_flow_rates, + offsets, touch_tip, liquid_height, blow_out_air_volume, + spread, mix_stage, mix_times, mix_vol, mix_rate, + mix_liquid_height, delays + ) + else: + raise ValueError( + f"Unsupported transfer mode: {num_sources} sources -> {num_targets} targets. " + "Supported modes: 1->N, N->1, or N->N." + ) async def _transfer_one_to_one( self, @@ -1076,6 +1157,16 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): tip.extend(next(self.current_tip)) await self.pick_up_tips(tip) + if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0: + await self.mix( + targets=[targets[_]], + mix_time=mix_times, + mix_vol=mix_vol, + offsets=offsets if offsets else None, + height_to_bottom=mix_liquid_height if mix_liquid_height else None, + mix_rate=mix_rate if mix_rate else None, + ) + await self.aspirate( resources=[sources[_]], vols=[asp_vols[_]], @@ -1136,6 +1227,16 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): current_dis_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8 current_dis_flow_rates = dis_flow_rates[i:i + 8] if dis_flow_rates else None + if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0: + await self.mix( + targets=current_targets, + mix_time=mix_times, + mix_vol=mix_vol, + offsets=offsets if offsets else None, + height_to_bottom=mix_liquid_height if mix_liquid_height else None, + mix_rate=mix_rate if mix_rate else None, + ) + await self.aspirate( resources=current_reagent_sources, vols=current_asp_vols, @@ -1217,6 +1318,17 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): tip.extend(next(self.current_tip)) await self.pick_up_tips(tip) + if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0: + for idx, target in enumerate(targets): + await self.mix( + targets=[target], + mix_time=mix_times, + mix_vol=mix_vol, + offsets=offsets[idx:idx + 1] if offsets and len(offsets) > idx else None, + height_to_bottom=mix_liquid_height if mix_liquid_height else None, + mix_rate=mix_rate if mix_rate else None, + ) + # 从源容器吸液(总体积) await self.aspirate( resources=[source], @@ -1281,6 +1393,16 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): current_asp_liquid_height = liquid_height[0:1] * 8 if liquid_height and len(liquid_height) > 0 else [None] * 8 current_asp_blow_out_air_volume = blow_out_air_volume[0:1] * 8 if blow_out_air_volume and len(blow_out_air_volume) > 0 else [None] * 8 + if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0: + await self.mix( + targets=current_targets, + mix_time=mix_times, + mix_vol=mix_vol, + offsets=offsets[i:i + 8] if offsets else None, + height_to_bottom=mix_liquid_height if mix_liquid_height else None, + mix_rate=mix_rate if mix_rate else None, + ) + # 从源容器吸液(8个通道都从同一个源,但每个通道的吸液体积不同) await self.aspirate( resources=[source] * 8, # 8个通道都从同一个源 @@ -1379,8 +1501,14 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): # 单通道模式:多次吸液,一次分液 # 先混合前(如果需要) if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0: - # 注意:在吸液前混合源容器通常不常见,这里跳过 - pass + await self.mix( + targets=[target], + mix_time=mix_times, + mix_vol=mix_vol, + offsets=offsets[0:1] if offsets else None, + height_to_bottom=mix_liquid_height if mix_liquid_height else None, + mix_rate=mix_rate if mix_rate else None, + ) # 从每个源容器吸液并分液到目标容器 for idx, source in enumerate(sources): @@ -1455,6 +1583,16 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): raise ValueError(f"For 8-channel mode, number of sources {len(sources)} must be a multiple of 8.") # 每次处理8个源 + if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0: + await self.mix( + targets=[target], + mix_time=mix_times, + mix_vol=mix_vol, + offsets=offsets[0:1] if offsets else None, + height_to_bottom=mix_liquid_height if mix_liquid_height else None, + mix_rate=mix_rate if mix_rate else None, + ) + for i in range(0, len(sources), 8): tip = [] for _ in range(len(use_channels)): diff --git a/unilabos/devices/liquid_handling/prcxi/prcxi.py b/unilabos/devices/liquid_handling/prcxi/prcxi.py index 68cc280..e0c7e80 100644 --- a/unilabos/devices/liquid_handling/prcxi/prcxi.py +++ b/unilabos/devices/liquid_handling/prcxi/prcxi.py @@ -1,12 +1,14 @@ import asyncio import collections +from collections import OrderedDict import contextlib import json import os import socket import time import uuid -from typing import Any, List, Dict, Optional, OrderedDict, Tuple, TypedDict, Union, Sequence, Iterator, Literal +from typing import Any, List, Dict, Optional, Tuple, TypedDict, Union, Sequence, Iterator, Literal +from pylabrobot.liquid_handling.standard import GripDirection from pylabrobot.liquid_handling import ( LiquidHandlerBackend, @@ -28,9 +30,9 @@ from pylabrobot.liquid_handling.standard import ( ResourceMove, ResourceDrop, ) -from pylabrobot.resources import Tip, Deck, Plate, Well, TipRack, Resource, Container, Coordinate, TipSpot, Trash, TubeRack, PlateAdapter +from pylabrobot.resources import ResourceHolder, ResourceStack, Tip, Deck, Plate, Well, TipRack, Resource, Container, Coordinate, TipSpot, Trash, PlateAdapter, TubeRack -from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract +from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract, SimpleReturn from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode @@ -68,8 +70,45 @@ class PRCXI9300Deck(Deck): def __init__(self, name: str, size_x: float, size_y: float, size_z: float, **kwargs): super().__init__(name, size_x, size_y, size_z) - self.slots = [None] * 6 # PRCXI 9300 有 6 个槽位 + self.slots = [None] * 16 # PRCXI 9300/9320 最大有 16 个槽位 + self.slot_locations = [Coordinate(0, 0, 0)] * 16 + def assign_child_at_slot(self, resource: Resource, slot: int, reassign: bool = False) -> None: + if self.slots[slot - 1] is not None and not reassign: + raise ValueError(f"Spot {slot} is already occupied") + + self.slots[slot - 1] = resource + super().assign_child_resource(resource, location=self.slot_locations[slot - 1]) + +class PRCXI9300Container(Plate): + """PRCXI 9300 的专用 Container 类,继承自 Plate,用于槽位定位和未知模块。 + + 该类定义了 PRCXI 9300 的工作台布局和槽位信息。 + """ + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + category: str, + ordering: collections.OrderedDict, + model: Optional[str] = None, + **kwargs, + ): + 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 PRCXI9300Plate(Plate): """ 专用孔板类: @@ -83,11 +122,43 @@ class PRCXI9300Plate(Plate): model: Optional[str] = None, material_info: Optional[Dict[str, Any]] = None, **kwargs): - items = ordered_items if ordered_items is not None else ordering - super().__init__(name, size_x, size_y, size_z, - ordered_items=items, - category=category, - model=model, **kwargs) + # 如果 ordered_items 不为 None,直接使用 + if ordered_items is not None: + items = ordered_items + elif ordering is not None: + # 检查 ordering 中的值是否是字符串(从 JSON 反序列化时的情况) + # 如果是字符串,说明这是位置名称,需要让 Plate 自己创建 Well 对象 + # 我们只传递位置信息(键),不传递值,使用 ordering 参数 + if ordering and isinstance(next(iter(ordering.values()), None), str): + # ordering 的值是字符串,只使用键(位置信息)创建新的 OrderedDict + # 传递 ordering 参数而不是 ordered_items,让 Plate 自己创建 Well 对象 + items = None + # 使用 ordering 参数,只包含位置信息(键) + ordering_param = collections.OrderedDict((k, None) for k in ordering.keys()) + else: + # ordering 的值已经是对象,可以直接使用 + items = ordering + ordering_param = None + else: + items = None + ordering_param = None + + # 根据情况传递不同的参数 + if items is not None: + super().__init__(name, size_x, size_y, size_z, + ordered_items=items, + category=category, + model=model, **kwargs) + elif ordering_param is not None: + # 传递 ordering 参数,让 Plate 自己创建 Well 对象 + super().__init__(name, size_x, size_y, size_z, + ordering=ordering_param, + category=category, + model=model, **kwargs) + else: + super().__init__(name, size_x, size_y, size_z, + category=category, + model=model, **kwargs) self._unilabos_state = {} if material_info: @@ -124,8 +195,7 @@ class PRCXI9300Plate(Plate): safe_state[k] = v data.update(safe_state) - return data - + return data # 其他顶层属性也进行类型检查 class PRCXI9300TipRack(TipRack): """ 专用吸头盒类 """ def __init__(self, name: str, size_x: float, size_y: float, size_z: float, @@ -135,11 +205,43 @@ class PRCXI9300TipRack(TipRack): model: Optional[str] = None, material_info: Optional[Dict[str, Any]] = None, **kwargs): - items = ordered_items if ordered_items is not None else ordering - super().__init__(name, size_x, size_y, size_z, - ordered_items=items, - category=category, - model=model, **kwargs) + # 如果 ordered_items 不为 None,直接使用 + if ordered_items is not None: + items = ordered_items + elif ordering is not None: + # 检查 ordering 中的值是否是字符串(从 JSON 反序列化时的情况) + # 如果是字符串,说明这是位置名称,需要让 TipRack 自己创建 Tip 对象 + # 我们只传递位置信息(键),不传递值,使用 ordering 参数 + if ordering and isinstance(next(iter(ordering.values()), None), str): + # ordering 的值是字符串,只使用键(位置信息)创建新的 OrderedDict + # 传递 ordering 参数而不是 ordered_items,让 TipRack 自己创建 Tip 对象 + items = None + # 使用 ordering 参数,只包含位置信息(键) + ordering_param = collections.OrderedDict((k, None) for k in ordering.keys()) + else: + # ordering 的值已经是对象,可以直接使用 + items = ordering + ordering_param = None + else: + items = None + ordering_param = None + + # 根据情况传递不同的参数 + if items is not None: + super().__init__(name, size_x, size_y, size_z, + ordered_items=items, + category=category, + model=model, **kwargs) + elif ordering_param is not None: + # 传递 ordering 参数,让 TipRack 自己创建 Tip 对象 + super().__init__(name, size_x, size_y, size_z, + ordering=ordering_param, + category=category, + model=model, **kwargs) + else: + super().__init__(name, size_x, size_y, size_z, + category=category, + model=model, **kwargs) self._unilabos_state = {} if material_info: self._unilabos_state["Material"] = material_info @@ -235,16 +337,53 @@ class PRCXI9300TubeRack(TubeRack): category: str = "tube_rack", items: Optional[Dict[str, Any]] = None, ordered_items: Optional[OrderedDict] = None, + ordering: Optional[OrderedDict] = None, model: Optional[str] = None, material_info: Optional[Dict[str, Any]] = None, **kwargs): - # 兼容处理:PLR 的 TubeRack 构造函数可能接受 items 或 ordered_items - items_to_pass = items if items is not None else ordered_items - super().__init__(name, size_x, size_y, size_z, - ordered_items=ordered_items, - model=model, - **kwargs) + # 如果 ordered_items 不为 None,直接使用 + if ordered_items is not None: + items_to_pass = ordered_items + ordering_param = None + elif ordering is not None: + # 检查 ordering 中的值是否是字符串(从 JSON 反序列化时的情况) + # 如果是字符串,说明这是位置名称,需要让 TubeRack 自己创建 Tube 对象 + # 我们只传递位置信息(键),不传递值,使用 ordering 参数 + if ordering and isinstance(next(iter(ordering.values()), None), str): + # ordering 的值是字符串,只使用键(位置信息)创建新的 OrderedDict + # 传递 ordering 参数而不是 ordered_items,让 TubeRack 自己创建 Tube 对象 + items_to_pass = None + # 使用 ordering 参数,只包含位置信息(键) + ordering_param = collections.OrderedDict((k, None) for k in ordering.keys()) + else: + # ordering 的值已经是对象,可以直接使用 + items_to_pass = ordering + ordering_param = None + elif items is not None: + # 兼容旧的 items 参数 + items_to_pass = items + ordering_param = None + else: + items_to_pass = None + ordering_param = None + + # 根据情况传递不同的参数 + if items_to_pass is not None: + super().__init__(name, size_x, size_y, size_z, + ordered_items=items_to_pass, + model=model, + **kwargs) + elif ordering_param is not None: + # 传递 ordering 参数,让 TubeRack 自己创建 Tube 对象 + super().__init__(name, size_x, size_y, size_z, + ordering=ordering_param, + model=model, + **kwargs) + else: + super().__init__(name, size_x, size_y, size_z, + model=model, + **kwargs) self._unilabos_state = {} if material_info: @@ -375,16 +514,12 @@ class PRCXI9300Handler(LiquidHandlerAbstract): tablets_info = [] count = 0 for child in deck.children: - child_state = getattr(child, "_unilabos_state", {}) - if "Material" in child_state: - count += 1 - tablets_info.append( - WorkTablets( - Number=count, - Code=f"T{count}", - Material=child_state["Material"] + if child.children: + if "Material" in child.children[0]._unilabos_state: + number = int(child.name.replace("T", "")) + tablets_info.append( + WorkTablets(Number=number, Code=f"T{number}", Material=child.children[0]._unilabos_state["Material"]) ) - ) if is_9320: print("当前设备是9320") # 始终初始化 step_mode 属性 @@ -403,7 +538,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract): super().post_init(ros_node) self._unilabos_backend.post_init(ros_node) - def set_liquid(self, wells: list[Well], liquid_names: list[str], volumes: list[float]): + def set_liquid(self, wells: list[Well], liquid_names: list[str], volumes: list[float]) -> SimpleReturn: return super().set_liquid(wells, liquid_names, volumes) def set_group(self, group_name: str, wells: List[Well], volumes: List[float]): @@ -660,6 +795,37 @@ class PRCXI9300Handler(LiquidHandlerAbstract): async def move_to(self, well: Well, dis_to_top: float = 0, channel: int = 0): return await super().move_to(well, dis_to_top, channel) + async def shaker_action(self, time: int, module_no: int, amplitude: int, is_wait: bool): + return await self._unilabos_backend.shaker_action(time, module_no, amplitude, is_wait) + + async def heater_action(self, temperature: float, time: int): + return await self._unilabos_backend.heater_action(temperature, time) + async def move_plate( + self, + plate: Plate, + to: Resource, + intermediate_locations: Optional[List[Coordinate]] = None, + pickup_offset: Coordinate = Coordinate.zero(), + destination_offset: Coordinate = Coordinate.zero(), + drop_direction: GripDirection = GripDirection.FRONT, + pickup_direction: GripDirection = GripDirection.FRONT, + pickup_distance_from_top: float = 13.2 - 3.33, + **backend_kwargs, + ): + + return await super().move_plate( + plate, + to, + intermediate_locations, + pickup_offset, + destination_offset, + drop_direction, + pickup_direction, + pickup_distance_from_top, + target_plate_number = to, + **backend_kwargs, + ) + class PRCXI9300Backend(LiquidHandlerBackend): """PRCXI 9300 的后端实现,继承自 LiquidHandlerBackend。 @@ -700,6 +866,55 @@ class PRCXI9300Backend(LiquidHandlerBackend): self._num_channels = channel_num self._execute_setup = setup self.debug = debug + self.axis = "Left" + + async def shaker_action(self, time: int, module_no: int, amplitude: int, is_wait: bool): + step = self.api_client.shaker_action( + time=time, + module_no=module_no, + amplitude=amplitude, + is_wait=is_wait, + ) + self.steps_todo_list.append(step) + return step + + + async def pick_up_resource(self, pickup: ResourcePickup, **backend_kwargs): + + resource=pickup.resource + offset=pickup.offset + pickup_distance_from_top=pickup.pickup_distance_from_top + direction=pickup.direction + + plate_number = int(resource.parent.name.replace("T", "")) + is_whole_plate = True + balance_height = 0 + step = self.api_client.clamp_jaw_pick_up(plate_number, is_whole_plate, balance_height) + + self.steps_todo_list.append(step) + return step + + async def drop_resource(self, drop: ResourceDrop, **backend_kwargs): + + + plate_number = None + target_plate_number = backend_kwargs.get("target_plate_number", None) + if target_plate_number is not None: + plate_number = int(target_plate_number.name.replace("T", "")) + + + is_whole_plate = True + balance_height = 0 + if plate_number is None: + raise ValueError("target_plate_number is required when dropping a resource") + step = self.api_client.clamp_jaw_drop(plate_number, is_whole_plate, balance_height) + self.steps_todo_list.append(step) + return step + + + async def heater_action(self, temperature: float, time: int): + print(f"\n\nHeater action: temperature={temperature}, time={time}\n\n") + # return await self.api_client.heater_action(temperature, time) def post_init(self, ros_node: BaseROS2DeviceNode): self._ros_node = ros_node @@ -731,7 +946,11 @@ class PRCXI9300Backend(LiquidHandlerBackend): print(f"PRCXI9300Backend created solution with ID: {solution_id}") self.api_client.load_solution(solution_id) print(json.dumps(self.steps_todo_list, indent=2)) - return self.api_client.start() + if not self.api_client.start(): + return False + if not self.api_client.wait_for_finish(): + return False + return True @classmethod def check_channels(cls, use_channels: List[int]) -> List[int]: @@ -753,7 +972,7 @@ class PRCXI9300Backend(LiquidHandlerBackend): # 清除错误代码 self.api_client.clear_error_code() print("PRCXI9300 error code cleared.") - + self.api_client.call("IAutomation", "Stop") # 执行重置 print("Starting PRCXI9300 reset...") self.api_client.call("IAutomation", "Reset") @@ -777,12 +996,23 @@ class PRCXI9300Backend(LiquidHandlerBackend): async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int] = None): """Pick up tips from the specified resource.""" - + # INSERT_YOUR_CODE + # Ensure use_channels is converted to a list of ints if it's an array + if hasattr(use_channels, 'tolist'): + _use_channels = use_channels.tolist() + else: + _use_channels = list(use_channels) if use_channels is not None else None + if _use_channels == [0]: + axis = "Left" + elif _use_channels == [1]: + axis = "Right" + else: + raise ValueError("Invalid use channels: " + str(_use_channels)) plate_indexes = [] for op in ops: plate = op.resource.parent - deck = plate.parent - plate_index = deck.children.index(plate) + deck = plate.parent.parent + plate_index = deck.children.index(plate.parent) # print(f"Plate index: {plate_index}, Plate name: {plate.name}") # print(f"Number of children in deck: {len(deck.children)}") @@ -807,6 +1037,7 @@ class PRCXI9300Backend(LiquidHandlerBackend): hole_row = tipspot_index % 8 + 1 step = self.api_client.Load( + axis=axis, dosage=0, plate_no=PlateNo, is_whole_plate=False, @@ -821,13 +1052,23 @@ class PRCXI9300Backend(LiquidHandlerBackend): async def drop_tips(self, ops: List[Drop], use_channels: List[int] = None): """Pick up tips from the specified resource.""" - + if hasattr(use_channels, 'tolist'): + _use_channels = use_channels.tolist() + else: + _use_channels = list(use_channels) if use_channels is not None else None + if _use_channels == [0]: + axis = "Left" + elif _use_channels == [1]: + axis = "Right" + else: + raise ValueError("Invalid use channels: " + str(_use_channels)) # 检查trash # if ops[0].resource.name == "trash": - PlateNo = ops[0].resource.parent.children.index(ops[0].resource) + 1 + PlateNo = ops[0].resource.parent.parent.children.index(ops[0].resource.parent) + 1 step = self.api_client.UnLoad( + axis=axis, dosage=0, plate_no=PlateNo, is_whole_plate=False, @@ -845,8 +1086,8 @@ class PRCXI9300Backend(LiquidHandlerBackend): plate_indexes = [] for op in ops: plate = op.resource.parent - deck = plate.parent - plate_index = deck.children.index(plate) + deck = plate.parent.parent + plate_index = deck.children.index(plate.parent) plate_indexes.append(plate_index) if len(set(plate_indexes)) != 1: raise ValueError( @@ -870,6 +1111,7 @@ class PRCXI9300Backend(LiquidHandlerBackend): hole_row = tipspot_index % 8 + 1 step = self.api_client.UnLoad( + axis=axis, dosage=0, plate_no=PlateNo, is_whole_plate=False, @@ -893,12 +1135,12 @@ class PRCXI9300Backend(LiquidHandlerBackend): none_keys: List[str] = [], ): """Mix liquid in the specified resources.""" - + plate_indexes = [] for op in targets: - deck = op.parent.parent + deck = op.parent.parent.parent plate = op.parent - plate_index = deck.children.index(plate) + plate_index = deck.children.index(plate.parent) plate_indexes.append(plate_index) if len(set(plate_indexes)) != 1: @@ -936,12 +1178,21 @@ class PRCXI9300Backend(LiquidHandlerBackend): async def aspirate(self, ops: List[SingleChannelAspiration], use_channels: List[int] = None): """Aspirate liquid from the specified resources.""" - + if hasattr(use_channels, 'tolist'): + _use_channels = use_channels.tolist() + else: + _use_channels = list(use_channels) if use_channels is not None else None + if _use_channels == [0]: + axis = "Left" + elif _use_channels == [1]: + axis = "Right" + else: + raise ValueError("Invalid use channels: " + str(_use_channels)) plate_indexes = [] for op in ops: plate = op.resource.parent - deck = plate.parent - plate_index = deck.children.index(plate) + deck = plate.parent.parent + plate_index = deck.children.index(plate.parent) plate_indexes.append(plate_index) if len(set(plate_indexes)) != 1: @@ -969,6 +1220,7 @@ class PRCXI9300Backend(LiquidHandlerBackend): hole_row = tipspot_index % 8 + 1 step = self.api_client.Imbibing( + axis=axis, dosage=int(volumes[0]), plate_no=PlateNo, is_whole_plate=False, @@ -983,12 +1235,21 @@ class PRCXI9300Backend(LiquidHandlerBackend): async def dispense(self, ops: List[SingleChannelDispense], use_channels: List[int] = None): """Dispense liquid into the specified resources.""" - + if hasattr(use_channels, 'tolist'): + _use_channels = use_channels.tolist() + else: + _use_channels = list(use_channels) if use_channels is not None else None + if _use_channels == [0]: + axis = "Left" + elif _use_channels == [1]: + axis = "Right" + else: + raise ValueError("Invalid use channels: " + str(_use_channels)) plate_indexes = [] for op in ops: plate = op.resource.parent - deck = plate.parent - plate_index = deck.children.index(plate) + deck = plate.parent.parent + plate_index = deck.children.index(plate.parent) plate_indexes.append(plate_index) if len(set(plate_indexes)) != 1: @@ -1017,6 +1278,7 @@ class PRCXI9300Backend(LiquidHandlerBackend): hole_row = tipspot_index % 8 + 1 step = self.api_client.Tapping( + axis=axis, dosage=int(volumes[0]), plate_no=PlateNo, is_whole_plate=False, @@ -1041,14 +1303,8 @@ class PRCXI9300Backend(LiquidHandlerBackend): async def dispense96(self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]): raise NotImplementedError("The Opentrons backend does not support the 96 head.") - async def pick_up_resource(self, pickup: ResourcePickup): - raise NotImplementedError("The Opentrons backend does not support the robotic arm.") - async def move_picked_up_resource(self, move: ResourceMove): - raise NotImplementedError("The Opentrons backend does not support the robotic arm.") - - async def drop_resource(self, drop: ResourceDrop): - raise NotImplementedError("The Opentrons backend does not support the robotic arm.") + pass def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: return True # PRCXI9300Backend does not have tip compatibility issues @@ -1139,6 +1395,28 @@ class PRCXI9300Api: def start(self) -> bool: return self.call("IAutomation", "Start") + def wait_for_finish(self) -> bool: + success = False + start = False + while not success: + status = self.step_state_list() + if len(status) == 1: + start = True + if status is None: + break + if len(status) == 0: + break + if status[-1]["State"] == 2 and start: + success = True + elif status[-1]["State"] > 2: + break + elif status[-1]["State"] == 0: + start = True + else: + time.sleep(1) + return success + + def call(self, service: str, method: str, params: Optional[list] = None) -> Any: payload = json.dumps( {"ServiceName": service, "MethodName": method, "Paramters": params or []}, separators=(",", ":") @@ -1225,9 +1503,10 @@ class PRCXI9300Api: assist_fun4: str = "", assist_fun5: str = "", liquid_method: str = "NormalDispense", + axis: str = "Left", ) -> Dict[str, Any]: return { - "StepAxis": self.axis, + "StepAxis": axis, "Function": "Load", "DosageNum": dosage, "PlateNo": plate_no, @@ -1263,9 +1542,10 @@ class PRCXI9300Api: assist_fun4: str = "", assist_fun5: str = "", liquid_method: str = "NormalDispense", - ) -> Dict[str, Any]: + axis: str = "Left", + ) -> Dict[str, Any]: return { - "StepAxis": self.axis, + "StepAxis": axis, "Function": "Imbibing", "DosageNum": dosage, "PlateNo": plate_no, @@ -1301,9 +1581,10 @@ class PRCXI9300Api: assist_fun4: str = "", assist_fun5: str = "", liquid_method: str = "NormalDispense", + axis: str = "Left", ) -> Dict[str, Any]: return { - "StepAxis": self.axis, + "StepAxis": axis, "Function": "Tapping", "DosageNum": dosage, "PlateNo": plate_no, @@ -1339,9 +1620,10 @@ class PRCXI9300Api: assist_fun4: str = "", assist_fun5: str = "", liquid_method: str = "NormalDispense", - ) -> Dict[str, Any]: + axis: str = "Left", + ) -> Dict[str, Any]: return { - "StepAxis": self.axis, + "StepAxis": axis, "Function": "Blending", "DosageNum": dosage, "PlateNo": plate_no, @@ -1377,9 +1659,10 @@ class PRCXI9300Api: assist_fun4: str = "", assist_fun5: str = "", liquid_method: str = "NormalDispense", + axis: str = "Left", ) -> Dict[str, Any]: return { - "StepAxis": self.axis, + "StepAxis": axis, "Function": "UnLoad", "DosageNum": dosage, "PlateNo": plate_no, @@ -1398,6 +1681,50 @@ class PRCXI9300Api: "LiquidDispensingMethod": liquid_method, } + def clamp_jaw_pick_up(self, + plate_no: int, + is_whole_plate: bool, + balance_height: int, + + ) -> Dict[str, Any]: + return { + "StepAxis": "ClampingJaw", + "Function": "DefectiveLift", + "PlateNo": plate_no, + "IsWholePlate": is_whole_plate, + "HoleRow": 1, + "HoleCol": 1, + "BalanceHeight": balance_height, + "PlateOrHoleNum": f"T{plate_no}" + } + + def clamp_jaw_drop( + self, + plate_no: int, + is_whole_plate: bool, + balance_height: int, + + ) -> Dict[str, Any]: + return { + "StepAxis": "ClampingJaw", + "Function": "PutDown", + "PlateNo": plate_no, + "IsWholePlate": is_whole_plate, + "HoleRow": 1, + "HoleCol": 1, + "BalanceHeight": balance_height, + "PlateOrHoleNum": f"T{plate_no}" + } + + def shaker_action(self, time: int, module_no: int, amplitude: int, is_wait: bool): + return { + "StepAxis": "Left", + "Function": "Shaking", + "AssistFun1": time, + "AssistFun2": module_no, + "AssistFun3": amplitude, + "AssistFun4": is_wait, + } class DefaultLayout: diff --git a/unilabos/devices/liquid_handling/test_transfer_liquid.py b/unilabos/devices/liquid_handling/test_transfer_liquid.py new file mode 100644 index 0000000..f13980f --- /dev/null +++ b/unilabos/devices/liquid_handling/test_transfer_liquid.py @@ -0,0 +1,13 @@ +""" +说明: +这里放一个“入口文件”,方便在 `unilabos/devices/liquid_handling` 目录下直接找到 +`transfer_liquid` 的测试。 + +实际测试用例实现放在仓库标准测试目录: +`tests/devices/liquid_handling/test_transfer_liquid.py` +""" + +# 让 pytest 能从这里发现同一套测试(避免复制两份测试代码)。 +from tests.devices.liquid_handling.test_transfer_liquid import * # noqa: F401,F403 + + diff --git a/unilabos/registry/devices/cameraSII.yaml b/unilabos/registry/devices/cameraSII.yaml index d817f48..ad2df95 100644 --- a/unilabos/registry/devices/cameraSII.yaml +++ b/unilabos/registry/devices/cameraSII.yaml @@ -9,6 +9,7 @@ cameracontroller_device: goal_default: config: null handles: {} + placeholder_keys: {} result: {} schema: description: '' @@ -31,6 +32,7 @@ cameracontroller_device: goal: {} goal_default: {} handles: {} + placeholder_keys: {} result: {} schema: description: '' diff --git a/unilabos/registry/devices/chinwe.yaml b/unilabos/registry/devices/chinwe.yaml index a9f352e..2078d0f 100644 --- a/unilabos/registry/devices/chinwe.yaml +++ b/unilabos/registry/devices/chinwe.yaml @@ -4,6 +4,73 @@ separator.chinwe: - chinwe class: action_value_mappings: + auto-connect: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: connect参数 + type: object + type: UniLabJsonCommand + auto-disconnect: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: disconnect参数 + type: object + type: UniLabJsonCommand + auto-execute_command_from_outer: + feedback: {} + goal: {} + goal_default: + command_dict: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + command_dict: + type: object + required: + - command_dict + type: object + result: {} + required: + - goal + title: execute_command_from_outer参数 + type: object + type: UniLabJsonCommand motor_rotate_quarter: goal: direction: 顺时针 @@ -303,42 +370,44 @@ separator.chinwe: handles: [] icon: '' init_param_schema: - goal: - baudrate: - default: 9600 - description: 串口波特率 - type: integer - motor_ids: - default: - - 4 - - 5 - description: 步进电机ID列表 - items: + config: + properties: + baudrate: + default: 9600 type: integer - type: array - port: - default: 192.168.1.200:8899 - description: 串口号或 IP:Port - type: string - pump_ids: - default: - - 1 - - 2 - - 3 - description: 注射泵ID列表 - items: + motor_ids: + items: + type: integer + type: array + port: + default: 192.168.1.200:8899 + type: string + pump_ids: + items: + type: integer + type: array + sensor_id: + default: 6 type: integer - type: array - sensor_id: - default: 6 - description: XKC传感器ID - type: integer - sensor_threshold: - default: 300 - description: 传感器液位判定阈值 - type: integer - timeout: - default: 10 - description: 通信超时时间 (秒) - type: integer + sensor_threshold: + default: 300 + type: integer + timeout: + default: 10.0 + type: number + required: [] + type: object + data: + properties: + is_connected: + type: boolean + sensor_level: + type: boolean + sensor_rssi: + type: integer + required: + - sensor_level + - sensor_rssi + - is_connected + type: object version: 2.1.0 diff --git a/unilabos/registry/devices/laiyu_liquid.yaml b/unilabos/registry/devices/laiyu_liquid.yaml deleted file mode 100644 index 64c0c18..0000000 --- a/unilabos/registry/devices/laiyu_liquid.yaml +++ /dev/null @@ -1,1919 +0,0 @@ -laiyu_liquid: - category: - - liquid_handler - - workstation - - laiyu_liquid - class: - action_value_mappings: - add_liquid: - feedback: {} - goal: - asp_vols: asp_vols - dis_vols: dis_vols - flow_rates: flow_rates - offsets: offsets - reagent_sources: reagent_sources - 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: - flow_rates: flow_rates - 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: - success: success - 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 - auto-add_resource: - feedback: {} - goal: {} - goal_default: - name: null - position: null - resource_type: null - handles: {} - placeholder_keys: {} - result: {} - schema: - description: '' - properties: - feedback: {} - goal: - properties: - name: - type: string - position: - items: - type: number - type: array - resource_type: - type: string - required: - - name - - resource_type - - position - type: object - result: {} - required: - - goal - title: add_resource参数 - type: object - type: UniLabJsonCommand - dispense: - feedback: {} - goal: - flow_rates: flow_rates - 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: - success: success - 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_tip: - feedback: {} - goal: - 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: {} - result: - success: success - 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 - move_to: - feedback: {} - goal: - channel: channel - dis_to_top: dis_to_top - well: well - goal_default: - channel: 0 - dis_to_top: 0.0 - well: - 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: - well: unilabos_resources - result: - success: success - schema: - description: '' - properties: - feedback: - properties: {} - required: [] - title: LiquidHandlerMoveTo_Feedback - type: object - goal: - properties: - channel: - maximum: 2147483647 - minimum: -2147483648 - type: integer - dis_to_top: - type: number - well: - 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: well - type: object - required: - - well - - dis_to_top - - channel - title: LiquidHandlerMoveTo_Goal - type: object - result: - properties: - return_info: - type: string - success: - type: boolean - required: - - return_info - - success - title: LiquidHandlerMoveTo_Result - type: object - required: - - goal - title: LiquidHandlerMoveTo - type: object - type: LiquidHandlerMoveTo - pick_up_tip: - feedback: {} - goal: - tip_rack: tip_rack - 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_rack: unilabos_resources - result: - success: success - 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 - setup: - feedback: {} - goal: - string: string - goal_default: - string: '' - handles: {} - result: - success: success - schema: - description: '' - properties: - feedback: - properties: {} - required: [] - title: StrSingleInput_Feedback - type: object - goal: - properties: - string: - type: string - required: - - string - title: StrSingleInput_Goal - type: object - result: - properties: - return_info: - type: string - success: - type: boolean - required: - - return_info - - success - title: StrSingleInput_Result - type: object - required: - - goal - title: StrSingleInput - type: object - type: StrSingleInput - stop: - feedback: {} - goal: - string: string - goal_default: - string: '' - handles: {} - result: - success: success - schema: - description: '' - properties: - feedback: - properties: {} - required: [] - title: StrSingleInput_Feedback - type: object - goal: - properties: - string: - type: string - required: - - string - title: StrSingleInput_Goal - type: object - result: - properties: - return_info: - type: string - success: - type: boolean - required: - - return_info - - success - title: StrSingleInput_Result - type: object - required: - - goal - title: StrSingleInput - type: object - type: StrSingleInput - transfer: - feedback: {} - goal: - source: source - target: target - tip_position: tip_position - tip_rack: tip_rack - volume: volume - goal_default: - asp_flow_rates: - - 0.0 - asp_vols: - - 0.0 - blow_out_air_volume: - - 0.0 - delays: - - 0 - dis_flow_rates: - - 0.0 - dis_vols: - - 0.0 - is_96_well: false - liquid_height: - - 0.0 - mix_liquid_height: 0.0 - mix_rate: 0 - mix_stage: '' - mix_times: 0 - mix_vol: 0 - none_keys: - - '' - offsets: - - x: 0.0 - y: 0.0 - z: 0.0 - 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: '' - tip_racks: - - 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: '' - touch_tip: false - use_channels: - - 0 - handles: {} - placeholder_keys: - source: unilabos_resources - target: unilabos_resources - tip_rack: unilabos_resources - result: - success: success - schema: - description: '' - properties: - feedback: - properties: {} - required: [] - title: LiquidHandlerTransfer_Feedback - type: object - goal: - properties: - asp_flow_rates: - items: - type: number - type: array - asp_vols: - items: - type: number - type: array - blow_out_air_volume: - items: - type: number - type: array - delays: - items: - maximum: 2147483647 - minimum: -2147483648 - type: integer - type: array - dis_flow_rates: - items: - type: number - type: array - dis_vols: - 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_stage: - type: string - mix_times: - 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 - 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: 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 - tip_racks: - 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_racks - type: object - type: array - touch_tip: - type: boolean - use_channels: - items: - maximum: 2147483647 - minimum: -2147483648 - type: integer - type: array - required: - - asp_vols - - dis_vols - - sources - - targets - - tip_racks - - use_channels - - asp_flow_rates - - dis_flow_rates - - offsets - - touch_tip - - liquid_height - - blow_out_air_volume - - spread - - is_96_well - - mix_stage - - mix_times - - mix_vol - - mix_rate - - mix_liquid_height - - delays - - none_keys - title: LiquidHandlerTransfer_Goal - type: object - result: - properties: - return_info: - type: string - success: - type: boolean - required: - - return_info - - success - title: LiquidHandlerTransfer_Result - type: object - required: - - goal - title: LiquidHandlerTransfer - type: object - type: LiquidHandlerTransfer - module: unilabos.devices.laiyu_liquid.core.laiyu_liquid_main:LaiYuLiquid - status_types: - current_position: String - current_volume: float - is_connected: bool - is_initialized: bool - status: dict - tip_attached: bool - type: python - config_info: - - default: /dev/cu.usbserial-3130 - description: RS485转USB端口 - name: port - type: string - - default: 1 - description: 设备地址 - name: address - type: integer - - default: 9600 - description: 波特率 - name: baudrate - type: integer - - default: 5.0 - description: 通信超时时间 (秒) - name: timeout - type: number - - default: 340.0 - description: 工作台宽度 (mm) - name: deck_width - type: number - - default: 250.0 - description: 工作台高度 (mm) - name: deck_height - type: number - - default: 160.0 - description: 工作台深度 (mm) - name: deck_depth - type: number - - default: 77000.0 - description: 最大体积 (μL) - name: max_volume - type: number - - default: 0.1 - description: 最小体积 (μL) - name: min_volume - type: number - - default: 100.0 - description: 最大速度 (mm/s) - name: max_speed - type: number - - default: 50.0 - description: 加速度 (mm/s²) - name: acceleration - type: number - - default: 50.0 - description: 安全高度 (mm) - name: safe_height - type: number - - default: 10.0 - description: 吸头拾取深度 (mm) - name: tip_pickup_depth - type: number - - default: true - description: 液面检测功能 - name: liquid_detection - type: boolean - - default: 10.0 - description: X轴最小安全边距 (mm) - name: safety_margin_x_min - type: number - - default: 10.0 - description: X轴最大安全边距 (mm) - name: safety_margin_x_max - type: number - - default: 10.0 - description: Y轴最小安全边距 (mm) - name: safety_margin_y_min - type: number - - default: 10.0 - description: Y轴最大安全边距 (mm) - name: safety_margin_y_max - type: number - - default: 20.0 - description: Z轴安全间隙 (mm) - name: safety_margin_z_clearance - type: number - description: LaiYu液体处理工作站,基于RS485通信协议的自动化液体处理设备。集成XYZ三轴运动平台和SOPA气动式移液器,支持精确的液体分配和转移操作。具备完整的硬件控制、资源管理和标准化接口,适用于实验室自动化液体处理、样品制备、试剂分配等应用场景。设备通过RS485总线控制步进电机和移液器,提供高精度的位置控制和液体处理能力。 - handles: [] - icon: '' - init_param_schema: - config: - properties: - config: - type: string - required: [] - type: object - data: - properties: - current_position: - items: - type: number - type: array - current_volume: - type: number - is_connected: - type: boolean - is_initialized: - type: boolean - status: - type: object - tip_attached: - type: boolean - required: - - current_position - - current_volume - - is_connected - - is_initialized - - tip_attached - - status - type: object - model: - mesh: laiyu_liquid_handler - type: device - version: 1.0.0 diff --git a/unilabos/registry/devices/laiyu_liquid_test.yaml b/unilabos/registry/devices/laiyu_liquid_test.yaml index bbc0e87..dcaa981 100644 --- a/unilabos/registry/devices/laiyu_liquid_test.yaml +++ b/unilabos/registry/devices/laiyu_liquid_test.yaml @@ -3,11 +3,11 @@ xyz_stepper_controller: - laiyu_liquid_test class: action_value_mappings: - auto-define_current_as_zero: + auto-degrees_to_steps: feedback: {} goal: {} goal_default: - save_path: work_origin.json + degrees: null handles: {} placeholder_keys: {} result: {} @@ -17,23 +17,22 @@ xyz_stepper_controller: feedback: {} goal: properties: - save_path: - default: work_origin.json - type: string - required: [] + degrees: + type: number + required: + - degrees type: object result: {} required: - goal - title: define_current_as_zero参数 + title: degrees_to_steps参数 type: object type: UniLabJsonCommand - auto-enable: + auto-emergency_stop: feedback: {} goal: {} goal_default: axis: null - state: null handles: {} placeholder_keys: {} result: {} @@ -44,27 +43,415 @@ xyz_stepper_controller: goal: properties: axis: - type: string - state: - type: boolean + type: object required: - axis - - state type: object result: {} required: - goal - title: enable参数 + title: emergency_stop参数 type: object type: UniLabJsonCommand - auto-move_to: + auto-enable_all_axes: + feedback: {} + goal: {} + goal_default: + enable: true + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + enable: + default: true + type: boolean + required: [] + type: object + result: {} + required: + - goal + title: enable_all_axes参数 + type: object + type: UniLabJsonCommand + auto-enable_motor: feedback: {} goal: {} goal_default: - acc: 500 axis: null - precision: 50 - speed: 2000 + enable: true + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + axis: + type: object + enable: + default: true + type: boolean + required: + - axis + type: object + result: {} + required: + - goal + title: enable_motor参数 + type: object + type: UniLabJsonCommand + auto-home_all_axes: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: home_all_axes参数 + type: object + type: UniLabJsonCommand + auto-home_axis: + feedback: {} + goal: {} + goal_default: + axis: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + axis: + type: object + required: + - axis + type: object + result: {} + required: + - goal + title: home_axis参数 + type: object + type: UniLabJsonCommand + auto-move_to_position: + feedback: {} + goal: {} + goal_default: + acceleration: 1000 + axis: null + position: null + precision: 100 + speed: 5000 + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + acceleration: + default: 1000 + type: integer + axis: + type: object + position: + type: integer + precision: + default: 100 + type: integer + speed: + default: 5000 + type: integer + required: + - axis + - position + type: object + result: {} + required: + - goal + title: move_to_position参数 + type: object + type: UniLabJsonCommand + auto-move_to_position_degrees: + feedback: {} + goal: {} + goal_default: + acceleration: 1000 + axis: null + degrees: null + precision: 100 + speed: 5000 + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + acceleration: + default: 1000 + type: integer + axis: + type: object + degrees: + type: number + precision: + default: 100 + type: integer + speed: + default: 5000 + type: integer + required: + - axis + - degrees + type: object + result: {} + required: + - goal + title: move_to_position_degrees参数 + type: object + type: UniLabJsonCommand + auto-move_to_position_revolutions: + feedback: {} + goal: {} + goal_default: + acceleration: 1000 + axis: null + precision: 100 + revolutions: null + speed: 5000 + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + acceleration: + default: 1000 + type: integer + axis: + type: object + precision: + default: 100 + type: integer + revolutions: + type: number + speed: + default: 5000 + type: integer + required: + - axis + - revolutions + type: object + result: {} + required: + - goal + title: move_to_position_revolutions参数 + type: object + type: UniLabJsonCommand + auto-move_xyz: + feedback: {} + goal: {} + goal_default: + acceleration: 1000 + speed: 5000 + x: null + y: null + z: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + acceleration: + default: 1000 + type: integer + speed: + default: 5000 + type: integer + x: + type: string + y: + type: string + z: + type: string + required: [] + type: object + result: {} + required: + - goal + title: move_xyz参数 + type: object + type: UniLabJsonCommand + auto-move_xyz_degrees: + feedback: {} + goal: {} + goal_default: + acceleration: 1000 + speed: 5000 + x_deg: null + y_deg: null + z_deg: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + acceleration: + default: 1000 + type: integer + speed: + default: 5000 + type: integer + x_deg: + type: string + y_deg: + type: string + z_deg: + type: string + required: [] + type: object + result: {} + required: + - goal + title: move_xyz_degrees参数 + type: object + type: UniLabJsonCommand + auto-move_xyz_revolutions: + feedback: {} + goal: {} + goal_default: + acceleration: 1000 + speed: 5000 + x_rev: null + y_rev: null + z_rev: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + acceleration: + default: 1000 + type: integer + speed: + default: 5000 + type: integer + x_rev: + type: string + y_rev: + type: string + z_rev: + type: string + required: [] + type: object + result: {} + required: + - goal + title: move_xyz_revolutions参数 + type: object + type: UniLabJsonCommand + auto-revolutions_to_steps: + feedback: {} + goal: {} + goal_default: + revolutions: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + revolutions: + type: number + required: + - revolutions + type: object + result: {} + required: + - goal + title: revolutions_to_steps参数 + type: object + type: UniLabJsonCommand + auto-set_speed_mode: + feedback: {} + goal: {} + goal_default: + acceleration: 1000 + axis: null + speed: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + acceleration: + default: 1000 + type: integer + axis: + type: object + speed: + type: integer + required: + - axis + - speed + type: object + result: {} + required: + - goal + title: set_speed_mode参数 + type: object + type: UniLabJsonCommand + auto-steps_to_degrees: + feedback: {} + goal: {} + goal_default: steps: null handles: {} placeholder_keys: {} @@ -75,38 +462,22 @@ xyz_stepper_controller: feedback: {} goal: properties: - acc: - default: 500 - type: integer - axis: - type: string - precision: - default: 50 - type: integer - speed: - default: 2000 - type: integer steps: type: integer required: - - axis - steps type: object result: {} required: - goal - title: move_to参数 + title: steps_to_degrees参数 type: object type: UniLabJsonCommand - auto-move_xyz_work: + auto-steps_to_revolutions: feedback: {} goal: {} goal_default: - acc: 1500 - speed: 100 - x: 0.0 - y: 0.0 - z: 0.0 + steps: null handles: {} placeholder_keys: {} result: {} @@ -116,35 +487,21 @@ xyz_stepper_controller: feedback: {} goal: properties: - acc: - default: 1500 + steps: type: integer - speed: - default: 100 - type: integer - x: - default: 0.0 - type: number - y: - default: 0.0 - type: number - z: - default: 0.0 - type: number - required: [] + required: + - steps type: object result: {} required: - goal - title: move_xyz_work参数 + title: steps_to_revolutions参数 type: object type: UniLabJsonCommand - auto-return_to_work_origin: + auto-stop_all_axes: feedback: {} goal: {} - goal_default: - acc: 800 - speed: 200 + goal_default: {} handles: {} placeholder_keys: {} result: {} @@ -153,22 +510,16 @@ xyz_stepper_controller: properties: feedback: {} goal: - properties: - acc: - default: 800 - type: integer - speed: - default: 200 - type: integer + properties: {} required: [] type: object result: {} required: - goal - title: return_to_work_origin参数 + title: stop_all_axes参数 type: object type: UniLabJsonCommand - auto-wait_complete: + auto-wait_for_completion: feedback: {} goal: {} goal_default: @@ -184,22 +535,23 @@ xyz_stepper_controller: goal: properties: axis: - type: string + type: object timeout: default: 30.0 - type: string + type: number required: - axis type: object result: {} required: - goal - title: wait_complete参数 + title: wait_for_completion参数 type: object type: UniLabJsonCommand - module: unilabos.devices.laiyu_liquid_test.xyz_stepper_driver:XYZStepperController + module: unilabos.devices.liquid_handling.laiyu.drivers.xyz_stepper_driver:XYZStepperController status_types: - status: list + all_positions: dict + motor_status: unilabos.devices.liquid_handling.laiyu.drivers.xyz_stepper_driver:MotorPosition type: python config_info: [] description: 新XYZ控制器 @@ -210,23 +562,24 @@ xyz_stepper_controller: properties: baudrate: default: 115200 - type: string - client: - type: string - origin_path: - default: unilabos/devices/laiyu_liquid_test/work_origin.json - type: string + type: integer port: - default: /dev/ttyUSB0 type: string - required: [] + timeout: + default: 1.0 + type: number + required: + - port type: object data: properties: - status: - type: array + all_positions: + type: object + motor_status: + type: object required: - - status + - motor_status + - all_positions type: object registry_type: device version: 1.0.0 diff --git a/unilabos/registry/devices/liquid_handler.yaml b/unilabos/registry/devices/liquid_handler.yaml index fdfb6b5..b0656d1 100644 --- a/unilabos/registry/devices/liquid_handler.yaml +++ b/unilabos/registry/devices/liquid_handler.yaml @@ -4497,6 +4497,9 @@ liquid_handler: simulator: default: false type: boolean + total_height: + default: 310 + type: number required: - backend - deck @@ -7547,6 +7550,35 @@ liquid_handler.prcxi: title: custom_delay参数 type: object type: UniLabJsonCommandAsync + auto-heater_action: + feedback: {} + goal: {} + goal_default: + temperature: null + time: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + temperature: + type: number + time: + type: integer + required: + - temperature + - time + type: object + result: {} + required: + - goal + title: heater_action参数 + type: object + type: UniLabJsonCommandAsync auto-iter_tips: feedback: {} goal: {} @@ -7688,6 +7720,43 @@ liquid_handler.prcxi: title: set_group参数 type: object type: UniLabJsonCommand + auto-shaker_action: + feedback: {} + goal: {} + goal_default: + amplitude: null + is_wait: null + module_no: null + time: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + amplitude: + type: integer + is_wait: + type: boolean + module_no: + type: integer + time: + type: integer + required: + - time + - module_no + - amplitude + - is_wait + type: object + result: {} + required: + - goal + title: shaker_action参数 + type: object + type: UniLabJsonCommandAsync auto-touch_tip: feedback: {} goal: {} @@ -8347,6 +8416,341 @@ liquid_handler.prcxi: title: LiquidHandlerMix type: object type: LiquidHandlerMix + move_plate: + feedback: {} + goal: + destination_offset: destination_offset + drop_direction: drop_direction + get_direction: get_direction + intermediate_locations: intermediate_locations + pickup_direction: pickup_direction + pickup_offset: pickup_offset + plate: plate + put_direction: put_direction + resource_offset: resource_offset + to: to + goal_default: + destination_offset: + x: 0.0 + y: 0.0 + z: 0.0 + drop_direction: '' + get_direction: '' + intermediate_locations: + - x: 0.0 + y: 0.0 + z: 0.0 + pickup_direction: '' + pickup_distance_from_top: 0.0 + pickup_offset: + x: 0.0 + y: 0.0 + z: 0.0 + plate: + 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: '' + put_direction: '' + resource_offset: + x: 0.0 + y: 0.0 + z: 0.0 + to: + 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: + plate: unilabos_resources + to: unilabos_resources + result: + name: name + schema: + description: '' + properties: + feedback: + properties: {} + required: [] + title: LiquidHandlerMovePlate_Feedback + type: object + goal: + properties: + destination_offset: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: destination_offset + type: object + drop_direction: + type: string + get_direction: + type: string + intermediate_locations: + items: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: intermediate_locations + type: object + type: array + pickup_direction: + type: string + pickup_distance_from_top: + type: number + pickup_offset: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: pickup_offset + type: object + plate: + 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: plate + type: object + put_direction: + type: string + resource_offset: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: resource_offset + type: object + to: + 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: to + type: object + required: + - plate + - to + - intermediate_locations + - resource_offset + - pickup_offset + - destination_offset + - pickup_direction + - drop_direction + - get_direction + - put_direction + - pickup_distance_from_top + title: LiquidHandlerMovePlate_Goal + type: object + result: + properties: + return_info: + type: string + success: + type: boolean + required: + - return_info + - success + title: LiquidHandlerMovePlate_Result + type: object + required: + - goal + title: LiquidHandlerMovePlate + type: object + type: LiquidHandlerMovePlate pick_up_tips: feedback: {} goal: diff --git a/unilabos/registry/devices/neware_battery_test_system.yaml b/unilabos/registry/devices/neware_battery_test_system.yaml index 0157819..ea6bedc 100644 --- a/unilabos/registry/devices/neware_battery_test_system.yaml +++ b/unilabos/registry/devices/neware_battery_test_system.yaml @@ -5,6 +5,73 @@ neware_battery_test_system: - battery_test class: action_value_mappings: + auto-post_init: + feedback: {} + goal: {} + goal_default: + ros_node: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + ros_node: + type: string + required: + - ros_node + type: object + result: {} + required: + - goal + title: post_init参数 + type: object + type: UniLabJsonCommand + auto-print_status_summary: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: print_status_summary参数 + type: object + type: UniLabJsonCommand + auto-test_connection: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: test_connection参数 + type: object + type: UniLabJsonCommand debug_resource_names: feedback: {} goal: {} @@ -407,6 +474,8 @@ neware_battery_test_system: status_types: channel_status: dict connection_info: dict + device_summary: dict + plate_status: dict status: str total_channels: int type: python @@ -418,36 +487,30 @@ neware_battery_test_system: config: properties: devtype: - default: '27' type: string ip: - default: 127.0.0.1 type: string machine_id: default: 1 type: integer oss_prefix: default: neware_backup - description: OSS对象路径前缀 type: string oss_upload_enabled: default: false - description: 是否启用OSS上传功能 type: boolean port: - default: 502 type: integer size_x: - default: 500.0 + default: 50 type: number size_y: - default: 500.0 + default: 50 type: number size_z: - default: 2000.0 + default: 20 type: number timeout: - default: 20 type: integer required: [] type: object @@ -459,6 +522,8 @@ neware_battery_test_system: type: object device_summary: type: object + plate_status: + type: object status: type: string total_channels: @@ -468,6 +533,7 @@ neware_battery_test_system: - channel_status - connection_info - total_channels + - plate_status - device_summary type: object version: 1.0.0 diff --git a/unilabos/registry/devices/opcua_example.yaml b/unilabos/registry/devices/opcua_example.yaml index a7e6b4e..0f500cf 100644 --- a/unilabos/registry/devices/opcua_example.yaml +++ b/unilabos/registry/devices/opcua_example.yaml @@ -49,7 +49,32 @@ opcua_example: title: load_config参数 type: object type: UniLabJsonCommand - auto-refresh_node_values: + auto-post_init: + feedback: {} + goal: {} + goal_default: + ros_node: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + ros_node: + type: string + required: + - ros_node + type: object + result: {} + required: + - goal + title: post_init参数 + type: object + type: UniLabJsonCommand + auto-print_cache_stats: feedback: {} goal: {} goal_default: {} @@ -67,7 +92,32 @@ opcua_example: result: {} required: - goal - title: refresh_node_values参数 + title: print_cache_stats参数 + type: object + type: UniLabJsonCommand + auto-read_node: + feedback: {} + goal: {} + goal_default: + node_name: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + node_name: + type: string + required: + - node_name + type: object + result: {} + required: + - goal + title: read_node参数 type: object type: UniLabJsonCommand auto-set_node_value: @@ -99,50 +149,9 @@ opcua_example: title: set_node_value参数 type: object type: UniLabJsonCommand - auto-start_node_refresh: - feedback: {} - goal: {} - goal_default: {} - handles: {} - placeholder_keys: {} - result: {} - schema: - description: '' - properties: - feedback: {} - goal: - properties: {} - required: [] - type: object - result: {} - required: - - goal - title: start_node_refresh参数 - type: object - type: UniLabJsonCommand - auto-stop_node_refresh: - feedback: {} - goal: {} - goal_default: {} - handles: {} - placeholder_keys: {} - result: {} - schema: - description: '' - properties: - feedback: {} - goal: - properties: {} - required: [] - type: object - result: {} - required: - - goal - title: stop_node_refresh参数 - type: object - type: UniLabJsonCommand module: unilabos.device_comms.opcua_client.client:OpcUaClient status_types: + cache_stats: dict node_value: String type: python config_info: [] @@ -152,15 +161,23 @@ opcua_example: init_param_schema: config: properties: + cache_timeout: + default: 5.0 + type: number config_path: type: string + deck: + type: string password: type: string - refresh_interval: - default: 1.0 - type: number + subscription_interval: + default: 500 + type: integer url: type: string + use_subscription: + default: true + type: boolean username: type: string required: @@ -168,9 +185,12 @@ opcua_example: type: object data: properties: + cache_stats: + type: object node_value: type: string required: - node_value + - cache_stats type: object version: 1.0.0 diff --git a/unilabos/registry/devices/post_process_station.yaml b/unilabos/registry/devices/post_process_station.yaml index cf4a11b..be42bad 100644 --- a/unilabos/registry/devices/post_process_station.yaml +++ b/unilabos/registry/devices/post_process_station.yaml @@ -3,6 +3,106 @@ post_process_station: - post_process_station class: action_value_mappings: + auto-load_config: + feedback: {} + goal: {} + goal_default: + config_path: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + config_path: + type: string + required: + - config_path + type: object + result: {} + required: + - goal + title: load_config参数 + type: object + type: UniLabJsonCommand + auto-post_init: + feedback: {} + goal: {} + goal_default: + ros_node: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + ros_node: + type: string + required: + - ros_node + type: object + result: {} + required: + - goal + title: post_init参数 + type: object + type: UniLabJsonCommand + auto-print_cache_stats: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: print_cache_stats参数 + type: object + type: UniLabJsonCommand + auto-set_node_value: + feedback: {} + goal: {} + goal_default: + name: null + value: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + result: {} + required: + - goal + title: set_node_value参数 + type: object + type: UniLabJsonCommand disconnect: feedback: {} goal: @@ -602,29 +702,46 @@ post_process_station: type: SendCmd module: unilabos.devices.workstation.post_process.post_process:OpcUaClient status_types: - acetone_tank_empty_alarm: Bool - atomization_fast_speed: Float64 - atomization_pressure_kpa: Int32 - cleaning_complete: Bool - device_ready: Bool - door_open_alarm: Bool - grab_complete: Bool - grab_trigger: Bool - injection_pump_push_speed: Int32 - injection_pump_suction_speed: Int32 - nmp_tank_empty_alarm: Bool - post_process_complete: Bool - post_process_trigger: Bool - raw_tank_number: Int32 - reaction_tank_number: Int32 - remote_mode: Bool - wash_slow_speed: Float64 - waste_tank_full_alarm: Bool - water_tank_empty_alarm: Bool + cache_stats: dict + node_value: String type: python config_info: [] description: 后处理站 handles: [] icon: post_process_station.webp - init_param_schema: {} + init_param_schema: + config: + properties: + cache_timeout: + default: 5.0 + type: number + config_path: + type: string + deck: + type: string + password: + type: string + subscription_interval: + default: 500 + type: integer + url: + type: string + use_subscription: + default: true + type: boolean + username: + type: string + required: + - url + type: object + data: + properties: + cache_stats: + type: object + node_value: + type: string + required: + - node_value + - cache_stats + type: object version: 1.0.0 diff --git a/unilabos/registry/resources/post_process/bottle_carriers.yaml b/unilabos/registry/resources/post_process/bottle_carriers.yaml index df0391a..ea30cb7 100644 --- a/unilabos/registry/resources/post_process/bottle_carriers.yaml +++ b/unilabos/registry/resources/post_process/bottle_carriers.yaml @@ -10,7 +10,6 @@ POST_PROCESS_Raw_1BottleCarrier: init_param_schema: {} registry_type: resource version: 1.0.0 - POST_PROCESS_Reaction_1BottleCarrier: category: - bottle_carriers diff --git a/unilabos/registry/resources/post_process/bottles.yaml b/unilabos/registry/resources/post_process/bottles.yaml index 4243a21..25fc397 100644 --- a/unilabos/registry/resources/post_process/bottles.yaml +++ b/unilabos/registry/resources/post_process/bottles.yaml @@ -8,4 +8,3 @@ POST_PROCESS_PolymerStation_Reagent_Bottle: icon: '' init_param_schema: {} version: 1.0.0 - diff --git a/unilabos/registry/resources/post_process/deck.yaml b/unilabos/registry/resources/post_process/deck.yaml index 7c1f49d..621cafc 100644 --- a/unilabos/registry/resources/post_process/deck.yaml +++ b/unilabos/registry/resources/post_process/deck.yaml @@ -1,6 +1,7 @@ post_process_deck: category: - post_process_deck + - deck class: module: unilabos.devices.workstation.post_process.decks:post_process_deck type: pylabrobot diff --git a/unilabos/registry/resources/prcxi/plate_adapters.yaml b/unilabos/registry/resources/prcxi/plate_adapters.yaml index 027b932..a769fee 100644 --- a/unilabos/registry/resources/prcxi/plate_adapters.yaml +++ b/unilabos/registry/resources/prcxi/plate_adapters.yaml @@ -1,6 +1,7 @@ PRCXI_30mm_Adapter: - category: + category: - prcxi + - plate_adapters class: module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_30mm_Adapter type: pylabrobot @@ -11,8 +12,9 @@ PRCXI_30mm_Adapter: registry_type: resource version: 1.0.0 PRCXI_Adapter: - category: - - prcxi + category: + - prcxi + - plate_adapters class: module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Adapter type: pylabrobot @@ -23,8 +25,9 @@ PRCXI_Adapter: registry_type: resource version: 1.0.0 PRCXI_Deep10_Adapter: - category: + category: - prcxi + - plate_adapters class: module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Deep10_Adapter type: pylabrobot @@ -35,8 +38,9 @@ PRCXI_Deep10_Adapter: registry_type: resource version: 1.0.0 PRCXI_Deep300_Adapter: - category: + category: - prcxi + - plate_adapters class: module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Deep300_Adapter type: pylabrobot @@ -47,8 +51,9 @@ PRCXI_Deep300_Adapter: registry_type: resource version: 1.0.0 PRCXI_PCR_Adapter: - category: + category: - prcxi + - plate_adapters class: module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_PCR_Adapter type: pylabrobot @@ -59,8 +64,9 @@ PRCXI_PCR_Adapter: registry_type: resource version: 1.0.0 PRCXI_Reservoir_Adapter: - category: + category: - prcxi + - plate_adapters class: module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Reservoir_Adapter type: pylabrobot @@ -73,6 +79,7 @@ PRCXI_Reservoir_Adapter: PRCXI_Tip10_Adapter: category: - prcxi + - plate_adapters class: module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Tip10_Adapter type: pylabrobot @@ -85,6 +92,7 @@ PRCXI_Tip10_Adapter: PRCXI_Tip1250_Adapter: category: - prcxi + - plate_adapters class: module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Tip1250_Adapter type: pylabrobot @@ -97,6 +105,7 @@ PRCXI_Tip1250_Adapter: PRCXI_Tip300_Adapter: category: - prcxi + - plate_adapters class: module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Tip300_Adapter type: pylabrobot diff --git a/unilabos/registry/resources/prcxi/plates.yaml b/unilabos/registry/resources/prcxi/plates.yaml index 68bae48..81e2ae9 100644 --- a/unilabos/registry/resources/prcxi/plates.yaml +++ b/unilabos/registry/resources/prcxi/plates.yaml @@ -1,6 +1,7 @@ PRCXI_48_DeepWell: - category: + category: - prcxi + - plates class: module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_48_DeepWell type: pylabrobot @@ -11,8 +12,9 @@ PRCXI_48_DeepWell: registry_type: resource version: 1.0.0 PRCXI_96_DeepWell: - category: + category: - prcxi + - plates class: module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_96_DeepWell type: pylabrobot @@ -23,8 +25,9 @@ PRCXI_96_DeepWell: registry_type: resource version: 1.0.0 PRCXI_AGenBio_4_troughplate: - category: + category: - prcxi + - plates class: module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_AGenBio_4_troughplate type: pylabrobot @@ -35,8 +38,9 @@ PRCXI_AGenBio_4_troughplate: registry_type: resource version: 1.0.0 PRCXI_BioER_96_wellplate: - category: + category: - prcxi + - plates class: module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_BioER_96_wellplate type: pylabrobot @@ -47,8 +51,9 @@ PRCXI_BioER_96_wellplate: registry_type: resource version: 1.0.0 PRCXI_BioRad_384_wellplate: - category: + category: - prcxi + - plates class: module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_BioRad_384_wellplate type: pylabrobot @@ -59,8 +64,9 @@ PRCXI_BioRad_384_wellplate: registry_type: resource version: 1.0.0 PRCXI_CellTreat_96_wellplate: - category: + category: - prcxi + - plates class: module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_CellTreat_96_wellplate type: pylabrobot @@ -71,8 +77,9 @@ PRCXI_CellTreat_96_wellplate: registry_type: resource version: 1.0.0 PRCXI_PCR_Plate_200uL_nonskirted: - category: + category: - prcxi + - plates class: module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_PCR_Plate_200uL_nonskirted type: pylabrobot @@ -83,8 +90,9 @@ PRCXI_PCR_Plate_200uL_nonskirted: registry_type: resource version: 1.0.0 PRCXI_PCR_Plate_200uL_semiskirted: - category: + category: - prcxi + - plates class: module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_PCR_Plate_200uL_semiskirted type: pylabrobot @@ -95,8 +103,9 @@ PRCXI_PCR_Plate_200uL_semiskirted: registry_type: resource version: 1.0.0 PRCXI_PCR_Plate_200uL_skirted: - category: + category: - prcxi + - plates class: module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_PCR_Plate_200uL_skirted type: pylabrobot @@ -107,8 +116,9 @@ PRCXI_PCR_Plate_200uL_skirted: registry_type: resource version: 1.0.0 PRCXI_nest_12_troughplate: - category: + category: - prcxi + - plates class: module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_nest_12_troughplate type: pylabrobot @@ -119,8 +129,9 @@ PRCXI_nest_12_troughplate: registry_type: resource version: 1.0.0 PRCXI_nest_1_troughplate: - category: + category: - prcxi + - plates class: module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_nest_1_troughplate type: pylabrobot diff --git a/unilabos/registry/resources/prcxi/tip_racks.yaml b/unilabos/registry/resources/prcxi/tip_racks.yaml index ef2c599..56a16db 100644 --- a/unilabos/registry/resources/prcxi/tip_racks.yaml +++ b/unilabos/registry/resources/prcxi/tip_racks.yaml @@ -1,6 +1,7 @@ PRCXI_1000uL_Tips: - category: + category: - prcxi + - tip_racks class: module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_1000uL_Tips type: pylabrobot @@ -11,8 +12,9 @@ PRCXI_1000uL_Tips: registry_type: resource version: 1.0.0 PRCXI_10uL_Tips: - category: + category: - prcxi + - tip_racks class: module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_10uL_Tips type: pylabrobot @@ -23,8 +25,9 @@ PRCXI_10uL_Tips: registry_type: resource version: 1.0.0 PRCXI_10ul_eTips: - category: + category: - prcxi + - tip_racks class: module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_10ul_eTips type: pylabrobot @@ -35,8 +38,9 @@ PRCXI_10ul_eTips: registry_type: resource version: 1.0.0 PRCXI_1250uL_Tips: - category: + category: - prcxi + - tip_racks class: module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_1250uL_Tips type: pylabrobot @@ -47,8 +51,9 @@ PRCXI_1250uL_Tips: registry_type: resource version: 1.0.0 PRCXI_200uL_Tips: - category: + category: - prcxi + - tip_racks class: module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_200uL_Tips type: pylabrobot @@ -59,8 +64,9 @@ PRCXI_200uL_Tips: registry_type: resource version: 1.0.0 PRCXI_300ul_Tips: - category: + category: - prcxi + - tip_racks class: module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_300ul_Tips type: pylabrobot diff --git a/unilabos/registry/resources/prcxi/trash.yaml b/unilabos/registry/resources/prcxi/trash.yaml index b71d1cf..f87a762 100644 --- a/unilabos/registry/resources/prcxi/trash.yaml +++ b/unilabos/registry/resources/prcxi/trash.yaml @@ -1,6 +1,7 @@ PRCXI_trash: - category: + category: - prcxi + - trash class: module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_trash type: pylabrobot diff --git a/unilabos/registry/resources/prcxi/tube_racks.yaml b/unilabos/registry/resources/prcxi/tube_racks.yaml index 4aa7e95..0b1e07c 100644 --- a/unilabos/registry/resources/prcxi/tube_racks.yaml +++ b/unilabos/registry/resources/prcxi/tube_racks.yaml @@ -1,6 +1,7 @@ PRCXI_EP_Adapter: - category: + category: - prcxi + - tube_racks class: module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_EP_Adapter type: pylabrobot diff --git a/unilabos/resources/graphio.py b/unilabos/resources/graphio.py index 0d9b19e..f91b972 100644 --- a/unilabos/resources/graphio.py +++ b/unilabos/resources/graphio.py @@ -13,7 +13,7 @@ from unilabos.config.config import BasicConfig from unilabos.resources.container import RegularContainer from unilabos.resources.itemized_carrier import ItemizedCarrier, BottleCarrier from unilabos.ros.msgs.message_converter import convert_to_ros_msg -from unilabos.ros.nodes.resource_tracker import ( +from unilabos.resources.resource_tracker import ( ResourceDictInstance, ResourceTreeSet, ) @@ -134,7 +134,7 @@ def canonicalize_nodes_data( parent_instance.children.append(current_instance) # 第五步:创建 ResourceTreeSet - resource_tree_set = ResourceTreeSet.from_nested_list(standardized_instances) + resource_tree_set = ResourceTreeSet.from_nested_instance_list(standardized_instances) return resource_tree_set diff --git a/unilabos/resources/itemized_carrier.py b/unilabos/resources/itemized_carrier.py index 3b3454a..a5207d4 100644 --- a/unilabos/resources/itemized_carrier.py +++ b/unilabos/resources/itemized_carrier.py @@ -149,6 +149,7 @@ class ItemizedCarrier(ResourcePLR): if not reassign and self.sites[idx] is not None: raise ValueError(f"a site with index {idx} already exists") + location = list(self.child_locations.values())[idx] super().assign_child_resource(resource, location=location, reassign=reassign) self.sites[idx] = resource diff --git a/unilabos/ros/nodes/resource_tracker.py b/unilabos/resources/resource_tracker.py similarity index 97% rename from unilabos/ros/nodes/resource_tracker.py rename to unilabos/resources/resource_tracker.py index 849d64a..610ba3d 100644 --- a/unilabos/ros/nodes/resource_tracker.py +++ b/unilabos/resources/resource_tracker.py @@ -14,9 +14,9 @@ if TYPE_CHECKING: class ResourceDictPositionSize(BaseModel): - depth: float = Field(description="Depth", default=0.0) - width: float = Field(description="Width", default=0.0) - height: float = Field(description="Height", default=0.0) + depth: float = Field(description="Depth", default=0.0) # z + width: float = Field(description="Width", default=0.0) # x + height: float = Field(description="Height", default=0.0) # y class ResourceDictPositionScale(BaseModel): @@ -146,8 +146,20 @@ class ResourceDictInstance(object): content["data"] = {} if not content.get("extra"): # MagicCode content["extra"] = {} - if "pose" not in content: - content["pose"] = content.pop("position", {}) + if "position" in content: + pose = content.get("pose",{}) + if "position" not in pose : + if "position" in content["position"]: + pose["position"] = content["position"]["position"] + else: + pose["position"] = {"x": 0, "y": 0, "z": 0} + if "size" not in pose: + pose["size"] = { + "width": content["config"].get("size_x", 0), + "height": content["config"].get("size_y", 0), + "depth": content["config"].get("size_z", 0) + } + content["pose"] = pose return ResourceDictInstance(ResourceDict.model_validate(content)) def get_plr_nested_dict(self) -> Dict[str, Any]: @@ -436,7 +448,7 @@ class ResourceTreeSet(object): from pylabrobot.utils.object_parsing import find_subclass # 类型映射 - TYPE_MAP = {"plate": "Plate", "well": "Well", "deck": "Deck", "container": "RegularContainer"} + TYPE_MAP = {"plate": "Plate", "well": "Well", "deck": "Deck", "container": "RegularContainer", "tip_spot": "TipSpot"} def collect_node_data(node: ResourceDictInstance, name_to_uuid: dict, all_states: dict, name_to_extra: dict): """一次遍历收集 name_to_uuid, all_states 和 name_to_extra""" @@ -457,9 +469,9 @@ class ResourceTreeSet(object): **res.config, "name": res.name, "type": res.config.get("type", plr_type), - "size_x": res.config.get("size_x", 0), - "size_y": res.config.get("size_y", 0), - "size_z": res.config.get("size_z", 0), + "size_x": res.pose.size.width, + "size_y": res.pose.size.height, + "size_z": res.pose.size.depth, "location": { "x": res.pose.position.x, "y": res.pose.position.y, @@ -511,7 +523,7 @@ class ResourceTreeSet(object): return plr_resources @classmethod - def from_raw_list(cls, raw_list: List[Dict[str, Any]]) -> "ResourceTreeSet": + def from_raw_dict_list(cls, raw_list: List[Dict[str, Any]]) -> "ResourceTreeSet": """ 从原始字典列表创建 ResourceTreeSet,自动建立 parent-children 关系 @@ -561,10 +573,10 @@ class ResourceTreeSet(object): parent_instance.children.append(instance) # 第四步:使用 from_nested_list 创建 ResourceTreeSet - return cls.from_nested_list(instances) + return cls.from_nested_instance_list(instances) @classmethod - def from_nested_list(cls, nested_list: List[ResourceDictInstance]) -> "ResourceTreeSet": + def from_nested_instance_list(cls, nested_list: List[ResourceDictInstance]) -> "ResourceTreeSet": """ 从扁平化的资源列表创建ResourceTreeSet,自动按根节点分组 @@ -773,7 +785,7 @@ class ResourceTreeSet(object): """ nested_lists = [] for tree_data in data: - nested_lists.extend(ResourceTreeSet.from_raw_list(tree_data).trees) + nested_lists.extend(ResourceTreeSet.from_raw_dict_list(tree_data).trees) return cls(nested_lists) @@ -953,7 +965,7 @@ class DeviceNodeResourceTracker(object): if current_uuid in self.uuid_to_resources: self.uuid_to_resources.pop(current_uuid) self.uuid_to_resources[new_uuid] = res - logger.debug(f"更新uuid: {current_uuid} -> {new_uuid}") + logger.trace(f"更新uuid: {current_uuid} -> {new_uuid}") replaced = 1 return replaced diff --git a/unilabos/ros/device_node_wrapper.py b/unilabos/ros/device_node_wrapper.py index f5e80c5..db9caa4 100644 --- a/unilabos/ros/device_node_wrapper.py +++ b/unilabos/ros/device_node_wrapper.py @@ -5,7 +5,7 @@ from unilabos.ros.msgs.message_converter import ( get_action_type, ) from unilabos.ros.nodes.base_device_node import init_wrapper, ROS2DeviceNode -from unilabos.ros.nodes.resource_tracker import ResourceDictInstance +from unilabos.resources.resource_tracker import ResourceDictInstance # 定义泛型类型变量 T = TypeVar("T") diff --git a/unilabos/ros/initialize_device.py b/unilabos/ros/initialize_device.py index 55ac145..675814a 100644 --- a/unilabos/ros/initialize_device.py +++ b/unilabos/ros/initialize_device.py @@ -1,10 +1,9 @@ -import copy from typing import Optional from unilabos.registry.registry import lab_registry from unilabos.ros.device_node_wrapper import ros2_device_node from unilabos.ros.nodes.base_device_node import ROS2DeviceNode, DeviceInitError -from unilabos.ros.nodes.resource_tracker import ResourceDictInstance +from unilabos.resources.resource_tracker import ResourceDictInstance from unilabos.utils import logger from unilabos.utils.exception import DeviceClassInvalid from unilabos.utils.import_manager import default_manager diff --git a/unilabos/ros/main_slave_run.py b/unilabos/ros/main_slave_run.py index 4373cea..b79c368 100644 --- a/unilabos/ros/main_slave_run.py +++ b/unilabos/ros/main_slave_run.py @@ -10,7 +10,7 @@ from unilabos_msgs.srv._serial_command import SerialCommand_Response from unilabos.app.register import register_devices_and_resources from unilabos.ros.nodes.presets.resource_mesh_manager import ResourceMeshManager -from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker, ResourceTreeSet +from unilabos.resources.resource_tracker import DeviceNodeResourceTracker, ResourceTreeSet from unilabos.devices.ros_dev.liquid_handler_joint_publisher import LiquidHandlerJointPublisher from unilabos_msgs.srv import SerialCommand # type: ignore from rclpy.executors import MultiThreadedExecutor diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index 6952320..89c4d39 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -1,18 +1,17 @@ -import copy import inspect import io import json import threading import time import traceback -from typing import get_type_hints, TypeVar, Generic, Dict, Any, Type, TypedDict, Optional, List, TYPE_CHECKING, Union +from typing import get_type_hints, TypeVar, Generic, Dict, Any, Type, TypedDict, Optional, List, TYPE_CHECKING, Union, \ + Tuple from concurrent.futures import ThreadPoolExecutor import asyncio import rclpy import yaml -from msgcenterpy import ROS2MessageInstance from rclpy.node import Node from rclpy.action import ActionServer, ActionClient from rclpy.action.server import ServerGoalHandle @@ -22,13 +21,12 @@ from rclpy.service import Service from unilabos_msgs.action import SendCmd from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response +from unilabos.config.config import BasicConfig +from unilabos.utils.decorator import get_topic_config, get_all_subscriptions + from unilabos.resources.container import RegularContainer from unilabos.resources.graphio import ( - resource_ulab_to_plr, initialize_resources, - dict_to_tree, - resource_plr_to_ulab, - tree_to_list, ) from unilabos.resources.plr_additional_res_reg import register from unilabos.ros.msgs.message_converter import ( @@ -45,10 +43,11 @@ from unilabos_msgs.srv import ( ) # type: ignore from unilabos_msgs.msg import Resource # type: ignore -from unilabos.ros.nodes.resource_tracker import ( +from unilabos.resources.resource_tracker import ( DeviceNodeResourceTracker, ResourceTreeSet, - ResourceTreeInstance, ResourceDictInstance, + ResourceTreeInstance, + ResourceDictInstance, ) from unilabos.ros.x.rclpyx import get_event_loop from unilabos.ros.utils.driver_creator import WorkstationNodeCreator, PyLabRobotCreator, DeviceClassCreator @@ -168,6 +167,7 @@ class PropertyPublisher: msg_type, initial_period: float = 5.0, print_publish=True, + qos: int = 10, ): self.node = node self.name = name @@ -175,10 +175,11 @@ class PropertyPublisher: self.get_method = get_method self.timer_period = initial_period self.print_publish = print_publish + self.qos = qos self._value = None try: - self.publisher_ = node.create_publisher(msg_type, f"{name}", 10) + self.publisher_ = node.create_publisher(msg_type, f"{name}", qos) except AttributeError as ex: self.node.lab_logger().error( f"创建发布者 {name} 失败,可能由于注册表有误,类型: {msg_type},错误: {ex}\n{traceback.format_exc()}" @@ -186,7 +187,7 @@ class PropertyPublisher: self.timer = node.create_timer(self.timer_period, self.publish_property) self.__loop = get_event_loop() str_msg_type = str(msg_type)[8:-2] - self.node.lab_logger().trace(f"发布属性: {name}, 类型: {str_msg_type}, 周期: {initial_period}秒") + self.node.lab_logger().trace(f"发布属性: {name}, 类型: {str_msg_type}, 周期: {initial_period}秒, QoS: {qos}") def get_property(self): if asyncio.iscoroutinefunction(self.get_method): @@ -326,6 +327,10 @@ class BaseROS2DeviceNode(Node, Generic[T]): continue self.create_ros_action_server(action_name, action_value_mapping) + # 创建订阅者(通过 @subscribe 装饰器) + self._topic_subscribers: Dict[str, Any] = {} + self._setup_decorated_subscribers() + # 创建线程池执行器 self._executor = ThreadPoolExecutor( max_workers=max(len(action_value_mappings), 1), thread_name_prefix=f"ROSDevice{self.device_id}" @@ -354,78 +359,81 @@ class BaseROS2DeviceNode(Node, Generic[T]): return res async def append_resource(req: SerialCommand_Request, res: SerialCommand_Response): + from pylabrobot.resources.deck import Deck + from pylabrobot.resources import Coordinate + from pylabrobot.resources import Plate # 物料传输到对应的node节点 - rclient = self.create_client(ResourceAdd, "/resources/add") - rclient.wait_for_service() - rclient2 = self.create_client(ResourceAdd, "/resources/add") - rclient2.wait_for_service() - request = ResourceAdd.Request() - request2 = ResourceAdd.Request() + client = self._resource_clients["c2s_update_resource_tree"] + request = SerialCommand.Request() + request2 = SerialCommand.Request() command_json = json.loads(req.command) namespace = command_json["namespace"] bind_parent_id = command_json["bind_parent_id"] edge_device_id = command_json["edge_device_id"] location = command_json["bind_location"] other_calling_param = command_json["other_calling_param"] - resources = command_json["resource"] + input_resources = command_json["resource"] initialize_full = other_calling_param.pop("initialize_full", False) # 用来增加液体 ADD_LIQUID_TYPE = other_calling_param.pop("ADD_LIQUID_TYPE", []) - LIQUID_VOLUME = other_calling_param.pop("LIQUID_VOLUME", []) - LIQUID_INPUT_SLOT = other_calling_param.pop("LIQUID_INPUT_SLOT", []) + LIQUID_VOLUME: List[float] = other_calling_param.pop("LIQUID_VOLUME", []) + LIQUID_INPUT_SLOT: List[int] = other_calling_param.pop("LIQUID_INPUT_SLOT", []) slot = other_calling_param.pop("slot", "-1") - resource = None - if slot != "-1": # slot为负数的时候采用assign方法 + if slot != -1: # slot为负数的时候采用assign方法 other_calling_param["slot"] = slot - # 本地拿到这个物料,可能需要先做初始化? - if isinstance(resources, list): - if ( - len(resources) == 1 and isinstance(resources[0], list) and not initialize_full - ): # 取消,不存在的情况 - # 预先initialize过,以整组的形式传入 - request.resources = [convert_to_ros_msg(Resource, resource_) for resource_ in resources[0]] - elif initialize_full: - resources = initialize_resources(resources) - request.resources = [convert_to_ros_msg(Resource, resource) for resource in resources] - else: - request.resources = [convert_to_ros_msg(Resource, resource) for resource in resources] - else: - if initialize_full: - resources = initialize_resources([resources]) - request.resources = [convert_to_ros_msg(Resource, resources)] - if len(LIQUID_INPUT_SLOT) and LIQUID_INPUT_SLOT[0] == -1: - container_instance = request.resources[0] - container_query_dict: dict = resources + # 本地拿到这个物料,可能需要先做初始化 + if isinstance(input_resources, list) and initialize_full: + input_resources = initialize_resources(input_resources) + elif initialize_full: + input_resources = initialize_resources([input_resources]) + rts: ResourceTreeSet = ResourceTreeSet.from_raw_dict_list(input_resources) + parent_resource = None + if bind_parent_id != self.node_name: + parent_resource = self.resource_tracker.figure_resource( + {"name": bind_parent_id} + ) + for r in rts.root_nodes: + # noinspection PyUnresolvedReferences + r.res_content.parent_uuid = parent_resource.unilabos_uuid + + if len(LIQUID_INPUT_SLOT) and LIQUID_INPUT_SLOT[0] == -1 and len(rts.root_nodes) == 1 and isinstance(rts.root_nodes[0], RegularContainer): + # noinspection PyTypeChecker + container_instance: RegularContainer = rts.root_nodes[0] found_resources = self.resource_tracker.figure_resource( - {"id": container_query_dict["name"]}, try_mode=True + {"id": container_instance.name}, try_mode=True ) if not len(found_resources): self.resource_tracker.add_resource(container_instance) - logger.info(f"添加物料{container_query_dict['name']}到资源跟踪器") + logger.info(f"添加物料{container_instance.name}到资源跟踪器") else: assert ( len(found_resources) == 1 - ), f"找到多个同名物料: {container_query_dict['name']}, 请检查物料系统" - resource = found_resources[0] - if isinstance(resource, Resource): - regular_container = RegularContainer(resource.id) - regular_container.ulr_resource = resource - regular_container.ulr_resource_data.update(json.loads(container_instance.data)) - logger.info(f"更新物料{container_query_dict['name']}的数据{resource.data} ULR") - elif isinstance(resource, dict): - if "data" not in resource: - resource["data"] = {} - resource["data"].update(json.loads(container_instance.data)) - request.resources[0].name = resource["name"] - logger.info(f"更新物料{container_query_dict['name']}的数据{resource['data']} dict") + ), f"找到多个同名物料: {container_instance.name}, 请检查物料系统" + found_resource = found_resources[0] + if isinstance(found_resource, RegularContainer): + logger.info(f"更新物料{container_instance.name}的数据{found_resource.state}") + found_resource.state.update(json.loads(container_instance.state)) + elif isinstance(found_resource, dict): + raise ValueError("已不支持 字典 版本的RegularContainer") else: logger.info( - f"更新物料{container_query_dict['name']}出现不支持的数据类型{type(resource)} {resource}" + f"更新物料{container_instance.name}出现不支持的数据类型{type(found_resource)} {found_resource}" ) - response: ResourceAdd.Response = await rclient.call_async(request) - # 应该先add_resource了 + # noinspection PyUnresolvedReferences + request.command = json.dumps({ + "action": "add", + "data": { + "data": rts.dump(), + "mount_uuid": parent_resource.unilabos_uuid if parent_resource is not None else "", + "first_add": False, + }, + }) + tree_response: SerialCommand.Response = await client.call_async(request) + uuid_maps = json.loads(tree_response.response) + self.resource_tracker.loop_update_uuid(input_resources, uuid_maps) + self.lab_logger().info(f"Resource tree added. UUID mapping: {len(uuid_maps)} nodes") final_response = { - "created_resources": [ROS2MessageInstance(i).get_python_dict() for i in request.resources], + "created_resources": rts.dump(), "liquid_input_resources": [], } res.response = json.dumps(final_response) @@ -450,59 +458,63 @@ class BaseROS2DeviceNode(Node, Generic[T]): ) res.response = get_result_info_str(traceback.format_exc(), False, {}) return res - # 接下来该根据bind_parent_id进行assign了,目前只有plr可以进行assign,不然没有办法输入到物料系统中 - if bind_parent_id != self.node_name: - resource = self.resource_tracker.figure_resource( - {"name": bind_parent_id} - ) # 拿到父节点,进行具体assign等操作 - # request.resources = [convert_to_ros_msg(Resource, resources)] - try: - from pylabrobot.resources.resource import Resource as ResourcePLR - from pylabrobot.resources.deck import Deck - from pylabrobot.resources import Coordinate - from pylabrobot.resources import OTDeck - from pylabrobot.resources import Plate - - contain_model = not isinstance(resource, Deck) - if isinstance(resource, ResourcePLR): - # resources.list() - plr_instance = ResourceTreeSet.from_raw_list(resources).to_plr_resources()[0] - # resources_tree = dict_to_tree(copy.deepcopy({r["id"]: r for r in resources})) - # plr_instance = resource_ulab_to_plr(resources_tree[0], contain_model) - + if len(rts.root_nodes) == 1 and parent_resource is not None: + plr_instance = rts.to_plr_resources()[0] if isinstance(plr_instance, Plate): - empty_liquid_info_in = [(None, 0)] * plr_instance.num_items + empty_liquid_info_in: List[Tuple[Optional[str], float]] = [(None, 0)] * plr_instance.num_items + if len(ADD_LIQUID_TYPE) == 1 and len(LIQUID_VOLUME) == 1 and len(LIQUID_INPUT_SLOT) > 1: + ADD_LIQUID_TYPE = ADD_LIQUID_TYPE * len(LIQUID_INPUT_SLOT) + LIQUID_VOLUME = LIQUID_VOLUME * len(LIQUID_INPUT_SLOT) + self.lab_logger().warning(f"增加液体资源时,数量为1,自动补全为 {len(LIQUID_INPUT_SLOT)} 个") for liquid_type, liquid_volume, liquid_input_slot in zip( ADD_LIQUID_TYPE, LIQUID_VOLUME, LIQUID_INPUT_SLOT ): empty_liquid_info_in[liquid_input_slot] = (liquid_type, liquid_volume) plr_instance.set_well_liquids(empty_liquid_info_in) - input_wells_ulr = [ - convert_to_ros_msg( - Resource, - resource_plr_to_ulab(plr_instance.get_well(LIQUID_INPUT_SLOT), with_children=False), - ) - for r in LIQUID_INPUT_SLOT - ] - final_response["liquid_input_resources"] = [ - ROS2MessageInstance(i).get_python_dict() for i in input_wells_ulr - ] + try: + # noinspection PyProtectedMember + keys = list(plr_instance._ordering.keys()) + for ind, r in enumerate(LIQUID_INPUT_SLOT[:]): + if isinstance(r, int): + # noinspection PyTypeChecker + LIQUID_INPUT_SLOT[ind] = keys[r] + input_wells = [plr_instance.get_well(r) for r in LIQUID_INPUT_SLOT] + except AttributeError: + # 按照id回去失败,回退到children + input_wells = [] + for r in LIQUID_INPUT_SLOT: + input_wells.append(plr_instance.children[r]) + final_response["liquid_input_resources"] = ResourceTreeSet.from_plr_resources(input_wells).dump() res.response = json.dumps(final_response) - if isinstance(resource, OTDeck) and "slot" in other_calling_param: + if issubclass(parent_resource.__class__, Deck) and hasattr(parent_resource, "assign_child_at_slot") and "slot" in other_calling_param: other_calling_param["slot"] = int(other_calling_param["slot"]) - resource.assign_child_at_slot(plr_instance, **other_calling_param) + parent_resource.assign_child_at_slot(plr_instance, **other_calling_param) else: - _discard_slot = other_calling_param.pop("slot", "-1") - resource.assign_child_resource( + _discard_slot = other_calling_param.pop("slot", -1) + parent_resource.assign_child_resource( plr_instance, Coordinate(location["x"], location["y"], location["z"]), **other_calling_param, ) - request2.resources = [ - convert_to_ros_msg(Resource, r) for r in tree_to_list([resource_plr_to_ulab(resource)]) - ] - rclient2.call(request2) + # 调整了液体以及Deck之后要重新Assign + # noinspection PyUnresolvedReferences + rts_with_parent = ResourceTreeSet.from_plr_resources([parent_resource]) + if rts_with_parent.root_nodes[0].res_content.uuid_parent is None: + rts_with_parent.root_nodes[0].res_content.parent_uuid = self.uuid + request.command = json.dumps({ + "action": "add", + "data": { + "data": rts_with_parent.dump(), + "mount_uuid": rts_with_parent.root_nodes[0].res_content.uuid_parent, + "first_add": False, + }, + }) + tree_response: SerialCommand.Response = await client.call_async(request) + uuid_maps = json.loads(tree_response.response) + self.resource_tracker.loop_update_uuid(input_resources, uuid_maps) + self._lab_logger.info(f"Resource tree added. UUID mapping: {len(uuid_maps)} nodes") + # 这里created_resources不包含parent_resource # 发送给ResourceMeshManager action_client = ActionClient( self, @@ -513,7 +525,7 @@ class BaseROS2DeviceNode(Node, Generic[T]): goal = SendCmd.Goal() goal.command = json.dumps( { - "resources": resources, + "resources": input_resources, "bind_parent_id": bind_parent_id, } ) @@ -606,7 +618,7 @@ class BaseROS2DeviceNode(Node, Generic[T]): ) ) # type: ignore raw_nodes = json.loads(response.response) - tree_set = ResourceTreeSet.from_raw_list(raw_nodes) + tree_set = ResourceTreeSet.from_raw_dict_list(raw_nodes) self.lab_logger().debug(f"获取资源结果: {len(tree_set.trees)} 个资源树") return tree_set @@ -634,7 +646,7 @@ class BaseROS2DeviceNode(Node, Generic[T]): raw_data = json.loads(response.response) # 转换为 PLR 资源 - tree_set = ResourceTreeSet.from_raw_list(raw_data) + tree_set = ResourceTreeSet.from_raw_dict_list(raw_data) plr_resource = tree_set.to_plr_resources()[0] self.lab_logger().debug(f"获取资源 {resource_id} 成功") return plr_resource @@ -779,8 +791,8 @@ class BaseROS2DeviceNode(Node, Generic[T]): } def _handle_update( - plr_resources: List[ResourcePLR], tree_set: ResourceTreeSet, additional_add_params: Dict[str, Any] - ) -> Dict[str, Any]: + plr_resources: List[Union[ResourcePLR, ResourceDictInstance]], tree_set: ResourceTreeSet, additional_add_params: Dict[str, Any] + ) -> Tuple[Dict[str, Any], List[ResourcePLR]]: """ 处理资源更新操作的内部函数 @@ -792,7 +804,11 @@ class BaseROS2DeviceNode(Node, Generic[T]): Returns: 操作结果字典 """ + original_instances = [] for plr_resource, tree in zip(plr_resources, tree_set.trees): + if isinstance(plr_resource, ResourceDictInstance): + self._lab_logger.info(f"跳过 非资源{plr_resource.res_content.name} 的更新") + continue states = plr_resource.serialize_all_state() original_instance: ResourcePLR = self.resource_tracker.figure_resource( {"uuid": tree.root_node.res_content.uuid}, try_mode=False @@ -831,6 +847,16 @@ class BaseROS2DeviceNode(Node, Generic[T]): and original_parent_resource is not None ): self.transfer_to_new_resource(original_instance, tree, additional_add_params) + else: + # 判断是否变更了resource_site + target_site = original_instance.unilabos_extra.get("update_resource_site") + sites = original_instance.parent.sites if original_instance.parent is not None and hasattr(original_instance.parent, "sites") else None + site_names = list(original_instance.parent._ordering.keys()) if original_instance.parent is not None and hasattr(original_instance.parent, "sites") else [] + if target_site is not None and sites is not None and site_names is not None: + site_index = sites.index(original_instance) + site_name = site_names[site_index] + if site_name != target_site: + self.transfer_to_new_resource(original_instance, tree, additional_add_params) # 加载状态 original_instance.load_all_state(states) @@ -838,13 +864,14 @@ class BaseROS2DeviceNode(Node, Generic[T]): self.lab_logger().info( f"更新了资源属性 {plr_resource}[{tree.root_node.res_content.uuid}] " f"及其子节点 {child_count} 个" ) + original_instances.append(original_instance) # 调用driver的update回调 func = getattr(self.driver_instance, "resource_tree_update", None) if callable(func): - func(plr_resources) + func(original_instances) - return {"success": True, "action": "update"} + return {"success": True, "action": "update"}, original_instances try: data = json.loads(req.command) @@ -868,12 +895,32 @@ class BaseROS2DeviceNode(Node, Generic[T]): raise ValueError("tree_set不能为None") plr_resources = tree_set.to_plr_resources() result = _handle_add(plr_resources, tree_set, additional_add_params) + new_tree_set = ResourceTreeSet.from_plr_resources(plr_resources) + r = SerialCommand.Request() + r.command = json.dumps( + {"data": {"data": new_tree_set.dump()}, "action": "update"}) # 和Update Resource一致 + response: SerialCommand_Response = await self._resource_clients[ + "c2s_update_resource_tree"].call_async(r) # type: ignore + self.lab_logger().info(f"确认资源云端 Add 结果: {response.response}") results.append(result) elif action == "update": if tree_set is None: raise ValueError("tree_set不能为None") - plr_resources = tree_set.to_plr_resources() - result = _handle_update(plr_resources, tree_set, additional_add_params) + plr_resources = [] + for tree in tree_set.trees: + if tree.root_node.res_content.type == "device": + plr_resources.append(tree.root_node) + else: + plr_resources.append(ResourceTreeSet([tree]).to_plr_resources()[0]) + result, original_instances = _handle_update(plr_resources, tree_set, additional_add_params) + if not BasicConfig.no_update_feedback: + new_tree_set = ResourceTreeSet.from_plr_resources(original_instances) + r = SerialCommand.Request() + r.command = json.dumps( + {"data": {"data": new_tree_set.dump()}, "action": "update"}) # 和Update Resource一致 + response: SerialCommand_Response = await self._resource_clients[ + "c2s_update_resource_tree"].call_async(r) # type: ignore + self.lab_logger().info(f"确认资源云端 Update 结果: {response.response}") results.append(result) elif action == "remove": result = _handle_remove(resources_uuid) @@ -1043,6 +1090,29 @@ class BaseROS2DeviceNode(Node, Generic[T]): def create_ros_publisher(self, attr_name, msg_type, initial_period=5.0): """创建ROS发布者""" + # 检测装饰器配置(支持 get_{attr_name} 方法和 @property) + topic_config = {} + + # 优先检测 get_{attr_name} 方法 + if hasattr(self.driver_instance, f"get_{attr_name}"): + getter_method = getattr(self.driver_instance, f"get_{attr_name}") + topic_config = get_topic_config(getter_method) + + # 如果没有配置,检测 @property 装饰的属性 + if not topic_config: + driver_class = type(self.driver_instance) + if hasattr(driver_class, attr_name): + class_attr = getattr(driver_class, attr_name) + if isinstance(class_attr, property) and class_attr.fget is not None: + topic_config = get_topic_config(class_attr.fget) + + # 使用装饰器配置或默认值 + cfg_period = topic_config.get("period") + cfg_print = topic_config.get("print_publish") + cfg_qos = topic_config.get("qos") + period: float = cfg_period if cfg_period is not None else initial_period + print_publish: bool = cfg_print if cfg_print is not None else self._print_publish + qos: int = cfg_qos if cfg_qos is not None else 10 # 获取属性值的方法 def get_device_attr(): @@ -1063,7 +1133,7 @@ class BaseROS2DeviceNode(Node, Generic[T]): self.lab_logger().error(traceback.format_exc()) self._property_publishers[attr_name] = PropertyPublisher( - self, attr_name, get_device_attr, msg_type, initial_period, self._print_publish + self, attr_name, get_device_attr, msg_type, period, print_publish, qos ) def create_ros_action_server(self, action_name, action_value_mapping): @@ -1081,6 +1151,76 @@ class BaseROS2DeviceNode(Node, Generic[T]): self.lab_logger().trace(f"发布动作: {action_name}, 类型: {str_action_type}") + def _setup_decorated_subscribers(self): + """扫描 driver_instance 中带有 @subscribe 装饰器的方法并创建订阅者""" + subscriptions = get_all_subscriptions(self.driver_instance) + + for method_name, method, config in subscriptions: + topic_template = config.get("topic") + msg_type = config.get("msg_type") + qos = config.get("qos", 10) + + if not topic_template: + self.lab_logger().warning(f"订阅方法 {method_name} 缺少 topic 配置,跳过") + continue + + # 如果没有指定 msg_type,尝试从类型注解推断 + if msg_type is None: + try: + hints = get_type_hints(method) + # 第一个参数是 self,第二个是 msg + param_names = list(hints.keys()) + if param_names: + msg_type = hints[param_names[0]] + except Exception: + pass + + if msg_type is None: + self.lab_logger().warning(f"订阅方法 {method_name} 缺少 msg_type 配置且无法从类型注解推断,跳过") + continue + + # 替换 topic 模板中的占位符 + topic = self._resolve_topic_template(topic_template) + + self.create_ros_subscriber(topic, msg_type, method, qos) + + def _resolve_topic_template(self, topic_template: str) -> str: + """ + 解析 topic 模板,替换占位符 + + 支持的占位符: + - {device_id}: 设备ID + - {namespace}: 完整命名空间 + """ + return topic_template.format( + device_id=self.device_id, + namespace=self.namespace, + ) + + def create_ros_subscriber(self, topic: str, msg_type, callback, qos: int = 10): + """ + 创建ROS订阅者 + + Args: + topic: Topic 名称 + msg_type: ROS 消息类型 + callback: 回调方法(会自动绑定到 driver_instance) + qos: QoS 深度配置 + """ + try: + subscription = self.create_subscription( + msg_type, + topic, + callback, + qos, + callback_group=self.callback_group, + ) + self._topic_subscribers[topic] = subscription + str_msg_type = str(msg_type)[8:-2] if str(msg_type).startswith(" str: + return "Idle" - 支持多种泵送模式,具有高精度流量控制和自动校准功能。 - 适用于实验室自动化系统中的液体处理任务。 - """ - - _ros_node: BaseROS2DeviceNode - - def __init__(self, device_id: str = "smart_pump_01", port: str = "/dev/ttyUSB0"): - """ - 初始化智能泵控制器 - - Args: - device_id: 设备唯一标识符 - port: 通信端口 - """ - self.device_id = device_id - self.port = port - self.is_connected = False - self.current_flow_rate = 0.0 - self.total_volume_pumped = 0.0 - self.calibration_factor = 1.0 - self.pump_mode = "continuous" # continuous, volume, rate - - def post_init(self, ros_node: BaseROS2DeviceNode): - self._ros_node = ros_node - - def connect_device(self, timeout: int = 10) -> bool: - """ - 连接到泵设备 - - Args: - timeout: 连接超时时间(秒) - - Returns: - bool: 连接是否成功 - """ - # 模拟连接过程 - self.is_connected = True + async def action(self, addr: str) -> bool: return True + + + + def disconnect_device(self) -> bool: """ 断开设备连接 diff --git a/unilabos/utils/decorator.py b/unilabos/utils/decorator.py index 77e473c..667f353 100644 --- a/unilabos/utils/decorator.py +++ b/unilabos/utils/decorator.py @@ -1,3 +1,9 @@ +from functools import wraps +from typing import Any, Callable, Optional, TypeVar + +F = TypeVar("F", bound=Callable[..., Any]) + + def singleton(cls): """ 单例装饰器 @@ -12,3 +18,167 @@ def singleton(cls): return get_instance + +def topic_config( + period: Optional[float] = None, + print_publish: Optional[bool] = None, + qos: Optional[int] = None, +) -> Callable[[F], F]: + """ + Topic发布配置装饰器 + + 用于装饰 get_{attr_name} 方法或 @property,控制对应属性的ROS topic发布行为。 + + Args: + period: 发布周期(秒)。None 表示使用默认值 5.0 + print_publish: 是否打印发布日志。None 表示使用节点默认配置 + qos: QoS深度配置。None 表示使用默认值 10 + + Example: + class MyDriver: + # 方式1: 装饰 get_{attr_name} 方法 + @topic_config(period=1.0, print_publish=False, qos=5) + def get_temperature(self): + return self._temperature + + # 方式2: 与 @property 连用(topic_config 放在下面) + @property + @topic_config(period=0.1) + def position(self): + return self._position + + Note: + 与 @property 连用时,@topic_config 必须放在 @property 下面, + 这样装饰器执行顺序为:先 topic_config 添加配置,再 property 包装。 + """ + + def decorator(func: F) -> F: + @wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + # 在函数上附加配置属性 (type: ignore 用于动态属性) + wrapper._topic_period = period # type: ignore[attr-defined] + wrapper._topic_print_publish = print_publish # type: ignore[attr-defined] + wrapper._topic_qos = qos # type: ignore[attr-defined] + wrapper._has_topic_config = True # type: ignore[attr-defined] + + return wrapper # type: ignore[return-value] + + return decorator + + +def get_topic_config(func) -> dict: + """ + 获取函数上的topic配置 + + Args: + func: 被装饰的函数 + + Returns: + 包含 period, print_publish, qos 的配置字典 + """ + if hasattr(func, "_has_topic_config") and getattr(func, "_has_topic_config", False): + return { + "period": getattr(func, "_topic_period", None), + "print_publish": getattr(func, "_topic_print_publish", None), + "qos": getattr(func, "_topic_qos", None), + } + return {} + + +def subscribe( + topic: str, + msg_type: Optional[type] = None, + qos: int = 10, +) -> Callable[[F], F]: + """ + Topic订阅装饰器 + + 用于装饰 driver 类中的方法,使其成为 ROS topic 的订阅回调。 + 当 ROS2DeviceNode 初始化时,会自动扫描并创建对应的订阅者。 + + Args: + topic: Topic 名称模板,支持以下占位符: + - {device_id}: 设备ID (如 "pump_1") + - {namespace}: 完整命名空间 (如 "/devices/pump_1") + msg_type: ROS 消息类型。如果为 None,需要在回调函数的类型注解中指定 + qos: QoS 深度配置,默认为 10 + + Example: + from std_msgs.msg import String, Float64 + + class MyDriver: + @subscribe(topic="/devices/{device_id}/set_speed", msg_type=Float64) + def on_speed_update(self, msg: Float64): + self._speed = msg.data + print(f"Speed updated to: {self._speed}") + + @subscribe(topic="{namespace}/command") + def on_command(self, msg: String): + # msg_type 可从类型注解推断 + self.execute_command(msg.data) + + Note: + - 回调方法的第一个参数是 self,第二个参数是收到的 ROS 消息 + - topic 中的占位符会在创建订阅时被实际值替换 + """ + + def decorator(func: F) -> F: + @wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + # 在函数上附加订阅配置 + wrapper._subscribe_topic = topic # type: ignore[attr-defined] + wrapper._subscribe_msg_type = msg_type # type: ignore[attr-defined] + wrapper._subscribe_qos = qos # type: ignore[attr-defined] + wrapper._has_subscribe = True # type: ignore[attr-defined] + + return wrapper # type: ignore[return-value] + + return decorator + + +def get_subscribe_config(func) -> dict: + """ + 获取函数上的订阅配置 + + Args: + func: 被装饰的函数 + + Returns: + 包含 topic, msg_type, qos 的配置字典 + """ + if hasattr(func, "_has_subscribe") and getattr(func, "_has_subscribe", False): + return { + "topic": getattr(func, "_subscribe_topic", None), + "msg_type": getattr(func, "_subscribe_msg_type", None), + "qos": getattr(func, "_subscribe_qos", 10), + } + return {} + + +def get_all_subscriptions(instance) -> list: + """ + 扫描实例的所有方法,获取带有 @subscribe 装饰器的方法及其配置 + + Args: + instance: 要扫描的实例 + + Returns: + 包含 (method_name, method, config) 元组的列表 + """ + subscriptions = [] + for attr_name in dir(instance): + if attr_name.startswith("_"): + continue + try: + attr = getattr(instance, attr_name) + if callable(attr): + config = get_subscribe_config(attr) + if config: + subscriptions.append((attr_name, attr, config)) + except Exception: + pass + return subscriptions diff --git a/unilabos/utils/type_check.py b/unilabos/utils/type_check.py index cffb446..64001e5 100644 --- a/unilabos/utils/type_check.py +++ b/unilabos/utils/type_check.py @@ -78,7 +78,11 @@ def get_result_info_str(error: str, suc: bool, return_value=None) -> str: Returns: JSON字符串格式的结果信息 """ - result_info = {"error": error, "suc": suc, "return_value": return_value} + samples = None + if isinstance(return_value, dict): + if "samples" in return_value: + samples = return_value.pop("samples") + result_info = {"error": error, "suc": suc, "return_value": return_value, "samples": samples} return json.dumps(result_info, ensure_ascii=False, cls=ResultInfoEncoder) diff --git a/unilabos_msgs/package.xml b/unilabos_msgs/package.xml index dbb2038..b9c2632 100644 --- a/unilabos_msgs/package.xml +++ b/unilabos_msgs/package.xml @@ -2,7 +2,7 @@ unilabos_msgs - 0.10.12 + 0.10.15 ROS2 Messages package for unilabos devices Junhan Chang Xuwznln