diff --git a/.conda/environment/recipe.yaml b/.conda/environment/recipe.yaml index 3f8df0f6..e9fd3e24 100644 --- a/.conda/environment/recipe.yaml +++ b/.conda/environment/recipe.yaml @@ -2,7 +2,7 @@ package: name: unilabos-env - version: 0.10.17 + version: 0.10.19 build: noarch: generic diff --git a/.gitignore b/.gitignore index 44488625..85b05d73 100644 --- a/.gitignore +++ b/.gitignore @@ -253,8 +253,4 @@ test_config.py /.claude -/.conda /.cursor -/.github -/.conda/base -.conda/base/recipe.yaml diff --git a/CLAUDE.md b/CLAUDE.md index bd5ce566..16fa5732 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,95 @@ +# CLAUDE.md -Please follow the rules defined in: +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -@AGENTS.md +## Build & Development + +```bash +# Install (requires mamba env with python 3.11) +mamba create -n unilab python=3.11.14 +mamba activate unilab +mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge +pip install -e . +uv pip install -r unilabos/utils/requirements.txt + +# Run with a device graph +unilab --graph --config --backend ros +unilab --graph --config --backend simple # no ROS2 needed + +# Common CLI flags +unilab --app_bridges websocket fastapi # communication bridges +unilab --test_mode # simulate hardware, no real execution +unilab --check_mode # CI validation of registry imports (AST-based) +unilab --skip_env_check # skip auto-install of dependencies +unilab --visual rviz|web|disable # visualization mode +unilab --is_slave # run as slave node +unilab --restart_mode # auto-restart on config changes (supervisor/child process) + +# Workflow upload subcommand +unilab workflow_upload -f -n --tags tag1 tag2 + +# Tests +pytest tests/ # all tests +pytest tests/resources/test_resourcetreeset.py # single test file +pytest tests/resources/test_resourcetreeset.py::TestClassName::test_method # single test + +# CI check (matches .github/workflows/ci-check.yml) +python -m unilabos --check_mode --skip_env_check +``` + +## Architecture + +### Startup Flow + +`unilab` CLI (entry point in `setup.py`) → `unilabos/app/main.py:main()` → loads config → builds registry → reads device graph (JSON/GraphML) → starts backend thread (ROS2/simple) → starts FastAPI web server + WebSocket client. + +### Core Layers + +**Registry** (`unilabos/registry/`): Singleton `Registry` class discovers and catalogs all device types, resource types, and communication devices. Two registration mechanisms: YAML definitions in `registry/devices/*.yaml` and Python decorators (`@device`, `@action`, `@resource` in `registry/decorators.py`). AST scanning discovers decorated classes without importing them. Class paths resolved to Python classes via `utils/import_manager.py`. + +**Resource Tracking** (`unilabos/resources/resource_tracker.py`): Pydantic-based `ResourceDict` → `ResourceDictInstance` → `ResourceTreeSet` hierarchy. `ResourceTreeSet` is the canonical in-memory representation of all devices and resources. Graph I/O in `resources/graphio.py` reads JSON/GraphML device topology files into `nx.Graph` + `ResourceTreeSet`. + +**Device Drivers** (`unilabos/devices/`): 30+ hardware drivers organized by category (liquid_handling, hplc, balance, arm, etc.). Each driver class gets wrapped by `ros/device_node_wrapper.py:ros2_device_node()` into a `ROS2DeviceNode` (defined in `ros/nodes/base_device_node.py`) with publishers, subscribers, and action servers. + +**ROS2 Layer** (`unilabos/ros/`): Preset node types in `ros/nodes/presets/` — `host_node` (main orchestrator, ~90KB), `controller_node`, `workstation`, `serial_node`, `camera`, `resource_mesh_manager`. Custom messages in `unilabos_msgs/` (80+ action types, pre-built via conda `ros-humble-unilabos-msgs`). + +**Protocol Compilation** (`unilabos/compile/`): 20+ protocol compilers (add, centrifuge, dissolve, filter, heatchill, stir, pump, etc.) registered in `__init__.py:action_protocol_generators` dict. Utility parsers in `compile/utils/` (vessel, unit, logger). + +**Workflow** (`unilabos/workflow/`): Converts workflow definitions from multiple formats — JSON (`convert_from_json.py`, `common.py`), Python scripts (`from_python_script.py`), XDL (`from_xdl.py`) — into executable `WorkflowGraph`. Legacy converters in `workflow/legacy/`. + +**Communication** (`unilabos/device_comms/`): Hardware adapters — OPC-UA, Modbus PLC, RPC, universal driver. `app/communication.py` provides factory pattern for WebSocket connections. + +**Web/API** (`unilabos/app/web/`): FastAPI server with REST API (`api.py`), Jinja2 templates (`pages.py`), HTTP client (`client.py`). Default port 8002. + +### Configuration System + +- **Config classes** in `unilabos/config/config.py`: `BasicConfig`, `WSConfig`, `HTTPConfig`, `ROSConfig` — class-level attributes, loaded from Python `.py` config files (see `config/example_config.py`) +- Environment variable overrides with prefix `UNILABOS_` (e.g., `UNILABOS_BASICCONFIG_PORT=9000`) +- Device topology defined in graph files (JSON node-link format or GraphML) + +### Key Data Flow + +1. Graph file → `graphio.read_node_link_json()` → `(nx.Graph, ResourceTreeSet, resource_links)` +2. `ResourceTreeSet` + `Registry` → `initialize_device.initialize_device_from_dict()` → `ROS2DeviceNode` instances +3. Device nodes communicate via ROS2 topics/actions or direct Python calls (simple backend) +4. Cloud sync via WebSocket (`app/ws_client.py`) and HTTP (`app/web/client.py`) + +### Test Data + +Example device graphs and experiment configs are in `unilabos/test/experiments/` (not `tests/`). Registry test fixtures in `unilabos/test/registry/`. + +## Code Conventions + +- Code comments and log messages in **simplified Chinese** +- Python 3.11+, type hints expected +- Pydantic models for data validation (`resource_tracker.py`) +- Singleton pattern via `@singleton` decorator (`utils/decorator.py`) +- Dynamic class loading via `utils/import_manager.py` — device classes resolved at runtime from registry YAML paths +- CLI argument dashes auto-converted to underscores for consistency +- No linter/formatter configuration enforced (no ruff, black, flake8, mypy configs) +- Documentation built with Sphinx (Chinese language, `sphinx_rtd_theme`, `myst_parser`) + +## Licensing + +- Framework code: GPL-3.0 +- Device drivers (`unilabos/devices/`): DP Technology Proprietary License — do not redistribute diff --git a/docs/moveit2_integration_summary.md b/docs/moveit2_integration_summary.md new file mode 100644 index 00000000..e645f6af --- /dev/null +++ b/docs/moveit2_integration_summary.md @@ -0,0 +1,1315 @@ +# Uni-Lab-OS MoveIt2 集成架构总结 + +## 概览 + +Uni-Lab-OS 通过三个核心文件实现了对 MoveIt2(ROS 2 运动规划框架)的深度集成,形成了从**底层运动规划接口** → **业务逻辑封装** → **场景构建与启动**的完整链路。 + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ resource_visalization.py │ +│ 场景构建层:URDF/SRDF 生成、MoveIt2 节点启动 │ +│ ros2_control_node / move_group / robot_state_publisher / rviz2 │ +└────────────────────────────┬─────────────────────────────────────────┘ + │ 提供 MoveIt2 运行环境(Planning Scene、控制器) + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ moveit_interface.py │ +│ 业务逻辑层:pick_and_place、set_position、set_status │ +│ 管理多个 MoveGroup,提供 FK/IK 计算、资源 TF 更新 │ +└────────────────────────────┬─────────────────────────────────────────┘ + │ 调用 MoveIt2 Python API + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ moveit2.py │ +│ 底层接口层:MoveIt2 ROS 2 Python 客户端实现 │ +│ Action Client / Service Client / Collision Scene / 轨迹执行 │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 1. `moveit2.py` — MoveIt2 底层 Python 接口 + +**路径**: `unilabos/devices/ros_dev/moveit2.py` +**行数**: ~2443 行 +**角色**: 对 MoveIt2 ROS 2 接口的完整 Python 封装,是整个运动控制的基础。 + +### 1.1 核心类:`MoveIt2` + +对 MoveIt2 框架的 ROS 2 通信协议进行了全面的 Python 封装,提供了规划、执行、运动学计算和碰撞管理的统一接口。 + +#### 1.1.1 ROS 2 通信拓扑 + +`MoveIt2` 类在初始化时创建了丰富的 ROS 2 通信端点: + +| 类型 | 名称 | 用途 | +|------|------|------| +| **Subscriber** | `/joint_states` | 实时获取关节状态(BEST_EFFORT QoS) | +| **Action Client** | `/move_action` | 通过 MoveGroup Action 一体化规划+执行 | +| **Action Client** | `/execute_trajectory` | 独立的轨迹执行 | +| **Service Client** | `/plan_kinematic_path` | 关节空间/笛卡尔空间运动规划 | +| **Service Client** | `/compute_cartesian_path` | 笛卡尔路径规划(直线插补) | +| **Service Client** | `/compute_fk` | 正运动学计算 | +| **Service Client** | `/compute_ik` | 逆运动学计算 | +| **Service Client** | `/get_planning_scene` | 获取当前规划场景 | +| **Service Client** | `/apply_planning_scene` | 应用修改后的规划场景 | +| **Publisher** | `/collision_object` | 发布碰撞物体 | +| **Publisher** | `/attached_collision_object` | 发布附着碰撞物体 | +| **Publisher** | `/trajectory_execution_event` | 发送轨迹取消指令 | + +#### 1.1.2 运动规划与执行 + +提供两种执行模式: + +- **MoveGroup Action 模式** (`use_move_group_action=True`):规划和执行合并在一个 Action 调用中完成,由 MoveIt2 内部管理整个流程。 +- **Plan + Execute 模式**:先通过 Service 调用进行路径规划(`/plan_kinematic_path` 或 `/compute_cartesian_path`),再通过 ExecuteTrajectory Action 执行。 + +关键方法: + +| 方法 | 说明 | +|------|------| +| `move_to_pose()` | 移动到目标位姿(位置 + 四元数姿态),支持笛卡尔直线规划 | +| `move_to_configuration()` | 移动到目标关节配置 | +| `plan()` / `plan_async()` | 异步路径规划,返回 `JointTrajectory` | +| `execute()` | 执行规划好的轨迹 | +| `wait_until_executed()` | 阻塞等待运动完成,返回成功/失败 | +| `cancel_execution()` | 取消当前运动 | + +#### 1.1.3 目标约束系统 + +支持多层次的目标设定: + +- **位置约束** (`set_position_goal`): 以球形区域定义目标位置的容差 +- **姿态约束** (`set_orientation_goal`): 支持 Euler 角和旋转向量两种参数化方式 +- **关节约束** (`set_joint_goal`): 直接指定目标关节角度 +- **复合约束** (`set_pose_goal`): 位置 + 姿态的组合 +- **路径约束** (`set_path_joint_constraint`, `set_path_position_constraint`, `set_path_orientation_constraint`): 对整条运动路径施加约束 +- **多目标组** (`create_new_goal_constraint`): 支持同时设置多组目标约束 + +#### 1.1.4 运动学服务 + +- **正运动学 (FK)**: `compute_fk()` / `compute_fk_async()` — 给定关节角求末端位姿 +- **逆运动学 (IK)**: `compute_ik()` / `compute_ik_async()` — 给定目标位姿求关节角,支持传入约束和避碰选项 + +#### 1.1.5 碰撞场景管理 + +提供完整的 Planning Scene 管理接口: + +| 方法 | 说明 | +|------|------| +| `add_collision_box()` | 添加长方体碰撞体 | +| `add_collision_sphere()` | 添加球形碰撞体 | +| `add_collision_cylinder()` | 添加圆柱碰撞体 | +| `add_collision_cone()` | 添加锥形碰撞体 | +| `add_collision_mesh()` | 添加三角网格碰撞体(依赖 trimesh) | +| `move_collision()` | 移动已有碰撞体 | +| `remove_collision_object()` | 移除碰撞体 | +| `attach_collision_object()` | 将碰撞体附着到机器人 link 上 | +| `detach_collision_object()` | 从机器人上分离碰撞体 | +| `allow_collisions()` | 修改 Allowed Collision Matrix,允许/禁止特定碰撞 | +| `clear_all_collision_objects()` | 清空所有碰撞物体 | + +#### 1.1.6 状态管理 + +通过 `MoveIt2State` 枚举和线程锁实现并发安全的状态管理: + +- `IDLE`: 空闲 +- `REQUESTING`: 已发送运动请求,等待接受 +- `EXECUTING`: 正在执行轨迹 + +配合 `ignore_new_calls_while_executing` 标志,防止运动冲突。 + +#### 1.1.7 辅助工具函数 + +文件末尾提供模块级工具函数: + +- `init_joint_state()`: 构造 `JointState` 消息 +- `init_execute_trajectory_goal()`: 构造 `ExecuteTrajectory.Goal` 消息 +- `init_dummy_joint_trajectory_from_state()`: 构造用于重置控制器的虚拟轨迹 + +--- + +## 2. `moveit_interface.py` — 业务逻辑封装层 + +**路径**: `unilabos/devices/ros_dev/moveit_interface.py` +**行数**: 385 行 +**角色**: 将 `MoveIt2` 底层接口封装为实验室场景中可用的高级操作(pick/place、状态切换、资源管理)。 + +### 2.1 核心类:`MoveitInterface` + +`MoveitInterface` 是连接实验室业务逻辑与 MoveIt2 运动规划的桥梁。它不直接继承 `MoveIt2`,而是通过**组合**的方式管理多个 `MoveIt2` 实例(每个 MoveGroup 一个),并在其上构建抓取放置、状态切换等实验室级操作。 + +--- + +#### 2.1.1 类属性与实例属性 + +```python +class MoveitInterface: + _ros_node: BaseROS2DeviceNode # ROS 2 节点引用(post_init 注入) + tf_buffer: Buffer # TF2 坐标变换缓冲区 + tf_listener: TransformListener # TF2 变换监听器 +``` + +**构造函数参数:** + +| 参数 | 类型 | 说明 | +|------|------|------| +| `moveit_type` | `str` | 设备类型标识,用于定位 `device_mesh/devices/{moveit_type}/config/move_group.json` 配置文件 | +| `joint_poses` | `dict` | 预定义关节位姿字典,结构为 `{move_group: {status_name: [joint_values...]}}` | +| `rotation` | `optional` | 设备旋转参数(预留) | +| `device_config` | `optional` | 设备自定义配置 | + +**实例属性:** + +| 属性 | 类型 | 初始值 | 说明 | +|------|------|--------|------| +| `data_config` | `dict` | 从 JSON 加载 | Move Group 配置,结构为 `{group_name: {base_link_name, end_effector_name, joint_names}}` | +| `arm_move_flag` | `bool` | `False` | 机械臂运动标志(预留) | +| `move_option` | `list` | `["pick", "place", "side_pick", "side_place"]` | 支持的抓取/放置动作类型 | +| `joint_poses` | `dict` | 构造函数传入 | 预定义关节位姿查找表 | +| `cartesian_flag` | `bool` | `False` | 当前是否使用笛卡尔直线规划(在 `pick_and_place` 执行过程中动态切换) | +| `mesh_group` | `list` | `["reactor", "sample", "beaker"]` | 碰撞网格分组类别 | +| `moveit2` | `dict` | `{}` | MoveIt2 实例字典,键为 Move Group 名称 | +| `resource_action` | `str/None` | `None` | 发现的 `tf_update` Action Server 名称 | +| `resource_client` | `ActionClient/None` | `None` | 用于发送资源 TF 更新请求的 Action Client | +| `resource_action_ok` | `bool` | `False` | `tf_update` Action Server 是否就绪 | + +--- + +#### 2.1.2 初始化流程:`__init__` + `post_init` + +采用**两阶段初始化**设计: + +**阶段一:`__init__`(纯数据初始化,不依赖 ROS 2)** + +``` +__init__(moveit_type, joint_poses, ...) + │ + ├── 加载 move_group.json 配置文件 + │ 路径: device_mesh/devices/{moveit_type}/config/move_group.json + │ 内容: { "arm": { "base_link_name": "base_link", + │ "end_effector_name": "tool0", + │ "joint_names": ["joint1", "joint2", ...] } } + │ + ├── 初始化状态标志 (arm_move_flag, cartesian_flag, resource_action_ok) + └── 记录 joint_poses 预定义位姿 +``` + +**阶段二:`post_init(ros_node)`(ROS 2 依赖初始化)** + +``` +post_init(ros_node) + │ + ├── 保存 ROS 节点引用 → self._ros_node + │ + ├── 创建 TF2 基础设施 + │ ├── Buffer() → self.tf_buffer + │ └── TransformListener(buffer, node) → self.tf_listener + │ + ├── 遍历 data_config 中的每个 Move Group + │ │ + │ │ 对于每个 {move_group, config}: + │ │ + │ ├── 生成带设备前缀的名称 + │ │ ├── base_link_name = "{device_id}_{config.base_link_name}" + │ │ ├── end_effector = "{device_id}_{config.end_effector_name}" + │ │ └── joint_names = ["{device_id}_{name}" for name in config.joint_names] + │ │ + │ └── 创建 MoveIt2 实例 + │ self.moveit2[move_group] = MoveIt2( + │ node=ros_node, + │ joint_names=..., + │ base_link_name=..., + │ end_effector_name=..., + │ group_name="{device_id}_{move_group}", ← MoveIt2 Planning Group 名 + │ callback_group=ros_node.callback_group, ← 共享回调组 + │ use_move_group_action=True, ← 使用 MoveGroup Action 模式 + │ ignore_new_calls_while_executing=True ← 防止运动冲突 + │ ) + │ .allowed_planning_time = 3.0 ← 规划超时 3 秒 + │ + └── 创建定时器 (1秒间隔) + → wait_for_resource_action() ← 异步等待 tf_update Action Server +``` + +**设备名前缀机制**:所有关节名、link 名、group 名都加上 `{device_id}_` 前缀。这是多设备共存的关键——当多台机械臂在同一 ROS 2 网络中运行时,前缀保证名称唯一,MoveIt2 能正确区分不同设备的规划组。 + +--- + +#### 2.1.3 资源 TF 更新服务发现 + +`MoveitInterface` 需要在运行时动态更新实验室资源(如烧杯、样品瓶)的 TF 父链接——当机械臂抓取物体时,物体的 TF 从 `world` 切换到末端执行器;放置时切换回 `world`。 + +``` +wait_for_resource_action() [定时器回调,1秒间隔] + │ + ├── 若 resource_action_ok 已为 True → 直接返回 + │ + ├── 轮询 check_tf_update_actions() + │ │ + │ ├── 遍历所有 ROS 2 Topic + │ ├── 查找 topic_type == "action_msgs/msg/GoalStatusArray" 的 Topic + │ ├── 从 Topic 名称中提取 Action 名(去掉 "/_action/status" 后缀) + │ └── 检查最后一段是否为 "tf_update" → 返回 Action 名称 + │ + ├── 创建 ActionClient(SendCmd, action_name) + ├── 等待 Action Server 就绪 (timeout=5s,循环等待) + └── 设置 resource_action_ok = True +``` + +**为什么用 Topic 发现而非硬编码?** +`tf_update` Action Server 由 `resource_mesh_manager` 节点提供,其命名空间可能随部署配置变化。通过 Topic 自动发现机制,`MoveitInterface` 能自适应不同的部署环境。 + +--- + +#### 2.1.4 底层运动方法:`moveit_task` + +```python +def moveit_task(self, move_group, position, quaternion, + speed=1, retry=10, cartesian=False, + target_link=None, offsets=[0,0,0]) +``` + +这是笛卡尔空间运动的核心封装,所有高级方法最终都通过它调用 `MoveIt2.move_to_pose()`。 + +**执行流程:** + +``` +moveit_task(move_group, position, quaternion, speed, retry, ...) + │ + ├── 速度限幅: speed_ = clamp(speed, 0.1, 1.0) + ├── 设置 MoveIt2 速度: + │ ├── moveit2[group].max_velocity = speed_ + │ └── moveit2[group].max_acceleration = speed_ + │ + ├── 计算最终位置: pose_result = position + offsets (逐元素相加) + │ + └── 重试循环 (最多 retry+1 次): + │ + ├── moveit2[group].move_to_pose( + │ target_link=target_link, ← 可指定非默认末端 link + │ position=pose_result, ← 目标 [x, y, z] + │ quat_xyzw=quaternion, ← 目标 [qx, qy, qz, qw] + │ cartesian=cartesian, ← 笛卡尔直线 or 自由空间 + │ cartesian_max_step=0.01, ← 笛卡尔插补步长 1cm + │ weight_position=1.0 ← 位置约束权重 + │ ) + │ + ├── re_ = moveit2[group].wait_until_executed() ← 阻塞等待 + │ + └── 若 re_ 为 True → 返回成功; 否则 retry -= 1 继续 +``` + +**参数说明:** + +| 参数 | 说明 | MoveIt2 对应 | +|------|------|-------------| +| `position` | 目标位置 `[x, y, z]`(米) | `move_to_pose(position=...)` | +| `quaternion` | 目标姿态四元数 `[x, y, z, w]` | `move_to_pose(quat_xyzw=...)` | +| `speed` | 速度因子 0.1~1.0,同时控制速度和加速度 | `max_velocity` + `max_acceleration` | +| `retry` | 规划失败时的最大重试次数 | N/A(应用层重试) | +| `cartesian` | 是否使用笛卡尔直线规划 | `move_to_pose(cartesian=...)` | +| `target_link` | 目标 link(默认末端执行器) | `move_to_pose(target_link=...)` | +| `offsets` | 位置偏移量 `[dx, dy, dz]` | 叠加到 `position` | + +--- + +#### 2.1.5 底层运动方法:`moveit_joint_task` + +```python +def moveit_joint_task(self, move_group, joint_positions, + joint_names=None, speed=1, retry=10) +``` + +关节空间运动的核心封装,调用 `MoveIt2.move_to_configuration()`。 + +**执行流程:** + +``` +moveit_joint_task(move_group, joint_positions, joint_names, speed, retry) + │ + ├── 关节角度转 float: joint_positions_ = [float(x) for x in joint_positions] + ├── 速度限幅: speed_ = clamp(speed, 0.1, 1.0) + ├── 设置 MoveIt2 速度 + │ + └── 重试循环: + │ + ├── moveit2[group].move_to_configuration( + │ joint_positions=joint_positions_, + │ joint_names=joint_names ← None 时使用 MoveIt2 默认关节名 + │ ) + │ + ├── re_ = moveit2[group].wait_until_executed() + │ + ├── 打印 FK 结果 (调试用): + │ compute_fk(joint_positions) → 显示对应的末端位姿 + │ + └── 若成功 → 返回; 否则 retry -= 1 +``` + +**与 `moveit_task` 的区别:** + +- `moveit_task`:目标是笛卡尔空间位姿(位置 + 姿态),MoveIt2 自动进行 IK 求解 +- `moveit_joint_task`:目标直接是关节角度,无需 IK 计算,确定性更高 +- 每次循环后调用 `compute_fk` 输出当前末端位姿,便于调试 + +--- + +#### 2.1.6 资源 TF 管理:`resource_manager` + +```python +def resource_manager(self, resource, parent_link) +``` + +通过 `SendCmd` Action 向 `tf_update` 服务发送 TF 父链接更新请求。 + +``` +resource_manager("beaker_1", "tool0") + │ + ├── 构造 SendCmd.Goal: + │ goal.command = '{"beaker_1": "tool0"}' ← JSON 格式: {资源名: 新父link} + │ + └── resource_client.send_goal(goal) ← 异步发送,不等待结果 +``` + +**在 pick/place 流程中的角色:** + +- **pick 时**:`resource_manager(resource, end_effector_name)` — 资源跟随末端执行器 +- **pick 且有 target 时**:`resource_manager(resource, target)` — 资源挂到指定 link +- **place 时**:`resource_manager(resource, "world")` — 资源释放到世界坐标系 + +--- + +#### 2.1.7 直接位姿控制:`set_position` + +```python +def set_position(self, command: str) +``` + +最简单的运动接口,解析 JSON 指令后直接委托给 `moveit_task`。 + +**JSON 指令格式:** + +```json +{ + "position": [0.3, 0.0, 0.5], + "quaternion": [0.0, 0.0, 0.0, 1.0], + "move_group": "arm", + "speed": 0.5, + "retry": 10 +} +``` + +**调用链:** + +``` +set_position(command_json) + │ + ├── JSON 解析 (替换单引号为双引号) + └── moveit_task(**cmd_dict) + └── MoveIt2.move_to_pose(...) +``` + +--- + +#### 2.1.8 预定义状态切换:`set_status` + +```python +def set_status(self, command: str) +``` + +将机械臂移动到预定义的关节配置(如 home 位、准备位等),关节角度从 `self.joint_poses` 查找表中获取。 + +**JSON 指令格式:** + +```json +{ + "status": "home", + "move_group": "arm", + "speed": 0.8, + "retry": 5 +} +``` + +**调用链:** + +``` +set_status(command_json) + │ + ├── JSON 解析 + ├── 查找预定义关节角: joint_poses[move_group][status] + │ 例: joint_poses["arm"]["home"] → [0.0, -1.57, 1.57, 0.0, 0.0, 0.0] + │ + └── moveit_joint_task(move_group, joint_positions, speed, retry) + └── MoveIt2.move_to_configuration(...) +``` + +**`joint_poses` 查找表结构:** + +```python +{ + "arm": { + "home": [0.0, -1.57, 1.57, 0.0, 0.0, 0.0], + "ready": [0.0, -0.78, 1.57, 0.0, 0.78, 0.0], + "pick_A1": [0.5, -1.2, 1.0, 0.0, 0.5, 0.3], + ... + }, + "gripper": { + "open": [0.04], + "close": [0.0], + ... + } +} +``` + +--- + +#### 2.1.9 核心方法详解:`pick_and_place` + +```python +def pick_and_place(self, command: str) +``` + +这是 `MoveitInterface` 最复杂的方法,实现了完整的抓取-放置工作流。它动态构建一个**有序函数列表** (`function_list`),然后顺序执行。 + +**JSON 指令格式(完整参数):** + +```json +{ + "option": "pick", // *必须: pick/place/side_pick/side_place + "move_group": "arm", // *必须: MoveIt2 规划组名 + "status": "pick_station_A", // *必须: 在 joint_poses 中的目标状态名 + "resource": "beaker_1", // 要操作的资源名称 + "target": "custom_link", // pick 时资源附着的目标 link (默认末端执行器) + "lift_height": 0.05, // 抬升高度 (米) + "x_distance": 0.1, // X 方向水平移动距离 (米) + "y_distance": 0.0, // Y 方向水平移动距离 (米) + "speed": 0.5, // 运动速度因子 (0.1~1.0) + "retry": 10, // 规划失败重试次数 + "constraints": [0, 0, 0, 0.5, 0, 0] // 各关节约束容差 (>0 时生效) +} +``` + +##### 阶段 1:指令解析与动作类型判定 + +``` +pick_and_place(command_json) + │ + ├── JSON 解析 + ├── 动作类型判定: + │ move_option = ["pick", "place", "side_pick", "side_place"] + │ 0 1 2 3 + │ option_index = move_option.index(cmd["option"]) + │ place_flag = option_index % 2 ← 0=pick类, 1=place类 + │ + ├── 提取运动参数: + │ config = {speed, retry, move_group} ← 从 cmd_dict 中按需提取 + │ + └── 获取目标关节位姿: + joint_positions_ = joint_poses[move_group][status] +``` + +##### 阶段 2:构建资源 TF 更新动作 + +``` +根据 place_flag 决定资源 TF 操作: + + if pick 类 (place_flag == 0): + if "target" 已指定: + function_list += [resource_manager(resource, target)] ← 挂到自定义 link + else: + function_list += [resource_manager(resource, end_effector)] ← 挂到末端执行器 + + if place 类 (place_flag == 1): + function_list += [resource_manager(resource, "world")] ← 释放到世界坐标 +``` + +##### 阶段 3:构建关节约束 + +``` +if "constraints" 存在于指令中: + for i, tolerance in enumerate(constraints): + if tolerance > 0: + JointConstraint( + joint_name = moveit2[group].joint_names[i], + position = joint_positions_[i], ← 约束中心 = 目标关节角 + tolerance_above = tolerance, + tolerance_below = tolerance, + weight = 1.0 + ) +``` + +约束的作用:限制 IK 求解的搜索空间,确保机械臂在抬升/移动过程中保持特定关节(如肘关节)在安全范围内。 + +##### 阶段 4A:有 `lift_height` 的完整流程 + +这是最复杂的场景,涉及 FK/IK 计算和多段运动拼接: + +``` +if "lift_height" 存在: + │ + ├── Step 1: FK 计算 → 获取目标关节配置对应的末端位姿 + │ retval = compute_fk(joint_positions_) ← 可能需要重试 + │ pose = [retval.position.x, .y, .z] + │ quaternion = [retval.orientation.x, .y, .z, .w] + │ + ├── Step 2: 构建"下降到目标点"动作 + │ function_list = [moveit_task(position=pose, ...)] + function_list + │ 注:此时 function_list 已包含 resource_manager,它被插入到中间 + │ + ├── Step 3: 构建"从目标点抬升"动作 + │ pose[2] += lift_height ← Z 轴抬升 + │ function_list += [moveit_task(position=pose_lifted, ...)] + │ + ├── Step 4 (可选): 水平移动 + │ if "x_distance": + │ deep_pose = copy(pose_lifted) + │ deep_pose[0] += x_distance + │ function_list = [moveit_task(pose_lifted)] + function_list + │ function_list += [moveit_task(deep_pose)] + │ elif "y_distance": + │ 类似处理 Y 方向 + │ + ├── Step 5: IK 预计算 → 将末端位姿转换为安全的关节配置 + │ retval_ik = compute_ik( + │ position = end_pose, ← 最终抬升/移动后的位姿 + │ quat_xyzw = quaternion, + │ constraints = Constraints(joint_constraints=constraints) + │ ) + │ position_ = 从 IK 结果提取各关节角度 + │ + └── Step 6: 构建"关节空间移动到起始位"动作 + function_list = [moveit_joint_task(position_)] + function_list +``` + +##### 阶段 4B:无 `lift_height` 的简单流程 + +``` +else (无 lift_height): + │ + └── 直接关节运动到目标位姿 + function_list = [moveit_joint_task(joint_positions_)] + function_list +``` + +##### 阶段 5:顺序执行动作列表 + +``` +for i, func in enumerate(function_list): + │ + ├── 设置规划模式: + │ i == 0: cartesian_flag = False ← 第一步用自由空间规划(大范围移动) + │ i > 0: cartesian_flag = True ← 后续用笛卡尔直线规划(精确控制) + │ + ├── result = func() ← 执行动作 + │ + └── if not result: + return failure ← 任一步骤失败即中止 +``` + +##### 完整 pick 流程示例(含 lift_height + x_distance) + +假设指令为:pick beaker_1 from station_A, lift 5cm, move 10cm in X + +``` +最终 function_list 执行顺序: +┌─────────────────────────────────────────────────────────────────────┐ +│ Step 0: moveit_joint_task → IK 求解的关节角 │ +│ [cartesian=False, 自由空间规划] │ +│ 机械臂从当前位置移动到抬升位 │ +├─────────────────────────────────────────────────────────────────────┤ +│ Step 1: moveit_task → 水平移动后的抬升位 (pose_lifted) │ +│ [cartesian=True, 笛卡尔直线] │ +│ 对齐到 station_A 正上方 │ +├─────────────────────────────────────────────────────────────────────┤ +│ Step 2: moveit_task → station_A 目标位姿 (FK 计算的位置) │ +│ [cartesian=True, 笛卡尔直线] │ +│ 末端执行器下降到目标点 │ +├─────────────────────────────────────────────────────────────────────┤ +│ Step 3: resource_manager("beaker_1", end_effector) │ +│ 资源 TF 附着到末端执行器 │ +├─────────────────────────────────────────────────────────────────────┤ +│ Step 4: moveit_task → 抬升位 (z + lift_height) │ +│ [cartesian=True, 笛卡尔直线] │ +│ 抓取后垂直抬升 │ +├─────────────────────────────────────────────────────────────────────┤ +│ Step 5: moveit_task → 水平偏移位 (x + x_distance) │ +│ [cartesian=True, 笛卡尔直线] │ +│ 抬升后水平移开,避免碰撞 │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +##### 完整 place 流程(同结构,反向操作) + +与 pick 相同的运动轨迹,但 `resource_manager` 调用变为 `resource_manager(resource, "world")`——在 Step 3 处将资源从末端执行器释放到世界坐标系。 + +--- + +#### 2.1.10 MoveIt2 API 调用汇总 + +`MoveitInterface` 使用的 `MoveIt2` 接口及其调用位置: + +| MoveIt2 方法 | 调用位置 | 用途 | +|-------------|---------|------| +| `MoveIt2(...)` 构造 | `post_init` L51-60 | 为每个 MoveGroup 创建实例 | +| `.allowed_planning_time = 3.0` | `post_init` L61 | 设置规划超时时间 | +| `.max_velocity = speed_` | `moveit_task` L119, `moveit_joint_task` L151 | 动态设置最大速度缩放因子 | +| `.max_acceleration = speed_` | `moveit_task` L120, `moveit_joint_task` L152 | 动态设置最大加速度缩放因子 | +| `.move_to_pose(...)` | `moveit_task` L129-137 | 笛卡尔空间运动规划与执行 | +| `.wait_until_executed()` | `moveit_task` L138, `moveit_joint_task` L157 | 阻塞等待运动完成 | +| `.move_to_configuration(...)` | `moveit_joint_task` L156 | 关节空间运动规划与执行 | +| `.compute_fk(...)` | `pick_and_place` L244, `moveit_joint_task` L160 | 正运动学:关节角 → 末端位姿 | +| `.compute_ik(...)` | `pick_and_place` L298-300 | 逆运动学:末端位姿 → 关节角(含约束) | +| `.end_effector_name` | `pick_and_place` L218 | 获取末端执行器 link 名 | +| `.joint_names` | `pick_and_place` L232, L308, L313 | 获取关节名列表 | + +--- + +#### 2.1.11 错误处理策略 + +| 场景 | 处理方式 | +|------|---------| +| FK 计算失败 | 最多重试 `retry` 次(每次间隔 0.1s),超时返回 `result.success = False` | +| IK 计算失败 | 同上 | +| 运动规划失败 | 在 `moveit_task` / `moveit_joint_task` 中最多重试 `retry+1` 次 | +| 动作序列中任一步失败 | `pick_and_place` 立即中止并返回 `result.success = False` | +| 未知异常 | `pick_and_place` 和 `set_status` 捕获 Exception,重置 `cartesian_flag`,返回失败 | + +--- + +#### 2.1.12 `cartesian_flag` 状态机 + +`cartesian_flag` 控制 `moveit_task` 中是否使用笛卡尔直线规划。在 `pick_and_place` 执行过程中,它被动态切换: + +``` +执行前: cartesian_flag = (上次残留状态) + +动作序列执行: + Step 0: cartesian_flag ← False (自由空间规划,适合大范围移动) + Step 1: cartesian_flag ← True (笛卡尔直线,适合精确操作) + Step 2: cartesian_flag ← True + ... + Step N: cartesian_flag ← True + +异常时: cartesian_flag ← False (重置,防止残留影响后续操作) +``` + +这种设计的考量:第一步通常是从安全位移动到工作区附近(距离远、可能需要绕障),使用自由空间规划更灵活;后续步骤是在工作区内的精确操作(下降、抬升、平移),笛卡尔直线规划确保路径可预测。 + +--- + +#### 2.1.13 数据流总览 + +``` +外部系统 (base_device_node) + │ + │ JSON 指令字符串 + ▼ +┌── MoveitInterface ──────────────────────────────────────────────────┐ +│ │ +│ set_position(cmd) ──→ moveit_task() ──→ MoveIt2.move_to_pose() │ +│ │ +│ set_status(cmd) ──→ moveit_joint_task() ──→ MoveIt2.move_to_config│ +│ │ +│ pick_and_place(cmd) │ +│ │ │ +│ ├─ MoveIt2.compute_fk() ─── /compute_fk service ──→ move_group │ +│ ├─ MoveIt2.compute_ik() ─── /compute_ik service ──→ move_group │ +│ ├─ moveit_task() ─── /move_action ──→ move_group │ +│ ├─ moveit_joint_task() ─── /move_action ──→ move_group │ +│ └─ resource_manager() ─── SendCmd Action ──→ tf_update │ +│ │ +│ 内部状态: │ +│ joint_poses ← 预定义位姿查找表 │ +│ moveit2{} ← MoveIt2 实例池 (per MoveGroup) │ +│ tf_buffer ← TF2 坐标变换缓存 │ +│ cartesian_flag ← 规划模式状态机 │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 3. `resource_visalization.py` — 场景构建与 MoveIt2 启动 + +**路径**: `unilabos/device_mesh/resource_visalization.py` +**行数**: 429 行 +**角色**: 根据实验室设备/资源配置,动态生成 URDF/SRDF,并启动 MoveIt2 所需的全部 ROS 2 节点。 + +### 3.1 核心类:`ResourceVisualization` + +#### 3.1.1 模型加载与 URDF 生成 + +初始化阶段完成以下工作: + +1. **遍历设备注册表**:区分 `resource`(静态资源如 plate/deck)和 `device`(可动设备如机械臂) +2. **Resource 类型**:记录 mesh 路径和 TF 变换信息,后续用于碰撞场景 +3. **Device 类型**:通过 `xacro` 宏注入动态参数: + - 设备名前缀(`device_name`) + - 位置/旋转(从配置中提取) + - 自定义设备参数(`device_config`) +4. **MoveIt 设备**(`class` 包含 `moveit.`):额外加载: + - `ros2_control` xacro 宏 → 生成 `ros2_control` 硬件接口描述 + - SRDF xacro 宏 → 生成运动学语义描述(move group 定义、自碰撞矩阵等) + +#### 3.1.2 MoveIt2 配置初始化 (`moveit_init`) + +对每个 MoveIt 设备,加载并合并以下配置文件(均加上设备名前缀): + +| 配置文件 | 生成目标 | 作用 | +|----------|----------|------| +| `ros2_controllers.yaml` | `ros2_controllers_yaml` | 定义 ros2_control 控制器(JointTrajectoryController 等) | +| `moveit_controllers.yaml` | `moveit_controllers_yaml` | 定义 MoveIt 控制器映射 | +| `kinematics.yaml` | `moveit_nodes_kinematics` | 定义运动学求解器参数 | + +所有关节名和控制器名都加上设备 ID 前缀,确保多设备共存时不冲突。 + +#### 3.1.3 ROS 2 Launch 节点启动 + +`create_launch_description()` 方法启动以下 ROS 2 节点: + +| 节点 | 包 | 作用 | +|------|-----|------| +| `ros2_control_node` | `controller_manager` | ros2_control 硬件管理器,加载 URDF 和控制器配置 | +| `spawner` (per controller) | `controller_manager` | 激活各 JointTrajectoryController | +| `spawner` (joint_state_broadcaster) | `controller_manager` | 广播关节状态到 `/joint_states` | +| `robot_state_publisher` | `robot_state_publisher` | 根据 URDF 和关节状态发布 TF | +| `move_group` | `moveit_ros_move_group` | **MoveIt2 核心节点**,提供运动规划服务 | +| `rviz2` (可选) | `rviz2` | 3D 可视化 | + +`move_group` 节点的参数配置包括: + +- `robot_description`: 动态生成的 URDF +- `robot_description_semantic`: 动态生成的 SRDF +- `robot_description_kinematics`: 合并后的运动学配置 +- `planning_pipelines`: 从 `moveit_configs_utils` 加载的规划器配置(默认 OMPL) +- `moveit_controllers_yaml`: 控制器映射配置 +- `robot_description_planning`: 速度/加速度限制 + +--- + +## 4. 三个文件的协作关系 + +### 4.1 启动阶段 + +``` +resource_visalization.py + │ + ├── 1. 解析设备/资源配置 → 生成 URDF + SRDF + ├── 2. 合并控制器/运动学配置(带设备名前缀) + ├── 3. 启动 ros2_control_node + controller spawners + ├── 4. 启动 robot_state_publisher(发布 TF) + ├── 5. 启动 move_group(提供规划服务) + └── 6. (可选) 启动 rviz2 +``` + +### 4.2 运行阶段 + +``` +外部调用(如 base_device_node) + │ + ▼ +moveit_interface.py + │ + ├── pick_and_place() ──┬── compute_fk() ──→ moveit2.py → /compute_fk service + │ ├── compute_ik() ──→ moveit2.py → /compute_ik service + │ ├── move_to_pose() ──→ moveit2.py → /move_action or /plan + /execute + │ ├── move_to_config()──→ moveit2.py → /move_action or /plan + /execute + │ └── resource_manager()→ SendCmd Action → tf_update + │ + ├── set_position() ────→ moveit_task() ──→ moveit2.py → move_to_pose() + │ + └── set_status() ──────→ moveit_joint_task()→ moveit2.py → move_to_configuration() +``` + +### 4.3 MoveIt2 消息类型使用一览 + +| 消息/服务/Action | 文件 | 用途 | +|-----------------|------|------| +| `moveit_msgs/action/MoveGroup` | moveit2.py | 一体化规划+执行 | +| `moveit_msgs/action/ExecuteTrajectory` | moveit2.py | 独立轨迹执行 | +| `moveit_msgs/srv/GetMotionPlan` | moveit2.py | 运动规划 | +| `moveit_msgs/srv/GetCartesianPath` | moveit2.py | 笛卡尔路径规划 | +| `moveit_msgs/srv/GetPositionFK` | moveit2.py | 正运动学 | +| `moveit_msgs/srv/GetPositionIK` | moveit2.py | 逆运动学 | +| `moveit_msgs/srv/GetPlanningScene` | moveit2.py | 获取规划场景 | +| `moveit_msgs/srv/ApplyPlanningScene` | moveit2.py | 应用规划场景 | +| `moveit_msgs/msg/CollisionObject` | moveit2.py | 碰撞物体管理 | +| `moveit_msgs/msg/AttachedCollisionObject` | moveit2.py | 附着碰撞物体 | +| `moveit_msgs/msg/Constraints` | moveit2.py, moveit_interface.py | 目标/路径约束 | +| `moveit_msgs/msg/JointConstraint` | moveit2.py, moveit_interface.py | 关节约束 | +| `moveit_msgs/msg/PlanningScene` | moveit2.py | 规划场景 | + +--- + +## 5. 注册表中的 `model` 字段与 3D 模型加载 + +### 5.1 `model` 字段概述 + +在 Uni-Lab-OS 的设备/资源注册表 YAML 中,`model` 字段是连接**注册表定义**与**3D 可视化系统**的关键。它告诉 `ResourceVisualization` 如何为该设备或资源加载 3D 模型。 + +以 `arm_slider` 为例: + +```yaml +# unilabos/registry/devices/robot_arm.yaml (L355-358) +model: + mesh: arm_slider + path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/arm_slider/macro_device.xacro + type: device +``` + +**三个字段的作用:** + +| 字段 | 值 | 说明 | +|------|-----|------| +| `mesh` | `arm_slider` | 模型文件夹名,对应 `device_mesh/devices/arm_slider/` 目录 | +| `path` | `https://...macro_device.xacro` | 模型文件的远程下载地址(OSS),用于首次部署时下载模型资源 | +| `type` | `device` | 模型类型标识,决定 `ResourceVisualization` 的处理逻辑 | + +### 5.2 `type` 字段的两种取值 + +`model.type` 决定了 `ResourceVisualization` 如何加载和处理 3D 模型,有两条完全不同的路径: + +#### `type: device` — 动态设备(如机械臂、龙门架) + +```python +# resource_visalization.py L121-163 +if model_config['type'] == 'device': + # 1. 通过 xacro include 加载设备的 URDF 宏 + # → device_mesh/devices/{mesh}/macro_device.xacro + + # 2. 调用 xacro 宏,注入参数: + # parent_link, mesh_path, device_name, x/y/z, rx/ry/r, device_config... + + # 3. 若设备 class 包含 "moveit.": + # → 额外加载 ros2_control xacro 和 SRDF xacro + # → 注册为 moveit_nodes,后续由 moveit_init() 加载控制器配置 +``` + +**处理流程:** + +``` +注册表 model.mesh = "arm_slider" + │ + ├── 加载 URDF: device_mesh/devices/arm_slider/macro_device.xacro + │ → 生成完整的 link/joint 运动链,嵌入到全局 URDF 中 + │ + ├── 注入位置参数: x, y, z, rx, ry, r (从节点配置 position/rotation 中读取, 单位 mm→m) + │ + ├── 注入设备参数: device_config 中的键值对作为 xacro 参数 + │ + └── 若 class 含 "moveit.": + ├── 加载 ros2_control: config/macro.ros2_control.xacro + ├── 加载 SRDF: config/macro.srdf.xacro + └── 记录到 moveit_nodes → moveit_init() 加载 controllers/kinematics +``` + +#### `type: resource` — 静态资源(如微孔板、试管架) + +```python +# resource_visalization.py L111-120 +if model_config['type'] == 'resource': + # 只记录 mesh 文件路径和 TF 偏移,用于碰撞场景 + # 不注入到 URDF 运动链中 + resource_model[node_id] = { + 'mesh': f"device_mesh/resources/{mesh}", + 'mesh_tf': model_config['mesh_tf'] + } +``` + +Resource 类型的 `model` 结构更丰富,包含 TF 偏移和子物体: + +```yaml +# 例: registry/resources/opentrons/plates.yaml +model: + mesh: tecan_nested_tip_rack/meshes/plate.stl # 主体 mesh(STL 文件) + mesh_tf: [0.064, 0.043, 0, -1.5708] # 位姿偏移 [x, y, z, rotation] + children_mesh: generic_labware_tube_10_75/meshes/0_base.stl # 子物体 mesh + children_mesh_tf: [0.0018, 0.0018, 0, -1.5708] # 子物体偏移 +``` + +### 5.3 两种类型的对比 + +| 对比项 | `type: device` | `type: resource` | +|--------|---------------|-----------------| +| **模型格式** | xacro 宏(动态参数化 URDF) | STL 静态 mesh 文件 | +| **加载方式** | xacro include → 嵌入全局 URDF | 记录路径 → 后续作为碰撞体添加 | +| **位置来源** | 节点配置中的 position/rotation | `mesh_tf` 偏移数组 | +| **是否有关节** | 是(prismatic/revolute) | 否(纯静态) | +| **支持 MoveIt** | 是(通过 class 名中的 `moveit.` 触发) | 否 | +| **子物体** | 无(运动链本身定义了所有部件) | 可选 `children_mesh`(如管架中的管子) | +| **远程路径** | `path` 字段指向 OSS 下载地址 | 类似,`children_mesh_path` 指向子物体 | +| **存放目录** | `device_mesh/devices/{mesh}/` | `device_mesh/resources/{mesh}` | +| **实际示例** | arm_slider, toyo_xyz, elite_robot | 微孔板, tip rack, 试管架 | + +### 5.4 `arm_slider` 注册表完整结构 + +`arm_slider` 的注册表键名 `robotic_arm.SCARA_with_slider.moveit.virtual` 本身就编码了重要信息: + +``` +robotic_arm → 设备大类(机械臂) + .SCARA_with_slider → 具体型号(SCARA 构型 + 线性滑轨) + .moveit → ★ 标记为 MoveIt 设备(class 名包含 "moveit.") + .virtual → 仿真/虚拟设备 +``` + +**class 名中包含 `moveit.` 的关键作用**:`ResourceVisualization` 在 L151 通过 `node['class'].find('moveit.') != -1` 判断是否需要加载 MoveIt 配置。这是 **MoveIt 设备与普通 device 的唯一判别条件**——即使两者的 `model.type` 都是 `device`。 + +注册表中的关键部分: + +```yaml +robotic_arm.SCARA_with_slider.moveit.virtual: + # 设备驱动类 + class: + module: unilabos.devices.ros_dev.moveit_interface:MoveitInterface + type: python + action_value_mappings: + pick_and_place: ... # SendCmd Action(JSON 指令) + set_position: ... # SendCmd Action + set_status: ... # SendCmd Action + auto-moveit_task: ... # 自动发现的方法(UniLabJsonCommand) + auto-moveit_joint_task: ... + auto-resource_manager: ... + auto-post_init: ... + + # 初始化参数 + init_param_schema: + config: + properties: + moveit_type: ... # → 对应 device_mesh/devices/{moveit_type}/ 文件夹 + joint_poses: ... # → 预定义关节位姿查找表 + rotation: ... + device_config: ... + + # ★ 3D 模型定义 + model: + mesh: arm_slider # → device_mesh/devices/arm_slider/ + path: https://... # → OSS 远程下载地址 + type: device # → ResourceVisualization 按 device 逻辑加载 +``` + +### 5.5 从注册表到 MoveIt2 的完整链路 + +``` +注册表 YAML +│ +│ model.mesh = "arm_slider" +│ model.type = "device" +│ class = "robotic_arm.SCARA_with_slider.moveit.virtual" +│ ^^^^^^ +│ class 含 "moveit." +▼ +ResourceVisualization.__init__() +│ +├── model.type == "device" +│ └── xacro include: devices/arm_slider/macro_device.xacro → URDF +│ +├── class 含 "moveit." +│ ├── xacro include: devices/arm_slider/config/macro.ros2_control.xacro → URDF +│ ├── xacro include: devices/arm_slider/config/macro.srdf.xacro → SRDF +│ └── moveit_nodes["device_id"] = "arm_slider" +│ +▼ +ResourceVisualization.moveit_init() +│ +├── 加载 devices/arm_slider/config/ros2_controllers.yaml +├── 加载 devices/arm_slider/config/moveit_controllers.yaml +├── 加载 devices/arm_slider/config/kinematics.yaml +└── 合并到全局配置(带设备名前缀) +│ +▼ +ResourceVisualization.create_launch_description() +│ +├── 启动 ros2_control_node(加载 URDF + 控制器配置) +├── 启动 controller spawners(激活 arm_controller、gripper_controller) +├── 启动 robot_state_publisher(发布 TF) +├── 启动 move_group(MoveIt2 核心,加载 SRDF + kinematics + planners) +└── (可选) 启动 rviz2 +│ +▼ +MoveitInterface.post_init() +│ +├── 读取 devices/arm_slider/config/move_group.json +├── 为 "arm" 组创建 MoveIt2 实例 +└── 等待 tf_update Action Server 就绪 +│ +▼ +运行时: pick_and_place / set_position / set_status +``` + +--- + +## 6. 设备模型文件夹结构:MoveIt 设备 vs 非 MoveIt 设备 + +`device_mesh/devices/` 下的每个子文件夹代表一种设备类型的 3D 模型和配置。根据设备是否需要 MoveIt2 运动规划,文件夹内容有**显著差异**。 + +### 5.1 非 MoveIt 设备示例:`slide_w140` / `hplc_station` + +非 MoveIt 设备只需要 3D 可视化和简单的关节控制(由 `liquid_handler_joint_publisher` 等自定义节点直接控制),**不需要运动规划**。 + +``` +slide_w140/ hplc_station/ +├── macro_device.xacro ├── macro_device.xacro +├── joint_config.json ├── joint_config.json +├── param_config.json ├── param_config.json +└── meshes/ └── meshes/ + └── *.STL └── *.STL +``` + +**仅 3 个配置文件:** + +| 文件 | 作用 | +|------|------| +| `macro_device.xacro` | URDF 模型(xacro 宏),定义 link/joint/visual/collision | +| `joint_config.json` | 关节名与轴向信息,供自定义关节发布器使用 | +| `param_config.json` | 设备尺寸等可配参数(如轨道长度),注入到 xacro 宏参数中 | + +**特点:** +- 没有 `config/` 子文件夹 +- 没有 SRDF、ros2_control、MoveIt 控制器等配置 +- 关节由应用层直接发布 `JointState`,不经过 ros2_control 和 MoveIt2 + +--- + +### 5.2 MoveIt 设备示例:`arm_slider` + +MoveIt 设备需要完整的运动规划支持——从 ros2_control 硬件抽象到 MoveIt2 运动学求解和碰撞矩阵。 + +``` +arm_slider/ +├── macro_device.xacro ← URDF 模型(xacro 宏) +├── joint_limit.yaml ← 关节物理限制(effort/velocity/position) +├── meshes/ ← 3D 网格文件 +│ ├── arm_slideway.STL +│ ├── arm_base.STL +│ ├── arm_link_1.STL +│ ├── arm_link_2.STL +│ ├── arm_link_3.STL +│ ├── gripper_base.STL +│ ├── gripper_right.STL +│ └── gripper_left.STL +│ +└── config/ ← ★ MoveIt 设备独有的配置目录 + ├── macro.ros2_control.xacro ← ros2_control 硬件接口定义 + ├── macro.srdf.xacro ← SRDF 语义描述(Move Group + 碰撞矩阵) + ├── move_group.json ← Move Group 定义(供 MoveitInterface 使用) + ├── ros2_controllers.yaml ← ros2_control 控制器配置 + ├── moveit_controllers.yaml ← MoveIt ↔ 控制器映射 + ├── kinematics.yaml ← 运动学求解器配置 + ├── joint_limits.yaml ← MoveIt 用关节限制(速度/加速度缩放) + ├── initial_positions.yaml ← 仿真初始关节位置 + ├── pilz_cartesian_limits.yaml ← Pilz 笛卡尔限制 + └── moveit_planners.yaml ← 规划器配置 +``` + +**比非 MoveIt 设备多出 10 个配置文件**,全部位于 `config/` 子目录。 + +--- + +### 5.3 `arm_slider` 各文件详解 + +#### 5.3.1 `macro_device.xacro` — URDF 运动链定义 + +定义了 arm_slider 的完整运动链,包含 8 个 link 和 7 个 joint: + +``` +world + └── [fixed] base_link_joint + └── device_link + └── [fixed] device_link_joint + └── arm_slideway (底座滑轨, 有 visual + collision mesh) + └── [prismatic, X轴] arm_base_joint (滑轨平移) + └── arm_base (机械臂底座) + └── [prismatic, Z轴] arm_link_1_joint (升降) + └── arm_link_1 + └── [revolute, Z轴] arm_link_2_joint (旋转关节1) + └── arm_link_2 + └── [revolute, Z轴] arm_link_3_joint (旋转关节2) + └── arm_link_3 + └── [revolute, Z轴] gripper_base_joint (夹爪旋转) + └── gripper_base (夹爪底座) + ├── [prismatic, X轴] gripper_right_joint + │ └── gripper_right + └── [prismatic, X轴, mimic] gripper_left_joint + └── gripper_left +``` + +**关键设计点:** + +- **混合关节类型**:包含 prismatic(滑轨平移 + 升降 + 夹爪)和 revolute(旋转)关节 +- **Mimic 关节**:`gripper_left_joint` 通过 `` 标签跟随 `gripper_right_joint`,实现对称夹爪联动 +- **参数化前缀**:所有 link/joint 名都带 `${station_name}${device_name}` 前缀,支持多实例 +- **外部关节限制**:从 `joint_limit.yaml` 加载 effort/velocity/position 范围 +- **完整物理属性**:每个 link 都有 ``(质量、惯量矩阵)、``(STL mesh)和 ``(碰撞体) + +#### 5.3.2 `joint_limit.yaml` — 关节物理限制 + +定义每个关节的运动范围和动力学参数,被 `macro_device.xacro` 引用: + +| 关节 | 类型 | 范围 | 说明 | +|------|------|------|------| +| `arm_base_joint` | prismatic | 0 ~ 1.5m | 滑轨水平行程 | +| `arm_link_1_joint` | prismatic | 0 ~ 0.6m | 升降行程 | +| `arm_link_2_joint` | revolute | -95° ~ 95° | 第一旋转关节 | +| `arm_link_3_joint` | revolute | -195° ~ 195° | 第二旋转关节 | +| `gripper_base_joint` | revolute | -95° ~ 95° | 夹爪旋转 | +| `gripper_right/left_joint` | prismatic | 0 ~ 0.03m | 夹爪开合 | + +#### 5.3.3 `config/macro.ros2_control.xacro` — ros2_control 硬件接口 + +定义 ros2_control 硬件抽象层,将关节映射到控制接口: + +- **硬件插件**:`mock_components/GenericSystem`(仿真模式,可替换为真实硬件驱动) +- **每个关节声明**: + - `command_interface: position` — 位置控制模式 + - `state_interface: position` — 位置反馈(含 `initial_value` 从 `initial_positions.yaml` 加载) + - `state_interface: velocity` — 速度反馈 +- **6 个关节**:`arm_base_joint` ~ `gripper_right_joint`(`gripper_left_joint` 因为是 mimic 关节,不需要独立控制接口) + +#### 5.3.4 `config/macro.srdf.xacro` — SRDF 语义描述 + +MoveIt2 的语义机器人描述,定义了: + +**Move Groups(规划组):** + +| 组名 | 类型 | 内容 | +|------|------|------| +| `{device_name}arm` | chain | `arm_slideway` → `gripper_base`(5 DOF 运动链) | +| `{device_name}arm_gripper` | joint | `gripper_right_joint`(夹爪控制) | + +**Disable Collisions(自碰撞矩阵):** + +22 条 `` 规则,标记不可能碰撞的 link 对(Adjacent / Never),减少碰撞检测计算量。例如: + +- `arm_base` ↔ `arm_slideway`:Adjacent(相邻 link,必然接触) +- `arm_link_1` ↔ `arm_link_3`:Never(物理上不可能碰撞) +- `gripper_left` ↔ `gripper_right`:Never + +#### 5.3.5 `config/move_group.json` — MoveitInterface 配置 + +供 `MoveitInterface.post_init()` 使用,定义每个 Move Group 的关节和端点: + +```json +{ + "arm": { + "joint_names": ["arm_base_joint", "arm_link_1_joint", + "arm_link_2_joint", "arm_link_3_joint", + "gripper_base_joint"], + "base_link_name": "device_link", + "end_effector_name": "gripper_base" + } +} +``` + +`MoveitInterface` 读取此文件后,为 `"arm"` 组创建一个 `MoveIt2` 实例,自动加上设备名前缀。 + +#### 5.3.6 `config/ros2_controllers.yaml` — 控制器定义 + +定义两个 `JointTrajectoryController`: + +| 控制器 | 控制的关节 | 说明 | +|--------|-----------|------| +| `arm_controller` | arm_base_joint ~ gripper_base_joint (5个) | 机械臂主体 | +| `gripper_controller` | gripper_right_joint (1个) | 夹爪 | + +被 `resource_visalization.py` 的 `moveit_init()` 读取,加上设备名前缀后合并到全局 `ros2_controllers_yaml` 中。 + +#### 5.3.7 `config/moveit_controllers.yaml` — MoveIt ↔ 控制器映射 + +告诉 MoveIt2 的 `move_group` 节点如何将规划好的轨迹发送到 ros2_control 控制器: + +- `arm_controller` → FollowJointTrajectory Action(5 个关节) +- `gripper_controller` → FollowJointTrajectory Action(1 个关节) + +#### 5.3.8 `config/kinematics.yaml` — 运动学求解器 + +```yaml +arm: + kinematics_solver: lma_kinematics_plugin/LMAKinematicsPlugin + kinematics_solver_search_resolution: 0.005 + kinematics_solver_timeout: 0.005 +``` + +使用 **LMA (Levenberg-Marquardt Algorithm)** 运动学求解器进行正/逆运动学计算。这是 MoveIt2 的通用 IK 求解器,适用于任意运动链拓扑。 + +#### 5.3.9 其他配置文件 + +| 文件 | 作用 | +|------|------| +| `initial_positions.yaml` | 仿真启动时各关节初始角度/位置(所有为 0,夹爪张开 0.03m) | +| `joint_limits.yaml` | MoveIt 层面的速度/加速度缩放限制(覆盖 URDF 中的值) | +| `pilz_cartesian_limits.yaml` | Pilz 工业运动规划器的笛卡尔速度/加速度限制 | +| `moveit_planners.yaml` | 可用规划器列表(`ompl_interface/OMPLPlanner`) | + +--- + +### 5.4 对比:`toyo_xyz`(另一个 MoveIt 设备) + +`toyo_xyz` 是一个三轴直线运动平台(XYZ 龙门),也是 MoveIt 设备。与 `arm_slider` 对比: + +| 对比项 | `arm_slider` | `toyo_xyz` | +|--------|-------------|------------| +| **自由度** | 5 DOF (2 prismatic + 3 revolute) + 夹爪 | 3 DOF (3 prismatic) | +| **运动链** | 混合链(平移+旋转) | 纯直线链(全 prismatic) | +| **Move Group** | `arm` (5 joints) + `arm_gripper` (1 joint) | `toyo_xyz` (3 joints) | +| **末端执行器** | `gripper_base` | `slider3_link` | +| **独有文件** | `joint_limit.yaml` | `joint_config.json` + `param_config.json` | +| **xacro 参数** | 固定尺寸 | 可配长度 (`length1/2/3`) + mesh scale 缩放 | +| **IK 求解器** | LMA | LMA | + +`toyo_xyz` 的额外特点: +- `param_config.json`:定义三轴行程和滑块尺寸,通过 xacro 参数动态缩放 STL 模型 +- `joint_config.json`:简单的关节名→轴向映射,供非 MoveIt 的关节发布器使用 +- `config/full_dev.urdf.xacro`:额外的完整 URDF 文件(独立调试用) + +两者的 **MoveIt 配置文件结构完全一致**(SRDF、ros2_control、controllers、kinematics),说明 Uni-Lab-OS 的 MoveIt 设备遵循统一的模板。 + +--- + +### 5.5 MoveIt vs 非 MoveIt 设备文件对比总结 + +``` +非 MoveIt 设备 (slide_w140) MoveIt 设备 (arm_slider) +─────────────────────────── ─────────────────────────── +macro_device.xacro ✓ macro_device.xacro ✓ +joint_config.json ✓ joint_limit.yaml ✓ +param_config.json ✓ + config/ + ├── macro.ros2_control.xacro ★ ros2_control 硬件接口 + ├── macro.srdf.xacro ★ SRDF (Move Group + 碰撞) + ├── move_group.json ★ MoveitInterface 配置 + ├── ros2_controllers.yaml ★ 控制器定义 + ├── moveit_controllers.yaml ★ MoveIt↔控制器映射 + ├── kinematics.yaml ★ IK 求解器配置 + ├── joint_limits.yaml ★ MoveIt 关节限制 + ├── initial_positions.yaml ★ 仿真初始状态 + ├── pilz_cartesian_limits.yaml ★ 笛卡尔限制 + └── moveit_planners.yaml ★ 规划器列表 + +文件数: 3 文件数: 12 (3 + 10 MoveIt 专用) +``` + +**核心区别:MoveIt 设备多出的 `config/` 目录下的 10 个文件,构成了 MoveIt2 运动规划所需的完整配置栈:** + +1. **硬件层** (`macro.ros2_control.xacro`): 关节如何被控制 +2. **语义层** (`macro.srdf.xacro`): 哪些关节组成规划组,哪些碰撞可以忽略 +3. **规划层** (`kinematics.yaml`, `moveit_planners.yaml`, `pilz_cartesian_limits.yaml`): 如何求解和规划 +4. **执行层** (`ros2_controllers.yaml`, `moveit_controllers.yaml`): 轨迹如何下发到控制器 +5. **桥接层** (`move_group.json`): Uni-Lab-OS 的 `MoveitInterface` 如何连接到 MoveIt2 + +--- + +## 6. 设计特点 + +1. **多设备支持**:通过设备名前缀机制(`device_id_`),所有关节名、link 名、控制器名都是唯一的,支持在同一 ROS 2 环境中运行多台机械臂。 + +2. **动态场景构建**:`ResourceVisualization` 根据实验室配置动态生成 URDF/SRDF,无需手动编写或维护静态模型文件。 + +3. **规划/执行分离**:`MoveIt2` 类支持 MoveGroup Action(合并模式)和 Plan+Execute(分离模式),可根据场景灵活选择。 + +4. **线程安全**:`MoveIt2` 类通过 `threading.Lock` 保护关节状态和执行状态的并发访问。 + +5. **碰撞场景集成**:支持完整的碰撞物体生命周期管理(添加/移动/附着/分离/删除),可在运行时动态更新规划场景。 + +6. **资源 TF 动态更新**:`MoveitInterface` 通过 `resource_manager()` 在 pick/place 时动态更新资源的 TF 父 link,实现物体在机器人和环境之间的"跟随"效果。 + +7. **统一设备模板**:MoveIt 设备遵循统一的 `config/` 目录结构(SRDF、ros2_control、controllers、kinematics),新增设备只需按模板创建配置文件即可接入 MoveIt2 运动规划。 diff --git a/unilabos/device_mesh/resource_visalization.py b/unilabos/device_mesh/resource_visalization.py index ee58d67e..0848bfb5 100644 --- a/unilabos/device_mesh/resource_visalization.py +++ b/unilabos/device_mesh/resource_visalization.py @@ -201,17 +201,42 @@ class ResourceVisualization: self.moveit_controllers_yaml['moveit_simple_controller_manager'][f"{name}_{controller_name}"] = moveit_dict['moveit_simple_controller_manager'][controller_name] + @staticmethod + def _ensure_ros2_env() -> dict: + """确保 ROS2 环境变量正确设置,返回可用于子进程的 env dict""" + import sys + env = dict(os.environ) + conda_prefix = os.path.dirname(os.path.dirname(sys.executable)) + + if "AMENT_PREFIX_PATH" not in env or not env["AMENT_PREFIX_PATH"].strip(): + candidate = os.pathsep.join([conda_prefix, os.path.join(conda_prefix, "Library")]) + env["AMENT_PREFIX_PATH"] = candidate + os.environ["AMENT_PREFIX_PATH"] = candidate + + extra_bin_dirs = [ + os.path.join(conda_prefix, "Library", "bin"), + os.path.join(conda_prefix, "Library", "lib"), + os.path.join(conda_prefix, "Scripts"), + conda_prefix, + ] + current_path = env.get("PATH", "") + for d in extra_bin_dirs: + if d not in current_path: + current_path = d + os.pathsep + current_path + env["PATH"] = current_path + os.environ["PATH"] = current_path + + return env + def create_launch_description(self) -> LaunchDescription: """ 创建launch描述,包含robot_state_publisher和move_group节点 - Args: - urdf_str: URDF文本 - Returns: LaunchDescription: launch描述对象 """ - # 检查ROS 2环境变量 + launch_env = self._ensure_ros2_env() + if "AMENT_PREFIX_PATH" not in os.environ: raise OSError( "ROS 2环境未正确设置。需要设置 AMENT_PREFIX_PATH 环境变量。\n" @@ -290,7 +315,7 @@ class ResourceVisualization: {"robot_description": robot_description}, ros2_controllers, ], - env=dict(os.environ) + env=launch_env, ) ) for controller in self.moveit_controllers_yaml['moveit_simple_controller_manager']['controller_names']: @@ -300,7 +325,7 @@ class ResourceVisualization: executable="spawner", arguments=[f"{controller}", "--controller-manager", f"controller_manager"], output="screen", - env=dict(os.environ) + env=launch_env, ) ) controllers.append( @@ -309,7 +334,7 @@ class ResourceVisualization: executable="spawner", arguments=["joint_state_broadcaster", "--controller-manager", f"controller_manager"], output="screen", - env=dict(os.environ) + env=launch_env, ) ) for i in controllers: @@ -317,7 +342,6 @@ class ResourceVisualization: else: ros2_controllers = None - # 创建robot_state_publisher节点 robot_state_publisher = nd( package='robot_state_publisher', executable='robot_state_publisher', @@ -327,9 +351,8 @@ class ResourceVisualization: 'robot_description': robot_description, 'use_sim_time': False }, - # kinematics_dict ], - env=dict(os.environ) + env=launch_env, ) @@ -361,7 +384,7 @@ class ResourceVisualization: executable='move_group', output='screen', parameters=moveit_params, - env=dict(os.environ) + env=launch_env, ) @@ -379,13 +402,11 @@ class ResourceVisualization: arguments=['-d', f"{str(self.mesh_path)}/view_robot.rviz"], output='screen', parameters=[ - {'robot_description_kinematics': kinematics_dict, - }, + {'robot_description_kinematics': kinematics_dict}, robot_description_planning, planning_pipelines, - ], - env=dict(os.environ) + env=launch_env, ) self.launch_description.add_action(rviz_node) diff --git a/unilabos/devices/liquid_handling/liquid_handler_abstract.py b/unilabos/devices/liquid_handling/liquid_handler_abstract.py index 3d60bcd5..a10e1eed 100644 --- a/unilabos/devices/liquid_handling/liquid_handler_abstract.py +++ b/unilabos/devices/liquid_handling/liquid_handler_abstract.py @@ -1,5 +1,6 @@ from __future__ import annotations +from math import e import time import traceback from collections import Counter @@ -8,6 +9,8 @@ from typing import List, Sequence, Optional, Literal, Union, Iterator, Dict, Any from pylabrobot.liquid_handling import LiquidHandler, LiquidHandlerBackend, LiquidHandlerChatterboxBackend, Strictness from pylabrobot.liquid_handling.liquid_handler import TipPresenceProbingMethod from pylabrobot.liquid_handling.standard import GripDirection +from pylabrobot.resources.errors import TooLittleLiquidError, TooLittleVolumeError +from pylabrobot.resources.volume_tracker import no_volume_tracking from pylabrobot.resources import ( Resource, TipRack, @@ -68,7 +71,8 @@ class LiquidHandlerMiddleware(LiquidHandler): if simulator: if joint_config: self._simulate_backend = UniLiquidHandlerRvizBackend( - channel_num, kwargs["total_height"], joint_config=joint_config, lh_device_id=deck.name + channel_num, kwargs["total_height"], joint_config=joint_config, lh_device_id=deck.name, + simulate_rviz=kwargs.get("simulate_rviz", True) ) else: self._simulate_backend = LiquidHandlerChatterboxBackend(channel_num) @@ -205,23 +209,83 @@ class LiquidHandlerMiddleware(LiquidHandler): 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", + spread: Literal["wide", "tight", "custom"] = "custom", **backend_kwargs, ): if spread == "": - spread = "wide" + spread = "custom" + + for i, res in enumerate(resources): + tracker = getattr(res, "tracker", None) + if tracker is None or getattr(tracker, "is_disabled", False): + continue + need = float(vols[i]) if i < len(vols) else 0.0 + if blow_out_air_volume and i < len(blow_out_air_volume) and blow_out_air_volume[i] is not None: + need += float(blow_out_air_volume[i] or 0.0) + if need <= 0: + continue + try: + used = float(tracker.get_used_volume()) + except Exception: + used = 0.0 + if used >= need: + continue + mv = float(getattr(tracker, "max_volume", 0) or 0) + if used <= 0: + # 与旧逻辑一致:空孔优先加满(或极大默认),避免仅有 history 记录但 used=0 时不补液 + fill_vol = mv if mv > 0 else max(need, 50000.0) + else: + fill_vol = need - used + if mv > 0: + fill_vol = min(fill_vol, max(0.0, mv - used)) + try: + tracker.add_liquid(fill_vol) + except Exception: + try: + tracker.add_liquid(max(need - used, 1.0)) + except Exception: + history = getattr(tracker, "liquid_history", None) + if isinstance(history, list): + history.append(("auto_init", max(fill_vol, need, 1.0))) + if self._simulator: - return await self._simulate_handler.aspirate( - resources, - vols, - use_channels, - flow_rates, - offsets, - liquid_height, - blow_out_air_volume, - spread, - **backend_kwargs, - ) + try: + return await self._simulate_handler.aspirate( + resources, + vols, + use_channels, + flow_rates, + offsets, + liquid_height, + blow_out_air_volume, + spread, + **backend_kwargs, + ) + except (TooLittleLiquidError, TooLittleVolumeError) as e: + tracker_info = [] + for r in resources: + t = r.tracker + tracker_info.append( + f"{r.name}(used={t.get_used_volume():.1f}, " + f"free={t.get_free_volume():.1f}, max={r.max_volume})" + ) + if hasattr(self, "_ros_node") and self._ros_node is not None: + self._ros_node.lab_logger().warning( + f"[aspirate] volume tracker error, bypassing tracking. " + f"error={e}, vols={vols}, trackers={tracker_info}" + ) + with no_volume_tracking(): + return await self._simulate_handler.aspirate( + resources, + vols, + use_channels, + flow_rates, + offsets, + liquid_height, + blow_out_air_volume, + spread, + **backend_kwargs, + ) try: await super().aspirate( resources, @@ -234,6 +298,37 @@ class LiquidHandlerMiddleware(LiquidHandler): spread, **backend_kwargs, ) + except (TooLittleLiquidError, TooLittleVolumeError) as e: + tracker_info = [] + for r in resources: + t = getattr(r, "tracker", None) + if t is None: + tracker_info.append(f"{r.name}(no_tracker)") + else: + try: + tracker_info.append( + f"{r.name}(used={t.get_used_volume():.1f}, " + f"free={t.get_free_volume():.1f}, max={getattr(r, 'max_volume', '?')})" + ) + except Exception: + tracker_info.append(f"{r.name}(tracker_err)") + if hasattr(self, "_ros_node") and self._ros_node is not None: + self._ros_node.lab_logger().warning( + f"[aspirate] hardware tracker shortfall, retry without volume tracking. " + f"error={e}, vols={vols}, trackers={tracker_info}" + ) + with no_volume_tracking(): + await super().aspirate( + resources, + vols, + use_channels, + flow_rates, + offsets, + liquid_height, + blow_out_air_volume, + spread, + **backend_kwargs, + ) except ValueError as e: if "Resource is too small to space channels" in str(e) and spread != "custom": await super().aspirate( @@ -252,9 +347,7 @@ class LiquidHandlerMiddleware(LiquidHandler): res_samples = [] res_volumes = [] - # 处理 use_channels 为 None 的情况(通常用于单通道操作) if use_channels is None: - # 对于单通道操作,推断通道为 [0] channels_to_use = [0] * len(resources) else: channels_to_use = use_channels @@ -283,22 +376,70 @@ class LiquidHandlerMiddleware(LiquidHandler): ) -> SimpleReturn: if spread == "": spread = "wide" + + def _safe_dispense_volumes(_resources: Sequence[Container], _vols: List[float]) -> List[float]: + """将 dispense 体积裁剪到目标容器可用体积范围内,避免 volume tracker 报错。""" + safe: List[float] = [] + for res, vol in zip(_resources, _vols): + req = max(float(vol), 0.0) + free_volume = None + try: + tracker = getattr(res, "tracker", None) + get_free = getattr(tracker, "get_free_volume", None) + if callable(get_free): + free_volume = get_free() + except Exception: + free_volume = None + + if isinstance(free_volume, (int, float)): + req = min(req, max(float(free_volume), 0.0)) + safe.append(req) + return safe + + actual_vols = _safe_dispense_volumes(resources, vols) + if self._simulator: - return await self._simulate_handler.dispense( - resources, - vols, - use_channels, - flow_rates, - offsets, - liquid_height, - blow_out_air_volume, - spread, - **backend_kwargs, - ) + try: + return await self._simulate_handler.dispense( + resources, + actual_vols, + use_channels, + flow_rates, + offsets, + liquid_height, + blow_out_air_volume, + spread, + **backend_kwargs, + ) + except (TooLittleLiquidError, TooLittleVolumeError) as e: + tracker_info = [] + for r in resources: + t = r.tracker + tracker_info.append( + f"{r.name}(used={t.get_used_volume():.1f}, " + f"free={t.get_free_volume():.1f}, max={r.max_volume})" + ) + if hasattr(self, "_ros_node") and self._ros_node is not None: + self._ros_node.lab_logger().warning( + f"[dispense] volume tracker error, bypassing tracking. " + f"error={e}, vols={actual_vols}, trackers={tracker_info}" + ) + with no_volume_tracking(): + return await self._simulate_handler.dispense( + resources, + actual_vols, + use_channels, + flow_rates, + offsets, + liquid_height, + blow_out_air_volume, + spread, + **backend_kwargs, + ) try: await super().dispense( resources, - vols, + actual_vols, use_channels, flow_rates, offsets, @@ -311,7 +452,7 @@ class LiquidHandlerMiddleware(LiquidHandler): if "Resource is too small to space channels" in str(e) and spread != "custom": await super().dispense( resources, - vols, + actual_vols, use_channels, flow_rates, offsets, @@ -322,9 +463,31 @@ class LiquidHandlerMiddleware(LiquidHandler): ) else: raise + except TooLittleVolumeError: + # 再兜底一次:按实时 free volume 重新裁剪后重试,避免并发状态更新导致的瞬时超量 + retry_vols = _safe_dispense_volumes(resources, actual_vols) + if any(v > 0 for v in retry_vols): + await super().dispense( + resources, + retry_vols, + use_channels, + flow_rates, + offsets, + liquid_height, + blow_out_air_volume, + spread, + **backend_kwargs, + ) + actual_vols = retry_vols + else: + actual_vols = retry_vols res_samples = [] res_volumes = [] - for resource, volume, channel in zip(resources, vols, use_channels): + if use_channels is None: + channels_to_use = [0] * len(resources) + else: + channels_to_use = use_channels + for resource, volume, channel in zip(resources, actual_vols, channels_to_use): res_uuid = self.pending_liquids_dict[channel][EXTRA_SAMPLE_UUID] self.pending_liquids_dict[channel]["volume"] -= volume resource.unilabos_extra[EXTRA_SAMPLE_UUID] = res_uuid @@ -660,6 +823,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): simulator: bool = False, channel_num: int = 8, total_height: float = 310, + **kwargs, ): """Initialize a LiquidHandler. @@ -699,18 +863,100 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): except Exception: backend_cls = None if backend_cls is not None and isinstance(backend_cls, type): - backend_type = backend_cls(**backend_dict) # pass the rest of dict as kwargs + if simulator: + backend_type = LiquidHandlerChatterboxBackend(channel_num) + else: + init_kwargs = dict(backend_dict) + init_kwargs["total_height"] = total_height + init_kwargs.update(kwargs) + backend_type = backend_cls(**init_kwargs) except Exception as exc: raise RuntimeError(f"Failed to convert backend type '{type_str}' to class: {exc}") else: backend_type = backend self._simulator = simulator self.group_info = dict() - super().__init__(backend_type, deck, simulator, channel_num) + super().__init__(backend_type, deck, simulator, channel_num, total_height=total_height, **kwargs) def post_init(self, ros_node: BaseROS2DeviceNode): self._ros_node = ros_node + async def _resolve_to_plr_resources( + self, + items: Sequence[Union[Container, TipRack, Dict[str, Any]]], + ) -> List[Union[Container, TipRack]]: + """将 dict 格式的资源解析为 PLR 实例。若全部已是 PLR,直接返回。""" + dict_items = [(i, x) for i, x in enumerate(items) if isinstance(x, dict)] + if not dict_items: + return list(items) + if not hasattr(self, "_ros_node") or self._ros_node is None: + raise ValueError( + "传入 dict 格式的 sources/targets/tip_racks 时,需通过 post_init 注入 _ros_node," + "才能从物料系统按 uuid 解析为 PLR 资源。" + ) + uuids = [x.get("uuid") or x.get("unilabos_uuid") for _, x in dict_items] + if any(u is None for u in uuids): + raise ValueError("dict 格式的资源必须包含 uuid 或 unilabos_uuid 字段") + + def _resolve_from_local_by_uuids() -> List[Union[Container, TipRack]]: + resolved_locals: List[Union[Container, TipRack]] = [] + missing: List[str] = [] + for uid in uuids: + matches = self._ros_node.resource_tracker.figure_resource({"uuid": uid}, try_mode=True) + if matches: + resolved_locals.append(cast(Union[Container, TipRack], matches[0])) + else: + missing.append(str(uid)) + if missing: + raise ValueError( + f"远端资源树未返回且本地资源也未命中,缺失 UUID: {missing}" + ) + return resolved_locals + + # 优先走远端资源树查询;若远端为空或 requested_uuids 无法解析,则降级到本地 tracker 按 UUID 解析。 + resolved = [] + try: + resource_tree = await self._ros_node.get_resource(uuids) + plr_list = resource_tree.to_plr_resources(requested_uuids=uuids) + for uid, plr in zip(uuids, plr_list): + local_matches = self._ros_node.resource_tracker.figure_resource({"uuid": uid}, try_mode=True) + if local_matches: + local = cast(Union[Container, TipRack], local_matches[0]) + else: + local = cast(Union[Container, TipRack], plr) + if hasattr(plr, "unilabos_extra") and hasattr(local, "unilabos_extra"): + local.unilabos_extra = getattr(plr, "unilabos_extra", {}).copy() + if local is not plr and hasattr(plr, "tracker") and hasattr(local, "tracker"): + local_tracker = local.tracker + plr_tracker = plr.tracker + local_history = getattr(local_tracker, "liquid_history", None) + plr_history = getattr(plr_tracker, "liquid_history", None) + if (isinstance(local_history, list) and len(local_history) == 0 + and isinstance(plr_history, list) and len(plr_history) > 0): + local_tracker.liquid_history = list(plr_history) + resolved.append(local) + if len(resolved) != len(uuids): + raise ValueError( + f"远端资源解析数量不匹配: requested={len(uuids)}, resolved={len(resolved)}" + ) + except Exception: + resolved = _resolve_from_local_by_uuids() + + result = list(items) + for (idx, orig_dict), res in zip(dict_items, resolved): + if isinstance(orig_dict, dict) and hasattr(res, "tracker"): + tracker = res.tracker + local_history = getattr(tracker, "liquid_history", None) + if isinstance(local_history, list) and len(local_history) == 0: + data = orig_dict.get("data") or {} + dict_history = data.get("liquid_history") + if isinstance(dict_history, list) and len(dict_history) > 0: + tracker.liquid_history = [ + (name, float(vol)) for name, vol in dict_history + ] + result[idx] = res + return result + @classmethod def set_liquid(cls, wells: list[Well], liquid_names: list[str], volumes: list[float]) -> SetLiquidReturn: """Set the liquid in a well. @@ -724,9 +970,18 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): wells=ResourceTreeSet.from_plr_resources(wells, known_newly_created=False).dump(), volumes=res_volumes # type: ignore ) + def _clamp_volume(resource: Union[Well, Container], volume: float) -> float: + # 防止初始化液量超过容器容量,导致后续 dispense 时 free volume 为负 + clamped = max(float(volume), 0.0) + max_volume = getattr(resource, "max_volume", None) + if isinstance(max_volume, (int, float)) and max_volume > 0: + clamped = min(clamped, float(max_volume)) + return clamped + for well, liquid_name, volume in zip(wells, liquid_names, volumes): - well.set_liquids([(liquid_name, volume)]) # type: ignore - res_volumes.append(volume) + safe_volume = _clamp_volume(well, volume) + well.set_liquids([(liquid_name, safe_volume)]) # type: ignore + res_volumes.append(safe_volume) return SetLiquidReturn( wells=ResourceTreeSet.from_plr_resources(wells, known_newly_created=False).dump(), volumes=res_volumes # type: ignore @@ -756,9 +1011,18 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): volumes=res_volumes, ) + def _clamp_volume(resource: Union[Well, Container], volume: float) -> float: + # 防止初始化液量超过容器容量,导致后续 dispense 时 free volume 为负 + clamped = max(float(volume), 0.0) + max_volume = getattr(resource, "max_volume", None) + if isinstance(max_volume, (int, float)) and max_volume > 0: + clamped = min(clamped, float(max_volume)) + return clamped + for well, liquid_name, volume in zip(wells, liquid_names, volumes): - well.set_liquids([(liquid_name, volume)]) # type: ignore - res_volumes.append(volume) + safe_volume = _clamp_volume(well, volume) + well.set_liquids([(liquid_name, safe_volume)]) # type: ignore + res_volumes.append(safe_volume) task = ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{"resources": wells}) submit_time = time.time() @@ -891,7 +1155,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): for _ in range(len(sources)): tip = [] for __ in range(len(use_channels)): - tip.extend(self._get_next_tip()) + tip.append(self._get_next_tip()) await self.pick_up_tips(tip) await self.aspirate( resources=[sources[_]], @@ -931,7 +1195,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): for i in range(0, len(sources), 8): tip = [] for _ in range(len(use_channels)): - tip.extend(self._get_next_tip()) + tip.append(self._get_next_tip()) await self.pick_up_tips(tip) current_targets = waste_liquid[i : i + 8] current_reagent_sources = sources[i : i + 8] @@ -1025,7 +1289,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): for _ in range(len(targets)): tip = [] for x in range(len(use_channels)): - tip.extend(self._get_next_tip()) + tip.append(self._get_next_tip()) await self.pick_up_tips(tip) await self.aspirate( @@ -1077,7 +1341,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): for i in range(0, len(targets), 8): tip = [] for _ in range(len(use_channels)): - tip.extend(self._get_next_tip()) + tip.append(self._get_next_tip()) await self.pick_up_tips(tip) current_targets = targets[i : i + 8] current_reagent_sources = reagent_sources[i : i + 8] @@ -1151,9 +1415,9 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): # --------------------------------------------------------------- async def transfer_liquid( self, - sources: Sequence[Container], - targets: Sequence[Container], - tip_racks: Sequence[TipRack], + sources: Sequence[Union[Container, Dict[str, Any]]], + targets: Sequence[Union[Container, Dict[str, Any]]], + tip_racks: Sequence[Union[TipRack, Dict[str, Any]]], *, use_channels: Optional[List[int]] = None, asp_vols: Union[List[float], float], @@ -1164,6 +1428,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): touch_tip: bool = False, liquid_height: Optional[List[Optional[float]]] = None, blow_out_air_volume: Optional[List[Optional[float]]] = None, + blow_out_air_volume_before: 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", @@ -1186,12 +1451,13 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): asp_vols, dis_vols Single volume (µL) or list. Automatically expanded based on transfer mode. sources, targets - Containers (wells or plates). Length determines transfer mode: + Containers (wells or plates),可为 PLR 实例或 dict(含 uuid 字段,将自动解析)。 + 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. + One or more TipRacks(可为 PLR 实例或含 uuid 的 dict)providing fresh tips. is_96_well Set *True* to use the 96‑channel head. mix_stage @@ -1202,12 +1468,16 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): Number of mix cycles. If *None* (default) no mixing occurs regardless of mix_stage. """ + # 若传入 dict(含 uuid),解析为 PLR Container/TipRack + sources = await self._resolve_to_plr_resources(sources) + targets = await self._resolve_to_plr_resources(targets) + tip_racks = list(await self._resolve_to_plr_resources(tip_racks)) num_sources = len(sources) num_targets = len(targets) len_asp_vols = len(asp_vols) len_dis_vols = len(dis_vols) # 确保 use_channels 有默认值 - if use_channels is None: + if use_channels is None or len(use_channels) == 0: # 默认使用设备所有通道(例如 8 通道移液站默认就是 0-7) use_channels = list(range(self.channel_num)) if self.channel_num == 8 else [0] elif len(use_channels) == 8: @@ -1259,11 +1529,25 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): if len(use_channels) != 8: max_len = max(num_sources, num_targets) + prev_dropped = True # 循环开始前通道上无 tip for i in range(max_len): - # 辅助函数:安全地从列表中获取元素,如果列表为空则返回None - def safe_get(lst, idx, default=None): - return [lst[idx]] if lst else default + # 辅助函数: + # - wrap=True: 返回 [value](用于 liquid_height 等列表参数) + # - wrap=False: 返回 value(用于 mix_* 标量参数) + def safe_get(value, idx, default=None, wrap: bool = True): + if value is None: + return default + try: + if isinstance(value, (list, tuple)): + if len(value) == 0: + return default + item = value[idx % len(value)] + else: + item = value + return [item] if wrap else item + except Exception: + return default # 动态构建参数字典,只传递实际提供的参数 kwargs = { @@ -1288,99 +1572,48 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): kwargs['liquid_height'] = safe_get(liquid_height, i) if blow_out_air_volume is not None: kwargs['blow_out_air_volume'] = safe_get(blow_out_air_volume, i) + if blow_out_air_volume_before is not None: + kwargs['blow_out_air_volume_before'] = safe_get(blow_out_air_volume_before, i) if spread is not None: kwargs['spread'] = spread if mix_stage is not None: - kwargs['mix_stage'] = safe_get(mix_stage, i) + kwargs['mix_stage'] = safe_get(mix_stage, i, wrap=False) if mix_times is not None: - kwargs['mix_times'] = safe_get(mix_times, i) + kwargs['mix_times'] = safe_get(mix_times, i, wrap=False) if mix_vol is not None: - kwargs['mix_vol'] = safe_get(mix_vol, i) + kwargs['mix_vol'] = safe_get(mix_vol, i, wrap=False) if mix_rate is not None: - kwargs['mix_rate'] = safe_get(mix_rate, i) + kwargs['mix_rate'] = safe_get(mix_rate, i, wrap=False) if mix_liquid_height is not None: - kwargs['mix_liquid_height'] = safe_get(mix_liquid_height, i) + kwargs['mix_liquid_height'] = safe_get(mix_liquid_height, i, wrap=False) if delays is not None: kwargs['delays'] = safe_get(delays, i) + cur_source = sources[i % num_sources] + cur_target = targets[i % num_targets] + + # drop: 仅当下一轮的 source 和 target 都相同时才保留 tip(下一轮可以复用) + drop_tip = True + if i < max_len - 1: + next_source = sources[(i + 1) % num_sources] + next_target = targets[(i + 1) % num_targets] + if cur_target is next_target and cur_source is next_source: + drop_tip = False + + # pick_up: 仅当上一轮保留了 tip(未 drop)且 source 相同时才复用 + pick_up_tip = True + if i > 0 and not prev_dropped: + prev_source = sources[(i - 1) % num_sources] + if cur_source is prev_source: + pick_up_tip = False + + prev_dropped = drop_tip + + kwargs['pick_up'] = pick_up_tip + kwargs['drop'] = drop_tip + await self._transfer_base_method(**kwargs) - - - # if num_sources == 1 and num_targets > 1: - # # 模式1: 一对多 (1 source -> N targets) - # await self._transfer_one_to_many( - # 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, - # ) - # 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." - # ) return TransferLiquidReturn( sources=ResourceTreeSet.from_plr_resources(list(sources), known_newly_created=False).dump(), # type: ignore @@ -1394,6 +1627,8 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): use_channels: List[int], asp_vols: List[float], dis_vols: List[float], + pick_up: bool = True, + drop: bool = True, **kwargs ): @@ -1404,6 +1639,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): touch_tip = kwargs.get('touch_tip', False) liquid_height = kwargs.get('liquid_height') blow_out_air_volume = kwargs.get('blow_out_air_volume') + blow_out_air_volume_before = kwargs.get('blow_out_air_volume_before') spread = kwargs.get('spread', 'wide') mix_stage = kwargs.get('mix_stage') mix_times = kwargs.get('mix_times') @@ -1413,8 +1649,17 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): delays = kwargs.get('delays') tip = [] - tip.extend(self._get_next_tip()) - await self.pick_up_tips(tip) + if pick_up: + tip.append(self._get_next_tip()) + await self.pick_up_tips(tip) + blow_out_air_volume_before_vol = 0.0 + if blow_out_air_volume_before is not None and len(blow_out_air_volume_before) > 0: + blow_out_air_volume_before_vol = float(blow_out_air_volume_before[0] or 0.0) + blow_out_air_volume_vol = 0.0 + if blow_out_air_volume is not None and len(blow_out_air_volume) > 0: + blow_out_air_volume_vol = float(blow_out_air_volume[0] or 0.0) + # PLR 的 blow_out_air_volume 是空气参数,不计入液体体积。 + # before 空气通过单独预吸实现,after 空气通过 blow_out_air_volume 参数实现。 if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0: await self.mix( @@ -1427,6 +1672,26 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): use_channels=use_channels, ) + if blow_out_air_volume_before_vol > 0: + source_tracker = getattr(sources[0], "tracker", None) + source_tracker_was_disabled = bool(getattr(source_tracker, "is_disabled", False)) + try: + if source_tracker is not None and hasattr(source_tracker, "disable"): + source_tracker.disable() + await self.aspirate( + resources=[sources[0]], + vols=[0], + use_channels=use_channels, + flow_rates=None, + offsets=[Coordinate(x=0, y=0, z=sources[0].get_size_z())], + liquid_height=None, + blow_out_air_volume=[blow_out_air_volume_before_vol], + spread="custom", + ) + finally: + if source_tracker is not None: + source_tracker.enable() + await self.aspirate( resources=[sources[0]], vols=[asp_vols[0]], @@ -1435,7 +1700,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): 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 + [blow_out_air_volume_vol] if blow_out_air_volume_vol > 0 else None ), spread=spread, ) @@ -1447,9 +1712,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): use_channels=use_channels, flow_rates=[dis_flow_rates[0]] if dis_flow_rates and len(dis_flow_rates) > 0 else None, offsets=[offsets[0]] if offsets and len(offsets) > 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 - ), + blow_out_air_volume=[blow_out_air_volume_vol+blow_out_air_volume_before_vol], liquid_height=[liquid_height[0]] if liquid_height and len(liquid_height) > 0 else None, spread=spread, ) @@ -1468,629 +1731,9 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): if delays is not None and len(delays) > 1: await self.custom_delay(seconds=delays[0]) await self.touch_tip(targets[0]) - await self.discard_tips(use_channels=use_channels) - - 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): - if len(asp_vols) == 1: - asp_vols = [asp_vols[0]] * len(targets) - else: - raise ValueError(f"Length of `asp_vols` {len(asp_vols)} must match `targets` {len(targets)}.") - if len(dis_vols) != len(targets): - if len(dis_vols) == 1: - dis_vols = [dis_vols[0]] * len(targets) - else: - 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(self._get_next_tip()) - await self.pick_up_tips(tip) - - if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0: - await self.mix( - targets=[targets[_]], - mix_time=mix_times, - mix_vol=mix_vol, - offsets=offsets if offsets else None, - height_to_bottom=mix_liquid_height if mix_liquid_height else None, - mix_rate=mix_rate if mix_rate else None, - use_channels=use_channels, - ) - - 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, - 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, - use_channels=use_channels, - ) - 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: - if len(targets) % 8 != 0: - raise ValueError(f"Length of `targets` {len(targets)} must be a multiple of 8 for 8-channel mode.") - - for i in range(0, len(targets), 8): - tip = [] - for _ in range(len(use_channels)): - tip.extend(self._get_next_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 - - if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0: - await self.mix( - targets=current_targets, - mix_time=mix_times, - mix_vol=mix_vol, - offsets=offsets if offsets else None, - height_to_bottom=mix_liquid_height if mix_liquid_height else None, - mix_rate=mix_rate if mix_rate else None, - use_channels=use_channels, - ) - - 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 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, - use_channels=use_channels, - ) - 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(self._get_next_tip()) - await self.pick_up_tips(tip) - - if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0: - for idx, target in enumerate(targets): - await self.mix( - targets=[target], - mix_time=mix_times, - mix_vol=mix_vol, - offsets=offsets[idx : idx + 1] if offsets and len(offsets) > idx else None, - height_to_bottom=mix_liquid_height if mix_liquid_height else None, - mix_rate=mix_rate if mix_rate else None, - use_channels=use_channels, - ) - - # 从源容器吸液(总体积) - 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, - use_channels=use_channels, - ) - if touch_tip: - await self.touch_tip([target]) - + if drop: 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(self._get_next_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 - ) - - if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0: - await self.mix( - targets=current_targets, - mix_time=mix_times, - mix_vol=mix_vol, - offsets=offsets[i : i + 8] if offsets else None, - height_to_bottom=mix_liquid_height if mix_liquid_height else None, - mix_rate=mix_rate if mix_rate else None, - use_channels=use_channels, - ) - - # 从源容器吸液(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, - use_channels=use_channels, - ) - - if touch_tip: - await self.touch_tip(current_targets) - - 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): - if len(asp_vols) == 1: - asp_vols = [asp_vols[0]] * len(sources) - else: - 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)}." - ) - - need_mix_after = mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0 - defer_final_discard = need_mix_after or touch_tip - - if len(use_channels) == 1: - # 单通道模式:多次吸液,一次分液 - - # 如果需要 before mix,先 pick up tip 并执行 mix - if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0: - tip = [] - for _ in range(len(use_channels)): - tip.extend(self._get_next_tip()) - await self.pick_up_tips(tip) - - 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, - use_channels=use_channels, - ) - - await self.discard_tips(use_channels=use_channels) - - # 从每个源容器吸液并分液到目标容器 - for idx, source in enumerate(sources): - tip = [] - for _ in range(len(use_channels)): - tip.extend(self._get_next_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]) - - if not (defer_final_discard and idx == len(sources) - 1): - await self.discard_tips(use_channels=use_channels) - - # 最后在目标容器中混合(如果需要) - if need_mix_after: - 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, - use_channels=use_channels, - ) - - if touch_tip: - await self.touch_tip([target]) - - if defer_final_discard: - await self.discard_tips(use_channels=use_channels) - - 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个源 - if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0: - tip = [] - for _ in range(len(use_channels)): - tip.extend(self._get_next_tip()) - await self.pick_up_tips(tip) - - 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, - use_channels=use_channels, - ) - - await self.discard_tips([0,1,2,3,4,5,6,7]) - - for i in range(0, len(sources), 8): - tip = [] - for _ in range(len(use_channels)): - tip.extend(self._get_next_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]) - - if not (defer_final_discard and i + 8 >= len(sources)): - await self.discard_tips([0,1,2,3,4,5,6,7]) - - # 最后在目标容器中混合(如果需要) - if need_mix_after: - 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, - use_channels=use_channels, - ) - - if touch_tip: - await self.touch_tip([target]) - - if defer_final_discard: - await self.discard_tips([0,1,2,3,4,5,6,7]) - # except Exception as e: # traceback.print_exc() # raise RuntimeError(f"Liquid addition failed: {e}") from e @@ -2209,23 +1852,77 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware): def iter_tips(self, tip_racks: Sequence[TipRack]) -> Iterator[Resource]: """Yield tips from a list of TipRacks one-by-one until depleted.""" for rack in tip_racks: - for tip in rack: - yield tip - # raise RuntimeError("Out of tips!") + if isinstance(rack, TipSpot): + yield rack + elif isinstance(rack, TipRack): + for item in rack: + if isinstance(item, list): + yield from item + else: + yield item def _get_next_tip(self): """从 current_tip 迭代器获取下一个 tip,耗尽时抛出明确错误而非 StopIteration""" try: return next(self.current_tip) except StopIteration as e: - raise RuntimeError("Tip rack exhausted: no more tips available for transfer") from e + diag_parts = [] + tip_racks = getattr(self, 'tip_racks', None) + if tip_racks is not None: + for idx, rack in enumerate(tip_racks): + r_name = getattr(rack, 'name', '?') + r_type = type(rack).__name__ + is_tr = isinstance(rack, TipRack) + is_ts = isinstance(rack, TipSpot) + n_children = len(getattr(rack, 'children', [])) + diag_parts.append( + f"rack[{idx}] name={r_name}, type={r_type}, " + f"is_TipRack={is_tr}, is_TipSpot={is_ts}, children={n_children}" + ) + else: + diag_parts.append("tip_racks=None") + by_type = getattr(self, '_tip_racks_by_type', {}) + diag_parts.append(f"_tip_racks_by_type keys={list(by_type.keys())}") + raise RuntimeError( + f"Tip rack exhausted: no more tips available for transfer. " + f"Diagnostics: {'; '.join(diag_parts)}" + ) from e def set_tiprack(self, tip_racks: Sequence[TipRack]): - """Set the tip racks for the liquid handler.""" + """Set the tip racks for the liquid handler. + + Groups tip racks by type name (``type(rack).__name__``). + - Only actual TipRack / TipSpot instances are registered. + - If a rack has already been registered (by ``name``), it is skipped. + - If a rack is new and its type already exists, it is appended to that type's list. + - If the type is new, a new key-value pair is created. + + If the current ``tip_racks`` contain no valid TipRack/TipSpot (e.g. a + Plate was passed by mistake), the iterator falls back to all previously + registered racks. + """ + if not hasattr(self, '_tip_racks_by_type'): + self._tip_racks_by_type: Dict[str, List[TipRack]] = {} + self._seen_rack_names: Set[str] = set() + + for rack in tip_racks: + if not isinstance(rack, (TipRack, TipSpot)): + continue + rack_name = rack.name if hasattr(rack, 'name') else str(id(rack)) + if rack_name in self._seen_rack_names: + continue + self._seen_rack_names.add(rack_name) + type_key = type(rack).__name__ + if type_key not in self._tip_racks_by_type: + self._tip_racks_by_type[type_key] = [] + self._tip_racks_by_type[type_key].append(rack) + + valid_racks = [r for r in tip_racks if isinstance(r, (TipRack, TipSpot))] + if not valid_racks: + valid_racks = [r for racks in self._tip_racks_by_type.values() for r in racks] self.tip_racks = tip_racks - tip_iter = self.iter_tips(tip_racks) - self.current_tip = tip_iter + self.current_tip = self.iter_tips(valid_racks) async def move_to(self, well: Well, dis_to_top: float = 0, channel: int = 0): """ diff --git a/unilabos/devices/liquid_handling/prcxi/prcxi.py b/unilabos/devices/liquid_handling/prcxi/prcxi.py index 8c06d798..c8bb83cf 100644 --- a/unilabos/devices/liquid_handling/prcxi/prcxi.py +++ b/unilabos/devices/liquid_handling/prcxi/prcxi.py @@ -87,19 +87,31 @@ class MatrixInfo(TypedDict): WorkTablets: list[WorkTablets] +def _get_slot_number(resource) -> Optional[int]: + """从 resource 的 unilabos_extra["update_resource_site"](如 "T13")或位置反算槽位号。""" + extra = getattr(resource, "unilabos_extra", {}) or {} + site = extra.get("update_resource_site", "") + if site: + digits = "".join(c for c in str(site) if c.isdigit()) + return int(digits) if digits else None + loc = getattr(resource, "location", None) + if loc is not None and loc.x is not None and loc.y is not None: + col = round((loc.x - 5) / 137.5) + row = round(3 - (loc.y - 13) / 96) + idx = row * 4 + col + if 0 <= idx < 16: + return idx + 1 + return None + + class PRCXI9300Deck(Deck): """PRCXI 9300 的专用 Deck 类,继承自 Deck。 该类定义了 PRCXI 9300 的工作台布局和槽位信息。 """ - # 9320: 4列×4行 = 16 slots(Y轴从上往下递减, T1在左上角) - _9320_SITE_POSITIONS = [ - (0, 288, 0), (138, 288, 0), (276, 288, 0), (414, 288, 0), # T1-T4 (第1行, 最上) - (0, 192, 0), (138, 192, 0), (276, 192, 0), (414, 192, 0), # T5-T8 (第2行) - (0, 96, 0), (138, 96, 0), (276, 96, 0), (414, 96, 0), # T9-T12 (第3行) - (0, 0, 0), (138, 0, 0), (276, 0, 0), (414, 0, 0), # T13-T16 (第4行, 最下) - ] + _9320_SITE_POSITIONS = [((i%4)*137.5+5, (3-int(i/4))*96+13, 0) for i in range(0, 16)] + # 9300: 3列×2行 = 6 slots,间距与9320相同(X: 138mm, Y: 96mm) _9300_SITE_POSITIONS = [ @@ -113,15 +125,13 @@ class PRCXI9300Deck(Deck): _DEFAULT_CONTENT_TYPE = ["plate", "tip_rack", "plates", "tip_racks", "tube_rack", "adaptor", "plateadapter", "module", "trash"] def __init__(self, name: str, size_x: float, size_y: float, size_z: float, - sites: Optional[List[Dict[str, Any]]] = None, model: str = "9320", **kwargs): - super().__init__(size_x, size_y, size_z, name) - self.model = model + sites: Optional[List[Dict[str, Any]]] = None, **kwargs): + super().__init__( size_x, size_y, size_z, name=name) if sites is not None: self.sites: List[Dict[str, Any]] = [dict(s) for s in sites] else: - positions = self._9300_SITE_POSITIONS if model == "9300" else self._9320_SITE_POSITIONS self.sites = [] - for i, (x, y, z) in enumerate(positions): + for i, (x, y, z) in enumerate(self._DEFAULT_SITE_POSITIONS): self.sites.append({ "label": f"T{i + 1}", "visible": True, @@ -133,6 +143,7 @@ class PRCXI9300Deck(Deck): self._ordering = collections.OrderedDict( (site["label"], None) for site in self.sites ) + self.root = self.get_root() def _get_site_location(self, idx: int) -> Coordinate: pos = self.sites[idx]["position"] @@ -175,7 +186,10 @@ class PRCXI9300Deck(Deck): raise ValueError(f"No available site on deck '{self.name}' for resource '{resource.name}'") if not reassign and self._get_site_resource(idx) is not None: - raise ValueError(f"Site {idx} ('{self.sites[idx]['label']}') is already occupied") + existing = self.root.get_resource(resource.name) + if existing is not resource and existing.parent is not None: + existing.parent.unassign_child_resource(existing) + loc = self._get_site_location(idx) super().assign_child_resource(resource, location=loc, reassign=reassign) @@ -761,23 +775,49 @@ class PRCXI9300Handler(LiquidHandlerAbstract): simulator=False, step_mode=False, matrix_id="", - is_9320=None, + is_9320=False, + start_rail=2, + rail_nums=4, + rail_interval=0, + x_increase = -0.003636, + y_increase = -0.003636, + x_offset = 9.2, + y_offset = -27.98, + deck_z = 300, + deck_y = 400, + rail_width=27.5, + xy_coupling = -0.0045, ): + + self.deck_x = (start_rail + rail_nums*5 + (rail_nums-1)*rail_interval) * rail_width + self.deck_y = deck_y + self.deck_z = deck_z + self.x_increase = x_increase + self.y_increase = y_increase + self.x_offset = x_offset + self.y_offset = y_offset + self.xy_coupling = xy_coupling + self.left_2_claw = Coordinate(-130.2, 34, -134) + self.right_2_left = Coordinate(22,-1, 8) + plate_positions = [] + tablets_info = [] - for site_id in range(len(deck.sites)): - child = deck._get_site_resource(site_id) - # 如果放其他类型的物料,是不可以的 - if hasattr(child, "_unilabos_state") and "Material" in child._unilabos_state: - number = site_id + 1 - tablets_info.append( - WorkTablets( - Number=number, Code=f"T{number}", Material=child._unilabos_state["Material"] - ) - ) + if is_9320 is None: is_9320 = getattr(deck, 'model', '9300') == '9320' if is_9320: print("当前设备是9320") + else: + for site_id in range(len(deck.sites)): + child = deck._get_site_resource(site_id) + # 如果放其他类型的物料,是不可以的 + if hasattr(child, "_unilabos_state") and "Material" in child._unilabos_state: + number = site_id + 1 + tablets_info.append( + WorkTablets( + Number=number, Code=f"T{number}", Material=child._unilabos_state["Material"] + ) + ) # 始终初始化 step_mode 属性 self.step_mode = False if step_mode: @@ -789,6 +829,190 @@ class PRCXI9300Handler(LiquidHandlerAbstract): tablets_info, host, port, timeout, channel_num, axis, setup, debug, matrix_id, is_9320 ) super().__init__(backend=self._unilabos_backend, deck=deck, simulator=simulator, channel_num=channel_num) + self._first_transfer_done = False + + @staticmethod + def _get_slot_number(resource) -> Optional[int]: + """从 resource 的 unilabos_extra["update_resource_site"](如 "T13")或位置反算槽位号。""" + return _get_slot_number(resource) + + def _match_and_create_matrix(self): + """首次 transfer_liquid 时,根据 deck 上的 resource 自动匹配耗材并创建 WorkTabletMatrix。""" + backend = self._unilabos_backend + api = backend.api_client + + if backend.matrix_id: + return + + material_list = api.get_all_materials() + if not material_list: + return + + # 按 materialEnum 分组: {enum_value: [material, ...]} + material_dict = {} + material_uuid_map = {} + for m in material_list: + enum_key = m.get("materialEnum") + material_dict.setdefault(enum_key, []).append(m) + if "uuid" in m: + material_uuid_map[m["uuid"]] = m + + work_tablets = [] + slot_none = [i for i in range(1, 17)] + + for child in self.deck.children: + + resource = child + number = self._get_slot_number(resource) + if number is None: + continue + + # 如果 resource 已有 Material UUID,直接使用 + if hasattr(resource, "_unilabos_state") and "Material" in getattr(resource, "_unilabos_state", {}): + mat_uuid = resource._unilabos_state["Material"].get("uuid") + if mat_uuid and mat_uuid in material_uuid_map: + work_tablets.append({"Number": number, "Material": material_uuid_map[mat_uuid]}) + continue + + # 根据 resource 类型推断 materialEnum + # MaterialEnum: Other=0, Tips=1, DeepWellPlate=2, PCRPlate=3, ELISAPlate=4, Reservoir=5, WasteBox=6 + expected_enum = None + if isinstance(resource, PRCXI9300TipRack) or isinstance(resource, TipRack): + expected_enum = 1 # Tips + elif isinstance(resource, PRCXI9300Trash) or isinstance(resource, Trash): + expected_enum = 6 # WasteBox + elif isinstance(resource, (PRCXI9300Plate, Plate)): + expected_enum = None # Plate 可能是 DeepWellPlate/PCRPlate/ELISAPlate,不限定 + + + # 根据 expected_enum 筛选候选耗材列表 + if expected_enum is not None: + candidates = material_dict.get(expected_enum, []) + else: + # expected_enum 未确定时,搜索所有耗材 + candidates = material_list + + # 根据 children 个数和容量匹配最相似的耗材 + num_children = len(resource.children) + child_max_volume = None + if resource.children: + first_child = resource.children[0] + if hasattr(first_child, "max_volume") and first_child.max_volume is not None: + child_max_volume = first_child.max_volume + + best_material = None + best_score = float("inf") + + for material in candidates: + hole_count = (material.get("HoleRow", 0) or 0) * (material.get("HoleColum", 0) or 0) + material_volume = material.get("Volume", 0) or 0 + + # 孔数差异(高权重优先匹配孔数) + hole_diff = abs(num_children - hole_count) + # 容量差异(归一化) + if child_max_volume is not None and material_volume > 0: + vol_diff = abs(child_max_volume - material_volume) / material_volume + else: + vol_diff = 0 + + score = hole_diff * 1000 + vol_diff + if score < best_score: + best_score = score + best_material = material + + if best_material: + work_tablets.append({"Number": number, "Material": best_material}) + slot_none.remove(number) + + if not work_tablets: + return + + matrix_id = str(uuid.uuid4()) + matrix_info = { + "MatrixId": matrix_id, + "MatrixName": matrix_id, + "WorkTablets": work_tablets + + [{"Number": number, "Material": {"uuid": "730067cf07ae43849ddf4034299030e9"}} for number in slot_none], + } + res = api.add_WorkTablet_Matrix(matrix_info) + if res.get("Success"): + backend.matrix_id = matrix_id + backend.matrix_info = matrix_info + + # 重新计算所有槽位的位置(初始化时 deck 可能为空,此时才有资源) + pipetting_positions = [] + plate_positions = [] + for child in self.deck.children: + number = self._get_slot_number(child) + + if number is None: + continue + + pos = self.plr_pos_to_prcxi(child) + plate_positions.append({"Number": number, "XPos": pos.x, "YPos": pos.y, "ZPos": pos.z}) + + if child.children: + pip_pos = self.plr_pos_to_prcxi(child.children[0], self.left_2_claw) + else: + pip_pos = self.plr_pos_to_prcxi(child, Coordinate(-100, self.left_2_claw.y, self.left_2_claw.z)) + half_x = child.get_size_x() / 2 * abs(1 + self.x_increase) + z_wall = child.get_size_z() + + pipetting_positions.append({ + "Number": number, + "XPos": pip_pos.x, + "YPos": pip_pos.y, + "ZPos": pip_pos.z, + "X_Left": half_x, + "X_Right": half_x, + "ZAgainstTheWall": pip_pos.z - z_wall, + "X2Pos": pip_pos.x + self.right_2_left.x, + "Y2Pos": pip_pos.y + self.right_2_left.y, + "Z2Pos": pip_pos.z + self.right_2_left.z, + "X2_Left": half_x, + "X2_Right": half_x, + "ZAgainstTheWall2": pip_pos.z - z_wall, + }) + + if pipetting_positions: + api.update_pipetting_position(matrix_id, pipetting_positions) + # 更新 backend 中的 plate_positions + backend.plate_positions = plate_positions + + if plate_positions: + api.update_clamp_jaw_position(matrix_id, plate_positions) + + + print(f"Auto-matched materials and created matrix: {matrix_id}") + else: + raise PRCXIError(f"Failed to create auto-matched matrix: {res.get('Message', 'Unknown error')}") + + def plr_pos_to_prcxi(self, resource: Resource, offset: Coordinate = Coordinate(0, 0, 0)): + z_pos = 'c' + if isinstance(resource, Tip): + z_pos = 'b' + resource_pos = resource.get_absolute_location(x="c",y="c",z=z_pos) + x = resource_pos.x + y = resource_pos.y + z = resource_pos.z + # 如果z等于0,则递归resource.parent的高度并向z加,使用get_size_z方法 + + parent = resource.parent + res_z = resource.location.z + while not isinstance(parent, LiquidHandlerAbstract) and (res_z == 0) and parent is not None: + z += parent.get_size_z() + res_z = parent.location.z + parent = getattr(parent, "parent", None) + + prcxi_x = (self.deck_x - x)*(1+self.x_increase) + self.x_offset + self.xy_coupling * (self.deck_y - y) + prcxi_y = (self.deck_y - y)*(1+self.y_increase) + self.y_offset + prcxi_z = self.deck_z - z + + prcxi_x = min(max(0, prcxi_x+offset.x),self.deck_x) + prcxi_y = min(max(0, prcxi_y+offset.y),self.deck_y) + prcxi_z = min(max(0, prcxi_z+offset.z),self.deck_z) + + return Coordinate(prcxi_x, prcxi_y, prcxi_z) def post_init(self, ros_node: BaseROS2DeviceNode): super().post_init(ros_node) @@ -820,8 +1044,8 @@ class PRCXI9300Handler(LiquidHandlerAbstract): ): self._unilabos_backend.create_protocol(protocol_name) - async def run_protocol(self): - return self._unilabos_backend.run_protocol() + async def run_protocol(self, protocol_id: str = None): + return self._unilabos_backend.run_protocol(protocol_id) async def remove_liquid( self, @@ -912,6 +1136,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract): touch_tip: bool = False, liquid_height: Optional[List[Optional[float]]] = None, blow_out_air_volume: Optional[List[Optional[float]]] = None, + blow_out_air_volume_before: 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", @@ -922,7 +1147,12 @@ class PRCXI9300Handler(LiquidHandlerAbstract): delays: Optional[List[int]] = None, none_keys: List[str] = [], ) -> TransferLiquidReturn: - return await super().transfer_liquid( + if not self._first_transfer_done: + self._match_and_create_matrix() + self._first_transfer_done = True + if self.step_mode: + await self.create_protocol(f"transfer_liquid{time.time()}") + res = await super().transfer_liquid( sources, targets, tip_racks, @@ -935,6 +1165,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract): touch_tip=touch_tip, liquid_height=liquid_height, blow_out_air_volume=blow_out_air_volume, + blow_out_air_volume_before=blow_out_air_volume_before, spread=spread, is_96_well=is_96_well, mix_stage=mix_stage, @@ -945,6 +1176,9 @@ class PRCXI9300Handler(LiquidHandlerAbstract): delays=delays, none_keys=none_keys, ) + if self.step_mode: + await self.run_protocol() + return res async def custom_delay(self, seconds=0, msg=None): return await super().custom_delay(seconds, msg) @@ -961,9 +1195,10 @@ class PRCXI9300Handler(LiquidHandlerAbstract): offsets: Optional[Coordinate] = None, mix_rate: Optional[float] = None, none_keys: List[str] = [], + use_channels: Optional[List[int]] = [0], ): return await self._unilabos_backend.mix( - targets, mix_time, mix_vol, height_to_bottom, offsets, mix_rate, none_keys + targets, mix_time, mix_vol, height_to_bottom, offsets, mix_rate, none_keys, use_channels ) def iter_tips(self, tip_racks: Sequence[TipRack]) -> Iterator[Resource]: @@ -976,10 +1211,6 @@ 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( @@ -1131,6 +1362,24 @@ class PRCXI9300Backend(LiquidHandlerBackend): self.debug = debug self.axis = "Left" + @staticmethod + def _deck_plate_slot_no(plate, deck) -> int: + """台面板位槽号(1–16):与 PRCXI9300Handler._get_slot_number 一致;无法解析时退回 deck 子项顺序 +1。""" + sn = PRCXI9300Handler._get_slot_number(plate) + if sn is not None: + return sn + return deck.children.index(plate) + 1 + + @staticmethod + def _resource_num_items_y(resource) -> int: + """板/TipRack 等在 Y 向孔位数;无 ``num_items_y`` 或非正数时返回 1。""" + ny = getattr(resource, "num_items_y", None) + try: + n = int(ny) if ny is not None else 1 + except (TypeError, ValueError): + n = 1 + return n if n >= 1 else 1 + async def shaker_action(self, time: int, module_no: int, amplitude: int, is_wait: bool): step = self.api_client.shaker_action( time=time, @@ -1182,26 +1431,40 @@ class PRCXI9300Backend(LiquidHandlerBackend): self.protocol_name = protocol_name self.steps_todo_list = [] - def run_protocol(self): + if not len(self.matrix_id): + self.matrix_id = str(uuid.uuid4()) + + material_list = self.api_client.get_all_materials() + material_dict = {material["uuid"]: material for material in material_list} + + work_tablets = [] + for num, material_id in self.tablets_info.items(): + work_tablets.append({ + "Number": num, + "Material": material_dict[material_id] + }) + + self.matrix_info = { + "MatrixId": self.matrix_id, + "MatrixName": self.matrix_id, + "WorkTablets": work_tablets, + } + # print(json.dumps(self.matrix_info, indent=2)) + res = self.api_client.add_WorkTablet_Matrix(self.matrix_info) + if not res["Success"]: + self.matrix_id = "" + raise AssertionError(f"Failed to create matrix: {res.get('Message', 'Unknown error')}") + print(f"PRCXI9300Backend created matrix with ID: {self.matrix_info['MatrixId']}, result: {res}") + + def run_protocol(self, protocol_id: str = None): assert self.is_reset_ok, "PRCXI9300Backend is not reset successfully. Please call setup() first." run_time = time.time() - self.matrix_info = MatrixInfo( - MatrixId=f"{int(run_time)}", - MatrixName=f"protocol_{run_time}", - MatrixCount=len(self.tablets_info), - WorkTablets=self.tablets_info, - ) - # print(json.dumps(self.matrix_info, indent=2)) - if not len(self.matrix_id): - res = self.api_client.add_WorkTablet_Matrix(self.matrix_info) - assert res["Success"], f"Failed to create matrix: {res.get('Message', 'Unknown error')}" - print(f"PRCXI9300Backend created matrix with ID: {self.matrix_info['MatrixId']}, result: {res}") + if protocol_id == "" or protocol_id is None: solution_id = self.api_client.add_solution( - f"protocol_{run_time}", self.matrix_info["MatrixId"], self.steps_todo_list + f"protocol_{run_time}", self.matrix_id, self.steps_todo_list ) else: - print(f"PRCXI9300Backend using predefined worktable {self.matrix_id}, skipping matrix creation.") - solution_id = self.api_client.add_solution(f"protocol_{run_time}", self.matrix_id, self.steps_todo_list) + solution_id = protocol_id print(f"PRCXI9300Backend created solution with ID: {solution_id}") self.api_client.load_solution(solution_id) print(json.dumps(self.steps_todo_list, indent=2)) @@ -1244,6 +1507,9 @@ class PRCXI9300Backend(LiquidHandlerBackend): else: await asyncio.sleep(1) print("PRCXI9300 reset successfully.") + + # self.api_client.update_clamp_jaw_position(self.matrix_id, self.plate_positions) + except ConnectionRefusedError as e: raise RuntimeError( f"Failed to connect to PRCXI9300 API at {self.host}:{self.port}. " @@ -1267,33 +1533,33 @@ class PRCXI9300Backend(LiquidHandlerBackend): axis = "Right" else: raise ValueError("Invalid use channels: " + str(_use_channels)) - plate_indexes = [] + plate_slots = [] for op in ops: plate = op.resource.parent - deck = plate.parent.parent - plate_index = deck.children.index(plate.parent) - # print(f"Plate index: {plate_index}, Plate name: {plate.name}") - # print(f"Number of children in deck: {len(deck.children)}") + deck = plate.parent + plate_slots.append(self._deck_plate_slot_no(plate, deck)) - plate_indexes.append(plate_index) - - if len(set(plate_indexes)) != 1: - raise ValueError("All pickups must be from the same plate. Found different plates: " + str(plate_indexes)) + if len(set(plate_slots)) != 1: + raise ValueError("All pickups must be from the same plate (slot). Found different slots: " + str(plate_slots)) + _rack = ops[0].resource.parent + ny = self._resource_num_items_y(_rack) tip_columns = [] for op in ops: tipspot = op.resource + if self._resource_num_items_y(tipspot.parent) != ny: + raise ValueError("All pickups must use tip racks with the same num_items_y") tipspot_index = tipspot.parent.children.index(tipspot) - tip_columns.append(tipspot_index // 8) + tip_columns.append(tipspot_index // ny) if len(set(tip_columns)) != 1: raise ValueError( "All pickups must be from the same tip column. Found different columns: " + str(tip_columns) ) - PlateNo = plate_indexes[0] + 1 + PlateNo = plate_slots[0] hole_col = tip_columns[0] + 1 hole_row = 1 - if self._num_channels == 1: - hole_row = tipspot_index % 8 + 1 + if self.num_channels != 8: + hole_row = tipspot_index % ny + 1 step = self.api_client.Load( axis=axis, @@ -1304,8 +1570,8 @@ class PRCXI9300Backend(LiquidHandlerBackend): hole_col=hole_col, blending_times=0, balance_height=0, - plate_or_hole=f"H{hole_col}-8,T{PlateNo}", - hole_numbers=f"{(hole_col - 1) * 8 + hole_row}" if self._num_channels == 1 else "1,2,3,4,5", + plate_or_hole=f"H{hole_col}-{ny},T{PlateNo}", + hole_numbers=f"{(hole_col - 1) * ny + hole_row}" if self._num_channels != 8 else "1,2,3,4,5", ) self.steps_todo_list.append(step) @@ -1323,8 +1589,9 @@ class PRCXI9300Backend(LiquidHandlerBackend): raise ValueError("Invalid use channels: " + str(_use_channels)) # 检查trash # if ops[0].resource.name == "trash": - - PlateNo = ops[0].resource.parent.parent.children.index(ops[0].resource.parent) + 1 + _plate = ops[0].resource + _deck = _plate.parent + PlateNo = self._deck_plate_slot_no(_plate, _deck) step = self.api_client.UnLoad( axis=axis, @@ -1342,32 +1609,35 @@ class PRCXI9300Backend(LiquidHandlerBackend): return # print(ops[0].resource.parent.children.index(ops[0].resource)) - plate_indexes = [] + plate_slots = [] for op in ops: plate = op.resource.parent - deck = plate.parent.parent - plate_index = deck.children.index(plate.parent) - plate_indexes.append(plate_index) - if len(set(plate_indexes)) != 1: + deck = plate.parent + plate_slots.append(self._deck_plate_slot_no(plate, deck)) + if len(set(plate_slots)) != 1: raise ValueError( - "All drop_tips must be from the same plate. Found different plates: " + str(plate_indexes) + "All drop_tips must be from the same plate (slot). Found different slots: " + str(plate_slots) ) + _rack = ops[0].resource.parent + ny = self._resource_num_items_y(_rack) tip_columns = [] for op in ops: tipspot = op.resource + if self._resource_num_items_y(tipspot.parent) != ny: + raise ValueError("All drop_tips must use tip racks with the same num_items_y") tipspot_index = tipspot.parent.children.index(tipspot) - tip_columns.append(tipspot_index // 8) + tip_columns.append(tipspot_index // ny) if len(set(tip_columns)) != 1: raise ValueError( "All drop_tips must be from the same tip column. Found different columns: " + str(tip_columns) ) - PlateNo = plate_indexes[0] + 1 + PlateNo = plate_slots[0] hole_col = tip_columns[0] + 1 - - if self.channel_num == 1: - hole_row = tipspot_index % 8 + 1 + hole_row = 1 + if self.num_channels != 8: + hole_row = tipspot_index % ny + 1 step = self.api_client.UnLoad( axis=axis, @@ -1378,7 +1648,7 @@ class PRCXI9300Backend(LiquidHandlerBackend): hole_col=hole_col, blending_times=0, balance_height=0, - plate_or_hole=f"H{hole_col}-8,T{PlateNo}", + plate_or_hole=f"H{hole_col}-{ny},T{PlateNo}", hole_numbers="1,2,3,4,5,6,7,8", ) self.steps_todo_list.append(step) @@ -1392,34 +1662,43 @@ class PRCXI9300Backend(LiquidHandlerBackend): offsets: Optional[Coordinate] = None, mix_rate: Optional[float] = None, none_keys: List[str] = [], + use_channels: Optional[List[int]] = [0], ): """Mix liquid in the specified resources.""" - - plate_indexes = [] + if use_channels == [0]: + axis = "Left" + elif use_channels == [1]: + axis = "Right" + else: + raise ValueError("Invalid use channels: " + str(use_channels)) + plate_slots = [] for op in targets: deck = op.parent.parent.parent plate = op.parent - plate_index = deck.children.index(plate.parent) - plate_indexes.append(plate_index) + plate_slots.append(self._deck_plate_slot_no(plate, deck)) - if len(set(plate_indexes)) != 1: - raise ValueError("All pickups must be from the same plate. Found different plates: " + str(plate_indexes)) + if len(set(plate_slots)) != 1: + raise ValueError("All mix targets must be from the same plate (slot). Found different slots: " + str(plate_slots)) + _plate0 = targets[0].parent + ny = self._resource_num_items_y(_plate0) tip_columns = [] for op in targets: + if self._resource_num_items_y(op.parent) != ny: + raise ValueError("All mix targets must be on plates with the same num_items_y") tipspot_index = op.parent.children.index(op) - tip_columns.append(tipspot_index // 8) + tip_columns.append(tipspot_index // ny) if len(set(tip_columns)) != 1: raise ValueError( - "All pickups must be from the same tip column. Found different columns: " + str(tip_columns) + "All mix targets must be in the same column group. Found different columns: " + str(tip_columns) ) - PlateNo = plate_indexes[0] + 1 + PlateNo = plate_slots[0] hole_col = tip_columns[0] + 1 hole_row = 1 - if self.num_channels == 1: - hole_row = tipspot_index % 8 + 1 + if self.num_channels != 8: + hole_row = tipspot_index % ny + 1 assert mix_time > 0 step = self.api_client.Blending( @@ -1430,13 +1709,15 @@ class PRCXI9300Backend(LiquidHandlerBackend): hole_col=hole_col, blending_times=mix_time, balance_height=0, - plate_or_hole=f"H{hole_col}-8,T{PlateNo}", + plate_or_hole=f"H{hole_col}-{ny},T{PlateNo}", hole_numbers="1,2,3,4,5,6,7,8", ) self.steps_todo_list.append(step) async def aspirate(self, ops: List[SingleChannelAspiration], use_channels: List[int] = None): """Aspirate liquid from the specified resources.""" + if ops[0].blow_out_air_volume and ops[0].volume == 0: + return if hasattr(use_channels, "tolist"): _use_channels = use_channels.tolist() else: @@ -1447,36 +1728,39 @@ class PRCXI9300Backend(LiquidHandlerBackend): axis = "Right" else: raise ValueError("Invalid use channels: " + str(_use_channels)) - plate_indexes = [] + plate_slots = [] for op in ops: plate = op.resource.parent - deck = plate.parent.parent - plate_index = deck.children.index(plate.parent) - plate_indexes.append(plate_index) + deck = plate.parent + plate_slots.append(self._deck_plate_slot_no(plate, deck)) - if len(set(plate_indexes)) != 1: - raise ValueError("All pickups must be from the same plate. Found different plates: " + str(plate_indexes)) + if len(set(plate_slots)) != 1: + raise ValueError("All aspirate must be from the same plate (slot). Found different slots: " + str(plate_slots)) + _plate0 = ops[0].resource.parent + ny = self._resource_num_items_y(_plate0) tip_columns = [] for op in ops: tipspot = op.resource + if self._resource_num_items_y(tipspot.parent) != ny: + raise ValueError("All aspirate wells must be on plates with the same num_items_y") tipspot_index = tipspot.parent.children.index(tipspot) - tip_columns.append(tipspot_index // 8) + tip_columns.append(tipspot_index // ny) if len(set(tip_columns)) != 1: raise ValueError( - "All pickups must be from the same tip column. Found different columns: " + str(tip_columns) + "All aspirate must be from the same tip column. Found different columns: " + str(tip_columns) ) volumes = [op.volume for op in ops] if len(set(volumes)) != 1: raise ValueError("All aspirate volumes must be the same. Found different volumes: " + str(volumes)) - PlateNo = plate_indexes[0] + 1 + PlateNo = plate_slots[0] hole_col = tip_columns[0] + 1 hole_row = 1 - if self.num_channels == 1: - hole_row = tipspot_index % 8 + 1 + if self.num_channels != 8: + hole_row = tipspot_index % ny + 1 step = self.api_client.Imbibing( axis=axis, @@ -1487,7 +1771,7 @@ class PRCXI9300Backend(LiquidHandlerBackend): hole_col=hole_col, blending_times=0, balance_height=0, - plate_or_hole=f"H{hole_col}-8,T{PlateNo}", + plate_or_hole=f"H{hole_col}-{ny},T{PlateNo}", hole_numbers="1,2,3,4,5,6,7,8", ) self.steps_todo_list.append(step) @@ -1504,21 +1788,24 @@ class PRCXI9300Backend(LiquidHandlerBackend): axis = "Right" else: raise ValueError("Invalid use channels: " + str(_use_channels)) - plate_indexes = [] + plate_slots = [] for op in ops: plate = op.resource.parent - deck = plate.parent.parent - plate_index = deck.children.index(plate.parent) - plate_indexes.append(plate_index) + deck = plate.parent + plate_slots.append(self._deck_plate_slot_no(plate, deck)) - if len(set(plate_indexes)) != 1: - raise ValueError("All dispense must be from the same plate. Found different plates: " + str(plate_indexes)) + if len(set(plate_slots)) != 1: + raise ValueError("All dispense must be from the same plate (slot). Found different slots: " + str(plate_slots)) + _plate0 = ops[0].resource.parent + ny = self._resource_num_items_y(_plate0) tip_columns = [] for op in ops: tipspot = op.resource + if self._resource_num_items_y(tipspot.parent) != ny: + raise ValueError("All dispense wells must be on plates with the same num_items_y") tipspot_index = tipspot.parent.children.index(tipspot) - tip_columns.append(tipspot_index // 8) + tip_columns.append(tipspot_index // ny) if len(set(tip_columns)) != 1: raise ValueError( @@ -1529,12 +1816,12 @@ class PRCXI9300Backend(LiquidHandlerBackend): if len(set(volumes)) != 1: raise ValueError("All dispense volumes must be the same. Found different volumes: " + str(volumes)) - PlateNo = plate_indexes[0] + 1 + PlateNo = plate_slots[0] hole_col = tip_columns[0] + 1 hole_row = 1 - if self.num_channels == 1: - hole_row = tipspot_index % 8 + 1 + if self.num_channels != 8: + hole_row = tipspot_index % ny + 1 step = self.api_client.Tapping( axis=axis, @@ -1545,7 +1832,7 @@ class PRCXI9300Backend(LiquidHandlerBackend): hole_col=hole_col, blending_times=0, balance_height=0, - plate_or_hole=f"H{hole_col}-8,T{PlateNo}", + plate_or_hole=f"H{hole_col}-{ny},T{PlateNo}", hole_numbers="1,2,3,4,5,6,7,8", ) self.steps_todo_list.append(step) @@ -1741,6 +2028,21 @@ class PRCXI9300Api: """GetWorkTabletMatrixById""" return self.call("IMatrix", "GetWorkTabletMatrixById", [matrix_id]) + def update_clamp_jaw_position(self, target_matrix_id: str, plate_positions: List[Dict[str, Any]]): + position_params = { + "MatrixId": target_matrix_id, + "WorkTablets": plate_positions + } + return self.call("IMatrix", "UpdateClampJawPosition", [position_params]) + + def update_pipetting_position(self, target_matrix_id: str, pipetting_positions: List[Dict[str, Any]]): + """UpdatePipettingPosition - 更新移液位置""" + position_params = { + "MatrixId": target_matrix_id, + "WorkTablets": pipetting_positions + } + return self.call("IMatrix", "UpdatePipettingPosition", [position_params]) + def add_WorkTablet_Matrix(self, matrix: MatrixInfo): return self.call("IMatrix", "AddWorkTabletMatrix2" if self.is_9320 else "AddWorkTabletMatrix", [matrix]) diff --git a/unilabos/devices/liquid_handling/prcxi/prcxi_labware.py b/unilabos/devices/liquid_handling/prcxi/prcxi_labware.py index b87e1e23..ce754a78 100644 --- a/unilabos/devices/liquid_handling/prcxi/prcxi_labware.py +++ b/unilabos/devices/liquid_handling/prcxi/prcxi_labware.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Any, Callable, Dict, List, Optional, Tuple from pylabrobot.resources import Tube, Coordinate from pylabrobot.resources.well import Well, WellBottomType, CrossSectionType from pylabrobot.resources.tip import Tip, TipCreator @@ -838,4 +838,102 @@ def PRCXI_30mm_Adapter(name: str) -> PRCXI9300PlateAdapter: "Name": "30mm适配器", "SupplyType": 2 } - ) \ No newline at end of file + ) + + +# --------------------------------------------------------------------------- +# 协议上传 / workflow 用:与设备端耗材字典字段对齐的模板描述(供 common 自动匹配) +# --------------------------------------------------------------------------- + +_PRCXI_TEMPLATE_SPECS_CACHE: Optional[List[Dict[str, Any]]] = None + + +def _probe_prcxi_resource(factory: Callable[..., Any]) -> Any: + probe = "__unilab_template_probe__" + if factory.__name__ == "PRCXI_trash": + return factory() + return factory(probe) + + +def _first_child_capacity_for_match(resource: Any) -> float: + """Well max_volume 或 Tip 的 maximal_volume,用于与设备端 Volume 类似的打分。""" + ch = getattr(resource, "children", None) or [] + if not ch: + return 0.0 + c0 = ch[0] + mv = getattr(c0, "max_volume", None) + if mv is not None: + return float(mv) + tip = getattr(c0, "tip", None) + if tip is not None: + mv2 = getattr(tip, "maximal_volume", None) + if mv2 is not None: + return float(mv2) + return 0.0 + + +# (factory, kind) — 不含各类 Adapter,避免与真实板子误匹配 +PRCXI_TEMPLATE_FACTORY_KINDS: List[Tuple[Callable[..., Any], str]] = [ + (PRCXI_BioER_96_wellplate, "plate"), + (PRCXI_nest_1_troughplate, "plate"), + (PRCXI_BioRad_384_wellplate, "plate"), + (PRCXI_AGenBio_4_troughplate, "plate"), + (PRCXI_nest_12_troughplate, "plate"), + (PRCXI_CellTreat_96_wellplate, "plate"), + (PRCXI_10ul_eTips, "tip_rack"), + (PRCXI_300ul_Tips, "tip_rack"), + (PRCXI_PCR_Plate_200uL_nonskirted, "plate"), + (PRCXI_PCR_Plate_200uL_semiskirted, "plate"), + (PRCXI_PCR_Plate_200uL_skirted, "plate"), + (PRCXI_trash, "trash"), + (PRCXI_96_DeepWell, "plate"), + (PRCXI_EP_Adapter, "tube_rack"), + (PRCXI_1250uL_Tips, "tip_rack"), + (PRCXI_10uL_Tips, "tip_rack"), + (PRCXI_1000uL_Tips, "tip_rack"), + (PRCXI_200uL_Tips, "tip_rack"), + (PRCXI_48_DeepWell, "plate"), +] + + +def get_prcxi_labware_template_specs() -> List[Dict[str, Any]]: + """返回与 ``prcxi._match_and_create_matrix`` 中耗材字段兼容的模板列表,用于按孔数+容量打分。""" + global _PRCXI_TEMPLATE_SPECS_CACHE + if _PRCXI_TEMPLATE_SPECS_CACHE is not None: + return _PRCXI_TEMPLATE_SPECS_CACHE + + out: List[Dict[str, Any]] = [] + for factory, kind in PRCXI_TEMPLATE_FACTORY_KINDS: + try: + r = _probe_prcxi_resource(factory) + except Exception: + continue + nx = int(getattr(r, "num_items_x", None) or 0) + ny = int(getattr(r, "num_items_y", None) or 0) + nchild = len(getattr(r, "children", []) or []) + hole_count = nx * ny if nx > 0 and ny > 0 else nchild + hole_row = ny if nx > 0 and ny > 0 else 0 + hole_col = nx if nx > 0 and ny > 0 else 0 + mi = getattr(r, "material_info", None) or {} + vol = _first_child_capacity_for_match(r) + menum = mi.get("materialEnum") + if menum is None and kind == "tip_rack": + menum = 1 + elif menum is None and kind == "trash": + menum = 6 + out.append( + { + "class_name": factory.__name__, + "kind": kind, + "materialEnum": menum, + "HoleRow": hole_row, + "HoleColum": hole_col, + "Volume": vol, + "hole_count": hole_count, + "material_uuid": mi.get("uuid"), + "material_code": mi.get("Code"), + } + ) + + _PRCXI_TEMPLATE_SPECS_CACHE = out + return out \ No newline at end of file diff --git a/unilabos/devices/liquid_handling/rviz_backend.py b/unilabos/devices/liquid_handling/rviz_backend.py index 3bd2c2f8..f9d83d9e 100644 --- a/unilabos/devices/liquid_handling/rviz_backend.py +++ b/unilabos/devices/liquid_handling/rviz_backend.py @@ -59,6 +59,7 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend): self.total_height = total_height self.joint_config = kwargs.get("joint_config", None) self.lh_device_id = kwargs.get("lh_device_id", "lh_joint_publisher") + self.simulate_rviz = kwargs.get("simulate_rviz", False) if not rclpy.ok(): rclpy.init() self.joint_state_publisher = None @@ -69,7 +70,7 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend): self.joint_state_publisher = LiquidHandlerJointPublisher( joint_config=self.joint_config, lh_device_id=self.lh_device_id, - simulate_rviz=True) + simulate_rviz=self.simulate_rviz) # 启动ROS executor self.executor = rclpy.executors.MultiThreadedExecutor() diff --git a/unilabos/devices/ros_dev/liquid_handler_joint_publisher_node.py b/unilabos/devices/ros_dev/liquid_handler_joint_publisher_node.py index 5b7c7252..400bcac0 100644 --- a/unilabos/devices/ros_dev/liquid_handler_joint_publisher_node.py +++ b/unilabos/devices/ros_dev/liquid_handler_joint_publisher_node.py @@ -42,6 +42,7 @@ class LiquidHandlerJointPublisher(Node): while self.resource_action is None: self.resource_action = self.check_tf_update_actions() time.sleep(1) + self.get_logger().info(f'Waiting for TfUpdate server: {self.resource_action}') self.resource_action_client = ActionClient(self, SendCmd, self.resource_action) while not self.resource_action_client.wait_for_server(timeout_sec=1.0): diff --git a/unilabos/registry/devices/liquid_handler.yaml b/unilabos/registry/devices/liquid_handler.yaml index 2912f37c..145ee47f 100644 --- a/unilabos/registry/devices/liquid_handler.yaml +++ b/unilabos/registry/devices/liquid_handler.yaml @@ -8,7 +8,6 @@ liquid_handler: goal: asp_vols: asp_vols blow_out_air_volume: blow_out_air_volume - delays: delays dis_vols: dis_vols flow_rates: flow_rates is_96_well: is_96_well @@ -24,38 +23,84 @@ liquid_handler: targets: targets use_channels: use_channels goal_default: - asp_vols: [] - blow_out_air_volume: [] - dis_vols: [] - flow_rates: [] + asp_vols: + - 0.0 + blow_out_air_volume: + - 0.0 + dis_vols: + - 0.0 + flow_rates: + - 0.0 is_96_well: false - liquid_height: [] + liquid_height: + - 0.0 mix_liquid_height: 0.0 mix_rate: 0 mix_time: 0 mix_vol: 0 - none_keys: [] - offsets: [] - reagent_sources: [] + 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: [] - use_channels: [] + 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: - return_info: return_info - success: success + result: {} schema: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerAdd_Feedback type: object goal: - additionalProperties: false properties: asp_vols: items: @@ -80,8 +125,6 @@ liquid_handler: type: number type: array mix_liquid_height: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number mix_rate: maximum: 2147483647 @@ -112,6 +155,7 @@ liquid_handler: - x - y - z + title: offsets type: object type: array reagent_sources: @@ -186,6 +230,7 @@ liquid_handler: - pose - config - data + title: reagent_sources type: object type: array spread: @@ -262,21 +307,43 @@ liquid_handler: - 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: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerAdd_Result type: object required: @@ -295,14 +362,41 @@ liquid_handler: use_channels: use_channels vols: vols goal_default: - blow_out_air_volume: [] - flow_rates: [] - liquid_height: [] - offsets: [] - resources: [] + 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: [] - vols: [] + use_channels: + - 0 + vols: + - 0.0 handles: {} result: name: name @@ -310,11 +404,11 @@ liquid_handler: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerAspirate_Feedback type: object goal: - additionalProperties: false properties: blow_out_air_volume: items: @@ -341,6 +435,7 @@ liquid_handler: - x - y - z + title: offsets type: object type: array resources: @@ -415,27 +510,41 @@ liquid_handler: - 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: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerAspirate_Result type: object required: @@ -465,9 +574,7 @@ liquid_handler: properties: none_keys: default: [] - items: - type: string - type: array + type: string protocol_author: type: string protocol_date: @@ -537,19 +644,41 @@ liquid_handler: goal: properties: tip_racks: - items: - type: object - type: array + type: string required: - tip_racks type: object - result: - type: string + result: {} required: - goal title: iter_tips参数 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-set_group: feedback: {} goal: {} @@ -569,13 +698,9 @@ liquid_handler: group_name: type: string volumes: - items: - type: number - type: array + type: string wells: - items: - type: object - type: array + type: string required: - group_name - wells @@ -587,259 +712,6 @@ liquid_handler: title: set_group参数 type: object type: UniLabJsonCommand - auto-set_liquid: - feedback: {} - goal: {} - goal_default: - liquid_names: null - volumes: null - wells: null - handles: {} - placeholder_keys: {} - result: {} - schema: - description: set_liquid的参数schema - properties: - feedback: {} - goal: - properties: - liquid_names: - items: - type: string - type: array - volumes: - items: - type: number - type: array - wells: - items: - type: object - type: array - required: - - wells - - liquid_names - - volumes - type: object - result: - $defs: - ResourceDict: - properties: - class: - description: Resource class name - title: Class - type: string - config: - additionalProperties: true - description: Resource configuration - title: Config - type: object - data: - additionalProperties: true - description: 'Resource data, eg: container liquid data' - title: Data - type: object - description: - default: '' - description: Resource description - title: Description - type: string - extra: - additionalProperties: true - description: 'Extra data, eg: slot index' - title: Extra - type: object - icon: - default: '' - description: Resource icon - title: Icon - type: string - id: - description: Resource ID - title: Id - type: string - machine_name: - default: '' - description: Machine this resource belongs to - title: Machine Name - type: string - model: - additionalProperties: true - description: Resource model - title: Model - type: object - name: - description: Resource name - title: Name - type: string - parent: - anyOf: - - $ref: '#/$defs/ResourceDict' - - type: 'null' - default: null - description: Parent resource object - parent_uuid: - anyOf: - - type: string - - type: 'null' - default: null - description: Parent resource uuid - title: Parent Uuid - pose: - $ref: '#/$defs/ResourceDictPosition' - description: Resource position - schema: - additionalProperties: true - description: Resource schema - title: Schema - type: object - type: - anyOf: - - const: device - type: string - - type: string - description: Resource type - title: Type - uuid: - description: Resource UUID - title: Uuid - type: string - required: - - id - - uuid - - name - - type - - class - - config - - data - - extra - title: ResourceDict - type: object - ResourceDictPosition: - properties: - cross_section_type: - default: rectangle - description: Cross section type - enum: - - rectangle - - circle - - rounded_rectangle - title: Cross Section Type - type: string - extra: - anyOf: - - additionalProperties: true - type: object - - type: 'null' - default: null - description: Extra data - title: Extra - layout: - default: x-y - description: Resource layout - enum: - - 2d - - x-y - - z-y - - x-z - title: Layout - type: string - position: - $ref: '#/$defs/ResourceDictPositionObject' - description: Resource position - position3d: - $ref: '#/$defs/ResourceDictPositionObject' - description: Resource position in 3D space - rotation: - $ref: '#/$defs/ResourceDictPositionObject' - description: Resource rotation - scale: - $ref: '#/$defs/ResourceDictPositionScale' - description: Resource scale - size: - $ref: '#/$defs/ResourceDictPositionSize' - description: Resource size - title: ResourceDictPosition - type: object - ResourceDictPositionObject: - properties: - x: - default: 0.0 - description: X coordinate - title: X - type: number - y: - default: 0.0 - description: Y coordinate - title: Y - type: number - z: - default: 0.0 - description: Z coordinate - title: Z - type: number - title: ResourceDictPositionObject - type: object - ResourceDictPositionScale: - properties: - x: - default: 0.0 - description: x scale - title: X - type: number - y: - default: 0.0 - description: y scale - title: Y - type: number - z: - default: 0.0 - description: z scale - title: Z - type: number - title: ResourceDictPositionScale - type: object - ResourceDictPositionSize: - properties: - depth: - default: 0.0 - description: Depth - title: Depth - type: number - height: - default: 0.0 - description: Height - title: Height - type: number - width: - default: 0.0 - description: Width - title: Width - type: number - title: ResourceDictPositionSize - type: object - properties: - volumes: - items: - type: number - title: Volumes - type: array - wells: - items: - items: - $ref: '#/$defs/ResourceDict' - type: array - title: Wells - type: array - required: - - wells - - volumes - title: SetLiquidReturn - type: object - required: - - goal - title: set_liquid参数 - type: object - type: UniLabJsonCommand auto-set_liquid_from_plate: feedback: {} goal: {} @@ -849,8 +721,7 @@ liquid_handler: volumes: null well_names: null handles: {} - placeholder_keys: - plate: unilabos_resources + placeholder_keys: {} result: {} schema: description: '' @@ -859,326 +730,20 @@ liquid_handler: goal: properties: liquid_names: - items: - type: string - type: array + type: string plate: - additionalProperties: false - 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: - additionalProperties: false - properties: - orientation: - additionalProperties: false - properties: - w: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 - type: number - x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 - type: number - y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 - type: number - z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 - type: number - required: - - x - - y - - z - - w - title: orientation - type: object - position: - additionalProperties: false - properties: - x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 - type: number - y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 - type: number - z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 - type: number - required: - - x - - y - - z - title: position - type: object - required: - - position - - orientation - title: pose - type: object - sample_id: - type: string - type: - type: string - title: plate - type: object + type: string volumes: - items: - type: number - type: array + type: string well_names: - items: - type: string - type: array + type: string required: - plate - well_names - liquid_names - volumes type: object - result: - $defs: - ResourceDict: - properties: - class: - description: Resource class name - title: Class - type: string - config: - additionalProperties: true - description: Resource configuration - title: Config - type: object - data: - additionalProperties: true - description: 'Resource data, eg: container liquid data' - title: Data - type: object - description: - default: '' - description: Resource description - title: Description - type: string - extra: - additionalProperties: true - description: 'Extra data, eg: slot index' - title: Extra - type: object - icon: - default: '' - description: Resource icon - title: Icon - type: string - id: - description: Resource ID - title: Id - type: string - machine_name: - default: '' - description: Machine this resource belongs to - title: Machine Name - type: string - model: - additionalProperties: true - description: Resource model - title: Model - type: object - name: - description: Resource name - title: Name - type: string - parent: - anyOf: - - $ref: '#/$defs/ResourceDict' - - type: 'null' - default: null - description: Parent resource object - parent_uuid: - anyOf: - - type: string - - type: 'null' - default: null - description: Parent resource uuid - title: Parent Uuid - pose: - $ref: '#/$defs/ResourceDictPosition' - description: Resource position - schema: - additionalProperties: true - description: Resource schema - title: Schema - type: object - type: - anyOf: - - const: device - type: string - - type: string - description: Resource type - title: Type - uuid: - description: Resource UUID - title: Uuid - type: string - required: - - id - - uuid - - name - - type - - class - - config - - data - - extra - title: ResourceDict - type: object - ResourceDictPosition: - properties: - cross_section_type: - default: rectangle - description: Cross section type - enum: - - rectangle - - circle - - rounded_rectangle - title: Cross Section Type - type: string - extra: - anyOf: - - additionalProperties: true - type: object - - type: 'null' - default: null - description: Extra data - title: Extra - layout: - default: x-y - description: Resource layout - enum: - - 2d - - x-y - - z-y - - x-z - title: Layout - type: string - position: - $ref: '#/$defs/ResourceDictPositionObject' - description: Resource position - position3d: - $ref: '#/$defs/ResourceDictPositionObject' - description: Resource position in 3D space - rotation: - $ref: '#/$defs/ResourceDictPositionObject' - description: Resource rotation - scale: - $ref: '#/$defs/ResourceDictPositionScale' - description: Resource scale - size: - $ref: '#/$defs/ResourceDictPositionSize' - description: Resource size - title: ResourceDictPosition - type: object - ResourceDictPositionObject: - properties: - x: - default: 0.0 - description: X coordinate - title: X - type: number - y: - default: 0.0 - description: Y coordinate - title: Y - type: number - z: - default: 0.0 - description: Z coordinate - title: Z - type: number - title: ResourceDictPositionObject - type: object - ResourceDictPositionScale: - properties: - x: - default: 0.0 - description: x scale - title: X - type: number - y: - default: 0.0 - description: y scale - title: Y - type: number - z: - default: 0.0 - description: z scale - title: Z - type: number - title: ResourceDictPositionScale - type: object - ResourceDictPositionSize: - properties: - depth: - default: 0.0 - description: Depth - title: Depth - type: number - height: - default: 0.0 - description: Height - title: Height - type: number - width: - default: 0.0 - description: Width - title: Width - type: number - title: ResourceDictPositionSize - type: object - properties: - plate: - items: - items: - $ref: '#/$defs/ResourceDict' - type: array - title: Plate - type: array - volumes: - items: - type: number - title: Volumes - type: array - wells: - items: - items: - $ref: '#/$defs/ResourceDict' - type: array - title: Wells - type: array - required: - - plate - - wells - - volumes - title: SetLiquidFromPlateReturn - type: object + result: {} required: - goal title: set_liquid_from_plate参数 @@ -1199,9 +764,7 @@ liquid_handler: goal: properties: tip_racks: - items: - type: object - type: array + type: string required: - tip_racks type: object @@ -1226,9 +789,7 @@ liquid_handler: goal: properties: targets: - items: - type: object - type: array + type: string required: - targets type: object @@ -1276,7 +837,8 @@ liquid_handler: goal: use_channels: use_channels goal_default: - use_channels: [] + use_channels: + - 0 handles: {} result: name: name @@ -1284,25 +846,31 @@ liquid_handler: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerDiscardTips_Feedback type: object goal: - additionalProperties: false properties: use_channels: items: + maximum: 2147483647 + minimum: -2147483648 type: integer type: array + required: + - use_channels title: LiquidHandlerDiscardTips_Goal type: object result: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerDiscardTips_Result type: object required: @@ -1321,13 +889,39 @@ liquid_handler: use_channels: use_channels vols: vols goal_default: - blow_out_air_volume: [] - flow_rates: [] - offsets: [] - resources: [] + 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: [] - vols: [] + use_channels: + - 0 + vols: + - 0.0 handles: {} result: name: name @@ -1335,14 +929,16 @@ liquid_handler: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerDispense_Feedback type: object goal: - additionalProperties: false properties: blow_out_air_volume: items: + maximum: 2147483647 + minimum: -2147483648 type: integer type: array flow_rates: @@ -1362,6 +958,7 @@ liquid_handler: - x - y - z + title: offsets type: object type: array resources: @@ -1436,27 +1033,40 @@ liquid_handler: - 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: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerDispense_Result type: object required: @@ -1473,9 +1083,32 @@ liquid_handler: use_channels: use_channels goal_default: allow_nonzero_volume: false - offsets: [] - tip_spots: [] - use_channels: [] + 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 @@ -1485,11 +1118,11 @@ liquid_handler: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerDropTips_Feedback type: object goal: - additionalProperties: false properties: allow_nonzero_volume: type: boolean @@ -1506,6 +1139,7 @@ liquid_handler: - x - y - z + title: offsets type: object type: array tip_spots: @@ -1580,21 +1214,31 @@ liquid_handler: - 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: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerDropTips_Result type: object required: @@ -1641,28 +1285,21 @@ liquid_handler: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerDropTips96_Feedback type: object goal: - additionalProperties: false properties: allow_nonzero_volume: type: boolean offset: - additionalProperties: false properties: x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -1671,7 +1308,6 @@ liquid_handler: title: offset type: object tip_rack: - additionalProperties: false properties: category: type: string @@ -1690,26 +1326,16 @@ liquid_handler: parent: type: string pose: - additionalProperties: false properties: orientation: - additionalProperties: false properties: w: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -1719,19 +1345,12 @@ liquid_handler: title: orientation type: object position: - additionalProperties: false properties: x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -1761,15 +1380,21 @@ liquid_handler: - data title: tip_rack type: object + required: + - tip_rack + - offset + - allow_nonzero_volume title: LiquidHandlerDropTips96_Goal type: object result: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerDropTips96_Result type: object required: @@ -1792,31 +1417,47 @@ liquid_handler: mix_rate: 0.0 mix_time: 0 mix_vol: 0 - none_keys: [] - offsets: [] - targets: [] + 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: {} - result: - return_info: return_info - success: success + result: {} schema: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerMix_Feedback type: object goal: - additionalProperties: false properties: height_to_bottom: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number mix_rate: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number mix_time: maximum: 2147483647 @@ -1843,6 +1484,7 @@ liquid_handler: - x - y - z + title: offsets type: object type: array targets: @@ -1917,17 +1559,28 @@ liquid_handler: - 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: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerMix_Result type: object required: @@ -1955,7 +1608,10 @@ liquid_handler: z: 0.0 drop_direction: '' get_direction: '' - intermediate_locations: [] + intermediate_locations: + - x: 0.0 + y: 0.0 + z: 0.0 lid: category: '' children: [] @@ -2010,26 +1666,19 @@ liquid_handler: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerMoveLid_Feedback type: object goal: - additionalProperties: false properties: destination_offset: - additionalProperties: false properties: x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -2054,10 +1703,10 @@ liquid_handler: - x - y - z + title: intermediate_locations type: object type: array lid: - additionalProperties: false properties: category: type: string @@ -2076,26 +1725,16 @@ liquid_handler: parent: type: string pose: - additionalProperties: false properties: orientation: - additionalProperties: false properties: w: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -2105,19 +1744,12 @@ liquid_handler: title: orientation type: object position: - additionalProperties: false properties: x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -2150,25 +1782,16 @@ liquid_handler: pickup_direction: type: string pickup_distance_from_top: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number put_direction: type: string resource_offset: - additionalProperties: false properties: x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -2177,7 +1800,6 @@ liquid_handler: title: resource_offset type: object to: - additionalProperties: false properties: category: type: string @@ -2196,26 +1818,16 @@ liquid_handler: parent: type: string pose: - additionalProperties: false properties: orientation: - additionalProperties: false properties: w: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -2225,19 +1837,12 @@ liquid_handler: title: orientation type: object position: - additionalProperties: false properties: x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -2267,15 +1872,28 @@ liquid_handler: - data title: to type: object + required: + - lid + - to + - intermediate_locations + - resource_offset + - destination_offset + - pickup_direction + - drop_direction + - get_direction + - put_direction + - pickup_distance_from_top title: LiquidHandlerMoveLid_Goal type: object result: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerMoveLid_Result type: object required: @@ -2303,7 +1921,10 @@ liquid_handler: z: 0.0 drop_direction: '' get_direction: '' - intermediate_locations: [] + intermediate_locations: + - x: 0.0 + y: 0.0 + z: 0.0 pickup_direction: '' pickup_distance_from_top: 0.0 pickup_offset: @@ -2362,26 +1983,19 @@ liquid_handler: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerMovePlate_Feedback type: object goal: - additionalProperties: false properties: destination_offset: - additionalProperties: false properties: x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -2406,28 +2020,20 @@ liquid_handler: - x - y - z + title: intermediate_locations type: object type: array pickup_direction: type: string pickup_distance_from_top: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number pickup_offset: - additionalProperties: false properties: x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -2436,7 +2042,6 @@ liquid_handler: title: pickup_offset type: object plate: - additionalProperties: false properties: category: type: string @@ -2455,26 +2060,16 @@ liquid_handler: parent: type: string pose: - additionalProperties: false properties: orientation: - additionalProperties: false properties: w: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -2484,19 +2079,12 @@ liquid_handler: title: orientation type: object position: - additionalProperties: false properties: x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -2529,19 +2117,12 @@ liquid_handler: put_direction: type: string resource_offset: - additionalProperties: false properties: x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -2550,7 +2131,6 @@ liquid_handler: title: resource_offset type: object to: - additionalProperties: false properties: category: type: string @@ -2569,26 +2149,16 @@ liquid_handler: parent: type: string pose: - additionalProperties: false properties: orientation: - additionalProperties: false properties: w: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -2598,19 +2168,12 @@ liquid_handler: title: orientation type: object position: - additionalProperties: false properties: x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -2640,15 +2203,29 @@ liquid_handler: - data title: to type: object + required: + - plate + - to + - intermediate_locations + - resource_offset + - pickup_offset + - destination_offset + - pickup_direction + - drop_direction + - get_direction + - put_direction + - pickup_distance_from_top title: LiquidHandlerMovePlate_Goal type: object result: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerMovePlate_Result type: object required: @@ -2676,7 +2253,10 @@ liquid_handler: z: 0.0 drop_direction: '' get_direction: '' - intermediate_locations: [] + intermediate_locations: + - x: 0.0 + y: 0.0 + z: 0.0 pickup_direction: '' pickup_distance_from_top: 0.0 put_direction: '' @@ -2715,26 +2295,19 @@ liquid_handler: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerMoveResource_Feedback type: object goal: - additionalProperties: false properties: destination_offset: - additionalProperties: false properties: x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -2759,18 +2332,16 @@ liquid_handler: - x - y - z + title: intermediate_locations type: object type: array pickup_direction: type: string pickup_distance_from_top: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number put_direction: type: string resource: - additionalProperties: false properties: category: type: string @@ -2789,26 +2360,16 @@ liquid_handler: parent: type: string pose: - additionalProperties: false properties: orientation: - additionalProperties: false properties: w: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -2818,19 +2379,12 @@ liquid_handler: title: orientation type: object position: - additionalProperties: false properties: x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -2861,19 +2415,12 @@ liquid_handler: title: resource type: object resource_offset: - additionalProperties: false properties: x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -2882,19 +2429,12 @@ liquid_handler: title: resource_offset type: object to: - additionalProperties: false properties: x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -2902,15 +2442,28 @@ liquid_handler: - z title: to type: object + required: + - resource + - to + - intermediate_locations + - resource_offset + - destination_offset + - pickup_distance_from_top + - pickup_direction + - drop_direction + - get_direction + - put_direction title: LiquidHandlerMoveResource_Goal type: object result: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerMoveResource_Result type: object required: @@ -2948,30 +2501,24 @@ liquid_handler: sample_id: '' type: '' handles: {} - placeholder_keys: {} - result: - return_info: return_info - success: success + result: {} schema: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerMoveTo_Feedback type: object goal: - additionalProperties: false properties: channel: maximum: 2147483647 minimum: -2147483648 type: integer dis_to_top: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number well: - additionalProperties: false properties: category: type: string @@ -2990,26 +2537,16 @@ liquid_handler: parent: type: string pose: - additionalProperties: false properties: orientation: - additionalProperties: false properties: w: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -3019,19 +2556,12 @@ liquid_handler: title: orientation type: object position: - additionalProperties: false properties: x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -3061,15 +2591,21 @@ liquid_handler: - data title: well type: object + required: + - well + - dis_to_top + - channel title: LiquidHandlerMoveTo_Goal type: object result: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerMoveTo_Result type: object required: @@ -3084,9 +2620,32 @@ liquid_handler: tip_spots: tip_spots use_channels: use_channels goal_default: - offsets: [] - tip_spots: [] - use_channels: [] + offsets: + - x: 0.0 + y: 0.0 + z: 0.0 + tip_spots: + - category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + use_channels: + - 0 handles: {} result: name: name @@ -3094,11 +2653,11 @@ liquid_handler: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerPickUpTips_Feedback type: object goal: - additionalProperties: false properties: offsets: items: @@ -3113,6 +2672,7 @@ liquid_handler: - x - y - z + title: offsets type: object type: array tip_spots: @@ -3187,21 +2747,30 @@ liquid_handler: - 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: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerPickUpTips_Result type: object required: @@ -3246,26 +2815,19 @@ liquid_handler: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerPickUpTips96_Feedback type: object goal: - additionalProperties: false properties: offset: - additionalProperties: false properties: x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -3274,7 +2836,6 @@ liquid_handler: title: offset type: object tip_rack: - additionalProperties: false properties: category: type: string @@ -3293,26 +2854,16 @@ liquid_handler: parent: type: string pose: - additionalProperties: false properties: orientation: - additionalProperties: false properties: w: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -3322,19 +2873,12 @@ liquid_handler: title: orientation type: object position: - additionalProperties: false properties: x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -3364,15 +2908,20 @@ liquid_handler: - data title: tip_rack type: object + required: + - tip_rack + - offset title: LiquidHandlerPickUpTips96_Goal type: object result: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerPickUpTips96_Result type: object required: @@ -3397,18 +2946,48 @@ liquid_handler: vols: vols waste_liquid: waste_liquid goal_default: - blow_out_air_volume: [] - delays: [] - flow_rates: [] + blow_out_air_volume: + - 0.0 + delays: + - 0 + flow_rates: + - 0.0 is_96_well: false - liquid_height: [] - none_keys: [] - offsets: [] - sources: [] + liquid_height: + - 0.0 + none_keys: + - '' + offsets: + - x: 0.0 + y: 0.0 + z: 0.0 + sources: + - category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' spread: '' - top: [] - use_channels: [] - vols: [] + top: + - 0.0 + use_channels: + - 0 + vols: + - 0.0 waste_liquid: category: '' children: [] @@ -3435,11 +3014,11 @@ liquid_handler: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerRemove_Feedback type: object goal: - additionalProperties: false properties: blow_out_air_volume: items: @@ -3447,6 +3026,8 @@ liquid_handler: type: array delays: items: + maximum: 2147483647 + minimum: -2147483648 type: integer type: array flow_rates: @@ -3476,6 +3057,7 @@ liquid_handler: - x - y - z + title: offsets type: object type: array sources: @@ -3550,6 +3132,7 @@ liquid_handler: - pose - config - data + title: sources type: object type: array spread: @@ -3560,6 +3143,8 @@ liquid_handler: type: array use_channels: items: + maximum: 2147483647 + minimum: -2147483648 type: integer type: array vols: @@ -3567,7 +3152,6 @@ liquid_handler: type: number type: array waste_liquid: - additionalProperties: false properties: category: type: string @@ -3586,26 +3170,16 @@ liquid_handler: parent: type: string pose: - additionalProperties: false properties: orientation: - additionalProperties: false properties: w: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -3615,19 +3189,12 @@ liquid_handler: title: orientation type: object position: - additionalProperties: false properties: x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -3657,15 +3224,31 @@ liquid_handler: - data title: waste_liquid type: object + required: + - vols + - sources + - waste_liquid + - use_channels + - flow_rates + - offsets + - liquid_height + - blow_out_air_volume + - spread + - delays + - is_96_well + - top + - none_keys title: LiquidHandlerRemove_Goal type: object result: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerRemove_Result type: object required: @@ -3690,18 +3273,48 @@ liquid_handler: vols: vols waste_liquid: waste_liquid goal_default: - blow_out_air_volume: [] - delays: [] - flow_rates: [] + blow_out_air_volume: + - 0.0 + delays: + - 0 + flow_rates: + - 0.0 is_96_well: false - liquid_height: [] - none_keys: [] - offsets: [] - sources: [] + liquid_height: + - 0.0 + none_keys: + - '' + offsets: + - x: 0.0 + y: 0.0 + z: 0.0 + sources: + - category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' spread: '' - top: [] - use_channels: [] - vols: [] + top: + - 0.0 + use_channels: + - 0 + vols: + - 0.0 waste_liquid: category: '' children: [] @@ -3726,18 +3339,16 @@ liquid_handler: placeholder_keys: sources: unilabos_resources waste_liquid: unilabos_resources - result: - return_info: return_info - success: success + result: {} schema: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerRemove_Feedback type: object goal: - additionalProperties: false properties: blow_out_air_volume: items: @@ -3745,6 +3356,8 @@ liquid_handler: type: array delays: items: + maximum: 2147483647 + minimum: -2147483648 type: integer type: array flow_rates: @@ -3774,6 +3387,7 @@ liquid_handler: - x - y - z + title: offsets type: object type: array sources: @@ -3848,6 +3462,7 @@ liquid_handler: - pose - config - data + title: sources type: object type: array spread: @@ -3858,6 +3473,8 @@ liquid_handler: type: array use_channels: items: + maximum: 2147483647 + minimum: -2147483648 type: integer type: array vols: @@ -3865,7 +3482,6 @@ liquid_handler: type: number type: array waste_liquid: - additionalProperties: false properties: category: type: string @@ -3884,26 +3500,16 @@ liquid_handler: parent: type: string pose: - additionalProperties: false properties: orientation: - additionalProperties: false properties: w: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -3913,19 +3519,12 @@ liquid_handler: title: orientation type: object position: - additionalProperties: false properties: x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -3955,15 +3554,31 @@ liquid_handler: - data title: waste_liquid type: object + required: + - vols + - sources + - waste_liquid + - use_channels + - flow_rates + - offsets + - liquid_height + - blow_out_air_volume + - spread + - delays + - is_96_well + - top + - none_keys title: LiquidHandlerRemove_Goal type: object result: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerRemove_Result type: object required: @@ -3978,7 +3593,8 @@ liquid_handler: use_channels: use_channels goal_default: allow_nonzero_volume: false - use_channels: [] + use_channels: + - 0 handles: {} result: name: name @@ -3986,27 +3602,34 @@ liquid_handler: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerReturnTips_Feedback type: object goal: - additionalProperties: false properties: allow_nonzero_volume: type: boolean use_channels: items: + maximum: 2147483647 + minimum: -2147483648 type: integer type: array + required: + - use_channels + - allow_nonzero_volume title: LiquidHandlerReturnTips_Goal type: object result: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerReturnTips_Result type: object required: @@ -4027,23 +3650,27 @@ liquid_handler: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerReturnTips96_Feedback type: object goal: - additionalProperties: false properties: allow_nonzero_volume: type: boolean + required: + - allow_nonzero_volume title: LiquidHandlerReturnTips96_Goal type: object result: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerReturnTips96_Result type: object required: @@ -4110,22 +3737,17 @@ liquid_handler: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerStamp_Feedback type: object goal: - additionalProperties: false properties: aspiration_flow_rate: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number dispense_flow_rate: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number source: - additionalProperties: false properties: category: type: string @@ -4144,26 +3766,16 @@ liquid_handler: parent: type: string pose: - additionalProperties: false properties: orientation: - additionalProperties: false properties: w: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -4173,19 +3785,12 @@ liquid_handler: title: orientation type: object position: - additionalProperties: false properties: x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -4216,7 +3821,6 @@ liquid_handler: title: source type: object target: - additionalProperties: false properties: category: type: string @@ -4235,26 +3839,16 @@ liquid_handler: parent: type: string pose: - additionalProperties: false properties: orientation: - additionalProperties: false properties: w: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -4264,19 +3858,12 @@ liquid_handler: title: orientation type: object position: - additionalProperties: false properties: x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -4307,18 +3894,24 @@ liquid_handler: title: target type: object volume: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number + required: + - source + - target + - volume + - aspiration_flow_rate + - dispense_flow_rate title: LiquidHandlerStamp_Goal type: object result: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerStamp_Result type: object required: @@ -4351,22 +3944,20 @@ liquid_handler: description: '' properties: feedback: - additionalProperties: false properties: current_status: type: string progress: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number transferred_volume: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number + required: + - progress + - transferred_volume + - current_status title: Transfer_Feedback type: object goal: - additionalProperties: false properties: amount: type: string @@ -4379,27 +3970,31 @@ liquid_handler: rinsing_solvent: type: string rinsing_volume: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number solid: type: boolean time: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number to_vessel: type: string viscous: type: boolean volume: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number + required: + - from_vessel + - to_vessel + - volume + - amount + - time + - viscous + - rinsing_solvent + - rinsing_volume + - rinsing_repeats + - solid title: Transfer_Goal type: object result: - additionalProperties: false properties: message: type: string @@ -4407,6 +4002,10 @@ liquid_handler: type: string success: type: boolean + required: + - success + - message + - return_info title: Transfer_Result type: object required: @@ -4439,27 +4038,96 @@ liquid_handler: touch_tip: touch_tip use_channels: use_channels goal_default: - asp_flow_rates: [] - asp_vols: [] - blow_out_air_volume: [] - delays: [] - dis_flow_rates: [] - dis_vols: [] + asp_flow_rates: + - 0.0 + asp_vols: + - 0.0 + blow_out_air_volume: + - 0.0 + delays: + - 0 + dis_flow_rates: + - 0.0 + dis_vols: + - 0.0 is_96_well: false - liquid_height: [] + liquid_height: + - 0.0 mix_liquid_height: 0.0 mix_rate: 0 mix_stage: '' mix_times: 0 mix_vol: 0 - none_keys: [] - offsets: [] - sources: [] + none_keys: + - '' + offsets: + - x: 0.0 + y: 0.0 + z: 0.0 + sources: + - category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' spread: '' - targets: [] - tip_racks: [] + targets: + - category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + tip_racks: + - category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' touch_tip: false - use_channels: [] + use_channels: + - 0 handles: input: - data_key: sources @@ -4492,18 +4160,16 @@ liquid_handler: sources: unilabos_resources targets: unilabos_resources tip_racks: unilabos_resources - result: - return_info: return_info - success: success + result: {} schema: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerTransfer_Feedback type: object goal: - additionalProperties: false properties: asp_flow_rates: items: @@ -4519,6 +4185,8 @@ liquid_handler: type: array delays: items: + maximum: 2147483647 + minimum: -2147483648 type: integer type: array dis_flow_rates: @@ -4536,8 +4204,6 @@ liquid_handler: type: number type: array mix_liquid_height: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number mix_rate: maximum: 2147483647 @@ -4570,6 +4236,7 @@ liquid_handler: - x - y - z + title: offsets type: object type: array sources: @@ -4644,6 +4311,7 @@ liquid_handler: - pose - config - data + title: sources type: object type: array spread: @@ -4720,6 +4388,7 @@ liquid_handler: - pose - config - data + title: targets type: object type: array tip_racks: @@ -4794,23 +4463,50 @@ liquid_handler: - pose - config - data + title: tip_racks type: object type: array touch_tip: type: boolean use_channels: items: + maximum: 2147483647 + minimum: -2147483648 type: integer type: array + required: + - asp_vols + - dis_vols + - sources + - targets + - tip_racks + - use_channels + - asp_flow_rates + - dis_flow_rates + - offsets + - touch_tip + - liquid_height + - blow_out_air_volume + - spread + - is_96_well + - mix_stage + - mix_times + - mix_vol + - mix_rate + - mix_liquid_height + - delays + - none_keys title: LiquidHandlerTransfer_Goal type: object result: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerTransfer_Result type: object required: @@ -4829,12 +4525,12 @@ liquid_handler: config: properties: backend: - type: object + type: string channel_num: default: 8 type: integer deck: - type: object + type: string simulator: default: false type: boolean @@ -4877,8 +4573,6 @@ liquid_handler.biomek: goal: properties: bind_location: - additionalProperties: - type: number type: object bind_parent_id: type: string @@ -4918,36 +4612,6 @@ liquid_handler.biomek: title: create_resource参数 type: object type: UniLabJsonCommand - auto-deserialize: - feedback: {} - goal: {} - goal_default: - allow_marshal: false - data: null - handles: {} - placeholder_keys: {} - result: {} - schema: - description: deserialize的参数schema - properties: - feedback: {} - goal: - properties: - allow_marshal: - default: false - type: boolean - data: - type: object - required: - - data - type: object - result: - type: object - required: - - goal - title: deserialize参数 - type: object - type: UniLabJsonCommand auto-instrument_setup_biomek: feedback: {} goal: {} @@ -5014,7 +4678,8 @@ liquid_handler.biomek: protocol_type: protocol_type protocol_version: protocol_version goal_default: - none_keys: [] + none_keys: + - '' protocol_author: '' protocol_date: '' protocol_description: '' @@ -5022,18 +4687,16 @@ liquid_handler.biomek: protocol_type: '' protocol_version: '' handles: {} - placeholder_keys: {} - result: - return_info: return_info + result: {} schema: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerProtocolCreation_Feedback type: object goal: - additionalProperties: false properties: none_keys: items: @@ -5051,13 +4714,22 @@ liquid_handler.biomek: type: string protocol_version: type: string + required: + - protocol_name + - protocol_description + - protocol_version + - protocol_author + - protocol_date + - protocol_type + - none_keys title: LiquidHandlerProtocolCreation_Goal type: object result: - additionalProperties: false properties: return_info: type: string + required: + - return_info title: LiquidHandlerProtocolCreation_Result type: object required: @@ -5084,33 +4756,34 @@ liquid_handler.biomek: data_type: resource handler_key: plate_out label: plate - placeholder_keys: {} - result: - return_info: return_info - success: success + result: {} schema: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerIncubateBiomek_Feedback type: object goal: - additionalProperties: false properties: time: maximum: 2147483647 minimum: -2147483648 type: integer + required: + - time title: LiquidHandlerIncubateBiomek_Goal type: object result: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerIncubateBiomek_Result type: object required: @@ -5121,10 +4794,8 @@ liquid_handler.biomek: move_biomek: feedback: {} goal: - source: source - sources: sources - target: target - targets: targets + source: sources + target: targets goal_default: sources: '' targets: '' @@ -5141,33 +4812,36 @@ liquid_handler.biomek: data_type: resource handler_key: targets label: targets - placeholder_keys: {} result: - return_info: return_info - success: success + name: name schema: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerMoveBiomek_Feedback type: object goal: - additionalProperties: false properties: sources: type: string targets: type: string + required: + - sources + - targets title: LiquidHandlerMoveBiomek_Goal type: object result: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerMoveBiomek_Result type: object required: @@ -5196,19 +4870,16 @@ liquid_handler.biomek: data_type: resource handler_key: plate_out label: plate - placeholder_keys: {} - result: - return_info: return_info - success: success + result: {} schema: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerOscillateBiomek_Feedback type: object goal: - additionalProperties: false properties: rpm: maximum: 2147483647 @@ -5218,15 +4889,20 @@ liquid_handler.biomek: maximum: 2147483647 minimum: -2147483648 type: integer + required: + - rpm + - time title: LiquidHandlerOscillateBiomek_Goal type: object result: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerOscillateBiomek_Result type: object required: @@ -5239,25 +4915,26 @@ liquid_handler.biomek: goal: {} goal_default: {} handles: {} - placeholder_keys: {} - result: - return_info: return_info + result: {} schema: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: EmptyIn_Feedback type: object goal: - additionalProperties: true + properties: {} + required: [] title: EmptyIn_Goal type: object result: - additionalProperties: false properties: return_info: type: string + required: + - return_info title: EmptyIn_Result type: object required: @@ -5268,13 +4945,9 @@ liquid_handler.biomek: transfer_biomek: feedback: {} goal: - aspirate_technique: aspirate_technique aspirate_techniques: aspirate_techniques - dispense_technique: dispense_technique dispense_techniques: dispense_techniques - source: source sources: sources - target: target targets: targets tip_rack: tip_rack volume: volume @@ -5313,19 +4986,16 @@ liquid_handler.biomek: data_type: resource handler_key: targets_out label: targets - placeholder_keys: {} - result: - return_info: return_info - success: success + result: {} schema: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerTransferBiomek_Feedback type: object goal: - additionalProperties: false properties: aspirate_technique: type: string @@ -5338,18 +5008,25 @@ liquid_handler.biomek: tip_rack: type: string volume: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number + required: + - sources + - targets + - tip_rack + - volume + - aspirate_technique + - dispense_technique title: LiquidHandlerTransferBiomek_Goal type: object result: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerTransferBiomek_Result type: object required: @@ -5382,27 +5059,96 @@ liquid_handler.biomek: touch_tip: touch_tip use_channels: use_channels goal_default: - asp_flow_rates: [] - asp_vols: [] - blow_out_air_volume: [] - delays: [] - dis_flow_rates: [] - dis_vols: [] + asp_flow_rates: + - 0.0 + asp_vols: + - 0.0 + blow_out_air_volume: + - 0.0 + delays: + - 0 + dis_flow_rates: + - 0.0 + dis_vols: + - 0.0 is_96_well: false - liquid_height: [] + liquid_height: + - 0.0 mix_liquid_height: 0.0 mix_rate: 0 mix_stage: '' mix_times: 0 mix_vol: 0 - none_keys: [] - offsets: [] - sources: [] + none_keys: + - '' + offsets: + - x: 0.0 + y: 0.0 + z: 0.0 + sources: + - category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' spread: '' - targets: [] - tip_racks: [] + targets: + - category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' + tip_racks: + - category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' touch_tip: false - use_channels: [] + use_channels: + - 0 handles: input: - data_key: sources @@ -5437,18 +5183,16 @@ liquid_handler.biomek: sources: unilabos_resources targets: unilabos_resources tip_racks: unilabos_resources - result: - return_info: return_info - success: success + result: {} schema: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerTransfer_Feedback type: object goal: - additionalProperties: false properties: asp_flow_rates: items: @@ -5464,6 +5208,8 @@ liquid_handler.biomek: type: array delays: items: + maximum: 2147483647 + minimum: -2147483648 type: integer type: array dis_flow_rates: @@ -5481,8 +5227,6 @@ liquid_handler.biomek: type: number type: array mix_liquid_height: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number mix_rate: maximum: 2147483647 @@ -5515,6 +5259,7 @@ liquid_handler.biomek: - x - y - z + title: offsets type: object type: array sources: @@ -5589,6 +5334,7 @@ liquid_handler.biomek: - pose - config - data + title: sources type: object type: array spread: @@ -5665,6 +5411,7 @@ liquid_handler.biomek: - pose - config - data + title: targets type: object type: array tip_racks: @@ -5739,23 +5486,50 @@ liquid_handler.biomek: - pose - config - data + title: tip_racks type: object type: array touch_tip: type: boolean use_channels: items: + maximum: 2147483647 + minimum: -2147483648 type: integer type: array + required: + - asp_vols + - dis_vols + - sources + - targets + - tip_racks + - use_channels + - asp_flow_rates + - dis_flow_rates + - offsets + - touch_tip + - liquid_height + - blow_out_air_volume + - spread + - is_96_well + - mix_stage + - mix_times + - mix_vol + - mix_rate + - mix_liquid_height + - delays + - none_keys title: LiquidHandlerTransfer_Goal type: object result: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerTransfer_Result type: object required: @@ -5765,7 +5539,7 @@ liquid_handler.biomek: type: LiquidHandlerTransfer module: unilabos.devices.liquid_handling.biomek:LiquidHandlerBiomek status_types: - success: '' + success: String type: python config_info: [] description: Biomek液体处理器设备,基于pylabrobot控制 @@ -5794,7 +5568,6 @@ liquid_handler.laiyu: goal: asp_vols: asp_vols blow_out_air_volume: blow_out_air_volume - delays: delays dis_vols: dis_vols flow_rates: flow_rates is_96_well: is_96_well @@ -5810,38 +5583,84 @@ liquid_handler.laiyu: targets: targets use_channels: use_channels goal_default: - asp_vols: [] - blow_out_air_volume: [] - dis_vols: [] - flow_rates: [] + asp_vols: + - 0.0 + blow_out_air_volume: + - 0.0 + dis_vols: + - 0.0 + flow_rates: + - 0.0 is_96_well: false - liquid_height: [] + liquid_height: + - 0.0 mix_liquid_height: 0.0 mix_rate: 0 mix_time: 0 mix_vol: 0 - none_keys: [] - offsets: [] - reagent_sources: [] + 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: [] - use_channels: [] + 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: - return_info: return_info - success: success + result: {} schema: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerAdd_Feedback type: object goal: - additionalProperties: false properties: asp_vols: items: @@ -5866,8 +5685,6 @@ liquid_handler.laiyu: type: number type: array mix_liquid_height: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number mix_rate: maximum: 2147483647 @@ -5898,6 +5715,7 @@ liquid_handler.laiyu: - x - y - z + title: offsets type: object type: array reagent_sources: @@ -5972,6 +5790,7 @@ liquid_handler.laiyu: - pose - config - data + title: reagent_sources type: object type: array spread: @@ -6048,21 +5867,43 @@ liquid_handler.laiyu: - 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: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerAdd_Result type: object required: @@ -6078,33 +5919,57 @@ liquid_handler.laiyu: liquid_height: liquid_height offsets: offsets resources: resources - spread: spread use_channels: use_channels vols: vols goal_default: - blow_out_air_volume: [] - flow_rates: [] - liquid_height: [] - offsets: [] - resources: [] + 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: [] - vols: [] + use_channels: + - 0 + vols: + - 0.0 handles: {} placeholder_keys: resources: unilabos_resources - result: - return_info: return_info - success: success + result: {} schema: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerAspirate_Feedback type: object goal: - additionalProperties: false properties: blow_out_air_volume: items: @@ -6131,6 +5996,7 @@ liquid_handler.laiyu: - x - y - z + title: offsets type: object type: array resources: @@ -6205,27 +6071,41 @@ liquid_handler.laiyu: - 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: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerAspirate_Result type: object required: @@ -6268,93 +6148,54 @@ liquid_handler.laiyu: goal: properties: asp_flow_rates: - items: - type: number - type: array + type: string asp_vols: - anyOf: - - items: - type: number - type: array - - type: number + type: string blow_out_air_volume: - items: - type: number - type: array + type: string delays: - items: - type: integer - type: array + type: string dis_flow_rates: - items: - type: number - type: array + type: string dis_vols: - anyOf: - - items: - type: number - type: array - - type: number + type: string is_96_well: default: false type: boolean liquid_height: - items: - type: number - type: array + type: string mix_liquid_height: - type: number + type: string mix_rate: - type: integer + type: string mix_stage: default: none - enum: - - none - - before - - after - - both type: string mix_times: - items: - type: integer - type: array + type: string mix_vol: - type: integer + type: string none_keys: default: [] items: type: string type: array offsets: - items: - type: object - type: array + type: string sources: - items: - type: object - type: array + type: string spread: default: wide - enum: - - wide - - tight - - custom type: string targets: - items: - type: object - type: array + type: string tip_racks: - items: - type: object - type: array + type: string touch_tip: default: false type: boolean use_channels: - items: - type: integer - type: array + type: string required: - sources - targets @@ -6376,35 +6217,60 @@ liquid_handler.laiyu: liquid_height: liquid_height offsets: offsets resources: resources - spread: spread use_channels: use_channels vols: vols goal_default: - blow_out_air_volume: [] - flow_rates: [] - offsets: [] - resources: [] + 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: [] - vols: [] + use_channels: + - 0 + vols: + - 0.0 handles: {} placeholder_keys: resources: unilabos_resources - result: - return_info: return_info - success: success + result: {} schema: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerDispense_Feedback type: object goal: - additionalProperties: false properties: blow_out_air_volume: items: + maximum: 2147483647 + minimum: -2147483648 type: integer type: array flow_rates: @@ -6424,6 +6290,7 @@ liquid_handler.laiyu: - x - y - z + title: offsets type: object type: array resources: @@ -6498,27 +6365,40 @@ liquid_handler.laiyu: - 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: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerDispense_Result type: object required: @@ -6535,24 +6415,45 @@ liquid_handler.laiyu: use_channels: use_channels goal_default: allow_nonzero_volume: false - offsets: [] - tip_spots: [] - use_channels: [] + 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: - return_info: return_info - success: success + result: {} schema: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerDropTips_Feedback type: object goal: - additionalProperties: false properties: allow_nonzero_volume: type: boolean @@ -6569,6 +6470,7 @@ liquid_handler.laiyu: - x - y - z + title: offsets type: object type: array tip_spots: @@ -6643,21 +6545,31 @@ liquid_handler.laiyu: - 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: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerDropTips_Result type: object required: @@ -6680,32 +6592,49 @@ liquid_handler.laiyu: mix_rate: 0.0 mix_time: 0 mix_vol: 0 - none_keys: [] - offsets: [] - targets: [] + 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: - return_info: return_info - success: success + result: {} schema: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerMix_Feedback type: object goal: - additionalProperties: false properties: height_to_bottom: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number mix_rate: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number mix_time: maximum: 2147483647 @@ -6732,6 +6661,7 @@ liquid_handler.laiyu: - x - y - z + title: offsets type: object type: array targets: @@ -6806,17 +6736,28 @@ liquid_handler.laiyu: - 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: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerMix_Result type: object required: @@ -6831,24 +6772,45 @@ liquid_handler.laiyu: tip_spots: tip_spots use_channels: use_channels goal_default: - offsets: [] - tip_spots: [] - use_channels: [] + 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: - return_info: return_info - success: success + result: {} schema: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerPickUpTips_Feedback type: object goal: - additionalProperties: false properties: offsets: items: @@ -6863,6 +6825,7 @@ liquid_handler.laiyu: - x - y - z + title: offsets type: object type: array tip_spots: @@ -6937,21 +6900,30 @@ liquid_handler.laiyu: - 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: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerPickUpTips_Result type: object required: @@ -7006,7 +6978,6 @@ liquid_handler.prcxi: goal: asp_vols: asp_vols blow_out_air_volume: blow_out_air_volume - delays: delays dis_vols: dis_vols flow_rates: flow_rates is_96_well: is_96_well @@ -7022,38 +6993,84 @@ liquid_handler.prcxi: targets: targets use_channels: use_channels goal_default: - asp_vols: [] - blow_out_air_volume: [] - dis_vols: [] - flow_rates: [] + asp_vols: + - 0.0 + blow_out_air_volume: + - 0.0 + dis_vols: + - 0.0 + flow_rates: + - 0.0 is_96_well: false - liquid_height: [] + liquid_height: + - 0.0 mix_liquid_height: 0.0 mix_rate: 0 mix_time: 0 mix_vol: 0 - none_keys: [] - offsets: [] - reagent_sources: [] + 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: [] - use_channels: [] + 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: - return_info: return_info - success: success + result: {} schema: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerAdd_Feedback type: object goal: - additionalProperties: false properties: asp_vols: items: @@ -7078,8 +7095,6 @@ liquid_handler.prcxi: type: number type: array mix_liquid_height: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number mix_rate: maximum: 2147483647 @@ -7110,6 +7125,7 @@ liquid_handler.prcxi: - x - y - z + title: offsets type: object type: array reagent_sources: @@ -7184,6 +7200,7 @@ liquid_handler.prcxi: - pose - config - data + title: reagent_sources type: object type: array spread: @@ -7260,21 +7277,43 @@ liquid_handler.prcxi: - 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: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerAdd_Result type: object required: @@ -7290,33 +7329,57 @@ liquid_handler.prcxi: liquid_height: liquid_height offsets: offsets resources: resources - spread: spread use_channels: use_channels vols: vols goal_default: - blow_out_air_volume: [] - flow_rates: [] - liquid_height: [] - offsets: [] - resources: [] + 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: [] - vols: [] + use_channels: + - 0 + vols: + - 0.0 handles: {} placeholder_keys: resources: unilabos_resources - result: - return_info: return_info - success: success + result: {} schema: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerAspirate_Feedback type: object goal: - additionalProperties: false properties: blow_out_air_volume: items: @@ -7343,6 +7406,7 @@ liquid_handler.prcxi: - x - y - z + title: offsets type: object type: array resources: @@ -7417,27 +7481,41 @@ liquid_handler.prcxi: - 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: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerAspirate_Result type: object required: @@ -7568,14 +7646,11 @@ liquid_handler.prcxi: goal: properties: tip_racks: - items: - type: object - type: array + type: string required: - tip_racks type: object - result: - type: string + result: {} required: - goal title: iter_tips参数 @@ -7651,10 +7726,61 @@ liquid_handler.prcxi: title: move_to参数 type: object type: UniLabJsonCommandAsync + auto-plr_pos_to_prcxi: + feedback: {} + goal: {} + goal_default: + resource: null + handles: {} + placeholder_keys: {} + result: {} + schema: + description: '' + properties: + feedback: {} + goal: + properties: + resource: + type: object + required: + - resource + type: object + result: {} + required: + - goal + title: plr_pos_to_prcxi参数 + 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: object + required: + - ros_node + type: object + result: {} + required: + - goal + title: post_init参数 + type: object + type: UniLabJsonCommand auto-run_protocol: feedback: {} goal: {} - goal_default: {} + goal_default: + protocol_id: null handles: {} placeholder_keys: {} result: {} @@ -7663,7 +7789,9 @@ liquid_handler.prcxi: properties: feedback: {} goal: - properties: {} + properties: + protocol_id: + type: string required: [] type: object result: {} @@ -7802,9 +7930,7 @@ liquid_handler.prcxi: goal: properties: targets: - items: - type: object - type: array + type: string required: - targets type: object @@ -7850,39 +7976,41 @@ liquid_handler.prcxi: discard_tips: feedback: {} goal: - allow_nonzero_volume: allow_nonzero_volume - offsets: offsets use_channels: use_channels goal_default: - use_channels: [] + use_channels: + - 0 handles: {} - placeholder_keys: {} - result: - return_info: return_info - success: success + result: {} schema: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerDiscardTips_Feedback type: object goal: - additionalProperties: false properties: use_channels: items: + maximum: 2147483647 + minimum: -2147483648 type: integer type: array + required: + - use_channels title: LiquidHandlerDiscardTips_Goal type: object result: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerDiscardTips_Result type: object required: @@ -7895,38 +8023,63 @@ liquid_handler.prcxi: goal: blow_out_air_volume: blow_out_air_volume flow_rates: flow_rates - liquid_height: liquid_height offsets: offsets resources: resources spread: spread use_channels: use_channels vols: vols goal_default: - blow_out_air_volume: [] - flow_rates: [] - offsets: [] - resources: [] + 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: [] - vols: [] + use_channels: + - 0 + vols: + - 0.0 handles: {} placeholder_keys: resources: unilabos_resources - result: - return_info: return_info - success: success + result: {} schema: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerDispense_Feedback type: object goal: - additionalProperties: false properties: blow_out_air_volume: items: + maximum: 2147483647 + minimum: -2147483648 type: integer type: array flow_rates: @@ -7946,6 +8099,7 @@ liquid_handler.prcxi: - x - y - z + title: offsets type: object type: array resources: @@ -8020,27 +8174,40 @@ liquid_handler.prcxi: - 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: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerDispense_Result type: object required: @@ -8057,24 +8224,45 @@ liquid_handler.prcxi: use_channels: use_channels goal_default: allow_nonzero_volume: false - offsets: [] - tip_spots: [] - use_channels: [] + 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: - return_info: return_info - success: success + result: {} schema: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerDropTips_Feedback type: object goal: - additionalProperties: false properties: allow_nonzero_volume: type: boolean @@ -8091,6 +8279,7 @@ liquid_handler.prcxi: - x - y - z + title: offsets type: object type: array tip_spots: @@ -8165,21 +8354,31 @@ liquid_handler.prcxi: - 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: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerDropTips_Result type: object required: @@ -8202,32 +8401,49 @@ liquid_handler.prcxi: mix_rate: 0.0 mix_time: 0 mix_vol: 0 - none_keys: [] - offsets: [] - targets: [] + 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: - return_info: return_info - success: success + result: {} schema: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerMix_Feedback type: object goal: - additionalProperties: false properties: height_to_bottom: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number mix_rate: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number mix_time: maximum: 2147483647 @@ -8254,6 +8470,7 @@ liquid_handler.prcxi: - x - y - z + title: offsets type: object type: array targets: @@ -8328,17 +8545,28 @@ liquid_handler.prcxi: - 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: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerMix_Result type: object required: @@ -8354,7 +8582,6 @@ liquid_handler.prcxi: get_direction: get_direction intermediate_locations: intermediate_locations pickup_direction: pickup_direction - pickup_distance_from_top: pickup_distance_from_top pickup_offset: pickup_offset plate: plate put_direction: put_direction @@ -8367,7 +8594,10 @@ liquid_handler.prcxi: z: 0.0 drop_direction: '' get_direction: '' - intermediate_locations: [] + intermediate_locations: + - x: 0.0 + y: 0.0 + z: 0.0 pickup_direction: '' pickup_distance_from_top: 0.0 pickup_offset: @@ -8424,32 +8654,24 @@ liquid_handler.prcxi: plate: unilabos_resources to: unilabos_resources result: - return_info: return_info - success: success + name: name schema: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerMovePlate_Feedback type: object goal: - additionalProperties: false properties: destination_offset: - additionalProperties: false properties: x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -8474,28 +8696,20 @@ liquid_handler.prcxi: - x - y - z + title: intermediate_locations type: object type: array pickup_direction: type: string pickup_distance_from_top: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number pickup_offset: - additionalProperties: false properties: x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -8504,7 +8718,6 @@ liquid_handler.prcxi: title: pickup_offset type: object plate: - additionalProperties: false properties: category: type: string @@ -8523,26 +8736,16 @@ liquid_handler.prcxi: parent: type: string pose: - additionalProperties: false properties: orientation: - additionalProperties: false properties: w: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -8552,19 +8755,12 @@ liquid_handler.prcxi: title: orientation type: object position: - additionalProperties: false properties: x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -8597,19 +8793,12 @@ liquid_handler.prcxi: put_direction: type: string resource_offset: - additionalProperties: false properties: x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -8618,7 +8807,6 @@ liquid_handler.prcxi: title: resource_offset type: object to: - additionalProperties: false properties: category: type: string @@ -8637,26 +8825,16 @@ liquid_handler.prcxi: parent: type: string pose: - additionalProperties: false properties: orientation: - additionalProperties: false properties: w: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -8666,19 +8844,12 @@ liquid_handler.prcxi: title: orientation type: object position: - additionalProperties: false properties: x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -8708,15 +8879,29 @@ liquid_handler.prcxi: - data title: to type: object + required: + - plate + - to + - intermediate_locations + - resource_offset + - pickup_offset + - destination_offset + - pickup_direction + - drop_direction + - get_direction + - put_direction + - pickup_distance_from_top title: LiquidHandlerMovePlate_Goal type: object result: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerMovePlate_Result type: object required: @@ -8731,24 +8916,45 @@ liquid_handler.prcxi: tip_spots: tip_spots use_channels: use_channels goal_default: - offsets: [] - tip_spots: [] - use_channels: [] + 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: - return_info: return_info - success: success + result: {} schema: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerPickUpTips_Feedback type: object goal: - additionalProperties: false properties: offsets: items: @@ -8763,6 +8969,7 @@ liquid_handler.prcxi: - x - y - z + title: offsets type: object type: array tip_spots: @@ -8837,21 +9044,30 @@ liquid_handler.prcxi: - 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: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerPickUpTips_Result type: object required: @@ -8876,18 +9092,48 @@ liquid_handler.prcxi: vols: vols waste_liquid: waste_liquid goal_default: - blow_out_air_volume: [] - delays: [] - flow_rates: [] + blow_out_air_volume: + - 0.0 + delays: + - 0 + flow_rates: + - 0.0 is_96_well: false - liquid_height: [] - none_keys: [] - offsets: [] - sources: [] + liquid_height: + - 0.0 + none_keys: + - '' + offsets: + - x: 0.0 + y: 0.0 + z: 0.0 + sources: + - category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' spread: '' - top: [] - use_channels: [] - vols: [] + top: + - 0.0 + use_channels: + - 0 + vols: + - 0.0 waste_liquid: category: '' children: [] @@ -8912,18 +9158,16 @@ liquid_handler.prcxi: placeholder_keys: sources: unilabos_resources waste_liquid: unilabos_resources - result: - return_info: return_info - success: success + result: {} schema: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerRemove_Feedback type: object goal: - additionalProperties: false properties: blow_out_air_volume: items: @@ -8931,6 +9175,8 @@ liquid_handler.prcxi: type: array delays: items: + maximum: 2147483647 + minimum: -2147483648 type: integer type: array flow_rates: @@ -8960,6 +9206,7 @@ liquid_handler.prcxi: - x - y - z + title: offsets type: object type: array sources: @@ -9034,6 +9281,7 @@ liquid_handler.prcxi: - pose - config - data + title: sources type: object type: array spread: @@ -9044,6 +9292,8 @@ liquid_handler.prcxi: type: array use_channels: items: + maximum: 2147483647 + minimum: -2147483648 type: integer type: array vols: @@ -9051,7 +9301,6 @@ liquid_handler.prcxi: type: number type: array waste_liquid: - additionalProperties: false properties: category: type: string @@ -9070,26 +9319,16 @@ liquid_handler.prcxi: parent: type: string pose: - additionalProperties: false properties: orientation: - additionalProperties: false properties: w: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -9099,19 +9338,12 @@ liquid_handler.prcxi: title: orientation type: object position: - additionalProperties: false properties: x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -9141,15 +9373,31 @@ liquid_handler.prcxi: - data title: waste_liquid type: object + required: + - vols + - sources + - waste_liquid + - use_channels + - flow_rates + - offsets + - liquid_height + - blow_out_air_volume + - spread + - delays + - is_96_well + - top + - none_keys title: LiquidHandlerRemove_Goal type: object result: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerRemove_Result type: object required: @@ -9164,9 +9412,30 @@ liquid_handler.prcxi: volumes: volumes wells: wells goal_default: - liquid_names: [] - volumes: [] - wells: [] + liquid_names: + - '' + volumes: + - 0.0 + wells: + - 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: wells @@ -9182,17 +9451,16 @@ liquid_handler.prcxi: label: 已设定液体孔 placeholder_keys: wells: unilabos_resources - result: - return_info: return_info + result: {} schema: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerSetLiquid_Feedback type: object goal: - additionalProperties: false properties: liquid_names: items: @@ -9274,15 +9542,21 @@ liquid_handler.prcxi: - pose - config - data + title: wells type: object type: array + required: + - wells + - liquid_names + - volumes title: LiquidHandlerSetLiquid_Goal type: object result: - additionalProperties: false properties: return_info: type: string + required: + - return_info title: LiquidHandlerSetLiquid_Result type: object required: @@ -9335,7 +9609,6 @@ liquid_handler.prcxi: type: string type: array plate: - additionalProperties: false properties: category: type: string @@ -9354,26 +9627,16 @@ liquid_handler.prcxi: parent: type: string pose: - additionalProperties: false properties: orientation: - additionalProperties: false properties: w: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -9383,19 +9646,12 @@ liquid_handler.prcxi: title: orientation type: object position: - additionalProperties: false properties: x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -9412,6 +9668,17 @@ liquid_handler.prcxi: type: string type: type: string + required: + - id + - name + - sample_id + - children + - parent + - type + - category + - pose + - config + - data title: plate type: object volumes: @@ -9465,11 +9732,6 @@ liquid_handler.prcxi: description: Resource ID title: Id type: string - machine_name: - default: '' - description: Machine this resource belongs to - title: Machine Name - type: string model: additionalProperties: true description: Resource model @@ -9533,14 +9795,6 @@ liquid_handler.prcxi: - rounded_rectangle title: Cross Section Type type: string - extra: - anyOf: - - additionalProperties: true - type: object - - type: 'null' - default: null - description: Extra data - title: Extra layout: default: x-y description: Resource layout @@ -9661,22 +9915,39 @@ liquid_handler.prcxi: goal: tip_racks: tip_racks goal_default: - tip_racks: [] + tip_racks: + - category: '' + children: [] + config: '' + data: '' + id: '' + name: '' + parent: '' + pose: + orientation: + w: 1.0 + x: 0.0 + y: 0.0 + z: 0.0 + position: + x: 0.0 + y: 0.0 + z: 0.0 + sample_id: '' + type: '' handles: {} placeholder_keys: tip_racks: unilabos_resources - result: - return_info: return_info - success: success + result: {} schema: description: '' properties: feedback: - additionalProperties: true + properties: {} + required: [] title: LiquidHandlerSetTipRack_Feedback type: object goal: - additionalProperties: false properties: tip_racks: items: @@ -9750,17 +10021,22 @@ liquid_handler.prcxi: - pose - config - data + title: tip_racks type: object type: array + required: + - tip_racks title: LiquidHandlerSetTipRack_Goal type: object result: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: LiquidHandlerSetTipRack_Result type: object required: @@ -9793,22 +10069,20 @@ liquid_handler.prcxi: description: '' properties: feedback: - additionalProperties: false properties: current_status: type: string progress: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number transferred_volume: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number + required: + - progress + - transferred_volume + - current_status title: Transfer_Feedback type: object goal: - additionalProperties: false properties: amount: type: string @@ -9821,27 +10095,31 @@ liquid_handler.prcxi: rinsing_solvent: type: string rinsing_volume: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number solid: type: boolean time: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number to_vessel: type: string viscous: type: boolean volume: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number + required: + - from_vessel + - to_vessel + - volume + - amount + - time + - viscous + - rinsing_solvent + - rinsing_volume + - rinsing_repeats + - solid title: Transfer_Goal type: object result: - additionalProperties: false properties: message: type: string @@ -9849,6 +10127,10 @@ liquid_handler.prcxi: type: string success: type: boolean + required: + - success + - message + - return_info title: Transfer_Result type: object required: @@ -9858,50 +10140,31 @@ liquid_handler.prcxi: type: Transfer transfer_liquid: feedback: {} - goal: - asp_flow_rates: asp_flow_rates - asp_vols: asp_vols - blow_out_air_volume: blow_out_air_volume - delays: delays - dis_flow_rates: dis_flow_rates - dis_vols: dis_vols - is_96_well: is_96_well - liquid_height: liquid_height - mix_liquid_height: mix_liquid_height - mix_rate: mix_rate - mix_stage: mix_stage - mix_times: mix_times - mix_vol: mix_vol - none_keys: none_keys - offsets: offsets - sources: sources - spread: spread - targets: targets - tip_racks: tip_racks - touch_tip: touch_tip - use_channels: use_channels + goal: {} goal_default: - asp_flow_rates: [] - asp_vols: [] - blow_out_air_volume: [] - delays: [] - dis_flow_rates: [] - dis_vols: [] + asp_flow_rates: null + asp_vols: null + blow_out_air_volume: null + blow_out_air_volume_before: null + delays: null + dis_flow_rates: null + dis_vols: null is_96_well: false - liquid_height: [] - mix_liquid_height: 0.0 - mix_rate: 0 - mix_stage: '' - mix_times: 0 - mix_vol: 0 + liquid_height: null + mix_liquid_height: null + mix_rate: null + mix_stage: none + mix_times: null + mix_vol: null none_keys: [] - offsets: [] - sources: [] - spread: '' - targets: [] - tip_racks: [] + offsets: null + sources: null + spread: wide + targets: null + tip_racks: null touch_tip: false - use_channels: [] + use_channels: + - 0 handles: input: - data_key: sources @@ -9934,18 +10197,12 @@ liquid_handler.prcxi: sources: unilabos_resources targets: unilabos_resources tip_racks: unilabos_resources - result: - return_info: return_info - success: success + result: {} schema: description: '' properties: - feedback: - additionalProperties: true - title: LiquidHandlerTransfer_Feedback - type: object + feedback: {} goal: - additionalProperties: false properties: asp_flow_rates: items: @@ -9959,8 +10216,14 @@ liquid_handler.prcxi: items: type: number type: array + blow_out_air_volume_before: + items: + type: number + type: array delays: items: + maximum: 2147483647 + minimum: -2147483648 type: integer type: array dis_flow_rates: @@ -9972,20 +10235,20 @@ liquid_handler.prcxi: type: number type: array is_96_well: + default: false type: boolean liquid_height: items: type: number type: array mix_liquid_height: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number mix_rate: maximum: 2147483647 minimum: -2147483648 type: integer mix_stage: + default: none type: string mix_times: maximum: 2147483647 @@ -9996,6 +10259,7 @@ liquid_handler.prcxi: minimum: -2147483648 type: integer none_keys: + default: [] items: type: string type: array @@ -10012,6 +10276,7 @@ liquid_handler.prcxi: - x - y - z + title: offsets type: object type: array sources: @@ -10086,9 +10351,11 @@ liquid_handler.prcxi: - pose - config - data + title: sources type: object type: array spread: + default: wide type: string targets: items: @@ -10162,6 +10429,7 @@ liquid_handler.prcxi: - pose - config - data + title: targets type: object type: array tip_racks: @@ -10236,30 +10504,234 @@ liquid_handler.prcxi: - pose - config - data + title: tip_racks type: object type: array touch_tip: + default: false type: boolean use_channels: items: + maximum: 2147483647 + minimum: -2147483648 type: integer type: array - title: LiquidHandlerTransfer_Goal + required: + - sources + - targets + - tip_racks + - asp_vols + - dis_vols type: object result: - additionalProperties: false + $defs: + ResourceDict: + properties: + class: + description: Resource class name + title: Class + type: string + config: + additionalProperties: true + description: Resource configuration + title: Config + type: object + data: + additionalProperties: true + description: 'Resource data, eg: container liquid data' + title: Data + type: object + description: + default: '' + description: Resource description + title: Description + type: string + extra: + additionalProperties: true + description: 'Extra data, eg: slot index' + title: Extra + type: object + icon: + default: '' + description: Resource icon + title: Icon + type: string + id: + description: Resource ID + title: Id + type: string + model: + additionalProperties: true + description: Resource model + title: Model + type: object + name: + description: Resource name + title: Name + type: string + parent: + anyOf: + - $ref: '#/$defs/ResourceDict' + - type: 'null' + default: null + description: Parent resource object + parent_uuid: + anyOf: + - type: string + - type: 'null' + default: null + description: Parent resource uuid + title: Parent Uuid + pose: + $ref: '#/$defs/ResourceDictPosition' + description: Resource position + schema: + additionalProperties: true + description: Resource schema + title: Schema + type: object + type: + anyOf: + - const: device + type: string + - type: string + description: Resource type + title: Type + uuid: + description: Resource UUID + title: Uuid + type: string + required: + - id + - uuid + - name + - type + - class + - config + - data + - extra + title: ResourceDict + type: object + ResourceDictPosition: + properties: + cross_section_type: + default: rectangle + description: Cross section type + enum: + - rectangle + - circle + - rounded_rectangle + title: Cross Section Type + type: string + layout: + default: x-y + description: Resource layout + enum: + - 2d + - x-y + - z-y + - x-z + title: Layout + type: string + position: + $ref: '#/$defs/ResourceDictPositionObject' + description: Resource position + position3d: + $ref: '#/$defs/ResourceDictPositionObject' + description: Resource position in 3D space + rotation: + $ref: '#/$defs/ResourceDictPositionObject' + description: Resource rotation + scale: + $ref: '#/$defs/ResourceDictPositionScale' + description: Resource scale + size: + $ref: '#/$defs/ResourceDictPositionSize' + description: Resource size + title: ResourceDictPosition + type: object + ResourceDictPositionObject: + properties: + x: + default: 0.0 + description: X coordinate + title: X + type: number + y: + default: 0.0 + description: Y coordinate + title: Y + type: number + z: + default: 0.0 + description: Z coordinate + title: Z + type: number + title: ResourceDictPositionObject + type: object + ResourceDictPositionScale: + properties: + x: + default: 0.0 + description: x scale + title: X + type: number + y: + default: 0.0 + description: y scale + title: Y + type: number + z: + default: 0.0 + description: z scale + title: Z + type: number + title: ResourceDictPositionScale + type: object + ResourceDictPositionSize: + properties: + depth: + default: 0.0 + description: Depth + title: Depth + type: number + height: + default: 0.0 + description: Height + title: Height + type: number + width: + default: 0.0 + description: Width + title: Width + type: number + title: ResourceDictPositionSize + type: object properties: - return_info: - type: string - success: - type: boolean - title: LiquidHandlerTransfer_Result + sources: + items: + items: + $ref: '#/$defs/ResourceDict' + type: array + title: Sources + type: array + targets: + items: + items: + $ref: '#/$defs/ResourceDict' + type: array + title: Targets + type: array + required: + - sources + - targets + title: TransferLiquidReturn type: object required: - goal - title: LiquidHandlerTransfer + title: transfer_liquid参数 type: object - type: LiquidHandlerTransfer + type: UniLabJsonCommandAsync module: unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Handler status_types: reset_ok: bool @@ -10282,6 +10754,12 @@ liquid_handler.prcxi: type: string deck: type: object + deck_y: + default: 400 + type: string + deck_z: + default: 300 + type: string host: type: string is_9320: @@ -10292,17 +10770,44 @@ liquid_handler.prcxi: type: string port: type: integer + rail_interval: + default: 0 + type: string + rail_nums: + default: 4 + type: string + rail_width: + default: 27.5 + type: string setup: default: true type: string simulator: default: false type: string + start_rail: + default: 2 + type: string step_mode: default: false type: string timeout: type: number + x_increase: + default: -0.003636 + type: string + x_offset: + default: -0.8 + type: string + xy_coupling: + default: -0.0045 + type: string + y_increase: + default: -0.003636 + type: string + y_offset: + default: -37.98 + type: string required: - deck - host @@ -10324,13 +10829,11 @@ liquid_handler.revvity: action_value_mappings: run: feedback: - gantt: gantt status: status goal: - file_path: file_path params: params resource: resource - wf_name: wf_name + wf_name: file_path goal_default: params: '' resource: @@ -10355,29 +10858,27 @@ liquid_handler.revvity: type: '' wf_name: '' handles: {} - placeholder_keys: {} result: - return_info: return_info success: success schema: description: '' properties: feedback: - additionalProperties: false properties: gantt: type: string status: type: string + required: + - status + - gantt title: WorkStationRun_Feedback type: object goal: - additionalProperties: false properties: params: type: string resource: - additionalProperties: false properties: category: type: string @@ -10396,26 +10897,16 @@ liquid_handler.revvity: parent: type: string pose: - additionalProperties: false properties: orientation: - additionalProperties: false properties: w: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -10425,19 +10916,12 @@ liquid_handler.revvity: title: orientation type: object position: - additionalProperties: false properties: x: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number y: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number z: - maximum: 1.7976931348623157e+308 - minimum: -1.7976931348623157e+308 type: number required: - x @@ -10469,15 +10953,21 @@ liquid_handler.revvity: type: object wf_name: type: string + required: + - wf_name + - params + - resource title: WorkStationRun_Goal type: object result: - additionalProperties: false properties: return_info: type: string success: type: boolean + required: + - return_info + - success title: WorkStationRun_Result type: object required: @@ -10506,7 +10996,7 @@ liquid_handler.revvity: success: type: boolean required: - - status - success + - status type: object version: 1.0.0 diff --git a/unilabos/ros/main_slave_run.py b/unilabos/ros/main_slave_run.py index c24f9e8e..3d957f62 100644 --- a/unilabos/ros/main_slave_run.py +++ b/unilabos/ros/main_slave_run.py @@ -51,6 +51,7 @@ def main( bridges: List[Any] = [], visual: str = "disable", resources_mesh_config: dict = {}, + resources_mesh_resource_list: list = [], rclpy_init_args: List[str] = ["--log-level", "debug"], discovery_interval: float = 15.0, ) -> None: @@ -77,12 +78,12 @@ def main( if visual != "disable": from unilabos.ros.nodes.presets.joint_republisher import JointRepublisher - # 将 ResourceTreeSet 转换为 list 用于 visual 组件 - resources_list = ( - [node.res_content.model_dump(by_alias=True) for node in resources_config.all_nodes] - if resources_config - else [] - ) + # 优先使用从 main.py 传入的完整资源列表(包含所有子资源) + if resources_mesh_resource_list: + resources_list = resources_mesh_resource_list + else: + # fallback: 从 ResourceTreeSet 获取 + resources_list = [node.res_content.model_dump(by_alias=True) for node in resources_config.all_nodes] resource_mesh_manager = ResourceMeshManager( resources_mesh_config, resources_list, @@ -90,7 +91,7 @@ def main( device_id="resource_mesh_manager", device_uuid=str(uuid.uuid4()), ) - joint_republisher = JointRepublisher("joint_republisher", host_node.resource_tracker) + joint_republisher = JointRepublisher("joint_republisher","joint_republisher", host_node.resource_tracker) # lh_joint_pub = LiquidHandlerJointPublisher( # resources_config=resources_list, resource_tracker=host_node.resource_tracker # ) @@ -114,6 +115,7 @@ def slave( bridges: List[Any] = [], visual: str = "disable", resources_mesh_config: dict = {}, + resources_mesh_resource_list: list = [], rclpy_init_args: List[str] = ["--log-level", "debug"], ) -> None: """从节点函数""" @@ -208,12 +210,12 @@ def slave( if visual != "disable": from unilabos.ros.nodes.presets.joint_republisher import JointRepublisher - # 将 ResourceTreeSet 转换为 list 用于 visual 组件 - resources_list = ( - [node.res_content.model_dump(by_alias=True) for node in resources_config.all_nodes] - if resources_config - else [] - ) + # 优先使用从 main.py 传入的完整资源列表(包含所有子资源) + if resources_mesh_resource_list: + resources_list = resources_mesh_resource_list + else: + # fallback: 从 ResourceTreeSet 获取 + resources_list = [node.res_content.model_dump(by_alias=True) for node in resources_config.all_nodes] resource_mesh_manager = ResourceMeshManager( resources_mesh_config, resources_list, diff --git a/unilabos/ros/nodes/presets/host_node.py b/unilabos/ros/nodes/presets/host_node.py index 2cac28f4..369ed5ae 100644 --- a/unilabos/ros/nodes/presets/host_node.py +++ b/unilabos/ros/nodes/presets/host_node.py @@ -22,6 +22,7 @@ from unilabos_msgs.srv import ( SerialCommand, ) # type: ignore from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response +from unilabos_msgs.action import SendCmd from unique_identifier_msgs.msg import UUID from unilabos.registry.decorators import device, action, NodeType @@ -313,9 +314,15 @@ class HostNode(BaseROS2DeviceNode): callback_group=self.callback_group, ), } # 用来存储多个ActionClient实例 + self._add_resource_mesh_client = ActionClient( + self, + SendCmd, + "/devices/resource_mesh_manager/add_resource_mesh", + callback_group=self.callback_group, + ) self._action_value_mappings: Dict[str, Dict] = { device_id: self._action_value_mappings - } # device_id -> action_value_mappings(本地+远程设备统一存储) + } # device_id -> action_value_mappings(本地+远程设备统一存储) self._slave_registry_configs: Dict[str, Dict] = {} # registry_name -> registry_config(含action_value_mappings) self._goals: Dict[str, Any] = {} # 用来存储多个目标的状态 self._online_devices: Set[str] = {f"{self.namespace}/{device_id}"} # 用于跟踪在线设备 @@ -1131,6 +1138,27 @@ class HostNode(BaseROS2DeviceNode): ), } + def _notify_resource_mesh_add(self, resource_tree_set: ResourceTreeSet): + """通知 ResourceMeshManager 添加资源的 mesh 可视化""" + if not self._add_resource_mesh_client.server_is_ready(): + self.lab_logger().debug("[Host Node] ResourceMeshManager 未就绪,跳过 mesh 添加通知") + return + + resource_configs = [] + for node in resource_tree_set.all_nodes: + res_dict = node.res_content.model_dump(by_alias=True) + if res_dict.get("type") == "device": + continue + resource_configs.append(res_dict) + + if not resource_configs: + return + + goal_msg = SendCmd.Goal() + goal_msg.command = json.dumps({"resources": resource_configs}) + self._add_resource_mesh_client.send_goal_async(goal_msg) + self.lab_logger().info(f"[Host Node] 已发送 {len(resource_configs)} 个资源 mesh 添加请求") + async def _resource_tree_action_add_callback(self, data: dict, response: SerialCommand_Response): # OK resource_tree_set = ResourceTreeSet.load(data["data"]) mount_uuid = data["mount_uuid"] @@ -1171,6 +1199,12 @@ class HostNode(BaseROS2DeviceNode): response.response = json.dumps(uuid_mapping) if success else "FAILED" self.lab_logger().info(f"[Host Node-Resource] Resource tree add completed, success: {success}") + if success: + try: + self._notify_resource_mesh_add(resource_tree_set) + except Exception as e: + self.lab_logger().error(f"[Host Node] 通知 ResourceMeshManager 添加 mesh 失败: {e}") + async def _resource_tree_action_get_callback(self, data: dict, response: SerialCommand_Response): # OK uuid_list: List[str] = data["data"] with_children: bool = data["with_children"] @@ -1222,6 +1256,12 @@ class HostNode(BaseROS2DeviceNode): response.response = json.dumps(uuid_mapping) self.lab_logger().info(f"[Host Node-Resource] Resource tree update completed, success: {success}") + if success: + try: + self._notify_resource_mesh_add(new_tree_set) + except Exception as e: + self.lab_logger().error(f"[Host Node] 通知 ResourceMeshManager 更新 mesh 失败: {e}") + async def _resource_tree_update_callback(self, request: SerialCommand_Request, response: SerialCommand_Response): """ 子节点通知Host物料树更新 diff --git a/unilabos/ros/nodes/presets/resource_mesh_manager.py b/unilabos/ros/nodes/presets/resource_mesh_manager.py index 45e330dd..3eac290b 100644 --- a/unilabos/ros/nodes/presets/resource_mesh_manager.py +++ b/unilabos/ros/nodes/presets/resource_mesh_manager.py @@ -23,17 +23,32 @@ from unilabos_msgs.action import SendCmd from rclpy.action.server import ServerGoalHandle from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode,DeviceNodeResourceTracker from unilabos.resources.graphio import initialize_resources +from unilabos.resources.resource_tracker import EXTRA_CLASS 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", registry_name: str = "", rate=50, **kwargs): + def __init__( + self, + resource_model: Optional[dict] = None, + resource_config: Optional[list] = None, + resource_tracker=None, + device_id: str = "resource_mesh_manager", + registry_name: str = "", + rate=50, + **kwargs, + ): """初始化资源网格管理器节点 - + Args: - resource_model (dict): 资源模型字典,包含资源的3D模型信息 - resource_config (dict): 资源配置字典,包含资源的配置信息 - device_id (str): 节点名称 + resource_model: 资源模型字典(可选,为 None 时自动从 registry 构建) + resource_config: 资源配置列表(可选,为 None 时启动后通过 ActionServer 或 load_from_resource_tree 加载) + resource_tracker: 资源追踪器 + device_id: 节点名称 + rate: TF 发布频率 """ + if resource_tracker is None: + resource_tracker = DeviceNodeResourceTracker() + super().__init__( driver_instance=self, device_id=device_id, @@ -42,12 +57,14 @@ class ResourceMeshManager(BaseROS2DeviceNode): action_value_mappings={}, hardware_interface={}, print_publish=False, - resource_tracker=resource_tracker, + resource_tracker=resource_tracker, device_uuid=kwargs.get("uuid", str(uuid.uuid4())), - ) + ) - self.resource_model = resource_model - self.resource_config_dict = {item['uuid']: item for item in resource_config} + self.resource_model = resource_model if resource_model is not None else {} + self.resource_config_dict = ( + {item['uuid']: item for item in resource_config} if resource_config else {} + ) self.move_group_ready = False self.resource_tf_dict = {} self.tf_broadcaster = TransformBroadcaster(self) @@ -63,7 +80,7 @@ class ResourceMeshManager(BaseROS2DeviceNode): self.mesh_path = Path(__file__).parent.parent.parent.parent.absolute() self.msg_type = 'resource_status' self.resource_status_dict = {} - + callback_group = ReentrantCallbackGroup() self._get_planning_scene_service = self.create_client( srv_type=GetPlanningScene, @@ -76,8 +93,7 @@ class ResourceMeshManager(BaseROS2DeviceNode): ), callback_group=callback_group, ) - - # Create a service for applying the planning scene + self._apply_planning_scene_service = self.create_client( srv_type=ApplyPlanningScene, srv_name="/apply_planning_scene", @@ -103,27 +119,36 @@ class ResourceMeshManager(BaseROS2DeviceNode): AttachedCollisionObject, "/attached_collision_object", 0 ) - # 创建一个Action Server用于修改resource_tf_dict self._action_server = ActionServer( self, SendCmd, f"tf_update", self.tf_update, - callback_group=callback_group + callback_group=callback_group, ) - # 创建一个Action Server用于添加新的资源模型与resource_tf_dict self._add_resource_mesh_action_server = ActionServer( self, SendCmd, f"add_resource_mesh", self.add_resource_mesh_callback, - callback_group=callback_group + callback_group=callback_group, ) - self.resource_tf_dict = self.resource_mesh_setup(self.resource_config_dict) - self.create_timer(1/self.rate, self.publish_resource_tf) - self.create_timer(1/self.rate, self.check_resource_pose_changes) + self._reload_resource_mesh_action_server = ActionServer( + self, + SendCmd, + f"reload_resource_mesh", + self._reload_resource_mesh_callback, + callback_group=callback_group, + ) + + if self.resource_config_dict: + self.resource_tf_dict = self.resource_mesh_setup(self.resource_config_dict) + else: + self.get_logger().info("未提供 resource_config,将通过 ActionServer 或 load_from_resource_tree 加载") + self.create_timer(1 / self.rate, self.publish_resource_tf) + self.create_timer(1 / self.rate, self.check_resource_pose_changes) def check_move_group_ready(self): """检查move_group节点是否已初始化完成""" @@ -140,56 +165,156 @@ class ResourceMeshManager(BaseROS2DeviceNode): self.add_resource_collision_meshes(self.resource_tf_dict) - def add_resource_mesh_callback(self, goal_handle : ServerGoalHandle): + def _build_resource_model_for_config(self, resource_config_dict: dict): + """从 registry 中为给定的资源配置自动构建 resource_model(mesh 信息)""" + registry = lab_registry + for _uuid, res_cfg in resource_config_dict.items(): + resource_id = res_cfg.get('id', '') + resource_class = res_cfg.get('class', '') + if not resource_class: + continue + if resource_class not in registry.resource_type_registry: + continue + reg_entry = registry.resource_type_registry[resource_class] + if 'model' not in reg_entry: + continue + model_config = reg_entry['model'] + if model_config.get('type') != 'resource': + continue + if resource_id in self.resource_model: + continue + self.resource_model[resource_id] = { + 'mesh': f"{str(self.mesh_path)}/device_mesh/resources/{model_config['mesh']}", + 'mesh_tf': model_config['mesh_tf'], + } + if model_config.get('children_mesh') is not None: + self.resource_model[f"{resource_id}_"] = { + 'mesh': f"{str(self.mesh_path)}/device_mesh/resources/{model_config['children_mesh']}", + 'mesh_tf': model_config['children_mesh_tf'], + } + + def load_from_resource_tree(self): + """从 resource_tracker 中读取资源树,自动构建 resource_config_dict / resource_model 并刷新 TF""" + new_config_dict: dict = {} + + def _collect_plr_resource(res, parent_uuid: Optional[str] = None): + res_uuid = getattr(res, 'unilabos_uuid', None) + if not res_uuid: + res_uuid = str(uuid.uuid4()) + extra = getattr(res, 'unilabos_extra', {}) or {} + resource_class = extra.get(EXTRA_CLASS, '') + + location = getattr(res, 'location', None) + pos_x = float(location.x) if location else 0.0 + pos_y = float(location.y) if location else 0.0 + pos_z = float(location.z) if location else 0.0 + + rotation = extra.get('rotation', {'x': 0, 'y': 0, 'z': 0}) + + new_config_dict[res_uuid] = { + 'id': res.name, + 'uuid': res_uuid, + 'class': resource_class, + 'parent_uuid': parent_uuid, + 'pose': { + 'position': {'x': pos_x, 'y': pos_y, 'z': pos_z}, + 'rotation': rotation, + }, + } + + for child in getattr(res, 'children', []) or []: + _collect_plr_resource(child, res_uuid) + + for resource in self.resource_tracker.resources: + root_parent_uuid = None + plr_parent = getattr(resource, 'parent', None) + if plr_parent is not None: + root_parent_uuid = getattr(plr_parent, 'unilabos_uuid', None) + _collect_plr_resource(resource, root_parent_uuid) + + if not new_config_dict: + self.get_logger().warning("resource_tracker 中没有找到任何资源") + return + + self.resource_config_dict = {**self.resource_config_dict, **new_config_dict} + self._build_resource_model_for_config(new_config_dict) + + tf_dict = self.resource_mesh_setup(new_config_dict) + self.resource_tf_dict = {**self.resource_tf_dict, **tf_dict} + self.publish_resource_tf() + if self.move_group_ready: + self.add_resource_collision_meshes(tf_dict) + self.get_logger().info(f"从资源树加载了 {len(new_config_dict)} 个资源") + + def _reload_resource_mesh_callback(self, goal_handle: ServerGoalHandle): + """ActionServer 回调:重新从资源树加载所有 mesh""" + try: + self.load_from_resource_tree() + except Exception as e: + self.get_logger().error(f"重新加载资源失败: {e}") + goal_handle.abort() + return SendCmd.Result(success=False) + goal_handle.succeed() + return SendCmd.Result(success=True) + + def add_resource_mesh_callback(self, goal_handle: ServerGoalHandle): tf_update_msg = goal_handle.request - try: - self.add_resource_mesh(tf_update_msg.command) + try: + parsed = json.loads(tf_update_msg.command.replace("'", '"')) + if 'resources' in parsed: + for res_config in parsed['resources']: + self.add_resource_mesh(json.dumps(res_config)) + else: + self.add_resource_mesh(tf_update_msg.command) except Exception as e: self.get_logger().error(f"添加资源失败: {e}") goal_handle.abort() return SendCmd.Result(success=False) goal_handle.succeed() return SendCmd.Result(success=True) - - def add_resource_mesh(self,resource_config_str:str): - """刷新资源配置""" + def add_resource_mesh(self, resource_config_str: str): + """添加单个资源的 mesh 配置""" registry = lab_registry - resource_config = json.loads(resource_config_str.replace("'",'"')) - + resource_config = json.loads(resource_config_str.replace("'", '"')) + if resource_config['id'] in self.resource_config_dict: self.get_logger().info(f'资源 {resource_config["id"]} 已存在') return - if resource_config['class'] in registry.resource_type_registry.keys(): - model_config = registry.resource_type_registry[resource_config['class']]['model'] - if model_config['type'] == 'resource': - self.resource_model[resource_config['id']] = { - 'mesh': f"{str(self.mesh_path)}/device_mesh/resources/{model_config['mesh']}", - 'mesh_tf': model_config['mesh_tf']} - if 'children_mesh' in model_config.keys(): - self.resource_model[f"{resource_config['id']}_"] = { - 'mesh': f"{str(self.mesh_path)}/device_mesh/resources/{model_config['children_mesh']}", - 'mesh_tf': model_config['children_mesh_tf'] + resource_class = resource_config.get('class', '') + if resource_class and resource_class in registry.resource_type_registry: + reg_entry = registry.resource_type_registry[resource_class] + if 'model' in reg_entry: + model_config = reg_entry['model'] + if model_config.get('type') == 'resource': + self.resource_model[resource_config['id']] = { + 'mesh': f"{str(self.mesh_path)}/device_mesh/resources/{model_config['mesh']}", + 'mesh_tf': model_config['mesh_tf'], } + if model_config.get('children_mesh') is not None: + self.resource_model[f"{resource_config['id']}_"] = { + 'mesh': f"{str(self.mesh_path)}/device_mesh/resources/{model_config['children_mesh']}", + 'mesh_tf': model_config['children_mesh_tf'], + } resources = initialize_resources([resource_config]) resource_dict = {item['id']: item for item in resources} - self.resource_config_dict = {**self.resource_config_dict,**resource_dict} + self.resource_config_dict = {**self.resource_config_dict, **resource_dict} tf_dict = self.resource_mesh_setup(resource_dict) - self.resource_tf_dict = {**self.resource_tf_dict,**tf_dict} + self.resource_tf_dict = {**self.resource_tf_dict, **tf_dict} self.publish_resource_tf() self.add_resource_collision_meshes(tf_dict) - - def resource_mesh_setup(self, resource_config_dict:dict): - """move_group初始化完成后的设置""" + def resource_mesh_setup(self, resource_config_dict: dict): + """根据资源配置字典设置 TF 关系""" self.get_logger().info('开始设置资源网格管理器') - #遍历resource_config中的资源配置,判断panent是否在resource_model中, resource_tf_dict = {} 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_uuid = resource_config.get('parent_uuid') + if parent_uuid is not None and parent_uuid != "": + parent_entry = resource_config_dict.get(parent_uuid) or self.resource_config_dict.get(parent_uuid) + parent = parent_entry['id'] if parent_entry else None parent_link = 'world' if parent in self.resource_model: diff --git a/unilabos/test/experiments/prcxi_9320_slim.json b/unilabos/test/experiments/prcxi_9320_slim.json index 7f3492ca..6b01e4ca 100644 --- a/unilabos/test/experiments/prcxi_9320_slim.json +++ b/unilabos/test/experiments/prcxi_9320_slim.json @@ -8,8 +8,8 @@ "parent": "", "pose": { "size": { - "width": 562, - "height": 394, + "width": 550, + "height": 400, "depth": 0 } }, @@ -55,9 +55,9 @@ }, "config": { "type": "PRCXI9300Deck", - "size_x": 542, - "size_y": 374, - "size_z": 0, + "size_x": 550, + "size_y": 400, + "size_z": 17, "rotation": { "x": 0, "y": 0, diff --git a/unilabos/workflow/common.py b/unilabos/workflow/common.py index 3e2fec92..5965a460 100644 --- a/unilabos/workflow/common.py +++ b/unilabos/workflow/common.py @@ -51,6 +51,7 @@ -------------------------------------------------------------------------------- - 遍历 workflow 数组,为每个动作创建步骤节点 - 参数重命名: asp_vol -> asp_vols, dis_vol -> dis_vols, asp_flow_rate -> asp_flow_rates, dis_flow_rate -> dis_flow_rates +- 参数输入转换: liquid_height(按 wells 扩展);mix_stage/mix_times/mix_vol/mix_rate/mix_liquid_height 保持标量 - 参数扩展: 根据 targets 的 wells 数量,将单值扩展为数组 例: asp_vol=100.0, targets 有 3 个 wells -> asp_vols=[100.0, 100.0, 100.0] - 连接处理: 如果 sources/targets 已通过 set_liquid_from_plate 连接,参数值改为 [] @@ -119,11 +120,14 @@ DEVICE_NAME_DEFAULT = "PRCXI" # transfer_liquid, set_liquid_from_plate 等动 # 节点类型 NODE_TYPE_DEFAULT = "ILab" # 所有节点的默认类型 +CLASS_NAMES_MAPPING = { + "plate": "PRCXI_BioER_96_wellplate", + "tip_rack": "PRCXI_300ul_Tips", +} # create_resource 节点默认参数 CREATE_RESOURCE_DEFAULTS = { "device_id": "/PRCXI", "parent_template": "/PRCXI/PRCXI_Deck", - "class_name": "PRCXI_BioER_96_wellplate", } # 默认液体体积 (uL) @@ -138,6 +142,263 @@ PARAM_RENAME_MAPPING = { } +def _map_deck_slot(raw_slot: str, object_type: str = "") -> str: + """协议槽位 -> 实际 deck:4→13,8→14,12+trash→16,其余不变。""" + s = "" if raw_slot is None else str(raw_slot).strip() + if not s: + return "" + if s == "12" and (object_type or "").strip().lower() == "trash": + return "16" + return {"4": "13", "8": "14"}.get(s, s) + + +def _labware_def_index(labware_defs: Optional[List[Dict[str, Any]]]) -> Dict[str, Dict[str, Any]]: + m: Dict[str, Dict[str, Any]] = {} + for d in labware_defs or []: + for k in ("id", "name", "reagent_id", "reagent"): + key = d.get(k) + if key is not None and str(key): + m[str(key)] = d + return m + + +def _labware_hint_text(labware_id: str, item: Dict[str, Any]) -> str: + """合并 id 与协议里的 labware 描述(OpenTrons 全名常在 labware 字段)。""" + parts = [str(labware_id), str(item.get("labware", "") or "")] + return " ".join(parts).lower() + + +def _infer_reagent_kind(labware_id: str, item: Dict[str, Any]) -> str: + ot = (item.get("object") or "").strip().lower() + if ot == "trash": + return "trash" + if ot == "tiprack": + return "tip_rack" + lid = _labware_hint_text(labware_id, item) + if "trash" in lid: + return "trash" + # tiprack / tip + rack(顺序在 tuberack 之前) + if "tiprack" in lid or ("tip" in lid and "rack" in lid): + return "tip_rack" + # 离心管架 / OpenTrons tuberack(勿与 96 tiprack 混淆) + if "tuberack" in lid or "tube_rack" in lid: + return "tube_rack" + if "eppendorf" in lid and "rack" in lid: + return "tube_rack" + if "safelock" in lid and "rack" in lid: + return "tube_rack" + if "rack" in lid and "tip" not in lid: + return "tube_rack" + return "plate" + + +def _infer_tube_rack_num_positions(labware_id: str, item: Dict[str, Any]) -> int: + """从 ``24_tuberack`` 等命名中解析孔位数;解析不到则默认 24(与 PRCXI_EP_Adapter 4×6 一致)。""" + hint = _labware_hint_text(labware_id, item) + m = re.search(r"(\d+)_tuberack", hint) + if m: + return int(m.group(1)) + m = re.search(r"tuberack[_\s]*(\d+)", hint) + if m: + return int(m.group(1)) + m = re.search(r"(\d+)\s*[-_]?\s*pos(?:ition)?s?", hint) + if m: + return int(m.group(1)) + return 96 + + +def _tip_volume_hint(item: Dict[str, Any], labware_id: str) -> Optional[float]: + s = _labware_hint_text(labware_id, item) + for v in (1250, 1000, 300, 200, 10): + if f"{v}ul" in s or f"{v}μl" in s or f"{v}u" in s: + return float(v) + if f" {v} " in f" {s} ": + return float(v) + return None + + +def _volume_template_covers_requirement(template: Dict[str, Any], req: Optional[float], kind: str) -> bool: + """有明确需求体积时,模板标称 Volume 必须 >= 需求;无 Volume 的模板不参与(trash 除外)。""" + if kind == "trash": + return True + if req is None or req <= 0: + return True + mv = float(template.get("Volume") or 0) + if mv <= 0: + return False + return mv >= req + + +def _direct_labware_class_name(item: Dict[str, Any]) -> str: + """仅用于 tip_rack 且 ``preserve_tip_rack_incoming_class=True``:``class_name``/``class`` 原样;否则 ``labware`` → ``lab_*``。""" + explicit = item.get("class_name") or item.get("class") + if explicit is not None and str(explicit).strip() != "": + return str(explicit).strip() + lw = str(item.get("labware", "") or "").strip() + if lw: + return f"lab_{lw.lower().replace('.', 'point').replace(' ', '_')}" + return "" + + +def _match_score_prcxi_template( + template: Dict[str, Any], + num_children: int, + child_max_volume: Optional[float], +) -> float: + """孔数差主导;有需求体积且模板已满足 >= 时,余量比例 (模板-需求)/需求 越小越好(优先选刚好够的)。""" + hole_count = int(template.get("hole_count") or 0) + hole_diff = abs(num_children - hole_count) + material_volume = float(template.get("Volume") or 0) + req = child_max_volume + if req is not None and req > 0 and material_volume > 0: + vol_diff = (material_volume - req) / max(req, 1e-9) + elif material_volume > 0 and req is not None: + vol_diff = abs(float(req) - material_volume) / material_volume + else: + vol_diff = 0.0 + return hole_diff * 1000 + vol_diff + + +def _apply_prcxi_labware_auto_match( + labware_info: Dict[str, Dict[str, Any]], + labware_defs: Optional[List[Dict[str, Any]]] = None, + *, + preserve_tip_rack_incoming_class: bool = True, +) -> None: + """上传构建图前:按孔数+容量将 reagent 条目匹配到 ``prcxi_labware`` 注册类名,写入 ``prcxi_class_name``。 + 若给出需求体积,仅选用模板标称 Volume >= 该值的物料,并在满足条件的模板中选余量最小者。 + + ``preserve_tip_rack_incoming_class=True``(默认)时:**仅 tip_rack** 不做模板匹配,类名由 ``class_name``/``class`` 或 + ``labware``(``lab_*``)直接给出;**plate / tube_rack / trash 等**仍按注册模板匹配。 + ``False`` 时 **全部**(含 tip_rack)走模板匹配。""" + if not labware_info: + return + + default_prcxi_tip_class = CLASS_NAMES_MAPPING.get("tip_rack", "PRCXI_300ul_Tips") + + try: + from unilabos.devices.liquid_handling.prcxi.prcxi_labware import get_prcxi_labware_template_specs + except Exception: + return + + templates = get_prcxi_labware_template_specs() + if not templates: + return + + def_map = _labware_def_index(labware_defs) + + for labware_id, item in labware_info.items(): + if item.get("prcxi_class_name"): + continue + + kind = _infer_reagent_kind(labware_id, item) + + if preserve_tip_rack_incoming_class and kind == "tip_rack": + inc_s = _direct_labware_class_name(item) + if inc_s == default_prcxi_tip_class: + inc_s = "" + if inc_s: + item["prcxi_class_name"] = inc_s + continue + + explicit = item.get("class_name") or item.get("class") + if explicit and str(explicit).startswith("PRCXI_"): + item["prcxi_class_name"] = str(explicit) + continue + + extra = def_map.get(str(labware_id), {}) + + wells = item.get("well") or [] + well_n = len(wells) if isinstance(wells, list) else 0 + num_from_def = int(extra.get("num_wells") or extra.get("well_count") or item.get("num_wells") or 0) + + if kind == "trash": + num_children = 0 + elif kind == "tip_rack": + num_children = num_from_def if num_from_def > 0 else 96 + elif kind == "tube_rack": + if num_from_def > 0: + num_children = num_from_def + elif well_n > 0: + num_children = well_n + else: + num_children = _infer_tube_rack_num_positions(labware_id, item) + else: + num_children = num_from_def if num_from_def > 0 else 96 + + child_max_volume = item.get("max_volume") + if child_max_volume is None: + child_max_volume = extra.get("max_volume") + try: + child_max_volume_f = float(child_max_volume) if child_max_volume is not None else None + except (TypeError, ValueError): + child_max_volume_f = None + + if kind == "tip_rack" and child_max_volume_f is None: + child_max_volume_f = _tip_volume_hint(item, labware_id) or 300.0 + + candidates = [t for t in templates if t["kind"] == kind] + if not candidates: + continue + + best = None + best_score = float("inf") + for t in candidates: + if kind != "trash" and int(t.get("hole_count") or 0) <= 0: + continue + if not _volume_template_covers_requirement(t, child_max_volume_f, kind): + continue + sc = _match_score_prcxi_template(t, num_children, child_max_volume_f) + if sc < best_score: + best_score = sc + best = t + + if best: + item["prcxi_class_name"] = best["class_name"] + + +def _reconcile_slot_carrier_prcxi_class( + labware_info: Dict[str, Dict[str, Any]], + *, + preserve_tip_rack_incoming_class: bool = False, +) -> None: + """同一 deck 槽位上多条 reagent 时,按载体类型优先级统一 ``prcxi_class_name``,避免先遍历到 96 板后槽位被错误绑定。 + + ``preserve_tip_rack_incoming_class=True`` 时:tip_rack 条目不参与同槽类名合并(不被覆盖、也不把 tip 类名扩散到同槽其它条目)。""" + by_slot: Dict[str, List[Tuple[str, Dict[str, Any]]]] = {} + for lid, item in labware_info.items(): + ot = item.get("object", "") or "" + slot = _map_deck_slot(str(item.get("slot", "")), ot) + if not slot: + continue + by_slot.setdefault(str(slot), []).append((lid, item)) + + priority = {"trash": 0, "tube_rack": 1, "tip_rack": 2, "plate": 3} + + for _slot, pairs in by_slot.items(): + if len(pairs) < 2: + continue + + def _rank(p: Tuple[str, Dict[str, Any]]) -> int: + return priority.get(_infer_reagent_kind(p[0], p[1]), 9) + + pairs_sorted = sorted(pairs, key=_rank) + best_cls = None + for lid, it in pairs_sorted: + if preserve_tip_rack_incoming_class and _infer_reagent_kind(lid, it) == "tip_rack": + continue + c = it.get("prcxi_class_name") + if c: + best_cls = c + break + if not best_cls: + continue + for lid, it in pairs: + if preserve_tip_rack_incoming_class and _infer_reagent_kind(lid, it) == "tip_rack": + continue + it["prcxi_class_name"] = best_cls + + # ---------------- Graph ---------------- @@ -363,23 +624,77 @@ def build_protocol_graph( workstation_name: str, action_resource_mapping: Optional[Dict[str, str]] = None, labware_defs: Optional[List[Dict[str, Any]]] = None, + preserve_tip_rack_incoming_class: bool = True, ) -> WorkflowGraph: """统一的协议图构建函数,根据设备类型自动选择构建逻辑 Args: - labware_info: reagent 信息字典,格式为 {name: {slot, well}, ...},用于 set_liquid 和 well 查找 + labware_info: labware 信息字典,格式为 {name: {slot, well, labware, ...}, ...} protocol_steps: 协议步骤列表 workstation_name: 工作站名称 action_resource_mapping: action 到 resource_name 的映射字典,可选 - labware_defs: labware 定义列表,格式为 [{"name": "...", "slot": "1", "type": "lab_xxx"}, ...] + labware_defs: 可选,``[{"id": "...", "num_wells": 96, "max_volume": 2200}, ...]`` 等,辅助 PRCXI 模板匹配 + preserve_tip_rack_incoming_class: 默认 True 时**仅 tip_rack** 不跑模板匹配(类名由传入的 class/labware 决定); + **其它载体**仍按 PRCXI 模板匹配。False 时 **全部**(含 tip_rack)都走模板匹配。 """ G = WorkflowGraph() resource_last_writer = {} # reagent_name -> "node_id:port" slot_to_create_resource = {} # slot -> create_resource node_id + _apply_prcxi_labware_auto_match( + labware_info, + labware_defs, + preserve_tip_rack_incoming_class=preserve_tip_rack_incoming_class, + ) + _reconcile_slot_carrier_prcxi_class( + labware_info, + preserve_tip_rack_incoming_class=preserve_tip_rack_incoming_class, + ) + protocol_steps = refactor_data(protocol_steps, action_resource_mapping) - # ==================== 第一步:按 slot 创建 create_resource 节点 ==================== + # ==================== 第一步:按 slot 去重创建 create_resource 节点 ==================== + # 按槽聚合:同一 slot 多条 reagent 时不能只取遍历顺序第一条,否则 tip 的 prcxi_class_name / object 会被其它条目盖住 + by_slot: Dict[str, List[Tuple[str, Dict[str, Any]]]] = {} + for labware_id, item in labware_info.items(): + object_type = item.get("object", "") or "" + slot = _map_deck_slot(str(item.get("slot", "")), object_type) + if not slot: + continue + by_slot.setdefault(slot, []).append((labware_id, item)) + + slots_info: Dict[str, Dict[str, Any]] = {} + for slot, pairs in by_slot.items(): + def _ot_tip(it: Dict[str, Any]) -> bool: + return str(it.get("object", "") or "").strip().lower() == "tiprack" + + tip_pairs = [(lid, it) for lid, it in pairs if _ot_tip(it)] + chosen_lid = "" + chosen_item: Dict[str, Any] = {} + prcxi_val: Optional[str] = None + + scan = tip_pairs if tip_pairs else pairs + for lid, it in scan: + c = it.get("prcxi_class_name") + if c: + chosen_lid, chosen_item, prcxi_val = lid, it, str(c) + break + if not chosen_lid and scan: + chosen_lid, chosen_item = scan[0] + pv = chosen_item.get("prcxi_class_name") + prcxi_val = str(pv) if pv else None + + labware = str(chosen_item.get("labware", "") or "") + res_id = f"{labware}_slot_{slot}" if labware.strip() else f"{chosen_lid}_slot_{slot}" + res_id = res_id.replace(" ", "_") + slots_info[slot] = { + "labware": labware, + "res_id": res_id, + "labware_id": chosen_lid, + "object": chosen_item.get("object", "") or "", + "prcxi_class_name": prcxi_val, + } + # 创建 Group 节点,包含所有 create_resource 节点 group_node_id = str(uuid.uuid4()) G.add_node( @@ -395,41 +710,54 @@ def build_protocol_graph( param=None, ) - # 直接使用 JSON 中的 labware 定义,每个 slot 一条记录,type 即 class_name - res_index = 0 - for lw in (labware_defs or []): - slot = str(lw.get("slot", "")) - if not slot or slot in slot_to_create_resource: - continue # 跳过空 slot 或已处理的 slot + trash_create_node_id = None # 记录 trash 的 create_resource 节点 - lw_name = lw.get("name", f"slot {slot}") - lw_type = lw.get("type", CREATE_RESOURCE_DEFAULTS["class_name"]) - res_id = f"plate_slot_{slot}" - - res_index += 1 + # 为每个唯一的 slot 创建 create_resource 节点 + for slot, info in slots_info.items(): node_id = str(uuid.uuid4()) + res_id = info["res_id"] + object_type = info.get("object", "") or "" + ot_lo = str(object_type).strip().lower() + matched = info.get("prcxi_class_name") + if ot_lo == "trash": + res_type_name = "PRCXI_trash" + elif matched: + res_type_name = matched + elif ot_lo == "tiprack": + if preserve_tip_rack_incoming_class: + lid = str(info.get("labware_id") or "").strip() or "tip_rack" + res_type_name = f"lab_{lid.lower().replace('.', 'point').replace(' ', '_')}" + else: + res_type_name = CLASS_NAMES_MAPPING.get("tip_rack", "PRCXI_300ul_Tips") + else: + res_type_name = f"lab_{info['labware'].lower().replace('.', 'point')}" G.add_node( node_id, template_name="create_resource", resource_name="host_node", - name=lw_name, - description=f"Create {lw_name}", + name=f"{res_type_name}_slot{slot}", + description=f"Create plate on slot {slot}", lab_node_type="Labware", footer="create_resource-host_node", device_name=DEVICE_NAME_HOST, type=NODE_TYPE_DEFAULT, - parent_uuid=group_node_id, - minimized=True, + parent_uuid=group_node_id, # 指向 Group 节点 + minimized=True, # 折叠显示 param={ "res_id": res_id, "device_id": CREATE_RESOURCE_DEFAULTS["device_id"], - "class_name": lw_type, - "parent": CREATE_RESOURCE_DEFAULTS["parent_template"], + "class_name": res_type_name, + "parent": CREATE_RESOURCE_DEFAULTS["parent_template"].format(slot=slot), "bind_locations": {"x": 0.0, "y": 0.0, "z": 0.0}, "slot_on_deck": slot, }, ) slot_to_create_resource[slot] = node_id + if ot_lo == "tiprack": + resource_last_writer[info["labware_id"]] = f"{node_id}:labware" + if ot_lo == "trash": + trash_create_node_id = node_id + # create_resource 之间不需要 ready 连接 # ==================== 第二步:为每个 reagent 创建 set_liquid_from_plate 节点 ==================== # 创建 Group 节点,包含所有 set_liquid_from_plate 节点 @@ -456,7 +784,8 @@ def build_protocol_graph( if item.get("type") == "hardware": continue - slot = str(item.get("slot", "")) + object_type = item.get("object", "") or "" + slot = _map_deck_slot(str(item.get("slot", "")), object_type) wells = item.get("well", []) if not wells or not slot: continue @@ -464,6 +793,7 @@ def build_protocol_graph( # res_id 不能有空格 res_id = str(labware_id).replace(" ", "_") well_count = len(wells) + liquid_volume = DEFAULT_LIQUID_VOLUME if object_type == "source" else 0 node_id = str(uuid.uuid4()) set_liquid_index += 1 @@ -484,7 +814,7 @@ def build_protocol_graph( "plate": [], # 通过连接传递 "well_names": wells, # 孔位名数组,如 ["A1", "A3", "A5"] "liquid_names": [res_id] * well_count, - "volumes": [DEFAULT_LIQUID_VOLUME] * well_count, + "volumes": [liquid_volume] * well_count, }, ) @@ -498,8 +828,12 @@ def build_protocol_graph( # set_liquid_from_plate 的输出 output_wells 用于连接 transfer_liquid resource_last_writer[labware_id] = f"{node_id}:output_wells" - # transfer_liquid 之间通过 ready 串联,从 None 开始 - last_control_node_id = None + # 收集所有 create_resource 节点 ID,用于让第一个 transfer_liquid 等待所有资源创建完成 + all_create_resource_node_ids = list(slot_to_create_resource.values()) + + # transfer_liquid 之间通过 ready 串联;第一个 transfer_liquid 需要等待所有 create_resource 完成 + last_control_node_id = trash_create_node_id + is_first_action_node = True # 端口名称映射:JSON 字段名 -> 实际 handle key INPUT_PORT_MAPPING = { @@ -511,6 +845,7 @@ def build_protocol_graph( "reagent": "reagent", "solvent": "solvent", "compound": "compound", + "tip_racks": "tip_rack_identifier", } OUTPUT_PORT_MAPPING = { @@ -525,8 +860,17 @@ def build_protocol_graph( "compound": "compound", } - # 需要根据 wells 数量扩展的参数列表(复数形式) - EXPAND_BY_WELLS_PARAMS = ["asp_vols", "dis_vols", "asp_flow_rates", "dis_flow_rates"] + # 需要根据 wells 数量扩展的参数列表: + # - 复数参数(asp_vols 等)支持单值自动扩展 + # - liquid_height 按 wells 扩展为数组 + # - mix_* 参数保持标量,避免被转换为 list + EXPAND_BY_WELLS_PARAMS = [ + "asp_vols", + "dis_vols", + "asp_flow_rates", + "dis_flow_rates", + "liquid_height", + ] # 处理协议步骤 for step in protocol_steps: @@ -540,6 +884,57 @@ def build_protocol_graph( if old_name in params: params[new_name] = params.pop(old_name) + # touch_tip 输入归一化: + # - 支持 bool / 0/1 / "true"/"false" / 单元素 list + # - 最终统一为 bool 标量,避免被下游误当作序列处理 + if "touch_tip" in params: + touch_tip_value = params.get("touch_tip") + if isinstance(touch_tip_value, list): + if len(touch_tip_value) == 1: + touch_tip_value = touch_tip_value[0] + elif len(touch_tip_value) == 0: + touch_tip_value = False + else: + warnings.append(f"touch_tip 期望标量,但收到长度为 {len(touch_tip_value)} 的列表,使用首个值") + touch_tip_value = touch_tip_value[0] + if isinstance(touch_tip_value, str): + norm = touch_tip_value.strip().lower() + if norm in {"true", "1", "yes", "y", "on"}: + touch_tip_value = True + elif norm in {"false", "0", "no", "n", "off", ""}: + touch_tip_value = False + else: + warnings.append(f"touch_tip 字符串值无法识别: {touch_tip_value},按 True 处理") + touch_tip_value = True + elif isinstance(touch_tip_value, (int, float)): + touch_tip_value = bool(touch_tip_value) + elif touch_tip_value is None: + touch_tip_value = False + else: + touch_tip_value = bool(touch_tip_value) + params["touch_tip"] = touch_tip_value + + # delays 输入归一化: + # - 支持标量(int/float/字符串数字)与 list + # - 最终统一为数字列表,供下游按 delays[0]/delays[1] 使用 + if "delays" in params: + delays_value = params.get("delays") + if delays_value is None or delays_value == "": + params["delays"] = [] + else: + raw_list = delays_value if isinstance(delays_value, list) else [delays_value] + normalized_delays = [] + for delay_item in raw_list: + if isinstance(delay_item, str): + delay_item = delay_item.strip() + if delay_item == "": + continue + try: + normalized_delays.append(float(delay_item)) + except (TypeError, ValueError): + warnings.append(f"delays 包含无法转换为数字的值: {delay_item},已忽略") + params["delays"] = normalized_delays + # 处理输入连接 for param_key, target_port in INPUT_PORT_MAPPING.items(): resource_name = params.get(param_key) @@ -606,7 +1001,12 @@ def build_protocol_graph( G.add_node(node_id, **step_copy) # 控制流 - if last_control_node_id is not None: + if is_first_action_node: + # 第一个 transfer_liquid 需要等待所有 create_resource 完成 + for cr_node_id in all_create_resource_node_ids: + G.add_edge(cr_node_id, node_id, source_port="ready", target_port="ready") + is_first_action_node = False + elif 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 diff --git a/unilabos/workflow/convert_from_json.py b/unilabos/workflow/convert_from_json.py index acd0d71a..cd88552b 100644 --- a/unilabos/workflow/convert_from_json.py +++ b/unilabos/workflow/convert_from_json.py @@ -210,6 +210,7 @@ def convert_from_json( data: Union[str, PathLike, Dict[str, Any]], workstation_name: str = DEFAULT_WORKSTATION, validate: bool = True, + preserve_tip_rack_incoming_class: bool = True, ) -> WorkflowGraph: """ 从 JSON 数据或文件转换为 WorkflowGraph @@ -221,6 +222,8 @@ def convert_from_json( data: JSON 文件路径、字典数据、或 JSON 字符串 workstation_name: 工作站名称,默认 "PRCXi" validate: 是否校验句柄配置,默认 True + preserve_tip_rack_incoming_class: True(默认)时仅 tip_rack 不跑模板、按传入类名/labware;其它载体仍自动匹配。 + False 时全部走模板。JSON 根 ``preserve_tip_rack_incoming_class`` 可覆盖此参数。 Returns: WorkflowGraph: 构建好的工作流图 @@ -263,6 +266,10 @@ def convert_from_json( # reagent 已经是字典格式,用于 set_liquid 和 well 数量查找 labware_info = reagent + preserve = preserve_tip_rack_incoming_class + if "preserve_tip_rack_incoming_class" in json_data: + preserve = bool(json_data["preserve_tip_rack_incoming_class"]) + # 构建工作流图 graph = build_protocol_graph( labware_info=labware_info, @@ -270,6 +277,7 @@ def convert_from_json( workstation_name=workstation_name, action_resource_mapping=ACTION_RESOURCE_MAPPING, labware_defs=labware_defs, + preserve_tip_rack_incoming_class=preserve, ) # 校验句柄配置 @@ -287,6 +295,7 @@ def convert_from_json( def convert_json_to_node_link( data: Union[str, PathLike, Dict[str, Any]], workstation_name: str = DEFAULT_WORKSTATION, + preserve_tip_rack_incoming_class: bool = True, ) -> Dict[str, Any]: """ 将 JSON 数据转换为 node-link 格式的字典 @@ -298,13 +307,18 @@ def convert_json_to_node_link( Returns: Dict: node-link 格式的工作流数据 """ - graph = convert_from_json(data, workstation_name) + graph = convert_from_json( + data, + workstation_name, + preserve_tip_rack_incoming_class=preserve_tip_rack_incoming_class, + ) return graph.to_node_link_dict() def convert_json_to_workflow_list( data: Union[str, PathLike, Dict[str, Any]], workstation_name: str = DEFAULT_WORKSTATION, + preserve_tip_rack_incoming_class: bool = True, ) -> List[Dict[str, Any]]: """ 将 JSON 数据转换为工作流列表格式 @@ -316,5 +330,9 @@ def convert_json_to_workflow_list( Returns: List: 工作流节点列表 """ - graph = convert_from_json(data, workstation_name) + graph = convert_from_json( + data, + workstation_name, + preserve_tip_rack_incoming_class=preserve_tip_rack_incoming_class, + ) return graph.to_dict() diff --git a/unilabos/workflow/legacy/convert_from_json_legacy.py b/unilabos/workflow/legacy/convert_from_json_legacy.py index 7a6d2b40..3d830d94 100644 --- a/unilabos/workflow/legacy/convert_from_json_legacy.py +++ b/unilabos/workflow/legacy/convert_from_json_legacy.py @@ -234,6 +234,7 @@ def convert_from_json( data: Union[str, PathLike, Dict[str, Any]], workstation_name: str = "PRCXi", validate: bool = True, + preserve_tip_rack_incoming_class: bool = True, ) -> WorkflowGraph: """ 从 JSON 数据或文件转换为 WorkflowGraph @@ -246,6 +247,7 @@ def convert_from_json( data: JSON 文件路径、字典数据、或 JSON 字符串 workstation_name: 工作站名称,默认 "PRCXi" validate: 是否校验句柄配置,默认 True + preserve_tip_rack_incoming_class: True 时仅 tip 不跑模板;False 时全部匹配;JSON 根字段同名可覆盖 Returns: WorkflowGraph: 构建好的工作流图 @@ -295,12 +297,17 @@ def convert_from_json( "3. {'steps': [...], 'labware': [...]}" ) + preserve = preserve_tip_rack_incoming_class + if "preserve_tip_rack_incoming_class" in json_data: + preserve = bool(json_data["preserve_tip_rack_incoming_class"]) + # 构建工作流图 graph = build_protocol_graph( labware_info=labware_info, protocol_steps=protocol_steps, workstation_name=workstation_name, action_resource_mapping=ACTION_RESOURCE_MAPPING, + preserve_tip_rack_incoming_class=preserve, ) # 校验句柄配置