mirror of
https://github.com/dptech-corp/Uni-Lab-OS.git
synced 2026-02-04 13:25:13 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
266366cc25 | ||
|
|
121c3985cc | ||
|
|
6ca5c72fc6 | ||
|
|
bc8c49ddda | ||
|
|
28f93737ac |
@@ -1,6 +1,6 @@
|
|||||||
package:
|
package:
|
||||||
name: unilabos
|
name: unilabos
|
||||||
version: 0.10.13
|
version: 0.10.14
|
||||||
|
|
||||||
source:
|
source:
|
||||||
path: ../unilabos
|
path: ../unilabos
|
||||||
@@ -9,7 +9,7 @@ source:
|
|||||||
build:
|
build:
|
||||||
python:
|
python:
|
||||||
entry_points:
|
entry_points:
|
||||||
- unilab = unilabos.app.main:main
|
- unilab = unilabos.app.main:main
|
||||||
script:
|
script:
|
||||||
- set PIP_NO_INDEX=
|
- set PIP_NO_INDEX=
|
||||||
- if: win
|
- if: win
|
||||||
@@ -25,7 +25,6 @@ build:
|
|||||||
- cp $RECIPE_DIR/../setup.py $SRC_DIR
|
- cp $RECIPE_DIR/../setup.py $SRC_DIR
|
||||||
- $PYTHON -m pip install $SRC_DIR
|
- $PYTHON -m pip install $SRC_DIR
|
||||||
|
|
||||||
|
|
||||||
requirements:
|
requirements:
|
||||||
host:
|
host:
|
||||||
- python ==3.11.11
|
- python ==3.11.11
|
||||||
@@ -87,6 +86,6 @@ requirements:
|
|||||||
- uni-lab::ros-humble-unilabos-msgs
|
- uni-lab::ros-humble-unilabos-msgs
|
||||||
|
|
||||||
about:
|
about:
|
||||||
repository: https://github.com/dptech-corp/Uni-Lab-OS
|
repository: https://github.com/deepmodeling/Uni-Lab-OS
|
||||||
license: GPL-3.0-only
|
license: GPL-3.0-only
|
||||||
description: "Uni-Lab-OS"
|
description: "Uni-Lab-OS"
|
||||||
|
|||||||
26
.cursorignore
Normal file
26
.cursorignore
Normal file
@@ -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__
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
|
cursor_docs/
|
||||||
configs/
|
configs/
|
||||||
temp/
|
temp/
|
||||||
output/
|
output/
|
||||||
|
|||||||
17
NOTICE
Normal file
17
NOTICE
Normal file
@@ -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.
|
||||||
46
README.md
46
README.md
@@ -8,17 +8,13 @@
|
|||||||
|
|
||||||
**English** | [中文](README_zh.md)
|
**English** | [中文](README_zh.md)
|
||||||
|
|
||||||
[](https://github.com/dptech-corp/Uni-Lab-OS/stargazers)
|
[](https://github.com/deepmodeling/Uni-Lab-OS/stargazers)
|
||||||
[](https://github.com/dptech-corp/Uni-Lab-OS/network/members)
|
[](https://github.com/deepmodeling/Uni-Lab-OS/network/members)
|
||||||
[](https://github.com/dptech-corp/Uni-Lab-OS/issues)
|
[](https://github.com/deepmodeling/Uni-Lab-OS/issues)
|
||||||
[](https://github.com/dptech-corp/Uni-Lab-OS/blob/main/LICENSE)
|
[](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.
|
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
|
## Key Features
|
||||||
|
|
||||||
- Multi-device integration management
|
- Multi-device integration management
|
||||||
@@ -31,7 +27,7 @@ Join the [Intelligent Organic Chemistry Synthesis Competition](https://bohrium.d
|
|||||||
|
|
||||||
Detailed documentation can be found at:
|
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
|
## Quick Start
|
||||||
|
|
||||||
@@ -48,7 +44,7 @@ mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone the repository
|
# 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
|
cd Uni-Lab-OS
|
||||||
|
|
||||||
# Install Uni-Lab-OS
|
# Install Uni-Lab-OS
|
||||||
@@ -57,15 +53,37 @@ 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)
|
||||||
|
|
||||||
## Message Format
|
## 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 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
|
## 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
|
## Project Statistics
|
||||||
|
|
||||||
@@ -77,4 +95,4 @@ This project is licensed under GPL-3.0 - see the [LICENSE](LICENSE) file for det
|
|||||||
|
|
||||||
## Contact Us
|
## 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)
|
||||||
|
|||||||
46
README_zh.md
46
README_zh.md
@@ -8,17 +8,13 @@
|
|||||||
|
|
||||||
[English](README.md) | **中文**
|
[English](README.md) | **中文**
|
||||||
|
|
||||||
[](https://github.com/dptech-corp/Uni-Lab-OS/stargazers)
|
[](https://github.com/deepmodeling/Uni-Lab-OS/stargazers)
|
||||||
[](https://github.com/dptech-corp/Uni-Lab-OS/network/members)
|
[](https://github.com/deepmodeling/Uni-Lab-OS/network/members)
|
||||||
[](https://github.com/dptech-corp/Uni-Lab-OS/issues)
|
[](https://github.com/deepmodeling/Uni-Lab-OS/issues)
|
||||||
[](https://github.com/dptech-corp/Uni-Lab-OS/blob/main/LICENSE)
|
[](https://github.com/deepmodeling/Uni-Lab-OS/blob/main/LICENSE)
|
||||||
|
|
||||||
Uni-Lab-OS 是一个用于实验室自动化的综合平台,旨在连接和控制各种实验设备,实现实验流程的自动化和标准化。
|
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,7 +46,7 @@ mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
|
|||||||
|
|
||||||
```bash
|
```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
|
cd Uni-Lab-OS
|
||||||
|
|
||||||
# 安装 Uni-Lab-OS
|
# 安装 Uni-Lab-OS
|
||||||
@@ -59,15 +55,37 @@ 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)
|
||||||
|
|
||||||
## 消息格式
|
## 消息格式
|
||||||
|
|
||||||
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,请引用:
|
||||||
|
|
||||||
|
```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 +97,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)
|
||||||
|
|||||||
@@ -1807,7 +1807,7 @@ unilab --ak your_ak --sk your_sk -g graph.json \
|
|||||||
|
|
||||||
#### 14.5 社区支持
|
#### 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)
|
- **官方网站**:[https://uni-lab.bohrium.com](https://uni-lab.bohrium.com)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -857,4 +857,4 @@ class ResourceDictPosition(BaseModel):
|
|||||||
- 在 Web 界面中使用模板创建
|
- 在 Web 界面中使用模板创建
|
||||||
- 参考示例文件:`test/experiments/` 目录
|
- 参考示例文件:`test/experiments/` 目录
|
||||||
- 查看 ResourceDict 源码了解完整定义
|
- 查看 ResourceDict 源码了解完整定义
|
||||||
- [GitHub 讨论区](https://github.com/dptech-corp/Uni-Lab-OS/discussions)
|
- [GitHub 讨论区](https://github.com/deepmodeling/Uni-Lab-OS/discussions)
|
||||||
|
|||||||
@@ -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. 选择最新的成功构建记录(绿色勾号 ✓)
|
2. 选择最新的成功构建记录(绿色勾号 ✓)
|
||||||
|
|
||||||
@@ -189,13 +189,13 @@ conda activate unilab
|
|||||||
### 第一步:克隆仓库
|
### 第一步:克隆仓库
|
||||||
|
|
||||||
```bash
|
```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
|
cd Uni-Lab-OS
|
||||||
```
|
```
|
||||||
|
|
||||||
如果您需要贡献代码,建议先 Fork 仓库:
|
如果您需要贡献代码,建议先 Fork 仓库:
|
||||||
|
|
||||||
1. 访问 https://github.com/dptech-corp/Uni-Lab-OS
|
1. 访问 https://github.com/deepmodeling/Uni-Lab-OS
|
||||||
2. 点击右上角的 "Fork" 按钮
|
2. 点击右上角的 "Fork" 按钮
|
||||||
3. Clone 您的 Fork 版本:
|
3. Clone 您的 Fork 版本:
|
||||||
```bash
|
```bash
|
||||||
@@ -240,7 +240,7 @@ pip uninstall unilabos -y
|
|||||||
|
|
||||||
# 克隆 dev 分支(如果还未克隆)
|
# 克隆 dev 分支(如果还未克隆)
|
||||||
cd /path/to/your/workspace
|
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 分支
|
# 或者如果已经克隆,切换到 dev 分支
|
||||||
cd Uni-Lab-OS
|
cd Uni-Lab-OS
|
||||||
git checkout dev
|
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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package:
|
package:
|
||||||
name: ros-humble-unilabos-msgs
|
name: ros-humble-unilabos-msgs
|
||||||
version: 0.10.13
|
version: 0.10.14
|
||||||
source:
|
source:
|
||||||
path: ../../unilabos_msgs
|
path: ../../unilabos_msgs
|
||||||
target_directory: src
|
target_directory: src
|
||||||
@@ -17,7 +17,7 @@ build:
|
|||||||
- bash $SRC_DIR/build_ament_cmake.sh
|
- bash $SRC_DIR/build_ament_cmake.sh
|
||||||
|
|
||||||
about:
|
about:
|
||||||
repository: https://github.com/dptech-corp/Uni-Lab-OS
|
repository: https://github.com/deepmodeling/Uni-Lab-OS
|
||||||
license: BSD-3-Clause
|
license: BSD-3-Clause
|
||||||
description: "ros-humble-unilabos-msgs is a package that provides message definitions for Uni-Lab-OS."
|
description: "ros-humble-unilabos-msgs is a package that provides message definitions for Uni-Lab-OS."
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package:
|
package:
|
||||||
name: unilabos
|
name: unilabos
|
||||||
version: "0.10.13"
|
version: "0.10.14"
|
||||||
|
|
||||||
source:
|
source:
|
||||||
path: ../..
|
path: ../..
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ If installation fails:
|
|||||||
For more help:
|
For more help:
|
||||||
- Documentation: docs/user_guide/installation.md
|
- Documentation: docs/user_guide/installation.md
|
||||||
- Quick Start: QUICK_START_CONDA_PACK.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:
|
License:
|
||||||
--------
|
--------
|
||||||
@@ -134,7 +134,7 @@ License:
|
|||||||
UniLabOS is licensed under GPL-3.0-only.
|
UniLabOS is licensed under GPL-3.0-only.
|
||||||
See LICENSE file for details.
|
See LICENSE file for details.
|
||||||
|
|
||||||
Repository: https://github.com/dptech-corp/Uni-Lab-OS
|
Repository: https://github.com/deepmodeling/Uni-Lab-OS
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return readme
|
return readme
|
||||||
|
|||||||
2
setup.py
2
setup.py
@@ -4,7 +4,7 @@ package_name = 'unilabos'
|
|||||||
|
|
||||||
setup(
|
setup(
|
||||||
name=package_name,
|
name=package_name,
|
||||||
version='0.10.13',
|
version='0.10.14',
|
||||||
packages=find_packages(),
|
packages=find_packages(),
|
||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
install_requires=['setuptools'],
|
install_requires=['setuptools'],
|
||||||
|
|||||||
7
tests/__init__.py
Normal file
7
tests/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
测试包根目录。
|
||||||
|
|
||||||
|
让 `tests.*` 模块可以被正常 import(例如给 `unilabos` 下的测试入口使用)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
1
tests/devices/__init__.py
Normal file
1
tests/devices/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
5
tests/devices/liquid_handling/__init__.py
Normal file
5
tests/devices/liquid_handling/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
液体处理设备相关测试。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
505
tests/devices/liquid_handling/test_transfer_liquid.py
Normal file
505
tests/devices/liquid_handling/test_transfer_liquid.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
@@ -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__))))))
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))))
|
||||||
|
|
||||||
# 导入测试模块
|
# 导入测试模块(统一从 tests 包获取)
|
||||||
from test.ros.msgs.test_basic import TestBasicFunctionality
|
from tests.ros.msgs.test_basic import TestBasicFunctionality
|
||||||
from test.ros.msgs.test_conversion import TestBasicConversion, TestMappingConversion
|
from tests.ros.msgs.test_conversion import TestBasicConversion, TestMappingConversion
|
||||||
from test.ros.msgs.test_mapping import TestTypeMapping, TestFieldMapping
|
from tests.ros.msgs.test_mapping import TestTypeMapping, TestFieldMapping
|
||||||
|
|
||||||
|
|
||||||
def run_tests():
|
def run_tests():
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "0.10.13"
|
__version__ = "0.10.14"
|
||||||
|
|||||||
@@ -359,7 +359,7 @@ def main():
|
|||||||
# 如果从远端获取了物料信息,则与本地物料进行同步
|
# 如果从远端获取了物料信息,则与本地物料进行同步
|
||||||
if request_startup_json and "nodes" in request_startup_json:
|
if request_startup_json and "nodes" in request_startup_json:
|
||||||
print_status("开始同步远端物料到本地...", "info")
|
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)
|
resource_tree_set.merge_remote_resources(remote_tree_set)
|
||||||
print_status("远端物料同步完成", "info")
|
print_status("远端物料同步完成", "info")
|
||||||
|
|
||||||
|
|||||||
@@ -579,6 +579,8 @@ class MessageProcessor:
|
|||||||
elif message_type == "session_id":
|
elif message_type == "session_id":
|
||||||
self.session_id = message_data.get("session_id")
|
self.session_id = message_data.get("session_id")
|
||||||
logger.info(f"[MessageProcessor] Session ID: {self.session_id}")
|
logger.info(f"[MessageProcessor] Session ID: {self.session_id}")
|
||||||
|
elif message_type == "request_reload":
|
||||||
|
await self._handle_request_reload(message_data)
|
||||||
else:
|
else:
|
||||||
logger.debug(f"[MessageProcessor] Unknown message type: {message_type}")
|
logger.debug(f"[MessageProcessor] Unknown message type: {message_type}")
|
||||||
|
|
||||||
@@ -888,6 +890,20 @@ class MessageProcessor:
|
|||||||
)
|
)
|
||||||
thread.start()
|
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(
|
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
|
self, device_id: str, action_name: str, task_id: str, job_id: str, typ: str, free: bool, need_more: int
|
||||||
):
|
):
|
||||||
@@ -1243,7 +1259,7 @@ class WebSocketClient(BaseCommunicationClient):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
self.message_processor.send_message(message)
|
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(
|
def publish_job_status(
|
||||||
self, feedback_data: dict, item: QueueItem, status: str, return_info: Optional[dict] = None
|
self, feedback_data: dict, item: QueueItem, status: str, return_info: Optional[dict] = None
|
||||||
@@ -1285,7 +1301,7 @@ class WebSocketClient(BaseCommunicationClient):
|
|||||||
self.message_processor.send_message(message)
|
self.message_processor.send_message(message)
|
||||||
|
|
||||||
job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name)
|
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:
|
def send_ping(self, ping_id: str, timestamp: float) -> None:
|
||||||
"""发送ping消息"""
|
"""发送ping消息"""
|
||||||
@@ -1316,17 +1332,55 @@ class WebSocketClient(BaseCommunicationClient):
|
|||||||
logger.warning(f"[WebSocketClient] Failed to cancel job {job_log}")
|
logger.warning(f"[WebSocketClient] Failed to cancel job {job_log}")
|
||||||
|
|
||||||
def publish_host_ready(self) -> None:
|
def publish_host_ready(self) -> None:
|
||||||
"""发布host_node ready信号"""
|
"""发布host_node ready信号,包含设备和动作信息"""
|
||||||
if self.is_disabled or not self.is_connected():
|
if self.is_disabled or not self.is_connected():
|
||||||
logger.debug("[WebSocketClient] Not connected, cannot publish host ready signal")
|
logger.debug("[WebSocketClient] Not connected, cannot publish host ready signal")
|
||||||
return
|
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 = {
|
message = {
|
||||||
"action": "host_node_ready",
|
"action": "host_node_ready",
|
||||||
"data": {
|
"data": {
|
||||||
"status": "ready",
|
"status": "ready",
|
||||||
"timestamp": time.time(),
|
"timestamp": time.time(),
|
||||||
|
"machine_name": machine_name,
|
||||||
|
"devices": devices,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
self.message_processor.send_message(message)
|
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")
|
||||||
|
|||||||
73
unilabos/devices/LICENSE
Normal file
73
unilabos/devices/LICENSE
Normal file
@@ -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、语言优先
|
||||||
|
本准则同时具有中文版本和英文版本,如果英文版本和中文版本有冲突,以中文版本为准。
|
||||||
|
|
||||||
73
unilabos/devices/LICENSE_eng
Normal file
73
unilabos/devices/LICENSE_eng
Normal file
@@ -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.
|
||||||
|
|
||||||
@@ -1042,11 +1042,19 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
One or more TipRacks providing fresh tips.
|
One or more TipRacks providing fresh tips.
|
||||||
is_96_well
|
is_96_well
|
||||||
Set *True* to use the 96‑channel head.
|
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 有默认值
|
# 确保 use_channels 有默认值
|
||||||
if use_channels is None:
|
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:
|
if is_96_well:
|
||||||
pass # This mode is not verified.
|
pass # This mode is not verified.
|
||||||
@@ -1074,42 +1082,42 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
if mix_times is not None:
|
if mix_times is not None:
|
||||||
mix_times = int(mix_times)
|
mix_times = int(mix_times)
|
||||||
|
|
||||||
# 识别传输模式
|
# 识别传输模式(mix_times 为 None 也应该能正常移液,只是不做 mix)
|
||||||
num_sources = len(sources)
|
num_sources = len(sources)
|
||||||
num_targets = len(targets)
|
num_targets = len(targets)
|
||||||
|
|
||||||
if num_sources == 1 and num_targets > 1:
|
if num_sources == 1 and num_targets > 1:
|
||||||
# 模式1: 一对多 (1 source -> N targets)
|
# 模式1: 一对多 (1 source -> N targets)
|
||||||
await self._transfer_one_to_many(
|
await self._transfer_one_to_many(
|
||||||
sources[0], targets, tip_racks, use_channels,
|
sources[0], targets, tip_racks, use_channels,
|
||||||
asp_vols, dis_vols, asp_flow_rates, dis_flow_rates,
|
asp_vols, dis_vols, asp_flow_rates, dis_flow_rates,
|
||||||
offsets, touch_tip, liquid_height, blow_out_air_volume,
|
offsets, touch_tip, liquid_height, blow_out_air_volume,
|
||||||
spread, mix_stage, mix_times, mix_vol, mix_rate,
|
spread, mix_stage, mix_times, mix_vol, mix_rate,
|
||||||
mix_liquid_height, delays
|
mix_liquid_height, delays
|
||||||
)
|
)
|
||||||
elif num_sources > 1 and num_targets == 1:
|
elif num_sources > 1 and num_targets == 1:
|
||||||
# 模式2: 多对一 (N sources -> 1 target)
|
# 模式2: 多对一 (N sources -> 1 target)
|
||||||
await self._transfer_many_to_one(
|
await self._transfer_many_to_one(
|
||||||
sources, targets[0], tip_racks, use_channels,
|
sources, targets[0], tip_racks, use_channels,
|
||||||
asp_vols, dis_vols, asp_flow_rates, dis_flow_rates,
|
asp_vols, dis_vols, asp_flow_rates, dis_flow_rates,
|
||||||
offsets, touch_tip, liquid_height, blow_out_air_volume,
|
offsets, touch_tip, liquid_height, blow_out_air_volume,
|
||||||
spread, mix_stage, mix_times, mix_vol, mix_rate,
|
spread, mix_stage, mix_times, mix_vol, mix_rate,
|
||||||
mix_liquid_height, delays
|
mix_liquid_height, delays
|
||||||
)
|
)
|
||||||
elif num_sources == num_targets:
|
elif num_sources == num_targets:
|
||||||
# 模式3: 一对一 (N sources -> N targets) - 原有逻辑
|
# 模式3: 一对一 (N sources -> N targets)
|
||||||
await self._transfer_one_to_one(
|
await self._transfer_one_to_one(
|
||||||
sources, targets, tip_racks, use_channels,
|
sources, targets, tip_racks, use_channels,
|
||||||
asp_vols, dis_vols, asp_flow_rates, dis_flow_rates,
|
asp_vols, dis_vols, asp_flow_rates, dis_flow_rates,
|
||||||
offsets, touch_tip, liquid_height, blow_out_air_volume,
|
offsets, touch_tip, liquid_height, blow_out_air_volume,
|
||||||
spread, mix_stage, mix_times, mix_vol, mix_rate,
|
spread, mix_stage, mix_times, mix_vol, mix_rate,
|
||||||
mix_liquid_height, delays
|
mix_liquid_height, delays
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Unsupported transfer mode: {num_sources} sources -> {num_targets} targets. "
|
f"Unsupported transfer mode: {num_sources} sources -> {num_targets} targets. "
|
||||||
"Supported modes: 1->N, N->1, or N->N."
|
"Supported modes: 1->N, N->1, or N->N."
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _transfer_one_to_one(
|
async def _transfer_one_to_one(
|
||||||
self,
|
self,
|
||||||
@@ -1149,6 +1157,16 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
tip.extend(next(self.current_tip))
|
tip.extend(next(self.current_tip))
|
||||||
await self.pick_up_tips(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(
|
await self.aspirate(
|
||||||
resources=[sources[_]],
|
resources=[sources[_]],
|
||||||
vols=[asp_vols[_]],
|
vols=[asp_vols[_]],
|
||||||
@@ -1209,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_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
|
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(
|
await self.aspirate(
|
||||||
resources=current_reagent_sources,
|
resources=current_reagent_sources,
|
||||||
vols=current_asp_vols,
|
vols=current_asp_vols,
|
||||||
@@ -1290,6 +1318,17 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
tip.extend(next(self.current_tip))
|
tip.extend(next(self.current_tip))
|
||||||
await self.pick_up_tips(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(
|
await self.aspirate(
|
||||||
resources=[source],
|
resources=[source],
|
||||||
@@ -1354,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_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
|
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个通道都从同一个源,但每个通道的吸液体积不同)
|
# 从源容器吸液(8个通道都从同一个源,但每个通道的吸液体积不同)
|
||||||
await self.aspirate(
|
await self.aspirate(
|
||||||
resources=[source] * 8, # 8个通道都从同一个源
|
resources=[source] * 8, # 8个通道都从同一个源
|
||||||
@@ -1452,8 +1501,14 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
# 单通道模式:多次吸液,一次分液
|
# 单通道模式:多次吸液,一次分液
|
||||||
# 先混合前(如果需要)
|
# 先混合前(如果需要)
|
||||||
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
|
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
|
||||||
# 注意:在吸液前混合源容器通常不常见,这里跳过
|
await self.mix(
|
||||||
pass
|
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):
|
for idx, source in enumerate(sources):
|
||||||
@@ -1528,6 +1583,16 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
raise ValueError(f"For 8-channel mode, number of sources {len(sources)} must be a multiple of 8.")
|
raise ValueError(f"For 8-channel mode, number of sources {len(sources)} must be a multiple of 8.")
|
||||||
|
|
||||||
# 每次处理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):
|
for i in range(0, len(sources), 8):
|
||||||
tip = []
|
tip = []
|
||||||
for _ in range(len(use_channels)):
|
for _ in range(len(use_channels)):
|
||||||
|
|||||||
@@ -70,7 +70,16 @@ class PRCXI9300Deck(Deck):
|
|||||||
|
|
||||||
def __init__(self, name: str, size_x: float, size_y: float, size_z: float, **kwargs):
|
def __init__(self, name: str, size_x: float, size_y: float, size_z: float, **kwargs):
|
||||||
super().__init__(name, size_x, size_y, size_z)
|
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):
|
class PRCXI9300Container(Plate):
|
||||||
"""PRCXI 9300 的专用 Container 类,继承自 Plate,用于槽位定位和未知模块。
|
"""PRCXI 9300 的专用 Container 类,继承自 Plate,用于槽位定位和未知模块。
|
||||||
|
|
||||||
|
|||||||
13
unilabos/devices/liquid_handling/test_transfer_liquid.py
Normal file
13
unilabos/devices/liquid_handling/test_transfer_liquid.py
Normal file
@@ -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
|
||||||
|
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@ def canonicalize_nodes_data(
|
|||||||
parent_instance.children.append(current_instance)
|
parent_instance.children.append(current_instance)
|
||||||
|
|
||||||
# 第五步:创建 ResourceTreeSet
|
# 第五步:创建 ResourceTreeSet
|
||||||
resource_tree_set = ResourceTreeSet.from_nested_list(standardized_instances)
|
resource_tree_set = ResourceTreeSet.from_nested_instance_list(standardized_instances)
|
||||||
return resource_tree_set
|
return resource_tree_set
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import json
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import traceback
|
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
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -362,78 +363,82 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
return res
|
return res
|
||||||
|
|
||||||
async def append_resource(req: SerialCommand_Request, res: SerialCommand_Response):
|
async def append_resource(req: SerialCommand_Request, res: SerialCommand_Response):
|
||||||
|
from pylabrobot.resources.resource import Resource as ResourcePLR
|
||||||
|
from pylabrobot.resources.deck import Deck
|
||||||
|
from pylabrobot.resources import Coordinate
|
||||||
|
from pylabrobot.resources import Plate
|
||||||
# 物料传输到对应的node节点
|
# 物料传输到对应的node节点
|
||||||
rclient = self.create_client(ResourceAdd, "/resources/add")
|
client = self._resource_clients["c2s_update_resource_tree"]
|
||||||
rclient.wait_for_service()
|
request = SerialCommand.Request()
|
||||||
rclient2 = self.create_client(ResourceAdd, "/resources/add")
|
request2 = SerialCommand.Request()
|
||||||
rclient2.wait_for_service()
|
|
||||||
request = ResourceAdd.Request()
|
|
||||||
request2 = ResourceAdd.Request()
|
|
||||||
command_json = json.loads(req.command)
|
command_json = json.loads(req.command)
|
||||||
namespace = command_json["namespace"]
|
namespace = command_json["namespace"]
|
||||||
bind_parent_id = command_json["bind_parent_id"]
|
bind_parent_id = command_json["bind_parent_id"]
|
||||||
edge_device_id = command_json["edge_device_id"]
|
edge_device_id = command_json["edge_device_id"]
|
||||||
location = command_json["bind_location"]
|
location = command_json["bind_location"]
|
||||||
other_calling_param = command_json["other_calling_param"]
|
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)
|
initialize_full = other_calling_param.pop("initialize_full", False)
|
||||||
# 用来增加液体
|
# 用来增加液体
|
||||||
ADD_LIQUID_TYPE = other_calling_param.pop("ADD_LIQUID_TYPE", [])
|
ADD_LIQUID_TYPE = other_calling_param.pop("ADD_LIQUID_TYPE", [])
|
||||||
LIQUID_VOLUME = other_calling_param.pop("LIQUID_VOLUME", [])
|
LIQUID_VOLUME: List[float] = other_calling_param.pop("LIQUID_VOLUME", [])
|
||||||
LIQUID_INPUT_SLOT = other_calling_param.pop("LIQUID_INPUT_SLOT", [])
|
LIQUID_INPUT_SLOT: List[int] = other_calling_param.pop("LIQUID_INPUT_SLOT", [])
|
||||||
slot = other_calling_param.pop("slot", "-1")
|
slot = other_calling_param.pop("slot", "-1")
|
||||||
resource = None
|
if slot != -1: # slot为负数的时候采用assign方法
|
||||||
if slot != "-1": # slot为负数的时候采用assign方法
|
|
||||||
other_calling_param["slot"] = slot
|
other_calling_param["slot"] = slot
|
||||||
# 本地拿到这个物料,可能需要先做初始化?
|
# 本地拿到这个物料,可能需要先做初始化
|
||||||
if isinstance(resources, list):
|
if isinstance(input_resources, list) and initialize_full:
|
||||||
if (
|
input_resources = initialize_resources(input_resources)
|
||||||
len(resources) == 1 and isinstance(resources[0], list) and not initialize_full
|
elif initialize_full:
|
||||||
): # 取消,不存在的情况
|
input_resources = initialize_resources([input_resources])
|
||||||
# 预先initialize过,以整组的形式传入
|
rts: ResourceTreeSet = ResourceTreeSet.from_raw_dict_list(input_resources)
|
||||||
request.resources = [convert_to_ros_msg(Resource, resource_) for resource_ in resources[0]]
|
parent_resource = None
|
||||||
elif initialize_full:
|
if bind_parent_id != self.node_name:
|
||||||
resources = initialize_resources(resources)
|
parent_resource = self.resource_tracker.figure_resource(
|
||||||
request.resources = [convert_to_ros_msg(Resource, resource) for resource in resources]
|
{"name": bind_parent_id}
|
||||||
else:
|
)
|
||||||
request.resources = [convert_to_ros_msg(Resource, resource) for resource in resources]
|
for r in rts.root_nodes:
|
||||||
else:
|
# noinspection PyUnresolvedReferences
|
||||||
if initialize_full:
|
r.res_content.parent_uuid = parent_resource.unilabos_uuid
|
||||||
resources = initialize_resources([resources])
|
|
||||||
request.resources = [convert_to_ros_msg(Resource, resources)]
|
if len(LIQUID_INPUT_SLOT) and LIQUID_INPUT_SLOT[0] == -1 and len(rts.root_nodes) == 1 and isinstance(rts.root_nodes[0], RegularContainer):
|
||||||
if len(LIQUID_INPUT_SLOT) and LIQUID_INPUT_SLOT[0] == -1:
|
# noinspection PyTypeChecker
|
||||||
container_instance = request.resources[0]
|
container_instance: RegularContainer = rts.root_nodes[0]
|
||||||
container_query_dict: dict = resources
|
|
||||||
found_resources = self.resource_tracker.figure_resource(
|
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):
|
if not len(found_resources):
|
||||||
self.resource_tracker.add_resource(container_instance)
|
self.resource_tracker.add_resource(container_instance)
|
||||||
logger.info(f"添加物料{container_query_dict['name']}到资源跟踪器")
|
logger.info(f"添加物料{container_instance.name}到资源跟踪器")
|
||||||
else:
|
else:
|
||||||
assert (
|
assert (
|
||||||
len(found_resources) == 1
|
len(found_resources) == 1
|
||||||
), f"找到多个同名物料: {container_query_dict['name']}, 请检查物料系统"
|
), f"找到多个同名物料: {container_instance.name}, 请检查物料系统"
|
||||||
resource = found_resources[0]
|
found_resource = found_resources[0]
|
||||||
if isinstance(resource, Resource):
|
if isinstance(found_resource, RegularContainer):
|
||||||
regular_container = RegularContainer(resource.id)
|
logger.info(f"更新物料{container_instance.name}的数据{found_resource.state}")
|
||||||
regular_container.ulr_resource = resource
|
found_resource.state.update(json.loads(container_instance.state))
|
||||||
regular_container.ulr_resource_data.update(json.loads(container_instance.data))
|
elif isinstance(found_resource, dict):
|
||||||
logger.info(f"更新物料{container_query_dict['name']}的数据{resource.data} ULR")
|
raise ValueError("已不支持 字典 版本的RegularContainer")
|
||||||
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")
|
|
||||||
else:
|
else:
|
||||||
logger.info(
|
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)
|
# noinspection PyUnresolvedReferences
|
||||||
# 应该先add_resource了
|
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 = {
|
final_response = {
|
||||||
"created_resources": [ROS2MessageInstance(i).get_python_dict() for i in request.resources],
|
"created_resources": rts.dump(),
|
||||||
"liquid_input_resources": [],
|
"liquid_input_resources": [],
|
||||||
}
|
}
|
||||||
res.response = json.dumps(final_response)
|
res.response = json.dumps(final_response)
|
||||||
@@ -458,59 +463,63 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
)
|
)
|
||||||
res.response = get_result_info_str(traceback.format_exc(), False, {})
|
res.response = get_result_info_str(traceback.format_exc(), False, {})
|
||||||
return res
|
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:
|
try:
|
||||||
from pylabrobot.resources.resource import Resource as ResourcePLR
|
if len(rts.root_nodes) == 1 and parent_resource is not None:
|
||||||
from pylabrobot.resources.deck import Deck
|
plr_instance = rts.to_plr_resources()[0]
|
||||||
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 isinstance(plr_instance, Plate):
|
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(
|
for liquid_type, liquid_volume, liquid_input_slot in zip(
|
||||||
ADD_LIQUID_TYPE, LIQUID_VOLUME, LIQUID_INPUT_SLOT
|
ADD_LIQUID_TYPE, LIQUID_VOLUME, LIQUID_INPUT_SLOT
|
||||||
):
|
):
|
||||||
empty_liquid_info_in[liquid_input_slot] = (liquid_type, liquid_volume)
|
empty_liquid_info_in[liquid_input_slot] = (liquid_type, liquid_volume)
|
||||||
plr_instance.set_well_liquids(empty_liquid_info_in)
|
plr_instance.set_well_liquids(empty_liquid_info_in)
|
||||||
input_wells_ulr = [
|
try:
|
||||||
convert_to_ros_msg(
|
# noinspection PyProtectedMember
|
||||||
Resource,
|
keys = list(plr_instance._ordering.keys())
|
||||||
resource_plr_to_ulab(plr_instance.get_well(LIQUID_INPUT_SLOT), with_children=False),
|
for ind, r in enumerate(LIQUID_INPUT_SLOT[:]):
|
||||||
)
|
if isinstance(r, int):
|
||||||
for r in LIQUID_INPUT_SLOT
|
# noinspection PyTypeChecker
|
||||||
]
|
LIQUID_INPUT_SLOT[ind] = keys[r]
|
||||||
final_response["liquid_input_resources"] = [
|
input_wells = [plr_instance.get_well(r) for r in LIQUID_INPUT_SLOT]
|
||||||
ROS2MessageInstance(i).get_python_dict() for i in input_wells_ulr
|
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)
|
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"])
|
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:
|
else:
|
||||||
_discard_slot = other_calling_param.pop("slot", "-1")
|
_discard_slot = other_calling_param.pop("slot", -1)
|
||||||
resource.assign_child_resource(
|
parent_resource.assign_child_resource(
|
||||||
plr_instance,
|
plr_instance,
|
||||||
Coordinate(location["x"], location["y"], location["z"]),
|
Coordinate(location["x"], location["y"], location["z"]),
|
||||||
**other_calling_param,
|
**other_calling_param,
|
||||||
)
|
)
|
||||||
request2.resources = [
|
# 调整了液体以及Deck之后要重新Assign
|
||||||
convert_to_ros_msg(Resource, r) for r in tree_to_list([resource_plr_to_ulab(resource)])
|
# noinspection PyUnresolvedReferences
|
||||||
]
|
rts_with_parent = ResourceTreeSet.from_plr_resources([parent_resource])
|
||||||
rclient2.call(request2)
|
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
|
# 发送给ResourceMeshManager
|
||||||
action_client = ActionClient(
|
action_client = ActionClient(
|
||||||
self,
|
self,
|
||||||
@@ -521,7 +530,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
goal = SendCmd.Goal()
|
goal = SendCmd.Goal()
|
||||||
goal.command = json.dumps(
|
goal.command = json.dumps(
|
||||||
{
|
{
|
||||||
"resources": resources,
|
"resources": input_resources,
|
||||||
"bind_parent_id": bind_parent_id,
|
"bind_parent_id": bind_parent_id,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -614,7 +623,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
)
|
)
|
||||||
) # type: ignore
|
) # type: ignore
|
||||||
raw_nodes = json.loads(response.response)
|
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)} 个资源树")
|
self.lab_logger().debug(f"获取资源结果: {len(tree_set.trees)} 个资源树")
|
||||||
return tree_set
|
return tree_set
|
||||||
|
|
||||||
@@ -642,7 +651,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
raw_data = json.loads(response.response)
|
raw_data = json.loads(response.response)
|
||||||
|
|
||||||
# 转换为 PLR 资源
|
# 转换为 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]
|
plr_resource = tree_set.to_plr_resources()[0]
|
||||||
self.lab_logger().debug(f"获取资源 {resource_id} 成功")
|
self.lab_logger().debug(f"获取资源 {resource_id} 成功")
|
||||||
return plr_resource
|
return plr_resource
|
||||||
@@ -787,7 +796,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
}
|
}
|
||||||
|
|
||||||
def _handle_update(
|
def _handle_update(
|
||||||
plr_resources: List[ResourcePLR], tree_set: ResourceTreeSet, additional_add_params: Dict[str, Any]
|
plr_resources: List[Union[ResourcePLR, ResourceDictInstance]], tree_set: ResourceTreeSet, additional_add_params: Dict[str, Any]
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
处理资源更新操作的内部函数
|
处理资源更新操作的内部函数
|
||||||
@@ -801,6 +810,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
操作结果字典
|
操作结果字典
|
||||||
"""
|
"""
|
||||||
for plr_resource, tree in zip(plr_resources, tree_set.trees):
|
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()
|
states = plr_resource.serialize_all_state()
|
||||||
original_instance: ResourcePLR = self.resource_tracker.figure_resource(
|
original_instance: ResourcePLR = self.resource_tracker.figure_resource(
|
||||||
{"uuid": tree.root_node.res_content.uuid}, try_mode=False
|
{"uuid": tree.root_node.res_content.uuid}, try_mode=False
|
||||||
@@ -880,7 +892,12 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
elif action == "update":
|
elif action == "update":
|
||||||
if tree_set is None:
|
if tree_set is None:
|
||||||
raise ValueError("tree_set不能为None")
|
raise ValueError("tree_set不能为None")
|
||||||
plr_resources = tree_set.to_plr_resources()
|
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 = _handle_update(plr_resources, tree_set, additional_add_params)
|
result = _handle_update(plr_resources, tree_set, additional_add_params)
|
||||||
results.append(result)
|
results.append(result)
|
||||||
elif action == "remove":
|
elif action == "remove":
|
||||||
@@ -1523,7 +1540,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
raw_data = json.loads(response.response)
|
raw_data = json.loads(response.response)
|
||||||
|
|
||||||
# 转换为 PLR 资源
|
# 转换为 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]
|
plr_resource = tree_set.to_plr_resources()[0]
|
||||||
|
|
||||||
# 通过资源跟踪器获取本地实例
|
# 通过资源跟踪器获取本地实例
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ from unilabos.ros.nodes.resource_tracker import (
|
|||||||
)
|
)
|
||||||
from unilabos.utils import logger
|
from unilabos.utils import logger
|
||||||
from unilabos.utils.exception import DeviceClassInvalid
|
from unilabos.utils.exception import DeviceClassInvalid
|
||||||
|
from unilabos.utils.log import warning
|
||||||
from unilabos.utils.type_check import serialize_result_info
|
from unilabos.utils.type_check import serialize_result_info
|
||||||
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
|
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
|
||||||
|
|
||||||
@@ -180,7 +181,7 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
for plr_resource in ResourceTreeSet([tree]).to_plr_resources():
|
for plr_resource in ResourceTreeSet([tree]).to_plr_resources():
|
||||||
self._resource_tracker.add_resource(plr_resource)
|
self._resource_tracker.add_resource(plr_resource)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
self.lab_logger().warning(f"[Host Node-Resource] 根节点物料{tree}序列化失败!")
|
warning(f"[Host Node-Resource] 根节点物料{tree}序列化失败!")
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.error(f"[Host Node-Resource] 添加物料出错!\n{traceback.format_exc()}")
|
logger.error(f"[Host Node-Resource] 添加物料出错!\n{traceback.format_exc()}")
|
||||||
# 初始化Node基类,传递空参数覆盖列表
|
# 初始化Node基类,传递空参数覆盖列表
|
||||||
@@ -455,10 +456,10 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
|
|
||||||
async def create_resource(
|
async def create_resource(
|
||||||
self,
|
self,
|
||||||
device_id: str,
|
device_id: DeviceSlot,
|
||||||
res_id: str,
|
res_id: str,
|
||||||
class_name: str,
|
class_name: str,
|
||||||
parent: str,
|
parent: ResourceSlot,
|
||||||
bind_locations: Point,
|
bind_locations: Point,
|
||||||
liquid_input_slot: list[int] = [],
|
liquid_input_slot: list[int] = [],
|
||||||
liquid_type: list[str] = [],
|
liquid_type: list[str] = [],
|
||||||
@@ -805,7 +806,7 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
|
|
||||||
self.lab_logger().info(f"[Host Node] Result for {action_id} ({job_id[:8]}): {status}")
|
self.lab_logger().info(f"[Host Node] Result for {action_id} ({job_id[:8]}): {status}")
|
||||||
if goal_status != GoalStatus.STATUS_CANCELED:
|
if goal_status != GoalStatus.STATUS_CANCELED:
|
||||||
self.lab_logger().debug(f"[Host Node] Result data: {result_data}")
|
self.lab_logger().trace(f"[Host Node] Result data: {result_data}")
|
||||||
|
|
||||||
# 清理 _goals 中的记录
|
# 清理 _goals 中的记录
|
||||||
if job_id in self._goals:
|
if job_id in self._goals:
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
|
|||||||
r
|
r
|
||||||
) # type: ignore
|
) # type: ignore
|
||||||
raw_data = json.loads(response.response)
|
raw_data = json.loads(response.response)
|
||||||
tree_set = ResourceTreeSet.from_raw_list(raw_data)
|
tree_set = ResourceTreeSet.from_raw_dict_list(raw_data)
|
||||||
target = tree_set.dump()
|
target = tree_set.dump()
|
||||||
protocol_kwargs[k] = target[0][0] if v == "unilabos_msgs/Resource" else target
|
protocol_kwargs[k] = target[0][0] if v == "unilabos_msgs/Resource" else target
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
|
|||||||
@@ -523,7 +523,7 @@ class ResourceTreeSet(object):
|
|||||||
return plr_resources
|
return plr_resources
|
||||||
|
|
||||||
@classmethod
|
@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 关系
|
从原始字典列表创建 ResourceTreeSet,自动建立 parent-children 关系
|
||||||
|
|
||||||
@@ -573,10 +573,10 @@ class ResourceTreeSet(object):
|
|||||||
parent_instance.children.append(instance)
|
parent_instance.children.append(instance)
|
||||||
|
|
||||||
# 第四步:使用 from_nested_list 创建 ResourceTreeSet
|
# 第四步:使用 from_nested_list 创建 ResourceTreeSet
|
||||||
return cls.from_nested_list(instances)
|
return cls.from_nested_instance_list(instances)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_nested_list(cls, nested_list: List[ResourceDictInstance]) -> "ResourceTreeSet":
|
def from_nested_instance_list(cls, nested_list: List[ResourceDictInstance]) -> "ResourceTreeSet":
|
||||||
"""
|
"""
|
||||||
从扁平化的资源列表创建ResourceTreeSet,自动按根节点分组
|
从扁平化的资源列表创建ResourceTreeSet,自动按根节点分组
|
||||||
|
|
||||||
@@ -785,7 +785,7 @@ class ResourceTreeSet(object):
|
|||||||
"""
|
"""
|
||||||
nested_lists = []
|
nested_lists = []
|
||||||
for tree_data in data:
|
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)
|
return cls(nested_lists)
|
||||||
|
|
||||||
|
|
||||||
@@ -965,7 +965,7 @@ class DeviceNodeResourceTracker(object):
|
|||||||
if current_uuid in self.uuid_to_resources:
|
if current_uuid in self.uuid_to_resources:
|
||||||
self.uuid_to_resources.pop(current_uuid)
|
self.uuid_to_resources.pop(current_uuid)
|
||||||
self.uuid_to_resources[new_uuid] = res
|
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
|
replaced = 1
|
||||||
return replaced
|
return replaced
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
],
|
],
|
||||||
"parent": null,
|
"parent": null,
|
||||||
"type": "device",
|
"type": "device",
|
||||||
"class": "mock_chiller",
|
"class": "virtual_heatchill",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 620.6111111111111,
|
"x": 620.6111111111111,
|
||||||
"y": 171,
|
"y": 171,
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
"children": [],
|
"children": [],
|
||||||
"parent": null,
|
"parent": null,
|
||||||
"type": "device",
|
"type": "device",
|
||||||
"class": "mock_filter",
|
"class": "virtual_filter",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 620.6111111111111,
|
"x": 620.6111111111111,
|
||||||
"y": 171,
|
"y": 171,
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
"children": [],
|
"children": [],
|
||||||
"parent": null,
|
"parent": null,
|
||||||
"type": "device",
|
"type": "device",
|
||||||
"class": "mock_heater",
|
"class": "virtual_heatchill",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 620.6111111111111,
|
"x": 620.6111111111111,
|
||||||
"y": 171,
|
"y": 171,
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
"children": [],
|
"children": [],
|
||||||
"parent": null,
|
"parent": null,
|
||||||
"type": "device",
|
"type": "device",
|
||||||
"class": "mock_pump",
|
"class": "virtual_transfer_pump",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 620.6111111111111,
|
"x": 620.6111111111111,
|
||||||
"y": 171,
|
"y": 171,
|
||||||
@@ -147,7 +147,7 @@
|
|||||||
"children": [],
|
"children": [],
|
||||||
"parent": null,
|
"parent": null,
|
||||||
"type": "device",
|
"type": "device",
|
||||||
"class": "mock_rotavap",
|
"class": "virtual_rotavap",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 620.6111111111111,
|
"x": 620.6111111111111,
|
||||||
"y": 171,
|
"y": 171,
|
||||||
@@ -175,7 +175,7 @@
|
|||||||
"children": [],
|
"children": [],
|
||||||
"parent": null,
|
"parent": null,
|
||||||
"type": "device",
|
"type": "device",
|
||||||
"class": "mock_separator",
|
"class": "virtual_separator",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 620.6111111111111,
|
"x": 620.6111111111111,
|
||||||
"y": 171,
|
"y": 171,
|
||||||
@@ -213,7 +213,7 @@
|
|||||||
"children": [],
|
"children": [],
|
||||||
"parent": null,
|
"parent": null,
|
||||||
"type": "device",
|
"type": "device",
|
||||||
"class": "mock_solenoid_valve",
|
"class": "virtual_solenoid_valve",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 620.6111111111111,
|
"x": 620.6111111111111,
|
||||||
"y": 171,
|
"y": 171,
|
||||||
@@ -233,7 +233,7 @@
|
|||||||
"children": [],
|
"children": [],
|
||||||
"parent": null,
|
"parent": null,
|
||||||
"type": "device",
|
"type": "device",
|
||||||
"class": "mock_stirrer_new",
|
"class": "virtual_stirrer",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 620.6111111111111,
|
"x": 620.6111111111111,
|
||||||
"y": 171,
|
"y": 171,
|
||||||
@@ -261,7 +261,7 @@
|
|||||||
"children": [],
|
"children": [],
|
||||||
"parent": null,
|
"parent": null,
|
||||||
"type": "device",
|
"type": "device",
|
||||||
"class": "mock_stirrer",
|
"class": "virtual_stirrer",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 620.6111111111111,
|
"x": 620.6111111111111,
|
||||||
"y": 171,
|
"y": 171,
|
||||||
@@ -289,7 +289,7 @@
|
|||||||
"children": [],
|
"children": [],
|
||||||
"parent": null,
|
"parent": null,
|
||||||
"type": "device",
|
"type": "device",
|
||||||
"class": "mock_vacuum",
|
"class": "virtual_vacuum_pump",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 620.6111111111111,
|
"x": 620.6111111111111,
|
||||||
"y": 171,
|
"y": 171,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"children": [],
|
"children": [],
|
||||||
"parent": null,
|
"parent": null,
|
||||||
"type": "device",
|
"type": "device",
|
||||||
"class": "mock_chiller",
|
"class": "virtual_heatchill",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 620.6111111111111,
|
"x": 620.6111111111111,
|
||||||
"y": 171,
|
"y": 171,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"children": [],
|
"children": [],
|
||||||
"parent": null,
|
"parent": null,
|
||||||
"type": "device",
|
"type": "device",
|
||||||
"class": "mock_filter",
|
"class": "virtual_filter",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 620.6111111111111,
|
"x": 620.6111111111111,
|
||||||
"y": 171,
|
"y": 171,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"children": [],
|
"children": [],
|
||||||
"parent": null,
|
"parent": null,
|
||||||
"type": "device",
|
"type": "device",
|
||||||
"class": "mock_heater",
|
"class": "virtual_heatchill",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 620.6111111111111,
|
"x": 620.6111111111111,
|
||||||
"y": 171,
|
"y": 171,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"children": [],
|
"children": [],
|
||||||
"parent": null,
|
"parent": null,
|
||||||
"type": "device",
|
"type": "device",
|
||||||
"class": "mock_pump",
|
"class": "virtual_transfer_pump",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 620.6111111111111,
|
"x": 620.6111111111111,
|
||||||
"y": 171,
|
"y": 171,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"children": [],
|
"children": [],
|
||||||
"parent": null,
|
"parent": null,
|
||||||
"type": "device",
|
"type": "device",
|
||||||
"class": "mock_rotavap",
|
"class": "virtual_rotavap",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 620.6111111111111,
|
"x": 620.6111111111111,
|
||||||
"y": 171,
|
"y": 171,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"children": [],
|
"children": [],
|
||||||
"parent": null,
|
"parent": null,
|
||||||
"type": "device",
|
"type": "device",
|
||||||
"class": "mock_separator",
|
"class": "virtual_separator",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 620.6111111111111,
|
"x": 620.6111111111111,
|
||||||
"y": 171,
|
"y": 171,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"children": [],
|
"children": [],
|
||||||
"parent": null,
|
"parent": null,
|
||||||
"type": "device",
|
"type": "device",
|
||||||
"class": "mock_solenoid_valve",
|
"class": "virtual_solenoid_valve",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 620.6111111111111,
|
"x": 620.6111111111111,
|
||||||
"y": 171,
|
"y": 171,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"children": [],
|
"children": [],
|
||||||
"parent": null,
|
"parent": null,
|
||||||
"type": "device",
|
"type": "device",
|
||||||
"class": "mock_stirrer",
|
"class": "virtual_stirrer",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 620.6111111111111,
|
"x": 620.6111111111111,
|
||||||
"y": 171,
|
"y": 171,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"children": [],
|
"children": [],
|
||||||
"parent": null,
|
"parent": null,
|
||||||
"type": "device",
|
"type": "device",
|
||||||
"class": "mock_stirrer_new",
|
"class": "virtual_stirrer",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 620.6111111111111,
|
"x": 620.6111111111111,
|
||||||
"y": 171,
|
"y": 171,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"children": [],
|
"children": [],
|
||||||
"parent": null,
|
"parent": null,
|
||||||
"type": "device",
|
"type": "device",
|
||||||
"class": "mock_vacuum",
|
"class": "virtual_vacuum_pump",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 620.6111111111111,
|
"x": 620.6111111111111,
|
||||||
"y": 171,
|
"y": 171,
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"children": [],
|
"children": [],
|
||||||
"parent": "CentrifugeTestStation",
|
"parent": "CentrifugeTestStation",
|
||||||
"type": "device",
|
"type": "device",
|
||||||
"class": "virtual_pump",
|
"class": "virtual_transfer_pump",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 520.6111111111111,
|
"x": 520.6111111111111,
|
||||||
"y": 300,
|
"y": 300,
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"children": [],
|
"children": [],
|
||||||
"parent": "FilterTestStation",
|
"parent": "FilterTestStation",
|
||||||
"type": "device",
|
"type": "device",
|
||||||
"class": "virtual_pump",
|
"class": "virtual_transfer_pump",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 520.6111111111111,
|
"x": 520.6111111111111,
|
||||||
"y": 300,
|
"y": 300,
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"children": [],
|
"children": [],
|
||||||
"parent": "HeatChillTestStation",
|
"parent": "HeatChillTestStation",
|
||||||
"type": "device",
|
"type": "device",
|
||||||
"class": "virtual_pump",
|
"class": "virtual_transfer_pump",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 520.6111111111111,
|
"x": 520.6111111111111,
|
||||||
"y": 300,
|
"y": 300,
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"children": [],
|
"children": [],
|
||||||
"parent": "StirTestStation",
|
"parent": "StirTestStation",
|
||||||
"type": "device",
|
"type": "device",
|
||||||
"class": "virtual_pump",
|
"class": "virtual_transfer_pump",
|
||||||
"position": {
|
"position": {
|
||||||
"x": 520.6111111111111,
|
"x": 520.6111111111111,
|
||||||
"y": 300,
|
"y": 300,
|
||||||
|
|||||||
@@ -21,12 +21,12 @@
|
|||||||
},
|
},
|
||||||
"host": "10.20.30.184",
|
"host": "10.20.30.184",
|
||||||
"port": 9999,
|
"port": 9999,
|
||||||
"debug": false,
|
"debug": true,
|
||||||
"setup": true,
|
"setup": true,
|
||||||
"is_9320": true,
|
"is_9320": true,
|
||||||
"timeout": 10,
|
"timeout": 10,
|
||||||
"matrix_id": "5de524d0-3f95-406c-86dd-f83626ebc7cb",
|
"matrix_id": "5de524d0-3f95-406c-86dd-f83626ebc7cb",
|
||||||
"simulator": false,
|
"simulator": true,
|
||||||
"channel_num": 2
|
"channel_num": 2
|
||||||
},
|
},
|
||||||
"data": {
|
"data": {
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
import json
|
|
||||||
import sys
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
|
||||||
if str(ROOT_DIR) not in sys.path:
|
|
||||||
sys.path.insert(0, str(ROOT_DIR))
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from scripts.workflow import build_protocol_graph, draw_protocol_graph, draw_protocol_graph_with_ports
|
|
||||||
|
|
||||||
|
|
||||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
|
||||||
if str(ROOT_DIR) not in sys.path:
|
|
||||||
sys.path.insert(0, str(ROOT_DIR))
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_steps(data):
|
|
||||||
normalized = []
|
|
||||||
for step in data:
|
|
||||||
action = step.get("action") or step.get("operation")
|
|
||||||
if not action:
|
|
||||||
continue
|
|
||||||
raw_params = step.get("parameters") or step.get("action_args") or {}
|
|
||||||
params = dict(raw_params)
|
|
||||||
|
|
||||||
if "source" in raw_params and "sources" not in raw_params:
|
|
||||||
params["sources"] = raw_params["source"]
|
|
||||||
if "target" in raw_params and "targets" not in raw_params:
|
|
||||||
params["targets"] = raw_params["target"]
|
|
||||||
|
|
||||||
description = step.get("description") or step.get("purpose")
|
|
||||||
step_dict = {"action": action, "parameters": params}
|
|
||||||
if description:
|
|
||||||
step_dict["description"] = description
|
|
||||||
normalized.append(step_dict)
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_labware(data):
|
|
||||||
labware = {}
|
|
||||||
for item in data:
|
|
||||||
reagent_name = item.get("reagent_name")
|
|
||||||
key = reagent_name or item.get("material_name") or item.get("name")
|
|
||||||
if not key:
|
|
||||||
continue
|
|
||||||
key = str(key)
|
|
||||||
idx = 1
|
|
||||||
original_key = key
|
|
||||||
while key in labware:
|
|
||||||
idx += 1
|
|
||||||
key = f"{original_key}_{idx}"
|
|
||||||
|
|
||||||
labware[key] = {
|
|
||||||
"slot": item.get("positions") or item.get("slot"),
|
|
||||||
"labware": item.get("material_name") or item.get("labware"),
|
|
||||||
"well": item.get("well", []),
|
|
||||||
"type": item.get("type", "reagent"),
|
|
||||||
"role": item.get("role", ""),
|
|
||||||
"name": key,
|
|
||||||
}
|
|
||||||
return labware
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("protocol_name", [
|
|
||||||
"example_bio",
|
|
||||||
# "bioyond_materials_liquidhandling_1",
|
|
||||||
"example_prcxi",
|
|
||||||
])
|
|
||||||
def test_build_protocol_graph(protocol_name):
|
|
||||||
data_path = Path(__file__).with_name(f"{protocol_name}.json")
|
|
||||||
with data_path.open("r", encoding="utf-8") as fp:
|
|
||||||
d = json.load(fp)
|
|
||||||
|
|
||||||
if "workflow" in d and "reagent" in d:
|
|
||||||
protocol_steps = d["workflow"]
|
|
||||||
labware_info = d["reagent"]
|
|
||||||
elif "steps_info" in d and "labware_info" in d:
|
|
||||||
protocol_steps = _normalize_steps(d["steps_info"])
|
|
||||||
labware_info = _normalize_labware(d["labware_info"])
|
|
||||||
else:
|
|
||||||
raise ValueError("Unsupported protocol format")
|
|
||||||
|
|
||||||
graph = build_protocol_graph(
|
|
||||||
labware_info=labware_info,
|
|
||||||
protocol_steps=protocol_steps,
|
|
||||||
workstation_name="PRCXi",
|
|
||||||
)
|
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M")
|
|
||||||
output_path = data_path.with_name(f"{protocol_name}_graph_{timestamp}.png")
|
|
||||||
draw_protocol_graph_with_ports(graph, str(output_path))
|
|
||||||
print(graph)
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
|
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
|
||||||
<package format="3">
|
<package format="3">
|
||||||
<name>unilabos_msgs</name>
|
<name>unilabos_msgs</name>
|
||||||
<version>0.10.13</version>
|
<version>0.10.14</version>
|
||||||
<description>ROS2 Messages package for unilabos devices</description>
|
<description>ROS2 Messages package for unilabos devices</description>
|
||||||
<maintainer email="changjh@pku.edu.cn">Junhan Chang</maintainer>
|
<maintainer email="changjh@pku.edu.cn">Junhan Chang</maintainer>
|
||||||
<maintainer email="18435084+Xuwznln@users.noreply.github.com">Xuwznln</maintainer>
|
<maintainer email="18435084+Xuwznln@users.noreply.github.com">Xuwznln</maintainer>
|
||||||
|
|||||||
Reference in New Issue
Block a user