Compare commits

..

31 Commits

Author SHA1 Message Date
Xuwznln
dd5a7cab75 支持Biomek创建 2025-06-05 16:04:44 +08:00
Xuwznln
39de3ac58e 更新transfer_biomek的msg 2025-06-05 15:41:16 +08:00
Xuwznln
b99969278c 更新transfer_biomek的msg 2025-06-05 15:30:51 +08:00
Guangxin Zhang
b957ad2f71 Merge branch '37-biomek-i5i7' of https://github.com/dptech-corp/Uni-Lab-OS into 37-biomek-i5i7 2025-06-04 21:49:27 +08:00
Guangxin Zhang
e1a7c3a103 Updated transfer_biomek 2025-06-04 21:49:22 +08:00
Guangxin Zhang
e63c15997c New transfer_biomek 2025-06-04 21:29:54 +08:00
Xuwznln
c5a495f409 新增transfer_biomek的msg 2025-06-04 19:03:00 +08:00
Guangxin Zhang
5b240cb0ea Update biomek.py 2025-06-04 17:30:53 +08:00
Guangxin Zhang
147b8f47c0 Biomek test 2025-06-04 16:38:18 +08:00
Guangxin Zhang
6d2489af5f Merge branch '37-biomek-i5i7' of https://github.com/dptech-corp/Uni-Lab-OS into 37-biomek-i5i7 2025-06-04 13:27:11 +08:00
Guangxin Zhang
807dcdd226 Update biomek.py 2025-06-04 13:27:05 +08:00
Guangxin Zhang
8a29bc5597 Remove warnings 2025-06-04 13:20:12 +08:00
Guangxin Zhang
6f6c70ee57 delete 's' 2025-06-04 13:11:45 +08:00
Xuwznln
478a85951c 修复biomek缺少的字段 2025-05-31 00:00:55 +08:00
Xuwznln
0f2555c90c 注册表上报handle和schema (param input) 2025-05-31 00:00:39 +08:00
Guangxin Zhang
d2dda6ee03 Merge branch '37-biomek-i5i7' of https://github.com/dptech-corp/Uni-Lab-OS into 37-biomek-i5i7 2025-05-30 17:11:23 +08:00
Guangxin Zhang
208540b307 Update biomek.py 2025-05-30 17:08:19 +08:00
Guangxin Zhang
cb7c56a1d9 Convert LH action to biomek. 2025-05-30 17:00:06 +08:00
Xuwznln
ea2e9c3e3a fix biomek success type 2025-05-30 16:50:13 +08:00
Guangxin Zhang
0452a68180 Test 2025-05-30 16:03:49 +08:00
Xuwznln
90a0f3db9b merge 2025-05-30 15:40:14 +08:00
Junhan Chang
055d120ba8 更新LiquidHandlerBiomek类,添加资源创建功能,优化协议创建方法,修复部分代码格式问题,更新YAML配置以支持新功能。 2025-05-30 15:38:23 +08:00
Junhan Chang
a948f09f60 add biomek.py demo implementation 2025-05-30 13:33:10 +08:00
Xuwznln
6f69df440c 修复linux 64构建问题 2025-05-30 01:27:34 +08:00
Xuwznln
b420d1fa8e bump version to 0.9.1 2025-05-29 22:17:50 +08:00
Xuwznln
767e0fcdee bump version to 0.9.1 2025-05-29 20:44:30 +08:00
Xuwznln
84944396e9 34 icon support online (#35)
* unify liquid_handler definition

* remove default values

* Dev Sync (#25)

* Update README and MQTTClient for installation instructions and code improvements

* feat: 支持local_config启动
add: 增加对crt path的说明,为传入config.py的相对路径
move: web component

* add: registry description

* add 3d visualization

* 完成在main中启动设备可视化

完成在main中启动设备可视化,并输出物料ID:mesh的对应关系resource_model

添加物料模型管理类,遍历物料与resource_model,完成TF数据收集

* 完成TF发布

* 修改模型方向,在yaml中添加变换属性

* 添加物料tf变化时,发送topic到前端

另外修改了物料初始化的方法,防止在tf还未发布时提前建立物料模型与发布话题

* 添加关节发布节点与物料可视化节点进入unilab

* 使用json启动plr与3D模型仿真

* feat: node_info_update srv
fix: OTDeck cant create

* close #12
feat: slave node registry

* feat: show machine name
fix: host node registry not uploaded

* feat: add hplc registry

* feat: add hplc registry

* fix: hplc status typo

* fix: devices/

* 完成启动OT并联动rviz

* add 3d visualization

* 完成在main中启动设备可视化

完成在main中启动设备可视化,并输出物料ID:mesh的对应关系resource_model

添加物料模型管理类,遍历物料与resource_model,完成TF数据收集

* 完成TF发布

* 修改模型方向,在yaml中添加变换属性

* 添加物料tf变化时,发送topic到前端

另外修改了物料初始化的方法,防止在tf还未发布时提前建立物料模型与发布话题

* 添加关节发布节点与物料可视化节点进入unilab

* 使用json启动plr与3D模型仿真

* 完成启动OT并联动rviz

* fix: device.class possible null

* fix: HPLC additions with online service

* fix: slave mode spin not working

* fix: slave mode spin not working

* 修复rviz位置问题,

修复rviz位置问题,
在无tf变动时减缓发送频率
在backend中添加物料跟随方法

* feat: 多ProtocolNode 允许子设备ID相同
feat: 上报发现的ActionClient
feat: Host重启动,通过discover机制要求slaveNode重新注册,实现信息及时上报

* feat: 支持env设置config

* fix: running logic

* fix: running logic

* fix: missing ot

* 在main中直接初始化republisher和物料的mesh节点

* 将joint_republisher和resource_mesh_manager添加进 main_slave_run.py中

* Device visualization (#14)

* add 3d visualization

* 完成在main中启动设备可视化

完成在main中启动设备可视化,并输出物料ID:mesh的对应关系resource_model

添加物料模型管理类,遍历物料与resource_model,完成TF数据收集

* 完成TF发布

* 修改模型方向,在yaml中添加变换属性

* 添加物料tf变化时,发送topic到前端

另外修改了物料初始化的方法,防止在tf还未发布时提前建立物料模型与发布话题

* 添加关节发布节点与物料可视化节点进入unilab

* 使用json启动plr与3D模型仿真

* 完成启动OT并联动rviz

* add 3d visualization

* 完成在main中启动设备可视化

完成在main中启动设备可视化,并输出物料ID:mesh的对应关系resource_model

添加物料模型管理类,遍历物料与resource_model,完成TF数据收集

* 完成TF发布

* 修改模型方向,在yaml中添加变换属性

* 添加物料tf变化时,发送topic到前端

另外修改了物料初始化的方法,防止在tf还未发布时提前建立物料模型与发布话题

* 添加关节发布节点与物料可视化节点进入unilab

* 使用json启动plr与3D模型仿真

* 完成启动OT并联动rviz

* 修复rviz位置问题,

修复rviz位置问题,
在无tf变动时减缓发送频率
在backend中添加物料跟随方法

* fix: running logic

* fix: running logic

* fix: missing ot

* 在main中直接初始化republisher和物料的mesh节点

* 将joint_republisher和resource_mesh_manager添加进 main_slave_run.py中

---------

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

* fix: missing hostname in devices_names
fix: upload_file for model file

* fix: missing paho-mqtt package
bump version to 0.9.0

* fix startup
add ResourceCreateFromOuter.action

* fix type hint

* update actions

* update actions

* host node add_resource_from_outer
fix cmake list

* pass device config to device class

* add: bind_parent_ids to resource create action
fix: message convert string

* fix: host node should not be re_discovered

* feat: resource tracker support dict

* feat: add more necessary params

* feat: fix boolean null in registry action data

* feat: add outer resource

* 编写mesh添加action

* feat: append resource

* add action

* feat: vis 2d for plr

* fix

* fix: browser on rviz

* fix: cloud bridge error fallback to local

* fix: salve auto run rviz

* 初始化两个plate

* Device visualization (#22)

* add 3d visualization

* 完成在main中启动设备可视化

完成在main中启动设备可视化,并输出物料ID:mesh的对应关系resource_model

添加物料模型管理类,遍历物料与resource_model,完成TF数据收集

* 完成TF发布

* 修改模型方向,在yaml中添加变换属性

* 添加物料tf变化时,发送topic到前端

另外修改了物料初始化的方法,防止在tf还未发布时提前建立物料模型与发布话题

* 添加关节发布节点与物料可视化节点进入unilab

* 使用json启动plr与3D模型仿真

* 完成启动OT并联动rviz

* add 3d visualization

* 完成在main中启动设备可视化

完成在main中启动设备可视化,并输出物料ID:mesh的对应关系resource_model

添加物料模型管理类,遍历物料与resource_model,完成TF数据收集

* 完成TF发布

* 修改模型方向,在yaml中添加变换属性

* 添加物料tf变化时,发送topic到前端

另外修改了物料初始化的方法,防止在tf还未发布时提前建立物料模型与发布话题

* 添加关节发布节点与物料可视化节点进入unilab

* 使用json启动plr与3D模型仿真

* 完成启动OT并联动rviz

* 修复rviz位置问题,

修复rviz位置问题,
在无tf变动时减缓发送频率
在backend中添加物料跟随方法

* fix: running logic

* fix: running logic

* fix: missing ot

* 在main中直接初始化republisher和物料的mesh节点

* 将joint_republisher和resource_mesh_manager添加进 main_slave_run.py中

* 编写mesh添加action

* add action

* fix

* fix: browser on rviz

* fix: cloud bridge error fallback to local

* fix: salve auto run rviz

* 初始化两个plate

---------

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

* fix: multi channel

* fix: aspirate

* fix: aspirate

* fix: aspirate

* fix: aspirate

* 提交

* fix: jobadd

* fix: jobadd

* fix: msg converter

* tijiao

* add resource creat easy action

* identify debug msg

* mq client id

---------

Co-authored-by: Harvey Que <Q-Query@outlook.com>
Co-authored-by: zhangshixiang <@zhangshixiang>
Co-authored-by: q434343 <73513873+q434343@users.noreply.github.com>

* remove default behavior for visualization

* change liquidhandler name

* resource create from outer easy

* add easy resource creation

* easy resource creation logic

* remove wrongly debug msg from others

* remove wrongly debug msg from others

* add missing action clients

* fix device_id

* fix slot_on_deck

* fix registry typo

* complete require packages
msg converter support array string
implements create resource logic

* 修复port输入

* 修复必须两次启动edge后端才有节点生成的bug
新增resources报送

* 新增延迟统计

---------

Co-authored-by: Junhan Chang <changjh@pku.edu.cn>
Co-authored-by: Harvey Que <Q-Query@outlook.com>
Co-authored-by: q434343 <73513873+q434343@users.noreply.github.com>
2025-05-29 20:43:01 +08:00
Xuwznln
bfcb214b53 24 high level liquidhandler (#28)
* unify liquid_handler definition

* remove default values

* Dev Sync (#25)

* Update README and MQTTClient for installation instructions and code improvements

* feat: 支持local_config启动
add: 增加对crt path的说明,为传入config.py的相对路径
move: web component

* add: registry description

* add 3d visualization

* 完成在main中启动设备可视化

完成在main中启动设备可视化,并输出物料ID:mesh的对应关系resource_model

添加物料模型管理类,遍历物料与resource_model,完成TF数据收集

* 完成TF发布

* 修改模型方向,在yaml中添加变换属性

* 添加物料tf变化时,发送topic到前端

另外修改了物料初始化的方法,防止在tf还未发布时提前建立物料模型与发布话题

* 添加关节发布节点与物料可视化节点进入unilab

* 使用json启动plr与3D模型仿真

* feat: node_info_update srv
fix: OTDeck cant create

* close #12
feat: slave node registry

* feat: show machine name
fix: host node registry not uploaded

* feat: add hplc registry

* feat: add hplc registry

* fix: hplc status typo

* fix: devices/

* 完成启动OT并联动rviz

* add 3d visualization

* 完成在main中启动设备可视化

完成在main中启动设备可视化,并输出物料ID:mesh的对应关系resource_model

添加物料模型管理类,遍历物料与resource_model,完成TF数据收集

* 完成TF发布

* 修改模型方向,在yaml中添加变换属性

* 添加物料tf变化时,发送topic到前端

另外修改了物料初始化的方法,防止在tf还未发布时提前建立物料模型与发布话题

* 添加关节发布节点与物料可视化节点进入unilab

* 使用json启动plr与3D模型仿真

* 完成启动OT并联动rviz

* fix: device.class possible null

* fix: HPLC additions with online service

* fix: slave mode spin not working

* fix: slave mode spin not working

* 修复rviz位置问题,

修复rviz位置问题,
在无tf变动时减缓发送频率
在backend中添加物料跟随方法

* feat: 多ProtocolNode 允许子设备ID相同
feat: 上报发现的ActionClient
feat: Host重启动,通过discover机制要求slaveNode重新注册,实现信息及时上报

* feat: 支持env设置config

* fix: running logic

* fix: running logic

* fix: missing ot

* 在main中直接初始化republisher和物料的mesh节点

* 将joint_republisher和resource_mesh_manager添加进 main_slave_run.py中

* Device visualization (#14)

* add 3d visualization

* 完成在main中启动设备可视化

完成在main中启动设备可视化,并输出物料ID:mesh的对应关系resource_model

添加物料模型管理类,遍历物料与resource_model,完成TF数据收集

* 完成TF发布

* 修改模型方向,在yaml中添加变换属性

* 添加物料tf变化时,发送topic到前端

另外修改了物料初始化的方法,防止在tf还未发布时提前建立物料模型与发布话题

* 添加关节发布节点与物料可视化节点进入unilab

* 使用json启动plr与3D模型仿真

* 完成启动OT并联动rviz

* add 3d visualization

* 完成在main中启动设备可视化

完成在main中启动设备可视化,并输出物料ID:mesh的对应关系resource_model

添加物料模型管理类,遍历物料与resource_model,完成TF数据收集

* 完成TF发布

* 修改模型方向,在yaml中添加变换属性

* 添加物料tf变化时,发送topic到前端

另外修改了物料初始化的方法,防止在tf还未发布时提前建立物料模型与发布话题

* 添加关节发布节点与物料可视化节点进入unilab

* 使用json启动plr与3D模型仿真

* 完成启动OT并联动rviz

* 修复rviz位置问题,

修复rviz位置问题,
在无tf变动时减缓发送频率
在backend中添加物料跟随方法

* fix: running logic

* fix: running logic

* fix: missing ot

* 在main中直接初始化republisher和物料的mesh节点

* 将joint_republisher和resource_mesh_manager添加进 main_slave_run.py中

---------

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

* fix: missing hostname in devices_names
fix: upload_file for model file

* fix: missing paho-mqtt package
bump version to 0.9.0

* fix startup
add ResourceCreateFromOuter.action

* fix type hint

* update actions

* update actions

* host node add_resource_from_outer
fix cmake list

* pass device config to device class

* add: bind_parent_ids to resource create action
fix: message convert string

* fix: host node should not be re_discovered

* feat: resource tracker support dict

* feat: add more necessary params

* feat: fix boolean null in registry action data

* feat: add outer resource

* 编写mesh添加action

* feat: append resource

* add action

* feat: vis 2d for plr

* fix

* fix: browser on rviz

* fix: cloud bridge error fallback to local

* fix: salve auto run rviz

* 初始化两个plate

* Device visualization (#22)

* add 3d visualization

* 完成在main中启动设备可视化

完成在main中启动设备可视化,并输出物料ID:mesh的对应关系resource_model

添加物料模型管理类,遍历物料与resource_model,完成TF数据收集

* 完成TF发布

* 修改模型方向,在yaml中添加变换属性

* 添加物料tf变化时,发送topic到前端

另外修改了物料初始化的方法,防止在tf还未发布时提前建立物料模型与发布话题

* 添加关节发布节点与物料可视化节点进入unilab

* 使用json启动plr与3D模型仿真

* 完成启动OT并联动rviz

* add 3d visualization

* 完成在main中启动设备可视化

完成在main中启动设备可视化,并输出物料ID:mesh的对应关系resource_model

添加物料模型管理类,遍历物料与resource_model,完成TF数据收集

* 完成TF发布

* 修改模型方向,在yaml中添加变换属性

* 添加物料tf变化时,发送topic到前端

另外修改了物料初始化的方法,防止在tf还未发布时提前建立物料模型与发布话题

* 添加关节发布节点与物料可视化节点进入unilab

* 使用json启动plr与3D模型仿真

* 完成启动OT并联动rviz

* 修复rviz位置问题,

修复rviz位置问题,
在无tf变动时减缓发送频率
在backend中添加物料跟随方法

* fix: running logic

* fix: running logic

* fix: missing ot

* 在main中直接初始化republisher和物料的mesh节点

* 将joint_republisher和resource_mesh_manager添加进 main_slave_run.py中

* 编写mesh添加action

* add action

* fix

* fix: browser on rviz

* fix: cloud bridge error fallback to local

* fix: salve auto run rviz

* 初始化两个plate

---------

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

* fix: multi channel

* fix: aspirate

* fix: aspirate

* fix: aspirate

* fix: aspirate

* 提交

* fix: jobadd

* fix: jobadd

* fix: msg converter

* tijiao

* add resource creat easy action

* identify debug msg

* mq client id

---------

Co-authored-by: Harvey Que <Q-Query@outlook.com>
Co-authored-by: zhangshixiang <@zhangshixiang>
Co-authored-by: q434343 <73513873+q434343@users.noreply.github.com>

* remove default behavior for visualization

* change liquidhandler name

* resource create from outer easy

* add easy resource creation

* easy resource creation logic

* remove wrongly debug msg from others

* remove wrongly debug msg from others

* add missing action clients

* fix device_id

* fix slot_on_deck

* fix registry typo

* complete require packages
msg converter support array string
implements create resource logic

* 修复port输入

* fix: remove dirty actions

---------

Co-authored-by: Junhan Chang <changjh@pku.edu.cn>
Co-authored-by: Harvey Que <Q-Query@outlook.com>
Co-authored-by: q434343 <73513873+q434343@users.noreply.github.com>
2025-05-29 20:40:16 +08:00
Xuwznln
ec4e6c6cfd 增加英文readme描述 (#33) 2025-05-23 10:06:30 +08:00
Xuwznln
53b6457a88 修复property类型的Action执行失败 (#30) 2025-05-17 17:57:49 +08:00
Xuwznln
133dbf77bb 嵌套节点上报云端出现ID错误 (#27)
* 修复嵌套节点,mq发送任务id出错的问题
修正一处注册表命名错误

* 修复本地看板Action显示不全
修复本地子设备没有机器名称的bug

* 补全vacuum_pump.mock注册信息
2025-05-16 19:12:59 +08:00
50 changed files with 6853 additions and 378 deletions

1
.gitignore vendored
View File

@@ -6,6 +6,7 @@ __pycache__/
.vscode .vscode
*.py[cod] *.py[cod]
*$py.class *$py.class
service
# C extensions # C extensions
*.so *.so

View File

@@ -4,83 +4,86 @@
# Uni-Lab-OS # Uni-Lab-OS
<!-- Language switcher -->
**English** | [中文](README_zh.md)
[![GitHub Stars](https://img.shields.io/github/stars/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/stargazers) [![GitHub Stars](https://img.shields.io/github/stars/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/stargazers)
[![GitHub Forks](https://img.shields.io/github/forks/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/network/members) [![GitHub Forks](https://img.shields.io/github/forks/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/network/members)
[![GitHub Issues](https://img.shields.io/github/issues/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/issues) [![GitHub Issues](https://img.shields.io/github/issues/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/issues)
[![GitHub License](https://img.shields.io/github/license/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/blob/main/LICENSE) [![GitHub License](https://img.shields.io/github/license/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/blob/main/LICENSE)
Uni-Lab 操作系统是一个用于实验室自动化的综合平台,旨在连接和控制各种实验设备,实现实验流程的自动化和标准化。 Uni-Lab Operating System is a platform for laboratory automation, designed to connect and control various experimental equipment, enabling automation and standardization of experimental workflows.
## 核心特点 ## Key Features
- 多设备集成管理 - Multi-device integration management
- 自动化实验流程 - Automated experimental workflows
- 云端连接能力 - Cloud connectivity capabilities
- 灵活的配置系统 - Flexible configuration system
- 支持多种实验协议 - Support for multiple experimental protocols
## 文档 ## Documentation
详细文档可在以下位置找到: Detailed documentation can be found at:
- [在线文档](https://readthedocs.dp.tech/Uni-Lab/v0.8.0/) - [Online Documentation](https://readthedocs.dp.tech/Uni-Lab/v0.8.0/)
## 快速开始 ## Quick Start
1. 配置Conda环境 1. Configure Conda Environment
Uni-Lab-OS 建议使用 `mamba` 管理环境。根据您的操作系统选择适当的环境文件: Uni-Lab-OS recommends using `mamba` for environment management. Choose the appropriate environment file for your operating system:
```bash ```bash
# 创建新环境 # Create new environment
mamba env create -f unilabos-[YOUR_OS].yaml mamba env create -f unilabos-[YOUR_OS].yaml
mamba activate unilab mamba activate unilab
# 或更新现有环境 # Or update existing environment
# 其中 `[YOUR_OS]` 可以是 `win64`, `linux-64`, `osx-64`, `osx-arm64` # Where `[YOUR_OS]` can be `win64`, `linux-64`, `osx-64`, or `osx-arm64`.
conda env update --file unilabos-[YOUR_OS].yml -n 环境名 conda env update --file unilabos-[YOUR_OS].yml -n environment_name
# 现阶段,需要安装 `unilabos_msgs` # Currently, you need to install the `unilabos_msgs` package
# 可以前往 Release 页面下载系统对应的包进行安装 # You can download the system-specific package from the Release page
conda install ros-humble-unilabos-msgs-0.9.0-xxxxx.tar.bz2 conda install ros-humble-unilabos-msgs-0.9.1-xxxxx.tar.bz2
# 安装PyLabRobot等前置 # Install PyLabRobot and other prerequisites
git clone https://github.com/PyLabRobot/pylabrobot plr_repo git clone https://github.com/PyLabRobot/pylabrobot plr_repo
cd plr_repo cd plr_repo
pip install .[opentrons] pip install .[opentrons]
``` ```
2. 安装 Uni-Lab-OS: 2. Install Uni-Lab-OS:
```bash ```bash
# 克隆仓库 # Clone the repository
git clone https://github.com/dptech-corp/Uni-Lab-OS.git git clone https://github.com/dptech-corp/Uni-Lab-OS.git
cd Uni-Lab-OS cd Uni-Lab-OS
# 安装 Uni-Lab-OS # Install Uni-Lab-OS
pip install . pip install .
``` ```
3. 启动 Uni-Lab 系统: 3. Start Uni-Lab System:
请见[文档-启动样例](https://readthedocs.dp.tech/Uni-Lab/v0.8.0/boot_examples/index.html) Please refer to [Documentation - Boot Examples](https://readthedocs.dp.tech/Uni-Lab/v0.8.0/boot_examples/index.html)
## 消息格式 ## Message Format
Uni-Lab-OS 使用预构建的 `unilabos_msgs` 进行系统通信。您可以在 [GitHub Releases](https://github.com/dptech-corp/Uni-Lab-OS/releases) 页面找到已构建的版本。 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.
## 许可证 ## License
此项目采用 GPL-3.0 许可 - 详情请参阅 [LICENSE](LICENSE) 文件。 This project is licensed under GPL-3.0 - see the [LICENSE](LICENSE) file for details.
## 项目统计 ## Project Statistics
### Stars 趋势 ### Stars Trend
<a href="https://star-history.com/#dptech-corp/Uni-Lab-OS&Date"> <a href="https://star-history.com/#dptech-corp/Uni-Lab-OS&Date">
<img src="https://api.star-history.com/svg?repos=dptech-corp/Uni-Lab-OS&type=Date" alt="Star History Chart" width="600"> <img src="https://api.star-history.com/svg?repos=dptech-corp/Uni-Lab-OS&type=Date" alt="Star History Chart" width="600">
</a> </a>
## 联系我们 ## 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/dptech-corp/Uni-Lab-OS/issues](https://github.com/dptech-corp/Uni-Lab-OS/issues)

89
README_zh.md Normal file
View File

@@ -0,0 +1,89 @@
<div align="center">
<img src="docs/logo.png" alt="Uni-Lab Logo" width="200"/>
</div>
# Uni-Lab-OS
<!-- Language switcher -->
[English](README.md) | **中文**
[![GitHub Stars](https://img.shields.io/github/stars/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/stargazers)
[![GitHub Forks](https://img.shields.io/github/forks/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/network/members)
[![GitHub Issues](https://img.shields.io/github/issues/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/issues)
[![GitHub License](https://img.shields.io/github/license/dptech-corp/Uni-Lab-OS.svg)](https://github.com/dptech-corp/Uni-Lab-OS/blob/main/LICENSE)
Uni-Lab 操作系统是一个用于实验室自动化的综合平台,旨在连接和控制各种实验设备,实现实验流程的自动化和标准化。
## 核心特点
- 多设备集成管理
- 自动化实验流程
- 云端连接能力
- 灵活的配置系统
- 支持多种实验协议
## 文档
详细文档可在以下位置找到:
- [在线文档](https://readthedocs.dp.tech/Uni-Lab/v0.8.0/)
## 快速开始
1. 配置Conda环境
Uni-Lab-OS 建议使用 `mamba` 管理环境。根据您的操作系统选择适当的环境文件:
```bash
# 创建新环境
mamba env create -f unilabos-[YOUR_OS].yaml
mamba activate unilab
# 或更新现有环境
# 其中 `[YOUR_OS]` 可以是 `win64`, `linux-64`, `osx-64`, 或 `osx-arm64`。
conda env update --file unilabos-[YOUR_OS].yml -n 环境名
# 现阶段,需要安装 `unilabos_msgs` 包
# 可以前往 Release 页面下载系统对应的包进行安装
conda install ros-humble-unilabos-msgs-0.9.1-xxxxx.tar.bz2
# 安装PyLabRobot等前置
git clone https://github.com/PyLabRobot/pylabrobot plr_repo
cd plr_repo
pip install .[opentrons]
```
2. 安装 Uni-Lab-OS:
```bash
# 克隆仓库
git clone https://github.com/dptech-corp/Uni-Lab-OS.git
cd Uni-Lab-OS
# 安装 Uni-Lab-OS
pip install .
```
3. 启动 Uni-Lab 系统:
请见[文档-启动样例](https://readthedocs.dp.tech/Uni-Lab/v0.8.0/boot_examples/index.html)
## 消息格式
Uni-Lab-OS 使用预构建的 `unilabos_msgs` 进行系统通信。您可以在 [GitHub Releases](https://github.com/dptech-corp/Uni-Lab-OS/releases) 页面找到已构建的版本。
## 许可证
此项目采用 GPL-3.0 许可 - 详情请参阅 [LICENSE](LICENSE) 文件。
## 项目统计
### Stars 趋势
<a href="https://star-history.com/#dptech-corp/Uni-Lab-OS&Date">
<img src="https://api.star-history.com/svg?repos=dptech-corp/Uni-Lab-OS&type=Date" alt="Star History Chart" width="600">
</a>
## 联系我们
- GitHub Issues: [https://github.com/dptech-corp/Uni-Lab-OS/issues](https://github.com/dptech-corp/Uni-Lab-OS/issues)

View File

@@ -1,6 +1,6 @@
package: package:
name: ros-humble-unilabos-msgs name: ros-humble-unilabos-msgs
version: 0.9.0 version: 0.9.1
source: source:
path: ../../unilabos_msgs path: ../../unilabos_msgs
folder: ros-humble-unilabos-msgs/src/work folder: ros-humble-unilabos-msgs/src/work

View File

@@ -1,6 +1,6 @@
package: package:
name: unilabos name: unilabos
version: "0.9.0" version: "0.9.1"
source: source:
path: ../.. path: ../..

View File

@@ -4,7 +4,7 @@ package_name = 'unilabos'
setup( setup(
name=package_name, name=package_name,
version='0.9.0', version='0.9.1',
packages=find_packages(), packages=find_packages(),
include_package_data=True, include_package_data=True,
install_requires=['setuptools'], install_requires=['setuptools'],

View File

@@ -1,5 +1,5 @@
使用plr_test.json启动将Well加入Plate中 使用plr_test.json启动将Well加入Plate中
```bash ```bash
ros2 action send_goal /devices/host_node/add_resource_from_outer unilabos_msgs/action/_resource_create_from_outer/ResourceCreateFromOuter "{ resources: [ { 'category': '', 'children': [], 'config': { 'type': 'Well', 'size_x': 6.86, 'size_y': 6.86, 'size_z': 10.67, 'rotation': { 'x': 0, 'y': 0, 'z': 0, 'type': 'Rotation' }, 'category': 'well', 'model': null, 'max_volume': 360, 'material_z_thickness': 0.5, 'compute_volume_from_height': null, 'compute_height_from_volume': null, 'bottom_type': 'flat', 'cross_section_type': 'circle' }, 'data': { 'liquids': [], 'pending_liquids': [], 'liquid_history': [] }, 'id': 'plate_well_11_7', 'name': 'plate_well_11_7', 'pose': { 'orientation': { 'w': 1.0, 'x': 0.0, 'y': 0.0, 'z': 0.0 }, 'position': { 'x': 0.0, 'y': 0.0, 'z': 0.0 } }, 'sample_id': '', 'parent': 'plate', 'type': 'device' } ], device_ids: [ 'PLR_STATION' ], bind_parent_ids: [ 'plate' ], bind_locations: [ { 'x': 0.0, 'y': 0.0, 'z': 0.0 } ], other_calling_params: [ '{}' ] }" ros2 action send_goal /devices/host_node/create_resource_detailed unilabos_msgs/action/_resource_create_from_outer/ResourceCreateFromOuter "{ resources: [ { 'category': '', 'children': [], 'config': { 'type': 'Well', 'size_x': 6.86, 'size_y': 6.86, 'size_z': 10.67, 'rotation': { 'x': 0, 'y': 0, 'z': 0, 'type': 'Rotation' }, 'category': 'well', 'model': null, 'max_volume': 360, 'material_z_thickness': 0.5, 'compute_volume_from_height': null, 'compute_height_from_volume': null, 'bottom_type': 'flat', 'cross_section_type': 'circle' }, 'data': { 'liquids': [], 'pending_liquids': [], 'liquid_history': [] }, 'id': 'plate_well_11_7', 'name': 'plate_well_11_7', 'pose': { 'orientation': { 'w': 1.0, 'x': 0.0, 'y': 0.0, 'z': 0.0 }, 'position': { 'x': 0.0, 'y': 0.0, 'z': 0.0 } }, 'sample_id': '', 'parent': 'plate', 'type': 'device' } ], device_ids: [ 'PLR_STATION' ], bind_parent_ids: [ 'plate' ], bind_locations: [ { 'x': 0.0, 'y': 0.0, 'z': 0.0 } ], other_calling_params: [ '{}' ] }"
``` ```

View File

@@ -56,6 +56,8 @@ dependencies:
- ros-humble-moveit-servo - ros-humble-moveit-servo
# simulation # simulation
- ros-humble-simulation - ros-humble-simulation
- ros-humble-tf-transformations
- transforms3d
# ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo # ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo
# ilab equipments # ilab equipments
# - ros-humble-unilabos-msgs # - ros-humble-unilabos-msgs

View File

@@ -56,6 +56,8 @@ dependencies:
# - ros-humble-moveit-servo # - ros-humble-moveit-servo
# simulation # simulation
- ros-humble-simulation - ros-humble-simulation
- ros-humble-tf-transformations
- transforms3d
# ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo # ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo
# ilab equipments # ilab equipments
# - ros-humble-unilabos-msgs # - ros-humble-unilabos-msgs

View File

@@ -58,6 +58,8 @@ dependencies:
- ros-humble-moveit-servo - ros-humble-moveit-servo
# simulation # simulation
- ros-humble-simulation - ros-humble-simulation
- ros-humble-tf-transformations
- transforms3d
# ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo # ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo
# ilab equipments # ilab equipments
# - ros-humble-unilabos-msgs # - ros-humble-unilabos-msgs

View File

@@ -56,6 +56,8 @@ dependencies:
- ros-humble-moveit-servo - ros-humble-moveit-servo
# simulation # simulation
- ros-humble-simulation # ignored because of NO python3.11 package in WIN64 - ros-humble-simulation # ignored because of NO python3.11 package in WIN64
- ros-humble-tf-transformations
- transforms3d
# ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo # ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo
# ilab equipments # ilab equipments
# ros-humble-unilabos-msgs # ros-humble-unilabos-msgs

View File

@@ -31,6 +31,6 @@ def job_add(req: JobAddReq) -> JobData:
action_kwargs = {"command": json.dumps(action_kwargs)} action_kwargs = {"command": json.dumps(action_kwargs)}
elif "command" in action_kwargs: elif "command" in action_kwargs:
action_kwargs = action_kwargs["command"] action_kwargs = action_kwargs["command"]
print(f"job_add:{req.device_id} {action_name} {action_kwargs}") # print(f"job_add:{req.device_id} {action_name} {action_kwargs}")
HostNode.get_instance().send_goal(req.device_id, action_name=action_name, action_kwargs=action_kwargs, goal_uuid=req.job_id) HostNode.get_instance().send_goal(req.device_id, action_name=action_name, action_kwargs=action_kwargs, goal_uuid=req.job_id, server_info=req.server_info)
return JobData(jobId=req.job_id) return JobData(jobId=req.job_id)

View File

@@ -18,7 +18,6 @@ if unilabos_dir not in sys.path:
from unilabos.config.config import load_config, BasicConfig, _update_config_from_env from unilabos.config.config import load_config, BasicConfig, _update_config_from_env
from unilabos.utils.banner_print import print_status, print_unilab_banner from unilabos.utils.banner_print import print_status, print_unilab_banner
from unilabos.device_mesh.resource_visalization import ResourceVisualization
def parse_args(): def parse_args():
@@ -188,11 +187,12 @@ def main():
if args_dict["visual"] != "disable": if args_dict["visual"] != "disable":
enable_rviz = args_dict["visual"] == "rviz" enable_rviz = args_dict["visual"] == "rviz"
if devices_and_resources is not None: if devices_and_resources is not None:
from unilabos.device_mesh.resource_visalization import ResourceVisualization # 此处开启后logger会变更为INFO有需要请调整
resource_visualization = ResourceVisualization(devices_and_resources, args_dict["resources_config"] ,enable_rviz=enable_rviz) resource_visualization = ResourceVisualization(devices_and_resources, args_dict["resources_config"] ,enable_rviz=enable_rviz)
args_dict["resources_mesh_config"] = resource_visualization.resource_model args_dict["resources_mesh_config"] = resource_visualization.resource_model
start_backend(**args_dict) start_backend(**args_dict)
server_thread = threading.Thread(target=start_server, kwargs=dict( server_thread = threading.Thread(target=start_server, kwargs=dict(
open_browser=not args_dict["disable_browser"] open_browser=not args_dict["disable_browser"], port=args_dict["port"],
)) ))
server_thread.start() server_thread.start()
asyncio.set_event_loop(asyncio.new_event_loop()) asyncio.set_event_loop(asyncio.new_event_loop())
@@ -201,10 +201,10 @@ def main():
time.sleep(1) time.sleep(1)
else: else:
start_backend(**args_dict) start_backend(**args_dict)
start_server(open_browser=not args_dict["disable_browser"]) start_server(open_browser=not args_dict["disable_browser"], port=args_dict["port"],)
else: else:
start_backend(**args_dict) start_backend(**args_dict)
start_server(open_browser=not args_dict["disable_browser"]) start_server(open_browser=not args_dict["disable_browser"], port=args_dict["port"],)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -51,8 +51,9 @@ class Resp(BaseModel):
class JobAddReq(BaseModel): class JobAddReq(BaseModel):
device_id: str = Field(examples=["Gripper"], description="device id") device_id: str = Field(examples=["Gripper"], description="device id")
data: dict = Field(examples=[{"position": 30, "torque": 5, "action": "push_to"}]) data: dict = Field(examples=[{"position": 30, "torque": 5, "action": "push_to"}])
job_id: str = Field(examples=["sfsfsfeq"], description="goal uuid") job_id: str = Field(examples=["job_id"], description="goal uuid")
node_id: str = Field(examples=["sfsfsfeq"], description="node uuid") node_id: str = Field(examples=["node_id"], description="node uuid")
server_info: dict = Field(examples=[{"send_timestamp": 1717000000.0}], description="server info")
class JobStepFinishReq(BaseModel): class JobStepFinishReq(BaseModel):

View File

@@ -12,7 +12,7 @@ import tempfile
import os import os
from unilabos.config.config import MQConfig from unilabos.config.config import MQConfig
from unilabos.app.controler import devices, job_add from unilabos.app.controler import job_add
from unilabos.app.model import JobAddReq from unilabos.app.model import JobAddReq
from unilabos.utils import logger from unilabos.utils import logger
from unilabos.utils.type_check import TypeEncoder from unilabos.utils.type_check import TypeEncoder
@@ -26,6 +26,7 @@ class MQTTClient:
def __init__(self): def __init__(self):
self.mqtt_disable = not MQConfig.lab_id self.mqtt_disable = not MQConfig.lab_id
self.client_id = f"{MQConfig.group_id}@@@{MQConfig.lab_id}{uuid.uuid4()}" self.client_id = f"{MQConfig.group_id}@@@{MQConfig.lab_id}{uuid.uuid4()}"
logger.info("[MQTT] Client_id: " + self.client_id)
self.client = mqtt.Client(CallbackAPIVersion.VERSION2, client_id=self.client_id, protocol=mqtt.MQTTv5) self.client = mqtt.Client(CallbackAPIVersion.VERSION2, client_id=self.client_id, protocol=mqtt.MQTTv5)
self._setup_callbacks() self._setup_callbacks()
@@ -42,20 +43,14 @@ class MQTTClient:
def _on_connect(self, client, userdata, flags, rc, properties=None): def _on_connect(self, client, userdata, flags, rc, properties=None):
logger.info("[MQTT] Connected with result code " + str(rc)) logger.info("[MQTT] Connected with result code " + str(rc))
client.subscribe(f"labs/{MQConfig.lab_id}/job/start/", 0) client.subscribe(f"labs/{MQConfig.lab_id}/job/start/", 0)
isok, data = devices() client.subscribe(f"labs/{MQConfig.lab_id}/pong/", 0)
if not isok:
logger.error("[MQTT] on_connect ErrorHostNotInit")
return
def _on_message(self, client, userdata, msg) -> None: def _on_message(self, client, userdata, msg) -> None:
logger.info("[MQTT] on_message<<<< " + msg.topic + " " + str(msg.payload)) # logger.info("[MQTT] on_message<<<< " + msg.topic + " " + str(msg.payload))
try: try:
payload_str = msg.payload.decode("utf-8") payload_str = msg.payload.decode("utf-8")
payload_json = json.loads(payload_str) payload_json = json.loads(payload_str)
logger.debug(f"Topic: {msg.topic}")
logger.debug("Payload:", json.dumps(payload_json, indent=2, ensure_ascii=False))
if msg.topic == f"labs/{MQConfig.lab_id}/job/start/": if msg.topic == f"labs/{MQConfig.lab_id}/job/start/":
logger.debug("job_add", type(payload_json), payload_json)
if "data" not in payload_json: if "data" not in payload_json:
payload_json["data"] = {} payload_json["data"] = {}
if "action" in payload_json: if "action" in payload_json:
@@ -65,6 +60,14 @@ class MQTTClient:
job_req = JobAddReq.model_validate(payload_json) job_req = JobAddReq.model_validate(payload_json)
data = job_add(job_req) data = job_add(job_req)
return return
elif msg.topic == f"labs/{MQConfig.lab_id}/pong/":
# 处理pong响应通知HostNode
from unilabos.ros.nodes.presets.host_node import HostNode
host_instance = HostNode.get_instance(0)
if host_instance:
host_instance.handle_pong_response(payload_json)
return
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
logger.error(f"[MQTT] JSON 解析错误: {e}") logger.error(f"[MQTT] JSON 解析错误: {e}")
@@ -181,6 +184,28 @@ class MQTTClient:
self.client.publish(address, json.dumps(action_info), qos=2) self.client.publish(address, json.dumps(action_info), qos=2)
logger.debug(f"Action data published: address: {address}, {action_id}, {action_info}") logger.debug(f"Action data published: address: {address}, {action_id}, {action_info}")
def send_ping(self, ping_id: str, timestamp: float):
"""发送ping消息到服务端"""
if self.mqtt_disable:
return
address = f"labs/{MQConfig.lab_id}/ping/"
ping_data = {"ping_id": ping_id, "client_timestamp": timestamp, "type": "ping"}
self.client.publish(address, json.dumps(ping_data), qos=2)
def setup_pong_subscription(self):
"""设置pong消息订阅"""
if self.mqtt_disable:
return
pong_topic = f"labs/{MQConfig.lab_id}/pong/"
self.client.subscribe(pong_topic, 0)
logger.debug(f"Subscribed to pong topic: {pong_topic}")
def handle_pong(self, pong_data: dict):
"""处理pong响应这个方法会在收到pong消息时被调用"""
logger.debug(f"Pong received: {pong_data}")
# 这里会被HostNode的ping-pong处理逻辑调用
pass
mqtt_client = MQTTClient() mqtt_client = MQTTClient()

View File

@@ -42,7 +42,7 @@ def get_host_node_info() -> Dict[str, Any]:
host_info["subscribed_topics"] = sorted(list(host_node._subscribed_topics)) host_info["subscribed_topics"] = sorted(list(host_node._subscribed_topics))
# 获取动作客户端信息 # 获取动作客户端信息
for action_id, client in host_node._action_clients.items(): for action_id, client in host_node._action_clients.items():
host_info["action_clients"] = {action_id: get_action_info(client, full_name=action_id)} host_info["action_clients"][action_id] = get_action_info(client, full_name=action_id)
# 获取设备状态 # 获取设备状态
host_info["device_status"] = host_node.device_status host_info["device_status"] = host_node.device_status

View File

@@ -0,0 +1,842 @@
import requests
from typing import List, Sequence, Optional, Union, Literal
from geometry_msgs.msg import Point
from pylabrobot.liquid_handling import LiquidHandler
from unilabos_msgs.msg import Resource
from pylabrobot.resources import (
TipRack,
Container,
Coordinate,
)
from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker # type: ignore
from .liquid_handler_abstract import LiquidHandlerAbstract
import json
from typing import Sequence, Optional, List, Union, Literal
class LiquidHandlerBiomek(LiquidHandlerAbstract):
"""
Biomek液体处理器的实现类继承自LiquidHandlerAbstract。
该类用于处理Biomek液体处理器的特定操作。
"""
def __init__(self, backend=None, deck=None, *args, **kwargs):
super().__init__(backend, deck, *args, **kwargs)
self._status = "Idle" # 初始状态为 Idle
self._success = False # 初始成功状态为 False
self._status_queue = kwargs.get("status_queue", None) # 状态队列
self.temp_protocol = {}
self.py32_path = "/opt/py32" # Biomek的Python 3.2路径
self.aspirate_techniques = {
'MC P300 high':{
"Solvent": "Water",
}
}
self.dispense_techniques = {
'MC P300 high':{
"Span8": False,
"Pod": "Pod1",
"Wash": False,
"Dynamic?": True,
"AutoSelectActiveWashTechnique": False,
"ActiveWashTechnique": "",
"ChangeTipsBetweenDests": True,
"ChangeTipsBetweenSources": False,
"DefaultCaption": "",
"UseExpression": False,
"LeaveTipsOn": False,
"MandrelExpression": "",
"Repeats": "1",
"RepeatsByVolume": False,
"Replicates": "1",
"ShowTipHandlingDetails": False,
"ShowTransferDetails": True,
"Span8Wash": False,
"Span8WashVolume": "2",
"Span8WasteVolume": "1",
"SplitVolume": False,
"SplitVolumeCleaning": False,
"Stop": "Destinations",
"UseCurrentTips": False,
"UseDisposableTips": False,
"UseFixedTips": False,
"UseJIT": True,
"UseMandrelSelection": True,
"UseProbes": [True, True, True, True, True, True, True, True],
"WashCycles": "3",
"WashVolume": "110%",
"Wizard": False
}
}
@classmethod
def deserialize(cls, data: dict, allow_marshal: bool = False) -> LiquidHandler:
return LiquidHandler.deserialize(data, allow_marshal)
def create_protocol(
self,
protocol_name: str,
protocol_description: str,
protocol_version: str,
protocol_author: str,
protocol_date: str,
protocol_type: str,
none_keys: List[str] = [],
):
"""
创建一个新的协议。
Args:
protocol_name (str): 协议名称
protocol_description (str): 协议描述
protocol_version (str): 协议版本
protocol_author (str): 协议作者
protocol_date (str): 协议日期
protocol_type (str): 协议类型
none_keys (List[str]): 需要设置为None的键列表
Returns:
dict: 创建的协议字典
"""
self.temp_protocol = {
"meta": {
"name": protocol_name,
"description": protocol_description,
"version": protocol_version,
"author": protocol_author,
"date": protocol_date,
"type": protocol_type,
},
"labwares": [],
"steps": [],
}
return self.temp_protocol
def run_protocol(self):
"""
执行创建的实验流程。
工作站的完整执行流程是,
从 create_protocol 开始,创建新的 method
随后执行 transfer_liquid 等操作向实验流程添加步骤,
最后 run_protocol 执行整个方法。
Returns:
dict: 执行结果
"""
#use popen or subprocess to create py32 process and communicate send the temp protocol to it
if not self.temp_protocol:
raise ValueError("No protocol created. Please create a protocol first.")
# 模拟执行协议
self._status = "Running"
self._success = True
# 在这里可以添加实际执行协议的逻辑
response = requests.post("localhost:5000/api/protocols", json=self.temp_protocol)
def create_resource(
self,
resource_tracker: DeviceNodeResourceTracker,
resources: list[Resource],
bind_parent_id: str,
bind_location: dict[str, float],
liquid_input_slot: list[int],
liquid_type: list[str],
liquid_volume: list[int],
slot_on_deck: int,
):
"""
创建一个新的资源。
Args:
device_id (str): 设备ID
res_id (str): 资源ID
class_name (str): 资源类名
parent (str): 父级ID
bind_locations (Point): 绑定位置
liquid_input_slot (list[int]): 液体输入槽列表
liquid_type (list[str]): 液体类型列表
liquid_volume (list[int]): 液体体积列表
slot_on_deck (int): 甲板上的槽位
Returns:
dict: 创建的资源字典
"""
# TODO需要对好接口下面这个是临时的
for resource in resources:
res_id = resource.id
class_name = resource.class_name
parent = bind_parent_id
bind_locations = Coordinate.from_point(resource.bind_location)
liquid_input_slot = liquid_input_slot
liquid_type = liquid_type
liquid_volume = liquid_volume
slot_on_deck = slot_on_deck
resource = {
"id": res_id,
"class": class_name,
"parent": parent,
"bind_locations": bind_locations.to_dict(),
"liquid_input_slot": liquid_input_slot,
"liquid_type": liquid_type,
"liquid_volume": liquid_volume,
"slot_on_deck": slot_on_deck,
}
self.temp_protocol["labwares"].append(resource)
return resource
def transfer_liquid(
self,
sources: Sequence[Container],
targets: Sequence[Container],
tip_racks: Sequence[TipRack],
*,
use_channels: Optional[List[int]] = None,
asp_vols: Union[List[float], float],
dis_vols: Union[List[float], float],
asp_flow_rates: Optional[List[Optional[float]]] = None,
dis_flow_rates: Optional[List[Optional[float]]] = None,
offsets: Optional[List[Coordinate]] = None,
touch_tip: bool = False,
liquid_height: Optional[List[Optional[float]]] = None,
blow_out_air_volume: Optional[List[Optional[float]]] = None,
spread: Literal["wide", "tight", "custom"] = "wide",
is_96_well: bool = False,
mix_stage: Optional[Literal["none", "before", "after", "both"]] = "none",
mix_times: Optional[int] = None,
mix_vol: Optional[int] = None,
mix_rate: Optional[int] = None,
mix_liquid_height: Optional[float] = None,
delays: Optional[List[int]] = None,
none_keys: List[str] = []
):
transfer_params = {
"Span8": False,
"Pod": "Pod1",
"items": {},
"Wash": False,
"Dynamic?": True,
"AutoSelectActiveWashTechnique": False,
"ActiveWashTechnique": "",
"ChangeTipsBetweenDests": False,
"ChangeTipsBetweenSources": False,
"DefaultCaption": "",
"UseExpression": False,
"LeaveTipsOn": False,
"MandrelExpression": "",
"Repeats": "1",
"RepeatsByVolume": False,
"Replicates": "1",
"ShowTipHandlingDetails": False,
"ShowTransferDetails": True,
"Solvent": "Well Content",
"Span8Wash": False,
"Span8WashVolume": "2",
"Span8WasteVolume": "1",
"SplitVolume": False,
"SplitVolumeCleaning": False,
"Stop": "Destinations",
"TipLocation": "BC1025F",
"UseCurrentTips": False,
"UseDisposableTips": True,
"UseFixedTips": False,
"UseJIT": True,
"UseMandrelSelection": True,
"UseProbes": [True, True, True, True, True, True, True, True],
"WashCycles": "1",
"WashVolume": "110%",
"Wizard": False
}
items: dict = {}
for idx, (src, dst) in enumerate(zip(sources, targets)):
items[str(idx)] = {
"Source": str(src),
"Destination": str(dst),
"Volume": dis_vols[idx]
}
transfer_params["items"] = items
transfer_params["Solvent"] = "Water"
TipLocation = tip_racks[0].name
transfer_params["TipLocation"] = TipLocation
if len(tip_racks) == 1:
transfer_params['UseCurrentTips'] = True
elif len(tip_racks) > 1:
transfer_params["ChangeTipsBetweenDests"] = True
self.temp_protocol["steps"].append(transfer_params)
return
def transfer_biomek(
self,
source: str,
target: str,
tip_rack: str,
volume: float,
aspirate_techniques: str,
dispense_techniques: str,
):
"""
处理Biomek的液体转移操作。
"""
asp_params = self.aspirate_techniques.get(aspirate_techniques, {})
dis_params = self.dispense_techniques.get(dispense_techniques, {})
transfer_params = {
"Span8": False,
"Pod": "Pod1",
"items": {},
"Wash": False,
"Dynamic?": True,
"AutoSelectActiveWashTechnique": False,
"ActiveWashTechnique": "",
"ChangeTipsBetweenDests": False,
"ChangeTipsBetweenSources": True,
"DefaultCaption": "",
"UseExpression": False,
"LeaveTipsOn": False,
"MandrelExpression": "",
"Repeats": "1",
"RepeatsByVolume": False,
"Replicates": "1",
"ShowTipHandlingDetails": False,
"ShowTransferDetails": True,
"Solvent": "Water",
"Span8Wash": False,
"Span8WashVolume": "2",
"Span8WasteVolume": "1",
"SplitVolume": False,
"SplitVolumeCleaning": False,
"Stop": "Destinations",
"TipLocation": "BC1025F",
"UseCurrentTips": False,
"UseDisposableTips": True,
"UseFixedTips": False,
"UseJIT": True,
"UseMandrelSelection": True,
"UseProbes": [True, True, True, True, True, True, True, True],
"WashCycles": "1",
"WashVolume": "110%",
"Wizard": False
}
items: dict = {}
items["Source"] = source
items["Destination"] = target
items["Volume"] = volume
transfer_params["items"] = items
transfer_params["Solvent"] = asp_params['Solvent']
transfer_params["TipLocation"] = tip_rack
transfer_params.update(asp_params)
transfer_params.update(dis_params)
self.temp_protocol["steps"].append(transfer_params)
return
if __name__ == "__main__":
steps_info = '''
{
"steps": [
{
"step_number": 1,
"operation": "transfer",
"description": "转移PCR产物或酶促反应液至0.05ml 96孔板中",
"parameters": {
"source": "P1",
"target": "P11",
"tip_rack": "BC230",
"volume": 50
}
},
{
"step_number": 2,
"operation": "transfer",
"description": "加入2倍体积Bind Beads BC至产物中",
"parameters": {
"source": "P2",
"target": "P11",
"tip_rack": "BC230",
"volume": 100
}
},
{
"step_number": 3,
"operation": "move_labware",
"description": "移动P11至Orbital1用于振荡混匀",
"parameters": {
"source": "P11",
"target": "Orbital1"
}
},
{
"step_number": 4,
"operation": "oscillation",
"description": "在Orbital1上振荡混匀Bind Beads BC与PCR产物700-900rpm300秒",
"parameters": {
"rpm": 800,
"time": 300
}
},
{
"step_number": 5,
"operation": "move_labware",
"description": "移动混匀后的板回P11",
"parameters": {
"source": "Orbital1",
"target": "P11"
}
},
{
"step_number": 6,
"operation": "move_labware",
"description": "将P11移动到磁力架P12吸附3分钟",
"parameters": {
"source": "P11",
"target": "P12"
}
},
{
"step_number": 7,
"operation": "incubation",
"description": "磁力架上室温静置3分钟完成吸附",
"parameters": {
"time": 180
}
},
{
"step_number": 8,
"operation": "transfer",
"description": "去除上清液至废液槽",
"parameters": {
"source": "P12",
"target": "P22",
"tip_rack": "BC230",
"volume": 150
}
},
{
"step_number": 9,
"operation": "transfer",
"description": "加入300-500μl 75%乙醇清洗",
"parameters": {
"source": "P3",
"target": "P12",
"tip_rack": "BC230",
"volume": 400
}
},
{
"step_number": 10,
"operation": "move_labware",
"description": "移动清洗板到Orbital1进行振荡",
"parameters": {
"source": "P12",
"target": "Orbital1"
}
},
{
"step_number": 11,
"operation": "oscillation",
"description": "乙醇清洗液振荡混匀700-900rpm, 45秒",
"parameters": {
"rpm": 800,
"time": 45
}
},
{
"step_number": 12,
"operation": "move_labware",
"description": "振荡后将板移回磁力架P12吸附",
"parameters": {
"source": "Orbital1",
"target": "P12"
}
},
{
"step_number": 13,
"operation": "incubation",
"description": "吸附3分钟",
"parameters": {
"time": 180
}
},
{
"step_number": 14,
"operation": "transfer",
"description": "去除乙醇上清液至废液槽",
"parameters": {
"source": "P12",
"target": "P22",
"tip_rack": "BC230",
"volume": 400
}
},
{
"step_number": 15,
"operation": "transfer",
"description": "第二次加入300-500μl 75%乙醇清洗",
"parameters": {
"source": "P3",
"target": "P12",
"tip_rack": "BC230",
"volume": 400
}
},
{
"step_number": 16,
"operation": "move_labware",
"description": "再次移动清洗板到Orbital1振荡",
"parameters": {
"source": "P12",
"target": "Orbital1"
}
},
{
"step_number": 17,
"operation": "oscillation",
"description": "再次乙醇清洗液振荡混匀700-900rpm, 45秒",
"parameters": {
"rpm": 800,
"time": 45
}
},
{
"step_number": 18,
"operation": "move_labware",
"description": "振荡后板送回磁力架P12吸附",
"parameters": {
"source": "Orbital1",
"target": "P12"
}
},
{
"step_number": 19,
"operation": "incubation",
"description": "再次吸附3分钟",
"parameters": {
"time": 180
}
},
{
"step_number": 20,
"operation": "transfer",
"description": "去除乙醇上清液至废液槽",
"parameters": {
"source": "P12",
"target": "P22",
"tip_rack": "BC230",
"volume": 400
}
},
{
"step_number": 21,
"operation": "incubation",
"description": "空气干燥15分钟",
"parameters": {
"time": 900
}
},
{
"step_number": 22,
"operation": "transfer",
"description": "加30-50μl Elution Buffer洗脱",
"parameters": {
"source": "P4",
"target": "P12",
"tip_rack": "BC230",
"volume": 40
}
},
{
"step_number": 23,
"operation": "move_labware",
"description": "移动到Orbital1振荡混匀60秒",
"parameters": {
"source": "P12",
"target": "Orbital1"
}
},
{
"step_number": 24,
"operation": "oscillation",
"description": "Elution Buffer振荡混匀700-900rpm, 60秒",
"parameters": {
"rpm": 800,
"time": 60
}
},
{
"step_number": 25,
"operation": "move_labware",
"description": "振荡后送回磁力架P12",
"parameters": {
"source": "Orbital1",
"target": "P12"
}
},
{
"step_number": 26,
"operation": "incubation",
"description": "室温静置3分钟洗脱反应",
"parameters": {
"time": 180
}
},
{
"step_number": 27,
"operation": "transfer",
"description": "将上清液DNA转移到新板P13",
"parameters": {
"source": "P12",
"target": "P13",
"tip_rack": "BC230",
"volume": 40
}
}
]
}
'''
labware_with_liquid = '''
[ {
"id": "stock plate on P1",
"parent": "deck",
"slot_on_deck": "P1",
"class_name": "nest_12_reservoir_15ml",
"liquid_type": [
"master_mix"
],
"liquid_volume": [10000],
"liquid_input_wells": [
"A1"
]
},
{
"id": "Tip Rack BC230 TL2",
"parent": "deck",
"slot_on_deck": "TL2",
"class_name": "BC230",
"liquid_type": [],
"liquid_volume": [],
"liquid_input_wells": [
]
},
{
"id": "Tip Rack BC230 on TL3",
"parent": "deck",
"slot_on_deck": "TL3",
"class_name": "BC230",
"liquid_type": [],
"liquid_volume": [],
"liquid_input_wells": [
]
},
{
"id": "Tip Rack BC230 on TL4",
"parent": "deck",
"slot_on_deck": "TL4",
"class_name": "BC230",
"liquid_type": [],
"liquid_volume": [],
"liquid_input_wells": [
]
},
{
"id": "Tip Rack BC230 on TL5",
"parent": "deck",
"slot_on_deck": "TL5",
"class_name": "BC230",
"liquid_type": [],
"liquid_volume": [],
"liquid_input_wells": [
]
},
{
"id": "Tip Rack BC230 on P5",
"parent": "deck",
"slot_on_deck": "P5",
"class_name": "BC230",
"liquid_type": [],
"liquid_volume": [],
"liquid_input_wells": [
]
},
{
"id": "Tip Rack BC230 on P6",
"parent": "deck",
"slot_on_deck": "P6",
"class_name": "BC230",
"liquid_type": [],
"liquid_volume": [],
"liquid_input_wells": [
]
},
{
"id": "Tip Rack BC230 on P7",
"parent": "deck",
"slot_on_deck": "P7",
"class_name": "BC230",
"liquid_type": [],
"liquid_volume": [],
"liquid_input_wells": [
]
},
{
"id": "Tip Rack BC230 on P8",
"parent": "deck",
"slot_on_deck": "P8",
"class_name": "BC230",
"liquid_type": [],
"liquid_volume": [],
"liquid_input_wells": [
]
},
{
"id": "Tip Rack BC230 P16",
"parent": "deck",
"slot_on_deck": "P16",
"class_name": "BC230",
"liquid_type": [],
"liquid_volume": [],
"liquid_input_wells": [
]
},
{
"id": "stock plate on 4",
"parent": "deck",
"slot_on_deck": "P2",
"class_name": "nest_12_reservoir_15ml",
"liquid_type": [
"bind beads"
],
"liquid_volume": [10000],
"liquid_input_wells": [
"A1"
]
},
{
"id": "stock plate on P2",
"parent": "deck",
"slot_on_deck": "P2",
"class_name": "nest_12_reservoir_15ml",
"liquid_type": [
"bind beads"
],
"liquid_volume": [10000],
"liquid_input_wells": [
"A1"
]
},
{
"id": "stock plate on P3",
"parent": "deck",
"slot_on_deck": "P3",
"class_name": "nest_12_reservoir_15ml",
"liquid_type": [
"ethyl alcohol"
],
"liquid_volume": [10000],
"liquid_input_wells": [
"A1"
]
},
{
"id": "oscillation",
"parent": "deck",
"slot_on_deck": "Orbital1",
"class_name": "Orbital",
"liquid_type": [],
"liquid_volume": [],
"liquid_input_wells": [
]
},
{
"id": "working plate on P11",
"parent": "deck",
"slot_on_deck": "P11",
"class_name": "NEST 2ml Deep Well Plate",
"liquid_type": [
],
"liquid_volume": [],
"liquid_input_wells": [
]
},
{
"id": "magnetics module on P12",
"parent": "deck",
"slot_on_deck": "P12",
"class_name": "magnetics module",
"liquid_type": [],
"liquid_volume": [],
"liquid_input_wells": [
]
},
{
"id": "working plate on P13",
"parent": "deck",
"slot_on_deck": "P13",
"class_name": "NEST 2ml Deep Well Plate",
"liquid_type": [
],
"liquid_volume": [],
"liquid_input_wells": [
]
},
{
"id": "waste on P22",
"parent": "deck",
"slot_on_deck": "P22",
"class_name": "nest_1_reservoir_195ml",
"liquid_type": [
],
"liquid_volume": [],
"liquid_input_wells": [
]
}
]
'''
handler = LiquidHandlerBiomek()
handler.temp_protocol = {
"meta": {},
"labwares": [],
"steps": []
}
input_steps = json.loads(steps_info)
labwares = json.loads(labware_with_liquid)
for step in input_steps['steps']:
if step['operation'] != 'transfer':
continue
parameters = step['parameters']
tip_rack=parameters['tip_rack']
# 找到labwares中与tip_rack匹配的项的id
tip_rack_id = [lw['id'] for lw in labwares if lw['class_name'] == tip_rack][0]
handler.transfer_biomek(source=parameters['source'],
target=parameters['target'],
volume=parameters['volume'],
tip_rack=tip_rack_id,
aspirate_techniques='MC P300 high',
dispense_techniques='MC P300 high'
)
print(json.dumps(handler.temp_protocol['steps'],indent=4, ensure_ascii=False))

View File

@@ -0,0 +1,747 @@
import requests
from typing import List, Sequence, Optional, Union, Literal
# from geometry_msgs.msg import Point
# from unilabos_msgs.msg import Resource
from pylabrobot.resources import (
Resource,
TipRack,
Container,
Coordinate,
Well
)
# from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker # type: ignore
# from .liquid_handler_abstract import LiquidHandlerAbstract
import json
from typing import Sequence, Optional, List, Union, Literal
steps_info = '''
{
"steps": [
{
"step_number": 1,
"operation": "transfer",
"description": "转移PCR产物或酶促反应液至0.05ml 96孔板中",
"parameters": {
"source": "P1",
"target": "P11",
"tip_rack": "BC230",
"volume": 50
}
},
{
"step_number": 2,
"operation": "transfer",
"description": "加入2倍体积Bind Beads BC至产物中",
"parameters": {
"source": "P2",
"target": "P11",
"tip_rack": "BC230",
"volume": 100
}
},
{
"step_number": 3,
"operation": "move_labware",
"description": "移动P11至Orbital1用于振荡混匀",
"parameters": {
"source": "P11",
"target": "Orbital1"
}
},
{
"step_number": 4,
"operation": "oscillation",
"description": "在Orbital1上振荡混匀Bind Beads BC与PCR产物700-900rpm300秒",
"parameters": {
"rpm": 800,
"time": 300
}
},
{
"step_number": 5,
"operation": "move_labware",
"description": "移动混匀后的板回P11",
"parameters": {
"source": "Orbital1",
"target": "P11"
}
},
{
"step_number": 6,
"operation": "move_labware",
"description": "将P11移动到磁力架P12吸附3分钟",
"parameters": {
"source": "P11",
"target": "P12"
}
},
{
"step_number": 7,
"operation": "incubation",
"description": "磁力架上室温静置3分钟完成吸附",
"parameters": {
"time": 180
}
},
{
"step_number": 8,
"operation": "transfer",
"description": "去除上清液至废液槽",
"parameters": {
"source": "P12",
"target": "P22",
"tip_rack": "BC230",
"volume": 150
}
},
{
"step_number": 9,
"operation": "transfer",
"description": "加入300-500μl 75%乙醇清洗",
"parameters": {
"source": "P3",
"target": "P12",
"tip_rack": "BC230",
"volume": 400
}
},
{
"step_number": 10,
"operation": "move_labware",
"description": "移动清洗板到Orbital1进行振荡",
"parameters": {
"source": "P12",
"target": "Orbital1"
}
},
{
"step_number": 11,
"operation": "oscillation",
"description": "乙醇清洗液振荡混匀700-900rpm, 45秒",
"parameters": {
"rpm": 800,
"time": 45
}
},
{
"step_number": 12,
"operation": "move_labware",
"description": "振荡后将板移回磁力架P12吸附",
"parameters": {
"source": "Orbital1",
"target": "P12"
}
},
{
"step_number": 13,
"operation": "incubation",
"description": "吸附3分钟",
"parameters": {
"time": 180
}
},
{
"step_number": 14,
"operation": "transfer",
"description": "去除乙醇上清液至废液槽",
"parameters": {
"source": "P12",
"target": "P22",
"tip_rack": "BC230",
"volume": 400
}
},
{
"step_number": 15,
"operation": "transfer",
"description": "第二次加入300-500μl 75%乙醇清洗",
"parameters": {
"source": "P3",
"target": "P12",
"tip_rack": "BC230",
"volume": 400
}
},
{
"step_number": 16,
"operation": "move_labware",
"description": "再次移动清洗板到Orbital1振荡",
"parameters": {
"source": "P12",
"target": "Orbital1"
}
},
{
"step_number": 17,
"operation": "oscillation",
"description": "再次乙醇清洗液振荡混匀700-900rpm, 45秒",
"parameters": {
"rpm": 800,
"time": 45
}
},
{
"step_number": 18,
"operation": "move_labware",
"description": "振荡后板送回磁力架P12吸附",
"parameters": {
"source": "Orbital1",
"target": "P12"
}
},
{
"step_number": 19,
"operation": "incubation",
"description": "再次吸附3分钟",
"parameters": {
"time": 180
}
},
{
"step_number": 20,
"operation": "transfer",
"description": "去除乙醇上清液至废液槽",
"parameters": {
"source": "P12",
"target": "P22",
"tip_rack": "BC230",
"volume": 400
}
},
{
"step_number": 21,
"operation": "incubation",
"description": "空气干燥15分钟",
"parameters": {
"time": 900
}
},
{
"step_number": 22,
"operation": "transfer",
"description": "加30-50μl Elution Buffer洗脱",
"parameters": {
"source": "P4",
"target": "P12",
"tip_rack": "BC230",
"volume": 40
}
},
{
"step_number": 23,
"operation": "move_labware",
"description": "移动到Orbital1振荡混匀60秒",
"parameters": {
"source": "P12",
"target": "Orbital1"
}
},
{
"step_number": 24,
"operation": "oscillation",
"description": "Elution Buffer振荡混匀700-900rpm, 60秒",
"parameters": {
"rpm": 800,
"time": 60
}
},
{
"step_number": 25,
"operation": "move_labware",
"description": "振荡后送回磁力架P12",
"parameters": {
"source": "Orbital1",
"target": "P12"
}
},
{
"step_number": 26,
"operation": "incubation",
"description": "室温静置3分钟洗脱反应",
"parameters": {
"time": 180
}
},
{
"step_number": 27,
"operation": "transfer",
"description": "将上清液DNA转移到新板P13",
"parameters": {
"source": "P12",
"target": "P13",
"tip_rack": "BC230",
"volume": 40
}
}
]
}
'''
#class LiquidHandlerBiomek(LiquidHandlerAbstract):
class LiquidHandlerBiomek:
"""
Biomek液体处理器的实现类继承自LiquidHandlerAbstract。
该类用于处理Biomek液体处理器的特定操作。
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._status = "Idle" # 初始状态为 Idle
self._success = False # 初始成功状态为 False
self._status_queue = kwargs.get("status_queue", None) # 状态队列
self.temp_protocol = {}
self.py32_path = "/opt/py32" # Biomek的Python 3.2路径
self.aspirate_techniques = {
'MC P300 high':{
"Solvent": "Water",
}
}
self.dispense_techniques = {
'MC P300 high':{
"Span8": False,
"Pod": "Pod1",
"Wash": False,
"Dynamic?": True,
"AutoSelectActiveWashTechnique": False,
"ActiveWashTechnique": "",
"ChangeTipsBetweenDests": True,
"ChangeTipsBetweenSources": False,
"DefaultCaption": "",
"UseExpression": False,
"LeaveTipsOn": False,
"MandrelExpression": "",
"Repeats": "1",
"RepeatsByVolume": False,
"Replicates": "1",
"ShowTipHandlingDetails": False,
"ShowTransferDetails": True,
"Span8Wash": False,
"Span8WashVolume": "2",
"Span8WasteVolume": "1",
"SplitVolume": False,
"SplitVolumeCleaning": False,
"Stop": "Destinations",
"UseCurrentTips": False,
"UseDisposableTips": False,
"UseFixedTips": False,
"UseJIT": True,
"UseMandrelSelection": True,
"UseProbes": [True, True, True, True, True, True, True, True],
"WashCycles": "3",
"WashVolume": "110%",
"Wizard": False
}
}
def create_protocol(
self,
protocol_name: str,
protocol_description: str,
protocol_version: str,
protocol_author: str,
protocol_date: str,
protocol_type: str,
none_keys: List[str] = [],
):
"""
创建一个新的协议。
Args:
protocol_name (str): 协议名称
protocol_description (str): 协议描述
protocol_version (str): 协议版本
protocol_author (str): 协议作者
protocol_date (str): 协议日期
protocol_type (str): 协议类型
none_keys (List[str]): 需要设置为None的键列表
Returns:
dict: 创建的协议字典
"""
self.temp_protocol = {
"meta": {
"name": protocol_name,
"description": protocol_description,
"version": protocol_version,
"author": protocol_author,
"date": protocol_date,
"type": protocol_type,
},
"labwares": [],
"steps": [],
}
return self.temp_protocol
# def run_protocol(self):
# """
# 执行创建的实验流程。
# 工作站的完整执行流程是,
# 从 create_protocol 开始,创建新的 method
# 随后执行 transfer_liquid 等操作向实验流程添加步骤,
# 最后 run_protocol 执行整个方法。
# Returns:
# dict: 执行结果
# """
# #use popen or subprocess to create py32 process and communicate send the temp protocol to it
# if not self.temp_protocol:
# raise ValueError("No protocol created. Please create a protocol first.")
# # 模拟执行协议
# self._status = "Running"
# self._success = True
# # 在这里可以添加实际执行协议的逻辑
# response = requests.post("localhost:5000/api/protocols", json=self.temp_protocol)
# def create_resource(
# self,
# resource_tracker: DeviceNodeResourceTracker,
# resources: list[Resource],
# bind_parent_id: str,
# bind_location: dict[str, float],
# liquid_input_slot: list[int],
# liquid_type: list[str],
# liquid_volume: list[int],
# slot_on_deck: int,
# res_id,
# class_name,
# bind_locations,
# parent
# ):
# """
# 创建一个新的资源。
# Args:
# device_id (str): 设备ID
# res_id (str): 资源ID
# class_name (str): 资源类名
# parent (str): 父级ID
# bind_locations (Point): 绑定位置
# liquid_input_slot (list[int]): 液体输入槽列表
# liquid_type (list[str]): 液体类型列表
# liquid_volume (list[int]): 液体体积列表
# slot_on_deck (int): 甲板上的槽位
# Returns:
# dict: 创建的资源字典
# """
# # TODO需要对好接口下面这个是临时的
# resource = {
# "id": res_id,
# "class": class_name,
# "parent": parent,
# "bind_locations": bind_locations.to_dict(),
# "liquid_input_slot": liquid_input_slot,
# "liquid_type": liquid_type,
# "liquid_volume": liquid_volume,
# "slot_on_deck": slot_on_deck,
# }
# self.temp_protocol["labwares"].append(resource)
# return resource
def transfer_biomek(
self,
source: str,
target: str,
tip_rack: str,
volume: float,
aspirate_techniques: str,
dispense_techniques: str,
):
"""
处理Biomek的液体转移操作。
"""
asp_params = self.aspirate_techniques.get(aspirate_techniques, {})
dis_params = self.dispense_techniques.get(dispense_techniques, {})
transfer_params = {
"Span8": False,
"Pod": "Pod1",
"items": {},
"Wash": False,
"Dynamic?": True,
"AutoSelectActiveWashTechnique": False,
"ActiveWashTechnique": "",
"ChangeTipsBetweenDests": False,
"ChangeTipsBetweenSources": True,
"DefaultCaption": "",
"UseExpression": False,
"LeaveTipsOn": False,
"MandrelExpression": "",
"Repeats": "1",
"RepeatsByVolume": False,
"Replicates": "1",
"ShowTipHandlingDetails": False,
"ShowTransferDetails": True,
"Solvent": "Water",
"Span8Wash": False,
"Span8WashVolume": "2",
"Span8WasteVolume": "1",
"SplitVolume": False,
"SplitVolumeCleaning": False,
"Stop": "Destinations",
"TipLocation": "BC1025F",
"UseCurrentTips": False,
"UseDisposableTips": True,
"UseFixedTips": False,
"UseJIT": True,
"UseMandrelSelection": True,
"UseProbes": [True, True, True, True, True, True, True, True],
"WashCycles": "1",
"WashVolume": "110%",
"Wizard": False
}
items: dict = {}
items["Source"] = source
items["Destination"] = target
items["Volume"] = volume
transfer_params["items"] = items
transfer_params["Solvent"] = asp_params['Solvent']
transfer_params["TipLocation"] = tip_rack
transfer_params.update(asp_params)
transfer_params.update(dis_params)
self.temp_protocol["steps"].append(transfer_params)
return
labware_with_liquid = '''
[ {
"id": "stock plate on P1",
"parent": "deck",
"slot_on_deck": "P1",
"class_name": "nest_12_reservoir_15ml",
"liquid_type": [
"master_mix"
],
"liquid_volume": [10000],
"liquid_input_wells": [
"A1"
]
},
{
"id": "Tip Rack BC230 TL2",
"parent": "deck",
"slot_on_deck": "TL2",
"class_name": "BC230",
"liquid_type": [],
"liquid_volume": [],
"liquid_input_wells": [
]
},
{
"id": "Tip Rack BC230 on TL3",
"parent": "deck",
"slot_on_deck": "TL3",
"class_name": "BC230",
"liquid_type": [],
"liquid_volume": [],
"liquid_input_wells": [
]
},
{
"id": "Tip Rack BC230 on TL4",
"parent": "deck",
"slot_on_deck": "TL4",
"class_name": "BC230",
"liquid_type": [],
"liquid_volume": [],
"liquid_input_wells": [
]
},
{
"id": "Tip Rack BC230 on TL5",
"parent": "deck",
"slot_on_deck": "TL5",
"class_name": "BC230",
"liquid_type": [],
"liquid_volume": [],
"liquid_input_wells": [
]
},
{
"id": "Tip Rack BC230 on P5",
"parent": "deck",
"slot_on_deck": "P5",
"class_name": "BC230",
"liquid_type": [],
"liquid_volume": [],
"liquid_input_wells": [
]
},
{
"id": "Tip Rack BC230 on P6",
"parent": "deck",
"slot_on_deck": "P6",
"class_name": "BC230",
"liquid_type": [],
"liquid_volume": [],
"liquid_input_wells": [
]
},
{
"id": "Tip Rack BC230 on P7",
"parent": "deck",
"slot_on_deck": "P7",
"class_name": "BC230",
"liquid_type": [],
"liquid_volume": [],
"liquid_input_wells": [
]
},
{
"id": "Tip Rack BC230 on P8",
"parent": "deck",
"slot_on_deck": "P8",
"class_name": "BC230",
"liquid_type": [],
"liquid_volume": [],
"liquid_input_wells": [
]
},
{
"id": "Tip Rack BC230 P16",
"parent": "deck",
"slot_on_deck": "P16",
"class_name": "BC230",
"liquid_type": [],
"liquid_volume": [],
"liquid_input_wells": [
]
},
{
"id": "stock plate on 4",
"parent": "deck",
"slot_on_deck": "P2",
"class_name": "nest_12_reservoir_15ml",
"liquid_type": [
"bind beads"
],
"liquid_volume": [10000],
"liquid_input_wells": [
"A1"
]
},
{
"id": "stock plate on P2",
"parent": "deck",
"slot_on_deck": "P2",
"class_name": "nest_12_reservoir_15ml",
"liquid_type": [
"bind beads"
],
"liquid_volume": [10000],
"liquid_input_wells": [
"A1"
]
},
{
"id": "stock plate on P3",
"parent": "deck",
"slot_on_deck": "P3",
"class_name": "nest_12_reservoir_15ml",
"liquid_type": [
"ethyl alcohol"
],
"liquid_volume": [10000],
"liquid_input_wells": [
"A1"
]
},
{
"id": "oscillation",
"parent": "deck",
"slot_on_deck": "Orbital1",
"class_name": "Orbital",
"liquid_type": [],
"liquid_volume": [],
"liquid_input_wells": [
]
},
{
"id": "working plate on P11",
"parent": "deck",
"slot_on_deck": "P11",
"class_name": "NEST 2ml Deep Well Plate",
"liquid_type": [
],
"liquid_volume": [],
"liquid_input_wells": [
]
},
{
"id": "magnetics module on P12",
"parent": "deck",
"slot_on_deck": "P12",
"class_name": "magnetics module",
"liquid_type": [],
"liquid_volume": [],
"liquid_input_wells": [
]
},
{
"id": "working plate on P13",
"parent": "deck",
"slot_on_deck": "P13",
"class_name": "NEST 2ml Deep Well Plate",
"liquid_type": [
],
"liquid_volume": [],
"liquid_input_wells": [
]
},
{
"id": "waste on P22",
"parent": "deck",
"slot_on_deck": "P22",
"class_name": "nest_1_reservoir_195ml",
"liquid_type": [
],
"liquid_volume": [],
"liquid_input_wells": [
]
}
]
'''
handler = LiquidHandlerBiomek()
handler.temp_protocol = {
"meta": {},
"labwares": [],
"steps": []
}
input_steps = json.loads(steps_info)
labwares = json.loads(labware_with_liquid)
for step in input_steps['steps']:
if step['operation'] != 'transfer':
continue
parameters = step['parameters']
handler.transfer_biomek(source=parameters['source'],
target=parameters['target'],
volume=parameters['volume'],
tip_rack=parameters['tip_rack'],
aspirate_techniques='MC P300 high',
dispense_techniques='MC P300 high'
)
print(json.dumps(handler.temp_protocol['steps'],indent=4, ensure_ascii=False))

View File

@@ -6,21 +6,29 @@ import asyncio
import time import time
from pylabrobot.liquid_handling import LiquidHandler from pylabrobot.liquid_handling import LiquidHandler
from pylabrobot.resources import ( from pylabrobot.resources import Resource, TipRack, Container, Coordinate, Well
Resource,
TipRack,
Container,
Coordinate,
Well
)
class DPLiquidHandler(LiquidHandler):
class LiquidHandlerAbstract(LiquidHandler):
"""Extended LiquidHandler with additional operations.""" """Extended LiquidHandler with additional operations."""
# --------------------------------------------------------------- # ---------------------------------------------------------------
# REMOVE LIQUID -------------------------------------------------- # REMOVE LIQUID --------------------------------------------------
# --------------------------------------------------------------- # ---------------------------------------------------------------
async def create_protocol(
self,
protocol_name: str,
protocol_description: str,
protocol_version: str,
protocol_author: str,
protocol_date: str,
protocol_type: str,
none_keys: List[str] = [],
):
"""Create a new protocol with the given metadata."""
pass
async def remove_liquid( async def remove_liquid(
self, self,
vols: List[float], vols: List[float],
@@ -35,26 +43,26 @@ class DPLiquidHandler(LiquidHandler):
spread: Optional[Literal["wide", "tight", "custom"]] = "wide", spread: Optional[Literal["wide", "tight", "custom"]] = "wide",
delays: Optional[List[int]] = None, delays: Optional[List[int]] = None,
is_96_well: Optional[bool] = False, is_96_well: Optional[bool] = False,
top: Optional[List(float)] = None, top: Optional[List[float]] = None,
none_keys: List[str] = [] none_keys: List[str] = [],
): ):
"""A complete *remove* (aspirate → waste) operation.""" """A complete *remove* (aspirate → waste) operation."""
trash = self.deck.get_trash_area() trash = self.deck.get_trash_area()
try: try:
if is_96_well: if is_96_well:
pass # This mode is not verified pass # This mode is not verified
else: else:
if len(vols) != len(sources): if len(vols) != len(sources):
raise ValueError("Length of `vols` must match `sources`.") raise ValueError("Length of `vols` must match `sources`.")
for src, vol in zip(sources, vols): for src, vol in zip(sources, vols):
self.move_to(src, dis_to_top=top[0] if top else 0) await self.move_to(src, dis_to_top=top[0] if top else 0)
tip = next(self.current_tip) tip = next(self.current_tip)
await self.pick_up_tips(tip) await self.pick_up_tips(tip)
await self.aspirate( await self.aspirate(
resources=[src], resources=[src],
vols=[vol], vols=[vol],
use_channels=use_channels, # only aspirate96 used, default to None use_channels=use_channels, # only aspirate96 used, default to None
flow_rates=[flow_rates[0]] if flow_rates else None, flow_rates=[flow_rates[0]] if flow_rates else None,
offsets=[offsets[0]] if offsets else None, offsets=[offsets[0]] if offsets else None,
liquid_height=[liquid_height[0]] if liquid_height else None, liquid_height=[liquid_height[0]] if liquid_height else None,
@@ -64,15 +72,15 @@ class DPLiquidHandler(LiquidHandler):
await self.custom_delay(seconds=delays[0] if delays else 0) await self.custom_delay(seconds=delays[0] if delays else 0)
await self.dispense( await self.dispense(
resources=waste_liquid, resources=waste_liquid,
vols=[vol], vols=[vol],
use_channels=use_channels, use_channels=use_channels,
flow_rates=[flow_rates[1]] if flow_rates else None, flow_rates=[flow_rates[1]] if flow_rates else None,
offsets=[offsets[1]] if offsets else None, offsets=[offsets[1]] if offsets else None,
liquid_height=[liquid_height[1]] if liquid_height else None, liquid_height=[liquid_height[1]] if liquid_height else None,
blow_out_air_volume=blow_out_air_volume[1] if blow_out_air_volume else None, blow_out_air_volume=blow_out_air_volume[1] if blow_out_air_volume else None,
spread=spread, spread=spread,
) )
await self.discard_tips() # For now, each of tips is discarded after use await self.discard_tips() # For now, each of tips is discarded after use
except Exception as e: except Exception as e:
raise RuntimeError(f"Liquid removal failed: {e}") from e raise RuntimeError(f"Liquid removal failed: {e}") from e
@@ -100,13 +108,13 @@ class DPLiquidHandler(LiquidHandler):
mix_vol: Optional[int] = None, mix_vol: Optional[int] = None,
mix_rate: Optional[int] = None, mix_rate: Optional[int] = None,
mix_liquid_height: Optional[float] = None, mix_liquid_height: Optional[float] = None,
none_keys: List[str] = [] none_keys: List[str] = [],
): ):
"""A complete *add* (aspirate reagent → dispense into targets) operation.""" """A complete *add* (aspirate reagent → dispense into targets) operation."""
try: try:
if is_96_well: if is_96_well:
pass # This mode is not verified. pass # This mode is not verified.
else: else:
if len(asp_vols) != len(targets): if len(asp_vols) != len(targets):
raise ValueError("Length of `vols` must match `targets`.") raise ValueError("Length of `vols` must match `targets`.")
@@ -122,7 +130,7 @@ class DPLiquidHandler(LiquidHandler):
offsets=[offsets[0]] if offsets else None, offsets=[offsets[0]] if offsets else None,
liquid_height=[liquid_height[0]] if liquid_height else None, liquid_height=[liquid_height[0]] if liquid_height else None,
blow_out_air_volume=[blow_out_air_volume[0]] if blow_out_air_volume else None, blow_out_air_volume=[blow_out_air_volume[0]] if blow_out_air_volume else None,
spread=spread spread=spread,
) )
if delays is not None: if delays is not None:
await self.custom_delay(seconds=delays[0]) await self.custom_delay(seconds=delays[0])
@@ -144,7 +152,8 @@ class DPLiquidHandler(LiquidHandler):
mix_vol=mix_vol, mix_vol=mix_vol,
offsets=offsets if offsets else None, offsets=offsets if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None, height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None) mix_rate=mix_rate if mix_rate else None,
)
if delays is not None: if delays is not None:
await self.custom_delay(seconds=delays[1]) await self.custom_delay(seconds=delays[1])
await self.touch_tip(targets[_]) await self.touch_tip(targets[_])
@@ -158,13 +167,13 @@ class DPLiquidHandler(LiquidHandler):
# --------------------------------------------------------------- # ---------------------------------------------------------------
async def transfer_liquid( async def transfer_liquid(
self, self,
asp_vols: Union[List[float], float],
dis_vols: Union[List[float], float],
sources: Sequence[Container], sources: Sequence[Container],
targets: Sequence[Container], targets: Sequence[Container],
tip_racks: Sequence[TipRack], tip_racks: Sequence[TipRack],
*, *,
use_channels: Optional[List[int]] = None, use_channels: Optional[List[int]] = None,
asp_vols: Union[List[float], float],
dis_vols: Union[List[float], float],
asp_flow_rates: Optional[List[Optional[float]]] = None, asp_flow_rates: Optional[List[Optional[float]]] = None,
dis_flow_rates: Optional[List[Optional[float]]] = None, dis_flow_rates: Optional[List[Optional[float]]] = None,
offsets: Optional[List[Coordinate]] = None, offsets: Optional[List[Coordinate]] = None,
@@ -179,7 +188,7 @@ class DPLiquidHandler(LiquidHandler):
mix_rate: Optional[int] = None, mix_rate: Optional[int] = None,
mix_liquid_height: Optional[float] = None, mix_liquid_height: Optional[float] = None,
delays: Optional[List[int]] = None, delays: Optional[List[int]] = None,
none_keys: List[str] = [] none_keys: List[str] = [],
): ):
"""Transfer liquid from each *source* well/plate to the corresponding *target*. """Transfer liquid from each *source* well/plate to the corresponding *target*.
@@ -201,14 +210,15 @@ class DPLiquidHandler(LiquidHandler):
# 96channel head mode # 96channel head mode
# ------------------------------------------------------------------ # ------------------------------------------------------------------
if is_96_well: if is_96_well:
pass # This mode is not verified pass # This mode is not verified
else: else:
if not (len(asp_vols) == len(sources) and len(dis_vols) == len(targets)): if not (len(asp_vols) == len(sources) and len(dis_vols) == len(targets)):
raise ValueError("`sources`, `targets`, and `vols` must have the same length.") raise ValueError("`sources`, `targets`, and `vols` must have the same length.")
tip_iter = self.iter_tips(tip_racks) tip_iter = self.iter_tips(tip_racks)
for src, tgt, asp_vol, asp_flow_rate, dis_vol, dis_flow_rate in ( for src, tgt, asp_vol, asp_flow_rate, dis_vol, dis_flow_rate in zip(
zip(sources, targets, asp_vols, asp_flow_rates, dis_vols, dis_flow_rates)): sources, targets, asp_vols, asp_flow_rates, dis_vols, dis_flow_rates
):
tip = next(tip_iter) tip = next(tip_iter)
await self.pick_up_tips(tip) await self.pick_up_tips(tip)
# Aspirate from source # Aspirate from source
@@ -247,9 +257,9 @@ class DPLiquidHandler(LiquidHandler):
except Exception as exc: except Exception as exc:
raise RuntimeError(f"Liquid transfer failed: {exc}") from exc raise RuntimeError(f"Liquid transfer failed: {exc}") from exc
# --------------------------------------------------------------- # ---------------------------------------------------------------
# Helper utilities # Helper utilities
# --------------------------------------------------------------- # ---------------------------------------------------------------
async def custom_delay(self, seconds=0, msg=None): async def custom_delay(self, seconds=0, msg=None):
""" """
@@ -266,28 +276,26 @@ class DPLiquidHandler(LiquidHandler):
print(f"Done: {msg}") print(f"Done: {msg}")
print(f"Current time: {time.strftime('%H:%M:%S')}") print(f"Current time: {time.strftime('%H:%M:%S')}")
async def touch_tip(self, async def touch_tip(self, targets: Sequence[Container]):
targets: Sequence[Container],
):
"""Touch the tip to the side of the well.""" """Touch the tip to the side of the well."""
await self.aspirate( await self.aspirate(
resources=[targets], resources=[targets],
vols=[0], vols=[0],
use_channels=None, use_channels=None,
flow_rates=None, flow_rates=None,
offsets=[Coordinate(x=-targets.get_size_x()/2,y=0,z=0)], offsets=[Coordinate(x=-targets.get_size_x() / 2, y=0, z=0)],
liquid_height=None, liquid_height=None,
blow_out_air_volume=None blow_out_air_volume=None,
) )
#await self.custom_delay(seconds=1) # In the simulation, we do not need to wait # await self.custom_delay(seconds=1) # In the simulation, we do not need to wait
await self.aspirate( await self.aspirate(
resources=[targets], resources=[targets],
vols=[0], vols=[0],
use_channels=None, use_channels=None,
flow_rates=None, flow_rates=None,
offsets=[Coordinate(x=targets.get_size_x()/2,y=0,z=0)], offsets=[Coordinate(x=targets.get_size_x() / 2, y=0, z=0)],
liquid_height=None, liquid_height=None,
blow_out_air_volume=None blow_out_air_volume=None,
) )
async def mix( async def mix(
@@ -298,9 +306,9 @@ class DPLiquidHandler(LiquidHandler):
height_to_bottom: Optional[float] = None, height_to_bottom: Optional[float] = None,
offsets: Optional[Coordinate] = None, offsets: Optional[Coordinate] = None,
mix_rate: Optional[float] = None, mix_rate: Optional[float] = None,
none_keys: List[str] = [] none_keys: List[str] = [],
): ):
if mix_time is None: # No mixing required if mix_time is None: # No mixing required
return return
"""Mix the liquid in the target wells.""" """Mix the liquid in the target wells."""
for _ in range(mix_time): for _ in range(mix_time):
@@ -333,7 +341,7 @@ class DPLiquidHandler(LiquidHandler):
tip_iter = self.iter_tips(tip_racks) tip_iter = self.iter_tips(tip_racks)
self.current_tip = tip_iter self.current_tip = tip_iter
async def move_to(self, well: Well, dis_to_top: float = 0 , channel: int = 0): async def move_to(self, well: Well, dis_to_top: float = 0, channel: int = 0):
""" """
Move a single channel to a specific well with a given z-height. Move a single channel to a specific well with a given z-height.
@@ -352,4 +360,3 @@ class DPLiquidHandler(LiquidHandler):
await self.move_channel_x(channel, abs_loc.x) await self.move_channel_x(channel, abs_loc.x)
await self.move_channel_y(channel, abs_loc.y) await self.move_channel_y(channel, abs_loc.y)
await self.move_channel_z(channel, abs_loc.z + well_height + dis_to_top) await self.move_channel_z(channel, abs_loc.z + well_height + dis_to_top)

View File

@@ -0,0 +1,154 @@
import json
from typing import Sequence, Optional, List, Union, Literal
json_path = "/Users/guangxinzhang/Documents/Deep Potential/opentrons/convert/protocols/enriched_steps/sci-lucif-assay4.json"
with open(json_path, "r") as f:
data = json.load(f)
transfer_example = data[0]
#print(transfer_example)
temp_protocol = []
TipLocation = "BC1025F" # Assuming this is a fixed tip location for the transfer
sources = transfer_example["sources"] # Assuming sources is a list of Container objects
targets = transfer_example["targets"] # Assuming targets is a list of Container objects
tip_racks = transfer_example["tip_racks"] # Assuming tip_racks is a list of TipRack objects
asp_vols = transfer_example["asp_vols"] # Assuming asp_vols is a list of volumes
solvent = "PBS"
def transfer_liquid(
#self,
sources,#: Sequence[Container],
targets,#: Sequence[Container],
tip_racks,#: Sequence[TipRack],
TipLocation,
# *,
# use_channels: Optional[List[int]] = None,
asp_vols: Union[List[float], float],
solvent: Optional[str] = None,
# dis_vols: Union[List[float], float],
# asp_flow_rates: Optional[List[Optional[float]]] = None,
# dis_flow_rates: Optional[List[Optional[float]]] = None,
# offsets,#: Optional[List[]] = None,
# touch_tip: bool = False,
# liquid_height: Optional[List[Optional[float]]] = None,
# blow_out_air_volume: Optional[List[Optional[float]]] = None,
# spread: Literal["wide", "tight", "custom"] = "wide",
# is_96_well: bool = False,
# mix_stage: Optional[Literal["none", "before", "after", "both"]] = "none",
# mix_times,#: Optional[list() = None,
# mix_vol: Optional[int] = None,
# mix_rate: Optional[int] = None,
# mix_liquid_height: Optional[float] = None,
# delays: Optional[List[int]] = None,
# none_keys: List[str] = []
):
# -------- Build Biomek transfer step --------
# 1) Construct default parameter scaffold (values mirror Biomek “Transfer” block).
transfer_params = {
"Span8": False,
"Pod": "Pod1",
"items": {}, # to be filled below
"Wash": False,
"Dynamic?": True,
"AutoSelectActiveWashTechnique": False,
"ActiveWashTechnique": "",
"ChangeTipsBetweenDests": False,
"ChangeTipsBetweenSources": False,
"DefaultCaption": "", # filled after we know first pair/vol
"UseExpression": False,
"LeaveTipsOn": False,
"MandrelExpression": "",
"Repeats": "1",
"RepeatsByVolume": False,
"Replicates": "1",
"ShowTipHandlingDetails": False,
"ShowTransferDetails": True,
"Solvent": "Water",
"Span8Wash": False,
"Span8WashVolume": "2",
"Span8WasteVolume": "1",
"SplitVolume": False,
"SplitVolumeCleaning": False,
"Stop": "Destinations",
"TipLocation": "BC1025F",
"UseCurrentTips": False,
"UseDisposableTips": True,
"UseFixedTips": False,
"UseJIT": True,
"UseMandrelSelection": True,
"UseProbes": [True, True, True, True, True, True, True, True],
"WashCycles": "1",
"WashVolume": "110%",
"Wizard": False
}
items: dict = {}
for idx, (src, dst) in enumerate(zip(sources, targets)):
items[str(idx)] = {
"Source": str(src),
"Destination": str(dst),
"Volume": asp_vols[idx]
}
transfer_params["items"] = items
transfer_params["Solvent"] = solvent if solvent else "Water"
transfer_params["TipLocation"] = TipLocation
if len(tip_racks) == 1:
transfer_params['UseCurrentTips'] = True
elif len(tip_racks) > 1:
transfer_params["ChangeTipsBetweenDests"] = True
return transfer_params
action = transfer_liquid(sources=sources,targets=targets,tip_racks=tip_racks, asp_vols=asp_vols,solvent = solvent, TipLocation=TipLocation)
print(json.dumps(action,indent=2))
# print(action)
"""
"transfer": {
"items": {},
"Wash": false,
"Dynamic?": true,
"AutoSelectActiveWashTechnique": false,
"ActiveWashTechnique": "",
"ChangeTipsBetweenDests": true,
"ChangeTipsBetweenSources": false,
"DefaultCaption": "Transfer 100 µL from P13 to P3",
"UseExpression": false,
"LeaveTipsOn": false,
"MandrelExpression": "",
"Repeats": "1",
"RepeatsByVolume": false,
"Replicates": "1",
"ShowTipHandlingDetails": true,
"ShowTransferDetails": true,
"Span8Wash": false,
"Span8WashVolume": "2",
"Span8WasteVolume": "1",
"SplitVolume": false,
"SplitVolumeCleaning": false,
"Stop": "Destinations",
"TipLocation": "BC1025F",
"UseCurrentTips": false,
"UseDisposableTips": false,
"UseFixedTips": false,
"UseJIT": true,
"UseMandrelSelection": true,
"UseProbes": [true, true, true, true, true, true, true, true],
"WashCycles": "3",
"WashVolume": "110%",
"Wizard": false
"""

File diff suppressed because it is too large Load Diff

View File

@@ -5,22 +5,22 @@ class SolenoidValveMock:
def __init__(self, port: str = "COM6"): def __init__(self, port: str = "COM6"):
self._status = "Idle" self._status = "Idle"
self._valve_position = "OPEN" self._valve_position = "OPEN"
@property @property
def status(self) -> str: def status(self) -> str:
return self._status return self._status
@property @property
def valve_position(self) -> str: def valve_position(self) -> str:
return self._valve_position return self._valve_position
def get_valve_position(self) -> str: def get_valve_position(self) -> str:
return self._valve_position return self._valve_position
def set_valve_position(self, position): def set_valve_position(self, position):
self._status = "Busy" self._status = "Busy"
time.sleep(5) time.sleep(5)
self._valve_position = position self._valve_position = position
time.sleep(5) time.sleep(5)
self._status = "Idle" self._status = "Idle"

View File

@@ -4,17 +4,17 @@ import time
class VacuumPumpMock: class VacuumPumpMock:
def __init__(self, port: str = "COM6"): def __init__(self, port: str = "COM6"):
self._status = "OPEN" self._status = "OPEN"
@property @property
def status(self) -> str: def status(self) -> str:
return self._status return self._status
def get_status(self) -> str: def get_status(self) -> str:
return self._status return self._status
def set_status(self, position): def set_status(self, position):
time.sleep(5) time.sleep(5)
self._status = position self._status = position
time.sleep(5) time.sleep(5)

View File

@@ -1,11 +1,97 @@
liquid_handler: liquid_handler:
description: Liquid handler device controlled by pylabrobot description: Liquid handler device controlled by pylabrobot
icon: icon_yiyezhan.webp
class: class:
module: pylabrobot.liquid_handling:LiquidHandler module: unilabos.devices.liquid_handling.liquid_handler_abstract:LiquidHandlerAbstract
type: python type: python
status_types: status_types:
name: String name: String
action_value_mappings: action_value_mappings:
remove:
type: LiquidHandlerRemove
goal:
vols: vols
sources: sources
waste_liquid: waste_liquid
use_channels: use_channels
flow_rates: flow_rates
offsets: offsets
liquid_height: liquid_height
blow_out_air_volume: blow_out_air_volume
spread: spread
delays: delays
is_96_well: is_96_well
top: top
none_keys: none_keys
feedback: {}
result: {}
add_liquid:
type: LiquidHandlerAdd
goal:
asp_vols: asp_vols
dis_vols: dis_vols
reagent_sources: reagent_sources
targets: targets
use_channels: use_channels
flow_rates: flow_rates
offsets: offsets
liquid_height: liquid_height
blow_out_air_volume: blow_out_air_volume
spread: spread
is_96_well: is_96_well
mix_time: mix_time
mix_vol: mix_vol
mix_rate: mix_rate
mix_liquid_height: mix_liquid_height
none_keys: none_keys
feedback: {}
result: {}
transfer_liquid:
type: LiquidHandlerTransfer
goal:
asp_vols: asp_vols
dis_vols: dis_vols
sources: sources
targets: targets
tip_racks: tip_racks
use_channels: use_channels
asp_flow_rates: asp_flow_rates
dis_flow_rates: dis_flow_rates
offsets: offsets
touch_tip: touch_tip
liquid_height: liquid_height
blow_out_air_volume: blow_out_air_volume
spread: spread
is_96_well: is_96_well
mix_stage: mix_stage
mix_times: mix_times
mix_vol: mix_vol
mix_rate: mix_rate
mix_liquid_height: mix_liquid_height
delays: delays
none_keys: none_keys
feedback: {}
result: {}
mix:
type: LiquidHandlerMix
goal:
targets: targets
mix_time: mix_time
mix_vol: mix_vol
height_to_bottom: height_to_bottom
offsets: offsets
mix_rate: mix_rate
none_keys: none_keys
feedback: {}
result: {}
move_to:
type: LiquidHandlerMoveTo
goal:
well: well
dis_to_top: dis_to_top
channel: channel
feedback: {}
result: {}
aspirate: aspirate:
type: LiquidHandlerAspirate type: LiquidHandlerAspirate
goal: goal:
@@ -160,6 +246,21 @@ liquid_handler:
target_vols: target_vols target_vols: target_vols
aspiration_flow_rate: aspiration_flow_rate aspiration_flow_rate: aspiration_flow_rate
dispense_flow_rates: dispense_flow_rates dispense_flow_rates: dispense_flow_rates
handles:
input:
- handler_key: liquid-input
label: Liquid Input
data_type: resource
io_type: target
data_source: handle
data_key: liquid
output:
- handler_key: liquid-output
label: Liquid Output
data_type: resource
io_type: source
data_source: executor
data_key: liquid
schema: schema:
type: object type: object
properties: properties:
@@ -170,55 +271,51 @@ liquid_handler:
- name - name
additionalProperties: false additionalProperties: false
dp_liquid_handler: liquid_handler.revvity:
description: 通用液体处理
class: class:
module: unilabos.devices.liquid_handling.action_definition:DPLiquidHandler module: unilabos.devices.liquid_handling.revvity:Revvity
type: python type: python
status_types: status_types:
status: String status: String
action_value_mappings: action_value_mappings:
remove_liquid: run:
type: DPLiquidHandlerRemoveLiquid type: WorkStationRun
goal: goal:
vols: vols wf_name: file_path
sources: sources params: params
waste_liquid: waste_liquid resource: resource
use_channels: use_channels feedback:
flow_rates: flow_rates status: status
offsets: offsets result:
liquid_height: liquid_height success: success
blow_out_air_volume: blow_out_air_volume
spread: spread liquid_handler.biomek:
delays: delays description: Biomek液体处理器设备基于pylabrobot控制
is_96_well: is_96_well icon: icon_yiyezhan.webp
top: top class:
module: unilabos.devices.liquid_handling.biomek:LiquidHandlerBiomek
type: python
status_types: {}
action_value_mappings:
create_protocol:
type: LiquidHandlerProtocolCreation
goal:
protocol_name: protocol_name
protocol_description: protocol_description
protocol_version: protocol_version
protocol_author: protocol_author
protocol_date: protocol_date
protocol_type: protocol_type
none_keys: none_keys none_keys: none_keys
feedback: {} feedback: {}
result: {} result: {}
add_liquid: run_protocol:
type: DPLiquidHandlerAddLiquid type: EmptyIn
goal: goal: {}
asp_vols: asp_vols
dis_vols: dis_vols
reagent_sources: reagent_sources
targets: targets
use_channels: use_channels
flow_rates: flow_rates
offsets: offsets
liquid_height: liquid_height
blow_out_air_volume: blow_out_air_volume
spread: spread
is_96_well: is_96_well
mix_time: mix_time
mix_vol: mix_vol
mix_rate: mix_rate
mix_liquid_height: mix_liquid_height
none_keys: none_keys
feedback: {} feedback: {}
result: {} result: {}
transfer_liquid: transfer_liquid:
type: DPLiquidHandlerTransferLiquid type: LiquidHandlerTransfer
goal: goal:
asp_vols: asp_vols asp_vols: asp_vols
dis_vols: dis_vols dis_vols: dis_vols
@@ -243,68 +340,19 @@ dp_liquid_handler:
none_keys: none_keys none_keys: none_keys
feedback: {} feedback: {}
result: {} result: {}
custom_delay: transfer_biomek:
type: DPLiquidHandlerCustomDelay type: LiquidHandlerTransferBiomek
goal: goal:
seconds: seconds source: source,
msg: msg target: target,
feedback: {} tip_rack: tip_rack,
result: {} volume: volume,
touch_tip: aspirate_techniques: aspirate_techniques,
type: DPLiquidHandlerTouchTip dispense_techniques: dispense_techniques,
goal:
targets: targets
feedback: {}
result: {}
mix:
type: DPLiquidHandlerMix
goal:
targets: targets
mix_time: mix_time
mix_vol: mix_vol
height_to_bottom: height_to_bottom
offsets: offsets
mix_rate: mix_rate
none_keys: none_keys
feedback: {}
result: {}
set_tiprack:
type: DPLiquidHandlerSetTiprack
goal:
tip_racks: tip_racks
feedback: {}
result: {}
move_to:
type: DPLiquidHandlerMoveTo
goal:
well: well
dis_to_top: dis_to_top
channel: channel
feedback: {} feedback: {}
result: {} result: {}
schema: schema:
type: object type: object
properties: properties: {}
name: required: []
type: string additionalProperties: false
description: 物料名
required:
- name
liquid_handler.revvity:
class:
module: unilabos.devices.liquid_handling.revvity:Revvity
type: python
status_types:
status: String
action_value_mappings:
run:
type: WorkStationRun
goal:
wf_name: file_path
params: params
resource: resource
feedback:
status: status
result:
success: success

View File

@@ -12,7 +12,7 @@ separator.homemade:
goal: goal:
stir_time: stir_time, stir_time: stir_time,
stir_speed: stir_speed stir_speed: stir_speed
settling_time": settling_time settling_time: settling_time
feedback: feedback:
status: status status: status
result: result:

View File

@@ -23,20 +23,51 @@ syringe_pump_with_valve.runze:
type: string type: string
description: The position of the valve description: The position of the valve
required: required:
- status - status
- position - position
- valve_position - valve_position
additionalProperties: false additionalProperties: false
solenoid_valve.mock: solenoid_valve.mock:
description: Mock solenoid valve description: Mock solenoid valve
class: class:
module: unilabos.devices.pump_and_valve.solenoid_valve_mock:SolenoidValveMock module: unilabos.devices.pump_and_valve.solenoid_valve_mock:SolenoidValveMock
type: python type: python
status_types:
status: String
valve_position: String
action_value_mappings:
open:
type: EmptyIn
goal: {}
feedback: {}
result: {}
close:
type: EmptyIn
goal: {}
feedback: {}
result: {}
handles:
input:
- handler_key: fluid-input
label: Fluid Input
data_type: fluid
output:
- handler_key: fluid-output
label: Fluid Output
data_type: fluid
init_param_schema:
type: object
properties:
port:
type: string
description: "通信端口"
default: "COM6"
required:
- port
solenoid_valve: solenoid_valve:
description: Solenoid valve description: Solenoid valve
class: class:
module: unilabos.devices.pump_and_valve.solenoid_valve:SolenoidValve module: unilabos.devices.pump_and_valve.solenoid_valve:SolenoidValve
type: python type: python

View File

@@ -3,9 +3,95 @@ vacuum_pump.mock:
class: class:
module: unilabos.devices.pump_and_valve.vacuum_pump_mock:VacuumPumpMock module: unilabos.devices.pump_and_valve.vacuum_pump_mock:VacuumPumpMock
type: python type: python
status_types:
status: String
action_value_mappings:
open:
type: EmptyIn
goal: {}
feedback: {}
result: {}
close:
type: EmptyIn
goal: {}
feedback: {}
result: {}
set_status:
type: StrSingleInput
goal:
string: string
feedback: {}
result: {}
handles:
input:
- handler_key: fluid-input
label: Fluid Input
data_type: fluid
io_type: target
data_source: handle
data_key: fluid_in
output:
- handler_key: fluid-output
label: Fluid Output
data_type: fluid
io_type: source
data_source: executor
data_key: fluid_out
init_param_schema:
type: object
properties:
port:
type: string
description: "通信端口"
default: "COM6"
required:
- port
gas_source.mock: gas_source.mock:
description: Mock gas source description: Mock gas source
class: class:
module: unilabos.devices.pump_and_valve.vacuum_pump_mock:VacuumPumpMock module: unilabos.devices.pump_and_valve.vacuum_pump_mock:VacuumPumpMock
type: python type: python
status_types:
status: String
action_value_mappings:
open:
type: EmptyIn
goal: {}
feedback: {}
result: {}
close:
type: EmptyIn
goal: {}
feedback: {}
result: {}
set_status:
type: StrSingleInput
goal:
string: string
feedback: {}
result: {}
handles:
input:
- handler_key: fluid-input
label: Fluid Input
data_type: fluid
io_type: target
data_source: handle
data_key: fluid_in
output:
- handler_key: fluid-output
label: Fluid Output
data_type: fluid
io_type: source
data_source: executor
data_key: fluid_out
init_param_schema:
type: object
properties:
port:
type: string
description: "通信端口"
default: "COM6"
required:
- port

View File

@@ -4,4 +4,4 @@ workstation:
module: unilabos.ros.nodes.presets.protocol_node:ROS2ProtocolNode module: unilabos.ros.nodes.presets.protocol_node:ROS2ProtocolNode
type: ros2 type: ros2
schema: schema:
properties: {} properties: {}

View File

@@ -1,5 +1,4 @@
import io import io
import json
import os import os
import sys import sys
from pathlib import Path from pathlib import Path
@@ -7,10 +6,9 @@ from typing import Any
import yaml import yaml
from unilabos.utils import logger
from unilabos.ros.msgs.message_converter import msg_converter_manager, ros_action_to_json_schema from unilabos.ros.msgs.message_converter import msg_converter_manager, ros_action_to_json_schema
from unilabos.utils import logger
from unilabos.utils.decorator import singleton from unilabos.utils.decorator import singleton
from unilabos.utils.type_check import TypeEncoder
DEFAULT_PATHS = [Path(__file__).absolute().parent] DEFAULT_PATHS = [Path(__file__).absolute().parent]
@@ -21,43 +19,14 @@ class Registry:
self.registry_paths = DEFAULT_PATHS.copy() # 使用copy避免修改默认值 self.registry_paths = DEFAULT_PATHS.copy() # 使用copy避免修改默认值
if registry_paths: if registry_paths:
self.registry_paths.extend(registry_paths) self.registry_paths.extend(registry_paths)
action_type = self._replace_type_with_class( self.ResourceCreateFromOuter = self._replace_type_with_class(
"ResourceCreateFromOuter", "host_node", f"动作 add_resource_from_outer" "ResourceCreateFromOuter", "host_node", f"动作 create_resource_detailed"
) )
schema = ros_action_to_json_schema(action_type) self.ResourceCreateFromOuterEasy = self._replace_type_with_class(
self.device_type_registry = { "ResourceCreateFromOuterEasy", "host_node", f"动作 create_resource"
"host_node": { )
"description": "UniLabOS主机节点", self.EmptyIn = self._replace_type_with_class("EmptyIn", "host_node", f"")
"class": { self.device_type_registry = {}
"module": "unilabos.ros.nodes.presets.host_node",
"type": "python",
"status_types": {},
"action_value_mappings": {
"add_resource_from_outer": {
"type": msg_converter_manager.search_class("ResourceCreateFromOuter"),
"goal": {
"resources": "resources",
"device_ids": "device_ids",
"bind_parent_ids": "bind_parent_ids",
"bind_locations": "bind_locations",
"other_calling_params": "other_calling_params",
},
"feedback": {},
"result": {
"success": "success"
},
"schema": schema
}
}
},
"schema": {
"properties": {},
"additionalProperties": False,
"type": "object"
},
"file_path": "/"
}
}
self.resource_type_registry = {} self.resource_type_registry = {}
self._setup_called = False # 跟踪setup是否已调用 self._setup_called = False # 跟踪setup是否已调用
# 其他状态变量 # 其他状态变量
@@ -69,9 +38,72 @@ class Registry:
logger.critical("[UniLab Registry] setup方法已被调用过不允许多次调用") logger.critical("[UniLab Registry] setup方法已被调用过不允许多次调用")
return return
# 标记setup已被调用 from unilabos.app.web.utils.action_utils import get_yaml_from_goal_type
self._setup_called = True
self.device_type_registry.update(
{
"host_node": {
"description": "UniLabOS主机节点",
"class": {
"module": "unilabos.ros.nodes.presets.host_node",
"type": "python",
"status_types": {},
"action_value_mappings": {
"create_resource_detailed": {
"type": self.ResourceCreateFromOuter,
"goal": {
"resources": "resources",
"device_ids": "device_ids",
"bind_parent_ids": "bind_parent_ids",
"bind_locations": "bind_locations",
"other_calling_params": "other_calling_params",
},
"feedback": {},
"result": {"success": "success"},
"schema": ros_action_to_json_schema(self.ResourceCreateFromOuter),
"goal_default": yaml.safe_load(
io.StringIO(get_yaml_from_goal_type(self.ResourceCreateFromOuter.Goal))
),
},
"create_resource": {
"type": self.ResourceCreateFromOuterEasy,
"goal": {
"res_id": "res_id",
"class_name": "class_name",
"parent": "parent",
"device_id": "device_id",
"bind_locations": "bind_locations",
"liquid_input_slot": "liquid_input_slot[]",
"liquid_type": "liquid_type[]",
"liquid_volume": "liquid_volume[]",
"slot_on_deck": "slot_on_deck",
},
"feedback": {},
"result": {"success": "success"},
"schema": ros_action_to_json_schema(self.ResourceCreateFromOuterEasy),
"goal_default": yaml.safe_load(
io.StringIO(get_yaml_from_goal_type(self.ResourceCreateFromOuterEasy.Goal))
),
},
"test_latency": {
"type": self.EmptyIn,
"goal": {},
"feedback": {},
"result": {"latency_ms": "latency_ms", "time_diff_ms": "time_diff_ms"},
"schema": ros_action_to_json_schema(self.EmptyIn),
"goal_default": {},
},
},
},
"icon": "icon_device.webp",
"registry_type": "device",
"handles": [],
"init_param_schema": {},
"schema": {"properties": {}, "additionalProperties": False, "type": "object"},
"file_path": "/",
}
}
)
logger.debug(f"[UniLab Registry] ----------Setup----------") logger.debug(f"[UniLab Registry] ----------Setup----------")
self.registry_paths = [Path(path).absolute() for path in self.registry_paths] self.registry_paths = [Path(path).absolute() for path in self.registry_paths]
for i, path in enumerate(self.registry_paths): for i, path in enumerate(self.registry_paths):
@@ -81,6 +113,8 @@ class Registry:
self.load_device_types(path) self.load_device_types(path)
self.load_resource_types(path) self.load_resource_types(path)
logger.info("[UniLab Registry] 注册表设置完成") logger.info("[UniLab Registry] 注册表设置完成")
# 标记setup已被调用
self._setup_called = True
def load_resource_types(self, path: os.PathLike): def load_resource_types(self, path: os.PathLike):
abs_path = Path(path).absolute() abs_path = Path(path).absolute()
@@ -96,6 +130,13 @@ class Registry:
resource_info["file_path"] = str(file.absolute()).replace("\\", "/") resource_info["file_path"] = str(file.absolute()).replace("\\", "/")
if "description" not in resource_info: if "description" not in resource_info:
resource_info["description"] = "" resource_info["description"] = ""
if "icon" not in resource_info:
resource_info["icon"] = ""
if "handles" not in resource_info:
resource_info["handles"] = []
if "init_param_schema" not in resource_info:
resource_info["init_param_schema"] = {}
resource_info["registry_type"] = "resource"
self.resource_type_registry.update(data) self.resource_type_registry.update(data)
logger.debug( logger.debug(
f"[UniLab Registry] Resource-{current_resource_number} File-{i+1}/{len(files)} " f"[UniLab Registry] Resource-{current_resource_number} File-{i+1}/{len(files)} "
@@ -145,6 +186,7 @@ class Registry:
) )
current_device_number = len(self.device_type_registry) + 1 current_device_number = len(self.device_type_registry) + 1
from unilabos.app.web.utils.action_utils import get_yaml_from_goal_type from unilabos.app.web.utils.action_utils import get_yaml_from_goal_type
for i, file in enumerate(files): for i, file in enumerate(files):
data = yaml.safe_load(open(file, encoding="utf-8")) data = yaml.safe_load(open(file, encoding="utf-8"))
if data: if data:
@@ -154,6 +196,13 @@ class Registry:
device_config["file_path"] = str(file.absolute()).replace("\\", "/") device_config["file_path"] = str(file.absolute()).replace("\\", "/")
if "description" not in device_config: if "description" not in device_config:
device_config["description"] = "" device_config["description"] = ""
if "icon" not in device_config:
device_config["icon"] = ""
if "handles" not in device_config:
device_config["handles"] = []
if "init_param_schema" not in device_config:
device_config["init_param_schema"] = {}
device_config["registry_type"] = "device"
if "class" in device_config: if "class" in device_config:
# 处理状态类型 # 处理状态类型
if "status_types" in device_config["class"]: if "status_types" in device_config["class"]:
@@ -169,8 +218,15 @@ class Registry:
action_config["type"] = self._replace_type_with_class( action_config["type"] = self._replace_type_with_class(
action_config["type"], device_id, f"动作 {action_name}" action_config["type"], device_id, f"动作 {action_name}"
) )
action_config["goal_default"] = yaml.safe_load(io.StringIO(get_yaml_from_goal_type(action_config["type"].Goal))) if action_config["type"] is not None:
action_config["schema"] = ros_action_to_json_schema(action_config["type"]) action_config["goal_default"] = yaml.safe_load(
io.StringIO(get_yaml_from_goal_type(action_config["type"].Goal))
)
action_config["schema"] = ros_action_to_json_schema(action_config["type"])
else:
logger.warning(
f"[UniLab Registry] 设备 {device_id} 的动作 {action_name} 类型为空,跳过替换"
)
self.device_type_registry.update(data) self.device_type_registry.update(data)
@@ -188,13 +244,17 @@ class Registry:
def obtain_registry_device_info(self): def obtain_registry_device_info(self):
devices = [] devices = []
for device_id, device_info in self.device_type_registry.items(): for device_id, device_info in self.device_type_registry.items():
msg = { msg = {"id": device_id, **device_info}
"id": device_id,
**device_info
}
devices.append(msg) devices.append(msg)
return devices return devices
def obtain_registry_resource_info(self):
resources = []
for resource_id, resource_info in self.resource_type_registry.items():
msg = {"id": resource_id, **resource_info}
resources.append(msg)
return resources
# 全局单例实例 # 全局单例实例
lab_registry = Registry() lab_registry = Registry()

View File

@@ -1,35 +1,35 @@
agilent_1_reservoir_290ml: agilent_1_reservoir_290ml:
description: Agilent 1 reservoir 290ml description: Agilent 1 reservoir 290ml
class: class:
module: pylabrobot.resources.opentrons.reserviors:agilent_1_reservoir_290ml module: pylabrobot.resources.opentrons.reservoirs:agilent_1_reservoir_290ml
type: pylabrobot type: pylabrobot
axygen_1_reservoir_90ml: axygen_1_reservoir_90ml:
description: Axygen 1 reservoir 90ml description: Axygen 1 reservoir 90ml
class: class:
module: pylabrobot.resources.opentrons.reserviors:axygen_1_reservoir_90ml module: pylabrobot.resources.opentrons.reservoirs:axygen_1_reservoir_90ml
type: pylabrobot type: pylabrobot
nest_12_reservoir_15ml: nest_12_reservoir_15ml:
description: Nest 12 reservoir 15ml description: Nest 12 reservoir 15ml
class: class:
module: pylabrobot.resources.opentrons.reserviors:nest_12_reservoir_15ml module: pylabrobot.resources.opentrons.reservoirs:nest_12_reservoir_15ml
type: pylabrobot type: pylabrobot
nest_1_reservoir_195ml: nest_1_reservoir_195ml:
description: Nest 1 reservoir 195ml description: Nest 1 reservoir 195ml
class: class:
module: pylabrobot.resources.opentrons.reserviors:nest_1_reservoir_195ml module: pylabrobot.resources.opentrons.reservoirs:nest_1_reservoir_195ml
type: pylabrobot type: pylabrobot
nest_1_reservoir_290ml: nest_1_reservoir_290ml:
description: Nest 1 reservoir 290ml description: Nest 1 reservoir 290ml
class: class:
module: pylabrobot.resources.opentrons.reserviors:nest_1_reservoir_290ml module: pylabrobot.resources.opentrons.reservoirs:nest_1_reservoir_290ml
type: pylabrobot type: pylabrobot
usascientific_12_reservoir_22ml: usascientific_12_reservoir_22ml:
description: USAScientific 12 reservoir 22ml description: USAScientific 12 reservoir 22ml
class: class:
module: pylabrobot.resources.opentrons.reserviors:usascientific_12_reservoir_22ml module: pylabrobot.resources.opentrons.reservoirs:usascientific_12_reservoir_22ml
type: pylabrobot type: pylabrobot

View File

@@ -189,6 +189,7 @@ def dict_from_graph(graph: nx.Graph) -> dict:
def dict_to_tree(nodes: dict, devices_only: bool = False) -> list[dict]: def dict_to_tree(nodes: dict, devices_only: bool = False) -> list[dict]:
# 将节点转换为字典,以便通过 ID 快速查找 # 将节点转换为字典,以便通过 ID 快速查找
nodes_list = [node for node in nodes.values() if node.get("type") == "device" or not devices_only] nodes_list = [node for node in nodes.values() if node.get("type") == "device" or not devices_only]
id_list = [node["id"] for node in nodes_list]
# 初始化每个节点的 children 为包含节点字典的列表 # 初始化每个节点的 children 为包含节点字典的列表
for node in nodes_list: for node in nodes_list:
@@ -196,7 +197,7 @@ def dict_to_tree(nodes: dict, devices_only: bool = False) -> list[dict]:
# 找到根节点并返回 # 找到根节点并返回
root_nodes = [ root_nodes = [
node for node in nodes_list if len(nodes_list) == 1 or node.get("parent", node.get("parent_name")) in [None, "", "None", np.nan] node for node in nodes_list if len(nodes_list) == 1 or node.get("parent", node.get("parent_name")) in [None, "", "None", np.nan] or node.get("parent", node.get("parent_name")) not in id_list
] ]
# 如果存在多个根节点,返回所有根节点 # 如果存在多个根节点,返回所有根节点
@@ -421,7 +422,7 @@ def resource_plr_to_ulab(resource_plr: "ResourcePLR", parent_name: str = None):
return r return r
def initialize_resource(resource_config: dict, lab_registry: dict) -> list[dict]: def initialize_resource(resource_config: dict) -> list[dict]:
"""Initializes a resource based on its configuration. """Initializes a resource based on its configuration.
If the config is detailed, then do nothing; If the config is detailed, then do nothing;
@@ -433,6 +434,7 @@ def initialize_resource(resource_config: dict, lab_registry: dict) -> list[dict]
Returns: Returns:
None None
""" """
from unilabos.registry.registry import lab_registry
resource_class_config = resource_config.get("class", None) resource_class_config = resource_config.get("class", None)
if resource_class_config is None: if resource_class_config is None:
return [resource_config] return [resource_config]
@@ -476,11 +478,8 @@ def initialize_resources(resources_config) -> list[dict]:
None None
""" """
from unilabos.registry.registry import lab_registry
resources = [] resources = []
for resource_config in resources_config: for resource_config in resources_config:
if resource_config["parent"] == "tip_rack" or resource_config["parent"] == "plate_well": resources.extend(initialize_resource(resource_config))
continue
resources.extend(initialize_resource(resource_config, lab_registry))
return resources return resources

View File

@@ -131,7 +131,7 @@ _msg_converter: Dict[Type, Any] = {
Bool: lambda x: Bool(data=bool(x)), Bool: lambda x: Bool(data=bool(x)),
str: str, str: str,
String: lambda x: String(data=str(x)), String: lambda x: String(data=str(x)),
Point: lambda x: Point(x=x.x, y=x.y, z=x.z), Point: lambda x: Point(x=x.x, y=x.y, z=x.z) if not isinstance(x, dict) else Point(x=x.get("x", 0), y=x.get("y", 0), z=x.get("z", 0)),
Resource: lambda x: Resource( Resource: lambda x: Resource(
id=x.get("id", ""), id=x.get("id", ""),
name=x.get("name", ""), name=x.get("name", ""),
@@ -348,10 +348,16 @@ def convert_to_ros_msg(ros_msg_type: Union[Type, Any], obj: Any) -> Any:
if isinstance(td, NamespacedType): if isinstance(td, NamespacedType):
target_class = msg_converter_manager.get_class(f"{'.'.join(td.namespaces)}.{td.name}") target_class = msg_converter_manager.get_class(f"{'.'.join(td.namespaces)}.{td.name}")
setattr(ros_msg, key, [convert_to_ros_msg(target_class, v) for v in value]) setattr(ros_msg, key, [convert_to_ros_msg(target_class, v) for v in value])
elif isinstance(td, UnboundedString):
setattr(ros_msg, key, value)
else: else:
logger.warning(f"Not Supported type: {td}")
setattr(ros_msg, key, []) # FIXME setattr(ros_msg, key, []) # FIXME
elif "array.array" in str(type(attr)): elif "array.array" in str(type(attr)):
setattr(ros_msg, key, value) if attr.typecode == "f":
setattr(ros_msg, key, [float(i) for i in value])
else:
setattr(ros_msg, key, value)
else: else:
nested_ros_msg = convert_to_ros_msg(type(attr)(), value) nested_ros_msg = convert_to_ros_msg(type(attr)(), value)
setattr(ros_msg, key, nested_ros_msg) setattr(ros_msg, key, nested_ros_msg)
@@ -574,6 +580,7 @@ basic_type_map = {
'int64': {'type': 'integer'}, 'int64': {'type': 'integer'},
'uint64': {'type': 'integer', 'minimum': 0}, 'uint64': {'type': 'integer', 'minimum': 0},
'double': {'type': 'number'}, 'double': {'type': 'number'},
'float': {'type': 'number'},
'float32': {'type': 'number'}, 'float32': {'type': 'number'},
'float64': {'type': 'number'}, 'float64': {'type': 'number'},
'string': {'type': 'string'}, 'string': {'type': 'string'},

View File

@@ -1,3 +1,5 @@
import copy
import functools
import json import json
import threading import threading
import time import time
@@ -19,7 +21,7 @@ from unilabos_msgs.action import SendCmd
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response
from unilabos.resources.graphio import convert_resources_to_type, convert_resources_from_type, resource_ulab_to_plr, \ from unilabos.resources.graphio import convert_resources_to_type, convert_resources_from_type, resource_ulab_to_plr, \
initialize_resources initialize_resources, list_to_nested_dict, dict_to_tree, resource_plr_to_ulab, tree_to_list
from unilabos.ros.msgs.message_converter import ( from unilabos.ros.msgs.message_converter import (
convert_to_ros_msg, convert_to_ros_msg,
convert_from_ros_msg, convert_from_ros_msg,
@@ -311,7 +313,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
# 物料传输到对应的node节点 # 物料传输到对应的node节点
rclient = self.create_client(ResourceAdd, "/resources/add") rclient = self.create_client(ResourceAdd, "/resources/add")
rclient.wait_for_service() rclient.wait_for_service()
rclient2 = self.create_client(ResourceAdd, "/resources/add")
rclient2.wait_for_service()
request = ResourceAdd.Request() 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"]
@@ -320,11 +325,23 @@ class BaseROS2DeviceNode(Node, Generic[T]):
other_calling_param = command_json["other_calling_param"] other_calling_param = command_json["other_calling_param"]
resources = command_json["resource"] 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", [])
LIQUID_VOLUME = other_calling_param.pop("LIQUID_VOLUME", [])
LIQUID_INPUT_SLOT = other_calling_param.pop("LIQUID_INPUT_SLOT", [])
slot = other_calling_param.pop("slot", -1)
if slot >= 0: # slot为负数的时候采用assign方法
other_calling_param["slot"] = slot
# 本地拿到这个物料,可能需要先做初始化? # 本地拿到这个物料,可能需要先做初始化?
if isinstance(resources, list): if isinstance(resources, list):
if initialize_full: if len(resources) == 1 and isinstance(resources[0], list) and not initialize_full: # 取消,不存在的情况
# 预先initialize过以整组的形式传入
request.resources = [convert_to_ros_msg(Resource, resource_) for resource_ in resources[0]]
elif initialize_full:
resources = initialize_resources(resources) resources = initialize_resources(resources)
request.resources = [convert_to_ros_msg(Resource, resource) for resource in resources] request.resources = [convert_to_ros_msg(Resource, resource) for resource in resources]
else:
request.resources = [convert_to_ros_msg(Resource, resource) for resource in resources]
else: else:
if initialize_full: if initialize_full:
resources = initialize_resources([resources]) resources = initialize_resources([resources])
@@ -332,22 +349,47 @@ class BaseROS2DeviceNode(Node, Generic[T]):
response = rclient.call(request) response = rclient.call(request)
# 应该先add_resource了 # 应该先add_resource了
res.response = "OK" res.response = "OK"
# 如果driver自己就有assign的方法那就使用driver自己的assign方法
if hasattr(self.driver_instance, "create_resource"):
create_resource_func = getattr(self.driver_instance, "create_resource")
create_resource_func(
resource_tracker=self.resource_tracker,
resources=request.resources,
bind_parent_id=bind_parent_id,
bind_location=location,
liquid_input_slot=LIQUID_INPUT_SLOT,
liquid_type=ADD_LIQUID_TYPE,
liquid_volume=LIQUID_VOLUME,
slot_on_deck=slot,
)
return res
# 接下来该根据bind_parent_id进行assign了目前只有plr可以进行assign不然没有办法输入到物料系统中 # 接下来该根据bind_parent_id进行assign了目前只有plr可以进行assign不然没有办法输入到物料系统中
resource = self.resource_tracker.figure_resource({"name": bind_parent_id}) resource = self.resource_tracker.figure_resource({"name": bind_parent_id})
request.resources = [convert_to_ros_msg(Resource, resources)] # request.resources = [convert_to_ros_msg(Resource, resources)]
try: try:
from pylabrobot.resources.resource import Resource as ResourcePLR from pylabrobot.resources.resource import Resource as ResourcePLR
from pylabrobot.resources.deck import Deck from pylabrobot.resources.deck import Deck
from pylabrobot.resources import Coordinate from pylabrobot.resources import Coordinate
from pylabrobot.resources import OTDeck from pylabrobot.resources import OTDeck
from pylabrobot.resources import Plate
contain_model = not isinstance(resource, Deck) contain_model = not isinstance(resource, Deck)
if isinstance(resource, ResourcePLR): if isinstance(resource, ResourcePLR):
# resources.list() # resources.list()
plr_instance = resource_ulab_to_plr(resources, contain_model) 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):
empty_liquid_info_in = [(None, 0)] * plr_instance.num_items
for liquid_type, liquid_volume, liquid_input_slot in zip(ADD_LIQUID_TYPE, LIQUID_VOLUME, LIQUID_INPUT_SLOT):
empty_liquid_info_in[liquid_input_slot] = (liquid_type, liquid_volume)
plr_instance.set_well_liquids(empty_liquid_info_in)
if isinstance(resource, OTDeck) and "slot" in other_calling_param: if isinstance(resource, OTDeck) and "slot" in other_calling_param:
resource.assign_child_at_slot(plr_instance, **other_calling_param) resource.assign_child_at_slot(plr_instance, **other_calling_param)
resource.assign_child_resource(plr_instance, Coordinate(location["x"], location["y"], location["z"]), **other_calling_param) else:
_discard_slot = other_calling_param.pop("slot", -1)
resource.assign_child_resource(plr_instance, Coordinate(location["x"], location["y"], location["z"]), **other_calling_param)
request2.resources = [convert_to_ros_msg(Resource, r) for r in tree_to_list([resource_plr_to_ulab(resource)])]
rclient2.call(request2)
# 发送给ResourceMeshManager # 发送给ResourceMeshManager
action_client = ActionClient( action_client = ActionClient(
self, SendCmd, "/devices/resource_mesh_manager/add_resource_mesh", callback_group=self.callback_group self, SendCmd, "/devices/resource_mesh_manager/add_resource_mesh", callback_group=self.callback_group
@@ -404,6 +446,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
# 加入全局注册表 # 加入全局注册表
registered_devices[self.device_id] = device_info registered_devices[self.device_id] = device_info
from unilabos.config.config import BasicConfig from unilabos.config.config import BasicConfig
from unilabos.ros.nodes.presets.host_node import HostNode
if not BasicConfig.is_host_mode: if not BasicConfig.is_host_mode:
sclient = self.create_client(SerialCommand, "/node_info_update") sclient = self.create_client(SerialCommand, "/node_info_update")
# 启动线程执行发送任务 # 启动线程执行发送任务
@@ -413,6 +456,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
daemon=True, daemon=True,
name=f"ROSDevice{self.device_id}_send_slave_node_info" name=f"ROSDevice{self.device_id}_send_slave_node_info"
).start() ).start()
else:
host_node = HostNode.get_instance(0)
if host_node is not None:
host_node.device_machine_names[self.device_id] = "本地"
def send_slave_node_info(self, sclient): def send_slave_node_info(self, sclient):
sclient.wait_for_service() sclient.wait_for_service()
@@ -481,6 +528,17 @@ class BaseROS2DeviceNode(Node, Generic[T]):
self.lab_logger().debug(f"发布动作: {action_name}, 类型: {str_action_type}") self.lab_logger().debug(f"发布动作: {action_name}, 类型: {str_action_type}")
def get_real_function(self, instance, attr_name):
if hasattr(instance.__class__, attr_name):
obj = getattr(instance.__class__, attr_name)
if isinstance(obj, property):
return lambda *args, **kwargs: obj.fset(instance, *args, **kwargs), get_type_hints(obj.fset)
obj = getattr(instance, attr_name)
return obj, get_type_hints(obj)
else:
obj = getattr(instance, attr_name)
return obj, get_type_hints(obj)
def _create_execute_callback(self, action_name, action_value_mapping): def _create_execute_callback(self, action_name, action_value_mapping):
"""创建动作执行回调函数""" """创建动作执行回调函数"""
@@ -495,22 +553,21 @@ class BaseROS2DeviceNode(Node, Generic[T]):
for i, action in enumerate(self._action_value_mappings["sequence"]): for i, action in enumerate(self._action_value_mappings["sequence"]):
if i == 0: if i == 0:
self.lab_logger().info(f"执行序列动作第一步: {action}") self.lab_logger().info(f"执行序列动作第一步: {action}")
getattr(self.driver_instance, action)(**kwargs) self.get_real_function(self.driver_instance, action)[0](**kwargs)
else: else:
self.lab_logger().info(f"执行序列动作后续步骤: {action}") self.lab_logger().info(f"执行序列动作后续步骤: {action}")
getattr(self.driver_instance, action)() self.get_real_function(self.driver_instance, action)[0]()
action_paramtypes = get_type_hints( action_paramtypes = get_type_hints(
getattr(self.driver_instance, self._action_value_mappings["sequence"][0]) self.get_real_function(self.driver_instance, self._action_value_mappings["sequence"][0])
) )[1]
else: else:
ACTION = getattr(self.driver_instance, action_name) ACTION, action_paramtypes = self.get_real_function(self.driver_instance, action_name)
action_paramtypes = get_type_hints(ACTION)
action_kwargs = convert_from_ros_msg_with_mapping(goal, action_value_mapping["goal"]) action_kwargs = convert_from_ros_msg_with_mapping(goal, action_value_mapping["goal"])
self.lab_logger().debug(f"接收到原始目标: {action_kwargs}") self.lab_logger().debug(f"接收到原始目标: {action_kwargs}")
# 向Host查询物料当前状态如果是host本身的增加物料的请求则直接跳过 # 向Host查询物料当前状态如果是host本身的增加物料的请求则直接跳过
if action_name != "add_resource_from_outer": if action_name not in ["create_resource_detailed", "create_resource"]:
for k, v in goal.get_fields_and_field_types().items(): for k, v in goal.get_fields_and_field_types().items():
if v in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]: if v in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]:
self.lab_logger().info(f"查询资源状态: Key: {k} Type: {v}") self.lab_logger().info(f"查询资源状态: Key: {k} Type: {v}")
@@ -609,7 +666,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
del future del future
# 向Host更新物料当前状态 # 向Host更新物料当前状态
if action_name != "add_resource_from_outer": if action_name not in ["create_resource_detailed", "create_resource"]:
for k, v in goal.get_fields_and_field_types().items(): for k, v in goal.get_fields_and_field_types().items():
if v not in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]: if v not in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]:
continue continue
@@ -748,7 +805,9 @@ class ROS2DeviceNode:
self.resource_tracker = DeviceNodeResourceTracker() self.resource_tracker = DeviceNodeResourceTracker()
# use_pylabrobot_creator 使用 cls的包路径检测 # use_pylabrobot_creator 使用 cls的包路径检测
use_pylabrobot_creator = driver_class.__module__.startswith("pylabrobot") or driver_class.__name__ == "DPLiquidHandler" use_pylabrobot_creator = (driver_class.__module__.startswith("pylabrobot")
or driver_class.__name__ == "LiquidHandlerAbstract"
or driver_class.__name__ == "LiquidHandlerBiomek")
# TODO: 要在创建之前预先请求服务器是否有当前id的物料放到resource_tracker中让pylabrobot进行创建 # TODO: 要在创建之前预先请求服务器是否有当前id的物料放到resource_tracker中让pylabrobot进行创建
# 创建设备类实例 # 创建设备类实例

View File

@@ -12,11 +12,18 @@ from rclpy.action import ActionClient, get_action_server_names_and_types_by_node
from rclpy.callback_groups import ReentrantCallbackGroup from rclpy.callback_groups import ReentrantCallbackGroup
from rclpy.service import Service from rclpy.service import Service
from unilabos_msgs.msg import Resource # type: ignore from unilabos_msgs.msg import Resource # type: ignore
from unilabos_msgs.srv import ResourceAdd, ResourceGet, ResourceDelete, ResourceUpdate, ResourceList, \ from unilabos_msgs.srv import (
SerialCommand # type: ignore ResourceAdd,
ResourceGet,
ResourceDelete,
ResourceUpdate,
ResourceList,
SerialCommand,
) # type: ignore
from unique_identifier_msgs.msg import UUID from unique_identifier_msgs.msg import UUID
from unilabos.registry.registry import lab_registry from unilabos.registry.registry import lab_registry
from unilabos.resources.graphio import initialize_resource
from unilabos.resources.registry import add_schema from unilabos.resources.registry import add_schema
from unilabos.ros.initialize_device import initialize_device_from_dict from unilabos.ros.initialize_device import initialize_device_from_dict
from unilabos.ros.msgs.message_converter import ( from unilabos.ros.msgs.message_converter import (
@@ -86,6 +93,7 @@ class HostNode(BaseROS2DeviceNode):
self.__class__._instance = self self.__class__._instance = self
# 初始化配置 # 初始化配置
self.server_latest_timestamp = 0.0 #
self.devices_config = devices_config self.devices_config = devices_config
self.resources_config = resources_config self.resources_config = resources_config
self.physical_setup_graph = physical_setup_graph self.physical_setup_graph = physical_setup_graph
@@ -99,9 +107,32 @@ class HostNode(BaseROS2DeviceNode):
# 创建设备、动作客户端和目标存储 # 创建设备、动作客户端和目标存储
self.devices_names: Dict[str, str] = {device_id: self.namespace} # 存储设备名称和命名空间的映射 self.devices_names: Dict[str, str] = {device_id: self.namespace} # 存储设备名称和命名空间的映射
self.devices_instances: Dict[str, ROS2DeviceNode] = {} # 存储设备实例 self.devices_instances: Dict[str, ROS2DeviceNode] = {} # 存储设备实例
self.device_machine_names: Dict[str, str] = {device_id: "本地", } # 存储设备ID到机器名称的映射 self.device_machine_names: Dict[str, str] = {
self._action_clients: Dict[str, ActionClient] = {} # 用来存储多个ActionClient实例 device_id: "本地",
self._action_value_mappings: Dict[str, Dict] = {} # 用来存储多个ActionClient的type, goal, feedback, result的变量名映射关系 } # 存储设备ID到机器名称的映射
self._action_clients: Dict[str, ActionClient] = { # 为了方便了解实际的数据类型host的默认写好
"/devices/host_node/create_resource": ActionClient(
self,
lab_registry.ResourceCreateFromOuterEasy,
"/devices/host_node/create_resource",
callback_group=self.callback_group,
),
"/devices/host_node/create_resource_detailed": ActionClient(
self,
lab_registry.ResourceCreateFromOuter,
"/devices/host_node/create_resource_detailed",
callback_group=self.callback_group,
),
"/devices/host_node/test_latency": ActionClient(
self,
lab_registry.EmptyIn,
"/devices/host_node/test_latency",
callback_group=self.callback_group,
),
} # 用来存储多个ActionClient实例
self._action_value_mappings: Dict[str, Dict] = (
{}
) # 用来存储多个ActionClient的type, goal, feedback, result的变量名映射关系
self._goals: Dict[str, Any] = {} # 用来存储多个目标的状态 self._goals: Dict[str, Any] = {} # 用来存储多个目标的状态
self._online_devices: Set[str] = {f"{self.namespace}/{device_id}"} # 用于跟踪在线设备 self._online_devices: Set[str] = {f"{self.namespace}/{device_id}"} # 用于跟踪在线设备
self._last_discovery_time = 0.0 # 上次设备发现的时间 self._last_discovery_time = 0.0 # 上次设备发现的时间
@@ -115,8 +146,11 @@ class HostNode(BaseROS2DeviceNode):
self.device_status_timestamps = {} # 用来存储设备状态最后更新时间 self.device_status_timestamps = {} # 用来存储设备状态最后更新时间
from unilabos.app.mq import mqtt_client from unilabos.app.mq import mqtt_client
for device_config in lab_registry.obtain_registry_device_info():
mqtt_client.publish_registry(device_config["id"], device_config) for device_info in lab_registry.obtain_registry_device_info():
mqtt_client.publish_registry(device_info["id"], device_info)
for resource_info in lab_registry.obtain_registry_resource_info():
mqtt_client.publish_registry(resource_info["id"], resource_info)
# 首次发现网络中的设备 # 首次发现网络中的设备
self._discover_devices() self._discover_devices()
@@ -141,12 +175,36 @@ class HostNode(BaseROS2DeviceNode):
].items(): ].items():
controller_config["update_rate"] = update_rate controller_config["update_rate"] = update_rate
self.initialize_controller(controller_id, controller_config) self.initialize_controller(controller_id, controller_config)
resources_config.insert(
0,
{
"id": "host_node",
"name": "host_node",
"parent": None,
"type": "device",
"class": "host_node",
"position": {"x": 0, "y": 0, "z": 0},
"config": {},
"data": {},
"children": [],
},
)
resource_with_parent_name = []
resource_ids_to_instance = {i["id"]: i for i in resources_config}
for res in resources_config:
if res.get("parent") and res.get("type") == "device" and res.get("class"):
parent_id = res.get("parent")
parent_res = resource_ids_to_instance[parent_id]
if parent_res.get("type") == "device" and parent_res.get("class"):
resource_with_parent_name.append(copy.deepcopy(res))
resource_with_parent_name[-1]["id"] = f"{parent_res['id']}/{res['id']}"
continue
resource_with_parent_name.append(copy.deepcopy(res))
try: try:
for bridge in self.bridges: for bridge in self.bridges:
if hasattr(bridge, "resource_add"): if hasattr(bridge, "resource_add"):
self.lab_logger().info("[Host Node-Resource] Adding resources to bridge.") self.lab_logger().info("[Host Node-Resource] Adding resources to bridge.")
bridge.resource_add(add_schema(resources_config)) resource_add_res = bridge.resource_add(add_schema(resource_with_parent_name))
except Exception as ex: except Exception as ex:
self.lab_logger().error("[Host Node-Resource] 添加物料出错!") self.lab_logger().error("[Host Node-Resource] 添加物料出错!")
self.lab_logger().error(traceback.format_exc()) self.lab_logger().error(traceback.format_exc())
@@ -156,6 +214,10 @@ class HostNode(BaseROS2DeviceNode):
discovery_interval, self._discovery_devices_callback, callback_group=ReentrantCallbackGroup() discovery_interval, self._discovery_devices_callback, callback_group=ReentrantCallbackGroup()
) )
# 添加ping-pong相关属性
self._ping_responses = {} # 存储ping响应
self._ping_lock = threading.Lock()
self.lab_logger().info("[Host Node] Host node initialized.") self.lab_logger().info("[Host Node] Host node initialized.")
HostNode._ready_event.set() HostNode._ready_event.set()
@@ -191,7 +253,7 @@ class HostNode(BaseROS2DeviceNode):
# 如果是新设备记录并创建ActionClient # 如果是新设备记录并创建ActionClient
if edge_device_id not in self.devices_names: if edge_device_id not in self.devices_names:
self.lab_logger().info(f"[Host Node] Discovered new device: {device_key}") self.lab_logger().info(f"[Host Node] Discovered new device: {edge_device_id}")
self.devices_names[edge_device_id] = namespace self.devices_names[edge_device_id] = namespace
self._create_action_clients_for_device(device_id, namespace) self._create_action_clients_for_device(device_id, namespace)
self._online_devices.add(device_key) self._online_devices.add(device_key)
@@ -200,7 +262,7 @@ class HostNode(BaseROS2DeviceNode):
target=self._send_re_register, target=self._send_re_register,
args=(sclient,), args=(sclient,),
daemon=True, daemon=True,
name=f"ROSDevice{self.device_id}_query_host_name_{namespace}" name=f"ROSDevice{self.device_id}_query_host_name_{namespace}",
).start() ).start()
elif device_key not in self._online_devices: elif device_key not in self._online_devices:
# 设备重新上线 # 设备重新上线
@@ -211,7 +273,7 @@ class HostNode(BaseROS2DeviceNode):
target=self._send_re_register, target=self._send_re_register,
args=(sclient,), args=(sclient,),
daemon=True, daemon=True,
name=f"ROSDevice{self.device_id}_query_host_name_{namespace}" name=f"ROSDevice{self.device_id}_query_host_name_{namespace}",
).start() ).start()
# 检测离线设备 # 检测离线设备
@@ -255,7 +317,7 @@ class HostNode(BaseROS2DeviceNode):
self, action_type, action_id, callback_group=self.callback_group self, action_type, action_id, callback_group=self.callback_group
) )
self.lab_logger().debug(f"[Host Node] Created ActionClient (Discovery): {action_id}") self.lab_logger().debug(f"[Host Node] Created ActionClient (Discovery): {action_id}")
action_name = action_id[len(namespace) + 1:] action_name = action_id[len(namespace) + 1 :]
edge_device_id = namespace[9:] edge_device_id = namespace[9:]
# from unilabos.app.mq import mqtt_client # from unilabos.app.mq import mqtt_client
# info_with_schema = ros_action_to_json_schema(action_type) # info_with_schema = ros_action_to_json_schema(action_type)
@@ -268,30 +330,84 @@ class HostNode(BaseROS2DeviceNode):
except Exception as e: except Exception as e:
self.lab_logger().error(f"[Host Node] Failed to create ActionClient for {action_id}: {str(e)}") self.lab_logger().error(f"[Host Node] Failed to create ActionClient for {action_id}: {str(e)}")
def add_resource_from_outer(self, resources: list["Resource"], device_ids: list[str], bind_parent_ids: list[str], bind_locations: list[Point], other_calling_params: list[str]): def create_resource_detailed(
for resource, device_id, bind_parent_id, bind_location, other_calling_param in zip(resources, device_ids, bind_parent_ids, bind_locations, other_calling_params): self,
resources: list["Resource"],
device_ids: list[str],
bind_parent_ids: list[str],
bind_locations: list[Point],
other_calling_params: list[str],
):
for resource, device_id, bind_parent_id, bind_location, other_calling_param in zip(
resources, device_ids, bind_parent_ids, bind_locations, other_calling_params
):
# 这里要求device_id传入必须是edge_device_id # 这里要求device_id传入必须是edge_device_id
namespace = "/devices/" + device_id namespace = "/devices/" + device_id
srv_address = f"/srv{namespace}/append_resource" srv_address = f"/srv{namespace}/append_resource"
sclient = self.create_client(SerialCommand, srv_address) sclient = self.create_client(SerialCommand, srv_address)
sclient.wait_for_service() sclient.wait_for_service()
request = SerialCommand.Request() request = SerialCommand.Request()
request.command = json.dumps({ request.command = json.dumps(
"resource": resource, {
"namespace": namespace, "resource": resource, # 单个/单组 可为 list[list[Resource]]
"edge_device_id": device_id, "namespace": namespace,
"bind_parent_id": bind_parent_id, "edge_device_id": device_id,
"bind_location": { "bind_parent_id": bind_parent_id,
"x": bind_location.x, "bind_location": {
"y": bind_location.y, "x": bind_location.x,
"z": bind_location.z, "y": bind_location.y,
"z": bind_location.z,
},
"other_calling_param": json.loads(other_calling_param) if other_calling_param else {},
}, },
"other_calling_param": json.loads(other_calling_param) if other_calling_param else {}, ensure_ascii=False,
}, ensure_ascii=False) )
response = sclient.call(request) response = sclient.call(request)
pass pass
pass pass
def create_resource(
self,
device_id: str,
res_id: str,
class_name: str,
parent: str,
bind_locations: Point,
liquid_input_slot: list[int],
liquid_type: list[str],
liquid_volume: list[int],
slot_on_deck: int,
):
init_new_res = initialize_resource(
{
"name": res_id,
"class": class_name,
"parent": parent,
"position": {
"x": bind_locations.x,
"y": bind_locations.y,
"z": bind_locations.z,
},
}
) # flatten的格式
resources = init_new_res # initialize_resource已经返回list[dict]
device_ids = [device_id]
bind_parent_id = [parent]
bind_location = [bind_locations]
other_calling_param = [
json.dumps(
{
"ADD_LIQUID_TYPE": liquid_type,
"LIQUID_VOLUME": liquid_volume,
"LIQUID_INPUT_SLOT": liquid_input_slot,
"initialize_full": False,
"slot": slot_on_deck,
}
)
]
return self.create_resource_detailed(resources, device_ids, bind_parent_id, bind_location, other_calling_param)
def initialize_device(self, device_id: str, device_config: Dict[str, Any]) -> None: def initialize_device(self, device_id: str, device_config: Dict[str, Any]) -> None:
""" """
根据配置初始化设备, 根据配置初始化设备,
@@ -319,7 +435,9 @@ class HostNode(BaseROS2DeviceNode):
if action_id not in self._action_clients: if action_id not in self._action_clients:
action_type = action_value_mapping["type"] action_type = action_value_mapping["type"]
self._action_clients[action_id] = ActionClient(self, action_type, action_id) self._action_clients[action_id] = ActionClient(self, action_type, action_id)
self.lab_logger().debug(f"[Host Node] Created ActionClient (Local): {action_id}") # 子设备再创建用的是Discover发现的 self.lab_logger().debug(
f"[Host Node] Created ActionClient (Local): {action_id}"
) # 子设备再创建用的是Discover发现的
# from unilabos.app.mq import mqtt_client # from unilabos.app.mq import mqtt_client
# info_with_schema = ros_action_to_json_schema(action_type) # info_with_schema = ros_action_to_json_schema(action_type)
# mqtt_client.publish_actions(action_name, { # mqtt_client.publish_actions(action_name, {
@@ -419,7 +537,12 @@ class HostNode(BaseROS2DeviceNode):
) )
def send_goal( def send_goal(
self, device_id: str, action_name: str, action_kwargs: Dict[str, Any], goal_uuid: Optional[str] = None self,
device_id: str,
action_name: str,
action_kwargs: Dict[str, Any],
goal_uuid: Optional[str] = None,
server_info: Optional[Dict[str, Any]] = None,
) -> None: ) -> None:
""" """
向设备发送目标请求 向设备发送目标请求
@@ -431,6 +554,8 @@ class HostNode(BaseROS2DeviceNode):
goal_uuid: 目标UUID如果为None则自动生成 goal_uuid: 目标UUID如果为None则自动生成
""" """
action_id = f"/devices/{device_id}/{action_name}" action_id = f"/devices/{device_id}/{action_name}"
if action_name == "test_latency" and server_info is not None:
self.server_latest_timestamp = server_info.get("send_timestamp", 0.0)
if action_id not in self._action_clients: if action_id not in self._action_clients:
self.lab_logger().error(f"[Host Node] ActionClient {action_id} not found.") self.lab_logger().error(f"[Host Node] ActionClient {action_id} not found.")
return return
@@ -725,3 +850,148 @@ class HostNode(BaseROS2DeviceNode):
# 这里可以实现返回资源列表的逻辑 # 这里可以实现返回资源列表的逻辑
self.lab_logger().debug(f"[Host Node-Resource] List parameters: {request}") self.lab_logger().debug(f"[Host Node-Resource] List parameters: {request}")
return response return response
def test_latency(self):
"""
测试网络延迟的action实现
通过5次ping-pong机制校对时间误差并计算实际延迟
"""
import time
import uuid as uuid_module
self.lab_logger().info("=" * 60)
self.lab_logger().info("开始网络延迟测试...")
# 记录任务开始执行的时间
task_start_time = time.time()
# 进行5次ping-pong测试
ping_results = []
for i in range(5):
self.lab_logger().info(f"{i+1}/5次ping-pong测试...")
# 生成唯一的ping ID
ping_id = str(uuid_module.uuid4())
# 记录发送时间
send_timestamp = time.time()
# 发送ping
from unilabos.app.mq import mqtt_client
mqtt_client.send_ping(ping_id, send_timestamp)
# 等待pong响应
timeout = 10.0
start_wait_time = time.time()
while time.time() - start_wait_time < timeout:
with self._ping_lock:
if ping_id in self._ping_responses:
pong_data = self._ping_responses.pop(ping_id)
break
time.sleep(0.001)
else:
self.lab_logger().error(f"❌ 第{i+1}次测试超时")
continue
# 计算本次测试结果
receive_timestamp = time.time()
client_timestamp = pong_data["client_timestamp"]
server_timestamp = pong_data["server_timestamp"]
# 往返时间
rtt_ms = (receive_timestamp - send_timestamp) * 1000
# 客户端与服务端时间差(客户端时间 - 服务端时间)
# 假设网络延迟对称,取中间点的服务端时间
mid_point_time = send_timestamp + (receive_timestamp - send_timestamp) / 2
time_diff_ms = (mid_point_time - server_timestamp) * 1000
ping_results.append({"rtt_ms": rtt_ms, "time_diff_ms": time_diff_ms})
self.lab_logger().info(f"✅ 第{i+1}次: 往返时间={rtt_ms:.2f}ms, 时间差={time_diff_ms:.2f}ms")
time.sleep(0.1)
if not ping_results:
self.lab_logger().error("❌ 所有ping-pong测试都失败了")
return {"status": "all_timeout"}
# 统计分析
rtts = [r["rtt_ms"] for r in ping_results]
time_diffs = [r["time_diff_ms"] for r in ping_results]
avg_rtt_ms = sum(rtts) / len(rtts)
avg_time_diff_ms = sum(time_diffs) / len(time_diffs)
max_time_diff_error_ms = max(abs(min(time_diffs)), abs(max(time_diffs)))
self.lab_logger().info("-" * 50)
self.lab_logger().info("[测试统计]")
self.lab_logger().info(f"有效测试次数: {len(ping_results)}/5")
self.lab_logger().info(f"平均往返时间: {avg_rtt_ms:.2f}ms")
self.lab_logger().info(f"平均时间差: {avg_time_diff_ms:.2f}ms")
self.lab_logger().info(f"时间差范围: {min(time_diffs):.2f}ms ~ {max(time_diffs):.2f}ms")
self.lab_logger().info(f"最大时间误差: ±{max_time_diff_error_ms:.2f}ms")
# 计算任务执行延迟
if hasattr(self, "server_latest_timestamp") and self.server_latest_timestamp > 0:
self.lab_logger().info("-" * 50)
self.lab_logger().info("[任务执行延迟分析]")
self.lab_logger().info(f"服务端任务下发时间: {self.server_latest_timestamp:.6f}")
self.lab_logger().info(f"客户端任务开始时间: {task_start_time:.6f}")
# 原始时间差(不考虑时间同步误差)
raw_delay_ms = (task_start_time - self.server_latest_timestamp) * 1000
# 考虑时间同步误差后的延迟(用平均时间差校正)
corrected_delay_ms = raw_delay_ms - avg_time_diff_ms
self.lab_logger().info(f"📊 原始时间差: {raw_delay_ms:.2f}ms")
self.lab_logger().info(f"🔧 时间同步校正: {avg_time_diff_ms:.2f}ms")
self.lab_logger().info(f"⏰ 实际任务延迟: {corrected_delay_ms:.2f}ms")
self.lab_logger().info(f"📏 误差范围: ±{max_time_diff_error_ms:.2f}ms")
# 给出延迟范围
min_delay = corrected_delay_ms - max_time_diff_error_ms
max_delay = corrected_delay_ms + max_time_diff_error_ms
self.lab_logger().info(f"📋 延迟范围: {min_delay:.2f}ms ~ {max_delay:.2f}ms")
else:
self.lab_logger().warning("⚠️ 无法获取服务端任务下发时间,跳过任务延迟分析")
corrected_delay_ms = -1
self.lab_logger().info("=" * 60)
return {
"avg_rtt_ms": avg_rtt_ms,
"avg_time_diff_ms": avg_time_diff_ms,
"max_time_error_ms": max_time_diff_error_ms,
"task_delay_ms": corrected_delay_ms if corrected_delay_ms > 0 else -1,
"raw_delay_ms": (
raw_delay_ms if hasattr(self, "server_latest_timestamp") and self.server_latest_timestamp > 0 else -1
),
"test_count": len(ping_results),
"status": "success",
}
def handle_pong_response(self, pong_data: dict):
"""
处理pong响应
"""
ping_id = pong_data.get("ping_id")
if ping_id:
with self._ping_lock:
self._ping_responses[ping_id] = pong_data
# 详细信息合并为一条日志
client_timestamp = pong_data.get("client_timestamp", 0)
server_timestamp = pong_data.get("server_timestamp", 0)
current_time = time.time()
self.lab_logger().debug(
f"📨 Pong | ID:{ping_id[:8]}.. | C→S→C: {client_timestamp:.3f}{server_timestamp:.3f}{current_time:.3f}"
)
else:
self.lab_logger().warning("⚠️ 收到无效的Pong响应缺少ping_id")

View File

@@ -13,6 +13,7 @@ endif()
if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
add_compile_options(-Wall -Wextra -Wpedantic) add_compile_options(-Wall -Wextra -Wpedantic)
add_compile_options(-include cstdint)
endif() endif()
# find dependencies # find dependencies
@@ -28,6 +29,8 @@ set(action_files
"action/HeatChillStart.action" "action/HeatChillStart.action"
"action/HeatChillStop.action" "action/HeatChillStop.action"
"action/LiquidHandlerProtocolCreation.action"
"action/LiquidHandlerAspirate.action" "action/LiquidHandlerAspirate.action"
"action/LiquidHandlerDiscardTips.action" "action/LiquidHandlerDiscardTips.action"
"action/LiquidHandlerDispense.action" "action/LiquidHandlerDispense.action"
@@ -42,15 +45,12 @@ set(action_files
"action/LiquidHandlerReturnTips96.action" "action/LiquidHandlerReturnTips96.action"
"action/LiquidHandlerStamp.action" "action/LiquidHandlerStamp.action"
"action/LiquidHandlerTransfer.action" "action/LiquidHandlerTransfer.action"
"action/LiquidHandlerTransferBiomek.action"
"action/DPLiquidHandlerAddLiquid.action" "action/LiquidHandlerAdd.action"
"action/DPLiquidHandlerCustomDelay.action" "action/LiquidHandlerMix.action"
"action/DPLiquidHandlerMix.action" "action/LiquidHandlerMoveTo.action"
"action/DPLiquidHandlerMoveTo.action" "action/LiquidHandlerRemove.action"
"action/DPLiquidHandlerRemoveLiquid.action"
"action/DPLiquidHandlerSetTiprack.action"
"action/DPLiquidHandlerTouchTip.action"
"action/DPLiquidHandlerTransferLiquid.action"
"action/EmptyIn.action" "action/EmptyIn.action"
"action/FloatSingleInput.action" "action/FloatSingleInput.action"
@@ -59,9 +59,10 @@ set(action_files
"action/Point3DSeparateInput.action" "action/Point3DSeparateInput.action"
"action/ResourceCreateFromOuter.action" "action/ResourceCreateFromOuter.action"
"action/ResourceCreateFromOuterEasy.action"
"action/SolidDispenseAddPowderTube.action" "action/SolidDispenseAddPowderTube.action"
"action/PumpTransfer.action" "action/PumpTransfer.action"
"action/Clean.action" "action/Clean.action"
"action/Separate.action" "action/Separate.action"

View File

@@ -1,6 +0,0 @@
float64 seconds
string msg
---
bool success
---
# 反馈

View File

@@ -1,5 +0,0 @@
Resource[] tip_racks
---
bool success
---
# 反馈

View File

@@ -1,5 +0,0 @@
Resource[] targets
---
bool success
---
# 反馈

View File

@@ -1,25 +0,0 @@
float64[] asp_vols
float64[] dis_vols
Resource[] sources
Resource[] targets
Resource[] tip_racks
int32[] use_channels
float64[] asp_flow_rates
float64[] dis_flow_rates
geometry_msgs/Point[] offsets
bool touch_tip
float64[] liquid_height
float64[] blow_out_air_volume
string spread
bool is_96_well
string mix_stage
int32[] mix_times
int32 mix_vol
int32 mix_rate
float64 mix_liquid_height
int32[] delays
string[] none_keys
---
bool success
---
# 反馈

View File

@@ -5,7 +5,7 @@ float64[] flow_rates
geometry_msgs/Point[] offsets geometry_msgs/Point[] offsets
float64[] liquid_height float64[] liquid_height
float64[] blow_out_air_volume float64[] blow_out_air_volume
string spread="wide" string spread
--- ---
bool success bool success
--- ---

View File

@@ -5,7 +5,7 @@ int32[] use_channels
float64[] flow_rates float64[] flow_rates
geometry_msgs/Point[] offsets geometry_msgs/Point[] offsets
int32[] blow_out_air_volume int32[] blow_out_air_volume
string spread="wide" string spread
--- ---
# 结果字段 # 结果字段
bool success bool success

View File

@@ -0,0 +1,9 @@
string protocol_name
string protocol_description
string protocol_version
string protocol_author
string protocol_date
string protocol_type
string[] none_keys
---
---

View File

@@ -1,11 +1,25 @@
# Bio float64[] asp_vols
Resource source float64[] dis_vols
Resource[] sources
Resource[] targets Resource[] targets
float64 source_vol Resource[] tip_racks
float64[] ratios int32[] use_channels
float64[] target_vols float64[] asp_flow_rates
float64 aspiration_flow_rate float64[] dis_flow_rates
float64[] dispense_flow_rates geometry_msgs/Point[] offsets
bool touch_tip
float64[] liquid_height
float64[] blow_out_air_volume
string spread
bool is_96_well
string mix_stage
int32[] mix_times
int32 mix_vol
int32 mix_rate
float64 mix_liquid_height
int32[] delays
string[] none_keys
--- ---
bool success bool success
--- ---
# 反馈

View File

@@ -0,0 +1,10 @@
string source
string target
string tip_rack
float64 volume
string aspirate_technique
string dispense_technique
---
---

View File

@@ -0,0 +1,12 @@
string res_id
string device_id
string class_name
string parent
geometry_msgs/Point bind_locations
int32[] liquid_input_slot
string[] liquid_type
float32[] liquid_volume
int32 slot_on_deck
---
bool success
---