diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..dfe07704 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..5ff8b0f7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,234 @@ +## Python + +# Byte-compiled / optimized / DLL files +__pycache__/ +.idea +.vscode +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + + +## ROS + +devel/ +logs/ +log/ +build/ +bin/ +lib/ +install/ +*/devel/ +*/logs/ +*/log/ +*/build/ +*/bin/ +*/lib/ +*/install/ +msg_gen/ +srv_gen/ +msg/*Action.msg +msg/*ActionFeedback.msg +msg/*ActionGoal.msg +msg/*ActionResult.msg +msg/*Feedback.msg +msg/*Goal.msg +msg/*Result.msg +msg/_*.py +build_isolated/ +devel_isolated/ + +# Generated by dynamic reconfigure +*.cfgc +/cfg/cpp/ +/cfg/*.py + +# Ignore generated docs +*.dox +*.wikidoc + +# eclipse stuff +.project +.cproject + +# qcreator stuff +CMakeLists.txt.user + +srv/_*.py +*.pcd +*.pyc +qtcreator-* +*.user + +/planning/cfg +/planning/docs +/planning/src + +*~ + +# Emacs +.#* + +# Catkin custom files +CATKIN_IGNORE + +.DS_Store + +local_config.py + +*.graphml \ No newline at end of file diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 00000000..d4bb2cbb --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/boot_examples/index.md b/docs/boot_examples/index.md new file mode 100644 index 00000000..f02f2406 --- /dev/null +++ b/docs/boot_examples/index.md @@ -0,0 +1,10 @@ +# 启动样例 + +本章节提供了几个典型的启动样例,帮助您快速了解和使用系统。每个样例都包含了详细的配置说明、文件解读以及操作步骤,便于您参考和实践。 + +```{toctree} +:maxdepth: 1 + +liquid_handler.md +organic_synthesis.md +``` \ No newline at end of file diff --git a/docs/boot_examples/liquid_handler.md b/docs/boot_examples/liquid_handler.md new file mode 100644 index 00000000..707b1551 --- /dev/null +++ b/docs/boot_examples/liquid_handler.md @@ -0,0 +1,100 @@ +# 移液站样例 + +本样例介绍如何配置和启动移液站设备,并执行基本操作如插入枪头等。 + +## 准备工作 + +### 设备配置文件 + +移液站设备的完整配置可在 `test/experiments/plr_test.json` 文件中找到。该配置文件采用平展结构,通过 `type` 字段区分物料和设备,并通过 `parent` 和 `children` 字段实现层级关系。 + +配置文件示例片段: + +```json +{ + "nodes": [ + { + "id": "PLR_STATION", + "name": "PLR_LH_TEST", + "parent": null, + "type": "device", + "class": "liquid_handler", + "config": {}, + "data": {}, + "children": [ + "deck" + ] + }, + { + "id": "deck", + "name": "deck", + "type": "container", + "class": null, + "parent": "PLR_STATION", + "children": [ + "trash", + "trash_core96", + "teaching_carrier", + "tip_rack", + "plate" + ] + } + ], + "links": [] +} +``` + +配置文件定义了移液站的组成部分,主要包括: +- 移液站本体(LiquidHandler)- 设备类型 +- 移液站携带物料实例(deck)- 物料类型 + +## 启动方法 + +### 1. 启动移液站节点 + +使用以下命令启动移液站设备: + +```bash +unilab -g test/experiments/plr_test.json --app_bridges "" +``` + +### 2. 执行枪头插入操作 + +启动后,您可以使用以下命令执行插入枪头操作: + +```bash +ros2 action send_goal /devices/PLR_STATION/pick_up_tips unilabos_msgs/action/_liquid_handler_pick_up_tips/LiquidHandlerPickUpTips "{ tip_spots: [ { id: 'tip_rack_tipspot_0_0', name: 'tip_rack_tipspot_0_0', sample_id: null, children: [], parent: 'tip_rack', type: 'device', config: { position: { x: 7.2, y: 68.3, z: -83.5 }, size_x: 9.0, size_y: 9.0, size_z: 0, rotation: { x: 0, y: 0, z: 0, type: 'Rotation' }, category: 'tip_spot', model: null, type: 'TipSpot', prototype_tip: { type: 'HamiltonTip', total_tip_length: 95.1, has_filter: true, maximal_volume: 1065, pickup_method: 'OUT_OF_RACK', tip_size: 'HIGH_VOLUME' } }, data: { tip: { type: 'HamiltonTip', total_tip_length: 95.1, has_filter: true, maximal_volume: 1065, pickup_method: 'OUT_OF_RACK', tip_size: 'HIGH_VOLUME' }, tip_state: { liquids: [], pending_liquids: [], liquid_history: [] }, pending_tip: { type: 'HamiltonTip', total_tip_length: 95.1, has_filter: true, maximal_volume: 1065, pickup_method: 'OUT_OF_RACK', tip_size: 'HIGH_VOLUME' } } } ], use_channels: [ 0 ], offsets: [ { x: 0.0, y: 0.0, z: 0.0 } ] }" +``` + +此命令会通过ros通信触发移液站执行枪头插入操作,得到如下的PyLabRobot的输出日志。 + +```log +Picking up tips: +pip# resource offset tip type max volume (µL) fitting depth (mm) tip length (mm) filter + p0: tip_rack_tipspot_0_0 0.0,0.0,0.0 HamiltonTip 1065 8 95.1 Yes +``` + +## 常见问题 + +1. **重复插入枪头不成功**:操作编排应该符合实际操作顺序,可自行通过PyLabRobot进行测试 + +## 移液站支持的操作 + +移液站支持多种操作,以下是当前系统支持的操作列表: + +1. **LiquidHandlerAspirate** - 吸液操作 +2. **LiquidHandlerDispense** - 排液操作 +3. **LiquidHandlerDiscardTips** - 丢弃枪头 +4. **LiquidHandlerDropTips** - 卸下枪头 +5. **LiquidHandlerDropTips96** - 卸下96通道枪头 +6. **LiquidHandlerMoveLid** - 移动盖子 +7. **LiquidHandlerMovePlate** - 移动板子 +8. **LiquidHandlerMoveResource** - 移动资源 +9. **LiquidHandlerPickUpTips** - 插入枪头 +10. **LiquidHandlerPickUpTips96** - 插入96通道枪头 +11. **LiquidHandlerReturnTips** - 归还枪头 +12. **LiquidHandlerReturnTips96** - 归还96通道枪头 +13. **LiquidHandlerStamp** - 打印标记 +14. **LiquidHandlerTransfer** - 液体转移 + +这些操作可通过ROS2 Action接口进行调用,以实现复杂的移液流程。 \ No newline at end of file diff --git a/docs/boot_examples/organic_synthesis.md b/docs/boot_examples/organic_synthesis.md new file mode 100644 index 00000000..31b83df7 --- /dev/null +++ b/docs/boot_examples/organic_synthesis.md @@ -0,0 +1,105 @@ +# 有机常量合成样例 + +本样例演示如何配置和操作有机常量合成工作站,实现抽真空和充气等基本操作。 + +## 准备工作 + +### 设备配置文件 + +有机常量合成工作站的完整配置可在 `test/experiments/mock_reactor.json` 文件中找到。该配置文件采用平展结构,通过 `type` 字段区分物料和设备,并通过 `parent` 和 `children` 字段实现层级关系。 + +配置文件示例片段: + +```json +{ + "nodes": [ + { + "id": "ReactorX", + "children": [ + "reactor", + "vacuum_valve", + "gas_valve", + "vacuum_pump", + "gas_source" + ], + "parent": null, + "type": "device", + "class": "workstation" + }, + { + "id": "reactor", + "parent": "ReactorX", + "type": "container" + }, + { + "id": "vacuum_valve", + "parent": "ReactorX", + "type": "device" + }, + { + "id": "gas_valve", + "parent": "ReactorX", + "type": "device" + }, + { + "id": "vacuum_pump", + "parent": "ReactorX", + "type": "device" + }, + { + "id": "gas_source", + "parent": "ReactorX", + "type": "device" + } + ], + "links": [ + { + "source": "reactor", + "target": "vacuum_valve" + }, + { + "source": "reactor", + "target": "gas_valve" + }, + { + "source": "vacuum_pump", + "target": "vacuum_valve" + }, + { + "source": "gas_source", + "target": "gas_valve" + } + ] +} +``` + +配置文件定义了反应系统的组成部分,主要包括: + +1. **反应工作站 (ReactorX)** - 整个系统的父节点,包含所有子组件 +2. **反应器 (reactor)** - 实际进行反应的容器 +3. **真空阀 (vacuum_valve)** - 连接反应器和真空泵 +4. **气体阀 (gas_valve)** - 连接反应器和气源 +5. **真空泵 (vacuum_pump)** - 用于抽真空 +6. **气源 (gas_source)** - 提供充气 + +这些组件通过链接关系形成一个完整的气路系统,可以实现抽真空和充气的功能。 + +## 启动方法 + +### 1. 启动反应器节点 + +使用以下命令启动模拟反应器: + +```bash +unilab -g test/experiments/mock_reactor.json --app_bridges "" +``` + +### 2. 执行抽真空和充气操作 + +启动后,您可以使用以下命令执行抽真空操作: + +```bash +ros2 action send_goal /devices/ReactorX/EvacuateAndRefillProtocol unilabos_msgs/action/EvacuateAndRefill "{vessel: reactor, gas: N2, repeats: 2}" +``` + +此命令会通过ros通信触发工作站执行抽真空和充气的协议操作,与此同时,您可以通过 http://localhost:8002/status 在`主机节点信息`-`设备状态`查看该操作对设备开关的实时效果。 diff --git a/docs/concepts/01-communication-instruction.md b/docs/concepts/01-communication-instruction.md new file mode 100644 index 00000000..09e69754 --- /dev/null +++ b/docs/concepts/01-communication-instruction.md @@ -0,0 +1,45 @@ +(instructions)= +# 设备抽象、指令集与通信中间件 + +Uni-Lab 操作系统的目的是将不同类型和厂家的实验仪器进行抽象统一,对应用层提供服务。因此,理清实验室设备之间的业务逻辑至关重要。 + +## 设备间通信模式 + +### **[话题(一对多发送,一对多订阅)](https://book.guyuehome.com/ROS2/2.%E6%A0%B8%E5%BF%83%E6%A6%82%E5%BF%B5/2.4_%E8%AF%9D%E9%A2%98/)** + +典型的话题通信有: + +* 传感器连续发送设备状态和数据; +* 连续时间控制器发送控制指令,如控温、连续称量、机械臂轨迹跟随、视觉识别操作等 + +![](image/01-communication-instruction/topic.gif) + +### **[服务(短时请求与响应)](https://book.guyuehome.com/ROS2/2.%E6%A0%B8%E5%BF%83%E6%A6%82%E5%BF%B5/2.5_%E6%9C%8D%E5%8A%A1/)** + +典型的服务通信有: + +* 查/改全局参数如物料、设备 +* 使用其他通信接口发送/接收数据 + +![](image/01-communication-instruction/service.gif) + +### **[动作(长时任务启动,随后连续收到反馈值,直到达到目标)](https://book.guyuehome.com/ROS2/2.%E6%A0%B8%E5%BF%83%E6%A6%82%E5%BF%B5/2.7_%E5%8A%A8%E4%BD%9C/)** + +动作机制主要用于处理运行时长较长的单点任务或任务组合,如: + +* 执行工作流 +* 执行工作流的子动作 + +![](image/01-communication-instruction/action.gif) + +## 通信指令集 + +Uni-Lab 目前使用 ROS2 作为通信中间件,因此大量使用其标准消息作为话题、服务、动作。新增指令位于仓库中的 `unilabos_msgs` ,各类实验动作指令集分类整理于 {ref}`actions` + +## 通信中间件层 + +通信中间件层的一个重要设计思想是:将业务逻辑开发,与实际部署中的通信和运行解耦。开发者在实现具体业务逻辑时,可以不用关心最终运行时的 **部署方式** 、 **通信方式** 。当用户开发完成后,再根据实际情况决定部署、通信方案。 + +* 对于 **“流动化学实验室”和“桌面机器人”** 来说,一台电脑通过串口控制所有设备足够。**此时在这台电脑启动 Uni-Lab 作为 Server 即可。** +* 对于 **“移动机器人”大型实验室** ,典型场景是,一个实验室由多台不同位置的工作站组成,每台大型设备有一台工控机,通过串口再控制子设备。同时有 AGV/机械臂 负责转运。**此时,在每台工控机启动 Uni-Lab,完成通信中间件层的包装之后,只要处于同一局域网下,他们将能自动互相发现并组成分布式的“Uni-Lab-Edge Server”。** +* 通信中间件层的分布式机制,使得 Node 之间做好了隔离,一台设备故障时只需重启单个 Node。很像微服务、微内核的设计理念。 diff --git a/docs/concepts/02-topology-and-chemputer-compile.md b/docs/concepts/02-topology-and-chemputer-compile.md new file mode 100644 index 00000000..9e40bad9 --- /dev/null +++ b/docs/concepts/02-topology-and-chemputer-compile.md @@ -0,0 +1,79 @@ +(graph)= +# 实验室组态图 + +组态(configuration)图是指在自动化领域中,用来描述和展示控制系统中各个组件之间关系的图形化表示方法。 +它是一个系统的框架图,通过图形符号和连接线,将各个组件(如传感器、执行器、控制器等)以及它们之间的关系进行可视化展示。 + +Uni-Lab 的组态图当前支持 node-link json 和 graphml 格式,其中包含4类重要信息: + +* 单个设备/物料配置,即图中节点的参数; +* 父子关系,如一台工作站包含它的多个子设备、放置着多个物料耗材; +* 物理连接关系,如流体管路连接、AGV/机械臂/直线模组转运连接。 +* 通信转接关系,如多个 IO 设备通过 IO 板卡或 PLC 转为 Modbus;串口转网口等 +* 控制逻辑关系,如某个输出量被某个输入量 PID 控制 + +## 父子关系、物质流与"编译"操作 + +在计算机操作系统下,软件操作数据和文件。在实验操作系统下,实验“软件”利用仪器“硬件”操作物质。实验人员能理解的操作,最终都是对物质的处理。将实验步骤,转化为硬件指令,这个操作我们可以类比为“编译”。 + +对用户来说,“直接操作设备执行单个指令”不是个真实需求,真正的需求是**“执行对实验有意义的单个完整动作”——加入某种液体多少量;萃取分液;洗涤仪器等等。就像实验步骤文字书写的那样。** + +而这些对实验有意义的单个完整动作,**一般需要多个设备的协同**,还依赖于他们的**物理连接关系(管道相连;机械臂可转运)**。 +于是 Uni-Lab 实现了抽象的“工作站”,即注册表中的 `workstation` 设备(`ProtocolNode`类)来处理编译、规划操作。以泵骨架组成的自动有机实验室为例,设备管道连接关系如下: + +![topology](image/02-topology-and-chemputer-compile/topology.png) + +接收“移液”动作,编译为一系列泵指令和阀指令 + +```text +Goal received: { + 'from_vessel': 'flask_acetone', + 'to_vessel': 'reactor', + 'volume': 2000.0, + 'flowrate': 100.0 +}, running steps: +``` + +```JSON +[ +{ + "device_id": "pump_reagents", + "action_name": "set_valve_position", + "action_kwargs": {"command": "3"} +}, +{ + "device_id": "pump_reagents", + "action_name": "set_position", + "action_kwargs": { + "position": 2000.0, + "max_velocity": 100.0 + } +}, +{ + "device_id": "pump_reagents", + "action_name": "set_valve_position", + "action_kwargs": {"command": '5'} +}, +{ + "device_id": "pump_reagents", + "action_name": "set_position", + "action_kwargs": { + "position": 0.0, + "max_velocity": 100.0 + } +} +] +``` + +若想开发新的“编译”/“规划”功能,在 `unilabos/compilers` 实现一个新函数即可。详情请见 [添加新实验操作(Protocol)](../developer_guide/add_protocol.md) + +## 通信转接关系 + +Uni-Lab 秉持着**通信逻辑**与**业务逻辑**分离的设计理念,以追求实验设备、通信设备最大的代码复用性。 + +如对 IO 板卡8路 IO 控制的8个电磁阀,经过设备抽象层后,仅有逻辑意义上分立的8个电磁阀。在组态图中,他们通过代表通信关系的边(edge)与 IO 板卡设备相连,边的属性包含 IO 地址。 +在实际使用中,对抽象的电磁阀发送开关动作请求,将通过 Uni-Lab 设备抽象&通信中间件转化为对 IO 板卡发送 IO 读写指令。因此一定意义上,组态图的通信转接部分代表了简化的电气接线图。 + +代码架构上,实验设备位于 Uni-Lab 仓库/注册表的 `devices` 目录,通信设备位于 Uni-Lab 仓库/注册表的 `device_comms` 目录。 + +![configuration](image/01-communication-instruction/configuration.png) diff --git a/docs/concepts/image/01-communication-instruction/action.gif b/docs/concepts/image/01-communication-instruction/action.gif new file mode 100644 index 00000000..da078bd3 Binary files /dev/null and b/docs/concepts/image/01-communication-instruction/action.gif differ diff --git a/docs/concepts/image/01-communication-instruction/configuration.png b/docs/concepts/image/01-communication-instruction/configuration.png new file mode 100644 index 00000000..3fd8ed28 Binary files /dev/null and b/docs/concepts/image/01-communication-instruction/configuration.png differ diff --git a/docs/concepts/image/01-communication-instruction/service.gif b/docs/concepts/image/01-communication-instruction/service.gif new file mode 100644 index 00000000..74af1bb7 Binary files /dev/null and b/docs/concepts/image/01-communication-instruction/service.gif differ diff --git a/docs/concepts/image/01-communication-instruction/topic.gif b/docs/concepts/image/01-communication-instruction/topic.gif new file mode 100644 index 00000000..ab802ce5 Binary files /dev/null and b/docs/concepts/image/01-communication-instruction/topic.gif differ diff --git a/docs/concepts/image/02-topology-and-chemputer-compile/topology.png b/docs/concepts/image/02-topology-and-chemputer-compile/topology.png new file mode 100644 index 00000000..cde018b6 Binary files /dev/null and b/docs/concepts/image/02-topology-and-chemputer-compile/topology.png differ diff --git a/docs/concepts/image/overview/Uni-Lab-layers.png b/docs/concepts/image/overview/Uni-Lab-layers.png new file mode 100644 index 00000000..92ee2bb3 Binary files /dev/null and b/docs/concepts/image/overview/Uni-Lab-layers.png differ diff --git a/docs/concepts/image/overview/Uni-Lab-whiteboard.png b/docs/concepts/image/overview/Uni-Lab-whiteboard.png new file mode 100644 index 00000000..5b749c7e Binary files /dev/null and b/docs/concepts/image/overview/Uni-Lab-whiteboard.png differ diff --git a/docs/concepts/overview.md b/docs/concepts/overview.md new file mode 100644 index 00000000..ba55aa21 --- /dev/null +++ b/docs/concepts/overview.md @@ -0,0 +1,3 @@ +# Uni-Lab 操作系统总览 + +![Layers](image/overview/Uni-Lab-layers.png)![Layers](image/overview/Uni-Lab-whiteboard.png) \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 00000000..a6dc55a9 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,205 @@ +# Configuration file for the Sphinx documentation builder. +# Sphinx 文档生成器的配置文件 +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +import os +import sys + +# 将项目的根目录添加到 sys.path 中,以便 Sphinx 能够找到 unilabos 包 +sys.path.insert(0, os.path.abspath("..")) + +project = "Uni-Lab" +copyright = "2025, Uni-Lab Community, DP Technology & Peking University" +author = "Uni-Lab Community, DP Technology & Peking University" + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + "myst_parser", + "sphinx.ext.autodoc", + "sphinx.ext.napoleon", # 如果您使用 Google 或 NumPy 风格的 docstrings + "sphinx_rtd_theme" +] + +source_suffix = { + ".rst": "restructuredtext", + ".txt": "markdown", + ".md": "markdown", +} + +myst_enable_extensions = [ + "colon_fence", + "deflist", + "dollarmath", + "html_image", + "replacements", + "smartquotes", + "substitution", +] + +templates_path = ["_templates"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +language = "zh" + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +# 设置 HTML 主题为 sphinx-book-theme +html_theme = "sphinx_rtd_theme" + +# sphinx-book-theme 主题选项 +html_theme_options = { + "repository_url": "https://github.com/用户名/Uni-Lab", + "use_repository_button": True, + "use_issues_button": True, + "use_edit_page_button": True, + "use_download_button": True, + "path_to_docs": "docs", + "show_navbar_depth": 2, + "show_toc_level": 2, + "home_page_in_toc": True, + "logo_only": False, +} + +# 设置 HTML 文档的静态文件路径 +html_static_path = ["_static"] # 如果有自定义 CSS,可以放在 _static 目录中 + +section_titles = { + "Simple": "## 简单单变量动作函数", + "Organic": """## 常量有机化学操作 + +Uni-Lab 常量有机化学指令集多数来自 [XDL](https://croningroup.gitlab.io/chemputer/xdl/standard/full_steps_specification.html#),包含有机合成实验中常见的操作,如加热、搅拌、冷却等。 +""", + "Bio": """## 移液工作站及相关生物自动化设备操作 + +Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.org/user_guide/index.html),包含生物实验中常见的操作,如移液、混匀、离心等。 +""", + "MobileRobot": "## 多工作站及小车运行、物料转移", + "Robot": """## 机械臂、夹爪等机器人设备 + +Uni-Lab 机械臂、机器人、夹爪和导航指令集沿用 ROS2 的 `control_msgs` 和 `nav2_msgs`: +""", +} + +import os +from pathlib import Path + + +def get_conda_share_dir(package_name=None): + """获取 Conda 环境的 share 目录路径 + + :param package_name: 可选参数,指定具体包的 share 子目录 + :return: Path 对象或 None + """ + # 获取当前 Conda 环境根目录 + conda_prefix = os.getenv("CONDA_PREFIX") + if not conda_prefix: + raise EnvironmentError("未检测到激活的 Conda 环境") + + # 构建基础 share 目录路径 + share_dir = Path(conda_prefix) / "share" + + # 如果指定了包名,追加包子目录 + if package_name: + share_dir = share_dir / package_name + + # 验证路径是否存在 + if not share_dir.exists(): + print(f"警告: 路径 {share_dir} 不存在") + return None + + return share_dir + + +def generate_action_includes(app): + src_dir = Path(app.srcdir) + print(f"Generating action includes for {src_dir}") + action_dir = src_dir.parent / "unilabos_msgs" / "action" # 修改为你的实际路径 + output_file = src_dir / "developer_guide" / "action_includes.md" + + # 确保输出目录存在 + output_file.parent.mkdir(exist_ok=True) + + # 初始化各部分内容 + sections = {} + + # 仅处理本地消息文件 + if action_dir.exists(): + for action_file in sorted(action_dir.glob("*.action")): + # 获取相对路径 + rel_path = f"../../unilabos_msgs/action/{action_file.name}" + # 读取首行注释 + try: + with open(action_file, "r", encoding="utf-8") as af: + first_line = af.readline().strip() + # 提取注释内容(去除#和空格) + section = first_line.lstrip("#").strip() + + text = f""" +### `{action_file.stem}` + +```{{literalinclude}} {rel_path} +:language: yaml +``` + +---- +""" + + if sections.get(section) is None: + sections[section] = text + else: + sections[section] += text + except Exception as e: + print(f"处理文件 {action_file} 时出错: {e}") + else: + print(f"警告: 动作消息目录 {action_dir} 不存在") + + ros_action_dirs = [] + control_msgs_dir = get_conda_share_dir("control_msgs") + nav2_msgs_dir = get_conda_share_dir("nav2_msgs") + + if control_msgs_dir is not None: + ros_action_dirs.append(control_msgs_dir / "action") + if nav2_msgs_dir is not None: + ros_action_dirs.append(nav2_msgs_dir / "action") + + for action_dir in ros_action_dirs: + for action_file in sorted(action_dir.glob("*.action")): + # 获取相对路径 + rel_path = f"{action_file.absolute()}" + # 读取首行注释 + with open(action_file, "r", encoding="utf-8") as af: + # 提取注释内容(去除#和空格) + section = "Robot" + + text = f"""### `{action_file.stem}` + +```yaml +{open(rel_path, 'r').read()} +``` + +---- +""" + if sections.get(section) is None: + sections[section] = text + else: + sections[section] += text + + # 写入内容到输出文件 + with open(output_file, "w", encoding="utf-8") as f: + # 按 Section 生成总文档 + for section, title in section_titles.items(): + content = sections.get(section, "") + if content: # 只有有内容时才写入标题和内容 + f.write(f"{title}\n\n") + f.write(content) + + +def setup(app): + app.connect("builder-inited", generate_action_includes) diff --git a/docs/developer_guide/action_includes.md b/docs/developer_guide/action_includes.md new file mode 100644 index 00000000..4111fb98 --- /dev/null +++ b/docs/developer_guide/action_includes.md @@ -0,0 +1,577 @@ +## 简单单变量动作函数 + + +### `SendCmd` + +```{literalinclude} ../../unilabos_msgs/action/SendCmd.action +:language: yaml +``` + +---- +## 常量有机化学操作 + +Uni-Lab 常量有机化学指令集多数来自 [XDL](https://croningroup.gitlab.io/chemputer/xdl/standard/full_steps_specification.html#),包含有机合成实验中常见的操作,如加热、搅拌、冷却等。 + + + +### `Clean` + +```{literalinclude} ../../unilabos_msgs/action/Clean.action +:language: yaml +``` + +---- + +### `EvacuateAndRefill` + +```{literalinclude} ../../unilabos_msgs/action/EvacuateAndRefill.action +:language: yaml +``` + +---- + +### `Evaporate` + +```{literalinclude} ../../unilabos_msgs/action/Evaporate.action +:language: yaml +``` + +---- + +### `HeatChill` + +```{literalinclude} ../../unilabos_msgs/action/HeatChill.action +:language: yaml +``` + +---- + +### `HeatChillStart` + +```{literalinclude} ../../unilabos_msgs/action/HeatChillStart.action +:language: yaml +``` + +---- + +### `HeatChillStop` + +```{literalinclude} ../../unilabos_msgs/action/HeatChillStop.action +:language: yaml +``` + +---- + +### `PumpTransfer` + +```{literalinclude} ../../unilabos_msgs/action/PumpTransfer.action +:language: yaml +``` + +---- + +### `Separate` + +```{literalinclude} ../../unilabos_msgs/action/Separate.action +:language: yaml +``` + +---- + +### `Stir` + +```{literalinclude} ../../unilabos_msgs/action/Stir.action +:language: yaml +``` + +---- +## 移液工作站及相关生物自动化设备操作 + +Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.org/user_guide/index.html),包含生物实验中常见的操作,如移液、混匀、离心等。 + + + +### `LiquidHandlerAspirate` + +```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerAspirate.action +:language: yaml +``` + +---- + +### `LiquidHandlerDiscardTips` + +```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerDiscardTips.action +:language: yaml +``` + +---- + +### `LiquidHandlerDispense` + +```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerDispense.action +:language: yaml +``` + +---- + +### `LiquidHandlerDropTips` + +```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerDropTips.action +:language: yaml +``` + +---- + +### `LiquidHandlerDropTips96` + +```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerDropTips96.action +:language: yaml +``` + +---- + +### `LiquidHandlerMoveLid` + +```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerMoveLid.action +:language: yaml +``` + +---- + +### `LiquidHandlerMovePlate` + +```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerMovePlate.action +:language: yaml +``` + +---- + +### `LiquidHandlerMoveResource` + +```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerMoveResource.action +:language: yaml +``` + +---- + +### `LiquidHandlerPickUpTips` + +```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerPickUpTips.action +:language: yaml +``` + +---- + +### `LiquidHandlerPickUpTips96` + +```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerPickUpTips96.action +:language: yaml +``` + +---- + +### `LiquidHandlerReturnTips` + +```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerReturnTips.action +:language: yaml +``` + +---- + +### `LiquidHandlerReturnTips96` + +```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerReturnTips96.action +:language: yaml +``` + +---- + +### `LiquidHandlerStamp` + +```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerStamp.action +:language: yaml +``` + +---- + +### `LiquidHandlerTransfer` + +```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerTransfer.action +:language: yaml +``` + +---- +## 多工作站及小车运行、物料转移 + + +### `AGVTransfer` + +```{literalinclude} ../../unilabos_msgs/action/AGVTransfer.action +:language: yaml +``` + +---- + +### `WorkStationRun` + +```{literalinclude} ../../unilabos_msgs/action/WorkStationRun.action +:language: yaml +``` + +---- +## 机械臂、夹爪等机器人设备 + +Uni-Lab 机械臂、机器人、夹爪和导航指令集沿用 ROS2 的 `control_msgs` 和 `nav2_msgs`: + + +### `FollowJointTrajectory` + +```yaml +# The trajectory for all revolute, continuous or prismatic joints +trajectory_msgs/JointTrajectory trajectory +# The trajectory for all planar or floating joints (i.e. individual joints with more than one DOF) +trajectory_msgs/MultiDOFJointTrajectory multi_dof_trajectory + +# Tolerances for the trajectory. If the measured joint values fall +# outside the tolerances the trajectory goal is aborted. Any +# tolerances that are not specified (by being omitted or set to 0) are +# set to the defaults for the action server (often taken from the +# parameter server). + +# Tolerances applied to the joints as the trajectory is executed. If +# violated, the goal aborts with error_code set to +# PATH_TOLERANCE_VIOLATED. +JointTolerance[] path_tolerance +JointComponentTolerance[] component_path_tolerance + +# To report success, the joints must be within goal_tolerance of the +# final trajectory value. The goal must be achieved by time the +# trajectory ends plus goal_time_tolerance. (goal_time_tolerance +# allows some leeway in time, so that the trajectory goal can still +# succeed even if the joints reach the goal some time after the +# precise end time of the trajectory). +# +# If the joints are not within goal_tolerance after "trajectory finish +# time" + goal_time_tolerance, the goal aborts with error_code set to +# GOAL_TOLERANCE_VIOLATED +JointTolerance[] goal_tolerance +JointComponentTolerance[] component_goal_tolerance +builtin_interfaces/Duration goal_time_tolerance + +--- +int32 error_code +int32 SUCCESSFUL = 0 +int32 INVALID_GOAL = -1 +int32 INVALID_JOINTS = -2 +int32 OLD_HEADER_TIMESTAMP = -3 +int32 PATH_TOLERANCE_VIOLATED = -4 +int32 GOAL_TOLERANCE_VIOLATED = -5 + +# Human readable description of the error code. Contains complementary +# information that is especially useful when execution fails, for instance: +# - INVALID_GOAL: The reason for the invalid goal (e.g., the requested +# trajectory is in the past). +# - INVALID_JOINTS: The mismatch between the expected controller joints +# and those provided in the goal. +# - PATH_TOLERANCE_VIOLATED and GOAL_TOLERANCE_VIOLATED: Which joint +# violated which tolerance, and by how much. +string error_string + +--- +std_msgs/Header header +string[] joint_names +trajectory_msgs/JointTrajectoryPoint desired +trajectory_msgs/JointTrajectoryPoint actual +trajectory_msgs/JointTrajectoryPoint error + +string[] multi_dof_joint_names +trajectory_msgs/MultiDOFJointTrajectoryPoint multi_dof_desired +trajectory_msgs/MultiDOFJointTrajectoryPoint multi_dof_actual +trajectory_msgs/MultiDOFJointTrajectoryPoint multi_dof_error + +``` + +---- +### `GripperCommand` + +```yaml +GripperCommand command +--- +float64 position # The current gripper gap size (in meters) +float64 effort # The current effort exerted (in Newtons) +bool stalled # True iff the gripper is exerting max effort and not moving +bool reached_goal # True iff the gripper position has reached the commanded setpoint +--- +float64 position # The current gripper gap size (in meters) +float64 effort # The current effort exerted (in Newtons) +bool stalled # True iff the gripper is exerting max effort and not moving +bool reached_goal # True iff the gripper position has reached the commanded setpoint + +``` + +---- +### `JointTrajectory` + +```yaml +trajectory_msgs/JointTrajectory trajectory +--- +--- + +``` + +---- +### `PointHead` + +```yaml +geometry_msgs/PointStamped target +geometry_msgs/Vector3 pointing_axis +string pointing_frame +builtin_interfaces/Duration min_duration +float64 max_velocity +--- +--- +float64 pointing_angle_error + +``` + +---- +### `SingleJointPosition` + +```yaml +float64 position +builtin_interfaces/Duration min_duration +float64 max_velocity +--- +--- +std_msgs/Header header +float64 position +float64 velocity +float64 error + +``` + +---- +### `AssistedTeleop` + +```yaml +#goal definition +builtin_interfaces/Duration time_allowance +--- +#result definition +builtin_interfaces/Duration total_elapsed_time +--- +#feedback +builtin_interfaces/Duration current_teleop_duration + +``` + +---- +### `BackUp` + +```yaml +#goal definition +geometry_msgs/Point target +float32 speed +builtin_interfaces/Duration time_allowance +--- +#result definition +builtin_interfaces/Duration total_elapsed_time +--- +#feedback definition +float32 distance_traveled + +``` + +---- +### `ComputePathThroughPoses` + +```yaml +#goal definition +geometry_msgs/PoseStamped[] goals +geometry_msgs/PoseStamped start +string planner_id +bool use_start # If false, use current robot pose as path start, if true, use start above instead +--- +#result definition +nav_msgs/Path path +builtin_interfaces/Duration planning_time +--- +#feedback definition + +``` + +---- +### `ComputePathToPose` + +```yaml +#goal definition +geometry_msgs/PoseStamped goal +geometry_msgs/PoseStamped start +string planner_id +bool use_start # If false, use current robot pose as path start, if true, use start above instead +--- +#result definition +nav_msgs/Path path +builtin_interfaces/Duration planning_time +--- +#feedback definition + +``` + +---- +### `DriveOnHeading` + +```yaml +#goal definition +geometry_msgs/Point target +float32 speed +builtin_interfaces/Duration time_allowance +--- +#result definition +builtin_interfaces/Duration total_elapsed_time +--- +#feedback definition +float32 distance_traveled + +``` + +---- +### `DummyBehavior` + +```yaml +#goal definition +std_msgs/String command +--- +#result definition +builtin_interfaces/Duration total_elapsed_time +--- +#feedback definition + +``` + +---- +### `FollowPath` + +```yaml +#goal definition +nav_msgs/Path path +string controller_id +string goal_checker_id +--- +#result definition +std_msgs/Empty result +--- +#feedback definition +float32 distance_to_goal +float32 speed + +``` + +---- +### `FollowWaypoints` + +```yaml +#goal definition +geometry_msgs/PoseStamped[] poses +--- +#result definition +int32[] missed_waypoints +--- +#feedback definition +uint32 current_waypoint + +``` + +---- +### `NavigateThroughPoses` + +```yaml +#goal definition +geometry_msgs/PoseStamped[] poses +string behavior_tree +--- +#result definition +std_msgs/Empty result +--- +#feedback definition +geometry_msgs/PoseStamped current_pose +builtin_interfaces/Duration navigation_time +builtin_interfaces/Duration estimated_time_remaining +int16 number_of_recoveries +float32 distance_remaining +int16 number_of_poses_remaining + +``` + +---- +### `NavigateToPose` + +```yaml +#goal definition +geometry_msgs/PoseStamped pose +string behavior_tree +--- +#result definition +std_msgs/Empty result +--- +#feedback definition +geometry_msgs/PoseStamped current_pose +builtin_interfaces/Duration navigation_time +builtin_interfaces/Duration estimated_time_remaining +int16 number_of_recoveries +float32 distance_remaining + +``` + +---- +### `SmoothPath` + +```yaml +#goal definition +nav_msgs/Path path +string smoother_id +builtin_interfaces/Duration max_smoothing_duration +bool check_for_collisions +--- +#result definition +nav_msgs/Path path +builtin_interfaces/Duration smoothing_duration +bool was_completed +--- +#feedback definition + +``` + +---- +### `Spin` + +```yaml +#goal definition +float32 target_yaw +builtin_interfaces/Duration time_allowance +--- +#result definition +builtin_interfaces/Duration total_elapsed_time +--- +#feedback definition +float32 angular_distance_traveled + +``` + +---- +### `Wait` + +```yaml +#goal definition +builtin_interfaces/Duration time +--- +#result definition +builtin_interfaces/Duration total_elapsed_time +--- +#feedback definition +builtin_interfaces/Duration time_left + +``` + +---- diff --git a/docs/developer_guide/actions.md b/docs/developer_guide/actions.md new file mode 100644 index 00000000..1b333ee1 --- /dev/null +++ b/docs/developer_guide/actions.md @@ -0,0 +1,7 @@ +(actions)= +# Uni-Lab 动作指令集 + +Uni-Lab 当前动作指令集设计时,主要考虑兼容领域历史开源工具。目前包括以下场景: + +```{include} action_includes.md +``` diff --git a/docs/developer_guide/add_action.md b/docs/developer_guide/add_action.md new file mode 100644 index 00000000..227c2797 --- /dev/null +++ b/docs/developer_guide/add_action.md @@ -0,0 +1,37 @@ +# 添加新动作指令(Action) + +1. 在 `unilabos_msgs/action` 中新建实验操作名和参数列表,如 `MyDeviceCmd.action`。一个 Action 定义由三个部分组成,分别是目标(Goal)、结果(Result)和反馈(Feedback),之间使用 `---` 分隔: + +```action +# 目标(Goal) +string command +--- +# 结果(Result) +bool success +--- +# 反馈(Feedback) +``` + +2. 在 `unilabos_msgs/CMakeLists.txt` 中添加新定义的 action + +```cmake +add_action_files( + FILES + MyDeviceCmd.action +) +``` + +3. 因为在指令集中新建了指令,因此调试时需要编译,并在终端环境中加载临时路径: + +```bash +cd unilabos_msgs +colcon build +source ./install/local_setup.sh +cd .. +``` + +调试成功后,发起 pull request,Uni-Lab 的 CI/CD 系统会自动将新的指令集编译打包,mamba执行升级即可永久生效: + +```bash +mamba update ros-humble-unilabos-msgs -c http://quetz.dp.tech:8088/get/unilab -c robostack-humble -c robostack-staging +``` diff --git a/docs/developer_guide/add_device.md b/docs/developer_guide/add_device.md new file mode 100644 index 00000000..1fae4bdb --- /dev/null +++ b/docs/developer_guide/add_device.md @@ -0,0 +1,200 @@ +# 添加新设备 + +在 Uni-Lab 中,设备(Device)是实验操作的基础单元。Uni-Lab 使用**注册表机制**来兼容管理种类繁多的设备驱动程序。回顾 {ref}`instructions` 中的概念,抽象的设备对外拥有【话题】【服务】【动作】三种通信机制,因此将设备添加进 Uni-Lab,实际上是将设备驱动中的三种机制映射到 Uni-Lab 标准指令集上。 + +能被 Uni-Lab 添加的驱动程序类型有以下种类: + +1. Python Class,如 + +```python +class MockGripper: + def __init__(self): + self._position: float = 0.0 + self._velocity: float = 2.0 + self._torque: float = 0.0 + self._status = "Idle" + + @property + def position(self) -> float: + return self._position + + @property + def velocity(self) -> float: + return self._velocity + + @property + def torque(self) -> float: + return self._torque + + # 会被自动识别的设备属性,接入 Uni-Lab 时会定时对外广播 + @property + def status(self) -> str: + return self._status + + # 会被自动识别的设备动作,接入 Uni-Lab 时会作为 ActionServer 接受任意控制者的指令 + @status.setter + def status(self, target): + self._status = target + + # 需要在注册表添加的设备动作,接入 Uni-Lab 时会作为 ActionServer 接受任意控制者的指令 + def push_to(self, position: float, torque: float, velocity: float = 0.0): + self._status = "Running" + current_pos = self.position + if velocity == 0.0: + velocity = self.velocity + + move_time = abs(position - current_pos) / velocity + for i in range(20): + self._position = current_pos + (position - current_pos) / 20 * (i+1) + self._torque = torque / (20 - i) + self._velocity = velocity + time.sleep(move_time / 20) + self._torque = torque + self._status = "Idle" +``` + +Python 类设备驱动在完成注册表后可以直接在 Uni-Lab 使用。 + +2. C# Class,如 + +```csharp +using System; +using System.Threading.Tasks; + +public class MockGripper +{ + // 会被自动识别的设备属性,接入 Uni-Lab 时会定时对外广播 + public double position { get; private set; } = 0.0; + public double velocity { get; private set; } = 2.0; + public double torque { get; private set; } = 0.0; + public string status { get; private set; } = "Idle"; + + // 需要在注册表添加的设备动作,接入 Uni-Lab 时会作为 ActionServer 接受任意控制者的指令 + public async Task PushToAsync(double Position, double Torque, double Velocity = 0.0) + { + status = "Running"; + double currentPos = Position; + if (Velocity == 0.0) + { + velocity = Velocity; + } + double moveTime = Math.Abs(Position - currentPos) / velocity; + for (int i = 0; i < 20; i++) + { + position = currentPos + (Position - currentPos) / 20 * (i + 1); + torque = Torque / (20 - i); + velocity = Velocity; + await Task.Delay((int)(moveTime * 1000 / 20)); // Convert seconds to milliseconds + } + torque = Torque; + status = "Idle"; + } +} +``` + +C# 驱动设备在完成注册表后,需要调用 Uni-Lab C# 编译后才能使用,但只需一次。 + +## 注册表文件位置 + +Uni-Lab 启动时会自动读取默认注册表路径 `unilabos/registry/devices` 下的所有注册设备。您也可以任意维护自己的注册表路径,只需要在 Uni-Lab 启动时使用 `--registry` 参数将路径添加即可。 + +在 `/devices` 中新建一个 yaml 文件,即可开始撰写。您可以将多个设备写到同一个 yaml 文件中。 + +## 注册表的结构 + +1. 顶层名称:每个设备的注册表以设备名称开头,例如 `new_device`, `gripper.mock`。 +1. `class` 字段:定义设备的模块路径和驱动程序语言。 +1. `status_types` 字段:定义设备定时对 Uni-Lab 实验室内发送的属性名及其类型。 +1. `action_value_mappings` 字段:定义设备支持的动作及其目标、反馈和结果。 +1. `schema` 字段:定义设备定时对 Uni-Lab 云端监控发送的属性名及其类型、描述(非必须) + +## 创建新的注册表教程 + +1. 创建文件 + 在 devices 文件夹中创建一个新的 YAML 文件,例如 `new_device.yaml`。 +2. 定义设备名称 + 在文件中定义设备的顶层名称,例如:`new_device` 或 `gripper.mock` +3. 定义设备的类信息 + 添加设备的模块路径和类型: + +```yaml +gripper.mock: + class: # 定义设备的类信息 + module: unilabos.devices.gripper.mock:MockGripper + type: python # 指定驱动语言为 Python + status_types: + position: Float64 + torque: Float64 + status: String +``` + +4. 定义设备的定时发布属性。注意,对于 Python Class 来说,PROP 是 class 的 `property`,或满足能被 `getattr(cls, PROP)` 或 `cls.get_PROP` 读取到的属性值的对象。 + +```yaml + status_types: + PROP: TYPE +``` +5. 定义设备支持的动作 + 添加设备支持的动作及其目标、反馈和结果: + +```yaml + action_value_mappings: + set_speed: + type: SendCmd + goal: + command: speed + feedback: {} + result: + success: success +``` + +在 devices 文件夹中的 YAML 文件中,action_value_mappings 是用来将驱动内的动作函数,映射到 Uni-Lab 标准动作(actions)及其目标参数值(goal)、反馈值(feedback)和结果值(result)的映射规则。若在 Uni-Lab 指令集内找不到符合心意的,请【创建新指令】。 + +```yaml + action_value_mappings: + : # :动作的名称 + # start:启动设备或某个功能。 + # stop:停止设备或某个功能。 + # set_speed:设置设备的速度。 + # set_temperature:设置设备的温度。 + # move_to_position:移动设备到指定位置。 + # stir:执行搅拌操作。 + # heatchill:执行加热或冷却操作。 + # send_nav_task:发送导航任务(例如机器人导航)。 + # set_timer:设置设备的计时器。 + # valve_open_cmd:打开阀门。 + # valve_close_cmd:关闭阀门。 + # execute_command_from_outer:执行外部命令。 + # push_to:控制设备推送到某个位置(例如机械爪)。 + # move_through_points:导航设备通过多个点。 + + type: # 动作的类型,表示动作的功能 + # 根据动作的功能选择合适的类型,请查阅 Uni-Lab 已支持的指令集。 + + goal: # 定义动作的目标值映射,表示需要传递给设备的参数。 + : #确定设备需要的输入参数,并将其映射到设备的字段。 + + feedback: # 定义动作的反馈值映射,表示设备执行动作时返回的实时状态。 + : + result: # 定义动作的结果值映射,表示动作完成后返回的最终结果。 + : +``` + +6. 定义设备的网页展示属性类型,这部分会被用于在 Uni-Lab 网页端渲染成状态监控 + 添加设备的属性模式,包括属性类型和描述: + +```yaml +schema: + type: object + properties: + status: + type: string + description: The status of the device + speed: + type: number + description: The speed of the device + required: + - status + - speed + additionalProperties: false +``` diff --git a/docs/developer_guide/add_protocol.md b/docs/developer_guide/add_protocol.md new file mode 100644 index 00000000..da1ca0d3 --- /dev/null +++ b/docs/developer_guide/add_protocol.md @@ -0,0 +1,50 @@ +# 添加新实验操作(Protocol) + +在 `Uni-Lab` 中,实验操作(Protocol)指的是**对实验有意义的单个完整动作**——加入某种液体多少量;萃取分液;洗涤仪器;机械+末端执行器等等,就像实验步骤文字书写的那样。 + +而这些对实验有意义的单个完整动作,**一般需要多个设备的协同**,或者同一设备连续动作,还依赖于他们的**物理连接关系(管道相连;机械臂可转运)**。`Protocol` 根据实验操作目标和设备物理连接关系,通过 `unilabos/compile` 中的“编译”过程产生硬件可执行的机器指令,并依次执行。 + +开发一个 `Protocol` 一般共需要修改6个文件: + +1. 在 `unilabos_msgs/action` 中新建实验操作名和参数列表,如 `PumpTransfer.action`。一个 Action 定义由三个部分组成,分别是目标(Goal)、结果(Result)和反馈(Feedback),之间使用 `---` 分隔: + +```{literalinclude} ../../unilabos_msgs/action/PumpTransfer.action +``` + +2. 在 `unilabos_msgs/CMakeLists.txt` 中添加新定义的 action +因为在指令集中新建了指令,因此调试时需要编译,并在终端环境中加载临时路径: +```bash +cd unilabos_msgs +colcon build +source ./install/local_setup.sh +cd .. +``` + +调试成功后,发起 pull request,Uni-Lab 的 CI/CD 系统会自动将新的指令集编译打包,mamba执行升级即可永久生效: + +```bash +mamba update ros-humble-unilabos-msgs -c http://quetz.dp.tech:8088/get/unilab -c robostack-humble -c robostack-staging +``` + +3. 在 `unilabos/messages/__init__.py` 中添加 Pydantic 定义的实验操作名和参数列表 +```{literalinclude} ../../unilabos/messages/__init__.py +:start-after: Start Protocols +:end-before: End Protocols +``` + +4. 在 `unilabos/compile` 中新建编译为机器指令的函数,函数入参为设备连接图 `G` 和实验操作参数。 +```{literalinclude} ../../unilabos/compile/pump_protocol.py +:start-after: Pump protocol compilation +:end-before: End Protocols +``` + +5. 将该函数加入 `unilabos/compile/__init__.py` 的 `action_protocol_generators` 中: +```{literalinclude} ../../unilabos/compile/__init__.py +:start-after: Define +:end-before: End Protocols +``` + +6. 记得将新开发的 `Protocol` 添加至启动时的 `devices.json` 中。 +```{literalinclude} ../../devices.json +:lines: 2-4 +``` diff --git a/docs/developer_guide/add_yaml.md b/docs/developer_guide/add_yaml.md new file mode 100644 index 00000000..aef71226 --- /dev/null +++ b/docs/developer_guide/add_yaml.md @@ -0,0 +1,95 @@ +# yaml注册表编写指南 + +`注册表的结构` + +1. 顶层名称:每个设备的注册表以设备名称开头,例如 new_device。 +2. class 字段:定义设备的模块路径和类型。 +3. schema 字段:定义设备的属性模式,包括属性类型、描述和必需字段。 +4. action_value_mappings 字段:定义设备支持的动作及其目标、反馈和结果。 + +`创建新的注册表教程` +1. 创建文件 + 在 devices 文件夹中创建一个新的 YAML 文件,例如 new_device.yaml。 + +2. 定义设备名称 + 在文件中定义设备的顶层名称,例如:new_device + +3. 定义设备的类信息 + 添加设备的模块路径和类型: + +```python +new_device: # 定义一个名为 linear_motion.grbl 的设备 + + +class: # 定义设备的类信息 + module: unilabos.devices_names.new_device:NewDeviceClass # 指定模块路径和类名 + type: python # 指定类型为 Python 类 + status_types: +``` +4. 定义设备支持的动作 + 添加设备支持的动作及其目标、反馈和结果: +```python + action_value_mappings: + set_speed: + type: SendCmd + goal: + command: speed + feedback: {} + result: + success: success +``` +`如何编写action_valve_mappings` +1. 在 devices 文件夹中的 YAML 文件中,action_value_mappings 是用来定义设备支持的动作(actions)及其目标值(goal)、反馈值(feedback)和结果值(result)的映射规则。以下是规则和编写方法: +```python + action_value_mappings: + : # :动作的名称 + # start:启动设备或某个功能。 + # stop:停止设备或某个功能。 + # set_speed:设置设备的速度。 + # set_temperature:设置设备的温度。 + # move_to_position:移动设备到指定位置。 + # stir:执行搅拌操作。 + # heatchill:执行加热或冷却操作。 + # send_nav_task:发送导航任务(例如机器人导航)。 + # set_timer:设置设备的计时器。 + # valve_open_cmd:打开阀门。 + # valve_close_cmd:关闭阀门。 + # execute_command_from_outer:执行外部命令。 + # push_to:控制设备推送到某个位置(例如机械爪)。 + # move_through_points:导航设备通过多个点。 + + type: # 动作的类型,表示动作的功能 + # 根据动作的功能选择合适的类型: + # SendCmd:发送简单命令。 + # NavigateThroughPoses:导航动作。 + # SingleJointPosition:设置单一关节的位置。 + # Stir:搅拌动作。 + # HeatChill:加热或冷却动作。 + + goal: # 定义动作的目标值映射,表示需要传递给设备的参数。 + : #确定设备需要的输入参数,并将其映射到设备的字段。 + + feedback: # 定义动作的反馈值映射,表示设备执行动作时返回的实时状态。 + : + result: # 定义动作的结果值映射,表示动作完成后返回的最终结果。 + : +``` + +6. 定义设备的属性模式 + 添加设备的属性模式,包括属性类型和描述: +```python +schema: + type: object + properties: + status: + type: string + description: The status of the device + speed: + type: number + description: The speed of the device + required: + - status + - speed + additionalProperties: false +``` +# 写完yaml注册表后需要添加到哪些其他文件? diff --git a/docs/developer_guide/device_driver.md b/docs/developer_guide/device_driver.md new file mode 100644 index 00000000..753133a3 --- /dev/null +++ b/docs/developer_guide/device_driver.md @@ -0,0 +1,330 @@ +# 设备 Driver 开发 + +我们对设备 Driver 的定义,是一个 Python/C++/C# 类,类的方法可以用于获取传感器数据、执行设备动作、更新物料信息。它们经过 Uni-Lab 的通信中间件包装,就能成为高效分布式通信的设备节点。 + +因此,若已有设备的 SDK (Driver),可以直接 [添加进 Uni-Lab](add_device.md)。仅当没有 SDK (Driver) 时,请参考本章作开发。 + +## 有串口字符串指令集文档的设备:Python 串口通信(常见 RS485, RS232, USB) + +开发方式:对照厂家给出的指令集文档,实现相应发送指令字符串的 python 函数。可参考 [注射泵串口驱动样例](https://github.com/TablewareBox/runze-syringe-pump) + +## 常见工业通信协议:Modbus(RTU, TCP) + +Modbus 与 RS485、RS232 不一样的地方在于,会有更多直接寄存器的读写,以及涉及字节序转换(Big Endian, Little Endian)。 + +Uni-Lab 开发团队在仓库中提供了3个样例: + +* 单一机械设备**电夹爪**,通讯协议可见 [增广夹爪通讯协议](https://doc.rmaxis.com/docs/communication/fieldbus/),驱动代码位于 `unilabos/devices/gripper/rmaxis_v4.py` +* 单一通信设备**IO板卡**,驱动代码位于 `unilabos/device_comms/gripper/SRND_16_IO.py` +* 执行多设备复杂任务逻辑的**PLC**,Uni-Lab 提供了基于地址表的接入方式和点动工作流编写,测试代码位于 `unilabos/device_comms/modbus_plc/test/test_workflow.py` + +**** + +## 其他工业通信协议:CANopen, Ethernet, OPCUA... + +【敬请期待】 + +## 没有接口的老设备老软件:使用 PyWinAuto + +**pywinauto**是一个 Python 库,用于自动化Windows GUI操作。它可以模拟用户的鼠标点击、键盘输入、窗口操作等,广泛应用于自动化测试、GUI自动化等场景。它支持通过两个后端进行操作: + +* **win32**后端:适用于大多数Windows应用程序,使用native Win32 API。(pywinauto_recorder默认使用win32后端) +* **uia**后端:基于Microsoft UI Automation,适用于较新的应用程序,特别是基于WPF或UWP的应用程序。(在win10上,会有更全的目录,有的窗口win32会识别不到) + +### windows平台安装pywinauto和pywinauto_recorder + +直接安装会造成环境崩溃,需要下载并解压已经修改好的文件。 + +cd到对应目录,执行安装 + +`pip install . -i ``https://pypi.tuna.tsinghua.edu.cn/simple` + +![pywinauto_install](image/device_driver/pywinauto_install.png) + +windows平台测试 python pywinauto_recorder.py,退出使用两次ctrl+alt+r取消选中,关闭命令提示符。 + +### 计算器例子 + +你可以先打开windows的计算器,然后在ilab的环境中运行下面的代码片段,可观察到得到结果,通过这一案例,你需要掌握的pywinauto用法: + +* 连接到指定进程 +* 利用dump_tree查找需要的窗口 +* 获取某个位置的信息 +* 模拟点击 +* 模拟输入 + +#### 代码学习 + +```Python +from pywinauto import Application +import time + +from pywinauto.findwindows import ElementAmbiguousError + +# 启动计算器应用 +app = Application(backend='uia').connect(title="计算器") + +# 连接到计算器窗口 +window = app["计算器Dialog0"] + +# 打印窗口控件树结构,帮助定位控件 +window.dump_tree(depth=3) +# 详细输出 +""" +Dialog - '计算器' (L-419, T773, R-73, B1287) +['计算器Dialog', 'Dialog', '计算器', '计算器Dialog0', '计算器Dialog1', 'Dialog0', 'Dialog1', '计算器0', '计算器1'] +child_window(title="计算器", control_type="Window") + | + | Dialog - '计算器' (L-269, T774, R-81, B806) + | ['计算器Dialog2', 'Dialog2', '计算器2'] + | child_window(title="计算器", auto_id="TitleBar", control_type="Window") + | | + | | Menu - '系统' (L0, T0, R0, B0) + | | ['Menu', '系统', '系统Menu', '系统0', '系统1'] + | | child_window(title="系统", auto_id="SystemMenuBar", control_type="MenuBar") + | | + | | Button - '最小化 计算器' (L-219, T774, R-173, B806) + | | ['Button', '最小化 计算器Button', '最小化 计算器', 'Button0', 'Button1'] + | | child_window(title="最小化 计算器", auto_id="Minimize", control_type="Button") + | | + | | Button - '使 计算器 最大化' (L-173, T774, R-127, B806) + | | ['Button2', '使 计算器 最大化', '使 计算器 最大化Button'] + | | child_window(title="使 计算器 最大化", auto_id="Maximize", control_type="Button") + | | + | | Button - '关闭 计算器' (L-127, T774, R-81, B806) + | | ['Button3', '关闭 计算器Button', '关闭 计算器'] + | | child_window(title="关闭 计算器", auto_id="Close", control_type="Button") + | + | Dialog - '计算器' (L-411, T774, R-81, B1279) + | ['计算器Dialog3', 'Dialog3', '计算器3'] + | child_window(title="计算器", control_type="Window") + | | + | | Static - '计算器' (L-363, T782, R-327, B798) + | | ['计算器Static', 'Static', '计算器4', 'Static0', 'Static1'] + | | child_window(title="计算器", auto_id="AppName", control_type="Text") + | | + | | Custom - '' (L-411, T806, R-81, B1279) + | | ['Custom', '计算器Custom'] + | | child_window(auto_id="NavView", control_type="Custom") + | + | Pane - '' (L-411, T806, R-81, B1279) + | ['Pane', '计算器Pane'] +""" + +# 通过控件路径(可参考下一小节路径)可以发现,本文档第48-50行是我们需要定位的控件 +# 本文档第16-18行为其自身,即depth=1,我们要定位的第48-50行是depth=3的控件,从树来一级一级定位即可 +# PyWinAuto为我们提供了非常便捷的取窗口方式,在每3行dump的内容中,第三行就是从上一级取出当前窗口的方式,直接复制即可 +# 这里注意到,使用title="计算器", control_type="Window"进行匹配,会出现两个,因此指定found_index=1 +sub_window = window.child_window(title="计算器", control_type="Window", found_index=1) +target_window = sub_window.child_window(auto_id="NavView", control_type="Custom") +target_window.dump_tree(depth=3) +""" +Custom - '' (L-411, T806, R-81, B1279) +['标准Custom', 'Custom'] +child_window(auto_id="NavView", control_type="Custom") + | + | Button - '打开导航' (L-407, T812, R-367, B848) + | ['打开导航Button', '打开导航', 'Button', 'Button0', 'Button1'] + | child_window(title="打开导航", auto_id="TogglePaneButton", control_type="Button") + | | + | | Static - '' (L0, T0, R0, B0) + | | ['Static', 'Static0', 'Static1'] + | | child_window(auto_id="PaneTitleTextBlock", control_type="Text") + | + | GroupBox - '' (L-411, T814, R-81, B1275) + | ['标准GroupBox', 'GroupBox', 'GroupBox0', 'GroupBox1'] + | | + | | Static - '表达式为 ' (L0, T0, R0, B0) + | | ['表达式为 ', 'Static2', '表达式为 Static'] + | | child_window(title="表达式为 ", auto_id="CalculatorExpression", control_type="Text") + | | + | | Static - '显示为 0' (L-411, T875, R-81, B947) + | | ['显示为 0Static', '显示为 0', 'Static3'] + | | child_window(title="显示为 0", auto_id="CalculatorResults", control_type="Text") + | | + | | Button - '打开历史记录浮出控件' (L-121, T814, R-89, B846) + | | ['打开历史记录浮出控件', '打开历史记录浮出控件Button', 'Button2'] + | | child_window(title="打开历史记录浮出控件", auto_id="HistoryButton", control_type="Button") + | | + | | GroupBox - '记忆控件' (L-407, T948, R-85, B976) + | | ['记忆控件', '记忆控件GroupBox', 'GroupBox2'] + | | child_window(title="记忆控件", auto_id="MemoryPanel", control_type="Group") + | | + | | GroupBox - '显示控件' (L-407, T978, R-85, B1026) + | | ['显示控件', 'GroupBox3', '显示控件GroupBox'] + | | child_window(title="显示控件", auto_id="DisplayControls", control_type="Group") + | | + | | GroupBox - '标准函数' (L-407, T1028, R-166, B1076) + | | ['标准函数', '标准函数GroupBox', 'GroupBox4'] + | | child_window(title="标准函数", auto_id="StandardFunctions", control_type="Group") + | | + | | GroupBox - '标准运算符' (L-164, T1028, R-85, B1275) + | | ['标准运算符', '标准运算符GroupBox', 'GroupBox5'] + | | child_window(title="标准运算符", auto_id="StandardOperators", control_type="Group") + | | + | | GroupBox - '数字键盘' (L-407, T1078, R-166, B1275) + | | ['GroupBox6', '数字键盘', '数字键盘GroupBox'] + | | child_window(title="数字键盘", auto_id="NumberPad", control_type="Group") + | | + | | Button - '正负' (L-407, T1228, R-328, B1275) + | | ['Button32', '正负Button', '正负'] + | | child_window(title="正负", auto_id="negateButton", control_type="Button") + | + | Static - '标准' (L-363, T815, R-322, B842) + | ['标准', '标准Static', 'Static4'] + | child_window(title="标准", auto_id="Header", control_type="Text") + | + | Button - '始终置顶' (L-312, T814, R-280, B846) + | ['始终置顶Button', '始终置顶', 'Button33'] + | child_window(title="始终置顶", auto_id="NormalAlwaysOnTopButton", control_type="Button") +""" +# 观察到GroupBox控件并没有提供默认的child_window,而list中的identifier均可作为best_match来索引 +# ['标准GroupBox', 'GroupBox', 'GroupBox0', 'GroupBox1'] 这里选用第0项 +group_box = target_window.child_window(best_match="标准GroupBox") +numpad = group_box.child_window(title="数字键盘", auto_id="NumberPad", control_type="Group") +numpad.dump_tree(depth=2) +""" +GroupBox - '数字键盘' (L-334, T1350, R-93, B1547) +['GroupBox', '数字键盘', '数字键盘GroupBox'] +child_window(title="数字键盘", auto_id="NumberPad", control_type="Group") + | + | Button - '零' (L-253, T1500, R-174, B1547) + | ['零Button', 'Button', '零', 'Button0', 'Button1'] + | child_window(title="零", auto_id="num0Button", control_type="Button") + | + | Button - '一' (L-334, T1450, R-255, B1498) + | ['一Button', 'Button2', '一'] + | child_window(title="一", auto_id="num1Button", control_type="Button") + | + | Button - '二' (L-253, T1450, R-174, B1498) + | ['Button3', '二', '二Button'] + | child_window(title="二", auto_id="num2Button", control_type="Button") + | + | Button - '三' (L-172, T1450, R-93, B1498) + | ['Button4', '三', '三Button'] + | child_window(title="三", auto_id="num3Button", control_type="Button") + | + | Button - '四' (L-334, T1400, R-255, B1448) + | ['四', 'Button5', '四Button'] + | child_window(title="四", auto_id="num4Button", control_type="Button") + | + | Button - '五' (L-253, T1400, R-174, B1448) + | ['Button6', '五Button', '五'] + | child_window(title="五", auto_id="num5Button", control_type="Button") + | + | Button - '六' (L-172, T1400, R-93, B1448) + | ['六Button', 'Button7', '六'] + | child_window(title="六", auto_id="num6Button", control_type="Button") + | + | Button - '七' (L-334, T1350, R-255, B1398) + | ['Button8', '七Button', '七'] + | child_window(title="七", auto_id="num7Button", control_type="Button") + | + | Button - '八' (L-253, T1350, R-174, B1398) + | ['八', 'Button9', '八Button'] + | child_window(title="八", auto_id="num8Button", control_type="Button") + | + | Button - '九' (L-172, T1350, R-93, B1398) + | ['Button10', '九', '九Button'] + | child_window(title="九", auto_id="num9Button", control_type="Button") + | + | Button - '十进制分隔符' (L-172, T1500, R-93, B1547) + | ['十进制分隔符Button', 'Button11', '十进制分隔符'] + | child_window(title="十进制分隔符", auto_id="decimalSeparatorButton", control_type="Button") +""" +# 获取按钮 '9' +button_9 = numpad.child_window(title="九", auto_id="num9Button", control_type="Button") +# 利用相同的办法,我们也可以找到增加和等于号的控件 +std_calc_panel = group_box.child_window(title="标准运算符", auto_id="StandardOperators", control_type="Group") +equal_operation = std_calc_panel.child_window(title="等于", auto_id="equalButton", control_type="Button") + +# 模拟点击按钮 '9' +button_9.click_input() +# 键入:https://github.com/pywinauto/pywinauto/blob/atspi/pywinauto/windows/keyboard.py +# 模拟输入 '加号' 和 数字9 +window.type_keys("{VK_ADD}9") +# 等于 +equal_operation.click_input() +# 获取计算结果文本(显示在计算器窗口的文本框中) +result = group_box.child_window(auto_id="CalculatorResults", control_type="Text").window_text() +print(f"计算结果:{result[4:]}") # 应当得到结果18 +``` + +#### 依据像素判定状态 + +```Python +# 有时,你需要根据窗口的颜色判断是否可用,是否正在运行,可以使用pyautogui来实现这一功能 +# pyautogui需要对应在环境中进行安装 +point_x = int(control_view.rectangle().left + control_view.rectangle().width() * 0.15) +point_y = 15 + control_view.rectangle().top +r, g, b = pyautogui.pixel(point_x, point_y) +``` + +### pywinauto_recorder + +pywinauto_recorder是一个配合 pywinauto 使用的工具,用于录制用户的操作,并生成相应的 pywinauto 脚本。这对于一些暂时无法直接调用DLL的函数并且需要模拟用户操作的场景非常有用。同时,可以省去仅用pywinauto的一些查找UI步骤。 + +#### 运行尝试 + +请参照 上手尝试-环境创建-3 开启pywinauto_recorder + +例如我们这里先启动一个windows自带的计算器软件 + +![calculator_01](image/device_driver/calculator_01.png) + +在录制状态下点击数字键盘的“9”,随后退出录制,得到下图运行的文件。 + +![calculator_02](image/device_driver/calculator_02.png) + +```Python +# encoding: utf-8 + +from pywinauto_recorder.player import * + +with UIPath(u"计算器||Window"): + with UIPath(u"计算器||Window->||Custom->||Group->数字键盘||Group"): + click(u"九||Button") +``` + +执行该python脚本,可以观察到新开启的计算器被点击了数字9 + +![calculator_03](image/device_driver/calculator_03.png) + +### `dump_tree`详解 + +`dump_tree`方法用于打印控件树结构,帮助我们快速了解应用程序窗口中的控件层级,尤其是在自动化测试或脚本开发中,识别控件非常重要。 + +```Python +window.dump_tree(depth=[int类型数字], filename=None) +# 打印当前窗口及其子控件的树结构 +# 在debug的过程中,如果需要查找某一控件,可以通过depth指定为4~5,利用搜索查看是哪个独立控件 +# 指定filename后将保存到对应目录文件中 +``` + +输出会列出窗口的各个控件及其子控件,显示每个控件的属性(如标题、类型等)。 + +```Python +""" +GroupBox - '数字键盘' (L-334, T1350, R-93, B1547) +['GroupBox', '数字键盘', '数字键盘GroupBox'] +child_window(title="数字键盘", auto_id="NumberPad", control_type="Group") + | + | Button - '零' (L-253, T1500, R-174, B1547) + | ['零Button', 'Button', '零', 'Button0', 'Button1'] + | child_window(title="零", auto_id="num0Button", control_type="Button") +""" +``` + +这里以上面计算器的例子对dump_tree进行解读 + +2~4行为当前对象的窗口 + +* 第2行分别是窗体的类型 `GroupBox`,窗体的题目 `数字键盘`,窗体的矩形区域坐标,对应的是屏幕上的位置(左、上、右、下) +* 第3行是 `['GroupBox', '数字键盘', '数字键盘GroupBox']`,为控件的标识符列表,可以选择任意一个,使用 `child_window(best_match="标识符")`来获取该窗口 +* 第4行是获取该控件的方法,请注意该方法不能保证获取唯一,`title`如果是变化的,也需要删除 `title`参数 + +6~8行为当前对象窗口所包含的子窗口信息,信息类型对应2~4行 + +### 窗口获取注意事项 + +1. 在 `child_window`的时候,并不会立刻报错,只有在执行窗口的信息获取时才会调用,查询窗口是否存在,因此要想确定 `child_window`是否正确,可以调用子窗口对象的属性 `element_info`,来保证窗口存在 \ No newline at end of file diff --git a/docs/developer_guide/image/device_driver/calculator_01.png b/docs/developer_guide/image/device_driver/calculator_01.png new file mode 100644 index 00000000..2f60c587 Binary files /dev/null and b/docs/developer_guide/image/device_driver/calculator_01.png differ diff --git a/docs/developer_guide/image/device_driver/calculator_02.png b/docs/developer_guide/image/device_driver/calculator_02.png new file mode 100644 index 00000000..17415c56 Binary files /dev/null and b/docs/developer_guide/image/device_driver/calculator_02.png differ diff --git a/docs/developer_guide/image/device_driver/calculator_03.png b/docs/developer_guide/image/device_driver/calculator_03.png new file mode 100644 index 00000000..e55326a4 Binary files /dev/null and b/docs/developer_guide/image/device_driver/calculator_03.png differ diff --git a/docs/developer_guide/image/device_driver/pywinauto_install.png b/docs/developer_guide/image/device_driver/pywinauto_install.png new file mode 100644 index 00000000..65d3202a Binary files /dev/null and b/docs/developer_guide/image/device_driver/pywinauto_install.png differ diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..a8bf8252 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,9 @@ +# Uni-Lab 项目文档 + +欢迎来到项目文档的首页! + +```{toctree} +:maxdepth: 3 + +intro.md +``` diff --git a/docs/intro.md b/docs/intro.md new file mode 100644 index 00000000..5b0b3a63 --- /dev/null +++ b/docs/intro.md @@ -0,0 +1,44 @@ +欢迎来到项目文档的首页! + +## 核心概念 + +```{toctree} +:maxdepth: 2 + +concepts/overview.md +concepts/01-communication-instruction.md +concepts/02-topology-and-chemputer-compile.md +``` + +## **用户指南** + +本指南将带你了解如何使用项目的功能。 + +```{toctree} +:maxdepth: 2 + +user_guide/installation.md +user_guide/configuration.md +user_guide/launch.md +boot_examples/index.md +``` + +## 开发者指南 + +```{toctree} +:maxdepth: 2 + +developer_guide/device_driver +developer_guide/add_device +developer_guide/add_action +developer_guide/actions +developer_guide/add_protocol +``` + +## 接口文档 + +```{toctree} +:maxdepth: 2 + +apidocs/unilabos +``` diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 00000000..954237b9 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/user_guide/configuration.md b/docs/user_guide/configuration.md new file mode 100644 index 00000000..7e148676 --- /dev/null +++ b/docs/user_guide/configuration.md @@ -0,0 +1,120 @@ +# Uni-Lab 配置指南 + +Uni-Lab支持通过Python配置文件进行灵活的系统配置。本指南将帮助您理解配置选项并设置您的Uni-Lab环境。 + +## 配置文件格式 + +Uni-Lab支持Python格式的配置文件,它比YAML或JSON提供更多的灵活性,包括支持注释、条件逻辑和复杂数据结构。 + +### 基本配置示例 + +一个典型的配置文件包含以下部分: + +```python +#!/usr/bin/env python +# coding=utf-8 +"""Uni-Lab 配置文件""" + +from dataclasses import dataclass + +# 配置类定义 +@dataclass +class MQConfig: + """MQTT 配置类""" + lab_id: str = "YOUR_LAB_ID" + # 更多配置... + +# 其他配置类... +``` + +## 配置选项说明 + +### MQTT配置 (MQConfig) + +MQTT配置用于连接消息队列服务,是Uni-Lab与云端通信的主要方式。 + +```python +@dataclass +class MQConfig: + """MQTT 配置类""" + lab_id: str = "7AAEDBEA" # 实验室唯一标识 + instance_id: str = "mqtt-cn-instance" + access_key: str = "your-access-key" + secret_key: str = "your-secret-key" + group_id: str = "GID_labs" + broker_url: str = "mqtt-cn-instance.mqtt.aliyuncs.com" + port: int = 8883 + + # 可以直接提供证书文件路径 + ca_file: str = "/path/to/ca.pem" + cert_file: str = "/path/to/cert.pem" + key_file: str = "/path/to/key.pem" + + # 或者直接提供证书内容 + ca_content: str = "" + cert_content: str = "" + key_content: str = "" +``` + +#### 证书配置 + +MQTT连接支持两种方式配置证书: + +1. **文件路径方式**(推荐):指定证书文件的路径,系统会自动读取文件内容 +2. **直接内容方式**:直接在配置中提供证书内容 + +推荐使用文件路径方式,便于证书的更新和管理。 + +### HTTP客户端配置 (HTTPConfig) + +即将开放 Uni-Lab 云端实验室。 + +### ROS模块配置 (ROSConfig) + +配置ROS消息转换器需要加载的模块: + +```python +@dataclass +class ROSConfig: + """ROS模块配置""" + modules: list = None + + def __post_init__(self): + if self.modules is None: + self.modules = [ + "std_msgs.msg", + "geometry_msgs.msg", + "control_msgs.msg", + "control_msgs.action", + "nav2_msgs.action", + "unilabos_msgs.msg", + "unilabos_msgs.action", + ] +``` + +您可以根据需要添加其他ROS模块。 + +### 其他配置选项 + +- **OSSUploadConfig**: 对象存储上传配置 + +## 如何使用配置文件 + +启动Uni-Lab时通过`--config`参数指定配置文件路径: + +```bash +unilab --config path/to/your/config.py +``` + +## 环境变量覆盖 + +某些配置项可以通过环境变量进行覆盖,这在不同环境部署时特别有用: + +```bash +# 设置环境变量覆盖配置 +export UNILAB_LAB_ID="YOUR_LAB_ID" +export UNILAB_MQTT_BROKER="mqtt-broker-address" + +# 启动Uni-Lab +python -m unilabos.app.main --config path/to/your/config.py +``` diff --git a/docs/user_guide/installation.md b/docs/user_guide/installation.md new file mode 100644 index 00000000..1fdf3355 --- /dev/null +++ b/docs/user_guide/installation.md @@ -0,0 +1,24 @@ +# **Uni-Lab 安装** + +请先 `git clone` 本仓库,随后按照以下步骤安装项目: + +`Uni-Lab` 建议您采用 `mamba` 管理环境。若需从头建立 `Uni-Lab` 的运行依赖环境,请执行 + +```shell +mamba env create -f unilabos-.yaml +mamba activate ilab +``` + +其中 `YOUR_OS` 是您的操作系统,可选值 `win64`, `linux-64`, `osx-64`, `osx-arm64` + +若需将依赖安装进当前环境,请执行 + +```shell +conda env update --file unilabos-.yml +``` + +随后,可在本仓库安装 `unilabos` 的开发版: + +```shell +pip install . +``` diff --git a/docs/user_guide/launch.md b/docs/user_guide/launch.md new file mode 100644 index 00000000..b973975e --- /dev/null +++ b/docs/user_guide/launch.md @@ -0,0 +1,77 @@ +# Uni-Lab 启动 + +安装完毕后,可以通过 `unilab` 命令行启动: + +```bash +Start Uni-Lab Edge server. + +options: + -h, --help show this help message and exit + -g GRAPH, --graph GRAPH + Physical setup graph. + -d DEVICES, --devices DEVICES + Devices config file. + -r RESOURCES, --resources RESOURCES + Resources config file. + -c CONTROLLERS, --controllers CONTROLLERS + Controllers config file. + --registry_path REGISTRY_PATH + Path to the registry + --backend {ros,simple,automancer} + Choose the backend to run with: 'ros', 'simple', or 'automancer'. + --app_bridges APP_BRIDGES [APP_BRIDGES ...] + Bridges to connect to. Now support 'mqtt' and 'fastapi'. + --without_host Run the backend as slave (without host). + --config CONFIG Configuration file path for system settings +``` + +## 使用配置文件 + +Uni-Lab支持使用Python格式的配置文件进行系统设置。通过 `--config` 参数指定配置文件路径: + +```bash +# 使用配置文件启动 +unilab --config path/to/your/config.py +``` + +配置文件包含MQTT、HTTP、ROS等系统设置。有关配置文件的详细信息,请参阅[配置指南](configuration.md)。 + +## 初始化信息来源 + +启动 Uni-Lab 时,可以选用两种方式之一配置实验室设备、耗材、通信、控制逻辑: + +### 1. 组态&拓扑图 + +使用 `-g` 时,组态&拓扑图应包含实验室所有信息,详见{ref}`graph`。目前支持 graphml 和 node-link json 两种格式。格式可参照 `tests/experiments` 下的启动文件。 + +### 2. 分别指定设备、耗材、控制逻辑 + +分别使用 `-d, -r, -c` 依次传入设备组态配置、耗材列表、控制逻辑。 + +可参照 `devices.json` 和 `resources.json`。 + +不管使用哪一种初始化方式,设备/物料字典均需包含 `class` 属性,用于查找注册表信息。默认查找范围都是 Uni-Lab 内部注册表 `unilabos/registry/{devices,device_comms,resources}`。要添加额外的注册表路径,可以使用 `--registry` 加入 `/{devices,device_comms,resources}`。 + +## 通信中间件 `--backend` + +目前 Uni-Lab 仅支持 ros2 作为通信中间件。 + +## 端云桥接 `--app_bridges` + +目前 Uni-Lab 提供 FastAPI (http), MQTT 两种端云通信方式。其中默认 MQTT 负责端对云状态同步和云对端任务下发,FastAPI 负责端对云物料更新。 + +## 分布式组网 + +启动 Uni-Lab 时,加入 `--without_host` 将作为从站,不加将作为主站,主站 (host) 持有物料修改权以及对云端的通信。局域网内分别启动的 Uni-Lab 主站/从站将自动组网,互相能访问所有设备状态、传感器信息并发送指令。 + +## 完整启动示例 + +以下是一些常用的启动命令示例: + +```bash +# 使用配置文件和组态图启动 +unilab -g path/to/graph.json + +# 使用配置文件和分离的设备/资源文件启动 +unilab -d devices.json -r resources.json +``` diff --git a/package.xml b/package.xml new file mode 100644 index 00000000..97415270 --- /dev/null +++ b/package.xml @@ -0,0 +1,22 @@ + + + + unilabos + 0.0.0 + ROS2 package for unilabos server + changjh + TODO: License declaration + + action_msgs + action_msgs + rosidl_interface_packages + + ament_copyright + ament_flake8 + ament_pep257 + python3-pytest + + + ament_python + + diff --git a/recipes/conda_build_config.yaml b/recipes/conda_build_config.yaml new file mode 100644 index 00000000..fd870d01 --- /dev/null +++ b/recipes/conda_build_config.yaml @@ -0,0 +1,47 @@ +gazebo: + - '11' +libpqxx: + - 6 +numpy: + - 1.26.* + +cdt_name: # [linux] + - cos7 # [linux] + +python: + - 3.11.* *_cpython +python_impl: + - cpython + +# Project overrides +macos_min_version: # [osx and x86_64] + - 10.14 # [osx and x86_64] +macos_machine: # [osx] + - x86_64-apple-darwin13.4.0 # [osx and x86_64] + - arm64-apple-darwin20.0.0 # [osx and arm64] +MACOSX_DEPLOYMENT_TARGET: # [osx] + - 11.0 # [osx and arm64] + - 10.14 # [osx and x86_64] +CONDA_BUILD_SYSROOT: + - /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk # [osx and arm64] + + +# fix build metadata, needed for mapviz and moveit-core +replacements: + all_replacements: + - tag: 'pkg-config build metadata' + glob_patterns: + - '*.pc' + regex_re: '(?:-L|-I)?\"?([^;\s]+\/sysroot\/)' + replacement_re: '$(CONDA_BUILD_SYSROOT_S)' + regex_rg: '([^;\s"]+/sysroot/)' + - tag: 'CMake build metadata' + glob_patterns: + - '*.cmake' + regex_re: '([^;\s"]+/sysroot)' + replacement_re: '$ENV{CONDA_BUILD_SYSROOT}' + - tag: 'CMake build metadata OSX' + glob_patterns: + - '*.cmake' + regex_re: '([^;\s"]+/MacOSX\d*\.?\d*\.sdk)' + replacement_re: '$ENV{CONDA_BUILD_SYSROOT}' diff --git a/recipes/ros-humble-unilabos-msgs/bld_ament_cmake.bat b/recipes/ros-humble-unilabos-msgs/bld_ament_cmake.bat new file mode 100644 index 00000000..9bf01552 --- /dev/null +++ b/recipes/ros-humble-unilabos-msgs/bld_ament_cmake.bat @@ -0,0 +1,41 @@ +:: Generated by vinca http://github.com/RoboStack/vinca. +:: DO NOT EDIT! +setlocal EnableDelayedExpansion + +set "PYTHONPATH=%LIBRARY_PREFIX%\lib\site-packages;%SP_DIR%" + +:: MSVC is preferred. +set CC=cl.exe +set CXX=cl.exe + +rd /s /q build +mkdir build +pushd build + +:: set "CMAKE_GENERATOR=Ninja" + +:: try to fix long paths issues by using default generator +set "CMAKE_GENERATOR=Visual Studio %VS_MAJOR% %VS_YEAR%" +set "SP_DIR_FORWARDSLASHES=%SP_DIR:\=/%" + +set PYTHON="%PREFIX%\python.exe" + +cmake ^ + -G "%CMAKE_GENERATOR%" ^ + -DCMAKE_INSTALL_PREFIX=%LIBRARY_PREFIX% ^ + -DCMAKE_BUILD_TYPE=Release ^ + -DCMAKE_INSTALL_SYSTEM_RUNTIME_LIBS_SKIP=True ^ + -DPYTHON_EXECUTABLE=%PYTHON% ^ + -DPython_EXECUTABLE=%PYTHON% ^ + -DPython3_EXECUTABLE=%PYTHON% ^ + -DSETUPTOOLS_DEB_LAYOUT=OFF ^ + -DBUILD_SHARED_LIBS=ON ^ + -DBUILD_TESTING=OFF ^ + -DCMAKE_OBJECT_PATH_MAX=255 ^ + -DPYTHON_INSTALL_DIR=%SP_DIR_FORWARDSLASHES% ^ + --compile-no-warning-as-error ^ + %SRC_DIR%\%PKG_NAME%\src\work +if errorlevel 1 exit 1 + +cmake --build . --config Release --target install +if errorlevel 1 exit 1 diff --git a/recipes/ros-humble-unilabos-msgs/build_ament_cmake.sh b/recipes/ros-humble-unilabos-msgs/build_ament_cmake.sh new file mode 100644 index 00000000..52baa99c --- /dev/null +++ b/recipes/ros-humble-unilabos-msgs/build_ament_cmake.sh @@ -0,0 +1,71 @@ +# Generated by vinca http://github.com/RoboStack/vinca. +# DO NOT EDIT! + +rm -rf build +mkdir build +cd build + +# necessary for correctly linking SIP files (from python_qt_bindings) +export LINK=$CXX + +if [[ "$CONDA_BUILD_CROSS_COMPILATION" != "1" ]]; then + PYTHON_EXECUTABLE=$PREFIX/bin/python + PKG_CONFIG_EXECUTABLE=$PREFIX/bin/pkg-config + OSX_DEPLOYMENT_TARGET="10.15" +else + PYTHON_EXECUTABLE=$BUILD_PREFIX/bin/python + PKG_CONFIG_EXECUTABLE=$BUILD_PREFIX/bin/pkg-config + OSX_DEPLOYMENT_TARGET="11.0" +fi + +echo "USING PYTHON_EXECUTABLE=${PYTHON_EXECUTABLE}" +echo "USING PKG_CONFIG_EXECUTABLE=${PKG_CONFIG_EXECUTABLE}" + +export ROS_PYTHON_VERSION=`$PYTHON_EXECUTABLE -c "import sys; print('%i.%i' % (sys.version_info[0:2]))"` +echo "Using Python ${ROS_PYTHON_VERSION}" +# Fix up SP_DIR which for some reason might contain a path to a wrong Python version +FIXED_SP_DIR=$(echo $SP_DIR | sed -E "s/python[0-9]+\.[0-9]+/python$ROS_PYTHON_VERSION/") +echo "Using site-package dir ${FIXED_SP_DIR}" + +# see https://github.com/conda-forge/cross-python-feedstock/issues/24 +if [[ "$CONDA_BUILD_CROSS_COMPILATION" == "1" ]]; then + find $PREFIX/lib/cmake -type f -exec sed -i "s~\${_IMPORT_PREFIX}/lib/python${ROS_PYTHON_VERSION}/site-packages~${BUILD_PREFIX}/lib/python${ROS_PYTHON_VERSION}/site-packages~g" {} + || true + find $PREFIX/share/rosidl* -type f -exec sed -i "s~$PREFIX/lib/python${ROS_PYTHON_VERSION}/site-packages~${BUILD_PREFIX}/lib/python${ROS_PYTHON_VERSION}/site-packages~g" {} + || true + find $PREFIX/share/rosidl* -type f -exec sed -i "s~\${_IMPORT_PREFIX}/lib/python${ROS_PYTHON_VERSION}/site-packages~${BUILD_PREFIX}/lib/python${ROS_PYTHON_VERSION}/site-packages~g" {} + || true + find $PREFIX/lib/cmake -type f -exec sed -i "s~message(FATAL_ERROR \"The imported target~message(WARNING \"The imported target~g" {} + || true +fi + +if [[ $target_platform =~ linux.* ]]; then + export CFLAGS="${CFLAGS} -D__STDC_FORMAT_MACROS=1" + export CXXFLAGS="${CXXFLAGS} -D__STDC_FORMAT_MACROS=1" +fi; + +# Needed for qt-gui-cpp .. +if [[ $target_platform =~ linux.* ]]; then + ln -s $GCC ${BUILD_PREFIX}/bin/gcc + ln -s $GXX ${BUILD_PREFIX}/bin/g++ +fi; + +cmake \ + -G "Ninja" \ + -DCMAKE_INSTALL_PREFIX=$PREFIX \ + -DCMAKE_PREFIX_PATH=$PREFIX \ + -DAMENT_PREFIX_PATH=$PREFIX \ + -DCMAKE_INSTALL_LIBDIR=lib \ + -DCMAKE_BUILD_TYPE=Release \ + -DPYTHON_EXECUTABLE=$PYTHON_EXECUTABLE \ + -DPython_EXECUTABLE=$PYTHON_EXECUTABLE \ + -DPython3_EXECUTABLE=$PYTHON_EXECUTABLE \ + -DPython3_FIND_STRATEGY=LOCATION \ + -DPKG_CONFIG_EXECUTABLE=$PKG_CONFIG_EXECUTABLE \ + -DPYTHON_INSTALL_DIR=$FIXED_SP_DIR \ + -DSETUPTOOLS_DEB_LAYOUT=OFF \ + -DCATKIN_SKIP_TESTING=$SKIP_TESTING \ + -DCMAKE_INSTALL_SYSTEM_RUNTIME_LIBS_SKIP=True \ + -DBUILD_SHARED_LIBS=ON \ + -DBUILD_TESTING=OFF \ + -DCMAKE_OSX_DEPLOYMENT_TARGET=$OSX_DEPLOYMENT_TARGET \ + --compile-no-warning-as-error \ + $SRC_DIR/$PKG_NAME/src/work + +cmake --build . --config Release --target install diff --git a/recipes/ros-humble-unilabos-msgs/recipe.yaml b/recipes/ros-humble-unilabos-msgs/recipe.yaml new file mode 100644 index 00000000..db9c3ede --- /dev/null +++ b/recipes/ros-humble-unilabos-msgs/recipe.yaml @@ -0,0 +1,61 @@ +package: + name: ros-humble-unilabos-msgs + version: 0.8.0 +source: + path: ../../unilabos_msgs + folder: ros-humble-unilabos-msgs/src/work + +build: + script: + sel(win): bld_ament_cmake.bat + sel(unix): build_ament_cmake.sh + number: 5 +about: + home: https://www.ros.org/ + license: BSD-3-Clause + summary: | + Robot Operating System + +extra: + recipe-maintainers: + - ros-forge + +requirements: + build: + - "{{ compiler('cxx') }}" + - "{{ compiler('c') }}" + - sel(linux64): sysroot_linux-64 2.17 + - ninja + - setuptools + - sel(unix): make + - sel(unix): coreutils + - sel(osx): tapi + - sel(build_platform != target_platform): pkg-config + - cmake + - cython + - sel(win): vs2022_win-64 + - sel(build_platform != target_platform): python + - sel(build_platform != target_platform): cross-python_{{ target_platform }} + - sel(build_platform != target_platform): numpy + host: + - numpy + - pip + - sel(build_platform == target_platform): pkg-config + - robostack-staging::ros-humble-action-msgs + - robostack-staging::ros-humble-ament-cmake + - robostack-staging::ros-humble-ament-lint-auto + - robostack-staging::ros-humble-ament-lint-common + - robostack-staging::ros-humble-ros-environment + - robostack-staging::ros-humble-ros-workspace + - robostack-staging::ros-humble-rosidl-default-generators + - robostack-staging::ros-humble-std-msgs + - robostack-staging::ros-humble-geometry-msgs + - robostack-staging::ros2-distro-mutex=0.6.* + run: + - robostack-staging::ros-humble-action-msgs + - robostack-staging::ros-humble-ros-workspace + - robostack-staging::ros-humble-rosidl-default-runtime + - robostack-staging::ros-humble-std-msgs + - robostack-staging::ros-humble-geometry-msgs + - robostack-staging::ros2-distro-mutex=0.6.* + - sel(osx and x86_64): __osx >={{ MACOSX_DEPLOYMENT_TARGET|default('10.14') }} diff --git a/recipes/unilabos/clean_build_dir.py b/recipes/unilabos/clean_build_dir.py new file mode 100644 index 00000000..e234301c --- /dev/null +++ b/recipes/unilabos/clean_build_dir.py @@ -0,0 +1,15 @@ +import os +import shutil +for item in os.listdir("../.."): + if item.startswith("."): + continue + if item.endswith(".bat"): + continue + if item in ("setup.py", "unilabos", "config"): + continue + print("****", item) + if os.path.isfile(item) or os.path.islink(item): + os.remove(item) + elif os.path.isdir(item): + shutil.rmtree(item) +print(os.listdir("../..")) \ No newline at end of file diff --git a/recipes/unilabos/recipe.yaml b/recipes/unilabos/recipe.yaml new file mode 100644 index 00000000..4fec1c02 --- /dev/null +++ b/recipes/unilabos/recipe.yaml @@ -0,0 +1,23 @@ +package: + name: unilabos + version: "0.8.0" + +source: + path: ../.. + +build: + noarch: python + script: | + {{ PYTHON }} -m pip install . --no-deps --ignore-installed -vv +# {{ PYTHON }} clean_build_dir.py + +requirements: + host: + - python + - pip + run: + - python + +test: + imports: + - unilabos diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..e152926a --- /dev/null +++ b/setup.cfg @@ -0,0 +1,4 @@ +[develop] +script_dir=$base/lib/unilabos +[install] +install_scripts=$base/lib/unilabos diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..b13ea887 --- /dev/null +++ b/setup.py @@ -0,0 +1,32 @@ +from setuptools import setup, find_packages +from glob import glob +import os + +package_name = 'unilabos' + +setup( + name=package_name, + version='0.8.0', + packages=find_packages(), + # data_files=[ + # ('share/ament_index/resource_index/packages', + # ['resource/' + package_name]), + # ('share/' + package_name, ['package.xml']), + # # (os.path.join('share', package_name, 'launch'), glob('launch/*.launch.py')), + # # (os.path.join('share', package_name, 'urdf'), glob('urdf/*')), + # # (os.path.join('share', package_name, 'meshes'), glob('meshes/*')), + # # (os.path.join('share', package_name, 'config'), glob('config/*')) + # ], + install_requires=['setuptools'], + zip_safe=True, + maintainer='Junhan Chang', + maintainer_email='changjh@pku.edu.cn', + description='TODO: Package description', + license='TODO: License declaration', + tests_require=['pytest'], + entry_points={ + 'console_scripts': [ + "unilab = unilabos.app.main:main", + ], + }, +) diff --git a/test/experiments/Grignard_flow_batchreact_single_pumpvalve.json b/test/experiments/Grignard_flow_batchreact_single_pumpvalve.json new file mode 100644 index 00000000..c3d70c80 --- /dev/null +++ b/test/experiments/Grignard_flow_batchreact_single_pumpvalve.json @@ -0,0 +1,966 @@ +{ + "nodes": [ + { + "id": "YugongStation", + "name": "愚公常量合成工作站", + "children": [ + "serial_pump", + "pump_reagents", + "flask_CH2Cl2", + "flask_acetone", + "flask_NH4Cl", + "flask_grignard", + "flask_THF", + "reactor", + "pump_workup", + "waste_workup", + "separator_controller", + "flask_separator", + "flask_holding", + "flask_H2O", + "flask_NaHCO3", + "pump_column", + "rotavap", + "flask_rv", + "column", + "flask_column", + "flask_air", + "dry_column", + "flask_dry_column", + "pump_ext", + "stirrer" + ], + "parent": null, + "type": "device", + "class": "workstation", + "position": { + "x": 620.6111111111111, + "y": 171, + "z": 0 + }, + "config": { + "protocol_type": ["PumpTransferProtocol", "CleanProtocol", "SeparateProtocol", "EvaporateProtocol"] + }, + "data": { + } + }, + { + "id": "serial_pump", + "name": "serial_pump", + "children": [], + "parent": "YugongStation", + "type": "device", + "class": "serial", + "position": { + "x": 620.6111111111111, + "y": 171, + "z": 0 + }, + "config": { + "port": "COM7", + "baudrate": 9600 + }, + "data": { + } + }, + { + "id": "pump_reagents", + "name": "pump_reagents", + "children": [], + "parent": "YugongStation", + "type": "device", + "class": "syringepump.runze", + "position": { + "x": 620.6111111111111, + "y": 171, + "z": 0 + }, + "config": { + "port": "/devices/PumpBackbone/Serial/serialwrite", + "address": "1", + "max_volume": 25.0 + }, + "data": { + "max_velocity": 1.0, + "position": 0.0, + "status": "Idle", + "valve_position": "0" + } + }, + { + "id": "flask_CH2Cl2", + "name": "flask_CH2Cl2", + "children": [], + "parent": "YugongStation", + "type": "container", + "class": null, + "position": { + "x": 430.4087301587302, + "y": 428, + "z": 0 + }, + "config": { + "max_volume": 2000.0 + }, + "data": { + "liquid": [ + { + "liquid_type": "CH2Cl2", + "liquid_volume": 1500.0 + } + ] + } + }, + { + "id": "flask_acetone", + "name": "flask_acetone", + "children": [], + "parent": "YugongStation", + "type": "container", + "class": null, + "position": { + "x": 295.36944444444447, + "y": 428, + "z": 0 + }, + "config": { + "max_volume": 2000.0 + }, + "data": { + "liquid": [ + { + "liquid_type": "acetone", + "liquid_volume": 1500.0 + } + ] + } + }, + { + "id": "flask_NH4Cl", + "name": "flask_NH4Cl", + "children": [], + "parent": "YugongStation", + "type": "container", + "class": null, + "position": { + "x": 165.36944444444444, + "y": 428, + "z": 0 + }, + "config": { + "max_volume": 2000.0 + }, + "data": { + "liquid": [ + { + "liquid_type": "NH4Cl", + "liquid_volume": 1500.0 + } + ] + } + }, + { + "id": "flask_grignard", + "name": "flask_grignard", + "children": [], + "parent": "YugongStation", + "type": "container", + "class": null, + "position": { + "x": 165.36944444444444, + "y": 428, + "z": 0 + }, + "config": { + "max_volume": 2000.0 + }, + "data": { + "liquid": [ + { + "liquid_type": "grignard", + "liquid_volume": 1500.0 + } + ] + } + }, + { + "id": "flask_THF", + "name": "flask_THF", + "children": [], + "parent": "YugongStation", + "type": "container", + "class": null, + "position": { + "x": 35, + "y": 428, + "z": 0 + }, + "config": { + "max_volume": 2000.0 + }, + "data": { + "liquid": [ + { + "liquid_type": "THF", + "liquid_volume": 1500.0 + } + ] + } + }, + { + "id": "reactor", + "name": "reactor", + "children": [], + "parent": "YugongStation", + "type": "container", + "class": null, + "position": { + "x": 698.1111111111111, + "y": 428, + "z": 0 + }, + "config": { + "max_volume": 5000.0 + }, + "data": { + "liquid": [ + ] + } + }, + { + "id": "stirrer", + "name": "stirrer", + "children": [], + "parent": "YugongStation", + "type": "device", + "class": "heaterstirrer.dalong", + "position": { + "x": 698.1111111111111, + "y": 478, + "z": 0 + }, + "config": { + "port": "COM43", + "temp_warning": 60.0 + }, + "data": { + "status": "Idle", + "temp": 0.0, + "stir_speed": 0.0 + } + }, + { + "id": "pump_workup", + "name": "pump_workup", + "children": [], + "parent": "YugongStation", + "type": "device", + "class": "syringepump.runze", + "position": { + "x": 1195.611507936508, + "y": 686, + "z": 0 + }, + "config": { + "port": "/devices/PumpBackbone/Serial/serialwrite", + "address": "2", + "max_volume": 25.0 + }, + "data": { + "max_velocity": 1.0, + "position": 0.0, + "status": "Idle", + "valve_position": "0" + } + }, + { + "id": "waste_workup", + "name": "waste_workup", + "children": [], + "parent": "YugongStation", + "type": "container", + "class": null, + "position": { + "x": 1587.703373015873, + "y": 1172.5, + "z": 0 + }, + "config": { + "max_volume": 2000.0 + }, + "data": { + "liquid": [ + ] + } + }, + { + "id": "separator_controller", + "name": "separator_controller", + "children": [], + "parent": "YugongStation", + "type": "device", + "class": "separator_controller", + "position": { + "x": 1624.4027777777778, + "y": 665.5, + "z": 0 + }, + "config": { + "port_executor": "COM41", + "port_sensor": "COM40" + }, + "data": { + "sensordata": 0.0, + "status": "Idle" + } + }, + { + "id": "flask_separator", + "name": "flask_separator", + "children": [], + "parent": "YugongStation", + "type": "container", + "class": null, + "position": { + "x": 1614.404365079365, + "y": 948, + "z": 0 + }, + "config": { + "max_volume": 2000.0 + }, + "data": { + "liquid": [ + ] + } + }, + { + "id": "flask_holding", + "name": "flask_holding", + "children": [], + "parent": "YugongStation", + "type": "container", + "class": null, + "position": { + "x": 1915.7035714285714, + "y": 665.5, + "z": 0 + }, + "config": { + "max_volume": 2000.0 + }, + "data": { + "liquid": [ + ] + } + }, + { + "id": "flask_H2O", + "name": "flask_H2O", + "children": [], + "parent": "YugongStation", + "type": "container", + "class": null, + "position": { + "x": 1785.7035714285714, + "y": 665.5, + "z": 0 + }, + "config": { + "max_volume": 2000.0 + }, + "data": { + "liquid": [ + { + "liquid_type": "H2O", + "liquid_volume": 1500.0 + } + ] + } + }, + { + "id": "flask_NaHCO3", + "name": "flask_NaHCO3", + "children": [], + "parent": "YugongStation", + "type": "container", + "class": null, + "position": { + "x": 2054.0650793650793, + "y": 665.5, + "z": 0 + }, + "config": { + "max_volume": 2000.0 + }, + "data": { + "liquid": [ + { + "liquid_type": "NaHCO3", + "liquid_volume": 1500.0 + } + ] + } + }, + { + "id": "pump_column", + "name": "pump_column", + "children": [], + "parent": "YugongStation", + "type": "device", + "class": "syringepump.runze", + "position": { + "x": 1630.6527777777778, + "y": 448.5, + "z": 0 + }, + "config": { + "port": "/devices/PumpBackbone/Serial/serialwrite", + "address": "3", + "max_volume": 25.0 + }, + "data": { + "max_velocity": 1.0, + "position": 0.0, + "status": "Idle", + "valve_position": "0" + } + }, + { + "id": "rotavap", + "name": "rotavap", + "children": [], + "parent": "YugongStation", + "type": "device", + "class": "rotavap", + "position": { + "x": 1339.7031746031746, + "y": 968.5, + "z": 0 + }, + "config": { + "port": "COM15" + }, + "data": { + "temperature": 0.0, + "rotate_time": 0.0, + "status": "Idle" + } + }, + { + "id": "flask_rv", + "name": "flask_rv", + "children": [], + "parent": "YugongStation", + "type": "container", + "class": null, + "position": { + "x": 1339.7031746031746, + "y": 1152, + "z": 0 + }, + "config": { + "max_volume": 2000.0 + }, + "data": { + "liquid": [ + ] + } + }, + { + "id": "column", + "name": "column", + "children": [], + "parent": "YugongStation", + "type": "container", + "class": null, + "position": { + "x": 909.722619047619, + "y": 948, + "z": 0 + }, + "config": { + "max_volume": 200.0 + }, + "data": { + "liquid": [ + ] + } + }, + { + "id": "flask_column", + "name": "flask_column", + "children": [], + "parent": "YugongStation", + "type": "container", + "class": null, + "position": { + "x": 867.972619047619, + "y": 1152, + "z": 0 + }, + "config": { + "max_volume": 2000.0 + }, + "data": { + "liquid": [ + ] + } + }, + { + "id": "flask_air", + "name": "flask_air", + "children": [], + "parent": "YugongStation", + "type": "container", + "class": null, + "position": { + "x": 742.722619047619, + "y": 948, + "z": 0 + }, + "config": { + "max_volume": 2000.0 + }, + "data": { + "liquid": [ + ] + } + }, + { + "id": "dry_column", + "name": "dry_column", + "children": [], + "parent": "YugongStation", + "type": "container", + "class": null, + "position": { + "x": 1206.722619047619, + "y": 948, + "z": 0 + }, + "config": { + "max_volume": 200.0 + }, + "data": { + "liquid": [ + ] + } + }, + { + "id": "flask_dry_column", + "name": "flask_dry_column", + "children": [], + "parent": "YugongStation", + "type": "container", + "class": null, + "position": { + "x": 1148.222619047619, + "y": 1152, + "z": 0 + }, + "config": { + "max_volume": 2000.0 + }, + "data": { + "liquid": [ + ] + } + }, + { + "id": "pump_ext", + "name": "pump_ext", + "children": [], + "parent": "YugongStation", + "type": "device", + "class": "syringepump.runze", + "position": { + "x": 1469.7031746031746, + "y": 968.5, + "z": 0 + }, + "config": { + "port": "/devices/PumpBackbone/Serial/serialwrite", + "address": "4", + "max_volume": 25.0 + }, + "data": { + "max_velocity": 1.0, + "position": 0.0, + "status": "Idle", + "valve_position": "0" + } + }, + { + "id": "AGV", + "name": "AGV", + "children": ["zhixing_agv", "zhixing_ur_arm"], + "parent": null, + "type": "device", + "class": "workstation", + "position": { + "x": 698.1111111111111, + "y": 478, + "z": 0 + }, + "config": { + "protocol_type": ["AGVTransferProtocol"] + }, + "data": { + } + }, + { + "id": "zhixing_agv", + "name": "zhixing_agv", + "children": [], + "parent": "AGV", + "type": "device", + "class": "zhixing_agv", + "position": { + "x": 698.1111111111111, + "y": 478, + "z": 0 + }, + "config": { + "host": "192.168.1.42" + }, + "data": { + } + }, + { + "id": "zhixing_ur_arm", + "name": "zhixing_ur_arm", + "children": [], + "parent": "AGV", + "type": "device", + "class": "zhixing_ur_arm", + "position": { + "x": 698.1111111111111, + "y": 478, + "z": 0 + }, + "config": { + "host": "192.168.1.178" + }, + "data": { + } + } + ], + "links": [ + { + "source": "pump_reagents", + "target": "serial_pump", + "type": "communication", + "port": { + "pump_reagents": "port", + "serial_pump": "port" + } + }, + { + "source": "pump_workup", + "target": "serial_pump", + "type": "communication", + "port": { + "pump_reagents": "port", + "serial_pump": "port" + } + }, + { + "source": "pump_column", + "target": "serial_pump", + "type": "communication", + "port": { + "pump_reagents": "port", + "serial_pump": "port" + } + }, + { + "source": "pump_ext", + "target": "serial_pump", + "type": "communication", + "port": { + "pump_reagents": "port", + "serial_pump": "port" + } + }, + { + "source": "reactor", + "target": "pump_reagents", + "type": "physical", + "port": { + "reactor": "top", + "pump_reagents": "5" + } + }, + { + "source": "rotavap", + "target": "flask_rv", + "type": "physical", + "port": { + "rotavap": "bottom", + "flask_rv": "top" + } + }, + { + "source": "separator_controller", + "target": "flask_separator", + "type": "physical", + "port": { + "separator_controller": "bottom", + "flask_separator": "top" + } + }, + { + "source": "column", + "target": "flask_column", + "type": "physical", + "port": { + "column": "bottom", + "flask_column": "top" + } + }, + { + "source": "dry_column", + "target": "flask_dry_column", + "type": "physical", + "port": { + "dry_column": "bottom", + "flask_dry_column": "top" + } + }, + { + "source": "pump_ext", + "target": "pump_column", + "type": "physical", + "port": { + "pump_ext": "8", + "pump_column": "1" + } + }, + { + "source": "pump_ext", + "target": "waste_workup", + "type": "physical", + "port": { + "pump_ext": "2", + "waste_workup": "-1" + } + }, + { + "source": "pump_reagents", + "target": "flask_THF", + "type": "physical", + "port": { + "pump_reagents": "7", + "flask_THF": "top" + } + }, + { + "source": "pump_reagents", + "target": "flask_NH4Cl", + "type": "physical", + "port": { + "pump_reagents": "4", + "flask_NH4Cl": "top" + } + }, + { + "source": "pump_reagents", + "target": "flask_CH2Cl2", + "type": "physical", + "port": { + "pump_reagents": "2", + "flask_CH2Cl2": "top" + } + }, + { + "source": "pump_reagents", + "target": "flask_acetone", + "type": "physical", + "port": { + "pump_reagents": "3", + "flask_acetone": "top" + } + }, + { + "source": "pump_reagents", + "target": "pump_workup", + "type": "physical", + "port": { + "pump_reagents": "1", + "pump_workup": "8" + } + }, + { + "source": "pump_reagents", + "target": "flask_grignard", + "type": "physical", + "port": { + "pump_reagents": "6", + "flask_grignard": "top" + } + }, + { + "source": "pump_reagents", + "target": "reactor", + "type": "physical", + "port": { + "pump_reagents": "5", + "reactor": "top" + } + }, + { + "source": "pump_reagents", + "target": "flask_air", + "type": "physical", + "port": { + "pump_reagents": "8", + "flask_air": "-1" + } + }, + { + "source": "pump_workup", + "target": "waste_workup", + "type": "physical", + "port": { + "pump_workup": "2", + "waste_workup": "-1" + } + }, + { + "source": "pump_workup", + "target": "flask_H2O", + "type": "physical", + "port": { + "pump_workup": "7", + "flask_H2O": "top" + } + }, + { + "source": "pump_workup", + "target": "flask_NaHCO3", + "type": "physical", + "port": { + "pump_workup": "6", + "flask_NaHCO3": "top" + } + }, + { + "source": "pump_workup", + "target": "pump_reagents", + "type": "physical", + "port": { + "pump_workup": "8", + "pump_reagents": "1" + } + }, + { + "source": "pump_workup", + "target": "flask_holding", + "type": "physical", + "port": { + "pump_workup": "5", + "flask_holding": "top" + } + }, + { + "source": "pump_workup", + "target": "separator_controller", + "type": "physical", + "port": { + "pump_workup": "4", + "separator_controller": "top" + } + }, + { + "source": "pump_workup", + "target": "flask_separator", + "type": "physical", + "port": { + "pump_workup": "3", + "flask_separator": "top" + } + }, + { + "source": "pump_workup", + "target": "pump_column", + "type": "physical", + "port": { + "pump_workup": "1", + "pump_column": "8" + } + }, + { + "source": "pump_column", + "target": "column", + "type": "physical", + "port": { + "pump_column": "4", + "column": "top" + } + }, + { + "source": "pump_column", + "target": "flask_column", + "type": "physical", + "port": { + "pump_column": "3", + "flask_column": "top" + } + }, + { + "source": "pump_column", + "target": "rotavap", + "type": "physical", + "port": { + "pump_column": "2", + "rotavap": "-1" + } + }, + { + "source": "pump_column", + "target": "pump_workup", + "type": "physical", + "port": { + "pump_column": "8", + "pump_workup": "1" + } + }, + { + "source": "pump_column", + "target": "flask_air", + "type": "physical", + "port": { + "pump_column": "5", + "flask_air": "-1" + } + }, + { + "source": "pump_column", + "target": "dry_column", + "type": "physical", + "port": { + "pump_column": "7", + "dry_column": "top" + } + }, + { + "source": "pump_column", + "target": "flask_dry_column", + "type": "physical", + "port": { + "pump_column": "6", + "flask_dry_column": "top" + } + }, + { + "source": "pump_column", + "target": "pump_ext", + "type": "physical", + "port": { + "pump_column": "1", + "pump_ext": "8" + } + } + ] +} \ No newline at end of file diff --git a/test/experiments/HPLC.json b/test/experiments/HPLC.json new file mode 100644 index 00000000..9e511b3c --- /dev/null +++ b/test/experiments/HPLC.json @@ -0,0 +1,174 @@ +{ + "nodes": [ + { + "id": "HPLC", + "name": "HPLC", + "parent": null, + "type": "device", + "class": "hplc", + "position": { + "x": 620.6111111111111, + "y": 171, + "z": 0 + }, + "config": {}, + "data": {}, + "children": [ + "BottlesRack3" + ] + }, + { + "id": "BottlesRack3", + "name": "Revvity上样盘3", + "parent": "Revvity", + "type": "plate", + "class": null, + "position": { + "x": 620.6111111111111, + "y": 171, + "z": 0 + }, + "config": {}, + "data": {}, + "children": [ + "Bottle3-1", + "Bottle3-2", + "Bottle3-3", + "Bottle3-4", + "Bottle3-5", + "Bottle3-6", + "Bottle3-7", + "Bottle3-8" + ] + }, + { + "id": "Bottle3-1", + "name": "Bottle3-1", + "parent": "BottlesRack3", + "type": "container", + "class": null, + "position": { + "x": 620.6111111111111, + "y": 171, + "z": 0 + }, + "config": {}, + "data": {}, + "children": [ + ] + }, + { + "id": "Bottle3-2", + "name": "Bottle3-2", + "parent": "BottlesRack3", + "type": "container", + "class": null, + "position": { + "x": 620.6111111111111, + "y": 171, + "z": 0 + }, + "config": {}, + "data": {}, + "children": [ + ] + }, + { + "id": "Bottle3-3", + "name": "Bottle3-3", + "parent": "BottlesRack3", + "type": "container", + "class": null, + "position": { + "x": 620.6111111111111, + "y": 171, + "z": 0 + }, + "config": {}, + "data": {}, + "children": [ + ] + }, + { + "id": "Bottle3-4", + "name": "Bottle3-4", + "parent": "BottlesRack3", + "type": "container", + "class": null, + "position": { + "x": 620.6111111111111, + "y": 171, + "z": 0 + }, + "config": {}, + "data": {}, + "children": [ + ] + }, + { + "id": "Bottle3-5", + "name": "Bottle3-5", + "parent": "BottlesRack3", + "type": "container", + "class": null, + "position": { + "x": 620.6111111111111, + "y": 171, + "z": 0 + }, + "config": {}, + "data": {}, + "children": [ + ] + }, + { + "id": "Bottle3-6", + "name": "Bottle3-6", + "parent": "BottlesRack3", + "type": "container", + "class": null, + "position": { + "x": 620.6111111111111, + "y": 171, + "z": 0 + }, + "config": {}, + "data": {}, + "children": [ + ] + }, + { + "id": "Bottle3-7", + "name": "Bottle3-7", + "parent": "BottlesRack3", + "type": "container", + "class": null, + "position": { + "x": 620.6111111111111, + "y": 171, + "z": 0 + }, + "config": {}, + "data": {}, + "children": [ + ] + }, + { + "id": "Bottle3-8", + "name": "Bottle3-8", + "parent": "BottlesRack3", + "type": "container", + "class": null, + "position": { + "x": 620.6111111111111, + "y": 171, + "z": 0 + }, + "config": {}, + "data": {}, + "children": [ + ] + } + ], + "links": [] +} \ No newline at end of file diff --git a/test/experiments/HT_hiwo.json b/test/experiments/HT_hiwo.json new file mode 100644 index 00000000..1807c110 --- /dev/null +++ b/test/experiments/HT_hiwo.json @@ -0,0 +1,4806 @@ +{ + "nodes": [ + { + "id": "AiChemEcoHiWo", + "name": "标智移液工作站", + "parent": null, + "type": "device", + "class": "work_station.aichemeco_hiwo", + "position": { + "x": 620.6111111111111, + "y": 171, + "z": 0 + }, + "config": {}, + "data": {}, + "schema": { + "type": "object", + "properties": {} + }, + "children": [ + "test-ZZ-B02", + "test-FY96-B01", + "test-GL96-2A01" + ] + }, + { + "id": "test-ZZ-B02", + "name": "96孔中转板", + "type": "plate", + "data": { + "layout": { + "gridCount": 96, + "gridColumnNumber": 12 + } + }, + "parent": "AiChemEcoHiWo", + "children": [ + "test-ZZ-B02-01", + "test-ZZ-B02-02", + "test-ZZ-B02-03", + "test-ZZ-B02-04", + "test-ZZ-B02-05", + "test-ZZ-B02-06", + "test-ZZ-B02-07", + "test-ZZ-B02-08", + "test-ZZ-B02-09", + "test-ZZ-B02-10", + "test-ZZ-B02-11", + "test-ZZ-B02-12", + "test-ZZ-B02-13", + "test-ZZ-B02-14", + "test-ZZ-B02-15", + "test-ZZ-B02-16", + "test-ZZ-B02-17", + "test-ZZ-B02-18", + "test-ZZ-B02-19", + "test-ZZ-B02-20", + "test-ZZ-B02-21", + "test-ZZ-B02-22", + "test-ZZ-B02-23", + "test-ZZ-B02-24", + "test-ZZ-B02-25", + "test-ZZ-B02-26", + "test-ZZ-B02-27", + "test-ZZ-B02-28", + "test-ZZ-B02-29", + "test-ZZ-B02-30", + "test-ZZ-B02-31", + "test-ZZ-B02-32", + "test-ZZ-B02-33", + "test-ZZ-B02-34", + "test-ZZ-B02-35", + "test-ZZ-B02-36", + "test-ZZ-B02-37", + "test-ZZ-B02-38", + "test-ZZ-B02-39", + "test-ZZ-B02-40", + "test-ZZ-B02-41", + "test-ZZ-B02-42", + "test-ZZ-B02-43", + "test-ZZ-B02-44", + "test-ZZ-B02-45", + "test-ZZ-B02-46", + "test-ZZ-B02-47", + "test-ZZ-B02-48", + "test-ZZ-B02-49", + "test-ZZ-B02-50", + "test-ZZ-B02-51", + "test-ZZ-B02-52", + "test-ZZ-B02-53", + "test-ZZ-B02-54", + "test-ZZ-B02-55", + "test-ZZ-B02-56", + "test-ZZ-B02-57", + "test-ZZ-B02-58", + "test-ZZ-B02-59", + "test-ZZ-B02-60", + "test-ZZ-B02-61", + "test-ZZ-B02-62", + "test-ZZ-B02-63", + "test-ZZ-B02-64", + "test-ZZ-B02-65", + "test-ZZ-B02-66", + "test-ZZ-B02-67", + "test-ZZ-B02-68", + "test-ZZ-B02-69", + "test-ZZ-B02-70", + "test-ZZ-B02-71", + "test-ZZ-B02-72", + "test-ZZ-B02-73", + "test-ZZ-B02-74", + "test-ZZ-B02-75", + "test-ZZ-B02-76", + "test-ZZ-B02-77", + "test-ZZ-B02-78", + "test-ZZ-B02-79", + "test-ZZ-B02-80", + "test-ZZ-B02-81", + "test-ZZ-B02-82", + "test-ZZ-B02-83", + "test-ZZ-B02-84", + "test-ZZ-B02-85", + "test-ZZ-B02-86", + "test-ZZ-B02-87", + "test-ZZ-B02-88", + "test-ZZ-B02-89", + "test-ZZ-B02-90", + "test-ZZ-B02-91", + "test-ZZ-B02-92", + "test-ZZ-B02-93", + "test-ZZ-B02-94", + "test-ZZ-B02-95", + "test-ZZ-B02-96" + ] + }, + { + "id": "test-ZZ-B02-01", + "name": "test-ZZ-B02-01", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "data": { + "liquid": [ + { + "liquid_type": "CuBr2", + "liquid_volume": "100" + } + ] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-02", + "name": "test-ZZ-B02-02", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 1, + "y": 0, + "z": 0 + }, + "data": { + "liquid": [ + { + "liquid_type": "CuBr-SMe2", + "liquid_volume": "100" + } + ] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-03", + "name": "test-ZZ-B02-03", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 2, + "y": 0, + "z": 0 + }, + "data": { + "liquid": [ + { + "liquid_type": "IPrCuCl", + "liquid_volume": "100" + } + ] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-04", + "name": "test-ZZ-B02-04", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 3, + "y": 0, + "z": 0 + }, + "data": { + "liquid": [ + { + "liquid_type": "Cu(MeCN)4PF6", + "liquid_volume": "100" + } + ] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-05", + "name": "test-ZZ-B02-05", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 4, + "y": 0, + "z": 0 + }, + "data": { + "liquid": [ + { + "liquid_type": "[CuI2NBu4]2", + "liquid_volume": "100" + } + ] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-06", + "name": "test-ZZ-B02-06", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 5, + "y": 0, + "z": 0 + }, + "data": { + "liquid": [ + { + "liquid_type": "CuCl2", + "liquid_volume": "100" + } + ] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-07", + "name": "test-ZZ-B02-07", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 6, + "y": 0, + "z": 0 + }, + "data": { + "liquid": [ + { + "liquid_type": "CuCl2", + "liquid_volume": "100" + } + ] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-08", + "name": "test-ZZ-B02-08", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 7, + "y": 0, + "z": 0 + }, + "data": { + "liquid": [ + { + "liquid_type": "[CuI2NBu4]2", + "liquid_volume": "100" + } + ] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-09", + "name": "test-ZZ-B02-09", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 0, + "y": 1, + "z": 0 + }, + "data": { + "liquid": [ + { + "liquid_type": "CC#N", + "liquid_volume": "100" + } + ] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-10", + "name": "test-ZZ-B02-10", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 1, + "y": 1, + "z": 0 + }, + "data": { + "liquid": [ + { + "liquid_type": "CC1=CC=CC=C1", + "liquid_volume": "100" + } + ] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-11", + "name": "test-ZZ-B02-11", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 2, + "y": 1, + "z": 0 + }, + "data": { + "liquid": [ + { + "liquid_type": "CC#N", + "liquid_volume": "100" + } + ] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-12", + "name": "test-ZZ-B02-12", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 3, + "y": 1, + "z": 0 + }, + "data": { + "liquid": [ + { + "liquid_type": "CC#N", + "liquid_volume": "100" + } + ] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-13", + "name": "test-ZZ-B02-13", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 4, + "y": 1, + "z": 0 + }, + "data": { + "liquid": [ + { + "liquid_type": "O=C(C)OCC", + "liquid_volume": "100" + } + ] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-14", + "name": "test-ZZ-B02-14", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 5, + "y": 1, + "z": 0 + }, + "data": { + "liquid": [ + { + "liquid_type": "CC#N", + "liquid_volume": "100" + } + ] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-15", + "name": "test-ZZ-B02-15", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 6, + "y": 1, + "z": 0 + }, + "data": { + "liquid": [ + { + "liquid_type": "CC#N", + "liquid_volume": "100" + } + ] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-16", + "name": "test-ZZ-B02-16", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 7, + "y": 1, + "z": 0 + }, + "data": { + "liquid": [ + { + "liquid_type": "CC#N", + "liquid_volume": "100" + } + ] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-17", + "name": "test-ZZ-B02-17", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 0, + "y": 2, + "z": 0 + }, + "data": { + "liquid": [ + { + "liquid_type": "CC(C)([O-])C", + "liquid_volume": "100" + } + ] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-18", + "name": "test-ZZ-B02-18", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 1, + "y": 2, + "z": 0 + }, + "data": { + "liquid": [ + { + "liquid_type": "CCN", + "liquid_volume": "100" + } + ] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-19", + "name": "test-ZZ-B02-19", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 2, + "y": 2, + "z": 0 + }, + "data": { + "liquid": [ + { + "liquid_type": "C1COCCN1", + "liquid_volume": "100" + } + ] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-20", + "name": "test-ZZ-B02-20", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 3, + "y": 2, + "z": 0 + }, + "data": { + "liquid": [ + { + "liquid_type": "C1CCN2CCCN=C2CC1", + "liquid_volume": "100" + } + ] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-21", + "name": "test-ZZ-B02-21", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 4, + "y": 2, + "z": 0 + }, + "data": { + "liquid": [ + { + "liquid_type": "CC(C)([O-])C", + "liquid_volume": "100" + } + ] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-22", + "name": "test-ZZ-B02-22", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 5, + "y": 2, + "z": 0 + }, + "data": { + "liquid": [ + { + "liquid_type": "C1COCCN1", + "liquid_volume": "100" + } + ] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-23", + "name": "test-ZZ-B02-23", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 6, + "y": 2, + "z": 0 + }, + "data": { + "liquid": [ + { + "liquid_type": "C1CCCCN1", + "liquid_volume": "100" + } + ] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-24", + "name": "test-ZZ-B02-24", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 7, + "y": 2, + "z": 0 + }, + "data": { + "liquid": [ + { + "liquid_type": "CN(C)C1=CC=NC=C1", + "liquid_volume": "100" + } + ] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-25", + "name": "test-ZZ-B02-25", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 0, + "y": 3, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-26", + "name": "test-ZZ-B02-26", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 1, + "y": 3, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-27", + "name": "test-ZZ-B02-27", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 2, + "y": 3, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-28", + "name": "test-ZZ-B02-28", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 3, + "y": 3, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-29", + "name": "test-ZZ-B02-29", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 4, + "y": 3, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-30", + "name": "test-ZZ-B02-30", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 5, + "y": 3, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-31", + "name": "test-ZZ-B02-31", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 6, + "y": 3, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-32", + "name": "test-ZZ-B02-32", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 7, + "y": 3, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-33", + "name": "test-ZZ-B02-33", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 0, + "y": 4, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-34", + "name": "test-ZZ-B02-34", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 1, + "y": 4, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-35", + "name": "test-ZZ-B02-35", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 2, + "y": 4, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-36", + "name": "test-ZZ-B02-36", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 3, + "y": 4, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-37", + "name": "test-ZZ-B02-37", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 4, + "y": 4, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-38", + "name": "test-ZZ-B02-38", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 5, + "y": 4, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-39", + "name": "test-ZZ-B02-39", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 6, + "y": 4, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-40", + "name": "test-ZZ-B02-40", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 7, + "y": 4, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-41", + "name": "test-ZZ-B02-41", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 0, + "y": 5, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-42", + "name": "test-ZZ-B02-42", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 1, + "y": 5, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-43", + "name": "test-ZZ-B02-43", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 2, + "y": 5, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-44", + "name": "test-ZZ-B02-44", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 3, + "y": 5, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-45", + "name": "test-ZZ-B02-45", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 4, + "y": 5, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-46", + "name": "test-ZZ-B02-46", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 5, + "y": 5, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-47", + "name": "test-ZZ-B02-47", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 6, + "y": 5, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-48", + "name": "test-ZZ-B02-48", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 7, + "y": 5, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-49", + "name": "test-ZZ-B02-49", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 0, + "y": 6, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-50", + "name": "test-ZZ-B02-50", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 1, + "y": 6, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-51", + "name": "test-ZZ-B02-51", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 2, + "y": 6, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-52", + "name": "test-ZZ-B02-52", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 3, + "y": 6, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-53", + "name": "test-ZZ-B02-53", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 4, + "y": 6, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-54", + "name": "test-ZZ-B02-54", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 5, + "y": 6, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-55", + "name": "test-ZZ-B02-55", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 6, + "y": 6, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-56", + "name": "test-ZZ-B02-56", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 7, + "y": 6, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-57", + "name": "test-ZZ-B02-57", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 0, + "y": 7, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-58", + "name": "test-ZZ-B02-58", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 1, + "y": 7, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-59", + "name": "test-ZZ-B02-59", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 2, + "y": 7, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-60", + "name": "test-ZZ-B02-60", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 3, + "y": 7, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-61", + "name": "test-ZZ-B02-61", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 4, + "y": 7, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-62", + "name": "test-ZZ-B02-62", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 5, + "y": 7, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-63", + "name": "test-ZZ-B02-63", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 6, + "y": 7, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-64", + "name": "test-ZZ-B02-64", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 7, + "y": 7, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-65", + "name": "test-ZZ-B02-65", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 0, + "y": 8, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-66", + "name": "test-ZZ-B02-66", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 1, + "y": 8, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-67", + "name": "test-ZZ-B02-67", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 2, + "y": 8, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-68", + "name": "test-ZZ-B02-68", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 3, + "y": 8, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-69", + "name": "test-ZZ-B02-69", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 4, + "y": 8, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-70", + "name": "test-ZZ-B02-70", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 5, + "y": 8, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-71", + "name": "test-ZZ-B02-71", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 6, + "y": 8, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-72", + "name": "test-ZZ-B02-72", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 7, + "y": 8, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-73", + "name": "test-ZZ-B02-73", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 0, + "y": 9, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-74", + "name": "test-ZZ-B02-74", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 1, + "y": 9, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-75", + "name": "test-ZZ-B02-75", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 2, + "y": 9, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-76", + "name": "test-ZZ-B02-76", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 3, + "y": 9, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-77", + "name": "test-ZZ-B02-77", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 4, + "y": 9, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-78", + "name": "test-ZZ-B02-78", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 5, + "y": 9, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-79", + "name": "test-ZZ-B02-79", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 6, + "y": 9, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-80", + "name": "test-ZZ-B02-80", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 7, + "y": 9, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-81", + "name": "test-ZZ-B02-81", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 0, + "y": 10, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-82", + "name": "test-ZZ-B02-82", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 1, + "y": 10, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-83", + "name": "test-ZZ-B02-83", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 2, + "y": 10, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-84", + "name": "test-ZZ-B02-84", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 3, + "y": 10, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-85", + "name": "test-ZZ-B02-85", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 4, + "y": 10, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-86", + "name": "test-ZZ-B02-86", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 5, + "y": 10, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-87", + "name": "test-ZZ-B02-87", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 6, + "y": 10, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-88", + "name": "test-ZZ-B02-88", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 7, + "y": 10, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-89", + "name": "test-ZZ-B02-89", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 0, + "y": 11, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-90", + "name": "test-ZZ-B02-90", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 1, + "y": 11, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-91", + "name": "test-ZZ-B02-91", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 2, + "y": 11, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-92", + "name": "test-ZZ-B02-92", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 3, + "y": 11, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-93", + "name": "test-ZZ-B02-93", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 4, + "y": 11, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-94", + "name": "test-ZZ-B02-94", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 5, + "y": 11, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-95", + "name": "test-ZZ-B02-95", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 6, + "y": 11, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-ZZ-B02-96", + "name": "test-ZZ-B02-96", + "type": "container", + "parent": "test-ZZ-B02", + "position": { + "x": 7, + "y": 11, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01", + "name": "96孔反应板", + "type": "plate", + "parent": "AiChemEcoHiWo", + "data": { + "layout": { + "gridCount": 96, + "gridColumnNumber": 12 + } + }, + "children": [ + "test-FY96-B01-01", + "test-FY96-B01-02", + "test-FY96-B01-03", + "test-FY96-B01-04", + "test-FY96-B01-05", + "test-FY96-B01-06", + "test-FY96-B01-07", + "test-FY96-B01-08", + "test-FY96-B01-09", + "test-FY96-B01-10", + "test-FY96-B01-11", + "test-FY96-B01-12", + "test-FY96-B01-13", + "test-FY96-B01-14", + "test-FY96-B01-15", + "test-FY96-B01-16", + "test-FY96-B01-17", + "test-FY96-B01-18", + "test-FY96-B01-19", + "test-FY96-B01-20", + "test-FY96-B01-21", + "test-FY96-B01-22", + "test-FY96-B01-23", + "test-FY96-B01-24", + "test-FY96-B01-25", + "test-FY96-B01-26", + "test-FY96-B01-27", + "test-FY96-B01-28", + "test-FY96-B01-29", + "test-FY96-B01-30", + "test-FY96-B01-31", + "test-FY96-B01-32", + "test-FY96-B01-33", + "test-FY96-B01-34", + "test-FY96-B01-35", + "test-FY96-B01-36", + "test-FY96-B01-37", + "test-FY96-B01-38", + "test-FY96-B01-39", + "test-FY96-B01-40", + "test-FY96-B01-41", + "test-FY96-B01-42", + "test-FY96-B01-43", + "test-FY96-B01-44", + "test-FY96-B01-45", + "test-FY96-B01-46", + "test-FY96-B01-47", + "test-FY96-B01-48", + "test-FY96-B01-49", + "test-FY96-B01-50", + "test-FY96-B01-51", + "test-FY96-B01-52", + "test-FY96-B01-53", + "test-FY96-B01-54", + "test-FY96-B01-55", + "test-FY96-B01-56", + "test-FY96-B01-57", + "test-FY96-B01-58", + "test-FY96-B01-59", + "test-FY96-B01-60", + "test-FY96-B01-61", + "test-FY96-B01-62", + "test-FY96-B01-63", + "test-FY96-B01-64", + "test-FY96-B01-65", + "test-FY96-B01-66", + "test-FY96-B01-67", + "test-FY96-B01-68", + "test-FY96-B01-69", + "test-FY96-B01-70", + "test-FY96-B01-71", + "test-FY96-B01-72", + "test-FY96-B01-73", + "test-FY96-B01-74", + "test-FY96-B01-75", + "test-FY96-B01-76", + "test-FY96-B01-77", + "test-FY96-B01-78", + "test-FY96-B01-79", + "test-FY96-B01-80", + "test-FY96-B01-81", + "test-FY96-B01-82", + "test-FY96-B01-83", + "test-FY96-B01-84", + "test-FY96-B01-85", + "test-FY96-B01-86", + "test-FY96-B01-87", + "test-FY96-B01-88", + "test-FY96-B01-89", + "test-FY96-B01-90", + "test-FY96-B01-91", + "test-FY96-B01-92", + "test-FY96-B01-93", + "test-FY96-B01-94", + "test-FY96-B01-95", + "test-FY96-B01-96" + ] + }, + { + "id": "test-FY96-B01-01", + "name": "test-FY96-B01-01", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "data": { + "liquid": [] + }, + "sample_id": "20250123-rxn-01", + "children": [] + }, + { + "id": "test-FY96-B01-02", + "name": "test-FY96-B01-02", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 1, + "y": 0, + "z": 0 + }, + "data": { + "liquid": [] + }, + "sample_id": "20250123-rxn-02", + "children": [] + }, + { + "id": "test-FY96-B01-03", + "name": "test-FY96-B01-03", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 2, + "y": 0, + "z": 0 + }, + "data": { + "liquid": [] + }, + "sample_id": "20250123-rxn-03", + "children": [] + }, + { + "id": "test-FY96-B01-04", + "name": "test-FY96-B01-04", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 3, + "y": 0, + "z": 0 + }, + "data": { + "liquid": [] + }, + "sample_id": "20250123-rxn-04", + "children": [] + }, + { + "id": "test-FY96-B01-05", + "name": "test-FY96-B01-05", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 4, + "y": 0, + "z": 0 + }, + "data": { + "liquid": [] + }, + "sample_id": "20250123-rxn-05", + "children": [] + }, + { + "id": "test-FY96-B01-06", + "name": "test-FY96-B01-06", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 5, + "y": 0, + "z": 0 + }, + "data": { + "liquid": [] + }, + "sample_id": "20250123-rxn-06", + "children": [] + }, + { + "id": "test-FY96-B01-07", + "name": "test-FY96-B01-07", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 6, + "y": 0, + "z": 0 + }, + "data": { + "liquid": [] + }, + "sample_id": "20250123-rxn-07", + "children": [] + }, + { + "id": "test-FY96-B01-08", + "name": "test-FY96-B01-08", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 7, + "y": 0, + "z": 0 + }, + "data": { + "liquid": [] + }, + "sample_id": "20250123-rxn-08", + "children": [] + }, + { + "id": "test-FY96-B01-09", + "name": "test-FY96-B01-09", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 0, + "y": 1, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-10", + "name": "test-FY96-B01-10", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 1, + "y": 1, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-11", + "name": "test-FY96-B01-11", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 2, + "y": 1, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-12", + "name": "test-FY96-B01-12", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 3, + "y": 1, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-13", + "name": "test-FY96-B01-13", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 4, + "y": 1, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-14", + "name": "test-FY96-B01-14", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 5, + "y": 1, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-15", + "name": "test-FY96-B01-15", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 6, + "y": 1, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-16", + "name": "test-FY96-B01-16", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 7, + "y": 1, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-17", + "name": "test-FY96-B01-17", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 0, + "y": 2, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-18", + "name": "test-FY96-B01-18", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 1, + "y": 2, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-19", + "name": "test-FY96-B01-19", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 2, + "y": 2, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-20", + "name": "test-FY96-B01-20", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 3, + "y": 2, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-21", + "name": "test-FY96-B01-21", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 4, + "y": 2, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-22", + "name": "test-FY96-B01-22", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 5, + "y": 2, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-23", + "name": "test-FY96-B01-23", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 6, + "y": 2, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-24", + "name": "test-FY96-B01-24", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 7, + "y": 2, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-25", + "name": "test-FY96-B01-25", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 0, + "y": 3, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-26", + "name": "test-FY96-B01-26", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 1, + "y": 3, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-27", + "name": "test-FY96-B01-27", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 2, + "y": 3, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-28", + "name": "test-FY96-B01-28", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 3, + "y": 3, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-29", + "name": "test-FY96-B01-29", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 4, + "y": 3, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-30", + "name": "test-FY96-B01-30", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 5, + "y": 3, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-31", + "name": "test-FY96-B01-31", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 6, + "y": 3, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-32", + "name": "test-FY96-B01-32", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 7, + "y": 3, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-33", + "name": "test-FY96-B01-33", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 0, + "y": 4, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-34", + "name": "test-FY96-B01-34", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 1, + "y": 4, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-35", + "name": "test-FY96-B01-35", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 2, + "y": 4, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-36", + "name": "test-FY96-B01-36", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 3, + "y": 4, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-37", + "name": "test-FY96-B01-37", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 4, + "y": 4, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-38", + "name": "test-FY96-B01-38", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 5, + "y": 4, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-39", + "name": "test-FY96-B01-39", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 6, + "y": 4, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-40", + "name": "test-FY96-B01-40", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 7, + "y": 4, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-41", + "name": "test-FY96-B01-41", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 0, + "y": 5, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-42", + "name": "test-FY96-B01-42", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 1, + "y": 5, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-43", + "name": "test-FY96-B01-43", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 2, + "y": 5, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-44", + "name": "test-FY96-B01-44", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 3, + "y": 5, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-45", + "name": "test-FY96-B01-45", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 4, + "y": 5, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-46", + "name": "test-FY96-B01-46", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 5, + "y": 5, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-47", + "name": "test-FY96-B01-47", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 6, + "y": 5, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-48", + "name": "test-FY96-B01-48", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 7, + "y": 5, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-49", + "name": "test-FY96-B01-49", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 0, + "y": 6, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-50", + "name": "test-FY96-B01-50", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 1, + "y": 6, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-51", + "name": "test-FY96-B01-51", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 2, + "y": 6, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-52", + "name": "test-FY96-B01-52", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 3, + "y": 6, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-53", + "name": "test-FY96-B01-53", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 4, + "y": 6, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-54", + "name": "test-FY96-B01-54", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 5, + "y": 6, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-55", + "name": "test-FY96-B01-55", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 6, + "y": 6, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-56", + "name": "test-FY96-B01-56", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 7, + "y": 6, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-57", + "name": "test-FY96-B01-57", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 0, + "y": 7, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-58", + "name": "test-FY96-B01-58", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 1, + "y": 7, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-59", + "name": "test-FY96-B01-59", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 2, + "y": 7, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-60", + "name": "test-FY96-B01-60", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 3, + "y": 7, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-61", + "name": "test-FY96-B01-61", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 4, + "y": 7, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-62", + "name": "test-FY96-B01-62", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 5, + "y": 7, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-63", + "name": "test-FY96-B01-63", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 6, + "y": 7, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-64", + "name": "test-FY96-B01-64", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 7, + "y": 7, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-65", + "name": "test-FY96-B01-65", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 0, + "y": 8, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-66", + "name": "test-FY96-B01-66", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 1, + "y": 8, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-67", + "name": "test-FY96-B01-67", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 2, + "y": 8, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-68", + "name": "test-FY96-B01-68", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 3, + "y": 8, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-69", + "name": "test-FY96-B01-69", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 4, + "y": 8, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-70", + "name": "test-FY96-B01-70", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 5, + "y": 8, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-71", + "name": "test-FY96-B01-71", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 6, + "y": 8, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-72", + "name": "test-FY96-B01-72", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 7, + "y": 8, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-73", + "name": "test-FY96-B01-73", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 0, + "y": 9, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-74", + "name": "test-FY96-B01-74", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 1, + "y": 9, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-75", + "name": "test-FY96-B01-75", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 2, + "y": 9, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-76", + "name": "test-FY96-B01-76", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 3, + "y": 9, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-77", + "name": "test-FY96-B01-77", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 4, + "y": 9, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-78", + "name": "test-FY96-B01-78", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 5, + "y": 9, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-79", + "name": "test-FY96-B01-79", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 6, + "y": 9, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-80", + "name": "test-FY96-B01-80", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 7, + "y": 9, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-81", + "name": "test-FY96-B01-81", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 0, + "y": 10, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-82", + "name": "test-FY96-B01-82", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 1, + "y": 10, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-83", + "name": "test-FY96-B01-83", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 2, + "y": 10, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-84", + "name": "test-FY96-B01-84", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 3, + "y": 10, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-85", + "name": "test-FY96-B01-85", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 4, + "y": 10, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-86", + "name": "test-FY96-B01-86", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 5, + "y": 10, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-87", + "name": "test-FY96-B01-87", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 6, + "y": 10, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-88", + "name": "test-FY96-B01-88", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 7, + "y": 10, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-89", + "name": "test-FY96-B01-89", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 0, + "y": 11, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-90", + "name": "test-FY96-B01-90", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 1, + "y": 11, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-91", + "name": "test-FY96-B01-91", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 2, + "y": 11, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-92", + "name": "test-FY96-B01-92", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 3, + "y": 11, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-93", + "name": "test-FY96-B01-93", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 4, + "y": 11, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-94", + "name": "test-FY96-B01-94", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 5, + "y": 11, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-95", + "name": "test-FY96-B01-95", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 6, + "y": 11, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-FY96-B01-96", + "name": "test-FY96-B01-96", + "type": "container", + "parent": "test-FY96-B01", + "position": { + "x": 7, + "y": 11, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01", + "name": "96孔过滤板", + "type": "plate", + "parent": "AiChemEcoHiWo", + "data": { + "layout": { + "gridCount": 96, + "gridColumnNumber": 12 + } + }, + "children": [ + "test-GL96-2A01-01", + "test-GL96-2A01-02", + "test-GL96-2A01-03", + "test-GL96-2A01-04", + "test-GL96-2A01-05", + "test-GL96-2A01-06", + "test-GL96-2A01-07", + "test-GL96-2A01-08", + "test-GL96-2A01-09", + "test-GL96-2A01-10", + "test-GL96-2A01-11", + "test-GL96-2A01-12", + "test-GL96-2A01-13", + "test-GL96-2A01-14", + "test-GL96-2A01-15", + "test-GL96-2A01-16", + "test-GL96-2A01-17", + "test-GL96-2A01-18", + "test-GL96-2A01-19", + "test-GL96-2A01-20", + "test-GL96-2A01-21", + "test-GL96-2A01-22", + "test-GL96-2A01-23", + "test-GL96-2A01-24", + "test-GL96-2A01-25", + "test-GL96-2A01-26", + "test-GL96-2A01-27", + "test-GL96-2A01-28", + "test-GL96-2A01-29", + "test-GL96-2A01-30", + "test-GL96-2A01-31", + "test-GL96-2A01-32", + "test-GL96-2A01-33", + "test-GL96-2A01-34", + "test-GL96-2A01-35", + "test-GL96-2A01-36", + "test-GL96-2A01-37", + "test-GL96-2A01-38", + "test-GL96-2A01-39", + "test-GL96-2A01-40", + "test-GL96-2A01-41", + "test-GL96-2A01-42", + "test-GL96-2A01-43", + "test-GL96-2A01-44", + "test-GL96-2A01-45", + "test-GL96-2A01-46", + "test-GL96-2A01-47", + "test-GL96-2A01-48", + "test-GL96-2A01-49", + "test-GL96-2A01-50", + "test-GL96-2A01-51", + "test-GL96-2A01-52", + "test-GL96-2A01-53", + "test-GL96-2A01-54", + "test-GL96-2A01-55", + "test-GL96-2A01-56", + "test-GL96-2A01-57", + "test-GL96-2A01-58", + "test-GL96-2A01-59", + "test-GL96-2A01-60", + "test-GL96-2A01-61", + "test-GL96-2A01-62", + "test-GL96-2A01-63", + "test-GL96-2A01-64", + "test-GL96-2A01-65", + "test-GL96-2A01-66", + "test-GL96-2A01-67", + "test-GL96-2A01-68", + "test-GL96-2A01-69", + "test-GL96-2A01-70", + "test-GL96-2A01-71", + "test-GL96-2A01-72", + "test-GL96-2A01-73", + "test-GL96-2A01-74", + "test-GL96-2A01-75", + "test-GL96-2A01-76", + "test-GL96-2A01-77", + "test-GL96-2A01-78", + "test-GL96-2A01-79", + "test-GL96-2A01-80", + "test-GL96-2A01-81", + "test-GL96-2A01-82", + "test-GL96-2A01-83", + "test-GL96-2A01-84", + "test-GL96-2A01-85", + "test-GL96-2A01-86", + "test-GL96-2A01-87", + "test-GL96-2A01-88", + "test-GL96-2A01-89", + "test-GL96-2A01-90", + "test-GL96-2A01-91", + "test-GL96-2A01-92", + "test-GL96-2A01-93", + "test-GL96-2A01-94", + "test-GL96-2A01-95", + "test-GL96-2A01-96" + ] + }, + { + "id": "test-GL96-2A01-01", + "name": "test-GL96-2A01-01", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-02", + "name": "test-GL96-2A01-02", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 1, + "y": 0, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-03", + "name": "test-GL96-2A01-03", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 2, + "y": 0, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-04", + "name": "test-GL96-2A01-04", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 3, + "y": 0, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-05", + "name": "test-GL96-2A01-05", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 4, + "y": 0, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-06", + "name": "test-GL96-2A01-06", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 5, + "y": 0, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-07", + "name": "test-GL96-2A01-07", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 6, + "y": 0, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-08", + "name": "test-GL96-2A01-08", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 7, + "y": 0, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-09", + "name": "test-GL96-2A01-09", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 0, + "y": 1, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-10", + "name": "test-GL96-2A01-10", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 1, + "y": 1, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-11", + "name": "test-GL96-2A01-11", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 2, + "y": 1, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-12", + "name": "test-GL96-2A01-12", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 3, + "y": 1, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-13", + "name": "test-GL96-2A01-13", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 4, + "y": 1, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-14", + "name": "test-GL96-2A01-14", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 5, + "y": 1, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-15", + "name": "test-GL96-2A01-15", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 6, + "y": 1, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-16", + "name": "test-GL96-2A01-16", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 7, + "y": 1, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-17", + "name": "test-GL96-2A01-17", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 0, + "y": 2, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-18", + "name": "test-GL96-2A01-18", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 1, + "y": 2, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-19", + "name": "test-GL96-2A01-19", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 2, + "y": 2, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-20", + "name": "test-GL96-2A01-20", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 3, + "y": 2, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-21", + "name": "test-GL96-2A01-21", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 4, + "y": 2, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-22", + "name": "test-GL96-2A01-22", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 5, + "y": 2, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-23", + "name": "test-GL96-2A01-23", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 6, + "y": 2, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-24", + "name": "test-GL96-2A01-24", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 7, + "y": 2, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-25", + "name": "test-GL96-2A01-25", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 0, + "y": 3, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-26", + "name": "test-GL96-2A01-26", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 1, + "y": 3, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-27", + "name": "test-GL96-2A01-27", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 2, + "y": 3, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-28", + "name": "test-GL96-2A01-28", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 3, + "y": 3, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-29", + "name": "test-GL96-2A01-29", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 4, + "y": 3, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-30", + "name": "test-GL96-2A01-30", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 5, + "y": 3, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-31", + "name": "test-GL96-2A01-31", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 6, + "y": 3, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-32", + "name": "test-GL96-2A01-32", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 7, + "y": 3, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-33", + "name": "test-GL96-2A01-33", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 0, + "y": 4, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-34", + "name": "test-GL96-2A01-34", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 1, + "y": 4, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-35", + "name": "test-GL96-2A01-35", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 2, + "y": 4, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-36", + "name": "test-GL96-2A01-36", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 3, + "y": 4, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-37", + "name": "test-GL96-2A01-37", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 4, + "y": 4, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-38", + "name": "test-GL96-2A01-38", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 5, + "y": 4, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-39", + "name": "test-GL96-2A01-39", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 6, + "y": 4, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-40", + "name": "test-GL96-2A01-40", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 7, + "y": 4, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-41", + "name": "test-GL96-2A01-41", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 0, + "y": 5, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-42", + "name": "test-GL96-2A01-42", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 1, + "y": 5, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-43", + "name": "test-GL96-2A01-43", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 2, + "y": 5, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-44", + "name": "test-GL96-2A01-44", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 3, + "y": 5, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-45", + "name": "test-GL96-2A01-45", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 4, + "y": 5, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-46", + "name": "test-GL96-2A01-46", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 5, + "y": 5, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-47", + "name": "test-GL96-2A01-47", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 6, + "y": 5, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-48", + "name": "test-GL96-2A01-48", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 7, + "y": 5, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-49", + "name": "test-GL96-2A01-49", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 0, + "y": 6, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-50", + "name": "test-GL96-2A01-50", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 1, + "y": 6, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-51", + "name": "test-GL96-2A01-51", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 2, + "y": 6, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-52", + "name": "test-GL96-2A01-52", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 3, + "y": 6, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-53", + "name": "test-GL96-2A01-53", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 4, + "y": 6, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-54", + "name": "test-GL96-2A01-54", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 5, + "y": 6, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-55", + "name": "test-GL96-2A01-55", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 6, + "y": 6, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-56", + "name": "test-GL96-2A01-56", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 7, + "y": 6, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-57", + "name": "test-GL96-2A01-57", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 0, + "y": 7, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-58", + "name": "test-GL96-2A01-58", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 1, + "y": 7, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-59", + "name": "test-GL96-2A01-59", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 2, + "y": 7, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-60", + "name": "test-GL96-2A01-60", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 3, + "y": 7, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-61", + "name": "test-GL96-2A01-61", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 4, + "y": 7, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-62", + "name": "test-GL96-2A01-62", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 5, + "y": 7, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-63", + "name": "test-GL96-2A01-63", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 6, + "y": 7, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-64", + "name": "test-GL96-2A01-64", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 7, + "y": 7, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-65", + "name": "test-GL96-2A01-65", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 0, + "y": 8, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-66", + "name": "test-GL96-2A01-66", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 1, + "y": 8, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-67", + "name": "test-GL96-2A01-67", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 2, + "y": 8, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-68", + "name": "test-GL96-2A01-68", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 3, + "y": 8, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-69", + "name": "test-GL96-2A01-69", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 4, + "y": 8, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-70", + "name": "test-GL96-2A01-70", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 5, + "y": 8, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-71", + "name": "test-GL96-2A01-71", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 6, + "y": 8, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-72", + "name": "test-GL96-2A01-72", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 7, + "y": 8, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-73", + "name": "test-GL96-2A01-73", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 0, + "y": 9, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-74", + "name": "test-GL96-2A01-74", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 1, + "y": 9, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-75", + "name": "test-GL96-2A01-75", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 2, + "y": 9, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-76", + "name": "test-GL96-2A01-76", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 3, + "y": 9, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-77", + "name": "test-GL96-2A01-77", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 4, + "y": 9, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-78", + "name": "test-GL96-2A01-78", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 5, + "y": 9, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-79", + "name": "test-GL96-2A01-79", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 6, + "y": 9, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-80", + "name": "test-GL96-2A01-80", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 7, + "y": 9, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-81", + "name": "test-GL96-2A01-81", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 0, + "y": 10, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-82", + "name": "test-GL96-2A01-82", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 1, + "y": 10, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-83", + "name": "test-GL96-2A01-83", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 2, + "y": 10, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-84", + "name": "test-GL96-2A01-84", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 3, + "y": 10, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-85", + "name": "test-GL96-2A01-85", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 4, + "y": 10, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-86", + "name": "test-GL96-2A01-86", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 5, + "y": 10, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-87", + "name": "test-GL96-2A01-87", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 6, + "y": 10, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-88", + "name": "test-GL96-2A01-88", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 7, + "y": 10, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-89", + "name": "test-GL96-2A01-89", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 0, + "y": 11, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-90", + "name": "test-GL96-2A01-90", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 1, + "y": 11, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-91", + "name": "test-GL96-2A01-91", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 2, + "y": 11, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-92", + "name": "test-GL96-2A01-92", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 3, + "y": 11, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-93", + "name": "test-GL96-2A01-93", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 4, + "y": 11, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-94", + "name": "test-GL96-2A01-94", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 5, + "y": 11, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-95", + "name": "test-GL96-2A01-95", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 6, + "y": 11, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + }, + { + "id": "test-GL96-2A01-96", + "name": "test-GL96-2A01-96", + "type": "container", + "parent": "test-GL96-2A01", + "position": { + "x": 7, + "y": 11, + "z": 0 + }, + "data": { + "liquid": [] + }, + "children": [] + } + ], + "links": [] +} \ No newline at end of file diff --git a/test/experiments/deis_control_config.yaml b/test/experiments/deis_control_config.yaml new file mode 100644 index 00000000..38c844da --- /dev/null +++ b/test/experiments/deis_control_config.yaml @@ -0,0 +1,21 @@ +controller_manager: + ros__parameters: + update_rate: 1.0 # 更新频率,单位 Hz. 20s 更新一次 + + # 控制器列表 + controllers: + deis_current_controller: + type: EISModelBasedController # PID 控制器类型 + inputs: + eis: # 环境输入(电化学阻抗谱) + topic: /devices/BioLogic/EISdata # 输入话题 + type: list[float] # 输入数据类型 + outputs: + current_control: # 控制输出(充电电流) + topic: /devices/BioLogic/current_control # 输出话题 + type: float # 输出数据类型 + parameters: + # set_point: 22.0 # 目标温度(可以动态更新) + # kp: 1.0 # PID 参数 + # ki: 0.1 + # kd: 0.05 \ No newline at end of file diff --git a/test/experiments/devices.json b/test/experiments/devices.json new file mode 100644 index 00000000..3f602d75 --- /dev/null +++ b/test/experiments/devices.json @@ -0,0 +1,54 @@ +{ + "PumpBackbone": { + "class": "protocol", + "protocol_type": ["PumpTransferProtocol", "CleanProtocol", "SeparateProtocol", "EvaporateProtocol"], + "children": { + "Serial": { + "class": "serial", + "port": "COM7", + "baudrate": 9600 + }, + "pump_reagents": { + "class": "syringepump.runze", + "port": "/devices/PumpBackbone/Serial/serialwrite", + "address": "1" + }, + "pump_workup": { + "class": "syringepump.runze", + "port": "/devices/PumpBackbone/Serial/serialwrite", + "address": "2" + }, + "pump_column": { + "class": "syringepump.runze", + "port": "/devices/PumpBackbone/Serial/serialwrite", + "address": "3" + }, + "pump_ext": { + "class": "syringepump.runze", + "port": "/devices/PumpBackbone/Serial/serialwrite", + "address": "4" + }, + "separator_controller": { + "class": "separator_controller", + "port_executor": "COM41", + "port_sensor": "COM40" + }, + "rotavap_chiller": { + "class": "chiller", + "port": "COM17" + }, + "rotavap_controller": { + "class": "rotavap", + "port": "COM15" + } + + } + }, + "AichemecoHiWo": { + "class": "work_station.aichemeco_hiwo" + }, + "Stirrer": { + "class": "heaterstirrer.dalong", + "port": "COM43" + } +} diff --git a/test/experiments/empty_devices.json b/test/experiments/empty_devices.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/test/experiments/empty_devices.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/test/experiments/lidocaine-graph.json b/test/experiments/lidocaine-graph.json new file mode 100644 index 00000000..1fc8228a --- /dev/null +++ b/test/experiments/lidocaine-graph.json @@ -0,0 +1,1233 @@ +{ + "nodes": [ + { + "type": "separator", + "x": 880, + "y": 240, + "internalId": 11, + "lock": "", + "connection_mode": "tcpip", + "address": "", + "port": "5000", + "simulation": true, + "device_name": "separator", + "obj": "", + "xdl_locks": { + "temp": "", + "ongoing": "" + }, + "id": "separator", + "class": "ChemputerSeparator", + "name": "separator", + "current_volume": 0, + "max_volume": 300, + "necks": 1, + "dead_volume": 3, + "can_filter": false, + "label": "separator" + }, + { + "type": "valve", + "x": 40, + "y": 120, + "internalId": 13, + "lock": "", + "connection_mode": "tcpip", + "port": "5000", + "simulation": true, + "device_name": "valve_Z", + "obj": "", + "xdl_locks": { + "temp": "", + "ongoing": "" + }, + "id": "valve_Z", + "class": "ChemputerValve", + "name": "valve_Z", + "address": "192.168.1.121", + "current_volume": 0, + "label": "valve_Z" + }, + { + "type": "pump", + "x": 40, + "y": 0, + "internalId": 14, + "lock": "", + "connection_mode": "tcpip", + "port": "5000", + "simulation": true, + "device_name": "pump_Z", + "obj": "", + "xdl_locks": { + "temp": "", + "ongoing": "" + }, + "id": "pump_Z", + "class": "ChemputerPump", + "name": "pump_Z", + "address": "192.168.1.101", + "max_volume": 25, + "current_volume": 0, + "label": "pump_Z" + }, + { + "type": "waste", + "x": 120, + "y": 40, + "internalId": 15, + "lock": "", + "connection_mode": "tcpip", + "address": "", + "port": "5000", + "simulation": true, + "device_name": "waste_Z", + "obj": "", + "xdl_locks": { + "temp": "", + "ongoing": "" + }, + "id": "waste_Z", + "class": "ChemputerWaste", + "name": "waste_Z", + "current_volume": 0, + "max_volume": 2000, + "necks": 1, + "can_filter": false, + "label": "waste_Z" + }, + { + "type": "valve", + "x": 320, + "y": 120, + "internalId": 16, + "lock": "", + "connection_mode": "tcpip", + "port": "5000", + "simulation": true, + "device_name": "valve_H", + "obj": "", + "xdl_locks": { + "temp": "", + "ongoing": "" + }, + "id": "valve_H", + "class": "ChemputerValve", + "name": "valve_H", + "address": "192.168.1.122", + "current_volume": 0, + "label": "valve_H" + }, + { + "type": "pump", + "x": 320, + "y": 0, + "internalId": 17, + "lock": "", + "connection_mode": "tcpip", + "port": "5000", + "simulation": true, + "device_name": "pump_H", + "obj": "", + "xdl_locks": { + "temp": "", + "ongoing": "" + }, + "id": "pump_H", + "class": "ChemputerPump", + "name": "pump_H", + "address": "192.168.1.102", + "max_volume": 25, + "current_volume": 0, + "label": "pump_H" + }, + { + "type": "waste", + "x": 400, + "y": 40, + "internalId": 18, + "lock": "", + "connection_mode": "tcpip", + "address": "", + "port": "5000", + "simulation": true, + "device_name": "waste_H", + "obj": "", + "xdl_locks": { + "temp": "", + "ongoing": "" + }, + "id": "waste_H", + "class": "ChemputerWaste", + "name": "waste_H", + "current_volume": 0, + "max_volume": 2000, + "necks": 1, + "can_filter": false, + "label": "waste_H" + }, + { + "type": "valve", + "x": 600, + "y": 120, + "internalId": 19, + "lock": "", + "connection_mode": "tcpip", + "port": "5000", + "simulation": true, + "device_name": "valve_G", + "obj": "", + "xdl_locks": { + "temp": "", + "ongoing": "" + }, + "id": "valve_G", + "class": "ChemputerValve", + "name": "valve_G", + "address": "192.168.1.123", + "current_volume": 0, + "label": "valve_G" + }, + { + "type": "valve", + "x": 880, + "y": 120, + "internalId": 23, + "lock": "", + "connection_mode": "tcpip", + "port": "5000", + "simulation": true, + "device_name": "valve_Y", + "obj": "", + "xdl_locks": { + "temp": "", + "ongoing": "" + }, + "id": "valve_Y", + "class": "ChemputerValve", + "name": "valve_Y", + "address": "192.168.1.124", + "current_volume": 0, + "label": "valve_Y" + }, + { + "type": "valve", + "x": 1080, + "y": 120, + "internalId": 24, + "lock": "", + "connection_mode": "tcpip", + "port": "5000", + "simulation": true, + "device_name": "valve_K", + "obj": "", + "xdl_locks": { + "temp": "", + "ongoing": "" + }, + "id": "valve_K", + "class": "ChemputerValve", + "name": "valve_K", + "address": "192.168.1.125", + "current_volume": 0, + "label": "valve_K" + }, + { + "type": "pump", + "x": 600, + "y": 0, + "internalId": 25, + "lock": "", + "connection_mode": "tcpip", + "port": "5000", + "simulation": true, + "device_name": "pump_G", + "obj": "", + "xdl_locks": { + "temp": "", + "ongoing": "" + }, + "id": "pump_G", + "class": "ChemputerPump", + "name": "pump_G", + "address": "192.168.1.103", + "max_volume": 25, + "current_volume": 0, + "label": "pump_G" + }, + { + "type": "pump", + "x": 880, + "y": 0, + "internalId": 26, + "lock": "", + "connection_mode": "tcpip", + "port": "5000", + "simulation": true, + "device_name": "pump_Y", + "obj": "", + "xdl_locks": { + "temp": "", + "ongoing": "" + }, + "id": "pump_Y", + "class": "ChemputerPump", + "name": "pump_Y", + "address": "192.168.1.104", + "max_volume": 25, + "current_volume": 0, + "label": "pump_Y" + }, + { + "type": "pump", + "x": 1080, + "y": 0, + "internalId": 27, + "lock": "", + "connection_mode": "tcpip", + "port": "5000", + "simulation": true, + "device_name": "pump_K", + "obj": "", + "xdl_locks": { + "temp": "", + "ongoing": "" + }, + "id": "pump_K", + "class": "ChemputerPump", + "name": "pump_K", + "address": "192.168.1.105", + "max_volume": 50, + "current_volume": 0, + "label": "pump_K" + }, + { + "type": "waste", + "x": 680, + "y": 40, + "internalId": 28, + "lock": "", + "connection_mode": "tcpip", + "address": "", + "port": "5000", + "simulation": true, + "device_name": "waste_G", + "obj": "", + "xdl_locks": { + "temp": "", + "ongoing": "" + }, + "id": "waste_G", + "class": "ChemputerWaste", + "name": "waste_G", + "current_volume": 0, + "max_volume": 2000, + "necks": 1, + "can_filter": false, + "label": "waste_G" + }, + { + "type": "waste", + "x": 960, + "y": 40, + "internalId": 29, + "lock": "", + "connection_mode": "tcpip", + "address": "", + "port": "5000", + "simulation": true, + "device_name": "waste_Y", + "obj": "", + "xdl_locks": { + "temp": "", + "ongoing": "" + }, + "id": "waste_Y", + "class": "ChemputerWaste", + "name": "waste_Y", + "current_volume": 0, + "max_volume": 2000, + "necks": 1, + "can_filter": false, + "label": "waste_Y" + }, + { + "type": "waste", + "x": 1160, + "y": 40, + "internalId": 30, + "lock": "", + "connection_mode": "tcpip", + "address": "", + "port": "5000", + "simulation": true, + "device_name": "waste_K", + "obj": "", + "xdl_locks": { + "temp": "", + "ongoing": "" + }, + "id": "waste_K", + "class": "ChemputerWaste", + "name": "waste_K", + "current_volume": 0, + "max_volume": 2000, + "necks": 1, + "can_filter": false, + "label": "waste_K" + }, + { + "type": "vacuum", + "x": 160, + "y": 400, + "internalId": 72, + "lock": "", + "simulation": true, + "device_name": "vacuum_filter", + "obj": "", + "xdl_locks": { + "temp": "", + "ongoing": "" + }, + "id": "vacuum_filter", + "connection_mode": "tcpip", + "class": "ChemputerVacuum", + "name": "vacuum_filter", + "address": "", + "port": "5000", + "label": "vacuum_filter" + }, + { + "type": "vacuum", + "x": 280, + "y": 400, + "internalId": 75, + "lock": "", + "simulation": true, + "device_name": "vacuum_pump", + "obj": "", + "xdl_locks": { + "temp": "", + "ongoing": "" + }, + "id": "vacuum_pump", + "connection_mode": "tcpip", + "class": "CVC3000VacuumPump", + "name": "vacuum_pump", + "address": "192.168.1.201", + "port": "5000", + "label": "vacuum_pump" + }, + { + "type": "conductivity_sensor", + "x": 880, + "y": 320, + "internalId": 80, + "lock": "", + "connection_mode": "tcpip", + "simulation": true, + "device_name": "sensor_separator", + "obj": "", + "xdl_locks": { + "temp": "", + "ongoing": "" + }, + "id": "sensor_separator", + "class": "ConductivitySensor", + "name": "sensor_separator", + "address": "", + "port": "5000", + "label": "sensor_separator" + }, + { + "type": "stirrer", + "x": 800, + "y": 240, + "internalId": 82, + "lock": "", + "simulation": true, + "device_name": "stirrer_separator", + "obj": "", + "xdl_locks": { + "temp": "", + "ongoing": "" + }, + "id": "stirrer_separator", + "connection_mode": "tcpip", + "class": "HeiTorque100PrecisionStirrer", + "name": "stirrer_separator", + "address": "192.168.1.206", + "port": "5000", + "label": "stirrer_separator" + }, + { + "type": "filter", + "x": 40, + "y": 240, + "internalId": 0, + "lock": "", + "connection_mode": "tcpip", + "address": "", + "port": "5000", + "simulation": true, + "device_name": "filter", + "obj": "", + "xdl_locks": { + "temp": "", + "ongoing": "" + }, + "id": "filter", + "class": "ChemputerFilter", + "name": "filter", + "current_volume": 0, + "max_volume": 100, + "dead_volume": 10, + "label": "filter" + }, + { + "type": "valve", + "x": 160, + "y": 240, + "internalId": 6, + "lock": "", + "connection_mode": "tcpip", + "port": "5000", + "simulation": true, + "device_name": "valve_vacuum", + "obj": "", + "xdl_locks": { + "temp": "", + "ongoing": "" + }, + "id": "valve_vacuum", + "class": "ChemputerValve", + "name": "valve_vacuum", + "address": "", + "current_volume": 0, + "label": "valve_vacuum" + }, + { + "type": "stirrer", + "x": -40, + "y": 240, + "internalId": 20, + "lock": "", + "simulation": true, + "device_name": "stirrer_filter", + "obj": "", + "xdl_locks": { + "temp": "", + "ongoing": "" + }, + "id": "stirrer_filter", + "class": "RZR2052ControlStirrer", + "name": "stirrer_filter", + "port": "", + "address": "", + "connection_mode": "tcpip", + "label": "stirrer_filter" + }, + { + "type": "chiller", + "x": 40, + "y": 320, + "internalId": 3, + "lock": "", + "simulation": true, + "device_name": "chiller_filter", + "obj": "", + "xdl_locks": { + "temp": "", + "ongoing": "" + }, + "id": "chiller_filter", + "class": "CF41Chiller", + "name": "chiller_filter", + "min_temp": null, + "max_temp": null, + "port": "", + "address": "", + "temp_sensor": "external", + "connection_mode": "tcpip", + "label": "chiller_filter" + }, + { + "type": "flask", + "x": 960, + "y": 200, + "id": "buffer_flask1", + "label": "buffer_flask1", + "internalId": 9, + "obj": "", + "class": "ChemputerFlask", + "name": "buffer_flask1", + "max_volume": 500, + "current_volume": 0, + "chemical": "", + "can_filter": false, + "buffer_flask": false + }, + { + "type": "flask", + "x": 600, + "y": 240, + "obj": "", + "label": "flask_2,6-Dimethylaniline", + "id": "flask_2,6-Dimethylaniline", + "internalId": 5, + "max_volume": 100, + "current_volume": 0, + "class": "ChemputerFlask", + "chemical": "2,6-Dimethylaniline", + "name": "flask_2,6-Dimethylaniline", + "can_filter": false, + "buffer_flask": false + }, + { + "type": "flask", + "x": 1080, + "y": 240, + "obj": "", + "label": "flask_3 M hydrochloric acid", + "id": "flask_3 M hydrochloric acid", + "internalId": 44, + "max_volume": 100, + "current_volume": 0, + "class": "ChemputerFlask", + "chemical": "3 M hydrochloric acid", + "name": "flask_3 M hydrochloric acid", + "can_filter": false, + "buffer_flask": false + }, + { + "type": "flask", + "x": 320, + "y": 240, + "obj": "", + "label": "flask_3 M sodium hydroxide", + "id": "flask_3 M sodium hydroxide", + "internalId": 46, + "max_volume": 100, + "current_volume": 0, + "class": "ChemputerFlask", + "chemical": "3 M sodium hydroxide", + "name": "flask_3 M sodium hydroxide", + "can_filter": false, + "buffer_flask": false + }, + { + "type": "flask", + "x": 1160, + "y": 200, + "obj": "", + "label": "flask_chloroacetyl chloride", + "id": "flask_chloroacetyl chloride", + "internalId": 48, + "max_volume": 100, + "current_volume": 0, + "class": "ChemputerFlask", + "chemical": "chloroacetyl chloride", + "name": "flask_chloroacetyl chloride", + "can_filter": false, + "buffer_flask": false + }, + { + "type": "flask", + "x": 520, + "y": 200, + "obj": "", + "label": "flask_diethylamine", + "id": "flask_diethylamine", + "internalId": 50, + "max_volume": 100, + "current_volume": 0, + "class": "ChemputerFlask", + "chemical": "diethylamine", + "name": "flask_diethylamine", + "can_filter": false, + "buffer_flask": false + }, + { + "type": "flask", + "x": 400, + "y": 200, + "obj": "", + "label": "flask_glacial acetic acid", + "id": "flask_glacial acetic acid", + "internalId": 52, + "max_volume": 100, + "current_volume": 0, + "class": "ChemputerFlask", + "chemical": "glacial acetic acid", + "name": "flask_glacial acetic acid", + "can_filter": false, + "buffer_flask": false + }, + { + "type": "flask", + "x": -40, + "y": 200, + "obj": "", + "label": "flask_half-saturated aqueous sodium acetate", + "id": "flask_half-saturated aqueous sodium acetate", + "internalId": 54, + "max_volume": 100, + "current_volume": 0, + "class": "ChemputerFlask", + "chemical": "half-saturated aqueous sodium acetate", + "name": "flask_half-saturated aqueous sodium acetate", + "can_filter": false, + "buffer_flask": false + }, + { + "type": "flask", + "x": -40, + "y": 40, + "obj": "", + "label": "flask_toluene", + "id": "flask_toluene", + "internalId": 56, + "max_volume": 100, + "current_volume": 0, + "class": "ChemputerFlask", + "chemical": "toluene", + "name": "flask_toluene", + "can_filter": false, + "buffer_flask": false + }, + { + "type": "flask", + "x": 680, + "y": 200, + "obj": "", + "label": "flask_water", + "id": "flask_water", + "internalId": 65, + "max_volume": 100, + "current_volume": 0, + "class": "ChemputerFlask", + "chemical": "water", + "name": "flask_water", + "can_filter": false, + "buffer_flask": false + }, + { + "type": "flask", + "x": 120, + "y": 200, + "obj": "", + "label": "flask_nitrogen", + "id": "flask_nitrogen", + "internalId": 67, + "max_volume": 100, + "current_volume": 0, + "class": "ChemputerFlask", + "chemical": "nitrogen", + "name": "flask_nitrogen", + "can_filter": false, + "buffer_flask": false + } + ], + "links": [ + { + "id": 58, + "sourceInternal": 11, + "targetInternal": 23, + "source": "separator", + "target": "valve_Y", + "port": "(bottom,4)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 32, + "sourceInternal": 13, + "targetInternal": 14, + "source": "valve_Z", + "target": "pump_Z", + "port": "(-1,0)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 85, + "sourceInternal": 13, + "targetInternal": 16, + "source": "valve_Z", + "target": "valve_H", + "port": "(1,2)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 95, + "sourceInternal": 13, + "targetInternal": 15, + "source": "valve_Z", + "target": "waste_Z", + "port": "(0,0)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 1, + "sourceInternal": 13, + "targetInternal": 0, + "source": "valve_Z", + "target": "filter", + "port": "(4,top)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 31, + "sourceInternal": 14, + "targetInternal": 13, + "source": "pump_Z", + "target": "valve_Z", + "port": "(0,-1)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 34, + "sourceInternal": 16, + "targetInternal": 17, + "source": "valve_H", + "target": "pump_H", + "port": "(-1,0)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 86, + "sourceInternal": 16, + "targetInternal": 13, + "source": "valve_H", + "target": "valve_Z", + "port": "(2,1)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 87, + "sourceInternal": 16, + "targetInternal": 19, + "source": "valve_H", + "target": "valve_G", + "port": "(1,2)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 96, + "sourceInternal": 16, + "targetInternal": 18, + "source": "valve_H", + "target": "waste_H", + "port": "(0,0)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 43, + "sourceInternal": 16, + "targetInternal": 6, + "source": "valve_H", + "target": "valve_vacuum", + "port": "(3,2)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 33, + "sourceInternal": 17, + "targetInternal": 16, + "source": "pump_H", + "target": "valve_H", + "port": "(0,-1)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 36, + "sourceInternal": 19, + "targetInternal": 25, + "source": "valve_G", + "target": "pump_G", + "port": "(-1,0)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 88, + "sourceInternal": 19, + "targetInternal": 16, + "source": "valve_G", + "target": "valve_H", + "port": "(2,1)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 89, + "sourceInternal": 19, + "targetInternal": 23, + "source": "valve_G", + "target": "valve_Y", + "port": "(1,2)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 97, + "sourceInternal": 19, + "targetInternal": 28, + "source": "valve_G", + "target": "waste_G", + "port": "(0,0)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 38, + "sourceInternal": 23, + "targetInternal": 26, + "source": "valve_Y", + "target": "pump_Y", + "port": "(-1,0)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 59, + "sourceInternal": 23, + "targetInternal": 11, + "source": "valve_Y", + "target": "separator", + "port": "(4,bottom)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 42, + "sourceInternal": 23, + "targetInternal": 11, + "source": "valve_Y", + "target": "separator", + "port": "(3,top)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 90, + "sourceInternal": 23, + "targetInternal": 19, + "source": "valve_Y", + "target": "valve_G", + "port": "(2,1)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 91, + "sourceInternal": 23, + "targetInternal": 24, + "source": "valve_Y", + "target": "valve_K", + "port": "(1,2)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 98, + "sourceInternal": 23, + "targetInternal": 29, + "source": "valve_Y", + "target": "waste_Y", + "port": "(0,0)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 10, + "sourceInternal": 23, + "targetInternal": 9, + "source": "valve_Y", + "target": "buffer_flask1", + "port": "(5,0)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 40, + "sourceInternal": 24, + "targetInternal": 27, + "source": "valve_K", + "target": "pump_K", + "port": "(-1,0)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 92, + "sourceInternal": 24, + "targetInternal": 23, + "source": "valve_K", + "target": "valve_Y", + "port": "(2,1)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 99, + "sourceInternal": 24, + "targetInternal": 30, + "source": "valve_K", + "target": "waste_K", + "port": "(0,0)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 35, + "sourceInternal": 25, + "targetInternal": 19, + "source": "pump_G", + "target": "valve_G", + "port": "(0,-1)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 37, + "sourceInternal": 26, + "targetInternal": 23, + "source": "pump_Y", + "target": "valve_Y", + "port": "(0,-1)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 39, + "sourceInternal": 27, + "targetInternal": 24, + "source": "pump_K", + "target": "valve_K", + "port": "(0,-1)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 77, + "sourceInternal": 75, + "targetInternal": 72, + "source": "vacuum_pump", + "target": "vacuum_filter", + "port": "(,)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 81, + "sourceInternal": 80, + "targetInternal": 11, + "source": "sensor_separator", + "target": "separator", + "port": "(,)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 83, + "sourceInternal": 82, + "targetInternal": 11, + "source": "stirrer_separator", + "target": "separator", + "port": "(,)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 8, + "sourceInternal": 0, + "targetInternal": 6, + "source": "filter", + "target": "valve_vacuum", + "port": "(bottom,-1)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 7, + "sourceInternal": 6, + "targetInternal": 0, + "source": "valve_vacuum", + "target": "filter", + "port": "(-1,bottom)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 4, + "sourceInternal": 6, + "targetInternal": 72, + "source": "valve_vacuum", + "target": "vacuum_filter", + "port": "(1,0)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 2, + "sourceInternal": 6, + "targetInternal": 16, + "source": "valve_vacuum", + "target": "valve_H", + "port": "(2,3)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 21, + "sourceInternal": 20, + "targetInternal": 0, + "source": "stirrer_filter", + "target": "filter", + "port": "(,)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 41, + "sourceInternal": 3, + "targetInternal": 0, + "source": "chiller_filter", + "target": "filter", + "port": "(,)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 12, + "sourceInternal": 9, + "targetInternal": 23, + "source": "buffer_flask1", + "target": "valve_Y", + "port": "(0,5)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 22, + "sourceInternal": 5, + "targetInternal": 19, + "source": "flask_2,6-Dimethylaniline", + "target": "valve_G", + "port": "(0,4)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 45, + "sourceInternal": 44, + "targetInternal": 24, + "source": "flask_3 M hydrochloric acid", + "target": "valve_K", + "port": "(0,4)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 47, + "sourceInternal": 46, + "targetInternal": 16, + "source": "flask_3 M sodium hydroxide", + "target": "valve_H", + "port": "(0,4)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 49, + "sourceInternal": 48, + "targetInternal": 24, + "source": "flask_chloroacetyl chloride", + "target": "valve_K", + "port": "(0,5)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 51, + "sourceInternal": 50, + "targetInternal": 19, + "source": "flask_diethylamine", + "target": "valve_G", + "port": "(0,3)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 53, + "sourceInternal": 52, + "targetInternal": 16, + "source": "flask_glacial acetic acid", + "target": "valve_H", + "port": "(0,5)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 55, + "sourceInternal": 54, + "targetInternal": 13, + "source": "flask_half-saturated aqueous sodium acetate", + "target": "valve_Z", + "port": "(0,3)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 57, + "sourceInternal": 56, + "targetInternal": 13, + "source": "flask_toluene", + "target": "valve_Z", + "port": "(0,2)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 66, + "sourceInternal": 65, + "targetInternal": 19, + "source": "flask_water", + "target": "valve_G", + "port": "(0,5)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 68, + "sourceInternal": 67, + "targetInternal": 13, + "source": "flask_nitrogen", + "target": "valve_Z", + "port": "(0,5)", + "tubeLength": null, + "tubeDiameter": null + }, + { + "id": 69, + "sourceInternal": 67, + "targetInternal": 6, + "source": "flask_nitrogen", + "target": "valve_vacuum", + "port": "(0,0)", + "tubeLength": null, + "tubeDiameter": null + } + ] +} \ No newline at end of file diff --git a/test/experiments/mock_reactor.json b/test/experiments/mock_reactor.json new file mode 100644 index 00000000..b0994cd0 --- /dev/null +++ b/test/experiments/mock_reactor.json @@ -0,0 +1,158 @@ +{ + "nodes": [ + { + "id": "ReactorX", + "name": "模拟常量合成工作站", + "children": [ + "reactor", + "vacuum_valve", + "gas_valve", + "vacuum_pump", + "gas_source" + ], + "parent": null, + "type": "device", + "class": "workstation", + "position": { + "x": 620.6111111111111, + "y": 171, + "z": 0 + }, + "config": { + "protocol_type": ["EvacuateAndRefillProtocol"] + }, + "data": { + } + }, + { + "id": "reactor", + "name": "reactor", + "children": [], + "parent": "ReactorX", + "type": "container", + "class": null, + "position": { + "x": 698.1111111111111, + "y": 428, + "z": 0 + }, + "config": { + "max_volume": 5000.0 + }, + "data": { + "liquid": [ + ] + } + }, + { + "id": "vacuum_valve", + "name": "vacuum_valve", + "children": [ + ], + "parent": "ReactorX", + "type": "device", + "class": "solenoid_valve.mock", + "position": { + "x": 620.6111111111111, + "y": 171, + "z": 0 + }, + "config": { + }, + "data": { + } + }, + { + "id": "gas_valve", + "name": "gas_valve", + "children": [ + ], + "parent": "ReactorX", + "type": "device", + "class": "solenoid_valve.mock", + "position": { + "x": 620.6111111111111, + "y": 171, + "z": 0 + }, + "config": { + }, + "data": { + } + }, + { + "id": "vacuum_pump", + "name": "vacuum_pump", + "children": [ + ], + "parent": "ReactorX", + "type": "device", + "class": "vacuum_pump.mock", + "position": { + "x": 620.6111111111111, + "y": 171, + "z": 0 + }, + "config": { + }, + "data": { + } + }, + { + "id": "gas_source", + "name": "gas_source", + "children": [ + ], + "parent": "ReactorX", + "type": "device", + "class": "gas_source.mock", + "position": { + "x": 620.6111111111111, + "y": 171, + "z": 0 + }, + "config": { + }, + "data": { + } + } + ], + "links": [ + { + "source": "reactor", + "target": "vacuum_valve", + "type": "physical", + "port": { + "reactor": "top", + "vacuum_valve": "1" + } + }, + { + "source": "reactor", + "target": "gas_valve", + "type": "physical", + "port": { + "reactor": "top", + "gas_valve": "1" + } + }, + { + "source": "vacuum_pump", + "target": "vacuum_valve", + "type": "physical", + "port": { + "vacuum_pump": "out", + "vacuum_valve": "0" + } + }, + { + "source": "gas_source", + "target": "gas_valve", + "type": "physical", + "port": { + "gas_source": "out", + "gas_valve": "0" + } + } + ] +} \ No newline at end of file diff --git a/test/experiments/plr_test.json b/test/experiments/plr_test.json new file mode 100644 index 00000000..c60f1b54 --- /dev/null +++ b/test/experiments/plr_test.json @@ -0,0 +1,10553 @@ +{ + "nodes": [ + { + "id": "PLR_STATION", + "name": "PLR_LH_TEST", + "parent": null, + "type": "device", + "class": "liquid_handler", + "position": { + "x": 620.6111111111111, + "y": 171, + "z": 0 + }, + "config": { + "data": { + "children": [ + { + "_resource_child_name": "deck", + "_resource_type": "pylabrobot.resources.hamilton.hamilton_decks:HamiltonSTARDeck" + } + ], + "backend": { + "type": "LiquidHandlerChatterboxBackend" + } + } + }, + "data": {}, + "children": [ + "deck" + ] + }, + { + "id": "deck", + "name": "deck", + "sample_id": null, + "children": [ + "trash", + "trash_core96", + "teaching_carrier", + "tip_rack", + "plate" + ], + "parent": "PLR_STATION", + "type": "device", + "class": "", + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "config": { + "type": "HamiltonSTARDeck", + "size_x": 1360, + "size_y": 653.5, + "size_z": 900, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "deck", + "num_rails": 32, + "with_trash": false, + "with_trash96": false, + "with_teaching_rack": false + }, + "data": {} + }, + { + "id": "trash", + "name": "trash", + "sample_id": null, + "children": [], + "parent": "deck", + "type": "device", + "class": "", + "position": { + "x": 800, + "y": 190.6, + "z": 137.1 + }, + "config": { + "type": "Trash", + "size_x": 0, + "size_y": 241.2, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "trash", + "model": null, + "max_volume": "Infinity", + "material_z_thickness": 0, + "compute_volume_from_height": null, + "compute_height_from_volume": null + }, + "data": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + } + }, + { + "id": "trash_core96", + "name": "trash_core96", + "sample_id": null, + "children": [], + "parent": "deck", + "type": "device", + "class": "", + "position": { + "x": -58.2, + "y": 106.0, + "z": 229.0 + }, + "config": { + "type": "Trash", + "size_x": 122.4, + "size_y": 82.6, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "trash", + "model": null, + "max_volume": "Infinity", + "material_z_thickness": 0, + "compute_volume_from_height": null, + "compute_height_from_volume": null + }, + "data": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + } + }, + { + "id": "teaching_carrier", + "name": "teaching_carrier", + "sample_id": null, + "children": [ + "teaching_tip_rack" + ], + "parent": "deck", + "type": "device", + "class": "", + "position": { + "x": 775.0, + "y": 51.8, + "z": 100 + }, + "config": { + "type": "Resource", + "size_x": 30, + "size_y": 445.2, + "size_z": 100, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": null, + "model": null + }, + "data": {} + }, + { + "id": "teaching_tip_rack", + "name": "teaching_tip_rack", + "sample_id": null, + "children": [ + "teaching_tip_rack_tip_spot_0", + "teaching_tip_rack_tip_spot_1", + "teaching_tip_rack_tip_spot_2", + "teaching_tip_rack_tip_spot_3", + "teaching_tip_rack_tip_spot_4", + "teaching_tip_rack_tip_spot_5", + "teaching_tip_rack_tip_spot_6", + "teaching_tip_rack_tip_spot_7" + ], + "parent": "teaching_carrier", + "type": "device", + "class": "", + "position": { + "x": 5.9, + "y": 409.3, + "z": 0 + }, + "config": { + "type": "TipRack", + "size_x": 9, + "size_y": 72, + "size_z": 50.4, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_rack", + "model": "hamilton_teaching_tip_rack", + "ordering": [ + "H1", + "G1", + "F1", + "E1", + "D1", + "C1", + "B1", + "A1" + ] + }, + "data": {} + }, + { + "id": "teaching_tip_rack_tip_spot_0", + "name": "teaching_tip_rack_tip_spot_0", + "sample_id": null, + "children": [], + "parent": "teaching_tip_rack", + "type": "device", + "class": "", + "position": { + "x": 0, + "y": 0, + "z": 23.1 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 59.9, + "has_filter": true, + "maximal_volume": 360, + "pickup_method": "OUT_OF_RACK", + "tip_size": "STANDARD_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 59.9, + "has_filter": true, + "maximal_volume": 360, + "pickup_method": "OUT_OF_RACK", + "tip_size": "STANDARD_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 59.9, + "has_filter": true, + "maximal_volume": 360, + "pickup_method": "OUT_OF_RACK", + "tip_size": "STANDARD_VOLUME" + } + } + }, + { + "id": "teaching_tip_rack_tip_spot_1", + "name": "teaching_tip_rack_tip_spot_1", + "sample_id": null, + "children": [], + "parent": "teaching_tip_rack", + "type": "device", + "class": "", + "position": { + "x": 0, + "y": 9, + "z": 23.1 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 59.9, + "has_filter": true, + "maximal_volume": 360, + "pickup_method": "OUT_OF_RACK", + "tip_size": "STANDARD_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 59.9, + "has_filter": true, + "maximal_volume": 360, + "pickup_method": "OUT_OF_RACK", + "tip_size": "STANDARD_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 59.9, + "has_filter": true, + "maximal_volume": 360, + "pickup_method": "OUT_OF_RACK", + "tip_size": "STANDARD_VOLUME" + } + } + }, + { + "id": "teaching_tip_rack_tip_spot_2", + "name": "teaching_tip_rack_tip_spot_2", + "sample_id": null, + "children": [], + "parent": "teaching_tip_rack", + "type": "device", + "class": "", + "position": { + "x": 0, + "y": 18, + "z": 23.1 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 59.9, + "has_filter": true, + "maximal_volume": 360, + "pickup_method": "OUT_OF_RACK", + "tip_size": "STANDARD_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 59.9, + "has_filter": true, + "maximal_volume": 360, + "pickup_method": "OUT_OF_RACK", + "tip_size": "STANDARD_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 59.9, + "has_filter": true, + "maximal_volume": 360, + "pickup_method": "OUT_OF_RACK", + "tip_size": "STANDARD_VOLUME" + } + } + }, + { + "id": "teaching_tip_rack_tip_spot_3", + "name": "teaching_tip_rack_tip_spot_3", + "sample_id": null, + "children": [], + "parent": "teaching_tip_rack", + "type": "device", + "class": "", + "position": { + "x": 0, + "y": 27, + "z": 23.1 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 59.9, + "has_filter": true, + "maximal_volume": 360, + "pickup_method": "OUT_OF_RACK", + "tip_size": "STANDARD_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 59.9, + "has_filter": true, + "maximal_volume": 360, + "pickup_method": "OUT_OF_RACK", + "tip_size": "STANDARD_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 59.9, + "has_filter": true, + "maximal_volume": 360, + "pickup_method": "OUT_OF_RACK", + "tip_size": "STANDARD_VOLUME" + } + } + }, + { + "id": "teaching_tip_rack_tip_spot_4", + "name": "teaching_tip_rack_tip_spot_4", + "sample_id": null, + "children": [], + "parent": "teaching_tip_rack", + "type": "device", + "class": "", + "position": { + "x": 0, + "y": 36, + "z": 23.1 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 59.9, + "has_filter": true, + "maximal_volume": 360, + "pickup_method": "OUT_OF_RACK", + "tip_size": "STANDARD_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 59.9, + "has_filter": true, + "maximal_volume": 360, + "pickup_method": "OUT_OF_RACK", + "tip_size": "STANDARD_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 59.9, + "has_filter": true, + "maximal_volume": 360, + "pickup_method": "OUT_OF_RACK", + "tip_size": "STANDARD_VOLUME" + } + } + }, + { + "id": "teaching_tip_rack_tip_spot_5", + "name": "teaching_tip_rack_tip_spot_5", + "sample_id": null, + "children": [], + "parent": "teaching_tip_rack", + "type": "device", + "class": "", + "position": { + "x": 0, + "y": 45, + "z": 23.1 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 59.9, + "has_filter": true, + "maximal_volume": 360, + "pickup_method": "OUT_OF_RACK", + "tip_size": "STANDARD_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 59.9, + "has_filter": true, + "maximal_volume": 360, + "pickup_method": "OUT_OF_RACK", + "tip_size": "STANDARD_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 59.9, + "has_filter": true, + "maximal_volume": 360, + "pickup_method": "OUT_OF_RACK", + "tip_size": "STANDARD_VOLUME" + } + } + }, + { + "id": "teaching_tip_rack_tip_spot_6", + "name": "teaching_tip_rack_tip_spot_6", + "sample_id": null, + "children": [], + "parent": "teaching_tip_rack", + "type": "device", + "class": "", + "position": { + "x": 0, + "y": 54, + "z": 23.1 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 59.9, + "has_filter": true, + "maximal_volume": 360, + "pickup_method": "OUT_OF_RACK", + "tip_size": "STANDARD_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 59.9, + "has_filter": true, + "maximal_volume": 360, + "pickup_method": "OUT_OF_RACK", + "tip_size": "STANDARD_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 59.9, + "has_filter": true, + "maximal_volume": 360, + "pickup_method": "OUT_OF_RACK", + "tip_size": "STANDARD_VOLUME" + } + } + }, + { + "id": "teaching_tip_rack_tip_spot_7", + "name": "teaching_tip_rack_tip_spot_7", + "sample_id": null, + "children": [], + "parent": "teaching_tip_rack", + "type": "device", + "class": "", + "position": { + "x": 0, + "y": 63, + "z": 23.1 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 59.9, + "has_filter": true, + "maximal_volume": 360, + "pickup_method": "OUT_OF_RACK", + "tip_size": "STANDARD_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 59.9, + "has_filter": true, + "maximal_volume": 360, + "pickup_method": "OUT_OF_RACK", + "tip_size": "STANDARD_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 59.9, + "has_filter": true, + "maximal_volume": 360, + "pickup_method": "OUT_OF_RACK", + "tip_size": "STANDARD_VOLUME" + } + } + }, + { + "id": "tip_rack", + "name": "tip_rack", + "sample_id": null, + "children": [ + "tip_rack_tipspot_0_0", + "tip_rack_tipspot_0_1", + "tip_rack_tipspot_0_2", + "tip_rack_tipspot_0_3", + "tip_rack_tipspot_0_4", + "tip_rack_tipspot_0_5", + "tip_rack_tipspot_0_6", + "tip_rack_tipspot_0_7", + "tip_rack_tipspot_1_0", + "tip_rack_tipspot_1_1", + "tip_rack_tipspot_1_2", + "tip_rack_tipspot_1_3", + "tip_rack_tipspot_1_4", + "tip_rack_tipspot_1_5", + "tip_rack_tipspot_1_6", + "tip_rack_tipspot_1_7", + "tip_rack_tipspot_2_0", + "tip_rack_tipspot_2_1", + "tip_rack_tipspot_2_2", + "tip_rack_tipspot_2_3", + "tip_rack_tipspot_2_4", + "tip_rack_tipspot_2_5", + "tip_rack_tipspot_2_6", + "tip_rack_tipspot_2_7", + "tip_rack_tipspot_3_0", + "tip_rack_tipspot_3_1", + "tip_rack_tipspot_3_2", + "tip_rack_tipspot_3_3", + "tip_rack_tipspot_3_4", + "tip_rack_tipspot_3_5", + "tip_rack_tipspot_3_6", + "tip_rack_tipspot_3_7", + "tip_rack_tipspot_4_0", + "tip_rack_tipspot_4_1", + "tip_rack_tipspot_4_2", + "tip_rack_tipspot_4_3", + "tip_rack_tipspot_4_4", + "tip_rack_tipspot_4_5", + "tip_rack_tipspot_4_6", + "tip_rack_tipspot_4_7", + "tip_rack_tipspot_5_0", + "tip_rack_tipspot_5_1", + "tip_rack_tipspot_5_2", + "tip_rack_tipspot_5_3", + "tip_rack_tipspot_5_4", + "tip_rack_tipspot_5_5", + "tip_rack_tipspot_5_6", + "tip_rack_tipspot_5_7", + "tip_rack_tipspot_6_0", + "tip_rack_tipspot_6_1", + "tip_rack_tipspot_6_2", + "tip_rack_tipspot_6_3", + "tip_rack_tipspot_6_4", + "tip_rack_tipspot_6_5", + "tip_rack_tipspot_6_6", + "tip_rack_tipspot_6_7", + "tip_rack_tipspot_7_0", + "tip_rack_tipspot_7_1", + "tip_rack_tipspot_7_2", + "tip_rack_tipspot_7_3", + "tip_rack_tipspot_7_4", + "tip_rack_tipspot_7_5", + "tip_rack_tipspot_7_6", + "tip_rack_tipspot_7_7", + "tip_rack_tipspot_8_0", + "tip_rack_tipspot_8_1", + "tip_rack_tipspot_8_2", + "tip_rack_tipspot_8_3", + "tip_rack_tipspot_8_4", + "tip_rack_tipspot_8_5", + "tip_rack_tipspot_8_6", + "tip_rack_tipspot_8_7", + "tip_rack_tipspot_9_0", + "tip_rack_tipspot_9_1", + "tip_rack_tipspot_9_2", + "tip_rack_tipspot_9_3", + "tip_rack_tipspot_9_4", + "tip_rack_tipspot_9_5", + "tip_rack_tipspot_9_6", + "tip_rack_tipspot_9_7", + "tip_rack_tipspot_10_0", + "tip_rack_tipspot_10_1", + "tip_rack_tipspot_10_2", + "tip_rack_tipspot_10_3", + "tip_rack_tipspot_10_4", + "tip_rack_tipspot_10_5", + "tip_rack_tipspot_10_6", + "tip_rack_tipspot_10_7", + "tip_rack_tipspot_11_0", + "tip_rack_tipspot_11_1", + "tip_rack_tipspot_11_2", + "tip_rack_tipspot_11_3", + "tip_rack_tipspot_11_4", + "tip_rack_tipspot_11_5", + "tip_rack_tipspot_11_6", + "tip_rack_tipspot_11_7" + ], + "parent": "deck", + "type": "device", + "class": "", + "position": { + "x": 145.0, + "y": 63, + "z": 100 + }, + "config": { + "type": "TipRack", + "size_x": 122.4, + "size_y": 82.6, + "size_z": 20.0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_rack", + "model": "HTF", + "ordering": [ + "A1", + "B1", + "C1", + "D1", + "E1", + "F1", + "G1", + "H1", + "A2", + "B2", + "C2", + "D2", + "E2", + "F2", + "G2", + "H2", + "A3", + "B3", + "C3", + "D3", + "E3", + "F3", + "G3", + "H3", + "A4", + "B4", + "C4", + "D4", + "E4", + "F4", + "G4", + "H4", + "A5", + "B5", + "C5", + "D5", + "E5", + "F5", + "G5", + "H5", + "A6", + "B6", + "C6", + "D6", + "E6", + "F6", + "G6", + "H6", + "A7", + "B7", + "C7", + "D7", + "E7", + "F7", + "G7", + "H7", + "A8", + "B8", + "C8", + "D8", + "E8", + "F8", + "G8", + "H8", + "A9", + "B9", + "C9", + "D9", + "E9", + "F9", + "G9", + "H9", + "A10", + "B10", + "C10", + "D10", + "E10", + "F10", + "G10", + "H10", + "A11", + "B11", + "C11", + "D11", + "E11", + "F11", + "G11", + "H11", + "A12", + "B12", + "C12", + "D12", + "E12", + "F12", + "G12", + "H12" + ] + }, + "data": {} + }, + { + "id": "tip_rack_tipspot_0_0", + "name": "tip_rack_tipspot_0_0", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 7.2, + "y": 68.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_0_1", + "name": "tip_rack_tipspot_0_1", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 7.2, + "y": 59.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_0_2", + "name": "tip_rack_tipspot_0_2", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 7.2, + "y": 50.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_0_3", + "name": "tip_rack_tipspot_0_3", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 7.2, + "y": 41.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_0_4", + "name": "tip_rack_tipspot_0_4", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 7.2, + "y": 32.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_0_5", + "name": "tip_rack_tipspot_0_5", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 7.2, + "y": 23.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_0_6", + "name": "tip_rack_tipspot_0_6", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 7.2, + "y": 14.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_0_7", + "name": "tip_rack_tipspot_0_7", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 7.2, + "y": 5.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_1_0", + "name": "tip_rack_tipspot_1_0", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 16.2, + "y": 68.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_1_1", + "name": "tip_rack_tipspot_1_1", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 16.2, + "y": 59.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_1_2", + "name": "tip_rack_tipspot_1_2", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 16.2, + "y": 50.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_1_3", + "name": "tip_rack_tipspot_1_3", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 16.2, + "y": 41.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_1_4", + "name": "tip_rack_tipspot_1_4", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 16.2, + "y": 32.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_1_5", + "name": "tip_rack_tipspot_1_5", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 16.2, + "y": 23.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_1_6", + "name": "tip_rack_tipspot_1_6", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 16.2, + "y": 14.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_1_7", + "name": "tip_rack_tipspot_1_7", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 16.2, + "y": 5.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_2_0", + "name": "tip_rack_tipspot_2_0", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 25.2, + "y": 68.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_2_1", + "name": "tip_rack_tipspot_2_1", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 25.2, + "y": 59.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_2_2", + "name": "tip_rack_tipspot_2_2", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 25.2, + "y": 50.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_2_3", + "name": "tip_rack_tipspot_2_3", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 25.2, + "y": 41.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_2_4", + "name": "tip_rack_tipspot_2_4", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 25.2, + "y": 32.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_2_5", + "name": "tip_rack_tipspot_2_5", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 25.2, + "y": 23.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_2_6", + "name": "tip_rack_tipspot_2_6", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 25.2, + "y": 14.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_2_7", + "name": "tip_rack_tipspot_2_7", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 25.2, + "y": 5.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_3_0", + "name": "tip_rack_tipspot_3_0", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 34.2, + "y": 68.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_3_1", + "name": "tip_rack_tipspot_3_1", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 34.2, + "y": 59.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_3_2", + "name": "tip_rack_tipspot_3_2", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 34.2, + "y": 50.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_3_3", + "name": "tip_rack_tipspot_3_3", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 34.2, + "y": 41.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_3_4", + "name": "tip_rack_tipspot_3_4", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 34.2, + "y": 32.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_3_5", + "name": "tip_rack_tipspot_3_5", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 34.2, + "y": 23.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_3_6", + "name": "tip_rack_tipspot_3_6", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 34.2, + "y": 14.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_3_7", + "name": "tip_rack_tipspot_3_7", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 34.2, + "y": 5.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_4_0", + "name": "tip_rack_tipspot_4_0", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 43.2, + "y": 68.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_4_1", + "name": "tip_rack_tipspot_4_1", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 43.2, + "y": 59.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_4_2", + "name": "tip_rack_tipspot_4_2", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 43.2, + "y": 50.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_4_3", + "name": "tip_rack_tipspot_4_3", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 43.2, + "y": 41.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_4_4", + "name": "tip_rack_tipspot_4_4", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 43.2, + "y": 32.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_4_5", + "name": "tip_rack_tipspot_4_5", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 43.2, + "y": 23.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_4_6", + "name": "tip_rack_tipspot_4_6", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 43.2, + "y": 14.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_4_7", + "name": "tip_rack_tipspot_4_7", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 43.2, + "y": 5.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_5_0", + "name": "tip_rack_tipspot_5_0", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 52.2, + "y": 68.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_5_1", + "name": "tip_rack_tipspot_5_1", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 52.2, + "y": 59.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_5_2", + "name": "tip_rack_tipspot_5_2", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 52.2, + "y": 50.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_5_3", + "name": "tip_rack_tipspot_5_3", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 52.2, + "y": 41.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_5_4", + "name": "tip_rack_tipspot_5_4", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 52.2, + "y": 32.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_5_5", + "name": "tip_rack_tipspot_5_5", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 52.2, + "y": 23.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_5_6", + "name": "tip_rack_tipspot_5_6", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 52.2, + "y": 14.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_5_7", + "name": "tip_rack_tipspot_5_7", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 52.2, + "y": 5.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_6_0", + "name": "tip_rack_tipspot_6_0", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 61.2, + "y": 68.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_6_1", + "name": "tip_rack_tipspot_6_1", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 61.2, + "y": 59.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_6_2", + "name": "tip_rack_tipspot_6_2", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 61.2, + "y": 50.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_6_3", + "name": "tip_rack_tipspot_6_3", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 61.2, + "y": 41.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_6_4", + "name": "tip_rack_tipspot_6_4", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 61.2, + "y": 32.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_6_5", + "name": "tip_rack_tipspot_6_5", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 61.2, + "y": 23.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_6_6", + "name": "tip_rack_tipspot_6_6", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 61.2, + "y": 14.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_6_7", + "name": "tip_rack_tipspot_6_7", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 61.2, + "y": 5.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_7_0", + "name": "tip_rack_tipspot_7_0", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 70.2, + "y": 68.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_7_1", + "name": "tip_rack_tipspot_7_1", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 70.2, + "y": 59.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_7_2", + "name": "tip_rack_tipspot_7_2", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 70.2, + "y": 50.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_7_3", + "name": "tip_rack_tipspot_7_3", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 70.2, + "y": 41.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_7_4", + "name": "tip_rack_tipspot_7_4", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 70.2, + "y": 32.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_7_5", + "name": "tip_rack_tipspot_7_5", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 70.2, + "y": 23.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_7_6", + "name": "tip_rack_tipspot_7_6", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 70.2, + "y": 14.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_7_7", + "name": "tip_rack_tipspot_7_7", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 70.2, + "y": 5.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_8_0", + "name": "tip_rack_tipspot_8_0", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 79.2, + "y": 68.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_8_1", + "name": "tip_rack_tipspot_8_1", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 79.2, + "y": 59.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_8_2", + "name": "tip_rack_tipspot_8_2", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 79.2, + "y": 50.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_8_3", + "name": "tip_rack_tipspot_8_3", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 79.2, + "y": 41.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_8_4", + "name": "tip_rack_tipspot_8_4", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 79.2, + "y": 32.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_8_5", + "name": "tip_rack_tipspot_8_5", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 79.2, + "y": 23.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_8_6", + "name": "tip_rack_tipspot_8_6", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 79.2, + "y": 14.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_8_7", + "name": "tip_rack_tipspot_8_7", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 79.2, + "y": 5.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_9_0", + "name": "tip_rack_tipspot_9_0", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 88.2, + "y": 68.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_9_1", + "name": "tip_rack_tipspot_9_1", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 88.2, + "y": 59.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_9_2", + "name": "tip_rack_tipspot_9_2", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 88.2, + "y": 50.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_9_3", + "name": "tip_rack_tipspot_9_3", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 88.2, + "y": 41.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_9_4", + "name": "tip_rack_tipspot_9_4", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 88.2, + "y": 32.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_9_5", + "name": "tip_rack_tipspot_9_5", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 88.2, + "y": 23.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_9_6", + "name": "tip_rack_tipspot_9_6", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 88.2, + "y": 14.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_9_7", + "name": "tip_rack_tipspot_9_7", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 88.2, + "y": 5.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_10_0", + "name": "tip_rack_tipspot_10_0", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 97.2, + "y": 68.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_10_1", + "name": "tip_rack_tipspot_10_1", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 97.2, + "y": 59.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_10_2", + "name": "tip_rack_tipspot_10_2", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 97.2, + "y": 50.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_10_3", + "name": "tip_rack_tipspot_10_3", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 97.2, + "y": 41.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_10_4", + "name": "tip_rack_tipspot_10_4", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 97.2, + "y": 32.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_10_5", + "name": "tip_rack_tipspot_10_5", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 97.2, + "y": 23.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_10_6", + "name": "tip_rack_tipspot_10_6", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 97.2, + "y": 14.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_10_7", + "name": "tip_rack_tipspot_10_7", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 97.2, + "y": 5.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_11_0", + "name": "tip_rack_tipspot_11_0", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 106.2, + "y": 68.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_11_1", + "name": "tip_rack_tipspot_11_1", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 106.2, + "y": 59.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_11_2", + "name": "tip_rack_tipspot_11_2", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 106.2, + "y": 50.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_11_3", + "name": "tip_rack_tipspot_11_3", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 106.2, + "y": 41.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_11_4", + "name": "tip_rack_tipspot_11_4", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 106.2, + "y": 32.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_11_5", + "name": "tip_rack_tipspot_11_5", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 106.2, + "y": 23.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_11_6", + "name": "tip_rack_tipspot_11_6", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 106.2, + "y": 14.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "tip_rack_tipspot_11_7", + "name": "tip_rack_tipspot_11_7", + "sample_id": null, + "children": [], + "parent": "tip_rack", + "type": "device", + "class": "", + "position": { + "x": 106.2, + "y": 5.3, + "z": -83.5 + }, + "config": { + "type": "TipSpot", + "size_x": 9.0, + "size_y": 9.0, + "size_z": 0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + }, + "data": { + "tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + }, + "tip_state": { + "liquids": [], + "pending_liquids": [], + "liquid_history": [] + }, + "pending_tip": { + "type": "HamiltonTip", + "total_tip_length": 95.1, + "has_filter": true, + "maximal_volume": 1065, + "pickup_method": "OUT_OF_RACK", + "tip_size": "HIGH_VOLUME" + } + } + }, + { + "id": "plate", + "name": "plate", + "sample_id": null, + "children": [ + "plate_well_0_0", + "plate_well_0_1", + "plate_well_0_2", + "plate_well_0_3", + "plate_well_0_4", + "plate_well_0_5", + "plate_well_0_6", + "plate_well_0_7", + "plate_well_1_0", + "plate_well_1_1", + "plate_well_1_2", + "plate_well_1_3", + "plate_well_1_4", + "plate_well_1_5", + "plate_well_1_6", + "plate_well_1_7", + "plate_well_2_0", + "plate_well_2_1", + "plate_well_2_2", + "plate_well_2_3", + "plate_well_2_4", + "plate_well_2_5", + "plate_well_2_6", + "plate_well_2_7", + "plate_well_3_0", + "plate_well_3_1", + "plate_well_3_2", + "plate_well_3_3", + "plate_well_3_4", + "plate_well_3_5", + "plate_well_3_6", + "plate_well_3_7", + "plate_well_4_0", + "plate_well_4_1", + "plate_well_4_2", + "plate_well_4_3", + "plate_well_4_4", + "plate_well_4_5", + "plate_well_4_6", + "plate_well_4_7", + "plate_well_5_0", + "plate_well_5_1", + "plate_well_5_2", + "plate_well_5_3", + "plate_well_5_4", + "plate_well_5_5", + "plate_well_5_6", + "plate_well_5_7", + "plate_well_6_0", + "plate_well_6_1", + "plate_well_6_2", + "plate_well_6_3", + "plate_well_6_4", + "plate_well_6_5", + "plate_well_6_6", + "plate_well_6_7", + "plate_well_7_0", + "plate_well_7_1", + "plate_well_7_2", + "plate_well_7_3", + "plate_well_7_4", + "plate_well_7_5", + "plate_well_7_6", + "plate_well_7_7", + "plate_well_8_0", + "plate_well_8_1", + "plate_well_8_2", + "plate_well_8_3", + "plate_well_8_4", + "plate_well_8_5", + "plate_well_8_6", + "plate_well_8_7", + "plate_well_9_0", + "plate_well_9_1", + "plate_well_9_2", + "plate_well_9_3", + "plate_well_9_4", + "plate_well_9_5", + "plate_well_9_6", + "plate_well_9_7", + "plate_well_10_0", + "plate_well_10_1", + "plate_well_10_2", + "plate_well_10_3", + "plate_well_10_4", + "plate_well_10_5", + "plate_well_10_6", + "plate_well_10_7", + "plate_well_11_0", + "plate_well_11_1", + "plate_well_11_2", + "plate_well_11_3", + "plate_well_11_4", + "plate_well_11_5", + "plate_well_11_6", + "plate_well_11_7" + ], + "parent": "deck", + "type": "device", + "class": "", + "position": { + "x": 280.0, + "y": 63, + "z": 100 + }, + "config": { + "type": "Plate", + "size_x": 127.76, + "size_y": 85.48, + "size_z": 14.2, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "plate", + "model": "Cor_96_wellplate_360ul_Fb", + "ordering": [ + "A1", + "B1", + "C1", + "D1", + "E1", + "F1", + "G1", + "H1", + "A2", + "B2", + "C2", + "D2", + "E2", + "F2", + "G2", + "H2", + "A3", + "B3", + "C3", + "D3", + "E3", + "F3", + "G3", + "H3", + "A4", + "B4", + "C4", + "D4", + "E4", + "F4", + "G4", + "H4", + "A5", + "B5", + "C5", + "D5", + "E5", + "F5", + "G5", + "H5", + "A6", + "B6", + "C6", + "D6", + "E6", + "F6", + "G6", + "H6", + "A7", + "B7", + "C7", + "D7", + "E7", + "F7", + "G7", + "H7", + "A8", + "B8", + "C8", + "D8", + "E8", + "F8", + "G8", + "H8", + "A9", + "B9", + "C9", + "D9", + "E9", + "F9", + "G9", + "H9", + "A10", + "B10", + "C10", + "D10", + "E10", + "F10", + "G10", + "H10", + "A11", + "B11", + "C11", + "D11", + "E11", + "F11", + "G11", + "H11", + "A12", + "B12", + "C12", + "D12", + "E12", + "F12", + "G12", + "H12" + ] + }, + "data": {} + }, + { + "id": "plate_well_0_0", + "name": "plate_well_0_0", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 10.87, + "y": 70.77, + "z": 3.03 + }, + "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_0_1", + "name": "plate_well_0_1", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 10.87, + "y": 61.77, + "z": 3.03 + }, + "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_0_2", + "name": "plate_well_0_2", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 10.87, + "y": 52.77, + "z": 3.03 + }, + "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_0_3", + "name": "plate_well_0_3", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 10.87, + "y": 43.77, + "z": 3.03 + }, + "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_0_4", + "name": "plate_well_0_4", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 10.87, + "y": 34.77, + "z": 3.03 + }, + "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_0_5", + "name": "plate_well_0_5", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 10.87, + "y": 25.77, + "z": 3.03 + }, + "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_0_6", + "name": "plate_well_0_6", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 10.87, + "y": 16.77, + "z": 3.03 + }, + "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_0_7", + "name": "plate_well_0_7", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 10.87, + "y": 7.77, + "z": 3.03 + }, + "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_1_0", + "name": "plate_well_1_0", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 19.87, + "y": 70.77, + "z": 3.03 + }, + "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_1_1", + "name": "plate_well_1_1", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 19.87, + "y": 61.77, + "z": 3.03 + }, + "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_1_2", + "name": "plate_well_1_2", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 19.87, + "y": 52.77, + "z": 3.03 + }, + "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_1_3", + "name": "plate_well_1_3", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 19.87, + "y": 43.77, + "z": 3.03 + }, + "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_1_4", + "name": "plate_well_1_4", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 19.87, + "y": 34.77, + "z": 3.03 + }, + "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_1_5", + "name": "plate_well_1_5", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 19.87, + "y": 25.77, + "z": 3.03 + }, + "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_1_6", + "name": "plate_well_1_6", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 19.87, + "y": 16.77, + "z": 3.03 + }, + "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_1_7", + "name": "plate_well_1_7", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 19.87, + "y": 7.77, + "z": 3.03 + }, + "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_2_0", + "name": "plate_well_2_0", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 28.87, + "y": 70.77, + "z": 3.03 + }, + "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_2_1", + "name": "plate_well_2_1", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 28.87, + "y": 61.77, + "z": 3.03 + }, + "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_2_2", + "name": "plate_well_2_2", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 28.87, + "y": 52.77, + "z": 3.03 + }, + "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_2_3", + "name": "plate_well_2_3", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 28.87, + "y": 43.77, + "z": 3.03 + }, + "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_2_4", + "name": "plate_well_2_4", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 28.87, + "y": 34.77, + "z": 3.03 + }, + "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_2_5", + "name": "plate_well_2_5", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 28.87, + "y": 25.77, + "z": 3.03 + }, + "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_2_6", + "name": "plate_well_2_6", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 28.87, + "y": 16.77, + "z": 3.03 + }, + "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_2_7", + "name": "plate_well_2_7", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 28.87, + "y": 7.77, + "z": 3.03 + }, + "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_3_0", + "name": "plate_well_3_0", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 37.87, + "y": 70.77, + "z": 3.03 + }, + "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_3_1", + "name": "plate_well_3_1", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 37.87, + "y": 61.77, + "z": 3.03 + }, + "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_3_2", + "name": "plate_well_3_2", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 37.87, + "y": 52.77, + "z": 3.03 + }, + "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_3_3", + "name": "plate_well_3_3", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 37.87, + "y": 43.77, + "z": 3.03 + }, + "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_3_4", + "name": "plate_well_3_4", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 37.87, + "y": 34.77, + "z": 3.03 + }, + "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_3_5", + "name": "plate_well_3_5", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 37.87, + "y": 25.77, + "z": 3.03 + }, + "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_3_6", + "name": "plate_well_3_6", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 37.87, + "y": 16.77, + "z": 3.03 + }, + "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_3_7", + "name": "plate_well_3_7", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 37.87, + "y": 7.77, + "z": 3.03 + }, + "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_4_0", + "name": "plate_well_4_0", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 46.87, + "y": 70.77, + "z": 3.03 + }, + "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_4_1", + "name": "plate_well_4_1", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 46.87, + "y": 61.77, + "z": 3.03 + }, + "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_4_2", + "name": "plate_well_4_2", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 46.87, + "y": 52.77, + "z": 3.03 + }, + "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_4_3", + "name": "plate_well_4_3", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 46.87, + "y": 43.77, + "z": 3.03 + }, + "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_4_4", + "name": "plate_well_4_4", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 46.87, + "y": 34.77, + "z": 3.03 + }, + "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_4_5", + "name": "plate_well_4_5", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 46.87, + "y": 25.77, + "z": 3.03 + }, + "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_4_6", + "name": "plate_well_4_6", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 46.87, + "y": 16.77, + "z": 3.03 + }, + "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_4_7", + "name": "plate_well_4_7", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 46.87, + "y": 7.77, + "z": 3.03 + }, + "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_5_0", + "name": "plate_well_5_0", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 55.87, + "y": 70.77, + "z": 3.03 + }, + "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_5_1", + "name": "plate_well_5_1", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 55.87, + "y": 61.77, + "z": 3.03 + }, + "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_5_2", + "name": "plate_well_5_2", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 55.87, + "y": 52.77, + "z": 3.03 + }, + "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_5_3", + "name": "plate_well_5_3", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 55.87, + "y": 43.77, + "z": 3.03 + }, + "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_5_4", + "name": "plate_well_5_4", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 55.87, + "y": 34.77, + "z": 3.03 + }, + "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_5_5", + "name": "plate_well_5_5", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 55.87, + "y": 25.77, + "z": 3.03 + }, + "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_5_6", + "name": "plate_well_5_6", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 55.87, + "y": 16.77, + "z": 3.03 + }, + "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_5_7", + "name": "plate_well_5_7", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 55.87, + "y": 7.77, + "z": 3.03 + }, + "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_6_0", + "name": "plate_well_6_0", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 64.87, + "y": 70.77, + "z": 3.03 + }, + "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_6_1", + "name": "plate_well_6_1", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 64.87, + "y": 61.77, + "z": 3.03 + }, + "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_6_2", + "name": "plate_well_6_2", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 64.87, + "y": 52.77, + "z": 3.03 + }, + "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_6_3", + "name": "plate_well_6_3", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 64.87, + "y": 43.77, + "z": 3.03 + }, + "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_6_4", + "name": "plate_well_6_4", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 64.87, + "y": 34.77, + "z": 3.03 + }, + "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_6_5", + "name": "plate_well_6_5", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 64.87, + "y": 25.77, + "z": 3.03 + }, + "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_6_6", + "name": "plate_well_6_6", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 64.87, + "y": 16.77, + "z": 3.03 + }, + "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_6_7", + "name": "plate_well_6_7", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 64.87, + "y": 7.77, + "z": 3.03 + }, + "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_7_0", + "name": "plate_well_7_0", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 73.87, + "y": 70.77, + "z": 3.03 + }, + "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_7_1", + "name": "plate_well_7_1", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 73.87, + "y": 61.77, + "z": 3.03 + }, + "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_7_2", + "name": "plate_well_7_2", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 73.87, + "y": 52.77, + "z": 3.03 + }, + "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_7_3", + "name": "plate_well_7_3", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 73.87, + "y": 43.77, + "z": 3.03 + }, + "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_7_4", + "name": "plate_well_7_4", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 73.87, + "y": 34.77, + "z": 3.03 + }, + "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_7_5", + "name": "plate_well_7_5", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 73.87, + "y": 25.77, + "z": 3.03 + }, + "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_7_6", + "name": "plate_well_7_6", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 73.87, + "y": 16.77, + "z": 3.03 + }, + "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_7_7", + "name": "plate_well_7_7", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 73.87, + "y": 7.77, + "z": 3.03 + }, + "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_8_0", + "name": "plate_well_8_0", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 82.87, + "y": 70.77, + "z": 3.03 + }, + "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_8_1", + "name": "plate_well_8_1", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 82.87, + "y": 61.77, + "z": 3.03 + }, + "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_8_2", + "name": "plate_well_8_2", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 82.87, + "y": 52.77, + "z": 3.03 + }, + "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_8_3", + "name": "plate_well_8_3", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 82.87, + "y": 43.77, + "z": 3.03 + }, + "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_8_4", + "name": "plate_well_8_4", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 82.87, + "y": 34.77, + "z": 3.03 + }, + "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_8_5", + "name": "plate_well_8_5", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 82.87, + "y": 25.77, + "z": 3.03 + }, + "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_8_6", + "name": "plate_well_8_6", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 82.87, + "y": 16.77, + "z": 3.03 + }, + "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_8_7", + "name": "plate_well_8_7", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 82.87, + "y": 7.77, + "z": 3.03 + }, + "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_9_0", + "name": "plate_well_9_0", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 91.87, + "y": 70.77, + "z": 3.03 + }, + "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_9_1", + "name": "plate_well_9_1", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 91.87, + "y": 61.77, + "z": 3.03 + }, + "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_9_2", + "name": "plate_well_9_2", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 91.87, + "y": 52.77, + "z": 3.03 + }, + "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_9_3", + "name": "plate_well_9_3", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 91.87, + "y": 43.77, + "z": 3.03 + }, + "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_9_4", + "name": "plate_well_9_4", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 91.87, + "y": 34.77, + "z": 3.03 + }, + "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_9_5", + "name": "plate_well_9_5", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 91.87, + "y": 25.77, + "z": 3.03 + }, + "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_9_6", + "name": "plate_well_9_6", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 91.87, + "y": 16.77, + "z": 3.03 + }, + "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_9_7", + "name": "plate_well_9_7", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 91.87, + "y": 7.77, + "z": 3.03 + }, + "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_10_0", + "name": "plate_well_10_0", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 100.87, + "y": 70.77, + "z": 3.03 + }, + "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_10_1", + "name": "plate_well_10_1", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 100.87, + "y": 61.77, + "z": 3.03 + }, + "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_10_2", + "name": "plate_well_10_2", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 100.87, + "y": 52.77, + "z": 3.03 + }, + "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_10_3", + "name": "plate_well_10_3", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 100.87, + "y": 43.77, + "z": 3.03 + }, + "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_10_4", + "name": "plate_well_10_4", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 100.87, + "y": 34.77, + "z": 3.03 + }, + "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_10_5", + "name": "plate_well_10_5", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 100.87, + "y": 25.77, + "z": 3.03 + }, + "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_10_6", + "name": "plate_well_10_6", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 100.87, + "y": 16.77, + "z": 3.03 + }, + "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_10_7", + "name": "plate_well_10_7", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 100.87, + "y": 7.77, + "z": 3.03 + }, + "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_0", + "name": "plate_well_11_0", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 109.87, + "y": 70.77, + "z": 3.03 + }, + "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_1", + "name": "plate_well_11_1", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 109.87, + "y": 61.77, + "z": 3.03 + }, + "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_2", + "name": "plate_well_11_2", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 109.87, + "y": 52.77, + "z": 3.03 + }, + "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_3", + "name": "plate_well_11_3", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 109.87, + "y": 43.77, + "z": 3.03 + }, + "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_4", + "name": "plate_well_11_4", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 109.87, + "y": 34.77, + "z": 3.03 + }, + "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_5", + "name": "plate_well_11_5", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 109.87, + "y": 25.77, + "z": 3.03 + }, + "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_6", + "name": "plate_well_11_6", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 109.87, + "y": 16.77, + "z": 3.03 + }, + "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", + "sample_id": null, + "children": [], + "parent": "plate", + "type": "device", + "class": "", + "position": { + "x": 109.87, + "y": 7.77, + "z": 3.03 + }, + "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": [] + } + } + ], + "links": [] +} \ No newline at end of file diff --git a/test/experiments/test.json b/test/experiments/test.json new file mode 100644 index 00000000..07b802cd --- /dev/null +++ b/test/experiments/test.json @@ -0,0 +1,43 @@ +{ + "nodes": [ + { + "id": "Gripper1", + "name": "假夹爪", + "children": [ + ], + "parent": null, + "type": "device", + "class": "gripper.mock", + "position": { + "x": 620.6111111111111, + "y": 171, + "z": 0 + }, + "config": { + }, + "data": { + } + }, + { + "id": "Plate1", + "name": "Plate1", + "children": [ + ], + "parent": null, + "type": "plate", + "class": "nest_96_wellplate_2ml_deep", + "position": { + "x": 620.6111111111111, + "y": 171, + "z": 0 + }, + "config": { + }, + "data": { + } + } + ], + "links": [ + + ] +} \ No newline at end of file diff --git a/test/ros/msgs/__init__.py b/test/ros/msgs/__init__.py new file mode 100644 index 00000000..69c17a3f --- /dev/null +++ b/test/ros/msgs/__init__.py @@ -0,0 +1 @@ +# 消息转换器测试包 diff --git a/test/ros/msgs/test_basic.py b/test/ros/msgs/test_basic.py new file mode 100644 index 00000000..7618d5e6 --- /dev/null +++ b/test/ros/msgs/test_basic.py @@ -0,0 +1,71 @@ +""" +基本测试 + +测试消息转换器的基本功能,包括导入、类型映射等。 +""" + +import unittest + +from unilabos.ros.msgs.message_converter import ( + msg_converter_manager, + get_msg_type, + get_action_type, + get_ros_type_by_msgname, + Point3D, + Point, + Float64, + String, + Bool, + Int32, +) + + +class TestBasicFunctionality(unittest.TestCase): + """测试消息转换器的基本功能""" + + def test_manager_initialization(self): + """测试导入管理器初始化""" + self.assertIsNotNone(msg_converter_manager) + self.assertTrue(len(msg_converter_manager.list_modules()) > 0) + self.assertTrue(len(msg_converter_manager.list_classes()) > 0) + + def test_get_msg_type(self): + """测试获取消息类型""" + self.assertEqual(get_msg_type(float), Float64) + self.assertEqual(get_msg_type(str), String) + self.assertEqual(get_msg_type(bool), Bool) + self.assertEqual(get_msg_type(int), Int32) + self.assertEqual(get_msg_type(Point3D), Point) + + # 测试错误情况 + with self.assertRaises(ValueError): + get_msg_type(set) # 不支持的类型 + + def test_get_action_type(self): + """测试获取动作类型""" + float_action = get_action_type(float) + self.assertIsNotNone(float_action) + self.assertTrue("type" in float_action) + self.assertTrue("goal" in float_action) + self.assertTrue("feedback" in float_action) + + # 测试错误情况 + with self.assertRaises(ValueError): + get_action_type(set) # 不支持的类型 + + def test_get_ros_type_by_msgname(self): + """测试通过消息名称获取ROS类型""" + # 测试有效的消息名称 + point_type = get_ros_type_by_msgname("geometry_msgs/msg/Point") + self.assertEqual(point_type, Point) + + # 测试无效的消息名称 + with self.assertRaises(ValueError): + get_ros_type_by_msgname("invalid_format") + + # 不存在的消息类型可能会引发ImportError,但这依赖于运行环境 + # 因此不进行显式测试 + + +if __name__ == "__main__": + unittest.main() diff --git a/test/ros/msgs/test_conversion.py b/test/ros/msgs/test_conversion.py new file mode 100644 index 00000000..27c4b54b --- /dev/null +++ b/test/ros/msgs/test_conversion.py @@ -0,0 +1,131 @@ +""" +转换测试 + +测试Python对象和ROS消息之间的转换功能。 +""" + +import unittest +from dataclasses import dataclass + +from unilabos.ros.msgs.message_converter import ( + convert_to_ros_msg, + convert_from_ros_msg, + convert_to_ros_msg_with_mapping, + convert_from_ros_msg_with_mapping, + Point, + Float64, + String, + Point3D, + Resource, +) + + +# 定义一些测试数据类 +@dataclass +class TestPoint: + x: float = 0.0 + y: float = 0.0 + z: float = 0.0 + + +class TestBasicConversion(unittest.TestCase): + """测试基本类型转换""" + + def test_primitive_conversion(self): + """测试原始类型转换""" + # Float转换 + float_value = 3.14 + ros_float = convert_to_ros_msg(Float64, float_value) + self.assertEqual(ros_float.data, float_value) + + # 反向转换 + py_float = convert_from_ros_msg(ros_float) + self.assertEqual(py_float, float_value) + + # 字符串转换 + str_value = "hello" + ros_str = convert_to_ros_msg(String, str_value) + self.assertEqual(ros_str.data, str_value) + + # 反向转换 + py_str = convert_from_ros_msg(ros_str) + self.assertEqual(py_str, str_value) + + def test_point_conversion(self): + """测试点类型转换""" + # 创建Point3D对象 + py_point = Point3D(x=1.0, y=2.0, z=3.0) + + # 转换为ROS Point + ros_point = convert_to_ros_msg(Point, py_point) + self.assertEqual(ros_point.x, py_point.x) + self.assertEqual(ros_point.y, py_point.y) + self.assertEqual(ros_point.z, py_point.z) + + # 反向转换 + py_point_back = convert_from_ros_msg(ros_point) + self.assertEqual(py_point_back.x, py_point.x) + self.assertEqual(py_point_back.y, py_point.y) + self.assertEqual(py_point_back.z, py_point.z) + + def test_dataclass_conversion(self): + """测试dataclass转换""" + # 创建dataclass + test_point = TestPoint(x=1.0, y=2.0, z=3.0) + + # 转换 + ros_point = convert_to_ros_msg(Point, test_point) + self.assertEqual(ros_point.x, test_point.x) + self.assertEqual(ros_point.y, test_point.y) + self.assertEqual(ros_point.z, test_point.z) + + +class TestMappingConversion(unittest.TestCase): + """测试映射转换功能""" + + def test_mapping_conversion(self): + """测试带映射的转换""" + # 创建测试数据 + test_data = { + "position": {"x": 1.0, "y": 2.0, "z": 3.0}, + "name": "test_resource", + "id": "123", + "type": "test_type", + } + + # 定义映射 + mapping = { + "id": "id", + "name": "name", + "type": "type", + "pose.position": "position", + } + + # 转换为ROS资源 + ros_resource = convert_to_ros_msg_with_mapping(Resource, test_data, mapping) + self.assertEqual(ros_resource.id, "123") + self.assertEqual(ros_resource.name, "test_resource") + self.assertEqual(ros_resource.type, "test_type") + self.assertEqual(ros_resource.pose.position.x, 1.0) + self.assertEqual(ros_resource.pose.position.y, 2.0) + self.assertEqual(ros_resource.pose.position.z, 3.0) + + # 反向转换 + reverse_mapping = { + "id": "id", + "name": "name", + "type": "type", + "pose.position": "position", + } + + py_data = convert_from_ros_msg_with_mapping(ros_resource, reverse_mapping) + self.assertEqual(py_data["id"], "123") + self.assertEqual(py_data["name"], "test_resource") + self.assertEqual(py_data["type"], "test_type") + self.assertEqual(py_data["position"].x, 1.0) + self.assertEqual(py_data["position"].y, 2.0) + self.assertEqual(py_data["position"].z, 3.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/ros/msgs/test_mapping.py b/test/ros/msgs/test_mapping.py new file mode 100644 index 00000000..195bde89 --- /dev/null +++ b/test/ros/msgs/test_mapping.py @@ -0,0 +1,120 @@ +""" +映射测试 + +测试消息类型映射和字段映射功能。 +""" + +import unittest +from dataclasses import dataclass + +from unilabos.ros.msgs.message_converter import ( + _msg_mapping, + _action_mapping, + _msg_converter, + _msg_converter_back, + compare_model_fields, + Point, + Point3D, + Float64, + String, + set_msg_data, +) + + +@dataclass +class TestMappingModel: + """用于测试映射的数据类""" + + id: str + name: str + value: float + + +@dataclass +class TestPointModel: + """用于测试字段比较的点模型""" + + x: float + y: float + z: float + + +class TestTypeMapping(unittest.TestCase): + """测试类型映射""" + + def test_msg_mapping(self): + """测试消息类型映射""" + self.assertIn(float, _msg_mapping) + self.assertEqual(_msg_mapping[float], Float64) + + self.assertIn(str, _msg_mapping) + self.assertEqual(_msg_mapping[str], String) + + self.assertIn(Point3D, _msg_mapping) + self.assertEqual(_msg_mapping[Point3D], Point) + + def test_action_mapping(self): + """测试动作类型映射""" + self.assertIn(float, _action_mapping) + self.assertIn("type", _action_mapping[float]) + self.assertIn("goal", _action_mapping[float]) + self.assertIn("feedback", _action_mapping[float]) + self.assertIn("result", _action_mapping[float]) + + def test_converter_mapping(self): + """测试转换器映射""" + # 测试Python到ROS映射 + self.assertIn(float, _msg_converter) + self.assertIn(Float64, _msg_converter) + self.assertIn(String, _msg_converter) + self.assertIn(Point, _msg_converter) + + # 测试ROS到Python映射 + self.assertIn(float, _msg_converter_back) + self.assertIn(Float64, _msg_converter_back) + self.assertIn(String, _msg_converter_back) + self.assertIn(Point, _msg_converter_back) + + +class TestFieldMapping(unittest.TestCase): + """测试字段映射""" + + def test_compare_model_fields(self): + """测试模型字段比较""" + # Point3D和TestPointModel有相同的字段 + self.assertTrue(compare_model_fields(Point3D, TestPointModel)) + + # 与其他类型比较 + self.assertFalse(compare_model_fields(Point3D, TestMappingModel)) + self.assertFalse(compare_model_fields(Point3D, float)) + + # 类型对象和实例对象比较 + point = Point3D(x=1.0, y=2.0, z=3.0) + self.assertTrue(compare_model_fields(Point3D, type(point))) + + def test_set_msg_data(self): + """测试设置消息数据类型""" + # 测试float转换 + float_value = "3.14" + self.assertEqual(set_msg_data("float", float_value), 3.14) + self.assertEqual(set_msg_data("double", float_value), 3.14) + + # 测试int转换 + int_value = "42" + self.assertEqual(set_msg_data("int", int_value), 42) + + # 测试bool转换 + bool_value = "True" + self.assertEqual(set_msg_data("bool", bool_value), True) + + # 测试str转换 + str_value = "hello" + self.assertEqual(set_msg_data("str", str_value), "hello") + + # 测试默认转换 + default_value = 123 + self.assertEqual(set_msg_data("unknown_type", default_value), "123") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/ros/msgs/test_runner.py b/test/ros/msgs/test_runner.py new file mode 100644 index 00000000..fe4cb096 --- /dev/null +++ b/test/ros/msgs/test_runner.py @@ -0,0 +1,47 @@ +""" +测试运行器 + +运行所有消息转换器的测试用例。 +""" + +import unittest +import sys +import os + +# 添加项目根目录到路径 +sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))) + +# 导入测试模块 +from test.ros.msgs.test_basic import TestBasicFunctionality +from test.ros.msgs.test_conversion import TestBasicConversion, TestMappingConversion +from test.ros.msgs.test_mapping import TestTypeMapping, TestFieldMapping + + +def run_tests(): + """运行所有测试""" + # 创建测试加载器 + loader = unittest.TestLoader() + + # 创建测试套件 + suite = unittest.TestSuite() + + # 添加测试类 + suite.addTests(loader.loadTestsFromTestCase(TestBasicFunctionality)) + suite.addTests(loader.loadTestsFromTestCase(TestBasicConversion)) + suite.addTests(loader.loadTestsFromTestCase(TestMappingConversion)) + suite.addTests(loader.loadTestsFromTestCase(TestTypeMapping)) + suite.addTests(loader.loadTestsFromTestCase(TestFieldMapping)) + + # 创建测试运行器 + runner = unittest.TextTestRunner(verbosity=2) + + # 运行测试 + result = runner.run(suite) + + # 返回结果 + return result.wasSuccessful() + + +if __name__ == "__main__": + success = run_tests() + sys.exit(not success) diff --git a/unilabos-linux-64.yaml b/unilabos-linux-64.yaml new file mode 100644 index 00000000..7ce69c9b --- /dev/null +++ b/unilabos-linux-64.yaml @@ -0,0 +1,61 @@ +name: unilab +channels: + - robostack + - robostack-staging + - conda-forge +dependencies: + # Basics + - python=3.11.11 + - compilers + - cmake + - make + - ninja + - sphinx + - sphinx_rtd_theme + # Data Visualization + - numpy + - scipy + - pandas + - networkx + - matplotlib + - pint + # Device communication + - pyserial + - pyusb + - pylibftdi + - pymodbus + - python-can + - pyvisa + - opencv + # Service + - pydantic + - fastapi + - uvicorn + - gradio + - flask + - websocket + # Notebook + - ipython + - jupyter + - jupyros + # ros + - colcon-common-extensions + - ros-humble-desktop-full + - ros-humble-control-msgs + - ros-humble-sensor-msgs + - ros-humble-trajectory-msgs + - ros-humble-navigation2 + - ros-humble-ros2-control + - ros-humble-robot-state-publisher + - ros-humble-joint-state-publisher + # web + - ros-humble-rosbridge-server + # geometry & motion planning + - ros-humble-tf2 + - ros-humble-moveit + - ros-humble-moveit-servo + # simulation + - ros-humble-simulation + # ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo + # ilab equipments +# - ros-humble-unilabos-msgs diff --git a/unilabos-osx-64.yaml b/unilabos-osx-64.yaml new file mode 100644 index 00000000..7e21a65d --- /dev/null +++ b/unilabos-osx-64.yaml @@ -0,0 +1,61 @@ +name: unilab +channels: + - robostack + - robostack-staging + - conda-forge +dependencies: + # Basics + - python=3.11.11 + - compilers + - cmake + - make + - ninja + - sphinx + - sphinx_rtd_theme + # Data Visualization + - numpy + - scipy + - pandas + - networkx + - matplotlib + - pint + # Device communication + - pyserial + - pyusb + - pylibftdi + - pymodbus + - python-can + - pyvisa + - opencv + # Service + - pydantic + - fastapi + - uvicorn + - gradio + - flask + - websocket + # Notebook + - ipython + - jupyter + - jupyros + # ros + - colcon-common-extensions + - ros-humble-desktop-full + - ros-humble-control-msgs + - ros-humble-sensor-msgs + - ros-humble-trajectory-msgs + - ros-humble-navigation2 + - ros-humble-ros2-control + - ros-humble-robot-state-publisher + - ros-humble-joint-state-publisher + # web + - ros-humble-rosbridge-server + # geometry & motion planning + - ros-humble-tf2 + # - ros-humble-moveit // ignored because of NO python3.11 package in OSX + # - ros-humble-moveit-servo + # simulation + - ros-humble-simulation + # ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo + # ilab equipments +# - ros-humble-unilabos-msgs diff --git a/unilabos-osx-arm64.yaml b/unilabos-osx-arm64.yaml new file mode 100644 index 00000000..4c69fb90 --- /dev/null +++ b/unilabos-osx-arm64.yaml @@ -0,0 +1,63 @@ +name: unilab +channels: + - robostack + - robostack-staging + - conda-forge +dependencies: + # Basics + - python=3.11.11 + - compilers + - cmake + - make + - ninja + - sphinx + - sphinx_rtd_theme + # Data Visualization + - numpy + - scipy + - pandas + - networkx + - matplotlib + - pint + - openpyxl + # Device communication + - pyserial + - pyusb + - pylibftdi + - pymodbus + - python-can + - pyvisa + - opencv + # Service + - pydantic + - fastapi + - uvicorn + - gradio + - flask + - websocket + - paho-mqtt + # Notebook + - ipython + - jupyter + - jupyros + # ros + - colcon-common-extensions + - ros-humble-desktop-full + - ros-humble-control-msgs + - ros-humble-sensor-msgs + - ros-humble-trajectory-msgs + - ros-humble-navigation2 + - ros-humble-ros2-control + - ros-humble-robot-state-publisher + - ros-humble-joint-state-publisher + # web + - ros-humble-rosbridge-server + # geometry & motion planning + - ros-humble-tf2 + - ros-humble-moveit + - ros-humble-moveit-servo + # simulation + - ros-humble-simulation + # ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo + # ilab equipments +# - ros-humble-unilabos-msgs diff --git a/unilabos-win64.yaml b/unilabos-win64.yaml new file mode 100644 index 00000000..03f010dc --- /dev/null +++ b/unilabos-win64.yaml @@ -0,0 +1,61 @@ +name: unilab +channels: + - robostack + - robostack-staging + - conda-forge +dependencies: + # Basics + - python=3.11.11 + - compilers + - cmake + - make + - ninja + - sphinx + - sphinx_rtd_theme + # Data Visualization + - numpy + - scipy + - pandas + - networkx + - matplotlib + - pint + # Device communication + - pyserial + - pyusb + - pylibftdi + - pymodbus + - python-can + - pyvisa + - opencv + # Service + - pydantic + - fastapi + - uvicorn + - gradio + - flask + - websocket + # Notebook + - ipython + - jupyter + - jupyros + # ros + - colcon-common-extensions + - ros-humble-desktop-full + - ros-humble-control-msgs + - ros-humble-sensor-msgs + - ros-humble-trajectory-msgs + - ros-humble-navigation2 + - ros-humble-ros2-control + - ros-humble-robot-state-publisher + - ros-humble-joint-state-publisher + # web + - ros-humble-rosbridge-server + # geometry & motion planning + - ros-humble-tf2 + - ros-humble-moveit + - ros-humble-moveit-servo + # simulation + - ros-humble-simulation # ignored because of NO python3.11 package in WIN64 + # ros-humble-gazebo-ros // ignored because of the conflict with ign-gazebo + # ilab equipments +# - ros-humble-unilabos-msgs diff --git a/unilabos/__init__.py b/unilabos/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/app/__init__.py b/unilabos/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/app/backend.py b/unilabos/app/backend.py new file mode 100644 index 00000000..19cebff0 --- /dev/null +++ b/unilabos/app/backend.py @@ -0,0 +1,35 @@ +import threading + +from unilabos.utils import logger + + +# 根据选择的 backend 启动相应的功能 +def start_backend( + backend: str, + devices_config: dict = {}, + resources_config: dict = {}, + graph=None, + controllers_config: dict = {}, + bridges=[], + without_host: bool = False, + **kwargs +): + if backend == "ros": + # 假设 ros_main, simple_main, automancer_main 是不同 backend 的启动函数 + from unilabos.ros.main_slave_run import main, slave # 如果选择 'ros' 作为 backend + elif backend == 'simple': + # 这里假设 simple_backend 和 automancer_backend 是你定义的其他两个后端 + # from simple_backend import main as simple_main + pass + elif backend == 'automancer': + # from automancer_backend import main as automancer_main + pass + else: + raise ValueError(f"Unsupported backend: {backend}") + + backend_thread = threading.Thread( + target=main if not without_host else slave, + args=(devices_config, resources_config, graph, controllers_config, bridges) + ) + backend_thread.start() + logger.info(f"Backend {backend} started.") diff --git a/unilabos/app/controler.py b/unilabos/app/controler.py new file mode 100644 index 00000000..391413f7 --- /dev/null +++ b/unilabos/app/controler.py @@ -0,0 +1,34 @@ + +import json +import uuid +from unilabos.app.model import JobAddReq, JobData +from unilabos.ros.nodes.presets.host_node import HostNode + + +def get_resources() -> tuple: + if HostNode.get_instance() is None: + return False, "Host node not initialized" + + return True, HostNode.get_instance().resources_config + +def devices() -> tuple: + if HostNode.get_instance() is None: + return False, "Host node not initialized" + + return True, HostNode.get_instance().devices_config + +def job_info(id: str): + get_goal_status = HostNode.get_instance().get_goal_status(id) + return JobData(jobId=id, status=get_goal_status) + +def job_add(req: JobAddReq) -> JobData: + if req.job_id is None: + req.job_id = str(uuid.uuid4()) + action_name = req.data["action"] + action_kwargs = req.data["action_kwargs"] + req.data['action'] = action_name + if action_name == "execute_command_from_outer": + action_kwargs = {"command": json.dumps(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) + return JobData(jobId=req.job_id) diff --git a/unilabos/app/main.py b/unilabos/app/main.py new file mode 100644 index 00000000..11d790b3 --- /dev/null +++ b/unilabos/app/main.py @@ -0,0 +1,155 @@ +import argparse +import os +import signal +import sys +import json +import yaml +from copy import deepcopy + +# 首先添加项目根目录到路径 +current_dir = os.path.dirname(os.path.abspath(__file__)) +ilabos_dir = os.path.dirname(os.path.dirname(current_dir)) +if ilabos_dir not in sys.path: + sys.path.append(ilabos_dir) + +from unilabos.config.config import load_config, BasicConfig +from unilabos.utils.banner_print import print_status, print_unilab_banner + + +def parse_args(): + """解析命令行参数""" + parser = argparse.ArgumentParser(description="Start Uni-Lab Edge server.") + parser.add_argument("-g", "--graph", help="Physical setup graph.") + parser.add_argument("-d", "--devices", help="Devices config file.") + parser.add_argument("-r", "--resources", help="Resources config file.") + parser.add_argument("-c", "--controllers", default=None, help="Controllers config file.") + parser.add_argument( + "--registry_path", + type=str, + default=None, + action="append", + help="Path to the registry", + ) + parser.add_argument( + "--backend", + choices=["ros", "simple", "automancer"], + default="ros", + help="Choose the backend to run with: 'ros', 'simple', or 'automancer'.", + ) + parser.add_argument( + "--app_bridges", + nargs="+", + default=["mqtt", "fastapi"], + help="Bridges to connect to. Now support 'mqtt' and 'fastapi'.", + ) + parser.add_argument( + "--without_host", + action="store_true", + help="Run the backend as slave (without host).", + ) + parser.add_argument( + "--slave_no_host", + action="store_true", + help="Slave模式下跳过等待host服务", + ) + parser.add_argument( + "--config", + type=str, + default=None, + help="配置文件路径,支持.py格式的Python配置文件", + ) + + return parser.parse_args() + + +def main(): + """主函数""" + # 解析命令行参数 + args = parse_args() + args_dict = vars(args) + + # 加载配置文件 - 这里保持最先加载配置的逻辑 + if args_dict.get("config"): + config_path = args_dict["config"] + if not os.path.exists(config_path): + print_status(f"配置文件 {config_path} 不存在", "error") + elif not config_path.endswith(".py"): + print_status(f"配置文件 {config_path} 不是Python文件,必须以.py结尾", "error") + else: + load_config(config_path) + + # 设置BasicConfig参数 + BasicConfig.is_host_mode = not args_dict.get("without_host", False) + BasicConfig.slave_no_host = args_dict.get("slave_no_host", False) + + from unilabos.resources.graphio import ( + read_node_link_json, + read_graphml, + dict_from_graph, + dict_to_nested_dict, + initialize_resources, + ) + from unilabos.app.mq import mqtt_client + from unilabos.registry.registry import build_registry + from unilabos.app.backend import start_backend + from unilabos.web import http_client + from unilabos.web import start_server + + # 显示启动横幅 + print_unilab_banner(args_dict) + + # 注册表 + build_registry(args_dict["registry_path"]) + + if args_dict["graph"] is not None: + import unilabos.resources.graphio as graph_res + graph_res.physical_setup_graph = ( + read_node_link_json(args_dict["graph"]) + if args_dict["graph"].endswith(".json") + else read_graphml(args_dict["graph"]) + ) + devices_and_resources = dict_from_graph(graph_res.physical_setup_graph) + args_dict["resources_config"] = initialize_resources(list(deepcopy(devices_and_resources).values())) + args_dict["devices_config"] = dict_to_nested_dict(deepcopy(devices_and_resources), devices_only=False) + # args_dict["resources_config"] = dict_to_tree(devices_and_resources, devices_only=False) + args_dict["graph"] = graph_res.physical_setup_graph + else: + if args_dict["devices"] is None or args_dict["resources"] is None: + print_status("Either graph or devices and resources must be provided.", "error") + sys.exit(1) + args_dict["devices_config"] = json.load(open(args_dict["devices"], encoding="utf-8")) + args_dict["resources_config"] = initialize_resources( + list(json.load(open(args_dict["resources"], encoding="utf-8")).values()) + ) + + print_status(f"{len(args_dict['resources_config'])} Resources loaded:", "info") + for i in args_dict["resources_config"]: + print_status(f"DeviceId: {i['id']}, Class: {i['class']}", "info") + + if args_dict["controllers"] is not None: + args_dict["controllers_config"] = yaml.safe_load(open(args_dict["controllers"], encoding="utf-8")) + else: + args_dict["controllers_config"] = None + + args_dict["bridges"] = [] + + if "mqtt" in args_dict["app_bridges"]: + args_dict["bridges"].append(mqtt_client) + if "fastapi" in args_dict["app_bridges"]: + args_dict["bridges"].append(http_client) + if "mqtt" in args_dict["app_bridges"]: + + def _exit(signum, frame): + mqtt_client.stop() + sys.exit(0) + + signal.signal(signal.SIGINT, _exit) + signal.signal(signal.SIGTERM, _exit) + mqtt_client.start() + + start_backend(**args_dict) + start_server() + + +if __name__ == "__main__": + main() diff --git a/unilabos/app/model.py b/unilabos/app/model.py new file mode 100644 index 00000000..ee7568fa --- /dev/null +++ b/unilabos/app/model.py @@ -0,0 +1,137 @@ +from pydantic import BaseModel, Field + + +class RespCode: + Success = 0 + + ErrorHostNotInit = 2001 # Host node not initialized + ErrorInvalidReq = 2002 # Invalid request data + + +class DeviceAction(BaseModel): + x: str + y: str + action: str + + +class Device(BaseModel): + id: str + name: str + action: DeviceAction + + +class DeviceList(BaseModel): + items: list[Device] = [] + page: int + pageSize: int + + +class DevicesResponse(BaseModel): + code: int + data: DeviceList + + +class DeviceInfoResponse(BaseModel): + code: int + data: Device + + +class PageResp(BaseModel): + item: list = [] + page: int = 1 + pageSize: int = 10 + + +class Resp(BaseModel): + code: int = RespCode.Success + data: dict = {} + message: str = "success" + + +class JobAddReq(BaseModel): + device_id: str = Field(examples=["Gripper"], description="device id") + data: dict = Field(examples=[{"position": 30, "torque": 5, "action": "push_to"}]) + job_id: str = Field(examples=["sfsfsfeq"], description="goal uuid") + node_id: str = Field(examples=["sfsfsfeq"], description="node uuid") + + +class JobStepFinishReq(BaseModel): + token: str = Field(examples=["030944"], description="token") + request_time: str = Field( + examples=["2024-12-12 12:12:12.xxx"], description="requestTime" + ) + data: dict = Field( + examples=[ + { + "orderCode": "任务号。字符串", + "orderName": "任务名称。字符串", + "stepName": "步骤名称。字符串", + "stepId": "步骤Id。GUID", + "sampleId": "通量Id。GUID", + "startTime": "开始时间。时间格式", + "endTime": "完成时间。时间格式", + } + ] + ) + + +class JobPreintakeFinishReq(BaseModel): + token: str = Field(examples=["030944"], description="token") + request_time: str = Field( + examples=["2024-12-12 12:12:12.xxx"], description="requestTime" + ) + data: dict = Field( + examples=[ + { + "orderCode": "任务号。字符串", + "orderName": "任务名称。字符串", + "sampleId": "通量Id。GUID", + "startTime": "开始时间。时间格式", + "endTime": "完成时间。时间格式", + "Status": "通量状态,0待生产、2进样、10开始、完成20、异常停止-2、人工停止或取消-3", + } + ] + ) + + +class JobFinishReq(BaseModel): + token: str = Field(examples=["030944"], description="token") + request_time: str = Field( + examples=["2024-12-12 12:12:12.xxx"], description="requestTime" + ) + data: dict = Field( + examples=[ + { + "orderCode": "任务号。字符串", + "orderName": "任务名称。字符串", + "startTime": "开始时间。时间格式", + "endTime": "完成时间。时间格式", + "status": "通量状态,完成30、异常停止-11、人工停止或取消-12", + "usedMaterials": [ + { + "materialId": "物料Id。GUID", + "locationId": "库位Id。GUID", + "typeMode": "物料类型。 样品1、试剂2、耗材0", + "usedQuantity": "使用的数量。 数字", + } + ], + } + ] + ) + + +class JobData(BaseModel): + jobId: str = Field(examples=["sfsfsfeq"], description="goal uuid") + status: int = Field( + examples=[0, 1], + default=0, + description="0:UNKNOWN, 1:ACCEPTED, 2:EXECUTING, 3:CANCELING, 4:SUCCEEDED, 5:CANCELED, 6:ABORTED", + ) + + +class JobStatusResp(Resp): + data: JobData + + +class JobAddResp(Resp): + data: JobData diff --git a/unilabos/app/mq.py b/unilabos/app/mq.py new file mode 100644 index 00000000..a8bd08a1 --- /dev/null +++ b/unilabos/app/mq.py @@ -0,0 +1,177 @@ +import json +import time +import uuid + +import paho.mqtt.client as mqtt +import ssl, base64, hmac +from hashlib import sha1 +import tempfile +import os + +from unilabos.config.config import MQConfig +from unilabos.app.controler import devices, job_add +from unilabos.app.model import JobAddReq, JobAddResp +from unilabos.utils import logger +from unilabos.utils.type_check import TypeEncoder + + +class MQTTClient: + mqtt_disable = True + + def __init__(self): + self.mqtt_disable = not MQConfig.lab_id + self.client_id = f"{MQConfig.group_id}@@@{MQConfig.lab_id}{uuid.uuid4()}" + self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=self.client_id, protocol=mqtt.MQTTv5) + self._setup_callbacks() + + def _setup_callbacks(self): + self.client.on_log = self._on_log + self.client.on_connect = self._on_connect + self.client.on_message = self._on_message + self.client.on_disconnect = self._on_disconnect + + def _on_log(self, client, userdata, level, buf): + logger.info(f"[MQTT] log: {buf}") + + def _on_connect(self, client, userdata, flags, rc, properties=None): + logger.info("[MQTT] Connected with result code " + str(rc)) + client.subscribe(f"labs/{MQConfig.lab_id}/job/start/", 0) + isok, data = devices() + if not isok: + logger.error("[MQTT] on_connect ErrorHostNotInit") + return + + def _on_message(self, client, userdata, msg): + logger.info("[MQTT] on_message<<<< " + msg.topic + " " + str(msg.payload)) + try: + payload_str = msg.payload.decode("utf-8") + 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/": + logger.debug("job_add", type(payload_json), payload_json) + job_req = JobAddReq.model_validate(payload_json) + data = job_add(job_req) + return JobAddResp(data=data) + + except json.JSONDecodeError as e: + logger.error(f"[MQTT] JSON 解析错误: {e}") + logger.error(f"[MQTT] Raw message: {msg.payload}") + except Exception as e: + logger.error(f"[MQTT] 处理消息时出错: {e}") + + def _on_disconnect(self, client, userdata, rc, reasonCode=None, properties=None): + if rc != 0: + logger.error(f"[MQTT] Unexpected disconnection {rc}") + + def _setup_ssl_context(self): + temp_files = [] + try: + with tempfile.NamedTemporaryFile(mode="w", delete=False) as ca_temp: + ca_temp.write(MQConfig.ca_content) + temp_files.append(ca_temp.name) + + with tempfile.NamedTemporaryFile(mode="w", delete=False) as cert_temp: + cert_temp.write(MQConfig.cert_content) + temp_files.append(cert_temp.name) + + with tempfile.NamedTemporaryFile(mode="w", delete=False) as key_temp: + key_temp.write(MQConfig.key_content) + temp_files.append(key_temp.name) + + context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) + context.load_verify_locations(cafile=temp_files[0]) + context.load_cert_chain(certfile=temp_files[1], keyfile=temp_files[2]) + self.client.tls_set_context(context) + finally: + for temp_file in temp_files: + try: + os.unlink(temp_file) + except: + pass + + def start(self): + if self.mqtt_disable: + logger.warning("MQTT is disabled, skipping connection.") + return + userName = f"Signature|{MQConfig.access_key}|{MQConfig.instance_id}" + password = base64.b64encode( + hmac.new(MQConfig.secret_key.encode(), self.client_id.encode(), sha1).digest() + ).decode() + + self.client.username_pw_set(userName, password) + self._setup_ssl_context() + + # 创建连接线程 + def connect_thread_func(): + try: + self.client.connect(MQConfig.broker_url, MQConfig.port, 60) + self.client.loop_start() + + # 添加连接超时检测 + max_attempts = 5 + attempt = 0 + while not self.client.is_connected() and attempt < max_attempts: + logger.info( + f"[MQTT] 正在连接到 {MQConfig.broker_url}:{MQConfig.port},尝试 {attempt+1}/{max_attempts}" + ) + time.sleep(3) + attempt += 1 + + if self.client.is_connected(): + logger.info(f"[MQTT] 已成功连接到 {MQConfig.broker_url}:{MQConfig.port}") + else: + logger.error(f"[MQTT] 连接超时,可能是账号密码错误或网络问题") + self.client.loop_stop() + except Exception as e: + logger.error(f"[MQTT] 连接失败: {str(e)}") + + connect_thread_func() + # connect_thread = threading.Thread(target=connect_thread_func) + # connect_thread.daemon = True + # connect_thread.start() + + def stop(self): + if self.mqtt_disable: + return + self.client.disconnect() + self.client.loop_stop() + + def publish_device_status(self, device_status: dict, device_id, property_name): + # status = device_status.get(device_id, {}) + if self.mqtt_disable: + return + status = {"data": device_status.get(device_id, {}), "device_id": device_id} + address = f"labs/{MQConfig.lab_id}/devices" + self.client.publish(address, json.dumps(status), qos=2) + logger.critical(f"Device status published: address: {address}, {status}") + + def publish_job_status(self, feedback_data: dict, job_id: str, status: str): + if self.mqtt_disable: + return + jobdata = {"job_id": job_id, "data": feedback_data, "status": status} + self.client.publish(f"labs/{MQConfig.lab_id}/job/list/", json.dumps(jobdata), qos=2) + + def publish_registry(self, device_id: str, device_info: dict): + if self.mqtt_disable: + return + address = f"labs/{MQConfig.lab_id}/registry/" + registry_data = json.dumps({device_id: device_info}, ensure_ascii = False, cls = TypeEncoder) + self.client.publish(address, registry_data, qos=2) + logger.debug(f"Registry data published: address: {address}, {registry_data}") + + def publish_actions(self, action_id: str, action_info: dict): + if self.mqtt_disable: + return + address = f"labs/{MQConfig.lab_id}/actions/" + action_type_name = action_info["title"] + action_info["title"] = action_id + action_data = json.dumps({action_type_name: action_info}, ensure_ascii=False) + self.client.publish(address, action_data, qos=2) + logger.debug(f"Action data published: address: {address}, {action_data}") + + +mqtt_client = MQTTClient() + +if __name__ == "__main__": + mqtt_client.start() diff --git a/unilabos/app/oss_upload.py b/unilabos/app/oss_upload.py new file mode 100644 index 00000000..6f9431fe --- /dev/null +++ b/unilabos/app/oss_upload.py @@ -0,0 +1,231 @@ +import argparse +import os +import time +from typing import Dict, Optional, Tuple + +import requests + +from unilabos.config.config import OSSUploadConfig + + +def _init_upload(file_path: str, oss_path: str, filename: Optional[str] = None, + process_key: str = "file-upload", device_id: str = "default", + expires_hours: int = 1) -> Tuple[bool, Dict]: + """ + 初始化上传过程 + + Args: + file_path: 本地文件路径 + oss_path: OSS目标路径 + filename: 文件名,如果为None则使用file_path的文件名 + process_key: 处理键 + device_id: 设备ID + expires_hours: 链接过期小时数 + + Returns: + (成功标志, 响应数据) + """ + if filename is None: + filename = os.path.basename(file_path) + + # 构造初始化请求 + url = f"{OSSUploadConfig.api_host}{OSSUploadConfig.init_endpoint}" + headers = { + "Authorization": OSSUploadConfig.authorization, + "Content-Type": "application/json" + } + + payload = { + "device_id": device_id, + "process_key": process_key, + "filename": filename, + "path": oss_path, + "expires_hours": expires_hours + } + + try: + response = requests.post(url, headers=headers, json=payload) + if response.status_code == 201: + result = response.json() + if result.get("code") == "10000": + return True, result.get("data", {}) + + print(f"初始化上传失败: {response.status_code}, {response.text}") + return False, {} + except Exception as e: + print(f"初始化上传异常: {str(e)}") + return False, {} + + +def _put_upload(file_path: str, upload_url: str) -> bool: + """ + 执行PUT上传 + + Args: + file_path: 本地文件路径 + upload_url: 上传URL + + Returns: + 是否成功 + """ + try: + with open(file_path, "rb") as f: + response = requests.put(upload_url, data=f) + if response.status_code == 200: + return True + + print(f"PUT上传失败: {response.status_code}, {response.text}") + return False + except Exception as e: + print(f"PUT上传异常: {str(e)}") + return False + + +def _complete_upload(uuid: str) -> bool: + """ + 完成上传过程 + + Args: + uuid: 上传的UUID + + Returns: + 是否成功 + """ + url = f"{OSSUploadConfig.api_host}{OSSUploadConfig.complete_endpoint}" + headers = { + "Authorization": OSSUploadConfig.authorization, + "Content-Type": "application/json" + } + + payload = { + "uuid": uuid + } + + try: + response = requests.post(url, headers=headers, json=payload) + if response.status_code == 200: + result = response.json() + if result.get("code") == "10000": + return True + + print(f"完成上传失败: {response.status_code}, {response.text}") + return False + except Exception as e: + print(f"完成上传异常: {str(e)}") + return False + + +def oss_upload(file_path: str, oss_path: str, filename: Optional[str] = None, + process_key: str = "file-upload", device_id: str = "default") -> bool: + """ + 文件上传主函数,包含重试机制 + + Args: + file_path: 本地文件路径 + oss_path: OSS目标路径 + filename: 文件名,如果为None则使用file_path的文件名 + process_key: 处理键 + device_id: 设备ID + + Returns: + 是否成功上传 + """ + max_retries = OSSUploadConfig.max_retries + retry_count = 0 + + while retry_count < max_retries: + try: + # 步骤1:初始化上传 + init_success, init_data = _init_upload( + file_path=file_path, + oss_path=oss_path, + filename=filename, + process_key=process_key, + device_id=device_id + ) + + if not init_success: + print(f"初始化上传失败,重试 {retry_count + 1}/{max_retries}") + retry_count += 1 + time.sleep(1) # 等待1秒后重试 + continue + + # 获取UUID和上传URL + uuid = init_data.get("uuid") + upload_url = init_data.get("upload_url") + + if not uuid or not upload_url: + print(f"初始化上传返回数据不完整,重试 {retry_count + 1}/{max_retries}") + retry_count += 1 + time.sleep(1) + continue + + # 步骤2:PUT上传文件 + put_success = _put_upload(file_path, upload_url) + if not put_success: + print(f"PUT上传失败,重试 {retry_count + 1}/{max_retries}") + retry_count += 1 + time.sleep(1) + continue + + # 步骤3:完成上传 + complete_success = _complete_upload(uuid) + if not complete_success: + print(f"完成上传失败,重试 {retry_count + 1}/{max_retries}") + retry_count += 1 + time.sleep(1) + continue + + # 所有步骤都成功 + print(f"文件 {file_path} 上传成功") + return True + + except Exception as e: + print(f"上传过程异常: {str(e)},重试 {retry_count + 1}/{max_retries}") + retry_count += 1 + time.sleep(1) + + print(f"文件 {file_path} 上传失败,已达到最大重试次数 {max_retries}") + return False + + +if __name__ == "__main__": + # python -m unilabos.app.oss_upload -f /path/to/your/file.txt + # 命令行参数解析 + parser = argparse.ArgumentParser(description='文件上传测试工具') + parser.add_argument('--file', '-f', type=str, required=True, help='要上传的本地文件路径') + parser.add_argument('--path', '-p', type=str, default='/HPLC1/Any', help='OSS目标路径') + parser.add_argument('--device', '-d', type=str, default='test-device', help='设备ID') + parser.add_argument('--process', '-k', type=str, default='HPLC-txt-result', help='处理键') + + args = parser.parse_args() + + # 检查文件是否存在 + if not os.path.exists(args.file): + print(f"错误:文件 {args.file} 不存在") + exit(1) + + print("=" * 50) + print(f"开始上传文件: {args.file}") + print(f"目标路径: {args.path}") + print(f"设备ID: {args.device}") + print(f"处理键: {args.process}") + print("=" * 50) + + # 执行上传 + success = oss_upload( + file_path=args.file, + oss_path=args.path, + filename=None, # 使用默认文件名 + process_key=args.process, + device_id=args.device + ) + + # 输出结果 + if success: + print("\n√ 文件上传成功!") + exit(0) + else: + print("\n× 文件上传失败!") + exit(1) + diff --git a/unilabos/compile/__init__.py b/unilabos/compile/__init__.py new file mode 100644 index 00000000..820f43f1 --- /dev/null +++ b/unilabos/compile/__init__.py @@ -0,0 +1,19 @@ +from unilabos.messages import * +from .pump_protocol import generate_pump_protocol, generate_pump_protocol_with_rinsing +from .clean_protocol import generate_clean_protocol +from .separate_protocol import generate_separate_protocol +from .evaporate_protocol import generate_evaporate_protocol +from .evacuateandrefill_protocol import generate_evacuateandrefill_protocol +from .agv_transfer_protocol import generate_agv_transfer_protocol + + +# Define a dictionary of protocol generators. +action_protocol_generators = { + PumpTransferProtocol: generate_pump_protocol_with_rinsing, + CleanProtocol: generate_clean_protocol, + SeparateProtocol: generate_separate_protocol, + EvaporateProtocol: generate_evaporate_protocol, + EvacuateAndRefillProtocol: generate_evacuateandrefill_protocol, + AGVTransferProtocol: generate_agv_transfer_protocol, +} +# End Protocols diff --git a/unilabos/compile/agv_transfer_protocol.py b/unilabos/compile/agv_transfer_protocol.py new file mode 100644 index 00000000..18c28b6b --- /dev/null +++ b/unilabos/compile/agv_transfer_protocol.py @@ -0,0 +1,53 @@ +import networkx as nx + + +def generate_agv_transfer_protocol( + G: nx.Graph, + from_repo: dict, + from_repo_position: str, + to_repo: dict = {}, + to_repo_position: str = "" +): + from_repo_ = list(from_repo.values())[0] + to_repo_ = list(to_repo.values())[0] + resource_to_move = from_repo_["children"].pop(from_repo_position) + resource_to_move["parent"] = to_repo_["id"] + to_repo_["children"][to_repo_position] = resource_to_move + + from_repo_id = from_repo_["id"] + to_repo_id = to_repo_["id"] + + wf_list = { + ("AiChemEcoHiWo", "zhixing_agv"): {"nav_command" : '{"target" : "LM14"}', + "arm_command": '{"task_name" : "camera/250111_biaozhi.urp"}'}, + ("AiChemEcoHiWo", "AGV"): {"nav_command" : '{"target" : "LM14"}', + "arm_command": '{"task_name" : "camera/250111_biaozhi.urp"}'}, + + ("zhixing_agv", "Revvity"): {"nav_command" : '{"target" : "LM13"}', + "arm_command": '{"task_name" : "camera/250111_put_board.urp"}'}, + + ("AGV", "Revvity"): {"nav_command" : '{"target" : "LM13"}', + "arm_command": '{"task_name" : "camera/250111_put_board.urp"}'}, + + ("Revvity", "HPLC"): {"nav_command": '{"target" : "LM13"}', + "arm_command": '{"task_name" : "camera/250111_hplc.urp"}'}, + + ("HPLC", "Revvity"): {"nav_command": '{"target" : "LM13"}', + "arm_command": '{"task_name" : "camera/250111_lfp.urp"}'}, + } + return [ + { + "device_id": "zhixing_agv", + "action_name": "send_nav_task", + "action_kwargs": { + "command": wf_list[(from_repo_id, to_repo_id)]["nav_command"] + } + }, + { + "device_id": "zhixing_ur_arm", + "action_name": "move_pos_task", + "action_kwargs": { + "command": wf_list[(from_repo_id, to_repo_id)]["arm_command"] + } + } + ] diff --git a/unilabos/compile/clean_protocol.py b/unilabos/compile/clean_protocol.py new file mode 100644 index 00000000..b2ab1414 --- /dev/null +++ b/unilabos/compile/clean_protocol.py @@ -0,0 +1,62 @@ +import numpy as np +import networkx as nx + + +def generate_clean_protocol( + G: nx.DiGraph, + vessel: str, # Vessel to clean. + solvent: str, # Solvent to clean vessel with. + volume: float = 25000.0, # Optional. Volume of solvent to clean vessel with. + temp: float = 25, # Optional. Temperature to heat vessel to while cleaning. + repeats: int = 1, # Optional. Number of cleaning cycles to perform. +) -> list[dict]: + """ + Generate a protocol to clean a vessel with a solvent. + + :param G: Directed graph. Nodes are containers and pumps, edges are fluidic connections. + :param vessel: Vessel to clean. + :param solvent: Solvent to clean vessel with. + :param volume: Volume of solvent to clean vessel with. + :param temp: Temperature to heat vessel to while cleaning. + :param repeats: Number of cleaning cycles to perform. + :return: List of actions to clean vessel. + """ + + # 生成泵操作的动作序列 + pump_action_sequence = [] + from_vessel = f"flask_{solvent}" + waste_vessel = f"waste_workup" + + transfer_flowrate = flowrate = 2500.0 + + # 生成泵操作的动作序列 + for i in range(repeats): + # 单泵依次执行阀指令、活塞指令,将液体吸入与之相连的第一台泵 + pump_action_sequence.extend([ + { + "device_id": "", + "action_name": "PumpTransferProtocol", + "action_kwargs": { + "from_vessel": from_vessel, + "to_vessel": vessel, + "volume": volume, + "time": volume / flowrate, + # "transfer_flowrate": transfer_flowrate, + } + } + ]) + + pump_action_sequence.extend([ + { + "device_id": "", + "action_name": "PumpTransferProtocol", + "action_kwargs": { + "from_vessel": vessel, + "to_vessel": waste_vessel, + "volume": volume, + "time": volume / flowrate, + # "transfer_flowrate": transfer_flowrate, + } + } + ]) + return pump_action_sequence diff --git a/unilabos/compile/evacuateandrefill_protocol.py b/unilabos/compile/evacuateandrefill_protocol.py new file mode 100644 index 00000000..9cde400f --- /dev/null +++ b/unilabos/compile/evacuateandrefill_protocol.py @@ -0,0 +1,143 @@ +import numpy as np +import networkx as nx + + +def generate_evacuateandrefill_protocol( + G: nx.DiGraph, + vessel: str, + gas: str, + repeats: int = 1 +) -> list[dict]: + """ + 生成泵操作的动作序列。 + + :param G: 有向图, 节点为容器和注射泵, 边为流体管道, A→B边的属性为管道接A端的阀门位置 + :param from_vessel: 容器A + :param to_vessel: 容器B + :param volume: 转移的体积 + :param flowrate: 最终注入容器B时的流速 + :param transfer_flowrate: 泵骨架中转移流速(若不指定,默认与注入流速相同) + :return: 泵操作的动作序列 + """ + + # 生成电磁阀、真空泵、气源操作的动作序列 + vacuum_action_sequence = [] + nodes = G.nodes(data=True) + + # 找到和 vessel 相连的电磁阀和真空泵、气源 + vacuum_backbone = {"vessel": vessel} + + for neighbor in G.neighbors(vessel): + if nodes[neighbor]["class"].startswith("solenoid_valve"): + for neighbor2 in G.neighbors(neighbor): + if neighbor2 == vessel: + continue + if nodes[neighbor2]["class"].startswith("vacuum_pump"): + vacuum_backbone.update({"vacuum_valve": neighbor, "pump": neighbor2}) + break + elif nodes[neighbor2]["class"].startswith("gas_source"): + vacuum_backbone.update({"gas_valve": neighbor, "gas": neighbor2}) + break + # 判断是否设备齐全 + if len(vacuum_backbone) < 5: + print(f"\n\n\n{vacuum_backbone}\n\n\n") + raise ValueError("Not all devices are connected to the vessel.") + + # 生成操作的动作序列 + for i in range(repeats): + # 打开真空泵阀门、关闭气源阀门 + vacuum_action_sequence.append([ + { + "device_id": vacuum_backbone["vacuum_valve"], + "action_name": "set_valve_position", + "action_kwargs": { + "command": "OPEN" + } + }, + { + "device_id": vacuum_backbone["gas_valve"], + "action_name": "set_valve_position", + "action_kwargs": { + "command": "CLOSED" + } + } + ]) + + # 打开真空泵、关闭气源 + vacuum_action_sequence.append([ + { + "device_id": vacuum_backbone["pump"], + "action_name": "set_status", + "action_kwargs": { + "command": "ON" + } + }, + { + "device_id": vacuum_backbone["gas"], + "action_name": "set_status", + "action_kwargs": { + "command": "OFF" + } + } + ]) + vacuum_action_sequence.append({"action_name": "wait", "action_kwargs": {"time": 60}}) + + # 关闭真空泵阀门、打开气源阀门 + vacuum_action_sequence.append([ + { + "device_id": vacuum_backbone["vacuum_valve"], + "action_name": "set_valve_position", + "action_kwargs": { + "command": "CLOSED" + } + }, + { + "device_id": vacuum_backbone["gas_valve"], + "action_name": "set_valve_position", + "action_kwargs": { + "command": "OPEN" + } + } + ]) + + # 关闭真空泵、打开气源 + vacuum_action_sequence.append([ + { + "device_id": vacuum_backbone["pump"], + "action_name": "set_status", + "action_kwargs": { + "command": "OFF" + } + }, + { + "device_id": vacuum_backbone["gas"], + "action_name": "set_status", + "action_kwargs": { + "command": "ON" + } + } + ]) + vacuum_action_sequence.append({"action_name": "wait", "action_kwargs": {"time": 60}}) + + # 关闭气源 + vacuum_action_sequence.append( + { + "device_id": vacuum_backbone["gas"], + "action_name": "set_status", + "action_kwargs": { + "command": "OFF" + } + } + ) + + # 关闭阀门 + vacuum_action_sequence.append( + { + "device_id": vacuum_backbone["gas_valve"], + "action_name": "set_valve_position", + "action_kwargs": { + "command": "CLOSED" + } + } + ) + return vacuum_action_sequence diff --git a/unilabos/compile/evaporate_protocol.py b/unilabos/compile/evaporate_protocol.py new file mode 100644 index 00000000..8c729666 --- /dev/null +++ b/unilabos/compile/evaporate_protocol.py @@ -0,0 +1,81 @@ +import numpy as np +import networkx as nx + + +def generate_evaporate_protocol( + G: nx.DiGraph, + vessel: str, + pressure: float, + temp: float, + time: float, + stir_speed: float +) -> list[dict]: + """ + Generate a protocol to evaporate a solution from a vessel. + + :param G: Directed graph. Nodes are containers and pumps, edges are fluidic connections. + :param vessel: Vessel to clean. + :param solvent: Solvent to clean vessel with. + :param volume: Volume of solvent to clean vessel with. + :param temp: Temperature to heat vessel to while cleaning. + :param repeats: Number of cleaning cycles to perform. + :return: List of actions to clean vessel. + """ + + # 生成泵操作的动作序列 + pump_action_sequence = [] + reactor_volume = 500000.0 + transfer_flowrate = flowrate = 2500.0 + + # 开启冷凝器 + pump_action_sequence.append({ + "device_id": "rotavap_chiller", + "action_name": "set_temperature", + "action_kwargs": { + "command": "-40" + } + }) + # TODO: 通过温度反馈改为 HeatChillToTemp,而非等待固定时间 + pump_action_sequence.append({ + "action_name": "wait", + "action_kwargs": { + "time": 1800 + } + }) + + # 开启旋蒸真空泵、旋转,在液体转移后运行time时间 + pump_action_sequence.append({ + "device_id": "rotavap_controller", + "action_name": "set_pump_time", + "action_kwargs": { + "command": str(time + reactor_volume / flowrate * 3) + } + }) + pump_action_sequence.append({ + "device_id": "rotavap_controller", + "action_name": "set_pump_time", + "action_kwargs": { + "command": str(time + reactor_volume / flowrate * 3) + } + }) + + # 液体转入旋转蒸发器 + pump_action_sequence.append({ + "device_id": "", + "action_name": "PumpTransferProtocol", + "action_kwargs": { + "from_vessel": vessel, + "to_vessel": "rotavap", + "volume": reactor_volume, + "time": reactor_volume / flowrate, + # "transfer_flowrate": transfer_flowrate, + } + }) + + pump_action_sequence.append({ + "action_name": "wait", + "action_kwargs": { + "time": time + } + }) + return pump_action_sequence diff --git a/unilabos/compile/pump_protocol.py b/unilabos/compile/pump_protocol.py new file mode 100644 index 00000000..9b4c2884 --- /dev/null +++ b/unilabos/compile/pump_protocol.py @@ -0,0 +1,213 @@ +import numpy as np +import networkx as nx + + +def generate_pump_protocol( + G: nx.DiGraph, + from_vessel: str, + to_vessel: str, + volume: float, + flowrate: float = 500.0, + transfer_flowrate: float = 0, +) -> list[dict]: + """ + 生成泵操作的动作序列。 + + :param G: 有向图, 节点为容器和注射泵, 边为流体管道, A→B边的属性为管道接A端的阀门位置 + :param from_vessel: 容器A + :param to_vessel: 容器B + :param volume: 转移的体积 + :param flowrate: 最终注入容器B时的流速 + :param transfer_flowrate: 泵骨架中转移流速(若不指定,默认与注入流速相同) + :return: 泵操作的动作序列 + """ + + # 生成泵操作的动作序列 + pump_action_sequence = [] + nodes = G.nodes(data=True) + # 从from_vessel到to_vessel的最短路径 + shortest_path = nx.shortest_path(G, source=from_vessel, target=to_vessel) + print(shortest_path) + + pump_backbone = shortest_path + if not from_vessel.startswith("pump"): + pump_backbone = pump_backbone[1:] + if not to_vessel.startswith("pump"): + pump_backbone = pump_backbone[:-1] + + if transfer_flowrate == 0: + transfer_flowrate = flowrate + + min_transfer_volume = min([nodes[pump]["max_volume"] for pump in pump_backbone]) + repeats = int(np.ceil(volume / min_transfer_volume)) + if repeats > 1 and (from_vessel.startswith("pump") or to_vessel.startswith("pump")): + raise ValueError("Cannot transfer volume larger than min_transfer_volume between two pumps.") + + volume_left = volume + + # 生成泵操作的动作序列 + for i in range(repeats): + # 单泵依次执行阀指令、活塞指令,将液体吸入与之相连的第一台泵 + if not from_vessel.startswith("pump"): + pump_action_sequence.extend([ + { + "device_id": pump_backbone[0], + "action_name": "set_valve_position", + "action_kwargs": { + "command": G.get_edge_data(pump_backbone[0], from_vessel)["port"][pump_backbone[0]] + } + }, + { + "device_id": pump_backbone[0], + "action_name": "set_position", + "action_kwargs": { + "position": float(min(volume_left, min_transfer_volume)), + "max_velocity": transfer_flowrate + } + } + ]) + pump_action_sequence.append({"action_name": "wait", "action_kwargs": {"time": 5}}) + for pumpA, pumpB in zip(pump_backbone[:-1], pump_backbone[1:]): + # 相邻两泵同时切换阀门至连通位置 + pump_action_sequence.append([ + { + "device_id": pumpA, + "action_name": "set_valve_position", + "action_kwargs": { + "command": G.get_edge_data(pumpA, pumpB)["port"][pumpA] + } + }, + { + "device_id": pumpB, + "action_name": "set_valve_position", + "action_kwargs": { + "command": G.get_edge_data(pumpB, pumpA)["port"][pumpB], + } + } + ]) + # 相邻两泵液体转移:泵A排出液体,泵B吸入液体 + pump_action_sequence.append([ + { + "device_id": pumpA, + "action_name": "set_position", + "action_kwargs": { + "position": 0.0, + "max_velocity": transfer_flowrate + } + }, + { + "device_id": pumpB, + "action_name": "set_position", + "action_kwargs": { + "position": float(min(volume_left, min_transfer_volume)), + "max_velocity": transfer_flowrate + } + } + ]) + pump_action_sequence.append({"action_name": "wait", "action_kwargs": {"time": 5}}) + + if not to_vessel.startswith("pump"): + # 单泵依次执行阀指令、活塞指令,将最后一台泵液体缓慢加入容器B + pump_action_sequence.extend([ + { + "device_id": pump_backbone[-1], + "action_name": "set_valve_position", + "action_kwargs": { + "command": G.get_edge_data(pump_backbone[-1], to_vessel)["port"][pump_backbone[-1]] + } + }, + { + "device_id": pump_backbone[-1], + "action_name": "set_position", + "action_kwargs": { + "position": 0.0, + "max_velocity": flowrate + } + } + ]) + pump_action_sequence.append({"action_name": "wait", "action_kwargs": {"time": 5}}) + + volume_left -= min_transfer_volume + return pump_action_sequence + + +# Pump protocol compilation +def generate_pump_protocol_with_rinsing( + G: nx.DiGraph, + from_vessel: str, + to_vessel: str, + volume: float, + amount: str = "", + time: float = 0, + viscous: bool = False, + rinsing_solvent: str = "air", + rinsing_volume: float = 5000.0, + rinsing_repeats: int = 2, + solid: bool = False, + flowrate: float = 2500.0, + transfer_flowrate: float = 500.0, +) -> list[dict]: + """ + Generates a pump protocol for transferring a specified volume between vessels, including rinsing steps with a chosen solvent. This function constructs a sequence of pump actions based on the provided parameters and the shortest path in a directed graph. + + Args: + G (nx.DiGraph): The directed graph representing the vessels and connections. 有向图, 节点为容器和注射泵, 边为流体管道, A→B边的属性为管道接A端的阀门位置 + from_vessel (str): The name of the vessel to transfer from. + to_vessel (str): The name of the vessel to transfer to. + volume (float): The volume to transfer. + amount (str, optional): Additional amount specification (default is ""). + time (float, optional): Time over which to perform the transfer (default is 0). + viscous (bool, optional): Indicates if the fluid is viscous (default is False). + rinsing_solvent (str, optional): The solvent to use for rinsing (default is "air"). + rinsing_volume (float, optional): The volume of rinsing solvent to use (default is 5000.0). + rinsing_repeats (int, optional): The number of times to repeat rinsing (default is 2). + solid (bool, optional): Indicates if the transfer involves a solid (default is False). + flowrate (float, optional): The flow rate for the transfer (default is 2500.0). 最终注入容器B时的流速 + transfer_flowrate (float, optional): The flow rate for the transfer action (default is 500.0). 泵骨架中转移流速(若不指定,默认与注入流速相同) + + Returns: + list[dict]: A sequence of pump actions to be executed for the transfer and rinsing process. 泵操作的动作序列. + + Raises: + AssertionError: If the number of rinsing solvents does not match the number of rinsing repeats. + + Examples: + pump_protocol = generate_pump_protocol_with_rinsing(G, "vessel_A", "vessel_B", 100.0, rinsing_solvent="water") + """ + air_vessel = "flask_air" + waste_vessel = f"waste_workup" + + shortest_path = nx.shortest_path(G, source=from_vessel, target=to_vessel) + pump_backbone = shortest_path[1: -1] + nodes = G.nodes(data=True) + min_transfer_volume = float(min([nodes[pump]["max_volume"] for pump in pump_backbone])) + if time != 0: + flowrate = transfer_flowrate = volume / time + + pump_action_sequence = generate_pump_protocol(G, from_vessel, to_vessel, float(volume), flowrate, transfer_flowrate) + if rinsing_solvent != "air": + if "," in rinsing_solvent: + rinsing_solvents = rinsing_solvent.split(",") + assert len(rinsing_solvents) == rinsing_repeats, "Number of rinsing solvents must match number of rinsing repeats." + else: + rinsing_solvents = [rinsing_solvent] * rinsing_repeats + + for rinsing_solvent in rinsing_solvents: + solvent_vessel = f"flask_{rinsing_solvent}" + # 清洗泵 + pump_action_sequence.extend( + generate_pump_protocol(G, solvent_vessel, pump_backbone[0], min_transfer_volume, flowrate, transfer_flowrate) + + generate_pump_protocol(G, pump_backbone[0], pump_backbone[-1], min_transfer_volume, flowrate, transfer_flowrate) + + generate_pump_protocol(G, pump_backbone[-1], waste_vessel, min_transfer_volume, flowrate, transfer_flowrate) + ) + # 如果转移的是溶液,第一种冲洗溶剂请选用溶液的溶剂,稀释泵内、转移管道内的溶液。后续冲洗溶剂不需要此操作。 + if rinsing_solvent == rinsing_solvents[0]: + pump_action_sequence.extend(generate_pump_protocol(G, solvent_vessel, from_vessel, rinsing_volume, flowrate, transfer_flowrate)) + pump_action_sequence.extend(generate_pump_protocol(G, solvent_vessel, to_vessel, rinsing_volume, flowrate, transfer_flowrate)) + pump_action_sequence.extend(generate_pump_protocol(G, air_vessel, solvent_vessel, rinsing_volume, flowrate, transfer_flowrate)) + pump_action_sequence.extend(generate_pump_protocol(G, air_vessel, waste_vessel, rinsing_volume, flowrate, transfer_flowrate)) + pump_action_sequence.extend(generate_pump_protocol(G, air_vessel, from_vessel, rinsing_volume, flowrate, transfer_flowrate) * 2) + pump_action_sequence.extend(generate_pump_protocol(G, air_vessel, to_vessel, rinsing_volume, flowrate, transfer_flowrate) * 2) + + return pump_action_sequence +# End Protocols diff --git a/unilabos/compile/separate_protocol.py b/unilabos/compile/separate_protocol.py new file mode 100644 index 00000000..0ba0d1c8 --- /dev/null +++ b/unilabos/compile/separate_protocol.py @@ -0,0 +1,230 @@ +import numpy as np +import networkx as nx + + +def generate_separate_protocol( + G: nx.DiGraph, + purpose: str, # 'wash' or 'extract'. 'wash' means that product phase will not be the added solvent phase, 'extract' means product phase will be the added solvent phase. If no solvent is added just use 'extract'. + product_phase: str, # 'top' or 'bottom'. Phase that product will be in. + from_vessel: str, #Contents of from_vessel are transferred to separation_vessel and separation is performed. + separation_vessel: str, # Vessel in which separation of phases will be carried out. + to_vessel: str, # Vessel to send product phase to. + waste_phase_to_vessel: str, # Optional. Vessel to send waste phase to. + solvent: str, # Optional. Solvent to add to separation vessel after contents of from_vessel has been transferred to create two phases. + solvent_volume: float = 50000, # Optional. Volume of solvent to add. + through: str = "", # Optional. Solid chemical to send product phase through on way to to_vessel, e.g. 'celite'. + repeats: int = 1, # Optional. Number of separations to perform. + stir_time: float = 30, # Optional. Time stir for after adding solvent, before separation of phases. + stir_speed: float = 300, # Optional. Speed to stir at after adding solvent, before separation of phases. + settling_time: float = 300 # Optional. Time +) -> list[dict]: + """ + Generate a protocol to clean a vessel with a solvent. + + :param G: Directed graph. Nodes are containers and pumps, edges are fluidic connections. + :param vessel: Vessel to clean. + :param solvent: Solvent to clean vessel with. + :param volume: Volume of solvent to clean vessel with. + :param temp: Temperature to heat vessel to while cleaning. + :param repeats: Number of cleaning cycles to perform. + :return: List of actions to clean vessel. + """ + + # 生成泵操作的动作序列 + pump_action_sequence = [] + reactor_volume = 500000.0 + waste_vessel = waste_phase_to_vessel + + # TODO:通过物料管理系统找到溶剂的容器 + if "," in solvent: + solvents = solvent.split(",") + assert len(solvents) == repeats, "Number of solvents must match number of repeats." + else: + solvents = [solvent] * repeats + + # TODO: 通过设备连接图找到分离容器的控制器、底部出口 + separator_controller = f"{separation_vessel}_controller" + separation_vessel_bottom = f"flask_{separation_vessel}" + + transfer_flowrate = flowrate = 2500.0 + + if from_vessel != separation_vessel: + pump_action_sequence.append( + { + "device_id": "", + "action_name": "PumpTransferProtocol", + "action_kwargs": { + "from_vessel": from_vessel, + "to_vessel": separation_vessel, + "volume": reactor_volume, + "time": reactor_volume / flowrate, + # "transfer_flowrate": transfer_flowrate, + } + } + ) + + # for i in range(2): + # pump_action_sequence.append( + # { + # "device_id": "", + # "action_name": "CleanProtocol", + # "action_kwargs": { + # "vessel": from_vessel, + # "solvent": "H2O", # Solvent to clean vessel with. + # "volume": solvent_volume, # Optional. Volume of solvent to clean vessel with. + # "temp": 25.0, # Optional. Temperature to heat vessel to while cleaning. + # "repeats": 1 + # } + # } + # ) + # pump_action_sequence.append( + # { + # "device_id": "", + # "action_name": "CleanProtocol", + # "action_kwargs": { + # "vessel": from_vessel, + # "solvent": "CH2Cl2", # Solvent to clean vessel with. + # "volume": solvent_volume, # Optional. Volume of solvent to clean vessel with. + # "temp": 25.0, # Optional. Temperature to heat vessel to while cleaning. + # "repeats": 1 + # } + # } + # ) + + # 生成泵操作的动作序列 + for i in range(repeats): + # 找到当次萃取所用溶剂 + solvent_thistime = solvents[i] + solvent_vessel = f"flask_{solvent_thistime}" + + pump_action_sequence.append( + { + "device_id": "", + "action_name": "PumpTransferProtocol", + "action_kwargs": { + "from_vessel": solvent_vessel, + "to_vessel": separation_vessel, + "volume": solvent_volume, + "time": solvent_volume / flowrate, + # "transfer_flowrate": transfer_flowrate, + } + } + ) + pump_action_sequence.extend([ + # 搅拌、静置 + { + "device_id": separator_controller, + "action_name": "stir", + "action_kwargs": { + "stir_time": stir_time, + "stir_speed": stir_speed, + "settling_time": settling_time + } + }, + # 分液(判断电导突跃) + { + "device_id": separator_controller, + "action_name": "valve_open", + "action_kwargs": { + "command": "delta > 0.05" + } + } + ]) + + if product_phase == "bottom": + # 产物转移到目标瓶 + pump_action_sequence.append( + { + "device_id": "", + "action_name": "PumpTransferProtocol", + "action_kwargs": { + "from_vessel": separation_vessel_bottom, + "to_vessel": to_vessel, + "volume": 250000.0, + "time": 250000.0 / flowrate, + # "transfer_flowrate": transfer_flowrate, + } + } + ) + # 放出上面那一相,60秒后关阀门 + pump_action_sequence.append( + { + "device_id": separator_controller, + "action_name": "valve_open", + "action_kwargs": { + "command": "time > 60" + } + } + ) + # 弃去上面那一相进废液 + pump_action_sequence.append( + { + "device_id": "", + "action_name": "PumpTransferProtocol", + "action_kwargs": { + "from_vessel": separation_vessel_bottom, + "to_vessel": waste_vessel, + "volume": 250000.0, + "time": 250000.0 / flowrate, + # "transfer_flowrate": transfer_flowrate, + } + } + ) + elif product_phase == "top": + # 弃去下面那一相进废液 + pump_action_sequence.append( + { + "device_id": "", + "action_name": "PumpTransferProtocol", + "action_kwargs": { + "from_vessel": separation_vessel_bottom, + "to_vessel": waste_vessel, + "volume": 250000.0, + "time": 250000.0 / flowrate, + # "transfer_flowrate": transfer_flowrate, + } + } + ) + # 放出上面那一相 + pump_action_sequence.append( + { + "device_id": separator_controller, + "action_name": "valve_open", + "action_kwargs": { + "command": "time > 60" + } + } + ) + # 产物转移到目标瓶 + pump_action_sequence.append( + { + "device_id": "", + "action_name": "PumpTransferProtocol", + "action_kwargs": { + "from_vessel": separation_vessel_bottom, + "to_vessel": to_vessel, + "volume": 250000.0, + "time": 250000.0 / flowrate, + # "transfer_flowrate": transfer_flowrate, + } + } + ) + elif product_phase == "organic": + pass + + # 如果不是最后一次,从中转瓶转移回分液漏斗 + if i < repeats - 1: + pump_action_sequence.append( + { + "device_id": "", + "action_name": "PumpTransferProtocol", + "action_kwargs": { + "from_vessel": to_vessel, + "to_vessel": separation_vessel, + "volume": 250000.0, + "time": 250000.0 / flowrate, + # "transfer_flowrate": transfer_flowrate, + } + } + ) + return pump_action_sequence diff --git a/unilabos/config/__init__.py b/unilabos/config/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/config/config.py b/unilabos/config/config.py new file mode 100644 index 00000000..28d787a2 --- /dev/null +++ b/unilabos/config/config.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python +# coding=utf-8 +# 定义配置变量和加载函数 +import traceback +import os +import importlib.util +from unilabos.utils import logger + + +class BasicConfig: + ENV = "pro" # 'test' + config_path = "" + is_host_mode = True # 从registry.py移动过来 + slave_no_host = False # 是否跳过rclient.wait_for_service() + + +# MQTT配置 +class MQConfig: + lab_id = "" + instance_id = "" + access_key = "" + secret_key = "" + group_id = "" + broker_url = "" + port = 1883 + ca_content = "" + cert_content = "" + key_content = "" + + # 指定 + ca_file = "" + cert_file = "" + key_file = "" + + +# OSS上传配置 +class OSSUploadConfig: + api_host = "" + authorization = "" + init_endpoint = "" + complete_endpoint = "" + max_retries = 3 + + +# HTTP配置 +class HTTPConfig: + remote_addr = "http://127.0.0.1:48197/api/v1" + + +# ROS配置 +class ROSConfig: + modules = [ + "std_msgs.msg", + "geometry_msgs.msg", + "control_msgs.msg", + "control_msgs.action", + "nav2_msgs.action", + "unilabos_msgs.msg", + "unilabos_msgs.action", + ] + + +def _update_config_from_module(module): + for name, obj in globals().items(): + if isinstance(obj, type) and name.endswith("Config"): + if hasattr(module, name) and isinstance(getattr(module, name), type): + for attr in dir(getattr(module, name)): + if not attr.startswith("_"): + setattr(obj, attr, getattr(getattr(module, name), attr)) + # 更新OSS认证 + if len(OSSUploadConfig.authorization) == 0: + OSSUploadConfig.authorization = f"lab {MQConfig.lab_id}" + # 对 ca_file cert_file key_file 进行初始化 + if len(MQConfig.ca_content) == 0: + # 需要先判断是否为相对路径 + if MQConfig.ca_file.startswith("."): + MQConfig.ca_file = os.path.join(BasicConfig.config_path, MQConfig.ca_file) + with open(MQConfig.ca_file, "r", encoding="utf-8") as f: + MQConfig.ca_content = f.read() + if len(MQConfig.cert_content) == 0: + # 需要先判断是否为相对路径 + if MQConfig.cert_file.startswith("."): + MQConfig.cert_file = os.path.join(BasicConfig.config_path, MQConfig.cert_file) + with open(MQConfig.cert_file, "r", encoding="utf-8") as f: + MQConfig.cert_content = f.read() + if len(MQConfig.key_content) == 0: + # 需要先判断是否为相对路径 + if MQConfig.key_file.startswith("."): + MQConfig.key_file = os.path.join(BasicConfig.config_path, MQConfig.key_file) + with open(MQConfig.key_file, "r", encoding="utf-8") as f: + MQConfig.key_content = f.read() + + +def load_config(config_path=None): + # 如果提供了配置文件路径,从该文件导入配置 + if config_path: + BasicConfig.config_path = os.path.abspath(os.path.dirname(config_path)) + if not os.path.exists(config_path): + logger.error(f"配置文件 {config_path} 不存在") + return + + try: + module_name = "lab_" + os.path.basename(config_path).replace(".py", "") + spec = importlib.util.spec_from_file_location(module_name, config_path) + if spec is None: + logger.error(f"配置文件 {config_path} 错误") + return + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) # type: ignore + _update_config_from_module(module) + logger.info(f"配置文件 {config_path} 加载成功") + except Exception as e: + logger.error(f"加载配置文件 {config_path} 失败: {e}") + traceback.print_exc() + exit(1) + else: + try: + import unilabos.config.local_config as local_config # type: ignore + + _update_config_from_module(local_config) + logger.info("已加载默认配置 unilabos.config.local_config") + except ImportError: + pass diff --git a/unilabos/controllers/__init__.py b/unilabos/controllers/__init__.py new file mode 100644 index 00000000..db4ee185 --- /dev/null +++ b/unilabos/controllers/__init__.py @@ -0,0 +1 @@ +from .eis_model import EISModelBasedController \ No newline at end of file diff --git a/unilabos/controllers/eis_model.py b/unilabos/controllers/eis_model.py new file mode 100644 index 00000000..0f3381f2 --- /dev/null +++ b/unilabos/controllers/eis_model.py @@ -0,0 +1,5 @@ +import numpy as np + + +def EISModelBasedController(eis: np.array) -> float: + return 0.0 diff --git a/unilabos/device_comms/__init__.py b/unilabos/device_comms/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/device_comms/modbus_plc/__init__.py b/unilabos/device_comms/modbus_plc/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/device_comms/modbus_plc/client.py b/unilabos/device_comms/modbus_plc/client.py new file mode 100644 index 00000000..a7da3aff --- /dev/null +++ b/unilabos/device_comms/modbus_plc/client.py @@ -0,0 +1,537 @@ +import json +import time +import traceback +from typing import Any, Union, List, Dict, Callable, Optional, Tuple +from pydantic import BaseModel + +from pymodbus.client import ModbusSerialClient, ModbusTcpClient +from pymodbus.framer import FramerType +from typing import TypedDict + +from unilabos.device_comms.modbus_plc.node.modbus import DeviceType, HoldRegister, Coil, InputRegister, DiscreteInputs, DataType, WorderOrder +from unilabos.device_comms.modbus_plc.node.modbus import Base as ModbusNodeBase +from unilabos.device_comms.universal_driver import UniversalDriver +from unilabos.utils.log import logger +import pandas as pd + + +class ModbusNode(BaseModel): + name: str + device_type: DeviceType + address: int + data_type: DataType = DataType.INT16 + slave: int = 1 + + +class PLCWorkflow(BaseModel): + name: str + actions: List[ + Union[ + "PLCWorkflow", + Callable[ + [Callable[[str], ModbusNodeBase]], + None + ]] + ] + +class Action(BaseModel): + name: str + rw: bool # read是0 write是1 + +class WorkflowAction(BaseModel): + init: Optional[Callable[[Callable[[str], ModbusNodeBase]], bool]] = None + start: Optional[Callable[[Callable[[str], ModbusNodeBase]], bool]] = None + stop: Optional[Callable[[Callable[[str], ModbusNodeBase]], bool]] = None + cleanup: Optional[Callable[[Callable[[str], ModbusNodeBase]], None]] = None + + +class ModbusWorkflow(BaseModel): + name: str + actions: List[Union["ModbusWorkflow", WorkflowAction]] + + +""" 前后端Json解析用 """ +class AddressFunctionJson(TypedDict): + func_name: str + node_name: str + mode: str + value: Any + +class InitFunctionJson(AddressFunctionJson): + pass + +class StartFunctionJson(AddressFunctionJson): + pass + +class StopFunctionJson(AddressFunctionJson): + pass + +class CleanupFunctionJson(AddressFunctionJson): + pass + +class ActionJson(TypedDict): + address_function_to_create: list[AddressFunctionJson] + create_init_function: Optional[InitFunctionJson] + create_start_function: Optional[StartFunctionJson] + create_stop_function: Optional[StopFunctionJson] + create_cleanup_function: Optional[CleanupFunctionJson] + +class WorkflowCreateJson(TypedDict): + name: str + action: list[Union[ActionJson, 'WorkflowCreateJson'] | str] + +class ExecuteProcedureJson(TypedDict): + register_node_list_from_csv_path: Optional[dict[str, Any]] + create_flow: list[WorkflowCreateJson] + execute_flow: list[str] + + + +class BaseClient(UniversalDriver): + client: Optional[Union[ModbusSerialClient, ModbusTcpClient]] = None + _node_registry: Dict[str, ModbusNodeBase] = {} + DEFAULT_ADDRESS_PATH = "" + + def __init__(self): + super().__init__() + # self.register_node_list_from_csv_path() + + def _set_client(self, client: Optional[Union[ModbusSerialClient, ModbusTcpClient]]) -> None: + if client is None: + raise ValueError('client is not valid') + # if not isinstance(client, TCPClient ) or not isinstance(client, RTUClient): + # raise ValueError('client is not valid') + self.client = client + + def _connect(self) -> None: + logger.info('try to connect client...') + if self.client: + if self.client.connect(): + logger.info('client connected!') + else: + logger.error('client connect failed') + else: + raise ValueError('client is not initialized') + + @classmethod + def load_csv(cls, file_path: str): + df = pd.read_csv(file_path) + df = df.drop_duplicates(subset='Name', keep='first') # FIXME: 重复的数据应该报错 + data_dict = df.set_index('Name').to_dict(orient='index') + nodes = [] + for k, v in data_dict.items(): + deviceType = v.get('DeviceType', None) + addr = v.get('Address', 0) + dataType = v.get('DataType', 'BOOL') + if not deviceType or not addr: + continue + + if deviceType == DeviceType.COIL.value: + byteAddr = int(addr / 10) + bitAddr = addr % 10 + addr = byteAddr * 8 + bitAddr + + if dataType == 'BOOL': + # noinspection PyTypeChecker + dataType = 'INT16' + # noinspection PyTypeChecker + if pd.isna(dataType): + print(v, "没有注册成功!") + continue + dataType: DataType = DataType[dataType] + nodes.append(ModbusNode(name=k, device_type=DeviceType(deviceType), address=addr, data_type=dataType)) + return nodes + + def use_node(self, name: str) -> ModbusNodeBase: + if name not in self._node_registry: + raise ValueError(f'node {name} is not registered') + + return self._node_registry[name] + + def get_node_registry(self) -> Dict[str, ModbusNodeBase]: + return self._node_registry + + def register_node_list_from_csv_path(self, path: str = None) -> "BaseClient": + if path is None: + path = self.DEFAULT_ADDRESS_PATH + nodes = self.load_csv(path) + return self.register_node_list(nodes) + + def register_node_list(self, node_list: List[ModbusNode]) -> "BaseClient": + if not self.client: + raise ValueError('client is not connected') + + if not node_list or len(node_list) == 0: + logger.warning('node list is empty') + return self + + logger.info(f'start to register {len(node_list)} nodes...') + for node in node_list: + if node is None: + continue + if node.name in self._node_registry: + logger.info(f'node {node.name} already exists') + exist = self._node_registry[node.name] + if exist.type != node.device_type: + raise ValueError(f'node {node.name} type {node.device_type} is diplicated with {exist.type}') + if exist.address != node.address: + raise ValueError(f'node {node.name} address is duplicated with {exist.address}') + continue + if not isinstance(node.device_type, DeviceType): + raise ValueError(f'node {node.name} type is not valid') + + if node.device_type == DeviceType.HOLD_REGISTER: + self._node_registry[node.name] = HoldRegister(self.client, node.name, node.address, node.data_type) + elif node.device_type == DeviceType.COIL: + self._node_registry[node.name] = Coil(self.client, node.name, node.address, node.data_type) + elif node.device_type == DeviceType.INPUT_REGISTER: + self._node_registry[node.name] = InputRegister(self.client, node.name, node.address, node.data_type) + elif node.device_type == DeviceType.DISCRETE_INPUTS: + self._node_registry[node.name] = DiscreteInputs(self.client, node.name, node.address, node.data_type) + else: + raise ValueError(f'node {node.name} type {node.device_type} is not valid') + + logger.info('register nodes done.') + return self + + def run_plc_workflow(self, workflow: PLCWorkflow) -> None: + if not self.client: + raise ValueError('client is not connected') + + logger.info(f'start to run workflow {workflow.name}...') + + for action in workflow.actions: + if isinstance(action, PLCWorkflow): + self.run_plc_workflow(action) + elif isinstance(action, Callable): + action(self.use_node) + else: + raise ValueError(f'invalid action {action}') + + def call_lifecycle_fn( + self, + workflow: ModbusWorkflow, + fn: Optional[Callable[[Callable], bool]], + ) -> bool: + if not fn: + raise ValueError('fn is not valid in call_lifecycle_fn') + try: + return fn(self.use_node) + except Exception as e: + traceback.print_exc() + logger.error(f'execute {workflow.name} lifecycle failed, err: {e}') + return False + + def run_modbus_workflow(self, workflow: ModbusWorkflow) -> bool: + if not self.client: + raise ValueError('client is not connected') + + logger.info(f'start to run workflow {workflow.name}...') + + for action in workflow.actions: + if isinstance(action, ModbusWorkflow): + if self.run_modbus_workflow(action): + logger.info(f"{action.name} workflow done.") + continue + else: + logger.error(f"{action.name} workflow failed") + return False + elif isinstance(action, WorkflowAction): + init = action.init + start = action.start + stop = action.stop + cleanup = action.cleanup + if not init and not start and not stop: + raise ValueError(f'invalid action {action}') + + is_err = False + try: + if init and not self.call_lifecycle_fn(workflow, init): + raise ValueError(f"{workflow.name} init action failed") + if not self.call_lifecycle_fn(workflow, start): + raise ValueError(f"{workflow.name} start action failed") + if not self.call_lifecycle_fn(workflow, stop): + raise ValueError(f"{workflow.name} stop action failed") + logger.info(f"{workflow.name} action done.") + except Exception as e: + is_err = True + traceback.print_exc() + logger.error(f"{workflow.name} action failed, err: {e}") + finally: + logger.info(f"{workflow.name} try to run cleanup") + if cleanup: + self.call_lifecycle_fn(workflow, cleanup) + else: + logger.info(f"{workflow.name} cleanup is not defined") + if is_err: + return False + return True + else: + raise ValueError(f'invalid action type {type(action)}') + + return True + + function_name: dict[str, Callable[[Callable[[str], ModbusNodeBase]], bool]] = {} + + @classmethod + def pack_func(cls, func, value="UNDEFINED"): + def execute_pack_func(use_node: Callable[[str], ModbusNodeBase]): + if value == "UNDEFINED": + func() + else: + func(use_node, value) + return execute_pack_func + + def create_address_function(self, func_name: str = None, node_name: str = None, mode: str = None, value: Any = None, data_type: Optional[DataType] = None, word_order: WorderOrder = None, slave: Optional[int] = None) -> Callable[[Callable[[str], ModbusNodeBase]], bool]: + def execute_address_function(use_node: Callable[[str], ModbusNodeBase]) -> Union[bool, Tuple[Union[int, float, str, list[bool], list[int], list[float]], bool]]: + param = {"value": value} + if data_type is not None: + param["data_type"] = data_type + if word_order is not None: + param["word_order"] = word_order + if slave is not None: + param["slave"] = slave + target_node = use_node(node_name) + print("执行", node_name, type(target_node).__name__, target_node.address, mode, value) + if mode == 'read': + return use_node(node_name).read(**param) + elif mode == 'write': + return not use_node(node_name).write(**param) + return False + if func_name is None: + func_name = node_name + '_' + mode + '_' + str(value) + print("创建 address function", mode, func_name) + self.function_name[func_name] = execute_address_function + return execute_address_function + + def create_init_function(self, func_name: str = None, node_name: str = None, mode: str = None, value: Any = None, data_type: Optional[DataType] = None, word_order: WorderOrder = None, slave: Optional[int] = None): + return self.create_address_function(func_name, node_name, mode, value, data_type, word_order, slave) + + def create_stop_function(self, func_name: str = None, node_name: str = None, mode: str = None, value: Any = None, data_type: Optional[DataType] = None, word_order: WorderOrder = None, slave: Optional[int] = None): + return self.create_address_function(func_name, node_name, mode, value, data_type, word_order, slave) + + def create_cleanup_function(self, func_name: str = None, node_name: str = None, mode: str = None, value: Any = None, data_type: Optional[DataType] = None, word_order: WorderOrder = None, slave: Optional[int] = None): + return self.create_address_function(func_name, node_name, mode, value, data_type, word_order, slave) + + def create_start_function(self, func_name: str, write_functions: list[str], condition_functions: list[str], stop_condition_expression: str): + def execute_start_function(use_node: Callable[[str], ModbusNodeBase]) -> bool: + for write_function in write_functions: + self.function_name[write_function](use_node) + while True: + next_loop = False + condition_source = {} + for condition_function in condition_functions: + read_res, read_err = self.function_name[condition_function](use_node) + if read_err: + next_loop = True + break + condition_source[condition_function] = read_res + if not next_loop: + if stop_condition_expression: + condition_source["__RESULT"] = None + exec(f"__RESULT = {stop_condition_expression}", {}, condition_source) # todo: safety check + res = condition_source["__RESULT"] + print("取得计算结果;", res) + if res: + break + else: + time.sleep(0.3) + return True + return execute_start_function + + def create_action_from_json(self, data: ActionJson): + for i in data["address_function_to_create"]: + self.create_address_function(**i) + init = None + start = None + stop = None + cleanup = None + if data["create_init_function"]: + print("创建 init function") + init = self.create_init_function(**data["create_init_function"]) + if data["create_start_function"]: + print("创建 start function") + start = self.create_start_function(**data["create_start_function"]) + if data["create_stop_function"]: + print("创建 stop function") + stop = self.create_stop_function(**data["create_stop_function"]) + if data["create_cleanup_function"]: + print("创建 cleanup function") + cleanup = self.create_cleanup_function(**data["create_cleanup_function"]) + return WorkflowAction(init=init, start=start, stop=stop, cleanup=cleanup) + + workflow_name = {} + + def create_workflow_from_json(self, data: list[WorkflowCreateJson]): + for ind, flow in enumerate(data): + print("正在创建 workflow", ind, flow["name"]) + actions = [] + for i in flow["action"]: + if isinstance(i, str): + print("沿用 已有workflow 作为action", i) + action = self.workflow_name[i] + else: + print("创建 action") + action = self.create_action_from_json(i) + actions.append(action) + flow_instance = ModbusWorkflow(name=flow["name"], actions=actions) + print("创建完成 workflow", flow["name"]) + self.workflow_name[flow["name"]] = flow_instance + + def execute_workflow_from_json(self, data: list[str]): + for i in data: + print("正在执行 workflow", i) + self.run_modbus_workflow(self.workflow_name[i]) + + def execute_procedure_from_json(self, data: ExecuteProcedureJson): + if data["register_node_list_from_csv_path"]: + print("注册节点 csv", data["register_node_list_from_csv_path"]) + self.register_node_list_from_csv_path(**data["register_node_list_from_csv_path"]) + print("创建工作流") + self.create_workflow_from_json(data["create_flow"]) + print("执行工作流") + self.execute_workflow_from_json(data["execute_flow"]) + + +class TCPClient(BaseClient): + def __init__(self, addr: str, port: int): + super().__init__() + self._set_client(ModbusTcpClient(host=addr, port=port)) + # self._connect() + + +class RTUClient(BaseClient): + def __init__(self, port: str, baudrate: int, timeout: int): + super().__init__() + self._set_client(ModbusSerialClient(framer=FramerType.RTU, port=port, baudrate=baudrate, timeout=timeout)) + self._connect() + +if __name__ == '__main__': + """ 代码写法① """ + def idel_init(use_node: Callable[[str], ModbusNodeBase]) -> bool: + # 修改速度 + use_node('M01_idlepos_velocity_rw').write(20.0) + # 修改位置 + # use_node('M01_idlepos_position_rw').write(35.22) + return True + + def idel_position(use_node: Callable[[str], ModbusNodeBase]) -> bool: + use_node('M01_idlepos_coil_w').write(True) + while True: + pos_idel, idel_err = use_node('M01_idlepos_coil_r').read(1) + pos_stop, stop_err = use_node('M01_manual_stop_coil_r').read(1) + time.sleep(0.5) + if not idel_err and not stop_err and pos_idel[0] and pos_stop[0]: + break + + return True + + def idel_stop(use_node: Callable[[str], ModbusNodeBase]) -> bool: + use_node('M01_idlepos_coil_w').write(False) + return True + + move_idel = ModbusWorkflow(name="测试待机位置", actions=[WorkflowAction( + init=idel_init, + start=idel_position, + stop=idel_stop, + )]) + + def pipetter_init(use_node: Callable[[str], ModbusNodeBase]) -> bool: + # 修改速度 + # use_node('M01_idlepos_velocity_rw').write(10.0) + # 修改位置 + # use_node('M01_idlepos_position_rw').write(35.22) + return True + + def pipetter_position(use_node: Callable[[str], ModbusNodeBase]) -> bool: + use_node('M01_pipette0_coil_w').write(True) + while True: + pos_idel, isError = use_node('M01_pipette0_coil_r').read(1) + pos_stop, isError = use_node('M01_manual_stop_coil_r').read(1) + time.sleep(0.5) + if pos_idel[0] and pos_stop[0]: + break + + return True + + def pipetter_stop(use_node: Callable[[str], ModbusNodeBase]) -> bool: + use_node('M01_pipette0_coil_w').write(False) + return True + + move_pipetter = ModbusWorkflow(name="测试待机位置", actions=[WorkflowAction( + init=None, + start=pipetter_position, + stop=pipetter_stop, + )]) + + workflow_test_2 = ModbusWorkflow(name="测试水平移动并停止", actions=[ + move_idel, + move_pipetter, + ]) + + # .run_modbus_workflow(move_2_left_workflow) + + """ 代码写法② """ + # if False: + # modbus_tcp_client_test2 = TCPClient('192.168.3.2', 502) + # modbus_tcp_client_test2.register_node_list_from_csv_path('M01.csv') + # init = modbus_tcp_client_test2.create_init_function('idel_init', 'M01_idlepos_velocity_rw', 'write', 20.0) + # + # modbus_tcp_client_test2.create_address_function('pos_tip', 'M01_idlepos_coil_w', 'write', True) + # modbus_tcp_client_test2.create_address_function('pos_tip_read', 'M01_idlepos_coil_r', 'read', 1) + # modbus_tcp_client_test2.create_address_function('manual_stop', 'M01_manual_stop_coil_r', 'read', 1) + # start = modbus_tcp_client_test2.create_start_function( + # 'idel_position', + # write_functions=[ + # 'pos_tip' + # ], + # condition_functions=[ + # 'pos_tip_read', + # 'manual_stop' + # ], + # stop_condition_expression='pos_tip_read[0] and manual_stop[0]' + # ) + # stop = modbus_tcp_client_test2.create_stop_function('idel_stop', 'M01_idlepos_coil_w', 'write', False) + # + # move_idel = ModbusWorkflow(name="归位", actions=[WorkflowAction( + # init=init, + # start=start, + # stop=stop, + # )]) + # + # modbus_tcp_client_test2.create_address_function('pipetter_position', 'M01_pipette0_coil_w', 'write', True) + # modbus_tcp_client_test2.create_address_function('pipetter_position_read', 'M01_pipette0_coil_r', 'read', 1) + # modbus_tcp_client_test2.create_address_function('pipetter_stop_read', 'M01_manual_stop_coil_r', 'read', 1) + # pipetter_position = modbus_tcp_client_test2.create_start_function( + # 'pipetter_start', + # write_functions=[ + # 'pipetter_position' + # ], + # condition_functions=[ + # 'pipetter_position_read', + # 'pipetter_stop_read' + # ], + # stop_condition_expression='pipetter_position[0] and pipetter_stop_read[0]' + # ) + # pipetter_stop = modbus_tcp_client_test2.create_stop_function('pipetter_stop', 'M01_pipette0_coil_w', 'write', False) + # + # move_pipetter = ModbusWorkflow(name="测试待机位置", actions=[WorkflowAction( + # init=None, + # start=pipetter_position, + # stop=pipetter_stop, + # )]) + # + # workflow_test_2 = ModbusWorkflow(name="测试水平移动并停止", actions=[ + # move_idel, + # move_pipetter, + # ]) + # + # workflow_test_2.run_modbus_workflow() + + """ 代码写法③ """ + with open('example_json.json', 'r', encoding='utf-8') as f: + example_json = json.load(f) + modbus_tcp_client_test2 = TCPClient('127.0.0.1', 5021) + modbus_tcp_client_test2.execute_procedure_from_json(example_json) + # .run_modbus_workflow(move_2_left_workflow) +# init_client(FramerType.SOCKET, "", '192.168.3.2', 502) diff --git a/unilabos/device_comms/modbus_plc/example_json.json b/unilabos/device_comms/modbus_plc/example_json.json new file mode 100644 index 00000000..92999a0f --- /dev/null +++ b/unilabos/device_comms/modbus_plc/example_json.json @@ -0,0 +1,104 @@ +{ + "register_node_list_from_csv_path": { + "path": "M01.csv" + }, + "create_flow": [ + { + "name": "归位", + "action": [ + { + "address_function_to_create": [ + { + "func_name": "pos_tip", + "node_name": "M01_idlepos_coil_w", + "mode": "write", + "value": true + }, + { + "func_name": "pos_tip_read", + "node_name": "M01_idlepos_coil_r", + "mode": "read", + "value": 1 + }, + { + "func_name": "manual_stop", + "node_name": "M01_manual_stop_coil_r", + "mode": "read", + "value": 1 + } + ], + "create_init_function": { + "func_name": "idel_init", + "node_name": "M01_idlepos_velocity_rw", + "mode": "write", + "value": 20.0 + }, + "create_start_function": { + "func_name": "idel_position", + "write_functions": ["pos_tip"], + "condition_functions": ["pos_tip_read", "manual_stop"], + "stop_condition_expression": "pos_tip_read[0] and manual_stop[0]" + }, + "create_stop_function": { + "func_name": "idel_stop", + "node_name": "M01_idlepos_coil_w", + "mode": "write", + "value": false + }, + "create_cleanup_function": null + } + ] + }, + { + "name": "测试待机位置", + "action": [ + { + "address_function_to_create": [ + { + "func_name": "pipetter_position", + "node_name": "M01_pipette0_coil_w", + "mode": "write", + "value": true + }, + { + "func_name": "pipetter_position_read", + "node_name": "M01_pipette0_coil_r", + "mode": "read", + "value": 1 + }, + { + "func_name": "pipetter_stop_read", + "node_name": "M01_manual_stop_coil_r", + "mode": "read", + "value": 1 + } + ], + "create_init_function": null, + "create_start_function": { + "func_name": "pipetter_start", + "write_functions": ["pipetter_position"], + "condition_functions": ["pipetter_position_read", "pipetter_stop_read"], + "stop_condition_expression": "pipetter_position_read[0] and pipetter_stop_read[0]" + }, + "create_stop_function": { + "func_name": "pipetter_stop", + "node_name": "M01_pipette0_coil_w", + "mode": "write", + "value": false + }, + "create_cleanup_function": null + } + ] + }, + { + "name": "归位并测试待机位置", + "action": [ + "归位", + "测试待机位置" + ] + } + ], + "execute_flow": [ + "归位并测试待机位置" + ] +} diff --git a/unilabos/device_comms/modbus_plc/node/__init__.py b/unilabos/device_comms/modbus_plc/node/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/device_comms/modbus_plc/node/modbus.py b/unilabos/device_comms/modbus_plc/node/modbus.py new file mode 100644 index 00000000..028477ec --- /dev/null +++ b/unilabos/device_comms/modbus_plc/node/modbus.py @@ -0,0 +1,161 @@ +# coding=utf-8 +from enum import Enum +from abc import ABC, abstractmethod + +from pymodbus.client import ModbusBaseSyncClient +from pymodbus.client.mixin import ModbusClientMixin +from typing import Tuple, Union, Optional + +DataType = ModbusClientMixin.DATATYPE + +class WorderOrder(Enum): + BIG = "big" + LITTLE = "little" + +class DeviceType(Enum): + COIL = 'coil' + DISCRETE_INPUTS = 'discrete_inputs' + HOLD_REGISTER = 'hold_register' + INPUT_REGISTER = 'input_register' + + +class Base(ABC): + def __init__(self, client: ModbusBaseSyncClient, name: str, address: int, typ: DeviceType, data_type: DataType): + self._address: int = address + self._client = client + self._name = name + self._type = typ + self._data_type = data_type + + @abstractmethod + def read(self, value, data_type: Optional[DataType] = None, word_order: WorderOrder = WorderOrder.BIG, slave = 1,) -> Tuple[Union[int, float, str, list[bool], list[int], list[float]], bool]: + pass + + @abstractmethod + def write(self, value: Union[int, float, bool, str, list[bool], list[int], list[float]], data_type: Optional[DataType]= None, word_order: WorderOrder = WorderOrder.LITTLE, slave = 1) -> bool: + pass + + @property + def type(self) -> DeviceType: + return self._type + + @property + def address(self) -> int: + return self._address + + @property + def name(self) -> str: + return self._name + + +class Coil(Base): + def __init__(self, client,name, address: int, data_type: DataType): + super().__init__(client, name, address, DeviceType.COIL, data_type) + + def read(self, value, data_type: Optional[DataType] = None, word_order: WorderOrder = WorderOrder.BIG, slave = 1,) -> Tuple[Union[int, float, str, list[bool], list[int], list[float]], bool]: + resp = self._client.read_coils( + address = self.address, + count = value, + slave = slave) + + return resp.bits, resp.isError() + + def write(self,value: Union[int, float, bool, str, list[bool], list[int], list[float]], data_type: Optional[DataType ]= None, word_order: WorderOrder = WorderOrder.LITTLE, slave = 1) -> bool: + if isinstance(value, list): + for v in value: + if not isinstance(v, bool): + raise ValueError(f'value invalidate: {value}') + + return self._client.write_coils( + address = self.address, + values = [bool(v) for v in value], + slave = slave).isError() + else: + return self._client.write_coil( + address = self.address, + value = bool(value), + slave = slave).isError() + + +class DiscreteInputs(Base): + def __init__(self, client,name, address: int, data_type: DataType): + super().__init__(client, name, address, DeviceType.COIL, data_type) + + def read(self, value, data_type: Optional[DataType] = None, word_order: WorderOrder = WorderOrder.BIG, slave = 1,) -> Tuple[Union[int, float, str, list[bool], list[int], list[float]], bool]: + if not data_type and not self._data_type: + raise ValueError('data type is required') + if not data_type: + data_type = self._data_type + resp = self._client.read_discrete_inputs( + self.address, + count = value, + slave = slave) + + # noinspection PyTypeChecker + return self._client.convert_from_registers(resp.registers, data_type, word_order=word_order.value), resp.isError() + + def write(self,value: Union[int, float, bool, str, list[bool], list[int], list[float]], data_type: Optional[DataType ]= None, word_order: WorderOrder = WorderOrder.LITTLE, slave = 1) -> bool: + raise ValueError('discrete inputs only support read') + +class HoldRegister(Base): + def __init__(self, client,name, address: int, data_type: DataType): + super().__init__(client, name, address, DeviceType.COIL, data_type) + + def read(self, value, data_type: Optional[DataType] = None, word_order: WorderOrder = WorderOrder.BIG, slave = 1,) -> Tuple[Union[int, float, str, list[bool], list[int], list[float]], bool]: + if not data_type and not self._data_type: + raise ValueError('data type is required') + + if not data_type: + data_type = self._data_type + + resp = self._client.read_holding_registers( + address = self.address, + count = value, + slave = slave) + # noinspection PyTypeChecker + return self._client.convert_from_registers(resp.registers, data_type, word_order=word_order.value), resp.isError() + + + def write(self,value: Union[int, float, bool, str, list[bool], list[int], list[float]], data_type: Optional[DataType ]= None, word_order: WorderOrder = WorderOrder.LITTLE, slave = 1) -> bool: + if not data_type and not self._data_type: + raise ValueError('data type is required') + + if not data_type: + data_type = self._data_type + + if isinstance(value , bool): + if value: + return self._client.write_register(self.address, 1, slave= slave).isError() + else: + return self._client.write_register(self.address, 0, slave= slave).isError() + elif isinstance(value, int): + return self._client.write_register(self.address, value, slave= slave).isError() + else: + # noinspection PyTypeChecker + encoder_resp = self._client.convert_to_registers(value, data_type=data_type, word_order=word_order.value) + return self._client.write_registers(self.address, encoder_resp, slave=slave).isError() + + + +class InputRegister(Base): + def __init__(self, client,name, address: int, data_type: DataType): + super().__init__(client, name, address, DeviceType.COIL, data_type) + + + def read(self, value, data_type: Optional[DataType] = None, word_order: WorderOrder = WorderOrder.BIG, slave = 1) -> Tuple[Union[int, float, str, list[bool], list[int], list[float]], bool]: + if not data_type and not self._data_type: + raise ValueError('data type is required') + + if not data_type: + data_type = self._data_type + + resp = self._client.read_holding_registers( + address = self.address, + count = value, + slave = slave) + # noinspection PyTypeChecker + return self._client.convert_from_registers(resp.registers, data_type, word_order=word_order.value), resp.isError() + + def write(self,value: Union[int, float, bool, str, list[bool], list[int], list[float]], data_type: Optional[DataType ]= None, word_order: WorderOrder = WorderOrder.LITTLE, slave = 1) -> bool: + raise ValueError('input register only support read') + diff --git a/unilabos/device_comms/modbus_plc/server.py b/unilabos/device_comms/modbus_plc/server.py new file mode 100644 index 00000000..869e80c7 --- /dev/null +++ b/unilabos/device_comms/modbus_plc/server.py @@ -0,0 +1,37 @@ +import modbus_tk.defines as cst +import modbus_tk.modbus_tcp as modbus_tcp + +# 创建一个 Modbus TCP 服务器 +server = modbus_tcp.TcpServer( + address="127.0.0.1", port=5021, timeout_in_sec=1 +) + +# 添加一个从设备 (slave) +slave_id = 1 +slave = server.add_slave(slave_id) + +# 为从设备分配地址空间,例如保持寄存器 (holding registers) +# 假设地址范围为 7000 到 7100,对应客户端M01_idlepos_velocity_rw +slave.add_block('hr', cst.HOLDING_REGISTERS, 7000, 100) +slave.add_block('coil_block', cst.COILS, 56000, 1000) + + +# 初始化地址 56488 和 56432 的值为 True +slave.set_values('coil_block', 56488, [True]) # Coil 56488 设置为 True +slave.set_values('coil_block', 56432, [True]) # Coil 56432 设置为 True + +slave.set_values('coil_block', 56496, [True]) # Coil 56488 设置为 True +slave.set_values('coil_block', 56432, [True]) # Coil 56432 设置为 True + + +# slave.add_block('hr', cst.COILS, 7000, 100) +server.start() +print("Modbus TCP server running on localhost:5021") + +# 保持服务器运行,直到按下 Ctrl+C +try: + while True: + pass +except KeyboardInterrupt: + server.stop() + print("Server stopped.") diff --git a/unilabos/device_comms/modbus_plc/test/__init__.py b/unilabos/device_comms/modbus_plc/test/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/device_comms/modbus_plc/test/client.py b/unilabos/device_comms/modbus_plc/test/client.py new file mode 100644 index 00000000..070e180b --- /dev/null +++ b/unilabos/device_comms/modbus_plc/test/client.py @@ -0,0 +1,107 @@ +import time +from pymodbus.client import ModbusTcpClient +from unilabos.device_comms.modbus_plc.node.modbus import Coil, HoldRegister +from pymodbus.payload import BinaryPayloadDecoder +from pymodbus.constants import Endian + +# client = ModbusTcpClient('localhost', port=5020) +client = ModbusTcpClient('192.168.3.2', port=502) + +client.connect() + + +coil1 = Coil(client=client, name='coil_test1', data_type=bool, address=4502*8) +coil1.write(True) +time.sleep(3) +coil1.write(False) + + + +coil1 = Coil(client=client, name='coil_test1', data_type=bool, address=4503*8) +coil1.write(True) +time.sleep(3) +coil1.write(False) + + +exit(0) + + + +register1 = HoldRegister(client=client, name="test-1", address=7040) + + +coil1 = Coil(client=client, name='coil_test1', address=7002*8) + + +coil1.write(True) + +while True: + # result = client.read_holding_registers(address=7040, count=2, slave=1) # unit=1 是从站地址 + result = register1.read(2, slave=1) + if result.isError(): + print("读取失败") + else: + print("读取成功:", result.registers) + decoder = BinaryPayloadDecoder.fromRegisters( + result.registers, byteorder=Endian.BIG, wordorder=Endian.LITTLE + ) + real_value = decoder.decode_32bit_float() + print("这里的值是: ", real_value) + if real_value > 42: + coil1.write(False) + break + time.sleep(1) + + +# # 创建 Modbus TCP 客户端,连接到本地模拟的服务器 +# client = ModbusClient('localhost', port=5020) + +# # 连接到服务器 + +# # 读取保持寄存器(地址 0,读取 10 个寄存器) +# # address: int, +# # *, +# # count: int = 1, +# # slave: int = 1, +# response = client.read_holding_registers( +# address=0, count=10, slave=1 +# ) + +# response = coil1.read(2, slave=1) +# +# if response.isError(): +# print(f"Error reading registers: {response}") +# else: +# print(f"Read holding registers: {response.bits}") +# +# coil1.write(1, slave=1) +# print("Wrote value 1234 to holding register 0") +# +# response = coil1.read(2, slave=1) +# if response.isError(): +# print(f"Error reading registers: {response}") +# else: +# print(f"Read holding registers after write: {response.bits}") +# +# +# if response.isError(): +# print(f"Error reading registers: {response}") +# else: + # print(f"Read holding registers after write: {response.bits}") + +client.close() + +# # 写入保持寄存器(地址 0,值为 1234) +# client.write_register(0, 1234, slave=1) +# print("Wrote value 1234 to holding register 0") + +# # 再次读取寄存器,确认写入成功 +# response = client.read_holding_registers(address=0, count=10, slave=1) +# if response.isError(): +# print(f"Error reading registers: {response}") +# else: +# print(f"Read holding registers after write: {response.registers}") + +# # 关闭连接 +# client.close() + diff --git a/unilabos/device_comms/modbus_plc/test/node_test.py b/unilabos/device_comms/modbus_plc/test/node_test.py new file mode 100644 index 00000000..d2fa2d75 --- /dev/null +++ b/unilabos/device_comms/modbus_plc/test/node_test.py @@ -0,0 +1,45 @@ +# coding=utf-8 +from pymodbus.client import ModbusTcpClient +from unilabos.device_comms.modbus_plc.node.modbus import Coil +import time + + +client = ModbusTcpClient('192.168.3.2', port=502) +client.connect() + +coil1 = Coil(client=client, name='0', address=7012*8) + +coil2 = Coil(client=client, name='0', address=7062*8) +coil3 = Coil(client=client, name='0', address=7054*8) + + +while True: + time.sleep(1) + resp, isError = coil2.read(1) + resp1, isError = coil3.read(1) + print(resp[0], resp1[0]) + + + + + +# hr = HoldRegister(client, '1', 100) +# resp = hr.write([666.3, 777.4], data_type=DATATYPE.FLOAT32, word_order=WORDORDER.BIG) +# print('write ===== hr1', resp) +# time.sleep(1) +# h_resp = hr.read(4, data_type=DATATYPE.FLOAT32, word_order=WORDORDER.BIG) +# print('=======hr1', h_resp) +# +# +# resp = hr.write([666, 777], data_type=DATATYPE.INT32, word_order=WORDORDER.BIG) +# print('write ===== hr1', resp) +# time.sleep(1) +# h_resp = hr.read(4, data_type=DATATYPE.INT32, word_order=WORDORDER.BIG) +# print('=======hr1', h_resp) +# +# +# resp = hr.write('hello world!', data_type=DATATYPE.STRING, word_order=WORDORDER.BIG) +# print('write ===== hr1', resp) +# time.sleep(1) +# h_resp = hr.read(12, data_type=DATATYPE.STRING, word_order=WORDORDER.BIG) +# print('=======hr1', h_resp) diff --git a/unilabos/device_comms/modbus_plc/test/server.py b/unilabos/device_comms/modbus_plc/test/server.py new file mode 100644 index 00000000..bda6d8eb --- /dev/null +++ b/unilabos/device_comms/modbus_plc/test/server.py @@ -0,0 +1,42 @@ +import modbus_tk.modbus_tcp as modbus_tcp +import modbus_tk.defines as cst +from modbus_tk.modbus import Slave + +# 创建一个 Modbus TCP 服务器 +server = modbus_tcp.TcpServer( + address="localhost", port=5020, timeout_in_sec=1 +) + +# 设置服务器的地址和端口 +# server.set_address("localhost", 5020) # 监听在本地端口5020 + +# 添加一个从设备,Slave ID 设为 1 +slave: Slave = server.add_slave(1) + + +# 向从设备添加一个保持寄存器块,假设从地址0开始,10个寄存器 +# def add_block(self, block_name, block_type, starting_address, size) +# slave.add_block('0', cst.HOLDING_REGISTERS, 0, 10) + +# 添加一个线圈 +# 0 名字, 从 16 字节内存位置开始,分配连续两个字节的内存大小,注意地址只能是 8 的整数倍 +slave.add_block('0', cst.COILS, 2*8, 2) + +# 1 名字,100 起始地址, 8 是从 100 的位置分配 8 个字节内存, 两个线圈的量 +slave.add_block('1', cst.HOLDING_REGISTERS, 100, 8) +slave.add_block('2', cst.HOLDING_REGISTERS, 200, 16) + +# slave.add_block('2', cst.DISCRETE_INPUTS , 200, 2) +# slave.add_block('3', cst.ANALOG_INPUTS , 300, 2) +# 启动服务器 +server.start() + +print("Modbus TCP server running on localhost:5020") + +# 保持服务器运行,直到按下 Ctrl+C +try: + while True: + pass +except KeyboardInterrupt: + server.stop() + print("Server stopped.") diff --git a/unilabos/device_comms/modbus_plc/test/test_workflow.py b/unilabos/device_comms/modbus_plc/test/test_workflow.py new file mode 100644 index 00000000..e418a3c5 --- /dev/null +++ b/unilabos/device_comms/modbus_plc/test/test_workflow.py @@ -0,0 +1,168 @@ +import time +from typing import Callable +from unilabos.device_comms.modbus_plc.client import TCPClient, ModbusWorkflow, WorkflowAction, load_csv +from unilabos.device_comms.modbus_plc.node.modbus import Base as ModbusNodeBase + +############ 第一种写法 ############## + + +# modbus_tcp_client_test1 = TCPClient('192.168.3.2', 502) +# +# +# node_list = [ +# ModbusNode(name="left_move_coli", device_type=DeviceType.COIL, address=7003 * 8), +# ModbusNode(name="right_move_coli", device_type=DeviceType.COIL, address=7002 * 8), +# ModbusNode(name="position_register", device_type=DeviceType.HOLD_REGISTER, address=7040), +# ] +# +# def judge_position(node: ModbusNodeBase): +# idx = 0 +# while idx <= 5: +# result, is_err = node.read(2) +# if is_err: +# print("读取失败") +# else: +# print("读取成功:", result) +# idx+=1 +# time.sleep(1) +# +# workflow_move_2_right = PLCWorkflow(name="测试水平向右移动", actions=[ +# lambda use_node: use_node('left_move_coli').write(False), +# lambda use_node: use_node('right_move_coli').write(True), +# lambda use_node: judge_position(use_node('position_register')), +# lambda use_node: use_node('right_move_coli').write(False), +# ]) +# +# +# workflow_move_2_left = PLCWorkflow(name="测试水平向左移动", actions=[ +# lambda use_node: use_node('right_move_coli').write(False), +# lambda use_node: use_node('left_move_coli').write(True), +# lambda use_node: judge_position(use_node('position_register')), +# lambda use_node: use_node('left_move_coli').write(False), +# ]) +# +# +# workflow_test_1 = PLCWorkflow(name="测试水平移动并停止", actions=[ +# workflow_move_2_right, +# workflow_move_2_left, +# ]) +# +# modbus_tcp_client_test1 \ +# .register_node_list(node_list) \ +# .run_plc_workflow(workflow_test_1) +# + + +############ 第二种写法 ############## + +modbus_tcp_client_test2 = TCPClient('192.168.3.2', 502) + +# def judge_position(node: ModbusNodeBase): +# idx = 0 +# while idx <= 5: +# result, is_err = node.read(2) +# if is_err: +# print("读取失败") +# else: +# print("读取成功:", result) +# idx+=1 +# time.sleep(1) + + + +# def move_2_right_init(use_node: Callable[[str], ModbusNodeBase]) -> bool: +# use_node('left_move_coli').write(False) +# use_node('right_move_coli').write(True) +# return True + +# def move_2_right_start(use_node: Callable[[str], ModbusNodeBase]) -> bool: +# judge_position(use_node('position_register')) +# return True + +# def move_2_right_stop(use_node: Callable[[str], ModbusNodeBase]) -> bool: +# use_node('right_move_coli').write(False) +# return True + +# move_2_right_workflow = ModbusWorkflow(name="测试水平向右移动", actions=[WorkflowAction( +# init=move_2_right_init, +# start=move_2_right_start, +# stop=move_2_right_stop, +# )]) + +# move_2_right_workflow = ModbusWorkflow(name="测试水平向右移动", actions=[WorkflowAction( +# init=move_2_right_init, +# start= None, +# stop= None, +# cleanup=None, +# )]) + + +def idel_init(use_node: Callable[[str], ModbusNodeBase]) -> bool: + # 修改速度 + use_node('M01_idlepos_velocity_rw').write(20.0) + # 修改位置 + # use_node('M01_idlepos_position_rw').write(35.22) + return True + +def idel_position(use_node: Callable[[str], ModbusNodeBase]) -> bool: + use_node('M01_idlepos_coil_w').write(True) + while True: + pos_idel, idel_err = use_node('M01_idlepos_coil_r').read(1) + pos_stop, stop_err = use_node('M01_manual_stop_coil_r').read(1) + time.sleep(0.5) + if not idel_err and not stop_err and pos_idel[0] and pos_stop[0]: + break + + return True + +def idel_stop(use_node: Callable[[str], ModbusNodeBase]) -> bool: + use_node('M01_idlepos_coil_w').write(False) + return True + +move_idel= ModbusWorkflow(name="测试待机位置", actions=[WorkflowAction( + init=idel_init, + start=idel_position, + stop=idel_stop, +)]) + +def pipetter_init(use_node: Callable[[str], ModbusNodeBase]) -> bool: + # 修改速度 + # use_node('M01_idlepos_velocity_rw').write(10.0) + # 修改位置 + # use_node('M01_idlepos_position_rw').write(35.22) + return True + +def pipetter_position(use_node: Callable[[str], ModbusNodeBase]) -> bool: + use_node('M01_pipette0_coil_w').write(True) + while True: + pos_idel, isError = use_node('M01_pipette0_coil_r').read(1) + pos_stop, isError = use_node('M01_manual_stop_coil_r').read(1) + time.sleep(0.5) + if pos_idel[0] and pos_stop[0]: + break + + return True + +def pipetter_stop(use_node: Callable[[str], ModbusNodeBase]) -> bool: + use_node('M01_pipette0_coil_w').write(False) + return True + +move_pipetter= ModbusWorkflow(name="测试待机位置", actions=[WorkflowAction( + init=None, + start=pipetter_position, + stop=pipetter_stop, +)]) + + + +workflow_test_2 = ModbusWorkflow(name="测试水平移动并停止", actions=[ + move_idel, + move_pipetter, +]) + +nodes = load_csv('/Users/dingshinn/Desktop/lbg/uni-lab/M01.csv') + +modbus_tcp_client_test2 \ + .register_node_list(nodes) \ + .run_modbus_workflow(workflow_test_2) + # .run_modbus_workflow(move_2_left_workflow) diff --git a/unilabos/device_comms/rpc.py b/unilabos/device_comms/rpc.py new file mode 100644 index 00000000..b818205b --- /dev/null +++ b/unilabos/device_comms/rpc.py @@ -0,0 +1,66 @@ +import json +import requests +from rclpy.logging import get_logger + + +class BaseRequest: + def __init__(self): + self._logger = get_logger(__name__) + + def get_logger(self): + return self._logger + + def get(self, url, params, headers={"Content-Type": "application/json"}): + try: + response = requests.get(url, params=params, headers=headers, timeout=30) + self.get_logger().debug( + f"Request >>> : {params} {response.status_code} {response.text}" + ) + if response.status_code == 200: + return response.json() + + except Exception as e: + self.get_logger().error(f"Request ERROR: {e}") + return + + def post(self, url, params={}, files=None, headers={"Content-Type": "application/json"}): + try: + response = requests.post( + url, data=json.dumps(params) if params else None, headers=headers, timeout=120, files=files + ) + self.get_logger().debug( + f"Request >>> : {response.request.body} {response.status_code} {response.text}" + ) + if response.status_code == 200: + return response.json() + else: + raise Exception("Request ERROR:", response.text) + + except Exception as e: + self.get_logger().error(f"Request ERROR: {e}") + return + + def form_post(self, url, params): + try: + response = requests.post( + url=url, + data=params, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=3, + ) + self.get_logger().debug( + f"Request >>> : {response.request.body} {response.status_code} {response.text}" + ) + if response.status_code == 200: + return response.json() + else: + raise Exception("Request ERROR:", response.text) + + except Exception as e: + self.get_logger().error(f"Request ERROR: {e}") + return + + +# 使用示例 +if __name__ == "__main__": + pass diff --git a/unilabos/device_comms/universal_driver.py b/unilabos/device_comms/universal_driver.py new file mode 100644 index 00000000..281e0cd9 --- /dev/null +++ b/unilabos/device_comms/universal_driver.py @@ -0,0 +1,228 @@ + +from abc import abstractmethod +from functools import wraps +import inspect +import json +import logging +import queue +from socket import socket +import threading +import time +import traceback +from typing import Optional + +import serial + +class SingleRunningExecutor(object): + """ + 异步执行单个任务,不允许重复执行,通过class的函数获得唯一任务名的实例 + 需要对 + """ + __instance = {} + + @classmethod + def get_instance(cls, func, post_func=None, name=None, *var, **kwargs): + print(f"!!!get_instance: {name} {kwargs}") + if name is None: + name = func.__name__ + if name not in cls.__instance: + cls.__instance[name] = cls(func, post_func, *var, **kwargs) + return cls.__instance[name] + + start_time: float = None + end_time: float = None + is_running: bool = None + is_error: bool = None + is_success: bool = None + + @property + def is_ended(self): + return not self.is_running and (self.is_error or self.is_success) + + @property + def is_started(self): + return self.is_running or self.is_error or self.is_success + + def reset(self): + self.start_time = None + self.end_time = None + self.is_running = False + self.is_error = False + self.is_success = False + self._final_var = {} + self._thread = threading.Thread(target=self._execute) + + def __init__(self, func, post_func=None, *var, **kwargs): + self._func = func + self._post_func = post_func + self._sig = inspect.signature(self._func) + self._var = var + self._kwargs = kwargs + self.reset() + + def _execute(self): + res = None + try: + for ind, i in enumerate(self._var): + self._final_var[list(self._sig.parameters.keys())[ind]] = i + for k, v in self._kwargs.items(): + if k in self._sig.parameters.keys(): + self._final_var[k] = v + self.is_running = True + print(f"!!!_final_var: {self._final_var}") + res = self._func(**self._final_var) + except Exception as e: + self.is_running = False + self.is_error = True + traceback.print_exc() + if callable(self._post_func): + self._post_func(res, self._final_var) + + def start(self, **kwargs): + if len(kwargs) > 0: + self._kwargs = kwargs + self.start_time = time.time() + self._thread.start() + + def join(self): + if self.is_running: + self._thread.join() + self.end_time = time.time() + + +def command(func): + """ + Decorator for command_set execution. Checks if the method is called in the same thread as the class instance, + if so enqueues the command_set and waits for a reply in the reply queue. Else it concludes it must be the command + handler thread and actually executes the method. This way methods in the child classes need to be written + just once and decorated accordingly. + :return: decorated method + """ + + @wraps(func) + def wrapper(*args, **kwargs): + device_instance = args[0] + if threading.get_ident() == device_instance.current_thread: + command_set = [func, args, kwargs] + device_instance.command_queue.put(command_set) + while True: + if not device_instance.reply_queue.empty(): + return device_instance.reply_queue.get() + else: + return func(*args, **kwargs) + + return wrapper + + + +class UniversalDriver(object): + def _init_logger(self): + self.logger = logging.getLogger(f"{self.__class__.__name__}_logger") + + def __init__(self): + self._init_logger() + + def execute_command_from_outer(self, command: str): + try: + command = json.loads(command.replace("'", '"').replace("False", "false").replace("True", "true")) # 要求不能出现'作为字符串 + except Exception as e: + print(f"Json解析失败: {e}") + return False + for k, v in command.items(): + print(f"执行{k}方法,参数为{v}") + try: + getattr(self, k)(**v) + except Exception as e: + print(f"执行{k}方法失败: {e}") + traceback.print_exc() + return False + return True + + +class TransportDriver(UniversalDriver): + COMMAND_QUEUE_ENABLE = True + command_handler_thread: Optional[threading.Thread] = None + __connection: Optional[serial.Serial | socket] = None + + + def _init_command_queue(self): + self.command_queue = queue.Queue() + self.reply_queue = queue.Queue() + + def __command_handler_daemon(self): + while True: + try: + if not self.command_queue.empty(): + command_item = self.command_queue.get() + method = command_item[0] + arguments = command_item[1] + keywordarguments = command_item[2] + reply = method(*arguments, **keywordarguments) + self.reply_queue.put(reply) + else: + self.keepalive() + except ValueError as e: + # workaround if something goes wrong with the serial connection + # future me will certainly not hate past me for this... + self.logger.critical(e) + self.__connection.flush() + # thread-safe purging of both queues + while not self.command_queue.empty(): + self.command_queue.get() + while not self.reply_queue.empty(): + self.reply_queue.get() + + def launch_command_handler(self): + if self.COMMAND_QUEUE_ENABLE: + self.command_handler_thread = threading.Thread(target=self.__command_handler_daemon, name="{0}_command_handler".format(self.device_name), daemon=True) + self.command_handler_thread.start() + + @abstractmethod + def open_connection(self): + pass + + @abstractmethod + def close_connection(self): + pass + + @abstractmethod + def keepalive(self): + pass + + def __init__(self): + super().__init__() + if self.COMMAND_QUEUE_ENABLE: + self.launch_command_handler() + + +class DriverChecker(object): + def __init__(self, driver, interval: int | float): + self.driver = driver + self.interval = interval + self._thread = threading.Thread(target=self._monitor) + self._thread.daemon = True + self._stop_event = threading.Event() + + def _monitor(self): + while not self._stop_event.is_set(): + try: + # print(self.__class__.__name__, "Started!") + self.check() + except Exception as e: + print(f"Error in {self.__class__.__name__}: {str(e)}") + traceback.print_exc() + finally: + time.sleep(self.interval) + + @abstractmethod + def check(self): + """子类必须实现此方法""" + raise NotImplementedError + + def start_monitoring(self): + self._thread.start() + + def stop_monitoring(self): + self._stop_event.set() + self._thread.join() + diff --git a/unilabos/devices/UV_test/__init__.py b/unilabos/devices/UV_test/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/devices/UV_test/fuxiang2.py b/unilabos/devices/UV_test/fuxiang2.py new file mode 100644 index 00000000..c6f21ef5 --- /dev/null +++ b/unilabos/devices/UV_test/fuxiang2.py @@ -0,0 +1,333 @@ +import tkinter as tk +from tkinter import ttk # 使用 ttk 替换 tk 控件 +from tkinter import messagebox +from tkinter.font import Font +from threading import Thread +from ttkthemes import ThemedTk +import time +import matplotlib.pyplot as plt +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg +import clr # pythonnet library +import sys +import threading +import datetime + +jifenshijian = 10 #积分时间 +shuaxinshijian = 0.01 #刷新时间 +zihaodaxiao = 16 #字号大小 +ymax = 70000 +ymin = -2000 + +# 加载DLL +dll_path = "C:\\auto\\UV_spec\\idea-sdk 3.0.9\\idea-sdk.UPI\\IdeaOptics.dll" +clr.AddReference(dll_path) +from IdeaOptics import Wrapper + +# 初始化Wrapper对象和光谱仪 +wrapper = Wrapper() +number_of_spectrometers = wrapper.OpenAllSpectrometers() +if number_of_spectrometers > 0: + spectrometer_index = 0 # 假设使用第一个光谱仪 + integration_time = jifenshijian # 设置积分时间 + wrapper.setIntegrationTime(spectrometer_index, integration_time) + +class App: + def __init__(self, root): + self.root = root + self.root.title("光谱测试") + self.is_continuous = False + self.root.protocol("WM_DELETE_WINDOW", self.on_closing) + self.stop_event = threading.Event() # 使用Event代替布尔标志 + self.continuous_thread = None # 在这里初始化 + self.background_spectrum = None + self.correct_background = False + self.test_count = 0 + self.background_count = 0 + + self.source_spectrum = None # 初始化光源强度变量 + self.transmission_mode = False # 初始化透射模式标志 + + self.data_ready = False + + # 使用 grid 布局 + self.root.columnconfigure(0, weight=1) + self.root.rowconfigure(0, weight=1) + self.root.rowconfigure(1, weight=1) + self.root.rowconfigure(2, weight=1) + self.root.rowconfigure(3, weight=1) + + self.current_ylim = [-100, 1000] # 初始化y轴范围 + + # 创建一个 Style 对象 + style = ttk.Style() + + # 定义一个新的样式 + style.configure('Custom.TButton', font=('Helvetica', zihaodaxiao, 'bold'), foreground='white') + + # 创建滑动条和按钮的容器 Frame + control_frame = ttk.Frame(self.root) + control_frame.grid(row=0, column=0, sticky="ew") + + # 创建一个滑动条来选择平滑次数 + self.boxcar_width_slider = tk.Scale(control_frame, from_=0, to=10, orient=tk.HORIZONTAL, length=300, label="平滑次数", font=("Helvetica", zihaodaxiao, 'bold')) + self.boxcar_width_slider.grid(row=0, column=0, padx=10, pady=10) + + # 创建一个滑动条来选择平均次数 + self.scans_to_average_slider = tk.Scale(control_frame, from_=1, to=10, orient=tk.HORIZONTAL, length=300, label="平均次数", font=("Helvetica", zihaodaxiao, 'bold')) + self.scans_to_average_slider.grid(row=0, column=1, padx=10, pady=10) + + # 调整 Scale 控件的外观 + self.boxcar_width_slider.config(bg='grey', fg='white') + self.scans_to_average_slider.config(bg='grey', fg='white') + + # 字体设置 + entry_font = ('Helvetica', zihaodaxiao, 'bold') + + # 添加输入框的容器 Frame + entry_frame = ttk.Frame(self.root) + entry_frame.grid(row=1, column=0, sticky="ew") + + # 创建并放置"积分时间(ms)"输入框 + ttk.Label(entry_frame, text="积分时间(ms):", font=entry_font).grid(row=0, column=0, padx=10, pady=10) + self.integration_time_entry = ttk.Entry(entry_frame, font=entry_font) + self.integration_time_entry.grid(row=0, column=1, padx=10, pady=10) + self.integration_time_entry.insert(0, "10") # 设置默认值 + + # 创建并放置"刷新间隔(s)"输入框 + ttk.Label(entry_frame, text="刷新间隔(s):", font=entry_font).grid(row=0, column=2, padx=10, pady=10) + self.refresh_interval_entry = ttk.Entry(entry_frame, font=entry_font) + self.refresh_interval_entry.grid(row=0, column=3, padx=10, pady=10) + self.refresh_interval_entry.insert(0, "0.01") # 设置默认值 + + # 创建按钮的容器 Frame + button_frame = ttk.Frame(self.root) + button_frame.grid(row=2, column=0, sticky="ew") + + # 创建并放置按钮 + ttk.Button(button_frame, text="测试一下", style='Custom.TButton', command=self.single_test).grid(row=0, column=0, padx=10, pady=10) + ttk.Button(button_frame, text="连续测试", style='Custom.TButton', command=self.start_continuous_test).grid(row=0, column=1, padx=10, pady=10) + ttk.Button(button_frame, text="停止测试", style='Custom.TButton', command=self.stop_continuous_test).grid(row=0, column=2, padx=10, pady=10) + + # 创建背景相关按钮的容器 Frame + background_frame = ttk.Frame(self.root) + background_frame.grid(row=3, column=0, sticky="ew") + + # 创建并放置“采集背景”按钮 + self.collect_background_button = ttk.Button(background_frame, text="采集背景", style='Custom.TButton', command=self.collect_background) + self.collect_background_button.grid(row=0, column=0, padx=10, pady=10) + + # 创建并放置“背景校正”按钮 + self.correct_background_button = ttk.Button(background_frame, text="背景校正", style='Custom.TButton', command=self.toggle_background_correction) + self.correct_background_button.grid(row=0, column=1, padx=10, pady=10) + + # 创建“光源采集”按钮 + ttk.Button(background_frame, text="光源采集", style='Custom.TButton', command=self.collect_source).grid(row=0, column=2, padx=10, pady=10) + + # 创建“透射模式”按钮 + self.transmission_button = ttk.Button(background_frame, text="透射模式", style='Custom.TButton', command=self.toggle_transmission_mode) + self.transmission_button.grid(row=0, column=3, padx=10, pady=10) + + # 创建 matplotlib 画布 + plt.style.use('ggplot') # 使用预定义的样式,如 'ggplot' + self.figure, self.ax = plt.subplots(figsize=(10, 8)) + self.canvas = FigureCanvasTkAgg(self.figure, self.root) + self.canvas_widget = self.canvas.get_tk_widget() + self.canvas_widget.grid(row=3, column=0, sticky="ew") + + # 使用 grid 布局来放置 matplotlib 画布 + self.canvas_widget = self.canvas.get_tk_widget() + self.canvas_widget.grid(row=4, column=0, sticky="ew") + + # 创建文件名并打开文件 + start_time = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + self.data_file = open(f"C:\\auto\\UV_spec\\data\\{start_time}.txt", "w") + + def get_spectrum_data(self): + # 获取波长和光谱值 + pixels = wrapper.getNumberOfPixels(spectrometer_index) + spectrum = wrapper.getSpectrum(spectrometer_index) + wavelengths = wrapper.getWavelengths(spectrometer_index) + + # 转换.NET数组到Python列表 + spectrum_list = [spectrum[i] for i in range(pixels)] + wavelengths_list = [wavelengths[i] for i in range(pixels)] + + self.data_ready = True + return wavelengths_list, spectrum_list + + def collect_source(self): + # 采集光源强度数据 + wavelengths, self.source_spectrum = self.get_spectrum_data() + conditions = f"jifenshijian = {jifenshijian} shuaxinshijian = {shuaxinshijian} zihaodaxiao = {zihaodaxiao}" + self.write_data_to_file("source", 1, conditions, self.source_spectrum) + self.update_plot(wavelengths, self.source_spectrum) + + def toggle_transmission_mode(self): + # 切换透射模式 + self.transmission_mode = not self.transmission_mode + self.transmission_button.config(text="正在透射" if self.transmission_mode else "透射模式") + + def calculate_transmission(self, spectrum): + # 计算透射率 + transmission = [] + for s, b, src in zip(spectrum, self.background_spectrum, self.source_spectrum): + denominator = max(src - b, 0.1) + trans_value = (s - b) / denominator * 100 + trans_value = max(0, min(trans_value, 100)) + transmission.append(trans_value) + return transmission + + def update_plot(self, wavelengths, spectrum, plot_type='spectrum'): + + if not self.data_ready: + return + + self.ax.clear() + + if plot_type == 'transmission': + # 透射率模式的绘图设置 + self.ax.plot(wavelengths, spectrum, label='Transmission (%)') + self.ax.set_ylim(-10, 110) # 设置y轴范围为0%到100% + self.ax.set_ylabel('Transmission (%)', fontname='Arial', fontsize=zihaodaxiao) + else: + # 普通光谱模式的绘图设置 + self.ax.plot(wavelengths, spectrum) + self.ax.set_ylim(self.current_ylim) # 使用当前y轴范围 + + # 计算新的最大值和最小值 + new_min, new_max = min(spectrum), max(spectrum) + + # 检查新的最大值或最小值是否超过当前y轴范围 + while new_min < self.current_ylim[0] or new_max > self.current_ylim[1]: + # 扩大y轴范围 + self.current_ylim = [self.current_ylim[0] * 2, self.current_ylim[1] * 2] + + # 确保新的y轴范围不超过最大限制 + if self.current_ylim[0] < ymin: + self.current_ylim[0] = ymin + if self.current_ylim[1] > ymax: + self.current_ylim[1] = ymax + break + + self.ax.set_ylabel('Intensity', fontname='Arial', fontsize=zihaodaxiao) + + self.ax.set_xlabel('Wavelength (nm)', fontname='Arial', fontsize=zihaodaxiao) + self.ax.set_title('Spectrum', fontname='Arial', fontsize=zihaodaxiao) + + self.canvas.draw() + + self.data_ready = False + + def draw_plot(self): + self.canvas.draw() + + def write_data_to_file(self, test_type, test_number, conditions, spectrum): + data_str = " ".join(map(str, spectrum)) + self.data_file.write(f"{test_type}{test_number}\n{conditions}\n{data_str}\n\n") + self.data_file.flush() + + def collect_background(self): + # 设置平滑次数 + boxcar_width = self.boxcar_width_slider.get() + wrapper.setBoxcarWidth(spectrometer_index, boxcar_width) + + # 设置平均次数 + scans_to_average = self.scans_to_average_slider.get() + wrapper.setScansToAverage(spectrometer_index, scans_to_average) + + # 采集背景数据 + wavelengths, self.background_spectrum = self.get_spectrum_data() + conditions = f"jifenshijian = {jifenshijian} shuaxinshijian = {shuaxinshijian} zihaodaxiao = {zihaodaxiao} pinghuacishu = {self.boxcar_width_slider.get()} pingjuncishu = {self.scans_to_average_slider.get()}" + self.background_count += 1 + self.write_data_to_file("background", self.background_count, conditions, self.background_spectrum) + self.update_plot(wavelengths, self.background_spectrum) + + def toggle_background_correction(self): + self.correct_background = not self.correct_background + self.correct_background_button.config(text="正在校正" if self.correct_background else "背景校正") + + def apply_background_correction(self, spectrum): + if self.background_spectrum is not None and self.correct_background: + return [s - b for s, b in zip(spectrum, self.background_spectrum)] + return spectrum + + def single_test(self): + # 获取输入框的值 + jifenshijian = float(self.integration_time_entry.get()) + shuaxinshijian = float(self.refresh_interval_entry.get()) + + # 设置平滑次数 + boxcar_width = self.boxcar_width_slider.get() + wrapper.setBoxcarWidth(spectrometer_index, boxcar_width) + + # 设置平均次数 + scans_to_average = self.scans_to_average_slider.get() + wrapper.setScansToAverage(spectrometer_index, scans_to_average) + + conditions = f"jifenshijian = {jifenshijian} shuaxinshijian = {shuaxinshijian} zihaodaxiao = {zihaodaxiao} pinghuacishu = {self.boxcar_width_slider.get()} pingjuncishu = {self.scans_to_average_slider.get()}" + self.test_count += 1 + + wavelengths, spectrum = self.get_spectrum_data() + + # 在透射模式下计算透射率,否则应用背景校正 + if self.transmission_mode and self.background_spectrum is not None and self.source_spectrum is not None: + transmission = self.calculate_transmission(spectrum) + self.update_plot(wavelengths, transmission, plot_type='transmission') + else: + corrected_spectrum = self.apply_background_correction(spectrum) + self.update_plot(wavelengths, corrected_spectrum, plot_type='spectrum') + + def continuous_test(self): + while not self.stop_event.is_set(): + # 获取输入框的值 + jifenshijian = float(self.integration_time_entry.get()) + shuaxinshijian = float(self.refresh_interval_entry.get()) + + # 设置平滑次数和平均次数 + boxcar_width = self.boxcar_width_slider.get() + wrapper.setBoxcarWidth(spectrometer_index, boxcar_width) + scans_to_average = self.scans_to_average_slider.get() + wrapper.setScansToAverage(spectrometer_index, scans_to_average) + + conditions = f"jifenshijian = {jifenshijian} shuaxinshijian = {shuaxinshijian} zihaodaxiao = {zihaodaxiao} pinghuacishu = {self.boxcar_width_slider.get()} pingjuncishu = {self.scans_to_average_slider.get()}" + self.test_count += 1 + wavelengths, spectrum = self.get_spectrum_data() + self.write_data_to_file("test", self.test_count, conditions, spectrum) + + # 根据当前模式计算并更新图表 + if self.transmission_mode and self.background_spectrum is not None and self.source_spectrum is not None: + transmission = self.calculate_transmission(spectrum) + self.update_plot(wavelengths, transmission, plot_type='transmission') + else: + corrected_spectrum = self.apply_background_correction(spectrum) + self.update_plot(wavelengths, corrected_spectrum) + + time.sleep(shuaxinshijian) + + def start_continuous_test(self): + self.stop_event.clear() # 重置事件 + self.continuous_thread = Thread(target=self.continuous_test) + self.continuous_thread.start() + + def stop_continuous_test(self): + self.stop_event.set() # 设置事件通知线程停止 + self.continuous_thread = None + + def on_closing(self): + if self.data_file: + self.data_file.close() + if messagebox.askyesno("退出", "实验g了?"): + self.stop_continuous_test() + self.root.destroy() + sys.exit() + +if __name__ == "__main__": + # 使用 ThemedTk 而不是普通的 Tk + root = ThemedTk(theme="equilux") # 使用 'arc' 主题 + + # 由于我们已经使用了 ttkthemes 来设置主题,下面这些行可以省略 + # style = ttk.Style() + # style.theme_use('arc') + + app = App(root) + root.mainloop() diff --git a/unilabos/devices/__init__.py b/unilabos/devices/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/devices/agv/__init__.py b/unilabos/devices/agv/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/devices/agv/agv_navigator.py b/unilabos/devices/agv/agv_navigator.py new file mode 100644 index 00000000..4039b796 --- /dev/null +++ b/unilabos/devices/agv/agv_navigator.py @@ -0,0 +1,101 @@ +import socket +import json +import time +from pydantic import BaseModel + + +class AgvNavigator: + def __init__(self, host): + self.control_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.receive_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.control_socket.connect((host, 19206)) + self.receive_socket.connect((host, 19204)) + self.rec_cmd_code = { + "pose" : "03EC", + "status" : "03FC", + "nav" : "0BEB" + } + self.status_list = ['NONE', 'WAITING', 'RUNNING', 'SUSPENDED', 'COMPLETED', 'FAILED', 'CANCELED'] + self._pose = [] + self._status = 'NONE' + self.success = False + + @property + def pose(self) -> list: + data = self.send('pose') + + try: + self._pose = [data['x'], data['y'], data['angle']] + except: + print(data) + + return self._pose + + @property + def status(self) -> str: + data = self.send('status') + self._status = self.status_list[data['task_status']] + return self._status + + def send(self, cmd, ex_data = '', obj = 'receive_socket'): + data = bytearray.fromhex(f"5A 01 00 01 00 00 00 00 {self.rec_cmd_code[cmd]} 00 00 00 00 00 00") + if ex_data: + data_ = ex_data + data[7] = len(data_) + data= data + bytearray(data_,"utf-8") + + cmd_obj = getattr(self, obj) + cmd_obj.sendall(data) + response_data = b"" + while True: + part = cmd_obj.recv(4096) # 每次接收 4096 字节 + response_data += part + if len(part) < 4096: # 当接收到的数据少于缓冲区大小时,表示接收完毕 + break + + response_str = response_data.hex() + json_start = response_str.find('7b') # 找到JSON的开始位置 + if json_start == -1: + raise "Error: No JSON data found in response." + + json_data = bytes.fromhex(response_str[json_start:]) + + # 尝试解析 JSON 数据 + try: + parsed_json = json.loads(json_data.decode('utf-8')) + return parsed_json + + except json.JSONDecodeError as e: + raise f"JSON Decode Error: {e}" + + + def send_nav_task(self, command:str): + self.success = False + # source,target = cmd.replace(' ','').split("->") + + target = json.loads(command)['target'] + json_data = {} + # json_data["source_id"] = source + json_data["id"] = target + # json_data["use_down_pgv"] = True + result = self.send('nav', ex_data=json.dumps(json_data), obj="control_socket") + try: + if result['ret_code'] == 0: + # print(result) + while True: + if self.status == 'COMPLETED': + break + time.sleep(1) + self.success = True + except: + self.success = False + + + def __del__(self): + self.control_socket.close() + self.receive_socket.close() + +if __name__ == "__main__": + agv = AgvNavigator("192.168.1.42") + # print(agv.pose) + agv.send_nav_task('LM14') \ No newline at end of file diff --git a/unilabos/devices/agv/pose.json b/unilabos/devices/agv/pose.json new file mode 100644 index 00000000..07d7cca0 --- /dev/null +++ b/unilabos/devices/agv/pose.json @@ -0,0 +1,54 @@ +{ + "pretreatment": [ + [ + "moveL", + [0.443, -0.0435, 0.40, 1.209, 1.209, 1.209] + ], + [ + "moveL", + [0.543, -0.0435, 0.50, 0, 1.57, 0] + ], + [ + "moveL", + [0.443, -0.0435, 0.60, 0, 1.57, 0] + ], + [ + "wait", + [2] + ], + [ + "gripper", + [255,255,255] + ], + [ + "wait", + [2] + ], + [ + "gripper", + [0,0,0] + ], + [ + "set", + [0.3,0.2] + ], + [ + "moveL", + [0.643, -0.0435, 0.40, 1.209, 1.209, 1.209] + ] + ], + "pose": [ + [ + "moveL", + [0.243, -0.0435, 0.30, 0.0, 3.12, 0.0] + ], + [ + "moveL", + [0.443, -0.0435, 0.40, 0.0, 3.12, 0.0] + ], + [ + "moveL", + [0.243, -0.0435, 0.50, 0.0, 3.12, 0.0] + ] + ] +} \ No newline at end of file diff --git a/unilabos/devices/agv/robotiq_gripper.py b/unilabos/devices/agv/robotiq_gripper.py new file mode 100644 index 00000000..fd8c491d --- /dev/null +++ b/unilabos/devices/agv/robotiq_gripper.py @@ -0,0 +1,298 @@ +"""Module to control Robotiq's grippers - tested with HAND-E""" + +import socket +import threading +import time +from enum import Enum +from typing import Union, Tuple, OrderedDict + +class RobotiqGripper: + """ + Communicates with the gripper directly, via socket with string commands, leveraging string names for variables. + """ + # WRITE VARIABLES (CAN ALSO READ) + ACT = 'ACT' # act : activate (1 while activated, can be reset to clear fault status) + GTO = 'GTO' # gto : go to (will perform go to with the actions set in pos, for, spe) + ATR = 'ATR' # atr : auto-release (emergency slow move) + ADR = 'ADR' # adr : auto-release direction (open(1) or close(0) during auto-release) + FOR = 'FOR' # for : force (0-255) + SPE = 'SPE' # spe : speed (0-255) + POS = 'POS' # pos : position (0-255), 0 = open + # READ VARIABLES + STA = 'STA' # status (0 = is reset, 1 = activating, 3 = active) + PRE = 'PRE' # position request (echo of last commanded position) + OBJ = 'OBJ' # object detection (0 = moving, 1 = outer grip, 2 = inner grip, 3 = no object at rest) + FLT = 'FLT' # fault (0=ok, see manual for errors if not zero) + + ENCODING = 'UTF-8' # ASCII and UTF-8 both seem to work + + class GripperStatus(Enum): + """Gripper status reported by the gripper. The integer values have to match what the gripper sends.""" + RESET = 0 + ACTIVATING = 1 + # UNUSED = 2 # This value is currently not used by the gripper firmware + ACTIVE = 3 + + class ObjectStatus(Enum): + """Object status reported by the gripper. The integer values have to match what the gripper sends.""" + MOVING = 0 + STOPPED_OUTER_OBJECT = 1 + STOPPED_INNER_OBJECT = 2 + AT_DEST = 3 + + def __init__(self ,host): + """Constructor.""" + self.socket = None + self.command_lock = threading.Lock() + self._min_position = 0 + self._max_position = 255 + self._min_speed = 0 + self._max_speed = 255 + self._min_force = 0 + self._max_force = 255 + self.connect(host) + # self.activate() + + def connect(self, hostname: str, port: int = 63352, socket_timeout: float = 2.0) -> None: + """Connects to a gripper at the given address. + :param hostname: Hostname or ip. + :param port: Port. + :param socket_timeout: Timeout for blocking socket operations. + """ + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.socket.connect((hostname, port)) + self.socket.settimeout(socket_timeout) + + def disconnect(self) -> None: + """Closes the connection with the gripper.""" + self.socket.close() + + def _set_vars(self, var_dict: OrderedDict[str, Union[int, float]]): + """Sends the appropriate command via socket to set the value of n variables, and waits for its 'ack' response. + :param var_dict: Dictionary of variables to set (variable_name, value). + :return: True on successful reception of ack, false if no ack was received, indicating the set may not + have been effective. + """ + # construct unique command + cmd = "SET" + for variable, value in var_dict.items(): + cmd += f" {variable} {str(value)}" + cmd += '\n' # new line is required for the command to finish + # atomic commands send/rcv + with self.command_lock: + self.socket.sendall(cmd.encode(self.ENCODING)) + data = self.socket.recv(1024) + return self._is_ack(data) + + def _set_var(self, variable: str, value: Union[int, float]): + """Sends the appropriate command via socket to set the value of a variable, and waits for its 'ack' response. + :param variable: Variable to set. + :param value: Value to set for the variable. + :return: True on successful reception of ack, false if no ack was received, indicating the set may not + have been effective. + """ + return self._set_vars(OrderedDict([(variable, value)])) + + def _get_var(self, variable: str): + """Sends the appropriate command to retrieve the value of a variable from the gripper, blocking until the + response is received or the socket times out. + :param variable: Name of the variable to retrieve. + :return: Value of the variable as integer. + """ + # atomic commands send/rcv + with self.command_lock: + cmd = f"GET {variable}\n" + self.socket.sendall(cmd.encode(self.ENCODING)) + data = self.socket.recv(1024) + + # expect data of the form 'VAR x', where VAR is an echo of the variable name, and X the value + # note some special variables (like FLT) may send 2 bytes, instead of an integer. We assume integer here + var_name, value_str = data.decode(self.ENCODING).split() + if var_name != variable: + raise ValueError(f"Unexpected response {data} ({data.decode(self.ENCODING)}): does not match '{variable}'") + value = int(value_str) + return value + + @staticmethod + def _is_ack(data: str): + return data == b'ack' + + def _reset(self): + """ + Reset the gripper. + The following code is executed in the corresponding script function + def rq_reset(gripper_socket="1"): + rq_set_var("ACT", 0, gripper_socket) + rq_set_var("ATR", 0, gripper_socket) + + while(not rq_get_var("ACT", 1, gripper_socket) == 0 or not rq_get_var("STA", 1, gripper_socket) == 0): + rq_set_var("ACT", 0, gripper_socket) + rq_set_var("ATR", 0, gripper_socket) + sync() + end + + sleep(0.5) + end + """ + self._set_var(self.ACT, 0) + self._set_var(self.ATR, 0) + while (not self._get_var(self.ACT) == 0 or not self._get_var(self.STA) == 0): + self._set_var(self.ACT, 0) + self._set_var(self.ATR, 0) + time.sleep(0.5) + + + def activate(self, auto_calibrate: bool = True): + """Resets the activation flag in the gripper, and sets it back to one, clearing previous fault flags. + :param auto_calibrate: Whether to calibrate the minimum and maximum positions based on actual motion. + The following code is executed in the corresponding script function + def rq_activate(gripper_socket="1"): + if (not rq_is_gripper_activated(gripper_socket)): + rq_reset(gripper_socket) + + while(not rq_get_var("ACT", 1, gripper_socket) == 0 or not rq_get_var("STA", 1, gripper_socket) == 0): + rq_reset(gripper_socket) + sync() + end + + rq_set_var("ACT",1, gripper_socket) + end + end + def rq_activate_and_wait(gripper_socket="1"): + if (not rq_is_gripper_activated(gripper_socket)): + rq_activate(gripper_socket) + sleep(1.0) + + while(not rq_get_var("ACT", 1, gripper_socket) == 1 or not rq_get_var("STA", 1, gripper_socket) == 3): + sleep(0.1) + end + + sleep(0.5) + end + end + """ + if not self.is_active(): + self._reset() + while (not self._get_var(self.ACT) == 0 or not self._get_var(self.STA) == 0): + time.sleep(0.01) + + self._set_var(self.ACT, 1) + time.sleep(1.0) + while (not self._get_var(self.ACT) == 1 or not self._get_var(self.STA) == 3): + time.sleep(0.01) + + # auto-calibrate position range if desired + if auto_calibrate: + self.auto_calibrate() + + def is_active(self): + """Returns whether the gripper is active.""" + status = self._get_var(self.STA) + return RobotiqGripper.GripperStatus(status) == RobotiqGripper.GripperStatus.ACTIVE + + def get_min_position(self) -> int: + """Returns the minimum position the gripper can reach (open position).""" + return self._min_position + + def get_max_position(self) -> int: + """Returns the maximum position the gripper can reach (closed position).""" + return self._max_position + + def get_open_position(self) -> int: + """Returns what is considered the open position for gripper (minimum position value).""" + return self.get_min_position() + + def get_closed_position(self) -> int: + """Returns what is considered the closed position for gripper (maximum position value).""" + return self.get_max_position() + + def is_open(self): + """Returns whether the current position is considered as being fully open.""" + return self.get_current_position() <= self.get_open_position() + + def is_closed(self): + """Returns whether the current position is considered as being fully closed.""" + return self.get_current_position() >= self.get_closed_position() + + def get_current_position(self) -> int: + """Returns the current position as returned by the physical hardware.""" + return self._get_var(self.POS) + + def auto_calibrate(self, log: bool = True) -> None: + """Attempts to calibrate the open and closed positions, by slowly closing and opening the gripper. + :param log: Whether to print the results to log. + """ + # first try to open in case we are holding an object + (position, status) = self.move_and_wait_for_pos(self.get_open_position(), 64, 1) + if RobotiqGripper.ObjectStatus(status) != RobotiqGripper.ObjectStatus.AT_DEST: + raise RuntimeError(f"Calibration failed opening to start: {str(status)}") + + # try to close as far as possible, and record the number + (position, status) = self.move_and_wait_for_pos(self.get_closed_position(), 64, 1) + if RobotiqGripper.ObjectStatus(status) != RobotiqGripper.ObjectStatus.AT_DEST: + raise RuntimeError(f"Calibration failed because of an object: {str(status)}") + assert position <= self._max_position + self._max_position = position + + # try to open as far as possible, and record the number + (position, status) = self.move_and_wait_for_pos(self.get_open_position(), 64, 1) + if RobotiqGripper.ObjectStatus(status) != RobotiqGripper.ObjectStatus.AT_DEST: + raise RuntimeError(f"Calibration failed because of an object: {str(status)}") + assert position >= self._min_position + self._min_position = position + + if log: + print(f"Gripper auto-calibrated to [{self.get_min_position()}, {self.get_max_position()}]") + + def move(self, position: int, speed: int, force: int) -> Tuple[bool, int]: + """Sends commands to start moving towards the given position, with the specified speed and force. + :param position: Position to move to [min_position, max_position] + :param speed: Speed to move at [min_speed, max_speed] + :param force: Force to use [min_force, max_force] + :return: A tuple with a bool indicating whether the action it was successfully sent, and an integer with + the actual position that was requested, after being adjusted to the min/max calibrated range. + """ + + def clip_val(min_val, val, max_val): + return max(min_val, min(val, max_val)) + + clip_pos = clip_val(self._min_position, position, self._max_position) + clip_spe = clip_val(self._min_speed, speed, self._max_speed) + clip_for = clip_val(self._min_force, force, self._max_force) + + # moves to the given position with the given speed and force + var_dict = OrderedDict([(self.POS, clip_pos), (self.SPE, clip_spe), (self.FOR, clip_for), (self.GTO, 1)]) + return self._set_vars(var_dict), clip_pos + + def move_and_wait_for_pos(self, position: int, speed: int, force: int) -> Tuple[int, ObjectStatus]: # noqa + """Sends commands to start moving towards the given position, with the specified speed and force, and + then waits for the move to complete. + :param position: Position to move to [min_position, max_position] + :param speed: Speed to move at [min_speed, max_speed] + :param force: Force to use [min_force, max_force] + :return: A tuple with an integer representing the last position returned by the gripper after it notified + that the move had completed, a status indicating how the move ended (see ObjectStatus enum for details). Note + that it is possible that the position was not reached, if an object was detected during motion. + """ + set_ok, cmd_pos = self.move(position, speed, force) + if not set_ok: + raise RuntimeError("Failed to set variables for move.") + + # wait until the gripper acknowledges that it will try to go to the requested position + while self._get_var(self.PRE) != cmd_pos: + time.sleep(0.001) + + # wait until not moving + cur_obj = self._get_var(self.OBJ) + while RobotiqGripper.ObjectStatus(cur_obj) == RobotiqGripper.ObjectStatus.MOVING: + cur_obj = self._get_var(self.OBJ) + + # report the actual position and the object status + final_pos = self._get_var(self.POS) + final_obj = cur_obj + return final_pos, RobotiqGripper.ObjectStatus(final_obj) + +if __name__ == '__main__': + gripper = RobotiqGripper('192.168.1.178') + gripper.move(255,0,0) + print(gripper.move(255,0,0)) \ No newline at end of file diff --git a/unilabos/devices/agv/ur_arm_task.py b/unilabos/devices/agv/ur_arm_task.py new file mode 100644 index 00000000..8c84c855 --- /dev/null +++ b/unilabos/devices/agv/ur_arm_task.py @@ -0,0 +1,166 @@ +import rtde_control +import dashboard_client +import time +import json +from unilabos.devices.agv.robotiq_gripper import RobotiqGripper +import rtde_receive +from std_msgs.msg import Float64MultiArray +from pydantic import BaseModel + +class UrArmTask(): + def __init__(self, host, retry=30): + self.init_flag = False + self.dash_c = None + n = 0 + while self.dash_c is None: + try: + self.dash_c = dashboard_client.DashboardClient(host) + if not self.dash_c.isConnected(): + self.dash_c.connect() + + self.dash_c.loadURP('camera/250111_put_board.urp') + self.arm_init() + self.dash_c.running() + except Exception as e: + print(e) + self.dash_c = None + time.sleep(1) + n += 1 + if n > retry: + raise Exception('Can not connect to the robot dashboard server!') + + self.vel = 0.1 + self.acc = 0.1 + self.rtde_c = None + self.rtde_r = None + + self.gripper = None + self._pose = [0.0,0.0,0.0,0.0,0.0,0.0] + self._gripper_pose = None + self._status = 'IDLE' + + self._gripper_status = 'AT_DEST' + self.gripper_s_list = ['MOVING','STOPPED_OUTER_OBJECT','STOPPED_INNER_OBJECT','AT_DEST'] + + self.dash_c.loadURP('camera/250111_put_board.urp') + + self.arm_init() + self.success = True + self.init_flag = True + + + n = 0 + while self.gripper is None: + try: + self.gripper = RobotiqGripper(host) + self.gripper.activate() + # self._gripper_status = self.gripper_s_list[self.gripper._get_var('OBJ')] + except: + self.gripper = None + time.sleep(1) + n += 1 + if n > retry: + raise Exception('Can not connect to the robot gripper server!') + + n = 0 + while self.rtde_r is None: + try: + self.rtde_r = rtde_receive.RTDEReceiveInterface(host) + if not self.rtde_r.isConnected(): + self.rtde_r.reconnect() + self._pose = self.rtde_r.getActualTCPPose() + except Exception as e: + print(e) + self.rtde_r = None + time.sleep(1) + n += 1 + if n > retry: + raise Exception('Can not connect to the arm info server!') + + self.pose_data = {} + self.pose_file = 'C:\\auto\\unilabos\\unilabos\\devices\\agv\\pose.json' + self.reload_pose() + self.dash_c.stop() + + def arm_init(self): + self.dash_c.powerOn() + self.dash_c.brakeRelease() + self.dash_c.unlockProtectiveStop() + running = self.dash_c.running() + while running: + running = self.dash_c.running() + time.sleep(1) + + # def __del__(self): + # self.dash_c.disconnect() + # self.rtde_c.disconnect() + # self.rtde_r.disconnect() + # self.gripper.disconnect() + + def load_pose_file(self,file): + self.pose_file = file + self.reload_pose() + + def reload_pose(self): + self.pose_data = json.load(open(self.pose_file)) + + def load_pose_data(self,data): + self.pose_data = json.loads(data) + + @property + def arm_pose(self) -> list: + try: + if not self.rtde_r.isConnected(): + self.rtde_r.reconnect() + print('_'*30,'Reconnect to the arm info server!') + self._pose = self.rtde_r.getActualTCPPose() + # print(self._pose) + except Exception as e: + self._pose = self._pose + print('-'*20,'zhixing_arm\n',e) + return self._pose + + @property + def gripper_pose(self) -> float: + if self.init_flag: + try: + self._gripper_status = self.gripper_s_list[self.gripper._get_var('OBJ')] + self._gripper_pose = self.gripper.get_current_position() + except Exception as e: + self._gripper_status = self._gripper_status + self._gripper_pose = self._gripper_pose + print('-'*20,'zhixing_gripper\n',e) + return self._gripper_pose + + @property + def arm_status(self) -> str: + return self._status + + @property + def gripper_status(self) -> str: + if self.init_flag: + return self._gripper_status + + def move_pos_task(self,command): + self.success = False + task_name = json.loads(command)['task_name'] + + self.dash_c.loadURP(task_name) + self.dash_c.play() + + time.sleep(0.5) + self._status = 'RUNNING' + while self._status == 'RUNNING': + running = self.dash_c.running() + if not running: + self._status = 'IDLE' + time.sleep(1) + + self.success = True + + +if __name__ == "__main__": + arm = UrArmTask("192.168.1.178") + # arm.move_pos_task('t2_y4_transfer3.urp') + # print(arm.arm_pose()) + \ No newline at end of file diff --git a/unilabos/devices/cnc/__init__.py b/unilabos/devices/cnc/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/devices/cnc/grbl_async.py b/unilabos/devices/cnc/grbl_async.py new file mode 100644 index 00000000..7e5ac7f3 --- /dev/null +++ b/unilabos/devices/cnc/grbl_async.py @@ -0,0 +1,265 @@ +import os +import asyncio +from asyncio import Event, Future, Lock, Task +from enum import Enum +from dataclasses import dataclass +import re +import time +from typing import Any, Union, Optional, overload + +import serial.tools.list_ports +from serial import Serial +from serial.serialutil import SerialException + +from unilabos.messages import Point3D + + +class GrblCNCConnectionError(Exception): + pass + + +@dataclass(frozen=True, kw_only=True) +class GrblCNCInfo: + port: str + address: str = "1" + + limits: tuple[int, int, int, int, int, int] = (-150, 150, -200, 0, 0, 60) + + def create(self): + return GrblCNCAsync(self.port, self.address, self.limits) + + +class GrblCNCAsync: + _status: str = "Offline" + _position: Point3D = Point3D(x=0.0, y=0.0, z=0.0) + + def __init__(self, port: str, address: str = "1", limits: tuple[int, int, int, int, int, int] = (-150, 150, -200, 0, 0, 60)): + self.port = port + self.address = address + + self.limits = limits + + try: + self._serial = Serial( + baudrate=115200, + port=port + ) + except (OSError, SerialException) as e: + raise GrblCNCConnectionError from e + + self._busy = False + self._closing = False + self._pose_number = self.pose_number_remaining = -1 + self._error_event = Event() + self._query_future = Future[Any]() + self._query_lock = Lock() + self._read_task: Optional[Task[None]] = None + self._read_extra_line = False + self._run_future: Optional[Future[Any]] = None + self._run_lock = Lock() + + def _read_all(self): + data = self._serial.read_until(b"\n") + data_decoded = data.decode() + while not "ok" in data_decoded and not "Grbl" in data_decoded: + data += self._serial.read_until(b"\n") + data_decoded = data.decode() + return data + + async def _read_loop(self): + try: + while True: + self._receive((await asyncio.to_thread(lambda: self._read_all()))) + except SerialException as e: + raise GrblCNCConnectionError from e + finally: + if not self._closing: + self._error_event.set() + + if self._query_future and not self._query_future.done(): + self._query_future.set_exception(GrblCNCConnectionError()) + if self._run_future and not self._run_future.done(): + self._run_future.set_exception(GrblCNCConnectionError()) + + @overload + async def _query(self, command: str, dtype: type[bool]) -> bool: + pass + + @overload + async def _query(self, command: str, dtype: type[int]) -> int: + pass + + @overload + async def _query(self, command: str, dtype = None) -> str: + pass + + async def _query(self, command: str, dtype: Optional[type] = None): + async with self._query_lock: + if self._closing or self._error_event.is_set(): + raise GrblCNCConnectionError + + self._query_future = Future[Any]() + + self._read_extra_line = command.startswith("?") + run = '' + full_command = f"{command}{run}\n" + full_command_data = bytearray(full_command, 'ascii') + + try: + # await asyncio.to_thread(lambda: self._serial.write(full_command_data)) + self._serial.write(full_command_data) + return self._parse(await asyncio.wait_for(asyncio.shield(self._query_future), timeout=5.0), dtype=dtype) + except (SerialException, asyncio.TimeoutError) as e: + self._error_event.set() + raise GrblCNCConnectionError from e + finally: + self._query_future = None + + def _parse(self, data: bytes, dtype: Optional[type] = None): + response = data.decode() + + if dtype == bool: + return response == "1" + elif dtype == int: + return int(response) + else: + return response + + def _receive(self, data: bytes): + ascii_string = "".join(chr(byte) for byte in data) + was_busy = self._busy + self._busy = "Idle" not in ascii_string + + # if self._read_extra_line and ascii_string.startswith("ok"): + # self._read_extra_line = False + # return + if self._run_future and was_busy and not self._busy: + self._run_future.set_result(data) + if self._query_future: + self._query_future.set_result(data) + else: + raise Exception("Dropping data") + + async def _run(self, command: str): + async with self._run_lock: + self._run_future = Future[Any]() + # self._busy = True + + try: + await self._query(command) + while True: + await asyncio.sleep(0.2) # Wait for 0.5 seconds before polling again + + status = await self.get_status() + if "Idle" in status: + break + await asyncio.shield(self._run_future) + finally: + self._run_future = None + + async def initialize(self): + time.sleep(0.5) + await self._run("G0X0Y0Z0") + status = await self.get_status() + return status + + # Operations + + # Status Queries + + @property + def status(self) -> str: + return self._status + + async def get_status(self): + __pos_pattern__ = re.compile('.Pos:(\-?\d+\.\d+),(\-?\d+\.\d+),(\-?\d+\.\d+)') + __status_pattern__ = re.compile('<([a-zA-Z]+),') + + response = await self._query("?") + pat = re.search(__pos_pattern__, response) + if pat is not None: + pos = pat.group().split(":")[1].split(",") + self._status = re.search(__status_pattern__, response).group(1).lstrip("<").rstrip(",") + self._position = Point3D(x=float(pos[0]), y=float(pos[1]), z=float(pos[2])) + + return self.status + + # Position Setpoint and Queries + + @property + def position(self) -> Point3D: + # 由于此时一定调用过 get_status,所以 position 一定是被更新过的 + return self._position + + def get_position(self): + return self.position + + async def set_position(self, position: Point3D): + """ + Move to absolute position (unit: mm) + + Args: + x, y, z: float + + Returns: + None + """ + x = max(self.limits[0], min(self.limits[1], position.x)) + y = max(self.limits[2], min(self.limits[3], position.y)) + z = max(self.limits[4], min(self.limits[5], position.z)) + return await self._run(f"G0X{x:.3f}Y{y:.3f}Z{z:.3f}") + + async def move_through_points(self, points: list[Point3D]): + for i, point in enumerate(points): + self._pose_number = i + self.pose_number_remaining = len(points) - i + await self.set_position(point) + await asyncio.sleep(0.5) + self._step_number = -1 + + async def stop_operation(self): + return await self._run("T") + + # Queries + + async def wait_error(self): + await self._error_event.wait() + + async def __aenter__(self): + await self.open() + return self + + async def __aexit__(self, exc_type, exc, tb): + await self.close() + + async def open(self): + if self._read_task: + raise GrblCNCConnectionError + self._read_task = asyncio.create_task(self._read_loop()) + + try: + await self.get_status() + except Exception: + await self.close() + raise + + async def close(self): + if self._closing or not self._read_task: + raise GrblCNCConnectionError + + self._closing = True + self._read_task.cancel() + + try: + await self._read_task + except asyncio.CancelledError: + pass + finally: + del self._read_task + + self._serial.close() + + @staticmethod + def list(): + for item in serial.tools.list_ports.comports(): + yield GrblCNCInfo(port=item.device) diff --git a/unilabos/devices/cnc/grbl_sync.py b/unilabos/devices/cnc/grbl_sync.py new file mode 100644 index 00000000..b5ff716a --- /dev/null +++ b/unilabos/devices/cnc/grbl_sync.py @@ -0,0 +1,205 @@ +import os +import asyncio +from threading import Event, Lock +from enum import Enum +from dataclasses import dataclass +import re +import time +from typing import Any, Union, Optional, overload + +import serial.tools.list_ports +from serial import Serial +from serial.serialutil import SerialException + +from unilabos.messages import Point3D + + +class GrblCNCConnectionError(Exception): + pass + + +@dataclass(frozen=True, kw_only=True) +class GrblCNCInfo: + port: str + address: str = "1" + + limits: tuple[int, int, int, int, int, int] = (-150, 150, -200, 0, -80, 0) + + def create(self): + return GrblCNC(self.port, self.address, self.limits) + + +class GrblCNC: + _status: str = "Offline" + _position: Point3D = Point3D(x=0.0, y=0.0, z=0.0) + _spindle_speed: float = 0.0 + + def __init__(self, port: str, address: str = "1", limits: tuple[int, int, int, int, int, int] = (-150, 150, -200, 0, -80, 0)): + self.port = port + self.address = address + + self.limits = limits + + try: + self._serial = Serial( + baudrate=115200, + port=port + ) + except (OSError, SerialException) as e: + raise GrblCNCConnectionError from e + + self._busy = False + self._closing = False + self._pose_number = self.pose_number_remaining = -1 + + self._query_lock = Lock() + self._run_lock = Lock() + self._error_event = Event() + + def _read_all(self): + data = self._serial.read_until(b"\n") + data_decoded = data.decode() + while not "ok" in data_decoded and not "Grbl" in data_decoded: + data += self._serial.read_until(b"\n") + data_decoded = data.decode() + return data + + @overload + def _query(self, command: str, dtype: type[bool]) -> bool: + pass + + @overload + def _query(self, command: str, dtype: type[int]) -> int: + pass + + @overload + def _query(self, command: str, dtype = None) -> str: + pass + + def _query(self, command: str, dtype: Optional[type] = None): + with self._query_lock: + if self._closing or self._error_event.is_set(): + raise GrblCNCConnectionError + + self._read_extra_line = command.startswith("?") + run = '' + full_command = f"{command}{run}\n" + full_command_data = bytearray(full_command, 'ascii') + + try: + # await asyncio.to_thread(lambda: self._serial.write(full_command_data)) + self._serial.write(full_command_data) + time.sleep(0.1) + return self._receive(self._read_all()) + except (SerialException, asyncio.TimeoutError) as e: + self._error_event.set() + raise GrblCNCConnectionError from e + + def _receive(self, data: bytes): + ascii_string = "".join(chr(byte) for byte in data) + was_busy = self._busy + self._busy = "Idle" not in ascii_string + return ascii_string + + def _run(self, command: str): + with self._run_lock: + try: + self._query(command) + while True: + time.sleep(0.2) # Wait for 0.5 seconds before polling again + + status = self.get_status() + if "Idle" in status: + break + except: + self._error_event.set() + + def initialize(self): + time.sleep(0.5) + self._run("G0X0Y0Z0") + status = self.get_status() + return status + + # Operations + + # Status Queries + + @property + def status(self) -> str: + return self._status + + def get_status(self): + __pos_pattern__ = re.compile('.Pos:(\-?\d+\.\d+),(\-?\d+\.\d+),(\-?\d+\.\d+)') + __status_pattern__ = re.compile('<([a-zA-Z]+),') + + response = self._query("?") + pat = re.search(__pos_pattern__, response) + if pat is not None: + pos = pat.group().split(":")[1].split(",") + self._status = re.search(__status_pattern__, response).group(1).lstrip("<").rstrip(",") + self._position = Point3D(x=float(pos[0]), y=float(pos[1]), z=float(pos[2])) + + return self.status + + # Position Setpoint and Queries + + @property + def position(self) -> Point3D: + # 由于此时一定调用过 get_status,所以 position 一定是被更新过的 + return self._position + + def get_position(self): + return self.position + + def set_position(self, position: Point3D): + """ + Move to absolute position (unit: mm) + + Args: + x, y, z: float + + Returns: + None + """ + x = max(self.limits[0], min(self.limits[1], position.x)) + y = max(self.limits[2], min(self.limits[3], position.y)) + z = max(self.limits[4], min(self.limits[5], position.z)) + return self._run(f"G0X{x:.3f}Y{y:.3f}Z{z:.3f}") + + def move_through_points(self, positions: list[Point3D]): + for i, point in enumerate(positions): + self._pose_number = i + self.pose_number_remaining = len(positions) - i + self.set_position(point) + time.sleep(0.5) + self._pose_number = -1 + + @property + def spindle_speed(self) -> float: + return self._spindle_speed + + # def get_spindle_speed(self): + # self._spindle_speed = float(self._query("M3?")) + # return self.spindle_speed + + def set_spindle_speed(self, spindle_speed: float, max_velocity: float = 500): + if spindle_speed < 0: + spindle_speed = 0 + self._run("M5") + else: + spindle_speed = min(max_velocity, spindle_speed) + self._run(f"M3 S{spindle_speed}") + self._spindle_speed = spindle_speed + + def stop_operation(self): + return self._run("T") + + # Queries + + async def wait_error(self): + await self._error_event.wait() + + @staticmethod + def list(): + for item in serial.tools.list_ports.comports(): + yield GrblCNCInfo(port=item.device) diff --git a/unilabos/devices/cnc/mock.py b/unilabos/devices/cnc/mock.py new file mode 100644 index 00000000..b8c52f16 --- /dev/null +++ b/unilabos/devices/cnc/mock.py @@ -0,0 +1,42 @@ +import time +import asyncio +from pydantic import BaseModel + + +class Point3D(BaseModel): + x: float + y: float + z: float + + +def d(a: Point3D, b: Point3D) -> float: + return ((a.x - b.x) ** 2 + (a.y - b.y) ** 2 + (a.z - b.z) ** 2) ** 0.5 + + +class MockCNCAsync: + def __init__(self): + self._position: Point3D = Point3D(x=0.0, y=0.0, z=0.0) + self._status = "Idle" + + @property + def position(self) -> Point3D: + return self._position + + async def get_position(self): + return self.position + + @property + def status(self) -> str: + return self._status + + async def set_position(self, position: Point3D, velocity: float = 10.0): + self._status = "Running" + current_pos = self.position + + move_time = d(position, current_pos) / velocity + for i in range(20): + self._position.x = current_pos.x + (position.x - current_pos.x) / 20 * (i+1) + self._position.y = current_pos.y + (position.y - current_pos.y) / 20 * (i+1) + self._position.z = current_pos.z + (position.z - current_pos.z) / 20 * (i+1) + await asyncio.sleep(move_time / 20) + self._status = "Idle" diff --git a/unilabos/devices/gripper/__init__.py b/unilabos/devices/gripper/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/devices/gripper/mock.py b/unilabos/devices/gripper/mock.py new file mode 100644 index 00000000..cce33c1d --- /dev/null +++ b/unilabos/devices/gripper/mock.py @@ -0,0 +1,46 @@ +import time + + +class MockGripper: + def __init__(self): + self._position: float = 0.0 + self._velocity: float = 2.0 + self._torque: float = 0.0 + self._status = "Idle" + + @property + def position(self) -> float: + return self._position + + @property + def velocity(self) -> float: + return self._velocity + + @property + def torque(self) -> float: + return self._torque + + @property + def status(self) -> str: + return self._status + + def push_to(self, position: float, torque: float, velocity: float = 0.0): + self._status = "Running" + current_pos = self.position + if velocity == 0.0: + velocity = self.velocity + + move_time = abs(position - current_pos) / velocity + for i in range(20): + self._position = current_pos + (position - current_pos) / 20 * (i+1) + self._torque = torque / (20 - i) + self._velocity = velocity + time.sleep(move_time / 20) + self._torque = torque + self._status = "Idle" + + def edit_id(self, wf_name: str = "gripper_run", params: str = "{}", resource: dict = {"Gripper1": {}}): + v = list(resource.values())[0] + v["sample_id"] = "EDITED" + time.sleep(10) + return resource diff --git a/unilabos/devices/gripper/rmaxis_v4.py b/unilabos/devices/gripper/rmaxis_v4.py new file mode 100644 index 00000000..ff49ade0 --- /dev/null +++ b/unilabos/devices/gripper/rmaxis_v4.py @@ -0,0 +1,408 @@ +import time +from asyncio import Event +from enum import Enum, auto +from dataclasses import dataclass +from typing import Dict, Tuple +from pymodbus.client import ModbusSerialClient as ModbusClient +from pymodbus.client import ModbusTcpClient as ModbusTcpClient + + +class CommandType(Enum): + COMMAND_NONE = 0 + COMMAND_GO_HOME = 1 + COMMAND_DELAY = 2 + COMMAND_MOVE_ABSOLUTE = 3 + COMMAND_PUSH = 4 + COMMAND_MOVE_RELATIVE = 5 + COMMAND_PRECISE_PUSH = 6 + + +class ParamType(Enum): + BOOLEAN = 0 + INT32 = 1 + FLOAT = 2 + ENUM = 3 + + +class ParamEdit(Enum): + NORMAL = 0 + READONLY = 1 + + +@dataclass +class Param: + type: ParamType + editability: ParamEdit + address: int + +# 用于存储参数的字典类型 +ParamsDict = Dict[str, Param] + + +# Constants and other required elements can be defined as needed +IO_GAP_TIME = 0.01 +EXECUTE_COMMAND_INDEX = 15 # Example index +COMMAND_REACH_SIGNAL = "reach_15" +# Define other constants or configurations as needed + + +def REVERSE(x): + return ((x << 16) & 0xFFFF0000) | ((x >> 16) & 0x0000FFFF) + + +def int32_to_uint16_list(int32_list): + uint16_list = [] + for num in int32_list: + lower_half = num & 0xFFFF + upper_half = (num >> 16) & 0xFFFF + uint16_list.extend([upper_half, lower_half]) + return uint16_list + + +def uint16_list_to_int32_list(uint16_list): + if len(uint16_list) % 2 != 0: + raise ValueError("Input list must have even number of uint16 elements.") + int32_list = [] + for i in range(0, len(uint16_list), 2): + # Combine two uint16 values into one int32 value + high = uint16_list[i + 1] + low = uint16_list[i] + # Assuming the uint16_list is in big-endian order + int32_value = (high << 16) | low + int32_list.append(int(int32_value)) + return int32_list + + +class RMAxis: + modbus_device = {} + + def __init__(self, port, is_modbus_rtu, baudrate: int = 115200, address: str = "", slave_id: int = 1): + self.device = port + self.is_modbus_rtu = is_modbus_rtu + if is_modbus_rtu: + self.client = ModbusClient(port=port, baudrate=baudrate, parity='N', stopbits=1, bytesize=8, timeout=3) + else: + self.client = ModbusTcpClient(address, port) + if not self.client.connect(): + raise Exception(f"Modbus Connection failed") + self.slave_id = slave_id + + self._error_event = Event() + self.device_params = {} # Assuming some initialization for parameters + self.command_edited = {} + self.init_parameters(self.device_params) + + def init_parameters(self, params): + params["current_command_position"] = Param(ParamType.FLOAT, ParamEdit.NORMAL, 4902) + params["current_command_velocity"] = Param(ParamType.FLOAT, ParamEdit.NORMAL, 4904) + params["current_command_acceleration"] = Param(ParamType.FLOAT, ParamEdit.NORMAL, 4906) + params["current_command_deacceleration"] = Param(ParamType.FLOAT, ParamEdit.NORMAL, 4908) + params["current_position"] = Param(ParamType.FLOAT, ParamEdit.READONLY, 0) + params["current_velocity"] = Param(ParamType.FLOAT, ParamEdit.READONLY, 2) + params["control_torque"] = Param(ParamType.FLOAT, ParamEdit.NORMAL, 4) + params["error"] = Param(ParamType.INT32, ParamEdit.READONLY, 6) + params["current_force_sensor"] = Param(ParamType.FLOAT, ParamEdit.READONLY, 18) + params["io_in_go_home"] = Param(ParamType.BOOLEAN, ParamEdit.NORMAL, 1401) + params["io_in_error_reset"] = Param(ParamType.BOOLEAN, ParamEdit.NORMAL, 1402) + params["io_in_start"] = Param(ParamType.BOOLEAN, ParamEdit.NORMAL, 1403) + params["io_in_servo"] = Param(ParamType.BOOLEAN, ParamEdit.NORMAL, 1404) + params["io_in_stop"] = Param(ParamType.BOOLEAN, ParamEdit.NORMAL, 1405) + params["io_in_force_reset"] = Param(ParamType.BOOLEAN, ParamEdit.NORMAL, 1424) + params["io_in_save_parameters"] = Param(ParamType.BOOLEAN, ParamEdit.NORMAL, 1440) + params["io_in_load_parameters"] = Param(ParamType.BOOLEAN, ParamEdit.NORMAL, 1441) + params["io_in_save_positions"] = Param(ParamType.BOOLEAN, ParamEdit.NORMAL, 1442) + params["io_in_load_positions"] = Param(ParamType.BOOLEAN, ParamEdit.NORMAL, 1443) + params["io_out_gone_home"] = Param(ParamType.BOOLEAN, ParamEdit.NORMAL, 1501) + params["command_address"] = Param(ParamType.INT32, ParamEdit.NORMAL, 5000) + params["selected_command_index"] = Param(ParamType.INT32, ParamEdit.NORMAL, 4001) + params["io_out_reach_15"] = Param(ParamType.BOOLEAN, ParamEdit.NORMAL, 1521) + params["io_out_moving"] = Param(ParamType.BOOLEAN, ParamEdit.NORMAL, 1505) + params["limit_pos"] = Param(ParamType.FLOAT, ParamEdit.NORMAL, 74) + params["limit_neg"] = Param(ParamType.FLOAT, ParamEdit.NORMAL, 72) + params["hardware_direct_output"] = Param(ParamType.INT32, ParamEdit.NORMAL, 158) + params["hardware_direct_input"] = Param(ParamType.INT32, ParamEdit.NORMAL, 160) + + def get_version(self): + version_major = self.client.read_input_registers(8, 1, unit=self.slave_id).registers[0] + version_minor = self.client.read_input_registers(10, 1, unit=self.slave_id).registers[0] + version_build = self.client.read_input_registers(12, 1, unit=self.slave_id).registers[0] + version_type = self.client.read_input_registers(14, 1, unit=self.slave_id).registers[0] + return (version_major, version_minor, version_build, version_type) + + def set_input_signal(self, signal, level): + param_name = f"io_in_{signal}" + self.set_parameter(param_name, level) + + def get_output_signal(self, signal): + param_name = f"io_out_{signal}" + return self.get_device_parameter(param_name) + + def config_motion(self, velocity, acceleration, deacceleration): + self.set_parameter("current_command_velocity", velocity) + self.set_parameter("current_command_acceleration", acceleration) + self.set_parameter("current_command_deacceleration", deacceleration) + + def move_to(self, position): + self.set_parameter("current_command_position", position) + + def go_home(self): + self.set_input_signal("go_home", False) + time.sleep(IO_GAP_TIME) # Assuming IO_GAP_TIME is 0.1 seconds + self.set_input_signal("go_home", True) + + def move_absolute(self, position, velocity, acceleration, deacceleration, band): + command = { + 'type': CommandType.COMMAND_MOVE_ABSOLUTE.value, + 'position': position, + 'velocity': velocity, + 'acceleration': acceleration, + 'deacceleration': deacceleration, + 'band': band, + 'push_force': 0, + 'push_distance': 0, + 'delay': 0, + 'next_command_index': -1 + } + self.execute_command(command) + + def move_relative(self, position, velocity, acceleration, deacceleration, band): + command = { + 'type': CommandType.COMMAND_MOVE_RELATIVE.value, + 'position': position, + 'velocity': velocity, + 'acceleration': acceleration, + 'deacceleration': deacceleration, + 'band': band, + 'push_force': 0, + 'push_distance': 0, + 'delay': 0, + 'next_command_index': -1 + } + self.execute_command(command) + + def push(self, force, distance, velocity): + command = { + 'type': CommandType.COMMAND_PUSH.value, + 'position': 0, + 'velocity': velocity, + 'acceleration': 0, + 'deacceleration': 0, + 'band': 0, + 'push_force': force, + 'push_distance': distance, + 'delay': 0, + 'next_command_index': -1 + } + self.execute_command(command) + + def precise_push(self, force, distance, velocity, force_band, force_check_time): + command = { + 'type': CommandType.COMMAND_PRECISE_PUSH.value, + 'position': 0, + 'velocity': velocity, + 'acceleration': 0, + 'deacceleration': 0, + 'band': force_band, + 'push_force': force, + 'push_distance': distance, + 'delay': force_check_time, + 'next_command_index': -1 + } + self.execute_command(command) + + def is_moving(self): + return self.get_output_signal("moving") + + def is_reached(self): + return self.get_output_signal(COMMAND_REACH_SIGNAL) + + def is_push_empty(self): + return not self.is_moving() + + def set_command(self, index, command): + print("Setting command", command) + self.command_edited[index] = True + command_buffer = [ + command['type'], + int(command['position'] * 1000), + int(command['velocity'] * 1000), + int(command['acceleration'] * 1000), + int(command['deacceleration'] * 1000), + int(command['band'] * 1000), + int(command['push_force'] * 1000), + int(command['push_distance'] * 1000), + int(command['delay']), + int(command['next_command_index']) + ] + buffer = int32_to_uint16_list(command_buffer) + response = self.client.write_registers(self.device_params["command_address"].address + index * 20, buffer, self.slave_id) + + def get_command(self, index): + response = self.client.read_holding_registers(self.device_params["command_address"].address + index * 20, 20, self.slave_id) + print(response) + buffer = response.registers + command_buffer = uint16_list_to_int32_list(buffer) + command = { + 'type': command_buffer[0], + 'position': command_buffer[1] / 1000.0, + 'velocity': command_buffer[2] / 1000.0, + 'acceleration': command_buffer[3] / 1000.0, + 'deacceleration': command_buffer[4] / 1000.0, + 'band': command_buffer[5] / 1000.0, + 'push_force': command_buffer[6] / 1000.0, + 'push_distance': command_buffer[7] / 1000.0, + 'delay': command_buffer[8], + 'next_command_index': command_buffer[9] + } + return command + + def execute_command(self, command): + self.set_command(EXECUTE_COMMAND_INDEX, command) + self.save_commands() + self.trig_command(EXECUTE_COMMAND_INDEX) + time.sleep(IO_GAP_TIME) # Assuming IO_GAP_TIME is 0.1 seconds + + def trig_command(self, index): + print("Triggering command", index) + self.set_parameter("selected_command_index", index) + self.set_input_signal("start", False) + time.sleep(IO_GAP_TIME) # Assuming IO_GAP_TIME is 0.1 seconds + self.set_input_signal("start", True) + + def load_commands(self): + self.set_input_signal("load_positions", False) + time.sleep(IO_GAP_TIME) # Assuming IO_GAP_TIME is 0.1 seconds + self.set_input_signal("load_positions", True) + + def save_commands(self): + for index, edited in self.command_edited.items(): + if edited: + self.set_parameter("selected_command_index", index) + self.set_input_signal("save_positions", False) + time.sleep(IO_GAP_TIME) # Assuming IO_GAP_TIME is 0.1 seconds + self.set_input_signal("save_positions", True) + time.sleep(IO_GAP_TIME) # Assuming IO_GAP_TIME is 0.1 seconds + self.command_edited[index] = False + + @property + def position(self) -> float: + return self.get_device_parameter("current_position") + + def get_position(self) -> float: + return self.get_device_parameter("current_position") + + @property + def velocity(self) -> float: + return self.get_device_parameter("current_velocity") + + @property + def torque(self) -> float: + return self.get_device_parameter("control_torque") + + @property + def force_sensor(self) -> float: + return self.get_device_parameter("current_force_sensor") + + def error_code(self): + return self.get_device_parameter("error") + + def get_device_parameter(self, name): + # Assuming self.device_params is a dictionary with address and type + param = self.device_params.get(name) + if not param: + self._error_event.set() + raise Exception(f"parameter {name} does not exist") + + address = param.address + if param.editability == ParamEdit.READONLY: + if param.type == ParamType.BOOLEAN: + return self.client.read_input_discretes(address, 1).bits[0] + elif param.type == ParamType.ENUM: + return self.client.read_input_registers(address, 1).registers[0] + elif param.type == ParamType.INT32: + return self.client.read_input_registers(address, 2).registers[0] # Handle as needed + elif param.type == ParamType.FLOAT: + return self.client.read_input_registers(address, 2).registers[0] # Handle as needed + else: + self._error_event.set() + raise Exception(f"parameter {name} has unknown data type {param.type}") + else: + if param.type == ParamType.BOOLEAN: + return self.client.read_holding_registers(address, 1).registers[0] + elif param.type == ParamType.ENUM: + return self.client.read_holding_registers(address, 1).registers[0] + elif param.type == ParamType.INT32: + return self.client.read_holding_registers(address, 2).registers[0] # Handle as needed + elif param.type == ParamType.FLOAT: + return self.client.read_holding_registers(address, 2).registers[0] # Handle as needed + else: + self._error_event.set() + raise Exception(f"parameter {name} has unknown data type {param['type']}") + + def set_parameter(self, name, value): + param = self.device_params.get(name) + if not param: + self._error_event.set() + raise Exception(f"parameter {name} does not exist") + + address = param.address + if param.editability == ParamEdit.READONLY: + raise Exception(f"parameter {name} is read only") + else: + if param.type == ParamType.BOOLEAN: + self.client.write_coil(address, bool(value)) + elif param.type == ParamType.ENUM: + self.client.write_register(address, value) + elif param.type == ParamType.INT32: + self.client.write_register(address, int(value)) + elif param.type == ParamType.FLOAT: + self.client.write_register(address, float(value)) + else: + self._error_event.set() + raise Exception(f"parameter {name} has unknown data type {param['type']}") + + def load_parameters(self): + self.set_input_signal("load_parameters", False) + time.sleep(IO_GAP_TIME) # Assuming IO_GAP_TIME is 0.1 seconds + self.set_input_signal("load_parameters", True) + + def save_parameters(self): + self.set_input_signal("save_parameters", False) + time.sleep(IO_GAP_TIME) # Assuming IO_GAP_TIME is 0.1 seconds + self.set_input_signal("save_parameters", True) + + def reset_error(self): + self.set_input_signal("error_reset", False) + time.sleep(IO_GAP_TIME) # Assuming IO_GAP_TIME is 0.1 seconds + self.set_input_signal("error_reset", True) + + def set_servo_on_off(self, on_off): + self.set_input_signal("servo", on_off) + + def stop(self): + self.set_input_signal("stop", False) + time.sleep(IO_GAP_TIME) # Assuming IO_GAP_TIME is 0.1 seconds + self.set_input_signal("stop", True) + + def soft_reset(self): + self.client.write_register(186, 0x22205682) + + async def wait_error(self): + await self._error_event.wait() + + def close(self): + self.client.close() + del self.client + + +if __name__ == "__main__": + # gripper = RMAxis.create_rmaxis_modbus_rtu("COM7", 115200, 1) + gripper = RMAxis.create_rmaxis_modbus_rtu('/dev/tty.usbserial-B002YGXY', 115200, 0) + gripper.go_home() + # gripper.move_to(20) + # print("Moving abs...") + # gripper.move_absolute(20, 5, 100, 100, 0.1) + # print(gripper.get_command(EXECUTE_COMMAND_INDEX)) + # gripper.go_home() + # print("Pushing...") + # gripper.push(0.7, 10, 20) diff --git a/unilabos/devices/heaterstirrer/__init__.py b/unilabos/devices/heaterstirrer/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/devices/heaterstirrer/dalong.py b/unilabos/devices/heaterstirrer/dalong.py new file mode 100644 index 00000000..8232753a --- /dev/null +++ b/unilabos/devices/heaterstirrer/dalong.py @@ -0,0 +1,197 @@ +import json +import serial +import time as systime + + +class HeaterStirrer_DaLong: + def __init__(self, port: str = 'COM6', temp_warning = 50.0, baudrate: int = 9600): + try: + self.serial = serial.Serial(port, baudrate, timeout=2) + except serial.SerialException as e: + print("串口错误", f"无法打开串口{port}: {e}") + self._status = "Idle" + self._stir_speed = 0.0 + self._temp = 0.0 + self._temp_warning = temp_warning + self.set_temp_warning(temp_warning) + self._temp_target = 20.0 + self.success = False + + @property + def status(self) -> str: + return self._status + + def get_status(self) -> str: + self._status = "Idle" if self.stir_speed == 0 else "Running" + + @property + def stir_speed(self) -> float: + return self._stir_speed + + def set_stir_speed(self, speed: float): + try: + # 转换速度为整数 + speed_int = int(speed) + # 确保速度在允许的范围内 + if speed_int < 0 or speed_int > 65535: + raise ValueError("速度必须在0到65535之间") + except ValueError as e: + print("输入错误", str(e)) + return + + # 计算高位和低位 + speed_high = speed_int >> 8 + speed_low = speed_int & 0xFF + + # 构建搅拌控制指令 + command = bytearray([0xfe, 0xB1, speed_high, speed_low, 0x00]) + # 计算校验和 + command.append(sum(command[1:]) % 256) + + # 发送指令 + self.serial.write(command) + # 检查响应 + response = self.serial.read(6) + if len(response) == 6 and response[0] == 0xfd and response[1] == 0xB1 and response[2] == 0x00: + print("成功", "搅拌速度更新成功") + self._stir_speed = speed + else: + print("失败", "搅拌速度更新失败") + + def heatchill( + self, + vessel: str, + temp: float, + time: float = 3600, + stir: bool = True, + stir_speed: float = 300, + purpose: str = "reaction" + ): + self.set_temp_target(temp) + if stir: + self.set_stir_speed(stir_speed) + self.status = "Stirring" + systime.sleep(time) + self.set_stir_speed(0) + self.status = "Idle" + + @property + def temp(self) -> float: + self._temp = self.get_temp() + return self._temp + + def get_temp(self): + # 构建读取温度的指令 + command = bytearray([0xfe, 0xA2, 0x00, 0x00, 0x00]) + command.append(sum(command[1:]) % 256) + + # 发送指令 + self.serial.write(command) + # 读取响应 + systime.sleep(0.1) + num_bytes = self.serial.in_waiting + response = self.serial.read(num_bytes) + try: + high_value = response[8] + low_value = response[9] + raw_temp = (high_value << 8) + low_value + if raw_temp & 0x8000: # 如果低位寄存器最高位为1,表示负值 + raw_temp -= 0x10000 # 转换为正确的负数表示 + temp = raw_temp / 10 + return temp + except: + return None + + @property + def temp_warning(self) -> float: + return self._temp_warning + + def set_temp_warning(self, temp): + self.success = False + # temp = round(float(warning_temp), 1) + if self.set_temp_inner(float(temp), "warning"): + self._temp_warning = round(float(temp), 1) + self.success = True + + @property + def temp_target(self) -> float: + return self._temp_target + + def set_temp_target(self, temp): + self.success = False + # temp = round(float(target_temp), 1) + if self.set_temp_inner(float(temp), "target"): + self._temp_target = round(float(temp), 1) + self.success = True + + def set_temp_inner(self, temp: float, type: str = "warning"): + try: + # 转换为整数 + temp_int = int(temp*10) + except ValueError as e: + print("输入错误", str(e)) + return + + # 计算高位和低位 + temp_high = temp_int >> 8 + temp_low = temp_int & 0xFF + + # 构建控制指令 + if type == "warning": + command = bytearray([0xfe, 0xB4, temp_high, temp_low, 0x00]) + elif type == "target": + command = bytearray([0xfe, 0xB2, temp_high, temp_low, 0x00]) + else: + return False + # 计算校验和 + command.append(sum(command[1:]) % 256) + print(command) + # 发送指令 + self.serial.write(command) + # 检查响应 + systime.sleep(0.1) + response = self.serial.read(6) + print(response) + if len(response) == 6 and response[0] == 0xfd and response[1] == 0xB4 and response[2] == 0x00: + print("成功", "安全温度设置成功") + return True + else: + print("失败", "安全温度设置失败") + return False + + def close(self): + self.serial.close() + + +if __name__ == "__main__": + import tkinter as tk + from tkinter import messagebox + + heaterstirrer = HeaterStirrer_DaLong() + # heaterstirrer.set_mix_speed(0) + heaterstirrer.get_temp() + # heaterstirrer.set_warning(17) + print(heaterstirrer.temp) + print(heaterstirrer.temp_warning) + + # 创建主窗口 + # root = tk.Tk() + # root.title("搅拌速度控制") + + # # 创建速度变量 + # speed_var = tk.StringVar() + + # # 创建输入框 + # speed_entry = tk.Entry(root, textvariable=speed_var) + # speed_entry.pack(pady=10) + + # # 创建按钮 + # set_speed_button = tk.Button(root, text="确定", command=heaterstirrer.set_mix_speed) + # # set_speed_button = tk.Button(root, text="确定", command=heaterstirrer.read_temp) + # set_speed_button.pack(pady=5) + + # # 运行主事件循环 + # root.mainloop() + + # 关闭串口 + heaterstirrer.serial.close() diff --git a/unilabos/devices/hplc/AgilentHPLC.py b/unilabos/devices/hplc/AgilentHPLC.py new file mode 100644 index 00000000..d47c80d9 --- /dev/null +++ b/unilabos/devices/hplc/AgilentHPLC.py @@ -0,0 +1,470 @@ +import traceback +from datetime import datetime +import os +import re +from typing import TypedDict + +import pyautogui +from pywinauto import Application +from pywinauto.application import WindowSpecification +from pywinauto.controls.uiawrapper import UIAWrapper +from pywinauto.uia_element_info import UIAElementInfo + +from unilabos.app.oss_upload import oss_upload +from unilabos.device_comms import universal_driver as ud +from unilabos.device_comms.universal_driver import UniversalDriver + + +class DeviceStatusInfo(TypedDict): + name: str + name_obj: UIAWrapper + status: str + status_obj: UIAWrapper + open_btn: UIAWrapper + close_btn: UIAWrapper + sub_item: UIAWrapper + +class DeviceStatus(TypedDict): + VWD: DeviceStatusInfo + JinYangQi: DeviceStatusInfo + Beng: DeviceStatusInfo + ShouJiQi: DeviceStatusInfo + + +class HPLCDriver(UniversalDriver): + # 设备状态 + _device_status: DeviceStatus = None + _is_running: bool = False + _success: bool = False + _finished: int = None + _total_sample_number: int = None + _status_text: str = "" + # 外部传入 + _wf_name: str = "" + # 暂时用不到,用来支持action name + gantt: str = "" + status: str = "" + + @property + def status_text(self) -> str: + return self._status_text + + @property + def device_status(self) -> str: + return f", ".join([f"{k}:{v.get('status')}" for k, v in self._device_status.items()]) + + @property + def could_run(self) -> bool: + return self.driver_init_ok and all([v.get('status') == "空闲" for v in self._device_status.values()]) + + @property + def driver_init_ok(self) -> bool: + for k, v in self._device_status.items(): + if v.get("open_btn") is None: + return False + if v.get("close_btn") is None: + return False + return len(self._device_status) == 4 + + @property + def is_running(self) -> bool: + return self._is_running + + @property + def success(self) -> bool: + return self._success + + @property + def finish_status(self) -> str: + return f"{self._finished}/{self._total_sample_number}" + + def try_open_sub_device(self, device_name: str = None): + if not self.driver_init_ok: + self._success = False + print(f"仪器还没有初始化完成,无法查询设备:{device_name}") + return + if device_name is None: + for k, v in self._device_status.items(): + self.try_open_sub_device(k) + return + target_device_status = self._device_status[device_name] + if target_device_status["status"] == "未就绪": + print(f"尝试打开{device_name}设备") + target_device_status["open_btn"].click() + else: + print(f"{device_name}设备状态不支持打开:{target_device_status['status']}") + + def try_close_sub_device(self, device_name: str = None): + if not self.driver_init_ok: + self._success = False + print(f"仪器还没有初始化完成,无法查询设备:{device_name}") + return + if device_name is None: + for k, v in self._device_status.items(): + self.try_close_sub_device(k) + return + target_device_status = self._device_status[device_name] + if target_device_status["status"] == "空闲": + print(f"尝试关闭{device_name}设备") + target_device_status["close_btn"].click() + else: + print(f"{device_name}设备状态不支持关闭:{target_device_status['status']}") + + def _get_resource_sample_id(self, wf_name, idx): + try: + root = list(self.resource_info[wf_name].values())[0] + # print(root) + plates = root["children"] + plate_01 = list(plates.values())[0] + pots = list(plate_01["children"].values()) + return pots[idx]['sample_id'] + except Exception as ex: + traceback.print_exc() + + def start_sequence(self, wf_name: str, params: str = None, resource: dict = None): + print("!!!!!! 任务启动") + self.resource_info[wf_name] = resource + # 后续workflow_name将同步一下 + if self.is_running: + print("设备正在运行,无法启动序列") + self._success = False + return False + if not self.driver_init_ok: + print(f"仪器还没有初始化完成,无法启动序列") + self._success = False + return False + if not self.could_run: + print(f"仪器不处于空闲状态,无法运行") + self._success = False + return False + # 参考: + # with UIPath(u"PREP-LC (联机): 方法和运行控制 ||Window"): + # with UIPath(u"panelNavTabChem||Pane->||Pane->panelControlChemStation||Pane->||Tab->仪器控制||Pane->||Pane->panelChemStation||Pane->PREP-LC (联机): 方法和运行控制 ||Pane->ViewMGR||Pane->MRC view||Pane->||Pane->||Pane->||Pane->||Custom->||Custom"): + # click(u"||Button#[0,1]") + app = Application(backend='uia').connect(title=u"PREP-LC (联机): 方法和运行控制 ") + window = app['PREP-LC (联机): 方法和运行控制'] + window.allow_magic_lookup = False + panel_nav_tab = window.child_window(title="panelNavTabChem", auto_id="panelNavTabChem", control_type="Pane") + first_pane = panel_nav_tab.child_window(auto_id="uctlNavTabChem1", control_type="Pane") + panel_control_station = first_pane.child_window(title="panelControlChemStation", auto_id="panelControlChemStation", control_type="Pane") + instrument_control_tab: WindowSpecification = panel_control_station.\ + child_window(auto_id="tabControlChem", control_type="Tab").\ + child_window(title="仪器控制", auto_id="tabPage1", control_type="Pane").\ + child_window(auto_id="uctrlChemStation", control_type="Pane").\ + child_window(title="panelChemStation", auto_id="panelChemStation", control_type="Pane").\ + child_window(title="PREP-LC (联机): 方法和运行控制 ", control_type="Pane").\ + child_window(title="ViewMGR", control_type="Pane").\ + child_window(title="MRC view", control_type="Pane").\ + child_window(auto_id="mainMrcControlHost", control_type="Pane").\ + child_window(control_type="Pane", found_index=0).\ + child_window(control_type="Pane", found_index=0).\ + child_window(control_type="Custom", found_index=0).\ + child_window(control_type="Custom", found_index=0) + instrument_control_tab.dump_tree(3) + btn: UIAWrapper = instrument_control_tab.child_window(auto_id="methodButtonStartSequence", control_type="Button").wrapper_object() + self.start_time = datetime.now() + btn.click() + self._wf_name = wf_name + self._success = True + return True + + def check_status(self): + app = Application(backend='uia').connect(title=u"PREP-LC (联机): 方法和运行控制 ") + window = app['PREP-LC (联机): 方法和运行控制'] + ui_window = window.child_window(title="靠顶部", control_type="Group").\ + child_window(title="状态", control_type="ToolBar").\ + child_window(title="项目", control_type="Button", found_index=0) + # 检测pixel的颜色 + element_info: UIAElementInfo = ui_window.element_info + rectangle = element_info.rectangle + point_x = int(rectangle.left + rectangle.width() * 0.15) + point_y = int(rectangle.top + rectangle.height() * 0.15) + r, g, b = pyautogui.pixel(point_x, point_y) + if 270 > r > 250 and 200 > g > 180 and b < 10: # 是黄色 + self._is_running = False + self._status_text = "Not Ready" + elif r > 110 and g > 190 and 50 < b < 60: + self._is_running = False + self._status_text = "Ready" + elif 75 > r > 65 and 135 > g > 120 and 240 > b > 230: + self._is_running = True + self._status_text = "Running" + else: + print(point_x, point_y, "未知的状态", r, g, b) + + def extract_data_from_txt(self, file_path): + # 打开文件 + print(file_path) + with open(file_path, mode='r', encoding='utf-16') as f: + lines = f.readlines() + # 定义一个标志变量来判断是否已经找到“馏分列表” + started = False + data = [] + + for line in lines: + # 查找“馏分列表”,并开始提取后续行 + if line.startswith("-----|-----|-----"): + started = True + continue # 跳过当前行 + if started: + # 遇到"==="表示结束读取 + if '=' * 80 in line: + break + # 使用正则表达式提取馏分、孔、位置和原因 + res = re.split(r'\s+', line.strip()) + if res: + fraction, hole, position, reason = res[0], res[1], res[2], res[-1] + data.append({ + '馏分': fraction, + '孔': hole, + '位置': position, + '原因': reason.strip() + }) + + return data + + def get_data_file(self, mat_index: str = None, after_time: datetime = None) -> tuple[str, str]: + """ + 获取数据文件 + after_time: 由于HPLC是启动后生成一个带时间的目录,所以会选取after_time后的文件 + """ + if mat_index is None: + print(f"mat_index不能为空") + return None + if after_time is None: + after_time = self.start_time + files = [i for i in os.listdir(self.data_file_path) if i.startswith(self.using_method)] + time_to_files: list[tuple[datetime, str]] = [(datetime.strptime(i.split(" ", 1)[1], "%Y-%m-%d %H-%M-%S"), i) for i in files] + time_to_files.sort(key=lambda x: x[0]) + choose_folder = None + for i in time_to_files: + if i[0] > after_time: + print(i[0], after_time) + print(f"选取时间{datetime.strftime(after_time, '%Y-%m-%d %H-%M-%S')}之后的文件夹{i[1]}") + choose_folder = i[1] + break + if choose_folder is None: + print(f"没有找到{self.using_method} {datetime.strftime(after_time, '%Y-%m-%d %H-%M-%S')}之后的文件夹") + return None + current_data_path = os.path.join(self.data_file_path, choose_folder) + + # 需要匹配 数字数字数字-.* 001-P2-E1-DQJ-4-70.D + target_row = [i for i in os.listdir(current_data_path) if re.match(r"\d{3}-.*", i)] + index2filepath = {int(k.split("-")[0]): os.path.join(current_data_path, k) for k in target_row} + print(f"查找文件{mat_index}") + if int(mat_index) not in index2filepath: + print(f"没有找到{mat_index}的文件 已找到:{index2filepath}") + return None + mat_final_path = index2filepath[int(mat_index)] + pdf = os.path.join(mat_final_path, "Report.PDF") + txt = os.path.join(mat_final_path, "Report.TXT") + fractions = self.extract_data_from_txt(txt) + print(fractions) + return pdf, txt + + def __init__(self, driver_debug=False): + super().__init__() + self.data_file_path = r"D:\ChemStation\1\Data" + self.using_method = f"1106-dqj-4-64" + self.start_time = datetime.now() + self._device_status = dict() + self.resource_info: dict[str, dict] = dict() + # 启动所有监控器 + self.checkers = [ + InstrumentChecker(self, 1), + RunningChecker(self, 1), + RunningResultChecker(self, 1), + ] + if not driver_debug: + for checker in self.checkers: + checker.start_monitoring() + + +class DriverChecker(ud.DriverChecker): + driver: HPLCDriver + +class InstrumentChecker(DriverChecker): + _instrument_control_tab = None + _instrument_control_tab_wrapper = None + def get_instrument_status(self): + if self._instrument_control_tab is not None: + return self._instrument_control_tab + # 连接到目标窗口 + app = Application(backend='uia').connect(title=u"PREP-LC (联机): 方法和运行控制 ") + window = app['PREP-LC (联机): 方法和运行控制'] + window.allow_magic_lookup = False + panel_nav_tab = window.child_window(title="panelNavTabChem", auto_id="panelNavTabChem", control_type="Pane") + first_pane = panel_nav_tab.child_window(auto_id="uctlNavTabChem1", control_type="Pane") + panel_control_station = first_pane.child_window(title="panelControlChemStation", auto_id="panelControlChemStation", control_type="Pane") + instrument_control_tab: WindowSpecification = panel_control_station.\ + child_window(auto_id="tabControlChem", control_type="Tab").\ + child_window(title="仪器控制", auto_id="tabPage1", control_type="Pane").\ + child_window(auto_id="uctrlChemStation", control_type="Pane").\ + child_window(title="panelChemStation", auto_id="panelChemStation", control_type="Pane").\ + child_window(title="PREP-LC (联机): 方法和运行控制 ", control_type="Pane").\ + child_window(title="ViewMGR", control_type="Pane").\ + child_window(title="MRC view", control_type="Pane").\ + child_window(auto_id="mainMrcControlHost", control_type="Pane").\ + child_window(control_type="Pane", found_index=0).\ + child_window(control_type="Pane", found_index=0).\ + child_window(control_type="Custom", found_index=0).\ + child_window(best_match="Custom6").\ + child_window(auto_id="ListBox_DashboardPanel", control_type="List") + if self._instrument_control_tab is None: + self._instrument_control_tab = instrument_control_tab + self._instrument_control_tab_wrapper = instrument_control_tab.wrapper_object() + return self._instrument_control_tab + + + def check(self): + self.get_instrument_status() + if self._instrument_control_tab_wrapper is None or self._instrument_control_tab is None: + return + item: UIAWrapper + index = 0 + keys = list(self.driver._device_status.keys()) + for item in self._instrument_control_tab_wrapper.children(): + info: UIAElementInfo = item.element_info + if info.control_type == "ListItem" and item.window_text() == "Agilent.RapidControl.StatusDashboard.PluginViewModel": + sub_item: WindowSpecification = self._instrument_control_tab.\ + child_window(title="Agilent.RapidControl.StatusDashboard.PluginViewModel", control_type="ListItem", found_index=index).\ + child_window(control_type="Custom", found_index=0) + if index < len(keys): + deviceStatusInfo = self.driver._device_status[keys[index]] + name = deviceStatusInfo["name"] + deviceStatusInfo["status"] = deviceStatusInfo["status_obj"].window_text() + print(name, index, deviceStatusInfo["status"], "刷新") + if deviceStatusInfo["open_btn"] is not None and deviceStatusInfo["close_btn"] is not None: + index += 1 + continue + else: + name_obj = sub_item.child_window(control_type="Text", found_index=0).wrapper_object() + name = name_obj.window_text() + self.driver._device_status[name] = dict() + self.driver._device_status[name]["name_obj"] = name_obj + self.driver._device_status[name]["name"] = name + print(name, index) + status = sub_item.child_window(control_type="Custom", found_index=0).\ + child_window(auto_id="TextBlock_StateLabel", control_type="Text") + status_obj: UIAWrapper = status.wrapper_object() + self.driver._device_status[name]["status_obj"] = status_obj + self.driver._device_status[name]["status"] = status_obj.window_text() + print(status.window_text()) + sub_item = sub_item.wrapper_object() + found_index = 0 + open_btn = None + close_btn = None + for btn in sub_item.children(): + if btn.element_info.control_type == "Button": + found_index += 1 + if found_index == 5: + open_btn = btn + elif found_index == 6: + close_btn = btn + self.driver._device_status[name]["open_btn"] = open_btn + self.driver._device_status[name]["close_btn"] = close_btn + index += 1 + +class RunningChecker(DriverChecker): + def check(self): + self.driver.check_status() + +class RunningResultChecker(DriverChecker): + _finished: UIAWrapper = None + _total_sample_number: UIAWrapper = None + + def check(self): + if self._finished is None or self._total_sample_number is None: + app = Application(backend='uia').connect(title=u"PREP-LC (联机): 方法和运行控制 ") + window = app['PREP-LC (联机): 方法和运行控制'] + window.allow_magic_lookup = False + panel_nav_tab = window.child_window(title="panelNavTabChem", auto_id="panelNavTabChem", control_type="Pane") + first_pane = panel_nav_tab.child_window(auto_id="uctlNavTabChem1", control_type="Pane") + panel_control_station = first_pane.child_window(title="panelControlChemStation", auto_id="panelControlChemStation", control_type="Pane") + instrument_control_tab: WindowSpecification = panel_control_station.\ + child_window(auto_id="tabControlChem", control_type="Tab").\ + child_window(title="仪器控制", auto_id="tabPage1", control_type="Pane").\ + child_window(auto_id="uctrlChemStation", control_type="Pane").\ + child_window(title="panelChemStation", auto_id="panelChemStation", control_type="Pane").\ + child_window(title="PREP-LC (联机): 方法和运行控制 ", control_type="Pane").\ + child_window(title="ViewMGR", control_type="Pane").\ + child_window(title="MRC view", control_type="Pane").\ + child_window(auto_id="mainMrcControlHost", control_type="Pane").\ + child_window(control_type="Pane", found_index=0).\ + child_window(control_type="Pane", found_index=0).\ + child_window(control_type="Custom", found_index=0).\ + child_window(auto_id="mainControlExpanderSampleInformation", control_type="Group").\ + child_window(auto_id="controlsSampleInfo", control_type="Custom") + self._finished = instrument_control_tab.child_window(best_match="Static15").wrapper_object() + self._total_sample_number = instrument_control_tab.child_window(best_match="Static16").wrapper_object() + try: + temp = int(self._finished.window_text()) + if self.driver._finished is None or temp > self.driver._finished: + if self.driver._finished is None: + self.driver._finished = 0 + for i in range(self.driver._finished, temp): + sample_id = self.driver._get_resource_sample_id(self.driver._wf_name, i) # 从0开始计数 + pdf, txt = self.driver.get_data_file(i + 1) + device_id = self.driver.device_id if hasattr(self.driver, "device_id") else "default" + oss_upload(pdf, f"hplc/{sample_id}/{os.path.basename(pdf)}", process_key="example", device_id=device_id) + oss_upload(txt, f"hplc/{sample_id}/{os.path.basename(txt)}", process_key="HPLC-txt-result", device_id=device_id) + # self.driver.extract_data_from_txt() + except Exception as ex: + self.driver._finished = 0 + + print("转换数字出错", ex) + try: + self.driver._total_sample_number = int(self._total_sample_number.window_text()) + except Exception as ex: + self.driver._total_sample_number = 0 + print("转换数字出错", ex) + + + + +# 示例用法 +if __name__ == "__main__": + # obj = HPLCDriver.__new__(HPLCDriver) + # obj.start_sequence() + + # obj = HPLCDriver.__new__(HPLCDriver) + # obj.data_file_path = r"D:\ChemStation\1\Data" + # obj.using_method = r"1106-dqj-4-64" + # obj.get_data_file("001", after_time=datetime(2024, 11, 6, 19, 3, 6)) + + obj = HPLCDriver.__new__(HPLCDriver) + obj.data_file_path = r"D:\ChemStation\1\Data" + obj.using_method = r"1106-dqj-4-64" + obj._wf_name = "test" + obj.resource_info = { + "test": { + "1": { + "children": { + "11": { + "children": { + "111": { + "sample_id": "sample-1" + }, + "112": { + "sample_id": "sample-2" + } + } + } + } + } + } + } + sample_id = obj._get_resource_sample_id("test", 0) + pdf, txt = obj.get_data_file("1", after_time=datetime(2024, 11, 6, 19, 3, 6)) + oss_upload(pdf, f"hplc/{sample_id}/{os.path.basename(pdf)}", process_key="example") + oss_upload(txt, f"hplc/{sample_id}/{os.path.basename(txt)}", process_key="HPLC-txt-result") + # driver = HPLCDriver() + # for i in range(10000): + # print({k: v for k, v in driver._device_status.items() if isinstance(v, str)}) + # print(driver.device_status) + # print(driver.could_run) + # print(driver.driver_init_ok) + # print(driver.is_running) + # print(driver.finish_status) + # print(driver.status_text) + # time.sleep(5) diff --git a/unilabos/devices/hplc/__init__.py b/unilabos/devices/hplc/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/devices/liquid_handling/__init__.py b/unilabos/devices/liquid_handling/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/devices/liquid_handling/revvity.py b/unilabos/devices/liquid_handling/revvity.py new file mode 100644 index 00000000..5f37c453 --- /dev/null +++ b/unilabos/devices/liquid_handling/revvity.py @@ -0,0 +1,90 @@ +import time +import sys +import io +# sys.path.insert(0, r'C:\kui\winprep_cli\winprep_c_Uni-lab\x64\Debug') + +import winprep_c +from queue import Queue + + +class Revvity: + _success: bool = False + _status: str = "Idle" + _status_queue: Queue = Queue() + + def __init__(self): + self._status = "Idle" + self._success = False + self._status_queue = Queue() + + + @property + def success(self) -> bool: + # print("success") + return self._success + @property + def status(self) -> str: + if not self._status_queue.empty(): + self._status = self._status_queue.get() + return self._status + def _run_script(self, file_path: str): + output = io.StringIO() + sys.stdout = output # 重定向标准输出 + + try: + # 执行 winprep_c.test_mtp_script 并打印结果 + winprep_c.test_mtp_script(file_path) + except Exception as e: + # 捕获执行过程中的异常 + print(e) + self._status_queue.put(f"Error: {str(e)}") + + finally: + sys.stdout = sys.__stdout__ # 恢复标准输出 + + # 获取捕获的输出并逐行更新状态 + for line in output.getvalue().splitlines(): + print(line) + self._status_queue.put(line) + self._status=line + + def run(self, file_path: str, params:str, resource: dict = {"AichemecoHiwo": {"id": "AichemecoHiwo"}}): + # 设置状态为 Running + self._status = "Running" + winprep_c.test_mtp_script(file_path) + + # 在一个新的线程中运行 MTP 脚本,避免阻塞主线程 + # thread = threading.Thread(target=self._run_script, args=(file_path,)) + # thread.start() + # self._run_script(file_path) +# + # # 在主线程中持续访问状态 + # while thread.is_alive() or self._success == False: + # current_status = self.status() # 获取当前的状态 + # print(f"Current Status: {current_status}") + # time.sleep(0.5) + + # output = io.StringIO() + # sys.stdout = output # 重定向标准输出 + + # try: + # # 执行 winprep_c.test_mtp_script 并打印结果 + # winprep_c.test_mtp_script(file_path) + # finally: + # sys.stdout = sys.__stdout__ # 恢复标准输出 + + # # 获取捕获的输出并逐行更新状态 + # for line in output.getvalue().splitlines(): + # self._status_queue.put(line) + # self._success = True + # 修改物料信息 + workstation = list(resource.values())[0] + input_plate_wells = list(workstation["children"]["test-GL96-2A02"]["children"].values()) + output_plate_wells = list(workstation["children"]["HPLC_Plate"]["children"].values()) + + for j in range(8): + output_plate_wells[j]["data"]["liquid"] += input_plate_wells[j]["data"]["liquid"] + output_plate_wells[j]["sample_id"] = input_plate_wells[j]["sample_id"] + + self._status = "Idle" + self._success = True \ No newline at end of file diff --git a/unilabos/devices/motor/Consts.py b/unilabos/devices/motor/Consts.py new file mode 100644 index 00000000..f3603fab --- /dev/null +++ b/unilabos/devices/motor/Consts.py @@ -0,0 +1,4 @@ +class Config(object): + DEBUG_MODE = True + OPEN_DEVICE = True + DEVICE_ADDRESS = 0x01 \ No newline at end of file diff --git a/unilabos/devices/motor/FakeSerial.py b/unilabos/devices/motor/FakeSerial.py new file mode 100644 index 00000000..bcf897e7 --- /dev/null +++ b/unilabos/devices/motor/FakeSerial.py @@ -0,0 +1,21 @@ +class FakeSerial: + def __init__(self): + self.data = b'' + + def write(self, data): + print("发送数据: ", end="") + for i in data: + print(f"{i:02x}", end=" ") + print() # 换行 + # 这里可模拟把假数据写到某个内部缓存 + # self.data = ... + + def setRTS(self, b): + pass + + def read(self, n): + # 这里可返回预设的响应,例如 b'\x01\x03\x02\x00\x19\x79\x8E' + return b'\x01\x03\x02\x00\x19\x79\x8E' + + def close(self): + pass \ No newline at end of file diff --git a/unilabos/devices/motor/Grasp.py b/unilabos/devices/motor/Grasp.py new file mode 100644 index 00000000..08c8611e --- /dev/null +++ b/unilabos/devices/motor/Grasp.py @@ -0,0 +1,153 @@ +import time +from serial import Serial +from threading import Thread + +from unilabos.device_comms.universal_driver import UniversalDriver + +class EleGripper(UniversalDriver): + @property + def status(self) -> str: + return f"spin_pos: {self.rot_data[0]}, grasp_pos: {self.gri_data[0]}, spin_v: {self.rot_data[1]}, grasp_v: {self.gri_data[1]}, spin_F: {self.rot_data[2]}, grasp_F: {self.gri_data[2]}" + + def __init__(self,port,baudrate=115200, pos_error=-11, id = 9): + self.ser = Serial(port,baudrate) + self.pos_error = pos_error + + self.success = False + + # [pos, speed, force] + self.gri_data = [0,0,0] + self.rot_data = [0,0,0] + + self.id = id + + self.init_gripper() + self.wait_for_gripper_init() + + t = Thread(target=self.data_loop) + t.start() + + self.gripper_move(0,255,255) + self.rotate_move_abs(0,255,255) + + def modbus_crc(self, data: bytes) -> bytes: + crc = 0xFFFF + for pos in data: + crc ^= pos + for _ in range(8): + if (crc & 0x0001) != 0: + crc >>= 1 + crc ^= 0xA001 + else: + crc >>= 1 + return crc.to_bytes(2, byteorder='little') + + def send_cmd(self, id, fun, address, data:str): + data_len = len(bytes.fromhex(data)) + data_ = f"{id:02X} {fun} {address} {data_len//2:04X} {data_len:02X} {data}" + data_bytes = bytes.fromhex(data_) + data_with_checksum = data_bytes + self.modbus_crc(data_bytes) + self.ser.write(data_with_checksum) + time.sleep(0.01) + self.ser.read(8) + + def read_address(self, id , address, data_len): + data = f"{id:02X} 03 {address} {data_len:04X}" + data_bytes = bytes.fromhex(data) + data_with_checksum = data_bytes + self.modbus_crc(data_bytes) + self.ser.write(data_with_checksum) + time.sleep(0.01) + res_len = 5+data_len*2 + res = self.ser.read(res_len) + return res + + def init_gripper(self): + self.send_cmd(self.id,'10','03 E8','00 01') + self.send_cmd(self.id,'10','03 E9','00 01') + + def gripper_move(self, pos, speed, force): + self.send_cmd(self.id,'10', '03 EA', f"{speed:02x} {pos:02x} {force:02x} 01") + + def rotate_move_abs(self, pos, speed, force): + pos += self.pos_error + if pos < 0: + pos = (1 << 16) + pos + self.send_cmd(self.id,'10', '03 EC', f"{(pos):04x} {force:02x} {speed:02x} 0000 00 01") + + # def rotate_move_rel(self, pos, speed, force): + # if pos < 0: + # pos = (1 << 16) + pos + # print(f'{pos:04x}') + # self.send_cmd(self.id,'10', '03 EC', f"0000 {force:02x} {speed:02x} {pos:04x} 00 02") + + def wait_for_gripper_init(self): + res = self.read_address(self.id, "07 D0", 1) + res_r = self.read_address(self.id, "07 D1", 1) + while res[4] == 0x08 or res_r[4] == 0x08: + res = self.read_address(self.id, "07 D0", 1) + res_r = self.read_address(self.id, "07 D1", 1) + time.sleep(0.1) + + def wait_for_gripper(self): + while self.gri_data[1] != 0: + time.sleep(0.1) + + def wait_for_rotate(self): + while self.rot_data[1] != 0: + time.sleep(0.1) + + def data_reader(self): + res_g = self.read_address(self.id, "07 D2", 2) + res_r = self.read_address(self.id, "07 D4", 4) + int32_value = (res_r[3] << 8) | res_r[4] + if int32_value > 0x7FFF: + int32_value -= 0x10000 + self.gri_data = (int(res_g[4]), int(res_g[3]), int(res_g[5])) + self.rot_data = (int32_value, int(res_r[5]), int(res_r[6])) + + def data_loop(self): + while True: + self.data_reader() + time.sleep(0.1) + + + def node_gripper_move(self, cmd:str): + self.success = False + + try: + cmd_list = [int(x) for x in cmd.replace(' ','').split(',')] + self.gripper_move(*cmd_list) + self.wait_for_gripper() + except Exception as e: + raise e + + self.success = True + + def node_rotate_move(self, cmd:str): + self.success = False + + try: + cmd_list = [int(x) for x in cmd.replace(' ','').split(',')] + self.rotate_move_abs(*cmd_list) + self.wait_for_rotate() + except Exception as e: + raise e + + self.success = True + + def move_and_rotate(self, spin_pos, grasp_pos, spin_v, grasp_v, spin_F, grasp_F): + self.gripper_move(grasp_pos, grasp_v, grasp_F) + self.wait_for_gripper() + self.rotate_move_abs(spin_pos, spin_v, spin_F) + self.wait_for_rotate() + +if __name__ == "__main__": + gripper = EleGripper("COM12") + gripper.init_gripper() + gripper.wait_for_gripper_init() + gripper.gripper_move(210,127,255) + gripper.wait_for_gripper() + gripper.rotate_move_abs(135,10,255) + gripper.data_reader() + print(gripper.rot_data) + diff --git a/unilabos/devices/motor/Utils.py b/unilabos/devices/motor/Utils.py new file mode 100644 index 00000000..20c44ad7 --- /dev/null +++ b/unilabos/devices/motor/Utils.py @@ -0,0 +1,87 @@ +import struct +import time +from typing import Union + +from .Consts import Config + +def calculate_modbus_crc16(data: bytes) -> tuple[int, int]: + """ + 计算 Modbus RTU 的 CRC16 校验码,返回 (low_byte, high_byte)。 + data 可以是 bytes 或者 bytearray + """ + crc = 0xFFFF + for byte in data: + crc ^= byte + for _ in range(8): + if (crc & 0x0001): + crc = (crc >> 1) ^ 0xA001 + else: + crc >>= 1 + + # 低字节在前、高字节在后 + low_byte = crc & 0xFF + high_byte = (crc >> 8) & 0xFF + return low_byte, high_byte + + +def create_command(slave_id, func_code, address, data): + """ + 生成完整的 Modbus 通信指令: + - 第1字节: 从站地址 + - 第2字节: 功能码 + - 第3~4字节: 寄存器地址 (大端) + - 第5~6字节: 数据(或读寄存器个数) (大端) + - 第7~8字节: CRC校验, 先低后高 + """ + # 按照大端格式打包:B(1字节), B(1字节), H(2字节), H(2字节) + # 例如: 0x03, 0x03, 0x0191, 0x0001 + # 生成的命令将是: 03 03 01 91 00 01 (不含 CRC) + command = struct.pack(">B B H H", slave_id, func_code, address, data) + + # 计算CRC,并将低字节、后高字节拼到末尾 + low_byte, high_byte = calculate_modbus_crc16(command) + return command + bytes([low_byte, high_byte]) + + +def send_command(ser, command) -> Union[bytes, str]: + """通过串口发送指令并打印响应""" + # Modbus RTU 半双工,发送前拉高 RTS + ser.setRTS(True) + time.sleep(0.02) + ser.write(command) # 发送指令 + if Config.OPEN_DEVICE: + # 如果是实际串口,就打印16进制的发送内容 + print(f"发送的数据: ", end="") + for ind, c in enumerate(command.hex().upper()): + if ind % 2 == 0 and ind != 0: + print(" ", end="") + print(c, end="") + + # 发送完成后,切换到接收模式 + ser.setRTS(False) + + # 读取响应,具体长度要看从站返回,有时多字节 + response = ser.read(8) # 假设响应是8字节 + print(f"接收到的数据: ", end="") + for ind, c in enumerate(response.hex().upper()): + if ind % 2 == 0 and ind != 0: + print(" ", end="") + print(c, end="") + print() + return response + +def get_result_byte_int(data: bytes, byte_start: int = 6, byte_end: int = 10) -> int: + return int(data.hex()[byte_start:byte_end], 16) + +def get_result_byte_str(data: bytes, byte_start: int = 6, byte_end: int = 10) -> str: + return data.hex()[byte_start:byte_end] + +def run_commands(ser, duration=0.1, *commands): + for cmd in commands: + if isinstance(cmd, list): + for c in cmd: + send_command(ser, c) + time.sleep(duration) + else: + send_command(ser, cmd) + time.sleep(duration) diff --git a/unilabos/devices/motor/__init__.py b/unilabos/devices/motor/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/devices/motor/base_types/PrPath.py b/unilabos/devices/motor/base_types/PrPath.py new file mode 100644 index 00000000..86b3bfe4 --- /dev/null +++ b/unilabos/devices/motor/base_types/PrPath.py @@ -0,0 +1,37 @@ +from enum import Enum + +class PR_PATH(Enum): + # TYPE (Bit0-3) + TYPE_NO_ACTION = 0x0000 # 无动作 + TYPE_POSITIONING = 0x0001 # 位置定位 + TYPE_VELOCITY = 0x0002 # 速度运行 + TYPE_HOME = 0x0003 # 回零 + + # INS (Bit4) - 默认都是插断模式 + INS_INTERRUPT = 0x0010 # 插断 + + # OVLP (Bit5) - 默认都是不重叠 + OVLP_NO_OVERLAP = 0x0000 # 不重叠 + + # POSITION MODE (Bit6) + POS_ABSOLUTE = 0x0000 # 绝对位置 + POS_RELATIVE = 0x0040 # 相对位置 + + # MOTOR MODE (Bit7) + MOTOR_ABSOLUTE = 0x0000 # 绝对电机 + MOTOR_RELATIVE = 0x0080 # 相对电机 + + # 常用组合(默认都是插断且不重叠) + # 位置定位相关 + ABS_POS = TYPE_POSITIONING | INS_INTERRUPT | OVLP_NO_OVERLAP | POS_ABSOLUTE # 绝对定位 + REL_POS = TYPE_POSITIONING | INS_INTERRUPT | OVLP_NO_OVERLAP | POS_RELATIVE # 相对定位 + + # 速度运行相关 + VELOCITY = TYPE_VELOCITY | INS_INTERRUPT | OVLP_NO_OVERLAP # 速度模式 + + # 回零相关 + HOME = TYPE_HOME | INS_INTERRUPT | OVLP_NO_OVERLAP # 回零模式 + + # 电机模式组合 + ABS_POS_REL_MOTOR = ABS_POS | MOTOR_RELATIVE # 绝对定位+相对电机 + REL_POS_REL_MOTOR = REL_POS | MOTOR_RELATIVE # 相对定位+相对电机 diff --git a/unilabos/devices/motor/base_types/__init__.py b/unilabos/devices/motor/base_types/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/devices/motor/commands/PositionControl.py b/unilabos/devices/motor/commands/PositionControl.py new file mode 100644 index 00000000..b3cabbf8 --- /dev/null +++ b/unilabos/devices/motor/commands/PositionControl.py @@ -0,0 +1,92 @@ + +from ..base_types.PrPath import PR_PATH +from ..Utils import create_command, get_result_byte_int, get_result_byte_str, send_command + + +def create_position_commands(slave_id: int, which: int, how: PR_PATH, + position: float, velocity: int = 300, + acc_time: int = 50, dec_time: int = 50, + special: int = 0) -> list[list[bytes]]: + """ + 创建位置相关的Modbus命令列表 + + Args: + slave_id: 从站地址 + which: PR路径号(0-7) + how: PR_PATH枚举,定义运动方式 + position: 目标位置(Pulse) + velocity: 运行速度(rpm) + acc_time: 加速时间(ms/Krpm),默认50 + dec_time: 减速时间(ms/Krpm),默认50 + special: 特殊参数,默认0 + + Returns: + 包含所有设置命令的列表 + """ + if not 0 <= which <= 7: + raise ValueError("which必须在0到7之间") + + base_addr = 0x6200 + which * 8 + + # 位置值保持原样(Pulse) + position = int(position) + print(f"路径方式 {' '.join(bin(how.value)[2:])} 位置 {position} 速度 {velocity}") + position_high = (position >> 16) & 0xFFFF # 获取高16位 + position_low = position & 0xFFFF # 获取低16位 + + # 速度值(rpm)转换为0x0000格式 + velocity_value = 0x0000 + velocity + + # 加减速时间(ms/Krpm)转换为0x0000格式 + acc_time_value = 0x0000 + int(acc_time) + dec_time_value = 0x0000 + int(dec_time) + + # 特殊参数转换为0x0000格式 + special_value = 0x0000 + special + return [ + create_command(slave_id, 0x06, base_addr + 0, how.value), # 路径方式 + create_command(slave_id, 0x06, base_addr + 1, position_high), # 位置高16位 + create_command(slave_id, 0x06, base_addr + 2, position_low), # 位置低16位 + create_command(slave_id, 0x06, base_addr + 3, velocity_value), # 运行速度 + create_command(slave_id, 0x06, base_addr + 4, acc_time_value), # 加速时间 + create_command(slave_id, 0x06, base_addr + 5, dec_time_value), # 减速时间 + create_command(slave_id, 0x06, base_addr + 6, special_value), # 特殊参数 + ] + +def create_position_run_command(slave_id: int, which: int) -> list[list[bytes]]: + print(f"运行路径 PR{which}") + return [create_command(slave_id, 0x06, 0x6002, 0x0010 + which)] + +def run_set_position_zero(ser, DEVICE_ADDRESS) -> list[list[bytes]]: + print(f"手动回零") + send_command(ser, create_command(DEVICE_ADDRESS, 0x06, 0x6002, 0x0021)) + +def run_stop(ser, DEVICE_ADDRESS) -> list[list[bytes]]: + print(f"急停") + send_command(ser, create_command(DEVICE_ADDRESS, 0x06, 0x6002, 0x0040)) + +def run_set_forward_run(ser, DEVICE_ADDRESS) -> list[list[bytes]]: + print(f"设定正方向运动") + send_command(ser, create_command(DEVICE_ADDRESS, 0x06, 0x0007, 0x0000)) + +def run_set_backward_run(ser, DEVICE_ADDRESS) -> list[list[bytes]]: + print(f"设定反方向运动") + send_command(ser, create_command(DEVICE_ADDRESS, 0x06, 0x0007, 0x0001)) + +def run_get_command_position(ser, DEVICE_ADDRESS, print_pos=True) -> int: + retH = send_command(ser, create_command(DEVICE_ADDRESS, 0x03, 0x602A, 0x0001)) # 命令位置H + retL = send_command(ser, create_command(DEVICE_ADDRESS, 0x03, 0x602B, 0x0001)) # 命令位置L + value = get_result_byte_str(retH) + get_result_byte_str(retL) + value = int(value, 16) + if print_pos: + print(f"命令位置: {value}") + return value + +def run_get_motor_position(ser, DEVICE_ADDRESS, print_pos=True) -> int: + retH = send_command(ser, create_command(DEVICE_ADDRESS, 0x03, 0x602C, 0x0001)) # 电机位置H + retL = send_command(ser, create_command(DEVICE_ADDRESS, 0x03, 0x602D, 0x0001)) # 电机位置L + value = get_result_byte_str(retH) + get_result_byte_str(retL) + value = int(value, 16) + if print_pos: + print(f"电机位置: {value}") + return value diff --git a/unilabos/devices/motor/commands/Status.py b/unilabos/devices/motor/commands/Status.py new file mode 100644 index 00000000..c9d954c7 --- /dev/null +++ b/unilabos/devices/motor/commands/Status.py @@ -0,0 +1,44 @@ +import time +from ..Utils import create_command, send_command +from .PositionControl import run_get_motor_position + + +def run_get_status(ser, DEVICE_ADDRESS, print_status=True) -> list[list[bytes]]: + # Bit0 故障 + # Bit1 使能 + # Bit2 运行 + # Bit4 指令完成 + # Bit5 路径完成 + # Bit6 回零完成 + ret = send_command(ser, create_command(DEVICE_ADDRESS, 0x03, 0x1003, 0x0001)) + status = bin(int(ret.hex()[6:10], 16))[2:] + # 用0左位补齐 + status = status.zfill(8) + status_info_list = [] + if status[-1] == "1": + status_info_list.append("故障") + if status[-2] == "1": + status_info_list.append("使能") + if status[-3] == "1": + status_info_list.append("运行") + if status[-5] == "1": + status_info_list.append("指令完成") + if status[-6] == "1": + status_info_list.append("路径完成") + if status[-7] == "1": + status_info_list.append("回零完成") + if print_status: + print(f"获取状态: {' '.join(status_info_list)}") + return status_info_list + +def run_until_status(ser, DEVICE_ADDRESS, status_info: str, max_time=10): + start_time = time.time() + while True: + ret = run_get_status(ser, DEVICE_ADDRESS) + if status_info in ret: + break + if time.time() - start_time > max_time: + print(f"状态未达到 {status_info} 超时") + return False + time.sleep(0.05) + return True diff --git a/unilabos/devices/motor/commands/Warning.py b/unilabos/devices/motor/commands/Warning.py new file mode 100644 index 00000000..6744e072 --- /dev/null +++ b/unilabos/devices/motor/commands/Warning.py @@ -0,0 +1,12 @@ +from serial import Serial +from ..Consts import Config +from ..Utils import create_command, send_command + + +def create_elimate_warning_command(DEVICE_ADDRESS): + return create_command(DEVICE_ADDRESS, 0x06, 0x0145, 0x0087) + + +def run_elimate_warning(ser: Serial, DEVICE_ADDRESS): + send_command(ser, create_elimate_warning_command(DEVICE_ADDRESS)) + print("清除警报") diff --git a/unilabos/devices/motor/commands/__init__.py b/unilabos/devices/motor/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/devices/motor/iCL42.py b/unilabos/devices/motor/iCL42.py new file mode 100644 index 00000000..d937865e --- /dev/null +++ b/unilabos/devices/motor/iCL42.py @@ -0,0 +1,126 @@ +import os +import sys +from abc import abstractmethod +from typing import Optional + +import serial + +from .Consts import Config +from .FakeSerial import FakeSerial +from .Utils import run_commands +from .base_types.PrPath import PR_PATH +from .commands.PositionControl import run_get_command_position, run_set_forward_run, create_position_commands, \ + create_position_run_command +from .commands.Warning import run_elimate_warning + +try: + from unilabos.utils.pywinauto_util import connect_application, get_process_pid_by_name, get_ui_path_with_window_specification, print_wrapper_identifiers + from unilabos.device_comms.universal_driver import UniversalDriver, SingleRunningExecutor + from unilabos.device_comms import universal_driver as ud +except Exception as e: + sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))) + from unilabos.utils.pywinauto_util import connect_application, get_process_pid_by_name, get_ui_path_with_window_specification, print_wrapper_identifiers + from unilabos.device_comms.universal_driver import UniversalDriver, SingleRunningExecutor + from unilabos.devices.template_driver import universal_driver as ud + print(f"使用文件DEBUG运行: {e}") + + +class iCL42Driver(UniversalDriver): + _ser: Optional[serial.Serial | FakeSerial] = None + _motor_position: Optional[int] = None + _DEVICE_COM: Optional[str] = None + _DEVICE_ADDRESS: Optional[int] = None + # 运行状态 + _is_executing_run: bool = False + _success: bool = False + + @property + def motor_position(self) -> int: + return self._motor_position + + @property + def is_executing_run(self) -> bool: + return self._is_executing_run + + @property + def success(self) -> bool: + return self._success + + def init_device(self): + # 配置串口参数 + if Config.OPEN_DEVICE: + self._ser = serial.Serial( + port=self._DEVICE_COM, # 串口号 + baudrate=38400, # 波特率 + bytesize=serial.EIGHTBITS, # 数据位 + parity=serial.PARITY_NONE, # 校验位 N-无校验 + stopbits=serial.STOPBITS_TWO, # 停止位 + timeout=1 # 超时时间 + ) + else: + self._ser = FakeSerial() + + def run_motor(self, mode: str, position: float, velocity: int): + if self._ser is None: + print("Device is not initialized") + self._success = False + return False + def post_func(res, _): + self._success = res + if not res: + self._is_executing_run = False + ins: SingleRunningExecutor = SingleRunningExecutor.get_instance( + self.execute_run_motor, post_func + ) + # if not ins.is_ended and ins.is_started: + # print("Function is running") + # self._success = False + # return False + # elif not ins.is_started: + # print("Function started") + # ins.start() # 开始执行 + # else: + # print("Function reset and started") + ins.reset() + ins.start(mode=mode, position=position, velocity=velocity) + + def execute_run_motor(self, mode: str, position: float, velocity: int): + position = int(position * 1000) + PR = 0 + run_elimate_warning(self._ser, self._DEVICE_ADDRESS) + run_set_forward_run(self._ser, self._DEVICE_ADDRESS) + run_commands( + self._ser, 0.1, + create_position_commands(self._DEVICE_ADDRESS, PR, PR_PATH[mode], position, velocity), # 41.8cm 21.8cm + create_position_run_command(self._DEVICE_ADDRESS, PR), + ) + # if run_until_status(self._ser, self._DEVICE_ADDRESS, "路径完成"): + # pass + + + def __init__(self, device_com: str = "COM9", device_address: int = 0x01): + self._DEVICE_COM = device_com + self._DEVICE_ADDRESS = device_address + self.init_device() + # 启动所有监控器 + self.checkers = [ + # PositionChecker(self, 1), + ] + for checker in self.checkers: + checker.start_monitoring() + +@abstractmethod +class DriverChecker(ud.DriverChecker): + driver: iCL42Driver + +class PositionChecker(DriverChecker): + def check(self): + # noinspection PyProtectedMember + if self.driver._ser is not None: + # noinspection PyProtectedMember + self.driver._motor_position = run_get_command_position(self.driver._ser, self.driver._DEVICE_ADDRESS) + +# 示例用法 +if __name__ == "__main__": + driver = iCL42Driver("COM3") + driver._is_executing_run = True diff --git a/unilabos/devices/motor/test.py b/unilabos/devices/motor/test.py new file mode 100644 index 00000000..dea221ba --- /dev/null +++ b/unilabos/devices/motor/test.py @@ -0,0 +1,79 @@ +# 使用pyserial进行485的协议通信,端口指定为COM4 +import serial +from serial.rs485 import RS485Settings +import traceback + + +from Consts import Config +from FakeSerial import FakeSerial +from base_types.PrPath import PR_PATH +from Utils import run_commands +from commands.PositionControl import create_position_commands, create_position_run_command, run_get_command_position, run_get_motor_position, run_set_forward_run +from commands.Status import run_get_status, run_until_status +from commands.Warning import run_elimate_warning +from Grasp import EleGripper + + +DEVICE_ADDRESS = Config.DEVICE_ADDRESS + +# 配置串口参数 +if Config.OPEN_DEVICE: + ser = serial.Serial( + port='COM11', # 串口号 + baudrate=38400, # 波特率 + bytesize=serial.EIGHTBITS, # 数据位 + parity=serial.PARITY_NONE, # 校验位 N-无校验 + stopbits=serial.STOPBITS_TWO, # 停止位 + timeout=1 # 超时时间 + ) +else: + ser = FakeSerial() + +# RS485模式支持(如果需要) +try: + ser.rs485_mode = RS485Settings( + rts_level_for_tx=True, + rts_level_for_rx=False, + # delay_before_tx=0.01, + # delay_before_rx=0.01 + ) +except AttributeError: + traceback.print_exc() + print("RS485模式需要支持的硬件和pyserial版本") + +# run_set_position_zero(ser, DEVICE_ADDRESS) + +# api.get_running_state(ser, DEVICE_ADDRESS) +gripper = EleGripper("COM12") +gripper.init_gripper() +gripper.wait_for_gripper_init() +PR = 0 +run_get_status(ser, DEVICE_ADDRESS) +run_elimate_warning(ser, DEVICE_ADDRESS) +run_set_forward_run(ser, DEVICE_ADDRESS) +run_commands( + ser, 0.1, + create_position_commands(DEVICE_ADDRESS, PR, PR_PATH.ABS_POS, 20 * 1000, 300), # 41.8cm 21.8cm + create_position_run_command(DEVICE_ADDRESS, PR), +) +if run_until_status(ser, DEVICE_ADDRESS, "路径完成"): + pass +gripper.gripper_move(210,127,255) +gripper.wait_for_gripper() +gripper.rotate_move_abs(135,10,255) +gripper.data_reader() +print(gripper.rot_data) +run_commands( + ser, 0.1, + create_position_commands(DEVICE_ADDRESS, PR, PR_PATH.ABS_POS, 30 * 1000, 300), # 41.8cm 21.8cm + create_position_run_command(DEVICE_ADDRESS, PR), +) +gripper.gripper_move(210,127,255) +gripper.wait_for_gripper() +gripper.rotate_move_abs(135,10,255) +gripper.data_reader() + +# run_get_command_position(ser, DEVICE_ADDRESS) +# run_get_motor_position(ser, DEVICE_ADDRESS) + +# ser.close() diff --git a/unilabos/devices/platereader/NivoDriver.py b/unilabos/devices/platereader/NivoDriver.py new file mode 100644 index 00000000..d9896579 --- /dev/null +++ b/unilabos/devices/platereader/NivoDriver.py @@ -0,0 +1,295 @@ +import time +import traceback +from typing import Optional + +import requests +from pywinauto.application import WindowSpecification +from pywinauto.controls.uiawrapper import UIAWrapper +from pywinauto_recorder import UIApplication +from pywinauto_recorder.player import UIPath, click, focus_on_application, exists, find, FailedSearch + +from unilabos.device_comms import universal_driver as ud +from unilabos.device_comms.universal_driver import UniversalDriver, SingleRunningExecutor +from unilabos.utils.pywinauto_util import connect_application, get_process_pid_by_name, \ + get_ui_path_with_window_specification + + +class NivoDriver(UniversalDriver): + # 初始指定 + _device_ip: str = None + # 软件状态检测 + _software_enabled: bool = False + _software_pid: int = None + _http_service_available: bool = False + + # 初始化状态 + _is_initialized: bool = False + + # 任务是否成功 + _success: bool = False + + # 运行状态 + _is_executing_run: bool = False + _executing_ui_path: Optional[UIPath] = None + _executing_index: Optional[int] = None + _executing_status: Optional[str] = None + _total_tasks: Optional[list[str]] = None + _guide_app: Optional[UIApplication] = None + + @property + def executing_status(self) -> str: + if self._total_tasks is None: + return f"无任务" + if self._executing_index is None: + return f"等待任务开始,总计{len(self._total_tasks)}个".encode('utf-8').decode('utf-8') + else: + return f"正在执行第{self._executing_index + 1}/{len(self._total_tasks)}个任务,当前状态:{self._executing_status}".encode('utf-8').decode('utf-8') + + @property + def device_ip(self) -> str: + return self._device_ip + + @property + def success(self) -> bool: + return self._success + + @property + def status(self) -> str: + return f"Software: {self._software_enabled}, HTTP: {self._http_service_available} Initialized: {self._is_initialized} Executing: {self._is_executing_run}" + + def set_device_addr(self, device_ip_str): + self._device_ip = device_ip_str + print(f"Set device IP to: {self.device_ip}") + + def run_instrument(self): + if not self._is_initialized: + print("Instrument is not initialized") + self._success = False + return False + def post_func(res, _): + self._success = res + if not res: + self._is_executing_run = False + ins: SingleRunningExecutor = SingleRunningExecutor.get_instance(self.execute_run_instrument, post_func) + if not ins.is_ended and ins.is_started: + print("Function is running") + self._success = False + return False + elif not ins.is_started: + print("Function started") + ins.start() # 开始执行 + else: + print("Function reset and started") + ins.reset() + ins.start() + + def execute_run_instrument(self): + process_found, process_pid = get_process_pid_by_name("Guide.exe", min_memory_mb=20) + if not process_found: + uiapp = connect_application(process=self._software_pid) + focus_on_application(uiapp) + ui_window: WindowSpecification = uiapp.app.top_window() + button: WindowSpecification = ui_window.child_window(title="xtpBarTop", class_name="XTPDockBar").child_window( + title="Standard", class_name="XTPToolBar").child_window(title="Protocol", control_type="Button") + click(button.wrapper_object()) + for _ in range(5): + time.sleep(1) + process_found, process_pid = get_process_pid_by_name("Guide.exe", min_memory_mb=20) + if process_found: + break + if not process_found: + print("Guide.exe not found") + self._success = False + return False + uiapp = connect_application(process=process_pid) + self._guide_app = uiapp + focus_on_application(uiapp) + wrapper_object = uiapp.app.top_window().wrapper_object() + ui_path = get_ui_path_with_window_specification(wrapper_object) + self._executing_ui_path = ui_path + with ui_path: + try: + click(u"||Custom->||RadioButton-> Run||Text-> Run||Text") + except FailedSearch as e: + print(f"未找到Run按钮,可能已经在执行了") + with UIPath(u"WAA.Guide.Guide.RunControlViewModel||Custom->||Custom"): + click(u"Start||Button") + with UIPath(u"WAA.Guide.Guide.RunControlViewModel||Custom->||Custom->||Group->WAA.Guide.Guide.StartupControlViewModel||Custom->||Custom->Ok||Button"): + while self._executing_index is None or self._executing_index == 0: + if exists(None, timeout=2): + click(u"Ok||Text") + print("Run Init Success!") + self._is_executing_run = True + return True + else: + print("Wait for Ok button") + + def check_execute_run_status(self): + if not self._is_executing_run: + return False + if self._executing_ui_path is None: + return False + total_tasks = [] + executing_index = 0 + executing_status = "" + procedure_name_found = False + with self._executing_ui_path: + with UIPath(u"WAA.Guide.Guide.RunControlViewModel||Custom->||Custom"): + with UIPath("Progress||Group->||DataGrid"): + wrappered_object: UIAWrapper = find(timeout=0.5) # BUG: 在查找的时候会触发全局锁,建议还是使用Process来检测 + for custom_wrapper in wrappered_object.children(): + if len(custom_wrapper.children()) == 1: + each_custom_wrapper = custom_wrapper.children()[0] + if len(each_custom_wrapper.children()) == 2: + if not procedure_name_found: + procedure_name_found = True + continue + task_wrapper = each_custom_wrapper.children()[0] + total_tasks.append(task_wrapper.window_text()) + status_wrapper = each_custom_wrapper.children()[1] + status = status_wrapper.window_text() + if len(status) > 0: + executing_index = len(total_tasks) - 1 + executing_status = status + try: + if self._guide_app is not None: + wrapper_object = self._guide_app.app.top_window().wrapper_object() + ui_path = get_ui_path_with_window_specification(wrapper_object) + with ui_path: + with UIPath("OK||Button"): + btn = find(timeout=1) + if btn is not None: + btn.set_focus() + click(btn, timeout=1) + self._is_executing_run = False + print("运行完成!") + except: + pass + self._executing_index = executing_index + self._executing_status = executing_status + self._total_tasks = total_tasks + return True + + def initialize_instrument(self, force=False): + if not self._software_enabled: + print("Software is not opened") + self._success = False + return + if not self._http_service_available: + print("HTTP Server Not Available") + self._success = False + return + if self._is_initialized and not force: + print("Already Initialized") + self._success = True + return True + ins: SingleRunningExecutor = SingleRunningExecutor.get_instance(self.execute_initialize, lambda res, _: setattr(self, '_success', res)) + if not ins.is_ended and ins.is_started: + print("Initialize is running") + self._success = False + return False + elif not ins.is_started: + print("Initialize started") + ins.start() + else: # 可能外面is_initialized被设置为False,又进来重新初始化了 + print("Initialize reset and started") + ins.reset() + ins.start() + return True + + def execute_initialize(self, process=None) -> bool: + if process is None: + process = self._software_pid + try: + uiapp = connect_application(process=process) + ui_window: WindowSpecification = uiapp.app.top_window() + + button = ui_window.child_window(title="xtpBarTop", class_name="XTPDockBar").child_window( + title="Standard", class_name="XTPToolBar").child_window(title="Initialize Instrument", control_type="Button") + focus_on_application(uiapp) + click(button.wrapper_object()) + with get_ui_path_with_window_specification(ui_window): + with UIPath("Regex: (Initializing|Resetting|Perking).*||Window"): + # 检测窗口是否存在 + for i in range(3): + try: + initializing_windows = exists(None, timeout=2) + break + except: + pass + print("window has recovered", initializing_windows) + time.sleep(5) # another wait + self._is_initialized = True + return True + except Exception as e: + print("An error occurred during initialization:") + traceback.print_exc() + return False + + def __init__(self): + self._device_ip = "192.168.0.2" + # 启动所有监控器 + self.checkers = [ + ProcessChecker(self, 1), + HttpServiceChecker(self, 3), + RunStatusChecker(self, 1), + OkButtonChecker(self, 2) # 添加新的Checker + ] + for checker in self.checkers: + checker.start_monitoring() + + +class DriverChecker(ud.DriverChecker): + driver: NivoDriver + +class ProcessChecker(DriverChecker): + def check(self): + process_found, process_pid = get_process_pid_by_name("JANUS.exe", min_memory_mb=20) + self.driver._software_pid = process_pid + self.driver._software_enabled = process_found + if not process_found: + self.driver._is_initialized = False + + +class HttpServiceChecker(DriverChecker): + def check(self): + http_service_available = False + if self.driver.device_ip: + try: + response = requests.get(f"http://{self.driver.device_ip}", timeout=5) + http_service_available = response.status_code == 200 + except requests.RequestException: + pass + self.driver._http_service_available = http_service_available + + +class RunStatusChecker(DriverChecker): + def check(self): + process_found, process_pid = get_process_pid_by_name("Guide.exe", min_memory_mb=20) + if not process_found: + self.driver._is_executing_run = False + return + self.driver.check_execute_run_status() + +class OkButtonChecker(DriverChecker): + def check(self): + if not self.driver._is_executing_run or self.driver._guide_app is None: + return + # uiapp = connect_application(process=11276) + # self.driver._guide_app = uiapp + try: + ui_window: UIAWrapper = self.driver._guide_app.app.top_window() + btn: WindowSpecification = ui_window.child_window(title="OK", auto_id="2", control_type="Button") + if btn.exists(2): + click(btn.wrapper_object()) + self.driver._is_executing_run = False + print("运行完成!") + except Exception as e: + # traceback.print_exc() + pass + +# 示例用法 +if __name__ == "__main__": + driver = NivoDriver() + driver.set_device_addr("192.168.0.2") # 设置设备 IP 地址 + driver._is_executing_run = True diff --git a/unilabos/devices/platereader/PlayerUtil.py b/unilabos/devices/platereader/PlayerUtil.py new file mode 100644 index 00000000..07244131 --- /dev/null +++ b/unilabos/devices/platereader/PlayerUtil.py @@ -0,0 +1,166 @@ +import psutil +import pywinauto +from pywinauto_recorder import UIApplication +from pywinauto_recorder.player import UIPath, click, focus_on_application, exists, find, get_wrapper_path +from pywinauto.controls.uiawrapper import UIAWrapper +from pywinauto.application import WindowSpecification +from pywinauto import findbestmatch +import sys +import codecs +import os +import locale +import six + + +def connect_application(backend="uia", **kwargs): + app = pywinauto.Application(backend=backend) + app.connect(**kwargs) + top_window = app.top_window().wrapper_object() + native_window_handle = top_window.handle + return UIApplication(app, native_window_handle) + +def get_ui_path_with_window_specification(obj): + return UIPath(get_wrapper_path(obj)) + +def get_process_pid_by_name(process_name: str, min_memory_mb: float = 0) -> tuple[bool, int]: + """ + 通过进程名称和最小内存要求获取进程PID + + Args: + process_name: 进程名称 + min_memory_mb: 最小内存要求(MB),默认为0表示不检查内存 + + Returns: + tuple[bool, int]: (是否找到进程, 进程PID) + """ + process_found = False + process_pid = None + min_memory_bytes = min_memory_mb * 1024 * 1024 # 转换为字节 + + try: + for proc in psutil.process_iter(['name', 'pid', 'memory_info']): + try: + # 获取进程信息 + proc_info = proc.info + if proc_info['name'] == process_name: + # 如果设置了内存限制,则检查内存 + if min_memory_mb > 0: + memory_info = proc_info.get('memory_info') + if memory_info and memory_info.rss > min_memory_bytes: + process_found = True + process_pid = proc_info['pid'] + break + else: + # 不检查内存,直接返回找到的进程 + process_found = True + process_pid = proc_info['pid'] + break + + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + continue + + except Exception as e: + print(f"获取进程信息时发生错误: {str(e)}") + + return process_found, process_pid + +def print_wrapper_identifiers(wrapper_object, depth=None, filename=None): + """ + 打印控件及其子控件的标识信息 + + Args: + wrapper_object: UIAWrapper对象 + depth: 打印的最大深度,None表示打印全部 + filename: 输出文件名,None表示打印到控制台 + """ + if depth is None: + depth = sys.maxsize + + # 创建所有控件的列表(当前控件及其所有子代) + all_ctrls = [wrapper_object, ] + wrapper_object.descendants() + + # 创建所有可见文本控件的列表 + txt_ctrls = [ctrl for ctrl in all_ctrls if ctrl.can_be_label and ctrl.is_visible() and ctrl.window_text()] + + # 构建唯一的控件名称字典 + name_ctrl_id_map = findbestmatch.UniqueDict() + for index, ctrl in enumerate(all_ctrls): + ctrl_names = findbestmatch.get_control_names(ctrl, all_ctrls, txt_ctrls) + for name in ctrl_names: + name_ctrl_id_map[name] = index + + # 反转映射关系(控件索引到名称列表) + ctrl_id_name_map = {} + for name, index in name_ctrl_id_map.items(): + ctrl_id_name_map.setdefault(index, []).append(name) + + def print_identifiers(ctrls, current_depth=1, log_func=print): + """递归打印控件及其子代的标识信息""" + if len(ctrls) == 0 or current_depth > depth: + return + + indent = (current_depth - 1) * u" | " + for ctrl in ctrls: + try: + ctrl_id = all_ctrls.index(ctrl) + except ValueError: + continue + + ctrl_text = ctrl.window_text() + if ctrl_text: + # 将多行文本转换为单行 + ctrl_text = ctrl_text.replace('\n', r'\n').replace('\r', r'\r') + + output = indent + u'\n' + output += indent + u"{class_name} - '{text}' {rect}\n"\ + "".format(class_name=ctrl.friendly_class_name(), + text=ctrl_text, + rect=ctrl.rectangle()) + output += indent + u'{}'.format(ctrl_id_name_map[ctrl_id]) + + title = ctrl_text + class_name = ctrl.class_name() + auto_id = None + control_type = None + if hasattr(ctrl.element_info, 'automation_id'): + auto_id = ctrl.element_info.automation_id + if hasattr(ctrl.element_info, 'control_type'): + control_type = ctrl.element_info.control_type + if control_type: + class_name = None # 如果有control_type就不需要class_name + else: + control_type = None # 如果control_type为空,仍使用class_name + + criteria_texts = [] + recorder_texts = [] + if title: + criteria_texts.append(u'title="{}"'.format(title)) + recorder_texts.append(f"{title}") + if class_name: + criteria_texts.append(u'class_name="{}"'.format(class_name)) + if auto_id: + criteria_texts.append(u'auto_id="{}"'.format(auto_id)) + if control_type: + criteria_texts.append(u'control_type="{}"'.format(control_type)) + recorder_texts.append(f"||{control_type}") + if title or class_name or auto_id: + output += u'\n' + indent + u'child_window(' + u', '.join(criteria_texts) + u')' + " / " + "".join(recorder_texts) + + if six.PY3: + log_func(output) + else: + log_func(output.encode(locale.getpreferredencoding(), errors='backslashreplace')) + + print_identifiers(ctrl.children(), current_depth + 1, log_func) + + if filename is None: + print("Control Identifiers:") + print_identifiers([wrapper_object, ]) + else: + log_file = codecs.open(filename, "w", locale.getpreferredencoding()) + def log_func(msg): + log_file.write(str(msg) + os.linesep) + log_func("Control Identifiers:") + print_identifiers([wrapper_object, ], log_func=log_func) + log_file.close() + diff --git a/unilabos/devices/platereader/__init__.py b/unilabos/devices/platereader/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/devices/powder_dispense/__init__.py b/unilabos/devices/powder_dispense/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/devices/pump_and_valve/__init__.py b/unilabos/devices/pump_and_valve/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/devices/pump_and_valve/runze_async.py b/unilabos/devices/pump_and_valve/runze_async.py new file mode 100644 index 00000000..9b8d649e --- /dev/null +++ b/unilabos/devices/pump_and_valve/runze_async.py @@ -0,0 +1,394 @@ +import asyncio +from asyncio import Event, Future, Lock, Task +from enum import Enum +from dataclasses import dataclass +from typing import Any, Union, Optional, overload + +import serial.tools.list_ports +from serial import Serial +from serial.serialutil import SerialException + + +class RunzeSyringePumpMode(Enum): + Normal = 0 + AccuratePos = 1 + AccuratePosVel = 2 + + +pulse_freq_grades = { + 6000: "0" , + 5600: "1" , + 5000: "2" , + 4400: "3" , + 3800: "4" , + 3200: "5" , + 2600: "6" , + 2200: "7" , + 2000: "8" , + 1800: "9" , + 1600: "10", + 1400: "11", + 1200: "12", + 1000: "13", + 800 : "14", + 600 : "15", + 400 : "16", + 200 : "17", + 190 : "18", + 180 : "19", + 170 : "20", + 160 : "21", + 150 : "22", + 140 : "23", + 130 : "24", + 120 : "25", + 110 : "26", + 100 : "27", + 90 : "28", + 80 : "29", + 70 : "30", + 60 : "31", + 50 : "32", + 40 : "33", + 30 : "34", + 20 : "35", + 18 : "36", + 16 : "37", + 14 : "38", + 12 : "39", + 10 : "40", +} + + +class RunzeSyringePumpConnectionError(Exception): + pass + + +@dataclass(frozen=True, kw_only=True) +class RunzeSyringePumpInfo: + port: str + address: str = "1" + + volume: float = 25000 + mode: RunzeSyringePumpMode = RunzeSyringePumpMode.Normal + + def create(self): + return RunzeSyringePumpAsync(self.port, self.address, self.volume, self.mode) + + +class RunzeSyringePumpAsync: + def __init__(self, port: str, address: str = "1", volume: float = 25000, mode: RunzeSyringePumpMode = None): + self.port = port + self.address = address + + self.volume = volume + self.mode = mode + self.total_steps = self.total_steps_vel = 6000 + + try: + self._serial = Serial( + baudrate=9600, + port=port + ) + except (OSError, SerialException) as e: + raise RunzeSyringePumpConnectionError from e + + self._busy = False + self._closing = False + self._error_event = Event() + self._query_future = Future[Any]() + self._query_lock = Lock() + self._read_task: Optional[Task[None]] = None + self._run_future: Optional[Future[Any]] = None + self._run_lock = Lock() + + def _adjust_total_steps(self): + self.total_steps = 6000 if self.mode == RunzeSyringePumpMode.Normal else 48000 + self.total_steps_vel = 48000 if self.mode == RunzeSyringePumpMode.AccuratePosVel else 6000 + + async def _read_loop(self): + try: + while True: + self._receive((await asyncio.to_thread(lambda: self._serial.read_until(b"\n")))[3:-3]) + except SerialException as e: + raise RunzeSyringePumpConnectionError from e + finally: + if not self._closing: + self._error_event.set() + + if self._query_future and not self._query_future.done(): + self._query_future.set_exception(RunzeSyringePumpConnectionError()) + if self._run_future and not self._run_future.done(): + self._run_future.set_exception(RunzeSyringePumpConnectionError()) + + @overload + async def _query(self, command: str, dtype: type[bool]) -> bool: + pass + + @overload + async def _query(self, command: str, dtype: type[int]) -> int: + pass + + @overload + async def _query(self, command: str, dtype = None) -> str: + pass + + async def _query(self, command: str, dtype: Optional[type] = None): + async with self._query_lock: + if self._closing or self._error_event.is_set(): + raise RunzeSyringePumpConnectionError + + self._query_future = Future[Any]() + + run = 'R' if not command.startswith("?") else '' + full_command = f"/{self.address}{command}{run}\r\n" + full_command_data = bytearray(full_command, 'ascii') + + try: + await asyncio.to_thread(lambda: self._serial.write(full_command_data)) + return self._parse(await asyncio.wait_for(asyncio.shield(self._query_future), timeout=2.0), dtype=dtype) + except (SerialException, asyncio.TimeoutError) as e: + self._error_event.set() + raise RunzeSyringePumpConnectionError from e + finally: + self._query_future = None + + def _parse(self, data: bytes, dtype: Optional[type] = None): + response = data.decode() + + if dtype == bool: + return response == "1" + elif dtype == int: + return int(response) + else: + return response + + def _receive(self, data: bytes): + ascii_string = "".join(chr(byte) for byte in data) + was_busy = self._busy + self._busy = ((data[0] & (1 << 5)) < 1) or ascii_string.startswith("@") + + if self._run_future and was_busy and not self._busy: + self._run_future.set_result(data) + if self._query_future: + self._query_future.set_result(data) + else: + raise Exception("Dropping data") + + async def _run(self, command: str): + async with self._run_lock: + self._run_future = Future[Any]() + + try: + await self._query(command) + while True: + await asyncio.sleep(0.5) # Wait for 0.5 seconds before polling again + + status = await self.query_device_status() + if status == '`': + break + await asyncio.shield(self._run_future) + finally: + self._run_future = None + + async def initialize(self): + response = await self._run("Z") + if self.mode: + self.set_step_mode(self.mode) + else: + # self.mode = RunzeSyringePumpMode.Normal + # # self.set_step_mode(self.mode) + self.mode = await self.query_step_mode() + return response + + # Settings + + async def set_baudrate(self, baudrate): + if baudrate == 9600: + return await self._run("U41") + elif baudrate == 38400: + return await self._run("U47") + else: + raise ValueError("Unsupported baudrate") + + # Mode Settings and Queries + + async def set_step_mode(self, mode: RunzeSyringePumpMode): + self.mode = mode + self._adjust_total_steps() + command = f"N{mode.value}" + return await self._run(command) + + async def query_step_mode(self): + response = await self._query("?28") + status, mode = response[0], int(response[1]) + self.mode = RunzeSyringePumpMode._value2member_map_[mode] + self._adjust_total_steps() + return self.mode + + # Speed Settings and Queries + + async def set_speed_grade(self, speed: Union[int, str]): + return await self._run(f"S{speed}") + + async def set_speed_max(self, speed: float): + pulse_freq = int(speed / self.volume * self.total_steps_vel) + pulse_freq = min(6000, pulse_freq) + return await self._run(f"V{speed}") + + async def query_speed_grade(self): + pulse_freq, speed = await self.query_speed_max() + g = "-1" + for freq, grade in pulse_freq_grades.items(): + if pulse_freq >= freq: + g = grade + break + return g + + async def query_speed_init(self): + response = await self._query("?1") + status, pulse_freq = response[0], int(response[1:]) + speed = pulse_freq / self.total_steps_vel * self.volume + return pulse_freq, speed + + async def query_speed_max(self): + response = await self._query("?2") + status, pulse_freq = response[0], int(response[1:]) + speed = pulse_freq / self.total_steps_vel * self.volume + return pulse_freq, speed + + async def query_speed_end(self): + response = await self._query("?3") + status, pulse_freq = response[0], int(response[1:]) + speed = pulse_freq / self.total_steps_vel * self.volume + return pulse_freq, speed + + # Operations + + # Valve Setpoint and Queries + + async def set_valve_position(self, position: Union[int, str]): + command = f"I{position}" if type(position) == int or ord(position) <= 57 else position.upper() + return await self._run(command) + + async def query_valve_position(self): + response = await self._query("?6") + status, pos_valve = response[0], response[1].upper() + return pos_valve + + # Plunger Setpoint and Queries + + async def move_plunger_to(self, volume: float): + """ + Move to absolute volume (unit: μL) + + Args: + volume (float): absolute position of the plunger, unit: μL + + Returns: + None + """ + pos_step = int(volume / self.volume * self.total_steps) + return await self._run(f"A{pos_step}") + + async def pull_plunger(self, volume: float): + """ + Pull a fixed volume (unit: μL) + + Args: + volume (float): absolute position of the plunger, unit: μL + + Returns: + None + """ + pos_step = int(volume / self.volume * self.total_steps) + return await self._run(f"P{pos_step}") + + async def push_plunger(self, volume: float): + """ + Push a fixed volume (unit: μL) + + Args: + volume (float): absolute position of the plunger, unit: μL + + Returns: + None + """ + pos_step = int(volume / self.volume * self.total_steps) + return await self._run(f"D{pos_step}") + + async def report_position(self): + response = await self._query("?0") + status, pos_step = response[0], int(response[1:]) + return pos_step / self.total_steps * self.volume + + async def query_plunger_position(self): + response = await self._query("?4") + status, pos_step = response[0], int(response[1:]) + return pos_step / self.total_steps * self.volume + + async def stop_operation(self): + return await self._run("T") + + # Queries + + async def query_device_status(self): + return await self._query("Q") + + async def query_command_buffer_status(self): + return await self._query("?10") + + async def query_backlash_position(self): + return await self._query("?12") + + async def query_aux_input_status_1(self): + return await self._query("?13") + + async def query_aux_input_status_2(self): + return await self._query("?14") + + async def query_software_version(self): + return await self._query("?23") + + async def wait_error(self): + await self._error_event.wait() + + async def __aenter__(self): + await self.open() + return self + + async def __aexit__(self, exc_type, exc, tb): + await self.close() + + async def open(self): + if self._read_task: + raise RunzeSyringePumpConnectionError + + self._read_task = asyncio.create_task(self._read_loop()) + + try: + await self.query_device_status() + except Exception: + await self.close() + raise + + async def close(self): + if self._closing or not self._read_task: + raise RunzeSyringePumpConnectionError + + self._closing = True + self._read_task.cancel() + + try: + await self._read_task + except asyncio.CancelledError: + pass + finally: + del self._read_task + + self._serial.close() + + @staticmethod + def list(): + for item in serial.tools.list_ports.comports(): + yield RunzeSyringePumpInfo(port=item.device) diff --git a/unilabos/devices/pump_and_valve/runze_backbone.py b/unilabos/devices/pump_and_valve/runze_backbone.py new file mode 100644 index 00000000..f6629ee0 --- /dev/null +++ b/unilabos/devices/pump_and_valve/runze_backbone.py @@ -0,0 +1,388 @@ +import asyncio +from threading import Lock, Event +from enum import Enum +from dataclasses import dataclass +import time +from typing import Any, Union, Optional, overload + +import serial.tools.list_ports +from serial import Serial +from serial.serialutil import SerialException + + +class RunzeSyringePumpMode(Enum): + Normal = 0 + AccuratePos = 1 + AccuratePosVel = 2 + + +pulse_freq_grades = { + 6000: "0" , + 5600: "1" , + 5000: "2" , + 4400: "3" , + 3800: "4" , + 3200: "5" , + 2600: "6" , + 2200: "7" , + 2000: "8" , + 1800: "9" , + 1600: "10", + 1400: "11", + 1200: "12", + 1000: "13", + 800 : "14", + 600 : "15", + 400 : "16", + 200 : "17", + 190 : "18", + 180 : "19", + 170 : "20", + 160 : "21", + 150 : "22", + 140 : "23", + 130 : "24", + 120 : "25", + 110 : "26", + 100 : "27", + 90 : "28", + 80 : "29", + 70 : "30", + 60 : "31", + 50 : "32", + 40 : "33", + 30 : "34", + 20 : "35", + 18 : "36", + 16 : "37", + 14 : "38", + 12 : "39", + 10 : "40", +} + + +class RunzeSyringePumpConnectionError(Exception): + pass + + +@dataclass(frozen=True, kw_only=True) +class RunzeSyringePumpInfo: + port: str + address: str = "1" + + max_volume: float = 25.0 + mode: RunzeSyringePumpMode = RunzeSyringePumpMode.Normal + + def create(self): + return RunzeSyringePump(self.port, self.address, self.max_volume, self.mode) + + +class RunzeSyringePump: + def __init__(self, port: str, address: str = "1", max_volume: float = 25.0, mode: RunzeSyringePumpMode = None): + self.port = port + self.address = address + + self.max_volume = max_volume + self.total_steps = self.total_steps_vel = 6000 + + self._status = "Idle" + self._mode = mode + self._max_velocity = 0 + self._valve_position = "I" + self._position = 0 + + try: + # if port in serial_ports and serial_ports[port].is_open: + # self.hardware_interface = serial_ports[port] + # else: + # serial_ports[port] = self.hardware_interface = Serial( + # baudrate=9600, + # port=port + # ) + self.hardware_interface = Serial( + baudrate=9600, + port=port + ) + + except (OSError, SerialException) as e: + # raise RunzeSyringePumpConnectionError from e + self.hardware_interface = port + + self._busy = False + self._closing = False + self._error_event = Event() + self._query_lock = Lock() + self._run_lock = Lock() + + def _adjust_total_steps(self): + self.total_steps = 6000 if self.mode == RunzeSyringePumpMode.Normal else 48000 + self.total_steps_vel = 48000 if self.mode == RunzeSyringePumpMode.AccuratePosVel else 6000 + + def send_command(self, full_command: str): + full_command_data = bytearray(full_command, 'ascii') + response = self.hardware_interface.write(full_command_data) + time.sleep(0.05) + output = self._receive(self.hardware_interface.read_until(b"\n")) + return output + + def _query(self, command: str): + with self._query_lock: + if self._closing: + raise RunzeSyringePumpConnectionError + + run = 'R' if not "?" in command else '' + full_command = f"/{self.address}{command}{run}\r\n" + + output = self.send_command(full_command)[3:-3] + return output + + def _parse(self, data: bytes, dtype: Optional[type] = None): + response = data.decode() + + if dtype == bool: + return response == "1" + elif dtype == int: + return int(response) + else: + return response + + def _receive(self, data: bytes): + ascii_string = "".join(chr(byte) for byte in data) + was_busy = self._busy + self._busy = ((data[0] & (1 << 5)) < 1) or ascii_string.startswith("@") + return ascii_string + + def _run(self, command: str): + with self._run_lock: + try: + response = self._query(command) + while True: + time.sleep(0.5) # Wait for 0.5 seconds before polling again + + status = self.get_status() + if status == 'Idle': + break + finally: + pass + return response + + def initialize(self): + print("Initializing Runze Syringe Pump") + response = self._run("Z") + # if self.mode: + # self.set_mode(self.mode) + # else: + # # self.mode = RunzeSyringePumpMode.Normal + # # self.set_mode(self.mode) + # self.mode = self.get_mode() + return response + + # Settings + + def set_baudrate(self, baudrate): + if baudrate == 9600: + return self._run("U41") + elif baudrate == 38400: + return self._run("U47") + else: + raise ValueError("Unsupported baudrate") + + # Device Status + @property + def status(self) -> str: + return self._status + + def _standardize_status(self, status_raw): + return "Idle" if status_raw == "`" else "Busy" + + def get_status(self): + status_raw = self._query("Q") + self._status = self._standardize_status(status_raw) + return self._status + + # Mode Settings and Queries + + @property + def mode(self) -> int: + return self._mode + + # def set_mode(self, mode: RunzeSyringePumpMode): + # self.mode = mode + # self._adjust_total_steps() + # command = f"N{mode.value}" + # return self._run(command) + + # def get_mode(self): + # response = self._query("?28") + # status_raw, mode = response[0], int(response[1]) + # self.mode = RunzeSyringePumpMode._value2member_map_[mode] + # self._adjust_total_steps() + # return self.mode + + # Speed Settings and Queries + + @property + def max_velocity(self) -> float: + return self._max_velocity + + def set_max_velocity(self, velocity: float): + self._max_velocity = velocity + pulse_freq = int(velocity / self.max_volume * self.total_steps_vel) + pulse_freq = min(6000, pulse_freq) + return self._run(f"V{pulse_freq}") + + def get_max_velocity(self): + response = self._query("?2") + status_raw, pulse_freq = response[0], int(response[1:]) + self._status = self._standardize_status(status_raw) + self._max_velocity = pulse_freq / self.total_steps_vel * self.max_volume + return self._max_velocity + + def set_velocity_grade(self, velocity: Union[int, str]): + return self._run(f"S{velocity}") + + def get_velocity_grade(self): + response = self._query("?2") + status_raw, pulse_freq = response[0], int(response[1:]) + g = "-1" + for freq, grade in pulse_freq_grades.items(): + if pulse_freq >= freq: + g = grade + break + return g + + def get_velocity_init(self): + response = self._query("?1") + status_raw, pulse_freq = response[0], int(response[1:]) + self._status = self._standardize_status(status_raw) + velocity = pulse_freq / self.total_steps_vel * self.max_volume + return pulse_freq, velocity + + def get_velocity_end(self): + response = self._query("?3") + status_raw, pulse_freq = response[0], int(response[1:]) + self._status = self._standardize_status(status_raw) + velocity = pulse_freq / self.total_steps_vel * self.max_volume + return pulse_freq, velocity + + # Operations + + # Valve Setpoint and Queries + + @property + def valve_position(self) -> str: + return self._valve_position + + def set_valve_position(self, position: Union[int, str, float]): + if type(position) == float: + position = round(position / 120) + command = f"I{position}" if type(position) == int or ord(position) <= 57 else position.upper() + response = self._run(command) + self._valve_position = f"{position}" if type(position) == int or ord(position) <= 57 else position.upper() + return response + + def get_valve_position(self) -> str: + response = self._query("?6") + status_raw, pos_valve = response[0], response[1].upper() + self._valve_position = pos_valve + self._status = self._standardize_status(status_raw) + return pos_valve + + # Plunger Setpoint and Queries + + @property + def position(self) -> float: + return self._position + + def get_position(self): + response = self._query("?0") + status_raw, pos_step = response[0], int(response[1:]) + self._status = self._standardize_status(status_raw) + return pos_step / self.total_steps * self.max_volume + + def set_position(self, position: float, max_velocity: float = None): + """ + Move to absolute volume (unit: ml) + + Args: + position (float): absolute position of the plunger, unit: ml + max_velocity (float): maximum velocity of the plunger, unit: ml/s + + Returns: + None + """ + if max_velocity is not None: + self.set_max_velocity(max_velocity) + pulse_freq = int(max_velocity / self.max_volume * self.total_steps_vel) + pulse_freq = min(6000, pulse_freq) + velocity_cmd = f"V{pulse_freq}" + else: + velocity_cmd = "" + pos_step = int(position / self.max_volume * self.total_steps) + return self._run(f"{velocity_cmd}A{pos_step}") + + def pull_plunger(self, volume: float): + """ + Pull a fixed volume (unit: ml) + + Args: + volume (float): absolute position of the plunger, unit: mL + + Returns: + None + """ + pos_step = int(volume / self.max_volume * self.total_steps) + return self._run(f"P{pos_step}") + + def push_plunger(self, volume: float): + """ + Push a fixed volume (unit: ml) + + Args: + volume (float): absolute position of the plunger, unit: mL + + Returns: + None + """ + pos_step = int(volume / self.max_volume * self.total_steps) + return self._run(f"D{pos_step}") + + def get_plunger_position(self): + response = self._query("?4") + status, pos_step = response[0], int(response[1:]) + return pos_step / self.total_steps * self.max_volume + + def stop_operation(self): + return self._run("T") + + # Queries + + def query_command_buffer_status(self): + return self._query("?10") + + def query_backlash_position(self): + return self._query("?12") + + def query_aux_input_status_1(self): + return self._query("?13") + + def query_aux_input_status_2(self): + return self._query("?14") + + def query_software_version(self): + return self._query("?23") + + def wait_error(self): + self._error_event.wait() + + def close(self): + if self._closing: + raise RunzeSyringePumpConnectionError + + self._closing = True + self.hardware_interface.close() + + @staticmethod + def list(): + for item in serial.tools.list_ports.comports(): + yield RunzeSyringePumpInfo(port=item.device) diff --git a/unilabos/devices/pump_and_valve/solenoid_valve.py b/unilabos/devices/pump_and_valve/solenoid_valve.py new file mode 100644 index 00000000..e068d502 --- /dev/null +++ b/unilabos/devices/pump_and_valve/solenoid_valve.py @@ -0,0 +1,51 @@ +import time +import serial + + +class SolenoidValve: + def __init__(self, io_device_port: str): + self._status = "Idle" + self._valve_position = "OPEN" + self.io_device_port = io_device_port + + try: + self.hardware_interface = serial.Serial(str(io_device_port), 9600, timeout=1) + except serial.SerialException: + # raise Exception(f"Failed to connect to the device at {io_device_port}") + self.hardware_interface = str(io_device_port) + + @property + def status(self) -> str: + return self._status + + @property + def valve_position(self) -> str: + return self._valve_position + + def send_command(self, command): + self.hardware_interface.write(command) + + def read_data(self): + return self.hardware_interface.read() + + def get_valve_position(self) -> str: + self._valve_position = "OPEN" if self.read_data() else "CLOSED" + return self._valve_position + + def set_valve_position(self, position): + self._status = "Busy" + self.send_command(1 if position == "OPEN" else 0) + time.sleep(5) + self._status = "Idle" + + def open(self): + self._valve_position = "OPEN" + + def close(self): + self._valve_position = "CLOSED" + + def is_open(self): + return self._valve_position + + def is_closed(self): + return not self._valve_position diff --git a/unilabos/devices/pump_and_valve/solenoid_valve_mock.py b/unilabos/devices/pump_and_valve/solenoid_valve_mock.py new file mode 100644 index 00000000..08820ca0 --- /dev/null +++ b/unilabos/devices/pump_and_valve/solenoid_valve_mock.py @@ -0,0 +1,38 @@ +import time + + +class SolenoidValveMock: + def __init__(self, port: str = "COM6"): + self._status = "Idle" + self._valve_position = "OPEN" + + @property + def status(self) -> str: + return self._status + + @property + def valve_position(self) -> str: + return self._valve_position + + def get_valve_position(self) -> str: + return self._valve_position + + def set_valve_position(self, position): + self._status = "Busy" + time.sleep(5) + + self._valve_position = position + time.sleep(5) + self._status = "Idle" + + def open(self): + self._valve_position = "OPEN" + + def close(self): + self._valve_position = "CLOSED" + + def is_open(self): + return self._valve_position + + def is_closed(self): + return not self._valve_position diff --git a/unilabos/devices/pump_and_valve/vacuum_pump_mock.py b/unilabos/devices/pump_and_valve/vacuum_pump_mock.py new file mode 100644 index 00000000..3e330570 --- /dev/null +++ b/unilabos/devices/pump_and_valve/vacuum_pump_mock.py @@ -0,0 +1,31 @@ +import time + + +class VacuumPumpMock: + def __init__(self, port: str = "COM6"): + self._status = "OPEN" + + @property + def status(self) -> str: + return self._status + + def get_status(self) -> str: + return self._status + + def set_status(self, position): + time.sleep(5) + + self._status = position + time.sleep(5) + + def open(self): + self._status = "OPEN" + + def close(self): + self._status = "CLOSED" + + def is_open(self): + return self._status + + def is_closed(self): + return not self._status diff --git a/unilabos/devices/raman_uv/__init__.py b/unilabos/devices/raman_uv/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/devices/raman_uv/home_made_raman.py b/unilabos/devices/raman_uv/home_made_raman.py new file mode 100644 index 00000000..dcc3b565 --- /dev/null +++ b/unilabos/devices/raman_uv/home_made_raman.py @@ -0,0 +1,198 @@ +import json +import serial +import struct +import crcmod +import tkinter as tk +from tkinter import messagebox +import matplotlib.pyplot as plt +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg +import time + +class RamanObj: + def __init__(self, port_laser,port_ccd, baudrate_laser=9600, baudrate_ccd=921600): + self.port_laser = port_laser + self.port_ccd = port_ccd + + self.baudrate_laser = baudrate_laser + self.baudrate_ccd = baudrate_ccd + self.success = False + self.status = "None" + self.ser_laser = serial.Serial( self.port_laser, + self.baudrate_laser, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + timeout=1) + + self.ser_ccd = serial.Serial( self.port_ccd, + self.baudrate_ccd, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + timeout=1) + + def laser_on_power(self, output_voltage_laser): + + def calculate_crc(data): + """ + 计算Modbus CRC校验码 + """ + crc16 = crcmod.predefined.mkCrcFun('modbus') + return crc16(data) + + + device_id = 0x01 # 从机地址 + register_address = 0x0298 # 寄存器地址 + output_voltage = int(output_voltage_laser) # 输出值 + + # 将输出值转换为需要发送的格式 + output_value = int(output_voltage) # 确保是整数 + high_byte = (output_value >> 8) & 0xFF + low_byte = output_value & 0xFF + + # 构造Modbus RTU数据包 + function_code = 0x06 # 写单个寄存器 + message = struct.pack('>BBHH', device_id, function_code, register_address, output_value) + crc = calculate_crc(message) + crc_bytes = struct.pack('= 5: + response_crc = calculate_crc(response[:-2]) + received_crc = struct.unpack('H", data[i:i + 2])[0] for i in range(0, len(data), 2)] + return values + + try: + ser = self.ser_ccd + values = read_and_plot(ser, int_time) # 修正传递serial对象 + print(f"\u8fd4\u56de\u503c: {values}") + return values + except Exception as e: + messagebox.showerror("\u9519\u8bef", f"\u4e32\u53e3\u521d\u59cb\u5316\u5931\u8d25: {e}") + return None + + def raman_without_background(self,int_time, laser_power): + self.laser_on_power(0) + time.sleep(0.1) + ccd_data_background = self.ccd_time(int_time) + time.sleep(0.1) + self.laser_on_power(laser_power) + time.sleep(0.2) + ccd_data_total = self.ccd_time(int_time) + self.laser_on_power(0) + ccd_data = [x - y for x, y in zip(ccd_data_total, ccd_data_background)] + return ccd_data + + def raman_without_background_average(self, sample_name , int_time, laser_power, average): + ccd_data = [0] * 4136 + for i in range(average): + ccd_data_temp = self.raman_without_background(int_time, laser_power) + ccd_data = [x + y for x, y in zip(ccd_data, ccd_data_temp)] + ccd_data = [x / average for x in ccd_data] + #保存数据 用时间命名 + t = time.strftime("%Y-%m-%d-%H-%M-%S-%MS", time.localtime()) + folder_path = r"C:\auto\raman_data" + with open(f'{folder_path}/{sample_name}_{t}.txt', 'w') as f: + for i in ccd_data: + f.write(str(i) + '\n') + return ccd_data + + def raman_cmd(self, command:str): + self.success = False + try: + cmd_dict = json.loads(command) + ccd_data = self.raman_without_background_average(**cmd_dict) + self.success = True + + # return ccd_data + except Exception as e: + # return None + raise f"error: {e}" + +if __name__ == "__main__": + raman = RamanObj(port_laser='COM20', port_ccd='COM2') + ccd_data = raman.raman_without_background_average('test44',0.5,3000,1) + + plt.plot(ccd_data) + plt.xlabel("Pixel") + plt.ylabel("Intensity") + plt.title("Raman Spectrum") + plt.show() + + + + + + diff --git a/unilabos/devices/rotavap/__init__.py b/unilabos/devices/rotavap/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/devices/rotavap/rotavap_one.py b/unilabos/devices/rotavap/rotavap_one.py new file mode 100644 index 00000000..507e1231 --- /dev/null +++ b/unilabos/devices/rotavap/rotavap_one.py @@ -0,0 +1,64 @@ +import json +from serial import Serial +import time as time_ +import threading + +class RotavapOne: + def __init__(self, port, rate=9600): + self.ser = Serial(port,rate) + self.pump_state = 'h' + self.pump_time = 0 + self.rotate_state = 'h' + self.rotate_time = 0 + self.success = True + if not self.ser.is_open: + self.ser.open() + + # self.main_loop() + t = threading.Thread(target=self.main_loop ,daemon=True) + t.start() + + def cmd_write(self, cmd): + self.ser.write(cmd) + self.ser.read_all() + + def set_rotate_time(self, time): + self.success = False + self.rotate_time = time + self.success = True + + + def set_pump_time(self, time): + self.success = False + self.pump_time = time + self.success = True + + def set_timer(self, command): + self.success = False + timer = json.loads(command) + + rotate_time = timer['rotate_time'] + pump_time = timer['pump_time'] + + self.rotate_time = rotate_time + self.pump_time = pump_time + + self.success = True + + + def main_loop(self): + param = ['rotate','pump'] + while True: + for i in param: + if getattr(self, f'{i}_time') <= 0: + setattr(self, f'{i}_state','l') + else: + setattr(self, f'{i}_state','h') + setattr(self, f'{i}_time',getattr(self, f'{i}_time')-1) + cmd = f'1{self.pump_state}2{self.rotate_state}3l4l\n' + self.cmd_write(cmd.encode()) + + time_.sleep(1) + +if __name__ == '__main__': + ro = RotavapOne(port='COM15') diff --git a/unilabos/devices/separator/__init__.py b/unilabos/devices/separator/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/devices/separator/homemade_grbl_conductivity.py b/unilabos/devices/separator/homemade_grbl_conductivity.py new file mode 100644 index 00000000..9588f195 --- /dev/null +++ b/unilabos/devices/separator/homemade_grbl_conductivity.py @@ -0,0 +1,156 @@ +import json +import serial +import time as systime +import threading + + +class SeparatorController: + """Controls the operation of a separator device using serial communication. + + This class manages the interaction with both an executor and a sensor through serial ports, allowing for stirring, settling, and extracting operations based on sensor data. + + Args: + port_executor (str): The serial port for the executor device. + port_sensor (str): The serial port for the sensor device. + baudrate_executor (int, optional): The baud rate for the executor device. Defaults to 115200. + baudrate_sensor (int, optional): The baud rate for the sensor device. Defaults to 115200. + + Attributes: + serial_executor (Serial): The serial connection to the executor device. + serial_sensor (Serial): The serial connection to the sensor device. + sensordata (float): The latest sensor data read from the sensor device. + success (bool): Indicates whether the last operation was successful. + status (str): The current status of the controller, which can be 'Idle', 'Stirring', 'Settling', or 'Extracting'. + """ + def __init__( + self, + port_executor: str, + port_sensor: str, + baudrate_executor: int = 115200, + baudrate_sensor: int = 115200, + ): + + self.serial_executor = serial.Serial(port_executor, baudrate_executor) + self.serial_sensor = serial.Serial(port_sensor, baudrate_sensor) + + if not self.serial_executor.is_open: + self.serial_executor.open() + if not self.serial_sensor.is_open: + self.serial_sensor.open() + + self.sensordata = 0.00 + self.success = False + + self.status = "Idle" # 'Idle', 'Stirring' ,'Settling' , 'Extracting', + + systime.sleep(2) + t = threading.Thread(target=self.read_sensor_loop, daemon=True) + t.start() + + def write(self, data): + self.serial_executor.write(data) + a = self.serial_executor.read_all() + + def stir(self, stir_time: float = 10, stir_speed: float = 300, settling_time: float = 10): + """Controls the stirring operation of the separator. + + This function initiates a stirring process for a specified duration and speed, followed by a settling phase. It updates the status of the controller and communicates with the executor to perform the stirring action. + + Args: + stir_time (float, optional): The duration for which to stir, in seconds. Defaults to 10. + stir_speed (float, optional): The speed of stirring, in RPM. Defaults to 300. + settling_time (float, optional): The duration for which to settle after stirring, in seconds. Defaults to 10. + + Returns: + None + """ + self.success = False + start_time = systime.time() + self.status = "Stirring" + + stir_speed_second = stir_speed / 60 + cmd = f"G91 Z{stir_speed_second}\n" + cmd_data = bytearray(cmd, "ascii") + self.write(bytearray(f"$112={stir_speed_second}", "ascii")) + while self.status != "Idle": + # print(self.sensordata) + if self.status == "Stirring": + if systime.time() - start_time < stir_time: + self.write(cmd_data) + systime.sleep(1) + else: + self.status = "Settling" + start_time = systime.time() + + elif self.status == "Settling": + if systime.time() - start_time > settling_time: + break + self.success = True + + def valve_open(self, condition, value): + """Opens the valve, then wait to close the valve based on a specified condition. + + This function sends a command to open the valve and continuously monitors the sensor data until the specified condition is met. Once the condition is satisfied, it closes the valve and updates the status of the controller. + + Args: + + condition (str): The condition to be monitored, either 'delta' or 'time'. + value (float): The threshold value for the condition. + `delta > 0.05`, `time > 60` + + Returns: + None + """ + if condition not in ["delta", "time"]: + raise ValueError("Invalid condition") + elif condition == "delta": + valve_position = 0.66 + else: + valve_position = 0.8 + + self.write((f"G91 X{valve_position}\n").encode()) + last = self.sensordata + start_time = systime.time() + while True: + data = self.sensordata + delta = abs(data - last) + time = systime.time() - start_time + + if eval(f"{condition} > {value}"): + break + last = data + systime.sleep(0.05) + + self.status = "Idle" + + self.write((f"G91 X-{valve_position}\n").encode()) + + def valve_open_cmd(self,command:str): + self.success = False + try: + cmd_dict = json.loads(command) + self.valve_open(**cmd_dict) + self.success = True + except Exception as e: + raise f"error: {e}" + + def read_sensor_loop(self): + while True: + msg = self.serial_sensor.readline() + ascii_string = "".join(chr(byte) for byte in msg) + # print(msg) + if ascii_string.startswith("A3"): + try: + self.sensordata = float(ascii_string.split(":")[1]) + except: + return + self.serial_sensor.read_all() + systime.sleep(0.05) + + +if __name__ == "__main__": + + e = SeparatorController(port_sensor="COM40", port_executor="COM41") + print(e.status) + e.stir(10, 720, 10) + e.valve_open("delta", 0.3) diff --git a/unilabos/devices/temperature/__init__.py b/unilabos/devices/temperature/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/devices/temperature/chiller.py b/unilabos/devices/temperature/chiller.py new file mode 100644 index 00000000..22878b5c --- /dev/null +++ b/unilabos/devices/temperature/chiller.py @@ -0,0 +1,67 @@ +import json +import serial + +class Chiller(): +# def xuanzheng_temp_set(tempset: int): + # 设置目标温度 + def __init__(self, port,rate=9600): + self.T_set = 24 + self.success = True + self.ser = serial.Serial( + port=port, + baudrate=rate, + bytesize=8, + parity='N', + stopbits=1, + timeout=1 + ) + + def modbus_crc(self, data: bytes) -> bytes: + crc = 0xFFFF + for pos in data: + crc ^= pos + for _ in range(8): + if (crc & 0x0001) != 0: + crc >>= 1 + crc ^= 0xA001 + else: + crc >>= 1 + return crc.to_bytes(2, byteorder='little') + + def build_modbus_frame(self,device_address: int, function_code: int, register_address: int, value: int) -> bytes: + frame = bytearray([ + device_address, + function_code, + (register_address >> 8) & 0xFF, + register_address & 0xFF, + (value >> 8) & 0xFF, + value & 0xFF + ]) + crc = self.modbus_crc(frame) + return frame + crc + + def convert_temperature_to_modbus_value(self, temperature: float, decimal_points: int = 1) -> int: + factor = 10 ** decimal_points + value = int(temperature * factor) + return value & 0xFFFF + + def set_temperature(self, command): + T_set = json.loads(command)['temperature'] + self.T_set = int(T_set) + self.success = False + + temperature_value = self.convert_temperature_to_modbus_value(self.T_set, decimal_points=1) + device_address = 0x01 + function_code = 0x06 + register_address = 0x000B + frame = self.build_modbus_frame(device_address, function_code, register_address, temperature_value) + self.ser.write(frame) + response = self.ser.read(8) + self.success = True + + def stop(self): + self.set_temperature(25) + +if __name__ == '__main__': + ch = Chiller(port='COM17') + ch.set_temperature(20) \ No newline at end of file diff --git a/unilabos/devices/temperature/prototype_sensor.py b/unilabos/devices/temperature/prototype_sensor.py new file mode 100644 index 00000000..84a134ab --- /dev/null +++ b/unilabos/devices/temperature/prototype_sensor.py @@ -0,0 +1,69 @@ +import serial +import struct + +class TempSensor: + def __init__(self,port,baudrate=9600): + + # 配置串口 + self.ser = serial.Serial( + port=port, + baudrate=baudrate, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + timeout=1 + ) + + def calculate_crc(self,data): + crc = 0xFFFF + for pos in data: + crc ^= pos + for i in range(8): + if (crc & 0x0001) != 0: + crc >>= 1 + crc ^= 0xA001 + else: + crc >>= 1 + return crc + + def build_modbus_request(self, device_id, function_code, register_address, register_count): + request = struct.pack('>BBHH', device_id, function_code, register_address, register_count) + crc = self.calculate_crc(request) + request += struct.pack('H', data[:2])[0] + low_value = struct.unpack('>H', data[2:])[0] + + # 组合高位和低位并计算实际温度 + raw_temperature = (high_value << 16) | low_value + if raw_temperature & 0x8000: # 如果低位寄存器最高位为1,表示负值 + raw_temperature -= 0x10000 # 转换为正确的负数表示 + + actual_temperature = raw_temperature / 10.0 + return actual_temperature + + \ No newline at end of file diff --git a/unilabos/devices/temperature/sensor.py b/unilabos/devices/temperature/sensor.py new file mode 100644 index 00000000..81ffa5f2 --- /dev/null +++ b/unilabos/devices/temperature/sensor.py @@ -0,0 +1,90 @@ +import serial +import time +import struct +import tkinter as tk +from tkinter import ttk + +# 配置串口 +ser = serial.Serial( + port='COM13', + baudrate=9600, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + timeout=1 +) + +def calculate_crc(data): + crc = 0xFFFF + for pos in data: + crc ^= pos + for i in range(8): + if (crc & 0x0001) != 0: + crc >>= 1 + crc ^= 0xA001 + else: + crc >>= 1 + return crc + +def build_modbus_request(device_id, function_code, register_address, register_count): + request = struct.pack('>BBHH', device_id, function_code, register_address, register_count) + crc = calculate_crc(request) + request += struct.pack('H', data[:2])[0] + low_value = struct.unpack('>H', data[2:])[0] + + # 组合高位和低位并计算实际温度 + raw_temperature = (high_value << 16) | low_value + if raw_temperature & 0x8000: # 如果低位寄存器最高位为1,表示负值 + raw_temperature -= 0x10000 # 转换为正确的负数表示 + + actual_temperature = raw_temperature / 10.0 + return actual_temperature + +def update_temperature_label(): + temperature = read_temperature() + if temperature is not None: + temperature_label.config(text=f"反应温度: {temperature:.1f} °C") + else: + temperature_label.config(text="Error reading temperature") + root.after(1000, update_temperature_label) # 每秒更新一次 + +# 创建主窗口 +root = tk.Tk() +root.title("反应温度监控") + +# 创建标签来显示温度 +temperature_label = ttk.Label(root, text="反应温度: -- °C", font=("Helvetica", 20)) +temperature_label.pack(padx=20, pady=20) + +# 开始更新温度 +update_temperature_label() + +# 运行主循环 +root.mainloop() diff --git a/unilabos/devices/temperature/sensor_node.py b/unilabos/devices/temperature/sensor_node.py new file mode 100644 index 00000000..cb3b175f --- /dev/null +++ b/unilabos/devices/temperature/sensor_node.py @@ -0,0 +1,117 @@ +import json +import threading,time + +# class TempSensorNode: +# def __init__(self,port,warning,address): +# self._value = 0.0 +# self.warning = warning +# self.device_id = address + +# self.hardware_interface = port +# # t = threading.Thread(target=self.read_temperature, daemon=True) +# # t.start() + +# def send_command(self ,command): +# print('send_command---------------------') +# pass + +# @property +# def value(self) -> float: +# self._value = self.send_command(self.device_id) +# return self._value +# # def read_temperature(self): +# # while True: +# # self.value = self.send_command(self.device_id) +# # print(self.value,'-----------') +# # time.sleep(1) + +# def set_warning(self, warning_temp): +# self.warning = warning_temp + + + +import serial +import struct +from rclpy.node import Node +import rclpy +import threading + +class TempSensorNode(): + def __init__(self,port,warning,address,baudrate=9600): + self._value = 0.0 + self.warning = warning + self.device_id = address + self.success = False + # 配置串口 + self.hardware_interface = serial.Serial( + port=port, + baudrate=baudrate, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + timeout=1 + ) + self.lock = threading.Lock() + + def calculate_crc(self,data): + crc = 0xFFFF + for pos in data: + crc ^= pos + for i in range(8): + if (crc & 0x0001) != 0: + crc >>= 1 + crc ^= 0xA001 + else: + crc >>= 1 + return crc + + def build_modbus_request(self, device_id, function_code, register_address, register_count): + request = struct.pack('>BBHH', device_id, function_code, register_address, register_count) + crc = self.calculate_crc(request) + request += struct.pack('H', data[:2])[0] + low_value = struct.unpack('>H', data[2:])[0] + + # 组合高位和低位并计算实际温度 + raw_temperature = (high_value << 16) | low_value + if raw_temperature & 0x8000: # 如果低位寄存器最高位为1,表示负值 + raw_temperature -= 0x10000 # 转换为正确的负数表示 + + actual_temperature = raw_temperature / 10.0 + return actual_temperature + + @property + def value(self) -> float: + self._value = self.send_prototype_command(self.device_id) + return self._value + + def set_warning(self, command): + self.success = False + temp = json.loads(command)["warning_temp"] + self.warning = round(float(temp), 1) + self.success = True diff --git a/unilabos/devices/vaccum/__init__.py b/unilabos/devices/vaccum/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/messages/__init__.py b/unilabos/messages/__init__.py new file mode 100644 index 00000000..7bff6dc5 --- /dev/null +++ b/unilabos/messages/__init__.py @@ -0,0 +1,73 @@ +from pydantic import BaseModel, Field +import pint + + +class Point3D(BaseModel): + x: float = Field(..., title="X coordinate") + y: float = Field(..., title="Y coordinate") + z: float = Field(..., title="Z coordinate") + +# Start Protocols + +class PumpTransferProtocol(BaseModel): + from_vessel: str + to_vessel: str + volume: float + amount: str = "" + time: float = 0 + viscous: bool = False + rinsing_solvent: str = "air" + rinsing_volume: float = 5000 + rinsing_repeats: int = 2 + solid: bool = False + flowrate: float = 500 + transfer_flowrate: float = 2500 + + +class CleanProtocol(BaseModel): + vessel: str + solvent: str + volume: float + temp: float + repeats: int = 1 + + +class SeparateProtocol(BaseModel): + purpose: str # 'wash' or 'extract'. 'wash' means that product phase will not be the added solvent phase, 'extract' means product phase will be the added solvent phase. If no solvent is added just use 'extract'. + product_phase: str # 'top' or 'bottom'. Phase that product will be in. + from_vessel: str #Contents of from_vessel are transferred to separation_vessel and separation is performed. + separation_vessel: str # Vessel in which separation of phases will be carried out. + to_vessel: str # Vessel to send product phase to. + waste_phase_to_vessel: str # Optional. Vessel to send waste phase to. + solvent: str # Optional. Solvent to add to separation vessel after contents of from_vessel has been transferred to create two phases. + solvent_volume: float # Optional. Volume of solvent to add. + through: str # Optional. Solid chemical to send product phase through on way to to_vessel, e.g. 'celite'. + repeats: int # Optional. Number of separations to perform. + stir_time: float # Optional. Time stir for after adding solvent, before separation of phases. + stir_speed: float # Optional. Speed to stir at after adding solvent, before separation of phases. + settling_time: float # Optional. Time + + +class EvaporateProtocol(BaseModel): + vessel: str + pressure: float + temp: float + time: float + stir_speed: float + + +class EvacuateAndRefillProtocol(BaseModel): + vessel: str + gas: str + repeats: int + + +class AGVTransferProtocol(BaseModel): + from_repo: dict + to_repo: dict + from_repo_position: str + to_repo_position: str + + +__all__ = ["Point3D", "PumpTransferProtocol", "CleanProtocol", "SeparateProtocol", "EvaporateProtocol", "EvacuateAndRefillProtocol", "AGVTransferProtocol"] +# End Protocols diff --git a/unilabos/registry/__init__.py b/unilabos/registry/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/registry/device_comms/modbus_ioboard.yaml b/unilabos/registry/device_comms/modbus_ioboard.yaml new file mode 100644 index 00000000..31d33fef --- /dev/null +++ b/unilabos/registry/device_comms/modbus_ioboard.yaml @@ -0,0 +1,9 @@ +io_snrd: + class: + module: unilabos.device_comms.SRND_16_IO:SRND_16_IO + type: python + hardware_interface: + name: modbus_client + extra_info: address + read: read_io_coil + write: write_io_coil \ No newline at end of file diff --git a/unilabos/registry/device_comms/serial.yaml b/unilabos/registry/device_comms/serial.yaml new file mode 100644 index 00000000..3a262d8a --- /dev/null +++ b/unilabos/registry/device_comms/serial.yaml @@ -0,0 +1,6 @@ +serial: + class: + module: unilabos.ros.nodes.presets:ROS2SerialNode + type: ros2 + schema: + properties: {} \ No newline at end of file diff --git a/unilabos/registry/devices/characterization_optic.yaml b/unilabos/registry/devices/characterization_optic.yaml new file mode 100644 index 00000000..d1f9cb1e --- /dev/null +++ b/unilabos/registry/devices/characterization_optic.yaml @@ -0,0 +1,23 @@ +# 光学表征设备:红外、紫外可见、拉曼等 +raman_home_made: + class: + module: unilabos.devices.raman_uv.home_made_raman:RamanObj + type: python + status_types: + status: String + action_value_mappings: + raman_cmd: + type: SendCmd + goal: + command: command + feedback: {} + result: + success: success + schema: + properties: + status: + type: string + required: + - status + additionalProperties: false + type: object \ No newline at end of file diff --git a/unilabos/registry/devices/liquid_handler.yaml b/unilabos/registry/devices/liquid_handler.yaml new file mode 100644 index 00000000..03057bdc --- /dev/null +++ b/unilabos/registry/devices/liquid_handler.yaml @@ -0,0 +1,189 @@ +liquid_handler: + class: + module: pylabrobot.liquid_handling:LiquidHandler + type: python + status_types: + name: String + action_value_mappings: + aspirate: + type: LiquidHandlerAspirate + goal: + resources: resources + vols: vols + use_channels: use_channels + flow_rates: flow_rates + end_delay: end_delay + offsets: offsets + liquid_height: liquid_height + blow_out_air_volume: blow_out_air_volume + feedback: {} + result: + name: name + discard_tips: + type: LiquidHandlerDiscardTips + goal: + use_channels: use_channels + feedback: {} + result: + name: name + dispense: + type: LiquidHandlerDispense + goal: + resources: resources + vols: vols + use_channels: use_channels + flow_rates: flow_rates + offsets: offsets + blow_out_air_volume: blow_out_air_volume + spread: spread + feedback: {} + result: + name: name + drop_tips: + type: LiquidHandlerDropTips + goal: + tip_spots: tip_spots + use_channels: use_channels + offsets: offsets + allow_nonzero_volume: allow_nonzero_volume + feedback: {} + result: + name: name + drop_tips96: + type: LiquidHandlerDropTips96 + goal: + tip_rack: tip_rack + offset: offset + allow_nonzero_volume: allow_nonzero_volume + feedback: {} + result: + name: name + move_lid: + type: LiquidHandlerMoveLid + goal: + lid: lid + to: to + intermediate_locations: intermediate_locations + resource_offset: resource_offset + destination_offset: destination_offset + pickup_direction: pickup_direction + drop_direction: drop_direction + get_direction: get_direction + put_direction: put_direction + pickup_distance_from_top: pickup_distance_from_top + feedback: {} + result: + name: name + move_plate: + type: LiquidHandlerMovePlate + goal: + plate: plate + to: to + intermediate_locations: intermediate_locations + resource_offset: resource_offset + pickup_offset: pickup_offset + destination_offset: destination_offset + pickup_direction: pickup_direction + drop_direction: drop_direction + get_direction: get_direction + put_direction: put_direction + feedback: {} + result: + name: name + move_resource: + type: LiquidHandlerMoveResource + goal: + resource: resource + to: to + intermediate_locations: intermediate_locations + resource_offset: resource_offset + destination_offset: destination_offset + pickup_distance_from_top: pickup_distance_from_top + pickup_direction: pickup_direction + drop_direction: drop_direction + get_direction: get_direction + put_direction: put_direction + feedback: {} + result: + name: name + pick_up_tips: + type: LiquidHandlerPickUpTips + goal: + tip_spots: tip_spots + use_channels: use_channels + offsets: offsets + feedback: {} + result: + name: name + pick_up_tips96: + type: LiquidHandlerPickUpTips96 + goal: + tip_rack: tip_rack + offset: offset + feedback: {} + result: + name: name + return_tips: + type: LiquidHandlerReturnTips + goal: + use_channels: use_channels + allow_nonzero_volume: allow_nonzero_volume + feedback: {} + result: + name: name + return_tips96: + type: LiquidHandlerReturnTips96 + goal: + allow_nonzero_volume: allow_nonzero_volume + feedback: {} + result: + name: name + stamp: + type: LiquidHandlerStamp + goal: + source: source + target: target + volume: volume + aspiration_flow_rate: aspiration_flow_rate + dispense_flow_rate: dispense_flow_rate + feedback: {} + result: + name: name + transfer: + type: LiquidHandlerTransfer + goal: + source: source + targets: targets + source_vol: source_vol + ratios: ratios + target_vols: target_vols + aspiration_flow_rate: aspiration_flow_rate + dispense_flow_rates: dispense_flow_rates + schema: + type: object + properties: + status: + type: string + description: 液体处理仪器当前状态 + required: + - status + additionalProperties: false + +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 + \ No newline at end of file diff --git a/unilabos/registry/devices/organic_miscellaneous.yaml b/unilabos/registry/devices/organic_miscellaneous.yaml new file mode 100644 index 00000000..ff8cf735 --- /dev/null +++ b/unilabos/registry/devices/organic_miscellaneous.yaml @@ -0,0 +1,71 @@ +separator.homemade: + class: + module: unilabos.devices.separator.homemade_grbl_conductivity:Separator_Controller + type: python + status_types: + sensordata: Float64 + status: String + action_value_mappings: + stir: + type: Stir + goal: + stir_time: stir_time, + stir_speed: stir_speed + settling_time": settling_time + feedback: + status: status + result: + success: success + valve_open_cmd: + type: SendCmd + goal: + command: command + feedback: + status: status + result": + success: success + schema: + type: object + properties: + status: + type: string + description: The status of the device + sensordata: + type: number + description: 电导传感器数据 + required: + - status + - sensordata + additionalProperties: false + +rotavap.one: + class: + module: unilabos.devices.rotavap.rotavap_one:RotavapOne + type: python + status_types: + pump_time: Float64 + rotate_time: Float64 + action_value_mappings: + set_timer: + type: SendCmd + goal: + command: command + feedback: {} + result: + success: success + schema: + type: object + properties: + temperature: + type: number + description: 旋蒸水浴温度 + pump_time: + type: number + description: The pump time of the device + rotate_time: + type: number + description: The rotate time of the device + required: + - pump_time + - rotate_time + additionalProperties: false \ No newline at end of file diff --git a/unilabos/registry/devices/pump_and_valve.yaml b/unilabos/registry/devices/pump_and_valve.yaml new file mode 100644 index 00000000..d6538b8b --- /dev/null +++ b/unilabos/registry/devices/pump_and_valve.yaml @@ -0,0 +1,35 @@ +syringe_pump_with_valve.runze: + class: + module: unilabos.devices.pump_and_valve.runze_backbone:RunzeSyringePump + type: python + schema: + type: object + properties: + status: + type: string + description: The status of the device + position: + type: number + description: The volume of the syringe + speed_max: + type: number + description: The speed of the syringe + valve_position: + type: string + description: The position of the valve + required: + - status + - position + - valve_position + additionalProperties: false + + +solenoid_valve.mock: + class: + module: unilabos.devices.pump_and_valve.solenoid_valve_mock:SolenoidValveMock + type: python + +solenoid_valve: + class: + module: unilabos.devices.pump_and_valve.solenoid_valve:SolenoidValve + type: python \ No newline at end of file diff --git a/unilabos/registry/devices/robot_agv.yaml b/unilabos/registry/devices/robot_agv.yaml new file mode 100644 index 00000000..384c79f6 --- /dev/null +++ b/unilabos/registry/devices/robot_agv.yaml @@ -0,0 +1,28 @@ +# 仙工智能底盘(知行使用) +agv.SEER: + class: + module: unilabos.devices.agv.agv_navigator:AgvNavigator + type: python + status_types: + pose: Float64MultiArray + status: String + action_value_mappings: + send_nav_task: + type: SendCmd + goal: + command: command + feedback: {} + result: + success: success + schema: + properties: + pose: + type: array + items: + type: number + status: + type: string + required: + - status + additionalProperties: false + type: object \ No newline at end of file diff --git a/unilabos/registry/devices/robot_arm.yaml b/unilabos/registry/devices/robot_arm.yaml new file mode 100644 index 00000000..585b2fa4 --- /dev/null +++ b/unilabos/registry/devices/robot_arm.yaml @@ -0,0 +1,36 @@ +robotic_arm.UR: + class: + module: unilabos.devices.agv.ur_arm_task:UrArmTask + type: python + status_types: + arm_pose: Float64MultiArray + gripper_pose: Float64 + arm_status: String + gripper_status: String + action_value_mappings: + move_pos_task: + type: SendCmd + goal: + command: command + feedback: {} + result: + success: success + schema: + properties: + arm_pose: + type: array + items: + type: number + gripper_pose: + type: number + arm_status: + type: string + description: 机械臂设备状态 + gripper_status: + type: string + description: 机械爪设备状态 + required: + - arm_status + - gripper_status + additionalProperties: false + type: object \ No newline at end of file diff --git a/unilabos/registry/devices/robot_gripper.yaml b/unilabos/registry/devices/robot_gripper.yaml new file mode 100644 index 00000000..3b5a06d5 --- /dev/null +++ b/unilabos/registry/devices/robot_gripper.yaml @@ -0,0 +1,36 @@ +gripper.mock: + class: + module: unilabos.devices.gripper.mock:MockGripper + type: python + status_types: + position: Float64 + torque: Float64 + status: String + action_value_mappings: + push_to: + type: GripperCommand + goal: + command.position: position + command.max_effort: torque + feedback: + position: position + effort: torque + result: + position: position + effort: torque + + +gripper.misumi_rz: + class: + module: unilabos.devices.motor:Grasp.EleGripper + type: python + status_types: + status: String + action_value_mappings: + execute_command_from_outer: + type: SendCmd + goal: + command: command + feedback: {} + result: + success: success \ No newline at end of file diff --git a/unilabos/registry/devices/robot_linear_motion.yaml b/unilabos/registry/devices/robot_linear_motion.yaml new file mode 100644 index 00000000..eead2454 --- /dev/null +++ b/unilabos/registry/devices/robot_linear_motion.yaml @@ -0,0 +1,55 @@ +linear_motion.grbl: + class: + module: unilabos.devices.cnc.grbl_sync:GrblCNC + type: python + action_value_mappings: + move_through_points: &move_through_points + type: NavigateThroughPoses + goal: + poses[].pose.position: positions[] + feedback: + current_pose.pose.position: position + navigation_time.sec: time_spent + estimated_time_remaining.sec: time_remaining + number_of_poses_remaining: pose_number_remaining + result: {} + set_spindle_speed: + type: SingleJointPosition + goal: + position: spindle_speed + feedback: + position: spindle_speed + result: {} + schema: + type: object + properties: + position: + type: array + items: + type: number + description: The position of the device + spindle_speed: + type: number + description: The spindle speed of the device + required: + - position + - spindle_speed + additionalProperties: false + + +motor.iCL42: + class: + module: unilabos.devices.motor.iCL42:iCL42Driver + type: python + status_types: + motor_position: Int64 + is_executing_run: Bool + success: Bool + action_value_mappings: + execute_command_from_outer: + type: SendCmd + goal: + command: command + feedback: {} + result: + success: success \ No newline at end of file diff --git a/unilabos/registry/devices/temperature.yaml b/unilabos/registry/devices/temperature.yaml new file mode 100644 index 00000000..ba5d75df --- /dev/null +++ b/unilabos/registry/devices/temperature.yaml @@ -0,0 +1,62 @@ +heaterstirrer.dalong: + class: + module: unilabos.devices.heaterstirrer.dalong:HeaterStirrer_DaLong + type: python + status_types: + temp: Float64 + temp_warning: Float64 + stir_speed: Float64 + action_value_mappings: + set_temp_warning: + type: SendCmd + goal: + command: temp + feedback: {} + result: + success: success + set_temp_target: + type: SendCmd + goal: + command: temp + feedback: {} + result: + success: success + heatchill: + type: HeatChill + goal: + vessel: vessel + temp: temp + time: time + purpose: purpose + feedback: + status: status + result: + success: success + +chiller: + class: + module: unilabos.devices.temperature.chiller:Chiller + type: python + action_value_mappings: + set_temperature: + type: SendCmd + goal: + command: command + feedback: {} + result: + success: success +tempsensor: + class: + module: unilabos.devices.temperature.sensor_node:TempSensorNode + type: python + status_types: + value: Float64 + warning: Float64 + action_value_mappings: + set_warning: + type: SendCmd + goal: + command: command + feedback: {} + result: + success: success \ No newline at end of file diff --git a/unilabos/registry/devices/vacuum_and_purge.yaml b/unilabos/registry/devices/vacuum_and_purge.yaml new file mode 100644 index 00000000..4efa5a95 --- /dev/null +++ b/unilabos/registry/devices/vacuum_and_purge.yaml @@ -0,0 +1,9 @@ +vacuum_pump.mock: + class: + module: unilabos.devices.pump_and_valve.vacuum_pump_mock:VacuumPumpMock + type: python + +gas_source.mock: + class: + module: unilabos.devices.pump_and_valve.vacuum_pump_mock:VacuumPumpMock + type: python diff --git a/unilabos/registry/devices/work_station.yaml b/unilabos/registry/devices/work_station.yaml new file mode 100644 index 00000000..8688dd66 --- /dev/null +++ b/unilabos/registry/devices/work_station.yaml @@ -0,0 +1,6 @@ +workstation: + class: + module: unilabos.ros.nodes.presets.protocol_node:ROS2ProtocolNode + type: ros2 + schema: + properties: {} \ No newline at end of file diff --git a/unilabos/registry/registry.py b/unilabos/registry/registry.py new file mode 100644 index 00000000..2ea9bf3b --- /dev/null +++ b/unilabos/registry/registry.py @@ -0,0 +1,176 @@ +import os +import sys +from pathlib import Path +from typing import Any + +import yaml + +from unilabos.utils import logger +from unilabos.ros.msgs.message_converter import msg_converter_manager +from unilabos.utils.decorator import singleton + +DEFAULT_PATHS = [Path(__file__).absolute().parent] + + +@singleton +class Registry: + def __init__(self, registry_paths=None): + self.registry_paths = DEFAULT_PATHS.copy() # 使用copy避免修改默认值 + if registry_paths: + self.registry_paths.extend(registry_paths) + self.device_type_registry = {} + self.resource_type_registry = {} + self._setup_called = False # 跟踪setup是否已调用 + # 其他状态变量 + # self.is_host_mode = False # 移至BasicConfig中 + + def setup(self): + # 检查是否已调用过setup + if self._setup_called: + logger.critical("[UniLab Registry] setup方法已被调用过,不允许多次调用") + return + + # 标记setup已被调用 + self._setup_called = True + + logger.debug(f"[UniLab Registry] ----------Setup----------") + self.registry_paths = [Path(path).absolute() for path in self.registry_paths] + for i, path in enumerate(self.registry_paths): + sys_path = path.parent + logger.debug(f"[UniLab Registry] Path {i+1}/{len(self.registry_paths)}: {sys_path}") + sys.path.append(str(sys_path)) + self.load_device_types(path) + self.load_resource_types(path) + logger.info("[UniLab Registry] 注册表设置完成") + + def load_resource_types(self, path: os.PathLike): + abs_path = Path(path).absolute() + resource_path = abs_path / "resources" + files = list(resource_path.glob("*/*.yaml")) + logger.debug(f"[UniLab Registry] resources: {resource_path.exists()}, total: {len(files)}") + current_resource_number = len(self.resource_type_registry) + 1 + for i, file in enumerate(files): + data = yaml.safe_load(open(file, encoding="utf-8")) + if data: + # 为每个资源添加文件路径信息 + for resource_id, resource_info in data.items(): + # 添加文件路径 - 使用规范化的完整文件路径 + resource_info["file_path"] = str(file.absolute()).replace("\\", "/") + + self.resource_type_registry.update(data) + logger.debug( + f"[UniLab Registry] Resource-{current_resource_number} File-{i+1}/{len(files)} " + + f"Add {list(data.keys())}" + ) + current_resource_number += 1 + else: + logger.debug(f"[UniLab Registry] Res File-{i+1}/{len(files)} Not Valid YAML File: {file.absolute()}") + + def _replace_type_with_class(self, type_name: str, device_id: str, field_name: str) -> Any: + """ + 将类型名称替换为实际的类对象 + + Args: + type_name: 类型名称 + device_id: 设备ID,用于错误信息 + field_name: 字段名称,用于错误信息 + + Returns: + 找到的类对象或原始字符串 + + Raises: + SystemExit: 如果找不到类型则终止程序 + """ + # 如果类型名为空,跳过替换 + if not type_name or type_name == "": + logger.warning(f"[UniLab Registry] 设备 {device_id} 的 {field_name} 类型为空,跳过替换") + return type_name + if "." in type_name: + type_class = msg_converter_manager.get_class(type_name) + else: + type_class = msg_converter_manager.search_class(type_name) + if type_class: + return type_class + else: + logger.error(f"[UniLab Registry] 无法找到类型 '{type_name}' 用于设备 {device_id} 的 {field_name}") + sys.exit(1) + + def load_device_types(self, path: os.PathLike): + abs_path = Path(path).absolute() + devices_path = abs_path / "devices" + device_comms_path = abs_path / "device_comms" + files = list(devices_path.glob("*.yaml")) + list(device_comms_path.glob("*.yaml")) + logger.debug( + f"[UniLab Registry] devices: {devices_path.exists()}, device_comms: {device_comms_path.exists()}, " + + f"total: {len(files)}" + ) + current_device_number = len(self.device_type_registry) + 1 + for i, file in enumerate(files): + data = yaml.safe_load(open(file, encoding="utf-8")) + if data: + # 在添加到注册表前处理类型替换 + for device_id, device_config in data.items(): + # 添加文件路径信息 - 使用规范化的完整文件路径 + device_config["file_path"] = str(file.absolute()).replace("\\", "/") + + if "class" in device_config: + # 处理状态类型 + if "status_types" in device_config["class"]: + for status_name, status_type in device_config["class"]["status_types"].items(): + device_config["class"]["status_types"][status_name] = self._replace_type_with_class( + status_type, device_id, f"状态 {status_name}" + ) + + # 处理动作值映射 + if "action_value_mappings" in device_config["class"]: + for action_name, action_config in device_config["class"]["action_value_mappings"].items(): + if "type" in action_config: + action_config["type"] = self._replace_type_with_class( + action_config["type"], device_id, f"动作 {action_name}" + ) + + self.device_type_registry.update(data) + + for device_id in data.keys(): + logger.debug( + f"[UniLab Registry] Device-{current_device_number} File-{i+1}/{len(files)} Add {device_id} " + + f"[{data[device_id].get('name', '未命名设备')}]" + ) + current_device_number += 1 + else: + logger.debug( + f"[UniLab Registry] Device File-{i+1}/{len(files)} Not Valid YAML File: {file.absolute()}" + ) + + +# 全局单例实例 +lab_registry = Registry() + + +def build_registry(registry_paths=None): + """ + 构建或获取Registry单例实例 + + Args: + registry_paths: 额外的注册表路径列表 + + Returns: + Registry实例 + """ + logger.info("[UniLab Registry] 构建注册表实例") + + # 由于使用了单例,这里不需要重新创建实例 + global lab_registry + + # 如果有额外路径,添加到registry_paths + if registry_paths: + current_paths = lab_registry.registry_paths.copy() + # 检查是否有新路径需要添加 + for path in registry_paths: + if path not in current_paths: + lab_registry.registry_paths.append(path) + + # 初始化注册表 + lab_registry.setup() + + return lab_registry diff --git a/unilabos/registry/resources/opentrons/deck.yaml b/unilabos/registry/resources/opentrons/deck.yaml new file mode 100644 index 00000000..77fdc4f2 --- /dev/null +++ b/unilabos/registry/resources/opentrons/deck.yaml @@ -0,0 +1,4 @@ +OTDeck: + class: + module: pylabrobot.resources.opentrons.deck:OTDeck + type: pylabrobot \ No newline at end of file diff --git a/unilabos/registry/resources/opentrons/plate_adapters.yaml b/unilabos/registry/resources/opentrons/plate_adapters.yaml new file mode 100644 index 00000000..f2304eda --- /dev/null +++ b/unilabos/registry/resources/opentrons/plate_adapters.yaml @@ -0,0 +1,4 @@ +Opentrons_96_adapter_Vb: + class: + module: pylabrobot.resources.opentrons.plate_adapters:Opentrons_96_adapter_Vb + type: pylabrobot \ No newline at end of file diff --git a/unilabos/registry/resources/opentrons/plates.yaml b/unilabos/registry/resources/opentrons/plates.yaml new file mode 100644 index 00000000..a92a0abb --- /dev/null +++ b/unilabos/registry/resources/opentrons/plates.yaml @@ -0,0 +1,74 @@ +corning_6_wellplate_16point8ml_flat: + class: + module: pylabrobot.resources.opentrons.plates:corning_6_wellplate_16point8ml_flat + type: pylabrobot + +corning_12_wellplate_6point9ml_flat: + class: + module: pylabrobot.resources.opentrons.plates:corning_12_wellplate_6point9ml_flat + type: pylabrobot + +corning_24_wellplate_3point4ml_flat: + class: + module: pylabrobot.resources.opentrons.plates:corning_24_wellplate_3point4ml_flat + type: pylabrobot + +corning_48_wellplate_1point6ml_flat: + class: + module: pylabrobot.resources.opentrons.plates:corning_48_wellplate_1point6ml_flat + type: pylabrobot + +corning_96_wellplate_360ul_flat: + class: + module: pylabrobot.resources.opentrons.plates:corning_96_wellplate_360ul_flat + type: pylabrobot + +corning_384_wellplate_112ul_flat: + class: + module: pylabrobot.resources.opentrons.plates:corning_384_wellplate_112ul_flat + type: pylabrobot + +nest_96_wellplate_2ml_deep: + class: + module: pylabrobot.resources.opentrons.plates:nest_96_wellplate_2ml_deep + type: pylabrobot + +nest_96_wellplate_200ul_flat: + class: + module: pylabrobot.resources.opentrons.plates:nest_96_wellplate_200ul_flat + type: pylabrobot + +nest_96_wellplate_100ul_pcr_full_skirt: + class: + module: pylabrobot.resources.opentrons.plates:nest_96_wellplate_100ul_pcr_full_skirt + type: pylabrobot + +appliedbiosystemsmicroamp_384_wellplate_40ul: + class: + module: pylabrobot.resources.opentrons.plates:appliedbiosystemsmicroamp_384_wellplate_40ul + type: pylabrobot + +thermoscientificnunc_96_wellplate_1300ul: + class: + module: pylabrobot.resources.opentrons.plates:thermoscientificnunc_96_wellplate_1300ul + type: pylabrobot + +thermoscientificnunc_96_wellplate_2000ul: + class: + module: pylabrobot.resources.opentrons.plates:thermoscientificnunc_96_wellplate_2000ul + type: pylabrobot + +usascientific_96_wellplate_2point4ml_deep: + class: + module: pylabrobot.resources.opentrons.plates:usascientific_96_wellplate_2point4ml_deep + type: pylabrobot + +biorad_96_wellplate_200ul_pcr: + class: + module: pylabrobot.resources.opentrons.plates:biorad_96_wellplate_200ul_pcr + type: pylabrobot + +biorad_384_wellplate_50ul: + class: + module: pylabrobot.resources.opentrons.plates:biorad_384_wellplate_50ul + type: pylabrobot diff --git a/unilabos/registry/resources/opentrons/reservoirs.yaml b/unilabos/registry/resources/opentrons/reservoirs.yaml new file mode 100644 index 00000000..5bcb092e --- /dev/null +++ b/unilabos/registry/resources/opentrons/reservoirs.yaml @@ -0,0 +1,29 @@ +agilent_1_reservoir_290ml: + class: + module: pylabrobot.resources.opentrons.reserviors:agilent_1_reservoir_290ml + type: pylabrobot + +axygen_1_reservoir_90ml: + class: + module: pylabrobot.resources.opentrons.reserviors:axygen_1_reservoir_90ml + type: pylabrobot + +nest_12_reservoir_15ml: + class: + module: pylabrobot.resources.opentrons.reserviors:nest_12_reservoir_15ml + type: pylabrobot + +nest_1_reservoir_195ml: + class: + module: pylabrobot.resources.opentrons.reserviors:nest_1_reservoir_195ml + type: pylabrobot + +nest_1_reservoir_290ml: + class: + module: pylabrobot.resources.opentrons.reserviors:nest_1_reservoir_290ml + type: pylabrobot + +usascientific_12_reservoir_22ml: + class: + module: pylabrobot.resources.opentrons.reserviors:usascientific_12_reservoir_22ml + type: pylabrobot diff --git a/unilabos/registry/resources/opentrons/tip_racks.yaml b/unilabos/registry/resources/opentrons/tip_racks.yaml new file mode 100644 index 00000000..44ef090a --- /dev/null +++ b/unilabos/registry/resources/opentrons/tip_racks.yaml @@ -0,0 +1,64 @@ +eppendorf_96_tiprack_1000ul_eptips: + class: + module: pylabrobot.resources.opentrons.tip_racks:eppendorf_96_tiprack_1000ul_eptips + type: pylabrobot + +tipone_96_tiprack_200ul: + class: + module: pylabrobot.resources.opentrons.tip_racks:tipone_96_tiprack_200ul + type: pylabrobot + +opentrons_96_tiprack_300ul: + class: + module: pylabrobot.resources.opentrons.tip_racks:opentrons_96_tiprack_300ul + type: pylabrobot + +opentrons_96_tiprack_10ul: + class: + module: pylabrobot.resources.opentrons.tip_racks:opentrons_96_tiprack_10ul + type: pylabrobot + +opentrons_96_filtertiprack_10ul: + class: + module: pylabrobot.resources.opentrons.tip_racks:opentrons_96_filtertiprack_10ul + type: pylabrobot + +geb_96_tiprack_10ul: + class: + module: pylabrobot.resources.opentrons.tip_racks:geb_96_tiprack_10ul + type: pylabrobot + +opentrons_96_filtertiprack_200ul: + class: + module: pylabrobot.resources.opentrons.tip_racks:opentrons_96_filtertiprack_200ul + type: pylabrobot + +eppendorf_96_tiprack_10ul_eptips: + class: + module: pylabrobot.resources.opentrons.tip_racks:eppendorf_96_tiprack_10ul_eptips + type: pylabrobot + +opentrons_96_tiprack_1000ul: + class: + module: pylabrobot.resources.opentrons.tip_racks:opentrons_96_tiprack_1000ul + type: pylabrobot + +opentrons_96_tiprack_20ul: + class: + module: pylabrobot.resources.opentrons.tip_racks:opentrons_96_tiprack_20ul + type: pylabrobot + +opentrons_96_filtertiprack_1000ul: + class: + module: pylabrobot.resources.opentrons.tip_racks:opentrons_96_filtertiprack_1000ul + type: pylabrobot + +opentrons_96_filtertiprack_20ul: + class: + module: pylabrobot.resources.opentrons.tip_racks:opentrons_96_filtertiprack_20ul + type: pylabrobot + +geb_96_tiprack_1000ul: + class: + module: pylabrobot.resources.opentrons.tip_racks:geb_96_tiprack_1000ul + type: pylabrobot diff --git a/unilabos/registry/resources/opentrons/tube_racks.yaml b/unilabos/registry/resources/opentrons/tube_racks.yaml new file mode 100644 index 00000000..75081c21 --- /dev/null +++ b/unilabos/registry/resources/opentrons/tube_racks.yaml @@ -0,0 +1,99 @@ +opentrons_24_tuberack_eppendorf_2ml_safelock_snapcap: + class: + module: pylabrobot.resources.opentrons.tube_racks:opentrons_24_tuberack_eppendorf_2ml_safelock_snapcap + type: pylabrobot + +opentrons_24_tuberack_eppendorf_2ml_safelock_snapcap_acrylic: + class: + module: pylabrobot.resources.opentrons.tube_racks:opentrons_24_tuberack_eppendorf_2ml_safelock_snapcap_acrylic + type: pylabrobot + +opentrons_6_tuberack_falcon_50ml_conical: + class: + module: pylabrobot.resources.opentrons.tube_racks:opentrons_6_tuberack_falcon_50ml_conical + type: pylabrobot + +opentrons_15_tuberack_nest_15ml_conical: + class: + module: pylabrobot.resources.opentrons.tube_racks:opentrons_15_tuberack_nest_15ml_conical + type: pylabrobot + +opentrons_24_tuberack_nest_2ml_screwcap: + class: + module: pylabrobot.resources.opentrons.tube_racks:opentrons_24_tuberack_nest_2ml_screwcap + type: pylabrobot + +opentrons_24_tuberack_generic_0point75ml_snapcap_acrylic: + class: + module: pylabrobot.resources.opentrons.tube_racks:opentrons_24_tuberack_generic_0point75ml_snapcap_acrylic + type: pylabrobot + +opentrons_10_tuberack_nest_4x50ml_6x15ml_conical: + class: + module: pylabrobot.resources.opentrons.tube_racks:opentrons_10_tuberack_nest_4x50ml_6x15ml_conical + type: pylabrobot + +opentrons_10_tuberack_falcon_4x50ml_6x15ml_conical_acrylic: + class: + module: pylabrobot.resources.opentrons.tube_racks:opentrons_10_tuberack_falcon_4x50ml_6x15ml_conical_acrylic + type: pylabrobot + +opentrons_24_tuberack_nest_1point5ml_screwcap: + class: + module: pylabrobot.resources.opentrons.tube_racks:opentrons_24_tuberack_nest_1point5ml_screwcap + type: pylabrobot + +opentrons_24_tuberack_nest_1point5ml_snapcap: + class: + module: pylabrobot.resources.opentrons.tube_racks:opentrons_24_tuberack_nest_1point5ml_snapcap + type: pylabrobot + +opentrons_10_tuberack_falcon_4x50ml_6x15ml_conical: + class: + module: pylabrobot.resources.opentrons.tube_racks:opentrons_10_tuberack_falcon_4x50ml_6x15ml_conical + type: pylabrobot + +opentrons_24_tuberack_nest_2ml_snapcap: + class: + module: pylabrobot.resources.opentrons.tube_racks:opentrons_24_tuberack_nest_2ml_snapcap + type: pylabrobot + +opentrons_24_tuberack_nest_0point5ml_screwcap: + class: + module: pylabrobot.resources.opentrons.tube_racks:opentrons_24_tuberack_nest_0point5ml_screwcap + type: pylabrobot + +opentrons_24_tuberack_eppendorf_1point5ml_safelock_snapcap: + class: + module: pylabrobot.resources.opentrons.tube_racks:opentrons_24_tuberack_eppendorf_1point5ml_safelock_snapcap + type: pylabrobot + +opentrons_6_tuberack_nest_50ml_conical: + class: + module: pylabrobot.resources.opentrons.tube_racks:opentrons_6_tuberack_nest_50ml_conical + type: pylabrobot + +opentrons_15_tuberack_falcon_15ml_conical: + class: + module: pylabrobot.resources.opentrons.tube_racks:opentrons_15_tuberack_falcon_15ml_conical + type: pylabrobot + +opentrons_24_tuberack_generic_2ml_screwcap: + class: + module: pylabrobot.resources.opentrons.tube_racks:opentrons_24_tuberack_generic_2ml_screwcap + type: pylabrobot + +opentrons_96_well_aluminum_block: + class: + module: pylabrobot.resources.opentrons.tube_racks:opentrons_96_well_aluminum_block + type: pylabrobot + +opentrons_24_aluminumblock_generic_2ml_screwcap: + class: + module: pylabrobot.resources.opentrons.tube_racks:opentrons_24_aluminumblock_generic_2ml_screwcap + type: pylabrobot + +opentrons_24_aluminumblock_nest_1point5ml_snapcap: + class: + module: pylabrobot.resources.opentrons.tube_racks:opentrons_24_aluminumblock_nest_1point5ml_snapcap + type: pylabrobot diff --git a/unilabos/resources/__init__.py b/unilabos/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/resources/graphio.py b/unilabos/resources/graphio.py new file mode 100644 index 00000000..e5c16bc4 --- /dev/null +++ b/unilabos/resources/graphio.py @@ -0,0 +1,477 @@ +import importlib +import json +from typing import Union +import numpy as np +import networkx as nx + +try: + from pylabrobot.resources.resource import Resource as ResourcePLR +except ImportError: + pass + + +physical_setup_graph: nx.Graph = None + + +def canonicalize_nodes_data(data: dict, parent_relation: dict = {}) -> dict: + for node in data.get("nodes", []): + if node.get("label") is not None: + id = node.pop("label") + node["id"] = node["name"] = id + if "id" not in node: + node["id"] = node.get("name", "NaN") + if "name" not in node: + node["name"] = node["id"] + if node.get("position") is None: + node["position"] = { + "x": node.pop("x", 0.0), + "y": node.pop("y", 0.0), + "z": node.pop("z", 0.0), + } + if node.get("config") is None: + node["config"] = {} + node["data"] = {} + for k in list(node.keys()): + if k not in [ + "id", + "name", + "class", + "type", + "position", + "children", + "parent", + "config", + "data", + ]: + if k in ["chemical", "current_volume"]: + if node["data"].get("liquids") is None: + node["data"]["liquids"] = [{}] + if k == "chemical": + node["data"]["liquids"][0]["liquid_name"] = node.pop(k) + elif k == "current_volume": + node["data"]["liquids"][0]["liquid_volume"] = node.pop(k) + elif k == "max_volume": + node["data"]["max_volume"] = node.pop(k) + elif k == "url": + node.pop(k) + else: + node["config"][k] = node.pop(k) + if "class" not in node: + node["class"] = None + if "type" not in node: + node["type"] = ( + "container" + if node["class"] is None + else "device" if node["class"] not in ["container", "plate"] else node["class"] + ) + if "children" not in node: + node["children"] = [] + + id2idx = {node_data["id"]: idx for idx, node_data in enumerate(data["nodes"])} + for parent, children in parent_relation.items(): + data["nodes"][id2idx[parent]]["children"] = children + for child in children: + data["nodes"][id2idx[child]]["parent"] = parent + return data + + +def canonicalize_links_ports(data: dict) -> dict: + # 第一遍处理:将字符串类型的port转换为字典格式 + for link in data.get("links", []): + port = link.get("port") + if isinstance(port, int): + port = str(port) + if isinstance(port, str): + port_str = port.strip() + if port_str.startswith("(") and port_str.endswith(")"): + # 处理格式为 "(A,B)" 的情况 + content = port_str[1:-1].strip() + parts = [p.strip() for p in content.split(",", 1)] + source_port = parts[0] + dest_port = parts[1] if len(parts) > 1 else None + else: + # 处理格式为 "A" 的情况 + source_port = port_str + dest_port = None + link["port"] = {link["source"]: source_port, link["target"]: dest_port} + elif not isinstance(port, dict): + # 若port既非字符串也非字典,初始化为空结构 + link["port"] = {link["source"]: None, link["target"]: None} + + # 构建边字典,键为(source节点, target节点),值为对应的port信息 + edges = {(link["source"], link["target"]): link["port"] for link in data.get("links", [])} + + # 第二遍处理:填充反向边的dest信息 + delete_reverses = [] + for i, link in enumerate(data.get("links", [])): + s, t = link["source"], link["target"] + current_port = link["port"] + if current_port.get(t) is None: + reverse_key = (t, s) + reverse_port = edges.get(reverse_key) + if reverse_port: + reverse_source = reverse_port.get(s) + if reverse_source is not None: + # 设置当前边的dest为反向边的source + current_port[t] = reverse_source + delete_reverses.append(i) + else: + # 若不存在反向边,初始化为空结构 + current_port[t] = current_port[s] + # 删除已被使用反向端口信息的反向边 + data["links"] = [link for i, link in enumerate(data.get("links", [])) if i not in delete_reverses] + + return data + + +def handle_communications(G: nx.Graph): + available_communication_types = ["serial", "io_device", "plc", "io"] + for e, edata in G.edges.items(): + if edata.get("type", "physical") != "communication": + continue + if G.nodes[e[0]].get("class") in available_communication_types: + device_comm, device = e[0], e[1] + elif G.nodes[e[1]].get("class") in available_communication_types: + device_comm, device = e[1], e[0] + else: + continue + + if G.nodes[device_comm].get("class") == "serial": + G.nodes[device]["config"]["port"] = device_comm + elif G.nodes[device_comm].get("class") == "io_device": + print(f'!!! Modify {device}\'s io_device_port to {edata["port"][device_comm]}') + G.nodes[device]["config"]["io_device_port"] = int(edata["port"][device_comm]) + + +def read_node_link_json(json_file): + global physical_setup_graph + + data = json.load(open(json_file, encoding="utf-8")) + data = canonicalize_nodes_data(data) + data = canonicalize_links_ports(data) + + physical_setup_graph = nx.node_link_graph(data, multigraph=False) # edges="links" 3.6 warning + handle_communications(physical_setup_graph) + return physical_setup_graph + + +def read_graphml(graphml_file): + global physical_setup_graph + + G = nx.read_graphml(graphml_file) + mapping = {} + parent_relation = {} + for node in G.nodes(): + label = G.nodes[node].pop("label", G.nodes[node].get("id", G.nodes[node].get("name", "NaN"))) + mapping[node] = label + if "::" in node: + parent = mapping[node.split("::")[0]] + if parent not in parent_relation: + parent_relation[parent] = [] + parent_relation[parent].append(label) + + G2 = nx.relabel_nodes(G, mapping) + data = nx.node_link_data(G2) + data = canonicalize_nodes_data(data, parent_relation=parent_relation) + data = canonicalize_links_ports(data) + + physical_setup_graph = nx.node_link_graph(data, edges="links", multigraph=False) # edges="links" 3.6 warning + handle_communications(physical_setup_graph) + return physical_setup_graph + + +def dict_from_graph(graph: nx.Graph) -> dict: + nodes_copy = {node_id: {"id": node_id, **node} for node_id, node in graph.nodes(data=True)} + return nodes_copy + + +def dict_to_tree(nodes: dict, devices_only: bool = False) -> list[dict]: + # 将节点转换为字典,以便通过 ID 快速查找 + nodes_list = [node for node in nodes.values() if node.get("type") == "device" or not devices_only] + + # 初始化每个节点的 children 为包含节点字典的列表 + for node in nodes_list: + node["children"] = [nodes[child_id] for child_id in node.get("children", [])] + + # 找到根节点并返回 + 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] + ] + + # 如果存在多个根节点,返回所有根节点 + return root_nodes + + +def dict_to_nested_dict(nodes: dict, devices_only: bool = False) -> dict: + # 将节点转换为字典,以便通过 ID 快速查找 + nodes_list = [node for node in nodes.values() if node.get("type") == "device" or not devices_only] + + # 初始化每个节点的 children 为包含节点字典的列表 + for node in nodes_list: + node["children"] = { + child_id: nodes[child_id] + for child_id in node.get("children", []) + if nodes[child_id].get("type") == "device" or not devices_only + } + if len(node["children"]) > 0 and node["type"].lower() == "device" and devices_only: + node["config"]["children"] = node["children"] + + # 找到根节点并返回 + root_nodes = { + node["id"]: node + for node in nodes_list + if node.get("parent", node.get("parent_name")) in [None, "", "None", np.nan] + } + + # 如果存在多个根节点,返回所有根节点 + return root_nodes + + +def list_to_nested_dict(nodes: list[dict]) -> dict: + nodes_dict = {node["id"]: node for node in nodes} + return dict_to_nested_dict(nodes_dict) + + +def tree_to_list(tree: list[dict]) -> list[dict]: + def _tree_to_list(tree: list[dict], result: list[dict]): + for node_ in tree: + node = node_.copy() + result.append(node) + if node.get("children"): + _tree_to_list(node["children"], result) + node["children"] = [n["id"] for n in node["children"]] + + result = [] + _tree_to_list(tree, result) + return result + + +def nested_dict_to_list(nested_dict: dict) -> list[dict]: # FIXME 是tree? + """ + 将嵌套字典转换为扁平列表 + + 嵌套字典的层次结构将通过children属性表示 + + Args: + nested_dict: 嵌套的字典结构 + + Returns: + 扁平化的字典列表 + """ + result = [] + + # 如果输入本身是一个节点,先添加它 + if "id" in nested_dict: + node = nested_dict.copy() + # 暂存子节点 + children_dict = node.get("children", {}) + # 如果children是字典,将其转换为键列表 + if isinstance(children_dict, dict): + node["children"] = list(children_dict.keys()) + elif not isinstance(children_dict, list): + node["children"] = [] + result.append(node) + + # 处理子节点字典 + if isinstance(children_dict, dict): + for child_id, child_data in children_dict.items(): + if isinstance(child_data, dict): + # 为子节点添加ID(如果不存在) + if "id" not in child_data: + child_data["id"] = child_id + # 递归处理子节点 + result.extend(nested_dict_to_list(child_data)) + + # 处理children字段 + elif "children" in nested_dict: + children_dict = nested_dict.get("children", {}) + if isinstance(children_dict, dict): + for child_id, child_data in children_dict.items(): + if isinstance(child_data, dict): + # 为子节点添加ID(如果不存在) + if "id" not in child_data: + child_data["id"] = child_id + # 递归处理子节点 + result.extend(nested_dict_to_list(child_data)) + + return result + + +def convert_resources_to_type( + resources_list: list[dict], resource_type: type, *, plr_model: bool = False +) -> Union[list[dict], dict, None, "ResourcePLR"]: + """ + Convert resources to a given type (PyLabRobot or NestedDict) from flattened list of dictionaries. + + Args: + resources: List of resources in the flattened dictionary format. + resource_type: Type of the resources to convert to. + plr_model: 是否有plr_model类型 + + Returns: + List of resources in the given type. + """ + if resource_type == dict: + return list_to_nested_dict(resources_list) + elif isinstance(resource_type, type) and issubclass(resource_type, ResourcePLR): + if isinstance(resources_list, dict): + return resource_ulab_to_plr(resources_list, plr_model) + resources_tree = dict_to_tree({r["id"]: r for r in resources_list}) + return resource_ulab_to_plr(resources_tree[0], plr_model) + elif isinstance(resource_type, list) and all(issubclass(t, ResourcePLR) for t in resource_type): + resources_tree = dict_to_tree({r["id"]: r for r in resources_list}) + return [resource_ulab_to_plr(r, plr_model) for r in resources_tree] + else: + return None + + +def convert_resources_from_type(resources_list, resource_type: type) -> Union[list[dict], dict, None, "ResourcePLR"]: + """ + Convert resources from a given type (PyLabRobot or NestedDict) to flattened list of dictionaries. + + Args: + resources_list: List of resources in the given type. + resource_type: Type of the resources to convert from. + + Returns: + List of resources in the flattened dictionary format. + """ + if resource_type == dict: + return nested_dict_to_list(resources_list) + elif isinstance(resource_type, type) and issubclass(resource_type, ResourcePLR): + resources_tree = [resource_plr_to_ulab(resources_list)] + return tree_to_list(resources_tree) + elif isinstance(resource_type, list) and all(issubclass(t, ResourcePLR) for t in resource_type): + resources_tree = [resource_plr_to_ulab(r) for r in resources_list] + return tree_to_list(resources_tree) + else: + return None + + +def resource_ulab_to_plr(resource: dict, plr_model=False) -> "ResourcePLR": + """ + Resource有model字段,但是Deck下没有,这个plr由外面判断传入 + """ + if ResourcePLR is None: + raise ImportError("pylabrobot not found") + + all_states = {resource["id"]: resource["data"]} + + def resource_ulab_to_plr_inner(resource: dict): + all_states[resource["name"]] = resource["data"] + d = { + "name": resource["name"], + "type": resource["type"], + "size_x": resource["config"].get("size_x", 0), + "size_y": resource["config"].get("size_y", 0), + "size_z": resource["config"].get("size_z", 0), + "location": {**resource["position"], "type": "Coordinate"}, + "rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"}, # Resource如果没有rotation,是plr版本太低 + "category": resource["type"], + "model": resource["config"].get("model", None), # resource中deck没有model + "children": ( + [resource_ulab_to_plr_inner(child) for child in resource["children"]] + if isinstance(resource["children"], list) + else [resource_ulab_to_plr_inner(child) for child_id, child in resource["children"].items()] + ), + "parent_name": resource["parent"] if resource["parent"] is not None else None, + **resource["config"], + } + if not plr_model: + d.pop("model") + return d + + d = resource_ulab_to_plr_inner(resource) + """无法通过Resource进行反序列化,例如TipSpot必须内部序列化好,直接用TipSpot序列化会多参数,导致出错""" + from pylabrobot.utils.object_parsing import find_subclass + resource_plr = find_subclass(d["type"], ResourcePLR).deserialize(d, allow_marshal=True) + resource_plr.load_all_state(all_states) + return resource_plr + + +def resource_plr_to_ulab(resource_plr: "ResourcePLR"): + def resource_plr_to_ulab_inner(d: dict, all_states: dict) -> dict: + r = { + "id": d["name"], + "name": d["name"], + "sample_id": None, + "children": [resource_plr_to_ulab_inner(child, all_states) for child in d["children"]], + "parent": d["parent_name"] if d["parent_name"] else None, + "type": "device", # FIXME plr自带的type是python class name + "class": d.get("class", ""), + "position": ( + {"x": d["location"]["x"], "y": d["location"]["y"], "z": d["location"]["z"]} + if d["location"] + else {"x": 0, "y": 0, "z": 0} + ), + "config": {k: v for k, v in d.items() if k not in ["name", "children", "parent_name", "location"]}, + "data": all_states[d["name"]], + } + return r + + d = resource_plr.serialize() + all_states = resource_plr.serialize_all_state() + r = resource_plr_to_ulab_inner(d, all_states) + return r + + +def initialize_resource(resource_config: dict, lab_registry: dict) -> list[dict]: + """Initializes a resource based on its configuration. + + If the config is detailed, then do nothing; + If it is a string, then import the appropriate class and create an instance of it. + + Args: + resource_config (dict): The configuration dictionary for the resource, which includes the class type and other parameters. + + Returns: + None + """ + resource_class_config = resource_config.get("class", None) + if resource_class_config is None: + return [resource_config] + elif type(resource_class_config) == str: + # Allow special resource class names to be used + if resource_class_config not in lab_registry.resource_type_registry: + return [resource_config] + # If the resource class is a string, look up the class in the + # resource_type_registry and import it + resource_class_config = resource_config["class"] = lab_registry.resource_type_registry[resource_class_config][ + "class" + ] + if type(resource_class_config) == dict: + module = importlib.import_module(resource_class_config["module"].split(":")[0]) + mclass = resource_class_config["module"].split(":")[1] + RESOURCE = getattr(module, mclass) + + if resource_class_config["type"] == "pylabrobot": + resource_plr = RESOURCE(name=resource_config["name"]) + r = resource_plr_to_ulab(resource_plr=resource_plr) + if resource_config.get("position") is not None: + r["position"] = resource_config["position"] + r = tree_to_list([r]) + elif isinstance(RESOURCE, dict): + r = [RESOURCE.copy()] + + return r + + +def initialize_resources(resources_config) -> list[dict]: + """Initializes a list of resources based on their configuration. + + If the config is detailed, then do nothing; + If it is a string, then import the appropriate class and create an instance of it. + + Args: + resources_config (list[dict]): The configuration dictionary for the resources, which includes the class type and other parameters. + + Returns: + None + """ + + from unilabos.registry.registry import lab_registry + + resources = [] + for resource_config in resources_config: + resources.extend(initialize_resource(resource_config, lab_registry)) + return resources diff --git a/unilabos/resources/registry.py b/unilabos/resources/registry.py new file mode 100644 index 00000000..435edb68 --- /dev/null +++ b/unilabos/resources/registry.py @@ -0,0 +1,138 @@ +import sys + + +resource_schema = { + "workstation": {"type": "object", "properties": {}}, + "work_station.aichemeco_hiwo": { + "type": "object", + "properties": { + "status": {"type": "string", "description": "设备状态"}, + "tasks": {"type": "string", "description": "任务列表"}, + } + }, + "work_station.revvity": { + "type": "object", + "properties": { + "status": {"type": "string", "description": "设备状态"}, + "tasks": {"type": "string", "description": "任务列表"}, + } + }, + "syringepump.runze": { + "type": "object", + "properties": { + "max_velocity": {"type": "number", "description": "活塞最大速度"}, + "position": {"type": "number", "description": "活塞当前位置"}, + "status": {"type": "string", "description": "设备状态"}, + "valve_position": {"type": "string", "description": "阀门当前位置"}, + }, + "required": ["max_velocity", "position", "status", "valve_position"], + }, + "heaterstirrer.dalong": { + "type": "object", + "properties": { + "stir_speed": {"type": "number", "description": "搅拌器转速"}, + "temp": {"type": "number", "description": "搅拌器温度"}, + "temp_target": {"type": "number", "description": "搅拌器温度目标值"}, + "temp_warning": {"type": "number", "description": "搅拌器温度警告值"}, + }, + "required": ["temp"], + }, + "separator_controller": { + "type": "object", + "properties": { + "sensordata": {"type": "number", "description": "电导传感器数据"}, + "status": {"type": "string", "description": "设备状态"}, + }, + "required": ["sensordata", "status"], + }, + "rotavap": { + "type": "object", + "properties": { + "temperature": {"type": "number", "description": "蒸发器温度"}, + "rotate_time": {"type": "number", "description": "蒸发器转速"}, + "status": {"type": "string", "description": "设备状态"}, + }, + "required": ["temperature", "rotate_time", "status"], + }, + "container": { + "type": "object", + "properties": { + "liquid": { + "type": "array", + "items": { + "type": "object", + "properties": { + "liquid_type": {"type": "string"}, + "liquid_volume": {"type": "number"}, + }, + }, + }, + "max_volume": { + "type": "number", + }, + }, + }, + "plate": { + "type": "object", + "properties": { + "layout": { + "type": "object", + "properties": { + "gridCount": "number", + "gridColumnNumber": "number" + }, + } + }, + }, + "serial": None, + "gripper.mock": None, + "solenoid_valve.mock": None, + "vacuum_pump.mock": None, + "gas_source.mock": None, + "zhixing_agv": { + "type": "object", + "properties": { + + "status": {"type": "string", "description": "设备状态"}, + }, + "required": ["status"], + }, + "zhixing_ur_arm": { + "type": "object", + "properties": { + "arm_status": {"type": "string", "description": "机械臂设备状态"}, + "gripper_status": {"type": "string", "description": "夹爪设备状态"}, + }, + "required": ["arm_status"], + }, + "hplc": { + "type": "object", + "properties": { + "device_status": {"type": "string", "description": "机械臂设备状态"}, + "could_run": {"type": "bool", "description": "机械臂设备状态"}, + "driver_init_ok": {"type": "bool", "description": "机械臂设备状态"}, + "is_running": {"type": "bool", "description": "机械臂设备状态"}, + "finish_status": {"type": "string", "description": "机械臂设备状态"}, + "status_text": {"type": "string", "description": "机械臂设备状态"} + } + } +} + + +def add_schema(resources_config: list[dict]) -> list[dict]: + for resource in resources_config: + if "type" not in resource: + resource["type"] = str(resource["class"]) + if resource["type"].lower() == "container": + resource["schema"] = resource_schema["container"] + elif resource["type"].lower() == "device": + resource["schema"] = resource_schema.get(resource["class"], None) + + if len(resource["children"]) > 0: + try: + if type(resource["children"][0]) == dict: + resource["children"] = add_schema(resource["children"]) + except: + sys.exit(0) + + return resources_config diff --git a/unilabos/ros/__init__.py b/unilabos/ros/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/ros/device_node_wrapper.py b/unilabos/ros/device_node_wrapper.py new file mode 100644 index 00000000..1697a9e8 --- /dev/null +++ b/unilabos/ros/device_node_wrapper.py @@ -0,0 +1,86 @@ +from typing import Dict, Any, Optional, Type, TypeVar + +from unilabos.ros.msgs.message_converter import ( + get_msg_type, + get_action_type, +) +from unilabos.ros.nodes.base_device_node import init_wrapper, ROS2DeviceNode + +# 定义泛型类型变量 +T = TypeVar("T") + + +# noinspection PyMissingConstructor +class ROS2DeviceNodeWrapper(ROS2DeviceNode): + def __init__(self, device_id: str, *args, **kwargs): + pass + + +def ros2_device_node( + cls: Type[T], + status_types: Optional[Dict[str, Any]] = None, + action_value_mappings: Optional[Dict[str, Any]] = None, + hardware_interface: Optional[Dict[str, Any]] = None, + print_publish: bool = False, + children: Optional[Dict[str, Any]] = None, +) -> Type[ROS2DeviceNodeWrapper]: + """Create a ROS2 Node class for a device class with properties and actions. + + Args: + cls: 要封装的设备类 + status_types: 需要发布的状态和传感器信息,每个(PROP: TYPE),PROP应该匹配cls.PROP或cls.get_PROP(), + TYPE应该是ROS2消息类型。默认为{}。 + action_value_mappings: 设备动作。默认为{}。 + 每个(ACTION: {'type': CMD_TYPE, 'goal': {FIELD: PROP}, 'feedback': {FIELD: PROP}, 'result': {FIELD: PROP}}), + hardware_interface: 硬件接口配置。默认为{"name": "hardware_interface", "write": "send_command", "read": "read_data", "extra_info": []}。 + print_publish: 是否打印发布信息。默认为False。 + children: 物料/子节点信息。 + + Returns: + Type: 封装了设备类的ROS2节点类。 + """ + # 从属性中自动发现可发布状态 + if status_types is None: + status_types = {} + if action_value_mappings is None: + action_value_mappings = {} + if hardware_interface is None: + hardware_interface = { + "name": "hardware_interface", + "write": "send_command", + "read": "read_data", + "extra_info": [], + } + + for k, v in cls.__dict__.items(): + if not k.startswith("_") and isinstance(v, property): + # noinspection PyUnresolvedReferences + property_type = v.fget.__annotations__.get("return", str) + get_method_name = f"get_{k}" + set_method_name = f"set_{k}" + + if k not in status_types and hasattr(cls, get_method_name): + status_types[k] = get_msg_type(property_type) + + if f"set_{k}" not in action_value_mappings and hasattr(cls, set_method_name): + action_value_mappings[f"set_{k}"] = get_action_type(property_type) + # 创建一个包装类来返回ROS2DeviceNode + wrapper_class_name = f"ROS2NodeWrapper4{cls.__name__}" + ROS2DeviceNodeWrapper = type( + wrapper_class_name, + (ROS2DeviceNode,), + { + "__init__": lambda self, *args, **kwargs: init_wrapper( + self, + driver_class=cls, + status_types=status_types, + action_value_mappings=action_value_mappings, + hardware_interface=hardware_interface, + print_publish=print_publish, + children=children, + *args, + **kwargs, + ), + }, + ) + return ROS2DeviceNodeWrapper diff --git a/unilabos/ros/initialize_device.py b/unilabos/ros/initialize_device.py new file mode 100644 index 00000000..730caa13 --- /dev/null +++ b/unilabos/ros/initialize_device.py @@ -0,0 +1,51 @@ +import rclpy +from rclpy.node import Node +from typing import Optional +from unilabos.registry.registry import lab_registry +from unilabos.ros.nodes.base_device_node import ROS2DeviceNode, DeviceInitError +from unilabos.ros.device_node_wrapper import ros2_device_node +from unilabos.utils import logger +from unilabos.utils.import_manager import default_manager + + +def initialize_device_from_dict(device_id, device_config) -> Optional[ROS2DeviceNode]: + """Initializes a device based on its configuration. + + This function dynamically imports the appropriate device class and creates an instance of it using the provided device configuration. + It also sets up action clients for the device based on its action value mappings. + + Args: + device_id (str): The unique identifier for the device. + device_config (dict): The configuration dictionary for the device, which includes the class type and other parameters. + + Returns: + None + """ + d = None + device_class_config = device_config["class"] + if isinstance(device_class_config, str): # 如果是字符串,则直接去lab_registry中查找,获取class + if device_class_config not in lab_registry.device_type_registry: + raise ValueError(f"Device class {device_class_config} not found.") + device_class_config = device_config["class"] = lab_registry.device_type_registry[device_class_config]["class"] + if isinstance(device_class_config, dict): + DEVICE = default_manager.get_class(device_class_config["module"]) + # 不管是ros2的实例,还是python的,都必须包一次,除了HostNode + DEVICE = ros2_device_node( + DEVICE, + status_types=device_class_config.get("status_types", {}), + action_value_mappings=device_class_config.get("action_value_mappings", {}), + hardware_interface=device_class_config.get( + "hardware_interface", + {"name": "hardware_interface", "write": "send_command", "read": "read_data", "extra_info": []}, + ), + children=device_config.get("children", {}) + ) + try: + d = DEVICE( + device_id=device_id, driver_is_ros=device_class_config["type"] == "ros2", driver_params=device_config.get("config", {}) + ) + except DeviceInitError as ex: + return d + else: + logger.warning(f"initialize device {device_id} failed, provided device_config: {device_config}") + return d diff --git a/unilabos/ros/main_slave_run.py b/unilabos/ros/main_slave_run.py new file mode 100644 index 00000000..1ae37906 --- /dev/null +++ b/unilabos/ros/main_slave_run.py @@ -0,0 +1,124 @@ +import os +import traceback +from typing import Optional, Dict, Any, List + +import rclpy +from unilabos_msgs.msg import Resource # type: ignore +from unilabos_msgs.srv import ResourceAdd # type: ignore +from rclpy.executors import MultiThreadedExecutor +from rclpy.node import Node +from rclpy.timer import Timer + +from unilabos.ros.initialize_device import initialize_device_from_dict +from unilabos.ros.msgs.message_converter import ( + convert_to_ros_msg, +) +from unilabos.ros.nodes.presets.host_node import HostNode +from unilabos.ros.x.rclpyx import run_event_loop_in_thread +from unilabos.utils import logger +from unilabos.config.config import BasicConfig + + +def exit() -> None: + """关闭ROS节点和资源""" + host_instance = HostNode.get_instance() + if host_instance is not None: + # 停止发现定时器 + # noinspection PyProtectedMember + if hasattr(host_instance, "_discovery_timer") and isinstance(host_instance._discovery_timer, Timer): + # noinspection PyProtectedMember + host_instance._discovery_timer.cancel() + for _, device_node in host_instance.devices_instances.items(): + if hasattr(device_node, "destroy_node"): + device_node.ros_node_instance.destroy_node() + host_instance.destroy_node() + rclpy.shutdown() + + +def main( + devices_config: Dict[str, Any] = {}, + resources_config={}, + graph: Optional[Dict[str, Any]] = None, + controllers_config: Dict[str, Any] = {}, + bridges: List[Any] = [], + args: List[str] = ["--log-level", "debug"], + discovery_interval: float = 5.0, +) -> None: + """主函数""" + rclpy.init(args=args) + rclpy.__executor = executor = MultiThreadedExecutor() + + # 创建主机节点 + host_node = HostNode( + "host_node", + devices_config, + resources_config, + graph, + controllers_config, + bridges, + discovery_interval, + ) + + executor.add_node(host_node) + # run_event_loop_in_thread() + + try: + executor.spin() + except Exception as e: + logger.error(traceback.format_exc()) + print(f"Exception caught: {e}") + finally: + exit() + + +def slave( + devices_config: Dict[str, Any] = {}, + resources_config=[], + graph: Optional[Dict[str, Any]] = None, + controllers_config: Dict[str, Any] = {}, + bridges: List[Any] = [], + args: List[str] = ["--log-level", "debug"], +) -> None: + """从节点函数""" + rclpy.init(args=args) + rclpy.__executor = executor = MultiThreadedExecutor() + + for device_id, device_config in devices_config.items(): + d = initialize_device_from_dict(device_id, device_config) + if d is None: + continue + # 默认初始化 + # if d is not None and isinstance(d, Node): + # executor.add_node(d) + # else: + # print(f"Warning: Device {device_id} could not be initialized or is not a valid Node") + + machine_name = os.popen("hostname").read().strip() + machine_name = "".join([c if c.isalnum() or c == "_" else "_" for c in machine_name]) + n = Node(f"slaveMachine_{machine_name}", parameter_overrides=[]) + executor.add_node(n) + + if BasicConfig.slave_no_host: + # 确保ResourceAdd存在 + if "ResourceAdd" in globals(): + rclient = n.create_client(ResourceAdd, "/resources/add") + rclient.wait_for_service() # FIXME 可能一直等待,加一个参数 + + request = ResourceAdd.Request() + request.resources = [convert_to_ros_msg(Resource, resource) for resource in resources_config] + response = rclient.call_async(request) + else: + print("Warning: ResourceAdd service not available") + + run_event_loop_in_thread() + + try: + executor.spin() + except Exception as e: + print(f"Exception caught: {e}") + finally: + exit() + + +if __name__ == "__main__": + main() diff --git a/unilabos/ros/msgs/__init__.py b/unilabos/ros/msgs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/ros/msgs/message_converter.py b/unilabos/ros/msgs/message_converter.py new file mode 100644 index 00000000..5e87fce5 --- /dev/null +++ b/unilabos/ros/msgs/message_converter.py @@ -0,0 +1,789 @@ +""" +消息转换器 + +该模块提供了在Python对象(dataclass, Pydantic模型)和ROS消息类型之间进行转换的功能。 +使用ImportManager动态导入和管理所需模块。 +""" + +import json +import traceback +from io import StringIO +from typing import Iterable, Any, Dict, Type, TypeVar, Union + +import yaml +from pydantic import BaseModel +from dataclasses import asdict, is_dataclass + +from rosidl_parser.definition import UnboundedSequence, NamespacedType, BasicType, UnboundedString + +from unilabos.utils import logger +from unilabos.utils.import_manager import ImportManager +from unilabos.config.config import ROSConfig + +# 定义泛型类型 +T = TypeVar("T") +DataClassT = TypeVar("DataClassT") + +# 从配置中获取需要导入的模块列表 +ROS_MODULES = ROSConfig.modules + +msg_converter_manager = ImportManager(ROS_MODULES) + + +"""geometry_msgs""" +Point = msg_converter_manager.get_class("geometry_msgs.msg:Point") +Pose = msg_converter_manager.get_class("geometry_msgs.msg:Pose") +"""std_msgs""" +Float64 = msg_converter_manager.get_class("std_msgs.msg:Float64") +Float64MultiArray = msg_converter_manager.get_class("std_msgs.msg:Float64MultiArray") +Int32 = msg_converter_manager.get_class("std_msgs.msg:Int32") +Int64 = msg_converter_manager.get_class("std_msgs.msg:Int64") +String = msg_converter_manager.get_class("std_msgs.msg:String") +Bool = msg_converter_manager.get_class("std_msgs.msg:Bool") +"""nav2_msgs""" +NavigateToPose = msg_converter_manager.get_class("nav2_msgs.action:NavigateToPose") +NavigateThroughPoses = msg_converter_manager.get_class("nav2_msgs.action:NavigateThroughPoses") +SingleJointPosition = msg_converter_manager.get_class("control_msgs.action:SingleJointPosition") +"""unilabos_msgs""" +Resource = msg_converter_manager.get_class("unilabos_msgs.msg:Resource") +SendCmd = msg_converter_manager.get_class("unilabos_msgs.action:SendCmd") +"""unilabos""" +imsg = msg_converter_manager.get_module("unilabos.messages") +Point3D = msg_converter_manager.get_class("unilabos.messages:Point3D") + +# 基本消息类型映射 +_msg_mapping: Dict[Type, Type] = { + float: Float64, + list[float]: Float64MultiArray, + int: Int32, + str: String, + bool: Bool, + Point3D: Point, +} + +# Action类型映射 +_action_mapping: Dict[Type, Dict[str, Any]] = { + float: { + "type": SingleJointPosition, + "goal": {"position": "position", "max_velocity": "max_velocity"}, + "feedback": {"position": "position"}, + "result": {}, + }, + str: { + "type": SendCmd, + "goal": {"command": "position"}, + "feedback": {"status": "status"}, + "result": {}, + }, + Point3D: { + "type": NavigateToPose, + "goal": {"pose.pose.position": "position"}, + "feedback": { + "current_pose.pose.position": "position", + "navigation_time.sec": "time_spent", + "estimated_time_remaining.sec": "time_remaining", + }, + "result": {}, + }, + list[Point3D]: { + "type": NavigateThroughPoses, + "goal": {"poses[].pose.position": "positions[]"}, + "feedback": { + "current_pose.pose.position": "position", + "navigation_time.sec": "time_spent", + "estimated_time_remaining.sec": "time_remaining", + "number_of_poses_remaining": "pose_number_remaining", + }, + "result": {}, + }, +} + +# 添加Protocol action类型到映射 +for py_msgtype in imsg.__all__: + if py_msgtype not in _action_mapping and py_msgtype.endswith("Protocol"): + try: + protocol_class = msg_converter_manager.get_class(f"unilabos.messages.{py_msgtype}") + action_name = py_msgtype.replace("Protocol", "") + action_type = msg_converter_manager.get_class(f"unilabos_msgs.action.{action_name}") + + if action_type: + _action_mapping[protocol_class] = { + "type": action_type, + "goal": {k: k for k in action_type.Goal().get_fields_and_field_types().keys()}, + "feedback": { + (k if "time" not in k else f"{k}.sec"): k + for k in action_type.Feedback().get_fields_and_field_types().keys() + }, + "result": {k: k for k in action_type.Result().get_fields_and_field_types().keys()}, + } + except Exception: + logger.debug(f"Failed to load Protocol class: {py_msgtype}") + +# Python到ROS消息转换器 +_msg_converter: Dict[Type, Any] = { + float: float, + Float64: lambda x: Float64(data=float(x)), + Float64MultiArray: lambda x: Float64MultiArray(data=[float(i) for i in x]), + int: int, + Int32: lambda x: Int32(data=int(x)), + Int64: lambda x: Int64(data=int(x)), + bool: bool, + Bool: lambda x: Bool(data=bool(x)), + str: str, + String: lambda x: String(data=str(x)), + Point: lambda x: Point(x=x.x, y=x.y, z=x.z), + Resource: lambda x: Resource( + id=x["id"], + name=x["name"], + sample_id=x.get("sample_id", "") or "", + children=list(x.get("children", [])), + parent=x.get("parent", "") or "", + type=x["type"], + category=x.get("class", "") or x["type"], + pose=( + Pose(position=Point(x=float(x["position"]["x"]), y=float(x["position"]["y"]), z=float(x["position"]["z"]))) + if x.get("position", None) is not None + else Pose() + ), + config=json.dumps(x.get("config", {})), + data=json.dumps(x.get("data", {})), + ), +} + +def json_or_yaml_loads(data: str) -> Any: + try: + return json.loads(data) + except Exception as e: + try: + return yaml.safe_load(StringIO(data)) + except: + pass + raise e + +# ROS消息到Python转换器 +_msg_converter_back: Dict[Type, Any] = { + float: float, + Float64: lambda x: x.data, + Float64MultiArray: lambda x: x.data, + int: int, + Int32: lambda x: x.data, + Int64: lambda x: x.data, + bool: bool, + Bool: lambda x: x.data, + str: str, + String: lambda x: x.data, + Point: lambda x: Point3D(x=x.x, y=x.y, z=x.z), + Resource: lambda x: { + "id": x.id, + "name": x.name, + "sample_id": x.sample_id if x.sample_id else None, + "children": list(x.children), + "parent": x.parent if x.parent else None, + "type": x.type, + "class": x.category, + "position": {"x": x.pose.position.x, "y": x.pose.position.y, "z": x.pose.position.z}, + "config": json_or_yaml_loads(x.config or "{}"), + "data": json_or_yaml_loads(x.data or "{}"), + }, +} + +# 消息数据类型映射 +_msg_data_mapping: Dict[str, Type] = { + "double": float, + "float": float, + "int": int, + "bool": bool, + "str": str, +} + + +def compare_model_fields(cls1: Any, cls2: Any) -> bool: + """比较两个类的字段是否相同""" + + def get_class_fields(cls: Any) -> set: + if hasattr(cls, "__annotations__"): + return set(cls.__annotations__.keys()) + else: + return set(cls.__dict__.keys()) + + fields1 = get_class_fields(cls1) + fields2 = get_class_fields(cls2) + return fields1 == fields2 + + +def get_msg_type(datatype: Type) -> Type: + """ + 获取与Python数据类型对应的ROS消息类型 + + Args: + datatype: Python数据类型、Pydantic模型或dataclass + + Returns: + 对应的ROS消息类型 + + Raises: + ValueError: 如果不支持的消息类型 + """ + # 直接匹配已知类型 + if isinstance(datatype, type) and datatype in _msg_mapping: + return _msg_mapping[datatype] + + # 尝试通过字段比较匹配 + for k, v in _msg_mapping.items(): + if compare_model_fields(k, datatype): + return v + + raise ValueError(f"Unsupported message type: {datatype}") + + +def get_action_type(datatype: Type) -> Dict[str, Any]: + """ + 获取与Python数据类型对应的ROS动作类型 + + Args: + datatype: Python数据类型、Pydantic模型或dataclass + + Returns: + 对应的ROS动作类型配置 + + Raises: + ValueError: 如果不支持的动作类型 + """ + # 直接匹配已知类型 + if isinstance(datatype, type) and datatype in _action_mapping: + return _action_mapping[datatype] + + # 尝试通过字段比较匹配 + for k, v in _action_mapping.items(): + if compare_model_fields(k, datatype): + return v + + raise ValueError(f"Unsupported action type: {datatype}") + + +def get_ros_type_by_msgname(msgname: str) -> Type: + """ + 通过消息名称获取ROS类型 + + Args: + msgname: ROS消息名称,格式为 'package_name/(action,msg,srv)/TypeName' + + Returns: + 对应的ROS类型 + + Raises: + ValueError: 如果无效的ROS消息名称 + ImportError: 如果无法加载类型 + """ + parts = msgname.split("/") + if len(parts) != 3 or parts[1] not in ("action", "msg", "srv"): + raise ValueError( + f"Invalid ROS message name: {msgname}. Format should be 'package_name/(action,msg,srv)/TypeName'" + ) + + package_name, msg_type, type_name = parts + full_module_path = f"{package_name}.{msg_type}" + + try: + # 尝试通过ImportManager获取 + return msg_converter_manager.get_class(f"{full_module_path}.{type_name}") + except KeyError: + # 尝试动态导入 + try: + msg_converter_manager.load_module(full_module_path) + return msg_converter_manager.get_class(f"{full_module_path}.{type_name}") + except Exception as e: + raise ImportError(f"Failed to load type {type_name}. Make sure the package is installed.") from e + + +def _extract_data(obj: Any) -> Dict[str, Any]: + """提取对象数据为字典""" + if is_dataclass(obj) and not isinstance(obj, type) and hasattr(obj, "__dataclass_fields__"): + return asdict(obj) + elif isinstance(obj, BaseModel): + return obj.model_dump() + elif isinstance(obj, dict): + return obj + else: + return {"data": obj} + + +def convert_to_ros_msg(ros_msg_type: Union[Type, Any], obj: Any) -> Any: + """ + 将Python对象转换为ROS消息实例 + + Args: + ros_msg_type: 目标ROS消息类型 + obj: Python对象(基本类型、dataclass或Pydantic实例) + + Returns: + ROS消息实例 + """ + # 尝试使用预定义转换器 + try: + if isinstance(ros_msg_type, type) and ros_msg_type in _msg_converter: + return _msg_converter[ros_msg_type](obj) + except Exception as e: + logger.error(f"Converter error: {type(ros_msg_type)} -> {obj}") + traceback.print_exc() + + # 创建ROS消息实例 + ros_msg = ros_msg_type() if isinstance(ros_msg_type, type) else ros_msg_type + + # 提取数据 + data = _extract_data(obj) + + # 转换数据到ROS消息 + for key, value in data.items(): + if hasattr(ros_msg, key): + attr = getattr(ros_msg, key) + if isinstance(attr, (float, int, str, bool)): + setattr(ros_msg, key, value) + elif isinstance(attr, (list, tuple)) and isinstance(value, Iterable): + setattr(ros_msg, key, list(value)) + else: + nested_ros_msg = convert_to_ros_msg(type(attr)(), value) + setattr(ros_msg, key, nested_ros_msg) + else: + # 跳过不存在的字段,防止报错 + continue + + return ros_msg + + +def convert_to_ros_msg_with_mapping(ros_msg_type: Type, obj: Any, value_mapping: Dict[str, str]) -> Any: + """ + 根据字段映射将Python对象转换为ROS消息 + + Args: + ros_msg_type: 目标ROS消息类型 + obj: Python对象 + value_mapping: 字段名映射关系字典 + + Returns: + ROS消息实例 + """ + # 创建ROS消息实例 + ros_msg = ros_msg_type() if isinstance(ros_msg_type, type) else ros_msg_type + + # 提取数据 + data = _extract_data(obj) + + # 按照映射关系处理每个字段 + for msg_name, attr_name in value_mapping.items(): + msg_path = msg_name.split(".") + attr_base = attr_name.rstrip("[]") + + if attr_base not in data: + continue + + value = data[attr_base] + if value is None: + continue + + try: + if not attr_name.endswith("[]"): + # 处理单值映射,如 {"pose.position": "position"} + current = ros_msg + for i, name in enumerate(msg_path[:-1]): + current = getattr(current, name) + + last_field = msg_path[-1] + field_type = type(getattr(current, last_field)) + setattr(current, last_field, convert_to_ros_msg(field_type, value)) + else: + # 处理列表值映射,如 {"poses[].position": "positions[]"} + if not isinstance(value, Iterable) or isinstance(value, (str, dict)): + continue + + items = list(value) + if not items: + continue + + # 仅支持简单路径的数组映射 + if len(msg_path) <= 2: + array_field = msg_path[0] + if hasattr(ros_msg, array_field): + if len(msg_path) == 1: + # 直接设置数组 + setattr(ros_msg, array_field, items) + else: + # 设置数组元素的属性 + target_field = msg_path[1] + array_items = getattr(ros_msg, array_field) + + # 确保数组大小匹配 + while len(array_items) < len(items): + # 添加新元素类型 + if array_items: + elem_type = type(array_items[0]) + array_items.append(elem_type()) + else: + # 无法确定元素类型时中断 + break + + # 设置每个元素的属性 + for i, val in enumerate(items): + if i < len(array_items): + setattr(array_items[i], target_field, val) + except Exception as e: + # 忽略映射错误 + logger.debug(f"Mapping error for {msg_name} -> {attr_name}: {str(e)}") + continue + + return ros_msg + + +def convert_from_ros_msg(msg: Any) -> Any: + """ + 将ROS消息对象递归转换为Python对象 + + Args: + msg: ROS消息实例 + + Returns: + Python对象(字典或基本类型) + """ + # 使用预定义转换器 + if type(msg) in _msg_converter_back: + return _msg_converter_back[type(msg)](msg) + + # 处理标准ROS消息 + elif hasattr(msg, "__slots__") and hasattr(msg, "_fields_and_field_types"): + result = {} + for field in msg.__slots__: + field_value = getattr(msg, field) + field_name = field[1:] if field.startswith("_") else field + result[field_name] = convert_from_ros_msg(field_value) + return result + + # 处理列表或元组 + elif isinstance(msg, (list, tuple)): + return [convert_from_ros_msg(item) for item in msg] + + # 返回基本类型 + else: + return msg + + +def convert_from_ros_msg_with_mapping(ros_msg: Any, value_mapping: Dict[str, str]) -> Dict[str, Any]: + """ + 根据字段映射将ROS消息转换为Python字典 + + Args: + ros_msg: ROS消息实例 + value_mapping: 字段名映射关系字典 + + Returns: + Python字典 + """ + data: Dict[str, Any] = {} + + for msg_name, attr_name in value_mapping.items(): + msg_path = msg_name.split(".") + current = ros_msg + + try: + if not attr_name.endswith("[]"): + # 处理单值映射 + for name in msg_path: + current = getattr(current, name) + data[attr_name] = convert_from_ros_msg(current) + else: + # 处理列表值映射 + for name in msg_path: + if name.endswith("[]"): + base_name = name[:-2] + if hasattr(current, base_name): + current = list(getattr(current, base_name)) + else: + current = [] + break + else: + if isinstance(current, list): + next_level = [] + for item in current: + if hasattr(item, name): + next_level.append(getattr(item, name)) + current = next_level + elif hasattr(current, name): + current = getattr(current, name) + else: + current = [] + break + + attr_key = attr_name[:-2] + if current: + data[attr_key] = [convert_from_ros_msg(item) for item in current] + except (AttributeError, TypeError): + logger.debug(f"Mapping conversion error for {msg_name} -> {attr_name}") + continue + + return data + + +def set_msg_data(dtype_str: str, data: Any) -> Any: + """ + 将数据转换为指定消息类型 + + Args: + dtype_str: 消息类型字符串 + data: 要转换的数据 + + Returns: + 转换后的数据 + """ + converter = _msg_data_mapping.get(dtype_str, str) + return converter(data) + + +""" +ROS Action 到 JSON Schema 转换器 + +该模块提供了将 ROS Action 定义转换为 JSON Schema 的功能, +用于规范化 Action 接口和生成文档。 +""" + +import json +import yaml +from typing import Any, Dict, Type, Union, Optional + +from unilabos.utils import logger +from unilabos.utils.import_manager import ImportManager +from unilabos.config.config import ROSConfig + +basic_type_map = { + 'bool': {'type': 'boolean'}, + 'int8': {'type': 'integer', 'minimum': -128, 'maximum': 127}, + 'uint8': {'type': 'integer', 'minimum': 0, 'maximum': 255}, + 'int16': {'type': 'integer', 'minimum': -32768, 'maximum': 32767}, + 'uint16': {'type': 'integer', 'minimum': 0, 'maximum': 65535}, + 'int32': {'type': 'integer', 'minimum': -2147483648, 'maximum': 2147483647}, + 'uint32': {'type': 'integer', 'minimum': 0, 'maximum': 4294967295}, + 'int64': {'type': 'integer'}, + 'uint64': {'type': 'integer', 'minimum': 0}, + 'double': {'type': 'number'}, + 'float32': {'type': 'number'}, + 'float64': {'type': 'number'}, + 'string': {'type': 'string'}, + 'char': {'type': 'string', 'maxLength': 1}, + 'byte': {'type': 'integer', 'minimum': 0, 'maximum': 255}, +} + + +def ros_field_type_to_json_schema(type_info: Type | str, slot_type: str=None) -> Dict[str, Any]: + """ + 将 ROS 字段类型转换为 JSON Schema 类型定义 + + Args: + type_info: ROS 类型 + slot_type: ROS 类型 + + Returns: + 对应的 JSON Schema 类型定义 + """ + if isinstance(type_info, UnboundedSequence): + return { + 'type': 'array', + 'items': ros_field_type_to_json_schema(type_info.value_type) + } + if isinstance(type_info, NamespacedType): + cls_name = ".".join(type_info.namespaces) + ":" + type_info.name + type_class = msg_converter_manager.get_class(cls_name) + return ros_field_type_to_json_schema(type_class) + elif isinstance(type_info, BasicType): + return ros_field_type_to_json_schema(type_info.typename) + elif isinstance(type_info, UnboundedString): + return basic_type_map['string'] + elif isinstance(type_info, str): + if type_info in basic_type_map: + return basic_type_map[type_info] + + # 处理时间和持续时间类型 + if type_info in ('time', 'duration', 'builtin_interfaces/Time', 'builtin_interfaces/Duration'): + return { + 'type': 'object', + 'properties': { + 'sec': {'type': 'integer', 'description': '秒'}, + 'nanosec': {'type': 'integer', 'description': '纳秒'} + }, + 'required': ['sec', 'nanosec'] + } + else: + return ros_message_to_json_schema(type_info) + # # 处理数组类型 + # if field_type.endswith('[]'): + # item_type = field_type[:-2] + # return { + # 'type': 'array', + # 'items': ros_field_type_to_json_schema(item_type) + # } + + + + # # 处理复杂类型(尝试加载并处理) + # try: + # # 如果它是一个完整的消息类型规范 (包名/msg/类型名) + # if '/' in field_type: + # msg_class = get_ros_type_by_msgname(field_type) + # return ros_message_to_json_schema(msg_class) + # else: + # # 可能是相对引用或简单名称 + # return {'type': 'object', 'description': f'复合类型: {field_type}'} + # except Exception as e: + # # 如果无法解析,返回通用对象类型 + # logger.debug(f"无法解析类型 {field_type}: {str(e)}") + # return {'type': 'object', 'description': f'未知类型: {field_type}'} + +def ros_message_to_json_schema(msg_class: Any) -> Dict[str, Any]: + """ + 将 ROS 消息类转换为 JSON Schema + + Args: + msg_class: ROS 消息类 + + Returns: + 对应的 JSON Schema 定义 + """ + schema = { + 'type': 'object', + 'properties': {}, + 'required': [] + } + + # 获取类名作为标题 + if hasattr(msg_class, '__name__'): + schema['title'] = msg_class.__name__ + + # 获取消息的字段和字段类型 + try: + for ind, slot_info in enumerate(msg_class._fields_and_field_types.items()): + slot_name, slot_type = slot_info + type_info = msg_class.SLOT_TYPES[ind] + field_schema = ros_field_type_to_json_schema(type_info, slot_type) + schema['properties'][slot_name] = field_schema + schema['required'].append(slot_name) + # if hasattr(msg_class, 'get_fields_and_field_types'): + # fields_and_types = msg_class.get_fields_and_field_types() + # + # for field_name, field_type in fields_and_types.items(): + # # 将 ROS 字段类型转换为 JSON Schema + # field_schema = ros_field_type_to_json_schema(field_type) + # + # schema['properties'][field_name] = field_schema + # schema['required'].append(field_name) + # elif hasattr(msg_class, '__slots__') and hasattr(msg_class, '_fields_and_field_types'): + # # 直接从实例属性获取 + # for field_name in msg_class.__slots__: + # # 移除前导下划线(如果有) + # clean_name = field_name[1:] if field_name.startswith('_') else field_name + # + # # 从 _fields_and_field_types 获取类型 + # if clean_name in msg_class._fields_and_field_types: + # field_type = msg_class._fields_and_field_types[clean_name] + # field_schema = ros_field_type_to_json_schema(field_type) + # + # schema['properties'][clean_name] = field_schema + # schema['required'].append(clean_name) + except Exception as e: + # 如果获取字段类型失败,添加错误信息 + schema['description'] = f"解析消息字段时出错: {str(e)}" + logger.error(f"解析 {msg_class.__name__} 消息字段失败: {str(e)}") + + return schema + +def ros_action_to_json_schema(action_class: Any) -> Dict[str, Any]: + """ + 将 ROS Action 类转换为 JSON Schema + + Args: + action_class: ROS Action 类 + + Returns: + 完整的 JSON Schema 定义 + """ + if not hasattr(action_class, 'Goal') or not hasattr(action_class, 'Feedback') or not hasattr(action_class, 'Result'): + raise ValueError(f"{action_class.__name__} 不是有效的 ROS Action 类") + + # 创建基础 schema + schema = { + '$schema': 'http://json-schema.org/draft-07/schema#', + 'title': action_class.__name__, + 'description': f"ROS Action {action_class.__name__} 的 JSON Schema", + 'type': 'object', + 'properties': { + 'goal': { + 'description': 'Action 目标 - 从客户端发送到服务器', + **ros_message_to_json_schema(action_class.Goal) + }, + 'feedback': { + 'description': 'Action 反馈 - 执行过程中从服务器发送到客户端', + **ros_message_to_json_schema(action_class.Feedback) + }, + 'result': { + 'description': 'Action 结果 - 完成后从服务器发送到客户端', + **ros_message_to_json_schema(action_class.Result) + } + }, + 'required': ['goal'] + } + + return schema + +def convert_ros_action_to_jsonschema( + action_name_or_type: Union[str, Type], + output_file: Optional[str] = None, + format: str = 'json' +) -> Dict[str, Any]: + """ + 将 ROS Action 类型转换为 JSON Schema,并可选地保存到文件 + + Args: + action_name_or_type: ROS Action 类型名称或类 + output_file: 可选,输出 JSON Schema 的文件路径 + format: 输出格式,'json' 或 'yaml' + + Returns: + JSON Schema 定义(字典) + """ + # 处理输入参数 + if isinstance(action_name_or_type, str): + # 如果是字符串,尝试加载 Action 类型 + action_type = get_ros_type_by_msgname(action_name_or_type) + else: + action_type = action_name_or_type + + # 生成 JSON Schema + schema = ros_action_to_json_schema(action_type) + + # 如果指定了输出文件,将 Schema 保存到文件 + if output_file: + if format.lower() == 'json': + with open(output_file, 'w', encoding='utf-8') as f: + json.dump(schema, f, indent=2, ensure_ascii=False) + elif format.lower() == 'yaml': + with open(output_file, 'w', encoding='utf-8') as f: + yaml.safe_dump(schema, f, default_flow_style=False, allow_unicode=True) + else: + raise ValueError(f"不支持的格式: {format},请使用 'json' 或 'yaml'") + + return schema + + +# 示例用法 +if __name__ == "__main__": + # 示例:转换 NavigateToPose action + try: + from nav2_msgs.action import NavigateToPose + + # 转换为 JSON Schema 并打印 + schema = convert_ros_action_to_jsonschema(NavigateToPose) + print(json.dumps(schema, indent=2, ensure_ascii=False)) + + # 保存到文件 + # convert_ros_action_to_jsonschema(NavigateToPose, "navigate_to_pose_schema.json") + + # 或者使用字符串形式的 action 名称 + # schema = convert_ros_action_to_jsonschema("nav2_msgs/action/NavigateToPose") + except ImportError: + print("无法导入 NavigateToPose action,请确保已安装相关 ROS 包。") diff --git a/unilabos/ros/nodes/__init__.py b/unilabos/ros/nodes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py new file mode 100644 index 00000000..1424b0c6 --- /dev/null +++ b/unilabos/ros/nodes/base_device_node.py @@ -0,0 +1,672 @@ +import threading +import time +import traceback +import uuid +from typing import get_type_hints, TypeVar, Generic, Dict, Any, Type, TypedDict, Optional + +from concurrent.futures import ThreadPoolExecutor +import asyncio + +import rclpy +from rclpy.node import Node +from rclpy.action import ActionServer +from rclpy.action.server import ServerGoalHandle +from rclpy.client import Client +from rclpy.callback_groups import ReentrantCallbackGroup + +from unilabos.resources.graphio import convert_resources_to_type, convert_resources_from_type +from unilabos.ros.msgs.message_converter import ( + convert_to_ros_msg, + convert_from_ros_msg, + convert_from_ros_msg_with_mapping, + convert_to_ros_msg_with_mapping, +) +from unilabos_msgs.srv import ResourceAdd, ResourceGet, ResourceDelete, ResourceUpdate, ResourceList # type: ignore +from unilabos_msgs.msg import Resource # type: ignore + +from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker +from unilabos.ros.x.rclpyx import get_event_loop +from unilabos.ros.utils.driver_creator import ProtocolNodeCreator, PyLabRobotCreator, DeviceClassCreator +from unilabos.utils.async_util import run_async_func +from unilabos.utils.log import info, debug, warning, error, critical, logger +from unilabos.utils.type_check import get_type_class + +T = TypeVar("T") + + +# 在线设备注册表 +registered_devices: Dict[str, "DeviceInfoType"] = {} + + +# 实现同时记录自定义日志和ROS2日志的适配器 +class ROSLoggerAdapter: + """同时向自定义日志和ROS2日志发送消息的适配器""" + + @property + def identifier(self): + return f"{self.namespace}/{self.node_name}" + + def __init__(self, ros_logger, node_name, namespace): + """ + 初始化日志适配器 + + Args: + ros_logger: ROS2日志记录器 + node_name: 节点名称 + namespace: 命名空间 + """ + self.ros_logger = ros_logger + self.node_name = node_name + self.namespace = namespace + self.level_2_logger_func = { + "info": info, + "debug": debug, + "warning": warning, + "error": error, + "critical": critical, + } + + def _log(self, level, msg, *args, **kwargs): + """实际执行日志记录的内部方法""" + # 添加前缀,使日志更易识别 + msg = f"[{self.identifier}] {msg}" + # 向ROS2日志发送消息(标准库logging不支持stack_level参数) + ros_log_func = getattr(self.ros_logger, "debug") # 默认发送debug,这样不会显示在控制台 + ros_log_func(msg) + self.level_2_logger_func[level](msg, *args, stack_level=1, **kwargs) + + def debug(self, msg, *args, **kwargs): + """记录DEBUG级别日志""" + self._log("debug", msg, *args, **kwargs) + + def info(self, msg, *args, **kwargs): + """记录INFO级别日志""" + self._log("info", msg, *args, **kwargs) + + def warning(self, msg, *args, **kwargs): + """记录WARNING级别日志""" + self._log("warning", msg, *args, **kwargs) + + def error(self, msg, *args, **kwargs): + """记录ERROR级别日志""" + self._log("error", msg, *args, **kwargs) + + def critical(self, msg, *args, **kwargs): + """记录CRITICAL级别日志""" + self._log("critical", msg, *args, **kwargs) + + +def init_wrapper( + self, + device_id: str, + driver_class: type[T], + status_types: Dict[str, Any], + action_value_mappings: Dict[str, Any], + hardware_interface: Dict[str, Any], + print_publish: bool, + children: Optional[list] = None, + driver_params: Optional[Dict[str, Any]] = None, + driver_is_ros: bool = False, + *args, + **kwargs, +): + """初始化设备节点的包装函数,和ROS2DeviceNode初始化保持一致""" + if driver_params is None: + driver_params = kwargs.copy() + if children is None: + children = [] + kwargs["device_id"] = device_id + kwargs["driver_class"] = driver_class + kwargs["driver_params"] = driver_params + kwargs["status_types"] = status_types + kwargs["action_value_mappings"] = action_value_mappings + kwargs["hardware_interface"] = hardware_interface + kwargs["children"] = children + kwargs["print_publish"] = print_publish + kwargs["driver_is_ros"] = driver_is_ros + super(type(self), self).__init__(*args, **kwargs) + + +class PropertyPublisher: + def __init__( + self, + node: "BaseROS2DeviceNode", + name: str, + get_method, + msg_type, + initial_period: float = 5.0, + print_publish=True, + ): + self.node = node + self.name = name + self.msg_type = msg_type + self.get_method = get_method + self.timer_period = initial_period + self.print_publish = print_publish + + self._value = None + self.publisher_ = node.create_publisher(msg_type, f"{name}", 10) + self.timer = node.create_timer(self.timer_period, self.publish_property) + self.__loop = get_event_loop() + str_msg_type = str(msg_type)[8:-2] + self.node.lab_logger().debug(f"发布属性: {name}, 类型: {str_msg_type}, 周期: {initial_period}秒") + + def get_property(self): + if asyncio.iscoroutinefunction(self.get_method): + # 如果是异步函数,运行事件循环并等待结果 + self.node.get_logger().debug(f"【PropertyPublisher.get_property】获取异步属性: {self.name}") + loop = self.__loop + if loop: + future = asyncio.run_coroutine_threadsafe(self.get_method(), loop) + self._value = future.result() + return self._value + else: + self.node.get_logger().error(f"【PropertyPublisher.get_property】事件循环未初始化") + return None + else: + # 如果是同步函数,直接调用并返回结果 + self.node.get_logger().debug(f"【PropertyPublisher.get_property】获取同步属性: {self.name}") + self._value = self.get_method() + return self._value + + async def get_property_async(self): + try: + # 获取异步属性值 + self.node.get_logger().debug(f"【PropertyPublisher.get_property_async】异步获取属性: {self.name}") + self._value = await self.get_method() + except Exception as e: + self.node.get_logger().error(f"【PropertyPublisher.get_property_async】获取异步属性出错: {str(e)}") + + def publish_property(self): + try: + self.node.get_logger().debug(f"【PropertyPublisher.publish_property】开始发布属性: {self.name}") + value = self.get_property() + if self.print_publish: + self.node.get_logger().info(f"【PropertyPublisher.publish_property】发布 {self.msg_type}: {value}") + if value is not None: + msg = convert_to_ros_msg(self.msg_type, value) + self.publisher_.publish(msg) + self.node.get_logger().debug(f"【PropertyPublisher.publish_property】属性 {self.name} 发布成功") + except Exception as e: + traceback.print_exc() + self.node.get_logger().error(f"【PropertyPublisher.publish_property】发布属性出错: {str(e)}") + + def change_frequency(self, period): + # 动态改变定时器频率 + self.timer_period = period + self.node.get_logger().info( + f"【PropertyPublisher.change_frequency】修改 {self.name} 定时器周期为: {self.timer_period} 秒" + ) + + # 重置定时器 + self.timer.cancel() + self.timer = self.node.create_timer(self.timer_period, self.publish_property) + + +class BaseROS2DeviceNode(Node, Generic[T]): + """ + ROS2设备节点基类 + + 这个类提供了ROS2设备节点的基本功能,包括属性发布、动作服务等。 + 通过泛型参数T来指定具体的设备类型。 + """ + + @property + def identifier(self): + return f"{self.namespace}/{self.device_id}" + + node_name: str + namespace: str + # TODO 要删除,添加时间相关的属性,避免动态添加属性的警告 + time_spent = 0.0 + time_remaining = 0.0 + create_action_server = True + + def __init__( + self, + driver_instance: T, + device_id: str, + status_types: Dict[str, Any], + action_value_mappings: Dict[str, Any], + hardware_interface: Dict[str, Any], + print_publish=True, + resource_tracker: Optional["DeviceNodeResourceTracker"] = None, + ): + """ + 初始化ROS2设备节点 + + Args: + driver_instance: 设备实例 + device_id: 设备标识符 + status_types: 需要发布的状态和传感器信息 + action_value_mappings: 设备动作 + hardware_interface: 硬件接口配置 + print_publish: 是否打印发布信息 + """ + self.driver_instance = driver_instance + self.device_id = device_id + self.uuid = str(uuid.uuid4()) + self.publish_high_frequency = False + self.callback_group = ReentrantCallbackGroup() + self.resource_tracker = resource_tracker + + # 初始化ROS节点 + self.node_name = f'{device_id.split("/")[-1]}' + self.namespace = f"/devices/{device_id}" + Node.__init__(self, self.node_name, namespace=self.namespace) # type: ignore + if self.resource_tracker is None: + self.lab_logger().critical("资源跟踪器未初始化,请检查") + + # 创建自定义日志记录器 + self._lab_logger = ROSLoggerAdapter(self.get_logger(), self.node_name, self.namespace) + + self._action_servers = {} + self._property_publishers = {} + self._status_types = status_types + self._action_value_mappings = action_value_mappings + self._hardware_interface = hardware_interface + self._print_publish = print_publish + + # 创建属性发布者 + for attr_name, msg_type in self._status_types.items(): + if isinstance(attr_name, (int, float)): + if "param" in msg_type.keys(): + pass + else: + for k, v in msg_type.items(): + self.create_ros_publisher(k, v, initial_period=5.0) + else: + self.create_ros_publisher(attr_name, msg_type) + + # 创建动作服务 + if self.create_action_server: + for action_name, action_value_mapping in self._action_value_mappings.items(): + self.create_ros_action_server(action_name, action_value_mapping) + + # 创建线程池执行器 + self._executor = ThreadPoolExecutor(max_workers=max(len(action_value_mappings), 1)) + + # 创建资源管理客户端 + self._resource_clients: Dict[str, Client] = { + "resource_add": self.create_client(ResourceAdd, "/resources/add"), + "resource_get": self.create_client(ResourceGet, "/resources/get"), + "resource_delete": self.create_client(ResourceDelete, "/resources/delete"), + "resource_update": self.create_client(ResourceUpdate, "/resources/update"), + "resource_list": self.create_client(ResourceList, "/resources/list"), + } + + # 向全局在线设备注册表添加设备信息 + self.register_device() + rclpy.get_global_executor().add_node(self) + self.lab_logger().debug(f"ROS节点初始化完成") + + def register_device(self): + """向注册表中注册设备信息""" + topics_info = self._property_publishers.copy() + actions_info = self._action_servers.copy() + # 创建设备信息 + device_info = DeviceInfoType( + id=self.device_id, + uuid=self.uuid, + node_name=self.node_name, + namespace=self.namespace, + driver_instance=self.driver_instance, + status_publishers=topics_info, + actions=actions_info, + hardware_interface=self._hardware_interface, + base_node_instance=self, + ) + # 加入全局注册表 + registered_devices[self.device_id] = device_info + + def lab_logger(self): + """ + 获取实验室自定义日志记录器 + + 这个日志记录器会同时向ROS2日志和自定义日志发送消息, + 并使用node_name和namespace作为标识。 + + Returns: + 日志记录器实例 + """ + return self._lab_logger + + def create_ros_publisher(self, attr_name, msg_type, initial_period=5.0): + """创建ROS发布者""" + + # 获取属性值的方法 + def get_device_attr(): + try: + if hasattr(self.driver_instance, f"get_{attr_name}"): + return getattr(self.driver_instance, f"get_{attr_name}")() + else: + return getattr(self.driver_instance, attr_name) + except AttributeError as ex: + self.lab_logger().error( + f"publish error, {str(type(self.driver_instance))[8:-2]} has no attribute '{attr_name}'" + ) + + self._property_publishers[attr_name] = PropertyPublisher( + self, attr_name, get_device_attr, msg_type, initial_period, self._print_publish + ) + + def create_ros_action_server(self, action_name, action_value_mapping): + """创建ROS动作服务器""" + action_type = action_value_mapping["type"] + str_action_type = str(action_type)[8:-2] + + self._action_servers[action_name] = ActionServer( + self, + action_type, + action_name, + execute_callback=self._create_execute_callback(action_name, action_value_mapping), + callback_group=ReentrantCallbackGroup(), + ) + + self.lab_logger().debug(f"发布动作: {action_name}, 类型: {str_action_type}") + + def _create_execute_callback(self, action_name, action_value_mapping): + """创建动作执行回调函数""" + + async def execute_callback(goal_handle: ServerGoalHandle): + self.lab_logger().info(f"执行动作: {action_name}") + goal = goal_handle.request + + # 从目标消息中提取参数, 并调用对应的方法 + if "sequence" in self._action_value_mappings: + # 如果一个指令对应函数的连续调用,如启动和等待结果,默认参数应该属于第一个函数调用 + def ACTION(**kwargs): + for i, action in enumerate(self._action_value_mappings["sequence"]): + if i == 0: + self.lab_logger().info(f"执行序列动作第一步: {action}") + getattr(self.driver_instance, action)(**kwargs) + else: + self.lab_logger().info(f"执行序列动作后续步骤: {action}") + getattr(self.driver_instance, action)() + + action_paramtypes = get_type_hints( + getattr(self.driver_instance, self._action_value_mappings["sequence"][0]) + ) + else: + ACTION = getattr(self.driver_instance, action_name) + action_paramtypes = get_type_hints(ACTION) + + action_kwargs = convert_from_ros_msg_with_mapping(goal, action_value_mapping["goal"]) + self.lab_logger().debug(f"接收到原始目标: {action_kwargs}") + + # 向Host查询物料当前状态 + for k, v in goal.get_fields_and_field_types().items(): + if v in ["unilabos_msgs/Resource", "sequence"]: + self.lab_logger().info(f"查询资源状态: Key: {k} Type: {v}") + try: + r = ResourceGet.Request() + r.id = action_kwargs[k]["id"] if v == "unilabos_msgs/Resource" else action_kwargs[k][0]["id"] + r.with_children = True + response = await self._resource_clients["resource_get"].call_async(r) + except Exception: + logger.error(f"资源查询失败,默认使用本地资源") + # 删除对response.resources的检查,因为它总是存在 + resources_list = [convert_from_ros_msg(rs) for rs in response.resources] # type: ignore # FIXME + self.lab_logger().debug(f"资源查询结果: {len(resources_list)} 个资源") + type_hint = action_paramtypes[k] + final_type = get_type_class(type_hint) + # 判断 ACTION 是否需要特殊的物料类型如 pylabrobot.resources.Resource,并做转换 + final_resource = convert_resources_to_type(resources_list, final_type) + action_kwargs[k] = self.resource_tracker.figure_resource(final_resource) + + self.lab_logger().info(f"准备执行: {action_kwargs}, 函数: {ACTION.__name__}") + time_start = time.time() + time_overall = 100 + + # 将阻塞操作放入线程池执行 + if asyncio.iscoroutinefunction(ACTION): + try: + self.lab_logger().info(f"异步执行动作 {ACTION}") + future = ROS2DeviceNode.run_async_func(ACTION, **action_kwargs) + except Exception as e: + self.lab_logger().error(f"创建异步任务失败: {traceback.format_exc()}") + raise e + else: + self.lab_logger().info(f"同步执行动作 {ACTION}") + future = self._executor.submit(ACTION, **action_kwargs) + + action_type = action_value_mapping["type"] + feedback_msg_types = action_type.Feedback.get_fields_and_field_types() + result_msg_types = action_type.Result.get_fields_and_field_types() + + while not future.done(): + if goal_handle.is_cancel_requested: + self.lab_logger().info(f"取消动作: {action_name}") + future.cancel() # 尝试取消线程池中的任务 + goal_handle.canceled() + return action_type.Result() + + self.time_spent = time.time() - time_start + self.time_remaining = time_overall - self.time_spent + + # 发布反馈 + feedback_values = {} + for msg_name, attr_name in action_value_mapping["feedback"].items(): + if hasattr(self.driver_instance, f"get_{attr_name}"): + method = getattr(self.driver_instance, f"get_{attr_name}") + if not asyncio.iscoroutinefunction(method): + feedback_values[msg_name] = method() + elif hasattr(self.driver_instance, attr_name): + feedback_values[msg_name] = getattr(self.driver_instance, attr_name) + + if self._print_publish: + self.lab_logger().info(f"反馈: {feedback_values}") + + feedback_msg = convert_to_ros_msg_with_mapping( + ros_msg_type=action_type.Feedback(), + obj=feedback_values, + value_mapping=action_value_mapping["feedback"], + ) + + goal_handle.publish_feedback(feedback_msg) + time.sleep(0.5) + + if future.cancelled(): + self.lab_logger().info(f"动作 {action_name} 已取消") + return action_type.Result() + + self.lab_logger().info(f"动作执行完成: {action_name}") + del future + + # 向Host更新物料当前状态 + for k, v in goal.get_fields_and_field_types().items(): + if v not in ["unilabos_msgs/Resource", "sequence"]: + continue + self.lab_logger().info(f"更新资源状态: {k}") + r = ResourceUpdate.Request() + # 仅当action_kwargs[k]不为None时尝试转换 + akv = action_kwargs[k] + apv = action_paramtypes[k] + final_type = get_type_class(apv) + if final_type is None: + continue + try: + r.resources = [ + convert_to_ros_msg(Resource, self.resource_tracker.root_resource(rs)) + for rs in convert_resources_from_type(akv, final_type) # type: ignore # FIXME # 考虑反查到最大的 + ] + response = await self._resource_clients["resource_update"].call_async(r) + self.lab_logger().debug(f"资源更新结果: {response}") + except Exception as e: + self.lab_logger().error(f"资源更新失败: {e}") + self.lab_logger().error(traceback.format_exc()) + + # 发布结果 + goal_handle.succeed() + self.lab_logger().info(f"设置动作成功: {action_name}") + + result_values = {} + for msg_name, attr_name in action_value_mapping["result"].items(): + if hasattr(self.driver_instance, f"get_{attr_name}"): + result_values[msg_name] = getattr(self.driver_instance, f"get_{attr_name}")() + elif hasattr(self.driver_instance, attr_name): + result_values[msg_name] = getattr(self.driver_instance, attr_name) + + result_msg = convert_to_ros_msg_with_mapping( + ros_msg_type=action_type.Result(), obj=result_values, value_mapping=action_value_mapping["result"] + ) + + for attr_name in result_msg_types.keys(): + if attr_name in ["success", "reached_goal"]: + setattr(result_msg, attr_name, True) + + self.lab_logger().info(f"动作 {action_name} 完成并返回结果") + return result_msg + + return execute_callback + + # 异步上下文管理方法 + async def __aenter__(self): + """进入异步上下文""" + self.lab_logger().info(f"进入异步上下文: {self.device_id}") + if hasattr(self.driver_instance, "__aenter__"): + await self.driver_instance.__aenter__() # type: ignore + self.lab_logger().info(f"异步上下文初始化完成: {self.device_id}") + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """退出异步上下文""" + self.lab_logger().info(f"退出异步上下文: {self.device_id}") + if hasattr(self.driver_instance, "__aexit__"): + await self.driver_instance.__aexit__(exc_type, exc_val, exc_tb) # type: ignore + self.lab_logger().info(f"异步上下文清理完成: {self.device_id}") + + +class DeviceInitError(Exception): + pass + + +class ROS2DeviceNode: + """ + ROS2设备节点类 + + 这个类封装了设备类实例和ROS2节点的功能,提供ROS2接口。 + 它不继承设备类,而是通过代理模式访问设备类的属性和方法。 + """ + + # 类变量,用于循环管理 + _loop = None + _loop_running = False + _loop_thread = None + + @classmethod + def get_loop(cls): + return cls._loop + + @classmethod + def run_async_func(cls, func, **kwargs): + return run_async_func(func, loop=cls._loop, **kwargs) + + @property + def driver_instance(self): + return self._driver_instance + + @property + def ros_node_instance(self): + return self._ros_node + + def __init__( + self, + device_id: str, + driver_class: Type[T], + driver_params: Dict[str, Any], + status_types: Dict[str, Any], + action_value_mappings: Dict[str, Any], + hardware_interface: Dict[str, Any], + children: Dict[str, Any], + print_publish: bool = True, + driver_is_ros: bool = False, + ): + """ + 初始化ROS2设备节点 + + Args: + device_id: 设备标识符 + driver_class: 设备类 + status_types: 状态类型映射 + action_value_mappings: 动作值映射 + hardware_interface: 硬件接口配置 + children: + print_publish: 是否打印发布信息 + driver_is_ros: + """ + # 在初始化时检查循环状态 + if ROS2DeviceNode._loop_running and ROS2DeviceNode._loop_thread is not None: + pass + elif ROS2DeviceNode._loop_thread is None: + self._start_loop() + + # 保存设备类是否支持异步上下文 + self._has_async_context = hasattr(driver_class, "__aenter__") and hasattr(driver_class, "__aexit__") + self._driver_class = driver_class + self.driver_is_ros = driver_is_ros + self.resource_tracker = DeviceNodeResourceTracker() + + # use_pylabrobot_creator 使用 cls的包路径检测 + use_pylabrobot_creator = driver_class.__module__.startswith("pylabrobot") + + # TODO: 要在创建之前预先请求服务器是否有当前id的物料,放到resource_tracker中,让pylabrobot进行创建 + # 创建设备类实例 + if use_pylabrobot_creator: + self._driver_creator = PyLabRobotCreator( + driver_class, children=children, resource_tracker=self.resource_tracker + ) + else: + from unilabos.ros.nodes.presets.protocol_node import ROS2ProtocolNode + + if self._driver_class is ROS2ProtocolNode: + self._driver_creator = ProtocolNodeCreator(driver_class, children=children) + else: + self._driver_creator = DeviceClassCreator(driver_class) + + if driver_is_ros: + driver_params["device_id"] = device_id + driver_params["resource_tracker"] = self.resource_tracker + self._driver_instance = self._driver_creator.create_instance(driver_params) + if self._driver_instance is None: + logger.critical(f"设备实例创建失败 {driver_class}, params: {driver_params}") + raise DeviceInitError("错误: 设备实例创建失败") + + # 创建ROS2节点 + if driver_is_ros: + self._ros_node = self._driver_instance # type: ignore + else: + self._ros_node = BaseROS2DeviceNode( + driver_instance=self._driver_instance, + device_id=device_id, + status_types=status_types, + action_value_mappings=action_value_mappings, + hardware_interface=hardware_interface, + print_publish=print_publish, + resource_tracker=self.resource_tracker, + ) + self._ros_node: BaseROS2DeviceNode + self._ros_node.lab_logger().info(f"初始化完成 {self._ros_node.uuid} {self.driver_is_ros}") + + def _start_loop(self): + def run_event_loop(): + loop = asyncio.new_event_loop() + ROS2DeviceNode._loop = loop + asyncio.set_event_loop(loop) + loop.run_forever() + + ROS2DeviceNode._loop_thread = threading.Thread(target=run_event_loop, daemon=True, name="ROS2DeviceNode") + ROS2DeviceNode._loop_thread.start() + logger.info(f"循环线程已启动") + + +class DeviceInfoType(TypedDict): + id: str + uuid: str + node_name: str + namespace: str + driver_instance: Any + status_publishers: Dict[str, PropertyPublisher] + actions: Dict[str, ActionServer] + hardware_interface: Dict[str, Any] + base_node_instance: BaseROS2DeviceNode diff --git a/unilabos/ros/nodes/presets/__init__.py b/unilabos/ros/nodes/presets/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/ros/nodes/presets/controller_node.py b/unilabos/ros/nodes/presets/controller_node.py new file mode 100644 index 00000000..84510737 --- /dev/null +++ b/unilabos/ros/nodes/presets/controller_node.py @@ -0,0 +1,122 @@ +from typing import Callable, Dict +from std_msgs.msg import Float64 + +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, DeviceNodeResourceTracker + + +class ControllerNode(BaseROS2DeviceNode): + namespace_prefix = "/controllers" + + def __init__( + self, + device_id: str, + controller_func: Callable, + update_rate: float, + inputs: Dict[str, Dict[str, type | str]], + outputs: Dict[str, Dict[str, type]], + parameters: Dict, + resource_tracker: DeviceNodeResourceTracker, + ): + """ + 通用控制器节点 + + :param controller_id: 控制器的唯一标识符(作为命名空间的一部分) + :param update_rate: 控制器更新频率 (Hz) + :param controller_func: 控制器函数,接收 Python 格式的 inputs 和 parameters 返回 outputs + :param input_types: 输入话题及其消息类型的字典 + :param output_types: 输出话题及其消息类型的字典 + :param parameters: 控制器函数的额外参数 + """ + # 先准备所需的属性,以便在调用父类初始化前就可以使用 + self.device_id = device_id + self.controller_func = controller_func + self.update_rate = update_rate + self.update_time = 1.0 / update_rate + self.parameters = parameters + self.inputs = {topic: None for topic in inputs.keys()} + self.control_input_subscribers = {} + self.control_output_publishers = {} + self.topic_mapping = { + **{input_info["topic"]: input for input, input_info in inputs.items()}, + **{output_info["topic"]: output for output, output_info in outputs.items()}, + } + + # 调用BaseROS2DeviceNode初始化,使用自身作为driver_instance + status_types = {} + action_value_mappings = {} + hardware_interface = {} + + # 使用短ID作为节点名,完整ID(带namespace_prefix)作为device_id + BaseROS2DeviceNode.__init__( + self, + driver_instance=self, + device_id=device_id, + status_types=status_types, + action_value_mappings=action_value_mappings, + hardware_interface=hardware_interface, + print_publish=False, + resource_tracker=resource_tracker + ) + + # 原始初始化逻辑 + # 初始化订阅者 + for input, input_info in inputs.items(): + msg_type = input_info["type"] + topic = str(input_info["topic"]) + self.control_input_subscribers[input] = self.create_subscription( + msg_type, topic, lambda msg, t=topic: self.input_callback(t, msg), 10 + ) + + # 初始化发布者 + for output, output_info in outputs.items(): + self.lab_logger().info(f"Creating publisher for {output} {output_info}") + msg_type = output_info["type"] + topic = str(output_info["topic"]) + self.control_output_publishers[output] = self.create_publisher(msg_type, topic, 10) + + # 定时器,用于定期调用控制逻辑 + self.timer = self.create_timer(self.update_time, self.control_loop) + + def input_callback(self, topic: str, msg): + """ + 更新指定话题的输入数据,并将 ROS 消息转换为普通 Python 数据。 + 支持 `std_msgs` 类型消息。 + """ + self.inputs[self.topic_mapping[topic]] = msg.data + self.lab_logger().info(f"Received input on topic {topic}: {msg.data}") + + def control_loop(self): + """主控制逻辑""" + # 检查所有输入是否已更新 + if all(value is not None for value in self.inputs.values()): + self.lab_logger().info( + f"Calling controller function with inputs: {self.inputs}, parameters: {self.parameters}" + ) + try: + # 调用控制器函数,传入 Python 格式的数据 + outputs = self.controller_func(**self.inputs, **self.parameters) + self.lab_logger().info(f"Inputs: {self.inputs}, Outputs: {outputs}") + self.inputs = {topic: None for topic in self.inputs.keys()} + except Exception as e: + self.lab_logger().error(f"Controller function execution failed: {e}") + return + + # 发布控制信号,将普通 Python 数据转换为 ROS 消息 + if isinstance(outputs, dict): + for topic, value in outputs.items(): + if topic in self.control_output_publishers: + # 支持 Float64 输出 + if isinstance(value, (float, int)): + self.control_output_publishers[topic].publish(Float64(data=value)) + else: + self.lab_logger().error(f"Unsupported output type for topic {topic}: {type(value)}") + else: + self.lab_logger().warning(f"Output topic {topic} is not defined in output_types.") + else: + publisher = list(self.control_output_publishers.values())[0] + if isinstance(outputs, (float, int)): + publisher.publish(Float64(data=outputs)) + else: + self.lab_logger().error(f"Unsupported output type: {type(outputs)}") + else: + self.lab_logger().info("Waiting for all inputs to be received.") diff --git a/unilabos/ros/nodes/presets/host_node.py b/unilabos/ros/nodes/presets/host_node.py new file mode 100644 index 00000000..d5280a42 --- /dev/null +++ b/unilabos/ros/nodes/presets/host_node.py @@ -0,0 +1,623 @@ +import copy +import threading +import time +import uuid +from typing import Optional, Dict, Any, List, ClassVar, Set + +from action_msgs.msg import GoalStatus +from unilabos_msgs.msg import Resource # type: ignore +from unilabos_msgs.srv import ResourceAdd, ResourceGet, ResourceDelete, ResourceUpdate, ResourceList # type: ignore +from rclpy.action import ActionClient, get_action_server_names_and_types_by_node +from rclpy.callback_groups import ReentrantCallbackGroup +from rclpy.service import Service +from unique_identifier_msgs.msg import UUID + +from unilabos.resources.registry import add_schema +from unilabos.ros.initialize_device import initialize_device_from_dict +from unilabos.ros.msgs.message_converter import ( + get_msg_type, + get_ros_type_by_msgname, + convert_from_ros_msg, + convert_to_ros_msg, + msg_converter_manager, ros_action_to_json_schema, +) +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode, DeviceNodeResourceTracker +from unilabos.ros.nodes.presets.controller_node import ControllerNode + + +class HostNode(BaseROS2DeviceNode): + """ + 主机节点类,负责管理设备、资源和控制器 + + 作为单例模式实现,确保整个应用中只有一个主机节点实例 + """ + + _instance: ClassVar[Optional["HostNode"]] = None + _ready_event: ClassVar[threading.Event] = threading.Event() + + @classmethod + def get_instance(cls, timeout=None) -> Optional["HostNode"]: + if cls._ready_event.wait(timeout): + return cls._instance + return None + + def __init__( + self, + device_id: str, + devices_config: Dict[str, Any], + resources_config: Any, + physical_setup_graph: Optional[Dict[str, Any]] = None, + controllers_config: Optional[Dict[str, Any]] = None, + bridges: Optional[List[Any]] = None, + discovery_interval: float = 180.0, # 设备发现间隔,单位为秒 + ): + """ + 初始化主机节点 + + Args: + device_id: 节点名称 + devices_config: 设备配置 + resources_config: 资源配置 + physical_setup_graph: 物理设置图 + controllers_config: 控制器配置 + bridges: 桥接器列表 + discovery_interval: 设备发现间隔(秒),默认5秒 + """ + if self._instance is not None: + self._instance.lab_logger().critical("[Host Node] HostNode instance already exists.") + # 初始化Node基类,传递空参数覆盖列表 + BaseROS2DeviceNode.__init__( + self, + driver_instance=self, + device_id=device_id, + status_types={}, + action_value_mappings={}, + hardware_interface={}, + print_publish=False, + resource_tracker=DeviceNodeResourceTracker(), # host node并不是通过initialize 包一层传进来的 + ) + + # 设置单例实例 + self.__class__._instance = self + + # 初始化配置 + self.devices_config = devices_config + self.resources_config = resources_config + self.physical_setup_graph = physical_setup_graph + if controllers_config is None: + controllers_config = {} + self.controllers_config = controllers_config + if bridges is None: + bridges = [] + self.bridges = bridges + + # 创建设备、动作客户端和目标存储 + self.devices_names: Dict[str, str] = {} # 存储设备名称和命名空间的映射 + self.devices_instances: Dict[str, ROS2DeviceNode] = {} # 存储设备实例 + self._action_clients: Dict[str, ActionClient] = {} # 用来存储多个ActionClient实例 + self._action_value_mappings: Dict[str, Dict] = ( + {} + ) # 用来存储多个ActionClient的type, goal, feedback, result的变量名映射关系 + self._goals: Dict[str, Any] = {} # 用来存储多个目标的状态 + self._online_devices: Set[str] = set() # 用于跟踪在线设备 + self._last_discovery_time = 0.0 # 上次设备发现的时间 + self._discovery_lock = threading.Lock() # 设备发现的互斥锁 + self._subscribed_topics = set() # 用于跟踪已订阅的话题 + + # 创建物料增删改查服务(非客户端) + self._init_resource_service() + + self.device_status = {} # 用来存储设备状态 + self.device_status_timestamps = {} # 用来存储设备状态最后更新时间 + + # 首次发现网络中的设备 + self._discover_devices() + + # 初始化所有本机设备节点,多一次过滤,防止重复初始化 + for device_id, device_config in devices_config.items(): + if device_config.get("type", "device") != "device": + self.lab_logger().debug(f"[Host Node] Skipping type {device_config['type']} {device_id} already existed, skipping.") + continue + if device_id not in self.devices_names: + self.initialize_device(device_id, device_config) + else: + self.lab_logger().warning(f"[Host Node] Device {device_id} already existed, skipping.") + self.update_device_status_subscriptions() + # TODO: 需要验证 初始化所有控制器节点 + if controllers_config: + update_rate = controllers_config["controller_manager"]["ros__parameters"]["update_rate"] + for controller_id, controller_config in controllers_config["controller_manager"]["ros__parameters"][ + "controllers" + ].items(): + controller_config["update_rate"] = update_rate + self.initialize_controller(controller_id, controller_config) + + for bridge in self.bridges: + if hasattr(bridge, "resource_add"): + self.lab_logger().info("[Host Node-Resource] Adding resources to bridge.") + bridge.resource_add(add_schema(resources_config)) + + # 创建定时器,定期发现设备 + self._discovery_timer = self.create_timer( + discovery_interval, self._discovery_devices_callback, callback_group=ReentrantCallbackGroup() + ) + + self.lab_logger().info("[Host Node] Host node initialized.") + HostNode._ready_event.set() + + def _discover_devices(self) -> None: + """ + 发现网络中的设备 + + 检测ROS2网络中的所有设备节点,并为它们创建ActionClient + 同时检测设备离线情况 + """ + self.lab_logger().debug("[Host Node] Discovering devices in the network...") + + # 获取当前所有设备 + nodes_and_names = self.get_node_names_and_namespaces() + + # 跟踪本次发现的设备,用于检测离线设备 + current_devices = set() + + for device_id, namespace in nodes_and_names: + if not namespace.startswith("/devices"): + continue + + # 将设备添加到当前设备集合 + device_key = f"{namespace}/{device_id}" + current_devices.add(device_key) + + # 如果是新设备,记录并创建ActionClient + if device_id not in self.devices_names: + self.lab_logger().info(f"[Host Node] Discovered new device: {device_key}") + self.devices_names[device_id] = namespace + self._create_action_clients_for_device(device_id, namespace) + self._online_devices.add(device_key) + elif device_key not in self._online_devices: + # 设备重新上线 + self.lab_logger().info(f"[Host Node] Device reconnected: {device_key}") + self._online_devices.add(device_key) + + # 检测离线设备 + offline_devices = self._online_devices - current_devices + for device_key in offline_devices: + self.lab_logger().warning(f"[Host Node] Device offline: {device_key}") + self._online_devices.discard(device_key) + + # 更新在线设备列表 + self._online_devices = current_devices + self.lab_logger().debug(f"[Host Node] Total online devices: {len(self._online_devices)}") + + def _discovery_devices_callback(self) -> None: + """ + 设备发现定时器回调函数 + """ + # 使用互斥锁确保同时只有一个发现过程 + if self._discovery_lock.acquire(blocking=False): + try: + self._discover_devices() + # 发现新设备后,更新设备状态订阅 + self.update_device_status_subscriptions() + finally: + self._discovery_lock.release() + else: + self.lab_logger().debug("[Host Node] Device discovery already in progress, skipping.") + + def _create_action_clients_for_device(self, device_id: str, namespace: str) -> None: + """ + 为设备创建所有必要的ActionClient + + Args: + device_id: 设备ID + namespace: 设备命名空间 + """ + for action_id, action_types in get_action_server_names_and_types_by_node(self, device_id, namespace): + if action_id not in self._action_clients: + try: + action_type = get_ros_type_by_msgname(action_types[0]) + self._action_clients[action_id] = ActionClient( + self, action_type, action_id, callback_group=self.callback_group + ) + self.lab_logger().debug(f"[Host Node] Created ActionClient: {action_id}") + from unilabos.app.mq import mqtt_client + info_with_schema = ros_action_to_json_schema(action_type) + mqtt_client.publish_actions(action_id, info_with_schema) + except Exception as e: + self.lab_logger().error(f"[Host Node] Failed to create ActionClient for {action_id}: {str(e)}") + + def initialize_device(self, device_id: str, device_config: Dict[str, Any]) -> None: + """ + 根据配置初始化设备 + + 此函数根据提供的设备配置动态导入适当的设备类并创建其实例。 + 同时为设备的动作值映射设置动作客户端。 + + Args: + device_id: 设备唯一标识符 + device_config: 设备配置字典,包含类型和其他参数 + """ + self.lab_logger().info(f"[Host Node] Initializing device: {device_id}") + + device_config_copy = copy.deepcopy(device_config) + d = initialize_device_from_dict(device_id, device_config_copy) + if d is None: + return + # noinspection PyProtectedMember + self.devices_names[device_id] = d._ros_node.namespace + self.devices_instances[device_id] = d + # noinspection PyProtectedMember + for action_name, action_value_mapping in d._ros_node._action_value_mappings.items(): + action_id = f"/devices/{device_id}/{action_name}" + if action_id not in self._action_clients: + action_type = action_value_mapping["type"] + self._action_clients[action_id] = ActionClient(self, action_type, action_id) + self.lab_logger().debug(f"[Host Node] Created ActionClient: {action_id}") + from unilabos.app.mq import mqtt_client + info_with_schema = ros_action_to_json_schema(action_type) + mqtt_client.publish_actions(action_id, info_with_schema) + else: + self.lab_logger().warning(f"[Host Node] ActionClient {action_id} already exists.") + device_key = f"{self.devices_names[device_id]}/{device_id}" + # 添加到在线设备列表 + self._online_devices.add(device_key) + + def update_device_status_subscriptions(self) -> None: + """ + 更新设备状态订阅 + + 扫描所有设备话题,为新的话题创建订阅,确保不会重复订阅 + """ + topic_names_and_types = self.get_topic_names_and_types() + for topic, types in topic_names_and_types: + # 检查是否为设备状态话题且未订阅过 + if ( + topic.startswith("/devices/") + and not types[0].endswith("FeedbackMessage") + and "_action" not in topic + and topic not in self._subscribed_topics + ): + + # 解析设备名和属性名 + parts = topic.split("/") + if len(parts) >= 4: + device_id = parts[-2] + property_name = parts[-1] + + # 初始化设备状态字典 + if device_id not in self.device_status: + self.device_status[device_id] = {} + self.device_status_timestamps[device_id] = {} + + # 默认初始化属性值为 None + self.device_status[device_id][property_name] = None + self.device_status_timestamps[device_id][property_name] = 0 # 初始化时间戳 + + # 动态创建订阅 + try: + type_class = msg_converter_manager.search_class(types[0].replace("/", ".")) + if type_class is None: + self.lab_logger().error(f"[Host Node] Invalid type {types[0]} for {topic}") + else: + self.create_subscription( + type_class, + topic, + lambda msg, d=device_id, p=property_name: self.property_callback(msg, d, p), + 1, + callback_group=ReentrantCallbackGroup(), + ) + # 标记为已订阅 + self._subscribed_topics.add(topic) + self.lab_logger().debug(f"[Host Node] Subscribed to new topic: {topic}") + except (NameError, SyntaxError) as e: + self.lab_logger().error(f"[Host Node] Failed to create subscription for topic {topic}: {e}") + + """设备相关""" + + def property_callback(self, msg, device_id: str, property_name: str) -> None: + """ + 更新设备状态字典中的属性值,并发送到桥接器。 + + Args: + msg: 接收到的消息 + device_id: 设备ID + property_name: 属性名称 + """ + # 更新设备状态字典 + if hasattr(msg, "data"): + bChange = False + if isinstance(msg.data, (float, int, str)): + if self.device_status[device_id][property_name] != msg.data: + bChange = True + self.device_status[device_id][property_name] = msg.data + # 更新时间戳 + self.device_status_timestamps[device_id][property_name] = time.time() + else: + self.lab_logger().debug( + f"[Host Node] Unsupported data type for {device_id}/{property_name}: {type(msg.data)}" + ) + + # 所有 Bridge 对象都应具有 publish_device_status 方法;都会收到设备状态更新 + if bChange: + for bridge in self.bridges: + if hasattr(bridge, "publish_device_status"): + bridge.publish_device_status(self.device_status, device_id, property_name) + self.lab_logger().debug( + f"[Host Node] Status updated: {device_id}.{property_name} = {msg.data}" + ) + + def send_goal( + self, device_id: str, action_name: str, action_kwargs: Dict[str, Any], goal_uuid: Optional[str] = None + ) -> None: + """ + 向设备发送目标请求 + + Args: + device_id: 设备ID + action_name: 动作名称 + action_kwargs: 动作参数 + goal_uuid: 目标UUID,如果为None则自动生成 + """ + action_id = f"/devices/{device_id}/{action_name}" + if action_id not in self._action_clients: + self.lab_logger().error(f"[Host Node] ActionClient {action_id} not found.") + return + + action_client: ActionClient = self._action_clients[action_id] + + goal_msg = convert_to_ros_msg(action_client._action_type.Goal(), action_kwargs) + + self.lab_logger().info(f"[Host Node] Sending goal for {action_id}: {goal_msg}") + action_client.wait_for_server() + + uuid_str = goal_uuid + if goal_uuid is not None: + u = uuid.UUID(goal_uuid) + goal_uuid_obj = UUID(uuid=list(u.bytes)) + else: + goal_uuid_obj = None + + future = action_client.send_goal_async( + goal_msg, + feedback_callback=lambda feedback_msg: self.feedback_callback(action_id, uuid_str, feedback_msg), + goal_uuid=goal_uuid_obj, + ) + future.add_done_callback(lambda future: self.goal_response_callback(action_id, uuid_str, future)) + + def goal_response_callback(self, action_id: str, uuid_str: Optional[str], future) -> None: + """目标响应回调""" + goal_handle = future.result() + if not goal_handle.accepted: + self.lab_logger().warning(f"[Host Node] Goal {action_id} ({uuid_str}) rejected") + return + + self.lab_logger().info(f"[Host Node] Goal {action_id} ({uuid_str}) accepted") + if uuid_str: + self._goals[uuid_str] = goal_handle + goal_handle.get_result_async().add_done_callback( + lambda future: self.get_result_callback(action_id, uuid_str, future) + ) + + def feedback_callback(self, action_id: str, uuid_str: Optional[str], feedback_msg) -> None: + """反馈回调""" + feedback_data = convert_from_ros_msg(feedback_msg) + feedback_data.pop("goal_id") + self.lab_logger().debug(f"[Host Node] Feedback for {action_id} ({uuid_str}): {feedback_data}") + + if uuid_str: + for bridge in self.bridges: + if hasattr(bridge, "publish_job_status"): + bridge.publish_job_status(feedback_data, uuid_str, "running") + + def get_result_callback(self, action_id: str, uuid_str: Optional[str], future) -> None: + """获取结果回调""" + result_msg = future.result().result + result_data = convert_from_ros_msg(result_msg) + self.lab_logger().info(f"[Host Node] Result for {action_id} ({uuid_str}): success") + self.lab_logger().debug(f"[Host Node] Result data: {result_data}") + + if uuid_str: + for bridge in self.bridges: + if hasattr(bridge, "publish_job_status"): + bridge.publish_job_status(result_data, uuid_str, "success") + + def cancel_goal(self, goal_uuid: str) -> None: + """取消目标""" + if goal_uuid in self._goals: + self.lab_logger().info(f"[Host Node] Cancelling goal {goal_uuid}") + self._goals[goal_uuid].cancel_goal_async() + else: + self.lab_logger().warning(f"[Host Node] Goal {goal_uuid} not found, cannot cancel") + + def get_goal_status(self, uuid_str: str) -> int: + """获取目标状态""" + if uuid_str in self._goals: + g = self._goals[uuid_str] + status = g.status + self.lab_logger().debug(f"[Host Node] Goal status for {uuid_str}: {status}") + return status + self.lab_logger().warning(f"[Host Node] Goal {uuid_str} not found, status unknown") + return GoalStatus.STATUS_UNKNOWN + + """Controller Node""" + + def initialize_controller(self, controller_id: str, controller_config: Dict[str, Any]) -> None: + """ + 初始化控制器 + + Args: + controller_id: 控制器ID + controller_config: 控制器配置 + """ + self.lab_logger().info(f"[Host Node] Initializing controller: {controller_id}") + + class_name = controller_config.pop("type") + controller_func = globals()[class_name] + + for input_name, input_info in controller_config["inputs"].items(): + controller_config["inputs"][input_name]["type"] = get_msg_type(eval(input_info["type"])) + for output_name, output_info in controller_config["outputs"].items(): + controller_config["outputs"][output_name]["type"] = get_msg_type(eval(output_info["type"])) + + if controller_config["parameters"] is None: + controller_config["parameters"] = {} + + controller = ControllerNode(controller_id, controller_func=controller_func, **controller_config) + self.lab_logger().info(f"[Host Node] Controller {controller_id} created.") + # rclpy.get_global_executor().add_node(controller) + + """Resource""" + + def _init_resource_service(self): + self._resource_services: Dict[str, Service] = { + "resource_add": self.create_service( + ResourceAdd, "/resources/add", self._resource_add_callback, callback_group=ReentrantCallbackGroup() + ), + "resource_get": self.create_service( + ResourceGet, "/resources/get", self._resource_get_callback, callback_group=ReentrantCallbackGroup() + ), + "resource_delete": self.create_service( + ResourceDelete, + "/resources/delete", + self._resource_delete_callback, + callback_group=ReentrantCallbackGroup(), + ), + "resource_update": self.create_service( + ResourceUpdate, + "/resources/update", + self._resource_update_callback, + callback_group=ReentrantCallbackGroup(), + ), + "resource_list": self.create_service( + ResourceList, "/resources/list", self._resource_list_callback, callback_group=ReentrantCallbackGroup() + ), + } + + def _resource_add_callback(self, request, response): + """ + 添加资源回调 + + 处理添加资源请求,将资源数据传递到桥接器 + + Args: + request: 包含资源数据的请求对象 + response: 响应对象 + + Returns: + 响应对象,包含操作结果 + """ + resources = [convert_from_ros_msg(resource) for resource in request.resources] + self.lab_logger().info(f"[Host Node-Resource] Add request received: {len(resources)} resources") + + success = False + if len(self.bridges) > 0: + r = self.bridges[-1].resource_add(add_schema(resources)) + success = bool(r) + + response.success = success + self.lab_logger().info(f"[Host Node-Resource] Add request completed, success: {success}") + return response + + def _resource_get_callback(self, request, response): + """ + 获取资源回调 + + 处理获取资源请求,从桥接器或本地查询资源数据 + + Args: + request: 包含资源ID的请求对象 + response: 响应对象 + + Returns: + 响应对象,包含查询到的资源 + """ + self.lab_logger().info(f"[Host Node-Resource] Get request for ID: {request.id}") + + if len(self.bridges) > 0: + # 云上物料服务,根据 id 查询物料 + try: + r = self.bridges[-1].resource_get(request.id, request.with_children)["data"] + self.lab_logger().debug(f"[Host Node-Resource] Retrieved from bridge: {len(r)} resources") + except Exception as e: + self.lab_logger().error(f"[Host Node-Resource] Error retrieving from bridge: {str(e)}") + r = [] + else: + # 本地物料服务,根据 id 查询物料 + r = [resource for resource in self.resources_config if resource.get("id") == request.id] + self.lab_logger().debug(f"[Host Node-Resource] Retrieved from local: {len(r)} resources") + + response.resources = [convert_to_ros_msg(Resource, resource) for resource in r] + return response + + def _resource_delete_callback(self, request, response): + """ + 删除资源回调 + + 处理删除资源请求,将删除指令传递到桥接器 + + Args: + request: 包含资源ID的请求对象 + response: 响应对象 + + Returns: + 响应对象,包含操作结果 + """ + self.lab_logger().info(f"[Host Node-Resource] Delete request for ID: {request.id}") + + success = False + if len(self.bridges) > 0: + try: + r = self.bridges[-1].resource_delete(request.id) + success = bool(r) + except Exception as e: + self.lab_logger().error(f"[Host Node-Resource] Error deleting resource: {str(e)}") + + response.success = success + self.lab_logger().info(f"[Host Node-Resource] Delete request completed, success: {success}") + return response + + def _resource_update_callback(self, request, response): + """ + 更新资源回调 + + 处理更新资源请求,将更新指令传递到桥接器 + + Args: + request: 包含资源数据的请求对象 + response: 响应对象 + + Returns: + 响应对象,包含操作结果 + """ + resources = [convert_from_ros_msg(resource) for resource in request.resources] + self.lab_logger().info(f"[Host Node-Resource] Update request received: {len(resources)} resources") + + success = False + if len(self.bridges) > 0: + try: + r = self.bridges[-1].resource_update(add_schema(resources)) + success = bool(r) + except Exception as e: + self.lab_logger().error(f"[Host Node-Resource] Error updating resources: {str(e)}") + + response.success = success + self.lab_logger().info(f"[Host Node-Resource] Update request completed, success: {success}") + return response + + def _resource_list_callback(self, request, response): + """ + 列出资源回调 + + 处理列出资源请求,返回所有可用资源 + + Args: + request: 请求对象 + response: 响应对象 + + Returns: + 响应对象,包含资源列表 + """ + self.lab_logger().info(f"[Host Node-Resource] List request received") + # 这里可以实现返回资源列表的逻辑 + self.lab_logger().debug(f"[Host Node-Resource] List parameters: {request}") + return response diff --git a/unilabos/ros/nodes/presets/protocol_node.py b/unilabos/ros/nodes/presets/protocol_node.py new file mode 100644 index 00000000..323f6880 --- /dev/null +++ b/unilabos/ros/nodes/presets/protocol_node.py @@ -0,0 +1,267 @@ +import time +import asyncio +import traceback +from typing import Union + +import rclpy +from unilabos.messages import * # type: ignore # protocol names +from rclpy.action import ActionServer, ActionClient +from rclpy.action.server import ServerGoalHandle +from rclpy.callback_groups import ReentrantCallbackGroup +from unilabos_msgs.msg import Resource # type: ignore +from unilabos_msgs.srv import ResourceGet, ResourceUpdate # type: ignore + +from unilabos.compile import action_protocol_generators +from unilabos.resources.graphio import list_to_nested_dict, nested_dict_to_list +from unilabos.ros.initialize_device import initialize_device_from_dict +from unilabos.ros.msgs.message_converter import ( + get_action_type, + convert_to_ros_msg, + convert_from_ros_msg, + convert_from_ros_msg_with_mapping, +) +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, DeviceNodeResourceTracker + + +class ROS2ProtocolNode(BaseROS2DeviceNode): + """ + ROS2ProtocolNode代表管理ROS2环境中设备通信和动作的协议节点。 + 它初始化设备节点,处理动作客户端,并基于指定的协议执行工作流。 + 它还物理上代表一组协同工作的设备,如带夹持器的机械臂,带传送带的CNC机器等。 + """ + + # create_action_server = False # Action Server要自己创建 + + def __init__(self, device_id: str, children: dict, protocol_type: Union[str, list[str]], resource_tracker: DeviceNodeResourceTracker, *args, **kwargs): + self._setup_protocol_names(protocol_type) + + # 初始化其它属性 + self.children = children + self._busy = False + self.sub_devices = {} + self._goals = {} + self._protocol_servers = {} + self._action_clients = {} + + # 初始化基类,让基类处理常规动作 + super().__init__( + driver_instance=self, + device_id=device_id, + status_types={}, + action_value_mappings=self.protocol_action_mappings, + hardware_interface={}, + print_publish=False, + resource_tracker=resource_tracker, + ) + + # 初始化子设备 + communication_node_id = None + for device_id, device_config in self.children.items(): + if device_config.get("type", "device") != "device": + self.lab_logger().debug(f"[Protocol Node] Skipping type {device_config['type']} {device_id} already existed, skipping.") + continue + d = self.initialize_device(device_id, device_config) + if d is None: + continue + + if "serial_" in device_id or "io_" in device_id: + communication_node_id = device_id + continue + + # 设置硬件接口代理 + if d and hasattr(d, "_hardware_interface"): + if ( + hasattr(d, d._hardware_interface["name"]) + and hasattr(d, d._hardware_interface["write"]) + and (d._hardware_interface["read"] is None or hasattr(d, d._hardware_interface["read"])) + ): + + name = getattr(d, d._hardware_interface["name"]) + read = d._hardware_interface.get("read", None) + write = d._hardware_interface.get("write", None) + + # 如果硬件接口是字符串,通过通信设备提供 + if isinstance(name, str) and communication_node_id in self.sub_devices: + self._setup_hardware_proxy(d, self.sub_devices[communication_node_id], read, write) + + def _setup_protocol_names(self, protocol_type): + # 处理协议类型 + if isinstance(protocol_type, str): + if "," not in protocol_type: + self.protocol_names = [protocol_type] + else: + self.protocol_names = [protocol.strip() for protocol in protocol_type.split(",")] + else: + self.protocol_names = protocol_type + # 准备协议相关的动作值映射 + self.protocol_action_mappings = {} + for protocol_name in self.protocol_names: + protocol_type = globals()[protocol_name] + self.protocol_action_mappings[protocol_name] = get_action_type(protocol_type) + + def initialize_device(self, device_id, device_config): + """初始化设备并创建相应的动作客户端""" + device_id_abs = f"{self.device_id}/{device_id}" + self.lab_logger().info(f"初始化子设备: {device_id_abs}") + d = self.sub_devices[device_id] = initialize_device_from_dict(device_id_abs, device_config) + + # 为子设备的每个动作创建动作客户端 + if d is not None and hasattr(d, "ros_node_instance"): + node = d.ros_node_instance + for action_name, action_mapping in node._action_value_mappings.items(): + action_id = f"/devices/{device_id_abs}/{action_name}" + if action_id not in self._action_clients: + self._action_clients[action_id] = ActionClient( + self, action_mapping["type"], action_id, callback_group=self.callback_group + ) + self.lab_logger().debug(f"为子设备 {device_id} 创建动作客户端: {action_name}") + return d + + def create_ros_action_server(self, action_name, action_value_mapping): + """创建ROS动作服务器""" + # 和Base创建的路径是一致的 + protocol_name = action_name + action_type = action_value_mapping["type"] + str_action_type = str(action_type)[8:-2] + protocol_type = globals()[protocol_name] + protocol_steps_generator = action_protocol_generators[protocol_type] + + self._action_servers[action_name] = ActionServer( + self, + action_type, + action_name, + execute_callback=self._create_protocol_execute_callback(action_name, protocol_steps_generator), + callback_group=ReentrantCallbackGroup(), + ) + + self.lab_logger().debug(f"发布动作: {action_name}, 类型: {str_action_type}") + + def _create_protocol_execute_callback(self, protocol_name, protocol_steps_generator): + async def execute_protocol(goal_handle: ServerGoalHandle): + """执行完整的工作流""" + self.get_logger().info(f'Executing {protocol_name} action...') + action_value_mapping = self._action_value_mappings[protocol_name] + print('+'*30) + print(protocol_steps_generator) + # 从目标消息中提取参数, 并调用Protocol生成器(根据设备连接图)生成action步骤 + goal = goal_handle.request + protocol_kwargs = convert_from_ros_msg_with_mapping(goal, action_value_mapping["goal"]) + + # 向Host查询物料当前状态 + for k, v in goal.get_fields_and_field_types().items(): + if v in ["unilabos_msgs/Resource", "sequence"]: + r = ResourceGet.Request() + r.id = protocol_kwargs[k]["id"] if v == "unilabos_msgs/Resource" else protocol_kwargs[k][0]["id"] + r.with_children = True + response = await self._resource_clients["resource_get"].call_async(r) + protocol_kwargs[k] = list_to_nested_dict([convert_from_ros_msg(rs) for rs in response.resources]) + + from unilabos.resources.graphio import physical_setup_graph + self.get_logger().info(f'Working on physical setup: {physical_setup_graph}') + protocol_steps = protocol_steps_generator(G=physical_setup_graph, **protocol_kwargs) + + self.get_logger().info(f'Goal received: {protocol_kwargs}, running steps: \n{protocol_steps}') + + time_start = time.time() + time_overall = 100 + self._busy = True + + # 逐步执行工作流 + for i, action in enumerate(protocol_steps): + self.get_logger().info(f'Running step {i+1}: {action}') + if type(action) == dict: + # 如果是单个动作,直接执行 + if action["action_name"] == "wait": + time.sleep(action["action_kwargs"]["time"]) + else: + result = await self.execute_single_action(**action) + elif type(action) == list: + # 如果是并行动作,同时执行 + actions = action + futures = [rclpy.get_global_executor().create_task(self.execute_single_action(**a)) for a in actions] + results = [await f for f in futures] + + # 向Host更新物料当前状态 + for k, v in goal.get_fields_and_field_types().items(): + if v in ["unilabos_msgs/Resource", "sequence"]: + r = ResourceUpdate.Request() + r.resources = [ + convert_to_ros_msg(Resource, rs) for rs in nested_dict_to_list(protocol_kwargs[k]) + ] + response = await self._resource_clients["resource_update"].call_async(r) + + goal_handle.succeed() + result = action_value_mapping["type"].Result() + result.success = True + + self._busy = False + return result + return execute_protocol + + async def execute_single_action(self, device_id, action_name, action_kwargs): + """执行单个动作""" + # 构建动作ID + if device_id in ["", None, "self"]: + action_id = f"/devices/{self.device_id}/{action_name}" + else: + action_id = f"/devices/{self.device_id}/{device_id}/{action_name}" + + # 检查动作客户端是否存在 + if action_id not in self._action_clients: + self.lab_logger().error(f"找不到动作客户端: {action_id}") + return None + + # 发送动作请求 + action_client = self._action_clients[action_id] + goal_msg = convert_to_ros_msg(action_client._action_type.Goal(), action_kwargs) + + self.lab_logger().info(f"发送动作请求到: {action_id}") + action_client.wait_for_server() + + # 等待动作完成 + request_future = action_client.send_goal_async(goal_msg) + handle = await request_future + + if not handle.accepted: + self.lab_logger().error(f"动作请求被拒绝: {action_name}") + return None + + result_future = await handle.get_result_async() + self.lab_logger().info(f"动作完成: {action_name}") + + return result_future.result + + + """还没有改过的部分""" + + def _setup_hardware_proxy(self, device, communication_device, read_method, write_method): + """为设备设置硬件接口代理""" + extra_info = [getattr(device, info) for info in communication_device._hardware_interface.get("extra_info", [])] + write_func = getattr(communication_device, communication_device._hardware_interface["write"]) + read_func = getattr(communication_device, communication_device._hardware_interface["read"]) + + def _read(): + return read_func(*extra_info) + + def _write(command): + return write_func(*extra_info, command) + + if read_method: + setattr(device, read_method, _read) + if write_method: + setattr(device, write_method, _write) + + + async def _update_resources(self, goal, protocol_kwargs): + """更新资源状态""" + for k, v in goal.get_fields_and_field_types().items(): + if v in ["unilabos_msgs/Resource", "sequence"]: + if protocol_kwargs[k] is not None: + try: + r = ResourceUpdate.Request() + r.resources = [ + convert_to_ros_msg(Resource, rs) for rs in nested_dict_to_list(protocol_kwargs[k]) + ] + await self._resource_clients["resource_update"].call_async(r) + except Exception as e: + self.lab_logger().error(f"更新资源失败: {e}") diff --git a/unilabos/ros/nodes/presets/serial_node.py b/unilabos/ros/nodes/presets/serial_node.py new file mode 100644 index 00000000..6f01a07a --- /dev/null +++ b/unilabos/ros/nodes/presets/serial_node.py @@ -0,0 +1,84 @@ +from threading import Lock + +from unilabos_msgs.srv import SerialCommand +from serial import Serial, SerialException + +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, DeviceNodeResourceTracker + + +class ROS2SerialNode(BaseROS2DeviceNode): + def __init__(self, device_id, port: str, baudrate: int = 9600, resource_tracker: DeviceNodeResourceTracker=None): + # 保存属性,以便在调用父类初始化前使用 + self.port = port + self.baudrate = baudrate + self._hardware_interface = {"name": "hardware_interface", "write": "send_command", "read": "read_data"} + self._busy = False + self._closing = False + self._query_lock = Lock() + + # 初始化硬件接口 + try: + self.hardware_interface = Serial(baudrate=baudrate, port=port) + except (OSError, SerialException) as e: + # 因为还没调用父类初始化,无法使用日志,直接抛出异常 + raise RuntimeError(f"Failed to connect to serial port {port} at {baudrate} baudrate.") from e + + # 初始化BaseROS2DeviceNode,使用自身作为driver_instance + BaseROS2DeviceNode.__init__( + self, + driver_instance=self, + device_id=device_id, + status_types={}, + action_value_mappings={}, + hardware_interface=self._hardware_interface, + print_publish=False, + resource_tracker=resource_tracker, + ) + + # 现在可以使用日志 + self.lab_logger().info( + f"【ROS2SerialNode.__init__】初始化串口节点: {device_id}, 端口: {port}, 波特率: {baudrate}" + ) + self.lab_logger().info(f"【ROS2SerialNode.__init__】成功连接串口设备") + + # 创建服务 + self.create_service(SerialCommand, "serialwrite", self.handle_serial_request) + self.lab_logger().info(f"【ROS2SerialNode.__init__】创建串口写入服务: serialwrite") + + def send_command(self, command: str): + self.lab_logger().info(f"【ROS2SerialNode.send_command】发送命令: {command}") + with self._query_lock: + if self._closing: + self.lab_logger().error(f"【ROS2SerialNode.send_command】设备正在关闭,无法发送命令") + raise RuntimeError + + full_command = f"{command}\n" + full_command_data = bytearray(full_command, "ascii") + + response = self.hardware_interface.write(full_command_data) + # time.sleep(0.05) + output = self._receive(self.hardware_interface.read_until(b"\n")) + self.lab_logger().info(f"【ROS2SerialNode.send_command】接收响应: {output}") + return output + + def read_data(self): + self.lab_logger().debug(f"【ROS2SerialNode.read_data】读取数据") + with self._query_lock: + if self._closing: + self.lab_logger().error(f"【ROS2SerialNode.read_data】设备正在关闭,无法读取数据") + raise RuntimeError + data = self.hardware_interface.read_until(b"\n") + result = self._receive(data) + self.lab_logger().debug(f"【ROS2SerialNode.read_data】读取到数据: {result}") + return result + + def _receive(self, data: bytes): + ascii_string = "".join(chr(byte) for byte in data) + self.lab_logger().debug(f"【ROS2SerialNode._receive】接收数据: {ascii_string}") + return ascii_string + + def handle_serial_request(self, request, response): + self.lab_logger().info(f"【ROS2SerialNode.handle_serial_request】收到串口命令请求: {request.command}") + response.response = self.send_command(request.command) + self.lab_logger().info(f"【ROS2SerialNode.handle_serial_request】命令响应: {response.response}") + return response diff --git a/unilabos/ros/nodes/resource_tracker.py b/unilabos/ros/nodes/resource_tracker.py new file mode 100644 index 00000000..c0886cd4 --- /dev/null +++ b/unilabos/ros/nodes/resource_tracker.py @@ -0,0 +1,67 @@ +from unilabos.utils.log import logger + + +class DeviceNodeResourceTracker: + + def __init__(self): + self.resources = [] + self.root_resource2resource = {} + pass + + def root_resource(self, resource): + if id(resource) in self.root_resource2resource: + return self.root_resource2resource[id(resource)] + else: + return resource + + def add_resource(self, resource): + # 使用内存地址跟踪是否为同一个resource + for r in self.resources: + if id(r) == id(resource): + return + # 添加资源到跟踪器 + self.resources.append(resource) + + def clear_resource(self): + self.resources = [] + + def figure_resource(self, resource): + # 使用内存地址跟踪是否为同一个resource + if isinstance(resource, list): + return [self.figure_resource(r) for r in resource] + res_id = resource.id if hasattr(resource, "id") else None + res_name = resource.name if hasattr(resource, "name") else None + res_identifier = res_id if res_id else res_name + identifier_key = "id" if res_id else "name" + resource_cls_type = type(resource) + if res_identifier is None: + logger.warning(f"resource {resource} 没有id或name,暂不能对应figure") + res_list = [] + for r in self.resources: + res_list.extend( + self.loop_find_resource(r, resource_cls_type, identifier_key, getattr(resource, identifier_key)) + ) + assert len(res_list) == 1, f"找到多个资源,请检查资源是否唯一: {res_list}" + self.root_resource2resource[id(resource)] = res_list[0] + # 后续加入其他对比方式 + return res_list[0] + + def loop_find_resource(self, resource, resource_cls_type, identifier_key, compare_value): + res_list = [] + children = getattr(resource, "children", []) + for child in children: + res_list.extend(self.loop_find_resource(child, resource_cls_type, identifier_key, compare_value)) + if resource_cls_type == type(resource): + if hasattr(resource, identifier_key): + if getattr(resource, identifier_key) == compare_value: + res_list.append(resource) + return res_list + + def filter_find_list(self, res_list, compare_std_dict): + new_list = [] + for res in res_list: + for k, v in compare_std_dict.items(): + if hasattr(res, k): + if getattr(res, k) == v: + new_list.append(res) + return new_list diff --git a/unilabos/ros/scripts/__init__.py b/unilabos/ros/scripts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/ros/scripts/pydantic2rosmsg.py b/unilabos/ros/scripts/pydantic2rosmsg.py new file mode 100644 index 00000000..39847f6c --- /dev/null +++ b/unilabos/ros/scripts/pydantic2rosmsg.py @@ -0,0 +1,55 @@ +import os +import inspect +from pydantic import BaseModel, Field +from typing import get_type_hints + +# 定义你要解析的 pydantic 模型所在的 Python 文件 +MODULES = ['my_pydantic_models'] # 替换为你的 Python 模块名 + +ROS_MSG_DIR = 'msg' # 消息文件生成目录 + + +def map_python_type_to_ros(python_type): + type_map = { + int: 'int32', + float: 'float64', + str: 'string', + bool: 'bool', + list: '[]', # List in Pydantic should be handled separately + } + return type_map.get(python_type, None) + + +def generate_ros_msg_from_pydantic(model): + fields = get_type_hints(model) + ros_msg_lines = [] + + for field_name, field_type in fields.items(): + ros_type = map_python_type_to_ros(field_type) + if not ros_type: + raise TypeError(f"Unsupported type {field_type} for field {field_name}") + + ros_msg_lines.append(f"{ros_type} {field_name}\n") + + return ''.join(ros_msg_lines) + + +def save_ros_msg_file(model_name, ros_msg_definition): + msg_file_path = os.path.join(ROS_MSG_DIR, f'{model_name}.msg') + os.makedirs(ROS_MSG_DIR, exist_ok=True) + with open(msg_file_path, 'w') as msg_file: + msg_file.write(ros_msg_definition) + + +def main(): + for module_name in MODULES: + module = __import__(module_name) + for name, obj in inspect.getmembers(module): + if inspect.isclass(obj) and issubclass(obj, BaseModel) and obj != BaseModel: + print(f"Generating ROS message for Pydantic model: {name}") + ros_msg_definition = generate_ros_msg_from_pydantic(obj) + save_ros_msg_file(name, ros_msg_definition) + + +if __name__ == '__main__': + main() diff --git a/unilabos/ros/utils/__init__.py b/unilabos/ros/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/ros/utils/driver_creator.py b/unilabos/ros/utils/driver_creator.py new file mode 100644 index 00000000..b2402b5e --- /dev/null +++ b/unilabos/ros/utils/driver_creator.py @@ -0,0 +1,268 @@ +""" +设备类实例创建工厂 + +这个模块包含用于创建设备类实例的工厂类。 +基础工厂类提供通用的实例创建方法,而特定工厂类提供针对特定设备类的创建方法。 +""" +import asyncio +import inspect +import traceback +from abc import abstractmethod +from typing import Type, Any, Dict, Optional, TypeVar, Generic + +from unilabos.resources.graphio import nested_dict_to_list, resource_ulab_to_plr +from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker +from unilabos.utils import logger, import_manager +from unilabos.utils.cls_creator import create_instance_from_config + +# 定义泛型类型变量 +T = TypeVar("T") + + +class ClassCreator(Generic[T]): + @abstractmethod + def create_instance(self, *args, **kwargs) -> T: + pass + + +class DeviceClassCreator(Generic[T]): + """ + 设备类实例创建器基类 + + 这个类提供了从任意类创建实例的通用方法。 + """ + + def __init__(self, cls: Type[T]): + """ + 初始化设备类创建器 + + Args: + cls: 要创建实例的类 + """ + self.device_cls = cls + self.device_instance: Optional[T] = None + + def create_instance(self, data: Dict[str, Any]) -> T: + """ + 创建设备类实例 + + Args: + + + Returns: + 设备类的实例 + """ + self.device_instance = create_instance_from_config( + { + "_cls": self.device_cls.__module__ + ":" + self.device_cls.__name__, + "_params": data, + } + ) + self.post_create() + return self.device_instance + + def get_instance(self) -> Optional[T]: + """ + 获取当前实例 + + Returns: + 当前设备类实例,如果尚未创建则返回None + """ + return self.device_instance + + def post_create(self): + pass + + +class PyLabRobotCreator(DeviceClassCreator[T]): + """ + PyLabRobot设备类创建器 + + 这个类提供了针对PyLabRobot设备类的实例创建方法,特别处理deserialize方法。 + """ + + def __init__(self, cls: Type[T], children: Dict[str, Any], resource_tracker: DeviceNodeResourceTracker): + """ + 初始化PyLabRobot设备类创建器 + + Args: + cls: PyLabRobot设备类 + children: 子资源字典,用于资源替换 + """ + super().__init__(cls) + self.children = children + self.resource_tracker = resource_tracker + # 检查类是否具有deserialize方法 + self.has_deserialize = hasattr(cls, "deserialize") and callable(getattr(cls, "deserialize")) + if not self.has_deserialize: + logger.warning(f"类 {cls.__name__} 没有deserialize方法,将使用标准构造函数") + + def _process_resource_mapping(self, resource, source_type): + if source_type == dict: + from pylabrobot.resources.resource import Resource + + return nested_dict_to_list(resource), Resource + return resource, source_type + + def _process_resource_references(self, data: Any, to_dict=False) -> Any: + """ + 递归处理资源引用,替换_resource_child_name对应的资源 + + Args: + data: 需要处理的数据,可能是字典、列表或其他类型 + to_dict: 转换成对应的实例,还是转换成对应的字典 + + Returns: + 处理后的数据 + """ + from pylabrobot.resources import Deck, Resource + + if isinstance(data, dict): + # 检查是否包含资源引用 + if "_resource_child_name" in data: + child_name = data["_resource_child_name"] + if child_name in self.children: + # 找到了对应的资源 + resource = self.children[child_name] + + # 检查是否需要转换资源类型 + if "_resource_type" in data: + type_path = data["_resource_type"] + try: + # 尝试导入指定的类型 + target_type = import_manager.get_class(type_path) + contain_model = not issubclass(target_type, Deck) + resource, target_type = self._process_resource_mapping(resource, target_type) + # 在截图中格式,是deserialize,所以这里要转成plr resource可deserialize的字典 + # 这样后面执行deserialize的时候能够正确反序列化对应的物料 + resource_instance: Resource = resource_ulab_to_plr(resource, contain_model) + if to_dict: + return resource_instance.serialize() + else: + self.resource_tracker.add_resource(resource_instance) + return resource_instance + except Exception as e: + logger.warning(f"无法导入资源类型 {type_path}: {e}") + logger.warning(traceback.format_exc()) + else: + logger.debug(f"找不到资源类型,请补全_resource_type {self.device_cls.__name__} {data.keys()}") + return resource + else: + logger.warning(f"找不到资源引用 '{child_name}',保持原值不变") + + # 递归处理字典的每个值 + result = {} + for key, value in data.items(): + result[key] = self._process_resource_references(value, to_dict) + return result + + # 处理列表类型 + elif isinstance(data, list): + return [self._process_resource_references(item, to_dict) for item in data] + + # 其他类型直接返回 + return data + + def create_instance(self, data: Dict[str, Any]) -> Optional[T]: + """ + 从数据创建PyLabRobot设备实例 + + Args: + data: 用于反序列化的数据 + + Returns: + PyLabRobot设备类实例 + """ + deserialize_error = None + stack = None + if self.has_deserialize: + deserialize_method = getattr(self.device_cls, "deserialize") + spect = inspect.signature(deserialize_method) + spec_args = spect.parameters + for param_name, param_value in data.copy().items(): + if "_resource_child_name" in param_value and "_resource_type" not in param_value: + arg_value = spec_args[param_name].annotation + data[param_name]["_resource_type"] = self.device_cls.__module__ + ":" + arg_value + logger.debug(f"自动补充 _resource_type: {data[param_name]['_resource_type']}") + + # 首先处理资源引用 + processed_data = self._process_resource_references(data, to_dict=True) + + try: + self.device_instance = deserialize_method(**processed_data) + self.resource_tracker.add_resource(self.device_instance) + self.post_create() + return self.device_instance # type: ignore + except Exception as e: + # 先静默继续,尝试另外一种创建方法 + deserialize_error = e + stack = traceback.format_exc() + + if self.device_instance is None: + try: + spect = inspect.signature(self.device_cls.__init__) + spec_args = spect.parameters + for param_name, param_value in data.copy().items(): + if "_resource_child_name" in param_value and "_resource_type" not in param_value: + arg_value = spec_args[param_name].annotation + data[param_name]["_resource_type"] = self.device_cls.__module__ + ":" + arg_value + logger.debug(f"自动补充 _resource_type: {data[param_name]['_resource_type']}") + processed_data = self._process_resource_references(data, to_dict=False) + self.device_instance = super(PyLabRobotCreator, self).create_instance(processed_data) + except Exception as e: + logger.error(f"PyLabRobot创建实例失败: {e}") + logger.error(f"PyLabRobot创建实例堆栈: {traceback.format_exc()}") + finally: + if self.device_instance is None: + if deserialize_error: + logger.error(f"PyLabRobot反序列化失败: {deserialize_error}") + logger.error(f"PyLabRobot反序列化堆栈: {stack}") + + return self.device_instance + + def post_create(self): + if hasattr(self.device_instance, "setup") and asyncio.iscoroutinefunction(getattr(self.device_instance, "setup")): + from unilabos.ros.nodes.base_device_node import ROS2DeviceNode + ROS2DeviceNode.run_async_func(getattr(self.device_instance, "setup")).add_done_callback(lambda x: logger.debug(f"PyLabRobot设备实例 {self.device_instance} 设置完成")) +# 2486229810384 +#2486232539792 + +class ProtocolNodeCreator(DeviceClassCreator[T]): + """ + ProtocolNode设备类创建器 + + 这个类提供了针对ProtocolNode设备类的实例创建方法,处理children参数。 + """ + + def __init__(self, cls: Type[T], children: Dict[str, Any]): + """ + 初始化ProtocolNode设备类创建器 + + Args: + cls: ProtocolNode设备类 + children: 子资源字典,用于资源替换 + """ + super().__init__(cls) + self.children = children + + def create_instance(self, data: Dict[str, Any]) -> T: + """ + 从数据创建ProtocolNode设备实例 + + Args: + data: 用于创建实例的数据 + + Returns: + ProtocolNode设备类实例 + """ + try: + + # 创建实例 + data["children"] = self.children + self.device_instance = super(ProtocolNodeCreator, self).create_instance(data) + self.post_create() + return self.device_instance + except Exception as e: + logger.error(f"ProtocolNode创建实例失败: {e}") + logger.error(f"ProtocolNode创建实例堆栈: {traceback.format_exc()}") + raise diff --git a/unilabos/ros/x/__init__.py b/unilabos/ros/x/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/ros/x/rclpyx.py b/unilabos/ros/x/rclpyx.py new file mode 100644 index 00000000..a723922d --- /dev/null +++ b/unilabos/ros/x/rclpyx.py @@ -0,0 +1,182 @@ +import asyncio +from asyncio import events +import threading + +import rclpy +from rclpy.impl.implementation_singleton import rclpy_implementation as _rclpy +from rclpy.executors import await_or_execute, Executor +from rclpy.action import ActionClient, ActionServer +from rclpy.action.server import ServerGoalHandle, GoalResponse, GoalInfo, GoalStatus +from std_msgs.msg import String +from action_tutorials_interfaces.action import Fibonacci + + +loop = None + +def get_event_loop(): + global loop + return loop + + +async def default_handle_accepted_callback_async(goal_handle): + """Execute the goal.""" + await goal_handle.execute() + + +class ServerGoalHandleX(ServerGoalHandle): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + async def execute(self, execute_callback=None): + # It's possible that there has been a request to cancel the goal prior to executing. + # In this case we want to avoid the illegal state transition to EXECUTING + # but still call the users execute callback to let them handle canceling the goal. + if not self.is_cancel_requested: + self._update_state(_rclpy.GoalEvent.EXECUTE) + await self._action_server.notify_execute_async(self, execute_callback) + + +class ActionServerX(ActionServer): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.register_handle_accepted_callback(default_handle_accepted_callback_async) + + async def _execute_goal_request(self, request_header_and_message): + request_header, goal_request = request_header_and_message + goal_uuid = goal_request.goal_id + goal_info = GoalInfo() + goal_info.goal_id = goal_uuid + + self._node.get_logger().debug('New goal request with ID: {0}'.format(goal_uuid.uuid)) + + # Check if goal ID is already being tracked by this action server + with self._lock: + goal_id_exists = self._handle.goal_exists(goal_info) + + accepted = False + if not goal_id_exists: + # Call user goal callback + response = await await_or_execute(self._goal_callback, goal_request.goal) + if not isinstance(response, GoalResponse): + self._node.get_logger().warning( + 'Goal request callback did not return a GoalResponse type. Rejecting goal.') + else: + accepted = GoalResponse.ACCEPT == response + + if accepted: + # Stamp time of acceptance + goal_info.stamp = self._node.get_clock().now().to_msg() + + # Create a goal handle + try: + with self._lock: + goal_handle = ServerGoalHandleX(self, goal_info, goal_request.goal) + except RuntimeError as e: + self._node.get_logger().error( + 'Failed to accept new goal with ID {0}: {1}'.format(goal_uuid.uuid, e)) + accepted = False + else: + self._goal_handles[bytes(goal_uuid.uuid)] = goal_handle + + # Send response + response_msg = self._action_type.Impl.SendGoalService.Response() + response_msg.accepted = accepted + response_msg.stamp = goal_info.stamp + self._handle.send_goal_response(request_header, response_msg) + + if not accepted: + self._node.get_logger().debug('New goal rejected: {0}'.format(goal_uuid.uuid)) + return + + self._node.get_logger().debug('New goal accepted: {0}'.format(goal_uuid.uuid)) + + # Provide the user a reference to the goal handle + # await await_or_execute(self._handle_accepted_callback, goal_handle) + asyncio.create_task(self._handle_accepted_callback(goal_handle)) + + async def notify_execute_async(self, goal_handle, execute_callback): + # Use provided callback, defaulting to a previously registered callback + if execute_callback is None: + if self._execute_callback is None: + return + execute_callback = self._execute_callback + + # Schedule user callback for execution + self._node.get_logger().info(f"{events.get_running_loop()}") + asyncio.create_task(self._execute_goal(execute_callback, goal_handle)) + # loop = asyncio.new_event_loop() + # asyncio.set_event_loop(loop) + # task = loop.create_task(self._execute_goal(execute_callback, goal_handle)) + # await task + + +class ActionClientX(ActionClient): + feedback_queue = asyncio.Queue() + + async def feedback_cb(self, msg): + await self.feedback_queue.put(msg) + + async def send_goal_async(self, goal_msg): + goal_future = super().send_goal_async( + goal_msg, + feedback_callback=self.feedback_cb + ) + client_goal_handle = await asyncio.ensure_future(goal_future) + if not client_goal_handle.accepted: + raise Exception("Goal rejected.") + result_future = client_goal_handle.get_result_async() + while True: + feedback_future = asyncio.ensure_future(self.feedback_queue.get()) + tasks = [result_future, feedback_future] + await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + if result_future.done(): + result = result_future.result().result + yield (None, result) + break + else: + feedback = feedback_future.result().feedback + yield (feedback, None) + + +async def main(node): + print('Node started.') + action_client = ActionClientX(node, Fibonacci, 'fibonacci') + goal_msg = Fibonacci.Goal() + goal_msg.order = 10 + async for (feedback, result) in action_client.send_goal_async(goal_msg): + if feedback: + print(f'Feedback: {feedback}') + else: + print(f'Result: {result}') + print('Finished.') + + +async def ros_loop_node(node): + while rclpy.ok(): + rclpy.spin_once(node, timeout_sec=0) + await asyncio.sleep(1e-4) + + +async def ros_loop(executor: Executor): + while rclpy.ok(): + executor.spin_once(timeout_sec=0) + await asyncio.sleep(1e-4) + + +def run_event_loop(): + global loop + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_forever() + + +def run_event_loop_in_thread(): + thread = threading.Thread(target=run_event_loop, args=()) + thread.start() + + +if __name__ == "__main__": + rclpy.init() + node = rclpy.create_node('async_subscriber') + future = asyncio.wait([ros_loop(node), main()]) + asyncio.get_event_loop().run_until_complete(future) \ No newline at end of file diff --git a/unilabos/utils/__init__.py b/unilabos/utils/__init__.py new file mode 100644 index 00000000..80b95730 --- /dev/null +++ b/unilabos/utils/__init__.py @@ -0,0 +1,7 @@ +from unilabos.utils.log import logger + +# 确保日志配置在导入utils包时自动应用 +# 这样任何导入utils包或其子模块的代码都会自动配置好日志 + +# 导出logger,使其可以通过from unilabos.utils import logger直接导入 +__all__ = ['logger'] diff --git a/unilabos/utils/async_util.py b/unilabos/utils/async_util.py new file mode 100644 index 00000000..ce97f5a1 --- /dev/null +++ b/unilabos/utils/async_util.py @@ -0,0 +1,21 @@ +import asyncio +import traceback +from asyncio import get_event_loop + +from unilabos.utils.log import error + + +def run_async_func(func, *, loop=None, **kwargs): + if loop is None: + loop = get_event_loop() + + def _handle_future_exception(fut): + try: + fut.result() + except Exception as e: + error(f"异步任务 {func.__name__} 报错了") + error(traceback.format_exc()) + + future = asyncio.run_coroutine_threadsafe(func(**kwargs), loop) + future.add_done_callback(_handle_future_exception) + return future \ No newline at end of file diff --git a/unilabos/utils/banner_print.py b/unilabos/utils/banner_print.py new file mode 100644 index 00000000..888247ea --- /dev/null +++ b/unilabos/utils/banner_print.py @@ -0,0 +1,183 @@ +""" +横幅和UI打印工具 + +提供用于显示彩色横幅、状态信息和其他UI元素的工具函数。 +""" + +import os +import platform +from datetime import datetime +import importlib.metadata +from typing import Dict, Any + + +# ANSI颜色代码 +class Colors: + """ANSI颜色代码集合""" + + RESET = "\033[0m" + BOLD = "\033[1m" + ITALIC = "\033[3m" + UNDERLINE = "\033[4m" + + BLACK = "\033[30m" + RED = "\033[31m" + GREEN = "\033[32m" + YELLOW = "\033[33m" + BLUE = "\033[34m" + MAGENTA = "\033[35m" + CYAN = "\033[36m" + WHITE = "\033[37m" + COLOR_HEAD = "\033[38;5;214m" + COLOR_DECO = "\033[38;5;242m" + COLOR_TAIL = "\033[38;5;220m" + + BRIGHT_BLACK = "\033[90m" + BRIGHT_RED = "\033[91m" + BRIGHT_GREEN = "\033[92m" + BRIGHT_YELLOW = "\033[93m" + BRIGHT_BLUE = "\033[94m" + BRIGHT_MAGENTA = "\033[95m" + BRIGHT_CYAN = "\033[96m" + BRIGHT_WHITE = "\033[97m" + + BG_BLACK = "\033[40m" + BG_RED = "\033[41m" + BG_GREEN = "\033[42m" + BG_YELLOW = "\033[43m" + BG_BLUE = "\033[44m" + BG_MAGENTA = "\033[45m" + BG_CYAN = "\033[46m" + BG_WHITE = "\033[47m" + + +def get_version() -> str: + """ + 获取ilabos的版本号 + + 通过importlib.metadata尝试获取包版本。 + 如果失败,返回开发版本号。 + + Returns: + 版本号字符串 + """ + try: + return importlib.metadata.version("unilabos") + except importlib.metadata.PackageNotFoundError: + return "dev-0.1.0" # 开发版本 + + +def print_unilab_banner(args_dict: Dict[str, Any], show_config: bool = True) -> None: + """ + 打印UNI LAB启动横幅 + + Args: + args_dict: 命令行参数字典 + show_config: 是否显示配置信息 + """ + # 检测终端是否支持ANSI颜色 + if platform.system() == "Windows": + os.system("") # 启用Windows终端中的ANSI支持 + + # 获取当前时间 + current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + # 获取版本号 + version = get_version() + + # 构建UNI LAB字符艺术 + banner = f"""{Colors.COLOR_HEAD} + ██╗ ██╗ ███╗ ██╗ ██╗ ██╗ █████╗ ██████╗ {Colors.COLOR_TAIL} + ██║ ██║ ████╗ ██║ ██║ ██║ ██╔══██╗ ██╔══██╗ + ██║ ██║ ██╔██╗ ██║ ██║ ██║ ███████║ ██████╔╝ + ██║ ██║ ██║╚██╗██║ ██║ ██║ ██╔══██║ ██╔══██╗ + ╚██████╔╝ ██║ ╚████║ ██║ ██████╗ ██║ ██║ ██████╔╝{Colors.COLOR_DECO} + ╚════╝ ╚═╝ ╚═══╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ {Colors.RESET}""" + + # 显示版本信息 + system_info = f""" +{Colors.YELLOW}Version:{Colors.RESET} {Colors.BRIGHT_GREEN}{version}{Colors.RESET} +{Colors.YELLOW}System:{Colors.RESET} {Colors.WHITE}{platform.system()} {platform.release()}{Colors.RESET} +{Colors.YELLOW}Python:{Colors.RESET} {Colors.WHITE}{platform.python_version()}{Colors.RESET} +{Colors.YELLOW}Time:{Colors.RESET} {Colors.WHITE}{current_time}{Colors.RESET} +{Colors.BRIGHT_WHITE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{Colors.RESET}""" + + # 打印横幅和系统信息 + print(banner + system_info) + + # 如果需要,显示配置信息 + if show_config and args_dict: + print_config(args_dict) + + +def print_config(args_dict: Dict[str, Any]) -> None: + """ + 打印配置信息 + + Args: + args_dict: 命令行参数字典 + """ + config_info = f"{Colors.BRIGHT_BLUE}{Colors.BOLD}Configuration:{Colors.RESET}\n" + + # 后端信息 + if "backend" in args_dict: + config_info += f"{Colors.CYAN}• Backend:{Colors.RESET} " + config_info += f"{Colors.WHITE}{args_dict['backend']}{Colors.RESET}\n" + + # 桥接信息 + if "app_bridges" in args_dict: + config_info += f"{Colors.CYAN}• Bridges:{Colors.RESET} " + config_info += f"{Colors.WHITE}{', '.join(args_dict['app_bridges'])}{Colors.RESET}\n" + + # 主机模式 + if "without_host" in args_dict: + mode = "Slave" if args_dict["without_host"] else "Master" + config_info += f"{Colors.CYAN}• Host Mode:{Colors.RESET} {Colors.WHITE}{mode}{Colors.RESET}\n" + + # 如果有图或设备信息,显示它们 + if "graph" in args_dict and args_dict["graph"] is not None: + config_info += f"{Colors.CYAN}• Graph:{Colors.RESET} " + config_info += f"{Colors.WHITE}{args_dict['graph']}{Colors.RESET}\n" + elif "devices" in args_dict and args_dict["devices"] is not None: + config_info += f"{Colors.CYAN}• Devices:{Colors.RESET} " + config_info += f"{Colors.WHITE}{args_dict['devices']}{Colors.RESET}\n" + if "resources" in args_dict and args_dict["resources"] is not None: + config_info += f"{Colors.CYAN}• Resources:{Colors.RESET} " + config_info += f"{Colors.WHITE}{args_dict['resources']}{Colors.RESET}\n" + + # 控制器配置 + if "controllers" in args_dict and args_dict["controllers"] is not None: + config_info += f"{Colors.CYAN}• Controllers:{Colors.RESET} " + config_info += f"{Colors.WHITE}{args_dict['controllers']}{Colors.RESET}\n" + + # 打印结束分隔线 + config_info += f"{Colors.BRIGHT_WHITE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{Colors.RESET}\n" + + print(config_info) + + +def print_status(message: str, status_type: str = "info") -> None: + """ + 打印带颜色的状态消息 + + Args: + message: 要打印的消息 + status_type: 状态类型('info', 'success', 'warning', 'error') + """ + color = Colors.WHITE + prefix = "" + + if status_type == "info": + color = Colors.BLUE + prefix = "INFO" + elif status_type == "success": + color = Colors.GREEN + prefix = "SUCCESS" + elif status_type == "warning": + color = Colors.YELLOW + prefix = "WARNING" + elif status_type == "error": + color = Colors.RED + prefix = "ERROR" + + print(f"{color}[{prefix}]{Colors.RESET} {message}") diff --git a/unilabos/utils/cls_creator.py b/unilabos/utils/cls_creator.py new file mode 100644 index 00000000..5df7a721 --- /dev/null +++ b/unilabos/utils/cls_creator.py @@ -0,0 +1,138 @@ +""" +类实例创建工具 + +此模块提供了通过字典配置创建类实例的功能,支持嵌套实例的递归创建。 +类似于Hydra和Weights & Biases的配置系统。 +""" + +import importlib +import traceback +from typing import Any, Dict, TypeVar +from unilabos.utils import import_manager, logger + +T = TypeVar("T") + +# 定义实例创建规范的关键字 +INSTANCE_TYPE_KEY = "_cls" # 类的完整路径 +INSTANCE_PARAMS_KEY = "_params" # 构造函数参数 +INSTANCE_ARGS_KEY = "_args" # 位置参数列表(可选) + + +def is_instance_config(config: Any) -> bool: + """ + 检查配置是否符合实例创建规范 + + Args: + config: 要检查的配置对象 + + Returns: + 是否符合实例创建规范 + """ + if not isinstance(config, dict): + return False + + return INSTANCE_TYPE_KEY in config and INSTANCE_PARAMS_KEY in config + + +def import_class(class_path: str) -> type: + """ + 根据类路径导入类 + + Args: + class_path: 类的完整路径,如"pylabrobot.liquid_handling:LiquidHandler" + + Returns: + 导入的类 + + Raises: + ImportError: 如果导入失败 + AttributeError: 如果找不到指定的类 + """ + try: + return import_manager.get_class(class_path) + except ValueError as e: + raise ImportError(f"无法导入类 {class_path}: {str(e)}") + except (ImportError, AttributeError) as e: + raise ImportError(f"无法导入类 {class_path}: {str(e)}") + + +def create_instance_from_config(config: Dict[str, Any]) -> Any: + """ + 从配置字典创建实例,递归处理嵌套的实例配置 + + Args: + config: 配置字典,必须包含_type和_params键 + + Returns: + 创建的实例 + + Raises: + ValueError: 如果配置不符合规范 + ImportError: 如果类导入失败 + """ + if not is_instance_config(config): + raise ValueError(f"配置不符合实例创建规范: {config}") + + class_path = config[INSTANCE_TYPE_KEY] + params = config[INSTANCE_PARAMS_KEY] + args = config.get(INSTANCE_ARGS_KEY, []) + + # 递归处理嵌套的实例配置 + processed_args = [] + for arg in args: + if is_instance_config(arg): + processed_args.append(create_instance_from_config(arg)) + else: + processed_args.append(arg) + + processed_params = {} + for key, value in params.items(): + if is_instance_config(value): + processed_params[key] = create_instance_from_config(value) + elif isinstance(value, list): + # 处理列表中的实例配置 + processed_list = [] + for item in value: + if is_instance_config(item): + processed_list.append(create_instance_from_config(item)) + else: + processed_list.append(item) + processed_params[key] = processed_list + elif isinstance(value, dict) and not is_instance_config(value): + # 处理普通字典中可能包含的实例配置 + processed_dict = {} + for k, v in value.items(): + if is_instance_config(v): + processed_dict[k] = create_instance_from_config(v) + else: + processed_dict[k] = v + processed_params[key] = processed_dict + else: + processed_params[key] = value + + # 导入类并创建实例 + cls = import_class(class_path) + return cls(*processed_args, **processed_params) + + +def create_config_from_instance(instance: Any, include_args: bool = False) -> Dict[str, Any]: + """ + 从实例创建配置字典(序列化) + + Args: + instance: 要序列化的实例 + include_args: 是否包含位置参数(通常无法获取) + + Returns: + 配置字典 + """ + if instance is None: + return {} + + # 获取实例的类路径 + cls = instance.__class__ + class_path = f"{cls.__module__}.{cls.__name__}" + + # 无法获取实例的构造参数,这里返回空字典 + # 实际使用时可能需要手动指定或通过其他方式获取 + return {INSTANCE_TYPE_KEY: class_path, INSTANCE_PARAMS_KEY: {}, INSTANCE_ARGS_KEY: [] if include_args else None} diff --git a/unilabos/utils/decorator.py b/unilabos/utils/decorator.py new file mode 100644 index 00000000..77e473c0 --- /dev/null +++ b/unilabos/utils/decorator.py @@ -0,0 +1,14 @@ +def singleton(cls): + """ + 单例装饰器 + 确保被装饰的类只有一个实例 + """ + instances = {} + + def get_instance(*args, **kwargs): + if cls not in instances: + instances[cls] = cls(*args, **kwargs) + return instances[cls] + + return get_instance + diff --git a/unilabos/utils/fastapi/__init__.py b/unilabos/utils/fastapi/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/utils/fastapi/log_adapter.py b/unilabos/utils/fastapi/log_adapter.py new file mode 100644 index 00000000..d758ab23 --- /dev/null +++ b/unilabos/utils/fastapi/log_adapter.py @@ -0,0 +1,104 @@ +""" +日志适配器模块 + +用于将各种框架的日志(如Uvicorn、FastAPI等)统一适配到ilabos的日志系统 +""" + +import logging + +from unilabos.utils.log import debug, info, warning, error, critical + + +class UvicornLogAdapter: + """Uvicorn日志适配器,将Uvicorn的日志重定向到我们的日志系统""" + + @staticmethod + def configure(): + """配置Uvicorn的日志系统,使用我们自定义的日志格式""" + # 获取uvicorn相关的日志记录器 + uvicorn_loggers = [ + logging.getLogger("uvicorn"), + logging.getLogger("uvicorn.access"), + logging.getLogger("uvicorn.error"), + logging.getLogger("fastapi"), + ] + + # 清除现有处理器 + for logger_instance in uvicorn_loggers: + for handler in logger_instance.handlers[:]: + logger_instance.removeHandler(handler) + + # 添加自定义处理器 + adapter_handler = UvicornToIlabosHandler() + + # 为所有uvicorn日志记录器添加处理器 + for logger_instance in uvicorn_loggers: + logger_instance.addHandler(adapter_handler) + # 设置日志级别 + logger_instance.setLevel(logging.INFO) + # 禁止传播到根日志记录器,避免重复输出 + logger_instance.propagate = False + + +class UvicornToIlabosHandler(logging.Handler): + """将Uvicorn日志处理为ilabos日志格式的处理器""" + + def __init__(self): + super().__init__() + self.level_map = { + logging.DEBUG: debug, + logging.INFO: info, + logging.WARNING: warning, + logging.ERROR: error, + logging.CRITICAL: critical, + } + + def emit(self, record): + """发送日志记录到ilabos日志系统""" + try: + msg = self.format(record) + log_func = self.level_map.get(record.levelno, info) + # 根据日志源添加前缀 + if record.name.startswith("uvicorn"): + prefix = "[Uvicorn] " + if record.name == "uvicorn.access": + prefix = "[Uvicorn.HTTP] " + msg = f"{prefix}{msg}" + elif record.name.startswith("fastapi"): + msg = f"[FastAPI] {msg}" + else: + msg = f"{record.name} {msg}" + log_func(msg, stack_level=5) + except Exception: + self.handleError(record) + + +def setup_fastapi_logging(): + """设置FastAPI/Uvicorn的日志系统""" + # 配置Uvicorn的日志 + UvicornLogAdapter.configure() + + # 返回适合uvicorn.run()的日志配置 + return { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "default": { + "()": "uvicorn.logging.DefaultFormatter", + "fmt": "%(message)s", + "use_colors": True, + }, + }, + "handlers": { + "default": { + "formatter": "default", + "class": "unilabos.utils.fastapi.log_adapter.UvicornToIlabosHandler", + } + }, + "loggers": { + "uvicorn": {"handlers": ["default"], "level": "INFO"}, + "uvicorn.error": {"handlers": ["default"], "level": "INFO"}, + "uvicorn.access": {"handlers": ["default"], "level": "INFO"}, + "fastapi": {"handlers": ["default"], "level": "INFO"}, + }, + } diff --git a/unilabos/utils/import_manager.py b/unilabos/utils/import_manager.py new file mode 100644 index 00000000..17c69d20 --- /dev/null +++ b/unilabos/utils/import_manager.py @@ -0,0 +1,195 @@ +""" +导入管理器 + +该模块提供了一个动态导入和管理模块的系统,避免误删未使用的导入。 +""" + +import builtins +import importlib +import inspect +import traceback +from typing import Dict, List, Any, Optional, Callable, Type + + +__all__ = [ + "ImportManager", + "default_manager", + "load_module", + "get_class", + "get_module", + "init_from_list", +] + +from unilabos.utils import logger + + +class ImportManager: + """导入管理器类,用于动态加载和管理模块""" + + def __init__(self, module_list: Optional[List[str]] = None): + """ + 初始化导入管理器 + + Args: + module_list: 要预加载的模块路径列表 + """ + self._modules: Dict[str, Any] = {} + self._classes: Dict[str, Type] = {} + self._functions: Dict[str, Callable] = {} + + if module_list: + for module_path in module_list: + self.load_module(module_path) + + def load_module(self, module_path: str) -> Any: + """ + 加载指定路径的模块 + + Args: + module_path: 模块路径 + + Returns: + 加载的模块对象 + + Raises: + ImportError: 如果模块导入失败 + """ + try: + if module_path in self._modules: + return self._modules[module_path] + + module = importlib.import_module(module_path) + self._modules[module_path] = module + + # 索引模块中的类和函数 + for name, obj in inspect.getmembers(module): + if inspect.isclass(obj): + full_name = f"{module_path}.{name}" + self._classes[name] = obj + self._classes[full_name] = obj + elif inspect.isfunction(obj): + full_name = f"{module_path}.{name}" + self._functions[name] = obj + self._functions[full_name] = obj + + return module + except Exception as e: + logger.error(f"导入模块 '{module_path}' 时发生错误:{str(e)}") + logger.warning(traceback.format_exc()) + raise ImportError(f"无法导入模块 {module_path}: {str(e)}") + + def get_module(self, module_path: str) -> Any: + """ + 获取已加载的模块 + + Args: + module_path: 模块路径 + + Returns: + 模块对象 + + Raises: + KeyError: 如果模块未加载 + """ + if module_path not in self._modules: + return self.load_module(module_path) + return self._modules[module_path] + + def get_class(self, class_name: str) -> Type: + """ + 获取类对象 + + Args: + class_name: 类名或完整类路径 + + Returns: + 类对象 + + Raises: + KeyError: 如果找不到类 + """ + if class_name in self._classes: + return self._classes[class_name] + + # 尝试动态导入 + if ":" in class_name: + module_path, cls_name = class_name.rsplit(":", 1) + # 如果cls_name是builtins中的关键字,则返回对应类 + if cls_name in builtins.__dict__: + return builtins.__dict__[cls_name] + module = self.load_module(module_path) + if hasattr(module, cls_name): + cls = getattr(module, cls_name) + self._classes[class_name] = cls + self._classes[cls_name] = cls + return cls + + raise KeyError(f"找不到类: {class_name}") + + def list_modules(self) -> List[str]: + """列出所有已加载的模块路径""" + return list(self._modules.keys()) + + def list_classes(self) -> List[str]: + """列出所有已索引的类名""" + return list(self._classes.keys()) + + def list_functions(self) -> List[str]: + """列出所有已索引的函数名""" + return list(self._functions.keys()) + + def search_class(self, class_name: str, search_lower=False) -> Optional[Type]: + """ + 在所有已加载的模块中搜索特定类名 + + Args: + class_name: 要搜索的类名 + search_lower: 以小写搜索 + + Returns: + 找到的类对象,如果未找到则返回None + """ + # 首先在已索引的类中查找 + if class_name in self._classes: + return self._classes[class_name] + + if search_lower: + classes = {name.lower(): obj for name, obj in self._classes.items()} + if class_name in classes: + return classes[class_name] + + # 遍历所有已加载的模块进行搜索 + for module_path, module in self._modules.items(): + for name, obj in inspect.getmembers(module): + if inspect.isclass(obj) and ((name.lower() == class_name.lower()) if search_lower else (name == class_name)): + # 将找到的类添加到索引中 + self._classes[name] = obj + self._classes[f"{module_path}:{name}"] = obj + return obj + + return None + + +# 全局实例,便于直接使用 +default_manager = ImportManager() + + +def load_module(module_path: str) -> Any: + """加载模块的便捷函数""" + return default_manager.load_module(module_path) + + +def get_class(class_name: str) -> Type: + """获取类的便捷函数""" + return default_manager.get_class(class_name) + + +def get_module(module_path: str) -> Any: + """获取模块的便捷函数""" + return default_manager.get_module(module_path) + + +def init_from_list(module_list: List[str]) -> None: + """从模块列表初始化默认管理器""" + global default_manager + default_manager = ImportManager(module_list) diff --git a/unilabos/utils/log.py b/unilabos/utils/log.py new file mode 100644 index 00000000..61c95a14 --- /dev/null +++ b/unilabos/utils/log.py @@ -0,0 +1,315 @@ +import logging +import os +import platform +from datetime import datetime +import ctypes +import atexit +import inspect +from typing import Tuple, cast + + +class CustomRecord: + custom_stack_info: Tuple[str, int, str, str] + + +# Windows颜色支持 +if platform.system() == "Windows": + # 尝试启用Windows终端的ANSI支持 + kernel32 = ctypes.windll.kernel32 + # 获取STD_OUTPUT_HANDLE + STD_OUTPUT_HANDLE = -11 + # 启用ENABLE_VIRTUAL_TERMINAL_PROCESSING + ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004 + # 获取当前控制台模式 + handle = kernel32.GetStdHandle(STD_OUTPUT_HANDLE) + mode = ctypes.c_ulong() + kernel32.GetConsoleMode(handle, ctypes.byref(mode)) + # 启用ANSI处理 + kernel32.SetConsoleMode(handle, mode.value | ENABLE_VIRTUAL_TERMINAL_PROCESSING) + + # 程序退出时恢复控制台设置 + @atexit.register + def reset_console(): + kernel32.SetConsoleMode(handle, mode.value) + + +# 定义不同日志级别的颜色 +class ColoredFormatter(logging.Formatter): + """自定义日志格式化器,支持颜色输出""" + + # ANSI 颜色代码 + COLORS = { + "RESET": "\033[0m", # 重置 + "BOLD": "\033[1m", # 加粗 + "GRAY": "\033[37m", # 灰色 + "WHITE": "\033[97m", # 白色 + "BLACK": "\033[30m", # 黑色 + "DEBUG_LEVEL": "\033[1;36m", # 加粗青色 + "INFO_LEVEL": "\033[1;32m", # 加粗绿色 + "WARNING_LEVEL": "\033[1;33m", # 加粗黄色 + "ERROR_LEVEL": "\033[1;31m", # 加粗红色 + "CRITICAL_LEVEL": "\033[1;35m", # 加粗紫色 + "DEBUG_TEXT": "\033[37m", # 灰色 + "INFO_TEXT": "\033[97m", # 白色 + "WARNING_TEXT": "\033[33m", # 黄色 + "ERROR_TEXT": "\033[31m", # 红色 + "CRITICAL_TEXT": "\033[35m", # 紫色 + "DATE": "\033[37m", # 日期始终使用灰色 + } + + def __init__(self, use_colors=True): + super().__init__() + # 强制启用颜色 + self.use_colors = use_colors + + def format(self, record): + # 检查是否有自定义堆栈信息 + if hasattr(record, "custom_stack_info") and record.custom_stack_info: # type: ignore + r = cast(CustomRecord, record) + frame_info = r.custom_stack_info + record.filename = frame_info[0] + record.lineno = frame_info[1] + record.funcName = frame_info[2] + if len(frame_info) > 3: + record.name = frame_info[3] + if not self.use_colors: + return self._format_basic(record) + + level_color = self.COLORS.get(f"{record.levelname}_LEVEL", self.COLORS["WHITE"]) + text_color = self.COLORS.get(f"{record.levelname}_TEXT", self.COLORS["WHITE"]) + date_color = self.COLORS["DATE"] + reset = self.COLORS["RESET"] + + # 日期格式 + datetime_str = datetime.fromtimestamp(record.created).strftime("%y-%m-%d [%H:%M:%S,%f")[:-3] + "]" + + # 模块和函数信息 + filename = record.filename.replace(".py", "").split("\\")[-1] # 提取文件名(不含路径和扩展名) + if "/" in filename: + filename = filename.split("/")[-1] + module_path = f"{record.name}.{filename}" + func_line = f"{record.funcName}:{record.lineno}" + right_info = f" [{func_line}] [{module_path}]" + + # 主要消息 + main_msg = record.getMessage() + + # 构建基本消息格式 + formatted_message = ( + f"{date_color}{datetime_str}{reset} " + f"{level_color}[{record.levelname}]{reset} " + f"{text_color}{main_msg}" + f"{date_color}{right_info}{reset}" + ) + + # 处理异常信息 + if record.exc_info: + exc_text = self.formatException(record.exc_info) + if formatted_message[-1:] != "\n": + formatted_message = formatted_message + "\n" + formatted_message = formatted_message + text_color + exc_text + reset + elif record.stack_info: + if formatted_message[-1:] != "\n": + formatted_message = formatted_message + "\n" + formatted_message = formatted_message + text_color + self.formatStack(record.stack_info) + reset + + return formatted_message + + def _format_basic(self, record): + """基本格式化,不包含颜色""" + datetime_str = datetime.fromtimestamp(record.created).strftime("%y-%m-%d [%H:%M:%S,%f")[:-3] + "]" + filename = os.path.basename(record.filename).rsplit(".", 1)[0] # 提取文件名(不含路径和扩展名) + module_path = f"{record.name}.{filename}" + func_line = f"{record.funcName}:{record.lineno}" + + formatted_message = f"{datetime_str} [{record.levelname}] [{module_path}] [{func_line}]: {record.getMessage()}" + + if record.exc_info: + exc_text = self.formatException(record.exc_info) + if formatted_message[-1:] != "\n": + formatted_message = formatted_message + "\n" + formatted_message = formatted_message + exc_text + elif record.stack_info: + if formatted_message[-1:] != "\n": + formatted_message = formatted_message + "\n" + formatted_message = formatted_message + self.formatStack(record.stack_info) + + return formatted_message + + def formatException(self, exc_info): + """重写异常格式化,确保异常信息保持正确的格式和颜色""" + # 获取标准的异常格式化文本 + formatted_exc = super().formatException(exc_info) + return formatted_exc + + +# 配置日志处理器 +def configure_logger(): + """配置日志记录器""" + # 获取根日志记录器 + root_logger = logging.getLogger() + root_logger.setLevel(logging.DEBUG) # 修改为DEBUG以显示所有级别 + + # 移除已存在的处理器 + for handler in root_logger.handlers[:]: + root_logger.removeHandler(handler) + + # 创建控制台处理器 + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.DEBUG) # 修改为DEBUG以显示所有级别 + + # 使用自定义的颜色格式化器 + color_formatter = ColoredFormatter() + console_handler.setFormatter(color_formatter) + + # 添加处理器到根日志记录器 + root_logger.addHandler(console_handler) + + +# 配置日志系统 +configure_logger() + +# 获取日志记录器 +logger = logging.getLogger(__name__) + + +# 获取调用栈信息的工具函数 +def _get_caller_info(stack_level=0) -> Tuple[str, int, str, str]: + """ + 获取调用者的信息 + + Args: + stack_level: 堆栈回溯的级别,0表示当前函数,1表示调用者,依此类推 + + Returns: + (filename, line_number, function_name, module_name) 元组 + """ + # 堆栈级别需要加3: + # +1 因为这个函数本身占一层 + # +1 因为日志函数(debug, info等)占一层 + # +1 因为下面调用 inspect.stack() 也占一层 + frame = inspect.currentframe() + try: + # 跳过适当的堆栈帧 + for _ in range(stack_level + 3): + if frame and frame.f_back: + frame = frame.f_back + else: + break + + if frame: + filename = frame.f_code.co_filename if frame.f_code else "unknown" + line_number = frame.f_lineno if hasattr(frame, "f_lineno") else 0 + function_name = frame.f_code.co_name if frame.f_code else "unknown" + + # 获取模块名称 + module_name = "unknown" + if frame.f_globals and "__name__" in frame.f_globals: + module_name = frame.f_globals["__name__"].rsplit(".", 1)[0] + + return (filename, line_number, function_name, module_name) + return ("unknown", 0, "unknown", "unknown") + finally: + del frame # 避免循环引用 + + +# 便捷日志记录函数 +def debug(msg, *args, stack_level=0, **kwargs): + """ + 记录DEBUG级别日志 + + Args: + msg: 日志消息 + stack_level: 堆栈回溯级别,用于定位日志的实际调用位置 + *args, **kwargs: 传递给logger.debug的其他参数 + """ + # 获取调用者信息 + if stack_level > 0: + caller_info = _get_caller_info(stack_level) + extra = kwargs.get("extra", {}) + extra["custom_stack_info"] = caller_info + kwargs["extra"] = extra + logger.debug(msg, *args, **kwargs) + + +def info(msg, *args, stack_level=0, **kwargs): + """ + 记录INFO级别日志 + + Args: + msg: 日志消息 + stack_level: 堆栈回溯级别,用于定位日志的实际调用位置 + *args, **kwargs: 传递给logger.info的其他参数 + """ + if stack_level > 0: + caller_info = _get_caller_info(stack_level) + extra = kwargs.get("extra", {}) + extra["custom_stack_info"] = caller_info + kwargs["extra"] = extra + logger.info(msg, *args, **kwargs) + + +def warning(msg, *args, stack_level=0, **kwargs): + """ + 记录WARNING级别日志 + + Args: + msg: 日志消息 + stack_level: 堆栈回溯级别,用于定位日志的实际调用位置 + *args, **kwargs: 传递给logger.warning的其他参数 + """ + if stack_level > 0: + caller_info = _get_caller_info(stack_level) + extra = kwargs.get("extra", {}) + extra["custom_stack_info"] = caller_info + kwargs["extra"] = extra + logger.warning(msg, *args, **kwargs) + + +def error(msg, *args, stack_level=0, **kwargs): + """ + 记录ERROR级别日志 + + Args: + msg: 日志消息 + stack_level: 堆栈回溯级别,用于定位日志的实际调用位置 + *args, **kwargs: 传递给logger.error的其他参数 + """ + if stack_level > 0: + caller_info = _get_caller_info(stack_level) + extra = kwargs.get("extra", {}) + extra["custom_stack_info"] = caller_info + kwargs["extra"] = extra + logger.error(msg, *args, **kwargs) + + +def critical(msg, *args, stack_level=0, **kwargs): + """ + 记录CRITICAL级别日志 + + Args: + msg: 日志消息 + stack_level: 堆栈回溯级别,用于定位日志的实际调用位置 + *args, **kwargs: 传递给logger.critical的其他参数 + """ + if stack_level > 0: + caller_info = _get_caller_info(stack_level) + extra = kwargs.get("extra", {}) + extra["custom_stack_info"] = caller_info + kwargs["extra"] = extra + logger.critical(msg, *args, **kwargs) + + +# 测试日志输出(如果直接运行此文件) +if __name__ == "__main__": + print("测试不同日志级别的颜色输出:") + debug("这是一条调试日志 (DEBUG级别显示为蓝色,其他文本为灰色)") + info("这是一条信息日志 (INFO级别显示为绿色,其他文本为白色)") + warning("这是一条警告日志 (WARNING级别显示为黄色,其他文本也为黄色)") + error("这是一条错误日志 (ERROR级别显示为红色,其他文本也为红色)") + critical("这是一条严重错误日志 (CRITICAL级别显示为紫色,其他文本也为紫色)") + # 测试异常输出 + try: + 1 / 0 + except Exception as e: + error(f"发生错误: {e}", exc_info=True) diff --git a/unilabos/utils/pywinauto_util.py b/unilabos/utils/pywinauto_util.py new file mode 100644 index 00000000..3b78632a --- /dev/null +++ b/unilabos/utils/pywinauto_util.py @@ -0,0 +1,161 @@ +import psutil +import pywinauto +from pywinauto_recorder import UIApplication +from pywinauto_recorder.player import UIPath, click, focus_on_application, exists, find, get_wrapper_path +from pywinauto.controls.uiawrapper import UIAWrapper +from pywinauto.application import WindowSpecification +from pywinauto import findbestmatch +import sys +import codecs +import os +import locale + + +def connect_application(backend="uia", **kwargs): + app = pywinauto.Application(backend=backend) + app.connect(**kwargs) + top_window = app.top_window().wrapper_object() + native_window_handle = top_window.handle + return UIApplication(app, native_window_handle) + +def get_ui_path_with_window_specification(obj): + return UIPath(get_wrapper_path(obj)) + +def get_process_pid_by_name(process_name: str, min_memory_mb: float = 0) -> tuple[bool, int]: + """ + 通过进程名称和最小内存要求获取进程PID + + Args: + process_name: 进程名称 + min_memory_mb: 最小内存要求(MB),默认为0表示不检查内存 + + Returns: + tuple[bool, int]: (是否找到进程, 进程PID) + """ + process_found = False + process_pid = None + min_memory_bytes = min_memory_mb * 1024 * 1024 # 转换为字节 + + try: + for proc in psutil.process_iter(['name', 'pid', 'memory_info']): + try: + # 获取进程信息 + proc_info = proc.info + if process_name in proc_info['name']: + # 如果设置了内存限制,则检查内存 + if min_memory_mb > 0: + memory_info = proc_info.get('memory_info') + if memory_info and memory_info.rss > min_memory_bytes: + process_found = True + process_pid = proc_info['pid'] + break + else: + # 不检查内存,直接返回找到的进程 + process_found = True + process_pid = proc_info['pid'] + break + + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + continue + + except Exception as e: + print(f"获取进程信息时发生错误: {str(e)}") + + return process_found, process_pid + +def print_wrapper_identifiers(wrapper_object, depth=None, filename=None): + """ + 打印控件及其子控件的标识信息 + + Args: + wrapper_object: UIAWrapper对象 + depth: 打印的最大深度,None表示打印全部 + filename: 输出文件名,None表示打印到控制台 + """ + if depth is None: + depth = sys.maxsize + + # 创建所有控件的列表(当前控件及其所有子代) + all_ctrls = [wrapper_object, ] + wrapper_object.descendants() + + # 创建所有可见文本控件的列表 + txt_ctrls = [ctrl for ctrl in all_ctrls if ctrl.can_be_label and ctrl.is_visible() and ctrl.window_text()] + + # 构建唯一的控件名称字典 + name_ctrl_id_map = findbestmatch.UniqueDict() + for index, ctrl in enumerate(all_ctrls): + ctrl_names = findbestmatch.get_control_names(ctrl, all_ctrls, txt_ctrls) + for name in ctrl_names: + name_ctrl_id_map[name] = index + + # 反转映射关系(控件索引到名称列表) + ctrl_id_name_map = {} + for name, index in name_ctrl_id_map.items(): + ctrl_id_name_map.setdefault(index, []).append(name) + + def print_identifiers(ctrls, current_depth=1, log_func=print): + """递归打印控件及其子代的标识信息""" + if len(ctrls) == 0 or current_depth > depth: + return + + indent = (current_depth - 1) * u" | " + for ctrl in ctrls: + try: + ctrl_id = all_ctrls.index(ctrl) + except ValueError: + continue + + ctrl_text = ctrl.window_text() + if ctrl_text: + # 将多行文本转换为单行 + ctrl_text = ctrl_text.replace('\n', r'\n').replace('\r', r'\r') + + output = indent + u'\n' + output += indent + u"{class_name} - '{text}' {rect}\n"\ + "".format(class_name=ctrl.friendly_class_name(), + text=ctrl_text, + rect=ctrl.rectangle()) + output += indent + u'{}'.format(ctrl_id_name_map[ctrl_id]) + + title = ctrl_text + class_name = ctrl.class_name() + auto_id = None + control_type = None + if hasattr(ctrl.element_info, 'automation_id'): + auto_id = ctrl.element_info.automation_id + if hasattr(ctrl.element_info, 'control_type'): + control_type = ctrl.element_info.control_type + if control_type: + class_name = None # 如果有control_type就不需要class_name + else: + control_type = None # 如果control_type为空,仍使用class_name + + criteria_texts = [] + recorder_texts = [] + if title: + criteria_texts.append(u'title="{}"'.format(title)) + recorder_texts.append(f"{title}") + if class_name: + criteria_texts.append(u'class_name="{}"'.format(class_name)) + if auto_id: + criteria_texts.append(u'auto_id="{}"'.format(auto_id)) + if control_type: + criteria_texts.append(u'control_type="{}"'.format(control_type)) + recorder_texts.append(f"||{control_type}") + if title or class_name or auto_id: + output += u'\n' + indent + u'child_window(' + u', '.join(criteria_texts) + u')' + " / " + "".join(recorder_texts) + + log_func(output) + print_identifiers(ctrl.children(), current_depth + 1, log_func) + + if filename is None: + print("Control Identifiers:") + print_identifiers([wrapper_object, ]) + else: + log_file = codecs.open(filename, "w", locale.getpreferredencoding()) + def log_func(msg): + log_file.write(str(msg) + os.linesep) + log_func("Control Identifiers:") + print_identifiers([wrapper_object, ], log_func=log_func) + log_file.close() + diff --git a/unilabos/utils/tools.py b/unilabos/utils/tools.py new file mode 100644 index 00000000..89195cbd --- /dev/null +++ b/unilabos/utils/tools.py @@ -0,0 +1,4 @@ +# 辅助函数:将UUID数组转换为字符串 +def uuid_to_str(uuid_array) -> str: + """将UUID字节数组转换为十六进制字符串""" + return "".join(format(byte, "02x") for byte in uuid_array) \ No newline at end of file diff --git a/unilabos/utils/type_check.py b/unilabos/utils/type_check.py new file mode 100644 index 00000000..7366652b --- /dev/null +++ b/unilabos/utils/type_check.py @@ -0,0 +1,23 @@ +import collections +import json +from typing import get_origin, get_args + + +def get_type_class(type_hint): + origin = get_origin(type_hint) + if origin is not None and issubclass(origin, collections.abc.Sequence): + final_type = [get_args(type_hint)[0]] # 默认sequence中类型都一样 + else: + final_type = type_hint + return final_type + + +class TypeEncoder(json.JSONEncoder): + """自定义JSON编码器处理特殊类型""" + + def default(self, obj): + # 优先处理类型对象 + if isinstance(obj, type): + return str(obj)[8:-2] + return super().default(obj) + diff --git a/unilabos/web/__init__.py b/unilabos/web/__init__.py new file mode 100644 index 00000000..7872ee0f --- /dev/null +++ b/unilabos/web/__init__.py @@ -0,0 +1,18 @@ +""" +Web UI 模块 + +提供了UniLab系统的Web界面功能 +""" + +from unilabos.web.pages import setup_web_pages +from unilabos.web.server import setup_server, start_server +from unilabos.web.client import http_client +from unilabos.web.api import setup_api_routes + +__all__ = [ + "setup_web_pages", # 设置Web页面 + "setup_server", # 设置服务器 + "start_server", # 启动服务器 + "http_client", # HTTP客户端 + "setup_api_routes", # 设置API路由 +] diff --git a/unilabos/web/api.py b/unilabos/web/api.py new file mode 100644 index 00000000..84169a45 --- /dev/null +++ b/unilabos/web/api.py @@ -0,0 +1,197 @@ +""" +API模块 + +提供API路由和处理函数 +""" + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect +import asyncio + +from unilabos.app.controler import devices, job_add, job_info +from unilabos.app.model import ( + Resp, + RespCode, + JobStatusResp, + JobAddResp, + JobAddReq, + JobStepFinishReq, + JobPreintakeFinishReq, + JobFinishReq, +) +from unilabos.web.utils.host_utils import get_host_node_info + +# 创建API路由器 +api = APIRouter() +admin = APIRouter() + +# 存储所有活动的WebSocket连接 +active_connections: set[WebSocket] = set() + + +async def broadcast_device_status(): + """广播设备状态到所有连接的客户端""" + while True: + try: + # 获取最新的设备状态 + host_info = get_host_node_info() + if host_info["available"]: + # 准备要发送的数据 + status_data = { + "type": "device_status", + "data": { + "device_status": host_info["device_status"], + "device_status_timestamps": host_info["device_status_timestamps"], + }, + } + # 发送到所有连接的客户端 + for connection in active_connections: + try: + await connection.send_json(status_data) + except Exception as e: + print(f"Error sending to client: {e}") + active_connections.remove(connection) + await asyncio.sleep(1) # 每秒更新一次 + except Exception as e: + print(f"Error in broadcast: {e}") + await asyncio.sleep(1) + + +@api.websocket("/ws/device_status") +async def websocket_device_status(websocket: WebSocket): + """WebSocket端点,用于实时获取设备状态""" + await websocket.accept() + active_connections.add(websocket) + try: + while True: + # 保持连接活跃 + await websocket.receive_text() + except WebSocketDisconnect: + active_connections.remove(websocket) + except Exception as e: + print(f"WebSocket error: {e}") + active_connections.remove(websocket) + + +@api.get("/resources", summary="Resource list", response_model=Resp) +def get_resources(): + """获取资源列表""" + isok, data = devices() + if not isok: + return Resp(code=RespCode.ErrorHostNotInit, message=str(data)) + + return Resp(data=dict(data)) + + +@api.get("/repository", summary="Raw Material list", response_model=Resp) +def get_raw_material(): + """获取原材料列表""" + return Resp(data={}) + + +@api.post("/repository", summary="Raw Material set", response_model=Resp) +def post_raw_material(): + """设置原材料""" + return Resp(data={}) + + +@api.get("/devices", summary="Device list", response_model=Resp) +def get_devices(): + """获取设备列表""" + isok, data = devices() + if not isok: + return Resp(code=RespCode.ErrorHostNotInit, message=str(data)) + + return Resp(data=dict(data)) + + +@api.get("/devices/{id}/info", summary="Device info", response_model=Resp) +def device_info(id: str): + """获取设备信息""" + return Resp(data={}) + + +@api.get("/job/{id}/status", summary="Job status", response_model=JobStatusResp) +def job_status(id: str): + """获取任务状态""" + data = job_info(id) + return JobStatusResp(data=data) + + +@api.post("/job/add", summary="Create job", response_model=JobAddResp) +def post_job_add(req: JobAddReq): + """创建任务""" + device_id = req.device_id + if not req.data: + return Resp(code=RespCode.ErrorInvalidReq, message="Invalid request data") + + req.device_id = device_id + data = job_add(req) + return JobAddResp(data=data) + + +@api.post("/job/step_finish", summary="步骤完成推送", response_model=Resp) +def callback_step_finish(req: JobStepFinishReq): + """任务步骤完成回调""" + print(req) + return Resp(data={}) + + +@api.post("/job/preintake_finish", summary="通量完成推送", response_model=Resp) +def callback_preintake_finish(req: JobPreintakeFinishReq): + """通量完成回调""" + print(req) + return Resp(data={}) + + +@api.post("/job/finish", summary="完成推送", response_model=Resp) +def callback_order_finish(req: JobFinishReq): + """任务完成回调""" + print(req) + return Resp(data={}) + + +@admin.get("/device_models", summary="Device model list", response_model=Resp) +def admin_device_models(): + """获取设备模型列表""" + return Resp(data={}) + + +@admin.post("/device_model/add", summary="Add Device model", response_model=Resp) +def admin_device_model_add(): + """添加设备模型""" + return Resp(data={}) + + +@admin.delete("/device_model/{id}", summary="Delete device model", response_model=Resp) +def admin_device_model_del(id: str): + """删除设备模型""" + return Resp(data={}) + + +@admin.get("/devices", summary="Device list", response_model=Resp) +def admin_devices(): + """获取设备列表(管理员)""" + return Resp(data={}) + + +@admin.post("/devices/add", summary="Add Device", response_model=Resp) +def admin_device_add(): + """添加设备""" + return Resp(data={}) + + +@admin.delete("/devices/{id}", summary="Delete device", response_model=Resp) +def admin_device_del(id: str): + """删除设备""" + return Resp(data={}) + + +def setup_api_routes(app): + """设置API路由""" + app.include_router(admin, prefix="/admin/v1", tags=["admin"]) + app.include_router(api, prefix="/api/v1", tags=["api"]) + + # 启动广播任务 + @app.on_event("startup") + async def startup_event(): + asyncio.create_task(broadcast_device_status()) diff --git a/unilabos/web/client.py b/unilabos/web/client.py new file mode 100644 index 00000000..1957f5dd --- /dev/null +++ b/unilabos/web/client.py @@ -0,0 +1,107 @@ +""" +HTTP客户端模块 + +提供与远程服务器通信的客户端功能,只有host需要用 +""" + +from typing import List, Dict, Any, Optional + +import requests +from unilabos.utils.log import info +from unilabos.config.config import MQConfig, HTTPConfig + + +class HTTPClient: + """HTTP客户端,用于与远程服务器通信""" + + def __init__(self, remote_addr: Optional[str] = None, auth: Optional[str] = None) -> None: + """ + 初始化HTTP客户端 + + Args: + remote_addr: 远程服务器地址,如果不提供则从配置中获取 + auth: 授权信息 + """ + self.remote_addr = remote_addr or HTTPConfig.remote_addr + if auth is not None: + self.auth = auth + else: + self.auth = MQConfig.lab_id + info(f"HTTPClient 初始化完成: remote_addr={self.remote_addr}") + + def resource_add(self, resources: List[Dict[str, Any]]) -> requests.Response: + """ + 添加资源 + + Args: + resources: 要添加的资源列表 + + Returns: + Response: API响应对象 + """ + response = requests.post( + f"{self.remote_addr}/lab/resource/", + json=resources, + headers={"Authorization": f"lab {self.auth}"}, + timeout=5, + ) + return response + + def resource_get(self, id: str, with_children: bool = False) -> Dict[str, Any]: + """ + 获取资源 + + Args: + id: 资源ID + with_children: 是否包含子资源 + + Returns: + Dict: 返回的资源数据 + """ + response = requests.get( + f"{self.remote_addr}/lab/resource/", + params={"id": id, "with_children": with_children}, + headers={"Authorization": f"lab {self.auth}"}, + timeout=5, + ) + return response.json() + + def resource_del(self, id: str) -> requests.Response: + """ + 删除资源 + + Args: + id: 要删除的资源ID + + Returns: + Response: API响应对象 + """ + response = requests.delete( + f"{self.remote_addr}/lab/resource/batch_delete/", + params={"id": id}, + headers={"Authorization": f"lab {self.auth}"}, + timeout=5, + ) + return response + + def resource_update(self, resources: List[Dict[str, Any]]) -> requests.Response: + """ + 更新资源 + + Args: + resources: 要更新的资源列表 + + Returns: + Response: API响应对象 + """ + response = requests.patch( + f"{self.remote_addr}/lab/resource/batch_update/", + json=resources, + headers={"Authorization": f"lab {self.auth}"}, + timeout=5, + ) + return response + + +# 创建默认客户端实例 +http_client = HTTPClient() diff --git a/unilabos/web/pages.py b/unilabos/web/pages.py new file mode 100644 index 00000000..ecbe84f2 --- /dev/null +++ b/unilabos/web/pages.py @@ -0,0 +1,184 @@ +""" +Web页面模块 + +提供系统Web界面的页面定义 +""" + +import json +import os +import sys +from pathlib import Path +from typing import Dict + +from fastapi import APIRouter, HTTPException +from fastapi.responses import HTMLResponse, JSONResponse +from jinja2 import Environment, FileSystemLoader + +from unilabos.config.config import BasicConfig +from unilabos.registry.registry import lab_registry +from unilabos.app.mq import mqtt_client +from unilabos.ros.msgs.message_converter import msg_converter_manager +from unilabos.utils.log import error +from unilabos.utils.type_check import TypeEncoder +from unilabos.web.utils.device_utils import get_registry_info +from unilabos.web.utils.host_utils import get_host_node_info +from unilabos.web.utils.ros_utils import get_ros_node_info, update_ros_node_info + +# 设置Jinja2模板环境 +template_dir = Path(__file__).parent / "templates" +env = Environment(loader=FileSystemLoader(template_dir)) + + +def setup_web_pages(router: APIRouter) -> None: + """ + 设置Web页面路由 + + Args: + router: FastAPI路由器实例 + """ + # 在web服务启动时,尝试初始化ROS节点信息 + update_ros_node_info() + + @router.get("/", response_class=HTMLResponse, summary="Home Page") + async def home_page() -> str: + """ + 首页,显示所有可用的API路由 + + Returns: + HTMLResponse: 渲染后的HTML页面 + """ + try: + # 收集所有路由 + routes = [] + for route in router.routes: + if hasattr(route, "methods") and hasattr(route, "path"): + for method in list(getattr(route, "methods", [])): + path = getattr(route, "path", "") + # 只显示GET方法的路由作为链接 + if method == "GET": + name = getattr(route, "name", "") or path + summary = getattr(route, "summary", "") or name + routes.append({"method": method, "path": path, "name": name, "summary": summary}) + + # 使用模板渲染页面 + template = env.get_template("home.html") + html = template.render(routes=routes) + + return html + except Exception as e: + error(f"生成主页时出错: {str(e)}") + raise HTTPException(status_code=500, detail=f"Error generating home page: {str(e)}") + + @router.get("/status", response_class=HTMLResponse, summary="System Status") + async def status_page() -> str: + """ + 状态页面,显示系统状态信息 + + Returns: + HTMLResponse: 渲染后的HTML页面 + """ + try: + # 准备设备数据 + devices = [] + resources = [] + modules = {"names": [], "classes": [], "displayed_count": 0, "total_count": 0} + + # 获取在线设备信息 + ros_node_info = get_ros_node_info() + # 获取主机节点信息 + host_node_info = get_host_node_info() + # 获取Registry路径信息 + registry_info = get_registry_info() + + # 获取已加载的设备 + if lab_registry: + # 设备类型 + for device_id, device_info in lab_registry.device_type_registry.items(): + msg = { + "id": device_id, + "name": device_info.get("name", "未命名"), + "file_path": device_info.get("file_path", ""), + "class_json": json.dumps( + device_info.get("class", {}), indent=4, ensure_ascii=False, cls=TypeEncoder + ), + } + mqtt_client.publish_registry(device_id, device_info) + devices.append(msg) + + # 资源类型 + for resource_id, resource_info in lab_registry.resource_type_registry.items(): + resources.append( + { + "id": resource_id, + "name": resource_info.get("name", "未命名"), + "file_path": resource_info.get("file_path", ""), + } + ) + + # 获取导入的模块 + if msg_converter_manager: + modules["names"] = msg_converter_manager.list_modules() + all_classes = [i for i in msg_converter_manager.list_classes() if "." in i] + modules["total_count"] = len(all_classes) + modules["classes"] = all_classes + + # 使用模板渲染页面 + template = env.get_template("status.html") + html = template.render( + devices=devices, + resources=resources, + modules=modules, + is_host_mode=BasicConfig.is_host_mode, + registry_info=registry_info, + ros_node_info=ros_node_info, + host_node_info=host_node_info, + ) + + return html + except Exception as e: + error(f"生成状态页面时出错: {str(e)}") + raise HTTPException(status_code=500, detail=f"Error generating status page: {str(e)}") + + @router.get("/open-folder", response_class=JSONResponse, summary="Open Local Folder") + async def open_folder(path: str = "") -> Dict[str, str]: + """ + 打开本地文件夹 + + Args: + path: 要打开的文件夹路径 + + Returns: + JSONResponse: 操作结果 + + Raises: + HTTPException: 如果路径为空或不存在 + """ + if not path: + return {"status": "error", "message": "Path is empty"} + + try: + # 规范化路径 + norm_path = os.path.normpath(path) + + # 如果是文件路径,获取其目录 + if os.path.isfile(norm_path): + norm_path = os.path.dirname(norm_path) + + # 检查路径是否存在 + if not os.path.exists(norm_path): + return {"status": "error", "message": f"Path does not exist: {norm_path}"} + + # Windows + if os.name == "nt": + os.startfile(norm_path) + # macOS + elif sys.platform == "darwin": + os.system(f'open "{norm_path}"') + # Linux + else: + os.system(f'xdg-open "{norm_path}"') + + return {"status": "success", "message": f"Opened folder: {norm_path}"} + except Exception as e: + error(f"打开文件夹时出错: {str(e)}") + return {"status": "error", "message": f"Failed to open folder: {str(e)}"} diff --git a/unilabos/web/server.py b/unilabos/web/server.py new file mode 100644 index 00000000..723db9b4 --- /dev/null +++ b/unilabos/web/server.py @@ -0,0 +1,131 @@ +""" +Web服务器模块 + +提供Web服务器功能,网页信息服务 + mqtt代替 +""" + +import webbrowser + +import uvicorn +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from starlette.responses import Response + +from unilabos.utils.fastapi.log_adapter import setup_fastapi_logging +from unilabos.utils.log import info, error +from unilabos.web.api import setup_api_routes +from unilabos.web.pages import setup_web_pages + +# 创建FastAPI应用 +app = FastAPI( + title="UniLab API", + description="UniLab API Service", + docs_url="/api/docs", + redoc_url="/api/redoc", + openapi_url="/api/openapi.json", +) + +# 创建页面路由 +pages = None + +# noinspection PyTypeChecker +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], + allow_headers=["Authorization", "Content-Type", "Accept"], +) + + +@app.middleware("http") +async def log_requests(request: Request, call_next) -> Response: + """ + 记录HTTP请求日志的中间件 + + Args: + request: 当前HTTP请求对象 + call_next: 下一个处理函数 + + Returns: + Response: HTTP响应对象 + """ + # # 打印请求信息 + # info(f"[Web] Request: {request.method} {request.url}", stack_level=1) + # debug(f"[Web] Headers: {request.headers}", stack_level=1) + # + # # 使用日志模块记录请求体(如果需要) + # body = await request.body() + # if body: + # debug(f"[Web] Body: {body}", stack_level=1) + + # 调用下一个中间件或路由处理函数 + response = await call_next(request) + + # # 打印响应信息 + # info(f"[Web] Response status: {response.status_code}", stack_level=1) + + return response + + +def setup_server() -> FastAPI: + """ + 设置服务器 + + Returns: + FastAPI: 配置好的FastAPI应用实例 + """ + global pages + + # 创建页面路由 + if pages is None: + pages = app.router + + # 设置API路由 + setup_api_routes(app) + + # 设置页面路由 + try: + setup_web_pages(pages) + info("[Web] 已加载Web UI模块") + except ImportError as e: + info(f"[Web] 未找到Web页面模块: {str(e)}") + except Exception as e: + error(f"[Web] 加载Web页面模块时出错: {str(e)}") + + return app + + +def start_server(host: str = "0.0.0.0", port: int = 8002, open_browser: bool = True) -> None: + """ + 启动服务器 + + Args: + host: 服务器主机 + port: 服务器端口 + open_browser: 是否自动打开浏览器 + """ + # 设置服务器 + setup_server() + + # 配置日志 + log_config = setup_fastapi_logging() + + # 启动前打开浏览器 + if open_browser: + # noinspection HttpUrlsUsage + url = f"http://{host if host != '0.0.0.0' else 'localhost'}:{port}/status" + info(f"[Web] 正在打开浏览器访问: {url}") + try: + webbrowser.open(url) + except Exception as e: + error(f"[Web] 无法打开浏览器: {str(e)}") + + # 启动服务器 + info(f"[Web] 启动FastAPI服务器: {host}:{port}") + uvicorn.run(app, host=host, port=port, log_config=log_config) + + +# 当脚本直接运行时启动服务器 +if __name__ == "__main__": + start_server() diff --git a/unilabos/web/static/styles.css b/unilabos/web/static/styles.css new file mode 100644 index 00000000..0332da89 --- /dev/null +++ b/unilabos/web/static/styles.css @@ -0,0 +1,509 @@ +/* 基础样式 */ +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + line-height: 1.6; + color: #333; + background: #f5f5f5; + margin: 0; + padding: 0; +} + +/* 系统模式样式 */ +.system-mode-banner { + background: #f0f8ff; + padding: 8px 15px; + margin-bottom: 10px; + border-radius: 4px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.mode-indicator { + display: flex; + align-items: center; + justify-content: space-between; + padding: 5px 10px; + border-radius: 3px; + font-size: 14px; +} + +.mode-indicator.host-mode { + background-color: #e6f7ff; + border-left: 4px solid #1890ff; + color: #0050b3; +} + +.mode-indicator.slave-mode { + background-color: #fff7e6; + border-left: 4px solid #fa8c16; + color: #873800; +} + +.mode-detail { + font-size: 12px; + opacity: 0.8; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +header { + background: #2c3e50; + color: white; + padding: 20px; + text-align: center; + margin-bottom: 20px; + border-radius: 5px; +} + +header h1 { + margin: 0; + font-size: 24px; +} + +.card { + background: white; + border-radius: 5px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); + margin-bottom: 20px; + overflow: hidden; + padding: 20px; +} + +.card h2 { + color: #2c3e50; + margin-top: 0; + padding-bottom: 10px; + border-bottom: 1px solid #eee; +} + +/* 表格样式 */ +.responsive-table { + width: 100%; + border-collapse: collapse; + margin-bottom: 1rem; +} + +.responsive-table th, +.responsive-table td { + padding: 10px; + text-align: left; + border-bottom: 1px solid #ddd; +} + +.responsive-table th { + background-color: #f8f9fa; + font-weight: 600; +} + +.collapsible-row { + cursor: pointer; +} + +.collapsible-row:hover { + background-color: #f1f8ff; +} + +.collapsible-row.active { + background-color: #e6f7ff; + border-bottom: none; +} + +.detail-row td { + background-color: #f9f9f9; + padding: 0; +} + +.detail-row .content-full { + padding: 15px; +} + +.toggle-indicator, +.toggle-sub-indicator { + float: right; + color: #999; + transition: transform 0.2s; +} + +.collapsible-row.active .toggle-indicator { + transform: rotate(180deg); +} + +/* 主题样式 */ +.topics-container { + max-height: 300px; + overflow-y: auto; + border: 1px solid #eee; + border-radius: 4px; + background: #fcfcfc; +} + +.topics-list { + display: flex; + flex-wrap: wrap; + padding: 10px; +} + +.topic-item { + background: #f0f7ff; + border-radius: 4px; + margin: 5px; + padding: 5px 10px; + font-size: 13px; + display: flex; + align-items: center; + border: 1px solid #d6e8ff; +} + +.topic-name { + margin-right: 8px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 280px; +} + +/* 计数徽章 */ +.count-badge { + background: #e6f7ff; + color: #1890ff; + border-radius: 10px; + padding: 2px 8px; + font-size: 12px; + font-weight: normal; + margin-left: 5px; +} + +/* 主机节点区域 */ +.host-section { + margin-bottom: 20px; + padding-bottom: 15px; + border-bottom: 1px dashed #eee; +} + +.host-section:last-child { + border-bottom: none; + margin-bottom: 0; +} + +.host-section h3 { + color: #1890ff; + font-size: 1.1em; + margin-bottom: 10px; + display: flex; + align-items: center; +} + +/* 状态徽章 */ +.status-badge { + display: inline-block; + padding: 3px 8px; + border-radius: 3px; + font-size: 12px; + font-weight: 500; +} + +.status-badge.online { + background-color: #e6ffec; + color: #52c41a; + border: 1px solid #b7eb8f; +} + +.status-badge.offline { + background-color: #fff1f0; + color: #f5222d; + border: 1px solid #ffa39e; +} + +.status-badge.ready { + background-color: #e6ffec; + color: #52c41a; + border: 1px solid #b7eb8f; +} + +.status-badge.not-ready { + background-color: #fff7e6; + color: #fa8c16; + border: 1px solid #ffd591; +} + +/* 空状态提示 */ +.empty-state { + text-align: center; + padding: 20px; + color: #999; + font-style: italic; +} + +/* 内部表格 */ +.inner-table { + width: 100%; + border-collapse: collapse; + margin: 10px 0; +} + +.inner-table th, +.inner-table td { + padding: 8px; + border: 1px solid #eee; + font-size: 0.9em; +} + +.inner-table th { + background-color: #f8f9fa; + font-weight: 600; +} + +.topic-row, +.action-row { + cursor: pointer; +} + +.topic-row:hover, +.action-row:hover { + background-color: #f1f8ff; +} + +.cmd-row td { + padding: 0; +} + +.cmd-block { + background-color: #f5f5f5; + padding: 10px 15px; + border-radius: 3px; + margin: 5px 0; +} + +.cmd-line { + display: flex; + align-items: center; + margin: 10px 0; +} + +.cmd-line pre { + flex: 1; + background-color: #f1f1f1; + padding: 8px; + border-radius: 3px; + overflow-x: auto; + font-size: 13px; + margin: 0; + margin-right: 10px; +} + +.copy-btn { + background-color: #1890ff; + color: white; + border: none; + padding: 5px 10px; + border-radius: 3px; + cursor: pointer; + font-size: 12px; + transition: background-color 0.2s; +} + +.copy-btn:hover { + background-color: #096dd9; +} + +.copy-btn.small { + padding: 2px 6px; + font-size: 11px; +} + +.copy-btn.copy-success { + background-color: #52c41a; +} + +.goal-tip { + font-size: 12px; + color: #888; + margin: 5px 0 0 0; + font-style: italic; +} + +/* 文件路径样式 */ +.file-path { + position: relative; + max-width: 300px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.folder-link { + cursor: pointer; + margin-left: 8px; + color: #1890ff; +} + +.folder-link:hover { + color: #096dd9; +} + +/* 注册表路径样式 */ +.registry-info { + background-color: #f9f9f9; + padding: 15px; + border-radius: 5px; + margin-bottom: 20px; + font-size: 0.9em; +} + +.registry-path { + margin-bottom: 10px; +} + +.registry-path:last-child { + margin-bottom: 0; +} + +.path-list { + list-style: none; + padding-left: 10px; + margin: 5px 0; +} + +.path-list li { + margin-bottom: 5px; + display: flex; + align-items: center; +} + +.path { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* 导航标签 */ +.nav-tabs { + display: flex; + margin-bottom: 20px; + border-bottom: 1px solid #ddd; + overflow-x: auto; + white-space: nowrap; + padding-bottom: 1px; +} + +.nav-tab { + padding: 8px 16px; + color: #666; + text-decoration: none; + margin-right: 2px; + background-color: #f8f9fa; + border: 1px solid #ddd; + border-bottom: none; + border-radius: 4px 4px 0 0; + transition: all 0.2s; +} + +.nav-tab:hover { + background-color: #e9ecef; + color: #333; +} + +.nav-tab:active { + background-color: #fff; + border-bottom: 1px solid white; + margin-bottom: -1px; + color: #1890ff; +} + +/* 调试按钮 */ +.debug-btn { + background-color: #e8e8e8; + color: #666; + border: none; + padding: 5px 10px; + border-radius: 3px; + cursor: pointer; + font-size: 12px; + margin-left: 5px; + transition: background-color 0.2s; +} + +.debug-btn:hover { + background-color: #d9d9d9; +} + +.debug-info { + margin-top: 10px; + background-color: #fafafa; + border: 1px solid #eee; + padding: 10px; + border-radius: 3px; + font-size: 12px; + overflow: auto; + max-height: 200px; +} + +/* 返回顶部按钮 */ +#back-to-top { + display: none; + position: fixed; + bottom: 20px; + right: 20px; + background-color: #1890ff; + color: white; + width: 40px; + height: 40px; + border-radius: 50%; + text-align: center; + line-height: 40px; + font-size: 20px; + cursor: pointer; + border: none; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + z-index: 1000; + transition: all 0.3s; +} + +#back-to-top:hover { + background-color: #096dd9; + transform: translateY(-3px); +} + +/* 响应式设计 */ +@media (max-width: 768px) { + .responsive-table { + display: block; + overflow-x: auto; + } + + .card { + padding: 15px; + } + + .cmd-line { + flex-direction: column; + align-items: stretch; + } + + .cmd-line pre { + margin-right: 0; + margin-bottom: 10px; + } + + .topics-list { + flex-direction: column; + } + + .topic-item { + width: 100%; + box-sizing: border-box; + } + + .nav-tabs { + overflow-x: auto; + flex-wrap: nowrap; + } + + .nav-tab { + flex: 0 0 auto; + } +} \ No newline at end of file diff --git a/unilabos/web/templates/base.html b/unilabos/web/templates/base.html new file mode 100644 index 00000000..8e3a31d7 --- /dev/null +++ b/unilabos/web/templates/base.html @@ -0,0 +1,172 @@ + + + + {% block title %}UniLab{% endblock %} + + {% block scripts %}{% endblock %} + + +

{% block header %}UniLab{% endblock %}

+ {% block nav %} + Home + {% endblock %} + + {% block top_info %}{% endblock %} + + {% block content %}{% endblock %} + + \ No newline at end of file diff --git a/unilabos/web/templates/home.html b/unilabos/web/templates/home.html new file mode 100644 index 00000000..a95f9d6a --- /dev/null +++ b/unilabos/web/templates/home.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} + +{% block title %}UniLab API{% endblock %} + +{% block header %}UniLab API{% endblock %} + +{% block nav %} +System Status +{% endblock %} + +{% block content %} +
+

Available Endpoints

+ {% for route in routes %} +
+ {{ route.method }} + {{ route.path }} +

{{ route.summary }}

+
+ {% endfor %} +
+{% endblock %} \ No newline at end of file diff --git a/unilabos/web/templates/status.html b/unilabos/web/templates/status.html new file mode 100644 index 00000000..30c3e6b8 --- /dev/null +++ b/unilabos/web/templates/status.html @@ -0,0 +1,1241 @@ +{% extends "base.html" %} + +{% block title %}UniLab System Status{% endblock %} + +{% block header %}UniLab System Status{% endblock %} + +{% block top_info %} + +
+
+ 系统模式: {{ "主机模式 (HOST)" if is_host_mode else "从机模式 (SLAVE)" }} +
+
+ +{% if registry_info %} +
+ {% if registry_info.paths %} +
+ 注册表路径: +
    + {% for path in registry_info.paths %} +
  • + {{ path }} + 📁 +
  • + {% endfor %} +
+
+ {% endif %} + + {% if registry_info.devices_paths %} +
+ 设备目录: +
    + {% for path in registry_info.devices_paths %} +
  • + {{ path }} + 📁 +
  • + {% endfor %} +
+
+ {% endif %} + + {% if registry_info.device_comms_paths %} +
+ 设备通信目录: +
    + {% for path in registry_info.device_comms_paths %} +
  • + {{ path }} + 📁 +
  • + {% endfor %} +
+
+ {% endif %} + + {% if registry_info.resources_paths %} +
+ 资源目录: +
    + {% for path in registry_info.resources_paths %} +
  • + {{ path }} + 📁 +
  • + {% endfor %} +
+
+ {% endif %} +
+{% endif %} + + +{% endblock %} + +{% block content %} + +{% if is_host_mode and host_node_info.available %} +
+

主机节点信息

+ + +
+

已管理设备 {{ host_node_info.devices|length }}

+ + + + + + + {% for device_id, device_info in host_node_info.devices.items() %} + + + + + + {% else %} + + + + {% endfor %} +
设备ID命名空间状态
{{ device_id }}{{ device_info.namespace }}{{ "在线" if device_info.is_online else "离线" }}
没有发现已管理的设备
+
+ + +
+

动作客户端 {{ host_node_info.action_clients|length }}

+ +

已接纳动作:

+
+ + + + + + {% for action_name, action_info in host_node_info.action_clients.items() %} + + + + + + + + + {% endfor %} +
话题类型
{{ action_name }}{{ action_info.type_name }}
+ +
+ + +
+

已订阅主题 {{ host_node_info.subscribed_topics|length }}

+
+ {% if host_node_info.subscribed_topics %} +
+ {% for topic in host_node_info.subscribed_topics %} +
+ {{ topic }} + +
+ {% endfor %} +
+ {% else %} +
没有发现已订阅的主题
+ {% endif %} +
+
+ + + {% if host_node_info.device_status %} +
+

设备状态

+ + + + + + + + {% for device_id, properties in host_node_info.device_status.items() %} + {% for prop_name, prop_value in properties.items() %} + + {% if loop.first %} + + {% endif %} + + + + + {% endfor %} + {% else %} + + + + {% endfor %} +
设备ID属性最后更新
{{ device_id }}{{ prop_name }}{{ prop_value }} + {% if device_id in host_node_info.device_status_timestamps and prop_name in host_node_info.device_status_timestamps[device_id] %} + {% set ts_info = host_node_info.device_status_timestamps[device_id][prop_name] %} + {% if ts_info.elapsed >= 0 %} + {{ ts_info.elapsed }} 秒前 + {% else %} + 未更新 + {% endif %} + {% else %} + 无数据 + {% endif %} +
没有设备状态数据
+
+ {% endif %} +
+{% endif %} + + +
+

Local Devices

+ + + + + + + + + {% for device_id, device_info in ros_node_info.registered_devices.items() %} + {% set device_loop_index = loop.index %} + + + + + + + + + + + {% endfor %} +
Device ID节点名称命名空间状态项动作数
{{ device_id }}{{ device_info.node_name }}{{ device_info.namespace }}{{ ros_node_info.device_topics.get(device_id, {})|length }}{{ ros_node_info.device_actions.get(device_id, {})|length }}
+
+ + +
+

Device Types

+ + + + + + + + {% for device in devices %} + + + + + + + + + + {% endfor %} +
IDNameFile Path
{{ device.id }}{{ device.name }} + {{ device.file_path }} + 📁 +
+
+ + +
+

Resource Types

+ + + + + + + {% for resource in resources %} + + + + + + {% endfor %} +
IDNameFile Path
{{ resource.id }}{{ resource.name }} + {{ resource.file_path }} + 📁 +
+
+ + +
+

Converter Modules

+

Loaded Modules

+ + + + + {% for module in modules.names %} + + + + {% endfor %} +
Module Path
{{ module }}
+ +

Available Classes + ({{ modules.total_count }}) +

+ + + + + {% for class_name in modules.classes %} + + + + {% endfor %} +
Class Name
{{ class_name }}
+
+ + + +{% endblock %} + +{% block scripts %} +{{ super() }} + + +{% endblock %} \ No newline at end of file diff --git a/unilabos/web/utils/__init__.py b/unilabos/web/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/web/utils/action_utils.py b/unilabos/web/utils/action_utils.py new file mode 100644 index 00000000..1af458f5 --- /dev/null +++ b/unilabos/web/utils/action_utils.py @@ -0,0 +1,360 @@ +""" +Action 工具函数模块 + +提供处理 ROS Action 相关的辅助函数 +""" + +import traceback +from typing import Dict, Any, Type, TypedDict, Optional + +from rclpy.action import ActionClient, ActionServer +from rosidl_parser.definition import UnboundedSequence, NamespacedType, BasicType + +from unilabos.ros.msgs.message_converter import msg_converter_manager +from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode +from unilabos.utils import logger + + +class ActionInfoType(TypedDict): + type_name: str + type_name_convert: str + action_path: str + goal_info: str + + +def get_action_info( + v: ActionClient | ActionServer, name: Optional[str] = None, full_name: Optional[str] = None +) -> ActionInfoType: + # noinspection PyProtectedMember + n: BaseROS2DeviceNode = v._node + if full_name is None: + assert name is not None + full_name = n.namespace + "/" + name + # noinspection PyProtectedMember + return { + "type_name": v._action_type.__module__ + "." + v._action_type.__name__, + "type_name_convert": (v._action_type.__module__ + "." + v._action_type.__name__).replace(".", "/"), + "action_path": full_name, + "goal_info": get_yaml_from_goal_type(v._action_type.Goal), + } + + +def get_ros_msg_instance_as_dict(ros_msg_instance): + full_dict = {} + lower_dir = {i.lower(): i for i in dir(ros_msg_instance)} + for k in dir(ros_msg_instance): + if k == "SLOT_TYPES" or k.startswith("_") or k.endswith("__DEFAULT") or k in ["get_fields_and_field_types"]: + continue + v = getattr(ros_msg_instance, k) + if f"{k.lower()}__default" in lower_dir: + v_default = getattr(ros_msg_instance, lower_dir[f"{k.lower()}__default"]) + v = v_default + if isinstance(v, (str, int, float, list, dict)): + full_dict[k] = v + else: + full_dict[k] = get_ros_msg_instance_as_dict(v) + return full_dict + + +def get_yaml_from_goal_type(goal_type) -> str: + """从Goal类型对象中生成默认YAML格式字符串 + + Args: + goal_type: Goal类型对象 + + Returns: + str: 默认Goal参数的YAML格式字符串 + """ + if not goal_type: + return "{}" + + goal_dict = {} + slot_type = None + try: + for ind, slot_info in enumerate(goal_type._fields_and_field_types.items()): + slot_name, slot_type = slot_info + type_info = goal_type.SLOT_TYPES[ind] + default_value = "unknown" + if isinstance(type_info, UnboundedSequence): + inner_type = type_info.value_type + if isinstance(inner_type, NamespacedType): + cls_name = ".".join(inner_type.namespaces) + ":" + inner_type.name + type_class = msg_converter_manager.get_class(cls_name) + default_value = [get_ros_msg_instance_as_dict(type_class())] + elif isinstance(inner_type, BasicType): + default_value = [get_default_value_for_ros_type(inner_type.typename)] + else: + default_value = "unknown" + elif isinstance(type_info, NamespacedType): + cls_name = ".".join(type_info.namespaces) + ":" + type_info.name + type_class = msg_converter_manager.get_class(cls_name) + if type_class is None: + print("type_class", type_class, cls_name) + default_value = get_ros_msg_instance_as_dict(type_class()) + elif isinstance(type_info, BasicType): + default_value = get_default_value_for_ros_type(type_info.typename) + else: + type_class = msg_converter_manager.search_class(slot_type, search_lower=True) + if type_class is not None: + default_value = type_class().data + else: + default_value = "unknown" + goal_dict[slot_name] = default_value + except Exception as e: + logger.error(f"获取Goal字段 {slot_type} 信息时出错: {e}") + logger.error(traceback.format_exc()) + + # 将字典转换为YAML格式字符串 + yaml_str = "{" + + # 每个字段转换为YAML格式 + yaml_parts = [] + for key, value in goal_dict.items(): + if isinstance(value, str): + yaml_parts.append(f"{key}: '{value}'") + elif isinstance(value, bool): + yaml_parts.append(f"{key}: {str(value).lower()}") + elif isinstance(value, (int, float)): + yaml_parts.append(f"{key}: {value}") + elif isinstance(value, dict) and not value: + yaml_parts.append(f"{key}: {{}}") + else: + yaml_parts.append(f"{key}: {value}") + + yaml_str += ", ".join(yaml_parts) + "}" + + return yaml_str + + +"""旧版本函数""" + + +def get_default_value_for_ros_type(type_hint_or_str: Any) -> Any: + """生成基于ROS类型提示或字符串的默认值 + + 根据ROS2类型定义,生成适当的默认值。支持基本类型、数组类型和嵌套消息类型。 + + Args: + type_hint_or_str: ROS2类型提示或类型名称字符串 + + Returns: + Any: 对应类型的默认值 + """ + # 处理None或无效输入 + if type_hint_or_str is None: + return None + + # 基本类型映射 + type_str = str(type_hint_or_str).lower() # 使用字符串表示 + + # 处理常见基本类型 + if "int" in type_str: + return 0 + if "float" in type_str or "double" in type_str: + return 0.0 + if "bool" in type_str: + return False + if "string" in type_str: + return "" + if "byte" in type_str or "char" in type_str: + return 0 # 用整数表示 + if "time" == type_str or "duration" == type_str: + return {"sec": 0, "nanosec": 0} + + # 处理数组 - 返回空列表 + if "sequence" in type_str or "vector" in type_str or "[]" in type_str: + return [] + + # 处理嵌套消息类型 - 返回空字典占位符 + if "." in str(type_hint_or_str): + # 尝试用消息转换管理器查找类型并生成默认值 + try: + type_name = str(type_hint_or_str).strip().split("[")[0] # 移除数组部分 + # 尝试查找类型 + if msg_converter_manager: + type_class = msg_converter_manager.search_class(type_name) + if type_class: + # 递归生成默认值字典 + return generate_example_dict_from_ros_class(type_class) + except Exception as e: + print(f"查找类型默认值时出错: {type_hint_or_str}, {e}") + + # 如果找不到或出错,返回空字典 + return {} + + # 特殊类型的默认值 + if "pose" in type_str or "position" in type_str: + return {"x": 0.0, "y": 0.0, "z": 0.0} + if "orientation" in type_str or "quaternion" in type_str: + return {"x": 0.0, "y": 0.0, "z": 0.0, "w": 1.0} + if "header" in type_str: + return {"frame_id": "", "stamp": {"sec": 0, "nanosec": 0}} + + return None # 未知类型 + + +def generate_example_dict_from_ros_class(ros_class: Any) -> Dict[str, Any]: + """检查ROS消息/服务/动作类并生成带有默认值的字典 + + 分析ROS2消息类定义,提取其字段结构,并为每个字段生成合适的默认值。 + + Args: + ros_class: ROS2消息/服务/动作类或其实例 + + Returns: + Dict[str, Any]: 包含消息字段及默认值的字典 + """ + example_dict = {} + + # 检查是否已经是字典 + if isinstance(ros_class, dict): + return ros_class + + # 处理无效输入 + if ros_class is None: + return {} + + # 获取字段信息 + fields = {} + try: + if hasattr(ros_class, "_fields_and_field_types"): + fields = ros_class._fields_and_field_types + elif hasattr(ros_class, "__slots__") and hasattr(ros_class, "__annotations__"): + for slot in getattr(ros_class, "__slots__", []): + field_name = slot # 假设slot名称与字段名称匹配 + if field_name in getattr(ros_class, "__annotations__", {}): + fields[field_name] = ros_class.__annotations__[field_name] + else: + fields[field_name] = "unknown" # 如果缺少类型提示则使用默认值 + except Exception as e: + print(f"获取ROS类字段信息时出错: {e}") + return {} + + # 为每个字段生成默认值 + for field_name, field_type in fields.items(): + example_dict[field_name] = get_default_value_for_ros_type(field_type) + + return example_dict + + +def extract_action_structures(action_type: Type) -> Dict[str, Any]: + """从Action类型对象中提取Goal/Result/Feedback结构""" + result = {"goal": {}, "result": {}, "feedback": {}} + + try: + # 检查action_type是否为合法对象 + if hasattr(action_type, "Goal"): + # 获取Goal类及其字段 + goal_class = getattr(action_type, "Goal", None) + if goal_class: + result["goal"] = generate_example_dict_from_ros_class(goal_class) + + # 获取Result类及其字段 + result_class = getattr(action_type, "Result", None) + if result_class: + result["result"] = generate_example_dict_from_ros_class(result_class) + + # 获取Feedback类及其字段 + feedback_class = getattr(action_type, "Feedback", None) + if feedback_class: + result["feedback"] = generate_example_dict_from_ros_class(feedback_class) + except Exception as e: + print(f"提取Action结构时出错: {type(action_type)}") + print(traceback.format_exc()) + + return result + + +def process_device_actions(action_config: Dict[str, Any], action_type: Type, action_name: str) -> Dict[str, Any]: + """处理设备动作,生成命令示例和结构信息 + + Args: + action_config: 动作配置信息,包含topic等内容 + action_type: 动作类型,可以是类型对象或字符串 + action_name: 动作名称 + + Returns: + Dict[str, Any]: 包含命令示例和结构信息的字典 + """ + # 检查action_type是否为None或非法值 + if action_type is None: + # 返回基本结构,确保前端不会报错 + return { + "topic": action_config.get("topic", "UNKNOWN_TOPIC"), + "type_str": "UNKNOWN_TYPE", + "goal": "{}", + "full_command": f"ros2 action send_goal {action_config.get('topic', 'UNKNOWN_TOPIC')} UNKNOWN_TYPE '{{}}'", + "goal_dict": {}, + "result_dict": {}, + "feedback_dict": {}, + } + + # 提取类型路径字符串,从格式转换为package/action/ActionName + type_str = str(action_type)[8:-2] # 去除 + parts = type_str.split(".") + + # 构造ROS2类型字符串 + if len(parts) >= 3 and "action" in parts: + action_idx = parts.index("action") + if action_idx >= 0 and action_idx < len(parts) - 1: + package_name = parts[0] + action_class_name = parts[-1] + ros2_type_str = f"{package_name}/action/{action_class_name}" + else: + ros2_type_str = type_str.replace(".", "/") + else: + ros2_type_str = type_str.replace(".", "/") + + # 提取动作结构 + action_structures = extract_action_structures(action_type) + + # 获取goal部分并转换为YAML格式 + goal_dict = action_structures["goal"] + goal_yaml = dict_to_yaml_str(goal_dict) + + # 获取topic + topic = action_config.get("topic", "UNKNOWN_TOPIC") + + return { + "topic": topic, + "type_str": ros2_type_str, + "goal": goal_yaml, + "full_command": f"ros2 action send_goal {topic} {ros2_type_str} '{goal_yaml}'", + "goal_dict": goal_dict, + "result_dict": action_structures["result"], + "feedback_dict": action_structures["feedback"], + } + + +def dict_to_yaml_str(d: Dict) -> str: + """将字典转换为YAML字符串(单行格式) + + Args: + d: 要转换的字典 + + Returns: + str: YAML格式的字符串 + """ + if not d: + return "{}" + + parts = [] + + def format_value(v): + if isinstance(v, str): + return f"'{v}'" + elif isinstance(v, bool): + return str(v).lower() + elif isinstance(v, (int, float)) or v is None: + return str(v) + elif isinstance(v, list): + items = [format_value(item) for item in v] + return f"[{', '.join(items)}]" + elif isinstance(v, dict): + return dict_to_yaml_str(v) + return "null" + + for key, value in d.items(): + parts.append(f"{key}: {format_value(value)}") + + return "{" + ", ".join(parts) + "}" diff --git a/unilabos/web/utils/device_utils.py b/unilabos/web/utils/device_utils.py new file mode 100644 index 00000000..ff678eb1 --- /dev/null +++ b/unilabos/web/utils/device_utils.py @@ -0,0 +1,58 @@ +""" +设备工具函数模块 + +提供处理设备配置的辅助函数 +""" + +import json +from typing import Dict, Any + +# 这里不能循环导入 +# 在函数内部导入process_device_actions +# from unilabos.web.utils.action_utils import process_device_actions + + + +def get_registry_info() -> Dict[str, Any]: + """获取Registry相关路径信息 + + Returns: + 包含Registry路径信息的字典 + """ + from unilabos.registry.registry import lab_registry + from pathlib import Path + + registry_info = {} + + if lab_registry: + # 获取所有registry路径 + if hasattr(lab_registry, "registry_paths") and lab_registry.registry_paths: + # 保存所有注册表路径 + registry_info["paths"] = [str(path).replace("\\", "/") for path in lab_registry.registry_paths] + + # 获取设备和资源的相关路径 + for reg_path in lab_registry.registry_paths: + base_path = Path(reg_path) + + # 检查设备目录 + devices_path = base_path / "devices" + if devices_path.exists(): + if "devices_paths" not in registry_info: + registry_info["devices_paths"] = [] + registry_info["devices_paths"].append(str(devices_path).replace("\\", "/")) + + # 检查设备通信目录 + device_comms_path = base_path / "device_comms" + if device_comms_path.exists(): + if "device_comms_paths" not in registry_info: + registry_info["device_comms_paths"] = [] + registry_info["device_comms_paths"].append(str(device_comms_path).replace("\\", "/")) + + # 检查资源目录 + resources_path = base_path / "resources" + if resources_path.exists(): + if "resources_paths" not in registry_info: + registry_info["resources_paths"] = [] + registry_info["resources_paths"].append(str(resources_path).replace("\\", "/")) + + return registry_info diff --git a/unilabos/web/utils/host_utils.py b/unilabos/web/utils/host_utils.py new file mode 100644 index 00000000..7d241ae0 --- /dev/null +++ b/unilabos/web/utils/host_utils.py @@ -0,0 +1,68 @@ +""" +主机节点工具模块 + +提供与主机节点相关的工具函数 +""" + +import time +from typing import Dict, Any + +from unilabos.config.config import BasicConfig +from unilabos.ros.nodes.presets.host_node import HostNode +from unilabos.web.utils.action_utils import get_action_info + + +def get_host_node_info() -> Dict[str, Any]: + """ + 获取主机节点信息 + + 尝试获取HostNode实例并提取其设备、主题和动作客户端信息 + + Returns: + Dict: 包含主机节点信息的字典 + """ + host_info = {"available": False, "devices": {}, "subscribed_topics": [], "action_clients": {}} + if not BasicConfig.is_host_mode: + return host_info + # 尝试获取HostNode实例,设置超时为0秒 + host_node = HostNode.get_instance(0) + if not host_node: + return host_info + host_info["available"] = True + host_info["devices"] = { + device_id: { + "namespace": namespace, + "is_online": f"{namespace}/{device_id}" in host_node._online_devices, + "key": f"{namespace}/{device_id}" if namespace.startswith("/") else f"/{namespace}/{device_id}", + } + for device_id, namespace in host_node.devices_names.items() + } + # 获取已订阅的主题 + host_info["subscribed_topics"] = sorted(list(host_node._subscribed_topics)) + # 获取动作客户端信息 + 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["device_status"] = host_node.device_status + + # 添加设备状态更新时间戳 + current_time = time.time() + host_info["device_status_timestamps"] = {} + for device_id, properties in host_node.device_status_timestamps.items(): + host_info["device_status_timestamps"][device_id] = {} + for prop_name, timestamp in properties.items(): + if timestamp > 0: # 只处理有效的时间戳 + host_info["device_status_timestamps"][device_id][prop_name] = { + "timestamp": timestamp, + "elapsed": round(current_time - timestamp, 2), # 计算经过的时间(秒) + } + else: + host_info["device_status_timestamps"][device_id][prop_name] = { + "timestamp": 0, + "elapsed": -1, # 表示未曾更新过 + } + + return host_info diff --git a/unilabos/web/utils/ros_utils.py b/unilabos/web/utils/ros_utils.py new file mode 100644 index 00000000..713820fb --- /dev/null +++ b/unilabos/web/utils/ros_utils.py @@ -0,0 +1,68 @@ +""" +ROS 工具函数模块 + +提供处理 ROS 节点信息的辅助函数 +""" + +import traceback +from typing import Dict, Any + +from unilabos.web.utils.action_utils import get_action_info + +# 存储 ROS 节点信息的全局变量 +ros_node_info = {"online_devices": {}, "device_topics": {}, "device_actions": {}} + +def get_ros_node_info() -> Dict[str, Any]: + """获取 ROS 节点信息,包括设备节点、发布的状态和动作 + + Returns: + 包含 ROS 节点信息的字典 + """ + global ros_node_info + # 触发更新以获取最新信息 + update_ros_node_info() + return ros_node_info + + +def update_ros_node_info() -> Dict[str, Any]: + """更新 ROS 节点信息,使用全局设备注册表 + + Returns: + 更新后的 ROS 节点信息字典 + """ + global ros_node_info + result = {"registered_devices": {}, "device_topics": {}, "device_actions": {}} + + try: + from unilabos.ros.nodes.base_device_node import registered_devices + + for device_id, device_info in registered_devices.items(): + # 设备基本信息 + result["registered_devices"][device_id] = { + "node_name": device_info["node_name"], + "namespace": device_info["namespace"], + "uuid": device_info["uuid"], + } + + # 设备话题(状态)信息 + result["device_topics"][device_id] = { + k: { + "type_name": v.msg_type.__module__ + "." + v.msg_type.__name__, + "timer_period": v.timer_period, + "topic_path": device_info["base_node_instance"].namespace + "/" + v.name, + } + for k, v in device_info["status_publishers"].items() + } + + # 设备动作信息 + result["device_actions"][device_id] = { + k: get_action_info(v, k) + for k, v in device_info["actions"].items() + } + # 更新全局变量 + ros_node_info = result + except Exception as e: + print(f"更新ROS节点信息出错: {e}") + traceback.print_exc() + + return result diff --git a/unilabos_msgs/CMakeLists.txt b/unilabos_msgs/CMakeLists.txt new file mode 100644 index 00000000..acaad771 --- /dev/null +++ b/unilabos_msgs/CMakeLists.txt @@ -0,0 +1,89 @@ +cmake_minimum_required(VERSION 3.5) +project(unilabos_msgs) + +# Default to C99 +if(NOT CMAKE_C_STANDARD) + set(CMAKE_C_STANDARD 99) +endif() + +# Default to C++14 +if(NOT CMAKE_CXX_STANDARD) + set(CMAKE_CXX_STANDARD 14) +endif() + +if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") + add_compile_options(-Wall -Wextra -Wpedantic) +endif() + +# find dependencies +find_package(ament_cmake REQUIRED) +find_package(std_msgs REQUIRED) +find_package(rosidl_default_generators REQUIRED) +find_package(geometry_msgs REQUIRED) + +set(action_files + "action/SendCmd.action" + "action/Stir.action" + "action/HeatChill.action" + "action/HeatChillStart.action" + "action/HeatChillStop.action" + + "action/LiquidHandlerAspirate.action" + "action/LiquidHandlerDiscardTips.action" + "action/LiquidHandlerDispense.action" + "action/LiquidHandlerDropTips.action" + "action/LiquidHandlerDropTips96.action" + "action/LiquidHandlerMoveLid.action" + "action/LiquidHandlerMovePlate.action" + "action/LiquidHandlerMoveResource.action" + "action/LiquidHandlerPickUpTips.action" + "action/LiquidHandlerPickUpTips96.action" + "action/LiquidHandlerReturnTips.action" + "action/LiquidHandlerReturnTips96.action" + "action/LiquidHandlerStamp.action" + "action/LiquidHandlerTransfer.action" + + "action/PumpTransfer.action" + "action/Clean.action" + "action/Separate.action" + "action/Evaporate.action" + "action/EvacuateAndRefill.action" + + "action/WorkStationRun.action" + "action/AGVTransfer.action" +) + +set(srv_files + "srv/Stop.srv" + "srv/SerialCommand.srv" + "srv/ResourceGet.srv" + "srv/ResourceList.srv" + "srv/ResourceAdd.srv" + "srv/ResourceUpdate.srv" + "srv/ResourceDelete.srv" +) + +set(msg_files + "msg/State.msg" + "msg/Resource.msg" +) + +rosidl_generate_interfaces(${PROJECT_NAME} + ${msg_files} + ${srv_files} + ${action_files} + DEPENDENCIES std_msgs geometry_msgs +) + +if(BUILD_TESTING) + find_package(ament_lint_auto REQUIRED) + # the following line skips the linter which checks for copyrights + # uncomment the line when a copyright and license is not present in all source files + #set(ament_cmake_copyright_FOUND TRUE) + # the following line skips cpplint (only works in a git repo) + # uncomment the line when this package is not in a git repo + #set(ament_cmake_cpplint_FOUND TRUE) + ament_lint_auto_find_test_dependencies() +endif() + +ament_package() diff --git a/unilabos_msgs/CONTRIBUTING.md b/unilabos_msgs/CONTRIBUTING.md new file mode 100644 index 00000000..9eee72fe --- /dev/null +++ b/unilabos_msgs/CONTRIBUTING.md @@ -0,0 +1,3 @@ +Any contribution that you make to this repository will +be under the MIT license, as dictated by that +[license](https://opensource.org/licenses/MIT). diff --git a/unilabos_msgs/LICENSE b/unilabos_msgs/LICENSE new file mode 100644 index 00000000..30e8e2ec --- /dev/null +++ b/unilabos_msgs/LICENSE @@ -0,0 +1,17 @@ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/unilabos_msgs/README.md b/unilabos_msgs/README.md new file mode 100644 index 00000000..e117d105 --- /dev/null +++ b/unilabos_msgs/README.md @@ -0,0 +1,2 @@ +# unilabos_msgs +Messages, Services and Actions for interacting with Uni-Lab devices diff --git a/unilabos_msgs/action/AGVTransfer.action b/unilabos_msgs/action/AGVTransfer.action new file mode 100644 index 00000000..06c8c8ef --- /dev/null +++ b/unilabos_msgs/action/AGVTransfer.action @@ -0,0 +1,9 @@ +# MobileRobot +Resource from_repo +string from_repo_position +Resource to_repo +string to_repo_position +--- +bool success +--- +string status diff --git a/unilabos_msgs/action/Clean.action b/unilabos_msgs/action/Clean.action new file mode 100644 index 00000000..093a0dad --- /dev/null +++ b/unilabos_msgs/action/Clean.action @@ -0,0 +1,13 @@ +# Organic +string vessel # Vessel to clean. +string solvent # Solvent to clean vessel with. +float64 volume # Optional. Volume of solvent to clean vessel with. +float64 temp # Optional. Temperature to heat vessel to while cleaning. +int32 repeats # Optional. Number of cleaning cycles to perform. +--- +bool success +--- +string status +string current_device +builtin_interfaces/Duration time_spent +builtin_interfaces/Duration time_remaining diff --git a/unilabos_msgs/action/EvacuateAndRefill.action b/unilabos_msgs/action/EvacuateAndRefill.action new file mode 100644 index 00000000..ed138dd5 --- /dev/null +++ b/unilabos_msgs/action/EvacuateAndRefill.action @@ -0,0 +1,11 @@ +# Organic +string vessel +string gas +int32 repeats +--- +bool success +--- +string status +string current_device +builtin_interfaces/Duration time_spent +builtin_interfaces/Duration time_remaining \ No newline at end of file diff --git a/unilabos_msgs/action/Evaporate.action b/unilabos_msgs/action/Evaporate.action new file mode 100644 index 00000000..9638a9a8 --- /dev/null +++ b/unilabos_msgs/action/Evaporate.action @@ -0,0 +1,13 @@ +# Organic +string vessel +float64 pressure +float64 temp +float64 time +float64 stir_speed +--- +bool success +--- +string status +string current_device +builtin_interfaces/Duration time_spent +builtin_interfaces/Duration time_remaining diff --git a/unilabos_msgs/action/HeatChill.action b/unilabos_msgs/action/HeatChill.action new file mode 100644 index 00000000..1c7f8411 --- /dev/null +++ b/unilabos_msgs/action/HeatChill.action @@ -0,0 +1,11 @@ +# Organic +string vessel +float64 temp +float64 time +bool stir +float64 stir_speed +string purpose +--- +bool success +--- +string status \ No newline at end of file diff --git a/unilabos_msgs/action/HeatChillStart.action b/unilabos_msgs/action/HeatChillStart.action new file mode 100644 index 00000000..f9286937 --- /dev/null +++ b/unilabos_msgs/action/HeatChillStart.action @@ -0,0 +1,8 @@ +# Organic +string vessel +float64 temp +string purpose +--- +bool success +--- +string status \ No newline at end of file diff --git a/unilabos_msgs/action/HeatChillStop.action b/unilabos_msgs/action/HeatChillStop.action new file mode 100644 index 00000000..88fc0293 --- /dev/null +++ b/unilabos_msgs/action/HeatChillStop.action @@ -0,0 +1,6 @@ +# Organic +string vessel +--- +bool success +--- +string status \ No newline at end of file diff --git a/unilabos_msgs/action/LiquidHandlerAspirate.action b/unilabos_msgs/action/LiquidHandlerAspirate.action new file mode 100644 index 00000000..3784d943 --- /dev/null +++ b/unilabos_msgs/action/LiquidHandlerAspirate.action @@ -0,0 +1,12 @@ +# Bio +Resource[] resources +float64[] vols +int32[] use_channels +float64[] flow_rates +float64 end_delay +geometry_msgs/Point[] offsets +float64[] liquid_height +float64[] blow_out_air_volume +--- +bool success +--- \ No newline at end of file diff --git a/unilabos_msgs/action/LiquidHandlerDiscardTips.action b/unilabos_msgs/action/LiquidHandlerDiscardTips.action new file mode 100644 index 00000000..a7c6f8ae --- /dev/null +++ b/unilabos_msgs/action/LiquidHandlerDiscardTips.action @@ -0,0 +1,8 @@ +# Bio +# 请求字段 +int32[] use_channels +--- +# 结果字段 +bool success +--- +# 反馈字段 \ No newline at end of file diff --git a/unilabos_msgs/action/LiquidHandlerDispense.action b/unilabos_msgs/action/LiquidHandlerDispense.action new file mode 100644 index 00000000..f934aec2 --- /dev/null +++ b/unilabos_msgs/action/LiquidHandlerDispense.action @@ -0,0 +1,14 @@ +# Bio +# 请求字段 +Resource[] resources +float64[] vols +int32[] use_channels +float64[] flow_rates +geometry_msgs/Point[] offsets +int32[] blow_out_air_volume +string spread +--- +# 结果字段 +bool success +--- +# 反馈字段 \ No newline at end of file diff --git a/unilabos_msgs/action/LiquidHandlerDropTips.action b/unilabos_msgs/action/LiquidHandlerDropTips.action new file mode 100644 index 00000000..76a5625b --- /dev/null +++ b/unilabos_msgs/action/LiquidHandlerDropTips.action @@ -0,0 +1,11 @@ +# Bio +# 请求字段 +Resource[] tip_spots +int32[] use_channels +geometry_msgs/Point[] offsets +bool allow_nonzero_volume +--- +# 结果字段 +bool success +--- +# 反馈字段 \ No newline at end of file diff --git a/unilabos_msgs/action/LiquidHandlerDropTips96.action b/unilabos_msgs/action/LiquidHandlerDropTips96.action new file mode 100644 index 00000000..b4b7dfcf --- /dev/null +++ b/unilabos_msgs/action/LiquidHandlerDropTips96.action @@ -0,0 +1,10 @@ +# Bio +# 请求字段 +Resource tip_rack +geometry_msgs/Point offset +bool allow_nonzero_volume +--- +# 结果字段 +bool success +--- +# 反馈字段 \ No newline at end of file diff --git a/unilabos_msgs/action/LiquidHandlerMoveLid.action b/unilabos_msgs/action/LiquidHandlerMoveLid.action new file mode 100644 index 00000000..41a51e58 --- /dev/null +++ b/unilabos_msgs/action/LiquidHandlerMoveLid.action @@ -0,0 +1,17 @@ +# Bio +# 请求字段 +Resource lid +Resource to +geometry_msgs/Point[] intermediate_locations +geometry_msgs/Point resource_offset +geometry_msgs/Point destination_offset +string pickup_direction +string drop_direction +string get_direction +string put_direction +float64 pickup_distance_from_top +--- +# 结果字段 +bool success +--- +# 反馈字段 \ No newline at end of file diff --git a/unilabos_msgs/action/LiquidHandlerMovePlate.action b/unilabos_msgs/action/LiquidHandlerMovePlate.action new file mode 100644 index 00000000..ea7503a1 --- /dev/null +++ b/unilabos_msgs/action/LiquidHandlerMovePlate.action @@ -0,0 +1,18 @@ +# Bio +# 请求字段 +Resource plate +Resource to +geometry_msgs/Point[] intermediate_locations +geometry_msgs/Point resource_offset +geometry_msgs/Point pickup_offset +geometry_msgs/Point destination_offset +string pickup_direction +string drop_direction +string get_direction +string put_direction +float64 pickup_distance_from_top +--- +# 结果字段 +bool success +--- +# 反馈字段 \ No newline at end of file diff --git a/unilabos_msgs/action/LiquidHandlerMoveResource.action b/unilabos_msgs/action/LiquidHandlerMoveResource.action new file mode 100644 index 00000000..aaffa968 --- /dev/null +++ b/unilabos_msgs/action/LiquidHandlerMoveResource.action @@ -0,0 +1,17 @@ +# Bio +# 请求字段 +Resource resource +geometry_msgs/Point to +geometry_msgs/Point[] intermediate_locations +geometry_msgs/Point resource_offset +geometry_msgs/Point destination_offset +float64 pickup_distance_from_top +string pickup_direction +string drop_direction +string get_direction +string put_direction +--- +# 结果字段 +bool success +--- +# 反馈字段 \ No newline at end of file diff --git a/unilabos_msgs/action/LiquidHandlerPickUpTips.action b/unilabos_msgs/action/LiquidHandlerPickUpTips.action new file mode 100644 index 00000000..096bf17e --- /dev/null +++ b/unilabos_msgs/action/LiquidHandlerPickUpTips.action @@ -0,0 +1,10 @@ +# Bio +# 请求字段 +Resource[] tip_spots +int32[] use_channels +geometry_msgs/Point[] offsets +--- +# 结果字段 +bool success +--- +# 反馈字段 \ No newline at end of file diff --git a/unilabos_msgs/action/LiquidHandlerPickUpTips96.action b/unilabos_msgs/action/LiquidHandlerPickUpTips96.action new file mode 100644 index 00000000..761349a1 --- /dev/null +++ b/unilabos_msgs/action/LiquidHandlerPickUpTips96.action @@ -0,0 +1,9 @@ +# Bio +# 请求字段 +Resource tip_rack +geometry_msgs/Point offset +--- +# 结果字段 +bool success +--- +# 反馈字段 \ No newline at end of file diff --git a/unilabos_msgs/action/LiquidHandlerReturnTips.action b/unilabos_msgs/action/LiquidHandlerReturnTips.action new file mode 100644 index 00000000..25d15965 --- /dev/null +++ b/unilabos_msgs/action/LiquidHandlerReturnTips.action @@ -0,0 +1,9 @@ +# Bio +# 请求字段 +int32[] use_channels +bool allow_nonzero_volume +--- +# 结果字段 +bool success +--- +# 反馈字段 \ No newline at end of file diff --git a/unilabos_msgs/action/LiquidHandlerReturnTips96.action b/unilabos_msgs/action/LiquidHandlerReturnTips96.action new file mode 100644 index 00000000..fd20d712 --- /dev/null +++ b/unilabos_msgs/action/LiquidHandlerReturnTips96.action @@ -0,0 +1,8 @@ +# Bio +# 请求字段 +bool allow_nonzero_volume +--- +# 结果字段 +bool success +--- +# 反馈字段 \ No newline at end of file diff --git a/unilabos_msgs/action/LiquidHandlerStamp.action b/unilabos_msgs/action/LiquidHandlerStamp.action new file mode 100644 index 00000000..a7db4bf2 --- /dev/null +++ b/unilabos_msgs/action/LiquidHandlerStamp.action @@ -0,0 +1,12 @@ +# Bio +# 请求字段 +Resource source +Resource target +float64 volume +float64 aspiration_flow_rate +float64 dispense_flow_rate +--- +# 结果字段 +bool success +--- +# 反馈字段 \ No newline at end of file diff --git a/unilabos_msgs/action/LiquidHandlerTransfer.action b/unilabos_msgs/action/LiquidHandlerTransfer.action new file mode 100644 index 00000000..b6e3be32 --- /dev/null +++ b/unilabos_msgs/action/LiquidHandlerTransfer.action @@ -0,0 +1,11 @@ +# Bio +Resource source +Resource[] targets +float64 source_vol +float64[] ratios +float64[] target_vols +float64 aspiration_flow_rate +float64[] dispense_flow_rates +--- +bool success +--- \ No newline at end of file diff --git a/unilabos_msgs/action/PumpTransfer.action b/unilabos_msgs/action/PumpTransfer.action new file mode 100644 index 00000000..bbe6cb1e --- /dev/null +++ b/unilabos_msgs/action/PumpTransfer.action @@ -0,0 +1,18 @@ +# Organic +string from_vessel +string to_vessel +float64 volume +string amount +float64 time +bool viscous +string rinsing_solvent +float64 rinsing_volume +int32 rinsing_repeats +bool solid +--- +bool success +--- +string status +string current_device +builtin_interfaces/Duration time_spent +builtin_interfaces/Duration time_remaining diff --git a/unilabos_msgs/action/SendCmd.action b/unilabos_msgs/action/SendCmd.action new file mode 100644 index 00000000..cc883204 --- /dev/null +++ b/unilabos_msgs/action/SendCmd.action @@ -0,0 +1,6 @@ +# Simple +string command +--- +bool success +--- +string status diff --git a/unilabos_msgs/action/Separate.action b/unilabos_msgs/action/Separate.action new file mode 100644 index 00000000..502b420c --- /dev/null +++ b/unilabos_msgs/action/Separate.action @@ -0,0 +1,21 @@ +# Organic +string purpose # 'wash' or 'extract'. 'wash' means that product phase will not be the added solvent phase, 'extract' means product phase will be the added solvent phase. If no solvent is added just use 'extract'. +string product_phase # 'top' or 'bottom'. Phase that product will be in. +string from_vessel #Contents of from_vessel are transferred to separation_vessel and separation is performed. +string separation_vessel # Vessel in which separation of phases will be carried out. +string to_vessel # Vessel to send product phase to. +string waste_phase_to_vessel # Optional. Vessel to send waste phase to. +string solvent # Optional. Solvent to add to separation vessel after contents of from_vessel has been transferred to create two phases. +float64 solvent_volume # Optional. Volume of solvent to add. +string through # Optional. Solid chemical to send product phase through on way to to_vessel, e.g. 'celite'. +int32 repeats # Optional. Number of separations to perform. +float64 stir_time # Optional. Time stir for after adding solvent, before separation of phases. +float64 stir_speed # Optional. Speed to stir at after adding solvent, before separation of phases. +float64 settling_time # Optional. Time +--- +bool success +--- +string status +string current_device +builtin_interfaces/Duration time_spent +builtin_interfaces/Duration time_remaining diff --git a/unilabos_msgs/action/Stir.action b/unilabos_msgs/action/Stir.action new file mode 100644 index 00000000..defbed34 --- /dev/null +++ b/unilabos_msgs/action/Stir.action @@ -0,0 +1,8 @@ +# Organic +float64 stir_time +float64 stir_speed +float64 settling_time +--- +bool success +--- +string status \ No newline at end of file diff --git a/unilabos_msgs/action/WorkStationRun.action b/unilabos_msgs/action/WorkStationRun.action new file mode 100644 index 00000000..ea75668d --- /dev/null +++ b/unilabos_msgs/action/WorkStationRun.action @@ -0,0 +1,9 @@ +# MobileRobot +string wf_name +string params +Resource resource +--- +bool success +--- +string status +string gantt diff --git a/unilabos_msgs/msg/Resource.msg b/unilabos_msgs/msg/Resource.msg new file mode 100644 index 00000000..6d52a035 --- /dev/null +++ b/unilabos_msgs/msg/Resource.msg @@ -0,0 +1,11 @@ +string id +string name +string sample_id +string[] children +string parent +string type +string category + +geometry_msgs/Pose pose +string config +string data \ No newline at end of file diff --git a/unilabos_msgs/msg/State.msg b/unilabos_msgs/msg/State.msg new file mode 100644 index 00000000..abacac63 --- /dev/null +++ b/unilabos_msgs/msg/State.msg @@ -0,0 +1,3 @@ +std_msgs/Header header +string state_name +uint8 state diff --git a/unilabos_msgs/package.xml b/unilabos_msgs/package.xml new file mode 100644 index 00000000..4ccb3a9d --- /dev/null +++ b/unilabos_msgs/package.xml @@ -0,0 +1,28 @@ + + + + unilabos_msgs + 0.0.5 + ROS2 Messages package for unilabos devices + Junhan Chang + MIT + + ament_cmake + + action_msgs + std_msgs + geometry_msgs + + rosidl_default_generators + + rosidl_default_runtime + + ament_lint_auto + ament_lint_common + + rosidl_interface_packages + + + ament_cmake + + diff --git a/unilabos_msgs/srv/ResourceAdd.srv b/unilabos_msgs/srv/ResourceAdd.srv new file mode 100644 index 00000000..f37c5d3b --- /dev/null +++ b/unilabos_msgs/srv/ResourceAdd.srv @@ -0,0 +1,4 @@ +Resource[] resources +--- +bool success +string message \ No newline at end of file diff --git a/unilabos_msgs/srv/ResourceDelete.srv b/unilabos_msgs/srv/ResourceDelete.srv new file mode 100644 index 00000000..1d54edec --- /dev/null +++ b/unilabos_msgs/srv/ResourceDelete.srv @@ -0,0 +1,4 @@ +string id +--- +bool success +string message \ No newline at end of file diff --git a/unilabos_msgs/srv/ResourceGet.srv b/unilabos_msgs/srv/ResourceGet.srv new file mode 100644 index 00000000..b91c671e --- /dev/null +++ b/unilabos_msgs/srv/ResourceGet.srv @@ -0,0 +1,6 @@ +string id +bool with_children +--- +bool success +string message +Resource[] resources \ No newline at end of file diff --git a/unilabos_msgs/srv/ResourceList.srv b/unilabos_msgs/srv/ResourceList.srv new file mode 100644 index 00000000..130fac7f --- /dev/null +++ b/unilabos_msgs/srv/ResourceList.srv @@ -0,0 +1,4 @@ +--- +bool success +string message +Resource[] resources \ No newline at end of file diff --git a/unilabos_msgs/srv/ResourceUpdate.srv b/unilabos_msgs/srv/ResourceUpdate.srv new file mode 100644 index 00000000..f37c5d3b --- /dev/null +++ b/unilabos_msgs/srv/ResourceUpdate.srv @@ -0,0 +1,4 @@ +Resource[] resources +--- +bool success +string message \ No newline at end of file diff --git a/unilabos_msgs/srv/SerialCommand.srv b/unilabos_msgs/srv/SerialCommand.srv new file mode 100644 index 00000000..af02fff1 --- /dev/null +++ b/unilabos_msgs/srv/SerialCommand.srv @@ -0,0 +1,3 @@ +string command +--- +string response diff --git a/unilabos_msgs/srv/Stop.srv b/unilabos_msgs/srv/Stop.srv new file mode 100644 index 00000000..2c29c016 --- /dev/null +++ b/unilabos_msgs/srv/Stop.srv @@ -0,0 +1,4 @@ +string device_id +string command +--- +bool success