diff --git a/.conda/recipe.yaml b/.conda/recipe.yaml index fb4f1b8f..62b06337 100644 --- a/.conda/recipe.yaml +++ b/.conda/recipe.yaml @@ -1,6 +1,6 @@ package: name: unilabos - version: 0.10.7 + version: 0.10.12 source: path: ../unilabos diff --git a/CONTRIBUTORS b/CONTRIBUTORS index 9d0b395d..2bcdf372 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -1,15 +1,18 @@ - 156 Xuwznln <18435084+Xuwznln@users.noreply.github.com> - 39 Junhan Chang - 9 wznln <18435084+Xuwznln@users.noreply.github.com> - 8 Guangxin Zhang + 56 Xuwznln <18435084+Xuwznln@users.noreply.github.com> + 10 wznln <18435084+Xuwznln@users.noreply.github.com> + 6 Junhan Chang 5 ZiWei <131428629+ZiWei09@users.noreply.github.com> + 2 Guangxin Zhang 2 Junhan Chang - 2 Xie Qiming <97236197+Andy6M@users.noreply.github.com> + 2 WenzheG + 1 Harry Liu <113173203+ALITTLELZ@users.noreply.github.com> 1 Harvey Que <103566763+Mile-Away@users.noreply.github.com> 1 Junhan Chang <1700011741@pku.edu.cn> - 1 LccLink <1951855008@qq.com> - 1 h840473807 <47357934+h840473807@users.noreply.github.com> + 1 Xianwei Qi + 1 hh.(SII) <103566763+Mile-Away@users.noreply.github.com> 1 lixinyu1011 <61094742+lixinyu1011@users.noreply.github.com> - 1 shiyubo0410 + 1 q434343 <73513873+q434343@users.noreply.github.com> + 1 tt <166512503+tt11142023@users.noreply.github.com> + 1 xyc <49015816+xiaoyu10031@users.noreply.github.com> 1 王俊杰 <1800011822@pku.edu.cn> 1 王俊杰 <43375851+wjjxxx@users.noreply.github.com> diff --git a/MANIFEST.in b/MANIFEST.in index aa1a5d87..d81945e6 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,4 @@ +recursive-include unilabos/test * recursive-include unilabos/registry *.yaml recursive-include unilabos/app/web/static * recursive-include unilabos/app/web/templates * diff --git a/README.md b/README.md index 10552d6e..2e0288f3 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,9 @@ Uni-Lab-OS recommends using `mamba` for environment management. Choose the appro ```bash # Create new environment -mamba create -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge +mamba create -n unilab python=3.11.11 +mamba activate unilab +mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge ``` ## Install Dev Uni-Lab-OS diff --git a/README_zh.md b/README_zh.md index 810e2c55..76976ebf 100644 --- a/README_zh.md +++ b/README_zh.md @@ -41,7 +41,9 @@ Uni-Lab-OS 建议使用 `mamba` 管理环境。根据您的操作系统选择适 ```bash # 创建新环境 -mamba create -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge +mamba create -n unilab python=3.11.11 +mamba activate unilab +mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge ``` 2. 安装开发版 Uni-Lab-OS: diff --git a/docs/advanced_usage/configuration.md b/docs/advanced_usage/configuration.md new file mode 100644 index 00000000..3440044c --- /dev/null +++ b/docs/advanced_usage/configuration.md @@ -0,0 +1,726 @@ +# Uni-Lab 配置指南 + +本文档详细介绍 Uni-Lab 配置文件的结构、配置项、命令行覆盖和环境变量的使用方法。 + +## 配置文件概述 + +Uni-Lab 使用 Python 格式的配置文件(`.py`),默认为 `unilabos_data/local_config.py`。配置文件采用类属性的方式定义各种配置项,比 YAML 或 JSON 提供更多的灵活性,包括支持注释、条件逻辑和复杂数据结构。 + +## 获取实验室密钥 + +在配置文件或启动命令中,您需要提供实验室的访问密钥(ak)和私钥(sk)。 + +**获取方式:** + +进入 [Uni-Lab 实验室](https://uni-lab.bohrium.com),点击左下角的头像,在实验室详情中获取所在实验室的 ak 和 sk: + +![copy_aksk.gif](image/copy_aksk.gif) + +## 配置文件格式 + +### 默认配置示例 + +首次使用时,系统会自动创建一个基础配置文件 `local_config.py`: + +```python +# unilabos的配置文件 + +class BasicConfig: + ak = "" # 实验室网页给您提供的ak代码 + sk = "" # 实验室网页给您提供的sk代码 + + +# WebSocket配置,一般无需调整 +class WSConfig: + reconnect_interval = 5 # 重连间隔(秒) + max_reconnect_attempts = 999 # 最大重连次数 + ping_interval = 30 # ping间隔(秒) +``` + +### 完整配置示例 + +您可以根据需要添加更多配置选项: + +```python +#!/usr/bin/env python +# coding=utf-8 +"""Uni-Lab 配置文件""" + +# 基础配置 +class BasicConfig: + ak = "" # 实验室访问密钥 + sk = "" # 实验室私钥 + working_dir = "" # 工作目录(通常自动设置) + config_path = "" # 配置文件路径(自动设置) + is_host_mode = True # 是否为主站模式 + slave_no_host = False # 从站模式下是否跳过等待主机服务 + upload_registry = False # 是否上传注册表 + machine_name = "undefined" # 机器名称(自动获取) + vis_2d_enable = False # 是否启用2D可视化 + enable_resource_load = True # 是否启用资源加载 + communication_protocol = "websocket" # 通信协议 + log_level = "DEBUG" # 日志级别:TRACE, DEBUG, INFO, WARNING, ERROR, CRITICAL + +# WebSocket配置 +class WSConfig: + reconnect_interval = 5 # 重连间隔(秒) + max_reconnect_attempts = 999 # 最大重连次数 + ping_interval = 30 # ping间隔(秒) + +# HTTP配置 +class HTTPConfig: + remote_addr = "https://uni-lab.bohrium.com/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", + ] # 需要加载的ROS模块 +``` + +## 配置优先级 + +配置项的生效优先级从高到低为: + +1. **命令行参数**:最高优先级 +2. **环境变量**:中等优先级 +3. **配置文件**:基础优先级 + +这意味着命令行参数会覆盖环境变量和配置文件,环境变量会覆盖配置文件。 + +## 推荐配置方式 + +根据参数特性,不同配置项有不同的推荐配置方式: + +### 建议通过命令行指定的参数(不需要写入配置文件) + +以下参数推荐通过命令行或环境变量指定,**一般不需要在配置文件中配置**: + +| 参数 | 命令行参数 | 原因 | +| ----------------- | ------------------- | ------------------------------------ | +| `ak` / `sk` | `--ak` / `--sk` | **安全考虑**:避免敏感信息泄露 | +| `working_dir` | `--working_dir` | **灵活性**:不同环境可能使用不同目录 | +| `is_host_mode` | `--is_slave` | **运行模式**:由启动场景决定,不固定 | +| `slave_no_host` | `--slave_no_host` | **运行模式**:从站特殊配置,按需使用 | +| `upload_registry` | `--upload_registry` | **临时操作**:仅首次启动或更新时需要 | +| `vis_2d_enable` | `--2d_vis` | **调试功能**:按需临时启用 | +| `remote_addr` | `--addr` | **环境切换**:测试/生产环境快速切换 | + +**推荐用法示例:** + +```bash +# 标准启动命令(所有必要参数通过命令行指定) +unilab --ak your_ak --sk your_sk -g graph.json + +# 测试环境 +unilab --addr test --ak your_ak --sk your_sk -g graph.json + +# 从站模式 +unilab --is_slave --ak your_ak --sk your_sk + +# 首次启动上传注册表 +unilab --ak your_ak --sk your_sk -g graph.json --upload_registry +``` + +### 适合在配置文件中配置的参数 + +以下参数适合在配置文件中配置,通常不会频繁更改: + +| 参数 | 配置类 | 说明 | +| ------------------------ | ----------- | ---------------------- | +| `log_level` | BasicConfig | 日志级别配置 | +| `reconnect_interval` | WSConfig | WebSocket 重连间隔 | +| `max_reconnect_attempts` | WSConfig | WebSocket 最大重连次数 | +| `ping_interval` | WSConfig | WebSocket 心跳间隔 | +| `modules` | ROSConfig | ROS 模块列表 | + +**配置文件示例(推荐最小配置):** + +```python +# unilabos的配置文件 + +class BasicConfig: + log_level = "INFO" # 生产环境建议 INFO,调试时用 DEBUG + +# WebSocket配置,一般保持默认即可 +class WSConfig: + reconnect_interval = 5 + max_reconnect_attempts = 999 + ping_interval = 30 +``` + +**注意:** `ak` 和 `sk` 不建议写在配置文件中,始终通过命令行参数或环境变量传递。 + +## 命令行参数覆盖配置 + +Uni-Lab 允许通过命令行参数覆盖配置文件中的设置,提供更灵活的配置方式。 + +### 支持命令行覆盖的配置项 + +| 配置类 | 配置字段 | 命令行参数 | 说明 | +| ------------- | ----------------- | ------------------- | -------------------------------- | +| `BasicConfig` | `ak` | `--ak` | 实验室访问密钥 | +| `BasicConfig` | `sk` | `--sk` | 实验室私钥 | +| `BasicConfig` | `working_dir` | `--working_dir` | 工作目录路径 | +| `BasicConfig` | `is_host_mode` | `--is_slave` | 主站模式(参数为从站模式,取反) | +| `BasicConfig` | `slave_no_host` | `--slave_no_host` | 从站模式下跳过等待主机服务 | +| `BasicConfig` | `upload_registry` | `--upload_registry` | 启动时上传注册表信息 | +| `BasicConfig` | `vis_2d_enable` | `--2d_vis` | 启用 2D 可视化 | +| `HTTPConfig` | `remote_addr` | `--addr` | 远程服务地址 | + +### 特殊命令行参数 + +除了直接覆盖配置项的参数外,还有一些特殊的命令行参数: + +| 参数 | 说明 | +| ------------------- | ------------------------------------ | +| `--config` | 指定配置文件路径 | +| `--port` | Web 服务端口(不影响配置文件) | +| `--disable_browser` | 禁用自动打开浏览器(不影响配置文件) | +| `--visual` | 可视化工具选择(不影响配置文件) | +| `--skip_env_check` | 跳过环境检查(不影响配置文件) | + +### 命令行覆盖使用示例 + +```bash +# 通过命令行覆盖认证信息 +unilab --ak "new_access_key" --sk "new_secret_key" -g graph.json + +# 覆盖服务器地址 +unilab --ak ak --sk sk --addr "https://custom.server.com/api/v1" -g graph.json + +# 启用从站模式并跳过等待主机 +unilab --is_slave --slave_no_host --ak ak --sk sk + +# 启用上传注册表和2D可视化 +unilab --upload_registry --2d_vis --ak ak --sk sk -g graph.json + +# 组合使用多个覆盖参数 +unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis -g graph.json +``` + +### 预设环境地址 + +`--addr` 参数支持以下预设值,会自动转换为对应的完整 URL: + +- `test` → `https://uni-lab.test.bohrium.com/api/v1` +- `uat` → `https://uni-lab.uat.bohrium.com/api/v1` +- `local` → `http://127.0.0.1:48197/api/v1` +- 其他值 → 直接使用作为完整 URL + +## 配置选项详解 + +### 1. BasicConfig - 基础配置 + +基础配置包含了系统运行的核心参数: + +| 参数 | 类型 | 默认值 | 说明 | +| ------------------------ | ---- | ------------- | ------------------------------------------ | +| `ak` | str | `""` | 实验室访问密钥(必需) | +| `sk` | str | `""` | 实验室私钥(必需) | +| `working_dir` | str | `""` | 工作目录,通常自动设置 | +| `config_path` | str | `""` | 配置文件路径,自动设置 | +| `is_host_mode` | bool | `True` | 是否为主站模式 | +| `slave_no_host` | bool | `False` | 从站模式下是否跳过等待主机服务 | +| `upload_registry` | bool | `False` | 启动时是否上传注册表信息 | +| `machine_name` | str | `"undefined"` | 机器名称,自动从 hostname 获取(不可配置) | +| `vis_2d_enable` | bool | `False` | 是否启用 2D 可视化 | +| `enable_resource_load` | bool | `True` | 是否启用资源加载 | +| `communication_protocol` | str | `"websocket"` | 通信协议,固定为 websocket | +| `log_level` | str | `"DEBUG"` | 日志级别 | + +#### 日志级别选项 + +- `TRACE` - 追踪级别(最详细) +- `DEBUG` - 调试级别(默认) +- `INFO` - 信息级别 +- `WARNING` - 警告级别 +- `ERROR` - 错误级别 +- `CRITICAL` - 严重错误级别(最简略) + +#### 认证配置(ak / sk) + +`ak` 和 `sk` 是必需的认证参数: + +1. **获取方式**:在 [Uni-Lab 官网](https://uni-lab.bohrium.com) 注册实验室后获得 +2. **配置方式**: + - **命令行参数**:`--ak "your_key" --sk "your_secret"`(最高优先级,推荐) + - **环境变量**:`UNILABOS_BASICCONFIG_AK` 和 `UNILABOS_BASICCONFIG_SK` + - **配置文件**:在 `BasicConfig` 类中设置(不推荐,安全风险) +3. **安全注意**:请妥善保管您的密钥信息,不要提交到版本控制 + +**推荐做法**: + +- **开发环境**:使用命令行参数或环境变量 +- **生产环境**:使用环境变量 +- **临时测试**:使用命令行参数 + +### 2. WSConfig - WebSocket 配置 + +WebSocket 是 Uni-Lab 的主要通信方式: + +| 参数 | 类型 | 默认值 | 说明 | +| ------------------------ | ---- | ------ | ------------------ | +| `reconnect_interval` | int | `5` | 断线重连间隔(秒) | +| `max_reconnect_attempts` | int | `999` | 最大重连次数 | +| `ping_interval` | int | `30` | 心跳检测间隔(秒) | + +### 3. HTTPConfig - HTTP 配置 + +HTTP 客户端配置用于与云端服务通信: + +| 参数 | 类型 | 默认值 | 说明 | +| ------------- | ---- | -------------------------------------- | ------------ | +| `remote_addr` | str | `"https://uni-lab.bohrium.com/api/v1"` | 远程服务地址 | + +**预设环境地址**: + +- 生产环境:`https://uni-lab.bohrium.com/api/v1`(默认) +- 测试环境:`https://uni-lab.test.bohrium.com/api/v1` +- UAT 环境:`https://uni-lab.uat.bohrium.com/api/v1` +- 本地环境:`http://127.0.0.1:48197/api/v1` + +### 4. ROSConfig - ROS 配置 + +配置 ROS 消息转换器需要加载的模块: + +| 配置项 | 类型 | 默认值 | 说明 | +| --------- | ---- | ---------- | ------------ | +| `modules` | list | 见下方示例 | ROS 模块列表 | + +**默认模块列表:** + +```python +class ROSConfig: + modules = [ + "std_msgs.msg", # 标准消息类型 + "geometry_msgs.msg", # 几何消息类型 + "control_msgs.msg", # 控制消息类型 + "control_msgs.action", # 控制动作类型 + "nav2_msgs.action", # 导航动作类型 + "unilabos_msgs.msg", # UniLab 自定义消息类型 + "unilabos_msgs.action", # UniLab 自定义动作类型 + ] +``` + +您可以根据实际使用的设备和功能添加其他 ROS 模块。 + +## 环境变量配置 + +Uni-Lab 支持通过环境变量覆盖配置文件中的设置。 + +### 环境变量命名规则 + +``` +UNILABOS_<配置类名>_<配置项名> +``` + +**注意:** + +- 环境变量名不区分大小写 +- 配置类名和配置项名都会转换为大写进行匹配 + +### 设置环境变量 + +#### Linux / macOS + +```bash +# 临时设置(当前终端) +export UNILABOS_BASICCONFIG_LOG_LEVEL=INFO +export UNILABOS_BASICCONFIG_AK="your_access_key" +export UNILABOS_BASICCONFIG_SK="your_secret_key" + +# 永久设置(添加到 ~/.bashrc 或 ~/.zshrc) +echo 'export UNILABOS_BASICCONFIG_LOG_LEVEL=INFO' >> ~/.bashrc +source ~/.bashrc +``` + +#### Windows (cmd) + +```cmd +# 临时设置 +set UNILABOS_BASICCONFIG_LOG_LEVEL=INFO +set UNILABOS_BASICCONFIG_AK=your_access_key + +# 永久设置(系统环境变量) +setx UNILABOS_BASICCONFIG_LOG_LEVEL INFO +``` + +#### Windows (PowerShell) + +```powershell +# 临时设置 +$env:UNILABOS_BASICCONFIG_LOG_LEVEL="INFO" +$env:UNILABOS_BASICCONFIG_AK="your_access_key" + +# 永久设置 +[Environment]::SetEnvironmentVariable("UNILABOS_BASICCONFIG_LOG_LEVEL", "INFO", "User") +``` + +### 环境变量类型转换 + +系统会根据配置项的原始类型自动转换环境变量值: + +| 原始类型 | 转换规则 | +| -------- | --------------------------------------- | +| `bool` | "true", "1", "yes" → True;其他 → False | +| `int` | 转换为整数 | +| `float` | 转换为浮点数 | +| `str` | 直接使用字符串值 | + +**示例:** + +```bash +# 布尔值 +export UNILABOS_BASICCONFIG_IS_HOST_MODE=true # 将设置为 True +export UNILABOS_BASICCONFIG_IS_HOST_MODE=false # 将设置为 False + +# 整数 +export UNILABOS_WSCONFIG_RECONNECT_INTERVAL=10 # 将设置为 10 + +# 字符串 +export UNILABOS_BASICCONFIG_LOG_LEVEL=INFO # 将设置为 "INFO" +``` + +### 环境变量示例 + +```bash +# 设置基础配置 +export UNILABOS_BASICCONFIG_AK="your_access_key" +export UNILABOS_BASICCONFIG_SK="your_secret_key" +export UNILABOS_BASICCONFIG_IS_HOST_MODE="true" + +# 设置WebSocket配置 +export UNILABOS_WSCONFIG_RECONNECT_INTERVAL="10" +export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS="500" + +# 设置HTTP配置 +export UNILABOS_HTTPCONFIG_REMOTE_ADDR="https://uni-lab.test.bohrium.com/api/v1" +``` + +## 配置文件使用方法 + +### 1. 使用默认配置文件(推荐) + +系统会自动查找并加载配置文件: + +```bash +# 直接启动,使用默认的 unilabos_data/local_config.py +unilab --ak your_ak --sk your_sk -g graph.json +``` + +查找顺序: + +1. 环境变量 `UNILABOS_BASICCONFIG_CONFIG_PATH` 指定的路径 +2. 工作目录下的 `local_config.py` +3. 首次使用时会引导创建配置文件 + +### 2. 指定配置文件启动 + +```bash +# 使用指定配置文件启动 +unilab --config /path/to/your/config.py --ak ak --sk sk -g graph.json +``` + +### 3. 配置文件验证 + +系统启动时会自动验证配置文件: + +- **语法检查**:确保 Python 语法正确 +- **类型检查**:验证配置项类型是否匹配 +- **加载确认**:控制台输出加载成功信息 + +## 常用配置场景 + +### 场景 1:调整日志级别 + +**配置文件方式:** + +```python +class BasicConfig: + log_level = "INFO" # 生产环境建议使用 INFO 或 WARNING +``` + +**环境变量方式:** + +```bash +export UNILABOS_BASICCONFIG_LOG_LEVEL=INFO +unilab --ak ak --sk sk -g graph.json +``` + +**命令行方式**(需要配置文件已包含): + +```bash +# 配置文件无直接命令行参数,需通过环境变量 +UNILABOS_BASICCONFIG_LOG_LEVEL=INFO unilab --ak ak --sk sk -g graph.json +``` + +### 场景 2:配置 WebSocket 重连 + +**配置文件方式:** + +```python +class WSConfig: + reconnect_interval = 10 # 增加重连间隔到 10 秒 + max_reconnect_attempts = 100 # 减少最大重连次数到 100 次 +``` + +**环境变量方式:** + +```bash +export UNILABOS_WSCONFIG_RECONNECT_INTERVAL=10 +export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS=100 +``` + +### 场景 3:切换服务器环境 + +**配置文件方式:** + +```python +class HTTPConfig: + remote_addr = "https://uni-lab.test.bohrium.com/api/v1" +``` + +**环境变量方式:** + +```bash +export UNILABOS_HTTPCONFIG_REMOTE_ADDR=https://uni-lab.test.bohrium.com/api/v1 +``` + +**命令行方式(推荐):** + +```bash +unilab --addr test --ak your_ak --sk your_sk -g graph.json +``` + +### 场景 4:从站模式配置 + +**配置文件方式:** + +```python +class BasicConfig: + is_host_mode = False # 从站模式 + slave_no_host = True # 不等待主机服务 +``` + +**命令行方式(推荐):** + +```bash +unilab --is_slave --slave_no_host --ak your_ak --sk your_sk +``` + +## 最佳实践 + +### 1. 安全配置 + +**不要在配置文件中存储敏感信息** + +- ❌ **不推荐**:在配置文件中明文存储 ak/sk +- ✅ **推荐**:使用环境变量或命令行参数 + +```bash +# 生产环境 - 使用环境变量(推荐) +export UNILABOS_BASICCONFIG_AK="your_access_key" +export UNILABOS_BASICCONFIG_SK="your_secret_key" +unilab -g graph.json + +# 或使用命令行参数 +unilab --ak "your_access_key" --sk "your_secret_key" -g graph.json +``` + +**其他安全建议:** + +- 不要将包含密钥的配置文件提交到版本控制系统 +- 限制配置文件权限:`chmod 600 local_config.py` +- 定期更换访问密钥 +- 使用 `.gitignore` 排除配置文件 + +### 2. 多环境配置 + +为不同环境创建不同的配置文件: + +``` +configs/ +├── base_config.py # 基础配置(非敏感) +├── dev_config.py # 开发环境 +├── test_config.py # 测试环境 +├── prod_config.py # 生产环境 +└── example_config.py # 示例配置 +``` + +**环境切换示例**: + +```bash +# 本地开发环境 +unilab --config configs/dev_config.py --addr local --ak ak --sk sk -g graph.json + +# 测试环境 +unilab --config configs/test_config.py --addr test --ak ak --sk sk --upload_registry -g graph.json + +# 生产环境 +unilab --config configs/prod_config.py --ak "$PROD_AK" --sk "$PROD_SK" -g graph.json +``` + +### 3. 配置管理 + +**配置文件最佳实践:** + +- 保持配置文件简洁,只包含需要修改的配置项 +- 为配置项添加注释说明其作用 +- 定期检查和更新配置文件 +- 版本控制仅保存示例配置,不包含实际密钥 + +**命令行参数优先使用场景:** + +- 临时测试不同配置 +- CI/CD 流水线中的动态配置 +- 不同环境间快速切换 +- 敏感信息的安全传递 + +### 4. 灵活配置策略 + +**基础配置文件 + 命令行覆盖**的推荐方式: + +```python +# base_config.py - 基础配置(非敏感信息) +class BasicConfig: + # 非敏感配置写在文件中 + is_host_mode = True + upload_registry = False + vis_2d_enable = False + log_level = "INFO" + +class WSConfig: + reconnect_interval = 5 + max_reconnect_attempts = 999 + ping_interval = 30 +``` + +```bash +# 启动时通过命令行覆盖关键参数 +unilab --config base_config.py \ + --ak "$AK" \ + --sk "$SK" \ + --addr "test" \ + --upload_registry \ + --2d_vis \ + -g graph.json +``` + +## 故障排除 + +### 1. 配置文件加载失败 + +**错误信息**:`[ENV] 配置文件 xxx 不存在` + +**解决方法**: + +- 确认配置文件路径正确 +- 检查文件权限是否可读 +- 确保配置文件是 `.py` 格式 +- 使用绝对路径或相对于当前目录的路径 + +### 2. 语法错误 + +**错误信息**:`[ENV] 加载配置文件 xxx 失败` + +**解决方法**: + +- 检查 Python 语法是否正确 +- 确认类名和字段名拼写正确 +- 验证缩进是否正确(使用空格而非制表符) +- 确保字符串使用引号包裹 + +### 3. 认证失败 + +**错误信息**:`后续运行必须拥有一个实验室` + +**解决方法**: + +- 确认 `ak` 和 `sk` 已正确配置 +- 检查密钥是否有效(未过期或撤销) +- 确认网络连接正常 +- 验证密钥是否来自正确的实验室 + +### 4. 环境变量不生效 + +**解决方法**: + +- 确认环境变量名格式正确(`UNILABOS_<类名>_<字段名>`) +- 检查环境变量是否已正确设置(`echo $VARIABLE_NAME`) +- 重启终端或重新加载环境变量 +- 确认环境变量值的类型正确 + +### 5. 命令行参数不生效 + +**错误现象**:设置了命令行参数但配置没有生效 + +**解决方法**: + +- 确认参数名拼写正确(如 `--ak` 而不是 `--access_key`) +- 检查参数格式是否正确(布尔参数如 `--is_slave` 不需要值) +- 确认参数位置正确(所有参数都应在 `unilab` 之后) +- 查看启动日志确认参数是否被正确解析 +- 检查是否有配置文件或环境变量与之冲突 + +### 6. 配置优先级混淆 + +**错误现象**:不确定哪个配置生效 + +**解决方法**: + +- 记住优先级:**命令行参数 > 环境变量 > 配置文件** +- 使用 `--ak` 和 `--sk` 参数时会看到提示信息:"传入了 ak 参数,优先采用传入参数!" +- 检查启动日志中的配置加载信息 +- 临时移除低优先级配置来测试高优先级配置是否生效 +- 使用 `printenv | grep UNILABOS` 查看所有相关环境变量 + +## 配置验证 + +### 检查配置是否生效 + +启动 Uni-Lab 时,控制台会输出配置加载信息: + +``` +[ENV] 配置文件 /path/to/config.py 加载成功 +[ENV] 设置 BasicConfig.log_level = INFO +传入了ak参数,优先采用传入参数! +传入了sk参数,优先采用传入参数! +``` + +### 常见配置错误 + +1. **配置文件格式错误** + + ``` + [ENV] 加载配置文件 /path/to/config.py 失败 + ``` + + **解决方案**:检查 Python 语法,确保配置类定义正确 + +2. **环境变量格式错误** + + ``` + [ENV] 环境变量格式不正确:UNILABOS_INVALID_VAR + ``` + + **解决方案**:确保环境变量遵循 `UNILABOS_<类名>_<字段名>` 格式 + +3. **类或字段不存在** + ``` + [ENV] 未找到类:UNKNOWNCONFIG + [ENV] 类 BasicConfig 中未找到字段:UNKNOWN_FIELD + ``` + **解决方案**:检查配置类名和字段名是否正确 + +## 相关文档 + +- [工作目录详解](working_directory.md) +- [启动参数详解](../user_guide/launch.md) +- [快速安装指南](../user_guide/quick_install_guide.md) diff --git a/docs/advanced_usage/image/copy_aksk.gif b/docs/advanced_usage/image/copy_aksk.gif new file mode 100644 index 00000000..760dd011 Binary files /dev/null and b/docs/advanced_usage/image/copy_aksk.gif differ diff --git a/docs/advanced_usage/working_directory.md b/docs/advanced_usage/working_directory.md new file mode 100644 index 00000000..37215d72 --- /dev/null +++ b/docs/advanced_usage/working_directory.md @@ -0,0 +1,218 @@ +# 工作目录详解 + +本文档详细介绍 Uni-Lab 工作目录(`working_dir`)的判断逻辑和详细用法。 + +## 什么是工作目录 + +工作目录是 Uni-Lab 存储配置文件、日志和运行数据的目录。默认情况下,工作目录为 `当前目录/unilabos_data`。 + +## 工作目录判断逻辑 + +系统按以下决策树自动确定工作目录: + +### 第一步:初始判断 + +```python +# 检查当前目录 +if 当前目录以 "unilabos_data" 结尾: + working_dir = 当前目录的绝对路径 +else: + working_dir = 当前目录/unilabos_data +``` + +**解释:** +- 如果您已经在 `unilabos_data` 目录内启动,系统直接使用当前目录 +- 否则,系统会在当前目录下创建或使用 `unilabos_data` 子目录 + +### 第二步:处理 `--working_dir` 参数 + +如果用户指定了 `--working_dir` 参数: + +```python +working_dir = 用户指定的路径 +``` + +此时还会检查配置文件: +- 如果同时指定了 `--config` 但该文件不存在 +- 系统会尝试在 `working_dir/local_config.py` 查找 +- 如果仍未找到,报错退出 + +### 第三步:处理 `--config` 参数 + +如果用户指定了 `--config` 且文件存在: + +```python +# 工作目录改为配置文件所在目录 +working_dir = config_path 的父目录 +``` + +**重要:** 这意味着配置文件的位置会影响工作目录的判断。 + +## 使用场景示例 + +### 场景 1:默认场景(推荐) + +```bash +# 当前目录:/home/user/project +unilab --ak your_ak --sk your_sk -g graph.json + +# 结果: +# working_dir = /home/user/project/unilabos_data +# config_path = /home/user/project/unilabos_data/local_config.py +``` + +### 场景 2:在 unilabos_data 目录内启动 + +```bash +cd /home/user/project/unilabos_data +unilab --ak your_ak --sk your_sk -g graph.json + +# 结果: +# working_dir = /home/user/project/unilabos_data +# config_path = /home/user/project/unilabos_data/local_config.py +``` + +### 场景 3:手动指定工作目录 + +```bash +unilab --working_dir /custom/path --ak your_ak --sk your_sk -g graph.json + +# 结果: +# working_dir = /custom/path +# config_path = /custom/path/local_config.py (如果存在) +``` + +### 场景 4:通过配置文件路径推断工作目录 + +```bash +unilab --config /data/lab_a/local_config.py --ak your_ak --sk your_sk -g graph.json + +# 结果: +# working_dir = /data/lab_a +# config_path = /data/lab_a/local_config.py +``` + +## 高级用法:管理多个实验室配置 + +### 方法 1:使用不同的工作目录 + +```bash +# 实验室 A +unilab --working_dir ~/labs/lab_a --ak ak_a --sk sk_a -g graph_a.json + +# 实验室 B +unilab --working_dir ~/labs/lab_b --ak ak_b --sk sk_b -g graph_b.json +``` + +### 方法 2:使用不同的配置文件 + +```bash +# 实验室 A +unilab --config ~/labs/lab_a/config.py --ak ak_a --sk sk_a -g graph_a.json + +# 实验室 B +unilab --config ~/labs/lab_b/config.py --ak ak_b --sk sk_b -g graph_b.json +``` + +### 方法 3:使用shell脚本管理 + +创建 `start_lab_a.sh`: + +```bash +#!/bin/bash +cd ~/labs/lab_a +unilab --ak your_ak_a --sk your_sk_a -g graph_a.json +``` + +创建 `start_lab_b.sh`: + +```bash +#!/bin/bash +cd ~/labs/lab_b +unilab --ak your_ak_b --sk your_sk_b -g graph_b.json +``` + +## 完整决策流程图 + +``` +开始 + ↓ +判断当前目录是否以 unilabos_data 结尾? + ├─ 是 → working_dir = 当前目录 + └─ 否 → working_dir = 当前目录/unilabos_data + ↓ +用户是否指定 --working_dir? + └─ 是 → working_dir = 指定路径 + ↓ +用户是否指定 --config 且文件存在? + └─ 是 → working_dir = config 文件所在目录 + ↓ +检查 working_dir/local_config.py 是否存在? + ├─ 是 → 加载配置文件 → 继续启动 + └─ 否 → 询问是否首次使用 + ├─ 是 → 创建目录和配置文件 → 继续启动 + └─ 否 → 退出程序 +``` + +## 常见问题 + +### 1. 如何查看当前使用的工作目录? + +启动 Uni-Lab 时,系统会在控制台输出: + +``` +当前工作目录为 /path/to/working_dir +``` + +### 2. 可以在同一台机器上运行多个实验室吗? + +可以。使用不同的工作目录或配置文件即可: + +```bash +# 终端 1 +unilab --working_dir ~/lab1 --ak ak1 --sk sk1 -g graph1.json + +# 终端 2 +unilab --working_dir ~/lab2 --ak ak2 --sk sk2 -g graph2.json +``` + +### 3. 工作目录中存储了什么? + +- `local_config.py` - 配置文件 +- 日志文件 +- 临时运行数据 +- 缓存文件 + +### 4. 可以删除工作目录吗? + +可以,但会丢失: +- 配置文件(需要重新创建) +- 历史日志 +- 缓存数据 + +建议定期备份配置文件。 + +### 5. 如何迁移到新的工作目录? + +```bash +# 1. 复制旧的工作目录 +cp -r ~/old_path/unilabos_data ~/new_path/unilabos_data + +# 2. 在新位置启动 +cd ~/new_path +unilab --ak your_ak --sk your_sk -g graph.json +``` + +## 最佳实践 + +1. **使用默认工作目录**:对于单一实验室,使用默认的 `./unilabos_data` 即可 +2. **组织多实验室**:为每个实验室创建独立的目录结构 +3. **版本控制**:将配置文件纳入版本控制,但排除日志和缓存 +4. **备份配置**:定期备份 `local_config.py` 文件 +5. **使用脚本**:为不同实验室创建启动脚本,简化操作 + +## 相关文档 + +- [配置文件指南](configuration.md) +- [启动参数详解](../user_guide/launch.md) + diff --git a/docs/concepts/01-communication-instruction.md b/docs/concepts/01-communication-instruction.md index 09e69754..8b0c040f 100644 --- a/docs/concepts/01-communication-instruction.md +++ b/docs/concepts/01-communication-instruction.md @@ -1,7 +1,7 @@ (instructions)= # 设备抽象、指令集与通信中间件 -Uni-Lab 操作系统的目的是将不同类型和厂家的实验仪器进行抽象统一,对应用层提供服务。因此,理清实验室设备之间的业务逻辑至关重要。 +Uni-Lab-OS的目的是将不同类型和厂家的实验仪器进行抽象统一,对应用层提供服务。因此,理清实验室设备之间的业务逻辑至关重要。 ## 设备间通信模式 diff --git a/docs/conf.py b/docs/conf.py index c6b7d50a..f15f0e6f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -24,6 +24,7 @@ extensions = [ "sphinx.ext.autodoc", "sphinx.ext.napoleon", # 如果您使用 Google 或 NumPy 风格的 docstrings "sphinx_rtd_theme", + "sphinxcontrib.mermaid" ] source_suffix = { @@ -42,6 +43,8 @@ myst_enable_extensions = [ "substitution", ] +myst_fence_as_directive = ["mermaid"] + templates_path = ["_templates"] exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] @@ -203,3 +206,5 @@ def generate_action_includes(app): def setup(app): app.connect("builder-inited", generate_action_includes) + app.add_js_file("https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js") + app.add_js_file(None, body="mermaid.initialize({startOnLoad:true});") diff --git a/docs/developer_guide/action_includes.md b/docs/developer_guide/action_includes.md index 44403eb5..206f94e2 100644 --- a/docs/developer_guide/action_includes.md +++ b/docs/developer_guide/action_includes.md @@ -1,4 +1,4 @@ -## 简单单变量动作函数 +## 基础通用操作 ### `SendCmd` @@ -8,22 +8,6 @@ --- -### `StrSingleInput` - -```{literalinclude} ../../unilabos_msgs/action/StrSingleInput.action -:language: yaml -``` - ---- - -### `IntSingleInput` - -```{literalinclude} ../../unilabos_msgs/action/IntSingleInput.action -:language: yaml -``` - ---- - ### `FloatSingleInput` ```{literalinclude} ../../unilabos_msgs/action/FloatSingleInput.action @@ -32,6 +16,14 @@ --- +### `IntSingleInput` + +```{literalinclude} ../../unilabos_msgs/action/IntSingleInput.action +:language: yaml +``` + +--- + ### `Point3DSeparateInput` ```{literalinclude} ../../unilabos_msgs/action/Point3DSeparateInput.action @@ -40,6 +32,14 @@ --- +### `StrSingleInput` + +```{literalinclude} ../../unilabos_msgs/action/StrSingleInput.action +:language: yaml +``` + +--- + ### `Wait` ```{literalinclude} ../../unilabos_msgs/action/Wait.action @@ -48,83 +48,13 @@ --- -## 常量有机化学操作 +## 化学实验操作 -Uni-Lab 常量有机化学指令集多数来自 [XDL](https://croningroup.gitlab.io/chemputer/xdl/standard/full_steps_specification.html#),包含有机合成实验中常见的操作,如加热、搅拌、冷却等。 +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 -``` - ---- - -### `Add` +#### `Add` ```{literalinclude} ../../unilabos_msgs/action/Add.action :language: yaml @@ -132,7 +62,7 @@ Uni-Lab 常量有机化学指令集多数来自 [XDL](https://croningroup.gitlab --- -### `AddSolid` +#### `AddSolid` ```{literalinclude} ../../unilabos_msgs/action/AddSolid.action :language: yaml @@ -140,135 +70,25 @@ Uni-Lab 常量有机化学指令集多数来自 [XDL](https://croningroup.gitlab --- -### `AdjustPH` +### 液体转移与泵控制 -```{literalinclude} ../../unilabos_msgs/action/AdjustPH.action +#### `PumpTransfer` + +```{literalinclude} ../../unilabos_msgs/action/PumpTransfer.action :language: yaml ``` --- -### `Centrifuge` +#### `SetPumpPosition` -```{literalinclude} ../../unilabos_msgs/action/Centrifuge.action +```{literalinclude} ../../unilabos_msgs/action/SetPumpPosition.action :language: yaml ``` --- -### `CleanVessel` - -```{literalinclude} ../../unilabos_msgs/action/CleanVessel.action -:language: yaml -``` - ---- - -### `Crystallize` - -```{literalinclude} ../../unilabos_msgs/action/Crystallize.action -:language: yaml -``` - ---- - -### `Dissolve` - -```{literalinclude} ../../unilabos_msgs/action/Dissolve.action -:language: yaml -``` - ---- - -### `Dry` - -```{literalinclude} ../../unilabos_msgs/action/Dry.action -:language: yaml -``` - ---- - -### `Filter` - -```{literalinclude} ../../unilabos_msgs/action/Filter.action -:language: yaml -``` - ---- - -### `FilterThrough` - -```{literalinclude} ../../unilabos_msgs/action/FilterThrough.action -:language: yaml -``` - ---- - -### `Hydrogenate` - -```{literalinclude} ../../unilabos_msgs/action/Hydrogenate.action -:language: yaml -``` - ---- - -### `Purge` - -```{literalinclude} ../../unilabos_msgs/action/Purge.action -:language: yaml -``` - ---- - -### `Recrystallize` - -```{literalinclude} ../../unilabos_msgs/action/Recrystallize.action -:language: yaml -``` - ---- - -### `RunColumn` - -```{literalinclude} ../../unilabos_msgs/action/RunColumn.action -:language: yaml -``` - ---- - -### `StartPurge` - -```{literalinclude} ../../unilabos_msgs/action/StartPurge.action -:language: yaml -``` - ---- - -### `StartStir` - -```{literalinclude} ../../unilabos_msgs/action/StartStir.action -:language: yaml -``` - ---- - -### `StopPurge` - -```{literalinclude} ../../unilabos_msgs/action/StopPurge.action -:language: yaml -``` - ---- - -### `StopStir` - -```{literalinclude} ../../unilabos_msgs/action/StopStir.action -:language: yaml -``` - ---- - -### `Transfer` +#### `Transfer` ```{literalinclude} ../../unilabos_msgs/action/Transfer.action :language: yaml @@ -276,7 +96,193 @@ Uni-Lab 常量有机化学指令集多数来自 [XDL](https://croningroup.gitlab --- -### `WashSolid` +### 温度控制 + +#### `HeatChill` + +```{literalinclude} ../../unilabos_msgs/action/HeatChill.action +:language: yaml +``` + +--- + +#### `HeatChillStart` + +```{literalinclude} ../../unilabos_msgs/action/HeatChillStart.action +:language: yaml +``` + +--- + +#### `HeatChillStop` + +```{literalinclude} ../../unilabos_msgs/action/HeatChillStop.action +:language: yaml +``` + +--- + +### 搅拌控制 + +#### `StartStir` + +```{literalinclude} ../../unilabos_msgs/action/StartStir.action +:language: yaml +``` + +--- + +#### `Stir` + +```{literalinclude} ../../unilabos_msgs/action/Stir.action +:language: yaml +``` + +--- + +#### `StopStir` + +```{literalinclude} ../../unilabos_msgs/action/StopStir.action +:language: yaml +``` + +--- + +### 气体与真空控制 + +#### `EvacuateAndRefill` + +```{literalinclude} ../../unilabos_msgs/action/EvacuateAndRefill.action +:language: yaml +``` + +--- + +#### `Purge` + +```{literalinclude} ../../unilabos_msgs/action/Purge.action +:language: yaml +``` + +--- + +#### `StartPurge` + +```{literalinclude} ../../unilabos_msgs/action/StartPurge.action +:language: yaml +``` + +--- + +#### `StopPurge` + +```{literalinclude} ../../unilabos_msgs/action/StopPurge.action +:language: yaml +``` + +--- + +### 分离与过滤 + +#### `Centrifuge` + +```{literalinclude} ../../unilabos_msgs/action/Centrifuge.action +:language: yaml +``` + +--- + +#### `Filter` + +```{literalinclude} ../../unilabos_msgs/action/Filter.action +:language: yaml +``` + +--- + +#### `FilterThrough` + +```{literalinclude} ../../unilabos_msgs/action/FilterThrough.action +:language: yaml +``` + +--- + +#### `RunColumn` + +```{literalinclude} ../../unilabos_msgs/action/RunColumn.action +:language: yaml +``` + +--- + +#### `Separate` + +```{literalinclude} ../../unilabos_msgs/action/Separate.action +:language: yaml +``` + +--- + +### 化学处理 + +#### `AdjustPH` + +```{literalinclude} ../../unilabos_msgs/action/AdjustPH.action +:language: yaml +``` + +--- + +#### `Crystallize` + +```{literalinclude} ../../unilabos_msgs/action/Crystallize.action +:language: yaml +``` + +--- + +#### `Dissolve` + +```{literalinclude} ../../unilabos_msgs/action/Dissolve.action +:language: yaml +``` + +--- + +#### `Dry` + +```{literalinclude} ../../unilabos_msgs/action/Dry.action +:language: yaml +``` + +--- + +#### `Evaporate` + +```{literalinclude} ../../unilabos_msgs/action/Evaporate.action +:language: yaml +``` + +--- + +#### `Hydrogenate` + +```{literalinclude} ../../unilabos_msgs/action/Hydrogenate.action +:language: yaml +``` + +--- + +#### `Recrystallize` + +```{literalinclude} ../../unilabos_msgs/action/Recrystallize.action +:language: yaml +``` + +--- + +#### `WashSolid` ```{literalinclude} ../../unilabos_msgs/action/WashSolid.action :language: yaml @@ -284,9 +290,51 @@ Uni-Lab 常量有机化学指令集多数来自 [XDL](https://croningroup.gitlab --- -## 移液工作站及相关生物自动化设备操作 +### 清洁与维护 -Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.org/user_guide/index.html),包含生物实验中常见的操作,如移液、混匀、离心等。 +#### `Clean` + +```{literalinclude} ../../unilabos_msgs/action/Clean.action +:language: yaml +``` + +--- + +#### `CleanVessel` + +```{literalinclude} ../../unilabos_msgs/action/CleanVessel.action +:language: yaml +``` + +--- + +#### `EmptyIn` + +```{literalinclude} ../../unilabos_msgs/action/EmptyIn.action +:language: yaml +``` + +--- + +#### `ResetHandling` + +```{literalinclude} ../../unilabos_msgs/action/ResetHandling.action +:language: yaml +``` + +--- + +## 生物自动化操作 + +Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.org/user_guide/index.html),包含移液工作站的各类操作。 + +### `LiquidHandlerAdd` + +```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerAdd.action +:language: yaml +``` + +--- ### `LiquidHandlerAspirate` @@ -328,86 +376,6 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o --- -### `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 -``` - ---- - -### `LiquidHandlerAdd` - -```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerAdd.action -:language: yaml -``` - ---- - ### `LiquidHandlerIncubateBiomek` ```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerIncubateBiomek.action @@ -432,6 +400,30 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o --- +### `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 +``` + +--- + ### `LiquidHandlerMoveTo` ```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerMoveTo.action @@ -448,6 +440,22 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o --- +### `LiquidHandlerPickUpTips` + +```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerPickUpTips.action +:language: yaml +``` + +--- + +### `LiquidHandlerPickUpTips96` + +```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerPickUpTips96.action +:language: yaml +``` + +--- + ### `LiquidHandlerProtocolCreation` ```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerProtocolCreation.action @@ -464,6 +472,22 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o --- +### `LiquidHandlerReturnTips` + +```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerReturnTips.action +:language: yaml +``` + +--- + +### `LiquidHandlerReturnTips96` + +```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerReturnTips96.action +:language: yaml +``` + +--- + ### `LiquidHandlerSetGroup` ```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerSetGroup.action @@ -488,6 +512,22 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o --- +### `LiquidHandlerStamp` + +```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerStamp.action +:language: yaml +``` + +--- + +### `LiquidHandlerTransfer` + +```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerTransfer.action +:language: yaml +``` + +--- + ### `LiquidHandlerTransferBiomek` ```{literalinclude} ../../unilabos_msgs/action/LiquidHandlerTransferBiomek.action @@ -504,59 +544,85 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o --- -## 多工作站及小车运行、物料转移 +## 专用工作站操作 -### `AGVTransfer` +### 反应工作站 -```{literalinclude} ../../unilabos_msgs/action/AGVTransfer.action +#### `ReactionStationDripBack` + +```{literalinclude} ../../unilabos_msgs/action/ReactionStationDripBack.action :language: yaml ``` --- -### `WorkStationRun` +#### `ReactionStationLiquidFeedBeaker` -```{literalinclude} ../../unilabos_msgs/action/WorkStationRun.action +```{literalinclude} ../../unilabos_msgs/action/ReactionStationLiquidFeedBeaker.action :language: yaml ``` --- -### `ResetHandling` +#### `ReactionStationLiquidFeedSolvents` -```{literalinclude} ../../unilabos_msgs/action/ResetHandling.action +```{literalinclude} ../../unilabos_msgs/action/ReactionStationLiquidFeedSolvents.action :language: yaml ``` --- -### `ResourceCreateFromOuter` +#### `ReactionStationLiquidFeedTitration` -```{literalinclude} ../../unilabos_msgs/action/ResourceCreateFromOuter.action +```{literalinclude} ../../unilabos_msgs/action/ReactionStationLiquidFeedTitration.action :language: yaml ``` --- -### `ResourceCreateFromOuterEasy` +#### `ReactionStationLiquidFeedVialsNonTitration` -```{literalinclude} ../../unilabos_msgs/action/ResourceCreateFromOuterEasy.action +```{literalinclude} ../../unilabos_msgs/action/ReactionStationLiquidFeedVialsNonTitration.action :language: yaml ``` --- -### `SetPumpPosition` +#### `ReactionStationProExecu` -```{literalinclude} ../../unilabos_msgs/action/SetPumpPosition.action +```{literalinclude} ../../unilabos_msgs/action/ReactionStationProExecu.action :language: yaml ``` --- -## 固体分配与处理设备操作 +#### `ReactionStationReactorTakenOut` -### `SolidDispenseAddPowderTube` +```{literalinclude} ../../unilabos_msgs/action/ReactionStationReactorTakenOut.action +:language: yaml +``` + +--- + +#### `ReactionStationReaTackIn` + +```{literalinclude} ../../unilabos_msgs/action/ReactionStationReaTackIn.action +:language: yaml +``` + +--- + +#### `ReactionStationSolidFeedVial` + +```{literalinclude} ../../unilabos_msgs/action/ReactionStationSolidFeedVial.action +:language: yaml +``` + +--- + +### 固体分配站 + +#### `SolidDispenseAddPowderTube` ```{literalinclude} ../../unilabos_msgs/action/SolidDispenseAddPowderTube.action :language: yaml @@ -564,21 +630,103 @@ Uni-Lab 生物操作指令集多数来自 [PyLabRobot](https://docs.pylabrobot.o --- -## 其他设备操作 +### 分液工作站 -### `EmptyIn` +#### `DispenStationSolnPrep` -```{literalinclude} ../../unilabos_msgs/action/EmptyIn.action +```{literalinclude} ../../unilabos_msgs/action/DispenStationSolnPrep.action :language: yaml ``` --- -## 机械臂、夹爪等机器人设备 +#### `DispenStationVialFeed` -Uni-Lab 机械臂、机器人、夹爪和导航指令集沿用 ROS2 的 `control_msgs` 和 `nav2_msgs`: +```{literalinclude} ../../unilabos_msgs/action/DispenStationVialFeed.action +:language: yaml +``` -### `FollowJointTrajectory` +--- + +### 后处理工作站 + +#### `PostProcessGrab` + +```{literalinclude} ../../unilabos_msgs/action/PostProcessGrab.action +:language: yaml +``` + +--- + +#### `PostProcessTriggerClean` + +```{literalinclude} ../../unilabos_msgs/action/PostProcessTriggerClean.action +:language: yaml +``` + +--- + +#### `PostProcessTriggerPostPro` + +```{literalinclude} ../../unilabos_msgs/action/PostProcessTriggerPostPro.action +:language: yaml +``` + +--- + +## 系统管理与资源调度 + +### 资源与布局管理 + +#### `DefaultLayoutRecommendLayout` + +```{literalinclude} ../../unilabos_msgs/action/DefaultLayoutRecommendLayout.action +:language: yaml +``` + +--- + +#### `ResourceCreateFromOuter` + +```{literalinclude} ../../unilabos_msgs/action/ResourceCreateFromOuter.action +:language: yaml +``` + +--- + +#### `ResourceCreateFromOuterEasy` + +```{literalinclude} ../../unilabos_msgs/action/ResourceCreateFromOuterEasy.action +:language: yaml +``` + +--- + +### 多工作站协调 + +#### `AGVTransfer` + +```{literalinclude} ../../unilabos_msgs/action/AGVTransfer.action +:language: yaml +``` + +--- + +#### `WorkStationRun` + +```{literalinclude} ../../unilabos_msgs/action/WorkStationRun.action +:language: yaml +``` + +--- + +## 机器人控制(ROS2 标准) + +Uni-Lab 机械臂、机器人、夹爪和导航指令集沿用 ROS2 的 `control_msgs` 和 `nav2_msgs`。 + +### 机械臂与关节控制 + +#### `FollowJointTrajectory` ```yaml # The trajectory for all revolute, continuous or prismatic joints @@ -647,7 +795,53 @@ trajectory_msgs/MultiDOFJointTrajectoryPoint multi_dof_error --- -### `GripperCommand` +#### `JointTrajectory` + +```yaml +trajectory_msgs/JointTrajectory trajectory +--- + +--- +``` + +--- + +#### `PointHead` + +```yaml +geometry_msgs/PointStamped target +geometry_msgs/Vector3 pointing_axis +string pointing_frame +builtin_interfaces/Duration min_duration +float64 max_velocity +--- + +--- +float64 pointing_angle_error +``` + +--- + +#### `SingleJointPosition` + +```yaml +float64 position +builtin_interfaces/Duration min_duration +float64 max_velocity +--- + +--- +std_msgs/Header header +float64 position +float64 velocity +float64 error +``` + +--- + +### 夹爪控制 + +#### `GripperCommand` ```yaml GripperCommand command @@ -666,51 +860,35 @@ bool reached_goal # True iff the gripper position has reached the commanded setp --- -### `JointTrajectory` +#### `ParallelGripperCommand` ```yaml -trajectory_msgs/JointTrajectory trajectory +# Parallel grippers refer to an end effector where two opposing fingers grasp an object from opposite sides. +sensor_msgs/JointState command +# name: the name(s) of the joint this command is requesting +# position: desired position of each gripper joint (radians or meters) +# velocity: (optional, not used if empty) max velocity of the joint allowed while moving (radians or meters / second) +# effort: (optional, not used if empty) max effort of the joint allowed while moving (Newtons or Newton-meters) --- +sensor_msgs/JointState state # The current gripper state. +# position of each joint (radians or meters) +# optional: velocity of each joint (radians or meters / second) +# optional: effort of each joint (Newtons or Newton-meters) +bool stalled # True if the gripper is exerting max effort and not moving +bool reached_goal # True if the gripper position has reached the commanded setpoint +--- +sensor_msgs/JointState state # The current gripper state. +# position of each joint (radians or meters) +# optional: velocity of each joint (radians or meters / second) +# optional: effort of each joint (Newtons or Newton-meters) ---- ``` --- -### `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` +#### `AssistedTeleop` ```yaml #goal definition @@ -725,7 +903,7 @@ builtin_interfaces/Duration current_teleop_duration --- -### `BackUp` +#### `BackUp` ```yaml #goal definition @@ -742,7 +920,7 @@ float32 distance_traveled --- -### `ComputePathThroughPoses` +#### `ComputePathThroughPoses` ```yaml #goal definition @@ -760,7 +938,7 @@ builtin_interfaces/Duration planning_time --- -### `ComputePathToPose` +#### `ComputePathToPose` ```yaml #goal definition @@ -778,7 +956,7 @@ builtin_interfaces/Duration planning_time --- -### `DriveOnHeading` +#### `DriveOnHeading` ```yaml #goal definition @@ -795,7 +973,7 @@ float32 distance_traveled --- -### `DummyBehavior` +#### `DummyBehavior` ```yaml #goal definition @@ -809,7 +987,7 @@ builtin_interfaces/Duration total_elapsed_time --- -### `FollowPath` +#### `FollowPath` ```yaml #goal definition @@ -827,7 +1005,7 @@ float32 speed --- -### `FollowWaypoints` +#### `FollowWaypoints` ```yaml #goal definition @@ -842,7 +1020,7 @@ uint32 current_waypoint --- -### `NavigateThroughPoses` +#### `NavigateThroughPoses` ```yaml #goal definition @@ -863,7 +1041,7 @@ int16 number_of_poses_remaining --- -### `NavigateToPose` +#### `NavigateToPose` ```yaml #goal definition @@ -883,7 +1061,7 @@ float32 distance_remaining --- -### `SmoothPath` +#### `SmoothPath` ```yaml #goal definition @@ -902,7 +1080,7 @@ bool was_completed --- -### `Spin` +#### `Spin` ```yaml #goal definition @@ -918,7 +1096,9 @@ float32 angular_distance_traveled --- -### `Wait` +#### `Wait` (Nav2) + +> **注意**:这是 ROS2 nav2_msgs 的标准 Wait action,与 unilabos_msgs 的 Wait action 不同。 ```yaml #goal definition diff --git a/docs/developer_guide/add_device.md b/docs/developer_guide/add_device.md index 0cec54da..dc95274f 100644 --- a/docs/developer_guide/add_device.md +++ b/docs/developer_guide/add_device.md @@ -1,10 +1,18 @@ -# 添加新设备 +# 添加设备:编写驱动 -在 Uni-Lab 中,设备(Device)是实验操作的基础单元。Uni-Lab 使用**注册表机制**来兼容管理种类繁多的设备驱动程序。回顾 {ref}`instructions` 中的概念,抽象的设备对外拥有【话题】【服务】【动作】三种通信机制,因此将设备添加进 Uni-Lab,实际上是将设备驱动中的三种机制映射到 Uni-Lab 标准指令集上。 +在 Uni-Lab 中,设备(Device)是实验操作的基础单元。Uni-Lab 使用**注册表机制**来兼容管理种类繁多的设备驱动程序。抽象的设备对外拥有【话题】【服务】【动作】三种通信机制,因此将设备添加进 Uni-Lab,实际上是将设备驱动中的这三种机制映射到 Uni-Lab 标准指令集上。 -能被 Uni-Lab 添加的驱动程序类型有以下种类: +> **💡 提示:** 本文档介绍如何使用已有的设备驱动(SDK)。若设备没有现成的驱动程序,需要自己开发驱动,请参考 {doc}`add_old_device`。 -1. Python Class,如 +## 支持的驱动类型 + +Uni-Lab 支持以下两种驱动程序: + +### 1. Python Class(推荐) + +Python 类设备驱动在完成注册表后可以直接在 Uni-Lab 中使用,无需额外编译。 + +**示例:** ```python class MockGripper: @@ -31,12 +39,11 @@ class MockGripper: def status(self) -> str: return self._status - # 会被自动识别的设备动作,接入 Uni-Lab 时会作为 ActionServer 接受任意控制者的指令 @status.setter def status(self, target): self._status = target - # 需要在注册表添加的设备动作,接入 Uni-Lab 时会作为 ActionServer 接受任意控制者的指令 + # 会被自动识别的设备动作,接入 Uni-Lab 时会作为 ActionServer 接受任意控制者的指令 def push_to(self, position: float, torque: float, velocity: float = 0.0): self._status = "Running" current_pos = self.position @@ -53,9 +60,11 @@ class MockGripper: self._status = "Idle" ``` -Python 类设备驱动在完成注册表后可以直接在 Uni-Lab 使用。 +### 2. C# Class -2. C# Class,如 +C# 驱动设备在完成注册表后,需要调用 Uni-Lab C# 编译后才能使用(仅需一次)。 + +**示例:** ```csharp using System; @@ -84,7 +93,7 @@ public class MockGripper position = currentPos + (Position - currentPos) / 20 * (i + 1); torque = Torque / (20 - i); velocity = Velocity; - await Task.Delay((int)(moveTime * 1000 / 20)); // Convert seconds to milliseconds + await Task.Delay((int)(moveTime * 1000 / 20)); } torque = Torque; status = "Idle"; @@ -92,12 +101,16 @@ public class MockGripper } ``` -C# 驱动设备在完成注册表后,需要调用 Uni-Lab C# 编译后才能使用,但只需一次。 +--- -## 快速开始:使用注册表编辑器(推荐) +## 快速开始:两种方式添加设备 + +### 方式 1:使用注册表编辑器(推荐) 推荐使用 Uni-Lab-OS 自带的可视化编辑器,它能自动分析您的设备驱动并生成大部分配置: +**步骤:** + 1. 启动 Uni-Lab-OS 2. 在浏览器中打开"注册表编辑器"页面 3. 选择您的 Python 设备驱动文件 @@ -106,13 +119,18 @@ C# 驱动设备在完成注册表后,需要调用 Uni-Lab C# 编译后才能 6. 点击"生成注册表",复制生成的内容 7. 保存到 `devices/` 目录下 ---- +**优点:** -## 手动编写注册表(简化版) +- 自动识别设备属性和方法 +- 可视化界面,易于操作 +- 自动生成完整配置 +- 减少手动配置错误 + +### 方式 2:手动编写注册表(简化版) 如果需要手动编写,只需要提供两个必需字段,系统会自动补全其余内容: -### 最小配置示例 +**最小配置示例:** ```yaml my_device: # 设备唯一标识符 @@ -121,22 +139,22 @@ my_device: # 设备唯一标识符 type: python # 驱动类型 ``` -### 注册表文件位置 +**注册表文件位置:** - 默认路径:`unilabos/registry/devices` -- 自定义路径:启动时使用 `--registry` 参数指定 -- 可将多个设备写在同一个 yaml 文件中 +- 自定义路径:启动时使用 `--registry_path` 参数指定 +- 可将多个设备写在同一个 YAML 文件中 -### 系统自动生成的内容 +**系统自动生成的内容:** 系统会自动分析您的 Python 驱动类并生成: -- `status_types`:从 `get_*` 方法自动识别状态属性 +- `status_types`:从 `@property` 装饰的方法自动识别状态属性 - `action_value_mappings`:从类方法自动生成动作映射 - `init_param_schema`:从 `__init__` 方法分析初始化参数 - `schema`:前端显示用的属性类型定义 -### 完整结构概览 +**完整结构概览:** ```yaml my_device: @@ -151,4 +169,848 @@ my_device: schema: {} # 自动生成 ``` -详细的注册表编写指南和高级配置,请参考{doc}`yaml 注册表编写指南 `。 +> 💡 **提示:** 详细的注册表编写指南和高级配置,请参考 {doc}`03_add_device_registry`。 + +--- + +## Python 类结构要求 + +Uni-Lab 设备驱动是一个 Python 类,需要遵循以下结构: + +```python +from typing import Dict, Any + +class MyDevice: + """设备类文档字符串 + + 说明设备的功能、连接方式等 + """ + + def __init__(self, config: Dict[str, Any]): + """初始化设备 + + Args: + config: 配置字典,来自图文件或注册表 + """ + self.port = config.get('port', '/dev/ttyUSB0') + self.baudrate = config.get('baudrate', 9600) + self._status = "idle" + # 初始化硬件连接 + + @property + def status(self) -> str: + """设备状态(会自动广播)""" + return self._status + + def my_action(self, param: float) -> Dict[str, Any]: + """执行动作 + + Args: + param: 参数说明 + + Returns: + {"success": True, "result": ...} + """ + # 执行设备操作 + return {"success": True} +``` + +## 状态属性 vs 动作方法 + +### 状态属性(@property) + +状态属性会被自动识别并定期广播: + +```python +@property +def temperature(self) -> float: + """当前温度""" + return self._read_temperature() + +@property +def status(self) -> str: + """设备状态: idle, running, error""" + return self._status + +@property +def is_ready(self) -> bool: + """设备是否就绪""" + return self._status == "idle" +``` + +**特点**: + +- 使用`@property`装饰器 +- 只读,不能有参数 +- 自动添加到注册表的`status_types` +- 定期发布到 ROS2 topic + +### 动作方法 + +动作方法是设备可以执行的操作: + +```python +def start_heating(self, target_temp: float, rate: float = 1.0) -> Dict[str, Any]: + """开始加热 + + Args: + target_temp: 目标温度(°C) + rate: 升温速率(°C/min) + + Returns: + {"success": bool, "message": str} + """ + self._status = "heating" + self._target_temp = target_temp + # 发送命令到硬件 + return {"success": True, "message": f"Heating to {target_temp}°C"} + +async def async_operation(self, duration: float) -> Dict[str, Any]: + """异步操作(长时间运行) + + Args: + duration: 持续时间(秒) + """ + # 使用 self.sleep 而不是 asyncio.sleep(ROS2 异步机制) + await self.sleep(duration) + return {"success": True} +``` + +**特点**: + +- 普通方法或 async 方法 +- 返回 Dict 类型的结果 +- 自动注册为 ROS2 Action +- 支持参数和返回值 + +### 返回值设计指南 + +> **⚠️ 重要:返回值会自动显示在前端** +> +> 动作方法的返回值(字典)会自动显示在 Web 界面的工作流执行结果中。因此,**强烈建议**设计结构化、可读的返回值字典。 + +**推荐的返回值结构:** + +```python +def my_action(self, param: float) -> Dict[str, Any]: + """执行操作""" + try: + # 执行操作... + result = self._do_something(param) + + return { + "success": True, # 必需:操作是否成功 + "message": "操作完成", # 推荐:用户友好的消息 + "result": result, # 可选:具体结果数据 + "param_used": param, # 可选:记录使用的参数 + # 其他有用的信息... + } + except Exception as e: + return { + "success": False, + "error": str(e), + "message": "操作失败" + } +``` + +**最佳实践示例(参考 `host_node.test_latency`):** + +```python +def test_latency(self) -> Dict[str, Any]: + """测试网络延迟 + + 返回值会在前端显示,包含详细的测试结果 + """ + # 执行测试... + avg_rtt_ms = 25.5 + avg_time_diff_ms = 10.2 + test_count = 5 + + # 返回结构化的测试结果 + return { + "status": "success", # 状态标识 + "avg_rtt_ms": avg_rtt_ms, # 平均往返时间 + "avg_time_diff_ms": avg_time_diff_ms, # 平均时间差 + "max_time_error_ms": 5.3, # 最大误差 + "task_delay_ms": 15.7, # 任务延迟 + "test_count": test_count, # 测试次数 + } +``` + +**前端显示效果:** + +当用户在 Web 界面执行工作流时,返回的字典会以 JSON 格式显示在结果面板中: + +```json +{ + "status": "success", + "avg_rtt_ms": 25.5, + "avg_time_diff_ms": 10.2, + "max_time_error_ms": 5.3, + "task_delay_ms": 15.7, + "test_count": 5 +} +``` + +**返回值设计建议:** + +1. **始终包含 `success` 字段**:布尔值,表示操作是否成功 +2. **包含 `message` 字段**:字符串,提供用户友好的描述 +3. **使用有意义的键名**:使用描述性的键名(如 `avg_rtt_ms` 而不是 `v1`) +4. **包含单位**:在键名中包含单位(如 `_ms`、`_ml`、`_celsius`) +5. **记录重要参数**:返回使用的关键参数值,便于追溯 +6. **错误信息详细**:失败时包含 `error` 字段和详细的错误描述 +7. **避免返回大数据**:不要返回大型数组或二进制数据,这会影响前端性能 + +**错误处理示例:** + +```python +def risky_operation(self, param: float) -> Dict[str, Any]: + """可能失败的操作""" + if param < 0: + return { + "success": False, + "error": "参数不能为负数", + "message": f"无效参数: {param}", + "param": param + } + + try: + result = self._execute(param) + return { + "success": True, + "message": "操作成功", + "result": result, + "param": param + } + except IOError as e: + return { + "success": False, + "error": "通信错误", + "message": str(e), + "device_status": self._status + } +``` + +## 特殊参数类型:ResourceSlot 和 DeviceSlot + +Uni-Lab 提供特殊的参数类型,用于在方法中声明需要选择资源或设备。 + +### 导入类型 + +```python +from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot +from typing import List +``` + +### ResourceSlot - 资源选择 + +用于需要选择物料资源的场景: + +```python +def pipette_liquid( + self, + source: ResourceSlot, # 单个源容器 + target: ResourceSlot, # 单个目标容器 + volume: float +) -> Dict[str, Any]: + """从源容器吸取液体到目标容器 + + Args: + source: 源容器(前端会显示资源选择下拉框) + target: 目标容器(前端会显示资源选择下拉框) + volume: 体积(μL) + """ + print(f"Pipetting {volume}μL from {source.id} to {target.id}") + return {"success": True} +``` + +**多选示例**: + +```python +def mix_multiple( + self, + containers: List[ResourceSlot], # 多个容器选择 + speed: float +) -> Dict[str, Any]: + """混合多个容器 + + Args: + containers: 容器列表(前端会显示多选下拉框) + speed: 混合速度 + """ + for container in containers: + print(f"Mixing {container.name}") + return {"success": True} +``` + +### DeviceSlot - 设备选择 + +用于需要选择其他设备的场景: + +```python +def coordinate_with_device( + self, + other_device: DeviceSlot, # 单个设备选择 + command: str +) -> Dict[str, Any]: + """与另一个设备协同工作 + + Args: + other_device: 协同设备(前端会显示设备选择下拉框) + command: 命令 + """ + print(f"Coordinating with {other_device.name}") + return {"success": True} +``` + +**多设备示例**: + +```python +def sync_devices( + self, + devices: List[DeviceSlot], # 多个设备选择 + sync_signal: str +) -> Dict[str, Any]: + """同步多个设备 + + Args: + devices: 设备列表(前端会显示多选下拉框) + sync_signal: 同步信号 + """ + for dev in devices: + print(f"Syncing {dev.name}") + return {"success": True} +``` + +### 完整示例:液体处理工作站 + +```python +from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot +from typing import List, Dict, Any + +class LiquidHandler: + """液体处理工作站""" + + def __init__(self, config: Dict[str, Any]): + self.simulation = config.get('simulation', False) + self._status = "idle" + + @property + def status(self) -> str: + return self._status + + def transfer_liquid( + self, + source: ResourceSlot, # 源容器选择 + target: ResourceSlot, # 目标容器选择 + volume: float, + tip: ResourceSlot = None # 可选的枪头选择 + ) -> Dict[str, Any]: + """转移液体 + + 前端效果: + - source: 下拉框,列出所有可用容器 + - target: 下拉框,列出所有可用容器 + - volume: 数字输入框 + - tip: 下拉框(可选),列出所有枪头 + """ + self._status = "transferring" + + # source和target会被解析为实际的资源对象 + print(f"Transferring {volume}μL") + print(f" From: {source.id} ({source.name})") + print(f" To: {target.id} ({target.name})") + + if tip: + print(f" Using tip: {tip.id}") + + # 执行实际的液体转移 + # ... + + self._status = "idle" + return { + "success": True, + "volume_transferred": volume, + "source_id": source.id, + "target_id": target.id + } + + def multi_dispense( + self, + source: ResourceSlot, # 单个源 + targets: List[ResourceSlot], # 多个目标 + volumes: List[float] + ) -> Dict[str, Any]: + """从一个源分配到多个目标 + + 前端效果: + - source: 单选下拉框 + - targets: 多选下拉框(可选择多个容器) + - volumes: 数组输入(每个目标对应一个体积) + """ + results = [] + for target, vol in zip(targets, volumes): + print(f"Dispensing {vol}μL to {target.name}") + results.append({ + "target": target.id, + "volume": vol + }) + + return { + "success": True, + "dispense_results": results + } + + def test_with_balance( + self, + target: ResourceSlot, # 容器 + balance: DeviceSlot # 天平设备 + ) -> Dict[str, Any]: + """使用天平测量容器 + + 前端效果: + - target: 容器选择下拉框 + - balance: 设备选择下拉框(仅显示天平类型) + """ + print(f"Weighing {target.name} on {balance.name}") + + # 可以调用balance的方法 + # weight = balance.get_weight() + + return { + "success": True, + "container": target.id, + "balance_used": balance.id + } +``` + +### 工作原理 + +#### 1. 类型识别 + +注册表扫描方法签名时: + +```python +def my_method(self, resource: ResourceSlot, device: DeviceSlot): + pass +``` + +系统识别到`ResourceSlot`和`DeviceSlot`类型。 + +#### 2. 自动添加 placeholder_keys + +在注册表中自动生成: + +```yaml +my_device: + class: + action_value_mappings: + my_method: + goal: + resource: resource + device: device + placeholder_keys: + resource: unilabos_resources # 自动添加! + device: unilabos_devices # 自动添加! +``` + +#### 3. 前端 UI 生成 + +- `unilabos_resources`: 渲染为资源选择下拉框 +- `unilabos_devices`: 渲染为设备选择下拉框 + +#### 4. 运行时解析 + +用户选择资源/设备后,实际调用时会传入完整的资源/设备对象: + +```python +# 用户在前端选择了 plate_1 +# 运行时,source参数会收到完整的Resource对象 +source.id # "plate_1" +source.name # "96孔板" +source.type # "resource" +source.class_ # "corning_96_wellplate_360ul_flat" +``` + +## 支持的通信方式 + +### 1. 串口(Serial) + +```python +import serial + +class SerialDevice: + def __init__(self, config: Dict[str, Any]): + self.port = config['port'] + self.baudrate = config.get('baudrate', 9600) + self.ser = serial.Serial( + port=self.port, + baudrate=self.baudrate, + timeout=1 + ) + + def send_command(self, cmd: str) -> str: + """发送命令并读取响应""" + self.ser.write(f"{cmd}\r\n".encode()) + response = self.ser.readline().decode().strip() + return response + + def __del__(self): + if hasattr(self, 'ser') and self.ser.is_open: + self.ser.close() +``` + +### 2. TCP/IP Socket + +```python +import socket + +class TCPDevice: + def __init__(self, config: Dict[str, Any]): + self.host = config['host'] + self.port = config['port'] + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.sock.connect((self.host, self.port)) + + def send_command(self, cmd: str) -> str: + self.sock.sendall(cmd.encode()) + response = self.sock.recv(1024).decode() + return response +``` + +### 3. Modbus + +```python +from pymodbus.client import ModbusTcpClient + +class ModbusDevice: + def __init__(self, config: Dict[str, Any]): + self.host = config['host'] + self.port = config.get('port', 502) + self.client = ModbusTcpClient(self.host, port=self.port) + self.client.connect() + + def read_register(self, address: int) -> int: + result = self.client.read_holding_registers(address, 1) + return result.registers[0] + + def write_register(self, address: int, value: int): + self.client.write_register(address, value) +``` + +### 4. OPC UA + +```python +from opcua import Client + +class OPCUADevice: + def __init__(self, config: Dict[str, Any]): + self.url = config['url'] + self.client = Client(self.url) + self.client.connect() + + def read_node(self, node_id: str): + node = self.client.get_node(node_id) + return node.get_value() + + def write_node(self, node_id: str, value): + node = self.client.get_node(node_id) + node.set_value(value) +``` + +### 5. HTTP/RPC + +```python +import requests + +class HTTPDevice: + def __init__(self, config: Dict[str, Any]): + self.base_url = config['url'] + self.auth_token = config.get('token') + + def send_command(self, endpoint: str, data: Dict) -> Dict: + url = f"{self.base_url}/{endpoint}" + headers = {'Authorization': f'Bearer {self.auth_token}'} + response = requests.post(url, json=data, headers=headers) + return response.json() +``` + +## 异步 vs 同步方法 + +### 同步方法(适合快速操作) + +```python +def quick_operation(self, param: float) -> Dict[str, Any]: + """快速操作,立即返回""" + result = self._do_something(param) + return {"success": True, "result": result} +``` + +### 异步方法(适合耗时操作) + +```python +async def long_operation(self, duration: float) -> Dict[str, Any]: + """长时间运行的操作""" + self._status = "running" + + # 使用 ROS2 提供的 sleep 方法(而不是 asyncio.sleep) + await self.sleep(duration) + + # 可以在过程中发送feedback + # 需要配合ROS2 Action的feedback机制 + + self._status = "idle" + return {"success": True, "duration": duration} +``` + +> **⚠️ 重要提示:ROS2 异步机制 vs Python asyncio** +> +> Uni-Lab 的设备驱动虽然使用 `async def` 语法,但**底层是 ROS2 的异步机制,而不是 Python 的 asyncio**。 +> +> **不能使用的 asyncio 功能:** +> +> - ❌ `asyncio.sleep()` - 会导致 ROS2 事件循环阻塞 +> - ❌ `asyncio.create_task()` - 任务不会被 ROS2 正确调度 +> - ❌ `asyncio.gather()` - 无法与 ROS2 集成 +> - ❌ 其他 asyncio 标准库函数 +> +> **应该使用的方法(继承自 BaseROS2DeviceNode):** +> +> - ✅ `await self.sleep(seconds)` - ROS2 兼容的睡眠 +> - ✅ `await self.create_task(func, **kwargs)` - ROS2 兼容的任务创建 +> - ✅ ROS2 的 Action/Service 回调机制 +> +> **示例:** +> +> ```python +> async def complex_operation(self, duration: float) -> Dict[str, Any]: +> """正确使用 ROS2 异步方法""" +> self._status = "processing" +> +> # ✅ 正确:使用 self.sleep +> await self.sleep(duration) +> +> # ✅ 正确:创建并发任务 +> task = await self.create_task(self._background_work) +> +> # ❌ 错误:不要使用 asyncio +> # await asyncio.sleep(duration) # 这会导致问题! +> # task = asyncio.create_task(...) # 这也不行! +> +> self._status = "idle" +> return {"success": True} +> +> async def _background_work(self): +> """后台任务""" +> await self.sleep(1.0) +> self.lab_logger().info("Background work completed") +> ``` +> +> **为什么不能混用?** +> +> ROS2 使用 `rclpy` 的事件循环来管理所有异步操作。如果使用 `asyncio` 的函数,这些操作会在不同的事件循环中运行,导致: +> +> - ROS2 回调无法正确执行 +> - 任务可能永远不会完成 +> - 程序可能死锁或崩溃 +> +> **参考实现:** +> +> `BaseROS2DeviceNode` 提供的方法定义(`base_device_node.py:563-572`): +> +> ```python +> async def sleep(self, rel_time: float, callback_group=None): +> """ROS2 兼容的异步睡眠""" +> if callback_group is None: +> callback_group = self.callback_group +> await ROS2DeviceNode.async_wait_for(self, rel_time, callback_group) +> +> @classmethod +> async def create_task(cls, func, trace_error=True, **kwargs) -> Task: +> """ROS2 兼容的任务创建""" +> return ROS2DeviceNode.run_async_func(func, trace_error, **kwargs) +> ``` + +## 错误处理 + +### 基本错误处理 + +```python +def operation_with_error_handling(self, param: float) -> Dict[str, Any]: + """带错误处理的操作""" + try: + result = self._risky_operation(param) + return { + "success": True, + "result": result + } + except ValueError as e: + return { + "success": False, + "error": "Invalid parameter", + "message": str(e) + } + except IOError as e: + self._status = "error" + return { + "success": False, + "error": "Communication error", + "message": str(e) + } +``` + +### 自定义异常 + +```python +class DeviceError(Exception): + """设备错误基类""" + pass + +class DeviceNotReadyError(DeviceError): + """设备未就绪""" + pass + +class DeviceTimeoutError(DeviceError): + """设备超时""" + pass + +class MyDevice: + def operation(self) -> Dict[str, Any]: + if self._status != "idle": + raise DeviceNotReadyError(f"Device is {self._status}") + + # 执行操作 + return {"success": True} +``` + +## 最佳实践 + +### 1. 类型注解 + +```python +from typing import Dict, Any, Optional, List + +def method( + self, + param1: float, + param2: str, + optional_param: Optional[int] = None +) -> Dict[str, Any]: + """完整的类型注解有助于自动生成注册表""" + pass +``` + +### 2. 文档字符串 + +```python +def method(self, param: float) -> Dict[str, Any]: + """方法简短描述 + + 更详细的说明... + + Args: + param: 参数说明,包括单位和范围 + + Returns: + Dict包含: + - success (bool): 是否成功 + - result (Any): 结果数据 + + Raises: + DeviceError: 错误情况说明 + """ + pass +``` + +### 3. 配置验证 + +```python +def __init__(self, config: Dict[str, Any]): + # 验证必需参数 + required = ['port', 'baudrate'] + for key in required: + if key not in config: + raise ValueError(f"Missing required config: {key}") + + self.port = config['port'] + self.baudrate = config['baudrate'] +``` + +### 4. 资源清理 + +```python +def __del__(self): + """析构函数,清理资源""" + if hasattr(self, 'connection') and self.connection: + self.connection.close() +``` + +### 5. 设计前端友好的返回值 + +**记住:返回值会直接显示在 Web 界面** + +```python +import time + +def measure_temperature(self) -> Dict[str, Any]: + """测量温度 + + ✅ 好的返回值设计: + - 包含 success 状态 + - 使用描述性键名 + - 在键名中包含单位 + - 记录测量时间 + """ + temp = self._read_temperature() + + return { + "success": True, + "temperature_celsius": temp, # 键名包含单位 + "timestamp": time.time(), # 记录时间 + "sensor_status": "normal", # 额外状态信息 + "message": f"温度测量完成: {temp}°C" # 用户友好的消息 + } + +def bad_example(self) -> Dict[str, Any]: + """❌ 不好的返回值设计""" + return { + "s": True, # ❌ 键名不明确 + "v": 25.5, # ❌ 没有说明单位 + "t": 1234567890, # ❌ 不清楚是什么时间戳 + } +``` + +**参考 `host_node.test_latency` 方法**(第 1216-1340 行),它返回详细的测试结果,在前端清晰显示: + +```python +return { + "status": "success", + "avg_rtt_ms": 25.5, # 有意义的键名 + 单位 + "avg_time_diff_ms": 10.2, + "max_time_error_ms": 5.3, + "task_delay_ms": 15.7, + "test_count": 5, # 记录重要信息 +} +``` + +## 下一步 + +看完本文档后,建议继续阅读: + +- {doc}`add_action` - 了解如何添加新的动作指令 +- {doc}`add_yaml` - 学习如何编写和完善 YAML 注册表 + +进阶主题: + +- {doc}`03_add_device_registry` - 了解如何配置注册表 +- {doc}`04_add_device_testing` - 学习如何测试设备 +- {doc}`add_old_device` - 没有 SDK 时如何开发设备驱动 + +## 参考 + +- [Python 类型注解](https://docs.python.org/3/library/typing.html) +- [ROS2 rclpy 异步编程](https://docs.ros.org/en/humble/Tutorials/Intermediate/Writing-an-Action-Server-Client/Py.html) - Uni-Lab 使用 ROS2 的异步机制 +- [串口通信](https://pyserial.readthedocs.io/) + +> **注意:** 虽然设备驱动使用 `async def` 语法,但请**不要参考** Python 标准的 [asyncio 文档](https://docs.python.org/3/library/asyncio.html)。Uni-Lab 使用的是 ROS2 的异步机制,两者不兼容。请使用 `self.sleep()` 和 `self.create_task()` 等 BaseROS2DeviceNode 提供的方法。 diff --git a/docs/developer_guide/device_driver.md b/docs/developer_guide/add_old_device.md similarity index 78% rename from docs/developer_guide/device_driver.md rename to docs/developer_guide/add_old_device.md index 753133a3..583d0bb2 100644 --- a/docs/developer_guide/device_driver.md +++ b/docs/developer_guide/add_old_device.md @@ -1,8 +1,10 @@ -# 设备 Driver 开发 +# 设备 Driver 开发(无 SDK 设备) 我们对设备 Driver 的定义,是一个 Python/C++/C# 类,类的方法可以用于获取传感器数据、执行设备动作、更新物料信息。它们经过 Uni-Lab 的通信中间件包装,就能成为高效分布式通信的设备节点。 -因此,若已有设备的 SDK (Driver),可以直接 [添加进 Uni-Lab](add_device.md)。仅当没有 SDK (Driver) 时,请参考本章作开发。 +因此,若已有设备的 SDK (Driver),可以直接 [添加进 Uni-Lab](add_device.md)。**仅当没有 SDK (Driver) 时,请参考本章进行驱动开发。** + +> **💡 提示:** 本文档介绍如何为没有现成驱动的老设备开发驱动程序。如果您的设备已经有 SDK 或驱动,请直接参考 {doc}`add_device`。 ## 有串口字符串指令集文档的设备:Python 串口通信(常见 RS485, RS232, USB) @@ -12,13 +14,13 @@ Modbus 与 RS485、RS232 不一样的地方在于,会有更多直接寄存器的读写,以及涉及字节序转换(Big Endian, Little Endian)。 -Uni-Lab 开发团队在仓库中提供了3个样例: +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` +- 单一机械设备**电夹爪**,通讯协议可见 [增广夹爪通讯协议](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... @@ -26,32 +28,32 @@ Uni-Lab 开发团队在仓库中提供了3个样例: ## 没有接口的老设备老软件:使用 PyWinAuto -**pywinauto**是一个 Python 库,用于自动化Windows GUI操作。它可以模拟用户的鼠标点击、键盘输入、窗口操作等,广泛应用于自动化测试、GUI自动化等场景。它支持通过两个后端进行操作: +**pywinauto**是一个 Python 库,用于自动化 Windows GUI 操作。它可以模拟用户的鼠标点击、键盘输入、窗口操作等,广泛应用于自动化测试、GUI 自动化等场景。它支持通过两个后端进行操作: -* **win32**后端:适用于大多数Windows应用程序,使用native Win32 API。(pywinauto_recorder默认使用win32后端) -* **uia**后端:基于Microsoft UI Automation,适用于较新的应用程序,特别是基于WPF或UWP的应用程序。(在win10上,会有更全的目录,有的窗口win32会识别不到) +- **win32**后端:适用于大多数 Windows 应用程序,使用 native Win32 API。(pywinauto_recorder 默认使用 win32 后端) +- **uia**后端:基于 Microsoft UI Automation,适用于较新的应用程序,特别是基于 WPF 或 UWP 的应用程序。(在 win10 上,会有更全的目录,有的窗口 win32 会识别不到) -### windows平台安装pywinauto和pywinauto_recorder +### windows 平台安装 pywinauto 和 pywinauto_recorder 直接安装会造成环境崩溃,需要下载并解压已经修改好的文件。 -cd到对应目录,执行安装 +cd 到对应目录,执行安装 -`pip install . -i ``https://pypi.tuna.tsinghua.edu.cn/simple` +` 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 平台测试 python pywinauto_recorder.py,退出使用两次 ctrl+alt+r 取消选中,关闭命令提示符。 ### 计算器例子 -你可以先打开windows的计算器,然后在ilab的环境中运行下面的代码片段,可观察到得到结果,通过这一案例,你需要掌握的pywinauto用法: +你可以先打开 windows 的计算器,然后在 ilab 的环境中运行下面的代码片段,可观察到得到结果,通过这一案例,你需要掌握的 pywinauto 用法: -* 连接到指定进程 -* 利用dump_tree查找需要的窗口 -* 获取某个位置的信息 -* 模拟点击 -* 模拟输入 +- 连接到指定进程 +- 利用 dump_tree 查找需要的窗口 +- 获取某个位置的信息 +- 模拟点击 +- 模拟输入 #### 代码学习 @@ -74,39 +76,39 @@ 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'] """ @@ -122,58 +124,58 @@ 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") @@ -187,47 +189,47 @@ 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") @@ -262,13 +264,13 @@ r, g, b = pyautogui.pixel(point_x, point_y) ### pywinauto_recorder -pywinauto_recorder是一个配合 pywinauto 使用的工具,用于录制用户的操作,并生成相应的 pywinauto 脚本。这对于一些暂时无法直接调用DLL的函数并且需要模拟用户操作的场景非常有用。同时,可以省去仅用pywinauto的一些查找UI步骤。 +pywinauto_recorder 是一个配合 pywinauto 使用的工具,用于录制用户的操作,并生成相应的 pywinauto 脚本。这对于一些暂时无法直接调用 DLL 的函数并且需要模拟用户操作的场景非常有用。同时,可以省去仅用 pywinauto 的一些查找 UI 步骤。 #### 运行尝试 -请参照 上手尝试-环境创建-3 开启pywinauto_recorder +请参照 上手尝试-环境创建-3 开启 pywinauto_recorder -例如我们这里先启动一个windows自带的计算器软件 +例如我们这里先启动一个 windows 自带的计算器软件 ![calculator_01](image/device_driver/calculator_01.png) @@ -286,7 +288,7 @@ with UIPath(u"计算器||Window"): click(u"九||Button") ``` -执行该python脚本,可以观察到新开启的计算器被点击了数字9 +执行该 python 脚本,可以观察到新开启的计算器被点击了数字 9 ![calculator_03](image/device_driver/calculator_03.png) @@ -308,23 +310,38 @@ window.dump_tree(depth=[int类型数字], filename=None) 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进行解读 +这里以上面计算器的例子对 dump_tree 进行解读 -2~4行为当前对象的窗口 +2~4 行为当前对象的窗口 -* 第2行分别是窗体的类型 `GroupBox`,窗体的题目 `数字键盘`,窗体的矩形区域坐标,对应的是屏幕上的位置(左、上、右、下) -* 第3行是 `['GroupBox', '数字键盘', '数字键盘GroupBox']`,为控件的标识符列表,可以选择任意一个,使用 `child_window(best_match="标识符")`来获取该窗口 -* 第4行是获取该控件的方法,请注意该方法不能保证获取唯一,`title`如果是变化的,也需要删除 `title`参数 +- 第 2 行分别是窗体的类型 `GroupBox`,窗体的题目 `数字键盘`,窗体的矩形区域坐标,对应的是屏幕上的位置(左、上、右、下) +- 第 3 行是 `['GroupBox', '数字键盘', '数字键盘GroupBox']`,为控件的标识符列表,可以选择任意一个,使用 `child_window(best_match="标识符")`来获取该窗口 +- 第 4 行是获取该控件的方法,请注意该方法不能保证获取唯一,`title`如果是变化的,也需要删除 `title`参数 -6~8行为当前对象窗口所包含的子窗口信息,信息类型对应2~4行 +6~8 行为当前对象窗口所包含的子窗口信息,信息类型对应 2~4 行 ### 窗口获取注意事项 -1. 在 `child_window`的时候,并不会立刻报错,只有在执行窗口的信息获取时才会调用,查询窗口是否存在,因此要想确定 `child_window`是否正确,可以调用子窗口对象的属性 `element_info`,来保证窗口存在 \ No newline at end of file +1. 在 `child_window`的时候,并不会立刻报错,只有在执行窗口的信息获取时才会调用,查询窗口是否存在,因此要想确定 `child_window`是否正确,可以调用子窗口对象的属性 `element_info`,来保证窗口存在 + +--- + +## 下一步 + +完成设备驱动开发后,建议继续阅读: + +- {doc}`add_device` - 了解如何将驱动添加到 Uni-Lab 中 +- {doc}`add_action` - 学习如何添加新的动作指令 +- {doc}`add_yaml` - 编写和完善 YAML 注册表 + +进阶主题: + +- {doc}`03_add_device_registry` - 详细的注册表配置 +- {doc}`04_add_device_testing` - 设备测试指南 diff --git a/docs/developer_guide/add_registry.md b/docs/developer_guide/add_registry.md new file mode 100644 index 00000000..36caa943 --- /dev/null +++ b/docs/developer_guide/add_registry.md @@ -0,0 +1,1139 @@ +# 添加设备:注册表配置完整指南 + +本文档说明如何为设备创建和配置注册表,包括基本结构、特殊类型识别、动作配置等内容。 + +## 概述 + +注册表(Registry)是 Uni-Lab 的设备配置系统,采用 YAML 格式定义设备的: + +- 可用动作(Actions) +- 状态类型(Status Types) +- 初始化参数(Init Parameters) +- 连接点(Handles) + +好消息是系统会自动生成大部分配置内容,你只需要提供核心信息,让系统帮你完成剩余工作。 + +## 快速开始:使用注册表编辑器 + +推荐使用 UniLabOS 自带的可视化编辑器,它能帮你自动生成大部分配置,省去手写的麻烦。 + +### 使用步骤 + +1. 启动 UniLabOS +2. 在浏览器中打开"注册表编辑器"页面 +3. 上传你的 Python 设备驱动文件 +4. 点击"分析文件",让系统读取类信息 +5. 填写基本信息(设备描述、图标等) +6. 点击"生成注册表",复制生成的内容 +7. 保存到 `unilabos/registry/devices/your_device.yaml` + +**提示**:我们提供了测试驱动用于在界面上尝试注册表生成,参见:`test/registry/example_devices.py` + +## 注册表的基本结构 + +### 核心字段说明 + +| 字段名 | 类型 | 需要手写 | 说明 | +| ----------------- | ------ | -------- | --------------------------------- | +| 设备标识符 | string | 是 | 设备的唯一名字,如 `mock_chiller` | +| class | object | 部分 | 设备的核心信息,必须配置 | +| description | string | 否 | 设备描述,系统默认给空字符串 | +| handles | array | 否 | 连接关系,默认为空 | +| icon | string | 否 | 图标路径,默认为空 | +| init_param_schema | object | 否 | 初始化参数,系统自动分析生成 | +| version | string | 否 | 版本号,默认 "1.0.0" | +| category | array | 否 | 设备分类,默认使用文件名 | +| config_info | array | 否 | 嵌套配置,默认为空 | +| file_path | string | 否 | 文件路径,系统自动设置 | +| registry_type | string | 否 | 注册表类型,自动设为 "device" | + +### class 字段详解 + +class 是核心部分,包含这些内容: + +| 字段名 | 类型 | 需要手写 | 说明 | +| --------------------- | ------ | -------- | ---------------------------------- | +| module | string | 是 | Python 类的路径,必须写 | +| type | string | 是 | 驱动类型,一般写 "python" | +| status_types | object | 否 | 状态类型,系统自动分析生成 | +| action_value_mappings | object | 部分 | 动作配置,系统会自动生成一些基础的 | + +### 基本结构示例 + +```yaml +my_device: + class: + module: unilabos.devices.my_module.my_device:MyDevice + type: python + status_types: + status: str + temperature: float + action_value_mappings: + # 动作配置(详见后文) + action_name: + type: UniLabJsonCommand + goal: { ... } + result: { ... } + + description: '设备描述' + version: '1.0.0' + category: + - device_category + handles: [] + icon: '' + init_param_schema: + config: + properties: + port: + default: DEFAULT_PORT + type: string + required: [] + type: object + data: + properties: + status: + type: string + temperature: + type: number + required: + - status + type: object +``` + +## 创建注册表的方式 + +### 方式 1: 使用注册表编辑器(推荐) + +适合大多数场景,快速高效。 + +**步骤**: + +1. 启动 Uni-Lab +2. 访问 Web 界面的"注册表编辑器" +3. 上传您的 Python 设备驱动文件 +4. 点击"分析文件" +5. 填写描述和图标 +6. 点击"生成注册表" +7. 复制生成的 YAML 内容 +8. 保存到 `unilabos/registry/devices/your_device.yaml` + +### 方式 2: 使用--complete_registry 参数(开发调试) + +适合开发阶段,自动补全配置。 + +```bash +# 启动时自动补全注册表 +unilab -g dev.json --complete_registry --registry_path ./my_registry +``` + +系统会: + +1. 扫描 Python 类 +2. 分析方法签名和类型 +3. 自动生成缺失的字段 +4. 保存到注册表文件 + +**或者在代码中**: + +```python +# 启动系统时使用参数 +启动系统时用 complete_registry=True 参数,让系统自动补全 +``` + +### 方式 3: 手动编写(高级) + +适合需要精细控制或特殊需求的场景。 + +**最小化配置示例**: + +```yaml +# devices/my_device.yaml +my_device: + class: + module: unilabos.devices.my_module.my_device:MyDevice + type: python +``` + +然后启动时使用 `--complete_registry` 让系统自动补全其余内容。 + +## action_value_mappings 详解 + +这个部分定义设备能做哪些动作。系统会自动生成大部分动作,你通常只需要添加特殊的自定义动作。 + +### 系统自动生成的动作 + +1. **以 `auto-` 开头的动作**:从 Python 类的方法自动生成 +2. **通用的驱动动作**: + - `_execute_driver_command`:同步执行驱动命令(仅本地可用) + - `_execute_driver_command_async`:异步执行驱动命令(仅本地可用) + +### 动作配置字段 + +| 字段名 | 需要手写 | 说明 | +| ---------------- | -------- | -------------------------------- | +| type | 是 | 动作类型,必须指定 | +| goal | 是 | 输入参数映射 | +| feedback | 否 | 实时反馈,通常为空 | +| result | 是 | 结果返回映射 | +| goal_default | 部分 | 参数默认值,ROS 动作会自动生成 | +| schema | 部分 | 前端表单配置,ROS 动作会自动生成 | +| handles | 否 | 连接关系,默认为空 | +| placeholder_keys | 否 | 特殊输入字段配置 | + +### 动作类型 + +| 类型 | 使用场景 | 系统自动生成内容 | +| ---------------------- | -------------------- | ---------------------- | +| UniLabJsonCommand | 自定义同步 JSON 命令 | 无 | +| UniLabJsonCommandAsync | 自定义异步 JSON 命令 | 无 | +| ROS 动作类型 | 标准 ROS 动作 | goal_default 和 schema | + +**常用的 ROS 动作类型**: + +- `SendCmd`:发送简单命令 +- `NavigateThroughPoses`:导航动作 +- `SingleJointPosition`:单关节位置控制 +- `Stir`:搅拌动作 +- `HeatChill`、`HeatChillStart`:加热冷却动作 + +### 动作命名建议 + +根据设备用途来起名字: + +- **启动停止类**:`start`、`stop`、`pause`、`resume` +- **设置参数类**:`set_speed`、`set_temperature`、`set_timer` +- **移动控制类**:`move_to_position`、`move_through_points` +- **功能操作类**:`stir`、`heat_chill_start`、`heat_chill_stop` +- **开关控制类**:`valve_open_cmd`、`valve_close_cmd`、`push_to` +- **命令执行类**:`send_nav_task`、`execute_command_from_outer` + +### 动作配置示例 + +```yaml +heat_chill_start: + type: HeatChillStart + goal: + purpose: purpose + temp: temp + goal_default: + purpose: '' + temp: 0.0 + handles: + output: + - handler_key: labware + label: Labware + data_type: resource + data_source: handle + data_key: liquid + placeholder_keys: + purpose: unilabos_resources + result: + status: status + success: success + schema: + description: '启动加热冷却功能' + properties: + goal: + properties: + purpose: + type: string + description: '用途说明' + temp: + type: number + description: '目标温度' + required: + - purpose + - temp + title: HeatChillStart_Goal + type: object + required: + - goal + title: HeatChillStart + type: object + feedback: {} +``` + +## 特殊类型的自动识别 + +### ResourceSlot 和 DeviceSlot 识别 + +当您在驱动代码中使用这些特殊类型时,系统会自动识别并生成相应的前端选择器。 + +**Python 驱动代码示例**: + +```python +from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot +from typing import List + +class MyDevice: + def test_resource( + self, + resource: ResourceSlot, # 单个资源 + resources: List[ResourceSlot], # 多个资源 + device: DeviceSlot, # 单个设备 + devices: List[DeviceSlot] # 多个设备 + ): + pass +``` + +**自动生成的注册表**(使用--complete_registry): + +```yaml +my_device: + class: + action_value_mappings: + test_resource: + type: UniLabJsonCommand + goal: + resource: resource + resources: resources + device: device + devices: devices + placeholder_keys: + resource: unilabos_resources # 自动添加! + resources: unilabos_resources # 自动添加! + device: unilabos_devices # 自动添加! + devices: unilabos_devices # 自动添加! + result: + success: success +``` + +### 识别规则 + +| Python 类型 | placeholder_keys 值 | 前端效果 | +| -------------------- | -------------------- | -------------- | +| `ResourceSlot` | `unilabos_resources` | 单选资源下拉框 | +| `List[ResourceSlot]` | `unilabos_resources` | 多选资源下拉框 | +| `DeviceSlot` | `unilabos_devices` | 单选设备下拉框 | +| `List[DeviceSlot]` | `unilabos_devices` | 多选设备下拉框 | + +### 前端 UI 效果 + +#### 单选资源 + +```yaml +placeholder_keys: + source: unilabos_resources +``` + +**前端渲染**: + +``` +Source: [下拉选择框 ▼] + ├── plate_1 (96孔板) + ├── tiprack_1 (枪头架) + ├── reservoir_1 (试剂槽) + └── ... +``` + +#### 多选资源 + +```yaml +placeholder_keys: + targets: unilabos_resources +``` + +**前端渲染**: + +``` +Targets: [多选下拉框 ▼] + ☑ plate_1 (96孔板) + ☐ plate_2 (384孔板) + ☑ plate_3 (96孔板) + └── ... +``` + +#### 单选设备 + +```yaml +placeholder_keys: + pump: unilabos_devices +``` + +**前端渲染**: + +``` +Pump: [下拉选择框 ▼] + ├── pump_1 (注射泵A) + ├── pump_2 (注射泵B) + └── ... +``` + +#### 多选设备 + +```yaml +placeholder_keys: + sync_devices: unilabos_devices +``` + +**前端渲染**: + +``` +Sync Devices: [多选下拉框 ▼] + ☑ heater_1 (加热器A) + ☑ stirrer_1 (搅拌器) + ☐ pump_1 (注射泵) +``` + +### 手动配置 placeholder_keys + +如果需要手动添加或覆盖自动生成的 placeholder_keys: + +#### 场景 1: 非标准参数名 + +```yaml +action_value_mappings: + custom_action: + goal: + my_custom_resource_param: resource_param + my_device_param: device_param + placeholder_keys: + my_custom_resource_param: unilabos_resources + my_device_param: unilabos_devices +``` + +#### 场景 2: 混合类型 + +```python +def mixed_params( + self, + resource: ResourceSlot, + normal_param: str, + device: DeviceSlot +): + pass +``` + +```yaml +placeholder_keys: + resource: unilabos_resources # 资源选择 + device: unilabos_devices # 设备选择 + # normal_param不需要placeholder_keys +``` + +#### 场景 3: 自定义选择器 + +```yaml +placeholder_keys: + special_param: custom_selector # 使用自定义选择器 +``` + +## 系统自动生成的字段 + +### status_types + +系统会扫描你的 Python 类,从状态方法(property 或 get\_方法)自动生成这部分: + +```yaml +status_types: + current_temperature: float # 从 get_current_temperature() 或 @property current_temperature + is_heating: bool # 从 get_is_heating() 或 @property is_heating + status: str # 从 get_status() 或 @property status +``` + +**注意事项**: + +- 系统会查找所有 `get_` 开头的方法和 `@property` 装饰的属性 +- 类型会自动转成相应的类型(如 `str`、`float`、`bool`) +- 如果类型是 `Any`、`None` 或未知的,默认使用 `String` + +### init_param_schema + +完全由系统自动生成,无需手动编写: + +```yaml +init_param_schema: + config: # 从 __init__ 方法分析得出 + properties: + port: + type: string + default: '/dev/ttyUSB0' + baudrate: + type: integer + default: 9600 + required: [] + type: object + + data: # 根据 status_types 生成的前端类型定义 + properties: + current_temperature: + type: number + is_heating: + type: boolean + status: + type: string + required: + - status + type: object +``` + +**生成规则**: + +- `config` 部分:分析 `__init__` 方法的参数、类型和默认值 +- `data` 部分:根据 `status_types` 生成前端显示用的类型定义 + +### 其他自动填充的字段 + +```yaml +version: '1.0.0' # 默认版本 +category: ['文件名'] # 使用 yaml 文件名作为类别 +description: '' # 默认为空 +icon: '' # 默认为空 +handles: [] # 默认空数组 +config_info: [] # 默认空数组 +file_path: '/path/to/file' # 系统自动填写 +registry_type: 'device' # 自动设为设备类型 +``` + +### handles 字段 + +定义设备连接关系: + +```yaml +handles: # 大多数情况为空,除非设备需要特定连接 + - handler_key: device_output + label: Device Output + data_type: resource + data_source: value + data_key: default_value +``` + +### 可选配置字段 + +```yaml +description: '设备的详细描述' + +icon: 'device_icon.webp' # 设备图标文件名(会上传到OSS) + +version: '0.0.1' # 版本号 + +category: # 设备分类,前端用于分组显示 + - 'heating' + - 'cooling' + - 'temperature_control' + +config_info: # 嵌套配置,用于包含子设备 + - children: + - opentrons_24_tuberack_nest_1point5ml_snapcap_A1 + - other_nested_component +``` + +## 完整示例 + +### Python 驱动代码 + +```python +# unilabos/devices/my_lab/liquid_handler.py + +from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot +from typing import List, Dict, Any, Optional + +class AdvancedLiquidHandler: + """高级液体处理工作站""" + + def __init__(self, config: Dict[str, Any]): + self.simulation = config.get('simulation', False) + self._status = "idle" + self._temperature = 25.0 + + @property + def status(self) -> str: + """设备状态""" + return self._status + + @property + def temperature(self) -> float: + """当前温度""" + return self._temperature + + def transfer( + self, + source: ResourceSlot, + target: ResourceSlot, + volume: float, + tip: Optional[ResourceSlot] = None + ) -> Dict[str, Any]: + """转移液体""" + return {"success": True} + + def multi_transfer( + self, + source: ResourceSlot, + targets: List[ResourceSlot], + volumes: List[float] + ) -> Dict[str, Any]: + """多目标转移""" + return {"success": True} + + def coordinate_with_heater( + self, + plate: ResourceSlot, + heater: DeviceSlot, + temperature: float + ) -> Dict[str, Any]: + """与加热器协同""" + return {"success": True} +``` + +### 生成的完整注册表 + +```yaml +# unilabos/registry/devices/advanced_liquid_handler.yaml + +advanced_liquid_handler: + class: + module: unilabos.devices.my_lab.liquid_handler:AdvancedLiquidHandler + type: python + + # 自动提取的状态类型 + status_types: + status: str + temperature: float + + # 自动生成的初始化参数 + init_param_schema: + config: + properties: + simulation: + type: boolean + default: false + type: object + data: + properties: + status: + type: string + temperature: + type: number + required: + - status + type: object + + # 动作映射 + action_value_mappings: + transfer: + type: UniLabJsonCommand + goal: + source: source + target: target + volume: volume + tip: tip + goal_default: + source: {} + target: {} + volume: 0.0 + tip: null + placeholder_keys: + source: unilabos_resources # 自动添加 + target: unilabos_resources # 自动添加 + tip: unilabos_resources # 自动添加 + result: + success: success + schema: + description: '转移液体' + properties: + goal: + properties: + source: + type: object + description: '源容器' + target: + type: object + description: '目标容器' + volume: + type: number + description: '体积(μL)' + tip: + type: object + description: '枪头(可选)' + required: + - source + - target + - volume + type: object + required: + - goal + type: object + + multi_transfer: + type: UniLabJsonCommand + goal: + source: source + targets: targets + volumes: volumes + placeholder_keys: + source: unilabos_resources # 单选 + targets: unilabos_resources # 多选 + result: + success: success + + coordinate_with_heater: + type: UniLabJsonCommand + goal: + plate: plate + heater: heater + temperature: temperature + placeholder_keys: + plate: unilabos_resources # 资源选择 + heater: unilabos_devices # 设备选择 + result: + success: success + + description: '高级液体处理工作站,支持多目标转移和设备协同' + version: '1.0.0' + category: + - liquid_handling + handles: [] + icon: '' +``` + +### 另一个完整示例:温度控制器 + +```yaml +my_temperature_controller: + class: + action_value_mappings: + heat_start: + type: HeatChillStart + goal: + target_temp: temp + vessel: vessel + goal_default: + target_temp: 25.0 + vessel: '' + handles: + output: + - handler_key: heated_sample + label: Heated Sample + data_type: resource + data_source: handle + data_key: sample + placeholder_keys: + vessel: unilabos_resources + result: + status: status + success: success + schema: + description: '启动加热功能' + properties: + goal: + properties: + target_temp: + type: number + description: '目标温度' + vessel: + type: string + description: '容器标识' + required: + - target_temp + - vessel + title: HeatStart_Goal + type: object + required: + - goal + title: HeatStart + type: object + feedback: {} + + stop: + type: UniLabJsonCommand + goal: {} + goal_default: {} + handles: {} + result: + status: status + schema: + description: '停止设备' + properties: + goal: + type: object + title: Stop_Goal + title: Stop + type: object + feedback: {} + + module: unilabos.devices.temperature.my_controller:MyTemperatureController + status_types: + current_temperature: float + target_temperature: float + is_heating: bool + is_cooling: bool + status: str + vessel: str + type: python + + description: '我的温度控制器设备' + handles: [] + icon: 'temperature_controller.webp' + init_param_schema: + config: + properties: + port: + default: '/dev/ttyUSB0' + type: string + baudrate: + default: 9600 + type: number + required: [] + type: object + data: + properties: + current_temperature: + type: number + target_temperature: + type: number + is_heating: + type: boolean + is_cooling: + type: boolean + status: + type: string + vessel: + type: string + required: + - current_temperature + - target_temperature + - status + type: object + + version: '1.0.0' + category: + - 'temperature_control' + - 'heating' + config_info: [] +``` + +## 部署和使用 + +### Python 驱动类要求 + +你的设备类需要符合以下要求: + +```python +from unilabos.common.device_base import DeviceBase + +class MyDevice(DeviceBase): + def __init__(self, config): + """初始化,参数会自动分析到 init_param_schema.config""" + super().__init__(config) + self.port = config.get('port', '/dev/ttyUSB0') + + # 状态方法(会自动生成到 status_types) + @property + def status(self): + """返回设备状态""" + return "idle" + + @property + def temperature(self): + """返回当前温度""" + return 25.0 + + # 动作方法(会自动生成 auto- 开头的动作) + async def start_heating(self, temperature: float): + """开始加热到指定温度""" + pass + + def stop(self): + """停止操作""" + pass +``` + +### 方法一:使用编辑器(推荐) + +1. 先编写 Python 驱动类 +2. 使用注册表编辑器自动生成 yaml 配置 +3. 保存生成的文件到 `devices/` 目录 +4. 重启 UniLabOS 即可使用 + +### 方法二:手动编写(简化版) + +1. 创建最小配置: + +```yaml +# devices/my_device.yaml +my_device: + class: + module: unilabos.devices.my_module.my_device:MyDevice + type: python +``` + +2. 启动系统时使用 `--complete_registry` 参数,让系统自动补全 + +3. 检查生成的配置是否符合预期 + +### 系统集成 + +1. 将 yaml 文件放到 `unilabos/registry/devices/` 目录 +2. 系统启动时会自动扫描并加载设备 +3. 系统会自动补全所有缺失的字段 +4. 设备即可在前端界面中使用 + +### 高级配置 + +如果需要特殊设置,可以手动添加: + +```yaml +my_device: + class: + module: unilabos.devices.my_module.my_device:MyDevice + type: python + action_value_mappings: + # 自定义动作 + special_command: + type: UniLabJsonCommand + goal: {} + result: {} + + # 可选的自定义配置 + description: '我的特殊设备' + icon: 'my_device.webp' + category: ['temperature', 'heating'] +``` + +## 调试和验证 + +### 1. 检查生成的注册表 + +```bash +# 使用complete_registry生成 +unilab -g dev.json --complete_registry + +# 查看生成的文件 +cat unilabos/registry/devices/my_device.yaml +``` + +### 2. 验证 placeholder_keys + +确认: + +- ResourceSlot 参数有 `unilabos_resources` +- DeviceSlot 参数有 `unilabos_devices` +- List 类型被正确识别 + +### 3. 测试前端效果 + +1. 启动 Uni-Lab +2. 访问 Web 界面 +3. 选择设备 +4. 调用动作 +5. 检查是否显示正确的选择器 + +### 4. 检查文件路径和导入 + +```bash +# 确认模块路径正确 +python -c "from unilabos.devices.my_module.my_device import MyDevice" +``` + +## 常见问题 + +### Q1: placeholder_keys 没有自动生成 + +**检查**: + +1. 是否使用了`--complete_registry`参数? +2. 类型注解是否正确? + + ```python + # ✓ 正确 + def method(self, resource: ResourceSlot): + + # ✗ 错误(缺少类型注解) + def method(self, resource): + ``` + +3. 是否正确导入? + ```python + from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot + ``` + +### Q2: 前端显示普通输入框而不是选择器 + +**原因**: placeholder_keys 未正确配置 + +**解决**: + +```yaml +# 检查YAML中是否有 +placeholder_keys: + resource: unilabos_resources +``` + +### Q3: 多选不工作 + +**检查类型注解**: + +```python +# ✓ 正确 - 会生成多选 +def method(self, resources: List[ResourceSlot]): + +# ✗ 错误 - 会生成单选 +def method(self, resources: ResourceSlot): +``` + +### Q4: 运行时收到错误的类型 + +**说明**: 运行时会自动转换 + +前端传递: + +```json +{ + "resource": "plate_1" // 字符串ID +} +``` + +运行时收到: + +```python +resource.id # "plate_1" +resource.name # "96孔板" +resource.type # "resource" +# 完整的Resource对象 +``` + +### Q5: 设备加载不了 + +**检查**: + +1. 确认 `class.module` 路径是否正确 +2. 确认 Python 驱动类能否正常导入 +3. 使用 yaml 验证器检查文件格式 +4. 查看 UniLabOS 启动日志中的错误信息 + +### Q6: 自动生成失败 + +**检查**: + +1. 确认类继承了正确的基类 +2. 确保状态方法的返回类型注解清晰 +3. 检查类能否被动态导入 +4. 确认启用了 `complete_registry=True` + +### Q7: 前端显示问题 + +**解决步骤**: + +1. 删除旧的 yaml 文件,用编辑器重新生成 +2. 清除浏览器缓存,重新加载页面 +3. 确认必需字段(如 `schema`)都存在 +4. 检查 `goal_default` 和 `schema` 的数据类型是否一致 + +### Q8: 动作执行出错 + +**检查**: + +1. 确认动作方法名符合规范(如 `execute_`) +2. 检查 `goal` 字段的参数映射是否正确 +3. 确认方法返回值格式符合 `result` 映射 +4. 在驱动类中添加异常处理 + +## 最佳实践 + +### 开发流程 + +1. **优先使用编辑器**:除非有特殊需求,否则优先使用注册表编辑器 +2. **最小化配置**:手动配置时只定义必要字段,让系统自动生成其他内容 +3. **增量开发**:先创建基本配置,后续根据需要添加特殊动作 +4. **及时测试**:每次修改后及时在开发环境测试 + +### 代码规范 + +1. **始终使用类型注解** + +```python +# ✓ 好 +def method(self, resource: ResourceSlot, device: DeviceSlot): + pass + +# ✗ 差 +def method(self, resource, device): + pass +``` + +2. **提供有意义的参数名** + +```python +# ✓ 好 - 清晰的参数名 +def transfer(self, source: ResourceSlot, target: ResourceSlot): + pass + +# ✗ 差 - 模糊的参数名 +def transfer(self, r1: ResourceSlot, r2: ResourceSlot): + pass +``` + +3. **使用 Optional 表示可选参数** + +```python +from typing import Optional + +def method( + self, + required_resource: ResourceSlot, + optional_resource: Optional[ResourceSlot] = None +): + pass +``` + +4. **添加详细的文档字符串** + +```python +def method( + self, + source: ResourceSlot, # 源容器 + targets: List[ResourceSlot] # 目标容器列表 +) -> Dict[str, Any]: + """方法说明 + + Args: + source: 源容器,必须包含足够的液体 + targets: 目标容器列表,每个容器应该为空 + + Returns: + 包含操作结果的字典 + """ + pass +``` + +5. **方法命名规范** + + - 状态方法使用 `@property` 装饰器或 `get_` 前缀 + - 动作方法使用动词开头 + - 保持命名清晰、一致 + +6. **完善的错误处理** + - 实现完善的错误处理 + - 添加日志记录 + - 提供有意义的错误信息 + +### 配置管理 + +1. **版本控制**:所有 yaml 文件纳入版本控制 +2. **命名一致性**:设备 ID、文件名、类名保持一致的命名风格 +3. **定期更新**:定期运行完整注册以更新自动生成的字段 +4. **备份配置**:在修改前备份重要的手动配置 +5. **文档同步**:保持配置文件和文档的同步更新 + +### 测试验证 + +1. **本地测试**:在本地环境充分测试后再部署 +2. **渐进部署**:先部署到测试环境,验证无误后再上生产环境 +3. **监控日志**:密切监控设备加载和运行日志 +4. **回滚准备**:准备快速回滚机制,以应对紧急情况 +5. **自动化测试**:编写单元测试和集成测试 + +### 性能优化 + +1. **按需加载**:只加载实际使用的设备类型 +2. **缓存利用**:充分利用系统的注册表缓存机制 +3. **资源管理**:合理管理设备连接和资源占用 +4. **监控指标**:设置关键性能指标的监控和告警 + +## 参考资料 + +- {doc}`add_device` - 设备驱动编写指南 +- {doc}`04_add_device_testing` - 设备测试指南 +- Python [typing 模块](https://docs.python.org/3/library/typing.html) +- [YAML 语法](https://yaml.org/) +- [JSON Schema](https://json-schema.org/) diff --git a/docs/developer_guide/add_batteryPLC.md b/docs/developer_guide/examples/battery_plc_workstation.md similarity index 51% rename from docs/developer_guide/add_batteryPLC.md rename to docs/developer_guide/examples/battery_plc_workstation.md index f1e93974..3553fd20 100644 --- a/docs/developer_guide/add_batteryPLC.md +++ b/docs/developer_guide/examples/battery_plc_workstation.md @@ -1,6 +1,17 @@ -# 电池装配工站接入(PLC) +# 实例:电池装配工站接入(PLC 控制) -本指南将引导你完成电池装配工站(以 PLC 控制为例)的接入流程,包括新建工站文件、编写驱动与寄存器读写、生成注册表、上传及注意事项。 +> **文档类型**:实际应用案例 +> **适用场景**:使用 PLC 控制的电池装配工站接入 +> **前置知识**:{doc}`../add_device` | {doc}`../add_registry` + +本指南以电池装配工站为实际案例,引导你完成 PLC 控制设备的完整接入流程,包括新建工站文件、编写驱动与寄存器读写、生成注册表、上传及注意事项。 + +## 案例概述 + +**设备类型**:电池装配工站 +**通信方式**:Modbus TCP (PLC) +**工站基类**:`WorkstationBase` +**主要功能**:电池组装、寄存器读写、数据采集 ## 1. 新建工站文件 @@ -39,8 +50,6 @@ class CoinCellAssemblyWorkstation(WorkstationBase): self.client = tcp.register_node_list(self.nodes) ``` - - ## 2. 编写驱动与寄存器读写 ### 2.1 寄存器示例 @@ -84,49 +93,49 @@ def start_and_read_metrics(self): 完成工站类与驱动后,需要生成(或更新)工站注册表供系统识别。 - ### 3.1 新增工站设备(或资源)首次生成注册表 -首先通过以下命令启动unilab。进入unilab系统状态检查页面 + +首先通过以下命令启动 unilab。进入 unilab 系统状态检查页面 ```bash python unilabos\app\main.py -g celljson.json --ak --sk ``` 点击注册表编辑,进入注册表编辑页面 -![Layers](image_add_batteryPLC/unilab_sys_status.png) + +![系统状态页面](image_battery_plc/unilab_sys_status.png) 按照图示步骤填写自动生成注册表信息: -![Layers](image_add_batteryPLC/unilab_registry_process.png) + +![注册表生成流程](image_battery_plc/unilab_registry_process.png) 步骤说明: + 1. 选择新增的工站`coin_cell_assembly.py`文件 2. 点击分析按钮,分析`coin_cell_assembly.py`文件 3. 选择`coin_cell_assembly.py`文件中继承`WorkstationBase`类 -4. 填写新增的工站.py文件与`unilabos`目录的距离。例如,新增的工站文件`coin_cell_assembly.py`路径为`unilabos\devices\workstation\coin_cell_assembly\coin_cell_assembly.py`,则此处填写`unilabos.devices.workstation.coin_cell_assembly`。 +4. 填写新增的工站.py 文件与`unilabos`目录的距离。例如,新增的工站文件`coin_cell_assembly.py`路径为`unilabos\devices\workstation\coin_cell_assembly\coin_cell_assembly.py`,则此处填写`unilabos.devices.workstation.coin_cell_assembly`。 5. 此处填写新定义工站的类的名字(名称可以自拟) 6. 填写新的工站注册表备注信息 7. 生成注册表 -以上操作步骤完成,则会生成的新的注册表ymal文件,如下图: -![Layers](image_add_batteryPLC/unilab_new_yaml.png) - - - - +以上操作步骤完成,则会生成的新的注册表 YAML 文件,如下图: +![生成的YAML文件](image_battery_plc/unilab_new_yaml.png) ### 3.2 添加新生成注册表 -在`unilabos\registry\devices`目录下新建一个yaml文件,此处新建文件命名为`coincellassemblyworkstation_device.yaml`,将上面生成的新的注册表信息粘贴到`coincellassemblyworkstation_device.yaml`文件中。 + +在`unilabos\registry\devices`目录下新建一个 yaml 文件,此处新建文件命名为`coincellassemblyworkstation_device.yaml`,将上面生成的新的注册表信息粘贴到`coincellassemblyworkstation_device.yaml`文件中。 在终端输入以下命令进行注册表补全操作。 + ```bash python unilabos\app\register.py --complete_registry ``` - ### 3.3 启动并上传注册表 -新增设备之后,启动unilab需要增加`--upload_registry`参数,来上传注册表信息。 +新增设备之后,启动 unilab 需要增加`--upload_registry`参数,来上传注册表信息。 ```bash python unilabos\app\main.py -g celljson.json --ak --sk --upload_registry @@ -134,14 +143,60 @@ python unilabos\app\main.py -g celljson.json --ak --sk - ## 4. 注意事项 -- 在新生成的 YAML 中,确认 `module` 指向新工站类,本例中需检查`coincellassemblyworkstation_device.yaml`文件中是否指向了`coin_cell_assembly.py`文件中定义的`CoinCellAssemblyWorkstation`类文件: +### 4.1 验证模块路径 -``` +在新生成的 YAML 中,确认 `module` 指向新工站类。本例中需检查 `coincellassemblyworkstation_device.yaml` 文件中是否正确指向了 `CoinCellAssemblyWorkstation` 类: + +```yaml module: unilabos.devices.workstation.coin_cell_assembly.coin_cell_assembly:CoinCellAssemblyWorkstation ``` -- 首次新增设备(或资源)需要在网页端新增注册表信息,`--complete_registry`补全注册表,`--upload_registry`上传注册表信息。 +### 4.2 首次接入流程 -- 如果不是新增设备(或资源),仅对工站驱动的.py文件进行了修改,则不需要在网页端新增注册表信息。只需要运行补全注册表信息之后,上传注册表即可。 +首次新增设备(或资源)需要完整流程: +1. ✅ 在网页端生成注册表信息 +2. ✅ 使用 `--complete_registry` 补全注册表 +3. ✅ 使用 `--upload_registry` 上传注册表信息 +### 4.3 驱动更新流程 + +如果不是新增设备,仅修改了工站驱动的 `.py` 文件: + +1. ✅ 运行 `--complete_registry` 补全注册表 +2. ✅ 运行 `--upload_registry` 上传注册表 +3. ❌ 不需要在网页端重新生成注册表 + +### 4.4 PLC 通信注意事项 + +- **握手机制**:若需参数下发,建议在 PLC 端设置标志寄存器并完成握手复位,避免粘连与竞争 +- **字节序**:FLOAT32 等多字节数据类型需要正确指定字节序(如 `WorderOrder.LITTLE`) +- **寄存器映射**:确保 CSV 文件中的寄存器地址与 PLC 实际配置一致 +- **连接稳定性**:在初始化时检查 PLC 连接状态,建议添加重连机制 + +## 5. 扩展阅读 + +### 相关文档 + +- {doc}`../add_device` - 设备驱动编写通用指南 +- {doc}`../add_registry` - 注册表配置完整指南 +- {doc}`../workstation_architecture` - 工站架构详解 + +### 技术要点 + +- **Modbus TCP 通信**:PLC 通信协议和寄存器读写 +- **WorkstationBase**:工站基类的继承和使用 +- **寄存器映射**:CSV 格式的寄存器配置 +- **注册表生成**:自动化工具使用 + +## 6. 总结 + +通过本案例,你应该掌握: + +1. ✅ 如何创建 PLC 控制的工站驱动 +2. ✅ Modbus TCP 通信和寄存器读写 +3. ✅ 使用可视化编辑器生成注册表 +4. ✅ 注册表的补全和上传流程 +5. ✅ 新增设备与更新驱动的区别 + +这个案例展示了完整的 PLC 设备接入流程,可以作为其他类似设备接入的参考模板。 diff --git a/docs/developer_guide/image_add_batteryPLC/unilab_new_yaml.png b/docs/developer_guide/examples/image_battery_plc/unilab_new_yaml.png similarity index 100% rename from docs/developer_guide/image_add_batteryPLC/unilab_new_yaml.png rename to docs/developer_guide/examples/image_battery_plc/unilab_new_yaml.png diff --git a/docs/developer_guide/image_add_batteryPLC/unilab_registry_process.png b/docs/developer_guide/examples/image_battery_plc/unilab_registry_process.png similarity index 100% rename from docs/developer_guide/image_add_batteryPLC/unilab_registry_process.png rename to docs/developer_guide/examples/image_battery_plc/unilab_registry_process.png diff --git a/docs/developer_guide/image_add_batteryPLC/unilab_sys_status.png b/docs/developer_guide/examples/image_battery_plc/unilab_sys_status.png similarity index 100% rename from docs/developer_guide/image_add_batteryPLC/unilab_sys_status.png rename to docs/developer_guide/examples/image_battery_plc/unilab_sys_status.png diff --git a/docs/developer_guide/examples/materials_construction_guide.md b/docs/developer_guide/examples/materials_construction_guide.md new file mode 100644 index 00000000..90f23905 --- /dev/null +++ b/docs/developer_guide/examples/materials_construction_guide.md @@ -0,0 +1,409 @@ +# 实例:物料构建指南 + +> **文档类型**:物料系统实战指南 +> **适用场景**:工作站物料系统构建、Deck/Warehouse/Carrier/Bottle 配置 +> **前置知识**:PyLabRobot 基础 | 资源管理概念 + +## 概述 + +在UniLab-OS系统中,任何工作站中所需要用到的物料主要包括四个核心组件: + +1. **桌子(Deck)** - 工作台面,定义整个工作空间的布局 +2. **堆栈(Warehouse)** - 存储区域,用于放置载具和物料 +3. **载具(Carriers)** - 承载瓶子等物料的容器架 +4. **瓶子(Bottles)** - 实际的物料容器 + +本文档以BioYond工作站为例,详细说明如何构建这些物料组件。 + +## 文件结构 + +物料定义文件位于 `unilabos/resources/` 文件夹中: + +``` +unilabos/resources/bioyond/ +├── decks.py # 桌子定义 +├── YB_warehouses.py # 堆栈定义 +├── YB_bottle_carriers.py # 载具定义 +└── YB_bottles.py # 瓶子定义 +``` + +对应的注册表文件位于 `unilabos/registry/resources/bioyond/` 文件夹中: + +``` +unilabos/registry/resources/bioyond/ +├── deck.yaml # 桌子注册表 +├── YB_bottle_carriers.yaml # 载具注册表 +└── YB_bottle.yaml # 瓶子注册表 +``` + +## 1. 桌子(Deck)构建 + +桌子是整个工作站的基础,定义了工作空间的尺寸和各个组件的位置。 + +### 代码示例 (decks.py) + +```python +from pylabrobot.resources import Coordinate, Deck +from unilabos.resources.bioyond.YB_warehouses import ( + bioyond_warehouse_2x2x1, + bioyond_warehouse_3x5x1, + bioyond_warehouse_20x1x1, + bioyond_warehouse_3x3x1, + bioyond_warehouse_10x1x1 +) + +class BIOYOND_YB_Deck(Deck): + def __init__( + self, + name: str = "YB_Deck", + size_x: float = 4150, # 桌子X方向尺寸 (mm) + size_y: float = 1400.0, # 桌子Y方向尺寸 (mm) + size_z: float = 2670.0, # 桌子Z方向尺寸 (mm) + category: str = "deck", + setup: bool = False + ) -> None: + super().__init__(name=name, size_x=4150.0, size_y=1400.0, size_z=2670.0) + if setup: + self.setup() # 当在工作站配置中setup为True时,自动创建并放置所有预定义的堆栈 + + def setup(self) -> None: + # 定义桌子上的各个仓库区域 + self.warehouses = { + "自动堆栈-左": bioyond_warehouse_2x2x1("自动堆栈-左"), + "自动堆栈-右": bioyond_warehouse_2x2x1("自动堆栈-右"), + "手动堆栈-左": bioyond_warehouse_3x5x1("手动堆栈-左"), + "手动堆栈-右": bioyond_warehouse_3x5x1("手动堆栈-右"), + "粉末加样头堆栈": bioyond_warehouse_20x1x1("粉末加样头堆栈"), + "配液站内试剂仓库": bioyond_warehouse_3x3x1("配液站内试剂仓库"), + "试剂替换仓库": bioyond_warehouse_10x1x1("试剂替换仓库"), + } + + # 定义各个仓库在桌子上的坐标位置 + self.warehouse_locations = { + "自动堆栈-左": Coordinate(-100.3, 171.5, 0.0), + "自动堆栈-右": Coordinate(3960.1, 155.9, 0.0), + "手动堆栈-左": Coordinate(-213.3, 804.4, 0.0), + "手动堆栈-右": Coordinate(3960.1, 807.6, 0.0), + "粉末加样头堆栈": Coordinate(415.0, 1301.0, 0.0), + "配液站内试剂仓库": Coordinate(2162.0, 437.0, 0.0), + "试剂替换仓库": Coordinate(1173.0, 802.0, 0.0), + } + + # 将仓库分配到桌子的指定位置 + for warehouse_name, warehouse in self.warehouses.items(): + self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name]) +``` + +### 在工作站配置中的使用 + +当在工作站配置文件中定义桌子时,可以通过`setup`参数控制是否自动建立所有堆栈: + +```json +{ + "id": "YB_Bioyond_Deck", + "name": "YB_Bioyond_Deck", + "children": [], + "parent": "bioyond_cell_workstation", + "type": "deck", + "class": "BIOYOND_YB_Deck", + "config": { + "type": "BIOYOND_YB_Deck", + "setup": true + }, + "data": {} +} +``` + +**重要说明**: +- 当 `"setup": true` 时,系统会自动调用桌子的 `setup()` 方法 +- 这将创建并放置所有预定义的堆栈到桌子上的指定位置 +- 如果 `"setup": false` 或省略该参数,则只创建空桌子,需要手动添加堆栈 + +### 关键要点注释 + +- `size_x`, `size_y`, `size_z`: 定义桌子的物理尺寸 +- `warehouses`: 字典类型,包含桌子上所有的仓库区域 +- `warehouse_locations`: 定义每个仓库在桌子坐标系中的位置 +- `assign_child_resource()`: 将仓库资源分配到桌子的指定位置 +- `setup()`: 可选的自动设置方法,初始化时可调用 + +## 2. 堆栈(Warehouse)构建 + +堆栈定义了存储区域的规格和布局,用于放置载具。 + +### 代码示例 (YB_warehouses.py) + +```python +from unilabos.resources.warehouse import WareHouse, YB_warehouse_factory + +def bioyond_warehouse_1x4x4(name: str) -> WareHouse: + """创建BioYond 1x4x4仓库 + + Args: + name: 仓库名称 + + Returns: + WareHouse: 仓库对象 + """ + return YB_warehouse_factory( + name=name, + num_items_x=1, # X方向位置数量 + num_items_y=4, # Y方向位置数量 + num_items_z=4, # Z方向位置数量(层数) + dx=10.0, # X方向起始偏移 + dy=10.0, # Y方向起始偏移 + dz=10.0, # Z方向起始偏移 + item_dx=137.0, # X方向间距 + item_dy=96.0, # Y方向间距 + item_dz=120.0, # Z方向间距(层高) + category="warehouse", + ) + +def bioyond_warehouse_2x2x1(name: str) -> WareHouse: + """创建BioYond 2x2x1仓库(自动堆栈)""" + return YB_warehouse_factory( + name=name, + num_items_x=2, + num_items_y=2, + num_items_z=1, # 单层 + dx=10.0, + dy=10.0, + dz=10.0, + item_dx=137.0, + item_dy=96.0, + item_dz=120.0, + category="YB_warehouse", + ) +``` + +### 关键要点注释 + +- `num_items_x/y/z`: 定义仓库在各个方向的位置数量 +- `dx/dy/dz`: 第一个位置的起始偏移坐标 +- `item_dx/dy/dz`: 相邻位置之间的间距 +- `category`: 仓库类别,用于分类管理 +- `YB_warehouse_factory`: 统一的仓库创建工厂函数 + +## 3. 载具(Carriers)构建 + +载具是承载瓶子的容器架,定义了瓶子的排列方式和位置。 + +### 代码示例 (YB_bottle_carriers.py) + +```python +from pylabrobot.resources import create_homogeneous_resources, Coordinate, ResourceHolder, create_ordered_items_2d +from unilabos.resources.itemized_carrier import Bottle, BottleCarrier +from unilabos.resources.bioyond.YB_bottles import YB_pei_ye_xiao_Bottle + +def YB_peiyepingxiaoban(name: str) -> BottleCarrier: + """配液瓶(小)板 - 4x2布局,8个位置 + + Args: + name: 载具名称 + + Returns: + BottleCarrier: 载具对象,包含8个配液瓶位置 + """ + + # 载具物理尺寸 (mm) + carrier_size_x = 127.8 + carrier_size_y = 85.5 + carrier_size_z = 65.0 + + # 瓶位参数 + bottle_diameter = 35.0 # 瓶子直径 + bottle_spacing_x = 42.0 # X方向瓶子间距 + bottle_spacing_y = 35.0 # Y方向瓶子间距 + + # 计算起始位置 (居中排列) + start_x = (carrier_size_x - (4 - 1) * bottle_spacing_x - bottle_diameter) / 2 + start_y = (carrier_size_y - (2 - 1) * bottle_spacing_y - bottle_diameter) / 2 + + # 创建瓶位布局:4列x2行 + sites = create_ordered_items_2d( + klass=ResourceHolder, + num_items_x=4, # 4列 + num_items_y=2, # 2行 + dx=start_x, + dy=start_y, + dz=5.0, # 瓶子底部高度 + item_dx=bottle_spacing_x, + item_dy=bottle_spacing_y, + size_x=bottle_diameter, + size_y=bottle_diameter, + size_z=carrier_size_z, + ) + + # 为每个瓶位设置名称 + for k, v in sites.items(): + v.name = f"{name}_{v.name}" + + # 创建载具对象 + carrier = BottleCarrier( + name=name, + size_x=carrier_size_x, + size_y=carrier_size_y, + size_z=carrier_size_z, + sites=sites, + model="YB_peiyepingxiaoban", + ) + + # 设置载具布局参数 + carrier.num_items_x = 4 + carrier.num_items_y = 2 + carrier.num_items_z = 1 + + # 定义瓶子排列顺序 + ordering = ["A1", "A2", "A3", "A4", "B1", "B2", "B3", "B4"] + + # 为每个位置创建瓶子实例 + for i in range(8): + carrier[i] = YB_pei_ye_xiao_Bottle(f"{name}_bottle_{ordering[i]}") + + return carrier +``` + +### 关键要点注释 + +- `carrier_size_x/y/z`: 载具的物理尺寸 +- `bottle_diameter`: 瓶子的直径,用于计算瓶位大小 +- `bottle_spacing_x/y`: 瓶子之间的间距 +- `create_ordered_items_2d`: 创建二维排列的瓶位 +- `sites`: 瓶位字典,存储所有瓶子位置信息 +- `ordering`: 定义瓶位的命名规则(如A1, A2, B1等) + +## 4. 瓶子(Bottles)构建 + +瓶子是最终的物料容器,定义了容器的物理属性。 + +### 代码示例 (YB_bottles.py) + +```python +from unilabos.resources.itemized_carrier import Bottle + +def YB_pei_ye_xiao_Bottle( + name: str, + diameter: float = 35.0, # 瓶子直径 (mm) + height: float = 60.0, # 瓶子高度 (mm) + max_volume: float = 30000.0, # 最大容量 (μL) - 30mL + barcode: str = None, # 条码 +) -> Bottle: + """创建配液瓶(小) + + Args: + name: 瓶子名称 + diameter: 瓶子直径 + height: 瓶子高度 + max_volume: 最大容量(微升) + barcode: 条码标识 + + Returns: + Bottle: 瓶子对象 + """ + return Bottle( + name=name, + diameter=diameter, + height=height, + max_volume=max_volume, + barcode=barcode, + model="YB_pei_ye_xiao_Bottle", + ) + +def YB_ye_Bottle( + name: str, + diameter: float = 40.0, + height: float = 70.0, + max_volume: float = 50000.0, # 最大容量 + barcode: str = None, +) -> Bottle: + """创建液体瓶""" + return Bottle( + name=name, + diameter=diameter, + height=height, + max_volume=max_volume, + barcode=barcode, + model="YB_ye_Bottle", + ) +``` + +### 关键要点注释 + +- `diameter`: 瓶子直径,影响瓶位大小计算 +- `height`: 瓶子高度,用于碰撞检测和移液计算 +- `max_volume`: 最大容量,单位为微升(μL) +- `barcode`: 条码标识,用于瓶子追踪 +- `model`: 型号标识,用于区分不同类型的瓶子 + +## 5. 注册表配置 + +创建完物料定义后,需要在注册表中注册这些物料,使系统能够识别和使用它们。 + +在 `unilabos/registry/resources/bioyond/` 目录下创建: + +- `deck.yaml` - 桌子注册表 +- `YB_bottle_carriers.yaml` - 载具注册表 +- `YB_bottle.yaml` - 瓶子注册表 + +### 5.1 桌子注册表 (deck.yaml) + +```yaml +BIOYOND_YB_Deck: + category: + - deck # 前端显示的分类存放 + class: + module: unilabos.resources.bioyond.decks:BIOYOND_YB_Deck # 定义桌子的类的路径 + type: pylabrobot + description: BIOYOND_YB_Deck # 描述信息 + handles: [] + icon: 配液站.webp # 图标文件 + init_param_schema: {} + registry_type: resource # 注册类型 + version: 1.0.0 # 版本号 +``` + +### 5.2 载具注册表 (YB_bottle_carriers.yaml) + +```yaml +YB_peiyepingxiaoban: + category: + - yb3 + - YB_bottle_carriers + class: + module: unilabos.resources.bioyond.YB_bottle_carriers:YB_peiyepingxiaoban + type: pylabrobot + description: YB_peiyepingxiaoban + handles: [] + icon: '' + init_param_schema: {} + registry_type: resource + version: 1.0.0 +``` + +### 5.3 瓶子注册表 (YB_bottle.yaml) + +```yaml +YB_pei_ye_xiao_Bottle: + category: + - yb3 + - YB_bottle + class: + module: unilabos.resources.bioyond.YB_bottles:YB_pei_ye_xiao_Bottle + type: pylabrobot + description: YB_pei_ye_xiao_Bottle + handles: [] + icon: '' + init_param_schema: {} + registry_type: resource + version: 1.0.0 +``` + +### 注册表关键要点注释 + +- `category`: 物料分类,用于在云端(网页界面)中的分类中显示 +- `module`: Python模块路径,格式为 `模块路径:类名` +- `type`: 框架类型,通常为 `pylabrobot`(默认即可) +- `description`: 描述信息,显示在用户界面中 +- `icon`: (名称唯一自动匹配后端上传的图标文件名,显示在云端) +- `registry_type`: 固定为 `resource` +- `version`: 版本号,用于版本管理 diff --git a/docs/developer_guide/materials_tutorial.md b/docs/developer_guide/examples/materials_tutorial.md similarity index 96% rename from docs/developer_guide/materials_tutorial.md rename to docs/developer_guide/examples/materials_tutorial.md index 7142b270..398d2d86 100644 --- a/docs/developer_guide/materials_tutorial.md +++ b/docs/developer_guide/examples/materials_tutorial.md @@ -1,6 +1,10 @@ -# 物料教程(Resource) +# 实例:物料教程(Resource) -本教程面向 Uni-Lab-OS 的开发者,讲解“物料”的核心概念、3种物料格式(UniLab、PyLabRobot、奔耀Bioyond)及其相互转换方法,并说明4种 children 结构表现形式及使用场景。 +> **文档类型**:物料系统完整教程 +> **适用场景**:物料格式转换、多系统物料对接、资源结构理解 +> **前置知识**:Python 基础 | JSON 数据结构 + +本教程面向 Uni-Lab-OS 的开发者,讲解"物料"的核心概念、3种物料格式(UniLab、PyLabRobot、奔耀Bioyond)及其相互转换方法,并说明4种 children 结构表现形式及使用场景。 --- diff --git a/docs/developer_guide/examples/workstation_architecture.md b/docs/developer_guide/examples/workstation_architecture.md new file mode 100644 index 00000000..fddfd95c --- /dev/null +++ b/docs/developer_guide/examples/workstation_architecture.md @@ -0,0 +1,782 @@ +# 实例:工作站模板架构设计与对接指南 + +> **文档类型**:架构设计指南与实战案例 +> **适用场景**:大型工作站接入、子设备管理、物料系统集成 +> **前置知识**:{doc}`../add_device` | {doc}`../add_registry` + +## 0. 问题简介 + +我们可以从以下几类例子,来理解对接大型工作站需要哪些设计。本文档之后的实战案例也将由这些组成。 + +### 0.1 自研常量有机工站:最重要的是子设备管理和通信转发 + +![workstation_organic_yed](../image/workstation_architecture/workstation_organic_yed.png) + +![workstation_organic](../image/workstation_architecture/workstation_organic.png) + +这类工站由开发者自研,组合所有子设备和实验耗材、希望让他们在工作站这一级协调配合; + +1. 工作站包含大量已经注册的子设备,可能各自通信组态很不相同;部分设备可能会拥有同一个通信设备作为出口,如 2 个泵共用 1 个串口、所有设备共同接入 PLC 等。 +2. 任务系统是统一实现的 protocols,protocols 中会将高层指令处理成各子设备配合的工作流 json 并管理执行、同时更改物料信息 +3. 物料系统较为简单直接,如常量有机化学仅为工作站内固定的瓶子,初始化时就已固定;随后在任务执行过程中,记录试剂量更改信息 + +### 0.2 移液工作站:物料系统和工作流模板管理 + +![workstation_liquid_handler](../image/workstation_architecture/workstation_liquid_handler.png) + +1. 绝大多数情况没有子设备,有时候选配恒温震荡等模块时,接口也由工作站提供 +2. 所有任务系统均由工作站本身实现并下发指令,有统一的抽象函数可实现(pick_up_tips, aspirate, dispense, transfer 等)。有时需要将这些指令组合、转化为工作站的脚本语言,再统一下发。因此会形成大量固定的 protocols。 +3. 物料系统为固定的板位系统:台面上有多个可摆放位置,摆放标准孔板。 + +### 0.3 厂家开发的定制大型工站 + +![workstation_by_supplier](../image/workstation_architecture/workstation_by_supplier.png) + +由厂家开发,具备完善的物料系统、任务系统甚至调度系统;由 PLC 或 OpenAPI TCP 协议统一通信 + +1. 在监控状态时,希望展现子设备的状态;但子设备仅为逻辑概念,通信由工作站上位机接口提供;部分情况下,子设备状态是被记录在文件中的,需要读取 +2. 工作站有自己的工作流系统甚至调度系统;可以通过脚本/PLC 连续读写来配置工作站可用的工作流; +3. 部分拥有完善的物料入库、出库、过程记录,需要与 Uni-Lab-OS 物料系统对接 + +## 1. 整体架构图 + +### 1.1 工作站核心架构 + +```{mermaid} +graph TB + subgraph "工作站模板组成" + WB[WorkstationBase
工作流状态管理] + RPN[ROS2WorkstationNode
Protocol执行引擎] + WB -.post_init关联.-> RPN + end + + subgraph "物料管理系统" + DECK[Deck
PLR本地物料系统] + RS[ResourceSynchronizer
外部物料同步器] + WB --> DECK + WB --> RS + RS --> DECK + end + + subgraph "通信与子设备管理" + HW[hardware_interface
硬件通信接口] + SUBDEV[子设备集合
pumps/grippers/sensors] + WB --> HW + RPN --> SUBDEV + HW -.代理模式.-> RPN + end + + subgraph "工作流任务系统" + PROTO[Protocol定义
LiquidHandling/PlateHandling] + WORKFLOW[Workflow执行器
步骤管理与编排] + RPN --> PROTO + RPN --> WORKFLOW + WORKFLOW --> SUBDEV + end +``` + +### 1.2 外部系统对接关系 + +```{mermaid} +graph LR + subgraph "Uni-Lab-OS工作站" + WS[WorkstationBase + ROS2WorkstationNode] + DECK2[物料系统
Deck] + HW2[通信接口
hardware_interface] + HTTP[HTTP服务
WorkstationHTTPService] + end + + subgraph "外部物料系统" + BIOYOND[Bioyond物料管理] + LIMS[LIMS系统] + WAREHOUSE[第三方仓储] + end + + subgraph "外部硬件系统" + PLC[PLC设备] + SERIAL[串口设备] + ROBOT[机械臂/机器人] + end + + subgraph "云端系统" + CLOUD[UniLab云端
资源管理] + MONITOR[监控与调度] + end + + BIOYOND <-->|RPC双向同步| DECK2 + LIMS -->|HTTP报送| HTTP + WAREHOUSE <-->|API对接| DECK2 + + PLC <-->|Modbus TCP| HW2 + SERIAL <-->|串口通信| HW2 + ROBOT <-->|SDK/API| HW2 + + WS -->|ROS消息| CLOUD + CLOUD -->|任务下发| WS + MONITOR -->|状态查询| WS +``` + +### 1.3 具体实现示例 + +```{mermaid} +graph TB + subgraph "工作站基类" + BASE[WorkstationBase
抽象基类] + end + + subgraph "Bioyond集成工作站" + BW[BioyondWorkstation] + BW_DECK[Deck + Warehouses] + BW_SYNC[BioyondResourceSynchronizer] + BW_HW[BioyondV1RPC] + BW_HTTP[HTTP报送服务] + + BW --> BW_DECK + BW --> BW_SYNC + BW --> BW_HW + BW --> BW_HTTP + end + + subgraph "纯协议节点" + PN[ProtocolNode] + PN_SUB[子设备集合] + PN_PROTO[Protocol工作流] + + PN --> PN_SUB + PN --> PN_PROTO + end + + subgraph "PLC控制工作站" + PW[PLCWorkstation] + PW_DECK[Deck物料系统] + PW_PLC[Modbus PLC客户端] + PW_WF[工作流定义] + + PW --> PW_DECK + PW --> PW_PLC + PW --> PW_WF + end + + BASE -.继承.-> BW + BASE -.继承.-> PN + BASE -.继承.-> PW +``` + +## 2. 类关系图 + +```{mermaid} +classDiagram + class WorkstationBase { + <> + +_ros_node: ROS2WorkstationNode + +deck: Deck + +plr_resources: Dict[str, PLRResource] + +resource_synchronizer: ResourceSynchronizer + +hardware_interface: Union[Any, str] + +current_workflow_status: WorkflowStatus + +supported_workflows: Dict[str, WorkflowInfo] + + +post_init(ros_node)* + +set_hardware_interface(interface) + +call_device_method(method, *args, **kwargs) + +get_device_status() + +is_device_available() + + +get_deck() + +get_all_resources() + +find_resource_by_name(name) + +find_resources_by_type(type) + +sync_with_external_system() + + +execute_workflow(name, params) + +stop_workflow(emergency) + +workflow_status + +is_busy + } + + class ROS2WorkstationNode { + +device_id: str + +children: Dict[str, Any] + +sub_devices: Dict + +protocol_names: List[str] + +_action_clients: Dict + +_action_servers: Dict + +resource_tracker: DeviceNodeResourceTracker + + +initialize_device(device_id, config) + +create_ros_action_server(action_name, mapping) + +execute_single_action(device_id, action, kwargs) + +update_resource(resources) + +transfer_resource_to_another(resources, target, sites) + +_setup_hardware_proxy(device, comm_device, read, write) + } + + %% 物料管理相关类 + class Deck { + +name: str + +children: List + +assign_child_resource() + } + + class ResourceSynchronizer { + <> + +workstation: WorkstationBase + +sync_from_external()* + +sync_to_external(plr_resource)* + +handle_external_change(change_info)* + } + + class BioyondResourceSynchronizer { + +bioyond_api_client: BioyondV1RPC + +sync_interval: int + +last_sync_time: float + + +initialize() + +sync_from_external() + +sync_to_external(resource) + +handle_external_change(change_info) + } + + %% 硬件接口相关类 + class HardwareInterface { + <> + } + + class BioyondV1RPC { + +base_url: str + +api_key: str + +stock_material() + +add_material() + +material_inbound() + } + + %% 服务类 + class WorkstationHTTPService { + +workstation: WorkstationBase + +host: str + +port: int + +server: HTTPServer + +running: bool + + +start() + +stop() + +_handle_step_finish_report() + +_handle_sample_finish_report() + +_handle_order_finish_report() + +_handle_material_change_report() + +_handle_error_handling_report() + } + + %% 具体实现类 + class BioyondWorkstation { + +bioyond_config: Dict + +workflow_mappings: Dict + +workflow_sequence: List + + +post_init(ros_node) + +transfer_resource_to_another() + +resource_tree_add(resources) + +append_to_workflow_sequence(name) + +get_all_workflows() + +get_bioyond_status() + } + + class ProtocolNode { + +post_init(ros_node) + } + + %% 核心关系 + WorkstationBase o-- ROS2WorkstationNode : post_init关联 + WorkstationBase o-- WorkstationHTTPService : 可选服务 + + %% 物料管理侧 + WorkstationBase *-- Deck : deck + WorkstationBase *-- ResourceSynchronizer : 可选组合 + ResourceSynchronizer <|-- BioyondResourceSynchronizer + + %% 硬件接口侧 + WorkstationBase o-- HardwareInterface : hardware_interface + HardwareInterface <|.. BioyondV1RPC : 实现 + BioyondResourceSynchronizer --> BioyondV1RPC : 使用 + + %% 继承关系 + BioyondWorkstation --|> WorkstationBase + ProtocolNode --|> WorkstationBase + ROS2WorkstationNode --|> BaseROS2DeviceNode : 继承 +``` + +## 3. 工作站启动时序图 + +```{mermaid} +sequenceDiagram + participant APP as Application + participant WS as WorkstationBase + participant DECK as PLR Deck + participant SYNC as ResourceSynchronizer + participant HW as HardwareInterface + participant ROS as ROS2WorkstationNode + participant HTTP as HTTPService + + APP->>WS: 创建工作站实例(__init__) + WS->>DECK: 初始化PLR Deck + DECK->>DECK: 创建Warehouse等子资源 + DECK-->>WS: Deck创建完成 + + WS->>HW: 创建硬件接口(如BioyondV1RPC) + HW->>HW: 建立连接(PLC/RPC/串口等) + HW-->>WS: 硬件接口就绪 + + WS->>SYNC: 创建ResourceSynchronizer(可选) + SYNC->>HW: 使用hardware_interface + SYNC->>SYNC: 初始化同步配置 + SYNC-->>WS: 同步器创建完成 + + WS->>SYNC: sync_from_external() + SYNC->>HW: 查询外部物料系统 + HW-->>SYNC: 返回物料数据 + SYNC->>DECK: 转换并添加到Deck + SYNC-->>WS: 同步完成 + + Note over WS: __init__完成,等待ROS节点 + + APP->>ROS: 初始化ROS2WorkstationNode + ROS->>ROS: 初始化子设备(children) + ROS->>ROS: 创建Action客户端 + ROS->>ROS: 设置硬件接口代理 + ROS-->>APP: ROS节点就绪 + + APP->>WS: post_init(ros_node) + WS->>WS: self._ros_node = ros_node + WS->>ROS: update_resource([deck]) + ROS->>ROS: 上传物料到云端 + ROS-->>WS: 上传完成 + + WS->>HTTP: 创建WorkstationHTTPService(可选) + HTTP->>HTTP: 启动HTTP服务器线程 + HTTP-->>WS: HTTP服务启动 + + WS-->>APP: 工作站完全就绪 +``` + +## 4. 工作流执行时序图(Protocol 模式) + +```{mermaid} +sequenceDiagram + participant CLIENT as 客户端 + participant ROS as ROS2WorkstationNode + participant WS as WorkstationBase + participant HW as HardwareInterface + participant DECK as PLR Deck + participant CLOUD as 云端资源管理 + participant DEV as 子设备 + + CLIENT->>ROS: 发送Protocol Action请求 + ROS->>ROS: execute_protocol回调 + ROS->>ROS: 从Goal提取参数 + ROS->>ROS: 调用protocol_steps_generator + ROS->>ROS: 生成action步骤列表 + + ROS->>WS: 更新workflow_status = RUNNING + + loop 执行每个步骤 + alt 调用子设备 + ROS->>ROS: execute_single_action(device_id, action, params) + ROS->>DEV: 发送Action Goal(通过Action Client) + DEV->>DEV: 执行设备动作 + DEV-->>ROS: 返回Result + else 调用工作站自身 + ROS->>WS: call_device_method(method, *args) + alt 直接模式 + WS->>HW: 调用hardware_interface方法 + HW->>HW: 执行硬件操作 + HW-->>WS: 返回结果 + else 代理模式 + WS->>ROS: 转发到子设备 + ROS->>DEV: 调用子设备方法 + DEV-->>ROS: 返回结果 + ROS-->>WS: 返回结果 + end + WS-->>ROS: 返回结果 + end + + ROS->>DECK: 更新本地物料状态 + DECK->>DECK: 修改PLR资源属性 + end + + ROS->>CLOUD: 同步物料到云端(可选) + CLOUD-->>ROS: 同步完成 + + ROS->>WS: 更新workflow_status = COMPLETED + ROS-->>CLIENT: 返回Protocol Result +``` + +## 5. HTTP 报送处理时序图 + +```{mermaid} +sequenceDiagram + participant EXT as 外部工作站/LIMS + participant HTTP as HTTPService + participant WS as WorkstationBase + participant DECK as PLR Deck + participant SYNC as ResourceSynchronizer + participant CLOUD as 云端 + + EXT->>HTTP: POST /report/step_finish + HTTP->>HTTP: 解析请求数据 + HTTP->>HTTP: 验证LIMS协议字段 + HTTP->>WS: process_step_finish_report(request) + + WS->>WS: 增加接收计数(_reports_received_count++) + WS->>WS: 记录步骤完成事件 + WS->>DECK: 更新相关物料状态(可选) + DECK->>DECK: 修改PLR资源状态 + + WS->>WS: 保存报送记录到内存 + + WS-->>HTTP: 返回处理结果 + HTTP->>HTTP: 构造HTTP响应 + HTTP-->>EXT: 200 OK + acknowledgment_id + + Note over EXT,CLOUD: 类似处理sample_finish, order_finish等报送 + + alt 物料变更报送 + EXT->>HTTP: POST /report/material_change + HTTP->>WS: process_material_change_report(data) + WS->>DECK: 查找或创建物料 + WS->>SYNC: sync_to_external(resource) + SYNC->>SYNC: 同步到外部系统(如Bioyond) + SYNC-->>WS: 同步完成 + WS->>CLOUD: update_resource(通过ROS节点) + CLOUD-->>WS: 上传完成 + WS-->>HTTP: 返回结果 + HTTP-->>EXT: 200 OK + end +``` + +## 6. 错误处理时序图 + +```{mermaid} +sequenceDiagram + participant DEV as 子设备/外部系统 + participant ROS as ROS2WorkstationNode + participant WS as WorkstationBase + participant HW as HardwareInterface + participant HTTP as HTTPService + participant LOG as 日志系统 + + alt 设备错误(ROS Action失败) + DEV->>ROS: Action返回失败结果 + ROS->>ROS: 记录错误信息 + ROS->>WS: 更新workflow_status = ERROR + ROS->>LOG: 记录错误日志 + else 外部系统错误报送 + DEV->>HTTP: POST /report/error_handling + HTTP->>WS: handle_external_error(error_data) + WS->>WS: 记录错误历史 + WS->>LOG: 记录错误日志 + end + + alt 关键错误需要停止 + WS->>ROS: stop_workflow(emergency=True) + ROS->>ROS: 取消所有进行中的Action + ROS->>HW: 调用emergency_stop()(如果支持) + HW->>HW: 执行紧急停止 + WS->>WS: 更新workflow_status = ERROR + else 可恢复错误 + WS->>WS: 标记步骤失败 + WS->>ROS: 触发重试逻辑(可选) + ROS->>DEV: 重新发送Action + end + + WS-->>HTTP: 返回错误处理结果 + HTTP-->>DEV: 200 OK + 处理状态 +``` + +## 7. 典型工作站实现示例 + +### 7.1 Bioyond 集成工作站实现 + +```python +class BioyondWorkstation(WorkstationBase): + def __init__(self, bioyond_config: Dict, deck: Deck, *args, **kwargs): + # 初始化deck + super().__init__(deck=deck, *args, **kwargs) + + # 设置硬件接口为Bioyond RPC客户端 + self.hardware_interface = BioyondV1RPC(bioyond_config) + + # 创建资源同步器 + self.resource_synchronizer = BioyondResourceSynchronizer(self) + + # 从Bioyond同步物料到本地deck + self.resource_synchronizer.sync_from_external() + + # 配置工作流 + self.workflow_mappings = bioyond_config.get("workflow_mappings", {}) + + def post_init(self, ros_node: ROS2WorkstationNode): + """ROS节点就绪后的初始化""" + self._ros_node = ros_node + + # 上传deck(包括所有物料)到云端 + ROS2DeviceNode.run_async_func( + self._ros_node.update_resource, + True, + resources=[self.deck] + ) + + def resource_tree_add(self, resources: List[ResourcePLR]): + """添加物料并同步到Bioyond""" + for resource in resources: + self.deck.assign_child_resource(resource, location) + self.resource_synchronizer.sync_to_external(resource) +``` + +### 7.2 纯协议节点实现 + +```python +class ProtocolNode(WorkstationBase): + """纯协议节点,不需要物料管理和外部通信""" + + def __init__(self, deck: Optional[Deck] = None, *args, **kwargs): + super().__init__(deck=deck, *args, **kwargs) + # 不设置hardware_interface和resource_synchronizer + # 所有功能通过子设备协同完成 + + def post_init(self, ros_node: ROS2WorkstationNode): + self._ros_node = ros_node + # 不需要上传物料或其他初始化 +``` + +### 7.3 PLC 直接控制工作站 + +```python +class PLCWorkstation(WorkstationBase): + def __init__(self, plc_config: Dict, deck: Deck, *args, **kwargs): + super().__init__(deck=deck, *args, **kwargs) + + # 设置硬件接口为Modbus客户端 + from pymodbus.client import ModbusTcpClient + self.hardware_interface = ModbusTcpClient( + host=plc_config["host"], + port=plc_config["port"] + ) + self.hardware_interface.connect() + + # 定义支持的工作流 + self.supported_workflows = { + "battery_assembly": WorkflowInfo( + name="电池组装", + description="自动化电池组装流程", + estimated_duration=300.0, + required_materials=["battery_cell", "connector"], + output_product="battery_pack", + parameters_schema={"quantity": int, "model": str} + ) + } + + def execute_workflow(self, workflow_name: str, parameters: Dict): + """通过PLC执行工作流""" + workflow_id = self._get_workflow_id(workflow_name) + + # 写入PLC寄存器启动工作流 + self.hardware_interface.write_register(100, workflow_id) + self.hardware_interface.write_register(101, parameters["quantity"]) + + self.current_workflow_status = WorkflowStatus.RUNNING + return True +``` + +## 8. 核心接口说明 + +### 8.1 WorkstationBase 核心属性 + +| 属性 | 类型 | 说明 | +| ------------------------- | ----------------------- | ------------------------------- | +| `_ros_node` | ROS2WorkstationNode | ROS 节点引用,由 post_init 设置 | +| `deck` | Deck | PyLabRobot Deck,本地物料系统 | +| `plr_resources` | Dict[str, PLRResource] | 物料资源映射 | +| `resource_synchronizer` | ResourceSynchronizer | 外部物料同步器(可选) | +| `hardware_interface` | Union[Any, str] | 硬件接口或代理字符串 | +| `current_workflow_status` | WorkflowStatus | 当前工作流状态 | +| `supported_workflows` | Dict[str, WorkflowInfo] | 支持的工作流定义 | + +### 8.2 必须实现的方法 + +- `post_init(ros_node)`: ROS 节点就绪后的初始化,必须实现 + +### 8.3 硬件接口相关方法 + +- `set_hardware_interface(interface)`: 设置硬件接口 +- `call_device_method(method, *args, **kwargs)`: 统一设备方法调用 + - 支持直接模式: 直接调用 hardware_interface 的方法 + - 支持代理模式: hardware_interface="proxy:device_id"通过 ROS 转发 +- `get_device_status()`: 获取设备状态 +- `is_device_available()`: 检查设备可用性 + +### 8.4 物料管理方法 + +- `get_deck()`: 获取 PLR Deck +- `get_all_resources()`: 获取所有物料 +- `find_resource_by_name(name)`: 按名称查找物料 +- `find_resources_by_type(type)`: 按类型查找物料 +- `sync_with_external_system()`: 触发外部同步 + +### 8.5 工作流控制方法 + +- `execute_workflow(name, params)`: 执行工作流 +- `stop_workflow(emergency)`: 停止工作流 +- `workflow_status`: 获取工作流状态(属性) +- `is_busy`: 检查是否忙碌(属性) +- `workflow_runtime`: 获取运行时间(属性) + +### 8.6 可选的 HTTP 报送处理方法 + +- `process_step_finish_report()`: 步骤完成处理 +- `process_sample_finish_report()`: 样本完成处理 +- `process_order_finish_report()`: 订单完成处理 +- `process_material_change_report()`: 物料变更处理 +- `handle_external_error()`: 错误处理 + +### 8.7 ROS2WorkstationNode 核心方法 + +- `initialize_device(device_id, config)`: 初始化子设备 +- `create_ros_action_server(action_name, mapping)`: 创建 Action 服务器 +- `execute_single_action(device_id, action, kwargs)`: 执行单个动作 +- `update_resource(resources)`: 同步物料到云端 +- `transfer_resource_to_another(...)`: 跨设备物料转移 + +## 9. 配置参数说明 + +### 9.1 工作站初始化配置 + +```python +# 示例1: Bioyond集成工作站 +bioyond_config = { + "base_url": "http://192.168.1.100:8080", + "api_key": "your_api_key", + "sync_interval": 600, # 同步间隔(秒) + "workflow_mappings": { + "样品制备": "workflow_uuid_1", + "质检流程": "workflow_uuid_2" + }, + "material_type_mappings": { + "plate": "板", + "tube": "试管" + }, + "warehouse_mapping": { + "冷藏区": { + "uuid": "warehouse_uuid_1", + "locations": {...} + } + } +} + +# 创建Deck +from pylabrobot.resources import Deck +deck = Deck(name="main_deck", size_x=1000, size_y=800, size_z=200) + +workstation = BioyondWorkstation( + bioyond_config=bioyond_config, + deck=deck +) +``` + +### 9.2 子设备配置(children) + +```python +# 在devices.json中配置 +{ + "bioyond_workstation": { + "type": "protocol", # 表示这是工作站节点 + "protocol_type": ["LiquidHandling", "PlateHandling"], + "children": { + "pump_1": { + "type": "device", + "driver": "TricontInnovaDriver", + "communication": "serial_1", + "config": {...} + }, + "gripper_1": { + "type": "device", + "driver": "RobotiqGripperDriver", + "communication": "io_modbus_1", + "config": {...} + }, + "serial_1": { + "type": "communication", + "protocol": "serial", + "port": "/dev/ttyUSB0", + "baudrate": 9600 + }, + "io_modbus_1": { + "type": "communication", + "protocol": "modbus_tcp", + "host": "192.168.1.101", + "port": 502 + } + } + } +} +``` + +### 9.3 HTTP 服务配置 + +```python +from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService + +# 创建HTTP服务(可选) +http_service = WorkstationHTTPService( + workstation_instance=workstation, + host="0.0.0.0", # 监听所有网卡 + port=8081 +) +http_service.start() +``` + +## 10. 架构设计特点总结 + +这个简化后的架构设计具有以下特点: + +### 10.1 清晰的职责分离 + +- **WorkstationBase**: 负责物料管理(deck)、硬件接口(hardware_interface)、工作流状态管理 +- **ROS2WorkstationNode**: 负责子设备管理、Protocol 执行、云端物料同步 +- **ResourceSynchronizer**: 可选的外部物料系统同步(如 Bioyond) +- **WorkstationHTTPService**: 可选的 HTTP 报送接收服务 + +### 10.2 灵活的硬件接口模式 + +1. **直接模式**: hardware_interface 是具体对象(如 BioyondV1RPC、ModbusClient) +2. **代理模式**: hardware_interface="proxy:device_id",通过 ROS 节点转发到子设备 +3. **混合模式**: 工作站有自己的接口,同时管理多个子设备 + +### 10.3 统一的物料系统 + +- 基于 PyLabRobot Deck 的标准化物料表示 +- 通过 ResourceSynchronizer 实现与外部系统(如 Bioyond、LIMS)的双向同步 +- 通过 ROS2WorkstationNode 实现与云端的物料状态同步 + +### 10.4 Protocol 驱动的工作流 + +- ROS2WorkstationNode 负责 Protocol 的执行和步骤管理 +- 支持子设备协同(通过 Action Client 调用) +- 支持工作站直接控制(通过 hardware_interface) + +### 10.5 可选的 HTTP 报送服务 + +- 基于 LIMS 协议规范的统一报送接口 +- 支持步骤完成、样本完成、任务完成、物料变更等多种报送类型 +- 与工作站解耦,可独立启停 + +### 10.6 简化的初始化流程 + +``` +1. __init__: 创建deck、设置hardware_interface、创建resource_synchronizer +2. 从外部系统同步物料(如果有) +3. ROS节点初始化子设备 +4. post_init: 关联ROS节点、上传物料到云端 +5. (可选)启动HTTP服务 +``` + +这种设计既保持了灵活性,又避免了过度抽象,更适合实际的工作站对接场景。 diff --git a/docs/developer_guide/http_api.md b/docs/developer_guide/http_api.md new file mode 100644 index 00000000..a1f548df --- /dev/null +++ b/docs/developer_guide/http_api.md @@ -0,0 +1,334 @@ +# HTTP API 指南 + +本文档介绍如何通过 HTTP API 与 Uni-Lab-OS 进行交互,包括查询设备、提交任务和获取结果。 + +## 概述 + +Uni-Lab-OS 提供 RESTful HTTP API,允许外部系统通过标准 HTTP 请求控制实验室设备。API 基于 FastAPI 构建,默认运行在 `http://localhost:8002`。 + +### 基础信息 + +- **Base URL**: `http://localhost:8002/api/v1` +- **Content-Type**: `application/json` +- **响应格式**: JSON + +### 通用响应结构 + +```json +{ + "code": 0, + "data": { ... }, + "message": "success" +} +``` + +| 字段 | 类型 | 说明 | +| --------- | ------ | ------------------ | +| `code` | int | 状态码,0 表示成功 | +| `data` | object | 响应数据 | +| `message` | string | 响应消息 | + +## 快速开始 + +以下是一个完整的工作流示例:查询设备 → 获取动作 → 提交任务 → 获取结果。 + +### 步骤 1: 获取在线设备 + +```bash +curl -X GET "http://localhost:8002/api/v1/online-devices" +``` + +**响应示例**: + +```json +{ + "code": 0, + "data": { + "online_devices": { + "host_node": { + "device_key": "/host_node", + "namespace": "", + "machine_name": "本地", + "uuid": "xxx-xxx-xxx", + "node_name": "host_node" + } + }, + "total_count": 1, + "timestamp": 1732612345.123 + }, + "message": "success" +} +``` + +### 步骤 2: 获取设备可用动作 + +```bash +curl -X GET "http://localhost:8002/api/v1/devices/host_node/actions" +``` + +**响应示例**: + +```json +{ + "code": 0, + "data": { + "device_id": "host_node", + "actions": { + "test_latency": { + "type_name": "unilabos_msgs.action._empty_in.EmptyIn", + "type_name_convert": "unilabos_msgs/action/_empty_in/EmptyIn", + "action_path": "/devices/host_node/test_latency", + "goal_info": "{}", + "is_busy": false, + "current_job_id": null + }, + "create_resource": { + "type_name": "unilabos_msgs.action._resource_create_from_outer_easy.ResourceCreateFromOuterEasy", + "action_path": "/devices/host_node/create_resource", + "goal_info": "{res_id: '', device_id: '', class_name: '', ...}", + "is_busy": false, + "current_job_id": null + } + }, + "action_count": 5 + }, + "message": "success" +} +``` + +**动作状态字段说明**: + +| 字段 | 说明 | +| ---------------- | ----------------------------- | +| `type_name` | 动作类型的完整名称 | +| `action_path` | ROS2 动作路径 | +| `goal_info` | 动作参数模板 | +| `is_busy` | 动作是否正在执行 | +| `current_job_id` | 当前执行的任务 ID(如果繁忙) | + +### 步骤 3: 提交任务 + +```bash +curl -X POST "http://localhost:8002/api/v1/job/add" \ + -H "Content-Type: application/json" \ + -d '{"device_id":"host_node","action":"test_latency","action_args":{}}' +``` + +**请求体**: + +```json +{ + "device_id": "host_node", + "action": "test_latency", + "action_args": {} +} +``` + +**请求参数说明**: + +| 字段 | 类型 | 必填 | 说明 | +| ------------- | ------ | ---- | ---------------------------------- | +| `device_id` | string | ✓ | 目标设备 ID | +| `action` | string | ✓ | 动作名称 | +| `action_args` | object | ✓ | 动作参数(根据动作类型不同而变化) | + +**响应示例**: + +```json +{ + "code": 0, + "data": { + "jobId": "b6acb586-733a-42ab-9f73-55c9a52aa8bd", + "status": 1, + "result": {} + }, + "message": "success" +} +``` + +**任务状态码**: + +| 状态码 | 含义 | 说明 | +| ------ | --------- | ------------------------------ | +| 0 | UNKNOWN | 未知状态 | +| 1 | ACCEPTED | 任务已接受,等待执行 | +| 2 | EXECUTING | 任务执行中 | +| 3 | CANCELING | 任务取消中 | +| 4 | SUCCEEDED | 任务成功完成 | +| 5 | CANCELED | 任务已取消 | +| 6 | ABORTED | 任务中止(设备繁忙或执行失败) | + +### 步骤 4: 查询任务状态和结果 + +```bash +curl -X GET "http://localhost:8002/api/v1/job/b6acb586-733a-42ab-9f73-55c9a52aa8bd/status" +``` + +**响应示例(执行中)**: + +```json +{ + "code": 0, + "data": { + "jobId": "b6acb586-733a-42ab-9f73-55c9a52aa8bd", + "status": 2, + "result": {} + }, + "message": "success" +} +``` + +**响应示例(执行完成)**: + +```json +{ + "code": 0, + "data": { + "jobId": "b6acb586-733a-42ab-9f73-55c9a52aa8bd", + "status": 4, + "result": { + "error": "", + "suc": true, + "return_value": { + "avg_rtt_ms": 103.99, + "avg_time_diff_ms": 7181.55, + "max_time_error_ms": 7210.57, + "task_delay_ms": -1, + "raw_delay_ms": 33.19, + "test_count": 5, + "status": "success" + } + } + }, + "message": "success" +} +``` + +> **注意**: 任务结果在首次查询后会被自动删除,请确保保存返回的结果数据。 + +## API 端点列表 + +### 设备相关 + +| 端点 | 方法 | 说明 | +| ---------------------------------------------------------- | ---- | ---------------------- | +| `/api/v1/online-devices` | GET | 获取在线设备列表 | +| `/api/v1/devices` | GET | 获取设备配置 | +| `/api/v1/devices/{device_id}/actions` | GET | 获取指定设备的可用动作 | +| `/api/v1/devices/{device_id}/actions/{action_name}/schema` | GET | 获取动作参数 Schema | +| `/api/v1/actions` | GET | 获取所有设备的可用动作 | + +### 任务相关 + +| 端点 | 方法 | 说明 | +| ----------------------------- | ---- | ------------------ | +| `/api/v1/job/add` | POST | 提交新任务 | +| `/api/v1/job/{job_id}/status` | GET | 查询任务状态和结果 | + +### 资源相关 + +| 端点 | 方法 | 说明 | +| ------------------- | ---- | ------------ | +| `/api/v1/resources` | GET | 获取资源列表 | + +## 常见动作示例 + +### test_latency - 延迟测试 + +测试系统延迟,无需参数。 + +```bash +curl -X POST "http://localhost:8002/api/v1/job/add" \ + -H "Content-Type: application/json" \ + -d '{"device_id":"host_node","action":"test_latency","action_args":{}}' +``` + +### create_resource - 创建资源 + +在设备上创建新资源。 + +```bash +curl -X POST "http://localhost:8002/api/v1/job/add" \ + -H "Content-Type: application/json" \ + -d '{ + "device_id": "host_node", + "action": "create_resource", + "action_args": { + "res_id": "my_plate", + "device_id": "host_node", + "class_name": "Plate", + "parent": "deck", + "bind_locations": {"x": 0, "y": 0, "z": 0} + } +}' +``` + +## 错误处理 + +### 设备繁忙 + +当设备正在执行其他任务时,提交新任务会返回 `status: 6`(ABORTED): + +```json +{ + "code": 0, + "data": { + "jobId": "xxx", + "status": 6, + "result": {} + }, + "message": "success" +} +``` + +此时应等待当前任务完成后重试,或使用 `/devices/{device_id}/actions` 检查动作的 `is_busy` 状态。 + +### 参数错误 + +```json +{ + "code": 2002, + "data": { ... }, + "message": "device_id is required" +} +``` + +## 轮询策略 + +推荐的任务状态轮询策略: + +```python +import requests +import time + +def wait_for_job(job_id, timeout=60, interval=0.5): + """等待任务完成并返回结果""" + start_time = time.time() + + while time.time() - start_time < timeout: + response = requests.get(f"http://localhost:8002/api/v1/job/{job_id}/status") + data = response.json()["data"] + + status = data["status"] + if status in (4, 5, 6): # SUCCEEDED, CANCELED, ABORTED + return data + + time.sleep(interval) + + raise TimeoutError(f"Job {job_id} did not complete within {timeout} seconds") + +# 使用示例 +response = requests.post( + "http://localhost:8002/api/v1/job/add", + json={"device_id": "host_node", "action": "test_latency", "action_args": {}} +) +job_id = response.json()["data"]["jobId"] +result = wait_for_job(job_id) +print(result) +``` + +## 相关文档 + +- [设备注册指南](add_device.md) +- [动作定义指南](add_action.md) +- [网络架构概述](networking_overview.md) diff --git a/docs/developer_guide/image/workstation_architecture/workstation_by_supplier.png b/docs/developer_guide/image/workstation_architecture/workstation_by_supplier.png new file mode 100644 index 00000000..e5f3f666 Binary files /dev/null and b/docs/developer_guide/image/workstation_architecture/workstation_by_supplier.png differ diff --git a/docs/developer_guide/image/workstation_architecture/workstation_liquid_handler.png b/docs/developer_guide/image/workstation_architecture/workstation_liquid_handler.png new file mode 100644 index 00000000..71b2d9ad Binary files /dev/null and b/docs/developer_guide/image/workstation_architecture/workstation_liquid_handler.png differ diff --git a/docs/developer_guide/image/workstation_architecture/workstation_organic.png b/docs/developer_guide/image/workstation_architecture/workstation_organic.png new file mode 100644 index 00000000..cd159a81 Binary files /dev/null and b/docs/developer_guide/image/workstation_architecture/workstation_organic.png differ diff --git a/docs/developer_guide/image/workstation_architecture/workstation_organic_yed.png b/docs/developer_guide/image/workstation_architecture/workstation_organic_yed.png new file mode 100644 index 00000000..ab1da3fb Binary files /dev/null and b/docs/developer_guide/image/workstation_architecture/workstation_organic_yed.png differ diff --git a/docs/developer_guide/networking_overview.md b/docs/developer_guide/networking_overview.md new file mode 100644 index 00000000..40b308d3 --- /dev/null +++ b/docs/developer_guide/networking_overview.md @@ -0,0 +1,594 @@ +# 组网部署与主从模式配置 + +本文档介绍 Uni-Lab-OS 的组网架构、部署方式和主从模式的详细配置。 + +## 目录 + +- [架构概览](#架构概览) +- [节点类型](#节点类型) +- [通信机制](#通信机制) +- [典型拓扑](#典型拓扑) +- [主从模式配置](#主从模式配置) +- [网络配置](#网络配置) +- [示例:多房间部署](#示例多房间部署) +- [故障处理](#故障处理) +- [监控和维护](#监控和维护) + +--- + +## 架构概览 + +Uni-Lab-OS 支持多种部署模式: + +``` +┌──────────────────────────────────────────────┐ +│ Cloud Platform/Self-hosted Platform │ +│ uni-lab.bohrium.com │ +│ (Resource Management, Task Scheduling, │ +│ Monitoring) │ +└────────────────────┬─────────────────────────┘ + │ WebSocket / HTTP + │ + ┌──────────┴──────────┐ + │ │ + ┌────▼─────┐ ┌────▼─────┐ + │ Master │◄──ROS2──►│ Slave │ + │ Node │ │ Node │ + │ (Host) │ │ (Slave) │ + └────┬─────┘ └────┬─────┘ + │ │ + ┌────┴────┐ ┌────┴────┐ + │ Device A│ │ Device B│ + │ Device C│ │ Device D│ + └─────────┘ └─────────┘ +``` + +--- + +## 节点类型 + +### 主节点(Host Node) + +**功能**: + +- 创建和管理全局资源 +- 提供 host_node 服务 +- 连接云端平台 +- 协调多个从节点 +- 提供 Web 管理界面 + +**启动命令**: + +```bash +unilab --ak your_ak --sk your_sk -g host_devices.json +``` + +### 从节点(Slave Node) + +**功能**: + +- 管理本地设备 +- 不连接云端(可选) +- 向主节点注册 +- 执行分配的任务 + +**启动命令**: + +```bash +unilab --ak your_ak --sk your_sk -g slave_devices.json --is_slave +``` + +--- + +## 通信机制 + +### ROS2 通信 + +**用途**: 节点间实时通信 + +**通信方式**: + +- **Topic**: 状态广播(设备状态、传感器数据) +- **Service**: 同步请求(资源查询、配置获取) +- **Action**: 异步任务(设备操作、长时间运行) + +**示例**: + +```bash +# 查看ROS2节点 +ros2 node list + +# 查看topic +ros2 topic list + +# 查看action +ros2 action list +``` + +### WebSocket 通信 + +**用途**: 主节点与云端通信 + +**特点**: + +- 实时双向通信 +- 自动重连 +- 心跳保持 + +**配置**: + +```python +# local_config.py +BasicConfig.ak = "your_ak" +BasicConfig.sk = "your_sk" +``` + +--- + +## 典型拓扑 + +### 单节点模式 + +**适用场景**: 小型实验室、开发测试 + +``` +┌──────────────────┐ +│ Uni-Lab Node │ +│ ┌────────────┐ │ +│ │ Device A │ │ +│ │ Device B │ │ +│ │ Device C │ │ +│ └────────────┘ │ +└──────────────────┘ +``` + +**优点**: + +- 配置简单 +- 无网络延迟 +- 适合快速原型 + +**启动**: + +```bash +unilab --ak your_ak --sk your_sk -g all_devices.json +``` + +### 主从模式 + +**适用场景**: 多房间、分布式设备 + +``` +┌─────────────┐ ┌──────────────┐ +│ Master Node │◄────►│ Slave Node 1 │ +│ Coordinator │ │ Liquid │ +│ Web UI │ │ Handling │ +└──────┬──────┘ └──────────────┘ + │ + │ ┌──────────────┐ + └────────────►│ Slave Node 2 │ + │ Analytical │ + │ (NMR/GC) │ + └──────────────┘ +``` + +**优点**: + +- 物理分隔 +- 独立故障域 +- 易于扩展 + +**适用场景**: + +- 设备物理位置分散 +- 不同房间的设备 +- 需要独立故障域 +- 分阶段扩展系统 + +**主节点**: + +```bash +unilab --ak your_ak --sk your_sk -g host.json +``` + +**从节点**: + +```bash +unilab --ak your_ak --sk your_sk -g slave1.json --is_slave +unilab --ak your_ak --sk your_sk -g slave2.json --is_slave --port 8003 +``` + +### 云端集成模式 + +**适用场景**: 远程监控、多实验室协作 + +``` + Cloud Platform + │ + ┌───────┴────────┐ + │ │ +Laboratory A Laboratory B +(Master Node) (Master Node) +``` + +**优点**: + +- 远程访问 +- 数据同步 +- 任务调度 + +**启动**: + +```bash +# 实验室A +unilab --ak your_ak --sk your_sk --upload_registry --use_remote_resource + +# 实验室B +unilab --ak your_ak --sk your_sk --upload_registry --use_remote_resource +``` + +--- + +## 主从模式配置 + +### 主节点配置 + +#### 1. 创建主节点设备图 + +`host.json`: + +```json +{ + "nodes": [], + "links": [] +} +``` + +#### 2. 启动主节点 + +```bash +# 基本启动 +unilab --ak your_ak --sk your_sk -g host.json + +# 带云端集成 +unilab --ak your_ak --sk your_sk -g host.json --upload_registry + +# 指定端口 +unilab --ak your_ak --sk your_sk -g host.json --port 8002 +``` + +#### 3. 验证主节点 + +```bash +# 检查ROS2节点 +ros2 node list +# 应该看到 /host_node + +# 检查服务 +ros2 service list | grep host_node + +# Web界面 +# 访问 http://localhost:8002 +``` + +### 从节点配置 + +#### 1. 创建从节点设备图 + +`slave1.json`: + +```json +{ + "nodes": [ + { + "id": "liquid_handler_1", + "name": "液体处理工作站", + "type": "device", + "class": "liquid_handler", + "config": { + "simulation": false + } + } + ], + "links": [] +} +``` + +#### 2. 启动从节点 + +```bash +# 基本从节点启动 +unilab --ak your_ak --sk your_sk -g slave1.json --is_slave + +# 指定不同端口(如果多个从节点在同一台机器) +unilab --ak your_ak --sk your_sk -g slave1.json --is_slave --port 8003 + +# 跳过等待主节点(独立测试) +unilab --ak your_ak --sk your_sk -g slave1.json --is_slave --slave_no_host +``` + +#### 3. 验证从节点 + +```bash +# 检查节点连接 +ros2 node list + +# 检查设备状态 +ros2 topic echo /liquid_handler_1/status +``` + +### 跨节点通信 + +#### 资源访问 + +主节点可以访问从节点的资源: + +```bash +# 在主节点或其他节点调用从节点设备 +ros2 action send_goal /liquid_handler_1/transfer_liquid \ + unilabos_msgs/action/TransferLiquid \ + "{source: {...}, target: {...}, volume: 100.0}" +``` + +#### 状态监控 + +主节点监控所有从节点状态: + +```bash +# 订阅从节点状态 +ros2 topic echo /liquid_handler_1/status + +# 查看所有设备状态 +ros2 topic list | grep status +``` + +--- + +## 网络配置 + +### ROS2 DDS 配置 + +确保主从节点在同一网络: + +```bash +# 检查网络可达性 +ping + +# 设置ROS_DOMAIN_ID(可选,用于隔离) +export ROS_DOMAIN_ID=42 +``` + +### 防火墙配置 + +**建议做法**: + +为了确保 ROS2 DDS 通信正常,建议直接关闭防火墙,而不是配置特定端口。ROS2 使用动态端口范围,配置特定端口可能导致通信问题。 + +**Linux**: + +```bash +# 关闭防火墙 +sudo ufw disable + +# 或者临时停止防火墙 +sudo systemctl stop ufw +``` + +**Windows**: + +```powershell +# 在Windows安全中心关闭防火墙 +# 控制面板 -> 系统和安全 -> Windows Defender 防火墙 -> 启用或关闭Windows Defender防火墙 +``` + +### 验证网络连通性 + +在配置完成后,使用 ROS2 自带的 demo 节点来验证跨节点通信是否正常: + +**在主节点机器上**(激活 unilab 环境后): + +```bash +# 启动talker +ros2 run demo_nodes_cpp talker + +# 同时在另一个终端启动listener +ros2 run demo_nodes_cpp listener +``` + +**在从节点机器上**(激活 unilab 环境后): + +```bash +# 启动talker +ros2 run demo_nodes_cpp talker + +# 同时在另一个终端启动listener +ros2 run demo_nodes_cpp listener +``` + +**注意**:必须在两台机器上**互相启动** talker 和 listener,否则可能出现只能收不能发的单向通信问题。 + +**预期结果**: + +- 每台机器的 listener 应该能同时接收到本地和远程 talker 发送的消息 +- 如果只能看到本地消息,说明网络配置有问题 +- 如果两台机器都能互相收发消息,则组网配置正确 + +### 本地网络要求 + +**ROS2 通信**: + +- 同一局域网或 VPN +- 端口:默认 DDS 端口(7400-7500) +- 组播支持(或配置 unicast) + +**检查连通性**: + +```bash +# Ping测试 +ping + +# ROS2节点发现 +ros2 node list +ros2 daemon stop && ros2 daemon start +``` + +### 云端连接 + +**要求**: + +- HTTPS (443) +- WebSocket 支持 +- 稳定的互联网连接 + +**测试连接**: + +```bash +# 测试云端连接 +curl https://uni-lab.bohrium.com/api/v1/health + +# 测试WebSocket +# 启动Uni-Lab后查看日志 +``` + +--- + +## 示例:多房间部署 + +### 场景描述 + +- **房间 A**: 主控室,有 Web 界面 +- **房间 B**: 液体处理室 +- **房间 C**: 分析仪器室 + +### 房间 A - 主节点 + +```bash +# host.json +unilab --ak your_ak --sk your_sk -g host.json --port 8002 +``` + +### 房间 B - 从节点 1 + +```bash +# liquid_handler.json +unilab --ak your_ak --sk your_sk -g liquid_handler.json --is_slave --port 8003 +``` + +### 房间 C - 从节点 2 + +```bash +# analytical.json +unilab --ak your_ak --sk your_sk -g analytical.json --is_slave --port 8004 +``` + +--- + +## 故障处理 + +### 节点离线 + +**检测**: + +```bash +ros2 node list # 查看在线节点 +``` + +**处理**: + +1. 检查网络连接 +2. 重启节点 +3. 检查日志 + +### 从节点无法连接主节点 + +1. 检查网络: + + ```bash + ping + ``` + +2. 检查 ROS_DOMAIN_ID: + + ```bash + echo $ROS_DOMAIN_ID + ``` + +3. 使用`--slave_no_host`测试: + ```bash + unilab --ak your_ak --sk your_sk -g slave.json --is_slave --slave_no_host + ``` + +### 通信延迟 + +**排查**: + +```bash +# 网络延迟 +ping + +# ROS2话题延迟 +ros2 topic hz /device_status +ros2 topic bw /device_status +``` + +**优化**: + +- 减少发布频率 +- 使用 QoS 配置 +- 优化网络带宽 + +### 数据同步失败 + +**检查**: + +```bash +# 查看日志 +tail -f unilabos_data/logs/unilab.log | grep sync +``` + +**解决**: + +- 检查云端连接 +- 验证 AK/SK +- 手动触发同步 + +### 资源不可见 + +检查资源注册: + +```bash +ros2 service call /host_node/resource_list \ + unilabos_msgs/srv/ResourceList +``` + +--- + +## 监控和维护 + +### 节点状态监控 + +```bash +# 查看所有节点 +ros2 node list + +# 查看话题 +ros2 topic list +``` + +--- + +## 相关文档 + +- [最佳实践指南](../user_guide/best_practice.md) - 完整的实验室搭建流程 +- [安装指南](../user_guide/installation.md) - 环境安装步骤 +- [启动参数详解](../user_guide/launch.md) - 启动参数说明 +- [添加设备驱动](add_device.md) - 自定义设备开发 +- [工作站架构](workstation_architecture.md) - 复杂工作站搭建 + +--- + +## 参考资料 + +- [ROS2 网络配置](https://docs.ros.org/en/humble/Tutorials/Advanced/Networking.html) +- [DDS 配置](https://fast-dds.docs.eprosima.com/) +- Uni-Lab 云平台文档 diff --git a/docs/developer_guide/workstation_architecture.md b/docs/developer_guide/workstation_architecture.md deleted file mode 100644 index f9d113e2..00000000 --- a/docs/developer_guide/workstation_architecture.md +++ /dev/null @@ -1,378 +0,0 @@ -# 工作站基础架构设计文档 - -## 1. 整体架构图 - -```mermaid -graph TB - subgraph "工作站基础架构" - WB[WorkstationBase] - WB --> |继承| RPN[ROS2WorkstationNode] - WB --> |组合| WCB[WorkstationCommunicationBase] - WB --> |组合| MMB[MaterialManagementBase] - WB --> |组合| WHS[WorkstationHTTPService] - end - - subgraph "通信层实现" - WCB --> |实现| PLC[PLCCommunication] - WCB --> |实现| SER[SerialCommunication] - WCB --> |实现| ETH[EthernetCommunication] - end - - subgraph "物料管理实现" - MMB --> |实现| PLR[PyLabRobotMaterialManager] - MMB --> |实现| BIO[BioyondMaterialManager] - MMB --> |实现| SIM[SimpleMaterialManager] - end - - subgraph "HTTP服务" - WHS --> |处理| LIMS[LIMS协议报送] - WHS --> |处理| MAT[物料变更报送] - WHS --> |处理| ERR[错误处理报送] - end - - subgraph "具体工作站实现" - WB --> |继承| WS1[PLCWorkstation] - WB --> |继承| WS2[ReportingWorkstation] - WB --> |继承| WS3[HybridWorkstation] - end - - subgraph "外部系统" - EXT1[PLC设备] --> |通信| PLC - EXT2[外部工作站] --> |HTTP报送| WHS - EXT3[LIMS系统] --> |HTTP报送| WHS - EXT4[Bioyond物料系统] --> |查询| BIO - end -``` - -## 2. 类关系图 - -```mermaid -classDiagram - class WorkstationBase { - <> - +device_id: str - +communication: WorkstationCommunicationBase - +material_management: MaterialManagementBase - +http_service: WorkstationHTTPService - +workflow_status: WorkflowStatus - +supported_workflows: Dict - - +_create_communication_module()* - +_create_material_management_module()* - +_register_supported_workflows()* - - +process_step_finish_report() - +process_sample_finish_report() - +process_order_finish_report() - +process_material_change_report() - +handle_external_error() - - +start_workflow() - +stop_workflow() - +get_workflow_status() - +get_device_status() - } - - class ROS2WorkstationNode { - +sub_devices: Dict - +protocol_names: List - +execute_single_action() - +create_ros_action_server() - +initialize_device() - } - - class WorkstationCommunicationBase { - <> - +config: CommunicationConfig - +is_connected: bool - +connect() - +disconnect() - +start_workflow()* - +stop_workflow()* - +get_device_status()* - +write_register() - +read_register() - } - - class MaterialManagementBase { - <> - +device_id: str - +deck_config: Dict - +resource_tracker: DeviceNodeResourceTracker - +plr_deck: Deck - +find_materials_by_type() - +update_material_location() - +convert_to_unilab_format() - +_create_resource_by_type()* - } - - class WorkstationHTTPService { - +workstation_instance: WorkstationBase - +host: str - +port: int - +start() - +stop() - +_handle_step_finish_report() - +_handle_material_change_report() - } - - class PLCWorkstation { - +plc_config: Dict - +modbus_client: ModbusTCPClient - +_create_communication_module() - +_create_material_management_module() - +_register_supported_workflows() - } - - class ReportingWorkstation { - +report_handlers: Dict - +_create_communication_module() - +_create_material_management_module() - +_register_supported_workflows() - } - - WorkstationBase --|> ROS2WorkstationNode - WorkstationBase *-- WorkstationCommunicationBase - WorkstationBase *-- MaterialManagementBase - WorkstationBase *-- WorkstationHTTPService - - PLCWorkstation --|> WorkstationBase - ReportingWorkstation --|> WorkstationBase - - WorkstationCommunicationBase <|-- PLCCommunication - WorkstationCommunicationBase <|-- DummyCommunication - - MaterialManagementBase <|-- PyLabRobotMaterialManager - MaterialManagementBase <|-- SimpleMaterialManager -``` - -## 3. 工作站启动时序图 - -```mermaid -sequenceDiagram - participant APP as Application - participant WS as WorkstationBase - participant COMM as CommunicationModule - participant MAT as MaterialManager - participant HTTP as HTTPService - participant ROS as ROS2WorkstationNode - - APP->>WS: 创建工作站实例 - WS->>ROS: 初始化ROS2WorkstationNode - ROS->>ROS: 初始化子设备 - ROS->>ROS: 设置硬件接口代理 - - WS->>COMM: _create_communication_module() - COMM->>COMM: 初始化通信配置 - COMM->>COMM: 建立PLC/串口连接 - COMM-->>WS: 返回通信模块实例 - - WS->>MAT: _create_material_management_module() - MAT->>MAT: 创建PyLabRobot Deck - MAT->>MAT: 初始化物料资源 - MAT->>MAT: 注册到ResourceTracker - MAT-->>WS: 返回物料管理实例 - - WS->>WS: _register_supported_workflows() - WS->>WS: _create_workstation_services() - WS->>HTTP: _start_http_service() - HTTP->>HTTP: 创建HTTP服务器 - HTTP->>HTTP: 启动监听线程 - HTTP-->>WS: HTTP服务启动完成 - - WS-->>APP: 工作站初始化完成 -``` - -## 4. 工作流执行时序图 - -```mermaid -sequenceDiagram - participant EXT as ExternalSystem - participant WS as WorkstationBase - participant COMM as CommunicationModule - participant MAT as MaterialManager - participant ROS as ROS2WorkstationNode - participant DEV as SubDevice - - EXT->>WS: start_workflow(type, params) - WS->>WS: 验证工作流类型 - WS->>COMM: start_workflow(type, params) - COMM->>COMM: 发送启动命令到PLC - COMM-->>WS: 启动成功 - - WS->>WS: 更新workflow_status = RUNNING - - loop 工作流步骤执行 - WS->>ROS: execute_single_action(device_id, action, params) - ROS->>DEV: 发送ROS Action请求 - DEV->>DEV: 执行设备动作 - DEV-->>ROS: 返回执行结果 - ROS-->>WS: 返回动作结果 - - WS->>MAT: update_material_location(material_id, location) - MAT->>MAT: 更新PyLabRobot资源状态 - MAT-->>WS: 更新完成 - end - - WS->>COMM: get_workflow_status() - COMM->>COMM: 查询PLC状态寄存器 - COMM-->>WS: 返回状态信息 - - WS->>WS: 更新workflow_status = COMPLETED - WS-->>EXT: 工作流执行完成 -``` - -## 5. HTTP报送处理时序图 - -```mermaid -sequenceDiagram - participant EXT as ExternalWorkstation - participant HTTP as HTTPService - participant WS as WorkstationBase - participant MAT as MaterialManager - participant DB as DataStorage - - EXT->>HTTP: POST /report/step_finish - HTTP->>HTTP: 解析请求数据 - HTTP->>HTTP: 验证LIMS协议字段 - HTTP->>WS: process_step_finish_report(request) - - WS->>WS: 增加接收计数 - WS->>WS: 记录步骤完成事件 - WS->>MAT: 更新相关物料状态 - MAT->>MAT: 更新PyLabRobot资源 - MAT-->>WS: 更新完成 - - WS->>DB: 保存报送记录 - DB-->>WS: 保存完成 - - WS-->>HTTP: 返回处理结果 - HTTP->>HTTP: 构造HTTP响应 - HTTP-->>EXT: 200 OK + acknowledgment_id - - Note over EXT,DB: 类似处理sample_finish, order_finish, material_change等报送 -``` - -## 6. 错误处理时序图 - -```mermaid -sequenceDiagram - participant DEV as Device - participant WS as WorkstationBase - participant COMM as CommunicationModule - participant HTTP as HTTPService - participant EXT as ExternalSystem - - DEV->>WS: 设备错误事件 - WS->>WS: handle_external_error(error_data) - WS->>WS: 记录错误历史 - - alt 关键错误 - WS->>COMM: emergency_stop() - COMM->>COMM: 发送紧急停止命令 - WS->>WS: 更新workflow_status = ERROR - else 普通错误 - WS->>WS: 标记动作失败 - WS->>WS: 触发重试逻辑 - end - - WS->>HTTP: 记录错误报送 - HTTP->>EXT: 主动通知错误状态 - - WS-->>DEV: 错误处理完成 -``` - -## 7. 典型工作站实现示例 - -### 7.1 PLC工作站实现 - -```python -class PLCWorkstation(WorkstationBase): - def _create_communication_module(self): - return PLCCommunication(self.communication_config) - - def _create_material_management_module(self): - return PyLabRobotMaterialManager( - self.device_id, - self.deck_config, - self.resource_tracker - ) - - def _register_supported_workflows(self): - self.supported_workflows = { - "battery_assembly": WorkflowInfo(...), - "quality_check": WorkflowInfo(...) - } -``` - -### 7.2 报送接收工作站实现 - -```python -class ReportingWorkstation(WorkstationBase): - def _create_communication_module(self): - return DummyCommunication(self.communication_config) - - def _create_material_management_module(self): - return SimpleMaterialManager( - self.device_id, - self.deck_config, - self.resource_tracker - ) - - def _register_supported_workflows(self): - self.supported_workflows = { - "data_collection": WorkflowInfo(...), - "report_processing": WorkflowInfo(...) - } -``` - -## 8. 核心接口说明 - -### 8.1 必须实现的抽象方法 -- `_create_communication_module()`: 创建通信模块 -- `_create_material_management_module()`: 创建物料管理模块 -- `_register_supported_workflows()`: 注册支持的工作流 - -### 8.2 可重写的报送处理方法 -- `process_step_finish_report()`: 步骤完成处理 -- `process_sample_finish_report()`: 样本完成处理 -- `process_order_finish_report()`: 订单完成处理 -- `process_material_change_report()`: 物料变更处理 -- `handle_external_error()`: 错误处理 - -### 8.3 工作流控制接口 -- `start_workflow()`: 启动工作流 -- `stop_workflow()`: 停止工作流 -- `get_workflow_status()`: 获取状态 - -## 9. 配置参数说明 - -```python -workstation_config = { - "communication_config": { - "protocol": "modbus_tcp", - "host": "192.168.1.100", - "port": 502 - }, - "deck_config": { - "size_x": 1000.0, - "size_y": 1000.0, - "size_z": 500.0 - }, - "http_service_config": { - "enabled": True, - "host": "127.0.0.1", - "port": 8081 - }, - "communication_interfaces": { - "logical_device_1": CommunicationInterface(...) - } -} -``` - -这个架构设计支持: -1. **灵活的通信方式**: 通过CommunicationBase支持PLC、串口、以太网等 -2. **多样的物料管理**: 支持PyLabRobot、Bioyond、简单物料系统 -3. **统一的HTTP报送**: 基于LIMS协议的标准化报送接口 -4. **完整的工作流控制**: 支持动态和静态工作流 -5. **强大的错误处理**: 多层次的错误处理和恢复机制 diff --git a/docs/index.md b/docs/index.md index a8bf8252..6326bb8c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,9 +1,23 @@ -# Uni-Lab 项目文档 +# Uni-Lab-OS 项目文档 -欢迎来到项目文档的首页! +Uni-Lab-OS 是一个开源的实验室自动化操作系统,提供统一的设备接口、工作流管理和分布式部署能力。 ```{toctree} :maxdepth: 3 intro.md ``` + +## 开发者指南 + +```{toctree} +:maxdepth: 2 + +developer_guide/http_api.md +developer_guide/networking_overview.md +developer_guide/add_device.md +developer_guide/add_action.md +developer_guide/add_registry.md +developer_guide/add_yaml.md +developer_guide/action_includes.md +``` diff --git a/docs/intro.md b/docs/intro.md index 163598b4..8ef2a1ce 100644 --- a/docs/intro.md +++ b/docs/intro.md @@ -10,31 +10,51 @@ concepts/01-communication-instruction.md concepts/02-topology-and-chemputer-compile.md ``` -## **用户指南** +## 用户指南 -本指南将带你了解如何使用项目的功能。 +快速上手、系统配置与使用说明。 ```{toctree} :maxdepth: 2 +user_guide/best_practice.md user_guide/installation.md -user_guide/configuration.md user_guide/launch.md +user_guide/graph_files.md boot_examples/index.md ``` +## 进阶配置 + +高级配置和系统管理。 + +```{toctree} +:maxdepth: 2 + +advanced_usage/configuration.md +advanced_usage/working_directory.md +``` + ## 开发者指南 +设备开发、系统扩展与架构说明。 + ```{toctree} :maxdepth: 2 -developer_guide/device_driver -developer_guide/add_device -developer_guide/add_action -developer_guide/actions -developer_guide/add_protocol -developer_guide/add_batteryPLC -developer_guide/materials_tutorial.md +developer_guide/networking_overview.md +developer_guide/add_device.md +developer_guide/add_old_device.md +developer_guide/add_registry.md +developer_guide/add_yaml.md +developer_guide/add_action.md +developer_guide/actions.md +developer_guide/action_includes.md +developer_guide/add_protocol.md +developer_guide/examples/workstation_architecture.md +developer_guide/examples/materials_construction_guide.md +developer_guide/examples/materials_tutorial.md +developer_guide/examples/battery_plc_workstation.md ``` ## 接口文档 diff --git a/docs/logo.png b/docs/logo.png index 667235e7..d945e67a 100644 Binary files a/docs/logo.png and b/docs/logo.png differ diff --git a/docs/requirements.txt b/docs/requirements.txt index 36809637..1cc92477 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -2,6 +2,7 @@ sphinx>=7.0.0 sphinx-rtd-theme>=2.0.0 myst-parser>=2.0.0 +sphinxcontrib-mermaid # 用于支持Jupyter notebook文档 myst-nb>=1.0.0 diff --git a/docs/user_guide/best_practice.md b/docs/user_guide/best_practice.md new file mode 100644 index 00000000..5b960e8e --- /dev/null +++ b/docs/user_guide/best_practice.md @@ -0,0 +1,1837 @@ +# Uni-Lab-OS 最佳实践指南 + +## 关于本指南 + +本指南将引导您从零开始完成 Uni-Lab-OS 实验室系统的完整搭建,从环境安装到高级设备开发。无论您是初次接触 Uni-Lab-OS 的用户,还是希望深入定制开发的开发者,都能在本指南中找到清晰的步骤和实用建议。 + +### 适用对象 + +- **实验室管理员**:负责实验室系统部署和维护 +- **实验操作人员**:日常使用系统进行实验操作 +- **设备开发者**:为实验室添加自定义设备和功能 +- **系统集成商**:集成多个实验室系统 + +### 完整流程概览 + +``` +环境准备 → 创建实验室账号 → 系统启动 → 上传注册表 + ↓ +在线创建设备图 → 测试系统运行 → 运行工作流 + ↓ +多节点部署(可选)→ 自定义设备开发 → 复杂工作站搭建 +``` + +--- + +## 第一部分:快速上手 + +### 1. 环境准备(主机 Host) + +#### 1.1 安装 Uni-Lab-OS + +详细的安装步骤请参考 [安装指南](installation.md)。 + +**关键步骤:** + +```bash +# 1. 安装 Mamba(如果尚未安装) +# 下载 Miniforge: https://github.com/conda-forge/miniforge/releases + +# 2. 创建 Conda 环境 +mamba create -n unilab python=3.11.11 + +# 3. 激活环境 +mamba activate unilab + +# 4. 安装 Uni-Lab-OS +mamba install uni-lab::unilabos -c robostack-staging -c conda-forge +``` + +#### 1.2 验证安装 + +```bash +# 检查 unilabos 是否安装成功 +python -c "import unilabos; print(unilabos.__version__)" + +# 验证 ROS 消息包 +python -c "from unilabos_msgs.msg import Resource; print('ROS msgs OK')" +``` + +如果两条命令都正常输出,说明安装成功。 + +--- + +### 2. 创建您的第一个实验室 + +#### 2.1 注册实验室账号 + +1. 访问 [https://uni-lab.bohrium.com](https://uni-lab.bohrium.com) +2. 注册账号并登录 +3. 创建新实验室 + +#### 2.2 获取 Access Key 和 Secret Key + +1. 在实验室管理界面,找到"密钥管理" +2. 创建新的 AK/SK 密钥对 +3. **妥善保管这些密钥**(类似于密码,不要泄露) + +![复制 AK/SK](image/copy_aksk.gif) + +**重要提示:** + +- AK (Access Key):实验室的唯一标识 +- SK (Secret Key):实验室的访问密码 +- 这两个密钥在后续启动命令中都需要使用 + +#### 2.3 首次启动配置 + +首次启动时,系统会自动引导您创建配置文件: + +```bash +# 首次启动 +conda activate unilab +unilab --ak your_ak --sk your_sk +``` + +系统会询问是否创建配置文件,输入 `Y` 确认。配置文件会自动生成在 `./unilabos_data/local_config.py`。 + +更多配置选项请参考 [配置指南](../advanced_usage/configuration.md)。 + +--- + +### 3. 启动系统 + +详细的启动参数说明请参考 [启动指南](launch.md)。 + +#### 3.1 基本启动命令 + +```bash +conda activate unilab +unilab --ak your_ak --sk your_sk -g path/to/graph.json +``` + +**关键参数说明:** + +- `--ak`:您的 Access Key(必需) +- `--sk`:您的 Secret Key(必需) +- `-g`:设备组态图文件路径(必需) + +#### 3.2 首次启动注意事项 + +**第一次启动时,如果没有组态图文件:** + +由于首次启动需要 `-g` 参数指定组态图,您可以: + +**方案 1:使用预先创建好的示例组态图(推荐)** + +Uni-Lab-OS 在安装时已经预置了大量真实的设备图文件示例,无需下载或创建,直接使用 `-g` 参数指定即可: + +```bash +# 使用简单的工作台示例(推荐新手) +unilab --ak your_ak --sk your_sk -g test/experiments/workshop.json + +# 使用 PRCXI 液体处理工作站示例 +unilab --ak your_ak --sk your_sk -g test/experiments/prcxi_9300.json + +# 使用格林纳德反应工作站示例(有机合成) +unilab --ak your_ak --sk your_sk -g test/experiments/Grignard_flow_batchreact_single_pumpvalve.json + +# 使用 Bioyond 配液站示例 +unilab --ak your_ak --sk your_sk -g test/experiments/dispensing_station_bioyond.json + +# 使用 HPLC 分析系统示例 +unilab --ak your_ak --sk your_sk -g test/experiments/HPLC.json + +# 使用空设备配置(最小化配置) +unilab --ak your_ak --sk your_sk -g test/experiments/empty_devices.json +``` + +**更多可用示例**:系统预置了 40+ 个组态图文件,涵盖液体处理、有机合成、分析检测等多个领域。完整列表请查看 `unilabos/test/experiments/` 目录。 + +**方案 2:创建一个空的组态图** + +如果您想从零开始配置,可以创建一个简单的主机节点配置: + +```bash +# 创建 example_empty.json +cat > example_empty.json << 'EOF' +{ + "nodes": [], + "links": [] +} +EOF + +# 使用该配置启动 +unilab --ak your_ak --sk your_sk -g example_empty.json +``` + +> **提示**:详细的组态图文件编写指南,请参考 [设备图文件说明](graph_files.md)。 + +**启动成功标志:** + +- 终端显示 `[Host Node] Host node initialized.` +- 自动打开浏览器,显示 Web 管理界面 +- 地址:`http://localhost:8002` + +#### 3.3 常用启动选项 + +```bash +# 禁用自动打开浏览器 +unilab --ak your_ak --sk your_sk -g graph.json --disable_browser + +# 使用不同端口 +unilab --ak your_ak --sk your_sk -g graph.json --port 8080 + +# 测试环境 +unilab --addr test --ak your_ak --sk your_sk -g graph.json + +# 跳过环境检查(加快启动) +unilab --ak your_ak --sk your_sk -g graph.json --skip_env_check +``` + +--- + +### 4. 上传注册表(一次性操作) + +#### 4.1 什么是注册表上传? + +注册表包含您的设备和物料的完整定义。上传到云端后,在线界面才能识别和使用这些设备。 + +#### 4.2 何时需要上传? + +**必须上传的情况:** + +- 首次启动实验室 +- 添加了新的设备类型 +- 修改了设备的注册表定义 + +#### 4.3 如何上传注册表 + +```bash +unilab --ak your_ak --sk your_sk -g graph.json --upload_registry +``` + +**性能影响说明:** + +- 上传注册表会增加启动时间(通常 5-15 秒) +- 上传时间取决于: + - 设备和物料的数量 + - 网络速度 +- 建议:开发调试时首次上传,后续本地测试可省略 + +**验证上传成功:** + +在 Web 界面的"仪器设备"或"物料耗材"模块中,应该能看到您的设备和物料列表。 + +--- + +### 5. 在线创建您的第一个设备图 + +#### 5.1 使用预设组态图(推荐新手) + +Uni-Lab-OS 在安装时已经包含了大量真实的设备图文件,位于 `unilabos/test/experiments/` 目录,涵盖: + +- 液体处理:PRCXI、Biomek 等自动化移液工作站 +- 有机合成:格林纳德反应、流动化学等工作站 +- 分析检测:HPLC、光谱仪等分析仪器 +- 生物实验:Bioyond 配液站、反应站等 +- 协议测试:各种实验协议的测试工作站 +- 虚拟设备:用于开发调试的 mock 设备 + +**常用示例组态图:** + +| 组态图文件 | 说明 | 适合场景 | +| ------------------------------------------------ | -------------------- | -------------------- | +| `workshop.json` | 综合工作台 | 新手入门推荐 | +| `empty_devices.json` | 空设备配置 | 最小化配置,快速启动 | +| `prcxi_9300.json` | PRCXI 液体处理工作站 | 液体处理自动化 | +| `Grignard_flow_batchreact_single_pumpvalve.json` | 格林纳德反应工作站 | 有机合成流动化学 | +| `dispensing_station_bioyond.json` | Bioyond 配液站 | 生物样品配液 | +| `reaction_station_bioyond.json` | Bioyond 反应站 | 生物化学反应 | +| `HPLC.json` | HPLC 分析系统 | 色谱分析 | +| `plr_test.json` | PyLabRobot 测试 | PyLabRobot 集成测试 | +| `mock_devices/mock_all.json` | 完整虚拟设备集 | 开发调试,离线测试 | + +**使用方法:** + +```bash +# 使用简单工作台(推荐新手第一次启动) +unilab --ak your_ak --sk your_sk -g test/experiments/workshop.json + +# 使用 PRCXI 液体处理工作站 +unilab --ak your_ak --sk your_sk -g test/experiments/prcxi_9300.json + +# 使用格林纳德反应工作站(有机合成) +unilab --ak your_ak --sk your_sk -g test/experiments/Grignard_flow_batchreact_single_pumpvalve.json + +# 使用虚拟设备(无需真实硬件,用于学习和测试) +unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json +``` + +**注意**:所有组态图文件都是真实项目中使用的配置,可以直接学习和参考。完整文件列表请查看 `unilabos/test/experiments/` 目录。 + +> 关于设备图文件的结构、字段定义、格式兼容性等完整指南,请参考 [设备图文件说明](graph_files.md)。 + +#### 5.2 访问 Web 界面 + +启动系统后,访问[https://uni-lab.bohrium.com](https://uni-lab.bohrium.com) + +#### 5.3 添加设备和物料 + +进入"仪器耗材"模块: + +**示例场景:** 创建一个简单的液体转移实验 + +1. **添加工作站(必需):** + + - 在"仪器设备"中找到 `work_station` + - 添加 `workstation` x1 + +2. **添加虚拟转移泵:** + + - 在"仪器设备"中找到 `virtual_device` + - 添加 `virtual_transfer_pump` x1 + +3. **添加容器:** + - 在"物料耗材"中找到 `container` + - 添加 `container` x2 + +![物料列表](image/material.png) + +#### 5.4 建立设备连接关系 + +将设备和物料拖拽到画布上,建立父子关系: + +1. 将两个 `container` 拖拽到 `workstation` 中 +2. 将 `virtual_transfer_pump` 拖拽到 `workstation` 中 +3. 在画布上连接它们(模拟真实的物理连接) + +![设备连接](image/links.png) + +**连接规则:** + +- 所有设备和物料都必须有父节点(除了顶层工作站) +- 连接关系反映真实的物理布局 +- 连接后才能在工作流中使用 + +#### 5.5 保存组态图 + +1. 点击"保存"按钮 +2. 系统会生成 JSON 格式的组态图 +3. 可以下载保存为本地文件,下次使用 `-g` 参数启动 + +--- + +### 6. 测试实验室运行状态 + +在运行实际工作流之前,建议先测试系统是否正常运行。 + +#### 6.1 使用 host_node 的 test_latency + +`test_latency` 是 host_node 提供的测试方法,用于: + +- 测试网络延迟 +- 校准时间误差 +- 验证系统响应 + +**通过 Web 界面测试:** + +1. 在 Web 界面找到"工作流"模块 +2. 新建空白工作流 +3. 右键画布空白处,找到 `laboratory` -> `host_node` 在动作列表中找到 `test_latency` +4. 设置"关联设备名称"为 `host_node` + +![选择 host_node](image/test_latency_select_device.png) + +5. 点击保存,然后运行工作流 + +![运行测试](image/test_latency_running.png) + +6. 在控制台查看执行结果,点击查看返回值 + +![查看测试结果](image/test_latency_result.png) + +**测试结果解读:** + +系统会进行 5 次 ping-pong 测试,返回 JSON 格式的结果,包含以下关键指标: + +- **avg_rtt_ms**:平均往返延迟(毫秒),正常应在几十到几百毫秒 +- **avg_time_diff_ms**:主机与云端的平均时间差(毫秒) +- **max_time_error_ms**:最大时间误差(毫秒) +- **raw_delay_ms**:原始延迟测量值(毫秒) +- **task_delay_ms**:任务处理延迟(毫秒) +- **test_count**:测试次数 +- **status**:测试状态(success 表示成功) + +**实际返回结果示例:** + +```json +{ + "error": "", + "return_value": { + "avg_rtt_ms": 49.64, + "avg_time_diff_ms": 3872.43, + "max_time_error_ms": 3885.34, + "raw_delay_ms": 3900.24, + "status": "success", + "task_delay_ms": 27.8, + "test_count": 5 + }, + "suc": true +} +``` + +**结果评估:** + +- `avg_rtt_ms < 100ms`:网络连接良好 +- `avg_rtt_ms 100-500ms`:网络可用,略有延迟 +- `avg_rtt_ms > 500ms`:网络延迟较高,建议检查网络配置 +- `status: "success"`:测试通过 + +--- + +### 7. 运行您的第一个工作流 + +现在系统已经正常运行,让我们通过一个完整的案例来创建并执行第一个工作流。 + +#### 7.1 工作流场景 + +我们将实现一个简单的液体转移实验: + +1. 在容器 1 中加入水 +2. 通过传输泵将容器 1 中的水转移到容器 2 + +#### 7.2 添加所需的设备和物料 + +在创建工作流之前,我们需要先在"仪器耗材"模块中添加必要的设备和物料。 + +**所需设备和物料清单:** + +- 仪器设备 `work_station` 中的 `workstation` 数量 x1 +- 仪器设备 `virtual_device` 中的 `virtual_transfer_pump` 数量 x1 +- 物料耗材 `container` 中的 `container` 数量 x2 + +**操作步骤:** + +1. 访问 Web 界面,进入"仪器耗材"模块 +2. 在"仪器设备"区域找到并添加上述设备 +3. 在"物料耗材"区域找到并添加容器 + +![物料列表](image/material.png) + +#### 7.3 建立设备和物料的关联关系 + +当我们添加设备时,仪器耗材模块的物料列表会实时更新。我们需要将设备和物料拖拽到 workstation 中并在画布上将它们连接起来,就像真实的设备操作一样。 + +**操作步骤:** + +1. 将两个 `container` 拖拽到 `workstation` 中 +2. 将 `virtual_transfer_pump` 拖拽到 `workstation` 中 +3. 在画布上连接它们(建立父子关系) + +![设备连接](image/links.png) + +**连接规则:** + +- 所有设备和物料都必须有父节点(除了顶层工作站) +- 连接关系反映真实的物理布局 +- 连接后才能在工作流中使用 + +#### 7.4 创建工作流 + +1. 进入"工作流"模块 +2. 点击"我创建的" +3. 点击"新建工作流" + +![新建工作流](image/new.png) + +#### 7.5 新增工作流节点 + +我们可以进入指定工作流,在空白处右键,添加以下节点: + +- 选择 **Laboratory → host_node** 中的 `creat_resource` +- 选择 **Laboratory → workstation** 中的 `PumpTransferProtocol` + +![创建工作流](image/creatworkfollow.gif) + +#### 7.6 配置节点参数 + +根据案例,工作流包含两个步骤: + +1. 使用 `creat_resource` 在容器中创建水 +2. 通过泵传输协议将水传输到另一个容器 + +**配置 creat_resource 节点:** + +点击 `creat_resource` 卡片上的编辑按钮,配置参数: + +``` +class_name: container +device_id: workstation +liquid_input_slot: 0 或 -1 均可 +liquid_type: water +liquid_volume: 50(根据需求填写,默认单位 ml,这里举例 50) +parent: workstation +res_id: container +关联设备名称(原 unilabos_device_id): host_node +``` + +配置完成后点击底部保存按钮。 + +**配置 PumpTransferProtocol 节点:** + +点击 `PumpTransferProtocol` 卡片上的编辑按钮,配置参数: + +``` +event: transfer_liquid +from_vessel: water +to_vessel: container1 +volume: 50(根据需求填写,默认单位 ml,这里举例 50) +关联设备名称(原 unilabos_device_id): workstation +``` + +配置完成后点击底部保存按钮。 + +#### 7.7 运行工作流 + +1. 连接两个节点卡片(creat_resource → PumpTransferProtocol) +2. 点击底部保存按钮 +3. 点击运行按钮执行工作流 + +![连接并运行](image/linksandrun.png) + +#### 7.8 运行监控 + +- 运行状态和消息实时显示在底部控制台 +- 如有报错,可点击查看详细信息 +- 查看日志文件:`unilabos_data/logs/unilab.log` + +#### 7.9 结果验证 + +工作流完成后,返回"仪器耗材"模块: + +1. 点击 `container1` 卡片查看详情 +2. 确认其中包含参数指定的水和容量(50ml 的水) + +至此,您已经成功运行了第一个工作流。 + +--- + +## 第二部分:进阶部署 + +### 8. 多节点部署(Slave 模式) + +详细的组网部署和主从模式配置请参考 [组网部署与主从模式配置](../developer_guide/networking_overview.md)。 + +#### 8.1 主从模式概述 + +**适用场景:** + +- 设备物理位置分散在不同房间 +- 需要独立的故障隔离域 +- 逐步扩展实验室规模 +- 分布式实验操作 + +**架构:** + +``` +主节点(Host) + ├── 本地设备A + ├── 本地设备B + └── ROS2 通信 ←→ 从节点(Slave) + ├── 远程设备C + └── 远程设备D +``` + +#### 8.2 准备工作 + +**主节点准备:** + +1. 确保主节点已正常启动 +2. 记录主节点的 IP 地址 +3. 确保网络互通(同一局域网或 VPN) + +**网络要求:** + +- 主从节点在同一局域网 +- 可以互相 ping 通 +- 确保 ROS2 通信端口畅通 + +**验证网络连通性:** + +在配置完成后,使用 ROS2 自带的 demo 节点来验证跨节点通信是否正常: + +**在主节点机器上**(激活 unilab 环境后): + +```bash +# 终端1:启动 talker +ros2 run demo_nodes_cpp talker + +# 终端2:启动 listener +ros2 run demo_nodes_cpp listener +``` + +**在从节点机器上**(激活 unilab 环境后): + +```bash +# 终端1:启动 talker +ros2 run demo_nodes_cpp talker + +# 终端2:启动 listener +ros2 run demo_nodes_cpp listener +``` + +**注意**:必须在两台机器上**互相启动** talker 和 listener,否则可能出现只能收不能发的单向通信问题。 + +**预期结果:** + +- 每台机器的 listener 应该能同时接收到本地和远程 talker 发送的消息 +- 如果只能看到本地消息,说明网络配置有问题 +- 如果两台机器都能互相收发消息,则组网配置正确 + +**如果验证失败,尝试关闭防火墙:** + +为了确保 ROS2 DDS 通信正常,建议直接关闭防火墙,而不是配置特定端口。ROS2 使用动态端口范围,配置特定端口可能导致通信问题。 + +**Linux:** + +```bash +# 关闭防火墙 +sudo ufw disable + +# 或者临时停止防火墙 +sudo systemctl stop ufw +``` + +**Windows:** + +```powershell +# 在 Windows 安全中心关闭防火墙 +# 控制面板 -> 系统和安全 -> Windows Defender 防火墙 -> 启用或关闭 Windows Defender 防火墙 +``` + +#### 8.3 启动主节点 + +```bash +# 在主机(Host)上启动 +conda activate unilab +unilab --ak your_ak --sk your_sk -g host.json --upload_registry +``` + +**主节点职责:** + +- 与云端通信 +- 管理全局资源 +- 协调所有节点 + +#### 8.4 启动从节点 + +在另一台机器上: + +```bash +# 在从机(Slave)上启动 +conda activate unilab +unilab --ak your_ak --sk your_sk -g slave.json --is_slave +``` + +**从节点职责:** + +- 管理本地设备 +- 向主节点注册 +- 执行分配的任务 +- 不直接与云端通信 + +**重要:** 从节点使用相同的 AK/SK,因为它们属于同一个实验室。 + +#### 8.5 判断连接成功 + +**方法 1:检查 ROS2 节点列表** + +在任一节点上运行: + +```bash +ros2 node list +``` + +应该看到来自两个节点的设备: + +``` +/devices/host_node +/devices/workstation +/devices/liquid_handler_1 # 从节点的设备 +``` + +**方法 2:查看主节点设备列表** + +在主节点的 Web 界面: + +1. 进入"仪器耗材"模块 +2. 查看设备列表 +3. 从节点的设备应显示为"在线"状态 +4. 设备卡片上会显示来源机器名称 + +**方法 3:查看日志** + +主节点日志会显示: + +``` +[Host Node] Node info update request received +[Host Node] Slave node registered: liquid_handler_1 from Machine_B +``` + +从节点日志会显示: + +``` +Slave node info updated. +Resource tree synchronized with host. +``` + +#### 8.6 注册机制说明 + +从节点启动时会自动向主节点注册: + +**注册流程(代码逻辑):** + +1. **从节点启动**(`base_device_node.py` 的 `register_device` 方法): + + ```python + # 检测到非 Host 模式 + if not BasicConfig.is_host_mode: + # 创建服务客户端 + sclient = self.create_client(SerialCommand, "/node_info_update") + # 发送注册信息 + self.send_slave_node_info(sclient) + ``` + +2. **发送注册信息**(`main_slave_run.py`): + + ```python + request.command = json.dumps({ + "machine_name": BasicConfig.machine_name, + "type": "slave", + "devices_config": devices_config.dump(), + "registry_config": registry_config, + }) + ``` + +3. **主节点接收**(`host_node.py` 的 `_node_info_update_callback` 方法): + ```python + def _node_info_update_callback(self, request, response): + info = json.loads(request.command) + machine_name = info["machine_name"] + edge_device_id = info["edge_device_id"] + # 记录设备来源机器 + self.device_machine_names[edge_device_id] = machine_name + ``` + +**关键点:** + +- 注册是自动的,无需手动配置 +- 主节点会记录每个设备来自哪台机器 +- 从节点需要等待主节点的服务可用 + +#### 8.7 常见问题 + +**问题 1:从节点启动卡住** + +``` +Waiting for host service... +``` + +**解决方案:** + +- 检查主节点是否已启动 +- 验证网络连通性 +- 使用 `--slave_no_host` 参数跳过等待(仅用于测试) + +**问题 2:节点看不到彼此** + +**解决方案:** + +- 确保在同一网络 +- 关闭防火墙 +- 设置相同的 `ROS_DOMAIN_ID`(可选) + +**问题 3:注册失败** + +**解决方案:** + +- 检查 AK/SK 是否正确 +- 查看主节点日志 +- 重启从节点 + +--- + +## 第三部分:自定义开发 + +### 9. 创建您的第一个自定义设备 + +详细的设备驱动编写指南请参考 [添加设备驱动](../developer_guide/add_device.md)。 + +#### 9.1 为什么需要自定义设备? + +Uni-Lab-OS 内置了常见设备,但您的实验室可能有特殊设备需要集成: + +- 自研仪器 +- 定制化改造的商业设备 +- 特殊的实验流程 +- 第三方设备集成 + +#### 9.2 创建 Python 包 + +为了方便开发和管理,建议为您的实验室创建独立的 Python 包。 + +**包结构:** + +``` +my_lab_devices/ +├── setup.py +├── README.md +├── my_lab_devices/ +│ ├── __init__.py +│ ├── devices/ +│ │ ├── __init__.py +│ │ ├── my_pump.py +│ │ └── my_sensor.py +│ └── registry/ +│ ├── devices/ +│ │ ├── my_pump.yaml +│ │ └── my_sensor.yaml +│ └── resources/ +│ └── my_container.yaml +``` + +**创建步骤:** + +```bash +# 1. 创建包目录 +mkdir -p my_lab_devices/my_lab_devices/devices +mkdir -p my_lab_devices/my_lab_devices/registry/devices +mkdir -p my_lab_devices/my_lab_devices/registry/resources + +# 2. 创建 __init__.py +touch my_lab_devices/my_lab_devices/__init__.py +touch my_lab_devices/my_lab_devices/devices/__init__.py +``` + +#### 9.3 创建 setup.py + +```python +# my_lab_devices/setup.py + +from setuptools import setup, find_packages + +setup( + name="my_lab_devices", + version="0.1.0", + description="My Laboratory Custom Devices", + author="Your Name", + author_email="your.email@example.com", + packages=find_packages(), + install_requires=[ + "unilabos", # 依赖 Uni-Lab-OS + # 添加其他依赖 + # "pyserial", + # "requests", + ], + python_requires=">=3.11", + # 包含注册表文件 + package_data={ + "my_lab_devices": [ + "registry/devices/*.yaml", + "registry/resources/*.yaml", + ], + }, +) +``` + +#### 9.4 开发安装 + +使用 `-e` 参数进行可编辑安装,这样代码修改后立即生效: + +```bash +cd my_lab_devices +pip install -e . -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple +``` + +**优势:** + +- 代码修改实时生效,无需重新安装 +- 方便调试和测试 +- 支持版本控制(git) + +#### 9.5 编写设备驱动 + +创建设备驱动文件: + +```python +# my_lab_devices/my_lab_devices/devices/my_pump.py + +from typing import Dict, Any +from unilabos.registry.placeholder_type import ResourceSlot + +class MyPump: + """自定义注射泵驱动 + + 支持功能: + - 注射液体 + - 抽取液体 + - 速率控制 + """ + + def __init__(self, config: Dict[str, Any]): + """初始化设备 + + Args: + config: 配置字典,包含端口、波特率等 + """ + self.port = config.get('port', 'COM1') + self.baudrate = config.get('baudrate', 9600) + self._status = "idle" + self._current_volume = 0.0 + + # 初始化串口连接 + # self.serial = serial.Serial(self.port, self.baudrate) + print(f"MyPump initialized on {self.port}") + + @property + def status(self) -> str: + """设备状态""" + return self._status + + @property + def current_volume(self) -> float: + """当前注射器内液体体积(ml)""" + return self._current_volume + + def infuse( + self, + target: ResourceSlot, + volume: float, + rate: float = 1.0 + ) -> Dict[str, Any]: + """注射液体到目标容器 + + Args: + target: 目标容器(前端显示资源选择器) + volume: 注射体积(ml) + rate: 注射速率(ml/min) + + Returns: + 执行结果 + """ + self._status = "infusing" + + print(f"Infusing {volume}ml to {target.name} at {rate}ml/min") + + # 发送命令到硬件 + # self.serial.write(f"INF {volume} {rate}\r\n".encode()) + + # 模拟执行 + import time + time.sleep(volume / rate * 60) # 模拟注射时间 + + self._current_volume -= volume + self._status = "idle" + + return { + "success": True, + "volume_infused": volume, + "target": target.id, + "message": f"Successfully infused {volume}ml" + } + + def withdraw( + self, + source: ResourceSlot, + volume: float, + rate: float = 1.0 + ) -> Dict[str, Any]: + """从源容器抽取液体 + + Args: + source: 源容器(前端显示资源选择器) + volume: 抽取体积(ml) + rate: 抽取速率(ml/min) + + Returns: + 执行结果 + """ + self._status = "withdrawing" + + print(f"Withdrawing {volume}ml from {source.name} at {rate}ml/min") + + # 发送命令到硬件 + # self.serial.write(f"WDR {volume} {rate}\r\n".encode()) + + # 模拟执行 + import time + time.sleep(volume / rate * 60) + + self._current_volume += volume + self._status = "idle" + + return { + "success": True, + "volume_withdrawn": volume, + "source": source.id, + "message": f"Successfully withdrawn {volume}ml" + } + + def set_rate(self, rate: float) -> Dict[str, Any]: + """设置流速 + + Args: + rate: 流速(ml/min) + """ + print(f"Setting rate to {rate}ml/min") + # self.serial.write(f"RAT {rate}\r\n".encode()) + + return { + "success": True, + "rate": rate, + "message": f"Rate set to {rate}ml/min" + } +``` + +**关键点:** + +- **类型注解**:使用 `ResourceSlot` 等特殊类型 +- **@property**:状态属性会自动广播 +- **返回 Dict**:所有动作方法返回字典类型 +- **文档字符串**:详细说明参数和功能 + +#### 9.6 测试设备驱动 + +创建简单的测试脚本: + +```python +# test_my_pump.py + +from my_lab_devices.devices.my_pump import MyPump + +# 创建设备实例 +config = { + "port": "COM1", + "baudrate": 9600 +} +pump = MyPump(config) + +# 测试状态属性 +print(f"Status: {pump.status}") +print(f"Current volume: {pump.current_volume}ml") + +# 模拟注射(不使用 ResourceSlot) +class MockResource: + id = "test_container" + name = "测试容器" + +result = pump.infuse(MockResource(), volume=5.0, rate=2.0) +print(f"Result: {result}") +``` + +运行测试: + +```bash +python test_my_pump.py +``` + +--- + +### 10. 生成和完善设备注册表 + +注册表(Registry)是 Uni-Lab-OS 的核心配置文件,定义了设备的动作、状态、参数等信息。系统提供了自动生成功能,帮助您快速完成基础配置。 + +> 完整的注册表编写、字段说明、高级配置等内容,请参考 [添加设备注册表](../developer_guide/add_registry.md)。 + +#### 10.1 注册表目录结构 + +Uni-Lab-OS 使用三类注册表: + +``` +registry/ +├── devices/ # 设备注册表(仪器设备,如泵、加热器、机械臂等) +├── device_comms/ # 通信驱动注册表(如 Modbus、OPC UA) +└── resources/ # 物料注册表(耗材、容器,如孔板、试管、试剂等) +``` + +#### 10.2 生成注册表(两种方式) + +**方式 1:使用 Web 界面注册表编辑器(推荐)** + +1. 访问 Web 界面,进入"注册表编辑器" +2. 上传您的 Python 设备驱动文件 +3. 点击"分析文件",系统自动生成注册表 +4. 填写描述、图标等元数据 +5. 下载生成的 YAML 文件到您的注册表目录 + +**方式 2:使用 `--complete_registry` 参数(命令行)** + +```bash +unilab --ak your_ak --sk your_sk -g graph.json \ + --registry_path ./my_lab_devices/registry \ + --complete_registry +``` + +系统会自动: + +- 扫描 Python 设备类 +- 分析方法签名和类型注解 +- 识别 `ResourceSlot`、`DeviceSlot` 等特殊类型 +- 生成完整的 YAML 注册表文件 + +#### 10.3 生成的注册表示例 + +自动生成的注册表大致如下(已简化): + +```yaml +my_pump: + class: + module: my_lab_devices.devices.my_pump:MyPump + type: python + + status_types: # 自动提取的状态 + status: str + current_volume: float + + action_value_mappings: # 自动生成的动作 + infuse: + type: UniLabJsonCommand + goal: + target: target + volume: volume + placeholder_keys: + target: unilabos_resources # 自动识别 ResourceSlot + result: + success: success + + description: '自定义注射泵驱动' + version: '1.0.0' +``` + +系统会自动识别特殊类型并生成前端选择器: + +| Python 类型 | 前端效果 | +| -------------------- | -------------- | +| `ResourceSlot` | 资源单选下拉框 | +| `List[ResourceSlot]` | 资源多选下拉框 | +| `DeviceSlot` | 设备单选下拉框 | +| `List[DeviceSlot]` | 设备多选下拉框 | + +#### 10.4 使用自定义注册表 + +启动时指定注册表路径: + +```bash +unilab --ak your_ak --sk your_sk -g graph.json \ + --registry_path ./my_lab_devices/registry \ + --upload_registry +``` + +**支持多个注册表路径**(按顺序查找): + +```bash +unilab --ak your_ak --sk your_sk -g graph.json \ + --registry_path ./my_lab_devices/registry \ + --registry_path ./another_lab/registry +``` + +#### 10.5 验证注册表 + +**验证方法:** + +1. **查看启动日志**:确认设备被正确加载 +2. **访问 Web 界面**:"仪器设备"模块中应能看到您的自定义设备 +3. **测试动作调用**:在工作流中测试设备动作是否正常 + +#### 10.6 手动完善注册表(可选) + +自动生成的注册表已经可用,但您可以手动添加以下内容来提升用户体验: + +- **description**:设备详细描述 +- **icon**:设备图标(支持 Base64 或 URL) +- **category**:设备分类标签(用于前端分组显示) +- **handles**:设备连接点定义(用于可视化连接) + +**示例:** + +```yaml +my_pump: + description: '高精度注射泵,支持 0.001-100 ml/min 流速范围' + icon: 'pump_icon.webp' + category: + - pump + - liquid_handling + # ... 其他配置 +``` + +> 关于注册表的高级配置、字段详解、最佳实践等,请参考 [添加设备注册表](../developer_guide/add_registry.md)。 + +--- + +### 11. 创建复杂工作站 + +详细的工作站架构请参考 [工作站架构](../developer_guide/examples/workstation_architecture.md)。 + +#### 11.1 什么是复杂工作站? + +复杂工作站是多个设备和物料的集合,协同完成特定实验流程。 + +**示例场景:** + +- **液体处理工作站**:移液器 + 孔板 + 试剂槽 + 枪头架 +- **有机合成工作站**:反应釜 + 注射泵 + 加热搅拌器 + 冷凝器 +- **分析工作站**:色谱仪 + 进样器 + 流动相泵 + +#### 11.2 设计工作站 + +**设计原则:** + +1. **模块化**:每个设备独立,可单独控制 +2. **层次化**:使用父子关系组织设备 +3. **标准化**:使用统一的接口和命名 + +**工作站结构:** + +``` +Workstation(工作站根节点) +├── Deck(甲板/平台) +│ ├── PlateCarrier_1(板位) +│ │ └── Plate_96(96孔板) +│ ├── PlateCarrier_2 +│ │ └── Plate_384(384孔板) +│ └── TipRack(枪头架) +├── Pipette_8(8通道移液器) +└── WashStation(清洗站) +``` + +#### 11.3 组合多个设备 + +在组态图中: + +1. 创建工作站根节点(通常是 `workstation`) +2. 添加子设备 +3. 建立连接关系 +4. 配置每个设备的参数 + +**JSON 组态示例:** + +```json +{ + "nodes": [ + { + "id": "liquid_handler_ws", + "name": "液体处理工作站", + "type": "device", + "class": "workstation", + "config": {} + }, + { + "id": "pipette_8ch", + "name": "8通道移液器", + "type": "device", + "class": "pipette_8_channel", + "config": { + "volume_range": [0.5, 300] + } + }, + { + "id": "plate_carrier_1", + "name": "板位1", + "type": "resource", + "class": "plate_carrier", + "config": {} + } + ], + "links": [ + { + "source": "liquid_handler_ws", + "target": "pipette_8ch", + "type": "contains" + }, + { + "source": "liquid_handler_ws", + "target": "plate_carrier_1", + "type": "contains" + } + ] +} +``` + +#### 11.4 设计工作流程 + +**复杂工作站的工作流特点:** + +- 多步骤协同 +- 设备间依赖 +- 状态管理 +- 错误恢复 + +**示例:96 孔板液体分配流程** + +``` +1. 准备阶段 + ├── 检查枪头架 + ├── 检查孔板位置 + └── 检查试剂体积 + +2. 执行阶段 + ├── 装载枪头 + ├── 从试剂槽吸取 + ├── 移动到目标孔板 + ├── 分配液体 + └── 退出枪头 + +3. 清理阶段 + ├── 清洗移液器 + └── 复位到初始位置 +``` + +**在 Uni-Lab 中实现:** + +1. 创建工作流 +2. 添加各步骤节点 +3. 配置每个节点的设备和参数 +4. 连接节点形成流程 +5. 添加错误处理分支 + +#### 11.5 通信共享机制 + +在复杂工作站中,多个设备可能需要共享同一个通信端口(如串口、PLC),但大多数通信端口只能被一个实例占用。通信共享机制解决了这个问题,允许灵活控制连接到同一通信端口的多个设备。 + +##### 11.5.1 应用场景 + +**典型场景:** + +- **多泵阀共享 PLC 串口**:所有泵阀通过同一个 PLC 控制,泵阀数量可灵活增减 +- **多传感器共享 Modbus**:多个传感器通过同一个 Modbus 设备读取数据 +- **多设备共享 TCP/IP**:多台设备共享一个 TCP/IP 通信接口 + +**解决思路:** + +创建一个**通信设备作为代理**,所有需要使用该端口的设备通过代理进行通信,而不是直接访问端口。 + +##### 11.5.2 配置步骤 + +**步骤 1:定义 workstation 并指定 protocol_type** + +在组态图中,workstation 的 `config` 需要添加 `protocol_type`(协议类型列表,可为空): + +```json +{ + "id": "simple_station", + "name": "简单工作站", + "type": "device", + "class": "workstation", + "parent": null, + "config": { + "protocol_type": ["PumpTransferProtocol", "CleanProtocol"] + }, + "children": ["serial_pump", "pump_1", "pump_2", "pump_3"] +} +``` + +> **说明**:`children` 列表中,**通信设备必须排在最前面**(如 `"serial_pump"`),确保它先于其他设备初始化。 + +**步骤 2:创建通信设备(作为代理)** + +在 workstation 的子设备中,**首先定义通信设备**(必须在其他设备之前初始化,因此放到启动 json 靠前的位置): + +```json +{ + "id": "serial_pump", + "type": "device", + "class": "serial", + "parent": "simple_station", + "config": { + "port": "COM7", + "baudrate": 9600 + } +} +``` + +**步骤 3:配置使用该通信的设备** + +其他设备的 `config` 中指向通信设备(字段名根据设备注册表定义,如 `port` 或 `io_device_port`,他们在驱动中): + +```json +{ + "id": "pump_1", + "type": "device", + "class": "syringe_pump_with_valve.runze.SY03B-T06", + "parent": "simple_station", + "config": { + "port": "serial_pump", // 直接使用通信设备的设备名,最终能够通过__init__实现self.hardware_interface变量设定为serial_pump的字符串 + "address": "1", + "max_volume": 25.0 + } +} +``` + +> 配置字段的值直接是**通信设备的设备名**(如 `"serial_pump"`),不是完整路径。系统会自动进行变量替换。 + +**步骤 4:建立通信连接关系** + +在组态图的 `links` 中,建立 `communication` 类型的连接: + +```json +{ + "links": [ + { + "source": "pump_1", + "target": "serial_pump", + "type": "communication", + "port": { + "serial_pump": "1" // 通信端口号或地址 + } + }, + { + "source": "pump_2", + "target": "serial_pump", + "type": "communication", + "port": { + "serial_pump": "2" + } + } + ] +} +``` + +> **说明**:`port` 字段中的值是通信设备上的端口号或地址,具体含义取决于通信协议。 + +##### 11.5.3 注册表配置 + +**通信设备的注册表**(例如`device_comms/modbus_ioboard.yaml`): + +```yaml +io_snrd: + class: + module: unilabos.device_comms.SRND_16_IO:SRND_16_IO + type: python + + # hardware_interface.name 对象是实际的通信实例(如 ModbusClient()) + # 通过 Python 的 __init__ 方法初始化 + hardware_interface: + name: modbus_client + read: read_io_coil + write: write_io_coil + + description: 'IO Board with 16 IOs via Modbus' +``` + +**使用通信的设备注册表**(`devices/pump_and_valve.yaml`): + +```yaml +syringe_pump_with_valve.runze.SY03B-T06: + class: + module: unilabos.devices.pump_and_valve.runze_backbone:RunzeSyringePump + type: python + + # hardware_interface.name 初始化时是通信设备的设备名(字符串) + # 通过启动 JSON 的 config 传入(如 "port": "serial_pump") + # 系统会自动将字符串替换为实际的通信实例 + hardware_interface: + name: hardware_interface + read: send_command + write: send_command + + action_value_mappings: + # 设备的动作定义 + +solenoid_valve: + class: + module: unilabos.devices.pump_and_valve.solenoid_valve:SolenoidValve + type: python + + # 使用 io_device_port 字段接收通信设备名 + hardware_interface: + name: io_device_port # 例如当前电磁阀修改了name,从默认的hardware_interface改成了io_device_port,那么启动json中就应该对__init__中的io_device_port进行赋值 + read: read_io_coil + write: write_io_coil +``` + +**关键机制:** + +| 设备类型 | hardware_interface.name | 初始化方式 | 运行时对象 | +| ------------------ | ----------------------- | --------------------------------------------- | ----------------------------------- | +| **通信设备** | `modbus_client` 等 | Python `__init__` 中创建实例 | `ModbusClient()`、`serial.Serial()` | +| **使用通信的设备** | `hardware_interface` 等 | config 中传入设备名字符串(如 `"io_device"`) | **系统自动替换**为实际通信实例 | + +**自动替换流程:** + +1. 设备初始化时,`self.hardware_interface = "io_device"`(字符串) +2. workstation 检测到 `config` 中有通信设备名 +3. 系统自动将 `self.hardware_interface` 替换为 `io_device` 的实际通信实例 +4. 设备可以直接调用 `self.hardware_interface.read_io_coil()` 等方法 + +**完整示例:Modbus IO 板控制多个电磁阀** + +```json +{ + "nodes": [ + { + "id": "WorkStationTestMinimumSystem", + "name": "工作站节点", + "children": [ + "io_device", + "Product1_Solenoid_Valve", + "Water_Solenoid_Valve" + ], + "parent": null, + "type": "device", + "class": "workstation", + "config": { + "protocol_type": [] + } + }, + { + "id": "io_device", + "class": "io_snrd", // 通信设备类型 + "name": "io_device", + "config": { + "address": "192.168.1.7:23", // Modbus TCP 地址 + "slave_id": "1" + }, + "parent": "WorkStationTestMinimumSystem", + "type": "device" + }, + { + "id": "Product1_Solenoid_Valve", + "class": "solenoid_valve", + "name": "Product1_Solenoid_Valve", + "config": { + "io_device_port": "io_device" // 通信设备的设备名(字符串) + }, + "parent": "WorkStationTestMinimumSystem", + "type": "device" + }, + { + "id": "Water_Solenoid_Valve", + "class": "solenoid_valve", + "name": "Water_Solenoid_Valve", + "config": { + "io_device_port": "io_device" // 同样使用这个通信设备 + }, + "parent": "WorkStationTestMinimumSystem", + "type": "device" + } + ], + "links": [] +} +``` + +**工作原理:** + +1. `io_device` 初始化时创建 `ModbusClient("192.168.1.7:23")` +2. 两个电磁阀的 `self.io_device_port` 初始化为字符串 `"io_device"` +3. workstation 自动将 `self.io_device_port` 替换为实际的 `ModbusClient` 实例 +4. 电磁阀可以通过 `self.io_device_port.read_io_coil(15)` 或 `write_io_coil(14)` 操作 + +##### 11.5.4 验证通信共享 + +**启动成功标志:** + +系统启动时,在日志中查看以下提示信息: + +``` +通信代理:为子设备 pump_1 配置通信代理 serial_pump +通信代理:为子设备 pump_2 配置通信代理 serial_pump +通信代理:为子设备 pump_3 配置通信代理 serial_pump +``` + +看到此提示说明通信共享配置成功。 + +**测试步骤:** + +1. 启动系统,查看日志确认通信代理配置成功 +2. 在 Web 界面中,分别控制 `pump_1`、`pump_2`、`pump_3` +3. 确认所有泵都能正常响应(通过同一个串口) +4. 查看通信设备的状态,确认消息正常传递 + +--- + +### 12. 撰写物料定义 + +详细的物料编写指南请参考: + +- [物料构建指南](../developer_guide/examples/materials_construction_guide.md) +- [物料教程](../developer_guide/examples/materials_tutorial.md) + +#### 12.1 物料(Resource)概述 + +物料是实验中使用的消耗品和容器: + +- **容器**:孔板、试管、烧杯、反应瓶 +- **耗材**:枪头、移液管、过滤器 +- **试剂**:溶剂、试剂、样品 + +#### 12.2 创建自定义物料类型 + +**场景:** 创建实验室特殊规格的孔板 + +```python +# my_lab_devices/my_lab_devices/resources/custom_plate.py + +from pylabrobot.resources import Plate, Well +from typing import List + +class CustomPlate48(Plate): + """自定义 48 孔板 + + 规格: + - 6行 x 8列 + - 孔体积:2ml + - 孔间距:18mm + """ + + def __init__(self, name: str): + super().__init__( + name=name, + size_x=127.76, # mm + size_y=85.48, # mm + size_z=14.35, # mm + model="custom_48_wellplate_2000ul" + ) + + # 定义孔位 + self._create_wells() + + def _create_wells(self): + """创建 48 个孔位""" + rows = "ABCDEF" + cols = range(1, 9) + + well_size_x = 16.0 # mm + well_size_y = 16.0 + well_depth = 12.0 + spacing_x = 18.0 + spacing_y = 18.0 + + for row_idx, row in enumerate(rows): + for col in cols: + well_name = f"{row}{col}" + x = (col - 1) * spacing_x + 10 # 10mm 边距 + y = row_idx * spacing_y + 10 + + well = Well( + name=well_name, + size_x=well_size_x, + size_y=well_size_y, + size_z=well_depth, + max_volume=2000, # μL + ) + well.location = (x, y, 0) + self.children.append(well) +``` + +#### 12.3 物料注册表 + +创建物料注册表: + +```yaml +# my_lab_devices/my_lab_devices/registry/resources/custom_plate.yaml + +custom_48_wellplate_2000ul: + class: + module: my_lab_devices.resources.custom_plate:CustomPlate48 + type: python + + description: '自定义 48 孔板,2ml 孔体积' + version: '1.0.0' + category: + - plate + - container + + # 物料属性 + properties: + rows: 6 + columns: 8 + well_count: 48 + well_volume: 2000 # μL + well_shape: 'round' + material: 'polystyrene' + + # 尺寸信息 + dimensions: + size_x: 127.76 + size_y: 85.48 + size_z: 14.35 + unit: 'mm' + + # 兼容性 + compatible_with: + - 'plate_carrier' + - 'thermal_cycler' + + icon: '' +``` + +#### 12.4 物料属性定义 + +**常见属性:** + +```yaml +properties: + # 容量相关 + max_volume: 2000 # μL + min_volume: 10 # μL + dead_volume: 5 # μL(无法吸取的残留体积) + + # 几何形状 + shape: 'round' # round, square, v_bottom + diameter: 16.0 # mm + depth: 12.0 # mm + + # 材质 + material: 'polystyrene' # 聚苯乙烯 + transparency: true # 透明 + sterile: false # 无菌 + + # 温度范围 + min_temperature: -80 # °C + max_temperature: 121 # °C + + # 化学兼容性 + chemical_compatibility: + - 'water' + - 'ethanol' + - 'dmso' + + # 条形码 + barcode_support: true + barcode_type: '1D' +``` + +#### 12.5 在系统中使用自定义物料 + +1. **注册物料**: + +```bash +unilab --ak your_ak --sk your_sk -g graph.json \ + --registry_path ./my_lab_devices/my_lab_devices/registry \ + --upload_registry +``` + +2. **在组态图中添加**: + +在 Web 界面的"物料耗材"模块中,找到 `custom_48_wellplate_2000ul`,添加到实验室。 + +3. **在工作流中使用**: + +工作流节点的参数中,涉及容器选择的地方会显示您的自定义物料。 + +--- + +## 第四部分:最佳实践总结 + +### 13. 开发流程建议 + +``` +需求分析 → 环境搭建 → 原型验证 → 迭代开发 → 测试部署 → 生产运行 +``` + +**详细步骤:** + +1. **需求分析**: + + - 明确实验流程 + - 列出所需设备和物料 + - 设计工作流程图 + +2. **环境搭建**: + + - 安装 Uni-Lab-OS + - 创建实验室账号 + - 准备开发工具(IDE、Git) + +3. **原型验证**: + + - 使用虚拟设备测试流程 + - 验证工作流逻辑 + - 调整参数 + +4. **迭代开发**: + + - 实现自定义设备驱动(同时撰写单点函数测试) + - 编写注册表 + - 单元测试 + - 集成测试 + +5. **测试部署**: + + - 连接真实硬件 + - 空跑测试 + - 小规模试验 + +6. **生产运行**: + - 全量部署 + - 监控运行状态 + - 持续优化 + +--- + +### 14. 进一步学习资源 + +#### 14.1 官方文档 + +- **安装指南**:[installation.md](installation.md) +- **启动指南**:[launch.md](launch.md) +- **配置指南**:[../advanced_usage/configuration.md](../advanced_usage/configuration.md) + +#### 14.2 开发者文档 + +- **组网部署与主从模式**:[../developer_guide/networking_overview.md](../developer_guide/networking_overview.md) +- **添加设备驱动**:[../developer_guide/add_device.md](../developer_guide/add_device.md) +- **老设备驱动开发**:[../developer_guide/add_old_device.md](../developer_guide/add_old_device.md) +- **添加动作指令**:[../developer_guide/add_action.md](../developer_guide/add_action.md) +- **YAML 注册表编写**:[../developer_guide/add_yaml.md](../developer_guide/add_yaml.md) +- **添加设备注册表**:[../developer_guide/add_registry.md](../developer_guide/add_registry.md) +- **工作站架构**:[../developer_guide/examples/workstation_architecture.md](../developer_guide/examples/workstation_architecture.md) +- **物料构建指南**:[../developer_guide/examples/materials_construction_guide.md](../developer_guide/examples/materials_construction_guide.md) + +#### 14.3 进阶主题 + +- **工作目录详解**:[../advanced_usage/working_directory.md](../advanced_usage/working_directory.md) + + +#### 14.4 外部资源 + +- **ROS 2 文档**:[https://docs.ros.org/en/humble/](https://docs.ros.org/en/humble/) +- **PyLabRobot**:[https://github.com/PyLabRobot/pylabrobot](https://github.com/PyLabRobot/pylabrobot) +- **Python 类型注解**:[https://docs.python.org/3/library/typing.html](https://docs.python.org/3/library/typing.html) +- **YAML 语法**:[https://yaml.org/](https://yaml.org/) + +#### 14.5 社区支持 + +- **GitHub Issues**:[https://github.com/dptech-corp/Uni-Lab-OS/issues](https://github.com/dptech-corp/Uni-Lab-OS/issues) +- **官方网站**:[https://uni-lab.bohrium.com](https://uni-lab.bohrium.com) + +--- + +## 结语 + +通过本指南,您应该已经掌握了: + +- 安装和配置 Uni-Lab-OS 环境 +- 创建和管理实验室 +- 启动系统并运行工作流 +- 部署多节点分布式系统 +- 开发自定义设备驱动 +- 创建和注册物料定义 +- 构建复杂工作站 + +**下一步建议:** + +1. 从简单的工作流开始实践,逐步尝试更复杂的场景 +2. 在 GitHub 上提问和分享经验 +3. 关注文档更新和新功能发布 +4. 为 Uni-Lab-OS 社区贡献您的设备驱动和最佳实践 + +--- + +_本指南最后更新:2025-11_ +_Uni-Lab-OS 版本:最新稳定版_ diff --git a/docs/user_guide/configuration.md b/docs/user_guide/configuration.md deleted file mode 100644 index 036cb9c7..00000000 --- a/docs/user_guide/configuration.md +++ /dev/null @@ -1,442 +0,0 @@ -# Uni-Lab 配置指南 - -Uni-Lab 支持通过 Python 配置文件进行灵活的系统配置。本指南将帮助您理解配置选项并设置您的 Uni-Lab 环境。 - -## 配置文件格式 - -Uni-Lab 支持 Python 格式的配置文件,它比 YAML 或 JSON 提供更多的灵活性,包括支持注释、条件逻辑和复杂数据结构。 - -### 默认配置示例 - -首次使用时,系统会自动创建一个基础配置文件 `local_config.py`: - -```python -# unilabos的配置文件 - -class BasicConfig: - ak = "" # 实验室网页给您提供的ak代码,您可以在配置文件中指定,也可以通过运行unilabos时以 --ak 传入,优先按照传入参数解析 - sk = "" # 实验室网页给您提供的sk代码,您可以在配置文件中指定,也可以通过运行unilabos时以 --sk 传入,优先按照传入参数解析 - - -# WebSocket配置,一般无需调整 -class WSConfig: - reconnect_interval = 5 # 重连间隔(秒) - max_reconnect_attempts = 999 # 最大重连次数 - ping_interval = 30 # ping间隔(秒) -``` -您可以进入实验室,点击左下角的头像在实验室详情中获取所在实验室的ak sk -![copy_aksk.gif](image/copy_aksk.gif) - -### 完整配置示例 - -您可以根据需要添加更多配置选项: - -```python -#!/usr/bin/env python -# coding=utf-8 -"""Uni-Lab 配置文件""" - -# 基础配置 -class BasicConfig: - ak = "your_access_key" # 实验室访问密钥 - sk = "your_secret_key" # 实验室私钥 - working_dir = "" # 工作目录(通常自动设置) - config_path = "" # 配置文件路径(自动设置) - is_host_mode = True # 是否为主站模式 - slave_no_host = False # 从站模式下是否跳过等待主机服务 - upload_registry = False # 是否上传注册表 - machine_name = "undefined" # 机器名称(自动获取) - vis_2d_enable = False # 是否启用2D可视化 - enable_resource_load = True # 是否启用资源加载 - communication_protocol = "websocket" # 通信协议 - -# WebSocket配置 -class WSConfig: - reconnect_interval = 5 # 重连间隔(秒) - max_reconnect_attempts = 999 # 最大重连次数 - ping_interval = 30 # ping间隔(秒) - -# OSS上传配置 -class OSSUploadConfig: - api_host = "" # API主机地址 - 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", - ] # 需要加载的ROS模块 -``` - -## 命令行参数覆盖配置 - -Uni-Lab 允许通过命令行参数覆盖配置文件中的设置,提供更灵活的配置方式。命令行参数的优先级高于配置文件。 - -### 支持命令行覆盖的配置项 - -以下配置项可以通过命令行参数进行覆盖: - -| 配置类 | 配置字段 | 命令行参数 | 说明 | -| ------------- | ----------------- | ------------------- | -------------------------------- | -| `BasicConfig` | `ak` | `--ak` | 实验室访问密钥 | -| `BasicConfig` | `sk` | `--sk` | 实验室私钥 | -| `BasicConfig` | `working_dir` | `--working_dir` | 工作目录路径 | -| `BasicConfig` | `is_host_mode` | `--is_slave` | 主站模式(参数为从站模式,取反) | -| `BasicConfig` | `slave_no_host` | `--slave_no_host` | 从站模式下跳过等待主机服务 | -| `BasicConfig` | `upload_registry` | `--upload_registry` | 启动时上传注册表信息 | -| `BasicConfig` | `vis_2d_enable` | `--2d_vis` | 启用 2D 可视化 | -| `HTTPConfig` | `remote_addr` | `--addr` | 远程服务地址 | - -### 特殊命令行参数 - -除了直接覆盖配置项的参数外,还有一些特殊的命令行参数: - -| 参数 | 说明 | -| ------------------- | ------------------------------------ | -| `--config` | 指定配置文件路径 | -| `--port` | Web 服务端口(不影响配置文件) | -| `--disable_browser` | 禁用自动打开浏览器(不影响配置文件) | -| `--visual` | 可视化工具选择(不影响配置文件) | -| `--skip_env_check` | 跳过环境检查(不影响配置文件) | - -### 配置优先级 - -配置项的生效优先级从高到低为: - -1. **命令行参数**:最高优先级 -2. **环境变量**:中等优先级 -3. **配置文件**:基础优先级 - -### 使用示例 - -```bash -# 通过命令行覆盖认证信息 -unilab --ak "new_access_key" --sk "new_secret_key" - -# 覆盖服务器地址 -unilab --addr "https://custom.server.com/api/v1" - -# 启用从站模式并跳过等待主机 -unilab --is_slave --slave_no_host - -# 启用上传注册表和2D可视化 -unilab --upload_registry --2d_vis - -# 组合使用多个覆盖参数 -unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis -``` - -### 预设环境地址 - -`--addr` 参数支持以下预设值,会自动转换为对应的完整 URL: - -- `test` → `https://uni-lab.test.bohrium.com/api/v1` -- `uat` → `https://uni-lab.uat.bohrium.com/api/v1` -- `local` → `http://127.0.0.1:48197/api/v1` -- 其他值 → 直接使用作为完整 URL - -## 配置选项详解 - -### 基础配置 (BasicConfig) - -基础配置包含了系统运行的核心参数: - -| 参数 | 类型 | 默认值 | 说明 | -| ------------------------ | ---- | ------------- | ------------------------------------------ | -| `ak` | str | `""` | 实验室访问密钥(必需) | -| `sk` | str | `""` | 实验室私钥(必需) | -| `working_dir` | str | `""` | 工作目录,通常自动设置 | -| `is_host_mode` | bool | `True` | 是否为主站模式 | -| `slave_no_host` | bool | `False` | 从站模式下是否跳过等待主机服务 | -| `upload_registry` | bool | `False` | 启动时是否上传注册表信息 | -| `machine_name` | str | `"undefined"` | 机器名称,自动从 hostname 获取(不可配置) | -| `vis_2d_enable` | bool | `False` | 是否启用 2D 可视化 | -| `communication_protocol` | str | `"websocket"` | 通信协议,固定为 websocket | - -#### 认证配置 - -`ak` 和 `sk` 是必需的认证参数: - -1. **获取方式**:在 [Uni-Lab 官网](https://uni-lab.bohrium.com) 注册实验室后获得 -2. **配置方式**: - - **命令行参数**:`--ak "your_key" --sk "your_secret"`(最高优先级) - - **配置文件**:在 `BasicConfig` 类中设置 - - **环境变量**:`UNILABOS_BASICCONFIG_AK` 和 `UNILABOS_BASICCONFIG_SK` -3. **优先级顺序**:命令行参数 > 环境变量 > 配置文件 -4. **安全注意**:请妥善保管您的密钥信息 - -**推荐做法**: - -- 开发环境:使用配置文件 -- 生产环境:使用环境变量或命令行参数 -- 临时测试:使用命令行参数 - -### WebSocket 配置 (WSConfig) - -WebSocket 是 Uni-Lab 的主要通信方式: - -| 参数 | 类型 | 默认值 | 说明 | -| ------------------------ | ---- | ------ | ------------------ | -| `reconnect_interval` | int | `5` | 断线重连间隔(秒) | -| `max_reconnect_attempts` | int | `999` | 最大重连次数 | -| `ping_interval` | int | `30` | 心跳检测间隔(秒) | - -### HTTP 配置 (HTTPConfig) - -HTTP 客户端配置用于与云端服务通信: - -| 参数 | 类型 | 默认值 | 说明 | -| ------------- | ---- | --------------------------------- | ------------ | -| `remote_addr` | str | `"http://127.0.0.1:48197/api/v1"` | 远程服务地址 | - -**预设环境地址**: - -- 生产环境:`https://uni-lab.bohrium.com/api/v1` -- 测试环境:`https://uni-lab.test.bohrium.com/api/v1` -- UAT 环境:`https://uni-lab.uat.bohrium.com/api/v1` -- 本地环境:`http://127.0.0.1:48197/api/v1` - -### ROS 配置 (ROSConfig) - -配置 ROS 消息转换器需要加载的模块: - -```python -class ROSConfig: - modules = [ - "std_msgs.msg", # 标准消息类型 - "geometry_msgs.msg", # 几何消息类型 - "control_msgs.msg", # 控制消息类型 - "control_msgs.action", # 控制动作类型 - "nav2_msgs.action", # 导航动作类型 - "unilabos_msgs.msg", # UniLab 自定义消息类型 - "unilabos_msgs.action", # UniLab 自定义动作类型 - ] -``` - -您可以根据实际使用的设备和功能添加其他 ROS 模块。 - -### OSS 上传配置 (OSSUploadConfig) - -对象存储服务配置,用于文件上传功能: - -| 参数 | 类型 | 默认值 | 说明 | -| ------------------- | ---- | ------ | -------------------- | -| `api_host` | str | `""` | OSS API 主机地址 | -| `authorization` | str | `""` | 授权认证信息 | -| `init_endpoint` | str | `""` | 上传初始化端点 | -| `complete_endpoint` | str | `""` | 上传完成端点 | -| `max_retries` | int | `3` | 上传失败最大重试次数 | - -## 环境变量支持 - -Uni-Lab 支持通过环境变量覆盖配置文件中的设置。环境变量格式为: - -``` -UNILABOS_{配置类名}_{字段名} -``` - -### 环境变量示例 - -```bash -# 设置基础配置 -export UNILABOS_BASICCONFIG_AK="your_access_key" -export UNILABOS_BASICCONFIG_SK="your_secret_key" -export UNILABOS_BASICCONFIG_IS_HOST_MODE="true" - -# 设置WebSocket配置 -export UNILABOS_WSCONFIG_RECONNECT_INTERVAL="10" -export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS="500" - -# 设置HTTP配置 -export UNILABOS_HTTPCONFIG_REMOTE_ADDR="https://uni-lab.bohrium.com/api/v1" -``` - -### 环境变量类型转换 - -- **布尔值**:`"true"`, `"1"`, `"yes"` → `True`;其他 → `False` -- **整数**:自动转换为 `int` 类型 -- **浮点数**:自动转换为 `float` 类型 -- **字符串**:保持原值 - -## 配置文件使用方法 - -### 1. 指定配置文件启动 - -```bash -# 使用指定配置文件启动 -unilab --config /path/to/your/config.py -``` - -### 2. 使用默认配置文件 - -如果不指定配置文件,系统会按以下顺序查找: - -1. 环境变量 `UNILABOS_BASICCONFIG_CONFIG_PATH` 指定的路径 -2. 工作目录下的 `local_config.py` -3. 首次使用时会引导创建配置文件 - -### 3. 配置文件验证 - -系统启动时会自动验证配置文件: - -- **语法检查**:确保 Python 语法正确 -- **类型检查**:验证配置项类型是否匹配 -- **必需项检查**:确保 `ak` 和 `sk` 已配置 - -## 最佳实践 - -### 1. 安全配置 - -- 不要将包含密钥的配置文件提交到版本控制系统 -- 使用环境变量或命令行参数在生产环境中配置敏感信息 -- 定期更换访问密钥 -- **推荐配置方式**: - - ```bash - # 生产环境 - 使用环境变量 - export UNILABOS_BASICCONFIG_AK="your_access_key" - export UNILABOS_BASICCONFIG_SK="your_secret_key" - unilab - - # 或使用命令行参数 - unilab --ak "your_access_key" --sk "your_secret_key" - ``` - -### 2. 多环境配置 - -为不同环境创建不同的配置文件并结合命令行参数: - -``` -configs/ -├── local_config.py # 本地开发 -├── test_config.py # 测试环境 -├── prod_config.py # 生产环境 -└── example_config.py # 示例配置 -``` - -**环境切换示例**: - -```bash -# 本地开发环境 -unilab --config configs/local_config.py --addr local - -# 测试环境 -unilab --config configs/test_config.py --addr test --upload_registry - -# 生产环境 -unilab --config configs/prod_config.py --ak "$PROD_AK" --sk "$PROD_SK" -``` - -### 3. 配置管理 - -- 保持配置文件简洁,只包含需要修改的配置项 -- 为配置项添加注释说明其作用 -- 定期检查和更新配置文件 -- **命令行参数优先使用场景**: - - 临时测试不同配置 - - CI/CD 流水线中的动态配置 - - 不同环境间快速切换 - - 敏感信息的安全传递 - -### 4. 灵活配置策略 - -**基础配置文件 + 命令行覆盖**的推荐方式: - -```python -# base_config.py - 基础配置 -class BasicConfig: - # 非敏感配置写在文件中 - is_host_mode = True - upload_registry = False - vis_2d_enable = False - -class WSConfig: - reconnect_interval = 5 - max_reconnect_attempts = 999 - ping_interval = 30 -``` - -```bash -# 启动时通过命令行覆盖关键参数 -unilab --config base_config.py \ - --ak "$AK" \ - --sk "$SK" \ - --addr "test" \ - --upload_registry \ - --2d_vis -``` - -## 故障排除 - -### 1. 配置文件加载失败 - -**错误信息**:`[ENV] 配置文件 xxx 不存在` - -**解决方法**: - -- 确认配置文件路径正确 -- 检查文件权限是否可读 -- 确保配置文件是 `.py` 格式 - -### 2. 语法错误 - -**错误信息**:`[ENV] 加载配置文件 xxx 失败` - -**解决方法**: - -- 检查 Python 语法是否正确 -- 确认类名和字段名拼写正确 -- 验证缩进是否正确(使用空格而非制表符) - -### 3. 认证失败 - -**错误信息**:`后续运行必须拥有一个实验室` - -**解决方法**: - -- 确认 `ak` 和 `sk` 已正确配置 -- 检查密钥是否有效 -- 确认网络连接正常 - -### 4. 环境变量不生效 - -**解决方法**: - -- 确认环境变量名格式正确(`UNILABOS_CLASS_FIELD`) -- 检查环境变量是否已正确设置 -- 重启系统或重新加载环境变量 - -### 5. 命令行参数不生效 - -**错误现象**:设置了命令行参数但配置没有生效 - -**解决方法**: - -- 确认参数名拼写正确(如 `--ak` 而不是 `--access_key`) -- 检查参数格式是否正确(布尔参数如 `--is_slave` 不需要值) -- 确认参数位置正确(所有参数都应在 `unilab` 之后) -- 查看启动日志确认参数是否被正确解析 - -### 6. 配置优先级混淆 - -**错误现象**:不确定哪个配置生效 - -**解决方法**: - -- 记住优先级:命令行参数 > 环境变量 > 配置文件 -- 使用 `--ak` 和 `--sk` 参数时会看到提示信息 -- 检查启动日志中的配置加载信息 -- 临时移除低优先级配置来测试高优先级配置是否生效 diff --git a/docs/user_guide/graph_files.md b/docs/user_guide/graph_files.md new file mode 100644 index 00000000..a8f86aaf --- /dev/null +++ b/docs/user_guide/graph_files.md @@ -0,0 +1,860 @@ +# 设备图文件说明 + +设备图文件定义了实验室中所有设备、资源及其连接关系。本文档说明如何创建和使用设备图文件。 + +## 概述 + +设备图文件采用 JSON 格式,节点定义基于 **`ResourceDict`** 标准模型(定义在 `unilabos.ros.nodes.resource_tracker`)。系统会自动处理旧格式并转换为标准格式,确保向后兼容性。 + +**核心概念**: + +- **Nodes(节点)**: 代表设备或资源,通过 `parent` 字段建立层级关系 +- **Links(连接)**: 可选的连接关系定义,用于展示设备间的物理或通信连接 +- **UUID**: 全局唯一标识符,用于跨系统的资源追踪 +- **自动转换**: 旧格式会通过 `ResourceDictInstance.get_resource_instance_from_dict()` 自动转换 + +## 文件格式 + +Uni-Lab 支持两种格式的设备图文件: + +### JSON 格式(推荐) + +**优点**: + +- 易于编辑和阅读 +- 支持注释(使用预处理) +- 与 Web 界面完全兼容 +- 便于版本控制 + +**示例**: `workshop1.json` + +### GraphML 格式 + +**优点**: + +- 可用图形化工具编辑(如 yEd) +- 适合复杂拓扑可视化 + +**示例**: `setup.graphml` + +## JSON 文件结构 + +一个完整的 JSON 设备图文件包含两个主要部分: + +```json +{ + "nodes": [ + /* 设备和资源节点 */ + ], + "links": [ + /* 连接关系(可选)*/ + ] +} +``` + +### Nodes(节点) + +每个节点代表一个设备或资源。节点的定义遵循 `ResourceDict` 标准模型: + +```json +{ + "id": "liquid_handler_1", + "uuid": "550e8400-e29b-41d4-a716-446655440000", + "name": "液体处理工作站", + "type": "device", + "class": "liquid_handler", + "config": { + "port": "/dev/ttyUSB0", + "baudrate": 9600 + }, + "data": {}, + "position": { + "x": 100, + "y": 200 + }, + "parent": null +} +``` + +**字段说明(基于 ResourceDict 标准定义)**: + +| 字段 | 必需 | 说明 | 示例 | 默认值 | +| ------------- | ---- | ------------------------ | ---------------------------------------------------- | -------- | +| `id` | ✓ | 唯一标识符 | `"pump_1"` | - | +| `uuid` | | 全局唯一标识符 (UUID) | `"550e8400-e29b-41d4-a716-446655440000"` | 自动生成 | +| `name` | ✓ | 显示名称 | `"主反应泵"` | - | +| `type` | ✓ | 节点类型 | `"device"`, `"resource"`, `"container"`, `"deck"` 等 | - | +| `class` | ✓ | 设备/资源类别 | `"liquid_handler"`, `"syringepump.runze"` | `""` | +| `config` | | Python 类的初始化参数 | `{"port": "COM3"}` | `{}` | +| `data` | | 资源的运行状态数据 | `{"status": "Idle", "position": 0.0}` | `{}` | +| `position` | | 在图中的位置 | `{"x": 100, "y": 200}` 或完整的 pose 结构 | - | +| `pose` | | 完整的 3D 位置信息 | 参见下文 | - | +| `parent` | | 父节点 ID | `"deck_1"` | `null` | +| `parent_uuid` | | 父节点 UUID | `"550e8400-..."` | `null` | +| `children` | | 子节点 ID 列表(旧格式) | `["child1", "child2"]` | - | +| `description` | | 资源描述 | `"用于精确控制试剂A的加料速率"` | `""` | +| `schema` | | 资源 schema 定义 | `{}` | `{}` | +| `model` | | 资源 3D 模型信息 | `{}` | `{}` | +| `icon` | | 资源图标 | `"pump.webp"` | `""` | +| `extra` | | 额外的自定义数据 | `{"custom_field": "value"}` | `{}` | + +### Position 和 Pose(位置信息) + +**简单格式(旧格式,兼容)**: + +```json +"position": { + "x": 100, + "y": 200, + "z": 0 +} +``` + +**完整格式(推荐)**: + +```json +"pose": { + "size": { + "width": 127.76, + "height": 85.48, + "depth": 10.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + }, + "layout": "x-y", + "position": { + "x": 100, + "y": 200, + "z": 0 + }, + "position3d": { + "x": 100, + "y": 200, + "z": 0 + }, + "rotation": { + "x": 0, + "y": 0, + "z": 0 + }, + "cross_section_type": "rectangle" +} +``` + +### Links(连接) + +定义节点之间的连接关系(可选,主要用于物理连接或通信关系的可视化): + +```json +{ + "source": "pump_1", + "target": "reactor_1", + "sourceHandle": "output", + "targetHandle": "input", + "type": "physical" +} +``` + +**字段说明**: + +| 字段 | 必需 | 说明 | 示例 | +| -------------- | ---- | ---------------- | ---------------------------------------- | +| `source` | ✓ | 源节点 ID | `"pump_1"` | +| `target` | ✓ | 目标节点 ID | `"reactor_1"` | +| `sourceHandle` | | 源节点的连接点 | `"output"` | +| `targetHandle` | | 目标节点的连接点 | `"input"` | +| `type` | | 连接类型 | `"physical"`, `"communication"` | +| `port` | | 端口映射信息 | `{"source": "port1", "target": "port2"}` | + +**注意**: Links 主要用于图形化展示和文档说明,父子关系通过 `parent` 字段定义,不依赖 links。 + +## 完整示例 + +### 示例 1:液体处理工作站(PRCXI9300) + +这是一个真实的液体处理工作站配置,包含设备、工作台和多个板资源。 + +**文件位置**: `test/experiments/prcxi_9300.json` + +```json +{ + "nodes": [ + { + "id": "PRCXI9300", + "name": "PRCXI9300", + "parent": null, + "type": "device", + "class": "liquid_handler.prcxi", + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "config": { + "deck": { + "_resource_child_name": "PRCXI_Deck_9300", + "_resource_type": "unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Deck" + }, + "host": "10.181.214.132", + "port": 9999, + "timeout": 10.0, + "axis": "Left", + "channel_num": 8, + "setup": false, + "debug": true, + "simulator": true, + "matrix_id": "71593" + }, + "data": {}, + "children": ["PRCXI_Deck_9300"] + }, + { + "id": "PRCXI_Deck_9300", + "name": "PRCXI_Deck_9300", + "parent": "PRCXI9300", + "type": "deck", + "class": "", + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "config": { + "type": "PRCXI9300Deck", + "size_x": 100, + "size_y": 100, + "size_z": 100, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "deck" + }, + "data": {}, + "children": [ + "RackT1", + "PlateT2", + "trash", + "PlateT4", + "PlateT5", + "PlateT6" + ] + }, + { + "id": "RackT1", + "name": "RackT1", + "parent": "PRCXI_Deck_9300", + "type": "tip_rack", + "class": "", + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "config": { + "type": "TipRack", + "size_x": 127.76, + "size_y": 85.48, + "size_z": 100 + }, + "data": {}, + "children": [] + } + ] +} +``` + +**关键点**: + +- 使用 `parent` 字段建立层级关系(PRCXI9300 → Deck → Rack/Plate) +- 使用 `children` 字段(旧格式)列出子节点 +- `config` 中包含设备特定的连接参数 +- `data` 存储运行时状态 +- `position` 使用简单的 x/y/z 坐标 + +### 示例 2:有机合成工作站(带 Links) + +这是一个格林纳德反应的流动化学工作站配置,展示了完整的设备连接和通信关系。 + +**文件位置**: `test/experiments/Grignard_flow_batchreact_single_pumpvalve.json` + +```json +{ + "nodes": [ + { + "id": "YugongStation", + "name": "愚公常量合成工作站", + "parent": null, + "type": "device", + "class": "workstation", + "position": { + "x": 620.6111111111111, + "y": 171, + "z": 0 + }, + "config": { + "protocol_type": [ + "PumpTransferProtocol", + "CleanProtocol", + "SeparateProtocol", + "EvaporateProtocol" + ] + }, + "data": {}, + "children": [ + "serial_pump", + "pump_reagents", + "flask_CH2Cl2", + "reactor", + "pump_workup", + "separator_controller", + "flask_separator", + "rotavap", + "column" + ] + }, + { + "id": "serial_pump", + "name": "serial_pump", + "parent": "YugongStation", + "type": "device", + "class": "serial", + "position": { + "x": 620.6111111111111, + "y": 171, + "z": 0 + }, + "config": { + "port": "COM7", + "baudrate": 9600 + }, + "data": {}, + "children": [] + }, + { + "id": "pump_reagents", + "name": "pump_reagents", + "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" + }, + "children": [] + }, + { + "id": "reactor", + "name": "reactor", + "parent": "YugongStation", + "type": "container", + "class": null, + "position": { + "x": 430.4087301587302, + "y": 428, + "z": 0 + }, + "config": {}, + "data": {}, + "children": [] + } + ], + "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_workup": "port", + "serial_pump": "port" + } + } + ] +} +``` + +**关键点**: + +- 多级设备层次:工作站包含多个子设备和容器 +- `links` 定义通信关系(泵通过串口连接) +- `data` 字段存储设备状态(如泵的位置、速度等) +- `class` 可以使用点号分层(如 `"syringepump.runze"`) +- 容器的 `class` 可以为 `null` + +## 格式兼容性和转换 + +### 旧格式自动转换 + +Uni-Lab 使用 `ResourceDictInstance.get_resource_instance_from_dict()` 方法自动处理旧格式的节点数据,确保向后兼容性。 + +**自动转换规则**: + +1. **自动生成缺失字段**: + + ```python + # 如果缺少 id,使用 name 作为 id + if "id" not in content: + content["id"] = content["name"] + + # 如果缺少 uuid,自动生成 + if "uuid" not in content: + content["uuid"] = str(uuid.uuid4()) + ``` + +2. **Position 格式转换**: + + ```python + # 旧格式:简单的 x/y 坐标 + "position": {"x": 100, "y": 200} + + # 自动转换为新格式 + "position": { + "position": {"x": 100, "y": 200} + } + ``` + +3. **默认值填充**: + + ```python + # 自动填充空字段 + if not content.get("class"): + content["class"] = "" + if not content.get("config"): + content["config"] = {} + if not content.get("data"): + content["data"] = {} + if not content.get("extra"): + content["extra"] = {} + ``` + +4. **Pose 字段同步**: + ```python + # 如果没有 pose,使用 position + if "pose" not in content: + content["pose"] = content.get("position", {}) + ``` + +### 使用示例 + +```python +from unilabos.ros.nodes.resource_tracker import ResourceDictInstance + +# 旧格式节点 +old_format_node = { + "name": "pump_1", + "type": "device", + "class": "syringepump", + "position": {"x": 100, "y": 200} +} + +# 自动转换为标准格式 +instance = ResourceDictInstance.get_resource_instance_from_dict(old_format_node) + +# 访问标准化后的数据 +print(instance.res_content.id) # "pump_1" +print(instance.res_content.uuid) # 自动生成的 UUID +print(instance.res_content.config) # {} +print(instance.res_content.data) # {} +``` + +### 格式迁移建议 + +虽然系统会自动处理旧格式,但建议在新文件中使用完整的标准格式: + +| 字段 | 旧格式(兼容) | 新格式(推荐) | +| ------ | ---------------------------------- | ------------------------------------------------ | +| 标识符 | 仅 `id` 或仅 `name` | `id` + `uuid` | +| 位置 | `"position": {"x": 100, "y": 200}` | 完整的 `pose` 结构 | +| 父节点 | `"parent": "parent_id"` | `"parent": "parent_id"` + `"parent_uuid": "..."` | +| 配置 | 可省略 | 显式设置为 `{}` | +| 数据 | 可省略 | 显式设置为 `{}` | + +## 节点类型详解 + +### Device 节点 + +设备节点代表实际的硬件设备: + +```json +{ + "id": "device_id", + "name": "设备名称", + "type": "device", + "class": "设备类别", + "parent": null, + "config": { + "port": "COM3" + }, + "data": {}, + "children": [] +} +``` + +**常见设备类别**: + +- `liquid_handler`: 液体处理工作站 +- `liquid_handler.prcxi`: PRCXI 液体处理工作站 +- `syringepump`: 注射泵 +- `syringepump.runze`: 润泽注射泵 +- `heaterstirrer`: 加热搅拌器 +- `balance`: 天平 +- `reactor_vessel`: 反应釜 +- `serial`: 串口通信设备 +- `workstation`: 自动化工作站 + +### Resource 节点 + +资源节点代表物料容器、载具等: + +```json +{ + "id": "resource_id", + "name": "资源名称", + "type": "resource", + "class": "资源类别", + "parent": "父节点ID", + "config": { + "size_x": 127.76, + "size_y": 85.48, + "size_z": 100 + }, + "data": {}, + "children": [] +} +``` + +**常见资源类型**: + +- `deck`: 工作台/甲板 +- `plate`: 板(96 孔板等) +- `tip_rack`: 枪头架 +- `tube`: 试管 +- `container`: 容器 +- `well`: 孔位 +- `bottle_carrier`: 瓶架 + +## Handle(连接点) + +每个设备和资源可以有多个连接点(handles),用于定义可以连接的接口。 + +### 查看可用 handles + +设备和资源的可用 handles 定义在注册表中: + +```yaml +# 设备注册表示例 +liquid_handler: + handles: + - handler_key: pipette + io_type: source + - handler_key: deck + io_type: target +``` + +### 常见 handles + +| 设备类型 | Source Handles | Target Handles | +| ---------- | -------------- | -------------- | +| 泵 | output | input | +| 反应釜 | output, vessel | input | +| 液体处理器 | pipette | deck | +| 板 | wells | access | + +## 使用 Web 界面创建图文件 + +Uni-Lab 提供 Web 界面来可视化创建和编辑设备图: + +### 1. 启动 Uni-Lab + +```bash +unilab +``` + +### 2. 访问 Web 界面 + +打开浏览器访问 `http://localhost:8002` + +### 3. 图形化编辑 + +- 拖拽添加设备和资源 +- 连线建立连接关系 +- 编辑节点属性 +- 保存为 JSON 文件 + +### 4. 导出图文件 + +点击"导出"按钮,下载 JSON 文件到本地。 + +## 从云端获取图文件 + +如果不指定`-g`参数,Uni-Lab 会自动从云端获取: + +```bash +# 使用云端配置 +unilab + +# 日志会显示: +# [INFO] 未指定设备加载文件路径,尝试从HTTP获取... +# [INFO] 联网获取设备加载文件成功 +``` + +**云端图文件管理**: + +1. 登录 https://uni-lab.bohrium.com +2. 进入"设备配置" +3. 创建或编辑配置 +4. 保存到云端 + +本地启动时会自动同步最新配置。 + +## 调试图文件 + +### 验证 JSON 格式 + +```bash +# 使用Python验证 +python -c "import json; json.load(open('workshop1.json'))" + +# 使用在线工具 +# https://jsonlint.com/ +``` + +### 检查节点引用 + +确保: + +- 所有`links`中的`source`和`target`都存在于`nodes`中 +- `parent`字段指向的节点存在 +- `class`字段对应的设备/资源在注册表中存在 + +### 启动时验证 + +```bash +# Uni-Lab启动时会验证图文件 +unilab -g workshop1.json + +# 查看日志中的错误或警告 +# [ERROR] 节点 xxx 的source端点 yyy 不存在 +# [WARNING] 节点 zzz missing 'name', defaulting to ... +``` + +## 最佳实践 + +### 1. 命名规范 + +```json +{ + "id": "pump_reagent_1", // 小写+下划线,描述性 + "name": "试剂进料泵A", // 中文显示名称 + "class": "syringepump" // 使用注册表中的精确名称 +} +``` + +### 2. 层级组织 + +``` +host_node (主节点) +└── liquid_handler_1 (设备) + └── deck_1 (资源) + ├── tiprack_1 (资源) + ├── plate_1 (资源) + └── reservoir_1 (资源) +``` + +### 3. 配置分离 + +将设备特定配置放在`config`中: + +```json +{ + "id": "pump_1", + "class": "syringepump", + "config": { + "port": "COM3", // 设备特定 + "max_flow_rate": 10, // 设备特定 + "volume": 50 // 设备特定 + } +} +``` + +### 4. 版本控制 + +```bash +# 使用Git管理图文件 +git add workshop1.json +git commit -m "Add new liquid handler configuration" + +# 使用有意义的文件名 +workshop_v1.json +workshop_production.json +workshop_test.json +``` + +### 5. 注释(通过描述字段) + +虽然 JSON 不支持注释,但可以使用`description`字段: + +```json +{ + "id": "pump_1", + "name": "进料泵", + "description": "用于精确控制试剂A的加料速率,最大流速10mL/min", + "class": "syringepump" +} +``` + +## 示例文件位置 + +Uni-Lab 在安装时已预置了 **40+ 个真实的设备图文件示例**,位于 `unilabos/test/experiments/` 目录。这些都是真实项目中使用的配置文件,可以直接使用或作为参考。 + +### 📁 主要示例文件 + +``` +test/experiments/ +├── workshop.json # 综合工作台(推荐新手) +├── empty_devices.json # 空设备配置(最小化) +├── prcxi_9300.json # PRCXI液体处理工作站(本文示例1) +├── prcxi_9320.json # PRCXI 9320工作站 +├── biomek.json # Biomek液体处理工作站 +├── Grignard_flow_batchreact_single_pumpvalve.json # 格林纳德反应工作站(本文示例2) +├── dispensing_station_bioyond.json # Bioyond配液站 +├── reaction_station_bioyond.json # Bioyond反应站 +├── HPLC.json # HPLC分析系统 +├── plr_test.json # PyLabRobot测试配置 +├── lidocaine-graph.json # 利多卡因合成工作站 +├── opcua_example.json # OPC UA设备集成示例 +│ +├── mock_devices/ # 虚拟设备(用于离线测试) +│ ├── mock_all.json # 完整虚拟设备集 +│ ├── mock_pump.json # 虚拟泵 +│ ├── mock_stirrer.json # 虚拟搅拌器 +│ ├── mock_heater.json # 虚拟加热器 +│ └── ... # 更多虚拟设备 +│ +├── Protocol_Test_Station/ # 协议测试工作站 +│ ├── pumptransfer_test_station.json # 泵转移协议测试 +│ ├── heatchill_protocol_test_station.json # 加热冷却协议测试 +│ ├── filter_protocol_test_station.json # 过滤协议测试 +│ └── ... # 更多协议测试 +│ +└── comprehensive_protocol/ # 综合协议示例 + ├── comprehensive_station.json # 综合工作站 + └── comprehensive_slim.json # 精简版综合工作站 +``` + +### 🚀 快速使用 + +无需下载或创建,直接使用 `-g` 参数指定路径: + +```bash +# 使用简单工作台(推荐新手) +unilab --ak your_ak --sk your_sk -g test/experiments/workshop.json + +# 使用虚拟设备(无需真实硬件) +unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json + +# 使用 PRCXI 液体处理工作站 +unilab --ak your_ak --sk your_sk -g test/experiments/prcxi_9300.json + +# 使用格林纳德反应工作站 +unilab --ak your_ak --sk your_sk -g test/experiments/Grignard_flow_batchreact_single_pumpvalve.json +``` + +### 📚 文件分类 + +| 类别 | 说明 | 文件数量 | +| ------------ | ------------------------ | -------- | +| **主工作站** | 完整的实验工作站配置 | 15+ | +| **虚拟设备** | 用于开发测试的 mock 设备 | 10+ | +| **协议测试** | 各种实验协议的测试配置 | 12+ | +| **综合示例** | 包含多种协议的综合工作站 | 3+ | + +这些文件展示了不同场景下的设备图配置,涵盖液体处理、有机合成、分析检测等多个领域,是学习和创建自己配置的绝佳参考。 + +## 快速参考:ResourceDict 完整字段列表 + +基于 `unilabos.ros.nodes.resource_tracker.ResourceDict` 的完整字段定义: + +```python +class ResourceDict(BaseModel): + # === 基础标识 === + id: str # 资源ID(必需) + uuid: str # 全局唯一标识符(自动生成) + name: str # 显示名称(必需) + + # === 类型和分类 === + type: Union[Literal["device"], str] # 节点类型(必需) + klass: str # 资源类别(alias="class",必需) + + # === 层级关系 === + parent: Optional[ResourceDict] # 父资源对象(不序列化) + parent_uuid: Optional[str] # 父资源UUID + + # === 位置和姿态 === + position: ResourceDictPosition # 位置信息 + pose: ResourceDictPosition # 姿态信息(推荐使用) + + # === 配置和数据 === + config: Dict[str, Any] # 设备配置参数 + data: Dict[str, Any] # 运行时状态数据 + extra: Dict[str, Any] # 额外自定义数据 + + # === 元数据 === + description: str # 资源描述 + resource_schema: Dict[str, Any] # schema定义(alias="schema") + model: Dict[str, Any] # 3D模型信息 + icon: str # 图标路径 +``` + +**Position/Pose 结构**: + +```python +class ResourceDictPosition(BaseModel): + size: ResourceDictPositionSize # width, height, depth + scale: ResourceDictPositionScale # x, y, z + layout: Literal["2d", "x-y", "z-y", "x-z"] + position: ResourceDictPositionObject # x, y, z + position3d: ResourceDictPositionObject # x, y, z + rotation: ResourceDictPositionObject # x, y, z + cross_section_type: Literal["rectangle", "circle", "rounded_rectangle"] +``` + +## 下一步 + +- {doc}`../boot_examples/index` - 查看完整启动示例 +- {doc}`../developer_guide/add_device` - 了解如何添加新设备 +- {doc}`06_troubleshooting` - 图文件相关问题排查 +- 源码参考: `unilabos/ros/nodes/resource_tracker.py` - ResourceDict 标准定义 + +## 获取帮助 + +- 在 Web 界面中使用模板创建 +- 参考示例文件:`test/experiments/` 目录 +- 查看 ResourceDict 源码了解完整定义 +- [GitHub 讨论区](https://github.com/dptech-corp/Uni-Lab-OS/discussions) diff --git a/docs/user_guide/image/test_latency_result.png b/docs/user_guide/image/test_latency_result.png new file mode 100644 index 00000000..af6395d9 Binary files /dev/null and b/docs/user_guide/image/test_latency_result.png differ diff --git a/docs/user_guide/image/test_latency_running.png b/docs/user_guide/image/test_latency_running.png new file mode 100644 index 00000000..4483dc13 Binary files /dev/null and b/docs/user_guide/image/test_latency_running.png differ diff --git a/docs/user_guide/image/test_latency_select_device.png b/docs/user_guide/image/test_latency_select_device.png new file mode 100644 index 00000000..af6395d9 Binary files /dev/null and b/docs/user_guide/image/test_latency_select_device.png differ diff --git a/docs/user_guide/installation.md b/docs/user_guide/installation.md index 63a1c1b3..d3fd4982 100644 --- a/docs/user_guide/installation.md +++ b/docs/user_guide/installation.md @@ -1,43 +1,516 @@ -# **Uni-Lab 安装** +# Uni-Lab-OS 安装指南 -## 快速开始 +本指南提供 Uni-Lab-OS 的完整安装说明,涵盖从快速一键安装到完整开发环境配置的所有方式。 -1. **配置 Conda 环境** +## 系统要求 -Uni-Lab-OS 建议使用 `mamba` 管理环境。创建新的环境: +- **操作系统**: Windows 10/11, Linux (Ubuntu 20.04+), macOS (10.15+) +- **内存**: 最小 4GB,推荐 8GB 以上 +- **磁盘空间**: 至少 10GB 可用空间 +- **网络**: 稳定的互联网连接(用于下载软件包) +- **其他**: + - 已安装 Conda/Miniconda/Miniforge/Mamba + - 开发者需要 Git 和基本的 Python 开发知识 + - 自定义 msgs 需要 GitHub 账号 -```shell +## 安装方式选择 + +根据您的使用场景,选择合适的安装方式: + +| 安装方式 | 适用人群 | 特点 | 安装时间 | +| ---------------------- | -------------------- | ------------------------------ | ---------------------------- | +| **方式一:一键安装** | 实验室用户、快速体验 | 预打包环境,离线可用,无需配置 | 5-10 分钟 (网络良好的情况下) | +| **方式二:手动安装** | 标准用户、生产环境 | 灵活配置,版本可控 | 10-20 分钟 | +| **方式三:开发者安装** | 开发者、需要修改源码 | 可编辑模式,支持自定义 msgs | 20-30 分钟 | + +--- + +## 方式一:一键安装(推荐新用户) + +使用预打包的 conda 环境,最快速的安装方法。 + +### 前置条件 + +确保已安装 Conda/Miniconda/Miniforge/Mamba。 + +### 安装步骤 + +#### 第一步:下载预打包环境 + +1. 访问 [GitHub Actions - Conda Pack Build](https://github.com/dptech-corp/Uni-Lab-OS/actions/workflows/conda-pack-build.yml) + +2. 选择最新的成功构建记录(绿色勾号 ✓) + +3. 在页面底部的 "Artifacts" 部分,下载对应你操作系统的压缩包: + - Windows: `unilab-pack-win-64-{branch}.zip` + - macOS (Intel): `unilab-pack-osx-64-{branch}.tar.gz` + - macOS (Apple Silicon): `unilab-pack-osx-arm64-{branch}.tar.gz` + - Linux: `unilab-pack-linux-64-{branch}.tar.gz` + +#### 第二步:解压并运行安装脚本 + +**Windows**: + +```batch +REM 使用 Windows 资源管理器解压下载的 zip 文件 +REM 或使用命令行: +tar -xzf unilab-pack-win-64-dev.zip + +REM 进入解压后的目录 +cd unilab-pack-win-64-dev + +REM 双击运行 install_unilab.bat +REM 或在命令行中执行: +install_unilab.bat +``` + +**macOS**: + +```bash +# 解压下载的压缩包 +tar -xzf unilab-pack-osx-arm64-dev.tar.gz + +# 进入解压后的目录 +cd unilab-pack-osx-arm64-dev + +# 运行安装脚本 +bash install_unilab.sh +``` + +**Linux**: + +```bash +# 解压下载的压缩包 +tar -xzf unilab-pack-linux-64-dev.tar.gz + +# 进入解压后的目录 +cd unilab-pack-linux-64-dev + +# 添加执行权限(如果需要) +chmod +x install_unilab.sh + +# 运行安装脚本 +./install_unilab.sh +``` + +#### 第三步:激活环境 + +```bash +conda activate unilab +``` + +激活后,您的命令行提示符应该会显示 `(unilab)` 前缀。 + +--- + +## 方式二:手动安装(标准用户) + +适合生产环境和需要灵活配置的用户。 + +### 第一步:安装 Mamba 环境管理器 + +Mamba 是 Conda 的快速替代品,我们强烈推荐使用 Mamba 来管理 Uni-Lab 环境。 + +#### Windows + +下载并安装 Miniforge(包含 Mamba): + +```powershell +# 访问 https://github.com/conda-forge/miniforge/releases +# 下载 Miniforge3-Windows-x86_64.exe +# 运行安装程序 + +# 也可以使用镜像站 https://mirrors.tuna.tsinghua.edu.cn/github-release/conda-forge/miniforge/LatestRelease/ +# 下载 Miniforge3-Windows-x86_64.exe +# 运行安装程序 +``` + +#### Linux/macOS + +```bash +# 下载 Miniforge 安装脚本 +curl -L -O "https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-$(uname)-$(uname -m).sh" + +# 运行安装 +bash Miniforge3-$(uname)-$(uname -m).sh + +# 按照提示完成安装,建议选择 yes 来初始化 +``` + +安装完成后,重新打开终端使 Mamba 生效。 + +### 第二步:创建 Uni-Lab 环境 + +使用以下命令创建 Uni-Lab 专用环境: + +```bash +mamba create -n unilab python=3.11.11 # 目前ros2组件依赖版本大多为3.11.11 +mamba activate unilab +mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge +``` + +**参数说明**: + +- `-n unilab`: 创建名为 "unilab" 的环境 +- `uni-lab::unilabos`: 从 uni-lab channel 安装 unilabos 包 +- `-c robostack-staging -c conda-forge`: 添加额外的软件源 + +**如果遇到网络问题**,可以使用清华镜像源加速下载: + +```bash +# 配置清华镜像源 +mamba config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main/ +mamba config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free/ +mamba config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/ + +# 然后重新执行安装命令 +mamba create -n unilab uni-lab::unilabos -c robostack-staging +``` + +### 第三步:激活环境 + +```bash +conda activate unilab +``` + +--- + +## 方式三:开发者安装 + +适用于需要修改 Uni-Lab 源代码或开发新设备驱动的开发者。 + +### 前置条件 + +- 已安装 Git +- 已安装 Mamba/Conda +- 有 GitHub 账号(如需自定义 msgs) +- 基本的 Python 开发知识 + +### 第一步:克隆仓库 + +```bash +git clone https://github.com/dptech-corp/Uni-Lab-OS.git +cd Uni-Lab-OS +``` + +如果您需要贡献代码,建议先 Fork 仓库: + +1. 访问 https://github.com/dptech-corp/Uni-Lab-OS +2. 点击右上角的 "Fork" 按钮 +3. Clone 您的 Fork 版本: + ```bash + git clone https://github.com/YOUR_USERNAME/Uni-Lab-OS.git + cd Uni-Lab-OS + ``` + +### 第二步:安装基础环境 + +**推荐方式**:先通过**方式一(一键安装)**或**方式二(手动安装)**完成基础环境的安装,这将包含所有必需的依赖项(ROS2、msgs 等)。 + +#### 选项 A:通过一键安装(推荐) + +参考上文"方式一:一键安装",完成基础环境的安装后,激活环境: + +```bash +conda activate unilab +``` + +#### 选项 B:通过手动安装 + +参考上文"方式二:手动安装",创建并安装环境: + +```bash +mamba create -n unilab python=3.11.11 +conda activate unilab +mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge +``` + +**说明**:这会安装包括 Python 3.11.11、ROS2 Humble、ros-humble-unilabos-msgs 和所有必需依赖 + +### 第三步:切换到开发版本 + +现在你已经有了一个完整可用的 Uni-Lab 环境,接下来将 unilabos 包切换为开发版本: + +```bash +# 确保环境已激活 +conda activate unilab + +# 卸载 pip 安装的 unilabos(保留所有 conda 依赖) +pip uninstall unilabos -y + +# 克隆 dev 分支(如果还未克隆) +cd /path/to/your/workspace +git clone -b dev https://github.com/dptech-corp/Uni-Lab-OS.git +# 或者如果已经克隆,切换到 dev 分支 +cd Uni-Lab-OS +git checkout dev +git pull + +# 以可编辑模式安装开发版 unilabos +pip install -e . -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple +``` + +**参数说明**: + +- `-e`: editable mode(可编辑模式),代码修改立即生效,无需重新安装 +- `-i`: 使用清华镜像源加速下载 +- `pip uninstall unilabos`: 只卸载 pip 安装的 unilabos 包,不影响 conda 安装的其他依赖(如 ROS2、msgs 等) + +### 第四步:安装或自定义 ros-humble-unilabos-msgs(可选) + +Uni-Lab 使用 ROS2 消息系统进行设备间通信。如果你使用方式一或方式二安装,msgs 包已经自动安装。 + +#### 使用已安装的 msgs(大多数用户) + +如果你不需要修改 msgs,可以跳过此步骤,直接使用已安装的 msgs 包。验证安装: + +```bash +# 列出所有 unilabos_msgs 接口 +ros2 interface list | grep unilabos_msgs + +# 查看特定 action 定义 +ros2 interface show unilabos_msgs/action/DeviceCmd +``` + +#### 自定义 msgs(高级用户) + +如果你需要: + +- 添加新的 ROS2 action 定义 +- 修改现有 msg/srv/action 接口 +- 为特定设备定制通信协议 + +请参考 **[添加新动作指令(Action)指南](../developer_guide/add_action.md)**,该指南详细介绍了如何: + +- 编写新的 Action 定义 +- 在线构建 Action(通过 GitHub Actions) +- 下载并安装自定义的 msgs 包 +- 测试和验证新的 Action + +```bash +# 安装自定义构建的 msgs 包 +mamba remove --force ros-humble-unilabos-msgs +mamba config set safety_checks disabled # 关闭 md5 检查 +mamba install /path/to/ros-humble-unilabos-msgs-*.conda --offline +``` + +### 第五步:验证开发环境 + +完成上述步骤后,验证开发环境是否正确配置: + +```bash +# 确保环境已激活 +conda activate unilab + +# 检查 ROS2 环境 +ros2 --version + +# 检查 msgs 包 +ros2 interface list | grep unilabos_msgs + +# 检查 Python 可以导入 unilabos +python -c "import unilabos; print(f'Uni-Lab版本: {unilabos.__version__}')" + +# 检查 unilab 命令 +unilab --help +``` + +如果所有命令都正常输出,说明开发环境配置成功! + +--- + +## 验证安装 + +无论使用哪种安装方式,都应该验证安装是否成功。 + +### 基本验证 + +```bash +# 确保已激活环境 +conda activate unilab # 或 unilab-dev + +# 检查 unilab 命令 +unilab --help +``` + +您应该看到类似以下的输出: + +``` +usage: unilab [-h] [-g GRAPH] [-c CONTROLLERS] [--registry_path REGISTRY_PATH] + [--working_dir WORKING_DIR] [--backend {ros,simple,automancer}] + ... +``` + +### 检查版本 + +```bash +python -c "import unilabos; print(f'Uni-Lab版本: {unilabos.__version__}')" +``` + +### 使用验证脚本(方式一) + +如果使用一键安装,可以运行预打包的验证脚本: + +```bash +# 确保已激活环境 +conda activate unilab + +# 运行验证脚本 +python verify_installation.py +``` + +如果看到 "✓ All checks passed!",说明安装成功! + +--- + +## 常见问题 + +### 问题 1: 找不到 unilab 命令 + +**原因**: 环境未正确激活或 PATH 未设置 + +**解决方案**: + +```bash +# 确保激活了正确的环境 +conda activate unilab + +# 检查 unilab 是否在 PATH 中 +which unilab # Linux/macOS +where unilab # Windows +``` + +### 问题 2: 包冲突或依赖错误 + +**解决方案**: + +```bash +# 删除旧环境重新创建 +conda deactivate +conda env remove -n unilab mamba create -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge ``` -2. **安装开发版 Uni-Lab-OS** +### 问题 3: 下载速度慢 -```shell -# 配置好conda环境后,克隆仓库 -git clone https://github.com/dptech-corp/Uni-Lab-OS.git -b dev -cd Uni-Lab-OS +**解决方案**: 使用国内镜像源(清华、中科大等) -# 安装 Uni-Lab-OS -pip install -e . +```bash +# 查看当前 channel 配置 +conda config --show channels + +# 添加清华镜像 +conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/ ``` -3. **安装开发版 ros-humble-unilabos-msgs** +### 问题 4: 权限错误 -**卸载老版本:** -```shell +**Windows 解决方案**: 以管理员身份运行命令提示符 + +**Linux/macOS 解决方案**: + +```bash +# 不要使用 sudo 安装 conda 包 +# 如果 conda 安装在需要权限的位置,考虑重新安装 conda 到用户目录 +``` + +### 问题 5: 安装脚本找不到 conda(方式一) + +**解决方案**: 确保你已经安装了 conda/miniconda/miniforge,并且安装在标准位置: + +- **Windows**: + + - `%USERPROFILE%\miniforge3` + - `%USERPROFILE%\miniconda3` + - `%USERPROFILE%\anaconda3` + - `C:\ProgramData\miniforge3` + +- **macOS/Linux**: + - `~/miniforge3` + - `~/miniconda3` + - `~/anaconda3` + - `/opt/conda` + +如果安装在其他位置,可以先激活 conda base 环境,然后手动运行安装脚本。 + +### 问题 6: 安装后激活环境提示找不到? + +**解决方案**: 尝试以下方法: + +```bash +# 方法 1: 使用 conda activate conda activate unilab -conda remove --force ros-humble-unilabos-msgs -``` -有时相同的安装包版本会由于dev构建得到的md5不一样,触发安全检查,可输入 `config set safety_checks disabled` 来关闭安全检查。 -**安装新版本:** +# 方法 2: 使用完整路径激活(Windows) +call C:\Users\{YourUsername}\miniforge3\envs\unilab\Scripts\activate.bat -访问 https://github.com/dptech-corp/Uni-Lab-OS/actions/workflows/multi-platform-build.yml 选择最新的构建,下载对应平台的压缩包(仅解压一次,得到.conda文件)使用如下指令: -```shell -conda activate base -conda install ros-humble-unilabos-msgs--.conda --offline -n <环境名> +# 方法 2: 使用完整路径激活(Unix) +source ~/miniforge3/envs/unilab/bin/activate ``` -4. **启动 Uni-Lab 系统** +### 问题 7: conda-unpack 失败怎么办?(方式一) -请参见{doc}`启动样例 <../boot_examples/index>`或{doc}`启动指南 `了解详细的启动方法。 +**解决方案**: 尝试手动运行: + +```bash +# Windows +cd %CONDA_PREFIX%\envs\unilab +.\Scripts\conda-unpack.exe + +# macOS/Linux +cd $CONDA_PREFIX/envs/unilab +./bin/conda-unpack +``` + +### 问题 8: 环境很大,有办法减小吗? + +**解决方案**: 预打包的环境包含所有依赖,通常较大(压缩后 2-5GB)。这是为了确保离线安装和完整功能。如果空间有限,考虑使用方式二手动安装,只安装需要的组件。 + +### 问题 9: 如何更新到最新版本? + +**解决方案**: + +**方式一用户**: 重新下载最新的预打包环境,运行安装脚本时选择覆盖现有环境。 + +**方式二/三用户**: 在现有环境中更新: + +```bash +conda activate unilab + +# 更新 unilabos +cd /path/to/Uni-Lab-OS +git pull +pip install -e . --upgrade -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple + +# 更新 ros-humble-unilabos-msgs +mamba update ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge +``` + +--- + +## 下一步 + +安装完成后,请继续: + +- **快速启动**: 学习如何首次启动 Uni-Lab +- **配置指南**: 配置您的实验室环境和设备 +- **运行示例**: 查看启动示例和最佳实践 +- **开发指南**: + - 添加新设备驱动 + - 添加新物料资源 + - 了解工作站架构 + +## 需要帮助? + +- **故障排查**: 查看更详细的故障排查信息 +- **GitHub Issues**: [报告问题](https://github.com/dptech-corp/Uni-Lab-OS/issues) +- **开发者文档**: 查看开发者指南获取更多技术细节 +- **社区讨论**: [GitHub Discussions](https://github.com/dptech-corp/Uni-Lab-OS/discussions) + +--- + +**提示**: + +- 生产环境推荐使用方式二(手动安装)的稳定版本 +- 开发和测试推荐使用方式三(开发者安装) +- 快速体验和演示推荐使用方式一(一键安装) diff --git a/docs/user_guide/launch.md b/docs/user_guide/launch.md index 5691222c..402e39aa 100644 --- a/docs/user_guide/launch.md +++ b/docs/user_guide/launch.md @@ -132,15 +132,14 @@ unilab --config path/to/your/config.py 使用 `-c` 传入控制逻辑配置。 -不管使用哪一种初始化方式,设备/物料字典均需包含 `class` 属性,用于查找注册表信息。默认查找范围都是 Uni-Lab 内部注册表 `unilabos/registry/{devices,device_comms,resources}`。要添加额外的注册表路径,可以使用 `--registry_path` 加入 `/{devices,device_comms,resources}`。 +不管使用哪一种初始化方式,设备/物料字典均需包含 `class` 属性,用于查找注册表信息。默认查找范围都是 Uni-Lab 内部注册表 `unilabos/registry/{devices,device_comms,resources}`。要添加额外的注册表路径,可以使用 `--registry_path` 加入 `/{devices,device_comms,resources}`,只输入即可,支持多次--registry_path指定多个目录。 ## 通信中间件 `--backend` 目前 Uni-Lab 支持以下通信中间件: - **ros** (默认):基于 ROS2 的通信 -- **simple**:简化通信模式 -- **automancer**:Automancer 兼容模式 +- **automancer**:Automancer 兼容模式 (实验性) ## 端云桥接 `--app_bridges` @@ -169,7 +168,7 @@ unilab --config path/to/your/config.py 通过 `--visual` 参数选择: - **rviz**:使用 RViz 进行 3D 可视化 -- **web**:使用 Web 界面进行可视化 +- **web**:使用 Web 界面进行可视化 (基于Pylabrobot) - **disable** (默认):禁用可视化 ## 实验室管理 @@ -245,78 +244,3 @@ unilab --ak your_ak --sk your_sk --port 8080 --disable_browser - 检查图谱文件格式是否正确 - 验证设备连接和端点配置 - 确保注册表路径正确 - -## 页面操作 - -### 1. 启动成功 -当您启动成功后,可以看到物料列表,节点模版和组态图如图展示 -![material.png](image/material.png) - -### 2. 根据需求创建设备和物料 -我们可以做一个简单的案例 -* 在容器1中加入水 -* 通过传输泵将容器1中的水转移到容器2中 -#### 2.1 添加所需的设备和物料 -仪器设备work_station中的workstation 数量x1 -仪器设备virtual_device中的virtual_transfer_pump 数量x1 -物料耗材container中的container 数量x2 - -#### 2.2 将设备和物料根据父子关系进行关联 -当我们添加设备时,仪器耗材模块的物料列表也会实时更新 -我们需要将设备和物料拖拽到workstation中并在画布上将它们连接起来,就像真实的设备操作一样 -![links.png](image/links.png) - -### 3. 创建工作流 -进入工作流模块 → 点击"我创建的" → 新建工作流 -![new.png](image/new.png) - -#### 3.1 新增工作流节点 -我们可以进入指定工作流,在空白处右键 -* 选择Laboratory→host_node中的creat_resource -* 选择Laboratory→workstation中的PumpTransferProtocol - -![creatworkfollow.gif](image/creatworkfollow.gif) - -#### 3.2 配置节点参数 -根据案例,工作流包含两个步骤: -1. 使用creat_resource在容器中创建水 -2. 通过泵传输协议将水传输到另一个容器 - -我们点击creat_resource卡片上的编辑按钮来配置参数⭐️ -class_name :container -device_id : workstation -liquid_input_slot : 0或-1均可 -liquid_type : water -liquid_volume : 根据需求填写即可,默认单位ml,这里举例50 -parent : workstation -res_id : containe -关联设备名称(原unilabos_device_id) : 这里就填写host_node -**配置完成后点击底部保存按钮** - -我们点击PumpTransferProtocol卡片上的编辑按钮来配置参数⭐️ -event : transfer_liquid -from_vessel : water -to_vessel : container1 -volume : 根据需求填写即可,默认单位ml,这里举例50 -关联设备名称(原unilabos_device_id) : 这里就填写workstation -**配置完成后点击底部保存按钮** - -#### 3.3 运行工作流 -1. 连接两个节点卡片 -2. 点击底部保存按钮 -3. 点击运行按钮执行工作流 - -![linksandrun.png](image/linksandrun.png) - -### 运行监控 -* 运行状态和消息实时显示在底部控制台 -* 如有报错,可点击查看详细信息 - -### 结果验证 -工作流完成后,返回仪器耗材模块: -* 点击 container1卡片查看详情 -* 确认其中包含参数指定的水和容量 - - - - diff --git a/docs/user_guide/quick_install_guide.md b/docs/user_guide/quick_install_guide.md deleted file mode 100644 index eefa3ae9..00000000 --- a/docs/user_guide/quick_install_guide.md +++ /dev/null @@ -1,197 +0,0 @@ -# Uni-Lab-OS 一键安装快速指南 - -## 概述 - -本指南提供最快速的 Uni-Lab-OS 安装方法,使用预打包的 conda 环境,无需手动配置依赖。 - -## 前置要求 - -- 已安装 Conda/Miniconda/Miniforge/Mamba -- 至少 10GB 可用磁盘空间 -- Windows 10+, macOS 10.14+, 或 Linux (Ubuntu 20.04+) - -## 安装步骤 - -### 第一步:下载预打包环境 - -1. 访问 [GitHub Actions - Conda Pack Build](https://github.com/dptech-corp/Uni-Lab-OS/actions/workflows/conda-pack-build.yml) - -2. 选择最新的成功构建记录(绿色勾号 ✓) - -3. 在页面底部的 "Artifacts" 部分,下载对应你操作系统的压缩包: - - Windows: `unilab-pack-win-64-{branch}.zip` - - macOS (Intel): `unilab-pack-osx-64-{branch}.tar.gz` - - macOS (Apple Silicon): `unilab-pack-osx-arm64-{branch}.tar.gz` - - Linux: `unilab-pack-linux-64-{branch}.tar.gz` - -### 第二步:解压并运行安装脚本 - -#### Windows - -```batch -REM 使用 Windows 资源管理器解压下载的 zip 文件 -REM 或使用命令行: -tar -xzf unilab-pack-win-64-dev.zip - -REM 进入解压后的目录 -cd unilab-pack-win-64-dev - -REM 双击运行 install_unilab.bat -REM 或在命令行中执行: -install_unilab.bat -``` - -#### macOS - -```bash -# 解压下载的压缩包 -tar -xzf unilab-pack-osx-arm64-dev.tar.gz - -# 进入解压后的目录 -cd unilab-pack-osx-arm64-dev - -# 运行安装脚本 -bash install_unilab.sh -``` - -#### Linux - -```bash -# 解压下载的压缩包 -tar -xzf unilab-pack-linux-64-dev.tar.gz - -# 进入解压后的目录 -cd unilab-pack-linux-64-dev - -# 添加执行权限(如果需要) -chmod +x install_unilab.sh - -# 运行安装脚本 -./install_unilab.sh -``` - -### 第三步:激活环境 - -```bash -conda activate unilab -``` - -### 第四步:验证安装(推荐) - -```bash -# 确保已激活环境 -conda activate unilab - -# 运行验证脚本 -python verify_installation.py -``` - -如果看到 "✓ All checks passed!",说明安装成功! - -## 常见问题 - -### Q: 安装脚本找不到 conda? - -**A:** 确保你已经安装了 conda/miniconda/miniforge,并且安装在标准位置: - -- **Windows**: - - - `%USERPROFILE%\miniforge3` - - `%USERPROFILE%\miniconda3` - - `%USERPROFILE%\anaconda3` - - `C:\ProgramData\miniforge3` - -- **macOS/Linux**: - - `~/miniforge3` - - `~/miniconda3` - - `~/anaconda3` - - `/opt/conda` - -如果安装在其他位置,可以先激活 conda base 环境,然后手动运行安装脚本。 - -### Q: 安装后激活环境提示找不到? - -**A:** 尝试以下方法: - -```bash -# 方法 1: 使用 conda activate -conda activate unilab - -# 方法 2: 使用完整路径激活(Windows) -call C:\Users\{YourUsername}\miniforge3\envs\unilab\Scripts\activate.bat - -# 方法 2: 使用完整路径激活(Unix) -source ~/miniforge3/envs/unilab/bin/activate -``` - -### Q: conda-unpack 失败怎么办? - -**A:** 尝试手动运行: - -```bash -# Windows -cd %CONDA_PREFIX%\envs\unilab -.\Scripts\conda-unpack.exe - -# macOS/Linux -cd $CONDA_PREFIX/envs/unilab -./bin/conda-unpack -``` - -### Q: 验证脚本报错? - -**A:** 首先确认环境已激活: - -```bash -# 检查当前环境 -conda env list - -# 应该看到 unilab 前面有 * 标记 -``` - -如果仍有问题,查看具体报错信息,可能需要: - -- 重新运行安装脚本 -- 检查磁盘空间 -- 查看详细文档 - -### Q: 环境很大,有办法减小吗? - -**A:** 预打包的环境包含所有依赖,通常较大(压缩后 2-5GB)。这是为了确保离线安装和完整功能。如果空间有限,考虑使用手动安装方式,只安装需要的组件。 - -### Q: 如何更新到最新版本? - -**A:** 重新下载最新的预打包环境,运行安装脚本时选择覆盖现有环境。 - -或者在现有环境中更新: - -```bash -conda activate unilab - -# 更新 unilabos -cd /path/to/Uni-Lab-OS -git pull -pip install -e . --upgrade - -# 更新 ros-humble-unilabos-msgs -mamba update ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge -``` - -## 下一步 - -安装完成后,你可以: - -1. **查看启动指南**: {doc}`launch` -2. **运行示例**: {doc}`../boot_examples/index` -3. **配置设备**: 编辑 `unilabos_data/startup_config.json` -4. **阅读开发文档**: {doc}`../developer_guide/workstation_architecture` - -## 需要帮助? - -- **文档**: [docs/user_guide/installation.md](installation.md) -- **问题反馈**: [GitHub Issues](https://github.com/dptech-corp/Uni-Lab-OS/issues) -- **开发版安装**: 参考 {doc}`installation` 的方式二 - ---- - -**提示**: 这个预打包环境包含了从指定分支(通常是 `dev`)构建的最新代码。如果需要稳定版本,请使用方式二手动安装 release 版本。 diff --git a/new_cellconfig3c.json b/new_cellconfig3c.json index adeebeea..2ee2dacc 100644 --- a/new_cellconfig3c.json +++ b/new_cellconfig3c.json @@ -1,137 +1,101 @@ { - "nodes": [ - { - "id": "bioyond_cell_workstation", - "name": "配液分液工站", - "parent": null, - "children": [ - "YB_Bioyond_Deck" - ], - "type": "device", - "class": "bioyond_cell", - "config": { - "deck": { - "data": { - "_resource_child_name": "YB_Bioyond_Deck", - "_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_YB_Deck" - } + "nodes": [ + { + "id": "bioyond_cell_workstation", + "name": "配液分液工站", + "parent": null, + "children": [ + "YB_Bioyond_Deck" + ], + "type": "device", + "class": "bioyond_cell", + "config": { + "deck": { + "data": { + "_resource_child_name": "YB_Bioyond_Deck", + "_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_YB_Deck" + } + }, + "protocol_type": [] }, - "protocol_type": [] + "data": {} }, - "data": {} - }, - { - "id": "YB_Bioyond_Deck", - "name": "YB_Bioyond_Deck", - "children": [], - "parent": "bioyond_cell_workstation", - "type": "deck", - "class": "BIOYOND_YB_Deck", - "position": { - "x": 0, - "y": 0, - "z": 0 - }, - "config": { - "type": "BIOYOND_YB_Deck", - "setup": true, - "rotation": { + { + "id": "YB_Bioyond_Deck", + "name": "YB_Bioyond_Deck", + "children": [], + "parent": "bioyond_cell_workstation", + "type": "deck", + "class": "BIOYOND_YB_Deck", + "position": { "x": 0, "y": 0, - "z": 0, - "type": "Rotation" - } - }, - "data": {} - }, - { - "id": "BatteryStation", - "name": "扣电工作站", - "parent": null, - "children": [ - "coin_cell_deck" - ], - "type": "device", - "class":"coincellassemblyworkstation_device", - "config": { - "deck": { - "data": { - "_resource_child_name": "YB_YH_Deck", - "_resource_type": "unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials:CoincellDeck" + "z": 0 + }, + "config": { + "type": "BIOYOND_YB_Deck", + "setup": true, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" } }, - "protocol_type": [] + "data": {} }, - "position": { - "size": {"height": 1450, "width": 1450, "depth": 2100}, - "position": { - "x": -1500, - "y": 0, - "z": 0 - } - } - }, - { - "id": "YB_YH_Deck", - "name": "YB_YH_Deck", - "children": [], - "parent": "BatteryStation", - "type": "deck", - "class": "CoincellDeck", - "config": { - "type": "CoincellDeck", - "setup": true, - "rotation": { - "x": 0, - "y": 0, - "z": 0, - "type": "Rotation" - } - }, - "data": {} - }, - { - "id": "NewareTester", - "name": "新威电池测试系统", - "parent": null, - "children": [], - "type": "device", - "class": "neware_battery_test_system", - "config": { - "ip": "127.0.0.1", - "port": 502, - "machine_id": 1, - "devtype": "27", - "timeout": 20, - "size_x": 500.0, - "size_y": 500.0, - "size_z": 2000.0 - }, - "position": { - "size": { - "height": 1600, - "width": 1200, - "depth": 800 + { + "id": "BatteryStation", + "name": "扣电工作站", + "parent": null, + "children": [ + "coin_cell_deck" + ], + "type": "device", + "class":"coincellassemblyworkstation_device", + "config": { + "deck": { + "data": { + "_resource_child_name": "YB_YH_Deck", + "_resource_type": "unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials:CoincellDeck" + } + }, + "protocol_type": [] }, "position": { - "x": 1500, - "y": 0, - "z": 0 + "size": {"height": 1450, "width": 1450, "depth": 2100}, + "position": { + "x": -1500, + "y": 0, + "z": 0 + } } }, - "data": { - "功能说明": "新威电池测试系统,提供720通道监控和CSV批量提交功能", - "监控功能": "支持720个通道的实时状态监控、2盘电池物料管理、状态导出等", - "提交功能": "通过submit_from_csv action从CSV文件批量提交测试任务" + { + "id": "YB_YH_Deck", + "name": "YB_YH_Deck", + "children": [], + "parent": "BatteryStation", + "type": "deck", + "class": "CoincellDeck", + "config": { + "type": "CoincellDeck", + "setup": true, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + } + }, + "data": {} } - } - ], - "links": [] -} - - - - - - \ No newline at end of file + ] + } + + + + + + \ No newline at end of file diff --git a/recipes/msgs/recipe.yaml b/recipes/msgs/recipe.yaml index 1ce1efe1..86150f0d 100644 --- a/recipes/msgs/recipe.yaml +++ b/recipes/msgs/recipe.yaml @@ -1,6 +1,6 @@ package: name: ros-humble-unilabos-msgs - version: 0.10.7 + version: 0.10.12 source: path: ../../unilabos_msgs target_directory: src 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..e476d1b8 --- /dev/null +++ b/recipes/ros-humble-unilabos-msgs/recipe.yaml @@ -0,0 +1,61 @@ +package: + name: ros-humble-unilabos-msgs + version: 0.9.7 +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.5.* + 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/recipe.yaml b/recipes/unilabos/recipe.yaml index 7ac0781a..0f79b260 100644 --- a/recipes/unilabos/recipe.yaml +++ b/recipes/unilabos/recipe.yaml @@ -1,6 +1,6 @@ package: name: unilabos - version: "0.10.7" + version: "0.10.12" source: path: ../.. diff --git a/scripts/workflow.py b/scripts/workflow.py index be7bbd1e..8bd89640 100644 --- a/scripts/workflow.py +++ b/scripts/workflow.py @@ -2,7 +2,6 @@ import json import logging import traceback import uuid -import xml.etree.ElementTree as ET from typing import Any, Dict, List import networkx as nx @@ -25,7 +24,15 @@ class SimpleGraph: def add_edge(self, source, target, **attrs): """添加边""" - edge = {"source": source, "target": target, **attrs} + # edge = {"source": source, "target": target, **attrs} + edge = { + "source": source, "target": target, + "source_node_uuid": source, + "target_node_uuid": target, + "source_handle_io": "source", + "target_handle_io": "target", + **attrs + } self.edges.append(edge) def to_dict(self): @@ -42,6 +49,7 @@ class SimpleGraph: "multigraph": False, "graph": {}, "nodes": nodes_list, + "edges": self.edges, "links": self.edges, } @@ -58,495 +66,8 @@ def extract_json_from_markdown(text: str) -> str: return text -def convert_to_type(val: str) -> Any: - """将字符串值转换为适当的数据类型""" - if val == "True": - return True - if val == "False": - return False - if val == "?": - return None - if val.endswith(" g"): - return float(val.split(" ")[0]) - if val.endswith("mg"): - return float(val.split("mg")[0]) - elif val.endswith("mmol"): - return float(val.split("mmol")[0]) / 1000 - elif val.endswith("mol"): - return float(val.split("mol")[0]) - elif val.endswith("ml"): - return float(val.split("ml")[0]) - elif val.endswith("RPM"): - return float(val.split("RPM")[0]) - elif val.endswith(" °C"): - return float(val.split(" ")[0]) - elif val.endswith(" %"): - return float(val.split(" ")[0]) - return val -def refactor_data(data: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """统一的数据重构函数,根据操作类型自动选择模板""" - refactored_data = [] - - # 定义操作映射,包含生物实验和有机化学的所有操作 - OPERATION_MAPPING = { - # 生物实验操作 - "transfer_liquid": "SynBioFactory-liquid_handler.prcxi-transfer_liquid", - "transfer": "SynBioFactory-liquid_handler.biomek-transfer", - "incubation": "SynBioFactory-liquid_handler.biomek-incubation", - "move_labware": "SynBioFactory-liquid_handler.biomek-move_labware", - "oscillation": "SynBioFactory-liquid_handler.biomek-oscillation", - # 有机化学操作 - "HeatChillToTemp": "SynBioFactory-workstation-HeatChillProtocol", - "StopHeatChill": "SynBioFactory-workstation-HeatChillStopProtocol", - "StartHeatChill": "SynBioFactory-workstation-HeatChillStartProtocol", - "HeatChill": "SynBioFactory-workstation-HeatChillProtocol", - "Dissolve": "SynBioFactory-workstation-DissolveProtocol", - "Transfer": "SynBioFactory-workstation-TransferProtocol", - "Evaporate": "SynBioFactory-workstation-EvaporateProtocol", - "Recrystallize": "SynBioFactory-workstation-RecrystallizeProtocol", - "Filter": "SynBioFactory-workstation-FilterProtocol", - "Dry": "SynBioFactory-workstation-DryProtocol", - "Add": "SynBioFactory-workstation-AddProtocol", - } - - UNSUPPORTED_OPERATIONS = ["Purge", "Wait", "Stir", "ResetHandling"] - - for step in data: - operation = step.get("action") - if not operation or operation in UNSUPPORTED_OPERATIONS: - continue - - # 处理重复操作 - if operation == "Repeat": - times = step.get("times", step.get("parameters", {}).get("times", 1)) - sub_steps = step.get("steps", step.get("parameters", {}).get("steps", [])) - for i in range(int(times)): - sub_data = refactor_data(sub_steps) - refactored_data.extend(sub_data) - continue - - # 获取模板名称 - template = OPERATION_MAPPING.get(operation) - if not template: - # 自动推断模板类型 - if operation.lower() in ["transfer", "incubation", "move_labware", "oscillation"]: - template = f"SynBioFactory-liquid_handler.biomek-{operation}" - else: - template = f"SynBioFactory-workstation-{operation}Protocol" - - # 创建步骤数据 - step_data = { - "template": template, - "description": step.get("description", step.get("purpose", f"{operation} operation")), - "lab_node_type": "Device", - "parameters": step.get("parameters", step.get("action_args", {})), - } - refactored_data.append(step_data) - - return refactored_data - - -def build_protocol_graph( - labware_info: List[Dict[str, Any]], protocol_steps: List[Dict[str, Any]], workstation_name: str -) -> SimpleGraph: - """统一的协议图构建函数,根据设备类型自动选择构建逻辑""" - G = SimpleGraph() - resource_last_writer = {} - LAB_NAME = "SynBioFactory" - - protocol_steps = refactor_data(protocol_steps) - - # 检查协议步骤中的模板来判断协议类型 - has_biomek_template = any( - ("biomek" in step.get("template", "")) or ("prcxi" in step.get("template", "")) - for step in protocol_steps - ) - - if has_biomek_template: - # 生物实验协议图构建 - for labware_id, labware in labware_info.items(): - node_id = str(uuid.uuid4()) - - labware_attrs = labware.copy() - labware_id = labware_attrs.pop("id", labware_attrs.get("name", f"labware_{uuid.uuid4()}")) - labware_attrs["description"] = labware_id - labware_attrs["lab_node_type"] = ( - "Reagent" if "Plate" in str(labware_id) else "Labware" if "Rack" in str(labware_id) else "Sample" - ) - labware_attrs["device_id"] = workstation_name - - G.add_node(node_id, template=f"{LAB_NAME}-host_node-create_resource", **labware_attrs) - resource_last_writer[labware_id] = f"{node_id}:labware" - - # 处理协议步骤 - prev_node = None - for i, step in enumerate(protocol_steps): - node_id = str(uuid.uuid4()) - G.add_node(node_id, **step) - - # 添加控制流边 - if prev_node is not None: - G.add_edge(prev_node, node_id, source_port="ready", target_port="ready") - prev_node = node_id - - # 处理物料流 - params = step.get("parameters", {}) - if "sources" in params and params["sources"] in resource_last_writer: - source_node, source_port = resource_last_writer[params["sources"]].split(":") - G.add_edge(source_node, node_id, source_port=source_port, target_port="labware") - - if "targets" in params: - resource_last_writer[params["targets"]] = f"{node_id}:labware" - - # 添加协议结束节点 - end_id = str(uuid.uuid4()) - G.add_node(end_id, template=f"{LAB_NAME}-liquid_handler.biomek-run_protocol") - if prev_node is not None: - G.add_edge(prev_node, end_id, source_port="ready", target_port="ready") - - else: - # 有机化学协议图构建 - WORKSTATION_ID = workstation_name - - # 为所有labware创建资源节点 - for item_id, item in labware_info.items(): - # item_id = item.get("id") or item.get("name", f"item_{uuid.uuid4()}") - node_id = str(uuid.uuid4()) - - # 判断节点类型 - if item.get("type") == "hardware" or "reactor" in str(item_id).lower(): - if "reactor" not in str(item_id).lower(): - continue - lab_node_type = "Sample" - description = f"Prepare Reactor: {item_id}" - liquid_type = [] - liquid_volume = [] - else: - lab_node_type = "Reagent" - description = f"Add Reagent to Flask: {item_id}" - liquid_type = [item_id] - liquid_volume = [1e5] - - G.add_node( - node_id, - template=f"{LAB_NAME}-host_node-create_resource", - description=description, - lab_node_type=lab_node_type, - res_id=item_id, - device_id=WORKSTATION_ID, - class_name="container", - parent=WORKSTATION_ID, - bind_locations={"x": 0.0, "y": 0.0, "z": 0.0}, - liquid_input_slot=[-1], - liquid_type=liquid_type, - liquid_volume=liquid_volume, - slot_on_deck="", - role=item.get("role", ""), - ) - resource_last_writer[item_id] = f"{node_id}:labware" - - last_control_node_id = None - - # 处理协议步骤 - for step in protocol_steps: - node_id = str(uuid.uuid4()) - G.add_node(node_id, **step) - - # 控制流 - if last_control_node_id is not None: - G.add_edge(last_control_node_id, node_id, source_port="ready", target_port="ready") - last_control_node_id = node_id - - # 物料流 - params = step.get("parameters", {}) - input_resources = { - "Vessel": params.get("vessel"), - "ToVessel": params.get("to_vessel"), - "FromVessel": params.get("from_vessel"), - "reagent": params.get("reagent"), - "solvent": params.get("solvent"), - "compound": params.get("compound"), - "sources": params.get("sources"), - "targets": params.get("targets"), - } - - for target_port, resource_name in input_resources.items(): - if resource_name and resource_name in resource_last_writer: - source_node, source_port = resource_last_writer[resource_name].split(":") - G.add_edge(source_node, node_id, source_port=source_port, target_port=target_port) - - output_resources = { - "VesselOut": params.get("vessel"), - "FromVesselOut": params.get("from_vessel"), - "ToVesselOut": params.get("to_vessel"), - "FiltrateOut": params.get("filtrate_vessel"), - "reagent": params.get("reagent"), - "solvent": params.get("solvent"), - "compound": params.get("compound"), - "sources_out": params.get("sources"), - "targets_out": params.get("targets"), - } - - for source_port, resource_name in output_resources.items(): - if resource_name: - resource_last_writer[resource_name] = f"{node_id}:{source_port}" - - return G - - -def draw_protocol_graph(protocol_graph: SimpleGraph, output_path: str): - """ - (辅助功能) 使用 networkx 和 matplotlib 绘制协议工作流图,用于可视化。 - """ - if not protocol_graph: - print("Cannot draw graph: Graph object is empty.") - return - - G = nx.DiGraph() - - for node_id, attrs in protocol_graph.nodes.items(): - label = attrs.get("description", attrs.get("template", node_id[:8])) - G.add_node(node_id, label=label, **attrs) - - for edge in protocol_graph.edges: - G.add_edge(edge["source"], edge["target"]) - - plt.figure(figsize=(20, 15)) - try: - pos = nx.nx_agraph.graphviz_layout(G, prog="dot") - except Exception: - pos = nx.shell_layout(G) # Fallback layout - - node_labels = {node: data["label"] for node, data in G.nodes(data=True)} - nx.draw( - G, - pos, - with_labels=False, - node_size=2500, - node_color="skyblue", - node_shape="o", - edge_color="gray", - width=1.5, - arrowsize=15, - ) - nx.draw_networkx_labels(G, pos, labels=node_labels, font_size=8, font_weight="bold") - - plt.title("Chemical Protocol Workflow Graph", size=15) - plt.savefig(output_path, dpi=300, bbox_inches="tight") - plt.close() - print(f" - Visualization saved to '{output_path}'") - - -from networkx.drawing.nx_agraph import to_agraph -import re - -COMPASS = {"n","e","s","w","ne","nw","se","sw","c"} - -def _is_compass(port: str) -> bool: - return isinstance(port, str) and port.lower() in COMPASS - -def draw_protocol_graph_with_ports(protocol_graph, output_path: str, rankdir: str = "LR"): - """ - 使用 Graphviz 端口语法绘制协议工作流图。 - - 若边上的 source_port/target_port 是 compass(n/e/s/w/...),直接用 compass。 - - 否则自动为节点创建 record 形状并定义命名端口 。 - 最终由 PyGraphviz 渲染并输出到 output_path(后缀决定格式,如 .png/.svg/.pdf)。 - """ - if not protocol_graph: - print("Cannot draw graph: Graph object is empty.") - return - - # 1) 先用 networkx 搭建有向图,保留端口属性 - G = nx.DiGraph() - for node_id, attrs in protocol_graph.nodes.items(): - label = attrs.get("description", attrs.get("template", node_id[:8])) - # 保留一个干净的“中心标签”,用于放在 record 的中间槽 - G.add_node(node_id, _core_label=str(label), **{k:v for k,v in attrs.items() if k not in ("label",)}) - - edges_data = [] - in_ports_by_node = {} # 收集命名输入端口 - out_ports_by_node = {} # 收集命名输出端口 - - for edge in protocol_graph.edges: - u = edge["source"] - v = edge["target"] - sp = edge.get("source_port") - tp = edge.get("target_port") - - # 记录到图里(保留原始端口信息) - G.add_edge(u, v, source_port=sp, target_port=tp) - edges_data.append((u, v, sp, tp)) - - # 如果不是 compass,就按“命名端口”先归类,等会儿给节点造 record - if sp and not _is_compass(sp): - out_ports_by_node.setdefault(u, set()).add(str(sp)) - if tp and not _is_compass(tp): - in_ports_by_node.setdefault(v, set()).add(str(tp)) - - # 2) 转为 AGraph,使用 Graphviz 渲染 - A = to_agraph(G) - A.graph_attr.update(rankdir=rankdir, splines="true", concentrate="false", fontsize="10") - A.node_attr.update(shape="box", style="rounded,filled", fillcolor="lightyellow", color="#999999", fontname="Helvetica") - A.edge_attr.update(arrowsize="0.8", color="#666666") - - # 3) 为需要命名端口的节点设置 record 形状与 label - # 左列 = 输入端口;中间 = 核心标签;右列 = 输出端口 - for n in A.nodes(): - node = A.get_node(n) - core = G.nodes[n].get("_core_label", n) - - in_ports = sorted(in_ports_by_node.get(n, [])) - out_ports = sorted(out_ports_by_node.get(n, [])) - - # 如果该节点涉及命名端口,则用 record;否则保留原 box - if in_ports or out_ports: - def port_fields(ports): - if not ports: - return " " # 必须留一个空槽占位 - # 每个端口一个小格子,

name - return "|".join(f"<{re.sub(r'[^A-Za-z0-9_:.|-]', '_', p)}> {p}" for p in ports) - - left = port_fields(in_ports) - right = port_fields(out_ports) - - # 三栏:左(入) | 中(节点名) | 右(出) - record_label = f"{{ {left} | {core} | {right} }}" - node.attr.update(shape="record", label=record_label) - else: - # 没有命名端口:普通盒子,显示核心标签 - node.attr.update(label=str(core)) - - # 4) 给边设置 headport / tailport - # - 若端口为 compass:直接用 compass(e.g., headport="e") - # - 若端口为命名端口:使用在 record 中定义的 名(同名即可) - for (u, v, sp, tp) in edges_data: - e = A.get_edge(u, v) - - # Graphviz 属性:tail 是源,head 是目标 - if sp: - if _is_compass(sp): - e.attr["tailport"] = sp.lower() - else: - # 与 record label 中 名一致;特殊字符已在 label 中做了清洗 - e.attr["tailport"] = re.sub(r'[^A-Za-z0-9_:.|-]', '_', str(sp)) - - if tp: - if _is_compass(tp): - e.attr["headport"] = tp.lower() - else: - e.attr["headport"] = re.sub(r'[^A-Za-z0-9_:.|-]', '_', str(tp)) - - # 可选:若想让边更贴边缘,可设置 constraint/spline 等 - # e.attr["arrowhead"] = "vee" - - # 5) 输出 - A.draw(output_path, prog="dot") - print(f" - Port-aware workflow rendered to '{output_path}'") - - -def flatten_xdl_procedure(procedure_elem: ET.Element) -> List[ET.Element]: - """展平嵌套的XDL程序结构""" - flattened_operations = [] - TEMP_UNSUPPORTED_PROTOCOL = ["Purge", "Wait", "Stir", "ResetHandling"] - - def extract_operations(element: ET.Element): - if element.tag not in ["Prep", "Reaction", "Workup", "Purification", "Procedure"]: - if element.tag not in TEMP_UNSUPPORTED_PROTOCOL: - flattened_operations.append(element) - - for child in element: - extract_operations(child) - - for child in procedure_elem: - extract_operations(child) - - return flattened_operations - - -def parse_xdl_content(xdl_content: str) -> tuple: - """解析XDL内容""" - try: - xdl_content_cleaned = "".join(c for c in xdl_content if c.isprintable()) - root = ET.fromstring(xdl_content_cleaned) - - synthesis_elem = root.find("Synthesis") - if synthesis_elem is None: - return None, None, None - - # 解析硬件组件 - hardware_elem = synthesis_elem.find("Hardware") - hardware = [] - if hardware_elem is not None: - hardware = [{"id": c.get("id"), "type": c.get("type")} for c in hardware_elem.findall("Component")] - - # 解析试剂 - reagents_elem = synthesis_elem.find("Reagents") - reagents = [] - if reagents_elem is not None: - reagents = [{"name": r.get("name"), "role": r.get("role", "")} for r in reagents_elem.findall("Reagent")] - - # 解析程序 - procedure_elem = synthesis_elem.find("Procedure") - if procedure_elem is None: - return None, None, None - - flattened_operations = flatten_xdl_procedure(procedure_elem) - return hardware, reagents, flattened_operations - - except ET.ParseError as e: - raise ValueError(f"Invalid XDL format: {e}") - - -def convert_xdl_to_dict(xdl_content: str) -> Dict[str, Any]: - """ - 将XDL XML格式转换为标准的字典格式 - - Args: - xdl_content: XDL XML内容 - - Returns: - 转换结果,包含步骤和器材信息 - """ - try: - hardware, reagents, flattened_operations = parse_xdl_content(xdl_content) - if hardware is None: - return {"error": "Failed to parse XDL content", "success": False} - - # 将XDL元素转换为字典格式 - steps_data = [] - for elem in flattened_operations: - # 转换参数类型 - parameters = {} - for key, val in elem.attrib.items(): - converted_val = convert_to_type(val) - if converted_val is not None: - parameters[key] = converted_val - - step_dict = { - "operation": elem.tag, - "parameters": parameters, - "description": elem.get("purpose", f"Operation: {elem.tag}"), - } - steps_data.append(step_dict) - - # 合并硬件和试剂为统一的labware_info格式 - labware_data = [] - labware_data.extend({"id": hw["id"], "type": "hardware", **hw} for hw in hardware) - labware_data.extend({"name": reagent["name"], "type": "reagent", **reagent} for reagent in reagents) - - return { - "success": True, - "steps": steps_data, - "labware": labware_data, - "message": f"Successfully converted XDL to dict format. Found {len(steps_data)} steps and {len(labware_data)} labware items.", - } - - except Exception as e: - error_msg = f"XDL conversion failed: {str(e)}" - logger.error(error_msg) - return {"error": error_msg, "success": False} def create_workflow( diff --git a/setup.py b/setup.py index 508ff337..4f733d06 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ package_name = 'unilabos' setup( name=package_name, - version='0.10.7', + version='0.10.12', packages=find_packages(), include_package_data=True, install_requires=['setuptools'], diff --git a/test/experiments/reaction_station_bioyond.json b/test/experiments/reaction_station_bioyond.json deleted file mode 100644 index ca8bd683..00000000 --- a/test/experiments/reaction_station_bioyond.json +++ /dev/null @@ -1,72 +0,0 @@ -{ - "nodes": [ - { - "id": "reaction_station_bioyond", - "name": "reaction_station_bioyond", - "parent": null, - "children": [ - "Bioyond_Deck" - ], - "type": "device", - "class": "reaction_station.bioyond", - "config": { - "config": { - "api_key": "DE9BDDA0", - "api_host": "http://192.168.1.200:44402", - "workflow_mappings": { - "reactor_taken_out": "3a16081e-4788-ca37-eff4-ceed8d7019d1", - "reactor_taken_in": "3a160df6-76b3-0957-9eb0-cb496d5721c6", - "Solid_feeding_vials": "3a160877-87e7-7699-7bc6-ec72b05eb5e6", - "Liquid_feeding_vials(non-titration)": "3a167d99-6158-c6f0-15b5-eb030f7d8e47", - "Liquid_feeding_solvents": "3a160824-0665-01ed-285a-51ef817a9046", - "Liquid_feeding(titration)": "3a16082a-96ac-0449-446a-4ed39f3365b6", - "liquid_feeding_beaker": "3a16087e-124f-8ddb-8ec1-c2dff09ca784", - "Drip_back": "3a162cf9-6aac-565a-ddd7-682ba1796a4a" - }, - "material_type_mappings": { - "烧杯": ["YB_1FlaskCarrier", "3a14196b-24f2-ca49-9081-0cab8021bf1a"], - "试剂瓶": ["YB_1BottleCarrier", ""], - "样品板": ["YB_6StockCarrier", "3a14196e-b7a0-a5da-1931-35f3000281e9"], - "分装板": ["YB_6VialCarrier", "3a14196e-5dfe-6e21-0c79-fe2036d052c4"], - "样品瓶": ["YB_Solid_Stock", "3a14196a-cf7d-8aea-48d8-b9662c7dba94"], - "90%分装小瓶": ["YB_Solid_Vial", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea"], - "10%分装小瓶": ["YB_Liquid_Vial", "3a14196c-76be-2279-4e22-7310d69aed68"] - } - }, - "deck": { - "data": { - "_resource_child_name": "Bioyond_Deck", - "_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerReactionStation_Deck" - } - }, - "protocol_type": [] - }, - "data": {} - }, - { - "id": "Bioyond_Deck", - "name": "Bioyond_Deck", - "children": [ - ], - "parent": "reaction_station_bioyond", - "type": "deck", - "class": "BIOYOND_PolymerReactionStation_Deck", - "position": { - "x": 0, - "y": 0, - "z": 0 - }, - "config": { - "type": "BIOYOND_PolymerReactionStation_Deck", - "setup": true, - "rotation": { - "x": 0, - "y": 0, - "z": 0, - "type": "Rotation" - } - }, - "data": {} - } - ] -} diff --git a/test/resources/__init__.py b/test/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/ros/__init__.py b/test/ros/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/workflow/__init__.py b/test/workflow/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/workflow/merge_workflow.py b/test/workflow/merge_workflow.py index fb409769..2801a747 100644 --- a/test/workflow/merge_workflow.py +++ b/test/workflow/merge_workflow.py @@ -1,4 +1,3 @@ -import json import sys from datetime import datetime from pathlib import Path @@ -9,86 +8,28 @@ if str(ROOT_DIR) not in sys.path: import pytest -from scripts.workflow import build_protocol_graph, draw_protocol_graph, draw_protocol_graph_with_ports +from unilabos.workflow.convert_from_json import ( + convert_from_json, + normalize_steps as _normalize_steps, + normalize_labware as _normalize_labware, +) +from unilabos.workflow.common import draw_protocol_graph_with_ports -ROOT_DIR = Path(__file__).resolve().parents[2] -if str(ROOT_DIR) not in sys.path: - sys.path.insert(0, str(ROOT_DIR)) - - -def _normalize_steps(data): - normalized = [] - for step in data: - action = step.get("action") or step.get("operation") - if not action: - continue - raw_params = step.get("parameters") or step.get("action_args") or {} - params = dict(raw_params) - - if "source" in raw_params and "sources" not in raw_params: - params["sources"] = raw_params["source"] - if "target" in raw_params and "targets" not in raw_params: - params["targets"] = raw_params["target"] - - description = step.get("description") or step.get("purpose") - step_dict = {"action": action, "parameters": params} - if description: - step_dict["description"] = description - normalized.append(step_dict) - return normalized - - -def _normalize_labware(data): - labware = {} - for item in data: - reagent_name = item.get("reagent_name") - key = reagent_name or item.get("material_name") or item.get("name") - if not key: - continue - key = str(key) - idx = 1 - original_key = key - while key in labware: - idx += 1 - key = f"{original_key}_{idx}" - - labware[key] = { - "slot": item.get("positions") or item.get("slot"), - "labware": item.get("material_name") or item.get("labware"), - "well": item.get("well", []), - "type": item.get("type", "reagent"), - "role": item.get("role", ""), - "name": key, - } - return labware - - -@pytest.mark.parametrize("protocol_name", [ - "example_bio", - # "bioyond_materials_liquidhandling_1", - "example_prcxi", -]) +@pytest.mark.parametrize( + "protocol_name", + [ + "example_bio", + # "bioyond_materials_liquidhandling_1", + "example_prcxi", + ], +) def test_build_protocol_graph(protocol_name): data_path = Path(__file__).with_name(f"{protocol_name}.json") - with data_path.open("r", encoding="utf-8") as fp: - d = json.load(fp) - if "workflow" in d and "reagent" in d: - protocol_steps = d["workflow"] - labware_info = d["reagent"] - elif "steps_info" in d and "labware_info" in d: - protocol_steps = _normalize_steps(d["steps_info"]) - labware_info = _normalize_labware(d["labware_info"]) - else: - raise ValueError("Unsupported protocol format") + graph = convert_from_json(data_path, workstation_name="PRCXi") - graph = build_protocol_graph( - labware_info=labware_info, - protocol_steps=protocol_steps, - workstation_name="PRCXi", - ) timestamp = datetime.now().strftime("%Y%m%d_%H%M") output_path = data_path.with_name(f"{protocol_name}_graph_{timestamp}.png") draw_protocol_graph_with_ports(graph, str(output_path)) - print(graph) \ No newline at end of file + print(graph) diff --git a/unilabos/__init__.py b/unilabos/__init__.py index 5b0f7f2f..a37fec72 100644 --- a/unilabos/__init__.py +++ b/unilabos/__init__.py @@ -1 +1 @@ -__version__ = "0.10.7" +__version__ = "0.10.12" diff --git a/unilabos/app/communication.py b/unilabos/app/communication.py index 436fa98a..700065dc 100644 --- a/unilabos/app/communication.py +++ b/unilabos/app/communication.py @@ -141,7 +141,7 @@ class CommunicationClientFactory: """ if cls._client_cache is None: cls._client_cache = cls.create_client(protocol) - logger.info(f"[CommunicationFactory] Created {type(cls._client_cache).__name__} client") + logger.trace(f"[CommunicationFactory] Created {type(cls._client_cache).__name__} client") return cls._client_cache diff --git a/unilabos/app/main.py b/unilabos/app/main.py index db15e2c6..afd6068a 100644 --- a/unilabos/app/main.py +++ b/unilabos/app/main.py @@ -20,6 +20,7 @@ if unilabos_dir not in sys.path: from unilabos.utils.banner_print import print_status, print_unilab_banner from unilabos.config.config import load_config, BasicConfig, HTTPConfig + def load_config_from_file(config_path): if config_path is None: config_path = os.environ.get("UNILABOS_BASICCONFIG_CONFIG_PATH", None) @@ -41,7 +42,7 @@ def convert_argv_dashes_to_underscores(args: argparse.ArgumentParser): for i, arg in enumerate(sys.argv): for option_string in option_strings: if arg.startswith(option_string): - new_arg = arg[:2] + arg[2:len(option_string)].replace("-", "_") + arg[len(option_string):] + new_arg = arg[:2] + arg[2 : len(option_string)].replace("-", "_") + arg[len(option_string) :] sys.argv[i] = new_arg break @@ -49,6 +50,8 @@ def convert_argv_dashes_to_underscores(args: argparse.ArgumentParser): def parse_args(): """解析命令行参数""" parser = argparse.ArgumentParser(description="Start Uni-Lab Edge server.") + subparsers = parser.add_subparsers(title="Valid subcommands", dest="command") + parser.add_argument("-g", "--graph", help="Physical setup graph file path.") parser.add_argument("-c", "--controllers", default=None, help="Controllers config file path.") parser.add_argument( @@ -105,7 +108,7 @@ def parse_args(): parser.add_argument( "--port", type=int, - default=8002, + default=None, help="Port for web service information page", ) parser.add_argument( @@ -153,21 +156,54 @@ def parse_args(): default=False, help="Complete registry information", ) + # workflow upload subcommand + workflow_parser = subparsers.add_parser( + "workflow_upload", + aliases=["wf"], + help="Upload workflow from xdl/json/python files", + ) + workflow_parser.add_argument( + "-f", + "--workflow_file", + type=str, + required=True, + help="Path to the workflow file (JSON format)", + ) + workflow_parser.add_argument( + "-n", + "--workflow_name", + type=str, + default=None, + help="Workflow name, if not provided will use the name from file or filename", + ) + workflow_parser.add_argument( + "--tags", + type=str, + nargs="*", + default=[], + help="Tags for the workflow (space-separated)", + ) + workflow_parser.add_argument( + "--published", + action="store_true", + default=False, + help="Whether to publish the workflow (default: False)", + ) return parser def main(): """主函数""" # 解析命令行参数 - args = parse_args() - convert_argv_dashes_to_underscores(args) - args_dict = vars(args.parse_args()) + parser = parse_args() + convert_argv_dashes_to_underscores(parser) + args = parser.parse_args() + args_dict = vars(args) # 环境检查 - 检查并自动安装必需的包 (可选) if not args_dict.get("skip_env_check", False): from unilabos.utils.environment_check import check_environment - print_status("正在进行环境依赖检查...", "info") if not check_environment(auto_install=True): print_status("环境检查失败,程序退出", "error") os._exit(1) @@ -218,19 +254,20 @@ def main(): if hasattr(BasicConfig, "log_level"): logger.info(f"Log level set to '{BasicConfig.log_level}' from config file.") - configure_logger(loglevel=BasicConfig.log_level) + configure_logger(loglevel=BasicConfig.log_level, working_dir=working_dir) - if args_dict["addr"] == "test": - print_status("使用测试环境地址", "info") - HTTPConfig.remote_addr = "https://uni-lab.test.bohrium.com/api/v1" - elif args_dict["addr"] == "uat": - print_status("使用uat环境地址", "info") - HTTPConfig.remote_addr = "https://uni-lab.uat.bohrium.com/api/v1" - elif args_dict["addr"] == "local": - print_status("使用本地环境地址", "info") - HTTPConfig.remote_addr = "http://127.0.0.1:48197/api/v1" - else: - HTTPConfig.remote_addr = args_dict.get("addr", "") + if args.addr != parser.get_default("addr"): + if args.addr == "test": + print_status("使用测试环境地址", "info") + HTTPConfig.remote_addr = "https://uni-lab.test.bohrium.com/api/v1" + elif args.addr == "uat": + print_status("使用uat环境地址", "info") + HTTPConfig.remote_addr = "https://uni-lab.uat.bohrium.com/api/v1" + elif args.addr == "local": + print_status("使用本地环境地址", "info") + HTTPConfig.remote_addr = "http://127.0.0.1:48197/api/v1" + else: + HTTPConfig.remote_addr = args.addr # 设置BasicConfig参数 if args_dict.get("ak", ""): @@ -239,9 +276,12 @@ def main(): if args_dict.get("sk", ""): BasicConfig.sk = args_dict.get("sk", "") print_status("传入了sk参数,优先采用传入参数!", "info") + BasicConfig.working_dir = working_dir + + workflow_upload = args_dict.get("command") in ("workflow_upload", "wf") # 使用远程资源启动 - if args_dict["use_remote_resource"]: + if not workflow_upload and args_dict["use_remote_resource"]: print_status("使用远程资源启动", "info") from unilabos.app.web import http_client @@ -252,7 +292,8 @@ def main(): else: print_status("远程资源不存在,本地将进行首次上报!", "info") - BasicConfig.working_dir = working_dir + BasicConfig.port = args_dict["port"] if args_dict["port"] else BasicConfig.port + BasicConfig.disable_browser = args_dict["disable_browser"] or BasicConfig.disable_browser BasicConfig.is_host_mode = not args_dict.get("is_slave", False) BasicConfig.slave_no_host = args_dict.get("slave_no_host", False) BasicConfig.upload_registry = args_dict.get("upload_registry", False) @@ -281,9 +322,31 @@ def main(): # 注册表 lab_registry = build_registry( - args_dict["registry_path"], args_dict.get("complete_registry", False), args_dict["upload_registry"] + args_dict["registry_path"], args_dict.get("complete_registry", False), BasicConfig.upload_registry ) + if BasicConfig.upload_registry: + # 设备注册到服务端 - 需要 ak 和 sk + if BasicConfig.ak and BasicConfig.sk: + print_status("开始注册设备到服务端...", "info") + try: + register_devices_and_resources(lab_registry) + print_status("设备注册完成", "info") + except Exception as e: + print_status(f"设备注册失败: {e}", "error") + else: + print_status("未提供 ak 和 sk,跳过设备注册", "info") + else: + print_status("本次启动注册表不报送云端,如果您需要联网调试,请在启动命令增加--upload_registry", "warning") + + # 处理 workflow_upload 子命令 + if workflow_upload: + from unilabos.workflow.wf_utils import handle_workflow_upload_command + + handle_workflow_upload_command(args_dict) + print_status("工作流上传完成,程序退出", "info") + os._exit(0) + if not BasicConfig.ak or not BasicConfig.sk: print_status("后续运行必须拥有一个实验室,请前往 https://uni-lab.bohrium.com 注册实验室!", "warning") os._exit(1) @@ -291,7 +354,9 @@ def main(): resource_tree_set: ResourceTreeSet resource_links: List[Dict[str, Any]] request_startup_json = http_client.request_startup_json() - if args_dict["graph"] is None: + + file_path = args_dict.get("graph", BasicConfig.startup_json_path) + if file_path is None: if not request_startup_json: print_status( "未指定设备加载文件路径,尝试从HTTP获取失败,请检查网络或者使用-g参数指定设备加载文件路径", "error" @@ -301,7 +366,38 @@ def main(): print_status("联网获取设备加载文件成功", "info") graph, resource_tree_set, resource_links = read_node_link_json(request_startup_json) else: - file_path = args_dict["graph"] + if not os.path.isfile(file_path): + # 尝试从 main.py 向上两级目录查找 + temp_file_path = os.path.abspath(str(os.path.join(__file__, "..", "..", file_path))) + if os.path.isfile(temp_file_path): + print_status(f"使用相对路径{temp_file_path}", "info") + file_path = temp_file_path + else: + # 尝试在 working_dir 中查找 + working_dir_file_path = os.path.join(working_dir, file_path) + if os.path.isfile(working_dir_file_path): + print_status(f"在工作目录中找到文件: {working_dir_file_path}", "info") + file_path = working_dir_file_path + else: + # 尝试使用文件名在 working_dir 中查找 + file_name = os.path.basename(file_path) + working_dir_file_path = os.path.join(working_dir, file_name) + if os.path.isfile(working_dir_file_path): + print_status(f"在工作目录中找到文件: {working_dir_file_path}", "info") + file_path = working_dir_file_path + # 最终检查文件是否存在 + if not os.path.isfile(file_path): + print_status( + f"无法找到设备加载文件: {file_path}\n" + f"已尝试在以下位置查找:\n" + f" 1. 原始路径: {args_dict.get('graph', BasicConfig.startup_json_path)}\n" + f" 2. 相对路径: {os.path.abspath(str(os.path.join(__file__, '..', '..', args_dict.get('graph', BasicConfig.startup_json_path) or '')))}\n" + f" 3. 工作目录: {os.path.join(working_dir, args_dict.get('graph', BasicConfig.startup_json_path) or '')}\n" + f" 4. 工作目录(仅文件名): {os.path.join(working_dir, os.path.basename(args_dict.get('graph', BasicConfig.startup_json_path) or ''))}\n" + f"请使用 -g 参数指定正确的文件路径,或在工作目录 {working_dir} 中放置文件", + "error" + ) + os._exit(1) if file_path.endswith(".json"): graph, resource_tree_set, resource_links = read_node_link_json(file_path) else: @@ -354,20 +450,6 @@ def main(): args_dict["devices_config"] = resource_tree_set args_dict["graph"] = graph_res.physical_setup_graph - if BasicConfig.upload_registry: - # 设备注册到服务端 - 需要 ak 和 sk - if BasicConfig.ak and BasicConfig.sk: - print_status("开始注册设备到服务端...", "info") - try: - register_devices_and_resources(lab_registry) - print_status("设备注册完成", "info") - except Exception as e: - print_status(f"设备注册失败: {e}", "error") - else: - print_status("未提供 ak 和 sk,跳过设备注册", "info") - else: - print_status("本次启动注册表不报送云端,如果您需要联网调试,请在启动命令增加--upload_registry", "warning") - if args_dict["controllers"] is not None: args_dict["controllers_config"] = yaml.safe_load(open(args_dict["controllers"], encoding="utf-8")) else: @@ -382,6 +464,7 @@ def main(): comm_client = get_communication_client() if "websocket" in args_dict["app_bridges"]: args_dict["bridges"].append(comm_client) + def _exit(signum, frame): comm_client.stop() sys.exit(0) @@ -413,26 +496,39 @@ def main(): server_thread = threading.Thread( target=start_server, kwargs=dict( - open_browser=not args_dict["disable_browser"], - port=args_dict["port"], + open_browser=not BasicConfig.disable_browser, + port=BasicConfig.port, ), ) server_thread.start() asyncio.set_event_loop(asyncio.new_event_loop()) - resource_visualization.start() + try: + resource_visualization.start() + except OSError as e: + if "AMENT_PREFIX_PATH" in str(e): + print_status(f"ROS 2环境未正确设置,跳过3D可视化启动。错误详情: {e}", "warning") + print_status( + "建议解决方案:\n" + "1. 激活Conda环境: conda activate unilab\n" + "2. 或使用 --backend simple 参数\n" + "3. 或使用 --visual disable 参数禁用可视化", + "info", + ) + else: + raise while True: time.sleep(1) else: start_backend(**args_dict) start_server( open_browser=not args_dict["disable_browser"], - port=args_dict["port"], + port=BasicConfig.port, ) else: start_backend(**args_dict) start_server( open_browser=not args_dict["disable_browser"], - port=args_dict["port"], + port=BasicConfig.port, ) diff --git a/unilabos/app/model.py b/unilabos/app/model.py index a7c199c9..6f40e731 100644 --- a/unilabos/app/model.py +++ b/unilabos/app/model.py @@ -51,21 +51,25 @@ class Resp(BaseModel): class JobAddReq(BaseModel): device_id: str = Field(examples=["Gripper"], description="device id") action: str = Field(examples=["_execute_driver_command_async"], description="action name", default="") - action_type: str = Field(examples=["unilabos_msgs.action._str_single_input.StrSingleInput"], description="action name", default="") - action_args: dict = Field(examples=[{'string': 'string'}], description="action name", default="") - task_id: str = Field(examples=["task_id"], description="task uuid") - job_id: str = Field(examples=["job_id"], description="goal uuid") - node_id: str = Field(examples=["node_id"], description="node uuid") - server_info: dict = Field(examples=[{"send_timestamp": 1717000000.0}], description="server info") + action_type: str = Field( + examples=["unilabos_msgs.action._str_single_input.StrSingleInput"], description="action type", default="" + ) + action_args: dict = Field(examples=[{"string": "string"}], description="action arguments", default_factory=dict) + task_id: str = Field(examples=["task_id"], description="task uuid (auto-generated if empty)", default="") + job_id: str = Field(examples=["job_id"], description="goal uuid (auto-generated if empty)", default="") + node_id: str = Field(examples=["node_id"], description="node uuid", default="") + server_info: dict = Field( + examples=[{"send_timestamp": 1717000000.0}], + description="server info (auto-generated if empty)", + default_factory=dict, + ) - data: dict = Field(examples=[{"position": 30, "torque": 5, "action": "push_to"}], default={}) + data: dict = Field(examples=[{"position": 30, "torque": 5, "action": "push_to"}], default_factory=dict) class JobStepFinishReq(BaseModel): token: str = Field(examples=["030944"], description="token") - request_time: str = Field( - examples=["2024-12-12 12:12:12.xxx"], description="requestTime" - ) + request_time: str = Field(examples=["2024-12-12 12:12:12.xxx"], description="requestTime") data: dict = Field( examples=[ { @@ -83,9 +87,7 @@ class JobStepFinishReq(BaseModel): class JobPreintakeFinishReq(BaseModel): token: str = Field(examples=["030944"], description="token") - request_time: str = Field( - examples=["2024-12-12 12:12:12.xxx"], description="requestTime" - ) + request_time: str = Field(examples=["2024-12-12 12:12:12.xxx"], description="requestTime") data: dict = Field( examples=[ { @@ -102,9 +104,7 @@ class JobPreintakeFinishReq(BaseModel): class JobFinishReq(BaseModel): token: str = Field(examples=["030944"], description="token") - request_time: str = Field( - examples=["2024-12-12 12:12:12.xxx"], description="requestTime" - ) + request_time: str = Field(examples=["2024-12-12 12:12:12.xxx"], description="requestTime") data: dict = Field( examples=[ { @@ -133,6 +133,10 @@ class JobData(BaseModel): default=0, description="0:UNKNOWN, 1:ACCEPTED, 2:EXECUTING, 3:CANCELING, 4:SUCCEEDED, 5:CANCELED, 6:ABORTED", ) + result: dict = Field( + default_factory=dict, + description="Job result data (available when status is SUCCEEDED/CANCELED/ABORTED)", + ) class JobStatusResp(Resp): diff --git a/unilabos/app/oss_upload.py b/unilabos/app/oss_upload.py index 6f9431fe..f28187ea 100644 --- a/unilabos/app/oss_upload.py +++ b/unilabos/app/oss_upload.py @@ -1,161 +1,158 @@ import argparse import os import time -from typing import Dict, Optional, Tuple +from datetime import datetime +from pathlib import Path +from typing import Dict, Optional, Tuple, Union import requests -from unilabos.config.config import OSSUploadConfig +from unilabos.app.web.client import http_client, HTTPClient +from unilabos.utils import logger -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]: +def _get_oss_token( + filename: str, + driver_name: str = "default", + exp_type: str = "default", + client: Optional[HTTPClient] = None, +) -> Tuple[bool, Dict]: """ - 初始化上传过程 + 获取OSS上传Token Args: - file_path: 本地文件路径 - oss_path: OSS目标路径 - filename: 文件名,如果为None则使用file_path的文件名 - process_key: 处理键 - device_id: 设备ID - expires_hours: 链接过期小时数 + filename: 文件名 + driver_name: 驱动名称 + exp_type: 实验类型 + client: HTTPClient实例,如果不提供则使用默认的http_client Returns: - (成功标志, 响应数据) + (成功标志, Token数据字典包含token/path/host/expires) """ - if filename is None: - filename = os.path.basename(file_path) + # 使用提供的client或默认的http_client + if client is None: + client = http_client - # 构造初始化请求 - url = f"{OSSUploadConfig.api_host}{OSSUploadConfig.init_endpoint}" - headers = { - "Authorization": OSSUploadConfig.authorization, - "Content-Type": "application/json" - } + # 构造scene参数: driver_name-exp_type + sub_path = f"{driver_name}-{exp_type}" - payload = { - "device_id": device_id, - "process_key": process_key, - "filename": filename, - "path": oss_path, - "expires_hours": expires_hours - } + # 构造请求URL,使用client的remote_addr(已包含/api/v1/) + url = f"{client.remote_addr}/applications/token" + params = {"sub_path": sub_path, "filename": filename, "scene": "job"} 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", {}) + logger.info(f"[OSS] 请求预签名URL: sub_path={sub_path}, filename={filename}") + response = requests.get(url, params=params, headers={"Authorization": f"Lab {client.auth}"}, timeout=10) - print(f"初始化上传失败: {response.status_code}, {response.text}") + if response.status_code == 200: + result = response.json() + if result.get("code") == 0: + data = result.get("data", {}) + + # 转换expires时间戳为可读格式 + expires_timestamp = data.get("expires", 0) + expires_datetime = datetime.fromtimestamp(expires_timestamp) + expires_str = expires_datetime.strftime("%Y-%m-%d %H:%M:%S") + + logger.info(f"[OSS] 获取预签名URL成功") + logger.info(f"[OSS] - URL: {data.get('url', 'N/A')}") + logger.info(f"[OSS] - Expires: {expires_str} (timestamp: {expires_timestamp})") + + return True, data + + logger.error(f"[OSS] 获取预签名URL失败: {response.status_code}, {response.text}") return False, {} except Exception as e: - print(f"初始化上传异常: {str(e)}") + logger.error(f"[OSS] 获取预签名URL异常: {str(e)}") return False, {} def _put_upload(file_path: str, upload_url: str) -> bool: """ - 执行PUT上传 + 使用预签名URL上传文件到OSS Args: file_path: 本地文件路径 - upload_url: 上传URL + upload_url: 完整的预签名上传URL Returns: 是否成功 """ try: + logger.info(f"[OSS] 开始上传文件: {file_path}") + with open(file_path, "rb") as f: - response = requests.put(upload_url, data=f) + # 使用预签名URL上传,不需要额外的认证header + response = requests.put(upload_url, data=f, timeout=300) + if response.status_code == 200: + logger.info(f"[OSS] 文件上传成功") return True - print(f"PUT上传失败: {response.status_code}, {response.text}") + logger.error(f"[OSS] 上传失败: {response.status_code}") + logger.error(f"[OSS] 响应内容: {response.text[:500] if response.text else '无响应内容'}") return False except Exception as e: - print(f"PUT上传异常: {str(e)}") + logger.error(f"[OSS] 上传异常: {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: +def oss_upload( + file_path: Union[str, Path], + filename: Optional[str] = None, + driver_name: str = "default", + exp_type: str = "default", + max_retries: int = 3, + client: Optional[HTTPClient] = None, +) -> Dict: """ 文件上传主函数,包含重试机制 Args: file_path: 本地文件路径 - oss_path: OSS目标路径 filename: 文件名,如果为None则使用file_path的文件名 - process_key: 处理键 - device_id: 设备ID + driver_name: 驱动名称,用于构造scene + exp_type: 实验类型,用于构造scene + max_retries: 最大重试次数 + client: HTTPClient实例,如果不提供则使用默认的http_client Returns: - 是否成功上传 + Dict: { + "success": bool, # 是否上传成功 + "original_path": str, # 原始文件路径 + "oss_path": str # OSS路径(成功时)或空字符串(失败时) + } """ - max_retries = OSSUploadConfig.max_retries + file_path = Path(file_path) + if filename is None: + filename = os.path.basename(file_path) + + if not os.path.exists(file_path): + logger.error(f"[OSS] 文件不存在: {file_path}") + return {"success": False, "original_path": file_path, "oss_path": ""} + retry_count = 0 + oss_path = "" 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 + # 步骤1:获取预签名URL + token_success, token_data = _get_oss_token( + filename=filename, driver_name=driver_name, exp_type=exp_type, client=client ) - if not init_success: - print(f"初始化上传失败,重试 {retry_count + 1}/{max_retries}") + if not token_success: + logger.warning(f"[OSS] 获取预签名URL失败,重试 {retry_count + 1}/{max_retries}") retry_count += 1 - time.sleep(1) # 等待1秒后重试 + time.sleep(1) continue - # 获取UUID和上传URL - uuid = init_data.get("uuid") - upload_url = init_data.get("upload_url") + # 获取预签名URL和OSS路径 + upload_url = token_data.get("url") + oss_path = token_data.get("path", "") - if not uuid or not upload_url: - print(f"初始化上传返回数据不完整,重试 {retry_count + 1}/{max_retries}") + if not upload_url: + logger.warning(f"[OSS] 无法获取上传URL,API未返回url字段") retry_count += 1 time.sleep(1) continue @@ -163,69 +160,82 @@ def oss_upload(file_path: str, oss_path: str, filename: Optional[str] = None, # 步骤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}") + logger.warning(f"[OSS] PUT上传失败,重试 {retry_count + 1}/{max_retries}") retry_count += 1 time.sleep(1) continue # 所有步骤都成功 - print(f"文件 {file_path} 上传成功") - return True + logger.info(f"[OSS] 文件 {file_path} 上传成功") + return {"success": True, "original_path": file_path, "oss_path": oss_path} except Exception as e: - print(f"上传过程异常: {str(e)},重试 {retry_count + 1}/{max_retries}") + logger.error(f"[OSS] 上传过程异常: {str(e)},重试 {retry_count + 1}/{max_retries}") retry_count += 1 time.sleep(1) - print(f"文件 {file_path} 上传失败,已达到最大重试次数 {max_retries}") - return False + logger.error(f"[OSS] 文件 {file_path} 上传失败,已达到最大重试次数 {max_retries}") + return {"success": False, "original_path": file_path, "oss_path": oss_path} if __name__ == "__main__": - # python -m unilabos.app.oss_upload -f /path/to/your/file.txt + # python -m unilabos.app.oss_upload -f /path/to/your/file.txt --driver HPLC --type test + # python -m unilabos.app.oss_upload -f /path/to/your/file.txt --driver HPLC --type test \ + # --ak xxx --sk yyy --remote-addr http://xxx/api/v1 # 命令行参数解析 - 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='处理键') + parser = argparse.ArgumentParser(description="文件上传测试工具") + parser.add_argument("--file", "-f", type=str, required=True, help="要上传的本地文件路径") + parser.add_argument("--driver", "-d", type=str, default="default", help="驱动名称") + parser.add_argument("--type", "-t", type=str, default="default", help="实验类型") + parser.add_argument("--ak", type=str, help="Access Key,如果提供则覆盖配置") + parser.add_argument("--sk", type=str, help="Secret Key,如果提供则覆盖配置") + parser.add_argument("--remote-addr", type=str, help="远程服务器地址(包含/api/v1),如果提供则覆盖配置") args = parser.parse_args() # 检查文件是否存在 if not os.path.exists(args.file): - print(f"错误:文件 {args.file} 不存在") + logger.error(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) + # 如果提供了ak/sk/remote_addr,创建临时HTTPClient + temp_client = None + if args.ak and args.sk: + import base64 + + auth = base64.b64encode(f"{args.ak}:{args.sk}".encode("utf-8")).decode("utf-8") + remote_addr = args.remote_addr if args.remote_addr else http_client.remote_addr + temp_client = HTTPClient(remote_addr=remote_addr, auth=auth) + logger.info(f"[配置] 使用自定义配置: remote_addr={remote_addr}") + elif args.remote_addr: + temp_client = HTTPClient(remote_addr=args.remote_addr, auth=http_client.auth) + logger.info(f"[配置] 使用自定义remote_addr: {args.remote_addr}") + else: + logger.info(f"[配置] 使用默认配置: remote_addr={http_client.remote_addr}") + + logger.info("=" * 50) + logger.info(f"开始上传文件: {args.file}") + logger.info(f"驱动名称: {args.driver}") + logger.info(f"实验类型: {args.type}") + logger.info(f"Scene: {args.driver}-{args.type}") + logger.info("=" * 50) # 执行上传 - success = oss_upload( + result = oss_upload( file_path=args.file, - oss_path=args.path, filename=None, # 使用默认文件名 - process_key=args.process, - device_id=args.device + driver_name=args.driver, + exp_type=args.type, + client=temp_client, ) # 输出结果 - if success: - print("\n√ 文件上传成功!") + if result["success"]: + logger.info(f"\n√ 文件上传成功!") + logger.info(f"原始路径: {result['original_path']}") + logger.info(f"OSS路径: {result['oss_path']}") exit(0) else: - print("\n× 文件上传失败!") + logger.error(f"\n× 文件上传失败!") + logger.error(f"原始路径: {result['original_path']}") exit(1) - diff --git a/unilabos/app/web/api.py b/unilabos/app/web/api.py index 868de44a..0f6077c8 100644 --- a/unilabos/app/web/api.py +++ b/unilabos/app/web/api.py @@ -9,13 +9,22 @@ import asyncio import yaml -from unilabos.app.web.controler import devices, job_add, job_info +from unilabos.app.web.controller import ( + devices, + job_add, + job_info, + get_online_devices, + get_device_actions, + get_action_schema, + get_all_available_actions, +) from unilabos.app.model import ( Resp, RespCode, JobStatusResp, JobAddResp, JobAddReq, + JobData, ) from unilabos.app.web.utils.host_utils import get_host_node_info from unilabos.registry.registry import lab_registry @@ -1234,6 +1243,65 @@ def get_devices(): return Resp(data=dict(data)) +@api.get("/online-devices", summary="Online devices list", response_model=Resp) +def api_get_online_devices(): + """获取在线设备列表 + + 返回当前在线的设备列表,包含设备ID、命名空间、机器名等信息 + """ + isok, data = get_online_devices() + if not isok: + return Resp(code=RespCode.ErrorHostNotInit, message=data.get("error", "Unknown error")) + + return Resp(data=data) + + +@api.get("/devices/{device_id}/actions", summary="Device actions list", response_model=Resp) +def api_get_device_actions(device_id: str): + """获取设备可用的动作列表 + + Args: + device_id: 设备ID + + 返回指定设备的所有可用动作,包含动作名称、类型、是否繁忙等信息 + """ + isok, data = get_device_actions(device_id) + if not isok: + return Resp(code=RespCode.ErrorInvalidReq, message=data.get("error", "Unknown error")) + + return Resp(data=data) + + +@api.get("/devices/{device_id}/actions/{action_name}/schema", summary="Action schema", response_model=Resp) +def api_get_action_schema(device_id: str, action_name: str): + """获取动作的Schema详情 + + Args: + device_id: 设备ID + action_name: 动作名称 + + 返回动作的参数Schema、默认值、类型等详细信息 + """ + isok, data = get_action_schema(device_id, action_name) + if not isok: + return Resp(code=RespCode.ErrorInvalidReq, message=data.get("error", "Unknown error")) + + return Resp(data=data) + + +@api.get("/actions", summary="All available actions", response_model=Resp) +def api_get_all_actions(): + """获取所有设备的可用动作 + + 返回所有已注册设备的动作列表,包含设备信息和各动作的状态 + """ + isok, data = get_all_available_actions() + if not isok: + return Resp(code=RespCode.ErrorHostNotInit, message=data.get("error", "Unknown error")) + + return Resp(data=data) + + @api.get("/job/{id}/status", summary="Job status", response_model=JobStatusResp) def job_status(id: str): """获取任务状态""" @@ -1244,11 +1312,22 @@ def job_status(id: str): @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") + # 检查必要参数:device_id 和 action + if not req.device_id: + return JobAddResp( + data=JobData(jobId="", status=6), + code=RespCode.ErrorInvalidReq, + message="device_id is required", + ) + + action_name = req.data.get("action", req.action) if req.data else req.action + if not action_name: + return JobAddResp( + data=JobData(jobId="", status=6), + code=RespCode.ErrorInvalidReq, + message="action is required", + ) - req.device_id = device_id data = job_add(req) return JobAddResp(data=data) diff --git a/unilabos/app/web/client.py b/unilabos/app/web/client.py index 72c079a1..1f40a0b8 100644 --- a/unilabos/app/web/client.py +++ b/unilabos/app/web/client.py @@ -76,7 +76,8 @@ class HTTPClient: Dict[str, str]: 旧UUID到新UUID的映射关系 {old_uuid: new_uuid} """ with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_add.json"), "w", encoding="utf-8") as f: - f.write(json.dumps({"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid}, indent=4)) + payload = {"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid} + f.write(json.dumps(payload, indent=4)) # 从序列化数据中提取所有节点的UUID(保存旧UUID) old_uuids = {n.res_content.uuid: n for n in resources.all_nodes} if not self.initialized or first_add: @@ -331,6 +332,67 @@ class HTTPClient: logger.error(f"响应内容: {response.text}") return None + def workflow_import( + self, + name: str, + workflow_uuid: str, + workflow_name: str, + nodes: List[Dict[str, Any]], + edges: List[Dict[str, Any]], + tags: Optional[List[str]] = None, + published: bool = False, + ) -> Dict[str, Any]: + """ + 导入工作流到服务器 + + Args: + name: 工作流名称(顶层) + workflow_uuid: 工作流UUID + workflow_name: 工作流名称(data内部) + nodes: 工作流节点列表 + edges: 工作流边列表 + tags: 工作流标签列表,默认为空列表 + published: 是否发布工作流,默认为False + + Returns: + Dict: API响应数据,包含 code 和 data (uuid, name) + """ + # target_lab_uuid 暂时使用默认值,后续由后端根据 ak/sk 获取 + payload = { + "target_lab_uuid": "28c38bb0-63f6-4352-b0d8-b5b8eb1766d5", + "name": name, + "data": { + "workflow_uuid": workflow_uuid, + "workflow_name": workflow_name, + "nodes": nodes, + "edges": edges, + "tags": tags if tags is not None else [], + "published": published, + }, + } + # 保存请求到文件 + with open(os.path.join(BasicConfig.working_dir, "req_workflow_upload.json"), "w", encoding="utf-8") as f: + f.write(json.dumps(payload, indent=4, ensure_ascii=False)) + + response = requests.post( + f"{self.remote_addr}/lab/workflow/owner/import", + json=payload, + headers={"Authorization": f"Lab {self.auth}"}, + timeout=60, + ) + # 保存响应到文件 + with open(os.path.join(BasicConfig.working_dir, "res_workflow_upload.json"), "w", encoding="utf-8") as f: + f.write(f"{response.status_code}" + "\n" + response.text) + + if response.status_code == 200: + res = response.json() + if "code" in res and res["code"] != 0: + logger.error(f"导入工作流失败: {response.text}") + return res + else: + logger.error(f"导入工作流失败: {response.status_code}, {response.text}") + return {"code": response.status_code, "message": response.text} + # 创建默认客户端实例 http_client = HTTPClient() diff --git a/unilabos/app/web/controler.py b/unilabos/app/web/controler.py deleted file mode 100644 index d23470fe..00000000 --- a/unilabos/app/web/controler.py +++ /dev/null @@ -1,45 +0,0 @@ - -import json -import traceback -import uuid -from unilabos.app.model import JobAddReq, JobData -from unilabos.ros.nodes.presets.host_node import HostNode -from unilabos.utils.type_check import serialize_result_info - - -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_type = req.data.get("action_type", "LocalUnknown") - action_args = req.data.get("action_kwargs", None) # 兼容老版本,后续删除 - if action_args is None: - action_args = req.data.get("action_args") - else: - if "command" in action_args: - action_args = action_args["command"] - # print(f"job_add:{req.device_id} {action_name} {action_kwargs}") - try: - HostNode.get_instance().send_goal(req.device_id, action_type=action_type, action_name=action_name, action_kwargs=action_args, goal_uuid=req.job_id, server_info=req.server_info) - except Exception as e: - for bridge in HostNode.get_instance().bridges: - traceback.print_exc() - if hasattr(bridge, "publish_job_status"): - bridge.publish_job_status({}, req.job_id, "failed", serialize_result_info(traceback.format_exc(), False, {})) - return JobData(jobId=req.job_id) diff --git a/unilabos/app/web/controller.py b/unilabos/app/web/controller.py new file mode 100644 index 00000000..9b0f1ff6 --- /dev/null +++ b/unilabos/app/web/controller.py @@ -0,0 +1,587 @@ +""" +Web API Controller + +提供Web API的控制器函数,处理设备、任务和动作相关的业务逻辑 +""" + +import threading +import time +import traceback +import uuid +from dataclasses import dataclass, field +from typing import Optional, Dict, Any, Tuple + +from unilabos.app.model import JobAddReq, JobData +from unilabos.ros.nodes.presets.host_node import HostNode +from unilabos.utils import logger + + +@dataclass +class JobResult: + """任务结果数据""" + + job_id: str + status: int # 4:SUCCEEDED, 5:CANCELED, 6:ABORTED + result: Dict[str, Any] = field(default_factory=dict) + feedback: Dict[str, Any] = field(default_factory=dict) + timestamp: float = field(default_factory=time.time) + + +class JobResultStore: + """任务结果存储(单例)""" + + _instance: Optional["JobResultStore"] = None + _lock = threading.Lock() + + def __init__(self): + if not hasattr(self, "_initialized"): + self._results: Dict[str, JobResult] = {} + self._results_lock = threading.RLock() + self._initialized = True + + def __new__(cls): + if cls._instance is None: + with cls._lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def store_result( + self, job_id: str, status: int, result: Optional[Dict[str, Any]], feedback: Optional[Dict[str, Any]] = None + ): + """存储任务结果""" + with self._results_lock: + self._results[job_id] = JobResult( + job_id=job_id, + status=status, + result=result or {}, + feedback=feedback or {}, + timestamp=time.time(), + ) + logger.debug(f"[JobResultStore] Stored result for job {job_id[:8]}, status={status}") + + def get_and_remove(self, job_id: str) -> Optional[JobResult]: + """获取并删除任务结果""" + with self._results_lock: + result = self._results.pop(job_id, None) + if result: + logger.debug(f"[JobResultStore] Retrieved and removed result for job {job_id[:8]}") + return result + + def get_result(self, job_id: str) -> Optional[JobResult]: + """仅获取任务结果(不删除)""" + with self._results_lock: + return self._results.get(job_id) + + def cleanup_old_results(self, max_age_seconds: float = 3600): + """清理过期的结果""" + current_time = time.time() + with self._results_lock: + expired_jobs = [ + job_id for job_id, result in self._results.items() if current_time - result.timestamp > max_age_seconds + ] + for job_id in expired_jobs: + del self._results[job_id] + logger.debug(f"[JobResultStore] Cleaned up expired result for job {job_id[:8]}") + + +# 全局结果存储实例 +job_result_store = JobResultStore() + + +def store_job_result( + job_id: str, status: str, result: Optional[Dict[str, Any]], feedback: Optional[Dict[str, Any]] = None +): + """存储任务结果(供外部调用) + + Args: + job_id: 任务ID + status: 状态字符串 ("success", "failed", "cancelled") + result: 结果数据 + feedback: 反馈数据 + """ + # 转换状态字符串为整数 + status_map = { + "success": 4, # SUCCEEDED + "failed": 6, # ABORTED + "cancelled": 5, # CANCELED + "running": 2, # EXECUTING + } + status_int = status_map.get(status, 0) + + # 只存储最终状态 + if status_int in (4, 5, 6): + job_result_store.store_result(job_id, status_int, result, feedback) + + +def get_resources() -> Tuple[bool, Any]: + """获取资源配置 + + Returns: + Tuple[bool, Any]: (是否成功, 资源配置或错误信息) + """ + host_node = HostNode.get_instance(0) + if host_node is None: + return False, "Host node not initialized" + + return True, host_node.resources_config + + +def devices() -> Tuple[bool, Any]: + """获取设备配置 + + Returns: + Tuple[bool, Any]: (是否成功, 设备配置或错误信息) + """ + host_node = HostNode.get_instance(0) + if host_node is None: + return False, "Host node not initialized" + + return True, host_node.devices_config + + +def job_info(job_id: str, remove_after_read: bool = True) -> JobData: + """获取任务信息 + + Args: + job_id: 任务ID + remove_after_read: 是否在读取后删除结果(默认True) + + Returns: + JobData: 任务数据 + """ + # 首先检查结果存储中是否有已完成的结果 + if remove_after_read: + stored_result = job_result_store.get_and_remove(job_id) + else: + stored_result = job_result_store.get_result(job_id) + + if stored_result: + # 有存储的结果,直接返回 + return JobData( + jobId=job_id, + status=stored_result.status, + result=stored_result.result, + ) + + # 没有存储的结果,从 HostNode 获取当前状态 + host_node = HostNode.get_instance(0) + if host_node is None: + return JobData(jobId=job_id, status=0) + + get_goal_status = host_node.get_goal_status(job_id) + return JobData(jobId=job_id, status=get_goal_status) + + +def check_device_action_busy(device_id: str, action_name: str) -> Tuple[bool, Optional[str]]: + """检查设备动作是否正在执行(被占用) + + Args: + device_id: 设备ID + action_name: 动作名称 + + Returns: + Tuple[bool, Optional[str]]: (是否繁忙, 当前执行的job_id或None) + """ + host_node = HostNode.get_instance(0) + if host_node is None: + return False, None + + device_action_key = f"/devices/{device_id}/{action_name}" + + # 检查 _device_action_status 中是否有正在执行的任务 + if device_action_key in host_node._device_action_status: + status = host_node._device_action_status[device_action_key] + if status.job_ids: + # 返回第一个正在执行的job_id + current_job_id = next(iter(status.job_ids.keys()), None) + return True, current_job_id + + return False, None + + +def _get_action_type(device_id: str, action_name: str) -> Optional[str]: + """从注册表自动获取动作类型 + + Args: + device_id: 设备ID + action_name: 动作名称 + + Returns: + 动作类型字符串,未找到返回None + """ + try: + from unilabos.ros.nodes.base_device_node import registered_devices + + # 方法1: 从运行时注册设备获取 + if device_id in registered_devices: + device_info = registered_devices[device_id] + base_node = device_info.get("base_node_instance") + if base_node and hasattr(base_node, "_action_value_mappings"): + action_mappings = base_node._action_value_mappings + # 尝试直接匹配或 auto- 前缀匹配 + for key in [action_name, f"auto-{action_name}"]: + if key in action_mappings: + action_type = action_mappings[key].get("type") + if action_type: + # 转换为字符串格式 + if hasattr(action_type, "__module__") and hasattr(action_type, "__name__"): + return f"{action_type.__module__}.{action_type.__name__}" + return str(action_type) + + # 方法2: 从lab_registry获取 + from unilabos.registry.registry import lab_registry + + host_node = HostNode.get_instance(0) + if host_node and lab_registry: + devices_config = host_node.devices_config + device_class = None + + for tree in devices_config.trees: + node = tree.root_node + if node.res_content.id == device_id: + device_class = node.res_content.klass + break + + if device_class and device_class in lab_registry.device_type_registry: + device_type_info = lab_registry.device_type_registry[device_class] + class_info = device_type_info.get("class", {}) + action_mappings = class_info.get("action_value_mappings", {}) + + for key in [action_name, f"auto-{action_name}"]: + if key in action_mappings: + action_type = action_mappings[key].get("type") + if action_type: + if hasattr(action_type, "__module__") and hasattr(action_type, "__name__"): + return f"{action_type.__module__}.{action_type.__name__}" + return str(action_type) + + except Exception as e: + logger.warning(f"[Controller] Failed to get action type for {device_id}/{action_name}: {str(e)}") + + return None + + +def job_add(req: JobAddReq) -> JobData: + """添加任务(检查设备是否繁忙,繁忙则返回失败) + + Args: + req: 任务添加请求 + + Returns: + JobData: 任务数据(包含状态) + """ + # 服务端自动生成 job_id 和 task_id + job_id = str(uuid.uuid4()) + task_id = str(uuid.uuid4()) + + # 服务端自动生成 server_info + server_info = {"send_timestamp": time.time()} + + host_node = HostNode.get_instance(0) + if host_node is None: + logger.error(f"[Controller] Host node not initialized for job: {job_id[:8]}") + return JobData(jobId=job_id, status=6) # 6 = ABORTED + + # 解析动作信息 + action_name = req.data.get("action", req.action) if req.data else req.action + action_args = req.data.get("action_kwargs") or req.data.get("action_args") if req.data else req.action_args + + if action_args is None: + action_args = req.action_args or {} + elif isinstance(action_args, dict) and "command" in action_args: + action_args = action_args["command"] + + # 自动获取 action_type + action_type = _get_action_type(req.device_id, action_name) + if action_type is None: + logger.error(f"[Controller] Action type not found for {req.device_id}/{action_name}") + return JobData(jobId=job_id, status=6) # ABORTED + + # 检查设备动作是否繁忙 + is_busy, current_job_id = check_device_action_busy(req.device_id, action_name) + + if is_busy: + logger.warning( + f"[Controller] Device action busy: {req.device_id}/{action_name}, " + f"current job: {current_job_id[:8] if current_job_id else 'unknown'}" + ) + # 返回失败状态,status=6 表示 ABORTED + return JobData(jobId=job_id, status=6) + + # 设备空闲,提交任务执行 + try: + from unilabos.app.ws_client import QueueItem + + device_action_key = f"/devices/{req.device_id}/{action_name}" + queue_item = QueueItem( + task_type="job_call_back_status", + device_id=req.device_id, + action_name=action_name, + task_id=task_id, + job_id=job_id, + device_action_key=device_action_key, + ) + + host_node.send_goal( + queue_item, + action_type=action_type, + action_kwargs=action_args, + server_info=server_info, + ) + + logger.info(f"[Controller] Job submitted: {job_id[:8]} -> {req.device_id}/{action_name}") + # 返回已接受状态,status=1 表示 ACCEPTED + return JobData(jobId=job_id, status=1) + + except ValueError as e: + # ActionClient not found 等错误 + logger.error(f"[Controller] Action not available: {str(e)}") + return JobData(jobId=job_id, status=6) # ABORTED + + except Exception as e: + logger.error(f"[Controller] Error submitting job: {str(e)}") + traceback.print_exc() + return JobData(jobId=job_id, status=6) # ABORTED + + +def get_online_devices() -> Tuple[bool, Dict[str, Any]]: + """获取在线设备列表 + + Returns: + Tuple[bool, Dict]: (是否成功, 在线设备信息) + """ + host_node = HostNode.get_instance(0) + if host_node is None: + return False, {"error": "Host node not initialized"} + + try: + from unilabos.ros.nodes.base_device_node import registered_devices + + online_devices = {} + for device_key in host_node._online_devices: + # device_key 格式: "namespace/device_id" + parts = device_key.split("/") + if len(parts) >= 2: + device_id = parts[-1] + else: + device_id = device_key + + # 获取设备详细信息 + device_info = registered_devices.get(device_id, {}) + machine_name = host_node.device_machine_names.get(device_id, "未知") + + online_devices[device_id] = { + "device_key": device_key, + "namespace": host_node.devices_names.get(device_id, ""), + "machine_name": machine_name, + "uuid": device_info.get("uuid", "") if device_info else "", + "node_name": device_info.get("node_name", "") if device_info else "", + } + + return True, { + "online_devices": online_devices, + "total_count": len(online_devices), + "timestamp": time.time(), + } + + except Exception as e: + logger.error(f"[Controller] Error getting online devices: {str(e)}") + traceback.print_exc() + return False, {"error": str(e)} + + +def get_device_actions(device_id: str) -> Tuple[bool, Dict[str, Any]]: + """获取设备可用的动作列表 + + Args: + device_id: 设备ID + + Returns: + Tuple[bool, Dict]: (是否成功, 动作列表信息) + """ + host_node = HostNode.get_instance(0) + if host_node is None: + return False, {"error": "Host node not initialized"} + + try: + from unilabos.ros.nodes.base_device_node import registered_devices + from unilabos.app.web.utils.action_utils import get_action_info + + # 检查设备是否已注册 + if device_id not in registered_devices: + return False, {"error": f"Device not found: {device_id}"} + + device_info = registered_devices[device_id] + actions = device_info.get("actions", {}) + + actions_list = {} + for action_name, action_server in actions.items(): + try: + action_info = get_action_info(action_server, action_name) + # 检查动作是否繁忙 + is_busy, current_job = check_device_action_busy(device_id, action_name) + actions_list[action_name] = { + **action_info, + "is_busy": is_busy, + "current_job_id": current_job[:8] if current_job else None, + } + except Exception as e: + logger.warning(f"[Controller] Error getting action info for {action_name}: {str(e)}") + actions_list[action_name] = { + "type_name": "unknown", + "action_path": f"/devices/{device_id}/{action_name}", + "is_busy": False, + "error": str(e), + } + + return True, { + "device_id": device_id, + "actions": actions_list, + "action_count": len(actions_list), + } + + except Exception as e: + logger.error(f"[Controller] Error getting device actions: {str(e)}") + traceback.print_exc() + return False, {"error": str(e)} + + +def get_action_schema(device_id: str, action_name: str) -> Tuple[bool, Dict[str, Any]]: + """获取动作的Schema详情 + + Args: + device_id: 设备ID + action_name: 动作名称 + + Returns: + Tuple[bool, Dict]: (是否成功, Schema信息) + """ + host_node = HostNode.get_instance(0) + if host_node is None: + return False, {"error": "Host node not initialized"} + + try: + from unilabos.registry.registry import lab_registry + from unilabos.ros.nodes.base_device_node import registered_devices + + result = { + "device_id": device_id, + "action_name": action_name, + "schema": None, + "goal_default": None, + "action_type": None, + "is_busy": False, + } + + # 检查动作是否繁忙 + is_busy, current_job = check_device_action_busy(device_id, action_name) + result["is_busy"] = is_busy + result["current_job_id"] = current_job[:8] if current_job else None + + # 方法1: 从 registered_devices 获取运行时信息 + if device_id in registered_devices: + device_info = registered_devices[device_id] + base_node = device_info.get("base_node_instance") + + if base_node and hasattr(base_node, "_action_value_mappings"): + action_mappings = base_node._action_value_mappings + if action_name in action_mappings: + mapping = action_mappings[action_name] + result["schema"] = mapping.get("schema") + result["goal_default"] = mapping.get("goal_default") + result["action_type"] = str(mapping.get("type", "")) + + # 方法2: 从 lab_registry 获取注册表信息(如果运行时没有) + if result["schema"] is None and lab_registry: + # 尝试查找设备类型 + devices_config = host_node.devices_config + device_class = None + + # 从配置中获取设备类型 + for tree in devices_config.trees: + node = tree.root_node + if node.res_content.id == device_id: + device_class = node.res_content.klass + break + + if device_class and device_class in lab_registry.device_type_registry: + device_type_info = lab_registry.device_type_registry[device_class] + class_info = device_type_info.get("class", {}) + action_mappings = class_info.get("action_value_mappings", {}) + + # 尝试直接匹配或 auto- 前缀匹配 + for key in [action_name, f"auto-{action_name}"]: + if key in action_mappings: + mapping = action_mappings[key] + result["schema"] = mapping.get("schema") + result["goal_default"] = mapping.get("goal_default") + result["action_type"] = str(mapping.get("type", "")) + result["handles"] = mapping.get("handles", {}) + result["placeholder_keys"] = mapping.get("placeholder_keys", {}) + break + + if result["schema"] is None: + return False, {"error": f"Action schema not found: {device_id}/{action_name}"} + + return True, result + + except Exception as e: + logger.error(f"[Controller] Error getting action schema: {str(e)}") + traceback.print_exc() + return False, {"error": str(e)} + + +def get_all_available_actions() -> Tuple[bool, Dict[str, Any]]: + """获取所有设备的可用动作 + + Returns: + Tuple[bool, Dict]: (是否成功, 所有设备的动作信息) + """ + host_node = HostNode.get_instance(0) + if host_node is None: + return False, {"error": "Host node not initialized"} + + try: + from unilabos.ros.nodes.base_device_node import registered_devices + from unilabos.app.web.utils.action_utils import get_action_info + + all_actions = {} + total_action_count = 0 + + for device_id, device_info in registered_devices.items(): + actions = device_info.get("actions", {}) + device_actions = {} + + for action_name, action_server in actions.items(): + try: + action_info = get_action_info(action_server, action_name) + is_busy, current_job = check_device_action_busy(device_id, action_name) + device_actions[action_name] = { + "type_name": action_info.get("type_name", ""), + "action_path": action_info.get("action_path", ""), + "is_busy": is_busy, + "current_job_id": current_job[:8] if current_job else None, + } + total_action_count += 1 + except Exception as e: + logger.warning(f"[Controller] Error processing action {device_id}/{action_name}: {str(e)}") + + if device_actions: + all_actions[device_id] = { + "actions": device_actions, + "action_count": len(device_actions), + "machine_name": host_node.device_machine_names.get(device_id, "未知"), + } + + return True, { + "devices": all_actions, + "device_count": len(all_actions), + "total_action_count": total_action_count, + "timestamp": time.time(), + } + + except Exception as e: + logger.error(f"[Controller] Error getting all available actions: {str(e)}") + traceback.print_exc() + return False, {"error": str(e)} diff --git a/unilabos/app/ws_client.py b/unilabos/app/ws_client.py index d9f1a585..9a9d6cda 100644 --- a/unilabos/app/ws_client.py +++ b/unilabos/app/ws_client.py @@ -261,29 +261,28 @@ class DeviceActionManager: device_key = job_info.device_action_key # 如果是正在执行的任务 - if ( - device_key in self.active_jobs and self.active_jobs[device_key].job_id == job_id - ): # 后面需要和cancel_goal进行联动,而不是在这里进行处理,现在默认等待这个job结束 - # del self.active_jobs[device_key] - # job_info.status = JobStatus.ENDED - # # 从all_jobs中移除 - # del self.all_jobs[job_id] - # job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name) - # logger.info(f"[DeviceActionManager] Active job {job_log} cancelled for {device_key}") + if device_key in self.active_jobs and self.active_jobs[device_key].job_id == job_id: + # 清理active job状态 + del self.active_jobs[device_key] + job_info.status = JobStatus.ENDED + # 从all_jobs中移除 + del self.all_jobs[job_id] + job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name) + logger.info(f"[DeviceActionManager] Active job {job_log} cancelled for {device_key}") - # # 启动下一个任务 - # if device_key in self.device_queues and self.device_queues[device_key]: - # next_job = self.device_queues[device_key].pop(0) - # # 将下一个job设置为READY状态并放入active_jobs - # next_job.status = JobStatus.READY - # next_job.update_timestamp() - # next_job.set_ready_timeout(10) - # self.active_jobs[device_key] = next_job - # next_job_log = format_job_log(next_job.job_id, next_job.task_id, - # next_job.device_id, next_job.action_name) - # logger.info(f"[DeviceActionManager] Next job {next_job_log} can start after cancel") - # return True - pass + # 启动下一个任务 + if device_key in self.device_queues and self.device_queues[device_key]: + next_job = self.device_queues[device_key].pop(0) + # 将下一个job设置为READY状态并放入active_jobs + next_job.status = JobStatus.READY + next_job.update_timestamp() + next_job.set_ready_timeout(10) + self.active_jobs[device_key] = next_job + next_job_log = format_job_log( + next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name + ) + logger.info(f"[DeviceActionManager] Next job {next_job_log} can start after cancel") + return True # 如果是排队中的任务 elif device_key in self.device_queues: @@ -360,6 +359,7 @@ class MessageProcessor: self.device_manager = device_manager self.queue_processor = None # 延迟设置 self.websocket_client = None # 延迟设置 + self.session_id = "" # WebSocket连接 self.websocket = None @@ -389,7 +389,7 @@ class MessageProcessor: self.is_running = True self.thread = threading.Thread(target=self._run, daemon=True, name="MessageProcessor") self.thread.start() - logger.info("[MessageProcessor] Started") + logger.trace("[MessageProcessor] Started") def stop(self) -> None: """停止消息处理线程""" @@ -428,14 +428,17 @@ class MessageProcessor: ssl=ssl_context, ping_interval=WSConfig.ping_interval, ping_timeout=10, - additional_headers={"Authorization": f"Lab {BasicConfig.auth_secret()}"}, + additional_headers={ + "Authorization": f"Lab {BasicConfig.auth_secret()}", + "EdgeSession": f"{self.session_id}", + }, logger=ws_logger, ) as websocket: self.websocket = websocket self.connected = True self.reconnect_count = 0 - logger.info(f"[MessageProcessor] Connected to {self.websocket_url}") + logger.trace(f"[MessageProcessor] Connected to {self.websocket_url}") # 启动发送协程 send_task = asyncio.create_task(self._send_handler()) @@ -500,7 +503,7 @@ class MessageProcessor: async def _send_handler(self): """处理发送队列中的消息""" - logger.debug("[MessageProcessor] Send handler started") + logger.trace("[MessageProcessor] Send handler started") try: while self.connected and self.websocket: @@ -573,6 +576,9 @@ class MessageProcessor: await self._handle_resource_tree_update(message_data, "update") elif message_type == "remove_material": await self._handle_resource_tree_update(message_data, "remove") + elif message_type == "session_id": + self.session_id = message_data.get("session_id") + logger.info(f"[MessageProcessor] Session ID: {self.session_id}") else: logger.debug(f"[MessageProcessor] Unknown message type: {message_type}") @@ -741,31 +747,51 @@ class MessageProcessor: job_info.action_name if job_info else "", ) - # 按job_id取消单个job + # 先通知HostNode取消ROS2 action(如果存在) + host_node = HostNode.get_instance(0) + ros_cancel_success = False + if host_node: + ros_cancel_success = host_node.cancel_goal(job_id) + if ros_cancel_success: + logger.info(f"[MessageProcessor] ROS2 cancel request sent for job {job_log}") + else: + logger.debug( + f"[MessageProcessor] Job {job_log} not in ROS2 goals " "(may be queued or already finished)" + ) + + # 按job_id取消单个job(清理状态机) success = self.device_manager.cancel_job(job_id) if success: - # 通知HostNode取消 - host_node = HostNode.get_instance(0) - if host_node: - host_node.cancel_goal(job_id) - logger.info(f"[MessageProcessor] Job {job_log} cancelled") + logger.info(f"[MessageProcessor] Job {job_log} cancelled from queue/active list") # 通知QueueProcessor有队列更新 if self.queue_processor: self.queue_processor.notify_queue_update() else: - logger.warning(f"[MessageProcessor] Failed to cancel job {job_log}") + logger.warning(f"[MessageProcessor] Failed to cancel job {job_log} from queue") elif task_id: - # 按task_id取消所有相关job + # 先通知HostNode取消所有ROS2 actions + # 需要先获取所有相关job_ids + jobs_to_cancel = [] + with self.device_manager.lock: + jobs_to_cancel = [ + job_info for job_info in self.device_manager.all_jobs.values() if job_info.task_id == task_id + ] + + host_node = HostNode.get_instance(0) + if host_node and jobs_to_cancel: + ros_cancelled_count = 0 + for job_info in jobs_to_cancel: + if host_node.cancel_goal(job_info.job_id): + ros_cancelled_count += 1 + logger.info( + f"[MessageProcessor] Sent ROS2 cancel for " f"{ros_cancelled_count}/{len(jobs_to_cancel)} jobs" + ) + + # 按task_id取消所有相关job(清理状态机) cancelled_job_ids = self.device_manager.cancel_jobs_by_task_id(task_id) if cancelled_job_ids: - # 通知HostNode取消所有job - host_node = HostNode.get_instance(0) - if host_node: - for cancelled_job_id in cancelled_job_ids: - host_node.cancel_goal(cancelled_job_id) - logger.info(f"[MessageProcessor] Cancelled {len(cancelled_job_ids)} jobs for task_id: {task_id}") # 通知QueueProcessor有队列更新 @@ -913,7 +939,7 @@ class QueueProcessor: # 事件通知机制 self.queue_update_event = threading.Event() - logger.info("[QueueProcessor] Initialized") + logger.trace("[QueueProcessor] Initialized") def set_websocket_client(self, websocket_client: "WebSocketClient"): """设置WebSocket客户端引用""" @@ -928,7 +954,7 @@ class QueueProcessor: self.is_running = True self.thread = threading.Thread(target=self._run, daemon=True, name="QueueProcessor") self.thread.start() - logger.info("[QueueProcessor] Started") + logger.trace("[QueueProcessor] Started") def stop(self) -> None: """停止队列处理线程""" @@ -939,7 +965,7 @@ class QueueProcessor: def _run(self): """运行队列处理主循环""" - logger.debug("[QueueProcessor] Queue processor started") + logger.trace("[QueueProcessor] Queue processor started") while self.is_running: try: @@ -1056,11 +1082,19 @@ class QueueProcessor: """处理任务完成""" # 获取job信息用于日志 job_info = self.device_manager.get_job_info(job_id) + + # 如果job不存在,说明可能已被手动取消 + if not job_info: + logger.debug( + f"[QueueProcessor] Job {job_id[:8]} not found in manager " "(may have been cancelled manually)" + ) + return + job_log = format_job_log( job_id, - job_info.task_id if job_info else "", - job_info.device_id if job_info else "", - job_info.action_name if job_info else "", + job_info.task_id, + job_info.device_id, + job_info.action_name, ) logger.info(f"[QueueProcessor] Job {job_log} completed with status: {status}") @@ -1141,7 +1175,6 @@ class WebSocketClient(BaseCommunicationClient): else: url = f"{scheme}://{parsed.netloc}/api/v1/ws/schedule" - logger.debug(f"[WebSocketClient] URL: {url}") return url def start(self) -> None: @@ -1154,13 +1187,11 @@ class WebSocketClient(BaseCommunicationClient): logger.error("[WebSocketClient] WebSocket URL not configured") return - logger.info(f"[WebSocketClient] Starting connection to {self.websocket_url}") - # 启动两个核心线程 self.message_processor.start() self.queue_processor.start() - logger.info("[WebSocketClient] All threads started") + logger.trace("[WebSocketClient] All threads started") def stop(self) -> None: """停止WebSocket客户端""" @@ -1169,6 +1200,18 @@ class WebSocketClient(BaseCommunicationClient): logger.info("[WebSocketClient] Stopping connection") + # 发送 normal_exit 消息 + if self.is_connected(): + try: + session_id = self.message_processor.session_id + message = {"action": "normal_exit", "data": {"session_id": session_id}} + self.message_processor.send_message(message) + logger.info(f"[WebSocketClient] Sent normal_exit message with session_id: {session_id}") + # 给一点时间让消息发送出去 + time.sleep(1) + except Exception as e: + logger.warning(f"[WebSocketClient] Failed to send normal_exit message: {str(e)}") + # 停止两个核心线程 self.message_processor.stop() self.queue_processor.stop() @@ -1268,3 +1311,19 @@ class WebSocketClient(BaseCommunicationClient): logger.info(f"[WebSocketClient] Job {job_log} cancelled successfully") else: logger.warning(f"[WebSocketClient] Failed to cancel job {job_log}") + + def publish_host_ready(self) -> None: + """发布host_node ready信号""" + if self.is_disabled or not self.is_connected(): + logger.debug("[WebSocketClient] Not connected, cannot publish host ready signal") + return + + message = { + "action": "host_node_ready", + "data": { + "status": "ready", + "timestamp": time.time(), + }, + } + self.message_processor.send_message(message) + logger.info("[WebSocketClient] Host node ready signal published") diff --git a/unilabos/config/config.py b/unilabos/config/config.py index b5bc6191..1e409662 100644 --- a/unilabos/config/config.py +++ b/unilabos/config/config.py @@ -18,7 +18,11 @@ class BasicConfig: vis_2d_enable = False enable_resource_load = True communication_protocol = "websocket" - log_level: Literal['TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = "DEBUG" # 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL' + startup_json_path = None # 填写绝对路径 + disable_browser = False # 禁止浏览器自动打开 + port = 8002 # 本地HTTP服务 + # 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL' + log_level: Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "DEBUG" @classmethod def auth_secret(cls): @@ -36,18 +40,9 @@ class WSConfig: ping_interval = 30 # ping间隔(秒) -# 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" + remote_addr = "https://uni-lab.bohrium.com/api/v1" # ROS配置 @@ -71,13 +66,14 @@ def _update_config_from_module(module): if not attr.startswith("_"): setattr(obj, attr, getattr(getattr(module, name), attr)) + def _update_config_from_env(): prefix = "UNILABOS_" for env_key, env_value in os.environ.items(): if not env_key.startswith(prefix): continue try: - key_path = env_key[len(prefix):] # Remove UNILAB_ prefix + key_path = env_key[len(prefix) :] # Remove UNILAB_ prefix class_field = key_path.upper().split("_", 1) if len(class_field) != 2: logger.warning(f"[ENV] 环境变量格式不正确:{env_key}") diff --git a/unilabos/device_comms/opcua_client/README.md b/unilabos/device_comms/opcua_client/README.md new file mode 100644 index 00000000..a32735e8 --- /dev/null +++ b/unilabos/device_comms/opcua_client/README.md @@ -0,0 +1,19 @@ +# OPC UA 通用客户端 + +本模块提供了一个通用的 OPC UA 客户端实现,可以通过外部配置(CSV文件)来定义节点,并通过JSON配置来执行工作流。 + +## 特点 + +- 支持通过 CSV 文件配置 OPC UA 节点(只需提供名称、类型和数据类型,支持节点为中文名,需指定NodeLanguage) +- 自动查找服务器中的节点,无需知道确切的节点ID +- 提供工作流机制 +- 支持通过 JSON 配置创建工作流 + +## 使用方法 + +step1: 准备opcua_nodes.csv文件 +step2: 编写opcua_workflow_example.json,以定义工作流。指定opcua_nodes.csv +step3: 编写工作流对应action +step4: 编写opcua_example.yaml注册表 +step5: 编写opcua_example.json组态图。指定opcua_workflow_example.json定义工作流文件 + diff --git a/unilabos/device_comms/opcua_client/__init__.py b/unilabos/device_comms/opcua_client/__init__.py new file mode 100644 index 00000000..b3bf28da --- /dev/null +++ b/unilabos/device_comms/opcua_client/__init__.py @@ -0,0 +1,9 @@ +from unilabos.device_comms.opcua_client.node.uniopcua import Variable, Method, Object, NodeType, DataType + +__all__ = [ + 'Variable', + 'Method', + 'Object', + 'NodeType', + 'DataType', +] \ No newline at end of file diff --git a/unilabos/device_comms/opcua_client/client.py b/unilabos/device_comms/opcua_client/client.py new file mode 100644 index 00000000..011ce07e --- /dev/null +++ b/unilabos/device_comms/opcua_client/client.py @@ -0,0 +1,1380 @@ +import json +import time +import traceback +from typing import Any, Union, List, Dict, Callable, Optional, Tuple +from pydantic import BaseModel + +from opcua import Client, ua +from opcua.ua import NodeClass +import pandas as pd +import os + +from unilabos.device_comms.opcua_client.node.uniopcua import Base as OpcUaNodeBase +from unilabos.device_comms.opcua_client.node.uniopcua import Variable, Method, NodeType, DataType +from unilabos.device_comms.universal_driver import UniversalDriver +from unilabos.utils.log import logger + + +class OpcUaNode(BaseModel): + name: str + node_type: NodeType + node_id: str = "" + data_type: Optional[DataType] = None + parent_node_id: Optional[str] = None + + +class OpcUaWorkflow(BaseModel): + name: str + actions: List[ + Union[ + "OpcUaWorkflow", + Callable[ + [Callable[[str], OpcUaNodeBase]], + None + ]] + ] + + +class Action(BaseModel): + name: str + rw: bool # read是0 write是1 + + +class WorkflowAction(BaseModel): + init: Optional[Callable[[Callable[[str], OpcUaNodeBase]], bool]] = None + start: Optional[Callable[[Callable[[str], OpcUaNodeBase]], bool]] = None + stop: Optional[Callable[[Callable[[str], OpcUaNodeBase]], bool]] = None + cleanup: Optional[Callable[[Callable[[str], OpcUaNodeBase]], None]] = None + + +class OpcUaWorkflowModel(BaseModel): + name: str + actions: List[Union["OpcUaWorkflowModel", WorkflowAction]] + parameters: Optional[List[str]] = None + description: Optional[str] = None + + +""" 前后端Json解析用 """ +class NodeFunctionJson(BaseModel): + func_name: str + node_name: str + mode: str # read, write, call + value: Any = None + + +class InitFunctionJson(NodeFunctionJson): + pass + + +class StartFunctionJson(NodeFunctionJson): + write_functions: List[str] + condition_functions: List[str] + stop_condition_expression: str + + +class StopFunctionJson(NodeFunctionJson): + pass + + +class CleanupFunctionJson(NodeFunctionJson): + pass + + +class ActionJson(BaseModel): + node_function_to_create: List[NodeFunctionJson] + create_init_function: Optional[InitFunctionJson] = None + create_start_function: Optional[StartFunctionJson] = None + create_stop_function: Optional[StopFunctionJson] = None + create_cleanup_function: Optional[CleanupFunctionJson] = None + + +class SimplifiedActionJson(BaseModel): + """简化的动作JSON格式,直接定义节点列表和函数""" + nodes: Optional[Dict[str, Dict[str, Any]]] = None # 节点定义,格式为 {func_name: {node_name, mode, value}} + init_function: Optional[Dict[str, Any]] = None + start_function: Optional[Dict[str, Any]] = None + stop_function: Optional[Dict[str, Any]] = None + cleanup_function: Optional[Dict[str, Any]] = None + + +class WorkflowCreateJson(BaseModel): + name: str + action: List[Union[ActionJson, SimplifiedActionJson, 'WorkflowCreateJson', str]] + parameters: Optional[List[str]] = None + description: Optional[str] = None + + +class ExecuteProcedureJson(BaseModel): + register_node_list_from_csv_path: Optional[Dict[str, Any]] = None + create_flow: List[WorkflowCreateJson] + execute_flow: List[str] + + +class BaseClient(UniversalDriver): + client: Optional[Client] = None + _node_registry: Dict[str, OpcUaNodeBase] = {} + DEFAULT_ADDRESS_PATH = "" + _variables_to_find: Dict[str, Dict[str, Any]] = {} + _name_mapping: Dict[str, str] = {} # 英文名到中文名的映射 + _reverse_mapping: Dict[str, str] = {} # 中文名到英文名的映射 + + def __init__(self): + super().__init__() + # 自动查找节点功能默认开启 + self._auto_find_nodes = True + # 初始化名称映射字典 + self._name_mapping = {} + self._reverse_mapping = {} + + def _set_client(self, client: Optional[Client]) -> None: + if client is None: + raise ValueError('client is not valid') + self.client = client + + def _connect(self) -> None: + logger.info('try to connect client...') + if self.client: + try: + self.client.connect() + logger.info('client connected!') + + # 连接后开始查找节点 + if self._variables_to_find: + self._find_nodes() + except Exception as e: + logger.error(f'client connect failed: {e}') + raise + else: + raise ValueError('client is not initialized') + + def _find_nodes(self) -> None: + """查找服务器中的节点""" + if not self.client: + raise ValueError('client is not connected') + + logger.info('开始查找节点...') + try: + # 获取根节点 + root = self.client.get_root_node() + objects = root.get_child(["0:Objects"]) + + # 查找节点 + self._find_nodes_recursive(objects) + + # 检查是否所有节点都已找到 + not_found = [] + for var_name, var_info in self._variables_to_find.items(): + if var_name not in self._node_registry: + not_found.append(var_name) + + if not_found: + logger.warning(f"以下节点未找到: {', '.join(not_found)}") + else: + logger.info("所有节点均已找到") + + except Exception as e: + logger.error(f"查找节点失败: {e}") + traceback.print_exc() + + def _find_nodes_recursive(self, node) -> None: + """递归查找节点""" + try: + # 获取当前节点的浏览名称 + browse_name = node.get_browse_name() + node_name = browse_name.Name + + # 检查是否是我们要找的变量 + if node_name in self._variables_to_find and node_name not in self._node_registry: + var_info = self._variables_to_find[node_name] + node_type = var_info.get("node_type") + data_type = var_info.get("data_type") + + # 根据节点类型创建相应的对象 + if node_type == NodeType.VARIABLE: + self._node_registry[node_name] = Variable(self.client, node_name, str(node.nodeid), data_type) + logger.info(f"找到变量节点: {node_name}") + elif node_type == NodeType.METHOD: + # 对于方法节点,需要获取父节点ID + parent_node = node.get_parent() + parent_node_id = str(parent_node.nodeid) + self._node_registry[node_name] = Method(self.client, node_name, str(node.nodeid), parent_node_id, data_type) + logger.info(f"找到方法节点: {node_name}") + + # 递归处理子节点 + for child in node.get_children(): + self._find_nodes_recursive(child) + + except Exception as e: + # 忽略处理单个节点时的错误,继续处理其他节点 + pass + + @classmethod + def load_csv(cls, file_path: str) -> List[OpcUaNode]: + """ + 从CSV文件加载节点定义 + CSV文件需包含Name,NodeType,DataType列 + 可选包含EnglishName和NodeLanguage列 + """ + df = pd.read_csv(file_path) + df = df.drop_duplicates(subset='Name', keep='first') # 重复的数据应该报错 + nodes = [] + + # 检查是否包含英文名称列和节点语言列 + has_english_name = 'EnglishName' in df.columns + has_node_language = 'NodeLanguage' in df.columns + + # 如果存在英文名称列,创建名称映射字典 + name_mapping = {} + reverse_mapping = {} + + for _, row in df.iterrows(): + name = row.get('Name') + node_type_str = row.get('NodeType') + data_type_str = row.get('DataType') + + # 获取英文名称和节点语言(如果有) + english_name = row.get('EnglishName') if has_english_name else None + node_language = row.get('NodeLanguage') if has_node_language else 'English' # 默认为英文 + + # 如果有英文名称,添加到映射字典 + if english_name and not pd.isna(english_name) and node_language == 'Chinese': + name_mapping[english_name] = name + reverse_mapping[name] = english_name + + if not name or not node_type_str: + logger.warning(f"跳过无效行: 名称或节点类型缺失") + continue + + # 只支持VARIABLE和METHOD两种类型 + if node_type_str not in ['VARIABLE', 'METHOD']: + logger.warning(f"不支持的节点类型: {node_type_str},仅支持VARIABLE和METHOD") + continue + + try: + node_type = NodeType[node_type_str] + except KeyError: + logger.warning(f"无效的节点类型: {node_type_str}") + continue + + # 对于VARIABLE节点,必须指定数据类型 + if node_type == NodeType.VARIABLE: + if not data_type_str or pd.isna(data_type_str): + logger.warning(f"变量节点 {name} 必须指定数据类型") + continue + + try: + data_type = DataType[data_type_str] + except KeyError: + logger.warning(f"无效的数据类型: {data_type_str}") + continue + else: + # 对于METHOD节点,数据类型可选 + data_type = None + if data_type_str and not pd.isna(data_type_str): + try: + data_type = DataType[data_type_str] + except KeyError: + logger.warning(f"无效的数据类型: {data_type_str},将使用默认值") + + # 创建节点对象,节点ID留空,将通过自动查找功能获取 + nodes.append(OpcUaNode( + name=name, + node_type=node_type, + data_type=data_type + )) + + # 返回节点列表和名称映射字典 + return nodes, name_mapping, reverse_mapping + + def use_node(self, name: str) -> OpcUaNodeBase: + """ + 获取已注册的节点 + 如果节点尚未找到,会尝试再次查找 + 支持使用英文名称访问中文节点 + """ + # 检查是否使用英文名称访问中文节点 + if name in self._name_mapping: + chinese_name = self._name_mapping[name] + if chinese_name in self._node_registry: + return self._node_registry[chinese_name] + elif chinese_name in self._variables_to_find: + logger.warning(f"节点 {chinese_name} (英文名: {name}) 尚未找到,尝试重新查找") + if self.client: + self._find_nodes() + if chinese_name in self._node_registry: + return self._node_registry[chinese_name] + raise ValueError(f'节点 {chinese_name} (英文名: {name}) 未注册或未找到') + + # 直接使用原始名称查找 + if name not in self._node_registry: + if name in self._variables_to_find: + logger.warning(f"节点 {name} 尚未找到,尝试重新查找") + if self.client: + self._find_nodes() + if name in self._node_registry: + return self._node_registry[name] + raise ValueError(f'节点 {name} 未注册或未找到') + return self._node_registry[name] + + def get_node_registry(self) -> Dict[str, OpcUaNodeBase]: + return self._node_registry + + def register_node_list_from_csv_path(self, path: str = None) -> "BaseClient": + """从CSV文件注册节点""" + if path is None: + path = self.DEFAULT_ADDRESS_PATH + nodes, name_mapping, reverse_mapping = self.load_csv(path) + self._name_mapping.update(name_mapping) + self._reverse_mapping.update(reverse_mapping) + return self.register_node_list(nodes) + + def register_node_list(self, node_list: List[OpcUaNode]) -> "BaseClient": + """注册节点列表""" + if not node_list or len(node_list) == 0: + logger.warning('节点列表为空') + return self + + logger.info(f'开始注册 {len(node_list)} 个节点...') + for node in node_list: + if node is None: + continue + + if node.name in self._node_registry: + logger.info(f'节点 {node.name} 已存在') + exist = self._node_registry[node.name] + if exist.type != node.node_type: + raise ValueError(f'节点 {node.name} 类型 {node.node_type} 与已存在的类型 {exist.type} 不一致') + continue + + # 将节点添加到待查找列表 + self._variables_to_find[node.name] = { + "node_type": node.node_type, + "data_type": node.data_type + } + logger.info(f'添加节点 {node.name} 到待查找列表') + + logger.info('节点注册完成') + + # 如果客户端已连接,立即开始查找 + if self.client: + self._find_nodes() + + return self + + def run_opcua_workflow(self, workflow: OpcUaWorkflow) -> 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, OpcUaWorkflow): + self.run_opcua_workflow(action) + elif callable(action): + action(self.use_node) + else: + raise ValueError(f'invalid action {action}') + + def call_lifecycle_fn( + self, + workflow: OpcUaWorkflowModel, + fn: Optional[Callable[[Callable], bool]], + ) -> bool: + if not fn: + raise ValueError('fn is not valid in call_lifecycle_fn') + try: + result = fn(self.use_node) + # 处理函数返回值可能是元组的情况 + if isinstance(result, tuple) and len(result) == 2: + # 第二个元素是错误标志,True表示出错,False表示成功 + value, error_flag = result + return not error_flag # 转换成True表示成功,False表示失败 + return result + except Exception as e: + traceback.print_exc() + logger.error(f'execute {workflow.name} lifecycle failed, err: {e}') + return False + + def run_opcua_workflow_model(self, workflow: OpcUaWorkflowModel) -> 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, OpcUaWorkflowModel): + if self.run_opcua_workflow_model(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], OpcUaNodeBase]], bool]] = {} + + def create_node_function(self, func_name: str = None, node_name: str = None, mode: str = None, value: Any = None, **kwargs) -> Callable[[Callable[[str], OpcUaNodeBase]], bool]: + def execute_node_function(use_node: Callable[[str], OpcUaNodeBase]) -> Union[bool, Tuple[Any, bool]]: + target_node = use_node(node_name) + + # 检查是否有对应的参数值可用 + current_value = value + if hasattr(self, '_workflow_params') and func_name in self._workflow_params: + current_value = self._workflow_params[func_name] + print(f"使用参数值 {func_name} = {current_value}") + else: + print(f"执行 {node_name}, {type(target_node).__name__}, {target_node.node_id}, {mode}, {current_value}") + + if mode == 'read': + result_str = self.read_node(node_name) + + try: + # 将字符串转换为字典 + result_str = result_str.replace("'", '"') # 替换单引号为双引号以便JSON解析 + result_dict = json.loads(result_str) + + # 从字典获取值和错误标志 + val = result_dict.get("value") + err = result_dict.get("error") + + print(f"读取 {node_name} 返回值 = {val} (类型: {type(val).__name__}), 错误 = {err}") + return val, err + except Exception as e: + print(f"解析读取结果失败: {e}, 原始结果: {result_str}") + return None, True + elif mode == 'write': + # 构造完整的JSON输入,包含node_name和value + input_json = json.dumps({"node_name": node_name, "value": current_value}) + result_str = self.write_node(input_json) + + try: + # 解析返回的字符串为字典 + result_str = result_str.replace("'", '"') # 替换单引号为双引号以便JSON解析 + result = json.loads(result_str) + success = result.get("success", False) + print(f"写入 {node_name} = {current_value}, 结果 = {success}") + return success + except Exception as e: + print(f"解析写入结果失败: {e}, 原始结果: {result_str}") + return False + elif mode == 'call' and hasattr(target_node, 'call'): + args = current_value if isinstance(current_value, list) else [current_value] + result = target_node.call(*args) + print(f"调用方法 {node_name} 参数 = {args}, 返回值 = {result}") + return result + return False + + if func_name is None: + func_name = f"{node_name}_{mode}_{str(value)}" + + print(f"创建 node function: {mode}, {func_name}") + self.function_name[func_name] = execute_node_function + + return execute_node_function + + def create_init_function(self, func_name: str = None, write_nodes: Union[Dict[str, Any], List[str]] = None): + """ + 创建初始化函数 + + 参数: + func_name: 函数名称 + write_nodes: 写节点配置,可以是节点名列表[节点1,节点2]或节点值映射{节点1:值1,节点2:值2} + 值可以是具体值,也可以是参数名称字符串(将从_workflow_params中查找) + """ + if write_nodes is None: + raise ValueError("必须提供write_nodes参数") + + def execute_init_function(use_node: Callable[[str], OpcUaNodeBase]) -> bool: + if isinstance(write_nodes, list): + # 处理节点列表 + for node_name in write_nodes: + # 尝试从参数中获取同名参数的值 + current_value = True # 默认值 + if hasattr(self, '_workflow_params') and node_name in self._workflow_params: + current_value = self._workflow_params[node_name] + print(f"初始化函数: 从参数获取值 {node_name} = {current_value}") + + print(f"初始化函数: 写入节点 {node_name} = {current_value}") + input_json = json.dumps({"node_name": node_name, "value": current_value}) + result_str = self.write_node(input_json) + try: + result_str = result_str.replace("'", '"') + result = json.loads(result_str) + success = result.get("success", False) + print(f"初始化函数: 写入结果 = {success}") + except Exception as e: + print(f"初始化函数: 解析写入结果失败: {e}, 原始结果: {result_str}") + elif isinstance(write_nodes, dict): + # 处理节点字典,使用指定的值 + for node_name, node_value in write_nodes.items(): + # 检查值是否是字符串类型的参数名 + current_value = node_value + if isinstance(node_value, str) and hasattr(self, '_workflow_params') and node_value in self._workflow_params: + current_value = self._workflow_params[node_value] + print(f"初始化函数: 从参数获取值 {node_value} = {current_value}") + + print(f"初始化函数: 写入节点 {node_name} = {current_value}") + input_json = json.dumps({"node_name": node_name, "value": current_value}) + result_str = self.write_node(input_json) + try: + result_str = result_str.replace("'", '"') + result = json.loads(result_str) + success = result.get("success", False) + print(f"初始化函数: 写入结果 = {success}") + except Exception as e: + print(f"初始化函数: 解析写入结果失败: {e}, 原始结果: {result_str}") + return True + + if func_name is None: + func_name = f"init_function_{str(time.time())}" + + print(f"创建初始化函数: {func_name}") + self.function_name[func_name] = execute_init_function + return execute_init_function + + def create_stop_function(self, func_name: str = None, write_nodes: Union[Dict[str, Any], List[str]] = None): + """ + 创建停止函数 + + 参数: + func_name: 函数名称 + write_nodes: 写节点配置,可以是节点名列表[节点1,节点2]或节点值映射{节点1:值1,节点2:值2} + """ + if write_nodes is None: + raise ValueError("必须提供write_nodes参数") + + def execute_stop_function(use_node: Callable[[str], OpcUaNodeBase]) -> bool: + if isinstance(write_nodes, list): + # 处理节点列表,默认值都是False + for node_name in write_nodes: + # 直接写入False + print(f"停止函数: 写入节点 {node_name} = False") + input_json = json.dumps({"node_name": node_name, "value": False}) + result_str = self.write_node(input_json) + try: + result_str = result_str.replace("'", '"') + result = json.loads(result_str) + success = result.get("success", False) + print(f"停止函数: 写入结果 = {success}") + except Exception as e: + print(f"停止函数: 解析写入结果失败: {e}, 原始结果: {result_str}") + elif isinstance(write_nodes, dict): + # 处理节点字典,使用指定的值 + for node_name, node_value in write_nodes.items(): + print(f"停止函数: 写入节点 {node_name} = {node_value}") + input_json = json.dumps({"node_name": node_name, "value": node_value}) + result_str = self.write_node(input_json) + try: + result_str = result_str.replace("'", '"') + result = json.loads(result_str) + success = result.get("success", False) + print(f"停止函数: 写入结果 = {success}") + except Exception as e: + print(f"停止函数: 解析写入结果失败: {e}, 原始结果: {result_str}") + return True + + if func_name is None: + func_name = f"stop_function_{str(time.time())}" + + print(f"创建停止函数: {func_name}") + self.function_name[func_name] = execute_stop_function + return execute_stop_function + + def create_cleanup_function(self, func_name: str = None, write_nodes: Union[Dict[str, Any], List[str]] = None): + """ + 创建清理函数 + + 参数: + func_name: 函数名称 + write_nodes: 写节点配置,可以是节点名列表[节点1,节点2]或节点值映射{节点1:值1,节点2:值2} + """ + if write_nodes is None: + raise ValueError("必须提供write_nodes参数") + + def execute_cleanup_function(use_node: Callable[[str], OpcUaNodeBase]) -> bool: + if isinstance(write_nodes, list): + # 处理节点列表,默认值都是False + for node_name in write_nodes: + # 直接写入False + print(f"清理函数: 写入节点 {node_name} = False") + input_json = json.dumps({"node_name": node_name, "value": False}) + result_str = self.write_node(input_json) + try: + result_str = result_str.replace("'", '"') + result = json.loads(result_str) + success = result.get("success", False) + print(f"清理函数: 写入结果 = {success}") + except Exception as e: + print(f"清理函数: 解析写入结果失败: {e}, 原始结果: {result_str}") + elif isinstance(write_nodes, dict): + # 处理节点字典,使用指定的值 + for node_name, node_value in write_nodes.items(): + print(f"清理函数: 写入节点 {node_name} = {node_value}") + input_json = json.dumps({"node_name": node_name, "value": node_value}) + result_str = self.write_node(input_json) + try: + result_str = result_str.replace("'", '"') + result = json.loads(result_str) + success = result.get("success", False) + print(f"清理函数: 写入结果 = {success}") + except Exception as e: + print(f"清理函数: 解析写入结果失败: {e}, 原始结果: {result_str}") + return True + + if func_name is None: + func_name = f"cleanup_function_{str(time.time())}" + + print(f"创建清理函数: {func_name}") + self.function_name[func_name] = execute_cleanup_function + return execute_cleanup_function + + def create_start_function(self, func_name: str, stop_condition_expression: str = "True", write_nodes: Union[Dict[str, Any], List[str]] = None, condition_nodes: Union[Dict[str, str], List[str]] = None): + """ + 创建开始函数 + + 参数: + func_name: 函数名称 + stop_condition_expression: 停止条件表达式,可直接引用节点名称 + write_nodes: 写节点配置,可以是节点名列表[节点1,节点2]或节点值映射{节点1:值1,节点2:值2} + condition_nodes: 条件节点列表 [节点名1, 节点名2] + """ + def execute_start_function(use_node: Callable[[str], OpcUaNodeBase]) -> bool: + # 直接处理写入节点 + if write_nodes: + if isinstance(write_nodes, list): + # 处理节点列表,默认值都是True + for i, node_name in enumerate(write_nodes): + # 尝试获取与节点对应的参数值 + param_name = f"write_{i}" + + # 获取参数值(如果有) + current_value = True # 默认值 + if hasattr(self, '_workflow_params') and param_name in self._workflow_params: + current_value = self._workflow_params[param_name] + + # 直接写入节点 + print(f"直接写入节点 {node_name} = {current_value}") + input_json = json.dumps({"node_name": node_name, "value": current_value}) + result_str = self.write_node(input_json) + try: + result_str = result_str.replace("'", '"') + result = json.loads(result_str) + success = result.get("success", False) + print(f"直接写入 {node_name} = {current_value}, 结果: {success}") + except Exception as e: + print(f"解析直接写入结果失败: {e}, 原始结果: {result_str}") + elif isinstance(write_nodes, dict): + # 处理节点字典,值是指定的 + for node_name, node_value in write_nodes.items(): + # 尝试获取参数值(如果节点名与参数名匹配) + current_value = node_value # 使用指定的默认值 + if hasattr(self, '_workflow_params') and node_name in self._workflow_params: + current_value = self._workflow_params[node_name] + + # 直接写入节点 + print(f"直接写入节点 {node_name} = {current_value}") + input_json = json.dumps({"node_name": node_name, "value": current_value}) + result_str = self.write_node(input_json) + try: + result_str = result_str.replace("'", '"') + result = json.loads(result_str) + success = result.get("success", False) + print(f"直接写入 {node_name} = {current_value}, 结果: {success}") + except Exception as e: + print(f"解析直接写入结果失败: {e}, 原始结果: {result_str}") + + # 如果没有条件节点,立即返回 + if not condition_nodes: + return True + + # 处理条件检查和等待 + while True: + next_loop = False + condition_source = {} + + # 直接读取条件节点 + if isinstance(condition_nodes, list): + # 处理节点列表 + for i, node_name in enumerate(condition_nodes): + # 直接读取节点 + result_str = self.read_node(node_name) + try: + result_str = result_str.replace("'", '"') + result_dict = json.loads(result_str) + read_res = result_dict.get("value") + read_err = result_dict.get("error", False) + print(f"直接读取 {node_name} 返回值 = {read_res}, 错误 = {read_err}") + + if read_err: + next_loop = True + break + + # 将节点值存入条件源字典,使用节点名称作为键 + condition_source[node_name] = read_res + # 为了向后兼容,也保留read_i格式 + condition_source[f"read_{i}"] = read_res + except Exception as e: + print(f"解析直接读取结果失败: {e}, 原始结果: {result_str}") + read_res, read_err = None, True + next_loop = True + break + elif isinstance(condition_nodes, dict): + # 处理节点字典 + for condition_func, node_name in condition_nodes.items(): + # 直接读取节点 + result_str = self.read_node(node_name) + try: + result_str = result_str.replace("'", '"') + result_dict = json.loads(result_str) + read_res = result_dict.get("value") + read_err = result_dict.get("error", False) + print(f"直接读取 {node_name} 返回值 = {read_res}, 错误 = {read_err}") + + if read_err: + next_loop = True + break + + # 将节点值存入条件源字典 + condition_source[node_name] = read_res + # 也保存使用函数名作为键 + condition_source[condition_func] = read_res + except Exception as e: + print(f"解析直接读取结果失败: {e}, 原始结果: {result_str}") + next_loop = True + break + + if not next_loop: + if stop_condition_expression: + # 添加调试信息 + print(f"条件源数据: {condition_source}") + condition_source["__RESULT"] = None + + # 确保安全地执行条件表达式 + try: + # 先尝试使用eval更安全的方式计算表达式 + result = eval(stop_condition_expression, {}, condition_source) + condition_source["__RESULT"] = result + except Exception as e: + print(f"使用eval执行表达式失败: {e}") + try: + # 回退到exec方式 + exec(f"__RESULT = {stop_condition_expression}", {}, condition_source) + except Exception as e2: + print(f"使用exec执行表达式也失败: {e2}") + condition_source["__RESULT"] = False + + res = condition_source["__RESULT"] + print(f"取得计算结果: {res}, 条件表达式: {stop_condition_expression}") + + if res: + print("满足停止条件,结束工作流") + break + else: + # 如果没有停止条件,直接退出 + break + else: + time.sleep(0.3) + + return True + + self.function_name[func_name] = execute_start_function + return execute_start_function + + create_action_from_json = None + + def create_action_from_json(self, data: Union[Dict, Any]) -> WorkflowAction: + """ + 从JSON配置创建工作流动作 + + 参数: + data: 动作JSON数据 + + 返回: + WorkflowAction对象 + """ + # 初始化所需变量 + start_function = None + write_nodes = {} + condition_nodes = [] + stop_function = None + init_function = None + cleanup_function = None + + # 提取start_function相关信息 + if hasattr(data, "start_function") and data.start_function: + start_function = data.start_function + if "write_nodes" in start_function: + write_nodes = start_function["write_nodes"] + if "condition_nodes" in start_function: + condition_nodes = start_function["condition_nodes"] + elif isinstance(data, dict) and data.get("start_function"): + start_function = data.get("start_function") + if "write_nodes" in start_function: + write_nodes = start_function["write_nodes"] + if "condition_nodes" in start_function: + condition_nodes = start_function["condition_nodes"] + + # 提取stop_function信息 + if hasattr(data, "stop_function") and data.stop_function: + stop_function = data.stop_function + elif isinstance(data, dict) and data.get("stop_function"): + stop_function = data.get("stop_function") + + # 提取init_function信息 + if hasattr(data, "init_function") and data.init_function: + init_function = data.init_function + elif isinstance(data, dict) and data.get("init_function"): + init_function = data.get("init_function") + + # 提取cleanup_function信息 + if hasattr(data, "cleanup_function") and data.cleanup_function: + cleanup_function = data.cleanup_function + elif isinstance(data, dict) and data.get("cleanup_function"): + cleanup_function = data.get("cleanup_function") + + # 创建工作流动作组件 + init = None + start = None + stop = None + cleanup = None + + # 处理init function + if init_function: + init_params = {"func_name": init_function.get("func_name")} + if "write_nodes" in init_function: + init_params["write_nodes"] = init_function["write_nodes"] + else: + # 如果没有write_nodes,创建一个空字典 + init_params["write_nodes"] = {} + + init = self.create_init_function(**init_params) + + # 处理start function + if start_function: + start_params = { + "func_name": start_function.get("func_name"), + "stop_condition_expression": start_function.get("stop_condition_expression", "True"), + "write_nodes": write_nodes, + "condition_nodes": condition_nodes + } + start = self.create_start_function(**start_params) + + # 处理stop function + if stop_function: + stop_params = { + "func_name": stop_function.get("func_name"), + "write_nodes": stop_function.get("write_nodes", {}) + } + stop = self.create_stop_function(**stop_params) + + # 处理cleanup function + if cleanup_function: + cleanup_params = { + "func_name": cleanup_function.get("func_name"), + "write_nodes": cleanup_function.get("write_nodes", {}) + } + cleanup = self.create_cleanup_function(**cleanup_params) + + return WorkflowAction(init=init, start=start, stop=stop, cleanup=cleanup) + + workflow_name: Dict[str, OpcUaWorkflowModel] = {} + + def create_workflow_from_json(self, data: List[Dict]) -> None: + """ + 从JSON配置创建工作流程序 + + 参数: + data: 工作流配置列表 + """ + for ind, flow_dict in enumerate(data): + print(f"正在创建 workflow {ind}, {flow_dict['name']}") + actions = [] + + for i in flow_dict["action"]: + if isinstance(i, str): + print(f"沿用已有 workflow 作为 action: {i}") + action = self.workflow_name[i] + else: + print("创建 action") + # 直接将字典转换为SimplifiedActionJson对象或直接使用字典 + action = self.create_action_from_json(i) + + actions.append(action) + + # 获取参数 + parameters = flow_dict.get("parameters", []) + + flow_instance = OpcUaWorkflowModel( + name=flow_dict["name"], + actions=actions, + parameters=parameters, + description=flow_dict.get("description", "") + ) + print(f"创建完成 workflow: {flow_dict['name']}") + self.workflow_name[flow_dict["name"]] = flow_instance + + def execute_workflow_from_json(self, data: List[str]) -> None: + for i in data: + print(f"正在执行 workflow: {i}") + self.run_opcua_workflow_model(self.workflow_name[i]) + + def execute_procedure_from_json(self, data: Union[ExecuteProcedureJson, Dict]) -> None: + """从JSON配置执行工作流程序""" + if isinstance(data, dict): + # 处理字典类型 + register_params = data.get("register_node_list_from_csv_path") + create_flow = data.get("create_flow", []) + execute_flow = data.get("execute_flow", []) + else: + # 处理Pydantic模型类型 + register_params = data.register_node_list_from_csv_path + create_flow = data.create_flow + execute_flow = data.execute_flow if hasattr(data, "execute_flow") else [] + + # 注册节点 + if register_params: + print(f"注册节点 csv: {register_params}") + self.register_node_list_from_csv_path(**register_params) + + # 创建工作流 + print("创建工作流") + self.create_workflow_from_json(create_flow) + + # 注册工作流为实例方法 + self.register_workflows_as_methods() + + # 如果存在execute_flow字段,则执行指定的工作流(向后兼容) + if execute_flow: + print("执行工作流") + self.execute_workflow_from_json(execute_flow) + + def register_workflows_as_methods(self) -> None: + """将工作流注册为实例方法""" + for workflow_name, workflow in self.workflow_name.items(): + # 获取工作流的参数信息(如果存在) + workflow_params = getattr(workflow, 'parameters', []) or [] + workflow_desc = getattr(workflow, 'description', None) or f"执行工作流: {workflow_name}" + + # 创建执行工作流的方法 + def create_workflow_method(wf_name=workflow_name, wf=workflow, params=workflow_params): + def workflow_method(*args, **kwargs): + logger.info(f"执行工作流: {wf_name}, 参数: {args}, {kwargs}") + + # 处理传入的参数 + if params and (args or kwargs): + # 将位置参数转换为关键字参数 + params_dict = {} + for i, param_name in enumerate(params): + if i < len(args): + params_dict[param_name] = args[i] + + # 合并关键字参数 + params_dict.update(kwargs) + + # 保存参数,供节点函数使用 + self._workflow_params = params_dict + else: + self._workflow_params = {} + + # 执行工作流 + result = self.run_opcua_workflow_model(wf) + + # 清理参数 + self._workflow_params = {} + + return result + + # 设置方法的文档字符串 + workflow_method.__doc__ = workflow_desc + if params: + param_doc = ", ".join(params) + workflow_method.__doc__ += f"\n参数: {param_doc}" + + return workflow_method + + # 注册为实例方法 + method = create_workflow_method() + setattr(self, workflow_name, method) + logger.info(f"已将工作流 '{workflow_name}' 注册为实例方法") + + def read_node(self, node_name: str) -> Dict[str, Any]: + """ + 读取节点值的便捷方法 + 返回包含result字段的字典 + """ + try: + node = self.use_node(node_name) + value, error = node.read() + + # 创建结果字典 + result = { + "value": value, + "error": error, + "node_name": node_name, + "timestamp": time.time() + } + + # 返回JSON字符串 + return json.dumps(result) + except Exception as e: + logger.error(f"读取节点 {node_name} 失败: {e}") + # 创建错误结果字典 + result = { + "value": None, + "error": True, + "node_name": node_name, + "error_message": str(e), + "timestamp": time.time() + } + return json.dumps(result) + + def write_node(self, json_input: str) -> str: + """ + 写入节点值的便捷方法 + 接受单个JSON格式的字符串作为输入,包含节点名称和值 + eg:'{\"node_name\":\"反应罐号码\",\"value\":\"2\"}' + 返回JSON格式的字符串,包含操作结果 + """ + try: + # 解析JSON格式的输入 + if not isinstance(json_input, str): + json_input = str(json_input) + + try: + input_data = json.loads(json_input) + if not isinstance(input_data, dict): + return json.dumps({"error": True, "error_message": "输入必须是包含node_name和value的JSON对象", "success": False}) + + # 从JSON中提取节点名称和值 + node_name = input_data.get("node_name") + value = input_data.get("value") + + if node_name is None: + return json.dumps({"error": True, "error_message": "JSON中缺少node_name字段", "success": False}) + except json.JSONDecodeError as e: + return json.dumps({"error": True, "error_message": f"JSON解析错误: {str(e)}", "success": False}) + + node = self.use_node(node_name) + error = node.write(value) + + # 创建结果字典 + result = { + "value": value, + "error": error, + "node_name": node_name, + "timestamp": time.time(), + "success": not error + } + + return json.dumps(result) + except Exception as e: + logger.error(f"写入节点失败: {e}") + result = { + "error": True, + "error_message": str(e), + "timestamp": time.time(), + "success": False + } + return json.dumps(result) + + def call_method(self, node_name: str, *args) -> Tuple[Any, bool]: + """ + 调用方法节点的便捷方法 + 返回 (返回值, 是否出错) + """ + try: + node = self.use_node(node_name) + if hasattr(node, 'call'): + return node.call(*args) + else: + logger.error(f"节点 {node_name} 不是方法节点") + return None, True + except Exception as e: + logger.error(f"调用方法 {node_name} 失败: {e}") + return None, True + + +class OpcUaClient(BaseClient): + def __init__(self, url: str, config_path: str = None, username: str = None, password: str = None, refresh_interval: float = 1.0): + # 降低OPCUA库的日志级别 + import logging + logging.getLogger("opcua").setLevel(logging.WARNING) + + super().__init__() + + client = Client(url) + + if username and password: + client.set_user(username) + client.set_password(password) + + self._set_client(client) + self._connect() + + # 节点值缓存和刷新相关属性 + self._node_values = {} # 缓存节点值 + self._refresh_interval = refresh_interval # 刷新间隔(秒) + self._refresh_running = False + self._refresh_thread = None + + # 如果提供了配置文件路径,则加载配置并注册工作流 + if config_path: + self.load_config(config_path) + + # 启动节点值刷新线程 + self.start_node_refresh() + + def _register_nodes_as_attributes(self): + """将所有节点注册为实例属性,可以通过self.node_name访问""" + for node_name, node in self._node_registry.items(): + # 检查是否有对应的英文名称 + eng_name = self._reverse_mapping.get(node_name) + if eng_name: + # 如果有对应的英文名称,使用英文名称作为属性名 + attr_name = eng_name + else: + # 如果没有对应的英文名称,使用原始名称,但替换空格和特殊字符 + attr_name = node_name.replace(' ', '_').replace('-', '_') + + # 创建获取节点值的属性方法,使用中文名称获取节点值 + def create_property_getter(node_key): + def getter(self): + # 优先从缓存获取值 + if node_key in self._node_values: + return self._node_values[node_key] + # 缓存中没有则直接读取 + value, _ = self.use_node(node_key).read() + return value + return getter + + # 使用property装饰器将方法注册为类属性 + setattr(OpcUaClient, attr_name, property(create_property_getter(node_name))) + logger.info(f"已注册节点 '{node_name}' 为属性 '{attr_name}'") + + def refresh_node_values(self): + """刷新所有节点的值到缓存""" + if not self.client: + logger.warning("客户端未初始化,无法刷新节点值") + return + + try: + # 简单检查连接状态,如果不连接会抛出异常 + self.client.get_namespace_array() + except Exception as e: + logger.warning(f"客户端连接异常,无法刷新节点值: {e}") + return + + for node_name, node in self._node_registry.items(): + try: + if hasattr(node, 'read'): + value, error = node.read() + if not error: + self._node_values[node_name] = value + #logger.debug(f"已刷新节点 '{node_name}' 的值: {value}") + except Exception as e: + logger.error(f"刷新节点 '{node_name}' 失败: {e}") + + def get_node_value(self, name): + """获取节点值,支持中文名和英文名""" + # 如果提供的是英文名,转换为中文名 + if name in self._name_mapping: + chinese_name = self._name_mapping[name] + # 优先从缓存获取值 + if chinese_name in self._node_values: + return self._node_values[chinese_name] + # 缓存中没有则直接读取 + value, _ = self.use_node(chinese_name).read() + return value + # 如果提供的是中文名,直接使用 + elif name in self._node_registry: + # 优先从缓存获取值 + if name in self._node_values: + return self._node_values[name] + # 缓存中没有则直接读取 + value, _ = self.use_node(name).read() + return value + else: + raise ValueError(f"未找到名称为 '{name}' 的节点") + + def set_node_value(self, name, value): + """设置节点值,支持中文名和英文名""" + # 如果提供的是英文名,转换为中文名 + if name in self._name_mapping: + chinese_name = self._name_mapping[name] + node = self.use_node(chinese_name) + # 如果提供的是中文名,直接使用 + elif name in self._node_registry: + node = self.use_node(name) + else: + raise ValueError(f"未找到名称为 '{name}' 的节点") + + # 写入值 + error = node.write(value) + if not error: + # 更新缓存 + if hasattr(node, 'name'): + self._node_values[node.name] = value + return True + return False + + def _refresh_worker(self): + """节点值刷新线程的工作函数""" + self._refresh_running = True + logger.info(f"节点值刷新线程已启动,刷新间隔: {self._refresh_interval}秒") + + while self._refresh_running: + try: + self.refresh_node_values() + except Exception as e: + logger.error(f"节点值刷新过程出错: {e}") + + # 等待下一次刷新 + time.sleep(self._refresh_interval) + + def start_node_refresh(self): + """启动节点值刷新线程""" + if self._refresh_thread is not None and self._refresh_thread.is_alive(): + logger.warning("节点值刷新线程已在运行") + return + + import threading + self._refresh_thread = threading.Thread(target=self._refresh_worker, daemon=True) + self._refresh_thread.start() + + def stop_node_refresh(self): + """停止节点值刷新线程""" + self._refresh_running = False + if self._refresh_thread and self._refresh_thread.is_alive(): + self._refresh_thread.join(timeout=2.0) + logger.info("节点值刷新线程已停止") + + def load_config(self, config_path: str) -> None: + """从JSON配置文件加载并注册工作流""" + try: + with open(config_path, 'r', encoding='utf-8') as f: + config_data = json.load(f) + + # 处理节点注册 + if "register_node_list_from_csv_path" in config_data: + # 获取配置文件所在目录 + config_dir = os.path.dirname(os.path.abspath(config_path)) + + # 处理CSV路径,如果是相对路径,则相对于配置文件所在目录 + if "path" in config_data["register_node_list_from_csv_path"]: + csv_path = config_data["register_node_list_from_csv_path"]["path"] + if not os.path.isabs(csv_path): + # 转换为绝对路径 + csv_path = os.path.join(config_dir, csv_path) + config_data["register_node_list_from_csv_path"]["path"] = csv_path + + # 直接使用字典 + self.register_node_list_from_csv_path(**config_data["register_node_list_from_csv_path"]) + + # 处理工作流创建 + if "create_flow" in config_data: + # 直接传递字典列表 + self.create_workflow_from_json(config_data["create_flow"]) + # 将工作流注册为实例方法 + self.register_workflows_as_methods() + + # 将所有节点注册为属性 + self._register_nodes_as_attributes() + + logger.info(f"成功从 {config_path} 加载配置") + except Exception as e: + logger.error(f"加载配置文件 {config_path} 失败: {e}") + traceback.print_exc() + + def disconnect(self): + # 停止刷新线程 + self.stop_node_refresh() + + if self.client: + self.client.disconnect() + logger.info("OPC UA client disconnected") + + +if __name__ == '__main__': + # 示例用法 + + # 使用配置文件创建客户端并自动注册工作流 + import os + current_dir = os.path.dirname(os.path.abspath(__file__)) + config_path = os.path.join(current_dir, "opcua_huairou.json") + + # 创建OPC UA客户端并加载配置 + try: + client = OpcUaClient( + url="opc.tcp://localhost:4840/freeopcua/server/", # 替换为实际的OPC UA服务器地址 + config_path=config_path # 传入配置文件路径 + ) + + # 列出所有已注册的工作流 + print("\n已注册的工作流:") + for workflow_name in client.workflow_name: + print(f" - {workflow_name}") + + # 测试trigger_grab_action工作流 - 使用英文参数名 + print("\n测试trigger_grab_action工作流 - 使用英文参数名:") + client.trigger_grab_action(reaction_tank_number=2, raw_tank_number=3) + + # 读取节点值 - 使用英文节点名 + grab_complete = client.get_node_value("grab_complete") + reaction_tank = client.get_node_value("reaction_tank_number") + raw_tank = client.get_node_value("raw_tank_number") + + print(f"\n执行后状态检查 (使用英文节点名):") + print(f" - 抓取完成状态: {grab_complete}") + print(f" - 当前反应罐号码: {reaction_tank}") + print(f" - 当前原料罐号码: {raw_tank}") + + # 测试节点值写入 - 使用英文节点名 + print("\n测试节点值写入 (使用英文节点名):") + success = client.set_node_value("atomization_fast_speed", 150.5) + print(f" - 写入搅拌浆雾化快速 = 150.5, 结果: {success}") + + # 读取写入的值 + atomization_speed = client.get_node_value("atomization_fast_speed") + print(f" - 读取搅拌浆雾化快速: {atomization_speed}") + + # 断开连接 + client.disconnect() + + except Exception as e: + print(f"错误: {e}") + traceback.print_exc() + + diff --git a/unilabos/device_comms/opcua_client/node/__init__.py b/unilabos/device_comms/opcua_client/node/__init__.py new file mode 100644 index 00000000..968e9056 --- /dev/null +++ b/unilabos/device_comms/opcua_client/node/__init__.py @@ -0,0 +1,10 @@ +from unilabos.device_comms.opcua_client.node.uniopcua import Variable, Method, Object, NodeType, DataType, Base + +__all__ = [ + 'Variable', + 'Method', + 'Object', + 'NodeType', + 'DataType', + 'Base', +] \ No newline at end of file diff --git a/unilabos/device_comms/opcua_client/node/uniopcua.py b/unilabos/device_comms/opcua_client/node/uniopcua.py new file mode 100644 index 00000000..ce16cfc4 --- /dev/null +++ b/unilabos/device_comms/opcua_client/node/uniopcua.py @@ -0,0 +1,180 @@ +# coding=utf-8 +from enum import Enum +from abc import ABC, abstractmethod +from typing import Tuple, Union, Optional, Any, List + +from opcua import Client, Node +from opcua.ua import NodeId, NodeClass, VariantType + + +class DataType(Enum): + BOOLEAN = VariantType.Boolean + SBYTE = VariantType.SByte + BYTE = VariantType.Byte + INT16 = VariantType.Int16 + UINT16 = VariantType.UInt16 + INT32 = VariantType.Int32 + UINT32 = VariantType.UInt32 + INT64 = VariantType.Int64 + UINT64 = VariantType.UInt64 + FLOAT = VariantType.Float + DOUBLE = VariantType.Double + STRING = VariantType.String + DATETIME = VariantType.DateTime + BYTESTRING = VariantType.ByteString + + +class NodeType(Enum): + VARIABLE = NodeClass.Variable + OBJECT = NodeClass.Object + METHOD = NodeClass.Method + OBJECTTYPE = NodeClass.ObjectType + VARIABLETYPE = NodeClass.VariableType + REFERENCETYPE = NodeClass.ReferenceType + DATATYPE = NodeClass.DataType + VIEW = NodeClass.View + + +class Base(ABC): + def __init__(self, client: Client, name: str, node_id: str, typ: NodeType, data_type: DataType): + self._node_id: str = node_id + self._client = client + self._name = name + self._type = typ + self._data_type = data_type + self._node: Optional[Node] = None + + def _get_node(self) -> Node: + if self._node is None: + try: + # 检查是否是NumericNodeId(ns=X;i=Y)格式 + if "NumericNodeId" in self._node_id: + # 从字符串中提取命名空间和标识符 + import re + match = re.search(r'ns=(\d+);i=(\d+)', self._node_id) + if match: + ns = int(match.group(1)) + identifier = int(match.group(2)) + node_id = NodeId(identifier, ns) + self._node = self._client.get_node(node_id) + else: + raise ValueError(f"无法解析节点ID: {self._node_id}") + else: + # 直接使用节点ID字符串 + self._node = self._client.get_node(self._node_id) + except Exception as e: + print(f"获取节点失败: {self._node_id}, 错误: {e}") + raise + return self._node + + @abstractmethod + def read(self) -> Tuple[Any, bool]: + """读取节点值,返回(值, 是否出错)""" + pass + + @abstractmethod + def write(self, value: Any) -> bool: + """写入节点值,返回是否出错""" + pass + + @property + def type(self) -> NodeType: + return self._type + + @property + def node_id(self) -> str: + return self._node_id + + @property + def name(self) -> str: + return self._name + + +class Variable(Base): + def __init__(self, client: Client, name: str, node_id: str, data_type: DataType): + super().__init__(client, name, node_id, NodeType.VARIABLE, data_type) + + def read(self) -> Tuple[Any, bool]: + try: + value = self._get_node().get_value() + return value, False + except Exception as e: + print(f"读取变量 {self._name} 失败: {e}") + return None, True + + def write(self, value: Any) -> bool: + try: + self._get_node().set_value(value) + return False + except Exception as e: + print(f"写入变量 {self._name} 失败: {e}") + return True + + +class Method(Base): + def __init__(self, client: Client, name: str, node_id: str, parent_node_id: str, data_type: DataType): + super().__init__(client, name, node_id, NodeType.METHOD, data_type) + self._parent_node_id = parent_node_id + self._parent_node = None + + def _get_parent_node(self) -> Node: + if self._parent_node is None: + try: + # 检查是否是NumericNodeId(ns=X;i=Y)格式 + if "NumericNodeId" in self._parent_node_id: + # 从字符串中提取命名空间和标识符 + import re + match = re.search(r'ns=(\d+);i=(\d+)', self._parent_node_id) + if match: + ns = int(match.group(1)) + identifier = int(match.group(2)) + node_id = NodeId(identifier, ns) + self._parent_node = self._client.get_node(node_id) + else: + raise ValueError(f"无法解析父节点ID: {self._parent_node_id}") + else: + # 直接使用节点ID字符串 + self._parent_node = self._client.get_node(self._parent_node_id) + except Exception as e: + print(f"获取父节点失败: {self._parent_node_id}, 错误: {e}") + raise + return self._parent_node + + def read(self) -> Tuple[Any, bool]: + """方法节点不支持读取操作""" + return None, True + + def write(self, value: Any) -> bool: + """方法节点不支持写入操作""" + return True + + def call(self, *args) -> Tuple[Any, bool]: + """调用方法,返回(返回值, 是否出错)""" + try: + result = self._get_parent_node().call_method(self._get_node(), *args) + return result, False + except Exception as e: + print(f"调用方法 {self._name} 失败: {e}") + return None, True + + +class Object(Base): + def __init__(self, client: Client, name: str, node_id: str): + super().__init__(client, name, node_id, NodeType.OBJECT, None) + + def read(self) -> Tuple[Any, bool]: + """对象节点不支持直接读取操作""" + return None, True + + def write(self, value: Any) -> bool: + """对象节点不支持直接写入操作""" + return True + + def get_children(self) -> Tuple[List[Node], bool]: + """获取子节点列表,返回(子节点列表, 是否出错)""" + try: + children = self._get_node().get_children() + return children, False + except Exception as e: + print(f"获取对象 {self._name} 的子节点失败: {e}") + return [], True \ No newline at end of file diff --git a/unilabos/device_comms/opcua_client/opcua_config.json b/unilabos/device_comms/opcua_client/opcua_config.json new file mode 100644 index 00000000..16771a38 --- /dev/null +++ b/unilabos/device_comms/opcua_client/opcua_config.json @@ -0,0 +1,98 @@ +{ + "register_node_list_from_csv_path": { + "path": "simple_opcua_nodes.csv" + }, + "create_flow": [ + { + "name": "温度控制流程", + "action": [ + { + "name": "温度控制动作", + "node_function_to_create": [ + { + "func_name": "read_temperature", + "node_name": "Temperature", + "mode": "read" + }, + { + "func_name": "read_heating_status", + "node_name": "HeatingStatus", + "mode": "read" + }, + { + "func_name": "set_heating", + "node_name": "HeatingEnabled", + "mode": "write", + "value": true + } + ], + "create_init_function": { + "func_name": "init_setpoint", + "node_name": "Setpoint", + "mode": "write", + "value": 25.0 + }, + "create_start_function": { + "func_name": "start_heating_control", + "node_name": "HeatingEnabled", + "mode": "write", + "write_functions": [ + "set_heating" + ], + "condition_functions": [ + "read_temperature", + "read_heating_status" + ], + "stop_condition_expression": "read_temperature >= 25.0 and read_heating_status" + }, + "create_stop_function": { + "func_name": "stop_heating", + "node_name": "HeatingEnabled", + "mode": "write", + "value": false + }, + "create_cleanup_function": null + } + ] + }, + { + "name": "报警重置流程", + "action": [ + { + "name": "报警重置动作", + "node_function_to_create": [ + { + "func_name": "reset_alarm", + "node_name": "ResetAlarm", + "mode": "call", + "value": [] + } + ], + "create_init_function": null, + "create_start_function": { + "func_name": "start_reset_alarm", + "node_name": "ResetAlarm", + "mode": "call", + "write_functions": [], + "condition_functions": [ + "reset_alarm" + ], + "stop_condition_expression": "True" + }, + "create_stop_function": null, + "create_cleanup_function": null + } + ] + }, + { + "name": "完整控制流程", + "action": [ + "温度控制流程", + "报警重置流程" + ] + } + ], + "execute_flow": [ + "完整控制流程" + ] +} \ No newline at end of file diff --git a/unilabos/device_comms/opcua_client/opcua_nodes_example.csv b/unilabos/device_comms/opcua_client/opcua_nodes_example.csv new file mode 100644 index 00000000..42458f1b --- /dev/null +++ b/unilabos/device_comms/opcua_client/opcua_nodes_example.csv @@ -0,0 +1,2 @@ +Name,EnglishName,NodeType,DataType,NodeLanguage +中文名,EnglishName,VARIABLE,INT32,Chinese diff --git a/unilabos/device_comms/opcua_client/opcua_workflow_example.json b/unilabos/device_comms/opcua_client/opcua_workflow_example.json new file mode 100644 index 00000000..d7286394 --- /dev/null +++ b/unilabos/device_comms/opcua_client/opcua_workflow_example.json @@ -0,0 +1,30 @@ +{ + "register_node_list_from_csv_path": { + "path": "opcua_nodes_example.csv" + }, + "create_flow": [ + { + "name": "name", + "description": "description", + "parameters": ["parameter1", "parameter2"], + "action": [ + { + "init_function": { + "func_name": "init_grab_params", + "write_nodes": ["parameter1", "parameter2"] + }, + "start_function": { + "func_name": "start_grab", + "write_nodes": {"parameter_start": true}, + "condition_nodes": ["parameter_condition"], + "stop_condition_expression": "parameter_condition == True" + }, + "stop_function": { + "func_name": "stop_grab", + "write_nodes": {"parameter_start": false} + } + } + ] + } + ] +} \ No newline at end of file diff --git a/unilabos/device_comms/opcua_client/server.py b/unilabos/device_comms/opcua_client/server.py new file mode 100644 index 00000000..481fd6b0 --- /dev/null +++ b/unilabos/device_comms/opcua_client/server.py @@ -0,0 +1,311 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +OPC UA测试服务器 +用于测试OPC UA客户端功能,特别是temperature_control和valve_control工作流 +""" + +import sys +import time +import logging +from opcua import Server, ua +import threading + +# 设置日志 +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +class OpcUaTestServer: + """OPC UA测试服务器类""" + + def __init__(self, endpoint="opc.tcp://localhost:4840/freeopcua/server/"): + """ + 初始化OPC UA服务器 + + Args: + endpoint: 服务器端点URL + """ + self.server = Server() + self.server.set_endpoint(endpoint) + + # 设置服务器名称 + self.server.set_server_name("UniLabOS OPC UA Test Server") + + # 设置服务器命名空间 + self.idx = self.server.register_namespace("http://unilabos.com/opcua/test") + + # 获取Objects节点 + self.objects = self.server.get_objects_node() + + # 创建设备对象 + self.device = self.objects.add_object(self.idx, "TestDevice") + + # 存储所有节点的字典 + self.nodes = {} + + # 初始化标志 + self.running = False + + # 控制标志 + self.simulation_active = True + + def add_variable(self, name, value, data_type=None): + """ + 添加变量节点 + + Args: + name: 变量名称 + value: 初始值 + data_type: 数据类型 (可选) + """ + if data_type is None: + var = self.device.add_variable(self.idx, name, value) + else: + var = self.device.add_variable(self.idx, name, value, data_type) + + # 设置变量可写 + var.set_writable() + + # 存储节点 + self.nodes[name] = var + logger.info(f"添加变量节点: {name}, 初始值: {value}") + return var + + def add_method(self, name, callback, inputs=None, outputs=None): + """ + 添加方法节点 + + Args: + name: 方法名称 + callback: 回调函数 + inputs: 输入参数列表 [(name, type), ...] + outputs: 输出参数列表 [(name, type), ...] + """ + if inputs is None: + inputs = [] + if outputs is None: + outputs = [] + + # 创建输入参数 + input_args = [] + for arg_name, arg_type in inputs: + input_args.append(ua.Argument()) + input_args[-1].Name = arg_name + input_args[-1].DataType = arg_type + input_args[-1].ValueRank = -1 + + # 创建输出参数 + output_args = [] + for arg_name, arg_type in outputs: + output_args.append(ua.Argument()) + output_args[-1].Name = arg_name + output_args[-1].DataType = arg_type + output_args[-1].ValueRank = -1 + + # 添加方法 + method = self.device.add_method( + self.idx, + name, + callback, + input_args, + output_args + ) + + # 存储节点 + self.nodes[name] = method + logger.info(f"添加方法节点: {name}") + return method + + def start(self): + """启动服务器""" + if not self.running: + self.server.start() + self.running = True + logger.info("OPC UA服务器已启动") + + # 启动模拟线程 + self.simulation_thread = threading.Thread(target=self.run_simulation) + self.simulation_thread.daemon = True + self.simulation_thread.start() + + def stop(self): + """停止服务器""" + if self.running: + self.simulation_active = False + if hasattr(self, 'simulation_thread'): + self.simulation_thread.join(timeout=2) + self.server.stop() + self.running = False + logger.info("OPC UA服务器已停止") + + def get_node(self, name): + """获取节点""" + if name in self.nodes: + return self.nodes[name] + return None + + def update_variable(self, name, value): + """更新变量值""" + if name in self.nodes: + self.nodes[name].set_value(value) + logger.debug(f"更新变量 {name} = {value}") + return True + logger.warning(f"变量 {name} 不存在") + return False + + def run_simulation(self): + """运行模拟线程""" + logger.info("启动模拟线程") + + temp = 20.0 + valve_position = 0.0 + flow_rate = 0.0 + + while self.simulation_active and self.running: + try: + # 温度控制模拟 + heating_enabled = self.get_node("HeatingEnabled").get_value() + setpoint = self.get_node("Setpoint").get_value() + + if heating_enabled: + self.update_variable("HeatingStatus", True) + if temp < setpoint: + temp += 0.5 # 加快温度上升速度 + else: + temp -= 0.1 + else: + self.update_variable("HeatingStatus", False) + if temp > 20.0: + temp -= 0.2 + + # 更新温度 + self.update_variable("Temperature", round(temp, 2)) + + # 阀门控制模拟 + valve_control = self.get_node("ValveControl").get_value() + valve_setpoint = self.get_node("ValveSetpoint").get_value() + + if valve_control: + if valve_position < valve_setpoint: + valve_position += 5.0 # 加快阀门开启速度 + if valve_position > valve_setpoint: + valve_position = valve_setpoint + else: + valve_position -= 1.0 + if valve_position < 0: + valve_position = 0 + else: + if valve_position > 0: + valve_position -= 5.0 + if valve_position < 0: + valve_position = 0 + + # 更新阀门位置 + self.update_variable("ValvePosition", round(valve_position, 2)) + + # 流量模拟 - 与阀门位置成正比 + flow_rate = valve_position * 0.2 # 简单线性关系 + self.update_variable("FlowRate", round(flow_rate, 2)) + + # 更新系统状态 + status = [] + if heating_enabled: + status.append("Heating") + if valve_control: + status.append("Valve_Open") + + if status: + self.update_variable("SystemStatus", "_".join(status)) + else: + self.update_variable("SystemStatus", "Idle") + + # 每200毫秒更新一次 + time.sleep(0.2) + + except Exception as e: + logger.error(f"模拟线程错误: {e}") + time.sleep(1) # 出错时稍等一会再继续 + + logger.info("模拟线程已停止") + +def reset_alarm_callback(parent, *args): + """重置报警的回调函数""" + logger.info("调用了重置报警方法") + return True + +def start_process_callback(parent, *args): + """启动流程的回调函数""" + process_id = args[0] if args else 0 + logger.info(f"启动流程 ID: {process_id}") + return process_id + +def stop_process_callback(parent, *args): + """停止流程的回调函数""" + process_id = args[0] if args else 0 + logger.info(f"停止流程 ID: {process_id}") + return True + +def main(): + """主函数""" + try: + # 创建服务器 + server = OpcUaTestServer() + + # 添加变量节点 - 温度控制相关 + server.add_variable("Temperature", 20.0, ua.VariantType.Float) + server.add_variable("Setpoint", 22.0, ua.VariantType.Float) + server.add_variable("HeatingEnabled", False, ua.VariantType.Boolean) + server.add_variable("HeatingStatus", False, ua.VariantType.Boolean) + + # 添加变量节点 - 阀门控制相关 + server.add_variable("ValvePosition", 0.0, ua.VariantType.Float) + server.add_variable("ValveSetpoint", 0.0, ua.VariantType.Float) + server.add_variable("ValveControl", False, ua.VariantType.Boolean) + server.add_variable("FlowRate", 0.0, ua.VariantType.Float) + + # 其他状态变量 + server.add_variable("SystemStatus", "Idle", ua.VariantType.String) + + # 添加方法节点 + server.add_method( + "ResetAlarm", + reset_alarm_callback, + [], + [("Result", ua.VariantType.Boolean)] + ) + + server.add_method( + "StartProcess", + start_process_callback, + [("ProcessId", ua.VariantType.Int32)], + [("Result", ua.VariantType.Int32)] + ) + + server.add_method( + "StopProcess", + stop_process_callback, + [("ProcessId", ua.VariantType.Int32)], + [("Result", ua.VariantType.Boolean)] + ) + + # 启动服务器 + server.start() + logger.info("服务器已启动,按Ctrl+C停止") + + # 保持服务器运行 + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + logger.info("收到键盘中断,正在停止服务器...") + + # 停止服务器 + server.stop() + + except Exception as e: + logger.error(f"服务器错误: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/unilabos/device_mesh/devices/dummy2_robot/config/default_kinematics.yaml b/unilabos/device_mesh/devices/dummy2_robot/config/default_kinematics.yaml new file mode 100644 index 00000000..a7c5ae6a --- /dev/null +++ b/unilabos/device_mesh/devices/dummy2_robot/config/default_kinematics.yaml @@ -0,0 +1,25 @@ +dummy2_robot: + kinematics: + # DH parameters for Dummy2 6-DOF robot arm + # [theta, d, a, alpha] for each joint + joint_1: [0.0, 0.1, 0.0, 1.5708] # Base rotation + joint_2: [0.0, 0.0, 0.2, 0.0] # Shoulder + joint_3: [0.0, 0.0, 0.15, 0.0] # Elbow + joint_4: [0.0, 0.1, 0.0, 1.5708] # Wrist roll + joint_5: [0.0, 0.0, 0.0, -1.5708] # Wrist pitch + joint_6: [0.0, 0.06, 0.0, 0.0] # Wrist yaw + + # Tool center point offset from last joint + tcp_offset: + x: 0.0 + y: 0.0 + z: 0.04 + + # Workspace limits + workspace: + x_min: -0.5 + x_max: 0.5 + y_min: -0.5 + y_max: 0.5 + z_min: 0.0 + z_max: 0.6 diff --git a/unilabos/device_mesh/devices/dummy2_robot/config/dummy2.srdf b/unilabos/device_mesh/devices/dummy2_robot/config/dummy2.srdf new file mode 100644 index 00000000..5b53b86f --- /dev/null +++ b/unilabos/device_mesh/devices/dummy2_robot/config/dummy2.srdf @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/unilabos/device_mesh/devices/dummy2_robot/config/dummy2.trans b/unilabos/device_mesh/devices/dummy2_robot/config/dummy2.trans new file mode 100644 index 00000000..edd3461d --- /dev/null +++ b/unilabos/device_mesh/devices/dummy2_robot/config/dummy2.trans @@ -0,0 +1,70 @@ + + + + + transmission_interface/SimpleTransmission + + hardware_interface/EffortJointInterface + + + hardware_interface/EffortJointInterface + 1 + + + + + transmission_interface/SimpleTransmission + + hardware_interface/EffortJointInterface + + + hardware_interface/EffortJointInterface + 1 + + + + + transmission_interface/SimpleTransmission + + hardware_interface/EffortJointInterface + + + hardware_interface/EffortJointInterface + 1 + + + + + transmission_interface/SimpleTransmission + + hardware_interface/EffortJointInterface + + + hardware_interface/EffortJointInterface + 1 + + + + + transmission_interface/SimpleTransmission + + hardware_interface/EffortJointInterface + + + hardware_interface/EffortJointInterface + 1 + + + + + transmission_interface/SimpleTransmission + + hardware_interface/EffortJointInterface + + + hardware_interface/EffortJointInterface + 1 + + + + diff --git a/unilabos/device_mesh/devices/dummy2_robot/config/dummy2.urdf.xacro b/unilabos/device_mesh/devices/dummy2_robot/config/dummy2.urdf.xacro new file mode 100644 index 00000000..1fb0c97b --- /dev/null +++ b/unilabos/device_mesh/devices/dummy2_robot/config/dummy2.urdf.xacro @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/unilabos/device_mesh/devices/dummy2_robot/config/dummy2_simulated_config.yaml b/unilabos/device_mesh/devices/dummy2_robot/config/dummy2_simulated_config.yaml new file mode 100644 index 00000000..4856bf1a --- /dev/null +++ b/unilabos/device_mesh/devices/dummy2_robot/config/dummy2_simulated_config.yaml @@ -0,0 +1,73 @@ +############################################### +# Modify all parameters related to servoing here +############################################### +# adapt to dummy2 by Muzhxiaowen, check out the details on bilibili.com + +use_gazebo: false # Whether the robot is started in a Gazebo simulation environment + +## Properties of incoming commands +command_in_type: "unitless" # "unitless"> in the range [-1:1], as if from joystick. "speed_units"> cmds are in m/s and rad/s +scale: + # Scale parameters are only used if command_in_type=="unitless" + linear: 0.4 # Max linear velocity. Unit is [m/s]. Only used for Cartesian commands. + rotational: 0.8 # Max angular velocity. Unit is [rad/s]. Only used for Cartesian commands. + # Max joint angular/linear velocity. Only used for joint commands on joint_command_in_topic. + joint: 0.5 + +# Optionally override Servo's internal velocity scaling when near singularity or collision (0.0 = use internal velocity scaling) +# override_velocity_scaling_factor = 0.0 # valid range [0.0:1.0] + +## Properties of outgoing commands +publish_period: 0.034 # 1/Nominal publish rate [seconds] +low_latency_mode: false # Set this to true to publish as soon as an incoming Twist command is received (publish_period is ignored) + +# What type of topic does your robot driver expect? +# Currently supported are std_msgs/Float64MultiArray or trajectory_msgs/JointTrajectory +command_out_type: trajectory_msgs/JointTrajectory + +# What to publish? Can save some bandwidth as most robots only require positions or velocities +publish_joint_positions: true +publish_joint_velocities: true +publish_joint_accelerations: false + +## Plugins for smoothing outgoing commands +smoothing_filter_plugin_name: "online_signal_smoothing::ButterworthFilterPlugin" + +# If is_primary_planning_scene_monitor is set to true, the Servo server's PlanningScene advertises the /get_planning_scene service, +# which other nodes can use as a source for information about the planning environment. +# NOTE: If a different node in your system is responsible for the "primary" planning scene instance (e.g. the MoveGroup node), +# then is_primary_planning_scene_monitor needs to be set to false. +is_primary_planning_scene_monitor: true + +## MoveIt properties +move_group_name: dummy2_arm # Often 'manipulator' or 'arm' +planning_frame: base_link # The MoveIt planning frame. Often 'base_link' or 'world' + +## Other frames +ee_frame_name: J6_1 # The name of the end effector link, used to return the EE pose +robot_link_command_frame: base_link # commands must be given in the frame of a robot link. Usually either the base or end effector + +## Stopping behaviour +incoming_command_timeout: 0.1 # Stop servoing if X seconds elapse without a new command +# If 0, republish commands forever even if the robot is stationary. Otherwise, specify num. to publish. +# Important because ROS may drop some messages and we need the robot to halt reliably. +num_outgoing_halt_msgs_to_publish: 4 + +## Configure handling of singularities and joint limits +lower_singularity_threshold: 170.0 # Start decelerating when the condition number hits this (close to singularity) +hard_stop_singularity_threshold: 3000.0 # Stop when the condition number hits this +joint_limit_margin: 0.1 # added as a buffer to joint limits [radians]. If moving quickly, make this larger. +leaving_singularity_threshold_multiplier: 2.0 # Multiply the hard stop limit by this when leaving singularity (see https://github.com/ros-planning/moveit2/pull/620) + +## Topic names +cartesian_command_in_topic: ~/delta_twist_cmds # Topic for incoming Cartesian twist commands +joint_command_in_topic: ~/delta_joint_cmds # Topic for incoming joint angle commands +joint_topic: /joint_states +status_topic: ~/status # Publish status to this topic +command_out_topic: /dummy2_arm_controller/joint_trajectory # Publish outgoing commands here + +## Collision checking for the entire robot body +check_collisions: true # Check collisions? +collision_check_rate: 10.0 # [Hz] Collision-checking can easily bog down a CPU if done too often. +self_collision_proximity_threshold: 0.001 # Start decelerating when a self-collision is this far [m] +scene_collision_proximity_threshold: 0.002 # Start decelerating when a scene collision is this far [m] diff --git a/unilabos/device_mesh/devices/dummy2_robot/config/initial_positions.yaml b/unilabos/device_mesh/devices/dummy2_robot/config/initial_positions.yaml new file mode 100644 index 00000000..841bba05 --- /dev/null +++ b/unilabos/device_mesh/devices/dummy2_robot/config/initial_positions.yaml @@ -0,0 +1,9 @@ +# Default initial positions for dummy2's ros2_control fake system + +initial_positions: + Joint1: 0 + Joint2: 0 + Joint3: 0 + Joint4: 0 + Joint5: 0 + Joint6: 0 \ No newline at end of file diff --git a/unilabos/device_mesh/devices/dummy2_robot/config/joint_limits.yaml b/unilabos/device_mesh/devices/dummy2_robot/config/joint_limits.yaml new file mode 100644 index 00000000..151fb300 --- /dev/null +++ b/unilabos/device_mesh/devices/dummy2_robot/config/joint_limits.yaml @@ -0,0 +1,40 @@ +# joint_limits.yaml allows the dynamics properties specified in the URDF to be overwritten or augmented as needed + +# For beginners, we downscale velocity and acceleration limits. +# You can always specify higher scaling factors (<= 1.0) in your motion requests. # Increase the values below to 1.0 to always move at maximum speed. +default_velocity_scaling_factor: 0.1 +default_acceleration_scaling_factor: 0.1 + +# Specific joint properties can be changed with the keys [max_position, min_position, max_velocity, max_acceleration] +# Joint limits can be turned off with [has_velocity_limits, has_acceleration_limits] +joint_limits: + joint_1: + has_velocity_limits: true + max_velocity: 2.0 + has_acceleration_limits: false + max_acceleration: 0 + joint_2: + has_velocity_limits: true + max_velocity: 2.0 + has_acceleration_limits: false + max_acceleration: 0 + joint_3: + has_velocity_limits: true + max_velocity: 2.0 + has_acceleration_limits: false + max_acceleration: 0 + joint_4: + has_velocity_limits: true + max_velocity: 2.0 + has_acceleration_limits: false + max_acceleration: 0 + joint_5: + has_velocity_limits: true + max_velocity: 2.0 + has_acceleration_limits: false + max_acceleration: 0 + joint_6: + has_velocity_limits: true + max_velocity: 2.0 + has_acceleration_limits: false + max_acceleration: 0 \ No newline at end of file diff --git a/unilabos/device_mesh/devices/dummy2_robot/config/kinematics.yaml b/unilabos/device_mesh/devices/dummy2_robot/config/kinematics.yaml new file mode 100644 index 00000000..55cefc6d --- /dev/null +++ b/unilabos/device_mesh/devices/dummy2_robot/config/kinematics.yaml @@ -0,0 +1,4 @@ +dummy2_arm: + kinematics_solver: kdl_kinematics_plugin/KDLKinematicsPlugin + kinematics_solver_search_resolution: 0.0050000000000000001 + kinematics_solver_timeout: 0.5 \ No newline at end of file diff --git a/unilabos/device_mesh/devices/dummy2_robot/config/macro.ros2_control.xacro b/unilabos/device_mesh/devices/dummy2_robot/config/macro.ros2_control.xacro new file mode 100644 index 00000000..25bba6d1 --- /dev/null +++ b/unilabos/device_mesh/devices/dummy2_robot/config/macro.ros2_control.xacro @@ -0,0 +1,60 @@ + + + + + + + + + mock_components/GenericSystem + + + + + + + ${initial_positions['Joint1']} + + + + + + + ${initial_positions['Joint2']} + + + + + + + ${initial_positions['Joint3']} + + + + + + + ${initial_positions['Joint4']} + + + + + + + ${initial_positions['Joint5']} + + + + + + + ${initial_positions['Joint6']} + + + + + + + + + \ No newline at end of file diff --git a/unilabos/device_mesh/devices/dummy2_robot/config/macro.srdf.xacro b/unilabos/device_mesh/devices/dummy2_robot/config/macro.srdf.xacro new file mode 100644 index 00000000..f1265731 --- /dev/null +++ b/unilabos/device_mesh/devices/dummy2_robot/config/macro.srdf.xacro @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/unilabos/device_mesh/devices/dummy2_robot/config/materials.xacro b/unilabos/device_mesh/devices/dummy2_robot/config/materials.xacro new file mode 100644 index 00000000..1e1fda33 --- /dev/null +++ b/unilabos/device_mesh/devices/dummy2_robot/config/materials.xacro @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/unilabos/device_mesh/devices/dummy2_robot/config/move_group.json b/unilabos/device_mesh/devices/dummy2_robot/config/move_group.json new file mode 100644 index 00000000..e2fc0c22 --- /dev/null +++ b/unilabos/device_mesh/devices/dummy2_robot/config/move_group.json @@ -0,0 +1,14 @@ +{ + "arm": { + "joint_names": [ + "joint_1", + "joint_2", + "joint_3", + "joint_4", + "joint_5", + "joint_6" + ], + "base_link_name": "base_link", + "end_effector_name": "J6_1" + } +} \ No newline at end of file diff --git a/unilabos/device_mesh/devices/dummy2_robot/config/moveit.rviz b/unilabos/device_mesh/devices/dummy2_robot/config/moveit.rviz new file mode 100644 index 00000000..99bb740f --- /dev/null +++ b/unilabos/device_mesh/devices/dummy2_robot/config/moveit.rviz @@ -0,0 +1,51 @@ +Panels: + - Class: rviz_common/Displays + Name: Displays + Property Tree Widget: + Expanded: + - /MotionPlanning1 + - Class: rviz_common/Help + Name: Help + - Class: rviz_common/Views + Name: Views +Visualization Manager: + Displays: + - Class: rviz_default_plugins/Grid + Name: Grid + Value: true + - Class: moveit_rviz_plugin/MotionPlanning + Name: MotionPlanning + Planned Path: + Loop Animation: true + State Display Time: 0.05 s + Trajectory Topic: display_planned_path + Planning Scene Topic: monitored_planning_scene + Robot Description: robot_description + Scene Geometry: + Scene Alpha: 1 + Scene Robot: + Robot Alpha: 0.5 + Value: true + Global Options: + Fixed Frame: base_link + Tools: + - Class: rviz_default_plugins/Interact + - Class: rviz_default_plugins/MoveCamera + - Class: rviz_default_plugins/Select + Value: true + Views: + Current: + Class: rviz_default_plugins/Orbit + Distance: 2.0 + Focal Point: + X: -0.1 + Y: 0.25 + Z: 0.30 + Name: Current View + Pitch: 0.5 + Target Frame: base_link + Yaw: -0.623 +Window Geometry: + Height: 975 + QMainWindow State: 000000ff00000000fd0000000100000000000002b400000375fc0200000005fb00000044004d006f00740069006f006e0050006c0061006e006e0069006e00670020002d0020005400720061006a006500630074006f0072007900200053006c00690064006500720000000000ffffffff0000004100fffffffb000000100044006900730070006c006100790073010000003d00000123000000c900fffffffb0000001c004d006f00740069006f006e0050006c0061006e006e0069006e00670100000166000001910000018800fffffffb0000000800480065006c0070000000029a0000006e0000006e00fffffffb0000000a0056006900650077007301000002fd000000b5000000a400ffffff000001f60000037500000004000000040000000800000008fc0000000100000002000000010000000a0054006f006f006c00730100000000ffffffff0000000000000000 + Width: 1200 diff --git a/unilabos/device_mesh/devices/dummy2_robot/config/moveit_controllers.yaml b/unilabos/device_mesh/devices/dummy2_robot/config/moveit_controllers.yaml new file mode 100644 index 00000000..153ff5e6 --- /dev/null +++ b/unilabos/device_mesh/devices/dummy2_robot/config/moveit_controllers.yaml @@ -0,0 +1,21 @@ +# MoveIt uses this configuration for controller management + +moveit_controller_manager: moveit_simple_controller_manager/MoveItSimpleControllerManager + +moveit_simple_controller_manager: + controller_names: + - dummy2_arm_controller + + dummy2_arm_controller: + type: FollowJointTrajectory + action_ns: follow_joint_trajectory + default: true + joints: + - Joint1 + - Joint2 + - Joint3 + - Joint4 + - Joint5 + - Joint6 + action_ns: follow_joint_trajectory + default: true \ No newline at end of file diff --git a/unilabos/device_mesh/devices/dummy2_robot/config/physical_parameters.yaml b/unilabos/device_mesh/devices/dummy2_robot/config/physical_parameters.yaml new file mode 100644 index 00000000..cd6f60c8 --- /dev/null +++ b/unilabos/device_mesh/devices/dummy2_robot/config/physical_parameters.yaml @@ -0,0 +1,39 @@ +dummy2_robot: + # Physical properties for each link + link_masses: + base_link: 5.0 + link_1: 3.0 + link_2: 2.5 + link_3: 2.0 + link_4: 1.5 + link_5: 1.0 + link_6: 0.5 + + # Center of mass for each link (relative to joint frame) + link_com: + base_link: [0.0, 0.0, 0.05] + link_1: [0.0, 0.0, 0.05] + link_2: [0.1, 0.0, 0.0] + link_3: [0.08, 0.0, 0.0] + link_4: [0.0, 0.0, 0.05] + link_5: [0.0, 0.0, 0.03] + link_6: [0.0, 0.0, 0.02] + + # Moment of inertia matrices + link_inertias: + base_link: [0.02, 0.0, 0.0, 0.02, 0.0, 0.02] + link_1: [0.01, 0.0, 0.0, 0.01, 0.0, 0.01] + link_2: [0.008, 0.0, 0.0, 0.008, 0.0, 0.008] + link_3: [0.006, 0.0, 0.0, 0.006, 0.0, 0.006] + link_4: [0.004, 0.0, 0.0, 0.004, 0.0, 0.004] + link_5: [0.002, 0.0, 0.0, 0.002, 0.0, 0.002] + link_6: [0.001, 0.0, 0.0, 0.001, 0.0, 0.001] + + # Motor specifications + motor_specs: + joint_1: { max_torque: 150.0, max_speed: 2.0, gear_ratio: 100 } + joint_2: { max_torque: 150.0, max_speed: 2.0, gear_ratio: 100 } + joint_3: { max_torque: 150.0, max_speed: 2.0, gear_ratio: 100 } + joint_4: { max_torque: 50.0, max_speed: 2.0, gear_ratio: 50 } + joint_5: { max_torque: 50.0, max_speed: 2.0, gear_ratio: 50 } + joint_6: { max_torque: 25.0, max_speed: 2.0, gear_ratio: 25 } diff --git a/unilabos/device_mesh/devices/dummy2_robot/config/pilz_cartesian_limits.yaml b/unilabos/device_mesh/devices/dummy2_robot/config/pilz_cartesian_limits.yaml new file mode 100644 index 00000000..b2997caf --- /dev/null +++ b/unilabos/device_mesh/devices/dummy2_robot/config/pilz_cartesian_limits.yaml @@ -0,0 +1,6 @@ +# Limits for the Pilz planner +cartesian_limits: + max_trans_vel: 1.0 + max_trans_acc: 2.25 + max_trans_dec: -5.0 + max_rot_vel: 1.57 diff --git a/unilabos/device_mesh/devices/dummy2_robot/config/ros2_controllers.yaml b/unilabos/device_mesh/devices/dummy2_robot/config/ros2_controllers.yaml new file mode 100644 index 00000000..6265fa4f --- /dev/null +++ b/unilabos/device_mesh/devices/dummy2_robot/config/ros2_controllers.yaml @@ -0,0 +1,26 @@ +# This config file is used by ros2_control +controller_manager: + ros__parameters: + update_rate: 100 # Hz + + dummy2_arm_controller: + type: joint_trajectory_controller/JointTrajectoryController + + + joint_state_broadcaster: + type: joint_state_broadcaster/JointStateBroadcaster + +dummy2_arm_controller: + ros__parameters: + joints: + - Joint1 + - Joint2 + - Joint3 + - Joint4 + - Joint5 + - Joint6 + command_interfaces: + - position + state_interfaces: + - position + - velocity \ No newline at end of file diff --git a/unilabos/device_mesh/devices/dummy2_robot/config/visual_parameters.yaml b/unilabos/device_mesh/devices/dummy2_robot/config/visual_parameters.yaml new file mode 100644 index 00000000..e9cc6615 --- /dev/null +++ b/unilabos/device_mesh/devices/dummy2_robot/config/visual_parameters.yaml @@ -0,0 +1,35 @@ +dummy2_robot: + # Visual appearance settings + materials: + base_material: + color: [0.8, 0.8, 0.8, 1.0] # Light gray + metallic: 0.1 + roughness: 0.3 + + link_material: + color: [0.2, 0.2, 0.8, 1.0] # Blue + metallic: 0.3 + roughness: 0.2 + + joint_material: + color: [0.6, 0.6, 0.6, 1.0] # Dark gray + metallic: 0.5 + roughness: 0.1 + + camera_material: + color: [0.1, 0.1, 0.1, 1.0] # Black + metallic: 0.0 + roughness: 0.8 + + # Mesh scaling factors + mesh_scale: [0.001, 0.001, 0.001] # Convert mm to m + + # Collision geometry simplification + collision_geometries: + base_link: "cylinder" # radius: 0.08, height: 0.1 + link_1: "cylinder" # radius: 0.05, height: 0.15 + link_2: "box" # size: [0.2, 0.08, 0.08] + link_3: "box" # size: [0.15, 0.06, 0.06] + link_4: "cylinder" # radius: 0.03, height: 0.1 + link_5: "cylinder" # radius: 0.025, height: 0.06 + link_6: "cylinder" # radius: 0.02, height: 0.04 diff --git a/unilabos/device_mesh/devices/dummy2_robot/dummy2.xacro b/unilabos/device_mesh/devices/dummy2_robot/dummy2.xacro new file mode 100644 index 00000000..f7959cbf --- /dev/null +++ b/unilabos/device_mesh/devices/dummy2_robot/dummy2.xacro @@ -0,0 +1,237 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/unilabos/device_mesh/devices/dummy2_robot/joint_limit.yaml b/unilabos/device_mesh/devices/dummy2_robot/joint_limit.yaml new file mode 100644 index 00000000..4bbb56c0 --- /dev/null +++ b/unilabos/device_mesh/devices/dummy2_robot/joint_limit.yaml @@ -0,0 +1,37 @@ +joint_limits: + + joint_1: + effort: 150 + velocity: 2.0 + lower: !degrees -180 + upper: !degrees 180 + + joint_2: + effort: 150 + velocity: 2.0 + lower: !degrees -90 + upper: !degrees 90 + + joint_3: + effort: 150 + velocity: 2.0 + lower: !degrees -90 + upper: !degrees 90 + + joint_4: + effort: 50 + velocity: 2.0 + lower: !degrees -180 + upper: !degrees 180 + + joint_5: + effort: 50 + velocity: 2.0 + lower: !degrees -90 + upper: !degrees 90 + + joint_6: + effort: 25 + velocity: 2.0 + lower: !degrees -180 + upper: !degrees 180 diff --git a/unilabos/device_mesh/devices/dummy2_robot/macro_device.xacro b/unilabos/device_mesh/devices/dummy2_robot/macro_device.xacro new file mode 100644 index 00000000..2112dd7e --- /dev/null +++ b/unilabos/device_mesh/devices/dummy2_robot/macro_device.xacro @@ -0,0 +1,249 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/unilabos/device_mesh/devices/dummy2_robot/meshes/J1_1.stl b/unilabos/device_mesh/devices/dummy2_robot/meshes/J1_1.stl new file mode 100644 index 00000000..744ff335 Binary files /dev/null and b/unilabos/device_mesh/devices/dummy2_robot/meshes/J1_1.stl differ diff --git a/unilabos/device_mesh/devices/dummy2_robot/meshes/J2_1.stl b/unilabos/device_mesh/devices/dummy2_robot/meshes/J2_1.stl new file mode 100644 index 00000000..94b75fe9 Binary files /dev/null and b/unilabos/device_mesh/devices/dummy2_robot/meshes/J2_1.stl differ diff --git a/unilabos/device_mesh/devices/dummy2_robot/meshes/J3_1.stl b/unilabos/device_mesh/devices/dummy2_robot/meshes/J3_1.stl new file mode 100644 index 00000000..fb172d87 Binary files /dev/null and b/unilabos/device_mesh/devices/dummy2_robot/meshes/J3_1.stl differ diff --git a/unilabos/device_mesh/devices/dummy2_robot/meshes/J4_1.stl b/unilabos/device_mesh/devices/dummy2_robot/meshes/J4_1.stl new file mode 100644 index 00000000..a7e12a6b Binary files /dev/null and b/unilabos/device_mesh/devices/dummy2_robot/meshes/J4_1.stl differ diff --git a/unilabos/device_mesh/devices/dummy2_robot/meshes/J5_1.stl b/unilabos/device_mesh/devices/dummy2_robot/meshes/J5_1.stl new file mode 100644 index 00000000..091eccc0 Binary files /dev/null and b/unilabos/device_mesh/devices/dummy2_robot/meshes/J5_1.stl differ diff --git a/unilabos/device_mesh/devices/dummy2_robot/meshes/J6_1.stl b/unilabos/device_mesh/devices/dummy2_robot/meshes/J6_1.stl new file mode 100644 index 00000000..55f51b28 Binary files /dev/null and b/unilabos/device_mesh/devices/dummy2_robot/meshes/J6_1.stl differ diff --git a/unilabos/device_mesh/devices/dummy2_robot/meshes/base_link.stl b/unilabos/device_mesh/devices/dummy2_robot/meshes/base_link.stl new file mode 100644 index 00000000..f5ded8a5 Binary files /dev/null and b/unilabos/device_mesh/devices/dummy2_robot/meshes/base_link.stl differ diff --git a/unilabos/device_mesh/devices/dummy2_robot/meshes/camera_1.stl b/unilabos/device_mesh/devices/dummy2_robot/meshes/camera_1.stl new file mode 100644 index 00000000..b5a6ece0 Binary files /dev/null and b/unilabos/device_mesh/devices/dummy2_robot/meshes/camera_1.stl differ diff --git a/unilabos/device_mesh/devices/dummy2_robot/meshes/dummy2.xacro b/unilabos/device_mesh/devices/dummy2_robot/meshes/dummy2.xacro new file mode 100644 index 00000000..f7959cbf --- /dev/null +++ b/unilabos/device_mesh/devices/dummy2_robot/meshes/dummy2.xacro @@ -0,0 +1,237 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/unilabos/device_mesh/devices/liquid_transform_xyz/meshes/base_link.STL b/unilabos/device_mesh/devices/liquid_transform_xyz/meshes/base_link.STL old mode 100644 new mode 100755 diff --git a/unilabos/device_mesh/devices/liquid_transform_xyz/meshes/x_link.STL b/unilabos/device_mesh/devices/liquid_transform_xyz/meshes/x_link.STL old mode 100644 new mode 100755 diff --git a/unilabos/device_mesh/devices/liquid_transform_xyz/meshes/y_link.STL b/unilabos/device_mesh/devices/liquid_transform_xyz/meshes/y_link.STL old mode 100644 new mode 100755 diff --git a/unilabos/device_mesh/devices/liquid_transform_xyz/meshes/z_link.STL b/unilabos/device_mesh/devices/liquid_transform_xyz/meshes/z_link.STL old mode 100644 new mode 100755 diff --git a/unilabos/device_mesh/resource_visalization.py b/unilabos/device_mesh/resource_visalization.py index e430abd1..fc37150c 100644 --- a/unilabos/device_mesh/resource_visalization.py +++ b/unilabos/device_mesh/resource_visalization.py @@ -14,6 +14,7 @@ from launch_ros.parameter_descriptions import ParameterFile from unilabos.registry.registry import lab_registry from ament_index_python.packages import get_package_share_directory + def get_pattern_matches(folder, pattern): """Given all the files in the folder, find those that match the pattern. @@ -51,7 +52,7 @@ class ResourceVisualization: self.launch_description = LaunchDescription() self.resource_dict = resource self.resource_model = {} - self.resource_type = ['deck', 'plate', 'container'] + self.resource_type = ['deck', 'plate', 'container', 'tip_rack'] self.mesh_path = Path(__file__).parent.absolute() self.enable_rviz = enable_rviz registry = lab_registry @@ -128,9 +129,9 @@ class ResourceVisualization: # if node["parent"] is not None: # new_dev.set("station_name", node["parent"]+'_') - new_dev.set("x",str(float(node["position"]["x"])/1000)) - new_dev.set("y",str(float(node["position"]["y"])/1000)) - new_dev.set("z",str(float(node["position"]["z"])/1000)) + new_dev.set("x",str(float(node["position"]["position"]["x"])/1000)) + new_dev.set("y",str(float(node["position"]["position"]["y"])/1000)) + new_dev.set("z",str(float(node["position"]["position"]["z"])/1000)) if "rotation" in node["config"]: new_dev.set("rx",str(float(node["config"]["rotation"]["x"]))) new_dev.set("ry",str(float(node["config"]["rotation"]["y"]))) @@ -140,7 +141,7 @@ class ResourceVisualization: new_dev.set(key, str(value)) # 添加ros2_controller - if node['class'].startswith('moveit.'): + if node['class'].find('moveit.')!= -1: new_include_controller = etree.SubElement(self.root, f"{{{xacro_uri}}}include") new_include_controller.set("filename", f"{str(self.mesh_path)}/devices/{model_config['mesh']}/config/macro.ros2_control.xacro") new_controller = etree.SubElement(self.root, f"{{{xacro_uri}}}{model_config['mesh']}_ros2_control") @@ -203,7 +204,24 @@ class ResourceVisualization: Returns: LaunchDescription: launch描述对象 """ - moveit_configs_utils_path = Path(get_package_share_directory("moveit_configs_utils")) + # 检查ROS 2环境变量 + if "AMENT_PREFIX_PATH" not in os.environ: + raise OSError( + "ROS 2环境未正确设置。需要设置 AMENT_PREFIX_PATH 环境变量。\n" + "请确保:\n" + "1. 已安装ROS 2 (推荐使用 ros-humble-desktop-full)\n" + "2. 已激活Conda环境: conda activate unilab\n" + "3. 或手动source ROS 2 setup文件: source /opt/ros/humble/setup.bash\n" + "4. 或者使用 --backend simple 参数跳过ROS依赖" + ) + + try: + moveit_configs_utils_path = Path(get_package_share_directory("moveit_configs_utils")) + except Exception as e: + raise OSError( + f"无法找到moveit_configs_utils包。请确保ROS 2和MoveIt 2已正确安装。\n" + f"原始错误: {e}" + ) default_folder = moveit_configs_utils_path / "default_configs" planning_pattern = re.compile("^(.*)_planning.yaml$") pipelines = [] @@ -264,7 +282,8 @@ class ResourceVisualization: parameters=[ {"robot_description": robot_description}, ros2_controllers, - ] + ], + env=dict(os.environ) ) ) for controller in self.moveit_controllers_yaml['moveit_simple_controller_manager']['controller_names']: @@ -274,6 +293,7 @@ class ResourceVisualization: executable="spawner", arguments=[f"{controller}", "--controller-manager", f"controller_manager"], output="screen", + env=dict(os.environ) ) ) controllers.append( @@ -282,6 +302,7 @@ class ResourceVisualization: executable="spawner", arguments=["joint_state_broadcaster", "--controller-manager", f"controller_manager"], output="screen", + env=dict(os.environ) ) ) for i in controllers: @@ -300,7 +321,8 @@ class ResourceVisualization: 'use_sim_time': False }, # kinematics_dict - ] + ], + env=dict(os.environ) ) @@ -331,7 +353,8 @@ class ResourceVisualization: package='moveit_ros_move_group', executable='move_group', output='screen', - parameters=moveit_params + parameters=moveit_params, + env=dict(os.environ) ) @@ -354,7 +377,8 @@ class ResourceVisualization: robot_description_planning, planning_pipelines, - ] + ], + env=dict(os.environ) ) self.launch_description.add_action(rviz_node) diff --git a/unilabos/device_mesh/resources/bottle/meshes/bottle.stl b/unilabos/device_mesh/resources/bottle/meshes/bottle.stl new file mode 100644 index 00000000..6e3a1437 Binary files /dev/null and b/unilabos/device_mesh/resources/bottle/meshes/bottle.stl differ diff --git a/unilabos/device_mesh/resources/bottle/modal.xacro b/unilabos/device_mesh/resources/bottle/modal.xacro new file mode 100644 index 00000000..07fa88e7 --- /dev/null +++ b/unilabos/device_mesh/resources/bottle/modal.xacro @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/unilabos/device_mesh/resources/bottle_container/meshes/bottle_container.stl b/unilabos/device_mesh/resources/bottle_container/meshes/bottle_container.stl new file mode 100644 index 00000000..5495e3a7 Binary files /dev/null and b/unilabos/device_mesh/resources/bottle_container/meshes/bottle_container.stl differ diff --git a/unilabos/device_mesh/resources/bottle_container/modal.xacro b/unilabos/device_mesh/resources/bottle_container/modal.xacro new file mode 100644 index 00000000..1de8dfe9 --- /dev/null +++ b/unilabos/device_mesh/resources/bottle_container/modal.xacro @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/unilabos/device_mesh/resources/plate_96/meshes/plate_96.stl b/unilabos/device_mesh/resources/plate_96/meshes/plate_96.stl new file mode 100644 index 00000000..609df740 Binary files /dev/null and b/unilabos/device_mesh/resources/plate_96/meshes/plate_96.stl differ diff --git a/unilabos/device_mesh/resources/plate_96/modal.xacro b/unilabos/device_mesh/resources/plate_96/modal.xacro new file mode 100644 index 00000000..0f94dccb --- /dev/null +++ b/unilabos/device_mesh/resources/plate_96/modal.xacro @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/unilabos/device_mesh/resources/tip/meshes/tip.stl b/unilabos/device_mesh/resources/tip/meshes/tip.stl new file mode 100644 index 00000000..11c0b968 Binary files /dev/null and b/unilabos/device_mesh/resources/tip/meshes/tip.stl differ diff --git a/unilabos/device_mesh/resources/tip/modal.xacro b/unilabos/device_mesh/resources/tip/modal.xacro new file mode 100644 index 00000000..758ed174 --- /dev/null +++ b/unilabos/device_mesh/resources/tip/modal.xacro @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/unilabos/device_mesh/resources/tiprack_box/meshes/tiprack_box.stl b/unilabos/device_mesh/resources/tiprack_box/meshes/tiprack_box.stl new file mode 100644 index 00000000..d8c52d36 Binary files /dev/null and b/unilabos/device_mesh/resources/tiprack_box/meshes/tiprack_box.stl differ diff --git a/unilabos/device_mesh/resources/tiprack_box/modal.xacro b/unilabos/device_mesh/resources/tiprack_box/modal.xacro new file mode 100644 index 00000000..dacb1985 --- /dev/null +++ b/unilabos/device_mesh/resources/tiprack_box/modal.xacro @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/unilabos/device_mesh/resources/tube/meshes/tube.stl b/unilabos/device_mesh/resources/tube/meshes/tube.stl new file mode 100644 index 00000000..0fce2fc6 Binary files /dev/null and b/unilabos/device_mesh/resources/tube/meshes/tube.stl differ diff --git a/unilabos/device_mesh/resources/tube/modal.xacro b/unilabos/device_mesh/resources/tube/modal.xacro new file mode 100644 index 00000000..348d9231 --- /dev/null +++ b/unilabos/device_mesh/resources/tube/modal.xacro @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/unilabos/device_mesh/resources/tube_container/meshes/tube_container.stl b/unilabos/device_mesh/resources/tube_container/meshes/tube_container.stl new file mode 100644 index 00000000..299e2ac0 Binary files /dev/null and b/unilabos/device_mesh/resources/tube_container/meshes/tube_container.stl differ diff --git a/unilabos/device_mesh/resources/tube_container/modal.xacro b/unilabos/device_mesh/resources/tube_container/modal.xacro new file mode 100644 index 00000000..600b368b --- /dev/null +++ b/unilabos/device_mesh/resources/tube_container/modal.xacro @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/unilabos/device_mesh/view_robot.rviz b/unilabos/device_mesh/view_robot.rviz index 50e0543e..25ffd2e8 100644 --- a/unilabos/device_mesh/view_robot.rviz +++ b/unilabos/device_mesh/view_robot.rviz @@ -5,11 +5,13 @@ Panels: Property Tree Widget: Expanded: - /TF1/Tree1 + - /PlanningScene1 + - /PlanningScene1/Scene Geometry1 - /MotionPlanning1/Scene Geometry1 - /MotionPlanning1/Scene Robot1 - /MotionPlanning1/Planning Request1 Splitter Ratio: 0.5016146302223206 - Tree Height: 1112 + Tree Height: 563 - Class: rviz_common/Selection Name: Selection - Class: rviz_common/Tool Properties @@ -91,7 +93,7 @@ Visualization Manager: Planning Scene Topic: /monitored_planning_scene Robot Description: robot_description Scene Geometry: - Scene Alpha: 0.8999999761581421 + Scene Alpha: 1 Scene Color: 50; 230; 50 Scene Display Time: 0.009999999776482582 Show Scene Geometry: true @@ -567,25 +569,25 @@ Visualization Manager: Pitch: 0.4297958016395569 Target Frame: Value: Orbit (rviz) - Yaw: 0.3525616228580475 + Yaw: 0.36756160855293274 Saved: ~ Window Geometry: Displays: collapsed: false - Height: 2032 + Height: 1088 Hide Left Dock: false Hide Right Dock: true MotionPlanning: - collapsed: true + collapsed: false MotionPlanning - Trajectory Slider: collapsed: false - QMainWindow State: 000000ff00000000fd0000000400000000000003a30000079bfc020000000bfb0000001200530065006c0065006300740069006f006e00000001e10000009b000000b000fffffffb0000001e0054006f006f006c002000500072006f007000650072007400690065007302000001ed000001df00000185000000a3fb000000120056006900650077007300200054006f006f02000001df000002110000018500000122fb000000200054006f006f006c002000500072006f0070006500720074006900650073003203000002880000011d000002210000017afb000000100044006900730070006c0061007900730100000027000004c60000018200fffffffb0000002000730065006c0065006300740069006f006e00200062007500660066006500720200000138000000aa0000023a00000294fb00000014005700690064006500530074006500720065006f02000000e6000000d2000003ee0000030bfb0000000c004b0069006e0065006300740200000186000001060000030c00000261fb000000280020002d0020005400720061006a006500630074006f0072007900200053006c00690064006500720000000000ffffffff0000000000000000fb00000044004d006f00740069006f006e0050006c0061006e006e0069006e00670020002d0020005400720061006a006500630074006f0072007900200053006c00690064006500720000000000ffffffff0000007a00fffffffb0000001c004d006f00740069006f006e0050006c0061006e006e0069006e006701000004f9000002c9000002b800ffffff000000010000010f00000387fc0200000003fb0000001e0054006f006f006c002000500072006f00700065007200740069006500730100000041000000780000000000000000fb0000000a00560069006500770073000000003b000003870000013200fffffffb0000001200530065006c0065006300740069006f006e010000025a000000b200000000000000000000000200000490000000a9fc0100000001fb0000000a00560069006500770073030000004e00000080000002e10000019700000003000004420000003efc0100000002fb0000000800540069006d00650100000000000004420000000000000000fb0000000800540069006d0065010000000000000450000000000000000000000bc50000079b00000004000000040000000800000008fc0000000100000002000000010000000a0054006f006f006c00730000000000ffffffff0000000000000000 + QMainWindow State: 000000ff00000000fd0000000400000000000003a30000040bfc020000000bfb0000001200530065006c0065006300740069006f006e00000001e10000009b0000005c00fffffffb0000001e0054006f006f006c002000500072006f007000650072007400690065007302000001ed000001df00000185000000a3fb000000120056006900650077007300200054006f006f02000001df000002110000018500000122fb000000200054006f006f006c002000500072006f0070006500720074006900650073003203000002880000011d000002210000017afb000000100044006900730070006c006100790073010000001700000271000000ca00fffffffb0000002000730065006c0065006300740069006f006e00200062007500660066006500720200000138000000aa0000023a00000294fb00000014005700690064006500530074006500720065006f02000000e6000000d2000003ee0000030bfb0000000c004b0069006e0065006300740200000186000001060000030c00000261fb000000280020002d0020005400720061006a006500630074006f0072007900200053006c00690064006500720000000000ffffffff0000000000000000fb00000044004d006f00740069006f006e0050006c0061006e006e0069006e00670020002d0020005400720061006a006500630074006f0072007900200053006c00690064006500720000000000ffffffff0000004200fffffffb0000001c004d006f00740069006f006e0050006c0061006e006e0069006e0067010000028e000001940000018900ffffff000000010000010f00000387fc0200000003fb0000001e0054006f006f006c002000500072006f00700065007200740069006500730100000041000000780000000000000000fb0000000a00560069006500770073000000003b00000387000000a600fffffffb0000001200530065006c0065006300740069006f006e010000025a000000b200000000000000000000000200000490000000a9fc0100000001fb0000000a00560069006500770073030000004e00000080000002e10000019700000003000004420000003efc0100000002fb0000000800540069006d00650100000000000004420000000000000000fb0000000800540069006d00650100000000000004500000000000000000000004110000040b00000004000000040000000800000008fc0000000100000002000000010000000a0054006f006f006c00730000000000ffffffff0000000000000000 Selection: collapsed: false Tool Properties: collapsed: false Views: collapsed: true - Width: 3956 - X: 140 - Y: 54 + Width: 1978 + X: 70 + Y: 27 diff --git a/unilabos/devices/Qone_nmr/QOne_NMR_User_Guide.md b/unilabos/devices/Qone_nmr/QOne_NMR_User_Guide.md new file mode 100644 index 00000000..c09785c9 --- /dev/null +++ b/unilabos/devices/Qone_nmr/QOne_NMR_User_Guide.md @@ -0,0 +1,200 @@ +# QOne NMR 用户指南 + +## 概述 + +Qone NMR 设备支持多字符串数据处理功能。该设备可以接收包含多个字符串的输入数据,并将每个字符串转换为独立的 TXT 文件,支持灵活的数据格式化和输出。 + +## 核心功能 + +- **多字符串处理**: 支持逗号分隔或换行分隔的多个字符串输入 +- **自动文件生成**: 每个输入字符串生成一个对应的 TXT 文件 +- **文件夹监督**: 自动监督指定目录,检测新内容生成 +- **错误处理**: 完善的输入验证和错误处理机制 + +## 参数说明 + +### 输入参数 + +- **string** (str): 包含多个字符串的输入数据,支持格式: + - **逗号分隔**: `"字符串1, 字符串2, 字符串3"` + +### 输出参数 + +- **return_info** (str): 处理结果信息,包含监督功能的执行结果 +- **success** (bool): 处理是否成功 +- **files_generated** (int): 生成的 TXT 文件数量 + +## 输入数据格式 + +### 支持的输入格式 + +1. **逗号分隔格式**: + ``` + "A 1 B 1 C 1 D 1 E 1 F 1 G 1 H 1 END, A 2 B 2 C 2 D 2 E 2 F 2 G 2 H 2 END" + ``` + + ``` + +### 数据项格式 + +每个字符串内的数据项应该用空格分隔,例如: +- `A 1 B 2 C 3 D 4 E 5 F 6 G 7 H 8 END` +- `Sample 001 Method A Volume 10.5 Temp 25.0` + +## 输出文件说明 + +### 文件命名 + +生成的 TXT 文件将按照row_字符串顺序命名,例如: +- `row_1.txt` +- `row_2.txt` + +### 文件格式 + +每个 TXT 文件包含对应字符串的格式化数据,格式为: +``` +A 1 +B 2 +C 3 +D 4 +E 5 +F 6 +G 7 +H 8 +END +``` + +### 输出目录 + +默认输出目录为 `D:/setup/txt`,可以在 `device.json` 中配置 `output_dir` 参数。 + +## 文件夹监督功能 + +### 监督机制 + +设备在完成字符串到TXT文件的转换后,会自动启动文件夹监督功能: + +- **监督目录**: 默认监督 `D:/Data/MyPC/Automation` 目录 +- **检查间隔**: 每60秒检查一次新生成的.nmr文件 +- **检测内容**: 新文件生成或现有文件大小变化 +- **停止条件**: 连续三次文件大小没有变化,则检测完成 + +## 文件夹监督功能详细说明 + +Oxford NMR设备驱动集成了智能文件夹监督功能,用于监测.nmr结果文件的生成完成状态。该功能通过监测文件大小变化来判断文件是否已完成写入。 + +### 工作机制 + +1. **文件大小监测**: 监督功能专门监测指定目录中新生成的.nmr文件的大小变化 +2. **稳定性检测**: 当文件大小在连续多次检查中保持不变时,认为文件已完成写入 +3. **批量处理支持**: 根据输入的.txt文件数量,自动确定需要监测的.nmr文件数量 +4. **实时反馈**: 提供详细的监测进度和文件状态信息 +5. **自动停止**: 当所有期望的.nmr文件都达到稳定状态时,监督功能自动停止,start函数执行完毕 + +### 配置参数 + +可以通过`device.json`配置文件调整监督功能的行为: + +```json +{ + "config": { + "output_dir": "D:/setup/txt", + "monitor_dir": "D:\\Data\\MyPC\\Automation", + "stability_checks": 3, + "check_interval": 60 + } +} +``` + +- `monitor_dir`: 监督的目录路径,默认为`D:\Data\MyPC\Automation` +- `stability_checks`: 文件大小稳定性检查次数,默认为3次(连续2次检查大小不变则认为文件完成) +- `check_interval`: 检查间隔时间(秒),默认为60秒 + +### 监测逻辑 + +1. **初始状态记录**: 记录监督开始时目录中已存在的.nmr文件及其大小 +2. **新文件检测**: 持续检测新生成的.nmr文件 +3. **大小变化跟踪**: 为每个新文件维护大小变化历史记录 +4. **稳定性判断**: 当文件大小在连续`stability_checks`次检查中保持不变且大小大于0时,认为文件完成 +5. **完成条件**: 当达到期望数量的.nmr文件都完成时,监督功能结束 + +### 配置监督目录 + +可以在 `device.json` 中配置 `monitor_dir` 参数来指定监督目录: + +```json +{ + "config": { + "output_dir": "D:/setup/txt", + "monitor_dir": "D:/Data/MyPC/Automation" + } +} +``` + +## 使用示例 + +### 示例 1: 基本多字符串处理 + +```python +from unilabos.devices.Qone_nmr.Qone_nmr import Qone_nmr + +# 创建设备实例 +device = Qone_nmr(output_directory="D:/setup/txt") + +# 输入多个字符串(逗号分隔) +input_data = "A 1 B 1 C 1 D 1 E 1 F 1 G 1 H 1 END, A 2 B 2 C 2 D 2 E 2 F 2 G 2 H 2 END" + +# 处理数据 +result = device.start(string=input_data) + +print(f"处理结果: {result}") +# 输出: {'return_info': 'Oxford NMR处理完成: 已生成 3 个 txt 文件,保存在: ./output | 监督完成: 成功检测到 3 个.nmr文件已完成生成', 'success': True, 'files_generated': 3} + +### 输出示例 + +当设备成功处理输入并完成文件监督后,会返回如下格式的结果: + +```json +{ + "return_info": "Oxford NMR处理完成: 已生成 3 个 txt 文件,保存在: D:/setup/txt | 监督完成: 成功检测到 3 个.nmr文件已完成生成,start函数执行完毕", + "success": true, + "files_generated": 3 +} +``` + +监督过程中的日志输出示例: +``` +[INFO] 开始监督目录: D:/Data/MyPC/Automation,检查间隔: 30秒,期望.nmr文件数量: 3,稳定性检查: 2次 +[INFO] 初始状态: 0 个.nmr文件 +[INFO] 检测到 3 个新.nmr文件,还需要 0 个... +[DEBUG] 文件大小监测中: D:/Data/MyPC/Automation/sample1.nmr (当前: 1024 字节, 检查次数: 1/3) +[DEBUG] 文件大小监测中: D:/Data/MyPC/Automation/sample2.nmr (当前: 2048 字节, 检查次数: 1/3) +[DEBUG] 文件大小监测中: D:/Data/MyPC/Automation/sample3.nmr (当前: 1536 字节, 检查次数: 1/3) +[INFO] 文件大小已稳定: D:/Data/MyPC/Automation/sample1.nmr (大小: 1024 字节) +[INFO] 文件大小已稳定: D:/Data/MyPC/Automation/sample2.nmr (大小: 2048 字节) +[INFO] 文件大小已稳定: D:/Data/MyPC/Automation/sample3.nmr (大小: 1536 字节) +[INFO] 所有期望的.nmr文件都已完成生成! 完成文件数: 3/3 +[INFO] 完成的.nmr文件: D:/Data/MyPC/Automation/sample1.nmr (最终大小: 1024 字节) +[INFO] 完成的.nmr文件: D:/Data/MyPC/Automation/sample2.nmr (最终大小: 2048 字节) +[INFO] 完成的.nmr文件: D:/Data/MyPC/Automation/sample3.nmr (最终大小: 1536 字节) +[INFO] 停止文件夹监测,所有文件已完成 +``` +``` + +## 错误处理 + +设备具有完善的错误处理机制: + +- **空输入**: 如果输入为空或 None,返回错误信息 +- **无效格式**: 如果输入格式不正确,返回相应错误 +- **文件系统错误**: 如果输出目录不存在或无权限,返回错误信息 + +## 注意事项 + +1. **目录权限**: 确保监督目录具有读取权限,以便设备能够检测文件变化 +2. **文件大小监测**: 监督功能现在基于文件大小变化来判断.nmr文件是否完成,而不是简单的文件存在性检查 +3. **稳定性检查**: 文件大小需要在连续多次检查中保持不变才被认为完成,默认为3次检查 +4. **自动停止**: 监督功能会在检测到期望数量的.nmr文件都达到稳定状态后自动停止,避免无限循环 +5. **配置灵活性**: 可以通过`device.json`调整稳定性检查次数和检查间隔,以适应不同的使用场景 +6. **文件类型**: 监督功能专门针对.nmr文件,忽略其他类型的文件变化 +7. **批量处理**: 支持同时监测多个.nmr文件的完成状态,适合批量处理场景 \ No newline at end of file diff --git a/unilabos/devices/Qone_nmr/Qone_nmr.py b/unilabos/devices/Qone_nmr/Qone_nmr.py new file mode 100644 index 00000000..1633a8d6 --- /dev/null +++ b/unilabos/devices/Qone_nmr/Qone_nmr.py @@ -0,0 +1,382 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Oxford NMR Device Driver for Uni-Lab OS + +支持Oxford NMR设备的CSV字符串到TXT文件转换功能。 +通过ROS2动作接口接收CSV字符串,批量生成TXT文件到指定目录。 +""" + +import csv +import io +import logging +import os +import re +import time +from pathlib import Path +from typing import Dict, Any + +class UniversalDriver: + """Fallback UniversalDriver for standalone testing""" + def __init__(self): + self.success = False + +class Qone_nmr(UniversalDriver): + """Oxford NMR Device Driver + + 支持CSV字符串到TXT文件的批量转换功能。 + """ + + def __init__(self, **kwargs): + """Initialize the Oxford NMR driver + + Args: + **kwargs: Device-specific configuration parameters + - config: Configuration dictionary containing output_dir + """ + super().__init__() + + # Device configuration + self.config = kwargs + config_dict = kwargs.get('config', {}) + + # 设置输出目录,优先使用配置中的output_dir,否则使用默认值 + self.output_directory = "D:\\setup\\txt" # 默认输出目录 + if config_dict and 'output_dir' in config_dict: + self.output_directory = config_dict['output_dir'] + + # 设置监督目录,优先使用配置中的monitor_dir,否则使用默认值 + self.monitor_directory = "D:/Data/MyPC/Automation" # 默认监督目录 + if config_dict and 'monitor_dir' in config_dict: + self.monitor_directory = config_dict['monitor_dir'] + + # 设置文件大小稳定性检查参数 + self.stability_checks = 3 # 默认稳定性检查次数 + if config_dict and 'stability_checks' in config_dict: + self.stability_checks = config_dict['stability_checks'] + + # 设置检查间隔时间 + self.check_interval = 60 # 默认检查间隔(秒) + if config_dict and 'check_interval' in config_dict: + self.check_interval = config_dict['check_interval'] + + # 确保输出目录存在 + os.makedirs(self.output_directory, exist_ok=True) + + # ROS节点引用,由框架设置 + self._ros_node = None + + # ROS2 action result properties + self.success = False + self.return_info = "" + + # Setup logging + self.logger = logging.getLogger(f"Qone_nmr-{kwargs.get('id', 'unknown')}") + self.logger.info(f"Oxford NMR driver initialized with output directory: {self.output_directory}") + self.logger.info(f"Monitor directory set to: {self.monitor_directory}") + self.logger.info(f"Stability checks: {self.stability_checks}, Check interval: {self.check_interval}s") + + def post_init(self, ros_node): + """ROS节点初始化后的回调方法 + + Args: + ros_node: ROS节点实例 + """ + self._ros_node = ros_node + ros_node.lab_logger().info(f"Oxford NMR设备初始化完成,输出目录: {self.output_directory}") + + def get_status(self) -> str: + """获取设备状态 + + Returns: + str: 设备状态 (Idle|Offline|Error|Busy|Unknown) + """ + return "Idle" # NMR设备始终处于空闲状态,等待处理请求 + + def strings_to_txt(self, string_list, output_dir=None, txt_encoding="utf-8"): + """ + 将字符串列表写入多个 txt 文件 + string_list: ["A 1 B 1 C 1 D 1 E 1 F 1 G 1 H 1 END", ...] + + Args: + string_list: 字符串列表 + output_dir: 输出目录(如果未指定,使用self.output_directory) + txt_encoding: 文件编码 + + Returns: + int: 生成的文件数量 + """ + # 使用指定的输出目录或默认目录 + target_dir = output_dir if output_dir else self.output_directory + + # 确保输出目录存在 + os.makedirs(target_dir, exist_ok=True) + + self.logger.info(f"开始生成文件到目录: {target_dir}") + + for i, s in enumerate(string_list, start=1): + try: + # 去掉开头结尾的引号(如果有) + s = s.strip('"').strip("'") + + # 拆分字符串 + parts = s.split() + + # 按两两一组重新排版为多行 + txt_lines = [] + for j in range(0, len(parts) - 1, 2): + txt_lines.append("{} {}".format(parts[j], parts[j+1])) + txt_lines.append("END") + + txt_content = "\n".join(txt_lines) + + # 生成文件名(row_1.txt, row_2.txt, ...) + file_name = "row_{}.txt".format(i) + out_path = os.path.join(target_dir, file_name) + + with open(out_path, "w", encoding=txt_encoding) as f: + f.write(txt_content) + + self.logger.info(f"成功生成文件: {file_name}") + + except Exception as e: + self.logger.error(f"处理第{i}个字符串时出错: {str(e)}") + raise + + return len(string_list) # 返回生成文件数量 + + def monitor_folder_for_new_content(self, monitor_dir=None, check_interval=60, expected_count=1, stability_checks=3): + """监督指定文件夹中.nmr文件的大小变化,当文件大小稳定时认为文件完成 + + Args: + monitor_dir (str): 要监督的目录路径,如果未指定则使用self.monitor_directory + check_interval (int): 检查间隔时间(秒),默认60秒 + expected_count (int): 期望生成的.nmr文件数量,默认1个 + stability_checks (int): 文件大小稳定性检查次数,默认3次 + + Returns: + bool: 如果检测到期望数量的.nmr文件且大小稳定返回True,否则返回False + """ + target_dir = monitor_dir if monitor_dir else self.monitor_directory + + # 确保监督目录存在 + if not os.path.exists(target_dir): + self.logger.warning(f"监督目录不存在: {target_dir}") + return False + + self.logger.info(f"开始监督目录: {target_dir},检查间隔: {check_interval}秒,期望.nmr文件数量: {expected_count},稳定性检查: {stability_checks}次") + + # 记录初始的.nmr文件及其大小 + initial_nmr_files = {} + + try: + for root, dirs, files in os.walk(target_dir): + for file in files: + if file.lower().endswith('.nmr'): + file_path = os.path.join(root, file) + try: + file_size = os.path.getsize(file_path) + initial_nmr_files[file_path] = file_size + except OSError: + pass # 忽略无法访问的文件 + except Exception as e: + self.logger.error(f"读取初始目录状态失败: {str(e)}") + return False + + self.logger.info(f"初始状态: {len(initial_nmr_files)} 个.nmr文件") + + # 跟踪新文件的大小变化历史 + new_files_size_history = {} + completed_files = set() + + # 开始监督循环 + while True: + time.sleep(check_interval) + + current_nmr_files = {} + + try: + for root, dirs, files in os.walk(target_dir): + for file in files: + if file.lower().endswith('.nmr'): + file_path = os.path.join(root, file) + try: + file_size = os.path.getsize(file_path) + current_nmr_files[file_path] = file_size + except OSError: + pass + + # 找出新生成的.nmr文件 + new_nmr_files = set(current_nmr_files.keys()) - set(initial_nmr_files.keys()) + + if len(new_nmr_files) < expected_count: + self.logger.info(f"检测到 {len(new_nmr_files)} 个新.nmr文件,还需要 {expected_count - len(new_nmr_files)} 个...") + continue + + # 检查新文件的大小稳定性 + for file_path in new_nmr_files: + if file_path in completed_files: + continue + + current_size = current_nmr_files.get(file_path, 0) + + # 初始化文件大小历史记录 + if file_path not in new_files_size_history: + new_files_size_history[file_path] = [] + + # 记录当前大小 + new_files_size_history[file_path].append(current_size) + + # 保持历史记录长度不超过稳定性检查次数 + if len(new_files_size_history[file_path]) > stability_checks: + new_files_size_history[file_path] = new_files_size_history[file_path][-stability_checks:] + + # 检查大小是否稳定 + size_history = new_files_size_history[file_path] + if len(size_history) >= stability_checks: + # 检查最近几次的大小是否相同且不为0 + if len(set(size_history[-stability_checks:])) == 1 and size_history[-1] > 0: + self.logger.info(f"文件大小已稳定: {file_path} (大小: {current_size} 字节)") + completed_files.add(file_path) + else: + self.logger.debug(f"文件大小仍在变化: {file_path} (当前: {current_size} 字节, 历史: {size_history[-3:]})") + else: + self.logger.debug(f"文件大小监测中: {file_path} (当前: {current_size} 字节, 检查次数: {len(size_history)}/{stability_checks})") + + # 检查是否所有期望的文件都已完成 + if len(completed_files) >= expected_count: + self.logger.info(f"所有期望的.nmr文件都已完成生成! 完成文件数: {len(completed_files)}/{expected_count}") + for completed_file in list(completed_files)[:expected_count]: + final_size = current_nmr_files.get(completed_file, 0) + self.logger.info(f"完成的.nmr文件: {completed_file} (最终大小: {final_size} 字节)") + self.logger.info("停止文件夹监测,所有文件已完成") + return True + else: + self.logger.info(f"已完成 {len(completed_files)} 个文件,还需要 {expected_count - len(completed_files)} 个文件完成...") + + except Exception as e: + self.logger.error(f"监督过程中出错: {str(e)}") + return False + + def start(self, string: str = None) -> dict: + """使用字符串列表启动TXT文件生成(支持ROS2动作调用) + + Args: + string (str): 包含多个字符串的输入数据,支持两种格式: + 1. 逗号分隔:如 "A 1 B 2 C 3, X 10 Y 20 Z 30" + 2. 换行分隔:如 "A 1 B 2 C 3\nX 10 Y 20 Z 30" + + Returns: + dict: ROS2动作结果格式 {"return_info": str, "success": bool, "files_generated": int} + """ + try: + if string is None or string.strip() == "": + error_msg = "未提供字符串参数或参数为空" + self.logger.error(error_msg) + return {"return_info": error_msg, "success": False, "files_generated": 0} + + self.logger.info(f"开始处理字符串数据,长度: {len(string)} 字符") + + # 支持两种分隔方式:逗号分隔或换行分隔 + string_list = [] + + # 首先尝试逗号分隔 + if ',' in string: + string_list = [item.strip() for item in string.split(',') if item.strip()] + else: + # 如果没有逗号,则按换行分隔 + string_list = [line.strip() for line in string.strip().split('\n') if line.strip()] + + if not string_list: + error_msg = "输入字符串解析后为空" + self.logger.error(error_msg) + return {"return_info": error_msg, "success": False, "files_generated": 0} + + # 确保输出目录存在 + os.makedirs(self.output_directory, exist_ok=True) + + # 使用strings_to_txt函数生成TXT文件 + file_count = self.strings_to_txt( + string_list=string_list, + output_dir=self.output_directory, + txt_encoding='utf-8' + ) + + success_msg = f"Oxford NMR处理完成: 已生成 {file_count} 个 txt 文件,保存在: {self.output_directory}" + self.logger.info(success_msg) + + # 在string转txt完成后,启动文件夹监督功能 + self.logger.info(f"开始启动文件夹监督功能,期望生成 {file_count} 个.nmr文件...") + monitor_result = self.monitor_folder_for_new_content( + expected_count=file_count, + check_interval=self.check_interval, + stability_checks=self.stability_checks + ) + + if monitor_result: + success_msg += f" | 监督完成: 成功检测到 {file_count} 个.nmr文件已完成生成,start函数执行完毕" + else: + success_msg += f" | 监督结束: 监督过程中断或失败,start函数执行完毕" + + return {"return_info": success_msg, "success": True, "files_generated": file_count} + + except Exception as e: + error_msg = f"字符串处理失败: {str(e)}" + self.logger.error(error_msg) + return {"return_info": error_msg, "success": False, "files_generated": 0} + + + +def test_qone_nmr(): + """测试Qone_nmr设备的字符串处理功能""" + try: + # 配置日志输出 + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + logger = logging.getLogger("Qone_nmr_test") + + logger.info("开始测试Qone_nmr设备...") + + # 创建设备实例,使用正确的配置格式 + device = Qone_nmr(config={'output_dir': "D:\\setup\\txt"}) + logger.info(f"设备初始化完成,输出目录: {device.output_directory}") + + # 测试数据:多个字符串,逗号分隔 + test_strings = "A 1 B 1 C 1 D 1 E 1 F 1 G 1 H 1 END, A 2 B 2 C 2 D 2 E 2 F 2 G 2 H 2 END" + logger.info(f"测试输入: {test_strings}") + + # 确保输出目录存在 + if not os.path.exists(device.output_directory): + os.makedirs(device.output_directory, exist_ok=True) + logger.info(f"创建输出目录: {device.output_directory}") + + # 调用start方法 + result = device.start(string=test_strings) + logger.info(f"处理结果: {result}") + + # 显示生成的文件内容 + if result.get('success', False): + output_dir = device.output_directory + if os.path.exists(output_dir): + txt_files = [f for f in os.listdir(output_dir) if f.endswith('.txt')] + logger.info(f"生成的文件数量: {len(txt_files)}") + for i, filename in enumerate(txt_files[:2]): # 只显示前2个文件 + filepath = os.path.join(output_dir, filename) + logger.info(f"文件 {i+1}: {filename}") + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read() + logger.info(f"内容:\n{content}") + + logger.info("测试完成!") + return result + except Exception as e: + logger.error(f"测试过程中出现错误: {str(e)}") + import traceback + traceback.print_exc() + return {"return_info": f"测试失败: {str(e)}", "success": False, "files_generated": 0} + + +if __name__ == "__main__": + test_qone_nmr() \ No newline at end of file diff --git a/unilabos/devices/Qone_nmr/device.json b/unilabos/devices/Qone_nmr/device.json new file mode 100644 index 00000000..7160f3a4 --- /dev/null +++ b/unilabos/devices/Qone_nmr/device.json @@ -0,0 +1,25 @@ +{ + "nodes": [ + { + "id": "Qone_nmr_device", + "name": "Qone_NMR_Device", + "parent": null, + "type": "device", + "class": "Qone_nmr", + "position": { + "x": 620.6111111111111, + "y": 171, + "z": 0 + }, + "config": { + "output_dir": "D:\\setup\\txt", + "monitor_dir": "D:\\Data\\MyPC\\Automation", + "stability_checks": 3, + "check_interval": 60 + }, + "data": {}, + "children": [] + } + ], + "links": [] +} \ No newline at end of file diff --git a/unilabos/devices/Qone_nmr/samples.csv b/unilabos/devices/Qone_nmr/samples.csv new file mode 100644 index 00000000..672343ac --- /dev/null +++ b/unilabos/devices/Qone_nmr/samples.csv @@ -0,0 +1,4 @@ +USERNAME,SLOT,EXPNAME,FILE,SOLVENT,TEMPLATE,TITLE +User,SLOT,Name,No.,SOLVENT,Experiment,TITLE +用户名,进样器孔位,实验任务的名字,保存文件的名字,溶剂(按照实验的要求),模板(按照实验的要求,指定测试的元素),标题 +admin,18,11LiDFOB,LiDFOB-11B,DMSO,B11,11LiDFOB_400MHz diff --git a/unilabos/devices/cytomat/cytomat.py b/unilabos/devices/cytomat/cytomat.py new file mode 100644 index 00000000..454111de --- /dev/null +++ b/unilabos/devices/cytomat/cytomat.py @@ -0,0 +1,61 @@ +import serial +import time + +ser = serial.Serial( + port="COM18", + baudrate=9600, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + timeout=15, + +def send_cmd(cmd: str, wait: float = 1.0) -> str: + """向 Cytomat 发送一行命令并打印/返回响应。""" + print(f">>> {cmd}") + ser.write((cmd + "\r").encode("ascii")) + time.sleep(wait) + resp = ser.read_all().decode("ascii", errors="ignore").strip() + print(f"<<< {resp or ''}") + return resp + +def initialize(): + """设备初始化 (ll:in)。""" + return send_cmd("ll:in") + +def wp_to_storage(pos: int): + """WP → 库位。pos: 1–9999 绝对地址。""" + return send_cmd(f"mv:ws {pos:04d}") + +def storage_to_tfs(stacker: int, level: int): + """库位 → TFS1。""" + return send_cmd(f"mv:st {stacker:02d} {level:02d}") + +def get_basic_state(): + """查询 Basic State Register。""" + return send_cmd("ch:bs") + +def set_pitch(stacker: int, pitch_mm: int): + """设置单个 stacker 的层间距(mm)。""" + return send_cmd(f"se:cs {stacker:02d} {pitch_mm}") + +def tfs_to_storage(stacker: int, level: int): + """TFS1 → 库位。""" + return send_cmd(f"mv:ts {stacker:02d} {level:02d}") + +# ---------- 示例工作流 ---------- +if __name__ == "__main__": + try: + if not ser.is_open: + ser.open() + initialize() + wp_to_storage(10) + storage_to_tfs(17, 3) + get_basic_state() + tfs_to_storage(7, 5) + + except Exception as exc: + print("Error:", exc) + finally: + ser.close() + print("Done.") + diff --git a/unilabos/devices/hplc/AgilentHPLC.py b/unilabos/devices/hplc/AgilentHPLC.py index d47c80d9..2e43c255 100644 --- a/unilabos/devices/hplc/AgilentHPLC.py +++ b/unilabos/devices/hplc/AgilentHPLC.py @@ -405,9 +405,19 @@ class RunningResultChecker(DriverChecker): 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) + # 使用新的OSS上传接口,传入driver_name和exp_type + pdf_result = oss_upload(pdf, filename=os.path.basename(pdf), driver_name="HPLC", exp_type="analysis") + txt_result = oss_upload(txt, filename=os.path.basename(txt), driver_name="HPLC", exp_type="result") + + if pdf_result["success"]: + print(f"PDF上传成功: {pdf_result['oss_path']}") + else: + print(f"PDF上传失败: {pdf_result['original_path']}") + + if txt_result["success"]: + print(f"TXT上传成功: {txt_result['oss_path']}") + else: + print(f"TXT上传失败: {txt_result['original_path']}") # self.driver.extract_data_from_txt() except Exception as ex: self.driver._finished = 0 @@ -456,8 +466,12 @@ if __name__ == "__main__": } 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") + # 使用新的OSS上传接口,传入driver_name和exp_type + pdf_result = oss_upload(pdf, filename=os.path.basename(pdf), driver_name="HPLC", exp_type="analysis") + txt_result = oss_upload(txt, filename=os.path.basename(txt), driver_name="HPLC", exp_type="result") + + print(f"PDF上传结果: {pdf_result}") + print(f"TXT上传结果: {txt_result}") # driver = HPLCDriver() # for i in range(10000): # print({k: v for k, v in driver._device_status.items() if isinstance(v, str)}) diff --git a/unilabos/devices/laiyu_liquid_test/__init__.py b/unilabos/devices/laiyu_liquid_test/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/devices/laiyu_liquid_test/driver_enable_move_test.py b/unilabos/devices/laiyu_liquid_test/driver_enable_move_test.py new file mode 100644 index 00000000..a3c5797d --- /dev/null +++ b/unilabos/devices/laiyu_liquid_test/driver_enable_move_test.py @@ -0,0 +1,138 @@ + +import os +import time +import json +import logging +from xyz_stepper_driver import ModbusRTUTransport, ModbusClient, XYZStepperController, MotorStatus + +# ========== 日志配置 ========== +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("XYZ_Debug") + + +def create_controller(port: str = "/dev/ttyUSB1", baudrate: int = 115200) -> XYZStepperController: + """ + 初始化通信层与三轴控制器 + """ + logger.info(f"🔧 初始化控制器: {port} @ {baudrate}bps") + transport = ModbusRTUTransport(port=port, baudrate=baudrate) + transport.open() + client = ModbusClient(transport) + return XYZStepperController(client=client, port=port, baudrate=baudrate) + + +def load_existing_soft_zero(ctrl: XYZStepperController, path: str = "work_origin.json") -> bool: + """ + 如果已存在软零点文件则加载,否则返回 False + """ + if not os.path.exists(path): + logger.warning("⚠ 未找到已有软零点文件,将等待人工定义新零点。") + return False + + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + origin = data.get("work_origin_steps", {}) + ctrl.work_origin_steps = origin + ctrl.is_homed = True + logger.info(f"✔ 已加载软零点文件:{path}") + logger.info(f"当前软零点步数: {origin}") + return True + except Exception as e: + logger.error(f"读取软零点文件失败: {e}") + return False + + +def test_enable_axis(ctrl: XYZStepperController): + """ + 依次使能 X / Y / Z 三轴 + """ + logger.info("=== 测试各轴使能 ===") + for axis in ["X", "Y", "Z"]: + try: + result = ctrl.enable(axis, True) + if result: + vals = ctrl.get_status(axis) + st = MotorStatus(vals[3]) + logger.info(f"{axis} 轴使能成功,当前状态: {st.name}") + else: + logger.error(f"{axis} 轴使能失败") + except Exception as e: + logger.error(f"{axis} 轴使能异常: {e}") + time.sleep(0.5) + + +def test_status_read(ctrl: XYZStepperController): + """ + 读取各轴当前状态(调试) + """ + logger.info("=== 当前各轴状态 ===") + for axis in ["X", "Y", "Z"]: + try: + vals = ctrl.get_status(axis) + st = MotorStatus(vals[3]) + logger.info( + f"{axis}: steps={vals[0]}, speed={vals[1]}, " + f"current={vals[2]}, status={st.name}" + ) + except Exception as e: + logger.error(f"获取 {axis} 状态失败: {e}") + time.sleep(0.2) + + +def redefine_soft_zero(ctrl: XYZStepperController): + """ + 手动重新定义软零点 + """ + logger.info("=== ⚙️ 重新定义软零点 ===") + ctrl.define_current_as_zero("work_origin.json") + logger.info("✅ 新软零点已写入 work_origin.json") + + +def test_soft_zero_move(ctrl: XYZStepperController): + """ + 以软零点为基准执行三轴运动测试 + """ + logger.info("=== 测试软零点相对运动 ===") + ctrl.move_xyz_work(x=100.0, y=100.0, z=40.0, speed=100, acc=800) + + for axis in ["X", "Y", "Z"]: + ctrl.wait_complete(axis) + + test_status_read(ctrl) + logger.info("✅ 软零点运动测试完成") + + +def main(): + ctrl = create_controller(port="/dev/ttyUSB1", baudrate=115200) + + try: + test_enable_axis(ctrl) + test_status_read(ctrl) + + # === 初始化或加载软零点 === + loaded = load_existing_soft_zero(ctrl) + if not loaded: + logger.info("👣 首次运行,定义软零点并保存。") + ctrl.define_current_as_zero("work_origin.json") + + # === 软零点回归动作 === + ctrl.return_to_work_origin() + + # === 可选软零点运动测试 === + # test_soft_zero_move(ctrl) + + except KeyboardInterrupt: + logger.info("🛑 手动中断退出") + + except Exception as e: + logger.exception(f"❌ 调试出错: {e}") + + finally: + if hasattr(ctrl.client, "transport"): + ctrl.client.transport.close() + logger.info("串口已安全关闭 ✅") + + +if __name__ == "__main__": + main() diff --git a/unilabos/devices/laiyu_liquid_test/driver_status_test.py b/unilabos/devices/laiyu_liquid_test/driver_status_test.py new file mode 100644 index 00000000..f6960d5b --- /dev/null +++ b/unilabos/devices/laiyu_liquid_test/driver_status_test.py @@ -0,0 +1,58 @@ + +import logging +from xyz_stepper_driver import ( + ModbusRTUTransport, + ModbusClient, + XYZStepperController, + MotorAxis, +) + +logger = logging.getLogger("XYZStepperCommTest") +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") + + +def test_xyz_stepper_comm(): + """仅测试 Modbus 通信是否正常(并输出寄存器数据,不做电机运动)""" + port = "/dev/ttyUSB1" + baudrate = 115200 + timeout = 1.2 # 略长避免响应被截断 + + logger.info(f"尝试连接 Modbus 设备 {port} ...") + transport = ModbusRTUTransport(port, baudrate=baudrate, timeout=timeout) + transport.open() + + client = ModbusClient(transport) + ctrl = XYZStepperController(client) + + try: + logger.info("✅ 串口已打开,开始读取三个轴状态(打印寄存器内容) ...") + for axis in [MotorAxis.X, MotorAxis.Y, MotorAxis.Z]: + addr = ctrl.axis_addr[axis] + + try: + # # 在 get_status 前打印原始寄存器内容 + # regs = client.read_registers(addr, ctrl.REG_STATUS, 6) + # hex_regs = [f"0x{val:04X}" for val in regs] + # logger.info(f"[{axis.name}] 原始寄存器 ({len(regs)} 个): {regs} -> {hex_regs}") + + # 调用 get_status() 正常解析 + status = ctrl.get_status(axis) + logger.info( + f"[{axis.name}] ✅ 通信正常: steps={status.steps}, speed={status.speed}, " + f"current={status.current}, status={status.status.name}" + ) + + except Exception as e_axis: + logger.error(f"[{axis.name}] ❌ 通信失败: {e_axis}") + + + except Exception as e: + logger.error(f"❌ 通讯测试失败: {e}") + + finally: + transport.close() + logger.info("🔌 串口已关闭") + + +if __name__ == "__main__": + test_xyz_stepper_comm() diff --git a/unilabos/devices/laiyu_liquid_test/work_origin.json b/unilabos/devices/laiyu_liquid_test/work_origin.json new file mode 100644 index 00000000..935c3e3b --- /dev/null +++ b/unilabos/devices/laiyu_liquid_test/work_origin.json @@ -0,0 +1,8 @@ +{ + "work_origin_steps": { + "x": 11799, + "y": 11476, + "z": 3312 + }, + "timestamp": "2025-11-04T15:31:09.802155" +} \ No newline at end of file diff --git a/unilabos/devices/laiyu_liquid_test/xyz_stepper_driver.py b/unilabos/devices/laiyu_liquid_test/xyz_stepper_driver.py new file mode 100644 index 00000000..6ad37ed7 --- /dev/null +++ b/unilabos/devices/laiyu_liquid_test/xyz_stepper_driver.py @@ -0,0 +1,336 @@ + +""" +XYZ 三轴步进电机驱动(统一字符串参数版) +基于 Modbus RTU 协议 +Author: Xiuyu Chen (Modified by Assistant) +""" + +import serial # type: ignore +import struct +import time +import logging +from enum import Enum +from dataclasses import dataclass +from typing import Optional, List, Dict + +# ========== 日志配置 ========== +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("XYZStepper") + + +# ========== 层 1:Modbus RTU ========== +class ModbusException(Exception): + pass + + +class ModbusRTUTransport: + """底层串口通信层""" + + def __init__(self, port: str, baudrate: int = 115200, timeout: float = 1.2): + self.port = port + self.baudrate = baudrate + self.timeout = timeout + self.ser: Optional[serial.Serial] = None + + def open(self): + try: + self.ser = serial.Serial( + port=self.port, + baudrate=self.baudrate, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + timeout=0.02, + write_timeout=0.5, + ) + logger.info(f"[RTU] 串口连接成功: {self.port}") + except Exception as e: + raise ModbusException(f"无法打开串口 {self.port}: {e}") + + def close(self): + if self.ser and self.ser.is_open: + self.ser.close() + logger.info("[RTU] 串口已关闭") + + def send(self, frame: bytes): + if not self.ser or not self.ser.is_open: + raise ModbusException("串口未连接") + + self.ser.reset_input_buffer() + self.ser.write(frame) + self.ser.flush() + logger.debug(f"[TX] {frame.hex(' ').upper()}") + + def receive(self, expected_len: int) -> bytes: + if not self.ser or not self.ser.is_open: + raise ModbusException("串口未连接") + + start = time.time() + buf = bytearray() + while len(buf) < expected_len and (time.time() - start) < self.timeout: + chunk = self.ser.read(expected_len - len(buf)) + if chunk: + buf.extend(chunk) + else: + time.sleep(0.01) + return bytes(buf) + + +# ========== 层 2:Modbus 协议 ========== +class ModbusFunction(Enum): + READ_HOLDING_REGISTERS = 0x03 + WRITE_SINGLE_REGISTER = 0x06 + WRITE_MULTIPLE_REGISTERS = 0x10 + + +class ModbusClient: + """Modbus RTU 客户端""" + + def __init__(self, transport: ModbusRTUTransport): + self.transport = transport + + @staticmethod + def calc_crc(data: bytes) -> bytes: + crc = 0xFFFF + for b in data: + crc ^= b + for _ in range(8): + crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1 + return struct.pack(" bytes: + frame = bytes([addr, func]) + payload + full = frame + self.calc_crc(frame) + self.transport.send(full) + time.sleep(0.01) + resp = self.transport.ser.read(256) + if not resp: + raise ModbusException("未收到响应") + + start = resp.find(bytes([addr, func])) + if start > 0: + resp = resp[start:] + if len(resp) < 5: + raise ModbusException(f"响应长度不足: {resp.hex(' ').upper()}") + if self.calc_crc(resp[:-2]) != resp[-2:]: + raise ModbusException("CRC 校验失败") + return resp + + def read_registers(self, addr: int, start: int, count: int) -> List[int]: + payload = struct.pack(">HH", start, count) + resp = self.send_request(addr, ModbusFunction.READ_HOLDING_REGISTERS.value, payload) + byte_count = resp[2] + regs = [struct.unpack(">H", resp[3 + i:5 + i])[0] for i in range(0, byte_count, 2)] + return regs + + def write_single_register(self, addr: int, reg: int, val: int) -> bool: + payload = struct.pack(">HH", reg, val) + resp = self.send_request(addr, ModbusFunction.WRITE_SINGLE_REGISTER.value, payload) + return resp[1] == ModbusFunction.WRITE_SINGLE_REGISTER.value + + def write_multiple_registers(self, addr: int, start: int, values: List[int]) -> bool: + byte_count = len(values) * 2 + payload = struct.pack(">HHB", start, len(values), byte_count) + payload += b"".join(struct.pack(">H", v & 0xFFFF) for v in values) + resp = self.send_request(addr, ModbusFunction.WRITE_MULTIPLE_REGISTERS.value, payload) + return resp[1] == ModbusFunction.WRITE_MULTIPLE_REGISTERS.value + + +# ========== 层 3:业务逻辑 ========== +class MotorAxis(Enum): + X = 1 + Y = 2 + Z = 3 + + +class MotorStatus(Enum): + STANDBY = 0 + RUNNING = 1 + COLLISION_STOP = 2 + FORWARD_LIMIT_STOP = 3 + REVERSE_LIMIT_STOP = 4 + + +@dataclass +class MotorPosition: + steps: int + speed: int + current: int + status: MotorStatus + + +class XYZStepperController: + """XYZ 三轴步进控制器(字符串接口版)""" + + STEPS_PER_REV = 16384 + LEAD_MM_X, LEAD_MM_Y, LEAD_MM_Z = 80.0, 80.0, 5.0 + STEPS_PER_MM_X = STEPS_PER_REV / LEAD_MM_X + STEPS_PER_MM_Y = STEPS_PER_REV / LEAD_MM_Y + STEPS_PER_MM_Z = STEPS_PER_REV / LEAD_MM_Z + + REG_STATUS, REG_POS_HIGH, REG_POS_LOW = 0x00, 0x01, 0x02 + REG_ACTUAL_SPEED, REG_CURRENT, REG_ENABLE = 0x03, 0x05, 0x06 + REG_ZERO_CMD, REG_TARGET_HIGH, REG_TARGET_LOW = 0x0F, 0x10, 0x11 + REG_SPEED, REG_ACCEL, REG_PRECISION, REG_START = 0x13, 0x14, 0x15, 0x16 + REG_COMMAND = 0x60 + + def __init__(self, client: Optional[ModbusClient] = None, + port="/dev/ttyUSB0", baudrate=115200, + origin_path="unilabos/devices/laiyu_liquid_test/work_origin.json"): + if client is None: + transport = ModbusRTUTransport(port, baudrate) + transport.open() + self.client = ModbusClient(transport) + else: + self.client = client + + self.axis_addr = {MotorAxis.X: 1, MotorAxis.Y: 2, MotorAxis.Z: 3} + self.work_origin_steps = {"x": 0, "y": 0, "z": 0} + self.is_homed = False + self._load_work_origin(origin_path) + + # ========== 基础工具 ========== + @staticmethod + def s16(v: int) -> int: + return v - 0x10000 if v & 0x8000 else v + + @staticmethod + def s32(h: int, l: int) -> int: + v = (h << 16) | l + return v - 0x100000000 if v & 0x80000000 else v + + @classmethod + def mm_to_steps(cls, axis: str, mm: float = 0.0) -> int: + axis = axis.upper() + if axis == "X": + return int(mm * cls.STEPS_PER_MM_X) + elif axis == "Y": + return int(mm * cls.STEPS_PER_MM_Y) + elif axis == "Z": + return int(mm * cls.STEPS_PER_MM_Z) + raise ValueError(f"未知轴: {axis}") + + @classmethod + def steps_to_mm(cls, axis: str, steps: int) -> float: + axis = axis.upper() + if axis == "X": + return steps / cls.STEPS_PER_MM_X + elif axis == "Y": + return steps / cls.STEPS_PER_MM_Y + elif axis == "Z": + return steps / cls.STEPS_PER_MM_Z + raise ValueError(f"未知轴: {axis}") + + # ========== 状态与控制 ========== + def get_status(self, axis: str = "Z") -> list: + """返回简化数组格式: [steps, speed, current, status_value]""" + if isinstance(axis, MotorAxis): + axis_enum = axis + elif isinstance(axis, str): + axis_enum = MotorAxis[axis.upper()] + else: + raise TypeError("axis 参数必须为 str 或 MotorAxis") + + vals = self.client.read_registers(self.axis_addr[axis_enum], self.REG_STATUS, 6) + return [ + self.s32(vals[1], vals[2]), + self.s16(vals[3]), + vals[4], + int(MotorStatus(vals[0]).value) + ] + + def enable(self, axis: str, state: bool) -> bool: + a = MotorAxis[axis.upper()] + return self.client.write_single_register(self.axis_addr[a], self.REG_ENABLE, 1 if state else 0) + + def wait_complete(self, axis: str, timeout=30.0) -> bool: + a = axis.upper() + start = time.time() + while time.time() - start < timeout: + vals = self.get_status(a) + st = MotorStatus(vals[3]) # 第4个元素是状态值 + if st == MotorStatus.STANDBY: + return True + if st in (MotorStatus.COLLISION_STOP, MotorStatus.FORWARD_LIMIT_STOP, MotorStatus.REVERSE_LIMIT_STOP): + logger.warning(f"{a} 轴异常停止: {st.name}") + return False + time.sleep(0.1) + logger.warning(f"{a} 轴运动超时") + return False + + # ========== 控制命令 ========== + def move_to(self, axis: str, steps: int, speed: int = 2000, acc: int = 500, precision: int = 50) -> bool: + a = MotorAxis[axis.upper()] + addr = self.axis_addr[a] + hi, lo = (steps >> 16) & 0xFFFF, steps & 0xFFFF + values = [hi, lo, speed, acc, precision] + ok = self.client.write_multiple_registers(addr, self.REG_TARGET_HIGH, values) + if ok: + self.client.write_single_register(addr, self.REG_START, 1) + return ok + + def move_xyz_work(self, x: float = 0.0, y: float = 0.0, z: float = 0.0, speed: int = 100, acc: int = 1500): + logger.info("🧭 执行安全多轴运动:Z→XY→Z") + if z is not None: + safe_z = self._to_machine_steps("Z", 0.0) + self.move_to("Z", safe_z, speed, acc) + self.wait_complete("Z") + + if x is not None or y is not None: + if x is not None: + self.move_to("X", self._to_machine_steps("X", x), speed, acc) + if y is not None: + self.move_to("Y", self._to_machine_steps("Y", y), speed, acc) + if x is not None: + self.wait_complete("X") + if y is not None: + self.wait_complete("Y") + + if z is not None: + self.move_to("Z", self._to_machine_steps("Z", z), speed, acc) + self.wait_complete("Z") + logger.info("✅ 多轴顺序运动完成") + + # ========== 坐标与零点 ========== + def _to_machine_steps(self, axis: str, mm: float) -> int: + base = self.work_origin_steps.get(axis.lower(), 0) + return base + self.mm_to_steps(axis, mm) + + def define_current_as_zero(self, save_path="work_origin.json"): + import json + from datetime import datetime + + origin = {} + for axis in ["X", "Y", "Z"]: + vals = self.get_status(axis) + origin[axis.lower()] = int(vals[0]) # 第1个是步数 + with open(save_path, "w", encoding="utf-8") as f: + json.dump({"work_origin_steps": origin, "timestamp": datetime.now().isoformat()}, f, indent=2) + self.work_origin_steps = origin + self.is_homed = True + logger.info(f"✅ 零点已定义并保存至 {save_path}") + + def _load_work_origin(self, path: str) -> bool: + import json, os + + if not os.path.exists(path): + logger.warning("⚠️ 未找到软零点文件") + return False + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + self.work_origin_steps = data.get("work_origin_steps", {"x": 0, "y": 0, "z": 0}) + self.is_homed = True + logger.info(f"📂 软零点已加载: {self.work_origin_steps}") + return True + + def return_to_work_origin(self, speed: int = 200, acc: int = 800): + logger.info("🏁 回工件软零点") + self.move_to("Z", self._to_machine_steps("Z", 0.0), speed, acc) + self.wait_complete("Z") + self.move_to("X", self.work_origin_steps.get("x", 0), speed, acc) + self.move_to("Y", self.work_origin_steps.get("y", 0), speed, acc) + self.wait_complete("X") + self.wait_complete("Y") + self.move_to("Z", self.work_origin_steps.get("z", 0), speed, acc) + self.wait_complete("Z") + logger.info("🎯 回软零点完成 ✅") diff --git a/unilabos/devices/liquid_handling/laiyu/__init__.py b/unilabos/devices/liquid_handling/laiyu/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/devices/liquid_handling/laiyu/backend/__init__.py b/unilabos/devices/liquid_handling/laiyu/backend/__init__.py new file mode 100644 index 00000000..4bf29392 --- /dev/null +++ b/unilabos/devices/liquid_handling/laiyu/backend/__init__.py @@ -0,0 +1,9 @@ +""" +LaiYu液体处理设备后端模块 + +提供设备后端接口和实现 +""" + +from .laiyu_backend import LaiYuLiquidBackend, create_laiyu_backend + +__all__ = ['LaiYuLiquidBackend', 'create_laiyu_backend'] \ No newline at end of file diff --git a/unilabos/devices/liquid_handling/laiyu/backend/laiyu_backend.py b/unilabos/devices/liquid_handling/laiyu/backend/laiyu_backend.py new file mode 100644 index 00000000..5e8041c0 --- /dev/null +++ b/unilabos/devices/liquid_handling/laiyu/backend/laiyu_backend.py @@ -0,0 +1,334 @@ +""" +LaiYu液体处理设备后端实现 + +提供设备的后端接口和控制逻辑 +""" + +import logging +from typing import Dict, Any, Optional, List +from abc import ABC, abstractmethod + +# 尝试导入PyLabRobot后端 +try: + from pylabrobot.liquid_handling.backends import LiquidHandlerBackend + PYLABROBOT_AVAILABLE = True +except ImportError: + PYLABROBOT_AVAILABLE = False + # 创建模拟后端基类 + class LiquidHandlerBackend: + def __init__(self, name: str): + self.name = name + self.is_connected = False + + def connect(self): + """连接设备""" + pass + + def disconnect(self): + """断开连接""" + pass + + +class LaiYuLiquidBackend(LiquidHandlerBackend): + """LaiYu液体处理设备后端""" + + def __init__(self, name: str = "LaiYu_Liquid_Backend"): + """ + 初始化LaiYu液体处理设备后端 + + Args: + name: 后端名称 + """ + if PYLABROBOT_AVAILABLE: + # PyLabRobot 的 LiquidHandlerBackend 不接受参数 + super().__init__() + else: + # 模拟版本接受 name 参数 + super().__init__(name) + + self.name = name + self.logger = logging.getLogger(__name__) + self.is_connected = False + self.device_info = { + "name": "LaiYu液体处理设备", + "version": "1.0.0", + "manufacturer": "LaiYu", + "model": "LaiYu_Liquid_Handler" + } + + def connect(self) -> bool: + """ + 连接到LaiYu液体处理设备 + + Returns: + bool: 连接是否成功 + """ + try: + self.logger.info("正在连接到LaiYu液体处理设备...") + # 这里应该实现实际的设备连接逻辑 + # 目前返回模拟连接成功 + self.is_connected = True + self.logger.info("成功连接到LaiYu液体处理设备") + return True + except Exception as e: + self.logger.error(f"连接LaiYu液体处理设备失败: {e}") + self.is_connected = False + return False + + def disconnect(self) -> bool: + """ + 断开与LaiYu液体处理设备的连接 + + Returns: + bool: 断开连接是否成功 + """ + try: + self.logger.info("正在断开与LaiYu液体处理设备的连接...") + # 这里应该实现实际的设备断开连接逻辑 + self.is_connected = False + self.logger.info("成功断开与LaiYu液体处理设备的连接") + return True + except Exception as e: + self.logger.error(f"断开LaiYu液体处理设备连接失败: {e}") + return False + + def is_device_connected(self) -> bool: + """ + 检查设备是否已连接 + + Returns: + bool: 设备是否已连接 + """ + return self.is_connected + + def get_device_info(self) -> Dict[str, Any]: + """ + 获取设备信息 + + Returns: + Dict[str, Any]: 设备信息字典 + """ + return self.device_info.copy() + + def home_device(self) -> bool: + """ + 设备归零操作 + + Returns: + bool: 归零是否成功 + """ + if not self.is_connected: + self.logger.error("设备未连接,无法执行归零操作") + return False + + try: + self.logger.info("正在执行设备归零操作...") + # 这里应该实现实际的设备归零逻辑 + self.logger.info("设备归零操作完成") + return True + except Exception as e: + self.logger.error(f"设备归零操作失败: {e}") + return False + + def aspirate(self, volume: float, location: Dict[str, Any]) -> bool: + """ + 吸液操作 + + Args: + volume: 吸液体积 (微升) + location: 吸液位置信息 + + Returns: + bool: 吸液是否成功 + """ + if not self.is_connected: + self.logger.error("设备未连接,无法执行吸液操作") + return False + + try: + self.logger.info(f"正在执行吸液操作: 体积={volume}μL, 位置={location}") + # 这里应该实现实际的吸液逻辑 + self.logger.info("吸液操作完成") + return True + except Exception as e: + self.logger.error(f"吸液操作失败: {e}") + return False + + def dispense(self, volume: float, location: Dict[str, Any]) -> bool: + """ + 排液操作 + + Args: + volume: 排液体积 (微升) + location: 排液位置信息 + + Returns: + bool: 排液是否成功 + """ + if not self.is_connected: + self.logger.error("设备未连接,无法执行排液操作") + return False + + try: + self.logger.info(f"正在执行排液操作: 体积={volume}μL, 位置={location}") + # 这里应该实现实际的排液逻辑 + self.logger.info("排液操作完成") + return True + except Exception as e: + self.logger.error(f"排液操作失败: {e}") + return False + + def pick_up_tip(self, location: Dict[str, Any]) -> bool: + """ + 取枪头操作 + + Args: + location: 枪头位置信息 + + Returns: + bool: 取枪头是否成功 + """ + if not self.is_connected: + self.logger.error("设备未连接,无法执行取枪头操作") + return False + + try: + self.logger.info(f"正在执行取枪头操作: 位置={location}") + # 这里应该实现实际的取枪头逻辑 + self.logger.info("取枪头操作完成") + return True + except Exception as e: + self.logger.error(f"取枪头操作失败: {e}") + return False + + def drop_tip(self, location: Dict[str, Any]) -> bool: + """ + 丢弃枪头操作 + + Args: + location: 丢弃位置信息 + + Returns: + bool: 丢弃枪头是否成功 + """ + if not self.is_connected: + self.logger.error("设备未连接,无法执行丢弃枪头操作") + return False + + try: + self.logger.info(f"正在执行丢弃枪头操作: 位置={location}") + # 这里应该实现实际的丢弃枪头逻辑 + self.logger.info("丢弃枪头操作完成") + return True + except Exception as e: + self.logger.error(f"丢弃枪头操作失败: {e}") + return False + + def move_to(self, location: Dict[str, Any]) -> bool: + """ + 移动到指定位置 + + Args: + location: 目标位置信息 + + Returns: + bool: 移动是否成功 + """ + if not self.is_connected: + self.logger.error("设备未连接,无法执行移动操作") + return False + + try: + self.logger.info(f"正在移动到位置: {location}") + # 这里应该实现实际的移动逻辑 + self.logger.info("移动操作完成") + return True + except Exception as e: + self.logger.error(f"移动操作失败: {e}") + return False + + def get_status(self) -> Dict[str, Any]: + """ + 获取设备状态 + + Returns: + Dict[str, Any]: 设备状态信息 + """ + return { + "connected": self.is_connected, + "device_info": self.device_info, + "status": "ready" if self.is_connected else "disconnected" + } + + # PyLabRobot 抽象方法实现 + def stop(self): + """停止所有操作""" + self.logger.info("停止所有操作") + pass + + @property + def num_channels(self) -> int: + """返回通道数量""" + return 1 # 单通道移液器 + + def can_pick_up_tip(self, tip_rack, tip_position) -> bool: + """检查是否可以拾取吸头""" + return True # 简化实现,总是返回True + + def pick_up_tips(self, tip_rack, tip_positions): + """拾取多个吸头""" + self.logger.info(f"拾取吸头: {tip_positions}") + pass + + def drop_tips(self, tip_rack, tip_positions): + """丢弃多个吸头""" + self.logger.info(f"丢弃吸头: {tip_positions}") + pass + + def pick_up_tips96(self, tip_rack): + """拾取96个吸头""" + self.logger.info("拾取96个吸头") + pass + + def drop_tips96(self, tip_rack): + """丢弃96个吸头""" + self.logger.info("丢弃96个吸头") + pass + + def aspirate96(self, volume, plate, well_positions): + """96通道吸液""" + self.logger.info(f"96通道吸液: 体积={volume}") + pass + + def dispense96(self, volume, plate, well_positions): + """96通道排液""" + self.logger.info(f"96通道排液: 体积={volume}") + pass + + def pick_up_resource(self, resource, location): + """拾取资源""" + self.logger.info(f"拾取资源: {resource}") + pass + + def drop_resource(self, resource, location): + """放置资源""" + self.logger.info(f"放置资源: {resource}") + pass + + def move_picked_up_resource(self, resource, location): + """移动已拾取的资源""" + self.logger.info(f"移动资源: {resource} 到 {location}") + pass + + +def create_laiyu_backend(name: str = "LaiYu_Liquid_Backend") -> LaiYuLiquidBackend: + """ + 创建LaiYu液体处理设备后端实例 + + Args: + name: 后端名称 + + Returns: + LaiYuLiquidBackend: 后端实例 + """ + return LaiYuLiquidBackend(name) \ No newline at end of file diff --git a/unilabos/devices/liquid_handling/laiyu/backend/laiyu_v_backend.py b/unilabos/devices/liquid_handling/laiyu/backend/laiyu_v_backend.py new file mode 100644 index 00000000..d5636b25 --- /dev/null +++ b/unilabos/devices/liquid_handling/laiyu/backend/laiyu_v_backend.py @@ -0,0 +1,385 @@ + +import json +from typing import List, Optional, Union + +from pylabrobot.liquid_handling.backends.backend import ( + LiquidHandlerBackend, +) +from pylabrobot.liquid_handling.standard import ( + Drop, + DropTipRack, + MultiHeadAspirationContainer, + MultiHeadAspirationPlate, + MultiHeadDispenseContainer, + MultiHeadDispensePlate, + Pickup, + PickupTipRack, + ResourceDrop, + ResourceMove, + ResourcePickup, + SingleChannelAspiration, + SingleChannelDispense, +) +from pylabrobot.resources import Resource, Tip + +import rclpy +from rclpy.node import Node +from sensor_msgs.msg import JointState +import time +from rclpy.action import ActionClient +from unilabos_msgs.action import SendCmd +import re + +from unilabos.devices.ros_dev.liquid_handler_joint_publisher import JointStatePublisher +from unilabos.devices.liquid_handling.laiyu.controllers.pipette_controller import PipetteController, TipStatus + + +class UniLiquidHandlerLaiyuBackend(LiquidHandlerBackend): + """Chatter box backend for device-free testing. Prints out all operations.""" + + _pip_length = 5 + _vol_length = 8 + _resource_length = 20 + _offset_length = 16 + _flow_rate_length = 10 + _blowout_length = 10 + _lld_z_length = 10 + _kwargs_length = 15 + _tip_type_length = 12 + _max_volume_length = 16 + _fitting_depth_length = 20 + _tip_length_length = 16 + # _pickup_method_length = 20 + _filter_length = 10 + + def __init__(self, num_channels: int = 8 , tip_length: float = 0 , total_height: float = 310, port: str = "/dev/ttyUSB0"): + """Initialize a chatter box backend.""" + super().__init__() + self._num_channels = num_channels + self.tip_length = tip_length + self.total_height = total_height +# rclpy.init() + if not rclpy.ok(): + rclpy.init() + self.joint_state_publisher = None + self.hardware_interface = PipetteController(port=port) + + async def setup(self): + # self.joint_state_publisher = JointStatePublisher() + # self.hardware_interface.xyz_controller.connect_device() + # self.hardware_interface.xyz_controller.home_all_axes() + await super().setup() + self.hardware_interface.connect() + self.hardware_interface.initialize() + + print("Setting up the liquid handler.") + + async def stop(self): + print("Stopping the liquid handler.") + + def serialize(self) -> dict: + return {**super().serialize(), "num_channels": self.num_channels} + + def pipette_aspirate(self, volume: float, flow_rate: float): + + self.hardware_interface.pipette.set_max_speed(flow_rate) + res = self.hardware_interface.pipette.aspirate(volume=volume) + + if not res: + self.hardware_interface.logger.error("吸取失败,当前体积: {self.hardware_interface.current_volume}") + return + + self.hardware_interface.current_volume += volume + + def pipette_dispense(self, volume: float, flow_rate: float): + + self.hardware_interface.pipette.set_max_speed(flow_rate) + res = self.hardware_interface.pipette.dispense(volume=volume) + if not res: + self.hardware_interface.logger.error("排液失败,当前体积: {self.hardware_interface.current_volume}") + return + self.hardware_interface.current_volume -= volume + + @property + def num_channels(self) -> int: + return self._num_channels + + async def assigned_resource_callback(self, resource: Resource): + print(f"Resource {resource.name} was assigned to the liquid handler.") + + async def unassigned_resource_callback(self, name: str): + print(f"Resource {name} was unassigned from the liquid handler.") + + async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int], **backend_kwargs): + print("Picking up tips:") + # print(ops.tip) + header = ( + f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} " + f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} " + f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} " + f"{'tip type':<{UniLiquidHandlerLaiyuBackend._tip_type_length}} " + f"{'max volume (µL)':<{UniLiquidHandlerLaiyuBackend._max_volume_length}} " + f"{'fitting depth (mm)':<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} " + f"{'tip length (mm)':<{UniLiquidHandlerLaiyuBackend._tip_length_length}} " + # f"{'pickup method':<{ChatterboxBackend._pickup_method_length}} " + f"{'filter':<{UniLiquidHandlerLaiyuBackend._filter_length}}" + ) + # print(header) + + for op, channel in zip(ops, use_channels): + offset = f"{round(op.offset.x, 1)},{round(op.offset.y, 1)},{round(op.offset.z, 1)}" + row = ( + f" p{channel}: " + f"{op.resource.name[-30:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} " + f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} " + f"{op.tip.__class__.__name__:<{UniLiquidHandlerLaiyuBackend._tip_type_length}} " + f"{op.tip.maximal_volume:<{UniLiquidHandlerLaiyuBackend._max_volume_length}} " + f"{op.tip.fitting_depth:<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} " + f"{op.tip.total_tip_length:<{UniLiquidHandlerLaiyuBackend._tip_length_length}} " + # f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} " + f"{'Yes' if op.tip.has_filter else 'No':<{UniLiquidHandlerLaiyuBackend._filter_length}}" + ) + # print(row) + # print(op.resource.get_absolute_location()) + + self.tip_length = ops[0].tip.total_tip_length + coordinate = ops[0].resource.get_absolute_location(x="c",y="c") + offset_xyz = ops[0].offset + x = coordinate.x + offset_xyz.x + y = coordinate.y + offset_xyz.y + z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z + # print("moving") + self.hardware_interface._update_tip_status() + if self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED: + print("已有枪头,无需重复拾取") + return + self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=100) + self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height,speed=100) + # self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "pick",channels=use_channels) + # goback() + + + + + async def drop_tips(self, ops: List[Drop], use_channels: List[int], **backend_kwargs): + print("Dropping tips:") + header = ( + f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} " + f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} " + f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} " + f"{'tip type':<{UniLiquidHandlerLaiyuBackend._tip_type_length}} " + f"{'max volume (µL)':<{UniLiquidHandlerLaiyuBackend._max_volume_length}} " + f"{'fitting depth (mm)':<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} " + f"{'tip length (mm)':<{UniLiquidHandlerLaiyuBackend._tip_length_length}} " + # f"{'pickup method':<{ChatterboxBackend._pickup_method_length}} " + f"{'filter':<{UniLiquidHandlerLaiyuBackend._filter_length}}" + ) + # print(header) + + for op, channel in zip(ops, use_channels): + offset = f"{round(op.offset.x, 1)},{round(op.offset.y, 1)},{round(op.offset.z, 1)}" + row = ( + f" p{channel}: " + f"{op.resource.name[-30:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} " + f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} " + f"{op.tip.__class__.__name__:<{UniLiquidHandlerLaiyuBackend._tip_type_length}} " + f"{op.tip.maximal_volume:<{UniLiquidHandlerLaiyuBackend._max_volume_length}} " + f"{op.tip.fitting_depth:<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} " + f"{op.tip.total_tip_length:<{UniLiquidHandlerLaiyuBackend._tip_length_length}} " + # f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} " + f"{'Yes' if op.tip.has_filter else 'No':<{UniLiquidHandlerLaiyuBackend._filter_length}}" + ) + # print(row) + + coordinate = ops[0].resource.get_absolute_location(x="c",y="c") + offset_xyz = ops[0].offset + x = coordinate.x + offset_xyz.x + y = coordinate.y + offset_xyz.y + z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z -20 + # print(x, y, z) + # print("moving") + self.hardware_interface._update_tip_status() + if self.hardware_interface.tip_status == TipStatus.NO_TIP: + print("无枪头,无需丢弃") + return + self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z) + self.hardware_interface.eject_tip + self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height) + + async def aspirate( + self, + ops: List[SingleChannelAspiration], + use_channels: List[int], + **backend_kwargs, + ): + print("Aspirating:") + header = ( + f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} " + f"{'vol(ul)':<{UniLiquidHandlerLaiyuBackend._vol_length}} " + f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} " + f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} " + f"{'flow rate':<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} " + f"{'blowout':<{UniLiquidHandlerLaiyuBackend._blowout_length}} " + f"{'lld_z':<{UniLiquidHandlerLaiyuBackend._lld_z_length}} " + # f"{'liquids':<20}" # TODO: add liquids + ) + for key in backend_kwargs: + header += f"{key:<{UniLiquidHandlerLaiyuBackend._kwargs_length}} "[-16:] + # print(header) + + for o, p in zip(ops, use_channels): + offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}" + row = ( + f" p{p}: " + f"{o.volume:<{UniLiquidHandlerLaiyuBackend._vol_length}} " + f"{o.resource.name[-20:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} " + f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} " + f"{str(o.flow_rate):<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} " + f"{str(o.blow_out_air_volume):<{UniLiquidHandlerLaiyuBackend._blowout_length}} " + f"{str(o.liquid_height):<{UniLiquidHandlerLaiyuBackend._lld_z_length}} " + # f"{o.liquids if o.liquids is not None else 'none'}" + ) + for key, value in backend_kwargs.items(): + if isinstance(value, list) and all(isinstance(v, bool) for v in value): + value = "".join("T" if v else "F" for v in value) + if isinstance(value, list): + value = "".join(map(str, value)) + row += f" {value:<15}" + # print(row) + coordinate = ops[0].resource.get_absolute_location(x="c",y="c") + offset_xyz = ops[0].offset + x = coordinate.x + offset_xyz.x + y = coordinate.y + offset_xyz.y + z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z + # print(x, y, z) + # print("moving") + + # 判断枪头是否存在 + self.hardware_interface._update_tip_status() + if not self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED: + print("无枪头,无法吸液") + return + # 判断吸液量是否超过枪头容量 + flow_rate = backend_kwargs["flow_rate"] if "flow_rate" in backend_kwargs else 500 + blow_out_air_volume = backend_kwargs["blow_out_air_volume"] if "blow_out_air_volume" in backend_kwargs else 0 + if self.hardware_interface.current_volume + ops[0].volume + blow_out_air_volume > self.hardware_interface.max_volume: + self.hardware_interface.logger.error(f"吸液量超过枪头容量: {self.hardware_interface.current_volume + ops[0].volume} > {self.hardware_interface.max_volume}") + return + + # 移动到吸液位置 + self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z) + self.pipette_aspirate(volume=ops[0].volume, flow_rate=flow_rate) + + + self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height) + if blow_out_air_volume >0: + self.pipette_aspirate(volume=blow_out_air_volume, flow_rate=flow_rate) + + + + + async def dispense( + self, + ops: List[SingleChannelDispense], + use_channels: List[int], + **backend_kwargs, + ): + # print("Dispensing:") + header = ( + f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} " + f"{'vol(ul)':<{UniLiquidHandlerLaiyuBackend._vol_length}} " + f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} " + f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} " + f"{'flow rate':<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} " + f"{'blowout':<{UniLiquidHandlerLaiyuBackend._blowout_length}} " + f"{'lld_z':<{UniLiquidHandlerLaiyuBackend._lld_z_length}} " + # f"{'liquids':<20}" # TODO: add liquids + ) + for key in backend_kwargs: + header += f"{key:<{UniLiquidHandlerLaiyuBackend._kwargs_length}} "[-16:] + # print(header) + + for o, p in zip(ops, use_channels): + offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}" + row = ( + f" p{p}: " + f"{o.volume:<{UniLiquidHandlerLaiyuBackend._vol_length}} " + f"{o.resource.name[-20:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} " + f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} " + f"{str(o.flow_rate):<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} " + f"{str(o.blow_out_air_volume):<{UniLiquidHandlerLaiyuBackend._blowout_length}} " + f"{str(o.liquid_height):<{UniLiquidHandlerLaiyuBackend._lld_z_length}} " + # f"{o.liquids if o.liquids is not None else 'none'}" + ) + for key, value in backend_kwargs.items(): + if isinstance(value, list) and all(isinstance(v, bool) for v in value): + value = "".join("T" if v else "F" for v in value) + if isinstance(value, list): + value = "".join(map(str, value)) + row += f" {value:<{UniLiquidHandlerLaiyuBackend._kwargs_length}}" + # print(row) + coordinate = ops[0].resource.get_absolute_location(x="c",y="c") + offset_xyz = ops[0].offset + x = coordinate.x + offset_xyz.x + y = coordinate.y + offset_xyz.y + z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z + # print(x, y, z) + # print("moving") + + # 判断枪头是否存在 + self.hardware_interface._update_tip_status() + if not self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED: + print("无枪头,无法排液") + return + # 判断排液量是否超过枪头容量 + flow_rate = backend_kwargs["flow_rate"] if "flow_rate" in backend_kwargs else 500 + blow_out_air_volume = backend_kwargs["blow_out_air_volume"] if "blow_out_air_volume" in backend_kwargs else 0 + if self.hardware_interface.current_volume - ops[0].volume - blow_out_air_volume < 0: + self.hardware_interface.logger.error(f"排液量超过枪头容量: {self.hardware_interface.current_volume - ops[0].volume - blow_out_air_volume} < 0") + return + + + # 移动到排液位置 + self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z) + self.pipette_dispense(volume=ops[0].volume, flow_rate=flow_rate) + + + self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height) + if blow_out_air_volume > 0: + self.pipette_dispense(volume=blow_out_air_volume, flow_rate=flow_rate) + # self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "",channels=use_channels) + + async def pick_up_tips96(self, pickup: PickupTipRack, **backend_kwargs): + print(f"Picking up tips from {pickup.resource.name}.") + + async def drop_tips96(self, drop: DropTipRack, **backend_kwargs): + print(f"Dropping tips to {drop.resource.name}.") + + async def aspirate96( + self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer] + ): + if isinstance(aspiration, MultiHeadAspirationPlate): + resource = aspiration.wells[0].parent + else: + resource = aspiration.container + print(f"Aspirating {aspiration.volume} from {resource}.") + + async def dispense96(self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]): + if isinstance(dispense, MultiHeadDispensePlate): + resource = dispense.wells[0].parent + else: + resource = dispense.container + print(f"Dispensing {dispense.volume} to {resource}.") + + async def pick_up_resource(self, pickup: ResourcePickup): + print(f"Picking up resource: {pickup}") + + async def move_picked_up_resource(self, move: ResourceMove): + print(f"Moving picked up resource: {move}") + + async def drop_resource(self, drop: ResourceDrop): + print(f"Dropping resource: {drop}") + + def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: + return True + diff --git a/unilabos/devices/liquid_handling/laiyu/config/deckconfig.json b/unilabos/devices/liquid_handling/laiyu/config/deckconfig.json new file mode 100644 index 00000000..ddda7e0f --- /dev/null +++ b/unilabos/devices/liquid_handling/laiyu/config/deckconfig.json @@ -0,0 +1,2620 @@ +{ + "name": "LaiYu_Liquid_Deck", + "size_x": 340.0, + "size_y": 250.0, + "size_z": 160.0, + "coordinate_system": { + "origin": "top_left", + "x_axis": "right", + "y_axis": "down", + "z_axis": "up", + "units": "mm" + }, + "children": [ + { + "id": "module_1_8tubes", + "name": "8管位置模块", + "type": "tube_rack", + "position": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "size": { + "x": 151.0, + "y": 75.0, + "z": 75.0 + }, + "wells": [ + { + "id": "A1", + "position": { + "x": 23.0, + "y": 20.0, + "z": 0.0 + }, + "diameter": 29.0, + "depth": 117.0, + "volume": 77000.0, + "shape": "circular" + }, + { + "id": "A2", + "position": { + "x": 58.0, + "y": 20.0, + "z": 0.0 + }, + "diameter": 29.0, + "depth": 117.0, + "volume": 77000.0, + "shape": "circular" + }, + { + "id": "A3", + "position": { + "x": 93.0, + "y": 20.0, + "z": 0.0 + }, + "diameter": 29.0, + "depth": 117.0, + "volume": 77000.0, + "shape": "circular" + }, + { + "id": "A4", + "position": { + "x": 128.0, + "y": 20.0, + "z": 0.0 + }, + "diameter": 29.0, + "depth": 117.0, + "volume": 77000.0, + "shape": "circular" + }, + { + "id": "B1", + "position": { + "x": 23.0, + "y": 55.0, + "z": 0.0 + }, + "diameter": 29.0, + "depth": 117.0, + "volume": 77000.0, + "shape": "circular" + }, + { + "id": "B2", + "position": { + "x": 58.0, + "y": 55.0, + "z": 0.0 + }, + "diameter": 29.0, + "depth": 117.0, + "volume": 77000.0, + "shape": "circular" + }, + { + "id": "B3", + "position": { + "x": 93.0, + "y": 55.0, + "z": 0.0 + }, + "diameter": 29.0, + "depth": 117.0, + "volume": 77000.0, + "shape": "circular" + }, + { + "id": "B4", + "position": { + "x": 128.0, + "y": 55.0, + "z": 0.0 + }, + "diameter": 29.0, + "depth": 117.0, + "volume": 77000.0, + "shape": "circular" + } + ], + "well_spacing": { + "x": 35.0, + "y": 35.0 + }, + "grid": { + "rows": 2, + "columns": 4, + "row_labels": ["A", "B"], + "column_labels": ["1", "2", "3", "4"] + }, + "metadata": { + "description": "8个试管位置,2x4排列", + "max_volume_ul": 77000, + "well_count": 8, + "tube_type": "50ml_falcon" + } + }, + { + "id": "module_2_96well_deep", + "name": "96深孔板", + "type": "96_well_plate", + "position": { + "x": 175.0, + "y": 11.0, + "z": 48.5 + }, + "size": { + "x": 127.1, + "y": 85.6, + "z": 45.5 + }, + "wells": [ + { + "id": "A01", + "position": { + "x": 175.0, + "y": 11.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "A02", + "position": { + "x": 184.0, + "y": 11.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "A03", + "position": { + "x": 193.0, + "y": 11.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "A04", + "position": { + "x": 202.0, + "y": 11.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "A05", + "position": { + "x": 211.0, + "y": 11.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "A06", + "position": { + "x": 220.0, + "y": 11.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "A07", + "position": { + "x": 229.0, + "y": 11.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "A08", + "position": { + "x": 238.0, + "y": 11.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "A09", + "position": { + "x": 247.0, + "y": 11.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "A10", + "position": { + "x": 256.0, + "y": 11.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "A11", + "position": { + "x": 265.0, + "y": 11.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "A12", + "position": { + "x": 274.0, + "y": 11.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "B01", + "position": { + "x": 175.0, + "y": 20.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "B02", + "position": { + "x": 184.0, + "y": 20.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "B03", + "position": { + "x": 193.0, + "y": 20.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "B04", + "position": { + "x": 202.0, + "y": 20.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "B05", + "position": { + "x": 211.0, + "y": 20.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "B06", + "position": { + "x": 220.0, + "y": 20.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "B07", + "position": { + "x": 229.0, + "y": 20.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "B08", + "position": { + "x": 238.0, + "y": 20.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "B09", + "position": { + "x": 247.0, + "y": 20.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "B10", + "position": { + "x": 256.0, + "y": 20.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "B11", + "position": { + "x": 265.0, + "y": 20.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "B12", + "position": { + "x": 274.0, + "y": 20.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "C01", + "position": { + "x": 175.0, + "y": 29.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "C02", + "position": { + "x": 184.0, + "y": 29.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "C03", + "position": { + "x": 193.0, + "y": 29.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "C04", + "position": { + "x": 202.0, + "y": 29.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "C05", + "position": { + "x": 211.0, + "y": 29.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "C06", + "position": { + "x": 220.0, + "y": 29.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "C07", + "position": { + "x": 229.0, + "y": 29.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "C08", + "position": { + "x": 238.0, + "y": 29.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "C09", + "position": { + "x": 247.0, + "y": 29.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "C10", + "position": { + "x": 256.0, + "y": 29.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "C11", + "position": { + "x": 265.0, + "y": 29.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "C12", + "position": { + "x": 274.0, + "y": 29.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "D01", + "position": { + "x": 175.0, + "y": 38.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "D02", + "position": { + "x": 184.0, + "y": 38.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "D03", + "position": { + "x": 193.0, + "y": 38.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "D04", + "position": { + "x": 202.0, + "y": 38.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "D05", + "position": { + "x": 211.0, + "y": 38.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "D06", + "position": { + "x": 220.0, + "y": 38.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "D07", + "position": { + "x": 229.0, + "y": 38.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "D08", + "position": { + "x": 238.0, + "y": 38.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "D09", + "position": { + "x": 247.0, + "y": 38.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "D10", + "position": { + "x": 256.0, + "y": 38.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "D11", + "position": { + "x": 265.0, + "y": 38.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "D12", + "position": { + "x": 274.0, + "y": 38.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "E01", + "position": { + "x": 175.0, + "y": 47.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "E02", + "position": { + "x": 184.0, + "y": 47.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "E03", + "position": { + "x": 193.0, + "y": 47.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "E04", + "position": { + "x": 202.0, + "y": 47.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "E05", + "position": { + "x": 211.0, + "y": 47.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "E06", + "position": { + "x": 220.0, + "y": 47.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "E07", + "position": { + "x": 229.0, + "y": 47.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "E08", + "position": { + "x": 238.0, + "y": 47.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "E09", + "position": { + "x": 247.0, + "y": 47.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "E10", + "position": { + "x": 256.0, + "y": 47.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "E11", + "position": { + "x": 265.0, + "y": 47.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "E12", + "position": { + "x": 274.0, + "y": 47.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "F01", + "position": { + "x": 175.0, + "y": 56.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "F02", + "position": { + "x": 184.0, + "y": 56.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "F03", + "position": { + "x": 193.0, + "y": 56.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "F04", + "position": { + "x": 202.0, + "y": 56.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "F05", + "position": { + "x": 211.0, + "y": 56.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "F06", + "position": { + "x": 220.0, + "y": 56.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "F07", + "position": { + "x": 229.0, + "y": 56.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "F08", + "position": { + "x": 238.0, + "y": 56.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "F09", + "position": { + "x": 247.0, + "y": 56.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "F10", + "position": { + "x": 256.0, + "y": 56.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "F11", + "position": { + "x": 265.0, + "y": 56.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "F12", + "position": { + "x": 274.0, + "y": 56.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "G01", + "position": { + "x": 175.0, + "y": 65.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "G02", + "position": { + "x": 184.0, + "y": 65.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "G03", + "position": { + "x": 193.0, + "y": 65.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "G04", + "position": { + "x": 202.0, + "y": 65.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "G05", + "position": { + "x": 211.0, + "y": 65.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "G06", + "position": { + "x": 220.0, + "y": 65.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "G07", + "position": { + "x": 229.0, + "y": 65.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "G08", + "position": { + "x": 238.0, + "y": 65.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "G09", + "position": { + "x": 247.0, + "y": 65.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "G10", + "position": { + "x": 256.0, + "y": 65.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "G11", + "position": { + "x": 265.0, + "y": 65.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "G12", + "position": { + "x": 274.0, + "y": 65.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "H01", + "position": { + "x": 175.0, + "y": 74.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "H02", + "position": { + "x": 184.0, + "y": 74.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "H03", + "position": { + "x": 193.0, + "y": 74.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "H04", + "position": { + "x": 202.0, + "y": 74.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "H05", + "position": { + "x": 211.0, + "y": 74.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "H06", + "position": { + "x": 220.0, + "y": 74.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "H07", + "position": { + "x": 229.0, + "y": 74.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "H08", + "position": { + "x": 238.0, + "y": 74.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "H09", + "position": { + "x": 247.0, + "y": 74.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "H10", + "position": { + "x": 256.0, + "y": 74.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "H11", + "position": { + "x": 265.0, + "y": 74.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + }, + { + "id": "H12", + "position": { + "x": 274.0, + "y": 74.0, + "z": 48.5 + }, + "diameter": 8.2, + "depth": 39.4, + "volume": 2080.0, + "shape": "circular" + } + ], + "well_spacing": { + "x": 9.0, + "y": 9.0 + }, + "grid": { + "rows": 8, + "columns": 12, + "row_labels": ["A", "B", "C", "D", "E", "F", "G", "H"], + "column_labels": ["01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12"] + }, + "metadata": { + "description": "深孔96孔板", + "max_volume_ul": 2080, + "well_count": 96, + "plate_type": "deep_well_plate" + } + }, + { + "id": "module_3_beaker", + "name": "敞口玻璃瓶", + "type": "beaker_holder", + "position": { + "x": 65.0, + "y": 143.5, + "z": 0.0 + }, + "size": { + "x": 130.0, + "y": 117.0, + "z": 110.0 + }, + "wells": [ + { + "id": "A1", + "position": { + "x": 65.0, + "y": 143.5, + "z": 0.0 + }, + "diameter": 80.0, + "depth": 145.0, + "volume": 500000.0, + "shape": "circular", + "container_type": "beaker" + } + ], + "supported_containers": [ + { + "type": "beaker_250ml", + "diameter": 70.0, + "height": 95.0, + "volume": 250000.0 + }, + { + "type": "beaker_500ml", + "diameter": 85.0, + "height": 115.0, + "volume": 500000.0 + }, + { + "type": "beaker_1000ml", + "diameter": 105.0, + "height": 145.0, + "volume": 1000000.0 + } + ], + "metadata": { + "description": "敞口玻璃瓶固定座,支持250ml-1000ml烧杯", + "max_beaker_diameter": 80.0, + "max_beaker_height": 145.0, + "well_count": 1, + "access_from_top": true + } + }, + { + "id": "module_4_96well_tips", + "name": "96枪头盒", + "type": "96_tip_rack", + "position": { + "x": 165.62, + "y": 115.5, + "z": 103.0 + }, + "size": { + "x": 134.0, + "y": 96.0, + "z": 7.0 + }, + "wells": [ + { + "id": "A01", + "position": { + "x": 165.62, + "y": 115.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "A02", + "position": { + "x": 174.62, + "y": 115.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "A03", + "position": { + "x": 183.62, + "y": 115.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "A04", + "position": { + "x": 192.62, + "y": 115.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "A05", + "position": { + "x": 201.62, + "y": 115.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "A06", + "position": { + "x": 210.62, + "y": 115.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "A07", + "position": { + "x": 219.62, + "y": 115.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "A08", + "position": { + "x": 228.62, + "y": 115.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "A09", + "position": { + "x": 237.62, + "y": 115.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "A10", + "position": { + "x": 246.62, + "y": 115.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "A11", + "position": { + "x": 255.62, + "y": 115.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "A12", + "position": { + "x": 264.62, + "y": 115.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "B01", + "position": { + "x": 165.62, + "y": 124.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "B02", + "position": { + "x": 174.62, + "y": 124.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "B03", + "position": { + "x": 183.62, + "y": 124.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "B04", + "position": { + "x": 192.62, + "y": 124.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "B05", + "position": { + "x": 201.62, + "y": 124.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "B06", + "position": { + "x": 210.62, + "y": 124.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "B07", + "position": { + "x": 219.62, + "y": 124.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "B08", + "position": { + "x": 228.62, + "y": 124.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "B09", + "position": { + "x": 237.62, + "y": 124.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "B10", + "position": { + "x": 246.62, + "y": 124.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "B11", + "position": { + "x": 255.62, + "y": 124.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "B12", + "position": { + "x": 264.62, + "y": 124.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "C01", + "position": { + "x": 165.62, + "y": 133.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "C02", + "position": { + "x": 174.62, + "y": 133.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "C03", + "position": { + "x": 183.62, + "y": 133.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "C04", + "position": { + "x": 192.62, + "y": 133.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "C05", + "position": { + "x": 201.62, + "y": 133.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "C06", + "position": { + "x": 210.62, + "y": 133.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "C07", + "position": { + "x": 219.62, + "y": 133.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "C08", + "position": { + "x": 228.62, + "y": 133.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "C09", + "position": { + "x": 237.62, + "y": 133.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "C10", + "position": { + "x": 246.62, + "y": 133.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "C11", + "position": { + "x": 255.62, + "y": 133.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "C12", + "position": { + "x": 264.62, + "y": 133.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "D01", + "position": { + "x": 165.62, + "y": 142.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "D02", + "position": { + "x": 174.62, + "y": 142.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "D03", + "position": { + "x": 183.62, + "y": 142.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "D04", + "position": { + "x": 192.62, + "y": 142.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "D05", + "position": { + "x": 201.62, + "y": 142.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "D06", + "position": { + "x": 210.62, + "y": 142.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "D07", + "position": { + "x": 219.62, + "y": 142.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "D08", + "position": { + "x": 228.62, + "y": 142.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "D09", + "position": { + "x": 237.62, + "y": 142.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "D10", + "position": { + "x": 246.62, + "y": 142.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "D11", + "position": { + "x": 255.62, + "y": 142.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "D12", + "position": { + "x": 264.62, + "y": 142.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "E01", + "position": { + "x": 165.62, + "y": 151.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "E02", + "position": { + "x": 174.62, + "y": 151.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "E03", + "position": { + "x": 183.62, + "y": 151.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "E04", + "position": { + "x": 192.62, + "y": 151.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "E05", + "position": { + "x": 201.62, + "y": 151.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "E06", + "position": { + "x": 210.62, + "y": 151.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "E07", + "position": { + "x": 219.62, + "y": 151.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "E08", + "position": { + "x": 228.62, + "y": 151.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "E09", + "position": { + "x": 237.62, + "y": 151.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "E10", + "position": { + "x": 246.62, + "y": 151.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "E11", + "position": { + "x": 255.62, + "y": 151.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "E12", + "position": { + "x": 264.62, + "y": 151.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "F01", + "position": { + "x": 165.62, + "y": 160.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "F02", + "position": { + "x": 174.62, + "y": 160.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "F03", + "position": { + "x": 183.62, + "y": 160.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "F04", + "position": { + "x": 192.62, + "y": 160.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "F05", + "position": { + "x": 201.62, + "y": 160.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "F06", + "position": { + "x": 210.62, + "y": 160.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "F07", + "position": { + "x": 219.62, + "y": 160.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "F08", + "position": { + "x": 228.62, + "y": 160.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "F09", + "position": { + "x": 237.62, + "y": 160.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "F10", + "position": { + "x": 246.62, + "y": 160.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "F11", + "position": { + "x": 255.62, + "y": 160.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "F12", + "position": { + "x": 264.62, + "y": 160.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "G01", + "position": { + "x": 165.62, + "y": 169.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "G02", + "position": { + "x": 174.62, + "y": 169.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "G03", + "position": { + "x": 183.62, + "y": 169.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "G04", + "position": { + "x": 192.62, + "y": 169.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "G05", + "position": { + "x": 201.62, + "y": 169.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "G06", + "position": { + "x": 210.62, + "y": 169.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "G07", + "position": { + "x": 219.62, + "y": 169.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "G08", + "position": { + "x": 228.62, + "y": 169.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "G09", + "position": { + "x": 237.62, + "y": 169.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "G10", + "position": { + "x": 246.62, + "y": 169.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "G11", + "position": { + "x": 255.62, + "y": 169.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "G12", + "position": { + "x": 264.62, + "y": 169.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "H01", + "position": { + "x": 165.62, + "y": 178.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "H02", + "position": { + "x": 174.62, + "y": 178.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "H03", + "position": { + "x": 183.62, + "y": 178.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "H04", + "position": { + "x": 192.62, + "y": 178.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "H05", + "position": { + "x": 201.62, + "y": 178.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "H06", + "position": { + "x": 210.62, + "y": 178.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "H07", + "position": { + "x": 219.62, + "y": 178.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "H08", + "position": { + "x": 228.62, + "y": 178.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "H09", + "position": { + "x": 237.62, + "y": 178.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "H10", + "position": { + "x": 246.62, + "y": 178.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "H11", + "position": { + "x": 255.62, + "y": 178.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + }, + { + "id": "H12", + "position": { + "x": 264.62, + "y": 178.5, + "z": 103.0 + }, + "diameter": 9.0, + "depth": 95.0, + "volume": 6000.0, + "shape": "circular" + } + ], + "well_spacing": { + "x": 9.0, + "y": 9.0 + }, + "grid": { + "rows": 8, + "columns": 12, + "row_labels": ["A", "B", "C", "D", "E", "F", "G", "H"], + "column_labels": ["01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12"] + }, + "metadata": { + "description": "标准96孔枪头盒", + "max_volume_ul": 6000, + "well_count": 96, + "plate_type": "tip_rack" + } + } + ], + "deck_metadata": { + "total_modules": 4, + "total_wells": 201, + "deck_area": { + "used_x": 299.62, + "used_y": 260.5, + "used_z": 103.0, + "efficiency_x": 88.1, + "efficiency_y": 104.2, + "efficiency_z": 64.4 + }, + "safety_margins": { + "x_min": 10.0, + "x_max": 10.0, + "y_min": 10.0, + "y_max": 10.0, + "z_clearance": 20.0 + }, + "calibration_points": [ + { + "id": "origin", + "position": {"x": 0.0, "y": 0.0, "z": 0.0}, + "description": "工作台左上角原点" + }, + { + "id": "module_1_ref", + "position": {"x": 23.0, "y": 20.0, "z": 0.0}, + "description": "模块1试管架基准孔A1" + }, + { + "id": "module_2_ref", + "position": {"x": 175.0, "y": 11.0, "z": 48.5}, + "description": "模块2深孔板基准孔A01" + }, + { + "id": "module_3_ref", + "position": {"x": 65.0, "y": 143.5, "z": 0.0}, + "description": "模块3敞口玻璃瓶中心" + }, + { + "id": "module_4_ref", + "position": {"x": 165.62, "y": 115.5, "z": 103.0}, + "description": "模块4枪头盒基准孔A01" + } + ], + "version": "2.0", + "created_by": "Doraemon Team", + "last_updated": "2025-09-29" + } +} \ No newline at end of file diff --git a/unilabos/devices/liquid_handling/laiyu/controllers/__init__.py b/unilabos/devices/liquid_handling/laiyu/controllers/__init__.py new file mode 100644 index 00000000..d50b1eca --- /dev/null +++ b/unilabos/devices/liquid_handling/laiyu/controllers/__init__.py @@ -0,0 +1,25 @@ +""" +LaiYu_Liquid 控制器模块 + +该模块包含了LaiYu_Liquid液体处理工作站的高级控制器: +- 移液器控制器:提供液体处理的高级接口 +- XYZ运动控制器:提供三轴运动的高级接口 +""" + +# 移液器控制器导入 +from .pipette_controller import PipetteController + +# XYZ运动控制器导入 +from .xyz_controller import XYZController + +__all__ = [ + # 移液器控制器 + "PipetteController", + + # XYZ运动控制器 + "XYZController", +] + +__version__ = "1.0.0" +__author__ = "LaiYu_Liquid Controller Team" +__description__ = "LaiYu_Liquid 高级控制器集合" \ No newline at end of file diff --git a/unilabos/devices/liquid_handling/laiyu/controllers/coordinate_origin.json b/unilabos/devices/liquid_handling/laiyu/controllers/coordinate_origin.json new file mode 100644 index 00000000..b21901ec --- /dev/null +++ b/unilabos/devices/liquid_handling/laiyu/controllers/coordinate_origin.json @@ -0,0 +1,14 @@ +{ + "machine_origin_steps": { + "x": -198.43, + "y": -94.25, + "z": -0.73 + }, + "work_origin_steps": { + "x": 59.39, + "y": 216.99, + "z": 2.0 + }, + "is_homed": true, + "timestamp": "2025-10-29T20:34:11.749055" +} \ No newline at end of file diff --git a/unilabos/devices/liquid_handling/laiyu/controllers/pipette_controller.py b/unilabos/devices/liquid_handling/laiyu/controllers/pipette_controller.py new file mode 100644 index 00000000..e6ddd4f0 --- /dev/null +++ b/unilabos/devices/liquid_handling/laiyu/controllers/pipette_controller.py @@ -0,0 +1,1097 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +移液控制器模块 +封装SOPA移液器的高级控制功能 +""" + +# 添加项目根目录到Python路径以解决模块导入问题 +import sys +import os +from tkinter import N + +from unilabos.devices.liquid_handling.laiyu.drivers.xyz_stepper_driver import ModbusException + +# 无论如何都添加项目根目录到路径 +current_file = os.path.abspath(__file__) +# 从 .../Uni-Lab-OS/unilabos/devices/LaiYu_Liquid/controllers/pipette_controller.py +# 向上5级到 .../Uni-Lab-OS +project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(current_file))))) +# 强制添加项目根目录到sys.path的开头 +sys.path.insert(0, project_root) + +import time +import logging +from typing import Optional, List, Dict, Tuple +from dataclasses import dataclass +from enum import Enum + +from unilabos.devices.liquid_handling.laiyu.drivers.sopa_pipette_driver import ( + SOPAPipette, + SOPAConfig, + SOPAStatusCode, + DetectionMode, + create_sopa_pipette, +) +# from unilabos.devices.liquid_handling.laiyu.drivers.xyz_stepper_driver import ( +# XYZStepperController, +# MotorAxis, +# MotorStatus, +# ModbusException +# ) + +from unilabos.devices.liquid_handling.laiyu.controllers.xyz_controller import ( + XYZController, + MotorAxis, + MotorStatus +) + +logger = logging.getLogger(__name__) + + +class TipStatus(Enum): + """枪头状态""" + NO_TIP = "no_tip" + TIP_ATTACHED = "tip_attached" + TIP_USED = "tip_used" + + +class LiquidClass(Enum): + """液体类型""" + WATER = "water" + SERUM = "serum" + VISCOUS = "viscous" + VOLATILE = "volatile" + CUSTOM = "custom" + + +@dataclass +class LiquidParameters: + """液体处理参数""" + aspirate_speed: int = 500 # 吸液速度 + dispense_speed: int = 800 # 排液速度 + air_gap: float = 10.0 # 空气间隙 + blow_out: float = 5.0 # 吹出量 + pre_wet: bool = False # 预润湿 + mix_cycles: int = 0 # 混合次数 + mix_volume: float = 50.0 # 混合体积 + touch_tip: bool = False # 接触壁 + delay_after_aspirate: float = 0.5 # 吸液后延时 + delay_after_dispense: float = 0.5 # 排液后延时 + + +class PipetteController: + """移液控制器""" + + # 预定义液体参数 + LIQUID_PARAMS = { + LiquidClass.WATER: LiquidParameters( + aspirate_speed=500, + dispense_speed=800, + air_gap=10.0 + ), + LiquidClass.SERUM: LiquidParameters( + aspirate_speed=200, + dispense_speed=400, + air_gap=15.0, + pre_wet=True, + delay_after_aspirate=1.0 + ), + LiquidClass.VISCOUS: LiquidParameters( + aspirate_speed=100, + dispense_speed=200, + air_gap=20.0, + delay_after_aspirate=2.0, + delay_after_dispense=2.0 + ), + LiquidClass.VOLATILE: LiquidParameters( + aspirate_speed=800, + dispense_speed=1000, + air_gap=5.0, + delay_after_aspirate=0.2, + delay_after_dispense=0.2 + ) + } + + def __init__(self, port: str, address: int = 4, xyz_port: Optional[str] = None): + """ + 初始化移液控制器 + + Args: + port: 移液器串口端口 + address: 移液器RS485地址 + xyz_port: XYZ步进电机串口端口(可选,用于枪头装载等运动控制) + """ + self.config = SOPAConfig( + port=port, + address=address, + baudrate=115200 + ) + self.pipette = SOPAPipette(self.config) + self.tip_status = TipStatus.NO_TIP + self.current_volume = 0.0 + self.max_volume = 1000.0 # 默认1000ul + self.liquid_class = LiquidClass.WATER + self.liquid_params = self.LIQUID_PARAMS[LiquidClass.WATER] + + # XYZ步进电机控制器(用于运动控制) + self.xyz_controller: Optional[XYZController] = None + self.xyz_port = xyz_port if xyz_port else port + self.xyz_connected = True + + # 统计信息 + # self.tip_count = 0 + self.aspirate_count = 0 + self.dispense_count = 0 + + def connect(self) -> bool: + """连接移液器和XYZ步进电机控制器""" + try: + # 连接移液器 + if not self.pipette.connect(): + logger.error("移液器连接失败") + return False + logger.info("移液器连接成功") + + # 连接XYZ步进电机控制器(如果提供了端口) + if self.xyz_port: + try: + self.xyz_controller = XYZController(self.xyz_port) + if self.xyz_controller.connect(): + self.xyz_connected = True + logger.info(f"XYZ步进电机控制器连接成功: {self.xyz_port}") + else: + logger.warning(f"XYZ步进电机控制器连接失败: {self.xyz_port}") + self.xyz_controller = None + except Exception as e: + logger.warning(f"XYZ步进电机控制器连接异常: {e}") + self.xyz_controller = None + self.xyz_connected = False + else: + logger.info("未配置XYZ步进电机端口,跳过运动控制器连接") + + return True + except Exception as e: + logger.error(f"设备连接失败: {e}") + return False + + def initialize(self) -> bool: + """初始化移液器""" + try: + if self.pipette.initialize(): + logger.info("移液器初始化成功") + # 检查枪头状态 + self._update_tip_status() + self.xyz_controller.home_all_axes() + self.xyz_controller.move_to_work_coord_safe(x=0, y=-150, z=0) + return True + return False + except Exception as e: + logger.error(f"移液器初始化失败: {e}") + return False + + def disconnect(self): + """断开连接""" + # 断开移液器连接 + self.pipette.disconnect() + logger.info("移液器已断开") + + # 断开 XYZ 步进电机连接 + if self.xyz_controller and self.xyz_connected: + try: + self.xyz_controller.disconnect() + self.xyz_connected = False + logger.info("XYZ 步进电机已断开") + except Exception as e: + logger.error(f"断开 XYZ 步进电机失败: {e}") + + def _check_xyz_safety(self, axis: MotorAxis, target_position: int) -> bool: + """ + 检查 XYZ 轴移动的安全性 + + Args: + axis: 电机轴 + target_position: 目标位置(步数) + + Returns: + 是否安全 + """ + try: + # 获取当前电机状态 + motor_position = self.xyz_controller.get_motor_status(axis) + + # 检查电机状态是否正常 (不是碰撞停止或限位停止) + if motor_position.status in [MotorStatus.COLLISION_STOP, + MotorStatus.FORWARD_LIMIT_STOP, + MotorStatus.REVERSE_LIMIT_STOP]: + logger.error(f"{axis.name} 轴电机处于错误状态: {motor_position.status.name}") + return False + + # 检查位置限制 (扩大安全范围以适应实际硬件) + # 步进电机的位置范围通常很大,这里设置更合理的范围 + if target_position < -500000 or target_position > 500000: + logger.error(f"{axis.name} 轴目标位置超出安全范围: {target_position}") + return False + + # 检查移动距离是否过大 (单次移动不超过 20000 步,约12mm) + current_position = motor_position.steps + move_distance = abs(target_position - current_position) + if move_distance > 20000: + logger.error(f"{axis.name} 轴单次移动距离过大: {move_distance}步") + return False + + return True + + except Exception as e: + logger.error(f"安全检查失败: {e}") + return False + + def move_z_relative(self, distance_mm: float, speed: int = 2000, acceleration: int = 500) -> bool: + """ + Z轴相对移动 + + Args: + distance_mm: 移动距离(mm),正值向下,负值向上 + speed: 移动速度(rpm) + acceleration: 加速度(rpm/s) + + Returns: + 移动是否成功 + """ + if not self.xyz_controller or not self.xyz_connected: + logger.error("XYZ 步进电机未连接,无法执行移动") + return False + + try: + # 参数验证 + if abs(distance_mm) > 15.0: + logger.error(f"移动距离过大: {distance_mm}mm,最大允许15mm") + return False + + if speed < 100 or speed > 5000: + logger.error(f"速度参数无效: {speed}rpm,范围应为100-5000") + return False + + # 获取当前 Z 轴位置 + current_status = self.xyz_controller.get_motor_status(MotorAxis.Z) + current_z_position = current_status.steps + + # 计算移动距离对应的步数 (1mm = 1638.4步) + mm_to_steps = 1638.4 + move_distance_steps = int(distance_mm * mm_to_steps) + + # 计算目标位置 + target_z_position = current_z_position + move_distance_steps + + # 安全检查 + if not self._check_xyz_safety(MotorAxis.Z, target_z_position): + logger.error("Z轴移动安全检查失败") + return False + + logger.info(f"Z轴相对移动: {distance_mm}mm ({move_distance_steps}步)") + logger.info(f"当前位置: {current_z_position}步 -> 目标位置: {target_z_position}步") + + # 执行移动 + success = self.xyz_controller.move_to_position( + axis=MotorAxis.Z, + position=target_z_position, + speed=speed, + acceleration=acceleration, + precision=50 + ) + + if not success: + logger.error("Z轴移动命令发送失败") + return False + + # 等待移动完成 + if not self.xyz_controller.wait_for_completion(MotorAxis.Z, timeout=10.0): + logger.error("Z轴移动超时") + return False + + # 验证移动结果 + final_status = self.xyz_controller.get_motor_status(MotorAxis.Z) + final_position = final_status.steps + position_error = abs(final_position - target_z_position) + + logger.info(f"Z轴移动完成,最终位置: {final_position}步,误差: {position_error}步") + + if position_error > 100: + logger.warning(f"Z轴位置误差较大: {position_error}步") + + return True + + except ModbusException as e: + logger.error(f"Modbus通信错误: {e}") + return False + except Exception as e: + logger.error(f"Z轴移动失败: {e}") + return False + + def emergency_stop(self) -> bool: + """ + 紧急停止所有运动 + + Returns: + 停止是否成功 + """ + success = True + + # 停止移液器操作 + try: + if self.pipette and self.connected: + # 这里可以添加移液器的紧急停止逻辑 + logger.info("移液器紧急停止") + except Exception as e: + logger.error(f"移液器紧急停止失败: {e}") + success = False + + # 停止 XYZ 轴运动 + try: + if self.xyz_controller and self.xyz_connected: + self.xyz_controller.emergency_stop() + logger.info("XYZ 轴紧急停止") + except Exception as e: + logger.error(f"XYZ 轴紧急停止失败: {e}") + success = False + + return success + + def pickup_tip(self) -> bool: + """ + 装载枪头 - Z轴向下移动10mm进行枪头装载 + + Returns: + 是否成功 + """ + self._update_tip_status() + if self.tip_status == TipStatus.TIP_ATTACHED: + logger.warning("已有枪头,无需重复装载") + return True + + logger.info("开始装载枪头 - Z轴向下移动10mm") + + # 使用相对移动方法,向下移动10mm + if self.move_z_relative(distance_mm=10.0, speed=2000, acceleration=500): + # 更新枪头状态 + self._update_tip_status() + # self.tip_status = TipStatus.TIP_ATTACHED + # self.tip_count += 1 + self.current_volume = 0.0 + if self.tip_status == TipStatus.TIP_ATTACHED: + logger.info("枪头装载成功") + return True + else : + logger.info("枪头装载失败") + return False + else: + logger.error("枪头装载失败 - Z轴移动失败") + return False + + def eject_tip(self) -> bool: + """ + 弹出枪头 + + Returns: + 是否成功 + """ + + self._update_tip_status() + + if self.tip_status == TipStatus.NO_TIP: + logger.warning("无枪头可弹出") + return True + + try: + if self.pipette.eject_tip(): + self._update_tip_status() + if self.tip_status == TipStatus.NO_TIP: + self.current_volume = 0.0 + logger.info("枪头已弹出") + return True + return False + except Exception as e: + logger.error(f"弹出枪头失败: {e}") + return False + + def aspirate(self, volume: float, liquid_class: Optional[LiquidClass] = None, + detection: bool = True) -> bool: + """ + 吸液 + + Args: + volume: 吸液体积(ul) + liquid_class: 液体类型 + detection: 是否开启液位检测 + + Returns: + 是否成功 + """ + self._update_tip_status() + if self.tip_status != TipStatus.TIP_ATTACHED: + logger.error("无枪头,无法吸液") + return False + + if self.current_volume + volume > self.max_volume: + logger.error(f"吸液量超过枪头容量: {self.current_volume + volume} > {self.max_volume}") + return False + + # 设置液体参数 + if liquid_class: + self.set_liquid_class(liquid_class) + + try: + # 设置吸液速度 + self.pipette.set_max_speed(self.liquid_params.aspirate_speed) + + # 执行液位检测 + if detection: + if not self.pipette.liquid_level_detection(): + logger.warning("液位检测失败,继续吸液") + + # 预润湿 + if self.liquid_params.pre_wet and self.current_volume == 0: + logger.info("执行预润湿") + self._pre_wet(volume * 0.2) + + # 吸液 + if self.pipette.aspirate(volume, detection=False): + self.current_volume += volume + self.aspirate_count += 1 + + # 吸液后延时 + time.sleep(self.liquid_params.delay_after_aspirate) + + # 吸取空气间隙 + if self.liquid_params.air_gap > 0: + self.pipette.aspirate(self.liquid_params.air_gap, detection=False) + self.current_volume += self.liquid_params.air_gap + + logger.info(f"吸液完成: {volume}ul, 当前体积: {self.current_volume}ul") + return True + else: + logger.error("吸液失败") + return False + + except Exception as e: + logger.error(f"吸液异常: {e}") + return False + + def dispense(self, volume: float, blow_out: bool = False) -> bool: + """ + 排液 + + Args: + volume: 排液体积(ul) + blow_out: 是否吹出 + + Returns: + 是否成功 + """ + self._update_tip_status() + if self.tip_status != TipStatus.TIP_ATTACHED: + logger.error("无枪头,无法排液") + return False + + if volume > self.current_volume: + logger.error(f"排液量超过当前体积: {volume} > {self.current_volume}") + return False + + try: + # 设置排液速度 + self.pipette.set_max_speed(self.liquid_params.dispense_speed) + + # 排液 + if self.pipette.dispense(volume): + self.current_volume -= volume + self.dispense_count += 1 + + # 排液后延时 + time.sleep(self.liquid_params.delay_after_dispense) + + # 吹出 + if blow_out and self.liquid_params.blow_out > 0: + self.pipette.dispense(self.liquid_params.blow_out) + logger.debug(f"执行吹出: {self.liquid_params.blow_out}ul") + + # 接触壁 + if self.liquid_params.touch_tip: + self._touch_tip() + + logger.info(f"排液完成: {volume}ul, 剩余体积: {self.current_volume}ul") + return True + else: + logger.error("排液失败") + return False + + except Exception as e: + logger.error(f"排液异常: {e}") + return False + + def transfer(self, volume: float, + source_well: Optional[str] = None, + dest_well: Optional[str] = None, + liquid_class: Optional[LiquidClass] = None, + new_tip: bool = True, + mix_before: Optional[Tuple[int, float]] = None, + mix_after: Optional[Tuple[int, float]] = None) -> bool: + """ + 液体转移 + + Args: + volume: 转移体积 + source_well: 源孔位 + dest_well: 目标孔位 + liquid_class: 液体类型 + new_tip: 是否使用新枪头 + mix_before: 吸液前混合(次数, 体积) + mix_after: 排液后混合(次数, 体积) + + Returns: + 是否成功 + """ + try: + # 装载新枪头 + if new_tip: + self.eject_tip() + if not self.pickup_tip(): + return False + + # 设置液体类型 + if liquid_class: + self.set_liquid_class(liquid_class) + + # 吸液前混合 + if mix_before: + cycles, mix_vol = mix_before + self.mix(cycles, mix_vol) + + # 吸液 + if not self.aspirate(volume): + return False + + # 排液 + if not self.dispense(volume, blow_out=True): + return False + + # 排液后混合 + if mix_after: + cycles, mix_vol = mix_after + self.mix(cycles, mix_vol) + + logger.info(f"液体转移完成: {volume}ul") + return True + + except Exception as e: + logger.error(f"液体转移失败: {e}") + return False + + def mix(self, cycles: int = 3, volume: Optional[float] = None) -> bool: + """ + 混合 + + Args: + cycles: 混合次数 + volume: 混合体积 + + Returns: + 是否成功 + """ + volume = volume or self.liquid_params.mix_volume + + logger.info(f"开始混合: {cycles}次, {volume}ul") + + for i in range(cycles): + if not self.aspirate(volume, detection=False): + return False + if not self.dispense(volume): + return False + + logger.info("混合完成") + return True + + def _pre_wet(self, volume: float): + """预润湿""" + self.pipette.aspirate(volume, detection=False) + time.sleep(0.2) + self.pipette.dispense(volume) + time.sleep(0.2) + + def _touch_tip(self): + """接触壁(需要与运动控制配合)""" + # TODO: 实现接触壁动作 + logger.debug("执行接触壁") + time.sleep(0.5) + + def _update_tip_status(self): + """更新枪头状态""" + if self.pipette.get_tip_status(): + self.tip_status = TipStatus.TIP_ATTACHED + else: + self.tip_status = TipStatus.NO_TIP + + def set_liquid_class(self, liquid_class: LiquidClass): + """设置液体类型""" + self.liquid_class = liquid_class + if liquid_class in self.LIQUID_PARAMS: + self.liquid_params = self.LIQUID_PARAMS[liquid_class] + logger.info(f"液体类型设置为: {liquid_class.value}") + + def set_custom_parameters(self, params: LiquidParameters): + """设置自定义液体参数""" + self.liquid_params = params + self.liquid_class = LiquidClass.CUSTOM + + def calibrate_volume(self, expected: float, actual: float): + """ + 体积校准 + + Args: + expected: 期望体积 + actual: 实际体积 + """ + factor = actual / expected + self.pipette.set_calibration_factor(factor) + logger.info(f"体积校准系数: {factor}") + + def get_status(self) -> Dict: + """获取状态信息""" + self._update_tip_status() + return { + 'tip_status': self.tip_status.value, + 'current_volume': self.current_volume, + 'max_volume': self.max_volume, + 'liquid_class': self.liquid_class.value, + 'statistics': { + # 'tip_count': self.tip_count, + 'aspirate_count': self.aspirate_count, + 'dispense_count': self.dispense_count + } + } + + def reset_statistics(self): + """重置统计信息""" + # self.tip_count = 0 + self.aspirate_count = 0 + self.dispense_count = 0 + +# ============================================================================ +# 实例化代码块 - 移液控制器使用示例 +# ============================================================================ + +if __name__ == "__main__": + # 配置日志 + import logging + + # 设置日志级别 + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + + def interactive_test(): + """交互式测试模式 - 适用于已连接的设备""" + print("\n" + "=" * 60) + print("🧪 移液器交互式测试模式") + print("=" * 60) + + # 获取用户输入的连接参数 + print("\n📡 设备连接配置:") + port = input("请输入移液器串口端口 (默认: /dev/ttyUSB_CH340): ").strip() or "/dev/ttyUSB_CH340" + address_input = input("请输入移液器设备地址 (默认: 4): ").strip() + address = int(address_input) if address_input else 4 + + # 询问是否连接 XYZ 步进电机控制器 + xyz_enable = input("是否连接 XYZ 步进电机控制器? (y/N): ").strip().lower() + xyz_port = None + if xyz_enable not in ['n', 'no']: + xyz_port = input("请输入 XYZ 控制器串口端口 (默认: /dev/ttyUSB_CH340): ").strip() or "/dev/ttyUSB_CH340" + + try: + # 创建移液控制器实例 + if xyz_port: + print(f"\n🔧 创建移液控制器实例 (移液器端口: {port}, 地址: {address}, XYZ端口: {xyz_port})...") + pipette = PipetteController(port=port, address=address, xyz_port=xyz_port) + else: + print(f"\n🔧 创建移液控制器实例 (端口: {port}, 地址: {address})...") + pipette = PipetteController(port=port, address=address) + + # 连接设备 + print("\n📞 连接移液器设备...") + if not pipette.connect(): + print("❌ 设备连接失败,请检查连接") + return + print("✅ 设备连接成功") + + # 初始化设备 + print("\n🚀 初始化设备...") + if not pipette.initialize(): + print("❌ 设备初始化失败") + return + print("✅ 设备初始化成功") + + # 交互式菜单 + while True: + print("\n" + "=" * 50) + print("🎮 交互式操作菜单:") + print("1. 📋 查看设备状态") + print("2. 🔧 装载枪头") + print("3. 🗑️ 弹出枪头") + print("4. 💧 吸液操作") + print("5. 💦 排液操作") + print("6. 🌀 混合操作") + print("7. 🔄 液体转移") + print("8. ⚙️ 设置液体类型") + print("9. 🎯 自定义参数") + print("10. 📊 校准体积") + print("11. 🧹 重置统计") + print("12. 🔍 液体类型测试") + print("99. 🚨 紧急停止") + print("0. 🚪 退出程序") + print("=" * 50) + + choice = input("\n请选择操作 (0-12, 99): ").strip() + + if choice == "0": + print("\n👋 退出程序...") + break + elif choice == "1": + # 查看设备状态 + status = pipette.get_status() + print("\n📊 设备状态信息:") + print(f" 🎯 枪头状态: {status['tip_status']}") + print(f" 💧 当前体积: {status['current_volume']}ul") + print(f" 📏 最大体积: {status['max_volume']}ul") + print(f" 🧪 液体类型: {status['liquid_class']}") + print(f" 📈 统计信息:") + # print(f" 🔧 枪头使用次数: {status['statistics']['tip_count']}") + print(f" ⬆️ 吸液次数: {status['statistics']['aspirate_count']}") + print(f" ⬇️ 排液次数: {status['statistics']['dispense_count']}") + + elif choice == "2": + # 装载枪头 + print("\n🔧 装载枪头...") + if pipette.xyz_connected: + print("📍 使用 XYZ 控制器进行 Z 轴定位 (下移 10mm)") + else: + print("⚠️ 未连接 XYZ 控制器,仅执行移液器枪头装载") + + if pipette.pickup_tip(): + print("✅ 枪头装载成功") + if pipette.xyz_connected: + print("📍 Z 轴已移动到装载位置") + else: + print("❌ 枪头装载失败") + + elif choice == "3": + # 弹出枪头 + print("\n🗑️ 弹出枪头...") + if pipette.eject_tip(): + print("✅ 枪头弹出成功") + else: + print("❌ 枪头弹出失败") + + elif choice == "4": + # 吸液操作 + try: + volume = float(input("请输入吸液体积 (ul): ")) + detection = input("是否启用液面检测? (y/n, 默认y): ").strip().lower() != 'n' + print(f"\n💧 执行吸液操作 ({volume}ul)...") + if pipette.aspirate(volume, detection=detection): + print(f"✅ 吸液成功: {volume}ul") + print(f"📊 当前体积: {pipette.current_volume}ul") + else: + print("❌ 吸液失败") + except ValueError: + print("❌ 请输入有效的数字") + + elif choice == "5": + # 排液操作 + try: + volume = float(input("请输入排液体积 (ul): ")) + blow_out = input("是否执行吹出操作? (y/n, 默认n): ").strip().lower() == 'y' + print(f"\n💦 执行排液操作 ({volume}ul)...") + if pipette.dispense(volume, blow_out=blow_out): + print(f"✅ 排液成功: {volume}ul") + print(f"📊 剩余体积: {pipette.current_volume}ul") + else: + print("❌ 排液失败") + except ValueError: + print("❌ 请输入有效的数字") + + elif choice == "6": + # 混合操作 + try: + cycles = int(input("请输入混合次数 (默认3): ") or "3") + volume_input = input("请输入混合体积 (ul, 默认使用当前体积的50%): ").strip() + volume = float(volume_input) if volume_input else None + print(f"\n🌀 执行混合操作 ({cycles}次)...") + if pipette.mix(cycles=cycles, volume=volume): + print("✅ 混合完成") + else: + print("❌ 混合失败") + except ValueError: + print("❌ 请输入有效的数字") + + elif choice == "7": + # 液体转移 + try: + volume = float(input("请输入转移体积 (ul): ")) + source = input("源孔位 (可选, 如A1): ").strip() or None + dest = input("目标孔位 (可选, 如B1): ").strip() or None + new_tip = input("是否使用新枪头? (y/n, 默认y): ").strip().lower() != 'n' + + print(f"\n🔄 执行液体转移 ({volume}ul)...") + if pipette.transfer(volume=volume, source_well=source, dest_well=dest, new_tip=new_tip): + print("✅ 液体转移完成") + else: + print("❌ 液体转移失败") + except ValueError: + print("❌ 请输入有效的数字") + + elif choice == "8": + # 设置液体类型 + print("\n🧪 可用液体类型:") + liquid_options = { + "1": (LiquidClass.WATER, "水溶液"), + "2": (LiquidClass.SERUM, "血清"), + "3": (LiquidClass.VISCOUS, "粘稠液体"), + "4": (LiquidClass.VOLATILE, "挥发性液体") + } + + for key, (liquid_class, description) in liquid_options.items(): + print(f" {key}. {description}") + + liquid_choice = input("请选择液体类型 (1-4): ").strip() + if liquid_choice in liquid_options: + liquid_class, description = liquid_options[liquid_choice] + pipette.set_liquid_class(liquid_class) + print(f"✅ 液体类型设置为: {description}") + + # 显示参数 + params = pipette.liquid_params + print(f"📋 参数设置:") + print(f" ⬆️ 吸液速度: {params.aspirate_speed}") + print(f" ⬇️ 排液速度: {params.dispense_speed}") + print(f" 💨 空气间隙: {params.air_gap}ul") + print(f" 💧 预润湿: {'是' if params.pre_wet else '否'}") + else: + print("❌ 无效选择") + + elif choice == "9": + # 自定义参数 + try: + print("\n⚙️ 设置自定义参数 (直接回车使用默认值):") + aspirate_speed = input("吸液速度 (默认500): ").strip() + dispense_speed = input("排液速度 (默认800): ").strip() + air_gap = input("空气间隙 (ul, 默认10.0): ").strip() + pre_wet = input("预润湿 (y/n, 默认n): ").strip().lower() == 'y' + + custom_params = LiquidParameters( + aspirate_speed=int(aspirate_speed) if aspirate_speed else 500, + dispense_speed=int(dispense_speed) if dispense_speed else 800, + air_gap=float(air_gap) if air_gap else 10.0, + pre_wet=pre_wet + ) + + pipette.set_custom_parameters(custom_params) + print("✅ 自定义参数设置完成") + except ValueError: + print("❌ 请输入有效的数字") + + elif choice == "10": + # 校准体积 + try: + expected = float(input("期望体积 (ul): ")) + actual = float(input("实际测量体积 (ul): ")) + pipette.calibrate_volume(expected, actual) + print(f"✅ 校准完成,校准系数: {actual/expected:.3f}") + except ValueError: + print("❌ 请输入有效的数字") + + elif choice == "11": + # 重置统计 + pipette.reset_statistics() + print("✅ 统计信息已重置") + + elif choice == "12": + # 液体类型测试 + print("\n🧪 液体类型参数对比:") + liquid_tests = [ + (LiquidClass.WATER, "水溶液"), + (LiquidClass.SERUM, "血清"), + (LiquidClass.VISCOUS, "粘稠液体"), + (LiquidClass.VOLATILE, "挥发性液体") + ] + + for liquid_class, description in liquid_tests: + params = pipette.LIQUID_PARAMS[liquid_class] + print(f"\n📋 {description} ({liquid_class.value}):") + print(f" ⬆️ 吸液速度: {params.aspirate_speed}") + print(f" ⬇️ 排液速度: {params.dispense_speed}") + print(f" 💨 空气间隙: {params.air_gap}ul") + print(f" 💧 预润湿: {'是' if params.pre_wet else '否'}") + print(f" ⏱️ 吸液后延时: {params.delay_after_aspirate}s") + + elif choice == "99": + # 紧急停止 + print("\n🚨 执行紧急停止...") + success = pipette.emergency_stop() + if success: + print("✅ 紧急停止执行成功") + print("⚠️ 所有运动已停止,请检查设备状态") + else: + print("❌ 紧急停止执行失败") + print("⚠️ 请手动检查设备状态并采取必要措施") + + # 紧急停止后询问是否继续 + continue_choice = input("\n是否继续操作?(y/n): ").strip().lower() + if continue_choice != 'y': + print("🚪 退出程序") + break + + else: + print("❌ 无效选择,请重新输入") + + # 等待用户确认继续 + input("\n按回车键继续...") + + except KeyboardInterrupt: + print("\n\n⚠️ 用户中断操作") + except Exception as e: + print(f"\n❌ 发生异常: {e}") + finally: + # 断开连接 + print("\n📞 断开设备连接...") + try: + pipette.disconnect() + print("✅ 连接已断开") + except: + print("⚠️ 断开连接时出现问题") + + def demo_test(): + """演示测试模式 - 完整功能演示""" + print("\n" + "=" * 60) + print("🎬 移液控制器演示测试") + print("=" * 60) + + try: + # 创建移液控制器实例 + print("1. 🔧 创建移液控制器实例...") + pipette = PipetteController(port="/dev/ttyUSB0", address=4) + print("✅ 移液控制器实例创建成功") + + # 连接设备 + print("\n2. 📞 连接移液器设备...") + if pipette.connect(): + print("✅ 设备连接成功") + else: + print("❌ 设备连接失败") + return False + + # 初始化设备 + print("\n3. 🚀 初始化设备...") + if pipette.initialize(): + print("✅ 设备初始化成功") + else: + print("❌ 设备初始化失败") + return False + + # 装载枪头 + print("\n4. 🔧 装载枪头...") + if pipette.pickup_tip(): + print("✅ 枪头装载成功") + else: + print("❌ 枪头装载失败") + + # 设置液体类型 + print("\n5. 🧪 设置液体类型为血清...") + pipette.set_liquid_class(LiquidClass.SERUM) + print("✅ 液体类型设置完成") + + # 吸液操作 + print("\n6. 💧 执行吸液操作...") + volume_to_aspirate = 100.0 + if pipette.aspirate(volume_to_aspirate, detection=True): + print(f"✅ 吸液成功: {volume_to_aspirate}ul") + print(f"📊 当前体积: {pipette.current_volume}ul") + else: + print("❌ 吸液失败") + + # 排液操作 + print("\n7. 💦 执行排液操作...") + volume_to_dispense = 50.0 + if pipette.dispense(volume_to_dispense, blow_out=True): + print(f"✅ 排液成功: {volume_to_dispense}ul") + print(f"📊 剩余体积: {pipette.current_volume}ul") + else: + print("❌ 排液失败") + + # 混合操作 + print("\n8. 🌀 执行混合操作...") + if pipette.mix(cycles=3, volume=30.0): + print("✅ 混合完成") + else: + print("❌ 混合失败") + + # 获取状态信息 + print("\n9. 📊 获取设备状态...") + status = pipette.get_status() + print("设备状态信息:") + print(f" 🎯 枪头状态: {status['tip_status']}") + print(f" 💧 当前体积: {status['current_volume']}ul") + print(f" 📏 最大体积: {status['max_volume']}ul") + print(f" 🧪 液体类型: {status['liquid_class']}") + print(f" 📈 统计信息:") + # print(f" 🔧 枪头使用次数: {status['statistics']['tip_count']}") + print(f" ⬆️ 吸液次数: {status['statistics']['aspirate_count']}") + print(f" ⬇️ 排液次数: {status['statistics']['dispense_count']}") + + # 弹出枪头 + print("\n10. 🗑️ 弹出枪头...") + if pipette.eject_tip(): + print("✅ 枪头弹出成功") + else: + print("❌ 枪头弹出失败") + + print("\n" + "=" * 60) + print("✅ 移液控制器演示测试完成") + print("=" * 60) + + return True + + except Exception as e: + print(f"\n❌ 测试过程中发生异常: {e}") + return False + + finally: + # 断开连接 + print("\n📞 断开连接...") + pipette.disconnect() + print("✅ 连接已断开") + + # 主程序入口 + print("🧪 移液器控制器测试程序") + print("=" * 40) + print("1. 🎮 交互式测试 (推荐)") + print("2. 🎬 演示测试") + print("0. 🚪 退出") + print("=" * 40) + + mode = input("请选择测试模式 (0-2): ").strip() + + if mode == "1": + interactive_test() + elif mode == "2": + demo_test() + elif mode == "0": + print("👋 再见!") + else: + print("❌ 无效选择") + + print("\n🎉 程序结束!") + print("\n💡 使用说明:") + print("1. 确保移液器硬件已正确连接") + print("2. 根据实际情况修改串口端口号") + print("3. 交互模式支持实时操作和参数调整") + print("4. 在实际使用中需要配合运动控制器进行位置移动") diff --git a/unilabos/devices/liquid_handling/laiyu/controllers/xyz_controller.py b/unilabos/devices/liquid_handling/laiyu/controllers/xyz_controller.py new file mode 100644 index 00000000..e06624ad --- /dev/null +++ b/unilabos/devices/liquid_handling/laiyu/controllers/xyz_controller.py @@ -0,0 +1,1253 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +XYZ三轴步进电机控制器 +支持坐标系管理、限位开关回零、工作原点设定等功能 + +主要功能: +- 坐标系转换层(步数↔毫米) +- 限位开关回零功能 +- 工作原点示教和保存 +- 安全限位检查 +- 运动控制接口 + +""" + +import json +import os +from re import X +import time +from typing import Optional, Dict, Tuple, Union +from dataclasses import dataclass, field, asdict +from pathlib import Path +import logging + +# 添加项目根目录到Python路径以解决模块导入问题 +import sys +import os + +# 无论如何都添加项目根目录到路径 +current_file = os.path.abspath(__file__) +# 从 .../Uni-Lab-OS/unilabos/devices/LaiYu_Liquid/controllers/xyz_controller.py +# 向上5级到 .../Uni-Lab-OS +project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(current_file))))) +# 强制添加项目根目录到sys.path的开头 +sys.path.insert(0, project_root) + +# 导入原有的驱动 +from unilabos.devices.liquid_handling.laiyu.drivers.xyz_stepper_driver import XYZStepperController, MotorAxis, MotorStatus + +logger = logging.getLogger(__name__) + + +@dataclass +class MachineConfig: + """机械配置参数""" + # 步距配置 (基于16384步/圈的步进电机) + steps_per_mm_x: float = 204.8 # X轴步距 (16384步/圈 ÷ 80mm导程) + steps_per_mm_y: float = 204.8 # Y轴步距 (16384步/圈 ÷ 80mm导程) + steps_per_mm_z: float = 3276.8 # Z轴步距 (16384步/圈 ÷ 5mm导程) + + # 行程限制 + max_travel_x: float = 340.0 # X轴最大行程 + max_travel_y: float = 250.0 # Y轴最大行程 + max_travel_z: float = 200.0 # Z轴最大行程 + reference_distance: Dict[str, float] = field(default_factory=lambda: { + "x": 29, + "y": -13, + "z": -75.5 + }) + # 安全移动参数 + safe_z_height: float = 0.0 # Z轴安全移动高度 (mm) - 液体处理工作站安全高度 + z_approach_height: float = 5.0 # Z轴接近高度 (mm) - 在目标位置上方的预备高度 + + # 回零参数 + homing_speed: int = 50 # 回零速度 (mm/s) + homing_timeout: float = 30.0 # 回零超时时间 + safe_clearance: float = 10.0 # 安全间隙 (mm) + position_stable_time: float = 1.0 # 位置稳定检测时间(秒) + position_check_interval: float = 0.2 # 位置检查间隔(秒) + + # 运动参数 + default_speed: int = 50 # 默认运动速度 (mm/s) + default_acceleration: int = 1000 # 默认加速度 + + +@dataclass +class CoordinateOrigin: + """坐标原点信息""" + machine_origin_steps: Dict[str, int] = None # 机械原点步数位置 + work_origin_steps: Dict[str, int] = None # 工作原点步数位置 + is_homed: bool = False # 是否已回零 + timestamp: str = "" # 设定时间戳 + + def __post_init__(self): + if self.machine_origin_steps is None: + self.machine_origin_steps = {"x": 0, "y": 0, "z": 0} + if self.work_origin_steps is None: + self.work_origin_steps = {"x": 0, "y": 0, "z": 0} + + +class CoordinateSystemError(Exception): + """坐标系统异常""" + pass + + +class XYZController(XYZStepperController): + """XYZ三轴控制器""" + + def __init__(self, port: str, baudrate: int = 115200, + machine_config: Optional[MachineConfig] = None, + config_file: str = "machine_config.json", + auto_connect: bool = True): + """ + 初始化XYZ控制器 + + Args: + port: 串口端口 + baudrate: 波特率 + machine_config: 机械配置参数 + config_file: 配置文件路径 + auto_connect: 是否自动连接设备 + """ + super().__init__(port, baudrate) + + # 机械配置 + self.machine_config = machine_config or MachineConfig() + self.config_file = config_file + + # 坐标系统 + self.coordinate_origin = CoordinateOrigin() + import os + self.origin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "coordinate_origin.json") + + # 连接状态 + self.is_connected = False + + # 加载配置 + self._load_config() + self._load_coordinate_origin() + + # 自动连接设备 + if auto_connect: + self.connect_device() + + def connect_device(self) -> bool: + """ + 连接设备并初始化 + + Returns: + bool: 连接是否成功 + """ + try: + logger.info(f"正在连接设备: {self.port}") + + # 连接硬件 + if not self.connect(): + logger.error("硬件连接失败") + return False + + self.is_connected = True + logger.info("设备连接成功") + + # 使能所有轴 + enable_results = self.enable_all_axes(True) + success_count = sum(1 for result in enable_results.values() if result) + logger.info(f"轴使能结果: {success_count}/{len(enable_results)} 成功") + + # 获取系统状态 + try: + status = self.get_system_status() + logger.info(f"系统状态获取成功: {len(status)} 项信息") + except Exception as e: + logger.warning(f"获取系统状态失败: {e}") + + return True + + except Exception as e: + logger.error(f"设备连接失败: {e}") + self.is_connected = False + return False + + def disconnect_device(self): + """断开设备连接""" + try: + if self.is_connected: + self.disconnect() # 使用父类的disconnect方法 + self.is_connected = False + logger.info("设备连接已断开") + except Exception as e: + logger.error(f"断开连接失败: {e}") + + def _load_config(self): + """加载机械配置""" + try: + if os.path.exists(self.config_file): + with open(self.config_file, 'r', encoding='utf-8') as f: + config_data = json.load(f) + # 更新配置参数 + for key, value in config_data.items(): + if hasattr(self.machine_config, key): + setattr(self.machine_config, key, value) + logger.info("机械配置加载完成") + except Exception as e: + logger.warning(f"加载机械配置失败: {e},使用默认配置") + + def _save_config(self): + """保存机械配置""" + try: + with open(self.config_file, 'w', encoding='utf-8') as f: + json.dump(asdict(self.machine_config), f, indent=2, ensure_ascii=False) + logger.info("机械配置保存完成") + except Exception as e: + logger.error(f"保存机械配置失败: {e}") + + def _load_coordinate_origin(self): + """加载坐标原点信息""" + try: + if os.path.exists(self.origin_file): + with open(self.origin_file, 'r', encoding='utf-8') as f: + origin_data = json.load(f) + + for key, value in origin_data["machine_origin_steps"].items(): + origin_data["machine_origin_steps"][key] = round(self.mm_to_steps(MotorAxis[key.upper()], value), 2) + + for key, value in origin_data["work_origin_steps"].items(): + origin_data["work_origin_steps"][key] = round(self.mm_to_steps(MotorAxis[key.upper()], value), 2) + + self.coordinate_origin = CoordinateOrigin(**origin_data) + logger.info("坐标原点信息加载完成") + except Exception as e: + logger.warning(f"加载坐标原点失败: {e},使用默认设置") + + def _save_coordinate_origin(self): + """保存坐标原点信息""" + try: + # 更新时间戳 + from datetime import datetime + self.coordinate_origin.timestamp = datetime.now().isoformat() + + with open(self.origin_file, 'w', encoding='utf-8') as f: + json_data = asdict(self.coordinate_origin) + for key, value in json_data["machine_origin_steps"].items(): + json_data["machine_origin_steps"][key] = round(self.steps_to_mm(MotorAxis[key.upper()], value), 2) + + for key, value in json_data["work_origin_steps"].items(): + json_data["work_origin_steps"][key] = round(self.steps_to_mm(MotorAxis[key.upper()], value), 2) + + json.dump(json_data, f, indent=2, ensure_ascii=False) + logger.info("坐标原点信息保存完成") + except Exception as e: + logger.error(f"保存坐标原点失败: {e}") + + # ==================== 坐标转换方法 ==================== + + def mm_to_steps(self, axis: MotorAxis, mm: float) -> int: + """毫米转步数""" + if axis == MotorAxis.X: + return int(mm * self.machine_config.steps_per_mm_x) + elif axis == MotorAxis.Y: + return int(mm * self.machine_config.steps_per_mm_y) + elif axis == MotorAxis.Z: + return int(mm * self.machine_config.steps_per_mm_z) + else: + raise ValueError(f"未知轴: {axis}") + + def steps_to_mm(self, axis: MotorAxis, steps: int) -> float: + """步数转毫米""" + if axis == MotorAxis.X: + return steps / self.machine_config.steps_per_mm_x + elif axis == MotorAxis.Y: + return steps / self.machine_config.steps_per_mm_y + elif axis == MotorAxis.Z: + return steps / self.machine_config.steps_per_mm_z + else: + raise ValueError(f"未知轴: {axis}") + + def work_to_machine_steps(self, x: float = None, y: float = None, z: float = None) -> Dict[str, int]: + """工作坐标转机械坐标步数""" + machine_steps = {} + + if x is not None: + work_steps = self.mm_to_steps(MotorAxis.X, x) + machine_steps['x'] = self.coordinate_origin.work_origin_steps['x'] + work_steps + self.coordinate_origin.machine_origin_steps['x'] + + if y is not None: + work_steps = self.mm_to_steps(MotorAxis.Y, y) + machine_steps['y'] = self.coordinate_origin.work_origin_steps['y'] + work_steps + self.coordinate_origin.machine_origin_steps['y'] + + if z is not None: + work_steps = self.mm_to_steps(MotorAxis.Z, z) + machine_steps['z'] = self.coordinate_origin.work_origin_steps['z'] + work_steps + self.coordinate_origin.machine_origin_steps['z'] + + return machine_steps + + def machine_to_work_coords(self, machine_steps: Dict[str, int]) -> Dict[str, float]: + """机械坐标步数转工作坐标""" + work_coords = {} + + for axis_name, steps in machine_steps.items(): + axis = MotorAxis[axis_name.upper()] + work_origin_steps = self.coordinate_origin.work_origin_steps[axis_name] + relative_steps = steps - work_origin_steps - self.coordinate_origin.machine_origin_steps[axis_name] + work_coords[axis_name] = self.steps_to_mm(axis, relative_steps) + + return work_coords + + def check_travel_limits(self, x: float = None, y: float = None, z: float = None) -> bool: + """检查行程限制""" + min_x = min(0, -50) + min_y = min(0, -50) + min_z = min(0, -50) + + if x is not None and (x < min_x or x > self.machine_config.max_travel_x): + raise CoordinateSystemError(f"X轴超出行程范围: {x}mm ({min_x} ~ {self.machine_config.max_travel_x}mm)") + + if y is not None and (y < min_y or y > self.machine_config.max_travel_y): + raise CoordinateSystemError(f"Y轴超出行程范围: {y}mm ({min_y} ~ {self.machine_config.max_travel_y}mm)") + + if z is not None and (z < min_z or z > self.machine_config.max_travel_z): + raise CoordinateSystemError(f"Z轴超出行程范围: {z}mm ({min_z} ~ {self.machine_config.max_travel_z}mm)") + + return True + + # ==================== 回零和原点设定方法 ==================== + + def home_axis(self, axis: MotorAxis, direction: int = -1, speed: float = None) -> bool: + """ + 单轴回零到限位开关 - 使用步数变化检测 + + Args: + axis: 要回零的轴 + direction: 回零方向 (-1负方向, 1正方向) + + Returns: + bool: 回零是否成功 + """ + if not self.is_connected: + logger.error("设备未连接,无法执行回零操作") + return False + + try: + logger.info(f"开始{axis.name}轴回零") + + # 使能电机 + if not self.enable_motor(axis, True): + raise CoordinateSystemError(f"{axis.name}轴使能失败") + + # 设置回零速度模式,根据方向设置正负 + if speed is None: + speed_ = self.machine_config.homing_speed + else: + speed_ = speed + + speed_ = min(max(speed_, 0), 500) + if not self.set_speed_mode(axis, self.ms_to_rpm(axis, speed_) * direction): + raise CoordinateSystemError(f"{axis.name}轴设置回零速度失败") + + + + # 智能回零检测 - 基于步数变化 + start_time = time.time() + limit_detected = False + final_position = None + + # 步数变化检测参数(从配置获取) + position_stable_time = self.machine_config.position_stable_time + check_interval = self.machine_config.position_check_interval + last_position = None + stable_start_time = None + + logger.info(f"{axis.name}轴开始移动,监测步数变化...") + + while time.time() - start_time < self.machine_config.homing_timeout: + status = self.get_motor_status(axis) + current_position = status.steps + + # 检查是否明确触碰限位开关 + if (direction < 0 and status.status == MotorStatus.REVERSE_LIMIT_STOP) or \ + (direction > 0 and status.status == MotorStatus.FORWARD_LIMIT_STOP): + # 停止运动 + self.emergency_stop(axis) + time.sleep(0.5) + + # 记录机械原点位置 + final_position = current_position + limit_detected = True + logger.info(f"{axis.name}轴检测到限位开关信号,位置: {final_position}步") + break + + # 检查是否发生碰撞 + if status.status == MotorStatus.COLLISION_STOP: + raise CoordinateSystemError(f"{axis.name}轴回零时发生碰撞") + + # 步数变化检测逻辑 + if last_position is not None: + # 检查位置是否发生变化 + if abs(current_position - last_position) <= 1: # 允许1步的误差 + # 位置基本没有变化 + if stable_start_time is None: + stable_start_time = time.time() + logger.debug(f"{axis.name}轴位置开始稳定在 {current_position}步") + elif time.time() - stable_start_time >= position_stable_time: + # 位置稳定超过指定时间,认为已到达限位 + self.emergency_stop(axis) + time.sleep(0.5) + + final_position = current_position + limit_detected = True + logger.info(f"{axis.name}轴位置稳定{position_stable_time}秒,假设已到达限位开关,位置: {final_position}步") + break + else: + # 位置发生变化,重置稳定计时 + stable_start_time = None + logger.debug(f"{axis.name}轴位置变化: {last_position} -> {current_position}") + + last_position = current_position + time.sleep(check_interval) + + # 超时处理 + if not limit_detected: + logger.warning(f"{axis.name}轴回零超时({self.machine_config.homing_timeout}秒),强制停止") + self.emergency_stop(axis) + time.sleep(0.5) + + # 获取当前位置作为机械原点 + try: + status = self.get_motor_status(axis) + final_position = status.steps + logger.info(f"{axis.name}轴超时后位置: {final_position}步") + except Exception as e: + logger.error(f"获取{axis.name}轴位置失败: {e}") + return False + + # 记录机械原点位置 + self.coordinate_origin.machine_origin_steps[axis.name.lower()] = final_position + + # 从限位开关退出安全距离 + try: + clearance_steps = self.mm_to_steps(axis, self.machine_config.safe_clearance) + safe_position = final_position + (clearance_steps * -direction) # 反方向退出 + + if not self.move_to_position(axis, safe_position, + self.ms_to_rpm(axis, speed_)): + logger.warning(f"{axis.name}轴无法退出到安全位置") + else: + self.wait_for_completion(axis, 10.0) + logger.info(f"{axis.name}轴已退出到安全位置: {safe_position}步") + except Exception as e: + logger.warning(f"{axis.name}轴退出安全位置时出错: {e}") + + status_msg = "限位检测成功" if limit_detected else "超时假设成功" + logger.info(f"{axis.name}轴回零完成 ({status_msg}),机械原点: {final_position}步") + return True + + except Exception as e: + logger.error(f"{axis.name}轴回零失败: {e}") + self.emergency_stop(axis) + return False + + def home_all_axes(self, sequence: list = None) -> bool: + """ + 全轴回零 (液体处理工作站安全回零) + + 液体处理工作站回零策略: + 1. Z轴必须首先回零,避免与容器、试管架等碰撞 + 2. 然后XY轴回零,确保移动路径安全 + 3. 严格按照Z->X->Y顺序执行,不允许更改 + + Args: + sequence: 回零顺序,液体处理工作站固定为Z->X->Y,不建议修改 + + Returns: + bool: 全轴回零是否成功 + """ + if not self.is_connected: + logger.error("设备未连接,无法执行回零操作") + return False + + # 液体处理工作站安全回零序列:Z轴绝对优先 + safe_sequence = [MotorAxis.Z, MotorAxis.X, MotorAxis.Y] + + if sequence is not None and sequence != safe_sequence: + logger.warning(f"液体处理工作站不建议修改回零序列,使用安全序列: {[axis.name for axis in safe_sequence]}") + + sequence = safe_sequence # 强制使用安全序列 + + logger.info("开始全轴回零") + + try: + for axis in sequence: + if not self.home_axis(axis): + logger.error(f"全轴回零失败,停止在{axis.name}轴") + return False + # 轴间等待时间 + time.sleep(0.5) + + # 标记为已回零 + self.coordinate_origin.is_homed = True + self._save_coordinate_origin() + + logger.info("全轴回零完成") + return True + + except Exception as e: + logger.error(f"全轴回零异常: {e}") + return False + + def set_work_origin_here(self) -> bool: + """将当前位置设置为工作原点""" + if not self.is_connected: + logger.error("设备未连接,无法设置工作原点") + return False + + try: + if not self.coordinate_origin.is_homed: + logger.warning("建议先执行回零操作再设置工作原点") + + # 获取当前各轴位置 + positions = self.get_all_positions() + + machine_steps = { + 'x': self.mm_to_steps(MotorAxis.X, self.machine_config.reference_distance['x']), + 'y': self.mm_to_steps(MotorAxis.Y, self.machine_config.reference_distance['y']), + 'z': self.mm_to_steps(MotorAxis.Z, self.machine_config.reference_distance['z']) + } + + for axis in MotorAxis: + axis_name = axis.name.lower() + current_steps = positions[axis].steps + self.coordinate_origin.work_origin_steps[axis_name] = current_steps + machine_steps[axis_name] - self.coordinate_origin.machine_origin_steps[axis_name] + + logger.info(f"{axis.name}轴工作原点设置为: {current_steps + machine_steps[axis_name] - self.coordinate_origin.machine_origin_steps[axis_name]}步 " + f"({self.steps_to_mm(axis, current_steps + machine_steps[axis_name] - self.coordinate_origin.machine_origin_steps[axis_name]):.2f}mm)") + + self._save_coordinate_origin() + logger.info("工作原点设置完成") + return True + + except Exception as e: + logger.error(f"设置工作原点失败: {e}") + return False + + def ms_to_rpm(self, axis: MotorAxis, velocity_mms: float) -> int: + """ + 将速度从米/秒(m/s)转换为转速(rpm) + + Args: + axis: 电机轴 + velocity_ms: 速度(米/秒) + + Returns: + 转速(rpm) + """ + # 获取每转的行程(mm) + if axis == MotorAxis.X: + lead_mm = 80.0 + elif axis == MotorAxis.Y: + lead_mm = 80.0 + elif axis == MotorAxis.Z: + lead_mm = 5.0 + else: + raise ValueError(f"未知轴: {axis}") + + # mm/s -> rps + rps = velocity_mms / lead_mm + # rps -> rpm + rpm = int(rps * 60.0) + return min(max(rpm, 0), 150) + + + # ==================== 高级运动控制方法 ==================== + + def move_to_work_coord_safe(self, x: float = None, y: float = None, z: float = None, + speed: float = None, acceleration: int = None) -> bool: + """ + 安全移动到工作坐标系指定位置 (液体处理工作站专用) + 移动策略:Z轴先上升到安全高度 -> XY轴移动到目标位置 -> Z轴下降到目标位置 + + Args: + x, y, z: 工作坐标系下的目标位置 (mm) + speed: 运动速度 (m/s) + acceleration: 加速度 (rpm/s) + + Returns: + bool: 移动是否成功 + """ + if not self.is_connected: + logger.error("设备未连接,无法执行移动操作") + return False + + try: + # 检查坐标系是否已设置 + if not self.coordinate_origin.work_origin_steps: + raise CoordinateSystemError("工作原点未设置,请先调用set_work_origin_here()") + + # 检查行程限制 + # self.check_travel_limits(x, y, z) + + # 设置运动参数 + speed = speed or self.machine_config.default_speed + acceleration = acceleration or self.machine_config.default_acceleration + + xy_success = True + # 步骤1: Z轴先上升到安全高度 + + machine_steps = self.work_to_machine_steps(x, y, z) + + if z is not None and (x is not None or y is not None): + safe_z_steps = self.work_to_machine_steps(None, None, self.machine_config.safe_z_height) + if not self.move_to_position(MotorAxis.Z, safe_z_steps['z'], self.ms_to_rpm(MotorAxis.Z, speed), acceleration): + logger.error("Z轴上升到安全高度失败") + return False + logger.info(f"Z轴上升到安全高度: {self.machine_config.safe_z_height} mm") + + # 等待Z轴移动完成 + self.wait_for_completion(MotorAxis.Z, 10.0) + + # 步骤2: XY轴移动到目标位置 + if x is not None: + if not self.move_to_position(MotorAxis.X, machine_steps['x'], self.ms_to_rpm(MotorAxis.X, speed), acceleration): + xy_success = False + if y is not None: + if not self.move_to_position(MotorAxis.Y, machine_steps['y'], self.ms_to_rpm(MotorAxis.Y, speed), acceleration): + xy_success = False + + if not xy_success: + logger.error("XY轴移动失败") + return False + + + if x is not None or y is not None: + logger.info(f"XY轴移动到目标位置: X:{x} Y:{y} mm") + # 等待XY轴移动完成 + if x is not None: + self.wait_for_completion(MotorAxis.X, 10.0) + if y is not None: + self.wait_for_completion(MotorAxis.Y, 10.0) + + # 步骤3: Z轴下降到目标位置 + if z is not None: + if not self.move_to_position(MotorAxis.Z, machine_steps['z'], self.ms_to_rpm(MotorAxis.Z, speed), acceleration): + logger.error("Z轴下降到目标位置失败") + return False + logger.info(f"Z轴下降到目标位置: {z} mm") + self.wait_for_completion(MotorAxis.Z, 10.0) + + logger.info(f"安全移动到工作坐标 X:{x} Y:{y} Z:{z} (mm) 完成") + return True + + except Exception as e: + logger.error(f"安全移动失败: {e}") + return False + + def move_to_work_coord(self, x: float = None, y: float = None, z: float = None, + speed: int = None, acceleration: int = None) -> bool: + """ + 移动到工作坐标 (已禁用) + + 此方法已被禁用,请使用 move_to_work_coord_safe() 方法。 + + Raises: + RuntimeError: 方法已禁用 + """ + error_msg = "Method disabled, use move_to_work_coord_safe instead" + logger.error(error_msg) + raise RuntimeError(error_msg) + + def move_relative_work_coord(self, dx: float = 0, dy: float = 0, dz: float = 0, + speed: float = None, acceleration: int = None) -> bool: + """ + 相对当前位置移动 + + Args: + dx, dy, dz: 相对移动距离 (mm) + speed: 运动速度 (m/s) + acceleration: 加速度 (rpm/s) + + Returns: + bool: 移动是否成功 + """ + if not self.is_connected: + logger.error("设备未连接,无法执行移动操作") + return False + + try: + # 获取当前工作坐标 + current_work = self.get_current_work_coords() + + # 计算目标坐标 + target_x = current_work['x'] + dx if dx != 0 else None + target_y = current_work['y'] + dy if dy != 0 else None + target_z = current_work['z'] + dz if dz != 0 else None + + return self.move_to_work_coord_safe(x=target_x, y=target_y, z=target_z, speed=speed, acceleration=acceleration) + + except Exception as e: + logger.error(f"相对移动失败: {e}") + return False + + def get_current_work_coords(self) -> Dict[str, float]: + """获取当前工作坐标""" + if not self.is_connected: + logger.error("设备未连接,无法获取当前坐标") + return {'x': 0.0, 'y': 0.0, 'z': 0.0} + + try: + # 获取当前机械坐标 + positions = self.get_all_positions() + machine_steps = {axis.name.lower(): pos.steps for axis, pos in positions.items()} + + # 转换为工作坐标 + return self.machine_to_work_coords(machine_steps) + + except Exception as e: + logger.error(f"获取工作坐标失败: {e}") + return {'x': 0.0, 'y': 0.0, 'z': 0.0} + + def get_current_position_mm(self) -> Dict[str, float]: + """获取当前位置坐标(毫米单位)""" + return self.get_current_work_coords() + + def wait_for_move_completion(self, timeout: float = 30.0) -> bool: + """等待所有轴运动完成""" + if not self.is_connected: + return False + + for axis in MotorAxis: + if not self.wait_for_completion(axis, timeout): + return False + return True + + # ==================== 系统状态和配置方法 ==================== + + def get_system_status(self) -> Dict: + """获取系统状态信息""" + status = { + "connection": { + "is_connected": self.is_connected, + "port": self.port, + "baudrate": self.baudrate + }, + "coordinate_system": { + "is_homed": self.coordinate_origin.is_homed, + "machine_origin": self.coordinate_origin.machine_origin_steps, + "work_origin": self.coordinate_origin.work_origin_steps, + "timestamp": self.coordinate_origin.timestamp + }, + "machine_config": asdict(self.machine_config), + "current_position": {} + } + + if self.is_connected: + try: + # 获取当前位置 + positions = self.get_all_positions() + for axis, pos in positions.items(): + axis_name = axis.name.lower() + status["current_position"][axis_name] = { + "steps": pos.steps, + "mm": self.steps_to_mm(axis, pos.steps), + "status": pos.status.name if hasattr(pos.status, 'name') else str(pos.status) + } + + # 获取工作坐标 + work_coords = self.get_current_work_coords() + status["current_work_coords"] = work_coords + + except Exception as e: + status["position_error"] = str(e) + + return status + + def update_machine_config(self, **kwargs): + """更新机械配置参数""" + for key, value in kwargs.items(): + if hasattr(self.machine_config, key): + setattr(self.machine_config, key, value) + logger.info(f"更新配置参数 {key}: {value}") + else: + logger.warning(f"未知配置参数: {key}") + + # 保存配置 + self._save_config() + + def reset_coordinate_system(self): + """重置坐标系统""" + self.coordinate_origin = CoordinateOrigin() + self._save_coordinate_origin() + logger.info("坐标系统已重置") + + def __enter__(self): + """上下文管理器入口""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """上下文管理器出口""" + self.disconnect_device() + + +def interactive_control(controller: XYZController): + """ + 交互式控制模式 + + Args: + controller: 已连接的控制器实例 + """ + print("\n" + "="*60) + print("进入交互式控制模式") + print("="*60) + + # 显示当前状态 + def show_status(): + try: + current_pos = controller.get_current_position_mm() + print(f"\n当前位置: X={current_pos['x']:.2f}mm, Y={current_pos['y']:.2f}mm, Z={current_pos['z']:.2f}mm") + except Exception as e: + print(f"获取位置失败: {e}") + + # 显示帮助信息 + def show_help(): + print("\n可用命令:") + print(" move <轴> <距离> - 相对移动,例: move x 10.5") + print(" goto - 绝对移动到指定坐标,例: goto 10 20 5") + print(" home [轴] - 回零操作,例: home 或 home x") + print(" origin - 设置当前位置为工作原点") + print(" status - 显示当前状态") + print(" speed <速度> - 设置运动速度(mm/s),例: speed 2000") + print(" limits - 显示行程限制") + print(" config - 显示机械配置") + print(" help - 显示此帮助信息") + print(" quit/exit - 退出交互模式") + print("\n提示:") + print(" - 轴名称: x, y, z") + print(" - 距离单位: 毫米(mm)") + print(" - 正数向正方向移动,负数向负方向移动") + + + + # 安全回零操作 + def safe_homing(): + print("\n系统安全初始化...") + print("为确保操作安全,系统将执行回零操作") + print("提示: 已安装限位开关,超时后将假设回零成功") + + # 询问用户是否继续 + while True: + user_choice = input("是否继续执行回零操作? (y/n/skip): ").strip().lower() + if user_choice in ['y', 'yes', '是']: + print("\n开始执行全轴回零...") + print("回零过程可能需要一些时间,请耐心等待...") + + # 执行回零操作 + homing_success = controller.home_all_axes() + + if homing_success: + print("回零操作完成,系统已就绪") + # 设置当前位置为工作原点 + + # if controller.set_work_origin_here(): + # print("工作原点已设置为回零位置") + # else: + # print("工作原点设置失败,但可以继续操作") + return True + else: + print("回零操作失败") + print("这可能是由于通信问题,但限位开关应该已经起作用") + + # 询问是否继续 + retry_choice = input("是否仍要继续操作? (y/n): ").strip().lower() + if retry_choice in ['y', 'yes', '是']: + print("继续操作,请手动确认设备位置安全") + return True + else: + return False + + elif user_choice in ['n', 'no', '否']: + print("用户取消回零操作,退出交互模式") + return False + elif user_choice in ['skip', 's', '跳过']: + print("跳过回零操作,请注意安全!") + print("建议在开始操作前手动执行 'home' 命令") + return True + else: + print("请输入 y(继续)/n(取消)/skip(跳过)") + + # 安全回原点操作 + def safe_return_home(): + print("\n系统安全关闭...") + print("正在将所有轴移动到安全位置...") + + try: + # 移动到工作原点 (0,0,0) - 使用安全移动方法 + if controller.move_to_work_coord_safe(0, 0, 0, speed=50): + print("已安全返回工作原点") + show_status() + else: + print("返回原点失败,请手动检查设备位置") + except Exception as e: + print(f"返回原点时出错: {e}") + + # 当前运动速度 + current_speed = controller.machine_config.default_speed + + try: + # 1. 首先执行安全回零 + if not safe_homing(): + return + + # 2. 显示初始状态和帮助 + show_status() + show_help() + + while True: + try: + # 获取用户输入 + user_input = input("\n请输入命令 (输入 help 查看帮助): ").strip().lower() + + if not user_input: + continue + + # 解析命令 + parts = user_input.split() + command = parts[0] + + if command in ['quit', 'exit', 'q']: + print("准备退出交互模式...") + # 执行安全回原点操作 + safe_return_home() + print("退出交互模式") + break + + elif command == 'help' or command == 'h': + show_help() + + elif command == 'status' or command == 's': + show_status() + print(f"当前速度: {current_speed} m/s") + print(f"是否已回零: {controller.coordinate_origin.is_homed}") + + elif command == 'move' or command == 'm': + if len(parts) != 3: + print("格式错误,正确格式: move <轴> <距离>") + print(" 例如: move x 10.5") + continue + + axis = parts[1].lower() + try: + distance = float(parts[2]) + except ValueError: + print("距离必须是数字") + continue + + if axis not in ['x', 'y', 'z']: + print("轴名称必须是 x, y 或 z") + continue + + print(f"{axis.upper()}轴移动 {distance:+.2f}mm...") + + # 执行移动 + kwargs = {f'd{axis}': distance, 'speed': current_speed} + if controller.move_relative_work_coord(**kwargs): + print(f"{axis.upper()}轴移动完成") + show_status() + else: + print(f"{axis.upper()}轴移动失败") + + elif command == 'goto' or command == 'g': + if len(parts) != 4: + print("格式错误,正确格式: goto ") + print(" 例如: goto 10 20 5") + continue + + try: + x = float(parts[1]) + y = float(parts[2]) + z = float(parts[3]) + except ValueError: + print("坐标必须是数字") + continue + + print(f"移动到坐标 ({x}, {y}, {z})...") + print("使用安全移动策略: Z轴先上升 → XY移动 → Z轴下降") + + if controller.move_to_work_coord_safe(x, y, z, speed=current_speed): + print("安全移动到目标位置完成") + show_status() + else: + print("移动失败") + + elif command == 'home': + if len(parts) == 1: + # 全轴回零 + print("开始全轴回零...") + if controller.home_all_axes(): + print("全轴回零完成") + show_status() + else: + print("回零失败") + elif len(parts) == 2: + # 单轴回零 + axis_name = parts[1].lower() + if axis_name not in ['x', 'y', 'z']: + print("轴名称必须是 x, y 或 z") + continue + + axis = MotorAxis[axis_name.upper()] + print(f"{axis_name.upper()}轴回零...") + + if controller.home_axis(axis): + print(f"{axis_name.upper()}轴回零完成") + show_status() + else: + print(f"{axis_name.upper()}轴回零失败") + else: + print("格式错误,正确格式: home 或 home <轴>") + + elif command == 'origin' or command == 'o': + print("设置当前位置为工作原点...") + if controller.set_work_origin_here(): + print("工作原点设置完成") + show_status() + else: + print("工作原点设置失败") + + elif command == 'speed': + if len(parts) != 2: + print("格式错误,正确格式: speed <速度>") + print(" 例如: speed 200") + continue + + try: + new_speed = int(parts[1]) + if new_speed <= 0: + print("速度必须大于0") + continue + if new_speed > 500: + print("速度不能超过500 mm/s") + continue + + current_speed = new_speed + print(f"运动速度设置为: {current_speed} mm/s") + + except ValueError: + print("速度必须是整数") + + elif command == 'limits' or command == 'l': + config = controller.machine_config + print("\n行程限制:") + print(f" X轴: 0 ~ {config.max_travel_x} mm") + print(f" Y轴: 0 ~ {config.max_travel_y} mm") + print(f" Z轴: 0 ~ {config.max_travel_z} mm") + + elif command == 'config' or command == 'c': + config = controller.machine_config + print("\n机械配置:") + print(f" X轴步距: {config.steps_per_mm_x:.1f} 步/mm") + print(f" Y轴步距: {config.steps_per_mm_y:.1f} 步/mm") + print(f" Z轴步距: {config.steps_per_mm_z:.1f} 步/mm") + print(f" 回零速度: {config.homing_speed} mm/s") + print(f" 默认速度: {config.default_speed} mm/s") + print(f" 安全间隙: {config.safe_clearance} mm") + + else: + print(f"未知命令: {command}") + print("输入 help 查看可用命令") + + except KeyboardInterrupt: + print("\n\n用户中断,退出交互模式") + break + except Exception as e: + print(f"命令执行错误: {e}") + print("输入 help 查看正确的命令格式") + + finally: + # 确保正确断开连接 + try: + controller.disconnect_device() + print("设备连接已断开") + except Exception as e: + print(f"断开连接时出错: {e}") + + +def run_tests(): + """运行测试函数""" + print("=== XYZ控制器测试 ===") + + # 1. 测试机械配置 + print("\n1. 测试机械配置") + config = MachineConfig( + steps_per_mm_x=204.8, # 16384步/圈 ÷ 80mm导程 + steps_per_mm_y=204.8, # 16384步/圈 ÷ 80mm导程 + steps_per_mm_z=3276.8, # 16384步/圈 ÷ 5mm导程 + max_travel_x=340.0, + max_travel_y=250.0, + max_travel_z=160.0, + homing_speed=50, + default_speed=50 + ) + print(f"X轴步距: {config.steps_per_mm_x} 步/mm") + print(f"Y轴步距: {config.steps_per_mm_y} 步/mm") + print(f"Z轴步距: {config.steps_per_mm_z} 步/mm") + print(f"行程限制: X={config.max_travel_x}mm, Y={config.max_travel_y}mm, Z={config.max_travel_z}mm") + + # 2. 测试坐标原点数据结构 + print("\n2. 测试坐标原点数据结构") + origin = CoordinateOrigin() + print(f"初始状态: 已回零={origin.is_homed}") + print(f"机械原点: {origin.machine_origin_steps}") + print(f"工作原点: {origin.work_origin_steps}") + + # 设置示例数据 + origin.machine_origin_steps = {'x': 0, 'y': 0, 'z': 0} + origin.work_origin_steps = {'x': 16384, 'y': 16384, 'z': 13107} # 5mm, 5mm, 2mm (基于16384步/圈) + origin.is_homed = True + origin.timestamp = "2024-09-26 12:00:00" + print(f"设置后: 已回零={origin.is_homed}") + print(f"机械原点: {origin.machine_origin_steps}") + print(f"工作原点: {origin.work_origin_steps}") + + # 3. 测试离线功能 + print("\n3. 测试离线功能") + + # 创建离线控制器(不自动连接) + offline_controller = XYZController( + port='/dev/ttyUSB_CH340', + machine_config=config, + auto_connect=False + ) + + # 测试单位转换 + print("\n单位转换测试:") + test_distances = [1.0, 5.0, 10.0, 25.5] + for distance in test_distances: + x_steps = offline_controller.mm_to_steps(MotorAxis.X, distance) + y_steps = offline_controller.mm_to_steps(MotorAxis.Y, distance) + z_steps = offline_controller.mm_to_steps(MotorAxis.Z, distance) + print(f"{distance}mm -> X:{x_steps}步, Y:{y_steps}步, Z:{z_steps}步") + + # 反向转换验证 + x_mm = offline_controller.steps_to_mm(MotorAxis.X, x_steps) + y_mm = offline_controller.steps_to_mm(MotorAxis.Y, y_steps) + z_mm = offline_controller.steps_to_mm(MotorAxis.Z, z_steps) + print(f"反向转换: X:{x_mm:.2f}mm, Y:{y_mm:.2f}mm, Z:{z_mm:.2f}mm") + + # 测试坐标系转换 + print("\n坐标系转换测试:") + offline_controller.coordinate_origin = origin # 使用示例原点 + work_coords = [(0, 0, 0), (10, 15, 5), (50, 30, 20)] + + for x, y, z in work_coords: + try: + machine_steps = offline_controller.work_to_machine_steps(x, y, z) + print(f"工作坐标 ({x}, {y}, {z}) -> 机械步数 {machine_steps}") + + # 反向转换验证 + work_coords_back = offline_controller.machine_to_work_coords(machine_steps) + print(f"反向转换: ({work_coords_back['x']:.2f}, {work_coords_back['y']:.2f}, {work_coords_back['z']:.2f})") + except Exception as e: + print(f"转换失败: {e}") + + # 测试行程限制检查 + print("\n行程限制检查测试:") + test_positions = [ + (50, 50, 25, "正常位置"), + (250, 50, 25, "X轴超限"), + (50, 350, 25, "Y轴超限"), + (50, 50, 150, "Z轴超限"), + (-10, 50, 25, "X轴负超限"), + (50, -10, 25, "Y轴负超限"), + (50, 50, -5, "Z轴负超限") + ] + + # for x, y, z, desc in test_positions: + # try: + # offline_controller.check_travel_limits(x, y, z) + # print(f"{desc} ({x}, {y}, {z}): 有效") + # except CoordinateSystemError as e: + # print(f"{desc} ({x}, {y}, {z}): 超限 - {e}") + + print("\n=== 离线功能测试完成 ===") + + # 4. 硬件连接测试 + print("\n4. 硬件连接测试") + print("尝试连接真实设备...") + + # 可能的串口列表 + possible_ports = [ + '/dev/ttyUSB_CH340' + ] + + connected_controller = None + + for port in possible_ports: + try: + print(f"尝试连接端口: {port}") + controller = XYZController( + port=port, + machine_config=config, + auto_connect=True + ) + + if controller.is_connected: + print(f"成功连接到 {port}") + connected_controller = controller + + # 获取系统状态 + status = controller.get_system_status() + print("\n系统状态:") + print(f" 连接状态: {status['connection']['is_connected']}") + print(f" 是否已回零: {status['coordinate_system']['is_homed']}") + + if 'current_position' in status: + print(" 当前位置:") + for axis, pos_info in status['current_position'].items(): + print(f" {axis.upper()}轴: {pos_info['steps']}步 ({pos_info['mm']:.2f}mm)") + + # 测试基本移动功能 + print("\n测试基本移动功能:") + try: + # 获取当前位置 + current_pos = controller.get_current_position_mm() + print(f"当前工作坐标: {current_pos}") + + # 小幅移动测试 + print("执行小幅移动测试 (X+1mm)...") + if controller.move_relative_work_coord(dx=1.0, speed=500): + print("移动成功") + time.sleep(1) + new_pos = controller.get_current_position_mm() + print(f"移动后坐标: {new_pos}") + else: + print("移动失败") + + except Exception as e: + print(f"移动测试失败: {e}") + + break + + except Exception as e: + print(f"连接 {port} 失败: {e}") + continue + + if not connected_controller: + print("未找到可用的设备端口") + print("请检查:") + print(" 1. 设备是否正确连接") + print(" 2. 串口端口是否正确") + print(" 3. 设备驱动是否安装") + else: + # 进入交互式控制模式 + interactive_control(connected_controller) + + print("\n=== XYZ控制器测试完成 ===") + + +# ==================== 测试和示例代码 ==================== +if __name__ == "__main__": + run_tests() + # xyz_controller = XYZController(port='/dev/ttyUSB_CH340', auto_connect=True) + # # xyz_controller.stop_all_axes() + # xyz_controller.connect_device() + # time.sleep(1) + # xyz_controller.home_all_axes() diff --git a/unilabos/devices/liquid_handling/laiyu/core/LaiYu_Liquid.py b/unilabos/devices/liquid_handling/laiyu/core/LaiYu_Liquid.py new file mode 100644 index 00000000..96092556 --- /dev/null +++ b/unilabos/devices/liquid_handling/laiyu/core/LaiYu_Liquid.py @@ -0,0 +1,881 @@ +""" +LaiYu_Liquid 液体处理工作站主要集成文件 + +该模块实现了 LaiYu_Liquid 与 UniLabOS 系统的集成,提供标准化的液体处理接口。 +主要包含: +- LaiYuLiquidBackend: 硬件通信后端 +- LaiYuLiquid: 主要接口类 +- 相关的异常类和容器类 +""" + +import asyncio +import logging +import time +from typing import List, Optional, Dict, Any, Union, Tuple +from dataclasses import dataclass +from abc import ABC, abstractmethod + +# 基础导入 +try: + from pylabrobot.resources import Deck, Plate, TipRack, Tip, Resource, Well + PYLABROBOT_AVAILABLE = True +except ImportError: + # 如果 pylabrobot 不可用,创建基础的模拟类 + PYLABROBOT_AVAILABLE = False + + class Resource: + def __init__(self, name: str): + self.name = name + + class Deck(Resource): + pass + + class Plate(Resource): + pass + + class TipRack(Resource): + pass + + class Tip(Resource): + pass + + class Well(Resource): + pass + +# LaiYu_Liquid 控制器导入 +try: + from .controllers.pipette_controller import ( + PipetteController, TipStatus, LiquidClass, LiquidParameters + ) + from .controllers.xyz_controller import ( + XYZController, MachineConfig, CoordinateOrigin, MotorAxis + ) + CONTROLLERS_AVAILABLE = True +except ImportError: + CONTROLLERS_AVAILABLE = False + # 创建模拟的控制器类 + class PipetteController: + def __init__(self, *args, **kwargs): + pass + + def connect(self): + return True + + def initialize(self): + return True + + class XYZController: + def __init__(self, *args, **kwargs): + pass + + def connect_device(self): + return True + +logger = logging.getLogger(__name__) + + +class LaiYuLiquidError(RuntimeError): + """LaiYu_Liquid 设备异常""" + pass + + +@dataclass +class LaiYuLiquidConfig: + """LaiYu_Liquid 设备配置""" + port: str = "/dev/cu.usbserial-3130" # RS485转USB端口 + address: int = 1 # 设备地址 + baudrate: int = 9600 # 波特率 + timeout: float = 5.0 # 通信超时时间 + + # 工作台尺寸 + deck_width: float = 340.0 # 工作台宽度 (mm) + deck_height: float = 250.0 # 工作台高度 (mm) + deck_depth: float = 160.0 # 工作台深度 (mm) + + # 移液参数 + max_volume: float = 1000.0 # 最大体积 (μL) + min_volume: float = 0.1 # 最小体积 (μL) + + # 运动参数 + max_speed: float = 100.0 # 最大速度 (mm/s) + acceleration: float = 50.0 # 加速度 (mm/s²) + + # 安全参数 + safe_height: float = 50.0 # 安全高度 (mm) + tip_pickup_depth: float = 10.0 # 吸头拾取深度 (mm) + liquid_detection: bool = True # 液面检测 + + # 取枪头相关参数 + tip_pickup_speed: int = 30 # 取枪头时的移动速度 (rpm) + tip_pickup_acceleration: int = 500 # 取枪头时的加速度 (rpm/s) + tip_approach_height: float = 5.0 # 接近枪头时的高度 (mm) + tip_pickup_force_depth: float = 2.0 # 强制插入深度 (mm) + tip_pickup_retract_height: float = 20.0 # 取枪头后的回退高度 (mm) + + # 丢弃枪头相关参数 + tip_drop_height: float = 10.0 # 丢弃枪头时的高度 (mm) + tip_drop_speed: int = 50 # 丢弃枪头时的移动速度 (rpm) + trash_position: Tuple[float, float, float] = (300.0, 200.0, 0.0) # 垃圾桶位置 (mm) + + # 安全范围配置 + deck_width: float = 300.0 # 工作台宽度 (mm) + deck_height: float = 200.0 # 工作台高度 (mm) + deck_depth: float = 100.0 # 工作台深度 (mm) + safe_height: float = 50.0 # 安全高度 (mm) + position_validation: bool = True # 启用位置验证 + emergency_stop_enabled: bool = True # 启用紧急停止 + + +class LaiYuLiquidDeck: + """LaiYu_Liquid 工作台管理""" + + def __init__(self, config: LaiYuLiquidConfig): + self.config = config + self.resources: Dict[str, Resource] = {} + self.positions: Dict[str, Tuple[float, float, float]] = {} + + def add_resource(self, name: str, resource: Resource, position: Tuple[float, float, float]): + """添加资源到工作台""" + self.resources[name] = resource + self.positions[name] = position + + def get_resource(self, name: str) -> Optional[Resource]: + """获取资源""" + return self.resources.get(name) + + def get_position(self, name: str) -> Optional[Tuple[float, float, float]]: + """获取资源位置""" + return self.positions.get(name) + + def list_resources(self) -> List[str]: + """列出所有资源""" + return list(self.resources.keys()) + + +class LaiYuLiquidContainer: + """LaiYu_Liquid 容器类""" + + def __init__(self, name: str, size_x: float = 0, size_y: float = 0, size_z: float = 0, container_type: str = "", volume: float = 0.0, max_volume: float = 1000.0, lid_height: float = 0.0): + self.name = name + self.size_x = size_x + self.size_y = size_y + self.size_z = size_z + self.lid_height = lid_height + self.container_type = container_type + self.volume = volume + self.max_volume = max_volume + self.last_updated = time.time() + self.child_resources = {} # 存储子资源 + + @property + def is_empty(self) -> bool: + return self.volume <= 0.0 + + @property + def is_full(self) -> bool: + return self.volume >= self.max_volume + + @property + def available_volume(self) -> float: + return max(0.0, self.max_volume - self.volume) + + def add_volume(self, volume: float) -> bool: + """添加体积""" + if self.volume + volume <= self.max_volume: + self.volume += volume + self.last_updated = time.time() + return True + return False + + def remove_volume(self, volume: float) -> bool: + """移除体积""" + if self.volume >= volume: + self.volume -= volume + self.last_updated = time.time() + return True + return False + + def assign_child_resource(self, resource, location=None): + """分配子资源 - 与 PyLabRobot 资源管理系统兼容""" + if hasattr(resource, 'name'): + self.child_resources[resource.name] = { + 'resource': resource, + 'location': location + } + + +class LaiYuLiquidTipRack: + """LaiYu_Liquid 吸头架类""" + + def __init__(self, name: str, size_x: float = 0, size_y: float = 0, size_z: float = 0, tip_count: int = 96, tip_volume: float = 1000.0): + self.name = name + self.size_x = size_x + self.size_y = size_y + self.size_z = size_z + self.tip_count = tip_count + self.tip_volume = tip_volume + self.tips_available = [True] * tip_count + self.child_resources = {} # 存储子资源 + + @property + def available_tips(self) -> int: + return sum(self.tips_available) + + @property + def is_empty(self) -> bool: + return self.available_tips == 0 + + def pick_tip(self, position: int) -> bool: + """拾取吸头""" + if 0 <= position < self.tip_count and self.tips_available[position]: + self.tips_available[position] = False + return True + return False + + def has_tip(self, position: int) -> bool: + """检查位置是否有吸头""" + if 0 <= position < self.tip_count: + return self.tips_available[position] + return False + + def assign_child_resource(self, resource, location=None): + """分配子资源到指定位置""" + self.child_resources[resource.name] = { + 'resource': resource, + 'location': location + } + + +def get_module_info(): + """获取模块信息""" + return { + "name": "LaiYu_Liquid", + "version": "1.0.0", + "description": "LaiYu液体处理工作站模块,提供移液器控制、XYZ轴控制和资源管理功能", + "author": "UniLabOS Team", + "capabilities": [ + "移液器控制", + "XYZ轴运动控制", + "吸头架管理", + "板和容器管理", + "资源位置管理" + ], + "dependencies": { + "required": ["serial"], + "optional": ["pylabrobot"] + } + } + + +class LaiYuLiquidBackend: + """LaiYu_Liquid 硬件通信后端""" + + def __init__(self, config: LaiYuLiquidConfig, deck: Optional['LaiYuLiquidDeck'] = None): + self.config = config + self.deck = deck # 工作台引用,用于获取资源位置信息 + self.pipette_controller = None + self.xyz_controller = None + self.is_connected = False + self.is_initialized = False + + # 状态跟踪 + self.current_position = (0.0, 0.0, 0.0) + self.tip_attached = False + self.current_volume = 0.0 + + def _validate_position(self, x: float, y: float, z: float) -> bool: + """验证位置是否在安全范围内""" + try: + # 检查X轴范围 + if not (0 <= x <= self.config.deck_width): + logger.error(f"X轴位置 {x:.2f}mm 超出范围 [0, {self.config.deck_width}]") + return False + + # 检查Y轴范围 + if not (0 <= y <= self.config.deck_height): + logger.error(f"Y轴位置 {y:.2f}mm 超出范围 [0, {self.config.deck_height}]") + return False + + # 检查Z轴范围(负值表示向下,0为工作台表面) + if not (-self.config.deck_depth <= z <= self.config.safe_height): + logger.error(f"Z轴位置 {z:.2f}mm 超出安全范围 [{-self.config.deck_depth}, {self.config.safe_height}]") + return False + + return True + except Exception as e: + logger.error(f"位置验证失败: {e}") + return False + + def _check_hardware_ready(self) -> bool: + """检查硬件是否准备就绪""" + if not self.is_connected: + logger.error("设备未连接") + return False + + if CONTROLLERS_AVAILABLE: + if self.xyz_controller is None: + logger.error("XYZ控制器未初始化") + return False + + return True + + async def emergency_stop(self) -> bool: + """紧急停止所有运动""" + try: + logger.warning("执行紧急停止") + + if CONTROLLERS_AVAILABLE and self.xyz_controller: + # 停止XYZ控制器 + await self.xyz_controller.stop_all_motion() + logger.info("XYZ控制器已停止") + + if self.pipette_controller: + # 停止移液器控制器 + await self.pipette_controller.stop() + logger.info("移液器控制器已停止") + + return True + except Exception as e: + logger.error(f"紧急停止失败: {e}") + return False + + async def move_to_safe_position(self) -> bool: + """移动到安全位置""" + try: + if not self._check_hardware_ready(): + return False + + safe_position = ( + self.config.deck_width / 2, # 工作台中心X + self.config.deck_height / 2, # 工作台中心Y + self.config.safe_height # 安全高度Z + ) + + if not self._validate_position(*safe_position): + logger.error("安全位置无效") + return False + + if CONTROLLERS_AVAILABLE and self.xyz_controller: + await self.xyz_controller.move_to_work_coord(*safe_position) + self.current_position = safe_position + logger.info(f"已移动到安全位置: {safe_position}") + return True + else: + # 模拟模式 + self.current_position = safe_position + logger.info("模拟移动到安全位置") + return True + + except Exception as e: + logger.error(f"移动到安全位置失败: {e}") + return False + + async def setup(self) -> bool: + """设置硬件连接""" + try: + if CONTROLLERS_AVAILABLE: + # 初始化移液器控制器 + self.pipette_controller = PipetteController( + port=self.config.port, + address=self.config.address + ) + + # 初始化XYZ控制器 + machine_config = MachineConfig() + self.xyz_controller = XYZController( + port=self.config.port, + baudrate=self.config.baudrate, + machine_config=machine_config + ) + + # 连接设备 + pipette_connected = await asyncio.to_thread(self.pipette_controller.connect) + xyz_connected = await asyncio.to_thread(self.xyz_controller.connect_device) + + if pipette_connected and xyz_connected: + self.is_connected = True + logger.info("LaiYu_Liquid 硬件连接成功") + return True + else: + logger.error("LaiYu_Liquid 硬件连接失败") + return False + else: + # 模拟模式 + logger.info("LaiYu_Liquid 运行在模拟模式") + self.is_connected = True + return True + + except Exception as e: + logger.error(f"LaiYu_Liquid 设置失败: {e}") + return False + + async def stop(self): + """停止设备""" + try: + if self.pipette_controller and hasattr(self.pipette_controller, 'disconnect'): + await asyncio.to_thread(self.pipette_controller.disconnect) + + if self.xyz_controller and hasattr(self.xyz_controller, 'disconnect'): + await asyncio.to_thread(self.xyz_controller.disconnect) + + self.is_connected = False + self.is_initialized = False + logger.info("LaiYu_Liquid 已停止") + + except Exception as e: + logger.error(f"LaiYu_Liquid 停止失败: {e}") + + async def move_to(self, x: float, y: float, z: float) -> bool: + """移动到指定位置""" + try: + if not self.is_connected: + raise LaiYuLiquidError("设备未连接") + + # 模拟移动 + await asyncio.sleep(0.1) # 模拟移动时间 + self.current_position = (x, y, z) + logger.debug(f"移动到位置: ({x}, {y}, {z})") + return True + + except Exception as e: + logger.error(f"移动失败: {e}") + return False + + async def pick_up_tip(self, tip_rack: str, position: int) -> bool: + """拾取吸头 - 包含真正的Z轴下降控制""" + try: + # 硬件准备检查 + if not self._check_hardware_ready(): + return False + + if self.tip_attached: + logger.warning("已有吸头附着,无法拾取新吸头") + return False + + logger.info(f"开始从 {tip_rack} 位置 {position} 拾取吸头") + + # 获取枪头架位置信息 + if self.deck is None: + logger.error("工作台未初始化") + return False + + tip_position = self.deck.get_position(tip_rack) + if tip_position is None: + logger.error(f"未找到枪头架 {tip_rack} 的位置信息") + return False + + # 计算具体枪头位置(这里简化处理,实际应根据position计算偏移) + tip_x, tip_y, tip_z = tip_position + + # 验证所有关键位置的安全性 + safe_z = tip_z + self.config.tip_approach_height + pickup_z = tip_z - self.config.tip_pickup_force_depth + retract_z = tip_z + self.config.tip_pickup_retract_height + + if not (self._validate_position(tip_x, tip_y, safe_z) and + self._validate_position(tip_x, tip_y, pickup_z) and + self._validate_position(tip_x, tip_y, retract_z)): + logger.error("枪头拾取位置超出安全范围") + return False + + if CONTROLLERS_AVAILABLE and self.xyz_controller: + # 真实硬件控制流程 + logger.info("使用真实XYZ控制器进行枪头拾取") + + try: + # 1. 移动到枪头上方的安全位置 + safe_z = tip_z + self.config.tip_approach_height + logger.info(f"移动到枪头上方安全位置: ({tip_x:.2f}, {tip_y:.2f}, {safe_z:.2f})") + move_success = await asyncio.to_thread( + self.xyz_controller.move_to_work_coord, + tip_x, tip_y, safe_z + ) + if not move_success: + logger.error("移动到枪头上方失败") + return False + + # 2. Z轴下降到枪头位置 + pickup_z = tip_z - self.config.tip_pickup_force_depth + logger.info(f"Z轴下降到枪头拾取位置: {pickup_z:.2f}mm") + z_down_success = await asyncio.to_thread( + self.xyz_controller.move_to_work_coord, + tip_x, tip_y, pickup_z + ) + if not z_down_success: + logger.error("Z轴下降到枪头位置失败") + return False + + # 3. 等待一小段时间确保枪头牢固附着 + await asyncio.sleep(0.2) + + # 4. Z轴上升到回退高度 + retract_z = tip_z + self.config.tip_pickup_retract_height + logger.info(f"Z轴上升到回退高度: {retract_z:.2f}mm") + z_up_success = await asyncio.to_thread( + self.xyz_controller.move_to_work_coord, + tip_x, tip_y, retract_z + ) + if not z_up_success: + logger.error("Z轴上升失败") + return False + + # 5. 更新当前位置 + self.current_position = (tip_x, tip_y, retract_z) + + except Exception as move_error: + logger.error(f"枪头拾取过程中发生错误: {move_error}") + # 尝试移动到安全位置 + if self.config.emergency_stop_enabled: + await self.emergency_stop() + await self.move_to_safe_position() + return False + + else: + # 模拟模式 + logger.info("模拟模式:执行枪头拾取动作") + await asyncio.sleep(1.0) # 模拟整个拾取过程的时间 + self.current_position = (tip_x, tip_y, tip_z + self.config.tip_pickup_retract_height) + + # 6. 标记枪头已附着 + self.tip_attached = True + logger.info("吸头拾取成功") + return True + + except Exception as e: + logger.error(f"拾取吸头失败: {e}") + return False + + async def drop_tip(self, location: str = "trash") -> bool: + """丢弃吸头 - 包含真正的Z轴控制""" + try: + # 硬件准备检查 + if not self._check_hardware_ready(): + return False + + if not self.tip_attached: + logger.warning("没有吸头附着,无需丢弃") + return True + + logger.info(f"开始丢弃吸头到 {location}") + + # 确定丢弃位置 + if location == "trash": + # 使用配置中的垃圾桶位置 + drop_x, drop_y, drop_z = self.config.trash_position + else: + # 尝试从deck获取指定位置 + if self.deck is None: + logger.error("工作台未初始化") + return False + + drop_position = self.deck.get_position(location) + if drop_position is None: + logger.error(f"未找到丢弃位置 {location} 的信息") + return False + drop_x, drop_y, drop_z = drop_position + + # 验证丢弃位置的安全性 + safe_z = drop_z + self.config.safe_height + drop_height_z = drop_z + self.config.tip_drop_height + + if not (self._validate_position(drop_x, drop_y, safe_z) and + self._validate_position(drop_x, drop_y, drop_height_z)): + logger.error("枪头丢弃位置超出安全范围") + return False + + if CONTROLLERS_AVAILABLE and self.xyz_controller: + # 真实硬件控制流程 + logger.info("使用真实XYZ控制器进行枪头丢弃") + + try: + # 1. 移动到丢弃位置上方的安全高度 + safe_z = drop_z + self.config.tip_drop_height + logger.info(f"移动到丢弃位置上方: ({drop_x:.2f}, {drop_y:.2f}, {safe_z:.2f})") + move_success = await asyncio.to_thread( + self.xyz_controller.move_to_work_coord, + drop_x, drop_y, safe_z + ) + if not move_success: + logger.error("移动到丢弃位置上方失败") + return False + + # 2. Z轴下降到丢弃高度 + logger.info(f"Z轴下降到丢弃高度: {drop_z:.2f}mm") + z_down_success = await asyncio.to_thread( + self.xyz_controller.move_to_work_coord, + drop_x, drop_y, drop_z + ) + if not z_down_success: + logger.error("Z轴下降到丢弃位置失败") + return False + + # 3. 执行枪头弹出动作(如果有移液器控制器) + if self.pipette_controller: + try: + # 发送弹出枪头命令 + await asyncio.to_thread(self.pipette_controller.eject_tip) + logger.info("执行枪头弹出命令") + except Exception as e: + logger.warning(f"枪头弹出命令失败: {e}") + + # 4. 等待一小段时间确保枪头完全脱离 + await asyncio.sleep(0.3) + + # 5. Z轴上升到安全高度 + logger.info(f"Z轴上升到安全高度: {safe_z:.2f}mm") + z_up_success = await asyncio.to_thread( + self.xyz_controller.move_to_work_coord, + drop_x, drop_y, safe_z + ) + if not z_up_success: + logger.error("Z轴上升失败") + return False + + # 6. 更新当前位置 + self.current_position = (drop_x, drop_y, safe_z) + + except Exception as drop_error: + logger.error(f"枪头丢弃过程中发生错误: {drop_error}") + # 尝试移动到安全位置 + if self.config.emergency_stop_enabled: + await self.emergency_stop() + await self.move_to_safe_position() + return False + + else: + # 模拟模式 + logger.info("模拟模式:执行枪头丢弃动作") + await asyncio.sleep(0.8) # 模拟整个丢弃过程的时间 + self.current_position = (drop_x, drop_y, drop_z + self.config.tip_drop_height) + + # 7. 标记枪头已脱离,清空体积 + self.tip_attached = False + self.current_volume = 0.0 + logger.info("吸头丢弃成功") + return True + + except Exception as e: + logger.error(f"丢弃吸头失败: {e}") + return False + + async def aspirate(self, volume: float, location: str) -> bool: + """吸取液体""" + try: + if not self.is_connected: + raise LaiYuLiquidError("设备未连接") + + if not self.tip_attached: + raise LaiYuLiquidError("没有吸头附着") + + if volume <= 0 or volume > self.config.max_volume: + raise LaiYuLiquidError(f"体积超出范围: {volume}") + + # 模拟吸取 + await asyncio.sleep(0.3) + self.current_volume += volume + logger.debug(f"从 {location} 吸取 {volume} μL") + return True + + except Exception as e: + logger.error(f"吸取失败: {e}") + return False + + async def dispense(self, volume: float, location: str) -> bool: + """分配液体""" + try: + if not self.is_connected: + raise LaiYuLiquidError("设备未连接") + + if not self.tip_attached: + raise LaiYuLiquidError("没有吸头附着") + + if volume <= 0 or volume > self.current_volume: + raise LaiYuLiquidError(f"分配体积无效: {volume}") + + # 模拟分配 + await asyncio.sleep(0.3) + self.current_volume -= volume + logger.debug(f"向 {location} 分配 {volume} μL") + return True + + except Exception as e: + logger.error(f"分配失败: {e}") + return False + + +class LaiYuLiquid: + """LaiYu_Liquid 主要接口类""" + + def __init__(self, config: Optional[LaiYuLiquidConfig] = None, **kwargs): + # 如果传入了关键字参数,创建配置对象 + if kwargs and config is None: + # 从kwargs中提取配置参数 + config_params = {} + for key, value in kwargs.items(): + if hasattr(LaiYuLiquidConfig, key): + config_params[key] = value + self.config = LaiYuLiquidConfig(**config_params) + else: + self.config = config or LaiYuLiquidConfig() + + # 先创建deck,然后传递给backend + self.deck = LaiYuLiquidDeck(self.config) + self.backend = LaiYuLiquidBackend(self.config, self.deck) + self.is_setup = False + + @property + def current_position(self) -> Tuple[float, float, float]: + """获取当前位置""" + return self.backend.current_position + + @property + def current_volume(self) -> float: + """获取当前体积""" + return self.backend.current_volume + + @property + def is_connected(self) -> bool: + """获取连接状态""" + return self.backend.is_connected + + @property + def is_initialized(self) -> bool: + """获取初始化状态""" + return self.backend.is_initialized + + @property + def tip_attached(self) -> bool: + """获取吸头附着状态""" + return self.backend.tip_attached + + async def setup(self) -> bool: + """设置液体处理器""" + try: + success = await self.backend.setup() + if success: + self.is_setup = True + logger.info("LaiYu_Liquid 设置完成") + return success + except Exception as e: + logger.error(f"LaiYu_Liquid 设置失败: {e}") + return False + + async def stop(self): + """停止液体处理器""" + await self.backend.stop() + self.is_setup = False + + async def transfer(self, source: str, target: str, volume: float, + tip_rack: str = "tip_rack_1", tip_position: int = 0) -> bool: + """液体转移""" + try: + if not self.is_setup: + raise LaiYuLiquidError("设备未设置") + + # 获取源和目标位置 + source_pos = self.deck.get_position(source) + target_pos = self.deck.get_position(target) + tip_pos = self.deck.get_position(tip_rack) + + if not all([source_pos, target_pos, tip_pos]): + raise LaiYuLiquidError("位置信息不完整") + + # 执行转移步骤 + steps = [ + ("移动到吸头架", self.backend.move_to(*tip_pos)), + ("拾取吸头", self.backend.pick_up_tip(tip_rack, tip_position)), + ("移动到源位置", self.backend.move_to(*source_pos)), + ("吸取液体", self.backend.aspirate(volume, source)), + ("移动到目标位置", self.backend.move_to(*target_pos)), + ("分配液体", self.backend.dispense(volume, target)), + ("丢弃吸头", self.backend.drop_tip()) + ] + + for step_name, step_coro in steps: + logger.debug(f"执行步骤: {step_name}") + success = await step_coro + if not success: + raise LaiYuLiquidError(f"步骤失败: {step_name}") + + logger.info(f"液体转移完成: {source} -> {target}, {volume} μL") + return True + + except Exception as e: + logger.error(f"液体转移失败: {e}") + return False + + def add_resource(self, name: str, resource_type: str, position: Tuple[float, float, float]): + """添加资源到工作台""" + if resource_type == "plate": + resource = Plate(name) + elif resource_type == "tip_rack": + resource = TipRack(name) + else: + resource = Resource(name) + + self.deck.add_resource(name, resource, position) + + def get_status(self) -> Dict[str, Any]: + """获取设备状态""" + return { + "connected": self.backend.is_connected, + "setup": self.is_setup, + "current_position": self.backend.current_position, + "tip_attached": self.backend.tip_attached, + "current_volume": self.backend.current_volume, + "resources": self.deck.list_resources() + } + + +def create_quick_setup() -> LaiYuLiquidDeck: + """ + 创建快速设置的LaiYu液体处理工作站 + + Returns: + LaiYuLiquidDeck: 配置好的工作台实例 + """ + # 创建默认配置 + config = LaiYuLiquidConfig() + + # 创建工作台 + deck = LaiYuLiquidDeck(config) + + # 导入资源创建函数 + try: + from .laiyu_liquid_res import ( + create_tip_rack_1000ul, + create_tip_rack_200ul, + create_96_well_plate, + create_waste_container + ) + + # 添加基本资源 + tip_rack_1000 = create_tip_rack_1000ul("tip_rack_1000") + tip_rack_200 = create_tip_rack_200ul("tip_rack_200") + plate_96 = create_96_well_plate("plate_96") + waste = create_waste_container("waste") + + # 添加到工作台 + deck.add_resource("tip_rack_1000", tip_rack_1000, (50, 50, 0)) + deck.add_resource("tip_rack_200", tip_rack_200, (150, 50, 0)) + deck.add_resource("plate_96", plate_96, (250, 50, 0)) + deck.add_resource("waste", waste, (50, 150, 0)) + + except ImportError: + # 如果资源模块不可用,创建空的工作台 + logger.warning("资源模块不可用,创建空的工作台") + + return deck + + +__all__ = [ + "LaiYuLiquid", + "LaiYuLiquidBackend", + "LaiYuLiquidConfig", + "LaiYuLiquidDeck", + "LaiYuLiquidContainer", + "LaiYuLiquidTipRack", + "LaiYuLiquidError", + "create_quick_setup", + "get_module_info" +] \ No newline at end of file diff --git a/unilabos/devices/liquid_handling/laiyu/core/__init__.py b/unilabos/devices/liquid_handling/laiyu/core/__init__.py new file mode 100644 index 00000000..e4d2baa9 --- /dev/null +++ b/unilabos/devices/liquid_handling/laiyu/core/__init__.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +LaiYu液体处理设备核心模块 + +该模块包含LaiYu液体处理设备的核心功能组件: +- LaiYu_Liquid.py: 主设备类和配置管理 +- abstract_protocol.py: 抽象协议定义 +- laiyu_liquid_res.py: 设备资源管理 + +作者: UniLab团队 +版本: 2.0.0 +""" + +from .LaiYu_Liquid import ( + LaiYuLiquid, + LaiYuLiquidConfig, + LaiYuLiquidDeck, + LaiYuLiquidContainer, + LaiYuLiquidTipRack, + create_quick_setup +) + +from .laiyu_liquid_res import ( + LaiYuLiquidDeck, + LaiYuLiquidContainer, + LaiYuLiquidTipRack +) + +__all__ = [ + # 主设备类 + 'LaiYuLiquid', + 'LaiYuLiquidConfig', + + # 设备资源 + 'LaiYuLiquidDeck', + 'LaiYuLiquidContainer', + 'LaiYuLiquidTipRack', + + # 工具函数 + 'create_quick_setup' +] \ No newline at end of file diff --git a/unilabos/devices/liquid_handling/laiyu/core/abstract_protocol.py b/unilabos/devices/liquid_handling/laiyu/core/abstract_protocol.py new file mode 100644 index 00000000..9959c364 --- /dev/null +++ b/unilabos/devices/liquid_handling/laiyu/core/abstract_protocol.py @@ -0,0 +1,529 @@ +""" +LaiYu_Liquid 抽象协议实现 + +该模块提供了液体资源管理和转移的抽象协议,包括: +- MaterialResource: 液体资源管理类 +- transfer_liquid: 液体转移函数 +- 相关的辅助类和函数 + +主要功能: +- 管理多孔位的液体资源 +- 计算和跟踪液体体积 +- 处理液体转移操作 +- 提供资源状态查询 +""" + +import logging +from typing import Dict, List, Optional, Union, Any, Tuple +from dataclasses import dataclass, field +from enum import Enum +import uuid +import time + +# pylabrobot 导入 +from pylabrobot.resources import Resource, Well, Plate + +logger = logging.getLogger(__name__) + + +class LiquidType(Enum): + """液体类型枚举""" + WATER = "water" + ETHANOL = "ethanol" + DMSO = "dmso" + BUFFER = "buffer" + SAMPLE = "sample" + REAGENT = "reagent" + WASTE = "waste" + UNKNOWN = "unknown" + + +@dataclass +class LiquidInfo: + """液体信息类""" + liquid_type: LiquidType = LiquidType.UNKNOWN + volume: float = 0.0 # 体积 (μL) + concentration: Optional[float] = None # 浓度 (mg/ml, M等) + ph: Optional[float] = None # pH值 + temperature: Optional[float] = None # 温度 (°C) + viscosity: Optional[float] = None # 粘度 (cP) + density: Optional[float] = None # 密度 (g/ml) + description: str = "" # 描述信息 + + def __str__(self) -> str: + return f"{self.liquid_type.value}({self.description})" + + +@dataclass +class WellContent: + """孔位内容类""" + volume: float = 0.0 # 当前体积 (ul) + max_volume: float = 1000.0 # 最大容量 (ul) + liquid_info: LiquidInfo = field(default_factory=LiquidInfo) + last_updated: float = field(default_factory=time.time) + + @property + def is_empty(self) -> bool: + """检查是否为空""" + return self.volume <= 0.0 + + @property + def is_full(self) -> bool: + """检查是否已满""" + return self.volume >= self.max_volume + + @property + def available_volume(self) -> float: + """可用体积""" + return max(0.0, self.max_volume - self.volume) + + @property + def fill_percentage(self) -> float: + """填充百分比""" + return (self.volume / self.max_volume) * 100.0 if self.max_volume > 0 else 0.0 + + def can_add_volume(self, volume: float) -> bool: + """检查是否可以添加指定体积""" + return (self.volume + volume) <= self.max_volume + + def can_remove_volume(self, volume: float) -> bool: + """检查是否可以移除指定体积""" + return self.volume >= volume + + def add_volume(self, volume: float, liquid_info: Optional[LiquidInfo] = None) -> bool: + """ + 添加液体体积 + + Args: + volume: 要添加的体积 (ul) + liquid_info: 液体信息 + + Returns: + bool: 是否成功添加 + """ + if not self.can_add_volume(volume): + return False + + self.volume += volume + if liquid_info: + self.liquid_info = liquid_info + self.last_updated = time.time() + return True + + def remove_volume(self, volume: float) -> bool: + """ + 移除液体体积 + + Args: + volume: 要移除的体积 (ul) + + Returns: + bool: 是否成功移除 + """ + if not self.can_remove_volume(volume): + return False + + self.volume -= volume + self.last_updated = time.time() + + # 如果完全清空,重置液体信息 + if self.volume <= 0.0: + self.volume = 0.0 + self.liquid_info = LiquidInfo() + + return True + + +class MaterialResource: + """ + 液体资源管理类 + + 该类用于管理液体处理过程中的资源状态,包括: + - 跟踪多个孔位的液体体积和类型 + - 计算总体积和可用体积 + - 处理液体的添加和移除 + - 提供资源状态查询 + """ + + def __init__( + self, + resource: Resource, + wells: Optional[List[Well]] = None, + default_max_volume: float = 1000.0 + ): + """ + 初始化材料资源 + + Args: + resource: pylabrobot 资源对象 + wells: 孔位列表,如果为None则自动获取 + default_max_volume: 默认最大体积 (ul) + """ + self.resource = resource + self.resource_id = str(uuid.uuid4()) + self.default_max_volume = default_max_volume + + # 获取孔位列表 + if wells is None: + if hasattr(resource, 'get_wells'): + self.wells = resource.get_wells() + elif hasattr(resource, 'wells'): + self.wells = resource.wells + else: + # 如果没有孔位,创建一个虚拟孔位 + self.wells = [resource] + else: + self.wells = wells + + # 初始化孔位内容 + self.well_contents: Dict[str, WellContent] = {} + for well in self.wells: + well_id = self._get_well_id(well) + self.well_contents[well_id] = WellContent( + max_volume=default_max_volume + ) + + logger.info(f"初始化材料资源: {resource.name}, 孔位数: {len(self.wells)}") + + def _get_well_id(self, well: Union[Well, Resource]) -> str: + """获取孔位ID""" + if hasattr(well, 'name'): + return well.name + else: + return str(id(well)) + + @property + def name(self) -> str: + """资源名称""" + return self.resource.name + + @property + def total_volume(self) -> float: + """总液体体积""" + return sum(content.volume for content in self.well_contents.values()) + + @property + def total_max_volume(self) -> float: + """总最大容量""" + return sum(content.max_volume for content in self.well_contents.values()) + + @property + def available_volume(self) -> float: + """总可用体积""" + return sum(content.available_volume for content in self.well_contents.values()) + + @property + def well_count(self) -> int: + """孔位数量""" + return len(self.wells) + + @property + def empty_wells(self) -> List[str]: + """空孔位列表""" + return [well_id for well_id, content in self.well_contents.items() + if content.is_empty] + + @property + def full_wells(self) -> List[str]: + """满孔位列表""" + return [well_id for well_id, content in self.well_contents.items() + if content.is_full] + + @property + def occupied_wells(self) -> List[str]: + """有液体的孔位列表""" + return [well_id for well_id, content in self.well_contents.items() + if not content.is_empty] + + def get_well_content(self, well_id: str) -> Optional[WellContent]: + """获取指定孔位的内容""" + return self.well_contents.get(well_id) + + def get_well_volume(self, well_id: str) -> float: + """获取指定孔位的体积""" + content = self.get_well_content(well_id) + return content.volume if content else 0.0 + + def set_well_volume( + self, + well_id: str, + volume: float, + liquid_info: Optional[LiquidInfo] = None + ) -> bool: + """ + 设置指定孔位的体积 + + Args: + well_id: 孔位ID + volume: 体积 (ul) + liquid_info: 液体信息 + + Returns: + bool: 是否成功设置 + """ + if well_id not in self.well_contents: + logger.error(f"孔位 {well_id} 不存在") + return False + + content = self.well_contents[well_id] + if volume > content.max_volume: + logger.error(f"体积 {volume} 超过最大容量 {content.max_volume}") + return False + + content.volume = max(0.0, volume) + if liquid_info: + content.liquid_info = liquid_info + content.last_updated = time.time() + + logger.info(f"设置孔位 {well_id} 体积: {volume}ul") + return True + + def add_liquid( + self, + well_id: str, + volume: float, + liquid_info: Optional[LiquidInfo] = None + ) -> bool: + """ + 向指定孔位添加液体 + + Args: + well_id: 孔位ID + volume: 添加的体积 (ul) + liquid_info: 液体信息 + + Returns: + bool: 是否成功添加 + """ + if well_id not in self.well_contents: + logger.error(f"孔位 {well_id} 不存在") + return False + + content = self.well_contents[well_id] + success = content.add_volume(volume, liquid_info) + + if success: + logger.info(f"向孔位 {well_id} 添加 {volume}ul 液体") + else: + logger.error(f"无法向孔位 {well_id} 添加 {volume}ul 液体") + + return success + + def remove_liquid(self, well_id: str, volume: float) -> bool: + """ + 从指定孔位移除液体 + + Args: + well_id: 孔位ID + volume: 移除的体积 (ul) + + Returns: + bool: 是否成功移除 + """ + if well_id not in self.well_contents: + logger.error(f"孔位 {well_id} 不存在") + return False + + content = self.well_contents[well_id] + success = content.remove_volume(volume) + + if success: + logger.info(f"从孔位 {well_id} 移除 {volume}ul 液体") + else: + logger.error(f"无法从孔位 {well_id} 移除 {volume}ul 液体") + + return success + + def find_wells_with_volume(self, min_volume: float) -> List[str]: + """ + 查找具有指定最小体积的孔位 + + Args: + min_volume: 最小体积 (ul) + + Returns: + List[str]: 符合条件的孔位ID列表 + """ + return [well_id for well_id, content in self.well_contents.items() + if content.volume >= min_volume] + + def find_wells_with_space(self, min_space: float) -> List[str]: + """ + 查找具有指定最小空间的孔位 + + Args: + min_space: 最小空间 (ul) + + Returns: + List[str]: 符合条件的孔位ID列表 + """ + return [well_id for well_id, content in self.well_contents.items() + if content.available_volume >= min_space] + + def get_status_summary(self) -> Dict[str, Any]: + """获取资源状态摘要""" + return { + "resource_name": self.name, + "resource_id": self.resource_id, + "well_count": self.well_count, + "total_volume": self.total_volume, + "total_max_volume": self.total_max_volume, + "available_volume": self.available_volume, + "fill_percentage": (self.total_volume / self.total_max_volume) * 100.0, + "empty_wells": len(self.empty_wells), + "full_wells": len(self.full_wells), + "occupied_wells": len(self.occupied_wells) + } + + def get_detailed_status(self) -> Dict[str, Any]: + """获取详细状态信息""" + well_details = {} + for well_id, content in self.well_contents.items(): + well_details[well_id] = { + "volume": content.volume, + "max_volume": content.max_volume, + "available_volume": content.available_volume, + "fill_percentage": content.fill_percentage, + "liquid_type": content.liquid_info.liquid_type.value, + "description": content.liquid_info.description, + "last_updated": content.last_updated + } + + return { + "summary": self.get_status_summary(), + "wells": well_details + } + + +def transfer_liquid( + source: MaterialResource, + target: MaterialResource, + volume: float, + source_well_id: Optional[str] = None, + target_well_id: Optional[str] = None, + liquid_info: Optional[LiquidInfo] = None +) -> bool: + """ + 在两个材料资源之间转移液体 + + Args: + source: 源资源 + target: 目标资源 + volume: 转移体积 (ul) + source_well_id: 源孔位ID,如果为None则自动选择 + target_well_id: 目标孔位ID,如果为None则自动选择 + liquid_info: 液体信息 + + Returns: + bool: 转移是否成功 + """ + try: + # 自动选择源孔位 + if source_well_id is None: + available_wells = source.find_wells_with_volume(volume) + if not available_wells: + logger.error(f"源资源 {source.name} 没有足够体积的孔位") + return False + source_well_id = available_wells[0] + + # 自动选择目标孔位 + if target_well_id is None: + available_wells = target.find_wells_with_space(volume) + if not available_wells: + logger.error(f"目标资源 {target.name} 没有足够空间的孔位") + return False + target_well_id = available_wells[0] + + # 检查源孔位是否有足够液体 + if not source.get_well_content(source_well_id).can_remove_volume(volume): + logger.error(f"源孔位 {source_well_id} 液体不足") + return False + + # 检查目标孔位是否有足够空间 + if not target.get_well_content(target_well_id).can_add_volume(volume): + logger.error(f"目标孔位 {target_well_id} 空间不足") + return False + + # 获取源液体信息 + source_content = source.get_well_content(source_well_id) + transfer_liquid_info = liquid_info or source_content.liquid_info + + # 执行转移 + if source.remove_liquid(source_well_id, volume): + if target.add_liquid(target_well_id, volume, transfer_liquid_info): + logger.info(f"成功转移 {volume}ul 液体: {source.name}[{source_well_id}] -> {target.name}[{target_well_id}]") + return True + else: + # 如果目标添加失败,回滚源操作 + source.add_liquid(source_well_id, volume, source_content.liquid_info) + logger.error("目标添加失败,已回滚源操作") + return False + else: + logger.error("源移除失败") + return False + + except Exception as e: + logger.error(f"液体转移失败: {e}") + return False + + +def create_material_resource( + name: str, + resource: Resource, + initial_volumes: Optional[Dict[str, float]] = None, + liquid_info: Optional[LiquidInfo] = None, + max_volume: float = 1000.0 +) -> MaterialResource: + """ + 创建材料资源的便捷函数 + + Args: + name: 资源名称 + resource: pylabrobot 资源对象 + initial_volumes: 初始体积字典 {well_id: volume} + liquid_info: 液体信息 + max_volume: 最大体积 + + Returns: + MaterialResource: 创建的材料资源 + """ + material_resource = MaterialResource( + resource=resource, + default_max_volume=max_volume + ) + + # 设置初始体积 + if initial_volumes: + for well_id, volume in initial_volumes.items(): + material_resource.set_well_volume(well_id, volume, liquid_info) + + return material_resource + + +def batch_transfer_liquid( + transfers: List[Tuple[MaterialResource, MaterialResource, float]], + liquid_info: Optional[LiquidInfo] = None +) -> List[bool]: + """ + 批量液体转移 + + Args: + transfers: 转移列表 [(source, target, volume), ...] + liquid_info: 液体信息 + + Returns: + List[bool]: 每个转移操作的结果 + """ + results = [] + + for source, target, volume in transfers: + result = transfer_liquid(source, target, volume, liquid_info=liquid_info) + results.append(result) + + if not result: + logger.warning(f"批量转移中的操作失败: {source.name} -> {target.name}") + + success_count = sum(results) + logger.info(f"批量转移完成: {success_count}/{len(transfers)} 成功") + + return results \ No newline at end of file diff --git a/unilabos/devices/liquid_handling/laiyu/core/laiyu_liquid_res.py b/unilabos/devices/liquid_handling/laiyu/core/laiyu_liquid_res.py new file mode 100644 index 00000000..0c127539 --- /dev/null +++ b/unilabos/devices/liquid_handling/laiyu/core/laiyu_liquid_res.py @@ -0,0 +1,954 @@ +""" +LaiYu_Liquid 资源定义模块 + +该模块提供了 LaiYu_Liquid 工作站专用的资源定义函数,包括: +- 各种规格的枪头架 +- 不同类型的板和容器 +- 特殊功能位置 +- 资源创建的便捷函数 + +所有资源都基于 deck.json 中的配置参数创建。 +""" + +import json +import os +from typing import Dict, List, Optional, Tuple, Any +from pathlib import Path + +# PyLabRobot 资源导入 +try: + from pylabrobot.resources import ( + Resource, Deck, Plate, TipRack, Container, Tip, + Coordinate + ) + from pylabrobot.resources.tip_rack import TipSpot + from pylabrobot.resources.well import Well as PlateWell + PYLABROBOT_AVAILABLE = True +except ImportError: + # 如果 PyLabRobot 不可用,创建模拟类 + PYLABROBOT_AVAILABLE = False + + class Resource: + def __init__(self, name: str): + self.name = name + + class Deck(Resource): + pass + + class Plate(Resource): + pass + + class TipRack(Resource): + pass + + class Container(Resource): + pass + + class Tip(Resource): + pass + + class TipSpot(Resource): + def __init__(self, name: str, **kwargs): + super().__init__(name) + # 忽略其他参数 + + class PlateWell(Resource): + pass + + class Coordinate: + def __init__(self, x: float, y: float, z: float): + self.x = x + self.y = y + self.z = z + +# 本地导入 +from .LaiYu_Liquid import LaiYuLiquidDeck, LaiYuLiquidContainer, LaiYuLiquidTipRack + + +def load_deck_config() -> Dict[str, Any]: + """ + 加载工作台配置文件 + + Returns: + Dict[str, Any]: 配置字典 + """ + # 优先使用最新的deckconfig.json文件 + config_path = Path(__file__).parent / "controllers" / "deckconfig.json" + + # 如果最新配置文件不存在,回退到旧配置文件 + if not config_path.exists(): + config_path = Path(__file__).parent / "config" / "deck.json" + + try: + with open(config_path, 'r', encoding='utf-8') as f: + return json.load(f) + except FileNotFoundError: + # 如果找不到配置文件,返回默认配置 + return { + "name": "LaiYu_Liquid_Deck", + "size_x": 340.0, + "size_y": 250.0, + "size_z": 160.0 + } + + +# 加载配置 +DECK_CONFIG = load_deck_config() + + +class LaiYuTipRack1000(LaiYuLiquidTipRack): + """1000μL 枪头架""" + + def __init__(self, name: str): + """ + 初始化1000μL枪头架 + + Args: + name: 枪头架名称 + """ + super().__init__( + name=name, + size_x=127.76, + size_y=85.48, + size_z=30.0, + tip_count=96, + tip_volume=1000.0 + ) + + # 创建枪头位置 + self._create_tip_spots( + tip_count=96, + tip_spacing=9.0, + tip_type="1000ul" + ) + + def _create_tip_spots(self, tip_count: int, tip_spacing: float, tip_type: str): + """ + 创建枪头位置 - 从配置文件中读取绝对坐标 + + Args: + tip_count: 枪头数量 + tip_spacing: 枪头间距 + tip_type: 枪头类型 + """ + # 从配置文件中获取枪头架的孔位信息 + config = DECK_CONFIG + tip_module = None + + # 查找枪头架模块 + for module in config.get("children", []): + if module.get("type") == "tip_rack": + tip_module = module + break + + if not tip_module: + # 如果配置文件中没有找到,使用默认的相对坐标计算 + rows = 8 + cols = 12 + + for row in range(rows): + for col in range(cols): + spot_name = f"{chr(65 + row)}{col + 1:02d}" + x = col * tip_spacing + tip_spacing / 2 + y = row * tip_spacing + tip_spacing / 2 + + # 创建枪头 - 根据PyLabRobot或模拟类使用不同参数 + if PYLABROBOT_AVAILABLE: + # PyLabRobot的Tip需要特定参数 + tip = Tip( + has_filter=False, + total_tip_length=95.0, # 1000ul枪头长度 + maximal_volume=1000.0, # 最大体积 + fitting_depth=8.0 # 安装深度 + ) + else: + # 模拟类只需要name + tip = Tip(name=f"tip_{spot_name}") + + # 创建枪头位置 + if PYLABROBOT_AVAILABLE: + # PyLabRobot的TipSpot需要特定参数 + tip_spot = TipSpot( + name=spot_name, + size_x=9.0, # 枪头位置宽度 + size_y=9.0, # 枪头位置深度 + size_z=95.0, # 枪头位置高度 + make_tip=lambda: tip # 创建枪头的函数 + ) + else: + # 模拟类只需要name + tip_spot = TipSpot(name=spot_name) + + # 将吸头位置分配到吸头架 + self.assign_child_resource( + tip_spot, + location=Coordinate(x, y, 0) + ) + return + + # 使用配置文件中的绝对坐标 + module_position = tip_module.get("position", {"x": 0, "y": 0, "z": 0}) + + for well_config in tip_module.get("wells", []): + spot_name = well_config["id"] + well_pos = well_config["position"] + + # 计算相对于模块的坐标(绝对坐标减去模块位置) + relative_x = well_pos["x"] - module_position["x"] + relative_y = well_pos["y"] - module_position["y"] + relative_z = well_pos["z"] - module_position["z"] + + # 创建枪头 - 根据PyLabRobot或模拟类使用不同参数 + if PYLABROBOT_AVAILABLE: + # PyLabRobot的Tip需要特定参数 + tip = Tip( + has_filter=False, + total_tip_length=95.0, # 1000ul枪头长度 + maximal_volume=1000.0, # 最大体积 + fitting_depth=8.0 # 安装深度 + ) + else: + # 模拟类只需要name + tip = Tip(name=f"tip_{spot_name}") + + # 创建枪头位置 + if PYLABROBOT_AVAILABLE: + # PyLabRobot的TipSpot需要特定参数 + tip_spot = TipSpot( + name=spot_name, + size_x=well_config.get("diameter", 9.0), # 使用配置中的直径 + size_y=well_config.get("diameter", 9.0), + size_z=well_config.get("depth", 95.0), # 使用配置中的深度 + make_tip=lambda: tip # 创建枪头的函数 + ) + else: + # 模拟类只需要name + tip_spot = TipSpot(name=spot_name) + + # 将吸头位置分配到吸头架 + self.assign_child_resource( + tip_spot, + location=Coordinate(relative_x, relative_y, relative_z) + ) + + # 注意:在PyLabRobot中,Tip不是Resource,不需要分配给TipSpot + # TipSpot的make_tip函数会在需要时创建Tip + + +class LaiYuTipRack200(LaiYuLiquidTipRack): + """200μL 枪头架""" + + def __init__(self, name: str): + """ + 初始化200μL枪头架 + + Args: + name: 枪头架名称 + """ + super().__init__( + name=name, + size_x=127.76, + size_y=85.48, + size_z=30.0, + tip_count=96, + tip_volume=200.0 + ) + + # 创建枪头位置 + self._create_tip_spots( + tip_count=96, + tip_spacing=9.0, + tip_type="200ul" + ) + + def _create_tip_spots(self, tip_count: int, tip_spacing: float, tip_type: str): + """ + 创建枪头位置 + + Args: + tip_count: 枪头数量 + tip_spacing: 枪头间距 + tip_type: 枪头类型 + """ + rows = 8 + cols = 12 + + for row in range(rows): + for col in range(cols): + spot_name = f"{chr(65 + row)}{col + 1:02d}" + x = col * tip_spacing + tip_spacing / 2 + y = row * tip_spacing + tip_spacing / 2 + + # 创建枪头 - 根据PyLabRobot或模拟类使用不同参数 + if PYLABROBOT_AVAILABLE: + # PyLabRobot的Tip需要特定参数 + tip = Tip( + has_filter=False, + total_tip_length=72.0, # 200ul枪头长度 + maximal_volume=200.0, # 最大体积 + fitting_depth=8.0 # 安装深度 + ) + else: + # 模拟类只需要name + tip = Tip(name=f"tip_{spot_name}") + + # 创建枪头位置 + if PYLABROBOT_AVAILABLE: + # PyLabRobot的TipSpot需要特定参数 + tip_spot = TipSpot( + name=spot_name, + size_x=9.0, # 枪头位置宽度 + size_y=9.0, # 枪头位置深度 + size_z=72.0, # 枪头位置高度 + make_tip=lambda: tip # 创建枪头的函数 + ) + else: + # 模拟类只需要name + tip_spot = TipSpot(name=spot_name) + + # 将吸头位置分配到吸头架 + self.assign_child_resource( + tip_spot, + location=Coordinate(x, y, 0) + ) + + # 注意:在PyLabRobot中,Tip不是Resource,不需要分配给TipSpot + # TipSpot的make_tip函数会在需要时创建Tip + + +class LaiYu96WellPlate(LaiYuLiquidContainer): + """96孔板""" + + def __init__(self, name: str, lid_height: float = 0.0): + """ + 初始化96孔板 + + Args: + name: 板名称 + lid_height: 盖子高度 + """ + super().__init__( + name=name, + size_x=127.76, + size_y=85.48, + size_z=14.22, + container_type="96_well_plate", + volume=0.0, + max_volume=200.0, + lid_height=lid_height + ) + + # 创建孔位 + self._create_wells( + well_count=96, + well_volume=200.0, + well_spacing=9.0 + ) + + def get_size_z(self) -> float: + """获取孔位深度""" + return 10.0 # 96孔板孔位深度 + + def _create_wells(self, well_count: int, well_volume: float, well_spacing: float): + """ + 创建孔位 - 从配置文件中读取绝对坐标 + + Args: + well_count: 孔位数量 + well_volume: 孔位体积 + well_spacing: 孔位间距 + """ + # 从配置文件中获取96孔板的孔位信息 + config = DECK_CONFIG + plate_module = None + + # 查找96孔板模块 + for module in config.get("children", []): + if module.get("type") == "96_well_plate": + plate_module = module + break + + if not plate_module: + # 如果配置文件中没有找到,使用默认的相对坐标计算 + rows = 8 + cols = 12 + + for row in range(rows): + for col in range(cols): + well_name = f"{chr(65 + row)}{col + 1:02d}" + x = col * well_spacing + well_spacing / 2 + y = row * well_spacing + well_spacing / 2 + + # 创建孔位 + well = PlateWell( + name=well_name, + size_x=well_spacing * 0.8, + size_y=well_spacing * 0.8, + size_z=self.get_size_z(), + max_volume=well_volume + ) + + # 添加到板 + self.assign_child_resource( + well, + location=Coordinate(x, y, 0) + ) + return + + # 使用配置文件中的绝对坐标 + module_position = plate_module.get("position", {"x": 0, "y": 0, "z": 0}) + + for well_config in plate_module.get("wells", []): + well_name = well_config["id"] + well_pos = well_config["position"] + + # 计算相对于模块的坐标(绝对坐标减去模块位置) + relative_x = well_pos["x"] - module_position["x"] + relative_y = well_pos["y"] - module_position["y"] + relative_z = well_pos["z"] - module_position["z"] + + # 创建孔位 + well = PlateWell( + name=well_name, + size_x=well_config.get("diameter", 8.2) * 0.8, # 使用配置中的直径 + size_y=well_config.get("diameter", 8.2) * 0.8, + size_z=well_config.get("depth", self.get_size_z()), + max_volume=well_config.get("volume", well_volume) + ) + + # 添加到板 + self.assign_child_resource( + well, + location=Coordinate(relative_x, relative_y, relative_z) + ) + + +class LaiYuDeepWellPlate(LaiYuLiquidContainer): + """深孔板""" + + def __init__(self, name: str, lid_height: float = 0.0): + """ + 初始化深孔板 + + Args: + name: 板名称 + lid_height: 盖子高度 + """ + super().__init__( + name=name, + size_x=127.76, + size_y=85.48, + size_z=41.3, + container_type="deep_well_plate", + volume=0.0, + max_volume=2000.0, + lid_height=lid_height + ) + + # 创建孔位 + self._create_wells( + well_count=96, + well_volume=2000.0, + well_spacing=9.0 + ) + + def get_size_z(self) -> float: + """获取孔位深度""" + return 35.0 # 深孔板孔位深度 + + def _create_wells(self, well_count: int, well_volume: float, well_spacing: float): + """ + 创建孔位 - 从配置文件中读取绝对坐标 + + Args: + well_count: 孔位数量 + well_volume: 孔位体积 + well_spacing: 孔位间距 + """ + # 从配置文件中获取深孔板的孔位信息 + config = DECK_CONFIG + plate_module = None + + # 查找深孔板模块(通常是第二个96孔板模块) + plate_modules = [] + for module in config.get("children", []): + if module.get("type") == "96_well_plate": + plate_modules.append(module) + + # 如果有多个96孔板模块,选择第二个作为深孔板 + if len(plate_modules) > 1: + plate_module = plate_modules[1] + elif len(plate_modules) == 1: + plate_module = plate_modules[0] + + if not plate_module: + # 如果配置文件中没有找到,使用默认的相对坐标计算 + rows = 8 + cols = 12 + + for row in range(rows): + for col in range(cols): + well_name = f"{chr(65 + row)}{col + 1:02d}" + x = col * well_spacing + well_spacing / 2 + y = row * well_spacing + well_spacing / 2 + + # 创建孔位 + well = PlateWell( + name=well_name, + size_x=well_spacing * 0.8, + size_y=well_spacing * 0.8, + size_z=self.get_size_z(), + max_volume=well_volume + ) + + # 添加到板 + self.assign_child_resource( + well, + location=Coordinate(x, y, 0) + ) + return + + # 使用配置文件中的绝对坐标 + module_position = plate_module.get("position", {"x": 0, "y": 0, "z": 0}) + + for well_config in plate_module.get("wells", []): + well_name = well_config["id"] + well_pos = well_config["position"] + + # 计算相对于模块的坐标(绝对坐标减去模块位置) + relative_x = well_pos["x"] - module_position["x"] + relative_y = well_pos["y"] - module_position["y"] + relative_z = well_pos["z"] - module_position["z"] + + # 创建孔位 + well = PlateWell( + name=well_name, + size_x=well_config.get("diameter", 8.2) * 0.8, # 使用配置中的直径 + size_y=well_config.get("diameter", 8.2) * 0.8, + size_z=well_config.get("depth", self.get_size_z()), + max_volume=well_config.get("volume", well_volume) + ) + + # 添加到板 + self.assign_child_resource( + well, + location=Coordinate(relative_x, relative_y, relative_z) + ) + + +class LaiYuWasteContainer(Container): + """废液容器""" + + def __init__(self, name: str): + """ + 初始化废液容器 + + Args: + name: 容器名称 + """ + super().__init__( + name=name, + size_x=100.0, + size_y=100.0, + size_z=50.0, + max_volume=5000.0 + ) + + +class LaiYuWashContainer(Container): + """清洗容器""" + + def __init__(self, name: str): + """ + 初始化清洗容器 + + Args: + name: 容器名称 + """ + super().__init__( + name=name, + size_x=100.0, + size_y=100.0, + size_z=50.0, + max_volume=5000.0 + ) + + +class LaiYuReagentContainer(Container): + """试剂容器""" + + def __init__(self, name: str): + """ + 初始化试剂容器 + + Args: + name: 容器名称 + """ + super().__init__( + name=name, + size_x=50.0, + size_y=50.0, + size_z=100.0, + max_volume=2000.0 + ) + + +class LaiYu8TubeRack(LaiYuLiquidContainer): + """8管试管架""" + + def __init__(self, name: str): + """ + 初始化8管试管架 + + Args: + name: 试管架名称 + """ + super().__init__( + name=name, + size_x=151.0, + size_y=75.0, + size_z=75.0, + container_type="tube_rack", + volume=0.0, + max_volume=77000.0 + ) + + # 创建孔位 + self._create_wells( + well_count=8, + well_volume=77000.0, + well_spacing=35.0 + ) + + def get_size_z(self) -> float: + """获取孔位深度""" + return 117.0 # 试管深度 + + def _create_wells(self, well_count: int, well_volume: float, well_spacing: float): + """ + 创建孔位 - 从配置文件中读取绝对坐标 + + Args: + well_count: 孔位数量 + well_volume: 孔位体积 + well_spacing: 孔位间距 + """ + # 从配置文件中获取8管试管架的孔位信息 + config = DECK_CONFIG + tube_module = None + + # 查找8管试管架模块 + for module in config.get("children", []): + if module.get("type") == "tube_rack": + tube_module = module + break + + if not tube_module: + # 如果配置文件中没有找到,使用默认的相对坐标计算 + rows = 2 + cols = 4 + + for row in range(rows): + for col in range(cols): + well_name = f"{chr(65 + row)}{col + 1}" + x = col * well_spacing + well_spacing / 2 + y = row * well_spacing + well_spacing / 2 + + # 创建孔位 + well = PlateWell( + name=well_name, + size_x=29.0, + size_y=29.0, + size_z=self.get_size_z(), + max_volume=well_volume + ) + + # 添加到试管架 + self.assign_child_resource( + well, + location=Coordinate(x, y, 0) + ) + return + + # 使用配置文件中的绝对坐标 + module_position = tube_module.get("position", {"x": 0, "y": 0, "z": 0}) + + for well_config in tube_module.get("wells", []): + well_name = well_config["id"] + well_pos = well_config["position"] + + # 计算相对于模块的坐标(绝对坐标减去模块位置) + relative_x = well_pos["x"] - module_position["x"] + relative_y = well_pos["y"] - module_position["y"] + relative_z = well_pos["z"] - module_position["z"] + + # 创建孔位 + well = PlateWell( + name=well_name, + size_x=well_config.get("diameter", 29.0), + size_y=well_config.get("diameter", 29.0), + size_z=well_config.get("depth", self.get_size_z()), + max_volume=well_config.get("volume", well_volume) + ) + + # 添加到试管架 + self.assign_child_resource( + well, + location=Coordinate(relative_x, relative_y, relative_z) + ) + + +class LaiYuTipDisposal(Resource): + """枪头废料位置""" + + def __init__(self, name: str): + """ + 初始化枪头废料位置 + + Args: + name: 位置名称 + """ + super().__init__( + name=name, + size_x=100.0, + size_y=100.0, + size_z=50.0 + ) + + +class LaiYuMaintenancePosition(Resource): + """维护位置""" + + def __init__(self, name: str): + """ + 初始化维护位置 + + Args: + name: 位置名称 + """ + super().__init__( + name=name, + size_x=50.0, + size_y=50.0, + size_z=100.0 + ) + + +# 资源创建函数 +def create_tip_rack_1000ul(name: str = "tip_rack_1000ul") -> LaiYuTipRack1000: + """ + 创建1000μL枪头架 + + Args: + name: 枪头架名称 + + Returns: + LaiYuTipRack1000: 1000μL枪头架实例 + """ + return LaiYuTipRack1000(name) + + +def create_tip_rack_200ul(name: str = "tip_rack_200ul") -> LaiYuTipRack200: + """ + 创建200μL枪头架 + + Args: + name: 枪头架名称 + + Returns: + LaiYuTipRack200: 200μL枪头架实例 + """ + return LaiYuTipRack200(name) + + +def create_96_well_plate(name: str = "96_well_plate", lid_height: float = 0.0) -> LaiYu96WellPlate: + """ + 创建96孔板 + + Args: + name: 板名称 + lid_height: 盖子高度 + + Returns: + LaiYu96WellPlate: 96孔板实例 + """ + return LaiYu96WellPlate(name, lid_height) + + +def create_deep_well_plate(name: str = "deep_well_plate", lid_height: float = 0.0) -> LaiYuDeepWellPlate: + """ + 创建深孔板 + + Args: + name: 板名称 + lid_height: 盖子高度 + + Returns: + LaiYuDeepWellPlate: 深孔板实例 + """ + return LaiYuDeepWellPlate(name, lid_height) + + +def create_8_tube_rack(name: str = "8_tube_rack") -> LaiYu8TubeRack: + """ + 创建8管试管架 + + Args: + name: 试管架名称 + + Returns: + LaiYu8TubeRack: 8管试管架实例 + """ + return LaiYu8TubeRack(name) + + +def create_waste_container(name: str = "waste_container") -> LaiYuWasteContainer: + """ + 创建废液容器 + + Args: + name: 容器名称 + + Returns: + LaiYuWasteContainer: 废液容器实例 + """ + return LaiYuWasteContainer(name) + + +def create_wash_container(name: str = "wash_container") -> LaiYuWashContainer: + """ + 创建清洗容器 + + Args: + name: 容器名称 + + Returns: + LaiYuWashContainer: 清洗容器实例 + """ + return LaiYuWashContainer(name) + + +def create_reagent_container(name: str = "reagent_container") -> LaiYuReagentContainer: + """ + 创建试剂容器 + + Args: + name: 容器名称 + + Returns: + LaiYuReagentContainer: 试剂容器实例 + """ + return LaiYuReagentContainer(name) + + +def create_tip_disposal(name: str = "tip_disposal") -> LaiYuTipDisposal: + """ + 创建枪头废料位置 + + Args: + name: 位置名称 + + Returns: + LaiYuTipDisposal: 枪头废料位置实例 + """ + return LaiYuTipDisposal(name) + + +def create_maintenance_position(name: str = "maintenance_position") -> LaiYuMaintenancePosition: + """ + 创建维护位置 + + Args: + name: 位置名称 + + Returns: + LaiYuMaintenancePosition: 维护位置实例 + """ + return LaiYuMaintenancePosition(name) + + +def create_standard_deck() -> LaiYuLiquidDeck: + """ + 创建标准工作台配置 + + Returns: + LaiYuLiquidDeck: 配置好的工作台实例 + """ + # 从配置文件创建工作台 + deck = LaiYuLiquidDeck(config=DECK_CONFIG) + + return deck + + +def get_resource_by_name(deck: LaiYuLiquidDeck, name: str) -> Optional[Resource]: + """ + 根据名称获取资源 + + Args: + deck: 工作台实例 + name: 资源名称 + + Returns: + Optional[Resource]: 找到的资源,如果不存在则返回None + """ + for child in deck.children: + if child.name == name: + return child + return None + + +def get_resources_by_type(deck: LaiYuLiquidDeck, resource_type: type) -> List[Resource]: + """ + 根据类型获取资源列表 + + Args: + deck: 工作台实例 + resource_type: 资源类型 + + Returns: + List[Resource]: 匹配类型的资源列表 + """ + return [child for child in deck.children if isinstance(child, resource_type)] + + +def list_all_resources(deck: LaiYuLiquidDeck) -> Dict[str, List[str]]: + """ + 列出所有资源 + + Args: + deck: 工作台实例 + + Returns: + Dict[str, List[str]]: 按类型分组的资源名称字典 + """ + resources = { + "tip_racks": [], + "plates": [], + "containers": [], + "positions": [] + } + + for child in deck.children: + if isinstance(child, (LaiYuTipRack1000, LaiYuTipRack200)): + resources["tip_racks"].append(child.name) + elif isinstance(child, (LaiYu96WellPlate, LaiYuDeepWellPlate)): + resources["plates"].append(child.name) + elif isinstance(child, (LaiYuWasteContainer, LaiYuWashContainer, LaiYuReagentContainer)): + resources["containers"].append(child.name) + elif isinstance(child, (LaiYuTipDisposal, LaiYuMaintenancePosition)): + resources["positions"].append(child.name) + + return resources + + +# 导出的类别名(向后兼容) +TipRack1000ul = LaiYuTipRack1000 +TipRack200ul = LaiYuTipRack200 +Plate96Well = LaiYu96WellPlate +Plate96DeepWell = LaiYuDeepWellPlate +TubeRack8 = LaiYu8TubeRack +WasteContainer = LaiYuWasteContainer +WashContainer = LaiYuWashContainer +ReagentContainer = LaiYuReagentContainer +TipDisposal = LaiYuTipDisposal +MaintenancePosition = LaiYuMaintenancePosition \ No newline at end of file diff --git a/unilabos/devices/liquid_handling/laiyu/docs/CHANGELOG.md b/unilabos/devices/liquid_handling/laiyu/docs/CHANGELOG.md new file mode 100644 index 00000000..a0f2b632 --- /dev/null +++ b/unilabos/devices/liquid_handling/laiyu/docs/CHANGELOG.md @@ -0,0 +1,69 @@ +# 更新日志 + +本文档记录了 LaiYu_Liquid 模块的所有重要变更。 + +## [1.0.0] - 2024-01-XX + +### 新增功能 +- ✅ 完整的液体处理工作站集成 +- ✅ RS485 通信协议支持 +- ✅ SOPA 气动式移液器驱动 +- ✅ XYZ 三轴步进电机控制 +- ✅ PyLabRobot 兼容后端 +- ✅ 标准化资源管理系统 +- ✅ 96孔板、离心管架、枪头架支持 +- ✅ RViz 可视化后端 +- ✅ 完整的配置管理系统 +- ✅ 抽象协议实现 +- ✅ 生产级错误处理和日志记录 + +### 技术特性 +- **硬件支持**: SOPA移液器 + XYZ三轴运动平台 +- **通信协议**: RS485总线,波特率115200 +- **坐标系统**: 机械坐标与工作坐标自动转换 +- **安全机制**: 限位保护、紧急停止、错误恢复 +- **兼容性**: 完全兼容 PyLabRobot 框架 + +### 文件结构 +``` +LaiYu_Liquid/ +├── core/ +│ └── LaiYu_Liquid.py # 主模块文件 +├── __init__.py # 模块初始化 +├── abstract_protocol.py # 抽象协议 +├── laiyu_liquid_res.py # 资源管理 +├── rviz_backend.py # RViz后端 +├── backend/ # 后端驱动 +├── config/ # 配置文件 +├── controllers/ # 控制器 +├── docs/ # 技术文档 +└── drivers/ # 底层驱动 +``` + +### 已知问题 +- 无 + +### 依赖要求 +- Python 3.8+ +- PyLabRobot +- pyserial +- asyncio + +--- + +## 版本说明 + +### 版本号格式 +采用语义化版本控制 (Semantic Versioning): `MAJOR.MINOR.PATCH` + +- **MAJOR**: 不兼容的API变更 +- **MINOR**: 向后兼容的功能新增 +- **PATCH**: 向后兼容的问题修复 + +### 变更类型 +- **新增功能**: 新的功能特性 +- **变更**: 现有功能的变更 +- **弃用**: 即将移除的功能 +- **移除**: 已移除的功能 +- **修复**: 问题修复 +- **安全**: 安全相关的修复 \ No newline at end of file diff --git a/unilabos/devices/liquid_handling/laiyu/docs/hardware/SOPA气动式移液器RS485控制指令.md b/unilabos/devices/liquid_handling/laiyu/docs/hardware/SOPA气动式移液器RS485控制指令.md new file mode 100644 index 00000000..6db19eb1 --- /dev/null +++ b/unilabos/devices/liquid_handling/laiyu/docs/hardware/SOPA气动式移液器RS485控制指令.md @@ -0,0 +1,267 @@ +# SOPA气动式移液器RS485控制指令合集 + +## 1. RS485通信基本配置 + +### 1.1 支持的设备型号 +- **仅SC-STxxx-00-13支持RS485通信** +- 其他型号主要使用CAN通信 + +### 1.2 通信参数 +- **波特率**: 9600, 115200(默认值) +- **地址范围**: 1~254个设备,255为广播地址 +- **通信接口**: RS485差分信号 + +### 1.3 引脚分配(10位LIF连接器) +- **引脚7**: RS485+ (RS485通信正极) +- **引脚8**: RS485- (RS485通信负极) + +## 2. RS485通信协议格式 + +### 2.1 发送数据格式 +``` +头码 | 地址 | 命令/数据 | 尾码 | 校验和 +``` + +### 2.2 从机回应格式 +``` +头码 | 地址 | 数据(固定9字节) | 尾码 | 校验和 +``` + +### 2.3 格式详细说明 +- **头码**: + - 终端调试: '/' (0x2F) + - OEM通信: '[' (0x5B) +- **地址**: 设备节点地址,1~254,多字节ASCII(注意:地址不可为47,69,91) +- **命令/数据**: ASCII格式的命令字符串 +- **尾码**: 'E' (0x45) +- **校验和**: 以上数据的累加值,1字节 + +## 3. 初始化和基本控制指令 + +### 3.1 初始化指令 +```bash +# 初始化活塞驱动机构 +HE + +# 示例(OEM通信): +# 主机发送: 5B 32 48 45 1A +# 从机回应开始: 2F 02 06 0A 30 00 00 00 00 00 00 45 B6 +# 从机回应完成: 2F 02 06 00 30 00 00 00 00 00 00 45 AC +``` + +### 3.2 枪头操作指令 +```bash +# 顶出枪头 +RE + +# 枪头检测状态报告 +Q28 # 返回枪头存在状态(0=不存在,1=存在) +``` + +## 4. 移液控制指令 + +### 4.1 位置控制指令 +```bash +# 绝对位置移动(微升) +A[n]E +# 示例:移动到位置0 +A0E + +# 相对抽吸(向上移动) +P[n]E +# 示例:抽吸200微升 +P200E + +# 相对分配(向下移动) +D[n]E +# 示例:分配200微升 +D200E +``` + +### 4.2 速度设置指令 +```bash +# 设置最高速度(0.1ul/秒为单位) +s[n]E +# 示例:设置最高速度为2000(200ul/秒) +s2000E + +# 设置启动速度 +b[n]E +# 示例:设置启动速度为100(10ul/秒) +b100E + +# 设置断流速度 +c[n]E +# 示例:设置断流速度为100(10ul/秒) +c100E + +# 设置加速度 +a[n]E +# 示例:设置加速度为30000 +a30000E +``` + +## 5. 液体检测和安全控制指令 + +### 5.1 吸排液检测控制 +```bash +# 开启吸排液检测 +f1E # 开启 +f0E # 关闭 + +# 设置空吸门限 +$[n]E +# 示例:设置空吸门限为4 +$4E + +# 设置泡沫门限 +![n]E +# 示例:设置泡沫门限为20 +!20E + +# 设置堵塞门限 +%[n]E +# 示例:设置堵塞门限为350 +%350E +``` + +### 5.2 液位检测指令 +```bash +# 压力式液位检测 +m0E # 设置为压力探测模式 +L[n]E # 执行液位检测,[n]为灵敏度(3~40) +k[n]E # 设置检测速度(100~2000) + +# 电容式液位检测 +m1E # 设置为电容探测模式 +``` + +## 6. 状态查询和报告指令 + +### 6.1 基本状态查询 +```bash +# 查询固件版本 +V + +# 查询设备状态 +Q[n] +# 常用查询参数: +Q01 # 报告加速度 +Q02 # 报告启动速度 +Q03 # 报告断流速度 +Q06 # 报告最大速度 +Q08 # 报告节点地址 +Q11 # 报告波特率 +Q18 # 报告当前位置 +Q28 # 报告枪头存在状态 +Q29 # 报告校准系数 +Q30 # 报告空吸门限 +Q31 # 报告堵针门限 +Q32 # 报告泡沫门限 +``` + +## 7. 配置和校准指令 + +### 7.1 校准参数设置 +```bash +# 设置校准系数 +j[n]E +# 示例:设置校准系数为1.04 +j1.04E + +# 设置补偿偏差 +e[n]E +# 示例:设置补偿偏差为2.03 +e2.03E + +# 设置吸头容量 +C[n]E +# 示例:设置1000ul吸头 +C1000E +``` + +### 7.2 高级控制参数 +```bash +# 设置回吸粘度 +][n]E +# 示例:设置回吸粘度为30 +]30E + +# 延时控制 +M[n]E +# 示例:延时1000毫秒 +M1000E +``` + +## 8. 复合操作指令示例 + +### 8.1 标准移液操作 +```bash +# 完整的200ul移液操作 +a30000b200c200s2000P200E +# 解析:设置加速度30000 + 启动速度200 + 断流速度200 + 最高速度2000 + 抽吸200ul + 执行 +``` + +### 8.2 带检测的移液操作 +```bash +# 带空吸检测的200ul抽吸 +a30000b200c200s2000f1P200f0E +# 解析:设置参数 + 开启检测 + 抽吸200ul + 关闭检测 + 执行 +``` + +### 8.3 液面检测操作 +```bash +# 压力式液面检测 +m0k200L5E +# 解析:压力模式 + 检测速度200 + 灵敏度5 + 执行检测 + +# 电容式液面检测 +m1L3E +# 解析:电容模式 + 灵敏度3 + 执行检测 +``` + +## 9. 错误处理 + +### 9.1 状态字节说明 +- **00h**: 无错误 +- **01h**: 上次动作未完成 +- **02h**: 设备未初始化 +- **03h**: 设备过载 +- **04h**: 无效指令 +- **05h**: 液位探测故障 +- **0Dh**: 空吸 +- **0Eh**: 堵针 +- **10h**: 泡沫 +- **11h**: 吸液超过吸头容量 + +### 9.2 错误查询 +```bash +# 查询当前错误状态 +Q # 返回状态字节和错误代码 +``` + +## 10. 通信示例 + +### 10.1 基本通信流程 +1. **执行命令**: 主机发送命令 → 从机确认 → 从机执行 → 从机回应完成 +2. **读取数据**: 主机发送查询 → 从机确认 → 从机返回数据 + +### 10.2 快速指令表 +| 操作 | 指令 | 说明 | +|------|------|------| +| 初始化 | `HE` | 初始化设备 | +| 退枪头 | `RE` | 顶出枪头 | +| 吸液200ul | `a30000b200c200s2000P200E` | 基本吸液 | +| 带检测吸液 | `a30000b200c200s2000f1P200f0E` | 开启空吸检测 | +| 吐液200ul | `a300000b500c500s6000D200E` | 基本分配 | +| 压力液面检测 | `m0k200L5E` | pLLD检测 | +| 电容液面检测 | `m1L3E` | cLLD检测 | + +## 11. 注意事项 + +1. **地址限制**: RS485地址不可设为47、69、91 +2. **校验和**: 终端调试时不关心校验和,OEM通信需要校验 +3. **ASCII格式**: 所有命令和参数都使用ASCII字符 +4. **执行指令**: 大部分命令需要以'E'结尾才能执行 +5. **设备支持**: 只有SC-STxxx-00-13型号支持RS485通信 +6. **波特率设置**: 默认115200,可设置为9600 \ No newline at end of file diff --git a/unilabos/devices/liquid_handling/laiyu/docs/hardware/步进电机控制指令.md b/unilabos/devices/liquid_handling/laiyu/docs/hardware/步进电机控制指令.md new file mode 100644 index 00000000..e7013484 --- /dev/null +++ b/unilabos/devices/liquid_handling/laiyu/docs/hardware/步进电机控制指令.md @@ -0,0 +1,162 @@ +# 步进电机B系列控制指令详解 + +## 基本通信参数 +- **通信方式**: RS485 +- **协议**: Modbus +- **波特率**: 115200 (默认) +- **数据位**: 8位 +- **停止位**: 1位 +- **校验位**: 无 +- **默认站号**: 1 (可设置1-254) + +## 支持的功能码 +- **03H**: 读取寄存器 +- **06H**: 写入单个寄存器 +- **10H**: 写入多个寄存器 + +## 寄存器地址表 + +### 状态监控寄存器 (只读) +| 地址 | 功能码 | 内容 | 说明 | +|------|--------|------|------| +| 00H | 03H | 电机状态 | 0000H-待机/到位, 0001H-运行中, 0002H-碰撞停, 0003H-正光电停, 0004H-反光电停 | +| 01H | 03H | 实际步数高位 | 当前电机位置的高16位 | +| 02H | 03H | 实际步数低位 | 当前电机位置的低16位 | +| 03H | 03H | 实际速度 | 当前转速 (rpm) | +| 05H | 03H | 电流 | 当前工作电流 (mA) | + +### 控制寄存器 (读写) +| 地址 | 功能码 | 内容 | 说明 | +|------|--------|------|------| +| 04H | 03H/06H/10H | 急停指令 | 紧急停止控制 | +| 06H | 03H/06H/10H | 失能控制 | 1-使能, 0-失能 | +| 07H | 03H/06H/10H | PWM输出 | 0-1000对应0%-100%占空比 | +| 0EH | 03H/06H/10H | 单圈绝对值归零 | 归零指令 | +| 0FH | 03H/06H/10H | 归零指令 | 定点模式归零速度设置 | + +### 位置模式寄存器 +| 地址 | 功能码 | 内容 | 说明 | +|------|--------|------|------| +| 10H | 03H/06H/10H | 目标步数高位 | 目标位置高16位 | +| 11H | 03H/06H/10H | 目标步数低位 | 目标位置低16位 | +| 12H | 03H/06H/10H | 保留 | - | +| 13H | 03H/06H/10H | 速度 | 运行速度 (rpm) | +| 14H | 03H/06H/10H | 加速度 | 0-60000 rpm/s | +| 15H | 03H/06H/10H | 精度 | 到位精度设置 | + +### 速度模式寄存器 +| 地址 | 功能码 | 内容 | 说明 | +|------|--------|------|------| +| 60H | 03H/06H/10H | 保留 | - | +| 61H | 03H/06H/10H | 速度 | 正值正转,负值反转 | +| 62H | 03H/06H/10H | 加速度 | 0-60000 rpm/s | + +### 设备参数寄存器 +| 地址 | 功能码 | 内容 | 默认值 | 说明 | +|------|--------|------|--------|------| +| E0H | 03H/06H/10H | 设备地址 | 0001H | Modbus从站地址 | +| E1H | 03H/06H/10H | 堵转电流 | 0BB8H | 堵转检测电流阈值 | +| E2H | 03H/06H/10H | 保留 | 0258H | - | +| E3H | 03H/06H/10H | 每圈步数 | 0640H | 细分设置 | +| E4H | 03H/06H/10H | 限位开关使能 | F000H | 1-使能, 0-禁用 | +| E5H | 03H/06H/10H | 堵转逻辑 | 0000H | 00-断电, 01-对抗 | +| E6H | 03H/06H/10H | 堵转时间 | 0000H | 堵转检测时间(ms) | +| E7H | 03H/06H/10H | 默认速度 | 1388H | 上电默认速度 | +| E8H | 03H/06H/10H | 默认加速度 | EA60H | 上电默认加速度 | +| E9H | 03H/06H/10H | 默认精度 | 0064H | 上电默认精度 | +| EAH | 03H/06H/10H | 波特率高位 | 0001H | 通信波特率设置 | +| EBH | 03H/06H/10H | 波特率低位 | C200H | 115200对应01C200H | + +### 版本信息寄存器 (只读) +| 地址 | 功能码 | 内容 | 说明 | +|------|--------|------|------| +| F0H | 03H | 版本号 | 固件版本信息 | +| F1H-F4H | 03H | 型号 | 产品型号信息 | + +## 常用控制指令示例 + +### 读取电机状态 +``` +发送: 01 03 00 00 00 01 84 0A +接收: 01 03 02 00 01 79 84 +说明: 电机状态为0001H (正在运行) +``` + +### 读取当前位置 +``` +发送: 01 03 00 01 00 02 95 CB +接收: 01 03 04 00 19 00 00 2B F4 +说明: 当前位置为1638400步 (100圈) +``` + +### 停止电机 +``` +发送: 01 10 00 04 00 01 02 00 00 A7 D4 +接收: 01 10 00 04 00 01 40 08 +说明: 急停指令 +``` + +### 位置模式运动 +``` +发送: 01 10 00 10 00 06 0C 00 19 00 00 00 00 13 88 00 00 00 00 9F FB +接收: 01 10 00 10 00 06 41 CE +说明: 以5000rpm速度运动到1638400步位置 +``` + +### 速度模式 - 正转 +``` +发送: 01 10 00 60 00 04 08 00 00 13 88 00 FA 00 00 F4 77 +接收: 01 10 00 60 00 04 C1 D4 +说明: 以5000rpm速度正转 +``` + +### 速度模式 - 反转 +``` +发送: 01 10 00 60 00 04 08 00 00 EC 78 00 FA 00 00 A0 6D +接收: 01 10 00 60 00 04 C1 D4 +说明: 以5000rpm速度反转 (EC78H = -5000) +``` + +### 设置设备地址 +``` +发送: 00 06 00 E0 00 02 C9 F1 +接收: 00 06 00 E0 00 02 C9 F1 +说明: 将设备地址设置为2 +``` + +## 错误码 +| 状态码 | 含义 | +|--------|------| +| 0001H | 功能码错误 | +| 0002H | 地址错误 | +| 0003H | 长度错误 | + +## CRC校验算法 +```c +public static byte[] ModBusCRC(byte[] data, int offset, int cnt) { + int wCrc = 0x0000FFFF; + byte[] CRC = new byte[2]; + for (int i = 0; i < cnt; i++) { + wCrc ^= ((data[i + offset]) & 0xFF); + for (int j = 0; j < 8; j++) { + if ((wCrc & 0x00000001) == 1) { + wCrc >>= 1; + wCrc ^= 0x0000A001; + } else { + wCrc >>= 1; + } + } + } + CRC[1] = (byte) ((wCrc & 0x0000FF00) >> 8); + CRC[0] = (byte) (wCrc & 0x000000FF); + return CRC; +} +``` + +## 注意事项 +1. 所有16位数据采用大端序传输 +2. 步数计算: 实际步数 = 高位<<16 | 低位 +3. 负数使用补码表示 +4. PWM输出K脚: 0%开漏, 100%接地, 其他输出1KHz PWM +5. 光电开关需使用NPN开漏型 +6. 限位开关: LF正向, LB反向 \ No newline at end of file diff --git a/unilabos/devices/liquid_handling/laiyu/docs/hardware/硬件连接配置指南.md b/unilabos/devices/liquid_handling/laiyu/docs/hardware/硬件连接配置指南.md new file mode 100644 index 00000000..64529097 --- /dev/null +++ b/unilabos/devices/liquid_handling/laiyu/docs/hardware/硬件连接配置指南.md @@ -0,0 +1,1281 @@ +# LaiYu液体处理设备硬件连接配置指南 + +## 📋 文档概述 + +本指南提供LaiYu液体处理设备的完整硬件连接配置方案,包括快速入门、详细配置、连接验证和故障排除。适用于设备初次安装、配置变更和问题诊断。 + +--- + +## 🚀 快速入门指南 + +### 基本配置步骤 + +1. **确认硬件连接** + - 将RS485转USB设备连接到计算机 + - 确保XYZ控制器和移液器通过RS485总线连接 + - 检查设备供电状态 + +2. **获取串口信息** + ```bash + # macOS/Linux + ls /dev/cu.* | grep usbserial + + # 常见输出: /dev/cu.usbserial-3130 + ``` + +3. **基本配置参数** + ```python + # 推荐的默认配置 + config = LaiYuLiquidConfig( + port="/dev/cu.usbserial-3130", # 🔧 替换为实际串口号 + address=4, # 移液器地址(固定) + baudrate=115200, # 推荐波特率 + timeout=5.0 # 通信超时 + ) + ``` + +4. **快速连接测试** + ```python + device = LaiYuLiquid(config) + success = await device.setup() + print(f"连接状态: {'成功' if success else '失败'}") + ``` + +--- + +## 🏗️ 硬件架构详解 + +### 系统组成 + +LaiYu液体处理设备采用RS485总线架构,包含以下核心组件: + +| 组件 | 通信协议 | 设备地址 | 默认波特率 | 功能描述 | +|------|----------|----------|------------|----------| +| **XYZ三轴控制器** | RS485 (Modbus) | X轴=1, Y轴=2, Z轴=3 | 115200 | 三维运动控制 | +| **SOPA移液器** | RS485 | 4 (推荐) | 115200 | 液体吸取分配 | +| **RS485转USB** | USB/串口 | - | 115200 | 通信接口转换 | + +### 地址分配策略 + +``` +RS485总线地址分配: +├── 地址 1: X轴步进电机 (自动分配) +├── 地址 2: Y轴步进电机 (自动分配) +├── 地址 3: Z轴步进电机 (自动分配) +├── 地址 4: SOPA移液器 (推荐配置) +└── 禁用地址: 47('/'), 69('E'), 91('[') +``` + +### 通信参数规范 + +| 参数 | XYZ控制器 | SOPA移液器 | 说明 | +|------|-----------|------------|------| +| **数据位** | 8 | 8 | 固定值 | +| **停止位** | 1 | 1 | 固定值 | +| **校验位** | 无 | 无 | 固定值 | +| **流控制** | 无 | 无 | 固定值 | + +--- + +## ⚙️ 配置参数详解 + +### 1. 核心配置类 + +#### LaiYuLiquidConfig 参数说明 + +```python +@dataclass +class LaiYuLiquidConfig: + # === 通信参数 === + port: str = "/dev/cu.usbserial-3130" # 串口设备路径 + address: int = 4 # 移液器地址(推荐值) + baudrate: int = 115200 # 通信波特率(推荐值) + timeout: float = 5.0 # 通信超时时间(秒) + + # === 工作台物理尺寸 === + deck_width: float = 340.0 # 工作台宽度 (mm) + deck_height: float = 250.0 # 工作台高度 (mm) + deck_depth: float = 160.0 # 工作台深度 (mm) + + # === 运动控制参数 === + max_speed: float = 100.0 # 最大移动速度 (mm/s) + acceleration: float = 50.0 # 加速度 (mm/s²) + safe_height: float = 50.0 # 安全移动高度 (mm) + + # === 移液参数 === + max_volume: float = 1000.0 # 最大移液体积 (μL) + min_volume: float = 0.1 # 最小移液体积 (μL) + liquid_detection: bool = True # 启用液面检测 + + # === 枪头操作参数 === + tip_pickup_speed: int = 30 # 取枪头速度 (rpm) + tip_pickup_acceleration: int = 500 # 取枪头加速度 (rpm/s) + tip_pickup_depth: float = 10.0 # 枪头插入深度 (mm) + tip_drop_height: float = 10.0 # 丢弃枪头高度 (mm) +``` + +### 2. 配置文件位置 + +#### A. 代码配置(推荐) +```python +# 在Python代码中直接配置 +from unilabos.devices.laiyu_liquid import LaiYuLiquidConfig + +config = LaiYuLiquidConfig( + port="/dev/cu.usbserial-3130", # 🔧 修改为实际串口 + address=4, # 🔧 移液器地址 + baudrate=115200, # 🔧 通信波特率 + timeout=5.0 # 🔧 超时时间 +) +``` + +#### B. JSON配置文件 +```json +{ + "laiyu_liquid_config": { + "port": "/dev/cu.usbserial-3130", + "address": 4, + "baudrate": 115200, + "timeout": 5.0, + "deck_width": 340.0, + "deck_height": 250.0, + "deck_depth": 160.0, + "max_speed": 100.0, + "acceleration": 50.0, + "safe_height": 50.0 + } +} +``` + +#### C. 实验协议配置 +```json +// test/experiments/laiyu_liquid.json +{ + "device_config": { + "type": "laiyu_liquid", + "config": { + "port": "/dev/cu.usbserial-3130", + "address": 4, + "baudrate": 115200 + } + } +} +``` + +### 2. 串口设备识别 + +#### 自动识别方法(推荐) + +```python +import serial.tools.list_ports + +def find_laiyu_device(): + """自动查找LaiYu设备串口""" + ports = serial.tools.list_ports.comports() + + for port in ports: + # 根据设备描述或VID/PID识别 + if 'usbserial' in port.device.lower(): + print(f"找到可能的设备: {port.device}") + print(f"描述: {port.description}") + print(f"硬件ID: {port.hwid}") + return port.device + + return None + +# 使用示例 +device_port = find_laiyu_device() +if device_port: + print(f"检测到设备端口: {device_port}") +else: + print("未检测到设备") +``` + +#### 手动识别方法 + +| 操作系统 | 命令 | 设备路径格式 | +|---------|------|-------------| +| **macOS** | `ls /dev/cu.*` | `/dev/cu.usbserial-XXXX` | +| **Linux** | `ls /dev/ttyUSB*` | `/dev/ttyUSB0` | +| **Windows** | 设备管理器 | `COM3`, `COM4` 等 | + +#### macOS 详细识别 +```bash +# 1. 列出所有USB串口设备 +ls /dev/cu.usbserial-* + +# 2. 查看USB设备详细信息 +system_profiler SPUSBDataType | grep -A 10 "Serial" + +# 3. 实时监控设备插拔 +ls /dev/cu.* && echo "--- 请插入设备 ---" && sleep 3 && ls /dev/cu.* +``` + +#### Linux 详细识别 +```bash +# 1. 列出串口设备 +ls /dev/ttyUSB* /dev/ttyACM* + +# 2. 查看设备信息 +dmesg | grep -i "usb.*serial" +lsusb | grep -i "serial\|converter" + +# 3. 查看设备属性 +udevadm info --name=/dev/ttyUSB0 --attribute-walk +``` + +#### Windows 详细识别 +```powershell +# PowerShell命令 +Get-WmiObject -Class Win32_SerialPort | Select-Object Name, DeviceID, Description + +# 或在设备管理器中查看"端口(COM和LPT)" +``` + +### 3. 控制器特定配置 + +#### XYZ步进电机控制器 +- **地址范围**: 1-3 (X轴=1, Y轴=2, Z轴=3) +- **通信协议**: Modbus RTU +- **波特率**: 9600 或 115200 +- **数据位**: 8 +- **停止位**: 1 +- **校验位**: None + +#### XYZ控制器配置 (`controllers/xyz_controller.py`) + +XYZ控制器负责三轴运动控制,提供精确的位置控制和运动规划功能。 + +**主要功能:** +- 三轴独立控制(X、Y、Z轴) +- 位置精度控制 +- 运动速度调节 +- 安全限位检测 + +**配置参数:** +```python +xyz_config = { + "port": "/dev/ttyUSB0", # 串口设备 + "baudrate": 115200, # 波特率 + "timeout": 1.0, # 通信超时 + "max_speed": { # 最大速度限制 + "x": 1000, # X轴最大速度 + "y": 1000, # Y轴最大速度 + "z": 500 # Z轴最大速度 + }, + "acceleration": 500, # 加速度 + "home_position": [0, 0, 0] # 原点位置 +} +``` + +```python +def __init__(self, port: str, baudrate: int = 115200, + machine_config: Optional[MachineConfig] = None, + config_file: str = "machine_config.json", + auto_connect: bool = True): + """ + Args: + port: 串口端口 (如: "/dev/cu.usbserial-3130") + baudrate: 波特率 (默认: 115200) + machine_config: 机械配置参数 + config_file: 配置文件路径 + auto_connect: 是否自动连接 + """ +``` + +#### SOPA移液器 +- **地址**: 通常为 4 或更高 +- **通信协议**: 自定义协议 +- **波特率**: 115200 (推荐) +- **响应时间**: < 100ms + +#### 移液器控制器配置 (`controllers/pipette_controller.py`) + +移液器控制器负责精确的液体吸取和分配操作,支持多种移液模式和参数配置。 + +**主要功能:** +- 精确体积控制 +- 液面检测 +- 枪头管理 +- 速度调节 + +**配置参数:** +```python +@dataclass +class SOPAConfig: + # 通信参数 + port: str = "/dev/ttyUSB0" # 🔧 修改串口号 + baudrate: int = 115200 # 🔧 修改波特率 + address: int = 1 # 🔧 修改设备地址 (1-254) + timeout: float = 5.0 # 🔧 修改超时时间 + comm_type: CommunicationType = CommunicationType.TERMINAL_DEBUG +``` + +## 🔍 连接验证与测试 + +### 1. 编程方式验证连接 + +#### 创建测试脚本 +```python +#!/usr/bin/env python3 +""" +LaiYu液体处理设备连接测试脚本 +""" + +import sys +import os +sys.path.append('/Users/dp/Documents/DPT/HuaiRou/Uni-Lab-OS') + +from unilabos.devices.laiyu_liquid.core.LaiYu_Liquid import ( + LaiYuLiquid, LaiYuLiquidConfig +) + +def test_connection(): + """测试设备连接""" + + # 🔧 修改这里的配置参数 + config = LaiYuLiquidConfig( + port="/dev/cu.usbserial-3130", # 修改为你的串口号 + address=1, # 修改为你的设备地址 + baudrate=9600, # 修改为你的波特率 + timeout=5.0 + ) + + print("🔌 正在测试LaiYu液体处理设备连接...") + print(f"串口: {config.port}") + print(f"波特率: {config.baudrate}") + print(f"设备地址: {config.address}") + print("-" * 50) + + try: + # 创建设备实例 + device = LaiYuLiquid(config) + + # 尝试连接和初始化 + print("📡 正在连接设备...") + success = await device.setup() + + if success: + print("✅ 设备连接成功!") + print(f"连接状态: {device.is_connected}") + print(f"初始化状态: {device.is_initialized}") + print(f"当前位置: {device.current_position}") + + # 获取设备状态 + status = device.get_status() + print("\n📊 设备状态:") + for key, value in status.items(): + print(f" {key}: {value}") + + else: + print("❌ 设备连接失败!") + print("请检查:") + print(" 1. 串口号是否正确") + print(" 2. 设备是否已连接并通电") + print(" 3. 波特率和设备地址是否匹配") + print(" 4. 串口是否被其他程序占用") + + except Exception as e: + print(f"❌ 连接测试出错: {e}") + print("\n🔧 故障排除建议:") + print(" 1. 检查串口设备是否存在:") + print(" macOS: ls /dev/cu.*") + print(" Linux: ls /dev/ttyUSB* /dev/ttyACM*") + print(" 2. 检查设备权限:") + print(" sudo chmod 666 /dev/cu.usbserial-*") + print(" 3. 检查设备是否被占用:") + print(" lsof | grep /dev/cu.usbserial") + + finally: + # 清理连接 + if 'device' in locals(): + await device.stop() + +if __name__ == "__main__": + import asyncio + asyncio.run(test_connection()) +``` + +### 2. 命令行验证工具 + +#### 串口通信测试 +```bash +# 安装串口调试工具 +pip install pyserial + +# 使用Python测试串口 +python -c " +import serial +try: + ser = serial.Serial('/dev/cu.usbserial-3130', 9600, timeout=1) + print('串口连接成功:', ser.is_open) + ser.close() +except Exception as e: + print('串口连接失败:', e) +" +``` + +#### 设备权限检查 +```bash +# macOS/Linux 检查串口权限 +ls -la /dev/cu.usbserial-* + +# 如果权限不足,修改权限 +sudo chmod 666 /dev/cu.usbserial-* + +# 检查串口是否被占用 +lsof | grep /dev/cu.usbserial +``` + +### 3. 连接状态指示器 + +设备提供多种方式检查连接状态: + +#### A. 属性检查 +```python +device = LaiYuLiquid(config) + +# 检查连接状态 +print(f"设备已连接: {device.is_connected}") +print(f"设备已初始化: {device.is_initialized}") +print(f"枪头已安装: {device.tip_attached}") +print(f"当前位置: {device.current_position}") +print(f"当前体积: {device.current_volume}") +``` + +#### B. 状态字典 +```python +status = device.get_status() +print("完整设备状态:", status) + +# 输出示例: +# { +# 'connected': True, +# 'initialized': True, +# 'position': (0.0, 0.0, 50.0), +# 'tip_attached': False, +# 'current_volume': 0.0, +# 'last_error': None +# } +``` + +## 🛠️ 故障排除指南 + +### 1. 连接问题诊断 + +#### 🔍 问题诊断流程 +```python +def diagnose_connection_issues(): + """连接问题诊断工具""" + import serial.tools.list_ports + import serial + + print("🔍 开始连接问题诊断...") + + # 1. 检查串口设备 + ports = list(serial.tools.list_ports.comports()) + if not ports: + print("❌ 未检测到任何串口设备") + print("💡 解决方案:") + print(" - 检查USB连接线") + print(" - 确认设备电源") + print(" - 安装设备驱动") + return + + print(f"✅ 检测到 {len(ports)} 个串口设备") + for port in ports: + print(f" 📍 {port.device}: {port.description}") + + # 2. 测试串口访问权限 + for port in ports: + try: + with serial.Serial(port.device, 9600, timeout=1): + print(f"✅ {port.device}: 访问权限正常") + except PermissionError: + print(f"❌ {port.device}: 权限不足") + print("💡 解决方案: sudo chmod 666 " + port.device) + except Exception as e: + print(f"⚠️ {port.device}: {e}") + +# 运行诊断 +diagnose_connection_issues() +``` + +#### 🚫 常见连接错误 + +| 错误类型 | 症状 | 解决方案 | +|---------|------|----------| +| **设备未找到** | `FileNotFoundError: No such file or directory` | 1. 检查USB连接
2. 确认设备驱动
3. 重新插拔设备 | +| **权限不足** | `PermissionError: Permission denied` | 1. `sudo chmod 666 /dev/ttyUSB0`
2. 添加用户到dialout组
3. 使用sudo运行 | +| **设备占用** | `SerialException: Device or resource busy` | 1. 关闭其他程序
2. `lsof /dev/ttyUSB0`查找占用
3. 重启系统 | +| **驱动问题** | 设备管理器显示未知设备 | 1. 安装CH340/CP210x驱动
2. 更新系统驱动
3. 使用原装USB线 | + +### 2. 通信问题解决 + +#### 📡 通信参数调试 +```python +def test_communication_parameters(): + """测试不同通信参数""" + import serial + + port = "/dev/cu.usbserial-3130" # 修改为实际端口 + baudrates = [9600, 19200, 38400, 57600, 115200] + + for baudrate in baudrates: + print(f"🔄 测试波特率: {baudrate}") + try: + with serial.Serial(port, baudrate, timeout=2) as ser: + # 发送测试命令 + test_cmd = b'\x01\x03\x00\x00\x00\x01\x84\x0A' + ser.write(test_cmd) + + response = ser.read(100) + if response: + print(f" ✅ 成功: 收到 {len(response)} 字节") + print(f" 📦 数据: {response.hex()}") + return baudrate + else: + print(f" ❌ 无响应") + except Exception as e: + print(f" ❌ 错误: {e}") + + return None +``` + +#### ⚡ 通信故障排除 + +| 问题类型 | 症状 | 诊断方法 | 解决方案 | +|---------|------|----------|----------| +| **通信超时** | `TimeoutError` | 检查波特率和设备地址 | 1. 调整超时时间
2. 验证波特率
3. 检查设备地址 | +| **数据校验错误** | `CRCError` | 检查数据完整性 | 1. 更换USB线
2. 降低波特率
3. 检查电磁干扰 | +| **协议错误** | 响应格式异常 | 验证命令格式 | 1. 检查协议版本
2. 确认设备类型
3. 更新固件 | +| **间歇性故障** | 时好时坏 | 监控连接稳定性 | 1. 检查连接线
2. 稳定电源
3. 减少干扰源 | + +### 3. 设备功能问题 + +#### 🎯 设备状态检查 +```python +def check_device_health(): + """设备健康状态检查""" + from unilabos.devices.laiyu_liquid import LaiYuLiquidConfig, LaiYuLiquidBackend + + config = LaiYuLiquidConfig( + port="/dev/cu.usbserial-3130", + address=4, + baudrate=115200, + timeout=5.0 + ) + + try: + backend = LaiYuLiquidBackend(config) + backend.connect() + + # 检查项目 + checks = { + "设备连接": lambda: backend.is_connected(), + "XYZ轴状态": lambda: backend.xyz_controller.get_all_positions(), + "移液器状态": lambda: backend.pipette_controller.get_status(), + "设备温度": lambda: backend.get_temperature(), + "错误状态": lambda: backend.get_error_status(), + } + + print("🏥 设备健康检查报告") + print("=" * 40) + + for check_name, check_func in checks.items(): + try: + result = check_func() + print(f"✅ {check_name}: 正常") + if result: + print(f" 📊 数据: {result}") + except Exception as e: + print(f"❌ {check_name}: 异常 - {e}") + + backend.disconnect() + + except Exception as e: + print(f"❌ 无法连接设备: {e}") +``` + +### 4. 高级故障排除 + +#### 🔧 日志分析工具 +```python +import logging + +def setup_debug_logging(): + """设置调试日志""" + logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler('laiyu_debug.log'), + logging.StreamHandler() + ] + ) + + # 启用串口通信日志 + serial_logger = logging.getLogger('serial') + serial_logger.setLevel(logging.DEBUG) + + print("🔍 调试日志已启用,日志文件: laiyu_debug.log") +``` + +#### 📊 性能监控 +```python +def monitor_performance(): + """性能监控工具""" + import time + import psutil + + print("📊 开始性能监控...") + + start_time = time.time() + start_cpu = psutil.cpu_percent() + start_memory = psutil.virtual_memory().percent + + # 执行设备操作 + # ... 你的设备操作代码 ... + + end_time = time.time() + end_cpu = psutil.cpu_percent() + end_memory = psutil.virtual_memory().percent + + print(f"⏱️ 执行时间: {end_time - start_time:.2f} 秒") + print(f"💻 CPU使用: {end_cpu - start_cpu:.1f}%") + print(f"🧠 内存使用: {end_memory - start_memory:.1f}%") +``` + +## 📝 配置文件模板 + +### 1. 基础配置模板 + +#### 标准配置(推荐) +```python +from unilabos.devices.laiyu_liquid import LaiYuLiquidConfig, LaiYuLiquidBackend, LaiYuLiquid + +# 创建标准配置 +config = LaiYuLiquidConfig( + # === 通信参数 === + port="/dev/cu.usbserial-3130", # 🔧 修改为实际串口 + address=4, # 移液器地址(推荐) + baudrate=115200, # 通信波特率(推荐) + timeout=5.0, # 通信超时时间 + + # === 工作台尺寸 === + deck_width=340.0, # 工作台宽度 (mm) + deck_height=250.0, # 工作台高度 (mm) + deck_depth=160.0, # 工作台深度 (mm) + + # === 运动控制参数 === + max_speed=100.0, # 最大移动速度 (mm/s) + acceleration=50.0, # 加速度 (mm/s²) + safe_height=50.0, # 安全移动高度 (mm) + + # === 移液参数 === + max_volume=1000.0, # 最大移液体积 (μL) + min_volume=0.1, # 最小移液体积 (μL) + liquid_detection=True, # 启用液面检测 + + # === 枪头操作参数 === + tip_pickup_speed=30, # 取枪头速度 (rpm) + tip_pickup_acceleration=500, # 取枪头加速度 (rpm/s) + tip_pickup_depth=10.0, # 枪头插入深度 (mm) + tip_drop_height=10.0, # 丢弃枪头高度 (mm) +) + +# 创建设备实例 +backend = LaiYuLiquidBackend(config) +device = LaiYuLiquid(backend) +``` + +### 2. 高级配置模板 + +#### 多设备配置 +```python +# 配置多个LaiYu设备 +configs = { + "device_1": LaiYuLiquidConfig( + port="/dev/cu.usbserial-3130", + address=4, + baudrate=115200, + deck_width=340.0, + deck_height=250.0, + deck_depth=160.0 + ), + "device_2": LaiYuLiquidConfig( + port="/dev/cu.usbserial-3131", + address=4, + baudrate=115200, + deck_width=340.0, + deck_height=250.0, + deck_depth=160.0 + ) +} + +# 创建设备实例 +devices = {} +for name, config in configs.items(): + backend = LaiYuLiquidBackend(config) + devices[name] = LaiYuLiquid(backend) +``` + +#### 自定义参数配置 +```python +# 高精度移液配置 +precision_config = LaiYuLiquidConfig( + port="/dev/cu.usbserial-3130", + address=4, + baudrate=115200, + timeout=10.0, # 增加超时时间 + + # 精密运动控制 + max_speed=50.0, # 降低速度提高精度 + acceleration=25.0, # 降低加速度 + safe_height=30.0, # 降低安全高度 + + # 精密移液参数 + max_volume=200.0, # 小体积移液 + min_volume=0.5, # 提高最小体积 + liquid_detection=True, + + # 精密枪头操作 + tip_pickup_speed=15, # 降低取枪头速度 + tip_pickup_acceleration=250, # 降低加速度 + tip_pickup_depth=8.0, # 减少插入深度 + tip_drop_height=5.0, # 降低丢弃高度 +) +``` + +### 3. 实验协议配置 + +#### JSON配置文件模板 +```json +{ + "experiment_name": "LaiYu液体处理实验", + "version": "1.0", + "devices": { + "laiyu_liquid": { + "type": "LaiYu_Liquid", + "config": { + "port": "/dev/cu.usbserial-3130", + "address": 4, + "baudrate": 115200, + "timeout": 5.0, + "deck_width": 340.0, + "deck_height": 250.0, + "deck_depth": 160.0, + "max_speed": 100.0, + "acceleration": 50.0, + "safe_height": 50.0, + "max_volume": 1000.0, + "min_volume": 0.1, + "liquid_detection": true + } + } + }, + "deck_layout": { + "tip_rack": { + "type": "tip_rack_96", + "position": [10, 10, 0], + "tips": "1000μL" + }, + "source_plate": { + "type": "plate_96", + "position": [100, 10, 0], + "contents": "样品" + }, + "dest_plate": { + "type": "plate_96", + "position": [200, 10, 0], + "contents": "目标" + } + } +} +``` + +### 4. 完整配置示例 +```json +{ + "laiyu_liquid_config": { + "communication": { + "xyz_controller": { + "port": "/dev/cu.usbserial-3130", + "baudrate": 115200, + "timeout": 5.0 + }, + "pipette_controller": { + "port": "/dev/cu.usbserial-3131", + "baudrate": 115200, + "address": 4, + "timeout": 5.0 + } + }, + "mechanical": { + "deck_width": 340.0, + "deck_height": 250.0, + "deck_depth": 160.0, + "safe_height": 50.0 + }, + "motion": { + "max_speed": 100.0, + "acceleration": 50.0, + "tip_pickup_speed": 30, + "tip_pickup_acceleration": 500 + }, + "safety": { + "position_validation": true, + "emergency_stop_enabled": true, + "deck_width": 300.0, + "deck_height": 200.0, + "deck_depth": 100.0, + "safe_height": 50.0 + } + } +} +``` + +### 5. 完整使用示例 + +#### 基础移液操作 +```python +async def basic_pipetting_example(): + """基础移液操作示例""" + + # 1. 设备初始化 + config = LaiYuLiquidConfig( + port="/dev/cu.usbserial-3130", + address=4, + baudrate=115200 + ) + + backend = LaiYuLiquidBackend(config) + device = LaiYuLiquid(backend) + + try: + # 2. 设备设置 + await device.setup() + print("✅ 设备初始化完成") + + # 3. 回到原点 + await device.home_all_axes() + print("✅ 轴归零完成") + + # 4. 取枪头 + tip_position = (50, 50, 10) # 枪头架位置 + await device.pick_up_tip(tip_position) + print("✅ 取枪头完成") + + # 5. 移液操作 + source_pos = (100, 100, 15) # 源位置 + dest_pos = (200, 200, 15) # 目标位置 + volume = 100.0 # 移液体积 (μL) + + await device.aspirate(volume, source_pos) + print(f"✅ 吸取 {volume}μL 完成") + + await device.dispense(volume, dest_pos) + print(f"✅ 分配 {volume}μL 完成") + + # 6. 丢弃枪头 + trash_position = (300, 300, 20) + await device.drop_tip(trash_position) + print("✅ 丢弃枪头完成") + + except Exception as e: + print(f"❌ 操作失败: {e}") + + finally: + # 7. 清理资源 + await device.cleanup() + print("✅ 设备清理完成") + +# 运行示例 +import asyncio +asyncio.run(basic_pipetting_example()) +``` + +#### 批量处理示例 +```python +async def batch_processing_example(): + """批量处理示例""" + + config = LaiYuLiquidConfig( + port="/dev/cu.usbserial-3130", + address=4, + baudrate=115200 + ) + + backend = LaiYuLiquidBackend(config) + device = LaiYuLiquid(backend) + + try: + await device.setup() + await device.home_all_axes() + + # 定义位置 + tip_rack = [(50 + i*9, 50, 10) for i in range(12)] # 12个枪头位置 + source_wells = [(100 + i*9, 100, 15) for i in range(12)] # 12个源孔 + dest_wells = [(200 + i*9, 200, 15) for i in range(12)] # 12个目标孔 + + # 批量移液 + for i in range(12): + print(f"🔄 处理第 {i+1} 个样品...") + + # 取枪头 + await device.pick_up_tip(tip_rack[i]) + + # 移液 + await device.aspirate(50.0, source_wells[i]) + await device.dispense(50.0, dest_wells[i]) + + # 丢弃枪头 + await device.drop_tip((300, 300, 20)) + + print(f"✅ 第 {i+1} 个样品处理完成") + + print("🎉 批量处理完成!") + + except Exception as e: + print(f"❌ 批量处理失败: {e}") + + finally: + await device.cleanup() + +# 运行批量处理 +asyncio.run(batch_processing_example()) +``` + +## 🔧 调试与日志管理 + +### 1. 调试模式配置 + +#### 启用全局调试 +```python +import logging +from unilabos.devices.laiyu_liquid import LaiYuLiquidConfig, LaiYuLiquidBackend + +# 配置全局日志 +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler('laiyu_debug.log'), + logging.StreamHandler() + ] +) + +# 创建调试配置 +debug_config = LaiYuLiquidConfig( + port="/dev/cu.usbserial-3130", + address=4, + baudrate=115200, + timeout=10.0, # 增加超时时间便于调试 + debug_mode=True # 启用调试模式 +) +``` + +#### 分级日志配置 +```python +def setup_logging(log_level="INFO"): + """设置分级日志""" + + # 日志级别映射 + levels = { + "DEBUG": logging.DEBUG, + "INFO": logging.INFO, + "WARNING": logging.WARNING, + "ERROR": logging.ERROR + } + + # 创建日志记录器 + logger = logging.getLogger('LaiYu_Liquid') + logger.setLevel(levels.get(log_level, logging.INFO)) + + # 文件处理器 + file_handler = logging.FileHandler('laiyu_operations.log') + file_formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s' + ) + file_handler.setFormatter(file_formatter) + + # 控制台处理器 + console_handler = logging.StreamHandler() + console_formatter = logging.Formatter('%(levelname)s - %(message)s') + console_handler.setFormatter(console_formatter) + + logger.addHandler(file_handler) + logger.addHandler(console_handler) + + return logger + +# 使用示例 +logger = setup_logging("DEBUG") +logger.info("开始LaiYu设备操作") +``` + +### 2. 通信监控 + +#### 串口通信日志 +```python +def enable_serial_logging(): + """启用串口通信日志""" + import serial + + # 创建串口日志记录器 + serial_logger = logging.getLogger('serial.communication') + serial_logger.setLevel(logging.DEBUG) + + # 创建专用的串口日志文件 + serial_handler = logging.FileHandler('laiyu_serial.log') + serial_formatter = logging.Formatter( + '%(asctime)s - SERIAL - %(message)s' + ) + serial_handler.setFormatter(serial_formatter) + serial_logger.addHandler(serial_handler) + + print("📡 串口通信日志已启用: laiyu_serial.log") + return serial_logger +``` + +#### 实时通信监控 +```python +class CommunicationMonitor: + """通信监控器""" + + def __init__(self): + self.sent_count = 0 + self.received_count = 0 + self.error_count = 0 + self.start_time = time.time() + + def log_sent(self, data): + """记录发送数据""" + self.sent_count += 1 + logging.debug(f"📤 发送 #{self.sent_count}: {data.hex()}") + + def log_received(self, data): + """记录接收数据""" + self.received_count += 1 + logging.debug(f"📥 接收 #{self.received_count}: {data.hex()}") + + def log_error(self, error): + """记录错误""" + self.error_count += 1 + logging.error(f"❌ 通信错误 #{self.error_count}: {error}") + + def get_statistics(self): + """获取统计信息""" + duration = time.time() - self.start_time + return { + "运行时间": f"{duration:.2f}秒", + "发送次数": self.sent_count, + "接收次数": self.received_count, + "错误次数": self.error_count, + "成功率": f"{((self.sent_count - self.error_count) / max(self.sent_count, 1) * 100):.1f}%" + } +``` + +### 3. 性能监控 + +#### 操作性能分析 +```python +import time +import functools + +def performance_monitor(operation_name): + """性能监控装饰器""" + def decorator(func): + @functools.wraps(func) + async def wrapper(*args, **kwargs): + start_time = time.time() + start_memory = psutil.Process().memory_info().rss / 1024 / 1024 # MB + + try: + result = await func(*args, **kwargs) + + end_time = time.time() + end_memory = psutil.Process().memory_info().rss / 1024 / 1024 # MB + + duration = end_time - start_time + memory_delta = end_memory - start_memory + + logging.info(f"⏱️ {operation_name}: {duration:.3f}s, 内存变化: {memory_delta:+.1f}MB") + + return result + + except Exception as e: + end_time = time.time() + duration = end_time - start_time + logging.error(f"❌ {operation_name} 失败 ({duration:.3f}s): {e}") + raise + + return wrapper + return decorator + +# 使用示例 +@performance_monitor("移液操作") +async def monitored_pipetting(): + await device.aspirate(100.0, (100, 100, 15)) + await device.dispense(100.0, (200, 200, 15)) +``` + +#### 系统资源监控 +```python +import psutil +import threading +import time + +class SystemMonitor: + """系统资源监控器""" + + def __init__(self, interval=1.0): + self.interval = interval + self.monitoring = False + self.data = [] + + def start_monitoring(self): + """开始监控""" + self.monitoring = True + self.monitor_thread = threading.Thread(target=self._monitor_loop) + self.monitor_thread.daemon = True + self.monitor_thread.start() + print("📊 系统监控已启动") + + def stop_monitoring(self): + """停止监控""" + self.monitoring = False + if hasattr(self, 'monitor_thread'): + self.monitor_thread.join() + print("📊 系统监控已停止") + + def _monitor_loop(self): + """监控循环""" + while self.monitoring: + cpu_percent = psutil.cpu_percent() + memory = psutil.virtual_memory() + + self.data.append({ + 'timestamp': time.time(), + 'cpu_percent': cpu_percent, + 'memory_percent': memory.percent, + 'memory_used_mb': memory.used / 1024 / 1024 + }) + + time.sleep(self.interval) + + def get_report(self): + """生成监控报告""" + if not self.data: + return "无监控数据" + + avg_cpu = sum(d['cpu_percent'] for d in self.data) / len(self.data) + avg_memory = sum(d['memory_percent'] for d in self.data) / len(self.data) + max_memory = max(d['memory_used_mb'] for d in self.data) + + return f""" +📊 系统资源监控报告 +================== +监控时长: {len(self.data) * self.interval:.1f}秒 +平均CPU使用率: {avg_cpu:.1f}% +平均内存使用率: {avg_memory:.1f}% +峰值内存使用: {max_memory:.1f}MB + """ + +# 使用示例 +monitor = SystemMonitor() +monitor.start_monitoring() + +# 执行设备操作 +# ... 你的代码 ... + +monitor.stop_monitoring() +print(monitor.get_report()) +``` + +### 4. 错误追踪 + +#### 异常处理和记录 +```python +import traceback + +class ErrorTracker: + """错误追踪器""" + + def __init__(self): + self.errors = [] + + def log_error(self, operation, error, context=None): + """记录错误""" + error_info = { + 'timestamp': time.time(), + 'operation': operation, + 'error_type': type(error).__name__, + 'error_message': str(error), + 'traceback': traceback.format_exc(), + 'context': context or {} + } + + self.errors.append(error_info) + + # 记录到日志 + logging.error(f"❌ {operation} 失败: {error}") + logging.debug(f"错误详情: {error_info}") + + def get_error_summary(self): + """获取错误摘要""" + if not self.errors: + return "✅ 无错误记录" + + error_types = {} + for error in self.errors: + error_type = error['error_type'] + error_types[error_type] = error_types.get(error_type, 0) + 1 + + summary = f"❌ 共记录 {len(self.errors)} 个错误:\n" + for error_type, count in error_types.items(): + summary += f" - {error_type}: {count} 次\n" + + return summary + +# 全局错误追踪器 +error_tracker = ErrorTracker() + +# 使用示例 +try: + await device.move_to(x=1000, y=1000, z=100) # 可能超出范围 +except Exception as e: + error_tracker.log_error("移动操作", e, {"target": (1000, 1000, 100)}) +``` + +--- + +## 📚 总结 + +本文档提供了LaiYu液体处理设备的完整硬件连接配置指南,涵盖了从基础设置到高级故障排除的所有方面。 + +### 🎯 关键要点 + +1. **标准配置**: 使用 `port="/dev/cu.usbserial-3130"`, `address=4`, `baudrate=115200` +2. **设备架构**: XYZ轴控制器(地址1-3) + SOPA移液器(地址4) +3. **连接验证**: 使用提供的测试脚本验证硬件连接 +4. **故障排除**: 参考故障排除指南解决常见问题 +5. **性能监控**: 启用日志和监控确保稳定运行 + +### 🔗 相关文档 + +- [LaiYu控制架构详解](./UniLab_LaiYu_控制架构详解.md) +- [XYZ集成功能说明](./XYZ_集成功能说明.md) +- [设备API文档](./readme.md) + +### 📞 技术支持 + +如遇到问题,请: +1. 检查硬件连接和配置 +2. 查看调试日志 +3. 参考故障排除指南 +4. 联系技术支持团队 + +--- + +*最后更新: 2024年1月* \ No newline at end of file diff --git a/unilabos/devices/liquid_handling/laiyu/docs/readme.md b/unilabos/devices/liquid_handling/laiyu/docs/readme.md new file mode 100644 index 00000000..b81ba93a --- /dev/null +++ b/unilabos/devices/liquid_handling/laiyu/docs/readme.md @@ -0,0 +1,285 @@ +# LaiYu_Liquid 液体处理工作站 - 生产就绪版本 + +## 概述 + +LaiYu_Liquid 是一个完全集成到 UniLabOS 系统的自动化液体处理工作站,基于 RS485 通信协议,专为精确的液体分配和转移操作而设计。本模块已完成生产环境部署准备,提供完整的硬件控制、资源管理和标准化接口。 + +## 系统组成 + +### 硬件组件 +- **XYZ三轴运动平台**: 3个RS485步进电机驱动(地址:X轴=0x01, Y轴=0x02, Z轴=0x03) +- **SOPA气动式移液器**: RS485总线控制,支持精密液体处理操作 +- **通信接口**: RS485转USB模块,默认波特率115200 +- **机械结构**: 稳固工作台面,支持离心管架、96孔板等标准实验耗材 + +### 软件架构 +- **驱动层**: 底层硬件通信驱动,支持RS485协议 +- **控制层**: 高级控制逻辑和坐标系管理 +- **抽象层**: 完全符合UniLabOS标准的液体处理接口 +- **资源层**: 标准化的实验器具和耗材管理 + +## 🎯 生产就绪组件 + +### ✅ 核心驱动程序 (`drivers/`) +- **`sopa_pipette_driver.py`** - SOPA移液器完整驱动 + - 支持液体吸取、分配、检测 + - 完整的错误处理和状态管理 + - 生产级别的通信协议实现 + +- **`xyz_stepper_driver.py`** - XYZ三轴步进电机驱动 + - 精确的位置控制和运动规划 + - 安全限位和错误检测 + - 高性能运动控制算法 + +### ✅ 高级控制器 (`controllers/`) +- **`pipette_controller.py`** - 移液控制器 + - 封装高级液体处理功能 + - 支持多种液体类型和处理参数 + - 智能错误恢复机制 + +- **`xyz_controller.py`** - XYZ运动控制器 + - 坐标系管理和转换 + - 运动路径优化 + - 安全运动控制 + +### ✅ UniLabOS集成 (`core/LaiYu_Liquid.py`) +- **完整的液体处理抽象接口** +- **标准化的资源管理系统** +- **与PyLabRobot兼容的后端实现** +- **生产级别的错误处理和日志记录** + +### ✅ 资源管理系统 +- **`laiyu_liquid_res.py`** - 标准化资源定义 + - 96孔板、离心管架、枪头架等标准器具 + - 自动化的资源创建和配置函数 + - 与工作台布局的完美集成 + +### ✅ 配置管理 (`config/`) +- **`config/deck.json`** - 工作台布局配置 + - 精确的空间定义和槽位管理 + - 支持多种实验器具的标准化放置 + - 可扩展的配置架构 + +- **`__init__.py`** - 模块集成和导出 + - 完整的API导出和版本管理 + - 依赖检查和安装验证 + - 专业的模块信息展示 + +### ✅ 可视化支持 +- **`rviz_backend.py`** - RViz可视化后端 + - 实时运动状态可视化 + - 液体处理过程监控 + - 与ROS系统的无缝集成 + +## 🚀 核心功能特性 + +### 液体处理能力 +- **精密体积控制**: 支持1-1000μL精确分配 +- **多种液体类型**: 水性、有机溶剂、粘稠液体等 +- **智能检测**: 液位检测、气泡检测、堵塞检测 +- **自动化流程**: 完整的吸取-转移-分配工作流 + +### 运动控制系统 +- **三轴精密定位**: 微米级精度控制 +- **路径优化**: 智能运动规划和碰撞避免 +- **安全机制**: 限位保护、紧急停止、错误恢复 +- **坐标系管理**: 工作坐标与机械坐标的自动转换 + +### 资源管理 +- **标准化器具**: 支持96孔板、离心管架、枪头架等 +- **状态跟踪**: 实时监控液体体积、枪头状态等 +- **自动配置**: 基于JSON的灵活配置系统 +- **扩展性**: 易于添加新的器具类型 + +## 📁 目录结构 + +``` +LaiYu_Liquid/ +├── __init__.py # 模块初始化和API导出 +├── readme.md # 本文档 +├── rviz_backend.py # RViz可视化后端 +├── backend/ # 后端驱动模块 +│ ├── __init__.py +│ └── laiyu_backend.py # PyLabRobot兼容后端 +├── core/ # 核心模块 +│ ├── core/ +│ │ └── LaiYu_Liquid.py # 主设备类 +│ ├── abstract_protocol.py # 抽象协议 +│ └── laiyu_liquid_res.py # 设备资源定义 +├── config/ # 配置文件目录 +│ └── deck.json # 工作台布局配置 +├── controllers/ # 高级控制器 +│ ├── __init__.py +│ ├── pipette_controller.py # 移液控制器 +│ └── xyz_controller.py # XYZ运动控制器 +├── docs/ # 技术文档 +│ ├── SOPA气动式移液器RS485控制指令.md +│ ├── 步进电机控制指令.md +│ └── hardware/ # 硬件相关文档 +├── drivers/ # 底层驱动程序 +│ ├── __init__.py +│ ├── sopa_pipette_driver.py # SOPA移液器驱动 +│ └── xyz_stepper_driver.py # XYZ步进电机驱动 +└── tests/ # 测试文件 +``` + +## 🔧 快速开始 + +### 1. 安装和验证 + +```python +# 验证模块安装 +from unilabos.devices.laiyu_liquid import ( + LaiYuLiquid, + LaiYuLiquidConfig, + create_quick_setup, + print_module_info +) + +# 查看模块信息 +print_module_info() + +# 快速创建默认资源 +resources = create_quick_setup() +print(f"已创建 {len(resources)} 个资源") +``` + +### 2. 基本使用示例 + +```python +from unilabos.devices.LaiYu_Liquid import ( + create_quick_setup, + create_96_well_plate, + create_laiyu_backend +) + +# 快速创建默认资源 +resources = create_quick_setup() +print(f"创建了以下资源: {list(resources.keys())}") + +# 创建96孔板 +plate_96 = create_96_well_plate("test_plate") +print(f"96孔板包含 {len(plate_96.children)} 个孔位") + +# 创建后端实例(用于PyLabRobot集成) +backend = create_laiyu_backend("LaiYu_Device") +print(f"后端设备: {backend.name}") +``` + +### 3. 后端驱动使用 + +```python +from unilabos.devices.laiyu_liquid.backend import create_laiyu_backend + +# 创建后端实例 +backend = create_laiyu_backend("LaiYu_Liquid_Station") + +# 连接设备 +await backend.connect() + +# 设备归位 +await backend.home_device() + +# 获取设备状态 +status = await backend.get_status() +print(f"设备状态: {status}") + +# 断开连接 +await backend.disconnect() +``` + +### 4. 资源管理示例 + +```python +from unilabos.devices.LaiYu_Liquid import ( + create_centrifuge_tube_rack, + create_tip_rack, + load_deck_config +) + +# 加载工作台配置 +deck_config = load_deck_config() +print(f"工作台尺寸: {deck_config['size_x']}x{deck_config['size_y']}mm") + +# 创建不同类型的资源 +tube_rack = create_centrifuge_tube_rack("sample_rack") +tip_rack = create_tip_rack("tip_rack_200ul") + +print(f"离心管架: {tube_rack.name}, 容量: {len(tube_rack.children)} 个位置") +print(f"枪头架: {tip_rack.name}, 容量: {len(tip_rack.children)} 个枪头") +``` + +## 🔍 技术架构 + +### 坐标系统 +- **机械坐标**: 基于步进电机的原始坐标系统 +- **工作坐标**: 用户友好的实验室坐标系统 +- **自动转换**: 透明的坐标系转换和校准 + +### 通信协议 +- **RS485总线**: 高可靠性工业通信标准 +- **Modbus协议**: 标准化的设备通信协议 +- **错误检测**: 完整的通信错误检测和恢复 + +### 安全机制 +- **限位保护**: 硬件和软件双重限位保护 +- **紧急停止**: 即时停止所有运动和操作 +- **状态监控**: 实时设备状态监控和报警 + +## 🧪 验证和测试 + +### 功能验证 +```python +# 验证模块安装 +from unilabos.devices.laiyu_liquid import validate_installation +validate_installation() + +# 查看模块信息 +from unilabos.devices.laiyu_liquid import print_module_info +print_module_info() +``` + +### 硬件连接测试 +```python +# 测试SOPA移液器连接 +from unilabos.devices.laiyu_liquid.drivers import SOPAPipette, SOPAConfig + +config = SOPAConfig(port="/dev/cu.usbserial-3130", address=4) +pipette = SOPAPipette(config) +success = pipette.connect() +print(f"SOPA连接状态: {'成功' if success else '失败'}") +``` + +## 📚 维护和支持 + +### 日志记录 +- **结构化日志**: 使用Python logging模块的专业日志记录 +- **错误追踪**: 详细的错误信息和堆栈跟踪 +- **性能监控**: 操作时间和性能指标记录 + +### 配置管理 +- **JSON配置**: 灵活的JSON格式配置文件 +- **参数验证**: 自动配置参数验证和错误提示 +- **热重载**: 支持配置文件的动态重载 + +### 扩展性 +- **模块化设计**: 易于扩展和定制的模块化架构 +- **插件接口**: 支持第三方插件和扩展 +- **API兼容**: 向后兼容的API设计 + +## 📞 技术支持 + +### 常见问题 +1. **串口权限问题**: 确保用户有串口访问权限 +2. **依赖库安装**: 使用pip安装所需的Python库 +3. **设备连接**: 检查RS485适配器和设备地址配置 + +### 联系方式 +- **技术文档**: 查看UniLabOS官方文档 +- **问题反馈**: 通过GitHub Issues提交问题 +- **社区支持**: 加入UniLabOS开发者社区 + +--- + +**LaiYu_Liquid v1.0.0** - 生产就绪的液体处理工作站集成模块 +© 2024 UniLabOS Project. All rights reserved. \ No newline at end of file diff --git a/unilabos/devices/liquid_handling/laiyu/drivers/__init__.py b/unilabos/devices/liquid_handling/laiyu/drivers/__init__.py new file mode 100644 index 00000000..cedd47a0 --- /dev/null +++ b/unilabos/devices/liquid_handling/laiyu/drivers/__init__.py @@ -0,0 +1,30 @@ +""" +LaiYu_Liquid 驱动程序模块 + +该模块包含了LaiYu_Liquid液体处理工作站的硬件驱动程序: +- SOPA移液器驱动程序 +- XYZ步进电机驱动程序 +""" + +# SOPA移液器驱动程序导入 +from .sopa_pipette_driver import SOPAPipette, SOPAConfig, SOPAStatusCode + +# XYZ步进电机驱动程序导入 +from .xyz_stepper_driver import StepperMotorDriver, XYZStepperController, MotorAxis, MotorStatus + +__all__ = [ + # SOPA移液器 + "SOPAPipette", + "SOPAConfig", + "SOPAStatusCode", + + # XYZ步进电机 + "StepperMotorDriver", + "XYZStepperController", + "MotorAxis", + "MotorStatus", +] + +__version__ = "1.0.0" +__author__ = "LaiYu_Liquid Driver Team" +__description__ = "LaiYu_Liquid 硬件驱动程序集合" \ No newline at end of file diff --git a/unilabos/devices/liquid_handling/laiyu/drivers/sopa_pipette_driver.py b/unilabos/devices/liquid_handling/laiyu/drivers/sopa_pipette_driver.py new file mode 100644 index 00000000..0e71bc71 --- /dev/null +++ b/unilabos/devices/liquid_handling/laiyu/drivers/sopa_pipette_driver.py @@ -0,0 +1,1085 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +SOPA气动式移液器RS485控制驱动程序 + +基于SOPA气动式移液器RS485控制指令合集编写的Python驱动程序, +支持完整的移液器控制功能,包括移液、检测、配置等操作。 + +仅支持SC-STxxx-00-13型号的RS485通信。 +""" + +import serial +import time +import logging +import threading +from typing import Optional, Union, Dict, Any, Tuple, List +from enum import Enum, IntEnum +from dataclasses import dataclass +from contextlib import contextmanager + +# 配置日志 +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class SOPAError(Exception): + """SOPA移液器异常基类""" + pass + + +class SOPACommunicationError(SOPAError): + """通信异常""" + pass + + +class SOPADeviceError(SOPAError): + """设备异常""" + pass + + +class SOPAStatusCode(IntEnum): + """状态码枚举""" + NO_ERROR = 0x00 # 无错误 + ACTION_INCOMPLETE = 0x01 # 上次动作未完成 + NOT_INITIALIZED = 0x02 # 设备未初始化 + DEVICE_OVERLOAD = 0x03 # 设备过载 + INVALID_COMMAND = 0x04 # 无效指令 + LLD_FAULT = 0x05 # 液位探测故障 + AIR_ASPIRATE = 0x0D # 空吸 + NEEDLE_BLOCK = 0x0E # 堵针 + FOAM_DETECT = 0x10 # 泡沫 + EXCEED_TIP_VOLUME = 0x11 # 吸液超过吸头容量 + + +class CommunicationType(Enum): + """通信类型""" + TERMINAL_DEBUG = "/" # 终端调试,头码为0x2F + OEM_COMMUNICATION = "[" # OEM通信,头码为0x5B + + +class DetectionMode(IntEnum): + """液位检测模式""" + PRESSURE = 0 # 压力式检测(pLLD) + CAPACITIVE = 1 # 电容式检测(cLLD) + + +@dataclass +class SOPAConfig: + """SOPA移液器配置参数""" + # 通信参数 + port: str = "/dev/ttyUSB0" + baudrate: int = 115200 + address: int = 1 + timeout: float = 5.0 + comm_type: CommunicationType = CommunicationType.TERMINAL_DEBUG + + # 运动参数 (单位: 0.1ul/秒) + max_speed: int = 2000 # 最高速度 200ul/秒 + start_speed: int = 200 # 启动速度 20ul/秒 + cutoff_speed: int = 200 # 断流速度 20ul/秒 + acceleration: int = 30000 # 加速度 + + # 检测参数 + empty_threshold: int = 4 # 空吸门限 + foam_threshold: int = 20 # 泡沫门限 + block_threshold: int = 350 # 堵塞门限 + + # 液位检测参数 + lld_speed: int = 200 # 检测速度 (100~2000) + lld_sensitivity: int = 5 # 检测灵敏度 (3~40) + detection_mode: DetectionMode = DetectionMode.PRESSURE + + # 吸头参数 + tip_volume: int = 1000 # 吸头容量 (ul) + calibration_factor: float = 1.0 # 校准系数 + compensation_offset: float = 0.0 # 补偿偏差 + + def __post_init__(self): + """初始化后验证参数""" + self._validate_address() + + def _validate_address(self): + """ + 验证设备地址是否符合协议要求 + + 协议要求: + - 地址范围:1~254 + - 禁用地址:47, 69, 91 (对应ASCII字符 '/', 'E', '[') + """ + if not (1 <= self.address <= 254): + raise ValueError(f"设备地址必须在1-254范围内,当前地址: {self.address}") + + forbidden_addresses = [47, 69, 91] # '/', 'E', '[' + if self.address in forbidden_addresses: + forbidden_chars = {47: "'/' (0x2F)", 69: "'E' (0x45)", 91: "'[' (0x5B)"} + char_desc = forbidden_chars[self.address] + raise ValueError( + f"地址 {self.address} 不可用,因为它对应协议字符 {char_desc}。" + f"请选择其他地址(1-254,排除47、69、91)" + ) + + +class SOPAPipette: + """SOPA气动式移液器驱动类""" + + def __init__(self, config: SOPAConfig): + """ + 初始化SOPA移液器 + + Args: + config: 移液器配置参数 + """ + self.config = config + self.serial_port: Optional[serial.Serial] = None + self.is_connected = False + self.is_initialized = False + self.lock = threading.Lock() + + # 状态缓存 + self._last_status = SOPAStatusCode.NOT_INITIALIZED + self._current_position = 0 + self._tip_present = False + + def connect(self) -> bool: + """ + 连接移液器 + + Returns: + bool: 连接是否成功 + """ + try: + self.serial_port = serial.Serial( + port=self.config.port, + baudrate=self.config.baudrate, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + timeout=self.config.timeout + ) + + if self.serial_port.is_open: + self.is_connected = True + logger.info(f"已连接到SOPA移液器,端口: {self.config.port}, 地址: {self.config.address}") + + # 查询设备信息 + version = self.get_firmware_version() + if version: + logger.info(f"固件版本: {version}") + + return True + else: + raise SOPACommunicationError("串口打开失败") + + except Exception as e: + logger.error(f"连接失败: {str(e)}") + self.is_connected = False + return False + + def disconnect(self): + """断开连接""" + if self.serial_port and self.serial_port.is_open: + self.serial_port.close() + self.is_connected = False + self.is_initialized = False + logger.info("已断开SOPA移液器连接") + + def _calculate_checksum(self, data: bytes) -> int: + """计算校验和""" + return sum(data) & 0xFF + + def _build_command(self, command: str) -> bytes: + """ + 构建完整命令字节串 + + 根据协议格式:头码 + 地址 + 命令/数据 + 尾码 + 校验和 + + Args: + command: 命令字符串 + + Returns: + bytes: 完整的命令字节串 + """ + header = self.config.comm_type.value # '/' 或 '[' + address = str(self.config.address) # 设备地址 + tail = "E" # 尾码固定为 'E' + + # 构建基础命令字符串:头码 + 地址 + 命令 + 尾码 + cmd_str = f"{header}{address}{command}{tail}" + + # 转换为字节串 + cmd_bytes = cmd_str.encode('ascii') + + # 计算校验和(所有字节的累加值) + checksum = self._calculate_checksum(cmd_bytes) + + # 返回完整命令:基础命令字节 + 校验和字节 + return cmd_bytes + bytes([checksum]) + + def _send_command(self, command: str) -> bool: + """ + 发送命令到移液器 + + Args: + command: 要发送的命令 + + Returns: + bool: 命令是否发送成功 + """ + if not self.is_connected or not self.serial_port: + raise SOPACommunicationError("设备未连接") + + with self.lock: + try: + full_command_bytes = self._build_command(command) + # 转换为可读字符串用于日志显示 + readable_cmd = ''.join(chr(b) if 32 <= b <= 126 else f'\\x{b:02X}' for b in full_command_bytes) + logger.debug(f"发送命令: {readable_cmd}") + + self.serial_port.write(full_command_bytes) + self.serial_port.flush() + + # 等待响应 + time.sleep(0.1) + return True + + except Exception as e: + logger.error(f"发送命令失败: {str(e)}") + raise SOPACommunicationError(f"发送命令失败: {str(e)}") + + def _read_response(self, timeout: float = None) -> Optional[str]: + """ + 读取设备响应 + + Args: + timeout: 超时时间 + + Returns: + Optional[str]: 设备响应字符串 + """ + if not self.is_connected or not self.serial_port: + return None + + timeout = timeout or self.config.timeout + + try: + # 设置读取超时 + self.serial_port.timeout = timeout + + response = b'' + start_time = time.time() + + while time.time() - start_time < timeout: + if self.serial_port.in_waiting > 0: + chunk = self.serial_port.read(self.serial_port.in_waiting) + response += chunk + + # 检查是否收到完整响应(以'E'结尾) + if response.endswith(b'E') or len(response) >= 20: + break + + time.sleep(0.01) + + if response: + decoded_response = response.decode('ascii', errors='ignore') + logger.debug(f"收到响应: {decoded_response}") + return decoded_response + + except Exception as e: + logger.error(f"读取响应失败: {str(e)}") + + return None + + def _send_query(self, query: str) -> Optional[str]: + """ + 发送查询命令并获取响应 + + Args: + query: 查询命令 + + Returns: + Optional[str]: 查询结果 + """ + try: + self._send_command(query) + return self._read_response() + except Exception as e: + logger.error(f"查询失败: {str(e)}") + return None + + # ==================== 基础控制方法 ==================== + + def initialize(self) -> bool: + """ + 初始化移液器 + + Returns: + bool: 初始化是否成功 + """ + try: + logger.info("初始化SOPA移液器...") + + # 发送初始化命令 + self._send_command("HE") + + # 等待初始化完成 + time.sleep(2.0) + + # 检查状态 + status = self.get_status() + if status == SOPAStatusCode.NO_ERROR: + self.is_initialized = True + logger.info("移液器初始化成功") + + # 应用配置参数 + self._apply_configuration() + return True + else: + logger.error(f"初始化失败,状态码: {status}") + return False + + except Exception as e: + logger.error(f"初始化异常: {str(e)}") + return False + + def _apply_configuration(self): + """应用配置参数""" + try: + # 设置运动参数 + self.set_acceleration(self.config.acceleration) + self.set_start_speed(self.config.start_speed) + self.set_cutoff_speed(self.config.cutoff_speed) + self.set_max_speed(self.config.max_speed) + + # 设置检测参数 + self.set_empty_threshold(self.config.empty_threshold) + self.set_foam_threshold(self.config.foam_threshold) + self.set_block_threshold(self.config.block_threshold) + + # 设置吸头参数 + self.set_tip_volume(self.config.tip_volume) + self.set_calibration_factor(self.config.calibration_factor) + + # 设置液位检测参数 + self.set_detection_mode(self.config.detection_mode) + self.set_lld_speed(self.config.lld_speed) + + logger.info("配置参数应用完成") + + except Exception as e: + logger.warning(f"应用配置参数失败: {str(e)}") + + def eject_tip(self) -> bool: + """ + 顶出枪头 + + Returns: + bool: 操作是否成功 + """ + try: + logger.info("顶出枪头") + self._send_command("RE") + time.sleep(1.0) + return True + except Exception as e: + logger.error(f"顶出枪头失败: {str(e)}") + return False + + def get_tip_status(self) -> bool: + """ + 获取枪头状态 + + Returns: + bool: True表示有枪头,False表示无枪头 + """ + try: + response = self._send_query("Q28") + if response and len(response) > 10: + # 解析响应中的枪头状态 + if "T1" in response: + self._tip_present = True + return True + elif "T0" in response: + self._tip_present = False + return False + else: + logger.error(f"获取枪头状态失败: {response}") + return False + except Exception as e: + logger.error(f"获取枪头状态失败: {str(e)}") + + return False + + # ==================== 移液控制方法 ==================== + + def move_absolute(self, position: float) -> bool: + """ + 绝对位置移动 + + Args: + position: 目标位置(微升) + + Returns: + bool: 移动是否成功 + """ + try: + if not self.is_initialized: + raise SOPADeviceError("设备未初始化") + + pos_int = int(position) + logger.debug(f"绝对移动到位置: {pos_int}ul") + + self._send_command(f"A{pos_int}E") + time.sleep(0.5) + + self._current_position = pos_int + return True + + except Exception as e: + logger.error(f"绝对移动失败: {str(e)}") + return False + + def aspirate(self, volume: float, detection: bool = False) -> bool: + """ + 抽吸液体 + + Args: + volume: 抽吸体积(微升) + detection: 是否开启液体检测 + + Returns: + bool: 抽吸是否成功 + """ + try: + if not self.is_initialized: + raise SOPADeviceError("设备未初始化") + + vol_int = int(volume) + logger.info(f"抽吸液体: {vol_int}ul, 检测: {detection}") + + # 构建命令 + cmd_parts = [] + cmd_parts.append(f"a{self.config.acceleration}") + cmd_parts.append(f"b{self.config.start_speed}") + cmd_parts.append(f"c{self.config.cutoff_speed}") + cmd_parts.append(f"s{self.config.max_speed}") + + if detection: + cmd_parts.append("f1") # 开启检测 + + cmd_parts.append(f"P{vol_int}") + + if detection: + cmd_parts.append("f0") # 关闭检测 + + cmd_parts.append("E") + + command = "".join(cmd_parts) + self._send_command(command) + + # 等待操作完成 + time.sleep(max(1.0, vol_int / 100.0)) + + # 检查状态 + status = self.get_status() + if status == SOPAStatusCode.NO_ERROR: + self._current_position += vol_int + logger.info(f"抽吸成功: {vol_int}ul") + return True + elif status == SOPAStatusCode.AIR_ASPIRATE: + logger.warning("检测到空吸") + return False + elif status == SOPAStatusCode.NEEDLE_BLOCK: + logger.error("检测到堵针") + return False + else: + logger.error(f"抽吸失败,状态码: {status}") + return False + + except Exception as e: + logger.error(f"抽吸失败: {str(e)}") + return False + + def dispense(self, volume: float, detection: bool = False) -> bool: + """ + 分配液体 + + Args: + volume: 分配体积(微升) + detection: 是否开启液体检测 + + Returns: + bool: 分配是否成功 + """ + try: + if not self.is_initialized: + raise SOPADeviceError("设备未初始化") + + vol_int = int(volume) + logger.info(f"分配液体: {vol_int}ul, 检测: {detection}") + + # 构建命令 + cmd_parts = [] + cmd_parts.append(f"a{self.config.acceleration}") + cmd_parts.append(f"b{self.config.start_speed}") + cmd_parts.append(f"c{self.config.cutoff_speed}") + cmd_parts.append(f"s{self.config.max_speed}") + + if detection: + cmd_parts.append("f1") # 开启检测 + + cmd_parts.append(f"D{vol_int}") + + if detection: + cmd_parts.append("f0") # 关闭检测 + + cmd_parts.append("E") + + command = "".join(cmd_parts) + self._send_command(command) + + # 等待操作完成 + time.sleep(max(1.0, vol_int / 200.0)) + + # 检查状态 + status = self.get_status() + if status == SOPAStatusCode.NO_ERROR: + self._current_position -= vol_int + logger.info(f"分配成功: {vol_int}ul") + return True + else: + logger.error(f"分配失败,状态码: {status}") + return False + + except Exception as e: + logger.error(f"分配失败: {str(e)}") + return False + + # ==================== 液位检测方法 ==================== + + def liquid_level_detection(self, sensitivity: int = None) -> bool: + """ + 执行液位检测 + + Args: + sensitivity: 检测灵敏度 (3~40) + + Returns: + bool: 检测是否成功 + """ + try: + if not self.is_initialized: + raise SOPADeviceError("设备未初始化") + + sens = sensitivity or self.config.lld_sensitivity + + if self.config.detection_mode == DetectionMode.PRESSURE: + # 压力式液面检测 + command = f"m0k{self.config.lld_speed}L{sens}E" + else: + # 电容式液面检测 + command = f"m1L{sens}E" + + logger.info(f"执行液位检测, 模式: {self.config.detection_mode.name}, 灵敏度: {sens}") + + self._send_command(command) + time.sleep(2.0) + + # 检查检测结果 + status = self.get_status() + if status == SOPAStatusCode.NO_ERROR: + logger.info("液位检测成功") + return True + elif status == SOPAStatusCode.LLD_FAULT: + logger.error("液位检测故障") + return False + else: + logger.warning(f"液位检测异常,状态码: {status}") + return False + + except Exception as e: + logger.error(f"液位检测失败: {str(e)}") + return False + + # ==================== 参数设置方法 ==================== + + def set_max_speed(self, speed: int) -> bool: + """设置最高速度 (0.1ul/秒为单位)""" + try: + self._send_command(f"s{speed}E") + self.config.max_speed = speed + logger.debug(f"设置最高速度: {speed} (0.1ul/秒)") + return True + except Exception as e: + logger.error(f"设置最高速度失败: {str(e)}") + return False + + def set_start_speed(self, speed: int) -> bool: + """设置启动速度 (0.1ul/秒为单位)""" + try: + self._send_command(f"b{speed}E") + self.config.start_speed = speed + logger.debug(f"设置启动速度: {speed} (0.1ul/秒)") + return True + except Exception as e: + logger.error(f"设置启动速度失败: {str(e)}") + return False + + def set_cutoff_speed(self, speed: int) -> bool: + """设置断流速度 (0.1ul/秒为单位)""" + try: + self._send_command(f"c{speed}E") + self.config.cutoff_speed = speed + logger.debug(f"设置断流速度: {speed} (0.1ul/秒)") + return True + except Exception as e: + logger.error(f"设置断流速度失败: {str(e)}") + return False + + def set_acceleration(self, accel: int) -> bool: + """设置加速度""" + try: + self._send_command(f"a{accel}E") + self.config.acceleration = accel + logger.debug(f"设置加速度: {accel}") + return True + except Exception as e: + logger.error(f"设置加速度失败: {str(e)}") + return False + + def set_empty_threshold(self, threshold: int) -> bool: + """设置空吸门限""" + try: + self._send_command(f"${threshold}E") + self.config.empty_threshold = threshold + logger.debug(f"设置空吸门限: {threshold}") + return True + except Exception as e: + logger.error(f"设置空吸门限失败: {str(e)}") + return False + + def set_foam_threshold(self, threshold: int) -> bool: + """设置泡沫门限""" + try: + self._send_command(f"!{threshold}E") + self.config.foam_threshold = threshold + logger.debug(f"设置泡沫门限: {threshold}") + return True + except Exception as e: + logger.error(f"设置泡沫门限失败: {str(e)}") + return False + + def set_block_threshold(self, threshold: int) -> bool: + """设置堵塞门限""" + try: + self._send_command(f"%{threshold}E") + self.config.block_threshold = threshold + logger.debug(f"设置堵塞门限: {threshold}") + return True + except Exception as e: + logger.error(f"设置堵塞门限失败: {str(e)}") + return False + + def set_tip_volume(self, volume: int) -> bool: + """设置吸头容量""" + try: + self._send_command(f"C{volume}E") + self.config.tip_volume = volume + logger.debug(f"设置吸头容量: {volume}ul") + return True + except Exception as e: + logger.error(f"设置吸头容量失败: {str(e)}") + return False + + def set_calibration_factor(self, factor: float) -> bool: + """设置校准系数""" + try: + self._send_command(f"j{factor}E") + self.config.calibration_factor = factor + logger.debug(f"设置校准系数: {factor}") + return True + except Exception as e: + logger.error(f"设置校准系数失败: {str(e)}") + return False + + def set_detection_mode(self, mode: DetectionMode) -> bool: + """设置液位检测模式""" + try: + self._send_command(f"m{mode.value}E") + self.config.detection_mode = mode + logger.debug(f"设置检测模式: {mode.name}") + return True + except Exception as e: + logger.error(f"设置检测模式失败: {str(e)}") + return False + + def set_lld_speed(self, speed: int) -> bool: + """设置液位检测速度""" + try: + if 100 <= speed <= 2000: + self._send_command(f"k{speed}E") + self.config.lld_speed = speed + logger.debug(f"设置检测速度: {speed}") + return True + else: + logger.error("检测速度超出范围 (100~2000)") + return False + except Exception as e: + logger.error(f"设置检测速度失败: {str(e)}") + return False + + # ==================== 状态查询方法 ==================== + + def get_status(self) -> SOPAStatusCode: + """ + 获取设备状态 + + Returns: + SOPAStatusCode: 当前状态码 + """ + try: + response = self._send_query("Q") + if response and len(response) > 8: + # 解析状态字节 + status_char = response[8] if len(response) > 8 else '0' + try: + status_code = int(status_char, 16) if status_char.isdigit() or status_char.lower() in 'abcdef' else 0 + self._last_status = SOPAStatusCode(status_code) + except ValueError: + self._last_status = SOPAStatusCode.NO_ERROR + + return self._last_status + except Exception as e: + logger.error(f"获取状态失败: {str(e)}") + + return SOPAStatusCode.NO_ERROR + + def get_firmware_version(self) -> Optional[str]: + """ + 获取固件版本信息 + 处理SOPA移液器的双响应帧格式 + + Returns: + Optional[str]: 固件版本字符串,获取失败返回None + """ + try: + if not self.is_connected: + logger.debug("设备未连接,无法查询版本") + return "设备未连接" + + # 清空串口缓冲区,避免残留数据干扰 + if self.serial_port and self.serial_port.in_waiting > 0: + logger.debug(f"清空缓冲区中的 {self.serial_port.in_waiting} 字节数据") + self.serial_port.reset_input_buffer() + + # 发送版本查询命令 - 使用VE命令 + command = self._build_command("VE") + logger.debug(f"发送版本查询命令: {command}") + self.serial_port.write(command) + + # 等待响应 + time.sleep(0.3) # 增加等待时间 + + # 读取所有可用数据 + all_data = b'' + timeout_count = 0 + max_timeout = 15 # 增加最大等待时间到1.5秒 + + while timeout_count < max_timeout: + if self.serial_port.in_waiting > 0: + data = self.serial_port.read(self.serial_port.in_waiting) + all_data += data + logger.debug(f"接收到 {len(data)} 字节数据: {data.hex().upper()}") + timeout_count = 0 # 重置超时计数 + else: + time.sleep(0.1) + timeout_count += 1 + + # 检查是否收到完整的双响应帧 + if len(all_data) >= 26: # 两个13字节的响应帧 + logger.debug("收到完整的双响应帧") + break + elif len(all_data) >= 13: # 至少一个响应帧 + # 继续等待一段时间看是否有第二个帧 + if timeout_count > 5: # 等待0.5秒后如果没有更多数据就停止 + logger.debug("只收到单响应帧") + break + + logger.debug(f"总共接收到 {len(all_data)} 字节数据: {all_data.hex().upper()}") + + if len(all_data) < 13: + logger.warning("接收到的数据不足一个完整响应帧") + return "版本信息不可用" + + # 解析响应数据 + version_info = self._parse_version_response(all_data) + logger.info(f"解析得到版本信息: {version_info}") + return version_info + + except Exception as e: + logger.error(f"获取固件版本失败: {str(e)}") + return "版本信息不可用" + + def _parse_version_response(self, data: bytes) -> str: + """ + 解析版本响应数据 + + Args: + data: 原始响应数据 + + Returns: + str: 解析后的版本信息 + """ + try: + # 将数据转换为十六进制字符串用于调试 + hex_data = data.hex().upper() + logger.debug(f"收到版本响应数据: {hex_data}") + + # 查找响应帧的起始位置 + responses = [] + i = 0 + while i < len(data) - 12: + # 查找帧头 0x2F (/) + if data[i] == 0x2F: + # 检查是否是完整的13字节帧 + if i + 12 < len(data) and data[i + 11] == 0x45: # 尾码 E + frame = data[i:i+13] + responses.append(frame) + i += 13 + else: + i += 1 + else: + i += 1 + + if len(responses) < 2: + # 如果只有一个响应帧,尝试解析 + if len(responses) == 1: + return self._extract_version_from_frame(responses[0]) + else: + return f"响应格式异常: {hex_data}" + + # 解析第二个响应帧(通常包含版本信息) + version_frame = responses[1] + return self._extract_version_from_frame(version_frame) + + except Exception as e: + logger.error(f"解析版本响应失败: {str(e)}") + return f"解析失败: {data.hex().upper()}" + + def _extract_version_from_frame(self, frame: bytes) -> str: + """ + 从响应帧中提取版本信息 + + Args: + frame: 13字节的响应帧 + + Returns: + str: 版本信息字符串 + """ + try: + # 帧格式: 头码(1) + 地址(1) + 数据(9) + 尾码(1) + 校验和(1) + if len(frame) != 13: + return f"帧长度错误: {frame.hex().upper()}" + + # 提取数据部分 (索引2-10,共9字节) + data_part = frame[2:11] + + # 尝试不同的解析方法 + version_candidates = [] + + # 方法1: 查找可打印的ASCII字符 + ascii_chars = [] + for byte in data_part: + if 32 <= byte <= 126: # 可打印ASCII范围 + ascii_chars.append(chr(byte)) + + if ascii_chars: + version_candidates.append(''.join(ascii_chars)) + + # 方法2: 解析为版本号格式 (如果前几个字节是版本信息) + if len(data_part) >= 3: + # 检查是否是 V.x.y 格式 + if data_part[0] == 0x56: # 'V' + version_str = f"V{data_part[1]}.{data_part[2]}" + version_candidates.append(version_str) + + # 方法3: 十六进制表示 + hex_version = ' '.join(f'{b:02X}' for b in data_part) + version_candidates.append(f"HEX: {hex_version}") + + # 返回最合理的版本信息 + for candidate in version_candidates: + if candidate and len(candidate.strip()) > 1: + return candidate.strip() + + return f"原始数据: {frame.hex().upper()}" + + except Exception as e: + logger.error(f"提取版本信息失败: {str(e)}") + return f"提取失败: {frame.hex().upper()}" + + def get_current_position(self) -> float: + """ + 获取当前位置 + + Returns: + float: 当前位置 (微升) + """ + try: + response = self._send_query("Q18") + if response and len(response) > 10: + # 解析位置信息 + pos_str = response[8:14].strip() + try: + self._current_position = int(pos_str) + except ValueError: + pass + except Exception as e: + logger.error(f"获取位置失败: {str(e)}") + + return self._current_position + + def get_device_info(self) -> Dict[str, Any]: + """ + 获取设备完整信息 + + Returns: + Dict[str, Any]: 设备信息字典 + """ + info = { + 'firmware_version': self.get_firmware_version(), + 'current_position': self.get_current_position(), + 'tip_present': self.get_tip_status(), + 'status': self.get_status(), + 'is_connected': self.is_connected, + 'is_initialized': self.is_initialized, + 'config': { + 'address': self.config.address, + 'baudrate': self.config.baudrate, + 'max_speed': self.config.max_speed, + 'tip_volume': self.config.tip_volume, + 'detection_mode': self.config.detection_mode.name + } + } + + return info + + # ==================== 高级操作方法 ==================== + + def transfer_liquid(self, source_volume: float, dispense_volume: float = None, + with_detection: bool = True, pre_wet: bool = False) -> bool: + """ + 完整的液体转移操作 + + Args: + source_volume: 从源容器抽吸的体积 + dispense_volume: 分配到目标容器的体积(默认等于抽吸体积) + with_detection: 是否使用液体检测 + pre_wet: 是否进行预润湿 + + Returns: + bool: 操作是否成功 + """ + try: + if not self.is_initialized: + raise SOPADeviceError("设备未初始化") + + dispense_volume = dispense_volume or source_volume + + logger.info(f"开始液体转移: 抽吸{source_volume}ul -> 分配{dispense_volume}ul") + + # 预润湿(如果需要) + if pre_wet: + logger.info("执行预润湿操作") + if not self.aspirate(source_volume * 0.1, with_detection): + return False + if not self.dispense(source_volume * 0.1): + return False + + # 执行液位检测(如果启用) + if with_detection: + if not self.liquid_level_detection(): + logger.warning("液位检测失败,继续执行") + + # 抽吸液体 + if not self.aspirate(source_volume, with_detection): + logger.error("抽吸失败") + return False + + # 可选的延时 + time.sleep(0.5) + + # 分配液体 + if not self.dispense(dispense_volume, with_detection): + logger.error("分配失败") + return False + + logger.info("液体转移完成") + return True + + except Exception as e: + logger.error(f"液体转移失败: {str(e)}") + return False + + @contextmanager + def batch_operation(self): + """批量操作上下文管理器""" + logger.info("开始批量操作") + try: + yield self + finally: + logger.info("批量操作完成") + + def reset_to_home(self) -> bool: + """回到初始位置""" + return self.move_absolute(0) + + def emergency_stop(self): + """紧急停止""" + try: + if self.serial_port and self.serial_port.is_open: + # 发送停止命令(如果协议支持) + self.serial_port.write(b'\x03') # Ctrl+C + logger.warning("执行紧急停止") + except Exception as e: + logger.error(f"紧急停止失败: {str(e)}") + + def __enter__(self): + """上下文管理器入口""" + if not self.is_connected: + self.connect() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """上下文管理器出口""" + self.disconnect() + + def __del__(self): + """析构函数""" + self.disconnect() + + +# ==================== 工厂函数和便利方法 ==================== + +def create_sopa_pipette(port: str = "/dev/ttyUSB0", address: int = 1, + baudrate: int = 115200, **kwargs) -> SOPAPipette: + """ + 创建SOPA移液器实例的便利函数 + + Args: + port: 串口端口 + address: RS485地址 + baudrate: 波特率 + **kwargs: 其他配置参数 + + Returns: + SOPAPipette: 移液器实例 + """ + config = SOPAConfig( + port=port, + address=address, + baudrate=baudrate, + **kwargs + ) + + return SOPAPipette(config) diff --git a/unilabos/devices/liquid_handling/laiyu/drivers/xyz_stepper_driver.py b/unilabos/devices/liquid_handling/laiyu/drivers/xyz_stepper_driver.py new file mode 100644 index 00000000..8505ba76 --- /dev/null +++ b/unilabos/devices/liquid_handling/laiyu/drivers/xyz_stepper_driver.py @@ -0,0 +1,663 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +XYZ三轴步进电机B系列驱动程序 +支持RS485通信,Modbus协议 +""" + +import serial +import struct +import time +import logging +from typing import Optional, Tuple, Dict, Any +from enum import Enum +from dataclasses import dataclass + +# 配置日志 +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class MotorAxis(Enum): + """电机轴枚举""" + X = 1 + Y = 2 + Z = 3 + + +class MotorStatus(Enum): + """电机状态枚举""" + STANDBY = 0x0000 # 待机/到位 + RUNNING = 0x0001 # 运行中 + COLLISION_STOP = 0x0002 # 碰撞停 + FORWARD_LIMIT_STOP = 0x0003 # 正光电停 + REVERSE_LIMIT_STOP = 0x0004 # 反光电停 + + +class ModbusFunction(Enum): + """Modbus功能码""" + READ_HOLDING_REGISTERS = 0x03 + WRITE_SINGLE_REGISTER = 0x06 + WRITE_MULTIPLE_REGISTERS = 0x10 + + +@dataclass +class MotorPosition: + """电机位置信息""" + steps: int + speed: int + current: int + status: MotorStatus + + +class ModbusException(Exception): + """Modbus通信异常""" + pass + + +class StepperMotorDriver: + """步进电机驱动器基类""" + + # 寄存器地址常量 + REG_STATUS = 0x00 + REG_POSITION_HIGH = 0x01 + REG_POSITION_LOW = 0x02 + REG_ACTUAL_SPEED = 0x03 + REG_EMERGENCY_STOP = 0x04 + REG_CURRENT = 0x05 + REG_ENABLE = 0x06 + REG_PWM_OUTPUT = 0x07 + REG_ZERO_SINGLE = 0x0E + REG_ZERO_COMMAND = 0x0F + + # 位置模式寄存器 + REG_TARGET_POSITION_HIGH = 0x10 + REG_TARGET_POSITION_LOW = 0x11 + REG_POSITION_SPEED = 0x13 + REG_POSITION_ACCELERATION = 0x14 + REG_POSITION_PRECISION = 0x15 + + # 速度模式寄存器 + REG_SPEED_MODE_SPEED = 0x61 + REG_SPEED_MODE_ACCELERATION = 0x62 + + # 设备参数寄存器 + REG_DEVICE_ADDRESS = 0xE0 + REG_DEFAULT_SPEED = 0xE7 + REG_DEFAULT_ACCELERATION = 0xE8 + + def __init__(self, port: str, baudrate: int = 115200, timeout: float = 1.0): + """ + 初始化步进电机驱动器 + + Args: + port: 串口端口名 + baudrate: 波特率 + timeout: 通信超时时间 + """ + self.port = port + self.baudrate = baudrate + self.timeout = timeout + self.serial_conn: Optional[serial.Serial] = None + + def connect(self) -> bool: + """ + 建立串口连接 + + Returns: + 连接是否成功 + """ + try: + self.serial_conn = serial.Serial( + port=self.port, + baudrate=self.baudrate, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + timeout=self.timeout + ) + logger.info(f"已连接到串口: {self.port}") + return True + except Exception as e: + logger.error(f"串口连接失败: {e}") + return False + + def disconnect(self) -> None: + """关闭串口连接""" + if self.serial_conn and self.serial_conn.is_open: + self.serial_conn.close() + logger.info("串口连接已关闭") + + def __enter__(self): + """上下文管理器入口""" + if self.connect(): + return self + raise ModbusException("无法建立串口连接") + + def __exit__(self, exc_type, exc_val, exc_tb): + """上下文管理器出口""" + self.disconnect() + + @staticmethod + def calculate_crc(data: bytes) -> bytes: + """ + 计算Modbus CRC校验码 + + Args: + data: 待校验的数据 + + Returns: + CRC校验码 (2字节) + """ + crc = 0xFFFF + for byte in data: + crc ^= byte + for _ in range(8): + if crc & 0x0001: + crc >>= 1 + crc ^= 0xA001 + else: + crc >>= 1 + return struct.pack(' bytes: + """ + 发送Modbus命令并接收响应 + + Args: + slave_addr: 从站地址 + data: 命令数据 + + Returns: + 响应数据 + + Raises: + ModbusException: 通信异常 + """ + if not self.serial_conn or not self.serial_conn.is_open: + raise ModbusException("串口未连接") + + # 构建完整命令 + command = bytes([slave_addr]) + data + crc = self.calculate_crc(command) + full_command = command + crc + + # 清空接收缓冲区 + self.serial_conn.reset_input_buffer() + + # 发送命令 + self.serial_conn.write(full_command) + logger.debug(f"发送命令: {' '.join(f'{b:02X}' for b in full_command)}") + + # 等待响应 + time.sleep(0.01) # 短暂延时 + + # 读取响应 + response = self.serial_conn.read(256) # 最大读取256字节 + if not response: + raise ModbusException("未收到响应") + + logger.debug(f"接收响应: {' '.join(f'{b:02X}' for b in response)}") + + # 验证CRC + if len(response) < 3: + raise ModbusException("响应数据长度不足") + + data_part = response[:-2] + received_crc = response[-2:] + calculated_crc = self.calculate_crc(data_part) + + if received_crc != calculated_crc: + raise ModbusException(f"CRC校验失败{response}") + + return response + + def read_registers(self, slave_addr: int, start_addr: int, count: int) -> list: + """ + 读取保持寄存器 + + Args: + slave_addr: 从站地址 + start_addr: 起始地址 + count: 寄存器数量 + + Returns: + 寄存器值列表 + """ + data = struct.pack('>BHH', ModbusFunction.READ_HOLDING_REGISTERS.value, start_addr, count) + response = self._send_command(slave_addr, data) + + if len(response) < 5: + raise ModbusException("响应长度不足") + + if response[1] != ModbusFunction.READ_HOLDING_REGISTERS.value: + raise ModbusException(f"功能码错误: {response[1]:02X}") + + byte_count = response[2] + values = [] + for i in range(0, byte_count, 2): + value = struct.unpack('>H', response[3+i:5+i])[0] + values.append(value) + + return values + + def write_single_register(self, slave_addr: int, addr: int, value: int) -> bool: + """ + 写入单个寄存器 + + Args: + slave_addr: 从站地址 + addr: 寄存器地址 + value: 寄存器值 + + Returns: + 写入是否成功 + """ + data = struct.pack('>BHH', ModbusFunction.WRITE_SINGLE_REGISTER.value, addr, value) + response = self._send_command(slave_addr, data) + + return len(response) >= 8 and response[1] == ModbusFunction.WRITE_SINGLE_REGISTER.value + + def write_multiple_registers(self, slave_addr: int, start_addr: int, values: list) -> bool: + """ + 写入多个寄存器 + + Args: + slave_addr: 从站地址 + start_addr: 起始地址 + values: 寄存器值列表 + + Returns: + 写入是否成功 + """ + byte_count = len(values) * 2 + data = struct.pack('>BHHB', ModbusFunction.WRITE_MULTIPLE_REGISTERS.value, + start_addr, len(values), byte_count) + + for value in values: + data += struct.pack('>H', value) + + response = self._send_command(slave_addr, data) + + return len(response) >= 8 and response[1] == ModbusFunction.WRITE_MULTIPLE_REGISTERS.value + + +class XYZStepperController(StepperMotorDriver): + """XYZ三轴步进电机控制器""" + + # 电机配置常量 + STEPS_PER_REVOLUTION = 16384 # 每圈步数 + + def __init__(self, port: str, baudrate: int = 115200, timeout: float = 1.0): + """ + 初始化XYZ三轴步进电机控制器 + + Args: + port: 串口端口名 + baudrate: 波特率 + timeout: 通信超时时间 + """ + super().__init__(port, baudrate, timeout) + self.axis_addresses = { + MotorAxis.X: 1, + MotorAxis.Y: 2, + MotorAxis.Z: 3 + } + + def degrees_to_steps(self, degrees: float) -> int: + """ + 将角度转换为步数 + + Args: + degrees: 角度值 + + Returns: + 对应的步数 + """ + return int(degrees * self.STEPS_PER_REVOLUTION / 360.0) + + def steps_to_degrees(self, steps: int) -> float: + """ + 将步数转换为角度 + + Args: + steps: 步数 + + Returns: + 对应的角度值 + """ + return steps * 360.0 / self.STEPS_PER_REVOLUTION + + def revolutions_to_steps(self, revolutions: float) -> int: + """ + 将圈数转换为步数 + + Args: + revolutions: 圈数 + + Returns: + 对应的步数 + """ + return int(revolutions * self.STEPS_PER_REVOLUTION) + + def steps_to_revolutions(self, steps: int) -> float: + """ + 将步数转换为圈数 + + Args: + steps: 步数 + + Returns: + 对应的圈数 + """ + return steps / self.STEPS_PER_REVOLUTION + + def get_motor_status(self, axis: MotorAxis) -> MotorPosition: + """ + 获取电机状态信息 + + Args: + axis: 电机轴 + + Returns: + 电机位置信息 + """ + addr = self.axis_addresses[axis] + + # 读取状态、位置、速度、电流 + values = self.read_registers(addr, self.REG_STATUS, 6) + + status = MotorStatus(values[0]) + position_high = values[1] + position_low = values[2] + speed = values[3] + current = values[5] + + # 合并32位位置 + position = (position_high << 16) | position_low + # 处理有符号数 + if position > 0x7FFFFFFF: + position -= 0x100000000 + + return MotorPosition(position, speed, current, status) + + def emergency_stop(self, axis: MotorAxis) -> bool: + """ + 紧急停止电机 + + Args: + axis: 电机轴 + + Returns: + 操作是否成功 + """ + addr = self.axis_addresses[axis] + return self.write_single_register(addr, self.REG_EMERGENCY_STOP, 0x0000) + + def enable_motor(self, axis: MotorAxis, enable: bool = True) -> bool: + """ + 使能/失能电机 + + Args: + axis: 电机轴 + enable: True为使能,False为失能 + + Returns: + 操作是否成功 + """ + addr = self.axis_addresses[axis] + value = 0x0001 if enable else 0x0000 + return self.write_single_register(addr, self.REG_ENABLE, value) + + def move_to_position(self, axis: MotorAxis, position: int, speed: int = 5000, + acceleration: int = 1000, precision: int = 100) -> bool: + """ + 移动到指定位置 + + Args: + axis: 电机轴 + position: 目标位置(步数) + speed: 运行速度(rpm) + acceleration: 加速度(rpm/s) + precision: 到位精度 + + Returns: + 操作是否成功 + """ + addr = self.axis_addresses[axis] + + # 处理32位位置 + if position < 0: + position += 0x100000000 + + position_high = (position >> 16) & 0xFFFF + position_low = position & 0xFFFF + + values = [ + position_high, # 目标位置高位 + position_low, # 目标位置低位 + 0x0000, # 保留 + speed, # 速度 + acceleration, # 加速度 + precision # 精度 + ] + + return self.write_multiple_registers(addr, self.REG_TARGET_POSITION_HIGH, values) + + def set_speed_mode(self, axis: MotorAxis, speed: int, acceleration: int = 1000) -> bool: + """ + 设置速度模式运行 + + Args: + axis: 电机轴 + speed: 运行速度(rpm),正值正转,负值反转 + acceleration: 加速度(rpm/s) + + Returns: + 操作是否成功 + """ + addr = self.axis_addresses[axis] + + # 处理负数 + if speed < 0: + speed = 0x10000 + speed # 补码表示 + + values = [0x0000, speed, acceleration, 0x0000] + + return self.write_multiple_registers(addr, 0x60, values) + + def home_axis(self, axis: MotorAxis) -> bool: + """ + 轴归零操作 + + Args: + axis: 电机轴 + + Returns: + 操作是否成功 + """ + addr = self.axis_addresses[axis] + return self.write_single_register(addr, self.REG_ZERO_SINGLE, 0x0001) + + def wait_for_completion(self, axis: MotorAxis, timeout: float = 30.0) -> bool: + """ + 等待电机运动完成 + + Args: + axis: 电机轴 + timeout: 超时时间(秒) + + Returns: + 是否在超时前完成 + """ + start_time = time.time() + + while time.time() - start_time < timeout: + status = self.get_motor_status(axis) + if status.status == MotorStatus.STANDBY: + return True + time.sleep(0.1) + + logger.warning(f"{axis.name}轴运动超时") + return False + + def move_xyz(self, x: Optional[int] = None, y: Optional[int] = None, z: Optional[int] = None, + speed: int = 5000, acceleration: int = 1000) -> Dict[MotorAxis, bool]: + """ + 同时控制XYZ轴移动 + + Args: + x: X轴目标位置 + y: Y轴目标位置 + z: Z轴目标位置 + speed: 运行速度 + acceleration: 加速度 + + Returns: + 各轴操作结果字典 + """ + results = {} + + if x is not None: + results[MotorAxis.X] = self.move_to_position(MotorAxis.X, x, speed, acceleration) + + if y is not None: + results[MotorAxis.Y] = self.move_to_position(MotorAxis.Y, y, speed, acceleration) + + if z is not None: + results[MotorAxis.Z] = self.move_to_position(MotorAxis.Z, z, speed, acceleration) + + return results + + def move_xyz_degrees(self, x_deg: Optional[float] = None, y_deg: Optional[float] = None, + z_deg: Optional[float] = None, speed: int = 5000, + acceleration: int = 1000) -> Dict[MotorAxis, bool]: + """ + 使用角度值同时移动多个轴到指定位置 + + Args: + x_deg: X轴目标角度(度) + y_deg: Y轴目标角度(度) + z_deg: Z轴目标角度(度) + speed: 移动速度 + acceleration: 加速度 + + Returns: + 各轴移动操作结果 + """ + # 将角度转换为步数 + x_steps = self.degrees_to_steps(x_deg) if x_deg is not None else None + y_steps = self.degrees_to_steps(y_deg) if y_deg is not None else None + z_steps = self.degrees_to_steps(z_deg) if z_deg is not None else None + + return self.move_xyz(x_steps, y_steps, z_steps, speed, acceleration) + + def move_xyz_revolutions(self, x_rev: Optional[float] = None, y_rev: Optional[float] = None, + z_rev: Optional[float] = None, speed: int = 5000, + acceleration: int = 1000) -> Dict[MotorAxis, bool]: + """ + 使用圈数值同时移动多个轴到指定位置 + + Args: + x_rev: X轴目标圈数 + y_rev: Y轴目标圈数 + z_rev: Z轴目标圈数 + speed: 移动速度 + acceleration: 加速度 + + Returns: + 各轴移动操作结果 + """ + # 将圈数转换为步数 + x_steps = self.revolutions_to_steps(x_rev) if x_rev is not None else None + y_steps = self.revolutions_to_steps(y_rev) if y_rev is not None else None + z_steps = self.revolutions_to_steps(z_rev) if z_rev is not None else None + + return self.move_xyz(x_steps, y_steps, z_steps, speed, acceleration) + + def move_to_position_degrees(self, axis: MotorAxis, degrees: float, speed: int = 5000, + acceleration: int = 1000, precision: int = 100) -> bool: + """ + 使用角度值移动单个轴到指定位置 + + Args: + axis: 电机轴 + degrees: 目标角度(度) + speed: 移动速度 + acceleration: 加速度 + precision: 精度 + + Returns: + 移动操作是否成功 + """ + steps = self.degrees_to_steps(degrees) + return self.move_to_position(axis, steps, speed, acceleration, precision) + + def move_to_position_revolutions(self, axis: MotorAxis, revolutions: float, speed: int = 5000, + acceleration: int = 1000, precision: int = 100) -> bool: + """ + 使用圈数值移动单个轴到指定位置 + + Args: + axis: 电机轴 + revolutions: 目标圈数 + speed: 移动速度 + acceleration: 加速度 + precision: 精度 + + Returns: + 移动操作是否成功 + """ + steps = self.revolutions_to_steps(revolutions) + return self.move_to_position(axis, steps, speed, acceleration, precision) + + def stop_all_axes(self) -> Dict[MotorAxis, bool]: + """ + 紧急停止所有轴 + + Returns: + 各轴停止结果字典 + """ + results = {} + for axis in MotorAxis: + results[axis] = self.emergency_stop(axis) + return results + + def enable_all_axes(self, enable: bool = True) -> Dict[MotorAxis, bool]: + """ + 使能/失能所有轴 + + Args: + enable: True为使能,False为失能 + + Returns: + 各轴操作结果字典 + """ + results = {} + for axis in MotorAxis: + results[axis] = self.enable_motor(axis, enable) + return results + + def get_all_positions(self) -> Dict[MotorAxis, MotorPosition]: + """ + 获取所有轴的位置信息 + + Returns: + 各轴位置信息字典 + """ + positions = {} + for axis in MotorAxis: + positions[axis] = self.get_motor_status(axis) + return positions + + def home_all_axes(self) -> Dict[MotorAxis, bool]: + """ + 所有轴归零 + + Returns: + 各轴归零结果字典 + """ + results = {} + for axis in MotorAxis: + results[axis] = self.home_axis(axis) + return results diff --git a/unilabos/devices/liquid_handling/laiyu/laiyu.py b/unilabos/devices/liquid_handling/laiyu/laiyu.py new file mode 100644 index 00000000..0d7074a7 --- /dev/null +++ b/unilabos/devices/liquid_handling/laiyu/laiyu.py @@ -0,0 +1,218 @@ +import asyncio +import collections +import contextlib +import json +import time +from typing import Any, List, Dict, Optional, TypedDict, Union, Sequence, Iterator, Literal + +from pylabrobot.liquid_handling import ( + LiquidHandlerBackend, + Pickup, + SingleChannelAspiration, + Drop, + SingleChannelDispense, + PickupTipRack, + DropTipRack, + MultiHeadAspirationPlate, ChatterBoxBackend, LiquidHandlerChatterboxBackend, +) +from pylabrobot.liquid_handling.standard import ( + MultiHeadAspirationContainer, + MultiHeadDispenseContainer, + MultiHeadDispensePlate, + ResourcePickup, + ResourceMove, + ResourceDrop, +) +from pylabrobot.resources import Tip, Deck, Plate, Well, TipRack, Resource, Container, Coordinate, TipSpot, Trash + +from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract +from unilabos.devices.liquid_handling.rviz_backend import UniLiquidHandlerRvizBackend +from unilabos.devices.liquid_handling.laiyu.backend.laiyu_v_backend import UniLiquidHandlerLaiyuBackend + + + +class TransformXYZDeck(Deck): + """Laiyu 的专用 Deck 类,继承自 Deck。 + + 该类定义了 Laiyu 的工作台布局和槽位信息。 + """ + + def __init__(self, name: str, size_x: float, size_y: float, size_z: float): + super().__init__(name, size_x, size_y, size_z) + self.name = name + +class TransformXYZBackend(LiquidHandlerBackend): + def __init__(self, name: str, host: str, port: int, timeout: float): + super().__init__() + self.host = host + self.port = port + self.timeout = timeout + +class TransformXYZRvizBackend(UniLiquidHandlerRvizBackend): + def __init__(self, name: str, channel_num: int): + super().__init__(channel_num) + self.name = name + + +class TransformXYZContainer(Plate, TipRack): + """Laiyu 的专用 Container 类,继承自 Plate和TipRack。 + + 该类定义了 Laiyu 的工作台布局和槽位信息。 + """ + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + category: str, + ordering: collections.OrderedDict, + model: Optional[str] = None, + ): + super().__init__(name, size_x, size_y, size_z, category=category, ordering=ordering, model=model) + self._unilabos_state = {} + + def load_state(self, state: Dict[str, Any]) -> None: + """从给定的状态加载工作台信息。""" + super().load_state(state) + self._unilabos_state = state + + def serialize_state(self) -> Dict[str, Dict[str, Any]]: + data = super().serialize_state() + data.update(self._unilabos_state) + return data + +class TransformXYZHandler(LiquidHandlerAbstract): + support_touch_tip = False + + def __init__(self, deck: Deck, host: str = "127.0.0.1", port: int = 9999, timeout: float = 10.0, channel_num=1, simulator=True, **backend_kwargs): + # Handle case where deck is passed as a dict (from serialization) + if isinstance(deck, dict): + # Try to create a TransformXYZDeck from the dict + if 'name' in deck and 'size_x' in deck and 'size_y' in deck and 'size_z' in deck: + deck = TransformXYZDeck( + name=deck['name'], + size_x=deck.get('size_x', 100), + size_y=deck.get('size_y', 100), + size_z=deck.get('size_z', 100) + ) + else: + # Fallback: create a basic deck + deck = TransformXYZDeck(name='deck', size_x=100, size_y=100, size_z=100) + + if simulator: + self._unilabos_backend = TransformXYZRvizBackend(name="laiyu",channel_num=channel_num) + else: + self._unilabos_backend = TransformXYZBackend(name="laiyu",host=host, port=port, timeout=timeout) + super().__init__(backend=self._unilabos_backend, deck=deck, simulator=simulator, channel_num=channel_num) + + async def add_liquid( + self, + asp_vols: Union[List[float], float], + dis_vols: Union[List[float], float], + reagent_sources: Sequence[Container], + targets: Sequence[Container], + *, + use_channels: Optional[List[int]] = None, + flow_rates: Optional[List[Optional[float]]] = None, + offsets: Optional[List[Coordinate]] = None, + liquid_height: Optional[List[Optional[float]]] = None, + blow_out_air_volume: Optional[List[Optional[float]]] = None, + spread: Optional[Literal["wide", "tight", "custom"]] = "wide", + is_96_well: bool = False, + delays: Optional[List[int]] = None, + mix_time: Optional[int] = None, + mix_vol: Optional[int] = None, + mix_rate: Optional[int] = None, + mix_liquid_height: Optional[float] = None, + none_keys: List[str] = [], + ): + pass + + async def aspirate( + self, + resources: Sequence[Container], + vols: List[float], + use_channels: Optional[List[int]] = None, + flow_rates: Optional[List[Optional[float]]] = None, + offsets: Optional[List[Coordinate]] = None, + liquid_height: Optional[List[Optional[float]]] = None, + blow_out_air_volume: Optional[List[Optional[float]]] = None, + spread: Literal["wide", "tight", "custom"] = "wide", + **backend_kwargs, + ): + pass + + async def dispense( + self, + resources: Sequence[Container], + vols: List[float], + use_channels: Optional[List[int]] = None, + flow_rates: Optional[List[Optional[float]]] = None, + offsets: Optional[List[Coordinate]] = None, + liquid_height: Optional[List[Optional[float]]] = None, + blow_out_air_volume: Optional[List[Optional[float]]] = None, + spread: Literal["wide", "tight", "custom"] = "wide", + **backend_kwargs, + ): + pass + + async def drop_tips( + self, + tip_spots: Sequence[Union[TipSpot, Trash]], + use_channels: Optional[List[int]] = None, + offsets: Optional[List[Coordinate]] = None, + allow_nonzero_volume: bool = False, + **backend_kwargs, + ): + pass + + async def mix( + self, + targets: Sequence[Container], + mix_time: int = None, + mix_vol: Optional[int] = None, + height_to_bottom: Optional[float] = None, + offsets: Optional[Coordinate] = None, + mix_rate: Optional[float] = None, + none_keys: List[str] = [], + ): + pass + + async def pick_up_tips( + self, + tip_spots: List[TipSpot], + use_channels: Optional[List[int]] = None, + offsets: Optional[List[Coordinate]] = None, + **backend_kwargs, + ): + pass + + async def transfer_liquid( + self, + sources: Sequence[Container], + targets: Sequence[Container], + tip_racks: Sequence[TipRack], + *, + use_channels: Optional[List[int]] = None, + asp_vols: Union[List[float], float], + dis_vols: Union[List[float], float], + asp_flow_rates: Optional[List[Optional[float]]] = None, + dis_flow_rates: Optional[List[Optional[float]]] = None, + offsets: Optional[List[Coordinate]] = None, + touch_tip: bool = False, + liquid_height: Optional[List[Optional[float]]] = None, + blow_out_air_volume: Optional[List[Optional[float]]] = None, + spread: Literal["wide", "tight", "custom"] = "wide", + is_96_well: bool = False, + mix_stage: Optional[Literal["none", "before", "after", "both"]] = "none", + mix_times: Optional[List[int]] = None, + mix_vol: Optional[int] = None, + mix_rate: Optional[int] = None, + mix_liquid_height: Optional[float] = None, + delays: Optional[List[int]] = None, + none_keys: List[str] = [], + ): + pass + \ No newline at end of file diff --git a/unilabos/devices/liquid_handling/laiyu/tests/__init__.py b/unilabos/devices/liquid_handling/laiyu/tests/__init__.py new file mode 100644 index 00000000..7ff58fe2 --- /dev/null +++ b/unilabos/devices/liquid_handling/laiyu/tests/__init__.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +LaiYu液体处理设备测试模块 + +该模块包含LaiYu液体处理设备的测试用例: +- test_deck_config.py: 工作台配置测试 + +作者: UniLab团队 +版本: 2.0.0 +""" + +__all__ = [] \ No newline at end of file diff --git a/unilabos/devices/liquid_handling/laiyu/tests/test_deck_config.py b/unilabos/devices/liquid_handling/laiyu/tests/test_deck_config.py new file mode 100644 index 00000000..04688302 --- /dev/null +++ b/unilabos/devices/liquid_handling/laiyu/tests/test_deck_config.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +测试脚本:验证更新后的deck配置是否正常工作 +""" + +import sys +import os +import json + +# 添加项目根目录到Python路径 +project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.insert(0, project_root) + +def test_config_loading(): + """测试配置文件加载功能""" + print("=" * 50) + print("测试配置文件加载功能") + print("=" * 50) + + try: + # 直接测试配置文件加载 + config_path = os.path.join(os.path.dirname(__file__), "controllers", "deckconfig.json") + fallback_path = os.path.join(os.path.dirname(__file__), "config", "deck.json") + + config = None + config_source = "" + + if os.path.exists(config_path): + with open(config_path, 'r', encoding='utf-8') as f: + config = json.load(f) + config_source = "config/deckconfig.json" + elif os.path.exists(fallback_path): + with open(fallback_path, 'r', encoding='utf-8') as f: + config = json.load(f) + config_source = "config/deck.json" + else: + print("❌ 配置文件不存在") + return False + + print(f"✅ 配置文件加载成功: {config_source}") + print(f" - 甲板尺寸: {config.get('size_x', 'N/A')} x {config.get('size_y', 'N/A')} x {config.get('size_z', 'N/A')}") + print(f" - 子模块数量: {len(config.get('children', []))}") + + # 检查各个模块是否存在 + modules = config.get('children', []) + module_types = [module.get('type') for module in modules] + module_names = [module.get('name') for module in modules] + + print(f" - 模块类型: {', '.join(set(filter(None, module_types)))}") + print(f" - 模块名称: {', '.join(filter(None, module_names))}") + + return config + except Exception as e: + print(f"❌ 配置文件加载失败: {e}") + return None + +def test_module_coordinates(config): + """测试各模块的坐标信息""" + print("\n" + "=" * 50) + print("测试模块坐标信息") + print("=" * 50) + + if not config: + print("❌ 配置为空,无法测试") + return False + + modules = config.get('children', []) + + for module in modules: + module_name = module.get('name', '未知模块') + module_type = module.get('type', '未知类型') + position = module.get('position', {}) + size = module.get('size', {}) + + print(f"\n模块: {module_name} ({module_type})") + print(f" - 位置: ({position.get('x', 0)}, {position.get('y', 0)}, {position.get('z', 0)})") + print(f" - 尺寸: {size.get('x', 0)} x {size.get('y', 0)} x {size.get('z', 0)}") + + # 检查孔位信息 + wells = module.get('wells', []) + if wells: + print(f" - 孔位数量: {len(wells)}") + + # 显示前几个和后几个孔位的坐标 + sample_wells = wells[:3] + wells[-3:] if len(wells) > 6 else wells + for well in sample_wells: + well_id = well.get('id', '未知') + well_pos = well.get('position', {}) + print(f" {well_id}: ({well_pos.get('x', 0)}, {well_pos.get('y', 0)}, {well_pos.get('z', 0)})") + else: + print(f" - 无孔位信息") + + return True + +def test_coordinate_ranges(config): + """测试坐标范围的合理性""" + print("\n" + "=" * 50) + print("测试坐标范围合理性") + print("=" * 50) + + if not config: + print("❌ 配置为空,无法测试") + return False + + deck_size = { + 'x': config.get('size_x', 340), + 'y': config.get('size_y', 250), + 'z': config.get('size_z', 160) + } + + print(f"甲板尺寸: {deck_size['x']} x {deck_size['y']} x {deck_size['z']}") + + modules = config.get('children', []) + all_coordinates = [] + + for module in modules: + module_name = module.get('name', '未知模块') + wells = module.get('wells', []) + + for well in wells: + well_pos = well.get('position', {}) + x, y, z = well_pos.get('x', 0), well_pos.get('y', 0), well_pos.get('z', 0) + all_coordinates.append((x, y, z, f"{module_name}:{well.get('id', '未知')}")) + + if not all_coordinates: + print("❌ 没有找到任何坐标信息") + return False + + # 计算坐标范围 + x_coords = [coord[0] for coord in all_coordinates] + y_coords = [coord[1] for coord in all_coordinates] + z_coords = [coord[2] for coord in all_coordinates] + + x_range = (min(x_coords), max(x_coords)) + y_range = (min(y_coords), max(y_coords)) + z_range = (min(z_coords), max(z_coords)) + + print(f"X坐标范围: {x_range[0]:.2f} ~ {x_range[1]:.2f}") + print(f"Y坐标范围: {y_range[0]:.2f} ~ {y_range[1]:.2f}") + print(f"Z坐标范围: {z_range[0]:.2f} ~ {z_range[1]:.2f}") + + # 检查是否超出甲板范围 + issues = [] + if x_range[1] > deck_size['x']: + issues.append(f"X坐标超出甲板范围: {x_range[1]} > {deck_size['x']}") + if y_range[1] > deck_size['y']: + issues.append(f"Y坐标超出甲板范围: {y_range[1]} > {deck_size['y']}") + if z_range[1] > deck_size['z']: + issues.append(f"Z坐标超出甲板范围: {z_range[1]} > {deck_size['z']}") + + if x_range[0] < 0: + issues.append(f"X坐标为负值: {x_range[0]}") + if y_range[0] < 0: + issues.append(f"Y坐标为负值: {y_range[0]}") + if z_range[0] < 0: + issues.append(f"Z坐标为负值: {z_range[0]}") + + if issues: + print("⚠️ 发现坐标问题:") + for issue in issues: + print(f" - {issue}") + return False + else: + print("✅ 所有坐标都在合理范围内") + return True + +def test_well_spacing(config): + """测试孔位间距的一致性""" + print("\n" + "=" * 50) + print("测试孔位间距一致性") + print("=" * 50) + + if not config: + print("❌ 配置为空,无法测试") + return False + + modules = config.get('children', []) + + for module in modules: + module_name = module.get('name', '未知模块') + module_type = module.get('type', '未知类型') + wells = module.get('wells', []) + + if len(wells) < 2: + continue + + print(f"\n模块: {module_name} ({module_type})") + + # 计算相邻孔位的间距 + spacings_x = [] + spacings_y = [] + + # 按行列排序孔位 + wells_by_row = {} + for well in wells: + well_id = well.get('id', '') + if len(well_id) >= 3: # 如A01格式 + row = well_id[0] + col = int(well_id[1:]) + if row not in wells_by_row: + wells_by_row[row] = {} + wells_by_row[row][col] = well + + # 计算同行相邻孔位的X间距 + for row, cols in wells_by_row.items(): + sorted_cols = sorted(cols.keys()) + for i in range(len(sorted_cols) - 1): + col1, col2 = sorted_cols[i], sorted_cols[i + 1] + if col2 == col1 + 1: # 相邻列 + pos1 = cols[col1].get('position', {}) + pos2 = cols[col2].get('position', {}) + spacing = abs(pos2.get('x', 0) - pos1.get('x', 0)) + spacings_x.append(spacing) + + # 计算同列相邻孔位的Y间距 + cols_by_row = {} + for well in wells: + well_id = well.get('id', '') + if len(well_id) >= 3: + row = ord(well_id[0]) - ord('A') + col = int(well_id[1:]) + if col not in cols_by_row: + cols_by_row[col] = {} + cols_by_row[col][row] = well + + for col, rows in cols_by_row.items(): + sorted_rows = sorted(rows.keys()) + for i in range(len(sorted_rows) - 1): + row1, row2 = sorted_rows[i], sorted_rows[i + 1] + if row2 == row1 + 1: # 相邻行 + pos1 = rows[row1].get('position', {}) + pos2 = rows[row2].get('position', {}) + spacing = abs(pos2.get('y', 0) - pos1.get('y', 0)) + spacings_y.append(spacing) + + # 检查间距一致性 + if spacings_x: + avg_x = sum(spacings_x) / len(spacings_x) + max_diff_x = max(abs(s - avg_x) for s in spacings_x) + print(f" - X方向平均间距: {avg_x:.2f}mm, 最大偏差: {max_diff_x:.2f}mm") + + if spacings_y: + avg_y = sum(spacings_y) / len(spacings_y) + max_diff_y = max(abs(s - avg_y) for s in spacings_y) + print(f" - Y方向平均间距: {avg_y:.2f}mm, 最大偏差: {max_diff_y:.2f}mm") + + return True + +def main(): + """主测试函数""" + print("LaiYu液体处理设备配置测试") + print("测试时间:", os.popen('date').read().strip()) + + # 运行所有测试 + tests = [ + ("配置文件加载", test_config_loading), + ] + + config = None + results = [] + + for test_name, test_func in tests: + try: + if test_name == "配置文件加载": + result = test_func() + config = result if result else None + results.append((test_name, bool(result))) + else: + result = test_func(config) + results.append((test_name, result)) + except Exception as e: + print(f"❌ 测试 {test_name} 执行失败: {e}") + results.append((test_name, False)) + + # 如果配置加载成功,运行其他测试 + if config: + additional_tests = [ + ("模块坐标信息", test_module_coordinates), + ("坐标范围合理性", test_coordinate_ranges), + ("孔位间距一致性", test_well_spacing) + ] + + for test_name, test_func in additional_tests: + try: + result = test_func(config) + results.append((test_name, result)) + except Exception as e: + print(f"❌ 测试 {test_name} 执行失败: {e}") + results.append((test_name, False)) + + # 输出测试总结 + print("\n" + "=" * 50) + print("测试总结") + print("=" * 50) + + passed = sum(1 for _, result in results if result) + total = len(results) + + for test_name, result in results: + status = "✅ 通过" if result else "❌ 失败" + print(f" {test_name}: {status}") + + print(f"\n总计: {passed}/{total} 个测试通过") + + if passed == total: + print("🎉 所有测试通过!配置更新成功。") + return True + else: + print("⚠️ 部分测试失败,需要进一步检查。") + return False + +if __name__ == "__main__": + success = main() + sys.exit(0 if success else 1) \ No newline at end of file diff --git a/unilabos/devices/liquid_handling/liquid_handler_abstract.py b/unilabos/devices/liquid_handling/liquid_handler_abstract.py index 6aefaf31..e849ffb9 100644 --- a/unilabos/devices/liquid_handling/liquid_handler_abstract.py +++ b/unilabos/devices/liquid_handling/liquid_handler_abstract.py @@ -7,6 +7,8 @@ from collections import Counter from typing import List, Sequence, Optional, Literal, Union, Iterator, Dict, Any, Callable, Set, cast from pylabrobot.liquid_handling import LiquidHandler, LiquidHandlerBackend, LiquidHandlerChatterboxBackend, Strictness +from unilabos.devices.liquid_handling.rviz_backend import UniLiquidHandlerRvizBackend +from unilabos.devices.liquid_handling.laiyu.backend.laiyu_v_backend import UniLiquidHandlerLaiyuBackend from pylabrobot.liquid_handling.liquid_handler import TipPresenceProbingMethod from pylabrobot.liquid_handling.standard import GripDirection from pylabrobot.resources import ( @@ -29,11 +31,16 @@ from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode class LiquidHandlerMiddleware(LiquidHandler): - def __init__(self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool = False, channel_num: int = 8): + def __init__(self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool = False, channel_num: int = 8, **kwargs): self._simulator = simulator self.channel_num = channel_num + joint_config = kwargs.get("joint_config", None) if simulator: - self._simulate_backend = LiquidHandlerChatterboxBackend(channel_num) + if joint_config: + self._simulate_backend = UniLiquidHandlerRvizBackend(channel_num, kwargs["total_height"], + joint_config=joint_config, lh_device_id=deck.name) + else: + self._simulate_backend = LiquidHandlerChatterboxBackend(channel_num) self._simulate_handler = LiquidHandlerAbstract(self._simulate_backend, deck, False) super().__init__(backend, deck) @@ -140,6 +147,9 @@ class LiquidHandlerMiddleware(LiquidHandler): offsets: Optional[List[Coordinate]] = None, **backend_kwargs, ): + # 如果 use_channels 为 None,使用默认值(所有通道) + if use_channels is None: + use_channels = list(range(self.channel_num)) if not offsets or (isinstance(offsets, list) and len(offsets) != len(use_channels)): offsets = [Coordinate.zero()] * len(use_channels) if self._simulator: @@ -217,7 +227,6 @@ class LiquidHandlerMiddleware(LiquidHandler): offsets, liquid_height, blow_out_air_volume, - spread, **backend_kwargs, ) @@ -753,7 +762,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): blow_out_air_volume=current_dis_blow_out_air_volume, spread=spread, ) - if delays is not None: + if delays is not None and len(delays) > 1: await self.custom_delay(seconds=delays[1]) await self.touch_tip(current_targets) await self.discard_tips() @@ -827,17 +836,19 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): spread=spread, ) - if delays is not None: + if delays is not None and len(delays) > 1: await self.custom_delay(seconds=delays[1]) - await self.mix( - targets=[targets[_]], - mix_time=mix_time, - mix_vol=mix_vol, - offsets=offsets if offsets else None, - height_to_bottom=mix_liquid_height if mix_liquid_height else None, - mix_rate=mix_rate if mix_rate else None, - ) - if delays is not None: + # 只有在 mix_time 有效时才调用 mix + if mix_time is not None and mix_time > 0: + await self.mix( + targets=[targets[_]], + mix_time=mix_time, + mix_vol=mix_vol, + offsets=offsets if offsets else None, + height_to_bottom=mix_liquid_height if mix_liquid_height else None, + mix_rate=mix_rate if mix_rate else None, + ) + if delays is not None and len(delays) > 1: await self.custom_delay(seconds=delays[1]) await self.touch_tip(targets[_]) await self.discard_tips() @@ -887,18 +898,20 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): blow_out_air_volume=current_dis_blow_out_air_volume, spread=spread, ) - if delays is not None: + if delays is not None and len(delays) > 1: await self.custom_delay(seconds=delays[1]) - await self.mix( - targets=current_targets, - mix_time=mix_time, - mix_vol=mix_vol, - offsets=offsets if offsets else None, - height_to_bottom=mix_liquid_height if mix_liquid_height else None, - mix_rate=mix_rate if mix_rate else None, - ) - if delays is not None: + # 只有在 mix_time 有效时才调用 mix + if mix_time is not None and mix_time > 0: + await self.mix( + targets=current_targets, + mix_time=mix_time, + mix_vol=mix_vol, + offsets=offsets if offsets else None, + height_to_bottom=mix_liquid_height if mix_liquid_height else None, + mix_rate=mix_rate if mix_rate else None, + ) + if delays is not None and len(delays) > 1: await self.custom_delay(seconds=delays[1]) await self.touch_tip(current_targets) await self.discard_tips() @@ -936,60 +949,158 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): delays: Optional[List[int]] = None, none_keys: List[str] = [], ): - """Transfer liquid from each *source* well/plate to the corresponding *target*. + """Transfer liquid with automatic mode detection. + + Supports three transfer modes: + 1. One-to-many (1 source -> N targets): Distribute from one source to multiple targets + 2. One-to-one (N sources -> N targets): Standard transfer, each source to corresponding target + 3. Many-to-one (N sources -> 1 target): Combine multiple sources into one target Parameters ---------- asp_vols, dis_vols - Single volume (µL) or list matching the number of transfers. + Single volume (µL) or list. Automatically expanded based on transfer mode. sources, targets - Same‑length sequences of containers (wells or plates). In 96‑well mode - each must contain exactly one plate. + Containers (wells or plates). Length determines transfer mode: + - len(sources) == 1, len(targets) > 1: One-to-many mode + - len(sources) == len(targets): One-to-one mode + - len(sources) > 1, len(targets) == 1: Many-to-one mode tip_racks One or more TipRacks providing fresh tips. is_96_well Set *True* to use the 96‑channel head. """ - - + + # 确保 use_channels 有默认值 + if use_channels is None: + use_channels = [0] if self.channel_num >= 1 else list(range(self.channel_num)) + if is_96_well: pass # This mode is not verified. else: - if len(asp_vols) != len(targets): - raise ValueError(f"Length of `asp_vols` {len(asp_vols)} must match `targets` {len(targets)}.") + # 转换体积参数为列表 + if isinstance(asp_vols, (int, float)): + asp_vols = [float(asp_vols)] + else: + asp_vols = [float(v) for v in asp_vols] + + if isinstance(dis_vols, (int, float)): + dis_vols = [float(dis_vols)] + else: + dis_vols = [float(v) for v in dis_vols] - # 首先应该对任务分组,然后每次1个/8个进行操作处理 - if len(use_channels) == 1: - for _ in range(len(targets)): - tip = [] - for ___ in range(len(use_channels)): - tip.extend(next(self.current_tip)) - await self.pick_up_tips(tip) + # 统一混合次数为标量,防止数组/列表与 int 比较时报错 + if mix_times is not None and not isinstance(mix_times, (int, float)): + try: + mix_times = mix_times[0] if len(mix_times) > 0 else None + except Exception: + try: + mix_times = next(iter(mix_times)) + except Exception: + pass + if mix_times is not None: + mix_times = int(mix_times) + + # 识别传输模式 + num_sources = len(sources) + num_targets = len(targets) + + if num_sources == 1 and num_targets > 1: + # 模式1: 一对多 (1 source -> N targets) + await self._transfer_one_to_many( + sources[0], targets, tip_racks, use_channels, + asp_vols, dis_vols, asp_flow_rates, dis_flow_rates, + offsets, touch_tip, liquid_height, blow_out_air_volume, + spread, mix_stage, mix_times, mix_vol, mix_rate, + mix_liquid_height, delays + ) + elif num_sources > 1 and num_targets == 1: + # 模式2: 多对一 (N sources -> 1 target) + await self._transfer_many_to_one( + sources, targets[0], tip_racks, use_channels, + asp_vols, dis_vols, asp_flow_rates, dis_flow_rates, + offsets, touch_tip, liquid_height, blow_out_air_volume, + spread, mix_stage, mix_times, mix_vol, mix_rate, + mix_liquid_height, delays + ) + elif num_sources == num_targets: + # 模式3: 一对一 (N sources -> N targets) - 原有逻辑 + await self._transfer_one_to_one( + sources, targets, tip_racks, use_channels, + asp_vols, dis_vols, asp_flow_rates, dis_flow_rates, + offsets, touch_tip, liquid_height, blow_out_air_volume, + spread, mix_stage, mix_times, mix_vol, mix_rate, + mix_liquid_height, delays + ) + else: + raise ValueError( + f"Unsupported transfer mode: {num_sources} sources -> {num_targets} targets. " + "Supported modes: 1->N, N->1, or N->N." + ) - await self.aspirate( - resources=[sources[_]], - vols=[asp_vols[_]], - use_channels=use_channels, - flow_rates=[asp_flow_rates[0]] if asp_flow_rates else None, - offsets=[offsets[0]] if offsets else None, - liquid_height=[liquid_height[0]] if liquid_height else None, - blow_out_air_volume=[blow_out_air_volume[0]] if blow_out_air_volume else None, - spread=spread, - ) - if delays is not None: - await self.custom_delay(seconds=delays[0]) - await self.dispense( - resources=[targets[_]], - vols=[dis_vols[_]], - use_channels=use_channels, - flow_rates=[dis_flow_rates[1]] if dis_flow_rates else None, - offsets=[offsets[1]] if offsets else None, - blow_out_air_volume=[blow_out_air_volume[1]] if blow_out_air_volume else None, - liquid_height=[liquid_height[1]] if liquid_height else None, - spread=spread, - ) - if delays is not None: - await self.custom_delay(seconds=delays[1]) + async def _transfer_one_to_one( + self, + sources: Sequence[Container], + targets: Sequence[Container], + tip_racks: Sequence[TipRack], + use_channels: List[int], + asp_vols: List[float], + dis_vols: List[float], + asp_flow_rates: Optional[List[Optional[float]]], + dis_flow_rates: Optional[List[Optional[float]]], + offsets: Optional[List[Coordinate]], + touch_tip: bool, + liquid_height: Optional[List[Optional[float]]], + blow_out_air_volume: Optional[List[Optional[float]]], + spread: Literal["wide", "tight", "custom"], + mix_stage: Optional[Literal["none", "before", "after", "both"]], + mix_times: Optional[int], + mix_vol: Optional[int], + mix_rate: Optional[int], + mix_liquid_height: Optional[float], + delays: Optional[List[int]], + ): + """一对一传输模式:N sources -> N targets""" + # 验证参数长度 + if len(asp_vols) != len(targets): + raise ValueError(f"Length of `asp_vols` {len(asp_vols)} must match `targets` {len(targets)}.") + if len(dis_vols) != len(targets): + raise ValueError(f"Length of `dis_vols` {len(dis_vols)} must match `targets` {len(targets)}.") + if len(sources) != len(targets): + raise ValueError(f"Length of `sources` {len(sources)} must match `targets` {len(targets)}.") + + if len(use_channels) == 1: + for _ in range(len(targets)): + tip = [] + for ___ in range(len(use_channels)): + tip.extend(next(self.current_tip)) + await self.pick_up_tips(tip) + + await self.aspirate( + resources=[sources[_]], + vols=[asp_vols[_]], + use_channels=use_channels, + flow_rates=[asp_flow_rates[_]] if asp_flow_rates and len(asp_flow_rates) > _ else None, + offsets=[offsets[_]] if offsets and len(offsets) > _ else None, + liquid_height=[liquid_height[_]] if liquid_height and len(liquid_height) > _ else None, + blow_out_air_volume=[blow_out_air_volume[_]] if blow_out_air_volume and len(blow_out_air_volume) > _ else None, + spread=spread, + ) + if delays is not None: + await self.custom_delay(seconds=delays[0]) + await self.dispense( + resources=[targets[_]], + vols=[dis_vols[_]], + use_channels=use_channels, + flow_rates=[dis_flow_rates[_]] if dis_flow_rates and len(dis_flow_rates) > _ else None, + offsets=[offsets[_]] if offsets and len(offsets) > _ else None, + blow_out_air_volume=[blow_out_air_volume[_]] if blow_out_air_volume and len(blow_out_air_volume) > _ else None, + liquid_height=[liquid_height[_]] if liquid_height and len(liquid_height) > _ else None, + spread=spread, + ) + if delays is not None and len(delays) > 1: + await self.custom_delay(seconds=delays[1]) + if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0: await self.mix( targets=[targets[_]], mix_time=mix_times, @@ -998,63 +1109,60 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): height_to_bottom=mix_liquid_height if mix_liquid_height else None, mix_rate=mix_rate if mix_rate else None, ) - if delays is not None: - await self.custom_delay(seconds=delays[1]) - await self.touch_tip(targets[_]) - await self.discard_tips() + if delays is not None and len(delays) > 1: + await self.custom_delay(seconds=delays[1]) + await self.touch_tip(targets[_]) + await self.discard_tips(use_channels=use_channels) - elif len(use_channels) == 8: - # 对于8个的情况,需要判断此时任务是不是能被8通道移液站来成功处理 - if len(targets) % 8 != 0: - raise ValueError(f"Length of `targets` {len(targets)} must be a multiple of 8 for 8-channel mode.") + elif len(use_channels) == 8: + if len(targets) % 8 != 0: + raise ValueError(f"Length of `targets` {len(targets)} must be a multiple of 8 for 8-channel mode.") - # 8个8个来取任务序列 + for i in range(0, len(targets), 8): + tip = [] + for _ in range(len(use_channels)): + tip.extend(next(self.current_tip)) + await self.pick_up_tips(tip) + current_targets = targets[i:i + 8] + current_reagent_sources = sources[i:i + 8] + current_asp_vols = asp_vols[i:i + 8] + current_dis_vols = dis_vols[i:i + 8] + current_asp_flow_rates = asp_flow_rates[i:i + 8] if asp_flow_rates else None + current_asp_offset = offsets[i:i + 8] if offsets else [None] * 8 + current_dis_offset = offsets[i:i + 8] if offsets else [None] * 8 + current_asp_liquid_height = liquid_height[i:i + 8] if liquid_height else [None] * 8 + current_dis_liquid_height = liquid_height[i:i + 8] if liquid_height else [None] * 8 + current_asp_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8 + current_dis_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8 + current_dis_flow_rates = dis_flow_rates[i:i + 8] if dis_flow_rates else None - for i in range(0, len(targets), 8): - # 取出8个任务 - tip = [] - for _ in range(len(use_channels)): - tip.extend(next(self.current_tip)) - await self.pick_up_tips(tip) - current_targets = targets[i:i + 8] - current_reagent_sources = sources[i:i + 8] - current_asp_vols = asp_vols[i:i + 8] - current_dis_vols = dis_vols[i:i + 8] - current_asp_flow_rates = asp_flow_rates[i:i + 8] - current_asp_offset = offsets[i:i + 8] if offsets else [None] * 8 - current_dis_offset = offsets[-i*8-8:len(offsets)-i*8] if offsets else [None] * 8 - current_asp_liquid_height = liquid_height[i:i + 8] if liquid_height else [None] * 8 - current_dis_liquid_height = liquid_height[-i*8-8:len(liquid_height)-i*8] if liquid_height else [None] * 8 - current_asp_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8 - current_dis_blow_out_air_volume = blow_out_air_volume[-i*8-8:len(blow_out_air_volume)-i*8] if blow_out_air_volume else [None] * 8 - current_dis_flow_rates = dis_flow_rates[i:i + 8] if dis_flow_rates else [None] * 8 + await self.aspirate( + resources=current_reagent_sources, + vols=current_asp_vols, + use_channels=use_channels, + flow_rates=current_asp_flow_rates, + offsets=current_asp_offset, + blow_out_air_volume=current_asp_blow_out_air_volume, + liquid_height=current_asp_liquid_height, + spread=spread, + ) - await self.aspirate( - resources=current_reagent_sources, - vols=current_asp_vols, - use_channels=use_channels, - flow_rates=current_asp_flow_rates, - offsets=current_asp_offset, - blow_out_air_volume=current_asp_blow_out_air_volume, - liquid_height=current_asp_liquid_height, - spread=spread, - ) - - if delays is not None: - await self.custom_delay(seconds=delays[0]) - await self.dispense( - resources=current_targets, - vols=current_dis_vols, - use_channels=use_channels, - flow_rates=current_dis_flow_rates, - offsets=current_dis_offset, - blow_out_air_volume=current_dis_blow_out_air_volume, - liquid_height=current_dis_liquid_height, - spread=spread, - ) - if delays is not None: - await self.custom_delay(seconds=delays[1]) + if delays is not None: + await self.custom_delay(seconds=delays[0]) + await self.dispense( + resources=current_targets, + vols=current_dis_vols, + use_channels=use_channels, + flow_rates=current_dis_flow_rates, + offsets=current_dis_offset, + blow_out_air_volume=current_dis_blow_out_air_volume, + liquid_height=current_dis_liquid_height, + spread=spread, + ) + if delays is not None and len(delays) > 1: + await self.custom_delay(seconds=delays[1]) + if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0: await self.mix( targets=current_targets, mix_time=mix_times, @@ -1063,10 +1171,363 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): height_to_bottom=mix_liquid_height if mix_liquid_height else None, mix_rate=mix_rate if mix_rate else None, ) - if delays is not None: - await self.custom_delay(seconds=delays[1]) + if delays is not None and len(delays) > 1: + await self.custom_delay(seconds=delays[1]) + await self.touch_tip(current_targets) + await self.discard_tips([0,1,2,3,4,5,6,7]) + + async def _transfer_one_to_many( + self, + source: Container, + targets: Sequence[Container], + tip_racks: Sequence[TipRack], + use_channels: List[int], + asp_vols: List[float], + dis_vols: List[float], + asp_flow_rates: Optional[List[Optional[float]]], + dis_flow_rates: Optional[List[Optional[float]]], + offsets: Optional[List[Coordinate]], + touch_tip: bool, + liquid_height: Optional[List[Optional[float]]], + blow_out_air_volume: Optional[List[Optional[float]]], + spread: Literal["wide", "tight", "custom"], + mix_stage: Optional[Literal["none", "before", "after", "both"]], + mix_times: Optional[int], + mix_vol: Optional[int], + mix_rate: Optional[int], + mix_liquid_height: Optional[float], + delays: Optional[List[int]], + ): + """一对多传输模式:1 source -> N targets""" + # 验证和扩展体积参数 + if len(asp_vols) == 1: + # 如果只提供一个吸液体积,计算总吸液体积(所有分液体积之和) + total_asp_vol = sum(dis_vols) + asp_vol = asp_vols[0] if asp_vols[0] >= total_asp_vol else total_asp_vol + else: + raise ValueError("For one-to-many mode, `asp_vols` should be a single value or list with one element.") + + if len(dis_vols) != len(targets): + raise ValueError(f"Length of `dis_vols` {len(dis_vols)} must match `targets` {len(targets)}.") + + if len(use_channels) == 1: + # 单通道模式:一次吸液,多次分液 + tip = [] + for _ in range(len(use_channels)): + tip.extend(next(self.current_tip)) + await self.pick_up_tips(tip) + + # 从源容器吸液(总体积) + await self.aspirate( + resources=[source], + vols=[asp_vol], + use_channels=use_channels, + flow_rates=[asp_flow_rates[0]] if asp_flow_rates and len(asp_flow_rates) > 0 else None, + offsets=[offsets[0]] if offsets and len(offsets) > 0 else None, + liquid_height=[liquid_height[0]] if liquid_height and len(liquid_height) > 0 else None, + blow_out_air_volume=[blow_out_air_volume[0]] if blow_out_air_volume and len(blow_out_air_volume) > 0 else None, + spread=spread, + ) + + if delays is not None: + await self.custom_delay(seconds=delays[0]) + + # 分多次分液到不同的目标容器 + for idx, target in enumerate(targets): + await self.dispense( + resources=[target], + vols=[dis_vols[idx]], + use_channels=use_channels, + flow_rates=[dis_flow_rates[idx]] if dis_flow_rates and len(dis_flow_rates) > idx else None, + offsets=[offsets[idx]] if offsets and len(offsets) > idx else None, + blow_out_air_volume=[blow_out_air_volume[idx]] if blow_out_air_volume and len(blow_out_air_volume) > idx else None, + liquid_height=[liquid_height[idx]] if liquid_height and len(liquid_height) > idx else None, + spread=spread, + ) + if delays is not None and len(delays) > 1: + await self.custom_delay(seconds=delays[1]) + if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0: + await self.mix( + targets=[target], + mix_time=mix_times, + mix_vol=mix_vol, + offsets=offsets[idx:idx+1] if offsets else None, + height_to_bottom=mix_liquid_height if mix_liquid_height else None, + mix_rate=mix_rate if mix_rate else None, + ) + if touch_tip: + await self.touch_tip([target]) + + await self.discard_tips(use_channels=use_channels) + + elif len(use_channels) == 8: + # 8通道模式:需要确保目标数量是8的倍数 + if len(targets) % 8 != 0: + raise ValueError(f"For 8-channel mode, number of targets {len(targets)} must be a multiple of 8.") + + # 每次处理8个目标 + for i in range(0, len(targets), 8): + tip = [] + for _ in range(len(use_channels)): + tip.extend(next(self.current_tip)) + await self.pick_up_tips(tip) + + current_targets = targets[i:i + 8] + current_dis_vols = dis_vols[i:i + 8] + + # 8个通道都从同一个源容器吸液,每个通道的吸液体积等于对应的分液体积 + current_asp_flow_rates = asp_flow_rates[0:1] * 8 if asp_flow_rates and len(asp_flow_rates) > 0 else None + current_asp_offset = offsets[0:1] * 8 if offsets and len(offsets) > 0 else [None] * 8 + current_asp_liquid_height = liquid_height[0:1] * 8 if liquid_height and len(liquid_height) > 0 else [None] * 8 + current_asp_blow_out_air_volume = blow_out_air_volume[0:1] * 8 if blow_out_air_volume and len(blow_out_air_volume) > 0 else [None] * 8 + + # 从源容器吸液(8个通道都从同一个源,但每个通道的吸液体积不同) + await self.aspirate( + resources=[source] * 8, # 8个通道都从同一个源 + vols=current_dis_vols, # 每个通道的吸液体积等于对应的分液体积 + use_channels=use_channels, + flow_rates=current_asp_flow_rates, + offsets=current_asp_offset, + liquid_height=current_asp_liquid_height, + blow_out_air_volume=current_asp_blow_out_air_volume, + spread=spread, + ) + + if delays is not None: + await self.custom_delay(seconds=delays[0]) + + # 分液到8个目标 + current_dis_flow_rates = dis_flow_rates[i:i + 8] if dis_flow_rates else None + current_dis_offset = offsets[i:i + 8] if offsets else [None] * 8 + current_dis_liquid_height = liquid_height[i:i + 8] if liquid_height else [None] * 8 + current_dis_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8 + + await self.dispense( + resources=current_targets, + vols=current_dis_vols, + use_channels=use_channels, + flow_rates=current_dis_flow_rates, + offsets=current_dis_offset, + blow_out_air_volume=current_dis_blow_out_air_volume, + liquid_height=current_dis_liquid_height, + spread=spread, + ) + + if delays is not None and len(delays) > 1: + await self.custom_delay(seconds=delays[1]) + + if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0: + await self.mix( + targets=current_targets, + mix_time=mix_times, + mix_vol=mix_vol, + offsets=offsets if offsets else None, + height_to_bottom=mix_liquid_height if mix_liquid_height else None, + mix_rate=mix_rate if mix_rate else None, + ) + + if touch_tip: await self.touch_tip(current_targets) - await self.discard_tips([0,1,2,3,4,5,6,7]) + + await self.discard_tips([0,1,2,3,4,5,6,7]) + + async def _transfer_many_to_one( + self, + sources: Sequence[Container], + target: Container, + tip_racks: Sequence[TipRack], + use_channels: List[int], + asp_vols: List[float], + dis_vols: List[float], + asp_flow_rates: Optional[List[Optional[float]]], + dis_flow_rates: Optional[List[Optional[float]]], + offsets: Optional[List[Coordinate]], + touch_tip: bool, + liquid_height: Optional[List[Optional[float]]], + blow_out_air_volume: Optional[List[Optional[float]]], + spread: Literal["wide", "tight", "custom"], + mix_stage: Optional[Literal["none", "before", "after", "both"]], + mix_times: Optional[int], + mix_vol: Optional[int], + mix_rate: Optional[int], + mix_liquid_height: Optional[float], + delays: Optional[List[int]], + ): + """多对一传输模式:N sources -> 1 target(汇总/混合)""" + # 验证和扩展体积参数 + if len(asp_vols) != len(sources): + raise ValueError(f"Length of `asp_vols` {len(asp_vols)} must match `sources` {len(sources)}.") + + # 支持两种模式: + # 1. dis_vols 为单个值:所有源汇总,使用总吸液体积或指定分液体积 + # 2. dis_vols 长度等于 asp_vols:每个源按不同比例分液(按比例混合) + if len(dis_vols) == 1: + # 模式1:使用单个分液体积 + total_dis_vol = sum(asp_vols) + dis_vol = dis_vols[0] if dis_vols[0] >= total_dis_vol else total_dis_vol + use_proportional_mixing = False + elif len(dis_vols) == len(asp_vols): + # 模式2:按不同比例混合 + use_proportional_mixing = True + else: + raise ValueError( + f"For many-to-one mode, `dis_vols` should be a single value or list with length {len(asp_vols)} " + f"(matching `asp_vols`). Got length {len(dis_vols)}." + ) + + if len(use_channels) == 1: + # 单通道模式:多次吸液,一次分液 + # 先混合前(如果需要) + if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0: + # 注意:在吸液前混合源容器通常不常见,这里跳过 + pass + + # 从每个源容器吸液并分液到目标容器 + for idx, source in enumerate(sources): + tip = [] + for _ in range(len(use_channels)): + tip.extend(next(self.current_tip)) + await self.pick_up_tips(tip) + + await self.aspirate( + resources=[source], + vols=[asp_vols[idx]], + use_channels=use_channels, + flow_rates=[asp_flow_rates[idx]] if asp_flow_rates and len(asp_flow_rates) > idx else None, + offsets=[offsets[idx]] if offsets and len(offsets) > idx else None, + liquid_height=[liquid_height[idx]] if liquid_height and len(liquid_height) > idx else None, + blow_out_air_volume=[blow_out_air_volume[idx]] if blow_out_air_volume and len(blow_out_air_volume) > idx else None, + spread=spread, + ) + + if delays is not None: + await self.custom_delay(seconds=delays[0]) + + # 分液到目标容器 + if use_proportional_mixing: + # 按不同比例混合:使用对应的 dis_vols + dis_vol = dis_vols[idx] + dis_flow_rate = dis_flow_rates[idx] if dis_flow_rates and len(dis_flow_rates) > idx else None + dis_offset = offsets[idx] if offsets and len(offsets) > idx else None + dis_liquid_height = liquid_height[idx] if liquid_height and len(liquid_height) > idx else None + dis_blow_out = blow_out_air_volume[idx] if blow_out_air_volume and len(blow_out_air_volume) > idx else None + else: + # 标准模式:分液体积等于吸液体积 + dis_vol = asp_vols[idx] + dis_flow_rate = dis_flow_rates[0] if dis_flow_rates and len(dis_flow_rates) > 0 else None + dis_offset = offsets[0] if offsets and len(offsets) > 0 else None + dis_liquid_height = liquid_height[0] if liquid_height and len(liquid_height) > 0 else None + dis_blow_out = blow_out_air_volume[0] if blow_out_air_volume and len(blow_out_air_volume) > 0 else None + + await self.dispense( + resources=[target], + vols=[dis_vol], + use_channels=use_channels, + flow_rates=[dis_flow_rate] if dis_flow_rate is not None else None, + offsets=[dis_offset] if dis_offset is not None else None, + blow_out_air_volume=[dis_blow_out] if dis_blow_out is not None else None, + liquid_height=[dis_liquid_height] if dis_liquid_height is not None else None, + spread=spread, + ) + + if delays is not None and len(delays) > 1: + await self.custom_delay(seconds=delays[1]) + + await self.discard_tips(use_channels=use_channels) + + # 最后在目标容器中混合(如果需要) + if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0: + await self.mix( + targets=[target], + mix_time=mix_times, + mix_vol=mix_vol, + offsets=offsets[0:1] if offsets else None, + height_to_bottom=mix_liquid_height if mix_liquid_height else None, + mix_rate=mix_rate if mix_rate else None, + ) + + if touch_tip: + await self.touch_tip([target]) + + elif len(use_channels) == 8: + # 8通道模式:需要确保源数量是8的倍数 + if len(sources) % 8 != 0: + raise ValueError(f"For 8-channel mode, number of sources {len(sources)} must be a multiple of 8.") + + # 每次处理8个源 + for i in range(0, len(sources), 8): + tip = [] + for _ in range(len(use_channels)): + tip.extend(next(self.current_tip)) + await self.pick_up_tips(tip) + + current_sources = sources[i:i + 8] + current_asp_vols = asp_vols[i:i + 8] + current_asp_flow_rates = asp_flow_rates[i:i + 8] if asp_flow_rates else None + current_asp_offset = offsets[i:i + 8] if offsets else [None] * 8 + current_asp_liquid_height = liquid_height[i:i + 8] if liquid_height else [None] * 8 + current_asp_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8 + + # 从8个源容器吸液 + await self.aspirate( + resources=current_sources, + vols=current_asp_vols, + use_channels=use_channels, + flow_rates=current_asp_flow_rates, + offsets=current_asp_offset, + blow_out_air_volume=current_asp_blow_out_air_volume, + liquid_height=current_asp_liquid_height, + spread=spread, + ) + + if delays is not None: + await self.custom_delay(seconds=delays[0]) + + # 分液到目标容器(每个通道分液到同一个目标) + if use_proportional_mixing: + # 按比例混合:使用对应的 dis_vols + current_dis_vols = dis_vols[i:i + 8] + current_dis_flow_rates = dis_flow_rates[i:i + 8] if dis_flow_rates else None + current_dis_offset = offsets[i:i + 8] if offsets else [None] * 8 + current_dis_liquid_height = liquid_height[i:i + 8] if liquid_height else [None] * 8 + current_dis_blow_out_air_volume = blow_out_air_volume[i:i + 8] if blow_out_air_volume else [None] * 8 + else: + # 标准模式:每个通道分液体积等于其吸液体积 + current_dis_vols = current_asp_vols + current_dis_flow_rates = dis_flow_rates[0:1] * 8 if dis_flow_rates else None + current_dis_offset = offsets[0:1] * 8 if offsets else [None] * 8 + current_dis_liquid_height = liquid_height[0:1] * 8 if liquid_height else [None] * 8 + current_dis_blow_out_air_volume = blow_out_air_volume[0:1] * 8 if blow_out_air_volume else [None] * 8 + + await self.dispense( + resources=[target] * 8, # 8个通道都分到同一个目标 + vols=current_dis_vols, + use_channels=use_channels, + flow_rates=current_dis_flow_rates, + offsets=current_dis_offset, + blow_out_air_volume=current_dis_blow_out_air_volume, + liquid_height=current_dis_liquid_height, + spread=spread, + ) + + if delays is not None and len(delays) > 1: + await self.custom_delay(seconds=delays[1]) + + await self.discard_tips([0,1,2,3,4,5,6,7]) + + # 最后在目标容器中混合(如果需要) + if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0: + await self.mix( + targets=[target], + mix_time=mix_times, + mix_vol=mix_vol, + offsets=offsets[0:1] if offsets else None, + height_to_bottom=mix_liquid_height if mix_liquid_height else None, + mix_rate=mix_rate if mix_rate else None, + ) + + if touch_tip: + await self.touch_tip([target]) # except Exception as e: # traceback.print_exc() diff --git a/unilabos/devices/liquid_handling/prcxi/deck.json b/unilabos/devices/liquid_handling/prcxi/deck.json index a892941b..bc52e1db 100644 --- a/unilabos/devices/liquid_handling/prcxi/deck.json +++ b/unilabos/devices/liquid_handling/prcxi/deck.json @@ -41151,5 +41151,6 @@ "uuid": "730067cf07ae43849ddf4034299030e9" } } - } -] \ No newline at end of file + ], + "links": [] +} \ No newline at end of file diff --git a/unilabos/devices/liquid_handling/prcxi/prcxi.py b/unilabos/devices/liquid_handling/prcxi/prcxi.py index a8677f49..cd20f4c9 100644 --- a/unilabos/devices/liquid_handling/prcxi/prcxi.py +++ b/unilabos/devices/liquid_handling/prcxi/prcxi.py @@ -5,6 +5,7 @@ import json import os import socket import time +import uuid from typing import Any, List, Dict, Optional, Tuple, TypedDict, Union, Sequence, Iterator, Literal from pylabrobot.liquid_handling import ( @@ -65,7 +66,7 @@ class PRCXI9300Deck(Deck): 该类定义了 PRCXI 9300 的工作台布局和槽位信息。 """ - def __init__(self, name: str, size_x: float, size_y: float, size_z: float): + def __init__(self, name: str, size_x: float, size_y: float, size_z: float, **kwargs): super().__init__(name, size_x, size_y, size_z) self.slots = [None] * 6 # PRCXI 9300 有 6 个槽位 @@ -85,6 +86,7 @@ class PRCXI9300Container(Plate, TipRack): category: str, ordering: collections.OrderedDict, model: Optional[str] = None, + **kwargs, ): super().__init__(name, size_x, size_y, size_z, category=category, ordering=ordering, model=model) self._unilabos_state = {} @@ -145,6 +147,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract): setup=True, debug=False, simulator=False, + step_mode=False, matrix_id="", is_9320=False, ): @@ -158,6 +161,13 @@ class PRCXI9300Handler(LiquidHandlerAbstract): ) if is_9320: print("当前设备是9320") + # 始终初始化 step_mode 属性 + self.step_mode = False + if step_mode: + if is_9320: + self.step_mode = step_mode + else: + print("9300设备不支持 单点动作模式") self._unilabos_backend = PRCXI9300Backend( tablets_info, host, port, timeout, channel_num, axis, setup, debug, matrix_id, is_9320 ) @@ -344,6 +354,10 @@ class PRCXI9300Handler(LiquidHandlerAbstract): offsets: Optional[List[Coordinate]] = None, **backend_kwargs, ): + if self.step_mode: + await self.create_protocol(f"单点动作{time.time()}") + await super().pick_up_tips(tip_spots, use_channels, offsets, **backend_kwargs) + await self.run_protocol() return await super().pick_up_tips(tip_spots, use_channels, offsets, **backend_kwargs) async def aspirate( @@ -506,10 +520,26 @@ class PRCXI9300Backend(LiquidHandlerBackend): await super().setup() try: if self._execute_setup: + # 先获取错误代码 + error_code = self.api_client.get_error_code() + if error_code: + print(f"PRCXI9300 error code detected: {error_code}") + + # 清除错误代码 + self.api_client.clear_error_code() + print("PRCXI9300 error code cleared.") + + # 执行重置 + print("Starting PRCXI9300 reset...") self.api_client.call("IAutomation", "Reset") + + # 检查重置状态并等待完成 while not self.is_reset_ok: print("Waiting for PRCXI9300 to reset...") - await self._ros_node.sleep(1) + if hasattr(self, '_ros_node') and self._ros_node is not None: + await self._ros_node.sleep(1) + else: + await asyncio.sleep(1) print("PRCXI9300 reset successfully.") except ConnectionRefusedError as e: raise RuntimeError( @@ -827,7 +857,30 @@ class PRCXI9300Api: def _raw_request(self, payload: str) -> str: if self.debug: - return " " + # 调试/仿真模式下直接返回可解析的模拟 JSON,避免后续 json.loads 报错 + try: + req = json.loads(payload) + method = req.get("MethodName") + except Exception: + method = None + + data: Any = True + if method in {"AddSolution"}: + data = str(uuid.uuid4()) + elif method in {"AddWorkTabletMatrix", "AddWorkTabletMatrix2"}: + data = {"Success": True, "Message": "debug mock"} + elif method in {"GetErrorCode"}: + data = "" + elif method in {"RemoveErrorCodet", "Reset", "Start", "LoadSolution", "Pause", "Resume", "Stop"}: + data = True + elif method in {"GetStepStateList", "GetStepStatus", "GetStepState"}: + data = [] + elif method in {"GetLocation"}: + data = {"X": 0, "Y": 0, "Z": 0} + elif method in {"GetResetStatus"}: + data = False + + return json.dumps({"Success": True, "Msg": "debug mock", "Data": data}) with contextlib.closing(socket.socket()) as sock: sock.settimeout(self.timeout) sock.connect((self.host, self.port)) @@ -1138,7 +1191,7 @@ class DefaultLayout: self.waste_liquid_slot = 6 elif product_name == "PRCXI9320": - self.rows = 3 + self.rows = 4 self.columns = 4 self.layout = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16] self.trash_slot = 16 @@ -1694,11 +1747,47 @@ if __name__ == "__main__": A = tree_to_list([resource_plr_to_ulab(deck)]) with open("deck.json", "w", encoding="utf-8") as f: - json.dump(A, f, indent=4, ensure_ascii=False) + A.insert(0, { + "id": "PRCXI", + "name": "PRCXI", + "parent": None, + "type": "device", + "class": "liquid_handler.prcxi", + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "config": { + "deck": { + "_resource_child_name": "PRCXI_Deck", + "_resource_type": "unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Deck" + }, + "host": "192.168.0.121", + "port": 9999, + "timeout": 10.0, + "axis": "Right", + "channel_num": 1, + "setup": False, + "debug": True, + "simulator": True, + "matrix_id": "5de524d0-3f95-406c-86dd-f83626ebc7cb", + "is_9320": True + }, + "data": {}, + "children": [ + "PRCXI_Deck" + ] + }) + A[1]["parent"] = "PRCXI" + json.dump({ + "nodes": A, + "links": [] + }, f, indent=4, ensure_ascii=False) handler = PRCXI9300Handler( deck=deck, - host="192.168.0.121", + host="192.168.1.201", port=9999, timeout=10.0, setup=True, @@ -1735,6 +1824,12 @@ if __name__ == "__main__": asyncio.run(handler.run_protocol()) time.sleep(5) os._exit(0) + + + prcxi_api = PRCXI9300Api(host="192.168.0.121", port=9999) + prcxi_api.list_matrices() + prcxi_api.get_all_materials() + # 第一种情景:一个孔往多个孔加液 # plate_2_liquids = handler.set_group("water", [plate2.children[0]], [300]) # plate5_liquids = handler.set_group("master_mix", plate5.children[:23], [100]*23) diff --git a/unilabos/devices/liquid_handling/prcxi/prcxi_materials.py b/unilabos/devices/liquid_handling/prcxi/prcxi_materials.py new file mode 100644 index 00000000..097ee0a1 --- /dev/null +++ b/unilabos/devices/liquid_handling/prcxi/prcxi_materials.py @@ -0,0 +1,21 @@ +import collections +import json +from pathlib import Path + +from unilabos.devices.liquid_handling.prcxi.prcxi import PRCXI9300Container + + +prcxi_materials_path = str(Path(__file__).parent / "prcxi_material.json") +with open(prcxi_materials_path, mode="r", encoding="utf-8") as f: + prcxi_materials = json.loads(f.read()) + + +def tip_adaptor_1250ul(name="Tip头适配器 1250uL") -> PRCXI9300Container: # 必须传入一个name参数,是plr的规范要求 + # tip_rack = PRCXI9300Container(name, prcxi_materials["name"]["Height"]) + tip_rack = PRCXI9300Container(name, 1000,400,800, "tip_rack", collections.OrderedDict()) + tip_rack.load_state({ + "Materials": {"uuid": "7960f49ddfe9448abadda89bd1556936", "materialEnum": "0"} + }) + return tip_rack + + diff --git a/unilabos/devices/liquid_handling/rviz_backend.py b/unilabos/devices/liquid_handling/rviz_backend.py index 05078a13..3bd2c2f8 100644 --- a/unilabos/devices/liquid_handling/rviz_backend.py +++ b/unilabos/devices/liquid_handling/rviz_backend.py @@ -1,5 +1,6 @@ - + import json +import threading from typing import List, Optional, Union from pylabrobot.liquid_handling.backends.backend import ( @@ -30,7 +31,7 @@ from rclpy.action import ActionClient from unilabos_msgs.action import SendCmd import re -from unilabos.devices.ros_dev.liquid_handler_joint_publisher import JointStatePublisher +from unilabos.devices.ros_dev.liquid_handler_joint_publisher_node import LiquidHandlerJointPublisher class UniLiquidHandlerRvizBackend(LiquidHandlerBackend): @@ -48,27 +49,44 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend): _max_volume_length = 16 _fitting_depth_length = 20 _tip_length_length = 16 - # _pickup_method_length = 20 _filter_length = 10 - def __init__(self, num_channels: int = 8 , tip_length: float = 0 , total_height: float = 310): + def __init__(self, num_channels: int = 8 , tip_length: float = 0 , total_height: float = 310, **kwargs): """Initialize a chatter box backend.""" super().__init__() self._num_channels = num_channels self.tip_length = tip_length self.total_height = total_height -# rclpy.init() + self.joint_config = kwargs.get("joint_config", None) + self.lh_device_id = kwargs.get("lh_device_id", "lh_joint_publisher") if not rclpy.ok(): rclpy.init() self.joint_state_publisher = None + self.executor = None + self.executor_thread = None async def setup(self): - self.joint_state_publisher = JointStatePublisher() + self.joint_state_publisher = LiquidHandlerJointPublisher( + joint_config=self.joint_config, + lh_device_id=self.lh_device_id, + simulate_rviz=True) + + # 启动ROS executor + self.executor = rclpy.executors.MultiThreadedExecutor() + self.executor.add_node(self.joint_state_publisher) + self.executor_thread = threading.Thread(target=self.executor.spin, daemon=True) + self.executor_thread.start() + await super().setup() print("Setting up the liquid handler.") async def stop(self): + # 停止ROS executor + if self.executor and self.joint_state_publisher: + self.executor.remove_node(self.joint_state_publisher) + if self.executor_thread and self.executor_thread.is_alive(): + self.executor.shutdown() print("Stopping the liquid handler.") def serialize(self) -> dict: @@ -123,7 +141,7 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend): y = coordinate.y + offset_xyz.y z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z # print("moving") - self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "pick",channels=use_channels) + self.joint_state_publisher.move_joints(ops[0].resource.name, x, y, z, "pick",channels=use_channels) # goback() @@ -166,7 +184,7 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend): z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z # print(x, y, z) # print("moving") - self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "drop_trash",channels=use_channels) + self.joint_state_publisher.move_joints(ops[0].resource.name, x, y, z, "drop_trash",channels=use_channels) # goback() async def aspirate( @@ -216,7 +234,7 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend): z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z # print(x, y, z) # print("moving") - self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "",channels=use_channels) + self.joint_state_publisher.move_joints(ops[0].resource.name, x, y, z, "",channels=use_channels) async def dispense( @@ -264,9 +282,8 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend): x = coordinate.x + offset_xyz.x y = coordinate.y + offset_xyz.y z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z - # print(x, y, z) - # print("moving") - self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "",channels=use_channels) + + self.joint_state_publisher.move_joints(ops[0].resource.name, x, y, z, "",channels=use_channels) async def pick_up_tips96(self, pickup: PickupTipRack, **backend_kwargs): print(f"Picking up tips from {pickup.resource.name}.") diff --git a/unilabos/devices/opsky_Raman/devices.json b/unilabos/devices/opsky_Raman/devices.json new file mode 100644 index 00000000..1ab5398c --- /dev/null +++ b/unilabos/devices/opsky_Raman/devices.json @@ -0,0 +1,20 @@ +{ + "nodes": [ + { + "id": "opsky_ATR30007", + "name": "opsky_ATR30007", + "parent": null, + "type": "device", + "class": "opsky_ATR30007", + "position": { + "x": 620.6111111111111, + "y": 171, + "z": 0 + }, + "config": {}, + "data": {}, + "children": [] + } + ], + "links": [] +} \ No newline at end of file diff --git a/unilabos/devices/opsky_Raman/dmqfengzhuang.py b/unilabos/devices/opsky_Raman/dmqfengzhuang.py new file mode 100644 index 00000000..fce22c71 --- /dev/null +++ b/unilabos/devices/opsky_Raman/dmqfengzhuang.py @@ -0,0 +1,71 @@ +import socket +import time +import csv +from datetime import datetime +import threading + +csv_lock = threading.Lock() # 防止多线程写CSV冲突 + +def scan_once(ip="192.168.1.50", port_in=2001, port_out=2002, + csv_file="scan_results.csv", timeout=5, retries=3): + """ + 改进版扫码函数: + - 自动重试 + - 全程超时保护 + - 更安全的socket关闭 + - 文件写入加锁 + """ + def save_result(qrcode): + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with csv_lock: + with open(csv_file, mode="a", newline="") as f: + writer = csv.writer(f) + writer.writerow([timestamp, qrcode]) + print(f"✅ 已保存结果: {timestamp}, {qrcode}") + + result = None + + for attempt in range(1, retries + 1): + print(f"\n🟡 扫码尝试 {attempt}/{retries} ...") + + try: + # -------- Step 1: 触发拍照 -------- + with socket.create_connection((ip, port_in), timeout=2) as client_in: + cmd = "start" + client_in.sendall(cmd.encode("ascii")) #把字符串转为byte字节流规则是ascii码 + print(f"→ 已发送触发指令: {cmd}") + + # -------- Step 2: 等待识别结果 -------- + with socket.create_connection((ip, port_out), timeout=timeout) as client_out: + print(f" 已连接相机输出端口 {port_out},等待结果...") + + # recv最多阻塞timeout秒 + client_out.settimeout(timeout) + data = client_out.recv(2048).decode("ascii", errors="ignore").strip() #结果输出为ascii字符串,遇到无法解析的字节则忽略 + # .strip():去掉字符串头尾的空白字符(包括 \n, \r, 空格等),便于后续判断是否为空或写入 CSV。 + if data: + print(f"📷 识别结果: {data}") + save_result(data) #调用 save_result(data) 把时间戳 + 识别字符串写入 CSV(线程安全)。 + result = data #把局部变量 result 设为 data,用于函数返回值 + break #如果读取成功跳出重试循环(for attempt in ...),不再进行后续重试。 + else: + print("⚠️ 相机返回空数据,重试中...") + + except socket.timeout: + print("⏰ 超时未收到识别结果,重试中...") + except ConnectionRefusedError: + print("❌ 无法连接到扫码器端口,请检查设备是否在线。") + except OSError as e: + print(f"⚠️ 网络错误: {e}") + except Exception as e: + print(f"❌ 未知异常: {e}") + + time.sleep(0.5) # 两次扫描之间稍作延时 + + # -------- Step 3: 返回最终结果 -------- + if result: + print(f"✅ 扫码成功:{result}") + else: + print("❌ 多次尝试后仍未获取二维码结果") + + return result diff --git a/unilabos/devices/opsky_Raman/opsky_ATR30007.py b/unilabos/devices/opsky_Raman/opsky_ATR30007.py new file mode 100644 index 00000000..8eee2bab --- /dev/null +++ b/unilabos/devices/opsky_Raman/opsky_ATR30007.py @@ -0,0 +1,398 @@ +# opsky_atr30007.py +import logging +import time as time_mod +import csv +from datetime import datetime +from typing import Optional, Dict, Any + +# 兼容 pymodbus 在不同版本中的位置与 API +try: + from pymodbus.client import ModbusTcpClient +except Exception: + ModbusTcpClient = None + +# 导入 run_raman_test(假定与本文件同目录) +# 如果你的项目是包结构且原先使用相对导入,请改回 `from .raman_module import run_raman_test` +try: + from .raman_module import run_raman_test +except Exception: + # 延迟导入失败不会阻止主流程(在 run 时会再尝试) + run_raman_test = None + +logger = logging.getLogger("opsky") +logger.setLevel(logging.INFO) +ch = logging.StreamHandler() +formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s", "%y-%m-%d %H:%M:%S") +ch.setFormatter(formatter) +logger.addHandler(ch) + + +class opsky_ATR30007: + """ + 封装 UniLabOS 设备动作逻辑,兼容 pymodbus 2.x / 3.x。 + 放在独立文件中:opsky_atr30007.py + """ + + def __init__( + self, + plc_ip: str = "192.168.1.88", + plc_port: int = 502, + robot_ip: str = "192.168.1.200", + robot_port: int = 502, + scan_csv_file: str = "scan_results.csv", + ): + self.plc_ip = plc_ip + self.plc_port = plc_port + self.robot_ip = robot_ip + self.robot_port = robot_port + self.scan_csv_file = scan_csv_file + + # ----------------- 参数字符串转换 helpers ----------------- + @staticmethod + def _str_to_int(s, default): + try: + return int(float(str(s).strip())) + except Exception: + return int(default) + + @staticmethod + def _str_to_float(s, default): + try: + return float(str(s).strip()) + except Exception: + return float(default) + + @staticmethod + def _str_to_bool(s, default): + try: + v = str(s).strip().lower() + if v in ("true", "1", "yes", "y", "t"): + return True + if v in ("false", "0", "no", "n", "f"): + return False + return default + except Exception: + return default + + # ----------------- Modbus / 安全读写 ----------------- + @staticmethod + def _adapt_req_kwargs_for_read(func_name: str, args: tuple, kwargs: dict): + # 如果调用方传的是 (address, count) positional,在新版接口可能是 address=..., count=... + if len(args) == 2 and func_name.startswith("read_"): + address, count = args + args = () + kwargs.setdefault("address", address) + kwargs.setdefault("count", count) + return args, kwargs + + @staticmethod + def _adapt_req_kwargs_for_write(func_name: str, args: tuple, kwargs: dict): + if len(args) == 2 and func_name.startswith("write_"): + address, value = args + args = () + kwargs.setdefault("address", address) + kwargs.setdefault("value", value) + return args, kwargs + + def ensure_connected(self, client, name, ip, port): + """确保连接存在,失败则尝试重连并返回新的 client 或 None""" + if client is None: + return None + try: + # 不同 pymodbus 版本可能有不同方法检测 socket + is_open = False + try: + is_open = bool(client.is_socket_open()) + except Exception: + # fallback: try to read nothing or attempt connection test + try: + # 轻试一次 + is_open = client.connected if hasattr(client, "connected") else False + except Exception: + is_open = False + + if not is_open: + logger.warning("%s 掉线,尝试重连...", name) + try: + client.close() + except Exception: + pass + time_mod.sleep(0.5) + if ModbusTcpClient: + new_client = ModbusTcpClient(ip, port=port) + try: + if new_client.connect(): + logger.info("%s 重新连接成功 (%s:%s)", name, ip, port) + return new_client + except Exception: + pass + logger.warning("%s 重连失败", name) + time_mod.sleep(1) + return None + return client + except Exception as e: + logger.exception("%s 连接检查异常: %s", name, e) + return None + + def safe_read(self, client, name, func, *args, retries=3, delay=0.3, **kwargs): + """兼容 pymodbus 2.x/3.x 的读函数,返回 response 或 None""" + if client is None: + return None + for attempt in range(1, retries + 1): + try: + # adapt args/kwargs for different API styles + args, kwargs = self._adapt_req_kwargs_for_read(func.__name__, args, kwargs) + # unit->slave compatibility + if "unit" in kwargs: + kwargs["slave"] = kwargs.pop("unit") + res = func(*args, **kwargs) + # pymodbus Response 在不同版本表现不同,尽量检测错误 + if res is None: + raise RuntimeError("返回 None") + if hasattr(res, "isError") and res.isError(): + raise RuntimeError("Modbus 返回 isError()") + return res + except Exception as e: + logger.warning("%s 读异常 (尝试 %d/%d): %s", name, attempt, retries, e) + time_mod.sleep(delay) + logger.error("%s 连续读取失败 %d 次", name, retries) + return None + + def safe_write(self, client, name, func, *args, retries=3, delay=0.3, **kwargs): + """兼容 pymodbus 2.x/3.x 的写函数,返回 True/False""" + if client is None: + return False + for attempt in range(1, retries + 1): + try: + args, kwargs = self._adapt_req_kwargs_for_write(func.__name__, args, kwargs) + if "unit" in kwargs: + kwargs["slave"] = kwargs.pop("unit") + res = func(*args, **kwargs) + if res is None: + raise RuntimeError("返回 None") + if hasattr(res, "isError") and res.isError(): + raise RuntimeError("Modbus 返回 isError()") + return True + except Exception as e: + logger.warning("%s 写异常 (尝试 %d/%d): %s", name, attempt, retries, e) + time_mod.sleep(delay) + logger.error("%s 连续写入失败 %d 次", name, retries) + return False + + def wait_with_quit_check(self, robot, seconds, addr_quit=270): + """等待指定时间,同时每 0.2s 检查 R270 是否为 1(立即退出)""" + if robot is None: + time_mod.sleep(seconds) + return False + checks = max(1, int(seconds / 0.2)) + for _ in range(checks): + rr = self.safe_read(robot, "机器人", robot.read_holding_registers, address=addr_quit, count=1) + if rr and getattr(rr, "registers", [None])[0] == 1: + logger.info("检测到 R270=1,立即退出等待") + return True + time_mod.sleep(0.2) + return False + + # ----------------- 主流程 run_once ----------------- + def run_once( + self, + integration_time: str = "5000", + laser_power: str = "200", + save_csv: str = "true", + save_plot: str = "true", + normalize: str = "true", + norm_max: str = "1.0", + **_: Any, + ) -> Dict[str, Any]: + result: Dict[str, Any] = {"success": False, "event": "none", "details": {}} + + integration_time_v = self._str_to_int(integration_time, 5000) + laser_power_v = self._str_to_int(laser_power, 200) + save_csv_v = self._str_to_bool(save_csv, True) + save_plot_v = self._str_to_bool(save_plot, True) + normalize_v = self._str_to_bool(normalize, True) + norm_max_v = None if norm_max in (None, "", "none", "null") else self._str_to_float(norm_max, 1.0) + + if ModbusTcpClient is None: + result["details"]["error"] = "未安装 pymodbus,无法执行连接" + logger.error(result["details"]["error"]) + return result + + # 建立连接 + plc = ModbusTcpClient(self.plc_ip, port=self.plc_port) + robot = ModbusTcpClient(self.robot_ip, port=self.robot_port) + try: + if not plc.connect(): + result["details"]["error"] = "无法连接 PLC" + logger.error(result["details"]["error"]) + return result + if not robot.connect(): + plc.close() + result["details"]["error"] = "无法连接 机器人" + logger.error(result["details"]["error"]) + return result + + logger.info("✅ PLC 与 机器人连接成功") + time_mod.sleep(0.2) + + # 伺服使能 (coil 写示例) + if self.safe_write(plc, "PLC", plc.write_coil, 10, True): + logger.info("✅ 伺服使能成功 (M10=True)") + else: + logger.warning("⚠️ 伺服使能失败") + + # 初始化 CSV 文件 + try: + with open(self.scan_csv_file, "w", newline="", encoding="utf-8") as f: + csv.writer(f).writerow(["Bottle_No", "Scan_Result", "Time"]) + except Exception as e: + logger.warning("⚠️ 初始化CSV失败: %s", e) + + bottle_count = 0 + logger.info("🟢 等待机器人触发信号... (R260=1扫码 / R256=1拉曼 / R270=1退出)") + + # 主循环:仅响应事件(每次循环后短暂 sleep) + while True: + plc = self.ensure_connected(plc, "PLC", self.plc_ip, self.plc_port) or plc + robot = self.ensure_connected(robot, "机器人", self.robot_ip, self.robot_port) or robot + + # 检查退出寄存器 + quit_signal = self.safe_read(robot, "机器人", robot.read_holding_registers, 270, 1) + if quit_signal and getattr(quit_signal, "registers", [None])[0] == 1: + logger.info("🟥 检测到 R270=1,准备退出...") + result["event"] = "quit" + result["success"] = True + break + + # 读取关键寄存器(256..260) + rr = self.safe_read(robot, "机器人", robot.read_holding_registers, 256, 5) + if not rr or not hasattr(rr, "registers"): + time_mod.sleep(0.3) + continue + + r256, r257, r258, r259, r260 = (rr.registers + [0, 0, 0, 0, 0])[:5] + + # ---------- 扫码逻辑 ---------- + if r260 == 1: + bottle_count += 1 + logger.info("📸 第 %d 瓶触发扫码 (R260=1)", bottle_count) + try: + # 调用外部扫码函数(用户实现) + from .dmqfengzhuang import scan_once as scan_once_local + scan_result = scan_once_local(ip="192.168.1.50", port_in=2001, port_out=2002) + if scan_result: + logger.info("✅ 扫码成功: %s", scan_result) + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with open(self.scan_csv_file, "a", newline="", encoding="utf-8") as f: + csv.writer(f).writerow([bottle_count, scan_result, timestamp]) + else: + logger.warning("⚠️ 扫码失败或无返回") + except Exception as e: + logger.exception("❌ 扫码异常: %s", e) + + # 写 R260->0, R261->1 + self.safe_write(robot, "机器人", robot.write_register, 260, 0) + time_mod.sleep(0.15) + self.safe_write(robot, "机器人", robot.write_register, 261, 1) + logger.info("➡️ 扫码完成 (R260→0, R261→1)") + result["event"] = "scan" + result["success"] = True + + # ---------- 拉曼逻辑 ---------- + if r256 == 1: + logger.info("⚙️ 检测到 R256=1(放瓶完成)") + # PLC 电机右转指令 + self.safe_write(plc, "PLC", plc.write_register, 1199, 1) + self.safe_write(plc, "PLC", plc.write_register, 1200, 1) + logger.info("➡️ 电机右转中...") + if self.wait_with_quit_check(robot, 3): + result["event"] = "quit" + break + self.safe_write(plc, "PLC", plc.write_register, 1199, 0) + logger.info("✅ 电机右转完成") + + # 调用拉曼测试(尽量捕获异常并记录) + logger.info("🧪 开始拉曼测试...") + try: + # 尝试使用模块导入好的 run_raman_test,否则再动态导入 + rr_func = run_raman_test + if rr_func is None: + from raman_module import run_raman_test as rr_func + success, file_prefix, df = rr_func( + integration_time=integration_time_v, + laser_power=laser_power_v, + save_csv=save_csv_v, + save_plot=save_plot_v, + normalize=normalize_v, + norm_max=norm_max_v, + ) + if success: + logger.info("✅ 拉曼测试完成: %s", file_prefix) + result["event"] = "raman" + result["success"] = True + else: + logger.warning("⚠️ 拉曼测试失败") + except Exception as e: + logger.exception("❌ 拉曼模块异常: %s", e) + + # 电机左转回位 + self.safe_write(plc, "PLC", plc.write_register, address=1299, value=1) + self.safe_write(plc, "PLC", plc.write_register, address=1300, value=1) + logger.info("⬅️ 电机左转中...") + if self.wait_with_quit_check(robot, 3): + result["event"] = "quit" + break + self.safe_write(plc, "PLC", plc.write_register, address=1299, value=0) + logger.info("✅ 电机左转完成") + + # 通知机器人拉曼完成 R257=1 + self.safe_write(robot, "机器人", robot.write_register, address=257, value=1) + logger.info("✅ 已写入 R257=1(拉曼完成)") + + # 延迟后清零 R256 + logger.info("⏳ 延迟4秒后清零 R256") + if self.wait_with_quit_check(robot, 4): + result["event"] = "quit" + break + self.safe_write(robot, "机器人", robot.write_register, address=256, value=0) + logger.info("✅ 已清零 R256") + + # 等待机器人清 R257 + logger.info("等待 R257 清零中...") + while True: + rr2 = self.safe_read(robot, "机器人", robot.read_holding_registers, address=257, count=1) + if rr2 and getattr(rr2, "registers", [None])[0] == 0: + logger.info("✅ 检测到 R257=0,准备下一循环") + break + if self.wait_with_quit_check(robot, 1): + result["event"] = "quit" + break + time_mod.sleep(0.2) + + time_mod.sleep(0.25) + + finally: + logger.info("🧹 开始清理...") + try: + self.safe_write(plc, "PLC", plc.write_coil, address=10, value=False) + except Exception: + pass + for addr in [256, 257, 260, 261, 270]: + try: + self.safe_write(robot, "机器人", robot.write_register, address=addr, value=0) + except Exception: + pass + + try: + if plc: + plc.close() + except Exception: + pass + try: + if robot: + robot.close() + except Exception: + pass + logger.info("🔚 已关闭所有连接") + + return result diff --git a/unilabos/devices/opsky_Raman/raman_module.py b/unilabos/devices/opsky_Raman/raman_module.py new file mode 100644 index 00000000..2bf76d27 --- /dev/null +++ b/unilabos/devices/opsky_Raman/raman_module.py @@ -0,0 +1,180 @@ +# raman_module.py +import os +import time as time_mod +import numpy as np +import pandas as pd + +# clr / ATRWrapper 依赖:在真实环境中使用 Windows + .NET wrapper +# 本模块对缺少 clr 或 Wrapper 的情况提供“仿真”回退,方便离线/调试运行。 +try: + import clr + has_clr = True +except Exception: + clr = None + has_clr = False + +# 本函数返回 (success: bool, file_prefix: str|None, df: pandas.DataFrame|None) +def run_raman_test(integration_time=5000, laser_power=200, + save_csv=True, save_plot=True, + normalize=False, norm_max=None, + max_wavenum=1300): + """ + 拉曼测试流程(更稳健的实现): + - 若能加载 ATRWrapper 则使用之 + - 否则生成模拟光谱(方便调试) + 返回 (success, file_prefix, df) + """ + timestamp = time_mod.strftime("%Y%m%d_%H%M%S") + file_prefix = f"raman_{timestamp}" + + wrapper = None + used_real_device = False + + try: + if has_clr: + try: + # 请根据你的 DLL 路径调整 + dll_path = r"D:\Raman\Raman_RS\ATRWrapper\ATRWrapper.dll" + if os.path.exists(dll_path): + clr.AddReference(dll_path) + else: + # 试图直接 AddReference 名称(若已在 PATH) + try: + clr.AddReference("ATRWrapper") + except Exception: + pass + + from Optosky.Wrapper import ATRWrapper # May raise + wrapper = ATRWrapper() + used_real_device = True + except Exception as e: + # 无法加载真实 wrapper -> fallback + print("⚠️ 未能加载 ATRWrapper,使用模拟数据。详细:", e) + wrapper = None + + if wrapper is None: + # 生成模拟光谱(方便调试) + # 模拟波数轴 50..1300 + WaveNum = np.linspace(50, max_wavenum, 1024) + # 合成几条高斯峰 + 噪声 + def gauss(x, mu, sig, A): + return A * np.exp(-0.5 * ((x - mu) / sig) ** 2) + Spect_data = (gauss(WaveNum, 200, 8, 1000) + + gauss(WaveNum, 520, 12, 600) + + gauss(WaveNum, 810, 20, 400) + + 50 * np.random.normal(scale=1.0, size=WaveNum.shape)) + Spect_bLC = Spect_data - np.min(Spect_data) * 0.05 # 简单 baseline + Spect_smooth = np.convolve(Spect_bLC, np.ones(3) / 3, mode="same") + df = pd.DataFrame({ + "WaveNum": WaveNum, + "Raw_Spect": Spect_data, + "BaseLineCorrected": Spect_bLC, + "Smooth_Spect": Spect_smooth + }) + success = True + file_prefix = f"raman_sim_{timestamp}" + # 保存 CSV / 绘图 等同真实设备 + else: + # 使用真实设备 API(根据你提供的 wrapper 调用) + On_flag = wrapper.OpenDevice() + print("通讯连接状态:", On_flag) + if not On_flag: + wrapper.CloseDevice() + return False, None, None + + wrapper.SetIntegrationTime(int(integration_time)) + wrapper.SetLdPower(int(laser_power), 1) + # 可能的冷却设置(如果 wrapper 支持) + try: + wrapper.SetCool(-5) + except Exception: + pass + + Spect = wrapper.AcquireSpectrum() + Spect_data = np.array(Spect.get_Data()) + if not Spect.get_Success(): + print("光谱采集失败") + try: + wrapper.CloseDevice() + except Exception: + pass + return False, None, None + WaveNum = np.array(wrapper.GetWaveNum()) + Spect_bLC = np.array(wrapper.BaseLineCorrect(Spect_data)) + Spect_smooth = np.array(wrapper.SmoothBoxcar(Spect_bLC, 3)) + df = pd.DataFrame({ + "WaveNum": WaveNum, + "Raw_Spect": Spect_data, + "BaseLineCorrected": Spect_bLC, + "Smooth_Spect": Spect_smooth + }) + wrapper.CloseDevice() + success = True + + # 如果需要限定波数范围 + mask = df["WaveNum"] <= max_wavenum + df = df[mask].reset_index(drop=True) + + # 可选归一化 + if normalize: + arr = df["Smooth_Spect"].values + mn, mx = arr.min(), arr.max() + if mx == mn: + df["Smooth_Spect"] = 0.0 + else: + scale = 1.0 if norm_max is None else float(norm_max) + df["Smooth_Spect"] = (arr - mn) / (mx - mn) * scale + # 同时处理其它列(可选) + arr_raw = df["Raw_Spect"].values + mn_r, mx_r = arr_raw.min(), arr_raw.max() + if mx_r == mn_r: + df["Raw_Spect"] = 0.0 + else: + scale = 1.0 if norm_max is None else float(norm_max) + df["Raw_Spect"] = (arr_raw - mn_r) / (mx_r - mn_r) * scale + + # 保存 CSV + if save_csv: + csv_filename = f"{file_prefix}.csv" + df.to_csv(csv_filename, index=False) + print("✅ CSV 文件已生成:", csv_filename) + + # 绘图(使用 matplotlib),注意:不要启用 GUI 后台 + if save_plot: + try: + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + plt.figure(figsize=(8, 5)) + plt.plot(df["WaveNum"], df["Raw_Spect"], linestyle='-', alpha=0.6, label="原始") + plt.plot(df["WaveNum"], df["BaseLineCorrected"], linestyle='--', alpha=0.8, label="基线校正") + plt.plot(df["WaveNum"], df["Smooth_Spect"], linewidth=1.2, label="平滑") + plt.xlabel("WaveNum (cm^-1)") + plt.ylabel("Intensity (a.u.)") + plt.title(f"Raman {file_prefix}") + plt.grid(True) + plt.legend() + plt.tight_layout() + plot_filename = f"{file_prefix}.png" + plt.savefig(plot_filename, dpi=300, bbox_inches="tight") + plt.close() + # 小短暂等待以确保文件系统刷新 + time_mod.sleep(0.2) + print("✅ 图像已生成:", plot_filename) + except Exception as e: + print("⚠️ 绘图失败:", e) + + return success, file_prefix, df + + except Exception as e: + print("拉曼测试异常:", e) + try: + if wrapper is not None: + try: + wrapper.CloseDevice() + except Exception: + pass + except Exception: + pass + return False, None, None diff --git a/unilabos/devices/opsky_Raman/test2.py b/unilabos/devices/opsky_Raman/test2.py new file mode 100644 index 00000000..7646439b --- /dev/null +++ b/unilabos/devices/opsky_Raman/test2.py @@ -0,0 +1,209 @@ +import time +import csv +from datetime import datetime +from pymodbus.client import ModbusTcpClient +from dmqfengzhuang import scan_once +from raman_module import run_raman_test + +# =================== 配置 =================== +PLC_IP = "192.168.1.88" +PLC_PORT = 502 +ROBOT_IP = "192.168.1.200" +ROBOT_PORT = 502 +SCAN_CSV_FILE = "scan_results.csv" + +# =================== 通用函数 =================== +def ensure_connected(client, name, ip, port): + if not client.is_socket_open(): + print(f"{name} 掉线,正在重连...") + client.close() + time.sleep(1) + + new_client = ModbusTcpClient(ip, port=port) + if new_client.connect(): + print(f"{name} 重新连接成功 ({ip}:{port})") + return new_client + else: + print(f"{name} 重连失败,稍后重试...") + time.sleep(3) + return None + return client + +def safe_read(client, name, func, *args, retries=3, delay=0.3, **kwargs): + for _ in range(retries): + try: + res = func(*args, **kwargs) + if res and not (hasattr(res, "isError") and res.isError()): + return res + except Exception as e: + print(f"{name} 读异常: {e}") + time.sleep(delay) + print(f"{name} 连续读取失败 {retries} 次") + return None + +def safe_write(client, name, func, *args, retries=3, delay=0.3, **kwargs): + for _ in range(retries): + try: + res = func(*args, **kwargs) + if res and not (hasattr(res, "isError") and res.isError()): + return True + except Exception as e: + print(f"{name} 写异常: {e}") + time.sleep(delay) + print(f"{name} 连续写入失败 {retries} 次") + return False + +def wait_with_quit_check(robot, seconds, addr_quit=270): + for _ in range(int(seconds / 0.2)): + rr = safe_read(robot, "机器人", robot.read_holding_registers, + address=addr_quit, count=1) + if rr and rr.registers[0] == 1: + print("检测到 R270=1,立即退出循环") + return True + time.sleep(0.2) + return False + +# =================== 初始化 =================== +plc = ModbusTcpClient(PLC_IP, port=PLC_PORT) +robot = ModbusTcpClient(ROBOT_IP, port=ROBOT_PORT) + +if not plc.connect(): + print("无法连接 PLC") + exit() +if not robot.connect(): + print("无法连接 机器人") + plc.close() + exit() + +print("✅ PLC 与 机器人连接成功") +time.sleep(0.5) + +# 伺服使能 +if safe_write(plc, "PLC", plc.write_coil, address=10, value=True): + print("✅ 伺服使能成功 (M10=True)") +else: + print("⚠️ 伺服使能失败") + +# 初始化扫码 CSV +with open(SCAN_CSV_FILE, "w", newline="", encoding="utf-8") as f: + csv.writer(f).writerow(["Bottle_No", "Scan_Result", "Time"]) + +bottle_count = 0 +print("🟢 等待机器人触发信号... (R260=1扫码 / R256=1拉曼 / R270=1退出)") + +# =================== 主监听循环 =================== +while True: + plc = ensure_connected(plc, "PLC", PLC_IP, PLC_PORT) or plc + robot = ensure_connected(robot, "机器人", ROBOT_IP, ROBOT_PORT) or robot + + # 退出命令检测 + quit_signal = safe_read(robot, "机器人", robot.read_holding_registers, + address=270, count=1) + if quit_signal and quit_signal.registers[0] == 1: + print("🟥 检测到 R270=1,准备退出程序...") + break + + # 读取关键寄存器 + rr = safe_read(robot, "机器人", robot.read_holding_registers, + address=256, count=5) + if not rr: + time.sleep(0.3) + continue + + r256, _, r258, r259, r260 = rr.registers[:5] + + # ----------- 扫码部分 (R260=1) ----------- + if r260 == 1: + bottle_count += 1 + print(f"📸 第 {bottle_count} 瓶触发扫码 (R260=1)") + + try: + result = scan_once(ip="192.168.1.50", port_in=2001, port_out=2002) + if result: + print(f"✅ 扫码成功: {result}") + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with open(SCAN_CSV_FILE, "a", newline="", encoding="utf-8") as f: + csv.writer(f).writerow([bottle_count, result, timestamp]) + else: + print("⚠️ 扫码失败或无返回") + except Exception as e: + print(f"❌ 扫码异常: {e}") + + safe_write(robot, "机器人", robot.write_register, address=260, value=0) + time.sleep(0.2) + safe_write(robot, "机器人", robot.write_register, address=261, value=1) + print("➡️ 扫码完成 (R260→0, R261→1)") + + # ----------- 拉曼 + 电机部分 (R256=1) ----------- + if r256 == 1: + print("⚙️ 检测到 R256=1(放瓶完成)") + + # 电机右转 + safe_write(plc, "PLC", plc.write_register, address=1199, value=1) + safe_write(plc, "PLC", plc.write_register, address=1200, value=1) + print("➡️ 电机右转中...") + if wait_with_quit_check(robot, 3): + break + safe_write(plc, "PLC", plc.write_register, address=1199, value=0) + print("✅ 电机右转完成") + + # 拉曼测试 + print("🧪 开始拉曼测试...") + try: + success, file_prefix, df = run_raman_test( + integration_time=5000, + laser_power=200, + save_csv=True, + save_plot=True, + normalize=True, + norm_max=1.0 + ) + if success: + print(f"✅ 拉曼完成:{file_prefix}.csv / .png") + else: + print("⚠️ 拉曼失败") + except Exception as e: + print(f"❌ 拉曼测试异常: {e}") + + # 电机左转 + safe_write(plc, "PLC", plc.write_register, address=1299, value=1) + safe_write(plc, "PLC", plc.write_register, address=1300, value=1) + print("⬅️ 电机左转中...") + if wait_with_quit_check(robot, 3): + break + safe_write(plc, "PLC", plc.write_register, address=1299, value=0) + print("✅ 电机左转完成") + + # 写入拉曼完成信号 + safe_write(robot, "机器人", robot.write_register, address=257, value=1) + print("✅ 已写入 R257=1(拉曼完成)") + + # 延迟清零 R256 + print("⏳ 延迟4秒后清零 R256") + if wait_with_quit_check(robot, 4): + break + safe_write(robot, "机器人", robot.write_register, address=256, value=0) + print("✅ 已清零 R256") + + # 等待机器人清零 R257 + print("等待 R257 清零中...") + while True: + rr2 = safe_read(robot, "机器人", robot.read_holding_registers, address=257, count=1) + if rr2 and rr2.registers[0] == 0: + print("✅ 检测到 R257=0,准备下一循环") + break + if wait_with_quit_check(robot, 1): + break + time.sleep(0.2) + + time.sleep(0.2) + +# =================== 程序退出清理 =================== +print("🧹 开始清理...") +safe_write(plc, "PLC", plc.write_coil, address=10, value=False) +for addr in [256, 257, 260, 261, 270]: + safe_write(robot, "机器人", robot.write_register, address=addr, value=0) + +plc.close() +robot.close() +print("✅ 程序已退出,设备全部复位。") diff --git a/unilabos/devices/resource_container/container.py b/unilabos/devices/resource_container/container.py index f80983ac..04e1442b 100644 --- a/unilabos/devices/resource_container/container.py +++ b/unilabos/devices/resource_container/container.py @@ -30,5 +30,21 @@ class PlateContainer: self.rotation = rotation self.status = 'idle' + def get_rotation(self): + return self.rotation + +class TubeRackContainer: + def __init__(self, rotation: dict, **kwargs): + self.rotation = rotation + self.status = 'idle' + + def get_rotation(self): + return self.rotation + +class BottleRackContainer: + def __init__(self, rotation: dict, **kwargs): + self.rotation = rotation + self.status = 'idle' + def get_rotation(self): return self.rotation \ No newline at end of file diff --git a/unilabos/devices/ros_dev/lh_joint_config.json b/unilabos/devices/ros_dev/lh_joint_config.json index 908cc545..8f09c299 100644 --- a/unilabos/devices/ros_dev/lh_joint_config.json +++ b/unilabos/devices/ros_dev/lh_joint_config.json @@ -34,5 +34,35 @@ "offset":0.0 } } + }, + "TransformXYZDeck":{ + "joint_names":[ + "x_joint", + "y_joint", + "z_joint" + ], + "link_names":[ + "x_link", + "y_link", + "z_link" + ], + "x":{ + "y_joint":{ + "factor":-0.001, + "offset":0.145 + } + }, + "y":{ + "x_joint":{ + "factor":0.001, + "offset":-0.21415 + } + }, + "z":{ + "z_joint":{ + "factor":-0.001, + "offset":0.0 + } + } } } \ No newline at end of file diff --git a/unilabos/devices/ros_dev/liquid_handler_joint_publisher.py b/unilabos/devices/ros_dev/liquid_handler_joint_publisher.py index 39417652..2ec7afe5 100644 --- a/unilabos/devices/ros_dev/liquid_handler_joint_publisher.py +++ b/unilabos/devices/ros_dev/liquid_handler_joint_publisher.py @@ -2,6 +2,7 @@ import asyncio import copy from pathlib import Path import threading +import uuid import rclpy import json import time @@ -18,7 +19,7 @@ from rclpy.node import Node import re class LiquidHandlerJointPublisher(BaseROS2DeviceNode): - def __init__(self,resources_config:list, resource_tracker, rate=50, device_id:str = "lh_joint_publisher"): + def __init__(self,resources_config:list, resource_tracker, rate=50, device_id:str = "lh_joint_publisher", **kwargs): super().__init__( driver_instance=self, device_id=device_id, @@ -27,6 +28,7 @@ class LiquidHandlerJointPublisher(BaseROS2DeviceNode): hardware_interface={}, print_publish=False, resource_tracker=resource_tracker, + device_uuid=kwargs.get("uuid", str(uuid.uuid4())), ) # 初始化参数 @@ -55,8 +57,8 @@ class LiquidHandlerJointPublisher(BaseROS2DeviceNode): # 初始化设备ID与config信息 for resource in resources_config: if resource['class'] == 'liquid_handler': - deck_id = resource['config']['data']['children'][0]['_resource_child_name'] - deck_class = resource['config']['data']['children'][0]['_resource_type'].split(':')[-1] + deck_id = resource['config']['deck']['_resource_child_name'] + deck_class = resource['config']['deck']['_resource_type'].split(':')[-1] key = f'{deck_id}' # key = f'{resource["id"]}_{deck_id}' self.lh_devices[key] = { @@ -208,7 +210,7 @@ class LiquidHandlerJointPublisher(BaseROS2DeviceNode): return joint_positions ,z_index - def move_joints(self, resource_names, x, y, z, option, speed = 0.1 ,x_joint=None, y_joint=None, z_joint=None): + def move_joints(self, resource_names, x, y, z, option, speed = 0.1 ,x_joint=None, y_joint=None, z_joint=None,channels=[0,1,2,3,4,5,6,7]): if isinstance(resource_names, list): resource_name_ = resource_names[0] else: @@ -217,9 +219,9 @@ class LiquidHandlerJointPublisher(BaseROS2DeviceNode): parent_id = self.find_resource_parent(resource_name_) - print('!'*20) - print(parent_id) - print('!'*20) + # print('!'*20) + # print(parent_id) + # print('!'*20) if x_joint is None: xa,xb = next(iter(self.lh_devices[parent_id]['joint_config']['x'].items())) x_joint_config = {xa:xb} @@ -252,11 +254,11 @@ class LiquidHandlerJointPublisher(BaseROS2DeviceNode): if option == "pick": link_name = self.lh_devices[parent_id]['joint_config']['link_names'][z_index] link_name = f'{parent_id}_{link_name}' - self.resource_move(resource_name_, link_name, [0,1,2,3,4,5,6,7]) + self.resource_move(resource_name_, link_name, channels) elif option == "drop_trash": - self.resource_move(resource_name_, "__trash", [0,1,2,3,4,5,6,7]) + self.resource_move(resource_name_, "__trash", channels) elif option == "drop": - self.resource_move(resource_name_, "world", [0,1,2,3,4,5,6,7]) + self.resource_move(resource_name_, "world", channels) self.move_to(joint_positions_target_zero, speed, parent_id) @@ -325,8 +327,20 @@ class JointStatePublisher(Node): return None - def send_resource_action(self, resource_name, x,y,z,option, speed = 0.1,x_joint=None, y_joint=None, z_joint=None): + def send_resource_action(self, resource_name, x,y,z,option, speed = 0.1,x_joint=None, y_joint=None, z_joint=None,channels=[0,1,2,3,4,5,6,7]): goal_msg = SendCmd.Goal() + + # Convert numpy arrays or other non-serializable objects to lists + def to_serializable(obj): + if hasattr(obj, 'tolist'): # numpy array + return obj.tolist() + elif isinstance(obj, list): + return [to_serializable(item) for item in obj] + elif isinstance(obj, dict): + return {k: to_serializable(v) for k, v in obj.items()} + else: + return obj + str_dict = { 'resource_names':resource_name, 'x':x, @@ -334,9 +348,10 @@ class JointStatePublisher(Node): 'z':z, 'option':option, 'speed':speed, - 'x_joint':x_joint, - 'y_joint':y_joint, - 'z_joint':z_joint + 'x_joint':to_serializable(x_joint), + 'y_joint':to_serializable(y_joint), + 'z_joint':to_serializable(z_joint), + 'channels':to_serializable(channels) } diff --git a/unilabos/devices/ros_dev/liquid_handler_joint_publisher_node.py b/unilabos/devices/ros_dev/liquid_handler_joint_publisher_node.py new file mode 100644 index 00000000..5b7c7252 --- /dev/null +++ b/unilabos/devices/ros_dev/liquid_handler_joint_publisher_node.py @@ -0,0 +1,374 @@ +import asyncio +import copy +from pathlib import Path +import threading +import uuid +import rclpy +import json +import time +from rclpy.executors import MultiThreadedExecutor +from rclpy.action import ActionServer,ActionClient +from sensor_msgs.msg import JointState +from unilabos_msgs.action import SendCmd +from rclpy.action.server import ServerGoalHandle + + +from rclpy.node import Node +import re + +class LiquidHandlerJointPublisher(Node): + def __init__(self, joint_config:str = None, lh_device_id: str = 'lh_joint_publisher', rate=50, **kwargs): + super().__init__(lh_device_id) + # 初始化参数 + self.lh_device_id = lh_device_id + # INSERT_YOUR_CODE + # 如果未传 joint_config,则自动读取同级的 lh_joint_config.json 文件 + + config_path = Path(__file__).parent / 'lh_joint_config.json' + with open(config_path, 'r', encoding='utf-8') as f: + config_json = json.load(f) + self.joint_config = config_json[joint_config] + self.simulate_rviz = kwargs.get("simulate_rviz", False) + + + self.rate = rate + self.j_pub = self.create_publisher(JointState,'/joint_states',10) + self.timer = self.create_timer(1, self.lh_joint_pub_callback) + + + self.resource_action = None + + if self.simulate_rviz: + while self.resource_action is None: + self.resource_action = self.check_tf_update_actions() + time.sleep(1) + + self.resource_action_client = ActionClient(self, SendCmd, self.resource_action) + while not self.resource_action_client.wait_for_server(timeout_sec=1.0): + self.get_logger().info('等待 TfUpdate 服务器...') + + self.deck_list = [] + self.lh_devices = {} + + self.j_msg = JointState( + name=[f'{self.lh_device_id}_{x}' for x in self.joint_config['joint_names']], + position=[0.0 for _ in self.joint_config['joint_names']], + velocity=[0.0 for _ in self.joint_config['joint_names']], + effort=[0.0 for _ in self.joint_config['joint_names']] + ) + + # self.j_action = ActionServer( + # self, + # SendCmd, + # "hl_joint_action", + # self.lh_joint_action_callback, + # result_timeout=5000 + # ) + + def check_tf_update_actions(self): + topics = self.get_topic_names_and_types() + + for topic_item in topics: + + topic_name, topic_types = topic_item + + if 'action_msgs/msg/GoalStatusArray' in topic_types: + # 删除 /_action/status 部分 + + base_name = topic_name.replace('/_action/status', '') + # 检查最后一个部分是否为 tf_update + parts = base_name.split('/') + if parts and parts[-1] == 'tf_update': + return base_name + + return None + + def send_resource_action(self, resource_id_list:list[str], link_name:str): + if self.simulate_rviz: + goal_msg = SendCmd.Goal() + str_dict = {} + for resource in resource_id_list: + str_dict[resource] = link_name + + goal_msg.command = json.dumps(str_dict) + + self.resource_action_client.send_goal(goal_msg) + else: + pass + + + def resource_move(self, resource_id:str, link_name:str, channels:list[int]): + resource = resource_id.rsplit("_",1) + + channel_list = ['A','B','C','D','E','F','G','H'] + + resource_list = [] + match = re.match(r'([a-zA-Z_]+)(\d+)', resource[1]) + if match: + number = match.group(2) + for channel in channels: + resource_list.append(f"{resource[0]}_{channel_list[channel]}{number}") + + if len(resource_list) > 0: + self.send_resource_action(resource_list, link_name) + + + + def lh_joint_action_callback(self,goal_handle: ServerGoalHandle): + """Move a single joint + + Args: + command: A JSON-formatted string that includes joint_name, speed, position + + joint_name (str): The name of the joint to move + speed (float): The speed of the movement, speed > 0 + position (float): The position to move to + + Returns: + None + """ + result = SendCmd.Result() + cmd_str = str(goal_handle.request.command).replace('\'','\"') + # goal_handle.execute() + + try: + cmd_dict = json.loads(cmd_str) + self.move_joints(**cmd_dict) + result.success = True + goal_handle.succeed() + + except Exception as e: + print(f'Liquid handler action error: \n{e}') + goal_handle.abort() + result.success = False + + return result + def inverse_kinematics(self, x, y, z, + parent_id, + x_joint:dict, + y_joint:dict, + z_joint:dict ): + """ + 将x、y、z坐标转换为对应关节的位置 + + Args: + x (float): x坐标 + y (float): y坐标 + z (float): z坐标 + x_joint (dict): x轴关节配置,包含factor和offset + y_joint (dict): y轴关节配置,包含factor和offset + z_joint (dict): z轴关节配置,包含factor和offset + + Returns: + dict: 关节名称和对应位置的字典 + """ + joint_positions = copy.deepcopy(self.j_msg.position) + + z_index = 0 + # 处理x轴关节 + for joint_name, config in x_joint.items(): + index = self.j_msg.name.index(f"{parent_id}_{joint_name}") + joint_positions[index] = x * config["factor"] + config["offset"] + + # 处理y轴关节 + for joint_name, config in y_joint.items(): + index = self.j_msg.name.index(f"{parent_id}_{joint_name}") + joint_positions[index] = y * config["factor"] + config["offset"] + + # 处理z轴关节 + for joint_name, config in z_joint.items(): + index = self.j_msg.name.index(f"{parent_id}_{joint_name}") + joint_positions[index] = z * config["factor"] + config["offset"] + z_index = index + + return joint_positions ,z_index + + + def move_joints(self, resource_names, x, y, z, option, speed = 0.1 ,x_joint=None, y_joint=None, z_joint=None,channels=[0,1,2,3,4,5,6,7]): + if isinstance(resource_names, list): + resource_name_ = resource_names[0] + else: + resource_name_ = resource_names + + lh_device_id = self.lh_device_id + + + # print('!'*20) + # print(parent_id) + # print('!'*20) + if x_joint is None: + xa,xb = next(iter(self.joint_config['x'].items())) + x_joint_config = {xa:xb} + elif x_joint in self.joint_config['x']: + x_joint_config = self.joint_config['x'][x_joint] + else: + raise ValueError(f"x_joint {x_joint} not in joint_config['x']") + if y_joint is None: + ya,yb = next(iter(self.joint_config['y'].items())) + y_joint_config = {ya:yb} + elif y_joint in self.joint_config['y']: + y_joint_config = self.joint_config['y'][y_joint] + else: + raise ValueError(f"y_joint {y_joint} not in joint_config['y']") + if z_joint is None: + za, zb = next(iter(self.joint_config['z'].items())) + z_joint_config = {za :zb} + elif z_joint in self.joint_config['z']: + z_joint_config = self.joint_config['z'][z_joint] + else: + raise ValueError(f"z_joint {z_joint} not in joint_config['z']") + + joint_positions_target, z_index = self.inverse_kinematics(x,y,z,lh_device_id,x_joint_config,y_joint_config,z_joint_config) + joint_positions_target_zero = copy.deepcopy(joint_positions_target) + joint_positions_target_zero[z_index] = 0 + + self.move_to(joint_positions_target_zero, speed) + self.move_to(joint_positions_target, speed) + time.sleep(1) + if option == "pick": + link_name = self.joint_config['link_names'][z_index] + link_name = f'{lh_device_id}_{link_name}' + self.resource_move(resource_name_, link_name, channels) + elif option == "drop_trash": + self.resource_move(resource_name_, "__trash", channels) + elif option == "drop": + self.resource_move(resource_name_, "world", channels) + self.move_to(joint_positions_target_zero, speed) + + + def move_to(self, joint_positions ,speed): + loop_flag = 0 + + while loop_flag < len(joint_positions): + loop_flag = 0 + for i in range(len(joint_positions)): + distance = joint_positions[i] - self.j_msg.position[i] + if distance == 0: + loop_flag += 1 + continue + minus_flag = distance/abs(distance) + if abs(distance) > speed/self.rate: + self.j_msg.position[i] += minus_flag * speed/self.rate + else : + self.j_msg.position[i] = joint_positions[i] + loop_flag += 1 + + + # 发布关节状态 + self.lh_joint_pub_callback() + time.sleep(1/self.rate) + + def lh_joint_pub_callback(self): + self.j_msg.header.stamp = self.get_clock().now().to_msg() + self.j_pub.publish(self.j_msg) + + +class JointStatePublisher(Node): + def __init__(self): + super().__init__('joint_state_publisher') + + self.lh_action = None + + while self.lh_action is None: + self.lh_action = self.check_hl_joint_actions() + time.sleep(1) + + self.lh_action_client = ActionClient(self, SendCmd, self.lh_action) + while not self.lh_action_client.wait_for_server(timeout_sec=1.0): + self.get_logger().info('等待 TfUpdate 服务器...') + + + + def check_hl_joint_actions(self): + topics = self.get_topic_names_and_types() + + + for topic_item in topics: + + topic_name, topic_types = topic_item + + if 'action_msgs/msg/GoalStatusArray' in topic_types: + # 删除 /_action/status 部分 + + base_name = topic_name.replace('/_action/status', '') + # 检查最后一个部分是否为 tf_update + parts = base_name.split('/') + if parts and parts[-1] == 'hl_joint_action': + return base_name + + return None + + def send_resource_action(self, resource_name, x,y,z,option, speed = 0.1,x_joint=None, y_joint=None, z_joint=None,channels=[0,1,2,3,4,5,6,7]): + goal_msg = SendCmd.Goal() + + # Convert numpy arrays or other non-serializable objects to lists + def to_serializable(obj): + if hasattr(obj, 'tolist'): # numpy array + return obj.tolist() + elif isinstance(obj, list): + return [to_serializable(item) for item in obj] + elif isinstance(obj, dict): + return {k: to_serializable(v) for k, v in obj.items()} + else: + return obj + + str_dict = { + 'resource_names':resource_name, + 'x':x, + 'y':y, + 'z':z, + 'option':option, + 'speed':speed, + 'x_joint':to_serializable(x_joint), + 'y_joint':to_serializable(y_joint), + 'z_joint':to_serializable(z_joint), + 'channels':to_serializable(channels) + } + + + goal_msg.command = json.dumps(str_dict) + + if not self.lh_action_client.wait_for_server(timeout_sec=5.0): + self.get_logger().error('Action server not available') + return None + + try: + # 创建新的executor + executor = rclpy.executors.MultiThreadedExecutor() + executor.add_node(self) + + # 发送目标 + future = self.lh_action_client.send_goal_async(goal_msg) + + # 使用executor等待结果 + while not future.done(): + executor.spin_once(timeout_sec=0.1) + + handle = future.result() + + if not handle.accepted: + self.get_logger().error('Goal was rejected') + return None + + # 等待最终结果 + result_future = handle.get_result_async() + while not result_future.done(): + executor.spin_once(timeout_sec=0.1) + + result = result_future.result() + return result + + except Exception as e: + self.get_logger().error(f'Error during action execution: {str(e)}') + return None + finally: + # 清理executor + executor.remove_node(self) + + +def main(): + + pass + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/unilabos/devices/sealer/sealer.py b/unilabos/devices/sealer/sealer.py new file mode 100644 index 00000000..f2149279 --- /dev/null +++ b/unilabos/devices/sealer/sealer.py @@ -0,0 +1,100 @@ +import serial, time, re + +class SimpleSealer: + """ + It purposely skips CRC/ACK handling and sends raw commands of the form + '**00??[=xxxx]zz!'. Good enough for quick experiments or automation + scripts where robustness is less critical. + + Example + ------- + >>> sealer = SimpleSealer("COM24") + >>> sealer.set_temperature(160) # 160 °C + >>> sealer.set_time(2.0) # 2 s + >>> sealer.seal_cycle() # wait‑heat‑seal‑eject + >>> sealer.close() + """ + T_RE = re.compile(r"\*T\d\d:\d\d:\d\d=(\d+),(\d),(\d),") + + def __init__(self, port: str, baud: int = 19200, thresh_c: int = 150): + self.port = port + self.baud = baud + self.thresh_c = thresh_c + self.ser = serial.Serial(port, baud, timeout=0.3) + self.ser.reset_input_buffer() + + # ---------- low‑level helpers ---------- + def _send(self, raw: str): + """Write an already‑formed ASCII command, e.g. '**00DH=0160zz!'.""" + self.ser.write(raw.encode()) + print(">>>", raw) + + def _read_frame(self) -> str: + """Read one frame (ending at '!') and strip the terminator.""" + return self.ser.read_until(b'!').decode(errors='ignore').strip() + + # ---------- high‑level commands ---------- + def set_temperature(self, celsius: int): + self._send(f"**00DH={celsius:04d}zz!") + + def set_time(self, seconds: float): + units = int(round(seconds * 10)) + self._send(f"**00DT={units:04d}zz!") + + def open_drawer(self): + self._send("**00MOzz!") + + def close_drawer(self): + self._send("**00MCzz!") + + def seal(self): + self._send("**00GSzz!") + + # ---------- waits ---------- + def wait_temp(self): + print(f"[Waiting ≥{self.thresh_c}°C]") + while True: + frame = self._read_frame() + if frame.startswith("*T"): + m = self.T_RE.match(frame) + if not m: + continue + temp = int(m.group(1)) / 10 + blk = int(m.group(3)) # 1=Ready,4=Converging + print(f"\rTemp={temp:5.1f}°C | Block={blk}", end="") + if temp >= self.thresh_c and blk in (1, 0, 4): + print(" <-- OK") + return + + def wait_finish(self): + while True: + frame = self._read_frame() + if frame.startswith("*T"): + parts = frame.split('=')[1].split(',') + status = int(parts[1]) + cnt = int(parts[6][: -3] if parts[6].endswith("!") else parts[6]) + print(f"\rRemaining {cnt/10:4.1f}s", end="") + if status == 4: + print("\n[Seal Done]") + return + + # ---------- convenience helpers ---------- + def seal_cycle(self): + """Full cycle: wait heat, seal, wait finish, eject drawer.""" + time.sleep(10) + self.seal() + self.open_drawer() + + def close(self): + self.ser.close() + + +if __name__ == "__main__": + # Quick demo usage (modify COM port and parameters as needed) + sealer = SimpleSealer("COM24") + try: + sealer.set_temperature(160) # °C + sealer.set_time(2.0) # seconds + sealer.seal_cycle() + finally: + sealer.close() \ No newline at end of file diff --git a/unilabos/devices/virtual/virtual_multiway_valve.py b/unilabos/devices/virtual/virtual_multiway_valve.py index d0d792e7..1512f33d 100644 --- a/unilabos/devices/virtual/virtual_multiway_valve.py +++ b/unilabos/devices/virtual/virtual_multiway_valve.py @@ -7,7 +7,7 @@ class VirtualMultiwayValve: """ 虚拟九通阀门 - 0号位连接transfer pump,1-8号位连接其他设备 🔄 """ - def __init__(self, port: str = "VIRTUAL", positions: int = 8): + def __init__(self, port: str = "VIRTUAL", positions: int = 8, **kwargs): self.port = port self.max_positions = positions # 1-8号位 self.total_positions = positions + 1 # 0-8号位,共9个位置 diff --git a/unilabos/devices/workstation/bioyond_studio/config.py b/unilabos/devices/workstation/bioyond_studio/config.py index 736ca66f..55b11241 100644 --- a/unilabos/devices/workstation/bioyond_studio/config.py +++ b/unilabos/devices/workstation/bioyond_studio/config.py @@ -17,7 +17,7 @@ API_CONFIG = { "report_token": os.getenv("BIOYOND_REPORT_TOKEN", "CHANGE_ME_TOKEN"), # HTTP 服务配置 - "HTTP_host": os.getenv("BIOYOND_HTTP_HOST", "172.16.11.2"), # HTTP服务监听地址,监听计算机飞连ip地址 + "HTTP_host": os.getenv("BIOYOND_HTTP_HOST", "172.16.11.6"), # HTTP服务监听地址,监听计算机飞连ip地址 "HTTP_port": int(os.getenv("BIOYOND_HTTP_PORT", "8080")), "debug_mode": False,# 调试模式 } diff --git a/unilabos/devices/xpeel/xpeel.py b/unilabos/devices/xpeel/xpeel.py new file mode 100644 index 00000000..a67cc1c3 --- /dev/null +++ b/unilabos/devices/xpeel/xpeel.py @@ -0,0 +1,59 @@ +import serial +import time + +class SealRemoverController: + def __init__(self, port='COM17', baudrate=9600, timeout=2): + self.ser = serial.Serial(port, baudrate, timeout=timeout) + + def send_command(self, command): + full_cmd = f"{command}\r\n".encode('ascii') + self.ser.write(full_cmd) + time.sleep(0.5) # 稍等设备响应 + return self.read_response() + + def read_response(self): + lines = [] + while self.ser.in_waiting: + line = self.ser.readline().decode('ascii').strip() + lines.append(line) + return lines + + def reset(self): + return self.send_command("*reset") + + def restart(self): + return self.send_command("*restart") + + def check_status(self): + return self.send_command("*stat") + + def move_in(self): + return self.send_command("*movein") + + def move_out(self): + return self.send_command("*moveout") + + def move_up(self): + return self.send_command("*moveup") + + def move_down(self): + return self.send_command("*movedown") + + def peel(self, param_set=1, adhere_time=1): + # param_set: 1~9, adhere_time: 1(2.5s) ~ 4(10s) + return self.send_command(f"*xpeel:{param_set}{adhere_time}") + + def check_tape(self): + return self.send_command("*tapeleft") + + def close(self): + self.ser.close() + +if __name__ == "__main__": + remover = SealRemoverController(port='COM17') + remover.restart # "restart" + remover.check_status() # "检查状态:" + remover.reset() # 复位设备 + remover.peel(param_set=4, adhere_time=1) #执行撕膜操作,慢速+2.5s + remover.move_out() # 送出板子 + remover.close() \ No newline at end of file diff --git a/unilabos/devices/xrd_d7mate/device.json b/unilabos/devices/xrd_d7mate/device.json new file mode 100644 index 00000000..4aebd228 --- /dev/null +++ b/unilabos/devices/xrd_d7mate/device.json @@ -0,0 +1,26 @@ +{ + "nodes": [ + { + "id": "XRD_D7MATE_STATION", + "name": "XRD_D7MATE", + "parent": null, + "type": "device", + "class": "xrd_d7mate", + "position": { + "x": 720.0, + "y": 200.0, + "z": 0 + }, + "config": { + "host": "127.0.0.1", + "port": 6001, + "timeout": 10.0 + }, + "data": { + "input_hint": "start 支持单字符串输入:'sample_name 样品A start_theta 10.0 end_theta 80.0 increment 0.02 exp_time 0.1 [wait_minutes 3]';也支持等号形式 'sample_id=样品A start_theta=10.0 end_theta=80.0 increment=0.02 exp_time=0.1 wait_minutes=3'" + }, + "children": [] + } + ], + "links": [] +} \ No newline at end of file diff --git a/unilabos/devices/xrd_d7mate/xrd_d7mate.py b/unilabos/devices/xrd_d7mate/xrd_d7mate.py new file mode 100644 index 00000000..f68baf4d --- /dev/null +++ b/unilabos/devices/xrd_d7mate/xrd_d7mate.py @@ -0,0 +1,939 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +XRD D7-Mate设备驱动 + +支持XRD D7-Mate设备的TCP通信协议,包括自动模式控制、上样流程、数据获取、下样流程和高压电源控制等功能。 +通信协议版本:1.0.0 +""" + +import json +import socket +import struct +import time +from typing import Dict, List, Optional, Tuple, Any, Union + + +class XRDClient: + def __init__(self, host='127.0.0.1', port=6001, timeout=10.0): + """ + 初始化XRD D7-Mate客户端 + + Args: + host (str): 设备IP地址 + port (int): 通信端口,默认6001 + timeout (float): 超时时间,单位秒 + """ + self.host = host + self.port = port + self.timeout = timeout + self.sock = None + self._ros_node = None # ROS节点引用,由框架设置 + + def post_init(self, ros_node): + """ + ROS节点初始化后的回调方法,保存ROS节点引用但不自动连接 + + Args: + ros_node: ROS节点实例 + """ + self._ros_node = ros_node + ros_node.lab_logger().info(f"XRD D7-Mate设备已初始化,将在需要时连接: {self.host}:{self.port}") + # 不自动连接,只有在调用具体功能时才建立连接 + + def connect(self): + """ + 建立TCP连接到XRD D7-Mate设备 + + Raises: + ConnectionError: 连接失败时抛出 + """ + try: + self.sock = socket.create_connection((self.host, self.port), timeout=self.timeout) + self.sock.settimeout(self.timeout) + except Exception as e: + raise ConnectionError(f"Failed to connect to {self.host}:{self.port} - {str(e)}") + + def close(self): + """ + 关闭与XRD D7-Mate设备的TCP连接 + """ + if self.sock: + try: + self.sock.close() + except Exception: + pass # 忽略关闭时的错误 + finally: + self.sock = None + + def _ensure_connection(self) -> bool: + """ + 确保连接存在,如果不存在则尝试建立连接 + + Returns: + bool: 连接是否成功建立 + """ + if self.sock is None: + try: + self.connect() + return True + except Exception as e: + if self._ros_node: + self._ros_node.lab_logger().error(f"建立连接失败: {e}") + return False + return True + + def _receive_with_length_prefix(self) -> dict: + """ + 使用长度前缀协议接收数据 + + Returns: + dict: 解析后的JSON响应数据 + + Raises: + ConnectionError: 连接错误 + TimeoutError: 超时错误 + """ + try: + # 首先接收4字节的长度信息 + length_data = bytearray() + while len(length_data) < 4: + chunk = self.sock.recv(4 - len(length_data)) + if not chunk: + raise ConnectionError("Connection closed while receiving length prefix") + length_data.extend(chunk) + + # 解析长度(大端序无符号整数) + data_length = struct.unpack('>I', length_data)[0] + + if self._ros_node: + self._ros_node.lab_logger().info(f"接收到数据长度: {data_length} 字节") + + # 根据长度接收实际数据 + json_data = bytearray() + while len(json_data) < data_length: + remaining = data_length - len(json_data) + chunk = self.sock.recv(min(4096, remaining)) + if not chunk: + raise ConnectionError("Connection closed while receiving JSON data") + json_data.extend(chunk) + + # 解码JSON数据,优先使用UTF-8,失败时尝试GBK + try: + json_str = json_data.decode('utf-8') + except UnicodeDecodeError: + json_str = json_data.decode('gbk') + + # 解析JSON + result = json.loads(json_str) + + if self._ros_node: + self._ros_node.lab_logger().info(f"成功解析JSON响应: {result}") + + return result + + except socket.timeout: + if self._ros_node: + self._ros_node.lab_logger().warning(f"接收超时") + raise TimeoutError(f"recv() timed out after {self.timeout:.1f}s") + except Exception as e: + if self._ros_node: + self._ros_node.lab_logger().error(f"接收数据失败: {e}") + raise ConnectionError(f"Failed to receive data: {str(e)}") + + def _send_command(self, cmd: dict) -> dict: + """ + 使用长度前缀协议发送命令到XRD D7-Mate设备并接收响应 + + Args: + cmd (dict): 要发送的命令字典 + + Returns: + dict: 设备响应的JSON数据 + + Raises: + ConnectionError: 连接错误 + TimeoutError: 超时错误 + """ + # 确保连接存在,如果不存在则建立连接 + if not self.sock: + try: + self.connect() + if self._ros_node: + self._ros_node.lab_logger().info(f"为命令重新建立连接") + except Exception as e: + raise ConnectionError(f"Failed to establish connection: {str(e)}") + + try: + # 序列化命令为JSON + json_str = json.dumps(cmd, ensure_ascii=False) + payload = json_str.encode('utf-8') + + # 计算JSON数据长度并打包为4字节大端序无符号整数 + length_prefix = struct.pack('>I', len(payload)) + + if self._ros_node: + self._ros_node.lab_logger().info(f"发送JSON命令到XRD D7-Mate: {json_str}") + self._ros_node.lab_logger().info(f"发送数据长度: {len(payload)} 字节") + + # 发送长度前缀 + self.sock.sendall(length_prefix) + + # 发送JSON数据 + self.sock.sendall(payload) + + # 使用长度前缀协议接收响应 + response = self._receive_with_length_prefix() + + return response + + except Exception as e: + # 如果是连接错误,尝试重新连接一次 + if "远程主机强迫关闭了一个现有的连接" in str(e) or "10054" in str(e): + if self._ros_node: + self._ros_node.lab_logger().warning(f"连接被远程主机关闭,尝试重新连接: {e}") + try: + self.close() + self.connect() + # 重新发送命令 + json_str = json.dumps(cmd, ensure_ascii=False) + payload = json_str.encode('utf-8') + if self._ros_node: + self._ros_node.lab_logger().info(f"重新发送JSON命令到XRD D7-Mate: {json_str}") + self.sock.sendall(payload) + + # 重新接收响应 + buffer = bytearray() + start = time.time() + while True: + try: + chunk = self.sock.recv(4096) + if not chunk: + break + buffer.extend(chunk) + + # 尝试解码和解析JSON + try: + text = buffer.decode('utf-8', errors='strict') + text = text.strip() + if text.startswith('{'): + brace_count = 0 + json_end = -1 + for i, char in enumerate(text): + if char == '{': + brace_count += 1 + elif char == '}': + brace_count -= 1 + if brace_count == 0: + json_end = i + 1 + break + if json_end > 0: + text = text[:json_end] + result = json.loads(text) + + if self._ros_node: + self._ros_node.lab_logger().info(f"重连后成功解析JSON响应: {result}") + return result + + except (UnicodeDecodeError, json.JSONDecodeError): + pass + + except socket.timeout: + if self._ros_node: + self._ros_node.lab_logger().warning(f"重连后接收超时") + raise TimeoutError(f"recv() timed out after reconnection") + + if time.time() - start > self.timeout * 2: + raise TimeoutError(f"No complete JSON received after reconnection") + + except Exception as retry_e: + if self._ros_node: + self._ros_node.lab_logger().error(f"重连失败: {retry_e}") + raise ConnectionError(f"Connection retry failed: {str(retry_e)}") + + if isinstance(e, (ConnectionError, TimeoutError)): + raise + else: + raise ConnectionError(f"Command send failed: {str(e)}") + + # ==================== 自动模式控制 ==================== + + def start_auto_mode(self, status: bool) -> dict: + """ + 启动或停止自动模式 + + Args: + status (bool): True-启动自动模式,False-停止自动模式 + + Returns: + dict: 响应结果,包含status、timestamp、message + """ + if not self.sock: + try: + self.connect() + if self._ros_node: + self._ros_node.lab_logger().info("XRD D7-Mate设备重新连接成功") + except Exception as e: + if self._ros_node: + self._ros_node.lab_logger().warning(f"XRD D7-Mate设备连接失败: {e}") + return {"status": False, "message": "设备连接异常"} + + try: + # 按协议要求,content 直接为布尔值,使用传入的status参数 + cmd = { + "command": "START_AUTO_MODE", + "content": { + "status": bool(True) + } + } + + if self._ros_node: + self._ros_node.lab_logger().info(f"发送自动模式控制命令: {cmd}") + + response = self._send_command(cmd) + if self._ros_node: + self._ros_node.lab_logger().info(f"收到自动模式控制响应: {response}") + + return response + except Exception as e: + if self._ros_node: + self._ros_node.lab_logger().error(f"自动模式控制失败: {e}") + return {"status": False, "message": f"自动模式控制失败: {str(e)}"} + + # ==================== 上样流程 ==================== + + def get_sample_request(self) -> dict: + """ + 上样请求,检查是否允许上样 + + Returns: + dict: 响应结果,包含status、timestamp、message + """ + if not self.sock: + try: + self.connect() + if self._ros_node: + self._ros_node.lab_logger().info("XRD D7-Mate设备重新连接成功") + except Exception as e: + if self._ros_node: + self._ros_node.lab_logger().warning(f"XRD D7-Mate设备连接失败: {e}") + return {"status": False, "message": "设备连接异常"} + + try: + cmd = { + "command": "GET_SAMPLE_REQUEST", + } + + if self._ros_node: + self._ros_node.lab_logger().info(f"发送上样请求命令: {cmd}") + + response = self._send_command(cmd) + if self._ros_node: + self._ros_node.lab_logger().info(f"收到上样请求响应: {response}") + + return response + except Exception as e: + if self._ros_node: + self._ros_node.lab_logger().error(f"上样请求失败: {e}") + return {"status": False, "message": f"上样请求失败: {str(e)}"} + + def send_sample_ready(self, sample_id: str, start_theta: float, end_theta: float, + increment: float, exp_time: float) -> dict: + """ + 送样完成后,发送样品信息和采集参数 + + Args: + sample_id (str): 样品标识符 + start_theta (float): 起始角度(≥5°) + end_theta (float): 结束角度(≥5.5°,且必须大于start_theta) + increment (float): 角度增量(≥0.005) + exp_time (float): 曝光时间(0.1-5.0秒) + + Returns: + dict: 响应结果,包含status、timestamp、message等 + """ + # 参数验证 + if start_theta < 5.0: + return {"status": False, "message": "起始角度必须≥5°"} + if end_theta < 5.5: + return {"status": False, "message": "结束角度必须≥5.5°"} + if end_theta <= start_theta: + return {"status": False, "message": "结束角度必须大于起始角度"} + if increment < 0.005: + return {"status": False, "message": "角度增量必须≥0.005"} + if not (0.1 <= exp_time <= 5.0): + return {"status": False, "message": "曝光时间必须在0.1-5.0秒之间"} + + if not self.sock: + try: + self.connect() + if self._ros_node: + self._ros_node.lab_logger().info("XRD D7-Mate设备重新连接成功") + except Exception as e: + if self._ros_node: + self._ros_node.lab_logger().warning(f"XRD D7-Mate设备连接失败: {e}") + return {"status": False, "message": "设备连接异常"} + + try: + cmd = { + "command": "SEND_SAMPLE_READY", + "content": { + "sample_id": sample_id, + "start_theta": start_theta, + "end_theta": end_theta, + "increment": increment, + "exp_time": exp_time + } + } + + if self._ros_node: + self._ros_node.lab_logger().info(f"发送样品准备完成命令: {cmd}") + + response = self._send_command(cmd) + if self._ros_node: + self._ros_node.lab_logger().info(f"收到样品准备完成响应: {response}") + + return response + except Exception as e: + if self._ros_node: + self._ros_node.lab_logger().error(f"样品准备完成失败: {e}") + return {"status": False, "message": f"样品准备完成失败: {str(e)}"} + + # ==================== 数据获取 ==================== + + def get_current_acquire_data(self) -> dict: + """ + 获取当前正在采集的样品数据 + + Returns: + dict: 响应结果,包含status、timestamp、sample_id、Energy、Intensity等 + """ + if not self.sock: + try: + self.connect() + if self._ros_node: + self._ros_node.lab_logger().info("XRD D7-Mate设备重新连接成功") + except Exception as e: + if self._ros_node: + self._ros_node.lab_logger().warning(f"XRD D7-Mate设备连接失败: {e}") + return {"status": False, "message": "设备连接异常"} + + try: + cmd = { + "command": "GET_CURRENT_ACQUIRE_DATA", + } + + if self._ros_node: + self._ros_node.lab_logger().info(f"发送获取采集数据命令: {cmd}") + + response = self._send_command(cmd) + if self._ros_node: + self._ros_node.lab_logger().info(f"收到获取采集数据响应: {response}") + + return response + except Exception as e: + if self._ros_node: + self._ros_node.lab_logger().error(f"获取采集数据失败: {e}") + return {"status": False, "message": f"获取采集数据失败: {str(e)}"} + + def get_sample_status(self) -> dict: + """ + 获取工位样品状态及设备状态 + + Returns: + dict: 响应结果,包含status、timestamp、Station等 + """ + if not self.sock: + try: + self.connect() + if self._ros_node: + self._ros_node.lab_logger().info("XRD D7-Mate设备重新连接成功") + except Exception as e: + if self._ros_node: + self._ros_node.lab_logger().warning(f"XRD D7-Mate设备连接失败: {e}") + return {"status": False, "message": "设备连接异常"} + + try: + cmd = { + "command": "GET_SAMPLE_STATUS", + } + + if self._ros_node: + self._ros_node.lab_logger().info(f"发送获取样品状态命令: {cmd}") + + response = self._send_command(cmd) + if self._ros_node: + self._ros_node.lab_logger().info(f"收到获取样品状态响应: {response}") + + return response + except Exception as e: + if self._ros_node: + self._ros_node.lab_logger().error(f"获取样品状态失败: {e}") + return {"status": False, "message": f"获取样品状态失败: {str(e)}"} + + # ==================== 下样流程 ==================== + + def get_sample_down(self, sample_station: int) -> dict: + """ + 下样请求 + + Args: + sample_station (int): 下样工位(1, 2, 3) + + Returns: + dict: 响应结果,包含status、timestamp、sample_info等 + """ + # 参数验证 + if sample_station not in [1, 2, 3]: + return {"status": False, "message": "下样工位必须是1、2或3"} + + if not self.sock: + try: + self.connect() + if self._ros_node: + self._ros_node.lab_logger().info("XRD D7-Mate设备重新连接成功") + except Exception as e: + if self._ros_node: + self._ros_node.lab_logger().warning(f"XRD D7-Mate设备连接失败: {e}") + return {"status": False, "message": "设备连接异常"} + + try: + # 按协议要求,content 直接为整数工位号 + cmd = { + "command": "GET_SAMPLE_DOWN", + "content": { + "Sample station":int(3) + } + } + + if self._ros_node: + self._ros_node.lab_logger().info(f"发送下样请求命令: {cmd}") + + response = self._send_command(cmd) + if self._ros_node: + self._ros_node.lab_logger().info(f"收到下样请求响应: {response}") + + return response + except Exception as e: + if self._ros_node: + self._ros_node.lab_logger().error(f"下样请求失败: {e}") + return {"status": False, "message": f"下样请求失败: {str(e)}"} + + def send_sample_down_ready(self) -> dict: + """ + 下样完成命令 + + Returns: + dict: 响应结果,包含status、timestamp、message + """ + if not self.sock: + try: + self.connect() + if self._ros_node: + self._ros_node.lab_logger().info("XRD D7-Mate设备重新连接成功") + except Exception as e: + if self._ros_node: + self._ros_node.lab_logger().warning(f"XRD D7-Mate设备连接失败: {e}") + return {"status": False, "message": "设备连接异常"} + + try: + cmd = { + "command": "SEND_SAMPLE_DOWN_READY", + } + + if self._ros_node: + self._ros_node.lab_logger().info(f"发送下样完成命令: {cmd}") + + response = self._send_command(cmd) + if self._ros_node: + self._ros_node.lab_logger().info(f"收到下样完成响应: {response}") + + return response + except Exception as e: + if self._ros_node: + self._ros_node.lab_logger().error(f"下样完成失败: {e}") + return {"status": False, "message": f"下样完成失败: {str(e)}"} + + # ==================== 高压电源控制 ==================== + + def set_power_on(self) -> dict: + """ + 高压电源开启 + + Returns: + dict: 响应结果,包含status、timestamp、message + """ + if not self.sock: + try: + self.connect() + if self._ros_node: + self._ros_node.lab_logger().info("XRD D7-Mate设备重新连接成功") + except Exception as e: + if self._ros_node: + self._ros_node.lab_logger().warning(f"XRD D7-Mate设备连接失败: {e}") + return {"status": False, "message": "设备连接异常"} + + try: + cmd = { + "command": "SET_POWER_ON", + } + + if self._ros_node: + self._ros_node.lab_logger().info(f"发送高压电源开启命令: {cmd}") + + response = self._send_command(cmd) + if self._ros_node: + self._ros_node.lab_logger().info(f"收到高压开启响应: {response}") + + return response + except Exception as e: + if self._ros_node: + self._ros_node.lab_logger().error(f"高压开启失败: {e}") + return {"status": False, "message": f"高压开启失败: {str(e)}"} + + def set_power_off(self) -> dict: + """ + 高压电源关闭 + + Returns: + dict: 响应结果,包含status、timestamp、message + """ + if not self.sock: + try: + self.connect() + if self._ros_node: + self._ros_node.lab_logger().info("XRD D7-Mate设备重新连接成功") + except Exception as e: + if self._ros_node: + self._ros_node.lab_logger().warning(f"XRD D7-Mate设备连接失败: {e}") + return {"status": False, "message": "设备连接异常"} + + try: + cmd = { + "command": "SET_POWER_OFF", + } + + if self._ros_node: + self._ros_node.lab_logger().info(f"发送高压电源关闭命令: {cmd}") + + response = self._send_command(cmd) + if self._ros_node: + self._ros_node.lab_logger().info(f"收到高压关闭响应: {response}") + + return response + except Exception as e: + if self._ros_node: + self._ros_node.lab_logger().error(f"高压关闭失败: {e}") + return {"status": False, "message": f"高压关闭失败: {str(e)}"} + + def set_voltage_current(self, voltage: float, current: float) -> dict: + """ + 设置高压电源电压和电流 + + Args: + voltage (float): 电压值(kV) + current (float): 电流值(mA) + + Returns: + dict: 响应结果,包含status、timestamp、message + """ + if not self.sock: + try: + self.connect() + if self._ros_node: + self._ros_node.lab_logger().info("XRD D7-Mate设备重新连接成功") + except Exception as e: + if self._ros_node: + self._ros_node.lab_logger().warning(f"XRD D7-Mate设备连接失败: {e}") + return {"status": False, "message": "设备连接异常"} + + try: + cmd = { + "command": "SET_VOLTAGE_CURRENT", + "content": { + "voltage": voltage, + "current": current + } + } + + if self._ros_node: + self._ros_node.lab_logger().info(f"发送设置电压电流命令: {cmd}") + + response = self._send_command(cmd) + if self._ros_node: + self._ros_node.lab_logger().info(f"收到设置电压电流响应: {response}") + + return response + except Exception as e: + if self._ros_node: + self._ros_node.lab_logger().error(f"设置电压电流失败: {e}") + return {"status": False, "message": f"设置电压电流失败: {str(e)}"} + + def start(self, sample_id: str = "", start_theta: float = 10.0, end_theta: float = 80.0, + increment: float = 0.05, exp_time: float = 0.1, wait_minutes: float = 3.0, + string: str = "") -> dict: + """ + Start 主流程: + 1) 启动自动模式; + 2) 发送上样请求并等待允许; + 3) 等待指定分钟后发送样品准备完成(携带采集参数); + 4) 周期性轮询采集数据与工位状态; + 5) 一旦任一下样位变为 True,执行下样流程(GET_SAMPLE_DOWN + SEND_SAMPLE_DOWN_READY)。 + + Args: + sample_id: 样品名称 + start_theta: 起始角度(≥5°) + end_theta: 结束角度(≥5.5°,且必须大于 start_theta) + increment: 角度增量(≥0.005) + exp_time: 曝光时间(0.1-5.0 秒) + wait_minutes: 在允许上样后、发送样品准备完成前的等待分钟数(默认 3 分钟) + string: 字符串格式的参数输入,如果提供则优先解析使用 + + Returns: + dict: {"return_info": str, "success": bool} + """ + try: + # 强制类型转换:除 sample_id 外的所有输入均转换为 float(若为字符串) + def _to_float(v, default): + try: + return float(v) + except (TypeError, ValueError): + return float(default) + + if not isinstance(sample_id, str): + sample_id = str(sample_id) + if isinstance(start_theta, str): + start_theta = _to_float(start_theta, 10.0) + if isinstance(end_theta, str): + end_theta = _to_float(end_theta, 80.0) + if isinstance(increment, str): + increment = _to_float(increment, 0.05) + if isinstance(exp_time, str): + exp_time = _to_float(exp_time, 0.1) + if isinstance(wait_minutes, str): + wait_minutes = _to_float(wait_minutes, 3.0) + + # 不再从 string 参数解析覆盖;保留参数但忽略字符串解析,统一使用结构化输入 + + # 确保设备连接 + if not self.sock: + try: + self.connect() + if self._ros_node: + self._ros_node.lab_logger().info("XRD D7-Mate设备连接成功,开始执行start流程") + except Exception as e: + if self._ros_node: + self._ros_node.lab_logger().error(f"XRD D7-Mate设备连接失败: {e}") + return {"return_info": f"设备连接失败: {str(e)}", "success": False} + + # 1) 启动自动模式 + r_auto = self.start_auto_mode(True) + if not r_auto.get("status", False): + return {"return_info": f"启动自动模式失败: {r_auto.get('message', '未知')}", "success": False} + if self._ros_node: + self._ros_node.lab_logger().info(f"自动模式已启动: {r_auto}") + + # 2) 上样请求 + r_req = self.get_sample_request() + if not r_req.get("status", False): + return {"return_info": f"上样请求未允许: {r_req.get('message', '未知')}", "success": False} + if self._ros_node: + self._ros_node.lab_logger().info(f"上样已允许: {r_req}") + + # 3) 等待指定分钟后发送样品准备完成 + wait_seconds = max(0.0, float(wait_minutes)) * 60.0 + if self._ros_node: + self._ros_node.lab_logger().info(f"等待 {wait_minutes} 分钟后发送样品准备完成") + time.sleep(wait_seconds) + + r_ready = self.send_sample_ready(sample_id=sample_id, + start_theta=start_theta, + end_theta=end_theta, + increment=increment, + exp_time=exp_time) + if not r_ready.get("status", False): + return {"return_info": f"样品准备完成失败: {r_ready.get('message', '未知')}", "success": False} + if self._ros_node: + self._ros_node.lab_logger().info(f"样品准备完成已发送: {r_ready}") + + # 4) 轮询采集数据与工位状态 + polling_interval = 5.0 # 秒 + down_station_idx: Optional[int] = None + while True: + try: + r_data = self.get_current_acquire_data() + if self._ros_node: + self._ros_node.lab_logger().info(f"采集中数据: {r_data}") + except Exception as e: + if self._ros_node: + self._ros_node.lab_logger().warning(f"获取采集数据失败: {e}") + + try: + r_status = self.get_sample_status() + if self._ros_node: + self._ros_node.lab_logger().info(f"工位状态: {r_status}") + + station = r_status.get("Station", {}) + if isinstance(station, dict): + for idx in (1, 2, 3): + key = f"DownStation{idx}" + val = station.get(key) + if isinstance(val, bool) and val: + down_station_idx = idx + break + if down_station_idx is not None: + break + except Exception as e: + if self._ros_node: + self._ros_node.lab_logger().warning(f"获取工位状态失败: {e}") + + time.sleep(polling_interval) + + if down_station_idx is None: + return {"return_info": "未检测到任一下样位 True,流程未完成", "success": False} + + # 5) 下样流程 + r_down = self.get_sample_down(down_station_idx) + if not r_down.get("status", False): + return {"return_info": f"下样请求失败(工位 {down_station_idx}): {r_down.get('message', '未知')}", "success": False} + if self._ros_node: + self._ros_node.lab_logger().info(f"下样请求成功(工位 {down_station_idx}): {r_down}") + + r_ready_down = self.send_sample_down_ready() + if not r_ready_down.get("status", False): + return {"return_info": f"下样完成发送失败: {r_ready_down.get('message', '未知')}", "success": False} + if self._ros_node: + self._ros_node.lab_logger().info(f"下样完成已发送: {r_ready_down}") + + return {"return_info": f"Start流程完成,工位 {down_station_idx} 已下样", "success": True} + + except Exception as e: + if self._ros_node: + self._ros_node.lab_logger().error(f"Start流程异常: {e}") + return {"return_info": f"Start流程异常: {str(e)}", "success": False} + + def _parse_start_params(self, params: Union[str, Dict[str, Any]]) -> Dict[str, Any]: + """ + 解析UI输入参数为 Start 流程参数。 + - 从UI字典中读取各个字段的字符串值 + - 将数值字段从字符串转换为 float 类型 + - 保留 sample_id 为字符串类型 + + 返回: + dict: {sample_id, start_theta, end_theta, increment, exp_time, wait_minutes} + """ + # 如果传入为字典,则直接按键读取;否则给出警告并使用空字典 + if isinstance(params, dict): + p = params + else: + p = {} + if self._ros_node: + self._ros_node.lab_logger().warning("start 参数应为结构化字典") + + def _to_float(v, default): + """将UI输入的字符串值转换为float,处理空值和无效值""" + if v is None or v == '': + return float(default) + try: + # 处理字符串输入(来自UI) + if isinstance(v, str): + v = v.strip() + if v == '': + return float(default) + return float(v) + except (TypeError, ValueError): + return float(default) + + # 从UI输入字典中读取参数 + sample_id = p.get('sample_id') or p.get('sample_name') or '样品名称' + if not isinstance(sample_id, str): + sample_id = str(sample_id) + + # 将UI字符串输入转换为float + result: Dict[str, Any] = { + 'sample_id': sample_id, + 'start_theta': _to_float(p.get('start_theta'), 10.0), + 'end_theta': _to_float(p.get('end_theta'), 80.0), + 'increment': _to_float(p.get('increment'), 0.05), + 'exp_time': _to_float(p.get('exp_time'), 0.1), + 'wait_minutes': _to_float(p.get('wait_minutes'), 3.0), + } + + return result + + def start_from_string(self, params: Union[str, Dict[str, Any]]) -> dict: + """ + 从UI输入参数执行 Start 主流程。 + 接收来自用户界面的参数字典,其中数值字段为字符串格式,自动转换为正确的类型。 + + 参数: + params: UI输入参数字典,例如: + { + 'sample_id': 'teste', + 'start_theta': '10.0', # UI字符串输入 + 'end_theta': '25.0', # UI字符串输入 + 'increment': '0.05', # UI字符串输入 + 'exp_time': '0.10', # UI字符串输入 + 'wait_minutes': '0.5' # UI字符串输入 + } + + 返回: + dict: 执行结果 + """ + parsed = self._parse_start_params(params) + + sample_id = parsed.get('sample_id', '样品名称') + start_theta = float(parsed.get('start_theta', 10.0)) + end_theta = float(parsed.get('end_theta', 80.0)) + increment = float(parsed.get('increment', 0.05)) + exp_time = float(parsed.get('exp_time', 0.1)) + wait_minutes = float(parsed.get('wait_minutes', 3.0)) + + return self.start( + sample_id=sample_id, + start_theta=start_theta, + end_theta=end_theta, + increment=increment, + exp_time=exp_time, + wait_minutes=wait_minutes, + ) + +# 测试函数 +def test_xrd_client(): + """ + 测试XRD客户端功能 + """ + client = XRDClient(host='127.0.0.1', port=6001) + + try: + # 测试连接 + client.connect() + print("连接成功") + + # 测试启动自动模式 + result = client.start_auto_mode(True) + print(f"启动自动模式: {result}") + + # 测试上样请求 + result = client.get_sample_request() + print(f"上样请求: {result}") + + # 测试获取样品状态 + result = client.get_sample_status() + print(f"样品状态: {result}") + + # 测试高压开启 + result = client.set_power_on() + print(f"高压开启: {result}") + + except Exception as e: + print(f"测试失败: {e}") + finally: + client.close() + + +if __name__ == "__main__": + test_xrd_client() + +# 为了兼容性,提供别名 +XRD_D7Mate = XRDClient \ No newline at end of file diff --git a/unilabos/registry/devices/Qone_nmr.yaml b/unilabos/registry/devices/Qone_nmr.yaml new file mode 100644 index 00000000..fa182c77 --- /dev/null +++ b/unilabos/registry/devices/Qone_nmr.yaml @@ -0,0 +1,223 @@ +Qone_nmr: + category: + - Qone_nmr + class: + action_value_mappings: + abort: + feedback: {} + goal: {} + goal_default: {} + handles: {} + result: {} + schema: + description: '' + properties: + feedback: + properties: {} + required: [] + title: EmptyIn_Feedback + type: object + goal: + properties: {} + required: [] + title: EmptyIn_Goal + type: object + result: + properties: + return_info: + type: string + required: + - return_info + title: EmptyIn_Result + type: object + required: + - goal + title: EmptyIn + type: object + type: EmptyIn + auto-monitor_folder_for_new_content: + feedback: {} + goal: {} + goal_default: + check_interval: 60 + expected_count: 1 + monitor_dir: null + stability_checks: 3 + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + check_interval: + default: 60 + type: string + expected_count: + default: 1 + type: string + monitor_dir: + type: string + stability_checks: + default: 3 + type: string + required: [] + type: object + result: {} + required: + - goal + title: monitor_folder_for_new_content参数 + type: object + type: UniLabJsonCommand + auto-post_init: + feedback: {} + goal: {} + goal_default: + ros_node: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + ros_node: + type: string + required: + - ros_node + type: object + result: {} + required: + - goal + title: post_init参数 + type: object + type: UniLabJsonCommand + auto-strings_to_txt: + feedback: {} + goal: {} + goal_default: + output_dir: null + string_list: null + txt_encoding: utf-8 + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + output_dir: + type: string + string_list: + type: string + txt_encoding: + default: utf-8 + type: string + required: + - string_list + type: object + result: {} + required: + - goal + title: strings_to_txt参数 + type: object + type: UniLabJsonCommand + get_status: + feedback: {} + goal: {} + goal_default: {} + handles: {} + result: {} + schema: + description: '' + properties: + feedback: + properties: {} + required: [] + title: EmptyIn_Feedback + type: object + goal: + properties: {} + required: [] + title: EmptyIn_Goal + type: object + result: + properties: + return_info: + type: string + required: + - return_info + title: EmptyIn_Result + type: object + required: + - goal + title: EmptyIn + type: object + type: EmptyIn + start: + feedback: {} + goal: + string: string + goal_default: + string: '' + handles: {} + result: {} + schema: + description: '' + properties: + feedback: + properties: {} + required: [] + title: StrSingleInput_Feedback + type: object + goal: + properties: + string: + type: string + required: + - string + title: StrSingleInput_Goal + type: object + result: + properties: + return_info: + type: string + success: + type: boolean + required: + - return_info + - success + title: StrSingleInput_Result + type: object + required: + - goal + title: StrSingleInput + type: object + type: StrSingleInput + module: unilabos.devices.Qone_nmr.Qone_nmr:Qone_nmr + status_types: + status: str + type: python + config_info: [] + description: Oxford NMR设备驱动,支持CSV字符串到TXT文件的批量转换功能,并监测对应.nmr文件的大小变化以确认结果生成完成 + handles: [] + icon: '' + init_param_schema: + config: + properties: {} + required: [] + type: object + data: + properties: + status: + type: string + required: + - status + type: object + version: 1.0.0 diff --git a/unilabos/registry/devices/bioyond.yaml b/unilabos/registry/devices/bioyond.yaml new file mode 100644 index 00000000..3325a260 --- /dev/null +++ b/unilabos/registry/devices/bioyond.yaml @@ -0,0 +1,589 @@ +workstation.bioyond_dispensing_station: + category: + - workstation + - bioyond + class: + action_value_mappings: + auto-batch_create_90_10_vial_feeding_tasks: + feedback: {} + goal: {} + goal_default: + delay_time: null + hold_m_name: null + liquid_material_name: NMP + speed: null + temperature: null + titration: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + delay_time: + type: string + hold_m_name: + type: string + liquid_material_name: + default: NMP + type: string + speed: + type: string + temperature: + type: string + titration: + type: string + required: + - titration + type: object + result: {} + required: + - goal + title: batch_create_90_10_vial_feeding_tasks参数 + type: object + type: UniLabJsonCommand + auto-batch_create_diamine_solution_tasks: + feedback: {} + goal: {} + goal_default: + delay_time: null + liquid_material_name: NMP + solutions: null + speed: null + temperature: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + delay_time: + type: string + liquid_material_name: + default: NMP + type: string + solutions: + type: string + speed: + type: string + temperature: + type: string + required: + - solutions + type: object + result: {} + required: + - goal + title: batch_create_diamine_solution_tasks参数 + type: object + type: UniLabJsonCommand + auto-brief_step_parameters: + feedback: {} + goal: {} + goal_default: + data: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + data: + type: object + required: + - data + type: object + result: {} + required: + - goal + title: brief_step_parameters参数 + type: object + type: UniLabJsonCommand + auto-compute_experiment_design: + feedback: {} + goal: {} + goal_default: + m_tot: '70' + ratio: null + titration_percent: '0.03' + wt_percent: '0.25' + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + m_tot: + default: '70' + type: string + ratio: + type: object + titration_percent: + default: '0.03' + type: string + wt_percent: + default: '0.25' + type: string + required: + - ratio + type: object + result: + properties: + feeding_order: + items: {} + title: Feeding Order + type: array + return_info: + title: Return Info + type: string + solutions: + items: {} + title: Solutions + type: array + solvents: + additionalProperties: true + title: Solvents + type: object + titration: + additionalProperties: true + title: Titration + type: object + required: + - solutions + - titration + - solvents + - feeding_order + - return_info + title: ComputeExperimentDesignReturn + type: object + required: + - goal + title: compute_experiment_design参数 + type: object + type: UniLabJsonCommand + auto-process_order_finish_report: + feedback: {} + goal: {} + goal_default: + report_request: null + used_materials: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + report_request: + type: string + used_materials: + type: string + required: + - report_request + - used_materials + type: object + result: {} + required: + - goal + title: process_order_finish_report参数 + type: object + type: UniLabJsonCommand + auto-project_order_report: + feedback: {} + goal: {} + goal_default: + order_id: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + order_id: + type: string + required: + - order_id + type: object + result: {} + required: + - goal + title: project_order_report参数 + type: object + type: UniLabJsonCommand + auto-query_resource_by_name: + feedback: {} + goal: {} + goal_default: + material_name: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + material_name: + type: string + required: + - material_name + type: object + result: {} + required: + - goal + title: query_resource_by_name参数 + type: object + type: UniLabJsonCommand + auto-transfer_materials_to_reaction_station: + feedback: {} + goal: {} + goal_default: + target_device_id: null + transfer_groups: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + target_device_id: + type: string + transfer_groups: + type: array + required: + - target_device_id + - transfer_groups + type: object + result: {} + required: + - goal + title: transfer_materials_to_reaction_station参数 + type: object + type: UniLabJsonCommand + auto-wait_for_multiple_orders_and_get_reports: + feedback: {} + goal: {} + goal_default: + batch_create_result: null + check_interval: 10 + timeout: 7200 + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + batch_create_result: + type: string + check_interval: + default: 10 + type: integer + timeout: + default: 7200 + type: integer + required: [] + type: object + result: {} + required: + - goal + title: wait_for_multiple_orders_and_get_reports参数 + type: object + type: UniLabJsonCommand + auto-workflow_sample_locations: + feedback: {} + goal: {} + goal_default: + workflow_id: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + workflow_id: + type: string + required: + - workflow_id + type: object + result: {} + required: + - goal + title: workflow_sample_locations参数 + type: object + type: UniLabJsonCommand + create_90_10_vial_feeding_task: + feedback: {} + goal: + delay_time: delay_time + hold_m_name: hold_m_name + order_name: order_name + percent_10_1_assign_material_name: percent_10_1_assign_material_name + percent_10_1_liquid_material_name: percent_10_1_liquid_material_name + percent_10_1_target_weigh: percent_10_1_target_weigh + percent_10_1_volume: percent_10_1_volume + percent_10_2_assign_material_name: percent_10_2_assign_material_name + percent_10_2_liquid_material_name: percent_10_2_liquid_material_name + percent_10_2_target_weigh: percent_10_2_target_weigh + percent_10_2_volume: percent_10_2_volume + percent_10_3_assign_material_name: percent_10_3_assign_material_name + percent_10_3_liquid_material_name: percent_10_3_liquid_material_name + percent_10_3_target_weigh: percent_10_3_target_weigh + percent_10_3_volume: percent_10_3_volume + percent_90_1_assign_material_name: percent_90_1_assign_material_name + percent_90_1_target_weigh: percent_90_1_target_weigh + percent_90_2_assign_material_name: percent_90_2_assign_material_name + percent_90_2_target_weigh: percent_90_2_target_weigh + percent_90_3_assign_material_name: percent_90_3_assign_material_name + percent_90_3_target_weigh: percent_90_3_target_weigh + speed: speed + temperature: temperature + goal_default: + delay_time: '' + hold_m_name: '' + order_name: '' + percent_10_1_assign_material_name: '' + percent_10_1_liquid_material_name: '' + percent_10_1_target_weigh: '' + percent_10_1_volume: '' + percent_10_2_assign_material_name: '' + percent_10_2_liquid_material_name: '' + percent_10_2_target_weigh: '' + percent_10_2_volume: '' + percent_10_3_assign_material_name: '' + percent_10_3_liquid_material_name: '' + percent_10_3_target_weigh: '' + percent_10_3_volume: '' + percent_90_1_assign_material_name: '' + percent_90_1_target_weigh: '' + percent_90_2_assign_material_name: '' + percent_90_2_target_weigh: '' + percent_90_3_assign_material_name: '' + percent_90_3_target_weigh: '' + speed: '' + temperature: '' + handles: {} + result: + return_info: return_info + schema: + description: '' + properties: + feedback: + properties: {} + required: [] + title: DispenStationVialFeed_Feedback + type: object + goal: + properties: + delay_time: + type: string + hold_m_name: + type: string + order_name: + type: string + percent_10_1_assign_material_name: + type: string + percent_10_1_liquid_material_name: + type: string + percent_10_1_target_weigh: + type: string + percent_10_1_volume: + type: string + percent_10_2_assign_material_name: + type: string + percent_10_2_liquid_material_name: + type: string + percent_10_2_target_weigh: + type: string + percent_10_2_volume: + type: string + percent_10_3_assign_material_name: + type: string + percent_10_3_liquid_material_name: + type: string + percent_10_3_target_weigh: + type: string + percent_10_3_volume: + type: string + percent_90_1_assign_material_name: + type: string + percent_90_1_target_weigh: + type: string + percent_90_2_assign_material_name: + type: string + percent_90_2_target_weigh: + type: string + percent_90_3_assign_material_name: + type: string + percent_90_3_target_weigh: + type: string + speed: + type: string + temperature: + type: string + required: + - order_name + - percent_90_1_assign_material_name + - percent_90_1_target_weigh + - percent_90_2_assign_material_name + - percent_90_2_target_weigh + - percent_90_3_assign_material_name + - percent_90_3_target_weigh + - percent_10_1_assign_material_name + - percent_10_1_target_weigh + - percent_10_1_volume + - percent_10_1_liquid_material_name + - percent_10_2_assign_material_name + - percent_10_2_target_weigh + - percent_10_2_volume + - percent_10_2_liquid_material_name + - percent_10_3_assign_material_name + - percent_10_3_target_weigh + - percent_10_3_volume + - percent_10_3_liquid_material_name + - speed + - temperature + - delay_time + - hold_m_name + title: DispenStationVialFeed_Goal + type: object + result: + properties: + return_info: + type: string + required: + - return_info + title: DispenStationVialFeed_Result + type: object + required: + - goal + title: DispenStationVialFeed + type: object + type: DispenStationVialFeed + create_diamine_solution_task: + feedback: {} + goal: + delay_time: delay_time + hold_m_name: hold_m_name + liquid_material_name: liquid_material_name + material_name: material_name + order_name: order_name + speed: speed + target_weigh: target_weigh + temperature: temperature + volume: volume + goal_default: + delay_time: '' + hold_m_name: '' + liquid_material_name: '' + material_name: '' + order_name: '' + speed: '' + target_weigh: '' + temperature: '' + volume: '' + handles: {} + result: + return_info: return_info + schema: + description: '' + properties: + feedback: + properties: {} + required: [] + title: DispenStationSolnPrep_Feedback + type: object + goal: + properties: + delay_time: + type: string + hold_m_name: + type: string + liquid_material_name: + type: string + material_name: + type: string + order_name: + type: string + speed: + type: string + target_weigh: + type: string + temperature: + type: string + volume: + type: string + required: + - order_name + - material_name + - target_weigh + - volume + - liquid_material_name + - speed + - temperature + - delay_time + - hold_m_name + title: DispenStationSolnPrep_Goal + type: object + result: + properties: + return_info: + type: string + required: + - return_info + title: DispenStationSolnPrep_Result + type: object + required: + - goal + title: DispenStationSolnPrep + type: object + type: DispenStationSolnPrep + module: unilabos.devices.workstation.bioyond_studio.dispensing_station:BioyondDispensingStation + status_types: {} + type: python + config_info: [] + description: '' + handles: [] + icon: '' + init_param_schema: + config: + properties: + config: + type: string + deck: + type: string + required: + - config + - deck + type: object + data: + properties: {} + required: [] + type: object + version: 1.0.0 diff --git a/unilabos/registry/devices/bioyond_dispensing_station.yaml b/unilabos/registry/devices/bioyond_dispensing_station.yaml new file mode 100644 index 00000000..ecc0c877 --- /dev/null +++ b/unilabos/registry/devices/bioyond_dispensing_station.yaml @@ -0,0 +1,734 @@ +bioyond_dispensing_station: + category: + - workstation + - bioyond + - bioyond_dispensing_station + class: + action_value_mappings: + auto-brief_step_parameters: + feedback: {} + goal: {} + goal_default: + data: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + data: + type: object + required: + - data + type: object + result: {} + required: + - goal + title: brief_step_parameters参数 + type: object + type: UniLabJsonCommand + auto-compute_experiment_design: + feedback: {} + goal: {} + goal_default: + m_tot: '70' + ratio: null + titration_percent: '0.03' + wt_percent: '0.25' + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + m_tot: + default: '70' + type: string + ratio: + type: object + titration_percent: + default: '0.03' + type: string + wt_percent: + default: '0.25' + type: string + required: + - ratio + type: object + result: + properties: + feeding_order: + items: {} + title: Feeding Order + type: array + return_info: + title: Return Info + type: string + solutions: + items: {} + title: Solutions + type: array + solvents: + additionalProperties: true + title: Solvents + type: object + titration: + additionalProperties: true + title: Titration + type: object + required: + - solutions + - titration + - solvents + - feeding_order + - return_info + title: ComputeExperimentDesignReturn + type: object + required: + - goal + title: compute_experiment_design参数 + type: object + type: UniLabJsonCommand + auto-process_order_finish_report: + feedback: {} + goal: {} + goal_default: + report_request: null + used_materials: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + report_request: + type: string + used_materials: + type: string + required: + - report_request + - used_materials + type: object + result: {} + required: + - goal + title: process_order_finish_report参数 + type: object + type: UniLabJsonCommand + auto-project_order_report: + feedback: {} + goal: {} + goal_default: + order_id: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + order_id: + type: string + required: + - order_id + type: object + result: {} + required: + - goal + title: project_order_report参数 + type: object + type: UniLabJsonCommand + auto-query_resource_by_name: + feedback: {} + goal: {} + goal_default: + material_name: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + material_name: + type: string + required: + - material_name + type: object + result: {} + required: + - goal + title: query_resource_by_name参数 + type: object + type: UniLabJsonCommand + auto-workflow_sample_locations: + feedback: {} + goal: {} + goal_default: + workflow_id: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + workflow_id: + type: string + required: + - workflow_id + type: object + result: {} + required: + - goal + title: workflow_sample_locations参数 + type: object + type: UniLabJsonCommand + batch_create_90_10_vial_feeding_tasks: + feedback: {} + goal: + delay_time: delay_time + hold_m_name: hold_m_name + liquid_material_name: liquid_material_name + speed: speed + temperature: temperature + titration: titration + goal_default: + delay_time: '600' + hold_m_name: '' + liquid_material_name: NMP + speed: '400' + temperature: '40' + titration: '' + handles: + input: + - data_key: titration + data_source: handle + data_type: object + handler_key: titration + io_type: source + label: Titration Data From Calculation Node + output: + - data_key: return_info + data_source: executor + data_type: string + handler_key: BATCH_CREATE_RESULT + io_type: sink + label: Complete Batch Create Result JSON (contains order_codes and order_ids) + result: + return_info: return_info + schema: + description: 批量创建90%10%小瓶投料任务。从计算节点接收titration数据,包含物料名称、主称固体质量、滴定固体质量和滴定溶剂体积。返回的return_info中包含order_codes和order_ids列表。 + properties: + feedback: + properties: {} + required: [] + title: BatchCreate9010VialFeedingTasks_Feedback + type: object + goal: + properties: + delay_time: + default: '600' + description: 延迟时间(秒),默认600 + type: string + hold_m_name: + description: 库位名称,如"C01",必填参数 + type: string + liquid_material_name: + default: NMP + description: 10%物料的液体物料名称,默认为"NMP" + type: string + speed: + default: '400' + description: 搅拌速度,默认400 + type: string + temperature: + default: '40' + description: 温度(℃),默认40 + type: string + titration: + description: '滴定信息对象,包含: name(物料名称), main_portion(主称固体质量g), titration_portion(滴定固体质量g), + titration_solvent(滴定溶液体积mL)' + type: string + required: + - titration + - hold_m_name + title: BatchCreate9010VialFeedingTasks_Goal + type: object + result: + properties: + return_info: + description: 批量任务创建结果汇总JSON字符串,包含total(总数)、success(成功数)、failed(失败数)、order_codes(任务编码数组)、order_ids(任务ID数组)、details(每个任务的详细信息) + type: string + required: + - return_info + title: BatchCreate9010VialFeedingTasks_Result + type: object + required: + - goal + title: BatchCreate9010VialFeedingTasks + type: object + type: UniLabJsonCommand + batch_create_diamine_solution_tasks: + feedback: {} + goal: + delay_time: delay_time + liquid_material_name: liquid_material_name + solutions: solutions + speed: speed + temperature: temperature + goal_default: + delay_time: '600' + liquid_material_name: NMP + solutions: '' + speed: '400' + temperature: '20' + handles: + input: + - data_key: solutions + data_source: handle + data_type: array + handler_key: solutions + io_type: source + label: Solution Data From Python + output: + - data_key: return_info + data_source: executor + data_type: string + handler_key: BATCH_CREATE_RESULT + io_type: sink + label: Complete Batch Create Result JSON (contains order_codes and order_ids) + result: + return_info: return_info + schema: + description: 批量创建二胺溶液配置任务。自动为多个二胺样品创建溶液配置任务,每个任务包含固体物料称量、溶剂添加、搅拌混合等步骤。返回的return_info中包含order_codes和order_ids列表。 + properties: + feedback: + properties: {} + required: [] + title: BatchCreateDiamineSolutionTasks_Feedback + type: object + goal: + properties: + delay_time: + default: '600' + description: 溶液配置完成后的延迟时间(秒),用于充分混合和溶解,默认600秒 + type: string + liquid_material_name: + default: NMP + description: 液体溶剂名称,用于溶解固体物料,默认为NMP(N-甲基吡咯烷酮) + type: string + solutions: + description: '溶液列表,JSON数组格式,每个元素包含: name(物料名称), order(序号), solid_mass(固体质量g), + solvent_volume(溶剂体积mL)。示例: [{"name": "MDA", "order": 0, "solid_mass": + 5.0, "solvent_volume": 20}, {"name": "MPDA", "order": 1, "solid_mass": + 4.5, "solvent_volume": 18}]' + type: string + speed: + default: '400' + description: 搅拌速度(rpm),用于混合溶液,默认400转/分钟 + type: string + temperature: + default: '20' + description: 配置温度(℃),溶液配置过程的目标温度,默认20℃(室温) + type: string + required: + - solutions + title: BatchCreateDiamineSolutionTasks_Goal + type: object + result: + properties: + return_info: + description: 批量任务创建结果汇总JSON字符串,包含total(总数)、success(成功数)、failed(失败数)、order_codes(任务编码数组)、order_ids(任务ID数组)、details(每个任务的详细信息) + type: string + required: + - return_info + title: BatchCreateDiamineSolutionTasks_Result + type: object + required: + - goal + title: BatchCreateDiamineSolutionTasks + type: object + type: UniLabJsonCommand + create_90_10_vial_feeding_task: + feedback: {} + goal: + delay_time: delay_time + hold_m_name: hold_m_name + order_name: order_name + percent_10_1_assign_material_name: percent_10_1_assign_material_name + percent_10_1_liquid_material_name: percent_10_1_liquid_material_name + percent_10_1_target_weigh: percent_10_1_target_weigh + percent_10_1_volume: percent_10_1_volume + percent_10_2_assign_material_name: percent_10_2_assign_material_name + percent_10_2_liquid_material_name: percent_10_2_liquid_material_name + percent_10_2_target_weigh: percent_10_2_target_weigh + percent_10_2_volume: percent_10_2_volume + percent_10_3_assign_material_name: percent_10_3_assign_material_name + percent_10_3_liquid_material_name: percent_10_3_liquid_material_name + percent_10_3_target_weigh: percent_10_3_target_weigh + percent_10_3_volume: percent_10_3_volume + percent_90_1_assign_material_name: percent_90_1_assign_material_name + percent_90_1_target_weigh: percent_90_1_target_weigh + percent_90_2_assign_material_name: percent_90_2_assign_material_name + percent_90_2_target_weigh: percent_90_2_target_weigh + percent_90_3_assign_material_name: percent_90_3_assign_material_name + percent_90_3_target_weigh: percent_90_3_target_weigh + speed: speed + temperature: temperature + goal_default: + delay_time: '' + hold_m_name: '' + order_name: '' + percent_10_1_assign_material_name: '' + percent_10_1_liquid_material_name: '' + percent_10_1_target_weigh: '' + percent_10_1_volume: '' + percent_10_2_assign_material_name: '' + percent_10_2_liquid_material_name: '' + percent_10_2_target_weigh: '' + percent_10_2_volume: '' + percent_10_3_assign_material_name: '' + percent_10_3_liquid_material_name: '' + percent_10_3_target_weigh: '' + percent_10_3_volume: '' + percent_90_1_assign_material_name: '' + percent_90_1_target_weigh: '' + percent_90_2_assign_material_name: '' + percent_90_2_target_weigh: '' + percent_90_3_assign_material_name: '' + percent_90_3_target_weigh: '' + speed: '' + temperature: '' + handles: {} + result: + return_info: return_info + schema: + description: '' + properties: + feedback: + properties: {} + required: [] + title: DispenStationVialFeed_Feedback + type: object + goal: + properties: + delay_time: + type: string + hold_m_name: + type: string + order_name: + type: string + percent_10_1_assign_material_name: + type: string + percent_10_1_liquid_material_name: + type: string + percent_10_1_target_weigh: + type: string + percent_10_1_volume: + type: string + percent_10_2_assign_material_name: + type: string + percent_10_2_liquid_material_name: + type: string + percent_10_2_target_weigh: + type: string + percent_10_2_volume: + type: string + percent_10_3_assign_material_name: + type: string + percent_10_3_liquid_material_name: + type: string + percent_10_3_target_weigh: + type: string + percent_10_3_volume: + type: string + percent_90_1_assign_material_name: + type: string + percent_90_1_target_weigh: + type: string + percent_90_2_assign_material_name: + type: string + percent_90_2_target_weigh: + type: string + percent_90_3_assign_material_name: + type: string + percent_90_3_target_weigh: + type: string + speed: + type: string + temperature: + type: string + required: + - order_name + - percent_90_1_assign_material_name + - percent_90_1_target_weigh + - percent_90_2_assign_material_name + - percent_90_2_target_weigh + - percent_90_3_assign_material_name + - percent_90_3_target_weigh + - percent_10_1_assign_material_name + - percent_10_1_target_weigh + - percent_10_1_volume + - percent_10_1_liquid_material_name + - percent_10_2_assign_material_name + - percent_10_2_target_weigh + - percent_10_2_volume + - percent_10_2_liquid_material_name + - percent_10_3_assign_material_name + - percent_10_3_target_weigh + - percent_10_3_volume + - percent_10_3_liquid_material_name + - speed + - temperature + - delay_time + - hold_m_name + title: DispenStationVialFeed_Goal + type: object + result: + properties: + return_info: + type: string + required: + - return_info + title: DispenStationVialFeed_Result + type: object + required: + - goal + title: DispenStationVialFeed + type: object + type: DispenStationVialFeed + create_diamine_solution_task: + feedback: {} + goal: + delay_time: delay_time + hold_m_name: hold_m_name + liquid_material_name: liquid_material_name + material_name: material_name + order_name: order_name + speed: speed + target_weigh: target_weigh + temperature: temperature + volume: volume + goal_default: + delay_time: '' + hold_m_name: '' + liquid_material_name: '' + material_name: '' + order_name: '' + speed: '' + target_weigh: '' + temperature: '' + volume: '' + handles: {} + result: + return_info: return_info + schema: + description: '' + properties: + feedback: + properties: {} + required: [] + title: DispenStationSolnPrep_Feedback + type: object + goal: + properties: + delay_time: + type: string + hold_m_name: + type: string + liquid_material_name: + type: string + material_name: + type: string + order_name: + type: string + speed: + type: string + target_weigh: + type: string + temperature: + type: string + volume: + type: string + required: + - order_name + - material_name + - target_weigh + - volume + - liquid_material_name + - speed + - temperature + - delay_time + - hold_m_name + title: DispenStationSolnPrep_Goal + type: object + result: + properties: + return_info: + type: string + required: + - return_info + title: DispenStationSolnPrep_Result + type: object + required: + - goal + title: DispenStationSolnPrep + type: object + type: DispenStationSolnPrep + transfer_materials_to_reaction_station: + feedback: {} + goal: + target_device_id: target_device_id + transfer_groups: transfer_groups + goal_default: + target_device_id: '' + transfer_groups: '' + handles: {} + placeholder_keys: + target_device_id: unilabos_devices + result: {} + schema: + description: 将配液站完成的物料(溶液、样品等)转移到指定反应站的堆栈库位。支持配置多组转移任务,每组包含物料名称、目标堆栈和目标库位。 + properties: + feedback: {} + goal: + properties: + target_device_id: + description: 目标反应站设备ID(从设备列表中选择,所有转移组都使用同一个目标设备) + type: string + transfer_groups: + description: 转移任务组列表,每组包含物料名称、目标堆栈和目标库位,可以添加多组 + items: + properties: + materials: + description: 物料名称(手动输入,系统将通过RPC查询验证) + type: string + target_sites: + description: 目标库位(手动输入,如"A01") + type: string + target_stack: + description: 目标堆栈名称(手动输入,如"堆栈1左") + type: string + required: + - materials + - target_stack + - target_sites + type: object + type: array + required: + - target_device_id + - transfer_groups + type: object + result: {} + required: + - goal + title: transfer_materials_to_reaction_station参数 + type: object + type: UniLabJsonCommand + wait_for_multiple_orders_and_get_reports: + feedback: {} + goal: + batch_create_result: batch_create_result + check_interval: check_interval + timeout: timeout + goal_default: + batch_create_result: '' + check_interval: '10' + timeout: '7200' + handles: + input: + - data_key: batch_create_result + data_source: handle + data_type: string + handler_key: BATCH_CREATE_RESULT + io_type: source + label: Batch Task Creation Result From Previous Step + output: + - data_key: return_info + data_source: handle + data_type: string + handler_key: batch_reports_result + io_type: sink + label: Batch Order Completion Reports + result: + return_info: return_info + schema: + description: 同时等待多个任务完成并获取所有实验报告。从上游batch_create任务接收包含order_codes和order_ids的结果对象,并行监控所有任务状态并返回每个任务的报告。 + properties: + feedback: + properties: {} + required: [] + title: WaitForMultipleOrdersAndGetReports_Feedback + type: object + goal: + properties: + batch_create_result: + description: 批量创建任务的返回结果对象,包含order_codes和order_ids数组。从上游batch_create节点通过handle传递 + type: string + check_interval: + default: '10' + description: 检查任务状态的时间间隔(秒),默认每10秒检查一次所有待完成任务 + type: string + timeout: + default: '7200' + description: 等待超时时间(秒),默认7200秒(2小时)。超过此时间未完成的任务将标记为timeout + type: string + required: + - batch_create_result + title: WaitForMultipleOrdersAndGetReports_Goal + type: object + result: + properties: + return_info: + description: 'JSON格式的批量任务完成信息,包含: total(总数), completed(成功数), timeout(超时数), + error(错误数), elapsed_time(总耗时), reports(报告数组,每个元素包含order_code, + order_id, status, completion_status, report, elapsed_time)' + type: string + required: + - return_info + title: WaitForMultipleOrdersAndGetReports_Result + type: object + required: + - goal + title: WaitForMultipleOrdersAndGetReports + type: object + type: UniLabJsonCommand + module: unilabos.devices.workstation.bioyond_studio.dispensing_station:BioyondDispensingStation + status_types: {} + type: python + config_info: [] + description: '' + handles: [] + icon: preparation_station.webp + init_param_schema: + config: + properties: + config: + type: string + deck: + type: string + required: + - config + - deck + type: object + data: + properties: {} + required: [] + type: object + version: 1.0.0 diff --git a/unilabos/registry/devices/camera.yaml b/unilabos/registry/devices/camera.yaml index 08c809cf..fe1aef28 100644 --- a/unilabos/registry/devices/camera.yaml +++ b/unilabos/registry/devices/camera.yaml @@ -1,4 +1,4 @@ -camera.USB: +camera: category: - camera class: @@ -61,6 +61,9 @@ camera.USB: device_id: default: video_publisher type: string + device_uuid: + default: '' + type: string period: default: 0.1 type: number diff --git a/unilabos/registry/devices/hotel.yaml b/unilabos/registry/devices/hotel.yaml index 204f021d..3fd0ea5b 100644 --- a/unilabos/registry/devices/hotel.yaml +++ b/unilabos/registry/devices/hotel.yaml @@ -31,5 +31,6 @@ hotel.thermo_orbitor_rs2_hotel: type: object model: mesh: thermo_orbitor_rs2_hotel + path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/thermo_orbitor_rs2_hotel/macro_device.xacro type: device version: 1.0.0 diff --git a/unilabos/registry/devices/laiyu_liquid_test.yaml b/unilabos/registry/devices/laiyu_liquid_test.yaml new file mode 100644 index 00000000..bbc0e873 --- /dev/null +++ b/unilabos/registry/devices/laiyu_liquid_test.yaml @@ -0,0 +1,232 @@ +xyz_stepper_controller: + category: + - laiyu_liquid_test + class: + action_value_mappings: + auto-define_current_as_zero: + feedback: {} + goal: {} + goal_default: + save_path: work_origin.json + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + save_path: + default: work_origin.json + type: string + required: [] + type: object + result: {} + required: + - goal + title: define_current_as_zero参数 + type: object + type: UniLabJsonCommand + auto-enable: + feedback: {} + goal: {} + goal_default: + axis: null + state: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + axis: + type: string + state: + type: boolean + required: + - axis + - state + type: object + result: {} + required: + - goal + title: enable参数 + type: object + type: UniLabJsonCommand + auto-move_to: + feedback: {} + goal: {} + goal_default: + acc: 500 + axis: null + precision: 50 + speed: 2000 + steps: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + acc: + default: 500 + type: integer + axis: + type: string + precision: + default: 50 + type: integer + speed: + default: 2000 + type: integer + steps: + type: integer + required: + - axis + - steps + type: object + result: {} + required: + - goal + title: move_to参数 + type: object + type: UniLabJsonCommand + auto-move_xyz_work: + feedback: {} + goal: {} + goal_default: + acc: 1500 + speed: 100 + x: 0.0 + y: 0.0 + z: 0.0 + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + acc: + default: 1500 + type: integer + speed: + default: 100 + type: integer + x: + default: 0.0 + type: number + y: + default: 0.0 + type: number + z: + default: 0.0 + type: number + required: [] + type: object + result: {} + required: + - goal + title: move_xyz_work参数 + type: object + type: UniLabJsonCommand + auto-return_to_work_origin: + feedback: {} + goal: {} + goal_default: + acc: 800 + speed: 200 + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + acc: + default: 800 + type: integer + speed: + default: 200 + type: integer + required: [] + type: object + result: {} + required: + - goal + title: return_to_work_origin参数 + type: object + type: UniLabJsonCommand + auto-wait_complete: + feedback: {} + goal: {} + goal_default: + axis: null + timeout: 30.0 + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + axis: + type: string + timeout: + default: 30.0 + type: string + required: + - axis + type: object + result: {} + required: + - goal + title: wait_complete参数 + type: object + type: UniLabJsonCommand + module: unilabos.devices.laiyu_liquid_test.xyz_stepper_driver:XYZStepperController + status_types: + status: list + type: python + config_info: [] + description: 新XYZ控制器 + handles: [] + icon: '' + init_param_schema: + config: + properties: + baudrate: + default: 115200 + type: string + client: + type: string + origin_path: + default: unilabos/devices/laiyu_liquid_test/work_origin.json + type: string + port: + default: /dev/ttyUSB0 + type: string + required: [] + type: object + data: + properties: + status: + type: array + required: + - status + type: object + registry_type: device + version: 1.0.0 diff --git a/unilabos/registry/devices/liquid_handler.yaml b/unilabos/registry/devices/liquid_handler.yaml index bfe98e89..fdfb6b5c 100644 --- a/unilabos/registry/devices/liquid_handler.yaml +++ b/unilabos/registry/devices/liquid_handler.yaml @@ -5503,6 +5503,1416 @@ liquid_handler.biomek: - success type: object version: 1.0.0 +liquid_handler.laiyu: + category: + - liquid_handler + class: + action_value_mappings: + add_liquid: + feedback: {} + goal: + asp_vols: asp_vols + blow_out_air_volume: blow_out_air_volume + dis_vols: dis_vols + flow_rates: flow_rates + is_96_well: is_96_well + liquid_height: liquid_height + mix_liquid_height: mix_liquid_height + mix_rate: mix_rate + mix_time: mix_time + mix_vol: mix_vol + none_keys: none_keys + offsets: offsets + reagent_sources: reagent_sources + spread: spread + targets: targets + use_channels: use_channels + goal_default: + asp_vols: + - 0.0 + blow_out_air_volume: + - 0.0 + dis_vols: + - 0.0 + flow_rates: + - 0.0 + is_96_well: false + liquid_height: + - 0.0 + mix_liquid_height: 0.0 + mix_rate: 0 + mix_time: 0 + mix_vol: 0 + none_keys: + - '' + offsets: + - x: 0.0 + y: 0.0 + z: 0.0 + reagent_sources: + - category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + spread: '' + targets: + - category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + use_channels: + - 0 + handles: {} + placeholder_keys: + reagent_sources: unilabos_resources + targets: unilabos_resources + result: {} + schema: + description: '' + properties: + feedback: + properties: {} + required: [] + title: LiquidHandlerAdd_Feedback + type: object + goal: + properties: + asp_vols: + items: + type: number + type: array + blow_out_air_volume: + items: + type: number + type: array + dis_vols: + items: + type: number + type: array + flow_rates: + items: + type: number + type: array + is_96_well: + type: boolean + liquid_height: + items: + type: number + type: array + mix_liquid_height: + type: number + mix_rate: + maximum: 2147483647 + minimum: -2147483648 + type: integer + mix_time: + maximum: 2147483647 + minimum: -2147483648 + type: integer + mix_vol: + maximum: 2147483647 + minimum: -2147483648 + type: integer + none_keys: + items: + type: string + type: array + offsets: + items: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: offsets + type: object + type: array + reagent_sources: + items: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: reagent_sources + type: object + type: array + spread: + type: string + targets: + items: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: targets + type: object + type: array + use_channels: + items: + maximum: 2147483647 + minimum: -2147483648 + type: integer + type: array + required: + - asp_vols + - dis_vols + - reagent_sources + - targets + - use_channels + - flow_rates + - offsets + - liquid_height + - blow_out_air_volume + - spread + - is_96_well + - mix_time + - mix_vol + - mix_rate + - mix_liquid_height + - none_keys + title: LiquidHandlerAdd_Goal + type: object + result: + properties: + return_info: + type: string + success: + type: boolean + required: + - return_info + - success + title: LiquidHandlerAdd_Result + type: object + required: + - goal + title: LiquidHandlerAdd + type: object + type: LiquidHandlerAdd + aspirate: + feedback: {} + goal: + blow_out_air_volume: blow_out_air_volume + flow_rates: flow_rates + liquid_height: liquid_height + offsets: offsets + resources: resources + use_channels: use_channels + vols: vols + goal_default: + blow_out_air_volume: + - 0.0 + flow_rates: + - 0.0 + liquid_height: + - 0.0 + offsets: + - x: 0.0 + y: 0.0 + z: 0.0 + resources: + - category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + spread: '' + use_channels: + - 0 + vols: + - 0.0 + handles: {} + placeholder_keys: + resources: unilabos_resources + result: {} + schema: + description: '' + properties: + feedback: + properties: {} + required: [] + title: LiquidHandlerAspirate_Feedback + type: object + goal: + properties: + blow_out_air_volume: + items: + type: number + type: array + flow_rates: + items: + type: number + type: array + liquid_height: + items: + type: number + type: array + offsets: + items: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: offsets + type: object + type: array + resources: + items: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: resources + type: object + type: array + spread: + type: string + use_channels: + items: + maximum: 2147483647 + minimum: -2147483648 + type: integer + type: array + vols: + items: + type: number + type: array + required: + - resources + - vols + - use_channels + - flow_rates + - offsets + - liquid_height + - blow_out_air_volume + - spread + title: LiquidHandlerAspirate_Goal + type: object + result: + properties: + return_info: + type: string + success: + type: boolean + required: + - return_info + - success + title: LiquidHandlerAspirate_Result + type: object + required: + - goal + title: LiquidHandlerAspirate + type: object + type: LiquidHandlerAspirate + auto-transfer_liquid: + feedback: {} + goal: {} + goal_default: + asp_flow_rates: null + asp_vols: null + blow_out_air_volume: null + delays: null + dis_flow_rates: null + dis_vols: null + is_96_well: false + liquid_height: null + mix_liquid_height: null + mix_rate: null + mix_stage: none + mix_times: null + mix_vol: null + none_keys: [] + offsets: null + sources: null + spread: wide + targets: null + tip_racks: null + touch_tip: false + use_channels: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + asp_flow_rates: + type: string + asp_vols: + type: string + blow_out_air_volume: + type: string + delays: + type: string + dis_flow_rates: + type: string + dis_vols: + type: string + is_96_well: + default: false + type: boolean + liquid_height: + type: string + mix_liquid_height: + type: string + mix_rate: + type: string + mix_stage: + default: none + type: string + mix_times: + type: string + mix_vol: + type: string + none_keys: + default: [] + items: + type: string + type: array + offsets: + type: string + sources: + type: string + spread: + default: wide + type: string + targets: + type: string + tip_racks: + type: string + touch_tip: + default: false + type: boolean + use_channels: + type: string + required: + - sources + - targets + - tip_racks + - asp_vols + - dis_vols + type: object + result: {} + required: + - goal + title: transfer_liquid参数 + type: object + type: UniLabJsonCommandAsync + dispense: + feedback: {} + goal: + blow_out_air_volume: blow_out_air_volume + flow_rates: flow_rates + liquid_height: liquid_height + offsets: offsets + resources: resources + use_channels: use_channels + vols: vols + goal_default: + blow_out_air_volume: + - 0 + flow_rates: + - 0.0 + offsets: + - x: 0.0 + y: 0.0 + z: 0.0 + resources: + - category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + spread: '' + use_channels: + - 0 + vols: + - 0.0 + handles: {} + placeholder_keys: + resources: unilabos_resources + result: {} + schema: + description: '' + properties: + feedback: + properties: {} + required: [] + title: LiquidHandlerDispense_Feedback + type: object + goal: + properties: + blow_out_air_volume: + items: + maximum: 2147483647 + minimum: -2147483648 + type: integer + type: array + flow_rates: + items: + type: number + type: array + offsets: + items: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: offsets + type: object + type: array + resources: + items: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: resources + type: object + type: array + spread: + type: string + use_channels: + items: + maximum: 2147483647 + minimum: -2147483648 + type: integer + type: array + vols: + items: + type: number + type: array + required: + - resources + - vols + - use_channels + - flow_rates + - offsets + - blow_out_air_volume + - spread + title: LiquidHandlerDispense_Goal + type: object + result: + properties: + return_info: + type: string + success: + type: boolean + required: + - return_info + - success + title: LiquidHandlerDispense_Result + type: object + required: + - goal + title: LiquidHandlerDispense + type: object + type: LiquidHandlerDispense + drop_tips: + feedback: {} + goal: + allow_nonzero_volume: allow_nonzero_volume + offsets: offsets + tip_spots: tip_spots + use_channels: use_channels + goal_default: + allow_nonzero_volume: false + offsets: + - x: 0.0 + y: 0.0 + z: 0.0 + tip_spots: + - category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + use_channels: + - 0 + handles: {} + placeholder_keys: + tip_spots: unilabos_resources + result: {} + schema: + description: '' + properties: + feedback: + properties: {} + required: [] + title: LiquidHandlerDropTips_Feedback + type: object + goal: + properties: + allow_nonzero_volume: + type: boolean + offsets: + items: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: offsets + type: object + type: array + tip_spots: + items: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: tip_spots + type: object + type: array + use_channels: + items: + maximum: 2147483647 + minimum: -2147483648 + type: integer + type: array + required: + - tip_spots + - use_channels + - offsets + - allow_nonzero_volume + title: LiquidHandlerDropTips_Goal + type: object + result: + properties: + return_info: + type: string + success: + type: boolean + required: + - return_info + - success + title: LiquidHandlerDropTips_Result + type: object + required: + - goal + title: LiquidHandlerDropTips + type: object + type: LiquidHandlerDropTips + mix: + feedback: {} + goal: + height_to_bottom: height_to_bottom + mix_rate: mix_rate + mix_time: mix_time + mix_vol: mix_vol + none_keys: none_keys + offsets: offsets + targets: targets + goal_default: + height_to_bottom: 0.0 + mix_rate: 0.0 + mix_time: 0 + mix_vol: 0 + none_keys: + - '' + offsets: + - x: 0.0 + y: 0.0 + z: 0.0 + targets: + - category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + handles: {} + placeholder_keys: + targets: unilabos_resources + result: {} + schema: + description: '' + properties: + feedback: + properties: {} + required: [] + title: LiquidHandlerMix_Feedback + type: object + goal: + properties: + height_to_bottom: + type: number + mix_rate: + type: number + mix_time: + maximum: 2147483647 + minimum: -2147483648 + type: integer + mix_vol: + maximum: 2147483647 + minimum: -2147483648 + type: integer + none_keys: + items: + type: string + type: array + offsets: + items: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: offsets + type: object + type: array + targets: + items: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: targets + type: object + type: array + required: + - targets + - mix_time + - mix_vol + - height_to_bottom + - offsets + - mix_rate + - none_keys + title: LiquidHandlerMix_Goal + type: object + result: + properties: + return_info: + type: string + success: + type: boolean + required: + - return_info + - success + title: LiquidHandlerMix_Result + type: object + required: + - goal + title: LiquidHandlerMix + type: object + type: LiquidHandlerMix + pick_up_tips: + feedback: {} + goal: + offsets: offsets + tip_spots: tip_spots + use_channels: use_channels + goal_default: + offsets: + - x: 0.0 + y: 0.0 + z: 0.0 + tip_spots: + - category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + use_channels: + - 0 + handles: {} + placeholder_keys: + tip_spots: unilabos_resources + result: {} + schema: + description: '' + properties: + feedback: + properties: {} + required: [] + title: LiquidHandlerPickUpTips_Feedback + type: object + goal: + properties: + offsets: + items: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: offsets + type: object + type: array + tip_spots: + items: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: tip_spots + type: object + type: array + use_channels: + items: + maximum: 2147483647 + minimum: -2147483648 + type: integer + type: array + required: + - tip_spots + - use_channels + - offsets + title: LiquidHandlerPickUpTips_Goal + type: object + result: + properties: + return_info: + type: string + success: + type: boolean + required: + - return_info + - success + title: LiquidHandlerPickUpTips_Result + type: object + required: + - goal + title: LiquidHandlerPickUpTips + type: object + type: LiquidHandlerPickUpTips + module: unilabos.devices.liquid_handling.laiyu.laiyu:TransformXYZHandler + properties: + support_touch_tip: bool + status_types: {} + type: python + config_info: [] + description: Laiyu液体处理器设备,基于pylabrobot控制 + handles: [] + icon: icon_yiyezhan.webp + init_param_schema: + config: + properties: + channel_num: + default: 1 + type: string + deck: + type: object + host: + default: 127.0.0.1 + type: string + port: + default: 9999 + type: integer + simulator: + default: true + type: string + timeout: + default: 10.0 + type: number + required: + - deck + type: object + data: + properties: {} + required: [] + type: object + version: 1.0.0 liquid_handler.prcxi: category: - liquid_handler @@ -7923,7 +9333,34 @@ liquid_handler.prcxi: touch_tip: false use_channels: - 0 - handles: {} + handles: + input: + - data_key: liquid + data_source: handle + data_type: resource + handler_key: sources + label: sources + - data_key: liquid + data_source: executor + data_type: resource + handler_key: targets + label: targets + - data_key: liquid + data_source: executor + data_type: resource + handler_key: tip_rack + label: tip_rack + output: + - data_key: liquid + data_source: handle + data_type: resource + handler_key: sources_out + label: sources + - data_key: liquid + data_source: executor + data_type: resource + handler_key: targets_out + label: targets placeholder_keys: sources: unilabos_resources targets: unilabos_resources @@ -8320,6 +9757,9 @@ liquid_handler.prcxi: simulator: default: false type: string + step_mode: + default: false + type: string timeout: type: number required: diff --git a/unilabos/registry/devices/opcua_example.yaml b/unilabos/registry/devices/opcua_example.yaml new file mode 100644 index 00000000..a7e6b4e3 --- /dev/null +++ b/unilabos/registry/devices/opcua_example.yaml @@ -0,0 +1,176 @@ +opcua_example: + category: + - opcua_example + class: + action_value_mappings: + auto-disconnect: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: disconnect参数 + type: object + type: UniLabJsonCommand + auto-load_config: + feedback: {} + goal: {} + goal_default: + config_path: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + config_path: + type: string + required: + - config_path + type: object + result: {} + required: + - goal + title: load_config参数 + type: object + type: UniLabJsonCommand + auto-refresh_node_values: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: refresh_node_values参数 + type: object + type: UniLabJsonCommand + auto-set_node_value: + feedback: {} + goal: {} + goal_default: + name: null + value: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + result: {} + required: + - goal + title: set_node_value参数 + type: object + type: UniLabJsonCommand + auto-start_node_refresh: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: start_node_refresh参数 + type: object + type: UniLabJsonCommand + auto-stop_node_refresh: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: stop_node_refresh参数 + type: object + type: UniLabJsonCommand + module: unilabos.device_comms.opcua_client.client:OpcUaClient + status_types: + node_value: String + type: python + config_info: [] + description: null + handles: [] + icon: '' + init_param_schema: + config: + properties: + config_path: + type: string + password: + type: string + refresh_interval: + default: 1.0 + type: number + url: + type: string + username: + type: string + required: + - url + type: object + data: + properties: + node_value: + type: string + required: + - node_value + type: object + version: 1.0.0 diff --git a/unilabos/registry/devices/opsky_ATR30007.yaml b/unilabos/registry/devices/opsky_ATR30007.yaml new file mode 100644 index 00000000..ee8b8871 --- /dev/null +++ b/unilabos/registry/devices/opsky_ATR30007.yaml @@ -0,0 +1,235 @@ +opsky_ATR30007: + category: + - characterization_optic + - opsky_ATR30007 + class: + action_value_mappings: + auto-ensure_connected: + feedback: {} + goal: {} + goal_default: + client: null + ip: null + name: null + port: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + client: + type: string + ip: + type: string + name: + type: string + port: + type: string + required: + - client + - name + - ip + - port + type: object + result: {} + required: + - goal + title: ensure_connected参数 + type: object + type: UniLabJsonCommand + auto-run_once: + feedback: {} + goal: {} + goal_default: + integration_time: '5000' + laser_power: '200' + norm_max: '1.0' + normalize: 'true' + save_csv: 'true' + save_plot: 'true' + handles: {} + placeholder_keys: {} + result: {} + schema: + description: 执行一次站控-扫码-拉曼流程的大函数入口,参数以字符串形式传入。 + properties: + feedback: {} + goal: + properties: + integration_time: + default: '5000' + type: string + laser_power: + default: '200' + type: string + norm_max: + default: '1.0' + type: string + normalize: + default: 'true' + type: string + save_csv: + default: 'true' + type: string + save_plot: + default: 'true' + type: string + required: [] + type: object + result: {} + required: + - goal + title: run_once参数 + type: object + type: UniLabJsonCommand + auto-safe_read: + feedback: {} + goal: {} + goal_default: + client: null + delay: 0.3 + func: null + name: null + retries: 3 + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + client: + type: string + delay: + default: 0.3 + type: string + func: + type: string + name: + type: string + retries: + default: 3 + type: string + required: + - client + - name + - func + type: object + result: {} + required: + - goal + title: safe_read参数 + type: object + type: UniLabJsonCommand + auto-safe_write: + feedback: {} + goal: {} + goal_default: + client: null + delay: 0.3 + func: null + name: null + retries: 3 + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + client: + type: string + delay: + default: 0.3 + type: string + func: + type: string + name: + type: string + retries: + default: 3 + type: string + required: + - client + - name + - func + type: object + result: {} + required: + - goal + title: safe_write参数 + type: object + type: UniLabJsonCommand + auto-wait_with_quit_check: + feedback: {} + goal: {} + goal_default: + addr_quit: 270 + robot: null + seconds: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + addr_quit: + default: 270 + type: string + robot: + type: string + seconds: + type: string + required: + - robot + - seconds + type: object + result: {} + required: + - goal + title: wait_with_quit_check参数 + type: object + type: UniLabJsonCommand + module: unilabos.devices.opsky_Raman.opsky_ATR30007:opsky_ATR30007 + status_types: {} + type: python + config_info: [] + description: OPSKY ATR30007 光纤拉曼模块,提供单一入口大函数以执行一次完整流程。 + handles: [] + icon: '' + init_param_schema: + config: + properties: + plc_ip: + default: 192.168.1.88 + type: string + plc_port: + default: 502 + type: integer + robot_ip: + default: 192.168.1.200 + type: string + robot_port: + default: 502 + type: integer + scan_csv_file: + default: scan_results.csv + type: string + required: [] + type: object + data: + properties: {} + required: [] + type: object + version: 1.0.0 diff --git a/unilabos/registry/devices/reaction_station_bioyond.yaml b/unilabos/registry/devices/reaction_station_bioyond.yaml index 0967ef42..b7d10a60 100644 --- a/unilabos/registry/devices/reaction_station_bioyond.yaml +++ b/unilabos/registry/devices/reaction_station_bioyond.yaml @@ -1 +1,816 @@ -{} +reaction_station.bioyond: + category: + - work_station + - reaction_station_bioyond + class: + action_value_mappings: + auto-create_order: + feedback: {} + goal: {} + goal_default: + json_str: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + json_str: + type: string + required: + - json_str + type: object + result: {} + required: + - goal + title: create_order参数 + type: object + type: UniLabJsonCommand + auto-hard_delete_merged_workflows: + feedback: {} + goal: {} + goal_default: + workflow_ids: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + workflow_ids: + items: + type: string + type: array + required: + - workflow_ids + type: object + result: {} + required: + - goal + title: hard_delete_merged_workflows参数 + type: object + type: UniLabJsonCommand + auto-merge_workflow_with_parameters: + feedback: {} + goal: {} + goal_default: + json_str: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + json_str: + type: string + required: + - json_str + type: object + result: {} + required: + - goal + title: merge_workflow_with_parameters参数 + type: object + type: UniLabJsonCommand + auto-process_temperature_cutoff_report: + feedback: {} + goal: {} + goal_default: + report_request: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + report_request: + type: string + required: + - report_request + type: object + result: {} + required: + - goal + title: process_temperature_cutoff_report参数 + type: object + type: UniLabJsonCommand + auto-process_web_workflows: + feedback: {} + goal: {} + goal_default: + web_workflow_json: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + web_workflow_json: + type: string + required: + - web_workflow_json + type: object + result: {} + required: + - goal + title: process_web_workflows参数 + type: object + type: UniLabJsonCommand + auto-skip_titration_steps: + feedback: {} + goal: {} + goal_default: + preintake_id: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + preintake_id: + type: string + required: + - preintake_id + type: object + result: {} + required: + - goal + title: skip_titration_steps参数 + type: object + type: UniLabJsonCommand + auto-wait_for_multiple_orders_and_get_reports: + feedback: {} + goal: {} + goal_default: + batch_create_result: null + check_interval: 10 + timeout: 7200 + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + batch_create_result: + type: string + check_interval: + default: 10 + type: integer + timeout: + default: 7200 + type: integer + required: [] + type: object + result: {} + required: + - goal + title: wait_for_multiple_orders_and_get_reports参数 + type: object + type: UniLabJsonCommand + auto-workflow_step_query: + feedback: {} + goal: {} + goal_default: + workflow_id: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + workflow_id: + type: string + required: + - workflow_id + type: object + result: {} + required: + - goal + title: workflow_step_query参数 + type: object + type: UniLabJsonCommand + drip_back: + feedback: {} + goal: + assign_material_name: assign_material_name + temperature: temperature + time: time + titration_type: titration_type + torque_variation: torque_variation + volume: volume + goal_default: + assign_material_name: '' + temperature: '' + time: '' + titration_type: '' + torque_variation: '' + volume: '' + handles: {} + result: {} + schema: + description: 滴回去 + properties: + feedback: {} + goal: + properties: + assign_material_name: + description: 物料名称(不能为空) + type: string + temperature: + description: 温度设定(°C) + type: string + time: + description: 观察时间(分钟) + type: string + titration_type: + description: 是否滴定(1=否, 2=是) + type: string + torque_variation: + description: 是否观察 (1=否, 2=是) + type: string + volume: + description: 分液公式(μL) + type: string + required: + - volume + - assign_material_name + - time + - torque_variation + - titration_type + - temperature + type: object + result: {} + required: + - goal + title: drip_back参数 + type: object + type: UniLabJsonCommand + extract_actuals_from_batch_reports: + feedback: {} + goal: + batch_reports_result: batch_reports_result + goal_default: + batch_reports_result: '' + handles: + input: + - data_key: batch_reports_result + data_source: handle + data_type: string + handler_key: BATCH_REPORTS_RESULT + io_type: source + label: Batch Order Completion Reports + output: + - data_key: return_info + data_source: executor + data_type: string + handler_key: ACTUALS_EXTRACTED + io_type: sink + label: Extracted Actuals + result: + return_info: return_info + schema: + description: 从批量任务完成报告中提取每个订单的实际加料量,输出extracted列表。 + properties: + feedback: {} + goal: + properties: + batch_reports_result: + description: 批量任务完成信息JSON字符串或对象,包含reports数组 + type: string + required: + - batch_reports_result + type: object + result: + properties: + return_info: + description: JSON字符串,包含actuals数组,每项含order_code, order_id, actualTargetWeigh, + actualVolume + type: string + required: + - return_info + title: extract_actuals_from_batch_reports结果 + type: object + required: + - goal + title: extract_actuals_from_batch_reports参数 + type: object + type: UniLabJsonCommand + liquid_feeding_beaker: + feedback: {} + goal: + assign_material_name: assign_material_name + temperature: temperature + time: time + titration_type: titration_type + torque_variation: torque_variation + volume: volume + goal_default: + assign_material_name: '' + temperature: '' + time: '' + titration_type: '' + torque_variation: '' + volume: '' + handles: {} + result: {} + schema: + description: 液体进料烧杯 + properties: + feedback: {} + goal: + properties: + assign_material_name: + description: 物料名称 + type: string + temperature: + description: 温度设定(°C) + type: string + time: + description: 观察时间(分钟) + type: string + titration_type: + description: 是否滴定(1=否, 2=是) + type: string + torque_variation: + description: 是否观察 (1=否, 2=是) + type: string + volume: + description: 分液公式(μL) + type: string + required: + - volume + - assign_material_name + - time + - torque_variation + - titration_type + - temperature + type: object + result: {} + required: + - goal + title: liquid_feeding_beaker参数 + type: object + type: UniLabJsonCommand + liquid_feeding_solvents: + feedback: {} + goal: + assign_material_name: assign_material_name + solvents: solvents + temperature: temperature + time: time + titration_type: titration_type + torque_variation: torque_variation + volume: volume + goal_default: + assign_material_name: '' + solvents: '' + temperature: '25.00' + time: '360' + titration_type: '1' + torque_variation: '2' + volume: '' + handles: + input: + - data_key: solvents + data_source: handle + data_type: object + handler_key: solvents + io_type: source + label: Solvents Data From Calculation Node + result: {} + schema: + description: 液体投料-溶剂。可以直接提供volume(μL),或通过solvents对象自动从additional_solvent(mL)计算volume。 + properties: + feedback: {} + goal: + properties: + assign_material_name: + description: 物料名称 + type: string + solvents: + description: '溶剂信息对象(可选),包含: additional_solvent(溶剂体积mL), total_liquid_volume(总液体体积mL)。如果提供,将自动计算volume' + type: string + temperature: + default: '25.00' + description: 温度设定(°C),默认25.00 + type: string + time: + default: '360' + description: 观察时间(分钟),默认360 + type: string + titration_type: + default: '1' + description: 是否滴定(1=否, 2=是),默认1 + type: string + torque_variation: + default: '2' + description: 是否观察 (1=否, 2=是),默认2 + type: string + volume: + description: 分液量(μL)。可直接提供,或通过solvents参数自动计算 + type: string + required: + - assign_material_name + type: object + result: {} + required: + - goal + title: liquid_feeding_solvents参数 + type: object + type: UniLabJsonCommand + liquid_feeding_titration: + feedback: {} + goal: + assign_material_name: assign_material_name + extracted_actuals: extracted_actuals + feeding_order_data: feeding_order_data + temperature: temperature + time: time + titration_type: titration_type + torque_variation: torque_variation + volume_formula: volume_formula + x_value: x_value + goal_default: + assign_material_name: '' + extracted_actuals: '' + feeding_order_data: '' + temperature: '25.00' + time: '90' + titration_type: '2' + torque_variation: '2' + volume_formula: '' + x_value: '' + handles: + input: + - data_key: extracted_actuals + data_source: handle + data_type: string + handler_key: ACTUALS_EXTRACTED + io_type: source + label: Extracted Actuals From Reports + - data_key: feeding_order_data + data_source: handle + data_type: object + handler_key: feeding_order + io_type: source + label: Feeding Order Data From Calculation Node + result: {} + schema: + description: 液体进料(滴定)。支持两种模式:1)直接提供volume_formula;2)自动计算-提供x_value+feeding_order_data+extracted_actuals,系统自动生成公式"1000*(m二酐-x)*V二酐滴定/m二酐滴定" + properties: + feedback: {} + goal: + properties: + assign_material_name: + description: 物料名称 + type: string + extracted_actuals: + description: 从报告提取的实际加料量JSON字符串,包含actualTargetWeigh(m二酐滴定)和actualVolume(V二酐滴定) + type: string + feeding_order_data: + description: 'feeding_order JSON对象,用于获取m二酐值(type为main_anhydride的amount)。示例: + {"feeding_order": [{"type": "main_anhydride", "amount": 1.915}]}' + type: string + temperature: + default: '25.00' + description: 温度设定(°C),默认25.00 + type: string + time: + default: '90' + description: 观察时间(分钟),默认90 + type: string + titration_type: + default: '2' + description: 是否滴定(1=否, 2=是),默认2 + type: string + torque_variation: + default: '2' + description: 是否观察 (1=否, 2=是),默认2 + type: string + volume_formula: + description: 分液公式(μL)。可直接提供固定公式,或留空由系统根据x_value、feeding_order_data、extracted_actuals自动生成 + type: string + x_value: + description: 公式中的x值,手工输入,格式为"{{1-2-3}}"(包含双花括号)。用于自动公式计算 + type: string + required: + - assign_material_name + type: object + result: {} + required: + - goal + title: liquid_feeding_titration参数 + type: object + type: UniLabJsonCommand + liquid_feeding_vials_non_titration: + feedback: {} + goal: + assign_material_name: assign_material_name + temperature: temperature + time: time + titration_type: titration_type + torque_variation: torque_variation + volume_formula: volume_formula + goal_default: + assign_material_name: '' + temperature: '' + time: '' + titration_type: '' + torque_variation: '' + volume_formula: '' + handles: {} + result: {} + schema: + description: 液体进料小瓶(非滴定) + properties: + feedback: {} + goal: + properties: + assign_material_name: + description: 物料名称 + type: string + temperature: + description: 温度设定(°C) + type: string + time: + description: 观察时间(分钟) + type: string + titration_type: + description: 是否滴定(1=否, 2=是) + type: string + torque_variation: + description: 是否观察 (1=否, 2=是) + type: string + volume_formula: + description: 分液公式(μL) + type: string + required: + - volume_formula + - assign_material_name + - time + - torque_variation + - titration_type + - temperature + type: object + result: {} + required: + - goal + title: liquid_feeding_vials_non_titration参数 + type: object + type: UniLabJsonCommand + process_and_execute_workflow: + feedback: {} + goal: + task_name: task_name + workflow_name: workflow_name + goal_default: + task_name: '' + workflow_name: '' + handles: {} + result: {} + schema: + description: 处理并执行工作流 + properties: + feedback: {} + goal: + properties: + task_name: + description: 任务名称 + type: string + workflow_name: + description: 工作流名称 + type: string + required: + - workflow_name + - task_name + type: object + result: {} + required: + - goal + title: process_and_execute_workflow参数 + type: object + type: UniLabJsonCommand + reactor_taken_in: + feedback: {} + goal: + assign_material_name: assign_material_name + cutoff: cutoff + temperature: temperature + goal_default: + assign_material_name: '' + cutoff: '' + temperature: '' + handles: {} + result: {} + schema: + description: 反应器放入 - 将反应器放入工作站,配置物料名称、粘度上限和温度参数 + properties: + feedback: {} + goal: + properties: + assign_material_name: + description: 物料名称 + type: string + cutoff: + description: 粘度上限 + type: string + temperature: + description: 温度设定(°C) + type: string + required: + - cutoff + - temperature + - assign_material_name + type: object + result: {} + required: + - goal + title: reactor_taken_in参数 + type: object + type: UniLabJsonCommand + reactor_taken_out: + feedback: {} + goal: {} + goal_default: {} + handles: {} + result: {} + schema: + description: 反应器取出 - 从工作站中取出反应器,无需参数的简单操作 + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: + properties: + code: + description: 操作结果代码(1表示成功,0表示失败) + type: integer + return_info: + description: 操作结果详细信息 + type: string + type: object + required: + - goal + title: reactor_taken_out参数 + type: object + type: UniLabJsonCommand + solid_feeding_vials: + feedback: {} + goal: + assign_material_name: assign_material_name + material_id: material_id + temperature: temperature + time: time + torque_variation: torque_variation + goal_default: + assign_material_name: '' + material_id: '' + temperature: '' + time: '' + torque_variation: '' + handles: {} + result: {} + schema: + description: 固体进料小瓶 - 通过小瓶向反应器中添加固体物料,支持多种粉末类型(盐、面粉、BTDA) + properties: + feedback: {} + goal: + properties: + assign_material_name: + description: 物料名称(用于获取试剂瓶位ID) + type: string + material_id: + description: 粉末类型ID,1=盐(21分钟),2=面粉(27分钟),3=BTDA(38分钟) + type: string + temperature: + description: 温度设定(°C) + type: string + time: + description: 观察时间(分钟) + type: string + torque_variation: + description: 是否观察 (1=否, 2=是) + type: string + required: + - assign_material_name + - material_id + - time + - torque_variation + - temperature + type: object + result: {} + required: + - goal + title: solid_feeding_vials参数 + type: object + type: UniLabJsonCommand + module: unilabos.devices.workstation.bioyond_studio.reaction_station:BioyondReactionStation + protocol_type: [] + status_types: + workflow_sequence: String + type: python + config_info: [] + description: Bioyond反应站 + handles: [] + icon: reaction_station.webp + init_param_schema: + config: + properties: + config: + type: object + deck: + type: string + protocol_type: + type: string + required: [] + type: object + data: + properties: + workflow_sequence: + items: + type: string + type: array + required: + - workflow_sequence + type: object + version: 1.0.0 +reaction_station.reactor: + category: + - reactor + - reaction_station_bioyond + class: + action_value_mappings: + auto-update_metrics: + feedback: {} + goal: {} + goal_default: + payload: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + payload: + type: object + required: + - payload + type: object + result: {} + required: + - goal + title: update_metrics参数 + type: object + type: UniLabJsonCommand + module: unilabos.devices.workstation.bioyond_studio.reaction_station:BioyondReactor + status_types: {} + type: python + config_info: [] + description: 反应站子设备-反应器 + handles: [] + icon: reaction_station.webp + init_param_schema: + config: + properties: + config: + type: object + deck: + type: string + protocol_type: + type: string + required: [] + type: object + data: + properties: {} + required: [] + type: object + version: 1.0.0 diff --git a/unilabos/registry/devices/robot_arm.yaml b/unilabos/registry/devices/robot_arm.yaml index 7c049ff7..147eab4d 100644 --- a/unilabos/registry/devices/robot_arm.yaml +++ b/unilabos/registry/devices/robot_arm.yaml @@ -1,4 +1,4 @@ -robotic_arm.SCARA_with_slider.virtual: +robotic_arm.SCARA_with_slider.moveit.virtual: category: - robot_arm class: @@ -354,6 +354,7 @@ robotic_arm.SCARA_with_slider.virtual: type: object model: mesh: arm_slider + path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/arm_slider/macro_device.xacro type: device version: 1.0.0 robotic_arm.UR: diff --git a/unilabos/registry/devices/work_station.yaml b/unilabos/registry/devices/work_station.yaml new file mode 100644 index 00000000..e1be7f3d --- /dev/null +++ b/unilabos/registry/devices/work_station.yaml @@ -0,0 +1,6051 @@ +workstation: + category: + - work_station + class: + action_value_mappings: + AGVTransferProtocol: + feedback: {} + goal: + from_repo: from_repo + from_repo_position: from_repo_position + to_repo: to_repo + to_repo_position: to_repo_position + goal_default: + from_repo: + category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + from_repo_position: '' + to_repo: + category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + to_repo_position: '' + handles: {} + result: {} + schema: + description: '' + properties: + feedback: + properties: + status: + type: string + required: + - status + title: AGVTransfer_Feedback + type: object + goal: + properties: + from_repo: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: from_repo + type: object + from_repo_position: + type: string + to_repo: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: to_repo + type: object + to_repo_position: + type: string + required: + - from_repo + - from_repo_position + - to_repo + - to_repo_position + title: AGVTransfer_Goal + type: object + result: + properties: + return_info: + type: string + success: + type: boolean + required: + - return_info + - success + title: AGVTransfer_Result + type: object + required: + - goal + title: AGVTransfer + type: object + type: AGVTransfer + AddProtocol: + feedback: {} + goal: + amount: amount + equiv: equiv + event: event + mass: mass + mol: mol + purpose: purpose + rate_spec: rate_spec + ratio: ratio + reagent: reagent + stir: stir + stir_speed: stir_speed + time: time + vessel: vessel + viscous: viscous + volume: volume + goal_default: + amount: '' + equiv: '' + event: '' + mass: '' + mol: '' + purpose: '' + rate_spec: '' + ratio: '' + reagent: '' + stir: false + stir_speed: 0.0 + time: '' + vessel: + category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + viscous: false + volume: '' + handles: + input: + - data_key: vessel + data_source: handle + data_type: resource + handler_key: Vessel + label: Vessel + - data_key: reagent + data_source: handle + data_type: resource + handler_key: reagent + label: Reagent + output: + - data_key: vessel + data_source: executor + data_type: resource + handler_key: VesselOut + label: Vessel + placeholder_keys: + vessel: unilabos_resources + result: {} + schema: + description: '' + properties: + feedback: + properties: + current_status: + type: string + progress: + type: number + required: + - progress + - current_status + title: Add_Feedback + type: object + goal: + properties: + amount: + type: string + equiv: + type: string + event: + type: string + mass: + type: string + mol: + type: string + purpose: + type: string + rate_spec: + type: string + ratio: + type: string + reagent: + type: string + stir: + type: boolean + stir_speed: + type: number + time: + type: string + vessel: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: vessel + type: object + viscous: + type: boolean + volume: + type: string + required: + - vessel + - reagent + - volume + - mass + - amount + - time + - stir + - stir_speed + - viscous + - purpose + - event + - mol + - rate_spec + - equiv + - ratio + title: Add_Goal + type: object + result: + properties: + message: + type: string + return_info: + type: string + success: + type: boolean + required: + - success + - message + - return_info + title: Add_Result + type: object + required: + - goal + title: Add + type: object + type: Add + AdjustPHProtocol: + feedback: {} + goal: + ph_value: ph_value + reagent: reagent + settling_time: settling_time + stir: stir + stir_speed: stir_speed + stir_time: stir_time + vessel: vessel + volume: volume + goal_default: + ph_value: 0.0 + reagent: '' + vessel: + category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + handles: + input: + - data_key: vessel + data_source: handle + data_type: resource + handler_key: Vessel + label: Vessel + - data_key: reagent + data_source: handle + data_type: resource + handler_key: reagent + label: Reagent + output: + - data_key: vessel + data_source: executor + data_type: resource + handler_key: VesselOut + label: Vessel + placeholder_keys: + vessel: unilabos_resources + result: {} + schema: + description: '' + properties: + feedback: + properties: + progress: + type: number + status: + type: string + required: + - status + - progress + title: AdjustPH_Feedback + type: object + goal: + properties: + ph_value: + type: number + reagent: + type: string + vessel: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: vessel + type: object + required: + - vessel + - ph_value + - reagent + title: AdjustPH_Goal + type: object + result: + properties: + message: + type: string + return_info: + type: string + success: + type: boolean + required: + - success + - message + - return_info + title: AdjustPH_Result + type: object + required: + - goal + title: AdjustPH + type: object + type: AdjustPH + CentrifugeProtocol: + feedback: {} + goal: + speed: speed + temp: temp + time: time + vessel: vessel + goal_default: + speed: 0.0 + temp: 0.0 + time: 0.0 + vessel: + category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + handles: + input: + - data_key: vessel + data_source: handle + data_type: resource + handler_key: Vessel + label: Vessel + output: + - data_key: vessel + data_source: executor + data_type: resource + handler_key: VesselOut + label: Vessel + placeholder_keys: + vessel: unilabos_resources + result: {} + schema: + description: '' + properties: + feedback: + properties: + current_speed: + type: number + current_status: + type: string + current_temp: + type: number + progress: + type: number + required: + - progress + - current_speed + - current_temp + - current_status + title: Centrifuge_Feedback + type: object + goal: + properties: + speed: + type: number + temp: + type: number + time: + type: number + vessel: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: vessel + type: object + required: + - vessel + - speed + - time + - temp + title: Centrifuge_Goal + type: object + result: + properties: + message: + type: string + return_info: + type: string + success: + type: boolean + required: + - success + - message + - return_info + title: Centrifuge_Result + type: object + required: + - goal + title: Centrifuge + type: object + type: Centrifuge + CleanProtocol: + feedback: {} + goal: + repeats: repeats + solvent: solvent + temp: temp + vessel: vessel + volume: volume + goal_default: + repeats: 0 + solvent: '' + temp: 0.0 + vessel: + category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + volume: 0.0 + handles: + input: + - data_key: vessel + data_source: handle + data_type: resource + handler_key: Vessel + label: Vessel + - data_key: solvent + data_source: handle + data_type: resource + handler_key: solvent + label: Solvent + output: + - data_key: vessel + data_source: executor + data_type: resource + handler_key: VesselOut + label: Vessel + placeholder_keys: + vessel: unilabos_resources + result: {} + schema: + description: '' + properties: + feedback: + properties: + current_device: + type: string + status: + type: string + time_remaining: + properties: + nanosec: + maximum: 4294967295 + minimum: 0 + type: integer + sec: + maximum: 2147483647 + minimum: -2147483648 + type: integer + required: + - sec + - nanosec + title: time_remaining + type: object + time_spent: + properties: + nanosec: + maximum: 4294967295 + minimum: 0 + type: integer + sec: + maximum: 2147483647 + minimum: -2147483648 + type: integer + required: + - sec + - nanosec + title: time_spent + type: object + required: + - status + - current_device + - time_spent + - time_remaining + title: Clean_Feedback + type: object + goal: + properties: + repeats: + maximum: 2147483647 + minimum: -2147483648 + type: integer + solvent: + type: string + temp: + type: number + vessel: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: vessel + type: object + volume: + type: number + required: + - vessel + - solvent + - volume + - temp + - repeats + title: Clean_Goal + type: object + result: + properties: + return_info: + type: string + success: + type: boolean + required: + - return_info + - success + title: Clean_Result + type: object + required: + - goal + title: Clean + type: object + type: Clean + CleanVesselProtocol: + feedback: {} + goal: + repeats: repeats + solvent: solvent + temp: temp + vessel: vessel + volume: volume + goal_default: + repeats: 0 + solvent: '' + temp: 0.0 + vessel: + category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + volume: 0.0 + handles: + input: + - data_key: vessel + data_source: handle + data_type: resource + handler_key: Vessel + label: Vessel + - data_key: solvent + data_source: handle + data_type: resource + handler_key: solvent + label: Solvent + output: + - data_key: vessel + data_source: executor + data_type: resource + handler_key: VesselOut + label: Vessel + placeholder_keys: + vessel: unilabos_resources + result: {} + schema: + description: '' + properties: + feedback: + properties: + progress: + type: number + status: + type: string + required: + - status + - progress + title: CleanVessel_Feedback + type: object + goal: + properties: + repeats: + maximum: 2147483647 + minimum: -2147483648 + type: integer + solvent: + type: string + temp: + type: number + vessel: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: vessel + type: object + volume: + type: number + required: + - vessel + - solvent + - volume + - temp + - repeats + title: CleanVessel_Goal + type: object + result: + properties: + message: + type: string + return_info: + type: string + success: + type: boolean + required: + - success + - message + - return_info + title: CleanVessel_Result + type: object + required: + - goal + title: CleanVessel + type: object + type: CleanVessel + DissolveProtocol: + feedback: {} + goal: + amount: amount + event: event + mass: mass + mol: mol + reagent: reagent + solvent: solvent + stir_speed: stir_speed + temp: temp + time: time + vessel: vessel + volume: volume + goal_default: + amount: '' + event: '' + mass: '' + mol: '' + reagent: '' + solvent: '' + stir_speed: 0.0 + temp: '' + time: '' + vessel: + category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + volume: '' + handles: + input: + - data_key: vessel + data_source: handle + data_type: resource + handler_key: Vessel + label: Vessel + - data_key: solvent + data_source: handle + data_type: resource + handler_key: solvent + label: Solvent + - data_key: reagent + data_source: handle + data_type: resource + handler_key: reagent + label: Reagent + output: + - data_key: vessel + data_source: executor + data_type: resource + handler_key: VesselOut + label: Vessel + placeholder_keys: + vessel: unilabos_resources + result: {} + schema: + description: '' + properties: + feedback: + properties: + progress: + type: number + status: + type: string + required: + - status + - progress + title: Dissolve_Feedback + type: object + goal: + properties: + amount: + type: string + event: + type: string + mass: + type: string + mol: + type: string + reagent: + type: string + solvent: + type: string + stir_speed: + type: number + temp: + type: string + time: + type: string + vessel: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: vessel + type: object + volume: + type: string + required: + - vessel + - solvent + - volume + - amount + - temp + - time + - stir_speed + - mass + - mol + - reagent + - event + title: Dissolve_Goal + type: object + result: + properties: + message: + type: string + return_info: + type: string + success: + type: boolean + required: + - success + - message + - return_info + title: Dissolve_Result + type: object + required: + - goal + title: Dissolve + type: object + type: Dissolve + DryProtocol: + feedback: {} + goal: + compound: compound + vessel: vessel + goal_default: + compound: '' + vessel: + category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + handles: + input: + - data_key: vessel + data_source: handle + data_type: resource + handler_key: Vessel + label: Vessel + output: + - data_key: vessel + data_source: executor + data_type: resource + handler_key: VesselOut + label: Vessel + placeholder_keys: + vessel: unilabos_resources + result: {} + schema: + description: '' + properties: + feedback: + properties: + progress: + type: number + status: + type: string + required: + - status + - progress + title: Dry_Feedback + type: object + goal: + properties: + compound: + type: string + vessel: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: vessel + type: object + required: + - compound + - vessel + title: Dry_Goal + type: object + result: + properties: + message: + type: string + return_info: + type: string + success: + type: boolean + required: + - success + - message + - return_info + title: Dry_Result + type: object + required: + - goal + title: Dry + type: object + type: Dry + EvacuateAndRefillProtocol: + feedback: {} + goal: + gas: gas + vessel: vessel + goal_default: + gas: '' + vessel: + category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + handles: + input: + - data_key: vessel + data_source: handle + data_type: resource + handler_key: Vessel + label: Vessel + output: + - data_key: vessel + data_source: executor + data_type: resource + handler_key: VesselOut + label: Vessel + placeholder_keys: + vessel: unilabos_resources + result: {} + schema: + description: '' + properties: + feedback: + properties: + current_device: + type: string + status: + type: string + time_remaining: + properties: + nanosec: + maximum: 4294967295 + minimum: 0 + type: integer + sec: + maximum: 2147483647 + minimum: -2147483648 + type: integer + required: + - sec + - nanosec + title: time_remaining + type: object + time_spent: + properties: + nanosec: + maximum: 4294967295 + minimum: 0 + type: integer + sec: + maximum: 2147483647 + minimum: -2147483648 + type: integer + required: + - sec + - nanosec + title: time_spent + type: object + required: + - status + - current_device + - time_spent + - time_remaining + title: EvacuateAndRefill_Feedback + type: object + goal: + properties: + gas: + type: string + vessel: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: vessel + type: object + required: + - vessel + - gas + title: EvacuateAndRefill_Goal + type: object + result: + properties: + return_info: + type: string + success: + type: boolean + required: + - return_info + - success + title: EvacuateAndRefill_Result + type: object + required: + - goal + title: EvacuateAndRefill + type: object + type: EvacuateAndRefill + EvaporateProtocol: + feedback: {} + goal: + pressure: pressure + solvent: solvent + stir_speed: stir_speed + temp: temp + time: time + vessel: vessel + goal_default: + pressure: 0.0 + solvent: '' + stir_speed: 0.0 + temp: 0.0 + time: '' + vessel: + category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + handles: + input: + - data_key: vessel + data_source: handle + data_type: resource + handler_key: Vessel + label: Evaporation Vessel + - data_key: solvent + data_source: handle + data_type: resource + handler_key: solvent + label: Eluting Solvent + output: + - data_key: vessel + data_source: handle + data_type: resource + handler_key: VesselOut + label: Evaporation Vessel + placeholder_keys: + vessel: unilabos_nodes + result: {} + schema: + description: '' + properties: + feedback: + properties: + current_device: + type: string + status: + type: string + time_remaining: + properties: + nanosec: + maximum: 4294967295 + minimum: 0 + type: integer + sec: + maximum: 2147483647 + minimum: -2147483648 + type: integer + required: + - sec + - nanosec + title: time_remaining + type: object + time_spent: + properties: + nanosec: + maximum: 4294967295 + minimum: 0 + type: integer + sec: + maximum: 2147483647 + minimum: -2147483648 + type: integer + required: + - sec + - nanosec + title: time_spent + type: object + required: + - status + - current_device + - time_spent + - time_remaining + title: Evaporate_Feedback + type: object + goal: + properties: + pressure: + type: number + solvent: + type: string + stir_speed: + type: number + temp: + type: number + time: + type: string + vessel: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: vessel + type: object + required: + - vessel + - pressure + - temp + - time + - stir_speed + - solvent + title: Evaporate_Goal + type: object + result: + properties: + return_info: + type: string + success: + type: boolean + required: + - return_info + - success + title: Evaporate_Result + type: object + required: + - goal + title: Evaporate + type: object + type: Evaporate + FilterProtocol: + feedback: {} + goal: + continue_heatchill: continue_heatchill + filtrate_vessel: filtrate_vessel + stir: stir + stir_speed: stir_speed + temp: temp + vessel: vessel + volume: volume + goal_default: + continue_heatchill: false + filtrate_vessel: + category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + stir: false + stir_speed: 0.0 + temp: 0.0 + vessel: + category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + volume: 0.0 + handles: + input: + - data_key: vessel + data_source: handle + data_type: resource + handler_key: Vessel + label: Vessel + - data_key: filtrate_vessel + data_source: handle + data_type: resource + handler_key: FiltrateVessel + label: Filtrate Vessel + output: + - data_key: vessel + data_source: executor + data_type: resource + handler_key: VesselOut + label: Vessel + - data_key: filtrate_vessel + data_source: executor + data_type: resource + handler_key: FiltrateOut + label: Filtrate Vessel + placeholder_keys: + filtrate_vessel: unilabos_resources + vessel: unilabos_nodes + result: {} + schema: + description: '' + properties: + feedback: + properties: + current_status: + type: string + current_temp: + type: number + filtered_volume: + type: number + progress: + type: number + required: + - progress + - current_temp + - filtered_volume + - current_status + title: Filter_Feedback + type: object + goal: + properties: + continue_heatchill: + type: boolean + filtrate_vessel: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: filtrate_vessel + type: object + stir: + type: boolean + stir_speed: + type: number + temp: + type: number + vessel: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: vessel + type: object + volume: + type: number + required: + - vessel + - filtrate_vessel + - stir + - stir_speed + - temp + - continue_heatchill + - volume + title: Filter_Goal + type: object + result: + properties: + message: + type: string + return_info: + type: string + success: + type: boolean + required: + - success + - message + - return_info + title: Filter_Result + type: object + required: + - goal + title: Filter + type: object + type: Filter + FilterThroughProtocol: + feedback: {} + goal: + eluting_repeats: eluting_repeats + eluting_solvent: eluting_solvent + eluting_volume: eluting_volume + filter_through: filter_through + from_vessel: from_vessel + residence_time: residence_time + to_vessel: to_vessel + goal_default: + eluting_repeats: 0 + eluting_solvent: '' + eluting_volume: 0.0 + filter_through: + category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + from_vessel: + category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + residence_time: 0.0 + to_vessel: + category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + handles: + input: + - data_key: vessel + data_source: handle + data_type: resource + handler_key: FromVessel + label: From Vessel + - data_key: vessel + data_source: executor + data_type: resource + handler_key: ToVessel + label: To Vessel + - data_key: solvent + data_source: handle + data_type: resource + handler_key: solvent + label: Eluting Solvent + output: + - data_key: vessel + data_source: handle + data_type: resource + handler_key: FromVesselOut + label: From Vessel + - data_key: vessel + data_source: executor + data_type: resource + handler_key: ToVesselOut + label: To Vessel + placeholder_keys: + from_vessel: unilabos_resources + to_vessel: unilabos_resources + result: {} + schema: + description: '' + properties: + feedback: + properties: + progress: + type: number + status: + type: string + required: + - status + - progress + title: FilterThrough_Feedback + type: object + goal: + properties: + eluting_repeats: + maximum: 2147483647 + minimum: -2147483648 + type: integer + eluting_solvent: + type: string + eluting_volume: + type: number + filter_through: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: filter_through + type: object + from_vessel: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: from_vessel + type: object + residence_time: + type: number + to_vessel: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: to_vessel + type: object + required: + - from_vessel + - to_vessel + - filter_through + - eluting_solvent + - eluting_volume + - eluting_repeats + - residence_time + title: FilterThrough_Goal + type: object + result: + properties: + message: + type: string + return_info: + type: string + success: + type: boolean + required: + - success + - message + - return_info + title: FilterThrough_Result + type: object + required: + - goal + title: FilterThrough + type: object + type: FilterThrough + HeatChillProtocol: + feedback: {} + goal: + pressure: pressure + purpose: purpose + reflux_solvent: reflux_solvent + stir: stir + stir_speed: stir_speed + temp: temp + temp_spec: temp_spec + time: time + time_spec: time_spec + vessel: vessel + goal_default: + pressure: '' + purpose: '' + reflux_solvent: '' + stir: false + stir_speed: 0.0 + temp: 0.0 + temp_spec: '' + time: '' + time_spec: '' + vessel: + category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + handles: + input: + - data_key: vessel + data_source: handle + data_type: resource + handler_key: Vessel + label: Vessel + output: + - data_key: vessel + data_source: executor + data_type: resource + handler_key: VesselOut + label: Vessel + placeholder_keys: + vessel: unilabos_resources + result: {} + schema: + description: '' + properties: + feedback: + properties: + status: + type: string + required: + - status + title: HeatChill_Feedback + type: object + goal: + properties: + pressure: + type: string + purpose: + type: string + reflux_solvent: + type: string + stir: + type: boolean + stir_speed: + type: number + temp: + type: number + temp_spec: + type: string + time: + type: string + time_spec: + type: string + vessel: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: vessel + type: object + required: + - vessel + - temp + - time + - temp_spec + - time_spec + - pressure + - reflux_solvent + - stir + - stir_speed + - purpose + title: HeatChill_Goal + type: object + result: + properties: + message: + type: string + return_info: + type: string + success: + type: boolean + required: + - success + - message + - return_info + title: HeatChill_Result + type: object + required: + - goal + title: HeatChill + type: object + type: HeatChill + HeatChillStartProtocol: + feedback: {} + goal: + purpose: purpose + temp: temp + vessel: vessel + goal_default: + purpose: '' + temp: 0.0 + vessel: + category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + handles: + input: + - data_key: vessel + data_source: handle + data_type: resource + handler_key: Vessel + label: Vessel + output: + - data_key: vessel + data_source: executor + data_type: resource + handler_key: VesselOut + label: Vessel + placeholder_keys: + vessel: unilabos_resources + result: {} + schema: + description: '' + properties: + feedback: + properties: + status: + type: string + required: + - status + title: HeatChillStart_Feedback + type: object + goal: + properties: + purpose: + type: string + temp: + type: number + vessel: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: vessel + type: object + required: + - vessel + - temp + - purpose + title: HeatChillStart_Goal + type: object + result: + properties: + return_info: + type: string + success: + type: boolean + required: + - return_info + - success + title: HeatChillStart_Result + type: object + required: + - goal + title: HeatChillStart + type: object + type: HeatChillStart + HeatChillStopProtocol: + feedback: {} + goal: + vessel: vessel + goal_default: + vessel: + category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + handles: + input: + - data_key: vessel + data_source: handle + data_type: resource + handler_key: Vessel + label: Vessel + output: + - data_key: vessel + data_source: executor + data_type: resource + handler_key: VesselOut + label: Vessel + placeholder_keys: + vessel: unilabos_resources + result: {} + schema: + description: '' + properties: + feedback: + properties: + status: + type: string + required: + - status + title: HeatChillStop_Feedback + type: object + goal: + properties: + vessel: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: vessel + type: object + required: + - vessel + title: HeatChillStop_Goal + type: object + result: + properties: + return_info: + type: string + success: + type: boolean + required: + - return_info + - success + title: HeatChillStop_Result + type: object + required: + - goal + title: HeatChillStop + type: object + type: HeatChillStop + HydrogenateProtocol: + feedback: {} + goal: + temp: temp + time: time + vessel: vessel + goal_default: + temp: '' + time: '' + vessel: + category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + handles: + input: + - data_key: vessel + data_source: handle + data_type: resource + handler_key: Vessel + label: Vessel + output: + - data_key: vessel + data_source: executor + data_type: resource + handler_key: VesselOut + label: Vessel + placeholder_keys: + vessel: unilabos_resources + result: {} + schema: + description: '' + properties: + feedback: + properties: + progress: + type: number + status: + type: string + required: + - status + - progress + title: Hydrogenate_Feedback + type: object + goal: + properties: + temp: + type: string + time: + type: string + vessel: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: vessel + type: object + required: + - temp + - time + - vessel + title: Hydrogenate_Goal + type: object + result: + properties: + message: + type: string + return_info: + type: string + success: + type: boolean + required: + - success + - message + - return_info + title: Hydrogenate_Result + type: object + required: + - goal + title: Hydrogenate + type: object + type: Hydrogenate + PumpTransferProtocol: + feedback: {} + goal: + amount: amount + event: event + flowrate: flowrate + from_vessel: from_vessel + rate_spec: rate_spec + rinsing_repeats: rinsing_repeats + rinsing_solvent: rinsing_solvent + rinsing_volume: rinsing_volume + solid: solid + through: through + time: time + to_vessel: to_vessel + transfer_flowrate: transfer_flowrate + viscous: viscous + volume: volume + goal_default: + amount: '' + event: '' + flowrate: 0.0 + from_vessel: + category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + rate_spec: '' + rinsing_repeats: 0 + rinsing_solvent: '' + rinsing_volume: 0.0 + solid: false + through: '' + time: 0.0 + to_vessel: + category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + transfer_flowrate: 0.0 + viscous: false + volume: 0.0 + handles: + input: + - data_key: vessel + data_source: handle + data_type: resource + handler_key: FromVessel + label: From Vessel + - data_key: vessel + data_source: executor + data_type: resource + handler_key: ToVessel + label: To Vessel + - data_key: solvent + data_source: handle + data_type: resource + handler_key: solvent + label: Rinsing Solvent + output: + - data_key: vessel + data_source: handle + data_type: resource + handler_key: FromVesselOut + label: From Vessel + - data_key: vessel + data_source: executor + data_type: resource + handler_key: ToVesselOut + label: To Vessel + placeholder_keys: + from_vessel: unilabos_nodes + to_vessel: unilabos_nodes + result: {} + schema: + description: '' + properties: + feedback: + properties: + current_device: + type: string + status: + type: string + time_remaining: + properties: + nanosec: + maximum: 4294967295 + minimum: 0 + type: integer + sec: + maximum: 2147483647 + minimum: -2147483648 + type: integer + required: + - sec + - nanosec + title: time_remaining + type: object + time_spent: + properties: + nanosec: + maximum: 4294967295 + minimum: 0 + type: integer + sec: + maximum: 2147483647 + minimum: -2147483648 + type: integer + required: + - sec + - nanosec + title: time_spent + type: object + required: + - status + - current_device + - time_spent + - time_remaining + title: PumpTransfer_Feedback + type: object + goal: + properties: + amount: + type: string + event: + type: string + flowrate: + type: number + from_vessel: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: from_vessel + type: object + rate_spec: + type: string + rinsing_repeats: + maximum: 2147483647 + minimum: -2147483648 + type: integer + rinsing_solvent: + type: string + rinsing_volume: + type: number + solid: + type: boolean + through: + type: string + time: + type: number + to_vessel: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: to_vessel + type: object + transfer_flowrate: + type: number + viscous: + type: boolean + volume: + type: number + required: + - from_vessel + - to_vessel + - volume + - amount + - time + - viscous + - rinsing_solvent + - rinsing_volume + - rinsing_repeats + - solid + - flowrate + - transfer_flowrate + - rate_spec + - event + - through + title: PumpTransfer_Goal + type: object + result: + properties: + return_info: + type: string + success: + type: boolean + required: + - return_info + - success + title: PumpTransfer_Result + type: object + required: + - goal + title: PumpTransfer + type: object + type: PumpTransfer + RecrystallizeProtocol: + feedback: {} + goal: + ratio: ratio + solvent1: solvent1 + solvent2: solvent2 + vessel: vessel + volume: volume + goal_default: + ratio: '' + solvent1: '' + solvent2: '' + vessel: + category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + volume: '' + handles: + input: + - data_key: vessel + data_source: handle + data_type: resource + handler_key: Vessel + label: Vessel + - data_key: solvent1 + data_source: handle + data_type: resource + handler_key: solvent1 + label: Solvent 1 + - data_key: solvent2 + data_source: handle + data_type: resource + handler_key: solvent2 + label: Solvent 2 + output: + - data_key: vessel + data_source: executor + data_type: resource + handler_key: VesselOut + label: Vessel + placeholder_keys: + vessel: unilabos_resources + result: {} + schema: + description: '' + properties: + feedback: + properties: + progress: + type: number + status: + type: string + required: + - status + - progress + title: Recrystallize_Feedback + type: object + goal: + properties: + ratio: + type: string + solvent1: + type: string + solvent2: + type: string + vessel: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: vessel + type: object + volume: + type: string + required: + - ratio + - solvent1 + - solvent2 + - vessel + - volume + title: Recrystallize_Goal + type: object + result: + properties: + message: + type: string + return_info: + type: string + success: + type: boolean + required: + - success + - message + - return_info + title: Recrystallize_Result + type: object + required: + - goal + title: Recrystallize + type: object + type: Recrystallize + ResetHandlingProtocol: + feedback: {} + goal: + solvent: solvent + goal_default: + solvent: '' + vessel: + category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + handles: + input: + - data_key: solvent + data_source: handle + data_type: resource + handler_key: solvent + label: Solvent + output: [] + result: {} + schema: + description: '' + properties: + feedback: + properties: + progress: + type: number + status: + type: string + required: + - status + - progress + title: ResetHandling_Feedback + type: object + goal: + properties: + solvent: + type: string + vessel: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: vessel + type: object + required: + - solvent + - vessel + title: ResetHandling_Goal + type: object + result: + properties: + message: + type: string + return_info: + type: string + success: + type: boolean + required: + - success + - message + - return_info + title: ResetHandling_Result + type: object + required: + - goal + title: ResetHandling + type: object + type: ResetHandling + RunColumnProtocol: + feedback: {} + goal: + column: column + from_vessel: from_vessel + to_vessel: to_vessel + goal_default: + column: '' + from_vessel: + category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + pct1: '' + pct2: '' + ratio: '' + rf: '' + solvent1: '' + solvent2: '' + to_vessel: + category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + handles: + input: + - data_key: vessel + data_source: handle + data_type: resource + handler_key: FromVessel + label: From Vessel + - data_key: vessel + data_source: executor + data_type: resource + handler_key: ToVessel + label: To Vessel + output: + - data_key: vessel + data_source: handle + data_type: resource + handler_key: FromVesselOut + label: From Vessel + - data_key: vessel + data_source: executor + data_type: resource + handler_key: ToVesselOut + label: To Vessel + placeholder_keys: + column: unilabos_devices + from_vessel: unilabos_resources + to_vessel: unilabos_resources + result: {} + schema: + description: '' + properties: + feedback: + properties: + progress: + type: number + status: + type: string + required: + - status + - progress + title: RunColumn_Feedback + type: object + goal: + properties: + column: + type: string + from_vessel: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: from_vessel + type: object + pct1: + type: string + pct2: + type: string + ratio: + type: string + rf: + type: string + solvent1: + type: string + solvent2: + type: string + to_vessel: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: to_vessel + type: object + required: + - from_vessel + - to_vessel + - column + - rf + - pct1 + - pct2 + - solvent1 + - solvent2 + - ratio + title: RunColumn_Goal + type: object + result: + properties: + message: + type: string + return_info: + type: string + success: + type: boolean + required: + - success + - message + - return_info + title: RunColumn_Result + type: object + required: + - goal + title: RunColumn + type: object + type: RunColumn + SeparateProtocol: + feedback: {} + goal: + from_vessel: from_vessel + product_phase: product_phase + purpose: purpose + repeats: repeats + separation_vessel: separation_vessel + settling_time: settling_time + solvent: solvent + solvent_volume: solvent_volume + stir_speed: stir_speed + stir_time: stir_time + through: through + to_vessel: to_vessel + waste_phase_to_vessel: waste_phase_to_vessel + goal_default: + from_vessel: + category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + product_phase: '' + product_vessel: + category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + purpose: '' + repeats: 0 + separation_vessel: + category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + settling_time: 0.0 + solvent: '' + solvent_volume: '' + stir_speed: 0.0 + stir_time: 0.0 + through: '' + to_vessel: + category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + vessel: + category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + volume: '' + waste_phase_to_vessel: + category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + waste_vessel: + category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + handles: + input: + - data_key: vessel + data_source: handle + data_type: resource + handler_key: FromVessel + label: From Vessel + - data_key: vessel + data_source: executor + data_type: resource + handler_key: ToVessel + label: To Vessel + - data_key: solvent + data_source: handle + data_type: resource + handler_key: solvent + label: Solvent + output: + - data_key: vessel + data_source: handle + data_type: resource + handler_key: FromVesselOut + label: From Vessel + - data_key: vessel + data_source: executor + data_type: resource + handler_key: ToVesselOut + label: To Vessel + placeholder_keys: + from_vessel: unilabos_resources + to_vessel: unilabos_resources + waste_phase_to_vessel: unilabos_resources + waste_vessel: unilabos_resources + result: {} + schema: + description: '' + properties: + feedback: + properties: + progress: + type: number + status: + type: string + required: + - status + - progress + title: Separate_Feedback + type: object + goal: + properties: + from_vessel: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: from_vessel + type: object + product_phase: + type: string + product_vessel: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: product_vessel + type: object + purpose: + type: string + repeats: + maximum: 2147483647 + minimum: -2147483648 + type: integer + separation_vessel: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: separation_vessel + type: object + settling_time: + type: number + solvent: + type: string + solvent_volume: + type: string + stir_speed: + type: number + stir_time: + type: number + through: + type: string + to_vessel: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: to_vessel + type: object + vessel: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: vessel + type: object + volume: + type: string + waste_phase_to_vessel: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: waste_phase_to_vessel + type: object + waste_vessel: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: waste_vessel + type: object + required: + - vessel + - purpose + - product_phase + - from_vessel + - separation_vessel + - to_vessel + - waste_phase_to_vessel + - product_vessel + - waste_vessel + - solvent + - solvent_volume + - volume + - through + - repeats + - stir_time + - stir_speed + - settling_time + title: Separate_Goal + type: object + result: + properties: + message: + type: string + return_info: + type: string + success: + type: boolean + required: + - success + - message + - return_info + title: Separate_Result + type: object + required: + - goal + title: Separate + type: object + type: Separate + StartStirProtocol: + feedback: {} + goal: + purpose: purpose + stir_speed: stir_speed + vessel: vessel + goal_default: + purpose: '' + stir_speed: 0.0 + vessel: + category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + handles: + input: + - data_key: vessel + data_source: handle + data_type: resource + handler_key: Vessel + label: Vessel + output: + - data_key: vessel + data_source: executor + data_type: resource + handler_key: VesselOut + label: Vessel + placeholder_keys: + vessel: unilabos_resources + result: {} + schema: + description: '' + properties: + feedback: + properties: + current_speed: + type: number + current_status: + type: string + progress: + type: number + required: + - progress + - current_speed + - current_status + title: StartStir_Feedback + type: object + goal: + properties: + purpose: + type: string + stir_speed: + type: number + vessel: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: vessel + type: object + required: + - vessel + - stir_speed + - purpose + title: StartStir_Goal + type: object + result: + properties: + message: + type: string + return_info: + type: string + success: + type: boolean + required: + - success + - message + - return_info + title: StartStir_Result + type: object + required: + - goal + title: StartStir + type: object + type: StartStir + StirProtocol: + feedback: {} + goal: + event: event + settling_time: settling_time + stir_speed: stir_speed + stir_time: stir_time + time: time + time_spec: time_spec + vessel: vessel + goal_default: + event: '' + settling_time: '' + stir_speed: 0.0 + stir_time: 0.0 + time: '' + time_spec: '' + vessel: + category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + handles: + input: + - data_key: vessel + data_source: handle + data_type: resource + handler_key: Vessel + label: Vessel + output: + - data_key: vessel + data_source: executor + data_type: resource + handler_key: VesselOut + label: Vessel + placeholder_keys: + vessel: unilabos_resources + result: {} + schema: + description: '' + properties: + feedback: + properties: + status: + type: string + required: + - status + title: Stir_Feedback + type: object + goal: + properties: + event: + type: string + settling_time: + type: string + stir_speed: + type: number + stir_time: + type: number + time: + type: string + time_spec: + type: string + vessel: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: vessel + type: object + required: + - vessel + - time + - event + - time_spec + - stir_time + - stir_speed + - settling_time + title: Stir_Goal + type: object + result: + properties: + message: + type: string + return_info: + type: string + success: + type: boolean + required: + - success + - message + - return_info + title: Stir_Result + type: object + required: + - goal + title: Stir + type: object + type: Stir + StopStirProtocol: + feedback: {} + goal: + vessel: vessel + goal_default: + vessel: + category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + handles: + input: + - data_key: vessel + data_source: handle + data_type: resource + handler_key: Vessel + label: Vessel + output: + - data_key: vessel + data_source: executor + data_type: resource + handler_key: VesselOut + label: Vessel + placeholder_keys: + vessel: unilabos_resources + result: {} + schema: + description: '' + properties: + feedback: + properties: + current_status: + type: string + progress: + type: number + required: + - progress + - current_status + title: StopStir_Feedback + type: object + goal: + properties: + vessel: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: vessel + type: object + required: + - vessel + title: StopStir_Goal + type: object + result: + properties: + message: + type: string + return_info: + type: string + success: + type: boolean + required: + - success + - message + - return_info + title: StopStir_Result + type: object + required: + - goal + title: StopStir + type: object + type: StopStir + TransferProtocol: + feedback: {} + goal: + amount: amount + from_vessel: from_vessel + rinsing_repeats: rinsing_repeats + rinsing_solvent: rinsing_solvent + rinsing_volume: rinsing_volume + solid: solid + time: time + to_vessel: to_vessel + viscous: viscous + volume: volume + goal_default: + amount: '' + from_vessel: '' + rinsing_repeats: 0 + rinsing_solvent: '' + rinsing_volume: 0.0 + solid: false + time: 0.0 + to_vessel: '' + viscous: false + volume: 0.0 + handles: + input: + - data_key: vessel + data_source: handle + data_type: resource + handler_key: FromVessel + label: From Vessel + - data_key: vessel + data_source: executor + data_type: resource + handler_key: ToVessel + label: To Vessel + - data_key: solvent + data_source: handle + data_type: resource + handler_key: solvent + label: Rinsing Solvent + output: + - data_key: vessel + data_source: handle + data_type: resource + handler_key: FromVesselOut + label: From Vessel + - data_key: vessel + data_source: executor + data_type: resource + handler_key: ToVesselOut + label: To Vessel + placeholder_keys: + from_vessel: unilabos_nodes + to_vessel: unilabos_nodes + result: {} + schema: + description: '' + properties: + feedback: + properties: + current_status: + type: string + progress: + type: number + transferred_volume: + type: number + required: + - progress + - transferred_volume + - current_status + title: Transfer_Feedback + type: object + goal: + properties: + amount: + type: string + from_vessel: + type: string + rinsing_repeats: + maximum: 2147483647 + minimum: -2147483648 + type: integer + rinsing_solvent: + type: string + rinsing_volume: + type: number + solid: + type: boolean + time: + type: number + to_vessel: + type: string + viscous: + type: boolean + volume: + type: number + required: + - from_vessel + - to_vessel + - volume + - amount + - time + - viscous + - rinsing_solvent + - rinsing_volume + - rinsing_repeats + - solid + title: Transfer_Goal + type: object + result: + properties: + message: + type: string + return_info: + type: string + success: + type: boolean + required: + - success + - message + - return_info + title: Transfer_Result + type: object + required: + - goal + title: Transfer + type: object + type: Transfer + WashSolidProtocol: + feedback: {} + goal: + filtrate_vessel: filtrate_vessel + repeats: repeats + solvent: solvent + stir: stir + stir_speed: stir_speed + temp: temp + time: time + vessel: vessel + volume: volume + goal_default: + event: '' + filtrate_vessel: + category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + mass: '' + repeats: 0 + repeats_spec: '' + solvent: '' + stir: false + stir_speed: 0.0 + temp: 0.0 + time: '' + vessel: + category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + volume: '' + volume_spec: '' + handles: + input: + - data_key: vessel + data_source: handle + data_type: resource + handler_key: Vessel + label: Vessel + - data_key: solvent + data_source: handle + data_type: resource + handler_key: solvent + label: Solvent + - data_key: filtrate_vessel + data_source: handle + data_type: resource + handler_key: filtrate_vessel + label: Filtrate Vessel + output: + - data_key: vessel + data_source: handle + data_type: resource + handler_key: VesselOut + label: Vessel Out + - data_key: filtrate_vessel + data_source: executor + data_type: resource + handler_key: filtrate_vessel_out + label: Filtrate Vessel + placeholder_keys: + filtrate_vessel: unilabos_resources + vessel: unilabos_resources + result: {} + schema: + description: '' + properties: + feedback: + properties: + progress: + type: number + status: + type: string + required: + - status + - progress + title: WashSolid_Feedback + type: object + goal: + properties: + event: + type: string + filtrate_vessel: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: filtrate_vessel + type: object + mass: + type: string + repeats: + maximum: 2147483647 + minimum: -2147483648 + type: integer + repeats_spec: + type: string + solvent: + type: string + stir: + type: boolean + stir_speed: + type: number + temp: + type: number + time: + type: string + vessel: + properties: + category: + type: string + children: + items: + type: string + type: array + config: + type: string + data: + type: string + id: + type: string + name: + type: string + parent: + type: string + pose: + properties: + orientation: + properties: + w: + type: number + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + - w + title: orientation + type: object + position: + properties: + x: + type: number + y: + type: number + z: + type: number + required: + - x + - y + - z + title: position + type: object + required: + - position + - orientation + title: pose + type: object + sample_id: + type: string + type: + type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data + title: vessel + type: object + volume: + type: string + volume_spec: + type: string + required: + - vessel + - solvent + - volume + - filtrate_vessel + - temp + - stir + - stir_speed + - time + - repeats + - volume_spec + - repeats_spec + - mass + - event + title: WashSolid_Goal + type: object + result: + properties: + message: + type: string + return_info: + type: string + success: + type: boolean + required: + - success + - message + - return_info + title: WashSolid_Result + type: object + required: + - goal + title: WashSolid + type: object + type: WashSolid + module: unilabos.devices.workstation.workstation_base:ProtocolNode + status_types: {} + type: python + config_info: [] + description: Workstation + handles: [] + icon: '' + init_param_schema: + config: + properties: + deck: + type: string + protocol_type: + items: + type: string + type: array + required: + - protocol_type + - deck + type: object + data: + properties: {} + required: [] + type: object + version: 1.0.0 diff --git a/unilabos/registry/devices/xrd_d7mate.yaml b/unilabos/registry/devices/xrd_d7mate.yaml new file mode 100644 index 00000000..cbdf8aa8 --- /dev/null +++ b/unilabos/registry/devices/xrd_d7mate.yaml @@ -0,0 +1,601 @@ +xrd_d7mate: + category: + - xrd_d7mate + class: + action_value_mappings: + auto-close: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: 安全关闭与XRD D7-Mate设备的TCP连接,释放网络资源。 + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: close参数 + type: object + type: UniLabJsonCommand + auto-connect: + feedback: {} + goal: {} + goal_default: {} + handles: {} + placeholder_keys: {} + result: {} + schema: + description: 与XRD D7-Mate设备建立TCP连接,配置超时参数。 + properties: + feedback: {} + goal: + properties: {} + required: [] + type: object + result: {} + required: + - goal + title: connect参数 + type: object + type: UniLabJsonCommand + auto-post_init: + feedback: {} + goal: {} + goal_default: + ros_node: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + ros_node: + type: string + required: + - ros_node + type: object + result: {} + required: + - goal + title: post_init参数 + type: object + type: UniLabJsonCommand + auto-start_from_string: + feedback: {} + goal: {} + goal_default: + params: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + params: + type: string + required: + - params + type: object + result: {} + required: + - goal + title: start_from_string参数 + type: object + type: UniLabJsonCommand + get_current_acquire_data: + feedback: {} + goal: {} + goal_default: {} + handles: {} + result: {} + schema: + description: '' + properties: + feedback: + properties: {} + required: [] + title: EmptyIn_Feedback + type: object + goal: + properties: {} + required: [] + title: EmptyIn_Goal + type: object + result: + properties: + return_info: + type: string + required: + - return_info + title: EmptyIn_Result + type: object + required: + - goal + title: EmptyIn + type: object + type: EmptyIn + get_sample_down: + feedback: {} + goal: + sample_station: 1 + goal_default: + int_input: 0 + handles: {} + result: {} + schema: + description: '' + properties: + feedback: + properties: {} + required: [] + title: IntSingleInput_Feedback + type: object + goal: + properties: + int_input: + maximum: 2147483647 + minimum: -2147483648 + type: integer + required: + - int_input + title: IntSingleInput_Goal + type: object + result: + properties: + return_info: + type: string + success: + type: boolean + required: + - return_info + - success + title: IntSingleInput_Result + type: object + required: + - goal + title: IntSingleInput + type: object + type: IntSingleInput + get_sample_request: + feedback: {} + goal: {} + goal_default: {} + handles: {} + result: {} + schema: + description: '' + properties: + feedback: + properties: {} + required: [] + title: EmptyIn_Feedback + type: object + goal: + properties: {} + required: [] + title: EmptyIn_Goal + type: object + result: + properties: + return_info: + type: string + required: + - return_info + title: EmptyIn_Result + type: object + required: + - goal + title: EmptyIn + type: object + type: EmptyIn + get_sample_status: + feedback: {} + goal: {} + goal_default: {} + handles: {} + result: {} + schema: + description: '' + properties: + feedback: + properties: {} + required: [] + title: EmptyIn_Feedback + type: object + goal: + properties: {} + required: [] + title: EmptyIn_Goal + type: object + result: + properties: + return_info: + type: string + required: + - return_info + title: EmptyIn_Result + type: object + required: + - goal + title: EmptyIn + type: object + type: EmptyIn + send_sample_down_ready: + feedback: {} + goal: {} + goal_default: {} + handles: {} + result: {} + schema: + description: '' + properties: + feedback: + properties: {} + required: [] + title: EmptyIn_Feedback + type: object + goal: + properties: {} + required: [] + title: EmptyIn_Goal + type: object + result: + properties: + return_info: + type: string + required: + - return_info + title: EmptyIn_Result + type: object + required: + - goal + title: EmptyIn + type: object + type: EmptyIn + send_sample_ready: + feedback: {} + goal: + end_theta: 80.0 + exp_time: 0.5 + increment: 0.02 + sample_id: '' + start_theta: 10.0 + goal_default: + end_theta: 80.0 + exp_time: 0.5 + increment: 0.02 + sample_id: Sample001 + start_theta: 10.0 + handles: {} + result: {} + schema: + description: 送样完成后,发送样品信息和采集参数 + properties: + feedback: + properties: {} + required: [] + title: SampleReadyInput_Feedback + type: object + goal: + properties: + end_theta: + description: 结束角度(≥5.5°,且必须大于start_theta) + minimum: 5.5 + type: number + exp_time: + description: 曝光时间(0.1-5.0秒) + maximum: 5.0 + minimum: 0.1 + type: number + increment: + description: 角度增量(≥0.005) + minimum: 0.005 + type: number + sample_id: + description: 样品标识符 + type: string + start_theta: + description: 起始角度(≥5°) + minimum: 5.0 + type: number + required: + - sample_id + - start_theta + - end_theta + - increment + - exp_time + title: SampleReadyInput_Goal + type: object + result: + properties: + return_info: + type: string + success: + type: boolean + required: + - return_info + - success + title: SampleReadyInput_Result + type: object + required: + - goal + title: SampleReadyInput + type: object + type: UniLabJsonCommand + set_power_off: + feedback: {} + goal: {} + goal_default: {} + handles: {} + result: {} + schema: + description: '' + properties: + feedback: + properties: {} + required: [] + title: EmptyIn_Feedback + type: object + goal: + properties: {} + required: [] + title: EmptyIn_Goal + type: object + result: + properties: + return_info: + type: string + required: + - return_info + title: EmptyIn_Result + type: object + required: + - goal + title: EmptyIn + type: object + type: EmptyIn + set_power_on: + feedback: {} + goal: {} + goal_default: {} + handles: {} + result: {} + schema: + description: '' + properties: + feedback: + properties: {} + required: [] + title: EmptyIn_Feedback + type: object + goal: + properties: {} + required: [] + title: EmptyIn_Goal + type: object + result: + properties: + return_info: + type: string + required: + - return_info + title: EmptyIn_Result + type: object + required: + - goal + title: EmptyIn + type: object + type: EmptyIn + set_voltage_current: + feedback: {} + goal: + current: 30.0 + voltage: 40.0 + goal_default: + current: 30.0 + voltage: 40.0 + handles: {} + result: {} + schema: + description: 设置高压电源电压和电流 + properties: + feedback: + properties: {} + required: [] + title: VoltageCurrentInput_Feedback + type: object + goal: + properties: + current: + description: 电流值(mA) + type: number + voltage: + description: 电压值(kV) + type: number + required: + - voltage + - current + title: VoltageCurrentInput_Goal + type: object + result: + properties: + return_info: + type: string + success: + type: boolean + required: + - return_info + - success + title: VoltageCurrentInput_Result + type: object + required: + - goal + title: VoltageCurrentInput + type: object + type: UniLabJsonCommand + start: + feedback: {} + goal: {} + goal_default: + end_theta: 80.0 + exp_time: 0.1 + increment: 0.05 + sample_id: 样品名称 + start_theta: 10.0 + string: '' + wait_minutes: 3.0 + handles: {} + result: {} + schema: + description: 启动自动模式→上样→等待→样品准备→监控→检测下样位→执行下样流程。 + properties: + feedback: {} + goal: + properties: + end_theta: + description: 结束角度(≥5.5°,且必须大于start_theta) + minimum: 5.5 + type: string + exp_time: + description: 曝光时间(0.1-5.0秒) + maximum: 5.0 + minimum: 0.1 + type: string + increment: + description: 角度增量(≥0.005) + minimum: 0.005 + type: string + sample_id: + description: 样品标识符 + type: string + start_theta: + description: 起始角度(≥5°) + minimum: 5.0 + type: string + string: + description: 字符串格式的参数输入,如果提供则优先解析使用 + type: string + wait_minutes: + description: 允许上样后等待分钟数 + minimum: 0.0 + type: number + required: + - sample_id + - start_theta + - end_theta + - increment + - exp_time + title: StartWorkflow_Goal + type: object + result: + properties: + return_info: + type: string + success: + type: boolean + required: + - return_info + - success + title: StartWorkflow_Result + type: object + required: + - goal + title: StartWorkflow + type: object + type: UniLabJsonCommand + start_auto_mode: + feedback: {} + goal: + status: true + goal_default: + status: true + handles: {} + result: {} + schema: + description: 启动或停止自动模式 + properties: + feedback: + properties: {} + required: [] + title: BoolSingleInput_Feedback + type: object + goal: + properties: + status: + description: True-启动自动模式,False-停止自动模式 + type: boolean + required: + - status + title: BoolSingleInput_Goal + type: object + result: + properties: + return_info: + type: string + success: + type: boolean + required: + - return_info + - success + title: BoolSingleInput_Result + type: object + required: + - goal + title: BoolSingleInput + type: object + type: UniLabJsonCommand + module: unilabos.devices.xrd_d7mate.xrd_d7mate:XRDClient + status_types: + current_acquire_data: dict + sample_down: dict + sample_request: dict + sample_status: dict + type: python + config_info: [] + description: XRD D7-Mate X射线衍射分析设备,通过TCP通信实现远程控制与状态监控,支持自动模式控制、上样流程、数据获取、下样流程和高压电源控制等功能。 + handles: [] + icon: '' + init_param_schema: + config: + properties: + host: + default: 127.0.0.1 + type: string + port: + default: 6001 + type: string + timeout: + default: 10.0 + type: string + required: [] + type: object + data: + properties: + current_acquire_data: + type: object + sample_down: + type: object + sample_request: + type: object + sample_status: + type: object + required: + - sample_request + - current_acquire_data + - sample_status + - sample_down + type: object + version: 1.0.0 diff --git a/unilabos/registry/registry.py b/unilabos/registry/registry.py index ecc9f820..49e5761d 100644 --- a/unilabos/registry/registry.py +++ b/unilabos/registry/registry.py @@ -222,7 +222,7 @@ class Registry: 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)}") + logger.trace(f"[UniLab Registry] load resources? {resource_path.exists()}, total: {len(files)}") current_resource_number = len(self.resource_type_registry) + 1 for i, file in enumerate(files): with open(file, encoding="utf-8", mode="r") as f: @@ -453,7 +453,7 @@ class Registry: return status_schema def _generate_unilab_json_command_schema( - self, method_args: List[Dict[str, Any]], method_name: str + self, method_args: List[Dict[str, Any]], method_name: str, return_annotation: Any = None ) -> Dict[str, Any]: """ 根据UniLabJsonCommand方法信息生成JSON Schema,暂不支持嵌套类型 @@ -461,6 +461,7 @@ class Registry: Args: method_args: 方法信息字典,包含args等 method_name: 方法名称 + return_annotation: 返回类型注解,用于生成result schema(仅支持TypedDict) Returns: JSON Schema格式的参数schema @@ -489,14 +490,68 @@ class Registry: if param_required: schema["required"].append(param_name) + # 生成result schema(仅当return_annotation是TypedDict时) + result_schema = {} + if return_annotation is not None and self._is_typed_dict(return_annotation): + result_schema = self._generate_typed_dict_result_schema(return_annotation) + return { "title": f"{method_name}参数", "description": f"", "type": "object", - "properties": {"goal": schema, "feedback": {}, "result": {}}, + "properties": {"goal": schema, "feedback": {}, "result": result_schema}, "required": ["goal"], } + def _is_typed_dict(self, annotation: Any) -> bool: + """ + 检查类型注解是否是TypedDict + + Args: + annotation: 类型注解对象 + + Returns: + 是否为TypedDict + """ + if annotation is None or annotation == inspect.Parameter.empty: + return False + + # 使用 typing_extensions.is_typeddict 进行检查(Python < 3.12 兼容) + try: + from typing_extensions import is_typeddict + + return is_typeddict(annotation) + except ImportError: + # 回退方案:检查 TypedDict 特有的属性 + if isinstance(annotation, type): + return hasattr(annotation, "__required_keys__") and hasattr(annotation, "__optional_keys__") + return False + + def _generate_typed_dict_result_schema(self, return_annotation: Any) -> Dict[str, Any]: + """ + 根据TypedDict类型生成result的JSON Schema + + Args: + return_annotation: TypedDict类型注解 + + Returns: + JSON Schema格式的result schema + """ + if not self._is_typed_dict(return_annotation): + return {} + + try: + from msgcenterpy.instances.typed_dict_instance import TypedDictMessageInstance + + result_schema = TypedDictMessageInstance.get_json_schema_from_typed_dict(return_annotation) + return result_schema + except ImportError: + logger.warning("[UniLab Registry] msgcenterpy未安装,无法生成TypedDict的result schema") + return {} + except Exception as e: + logger.warning(f"[UniLab Registry] 生成TypedDict result schema失败: {e}") + return {} + def _add_builtin_actions(self, device_config: Dict[str, Any], device_id: str): """ 为设备配置添加内置的执行驱动命令动作 @@ -577,9 +632,15 @@ class Registry: if "init_param_schema" not in device_config: device_config["init_param_schema"] = {} if "class" in device_config: - if "status_types" not in device_config["class"]: + if ( + "status_types" not in device_config["class"] + or device_config["class"]["status_types"] is None + ): device_config["class"]["status_types"] = {} - if "action_value_mappings" not in device_config["class"]: + if ( + "action_value_mappings" not in device_config["class"] + or device_config["class"]["action_value_mappings"] is None + ): device_config["class"]["action_value_mappings"] = {} enhanced_info = {} if complete_registry: @@ -631,7 +692,9 @@ class Registry: "goal": {}, "feedback": {}, "result": {}, - "schema": self._generate_unilab_json_command_schema(v["args"], k), + "schema": self._generate_unilab_json_command_schema( + v["args"], k, v.get("return_annotation") + ), "goal_default": {i["name"]: i["default"] for i in v["args"]}, "handles": [], "placeholder_keys": { diff --git a/unilabos/registry/resources/bioyond/bottle_carriers.yaml b/unilabos/registry/resources/bioyond/bottle_carriers.yaml new file mode 100644 index 00000000..764a8aa5 --- /dev/null +++ b/unilabos/registry/resources/bioyond/bottle_carriers.yaml @@ -0,0 +1,48 @@ +BIOYOND_PolymerStation_1BottleCarrier: + category: + - bottle_carriers + class: + module: unilabos.resources.bioyond.bottle_carriers:BIOYOND_PolymerStation_1BottleCarrier + type: pylabrobot + description: BIOYOND_PolymerStation_1BottleCarrier + handles: [] + icon: '' + init_param_schema: {} + registry_type: resource + version: 1.0.0 +BIOYOND_PolymerStation_1FlaskCarrier: + category: + - bottle_carriers + class: + module: unilabos.resources.bioyond.bottle_carriers:BIOYOND_PolymerStation_1FlaskCarrier + type: pylabrobot + description: BIOYOND_PolymerStation_1FlaskCarrier + handles: [] + icon: '' + init_param_schema: {} + registry_type: resource + version: 1.0.0 +BIOYOND_PolymerStation_6StockCarrier: + category: + - bottle_carriers + class: + module: unilabos.resources.bioyond.bottle_carriers:BIOYOND_PolymerStation_6StockCarrier + type: pylabrobot + description: BIOYOND_PolymerStation_6StockCarrier + handles: [] + icon: '' + init_param_schema: {} + registry_type: resource + version: 1.0.0 +BIOYOND_PolymerStation_8StockCarrier: + category: + - bottle_carriers + class: + module: unilabos.resources.bioyond.bottle_carriers:BIOYOND_PolymerStation_8StockCarrier + type: pylabrobot + description: BIOYOND_PolymerStation_8StockCarrier (2x4布局,8个位置) + handles: [] + icon: '' + init_param_schema: {} + registry_type: resource + version: 1.0.0 diff --git a/unilabos/registry/resources/bioyond/bottles.yaml b/unilabos/registry/resources/bioyond/bottles.yaml new file mode 100644 index 00000000..79aa712b --- /dev/null +++ b/unilabos/registry/resources/bioyond/bottles.yaml @@ -0,0 +1,84 @@ +BIOYOND_PolymerStation_Flask: + category: + - bottles + - flasks + class: + module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Flask + type: pylabrobot + description: 聚合站-烧杯容器 + handles: [] + icon: '' + init_param_schema: {} + version: 1.0.0 +BIOYOND_PolymerStation_Liquid_Vial: + category: + - bottles + class: + module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Liquid_Vial + type: pylabrobot + handles: [] + icon: '' + init_param_schema: {} + version: 1.0.0 +BIOYOND_PolymerStation_Reactor: + category: + - bottles + - reactors + class: + module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Reactor + type: pylabrobot + handles: [] + icon: '' + init_param_schema: {} + version: 1.0.0 +BIOYOND_PolymerStation_Reagent_Bottle: + category: + - bottles + class: + module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Reagent_Bottle + type: pylabrobot + handles: [] + icon: '' + init_param_schema: {} + version: 1.0.0 +BIOYOND_PolymerStation_Solid_Stock: + category: + - bottles + class: + module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Solid_Stock + type: pylabrobot + handles: [] + icon: '' + init_param_schema: {} + version: 1.0.0 +BIOYOND_PolymerStation_Solid_Vial: + category: + - bottles + class: + module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Solid_Vial + type: pylabrobot + handles: [] + icon: '' + init_param_schema: {} + version: 1.0.0 +BIOYOND_PolymerStation_Solution_Beaker: + category: + - bottles + class: + module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Solution_Beaker + type: pylabrobot + handles: [] + icon: '' + init_param_schema: {} + version: 1.0.0 +BIOYOND_PolymerStation_TipBox: + category: + - bottles + - tip_boxes + class: + module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_TipBox + type: pylabrobot + handles: [] + icon: '' + init_param_schema: {} + version: 1.0.0 diff --git a/unilabos/registry/resources/bioyond/deck.yaml b/unilabos/registry/resources/bioyond/deck.yaml index 07f78ea4..bc158508 100644 --- a/unilabos/registry/resources/bioyond/deck.yaml +++ b/unilabos/registry/resources/bioyond/deck.yaml @@ -22,27 +22,15 @@ BIOYOND_PolymerReactionStation_Deck: init_param_schema: {} registry_type: resource version: 1.0.0 -BIOYOND_YB_Deck: +YB_Deck11: category: - deck class: - module: unilabos.resources.bioyond.decks:BIOYOND_YB_Deck + module: unilabos.resources.bioyond.decks:YB_Deck type: pylabrobot - description: BIOYOND_YB_Deck + description: BIOYOND PolymerReactionStation Deck handles: [] icon: 配液站.webp init_param_schema: {} registry_type: resource version: 1.0.0 -CoincellDeck: - category: - - deck - class: - module: unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials:CoincellDeck - type: pylabrobot - description: CoincellDeck - handles: [] - icon: yihua.webp - init_param_schema: {} - registry_type: resource - version: 1.0.0 diff --git a/unilabos/registry/resources/common/resource_container.yaml b/unilabos/registry/resources/common/resource_container.yaml index 9850167c..48dcab59 100644 --- a/unilabos/registry/resources/common/resource_container.yaml +++ b/unilabos/registry/resources/common/resource_container.yaml @@ -38,6 +38,7 @@ hplc_plate: - 0 - 0 - 3.1416 + path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/hplc_plate/modal.xacro type: resource registry_type: resource version: 1.0.0 @@ -90,6 +91,29 @@ plate: init_param_schema: {} registry_type: resource version: 1.0.0 +plate_96: + category: + - resource_container + class: + module: unilabos.devices.resource_container.container:PlateContainer + type: python + description: 96孔板 + handles: [] + icon: '' + init_param_schema: {} + model: + mesh: plate_96/meshes/plate_96.stl + mesh_tf: + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/plate_96/modal.xacro + type: resource + registry_type: resource + version: 1.0.0 plate_96_high: category: - resource_container @@ -109,6 +133,7 @@ plate_96_high: - 1.5708 - 0 - 1.5708 + path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/plate_96_high/modal.xacro type: resource registry_type: resource version: 1.0.0 @@ -152,6 +177,7 @@ tiprack_96_high: init_param_schema: {} model: children_mesh: generic_labware_tube_10_75/meshes/0_base.stl + children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/generic_labware_tube_10_75/modal.xacro children_mesh_tf: - 0.0018 - 0.0018 @@ -167,6 +193,39 @@ tiprack_96_high: - 1.5708 - 0 - 1.5708 + path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tiprack_96_high/modal.xacro + type: resource + registry_type: resource + version: 1.0.0 +tiprack_box: + category: + - resource_container + class: + module: unilabos.devices.resource_container.container:TipRackContainer + type: python + description: 96针头盒 + handles: [] + icon: '' + init_param_schema: {} + model: + children_mesh: tip/meshes/tip.stl + children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tip/modal.xacro + children_mesh_tf: + - 0.0045 + - 0.0045 + - 0 + - 0 + - 0 + - 0 + mesh: tiprack_box/meshes/tiprack_box.stl + mesh_tf: + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tiprack_box/modal.xacro type: resource registry_type: resource version: 1.0.0 diff --git a/unilabos/registry/resources/laiyu/container.yaml b/unilabos/registry/resources/laiyu/container.yaml new file mode 100644 index 00000000..1652956e --- /dev/null +++ b/unilabos/registry/resources/laiyu/container.yaml @@ -0,0 +1,66 @@ +bottle_container: + category: + - resource_container + - container + class: + module: unilabos.devices.resource_container.container:BottleRackContainer + type: python + description: 96孔板 + handles: [] + icon: '' + init_param_schema: {} + model: + children_mesh: bottle/meshes/bottle.stl + children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/bottle/modal.xacro + children_mesh_tf: + - 0.04 + - 0.04 + - 0 + - 0 + - 0 + - 0 + mesh: bottle_container/meshes/bottle_container.stl + mesh_tf: + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/bottle_container/modal.xacro + type: resource + registry_type: resource + version: 1.0.0 +tube_container: + category: + - resource_container + - container + class: + module: unilabos.devices.resource_container.container:TubeRackContainer + type: python + description: 96孔板 + handles: [] + icon: '' + init_param_schema: {} + model: + children_mesh: tube/meshes/tube.stl + children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tube/modal.xacro + children_mesh_tf: + - 0.017 + - 0.017 + - 0 + - 0 + - 0 + - 0 + mesh: tube_container/meshes/tube_container.stl + mesh_tf: + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tube_container/modal.xacro + type: resource + registry_type: resource + version: 1.0.0 diff --git a/unilabos/registry/resources/laiyu/deck.yaml b/unilabos/registry/resources/laiyu/deck.yaml new file mode 100644 index 00000000..e6d930a5 --- /dev/null +++ b/unilabos/registry/resources/laiyu/deck.yaml @@ -0,0 +1,16 @@ +TransformXYZDeck: + category: + - deck + class: + module: unilabos.devices.liquid_handling.laiyu.laiyu:TransformXYZDeck + type: pylabrobot + description: Laiyu deck + handles: [] + icon: '' + init_param_schema: {} + model: + mesh: liquid_transform_xyz + path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/liquid_transform_xyz/macro_device.xacro + type: device + registry_type: resource + version: 1.0.0 diff --git a/unilabos/registry/resources/opentrons/deck.yaml b/unilabos/registry/resources/opentrons/deck.yaml index b683c97b..8fa35ee5 100644 --- a/unilabos/registry/resources/opentrons/deck.yaml +++ b/unilabos/registry/resources/opentrons/deck.yaml @@ -10,6 +10,7 @@ OTDeck: init_param_schema: {} model: mesh: opentrons_liquid_handler + path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/opentrons_liquid_handler/macro_device.xacro type: device registry_type: resource version: 1.0.0 @@ -25,6 +26,7 @@ hplc_station: init_param_schema: {} model: mesh: hplc_station + path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/hplc_station/macro_device.xacro type: device registry_type: resource version: 1.0.0 diff --git a/unilabos/registry/resources/opentrons/plates.yaml b/unilabos/registry/resources/opentrons/plates.yaml index dd2bdf80..02267ae0 100644 --- a/unilabos/registry/resources/opentrons/plates.yaml +++ b/unilabos/registry/resources/opentrons/plates.yaml @@ -118,6 +118,7 @@ nest_96_wellplate_100ul_pcr_full_skirt: init_param_schema: {} model: children_mesh: generic_labware_tube_10_75/meshes/0_base.stl + children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/generic_labware_tube_10_75/modal.xacro children_mesh_tf: - 0.0018 - 0.0018 @@ -133,6 +134,7 @@ nest_96_wellplate_100ul_pcr_full_skirt: - -1.5708 - 0 - 1.5708 + path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro type: resource registry_type: resource version: 1.0.0 @@ -167,6 +169,7 @@ nest_96_wellplate_2ml_deep: - -1.5708 - 0 - 1.5708 + path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro type: resource registry_type: resource version: 1.0.0 diff --git a/unilabos/registry/resources/opentrons/tip_racks.yaml b/unilabos/registry/resources/opentrons/tip_racks.yaml index 9138ec82..cbc7d6f1 100644 --- a/unilabos/registry/resources/opentrons/tip_racks.yaml +++ b/unilabos/registry/resources/opentrons/tip_racks.yaml @@ -73,6 +73,7 @@ opentrons_96_filtertiprack_1000ul: - -1.5708 - 0 - 1.5708 + path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro type: resource registry_type: resource version: 1.0.0 diff --git a/unilabos/registry/resources/organic/container.yaml b/unilabos/registry/resources/organic/container.yaml index 7da736c0..a8fb9b6c 100644 --- a/unilabos/registry/resources/organic/container.yaml +++ b/unilabos/registry/resources/organic/container.yaml @@ -2,7 +2,7 @@ container: category: - container class: - module: unilabos.resources.container:RegularContainer + module: unilabos.resources.container:get_regular_container type: pylabrobot description: regular organic container handles: diff --git a/unilabos/registry/resources/prcxi/tip_racks.yaml b/unilabos/registry/resources/prcxi/tip_racks.yaml index e74c17ad..f97a3d02 100644 --- a/unilabos/registry/resources/prcxi/tip_racks.yaml +++ b/unilabos/registry/resources/prcxi/tip_racks.yaml @@ -10,3 +10,16 @@ prcxi_opentrons_96_tiprack_10ul: init_param_schema: {} registry_type: resource version: 1.0.0 +tip_adaptor_1250ul_2: + category: + - prcxi + - tip_racks + class: + module: unilabos.devices.liquid_handling.prcxi.prcxi_materials:tip_adaptor_1250ul + type: pylabrobot + description: Tip头适配器 1250uL + handles: [] + icon: '' + init_param_schema: {} + registry_type: resource + version: 1.0.0 diff --git a/unilabos/resources/battery/__init__.py b/unilabos/resources/battery/__init__.py index e69de29b..b722d42b 100644 --- a/unilabos/resources/battery/__init__.py +++ b/unilabos/resources/battery/__init__.py @@ -0,0 +1,4 @@ +"""Battery-related resource classes for coin cell assembly""" + + + diff --git a/unilabos/resources/battery/bottle_carriers.py b/unilabos/resources/battery/bottle_carriers.py index 9d9827cd..dbc2ac43 100644 --- a/unilabos/resources/battery/bottle_carriers.py +++ b/unilabos/resources/battery/bottle_carriers.py @@ -1,56 +1,45 @@ -from pylabrobot.resources import create_homogeneous_resources, Coordinate, ResourceHolder, create_ordered_items_2d +""" +瓶架类定义 - 用于纽扣电池组装工作站 +Bottle Carrier Resource Classes +""" -from unilabos.resources.itemized_carrier import Bottle, BottleCarrier -from unilabos.resources.bioyond.YB_bottles import ( - YB_pei_ye_xiao_Bottle, -) -# 命名约定:试剂瓶-Bottle,烧杯-Beaker,烧瓶-Flask,小瓶-Vial +from __future__ import annotations +from pylabrobot.resources import ResourceHolder +from pylabrobot.resources.utils import create_ordered_items_2d +from unilabos.resources.itemized_carrier import ItemizedCarrier -def YIHUA_Electrolyte_12VialCarrier(name: str) -> BottleCarrier: - """12瓶载架 - 2x6布局""" - # 载架尺寸 (mm) - carrier_size_x = 120.0 - carrier_size_y = 250.0 - carrier_size_z = 50.0 - - # 瓶位尺寸 - bottle_diameter = 35.0 - bottle_spacing_x = 35.0 # X方向间距 - bottle_spacing_y = 35.0 # Y方向间距 - - # 计算起始位置 (居中排列) - start_x = (carrier_size_x - (2 - 1) * bottle_spacing_x - bottle_diameter) / 2 - start_y = (carrier_size_y - (6 - 1) * bottle_spacing_y - bottle_diameter) / 2 - +def YIHUA_Electrolyte_12VialCarrier(name: str) -> ItemizedCarrier: + """依华电解液12瓶架 - 3x4布局 + + Args: + name: 瓶架名称 + + Returns: + ItemizedCarrier: 包含12个瓶位的瓶架 + """ sites = create_ordered_items_2d( klass=ResourceHolder, - num_items_x=2, - num_items_y=6, - dx=start_x, - dy=start_y, + num_items_x=4, + num_items_y=3, + dx=10.0, + dy=10.0, dz=5.0, - item_dx=bottle_spacing_x, - item_dy=bottle_spacing_y, - - size_x=bottle_diameter, - size_y=bottle_diameter, - size_z=carrier_size_z, + item_dx=70.0, + item_dy=26.67, + size_x=60.0, + size_y=20.0, + size_z=70.0, ) - for k, v in sites.items(): - v.name = f"{name}_{v.name}" - - carrier = BottleCarrier( + + return ItemizedCarrier( name=name, - size_x=carrier_size_x, - size_y=carrier_size_y, - size_z=carrier_size_z, + size_x=300.0, + size_y=100.0, + size_z=80.0, + num_items_x=4, + num_items_y=3, sites=sites, - model="Electrolyte_12VialCarrier", + category="bottle_carrier", ) - carrier.num_items_x = 2 - carrier.num_items_y = 6 - carrier.num_items_z = 1 - for i in range(12): - carrier[i] = YB_pei_ye_xiao_Bottle(f"{name}_vial_{i+1}") - return carrier + diff --git a/unilabos/resources/battery/electrode_sheet.py b/unilabos/resources/battery/electrode_sheet.py index e86af24a..6fc9b0e3 100644 --- a/unilabos/resources/battery/electrode_sheet.py +++ b/unilabos/resources/battery/electrode_sheet.py @@ -1,44 +1,35 @@ -from typing import Any, Dict, Optional, TypedDict +""" +电极片类定义 +Electrode Sheet Resource Classes +""" -from pylabrobot.resources import Resource as ResourcePLR -from pylabrobot.resources import Container +from __future__ import annotations +from typing import Any, Dict, Optional +from pylabrobot.resources.resource import Resource -electrode_colors = { - "PositiveCan": "#ff0000", - "PositiveElectrode": "#cc3333", - "NegativeCan": "#000000", - "NegativeElectrode": "#666666", - "SpringWasher": "#8b7355", - "FlatWasher": "a9a9a9", - "AluminumFoil": "#ffcccc", - "Battery": "#00ff00", -} - -class ElectrodeSheetState(TypedDict): - mass: float # 质量 (g) - material_type: str # 材料类型(铜、铝、不锈钢、弹簧钢等) - color: str # 材料类型对应的颜色 - - -class ElectrodeSheet(ResourcePLR): - """极片类 - 包含正负极片、隔膜、弹片、垫片、铝箔等所有片状材料""" - +class ElectrodeSheet(Resource): + """电极片类 - 用于纽扣电池组装""" + def __init__( self, - name: str = "极片", - size_x=10, - size_y=10, - size_z=10, + name: str, + size_x: float = 12.0, + size_y: float = 12.0, + size_z: float = 0.1, category: str = "electrode_sheet", - model: Optional[str] = None, + electrode_type: str = "anode", # "anode" 负极, "cathode" 正极, "separator" 隔膜 + **kwargs ): - """初始化极片 - + """初始化电极片 + Args: - name: 极片名称 + name: 电极片名称 + size_x: X方向尺寸 (mm) + size_y: Y方向尺寸 (mm) + size_z: Z方向尺寸/厚度 (mm) category: 类别 - model: 型号 + electrode_type: 电极类型 """ super().__init__( name=name, @@ -46,134 +37,31 @@ class ElectrodeSheet(ResourcePLR): size_y=size_y, size_z=size_z, category=category, - model=model, + **kwargs ) - self._unilabos_state: ElectrodeSheetState = ElectrodeSheetState( - diameter=14, - thickness=0.1, - mass=0.5, - material_type="copper", - info=None - ) - - # TODO: 这个还要不要?给self._unilabos_state赋值的? + self._electrode_type = electrode_type + self._unilabos_state: Dict[str, Any] = { + "electrode_type": electrode_type, + "material": "", + "thickness": size_z, + } + + @property + def electrode_type(self) -> str: + """获取电极类型""" + return self._electrode_type + def load_state(self, state: Dict[str, Any]) -> None: - """格式不变""" + """加载状态""" super().load_state(state) - self._unilabos_state = state - #序列化 - def serialize_state(self) -> Dict[str, Dict[str, Any]]: - """格式不变""" + if isinstance(state, dict): + self._unilabos_state.update(state) + + def serialize_state(self) -> Dict[str, Any]: + """序列化状态""" data = super().serialize_state() - data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等) + data.update(self._unilabos_state) return data -def PositiveCan(name: str) -> ElectrodeSheet: - """创建正极壳""" - sheet = ElectrodeSheet(name=name, size_x=12, size_y=12, size_z=3.0, model="PositiveCan") - sheet.load_state({"material_type": "aluminum", "color": electrode_colors["PositiveCan"]}) - return sheet - -def PositiveElectrode(name: str) -> ElectrodeSheet: - """创建正极片""" - sheet = ElectrodeSheet(name=name, size_x=10, size_y=10, size_z=0.1, model="PositiveElectrode") - sheet.load_state({"material_type": "positive_electrode", "color": electrode_colors["PositiveElectrode"]}) - return sheet - - -def NegativeCan(name: str) -> ElectrodeSheet: - """创建负极壳""" - sheet = ElectrodeSheet(name=name, size_x=12, size_y=12, size_z=2.0, model="NegativeCan") - sheet.load_state({"material_type": "steel", "color": electrode_colors["NegativeCan"]}) - return sheet - - -def NegativeElectrode(name: str) -> ElectrodeSheet: - """创建负极片""" - sheet = ElectrodeSheet(name=name, size_x=10, size_y=10, size_z=0.1, model="NegativeElectrode") - sheet.load_state({"material_type": "negative_electrode", "color": electrode_colors["NegativeElectrode"]}) - return sheet - - -def SpringWasher(name: str) -> ElectrodeSheet: - """创建弹片""" - sheet = ElectrodeSheet(name=name, size_x=10, size_y=10, size_z=0.5, model="SpringWasher") - sheet.load_state({"material_type": "spring_steel", "color": electrode_colors["SpringWasher"]}) - return sheet - - -def FlatWasher(name: str) -> ElectrodeSheet: - """创建垫片""" - sheet = ElectrodeSheet(name=name, size_x=10, size_y=10, size_z=0.2, model="FlatWasher") - sheet.load_state({"material_type": "steel", "color": electrode_colors["FlatWasher"]}) - return sheet - - -def AluminumFoil(name: str) -> ElectrodeSheet: - """创建铝箔""" - sheet = ElectrodeSheet(name=name, size_x=10, size_y=10, size_z=0.05, model="AluminumFoil") - sheet.load_state({"material_type": "aluminum", "color": electrode_colors["AluminumFoil"]}) - return sheet - - -class BatteryState(TypedDict): - color: str # 材料类型对应的颜色 - electrolyte_name: str - data_electrolyte_code: str - open_circuit_voltage: float - assembly_pressure: float - electrolyte_volume: float - - info: Optional[str] # 附加信息 - - -class Battery(Container): - """电池类 - 包含组装好的电池""" - - def __init__( - self, - name: str = "电池", - size_x=12, - size_y=12, - size_z=6, - category: str = "battery", - model: Optional[str] = None, - ): - """初始化电池 - - Args: - name: 电池名称 - category: 类别 - model: 型号 - """ - super().__init__( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - category=category, - model=model, - ) - self._unilabos_state: BatteryState = BatteryState( - color=electrode_colors["Battery"], - electrolyte_name="无", - data_electrolyte_code="", - open_circuit_voltage=0.0, - assembly_pressure=0.0, - electrolyte_volume=0.0, - info=None - ) - - def load_state(self, state: Dict[str, Any]) -> None: - """格式不变""" - super().load_state(state) - self._unilabos_state = state - - #序列化 - def serialize_state(self) -> Dict[str, Dict[str, Any]]: - """格式不变""" - data = super().serialize_state() - data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等) - return data \ No newline at end of file diff --git a/unilabos/resources/battery/magazine.py b/unilabos/resources/battery/magazine.py index 04328a40..8fbe694b 100644 --- a/unilabos/resources/battery/magazine.py +++ b/unilabos/resources/battery/magazine.py @@ -1,344 +1,152 @@ -from typing import Dict, List, Optional, OrderedDict, Union, Callable -import math +""" +弹夹架类定义 - 用于纽扣电池组装工作站 +Magazine Holder Resource Classes +""" +from __future__ import annotations +from typing import List, Optional from pylabrobot.resources.coordinate import Coordinate -from pylabrobot.resources import Resource, ResourceStack, ItemizedResource -from pylabrobot.resources.carrier import create_homogeneous_resources - -from unilabos.resources.battery.electrode_sheet import ( - PositiveCan, PositiveElectrode, - NegativeCan, NegativeElectrode, - SpringWasher, FlatWasher, - AluminumFoil, - Battery -) +from pylabrobot.resources import ResourceHolder +from pylabrobot.resources.utils import create_ordered_items_2d +from unilabos.resources.itemized_carrier import ItemizedCarrier -class Magazine(ResourceStack): - """子弹夹洞位类""" - - def __init__( - self, - name: str, - direction: str = 'z', - resources: Optional[List[Resource]] = None, - max_sheets: int = 100, - **kwargs - ): - """初始化子弹夹洞位 - - Args: - name: 洞位名称 - direction: 堆叠方向 - resources: 资源列表 - max_sheets: 最大极片数量 - """ - super().__init__( - name=name, - direction=direction, - resources=resources, - ) - self.max_sheets = max_sheets - - @property - def size_x(self) -> float: - return self.get_size_x() - - @property - def size_y(self) -> float: - return self.get_size_y() - - @property - def size_z(self) -> float: - return self.get_size_z() - - def serialize(self) -> dict: - return { - **super().serialize(), - "size_x": self.size_x or 10.0, - "size_y": self.size_y or 10.0, - "size_z": self.size_z or 10.0, - "max_sheets": self.max_sheets, - } - - -class MagazineHolder(ItemizedResource): - """子弹夹类 - 有多个洞位,每个洞位放多个极片""" - - def __init__( - self, - name: str, - size_x: float, - size_y: float, - size_z: float, - ordered_items: Optional[Dict[str, Magazine]] = None, - ordering: Optional[OrderedDict[str, str]] = None, - hole_diameter: float = 14.0, - hole_depth: float = 10.0, - max_sheets_per_hole: int = 100, - cross_section_type: str = "circle", - category: str = "magazine_holder", - model: Optional[str] = None, - ): - """初始化子弹夹 - - Args: - name: 子弹夹名称 - size_x: 长度 (mm) - size_y: 宽度 (mm) - size_z: 高度 (mm) - hole_diameter: 洞直径 (mm) - hole_depth: 洞深度 (mm) - max_sheets_per_hole: 每个洞位最大极片数量 - category: 类别 - model: 型号 - """ - - super().__init__( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - ordered_items=ordered_items, - ordering=ordering, - category=category, - model=model, - ) - - # 保存洞位的直径和深度 - self.hole_diameter = hole_diameter - self.hole_depth = hole_depth - self.max_sheets_per_hole = max_sheets_per_hole - self.cross_section_type = cross_section_type - - def serialize(self) -> dict: - return { - **super().serialize(), - "hole_diameter": self.hole_diameter, - "hole_depth": self.hole_depth, - "max_sheets_per_hole": self.max_sheets_per_hole, - "cross_section_type": self.cross_section_type, - } - - -def magazine_factory( - name: str, - size_x: float, - size_y: float, - size_z: float, - locations: List[Coordinate], - klasses: Optional[List[Callable[[str], str]]] = None, - hole_diameter: float = 14.0, - hole_depth: float = 10.0, - max_sheets_per_hole: int = 100, - category: str = "magazine_holder", - model: Optional[str] = None, -) -> 'MagazineHolder': - """工厂函数:创建子弹夹 +def MagazineHolder_4_Cathode(name: str) -> ItemizedCarrier: + """正极&铝箔弹夹 - 4个洞位 (2x2布局) Args: - name: 子弹夹名称 - size_x: 长度 (mm) - size_y: 宽度 (mm) - size_z: 高度 (mm) - locations: 洞位坐标列表 - klasses: 每个洞位中极片的类列表 - hole_diameter: 洞直径 (mm) - hole_depth: 洞深度 (mm) - max_sheets_per_hole: 每个洞位最大极片数量 - category: 类别 - model: 型号 + name: 弹夹名称 + + Returns: + ItemizedCarrier: 包含4个槽位的弹夹架 """ - for loc in locations: - loc.x -= hole_diameter / 2 - loc.y -= hole_diameter / 2 - - # 创建洞位 - _sites = create_homogeneous_resources( - klass=Magazine, - locations=locations, - resource_size_x=hole_diameter, - resource_size_y=hole_diameter, - name_prefix=name, - max_sheets=max_sheets_per_hole, + sites = create_ordered_items_2d( + klass=ResourceHolder, + num_items_x=2, + num_items_y=2, + dx=10.0, + dy=10.0, + dz=0.0, + item_dx=50.0, + item_dy=30.0, + size_x=40.0, + size_y=25.0, + size_z=40.0, ) - # 生成编号键 - keys = [f"A{i+1}" for i in range(len(locations))] - sites = dict(zip(keys, _sites.values())) + return ItemizedCarrier( + name=name, + size_x=120.0, + size_y=80.0, + size_z=50.0, + num_items_x=2, + num_items_y=2, + sites=sites, + category="magazine_holder", + ) + + +def MagazineHolder_6_Cathode(name: str) -> ItemizedCarrier: + """正极壳&平垫片弹夹 - 6个洞位 (2x3布局) - holder = MagazineHolder( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - ordered_items=sites, - hole_diameter=hole_diameter, - hole_depth=hole_depth, - max_sheets_per_hole=max_sheets_per_hole, - category=category, - model=model, + Args: + name: 弹夹名称 + + Returns: + ItemizedCarrier: 包含6个槽位的弹夹架 + """ + sites = create_ordered_items_2d( + klass=ResourceHolder, + num_items_x=3, + num_items_y=2, + dx=10.0, + dy=10.0, + dz=0.0, + item_dx=40.0, + item_dy=30.0, + size_x=35.0, + size_y=25.0, + size_z=40.0, ) - - if klasses is not None: - for i, klass in enumerate(klasses): - hole_key = keys[i] - hole = holder.children[i] - for j in reversed(range(max_sheets_per_hole)): - item_name = f"{hole_key}_sheet{j+1}" - item = klass(name=item_name) - hole.assign_child_resource(item) - return holder - - -def MagazineHolder_6_Cathode( - name: str, - size_x: float = 80.0, - size_y: float = 80.0, - size_z: float = 40.0, - hole_diameter: float = 14.0, - hole_depth: float = 10.0, - hole_spacing: float = 20.0, - max_sheets_per_hole: int = 100, -) -> MagazineHolder: - """创建6孔子弹夹 - 六边形排布""" - center_x = size_x / 2 - center_y = size_y / 2 - - locations = [] - - # 周围6个孔,按六边形排布 - for i in range(6): - angle = i * 60 * math.pi / 180 # 每60度一个孔 - x = center_x + hole_spacing * math.cos(angle) - y = center_y + hole_spacing * math.sin(angle) - locations.append(Coordinate(x, y, size_z - hole_depth)) - - return magazine_factory( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - locations=locations, - klasses=[FlatWasher, PositiveCan, PositiveCan, FlatWasher, PositiveCan, PositiveCan], - hole_diameter=hole_diameter, - hole_depth=hole_depth, - max_sheets_per_hole=max_sheets_per_hole, - category="magazine_holder", - model="MagazineHolder_6_Cathode", - ) - - -def MagazineHolder_6_Anode( - name: str, - size_x: float = 80.0, - size_y: float = 80.0, - size_z: float = 40.0, - hole_diameter: float = 14.0, - hole_depth: float = 10.0, - hole_spacing: float = 20.0, - max_sheets_per_hole: int = 100, -) -> MagazineHolder: - """创建6孔子弹夹 - 六边形排布""" - center_x = size_x / 2 - center_y = size_y / 2 - - locations = [] - - # 周围6个孔,按六边形排布 - for i in range(6): - angle = i * 60 * math.pi / 180 # 每60度一个孔 - x = center_x + hole_spacing * math.cos(angle) - y = center_y + hole_spacing * math.sin(angle) - locations.append(Coordinate(x, y, size_z - hole_depth)) - - return magazine_factory( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - locations=locations, - klasses=[SpringWasher, NegativeCan, NegativeCan, SpringWasher, NegativeCan, NegativeCan], - hole_diameter=hole_diameter, - hole_depth=hole_depth, - max_sheets_per_hole=max_sheets_per_hole, - category="magazine_holder", - model="MagazineHolder_6_Anode", - ) - - -def MagazineHolder_6_Battery( - name: str, - size_x: float = 80.0, - size_y: float = 80.0, - size_z: float = 40.0, - hole_diameter: float = 14.0, - hole_depth: float = 10.0, - hole_spacing: float = 20.0, - max_sheets_per_hole: int = 100, -) -> MagazineHolder: - """创建6孔子弹夹 - 六边形排布""" - center_x = size_x / 2 - center_y = size_y / 2 - - locations = [] - - # 周围6个孔,按六边形排布 - for i in range(6): - angle = i * 60 * math.pi / 180 # 每60度一个孔 - x = center_x + hole_spacing * math.cos(angle) - y = center_y + hole_spacing * math.sin(angle) - locations.append(Coordinate(x, y, size_z - hole_depth)) - - return magazine_factory( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - locations=locations, - klasses=None, # 初始化时,不放入装好的电池 - hole_diameter=hole_diameter, - hole_depth=hole_depth, - max_sheets_per_hole=max_sheets_per_hole, - category="magazine_holder", - model="MagazineHolder_6_Battery", - ) - - -def MagazineHolder_4_Cathode( - name: str, -) -> MagazineHolder: - """创建4孔子弹夹 - 正方形四角排布""" - size_x: float = 80.0 - size_y: float = 80.0 - size_z: float = 10.0 - hole_diameter: float = 14.0 - hole_depth: float = 10.0 - hole_spacing: float = 25.0 - max_sheets_per_hole: int = 100 - - # 计算4个洞位的坐标(正方形四角排布) - center_x = size_x / 2 - center_y = size_y / 2 - offset = hole_spacing / 2 - locations = [ - Coordinate(center_x - offset, center_y - offset, size_z - hole_depth), # 左下 - Coordinate(center_x + offset, center_y - offset, size_z - hole_depth), # 右下 - Coordinate(center_x - offset, center_y + offset, size_z - hole_depth), # 左上 - Coordinate(center_x + offset, center_y + offset, size_z - hole_depth), # 右上 - ] - - return magazine_factory( + return ItemizedCarrier( name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - locations=locations, - klasses=[AluminumFoil, PositiveElectrode, PositiveElectrode, PositiveElectrode], - hole_diameter=hole_diameter, - hole_depth=hole_depth, - max_sheets_per_hole=max_sheets_per_hole, + size_x=150.0, + size_y=80.0, + size_z=50.0, + num_items_x=3, + num_items_y=2, + sites=sites, category="magazine_holder", - model="MagazineHolder_4_Cathode", ) + + +def MagazineHolder_6_Anode(name: str) -> ItemizedCarrier: + """负极壳&弹垫片弹夹 - 6个洞位 (2x3布局) + + Args: + name: 弹夹名称 + + Returns: + ItemizedCarrier: 包含6个槽位的弹夹架 + """ + sites = create_ordered_items_2d( + klass=ResourceHolder, + num_items_x=3, + num_items_y=2, + dx=10.0, + dy=10.0, + dz=0.0, + item_dx=40.0, + item_dy=30.0, + size_x=35.0, + size_y=25.0, + size_z=40.0, + ) + + return ItemizedCarrier( + name=name, + size_x=150.0, + size_y=80.0, + size_z=50.0, + num_items_x=3, + num_items_y=2, + sites=sites, + category="magazine_holder", + ) + + +def MagazineHolder_6_Battery(name: str) -> ItemizedCarrier: + """成品弹夹 - 6个洞位 (3x2布局) + + Args: + name: 弹夹名称 + + Returns: + ItemizedCarrier: 包含6个槽位的弹夹架 + """ + sites = create_ordered_items_2d( + klass=ResourceHolder, + num_items_x=3, + num_items_y=2, + dx=10.0, + dy=10.0, + dz=0.0, + item_dx=33.0, + item_dy=40.0, + size_x=30.0, + size_y=35.0, + size_z=40.0, + ) + + return ItemizedCarrier( + name=name, + size_x=120.0, + size_y=100.0, + size_z=50.0, + num_items_x=3, + num_items_y=2, + sites=sites, + category="magazine_holder", + ) + diff --git a/unilabos/resources/bioyond/bottle_carriers.py b/unilabos/resources/bioyond/bottle_carriers.py new file mode 100644 index 00000000..d79b8495 --- /dev/null +++ b/unilabos/resources/bioyond/bottle_carriers.py @@ -0,0 +1,324 @@ +from pylabrobot.resources import create_homogeneous_resources, Coordinate, ResourceHolder, create_ordered_items_2d + +from unilabos.resources.itemized_carrier import BottleCarrier +from unilabos.resources.bioyond.bottles import ( + BIOYOND_PolymerStation_Solid_Stock, + BIOYOND_PolymerStation_Solid_Vial, + BIOYOND_PolymerStation_Liquid_Vial, + BIOYOND_PolymerStation_Solution_Beaker, + BIOYOND_PolymerStation_Reagent_Bottle, + BIOYOND_PolymerStation_Flask, +) +# 命名约定:试剂瓶-Bottle,烧杯-Beaker,烧瓶-Flask,小瓶-Vial + + +# ============================================================================ +# 聚合站(PolymerStation)载体定义(统一入口) +# ============================================================================ + +def BIOYOND_PolymerStation_6StockCarrier(name: str) -> BottleCarrier: + """聚合站-6孔样品板 - 2x3布局 + + 参数: + - name: 载架名称前缀 + + 说明: + - 统一站点命名为 PolymerStation,使用 PolymerStation 的 Vial 资源类 + - A行(PLR y=0,对应 Bioyond 位置A01~A03)使用 Liquid_Vial(10% 分装小瓶) + - B行(PLR y=1,对应 Bioyond 位置B01~B03)使用 Solid_Vial(90% 分装小瓶) + """ + + # 载架尺寸 (mm) + carrier_size_x = 127.8 + carrier_size_y = 85.5 + carrier_size_z = 50.0 + + # 瓶位尺寸 + bottle_diameter = 20.0 + bottle_spacing_x = 42.0 # X方向间距 + bottle_spacing_y = 35.0 # Y方向间距 + + # 计算起始位置 (居中排列) + start_x = (carrier_size_x - (3 - 1) * bottle_spacing_x - bottle_diameter) / 2 + start_y = (carrier_size_y - (2 - 1) * bottle_spacing_y - bottle_diameter) / 2 + + sites = create_ordered_items_2d( + klass=ResourceHolder, + num_items_x=3, + num_items_y=2, + dx=start_x, + dy=start_y, + dz=5.0, + item_dx=bottle_spacing_x, + item_dy=bottle_spacing_y, + + size_x=bottle_diameter, + size_y=bottle_diameter, + size_z=carrier_size_z, + ) + for k, v in sites.items(): + v.name = f"{name}_{v.name}" + + carrier = BottleCarrier( + name=name, + size_x=carrier_size_x, + size_y=carrier_size_y, + size_z=carrier_size_z, + sites=sites, + model="BIOYOND_PolymerStation_6StockCarrier", + ) + carrier.num_items_x = 3 + carrier.num_items_y = 2 + carrier.num_items_z = 1 + + # 布局说明: + # - num_items_x=3, num_items_y=2 表示 3列×2行 + # - create_ordered_items_2d 按先y后x的顺序创建(列优先) + # - 索引顺序: 0=A1(x=0,y=0), 1=B1(x=0,y=1), 2=A2(x=1,y=0), 3=B2(x=1,y=1), 4=A3(x=2,y=0), 5=B3(x=2,y=1) + # + # Bioyond坐标映射: PLR(x,y) → Bioyond(y+1,x+1) + # - A行(PLR y=0) → Bioyond x=1 → 10%分装小瓶 + # - B行(PLR y=1) → Bioyond x=2 → 90%分装小瓶 + + ordering = ["A1", "B1", "A2", "B2", "A3", "B3"] + for col in range(3): # 3列 + for row in range(2): # 2行 + idx = col * 2 + row # 计算索引: 列优先顺序 + if row == 0: # A行 (PLR y=0 → Bioyond x=1) + carrier[idx] = BIOYOND_PolymerStation_Liquid_Vial(f"{ordering[idx]}") + else: # B行 (PLR y=1 → Bioyond x=2) + carrier[idx] = BIOYOND_PolymerStation_Solid_Vial(f"{ordering[idx]}") + return carrier + + +def BIOYOND_PolymerStation_8StockCarrier(name: str) -> BottleCarrier: + """聚合站-8孔样品板 - 2x4布局 + + 参数: + - name: 载架名称前缀 + + 说明: + - 统一站点命名为 PolymerStation,使用 PolymerStation 的 Solid_Stock 资源类 + """ + + # 载架尺寸 (mm) + carrier_size_x = 128.0 + carrier_size_y = 85.5 + carrier_size_z = 50.0 + + # 瓶位尺寸 + bottle_diameter = 20.0 + bottle_spacing_x = 30.0 # X方向间距 + bottle_spacing_y = 35.0 # Y方向间距 + + # 计算起始位置 (居中排列) + start_x = (carrier_size_x - (4 - 1) * bottle_spacing_x - bottle_diameter) / 2 + start_y = (carrier_size_y - (2 - 1) * bottle_spacing_y - bottle_diameter) / 2 + + sites = create_ordered_items_2d( + klass=ResourceHolder, + num_items_x=4, + num_items_y=2, + dx=start_x, + dy=start_y, + dz=5.0, + item_dx=bottle_spacing_x, + item_dy=bottle_spacing_y, + + size_x=bottle_diameter, + size_y=bottle_diameter, + size_z=carrier_size_z, + ) + for k, v in sites.items(): + v.name = f"{name}_{v.name}" + + carrier = BottleCarrier( + name=name, + size_x=carrier_size_x, + size_y=carrier_size_y, + size_z=carrier_size_z, + sites=sites, + model="BIOYOND_PolymerStation_8StockCarrier", + ) + carrier.num_items_x = 4 + carrier.num_items_y = 2 + carrier.num_items_z = 1 + ordering = ["A1", "B1", "A2", "B2", "A3", "B3", "A4", "B4"] + for i in range(8): + carrier[i] = BIOYOND_PolymerStation_Solid_Stock(f"{name}_vial_{ordering[i]}") + return carrier + + +def BIOYOND_PolymerStation_1BottleCarrier(name: str) -> BottleCarrier: + """聚合站-单试剂瓶载架 + + 参数: + - name: 载架名称前缀 + """ + + # 载架尺寸 (mm) + carrier_size_x = 127.8 + carrier_size_y = 85.5 + carrier_size_z = 20.0 + + # 烧杯/试剂瓶占位尺寸(使用圆形占位) + beaker_diameter = 60.0 + + # 计算中央位置 + center_x = (carrier_size_x - beaker_diameter) / 2 + center_y = (carrier_size_y - beaker_diameter) / 2 + center_z = 5.0 + + carrier = BottleCarrier( + name=name, + size_x=carrier_size_x, + size_y=carrier_size_y, + size_z=carrier_size_z, + sites=create_homogeneous_resources( + klass=ResourceHolder, + locations=[Coordinate(center_x, center_y, center_z)], + resource_size_x=beaker_diameter, + resource_size_y=beaker_diameter, + name_prefix=name, + ), + model="BIOYOND_PolymerStation_1BottleCarrier", + ) + carrier.num_items_x = 1 + carrier.num_items_y = 1 + carrier.num_items_z = 1 + # 统一后缀采用 "flask_1" 命名(可按需调整) + carrier[0] = BIOYOND_PolymerStation_Reagent_Bottle(f"{name}_flask_1") + return carrier + + +def BIOYOND_PolymerStation_1FlaskCarrier(name: str) -> BottleCarrier: + """聚合站-单烧杯载架 + + 说明: + - 使用 BIOYOND_PolymerStation_Flask 资源类 + - 载架命名与 model 统一为 PolymerStation + """ + + # 载架尺寸 (mm) + carrier_size_x = 127.8 + carrier_size_y = 85.5 + carrier_size_z = 20.0 + + # 烧杯尺寸 + beaker_diameter = 60.0 + + # 计算中央位置 + center_x = (carrier_size_x - beaker_diameter) / 2 + center_y = (carrier_size_y - beaker_diameter) / 2 + center_z = 5.0 + + carrier = BottleCarrier( + name=name, + size_x=carrier_size_x, + size_y=carrier_size_y, + size_z=carrier_size_z, + sites=create_homogeneous_resources( + klass=ResourceHolder, + locations=[Coordinate(center_x, center_y, center_z)], + resource_size_x=beaker_diameter, + resource_size_y=beaker_diameter, + name_prefix=name, + ), + model="BIOYOND_PolymerStation_1FlaskCarrier", + ) + carrier.num_items_x = 1 + carrier.num_items_y = 1 + carrier.num_items_z = 1 + carrier[0] = BIOYOND_PolymerStation_Flask(f"{name}_flask_1") + return carrier + + +# ============================================================================ +# 其他载体定义 +# ============================================================================ + +def BIOYOND_Electrolyte_6VialCarrier(name: str) -> BottleCarrier: + """6瓶载架 - 2x3布局""" + + # 载架尺寸 (mm) + carrier_size_x = 127.8 + carrier_size_y = 85.5 + carrier_size_z = 50.0 + + # 瓶位尺寸 + bottle_diameter = 30.0 + bottle_spacing_x = 42.0 # X方向间距 + bottle_spacing_y = 35.0 # Y方向间距 + + # 计算起始位置 (居中排列) + start_x = (carrier_size_x - (3 - 1) * bottle_spacing_x - bottle_diameter) / 2 + start_y = (carrier_size_y - (2 - 1) * bottle_spacing_y - bottle_diameter) / 2 + + sites = create_ordered_items_2d( + klass=ResourceHolder, + num_items_x=3, + num_items_y=2, + dx=start_x, + dy=start_y, + dz=5.0, + item_dx=bottle_spacing_x, + item_dy=bottle_spacing_y, + + size_x=bottle_diameter, + size_y=bottle_diameter, + size_z=carrier_size_z, + ) + for k, v in sites.items(): + v.name = f"{name}_{v.name}" + + carrier = BottleCarrier( + name=name, + size_x=carrier_size_x, + size_y=carrier_size_y, + size_z=carrier_size_z, + sites=sites, + model="BIOYOND_Electrolyte_6VialCarrier", + ) + carrier.num_items_x = 3 + carrier.num_items_y = 2 + carrier.num_items_z = 1 + for i in range(6): + carrier[i] = BIOYOND_PolymerStation_Solid_Vial(f"{name}_vial_{i+1}") + return carrier + + +def BIOYOND_Electrolyte_1BottleCarrier(name: str) -> BottleCarrier: + """1瓶载架 - 单个中央位置""" + + # 载架尺寸 (mm) + carrier_size_x = 127.8 + carrier_size_y = 85.5 + carrier_size_z = 100.0 + + # 烧杯尺寸 + beaker_diameter = 80.0 + + # 计算中央位置 + center_x = (carrier_size_x - beaker_diameter) / 2 + center_y = (carrier_size_y - beaker_diameter) / 2 + center_z = 5.0 + + carrier = BottleCarrier( + name=name, + size_x=carrier_size_x, + size_y=carrier_size_y, + size_z=carrier_size_z, + sites=create_homogeneous_resources( + klass=ResourceHolder, + locations=[Coordinate(center_x, center_y, center_z)], + resource_size_x=beaker_diameter, + resource_size_y=beaker_diameter, + name_prefix=name, + ), + model="BIOYOND_Electrolyte_1BottleCarrier", + ) + carrier.num_items_x = 1 + carrier.num_items_y = 1 + carrier.num_items_z = 1 + carrier[0] = BIOYOND_PolymerStation_Solution_Beaker(f"{name}_beaker_1") + return carrier diff --git a/unilabos/resources/bioyond/bottles.py b/unilabos/resources/bioyond/bottles.py new file mode 100644 index 00000000..d60d65ab --- /dev/null +++ b/unilabos/resources/bioyond/bottles.py @@ -0,0 +1,195 @@ +from unilabos.resources.itemized_carrier import Bottle + + +def BIOYOND_PolymerStation_Solid_Stock( + name: str, + diameter: float = 20.0, + height: float = 100.0, + max_volume: float = 30000.0, # 30mL + barcode: str = None, +) -> Bottle: + """创建粉末瓶""" + return Bottle( + name=name, + diameter=diameter, + height=height, + max_volume=max_volume, + barcode=barcode, + model="BIOYOND_PolymerStation_Solid_Stock", + ) + + +def BIOYOND_PolymerStation_Solid_Vial( + name: str, + diameter: float = 25.0, + height: float = 60.0, + max_volume: float = 30000.0, # 30mL + barcode: str = None, +) -> Bottle: + """创建粉末瓶""" + return Bottle( + name=name, + diameter=diameter, + height=height, + max_volume=max_volume, + barcode=barcode, + model="BIOYOND_PolymerStation_Solid_Vial", + ) + + +def BIOYOND_PolymerStation_Liquid_Vial( + name: str, + diameter: float = 25.0, + height: float = 60.0, + max_volume: float = 30000.0, # 30mL + barcode: str = None, +) -> Bottle: + """创建滴定液瓶""" + return Bottle( + name=name, + diameter=diameter, + height=height, + max_volume=max_volume, + barcode=barcode, + model="BIOYOND_PolymerStation_Liquid_Vial", + ) + + +def BIOYOND_PolymerStation_Solution_Beaker( + name: str, + diameter: float = 60.0, + height: float = 70.0, + max_volume: float = 200000.0, # 200mL + barcode: str = None, +) -> Bottle: + """创建溶液烧杯""" + return Bottle( + name=name, + diameter=diameter, + height=height, + max_volume=max_volume, + barcode=barcode, + model="BIOYOND_PolymerStation_Solution_Beaker", + ) + + +def BIOYOND_PolymerStation_Reagent_Bottle( + name: str, + diameter: float = 70.0, + height: float = 120.0, + max_volume: float = 500000.0, # 500mL + barcode: str = None, +) -> Bottle: + """创建试剂瓶""" + return Bottle( + name=name, + diameter=diameter, + height=height, + max_volume=max_volume, + barcode=barcode, + model="BIOYOND_PolymerStation_Reagent_Bottle", + ) + + +def BIOYOND_PolymerStation_Reactor( + name: str, + diameter: float = 30.0, + height: float = 80.0, + max_volume: float = 50000.0, # 50mL + barcode: str = None, +) -> Bottle: + """创建反应器""" + return Bottle( + name=name, + diameter=diameter, + height=height, + max_volume=max_volume, + barcode=barcode, + model="BIOYOND_PolymerStation_Reactor", + ) + + +def BIOYOND_PolymerStation_TipBox( + name: str, + size_x: float = 127.76, # 枪头盒宽度 + size_y: float = 85.48, # 枪头盒长度 + size_z: float = 100.0, # 枪头盒高度 + barcode: str = None, +): + """创建4×6枪头盒 (24个枪头) + + Args: + name: 枪头盒名称 + size_x: 枪头盒宽度 (mm) + size_y: 枪头盒长度 (mm) + size_z: 枪头盒高度 (mm) + barcode: 条形码 + + Returns: + TipBoxCarrier: 包含24个枪头孔位的枪头盒 + """ + from pylabrobot.resources import Container, Coordinate + + # 创建枪头盒容器 + tip_box = Container( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + category="tip_rack", + model="BIOYOND_PolymerStation_TipBox_4x6", + ) + + # 设置自定义属性 + tip_box.barcode = barcode + tip_box.tip_count = 24 # 4行×6列 + tip_box.num_items_x = 6 # 6列 + tip_box.num_items_y = 4 # 4行 + + # 创建24个枪头孔位 (4行×6列) + # 假设孔位间距为 9mm + tip_spacing_x = 9.0 # 列间距 + tip_spacing_y = 9.0 # 行间距 + start_x = 14.38 # 第一个孔位的x偏移 + start_y = 11.24 # 第一个孔位的y偏移 + + for row in range(4): # A, B, C, D + for col in range(6): # 1-6 + spot_name = f"{chr(65 + row)}{col + 1}" # A1, A2, ..., D6 + x = start_x + col * tip_spacing_x + y = start_y + row * tip_spacing_y + + # 创建枪头孔位容器 + tip_spot = Container( + name=spot_name, + size_x=8.0, # 单个枪头孔位大小 + size_y=8.0, + size_z=size_z - 10.0, # 略低于盒子高度 + category="tip_spot", + ) + + # 添加到枪头盒 + tip_box.assign_child_resource( + tip_spot, + location=Coordinate(x=x, y=y, z=0) + ) + + return tip_box + + +def BIOYOND_PolymerStation_Flask( + name: str, + diameter: float = 60.0, + height: float = 70.0, + max_volume: float = 200000.0, # 200mL + barcode: str = None, +) -> Bottle: + """聚合站-烧杯(统一 Flask 资源到 PolymerStation)""" + return Bottle( + name=name, + diameter=diameter, + height=height, + max_volume=max_volume, + barcode=barcode, + model="BIOYOND_PolymerStation_Flask", + ) diff --git a/unilabos/resources/bioyond/decks.py b/unilabos/resources/bioyond/decks.py index bfed3d7d..5572536b 100644 --- a/unilabos/resources/bioyond/decks.py +++ b/unilabos/resources/bioyond/decks.py @@ -1,13 +1,29 @@ from os import name -from pickle import TRUE from pylabrobot.resources import Deck, Coordinate, Rotation -from unilabos.resources.bioyond.YB_warehouses import bioyond_warehouse_1x4x4, bioyond_warehouse_1x4x2, bioyond_warehouse_liquid_and_lid_handling, bioyond_warehouse_1x2x2, bioyond_warehouse_1x3x3, bioyond_warehouse_10x1x1, bioyond_warehouse_3x3x1, bioyond_warehouse_3x3x1_2, bioyond_warehouse_5x1x1, bioyond_warehouse_20x1x1, bioyond_warehouse_2x2x1, bioyond_warehouse_3x5x1 +from unilabos.resources.bioyond.warehouses import ( + bioyond_warehouse_1x4x4, + bioyond_warehouse_1x4x4_right, # 新增:右侧仓库 (A05~D08) + bioyond_warehouse_1x4x2, + bioyond_warehouse_reagent_stack, # 新增:试剂堆栈 (A1-B4) + bioyond_warehouse_liquid_and_lid_handling, + bioyond_warehouse_1x2x2, + bioyond_warehouse_1x3x3, + bioyond_warehouse_10x1x1, + bioyond_warehouse_3x3x1, + bioyond_warehouse_3x3x1_2, + bioyond_warehouse_5x1x1, + bioyond_warehouse_1x8x4, + bioyond_warehouse_reagent_storage, + # bioyond_warehouse_liquid_preparation, + bioyond_warehouse_tipbox_storage, # 新增:Tip盒堆栈 + bioyond_warehouse_density_vial, +) class BIOYOND_PolymerReactionStation_Deck(Deck): def __init__( - self, + self, name: str = "PolymerReactionStation_Deck", size_x: float = 2700.0, size_y: float = 1080.0, @@ -21,24 +37,35 @@ class BIOYOND_PolymerReactionStation_Deck(Deck): def setup(self) -> None: # 添加仓库 + # 说明: 堆栈1物理上分为左右两部分 + # - 堆栈1左: A01~D04 (4行×4列, 位于反应站左侧) + # - 堆栈1右: A05~D08 (4行×4列, 位于反应站右侧) self.warehouses = { - "堆栈1": bioyond_warehouse_1x4x4("堆栈1"), - "堆栈2": bioyond_warehouse_1x4x4("堆栈2"), - "站内试剂存放堆栈": bioyond_warehouse_liquid_and_lid_handling("站内试剂存放堆栈"), + "堆栈1左": bioyond_warehouse_1x4x4("堆栈1左"), # 左侧堆栈: A01~D04 + "堆栈1右": bioyond_warehouse_1x4x4_right("堆栈1右"), # 右侧堆栈: A05~D08 + "站内试剂存放堆栈": bioyond_warehouse_reagent_storage("站内试剂存放堆栈"), # A01~A02 + # "移液站内10%分装液体准备仓库": bioyond_warehouse_liquid_preparation("移液站内10%分装液体准备仓库"), # A01~B04 + "站内Tip盒堆栈": bioyond_warehouse_tipbox_storage("站内Tip盒堆栈"), # A01~B03, 存放枪头盒. + "测量小瓶仓库(测密度)": bioyond_warehouse_density_vial("测量小瓶仓库(测密度)"), # A01~B03 } self.warehouse_locations = { - "堆栈1": Coordinate(0.0, 430.0, 0.0), - "堆栈2": Coordinate(2550.0, 430.0, 0.0), - "站内试剂存放堆栈": Coordinate(800.0, 475.0, 0.0), + "堆栈1左": Coordinate(0.0, 430.0, 0.0), # 左侧位置 + "堆栈1右": Coordinate(2500.0, 430.0, 0.0), # 右侧位置 + "站内试剂存放堆栈": Coordinate(640.0, 480.0, 0.0), + # "移液站内10%分装液体准备仓库": Coordinate(1200.0, 600.0, 0.0), + "站内Tip盒堆栈": Coordinate(300.0, 150.0, 0.0), + "测量小瓶仓库(测密度)": Coordinate(922.0, 552.0, 0.0), } self.warehouses["站内试剂存放堆栈"].rotation = Rotation(z=90) + self.warehouses["测量小瓶仓库(测密度)"].rotation = Rotation(z=270) for warehouse_name, warehouse in self.warehouses.items(): self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name]) + class BIOYOND_PolymerPreparationStation_Deck(Deck): def __init__( - self, + self, name: str = "PolymerPreparationStation_Deck", size_x: float = 2700.0, size_y: float = 1080.0, @@ -51,18 +78,21 @@ class BIOYOND_PolymerPreparationStation_Deck(Deck): self.setup() def setup(self) -> None: - # 添加仓库 + # 添加仓库 - 配液站的3个堆栈,使用Bioyond系统中的实际名称 + # 样品类型(typeMode=1):烧杯、试剂瓶、分装板 → 试剂堆栈、溶液堆栈 + # 试剂类型(typeMode=2):样品板 → 粉末堆栈 self.warehouses = { - "io_warehouse_left": bioyond_warehouse_1x4x4("io_warehouse_left"), - "io_warehouse_right": bioyond_warehouse_1x4x4("io_warehouse_right"), - "solutions": bioyond_warehouse_1x4x2("warehouse_solutions"), - "liquid_and_lid_handling": bioyond_warehouse_liquid_and_lid_handling("warehouse_liquid_and_lid_handling"), + # 试剂类型 - 样品板 + "粉末堆栈": bioyond_warehouse_1x4x4("粉末堆栈"), # 4行×4列 (A01-D04) + + # 样品类型 - 烧杯、试剂瓶、分装板 + "试剂堆栈": bioyond_warehouse_reagent_stack("试剂堆栈"), # 2行×4列 (A01-B04) + "溶液堆栈": bioyond_warehouse_1x4x4("溶液堆栈"), # 4行×4列 (A01-D04) } self.warehouse_locations = { - "io_warehouse_left": Coordinate(0.0, 650.0, 0.0), - "io_warehouse_right": Coordinate(2550.0, 650.0, 0.0), - "solutions": Coordinate(1915.0, 900.0, 0.0), - "liquid_and_lid_handling": Coordinate(1330.0, 490.0, 0.0), + "粉末堆栈": Coordinate(0.0, 450.0, 0.0), + "试剂堆栈": Coordinate(1850.0, 200.0, 0.0), + "溶液堆栈": Coordinate(2500.0, 450.0, 0.0), } for warehouse_name, warehouse in self.warehouses.items(): @@ -70,7 +100,7 @@ class BIOYOND_PolymerPreparationStation_Deck(Deck): class BIOYOND_YB_Deck(Deck): def __init__( - self, + self, name: str = "YB_Deck", size_x: float = 4150, size_y: float = 1400.0, @@ -85,32 +115,39 @@ class BIOYOND_YB_Deck(Deck): def setup(self) -> None: # 添加仓库 self.warehouses = { - "自动堆栈-左": bioyond_warehouse_2x2x1("自动堆栈-左"), - "自动堆栈-右": bioyond_warehouse_2x2x1("自动堆栈-右"), - "手动堆栈-左": bioyond_warehouse_3x5x1("手动堆栈-左"), - "手动堆栈-右": bioyond_warehouse_3x5x1("手动堆栈-右"), - "粉末加样头堆栈": bioyond_warehouse_20x1x1("粉末加样头堆栈"), - "配液站内试剂仓库": bioyond_warehouse_3x3x1("配液站内试剂仓库"), - "试剂替换仓库": bioyond_warehouse_10x1x1("试剂替换仓库"), + "321窗口": bioyond_warehouse_1x2x2("321窗口"), + "43窗口": bioyond_warehouse_1x2x2("43窗口"), + "手动传递窗左": bioyond_warehouse_1x3x3("手动传递窗左"), + "手动传递窗右": bioyond_warehouse_1x3x3("手动传递窗右"), + "加样头堆栈左": bioyond_warehouse_10x1x1("加样头堆栈左"), + "加样头堆栈右": bioyond_warehouse_10x1x1("加样头堆栈右"), + + "15ml配液堆栈左": bioyond_warehouse_3x3x1("15ml配液堆栈左"), + "母液加样右": bioyond_warehouse_3x3x1_2("母液加样右"), + "大瓶母液堆栈左": bioyond_warehouse_5x1x1("大瓶母液堆栈左"), + "大瓶母液堆栈右": bioyond_warehouse_5x1x1("大瓶母液堆栈右"), } # warehouse 的位置 self.warehouse_locations = { - "自动堆栈-左": Coordinate(-100.3, 171.5, 0.0), - "自动堆栈-右": Coordinate(3960.1, 155.9, 0.0), - "手动堆栈-左": Coordinate(-213.3, 804.4, 0.0), - "手动堆栈-右": Coordinate(3960.1, 807.6, 0.0), - "粉末加样头堆栈": Coordinate(415.0, 1301.0, 0.0), - "配液站内试剂仓库": Coordinate(2162.0, 437.0, 0.0), - "试剂替换仓库": Coordinate(1173.0, 802.0, 0.0), + "321窗口": Coordinate(-150.0, 158.0, 0.0), + "43窗口": Coordinate(4160.0, 158.0, 0.0), + "手动传递窗左": Coordinate(-150.0, 877.0, 0.0), + "手动传递窗右": Coordinate(4160.0, 877.0, 0.0), + "加样头堆栈左": Coordinate(385.0, 1300.0, 0.0), + "加样头堆栈右": Coordinate(2187.0, 1300.0, 0.0), + + "15ml配液堆栈左": Coordinate(749.0, 355.0, 0.0), + "母液加样右": Coordinate(2152.0, 333.0, 0.0), + "大瓶母液堆栈左": Coordinate(1164.0, 676.0, 0.0), + "大瓶母液堆栈右": Coordinate(2717.0, 676.0, 0.0), } for warehouse_name, warehouse in self.warehouses.items(): self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name]) - -# def YB_Deck(name: str) -> Deck: -# # by=BIOYOND_YB_Deck(name=name) -# # by.setup() -# return None +def YB_Deck(name: str) -> Deck: + by=BIOYOND_YB_Deck(name=name) + by.setup() + return by diff --git a/unilabos/resources/container.py b/unilabos/resources/container.py index 23b044c7..f9772442 100644 --- a/unilabos/resources/container.py +++ b/unilabos/resources/container.py @@ -9,6 +9,7 @@ from unilabos.ros.msgs.message_converter import convert_from_ros_msg class RegularContainer(Container): def __init__(self, *args, **kwargs): + pose = kwargs.pop("pose", None) if "size_x" not in kwargs: kwargs["size_x"] = 0 if "size_y" not in kwargs: @@ -17,10 +18,17 @@ class RegularContainer(Container): kwargs["size_z"] = 0 self.kwargs = kwargs self.state = {} - super().__init__(*args, **kwargs) + super().__init__(*args, category="container", **kwargs) def load_state(self, state: Dict[str, Any]): self.state = state + + +def get_regular_container(name="container"): + r = RegularContainer(name=name) + r.category = "container" + return RegularContainer(name=name) + # # class RegularContainer(object): # # 第一个参数必须是id传入 diff --git a/unilabos/resources/graphio.py b/unilabos/resources/graphio.py index 3d55468c..a56785ba 100644 --- a/unilabos/resources/graphio.py +++ b/unilabos/resources/graphio.py @@ -11,7 +11,7 @@ from unilabos_msgs.msg import Resource from unilabos.config.config import BasicConfig from unilabos.resources.container import RegularContainer -from unilabos.resources.itemized_carrier import ItemizedCarrier +from unilabos.resources.itemized_carrier import ItemizedCarrier, BottleCarrier from unilabos.ros.msgs.message_converter import convert_to_ros_msg from unilabos.ros.nodes.resource_tracker import ( ResourceDictInstance, @@ -42,13 +42,16 @@ def canonicalize_nodes_data( Returns: ResourceTreeSet: 标准化后的资源树集合 """ - print_status(f"{len(nodes)} Resources loaded:", "info") + print_status(f"{len(nodes)} Resources loaded", "info") # 第一步:基本预处理(处理graphml的label字段) - for node in nodes: + outer_host_node_id = None + for idx, node in enumerate(nodes): if node.get("label") is not None: node_id = node.pop("label") node["id"] = node["name"] = node_id + if node["id"] == "host_node": + outer_host_node_id = idx if not isinstance(node.get("config"), dict): node["config"] = {} if not node.get("type"): @@ -58,25 +61,26 @@ def canonicalize_nodes_data( node["name"] = node.get("id") print_status(f"Warning: Node {node.get('id', 'unknown')} missing 'name', defaulting to {node['name']}", "warning") if not isinstance(node.get("position"), dict): - node["position"] = {"position": {}} + node["pose"] = {"position": {}} x = node.pop("x", None) if x is not None: - node["position"]["position"]["x"] = x + node["pose"]["position"]["x"] = x y = node.pop("y", None) if y is not None: - node["position"]["position"]["y"] = y + node["pose"]["position"]["y"] = y z = node.pop("z", None) if z is not None: - node["position"]["position"]["z"] = z + node["pose"]["position"]["z"] = z if "sample_id" in node: sample_id = node.pop("sample_id") if sample_id: logger.error(f"{node}的sample_id参数已弃用,sample_id: {sample_id}") for k in list(node.keys()): - if k not in ["id", "uuid", "name", "description", "schema", "model", "icon", "parent_uuid", "parent", "type", "class", "position", "config", "data", "children"]: + if k not in ["id", "uuid", "name", "description", "schema", "model", "icon", "parent_uuid", "parent", "type", "class", "position", "config", "data", "children", "pose"]: v = node.pop(k) node["config"][k] = v - + if outer_host_node_id is not None: + nodes.pop(outer_host_node_id) # 第二步:处理parent_relation id2idx = {node["id"]: idx for idx, node in enumerate(nodes)} for parent, children in parent_relation.items(): @@ -93,7 +97,7 @@ def canonicalize_nodes_data( for node in nodes: try: - print_status(f"DeviceId: {node['id']}, Class: {node['class']}", "info") + # print_status(f"DeviceId: {node['id']}, Class: {node['class']}", "info") # 使用标准化方法 resource_instance = ResourceDictInstance.get_resource_instance_from_dict(node) known_nodes[node["id"]] = resource_instance @@ -228,7 +232,7 @@ def handle_communications(G: nx.Graph): 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]}') + logger.warning(f'Modify {device}\'s io_device_port to {edata["port"][device_comm]}') G.nodes[device]["config"]["io_device_port"] = int(edata["port"][device_comm]) @@ -580,11 +584,17 @@ def resource_plr_to_ulab(resource_plr: "ResourcePLR", parent_name: str = None, w "trash": "trash", "deck": "deck", "tip_rack": "tip_rack", + "warehouse": "warehouse", + "container": "container", + "tube": "tube", + "bottle_carrier": "bottle_carrier", + "plate_adapter": "plate_adapter", } if source in replace_info: return replace_info[source] else: - print("转换pylabrobot的时候,出现未知类型", source) + if source is not None: + logger.warning(f"转换pylabrobot的时候,出现未知类型: {source}") return source def resource_plr_to_ulab_inner(d: dict, all_states: dict, child=True) -> dict: @@ -619,131 +629,480 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st Args: bioyond_materials: bioyond 系统的物料查询结果列表 - type_mapping: 物料类型映射字典,格式 {bioyond_type: [plr_class_name, class_uuid]} + type_mapping: 物料类型映射字典,格式 {model: (显示名称, UUID)} 或 {显示名称: (model, UUID)} location_id_mapping: 库位 ID 到名称的映射字典,格式 {location_id: location_name} Returns: pylabrobot 格式的物料列表 """ - print("1:bioyond_materials:",bioyond_materials) - # print("2:type_mapping:",type_mapping) plr_materials = [] + # 创建反向映射: {显示名称: (model, UUID)} -> 用于从 Bioyond typeName 查找 model + # 如果 type_mapping 的 key 已经是显示名称,则直接使用;否则创建反向映射 + reverse_type_mapping = {} + for key, value in type_mapping.items(): + # value 可能是 tuple 或 list: (显示名称, UUID) 或 [显示名称, UUID] + display_name = value[0] if isinstance(value, (tuple, list)) and len(value) >= 1 else None + if display_name: + # 反向映射: {显示名称: (原始key作为model, UUID)} + resource_uuid = value[1] if len(value) >= 2 else "" + # 如果已存在该显示名称,跳过(保留第一个遇到的映射) + if display_name not in reverse_type_mapping: + reverse_type_mapping[display_name] = (key, resource_uuid) + + logger.debug(f"[反向映射表] 共 {len(reverse_type_mapping)} 个条目: {list(reverse_type_mapping.keys())}") + + + # 用于跟踪同名物料的计数器 + name_counter = {} + for material in bioyond_materials: - className = ( - type_mapping.get(material.get("typeName"), ("RegularContainer", ""))[0] if type_mapping else "RegularContainer" + # 从反向映射中查找: typeName(显示名称) -> (model, UUID) + type_info = reverse_type_mapping.get(material.get("typeName")) + className = type_info[0] if type_info else "RegularContainer" + + # 为同名物料添加唯一后缀 + base_name = material["name"] + if base_name in name_counter: + name_counter[base_name] += 1 + unique_name = f"{base_name}_{name_counter[base_name]}" + else: + name_counter[base_name] = 1 + unique_name = base_name + + plr_material_result = initialize_resource( + {"name": unique_name, "class": className}, resource_type=ResourcePLR ) - plr_material: ResourcePLR = initialize_resource( - {"name": material["name"], "class": className}, resource_type=ResourcePLR - ) - print("plr_material:",plr_material) - print("code:",material.get("code", "")) + # initialize_resource 可能返回列表或单个对象 + if isinstance(plr_material_result, list): + if len(plr_material_result) == 0: + logger.warning(f"物料 {material['name']} 初始化失败,跳过") + continue + plr_material = plr_material_result[0] + else: + plr_material = plr_material_result + + # 确保 plr_material 是 ResourcePLR 实例 + if not isinstance(plr_material, ResourcePLR): + logger.warning(f"物料 {unique_name} 不是有效的 ResourcePLR 实例,类型: {type(plr_material)}") + continue + plr_material.code = material.get("code", "") and material.get("barCode", "") or "" plr_material.unilabos_uuid = str(uuid.uuid4()) + # ⭐ 保存 Bioyond 原始信息到 unilabos_extra(用于出库时查询) + plr_material.unilabos_extra = { + "material_bioyond_id": material.get("id"), # Bioyond 物料 UUID + "material_bioyond_name": material.get("name"), # Bioyond 原始名称(如 "MDA") + "material_bioyond_type": material.get("typeName"), # Bioyond 物料类型名称 + } + + logger.debug(f"[转换物料] {material['name']} (ID:{material['id']}) → {unique_name} (类型:{className})") + # 处理子物料(detail) if material.get("detail") and len(material["detail"]) > 0: for bottle in reversed(plr_material.children): plr_material.unassign_child_resource(bottle) child_ids = [] + + # 确定detail物料的默认类型 + # 样品板的detail通常是样品瓶 + default_detail_type = "样品瓶" if "样品板" in material.get("typeName", "") else None + for detail in material["detail"]: number = ( (detail.get("z", 0) - 1) * plr_material.num_items_x * plr_material.num_items_y + (detail.get("y", 0) - 1) * plr_material.num_items_y + (detail.get("x", 0) - 1) ) - typeName = detail.get("typeName", detail.get("name", "")) - if typeName in type_mapping: + + # 检查索引是否超出范围 + max_index = plr_material.num_items_x * plr_material.num_items_y - 1 + if number < 0 or number > max_index: + logger.warning( + f" └─ [子物料警告] {detail['name']} 的坐标 (x={detail.get('x')}, y={detail.get('y')}, z={detail.get('z')}) " + f"计算出索引 {number} 超出载架范围 [0-{max_index}] (布局: {plr_material.num_items_x}×{plr_material.num_items_y}),跳过" + ) + continue + + # detail可能没有typeName,尝试从name推断,或使用默认类型 + typeName = detail.get("typeName") + + # 如果没有typeName,尝试根据父物料类型和位置推断 + if not typeName: + if "分装板" in material.get("typeName", ""): + # 分装板: 根据行(x)判断类型 + # 第一行(x=1)是10%分装小瓶,第二行(x=2)是90%分装小瓶 + x_pos = detail.get("x", 0) + y_pos = detail.get("y", 0) + # logger.debug(f" └─ [推断类型] {detail['name']} 坐标(x={x_pos}, y={y_pos})") + if x_pos == 1: + typeName = "10%分装小瓶" + elif x_pos == 2: + typeName = "90%分装小瓶" + # logger.debug(f" └─ [推断结果] {detail['name']} → {typeName}") + else: + typeName = default_detail_type + + if typeName and typeName in reverse_type_mapping: bottle = plr_material[number] = initialize_resource( - {"name": f'{detail["name"]}_{number}', "class": type_mapping[typeName][0]}, resource_type=ResourcePLR + {"name": f'{detail["name"]}_{number}', "class": reverse_type_mapping[typeName][0]}, resource_type=ResourcePLR ) bottle.tracker.liquids = [ (detail["name"], float(detail.get("quantity", 0)) if detail.get("quantity") else 0) ] bottle.code = detail.get("code", "") + logger.debug(f" └─ [子物料] {detail['name']} → {plr_material.name}[{number}] (类型:{typeName})") + else: + logger.warning(f" └─ [子物料警告] {detail['name']} 的类型 '{typeName}' 不在mapping中,跳过") else: - bottle = plr_material[0] if plr_material.capacity > 0 else plr_material - bottle.tracker.liquids = [ - (material["name"], float(material.get("quantity", 0)) if material.get("quantity") else 0) - ] + # 只对有 capacity 属性的容器(液体容器)处理液体追踪 + if hasattr(plr_material, 'capacity'): + bottle = plr_material[0] if plr_material.capacity > 0 else plr_material + bottle.tracker.liquids = [ + (material["name"], float(material.get("quantity", 0)) if material.get("quantity") else 0) + ] plr_materials.append(plr_material) if deck and hasattr(deck, "warehouses"): - for loc in material.get("locations", []): - if hasattr(deck, "warehouses") and loc.get("whName") in deck.warehouses: - warehouse = deck.warehouses[loc["whName"]] - num_x = getattr(warehouse, "num_items_x", 0) or 0 - num_y = getattr(warehouse, "num_items_y", 0) or 0 - num_z = getattr(warehouse, "num_items_z", 0) or 0 - if num_x <= 0 or num_y <= 0 or num_z <= 0: + locations = material.get("locations", []) + if not locations: + logger.debug(f"[物料位置] {unique_name} 没有location信息,跳过warehouse放置") + + for loc in locations: + wh_name = loc.get("whName") + logger.debug(f"[物料位置] {unique_name} 尝试放置到 warehouse: {wh_name} (Bioyond坐标: x={loc.get('x')}, y={loc.get('y')}, z={loc.get('z')})") + + # 特殊处理: Bioyond的"堆栈1"需要映射到"堆栈1左"或"堆栈1右" + # 根据列号(x)判断: 1-4映射到左侧, 5-8映射到右侧 + if wh_name == "堆栈1": + x_val = loc.get("x", 1) + if 1 <= x_val <= 4: + wh_name = "堆栈1左" + elif 5 <= x_val <= 8: + wh_name = "堆栈1右" + else: + logger.warning(f"物料 {material['name']} 的列号 x={x_val} 超出范围,无法映射到堆栈1左或堆栈1右") continue - idx = ( - (loc.get("z", 0) - 1) * num_x * num_y - + (loc.get("y", 0) - 1) * num_x - + (loc.get("x", 0) - 1) - ) + + if hasattr(deck, "warehouses") and wh_name in deck.warehouses: + warehouse = deck.warehouses[wh_name] + logger.debug(f"[Warehouse匹配] 找到warehouse: {wh_name} (容量: {warehouse.capacity}, 行×列: {warehouse.num_items_x}×{warehouse.num_items_y})") + + # Bioyond坐标映射 (重要!): x→行(1=A,2=B...), y→列(1=01,2=02...), z→层(通常=1) + # PyLabRobot warehouse是列优先存储: A01,B01,C01,D01, A02,B02,C02,D02, ... + x = loc.get("x", 1) # 行号 (1-based: 1=A, 2=B, 3=C, 4=D) + y = loc.get("y", 1) # 列号 (1-based: 1=01, 2=02, 3=03...) + z = loc.get("z", 1) # 层号 (1-based, 通常为1) + + # 如果是右侧堆栈,需要调整列号 (5→1, 6→2, 7→3, 8→4) + if wh_name == "堆栈1右": + y = y - 4 # 将5-8映射到1-4 + + # 特殊处理:对于1行×N列的横向warehouse(如站内试剂存放堆栈) + # Bioyond的y坐标表示线性位置序号,而不是列号 + if warehouse.num_items_y == 1: + # 1行warehouse: 直接用y作为线性索引 + idx = y - 1 + logger.debug(f"1行warehouse {wh_name}: y={y} → idx={idx}") + else: + # 多行warehouse: 根据 layout 使用不同的索引计算 + row_idx = x - 1 # x表示行: 转为0-based + col_idx = y - 1 # y表示列: 转为0-based + layer_idx = z - 1 # 转为0-based + + # 检查 warehouse 的排序方式属性 + ordering_layout = getattr(warehouse, 'ordering_layout', 'col-major') + logger.debug(f"🔍 Warehouse {wh_name} layout检测: hasattr={hasattr(warehouse, 'ordering_layout')}, ordering_layout值='{ordering_layout}', warehouse类型={type(warehouse).__name__}") + + if ordering_layout == "row-major": + # 行优先: A01,A02,A03,A04, B01,B02,B03,B04 (所有Bioyond堆栈) + # 索引计算: idx = (row) * num_cols + (col) + (layer) * (rows * cols) + idx = layer_idx * (warehouse.num_items_x * warehouse.num_items_y) + row_idx * warehouse.num_items_x + col_idx + logger.debug(f"行优先warehouse {wh_name}: x={x}(行),y={y}(列) → row={row_idx},col={col_idx} → idx={idx}") + else: + # 列优先 (后备): A01,B01,C01,D01, A02,B02,C02,D02 + # 索引计算: idx = (col) * num_rows + (row) + (layer) * (rows * cols) + idx = layer_idx * (warehouse.num_items_x * warehouse.num_items_y) + col_idx * warehouse.num_items_y + row_idx + logger.debug(f"列优先warehouse {wh_name}: x={x}(行),y={y}(列) → row={row_idx},col={col_idx} → idx={idx}") + if 0 <= idx < warehouse.capacity: if warehouse[idx] is None or isinstance(warehouse[idx], ResourceHolder): warehouse[idx] = plr_material + logger.debug(f"✅ 物料 {unique_name} 放置到 {wh_name}[{idx}] (Bioyond坐标: x={loc.get('x')}, y={loc.get('y')})") + else: + logger.warning(f"❌ 物料 {unique_name} 的索引 {idx} 超出仓库 {wh_name} 容量 {warehouse.capacity}") + else: + if wh_name: + logger.warning(f"❌ 物料 {unique_name} 的warehouse '{wh_name}' 在deck中不存在。可用warehouses: {list(deck.warehouses.keys()) if hasattr(deck, 'warehouses') else '无'}") return plr_materials -def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict = {}, warehouse_mapping: dict = {}) -> list[dict]: +def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict = {}, warehouse_mapping: dict = {}, material_params: dict = {}) -> list[dict]: + """ + 将 PyLabRobot 资源转换为 Bioyond 格式 + + Args: + plr_resources: PyLabRobot 资源列表 + type_mapping: 物料类型映射字典 + warehouse_mapping: 仓库映射字典 + material_params: 物料默认参数字典 (格式: {物料名称: {参数字典}}) + + Returns: + Bioyond 格式的物料列表 + """ bioyond_materials = [] + + # 定义不需要发送 details 的载架类型 + # 说明:这些载架上自带试剂瓶或烧杯,作为整体物料上传即可,不需要在 details 中重复上传子物料 + CARRIERS_WITHOUT_DETAILS = { + "BIOYOND_PolymerStation_1BottleCarrier", # 聚合站-单试剂瓶载架 + "BIOYOND_PolymerStation_1FlaskCarrier", # 聚合站-单烧杯载架 + } + for resource in plr_resources: - if hasattr(resource, "capacity") and resource.capacity > 1: + if isinstance(resource, BottleCarrier) and resource.capacity > 1: + # 获取 BottleCarrier 的类型映射 + type_info = type_mapping.get(resource.model) + if not type_info: + logger.error(f"❌ [PLR→Bioyond] BottleCarrier 资源 '{resource.name}' 的 model '{resource.model}' 不在 type_mapping 中") + logger.debug(f"[PLR→Bioyond] 可用的 type_mapping 键: {list(type_mapping.keys())}") + raise ValueError(f"资源 model '{resource.model}' 未在 MATERIAL_TYPE_MAPPINGS 中配置") + material = { - "typeId": type_mapping.get(resource.model)[1], + "typeId": type_info[1], + "code": "", + "barCode": "", "name": resource.name, "unit": "个", "quantity": 1, "details": [], - "Parameters": "{}" - } - for bottle in resource.children: - if isinstance(resource, ItemizedCarrier): - site = resource.get_child_identifier(bottle) - else: - site = {"x": bottle.location.x - 1, "y": bottle.location.y - 1} - detail_item = { - "typeId": type_mapping.get(bottle.model)[1], - "name": bottle.name, - "code": bottle.code if hasattr(bottle, "code") else "", - "quantity": sum(qty for _, qty in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0, - "x": site["x"] + 1, - "y": site["y"] + 1, - "molecular": 1, - "Parameters": json.dumps({"molecular": 1}) - } - material["details"].append(detail_item) - else: - bottle = resource[0] if resource.capacity > 0 else resource - material = { - "typeId": "3a14196b-24f2-ca49-9081-0cab8021bf1a", - "name": resource.get("name", ""), - "unit": "", - "quantity": sum(qty for _, qty in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0, - "Parameters": "{}" + "Parameters": "{}" # API 实际要求的字段(必需) } - if resource.parent is not None and isinstance(resource.parent, ItemizedCarrier): + # 如果是自带试剂瓶的载架类型,不处理子物料(details留空) + if resource.model in CARRIERS_WITHOUT_DETAILS: + logger.info(f"[PLR→Bioyond] 载架 '{resource.name}' (model: {resource.model}) 自带试剂瓶,不添加 details") + else: + # 处理其他载架类型的子物料 + for bottle in resource.children: + if isinstance(resource, ItemizedCarrier): + # ⭐ 优化:直接使用 get_child_identifier 获取真实的子物料坐标 + # 这个方法会遍历 resource.children 找到 bottle 对象的实际位置 + site = resource.get_child_identifier(bottle) + + # 🔧 如果 get_child_identifier 失败或返回无效坐标 (0,0) + # 这通常发生在子物料名称使用纯数字后缀时(如 "BTDA_0", "BTDA_4") + if not site or (site.get("x") == 0 and site.get("y") == 0): + # 方法1: 尝试从名称中提取标识符并解析 + bottle_identifier = None + if "_" in bottle.name: + bottle_identifier = bottle.name.split("_")[-1] + + # 只有非纯数字标识符才尝试解析(如 "A1", "B2") + if bottle_identifier and not bottle_identifier.isdigit(): + try: + x_idx, y_idx, z_idx = resource._parse_identifier_to_indices(bottle_identifier, 0) + site = {"x": x_idx, "y": y_idx, "z": z_idx, "identifier": bottle_identifier} + logger.debug(f" 🔧 [坐标修正-方法1] 从名称 {bottle.name} 解析标识符 {bottle_identifier} → ({x_idx}, {y_idx})") + except Exception as e: + logger.warning(f" ⚠️ [坐标解析] 标识符 {bottle_identifier} 解析失败: {e}") + + # 方法2: 如果方法1失败,使用线性索引反推坐标 + if not site or (site.get("x") == 0 and site.get("y") == 0): + # 找到bottle在children中的索引位置 + try: + # 遍历所有槽位找到bottle的实际位置 + for idx in range(resource.num_items_x * resource.num_items_y): + if resource[idx] is bottle: + # 根据载架布局计算行列坐标 + # ItemizedCarrier 默认是列优先布局 (A1,B1,C1,D1, A2,B2,C2,D2...) + col_idx = idx // resource.num_items_y # 列索引 (0-based) + row_idx = idx % resource.num_items_y # 行索引 (0-based) + site = {"x": col_idx, "y": row_idx, "z": 0, "identifier": str(idx)} + logger.debug(f" 🔧 [坐标修正-方法2] {bottle.name} 在索引 {idx} → 列={col_idx}, 行={row_idx}") + break + except Exception as e: + logger.error(f" ❌ [坐标计算失败] {bottle.name}: {e}") + # 最后的兜底:使用 (0,0) + site = {"x": 0, "y": 0, "z": 0, "identifier": ""} + else: + site = {"x": bottle.location.x - 1, "y": bottle.location.y - 1, "identifier": ""} + + # 获取子物料的类型映射 + bottle_type_info = type_mapping.get(bottle.model) + if not bottle_type_info: + logger.error(f"❌ [PLR→Bioyond] 子物料 '{bottle.name}' 的 model '{bottle.model}' 不在 type_mapping 中") + raise ValueError(f"子物料 model '{bottle.model}' 未在 MATERIAL_TYPE_MAPPINGS 中配置") + + # ⚠️ 坐标系转换说明: + # _parse_identifier_to_indices 返回: x=列索引, y=行索引 (0-based) + # Bioyond 系统要求: x=行号, y=列号 (1-based) + # 因此需要交换 x 和 y! + bioyond_x = site["y"] + 1 # 行索引 → Bioyond的x (行号) + bioyond_y = site["x"] + 1 # 列索引 → Bioyond的y (列号) + + # 🐛 调试日志 + logger.debug(f"🔍 [PLR→Bioyond] detail转换: {bottle.name} → PLR(x={site['x']},y={site['y']},id={site.get('identifier','?')}) → Bioyond(x={bioyond_x},y={bioyond_y})") + + # 🔥 提取物料名称:从 tracker.liquids 中获取第一个液体的名称(去除PLR系统添加的后缀) + # tracker.liquids 格式: [(物料名称, 数量), ...] + material_name = bottle_type_info[0] # 默认使用类型名称(如"样品瓶") + if hasattr(bottle, "tracker") and bottle.tracker.liquids: + # 如果有液体,使用液体的名称 + first_liquid_name = bottle.tracker.liquids[0][0] + # 去除PLR系统为了唯一性添加的后缀(如 "_0", "_1" 等) + if "_" in first_liquid_name and first_liquid_name.split("_")[-1].isdigit(): + material_name = "_".join(first_liquid_name.split("_")[:-1]) + else: + material_name = first_liquid_name + logger.debug(f" 💧 [物料名称] {bottle.name} 液体: {first_liquid_name} → 转换为: {material_name}") + else: + logger.debug(f" 📭 [物料名称] {bottle.name} 无液体,使用类型名: {material_name}") + + detail_item = { + "typeId": bottle_type_info[1], + "code": bottle.code if hasattr(bottle, "code") else "", + "name": material_name, # 使用物料名称(如"9090"),而不是类型名称("样品瓶") + "quantity": sum(qty for _, qty in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0, + "x": bioyond_x, + "y": bioyond_y, + "z": 1, + "unit": "微升", + "Parameters": "{}" # API 实际要求的字段(必需) + } + material["details"].append(detail_item) + else: + # 单个瓶子(非载架)类型的资源 + bottle = resource[0] if hasattr(resource, "capacity") and resource.capacity > 0 else resource + + # 根据 resource.model 从 type_mapping 获取正确的 typeId + type_info = type_mapping.get(resource.model) + if type_info: + type_id = type_info[1] + else: + # 如果找不到映射,记录警告并使用默认值 + logger.warning(f"[PLR→Bioyond] 资源 {resource.name} 的 model '{resource.model}' 不在 type_mapping 中,使用默认烧杯类型") + type_id = "3a14196b-24f2-ca49-9081-0cab8021bf1a" # 默认使用烧杯类型 + + # 🔥 提取物料名称:优先使用液体名称,否则使用资源名称 + material_name = resource.name if hasattr(resource, "name") else "" + if hasattr(bottle, "tracker") and bottle.tracker.liquids: + # 如果有液体,使用液体的名称 + first_liquid_name = bottle.tracker.liquids[0][0] + # 去除PLR系统为了唯一性添加的后缀(如 "_0", "_1" 等) + if "_" in first_liquid_name and first_liquid_name.split("_")[-1].isdigit(): + material_name = "_".join(first_liquid_name.split("_")[:-1]) + else: + material_name = first_liquid_name + logger.debug(f" 💧 [单瓶物料] {resource.name} 液体: {first_liquid_name} → 转换为: {material_name}") + else: + logger.debug(f" 📭 [单瓶物料] {resource.name} 无液体,使用资源名: {material_name}") + + # 🎯 处理物料默认参数和单位 + # 检查是否有该物料名称的默认参数配置 + default_unit = "个" # 默认单位 + material_parameters = {} + + if material_name in material_params: + params_config = material_params[material_name].copy() + + # 提取 unit 字段(如果有) + if "unit" in params_config: + default_unit = params_config.pop("unit") # 从参数中移除,放到外层 + + # 剩余的字段放入 Parameters + material_parameters = params_config + logger.debug(f" 🔧 [物料参数] 为 {material_name} 应用配置: unit={default_unit}, parameters={material_parameters}") + + # 转换为 JSON 字符串 + parameters_json = json.dumps(material_parameters) if material_parameters else "{}" + + material = { + "typeId": type_id, + "code": "", + "barCode": "", + "name": material_name, # 使用物料名称而不是资源名称 + "unit": default_unit, # 使用配置的单位或默认单位 + "quantity": sum(qty for _, qty in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0, + "Parameters": parameters_json # API 实际要求的字段(必需) + } + + # ⭐ 处理 locations 信息 + # 优先级: update_resource_site (位置更新请求) > 当前 parent 位置 + extra_info = getattr(resource, "unilabos_extra", {}) + update_site = extra_info.get("update_resource_site") + + if update_site: + # 情况1: 有明确的位置更新请求 (如从 A02 移动到 A03) + # 需要从 warehouse_mapping 中查找目标库位的 UUID + logger.debug(f"🔄 [PLR→Bioyond] 检测到位置更新请求: {resource.name} → {update_site}") + + # 遍历所有仓库查找目标库位 + target_warehouse_name = None + target_location_uuid = None + + for warehouse_name, warehouse_info in warehouse_mapping.items(): + site_uuids = warehouse_info.get("site_uuids", {}) + if update_site in site_uuids: + target_warehouse_name = warehouse_name + target_location_uuid = site_uuids[update_site] + break + + if target_warehouse_name and target_location_uuid: + # 从库位代码解析坐标 (如 "A03" -> x=1, y=3) + # A=1, B=2, C=3, D=4... + # 01=1, 02=2, 03=3... + try: + row_letter = update_site[0] # 'A', 'B', 'C', 'D' + col_number = int(update_site[1:]) # '01', '02', '03'... + bioyond_x = ord(row_letter) - ord('A') + 1 # A→1, B→2, C→3, D→4 + bioyond_y = col_number # 01→1, 02→2, 03→3 + + material["locations"] = [ + { + "id": target_location_uuid, + "whid": warehouse_mapping[target_warehouse_name].get("uuid", ""), + "whName": target_warehouse_name, + "x": bioyond_x, + "y": bioyond_y, + "z": 1, + "quantity": 0 + } + ] + logger.debug(f"✅ [PLR→Bioyond] 位置更新: {resource.name} → {target_warehouse_name}/{update_site} (x={bioyond_x}, y={bioyond_y})") + except Exception as e: + logger.error(f"❌ [PLR→Bioyond] 解析库位代码失败: {update_site}, 错误: {e}") + else: + logger.warning(f"⚠️ [PLR→Bioyond] 未找到库位 {update_site} 的配置") + + elif resource.parent is not None and isinstance(resource.parent, ItemizedCarrier): + # 情况2: 使用当前 parent 位置 site_in_parent = resource.parent.get_child_identifier(resource) + + # ⚠️ 坐标系转换说明: + # get_child_identifier 返回: x_idx=列索引, y_idx=行索引 (0-based) + # Bioyond 系统要求: x=行号, y=列号 (1-based) + # 因此需要交换 x 和 y! + bioyond_x = site_in_parent["y"] + 1 # 行索引 → Bioyond的x (行号) + bioyond_y = site_in_parent["x"] + 1 # 列索引 → Bioyond的y (列号) + material["locations"] = [ { "id": warehouse_mapping[resource.parent.name]["site_uuids"][site_in_parent["identifier"]], "whid": warehouse_mapping[resource.parent.name]["uuid"], "whName": resource.parent.name, - "x": site_in_parent["z"] + 1, - "y": site_in_parent["y"] + 1, + "x": bioyond_x, + "y": bioyond_y, "z": 1, "quantity": 0 } - ], + ] + logger.debug(f"🔄 [PLR→Bioyond] 坐标转换: {resource.name} 在 {resource.parent.name}[{site_in_parent['identifier']}] → UniLab(列={site_in_parent['x']},行={site_in_parent['y']}) → Bioyond(x={bioyond_x},y={bioyond_y})") - print(f"material_data: {material}") bioyond_materials.append(material) return bioyond_materials @@ -768,6 +1127,8 @@ def initialize_resource(resource_config: dict, resource_type: Any = None) -> Uni 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: + logger.warning(f"❌ 类 {resource_class_config} 不在 registry 中,返回原始配置") + logger.debug(f" 可用的类: {list(lab_registry.resource_type_registry.keys())[:10]}...") return [resource_config] # If the resource class is a string, look up the class in the # resource_type_registry and import it @@ -782,11 +1143,12 @@ def initialize_resource(resource_config: dict, resource_type: Any = None) -> Uni if resource_class_config["type"] == "pylabrobot": resource_plr = RESOURCE(name=resource_config["name"]) if resource_type != ResourcePLR: - r = resource_plr_to_ulab(resource_plr=resource_plr, parent_name=resource_config.get("parent", None)) - # 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]) + tree_sets = ResourceTreeSet.from_plr_resources([resource_plr]) + # r = resource_plr_to_ulab(resource_plr=resource_plr, parent_name=resource_config.get("parent", None)) + # # r = resource_plr_to_ulab(resource_plr=resource_plr) + # if resource_config.get("position") is not None: + # r["position"] = resource_config["position"] + r = tree_sets.dump() else: r = resource_plr elif resource_class_config["type"] == "unilabos": diff --git a/unilabos/resources/itemized_carrier.py b/unilabos/resources/itemized_carrier.py index 831a0734..74a3659b 100644 --- a/unilabos/resources/itemized_carrier.py +++ b/unilabos/resources/itemized_carrier.py @@ -146,7 +146,7 @@ class ItemizedCarrier(ResourcePLR): if site_location == location: idx = i break - + if not reassign and self.sites[idx] is not None: raise ValueError(f"a site with index {idx} already exists") super().assign_child_resource(resource, location=location, reassign=reassign) @@ -172,18 +172,18 @@ class ItemizedCarrier(ResourcePLR): def get_child_identifier(self, child: ResourcePLR): """Get the identifier information for a given child resource. - + Args: child: The Resource object to find the identifier for - + Returns: dict: A dictionary containing: - identifier: The string identifier (e.g. "A1", "B2") - idx: The integer index in the sites list - x: The x index (column index, 0-based) - - y: The y index (row index, 0-based) + - y: The y index (row index, 0-based) - z: The z index (layer index, 0-based) - + Raises: ValueError: If the child resource is not found in this carrier """ @@ -192,10 +192,10 @@ class ItemizedCarrier(ResourcePLR): if resource is child: # Get the identifier from ordering keys identifier = list(self._ordering.keys())[idx] - + # Parse identifier to get x, y, z indices x_idx, y_idx, z_idx = self._parse_identifier_to_indices(identifier, idx) - + return { "identifier": identifier, "idx": idx, @@ -203,17 +203,17 @@ class ItemizedCarrier(ResourcePLR): "y": y_idx, "z": z_idx } - + # If not found, raise an error raise ValueError(f"Resource {child} is not assigned to this carrier") def _parse_identifier_to_indices(self, identifier: str, idx: int) -> Tuple[int, int, int]: """Parse identifier string to get x, y, z indices. - + Args: identifier: String identifier like "A1", "B2", etc. idx: Linear index as fallback for calculation - + Returns: Tuple of (x_idx, y_idx, z_idx) """ @@ -225,31 +225,31 @@ class ItemizedCarrier(ResourcePLR): y_idx = remaining // self.num_items_x x_idx = remaining % self.num_items_x return x_idx, y_idx, z_idx - + # Fallback: parse from Excel-style identifier if isinstance(identifier, str) and len(identifier) >= 2: # Extract row (letter) and column (number) row_letters = "" col_numbers = "" - + for char in identifier: if char.isalpha(): row_letters += char elif char.isdigit(): col_numbers += char - + if row_letters and col_numbers: # Convert letter(s) to row index (A=0, B=1, etc.) y_idx = 0 for char in row_letters: y_idx = y_idx * 26 + (ord(char.upper()) - ord('A')) - + # Convert number to column index (1-based to 0-based) x_idx = int(col_numbers) - 1 z_idx = 0 # Default layer - + return x_idx, y_idx, z_idx - + # If all else fails, assume linear arrangement return idx, 0, 0 @@ -413,8 +413,8 @@ class ItemizedCarrier(ResourcePLR): "sites": [{ "label": str(identifier), "visible": False if identifier in self.invisible_slots else True, - "occupied_by": self[identifier].name - if isinstance(self[identifier], ResourcePLR) and not isinstance(self[identifier], ResourceHolder) else + "occupied_by": self[identifier].name + if isinstance(self[identifier], ResourcePLR) and not isinstance(self[identifier], ResourceHolder) else self[identifier] if isinstance(self[identifier], str) else None, "position": {"x": location.x, "y": location.y, "z": location.z}, "size": self.child_size[identifier], diff --git a/unilabos/resources/plr_additional_res_reg.py b/unilabos/resources/plr_additional_res_reg.py index 11532e72..a1e4831a 100644 --- a/unilabos/resources/plr_additional_res_reg.py +++ b/unilabos/resources/plr_additional_res_reg.py @@ -7,5 +7,10 @@ def register(): from unilabos.devices.liquid_handling.prcxi.prcxi import PRCXI9300Container # noinspection PyUnresolvedReferences from unilabos.devices.workstation.workstation_base import WorkStationContainer - + + from unilabos.devices.liquid_handling.laiyu.laiyu import TransformXYZDeck + from unilabos.devices.liquid_handling.laiyu.laiyu import TransformXYZContainer + from unilabos.devices.liquid_handling.rviz_backend import UniLiquidHandlerRvizBackend + from unilabos.devices.liquid_handling.laiyu.backend.laiyu_v_backend import UniLiquidHandlerLaiyuBackend + diff --git a/unilabos/resources/warehouse.py b/unilabos/resources/warehouse.py index 2f613cfa..4dcda6d9 100644 --- a/unilabos/resources/warehouse.py +++ b/unilabos/resources/warehouse.py @@ -8,7 +8,7 @@ from unilabos.resources.itemized_carrier import ItemizedCarrier, ResourcePLR LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" -def YB_warehouse_factory( +def warehouse_factory( name: str, num_items_x: int = 1, num_items_y: int = 4, @@ -19,19 +19,33 @@ def YB_warehouse_factory( item_dx: float = 10.0, item_dy: float = 10.0, item_dz: float = 10.0, + resource_size_x: float = 127.0, + resource_size_y: float = 86.0, + resource_size_z: float = 25.0, removed_positions: Optional[List[int]] = None, empty: bool = False, category: str = "warehouse", model: Optional[str] = None, + col_offset: int = 0, # 列起始偏移量,用于生成A05-D08等命名 + layout: str = "col-major", # 新增:排序方式,"col-major"=列优先,"row-major"=行优先 ): - # 创建16个板架位 (4层 x 4位置) + # 创建位置坐标 locations = [] - for layer in range(num_items_z): # 4层 - for row in range(num_items_y): # 4行 - for col in range(num_items_x): # 1列 (每层4x1=4个位置) + + for layer in range(num_items_z): # 层 + for row in range(num_items_y): # 行 + for col in range(num_items_x): # 列 # 计算位置 x = dx + col * item_dx - y = dy + (num_items_y - row - 1) * item_dy + + # 根据 layout 决定 y 坐标计算 + if layout == "row-major": + # 行优先:row=0(A行) 应该显示在上方,需要较小的 y 值 + y = dy + row * item_dy + else: + # 列优先:保持原逻辑(row=0 对应较大的 y) + y = dy + (num_items_y - row - 1) * item_dy + z = dz + (num_items_z - layer - 1) * item_dz locations.append(Coordinate(x, y, z)) if removed_positions: @@ -39,15 +53,25 @@ def YB_warehouse_factory( _sites = create_homogeneous_resources( klass=ResourceHolder, locations=locations, - resource_size_x=127.0, - resource_size_y=86.0, + resource_size_x=resource_size_x, + resource_size_y=resource_size_y, + resource_size_z=resource_size_z, name_prefix=name, ) len_x, len_y = (num_items_x, num_items_y) if num_items_z == 1 else (num_items_y, num_items_z) if num_items_x == 1 else (num_items_x, num_items_z) - keys = [f"{LETTERS[len_y-1-j]}{str(i+1).zfill(2)}" for j in range(len_y) for i in range(len_x)] + # 根据 layout 参数生成不同的排序方式 + # 注意:物理位置的 y 坐标是倒序的 (row=0 时 y 最大,对应前端显示的顶部) + if layout == "row-major": + # 行优先顺序: A01,A02,A03,A04, B01,B02,B03,B04 + # locations[0] 对应 row=0, y最大(前端顶部)→ 应该是 A01 + keys = [f"{LETTERS[j]}{i + 1 + col_offset:02d}" for j in range(len_y) for i in range(len_x)] + else: + # 列优先顺序: A01,B01,C01,D01, A02,B02,C02,D02 + keys = [f"{LETTERS[j]}{i + 1 + col_offset:02d}" for i in range(len_x) for j in range(len_y)] + sites = {i: site for i, site in zip(keys, _sites.values())} - + return WareHouse( name=name, size_x=dx + item_dx * num_items_x, @@ -56,6 +80,7 @@ def YB_warehouse_factory( num_items_x = num_items_x, num_items_y = num_items_y, num_items_z = num_items_z, + ordering_layout=layout, # 传递排序方式到 ordering_layout # ordered_items=ordered_items, # ordering=ordering, sites=sites, @@ -79,8 +104,9 @@ class WareHouse(ItemizedCarrier): sites: Optional[Dict[Union[int, str], Optional[ResourcePLR]]] = None, category: str = "warehouse", model: Optional[str] = None, + ordering_layout: str = "col-major", + **kwargs ): - super().__init__( name=name, size_x=size_x, @@ -97,6 +123,16 @@ class WareHouse(ItemizedCarrier): model=model, ) + # 保存排序方式,供graphio.py的坐标映射使用 + # 使用独立属性避免与父类的layout冲突 + self.ordering_layout = ordering_layout + + def serialize(self) -> dict: + """序列化时保存 ordering_layout 属性""" + data = super().serialize() + data['ordering_layout'] = self.ordering_layout + return data + def get_site_by_layer_position(self, row: int, col: int, layer: int) -> ResourceHolder: if not (0 <= layer < 4 and 0 <= row < 4 and 0 <= col < 1): raise ValueError("无效的位置: layer={}, row={}, col={}".format(layer, row, col)) @@ -110,4 +146,4 @@ class WareHouse(ItemizedCarrier): def get_rack_at_position(self, row: int, col: int, layer: int): site = self.get_site_by_layer_position(row, col, layer) - return site.resource \ No newline at end of file + return site.resource diff --git a/unilabos/ros/device_node_wrapper.py b/unilabos/ros/device_node_wrapper.py index 51ff2171..f5e80c58 100644 --- a/unilabos/ros/device_node_wrapper.py +++ b/unilabos/ros/device_node_wrapper.py @@ -5,6 +5,7 @@ from unilabos.ros.msgs.message_converter import ( get_action_type, ) from unilabos.ros.nodes.base_device_node import init_wrapper, ROS2DeviceNode +from unilabos.ros.nodes.resource_tracker import ResourceDictInstance # 定义泛型类型变量 T = TypeVar("T") @@ -18,12 +19,11 @@ class ROS2DeviceNodeWrapper(ROS2DeviceNode): def ros2_device_node( cls: Type[T], - device_config: Optional[Dict[str, Any]] = None, + device_config: Optional[ResourceDictInstance] = None, 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. @@ -45,7 +45,7 @@ def ros2_device_node( if status_types is None: status_types = {} if device_config is None: - device_config = {} + raise ValueError("device_config cannot be None") if action_value_mappings is None: action_value_mappings = {} if hardware_interface is None: @@ -82,7 +82,6 @@ def ros2_device_node( action_value_mappings=action_value_mappings, hardware_interface=hardware_interface, print_publish=print_publish, - children=children, *args, **kwargs, ), diff --git a/unilabos/ros/initialize_device.py b/unilabos/ros/initialize_device.py index a92a9f50..55ac1455 100644 --- a/unilabos/ros/initialize_device.py +++ b/unilabos/ros/initialize_device.py @@ -4,13 +4,14 @@ from typing import Optional from unilabos.registry.registry import lab_registry from unilabos.ros.device_node_wrapper import ros2_device_node from unilabos.ros.nodes.base_device_node import ROS2DeviceNode, DeviceInitError +from unilabos.ros.nodes.resource_tracker import ResourceDictInstance from unilabos.utils import logger from unilabos.utils.exception import DeviceClassInvalid from unilabos.utils.import_manager import default_manager -def initialize_device_from_dict(device_id, device_config) -> Optional[ROS2DeviceNode]: +def initialize_device_from_dict(device_id, device_config: ResourceDictInstance) -> 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. @@ -24,15 +25,14 @@ def initialize_device_from_dict(device_id, device_config) -> Optional[ROS2Device None """ d = None - original_device_config = copy.deepcopy(device_config) - device_class_config = device_config["class"] - uid = device_config["uuid"] + device_class_config = device_config.res_content.klass + uid = device_config.res_content.uuid if isinstance(device_class_config, str): # 如果是字符串,则直接去lab_registry中查找,获取class if len(device_class_config) == 0: raise DeviceClassInvalid(f"Device [{device_id}] class cannot be an empty string. {device_config}") if device_class_config not in lab_registry.device_type_registry: raise DeviceClassInvalid(f"Device [{device_id}] class {device_class_config} not found. {device_config}") - device_class_config = device_config["class"] = lab_registry.device_type_registry[device_class_config]["class"] + device_class_config = lab_registry.device_type_registry[device_class_config]["class"] elif isinstance(device_class_config, dict): raise DeviceClassInvalid(f"Device [{device_id}] class config should be type 'str' but 'dict' got. {device_config}") if isinstance(device_class_config, dict): @@ -41,17 +41,16 @@ def initialize_device_from_dict(device_id, device_config) -> Optional[ROS2Device DEVICE = ros2_device_node( DEVICE, status_types=device_class_config.get("status_types", {}), - device_config=original_device_config, + device_config=device_config, 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, device_uuid=uid, driver_is_ros=device_class_config["type"] == "ros2", driver_params=device_config.get("config", {}) + device_id=device_id, device_uuid=uid, driver_is_ros=device_class_config["type"] == "ros2", driver_params=device_config.res_content.config ) except DeviceInitError as ex: return d diff --git a/unilabos/ros/main_slave_run.py b/unilabos/ros/main_slave_run.py index 1ded6da1..4373cea3 100644 --- a/unilabos/ros/main_slave_run.py +++ b/unilabos/ros/main_slave_run.py @@ -1,7 +1,9 @@ import json +# from nt import device_encoding import threading import time from typing import Optional, Dict, Any, List +import uuid import rclpy from unilabos_msgs.srv._serial_command import SerialCommand_Response @@ -81,14 +83,15 @@ def main( resources_list, resource_tracker=host_node.resource_tracker, device_id="resource_mesh_manager", + device_uuid=str(uuid.uuid4()), ) joint_republisher = JointRepublisher("joint_republisher", host_node.resource_tracker) - lh_joint_pub = LiquidHandlerJointPublisher( - resources_config=resources_list, resource_tracker=host_node.resource_tracker - ) + # lh_joint_pub = LiquidHandlerJointPublisher( + # resources_config=resources_list, resource_tracker=host_node.resource_tracker + # ) executor.add_node(resource_mesh_manager) executor.add_node(joint_republisher) - executor.add_node(lh_joint_pub) + # executor.add_node(lh_joint_pub) thread = threading.Thread(target=executor.spin, daemon=True, name="host_executor_thread") thread.start() @@ -189,7 +192,7 @@ def slave( for device_config in devices_config.root_nodes: device_id = device_config.res_content.id if device_config.res_content.type == "device": - d = initialize_device_from_dict(device_id, device_config.get_nested_dict()) + d = initialize_device_from_dict(device_id, device_config) if d is not None: devices_instances[device_id] = d logger.info(f"Device {device_id} initialized.") diff --git a/unilabos/ros/nodes/base_device_node.py b/unilabos/ros/nodes/base_device_node.py index 7025e775..6952320f 100644 --- a/unilabos/ros/nodes/base_device_node.py +++ b/unilabos/ros/nodes/base_device_node.py @@ -5,7 +5,6 @@ import json import threading import time import traceback -import uuid from typing import get_type_hints, TypeVar, Generic, Dict, Any, Type, TypedDict, Optional, List, TYPE_CHECKING, Union from concurrent.futures import ThreadPoolExecutor @@ -39,7 +38,6 @@ from unilabos.ros.msgs.message_converter import ( ) from unilabos_msgs.srv import ( ResourceAdd, - ResourceGet, ResourceDelete, ResourceUpdate, ResourceList, @@ -49,7 +47,8 @@ from unilabos_msgs.msg import Resource # type: ignore from unilabos.ros.nodes.resource_tracker import ( DeviceNodeResourceTracker, - ResourceTreeSet, ResourceTreeInstance, + ResourceTreeSet, + ResourceTreeInstance, ResourceDictInstance, ) from unilabos.ros.x.rclpyx import get_event_loop from unilabos.ros.utils.driver_creator import WorkstationNodeCreator, PyLabRobotCreator, DeviceClassCreator @@ -134,12 +133,11 @@ def init_wrapper( device_id: str, device_uuid: str, driver_class: type[T], - device_config: Dict[str, Any], + device_config: ResourceTreeInstance, 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, @@ -148,8 +146,6 @@ def init_wrapper( """初始化设备节点的包装函数,和ROS2DeviceNode初始化保持一致""" if driver_params is None: driver_params = kwargs.copy() - if children is None: - children = [] kwargs["device_id"] = device_id kwargs["device_uuid"] = device_uuid kwargs["driver_class"] = driver_class @@ -158,7 +154,6 @@ def init_wrapper( 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) @@ -340,10 +335,16 @@ class BaseROS2DeviceNode(Node, Generic[T]): self._resource_clients: Dict[str, Client] = { "resource_add": self.create_client(ResourceAdd, "/resources/add", callback_group=self.callback_group), "resource_get": self.create_client(SerialCommand, "/resources/get", callback_group=self.callback_group), - "resource_delete": self.create_client(ResourceDelete, "/resources/delete", callback_group=self.callback_group), - "resource_update": self.create_client(ResourceUpdate, "/resources/update", callback_group=self.callback_group), + "resource_delete": self.create_client( + ResourceDelete, "/resources/delete", callback_group=self.callback_group + ), + "resource_update": self.create_client( + ResourceUpdate, "/resources/update", callback_group=self.callback_group + ), "resource_list": self.create_client(ResourceList, "/resources/list", callback_group=self.callback_group), - "c2s_update_resource_tree": self.create_client(SerialCommand, "/c2s_update_resource_tree", callback_group=self.callback_group), + "c2s_update_resource_tree": self.create_client( + SerialCommand, "/c2s_update_resource_tree", callback_group=self.callback_group + ), } def re_register_device(req, res): @@ -466,8 +467,9 @@ class BaseROS2DeviceNode(Node, Generic[T]): contain_model = not isinstance(resource, Deck) if isinstance(resource, ResourcePLR): # resources.list() - resources_tree = dict_to_tree(copy.deepcopy({r["id"]: r for r in resources})) - plr_instance = resource_ulab_to_plr(resources_tree[0], contain_model) + plr_instance = ResourceTreeSet.from_raw_list(resources).to_plr_resources()[0] + # resources_tree = dict_to_tree(copy.deepcopy({r["id"]: r for r in resources})) + # plr_instance = resource_ulab_to_plr(resources_tree[0], contain_model) if isinstance(plr_instance, Plate): empty_liquid_info_in = [(None, 0)] * plr_instance.num_items @@ -580,9 +582,66 @@ class BaseROS2DeviceNode(Node, Generic[T]): except Exception as e: self.lab_logger().error(f"更新资源uuid失败: {e}") self.lab_logger().error(traceback.format_exc()) - self.lab_logger().debug(f"资源更新结果: {response}") + self.lab_logger().trace(f"资源更新结果: {response}") - def transfer_to_new_resource(self, plr_resource: "ResourcePLR", tree: ResourceTreeInstance, additional_add_params: Dict[str, Any]): + async def get_resource(self, resources_uuid: List[str], with_children: bool = True) -> ResourceTreeSet: + """ + 根据资源UUID列表获取资源树 + + Args: + resources_uuid: 资源UUID列表 + with_children: 是否包含子节点,默认为True + + Returns: + ResourceTreeSet: 资源树集合 + """ + response: SerialCommand.Response = await self._resource_clients["c2s_update_resource_tree"].call_async( + SerialCommand.Request( + command=json.dumps( + { + "data": {"data": resources_uuid, "with_children": with_children}, + "action": "get", + } + ) + ) + ) # type: ignore + raw_nodes = json.loads(response.response) + tree_set = ResourceTreeSet.from_raw_list(raw_nodes) + self.lab_logger().debug(f"获取资源结果: {len(tree_set.trees)} 个资源树") + return tree_set + + async def get_resource_with_dir(self, resource_id: str, with_children: bool = True) -> "ResourcePLR": + """ + 根据资源ID获取单个资源实例 + + Args: + resource_ids: 资源ID字符串 + with_children: 是否包含子节点,默认为True + + Returns: + ResourcePLR: PLR资源实例 + """ + r = SerialCommand.Request() + r.command = json.dumps( + { + "id": resource_id, + "uuid": None, + "with_children": with_children, + } + ) + # 发送请求并等待响应 + response: SerialCommand_Response = await self._resource_clients["resource_get"].call_async(r) + raw_data = json.loads(response.response) + + # 转换为 PLR 资源 + tree_set = ResourceTreeSet.from_raw_list(raw_data) + plr_resource = tree_set.to_plr_resources()[0] + self.lab_logger().debug(f"获取资源 {resource_id} 成功") + return plr_resource + + def transfer_to_new_resource( + self, plr_resource: "ResourcePLR", tree: ResourceTreeInstance, additional_add_params: Dict[str, Any] + ): parent_uuid = tree.root_node.res_content.parent_uuid if parent_uuid: parent_resource: ResourcePLR = self.resource_tracker.uuid_to_resources.get(parent_uuid) @@ -609,16 +668,26 @@ class BaseROS2DeviceNode(Node, Generic[T]): old_parent = plr_resource.parent if old_parent is not None: # plr并不支持同一个deck的加载和卸载 - self.lab_logger().warning( - f"物料{plr_resource}请求从{old_parent}卸载" - ) + self.lab_logger().warning(f"物料{plr_resource}请求从{old_parent}卸载") old_parent.unassign_child_resource(plr_resource) self.lab_logger().warning( f"物料{plr_resource}请求挂载到{parent_resource},额外参数:{additional_params}" ) - parent_resource.assign_child_resource( - plr_resource, location=None, **additional_params - ) + + # ⭐ assign 之前,需要从 resources 列表中移除 + # 因为资源将不再是顶级资源,而是成为 parent_resource 的子资源 + # 如果不移除,figure_resource 会找到两次:一次在 resources,一次在 parent 的 children + resource_id = id(plr_resource) + for i, r in enumerate(self.resource_tracker.resources): + if id(r) == resource_id: + self.resource_tracker.resources.pop(i) + self.lab_logger().debug( + f"从顶级资源列表中移除 {plr_resource.name}(即将成为 {parent_resource.name} 的子资源)" + ) + break + + parent_resource.assign_child_resource(plr_resource, location=None, **additional_params) + func = getattr(self.driver_instance, "resource_tree_transfer", None) if callable(func): # 分别是 物料的原来父节点,当前物料的状态,物料的新父节点(此时物料已经重新assign了) @@ -790,17 +859,9 @@ class BaseROS2DeviceNode(Node, Generic[T]): ) tree_set = None if action in ["add", "update"]: - response: SerialCommand.Response = await self._resource_clients[ - "c2s_update_resource_tree" - ].call_async( - SerialCommand.Request( - command=json.dumps( - {"data": {"data": resources_uuid, "with_children": True if action == "add" else False}, "action": "get"} - ) - ) - ) # type: ignore - raw_nodes = json.loads(response.response) - tree_set = ResourceTreeSet.from_raw_list(raw_nodes) + tree_set = await self.get_resource( + resources_uuid=resources_uuid, with_children=True if action == "add" else False + ) try: if action == "add": if tree_set is None: @@ -1078,17 +1139,9 @@ class BaseROS2DeviceNode(Node, Generic[T]): # 批量查询资源 queried_resources = [] for resource_data in resource_inputs: - r = SerialCommand.Request() - r.command = json.dumps({"id": resource_data["id"], "uuid": resource_data.get("uuid", None), "with_children": True}) - # 发送请求并等待响应 - response: SerialCommand_Response = await self._resource_clients[ - "resource_get" - ].call_async(r) - raw_data = json.loads(response.response) - - # 转换为 PLR 资源 - tree_set = ResourceTreeSet.from_raw_list(raw_data) - plr_resource = tree_set.to_plr_resources()[0] + plr_resource = await self.get_resource_with_dir( + resource_id=resource_data["id"], with_children=True + ) queried_resources.append(plr_resource) self.lab_logger().debug(f"资源查询结果: 共 {len(queried_resources)} 个资源") @@ -1111,7 +1164,6 @@ class BaseROS2DeviceNode(Node, Generic[T]): execution_error = traceback.format_exc() break - ##### self.lab_logger().info(f"准备执行: {action_kwargs}, 函数: {ACTION.__name__}") time_start = time.time() time_overall = 100 future = None @@ -1119,35 +1171,36 @@ class BaseROS2DeviceNode(Node, Generic[T]): # 将阻塞操作放入线程池执行 if asyncio.iscoroutinefunction(ACTION): try: - ##### self.lab_logger().info(f"异步执行动作 {ACTION}") - future = ROS2DeviceNode.run_async_func(ACTION, trace_error=False, **action_kwargs) - - def _handle_future_exception(fut): + self.lab_logger().trace(f"异步执行动作 {ACTION}") + def _handle_future_exception(fut: Future): nonlocal execution_error, execution_success, action_return_value try: action_return_value = fut.result() + if isinstance(action_return_value, BaseException): + raise action_return_value execution_success = True - except Exception as e: + except Exception as _: execution_error = traceback.format_exc() error( f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}" ) + future = ROS2DeviceNode.run_async_func(ACTION, trace_error=False, **action_kwargs) future.add_done_callback(_handle_future_exception) except Exception as e: execution_error = traceback.format_exc() execution_success = False self.lab_logger().error(f"创建异步任务失败: {traceback.format_exc()}") else: - ##### self.lab_logger().info(f"同步执行动作 {ACTION}") + self.lab_logger().trace(f"同步执行动作 {ACTION}") future = self._executor.submit(ACTION, **action_kwargs) - def _handle_future_exception(fut): + def _handle_future_exception(fut: Future): nonlocal execution_error, execution_success, action_return_value try: action_return_value = fut.result() execution_success = True - except Exception as e: + except Exception as _: execution_error = traceback.format_exc() error( f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}" @@ -1252,7 +1305,7 @@ class BaseROS2DeviceNode(Node, Generic[T]): get_result_info_str(execution_error, execution_success, action_return_value), ) - ##### self.lab_logger().info(f"动作 {action_name} 完成并返回结果") + self.lab_logger().trace(f"动作 {action_name} 完成并返回结果") return result_msg return execute_callback @@ -1328,12 +1381,18 @@ class BaseROS2DeviceNode(Node, Generic[T]): """同步转换资源数据为实例""" # 创建资源查询请求 r = SerialCommand.Request() - r.command = json.dumps({"id": resource_data["id"], "with_children": True}) + r.command = json.dumps( + { + "id": resource_data.get("id", None), + "uuid": resource_data.get("uuid", None), + "with_children": True, + } + ) # 同步调用资源查询服务 future = self._resource_clients["resource_get"].call_async(r) - # 等待结果(使用while循环,每次sleep 0.5秒,最多等待5秒) + # 等待结果(使用while循环,每次sleep 0.05秒,最多等待30秒) timeout = 30.0 elapsed = 0.0 while not future.done() and elapsed < timeout: @@ -1341,16 +1400,16 @@ class BaseROS2DeviceNode(Node, Generic[T]): elapsed += 0.05 if not future.done(): - raise Exception(f"资源查询超时: {resource_data['id']}") + raise Exception(f"资源查询超时: {resource_data}") response = future.result() if response is None: - raise Exception(f"资源查询返回空结果: {resource_data['id']}") + raise Exception(f"资源查询返回空结果: {resource_data}") - current_resources = json.loads(response.response) + raw_data = json.loads(response.response) # 转换为 PLR 资源 - tree_set = ResourceTreeSet.from_raw_list(current_resources) + tree_set = ResourceTreeSet.from_raw_list(raw_data) plr_resource = tree_set.to_plr_resources()[0] # 通过资源跟踪器获取本地实例 @@ -1435,17 +1494,8 @@ class BaseROS2DeviceNode(Node, Generic[T]): async def _convert_resource_async(self, resource_data: Dict[str, Any]): """异步转换资源数据为实例""" - # 创建资源查询请求 - r = SerialCommand.Request() - r.command = json.dumps({"id": resource_data["id"], "with_children": True}) - - # 异步调用资源查询服务 - response: SerialCommand_Response = await self._resource_clients["resource_get"].call_async(r) - current_resources = json.loads(response.response) - - # 转换为 PLR 资源 - tree_set = ResourceTreeSet.from_raw_list(current_resources) - plr_resource = tree_set.to_plr_resources()[0] + # 使用封装的get_resource_with_dir方法获取PLR资源 + plr_resource = await self.get_resource_with_dir(resource_ids=resource_data["id"], with_children=True) # 通过资源跟踪器获取本地实例 res = self.resource_tracker.figure_resource(plr_resource, try_mode=True) @@ -1490,17 +1540,29 @@ class ROS2DeviceNode: 这个类封装了设备类实例和ROS2节点的功能,提供ROS2接口。 它不继承设备类,而是通过代理模式访问设备类的属性和方法。 """ + @staticmethod + async def safe_task_wrapper(trace_callback, func, **kwargs): + try: + if callable(trace_callback): + trace_callback(await func(**kwargs)) + return await func(**kwargs) + except Exception as e: + if callable(trace_callback): + trace_callback(e) + return e @classmethod - def run_async_func(cls, func, trace_error=True, **kwargs) -> Task: - def _handle_future_exception(fut): + def run_async_func(cls, func, trace_error=True, inner_trace_callback=None, **kwargs) -> Task: + def _handle_future_exception(fut: Future): try: - fut.result() + ret = fut.result() + if isinstance(ret, BaseException): + raise ret except Exception as e: - error(f"异步任务 {func.__name__} 报错了") + error(f"异步任务 {func.__name__} 获取结果失败") error(traceback.format_exc()) - future = rclpy.get_global_executor().create_task(func(**kwargs)) + future = rclpy.get_global_executor().create_task(ROS2DeviceNode.safe_task_wrapper(inner_trace_callback, func, **kwargs)) if trace_error: future.add_done_callback(_handle_future_exception) return future @@ -1508,7 +1570,9 @@ class ROS2DeviceNode: @classmethod async def async_wait_for(cls, node: Node, wait_time: float, callback_group=None): future = Future() - timer = node.create_timer(wait_time, lambda : future.set_result(None), callback_group=callback_group, clock=node.get_clock()) + timer = node.create_timer( + wait_time, lambda: future.set_result(None), callback_group=callback_group, clock=node.get_clock() + ) await future timer.cancel() node.destroy_timer(timer) @@ -1526,12 +1590,11 @@ class ROS2DeviceNode: device_id: str, device_uuid: str, driver_class: Type[T], - device_config: Dict[str, Any], + device_config: ResourceDictInstance, 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, ): @@ -1542,7 +1605,7 @@ class ROS2DeviceNode: device_id: 设备标识符 device_uuid: 设备uuid driver_class: 设备类 - device_config: 原始初始化的json + device_config: 原始初始化的ResourceDictInstance driver_params: driver初始化的参数 status_types: 状态类型映射 action_value_mappings: 动作值映射 @@ -1556,6 +1619,7 @@ class ROS2DeviceNode: self._has_async_context = hasattr(driver_class, "__aenter__") and hasattr(driver_class, "__aexit__") self._driver_class = driver_class self.device_config = device_config + children: List[ResourceDictInstance] = device_config.children self.driver_is_ros = driver_is_ros self.driver_is_workstation = False self.resource_tracker = DeviceNodeResourceTracker() @@ -1566,6 +1630,7 @@ class ROS2DeviceNode: or driver_class.__name__ == "LiquidHandlerAbstract" or driver_class.__name__ == "LiquidHandlerBiomek" or driver_class.__name__ == "PRCXI9300Handler" + or driver_class.__name__ == "TransformXYZHandler" ) # 创建设备类实例 diff --git a/unilabos/ros/nodes/presets/camera.py b/unilabos/ros/nodes/presets/camera.py index e161671f..25ae921a 100644 --- a/unilabos/ros/nodes/presets/camera.py +++ b/unilabos/ros/nodes/presets/camera.py @@ -6,12 +6,13 @@ from cv_bridge import CvBridge from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, DeviceNodeResourceTracker class VideoPublisher(BaseROS2DeviceNode): - def __init__(self, device_id='video_publisher', camera_index=0, period: float = 0.1, resource_tracker: DeviceNodeResourceTracker = None): + def __init__(self, device_id='video_publisher', device_uuid='', camera_index=0, period: float = 0.1, resource_tracker: DeviceNodeResourceTracker = None): # 初始化BaseROS2DeviceNode,使用自身作为driver_instance BaseROS2DeviceNode.__init__( self, driver_instance=self, device_id=device_id, + device_uuid=device_uuid, status_types={}, action_value_mappings={}, hardware_interface="camera", diff --git a/unilabos/ros/nodes/presets/host_node.py b/unilabos/ros/nodes/presets/host_node.py index 53b44c24..156e8f91 100644 --- a/unilabos/ros/nodes/presets/host_node.py +++ b/unilabos/ros/nodes/presets/host_node.py @@ -5,7 +5,7 @@ import threading import time import traceback import uuid -from typing import TYPE_CHECKING, Optional, Dict, Any, List, ClassVar, Set, Union +from typing import TYPE_CHECKING, Optional, Dict, Any, List, ClassVar, Set, TypedDict, Union from action_msgs.msg import GoalStatus from geometry_msgs.msg import Point @@ -38,6 +38,7 @@ from unilabos.ros.msgs.message_converter import ( from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode, DeviceNodeResourceTracker from unilabos.ros.nodes.presets.controller_node import ControllerNode from unilabos.ros.nodes.resource_tracker import ( + ResourceDict, ResourceDictInstance, ResourceTreeSet, ResourceTreeInstance, @@ -48,7 +49,7 @@ from unilabos.utils.type_check import serialize_result_info from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot if TYPE_CHECKING: - from unilabos.app.ws_client import QueueItem, WSResourceChatData + from unilabos.app.ws_client import QueueItem @dataclass @@ -56,6 +57,11 @@ class DeviceActionStatus: job_ids: Dict[str, float] = field(default_factory=dict) +class TestResourceReturn(TypedDict): + resources: List[List[ResourceDict]] + devices: List[DeviceSlot] + + class HostNode(BaseROS2DeviceNode): """ 主机节点类,负责管理设备、资源和控制器 @@ -283,6 +289,12 @@ class HostNode(BaseROS2DeviceNode): self.lab_logger().info("[Host Node] Host node initialized.") HostNode._ready_event.set() + # 发送host_node ready信号到所有桥接器 + for bridge in self.bridges: + if hasattr(bridge, "publish_host_ready"): + bridge.publish_host_ready() + self.lab_logger().debug(f"Host ready signal sent via {bridge.__class__.__name__}") + def _send_re_register(self, sclient): sclient.wait_for_service() request = SerialCommand.Request() @@ -526,7 +538,7 @@ class HostNode(BaseROS2DeviceNode): self.lab_logger().info(f"[Host Node] Initializing device: {device_id}") try: - d = initialize_device_from_dict(device_id, device_config.get_nested_dict()) + d = initialize_device_from_dict(device_id, device_config) except DeviceClassInvalid as e: self.lab_logger().error(f"[Host Node] Device class invalid: {e}") d = None @@ -706,7 +718,7 @@ class HostNode(BaseROS2DeviceNode): feedback_callback=lambda feedback_msg: self.feedback_callback(item, action_id, feedback_msg), goal_uuid=goal_uuid_obj, ) - future.add_done_callback(lambda future: self.goal_response_callback(item, action_id, future)) + future.add_done_callback(lambda f: self.goal_response_callback(item, action_id, f)) def goal_response_callback(self, item: "QueueItem", action_id: str, future) -> None: """目标响应回调""" @@ -717,9 +729,11 @@ class HostNode(BaseROS2DeviceNode): self.lab_logger().info(f"[Host Node] Goal {action_id} ({item.job_id}) accepted") self._goals[item.job_id] = goal_handle - goal_handle.get_result_async().add_done_callback( - lambda future: self.get_result_callback(item, action_id, future) + goal_future = goal_handle.get_result_async() + goal_future.add_done_callback( + lambda f: self.get_result_callback(item, action_id, f) ) + goal_future.result() def feedback_callback(self, item: "QueueItem", action_id: str, feedback_msg) -> None: """反馈回调""" @@ -734,46 +748,133 @@ class HostNode(BaseROS2DeviceNode): def get_result_callback(self, item: "QueueItem", action_id: str, future) -> None: """获取结果回调""" job_id = item.job_id - result_msg = future.result().result - result_data = convert_from_ros_msg(result_msg) - status = "success" - return_info_str = result_data.get("return_info") - if return_info_str is not None: - try: - return_info = json.loads(return_info_str) - suc = return_info.get("suc", False) - if not suc: - status = "failed" - except json.JSONDecodeError: + + try: + result = future.result() + result_msg = result.result + goal_status = result.status + + # 检查是否是被取消的任务 + if goal_status == GoalStatus.STATUS_CANCELED: + self.lab_logger().info(f"[Host Node] Goal {action_id} ({job_id[:8]}) was cancelled") status = "failed" - return_info = serialize_result_info("", False, result_data) - self.lab_logger().critical("错误的return_info类型,请断点修复") - else: - # 无 return_info 字段时,回退到 success 字段(若存在) - suc_field = result_data.get("success") - if isinstance(suc_field, bool): - status = "success" if suc_field else "failed" - return_info = serialize_result_info("", suc_field, result_data) + return_info = serialize_result_info("Job was cancelled", False, {}) else: - # 最保守的回退:标记失败并返回空JSON - status = "failed" - return_info = serialize_result_info("缺少return_info", False, result_data) + result_data = convert_from_ros_msg(result_msg) + status = "success" + return_info_str = result_data.get("return_info") + if return_info_str is not None: + try: + return_info = json.loads(return_info_str) + # 适配后端的一些额外处理 + return_value = return_info.get("return_value") + if isinstance(return_value, dict): + unilabos_samples = return_info.get("unilabos_samples") + if isinstance(unilabos_samples, list): + return_info["unilabos_samples"] = unilabos_samples + suc = return_info.get("suc", False) + if not suc: + status = "failed" + except json.JSONDecodeError: + status = "failed" + return_info = serialize_result_info("", False, result_data) + self.lab_logger().critical("错误的return_info类型,请断点修复") + else: + # 无 return_info 字段时,回退到 success 字段(若存在) + suc_field = result_data.get("success") + if isinstance(suc_field, bool): + status = "success" if suc_field else "failed" + return_info = serialize_result_info("", suc_field, result_data) + else: + # 最保守的回退:标记失败并返回空JSON + status = "failed" + return_info = serialize_result_info("缺少return_info", False, result_data) - self.lab_logger().info(f"[Host Node] Result for {action_id} ({job_id}): {status}") - self.lab_logger().debug(f"[Host Node] Result data: {result_data}") + self.lab_logger().info(f"[Host Node] Result for {action_id} ({job_id[:8]}): {status}") + if goal_status != GoalStatus.STATUS_CANCELED: + self.lab_logger().debug(f"[Host Node] Result data: {result_data}") - if job_id: + # 清理 _goals 中的记录 + if job_id in self._goals: + del self._goals[job_id] + self.lab_logger().debug(f"[Host Node] Removed goal {job_id[:8]} from _goals") + + # 存储结果供 HTTP API 查询 + try: + from unilabos.app.web.controller import store_job_result + + if goal_status == GoalStatus.STATUS_CANCELED: + store_job_result(job_id, status, return_info, {}) + else: + store_job_result(job_id, status, return_info, result_data) + except ImportError: + pass # controller 模块可能未加载 + + # 发布状态到桥接器 + if job_id: + for bridge in self.bridges: + if hasattr(bridge, "publish_job_status"): + if goal_status == GoalStatus.STATUS_CANCELED: + bridge.publish_job_status({}, item, status, return_info) + else: + bridge.publish_job_status(result_data, item, status, return_info) + + except Exception as e: + self.lab_logger().error( + f"[Host Node] Error in get_result_callback for {action_id} ({job_id[:8]}): {str(e)}" + ) + import traceback + + self.lab_logger().error(traceback.format_exc()) + + # 清理 _goals 中的记录 + if job_id in self._goals: + del self._goals[job_id] + + # 发布失败状态 for bridge in self.bridges: if hasattr(bridge, "publish_job_status"): - bridge.publish_job_status(result_data, item, status, return_info) + bridge.publish_job_status( + {}, item, "failed", serialize_result_info(f"Callback error: {str(e)}", False, {}) + ) - def cancel_goal(self, goal_uuid: str) -> None: - """取消目标""" + def cancel_goal(self, goal_uuid: str) -> bool: + """ + 取消目标 + + Args: + goal_uuid: 目标UUID(job_id) + + Returns: + bool: 如果找到目标并发起取消请求返回True,否则返回False + """ if goal_uuid in self._goals: - self.lab_logger().info(f"[Host Node] Cancelling goal {goal_uuid}") - self._goals[goal_uuid].cancel_goal_async() + self.lab_logger().info(f"[Host Node] Cancelling goal {goal_uuid[:8]}") + goal_handle = self._goals[goal_uuid] + + # 发起异步取消请求 + cancel_future = goal_handle.cancel_goal_async() + + # 添加取消完成的回调 + cancel_future.add_done_callback(lambda future: self._cancel_goal_callback(goal_uuid, future)) + return True else: - self.lab_logger().warning(f"[Host Node] Goal {goal_uuid} not found, cannot cancel") + self.lab_logger().warning(f"[Host Node] Goal {goal_uuid[:8]} not found in _goals, cannot cancel") + return False + + def _cancel_goal_callback(self, goal_uuid: str, future) -> None: + """取消目标的回调""" + try: + cancel_response = future.result() + if cancel_response.goals_canceling: + self.lab_logger().info(f"[Host Node] Goal {goal_uuid[:8]} cancel request accepted") + else: + self.lab_logger().warning(f"[Host Node] Goal {goal_uuid[:8]} cancel request rejected") + except Exception as e: + self.lab_logger().error(f"[Host Node] Error cancelling goal {goal_uuid[:8]}: {str(e)}") + import traceback + + self.lab_logger().error(traceback.format_exc()) def get_goal_status(self, job_id: str) -> int: """获取目标状态""" @@ -1056,11 +1157,12 @@ class HostNode(BaseROS2DeviceNode): 响应对象,包含查询到的资源 """ try: + from unilabos.app.web import http_client data = json.loads(request.command) if "uuid" in data and data["uuid"] is not None: - http_req = self.bridges[-1].resource_tree_get([data["uuid"]], data["with_children"]) + http_req = http_client.resource_tree_get([data["uuid"]], data["with_children"]) elif "id" in data and data["id"].startswith("/"): - http_req = self.bridges[-1].resource_get(data["id"], data["with_children"]) + http_req = http_client.resource_get(data["id"], data["with_children"]) else: raise ValueError("没有使用正确的物料 id 或 uuid") response.response = json.dumps(http_req["data"]) @@ -1270,7 +1372,7 @@ class HostNode(BaseROS2DeviceNode): def test_resource( self, resource: ResourceSlot, resources: List[ResourceSlot], device: DeviceSlot, devices: List[DeviceSlot] - ): + ) -> TestResourceReturn: return { "resources": ResourceTreeSet.from_plr_resources([resource, *resources]).dump(), "devices": [device, *devices], diff --git a/unilabos/ros/nodes/presets/joint_republisher.py b/unilabos/ros/nodes/presets/joint_republisher.py index b731acc7..65218303 100644 --- a/unilabos/ros/nodes/presets/joint_republisher.py +++ b/unilabos/ros/nodes/presets/joint_republisher.py @@ -1,3 +1,4 @@ +import uuid import rclpy,json from rclpy.node import Node from sensor_msgs.msg import JointState @@ -6,7 +7,7 @@ from rclpy.callback_groups import ReentrantCallbackGroup from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode class JointRepublisher(BaseROS2DeviceNode): - def __init__(self,device_id,resource_tracker): + def __init__(self,device_id,resource_tracker, **kwargs): super().__init__( driver_instance=self, device_id=device_id, @@ -15,6 +16,7 @@ class JointRepublisher(BaseROS2DeviceNode): hardware_interface={}, print_publish=False, resource_tracker=resource_tracker, + device_uuid=kwargs.get("uuid", str(uuid.uuid4())), ) # print('-'*20,device_id) diff --git a/unilabos/ros/nodes/presets/resource_mesh_manager.py b/unilabos/ros/nodes/presets/resource_mesh_manager.py index 8d70dfac..f46184b6 100644 --- a/unilabos/ros/nodes/presets/resource_mesh_manager.py +++ b/unilabos/ros/nodes/presets/resource_mesh_manager.py @@ -1,5 +1,6 @@ from pathlib import Path import time +import uuid import rclpy,json from rclpy.node import Node from std_msgs.msg import String,Header @@ -25,7 +26,7 @@ from unilabos.resources.graphio import initialize_resources from unilabos.registry.registry import lab_registry class ResourceMeshManager(BaseROS2DeviceNode): - def __init__(self, resource_model: dict, resource_config: list,resource_tracker, device_id: str = "resource_mesh_manager", rate=50): + def __init__(self, resource_model: dict, resource_config: list,resource_tracker, device_id: str = "resource_mesh_manager", rate=50, **kwargs): """初始化资源网格管理器节点 Args: @@ -41,10 +42,11 @@ class ResourceMeshManager(BaseROS2DeviceNode): hardware_interface={}, print_publish=False, resource_tracker=resource_tracker, + device_uuid=kwargs.get("uuid", str(uuid.uuid4())), ) self.resource_model = resource_model - self.resource_config_dict = {item['id']: item for item in resource_config} + self.resource_config_dict = {item['uuid']: item for item in resource_config} self.move_group_ready = False self.resource_tf_dict = {} self.tf_broadcaster = TransformBroadcaster(self) @@ -182,14 +184,16 @@ class ResourceMeshManager(BaseROS2DeviceNode): self.get_logger().info('开始设置资源网格管理器') #遍历resource_config中的资源配置,判断panent是否在resource_model中, resource_tf_dict = {} - for resource_id, resource_config in resource_config_dict.items(): + for resource_uuid, resource_config in resource_config_dict.items(): + parent = None + resource_id = resource_config['id'] + if resource_config['parent_uuid'] is not None and resource_config['parent_uuid'] != "": + parent = resource_config_dict[resource_config['parent_uuid']]['id'] - parent = resource_config['parent'] parent_link = 'world' if parent in self.resource_model: parent_link = parent elif parent is None and resource_id in self.resource_model: - pass elif parent is not None and resource_id in self.resource_model: # parent_link = f"{self.resource_config_dict[parent]['parent']}_{parent}_device_link".replace("None_","") @@ -199,9 +203,9 @@ class ResourceMeshManager(BaseROS2DeviceNode): continue # 提取位置信息并转换单位 position = { - "x": float(resource_config['position']['x'])/1000, - "y": float(resource_config['position']['y'])/1000, - "z": float(resource_config['position']['z'])/1000 + "x": float(resource_config['position']['position']['x'])/1000, + "y": float(resource_config['position']['position']['y'])/1000, + "z": float(resource_config['position']['position']['z'])/1000 } rotation_dict = { @@ -210,8 +214,8 @@ class ResourceMeshManager(BaseROS2DeviceNode): "z": 0 } - if 'rotation' in resource_config['config']: - rotation_dict = resource_config['config']['rotation'] + if 'rotation' in resource_config['position']: + rotation_dict = resource_config['position']['rotation'] # 从欧拉角转换为四元数 q = quaternion_from_euler( diff --git a/unilabos/ros/nodes/presets/workstation.py b/unilabos/ros/nodes/presets/workstation.py index dbbf8077..810e7a28 100644 --- a/unilabos/ros/nodes/presets/workstation.py +++ b/unilabos/ros/nodes/presets/workstation.py @@ -24,7 +24,7 @@ from unilabos.ros.msgs.message_converter import ( convert_from_ros_msg_with_mapping, ) from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, DeviceNodeResourceTracker, ROS2DeviceNode -from unilabos.ros.nodes.resource_tracker import ResourceTreeSet +from unilabos.ros.nodes.resource_tracker import ResourceTreeSet, ResourceDictInstance from unilabos.utils.type_check import get_result_info_str if TYPE_CHECKING: @@ -47,7 +47,7 @@ class ROS2WorkstationNode(BaseROS2DeviceNode): def __init__( self, protocol_type: List[str], - children: Dict[str, Any], + children: List[ResourceDictInstance], *, driver_instance: "WorkstationBase", device_id: str, @@ -81,10 +81,11 @@ class ROS2WorkstationNode(BaseROS2DeviceNode): # 初始化子设备 self.communication_node_id_to_instance = {} - for device_id, device_config in self.children.items(): - if device_config.get("type", "device") != "device": + for device_config in self.children: + device_id = device_config.res_content.id + if device_config.res_content.type != "device": self.lab_logger().debug( - f"[Protocol Node] Skipping type {device_config['type']} {device_id} already existed, skipping." + f"[Protocol Node] Skipping type {device_config.res_content.type} {device_id} already existed, skipping." ) continue try: @@ -101,8 +102,9 @@ class ROS2WorkstationNode(BaseROS2DeviceNode): self.communication_node_id_to_instance[device_id] = d continue - for device_id, device_config in self.children.items(): - if device_config.get("type", "device") != "device": + for device_config in self.children: + device_id = device_config.res_content.id + if device_config.res_content.type != "device": continue # 设置硬件接口代理 if device_id not in self.sub_devices: diff --git a/unilabos/ros/nodes/resource_tracker.py b/unilabos/ros/nodes/resource_tracker.py index ce23a5be..849d64a8 100644 --- a/unilabos/ros/nodes/resource_tracker.py +++ b/unilabos/ros/nodes/resource_tracker.py @@ -1,9 +1,11 @@ +import inspect import traceback import uuid from pydantic import BaseModel, field_serializer, field_validator from pydantic import Field from typing import List, Tuple, Any, Dict, Literal, Optional, cast, TYPE_CHECKING, Union +from unilabos.resources.plr_additional_res_reg import register from unilabos.utils.log import logger if TYPE_CHECKING: @@ -60,13 +62,12 @@ class ResourceDict(BaseModel): icon: str = Field(description="Resource icon", default="") parent_uuid: Optional["str"] = Field(description="Parent resource uuid", default=None) # 先设定parent_uuid parent: Optional["ResourceDict"] = Field(description="Parent resource object", default=None, exclude=True) - type: Literal["device"] | str = Field(description="Resource type") + type: Union[Literal["device"], str] = Field(description="Resource type") klass: str = Field(alias="class", description="Resource class name") - position: ResourceDictPosition = Field(description="Resource position", default_factory=ResourceDictPosition) pose: ResourceDictPosition = Field(description="Resource position", default_factory=ResourceDictPosition) config: Dict[str, Any] = Field(description="Resource configuration") - data: Dict[str, Any] = Field(description="Resource data") - extra: Dict[str, Any] = Field(description="Extra data") + data: Dict[str, Any] = Field(description="Resource data, eg: container liquid data") + extra: Dict[str, Any] = Field(description="Extra data, eg: slot index") @field_serializer("parent_uuid") def _serialize_parent(self, parent_uuid: Optional["ResourceDict"]): @@ -146,15 +147,16 @@ class ResourceDictInstance(object): if not content.get("extra"): # MagicCode content["extra"] = {} if "pose" not in content: - content["pose"] = content.get("position", {}) + content["pose"] = content.pop("position", {}) return ResourceDictInstance(ResourceDict.model_validate(content)) - def get_nested_dict(self) -> Dict[str, Any]: + def get_plr_nested_dict(self) -> Dict[str, Any]: """获取资源实例的嵌套字典表示""" res_dict = self.res_content.model_dump(by_alias=True) - res_dict["children"] = {child.res_content.id: child.get_nested_dict() for child in self.children} + res_dict["children"] = {child.res_content.id: child.get_plr_nested_dict() for child in self.children} res_dict["parent"] = self.res_content.parent_instance_name - res_dict["position"] = self.res_content.position.position.model_dump() + res_dict["position"] = self.res_content.pose.position.model_dump() + del res_dict["pose"] return res_dict @@ -429,9 +431,9 @@ class ResourceTreeSet(object): Returns: List[PLRResource]: PLR 资源实例列表 """ + register() from pylabrobot.resources import Resource as PLRResource from pylabrobot.utils.object_parsing import find_subclass - import inspect # 类型映射 TYPE_MAP = {"plate": "Plate", "well": "Well", "deck": "Deck", "container": "RegularContainer"} @@ -459,9 +461,9 @@ class ResourceTreeSet(object): "size_y": res.config.get("size_y", 0), "size_z": res.config.get("size_z", 0), "location": { - "x": res.position.position.x, - "y": res.position.position.y, - "z": res.position.position.z, + "x": res.pose.position.x, + "y": res.pose.position.y, + "z": res.pose.position.z, "type": "Coordinate", }, "rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"}, @@ -848,9 +850,13 @@ class DeviceNodeResourceTracker(object): extra: extra字典值 """ if isinstance(resource, dict): - resource["extra"] = extra + c_extra = resource.get("extra", {}) + c_extra.update(extra) + resource["extra"] = c_extra else: - setattr(resource, "unilabos_extra", extra) + c_extra = getattr(resource, "unilabos_extra", {}) + c_extra.update(extra) + setattr(resource, "unilabos_extra", c_extra) def _traverse_and_process(self, resource, process_func) -> int: """ diff --git a/unilabos/ros/utils/driver_creator.py b/unilabos/ros/utils/driver_creator.py index 9481ce31..7a60474a 100644 --- a/unilabos/ros/utils/driver_creator.py +++ b/unilabos/ros/utils/driver_creator.py @@ -9,10 +9,11 @@ import asyncio import inspect import traceback from abc import abstractmethod -from typing import Type, Any, Dict, Optional, TypeVar, Generic +from typing import Type, Any, Dict, Optional, TypeVar, Generic, List from unilabos.resources.graphio import nested_dict_to_list, resource_ulab_to_plr -from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker +from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker, ResourceTreeSet, ResourceDictInstance, \ + ResourceTreeInstance from unilabos.utils import logger, import_manager from unilabos.utils.cls_creator import create_instance_from_config @@ -33,7 +34,7 @@ class DeviceClassCreator(Generic[T]): 这个类提供了从任意类创建实例的通用方法。 """ - def __init__(self, cls: Type[T], children: Dict[str, Any], resource_tracker: DeviceNodeResourceTracker): + def __init__(self, cls: Type[T], children: List[ResourceDictInstance], resource_tracker: DeviceNodeResourceTracker): """ 初始化设备类创建器 @@ -50,9 +51,9 @@ class DeviceClassCreator(Generic[T]): 附加资源到设备类实例 """ if self.device_instance is not None: - for c in self.children.values(): - if c["type"] != "device": - self.resource_tracker.add_resource(c) + for c in self.children: + if c.res_content.type != "device": + self.resource_tracker.add_resource(c.get_plr_nested_dict()) def create_instance(self, data: Dict[str, Any]) -> T: """ @@ -94,7 +95,7 @@ class PyLabRobotCreator(DeviceClassCreator[T]): 这个类提供了针对PyLabRobot设备类的实例创建方法,特别处理deserialize方法。 """ - def __init__(self, cls: Type[T], children: Dict[str, Any], resource_tracker: DeviceNodeResourceTracker): + def __init__(self, cls: Type[T], children: List[ResourceDictInstance], resource_tracker: DeviceNodeResourceTracker): """ 初始化PyLabRobot设备类创建器 @@ -111,12 +112,12 @@ class PyLabRobotCreator(DeviceClassCreator[T]): def attach_resource(self): pass # 只能增加实例化物料,原来默认物料仅为字典查询 - 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_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, states=None, prefix_path="", name_to_uuid=None @@ -142,15 +143,21 @@ class PyLabRobotCreator(DeviceClassCreator[T]): 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] + resource: Optional[ResourceDictInstance] = None + for child in self.children: + if child.res_content.name == child_name: + resource = child + if resource is not None: 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) - resource_instance: Resource = resource_ulab_to_plr(resource, contain_model) # 带state + # 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) + res_tree = ResourceTreeInstance(resource) + res_tree_set = ResourceTreeSet([res_tree]) + resource_instance: Resource = res_tree_set.to_plr_resources()[0] + # resource_instance: Resource = resource_ulab_to_plr(resource, contain_model) # 带state states[prefix_path] = resource_instance.serialize_all_state() # 使用 prefix_path 作为 key 存储资源状态 if to_dict: @@ -202,12 +209,12 @@ class PyLabRobotCreator(DeviceClassCreator[T]): stack = None # 递归遍历 children 构建 name_to_uuid 映射 - def collect_name_to_uuid(children_dict: Dict[str, Any], result: Dict[str, str]): + def collect_name_to_uuid(children_list: List[ResourceDictInstance], result: Dict[str, str]): """递归遍历嵌套的 children 字典,收集 name 到 uuid 的映射""" - for child in children_dict.values(): - if isinstance(child, dict): - result[child["name"]] = child["uuid"] - collect_name_to_uuid(child["children"], result) + for child in children_list: + if isinstance(child, ResourceDictInstance): + result[child.res_content.name] = child.res_content.uuid + collect_name_to_uuid(child.children, result) name_to_uuid = {} collect_name_to_uuid(self.children, name_to_uuid) @@ -313,7 +320,7 @@ class WorkstationNodeCreator(DeviceClassCreator[T]): 这个类提供了针对WorkstationNode设备类的实例创建方法,处理children参数。 """ - def __init__(self, cls: Type[T], children: Dict[str, Any], resource_tracker: DeviceNodeResourceTracker): + def __init__(self, cls: Type[T], children: List[ResourceDictInstance], resource_tracker: DeviceNodeResourceTracker): """ 初始化WorkstationNode设备类创建器 @@ -336,9 +343,9 @@ class WorkstationNodeCreator(DeviceClassCreator[T]): try: # 创建实例,额外补充一个给protocol node的字段,后面考虑取消 data["children"] = self.children - for material_id, child in self.children.items(): - if child["type"] != "device": - self.resource_tracker.add_resource(self.children[material_id]) + for child in self.children: + if child.res_content.type != "device": + self.resource_tracker.add_resource(child.get_plr_nested_dict()) deck_dict = data.get("deck") if deck_dict: from pylabrobot.resources import Deck, Resource diff --git a/unilabos/test/__init__.py b/unilabos/test/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/commands/resource_add.md b/unilabos/test/commands/resource_add.md similarity index 100% rename from test/commands/resource_add.md rename to unilabos/test/commands/resource_add.md diff --git a/test/experiments/Grignard_flow_batchreact_single_pumpvalve.json b/unilabos/test/experiments/Grignard_flow_batchreact_single_pumpvalve.json similarity index 100% rename from test/experiments/Grignard_flow_batchreact_single_pumpvalve.json rename to unilabos/test/experiments/Grignard_flow_batchreact_single_pumpvalve.json diff --git a/test/experiments/HPLC.json b/unilabos/test/experiments/HPLC.json similarity index 100% rename from test/experiments/HPLC.json rename to unilabos/test/experiments/HPLC.json diff --git a/test/experiments/HT_hiwo.json b/unilabos/test/experiments/HT_hiwo.json similarity index 100% rename from test/experiments/HT_hiwo.json rename to unilabos/test/experiments/HT_hiwo.json diff --git a/unilabos/test/experiments/ICCAS506.json b/unilabos/test/experiments/ICCAS506.json new file mode 100644 index 00000000..b282acc1 --- /dev/null +++ b/unilabos/test/experiments/ICCAS506.json @@ -0,0 +1,171 @@ +{ + "nodes": [ + { + "id": "dispensing_station_bioyond", + "name": "dispensing_station_bioyond", + "children": [ + "Bioyond_Dispensing_Deck" + ], + "parent": null, + "type": "device", + "class": "bioyond_dispensing_station", + "config": { + "config": { + "api_key": "DE9BDDA0", + "api_host": "http://192.168.1.200:44400", + "material_type_mappings": { + "BIOYOND_PolymerStation_1FlaskCarrier": [ + "烧杯", + "3a14196b-24f2-ca49-9081-0cab8021bf1a" + ], + "BIOYOND_PolymerStation_1BottleCarrier": [ + "试剂瓶", + "3a14196b-8bcf-a460-4f74-23f21ca79e72" + ], + "BIOYOND_PolymerStation_6StockCarrier": [ + "分装板", + "3a14196e-5dfe-6e21-0c79-fe2036d052c4" + ], + "BIOYOND_PolymerStation_Liquid_Vial": [ + "10%分装小瓶", + "3a14196c-76be-2279-4e22-7310d69aed68" + ], + "BIOYOND_PolymerStation_Solid_Vial": [ + "90%分装小瓶", + "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea" + ], + "BIOYOND_PolymerStation_8StockCarrier": [ + "样品板", + "3a14196e-b7a0-a5da-1931-35f3000281e9" + ], + "BIOYOND_PolymerStation_Solid_Stock": [ + "样品瓶", + "3a14196a-cf7d-8aea-48d8-b9662c7dba94" + ] + } + }, + "deck": { + "data": { + "_resource_child_name": "Bioyond_Dispensing_Deck", + "_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerPreparationStation_Deck" + } + }, + "protocol_type": [] + }, + "data": {} + }, + { + "id": "Bioyond_Dispensing_Deck", + "name": "Bioyond_Dispensing_Deck", + "sample_id": null, + "children": [], + "parent": "dispensing_station_bioyond", + "type": "deck", + "class": "BIOYOND_PolymerPreparationStation_Deck", + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "config": { + "type": "BIOYOND_PolymerPreparationStation_Deck", + "setup": true, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + } + }, + "data": {} + }, + { + "id": "reaction_station_bioyond", + "name": "reaction_station_bioyond", + "parent": null, + "children": [ + "Bioyond_Deck" + ], + "type": "device", + "class": "reaction_station.bioyond", + "config": { + "config": { + "api_key": "DE9BDDA0", + "api_host": "http://192.168.1.200:44402", + "workflow_mappings": { + "reactor_taken_out": "3a16081e-4788-ca37-eff4-ceed8d7019d1", + "reactor_taken_in": "3a160df6-76b3-0957-9eb0-cb496d5721c6", + "Solid_feeding_vials": "3a160877-87e7-7699-7bc6-ec72b05eb5e6", + "Liquid_feeding_vials(non-titration)": "3a167d99-6158-c6f0-15b5-eb030f7d8e47", + "Liquid_feeding_solvents": "3a160824-0665-01ed-285a-51ef817a9046", + "Liquid_feeding(titration)": "3a16082a-96ac-0449-446a-4ed39f3365b6", + "liquid_feeding_beaker": "3a16087e-124f-8ddb-8ec1-c2dff09ca784", + "Drip_back": "3a162cf9-6aac-565a-ddd7-682ba1796a4a" + }, + "material_type_mappings": { + "BIOYOND_PolymerStation_Reactor": [ + "反应器", + "3a14233b-902d-0d7b-4533-3f60f1c41c1b" + ], + "BIOYOND_PolymerStation_1BottleCarrier": [ + "试剂瓶", + "3a14233b-56e3-6c53-a8ab-fcaac163a9ba" + ], + "BIOYOND_PolymerStation_1FlaskCarrier": [ + "烧杯", + "3a14233b-f0a9-ba84-eaa9-0d4718b361b6" + ], + "BIOYOND_PolymerStation_6StockCarrier": [ + "样品板", + "3a142339-80de-8f25-6093-1b1b1b6c322e" + ], + "BIOYOND_PolymerStation_Solid_Vial": [ + "90%分装小瓶", + "3a14233a-26e1-28f8-af6a-60ca06ba0165" + ], + "BIOYOND_PolymerStation_Liquid_Vial": [ + "10%分装小瓶", + "3a14233a-84a3-088d-6676-7cb4acd57c64" + ], + "BIOYOND_PolymerStation_TipBox": [ + "枪头盒", + "3a143890-9d51-60ac-6d6f-6edb43c12041" + ] + } + }, + "deck": { + "data": { + "_resource_child_name": "Bioyond_Deck", + "_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerReactionStation_Deck" + } + }, + "protocol_type": [] + }, + "data": {} + }, + { + "id": "Bioyond_Deck", + "name": "Bioyond_Deck", + "children": [], + "parent": "reaction_station_bioyond", + "type": "deck", + "class": "BIOYOND_PolymerReactionStation_Deck", + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "config": { + "type": "BIOYOND_PolymerReactionStation_Deck", + "setup": true, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + } + }, + "data": {} + } + ] +} diff --git a/test/experiments/Protocol_Test_Station/add_protocol_test_station.json b/unilabos/test/experiments/Protocol_Test_Station/add_protocol_test_station.json similarity index 100% rename from test/experiments/Protocol_Test_Station/add_protocol_test_station.json rename to unilabos/test/experiments/Protocol_Test_Station/add_protocol_test_station.json diff --git a/test/experiments/Protocol_Test_Station/centrifuge_protocol_test_station.json b/unilabos/test/experiments/Protocol_Test_Station/centrifuge_protocol_test_station.json similarity index 100% rename from test/experiments/Protocol_Test_Station/centrifuge_protocol_test_station.json rename to unilabos/test/experiments/Protocol_Test_Station/centrifuge_protocol_test_station.json diff --git a/test/experiments/Protocol_Test_Station/clean_vessel_protocol_test_station.json b/unilabos/test/experiments/Protocol_Test_Station/clean_vessel_protocol_test_station.json similarity index 100% rename from test/experiments/Protocol_Test_Station/clean_vessel_protocol_test_station.json rename to unilabos/test/experiments/Protocol_Test_Station/clean_vessel_protocol_test_station.json diff --git a/test/experiments/Protocol_Test_Station/dual_valve_pump_test_station.json b/unilabos/test/experiments/Protocol_Test_Station/dual_valve_pump_test_station.json similarity index 100% rename from test/experiments/Protocol_Test_Station/dual_valve_pump_test_station.json rename to unilabos/test/experiments/Protocol_Test_Station/dual_valve_pump_test_station.json diff --git a/test/experiments/Protocol_Test_Station/evacuateandrefill_test_station.json b/unilabos/test/experiments/Protocol_Test_Station/evacuateandrefill_test_station.json similarity index 100% rename from test/experiments/Protocol_Test_Station/evacuateandrefill_test_station.json rename to unilabos/test/experiments/Protocol_Test_Station/evacuateandrefill_test_station.json diff --git a/test/experiments/Protocol_Test_Station/evaporate_protocol_test_station.json b/unilabos/test/experiments/Protocol_Test_Station/evaporate_protocol_test_station.json similarity index 100% rename from test/experiments/Protocol_Test_Station/evaporate_protocol_test_station.json rename to unilabos/test/experiments/Protocol_Test_Station/evaporate_protocol_test_station.json diff --git a/test/experiments/Protocol_Test_Station/filter_protocol_test_station.json b/unilabos/test/experiments/Protocol_Test_Station/filter_protocol_test_station.json similarity index 100% rename from test/experiments/Protocol_Test_Station/filter_protocol_test_station.json rename to unilabos/test/experiments/Protocol_Test_Station/filter_protocol_test_station.json diff --git a/test/experiments/Protocol_Test_Station/heatchill_protocol_test_station.json b/unilabos/test/experiments/Protocol_Test_Station/heatchill_protocol_test_station.json similarity index 100% rename from test/experiments/Protocol_Test_Station/heatchill_protocol_test_station.json rename to unilabos/test/experiments/Protocol_Test_Station/heatchill_protocol_test_station.json diff --git a/test/experiments/Protocol_Test_Station/pumptransfer_filterthrough_test_station.json b/unilabos/test/experiments/Protocol_Test_Station/pumptransfer_filterthrough_test_station.json similarity index 100% rename from test/experiments/Protocol_Test_Station/pumptransfer_filterthrough_test_station.json rename to unilabos/test/experiments/Protocol_Test_Station/pumptransfer_filterthrough_test_station.json diff --git a/test/experiments/Protocol_Test_Station/pumptransfer_test_station.json b/unilabos/test/experiments/Protocol_Test_Station/pumptransfer_test_station.json similarity index 100% rename from test/experiments/Protocol_Test_Station/pumptransfer_test_station.json rename to unilabos/test/experiments/Protocol_Test_Station/pumptransfer_test_station.json diff --git a/test/experiments/Protocol_Test_Station/run_column_protocol_test_station.json b/unilabos/test/experiments/Protocol_Test_Station/run_column_protocol_test_station.json similarity index 100% rename from test/experiments/Protocol_Test_Station/run_column_protocol_test_station.json rename to unilabos/test/experiments/Protocol_Test_Station/run_column_protocol_test_station.json diff --git a/test/experiments/Protocol_Test_Station/simple_stir_heatchill_test_station.json b/unilabos/test/experiments/Protocol_Test_Station/simple_stir_heatchill_test_station.json similarity index 100% rename from test/experiments/Protocol_Test_Station/simple_stir_heatchill_test_station.json rename to unilabos/test/experiments/Protocol_Test_Station/simple_stir_heatchill_test_station.json diff --git a/test/experiments/biomek.json b/unilabos/test/experiments/biomek.json similarity index 100% rename from test/experiments/biomek.json rename to unilabos/test/experiments/biomek.json diff --git a/test/experiments/camera.json b/unilabos/test/experiments/camera.json similarity index 100% rename from test/experiments/camera.json rename to unilabos/test/experiments/camera.json diff --git a/test/experiments/comprehensive_protocol/checklist.md b/unilabos/test/experiments/comprehensive_protocol/checklist.md similarity index 100% rename from test/experiments/comprehensive_protocol/checklist.md rename to unilabos/test/experiments/comprehensive_protocol/checklist.md diff --git a/test/experiments/comprehensive_protocol/comprehensive_slim.json b/unilabos/test/experiments/comprehensive_protocol/comprehensive_slim.json similarity index 100% rename from test/experiments/comprehensive_protocol/comprehensive_slim.json rename to unilabos/test/experiments/comprehensive_protocol/comprehensive_slim.json diff --git a/test/experiments/comprehensive_protocol/comprehensive_station.json b/unilabos/test/experiments/comprehensive_protocol/comprehensive_station.json similarity index 100% rename from test/experiments/comprehensive_protocol/comprehensive_station.json rename to unilabos/test/experiments/comprehensive_protocol/comprehensive_station.json diff --git a/test/experiments/deis_control_config.yaml b/unilabos/test/experiments/deis_control_config.yaml similarity index 100% rename from test/experiments/deis_control_config.yaml rename to unilabos/test/experiments/deis_control_config.yaml diff --git a/test/experiments/devices.json b/unilabos/test/experiments/devices.json similarity index 100% rename from test/experiments/devices.json rename to unilabos/test/experiments/devices.json diff --git a/test/experiments/dispensing_station_bioyond.json b/unilabos/test/experiments/dispensing_station_bioyond.json similarity index 50% rename from test/experiments/dispensing_station_bioyond.json rename to unilabos/test/experiments/dispensing_station_bioyond.json index 751eac09..0be4129a 100644 --- a/test/experiments/dispensing_station_bioyond.json +++ b/unilabos/test/experiments/dispensing_station_bioyond.json @@ -12,7 +12,37 @@ "config": { "config": { "api_key": "DE9BDDA0", - "api_host": "http://192.168.1.200:44388" + "api_host": "http://192.168.1.200:44388", + "material_type_mappings": { + "BIOYOND_PolymerStation_1FlaskCarrier": [ + "烧杯", + "3a14196b-24f2-ca49-9081-0cab8021bf1a" + ], + "BIOYOND_PolymerStation_1BottleCarrier": [ + "试剂瓶", + "3a14196b-8bcf-a460-4f74-23f21ca79e72" + ], + "BIOYOND_PolymerStation_6StockCarrier": [ + "分装板", + "3a14196e-5dfe-6e21-0c79-fe2036d052c4" + ], + "BIOYOND_PolymerStation_Liquid_Vial": [ + "10%分装小瓶", + "3a14196c-76be-2279-4e22-7310d69aed68" + ], + "BIOYOND_PolymerStation_Solid_Vial": [ + "90%分装小瓶", + "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea" + ], + "BIOYOND_PolymerStation_8StockCarrier": [ + "样品板", + "3a14196e-b7a0-a5da-1931-35f3000281e9" + ], + "BIOYOND_PolymerStation_Solid_Stock": [ + "样品瓶", + "3a14196a-cf7d-8aea-48d8-b9662c7dba94" + ] + } }, "deck": { "data": { @@ -50,4 +80,4 @@ "data": {} } ] -} \ No newline at end of file +} diff --git a/test/experiments/empty_devices.json b/unilabos/test/experiments/empty_devices.json similarity index 100% rename from test/experiments/empty_devices.json rename to unilabos/test/experiments/empty_devices.json diff --git a/test/experiments/laiyu_liquid.json b/unilabos/test/experiments/laiyu_liquid.json similarity index 100% rename from test/experiments/laiyu_liquid.json rename to unilabos/test/experiments/laiyu_liquid.json diff --git a/test/experiments/lidocaine-graph.json b/unilabos/test/experiments/lidocaine-graph.json similarity index 100% rename from test/experiments/lidocaine-graph.json rename to unilabos/test/experiments/lidocaine-graph.json diff --git a/test/experiments/mock_devices/mock_all.json b/unilabos/test/experiments/mock_devices/mock_all.json similarity index 100% rename from test/experiments/mock_devices/mock_all.json rename to unilabos/test/experiments/mock_devices/mock_all.json diff --git a/test/experiments/mock_devices/mock_chiller.json b/unilabos/test/experiments/mock_devices/mock_chiller.json similarity index 100% rename from test/experiments/mock_devices/mock_chiller.json rename to unilabos/test/experiments/mock_devices/mock_chiller.json diff --git a/test/experiments/mock_devices/mock_filter.json b/unilabos/test/experiments/mock_devices/mock_filter.json similarity index 100% rename from test/experiments/mock_devices/mock_filter.json rename to unilabos/test/experiments/mock_devices/mock_filter.json diff --git a/test/experiments/mock_devices/mock_heater.json b/unilabos/test/experiments/mock_devices/mock_heater.json similarity index 100% rename from test/experiments/mock_devices/mock_heater.json rename to unilabos/test/experiments/mock_devices/mock_heater.json diff --git a/test/experiments/mock_devices/mock_pump.json b/unilabos/test/experiments/mock_devices/mock_pump.json similarity index 100% rename from test/experiments/mock_devices/mock_pump.json rename to unilabos/test/experiments/mock_devices/mock_pump.json diff --git a/test/experiments/mock_devices/mock_rotavap.json b/unilabos/test/experiments/mock_devices/mock_rotavap.json similarity index 100% rename from test/experiments/mock_devices/mock_rotavap.json rename to unilabos/test/experiments/mock_devices/mock_rotavap.json diff --git a/test/experiments/mock_devices/mock_separator.json b/unilabos/test/experiments/mock_devices/mock_separator.json similarity index 100% rename from test/experiments/mock_devices/mock_separator.json rename to unilabos/test/experiments/mock_devices/mock_separator.json diff --git a/test/experiments/mock_devices/mock_solenoid_valve.json b/unilabos/test/experiments/mock_devices/mock_solenoid_valve.json similarity index 100% rename from test/experiments/mock_devices/mock_solenoid_valve.json rename to unilabos/test/experiments/mock_devices/mock_solenoid_valve.json diff --git a/test/experiments/mock_devices/mock_stirrer.json b/unilabos/test/experiments/mock_devices/mock_stirrer.json similarity index 100% rename from test/experiments/mock_devices/mock_stirrer.json rename to unilabos/test/experiments/mock_devices/mock_stirrer.json diff --git a/test/experiments/mock_devices/mock_stirrer_new.json b/unilabos/test/experiments/mock_devices/mock_stirrer_new.json similarity index 100% rename from test/experiments/mock_devices/mock_stirrer_new.json rename to unilabos/test/experiments/mock_devices/mock_stirrer_new.json diff --git a/test/experiments/mock_devices/mock_vacuum.json b/unilabos/test/experiments/mock_devices/mock_vacuum.json similarity index 100% rename from test/experiments/mock_devices/mock_vacuum.json rename to unilabos/test/experiments/mock_devices/mock_vacuum.json diff --git a/test/experiments/mock_protocol/addteststation.json b/unilabos/test/experiments/mock_protocol/addteststation.json similarity index 100% rename from test/experiments/mock_protocol/addteststation.json rename to unilabos/test/experiments/mock_protocol/addteststation.json diff --git a/test/experiments/mock_protocol/centrifugeteststation.json b/unilabos/test/experiments/mock_protocol/centrifugeteststation.json similarity index 100% rename from test/experiments/mock_protocol/centrifugeteststation.json rename to unilabos/test/experiments/mock_protocol/centrifugeteststation.json diff --git a/test/experiments/mock_protocol/cleanvesselteststation.json b/unilabos/test/experiments/mock_protocol/cleanvesselteststation.json similarity index 100% rename from test/experiments/mock_protocol/cleanvesselteststation.json rename to unilabos/test/experiments/mock_protocol/cleanvesselteststation.json diff --git a/test/experiments/mock_protocol/dissolveteststation.json b/unilabos/test/experiments/mock_protocol/dissolveteststation.json similarity index 100% rename from test/experiments/mock_protocol/dissolveteststation.json rename to unilabos/test/experiments/mock_protocol/dissolveteststation.json diff --git a/test/experiments/mock_protocol/filterteststation.json b/unilabos/test/experiments/mock_protocol/filterteststation.json similarity index 100% rename from test/experiments/mock_protocol/filterteststation.json rename to unilabos/test/experiments/mock_protocol/filterteststation.json diff --git a/test/experiments/mock_protocol/filterthroughteststation.json b/unilabos/test/experiments/mock_protocol/filterthroughteststation.json similarity index 100% rename from test/experiments/mock_protocol/filterthroughteststation.json rename to unilabos/test/experiments/mock_protocol/filterthroughteststation.json diff --git a/test/experiments/mock_protocol/heatchillteststation.json b/unilabos/test/experiments/mock_protocol/heatchillteststation.json similarity index 100% rename from test/experiments/mock_protocol/heatchillteststation.json rename to unilabos/test/experiments/mock_protocol/heatchillteststation.json diff --git a/test/experiments/mock_protocol/runcolumnteststation.json b/unilabos/test/experiments/mock_protocol/runcolumnteststation.json similarity index 100% rename from test/experiments/mock_protocol/runcolumnteststation.json rename to unilabos/test/experiments/mock_protocol/runcolumnteststation.json diff --git a/test/experiments/mock_protocol/stirteststation.json b/unilabos/test/experiments/mock_protocol/stirteststation.json similarity index 100% rename from test/experiments/mock_protocol/stirteststation.json rename to unilabos/test/experiments/mock_protocol/stirteststation.json diff --git a/test/experiments/mock_protocol/transferteststation.json b/unilabos/test/experiments/mock_protocol/transferteststation.json similarity index 100% rename from test/experiments/mock_protocol/transferteststation.json rename to unilabos/test/experiments/mock_protocol/transferteststation.json diff --git a/test/experiments/mock_protocol/washsolidteststation.json b/unilabos/test/experiments/mock_protocol/washsolidteststation.json similarity index 100% rename from test/experiments/mock_protocol/washsolidteststation.json rename to unilabos/test/experiments/mock_protocol/washsolidteststation.json diff --git a/test/experiments/mock_reactor.json b/unilabos/test/experiments/mock_reactor.json similarity index 100% rename from test/experiments/mock_reactor.json rename to unilabos/test/experiments/mock_reactor.json diff --git a/unilabos/test/experiments/opcua_example.json b/unilabos/test/experiments/opcua_example.json new file mode 100644 index 00000000..a563628d --- /dev/null +++ b/unilabos/test/experiments/opcua_example.json @@ -0,0 +1,19 @@ +{ + "nodes": [ + { + "id": "id", + "name": "name", + "children": [ + ], + "parent": null, + "type": "device", + "class": "opcua_example", + "config": { + "url": "url", + "config_path": "unilabos/device_comms/opcua_client/opcua_workflow_example.json" + }, + "data": { + } + } + ] +} \ No newline at end of file diff --git a/test/experiments/plr_test.json b/unilabos/test/experiments/plr_test.json similarity index 100% rename from test/experiments/plr_test.json rename to unilabos/test/experiments/plr_test.json diff --git a/test/experiments/plr_test_converted.json b/unilabos/test/experiments/plr_test_converted.json similarity index 99% rename from test/experiments/plr_test_converted.json rename to unilabos/test/experiments/plr_test_converted.json index b3ec7053..a9e47f5f 100644 --- a/test/experiments/plr_test_converted.json +++ b/unilabos/test/experiments/plr_test_converted.json @@ -10,24 +10,22 @@ "x": 620.6111111111111, "y": 171, "z": 0 - }, - "config": { - "data": { - "children": [ - { - "_resource_child_name": "deck", - "_resource_type": "pylabrobot.resources.opentrons.deck:OTDeck" - } - ], - "backend": { - "type": "LiquidHandlerRvizBackend" - } - } - }, + }, "data": {}, "children": [ "deck" - ] + ], + "config": { + "deck": { + "_resource_child_name": "deck", + "_resource_type": "pylabrobot.resources.opentrons.deck:OTDeck" + }, + "backend": { + "type": "UniLiquidHandlerRvizBackend" + }, + "simulator": true + } + }, { "id": "deck", @@ -9650,7 +9648,7 @@ "children": [], "parent": null, "type": "device", - "class": "robotic_arm.SCARA_with_slider.virtual", + "class": "robotic_arm.SCARA_with_slider.moveit.virtual", "position": { "x": -500, "y": 1000, diff --git a/test/experiments/plr_test_converted_slim.json b/unilabos/test/experiments/plr_test_converted_slim.json similarity index 100% rename from test/experiments/plr_test_converted_slim.json rename to unilabos/test/experiments/plr_test_converted_slim.json diff --git a/test/experiments/prcxi_9300.json b/unilabos/test/experiments/prcxi_9300.json similarity index 100% rename from test/experiments/prcxi_9300.json rename to unilabos/test/experiments/prcxi_9300.json diff --git a/test/experiments/prcxi_9320.json b/unilabos/test/experiments/prcxi_9320.json similarity index 97% rename from test/experiments/prcxi_9320.json rename to unilabos/test/experiments/prcxi_9320.json index fb81d64d..7f403d4f 100644 --- a/test/experiments/prcxi_9320.json +++ b/unilabos/test/experiments/prcxi_9320.json @@ -16,10 +16,10 @@ "_resource_child_name": "PRCXI_Deck", "_resource_type": "unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Deck" }, - "host": "192.168.0.121", + "host": "192.168.1.201", "port": 9999, "timeout": 10.0, - "axis": "Right", + "axis": "Left", "channel_num": 1, "setup": true, "debug": false, @@ -35,7 +35,7 @@ { "id": "PRCXI_Deck", "name": "PRCXI_Deck", - "sample_id": null, + "children": [ "RackT1", "PlateT2", @@ -81,7 +81,7 @@ { "id": "RackT1", "name": "RackT1", - "sample_id": null, + "children": [ "RackT1_A1", "RackT1_B1", @@ -312,7 +312,7 @@ { "id": "RackT1_A1", "name": "RackT1_A1", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -353,7 +353,7 @@ { "id": "RackT1_B1", "name": "RackT1_B1", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -394,7 +394,7 @@ { "id": "RackT1_C1", "name": "RackT1_C1", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -435,7 +435,7 @@ { "id": "RackT1_D1", "name": "RackT1_D1", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -476,7 +476,7 @@ { "id": "RackT1_E1", "name": "RackT1_E1", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -517,7 +517,7 @@ { "id": "RackT1_F1", "name": "RackT1_F1", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -558,7 +558,7 @@ { "id": "RackT1_G1", "name": "RackT1_G1", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -599,7 +599,7 @@ { "id": "RackT1_H1", "name": "RackT1_H1", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -640,7 +640,7 @@ { "id": "RackT1_A2", "name": "RackT1_A2", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -681,7 +681,7 @@ { "id": "RackT1_B2", "name": "RackT1_B2", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -722,7 +722,7 @@ { "id": "RackT1_C2", "name": "RackT1_C2", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -763,7 +763,7 @@ { "id": "RackT1_D2", "name": "RackT1_D2", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -804,7 +804,7 @@ { "id": "RackT1_E2", "name": "RackT1_E2", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -845,7 +845,7 @@ { "id": "RackT1_F2", "name": "RackT1_F2", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -886,7 +886,7 @@ { "id": "RackT1_G2", "name": "RackT1_G2", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -927,7 +927,7 @@ { "id": "RackT1_H2", "name": "RackT1_H2", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -968,7 +968,7 @@ { "id": "RackT1_A3", "name": "RackT1_A3", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -1009,7 +1009,7 @@ { "id": "RackT1_B3", "name": "RackT1_B3", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -1050,7 +1050,7 @@ { "id": "RackT1_C3", "name": "RackT1_C3", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -1091,7 +1091,7 @@ { "id": "RackT1_D3", "name": "RackT1_D3", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -1132,7 +1132,7 @@ { "id": "RackT1_E3", "name": "RackT1_E3", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -1173,7 +1173,7 @@ { "id": "RackT1_F3", "name": "RackT1_F3", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -1214,7 +1214,7 @@ { "id": "RackT1_G3", "name": "RackT1_G3", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -1255,7 +1255,7 @@ { "id": "RackT1_H3", "name": "RackT1_H3", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -1296,7 +1296,7 @@ { "id": "RackT1_A4", "name": "RackT1_A4", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -1337,7 +1337,7 @@ { "id": "RackT1_B4", "name": "RackT1_B4", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -1378,7 +1378,7 @@ { "id": "RackT1_C4", "name": "RackT1_C4", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -1419,7 +1419,7 @@ { "id": "RackT1_D4", "name": "RackT1_D4", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -1460,7 +1460,7 @@ { "id": "RackT1_E4", "name": "RackT1_E4", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -1501,7 +1501,7 @@ { "id": "RackT1_F4", "name": "RackT1_F4", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -1542,7 +1542,7 @@ { "id": "RackT1_G4", "name": "RackT1_G4", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -1583,7 +1583,7 @@ { "id": "RackT1_H4", "name": "RackT1_H4", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -1624,7 +1624,7 @@ { "id": "RackT1_A5", "name": "RackT1_A5", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -1665,7 +1665,7 @@ { "id": "RackT1_B5", "name": "RackT1_B5", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -1706,7 +1706,7 @@ { "id": "RackT1_C5", "name": "RackT1_C5", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -1747,7 +1747,7 @@ { "id": "RackT1_D5", "name": "RackT1_D5", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -1788,7 +1788,7 @@ { "id": "RackT1_E5", "name": "RackT1_E5", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -1829,7 +1829,7 @@ { "id": "RackT1_F5", "name": "RackT1_F5", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -1870,7 +1870,7 @@ { "id": "RackT1_G5", "name": "RackT1_G5", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -1911,7 +1911,7 @@ { "id": "RackT1_H5", "name": "RackT1_H5", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -1952,7 +1952,7 @@ { "id": "RackT1_A6", "name": "RackT1_A6", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -1993,7 +1993,7 @@ { "id": "RackT1_B6", "name": "RackT1_B6", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -2034,7 +2034,7 @@ { "id": "RackT1_C6", "name": "RackT1_C6", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -2075,7 +2075,7 @@ { "id": "RackT1_D6", "name": "RackT1_D6", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -2116,7 +2116,7 @@ { "id": "RackT1_E6", "name": "RackT1_E6", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -2157,7 +2157,7 @@ { "id": "RackT1_F6", "name": "RackT1_F6", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -2198,7 +2198,7 @@ { "id": "RackT1_G6", "name": "RackT1_G6", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -2239,7 +2239,7 @@ { "id": "RackT1_H6", "name": "RackT1_H6", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -2280,7 +2280,7 @@ { "id": "RackT1_A7", "name": "RackT1_A7", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -2321,7 +2321,7 @@ { "id": "RackT1_B7", "name": "RackT1_B7", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -2362,7 +2362,7 @@ { "id": "RackT1_C7", "name": "RackT1_C7", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -2403,7 +2403,7 @@ { "id": "RackT1_D7", "name": "RackT1_D7", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -2444,7 +2444,7 @@ { "id": "RackT1_E7", "name": "RackT1_E7", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -2485,7 +2485,7 @@ { "id": "RackT1_F7", "name": "RackT1_F7", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -2526,7 +2526,7 @@ { "id": "RackT1_G7", "name": "RackT1_G7", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -2567,7 +2567,7 @@ { "id": "RackT1_H7", "name": "RackT1_H7", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -2608,7 +2608,7 @@ { "id": "RackT1_A8", "name": "RackT1_A8", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -2649,7 +2649,7 @@ { "id": "RackT1_B8", "name": "RackT1_B8", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -2690,7 +2690,7 @@ { "id": "RackT1_C8", "name": "RackT1_C8", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -2731,7 +2731,7 @@ { "id": "RackT1_D8", "name": "RackT1_D8", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -2772,7 +2772,7 @@ { "id": "RackT1_E8", "name": "RackT1_E8", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -2813,7 +2813,7 @@ { "id": "RackT1_F8", "name": "RackT1_F8", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -2854,7 +2854,7 @@ { "id": "RackT1_G8", "name": "RackT1_G8", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -2895,7 +2895,7 @@ { "id": "RackT1_H8", "name": "RackT1_H8", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -2936,7 +2936,7 @@ { "id": "RackT1_A9", "name": "RackT1_A9", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -2977,7 +2977,7 @@ { "id": "RackT1_B9", "name": "RackT1_B9", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -3018,7 +3018,7 @@ { "id": "RackT1_C9", "name": "RackT1_C9", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -3059,7 +3059,7 @@ { "id": "RackT1_D9", "name": "RackT1_D9", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -3100,7 +3100,7 @@ { "id": "RackT1_E9", "name": "RackT1_E9", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -3141,7 +3141,7 @@ { "id": "RackT1_F9", "name": "RackT1_F9", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -3182,7 +3182,7 @@ { "id": "RackT1_G9", "name": "RackT1_G9", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -3223,7 +3223,7 @@ { "id": "RackT1_H9", "name": "RackT1_H9", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -3264,7 +3264,7 @@ { "id": "RackT1_A10", "name": "RackT1_A10", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -3305,7 +3305,7 @@ { "id": "RackT1_B10", "name": "RackT1_B10", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -3346,7 +3346,7 @@ { "id": "RackT1_C10", "name": "RackT1_C10", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -3387,7 +3387,7 @@ { "id": "RackT1_D10", "name": "RackT1_D10", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -3428,7 +3428,7 @@ { "id": "RackT1_E10", "name": "RackT1_E10", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -3469,7 +3469,7 @@ { "id": "RackT1_F10", "name": "RackT1_F10", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -3510,7 +3510,7 @@ { "id": "RackT1_G10", "name": "RackT1_G10", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -3551,7 +3551,7 @@ { "id": "RackT1_H10", "name": "RackT1_H10", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -3592,7 +3592,7 @@ { "id": "RackT1_A11", "name": "RackT1_A11", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -3633,7 +3633,7 @@ { "id": "RackT1_B11", "name": "RackT1_B11", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -3674,7 +3674,7 @@ { "id": "RackT1_C11", "name": "RackT1_C11", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -3715,7 +3715,7 @@ { "id": "RackT1_D11", "name": "RackT1_D11", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -3756,7 +3756,7 @@ { "id": "RackT1_E11", "name": "RackT1_E11", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -3797,7 +3797,7 @@ { "id": "RackT1_F11", "name": "RackT1_F11", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -3838,7 +3838,7 @@ { "id": "RackT1_G11", "name": "RackT1_G11", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -3879,7 +3879,7 @@ { "id": "RackT1_H11", "name": "RackT1_H11", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -3920,7 +3920,7 @@ { "id": "RackT1_A12", "name": "RackT1_A12", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -3961,7 +3961,7 @@ { "id": "RackT1_B12", "name": "RackT1_B12", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -4002,7 +4002,7 @@ { "id": "RackT1_C12", "name": "RackT1_C12", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -4043,7 +4043,7 @@ { "id": "RackT1_D12", "name": "RackT1_D12", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -4084,7 +4084,7 @@ { "id": "RackT1_E12", "name": "RackT1_E12", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -4125,7 +4125,7 @@ { "id": "RackT1_F12", "name": "RackT1_F12", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -4166,7 +4166,7 @@ { "id": "RackT1_G12", "name": "RackT1_G12", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -4207,7 +4207,7 @@ { "id": "RackT1_H12", "name": "RackT1_H12", - "sample_id": null, + "children": [], "parent": "RackT1", "type": "container", @@ -4248,7 +4248,7 @@ { "id": "PlateT2", "name": "PlateT2", - "sample_id": null, + "children": [ "PlateT2_A1", "PlateT2_B1", @@ -4479,7 +4479,7 @@ { "id": "PlateT2_A1", "name": "PlateT2_A1", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -4519,7 +4519,7 @@ { "id": "PlateT2_B1", "name": "PlateT2_B1", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -4559,7 +4559,7 @@ { "id": "PlateT2_C1", "name": "PlateT2_C1", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -4599,7 +4599,7 @@ { "id": "PlateT2_D1", "name": "PlateT2_D1", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -4639,7 +4639,7 @@ { "id": "PlateT2_E1", "name": "PlateT2_E1", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -4679,7 +4679,7 @@ { "id": "PlateT2_F1", "name": "PlateT2_F1", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -4719,7 +4719,7 @@ { "id": "PlateT2_G1", "name": "PlateT2_G1", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -4759,7 +4759,7 @@ { "id": "PlateT2_H1", "name": "PlateT2_H1", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -4799,7 +4799,7 @@ { "id": "PlateT2_A2", "name": "PlateT2_A2", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -4839,7 +4839,7 @@ { "id": "PlateT2_B2", "name": "PlateT2_B2", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -4879,7 +4879,7 @@ { "id": "PlateT2_C2", "name": "PlateT2_C2", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -4919,7 +4919,7 @@ { "id": "PlateT2_D2", "name": "PlateT2_D2", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -4959,7 +4959,7 @@ { "id": "PlateT2_E2", "name": "PlateT2_E2", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -4999,7 +4999,7 @@ { "id": "PlateT2_F2", "name": "PlateT2_F2", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -5039,7 +5039,7 @@ { "id": "PlateT2_G2", "name": "PlateT2_G2", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -5079,7 +5079,7 @@ { "id": "PlateT2_H2", "name": "PlateT2_H2", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -5119,7 +5119,7 @@ { "id": "PlateT2_A3", "name": "PlateT2_A3", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -5159,7 +5159,7 @@ { "id": "PlateT2_B3", "name": "PlateT2_B3", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -5199,7 +5199,7 @@ { "id": "PlateT2_C3", "name": "PlateT2_C3", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -5239,7 +5239,7 @@ { "id": "PlateT2_D3", "name": "PlateT2_D3", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -5279,7 +5279,7 @@ { "id": "PlateT2_E3", "name": "PlateT2_E3", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -5319,7 +5319,7 @@ { "id": "PlateT2_F3", "name": "PlateT2_F3", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -5359,7 +5359,7 @@ { "id": "PlateT2_G3", "name": "PlateT2_G3", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -5399,7 +5399,7 @@ { "id": "PlateT2_H3", "name": "PlateT2_H3", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -5439,7 +5439,7 @@ { "id": "PlateT2_A4", "name": "PlateT2_A4", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -5479,7 +5479,7 @@ { "id": "PlateT2_B4", "name": "PlateT2_B4", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -5519,7 +5519,7 @@ { "id": "PlateT2_C4", "name": "PlateT2_C4", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -5559,7 +5559,7 @@ { "id": "PlateT2_D4", "name": "PlateT2_D4", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -5599,7 +5599,7 @@ { "id": "PlateT2_E4", "name": "PlateT2_E4", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -5639,7 +5639,7 @@ { "id": "PlateT2_F4", "name": "PlateT2_F4", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -5679,7 +5679,7 @@ { "id": "PlateT2_G4", "name": "PlateT2_G4", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -5719,7 +5719,7 @@ { "id": "PlateT2_H4", "name": "PlateT2_H4", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -5759,7 +5759,7 @@ { "id": "PlateT2_A5", "name": "PlateT2_A5", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -5799,7 +5799,7 @@ { "id": "PlateT2_B5", "name": "PlateT2_B5", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -5839,7 +5839,7 @@ { "id": "PlateT2_C5", "name": "PlateT2_C5", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -5879,7 +5879,7 @@ { "id": "PlateT2_D5", "name": "PlateT2_D5", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -5919,7 +5919,7 @@ { "id": "PlateT2_E5", "name": "PlateT2_E5", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -5959,7 +5959,7 @@ { "id": "PlateT2_F5", "name": "PlateT2_F5", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -5999,7 +5999,7 @@ { "id": "PlateT2_G5", "name": "PlateT2_G5", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -6039,7 +6039,7 @@ { "id": "PlateT2_H5", "name": "PlateT2_H5", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -6079,7 +6079,7 @@ { "id": "PlateT2_A6", "name": "PlateT2_A6", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -6119,7 +6119,7 @@ { "id": "PlateT2_B6", "name": "PlateT2_B6", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -6159,7 +6159,7 @@ { "id": "PlateT2_C6", "name": "PlateT2_C6", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -6199,7 +6199,7 @@ { "id": "PlateT2_D6", "name": "PlateT2_D6", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -6239,7 +6239,7 @@ { "id": "PlateT2_E6", "name": "PlateT2_E6", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -6279,7 +6279,7 @@ { "id": "PlateT2_F6", "name": "PlateT2_F6", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -6319,7 +6319,7 @@ { "id": "PlateT2_G6", "name": "PlateT2_G6", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -6359,7 +6359,7 @@ { "id": "PlateT2_H6", "name": "PlateT2_H6", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -6399,7 +6399,7 @@ { "id": "PlateT2_A7", "name": "PlateT2_A7", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -6439,7 +6439,7 @@ { "id": "PlateT2_B7", "name": "PlateT2_B7", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -6479,7 +6479,7 @@ { "id": "PlateT2_C7", "name": "PlateT2_C7", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -6519,7 +6519,7 @@ { "id": "PlateT2_D7", "name": "PlateT2_D7", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -6559,7 +6559,7 @@ { "id": "PlateT2_E7", "name": "PlateT2_E7", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -6599,7 +6599,7 @@ { "id": "PlateT2_F7", "name": "PlateT2_F7", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -6639,7 +6639,7 @@ { "id": "PlateT2_G7", "name": "PlateT2_G7", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -6679,7 +6679,7 @@ { "id": "PlateT2_H7", "name": "PlateT2_H7", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -6719,7 +6719,7 @@ { "id": "PlateT2_A8", "name": "PlateT2_A8", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -6759,7 +6759,7 @@ { "id": "PlateT2_B8", "name": "PlateT2_B8", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -6799,7 +6799,7 @@ { "id": "PlateT2_C8", "name": "PlateT2_C8", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -6839,7 +6839,7 @@ { "id": "PlateT2_D8", "name": "PlateT2_D8", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -6879,7 +6879,7 @@ { "id": "PlateT2_E8", "name": "PlateT2_E8", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -6919,7 +6919,7 @@ { "id": "PlateT2_F8", "name": "PlateT2_F8", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -6959,7 +6959,7 @@ { "id": "PlateT2_G8", "name": "PlateT2_G8", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -6999,7 +6999,7 @@ { "id": "PlateT2_H8", "name": "PlateT2_H8", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -7039,7 +7039,7 @@ { "id": "PlateT2_A9", "name": "PlateT2_A9", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -7079,7 +7079,7 @@ { "id": "PlateT2_B9", "name": "PlateT2_B9", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -7119,7 +7119,7 @@ { "id": "PlateT2_C9", "name": "PlateT2_C9", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -7159,7 +7159,7 @@ { "id": "PlateT2_D9", "name": "PlateT2_D9", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -7199,7 +7199,7 @@ { "id": "PlateT2_E9", "name": "PlateT2_E9", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -7239,7 +7239,7 @@ { "id": "PlateT2_F9", "name": "PlateT2_F9", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -7279,7 +7279,7 @@ { "id": "PlateT2_G9", "name": "PlateT2_G9", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -7319,7 +7319,7 @@ { "id": "PlateT2_H9", "name": "PlateT2_H9", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -7359,7 +7359,7 @@ { "id": "PlateT2_A10", "name": "PlateT2_A10", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -7399,7 +7399,7 @@ { "id": "PlateT2_B10", "name": "PlateT2_B10", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -7439,7 +7439,7 @@ { "id": "PlateT2_C10", "name": "PlateT2_C10", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -7479,7 +7479,7 @@ { "id": "PlateT2_D10", "name": "PlateT2_D10", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -7519,7 +7519,7 @@ { "id": "PlateT2_E10", "name": "PlateT2_E10", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -7559,7 +7559,7 @@ { "id": "PlateT2_F10", "name": "PlateT2_F10", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -7599,7 +7599,7 @@ { "id": "PlateT2_G10", "name": "PlateT2_G10", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -7639,7 +7639,7 @@ { "id": "PlateT2_H10", "name": "PlateT2_H10", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -7679,7 +7679,7 @@ { "id": "PlateT2_A11", "name": "PlateT2_A11", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -7719,7 +7719,7 @@ { "id": "PlateT2_B11", "name": "PlateT2_B11", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -7759,7 +7759,7 @@ { "id": "PlateT2_C11", "name": "PlateT2_C11", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -7799,7 +7799,7 @@ { "id": "PlateT2_D11", "name": "PlateT2_D11", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -7839,7 +7839,7 @@ { "id": "PlateT2_E11", "name": "PlateT2_E11", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -7879,7 +7879,7 @@ { "id": "PlateT2_F11", "name": "PlateT2_F11", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -7919,7 +7919,7 @@ { "id": "PlateT2_G11", "name": "PlateT2_G11", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -7959,7 +7959,7 @@ { "id": "PlateT2_H11", "name": "PlateT2_H11", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -7999,7 +7999,7 @@ { "id": "PlateT2_A12", "name": "PlateT2_A12", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -8039,7 +8039,7 @@ { "id": "PlateT2_B12", "name": "PlateT2_B12", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -8079,7 +8079,7 @@ { "id": "PlateT2_C12", "name": "PlateT2_C12", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -8119,7 +8119,7 @@ { "id": "PlateT2_D12", "name": "PlateT2_D12", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -8159,7 +8159,7 @@ { "id": "PlateT2_E12", "name": "PlateT2_E12", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -8199,7 +8199,7 @@ { "id": "PlateT2_F12", "name": "PlateT2_F12", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -8239,7 +8239,7 @@ { "id": "PlateT2_G12", "name": "PlateT2_G12", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -8279,7 +8279,7 @@ { "id": "PlateT2_H12", "name": "PlateT2_H12", - "sample_id": null, + "children": [], "parent": "PlateT2", "type": "well", @@ -8319,7 +8319,7 @@ { "id": "container_for_nothin3", "name": "container_for_nothin3", - "sample_id": null, + "children": [], "parent": "PRCXI_Deck", "type": "plate", @@ -8350,7 +8350,7 @@ { "id": "PlateT4", "name": "PlateT4", - "sample_id": null, + "children": [ "PlateT4_A1", "PlateT4_B1", @@ -8581,7 +8581,7 @@ { "id": "PlateT4_A1", "name": "PlateT4_A1", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -8621,7 +8621,7 @@ { "id": "PlateT4_B1", "name": "PlateT4_B1", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -8661,7 +8661,7 @@ { "id": "PlateT4_C1", "name": "PlateT4_C1", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -8701,7 +8701,7 @@ { "id": "PlateT4_D1", "name": "PlateT4_D1", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -8741,7 +8741,7 @@ { "id": "PlateT4_E1", "name": "PlateT4_E1", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -8781,7 +8781,7 @@ { "id": "PlateT4_F1", "name": "PlateT4_F1", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -8821,7 +8821,7 @@ { "id": "PlateT4_G1", "name": "PlateT4_G1", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -8861,7 +8861,7 @@ { "id": "PlateT4_H1", "name": "PlateT4_H1", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -8901,7 +8901,7 @@ { "id": "PlateT4_A2", "name": "PlateT4_A2", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -8941,7 +8941,7 @@ { "id": "PlateT4_B2", "name": "PlateT4_B2", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -8981,7 +8981,7 @@ { "id": "PlateT4_C2", "name": "PlateT4_C2", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -9021,7 +9021,7 @@ { "id": "PlateT4_D2", "name": "PlateT4_D2", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -9061,7 +9061,7 @@ { "id": "PlateT4_E2", "name": "PlateT4_E2", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -9101,7 +9101,7 @@ { "id": "PlateT4_F2", "name": "PlateT4_F2", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -9141,7 +9141,7 @@ { "id": "PlateT4_G2", "name": "PlateT4_G2", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -9181,7 +9181,7 @@ { "id": "PlateT4_H2", "name": "PlateT4_H2", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -9221,7 +9221,7 @@ { "id": "PlateT4_A3", "name": "PlateT4_A3", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -9261,7 +9261,7 @@ { "id": "PlateT4_B3", "name": "PlateT4_B3", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -9301,7 +9301,7 @@ { "id": "PlateT4_C3", "name": "PlateT4_C3", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -9341,7 +9341,7 @@ { "id": "PlateT4_D3", "name": "PlateT4_D3", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -9381,7 +9381,7 @@ { "id": "PlateT4_E3", "name": "PlateT4_E3", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -9421,7 +9421,7 @@ { "id": "PlateT4_F3", "name": "PlateT4_F3", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -9461,7 +9461,7 @@ { "id": "PlateT4_G3", "name": "PlateT4_G3", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -9501,7 +9501,7 @@ { "id": "PlateT4_H3", "name": "PlateT4_H3", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -9541,7 +9541,7 @@ { "id": "PlateT4_A4", "name": "PlateT4_A4", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -9581,7 +9581,7 @@ { "id": "PlateT4_B4", "name": "PlateT4_B4", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -9621,7 +9621,7 @@ { "id": "PlateT4_C4", "name": "PlateT4_C4", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -9661,7 +9661,7 @@ { "id": "PlateT4_D4", "name": "PlateT4_D4", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -9701,7 +9701,7 @@ { "id": "PlateT4_E4", "name": "PlateT4_E4", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -9741,7 +9741,7 @@ { "id": "PlateT4_F4", "name": "PlateT4_F4", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -9781,7 +9781,7 @@ { "id": "PlateT4_G4", "name": "PlateT4_G4", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -9821,7 +9821,7 @@ { "id": "PlateT4_H4", "name": "PlateT4_H4", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -9861,7 +9861,7 @@ { "id": "PlateT4_A5", "name": "PlateT4_A5", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -9901,7 +9901,7 @@ { "id": "PlateT4_B5", "name": "PlateT4_B5", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -9941,7 +9941,7 @@ { "id": "PlateT4_C5", "name": "PlateT4_C5", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -9981,7 +9981,7 @@ { "id": "PlateT4_D5", "name": "PlateT4_D5", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -10021,7 +10021,7 @@ { "id": "PlateT4_E5", "name": "PlateT4_E5", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -10061,7 +10061,7 @@ { "id": "PlateT4_F5", "name": "PlateT4_F5", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -10101,7 +10101,7 @@ { "id": "PlateT4_G5", "name": "PlateT4_G5", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -10141,7 +10141,7 @@ { "id": "PlateT4_H5", "name": "PlateT4_H5", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -10181,7 +10181,7 @@ { "id": "PlateT4_A6", "name": "PlateT4_A6", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -10221,7 +10221,7 @@ { "id": "PlateT4_B6", "name": "PlateT4_B6", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -10261,7 +10261,7 @@ { "id": "PlateT4_C6", "name": "PlateT4_C6", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -10301,7 +10301,7 @@ { "id": "PlateT4_D6", "name": "PlateT4_D6", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -10341,7 +10341,7 @@ { "id": "PlateT4_E6", "name": "PlateT4_E6", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -10381,7 +10381,7 @@ { "id": "PlateT4_F6", "name": "PlateT4_F6", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -10421,7 +10421,7 @@ { "id": "PlateT4_G6", "name": "PlateT4_G6", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -10461,7 +10461,7 @@ { "id": "PlateT4_H6", "name": "PlateT4_H6", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -10501,7 +10501,7 @@ { "id": "PlateT4_A7", "name": "PlateT4_A7", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -10541,7 +10541,7 @@ { "id": "PlateT4_B7", "name": "PlateT4_B7", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -10581,7 +10581,7 @@ { "id": "PlateT4_C7", "name": "PlateT4_C7", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -10621,7 +10621,7 @@ { "id": "PlateT4_D7", "name": "PlateT4_D7", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -10661,7 +10661,7 @@ { "id": "PlateT4_E7", "name": "PlateT4_E7", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -10701,7 +10701,7 @@ { "id": "PlateT4_F7", "name": "PlateT4_F7", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -10741,7 +10741,7 @@ { "id": "PlateT4_G7", "name": "PlateT4_G7", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -10781,7 +10781,7 @@ { "id": "PlateT4_H7", "name": "PlateT4_H7", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -10821,7 +10821,7 @@ { "id": "PlateT4_A8", "name": "PlateT4_A8", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -10861,7 +10861,7 @@ { "id": "PlateT4_B8", "name": "PlateT4_B8", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -10901,7 +10901,7 @@ { "id": "PlateT4_C8", "name": "PlateT4_C8", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -10941,7 +10941,7 @@ { "id": "PlateT4_D8", "name": "PlateT4_D8", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -10981,7 +10981,7 @@ { "id": "PlateT4_E8", "name": "PlateT4_E8", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -11021,7 +11021,7 @@ { "id": "PlateT4_F8", "name": "PlateT4_F8", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -11061,7 +11061,7 @@ { "id": "PlateT4_G8", "name": "PlateT4_G8", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -11101,7 +11101,7 @@ { "id": "PlateT4_H8", "name": "PlateT4_H8", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -11141,7 +11141,7 @@ { "id": "PlateT4_A9", "name": "PlateT4_A9", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -11181,7 +11181,7 @@ { "id": "PlateT4_B9", "name": "PlateT4_B9", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -11221,7 +11221,7 @@ { "id": "PlateT4_C9", "name": "PlateT4_C9", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -11261,7 +11261,7 @@ { "id": "PlateT4_D9", "name": "PlateT4_D9", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -11301,7 +11301,7 @@ { "id": "PlateT4_E9", "name": "PlateT4_E9", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -11341,7 +11341,7 @@ { "id": "PlateT4_F9", "name": "PlateT4_F9", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -11381,7 +11381,7 @@ { "id": "PlateT4_G9", "name": "PlateT4_G9", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -11421,7 +11421,7 @@ { "id": "PlateT4_H9", "name": "PlateT4_H9", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -11461,7 +11461,7 @@ { "id": "PlateT4_A10", "name": "PlateT4_A10", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -11501,7 +11501,7 @@ { "id": "PlateT4_B10", "name": "PlateT4_B10", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -11541,7 +11541,7 @@ { "id": "PlateT4_C10", "name": "PlateT4_C10", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -11581,7 +11581,7 @@ { "id": "PlateT4_D10", "name": "PlateT4_D10", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -11621,7 +11621,7 @@ { "id": "PlateT4_E10", "name": "PlateT4_E10", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -11661,7 +11661,7 @@ { "id": "PlateT4_F10", "name": "PlateT4_F10", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -11701,7 +11701,7 @@ { "id": "PlateT4_G10", "name": "PlateT4_G10", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -11741,7 +11741,7 @@ { "id": "PlateT4_H10", "name": "PlateT4_H10", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -11781,7 +11781,7 @@ { "id": "PlateT4_A11", "name": "PlateT4_A11", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -11821,7 +11821,7 @@ { "id": "PlateT4_B11", "name": "PlateT4_B11", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -11861,7 +11861,7 @@ { "id": "PlateT4_C11", "name": "PlateT4_C11", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -11901,7 +11901,7 @@ { "id": "PlateT4_D11", "name": "PlateT4_D11", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -11941,7 +11941,7 @@ { "id": "PlateT4_E11", "name": "PlateT4_E11", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -11981,7 +11981,7 @@ { "id": "PlateT4_F11", "name": "PlateT4_F11", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -12021,7 +12021,7 @@ { "id": "PlateT4_G11", "name": "PlateT4_G11", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -12061,7 +12061,7 @@ { "id": "PlateT4_H11", "name": "PlateT4_H11", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -12101,7 +12101,7 @@ { "id": "PlateT4_A12", "name": "PlateT4_A12", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -12141,7 +12141,7 @@ { "id": "PlateT4_B12", "name": "PlateT4_B12", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -12181,7 +12181,7 @@ { "id": "PlateT4_C12", "name": "PlateT4_C12", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -12221,7 +12221,7 @@ { "id": "PlateT4_D12", "name": "PlateT4_D12", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -12261,7 +12261,7 @@ { "id": "PlateT4_E12", "name": "PlateT4_E12", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -12301,7 +12301,7 @@ { "id": "PlateT4_F12", "name": "PlateT4_F12", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -12341,7 +12341,7 @@ { "id": "PlateT4_G12", "name": "PlateT4_G12", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -12381,7 +12381,7 @@ { "id": "PlateT4_H12", "name": "PlateT4_H12", - "sample_id": null, + "children": [], "parent": "PlateT4", "type": "well", @@ -12421,7 +12421,7 @@ { "id": "RackT5", "name": "RackT5", - "sample_id": null, + "children": [ "RackT5_A1", "RackT5_B1", @@ -12653,7 +12653,7 @@ { "id": "RackT5_A1", "name": "RackT5_A1", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -12694,7 +12694,7 @@ { "id": "RackT5_B1", "name": "RackT5_B1", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -12735,7 +12735,7 @@ { "id": "RackT5_C1", "name": "RackT5_C1", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -12776,7 +12776,7 @@ { "id": "RackT5_D1", "name": "RackT5_D1", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -12817,7 +12817,7 @@ { "id": "RackT5_E1", "name": "RackT5_E1", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -12858,7 +12858,7 @@ { "id": "RackT5_F1", "name": "RackT5_F1", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -12899,7 +12899,7 @@ { "id": "RackT5_G1", "name": "RackT5_G1", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -12940,7 +12940,7 @@ { "id": "RackT5_H1", "name": "RackT5_H1", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -12981,7 +12981,7 @@ { "id": "RackT5_A2", "name": "RackT5_A2", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -13022,7 +13022,7 @@ { "id": "RackT5_B2", "name": "RackT5_B2", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -13063,7 +13063,7 @@ { "id": "RackT5_C2", "name": "RackT5_C2", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -13104,7 +13104,7 @@ { "id": "RackT5_D2", "name": "RackT5_D2", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -13145,7 +13145,7 @@ { "id": "RackT5_E2", "name": "RackT5_E2", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -13186,7 +13186,7 @@ { "id": "RackT5_F2", "name": "RackT5_F2", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -13227,7 +13227,7 @@ { "id": "RackT5_G2", "name": "RackT5_G2", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -13268,7 +13268,7 @@ { "id": "RackT5_H2", "name": "RackT5_H2", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -13309,7 +13309,7 @@ { "id": "RackT5_A3", "name": "RackT5_A3", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -13350,7 +13350,7 @@ { "id": "RackT5_B3", "name": "RackT5_B3", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -13391,7 +13391,7 @@ { "id": "RackT5_C3", "name": "RackT5_C3", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -13432,7 +13432,7 @@ { "id": "RackT5_D3", "name": "RackT5_D3", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -13473,7 +13473,7 @@ { "id": "RackT5_E3", "name": "RackT5_E3", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -13514,7 +13514,7 @@ { "id": "RackT5_F3", "name": "RackT5_F3", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -13555,7 +13555,7 @@ { "id": "RackT5_G3", "name": "RackT5_G3", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -13596,7 +13596,7 @@ { "id": "RackT5_H3", "name": "RackT5_H3", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -13637,7 +13637,7 @@ { "id": "RackT5_A4", "name": "RackT5_A4", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -13678,7 +13678,7 @@ { "id": "RackT5_B4", "name": "RackT5_B4", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -13719,7 +13719,7 @@ { "id": "RackT5_C4", "name": "RackT5_C4", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -13760,7 +13760,7 @@ { "id": "RackT5_D4", "name": "RackT5_D4", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -13801,7 +13801,7 @@ { "id": "RackT5_E4", "name": "RackT5_E4", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -13842,7 +13842,7 @@ { "id": "RackT5_F4", "name": "RackT5_F4", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -13883,7 +13883,7 @@ { "id": "RackT5_G4", "name": "RackT5_G4", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -13924,7 +13924,7 @@ { "id": "RackT5_H4", "name": "RackT5_H4", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -13965,7 +13965,7 @@ { "id": "RackT5_A5", "name": "RackT5_A5", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -14006,7 +14006,7 @@ { "id": "RackT5_B5", "name": "RackT5_B5", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -14047,7 +14047,7 @@ { "id": "RackT5_C5", "name": "RackT5_C5", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -14088,7 +14088,7 @@ { "id": "RackT5_D5", "name": "RackT5_D5", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -14129,7 +14129,7 @@ { "id": "RackT5_E5", "name": "RackT5_E5", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -14170,7 +14170,7 @@ { "id": "RackT5_F5", "name": "RackT5_F5", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -14211,7 +14211,7 @@ { "id": "RackT5_G5", "name": "RackT5_G5", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -14252,7 +14252,7 @@ { "id": "RackT5_H5", "name": "RackT5_H5", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -14293,7 +14293,7 @@ { "id": "RackT5_A6", "name": "RackT5_A6", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -14334,7 +14334,7 @@ { "id": "RackT5_B6", "name": "RackT5_B6", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -14375,7 +14375,7 @@ { "id": "RackT5_C6", "name": "RackT5_C6", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -14416,7 +14416,7 @@ { "id": "RackT5_D6", "name": "RackT5_D6", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -14457,7 +14457,7 @@ { "id": "RackT5_E6", "name": "RackT5_E6", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -14498,7 +14498,7 @@ { "id": "RackT5_F6", "name": "RackT5_F6", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -14539,7 +14539,7 @@ { "id": "RackT5_G6", "name": "RackT5_G6", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -14580,7 +14580,7 @@ { "id": "RackT5_H6", "name": "RackT5_H6", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -14621,7 +14621,7 @@ { "id": "RackT5_A7", "name": "RackT5_A7", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -14662,7 +14662,7 @@ { "id": "RackT5_B7", "name": "RackT5_B7", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -14703,7 +14703,7 @@ { "id": "RackT5_C7", "name": "RackT5_C7", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -14744,7 +14744,7 @@ { "id": "RackT5_D7", "name": "RackT5_D7", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -14785,7 +14785,7 @@ { "id": "RackT5_E7", "name": "RackT5_E7", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -14826,7 +14826,7 @@ { "id": "RackT5_F7", "name": "RackT5_F7", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -14867,7 +14867,7 @@ { "id": "RackT5_G7", "name": "RackT5_G7", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -14908,7 +14908,7 @@ { "id": "RackT5_H7", "name": "RackT5_H7", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -14949,7 +14949,7 @@ { "id": "RackT5_A8", "name": "RackT5_A8", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -14990,7 +14990,7 @@ { "id": "RackT5_B8", "name": "RackT5_B8", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -15031,7 +15031,7 @@ { "id": "RackT5_C8", "name": "RackT5_C8", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -15072,7 +15072,7 @@ { "id": "RackT5_D8", "name": "RackT5_D8", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -15113,7 +15113,7 @@ { "id": "RackT5_E8", "name": "RackT5_E8", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -15154,7 +15154,7 @@ { "id": "RackT5_F8", "name": "RackT5_F8", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -15195,7 +15195,7 @@ { "id": "RackT5_G8", "name": "RackT5_G8", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -15236,7 +15236,7 @@ { "id": "RackT5_H8", "name": "RackT5_H8", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -15277,7 +15277,7 @@ { "id": "RackT5_A9", "name": "RackT5_A9", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -15318,7 +15318,7 @@ { "id": "RackT5_B9", "name": "RackT5_B9", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -15359,7 +15359,7 @@ { "id": "RackT5_C9", "name": "RackT5_C9", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -15400,7 +15400,7 @@ { "id": "RackT5_D9", "name": "RackT5_D9", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -15441,7 +15441,7 @@ { "id": "RackT5_E9", "name": "RackT5_E9", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -15482,7 +15482,7 @@ { "id": "RackT5_F9", "name": "RackT5_F9", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -15523,7 +15523,7 @@ { "id": "RackT5_G9", "name": "RackT5_G9", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -15564,7 +15564,7 @@ { "id": "RackT5_H9", "name": "RackT5_H9", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -15605,7 +15605,7 @@ { "id": "RackT5_A10", "name": "RackT5_A10", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -15646,7 +15646,7 @@ { "id": "RackT5_B10", "name": "RackT5_B10", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -15687,7 +15687,7 @@ { "id": "RackT5_C10", "name": "RackT5_C10", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -15728,7 +15728,7 @@ { "id": "RackT5_D10", "name": "RackT5_D10", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -15769,7 +15769,7 @@ { "id": "RackT5_E10", "name": "RackT5_E10", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -15810,7 +15810,7 @@ { "id": "RackT5_F10", "name": "RackT5_F10", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -15851,7 +15851,7 @@ { "id": "RackT5_G10", "name": "RackT5_G10", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -15892,7 +15892,7 @@ { "id": "RackT5_H10", "name": "RackT5_H10", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -15933,7 +15933,7 @@ { "id": "RackT5_A11", "name": "RackT5_A11", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -15974,7 +15974,7 @@ { "id": "RackT5_B11", "name": "RackT5_B11", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -16015,7 +16015,7 @@ { "id": "RackT5_C11", "name": "RackT5_C11", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -16056,7 +16056,7 @@ { "id": "RackT5_D11", "name": "RackT5_D11", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -16097,7 +16097,7 @@ { "id": "RackT5_E11", "name": "RackT5_E11", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -16138,7 +16138,7 @@ { "id": "RackT5_F11", "name": "RackT5_F11", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -16179,7 +16179,7 @@ { "id": "RackT5_G11", "name": "RackT5_G11", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -16220,7 +16220,7 @@ { "id": "RackT5_H11", "name": "RackT5_H11", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -16261,7 +16261,7 @@ { "id": "RackT5_A12", "name": "RackT5_A12", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -16302,7 +16302,7 @@ { "id": "RackT5_B12", "name": "RackT5_B12", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -16343,7 +16343,7 @@ { "id": "RackT5_C12", "name": "RackT5_C12", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -16384,7 +16384,7 @@ { "id": "RackT5_D12", "name": "RackT5_D12", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -16425,7 +16425,7 @@ { "id": "RackT5_E12", "name": "RackT5_E12", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -16466,7 +16466,7 @@ { "id": "RackT5_F12", "name": "RackT5_F12", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -16507,7 +16507,7 @@ { "id": "RackT5_G12", "name": "RackT5_G12", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -16548,7 +16548,7 @@ { "id": "RackT5_H12", "name": "RackT5_H12", - "sample_id": null, + "children": [], "parent": "RackT5", "type": "container", @@ -16589,7 +16589,7 @@ { "id": "PlateT6", "name": "PlateT6", - "sample_id": null, + "children": [ "PlateT6_A1", "PlateT6_B1", @@ -16822,7 +16822,7 @@ { "id": "PlateT6_A1", "name": "PlateT6_A1", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -16862,7 +16862,7 @@ { "id": "PlateT6_B1", "name": "PlateT6_B1", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -16902,7 +16902,7 @@ { "id": "PlateT6_C1", "name": "PlateT6_C1", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -16942,7 +16942,7 @@ { "id": "PlateT6_D1", "name": "PlateT6_D1", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -16982,7 +16982,7 @@ { "id": "PlateT6_E1", "name": "PlateT6_E1", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -17022,7 +17022,7 @@ { "id": "PlateT6_F1", "name": "PlateT6_F1", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -17062,7 +17062,7 @@ { "id": "PlateT6_G1", "name": "PlateT6_G1", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -17102,7 +17102,7 @@ { "id": "PlateT6_H1", "name": "PlateT6_H1", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -17142,7 +17142,7 @@ { "id": "PlateT6_A2", "name": "PlateT6_A2", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -17182,7 +17182,7 @@ { "id": "PlateT6_B2", "name": "PlateT6_B2", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -17222,7 +17222,7 @@ { "id": "PlateT6_C2", "name": "PlateT6_C2", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -17262,7 +17262,7 @@ { "id": "PlateT6_D2", "name": "PlateT6_D2", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -17302,7 +17302,7 @@ { "id": "PlateT6_E2", "name": "PlateT6_E2", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -17342,7 +17342,7 @@ { "id": "PlateT6_F2", "name": "PlateT6_F2", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -17382,7 +17382,7 @@ { "id": "PlateT6_G2", "name": "PlateT6_G2", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -17422,7 +17422,7 @@ { "id": "PlateT6_H2", "name": "PlateT6_H2", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -17462,7 +17462,7 @@ { "id": "PlateT6_A3", "name": "PlateT6_A3", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -17502,7 +17502,7 @@ { "id": "PlateT6_B3", "name": "PlateT6_B3", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -17542,7 +17542,7 @@ { "id": "PlateT6_C3", "name": "PlateT6_C3", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -17582,7 +17582,7 @@ { "id": "PlateT6_D3", "name": "PlateT6_D3", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -17622,7 +17622,7 @@ { "id": "PlateT6_E3", "name": "PlateT6_E3", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -17662,7 +17662,7 @@ { "id": "PlateT6_F3", "name": "PlateT6_F3", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -17702,7 +17702,7 @@ { "id": "PlateT6_G3", "name": "PlateT6_G3", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -17742,7 +17742,7 @@ { "id": "PlateT6_H3", "name": "PlateT6_H3", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -17782,7 +17782,7 @@ { "id": "PlateT6_A4", "name": "PlateT6_A4", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -17822,7 +17822,7 @@ { "id": "PlateT6_B4", "name": "PlateT6_B4", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -17862,7 +17862,7 @@ { "id": "PlateT6_C4", "name": "PlateT6_C4", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -17902,7 +17902,7 @@ { "id": "PlateT6_D4", "name": "PlateT6_D4", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -17942,7 +17942,7 @@ { "id": "PlateT6_E4", "name": "PlateT6_E4", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -17982,7 +17982,7 @@ { "id": "PlateT6_F4", "name": "PlateT6_F4", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -18022,7 +18022,7 @@ { "id": "PlateT6_G4", "name": "PlateT6_G4", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -18062,7 +18062,7 @@ { "id": "PlateT6_H4", "name": "PlateT6_H4", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -18102,7 +18102,7 @@ { "id": "PlateT6_A5", "name": "PlateT6_A5", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -18142,7 +18142,7 @@ { "id": "PlateT6_B5", "name": "PlateT6_B5", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -18182,7 +18182,7 @@ { "id": "PlateT6_C5", "name": "PlateT6_C5", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -18222,7 +18222,7 @@ { "id": "PlateT6_D5", "name": "PlateT6_D5", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -18262,7 +18262,7 @@ { "id": "PlateT6_E5", "name": "PlateT6_E5", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -18302,7 +18302,7 @@ { "id": "PlateT6_F5", "name": "PlateT6_F5", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -18342,7 +18342,7 @@ { "id": "PlateT6_G5", "name": "PlateT6_G5", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -18382,7 +18382,7 @@ { "id": "PlateT6_H5", "name": "PlateT6_H5", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -18422,7 +18422,7 @@ { "id": "PlateT6_A6", "name": "PlateT6_A6", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -18462,7 +18462,7 @@ { "id": "PlateT6_B6", "name": "PlateT6_B6", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -18502,7 +18502,7 @@ { "id": "PlateT6_C6", "name": "PlateT6_C6", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -18542,7 +18542,7 @@ { "id": "PlateT6_D6", "name": "PlateT6_D6", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -18582,7 +18582,7 @@ { "id": "PlateT6_E6", "name": "PlateT6_E6", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -18622,7 +18622,7 @@ { "id": "PlateT6_F6", "name": "PlateT6_F6", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -18662,7 +18662,7 @@ { "id": "PlateT6_G6", "name": "PlateT6_G6", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -18702,7 +18702,7 @@ { "id": "PlateT6_H6", "name": "PlateT6_H6", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -18742,7 +18742,7 @@ { "id": "PlateT6_A7", "name": "PlateT6_A7", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -18782,7 +18782,7 @@ { "id": "PlateT6_B7", "name": "PlateT6_B7", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -18822,7 +18822,7 @@ { "id": "PlateT6_C7", "name": "PlateT6_C7", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -18862,7 +18862,7 @@ { "id": "PlateT6_D7", "name": "PlateT6_D7", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -18902,7 +18902,7 @@ { "id": "PlateT6_E7", "name": "PlateT6_E7", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -18942,7 +18942,7 @@ { "id": "PlateT6_F7", "name": "PlateT6_F7", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -18982,7 +18982,7 @@ { "id": "PlateT6_G7", "name": "PlateT6_G7", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -19022,7 +19022,7 @@ { "id": "PlateT6_H7", "name": "PlateT6_H7", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -19062,7 +19062,7 @@ { "id": "PlateT6_A8", "name": "PlateT6_A8", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -19102,7 +19102,7 @@ { "id": "PlateT6_B8", "name": "PlateT6_B8", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -19142,7 +19142,7 @@ { "id": "PlateT6_C8", "name": "PlateT6_C8", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -19182,7 +19182,7 @@ { "id": "PlateT6_D8", "name": "PlateT6_D8", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -19222,7 +19222,7 @@ { "id": "PlateT6_E8", "name": "PlateT6_E8", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -19262,7 +19262,7 @@ { "id": "PlateT6_F8", "name": "PlateT6_F8", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -19302,7 +19302,7 @@ { "id": "PlateT6_G8", "name": "PlateT6_G8", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -19342,7 +19342,7 @@ { "id": "PlateT6_H8", "name": "PlateT6_H8", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -19382,7 +19382,7 @@ { "id": "PlateT6_A9", "name": "PlateT6_A9", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -19422,7 +19422,7 @@ { "id": "PlateT6_B9", "name": "PlateT6_B9", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -19462,7 +19462,7 @@ { "id": "PlateT6_C9", "name": "PlateT6_C9", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -19502,7 +19502,7 @@ { "id": "PlateT6_D9", "name": "PlateT6_D9", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -19542,7 +19542,7 @@ { "id": "PlateT6_E9", "name": "PlateT6_E9", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -19582,7 +19582,7 @@ { "id": "PlateT6_F9", "name": "PlateT6_F9", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -19622,7 +19622,7 @@ { "id": "PlateT6_G9", "name": "PlateT6_G9", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -19662,7 +19662,7 @@ { "id": "PlateT6_H9", "name": "PlateT6_H9", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -19702,7 +19702,7 @@ { "id": "PlateT6_A10", "name": "PlateT6_A10", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -19742,7 +19742,7 @@ { "id": "PlateT6_B10", "name": "PlateT6_B10", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -19782,7 +19782,7 @@ { "id": "PlateT6_C10", "name": "PlateT6_C10", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -19822,7 +19822,7 @@ { "id": "PlateT6_D10", "name": "PlateT6_D10", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -19862,7 +19862,7 @@ { "id": "PlateT6_E10", "name": "PlateT6_E10", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -19902,7 +19902,7 @@ { "id": "PlateT6_F10", "name": "PlateT6_F10", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -19942,7 +19942,7 @@ { "id": "PlateT6_G10", "name": "PlateT6_G10", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -19982,7 +19982,7 @@ { "id": "PlateT6_H10", "name": "PlateT6_H10", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -20022,7 +20022,7 @@ { "id": "PlateT6_A11", "name": "PlateT6_A11", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -20062,7 +20062,7 @@ { "id": "PlateT6_B11", "name": "PlateT6_B11", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -20102,7 +20102,7 @@ { "id": "PlateT6_C11", "name": "PlateT6_C11", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -20142,7 +20142,7 @@ { "id": "PlateT6_D11", "name": "PlateT6_D11", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -20182,7 +20182,7 @@ { "id": "PlateT6_E11", "name": "PlateT6_E11", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -20222,7 +20222,7 @@ { "id": "PlateT6_F11", "name": "PlateT6_F11", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -20262,7 +20262,7 @@ { "id": "PlateT6_G11", "name": "PlateT6_G11", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -20302,7 +20302,7 @@ { "id": "PlateT6_H11", "name": "PlateT6_H11", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -20342,7 +20342,7 @@ { "id": "PlateT6_A12", "name": "PlateT6_A12", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -20382,7 +20382,7 @@ { "id": "PlateT6_B12", "name": "PlateT6_B12", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -20422,7 +20422,7 @@ { "id": "PlateT6_C12", "name": "PlateT6_C12", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -20462,7 +20462,7 @@ { "id": "PlateT6_D12", "name": "PlateT6_D12", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -20502,7 +20502,7 @@ { "id": "PlateT6_E12", "name": "PlateT6_E12", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -20542,7 +20542,7 @@ { "id": "PlateT6_F12", "name": "PlateT6_F12", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -20582,7 +20582,7 @@ { "id": "PlateT6_G12", "name": "PlateT6_G12", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -20622,7 +20622,7 @@ { "id": "PlateT6_H12", "name": "PlateT6_H12", - "sample_id": null, + "children": [], "parent": "PlateT6", "type": "well", @@ -20662,7 +20662,7 @@ { "id": "container_for_nothing7", "name": "container_for_nothing7", - "sample_id": null, + "children": [], "parent": "PRCXI_Deck", "type": "plate", @@ -20693,7 +20693,7 @@ { "id": "container_for_nothing8", "name": "container_for_nothing8", - "sample_id": null, + "children": [], "parent": "PRCXI_Deck", "type": "plate", @@ -20724,7 +20724,7 @@ { "id": "PlateT9", "name": "PlateT9", - "sample_id": null, + "children": [ "PlateT9_A1", "PlateT9_B1", @@ -20957,7 +20957,7 @@ { "id": "PlateT9_A1", "name": "PlateT9_A1", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -20997,7 +20997,7 @@ { "id": "PlateT9_B1", "name": "PlateT9_B1", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -21037,7 +21037,7 @@ { "id": "PlateT9_C1", "name": "PlateT9_C1", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -21077,7 +21077,7 @@ { "id": "PlateT9_D1", "name": "PlateT9_D1", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -21117,7 +21117,7 @@ { "id": "PlateT9_E1", "name": "PlateT9_E1", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -21157,7 +21157,7 @@ { "id": "PlateT9_F1", "name": "PlateT9_F1", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -21197,7 +21197,7 @@ { "id": "PlateT9_G1", "name": "PlateT9_G1", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -21237,7 +21237,7 @@ { "id": "PlateT9_H1", "name": "PlateT9_H1", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -21277,7 +21277,7 @@ { "id": "PlateT9_A2", "name": "PlateT9_A2", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -21317,7 +21317,7 @@ { "id": "PlateT9_B2", "name": "PlateT9_B2", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -21357,7 +21357,7 @@ { "id": "PlateT9_C2", "name": "PlateT9_C2", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -21397,7 +21397,7 @@ { "id": "PlateT9_D2", "name": "PlateT9_D2", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -21437,7 +21437,7 @@ { "id": "PlateT9_E2", "name": "PlateT9_E2", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -21477,7 +21477,7 @@ { "id": "PlateT9_F2", "name": "PlateT9_F2", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -21517,7 +21517,7 @@ { "id": "PlateT9_G2", "name": "PlateT9_G2", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -21557,7 +21557,7 @@ { "id": "PlateT9_H2", "name": "PlateT9_H2", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -21597,7 +21597,7 @@ { "id": "PlateT9_A3", "name": "PlateT9_A3", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -21637,7 +21637,7 @@ { "id": "PlateT9_B3", "name": "PlateT9_B3", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -21677,7 +21677,7 @@ { "id": "PlateT9_C3", "name": "PlateT9_C3", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -21717,7 +21717,7 @@ { "id": "PlateT9_D3", "name": "PlateT9_D3", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -21757,7 +21757,7 @@ { "id": "PlateT9_E3", "name": "PlateT9_E3", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -21797,7 +21797,7 @@ { "id": "PlateT9_F3", "name": "PlateT9_F3", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -21837,7 +21837,7 @@ { "id": "PlateT9_G3", "name": "PlateT9_G3", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -21877,7 +21877,7 @@ { "id": "PlateT9_H3", "name": "PlateT9_H3", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -21917,7 +21917,7 @@ { "id": "PlateT9_A4", "name": "PlateT9_A4", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -21957,7 +21957,7 @@ { "id": "PlateT9_B4", "name": "PlateT9_B4", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -21997,7 +21997,7 @@ { "id": "PlateT9_C4", "name": "PlateT9_C4", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -22037,7 +22037,7 @@ { "id": "PlateT9_D4", "name": "PlateT9_D4", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -22077,7 +22077,7 @@ { "id": "PlateT9_E4", "name": "PlateT9_E4", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -22117,7 +22117,7 @@ { "id": "PlateT9_F4", "name": "PlateT9_F4", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -22157,7 +22157,7 @@ { "id": "PlateT9_G4", "name": "PlateT9_G4", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -22197,7 +22197,7 @@ { "id": "PlateT9_H4", "name": "PlateT9_H4", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -22237,7 +22237,7 @@ { "id": "PlateT9_A5", "name": "PlateT9_A5", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -22277,7 +22277,7 @@ { "id": "PlateT9_B5", "name": "PlateT9_B5", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -22317,7 +22317,7 @@ { "id": "PlateT9_C5", "name": "PlateT9_C5", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -22357,7 +22357,7 @@ { "id": "PlateT9_D5", "name": "PlateT9_D5", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -22397,7 +22397,7 @@ { "id": "PlateT9_E5", "name": "PlateT9_E5", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -22437,7 +22437,7 @@ { "id": "PlateT9_F5", "name": "PlateT9_F5", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -22477,7 +22477,7 @@ { "id": "PlateT9_G5", "name": "PlateT9_G5", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -22517,7 +22517,7 @@ { "id": "PlateT9_H5", "name": "PlateT9_H5", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -22557,7 +22557,7 @@ { "id": "PlateT9_A6", "name": "PlateT9_A6", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -22597,7 +22597,7 @@ { "id": "PlateT9_B6", "name": "PlateT9_B6", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -22637,7 +22637,7 @@ { "id": "PlateT9_C6", "name": "PlateT9_C6", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -22677,7 +22677,7 @@ { "id": "PlateT9_D6", "name": "PlateT9_D6", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -22717,7 +22717,7 @@ { "id": "PlateT9_E6", "name": "PlateT9_E6", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -22757,7 +22757,7 @@ { "id": "PlateT9_F6", "name": "PlateT9_F6", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -22797,7 +22797,7 @@ { "id": "PlateT9_G6", "name": "PlateT9_G6", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -22837,7 +22837,7 @@ { "id": "PlateT9_H6", "name": "PlateT9_H6", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -22877,7 +22877,7 @@ { "id": "PlateT9_A7", "name": "PlateT9_A7", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -22917,7 +22917,7 @@ { "id": "PlateT9_B7", "name": "PlateT9_B7", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -22957,7 +22957,7 @@ { "id": "PlateT9_C7", "name": "PlateT9_C7", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -22997,7 +22997,7 @@ { "id": "PlateT9_D7", "name": "PlateT9_D7", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -23037,7 +23037,7 @@ { "id": "PlateT9_E7", "name": "PlateT9_E7", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -23077,7 +23077,7 @@ { "id": "PlateT9_F7", "name": "PlateT9_F7", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -23117,7 +23117,7 @@ { "id": "PlateT9_G7", "name": "PlateT9_G7", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -23157,7 +23157,7 @@ { "id": "PlateT9_H7", "name": "PlateT9_H7", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -23197,7 +23197,7 @@ { "id": "PlateT9_A8", "name": "PlateT9_A8", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -23237,7 +23237,7 @@ { "id": "PlateT9_B8", "name": "PlateT9_B8", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -23277,7 +23277,7 @@ { "id": "PlateT9_C8", "name": "PlateT9_C8", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -23317,7 +23317,7 @@ { "id": "PlateT9_D8", "name": "PlateT9_D8", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -23357,7 +23357,7 @@ { "id": "PlateT9_E8", "name": "PlateT9_E8", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -23397,7 +23397,7 @@ { "id": "PlateT9_F8", "name": "PlateT9_F8", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -23437,7 +23437,7 @@ { "id": "PlateT9_G8", "name": "PlateT9_G8", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -23477,7 +23477,7 @@ { "id": "PlateT9_H8", "name": "PlateT9_H8", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -23517,7 +23517,7 @@ { "id": "PlateT9_A9", "name": "PlateT9_A9", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -23557,7 +23557,7 @@ { "id": "PlateT9_B9", "name": "PlateT9_B9", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -23597,7 +23597,7 @@ { "id": "PlateT9_C9", "name": "PlateT9_C9", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -23637,7 +23637,7 @@ { "id": "PlateT9_D9", "name": "PlateT9_D9", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -23677,7 +23677,7 @@ { "id": "PlateT9_E9", "name": "PlateT9_E9", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -23717,7 +23717,7 @@ { "id": "PlateT9_F9", "name": "PlateT9_F9", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -23757,7 +23757,7 @@ { "id": "PlateT9_G9", "name": "PlateT9_G9", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -23797,7 +23797,7 @@ { "id": "PlateT9_H9", "name": "PlateT9_H9", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -23837,7 +23837,7 @@ { "id": "PlateT9_A10", "name": "PlateT9_A10", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -23877,7 +23877,7 @@ { "id": "PlateT9_B10", "name": "PlateT9_B10", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -23917,7 +23917,7 @@ { "id": "PlateT9_C10", "name": "PlateT9_C10", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -23957,7 +23957,7 @@ { "id": "PlateT9_D10", "name": "PlateT9_D10", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -23997,7 +23997,7 @@ { "id": "PlateT9_E10", "name": "PlateT9_E10", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -24037,7 +24037,7 @@ { "id": "PlateT9_F10", "name": "PlateT9_F10", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -24077,7 +24077,7 @@ { "id": "PlateT9_G10", "name": "PlateT9_G10", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -24117,7 +24117,7 @@ { "id": "PlateT9_H10", "name": "PlateT9_H10", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -24157,7 +24157,7 @@ { "id": "PlateT9_A11", "name": "PlateT9_A11", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -24197,7 +24197,7 @@ { "id": "PlateT9_B11", "name": "PlateT9_B11", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -24237,7 +24237,7 @@ { "id": "PlateT9_C11", "name": "PlateT9_C11", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -24277,7 +24277,7 @@ { "id": "PlateT9_D11", "name": "PlateT9_D11", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -24317,7 +24317,7 @@ { "id": "PlateT9_E11", "name": "PlateT9_E11", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -24357,7 +24357,7 @@ { "id": "PlateT9_F11", "name": "PlateT9_F11", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -24397,7 +24397,7 @@ { "id": "PlateT9_G11", "name": "PlateT9_G11", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -24437,7 +24437,7 @@ { "id": "PlateT9_H11", "name": "PlateT9_H11", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -24477,7 +24477,7 @@ { "id": "PlateT9_A12", "name": "PlateT9_A12", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -24517,7 +24517,7 @@ { "id": "PlateT9_B12", "name": "PlateT9_B12", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -24557,7 +24557,7 @@ { "id": "PlateT9_C12", "name": "PlateT9_C12", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -24597,7 +24597,7 @@ { "id": "PlateT9_D12", "name": "PlateT9_D12", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -24637,7 +24637,7 @@ { "id": "PlateT9_E12", "name": "PlateT9_E12", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -24677,7 +24677,7 @@ { "id": "PlateT9_F12", "name": "PlateT9_F12", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -24717,7 +24717,7 @@ { "id": "PlateT9_G12", "name": "PlateT9_G12", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -24757,7 +24757,7 @@ { "id": "PlateT9_H12", "name": "PlateT9_H12", - "sample_id": null, + "children": [], "parent": "PlateT9", "type": "well", @@ -24797,7 +24797,7 @@ { "id": "PlateT10", "name": "PlateT10", - "sample_id": null, + "children": [ "PlateT10_A1", "PlateT10_B1", @@ -25030,7 +25030,7 @@ { "id": "PlateT10_A1", "name": "PlateT10_A1", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -25070,7 +25070,7 @@ { "id": "PlateT10_B1", "name": "PlateT10_B1", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -25110,7 +25110,7 @@ { "id": "PlateT10_C1", "name": "PlateT10_C1", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -25150,7 +25150,7 @@ { "id": "PlateT10_D1", "name": "PlateT10_D1", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -25190,7 +25190,7 @@ { "id": "PlateT10_E1", "name": "PlateT10_E1", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -25230,7 +25230,7 @@ { "id": "PlateT10_F1", "name": "PlateT10_F1", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -25270,7 +25270,7 @@ { "id": "PlateT10_G1", "name": "PlateT10_G1", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -25310,7 +25310,7 @@ { "id": "PlateT10_H1", "name": "PlateT10_H1", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -25350,7 +25350,7 @@ { "id": "PlateT10_A2", "name": "PlateT10_A2", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -25390,7 +25390,7 @@ { "id": "PlateT10_B2", "name": "PlateT10_B2", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -25430,7 +25430,7 @@ { "id": "PlateT10_C2", "name": "PlateT10_C2", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -25470,7 +25470,7 @@ { "id": "PlateT10_D2", "name": "PlateT10_D2", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -25510,7 +25510,7 @@ { "id": "PlateT10_E2", "name": "PlateT10_E2", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -25550,7 +25550,7 @@ { "id": "PlateT10_F2", "name": "PlateT10_F2", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -25590,7 +25590,7 @@ { "id": "PlateT10_G2", "name": "PlateT10_G2", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -25630,7 +25630,7 @@ { "id": "PlateT10_H2", "name": "PlateT10_H2", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -25670,7 +25670,7 @@ { "id": "PlateT10_A3", "name": "PlateT10_A3", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -25710,7 +25710,7 @@ { "id": "PlateT10_B3", "name": "PlateT10_B3", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -25750,7 +25750,7 @@ { "id": "PlateT10_C3", "name": "PlateT10_C3", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -25790,7 +25790,7 @@ { "id": "PlateT10_D3", "name": "PlateT10_D3", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -25830,7 +25830,7 @@ { "id": "PlateT10_E3", "name": "PlateT10_E3", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -25870,7 +25870,7 @@ { "id": "PlateT10_F3", "name": "PlateT10_F3", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -25910,7 +25910,7 @@ { "id": "PlateT10_G3", "name": "PlateT10_G3", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -25950,7 +25950,7 @@ { "id": "PlateT10_H3", "name": "PlateT10_H3", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -25990,7 +25990,7 @@ { "id": "PlateT10_A4", "name": "PlateT10_A4", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -26030,7 +26030,7 @@ { "id": "PlateT10_B4", "name": "PlateT10_B4", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -26070,7 +26070,7 @@ { "id": "PlateT10_C4", "name": "PlateT10_C4", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -26110,7 +26110,7 @@ { "id": "PlateT10_D4", "name": "PlateT10_D4", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -26150,7 +26150,7 @@ { "id": "PlateT10_E4", "name": "PlateT10_E4", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -26190,7 +26190,7 @@ { "id": "PlateT10_F4", "name": "PlateT10_F4", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -26230,7 +26230,7 @@ { "id": "PlateT10_G4", "name": "PlateT10_G4", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -26270,7 +26270,7 @@ { "id": "PlateT10_H4", "name": "PlateT10_H4", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -26310,7 +26310,7 @@ { "id": "PlateT10_A5", "name": "PlateT10_A5", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -26350,7 +26350,7 @@ { "id": "PlateT10_B5", "name": "PlateT10_B5", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -26390,7 +26390,7 @@ { "id": "PlateT10_C5", "name": "PlateT10_C5", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -26430,7 +26430,7 @@ { "id": "PlateT10_D5", "name": "PlateT10_D5", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -26470,7 +26470,7 @@ { "id": "PlateT10_E5", "name": "PlateT10_E5", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -26510,7 +26510,7 @@ { "id": "PlateT10_F5", "name": "PlateT10_F5", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -26550,7 +26550,7 @@ { "id": "PlateT10_G5", "name": "PlateT10_G5", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -26590,7 +26590,7 @@ { "id": "PlateT10_H5", "name": "PlateT10_H5", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -26630,7 +26630,7 @@ { "id": "PlateT10_A6", "name": "PlateT10_A6", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -26670,7 +26670,7 @@ { "id": "PlateT10_B6", "name": "PlateT10_B6", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -26710,7 +26710,7 @@ { "id": "PlateT10_C6", "name": "PlateT10_C6", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -26750,7 +26750,7 @@ { "id": "PlateT10_D6", "name": "PlateT10_D6", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -26790,7 +26790,7 @@ { "id": "PlateT10_E6", "name": "PlateT10_E6", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -26830,7 +26830,7 @@ { "id": "PlateT10_F6", "name": "PlateT10_F6", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -26870,7 +26870,7 @@ { "id": "PlateT10_G6", "name": "PlateT10_G6", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -26910,7 +26910,7 @@ { "id": "PlateT10_H6", "name": "PlateT10_H6", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -26950,7 +26950,7 @@ { "id": "PlateT10_A7", "name": "PlateT10_A7", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -26990,7 +26990,7 @@ { "id": "PlateT10_B7", "name": "PlateT10_B7", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -27030,7 +27030,7 @@ { "id": "PlateT10_C7", "name": "PlateT10_C7", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -27070,7 +27070,7 @@ { "id": "PlateT10_D7", "name": "PlateT10_D7", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -27110,7 +27110,7 @@ { "id": "PlateT10_E7", "name": "PlateT10_E7", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -27150,7 +27150,7 @@ { "id": "PlateT10_F7", "name": "PlateT10_F7", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -27190,7 +27190,7 @@ { "id": "PlateT10_G7", "name": "PlateT10_G7", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -27230,7 +27230,7 @@ { "id": "PlateT10_H7", "name": "PlateT10_H7", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -27270,7 +27270,7 @@ { "id": "PlateT10_A8", "name": "PlateT10_A8", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -27310,7 +27310,7 @@ { "id": "PlateT10_B8", "name": "PlateT10_B8", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -27350,7 +27350,7 @@ { "id": "PlateT10_C8", "name": "PlateT10_C8", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -27390,7 +27390,7 @@ { "id": "PlateT10_D8", "name": "PlateT10_D8", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -27430,7 +27430,7 @@ { "id": "PlateT10_E8", "name": "PlateT10_E8", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -27470,7 +27470,7 @@ { "id": "PlateT10_F8", "name": "PlateT10_F8", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -27510,7 +27510,7 @@ { "id": "PlateT10_G8", "name": "PlateT10_G8", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -27550,7 +27550,7 @@ { "id": "PlateT10_H8", "name": "PlateT10_H8", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -27590,7 +27590,7 @@ { "id": "PlateT10_A9", "name": "PlateT10_A9", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -27630,7 +27630,7 @@ { "id": "PlateT10_B9", "name": "PlateT10_B9", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -27670,7 +27670,7 @@ { "id": "PlateT10_C9", "name": "PlateT10_C9", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -27710,7 +27710,7 @@ { "id": "PlateT10_D9", "name": "PlateT10_D9", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -27750,7 +27750,7 @@ { "id": "PlateT10_E9", "name": "PlateT10_E9", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -27790,7 +27790,7 @@ { "id": "PlateT10_F9", "name": "PlateT10_F9", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -27830,7 +27830,7 @@ { "id": "PlateT10_G9", "name": "PlateT10_G9", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -27870,7 +27870,7 @@ { "id": "PlateT10_H9", "name": "PlateT10_H9", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -27910,7 +27910,7 @@ { "id": "PlateT10_A10", "name": "PlateT10_A10", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -27950,7 +27950,7 @@ { "id": "PlateT10_B10", "name": "PlateT10_B10", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -27990,7 +27990,7 @@ { "id": "PlateT10_C10", "name": "PlateT10_C10", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -28030,7 +28030,7 @@ { "id": "PlateT10_D10", "name": "PlateT10_D10", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -28070,7 +28070,7 @@ { "id": "PlateT10_E10", "name": "PlateT10_E10", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -28110,7 +28110,7 @@ { "id": "PlateT10_F10", "name": "PlateT10_F10", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -28150,7 +28150,7 @@ { "id": "PlateT10_G10", "name": "PlateT10_G10", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -28190,7 +28190,7 @@ { "id": "PlateT10_H10", "name": "PlateT10_H10", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -28230,7 +28230,7 @@ { "id": "PlateT10_A11", "name": "PlateT10_A11", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -28270,7 +28270,7 @@ { "id": "PlateT10_B11", "name": "PlateT10_B11", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -28310,7 +28310,7 @@ { "id": "PlateT10_C11", "name": "PlateT10_C11", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -28350,7 +28350,7 @@ { "id": "PlateT10_D11", "name": "PlateT10_D11", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -28390,7 +28390,7 @@ { "id": "PlateT10_E11", "name": "PlateT10_E11", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -28430,7 +28430,7 @@ { "id": "PlateT10_F11", "name": "PlateT10_F11", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -28470,7 +28470,7 @@ { "id": "PlateT10_G11", "name": "PlateT10_G11", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -28510,7 +28510,7 @@ { "id": "PlateT10_H11", "name": "PlateT10_H11", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -28550,7 +28550,7 @@ { "id": "PlateT10_A12", "name": "PlateT10_A12", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -28590,7 +28590,7 @@ { "id": "PlateT10_B12", "name": "PlateT10_B12", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -28630,7 +28630,7 @@ { "id": "PlateT10_C12", "name": "PlateT10_C12", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -28670,7 +28670,7 @@ { "id": "PlateT10_D12", "name": "PlateT10_D12", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -28710,7 +28710,7 @@ { "id": "PlateT10_E12", "name": "PlateT10_E12", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -28750,7 +28750,7 @@ { "id": "PlateT10_F12", "name": "PlateT10_F12", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -28790,7 +28790,7 @@ { "id": "PlateT10_G12", "name": "PlateT10_G12", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -28830,7 +28830,7 @@ { "id": "PlateT10_H12", "name": "PlateT10_H12", - "sample_id": null, + "children": [], "parent": "PlateT10", "type": "well", @@ -28870,7 +28870,7 @@ { "id": "container_for_nothing11", "name": "container_for_nothing11", - "sample_id": null, + "children": [], "parent": "PRCXI_Deck", "type": "plate", @@ -28901,7 +28901,7 @@ { "id": "container_for_nothing12", "name": "container_for_nothing12", - "sample_id": null, + "children": [], "parent": "PRCXI_Deck", "type": "plate", @@ -28932,7 +28932,7 @@ { "id": "PlateT13", "name": "PlateT13", - "sample_id": null, + "children": [ "PlateT13_A1", "PlateT13_B1", @@ -29165,7 +29165,7 @@ { "id": "PlateT13_A1", "name": "PlateT13_A1", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -29205,7 +29205,7 @@ { "id": "PlateT13_B1", "name": "PlateT13_B1", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -29245,7 +29245,7 @@ { "id": "PlateT13_C1", "name": "PlateT13_C1", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -29285,7 +29285,7 @@ { "id": "PlateT13_D1", "name": "PlateT13_D1", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -29325,7 +29325,7 @@ { "id": "PlateT13_E1", "name": "PlateT13_E1", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -29365,7 +29365,7 @@ { "id": "PlateT13_F1", "name": "PlateT13_F1", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -29405,7 +29405,7 @@ { "id": "PlateT13_G1", "name": "PlateT13_G1", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -29445,7 +29445,7 @@ { "id": "PlateT13_H1", "name": "PlateT13_H1", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -29485,7 +29485,7 @@ { "id": "PlateT13_A2", "name": "PlateT13_A2", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -29525,7 +29525,7 @@ { "id": "PlateT13_B2", "name": "PlateT13_B2", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -29565,7 +29565,7 @@ { "id": "PlateT13_C2", "name": "PlateT13_C2", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -29605,7 +29605,7 @@ { "id": "PlateT13_D2", "name": "PlateT13_D2", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -29645,7 +29645,7 @@ { "id": "PlateT13_E2", "name": "PlateT13_E2", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -29685,7 +29685,7 @@ { "id": "PlateT13_F2", "name": "PlateT13_F2", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -29725,7 +29725,7 @@ { "id": "PlateT13_G2", "name": "PlateT13_G2", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -29765,7 +29765,7 @@ { "id": "PlateT13_H2", "name": "PlateT13_H2", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -29805,7 +29805,7 @@ { "id": "PlateT13_A3", "name": "PlateT13_A3", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -29845,7 +29845,7 @@ { "id": "PlateT13_B3", "name": "PlateT13_B3", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -29885,7 +29885,7 @@ { "id": "PlateT13_C3", "name": "PlateT13_C3", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -29925,7 +29925,7 @@ { "id": "PlateT13_D3", "name": "PlateT13_D3", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -29965,7 +29965,7 @@ { "id": "PlateT13_E3", "name": "PlateT13_E3", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -30005,7 +30005,7 @@ { "id": "PlateT13_F3", "name": "PlateT13_F3", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -30045,7 +30045,7 @@ { "id": "PlateT13_G3", "name": "PlateT13_G3", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -30085,7 +30085,7 @@ { "id": "PlateT13_H3", "name": "PlateT13_H3", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -30125,7 +30125,7 @@ { "id": "PlateT13_A4", "name": "PlateT13_A4", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -30165,7 +30165,7 @@ { "id": "PlateT13_B4", "name": "PlateT13_B4", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -30205,7 +30205,7 @@ { "id": "PlateT13_C4", "name": "PlateT13_C4", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -30245,7 +30245,7 @@ { "id": "PlateT13_D4", "name": "PlateT13_D4", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -30285,7 +30285,7 @@ { "id": "PlateT13_E4", "name": "PlateT13_E4", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -30325,7 +30325,7 @@ { "id": "PlateT13_F4", "name": "PlateT13_F4", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -30365,7 +30365,7 @@ { "id": "PlateT13_G4", "name": "PlateT13_G4", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -30405,7 +30405,7 @@ { "id": "PlateT13_H4", "name": "PlateT13_H4", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -30445,7 +30445,7 @@ { "id": "PlateT13_A5", "name": "PlateT13_A5", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -30485,7 +30485,7 @@ { "id": "PlateT13_B5", "name": "PlateT13_B5", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -30525,7 +30525,7 @@ { "id": "PlateT13_C5", "name": "PlateT13_C5", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -30565,7 +30565,7 @@ { "id": "PlateT13_D5", "name": "PlateT13_D5", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -30605,7 +30605,7 @@ { "id": "PlateT13_E5", "name": "PlateT13_E5", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -30645,7 +30645,7 @@ { "id": "PlateT13_F5", "name": "PlateT13_F5", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -30685,7 +30685,7 @@ { "id": "PlateT13_G5", "name": "PlateT13_G5", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -30725,7 +30725,7 @@ { "id": "PlateT13_H5", "name": "PlateT13_H5", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -30765,7 +30765,7 @@ { "id": "PlateT13_A6", "name": "PlateT13_A6", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -30805,7 +30805,7 @@ { "id": "PlateT13_B6", "name": "PlateT13_B6", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -30845,7 +30845,7 @@ { "id": "PlateT13_C6", "name": "PlateT13_C6", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -30885,7 +30885,7 @@ { "id": "PlateT13_D6", "name": "PlateT13_D6", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -30925,7 +30925,7 @@ { "id": "PlateT13_E6", "name": "PlateT13_E6", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -30965,7 +30965,7 @@ { "id": "PlateT13_F6", "name": "PlateT13_F6", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -31005,7 +31005,7 @@ { "id": "PlateT13_G6", "name": "PlateT13_G6", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -31045,7 +31045,7 @@ { "id": "PlateT13_H6", "name": "PlateT13_H6", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -31085,7 +31085,7 @@ { "id": "PlateT13_A7", "name": "PlateT13_A7", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -31125,7 +31125,7 @@ { "id": "PlateT13_B7", "name": "PlateT13_B7", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -31165,7 +31165,7 @@ { "id": "PlateT13_C7", "name": "PlateT13_C7", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -31205,7 +31205,7 @@ { "id": "PlateT13_D7", "name": "PlateT13_D7", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -31245,7 +31245,7 @@ { "id": "PlateT13_E7", "name": "PlateT13_E7", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -31285,7 +31285,7 @@ { "id": "PlateT13_F7", "name": "PlateT13_F7", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -31325,7 +31325,7 @@ { "id": "PlateT13_G7", "name": "PlateT13_G7", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -31365,7 +31365,7 @@ { "id": "PlateT13_H7", "name": "PlateT13_H7", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -31405,7 +31405,7 @@ { "id": "PlateT13_A8", "name": "PlateT13_A8", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -31445,7 +31445,7 @@ { "id": "PlateT13_B8", "name": "PlateT13_B8", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -31485,7 +31485,7 @@ { "id": "PlateT13_C8", "name": "PlateT13_C8", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -31525,7 +31525,7 @@ { "id": "PlateT13_D8", "name": "PlateT13_D8", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -31565,7 +31565,7 @@ { "id": "PlateT13_E8", "name": "PlateT13_E8", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -31605,7 +31605,7 @@ { "id": "PlateT13_F8", "name": "PlateT13_F8", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -31645,7 +31645,7 @@ { "id": "PlateT13_G8", "name": "PlateT13_G8", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -31685,7 +31685,7 @@ { "id": "PlateT13_H8", "name": "PlateT13_H8", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -31725,7 +31725,7 @@ { "id": "PlateT13_A9", "name": "PlateT13_A9", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -31765,7 +31765,7 @@ { "id": "PlateT13_B9", "name": "PlateT13_B9", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -31805,7 +31805,7 @@ { "id": "PlateT13_C9", "name": "PlateT13_C9", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -31845,7 +31845,7 @@ { "id": "PlateT13_D9", "name": "PlateT13_D9", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -31885,7 +31885,7 @@ { "id": "PlateT13_E9", "name": "PlateT13_E9", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -31925,7 +31925,7 @@ { "id": "PlateT13_F9", "name": "PlateT13_F9", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -31965,7 +31965,7 @@ { "id": "PlateT13_G9", "name": "PlateT13_G9", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -32005,7 +32005,7 @@ { "id": "PlateT13_H9", "name": "PlateT13_H9", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -32045,7 +32045,7 @@ { "id": "PlateT13_A10", "name": "PlateT13_A10", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -32085,7 +32085,7 @@ { "id": "PlateT13_B10", "name": "PlateT13_B10", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -32125,7 +32125,7 @@ { "id": "PlateT13_C10", "name": "PlateT13_C10", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -32165,7 +32165,7 @@ { "id": "PlateT13_D10", "name": "PlateT13_D10", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -32205,7 +32205,7 @@ { "id": "PlateT13_E10", "name": "PlateT13_E10", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -32245,7 +32245,7 @@ { "id": "PlateT13_F10", "name": "PlateT13_F10", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -32285,7 +32285,7 @@ { "id": "PlateT13_G10", "name": "PlateT13_G10", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -32325,7 +32325,7 @@ { "id": "PlateT13_H10", "name": "PlateT13_H10", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -32365,7 +32365,7 @@ { "id": "PlateT13_A11", "name": "PlateT13_A11", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -32405,7 +32405,7 @@ { "id": "PlateT13_B11", "name": "PlateT13_B11", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -32445,7 +32445,7 @@ { "id": "PlateT13_C11", "name": "PlateT13_C11", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -32485,7 +32485,7 @@ { "id": "PlateT13_D11", "name": "PlateT13_D11", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -32525,7 +32525,7 @@ { "id": "PlateT13_E11", "name": "PlateT13_E11", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -32565,7 +32565,7 @@ { "id": "PlateT13_F11", "name": "PlateT13_F11", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -32605,7 +32605,7 @@ { "id": "PlateT13_G11", "name": "PlateT13_G11", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -32645,7 +32645,7 @@ { "id": "PlateT13_H11", "name": "PlateT13_H11", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -32685,7 +32685,7 @@ { "id": "PlateT13_A12", "name": "PlateT13_A12", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -32725,7 +32725,7 @@ { "id": "PlateT13_B12", "name": "PlateT13_B12", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -32765,7 +32765,7 @@ { "id": "PlateT13_C12", "name": "PlateT13_C12", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -32805,7 +32805,7 @@ { "id": "PlateT13_D12", "name": "PlateT13_D12", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -32845,7 +32845,7 @@ { "id": "PlateT13_E12", "name": "PlateT13_E12", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -32885,7 +32885,7 @@ { "id": "PlateT13_F12", "name": "PlateT13_F12", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -32925,7 +32925,7 @@ { "id": "PlateT13_G12", "name": "PlateT13_G12", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -32965,7 +32965,7 @@ { "id": "PlateT13_H12", "name": "PlateT13_H12", - "sample_id": null, + "children": [], "parent": "PlateT13", "type": "well", @@ -33005,7 +33005,7 @@ { "id": "PlateT14", "name": "PlateT14", - "sample_id": null, + "children": [ "PlateT14_A1", "PlateT14_B1", @@ -33238,7 +33238,7 @@ { "id": "PlateT14_A1", "name": "PlateT14_A1", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -33278,7 +33278,7 @@ { "id": "PlateT14_B1", "name": "PlateT14_B1", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -33318,7 +33318,7 @@ { "id": "PlateT14_C1", "name": "PlateT14_C1", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -33358,7 +33358,7 @@ { "id": "PlateT14_D1", "name": "PlateT14_D1", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -33398,7 +33398,7 @@ { "id": "PlateT14_E1", "name": "PlateT14_E1", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -33438,7 +33438,7 @@ { "id": "PlateT14_F1", "name": "PlateT14_F1", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -33478,7 +33478,7 @@ { "id": "PlateT14_G1", "name": "PlateT14_G1", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -33518,7 +33518,7 @@ { "id": "PlateT14_H1", "name": "PlateT14_H1", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -33558,7 +33558,7 @@ { "id": "PlateT14_A2", "name": "PlateT14_A2", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -33598,7 +33598,7 @@ { "id": "PlateT14_B2", "name": "PlateT14_B2", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -33638,7 +33638,7 @@ { "id": "PlateT14_C2", "name": "PlateT14_C2", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -33678,7 +33678,7 @@ { "id": "PlateT14_D2", "name": "PlateT14_D2", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -33718,7 +33718,7 @@ { "id": "PlateT14_E2", "name": "PlateT14_E2", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -33758,7 +33758,7 @@ { "id": "PlateT14_F2", "name": "PlateT14_F2", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -33798,7 +33798,7 @@ { "id": "PlateT14_G2", "name": "PlateT14_G2", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -33838,7 +33838,7 @@ { "id": "PlateT14_H2", "name": "PlateT14_H2", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -33878,7 +33878,7 @@ { "id": "PlateT14_A3", "name": "PlateT14_A3", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -33918,7 +33918,7 @@ { "id": "PlateT14_B3", "name": "PlateT14_B3", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -33958,7 +33958,7 @@ { "id": "PlateT14_C3", "name": "PlateT14_C3", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -33998,7 +33998,7 @@ { "id": "PlateT14_D3", "name": "PlateT14_D3", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -34038,7 +34038,7 @@ { "id": "PlateT14_E3", "name": "PlateT14_E3", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -34078,7 +34078,7 @@ { "id": "PlateT14_F3", "name": "PlateT14_F3", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -34118,7 +34118,7 @@ { "id": "PlateT14_G3", "name": "PlateT14_G3", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -34158,7 +34158,7 @@ { "id": "PlateT14_H3", "name": "PlateT14_H3", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -34198,7 +34198,7 @@ { "id": "PlateT14_A4", "name": "PlateT14_A4", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -34238,7 +34238,7 @@ { "id": "PlateT14_B4", "name": "PlateT14_B4", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -34278,7 +34278,7 @@ { "id": "PlateT14_C4", "name": "PlateT14_C4", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -34318,7 +34318,7 @@ { "id": "PlateT14_D4", "name": "PlateT14_D4", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -34358,7 +34358,7 @@ { "id": "PlateT14_E4", "name": "PlateT14_E4", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -34398,7 +34398,7 @@ { "id": "PlateT14_F4", "name": "PlateT14_F4", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -34438,7 +34438,7 @@ { "id": "PlateT14_G4", "name": "PlateT14_G4", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -34478,7 +34478,7 @@ { "id": "PlateT14_H4", "name": "PlateT14_H4", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -34518,7 +34518,7 @@ { "id": "PlateT14_A5", "name": "PlateT14_A5", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -34558,7 +34558,7 @@ { "id": "PlateT14_B5", "name": "PlateT14_B5", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -34598,7 +34598,7 @@ { "id": "PlateT14_C5", "name": "PlateT14_C5", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -34638,7 +34638,7 @@ { "id": "PlateT14_D5", "name": "PlateT14_D5", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -34678,7 +34678,7 @@ { "id": "PlateT14_E5", "name": "PlateT14_E5", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -34718,7 +34718,7 @@ { "id": "PlateT14_F5", "name": "PlateT14_F5", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -34758,7 +34758,7 @@ { "id": "PlateT14_G5", "name": "PlateT14_G5", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -34798,7 +34798,7 @@ { "id": "PlateT14_H5", "name": "PlateT14_H5", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -34838,7 +34838,7 @@ { "id": "PlateT14_A6", "name": "PlateT14_A6", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -34878,7 +34878,7 @@ { "id": "PlateT14_B6", "name": "PlateT14_B6", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -34918,7 +34918,7 @@ { "id": "PlateT14_C6", "name": "PlateT14_C6", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -34958,7 +34958,7 @@ { "id": "PlateT14_D6", "name": "PlateT14_D6", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -34998,7 +34998,7 @@ { "id": "PlateT14_E6", "name": "PlateT14_E6", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -35038,7 +35038,7 @@ { "id": "PlateT14_F6", "name": "PlateT14_F6", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -35078,7 +35078,7 @@ { "id": "PlateT14_G6", "name": "PlateT14_G6", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -35118,7 +35118,7 @@ { "id": "PlateT14_H6", "name": "PlateT14_H6", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -35158,7 +35158,7 @@ { "id": "PlateT14_A7", "name": "PlateT14_A7", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -35198,7 +35198,7 @@ { "id": "PlateT14_B7", "name": "PlateT14_B7", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -35238,7 +35238,7 @@ { "id": "PlateT14_C7", "name": "PlateT14_C7", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -35278,7 +35278,7 @@ { "id": "PlateT14_D7", "name": "PlateT14_D7", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -35318,7 +35318,7 @@ { "id": "PlateT14_E7", "name": "PlateT14_E7", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -35358,7 +35358,7 @@ { "id": "PlateT14_F7", "name": "PlateT14_F7", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -35398,7 +35398,7 @@ { "id": "PlateT14_G7", "name": "PlateT14_G7", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -35438,7 +35438,7 @@ { "id": "PlateT14_H7", "name": "PlateT14_H7", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -35478,7 +35478,7 @@ { "id": "PlateT14_A8", "name": "PlateT14_A8", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -35518,7 +35518,7 @@ { "id": "PlateT14_B8", "name": "PlateT14_B8", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -35558,7 +35558,7 @@ { "id": "PlateT14_C8", "name": "PlateT14_C8", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -35598,7 +35598,7 @@ { "id": "PlateT14_D8", "name": "PlateT14_D8", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -35638,7 +35638,7 @@ { "id": "PlateT14_E8", "name": "PlateT14_E8", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -35678,7 +35678,7 @@ { "id": "PlateT14_F8", "name": "PlateT14_F8", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -35718,7 +35718,7 @@ { "id": "PlateT14_G8", "name": "PlateT14_G8", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -35758,7 +35758,7 @@ { "id": "PlateT14_H8", "name": "PlateT14_H8", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -35798,7 +35798,7 @@ { "id": "PlateT14_A9", "name": "PlateT14_A9", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -35838,7 +35838,7 @@ { "id": "PlateT14_B9", "name": "PlateT14_B9", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -35878,7 +35878,7 @@ { "id": "PlateT14_C9", "name": "PlateT14_C9", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -35918,7 +35918,7 @@ { "id": "PlateT14_D9", "name": "PlateT14_D9", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -35958,7 +35958,7 @@ { "id": "PlateT14_E9", "name": "PlateT14_E9", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -35998,7 +35998,7 @@ { "id": "PlateT14_F9", "name": "PlateT14_F9", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -36038,7 +36038,7 @@ { "id": "PlateT14_G9", "name": "PlateT14_G9", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -36078,7 +36078,7 @@ { "id": "PlateT14_H9", "name": "PlateT14_H9", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -36118,7 +36118,7 @@ { "id": "PlateT14_A10", "name": "PlateT14_A10", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -36158,7 +36158,7 @@ { "id": "PlateT14_B10", "name": "PlateT14_B10", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -36198,7 +36198,7 @@ { "id": "PlateT14_C10", "name": "PlateT14_C10", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -36238,7 +36238,7 @@ { "id": "PlateT14_D10", "name": "PlateT14_D10", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -36278,7 +36278,7 @@ { "id": "PlateT14_E10", "name": "PlateT14_E10", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -36318,7 +36318,7 @@ { "id": "PlateT14_F10", "name": "PlateT14_F10", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -36358,7 +36358,7 @@ { "id": "PlateT14_G10", "name": "PlateT14_G10", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -36398,7 +36398,7 @@ { "id": "PlateT14_H10", "name": "PlateT14_H10", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -36438,7 +36438,7 @@ { "id": "PlateT14_A11", "name": "PlateT14_A11", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -36478,7 +36478,7 @@ { "id": "PlateT14_B11", "name": "PlateT14_B11", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -36518,7 +36518,7 @@ { "id": "PlateT14_C11", "name": "PlateT14_C11", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -36558,7 +36558,7 @@ { "id": "PlateT14_D11", "name": "PlateT14_D11", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -36598,7 +36598,7 @@ { "id": "PlateT14_E11", "name": "PlateT14_E11", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -36638,7 +36638,7 @@ { "id": "PlateT14_F11", "name": "PlateT14_F11", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -36678,7 +36678,7 @@ { "id": "PlateT14_G11", "name": "PlateT14_G11", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -36718,7 +36718,7 @@ { "id": "PlateT14_H11", "name": "PlateT14_H11", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -36758,7 +36758,7 @@ { "id": "PlateT14_A12", "name": "PlateT14_A12", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -36798,7 +36798,7 @@ { "id": "PlateT14_B12", "name": "PlateT14_B12", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -36838,7 +36838,7 @@ { "id": "PlateT14_C12", "name": "PlateT14_C12", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -36878,7 +36878,7 @@ { "id": "PlateT14_D12", "name": "PlateT14_D12", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -36918,7 +36918,7 @@ { "id": "PlateT14_E12", "name": "PlateT14_E12", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -36958,7 +36958,7 @@ { "id": "PlateT14_F12", "name": "PlateT14_F12", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -36998,7 +36998,7 @@ { "id": "PlateT14_G12", "name": "PlateT14_G12", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -37038,7 +37038,7 @@ { "id": "PlateT14_H12", "name": "PlateT14_H12", - "sample_id": null, + "children": [], "parent": "PlateT14", "type": "well", @@ -37078,7 +37078,7 @@ { "id": "PlateT15", "name": "PlateT15", - "sample_id": null, + "children": [ "PlateT15_A1", "PlateT15_B1", @@ -37307,7 +37307,7 @@ { "id": "PlateT15_A1", "name": "PlateT15_A1", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -37347,7 +37347,7 @@ { "id": "PlateT15_B1", "name": "PlateT15_B1", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -37387,7 +37387,7 @@ { "id": "PlateT15_C1", "name": "PlateT15_C1", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -37427,7 +37427,7 @@ { "id": "PlateT15_D1", "name": "PlateT15_D1", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -37467,7 +37467,7 @@ { "id": "PlateT15_E1", "name": "PlateT15_E1", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -37507,7 +37507,7 @@ { "id": "PlateT15_F1", "name": "PlateT15_F1", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -37547,7 +37547,7 @@ { "id": "PlateT15_G1", "name": "PlateT15_G1", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -37587,7 +37587,7 @@ { "id": "PlateT15_H1", "name": "PlateT15_H1", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -37627,7 +37627,7 @@ { "id": "PlateT15_A2", "name": "PlateT15_A2", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -37667,7 +37667,7 @@ { "id": "PlateT15_B2", "name": "PlateT15_B2", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -37707,7 +37707,7 @@ { "id": "PlateT15_C2", "name": "PlateT15_C2", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -37747,7 +37747,7 @@ { "id": "PlateT15_D2", "name": "PlateT15_D2", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -37787,7 +37787,7 @@ { "id": "PlateT15_E2", "name": "PlateT15_E2", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -37827,7 +37827,7 @@ { "id": "PlateT15_F2", "name": "PlateT15_F2", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -37867,7 +37867,7 @@ { "id": "PlateT15_G2", "name": "PlateT15_G2", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -37907,7 +37907,7 @@ { "id": "PlateT15_H2", "name": "PlateT15_H2", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -37947,7 +37947,7 @@ { "id": "PlateT15_A3", "name": "PlateT15_A3", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -37987,7 +37987,7 @@ { "id": "PlateT15_B3", "name": "PlateT15_B3", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -38027,7 +38027,7 @@ { "id": "PlateT15_C3", "name": "PlateT15_C3", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -38067,7 +38067,7 @@ { "id": "PlateT15_D3", "name": "PlateT15_D3", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -38107,7 +38107,7 @@ { "id": "PlateT15_E3", "name": "PlateT15_E3", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -38147,7 +38147,7 @@ { "id": "PlateT15_F3", "name": "PlateT15_F3", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -38187,7 +38187,7 @@ { "id": "PlateT15_G3", "name": "PlateT15_G3", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -38227,7 +38227,7 @@ { "id": "PlateT15_H3", "name": "PlateT15_H3", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -38267,7 +38267,7 @@ { "id": "PlateT15_A4", "name": "PlateT15_A4", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -38307,7 +38307,7 @@ { "id": "PlateT15_B4", "name": "PlateT15_B4", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -38347,7 +38347,7 @@ { "id": "PlateT15_C4", "name": "PlateT15_C4", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -38387,7 +38387,7 @@ { "id": "PlateT15_D4", "name": "PlateT15_D4", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -38427,7 +38427,7 @@ { "id": "PlateT15_E4", "name": "PlateT15_E4", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -38467,7 +38467,7 @@ { "id": "PlateT15_F4", "name": "PlateT15_F4", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -38507,7 +38507,7 @@ { "id": "PlateT15_G4", "name": "PlateT15_G4", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -38547,7 +38547,7 @@ { "id": "PlateT15_H4", "name": "PlateT15_H4", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -38587,7 +38587,7 @@ { "id": "PlateT15_A5", "name": "PlateT15_A5", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -38627,7 +38627,7 @@ { "id": "PlateT15_B5", "name": "PlateT15_B5", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -38667,7 +38667,7 @@ { "id": "PlateT15_C5", "name": "PlateT15_C5", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -38707,7 +38707,7 @@ { "id": "PlateT15_D5", "name": "PlateT15_D5", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -38747,7 +38747,7 @@ { "id": "PlateT15_E5", "name": "PlateT15_E5", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -38787,7 +38787,7 @@ { "id": "PlateT15_F5", "name": "PlateT15_F5", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -38827,7 +38827,7 @@ { "id": "PlateT15_G5", "name": "PlateT15_G5", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -38867,7 +38867,7 @@ { "id": "PlateT15_H5", "name": "PlateT15_H5", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -38907,7 +38907,7 @@ { "id": "PlateT15_A6", "name": "PlateT15_A6", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -38947,7 +38947,7 @@ { "id": "PlateT15_B6", "name": "PlateT15_B6", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -38987,7 +38987,7 @@ { "id": "PlateT15_C6", "name": "PlateT15_C6", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -39027,7 +39027,7 @@ { "id": "PlateT15_D6", "name": "PlateT15_D6", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -39067,7 +39067,7 @@ { "id": "PlateT15_E6", "name": "PlateT15_E6", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -39107,7 +39107,7 @@ { "id": "PlateT15_F6", "name": "PlateT15_F6", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -39147,7 +39147,7 @@ { "id": "PlateT15_G6", "name": "PlateT15_G6", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -39187,7 +39187,7 @@ { "id": "PlateT15_H6", "name": "PlateT15_H6", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -39227,7 +39227,7 @@ { "id": "PlateT15_A7", "name": "PlateT15_A7", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -39267,7 +39267,7 @@ { "id": "PlateT15_B7", "name": "PlateT15_B7", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -39307,7 +39307,7 @@ { "id": "PlateT15_C7", "name": "PlateT15_C7", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -39347,7 +39347,7 @@ { "id": "PlateT15_D7", "name": "PlateT15_D7", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -39387,7 +39387,7 @@ { "id": "PlateT15_E7", "name": "PlateT15_E7", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -39427,7 +39427,7 @@ { "id": "PlateT15_F7", "name": "PlateT15_F7", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -39467,7 +39467,7 @@ { "id": "PlateT15_G7", "name": "PlateT15_G7", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -39507,7 +39507,7 @@ { "id": "PlateT15_H7", "name": "PlateT15_H7", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -39547,7 +39547,7 @@ { "id": "PlateT15_A8", "name": "PlateT15_A8", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -39587,7 +39587,7 @@ { "id": "PlateT15_B8", "name": "PlateT15_B8", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -39627,7 +39627,7 @@ { "id": "PlateT15_C8", "name": "PlateT15_C8", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -39667,7 +39667,7 @@ { "id": "PlateT15_D8", "name": "PlateT15_D8", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -39707,7 +39707,7 @@ { "id": "PlateT15_E8", "name": "PlateT15_E8", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -39747,7 +39747,7 @@ { "id": "PlateT15_F8", "name": "PlateT15_F8", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -39787,7 +39787,7 @@ { "id": "PlateT15_G8", "name": "PlateT15_G8", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -39827,7 +39827,7 @@ { "id": "PlateT15_H8", "name": "PlateT15_H8", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -39867,7 +39867,7 @@ { "id": "PlateT15_A9", "name": "PlateT15_A9", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -39907,7 +39907,7 @@ { "id": "PlateT15_B9", "name": "PlateT15_B9", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -39947,7 +39947,7 @@ { "id": "PlateT15_C9", "name": "PlateT15_C9", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -39987,7 +39987,7 @@ { "id": "PlateT15_D9", "name": "PlateT15_D9", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -40027,7 +40027,7 @@ { "id": "PlateT15_E9", "name": "PlateT15_E9", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -40067,7 +40067,7 @@ { "id": "PlateT15_F9", "name": "PlateT15_F9", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -40107,7 +40107,7 @@ { "id": "PlateT15_G9", "name": "PlateT15_G9", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -40147,7 +40147,7 @@ { "id": "PlateT15_H9", "name": "PlateT15_H9", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -40187,7 +40187,7 @@ { "id": "PlateT15_A10", "name": "PlateT15_A10", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -40227,7 +40227,7 @@ { "id": "PlateT15_B10", "name": "PlateT15_B10", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -40267,7 +40267,7 @@ { "id": "PlateT15_C10", "name": "PlateT15_C10", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -40307,7 +40307,7 @@ { "id": "PlateT15_D10", "name": "PlateT15_D10", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -40347,7 +40347,7 @@ { "id": "PlateT15_E10", "name": "PlateT15_E10", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -40387,7 +40387,7 @@ { "id": "PlateT15_F10", "name": "PlateT15_F10", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -40427,7 +40427,7 @@ { "id": "PlateT15_G10", "name": "PlateT15_G10", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -40467,7 +40467,7 @@ { "id": "PlateT15_H10", "name": "PlateT15_H10", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -40507,7 +40507,7 @@ { "id": "PlateT15_A11", "name": "PlateT15_A11", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -40547,7 +40547,7 @@ { "id": "PlateT15_B11", "name": "PlateT15_B11", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -40587,7 +40587,7 @@ { "id": "PlateT15_C11", "name": "PlateT15_C11", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -40627,7 +40627,7 @@ { "id": "PlateT15_D11", "name": "PlateT15_D11", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -40667,7 +40667,7 @@ { "id": "PlateT15_E11", "name": "PlateT15_E11", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -40707,7 +40707,7 @@ { "id": "PlateT15_F11", "name": "PlateT15_F11", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -40747,7 +40747,7 @@ { "id": "PlateT15_G11", "name": "PlateT15_G11", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -40787,7 +40787,7 @@ { "id": "PlateT15_H11", "name": "PlateT15_H11", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -40827,7 +40827,7 @@ { "id": "PlateT15_A12", "name": "PlateT15_A12", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -40867,7 +40867,7 @@ { "id": "PlateT15_B12", "name": "PlateT15_B12", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -40907,7 +40907,7 @@ { "id": "PlateT15_C12", "name": "PlateT15_C12", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -40947,7 +40947,7 @@ { "id": "PlateT15_D12", "name": "PlateT15_D12", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -40987,7 +40987,7 @@ { "id": "PlateT15_E12", "name": "PlateT15_E12", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -41027,7 +41027,7 @@ { "id": "PlateT15_F12", "name": "PlateT15_F12", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -41067,7 +41067,7 @@ { "id": "PlateT15_G12", "name": "PlateT15_G12", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -41107,7 +41107,7 @@ { "id": "PlateT15_H12", "name": "PlateT15_H12", - "sample_id": null, + "children": [], "parent": "PlateT15", "type": "well", @@ -41147,7 +41147,7 @@ { "id": "trash", "name": "trash", - "sample_id": null, + "children": [], "parent": "PRCXI_Deck", "type": "container", diff --git a/test/experiments/prcxi_9320_visual.json b/unilabos/test/experiments/prcxi_9320_visual.json similarity index 100% rename from test/experiments/prcxi_9320_visual.json rename to unilabos/test/experiments/prcxi_9320_visual.json diff --git a/unilabos/test/experiments/reaction_station_bioyond.json b/unilabos/test/experiments/reaction_station_bioyond.json new file mode 100644 index 00000000..5cbe5b43 --- /dev/null +++ b/unilabos/test/experiments/reaction_station_bioyond.json @@ -0,0 +1,153 @@ +{ + "nodes": [ + { + "id": "reaction_station_bioyond", + "name": "reaction_station_bioyond", + "parent": null, + "children": [ + "Bioyond_Deck", + "reactor_1", + "reactor_2", + "reactor_3", + "reactor_4", + "reactor_5" + ], + "type": "device", + "class": "reaction_station.bioyond", + "position": {"x": 0, "y": 3800, "z": 0}, + "config": { + "config": { + "api_key": "DE9BDDA0", + "api_host": "http://192.168.1.200:44402", + "workflow_mappings": { + "reactor_taken_out": "3a16081e-4788-ca37-eff4-ceed8d7019d1", + "reactor_taken_in": "3a160df6-76b3-0957-9eb0-cb496d5721c6", + "Solid_feeding_vials": "3a160877-87e7-7699-7bc6-ec72b05eb5e6", + "Liquid_feeding_vials(non-titration)": "3a167d99-6158-c6f0-15b5-eb030f7d8e47", + "Liquid_feeding_solvents": "3a160824-0665-01ed-285a-51ef817a9046", + "Liquid_feeding(titration)": "3a16082a-96ac-0449-446a-4ed39f3365b6", + "liquid_feeding_beaker": "3a16087e-124f-8ddb-8ec1-c2dff09ca784", + "Drip_back": "3a162cf9-6aac-565a-ddd7-682ba1796a4a" + }, + "material_type_mappings": { + "BIOYOND_PolymerStation_Reactor": [ + "反应器", + "3a14233b-902d-0d7b-4533-3f60f1c41c1b" + ], + "BIOYOND_PolymerStation_1BottleCarrier": [ + "试剂瓶", + "3a14233b-56e3-6c53-a8ab-fcaac163a9ba" + ], + "BIOYOND_PolymerStation_1FlaskCarrier": [ + "烧杯", + "3a14233b-f0a9-ba84-eaa9-0d4718b361b6" + ], + "BIOYOND_PolymerStation_6StockCarrier": [ + "样品板", + "3a142339-80de-8f25-6093-1b1b1b6c322e" + ], + "BIOYOND_PolymerStation_Solid_Vial": [ + "90%分装小瓶", + "3a14233a-26e1-28f8-af6a-60ca06ba0165" + ], + "BIOYOND_PolymerStation_Liquid_Vial": [ + "10%分装小瓶", + "3a14233a-84a3-088d-6676-7cb4acd57c64" + ], + "BIOYOND_PolymerStation_TipBox": [ + "枪头盒", + "3a143890-9d51-60ac-6d6f-6edb43c12041" + ] + } + }, + "deck": { + "data": { + "_resource_child_name": "Bioyond_Deck", + "_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerReactionStation_Deck" + } + }, + "protocol_type": [] + }, + "data": {} + }, + { + "id": "reactor_1", + "name": "reactor_1", + "children": [], + "parent": "reaction_station_bioyond", + "type": "device", + "class": "reaction_station.reactor", + "position": {"x": 1150, "y": 380, "z": 0}, + "config": {}, + "data": {} + }, + { + "id": "reactor_2", + "name": "reactor_2", + "children": [], + "parent": "reaction_station_bioyond", + "type": "device", + "class": "reaction_station.reactor", + "position": {"x": 1365, "y": 380, "z": 0}, + "config": {}, + "data": {} + }, + { + "id": "reactor_3", + "name": "reactor_3", + "children": [], + "parent": "reaction_station_bioyond", + "type": "device", + "class": "reaction_station.reactor", + "position": {"x": 1580, "y": 380, "z": 0}, + "config": {}, + "data": {} + }, + { + "id": "reactor_4", + "name": "reactor_4", + "children": [], + "parent": "reaction_station_bioyond", + "type": "device", + "class": "reaction_station.reactor", + "position": {"x": 1790, "y": 380, "z": 0}, + "config": {}, + "data": {} + }, + { + "id": "reactor_5", + "name": "reactor_5", + "children": [], + "parent": "reaction_station_bioyond", + "type": "device", + "class": "reaction_station.reactor", + "position": {"x": 2010, "y": 380, "z": 0}, + "config": {}, + "data": {} + }, + { + "id": "Bioyond_Deck", + "name": "Bioyond_Deck", + "children": [], + "parent": "reaction_station_bioyond", + "type": "deck", + "class": "BIOYOND_PolymerReactionStation_Deck", + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "config": { + "type": "BIOYOND_PolymerReactionStation_Deck", + "setup": true, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + } + }, + "data": {} + } + ] +} diff --git a/test/experiments/reaction_station_bioyond_test.json b/unilabos/test/experiments/reaction_station_bioyond_test.json similarity index 100% rename from test/experiments/reaction_station_bioyond_test.json rename to unilabos/test/experiments/reaction_station_bioyond_test.json diff --git a/test/experiments/test.json b/unilabos/test/experiments/test.json similarity index 100% rename from test/experiments/test.json rename to unilabos/test/experiments/test.json diff --git a/test/experiments/test_copy.json b/unilabos/test/experiments/test_copy.json similarity index 100% rename from test/experiments/test_copy.json rename to unilabos/test/experiments/test_copy.json diff --git a/test/experiments/test_laiyu.json b/unilabos/test/experiments/test_laiyu.json similarity index 92% rename from test/experiments/test_laiyu.json rename to unilabos/test/experiments/test_laiyu.json index fa439407..6b03d1ec 100644 --- a/test/experiments/test_laiyu.json +++ b/unilabos/test/experiments/test_laiyu.json @@ -18,21 +18,21 @@ "config": { "deck": { "_resource_child_name": "deck", - "_resource_type": "pylabrobot.resources.opentrons.deck:OTDeck", + "_resource_type": "unilabos.devices.liquid_handling.laiyu.laiyu:TransformXYZDeck", "name": "deck" }, "backend": { - "type": "UniLiquidHandlerRvizBackend" - + "type": "UniLiquidHandlerLaiyuBackend", + "port": "/dev/ttyUSB_CH340" }, - "simulator": true, - "total_height": 300 + "simulator": false, + "total_height": 232.5 } }, { "id": "deck", "name": "deck", - "sample_id": null, + "children": [ "tip_rack", "plate_well", @@ -64,7 +64,7 @@ { "id": "tip_rack", "name": "tip_rack", - "sample_id": null, + "children": [ "tip_rack_A1" ], @@ -102,7 +102,7 @@ { "id": "tip_rack_A1", "name": "tip_rack_A1", - "sample_id": null, + "children": [], "parent": "tip_rack", "type": "container", @@ -144,7 +144,7 @@ { "id": "plate_well", "name": "plate_well", - "sample_id": null, + "children": [ "plate_well_A1" ], @@ -156,18 +156,6 @@ "y": 116, "z": 48.5 }, - "pose": { - "position_3d": { - "x": 161, - "y": 116, - "z": 48.5 - }, - "rotation": { - "x": 0, - "y": 0, - "z": 0 - } - }, "config": { "type": "Plate", "size_x": 127.76, @@ -195,7 +183,7 @@ { "id": "plate_well_A1", "name": "plate_well_A1", - "sample_id": null, + "children": [], "parent": "plate_well", "type": "device", @@ -236,7 +224,7 @@ { "id": "tube_rack", "name": "tube_rack", - "sample_id": null, + "children": [ "tube_rack_A1" ], @@ -271,7 +259,7 @@ { "id": "tube_rack_A1", "name": "tube_rack_A1", - "sample_id": null, + "children": [], "parent": "tube_rack", "type": "device", @@ -315,7 +303,7 @@ { "id": "bottle_rack", "name": "bottle_rack", - "sample_id": null, + "children": [ "bottle_rack_A1" ], @@ -351,7 +339,7 @@ { "id": "bottle_rack_A1", "name": "bottle_rack_A1", - "sample_id": null, + "children": [], "parent": "bottle_rack", "type": "device", diff --git a/unilabos/test/experiments/test_laiyu_v.json b/unilabos/test/experiments/test_laiyu_v.json new file mode 100644 index 00000000..64bedc8a --- /dev/null +++ b/unilabos/test/experiments/test_laiyu_v.json @@ -0,0 +1,383 @@ +{ + "nodes": [ + { + "id": "liquid_handler", + "name": "liquid_handler", + "parent": null, + "type": "device", + "class": "liquid_handler", + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "data": {}, + "children": [ + "deck" + ], + "config": { + "deck": { + "_resource_child_name": "deck", + "_resource_type": "unilabos.devices.liquid_handling.laiyu.laiyu:TransformXYZDeck", + "name": "deck" + }, + "backend": { + "type": "UniLiquidHandlerRvizBackend" + }, + "simulator": true, + "total_height": 300, + "joint_config": "TransformXYZDeck", + "simulate_rviz": true + } + }, + { + "id": "deck", + "name": "deck", + + "children": [ + "tip_rack", + "plate_well", + "tube_rack", + "bottle_rack" + ], + "parent": "liquid_handler", + "type": "deck", + "class": "TransformXYZDeck", + "position": { + "x": 0, + "y": 0, + "z": 18 + }, + "config": { + "type": "TransformXYZDeck", + "size_x": 624.3, + "size_y": 565.2, + "size_z": 900, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + } + }, + "data": {} + }, + { + "id": "tip_rack", + "name": "tip_rack", + + "children": [ + "tip_rack_A1" + ], + "parent": "deck", + "type": "tip_rack", + "class": "tiprack_box", + "position": { + "x": 150, + "y": 7, + "z": 103 + }, + "config": { + "type": "TipRack", + "size_x": 134, + "size_y": 96, + "size_z": 7.0, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_rack", + "model": "tiprack_box", + "ordering": [ + "A1" + ] + }, + "data": {} + }, + + + + + { + "id": "tip_rack_A1", + "name": "tip_rack_A1", + + "children": [], + "parent": "tip_rack", + "type": "container", + "class": "", + "position": { + "x": 11.12, + "y": 75, + "z": -91.54 + }, + "config": { + "type": "TipSpot", + "size_x": 9, + "size_y": 9, + "size_z": 95, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tip_spot", + "model": null, + "prototype_tip": { + "type": "Tip", + "total_tip_length": 95, + "has_filter": false, + "maximal_volume": 1000.0, + "fitting_depth": 3.29 + } + }, + "data": { + "tip": null, + "tip_state": null, + "pending_tip": null + } + }, + + + { + "id": "plate_well", + "name": "plate_well", + + "children": [ + "plate_well_A1" + ], + "parent": "deck", + "type": "plate", + "class": "plate_96", + "position": { + "x": 161, + "y": 116, + "z": 48.5 + }, + "config": { + "type": "Plate", + "size_x": 127.76, + "size_y": 85.48, + "size_z": 45.5, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "plate", + "model": "plate_96", + "ordering": [ + "A1" + ] + }, + "data": {} + }, + + + + + + { + "id": "plate_well_A1", + "name": "plate_well_A1", + + "children": [], + "parent": "plate_well", + "type": "device", + "class": "", + "position": { + "x": 10.1, + "y": 70, + "z": 6.1 + }, + "config": { + "type": "Well", + "size_x": 8.2, + "size_y": 8.2, + "size_z": 38, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "well", + "model": null, + "max_volume": 2000, + "material_z_thickness": null, + "compute_volume_from_height": null, + "compute_height_from_volume": null, + "bottom_type": "unknown", + "cross_section_type": "rectangle" + }, + "data": { + "liquids": [["water", 50.0]], + "pending_liquids": [["water", 50.0]], + "liquid_history": [] + } + }, + + + { + "id": "tube_rack", + "name": "tube_rack", + + "children": [ + "tube_rack_A1" + ], + "parent": "deck", + "type": "container", + "class": "tube_container", + "position": { + "x": 0, + "y": 127, + "z": 0 + }, + "config": { + "type": "Plate", + "size_x": 151, + "size_y": 75, + "size_z": 75, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "model": "tube_container", + "ordering": [ + "A1" + ] + }, + "data": {} + }, + + + { + "id": "tube_rack_A1", + "name": "tube_rack_A1", + + "children": [], + "parent": "tube_rack", + "type": "device", + "class": "", + "position": { + "x": 6, + "y": 38, + "z": 10 + }, + "config": { + "type": "Well", + "size_x": 34, + "size_y": 34, + "size_z": 117, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tube", + "model": null, + "max_volume": 2000, + "material_z_thickness": null, + "compute_volume_from_height": null, + "compute_height_from_volume": null, + "bottom_type": "unknown", + "cross_section_type": "rectangle" + }, + "data": { + "liquids": [["water", 50.0]], + "pending_liquids": [["water", 50.0]], + "liquid_history": [] + } + } + + + , + + + { + "id": "bottle_rack", + "name": "bottle_rack", + + "children": [ + "bottle_rack_A1" + ], + "parent": "deck", + "type": "container", + "class": "bottle_container", + "position": { + "x": 0, + "y": 0, + "z": 0 + }, + "config": { + "type": "Plate", + "size_x": 130, + "size_y": 117, + "size_z": 8, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tube_rack", + "model": "bottle_container", + "ordering": [ + "A1" + ] + }, + "data": {} + }, + + + { + "id": "bottle_rack_A1", + "name": "bottle_rack_A1", + + "children": [], + "parent": "bottle_rack", + "type": "device", + "class": "", + "position": { + "x": 25, + "y": 18.5, + "z": 8 + }, + "config": { + "type": "Well", + "size_x": 80, + "size_y": 80, + "size_z": 117, + "rotation": { + "x": 0, + "y": 0, + "z": 0, + "type": "Rotation" + }, + "category": "tube", + "model": null, + "max_volume": 2000, + "material_z_thickness": null, + "compute_volume_from_height": null, + "compute_height_from_volume": null, + "bottom_type": "unknown", + "cross_section_type": "rectangle" + }, + "data": { + "liquids": [["water", 50.0]], + "pending_liquids": [["water", 50.0]], + "liquid_history": [] + } + } + + + ], + "links": [] +} \ No newline at end of file diff --git a/test/experiments/test_moveit.json b/unilabos/test/experiments/test_moveit.json similarity index 100% rename from test/experiments/test_moveit.json rename to unilabos/test/experiments/test_moveit.json diff --git a/test/experiments/workshop.json b/unilabos/test/experiments/workshop.json similarity index 100% rename from test/experiments/workshop.json rename to unilabos/test/experiments/workshop.json diff --git a/unilabos/test/registry/__init__.py b/unilabos/test/registry/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/registry/example_devices.py b/unilabos/test/registry/example_devices.py similarity index 100% rename from test/registry/example_devices.py rename to unilabos/test/registry/example_devices.py diff --git a/unilabos/utils/environment_check.py b/unilabos/utils/environment_check.py index 66293e0e..3963b9ef 100644 --- a/unilabos/utils/environment_check.py +++ b/unilabos/utils/environment_check.py @@ -5,6 +5,7 @@ import argparse import importlib +import locale import subprocess import sys @@ -22,13 +23,33 @@ class EnvironmentChecker: "websockets": "websockets", "msgcenterpy": "msgcenterpy", "opentrons_shared_data": "opentrons_shared_data", + "typing_extensions": "typing_extensions", } # 特殊安装包(需要特殊处理的包) self.special_packages = {"pylabrobot": "git+https://github.com/Xuwznln/pylabrobot.git"} + # 包版本要求(包名: 最低版本) + self.version_requirements = { + "msgcenterpy": "0.1.5", # msgcenterpy 最低版本要求 + } + self.missing_packages = [] self.failed_installs = [] + self.packages_need_upgrade = [] + + # 检测系统语言 + self.is_chinese = self._is_chinese_locale() + + def _is_chinese_locale(self) -> bool: + """检测系统是否为中文环境""" + try: + lang = locale.getdefaultlocale()[0] + if lang and ("zh" in lang.lower() or "chinese" in lang.lower()): + return True + except Exception: + pass + return False def check_package_installed(self, package_name: str) -> bool: """检查包是否已安装""" @@ -38,31 +59,74 @@ class EnvironmentChecker: except ImportError: return False - def install_package(self, package_name: str, pip_name: str) -> bool: + def get_package_version(self, package_name: str) -> str | None: + """获取已安装包的版本""" + try: + module = importlib.import_module(package_name) + return getattr(module, "__version__", None) + except (ImportError, AttributeError): + return None + + def compare_version(self, current: str, required: str) -> bool: + """ + 比较版本号 + Returns: + True: current >= required + False: current < required + """ + try: + current_parts = [int(x) for x in current.split(".")] + required_parts = [int(x) for x in required.split(".")] + + # 补齐长度 + max_len = max(len(current_parts), len(required_parts)) + current_parts.extend([0] * (max_len - len(current_parts))) + required_parts.extend([0] * (max_len - len(required_parts))) + + return current_parts >= required_parts + except Exception: + return True # 如果无法比较,假设版本满足要求 + + def install_package(self, package_name: str, pip_name: str, upgrade: bool = False) -> bool: """安装包""" try: - print_status(f"正在安装 {package_name} ({pip_name})...", "info") + action = "升级" if upgrade else "安装" + print_status(f"正在{action} {package_name} ({pip_name})...", "info") # 构建安装命令 - cmd = [sys.executable, "-m", "pip", "install", pip_name] + cmd = [sys.executable, "-m", "pip", "install"] + + # 如果是升级操作,添加 --upgrade 参数 + if upgrade: + cmd.append("--upgrade") + + cmd.append(pip_name) + + # 如果是中文环境,使用清华镜像源 + if self.is_chinese: + cmd.extend(["-i", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"]) # 执行安装 result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) # 5分钟超时 if result.returncode == 0: - print_status(f"✓ {package_name} 安装成功", "success") + print_status(f"✓ {package_name} {action}成功", "success") return True else: - print_status(f"× {package_name} 安装失败: {result.stderr}", "error") + print_status(f"× {package_name} {action}失败: {result.stderr}", "error") return False except subprocess.TimeoutExpired: - print_status(f"× {package_name} 安装超时", "error") + print_status(f"× {package_name} {action}超时", "error") return False except Exception as e: - print_status(f"× {package_name} 安装异常: {str(e)}", "error") + print_status(f"× {package_name} {action}异常: {str(e)}", "error") return False + def upgrade_package(self, package_name: str, pip_name: str) -> bool: + """升级包""" + return self.install_package(package_name, pip_name, upgrade=True) + def check_all_packages(self) -> bool: """检查所有必需的包""" print_status("开始检查环境依赖...", "info") @@ -71,60 +135,116 @@ class EnvironmentChecker: for import_name, pip_name in self.required_packages.items(): if not self.check_package_installed(import_name): self.missing_packages.append((import_name, pip_name)) + else: + # 检查版本要求 + if import_name in self.version_requirements: + current_version = self.get_package_version(import_name) + required_version = self.version_requirements[import_name] + + if current_version: + if not self.compare_version(current_version, required_version): + print_status( + f"{import_name} 版本过低 (当前: {current_version}, 需要: >={required_version})", + "warning", + ) + self.packages_need_upgrade.append((import_name, pip_name)) # 检查特殊包 for package_name, install_url in self.special_packages.items(): if not self.check_package_installed(package_name): self.missing_packages.append((package_name, install_url)) - if not self.missing_packages: + all_ok = not self.missing_packages and not self.packages_need_upgrade + + if all_ok: print_status("✓ 所有依赖包检查完成,环境正常", "success") return True - print_status(f"发现 {len(self.missing_packages)} 个缺失的包", "warning") + if self.missing_packages: + print_status(f"发现 {len(self.missing_packages)} 个缺失的包", "warning") + if self.packages_need_upgrade: + print_status(f"发现 {len(self.packages_need_upgrade)} 个需要升级的包", "warning") + return False def install_missing_packages(self, auto_install: bool = True) -> bool: """安装缺失的包""" - if not self.missing_packages: + if not self.missing_packages and not self.packages_need_upgrade: return True if not auto_install: - print_status("缺失以下包:", "warning") - for import_name, pip_name in self.missing_packages: - print_status(f" - {import_name} (pip install {pip_name})", "warning") + if self.missing_packages: + print_status("缺失以下包:", "warning") + for import_name, pip_name in self.missing_packages: + print_status(f" - {import_name} (pip install {pip_name})", "warning") + if self.packages_need_upgrade: + print_status("需要升级以下包:", "warning") + for import_name, pip_name in self.packages_need_upgrade: + print_status(f" - {import_name} (pip install --upgrade {pip_name})", "warning") return False - print_status(f"开始自动安装 {len(self.missing_packages)} 个缺失的包...", "info") + # 安装缺失的包 + if self.missing_packages: + print_status(f"开始自动安装 {len(self.missing_packages)} 个缺失的包...", "info") - success_count = 0 - for import_name, pip_name in self.missing_packages: - if self.install_package(import_name, pip_name): - success_count += 1 - else: - self.failed_installs.append((import_name, pip_name)) + success_count = 0 + for import_name, pip_name in self.missing_packages: + if self.install_package(import_name, pip_name): + success_count += 1 + else: + self.failed_installs.append((import_name, pip_name)) + + print_status(f"✓ 成功安装 {success_count}/{len(self.missing_packages)} 个包", "success") + + # 升级需要更新的包 + if self.packages_need_upgrade: + print_status(f"开始自动升级 {len(self.packages_need_upgrade)} 个包...", "info") + + upgrade_success_count = 0 + for import_name, pip_name in self.packages_need_upgrade: + if self.upgrade_package(import_name, pip_name): + upgrade_success_count += 1 + else: + self.failed_installs.append((import_name, pip_name)) + + print_status(f"✓ 成功升级 {upgrade_success_count}/{len(self.packages_need_upgrade)} 个包", "success") if self.failed_installs: - print_status(f"有 {len(self.failed_installs)} 个包安装失败:", "error") + print_status(f"有 {len(self.failed_installs)} 个包操作失败:", "error") for import_name, pip_name in self.failed_installs: - print_status(f" - {import_name} (pip install {pip_name})", "error") + print_status(f" - {import_name} ({pip_name})", "error") return False - print_status(f"✓ 成功安装 {success_count} 个包", "success") return True def verify_installation(self) -> bool: """验证安装结果""" - if not self.missing_packages: + if not self.missing_packages and not self.packages_need_upgrade: return True print_status("验证安装结果...", "info") failed_verification = [] + + # 验证新安装的包 for import_name, pip_name in self.missing_packages: if not self.check_package_installed(import_name): failed_verification.append((import_name, pip_name)) + # 验证升级的包 + for import_name, pip_name in self.packages_need_upgrade: + if not self.check_package_installed(import_name): + failed_verification.append((import_name, pip_name)) + elif import_name in self.version_requirements: + current_version = self.get_package_version(import_name) + required_version = self.version_requirements[import_name] + if current_version and not self.compare_version(current_version, required_version): + failed_verification.append((import_name, pip_name)) + print_status( + f" {import_name} 版本仍然过低 (当前: {current_version}, 需要: >={required_version})", + "error", + ) + if failed_verification: print_status(f"有 {len(failed_verification)} 个包验证失败:", "error") for import_name, pip_name in failed_verification: diff --git a/unilabos/utils/import_manager.py b/unilabos/utils/import_manager.py index 4b873386..00fcd06b 100644 --- a/unilabos/utils/import_manager.py +++ b/unilabos/utils/import_manager.py @@ -239,8 +239,12 @@ class ImportManager: cls = get_class(class_path) class_name = cls.__name__ - result = {"class_name": class_name, "init_params": self._analyze_method_signature(cls.__init__)["args"], - "status_methods": {}, "action_methods": {}} + result = { + "class_name": class_name, + "init_params": self._analyze_method_signature(cls.__init__)["args"], + "status_methods": {}, + "action_methods": {}, + } # 分析类的所有成员 for name, method in cls.__dict__.items(): if name.startswith("_"): @@ -374,6 +378,7 @@ class ImportManager: "name": method.__name__, "args": args, "return_type": self._get_type_string(signature.return_annotation), + "return_annotation": signature.return_annotation, # 保留原始类型注解,用于TypedDict等特殊处理 "is_async": inspect.iscoroutinefunction(method), } diff --git a/unilabos/utils/log.py b/unilabos/utils/log.py index 9bf5cebf..14009e0c 100644 --- a/unilabos/utils/log.py +++ b/unilabos/utils/log.py @@ -124,11 +124,14 @@ class ColoredFormatter(logging.Formatter): 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] # 提取文件名(不含路径和扩展名) + 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}]" - formatted_message = f"{datetime_str} [{record.levelname}] [{module_path}] [{func_line}]: {record.getMessage()}" + formatted_message = f"{datetime_str} [{record.levelname}] {record.getMessage()}{right_info}" if record.exc_info: exc_text = self.formatException(record.exc_info) @@ -150,7 +153,7 @@ class ColoredFormatter(logging.Formatter): # 配置日志处理器 -def configure_logger(loglevel=None): +def configure_logger(loglevel=None, working_dir=None): """配置日志记录器 Args: @@ -159,8 +162,9 @@ def configure_logger(loglevel=None): """ # 获取根日志记录器 root_logger = logging.getLogger() - + root_logger.setLevel(TRACE_LEVEL) # 设置日志级别 + numeric_level = logging.DEBUG if loglevel is not None: if isinstance(loglevel, str): # 将字符串转换为logging级别 @@ -170,12 +174,8 @@ def configure_logger(loglevel=None): numeric_level = getattr(logging, loglevel.upper(), None) if not isinstance(numeric_level, int): print(f"警告: 无效的日志级别 '{loglevel}',使用默认级别 DEBUG") - numeric_level = logging.DEBUG else: numeric_level = loglevel - root_logger.setLevel(numeric_level) - else: - root_logger.setLevel(logging.DEBUG) # 默认级别 # 移除已存在的处理器 for handler in root_logger.handlers[:]: @@ -183,7 +183,7 @@ def configure_logger(loglevel=None): # 创建控制台处理器 console_handler = logging.StreamHandler() - console_handler.setLevel(root_logger.level) # 使用与根记录器相同的级别 + console_handler.setLevel(numeric_level) # 使用与根记录器相同的级别 # 使用自定义的颜色格式化器 color_formatter = ColoredFormatter() @@ -204,6 +204,28 @@ def configure_logger(loglevel=None): logging.getLogger('websockets.client').setLevel(logging.WARNING) logging.getLogger('websockets.server').setLevel(logging.WARNING) + # 如果指定了工作目录,添加文件处理器 + if working_dir is not None: + logs_dir = os.path.join(working_dir, "logs") + os.makedirs(logs_dir, exist_ok=True) + + # 生成日志文件名:日期 时间.log + log_filename = datetime.now().strftime("%Y-%m-%d %H-%M-%S") + ".log" + log_filepath = os.path.join(logs_dir, log_filename) + + # 创建文件处理器 + file_handler = logging.FileHandler(log_filepath, encoding="utf-8") + file_handler.setLevel(TRACE_LEVEL) + + # 使用不带颜色的格式化器 + file_formatter = ColoredFormatter(use_colors=False) + file_handler.setFormatter(file_formatter) + + root_logger.addHandler(file_handler) + + logging.getLogger("asyncio").setLevel(logging.INFO) + logging.getLogger("urllib3").setLevel(logging.INFO) + # 配置日志系统 configure_logger() diff --git a/unilabos/workflow/__init__.py b/unilabos/workflow/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/workflow/common.py b/unilabos/workflow/common.py new file mode 100644 index 00000000..9bff0494 --- /dev/null +++ b/unilabos/workflow/common.py @@ -0,0 +1,547 @@ +import re +import uuid + +import networkx as nx +from networkx.drawing.nx_agraph import to_agraph +import matplotlib.pyplot as plt +from typing import Dict, List, Any, Tuple, Optional + +Json = Dict[str, Any] + +# ---------------- Graph ---------------- + + +class WorkflowGraph: + """简单的有向图实现:使用 params 单层参数;inputs 内含连线;支持 node-link 导出""" + + def __init__(self): + self.nodes: Dict[str, Dict[str, Any]] = {} + self.edges: List[Dict[str, Any]] = [] + + def add_node(self, node_id: str, **attrs): + self.nodes[node_id] = attrs + + def add_edge(self, source: str, target: str, **attrs): + # 将 source_port/target_port 映射为服务端期望的 source_handle_key/target_handle_key + source_handle_key = attrs.pop("source_port", "") or attrs.pop("source_handle_key", "") + target_handle_key = attrs.pop("target_port", "") or attrs.pop("target_handle_key", "") + + edge = { + "source": source, + "target": target, + "source_node_uuid": source, + "target_node_uuid": target, + "source_handle_key": source_handle_key, + "source_handle_io": attrs.pop("source_handle_io", "source"), + "target_handle_key": target_handle_key, + "target_handle_io": attrs.pop("target_handle_io", "target"), + **attrs, + } + self.edges.append(edge) + + def _materialize_wiring_into_inputs( + self, + obj: Any, + inputs: Dict[str, Any], + variable_sources: Dict[str, Dict[str, Any]], + target_node_id: str, + base_path: List[str], + ): + has_var = False + + def walk(node: Any, path: List[str]): + nonlocal has_var + if isinstance(node, dict): + if "__var__" in node: + has_var = True + varname = node["__var__"] + placeholder = f"${{{varname}}}" + src = variable_sources.get(varname) + if src: + key = ".".join(path) # e.g. "params.foo.bar.0" + inputs[key] = {"node": src["node_id"], "output": src.get("output_name", "result")} + self.add_edge( + str(src["node_id"]), + target_node_id, + source_handle_io=src.get("output_name", "result"), + target_handle_io=key, + ) + return placeholder + return {k: walk(v, path + [k]) for k, v in node.items()} + if isinstance(node, list): + return [walk(v, path + [str(i)]) for i, v in enumerate(node)] + return node + + replaced = walk(obj, base_path[:]) + return replaced, has_var + + def add_workflow_node( + self, + node_id: int, + *, + device_key: Optional[str] = None, # 实例名,如 "ser" + resource_name: Optional[str] = None, # registry key(原 device_class) + module: Optional[str] = None, + template_name: Optional[str] = None, # 动作/模板名(原 action_key) + params: Dict[str, Any], + variable_sources: Dict[str, Dict[str, Any]], + add_ready_if_no_vars: bool = True, + prev_node_id: Optional[int] = None, + **extra_attrs, + ) -> None: + """添加工作流节点:params 单层;自动变量连线与 ready 串联;支持附加属性""" + node_id_str = str(node_id) + inputs: Dict[str, Any] = {} + + params, has_var = self._materialize_wiring_into_inputs( + params, inputs, variable_sources, node_id_str, base_path=["params"] + ) + + if add_ready_if_no_vars and not has_var: + last_id = str(prev_node_id) if prev_node_id is not None else "-1" + inputs["ready"] = {"node": int(last_id), "output": "ready"} + self.add_edge(last_id, node_id_str, source_handle_io="ready", target_handle_io="ready") + + node_obj = { + "device_key": device_key, + "resource_name": resource_name, # ✅ 新名字 + "module": module, + "template_name": template_name, # ✅ 新名字 + "params": params, + "inputs": inputs, + } + node_obj.update(extra_attrs or {}) + self.add_node(node_id_str, parameters=node_obj) + + # 顺序工作流导出(连线在 inputs,不返回 edges) + def to_dict(self) -> List[Dict[str, Any]]: + result = [] + for node_id, attrs in self.nodes.items(): + node = {"uuid": node_id} + params = dict(attrs.get("parameters", {}) or {}) + flat = {k: v for k, v in attrs.items() if k != "parameters"} + flat.update(params) + node.update(flat) + result.append(node) + return sorted(result, key=lambda n: int(n["uuid"]) if str(n["uuid"]).isdigit() else n["uuid"]) + + # node-link 导出(含 edges) + def to_node_link_dict(self) -> Dict[str, Any]: + nodes_list = [] + for node_id, attrs in self.nodes.items(): + node_attrs = attrs.copy() + params = node_attrs.pop("parameters", {}) or {} + node_attrs.update(params) + nodes_list.append({"uuid": node_id, **node_attrs}) + return { + "directed": True, + "multigraph": False, + "graph": {}, + "nodes": nodes_list, + "edges": self.edges, + "links": self.edges, + } + + +def refactor_data( + data: List[Dict[str, Any]], + action_resource_mapping: Optional[Dict[str, str]] = None, +) -> List[Dict[str, Any]]: + """统一的数据重构函数,根据操作类型自动选择模板 + + Args: + data: 原始步骤数据列表 + action_resource_mapping: action 到 resource_name 的映射字典,可选 + """ + refactored_data = [] + + # 定义操作映射,包含生物实验和有机化学的所有操作 + OPERATION_MAPPING = { + # 生物实验操作 + "transfer_liquid": "transfer_liquid", + "transfer": "transfer", + "incubation": "incubation", + "move_labware": "move_labware", + "oscillation": "oscillation", + # 有机化学操作 + "HeatChillToTemp": "HeatChillProtocol", + "StopHeatChill": "HeatChillStopProtocol", + "StartHeatChill": "HeatChillStartProtocol", + "HeatChill": "HeatChillProtocol", + "Dissolve": "DissolveProtocol", + "Transfer": "TransferProtocol", + "Evaporate": "EvaporateProtocol", + "Recrystallize": "RecrystallizeProtocol", + "Filter": "FilterProtocol", + "Dry": "DryProtocol", + "Add": "AddProtocol", + } + + UNSUPPORTED_OPERATIONS = ["Purge", "Wait", "Stir", "ResetHandling"] + + for step in data: + operation = step.get("action") + if not operation or operation in UNSUPPORTED_OPERATIONS: + continue + + # 处理重复操作 + if operation == "Repeat": + times = step.get("times", step.get("parameters", {}).get("times", 1)) + sub_steps = step.get("steps", step.get("parameters", {}).get("steps", [])) + for i in range(int(times)): + sub_data = refactor_data(sub_steps, action_resource_mapping) + refactored_data.extend(sub_data) + continue + + # 获取模板名称 + template_name = OPERATION_MAPPING.get(operation) + if not template_name: + # 自动推断模板类型 + if operation.lower() in ["transfer", "incubation", "move_labware", "oscillation"]: + template_name = f"biomek-{operation}" + else: + template_name = f"{operation}Protocol" + + # 获取 resource_name + resource_name = f"device.{operation.lower()}" + if action_resource_mapping: + resource_name = action_resource_mapping.get(operation, resource_name) + + # 获取步骤编号,生成 name 字段 + step_number = step.get("step_number") + name = f"Step {step_number}" if step_number is not None else None + + # 创建步骤数据 + step_data = { + "template_name": template_name, + "resource_name": resource_name, + "description": step.get("description", step.get("purpose", f"{operation} operation")), + "lab_node_type": "Device", + "param": step.get("parameters", step.get("action_args", {})), + "footer": f"{template_name}-{resource_name}", + } + if name: + step_data["name"] = name + refactored_data.append(step_data) + + return refactored_data + + +def build_protocol_graph( + labware_info: List[Dict[str, Any]], + protocol_steps: List[Dict[str, Any]], + workstation_name: str, + action_resource_mapping: Optional[Dict[str, str]] = None, +) -> WorkflowGraph: + """统一的协议图构建函数,根据设备类型自动选择构建逻辑 + + Args: + labware_info: labware 信息字典 + protocol_steps: 协议步骤列表 + workstation_name: 工作站名称 + action_resource_mapping: action 到 resource_name 的映射字典,可选 + """ + G = WorkflowGraph() + resource_last_writer = {} + + protocol_steps = refactor_data(protocol_steps, action_resource_mapping) + # 有机化学&移液站协议图构建 + WORKSTATION_ID = workstation_name + + # 为所有labware创建资源节点 + res_index = 0 + for labware_id, item in labware_info.items(): + # item_id = item.get("id") or item.get("name", f"item_{uuid.uuid4()}") + node_id = str(uuid.uuid4()) + + # 判断节点类型 + if "Rack" in str(labware_id) or "Tip" in str(labware_id): + lab_node_type = "Labware" + description = f"Prepare Labware: {labware_id}" + liquid_type = [] + liquid_volume = [] + elif item.get("type") == "hardware" or "reactor" in str(labware_id).lower(): + if "reactor" not in str(labware_id).lower(): + continue + lab_node_type = "Sample" + description = f"Prepare Reactor: {labware_id}" + liquid_type = [] + liquid_volume = [] + else: + lab_node_type = "Reagent" + description = f"Add Reagent to Flask: {labware_id}" + liquid_type = [labware_id] + liquid_volume = [1e5] + + res_index += 1 + G.add_node( + node_id, + template_name="create_resource", + resource_name="host_node", + name=f"Res {res_index}", + description=description, + lab_node_type=lab_node_type, + footer="create_resource-host_node", + param={ + "res_id": labware_id, + "device_id": WORKSTATION_ID, + "class_name": "container", + "parent": WORKSTATION_ID, + "bind_locations": {"x": 0.0, "y": 0.0, "z": 0.0}, + "liquid_input_slot": [-1], + "liquid_type": liquid_type, + "liquid_volume": liquid_volume, + "slot_on_deck": "", + }, + ) + resource_last_writer[labware_id] = f"{node_id}:labware" + + last_control_node_id = None + + # 处理协议步骤 + for step in protocol_steps: + node_id = str(uuid.uuid4()) + G.add_node(node_id, **step) + + # 控制流 + if last_control_node_id is not None: + G.add_edge(last_control_node_id, node_id, source_port="ready", target_port="ready") + last_control_node_id = node_id + + # 物料流 + params = step.get("param", {}) + input_resources_possible_names = [ + "vessel", + "to_vessel", + "from_vessel", + "reagent", + "solvent", + "compound", + "sources", + "targets", + ] + + for target_port in input_resources_possible_names: + resource_name = params.get(target_port) + if resource_name and resource_name in resource_last_writer: + source_node, source_port = resource_last_writer[resource_name].split(":") + G.add_edge(source_node, node_id, source_port=source_port, target_port=target_port) + + output_resources = { + "vessel_out": params.get("vessel"), + "from_vessel_out": params.get("from_vessel"), + "to_vessel_out": params.get("to_vessel"), + "filtrate_out": params.get("filtrate_vessel"), + "reagent": params.get("reagent"), + "solvent": params.get("solvent"), + "compound": params.get("compound"), + "sources_out": params.get("sources"), + "targets_out": params.get("targets"), + } + + for source_port, resource_name in output_resources.items(): + if resource_name: + resource_last_writer[resource_name] = f"{node_id}:{source_port}" + + return G + + +def draw_protocol_graph(protocol_graph: WorkflowGraph, output_path: str): + """ + (辅助功能) 使用 networkx 和 matplotlib 绘制协议工作流图,用于可视化。 + """ + if not protocol_graph: + print("Cannot draw graph: Graph object is empty.") + return + + G = nx.DiGraph() + + for node_id, attrs in protocol_graph.nodes.items(): + label = attrs.get("description", attrs.get("template_name", node_id[:8])) + G.add_node(node_id, label=label, **attrs) + + for edge in protocol_graph.edges: + G.add_edge(edge["source"], edge["target"]) + + plt.figure(figsize=(20, 15)) + try: + pos = nx.nx_agraph.graphviz_layout(G, prog="dot") + except Exception: + pos = nx.shell_layout(G) # Fallback layout + + node_labels = {node: data["label"] for node, data in G.nodes(data=True)} + nx.draw( + G, + pos, + with_labels=False, + node_size=2500, + node_color="skyblue", + node_shape="o", + edge_color="gray", + width=1.5, + arrowsize=15, + ) + nx.draw_networkx_labels(G, pos, labels=node_labels, font_size=8, font_weight="bold") + + plt.title("Chemical Protocol Workflow Graph", size=15) + plt.savefig(output_path, dpi=300, bbox_inches="tight") + plt.close() + print(f" - Visualization saved to '{output_path}'") + + +COMPASS = {"n", "e", "s", "w", "ne", "nw", "se", "sw", "c"} + + +def _is_compass(port: str) -> bool: + return isinstance(port, str) and port.lower() in COMPASS + + +def draw_protocol_graph_with_ports(protocol_graph, output_path: str, rankdir: str = "LR"): + """ + 使用 Graphviz 端口语法绘制协议工作流图。 + - 若边上的 source_port/target_port 是 compass(n/e/s/w/...),直接用 compass。 + - 否则自动为节点创建 record 形状并定义命名端口 。 + 最终由 PyGraphviz 渲染并输出到 output_path(后缀决定格式,如 .png/.svg/.pdf)。 + """ + if not protocol_graph: + print("Cannot draw graph: Graph object is empty.") + return + + # 1) 先用 networkx 搭建有向图,保留端口属性 + G = nx.DiGraph() + for node_id, attrs in protocol_graph.nodes.items(): + label = attrs.get("description", attrs.get("template_name", node_id[:8])) + # 保留一个干净的“中心标签”,用于放在 record 的中间槽 + G.add_node(node_id, _core_label=str(label), **{k: v for k, v in attrs.items() if k not in ("label",)}) + + edges_data = [] + in_ports_by_node = {} # 收集命名输入端口 + out_ports_by_node = {} # 收集命名输出端口 + + for edge in protocol_graph.edges: + u = edge["source"] + v = edge["target"] + sp = edge.get("source_handle_key") or edge.get("source_port") + tp = edge.get("target_handle_key") or edge.get("target_port") + + # 记录到图里(保留原始端口信息) + G.add_edge(u, v, source_handle_key=sp, target_handle_key=tp) + edges_data.append((u, v, sp, tp)) + + # 如果不是 compass,就按“命名端口”先归类,等会儿给节点造 record + if sp and not _is_compass(sp): + out_ports_by_node.setdefault(u, set()).add(str(sp)) + if tp and not _is_compass(tp): + in_ports_by_node.setdefault(v, set()).add(str(tp)) + + # 2) 转为 AGraph,使用 Graphviz 渲染 + A = to_agraph(G) + A.graph_attr.update(rankdir=rankdir, splines="true", concentrate="false", fontsize="10") + A.node_attr.update( + shape="box", style="rounded,filled", fillcolor="lightyellow", color="#999999", fontname="Helvetica" + ) + A.edge_attr.update(arrowsize="0.8", color="#666666") + + # 3) 为需要命名端口的节点设置 record 形状与 label + # 左列 = 输入端口;中间 = 核心标签;右列 = 输出端口 + for n in A.nodes(): + node = A.get_node(n) + core = G.nodes[n].get("_core_label", n) + + in_ports = sorted(in_ports_by_node.get(n, [])) + out_ports = sorted(out_ports_by_node.get(n, [])) + + # 如果该节点涉及命名端口,则用 record;否则保留原 box + if in_ports or out_ports: + + def port_fields(ports): + if not ports: + return " " # 必须留一个空槽占位 + # 每个端口一个小格子,

name + return "|".join(f"<{re.sub(r'[^A-Za-z0-9_:.|-]', '_', p)}> {p}" for p in ports) + + left = port_fields(in_ports) + right = port_fields(out_ports) + + # 三栏:左(入) | 中(节点名) | 右(出) + record_label = f"{{ {left} | {core} | {right} }}" + node.attr.update(shape="record", label=record_label) + else: + # 没有命名端口:普通盒子,显示核心标签 + node.attr.update(label=str(core)) + + # 4) 给边设置 headport / tailport + # - 若端口为 compass:直接用 compass(e.g., headport="e") + # - 若端口为命名端口:使用在 record 中定义的 名(同名即可) + for u, v, sp, tp in edges_data: + e = A.get_edge(u, v) + + # Graphviz 属性:tail 是源,head 是目标 + if sp: + if _is_compass(sp): + e.attr["tailport"] = sp.lower() + else: + # 与 record label 中 名一致;特殊字符已在 label 中做了清洗 + e.attr["tailport"] = re.sub(r"[^A-Za-z0-9_:.|-]", "_", str(sp)) + + if tp: + if _is_compass(tp): + e.attr["headport"] = tp.lower() + else: + e.attr["headport"] = re.sub(r"[^A-Za-z0-9_:.|-]", "_", str(tp)) + + # 可选:若想让边更贴边缘,可设置 constraint/spline 等 + # e.attr["arrowhead"] = "vee" + + # 5) 输出 + A.draw(output_path, prog="dot") + print(f" - Port-aware workflow rendered to '{output_path}'") + + +# ---------------- Registry Adapter ---------------- + + +class RegistryAdapter: + """根据 module 的类名(冒号右侧)反查 registry 的 resource_name(原 device_class),并抽取参数顺序""" + + def __init__(self, device_registry: Dict[str, Any]): + self.device_registry = device_registry or {} + self.module_class_to_resource = self._build_module_class_index() + + def _build_module_class_index(self) -> Dict[str, str]: + idx = {} + for resource_name, info in self.device_registry.items(): + module = info.get("module") + if isinstance(module, str) and ":" in module: + cls = module.split(":")[-1] + idx[cls] = resource_name + idx[cls.lower()] = resource_name + return idx + + def resolve_resource_by_classname(self, class_name: str) -> Optional[str]: + if not class_name: + return None + return self.module_class_to_resource.get(class_name) or self.module_class_to_resource.get(class_name.lower()) + + def get_device_module(self, resource_name: Optional[str]) -> Optional[str]: + if not resource_name: + return None + return self.device_registry.get(resource_name, {}).get("module") + + def get_actions(self, resource_name: Optional[str]) -> Dict[str, Any]: + if not resource_name: + return {} + return (self.device_registry.get(resource_name, {}).get("class", {}).get("action_value_mappings", {})) or {} + + def get_action_schema(self, resource_name: Optional[str], template_name: str) -> Optional[Json]: + return (self.get_actions(resource_name).get(template_name) or {}).get("schema") + + def get_action_goal_default(self, resource_name: Optional[str], template_name: str) -> Json: + return (self.get_actions(resource_name).get(template_name) or {}).get("goal_default", {}) or {} + + def get_action_input_keys(self, resource_name: Optional[str], template_name: str) -> List[str]: + schema = self.get_action_schema(resource_name, template_name) or {} + goal = (schema.get("properties") or {}).get("goal") or {} + props = goal.get("properties") or {} + required = goal.get("required") or [] + return list(dict.fromkeys(required + list(props.keys()))) diff --git a/unilabos/workflow/convert_from_json.py b/unilabos/workflow/convert_from_json.py new file mode 100644 index 00000000..7a6d2b40 --- /dev/null +++ b/unilabos/workflow/convert_from_json.py @@ -0,0 +1,356 @@ +""" +JSON 工作流转换模块 + +提供从多种 JSON 格式转换为统一工作流格式的功能。 +支持的格式: +1. workflow/reagent 格式 +2. steps_info/labware_info 格式 +""" + +import json +from os import PathLike +from pathlib import Path +from typing import Any, Dict, List, Optional, Set, Tuple, Union + +from unilabos.workflow.common import WorkflowGraph, build_protocol_graph +from unilabos.registry.registry import lab_registry + + +def get_action_handles(resource_name: str, template_name: str) -> Dict[str, List[str]]: + """ + 从 registry 获取指定设备和动作的 handles 配置 + + Args: + resource_name: 设备资源名称,如 "liquid_handler.prcxi" + template_name: 动作模板名称,如 "transfer_liquid" + + Returns: + 包含 source 和 target handler_keys 的字典: + {"source": ["sources_out", "targets_out", ...], "target": ["sources", "targets", ...]} + """ + result = {"source": [], "target": []} + + device_info = lab_registry.device_type_registry.get(resource_name, {}) + if not device_info: + return result + + action_mappings = device_info.get("class", {}).get("action_value_mappings", {}) + action_config = action_mappings.get(template_name, {}) + handles = action_config.get("handles", {}) + + if isinstance(handles, dict): + # 处理 input handles (作为 target) + for handle in handles.get("input", []): + handler_key = handle.get("handler_key", "") + if handler_key: + result["source"].append(handler_key) + # 处理 output handles (作为 source) + for handle in handles.get("output", []): + handler_key = handle.get("handler_key", "") + if handler_key: + result["target"].append(handler_key) + + return result + + +def validate_workflow_handles(graph: WorkflowGraph) -> Tuple[bool, List[str]]: + """ + 校验工作流图中所有边的句柄配置是否正确 + + Args: + graph: 工作流图对象 + + Returns: + (is_valid, errors): 是否有效,错误信息列表 + """ + errors = [] + nodes = graph.nodes + + for edge in graph.edges: + left_uuid = edge.get("source") + right_uuid = edge.get("target") + # target_handle_key是target, right的输入节点(入节点) + # source_handle_key是source, left的输出节点(出节点) + right_source_conn_key = edge.get("target_handle_key", "") + left_target_conn_key = edge.get("source_handle_key", "") + + # 获取源节点和目标节点信息 + left_node = nodes.get(left_uuid, {}) + right_node = nodes.get(right_uuid, {}) + + left_res_name = left_node.get("resource_name", "") + left_template_name = left_node.get("template_name", "") + right_res_name = right_node.get("resource_name", "") + right_template_name = right_node.get("template_name", "") + + # 获取源节点的 output handles + left_node_handles = get_action_handles(left_res_name, left_template_name) + target_valid_keys = left_node_handles.get("target", []) + target_valid_keys.append("ready") + + # 获取目标节点的 input handles + right_node_handles = get_action_handles(right_res_name, right_template_name) + source_valid_keys = right_node_handles.get("source", []) + source_valid_keys.append("ready") + + # 如果节点配置了 output handles,则 source_port 必须有效 + if not right_source_conn_key: + node_name = left_node.get("name", left_uuid[:8]) + errors.append(f"源节点 '{node_name}' 的 source_handle_key 为空," f"应设置为: {source_valid_keys}") + elif right_source_conn_key not in source_valid_keys: + node_name = left_node.get("name", left_uuid[:8]) + errors.append( + f"源节点 '{node_name}' 的 source 端点 '{right_source_conn_key}' 不存在," f"支持的端点: {source_valid_keys}" + ) + + # 如果节点配置了 input handles,则 target_port 必须有效 + if not left_target_conn_key: + node_name = right_node.get("name", right_uuid[:8]) + errors.append(f"目标节点 '{node_name}' 的 target_handle_key 为空," f"应设置为: {target_valid_keys}") + elif left_target_conn_key not in target_valid_keys: + node_name = right_node.get("name", right_uuid[:8]) + errors.append( + f"目标节点 '{node_name}' 的 target 端点 '{left_target_conn_key}' 不存在," + f"支持的端点: {target_valid_keys}" + ) + + return len(errors) == 0, errors + + +# action 到 resource_name 的映射 +ACTION_RESOURCE_MAPPING: Dict[str, str] = { + # 生物实验操作 + "transfer_liquid": "liquid_handler.prcxi", + "transfer": "liquid_handler.prcxi", + "incubation": "incubator.prcxi", + "move_labware": "labware_mover.prcxi", + "oscillation": "shaker.prcxi", + # 有机化学操作 + "HeatChillToTemp": "heatchill.chemputer", + "StopHeatChill": "heatchill.chemputer", + "StartHeatChill": "heatchill.chemputer", + "HeatChill": "heatchill.chemputer", + "Dissolve": "stirrer.chemputer", + "Transfer": "liquid_handler.chemputer", + "Evaporate": "rotavap.chemputer", + "Recrystallize": "reactor.chemputer", + "Filter": "filter.chemputer", + "Dry": "dryer.chemputer", + "Add": "liquid_handler.chemputer", +} + + +def normalize_steps(data: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + 将不同格式的步骤数据规范化为统一格式 + + 支持的输入格式: + - action + parameters + - action + action_args + - operation + parameters + + Args: + data: 原始步骤数据列表 + + Returns: + 规范化后的步骤列表,格式为 [{"action": str, "parameters": dict, "description": str?, "step_number": int?}, ...] + """ + normalized = [] + for idx, step in enumerate(data): + # 获取动作名称(支持 action 或 operation 字段) + action = step.get("action") or step.get("operation") + if not action: + continue + + # 获取参数(支持 parameters 或 action_args 字段) + raw_params = step.get("parameters") or step.get("action_args") or {} + params = dict(raw_params) + + # 规范化 source/target -> sources/targets + if "source" in raw_params and "sources" not in raw_params: + params["sources"] = raw_params["source"] + if "target" in raw_params and "targets" not in raw_params: + params["targets"] = raw_params["target"] + + # 获取描述(支持 description 或 purpose 字段) + description = step.get("description") or step.get("purpose") + + # 获取步骤编号(优先使用原始数据中的 step_number,否则使用索引+1) + step_number = step.get("step_number", idx + 1) + + step_dict = {"action": action, "parameters": params, "step_number": step_number} + if description: + step_dict["description"] = description + + normalized.append(step_dict) + + return normalized + + +def normalize_labware(data: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]: + """ + 将不同格式的 labware 数据规范化为统一的字典格式 + + 支持的输入格式: + - reagent_name + material_name + positions + - name + labware + slot + + Args: + data: 原始 labware 数据列表 + + Returns: + 规范化后的 labware 字典,格式为 {name: {"slot": int, "labware": str, "well": list, "type": str, "role": str, "name": str}, ...} + """ + labware = {} + for item in data: + # 获取 key 名称(优先使用 reagent_name,其次是 material_name 或 name) + reagent_name = item.get("reagent_name") + key = reagent_name or item.get("material_name") or item.get("name") + if not key: + continue + + key = str(key) + + # 处理重复 key,自动添加后缀 + idx = 1 + original_key = key + while key in labware: + idx += 1 + key = f"{original_key}_{idx}" + + labware[key] = { + "slot": item.get("positions") or item.get("slot"), + "labware": item.get("material_name") or item.get("labware"), + "well": item.get("well", []), + "type": item.get("type", "reagent"), + "role": item.get("role", ""), + "name": key, + } + + return labware + + +def convert_from_json( + data: Union[str, PathLike, Dict[str, Any]], + workstation_name: str = "PRCXi", + validate: bool = True, +) -> WorkflowGraph: + """ + 从 JSON 数据或文件转换为 WorkflowGraph + + 支持的 JSON 格式: + 1. {"workflow": [...], "reagent": {...}} - 直接格式 + 2. {"steps_info": [...], "labware_info": [...]} - 需要规范化的格式 + + Args: + data: JSON 文件路径、字典数据、或 JSON 字符串 + workstation_name: 工作站名称,默认 "PRCXi" + validate: 是否校验句柄配置,默认 True + + Returns: + WorkflowGraph: 构建好的工作流图 + + Raises: + ValueError: 不支持的 JSON 格式 或 句柄校验失败 + FileNotFoundError: 文件不存在 + json.JSONDecodeError: JSON 解析失败 + """ + # 处理输入数据 + if isinstance(data, (str, PathLike)): + path = Path(data) + if path.exists(): + with path.open("r", encoding="utf-8") as fp: + json_data = json.load(fp) + elif isinstance(data, str): + # 尝试作为 JSON 字符串解析 + json_data = json.loads(data) + else: + raise FileNotFoundError(f"文件不存在: {data}") + elif isinstance(data, dict): + json_data = data + else: + raise TypeError(f"不支持的数据类型: {type(data)}") + + # 根据格式解析数据 + if "workflow" in json_data and "reagent" in json_data: + # 格式1: workflow/reagent(已经是规范格式) + protocol_steps = json_data["workflow"] + labware_info = json_data["reagent"] + elif "steps_info" in json_data and "labware_info" in json_data: + # 格式2: steps_info/labware_info(需要规范化) + protocol_steps = normalize_steps(json_data["steps_info"]) + labware_info = normalize_labware(json_data["labware_info"]) + elif "steps" in json_data and "labware" in json_data: + # 格式3: steps/labware(另一种常见格式) + protocol_steps = normalize_steps(json_data["steps"]) + if isinstance(json_data["labware"], list): + labware_info = normalize_labware(json_data["labware"]) + else: + labware_info = json_data["labware"] + else: + raise ValueError( + "不支持的 JSON 格式。支持的格式:\n" + "1. {'workflow': [...], 'reagent': {...}}\n" + "2. {'steps_info': [...], 'labware_info': [...]}\n" + "3. {'steps': [...], 'labware': [...]}" + ) + + # 构建工作流图 + graph = build_protocol_graph( + labware_info=labware_info, + protocol_steps=protocol_steps, + workstation_name=workstation_name, + action_resource_mapping=ACTION_RESOURCE_MAPPING, + ) + + # 校验句柄配置 + if validate: + is_valid, errors = validate_workflow_handles(graph) + if not is_valid: + import warnings + + for error in errors: + warnings.warn(f"句柄校验警告: {error}") + + return graph + + +def convert_json_to_node_link( + data: Union[str, PathLike, Dict[str, Any]], + workstation_name: str = "PRCXi", +) -> Dict[str, Any]: + """ + 将 JSON 数据转换为 node-link 格式的字典 + + Args: + data: JSON 文件路径、字典数据、或 JSON 字符串 + workstation_name: 工作站名称,默认 "PRCXi" + + Returns: + Dict: node-link 格式的工作流数据 + """ + graph = convert_from_json(data, workstation_name) + return graph.to_node_link_dict() + + +def convert_json_to_workflow_list( + data: Union[str, PathLike, Dict[str, Any]], + workstation_name: str = "PRCXi", +) -> List[Dict[str, Any]]: + """ + 将 JSON 数据转换为工作流列表格式 + + Args: + data: JSON 文件路径、字典数据、或 JSON 字符串 + workstation_name: 工作站名称,默认 "PRCXi" + + Returns: + List: 工作流节点列表 + """ + graph = convert_from_json(data, workstation_name) + return graph.to_dict() + + +# 为了向后兼容,保留下划线前缀的别名 +_normalize_steps = normalize_steps +_normalize_labware = normalize_labware diff --git a/unilabos/workflow/from_python_script.py b/unilabos/workflow/from_python_script.py new file mode 100644 index 00000000..5a8ce38e --- /dev/null +++ b/unilabos/workflow/from_python_script.py @@ -0,0 +1,241 @@ +import ast +import json +from typing import Dict, List, Any, Tuple, Optional + +from .common import WorkflowGraph, RegistryAdapter + +Json = Dict[str, Any] + +# ---------------- Converter ---------------- + +class DeviceMethodConverter: + """ + - 字段统一:resource_name(原 device_class)、template_name(原 action_key) + - params 单层;inputs 使用 'params.' 前缀 + - SimpleGraph.add_workflow_node 负责变量连线与边 + """ + def __init__(self, device_registry: Optional[Dict[str, Any]] = None): + self.graph = WorkflowGraph() + self.variable_sources: Dict[str, Dict[str, Any]] = {} # var -> {node_id, output_name} + self.instance_to_resource: Dict[str, Optional[str]] = {} # 实例名 -> resource_name + self.node_id_counter: int = 0 + self.registry = RegistryAdapter(device_registry or {}) + + # ---- helpers ---- + def _new_node_id(self) -> int: + nid = self.node_id_counter + self.node_id_counter += 1 + return nid + + def _assign_targets(self, targets) -> List[str]: + names: List[str] = [] + import ast + if isinstance(targets, ast.Tuple): + for elt in targets.elts: + if isinstance(elt, ast.Name): + names.append(elt.id) + elif isinstance(targets, ast.Name): + names.append(targets.id) + return names + + def _extract_device_instantiation(self, node) -> Optional[Tuple[str, str]]: + import ast + if not isinstance(node.value, ast.Call): + return None + callee = node.value.func + if isinstance(callee, ast.Name): + class_name = callee.id + elif isinstance(callee, ast.Attribute) and isinstance(callee.value, ast.Name): + class_name = callee.attr + else: + return None + if isinstance(node.targets[0], ast.Name): + instance = node.targets[0].id + return instance, class_name + return None + + def _extract_call(self, call) -> Tuple[str, str, Dict[str, Any], str]: + import ast + owner_name, method_name, call_kind = "", "", "func" + if isinstance(call.func, ast.Attribute): + method_name = call.func.attr + if isinstance(call.func.value, ast.Name): + owner_name = call.func.value.id + call_kind = "instance" if owner_name in self.instance_to_resource else "class_or_module" + elif isinstance(call.func.value, ast.Attribute) and isinstance(call.func.value.value, ast.Name): + owner_name = call.func.value.attr + call_kind = "class_or_module" + elif isinstance(call.func, ast.Name): + method_name = call.func.id + call_kind = "func" + + def pack(node): + if isinstance(node, ast.Name): + return {"type": "variable", "value": node.id} + if isinstance(node, ast.Constant): + return {"type": "constant", "value": node.value} + if isinstance(node, ast.Dict): + return {"type": "dict", "value": self._parse_dict(node)} + if isinstance(node, ast.List): + return {"type": "list", "value": self._parse_list(node)} + return {"type": "raw", "value": ast.unparse(node) if hasattr(ast, "unparse") else str(node)} + + args: Dict[str, Any] = {} + pos: List[Any] = [] + for a in call.args: + pos.append(pack(a)) + for kw in call.keywords: + args[kw.arg] = pack(kw.value) + if pos: + args["_positional"] = pos + return owner_name, method_name, args, call_kind + + def _parse_dict(self, node) -> Dict[str, Any]: + import ast + out: Dict[str, Any] = {} + for k, v in zip(node.keys, node.values): + if isinstance(k, ast.Constant): + key = str(k.value) + if isinstance(v, ast.Name): + out[key] = f"var:{v.id}" + elif isinstance(v, ast.Constant): + out[key] = v.value + elif isinstance(v, ast.Dict): + out[key] = self._parse_dict(v) + elif isinstance(v, ast.List): + out[key] = self._parse_list(v) + return out + + def _parse_list(self, node) -> List[Any]: + import ast + out: List[Any] = [] + for elt in node.elts: + if isinstance(elt, ast.Name): + out.append(f"var:{elt.id}") + elif isinstance(elt, ast.Constant): + out.append(elt.value) + elif isinstance(elt, ast.Dict): + out.append(self._parse_dict(elt)) + elif isinstance(elt, ast.List): + out.append(self._parse_list(elt)) + return out + + def _normalize_var_tokens(self, x: Any) -> Any: + if isinstance(x, str) and x.startswith("var:"): + return {"__var__": x[4:]} + if isinstance(x, list): + return [self._normalize_var_tokens(i) for i in x] + if isinstance(x, dict): + return {k: self._normalize_var_tokens(v) for k, v in x.items()} + return x + + def _make_params_payload(self, resource_name: Optional[str], template_name: str, call_args: Dict[str, Any]) -> Dict[str, Any]: + input_keys = self.registry.get_action_input_keys(resource_name, template_name) if resource_name else [] + defaults = self.registry.get_action_goal_default(resource_name, template_name) if resource_name else {} + params: Dict[str, Any] = dict(defaults) + + def unpack(p): + t, v = p.get("type"), p.get("value") + if t == "variable": + return {"__var__": v} + if t == "dict": + return self._normalize_var_tokens(v) + if t == "list": + return self._normalize_var_tokens(v) + return v + + for k, p in call_args.items(): + if k == "_positional": + continue + params[k] = unpack(p) + + pos = call_args.get("_positional", []) + if pos: + if input_keys: + for i, p in enumerate(pos): + if i >= len(input_keys): + break + name = input_keys[i] + if name in params: + continue + params[name] = unpack(p) + else: + for i, p in enumerate(pos): + params[f"arg_{i}"] = unpack(p) + return params + + # ---- handlers ---- + def _on_assign(self, stmt): + import ast + inst = self._extract_device_instantiation(stmt) + if inst: + instance, code_class = inst + resource_name = self.registry.resolve_resource_by_classname(code_class) + self.instance_to_resource[instance] = resource_name + return + + if isinstance(stmt.value, ast.Call): + owner, method, call_args, kind = self._extract_call(stmt.value) + if kind == "instance": + device_key = owner + resource_name = self.instance_to_resource.get(owner) + else: + device_key = owner + resource_name = self.registry.resolve_resource_by_classname(owner) + + module = self.registry.get_device_module(resource_name) + params = self._make_params_payload(resource_name, method, call_args) + + nid = self._new_node_id() + self.graph.add_workflow_node( + nid, + device_key=device_key, + resource_name=resource_name, # ✅ + module=module, + template_name=method, # ✅ + params=params, + variable_sources=self.variable_sources, + add_ready_if_no_vars=True, + prev_node_id=(nid - 1) if nid > 0 else None, + ) + + out_vars = self._assign_targets(stmt.targets[0]) + for var in out_vars: + self.variable_sources[var] = {"node_id": nid, "output_name": "result"} + + def _on_expr(self, stmt): + import ast + if not isinstance(stmt.value, ast.Call): + return + owner, method, call_args, kind = self._extract_call(stmt.value) + if kind == "instance": + device_key = owner + resource_name = self.instance_to_resource.get(owner) + else: + device_key = owner + resource_name = self.registry.resolve_resource_by_classname(owner) + + module = self.registry.get_device_module(resource_name) + params = self._make_params_payload(resource_name, method, call_args) + + nid = self._new_node_id() + self.graph.add_workflow_node( + nid, + device_key=device_key, + resource_name=resource_name, # ✅ + module=module, + template_name=method, # ✅ + params=params, + variable_sources=self.variable_sources, + add_ready_if_no_vars=True, + prev_node_id=(nid - 1) if nid > 0 else None, + ) + + def convert(self, python_code: str): + tree = ast.parse(python_code) + for stmt in tree.body: + if isinstance(stmt, ast.Assign): + self._on_assign(stmt) + elif isinstance(stmt, ast.Expr): + self._on_expr(stmt) + return self diff --git a/unilabos/workflow/from_xdl.py b/unilabos/workflow/from_xdl.py new file mode 100644 index 00000000..1041f9ad --- /dev/null +++ b/unilabos/workflow/from_xdl.py @@ -0,0 +1,131 @@ +from typing import List, Any, Dict +import xml.etree.ElementTree as ET + + +def convert_to_type(val: str) -> Any: + """将字符串值转换为适当的数据类型""" + if val == "True": + return True + if val == "False": + return False + if val == "?": + return None + if val.endswith(" g"): + return float(val.split(" ")[0]) + if val.endswith("mg"): + return float(val.split("mg")[0]) + elif val.endswith("mmol"): + return float(val.split("mmol")[0]) / 1000 + elif val.endswith("mol"): + return float(val.split("mol")[0]) + elif val.endswith("ml"): + return float(val.split("ml")[0]) + elif val.endswith("RPM"): + return float(val.split("RPM")[0]) + elif val.endswith(" °C"): + return float(val.split(" ")[0]) + elif val.endswith(" %"): + return float(val.split(" ")[0]) + return val + + +def flatten_xdl_procedure(procedure_elem: ET.Element) -> List[ET.Element]: + """展平嵌套的XDL程序结构""" + flattened_operations = [] + TEMP_UNSUPPORTED_PROTOCOL = ["Purge", "Wait", "Stir", "ResetHandling"] + + def extract_operations(element: ET.Element): + if element.tag not in ["Prep", "Reaction", "Workup", "Purification", "Procedure"]: + if element.tag not in TEMP_UNSUPPORTED_PROTOCOL: + flattened_operations.append(element) + + for child in element: + extract_operations(child) + + for child in procedure_elem: + extract_operations(child) + + return flattened_operations + + +def parse_xdl_content(xdl_content: str) -> tuple: + """解析XDL内容""" + try: + xdl_content_cleaned = "".join(c for c in xdl_content if c.isprintable()) + root = ET.fromstring(xdl_content_cleaned) + + synthesis_elem = root.find("Synthesis") + if synthesis_elem is None: + return None, None, None + + # 解析硬件组件 + hardware_elem = synthesis_elem.find("Hardware") + hardware = [] + if hardware_elem is not None: + hardware = [{"id": c.get("id"), "type": c.get("type")} for c in hardware_elem.findall("Component")] + + # 解析试剂 + reagents_elem = synthesis_elem.find("Reagents") + reagents = [] + if reagents_elem is not None: + reagents = [{"name": r.get("name"), "role": r.get("role", "")} for r in reagents_elem.findall("Reagent")] + + # 解析程序 + procedure_elem = synthesis_elem.find("Procedure") + if procedure_elem is None: + return None, None, None + + flattened_operations = flatten_xdl_procedure(procedure_elem) + return hardware, reagents, flattened_operations + + except ET.ParseError as e: + raise ValueError(f"Invalid XDL format: {e}") + + +def convert_xdl_to_dict(xdl_content: str) -> Dict[str, Any]: + """ + 将XDL XML格式转换为标准的字典格式 + + Args: + xdl_content: XDL XML内容 + + Returns: + 转换结果,包含步骤和器材信息 + """ + try: + hardware, reagents, flattened_operations = parse_xdl_content(xdl_content) + if hardware is None: + return {"error": "Failed to parse XDL content", "success": False} + + # 将XDL元素转换为字典格式 + steps_data = [] + for elem in flattened_operations: + # 转换参数类型 + parameters = {} + for key, val in elem.attrib.items(): + converted_val = convert_to_type(val) + if converted_val is not None: + parameters[key] = converted_val + + step_dict = { + "operation": elem.tag, + "parameters": parameters, + "description": elem.get("purpose", f"Operation: {elem.tag}"), + } + steps_data.append(step_dict) + + # 合并硬件和试剂为统一的labware_info格式 + labware_data = [] + labware_data.extend({"id": hw["id"], "type": "hardware", **hw} for hw in hardware) + labware_data.extend({"name": reagent["name"], "type": "reagent", **reagent} for reagent in reagents) + + return { + "success": True, + "steps": steps_data, + "labware": labware_data, + "message": f"Successfully converted XDL to dict format. Found {len(steps_data)} steps and {len(labware_data)} labware items.", + } + + except Exception as e: + error_msg = f"XDL conversion failed: {str(e)}" + return {"error": error_msg, "success": False} diff --git a/unilabos/workflow/wf_utils.py b/unilabos/workflow/wf_utils.py new file mode 100644 index 00000000..f2dfc8ca --- /dev/null +++ b/unilabos/workflow/wf_utils.py @@ -0,0 +1,138 @@ +""" +工作流工具模块 + +提供工作流上传等功能 +""" + +import json +import os +import uuid +from typing import Any, Dict, List, Optional + +from unilabos.utils.banner_print import print_status + + +def _is_node_link_format(data: Dict[str, Any]) -> bool: + """检查数据是否为 node-link 格式""" + return "nodes" in data and "edges" in data + + +def _convert_to_node_link(workflow_file: str, workflow_data: Dict[str, Any]) -> Dict[str, Any]: + """ + 将非 node-link 格式的工作流数据转换为 node-link 格式 + + Args: + workflow_file: 工作流文件路径(用于日志) + workflow_data: 原始工作流数据 + + Returns: + node-link 格式的工作流数据 + """ + from unilabos.workflow.convert_from_json import convert_json_to_node_link + + print_status(f"检测到非 node-link 格式,正在转换...", "info") + node_link_data = convert_json_to_node_link(workflow_data) + print_status(f"转换完成", "success") + return node_link_data + + +def upload_workflow( + workflow_file: str, + workflow_name: Optional[str] = None, + tags: Optional[List[str]] = None, + published: bool = False, +) -> Dict[str, Any]: + """ + 上传工作流到服务器 + + 支持的输入格式: + 1. node-link 格式: {"nodes": [...], "edges": [...]} + 2. workflow/reagent 格式: {"workflow": [...], "reagent": {...}} + 3. steps_info/labware_info 格式: {"steps_info": [...], "labware_info": [...]} + 4. steps/labware 格式: {"steps": [...], "labware": [...]} + + Args: + workflow_file: 工作流文件路径(JSON格式) + workflow_name: 工作流名称,如果不提供则从文件中读取或使用文件名 + tags: 工作流标签列表,默认为空列表 + published: 是否发布工作流,默认为False + + Returns: + Dict: API响应数据 + """ + # 延迟导入,避免在配置文件加载之前初始化 http_client + from unilabos.app.web import http_client + + if not os.path.exists(workflow_file): + print_status(f"工作流文件不存在: {workflow_file}", "error") + return {"code": -1, "message": f"文件不存在: {workflow_file}"} + + # 读取工作流文件 + try: + with open(workflow_file, "r", encoding="utf-8") as f: + workflow_data = json.load(f) + except json.JSONDecodeError as e: + print_status(f"工作流文件JSON解析失败: {e}", "error") + return {"code": -1, "message": f"JSON解析失败: {e}"} + + # 自动检测并转换格式 + if not _is_node_link_format(workflow_data): + try: + workflow_data = _convert_to_node_link(workflow_file, workflow_data) + except Exception as e: + print_status(f"工作流格式转换失败: {e}", "error") + return {"code": -1, "message": f"格式转换失败: {e}"} + + # 提取工作流数据 + nodes = workflow_data.get("nodes", []) + edges = workflow_data.get("edges", []) + workflow_uuid_val = workflow_data.get("workflow_uuid", str(uuid.uuid4())) + wf_name_from_file = workflow_data.get("workflow_name", os.path.basename(workflow_file).replace(".json", "")) + + # 确定工作流名称 + final_name = workflow_name or wf_name_from_file + + print_status(f"正在上传工作流: {final_name}", "info") + print_status(f" - 节点数量: {len(nodes)}", "info") + print_status(f" - 边数量: {len(edges)}", "info") + print_status(f" - 标签: {tags or []}", "info") + print_status(f" - 发布状态: {published}", "info") + + # 调用 http_client 上传 + result = http_client.workflow_import( + name=final_name, + workflow_uuid=workflow_uuid_val, + workflow_name=final_name, + nodes=nodes, + edges=edges, + tags=tags, + published=published, + ) + + if result.get("code") == 0: + data = result.get("data", {}) + print_status("工作流上传成功!", "success") + print_status(f" - UUID: {data.get('uuid', 'N/A')}", "info") + print_status(f" - 名称: {data.get('name', 'N/A')}", "info") + else: + print_status(f"工作流上传失败: {result.get('message', '未知错误')}", "error") + + return result + + +def handle_workflow_upload_command(args_dict: Dict[str, Any]) -> None: + """ + 处理 workflow_upload 子命令 + + Args: + args_dict: 命令行参数字典 + """ + workflow_file = args_dict.get("workflow_file") + workflow_name = args_dict.get("workflow_name") + tags = args_dict.get("tags", []) + published = args_dict.get("published", False) + + if workflow_file: + upload_workflow(workflow_file, workflow_name, tags, published) + else: + print_status("未指定工作流文件路径,请使用 -f/--workflow_file 参数", "error") diff --git a/unilabos_msgs/package.xml b/unilabos_msgs/package.xml index c6e274a2..dbb20383 100644 --- a/unilabos_msgs/package.xml +++ b/unilabos_msgs/package.xml @@ -2,7 +2,7 @@ unilabos_msgs - 0.10.7 + 0.10.12 ROS2 Messages package for unilabos devices Junhan Chang Xuwznln