mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-05-25 09:19:43 +00:00
Compare commits
7 Commits
7df67ea9f3
...
prcix9320
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5be601177e | ||
|
|
ad05e8c73e | ||
|
|
940abc3664 | ||
|
|
6288e37464 | ||
|
|
3aed75bc8b | ||
|
|
acb2dc9359 | ||
|
|
f22c3f4c42 |
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
package:
|
package:
|
||||||
name: unilabos
|
name: unilabos
|
||||||
version: 0.10.19
|
version: 0.11.1
|
||||||
|
|
||||||
source:
|
source:
|
||||||
path: ../../unilabos
|
path: ../../unilabos
|
||||||
@@ -54,7 +54,7 @@ requirements:
|
|||||||
- pymodbus
|
- pymodbus
|
||||||
- matplotlib
|
- matplotlib
|
||||||
- pylibftdi
|
- pylibftdi
|
||||||
- uni-lab::unilabos-env ==0.10.19
|
- uni-lab::unilabos-env ==0.11.1
|
||||||
|
|
||||||
about:
|
about:
|
||||||
repository: https://github.com/deepmodeling/Uni-Lab-OS
|
repository: https://github.com/deepmodeling/Uni-Lab-OS
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
package:
|
package:
|
||||||
name: unilabos-env
|
name: unilabos-env
|
||||||
version: 0.10.19
|
version: 0.11.1
|
||||||
|
|
||||||
build:
|
build:
|
||||||
noarch: generic
|
noarch: generic
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
package:
|
package:
|
||||||
name: unilabos-full
|
name: unilabos-full
|
||||||
version: 0.10.19
|
version: 0.11.1
|
||||||
|
|
||||||
build:
|
build:
|
||||||
noarch: generic
|
noarch: generic
|
||||||
@@ -11,7 +11,7 @@ build:
|
|||||||
requirements:
|
requirements:
|
||||||
run:
|
run:
|
||||||
# Base unilabos package (includes unilabos-env)
|
# Base unilabos package (includes unilabos-env)
|
||||||
- uni-lab::unilabos ==0.10.19
|
- uni-lab::unilabos ==0.11.1
|
||||||
# Documentation tools
|
# Documentation tools
|
||||||
- sphinx
|
- sphinx
|
||||||
- sphinx_rtd_theme
|
- sphinx_rtd_theme
|
||||||
|
|||||||
@@ -71,6 +71,22 @@ from unilabos.registry.decorators import action
|
|||||||
- `_` 开头的方法 → 不扫描
|
- `_` 开头的方法 → 不扫描
|
||||||
- `@not_action` 标记的方法 → 排除
|
- `@not_action` 标记的方法 → 排除
|
||||||
|
|
||||||
|
### 参数文档 → JSON Schema 元数据
|
||||||
|
|
||||||
|
在 `__init__` 和 action 方法 docstring 的 `Args:` 小节里,使用以下格式生成入参 schema 的显示信息:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
param[显示名称]: 参数说明,会写入 JSON Schema 的 description。
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
- `param[显示名称]` 的显示名称会写入 goal property 的 `title`。
|
||||||
|
- `:` 后面的说明会写入 goal property 的 `description`。
|
||||||
|
- 如果只写 `param: 参数说明`,`title` 会兜底为字段名,`description` 使用参数说明。
|
||||||
|
- 如果没有写参数文档,生成器也会兜底补齐 `title=<字段名>` 和 `description=""`,但新设备应优先写清楚显示名和说明。
|
||||||
|
|
||||||
### @topic_config — 状态属性配置
|
### @topic_config — 状态属性配置
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@@ -105,13 +121,27 @@ import logging
|
|||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||||
from unilabos.registry.decorators import device, action, topic_config, not_action
|
from unilabos.registry.decorators import action, device, not_action, topic_config
|
||||||
|
|
||||||
@device(id="my_device", category=["my_category"], description="设备描述")
|
@device(
|
||||||
|
id="my_device",
|
||||||
|
category=["my_category"],
|
||||||
|
description="设备描述",
|
||||||
|
display_name="设备显示名",
|
||||||
|
)
|
||||||
class MyDevice:
|
class MyDevice:
|
||||||
|
"""设备类说明。"""
|
||||||
|
|
||||||
_ros_node: BaseROS2DeviceNode
|
_ros_node: BaseROS2DeviceNode
|
||||||
|
|
||||||
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
|
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
|
||||||
|
"""
|
||||||
|
初始化设备。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_id[设备ID]: 设备实例 ID,默认使用 my_device。
|
||||||
|
config[设备配置]: 设备启动配置。
|
||||||
|
"""
|
||||||
self.device_id = device_id or "my_device"
|
self.device_id = device_id or "my_device"
|
||||||
self.config = config or {}
|
self.config = config or {}
|
||||||
self.logger = logging.getLogger(f"MyDevice.{self.device_id}")
|
self.logger = logging.getLogger(f"MyDevice.{self.device_id}")
|
||||||
@@ -133,7 +163,13 @@ class MyDevice:
|
|||||||
|
|
||||||
@action(description="执行操作")
|
@action(description="执行操作")
|
||||||
def my_action(self, param: float = 0.0, name: str = "") -> Dict[str, Any]:
|
def my_action(self, param: float = 0.0, name: str = "") -> Dict[str, Any]:
|
||||||
"""带 @action 装饰器 → 注册为 'my_action' 动作"""
|
"""
|
||||||
|
带 @action 装饰器 → 注册为 'my_action' 动作。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
param[操作数值]: 操作使用的数值参数。
|
||||||
|
name[操作名称]: 操作名称或备注。
|
||||||
|
"""
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|
||||||
def get_info(self) -> Dict[str, Any]:
|
def get_info(self) -> Dict[str, Any]:
|
||||||
|
|||||||
@@ -28,13 +28,14 @@ python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{
|
|||||||
### 2. --addr → BASE URL
|
### 2. --addr → BASE URL
|
||||||
|
|
||||||
| `--addr` 值 | BASE |
|
| `--addr` 值 | BASE |
|
||||||
|-------------|------|
|
| ------------ | ----------------------------------- |
|
||||||
| `test` | `https://uni-lab.test.bohrium.com` |
|
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||||
| `uat` | `https://uni-lab.uat.bohrium.com` |
|
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||||
| `local` | `http://127.0.0.1:48197` |
|
| `local` | `http://127.0.0.1:48197` |
|
||||||
| 不传(默认) | `https://uni-lab.bohrium.com` |
|
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||||
|
|
||||||
确认后设置:
|
确认后设置:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
BASE="<根据 addr 确定的 URL>"
|
BASE="<根据 addr 确定的 URL>"
|
||||||
AUTH="Authorization: Lab <gen_auth.py 输出的 token>"
|
AUTH="Authorization: Lab <gen_auth.py 输出的 token>"
|
||||||
@@ -90,6 +91,7 @@ curl -s -X POST "$BASE/api/v1/lab/reagent" \
|
|||||||
```
|
```
|
||||||
|
|
||||||
返回成功时包含试剂 UUID:
|
返回成功时包含试剂 UUID:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"code": 0, "data": {"uuid": "xxx", ...}}
|
{"code": 0, "data": {"uuid": "xxx", ...}}
|
||||||
```
|
```
|
||||||
@@ -99,7 +101,7 @@ curl -s -X POST "$BASE/api/v1/lab/reagent" \
|
|||||||
## 试剂字段说明
|
## 试剂字段说明
|
||||||
|
|
||||||
| 字段 | 类型 | 必填 | 说明 | 示例 |
|
| 字段 | 类型 | 必填 | 说明 | 示例 |
|
||||||
|------|------|------|------|------|
|
| ------------------- | ------ | ---- | ----------------------------- | ------------------------ |
|
||||||
| `lab_uuid` | string | 是 | 实验室 UUID(从 API #1 获取) | `"8511c672-..."` |
|
| `lab_uuid` | string | 是 | 实验室 UUID(从 API #1 获取) | `"8511c672-..."` |
|
||||||
| `cas` | string | 是 | CAS 注册号 | `"7732-18-3"` |
|
| `cas` | string | 是 | CAS 注册号 | `"7732-18-3"` |
|
||||||
| `name` | string | 是 | 试剂中文/英文名称 | `"水"` |
|
| `name` | string | 是 | 试剂中文/英文名称 | `"水"` |
|
||||||
@@ -114,7 +116,7 @@ curl -s -X POST "$BASE/api/v1/lab/reagent" \
|
|||||||
### unit 单位值
|
### unit 单位值
|
||||||
|
|
||||||
| 值 | 单位 |
|
| 值 | 单位 |
|
||||||
|------|------|
|
| ------ | ---- |
|
||||||
| `"mL"` | 毫升 |
|
| `"mL"` | 毫升 |
|
||||||
| `"L"` | 升 |
|
| `"L"` | 升 |
|
||||||
| `"g"` | 克 |
|
| `"g"` | 克 |
|
||||||
@@ -133,8 +135,22 @@ curl -s -X POST "$BASE/api/v1/lab/reagent" \
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
{"cas": "7732-18-3", "name": "水", "molecular_formula": "H2O", "smiles": "O", "stock_in_quantity": 10, "unit": "mL"},
|
{
|
||||||
{"cas": "64-17-5", "name": "乙醇", "molecular_formula": "C2H6O", "smiles": "CCO", "stock_in_quantity": 5, "unit": "L"}
|
"cas": "7732-18-3",
|
||||||
|
"name": "水",
|
||||||
|
"molecular_formula": "H2O",
|
||||||
|
"smiles": "O",
|
||||||
|
"stock_in_quantity": 10,
|
||||||
|
"unit": "mL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cas": "64-17-5",
|
||||||
|
"name": "乙醇",
|
||||||
|
"molecular_formula": "C2H6O",
|
||||||
|
"smiles": "CCO",
|
||||||
|
"stock_in_quantity": 5,
|
||||||
|
"unit": "L"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -160,9 +176,20 @@ cas,name,molecular_formula,smiles,stock_in_quantity,unit,supplier,production_dat
|
|||||||
7732-18-3,水,H2O,O,10,mL,农夫山泉,2025-11-18T00:00:00Z,2026-11-18T00:00:00Z
|
7732-18-3,水,H2O,O,10,mL,农夫山泉,2025-11-18T00:00:00Z,2026-11-18T00:00:00Z
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 日期格式规则(重要)
|
||||||
|
|
||||||
|
所有日期字段(`production_date`、`expiry_date`)**必须**使用 ISO 8601 完整格式:`YYYY-MM-DDTHH:MM:SSZ`。
|
||||||
|
|
||||||
|
- 用户输入 `2025-03-01` → 转换为 `"2025-03-01T00:00:00Z"`
|
||||||
|
- 用户输入 `2025/9/1` → 转换为 `"2025-09-01T00:00:00Z"`
|
||||||
|
- 用户未提供日期 → 使用当天日期 + `T00:00:00Z`,有效期默认 +1 年
|
||||||
|
|
||||||
|
**禁止**发送不带时间部分的日期字符串(如 `"2025-03-01"`),API 会拒绝。
|
||||||
|
|
||||||
### 执行与汇报
|
### 执行与汇报
|
||||||
|
|
||||||
每次 API 调用后:
|
每次 API 调用后:
|
||||||
|
|
||||||
1. 检查返回 `code`(0 = 成功)
|
1. 检查返回 `code`(0 = 成功)
|
||||||
2. 记录成功/失败数量
|
2. 记录成功/失败数量
|
||||||
3. 全部完成后汇总:「共录入 N 条试剂,成功 X 条,失败 Y 条」
|
3. 全部完成后汇总:「共录入 N 条试剂,成功 X 条,失败 Y 条」
|
||||||
@@ -173,9 +200,10 @@ cas,name,molecular_formula,smiles,stock_in_quantity,unit,supplier,production_dat
|
|||||||
## 常见试剂速查表
|
## 常见试剂速查表
|
||||||
|
|
||||||
| 名称 | CAS | 分子式 | SMILES |
|
| 名称 | CAS | 分子式 | SMILES |
|
||||||
|------|-----|--------|--------|
|
| --------------------- | --------- | ---------- | ------------------------------------ |
|
||||||
| 水 | 7732-18-3 | H2O | O |
|
| 水 | 7732-18-3 | H2O | O |
|
||||||
| 乙醇 | 64-17-5 | C2H6O | CCO |
|
| 乙醇 | 64-17-5 | C2H6O | CCO |
|
||||||
|
| 乙酸 | 64-19-7 | C2H4O2 | CC(O)=O |
|
||||||
| 甲醇 | 67-56-1 | CH4O | CO |
|
| 甲醇 | 67-56-1 | CH4O | CO |
|
||||||
| 丙酮 | 67-64-1 | C3H6O | CC(C)=O |
|
| 丙酮 | 67-64-1 | C3H6O | CC(C)=O |
|
||||||
| 二甲基亚砜(DMSO) | 67-68-5 | C2H6OS | CS(C)=O |
|
| 二甲基亚砜(DMSO) | 67-68-5 | C2H6OS | CS(C)=O |
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
---
|
---
|
||||||
name: batch-submit-experiment
|
name: batch-submit-experiment
|
||||||
description: Batch submit experiments (notebooks) to Uni-Lab platform — list workflows, generate node_params from registry schemas, submit multiple rounds, check notebook status. Use when the user wants to submit experiments, create notebooks, batch run workflows, check experiment status, or mentions 提交实验/批量实验/notebook/实验轮次/实验状态.
|
description: Batch submit experiments (notebooks) to the Uni-Lab cloud platform (leap-lab) — list workflows, generate node_params from registry schemas, submit multiple rounds, check notebook status. Use when the user wants to submit experiments, create notebooks, batch run workflows, check experiment status, or mentions 提交实验/批量实验/notebook/实验轮次/实验状态.
|
||||||
---
|
---
|
||||||
|
|
||||||
# 批量提交实验指南
|
# Uni-Lab 批量提交实验指南
|
||||||
|
|
||||||
通过云端 API 批量提交实验(notebook),支持多轮实验参数配置。根据 workflow 模板详情和本地设备注册表自动生成 `node_params` 模板。
|
通过 Uni-Lab 云端 API 批量提交实验(notebook),支持多轮实验参数配置。根据 workflow 模板详情和本地设备注册表自动生成 `node_params` 模板。
|
||||||
|
|
||||||
|
> **重要**:本指南中的 `Authorization: Lab <token>` 是 **Uni-Lab 平台专用的认证方式**,`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。
|
||||||
|
|
||||||
## 前置条件(缺一不可)
|
## 前置条件(缺一不可)
|
||||||
|
|
||||||
@@ -18,25 +20,28 @@ description: Batch submit experiments (notebooks) to Uni-Lab platform — list w
|
|||||||
生成 AUTH token(任选一种方式):
|
生成 AUTH token(任选一种方式):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 方式一:Python 一行生成
|
# 方式一:Python 一行生成(注意:scheme 是 "Lab" 不是 "Basic")
|
||||||
python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
|
python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
|
||||||
|
|
||||||
# 方式二:手动计算
|
# 方式二:手动计算
|
||||||
# base64(ak:sk) → Authorization: Lab <token>
|
# base64(ak:sk) → Authorization: Lab <token>
|
||||||
|
# ⚠️ 这里的 "Lab" 是 Uni-Lab 平台的 auth scheme,绝对不能用 "Basic" 替代
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. --addr → BASE URL
|
### 2. --addr → BASE URL
|
||||||
|
|
||||||
| `--addr` 值 | BASE |
|
| `--addr` 值 | BASE |
|
||||||
|-------------|------|
|
| ------------ | ----------------------------------- |
|
||||||
| `test` | `https://uni-lab.test.bohrium.com` |
|
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||||
| `uat` | `https://uni-lab.uat.bohrium.com` |
|
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||||
| `local` | `http://127.0.0.1:48197` |
|
| `local` | `http://127.0.0.1:48197` |
|
||||||
| 不传(默认) | `https://uni-lab.bohrium.com` |
|
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||||
|
|
||||||
确认后设置:
|
确认后设置:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
BASE="<根据 addr 确定的 URL>"
|
BASE="<根据 addr 确定的 URL>"
|
||||||
|
# ⚠️ Auth scheme 必须是 "Lab"(Uni-Lab 专用),不是 "Basic"
|
||||||
AUTH="Authorization: Lab <上面命令输出的 token>"
|
AUTH="Authorization: Lab <上面命令输出的 token>"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -44,18 +49,19 @@ AUTH="Authorization: Lab <上面命令输出的 token>"
|
|||||||
|
|
||||||
**批量提交实验时需要本地注册表来解析 workflow 节点的参数 schema。**
|
**批量提交实验时需要本地注册表来解析 workflow 节点的参数 schema。**
|
||||||
|
|
||||||
按优先级搜索:
|
**必须先用 Glob 工具搜索文件**,不要直接猜测路径:
|
||||||
|
|
||||||
```
|
```
|
||||||
<workspace 根目录>/unilabos_data/req_device_registry_upload.json
|
Glob: **/req_device_registry_upload.json
|
||||||
<workspace 根目录>/req_device_registry_upload.json
|
|
||||||
```
|
```
|
||||||
|
|
||||||
也可直接 Glob 搜索:`**/req_device_registry_upload.json`
|
常见位置(仅供参考,以 Glob 实际结果为准):
|
||||||
|
- `<workspace>/unilabos_data/req_device_registry_upload.json`
|
||||||
|
- `<workspace>/req_device_registry_upload.json`
|
||||||
|
|
||||||
找到后**检查文件修改时间**并告知用户。超过 1 天提醒用户是否需要重新启动 `unilab`。
|
找到后**检查文件修改时间**并告知用户。超过 1 天提醒用户是否需要重新启动 `unilab`。
|
||||||
|
|
||||||
**如果文件不存在** → 告知用户先运行 `unilab` 启动命令,等注册表生成后再执行。可跳过此步,但将无法自动生成参数模板,需要用户手动填写 `param`。
|
**如果 Glob 搜索无结果** → 告知用户先运行 `unilab` 启动命令,等注册表生成后再执行。可跳过此步,但将无法自动生成参数模板,需要用户手动填写 `param`。
|
||||||
|
|
||||||
### 4. workflow_uuid(目标工作流)
|
### 4. workflow_uuid(目标工作流)
|
||||||
|
|
||||||
@@ -104,9 +110,33 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
|||||||
curl -s -X GET "$BASE/api/v1/lab/project/list?lab_uuid=$lab_uuid" -H "$AUTH"
|
curl -s -X GET "$BASE/api/v1/lab/project/list?lab_uuid=$lab_uuid" -H "$AUTH"
|
||||||
```
|
```
|
||||||
|
|
||||||
返回项目列表,展示给用户选择。列出每个项目的 `uuid` 和 `name`。
|
返回:
|
||||||
|
|
||||||
用户**必须**选择一个项目,记住 `project_uuid`,后续创建 notebook 时需要提供。
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"uuid": "1b3f249a-...",
|
||||||
|
"name": "bt",
|
||||||
|
"description": null,
|
||||||
|
"status": "active",
|
||||||
|
"created_at": "2026-04-09T14:31:28+08:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uuid": "b6366243-...",
|
||||||
|
"name": "default",
|
||||||
|
"description": "默认项目",
|
||||||
|
"status": "active",
|
||||||
|
"created_at": "2026-03-26T11:13:36+08:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
展示 `data.items[]` 中每个项目的 `name` 和 `uuid`,让用户选择。用户**必须**选择一个项目,记住 `project_uuid`(即选中项目的 `uuid`),后续创建 notebook 时需要提供。
|
||||||
|
|
||||||
### 3. 列出可用 workflow
|
### 3. 列出可用 workflow
|
||||||
|
|
||||||
@@ -123,6 +153,7 @@ curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$A
|
|||||||
```
|
```
|
||||||
|
|
||||||
返回 workflow 的完整结构,包含所有 action 节点信息。需要从响应中提取:
|
返回 workflow 的完整结构,包含所有 action 节点信息。需要从响应中提取:
|
||||||
|
|
||||||
- 每个 action 节点的 `node_uuid`
|
- 每个 action 节点的 `node_uuid`
|
||||||
- 每个节点对应的设备 ID(`resource_template_name`)
|
- 每个节点对应的设备 ID(`resource_template_name`)
|
||||||
- 每个节点的动作名(`node_template_name`)
|
- 每个节点的动作名(`node_template_name`)
|
||||||
@@ -195,14 +226,14 @@ curl -s -X GET "$BASE/api/v1/lab/notebook/status?uuid=$notebook_uuid" -H "$AUTH"
|
|||||||
### 每轮的字段
|
### 每轮的字段
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|------|------|------|
|
| -------------- | ------------- | ----------------------------------------- |
|
||||||
| `sample_uuids` | array\<uuid\> | 该轮实验的样品 UUID 数组,无样品时传 `[]` |
|
| `sample_uuids` | array\<uuid\> | 该轮实验的样品 UUID 数组,无样品时传 `[]` |
|
||||||
| `datas` | array | 该轮中每个 workflow 节点的参数配置 |
|
| `datas` | array | 该轮中每个 workflow 节点的参数配置 |
|
||||||
|
|
||||||
### datas 中每个节点
|
### datas 中每个节点
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|------|------|------|
|
| --------------- | ------ | -------------------------------------------- |
|
||||||
| `node_uuid` | string | workflow 模板中的节点 UUID(从 API #4 获取) |
|
| `node_uuid` | string | workflow 模板中的节点 UUID(从 API #4 获取) |
|
||||||
| `param` | object | 动作参数(根据本地注册表 schema 填写) |
|
| `param` | object | 动作参数(根据本地注册表 schema 填写) |
|
||||||
| `sample_params` | array | 样品相关参数(液体名、体积等) |
|
| `sample_params` | array | 样品相关参数(液体名、体积等) |
|
||||||
@@ -210,7 +241,7 @@ curl -s -X GET "$BASE/api/v1/lab/notebook/status?uuid=$notebook_uuid" -H "$AUTH"
|
|||||||
### sample_params 中每条
|
### sample_params 中每条
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|------|------|------|
|
| ---------------- | ------ | ---------------------------------------------------- |
|
||||||
| `container_uuid` | string | 容器 UUID |
|
| `container_uuid` | string | 容器 UUID |
|
||||||
| `sample_value` | object | 样品值,如 `{"liquid_names": "水", "volumes": 1000}` |
|
| `sample_value` | object | 样品值,如 `{"liquid_names": "水", "volumes": 1000}` |
|
||||||
|
|
||||||
@@ -233,6 +264,7 @@ python scripts/gen_notebook_params.py \
|
|||||||
> 脚本位于本文档同级目录下的 `scripts/gen_notebook_params.py`。
|
> 脚本位于本文档同级目录下的 `scripts/gen_notebook_params.py`。
|
||||||
|
|
||||||
脚本会:
|
脚本会:
|
||||||
|
|
||||||
1. 调用 workflow detail API 获取所有 action 节点
|
1. 调用 workflow detail API 获取所有 action 节点
|
||||||
2. 读取本地注册表,为每个节点查找对应的 action schema
|
2. 读取本地注册表,为每个节点查找对应的 action schema
|
||||||
3. 生成 `notebook_template.json`,包含:
|
3. 生成 `notebook_template.json`,包含:
|
||||||
@@ -270,7 +302,10 @@ python scripts/gen_notebook_params.py \
|
|||||||
"properties": {
|
"properties": {
|
||||||
"goal": {
|
"goal": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"asp_vols": {"type": "array", "items": {"type": "number"}},
|
"asp_vols": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "number" }
|
||||||
|
},
|
||||||
"sources": { "type": "array" }
|
"sources": { "type": "array" }
|
||||||
},
|
},
|
||||||
"required": ["asp_vols", "sources"]
|
"required": ["asp_vols", "sources"]
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
选项:
|
选项:
|
||||||
--auth <token> Lab token(base64(ak:sk) 的结果,不含 "Lab " 前缀)
|
--auth <token> Lab token(base64(ak:sk) 的结果,不含 "Lab " 前缀)
|
||||||
--base <url> API 基础 URL(如 https://uni-lab.test.bohrium.com)
|
--base <url> API 基础 URL(如 https://leap-lab.test.bohrium.com)
|
||||||
--workflow-uuid <uuid> 目标 workflow 的 UUID
|
--workflow-uuid <uuid> 目标 workflow 的 UUID
|
||||||
--registry <path> 本地注册表文件路径(默认自动搜索)
|
--registry <path> 本地注册表文件路径(默认自动搜索)
|
||||||
--rounds <n> 实验轮次数(默认 1)
|
--rounds <n> 实验轮次数(默认 1)
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
示例:
|
示例:
|
||||||
python gen_notebook_params.py \\
|
python gen_notebook_params.py \\
|
||||||
--auth YTFmZDlkNGUtxxxx \\
|
--auth YTFmZDlkNGUtxxxx \\
|
||||||
--base https://uni-lab.test.bohrium.com \\
|
--base https://leap-lab.test.bohrium.com \\
|
||||||
--workflow-uuid abc-123-def \\
|
--workflow-uuid abc-123-def \\
|
||||||
--rounds 2
|
--rounds 2
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -41,11 +41,11 @@ python ./scripts/gen_auth.py --config <config.py>
|
|||||||
决定 API 请求发往哪个服务器。从启动命令的 `--addr` 参数获取:
|
决定 API 请求发往哪个服务器。从启动命令的 `--addr` 参数获取:
|
||||||
|
|
||||||
| `--addr` 值 | BASE URL |
|
| `--addr` 值 | BASE URL |
|
||||||
|-------------|----------|
|
| -------------- | ----------------------------------- |
|
||||||
| `test` | `https://uni-lab.test.bohrium.com` |
|
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||||
| `uat` | `https://uni-lab.uat.bohrium.com` |
|
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||||
| `local` | `http://127.0.0.1:48197` |
|
| `local` | `http://127.0.0.1:48197` |
|
||||||
| 不传(默认) | `https://uni-lab.bohrium.com` |
|
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||||
| 其他自定义 URL | 直接使用该 URL |
|
| 其他自定义 URL | 直接使用该 URL |
|
||||||
|
|
||||||
#### 必备项 ③:req_device_registry_upload.json(设备注册表)
|
#### 必备项 ③:req_device_registry_upload.json(设备注册表)
|
||||||
@@ -55,7 +55,7 @@ python ./scripts/gen_auth.py --config <config.py>
|
|||||||
**推断 working_dir**(即 `unilabos_data` 所在目录):
|
**推断 working_dir**(即 `unilabos_data` 所在目录):
|
||||||
|
|
||||||
| 条件 | working_dir 取值 |
|
| 条件 | working_dir 取值 |
|
||||||
|------|------------------|
|
| -------------------- | -------------------------------------------------------- |
|
||||||
| 传了 `--working_dir` | `<working_dir>/unilabos_data/`(若子目录已存在则直接用) |
|
| 传了 `--working_dir` | `<working_dir>/unilabos_data/`(若子目录已存在则直接用) |
|
||||||
| 仅传了 `--config` | `<config 文件所在目录>/unilabos_data/` |
|
| 仅传了 `--config` | `<config 文件所在目录>/unilabos_data/` |
|
||||||
| 都没传 | `<当前工作目录>/unilabos_data/` |
|
| 都没传 | `<当前工作目录>/unilabos_data/` |
|
||||||
@@ -84,24 +84,6 @@ python ./scripts/gen_auth.py --config <config.py>
|
|||||||
python ./scripts/extract_device_actions.py --registry <找到的文件路径>
|
python ./scripts/extract_device_actions.py --registry <找到的文件路径>
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 完整示例
|
|
||||||
|
|
||||||
用户提供:
|
|
||||||
|
|
||||||
```
|
|
||||||
--ak a1fd9d4e-xxxx-xxxx-xxxx-d9a69c09f0fd
|
|
||||||
--sk 136ff5c6-xxxx-xxxx-xxxx-a03e301f827b
|
|
||||||
--addr test
|
|
||||||
--port 8003
|
|
||||||
--disable_browser
|
|
||||||
```
|
|
||||||
|
|
||||||
从中提取:
|
|
||||||
- ✅ ak/sk → 运行 `gen_auth.py` 得到 `AUTH="Authorization: Lab YTFmZDlk..."`
|
|
||||||
- ✅ addr=test → `BASE=https://uni-lab.test.bohrium.com`
|
|
||||||
- ✅ 搜索 `unilabos_data/req_device_registry_upload.json` → 找到并确认时间
|
|
||||||
- ✅ 用户指明目标设备 → 如 `liquid_handler.prcxi`
|
|
||||||
|
|
||||||
**四项全部就绪后才进入 Step 1。**
|
**四项全部就绪后才进入 Step 1。**
|
||||||
|
|
||||||
### Step 1 — 列出可用设备
|
### Step 1 — 列出可用设备
|
||||||
@@ -129,6 +111,7 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
|
|||||||
脚本会显示设备的 Python 源码路径和类名,方便阅读源码了解参数含义。
|
脚本会显示设备的 Python 源码路径和类名,方便阅读源码了解参数含义。
|
||||||
|
|
||||||
每个 action 生成一个 JSON 文件,包含:
|
每个 action 生成一个 JSON 文件,包含:
|
||||||
|
|
||||||
- `type` — 作为 API 调用的 `action_type`
|
- `type` — 作为 API 调用的 `action_type`
|
||||||
- `schema` — 完整 JSON Schema(含 `properties.goal.properties` 参数定义)
|
- `schema` — 完整 JSON Schema(含 `properties.goal.properties` 参数定义)
|
||||||
- `goal` — goal 字段映射(含占位符 `$placeholder`)
|
- `goal` — goal 字段映射(含占位符 `$placeholder`)
|
||||||
@@ -136,13 +119,14 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
|
|||||||
|
|
||||||
### Step 3 — 写 action-index.md
|
### Step 3 — 写 action-index.md
|
||||||
|
|
||||||
按模板为每个 action 写条目:
|
按模板为每个 action 写条目(**必须包含 `action_type`**):
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
### `<action_name>`
|
### `<action_name>`
|
||||||
|
|
||||||
<用途描述(一句话)>
|
<用途描述(一句话)>
|
||||||
|
|
||||||
|
- **action_type**: `<从 actions/<name>.json 的 type 字段获取>`
|
||||||
- **Schema**: [`actions/<filename>.json`](actions/<filename>.json)
|
- **Schema**: [`actions/<filename>.json`](actions/<filename>.json)
|
||||||
- **核心参数**: `param1`, `param2`(从 schema.required 获取)
|
- **核心参数**: `param1`, `param2`(从 schema.required 获取)
|
||||||
- **可选参数**: `param3`, `param4`
|
- **可选参数**: `param3`, `param4`
|
||||||
@@ -150,6 +134,8 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
|
|||||||
```
|
```
|
||||||
|
|
||||||
描述规则:
|
描述规则:
|
||||||
|
|
||||||
|
- **每个 action 必须标注 `action_type`**(从 JSON 的 `type` 字段读取),这是 API #9 调用时的必填参数,传错会导致任务永远卡住
|
||||||
- 从 `schema.properties` 读参数列表(schema 已提升为 goal 内容)
|
- 从 `schema.properties` 读参数列表(schema 已提升为 goal 内容)
|
||||||
- 从 `schema.required` 区分核心/可选参数
|
- 从 `schema.required` 区分核心/可选参数
|
||||||
- 按功能分类(移液、枪头、外设等)
|
- 按功能分类(移液、枪头、外设等)
|
||||||
@@ -165,6 +151,7 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
|
|||||||
### Step 4 — 写 SKILL.md
|
### Step 4 — 写 SKILL.md
|
||||||
|
|
||||||
直接复用 `unilab-device-api` 的 API 模板,修改:
|
直接复用 `unilab-device-api` 的 API 模板,修改:
|
||||||
|
|
||||||
- 设备名称
|
- 设备名称
|
||||||
- Action 数量
|
- Action 数量
|
||||||
- 目录列表
|
- 目录列表
|
||||||
@@ -172,42 +159,77 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
|
|||||||
- **AUTH 头** — 使用 Step 0 中 `gen_auth.py` 生成的 `Authorization: Lab <token>`(不要硬编码 `Api` 类型的 key)
|
- **AUTH 头** — 使用 Step 0 中 `gen_auth.py` 生成的 `Authorization: Lab <token>`(不要硬编码 `Api` 类型的 key)
|
||||||
- **Python 源码路径** — 在 SKILL.md 开头注明设备对应的源码文件,方便参考参数含义
|
- **Python 源码路径** — 在 SKILL.md 开头注明设备对应的源码文件,方便参考参数含义
|
||||||
- **Slot 字段表** — 列出本设备哪些 action 的哪些字段需要填入 Slot(物料/设备/节点/类名)
|
- **Slot 字段表** — 列出本设备哪些 action 的哪些字段需要填入 Slot(物料/设备/节点/类名)
|
||||||
|
- **action_type 速查表** — 在 API #9 说明后面紧跟一个表格,列出每个 action 对应的 `action_type` 值(从 JSON `type` 字段提取),方便 agent 快速查找而无需打开 JSON 文件
|
||||||
|
|
||||||
API 模板结构:
|
API 模板结构:
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
## 设备信息
|
## 设备信息
|
||||||
|
|
||||||
- device_id, Python 源码路径, 设备类名
|
- device_id, Python 源码路径, 设备类名
|
||||||
|
|
||||||
## 前置条件(缺一不可)
|
## 前置条件(缺一不可)
|
||||||
|
|
||||||
- ak/sk → AUTH, --addr → BASE URL
|
- ak/sk → AUTH, --addr → BASE URL
|
||||||
|
|
||||||
## 请求约定
|
## 请求约定
|
||||||
|
|
||||||
- Windows 平台必须用 curl.exe(非 PowerShell 的 curl 别名)
|
- Windows 平台必须用 curl.exe(非 PowerShell 的 curl 别名)
|
||||||
|
|
||||||
## Session State
|
## Session State
|
||||||
|
|
||||||
- lab_uuid(通过 GET /edge/lab/info 直接获取,不要问用户), device_name
|
- lab_uuid(通过 GET /edge/lab/info 直接获取,不要问用户), device_name
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
# - #1 GET /edge/lab/info → 直接拿到 lab_uuid
|
# - #1 GET /edge/lab/info → 直接拿到 lab_uuid
|
||||||
|
|
||||||
# - #2 创建工作流 POST /lab/workflow/owner → 拼 URL 告知用户
|
# - #2 创建工作流 POST /lab/workflow/owner → 拼 URL 告知用户
|
||||||
|
|
||||||
# - #3 创建节点 POST /edge/workflow/node
|
# - #3 创建节点 POST /edge/workflow/node
|
||||||
|
|
||||||
# body: {workflow_uuid, resource_template_name: "<device_id>", node_template_name: "<action_name>"}
|
# body: {workflow_uuid, resource_template_name: "<device_id>", node_template_name: "<action_name>"}
|
||||||
|
|
||||||
# - #4 删除节点 DELETE /lab/workflow/nodes
|
# - #4 删除节点 DELETE /lab/workflow/nodes
|
||||||
|
|
||||||
# - #5 更新节点参数 PATCH /lab/workflow/node
|
# - #5 更新节点参数 PATCH /lab/workflow/node
|
||||||
|
|
||||||
# - #6 查询节点 handles POST /lab/workflow/node-handles
|
# - #6 查询节点 handles POST /lab/workflow/node-handles
|
||||||
|
|
||||||
# body: {node_uuids: ["uuid1","uuid2"]} → 返回各节点的 handle_uuid
|
# body: {node_uuids: ["uuid1","uuid2"]} → 返回各节点的 handle_uuid
|
||||||
|
|
||||||
# - #7 批量创建边 POST /lab/workflow/edges
|
# - #7 批量创建边 POST /lab/workflow/edges
|
||||||
|
|
||||||
# body: {edges: [{source_node_uuid, target_node_uuid, source_handle_uuid, target_handle_uuid}]}
|
# body: {edges: [{source_node_uuid, target_node_uuid, source_handle_uuid, target_handle_uuid}]}
|
||||||
|
|
||||||
# - #8 启动工作流 POST /lab/workflow/{uuid}/run
|
# - #8 启动工作流 POST /lab/workflow/{uuid}/run
|
||||||
# - #9 运行设备单动作 POST /lab/mcp/run/action
|
|
||||||
|
# - #9 运行设备单动作 POST /lab/mcp/run/action(⚠️ action_type 必须从 action-index.md 或 actions/<name>.json 的 type 字段获取,传错会导致任务永远卡住)
|
||||||
|
|
||||||
# - #10 查询任务状态 GET /lab/mcp/task/{task_uuid}
|
# - #10 查询任务状态 GET /lab/mcp/task/{task_uuid}
|
||||||
|
|
||||||
# - #11 运行工作流单节点 POST /lab/mcp/run/workflow/action
|
# - #11 运行工作流单节点 POST /lab/mcp/run/workflow/action
|
||||||
|
|
||||||
# - #12 获取资源树 GET /lab/material/download/{lab_uuid}
|
# - #12 获取资源树 GET /lab/material/download/{lab_uuid}
|
||||||
|
|
||||||
# - #13 获取工作流模板详情 GET /lab/workflow/template/detail/{workflow_uuid}
|
# - #13 获取工作流模板详情 GET /lab/workflow/template/detail/{workflow_uuid}
|
||||||
|
|
||||||
# 返回 workflow 完整结构:data.nodes[] 含每个节点的 uuid、name、param、device_name、handles
|
# 返回 workflow 完整结构:data.nodes[] 含每个节点的 uuid、name、param、device_name、handles
|
||||||
|
|
||||||
|
# - #14 按名称查询物料模板 GET /lab/material/template/by-name?lab_uuid=&name=
|
||||||
|
|
||||||
|
# 返回 res_template_uuid,用于 #15 创建物料时的必填字段
|
||||||
|
|
||||||
|
# - #15 创建物料节点 POST /edge/material/node
|
||||||
|
|
||||||
|
# body: {res_template_uuid(从#14获取), name(自定义), display_name, parent_uuid?(从#12获取), ...}
|
||||||
|
|
||||||
|
# - #16 更新物料节点 PUT /edge/material/node
|
||||||
|
|
||||||
|
# body: {uuid(从#12获取), display_name?, description?, init_param_data?, data?, ...}
|
||||||
|
|
||||||
## Placeholder Slot 填写规则
|
## Placeholder Slot 填写规则
|
||||||
|
|
||||||
- unilabos_resources → ResourceSlot → {"id":"/path/name","name":"name","uuid":"xxx"}
|
- unilabos_resources → ResourceSlot → {"id":"/path/name","name":"name","uuid":"xxx"}
|
||||||
- unilabos_devices → DeviceSlot → "/parent/device" 路径字符串
|
- unilabos_devices → DeviceSlot → "/parent/device" 路径字符串
|
||||||
- unilabos_nodes → NodeSlot → "/parent/node" 路径字符串
|
- unilabos_nodes → NodeSlot → "/parent/node" 路径字符串
|
||||||
@@ -217,13 +239,15 @@ API 模板结构:
|
|||||||
- 列出本设备所有 Slot 字段、类型及含义
|
- 列出本设备所有 Slot 字段、类型及含义
|
||||||
|
|
||||||
## 渐进加载策略
|
## 渐进加载策略
|
||||||
|
|
||||||
## 完整工作流 Checklist
|
## 完整工作流 Checklist
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 5 — 验证
|
### Step 5 — 验证
|
||||||
|
|
||||||
检查文件完整性:
|
检查文件完整性:
|
||||||
- [ ] `SKILL.md` 包含 API endpoint(#1 获取 lab_uuid、#2-#7 工作流/节点/边、#8-#11 运行/查询、#12 资源树、#13 工作流模板详情)
|
|
||||||
|
- [ ] `SKILL.md` 包含 API endpoint(#1 获取 lab_uuid、#2-#7 工作流/节点/边、#8-#11 运行/查询、#12 资源树、#13 工作流模板详情、#14-#16 物料管理)
|
||||||
- [ ] `SKILL.md` 包含 Placeholder Slot 填写规则(ResourceSlot / DeviceSlot / NodeSlot / ClassSlot / FormulationSlot + create_resource 特例)和本设备的 Slot 字段表
|
- [ ] `SKILL.md` 包含 Placeholder Slot 填写规则(ResourceSlot / DeviceSlot / NodeSlot / ClassSlot / FormulationSlot + create_resource 特例)和本设备的 Slot 字段表
|
||||||
- [ ] `action-index.md` 列出所有 action 并有描述
|
- [ ] `action-index.md` 列出所有 action 并有描述
|
||||||
- [ ] `actions/` 目录中每个 action 有对应 JSON 文件
|
- [ ] `actions/` 目录中每个 action 有对应 JSON 文件
|
||||||
@@ -273,7 +297,7 @@ API 模板结构:
|
|||||||
`placeholder_keys` / `_unilabos_placeholder_info` 中有 5 种值,对应不同的填写方式:
|
`placeholder_keys` / `_unilabos_placeholder_info` 中有 5 种值,对应不同的填写方式:
|
||||||
|
|
||||||
| placeholder 值 | Slot 类型 | 填写格式 | 选取范围 |
|
| placeholder 值 | Slot 类型 | 填写格式 | 选取范围 |
|
||||||
|---------------|-----------|---------|---------|
|
| ---------------------- | --------------- | ----------------------------------------------------- | ----------------------------------------- |
|
||||||
| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅**物料**节点(不含设备) |
|
| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅**物料**节点(不含设备) |
|
||||||
| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅**设备**节点(type=device),路径字符串 |
|
| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅**设备**节点(type=device),路径字符串 |
|
||||||
| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | **设备 + 物料**,即所有节点,路径字符串 |
|
| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | **设备 + 物料**,即所有节点,路径字符串 |
|
||||||
@@ -284,80 +308,36 @@ API 模板结构:
|
|||||||
|
|
||||||
最常见的类型。从资源树中选取**物料**节点(孔板、枪头盒、试剂槽等):
|
最常见的类型。从资源树中选取**物料**节点(孔板、枪头盒、试剂槽等):
|
||||||
|
|
||||||
```json
|
- 单个:`{"id": "/workstation/container1", "name": "container1", "uuid": "ff149a9a-..."}`
|
||||||
{"id": "/workstation/container1", "name": "container1", "uuid": "ff149a9a-2cb8-419d-8db5-d3ba056fb3c2"}
|
- 数组:`[{"id": "/path/a", "name": "a", "uuid": "xxx"}, ...]`
|
||||||
```
|
- `id` 从 parent 计算的路径格式,根据 action 语义选择正确的物料
|
||||||
|
|
||||||
- 单个(schema type=object):`{"id": "/path/name", "name": "name", "uuid": "xxx"}`
|
> **特例**:`create_resource` 的 `res_id`,目标物料可能尚不存在,直接填期望路径,不需要 uuid。
|
||||||
- 数组(schema type=array):`[{"id": "/path/a", "name": "a", "uuid": "xxx"}, ...]`
|
|
||||||
- `id` 本身是从 parent 计算的路径格式
|
|
||||||
- 根据 action 语义选择正确的物料(如 `sources` = 液体来源,`targets` = 目标位置)
|
|
||||||
|
|
||||||
> **特例**:`create_resource` 的 `res_id` 字段,目标物料可能**尚不存在**,此时直接填写期望的路径(如 `"/workstation/container1"`),不需要 uuid。
|
### DeviceSlot / NodeSlot / ClassSlot
|
||||||
|
|
||||||
### DeviceSlot(`unilabos_devices`)
|
- **DeviceSlot**(`unilabos_devices`):路径字符串如 `"/host_node"`,仅 type=device 的节点
|
||||||
|
- **NodeSlot**(`unilabos_nodes`):路径字符串如 `"/PRCXI/PRCXI_Deck"`,设备 + 物料均可选
|
||||||
填写**设备路径字符串**。从资源树中筛选 type=device 的节点,从 parent 计算路径:
|
- **ClassSlot**(`unilabos_class`):类名字符串如 `"container"`,从 `req_resource_registry_upload.json` 查找
|
||||||
|
|
||||||
```
|
|
||||||
"/host_node"
|
|
||||||
"/bioyond_cell/reaction_station"
|
|
||||||
```
|
|
||||||
|
|
||||||
- 只填路径字符串,不需要 `{id, uuid}` 对象
|
|
||||||
- 根据 action 语义选择正确的设备(如 `target_device_id` = 目标设备)
|
|
||||||
|
|
||||||
### NodeSlot(`unilabos_nodes`)
|
|
||||||
|
|
||||||
范围 = 设备 + 物料。即资源树中**所有节点**都可以选,填写**路径字符串**:
|
|
||||||
|
|
||||||
```
|
|
||||||
"/PRCXI/PRCXI_Deck"
|
|
||||||
```
|
|
||||||
|
|
||||||
- 使用场景:当参数既可能指向物料也可能指向设备时(如 `PumpTransferProtocol` 的 `from_vessel`/`to_vessel`,`create_resource` 的 `parent`)
|
|
||||||
|
|
||||||
### ClassSlot(`unilabos_class`)
|
|
||||||
|
|
||||||
填写注册表中已上报的**资源类 name**。从本地 `req_resource_registry_upload.json` 中查找:
|
|
||||||
|
|
||||||
```
|
|
||||||
"container"
|
|
||||||
```
|
|
||||||
|
|
||||||
### FormulationSlot(`unilabos_formulation`)
|
### FormulationSlot(`unilabos_formulation`)
|
||||||
|
|
||||||
描述**液体配方**:向哪些物料容器中加入哪些液体及体积。填写为**对象数组**:
|
描述**液体配方**:向哪些容器中加入哪些液体及体积。
|
||||||
|
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"sample_uuid": "",
|
"sample_uuid": "",
|
||||||
"well_name": "YB_PrepBottle_15mL_Carrier_bottle_A1",
|
"well_name": "bottle_A1",
|
||||||
"liquids": [
|
"liquids": [{ "name": "LiPF6", "volume": 0.6 }]
|
||||||
{ "name": "LiPF6", "volume": 0.6 },
|
|
||||||
{ "name": "DMC", "volume": 1.2 }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 字段说明
|
- `well_name` — 目标物料的 **name**(从资源树取,不是 `id` 路径)
|
||||||
|
- `liquids[]` — 液体列表,每条含 `name`(试剂名)和 `volume`(体积,单位由上下文决定;pylabrobot 内部统一 uL)
|
||||||
| 字段 | 类型 | 说明 |
|
- `sample_uuid` — 样品 UUID,无样品传 `""`
|
||||||
|------|------|------|
|
- 与 ResourceSlot 的区别:ResourceSlot 指向物料本身,FormulationSlot 引用物料名并附带配方信息
|
||||||
| `sample_uuid` | string | 样品 UUID,无样品时传空字符串 `""` |
|
|
||||||
| `well_name` | string | 目标物料容器的 **name**(从资源树中取物料节点的 `name` 字段,如瓶子、孔位名称) |
|
|
||||||
| `liquids` | array | 要加入的液体列表 |
|
|
||||||
| `liquids[].name` | string | 液体名称(如试剂名、溶剂名) |
|
|
||||||
| `liquids[].volume` | number | 液体体积(单位由设备决定,通常为 mL) |
|
|
||||||
|
|
||||||
#### 填写规则
|
|
||||||
|
|
||||||
- `well_name` 必须是资源树中已存在的物料节点 `name`(不是 `id` 路径),通过 API #12 获取资源树后筛选
|
|
||||||
- 每个数组元素代表一个目标容器的配方
|
|
||||||
- 一个容器可以加入多种液体(`liquids` 数组多条记录)
|
|
||||||
- 与 ResourceSlot 的区别:ResourceSlot 填 `{id, name, uuid}` 指向物料本身;FormulationSlot 用 `well_name` 引用物料,并附带液体配方信息
|
|
||||||
|
|
||||||
### 通过 API #12 获取资源树
|
### 通过 API #12 获取资源树
|
||||||
|
|
||||||
@@ -365,7 +345,147 @@ API 模板结构:
|
|||||||
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
|
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
|
||||||
```
|
```
|
||||||
|
|
||||||
注意 `lab_uuid` 在路径中(不是查询参数)。资源树返回所有节点,每个节点包含 `id`(路径格式)、`name`、`uuid`、`type`、`parent` 等字段。填写 Slot 时需根据 placeholder 类型筛选正确的节点。
|
注意 `lab_uuid` 在路径中(不是查询参数)。返回结构:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"nodes": [
|
||||||
|
{"name": "host_node", "uuid": "c3ec1e68-...", "type": "device", "parent": ""},
|
||||||
|
{"name": "PRCXI", "uuid": "e249c9a6-...", "type": "device", "parent": ""},
|
||||||
|
{"name": "PRCXI_Deck", "uuid": "fb6a8b71-...", "type": "deck", "parent": "PRCXI"}
|
||||||
|
],
|
||||||
|
"edges": [...]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `data.nodes[]` — 所有节点(设备 + 物料),每个节点含 `name`、`uuid`、`type`、`parent`
|
||||||
|
- `type` 区分设备(`device`)和物料(`deck`、`container`、`resource` 等)
|
||||||
|
- `parent` 为父节点名称(空字符串表示顶级)
|
||||||
|
- 填写 Slot 时根据 placeholder 类型筛选:ResourceSlot 取非 device 节点,DeviceSlot 取 device 节点
|
||||||
|
- 创建/更新物料时:`parent_uuid` 取父节点的 `uuid`,更新目标的 `uuid` 取节点自身的 `uuid`
|
||||||
|
|
||||||
|
## 物料管理 API
|
||||||
|
|
||||||
|
设备 Skill 除了设备动作外,还需支持物料节点的创建和参数设定,用于在资源树中动态管理物料。
|
||||||
|
|
||||||
|
典型流程:先通过 **#14 按名称查询模板** 获取 `res_template_uuid` → 再通过 **#15 创建物料** → 之后可通过 **#16 更新物料** 修改属性。更新时需要的 `uuid` 和 `parent_uuid` 均从 **#12 资源树下载** 获取。
|
||||||
|
|
||||||
|
### API #14 — 按名称查询物料模板
|
||||||
|
|
||||||
|
创建物料前,需要先获取物料模板的 UUID。通过模板名称查询:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=<template_name>" -H "$AUTH"
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 必填 | 说明 |
|
||||||
|
| ---------- | ------ | -------------------------------- |
|
||||||
|
| `lab_uuid` | **是** | 实验室 UUID(从 API #1 获取) |
|
||||||
|
| `name` | **是** | 物料模板名称(如 `"container"`) |
|
||||||
|
|
||||||
|
返回 `code: 0` 时,**`data.uuid`** 即为 `res_template_uuid`,用于 API #15 创建物料。返回还包含 `name`、`resource_type`、`handles`、`config_infos` 等模板元信息。
|
||||||
|
|
||||||
|
模板不存在时返回 `code: 10002`,`data` 为空对象。模板名称来自资源注册表中已注册的资源类型。
|
||||||
|
|
||||||
|
### API #15 — 创建物料节点
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$BASE/api/v1/edge/material/node" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '<request_body>'
|
||||||
|
```
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"res_template_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||||
|
"name": "my_custom_bottle",
|
||||||
|
"display_name": "自定义瓶子",
|
||||||
|
"parent_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||||
|
"type": "",
|
||||||
|
"init_param_data": {},
|
||||||
|
"schema": {},
|
||||||
|
"data": {
|
||||||
|
"liquids": [["water", 1000, "uL"]],
|
||||||
|
"max_volume": 50000
|
||||||
|
},
|
||||||
|
"plate_well_datas": {},
|
||||||
|
"plate_reagent_datas": {},
|
||||||
|
"pose": {},
|
||||||
|
"model": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 必填 | 类型 | 数据来源 | 说明 |
|
||||||
|
| --------------------- | ------ | ------------- | ----------------------------------- | -------------------------------------- |
|
||||||
|
| `res_template_uuid` | **是** | string (UUID) | **API #14** 按名称查询获取 | 物料模板 UUID |
|
||||||
|
| `name` | 否 | string | **用户自定义** | 节点名称(标识符),可自由命名 |
|
||||||
|
| `display_name` | 否 | string | 用户自定义 | 显示名称(UI 展示用) |
|
||||||
|
| `parent_uuid` | 否 | string (UUID) | **API #12** 资源树中父节点的 `uuid` | 父节点,为空则创建顶级节点 |
|
||||||
|
| `type` | 否 | string | 从模板继承 | 节点类型 |
|
||||||
|
| `init_param_data` | 否 | object | 用户指定 | 初始化参数,覆盖模板默认值 |
|
||||||
|
| `data` | 否 | object | 用户指定 | 节点数据,container 见下方 data 格式 |
|
||||||
|
| `plate_well_datas` | 否 | object | 用户指定 | 孔板子节点数据(创建带孔位的板时使用) |
|
||||||
|
| `plate_reagent_datas` | 否 | object | 用户指定 | 试剂关联数据 |
|
||||||
|
| `schema` | 否 | object | 从模板继承 | 自定义 schema,不传则从模板继承 |
|
||||||
|
| `pose` | 否 | object | 用户指定 | 位姿信息 |
|
||||||
|
| `model` | 否 | object | 用户指定 | 3D 模型信息 |
|
||||||
|
|
||||||
|
#### container 的 `data` 格式
|
||||||
|
|
||||||
|
> **体积单位统一为 uL(微升)**。pylabrobot 体系中所有体积值(`max_volume`、`liquids` 中的 volume)均为 uL。外部如果是 mL 需乘 1000 转换。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"liquids": [["water", 1000, "uL"], ["ethanol", 500, "uL"]],
|
||||||
|
"max_volume": 50000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `liquids` — 液体列表,每条为 `[液体名称, 体积(uL), 单位字符串]`
|
||||||
|
- `max_volume` — 容器最大容量(uL),如 50 mL = 50000 uL
|
||||||
|
|
||||||
|
### API #16 — 更新物料节点
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X PUT "$BASE/api/v1/edge/material/node" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '<request_body>'
|
||||||
|
```
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||||
|
"parent_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||||
|
"display_name": "新显示名称",
|
||||||
|
"description": "新描述",
|
||||||
|
"init_param_data": {},
|
||||||
|
"data": {},
|
||||||
|
"pose": {},
|
||||||
|
"schema": {},
|
||||||
|
"extra": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 必填 | 类型 | 数据来源 | 说明 |
|
||||||
|
| ----------------- | ------ | ------------- | ------------------------------------- | ---------------- |
|
||||||
|
| `uuid` | **是** | string (UUID) | **API #12** 资源树中目标节点的 `uuid` | 要更新的物料节点 |
|
||||||
|
| `parent_uuid` | 否 | string (UUID) | API #12 资源树 | 移动到新父节点 |
|
||||||
|
| `display_name` | 否 | string | 用户指定 | 更新显示名称 |
|
||||||
|
| `description` | 否 | string | 用户指定 | 更新描述 |
|
||||||
|
| `init_param_data` | 否 | object | 用户指定 | 更新初始化参数 |
|
||||||
|
| `data` | 否 | object | 用户指定 | 更新节点数据 |
|
||||||
|
| `pose` | 否 | object | 用户指定 | 更新位姿 |
|
||||||
|
| `schema` | 否 | object | 用户指定 | 更新 schema |
|
||||||
|
| `extra` | 否 | object | 用户指定 | 更新扩展数据 |
|
||||||
|
|
||||||
|
> 只传需要更新的字段,未传的字段保持不变。
|
||||||
|
|
||||||
## 最终目录结构
|
## 最终目录结构
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
---
|
---
|
||||||
name: submit-agent-result
|
name: submit-agent-result
|
||||||
description: Submit historical experiment results (agent_result) to Uni-Lab notebook — read data files, assemble JSON payload, PUT to cloud API. Use when the user wants to submit experiment results, upload agent results, report experiment data, or mentions agent_result/实验结果/历史记录/notebook结果.
|
description: Submit historical experiment results (agent_result) to Uni-Lab cloud platform (leap-lab) notebook — read data files, assemble JSON payload, PUT to cloud API. Use when the user wants to submit experiment results, upload agent results, report experiment data, or mentions agent_result/实验结果/历史记录/notebook结果.
|
||||||
---
|
---
|
||||||
|
|
||||||
# 提交历史实验记录指南
|
# Uni-Lab 提交历史实验记录指南
|
||||||
|
|
||||||
通过云端 API 向已创建的 notebook 提交实验结果数据(agent_result)。支持从 JSON / CSV 文件读取数据,整合后提交。
|
通过 Uni-Lab 云端 API 向已创建的 notebook 提交实验结果数据(agent_result)。支持从 JSON / CSV 文件读取数据,整合后提交。
|
||||||
|
|
||||||
|
> **重要**:本指南中的 `Authorization: Lab <token>` 是 **Uni-Lab 平台专用的认证方式**,`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。
|
||||||
|
|
||||||
## 前置条件(缺一不可)
|
## 前置条件(缺一不可)
|
||||||
|
|
||||||
@@ -18,23 +20,26 @@ description: Submit historical experiment results (agent_result) to Uni-Lab note
|
|||||||
生成 AUTH token:
|
生成 AUTH token:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# ⚠️ 注意:scheme 是 "Lab"(Uni-Lab 专用),不是 "Basic"
|
||||||
python -c "import base64,sys; print(base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
|
python -c "import base64,sys; print(base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
|
||||||
```
|
```
|
||||||
|
|
||||||
输出即为 token 值,拼接为 `Authorization: Lab <token>`。
|
输出即为 token 值,拼接为 `Authorization: Lab <token>`(`Lab` 是 Uni-Lab 平台 auth scheme,不可替换为 `Basic`)。
|
||||||
|
|
||||||
### 2. --addr → BASE URL
|
### 2. --addr → BASE URL
|
||||||
|
|
||||||
| `--addr` 值 | BASE |
|
| `--addr` 值 | BASE |
|
||||||
|-------------|------|
|
| ------------ | ----------------------------------- |
|
||||||
| `test` | `https://uni-lab.test.bohrium.com` |
|
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||||
| `uat` | `https://uni-lab.uat.bohrium.com` |
|
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||||
| `local` | `http://127.0.0.1:48197` |
|
| `local` | `http://127.0.0.1:48197` |
|
||||||
| 不传(默认) | `https://uni-lab.bohrium.com` |
|
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||||
|
|
||||||
确认后设置:
|
确认后设置:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
BASE="<根据 addr 确定的 URL>"
|
BASE="<根据 addr 确定的 URL>"
|
||||||
|
# ⚠️ Auth scheme 必须是 "Lab"(Uni-Lab 专用),不是 "Basic"
|
||||||
AUTH="Authorization: Lab <上面命令输出的 token>"
|
AUTH="Authorization: Lab <上面命令输出的 token>"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -45,6 +50,7 @@ AUTH="Authorization: Lab <上面命令输出的 token>"
|
|||||||
notebook_uuid 来自之前通过「批量提交实验」创建的实验批次,即 `POST /api/v1/lab/notebook` 返回的 `data.uuid`。
|
notebook_uuid 来自之前通过「批量提交实验」创建的实验批次,即 `POST /api/v1/lab/notebook` 返回的 `data.uuid`。
|
||||||
|
|
||||||
如果用户不记得,可提示:
|
如果用户不记得,可提示:
|
||||||
|
|
||||||
- 查看之前的对话记录中创建 notebook 时返回的 UUID
|
- 查看之前的对话记录中创建 notebook 时返回的 UUID
|
||||||
- 或通过平台页面查找对应的 notebook
|
- 或通过平台页面查找对应的 notebook
|
||||||
|
|
||||||
@@ -55,7 +61,7 @@ notebook_uuid 来自之前通过「批量提交实验」创建的实验批次,
|
|||||||
用户需要提供实验结果数据,支持以下方式:
|
用户需要提供实验结果数据,支持以下方式:
|
||||||
|
|
||||||
| 方式 | 说明 |
|
| 方式 | 说明 |
|
||||||
|------|------|
|
| --------- | ----------------------------------------------- |
|
||||||
| JSON 文件 | 直接作为 `agent_result` 的内容合并 |
|
| JSON 文件 | 直接作为 `agent_result` 的内容合并 |
|
||||||
| CSV 文件 | 转为 `{"文件名": [行数据...]}` 格式 |
|
| CSV 文件 | 转为 `{"文件名": [行数据...]}` 格式 |
|
||||||
| 手动指定 | 用户直接告知 key-value 数据,由 agent 构建 JSON |
|
| 手动指定 | 用户直接告知 key-value 数据,由 agent 构建 JSON |
|
||||||
@@ -122,7 +128,7 @@ curl -s -X PUT "$BASE/api/v1/lab/notebook/agent-result" \
|
|||||||
#### 必要字段
|
#### 必要字段
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|------|------|------|
|
| --------------- | ------------- | ------------------------------------------- |
|
||||||
| `notebook_uuid` | string (UUID) | 目标 notebook 的 UUID,从批量提交实验时获取 |
|
| `notebook_uuid` | string (UUID) | 目标 notebook 的 UUID,从批量提交实验时获取 |
|
||||||
| `agent_result` | object | 实验结果数据,任意 JSON 对象 |
|
| `agent_result` | object | 实验结果数据,任意 JSON 对象 |
|
||||||
|
|
||||||
@@ -131,6 +137,7 @@ curl -s -X PUT "$BASE/api/v1/lab/notebook/agent-result" \
|
|||||||
`agent_result` 接受**任意 JSON 对象**,常见格式:
|
`agent_result` 接受**任意 JSON 对象**,常见格式:
|
||||||
|
|
||||||
**简单键值对**:
|
**简单键值对**:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"avg_rtt_ms": 12.5,
|
"avg_rtt_ms": 12.5,
|
||||||
@@ -140,6 +147,7 @@ curl -s -X PUT "$BASE/api/v1/lab/notebook/agent-result" \
|
|||||||
```
|
```
|
||||||
|
|
||||||
**包含嵌套结构**:
|
**包含嵌套结构**:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"summary": { "total": 100, "passed": 98, "failed": 2 },
|
"summary": { "total": 100, "passed": 98, "failed": 2 },
|
||||||
@@ -151,6 +159,7 @@ curl -s -X PUT "$BASE/api/v1/lab/notebook/agent-result" \
|
|||||||
```
|
```
|
||||||
|
|
||||||
**从 CSV 文件导入**(脚本自动转换):
|
**从 CSV 文件导入**(脚本自动转换):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"experiment_data": [
|
"experiment_data": [
|
||||||
@@ -179,7 +188,7 @@ python scripts/prepare_agent_result.py \
|
|||||||
```
|
```
|
||||||
|
|
||||||
| 参数 | 必选 | 说明 |
|
| 参数 | 必选 | 说明 |
|
||||||
|------|------|------|
|
| ----------------- | ---------- | ----------------------------------------------- |
|
||||||
| `--notebook-uuid` | 是 | 目标 notebook UUID |
|
| `--notebook-uuid` | 是 | 目标 notebook UUID |
|
||||||
| `--files` | 是 | 输入文件路径(支持多个,JSON / CSV) |
|
| `--files` | 是 | 输入文件路径(支持多个,JSON / CSV) |
|
||||||
| `--auth` | 提交时必选 | Lab token(base64(ak:sk)) |
|
| `--auth` | 提交时必选 | Lab token(base64(ak:sk)) |
|
||||||
@@ -190,7 +199,7 @@ python scripts/prepare_agent_result.py \
|
|||||||
### 文件合并规则
|
### 文件合并规则
|
||||||
|
|
||||||
| 文件类型 | 合并方式 |
|
| 文件类型 | 合并方式 |
|
||||||
|----------|----------|
|
| --------------------- | -------------------------------------------- |
|
||||||
| `.json`(dict) | 字段直接合并到 `agent_result` 顶层 |
|
| `.json`(dict) | 字段直接合并到 `agent_result` 顶层 |
|
||||||
| `.json`(list/other) | 以文件名为 key 放入 `agent_result` |
|
| `.json`(list/other) | 以文件名为 key 放入 `agent_result` |
|
||||||
| `.csv` | 以文件名(不含扩展名)为 key,值为行对象数组 |
|
| `.csv` | 以文件名(不含扩展名)为 key,值为行对象数组 |
|
||||||
@@ -210,7 +219,7 @@ python scripts/prepare_agent_result.py \
|
|||||||
--notebook-uuid 73c67dca-c8cc-4936-85a0-329106aa7cca \
|
--notebook-uuid 73c67dca-c8cc-4936-85a0-329106aa7cca \
|
||||||
--files results.json \
|
--files results.json \
|
||||||
--auth YTFmZDlkNGUt... \
|
--auth YTFmZDlkNGUt... \
|
||||||
--base https://uni-lab.test.bohrium.com \
|
--base https://leap-lab.test.bohrium.com \
|
||||||
--submit
|
--submit
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -272,4 +281,4 @@ Task Progress:
|
|||||||
|
|
||||||
### Q: 认证方式是 Lab 还是 Api?
|
### Q: 认证方式是 Lab 还是 Api?
|
||||||
|
|
||||||
本指南统一使用 `Authorization: Lab <base64(ak:sk)>` 方式。如果用户有独立的 API Key,也可用 `Authorization: Api <key>` 替代。
|
本指南统一使用 `Authorization: Lab <base64(ak:sk)>` 方式(`Lab` 是 Uni-Lab 平台的 auth scheme,**绝不能用 `Basic` 替代**)。如果用户有独立的 API Key,也可用 `Authorization: Api <key>` 替代。
|
||||||
|
|||||||
2
.github/workflows/ci-check.yml
vendored
2
.github/workflows/ci-check.yml
vendored
@@ -38,7 +38,7 @@ jobs:
|
|||||||
- name: Install ROS dependencies, uv and unilabos-msgs
|
- name: Install ROS dependencies, uv and unilabos-msgs
|
||||||
run: |
|
run: |
|
||||||
echo Installing ROS dependencies...
|
echo Installing ROS dependencies...
|
||||||
mamba install -n check-env conda-forge::uv conda-forge::opencv robostack-staging::ros-humble-ros-core robostack-staging::ros-humble-action-msgs robostack-staging::ros-humble-std-msgs robostack-staging::ros-humble-geometry-msgs robostack-staging::ros-humble-control-msgs robostack-staging::ros-humble-nav2-msgs uni-lab::ros-humble-unilabos-msgs robostack-staging::ros-humble-cv-bridge robostack-staging::ros-humble-vision-opencv robostack-staging::ros-humble-tf-transformations robostack-staging::ros-humble-moveit-msgs robostack-staging::ros-humble-tf2-ros robostack-staging::ros-humble-tf2-ros-py conda-forge::transforms3d -c robostack-staging -c conda-forge -c uni-lab -y
|
mamba install -n check-env --override-channels -c robostack-staging -c conda-forge -c uni-lab conda-forge::uv conda-forge::opencv robostack-staging::ros-humble-ros-core robostack-staging::ros-humble-action-msgs robostack-staging::ros-humble-std-msgs robostack-staging::ros-humble-geometry-msgs robostack-staging::ros-humble-control-msgs robostack-staging::ros-humble-nav2-msgs uni-lab::ros-humble-unilabos-msgs robostack-staging::ros-humble-cv-bridge robostack-staging::ros-humble-vision-opencv robostack-staging::ros-humble-tf-transformations robostack-staging::ros-humble-moveit-msgs robostack-staging::ros-humble-tf2-ros robostack-staging::ros-humble-tf2-ros-py conda-forge::transforms3d -y
|
||||||
|
|
||||||
- name: Install pip dependencies and unilabos
|
- name: Install pip dependencies and unilabos
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
77
.github/workflows/conda-pack-build.yml
vendored
77
.github/workflows/conda-pack-build.yml
vendored
@@ -1,6 +1,10 @@
|
|||||||
name: Build Conda-Pack Environment
|
name: Build Conda-Pack Environment
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
# 在 UniLabOS Conda Build 成功上传后自动构建非全量 conda-pack
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["UniLabOS Conda Build"]
|
||||||
|
types: [completed]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
branch:
|
branch:
|
||||||
@@ -21,6 +25,16 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-conda-pack:
|
build-conda-pack:
|
||||||
|
if: |
|
||||||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
|
(
|
||||||
|
github.event_name == 'workflow_run' &&
|
||||||
|
github.event.workflow_run.conclusion == 'success' &&
|
||||||
|
github.event.workflow_run.event == 'workflow_run'
|
||||||
|
)
|
||||||
|
env:
|
||||||
|
BUILD_FULL: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' }}
|
||||||
|
PACKAGE_REF: ${{ github.event.inputs.branch || github.event.workflow_run.head_sha || github.ref_name }}
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -54,7 +68,9 @@ jobs:
|
|||||||
id: should_build
|
id: should_build
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
if [[ -z "${{ github.event.inputs.platforms }}" ]]; then
|
if [[ "${{ github.event_name }}" != "workflow_dispatch" ]]; then
|
||||||
|
echo "should_build=true" >> $GITHUB_OUTPUT
|
||||||
|
elif [[ -z "${{ github.event.inputs.platforms }}" ]]; then
|
||||||
echo "should_build=true" >> $GITHUB_OUTPUT
|
echo "should_build=true" >> $GITHUB_OUTPUT
|
||||||
elif [[ "${{ github.event.inputs.platforms }}" == *"${{ matrix.platform }}"* ]]; then
|
elif [[ "${{ github.event.inputs.platforms }}" == *"${{ matrix.platform }}"* ]]; then
|
||||||
echo "should_build=true" >> $GITHUB_OUTPUT
|
echo "should_build=true" >> $GITHUB_OUTPUT
|
||||||
@@ -65,7 +81,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
if: steps.should_build.outputs.should_build == 'true'
|
if: steps.should_build.outputs.should_build == 'true'
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.inputs.branch }}
|
ref: ${{ github.event.inputs.branch || github.event.workflow_run.head_sha || github.ref }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup Miniforge (with mamba)
|
- name: Setup Miniforge (with mamba)
|
||||||
@@ -75,7 +91,7 @@ jobs:
|
|||||||
miniforge-version: latest
|
miniforge-version: latest
|
||||||
use-mamba: true
|
use-mamba: true
|
||||||
python-version: '3.11.14'
|
python-version: '3.11.14'
|
||||||
channels: conda-forge,robostack-staging,uni-lab,defaults
|
channels: conda-forge,robostack-staging,uni-lab
|
||||||
channel-priority: flexible
|
channel-priority: flexible
|
||||||
activate-environment: unilab
|
activate-environment: unilab
|
||||||
auto-update-conda: false
|
auto-update-conda: false
|
||||||
@@ -86,13 +102,13 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo Installing unilabos and dependencies to unilab environment...
|
echo Installing unilabos and dependencies to unilab environment...
|
||||||
echo Using mamba for faster and more reliable dependency resolution...
|
echo Using mamba for faster and more reliable dependency resolution...
|
||||||
echo Build full: ${{ github.event.inputs.build_full }}
|
echo Build full: ${{ env.BUILD_FULL }}
|
||||||
if "${{ github.event.inputs.build_full }}"=="true" (
|
if "${{ env.BUILD_FULL }}"=="true" (
|
||||||
echo Installing unilabos-full ^(complete package^)...
|
echo Installing unilabos-full ^(complete package^)...
|
||||||
mamba install -n unilab uni-lab::unilabos-full conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
|
mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos-full conda-pack zstandard -y
|
||||||
) else (
|
) else (
|
||||||
echo Installing unilabos ^(minimal package^)...
|
echo Installing unilabos ^(minimal package^)...
|
||||||
mamba install -n unilab uni-lab::unilabos conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
|
mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos conda-pack zstandard -y
|
||||||
)
|
)
|
||||||
|
|
||||||
- name: Install conda-pack, unilabos and dependencies (Unix)
|
- name: Install conda-pack, unilabos and dependencies (Unix)
|
||||||
@@ -101,13 +117,13 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "Installing unilabos and dependencies to unilab environment..."
|
echo "Installing unilabos and dependencies to unilab environment..."
|
||||||
echo "Using mamba for faster and more reliable dependency resolution..."
|
echo "Using mamba for faster and more reliable dependency resolution..."
|
||||||
echo "Build full: ${{ github.event.inputs.build_full }}"
|
echo "Build full: ${{ env.BUILD_FULL }}"
|
||||||
if [[ "${{ github.event.inputs.build_full }}" == "true" ]]; then
|
if [[ "${{ env.BUILD_FULL }}" == "true" ]]; then
|
||||||
echo "Installing unilabos-full (complete package)..."
|
echo "Installing unilabos-full (complete package)..."
|
||||||
mamba install -n unilab uni-lab::unilabos-full conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
|
mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos-full conda-pack zstandard -y
|
||||||
else
|
else
|
||||||
echo "Installing unilabos (minimal package)..."
|
echo "Installing unilabos (minimal package)..."
|
||||||
mamba install -n unilab uni-lab::unilabos conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
|
mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos conda-pack zstandard -y
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Get latest ros-humble-unilabos-msgs version (Windows)
|
- name: Get latest ros-humble-unilabos-msgs version (Windows)
|
||||||
@@ -134,27 +150,27 @@ jobs:
|
|||||||
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
|
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
|
||||||
run: |
|
run: |
|
||||||
echo Checking for available ros-humble-unilabos-msgs versions...
|
echo Checking for available ros-humble-unilabos-msgs versions...
|
||||||
mamba search ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge || echo Search completed
|
mamba search --override-channels -c uni-lab -c robostack-staging -c conda-forge ros-humble-unilabos-msgs || echo Search completed
|
||||||
echo.
|
echo.
|
||||||
echo Updating ros-humble-unilabos-msgs to latest version...
|
echo Updating ros-humble-unilabos-msgs to latest version...
|
||||||
mamba update -n unilab ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge -y || echo Already at latest version
|
mamba update -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge ros-humble-unilabos-msgs -y || echo Already at latest version
|
||||||
|
|
||||||
- name: Check for newer ros-humble-unilabos-msgs (Unix)
|
- name: Check for newer ros-humble-unilabos-msgs (Unix)
|
||||||
if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64'
|
if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
echo "Checking for available ros-humble-unilabos-msgs versions..."
|
echo "Checking for available ros-humble-unilabos-msgs versions..."
|
||||||
mamba search ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge || echo "Search completed"
|
mamba search --override-channels -c uni-lab -c robostack-staging -c conda-forge ros-humble-unilabos-msgs || echo "Search completed"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Updating ros-humble-unilabos-msgs to latest version..."
|
echo "Updating ros-humble-unilabos-msgs to latest version..."
|
||||||
mamba update -n unilab ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge -y || echo "Already at latest version"
|
mamba update -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge ros-humble-unilabos-msgs -y || echo "Already at latest version"
|
||||||
|
|
||||||
- name: Install latest unilabos from source (Windows)
|
- name: Install latest unilabos from source (Windows)
|
||||||
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
|
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
|
||||||
run: |
|
run: |
|
||||||
echo Uninstalling existing unilabos...
|
echo Uninstalling existing unilabos...
|
||||||
mamba run -n unilab pip uninstall unilabos -y || echo unilabos not installed via pip
|
mamba run -n unilab pip uninstall unilabos -y || echo unilabos not installed via pip
|
||||||
echo Installing unilabos from source (branch: ${{ github.event.inputs.branch }})...
|
echo Installing unilabos from source (ref: ${{ env.PACKAGE_REF }})...
|
||||||
mamba run -n unilab pip install .
|
mamba run -n unilab pip install .
|
||||||
echo Verifying installation...
|
echo Verifying installation...
|
||||||
mamba run -n unilab pip show unilabos
|
mamba run -n unilab pip show unilabos
|
||||||
@@ -165,7 +181,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "Uninstalling existing unilabos..."
|
echo "Uninstalling existing unilabos..."
|
||||||
mamba run -n unilab pip uninstall unilabos -y || echo "unilabos not installed via pip"
|
mamba run -n unilab pip uninstall unilabos -y || echo "unilabos not installed via pip"
|
||||||
echo "Installing unilabos from source (branch: ${{ github.event.inputs.branch }})..."
|
echo "Installing unilabos from source (ref: ${{ env.PACKAGE_REF }})..."
|
||||||
mamba run -n unilab pip install .
|
mamba run -n unilab pip install .
|
||||||
echo "Verifying installation..."
|
echo "Verifying installation..."
|
||||||
mamba run -n unilab pip show unilabos
|
mamba run -n unilab pip show unilabos
|
||||||
@@ -226,7 +242,9 @@ jobs:
|
|||||||
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
|
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
|
||||||
run: |
|
run: |
|
||||||
echo Packing unilab environment with conda-pack...
|
echo Packing unilab environment with conda-pack...
|
||||||
mamba activate unilab && conda pack -n unilab -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files
|
for /f "delims=" %%i in ('mamba run -n unilab python -c "import os; print(os.environ['CONDA_PREFIX'])"') do set "UNILAB_PREFIX=%%i"
|
||||||
|
echo Packing environment at: %UNILAB_PREFIX%
|
||||||
|
mamba run -n unilab conda-pack -p "%UNILAB_PREFIX%" -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files
|
||||||
echo Pack file created:
|
echo Pack file created:
|
||||||
dir unilab-env-${{ matrix.platform }}.tar.gz
|
dir unilab-env-${{ matrix.platform }}.tar.gz
|
||||||
|
|
||||||
@@ -235,8 +253,9 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
echo "Packing unilab environment with conda-pack..."
|
echo "Packing unilab environment with conda-pack..."
|
||||||
mamba install conda-pack -c conda-forge -y
|
UNILAB_PREFIX="$(mamba run -n unilab python -c 'import os; print(os.environ["CONDA_PREFIX"])')"
|
||||||
conda pack -n unilab -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files
|
echo "Packing environment at: $UNILAB_PREFIX"
|
||||||
|
mamba run -n unilab conda-pack -p "$UNILAB_PREFIX" -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files
|
||||||
echo "Pack file created:"
|
echo "Pack file created:"
|
||||||
ls -lh unilab-env-${{ matrix.platform }}.tar.gz
|
ls -lh unilab-env-${{ matrix.platform }}.tar.gz
|
||||||
|
|
||||||
@@ -267,7 +286,7 @@ jobs:
|
|||||||
|
|
||||||
rem Create README using Python script
|
rem Create README using Python script
|
||||||
echo Creating: README.txt
|
echo Creating: README.txt
|
||||||
python scripts\create_readme.py ${{ matrix.platform }} ${{ github.event.inputs.branch }} dist-package\README.txt
|
python scripts\create_readme.py ${{ matrix.platform }} ${{ env.PACKAGE_REF }} dist-package\README.txt
|
||||||
|
|
||||||
echo.
|
echo.
|
||||||
echo Distribution package contents:
|
echo Distribution package contents:
|
||||||
@@ -303,7 +322,7 @@ jobs:
|
|||||||
|
|
||||||
# Create README using Python script
|
# Create README using Python script
|
||||||
echo "Creating: README.txt"
|
echo "Creating: README.txt"
|
||||||
python scripts/create_readme.py ${{ matrix.platform }} ${{ github.event.inputs.branch }} dist-package/README.txt
|
python scripts/create_readme.py ${{ matrix.platform }} ${{ env.PACKAGE_REF }} dist-package/README.txt
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Distribution package contents:"
|
echo "Distribution package contents:"
|
||||||
@@ -314,7 +333,7 @@ jobs:
|
|||||||
if: steps.should_build.outputs.should_build == 'true'
|
if: steps.should_build.outputs.should_build == 'true'
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }}
|
name: unilab-pack-${{ matrix.platform }}-${{ env.PACKAGE_REF }}
|
||||||
path: dist-package/
|
path: dist-package/
|
||||||
retention-days: 90
|
retention-days: 90
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
@@ -326,9 +345,9 @@ jobs:
|
|||||||
echo Build Summary
|
echo Build Summary
|
||||||
echo ==========================================
|
echo ==========================================
|
||||||
echo Platform: ${{ matrix.platform }}
|
echo Platform: ${{ matrix.platform }}
|
||||||
echo Branch: ${{ github.event.inputs.branch }}
|
echo Branch: ${{ env.PACKAGE_REF }}
|
||||||
echo Python version: 3.11.14
|
echo Python version: 3.11.14
|
||||||
if "${{ github.event.inputs.build_full }}"=="true" (
|
if "${{ env.BUILD_FULL }}"=="true" (
|
||||||
echo Package: unilabos-full ^(complete^)
|
echo Package: unilabos-full ^(complete^)
|
||||||
) else (
|
) else (
|
||||||
echo Package: unilabos ^(minimal^)
|
echo Package: unilabos ^(minimal^)
|
||||||
@@ -337,7 +356,7 @@ jobs:
|
|||||||
echo Distribution package contents:
|
echo Distribution package contents:
|
||||||
dir dist-package
|
dir dist-package
|
||||||
echo.
|
echo.
|
||||||
echo Artifact name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }}
|
echo Artifact name: unilab-pack-${{ matrix.platform }}-${{ env.PACKAGE_REF }}
|
||||||
echo.
|
echo.
|
||||||
echo After download, extract the ZIP and run:
|
echo After download, extract the ZIP and run:
|
||||||
echo install_unilab.bat
|
echo install_unilab.bat
|
||||||
@@ -351,9 +370,9 @@ jobs:
|
|||||||
echo "Build Summary"
|
echo "Build Summary"
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo "Platform: ${{ matrix.platform }}"
|
echo "Platform: ${{ matrix.platform }}"
|
||||||
echo "Branch: ${{ github.event.inputs.branch }}"
|
echo "Branch: ${{ env.PACKAGE_REF }}"
|
||||||
echo "Python version: 3.11.14"
|
echo "Python version: 3.11.14"
|
||||||
if [[ "${{ github.event.inputs.build_full }}" == "true" ]]; then
|
if [[ "${{ env.BUILD_FULL }}" == "true" ]]; then
|
||||||
echo "Package: unilabos-full (complete)"
|
echo "Package: unilabos-full (complete)"
|
||||||
else
|
else
|
||||||
echo "Package: unilabos (minimal)"
|
echo "Package: unilabos (minimal)"
|
||||||
@@ -362,7 +381,7 @@ jobs:
|
|||||||
echo "Distribution package contents:"
|
echo "Distribution package contents:"
|
||||||
ls -lh dist-package/
|
ls -lh dist-package/
|
||||||
echo ""
|
echo ""
|
||||||
echo "Artifact name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }}"
|
echo "Artifact name: unilab-pack-${{ matrix.platform }}-${{ env.PACKAGE_REF }}"
|
||||||
echo ""
|
echo ""
|
||||||
echo "After download:"
|
echo "After download:"
|
||||||
echo " install_unilab.sh"
|
echo " install_unilab.sh"
|
||||||
|
|||||||
4
.github/workflows/deploy-docs.yml
vendored
4
.github/workflows/deploy-docs.yml
vendored
@@ -56,7 +56,7 @@ jobs:
|
|||||||
miniforge-version: latest
|
miniforge-version: latest
|
||||||
use-mamba: true
|
use-mamba: true
|
||||||
python-version: '3.11.14'
|
python-version: '3.11.14'
|
||||||
channels: conda-forge,robostack-staging,uni-lab,defaults
|
channels: conda-forge,robostack-staging,uni-lab
|
||||||
channel-priority: flexible
|
channel-priority: flexible
|
||||||
activate-environment: unilab
|
activate-environment: unilab
|
||||||
auto-update-conda: false
|
auto-update-conda: false
|
||||||
@@ -66,7 +66,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "Installing unilabos and dependencies to unilab environment..."
|
echo "Installing unilabos and dependencies to unilab environment..."
|
||||||
echo "Using mamba for faster and more reliable dependency resolution..."
|
echo "Using mamba for faster and more reliable dependency resolution..."
|
||||||
mamba install -n unilab uni-lab::unilabos -c uni-lab -c robostack-staging -c conda-forge -y
|
mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos -y
|
||||||
|
|
||||||
- name: Install latest unilabos from source
|
- name: Install latest unilabos from source
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
22
.github/workflows/multi-platform-build.yml
vendored
22
.github/workflows/multi-platform-build.yml
vendored
@@ -10,6 +10,9 @@ on:
|
|||||||
# 支持 tag 推送(不依赖 CI Check)
|
# 支持 tag 推送(不依赖 CI Check)
|
||||||
push:
|
push:
|
||||||
tags: ['v*']
|
tags: ['v*']
|
||||||
|
# GitHub Release 发布时自动构建并上传
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
# 手动触发
|
# 手动触发
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
@@ -80,7 +83,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
# 如果是 workflow_run 触发,使用触发 CI Check 的 commit
|
# 如果是 workflow_run 触发,使用触发 CI Check 的 commit
|
||||||
ref: ${{ github.event.workflow_run.head_sha || github.ref }}
|
ref: ${{ github.event.workflow_run.head_sha || github.event.release.tag_name || github.ref }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Check if platform should be built
|
- name: Check if platform should be built
|
||||||
@@ -96,12 +99,13 @@ jobs:
|
|||||||
echo "should_build=false" >> $GITHUB_OUTPUT
|
echo "should_build=false" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Setup Miniconda
|
- name: Setup Miniforge
|
||||||
if: steps.should_build.outputs.should_build == 'true'
|
if: steps.should_build.outputs.should_build == 'true'
|
||||||
uses: conda-incubator/setup-miniconda@v3
|
uses: conda-incubator/setup-miniconda@v3
|
||||||
with:
|
with:
|
||||||
miniconda-version: 'latest'
|
miniforge-version: latest
|
||||||
channels: conda-forge,robostack-staging,defaults
|
use-mamba: true
|
||||||
|
channels: conda-forge,robostack-staging
|
||||||
channel-priority: strict
|
channel-priority: strict
|
||||||
activate-environment: build-env
|
activate-environment: build-env
|
||||||
auto-update-conda: false
|
auto-update-conda: false
|
||||||
@@ -110,7 +114,7 @@ jobs:
|
|||||||
- name: Install rattler-build and anaconda-client
|
- name: Install rattler-build and anaconda-client
|
||||||
if: steps.should_build.outputs.should_build == 'true'
|
if: steps.should_build.outputs.should_build == 'true'
|
||||||
run: |
|
run: |
|
||||||
conda install -c conda-forge rattler-build anaconda-client
|
mamba install --override-channels -c conda-forge rattler-build anaconda-client -y
|
||||||
|
|
||||||
- name: Show environment info
|
- name: Show environment info
|
||||||
if: steps.should_build.outputs.should_build == 'true'
|
if: steps.should_build.outputs.should_build == 'true'
|
||||||
@@ -157,7 +161,13 @@ jobs:
|
|||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
- name: Upload to Anaconda.org (unilab organization)
|
- name: Upload to Anaconda.org (unilab organization)
|
||||||
if: steps.should_build.outputs.should_build == 'true' && github.event.inputs.upload_to_anaconda == 'true'
|
if: |
|
||||||
|
steps.should_build.outputs.should_build == 'true' &&
|
||||||
|
(
|
||||||
|
github.event_name == 'release' ||
|
||||||
|
startsWith(github.ref, 'refs/tags/') ||
|
||||||
|
github.event.inputs.upload_to_anaconda == 'true'
|
||||||
|
)
|
||||||
run: |
|
run: |
|
||||||
for package in $(find ./output -name "*.conda"); do
|
for package in $(find ./output -name "*.conda"); do
|
||||||
echo "Uploading $package to unilab organization..."
|
echo "Uploading $package to unilab organization..."
|
||||||
|
|||||||
57
.github/workflows/unilabos-conda-build.yml
vendored
57
.github/workflows/unilabos-conda-build.yml
vendored
@@ -1,14 +1,10 @@
|
|||||||
name: UniLabOS Conda Build
|
name: UniLabOS Conda Build
|
||||||
|
|
||||||
on:
|
on:
|
||||||
# 在 CI Check 成功后自动触发
|
# 在 Multi-Platform Conda Build 成功上传 msgs 后自动触发
|
||||||
workflow_run:
|
workflow_run:
|
||||||
workflows: ["CI Check"]
|
workflows: ["Multi-Platform Conda Build"]
|
||||||
types: [completed]
|
types: [completed]
|
||||||
branches: [main, dev]
|
|
||||||
# 标签推送时直接触发(发布版本)
|
|
||||||
push:
|
|
||||||
tags: ['v*']
|
|
||||||
# 手动触发
|
# 手动触发
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
@@ -33,30 +29,30 @@ on:
|
|||||||
type: boolean
|
type: boolean
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# 等待 CI Check 完成的 job (仅用于 workflow_run 触发)
|
# 等待上游 msgs 构建完成的 job (仅用于 workflow_run 触发)
|
||||||
wait-for-ci:
|
wait-for-upstream:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.event_name == 'workflow_run'
|
if: github.event_name == 'workflow_run'
|
||||||
outputs:
|
outputs:
|
||||||
should_continue: ${{ steps.check.outputs.should_continue }}
|
should_continue: ${{ steps.check.outputs.should_continue }}
|
||||||
steps:
|
steps:
|
||||||
- name: Check CI status
|
- name: Check upstream workflow status
|
||||||
id: check
|
id: check
|
||||||
run: |
|
run: |
|
||||||
if [[ "${{ github.event.workflow_run.conclusion }}" == "success" ]]; then
|
if [[ "${{ github.event.workflow_run.conclusion }}" == "success" && ( "${{ github.event.workflow_run.event }}" == "release" || "${{ github.event.workflow_run.event }}" == "push" ) ]]; then
|
||||||
echo "should_continue=true" >> $GITHUB_OUTPUT
|
echo "should_continue=true" >> $GITHUB_OUTPUT
|
||||||
echo "CI Check passed, proceeding with build"
|
echo "Multi-Platform Conda Build passed for release/tag, proceeding with UniLabOS build"
|
||||||
else
|
else
|
||||||
echo "should_continue=false" >> $GITHUB_OUTPUT
|
echo "should_continue=false" >> $GITHUB_OUTPUT
|
||||||
echo "CI Check did not succeed (status: ${{ github.event.workflow_run.conclusion }}), skipping build"
|
echo "Upstream workflow is not a successful release/tag build (status: ${{ github.event.workflow_run.conclusion }}, event: ${{ github.event.workflow_run.event }}), skipping build"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
build:
|
build:
|
||||||
needs: [wait-for-ci]
|
needs: [wait-for-upstream]
|
||||||
# 运行条件:workflow_run 触发且 CI 成功,或者其他触发方式
|
# 运行条件:workflow_run 触发且上游成功,或者手动触发
|
||||||
if: |
|
if: |
|
||||||
always() &&
|
always() &&
|
||||||
(needs.wait-for-ci.result == 'skipped' || needs.wait-for-ci.outputs.should_continue == 'true')
|
(needs.wait-for-upstream.result == 'skipped' || needs.wait-for-upstream.outputs.should_continue == 'true')
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -79,7 +75,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
# 如果是 workflow_run 触发,使用触发 CI Check 的 commit
|
# 如果是 workflow_run 触发,使用上游 conda 包构建的 commit
|
||||||
ref: ${{ github.event.workflow_run.head_sha || github.ref }}
|
ref: ${{ github.event.workflow_run.head_sha || github.ref }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -96,12 +92,13 @@ jobs:
|
|||||||
echo "should_build=false" >> $GITHUB_OUTPUT
|
echo "should_build=false" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Setup Miniconda
|
- name: Setup Miniforge
|
||||||
if: steps.should_build.outputs.should_build == 'true'
|
if: steps.should_build.outputs.should_build == 'true'
|
||||||
uses: conda-incubator/setup-miniconda@v3
|
uses: conda-incubator/setup-miniconda@v3
|
||||||
with:
|
with:
|
||||||
miniconda-version: 'latest'
|
miniforge-version: latest
|
||||||
channels: conda-forge,robostack-staging,uni-lab,defaults
|
use-mamba: true
|
||||||
|
channels: conda-forge,robostack-staging,uni-lab
|
||||||
channel-priority: strict
|
channel-priority: strict
|
||||||
activate-environment: build-env
|
activate-environment: build-env
|
||||||
auto-update-conda: false
|
auto-update-conda: false
|
||||||
@@ -110,7 +107,7 @@ jobs:
|
|||||||
- name: Install rattler-build and anaconda-client
|
- name: Install rattler-build and anaconda-client
|
||||||
if: steps.should_build.outputs.should_build == 'true'
|
if: steps.should_build.outputs.should_build == 'true'
|
||||||
run: |
|
run: |
|
||||||
conda install -c conda-forge rattler-build anaconda-client
|
mamba install --override-channels -c conda-forge rattler-build anaconda-client -y
|
||||||
|
|
||||||
- name: Show environment info
|
- name: Show environment info
|
||||||
if: steps.should_build.outputs.should_build == 'true'
|
if: steps.should_build.outputs.should_build == 'true'
|
||||||
@@ -119,11 +116,11 @@ jobs:
|
|||||||
conda list | grep -E "(rattler-build|anaconda-client)"
|
conda list | grep -E "(rattler-build|anaconda-client)"
|
||||||
echo "Platform: ${{ matrix.platform }}"
|
echo "Platform: ${{ matrix.platform }}"
|
||||||
echo "OS: ${{ matrix.os }}"
|
echo "OS: ${{ matrix.os }}"
|
||||||
echo "Build full package: ${{ github.event.inputs.build_full || 'false' }}"
|
echo "Build full package: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' }}"
|
||||||
echo "Building packages:"
|
echo "Building packages:"
|
||||||
echo " - unilabos-env (environment dependencies)"
|
echo " - unilabos-env (environment dependencies)"
|
||||||
echo " - unilabos (with pip package)"
|
echo " - unilabos (with pip package)"
|
||||||
if [[ "${{ github.event.inputs.build_full }}" == "true" ]]; then
|
if [[ "${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' }}" == "true" ]]; then
|
||||||
echo " - unilabos-full (complete package)"
|
echo " - unilabos-full (complete package)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -134,7 +131,12 @@ jobs:
|
|||||||
rattler-build build -r .conda/environment/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge
|
rattler-build build -r .conda/environment/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge
|
||||||
|
|
||||||
- name: Upload unilabos-env to Anaconda.org (if enabled)
|
- name: Upload unilabos-env to Anaconda.org (if enabled)
|
||||||
if: steps.should_build.outputs.should_build == 'true' && github.event.inputs.upload_to_anaconda == 'true'
|
if: |
|
||||||
|
steps.should_build.outputs.should_build == 'true' &&
|
||||||
|
(
|
||||||
|
github.event_name == 'workflow_run' ||
|
||||||
|
github.event.inputs.upload_to_anaconda == 'true'
|
||||||
|
)
|
||||||
run: |
|
run: |
|
||||||
echo "Uploading unilabos-env to uni-lab organization..."
|
echo "Uploading unilabos-env to uni-lab organization..."
|
||||||
for package in $(find ./output -name "unilabos-env*.conda"); do
|
for package in $(find ./output -name "unilabos-env*.conda"); do
|
||||||
@@ -149,7 +151,12 @@ jobs:
|
|||||||
rattler-build build -r .conda/base/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge --channel ./output
|
rattler-build build -r .conda/base/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge --channel ./output
|
||||||
|
|
||||||
- name: Upload unilabos to Anaconda.org (if enabled)
|
- name: Upload unilabos to Anaconda.org (if enabled)
|
||||||
if: steps.should_build.outputs.should_build == 'true' && github.event.inputs.upload_to_anaconda == 'true'
|
if: |
|
||||||
|
steps.should_build.outputs.should_build == 'true' &&
|
||||||
|
(
|
||||||
|
github.event_name == 'workflow_run' ||
|
||||||
|
github.event.inputs.upload_to_anaconda == 'true'
|
||||||
|
)
|
||||||
run: |
|
run: |
|
||||||
echo "Uploading unilabos to uni-lab organization..."
|
echo "Uploading unilabos to uni-lab organization..."
|
||||||
for package in $(find ./output -name "unilabos-0*.conda" -o -name "unilabos-[0-9]*.conda"); do
|
for package in $(find ./output -name "unilabos-0*.conda" -o -name "unilabos-[0-9]*.conda"); do
|
||||||
@@ -159,6 +166,7 @@ jobs:
|
|||||||
- name: Build unilabos-full - Only when explicitly requested
|
- name: Build unilabos-full - Only when explicitly requested
|
||||||
if: |
|
if: |
|
||||||
steps.should_build.outputs.should_build == 'true' &&
|
steps.should_build.outputs.should_build == 'true' &&
|
||||||
|
github.event_name == 'workflow_dispatch' &&
|
||||||
github.event.inputs.build_full == 'true'
|
github.event.inputs.build_full == 'true'
|
||||||
run: |
|
run: |
|
||||||
echo "Building unilabos-full package on ${{ matrix.platform }}..."
|
echo "Building unilabos-full package on ${{ matrix.platform }}..."
|
||||||
@@ -167,6 +175,7 @@ jobs:
|
|||||||
- name: Upload unilabos-full to Anaconda.org (if enabled)
|
- name: Upload unilabos-full to Anaconda.org (if enabled)
|
||||||
if: |
|
if: |
|
||||||
steps.should_build.outputs.should_build == 'true' &&
|
steps.should_build.outputs.should_build == 'true' &&
|
||||||
|
github.event_name == 'workflow_dispatch' &&
|
||||||
github.event.inputs.build_full == 'true' &&
|
github.event.inputs.build_full == 'true' &&
|
||||||
github.event.inputs.upload_to_anaconda == 'true'
|
github.event.inputs.upload_to_anaconda == 'true'
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
85
AGENTS.md
85
AGENTS.md
@@ -23,8 +23,11 @@ unilab --skip_env_check # skip auto-install of dependencies
|
|||||||
unilab --visual rviz|web|disable # visualization mode
|
unilab --visual rviz|web|disable # visualization mode
|
||||||
unilab --is_slave # run as slave node
|
unilab --is_slave # run as slave node
|
||||||
|
|
||||||
# Workflow upload subcommand
|
# Workflow upload subcommand(P6.1 新增 --target_device;P6.1.1 新增 --target_model)
|
||||||
unilab workflow_upload -f <workflow.json> -n <name> --tags tag1 tag2
|
unilab workflow_upload -f <workflow.json> -n <name> --tags tag1 tag2
|
||||||
|
unilab workflow_upload -f <workflow.json> --target_device prcxi # P6.1 默认;同上 P6 行为
|
||||||
|
unilab workflow_upload -f <workflow.json> --target_device prcxi --target_model 9320 # P6.1.1:型号粒度
|
||||||
|
unilab workflow_upload -f <workflow.json> --target_device beckman # 未来支持,需在 YAML 中声明 target_devices.beckman
|
||||||
|
|
||||||
# Tests
|
# Tests
|
||||||
pytest tests/ # all tests
|
pytest tests/ # all tests
|
||||||
@@ -72,6 +75,86 @@ pytest tests/resources/test_resourcetreeset.py::TestClassName::test_method # si
|
|||||||
|
|
||||||
Example device graphs and experiment configs are in `unilabos/test/experiments/` (not `tests/`). Registry test fixtures in `unilabos/test/registry/`.
|
Example device graphs and experiment configs are in `unilabos/test/experiments/` (not `tests/`). Registry test fixtures in `unilabos/test/registry/`.
|
||||||
|
|
||||||
|
### Labware Mapping Table (`labware_mapping.yaml`) — P6 + P6.1 + P6.1.1
|
||||||
|
|
||||||
|
Opentrons → 目标仪器(PRCXI / Beckman / Tecan ...)的「槽位重映射 + labware 归类 +
|
||||||
|
class_name 选择」全部外化到项目根的
|
||||||
|
[`labware_mapping.yaml`](./labware_mapping.yaml)(与 `pyproject.toml` 同级,最显眼的位置)。
|
||||||
|
要新增 SKU、新厂商、新型号、或调整 tip 量程档时,**只改 YAML,不改 Python**。
|
||||||
|
|
||||||
|
- **YAML 两段顶层语义**(P6.1.1 起 `slot_remap` 已下沉到 `target_devices` 内):
|
||||||
|
- `kinds` — 顺序敏感的 regex;把 labware 字符串归到 `trash / tip_rack / tube_rack / plate`。**全局段**,与目标仪器无关。
|
||||||
|
- `target_devices.<name>` — 按目标仪器组织的规则段,内含三个字段:
|
||||||
|
- `slot_remap` — 替代历史 `_map_deck_slot`(例:`4 → 13`、`8 → 14`、`12+trash → 16`)。
|
||||||
|
- `rules` — 顺序敏感的「`kind + hole_count + volume_min/volume_max` → `class_name`」规则,首个命中胜出。
|
||||||
|
- `models.<model_name>` — 可选的型号粒度覆盖(slot_remap / rules);缺失字段自动继承厂商级。
|
||||||
|
- **`target_devices` 内段名约定**:
|
||||||
|
- `default` — **固定段名**,兜底物料集 + 兜底 `slot_remap`。caller 传入的 `target_device` 在 `target_devices`
|
||||||
|
下未声明时,自动 fallback 到此段(loader 单次 warning,下游消费方零感知)。
|
||||||
|
**第一版按 prcxi 内容拷贝填充**(值仍是 `PRCXI_*`),但与 prcxi 段在 YAML 中
|
||||||
|
各自独立,可独立演进。**`default` 不支持 `models` 子段**——型号粒度差异必须落到具体仪器段。
|
||||||
|
- `prcxi` / `beckman` / `tecan` / ... — 具体仪器段(厂商粒度);caller 显式
|
||||||
|
`--target_device <name>` 时命中。可在 `models.<model>` 下声明同厂商不同型号的差异。
|
||||||
|
- **4 段 fallback 链**(`slot_remap` / `rules` 共用):
|
||||||
|
1. `target_devices.<device>.models.<model>.<field>`(caller 同时传 device + model)
|
||||||
|
2. `target_devices.<device>.<field>`(厂商级;步骤 1 缺字段时静默 fallback)
|
||||||
|
3. `target_devices.default.<field>`(caller 传未声明 device,或步骤 2 缺字段;打 warning)
|
||||||
|
4. `_BUILTIN_DEFAULT.target_devices.default.<field>`(YAML 误删 default 段时的最后兜底)
|
||||||
|
- **CLI 用法**:
|
||||||
|
- P6.1:`unilab workflow_upload -f <workflow.json> --target_device prcxi`
|
||||||
|
(`--target_device` snake-case,默认 `prcxi`;未声明的名字自动 fallback 到 `default` 段)。
|
||||||
|
- P6.1.1:可加 `--target_model <name>`(snake,可省略,默认 `None`)。
|
||||||
|
例:`unilab workflow_upload -f <workflow.json> --target_device prcxi --target_model 9320`。
|
||||||
|
- **入口代码**:`unilabos/workflow/labware_mapping.py` 暴露 `remap_slot` / `infer_kind` /
|
||||||
|
`resolve_target_class` / `reload_mapping`。
|
||||||
|
API 签名(P6.1.1):
|
||||||
|
- `remap_slot(raw_slot, object_type="", *, target_device="prcxi", target_model=None)`
|
||||||
|
- `resolve_target_class(target_device, kind, hole_count=None, volume=None, *, target_model=None)`
|
||||||
|
`workflow/common.py` 中 `_map_deck_slot` / `_infer_reagent_kind` /
|
||||||
|
`_apply_tip_rack_class_from_transfer_volumes` / `_apply_target_labware_class_auto_match` /
|
||||||
|
`_reconcile_slot_carrier_target_class` 都已转调 YAML 并透传 `target_device` / `target_model`;
|
||||||
|
YAML 未命中(孔数 / 体积超出 default 段覆盖范围)时 fallback 到
|
||||||
|
`prcxi_labware.get_prcxi_labware_template_specs` 的模板打分匹配,并打 warning 提示「请补到映射表」。
|
||||||
|
- **`labware_info` 字段重命名**:P6 的 `prcxi_class_name` → P6.1 的 `target_class_name`,
|
||||||
|
13 处全部同步刷新;旧 schema(顶层 `vendors` / `slot_remap` 或任一 rule 内 `prcxi_class`)
|
||||||
|
会触发 loader warning 并整段 fallback 到 builtin 默认表。
|
||||||
|
- **测试**:
|
||||||
|
- `pytest tests/workflow/test_labware_mapping.py` —— 45 项单元测试(含 P6.1 + P6.1.1 用例:
|
||||||
|
`test_remap_slot_model_level_overrides_device_level`、
|
||||||
|
`test_remap_slot_model_inherits_device_when_field_missing`、
|
||||||
|
`test_legacy_top_level_slot_remap_rejected`、
|
||||||
|
`test_default_section_models_subsection_warns` 等)。
|
||||||
|
- `pytest tests/workflow/test_build_protocol_graph_target_device.py` —— 6 项集成
|
||||||
|
测试(默认 / 显式 prcxi / unknown 段 fallback / per-device tip class / 字段重命名 /
|
||||||
|
P6.1.1 model-level slot_remap)。
|
||||||
|
- **设计文档**:[`product_designs/protocol_convert/06-labware-mapping-table.md`](../product_designs/protocol_convert/06-labware-mapping-table.md)
|
||||||
|
(§11.7 = P6.1 多目标仪器选择,§11.8 = P6.1.1 槽位映射按厂商+型号分叉)。
|
||||||
|
|
||||||
|
### P2 跨 slot transfer_liquid 合并(v2,已落地)
|
||||||
|
|
||||||
|
当一次 phase 中存在「单源吸取 → 跨多个 plate 分发」(典型 `steps/51b9a5.json` 9 plate × 12 well = 108 条 1:1 dispense),Stage 2 + Stage 3 现在能把它折叠成 **1 个 merged set_liquid_from_plate + 1 个 transfer_liquid** 节点。
|
||||||
|
|
||||||
|
- **Stage 2**([`Protocols/protocol_converter/change_to_transfer_group.py`](../Protocols/protocol_converter/change_to_transfer_group.py)):
|
||||||
|
- `_pair_mergeable` 只要求源 slot / tip 量程档 / use_channels 一致;不再要求 `_target_slot` 相同。
|
||||||
|
- `_merge_two_transfer_actions` 维护 `_target_slots: list[int]`(与 `_target_wells` 平行,每次 dispense 一条)。
|
||||||
|
- `export_transfer_actions` 通过 `_register_target_reagent_key` 统一注册 reagent_key:跨 slot 时按 `_target_slots` 顺序拼出 `action_args.targets: list[str]`(同板退化为 `str`)。
|
||||||
|
- 末尾 `pop` 全部 `_` 前缀字段(包括新增的 `_target_slots`)。
|
||||||
|
- **Stage 3**([`Uni-Lab-OS/unilabos/workflow/common.py`](unilabos/workflow/common.py)):
|
||||||
|
- 新增 `_emit_merged_set_liquid(...)`:对 `params.targets: list[str]` 的 transfer_liquid 节点,在其上游插入一个 **merged `set_liquid_from_plate`** 跨板聚合器;其 `param.wells` 是按 dispense 顺序通过 cursor 走 `reagent[key].well` 得出的有序跨板 well refs;多入边(每 plate 一条 `create_resource.labware → wells_identifier`),单出边(`output_wells → transfer_liquid.targets_identifier`)。
|
||||||
|
- 把 `params["targets"]` 改写为 synthetic str `_merged_targets_<idx>` 并注册 `resource_last_writer`,保证 INPUT_PORT_MAPPING 走 P3 既有的单边路径。
|
||||||
|
- `OUTPUT_PORT_MAPPING` 在原始 `step.param.targets` 为 `list[str]` 时为每个 reagent_key 分别注册 transfer_liquid 的下游 writer。
|
||||||
|
- **PRCXI runtime**([`prcxi/prcxi.py`](unilabos/devices/liquid_handling/prcxi/prcxi.py)):`change_slots` 改为遍历所有 source / target 的 parent plate 并按 plate name 去重(跨板 4 个 plate 都能 `update_pipetting_position`)。
|
||||||
|
- **`liquid_handler_abstract.transfer_liquid`**:**完全不改动**,主循环 `i % num_targets` 与单边 + 单 list 完全兼容。
|
||||||
|
|
||||||
|
CLI 行为不变:现有 `unilab workflow_upload -f <workflow.json> ...` 一切照旧;跨 slot 协议自动走 v2 路径。
|
||||||
|
|
||||||
|
测试:
|
||||||
|
- `pytest Protocols/protocol_converter/tests/test_cross_slot_merge.py` — Stage 2 单测 10 项。
|
||||||
|
- `pytest tests/workflow/test_common_cross_slot_v2.py` — Stage 3 集成测试 6 项。
|
||||||
|
- `pytest tests/devices/liquid_handling/test_set_liquid_from_plate_cross_plate.py` — device 跨板单测 6 项(pylabrobot 不全时优雅 skip)。
|
||||||
|
|
||||||
|
设计文档:[`product_designs/protocol_convert/02-cross-slot-merge.md`](../product_designs/protocol_convert/02-cross-slot-merge.md)(§9 v2 设计 + §11 落地记录)。
|
||||||
|
|
||||||
## Code Conventions
|
## Code Conventions
|
||||||
|
|
||||||
- Code comments and log messages in simplified Chinese
|
- Code comments and log messages in simplified Chinese
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Uni-Lab 使用 Python 格式的配置文件(`.py`),默认为 `unilabos_dat
|
|||||||
|
|
||||||
**获取方式:**
|
**获取方式:**
|
||||||
|
|
||||||
进入 [Uni-Lab 实验室](https://uni-lab.bohrium.com),点击左下角的头像,在实验室详情中获取所在实验室的 ak 和 sk:
|
进入 [Uni-Lab 实验室](https://leap-lab.bohrium.com),点击左下角的头像,在实验室详情中获取所在实验室的 ak 和 sk:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ class WSConfig:
|
|||||||
|
|
||||||
# HTTP配置
|
# HTTP配置
|
||||||
class HTTPConfig:
|
class HTTPConfig:
|
||||||
remote_addr = "https://uni-lab.bohrium.com/api/v1" # 远程服务器地址
|
remote_addr = "https://leap-lab.bohrium.com/api/v1" # 远程服务器地址
|
||||||
|
|
||||||
# ROS配置
|
# ROS配置
|
||||||
class ROSConfig:
|
class ROSConfig:
|
||||||
@@ -209,8 +209,8 @@ unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis -g grap
|
|||||||
|
|
||||||
`--addr` 参数支持以下预设值,会自动转换为对应的完整 URL:
|
`--addr` 参数支持以下预设值,会自动转换为对应的完整 URL:
|
||||||
|
|
||||||
- `test` → `https://uni-lab.test.bohrium.com/api/v1`
|
- `test` → `https://leap-lab.test.bohrium.com/api/v1`
|
||||||
- `uat` → `https://uni-lab.uat.bohrium.com/api/v1`
|
- `uat` → `https://leap-lab.uat.bohrium.com/api/v1`
|
||||||
- `local` → `http://127.0.0.1:48197/api/v1`
|
- `local` → `http://127.0.0.1:48197/api/v1`
|
||||||
- 其他值 → 直接使用作为完整 URL
|
- 其他值 → 直接使用作为完整 URL
|
||||||
|
|
||||||
@@ -248,7 +248,7 @@ unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis -g grap
|
|||||||
|
|
||||||
`ak` 和 `sk` 是必需的认证参数:
|
`ak` 和 `sk` 是必需的认证参数:
|
||||||
|
|
||||||
1. **获取方式**:在 [Uni-Lab 官网](https://uni-lab.bohrium.com) 注册实验室后获得
|
1. **获取方式**:在 [Uni-Lab 官网](https://leap-lab.bohrium.com) 注册实验室后获得
|
||||||
2. **配置方式**:
|
2. **配置方式**:
|
||||||
- **命令行参数**:`--ak "your_key" --sk "your_secret"`(最高优先级,推荐)
|
- **命令行参数**:`--ak "your_key" --sk "your_secret"`(最高优先级,推荐)
|
||||||
- **环境变量**:`UNILABOS_BASICCONFIG_AK` 和 `UNILABOS_BASICCONFIG_SK`
|
- **环境变量**:`UNILABOS_BASICCONFIG_AK` 和 `UNILABOS_BASICCONFIG_SK`
|
||||||
@@ -276,14 +276,14 @@ WebSocket 是 Uni-Lab 的主要通信方式:
|
|||||||
HTTP 客户端配置用于与云端服务通信:
|
HTTP 客户端配置用于与云端服务通信:
|
||||||
|
|
||||||
| 参数 | 类型 | 默认值 | 说明 |
|
| 参数 | 类型 | 默认值 | 说明 |
|
||||||
| ------------- | ---- | -------------------------------------- | ------------ |
|
| ------------- | ---- | --------------------------------------- | ------------ |
|
||||||
| `remote_addr` | str | `"https://uni-lab.bohrium.com/api/v1"` | 远程服务地址 |
|
| `remote_addr` | str | `"https://leap-lab.bohrium.com/api/v1"` | 远程服务地址 |
|
||||||
|
|
||||||
**预设环境地址**:
|
**预设环境地址**:
|
||||||
|
|
||||||
- 生产环境:`https://uni-lab.bohrium.com/api/v1`(默认)
|
- 生产环境:`https://leap-lab.bohrium.com/api/v1`(默认)
|
||||||
- 测试环境:`https://uni-lab.test.bohrium.com/api/v1`
|
- 测试环境:`https://leap-lab.test.bohrium.com/api/v1`
|
||||||
- UAT 环境:`https://uni-lab.uat.bohrium.com/api/v1`
|
- UAT 环境:`https://leap-lab.uat.bohrium.com/api/v1`
|
||||||
- 本地环境:`http://127.0.0.1:48197/api/v1`
|
- 本地环境:`http://127.0.0.1:48197/api/v1`
|
||||||
|
|
||||||
### 4. ROSConfig - ROS 配置
|
### 4. ROSConfig - ROS 配置
|
||||||
@@ -401,7 +401,7 @@ export UNILABOS_WSCONFIG_RECONNECT_INTERVAL="10"
|
|||||||
export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS="500"
|
export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS="500"
|
||||||
|
|
||||||
# 设置HTTP配置
|
# 设置HTTP配置
|
||||||
export UNILABOS_HTTPCONFIG_REMOTE_ADDR="https://uni-lab.test.bohrium.com/api/v1"
|
export UNILABOS_HTTPCONFIG_REMOTE_ADDR="https://leap-lab.test.bohrium.com/api/v1"
|
||||||
```
|
```
|
||||||
|
|
||||||
## 配置文件使用方法
|
## 配置文件使用方法
|
||||||
@@ -484,13 +484,13 @@ export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS=100
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
class HTTPConfig:
|
class HTTPConfig:
|
||||||
remote_addr = "https://uni-lab.test.bohrium.com/api/v1"
|
remote_addr = "https://leap-lab.test.bohrium.com/api/v1"
|
||||||
```
|
```
|
||||||
|
|
||||||
**环境变量方式:**
|
**环境变量方式:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export UNILABOS_HTTPCONFIG_REMOTE_ADDR=https://uni-lab.test.bohrium.com/api/v1
|
export UNILABOS_HTTPCONFIG_REMOTE_ADDR=https://leap-lab.test.bohrium.com/api/v1
|
||||||
```
|
```
|
||||||
|
|
||||||
**命令行方式(推荐):**
|
**命令行方式(推荐):**
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ Uni-Lab-OS 支持多种部署模式:
|
|||||||
```
|
```
|
||||||
┌──────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────┐
|
||||||
│ Cloud Platform/Self-hosted Platform │
|
│ Cloud Platform/Self-hosted Platform │
|
||||||
│ uni-lab.bohrium.com │
|
│ leap-lab.bohrium.com │
|
||||||
│ (Resource Management, Task Scheduling, │
|
│ (Resource Management, Task Scheduling, │
|
||||||
│ Monitoring) │
|
│ Monitoring) │
|
||||||
└────────────────────┬─────────────────────────┘
|
└────────────────────┬─────────────────────────┘
|
||||||
@@ -444,7 +444,7 @@ ros2 daemon stop && ros2 daemon start
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 测试云端连接
|
# 测试云端连接
|
||||||
curl https://uni-lab.bohrium.com/api/v1/health
|
curl https://leap-lab.bohrium.com/api/v1/health
|
||||||
|
|
||||||
# 测试WebSocket
|
# 测试WebSocket
|
||||||
# 启动Uni-Lab后查看日志
|
# 启动Uni-Lab后查看日志
|
||||||
|
|||||||
@@ -467,43 +467,58 @@ set_status(command_json)
|
|||||||
#### 2.1.9 核心方法详解:`pick_and_place`
|
#### 2.1.9 核心方法详解:`pick_and_place`
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def pick_and_place(self, command: str)
|
def pick_and_place(
|
||||||
|
self,
|
||||||
|
option: str,
|
||||||
|
move_group: str,
|
||||||
|
status: str,
|
||||||
|
resource: Optional[str] = None,
|
||||||
|
x_distance: Optional[float] = None,
|
||||||
|
y_distance: Optional[float] = None,
|
||||||
|
lift_height: Optional[float] = None,
|
||||||
|
retry: Optional[int] = None,
|
||||||
|
speed: Optional[float] = None,
|
||||||
|
target: Optional[str] = None,
|
||||||
|
constraints: Optional[Sequence[float]] = None,
|
||||||
|
) -> None:
|
||||||
```
|
```
|
||||||
|
|
||||||
这是 `MoveitInterface` 最复杂的方法,实现了完整的抓取-放置工作流。它动态构建一个**有序函数列表** (`function_list`),然后顺序执行。
|
这是 `MoveitInterface` 最复杂的方法,实现了完整的抓取-放置工作流。它动态构建一个**有序函数列表** (`function_list`),然后顺序执行。
|
||||||
|
|
||||||
**JSON 指令格式(完整参数):**
|
**破坏性变更(注册表 / 客户端)**:`pick_and_place` 在注册表中由 **SendCmd**(单字段 `command` JSON 字符串)改为 **UniLabJsonCommand**,goal 为与上表同名的**结构化字段**。云端与其它调用方需按 schema 提交 goal,而不再把整段 JSON 塞进 `command`。
|
||||||
|
|
||||||
|
**动作 goal 示例(字段与旧版 JSON 一致,现为结构化 goal):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"option": "pick", // *必须: pick/place/side_pick/side_place
|
"option": "pick",
|
||||||
"move_group": "arm", // *必须: MoveIt2 规划组名
|
"move_group": "arm",
|
||||||
"status": "pick_station_A", // *必须: 在 joint_poses 中的目标状态名
|
"status": "pick_station_A",
|
||||||
"resource": "beaker_1", // 要操作的资源名称
|
"resource": "beaker_1",
|
||||||
"target": "custom_link", // pick 时资源附着的目标 link (默认末端执行器)
|
"target": "custom_link",
|
||||||
"lift_height": 0.05, // 抬升高度 (米)
|
"lift_height": 0.05,
|
||||||
"x_distance": 0.1, // X 方向水平移动距离 (米)
|
"x_distance": 0.1,
|
||||||
"y_distance": 0.0, // Y 方向水平移动距离 (米)
|
"y_distance": 0.0,
|
||||||
"speed": 0.5, // 运动速度因子 (0.1~1.0)
|
"speed": 0.5,
|
||||||
"retry": 10, // 规划失败重试次数
|
"retry": 10,
|
||||||
"constraints": [0, 0, 0, 0.5, 0, 0] // 各关节约束容差 (>0 时生效)
|
"constraints": [0, 0, 0, 0.5, 0, 0]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
##### 阶段 1:指令解析与动作类型判定
|
##### 阶段 1:参数与动作类型判定
|
||||||
|
|
||||||
```
|
```
|
||||||
pick_and_place(command_json)
|
pick_and_place(option, move_group, status, ...)
|
||||||
│
|
│
|
||||||
├── JSON 解析
|
├── 校验 option ∈ move_option,否则直接 return
|
||||||
├── 动作类型判定:
|
├── 动作类型判定:
|
||||||
│ move_option = ["pick", "place", "side_pick", "side_place"]
|
│ move_option = ["pick", "place", "side_pick", "side_place"]
|
||||||
│ 0 1 2 3
|
│ 0 1 2 3
|
||||||
│ option_index = move_option.index(cmd["option"])
|
│ option_index = move_option.index(option)
|
||||||
│ place_flag = option_index % 2 ← 0=pick类, 1=place类
|
│ place_flag = option_index % 2 ← 0=pick类, 1=place类
|
||||||
│
|
│
|
||||||
├── 提取运动参数:
|
├── 提取运动参数:
|
||||||
│ config = {speed, retry, move_group} ← 从 cmd_dict 中按需提取
|
│ config = { move_group };若 speed/retry 非 None 则写入 config
|
||||||
│
|
│
|
||||||
└── 获取目标关节位姿:
|
└── 获取目标关节位姿:
|
||||||
joint_positions_ = joint_poses[move_group][status]
|
joint_positions_ = joint_poses[move_group][status]
|
||||||
@@ -515,7 +530,7 @@ pick_and_place(command_json)
|
|||||||
根据 place_flag 决定资源 TF 操作:
|
根据 place_flag 决定资源 TF 操作:
|
||||||
|
|
||||||
if pick 类 (place_flag == 0):
|
if pick 类 (place_flag == 0):
|
||||||
if "target" 已指定:
|
if target is not None:
|
||||||
function_list += [resource_manager(resource, target)] ← 挂到自定义 link
|
function_list += [resource_manager(resource, target)] ← 挂到自定义 link
|
||||||
else:
|
else:
|
||||||
function_list += [resource_manager(resource, end_effector)] ← 挂到末端执行器
|
function_list += [resource_manager(resource, end_effector)] ← 挂到末端执行器
|
||||||
@@ -527,7 +542,7 @@ pick_and_place(command_json)
|
|||||||
##### 阶段 3:构建关节约束
|
##### 阶段 3:构建关节约束
|
||||||
|
|
||||||
```
|
```
|
||||||
if "constraints" 存在于指令中:
|
if constraints is not None:
|
||||||
for i, tolerance in enumerate(constraints):
|
for i, tolerance in enumerate(constraints):
|
||||||
if tolerance > 0:
|
if tolerance > 0:
|
||||||
JointConstraint(
|
JointConstraint(
|
||||||
@@ -546,7 +561,7 @@ if "constraints" 存在于指令中:
|
|||||||
这是最复杂的场景,涉及 FK/IK 计算和多段运动拼接:
|
这是最复杂的场景,涉及 FK/IK 计算和多段运动拼接:
|
||||||
|
|
||||||
```
|
```
|
||||||
if "lift_height" 存在:
|
if lift_height is not None:
|
||||||
│
|
│
|
||||||
├── Step 1: FK 计算 → 获取目标关节配置对应的末端位姿
|
├── Step 1: FK 计算 → 获取目标关节配置对应的末端位姿
|
||||||
│ retval = compute_fk(joint_positions_) ← 可能需要重试
|
│ retval = compute_fk(joint_positions_) ← 可能需要重试
|
||||||
@@ -562,12 +577,12 @@ if "lift_height" 存在:
|
|||||||
│ function_list += [moveit_task(position=pose_lifted, ...)]
|
│ function_list += [moveit_task(position=pose_lifted, ...)]
|
||||||
│
|
│
|
||||||
├── Step 4 (可选): 水平移动
|
├── Step 4 (可选): 水平移动
|
||||||
│ if "x_distance":
|
│ if x_distance is not None:
|
||||||
│ deep_pose = copy(pose_lifted)
|
│ deep_pose = copy(pose_lifted)
|
||||||
│ deep_pose[0] += x_distance
|
│ deep_pose[0] += x_distance
|
||||||
│ function_list = [moveit_task(pose_lifted)] + function_list
|
│ function_list = [moveit_task(pose_lifted)] + function_list
|
||||||
│ function_list += [moveit_task(deep_pose)]
|
│ function_list += [moveit_task(deep_pose)]
|
||||||
│ elif "y_distance":
|
│ elif y_distance is not None:
|
||||||
│ 类似处理 Y 方向
|
│ 类似处理 Y 方向
|
||||||
│
|
│
|
||||||
├── Step 5: IK 预计算 → 将末端位姿转换为安全的关节配置
|
├── Step 5: IK 预计算 → 将末端位姿转换为安全的关节配置
|
||||||
@@ -585,7 +600,7 @@ if "lift_height" 存在:
|
|||||||
##### 阶段 4B:无 `lift_height` 的简单流程
|
##### 阶段 4B:无 `lift_height` 的简单流程
|
||||||
|
|
||||||
```
|
```
|
||||||
else (无 lift_height):
|
else (lift_height is None):
|
||||||
│
|
│
|
||||||
└── 直接关节运动到目标位姿
|
└── 直接关节运动到目标位姿
|
||||||
function_list = [moveit_joint_task(joint_positions_)] + function_list
|
function_list = [moveit_joint_task(joint_positions_)] + function_list
|
||||||
@@ -600,10 +615,10 @@ for i, func in enumerate(function_list):
|
|||||||
│ i == 0: cartesian_flag = False ← 第一步用自由空间规划(大范围移动)
|
│ i == 0: cartesian_flag = False ← 第一步用自由空间规划(大范围移动)
|
||||||
│ i > 0: cartesian_flag = True ← 后续用笛卡尔直线规划(精确控制)
|
│ i > 0: cartesian_flag = True ← 后续用笛卡尔直线规划(精确控制)
|
||||||
│
|
│
|
||||||
├── result = func() ← 执行动作
|
├── re = func() ← 执行动作
|
||||||
│
|
│
|
||||||
└── if not result:
|
└── if not re:
|
||||||
return failure ← 任一步骤失败即中止
|
return(无返回值,不构造 SendCmd.Result)← 任一步骤失败即中止
|
||||||
```
|
```
|
||||||
|
|
||||||
##### 完整 pick 流程示例(含 lift_height + x_distance)
|
##### 完整 pick 流程示例(含 lift_height + x_distance)
|
||||||
@@ -657,10 +672,10 @@ for i, func in enumerate(function_list):
|
|||||||
| `.move_to_pose(...)` | `moveit_task` L129-137 | 笛卡尔空间运动规划与执行 |
|
| `.move_to_pose(...)` | `moveit_task` L129-137 | 笛卡尔空间运动规划与执行 |
|
||||||
| `.wait_until_executed()` | `moveit_task` L138, `moveit_joint_task` L157 | 阻塞等待运动完成 |
|
| `.wait_until_executed()` | `moveit_task` L138, `moveit_joint_task` L157 | 阻塞等待运动完成 |
|
||||||
| `.move_to_configuration(...)` | `moveit_joint_task` L156 | 关节空间运动规划与执行 |
|
| `.move_to_configuration(...)` | `moveit_joint_task` L156 | 关节空间运动规划与执行 |
|
||||||
| `.compute_fk(...)` | `pick_and_place` L244, `moveit_joint_task` L160 | 正运动学:关节角 → 末端位姿 |
|
| `.compute_fk(...)` | `pick_and_place`, `moveit_joint_task` | 正运动学:关节角 → 末端位姿 |
|
||||||
| `.compute_ik(...)` | `pick_and_place` L298-300 | 逆运动学:末端位姿 → 关节角(含约束) |
|
| `.compute_ik(...)` | `pick_and_place` | 逆运动学:末端位姿 → 关节角(含约束) |
|
||||||
| `.end_effector_name` | `pick_and_place` L218 | 获取末端执行器 link 名 |
|
| `.end_effector_name` | `pick_and_place` | 获取末端执行器 link 名 |
|
||||||
| `.joint_names` | `pick_and_place` L232, L308, L313 | 获取关节名列表 |
|
| `.joint_names` | `pick_and_place` | 获取关节名列表 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -668,11 +683,11 @@ for i, func in enumerate(function_list):
|
|||||||
|
|
||||||
| 场景 | 处理方式 |
|
| 场景 | 处理方式 |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| FK 计算失败 | 最多重试 `retry` 次(每次间隔 0.1s),超时返回 `result.success = False` |
|
| FK 计算失败 | 最多重试 `retry` 次(每次间隔 0.1s),超时则提前 `return`(无返回值) |
|
||||||
| IK 计算失败 | 同上 |
|
| IK 计算失败 | 同上 |
|
||||||
| 运动规划失败 | 在 `moveit_task` / `moveit_joint_task` 中最多重试 `retry+1` 次 |
|
| 运动规划失败 | 在 `moveit_task` / `moveit_joint_task` 中最多重试 `retry+1` 次 |
|
||||||
| 动作序列中任一步失败 | `pick_and_place` 立即中止并返回 `result.success = False` |
|
| 动作序列中任一步失败 | `pick_and_place` 立即中止并 `return`(不返回 `SendCmd.Result`) |
|
||||||
| 未知异常 | `pick_and_place` 和 `set_status` 捕获 Exception,重置 `cartesian_flag`,返回失败 |
|
| 未知异常 | `pick_and_place` 捕获 Exception,打印并重置 `cartesian_flag`(`set_status` 仍返回 SendCmd.Result) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -702,7 +717,8 @@ for i, func in enumerate(function_list):
|
|||||||
```
|
```
|
||||||
外部系统 (base_device_node)
|
外部系统 (base_device_node)
|
||||||
│
|
│
|
||||||
│ JSON 指令字符串
|
│ set_position/set_status: JSON 指令字符串(SendCmd.command)
|
||||||
|
│ pick_and_place: UniLabJsonCommand 结构化 goal → Python 关键字参数
|
||||||
▼
|
▼
|
||||||
┌── MoveitInterface ──────────────────────────────────────────────────┐
|
┌── MoveitInterface ──────────────────────────────────────────────────┐
|
||||||
│ │
|
│ │
|
||||||
@@ -710,7 +726,7 @@ for i, func in enumerate(function_list):
|
|||||||
│ │
|
│ │
|
||||||
│ set_status(cmd) ──→ moveit_joint_task() ──→ MoveIt2.move_to_config│
|
│ set_status(cmd) ──→ moveit_joint_task() ──→ MoveIt2.move_to_config│
|
||||||
│ │
|
│ │
|
||||||
│ pick_and_place(cmd) │
|
│ pick_and_place(option, move_group, status, ...) │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ ├─ MoveIt2.compute_fk() ─── /compute_fk service ──→ move_group │
|
│ ├─ MoveIt2.compute_fk() ─── /compute_fk service ──→ move_group │
|
||||||
│ ├─ MoveIt2.compute_ik() ─── /compute_ik service ──→ move_group │
|
│ ├─ MoveIt2.compute_ik() ─── /compute_ik service ──→ move_group │
|
||||||
@@ -963,7 +979,7 @@ robotic_arm.SCARA_with_slider.moveit.virtual:
|
|||||||
module: unilabos.devices.ros_dev.moveit_interface:MoveitInterface
|
module: unilabos.devices.ros_dev.moveit_interface:MoveitInterface
|
||||||
type: python
|
type: python
|
||||||
action_value_mappings:
|
action_value_mappings:
|
||||||
pick_and_place: ... # SendCmd Action(JSON 指令)
|
pick_and_place: ... # UniLabJsonCommand(结构化 goal,与 Python 签名一致)
|
||||||
set_position: ... # SendCmd Action
|
set_position: ... # SendCmd Action
|
||||||
set_status: ... # SendCmd Action
|
set_status: ... # SendCmd Action
|
||||||
auto-moveit_task: ... # 自动发现的方法(UniLabJsonCommand)
|
auto-moveit_task: ... # 自动发现的方法(UniLabJsonCommand)
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
**选择合适的安装包:**
|
**选择合适的安装包:**
|
||||||
|
|
||||||
| 安装包 | 适用场景 | 包含组件 |
|
| 安装包 | 适用场景 | 包含组件 |
|
||||||
|--------|----------|----------|
|
| --------------- | ---------------------------- | --------------------------------------------- |
|
||||||
| `unilabos` | **推荐大多数用户**,生产部署 | 完整安装包,开箱即用 |
|
| `unilabos` | **推荐大多数用户**,生产部署 | 完整安装包,开箱即用 |
|
||||||
| `unilabos-env` | 开发者(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos |
|
| `unilabos-env` | 开发者(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos |
|
||||||
| `unilabos-full` | 仿真/可视化 | unilabos + 完整 ROS2 桌面版 + Gazebo + MoveIt |
|
| `unilabos-full` | 仿真/可视化 | unilabos + 完整 ROS2 桌面版 + Gazebo + MoveIt |
|
||||||
@@ -66,6 +66,7 @@ mamba install uni-lab::unilabos-full -c robostack-staging -c conda-forge
|
|||||||
```
|
```
|
||||||
|
|
||||||
**选择建议:**
|
**选择建议:**
|
||||||
|
|
||||||
- **日常使用/生产部署**:使用 `unilabos`(推荐),完整功能,开箱即用
|
- **日常使用/生产部署**:使用 `unilabos`(推荐),完整功能,开箱即用
|
||||||
- **开发者**:使用 `unilabos-env` + `pip install -e .` + `uv pip install -r unilabos/utils/requirements.txt`,代码修改立即生效
|
- **开发者**:使用 `unilabos-env` + `pip install -e .` + `uv pip install -r unilabos/utils/requirements.txt`,代码修改立即生效
|
||||||
- **仿真/可视化**:使用 `unilabos-full`,含 Gazebo、rviz2、MoveIt
|
- **仿真/可视化**:使用 `unilabos-full`,含 Gazebo、rviz2、MoveIt
|
||||||
@@ -88,7 +89,7 @@ python -c "from unilabos_msgs.msg import Resource; print('ROS msgs OK')"
|
|||||||
|
|
||||||
#### 2.1 注册实验室账号
|
#### 2.1 注册实验室账号
|
||||||
|
|
||||||
1. 访问 [https://uni-lab.bohrium.com](https://uni-lab.bohrium.com)
|
1. 访问 [https://leap-lab.bohrium.com](https://leap-lab.bohrium.com)
|
||||||
2. 注册账号并登录
|
2. 注册账号并登录
|
||||||
3. 创建新实验室
|
3. 创建新实验室
|
||||||
|
|
||||||
@@ -297,7 +298,7 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
|
|||||||
|
|
||||||
#### 5.2 访问 Web 界面
|
#### 5.2 访问 Web 界面
|
||||||
|
|
||||||
启动系统后,访问[https://uni-lab.bohrium.com](https://uni-lab.bohrium.com)
|
启动系统后,访问[https://leap-lab.bohrium.com](https://leap-lab.bohrium.com)
|
||||||
|
|
||||||
#### 5.3 添加设备和物料
|
#### 5.3 添加设备和物料
|
||||||
|
|
||||||
@@ -306,12 +307,10 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
|
|||||||
**示例场景:** 创建一个简单的液体转移实验
|
**示例场景:** 创建一个简单的液体转移实验
|
||||||
|
|
||||||
1. **添加工作站(必需):**
|
1. **添加工作站(必需):**
|
||||||
|
|
||||||
- 在"仪器设备"中找到 `work_station`
|
- 在"仪器设备"中找到 `work_station`
|
||||||
- 添加 `workstation` x1
|
- 添加 `workstation` x1
|
||||||
|
|
||||||
2. **添加虚拟转移泵:**
|
2. **添加虚拟转移泵:**
|
||||||
|
|
||||||
- 在"仪器设备"中找到 `virtual_device`
|
- 在"仪器设备"中找到 `virtual_device`
|
||||||
- 添加 `virtual_transfer_pump` x1
|
- 添加 `virtual_transfer_pump` x1
|
||||||
|
|
||||||
@@ -818,6 +817,7 @@ uv pip install -r unilabos/utils/requirements.txt
|
|||||||
```
|
```
|
||||||
|
|
||||||
**为什么使用这种方式?**
|
**为什么使用这种方式?**
|
||||||
|
|
||||||
- `unilabos-env` 提供 ROS2 核心组件和 uv(通过 conda 安装,避免编译)
|
- `unilabos-env` 提供 ROS2 核心组件和 uv(通过 conda 安装,避免编译)
|
||||||
- `unilabos/utils/requirements.txt` 包含所有运行时需要的 pip 依赖
|
- `unilabos/utils/requirements.txt` 包含所有运行时需要的 pip 依赖
|
||||||
- `dev_install.py` 自动检测中文环境,中文系统自动使用清华镜像
|
- `dev_install.py` 自动检测中文环境,中文系统自动使用清华镜像
|
||||||
@@ -1796,32 +1796,27 @@ unilab --ak your_ak --sk your_sk -g graph.json \
|
|||||||
**详细步骤:**
|
**详细步骤:**
|
||||||
|
|
||||||
1. **需求分析**:
|
1. **需求分析**:
|
||||||
|
|
||||||
- 明确实验流程
|
- 明确实验流程
|
||||||
- 列出所需设备和物料
|
- 列出所需设备和物料
|
||||||
- 设计工作流程图
|
- 设计工作流程图
|
||||||
|
|
||||||
2. **环境搭建**:
|
2. **环境搭建**:
|
||||||
|
|
||||||
- 安装 Uni-Lab-OS
|
- 安装 Uni-Lab-OS
|
||||||
- 创建实验室账号
|
- 创建实验室账号
|
||||||
- 准备开发工具(IDE、Git)
|
- 准备开发工具(IDE、Git)
|
||||||
|
|
||||||
3. **原型验证**:
|
3. **原型验证**:
|
||||||
|
|
||||||
- 使用虚拟设备测试流程
|
- 使用虚拟设备测试流程
|
||||||
- 验证工作流逻辑
|
- 验证工作流逻辑
|
||||||
- 调整参数
|
- 调整参数
|
||||||
|
|
||||||
4. **迭代开发**:
|
4. **迭代开发**:
|
||||||
|
|
||||||
- 实现自定义设备驱动(同时撰写单点函数测试)
|
- 实现自定义设备驱动(同时撰写单点函数测试)
|
||||||
- 编写注册表
|
- 编写注册表
|
||||||
- 单元测试
|
- 单元测试
|
||||||
- 集成测试
|
- 集成测试
|
||||||
|
|
||||||
5. **测试部署**:
|
5. **测试部署**:
|
||||||
|
|
||||||
- 连接真实硬件
|
- 连接真实硬件
|
||||||
- 空跑测试
|
- 空跑测试
|
||||||
- 小规模试验
|
- 小规模试验
|
||||||
@@ -1871,7 +1866,7 @@ unilab --ak your_ak --sk your_sk -g graph.json \
|
|||||||
#### 14.5 社区支持
|
#### 14.5 社区支持
|
||||||
|
|
||||||
- **GitHub Issues**:[https://github.com/deepmodeling/Uni-Lab-OS/issues](https://github.com/deepmodeling/Uni-Lab-OS/issues)
|
- **GitHub Issues**:[https://github.com/deepmodeling/Uni-Lab-OS/issues](https://github.com/deepmodeling/Uni-Lab-OS/issues)
|
||||||
- **官方网站**:[https://uni-lab.bohrium.com](https://uni-lab.bohrium.com)
|
- **官方网站**:[https://leap-lab.bohrium.com](https://leap-lab.bohrium.com)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -626,7 +626,7 @@ unilab
|
|||||||
|
|
||||||
**云端图文件管理**:
|
**云端图文件管理**:
|
||||||
|
|
||||||
1. 登录 https://uni-lab.bohrium.com
|
1. 登录 https://leap-lab.bohrium.com
|
||||||
2. 进入"设备配置"
|
2. 进入"设备配置"
|
||||||
3. 创建或编辑配置
|
3. 创建或编辑配置
|
||||||
4. 保存到云端
|
4. 保存到云端
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ Uni-Lab 的启动过程分为以下几个阶段:
|
|||||||
您可以直接跟随 unilabos 的提示进行,无需查阅本节
|
您可以直接跟随 unilabos 的提示进行,无需查阅本节
|
||||||
|
|
||||||
- **工作目录设置**:
|
- **工作目录设置**:
|
||||||
|
|
||||||
- 如果当前目录以 `unilabos_data` 结尾,则使用当前目录
|
- 如果当前目录以 `unilabos_data` 结尾,则使用当前目录
|
||||||
- 否则使用 `当前目录/unilabos_data` 作为工作目录
|
- 否则使用 `当前目录/unilabos_data` 作为工作目录
|
||||||
- 可通过 `--working_dir` 指定自定义工作目录
|
- 可通过 `--working_dir` 指定自定义工作目录
|
||||||
@@ -68,8 +67,8 @@ Uni-Lab 的启动过程分为以下几个阶段:
|
|||||||
|
|
||||||
支持多种后端环境:
|
支持多种后端环境:
|
||||||
|
|
||||||
- `--addr test`:测试环境 (`https://uni-lab.test.bohrium.com/api/v1`)
|
- `--addr test`:测试环境 (`https://leap-lab.test.bohrium.com/api/v1`)
|
||||||
- `--addr uat`:UAT 环境 (`https://uni-lab.uat.bohrium.com/api/v1`)
|
- `--addr uat`:UAT 环境 (`https://leap-lab.uat.bohrium.com/api/v1`)
|
||||||
- `--addr local`:本地环境 (`http://127.0.0.1:48197/api/v1`)
|
- `--addr local`:本地环境 (`http://127.0.0.1:48197/api/v1`)
|
||||||
- 自定义地址:直接指定完整 URL
|
- 自定义地址:直接指定完整 URL
|
||||||
|
|
||||||
@@ -176,7 +175,7 @@ unilab --config path/to/your/config.py
|
|||||||
|
|
||||||
如果是首次使用,系统会:
|
如果是首次使用,系统会:
|
||||||
|
|
||||||
1. 提示前往 https://uni-lab.bohrium.com 注册实验室
|
1. 提示前往 https://leap-lab.bohrium.com 注册实验室
|
||||||
2. 引导创建配置文件
|
2. 引导创建配置文件
|
||||||
3. 设置工作目录
|
3. 设置工作目录
|
||||||
|
|
||||||
@@ -216,7 +215,7 @@ unilab --ak your_ak --sk your_sk --port 8080 --disable_browser
|
|||||||
|
|
||||||
如果提示 "后续运行必须拥有一个实验室",请确保:
|
如果提示 "后续运行必须拥有一个实验室",请确保:
|
||||||
|
|
||||||
- 已在 https://uni-lab.bohrium.com 注册实验室
|
- 已在 https://leap-lab.bohrium.com 注册实验室
|
||||||
- 正确设置了 `--ak` 和 `--sk` 参数
|
- 正确设置了 `--ak` 和 `--sk` 参数
|
||||||
- 配置文件中包含正确的认证信息
|
- 配置文件中包含正确的认证信息
|
||||||
|
|
||||||
|
|||||||
140
labware_mapping.yaml
Normal file
140
labware_mapping.yaml
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
# Opentrons → 目标仪器 物料映射表(P6.1.1)
|
||||||
|
#
|
||||||
|
# 两段顶层 key(P6.1.1 起 slot_remap 从顶层下沉到 target_devices 内):
|
||||||
|
# kinds : labware 字符串 → kind 归类(与目标仪器无关,**保留全局**)
|
||||||
|
# target_devices : 按目标仪器 + 型号组织;rule = kind + hole_count + volume_min/max → class_name;
|
||||||
|
# slot_remap 也内嵌在 target_devices 下(按 deck 物理布局变化)
|
||||||
|
#
|
||||||
|
# target_devices 段内结构:
|
||||||
|
# target_devices.<device>: # 厂商段(必填)
|
||||||
|
# slot_remap: {...} # 厂商级默认 slot 映射(缺失 → 继承 default 段)
|
||||||
|
# rules: [...] # 厂商级规则(缺失 → 继承 default 段)
|
||||||
|
# models: # 同厂商多型号(可选;缺失 = 仅厂商级,不区分型号)
|
||||||
|
# <model_name>: # 型号子段
|
||||||
|
# slot_remap: {...} # 型号级覆盖(缺失 → 继承厂商级)
|
||||||
|
# rules: [...] # 型号级覆盖(缺失 → 继承厂商级)
|
||||||
|
#
|
||||||
|
# 段名约定:
|
||||||
|
# target_devices.default : 兜底物料集 + 兜底 slot_remap。caller 传未声明的 target_device 时使用此段。
|
||||||
|
# **不支持 models 子段**(型号粒度差异必须落到具体仪器段,否则歧义)。
|
||||||
|
# target_devices.<name> : 具体仪器段(prcxi / beckman / tecan ...)。
|
||||||
|
#
|
||||||
|
# 解析链(remap_slot / resolve_target_class 共用,字段级 fallback):
|
||||||
|
# 1. target_devices.<device>.models.<model>.<field> (caller 同时传 device + model)
|
||||||
|
# 2. target_devices.<device>.<field> (caller 传 device,或步骤 1 缺字段)
|
||||||
|
# 3. target_devices.default.<field> (caller 传未声明 device,或步骤 2 缺字段)
|
||||||
|
# 4. _BUILTIN_DEFAULT.target_devices.default.<field> (YAML 误删 default 段时的最后兜底)
|
||||||
|
#
|
||||||
|
# 编辑建议:
|
||||||
|
# 1. 顺序敏感:kinds 与 rules 内首个命中胜出;窄规则在前、宽规则在后。
|
||||||
|
# 2. volume_min / volume_max 是闭区间(µL)。任一字段可省略;都省略 = 不限制体积。
|
||||||
|
# 3. notes 仅作注释,不参与匹配。
|
||||||
|
# 4. 新增目标仪器:复制 target_devices.prcxi 段、改 device 名、改 slot_remap + rules。
|
||||||
|
# 5. 同厂商不同型号:在 target_devices.<device>.models.<model> 下显式覆盖差异字段;
|
||||||
|
# 没声明的字段自动继承厂商级。
|
||||||
|
# 6. P6.1.1 不再支持顶层 slot_remap;检出顶层 slot_remap → warning + fallback 到 builtin。
|
||||||
|
#
|
||||||
|
# 设计文档:product_designs/protocol_convert/06-labware-mapping-table.md(§11.8)
|
||||||
|
|
||||||
|
kinds:
|
||||||
|
# 顺序敏感的 regex;第一个命中胜出
|
||||||
|
# 注意:trash 必须在 tip_rack 之前;tip_rack 必须在 tube_rack 之前("tuberack" 含 "rack")
|
||||||
|
- { pattern: "trash", kind: trash }
|
||||||
|
- { pattern: "tiprack|tip[_ ]?rack|opentrons_\\d+_tiprack", kind: tip_rack }
|
||||||
|
- { pattern: "tuberack|tube[_ ]rack|eppendorf.*rack|safelock.*rack", kind: tube_rack }
|
||||||
|
# 「<labware> 含 'rack' 但不含 'tip'」也归到 tube_rack(与历史 _infer_reagent_kind 行为一致)
|
||||||
|
- { pattern: "(?:^|[^a-z])rack(?:[^a-z]|$)", kind: tube_rack }
|
||||||
|
- { pattern: ".*", kind: plate }
|
||||||
|
|
||||||
|
target_devices:
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
# default:兜底物料集 + 兜底 slot_remap。
|
||||||
|
# caller 传未声明的 target_device 时使用本段;**不支持 models 子段**。
|
||||||
|
# 第一版内容按 prcxi 拷贝填充(值仍是 PRCXI_*),但语义独立,可独立演进。
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
default:
|
||||||
|
notes: "默认兜底物料集;caller 传未声明 target_device 时使用此段。第一版按 prcxi 拷贝填充。"
|
||||||
|
slot_remap:
|
||||||
|
# raw slot → deck slot;与对象类型无关
|
||||||
|
default:
|
||||||
|
"4": "13"
|
||||||
|
"8": "14"
|
||||||
|
# 按 object 字段覆盖 default
|
||||||
|
by_object:
|
||||||
|
trash:
|
||||||
|
"12": "16"
|
||||||
|
rules:
|
||||||
|
# ─ tip rack(默认量程档:≤10 / <300 / 否则 1000) ─
|
||||||
|
- { kind: tip_rack, hole_count: 96, volume_max: 10, class_name: PRCXI_10uL_Tips }
|
||||||
|
- { kind: tip_rack, hole_count: 96, volume_max: 299.9, class_name: PRCXI_300ul_Tips }
|
||||||
|
- { kind: tip_rack, hole_count: 96, class_name: PRCXI_1000uL_Tips }
|
||||||
|
# ─ tube rack ─
|
||||||
|
- { kind: tube_rack, hole_count: 24, class_name: PRCXI_EP_Adapter, notes: "Eppendorf 1.5/2 mL 24 位 4×6" }
|
||||||
|
- { kind: tube_rack, hole_count: 10, class_name: PRCXI_EP_Adapter, notes: "Falcon 4x50 + 6x15 mL(10 位兼容 4×6 适配器)" }
|
||||||
|
# ─ plate ─
|
||||||
|
- { kind: plate, hole_count: 96, class_name: PRCXI_BioER_96_wellplate }
|
||||||
|
- { kind: plate, hole_count: 384, class_name: PRCXI_BioER_384_wellplate }
|
||||||
|
# ─ trash ─
|
||||||
|
- { kind: trash, class_name: PRCXI_trash }
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
# prcxi:PRCXI 仪器专用段。caller 显式传 --target_device prcxi 时命中此段。
|
||||||
|
# 厂商级 slot_remap + rules 适用于"未声明 model"的调用;
|
||||||
|
# models 子段下声明同厂商不同型号的 deck 物理布局差异。
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
prcxi:
|
||||||
|
slot_remap:
|
||||||
|
# PRCXI 多数型号通用的 deck 物理布局映射
|
||||||
|
default:
|
||||||
|
"4": "13"
|
||||||
|
"8": "14"
|
||||||
|
by_object:
|
||||||
|
trash:
|
||||||
|
"12": "16"
|
||||||
|
rules:
|
||||||
|
# ─ tip rack(PRCXI 量程档:≤10 / <300 / 否则 1000) ─
|
||||||
|
- { kind: tip_rack, hole_count: 96, volume_max: 10, class_name: PRCXI_10uL_Tips }
|
||||||
|
- { kind: tip_rack, hole_count: 96, volume_max: 299.9, class_name: PRCXI_300ul_Tips }
|
||||||
|
- { kind: tip_rack, hole_count: 96, class_name: PRCXI_1000uL_Tips }
|
||||||
|
# ─ tube rack ─
|
||||||
|
- { kind: tube_rack, hole_count: 24, class_name: PRCXI_EP_Adapter, notes: "Eppendorf 1.5/2 mL 24 位 4×6" }
|
||||||
|
- { kind: tube_rack, hole_count: 10, class_name: PRCXI_EP_Adapter, notes: "Falcon 4x50 + 6x15 mL(10 位兼容 4×6 适配器)" }
|
||||||
|
# ─ plate ─
|
||||||
|
- { kind: plate, hole_count: 96, class_name: PRCXI_BioER_96_wellplate }
|
||||||
|
- { kind: plate, hole_count: 384, class_name: PRCXI_BioER_384_wellplate }
|
||||||
|
# ─ trash ─
|
||||||
|
- { kind: trash, class_name: PRCXI_trash }
|
||||||
|
models:
|
||||||
|
# PRCXI 9320 —— 与厂商级完全一致(空 dict 仅作为合法 model 名占位)。
|
||||||
|
# caller `--target_model 9320` 时所有字段继承厂商级 prcxi 段。
|
||||||
|
"9320": {}
|
||||||
|
# 演示:假想 PRCXI 4040 把 slot 4 物理位换到 16、trash 槽换到 20。
|
||||||
|
# 仅 slot_remap 不同;rules 与厂商级一致 → 不重复声明(自动继承)。
|
||||||
|
"4040":
|
||||||
|
slot_remap:
|
||||||
|
default:
|
||||||
|
"4": "16"
|
||||||
|
"8": "14"
|
||||||
|
by_object:
|
||||||
|
trash:
|
||||||
|
"12": "20"
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
# 演示:未来加新仪器只复制 prcxi 段、改 device 名 + slot_remap + rules。
|
||||||
|
# 特别注意 tip 量程档可与 PRCXI 不同。
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
# beckman:
|
||||||
|
# slot_remap:
|
||||||
|
# default: {"4": "13"}
|
||||||
|
# by_object: {trash: {"12": "16"}}
|
||||||
|
# rules:
|
||||||
|
# - { kind: tip_rack, hole_count: 96, volume_max: 20, class_name: Beckman_20uL_Tips }
|
||||||
|
# - { kind: tip_rack, hole_count: 96, volume_max: 199.9, class_name: Beckman_200uL_Tips }
|
||||||
|
# - { kind: tip_rack, hole_count: 96, class_name: Beckman_1000uL_Tips }
|
||||||
|
# - { kind: tube_rack, hole_count: 24, class_name: Beckman_24_TubeRack }
|
||||||
|
# - { kind: plate, hole_count: 96, class_name: Beckman_BioMek_96_wellplate }
|
||||||
|
# - { kind: trash, class_name: Beckman_Trash }
|
||||||
|
# models:
|
||||||
|
# "i7":
|
||||||
|
# slot_remap:
|
||||||
|
# default: {"4": "13", "5": "14"} # 假想 i7 多一个 slot 重映射
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
channel_sources:
|
channel_sources:
|
||||||
- robostack,robostack-staging,conda-forge,defaults
|
- robostack,robostack-staging,conda-forge
|
||||||
|
|
||||||
gazebo:
|
gazebo:
|
||||||
- '11'
|
- '11'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package:
|
package:
|
||||||
name: ros-humble-unilabos-msgs
|
name: ros-humble-unilabos-msgs
|
||||||
version: 0.10.19
|
version: 0.11.1
|
||||||
source:
|
source:
|
||||||
path: ../../unilabos_msgs
|
path: ../../unilabos_msgs
|
||||||
target_directory: src
|
target_directory: src
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package:
|
package:
|
||||||
name: unilabos
|
name: unilabos
|
||||||
version: "0.10.19"
|
version: "0.11.1"
|
||||||
|
|
||||||
source:
|
source:
|
||||||
path: ../..
|
path: ../..
|
||||||
|
|||||||
2
setup.py
2
setup.py
@@ -4,7 +4,7 @@ package_name = 'unilabos'
|
|||||||
|
|
||||||
setup(
|
setup(
|
||||||
name=package_name,
|
name=package_name,
|
||||||
version='0.10.19',
|
version='0.11.1',
|
||||||
packages=find_packages(),
|
packages=find_packages(),
|
||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
install_requires=['setuptools'],
|
install_requires=['setuptools'],
|
||||||
|
|||||||
244
tests/devices/liquid_handling/test_liquid_history.py
Normal file
244
tests/devices/liquid_handling/test_liquid_history.py
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
"""P9 — ``liquid_history`` schema v3 + helper 单元测试。
|
||||||
|
|
||||||
|
测试覆盖:
|
||||||
|
- :func:`append_liquid_history`:写 v3 entry / tracker 缺失 graceful / 滚动上限
|
||||||
|
- :func:`normalize_liquid_history`:v3 dict / v2 tuple / list[str] / 混合 / 非法
|
||||||
|
- :func:`well_current_liquid_name`:tracker.liquids 末项 / get_liquids fallback / 缺失
|
||||||
|
|
||||||
|
注:``LiquidHandlerAbstract.set_liquid`` 写 history 的集成("set" action)覆盖
|
||||||
|
逻辑相同(直接调用 :func:`append_liquid_history`),由本测试间接验证;端到端走 PLR
|
||||||
|
真实 ``Well.set_liquids`` 的集成测试在 ``tests/devices/liquid_handling/unit_test.py``
|
||||||
|
范围内随 PLR 环境就绪后增补,本 P9 提交保持解耦。
|
||||||
|
|
||||||
|
详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md`` §8。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, List, Tuple
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# liquid_history 模块**不依赖** pylabrobot,可在 PLR 环境缺失时独立 import / 单测。
|
||||||
|
from unilabos.devices.liquid_handling.liquid_history import (
|
||||||
|
LIQUID_HISTORY_MAX_ENTRIES,
|
||||||
|
LiquidHistoryEntry,
|
||||||
|
append_liquid_history,
|
||||||
|
normalize_liquid_history,
|
||||||
|
well_current_liquid_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures:DummyTracker / DummyWell(避免引入真实 PLR Well/VolumeTracker 依赖)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DummyTracker:
|
||||||
|
"""模拟 PLR VolumeTracker:仅暴露 P9 hook 关心的字段。"""
|
||||||
|
|
||||||
|
liquid_history: List[Any] = field(default_factory=list)
|
||||||
|
liquids: List[Tuple[Any, float]] = field(default_factory=list)
|
||||||
|
max_volume: float = 200.0
|
||||||
|
is_disabled: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DummyWell:
|
||||||
|
"""模拟 PLR Well:仅暴露 ``tracker``。"""
|
||||||
|
|
||||||
|
name: str = "well_A1"
|
||||||
|
max_volume: float = 200.0
|
||||||
|
tracker: DummyTracker = field(default_factory=DummyTracker)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# append_liquid_history
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppendLiquidHistory:
|
||||||
|
def test_append_creates_v3_entry(self) -> None:
|
||||||
|
well = DummyWell()
|
||||||
|
append_liquid_history(well, "Plasma", 100.0, "set")
|
||||||
|
|
||||||
|
assert len(well.tracker.liquid_history) == 1
|
||||||
|
entry = well.tracker.liquid_history[0]
|
||||||
|
assert entry["name"] == "Plasma"
|
||||||
|
assert entry["volume"] == 100.0
|
||||||
|
assert entry["action"] == "set"
|
||||||
|
assert "timestamp" in entry and isinstance(entry["timestamp"], str)
|
||||||
|
|
||||||
|
def test_append_aspirate_negative_volume(self) -> None:
|
||||||
|
well = DummyWell()
|
||||||
|
append_liquid_history(well, "Water", -50.0, "aspirate")
|
||||||
|
|
||||||
|
assert well.tracker.liquid_history[0]["volume"] == -50.0
|
||||||
|
assert well.tracker.liquid_history[0]["action"] == "aspirate"
|
||||||
|
|
||||||
|
def test_append_with_empty_name_keeps_empty_string(self) -> None:
|
||||||
|
"""name 为空时应写入 ``""`` 而非字面 "unknown"(避免视觉混淆 bottom_type)。"""
|
||||||
|
well = DummyWell()
|
||||||
|
append_liquid_history(well, "", 50.0, "dispense")
|
||||||
|
|
||||||
|
assert well.tracker.liquid_history[0]["name"] == ""
|
||||||
|
|
||||||
|
def test_append_with_none_name_normalized_to_empty_string(self) -> None:
|
||||||
|
well = DummyWell()
|
||||||
|
append_liquid_history(well, None, 50.0, "dispense") # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert well.tracker.liquid_history[0]["name"] == ""
|
||||||
|
|
||||||
|
def test_append_initializes_history_if_missing(self) -> None:
|
||||||
|
"""tracker 没有 liquid_history 属性时 helper 自动创建空 list 并写入。"""
|
||||||
|
well = DummyWell()
|
||||||
|
del well.tracker.liquid_history # 模拟全新 PLR tracker
|
||||||
|
append_liquid_history(well, "X", 10.0, "set")
|
||||||
|
|
||||||
|
assert hasattr(well.tracker, "liquid_history")
|
||||||
|
assert len(well.tracker.liquid_history) == 1
|
||||||
|
|
||||||
|
def test_append_no_tracker_is_graceful(self) -> None:
|
||||||
|
"""well 无 tracker 时静默不抛(保护主流程)。"""
|
||||||
|
|
||||||
|
class NoTrackerWell:
|
||||||
|
name = "no_tracker"
|
||||||
|
|
||||||
|
well = NoTrackerWell()
|
||||||
|
append_liquid_history(well, "X", 10.0, "set") # 不应抛
|
||||||
|
assert not hasattr(well, "tracker")
|
||||||
|
|
||||||
|
def test_append_action_defaults_to_legacy_when_empty(self) -> None:
|
||||||
|
well = DummyWell()
|
||||||
|
append_liquid_history(well, "X", 1.0, "")
|
||||||
|
|
||||||
|
assert well.tracker.liquid_history[0]["action"] == "legacy"
|
||||||
|
|
||||||
|
def test_append_respects_max_entries_rolling(self) -> None:
|
||||||
|
"""超过 ``LIQUID_HISTORY_MAX_ENTRIES`` 时丢弃头部,保留最近 entries。"""
|
||||||
|
well = DummyWell()
|
||||||
|
well.tracker.liquid_history = [
|
||||||
|
{"name": f"old_{i}"} for i in range(LIQUID_HISTORY_MAX_ENTRIES + 5)
|
||||||
|
]
|
||||||
|
append_liquid_history(well, "newest", 1.0, "set")
|
||||||
|
|
||||||
|
assert len(well.tracker.liquid_history) == LIQUID_HISTORY_MAX_ENTRIES
|
||||||
|
assert well.tracker.liquid_history[-1]["name"] == "newest"
|
||||||
|
assert well.tracker.liquid_history[0]["name"] != "old_0"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# normalize_liquid_history
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeLiquidHistory:
|
||||||
|
def test_v3_dict_passthrough_with_field_defaults(self) -> None:
|
||||||
|
raw = [{"name": "A", "volume": 100, "action": "set", "timestamp": "2026-05-22T00:00:00Z"}]
|
||||||
|
result = normalize_liquid_history(raw)
|
||||||
|
|
||||||
|
assert result == [{
|
||||||
|
"name": "A",
|
||||||
|
"volume": 100.0,
|
||||||
|
"action": "set",
|
||||||
|
"timestamp": "2026-05-22T00:00:00Z",
|
||||||
|
}]
|
||||||
|
|
||||||
|
def test_v3_dict_missing_optional_fields_filled_with_defaults(self) -> None:
|
||||||
|
raw = [{"name": "A"}]
|
||||||
|
result = normalize_liquid_history(raw)
|
||||||
|
|
||||||
|
assert result == [{"name": "A", "volume": 0.0, "action": "legacy"}]
|
||||||
|
assert "timestamp" not in result[0]
|
||||||
|
|
||||||
|
def test_v2_tuple_upgraded_to_v3_legacy(self) -> None:
|
||||||
|
raw = [("A", 100), ("B", 50.5)]
|
||||||
|
result = normalize_liquid_history(raw)
|
||||||
|
|
||||||
|
assert result == [
|
||||||
|
{"name": "A", "volume": 100.0, "action": "legacy"},
|
||||||
|
{"name": "B", "volume": 50.5, "action": "legacy"},
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_list_of_strings_upgraded(self) -> None:
|
||||||
|
raw = ["A", "B"]
|
||||||
|
result = normalize_liquid_history(raw)
|
||||||
|
|
||||||
|
assert result == [
|
||||||
|
{"name": "A", "volume": 0.0, "action": "legacy"},
|
||||||
|
{"name": "B", "volume": 0.0, "action": "legacy"},
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_mixed_input_normalized(self) -> None:
|
||||||
|
raw = [
|
||||||
|
{"name": "A", "volume": 1, "action": "set"},
|
||||||
|
("B", 2),
|
||||||
|
"C",
|
||||||
|
]
|
||||||
|
result = normalize_liquid_history(raw)
|
||||||
|
|
||||||
|
assert [e["name"] for e in result] == ["A", "B", "C"]
|
||||||
|
assert [e["action"] for e in result] == ["set", "legacy", "legacy"]
|
||||||
|
|
||||||
|
def test_invalid_entries_dropped(self) -> None:
|
||||||
|
raw = [42, None, {"name": "A"}, ("only_one",)]
|
||||||
|
result = normalize_liquid_history(raw)
|
||||||
|
|
||||||
|
# 只保留 {"name": "A"} 这一条;其它都被丢弃
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["name"] == "A"
|
||||||
|
assert result[0]["volume"] == 0.0 # 缺省补 0
|
||||||
|
|
||||||
|
def test_non_list_input_returns_empty(self) -> None:
|
||||||
|
assert normalize_liquid_history(None) == []
|
||||||
|
assert normalize_liquid_history("not_a_list") == []
|
||||||
|
assert normalize_liquid_history({"name": "X"}) == []
|
||||||
|
|
||||||
|
def test_tuple_with_unconvertible_volume_falls_back_to_zero(self) -> None:
|
||||||
|
raw = [("A", "not_a_number")]
|
||||||
|
result = normalize_liquid_history(raw)
|
||||||
|
|
||||||
|
assert result[0]["volume"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# well_current_liquid_name
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestWellCurrentLiquidName:
|
||||||
|
def test_returns_last_liquid_name_from_tuple(self) -> None:
|
||||||
|
well = DummyWell()
|
||||||
|
well.tracker.liquids = [("Water", 50.0), ("Plasma", 100.0)]
|
||||||
|
assert well_current_liquid_name(well) == "Plasma"
|
||||||
|
|
||||||
|
def test_returns_enum_like_name_attr(self) -> None:
|
||||||
|
class FakeLiquid:
|
||||||
|
name = "ETHANOL"
|
||||||
|
|
||||||
|
well = DummyWell()
|
||||||
|
well.tracker.liquids = [(FakeLiquid(), 100.0)]
|
||||||
|
assert well_current_liquid_name(well) == "ETHANOL"
|
||||||
|
|
||||||
|
def test_empty_liquids_returns_empty_string(self) -> None:
|
||||||
|
well = DummyWell()
|
||||||
|
well.tracker.liquids = []
|
||||||
|
assert well_current_liquid_name(well) == ""
|
||||||
|
|
||||||
|
def test_no_tracker_returns_empty_string(self) -> None:
|
||||||
|
class NoTrackerWell:
|
||||||
|
name = "x"
|
||||||
|
|
||||||
|
assert well_current_liquid_name(NoTrackerWell()) == ""
|
||||||
|
|
||||||
|
def test_none_liquid_returns_empty_string(self) -> None:
|
||||||
|
well = DummyWell()
|
||||||
|
well.tracker.liquids = [(None, 100.0)]
|
||||||
|
assert well_current_liquid_name(well) == ""
|
||||||
|
|
||||||
|
def test_string_liquid_returned_as_is(self) -> None:
|
||||||
|
well = DummyWell()
|
||||||
|
well.tracker.liquids = ["Saline"]
|
||||||
|
assert well_current_liquid_name(well) == "Saline"
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
"""P2 v2 跨板能力验证 —— device 层 ``set_liquid_from_plate`` 单测。
|
||||||
|
|
||||||
|
对应 ``product_designs/protocol_convert/02-cross-slot-merge.md`` §9.1 / §9.5 step 6.3。
|
||||||
|
|
||||||
|
本测试聚焦于 **`_set_liquid_grouped_by_plate`** 已天然支持跨板 wells 的能力(v2 设计
|
||||||
|
的核心依据):
|
||||||
|
|
||||||
|
- 输入 ``wells`` 列表来自多个 plate(每板各一/多个 well)时,``set_liquid`` 应按 plate
|
||||||
|
分桶串行调用,每板一次(plate-bucket 顺序按 first-occurrence)。
|
||||||
|
- 同板内多孔归到同一桶。
|
||||||
|
- 返回 ``volumes`` 按 **输入 index 顺序**回拼,与 wells 一致 —— 这是 v2 Stage 3
|
||||||
|
merged ``set_liquid_from_plate.output_wells`` 的顺序权威来源。
|
||||||
|
- ``Well.set_liquids`` 在 ``set_liquid`` 链内被逐孔调用,与 PLR 实现的预期接口一致。
|
||||||
|
|
||||||
|
为了避免引入完整 PLR 资源树,测试用 duck-typed ``DummyWell`` / ``DummyPlate`` +
|
||||||
|
``ResourceTreeSet`` 的 monkeypatch(dump 直接返回输入列表)。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import List, Tuple
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 跨环境兼容:与现有 ``tests/devices/liquid_handling/test_transfer_liquid.py`` 一致,
|
||||||
|
# 本测试通过 import ``unilabos.devices.liquid_handling.liquid_handler_abstract``
|
||||||
|
# 拉起 pylabrobot 链;某些本地开发机的 pylabrobot 版本与代码库要求不一致,
|
||||||
|
# 会在 import 阶段抛 ``ImportError``。这里用 ``importorskip`` 优雅跳过,让
|
||||||
|
# CI(统一 pylabrobot 版本)跑全;纯逻辑测试(Stage 2 / Stage 3)不受影响。
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
LiquidHandlerAbstract = pytest.importorskip(
|
||||||
|
"unilabos.devices.liquid_handling.liquid_handler_abstract",
|
||||||
|
reason="pylabrobot 链未完整可用,跳过 device 单测;CI 上请保证 pylabrobot ≥ 项目要求版本",
|
||||||
|
exc_type=ImportError,
|
||||||
|
).LiquidHandlerAbstract
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Duck-typed PLR-like 资源 ====================
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DummyPlate:
|
||||||
|
name: str
|
||||||
|
|
||||||
|
def __repr__(self) -> str: # pragma: no cover
|
||||||
|
return f"DummyPlate({self.name})"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DummyWell:
|
||||||
|
name: str
|
||||||
|
parent: DummyPlate
|
||||||
|
max_volume: float = 1000.0
|
||||||
|
liquid_history: List[Tuple[str, float]] = field(default_factory=list)
|
||||||
|
|
||||||
|
def set_liquids(self, items):
|
||||||
|
"""模拟 PLR ``Well.set_liquids([(name, vol), ...])`` 接口。"""
|
||||||
|
for name, vol in items:
|
||||||
|
self.liquid_history.append((str(name), float(vol)))
|
||||||
|
|
||||||
|
def __repr__(self) -> str: # pragma: no cover
|
||||||
|
return f"DummyWell({self.parent.name}/{self.name})"
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== fixture:装一台 FakeLiquidHandler ====================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def patched_resource_tree(monkeypatch):
|
||||||
|
"""patch ``ResourceTreeSet.from_plr_resources`` 使其接受 duck-typed wells/plates。
|
||||||
|
|
||||||
|
返回的对象只要带 ``.dump()`` 即可(``_set_liquid_grouped_by_plate`` 仅消费该方法)。
|
||||||
|
"""
|
||||||
|
from unilabos.devices.liquid_handling import liquid_handler_abstract as lha
|
||||||
|
|
||||||
|
class _FakeTree:
|
||||||
|
def __init__(self, items):
|
||||||
|
self._items = items
|
||||||
|
|
||||||
|
def dump(self):
|
||||||
|
return [
|
||||||
|
{"name": getattr(x, "name", None), "type": type(x).__name__}
|
||||||
|
for x in self._items
|
||||||
|
]
|
||||||
|
|
||||||
|
def _fake_from_plr_resources(items, known_newly_created=False): # noqa: ARG001
|
||||||
|
return _FakeTree(list(items))
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
lha.ResourceTreeSet,
|
||||||
|
"from_plr_resources",
|
||||||
|
staticmethod(_fake_from_plr_resources),
|
||||||
|
)
|
||||||
|
return lha
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def handler(patched_resource_tree):
|
||||||
|
"""构造一台最小 LiquidHandlerAbstract 实例,绕过真实 backend / deck。"""
|
||||||
|
|
||||||
|
class _FakeHandler(LiquidHandlerAbstract):
|
||||||
|
def __init__(self):
|
||||||
|
# 不调用 super().__init__,避免真实硬件/后端依赖
|
||||||
|
self.channel_num = 8
|
||||||
|
self.support_touch_tip = True
|
||||||
|
|
||||||
|
return _FakeHandler()
|
||||||
|
|
||||||
|
|
||||||
|
def _wells_grid(plate_name: str, well_names: List[str]) -> List[DummyWell]:
|
||||||
|
plate = DummyPlate(name=plate_name)
|
||||||
|
return [DummyWell(name=w, parent=plate) for w in well_names]
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 用例 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def test_grouped_by_plate_single_plate_set_liquid_inline(handler):
|
||||||
|
"""单 plate 多孔:set_liquids 按 wells 顺序逐项调用,volumes 回拼一致。"""
|
||||||
|
wells = _wells_grid("plate_slot2", ["A1", "A2", "A3"])
|
||||||
|
ret = handler._set_liquid_grouped_by_plate(
|
||||||
|
wells=wells,
|
||||||
|
liquid_names=["reagent_X"] * 3,
|
||||||
|
volumes=[10.0, 20.0, 30.0],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 每个 well 的 liquid_history 各 1 条
|
||||||
|
for w, expected_vol in zip(wells, [10.0, 20.0, 30.0]):
|
||||||
|
assert w.liquid_history == [("reagent_X", expected_vol)]
|
||||||
|
|
||||||
|
# 返回 volumes 顺序与输入一致
|
||||||
|
assert ret.volumes == [10.0, 20.0, 30.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_grouped_by_plate_cross_plate_buckets_by_parent(handler):
|
||||||
|
"""跨板 wells 列表 → 按 first-occurrence plate 顺序分桶,每板单独 set_liquid。
|
||||||
|
|
||||||
|
51b9a5 简化(每板 1 孔):4 plate × 1 well = 4 set_liquids 调用。
|
||||||
|
"""
|
||||||
|
p2 = _wells_grid("plate_slot2", ["A1"])
|
||||||
|
p3 = _wells_grid("plate_slot3", ["A1"])
|
||||||
|
p5 = _wells_grid("plate_slot5", ["A1"])
|
||||||
|
p6 = _wells_grid("plate_slot6", ["A1"])
|
||||||
|
wells = p2 + p3 + p5 + p6
|
||||||
|
|
||||||
|
ret = handler._set_liquid_grouped_by_plate(
|
||||||
|
wells=wells,
|
||||||
|
liquid_names=["l1"] * 4,
|
||||||
|
volumes=[8.3] * 4,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 每个 well 都被 set_liquids 设过
|
||||||
|
for w in wells:
|
||||||
|
assert w.liquid_history == [("l1", 8.3)], f"well {w.parent.name}/{w.name} 未正确设液"
|
||||||
|
|
||||||
|
# volumes 顺序与输入对齐
|
||||||
|
assert ret.volumes == [8.3, 8.3, 8.3, 8.3]
|
||||||
|
|
||||||
|
# plate dump 应含 4 个 plate(按 first-occurrence)
|
||||||
|
plate_dump = ret.plate
|
||||||
|
plate_names = [p["name"] for p in plate_dump]
|
||||||
|
assert plate_names == ["plate_slot2", "plate_slot3", "plate_slot5", "plate_slot6"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_grouped_by_plate_interleaved_cross_plate_preserves_input_order(handler):
|
||||||
|
"""交错跨板:wells=[p2.A1, p3.A1, p2.A2, p5.A1] → volumes 顺序按输入回拼。
|
||||||
|
|
||||||
|
内部仍按 plate 分桶执行 set_liquid(per-plate 串行),但返回顺序遵循输入 index。
|
||||||
|
"""
|
||||||
|
p2 = DummyPlate(name="plate_slot2")
|
||||||
|
p3 = DummyPlate(name="plate_slot3")
|
||||||
|
p5 = DummyPlate(name="plate_slot5")
|
||||||
|
w_p2_a1 = DummyWell(name="A1", parent=p2)
|
||||||
|
w_p2_a2 = DummyWell(name="A2", parent=p2)
|
||||||
|
w_p3_a1 = DummyWell(name="A1", parent=p3)
|
||||||
|
w_p5_a1 = DummyWell(name="A1", parent=p5)
|
||||||
|
|
||||||
|
wells = [w_p2_a1, w_p3_a1, w_p2_a2, w_p5_a1]
|
||||||
|
ret = handler._set_liquid_grouped_by_plate(
|
||||||
|
wells=wells,
|
||||||
|
liquid_names=["l1"] * 4,
|
||||||
|
volumes=[10.0, 20.0, 30.0, 40.0],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 每个 well 都被设液
|
||||||
|
assert w_p2_a1.liquid_history == [("l1", 10.0)]
|
||||||
|
assert w_p3_a1.liquid_history == [("l1", 20.0)]
|
||||||
|
assert w_p2_a2.liquid_history == [("l1", 30.0)]
|
||||||
|
assert w_p5_a1.liquid_history == [("l1", 40.0)]
|
||||||
|
|
||||||
|
# 返回 volumes 严格按输入 index 顺序回拼
|
||||||
|
assert ret.volumes == [10.0, 20.0, 30.0, 40.0]
|
||||||
|
|
||||||
|
# plate dump:按 first-occurrence(plate_slot2 第 1 次出现于 idx=0,plate_slot3 idx=1,plate_slot5 idx=3)
|
||||||
|
plate_names = [p["name"] for p in ret.plate]
|
||||||
|
assert plate_names == ["plate_slot2", "plate_slot3", "plate_slot5"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_grouped_by_plate_volumes_clamped_to_max_volume(handler):
|
||||||
|
"""``set_liquid`` 会按 ``max_volume`` 做 clamp,防止初始化液量超容器容量。"""
|
||||||
|
plate = DummyPlate(name="plate_slot2")
|
||||||
|
well = DummyWell(name="A1", parent=plate, max_volume=200.0)
|
||||||
|
|
||||||
|
ret = handler._set_liquid_grouped_by_plate(
|
||||||
|
wells=[well],
|
||||||
|
liquid_names=["overflow"],
|
||||||
|
volumes=[500.0], # 超过 max_volume=200
|
||||||
|
)
|
||||||
|
|
||||||
|
assert well.liquid_history == [("overflow", 200.0)]
|
||||||
|
assert ret.volumes == [200.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_grouped_by_plate_empty_names_short_circuit(handler):
|
||||||
|
"""``liquid_names`` 与 ``volumes`` 均为空:早返回,wells 列表回显但不设液。"""
|
||||||
|
wells = _wells_grid("plate_slot2", ["A1", "A2"])
|
||||||
|
ret = handler._set_liquid_grouped_by_plate(
|
||||||
|
wells=wells,
|
||||||
|
liquid_names=[],
|
||||||
|
volumes=[],
|
||||||
|
)
|
||||||
|
# 不调用 set_liquids
|
||||||
|
assert all(w.liquid_history == [] for w in wells)
|
||||||
|
assert ret.volumes == []
|
||||||
|
# wells dump 仍返回输入列表
|
||||||
|
assert [w["name"] for w in ret.wells] == ["A1", "A2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_grouped_by_plate_length_mismatch_raises(handler):
|
||||||
|
"""wells / liquid_names / volumes 长度不一致应直接 raise(防御性校验)。"""
|
||||||
|
wells = _wells_grid("plate_slot2", ["A1", "A2"])
|
||||||
|
with pytest.raises(ValueError, match=r"必须等长"):
|
||||||
|
handler._set_liquid_grouped_by_plate(
|
||||||
|
wells=wells,
|
||||||
|
liquid_names=["r"] * 2,
|
||||||
|
volumes=[10.0], # 长度 1,不匹配
|
||||||
|
)
|
||||||
566
tests/devices/liquid_handling/test_tip_reuse_by_liquid_name.py
Normal file
566
tests/devices/liquid_handling/test_tip_reuse_by_liquid_name.py
Normal file
@@ -0,0 +1,566 @@
|
|||||||
|
"""P10 v2 — Tip 复用 ``tracker.liquids`` 等价规则单元测试。
|
||||||
|
|
||||||
|
测试覆盖(详见 ``product_designs/protocol_convert/10-tip-reuse-by-liquid-history.md`` §5):
|
||||||
|
|
||||||
|
- Helper:``is_known_liquid_name`` / ``same_liquid_via_liquids`` /
|
||||||
|
``same_liquid_via_liquids_pair`` / ``capture_tip_liquid_name``(4 helper
|
||||||
|
位于 ``liquid_history.py``,PLR-free 模块)。
|
||||||
|
- 单通道 transfer_liquid 主循环:identity-keep / liquids-keep / 配置开关 /
|
||||||
|
未知 name 保守换 tip / aspirate 顶层归零时序。
|
||||||
|
- 8 通道分支:段锚孔 liquids-keep。
|
||||||
|
- 跨节点边界:两个独立 transfer_liquid 调用状态隔离。
|
||||||
|
|
||||||
|
helper 测试独立于 PLR,可在 ``pylabrobot`` 缺失环境下单独运行;端到端
|
||||||
|
``transfer_liquid`` 主循环测试需要 PLR 环境(沿用 ``test_transfer_liquid.py`` 的
|
||||||
|
``FakeLiquidHandler`` 模式:跳过 ``super().__init__``,仅 stub 4 类方法记录调用)。
|
||||||
|
若 PLR import 失败则自动 skip 端到端测试,保留 helper 测试结果。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Iterable, List, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# P10 v2 helper 位于 PLR-free 模块,无论 pylabrobot 是否安装都能 import。
|
||||||
|
from unilabos.devices.liquid_handling.liquid_history import (
|
||||||
|
capture_tip_liquid_name,
|
||||||
|
is_known_liquid_name,
|
||||||
|
same_liquid_via_liquids,
|
||||||
|
same_liquid_via_liquids_pair,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 端到端测试依赖 PLR 完整环境;若 import 失败(例如本地 PLR 版本不匹配),
|
||||||
|
# 整段端到端测试自动 skip,但 helper 测试照常执行。
|
||||||
|
try:
|
||||||
|
from unilabos.devices.liquid_handling.liquid_handler_abstract import (
|
||||||
|
LiquidHandlerAbstract,
|
||||||
|
)
|
||||||
|
|
||||||
|
_PLR_AVAILABLE = True
|
||||||
|
_PLR_IMPORT_ERROR: Optional[Exception] = None
|
||||||
|
except Exception as exc: # pragma: no cover - 环境相关
|
||||||
|
LiquidHandlerAbstract = None # type: ignore[assignment, misc]
|
||||||
|
_PLR_AVAILABLE = False
|
||||||
|
_PLR_IMPORT_ERROR = exc
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures:DummyTracker / DummyWell / DummyTipSpot / FakeLiquidHandler
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DummyTracker:
|
||||||
|
"""模拟 PLR ``VolumeTracker``:仅暴露 P10 v2 关心的 ``liquids`` 字段。"""
|
||||||
|
|
||||||
|
liquids: List[Tuple[Any, float]] = field(default_factory=list)
|
||||||
|
max_volume: float = 200.0
|
||||||
|
is_disabled: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DummyWell:
|
||||||
|
"""模拟 PLR ``Well``:仅暴露 ``tracker``。"""
|
||||||
|
|
||||||
|
name: str = "well"
|
||||||
|
tracker: DummyTracker = field(default_factory=DummyTracker)
|
||||||
|
|
||||||
|
def __repr__(self) -> str: # pragma: no cover
|
||||||
|
return f"DummyWell({self.name})"
|
||||||
|
|
||||||
|
|
||||||
|
def make_well(name: str, liquid_name: Optional[str] = None, vol: float = 100.0) -> DummyWell:
|
||||||
|
"""构造一个 well;若指定 ``liquid_name`` 则写入 ``tracker.liquids`` 顶层。"""
|
||||||
|
well = DummyWell(name=name, tracker=DummyTracker())
|
||||||
|
if liquid_name is not None:
|
||||||
|
well.tracker.liquids = [(liquid_name, vol)]
|
||||||
|
return well
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DummyTipSpot:
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
def make_tip_iter(n: int = 256) -> Iterable[List[DummyTipSpot]]:
|
||||||
|
for i in range(n):
|
||||||
|
yield [DummyTipSpot(f"tip_{i}")]
|
||||||
|
|
||||||
|
|
||||||
|
# E2E 测试用的 base:PLR 可用时是 ``LiquidHandlerAbstract``,否则 fallback 到
|
||||||
|
# ``object`` 让模块仍能 import;带 ``LiquidHandlerAbstract`` 的 e2e 测试用
|
||||||
|
# ``skipif`` 跳过。
|
||||||
|
_FakeBase = LiquidHandlerAbstract if _PLR_AVAILABLE else object
|
||||||
|
|
||||||
|
|
||||||
|
class FakeLiquidHandler(_FakeBase): # type: ignore[misc, valid-type]
|
||||||
|
"""不初始化真实 backend/deck;仅记录 transfer_liquid 内部 4 类调用序列。
|
||||||
|
|
||||||
|
P10 v2 测试关心 ``pick_up_tips`` / ``discard_tips`` 的触发次数 + 顺序,
|
||||||
|
以推断 tip 是否被复用(一次 pick_up_tips 多次 aspirate/dispense → 复用)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, channel_num: int = 1, tip_reuse_by_liquid_name: bool = True):
|
||||||
|
# 不调用 super().__init__,避免硬件 / ROS / PLR Deck 初始化。
|
||||||
|
self.channel_num = channel_num
|
||||||
|
self.support_touch_tip = True
|
||||||
|
self.current_tip = iter(make_tip_iter(2048))
|
||||||
|
self.calls: List[Tuple[str, Any]] = []
|
||||||
|
self._tip_reuse_by_liquid_name: bool = tip_reuse_by_liquid_name
|
||||||
|
|
||||||
|
def set_tiprack(self, tip_racks):
|
||||||
|
if not tip_racks:
|
||||||
|
return
|
||||||
|
# 跳过真实 set_tiprack(依赖 PLR Deck)
|
||||||
|
return
|
||||||
|
|
||||||
|
async def pick_up_tips(self, tip_spots, use_channels=None, offsets=None, **kw):
|
||||||
|
self.calls.append(("pick_up_tips", {"tips": list(tip_spots), "use_channels": use_channels}))
|
||||||
|
|
||||||
|
async def aspirate(
|
||||||
|
self,
|
||||||
|
resources: Sequence[Any],
|
||||||
|
vols: List[float],
|
||||||
|
use_channels: Optional[List[int]] = None,
|
||||||
|
flow_rates: Optional[List[Optional[float]]] = None,
|
||||||
|
offsets: Any = None,
|
||||||
|
liquid_height: Any = None,
|
||||||
|
blow_out_air_volume: Any = None,
|
||||||
|
spread: str = "wide",
|
||||||
|
**backend_kwargs,
|
||||||
|
):
|
||||||
|
self.calls.append(
|
||||||
|
("aspirate", {"resources": list(resources), "vols": list(vols)})
|
||||||
|
)
|
||||||
|
|
||||||
|
async def dispense(
|
||||||
|
self,
|
||||||
|
resources: Sequence[Any],
|
||||||
|
vols: List[float],
|
||||||
|
use_channels: Optional[List[int]] = None,
|
||||||
|
flow_rates: Optional[List[Optional[float]]] = None,
|
||||||
|
offsets: Any = None,
|
||||||
|
liquid_height: Any = None,
|
||||||
|
blow_out_air_volume: Any = None,
|
||||||
|
spread: str = "wide",
|
||||||
|
**backend_kwargs,
|
||||||
|
):
|
||||||
|
self.calls.append(
|
||||||
|
("dispense", {"resources": list(resources), "vols": list(vols)})
|
||||||
|
)
|
||||||
|
|
||||||
|
async def discard_tips(self, use_channels=None, *args, **kwargs):
|
||||||
|
self.calls.append(("discard_tips", {"use_channels": use_channels}))
|
||||||
|
|
||||||
|
|
||||||
|
class AspiratePopFakeLiquidHandler(FakeLiquidHandler):
|
||||||
|
"""T11 专用:aspirate 时模拟 PLR "顶层归零时 pop ``tracker.liquids`` 顶层" 的行为。
|
||||||
|
|
||||||
|
用于验证 P10 v2 的关键时序约束:tip name 必须在 aspirate **之前**预读,
|
||||||
|
否则 aspirate 后再读 ``tracker.liquids[-1]`` 会拿不到液体身份。
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def aspirate(self, resources, vols, **kwargs):
|
||||||
|
await super().aspirate(resources, vols, **kwargs)
|
||||||
|
# 模拟 PLR 顶层归零时 pop:对每个 source well,若 liquids 非空则 pop 顶层
|
||||||
|
for r in resources:
|
||||||
|
tracker = getattr(r, "tracker", None)
|
||||||
|
if tracker is not None and tracker.liquids:
|
||||||
|
tracker.liquids.pop()
|
||||||
|
|
||||||
|
|
||||||
|
def run(coro):
|
||||||
|
return asyncio.run(coro)
|
||||||
|
|
||||||
|
|
||||||
|
def call_names(lh: FakeLiquidHandler) -> List[str]:
|
||||||
|
return [c[0] for c in lh.calls]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper 单元测试
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsKnownLiquidName:
|
||||||
|
def test_empty_string_is_unknown(self) -> None:
|
||||||
|
assert is_known_liquid_name("") is False
|
||||||
|
|
||||||
|
def test_none_is_unknown(self) -> None:
|
||||||
|
assert is_known_liquid_name(None) is False
|
||||||
|
|
||||||
|
def test_literal_unknown_is_unknown(self) -> None:
|
||||||
|
assert is_known_liquid_name("unknown") is False
|
||||||
|
assert is_known_liquid_name("UNKNOWN") is False
|
||||||
|
assert is_known_liquid_name(" Unknown ") is False
|
||||||
|
|
||||||
|
def test_literal_none_string_is_unknown(self) -> None:
|
||||||
|
assert is_known_liquid_name("none") is False
|
||||||
|
assert is_known_liquid_name("None") is False
|
||||||
|
|
||||||
|
def test_real_liquid_name_is_known(self) -> None:
|
||||||
|
assert is_known_liquid_name("PBS") is True
|
||||||
|
assert is_known_liquid_name("Tris HCl") is True
|
||||||
|
assert is_known_liquid_name("Liquid_3") is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestSameLiquidViaLiquids:
|
||||||
|
def test_well_and_tip_same_name_match(self) -> None:
|
||||||
|
well = make_well("A1", "PBS")
|
||||||
|
assert same_liquid_via_liquids(well, "PBS") is True
|
||||||
|
|
||||||
|
def test_well_and_tip_different_names_no_match(self) -> None:
|
||||||
|
well = make_well("A1", "PBS")
|
||||||
|
assert same_liquid_via_liquids(well, "Tris HCl") is False
|
||||||
|
|
||||||
|
def test_tip_unknown_returns_false(self) -> None:
|
||||||
|
well = make_well("A1", "PBS")
|
||||||
|
assert same_liquid_via_liquids(well, None) is False
|
||||||
|
assert same_liquid_via_liquids(well, "") is False
|
||||||
|
assert same_liquid_via_liquids(well, "unknown") is False
|
||||||
|
|
||||||
|
def test_well_empty_liquids_returns_false(self) -> None:
|
||||||
|
well = make_well("A1", liquid_name=None) # 不写 liquids
|
||||||
|
assert same_liquid_via_liquids(well, "PBS") is False
|
||||||
|
|
||||||
|
def test_well_unknown_literal_returns_false(self) -> None:
|
||||||
|
well = make_well("A1", "unknown")
|
||||||
|
assert same_liquid_via_liquids(well, "unknown") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestSameLiquidViaLiquidsPair:
|
||||||
|
def test_two_wells_same_name_match(self) -> None:
|
||||||
|
a = make_well("A1", "PBS")
|
||||||
|
b = make_well("B1", "PBS")
|
||||||
|
assert same_liquid_via_liquids_pair(a, b) is True
|
||||||
|
|
||||||
|
def test_two_wells_different_names_no_match(self) -> None:
|
||||||
|
a = make_well("A1", "PBS")
|
||||||
|
b = make_well("B1", "Tris HCl")
|
||||||
|
assert same_liquid_via_liquids_pair(a, b) is False
|
||||||
|
|
||||||
|
def test_either_well_empty_returns_false(self) -> None:
|
||||||
|
a = make_well("A1", "PBS")
|
||||||
|
b = make_well("B1", liquid_name=None)
|
||||||
|
assert same_liquid_via_liquids_pair(a, b) is False
|
||||||
|
assert same_liquid_via_liquids_pair(b, a) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestCaptureTipLiquidName:
|
||||||
|
def test_known_name_returned(self) -> None:
|
||||||
|
well = make_well("A1", "PBS")
|
||||||
|
assert capture_tip_liquid_name(well) == "PBS"
|
||||||
|
|
||||||
|
def test_empty_well_returns_none(self) -> None:
|
||||||
|
well = make_well("A1", liquid_name=None)
|
||||||
|
assert capture_tip_liquid_name(well) is None
|
||||||
|
|
||||||
|
def test_unknown_literal_returns_none(self) -> None:
|
||||||
|
well = make_well("A1", "unknown")
|
||||||
|
assert capture_tip_liquid_name(well) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# T1–T12 端到端测试(单通道 transfer_liquid 主循环)
|
||||||
|
#
|
||||||
|
# 需要 PLR 完整环境(``pylabrobot.liquid_handling.LiquidHandlerBackend`` 等)。
|
||||||
|
# 若 PLR import 失败则整段 skip,helper 测试照常运行。
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_skip_if_no_plr = pytest.mark.skipif(
|
||||||
|
not _PLR_AVAILABLE,
|
||||||
|
reason=f"pylabrobot import failed: {_PLR_IMPORT_ERROR}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@_skip_if_no_plr
|
||||||
|
class TestSingleChannelTipReuse:
|
||||||
|
"""覆盖 §5 矩阵 T1 / T2 / T3 / T4 / T5 / T6 / T8 / T10 / T11。"""
|
||||||
|
|
||||||
|
def test_T1_identity_hit_reuses_tip(self) -> None:
|
||||||
|
"""T1:连续 2 轮同 source/target → identity-keep 命中,复用 tip。"""
|
||||||
|
lh = FakeLiquidHandler(channel_num=1)
|
||||||
|
src = make_well("S0", "PBS")
|
||||||
|
tgt = make_well("T0")
|
||||||
|
run(
|
||||||
|
lh.transfer_liquid(
|
||||||
|
sources=[src, src],
|
||||||
|
targets=[tgt, tgt],
|
||||||
|
tip_racks=[],
|
||||||
|
use_channels=[0],
|
||||||
|
asp_vols=[1, 1],
|
||||||
|
dis_vols=[1, 1],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# 2 次 transfer,但 identity-keep → 仅 1 次 pick_up_tips / 1 次 discard_tips
|
||||||
|
assert call_names(lh).count("pick_up_tips") == 1
|
||||||
|
assert call_names(lh).count("discard_tips") == 1
|
||||||
|
assert call_names(lh).count("aspirate") == 2
|
||||||
|
assert call_names(lh).count("dispense") == 2
|
||||||
|
|
||||||
|
def test_T2_liquids_hit_across_plates(self) -> None:
|
||||||
|
"""T2:9 个独立 source well(不同 PLR Well 对象)都装 PBS → identity 全 fail,liquids-keep 全命中。"""
|
||||||
|
lh = FakeLiquidHandler(channel_num=1)
|
||||||
|
sources = [make_well(f"S{i}", "PBS") for i in range(9)]
|
||||||
|
targets = [make_well(f"T{i}") for i in range(9)]
|
||||||
|
run(
|
||||||
|
lh.transfer_liquid(
|
||||||
|
sources=sources,
|
||||||
|
targets=targets,
|
||||||
|
tip_racks=[],
|
||||||
|
use_channels=[0],
|
||||||
|
asp_vols=[1] * 9,
|
||||||
|
dis_vols=[1] * 9,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# 9 个 source 物理上同液 → 整段共用 1 个 tip
|
||||||
|
assert call_names(lh).count("pick_up_tips") == 1
|
||||||
|
assert call_names(lh).count("discard_tips") == 1
|
||||||
|
assert call_names(lh).count("aspirate") == 9
|
||||||
|
assert call_names(lh).count("dispense") == 9
|
||||||
|
|
||||||
|
def test_T3_liquids_hit_same_plate_different_wells(self) -> None:
|
||||||
|
"""T3:同 plate 上 A1-H1 都装 PBS(8 个不同 Well 对象)→ identity 全 fail,liquids-keep 命中。"""
|
||||||
|
lh = FakeLiquidHandler(channel_num=1)
|
||||||
|
sources = [make_well(f"A{i}", "PBS") for i in range(1, 9)]
|
||||||
|
targets = [make_well(f"T{i}") for i in range(8)]
|
||||||
|
run(
|
||||||
|
lh.transfer_liquid(
|
||||||
|
sources=sources,
|
||||||
|
targets=targets,
|
||||||
|
tip_racks=[],
|
||||||
|
use_channels=[0],
|
||||||
|
asp_vols=[1] * 8,
|
||||||
|
dis_vols=[1] * 8,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert call_names(lh).count("pick_up_tips") == 1
|
||||||
|
assert call_names(lh).count("discard_tips") == 1
|
||||||
|
|
||||||
|
def test_T4_liquids_not_match_forces_tip_change(self) -> None:
|
||||||
|
"""T4:A1=PBS,B1=Tris HCl → liquids 名不等,强制换 tip。"""
|
||||||
|
lh = FakeLiquidHandler(channel_num=1)
|
||||||
|
sources = [make_well("A1", "PBS"), make_well("B1", "Tris HCl")]
|
||||||
|
targets = [make_well("T0"), make_well("T1")]
|
||||||
|
run(
|
||||||
|
lh.transfer_liquid(
|
||||||
|
sources=sources,
|
||||||
|
targets=targets,
|
||||||
|
tip_racks=[],
|
||||||
|
use_channels=[0],
|
||||||
|
asp_vols=[1, 1],
|
||||||
|
dis_vols=[1, 1],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# 2 次完全独立的 transfer:2 次 pick_up / 2 次 discard
|
||||||
|
assert call_names(lh).count("pick_up_tips") == 2
|
||||||
|
assert call_names(lh).count("discard_tips") == 2
|
||||||
|
|
||||||
|
def test_T5_empty_liquids_forces_tip_change(self) -> None:
|
||||||
|
"""T5:source 从未调过 set_liquids(liquids 空)→ 视为未知,强制换 tip。"""
|
||||||
|
lh = FakeLiquidHandler(channel_num=1)
|
||||||
|
sources = [make_well("A1"), make_well("B1")] # 没装液体名
|
||||||
|
targets = [make_well("T0"), make_well("T1")]
|
||||||
|
run(
|
||||||
|
lh.transfer_liquid(
|
||||||
|
sources=sources,
|
||||||
|
targets=targets,
|
||||||
|
tip_racks=[],
|
||||||
|
use_channels=[0],
|
||||||
|
asp_vols=[1, 1],
|
||||||
|
dis_vols=[1, 1],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert call_names(lh).count("pick_up_tips") == 2
|
||||||
|
assert call_names(lh).count("discard_tips") == 2
|
||||||
|
|
||||||
|
def test_T6_switch_off_disables_liquids_keep(self) -> None:
|
||||||
|
"""T6:tip_reuse_by_liquid_name=False,T2 场景退化为 identity-only,强制换 tip。"""
|
||||||
|
lh = FakeLiquidHandler(channel_num=1, tip_reuse_by_liquid_name=False)
|
||||||
|
sources = [make_well(f"S{i}", "PBS") for i in range(9)]
|
||||||
|
targets = [make_well(f"T{i}") for i in range(9)]
|
||||||
|
run(
|
||||||
|
lh.transfer_liquid(
|
||||||
|
sources=sources,
|
||||||
|
targets=targets,
|
||||||
|
tip_racks=[],
|
||||||
|
use_channels=[0],
|
||||||
|
asp_vols=[1] * 9,
|
||||||
|
dis_vols=[1] * 9,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# 关闭开关后 → 退化为 identity-only,9 次独立换 tip
|
||||||
|
assert call_names(lh).count("pick_up_tips") == 9
|
||||||
|
assert call_names(lh).count("discard_tips") == 9
|
||||||
|
|
||||||
|
def test_T8_mix_style_same_source_reuses_via_identity(self) -> None:
|
||||||
|
"""T8:单 source 反复 aspirate/dispense → identity-keep 命中(mix-style)。"""
|
||||||
|
lh = FakeLiquidHandler(channel_num=1)
|
||||||
|
src = make_well("S0", "Methanol")
|
||||||
|
tgt = make_well("T0")
|
||||||
|
run(
|
||||||
|
lh.transfer_liquid(
|
||||||
|
sources=[src, src, src],
|
||||||
|
targets=[tgt, tgt, tgt],
|
||||||
|
tip_racks=[],
|
||||||
|
use_channels=[0],
|
||||||
|
asp_vols=[1, 1, 1],
|
||||||
|
dis_vols=[1, 1, 1],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert call_names(lh).count("pick_up_tips") == 1
|
||||||
|
assert call_names(lh).count("discard_tips") == 1
|
||||||
|
|
||||||
|
def test_T10_unknown_literal_treated_as_unknown(self) -> None:
|
||||||
|
"""T10:``tracker.liquids = [("unknown", v)]``(兼容旧数据)→ 视为未知,强制换 tip。"""
|
||||||
|
lh = FakeLiquidHandler(channel_num=1)
|
||||||
|
sources = [make_well("A1", "unknown"), make_well("B1", "unknown")]
|
||||||
|
targets = [make_well("T0"), make_well("T1")]
|
||||||
|
run(
|
||||||
|
lh.transfer_liquid(
|
||||||
|
sources=sources,
|
||||||
|
targets=targets,
|
||||||
|
tip_racks=[],
|
||||||
|
use_channels=[0],
|
||||||
|
asp_vols=[1, 1],
|
||||||
|
dis_vols=[1, 1],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert call_names(lh).count("pick_up_tips") == 2
|
||||||
|
assert call_names(lh).count("discard_tips") == 2
|
||||||
|
|
||||||
|
def test_T11_aspirate_pop_timing_pre_read(self) -> None:
|
||||||
|
"""T11:aspirate 顶层归零 → PLR pop ``tracker.liquids`` 顶层;
|
||||||
|
验证 P10 v2 ``pending_tip_name`` 必须在 aspirate **之前**预读才能命中下一轮。
|
||||||
|
"""
|
||||||
|
lh = AspiratePopFakeLiquidHandler(channel_num=1)
|
||||||
|
sources = [make_well(f"S{i}", "PBS") for i in range(3)]
|
||||||
|
targets = [make_well(f"T{i}") for i in range(3)]
|
||||||
|
run(
|
||||||
|
lh.transfer_liquid(
|
||||||
|
sources=sources,
|
||||||
|
targets=targets,
|
||||||
|
tip_racks=[],
|
||||||
|
use_channels=[0],
|
||||||
|
asp_vols=[1] * 3,
|
||||||
|
dis_vols=[1] * 3,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# 即使 aspirate 后 source.tracker.liquids 被 pop,pending_tip_name 已捕获 "PBS"
|
||||||
|
# → 下一轮 source 仍是 PBS(aspirate 还没发生),liquids-keep 命中
|
||||||
|
# → 整段 1 次 pick_up_tips
|
||||||
|
assert call_names(lh).count("pick_up_tips") == 1
|
||||||
|
assert call_names(lh).count("discard_tips") == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# T7:跨节点边界(两个独立 transfer_liquid 调用,状态隔离)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@_skip_if_no_plr
|
||||||
|
class TestCrossNodeBoundary:
|
||||||
|
"""T7:两个 transfer_liquid 节点之间不复用 tip(每次调用初始化 current_tip_liquid_name=None)。"""
|
||||||
|
|
||||||
|
def test_T7_two_calls_dont_share_tip_state(self) -> None:
|
||||||
|
lh = FakeLiquidHandler(channel_num=1)
|
||||||
|
src_a = make_well("A_src", "PBS")
|
||||||
|
tgt_a = make_well("A_tgt")
|
||||||
|
src_b = make_well("B_src", "PBS") # 同名液,但不同 well
|
||||||
|
tgt_b = make_well("B_tgt")
|
||||||
|
|
||||||
|
run(
|
||||||
|
lh.transfer_liquid(
|
||||||
|
sources=[src_a],
|
||||||
|
targets=[tgt_a],
|
||||||
|
tip_racks=[],
|
||||||
|
use_channels=[0],
|
||||||
|
asp_vols=[1],
|
||||||
|
dis_vols=[1],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
run(
|
||||||
|
lh.transfer_liquid(
|
||||||
|
sources=[src_b],
|
||||||
|
targets=[tgt_b],
|
||||||
|
tip_racks=[],
|
||||||
|
use_channels=[0],
|
||||||
|
asp_vols=[1],
|
||||||
|
dis_vols=[1],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# 两次调用各自独立换 tip → 2 次 pick_up_tips / 2 次 discard_tips
|
||||||
|
assert call_names(lh).count("pick_up_tips") == 2
|
||||||
|
assert call_names(lh).count("discard_tips") == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# T9:8 通道段锚孔 liquids-keep
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@_skip_if_no_plr
|
||||||
|
class TestEightChannelSegmentTipReuse:
|
||||||
|
"""T9:8 通道分段,连续两段 src_slice[0] 同名 → 段间不换 tip。"""
|
||||||
|
|
||||||
|
def test_T9_two_segments_same_anchor_liquid(self) -> None:
|
||||||
|
lh = FakeLiquidHandler(channel_num=8)
|
||||||
|
# 16 个 source wells,分 2 段;段 1 锚孔 = sources[0],段 2 锚孔 = sources[8]
|
||||||
|
sources = [make_well(f"S{i}", "PBS") for i in range(16)]
|
||||||
|
targets = [make_well(f"T{i}") for i in range(16)]
|
||||||
|
run(
|
||||||
|
lh.transfer_liquid(
|
||||||
|
sources=sources,
|
||||||
|
targets=targets,
|
||||||
|
tip_racks=[],
|
||||||
|
use_channels=list(range(8)),
|
||||||
|
asp_vols=[1] * 16,
|
||||||
|
dis_vols=[1] * 16,
|
||||||
|
mix_times=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# 2 段都同液 → liquids-keep 命中 → 仅 1 次 pick_up_tips
|
||||||
|
assert call_names(lh).count("pick_up_tips") == 1
|
||||||
|
assert call_names(lh).count("discard_tips") == 1
|
||||||
|
|
||||||
|
def test_T9b_two_segments_different_anchor_liquid_forces_tip_change(self) -> None:
|
||||||
|
"""T9b:段 1 锚孔 = PBS,段 2 锚孔 = Tris → 段间强制换 tip。"""
|
||||||
|
lh = FakeLiquidHandler(channel_num=8)
|
||||||
|
seg1 = [make_well(f"S{i}", "PBS") for i in range(8)]
|
||||||
|
seg2 = [make_well(f"S{i + 8}", "Tris HCl") for i in range(8)]
|
||||||
|
sources = seg1 + seg2
|
||||||
|
targets = [make_well(f"T{i}") for i in range(16)]
|
||||||
|
run(
|
||||||
|
lh.transfer_liquid(
|
||||||
|
sources=sources,
|
||||||
|
targets=targets,
|
||||||
|
tip_racks=[],
|
||||||
|
use_channels=list(range(8)),
|
||||||
|
asp_vols=[1] * 16,
|
||||||
|
dis_vols=[1] * 16,
|
||||||
|
mix_times=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# 2 段不同液 → 2 次独立换 tip
|
||||||
|
assert call_names(lh).count("pick_up_tips") == 2
|
||||||
|
assert call_names(lh).count("discard_tips") == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 配置开关默认值 / 实例字段读取
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@_skip_if_no_plr
|
||||||
|
class TestConfigDefault:
|
||||||
|
def test_default_switch_is_on(self) -> None:
|
||||||
|
"""默认 ``_tip_reuse_by_liquid_name`` 应为 True(测试 fixture 显式 default 一致)。"""
|
||||||
|
lh = FakeLiquidHandler()
|
||||||
|
assert lh._tip_reuse_by_liquid_name is True
|
||||||
|
|
||||||
|
def test_switch_off_takes_effect(self) -> None:
|
||||||
|
lh = FakeLiquidHandler(tip_reuse_by_liquid_name=False)
|
||||||
|
assert lh._tip_reuse_by_liquid_name is False
|
||||||
137
tests/resources/test_resource_tracker_history.py
Normal file
137
tests/resources/test_resource_tracker_history.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
"""P9 — ``_augment_states_with_liquid_history`` 单元测试(OS→Cloud sync 链路 Phase C)。
|
||||||
|
|
||||||
|
详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md`` §6.3 / §8 T4。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from unilabos.resources.resource_tracker import _augment_states_with_liquid_history
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures:纯 dataclass 模拟 PLR 资源树(避免引入 PLR 真实实例化)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FakeTracker:
|
||||||
|
liquid_history: Any = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FakeResource:
|
||||||
|
name: str
|
||||||
|
tracker: Any = None
|
||||||
|
children: List["FakeResource"] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestAugmentStatesWithLiquidHistory:
|
||||||
|
def test_single_well_history_attached(self) -> None:
|
||||||
|
well = FakeResource("well_A1", tracker=FakeTracker(liquid_history=[
|
||||||
|
{"name": "Plasma", "volume": 100, "action": "set"}
|
||||||
|
]))
|
||||||
|
states: Dict[str, Any] = {"well_A1": {"liquids": [], "pending_liquids": []}}
|
||||||
|
|
||||||
|
_augment_states_with_liquid_history(well, states)
|
||||||
|
|
||||||
|
assert "liquid_history" in states["well_A1"]
|
||||||
|
assert states["well_A1"]["liquid_history"] == [
|
||||||
|
{"name": "Plasma", "volume": 100, "action": "set"}
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_recursive_walk_attaches_to_all_wells(self) -> None:
|
||||||
|
"""resource 树有多层时,每个有 tracker 的节点都会被并入 states。"""
|
||||||
|
wells = [
|
||||||
|
FakeResource(f"well_{i}", tracker=FakeTracker(liquid_history=[
|
||||||
|
{"name": f"L_{i}", "volume": i * 10, "action": "set"}
|
||||||
|
]))
|
||||||
|
for i in range(3)
|
||||||
|
]
|
||||||
|
plate = FakeResource("plate", children=wells)
|
||||||
|
deck = FakeResource("deck", children=[plate])
|
||||||
|
states: Dict[str, Any] = {
|
||||||
|
"deck": {"liquids": []},
|
||||||
|
"plate": {"liquids": []},
|
||||||
|
"well_0": {"liquids": []},
|
||||||
|
"well_1": {"liquids": []},
|
||||||
|
"well_2": {"liquids": []},
|
||||||
|
}
|
||||||
|
|
||||||
|
_augment_states_with_liquid_history(deck, states)
|
||||||
|
|
||||||
|
assert states["well_0"]["liquid_history"] == [{"name": "L_0", "volume": 0, "action": "set"}]
|
||||||
|
assert states["well_1"]["liquid_history"] == [{"name": "L_1", "volume": 10, "action": "set"}]
|
||||||
|
assert states["well_2"]["liquid_history"] == [{"name": "L_2", "volume": 20, "action": "set"}]
|
||||||
|
|
||||||
|
def test_no_tracker_node_skipped(self) -> None:
|
||||||
|
"""没有 tracker 的节点(如 deck 自身)跳过,state dict 不被污染。"""
|
||||||
|
deck = FakeResource("deck") # tracker=None
|
||||||
|
states: Dict[str, Any] = {"deck": {"some_field": 1}}
|
||||||
|
|
||||||
|
_augment_states_with_liquid_history(deck, states)
|
||||||
|
|
||||||
|
assert "liquid_history" not in states["deck"]
|
||||||
|
|
||||||
|
def test_existing_liquid_history_in_state_not_overwritten(self) -> None:
|
||||||
|
"""state 已经有 liquid_history 字段(例如 PLR 升级未来支持了)→ 不覆盖。"""
|
||||||
|
well = FakeResource("well_A1", tracker=FakeTracker(liquid_history=[
|
||||||
|
{"name": "Plasma", "volume": 100, "action": "set"}
|
||||||
|
]))
|
||||||
|
states: Dict[str, Any] = {"well_A1": {"liquid_history": ["preexisting"]}}
|
||||||
|
|
||||||
|
_augment_states_with_liquid_history(well, states)
|
||||||
|
|
||||||
|
assert states["well_A1"]["liquid_history"] == ["preexisting"]
|
||||||
|
|
||||||
|
def test_history_is_shallow_copied(self) -> None:
|
||||||
|
"""augment 后的 history 应是独立 list(避免运行时 mutate 污染 dump 结果)。"""
|
||||||
|
original_history = [{"name": "X", "volume": 1, "action": "set"}]
|
||||||
|
well = FakeResource("well_A1", tracker=FakeTracker(liquid_history=original_history))
|
||||||
|
states: Dict[str, Any] = {"well_A1": {}}
|
||||||
|
|
||||||
|
_augment_states_with_liquid_history(well, states)
|
||||||
|
|
||||||
|
# mutate runtime history 不应反映到 augmented state
|
||||||
|
original_history.append({"name": "Y", "volume": 2, "action": "set"})
|
||||||
|
assert len(states["well_A1"]["liquid_history"]) == 1
|
||||||
|
|
||||||
|
def test_node_not_in_states_silently_skipped(self) -> None:
|
||||||
|
"""resource 树中的节点 name 不在 ``states`` 字典里 → 静默跳过。"""
|
||||||
|
well = FakeResource("well_orphan", tracker=FakeTracker(liquid_history=[
|
||||||
|
{"name": "X", "volume": 1, "action": "set"}
|
||||||
|
]))
|
||||||
|
states: Dict[str, Any] = {"well_A1": {}}
|
||||||
|
|
||||||
|
_augment_states_with_liquid_history(well, states)
|
||||||
|
|
||||||
|
# 不应该新增 well_orphan 键,也不应污染 well_A1
|
||||||
|
assert "well_orphan" not in states
|
||||||
|
assert "liquid_history" not in states["well_A1"]
|
||||||
|
|
||||||
|
def test_non_list_liquid_history_skipped(self) -> None:
|
||||||
|
"""tracker.liquid_history 非 list 时(异常情况)→ 跳过,不写入 state。"""
|
||||||
|
well = FakeResource("well_A1", tracker=FakeTracker(liquid_history="broken"))
|
||||||
|
states: Dict[str, Any] = {"well_A1": {}}
|
||||||
|
|
||||||
|
_augment_states_with_liquid_history(well, states)
|
||||||
|
|
||||||
|
assert "liquid_history" not in states["well_A1"]
|
||||||
|
|
||||||
|
def test_empty_history_still_written(self) -> None:
|
||||||
|
"""tracker.liquid_history = [] 是合法状态 → 应写入空 list(表示"未有任何液体操作")。"""
|
||||||
|
well = FakeResource("well_A1", tracker=FakeTracker(liquid_history=[]))
|
||||||
|
states: Dict[str, Any] = {"well_A1": {}}
|
||||||
|
|
||||||
|
_augment_states_with_liquid_history(well, states)
|
||||||
|
|
||||||
|
assert states["well_A1"]["liquid_history"] == []
|
||||||
351
tests/workflow/test_build_protocol_graph_target_device.py
Normal file
351
tests/workflow/test_build_protocol_graph_target_device.py
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
"""P6.1 / P6.1.1 `build_protocol_graph` 集成测试 —— 对应 06-labware-mapping-table.md §11.7.7 C / §11.8.7 C。
|
||||||
|
|
||||||
|
6 条用例:
|
||||||
|
|
||||||
|
- `test_build_graph_default_target_device_prcxi` —— 不传 target_device 时默认 "prcxi",
|
||||||
|
与 P6 等价(PRCXI_* class_name)。
|
||||||
|
- `test_build_graph_explicit_target_device_prcxi` —— 显式 "prcxi" 与默认完全等价。
|
||||||
|
- `test_build_graph_target_device_unknown_falls_back_to_default_section` —— 未声明的
|
||||||
|
target_device 由 loader 自动 fallback 到 ``target_devices.default``;第一版 default
|
||||||
|
段按 prcxi 拷贝,所以结果应与 "prcxi" 完全一致。
|
||||||
|
- `test_build_graph_per_device_tip_class` —— 临时 YAML 同时声明 prcxi 与 beckman tip
|
||||||
|
量程档;同一 transfer_liquid 在 target_device="prcxi" / "beckman" 下命中不同 class。
|
||||||
|
- `test_field_renamed_target_class_name` —— `labware_info` 写入的字段是
|
||||||
|
`target_class_name`,**旧字段 `prcxi_class_name` 不存在**。
|
||||||
|
- `test_build_graph_model_level_slot_remap` —— P6.1.1:``target_model`` 透传到
|
||||||
|
``_map_deck_slot`` 后改变 create_resource 的 slot(同厂商不同型号 deck 物理布局不同)。
|
||||||
|
|
||||||
|
本测试在导入 common.py 之前 mock 掉 matplotlib / networkx.drawing.nx_agraph,避免在
|
||||||
|
没有图形依赖的最小 Python 环境下也能跑(与 P6 批量回归脚本同样的策略)。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import warnings
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||||
|
if str(ROOT_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT_DIR))
|
||||||
|
|
||||||
|
|
||||||
|
def _install_fake_optional_deps() -> None:
|
||||||
|
"""安装 matplotlib / networkx.drawing.nx_agraph 的 fake 实现,避免本地环境硬依赖。
|
||||||
|
|
||||||
|
common.py 在模块级 import 这些库做可视化辅助;build_protocol_graph 主路径不会真用到。
|
||||||
|
fake 模块只需要满足 ``from X import Y`` 的查找即可。
|
||||||
|
"""
|
||||||
|
if "matplotlib" not in sys.modules:
|
||||||
|
fake_matplotlib = types.ModuleType("matplotlib")
|
||||||
|
sys.modules["matplotlib"] = fake_matplotlib
|
||||||
|
if "matplotlib.pyplot" not in sys.modules:
|
||||||
|
fake_plt = types.ModuleType("matplotlib.pyplot")
|
||||||
|
sys.modules["matplotlib.pyplot"] = fake_plt
|
||||||
|
# networkx.drawing.nx_agraph.to_agraph 依赖 pygraphviz;不可用时给个空 stub
|
||||||
|
try:
|
||||||
|
from networkx.drawing import nx_agraph # noqa: F401
|
||||||
|
except Exception:
|
||||||
|
nx_drawing = types.ModuleType("networkx.drawing")
|
||||||
|
nx_agraph_mod = types.ModuleType("networkx.drawing.nx_agraph")
|
||||||
|
|
||||||
|
def _to_agraph(_g): # type: ignore[no-untyped-def]
|
||||||
|
raise RuntimeError("nx_agraph fake — not used in build_protocol_graph main path")
|
||||||
|
|
||||||
|
nx_agraph_mod.to_agraph = _to_agraph # type: ignore[attr-defined]
|
||||||
|
nx_drawing.nx_agraph = nx_agraph_mod # type: ignore[attr-defined]
|
||||||
|
sys.modules["networkx.drawing"] = nx_drawing
|
||||||
|
sys.modules["networkx.drawing.nx_agraph"] = nx_agraph_mod
|
||||||
|
|
||||||
|
|
||||||
|
_install_fake_optional_deps()
|
||||||
|
|
||||||
|
from unilabos.workflow import labware_mapping as lm # noqa: E402
|
||||||
|
from unilabos.workflow.common import build_protocol_graph # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_mapping_cache():
|
||||||
|
"""每个用例后清 lru_cache,避免跨用例污染。"""
|
||||||
|
yield
|
||||||
|
lm.reload_mapping()
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 公共 fixture:最小 transfer_liquid 协议 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def _minimal_labware_info() -> dict:
|
||||||
|
"""返回最小可用的 labware_info(mutable,每个 case 独立 build 一份)。
|
||||||
|
|
||||||
|
包含 tip rack + 24-tube rack + 96 wellplate(slot 1/2/3),覆盖 P6.1 主要 kind。
|
||||||
|
tube rack / plate 显式声明 ``num_wells``,避免在无 labware_defs / 无 prcxi_labware 模板
|
||||||
|
时通过 well-count 启发式(well_n=3)误判孔数;与真实协议中 labware_defs 提供 num_wells
|
||||||
|
的行为对齐。
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"tips": {
|
||||||
|
"slot": 1,
|
||||||
|
"well": [],
|
||||||
|
"labware": "opentrons_96_tiprack_300ul",
|
||||||
|
"object": "tiprack",
|
||||||
|
},
|
||||||
|
"samples": {
|
||||||
|
"slot": 2,
|
||||||
|
"well": ["A1", "A2", "A3"],
|
||||||
|
"labware": "opentrons_24_tuberack_eppendorf_2ml_safelock_snapcap",
|
||||||
|
"object": "source",
|
||||||
|
"num_wells": 24,
|
||||||
|
},
|
||||||
|
"plate_target": {
|
||||||
|
"slot": 3,
|
||||||
|
"well": ["A1", "A2", "A3"],
|
||||||
|
"labware": "opentrons_96_wellplate_300ul_pcr",
|
||||||
|
"object": "target",
|
||||||
|
"num_wells": 96,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _minimal_protocol_steps() -> list:
|
||||||
|
"""最小 transfer_liquid 协议步骤:asp_vols/dis_vols 最大 200 µL → PRCXI 300ul 档。"""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"parameters": {
|
||||||
|
"sources": "samples",
|
||||||
|
"targets": "plate_target",
|
||||||
|
"tip_racks": "tips",
|
||||||
|
"asp_vols": [200.0, 200.0, 200.0],
|
||||||
|
"dis_vols": [200.0, 200.0, 200.0],
|
||||||
|
},
|
||||||
|
"step_number": 1,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_create_resource_classes(graph) -> dict:
|
||||||
|
"""从工作流图中提取每个 create_resource 节点的 ``slot_on_deck → class_name``。"""
|
||||||
|
out: dict = {}
|
||||||
|
for _nid, node in graph.nodes.items():
|
||||||
|
if node.get("template_name") != "create_resource":
|
||||||
|
continue
|
||||||
|
param = node.get("param") or {}
|
||||||
|
slot = str(param.get("slot_on_deck") or "")
|
||||||
|
cls = str(param.get("class_name") or "")
|
||||||
|
if slot:
|
||||||
|
out[slot] = cls
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 5 条核心用例 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_graph_default_target_device_prcxi():
|
||||||
|
"""不传 target_device → 默认 "prcxi" → 与 P6 等价(PRCXI_* class_name)。"""
|
||||||
|
labware_info = _minimal_labware_info()
|
||||||
|
g = build_protocol_graph(
|
||||||
|
labware_info=labware_info,
|
||||||
|
protocol_steps=_minimal_protocol_steps(),
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
classes = _collect_create_resource_classes(g)
|
||||||
|
assert classes["1"] == "PRCXI_300ul_Tips" # 200 µL → 300 档
|
||||||
|
assert classes["2"] == "PRCXI_EP_Adapter" # 24-tube rack
|
||||||
|
assert classes["3"] == "PRCXI_BioER_96_wellplate" # 96 wellplate
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_graph_explicit_target_device_prcxi():
|
||||||
|
"""显式传 target_device="prcxi" 应与默认完全等价。"""
|
||||||
|
labware_info_a = _minimal_labware_info()
|
||||||
|
labware_info_b = _minimal_labware_info()
|
||||||
|
g_default = build_protocol_graph(
|
||||||
|
labware_info=labware_info_a,
|
||||||
|
protocol_steps=_minimal_protocol_steps(),
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
g_prcxi = build_protocol_graph(
|
||||||
|
labware_info=labware_info_b,
|
||||||
|
protocol_steps=_minimal_protocol_steps(),
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
target_device="prcxi",
|
||||||
|
)
|
||||||
|
assert _collect_create_resource_classes(g_default) == _collect_create_resource_classes(g_prcxi)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_graph_target_device_unknown_falls_back_to_default_section():
|
||||||
|
"""未声明的 target_device → loader 自动 fallback 到固定段 target_devices.default + warning。
|
||||||
|
|
||||||
|
第一版 default 段按 prcxi 拷贝填充 → 结果应与 target_device="prcxi" 完全等价(PRCXI_*)。
|
||||||
|
"""
|
||||||
|
labware_info_a = _minimal_labware_info()
|
||||||
|
labware_info_b = _minimal_labware_info()
|
||||||
|
g_prcxi = build_protocol_graph(
|
||||||
|
labware_info=labware_info_a,
|
||||||
|
protocol_steps=_minimal_protocol_steps(),
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
target_device="prcxi",
|
||||||
|
)
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
g_unknown = build_protocol_graph(
|
||||||
|
labware_info=labware_info_b,
|
||||||
|
protocol_steps=_minimal_protocol_steps(),
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
target_device="unknown_xxx",
|
||||||
|
)
|
||||||
|
assert _collect_create_resource_classes(g_unknown) == _collect_create_resource_classes(g_prcxi)
|
||||||
|
# loader 至少打 1 次 warning 提示「未声明、已回退到 default」
|
||||||
|
assert any(
|
||||||
|
("未在 labware_mapping.yaml" in str(w.message))
|
||||||
|
or ("target_devices.default" in str(w.message))
|
||||||
|
for w in caught
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_graph_per_device_tip_class(tmp_path, monkeypatch):
|
||||||
|
"""同一 protocol,target_device="prcxi" / "beckman" 在 200µL 下命中不同 tip 档(P6.1.1 schema)。"""
|
||||||
|
yaml_path = tmp_path / "labware_mapping.yaml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
'kinds:\n'
|
||||||
|
' - {pattern: "trash", kind: trash}\n'
|
||||||
|
' - {pattern: "tiprack|tip[_ ]?rack|opentrons_\\\\d+_tiprack", kind: tip_rack}\n'
|
||||||
|
' - {pattern: "tuberack|tube[_ ]rack|eppendorf.*rack|safelock.*rack", kind: tube_rack}\n'
|
||||||
|
' - {pattern: ".*", kind: plate}\n'
|
||||||
|
'target_devices:\n'
|
||||||
|
' default:\n'
|
||||||
|
' slot_remap: {default: {"4": "13", "8": "14"}, by_object: {trash: {"12": "16"}}}\n'
|
||||||
|
' rules:\n'
|
||||||
|
' - {kind: tip_rack, hole_count: 96, volume_max: 10, class_name: PRCXI_10uL_Tips}\n'
|
||||||
|
' - {kind: tip_rack, hole_count: 96, volume_max: 299.9, class_name: PRCXI_300ul_Tips}\n'
|
||||||
|
' - {kind: tip_rack, hole_count: 96, class_name: PRCXI_1000uL_Tips}\n'
|
||||||
|
' - {kind: tube_rack, hole_count: 24, class_name: PRCXI_EP_Adapter}\n'
|
||||||
|
' - {kind: plate, hole_count: 96, class_name: PRCXI_BioER_96_wellplate}\n'
|
||||||
|
' prcxi:\n'
|
||||||
|
' slot_remap: {default: {"4": "13", "8": "14"}, by_object: {trash: {"12": "16"}}}\n'
|
||||||
|
' rules:\n'
|
||||||
|
' - {kind: tip_rack, hole_count: 96, volume_max: 10, class_name: PRCXI_10uL_Tips}\n'
|
||||||
|
' - {kind: tip_rack, hole_count: 96, volume_max: 299.9, class_name: PRCXI_300ul_Tips}\n'
|
||||||
|
' - {kind: tip_rack, hole_count: 96, class_name: PRCXI_1000uL_Tips}\n'
|
||||||
|
' - {kind: tube_rack, hole_count: 24, class_name: PRCXI_EP_Adapter}\n'
|
||||||
|
' - {kind: plate, hole_count: 96, class_name: PRCXI_BioER_96_wellplate}\n'
|
||||||
|
' beckman:\n'
|
||||||
|
' slot_remap: {default: {"4": "13"}, by_object: {trash: {"12": "16"}}}\n'
|
||||||
|
' rules:\n'
|
||||||
|
' - {kind: tip_rack, hole_count: 96, volume_max: 20, class_name: Beckman_20uL_Tips}\n'
|
||||||
|
' - {kind: tip_rack, hole_count: 96, volume_max: 199.9, class_name: Beckman_200uL_Tips}\n'
|
||||||
|
' - {kind: tip_rack, hole_count: 96, class_name: Beckman_1000uL_Tips}\n'
|
||||||
|
' - {kind: tube_rack, hole_count: 24, class_name: Beckman_24_TubeRack}\n'
|
||||||
|
' - {kind: plate, hole_count: 96, class_name: Beckman_BioMek_96_wellplate}\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
|
||||||
|
lm.reload_mapping()
|
||||||
|
|
||||||
|
g_prcxi = build_protocol_graph(
|
||||||
|
labware_info=_minimal_labware_info(),
|
||||||
|
protocol_steps=_minimal_protocol_steps(),
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
target_device="prcxi",
|
||||||
|
)
|
||||||
|
g_beckman = build_protocol_graph(
|
||||||
|
labware_info=_minimal_labware_info(),
|
||||||
|
protocol_steps=_minimal_protocol_steps(),
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
target_device="beckman",
|
||||||
|
)
|
||||||
|
|
||||||
|
classes_prcxi = _collect_create_resource_classes(g_prcxi)
|
||||||
|
classes_beckman = _collect_create_resource_classes(g_beckman)
|
||||||
|
|
||||||
|
# 200 µL:prcxi 走 300 档;beckman 200 档已超 → 1000 档
|
||||||
|
assert classes_prcxi["1"] == "PRCXI_300ul_Tips"
|
||||||
|
assert classes_beckman["1"] == "Beckman_1000uL_Tips"
|
||||||
|
# plate / tube rack 也按 target_device 输出对应厂商类
|
||||||
|
assert classes_prcxi["2"] == "PRCXI_EP_Adapter"
|
||||||
|
assert classes_beckman["2"] == "Beckman_24_TubeRack"
|
||||||
|
assert classes_prcxi["3"] == "PRCXI_BioER_96_wellplate"
|
||||||
|
assert classes_beckman["3"] == "Beckman_BioMek_96_wellplate"
|
||||||
|
|
||||||
|
|
||||||
|
def test_field_renamed_target_class_name():
|
||||||
|
"""`labware_info` 写入的字段是 `target_class_name`;旧字段 `prcxi_class_name` 不存在。"""
|
||||||
|
labware_info = _minimal_labware_info()
|
||||||
|
build_protocol_graph(
|
||||||
|
labware_info=labware_info,
|
||||||
|
protocol_steps=_minimal_protocol_steps(),
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
for lid, item in labware_info.items():
|
||||||
|
assert "target_class_name" in item, f"{lid!r} 缺少 target_class_name 字段"
|
||||||
|
assert "prcxi_class_name" not in item, f"{lid!r} 残留了旧字段 prcxi_class_name"
|
||||||
|
assert item["target_class_name"], f"{lid!r} target_class_name 为空"
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== P6.1.1 新增集成测试 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def _labware_info_slot4_plate() -> dict:
|
||||||
|
"""slot=4 的 96 板:用来验证 target_model 透传后 slot_remap 改变 create_resource 的槽位。"""
|
||||||
|
return {
|
||||||
|
"plate_slot4": {
|
||||||
|
"slot": 4,
|
||||||
|
"well": ["A1"],
|
||||||
|
"labware": "opentrons_96_wellplate_300ul_pcr",
|
||||||
|
"object": "target",
|
||||||
|
"num_wells": 96,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_graph_model_level_slot_remap(tmp_path, monkeypatch):
|
||||||
|
"""P6.1.1:target_model 透传到 _map_deck_slot 后改变 create_resource 的 slot_on_deck。
|
||||||
|
|
||||||
|
YAML 中 prcxi 厂商级 slot_remap 4→13;模型 "4040" 显式覆盖 4→16。
|
||||||
|
同一份 labware_info(slot=4)build 出的两份图,slot_on_deck 应分别为 "13" 与 "16"。
|
||||||
|
"""
|
||||||
|
yaml_path = tmp_path / "labware_mapping.yaml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
'kinds: [{pattern: ".*", kind: plate}]\n'
|
||||||
|
'target_devices:\n'
|
||||||
|
' default:\n'
|
||||||
|
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
||||||
|
' rules: [{kind: plate, hole_count: 96, class_name: PRCXI_BioER_96_wellplate}]\n'
|
||||||
|
' prcxi:\n'
|
||||||
|
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
||||||
|
' rules: [{kind: plate, hole_count: 96, class_name: PRCXI_BioER_96_wellplate}]\n'
|
||||||
|
' models:\n'
|
||||||
|
' "4040":\n'
|
||||||
|
' slot_remap: {default: {"4": "16"}, by_object: {}}\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
|
||||||
|
lm.reload_mapping()
|
||||||
|
|
||||||
|
g_default = build_protocol_graph(
|
||||||
|
labware_info=_labware_info_slot4_plate(),
|
||||||
|
protocol_steps=[],
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
target_device="prcxi",
|
||||||
|
)
|
||||||
|
g_model_4040 = build_protocol_graph(
|
||||||
|
labware_info=_labware_info_slot4_plate(),
|
||||||
|
protocol_steps=[],
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
target_device="prcxi",
|
||||||
|
target_model="4040",
|
||||||
|
)
|
||||||
|
|
||||||
|
classes_default = _collect_create_resource_classes(g_default)
|
||||||
|
classes_4040 = _collect_create_resource_classes(g_model_4040)
|
||||||
|
|
||||||
|
# 厂商级(无 model)→ slot 4 → "13"
|
||||||
|
assert "13" in classes_default, f"未找到 slot 13,实际生成的 slots: {list(classes_default)}"
|
||||||
|
assert "16" not in classes_default
|
||||||
|
# 模型 4040 → slot 4 → "16"
|
||||||
|
assert "16" in classes_4040, f"未找到 slot 16,实际生成的 slots: {list(classes_4040)}"
|
||||||
|
assert "13" not in classes_4040
|
||||||
|
# class_name 不变(rules 继承厂商级)
|
||||||
|
assert classes_default["13"] == "PRCXI_BioER_96_wellplate"
|
||||||
|
assert classes_4040["16"] == "PRCXI_BioER_96_wellplate"
|
||||||
369
tests/workflow/test_common_cross_slot_v2.py
Normal file
369
tests/workflow/test_common_cross_slot_v2.py
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
"""P2 v2 跨 slot transfer_liquid 合并 —— Stage 3 (`workflow/common.py`) 集成测试。
|
||||||
|
|
||||||
|
对应 ``product_designs/protocol_convert/02-cross-slot-merge.md`` §9.5 step 6.2。
|
||||||
|
|
||||||
|
v2 设计要点(与本测试用例的映射)
|
||||||
|
-----------------------------------
|
||||||
|
当 transfer_liquid 节点 ``params.targets`` 是 ``list[str]`` 时,``build_protocol_graph``
|
||||||
|
在该 transfer_liquid 之前**插入一个 merged ``set_liquid_from_plate`` 节点**:
|
||||||
|
|
||||||
|
- merged 节点的 ``param.wells`` 是按 ``params.targets`` 顺序通过 cursor 拼出来的有序跨板
|
||||||
|
well refs(每个元素是 ``{id, name, parent: reagent_key, type: "well"}``)。
|
||||||
|
- merged 节点接收来自每个涉及 plate 的 ``create_resource`` 节点的多入边
|
||||||
|
(``labware`` → ``wells_identifier``)。
|
||||||
|
- merged 节点的 ``output_wells`` 通过**单条边**连到 transfer_liquid 的 ``targets_identifier``。
|
||||||
|
- transfer_liquid 节点的 ``params.targets`` 被改写为 synthetic key
|
||||||
|
``_merged_targets_<idx>``(runtime 不消费 list 形态),保证 INPUT_PORT_MAPPING 走单边路径。
|
||||||
|
|
||||||
|
用例
|
||||||
|
----
|
||||||
|
- ``test_emit_merged_set_liquid_basic`` — 4 个 distinct reagent_key(51b9a5 主场景)。
|
||||||
|
- ``test_emit_merged_set_liquid_repeat_key`` — 同 reagent_key 重复(同板多孔)。
|
||||||
|
- ``test_emit_merged_set_liquid_mixed`` — 跨板混合 + 同板重复(cursor 推进)。
|
||||||
|
- ``test_emit_merged_set_liquid_8ch`` — 与 P1 multi-channel 复合(8 通道 cross-slot)。
|
||||||
|
- ``test_transfer_liquid_targets_rewrite`` — transfer_liquid 节点改写后只剩 1 条
|
||||||
|
``targets_identifier`` 入边;params.targets 不再是 list。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||||
|
if str(ROOT_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT_DIR))
|
||||||
|
|
||||||
|
|
||||||
|
def _install_fake_optional_deps() -> None:
|
||||||
|
"""与 test_build_protocol_graph_target_device.py 一致的可选依赖 stub。"""
|
||||||
|
if "matplotlib" not in sys.modules:
|
||||||
|
sys.modules["matplotlib"] = types.ModuleType("matplotlib")
|
||||||
|
if "matplotlib.pyplot" not in sys.modules:
|
||||||
|
sys.modules["matplotlib.pyplot"] = types.ModuleType("matplotlib.pyplot")
|
||||||
|
try:
|
||||||
|
from networkx.drawing import nx_agraph # noqa: F401
|
||||||
|
except Exception:
|
||||||
|
nx_drawing = types.ModuleType("networkx.drawing")
|
||||||
|
nx_agraph_mod = types.ModuleType("networkx.drawing.nx_agraph")
|
||||||
|
nx_agraph_mod.to_agraph = lambda _g: None # type: ignore[attr-defined]
|
||||||
|
nx_drawing.nx_agraph = nx_agraph_mod # type: ignore[attr-defined]
|
||||||
|
sys.modules["networkx.drawing"] = nx_drawing
|
||||||
|
sys.modules["networkx.drawing.nx_agraph"] = nx_agraph_mod
|
||||||
|
|
||||||
|
|
||||||
|
_install_fake_optional_deps()
|
||||||
|
|
||||||
|
import pytest # noqa: E402
|
||||||
|
|
||||||
|
from unilabos.workflow.common import build_protocol_graph # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 测试辅助:从工作流图中提取节点/边 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def _nodes_by_template(graph, template_name: str) -> List[Dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{"id": nid, **node}
|
||||||
|
for nid, node in graph.nodes.items()
|
||||||
|
if node.get("template_name") == template_name
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _create_resource_by_slot(graph) -> Dict[str, str]:
|
||||||
|
"""slot_on_deck (str) -> create_resource 节点 ID。"""
|
||||||
|
out: Dict[str, str] = {}
|
||||||
|
for nid, node in graph.nodes.items():
|
||||||
|
if node.get("template_name") == "create_resource":
|
||||||
|
slot = str(node.get("param", {}).get("slot_on_deck") or "")
|
||||||
|
if slot:
|
||||||
|
out[slot] = nid
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _edges_to(graph, target_id: str) -> List[Dict[str, Any]]:
|
||||||
|
return [e for e in graph.edges if e["target"] == target_id]
|
||||||
|
|
||||||
|
|
||||||
|
def _edges_from(graph, source_id: str) -> List[Dict[str, Any]]:
|
||||||
|
return [e for e in graph.edges if e["source"] == source_id]
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== fixture:构造跨板 labware + steps ====================
|
||||||
|
|
||||||
|
|
||||||
|
def _cross_slot_labware_info() -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""51b9a5 简化:slot1 source + slot2/3/5/6 target plates + slot12 tip。"""
|
||||||
|
return {
|
||||||
|
"l1": {
|
||||||
|
"slot": 1,
|
||||||
|
"well": ["A1"],
|
||||||
|
"labware": "nest_12_reservoir_15ml",
|
||||||
|
"object": "source",
|
||||||
|
},
|
||||||
|
"plate_slot2": {
|
||||||
|
"slot": 2,
|
||||||
|
"well": ["A1"],
|
||||||
|
"labware": "nest_96_wellplate_2ml_deep",
|
||||||
|
"object": "target",
|
||||||
|
},
|
||||||
|
"plate_slot3": {
|
||||||
|
"slot": 3,
|
||||||
|
"well": ["A1"],
|
||||||
|
"labware": "nest_96_wellplate_2ml_deep",
|
||||||
|
"object": "target",
|
||||||
|
},
|
||||||
|
"plate_slot5": {
|
||||||
|
"slot": 5,
|
||||||
|
"well": ["A1"],
|
||||||
|
"labware": "nest_96_wellplate_2ml_deep",
|
||||||
|
"object": "target",
|
||||||
|
},
|
||||||
|
"plate_slot6": {
|
||||||
|
"slot": 6,
|
||||||
|
"well": ["A1"],
|
||||||
|
"labware": "nest_96_wellplate_2ml_deep",
|
||||||
|
"object": "target",
|
||||||
|
},
|
||||||
|
"tiprack_12": {
|
||||||
|
"slot": 12,
|
||||||
|
"well": [],
|
||||||
|
"labware": "opentrons_96_tiprack_300ul",
|
||||||
|
"object": "tiprack",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _cross_slot_protocol_steps(targets: List[str], dis_vols: List[float]) -> List[Dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"parameters": {
|
||||||
|
"sources": "l1",
|
||||||
|
"targets": targets,
|
||||||
|
"tip_racks": "tiprack_12",
|
||||||
|
"asp_vols": dis_vols.copy(),
|
||||||
|
"dis_vols": dis_vols.copy(),
|
||||||
|
},
|
||||||
|
"step_number": 1,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 用例 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def test_emit_merged_set_liquid_basic():
|
||||||
|
"""51b9a5 主场景:targets=[A,B,C,D] → 1 merged set_liquid 节点
|
||||||
|
+ 4 条入边(来自 4 个 distinct create_resource)+ 1 条出边(去 transfer_liquid)。
|
||||||
|
"""
|
||||||
|
targets = ["plate_slot2", "plate_slot3", "plate_slot5", "plate_slot6"]
|
||||||
|
dis_vols = [8.3, 8.3, 8.3, 8.3]
|
||||||
|
g = build_protocol_graph(
|
||||||
|
labware_info=_cross_slot_labware_info(),
|
||||||
|
protocol_steps=_cross_slot_protocol_steps(targets, dis_vols),
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
|
||||||
|
set_liquid_nodes = _nodes_by_template(g, "set_liquid_from_plate")
|
||||||
|
merged_nodes = [n for n in set_liquid_nodes if str(n.get("name", "")).startswith("_merged_targets_")]
|
||||||
|
assert len(merged_nodes) == 1, (
|
||||||
|
f"应有且仅有 1 个 merged set_liquid_from_plate 节点(v2 跨板聚合器);"
|
||||||
|
f" 实际找到 {len(merged_nodes)}: {[n.get('name') for n in merged_nodes]}"
|
||||||
|
)
|
||||||
|
merged = merged_nodes[0]
|
||||||
|
merged_id = merged["id"]
|
||||||
|
|
||||||
|
# param.wells:长度 4,每元素的 parent 是对应 reagent_key
|
||||||
|
wells = merged.get("param", {}).get("wells") or []
|
||||||
|
assert len(wells) == 4
|
||||||
|
assert [w["parent"] for w in wells] == targets, "merged.wells 顺序必须严格按 targets 列表"
|
||||||
|
# well 字段映射到 reagent.well[0](都是 "A1")
|
||||||
|
for w, key in zip(wells, targets):
|
||||||
|
assert w["id"].endswith("/A1"), f"well id 应包含 well 名: {w}"
|
||||||
|
assert w["parent"] == key
|
||||||
|
|
||||||
|
# 入边:4 条来自 distinct create_resource 节点(slot 2/3/5/6),target_port=wells_identifier
|
||||||
|
cr_by_slot = _create_resource_by_slot(g)
|
||||||
|
in_edges = _edges_to(g, merged_id)
|
||||||
|
in_sources = {e["source"] for e in in_edges if e.get("target_handle_key") == "wells_identifier"}
|
||||||
|
expected_sources = {cr_by_slot[s] for s in ("2", "3", "5", "6")}
|
||||||
|
assert in_sources == expected_sources, (
|
||||||
|
f"merged 节点应接收 4 个 distinct create_resource 的 wells_identifier 边;"
|
||||||
|
f" 实际 {in_sources} vs 期望 {expected_sources}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 出边:1 条到 transfer_liquid(targets_identifier)
|
||||||
|
transfer_nodes = _nodes_by_template(g, "transfer_liquid")
|
||||||
|
assert len(transfer_nodes) == 1
|
||||||
|
transfer_id = transfer_nodes[0]["id"]
|
||||||
|
out_to_transfer = [
|
||||||
|
e for e in _edges_from(g, merged_id)
|
||||||
|
if e["target"] == transfer_id and e.get("target_handle_key") == "targets_identifier"
|
||||||
|
]
|
||||||
|
assert len(out_to_transfer) == 1, (
|
||||||
|
f"merged 节点应向 transfer_liquid.targets_identifier 发出唯一 1 条边;"
|
||||||
|
f" 实际 {len(out_to_transfer)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_emit_merged_set_liquid_repeat_key():
|
||||||
|
"""同 reagent_key 重复(同板多孔):targets=[A,A,A] + reagent.A.well=[A1,A2,A3]
|
||||||
|
→ merged.wells 顺序 = [A/A1, A/A2, A/A3](cursor 推进取每个 well)。
|
||||||
|
"""
|
||||||
|
labware = _cross_slot_labware_info()
|
||||||
|
labware["plate_slot2"]["well"] = ["A1", "A2", "A3"]
|
||||||
|
|
||||||
|
targets = ["plate_slot2", "plate_slot2", "plate_slot2"]
|
||||||
|
dis_vols = [10.0, 20.0, 30.0]
|
||||||
|
g = build_protocol_graph(
|
||||||
|
labware_info=labware,
|
||||||
|
protocol_steps=_cross_slot_protocol_steps(targets, dis_vols),
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
|
||||||
|
merged_nodes = [
|
||||||
|
n for n in _nodes_by_template(g, "set_liquid_from_plate")
|
||||||
|
if str(n.get("name", "")).startswith("_merged_targets_")
|
||||||
|
]
|
||||||
|
assert len(merged_nodes) == 1
|
||||||
|
wells = merged_nodes[0]["param"]["wells"]
|
||||||
|
assert [w["id"].rsplit("/", 1)[-1] for w in wells] == ["A1", "A2", "A3"], (
|
||||||
|
"cursor 应依次取 reagent.A.well[0/1/2]"
|
||||||
|
)
|
||||||
|
assert all(w["parent"] == "plate_slot2" for w in wells)
|
||||||
|
|
||||||
|
|
||||||
|
def test_emit_merged_set_liquid_mixed():
|
||||||
|
"""跨板 + 同板重复:targets=[A,B,A,C] + reagent.A.well=[A1,A2]
|
||||||
|
→ merged.wells = [A/A1, B/A1, A/A2, C/A1]。
|
||||||
|
"""
|
||||||
|
labware = _cross_slot_labware_info()
|
||||||
|
labware["plate_slot2"]["well"] = ["A1", "A2"]
|
||||||
|
|
||||||
|
targets = ["plate_slot2", "plate_slot3", "plate_slot2", "plate_slot5"]
|
||||||
|
dis_vols = [10.0, 20.0, 30.0, 40.0]
|
||||||
|
g = build_protocol_graph(
|
||||||
|
labware_info=labware,
|
||||||
|
protocol_steps=_cross_slot_protocol_steps(targets, dis_vols),
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
|
||||||
|
merged_nodes = [
|
||||||
|
n for n in _nodes_by_template(g, "set_liquid_from_plate")
|
||||||
|
if str(n.get("name", "")).startswith("_merged_targets_")
|
||||||
|
]
|
||||||
|
assert len(merged_nodes) == 1
|
||||||
|
wells = merged_nodes[0]["param"]["wells"]
|
||||||
|
ids = [(w["parent"], w["id"].rsplit("/", 1)[-1]) for w in wells]
|
||||||
|
assert ids == [
|
||||||
|
("plate_slot2", "A1"),
|
||||||
|
("plate_slot3", "A1"),
|
||||||
|
("plate_slot2", "A2"),
|
||||||
|
("plate_slot5", "A1"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_emit_merged_set_liquid_8ch():
|
||||||
|
"""与 P1 multi-channel 复合:targets=[A]*8+[B]*8(每列 8 通道)。
|
||||||
|
|
||||||
|
merged.wells 长度 16,前 8 全 plate_slot2 的 8 个 well,后 8 全 plate_slot3 的 8 个 well。
|
||||||
|
"""
|
||||||
|
labware = _cross_slot_labware_info()
|
||||||
|
# 8 通道场景 reagent.well 已被 P1 multi 展开为长度 8
|
||||||
|
labware["plate_slot2"]["well"] = [f"{r}1" for r in "ABCDEFGH"]
|
||||||
|
labware["plate_slot3"]["well"] = [f"{r}1" for r in "ABCDEFGH"]
|
||||||
|
|
||||||
|
targets = ["plate_slot2"] * 8 + ["plate_slot3"] * 8
|
||||||
|
dis_vols = [5.0] * 16
|
||||||
|
g = build_protocol_graph(
|
||||||
|
labware_info=labware,
|
||||||
|
protocol_steps=_cross_slot_protocol_steps(targets, dis_vols),
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
|
||||||
|
merged_nodes = [
|
||||||
|
n for n in _nodes_by_template(g, "set_liquid_from_plate")
|
||||||
|
if str(n.get("name", "")).startswith("_merged_targets_")
|
||||||
|
]
|
||||||
|
assert len(merged_nodes) == 1
|
||||||
|
wells = merged_nodes[0]["param"]["wells"]
|
||||||
|
assert len(wells) == 16
|
||||||
|
# 前 8 全 plate_slot2,后 8 全 plate_slot3(满足 cross-slot × 8ch 列对齐约束)
|
||||||
|
assert all(w["parent"] == "plate_slot2" for w in wells[:8])
|
||||||
|
assert all(w["parent"] == "plate_slot3" for w in wells[8:])
|
||||||
|
# well 名顺序:A1..H1 重复两遍
|
||||||
|
assert [w["id"].rsplit("/", 1)[-1] for w in wells[:8]] == [f"{r}1" for r in "ABCDEFGH"]
|
||||||
|
assert [w["id"].rsplit("/", 1)[-1] for w in wells[8:]] == [f"{r}1" for r in "ABCDEFGH"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_transfer_liquid_targets_rewrite():
|
||||||
|
"""transfer_liquid 节点改写后只剩 1 条 targets_identifier 入边;params.targets 不再是 list。"""
|
||||||
|
targets = ["plate_slot2", "plate_slot3", "plate_slot5", "plate_slot6"]
|
||||||
|
dis_vols = [8.3, 8.3, 8.3, 8.3]
|
||||||
|
g = build_protocol_graph(
|
||||||
|
labware_info=_cross_slot_labware_info(),
|
||||||
|
protocol_steps=_cross_slot_protocol_steps(targets, dis_vols),
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
|
||||||
|
transfer_nodes = _nodes_by_template(g, "transfer_liquid")
|
||||||
|
assert len(transfer_nodes) == 1
|
||||||
|
tnode = transfer_nodes[0]
|
||||||
|
transfer_id = tnode["id"]
|
||||||
|
|
||||||
|
# params.targets:v2 中 list 形态在 INPUT_PORT_MAPPING 处理后被清空([])或为单字符串
|
||||||
|
# (不再是原始 list[str]——避免下游 runtime 对其再做无序聚合)
|
||||||
|
tparams = tnode.get("param", {}) or {}
|
||||||
|
assert not isinstance(tparams.get("targets"), list) or tparams.get("targets") == [], (
|
||||||
|
f"v2:params.targets 不再是非空 list;实际 {tparams.get('targets')!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# targets_identifier 端口:只有 1 条入边
|
||||||
|
in_targets_edges = [
|
||||||
|
e for e in _edges_to(g, transfer_id)
|
||||||
|
if e.get("target_handle_key") == "targets_identifier"
|
||||||
|
]
|
||||||
|
assert len(in_targets_edges) == 1, (
|
||||||
|
f"v2:transfer_liquid.targets_identifier 必须是单入边(来自 merged set_liquid);"
|
||||||
|
f" 实际 {len(in_targets_edges)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 这条入边的源端口必须是 output_wells
|
||||||
|
edge = in_targets_edges[0]
|
||||||
|
assert edge.get("source_handle_key") == "output_wells"
|
||||||
|
|
||||||
|
|
||||||
|
def test_str_targets_no_merged_node_emitted():
|
||||||
|
"""对照组:targets 为 str(单 reagent) → 不插入 merged set_liquid_from_plate 节点。
|
||||||
|
|
||||||
|
保证 v2 改造**只**对 list 形态触发,单 reagent 走 P3 原有 per-plate set_liquid 路径。
|
||||||
|
"""
|
||||||
|
labware = _cross_slot_labware_info()
|
||||||
|
labware["plate_slot2"]["well"] = ["A1", "A2", "A3"]
|
||||||
|
|
||||||
|
steps = [
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"parameters": {
|
||||||
|
"sources": "l1",
|
||||||
|
"targets": "plate_slot2", # ← 单 str,非 list
|
||||||
|
"tip_racks": "tiprack_12",
|
||||||
|
"asp_vols": [8.3, 8.3, 8.3],
|
||||||
|
"dis_vols": [8.3, 8.3, 8.3],
|
||||||
|
},
|
||||||
|
"step_number": 1,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
g = build_protocol_graph(
|
||||||
|
labware_info=labware,
|
||||||
|
protocol_steps=steps,
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
merged_nodes = [
|
||||||
|
n for n in _nodes_by_template(g, "set_liquid_from_plate")
|
||||||
|
if str(n.get("name", "")).startswith("_merged_targets_")
|
||||||
|
]
|
||||||
|
assert merged_nodes == [], "str 形态 targets 不应触发 v2 merged 聚合节点"
|
||||||
452
tests/workflow/test_common_liquid_name_from_reagent.py
Normal file
452
tests/workflow/test_common_liquid_name_from_reagent.py
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
"""P8 — Stage 3 (``workflow/common.py``) 写入 ``set_liquid_from_plate.param.liquid_names`` 时
|
||||||
|
优先取 ``reagent[key].liquid_name``,缺省时 fallback 到 reagent_key。
|
||||||
|
|
||||||
|
对应 ``product_designs/protocol_convert/08-liquid-name-from-reagent-block.md`` §3.4 + §5。
|
||||||
|
|
||||||
|
设计要点
|
||||||
|
--------
|
||||||
|
- ``reagent[key].liquid_name`` 是 P8 新增的**可选**字段,承载真实化学名(与 reagent_key
|
||||||
|
解耦:reagent_key 仍是数据流引用名 / 业务别名,``liquid_name`` 是写入 PLR tracker /
|
||||||
|
前端的 human-readable 名称)。
|
||||||
|
- ``liquid_name`` 来源优先级:Stage 0 mock ``Well.load_liquid(liquid=...)`` 实参 >
|
||||||
|
README 语义词 > 不写(Stage 3 fallback 到 reagent_key)。
|
||||||
|
- ``liquid_name`` 保留空格 / 中文 / 括号等原字符,**不**做 snake_case / underscore 替换。
|
||||||
|
- 旧 JSON(无 ``liquid_name`` 字段)行为完全不变(设计点 §7.A)。
|
||||||
|
|
||||||
|
测试用例
|
||||||
|
--------
|
||||||
|
- ``test_per_plate_fallback_when_no_liquid_name`` —— 缺省 fallback:
|
||||||
|
reagent 块无 ``liquid_name`` → liquid_names[i] == reagent_key(与 P8 前一致)。
|
||||||
|
- ``test_per_plate_uses_explicit_liquid_name`` —— 显式 liquid_name:
|
||||||
|
liquid_names[i] == "EDTA Plasma"。
|
||||||
|
- ``test_per_plate_preserves_spaces_and_special_chars`` —— 含空格 / 括号:
|
||||||
|
liquid_names[i] 不被 ``replace(" ", "_")`` 处理(不同于 reagent_key 用的 res_id)。
|
||||||
|
- ``test_merged_node_uses_explicit_liquid_name_per_dispense`` —— merged 节点
|
||||||
|
每个 dispense 独立取 ``liquid_name or key``,部分有部分无能共存。
|
||||||
|
- ``test_liquid_name_independent_of_reagent_key_normalization`` —— 与 P4 共存:
|
||||||
|
reagent_key 仍是 ``samples_2`` 等去重后缀,但 liquid_names 写的是真实化学名。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||||
|
if str(ROOT_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT_DIR))
|
||||||
|
|
||||||
|
|
||||||
|
def _install_fake_optional_deps() -> None:
|
||||||
|
"""与 test_common_set_liquid_dedup.py 一致的可选依赖 stub。"""
|
||||||
|
if "matplotlib" not in sys.modules:
|
||||||
|
sys.modules["matplotlib"] = types.ModuleType("matplotlib")
|
||||||
|
if "matplotlib.pyplot" not in sys.modules:
|
||||||
|
sys.modules["matplotlib.pyplot"] = types.ModuleType("matplotlib.pyplot")
|
||||||
|
try:
|
||||||
|
from networkx.drawing import nx_agraph # noqa: F401
|
||||||
|
except Exception:
|
||||||
|
nx_drawing = types.ModuleType("networkx.drawing")
|
||||||
|
nx_agraph_mod = types.ModuleType("networkx.drawing.nx_agraph")
|
||||||
|
nx_agraph_mod.to_agraph = lambda _g: None # type: ignore[attr-defined]
|
||||||
|
nx_drawing.nx_agraph = nx_agraph_mod # type: ignore[attr-defined]
|
||||||
|
sys.modules["networkx.drawing"] = nx_drawing
|
||||||
|
sys.modules["networkx.drawing.nx_agraph"] = nx_agraph_mod
|
||||||
|
|
||||||
|
|
||||||
|
_install_fake_optional_deps()
|
||||||
|
|
||||||
|
import pytest # noqa: E402
|
||||||
|
|
||||||
|
from unilabos.workflow.common import build_protocol_graph # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 辅助 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def _set_liquid_nodes(graph) -> List[Dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{"id": nid, **node}
|
||||||
|
for nid, node in graph.nodes.items()
|
||||||
|
if node.get("template_name") == "set_liquid_from_plate"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _per_plate_for(graph, reagent_key: str) -> Dict[str, Any]:
|
||||||
|
"""根据 ``description = "Set liquid: <reagent_key>"`` 反查 per-plate 节点。"""
|
||||||
|
for n in _set_liquid_nodes(graph):
|
||||||
|
if n.get("description") == f"Set liquid: {reagent_key}":
|
||||||
|
return n
|
||||||
|
raise AssertionError(f"未找到 per-plate set_liquid_from_plate(reagent_key={reagent_key!r})")
|
||||||
|
|
||||||
|
|
||||||
|
def _merged_nodes(graph) -> List[Dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
n for n in _set_liquid_nodes(graph)
|
||||||
|
if str(n.get("name", "")).startswith("_merged_targets_")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _make_source_target_labware(
|
||||||
|
*,
|
||||||
|
source_key: str = "src_1",
|
||||||
|
source_liquid_name: str | None = None,
|
||||||
|
target_keys: List[str] | None = None,
|
||||||
|
target_liquid_names: Dict[str, str] | None = None,
|
||||||
|
) -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""构造 1 个 source + N 个 target reagent + 1 个 tip rack。
|
||||||
|
|
||||||
|
``*_liquid_name`` 为 None / 缺省时**不**写入 ``liquid_name`` 字段,
|
||||||
|
模拟旧 schema / mock 未给 liquid_name 的真实回归场景。
|
||||||
|
"""
|
||||||
|
info: Dict[str, Dict[str, Any]] = {}
|
||||||
|
source_entry: Dict[str, Any] = {
|
||||||
|
"slot": 1,
|
||||||
|
"well": ["A1"],
|
||||||
|
"labware": "nest_12_reservoir_15ml",
|
||||||
|
"object": "source",
|
||||||
|
}
|
||||||
|
if source_liquid_name is not None:
|
||||||
|
source_entry["liquid_name"] = source_liquid_name
|
||||||
|
info[source_key] = source_entry
|
||||||
|
|
||||||
|
target_keys = target_keys or ["t_A"]
|
||||||
|
target_liquid_names = target_liquid_names or {}
|
||||||
|
for i, tk in enumerate(target_keys, start=1):
|
||||||
|
entry: Dict[str, Any] = {
|
||||||
|
"slot": 2 + i,
|
||||||
|
"well": ["A1"],
|
||||||
|
"labware": "nest_96_wellplate_2ml_deep",
|
||||||
|
"object": "target",
|
||||||
|
}
|
||||||
|
if tk in target_liquid_names:
|
||||||
|
entry["liquid_name"] = target_liquid_names[tk]
|
||||||
|
info[tk] = entry
|
||||||
|
|
||||||
|
info["tiprack_12"] = {
|
||||||
|
"slot": 12,
|
||||||
|
"well": [],
|
||||||
|
"labware": "opentrons_96_tiprack_300ul",
|
||||||
|
"object": "tiprack",
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== T1 缺省 fallback ====================
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_plate_fallback_when_no_liquid_name():
|
||||||
|
"""reagent block 无 ``liquid_name`` 字段 → liquid_names[i] == reagent_key(P8 前行为)。"""
|
||||||
|
labware = _make_source_target_labware(
|
||||||
|
source_key="src_1",
|
||||||
|
target_keys=["t_A"],
|
||||||
|
# 都不给 liquid_name
|
||||||
|
)
|
||||||
|
steps = [
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"parameters": {
|
||||||
|
"sources": "src_1",
|
||||||
|
"targets": "t_A",
|
||||||
|
"tip_racks": "tiprack_12",
|
||||||
|
"asp_vols": [10.0],
|
||||||
|
"dis_vols": [10.0],
|
||||||
|
},
|
||||||
|
"step_number": 1,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
g = build_protocol_graph(
|
||||||
|
labware_info=labware,
|
||||||
|
protocol_steps=steps,
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
|
||||||
|
src_node = _per_plate_for(g, "src_1")
|
||||||
|
tgt_node = _per_plate_for(g, "t_A")
|
||||||
|
assert src_node["param"]["liquid_names"] == ["src_1"], (
|
||||||
|
f"无 liquid_name 时 source per-plate 应 fallback 到 reagent_key;"
|
||||||
|
f" 实际 {src_node['param']['liquid_names']}"
|
||||||
|
)
|
||||||
|
assert tgt_node["param"]["liquid_names"] == ["t_A"], (
|
||||||
|
f"无 liquid_name 时 target per-plate 应 fallback 到 reagent_key;"
|
||||||
|
f" 实际 {tgt_node['param']['liquid_names']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== T2 显式 liquid_name ====================
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_plate_uses_explicit_liquid_name():
|
||||||
|
"""reagent block 含 ``liquid_name`` → liquid_names[i] 用该值(不是 reagent_key)。"""
|
||||||
|
labware = _make_source_target_labware(
|
||||||
|
source_key="src_1",
|
||||||
|
source_liquid_name="EDTA Plasma",
|
||||||
|
target_keys=["t_A"],
|
||||||
|
target_liquid_names={"t_A": "PBS Diluent"},
|
||||||
|
)
|
||||||
|
steps = [
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"parameters": {
|
||||||
|
"sources": "src_1",
|
||||||
|
"targets": "t_A",
|
||||||
|
"tip_racks": "tiprack_12",
|
||||||
|
"asp_vols": [10.0],
|
||||||
|
"dis_vols": [10.0],
|
||||||
|
},
|
||||||
|
"step_number": 1,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
g = build_protocol_graph(
|
||||||
|
labware_info=labware,
|
||||||
|
protocol_steps=steps,
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
|
||||||
|
src_node = _per_plate_for(g, "src_1")
|
||||||
|
tgt_node = _per_plate_for(g, "t_A")
|
||||||
|
assert src_node["param"]["liquid_names"] == ["EDTA Plasma"], (
|
||||||
|
f"source per-plate 应使用 reagent.liquid_name;实际 {src_node['param']['liquid_names']}"
|
||||||
|
)
|
||||||
|
assert tgt_node["param"]["liquid_names"] == ["PBS Diluent"], (
|
||||||
|
f"target per-plate 应使用 reagent.liquid_name;实际 {tgt_node['param']['liquid_names']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== T3 空格 / 括号 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_plate_preserves_spaces_and_special_chars():
|
||||||
|
"""``liquid_name`` 保留空格 / 括号 / 中文等原字符,不被 replace(' ', '_') 处理。
|
||||||
|
|
||||||
|
这条与 reagent_key 走 ``res_id = str(labware_id).replace(' ', '_')`` 的语义不同。
|
||||||
|
"""
|
||||||
|
labware = _make_source_target_labware(
|
||||||
|
source_key="src_1",
|
||||||
|
source_liquid_name="Tris HCl pH 8.0 (1×)",
|
||||||
|
target_keys=["t_A"],
|
||||||
|
target_liquid_names={"t_A": "稀释液 A"},
|
||||||
|
)
|
||||||
|
steps = [
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"parameters": {
|
||||||
|
"sources": "src_1",
|
||||||
|
"targets": "t_A",
|
||||||
|
"tip_racks": "tiprack_12",
|
||||||
|
"asp_vols": [10.0],
|
||||||
|
"dis_vols": [10.0],
|
||||||
|
},
|
||||||
|
"step_number": 1,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
g = build_protocol_graph(
|
||||||
|
labware_info=labware,
|
||||||
|
protocol_steps=steps,
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
|
||||||
|
src_node = _per_plate_for(g, "src_1")
|
||||||
|
tgt_node = _per_plate_for(g, "t_A")
|
||||||
|
|
||||||
|
assert src_node["param"]["liquid_names"] == ["Tris HCl pH 8.0 (1×)"], (
|
||||||
|
f"空格 / 括号应原样保留;实际 {src_node['param']['liquid_names']}"
|
||||||
|
)
|
||||||
|
assert tgt_node["param"]["liquid_names"] == ["稀释液 A"], (
|
||||||
|
f"中文应原样保留;实际 {tgt_node['param']['liquid_names']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# reagent_key 自身仍受 ``res_id = replace(' ', '_')`` 影响,
|
||||||
|
# 但本测试 reagent_key 不含空格,故 sl_node_title 仍以 reagent_key 为根。
|
||||||
|
# 这里仅断言 liquid_names 字段独立于 reagent_key normalize。
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== T4 merged 节点跨板部分有部分无 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def test_merged_node_uses_explicit_liquid_name_per_dispense():
|
||||||
|
"""merged 节点 ``liquid_names`` 与 list-targets 同长,每个元素独立取
|
||||||
|
``reagent[key].liquid_name or key``:本例 3 个 target,2 个有显式名、1 个无。
|
||||||
|
"""
|
||||||
|
labware = _make_source_target_labware(
|
||||||
|
source_key="src_1",
|
||||||
|
target_keys=["t_A", "t_B", "t_C"],
|
||||||
|
target_liquid_names={
|
||||||
|
"t_A": "Plasma",
|
||||||
|
# t_B 无 liquid_name
|
||||||
|
"t_C": "Buffer X",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
steps = [
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"parameters": {
|
||||||
|
"sources": "src_1",
|
||||||
|
"targets": ["t_A", "t_B", "t_C"],
|
||||||
|
"tip_racks": "tiprack_12",
|
||||||
|
"asp_vols": [5.0] * 3,
|
||||||
|
"dis_vols": [5.0] * 3,
|
||||||
|
},
|
||||||
|
"step_number": 1,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
g = build_protocol_graph(
|
||||||
|
labware_info=labware,
|
||||||
|
protocol_steps=steps,
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
|
||||||
|
merged = _merged_nodes(g)
|
||||||
|
assert len(merged) == 1, f"应有 1 个 merged 节点,实际 {len(merged)}"
|
||||||
|
liquid_names = merged[0]["param"]["liquid_names"]
|
||||||
|
assert liquid_names == ["Plasma", "t_B", "Buffer X"], (
|
||||||
|
f"merged 每 dispense 独立取 liquid_name or key;实际 {liquid_names}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== T5 与 P4 reagent_key 后缀共存 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def test_liquid_name_independent_of_reagent_key_normalization():
|
||||||
|
"""P4 命名链产生 ``samples_2`` 这种带后缀的 reagent_key(跨板去重);
|
||||||
|
P8 ``liquid_name`` 应保持原始化学名,**不**带 P4 的去重后缀。
|
||||||
|
|
||||||
|
构造:2 个 target reagent_keys ``samples`` / ``samples_2``(不同 slot,
|
||||||
|
模拟跨板同液体被 Stage 2 去重),都标 liquid_name="Bacterial Culture"。
|
||||||
|
"""
|
||||||
|
labware = _make_source_target_labware(
|
||||||
|
source_key="src_1",
|
||||||
|
target_keys=["samples", "samples_2"],
|
||||||
|
target_liquid_names={
|
||||||
|
"samples": "Bacterial Culture",
|
||||||
|
"samples_2": "Bacterial Culture",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
steps = [
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"parameters": {
|
||||||
|
"sources": "src_1",
|
||||||
|
"targets": ["samples", "samples_2"],
|
||||||
|
"tip_racks": "tiprack_12",
|
||||||
|
"asp_vols": [5.0, 5.0],
|
||||||
|
"dis_vols": [5.0, 5.0],
|
||||||
|
},
|
||||||
|
"step_number": 1,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
g = build_protocol_graph(
|
||||||
|
labware_info=labware,
|
||||||
|
protocol_steps=steps,
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
|
||||||
|
merged = _merged_nodes(g)
|
||||||
|
assert len(merged) == 1
|
||||||
|
liquid_names = merged[0]["param"]["liquid_names"]
|
||||||
|
assert liquid_names == ["Bacterial Culture", "Bacterial Culture"], (
|
||||||
|
f"P8 liquid_name 应与 P4 reagent_key 后缀解耦:同液体的两个 reagent_key 应得相同"
|
||||||
|
f" liquid_name;实际 {liquid_names}"
|
||||||
|
)
|
||||||
|
# 同时 reagent_key 仍是 samples / samples_2(不变)
|
||||||
|
wells = merged[0]["param"]["wells"]
|
||||||
|
parents = [w["parent"] for w in wells]
|
||||||
|
assert parents == ["samples", "samples_2"], (
|
||||||
|
f"merged wells.parent 应等于 list-targets reagent_keys;实际 {parents}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== T6 source per-plate / target per-plate 同步生效 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def test_both_source_and_target_per_plate_use_liquid_name():
|
||||||
|
"""str-targets 路径(无 merged)下,source 和 target 都走 per-plate emit,
|
||||||
|
各自独立取 ``liquid_name``。"""
|
||||||
|
labware = _make_source_target_labware(
|
||||||
|
source_key="src_1",
|
||||||
|
source_liquid_name="Reagent A",
|
||||||
|
target_keys=["t_A"],
|
||||||
|
target_liquid_names={"t_A": "Reagent B"},
|
||||||
|
)
|
||||||
|
steps = [
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"parameters": {
|
||||||
|
"sources": "src_1",
|
||||||
|
"targets": "t_A", # str-targets,不触发 merged
|
||||||
|
"tip_racks": "tiprack_12",
|
||||||
|
"asp_vols": [10.0],
|
||||||
|
"dis_vols": [10.0],
|
||||||
|
},
|
||||||
|
"step_number": 1,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
g = build_protocol_graph(
|
||||||
|
labware_info=labware,
|
||||||
|
protocol_steps=steps,
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _merged_nodes(g) == [], "str-targets 不应产生 merged 节点"
|
||||||
|
src_node = _per_plate_for(g, "src_1")
|
||||||
|
tgt_node = _per_plate_for(g, "t_A")
|
||||||
|
assert src_node["param"]["liquid_names"] == ["Reagent A"]
|
||||||
|
assert tgt_node["param"]["liquid_names"] == ["Reagent B"]
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== T7 多孔同 reagent → 整列 liquid_names 一致 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_well_reagent_replicates_liquid_name():
|
||||||
|
"""1 个 reagent 含 8 wells(multi-channel 扩展场景)→ liquid_names 应是
|
||||||
|
``[liquid_name] * 8``,与 wells 长度一致。"""
|
||||||
|
labware: Dict[str, Dict[str, Any]] = {
|
||||||
|
"src_1": {
|
||||||
|
"slot": 1,
|
||||||
|
"well": ["A1", "B1", "C1", "D1", "E1", "F1", "G1", "H1"],
|
||||||
|
"labware": "nest_96_wellplate_100ul_pcr_full_skirt",
|
||||||
|
"object": "source",
|
||||||
|
"liquid_name": "Mastermix",
|
||||||
|
},
|
||||||
|
"t_A": {
|
||||||
|
"slot": 3,
|
||||||
|
"well": ["A1"],
|
||||||
|
"labware": "nest_96_wellplate_2ml_deep",
|
||||||
|
"object": "target",
|
||||||
|
},
|
||||||
|
"tiprack_12": {
|
||||||
|
"slot": 12,
|
||||||
|
"well": [],
|
||||||
|
"labware": "opentrons_96_tiprack_300ul",
|
||||||
|
"object": "tiprack",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
steps = [
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"parameters": {
|
||||||
|
"sources": "src_1",
|
||||||
|
"targets": "t_A",
|
||||||
|
"tip_racks": "tiprack_12",
|
||||||
|
"asp_vols": [10.0],
|
||||||
|
"dis_vols": [10.0],
|
||||||
|
},
|
||||||
|
"step_number": 1,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
g = build_protocol_graph(
|
||||||
|
labware_info=labware,
|
||||||
|
protocol_steps=steps,
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
|
||||||
|
src_node = _per_plate_for(g, "src_1")
|
||||||
|
liquid_names = src_node["param"]["liquid_names"]
|
||||||
|
assert liquid_names == ["Mastermix"] * 8, (
|
||||||
|
f"per-plate 应把 liquid_name 复制 well_count 份;实际 {liquid_names}"
|
||||||
|
)
|
||||||
|
# 同时 wells / volumes 长度一致
|
||||||
|
assert len(src_node["param"]["wells"]) == 8
|
||||||
|
assert len(src_node["param"]["volumes"]) == 8
|
||||||
174
tests/workflow/test_common_plate_num_children_hint.py
Normal file
174
tests/workflow/test_common_plate_num_children_hint.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"""P6 §17 hint bug —— `_infer_plate_num_children_from_labware_hint` 误把
|
||||||
|
reagent_id 末尾数字(如 ``samples_6`` 的 ``_6``)当作孔板规格,导致
|
||||||
|
``_apply_target_labware_class_auto_match`` fallback 到 PRCXI 4-孔 trough 模板。
|
||||||
|
|
||||||
|
跨板 fix(P2 v2 §14)把 plate name 作为 prefix 编码进 ``well_names`` 之后,
|
||||||
|
runtime 调用 ``plate.get_well("A5")`` 严格定位 well,trough plate 上不存在
|
||||||
|
``A5`` 会直接 IndexError,使得这个隐藏多年的孔数推断 bug 浮出。
|
||||||
|
|
||||||
|
修复策略(方案 A)
|
||||||
|
-----
|
||||||
|
hint 只用 ``item.get("labware", "")``,**不再**拼上 ``labware_id``(reagent_key
|
||||||
|
是业务名,不应参与孔板规格推断)。
|
||||||
|
|
||||||
|
测试矩阵
|
||||||
|
----
|
||||||
|
- ``test_reagent_key_numeric_suffix_must_not_match_hint`` —— samples_6 / samples_24 /
|
||||||
|
samples_96 + nunc_rectangular_agar_plate → hint 返回 None(labware string 不带孔数信息)。
|
||||||
|
- ``test_labware_string_X_well_correctly_inferred`` —— labware="nest_96_wellplate..." → 96;
|
||||||
|
"custom_384_wellplate" → 384;"nest_24_wellplate_2ml_pcr" → 24。
|
||||||
|
- ``test_apply_does_not_classify_samples_6_as_trough`` —— 集成:构造 Agar Plating-like
|
||||||
|
reagent block(slot 8 上 12 个 samples_X,X 末尾含 6/24/96),跑
|
||||||
|
``_apply_target_labware_class_auto_match`` 后,samples_6/24 不再得到 trough class。
|
||||||
|
- ``test_real_labware_96_wellplate_still_inferred_via_labware_str`` —— 即便 labware_id
|
||||||
|
与孔数无关,``nest_96_wellplate_100ul_pcr_full_skirt`` 这种 labware 命名仍应被识别为 96。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||||
|
if str(ROOT_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT_DIR))
|
||||||
|
|
||||||
|
|
||||||
|
def _install_fake_optional_deps() -> None:
|
||||||
|
if "matplotlib" not in sys.modules:
|
||||||
|
sys.modules["matplotlib"] = types.ModuleType("matplotlib")
|
||||||
|
if "matplotlib.pyplot" not in sys.modules:
|
||||||
|
sys.modules["matplotlib.pyplot"] = types.ModuleType("matplotlib.pyplot")
|
||||||
|
|
||||||
|
|
||||||
|
_install_fake_optional_deps()
|
||||||
|
|
||||||
|
import pytest # noqa: E402
|
||||||
|
|
||||||
|
from unilabos.workflow.common import ( # noqa: E402
|
||||||
|
_apply_target_labware_class_auto_match,
|
||||||
|
_infer_plate_num_children_from_labware_hint,
|
||||||
|
_reconcile_slot_carrier_target_class,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== unit:hint 函数本身 ====================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"labware_id",
|
||||||
|
["samples_6", "samples_24", "samples_96", "samples_12", "samples_48"],
|
||||||
|
)
|
||||||
|
def test_reagent_key_numeric_suffix_must_not_match_hint(labware_id):
|
||||||
|
"""reagent_id 末尾的孔数关键字数字不应被识别为孔板规格。"""
|
||||||
|
item = {
|
||||||
|
"slot": 8,
|
||||||
|
"well": ["A5"],
|
||||||
|
"labware": "nunc_rectangular_agar_plate",
|
||||||
|
"object": "target",
|
||||||
|
}
|
||||||
|
assert _infer_plate_num_children_from_labware_hint(labware_id, item) is None, (
|
||||||
|
f"reagent_id {labware_id!r} 不应被识别为孔板规格 "
|
||||||
|
f"(其末尾数字应当被忽略;labware string 不含 96/384/etc 关键字)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"labware_str,expected",
|
||||||
|
[
|
||||||
|
("nest_96_wellplate_100ul_pcr_full_skirt", 96),
|
||||||
|
("custom_384_wellplate", 384),
|
||||||
|
("nest_24_wellplate_2ml_pcr", 24),
|
||||||
|
("custom_48_wellplate", 48),
|
||||||
|
("opentrons_12_wellplate_15ml", 12),
|
||||||
|
("nest_6_wellplate_5ml", 6),
|
||||||
|
("nunc_rectangular_agar_plate", None),
|
||||||
|
("", None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_labware_string_well_count_inferred(labware_str, expected):
|
||||||
|
item = {"labware": labware_str}
|
||||||
|
assert (
|
||||||
|
_infer_plate_num_children_from_labware_hint("samples", item) == expected
|
||||||
|
), f"labware {labware_str!r} 应推断为 {expected!r}"
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== integration:模拟 Agar Plating ====================
|
||||||
|
|
||||||
|
|
||||||
|
def _agar_plating_reagent_block():
|
||||||
|
"""反推自 unilabos_data/req_workflow_upload.json:12 列 × 9 reagent per step。
|
||||||
|
|
||||||
|
slot 8 (mapped 14) 上 12 个 reagent_keys: samples_6, samples_15, samples_24,
|
||||||
|
samples_33, samples_42, samples_51, samples_60, samples_69, samples_78,
|
||||||
|
samples_87, samples_96, samples_105.
|
||||||
|
"""
|
||||||
|
info = {}
|
||||||
|
slot_for_idx = {0: 3, 1: 4, 2: 5, 3: 6, 4: 7, 5: 8, 6: 9, 7: 10, 8: 11}
|
||||||
|
cols = [f"A{i + 1}" for i in range(12)]
|
||||||
|
for col_i, col in enumerate(cols):
|
||||||
|
for di in range(9):
|
||||||
|
n = col_i * 9 + di + 1
|
||||||
|
key = "samples" if n == 1 else f"samples_{n}"
|
||||||
|
info[key] = {
|
||||||
|
"slot": slot_for_idx[di],
|
||||||
|
"well": [col],
|
||||||
|
"labware": "nunc_rectangular_agar_plate",
|
||||||
|
"object": "target",
|
||||||
|
}
|
||||||
|
for i in range(12):
|
||||||
|
key = "sources" if i == 0 else f"sources_{i + 1}"
|
||||||
|
info[key] = {
|
||||||
|
"slot": 2,
|
||||||
|
"well": [cols[i]],
|
||||||
|
"labware": "nest_96_wellplate_100ul_pcr_full_skirt",
|
||||||
|
"object": "source",
|
||||||
|
}
|
||||||
|
info["tiprack_1"] = {
|
||||||
|
"slot": 1,
|
||||||
|
"well": None,
|
||||||
|
"labware": "opentrons_96_tiprack_10ul",
|
||||||
|
"object": "tiprack",
|
||||||
|
}
|
||||||
|
info["trash"] = {
|
||||||
|
"slot": 12,
|
||||||
|
"well": None,
|
||||||
|
"labware": "opentrons_1_trash_1100ml_fixed",
|
||||||
|
"object": "trash",
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_does_not_classify_samples_6_as_trough():
|
||||||
|
"""集成回归:Agar Plating-like reagent block 跑完类匹配 + slot 统一后,
|
||||||
|
slot 8 上 12 个 reagent 不应得到 4-孔 trough class。"""
|
||||||
|
info = _agar_plating_reagent_block()
|
||||||
|
_apply_target_labware_class_auto_match(
|
||||||
|
info, preserve_tip_rack_incoming_class=True, target_device="prcxi"
|
||||||
|
)
|
||||||
|
_reconcile_slot_carrier_target_class(
|
||||||
|
info, preserve_tip_rack_incoming_class=True, target_device="prcxi"
|
||||||
|
)
|
||||||
|
slot8_keys = [
|
||||||
|
"samples_6", "samples_15", "samples_24", "samples_33",
|
||||||
|
"samples_42", "samples_51", "samples_60", "samples_69",
|
||||||
|
"samples_78", "samples_87", "samples_96", "samples_105",
|
||||||
|
]
|
||||||
|
for k in slot8_keys:
|
||||||
|
cls = info[k].get("target_class_name") or ""
|
||||||
|
assert "trough" not in cls.lower(), (
|
||||||
|
f"reagent {k} 被误识别为 trough class: {cls!r};"
|
||||||
|
"这通常是 hint 误把 reagent_id 末尾数字当孔板规格"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_labware_96_wellplate_still_inferred_via_labware_str():
|
||||||
|
"""labware string 含 96_wellplate 时应该正常识别为 96,不被 fix 破坏。"""
|
||||||
|
item = {
|
||||||
|
"slot": 2,
|
||||||
|
"well": ["A1"],
|
||||||
|
"labware": "nest_96_wellplate_100ul_pcr_full_skirt",
|
||||||
|
"object": "source",
|
||||||
|
}
|
||||||
|
assert _infer_plate_num_children_from_labware_hint("sources", item) == 96
|
||||||
379
tests/workflow/test_common_set_liquid_dedup.py
Normal file
379
tests/workflow/test_common_set_liquid_dedup.py
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
"""P2 v2 §14 set_liquid_from_plate 去重 —— Stage 3 (`workflow/common.py`) 集成测试。
|
||||||
|
|
||||||
|
对应 ``product_designs/protocol_convert/02-cross-slot-merge.md`` §14(2026-05-22 plan)。
|
||||||
|
|
||||||
|
§14 设计要点
|
||||||
|
-----------------
|
||||||
|
当 ``transfer_liquid.params.targets`` 是 ``list[str]`` 时,``_emit_merged_set_liquid``
|
||||||
|
已经为该 transfer 插入一个 merged ``set_liquid_from_plate`` 节点,
|
||||||
|
其 ``param.wells`` 聚合了 list 中所有 reagent_keys 的跨板 wells。
|
||||||
|
|
||||||
|
§14 之前:第二步循环(``for labware_id, item in labware_info.items()``)仍然为
|
||||||
|
list-targets 中出现的每个 reagent_key 创建一个 per-plate ``set_liquid_from_plate`` 节点,
|
||||||
|
导致**节点冗余**(per-plate 节点的 ``output_wells`` 对 transfer_liquid 的
|
||||||
|
``targets_identifier`` 边毫无贡献 —— transfer_liquid 单边只接 merged 节点)。
|
||||||
|
|
||||||
|
§14 改造:在第二步循环**之前**预扫描 protocol_steps,收集
|
||||||
|
``set_liquid_covered_by_merged: Set[str]``(出现在某个 list[str] targets 中的所有 keys)
|
||||||
|
与 ``set_liquid_referenced_by_str: Set[str]``(出现在 str targets 中的所有 keys)。
|
||||||
|
循环内对 ``object="target"`` 且 ``key ∈ covered ∧ key ∉ referenced_by_str`` 的 reagent_key
|
||||||
|
**跳过** per-plate 节点创建。
|
||||||
|
|
||||||
|
测试用例
|
||||||
|
----
|
||||||
|
- ``test_per_plate_skipped_when_covered_by_merged`` —— list-targets 覆盖的
|
||||||
|
target reagent_keys 不再产生 per-plate set_liquid_from_plate。
|
||||||
|
- ``test_per_plate_kept_when_also_referenced_by_str_targets`` —— R1 缓解:
|
||||||
|
同时被 list-targets 和 str-targets 引用的 reagent_key 仍保留 per-plate。
|
||||||
|
- ``test_str_targets_protocol_unaffected`` —— 单 slot 协议(仅 str-targets)
|
||||||
|
节点数完全不变(回归防护)。
|
||||||
|
- ``test_51b9a5_style_node_count`` —— 12 list-targets × len=9 大规模场景:
|
||||||
|
set_liquid_from_plate 总节点数 = source per-plate + merged + 0 target per-plate。
|
||||||
|
- ``test_source_per_plate_always_kept`` —— source 端不受 §14 影响:source
|
||||||
|
reagent_keys 不出现在 targets 字段中,per-plate 节点恒在。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||||
|
if str(ROOT_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT_DIR))
|
||||||
|
|
||||||
|
|
||||||
|
def _install_fake_optional_deps() -> None:
|
||||||
|
"""与 test_common_cross_slot_v2.py 一致的可选依赖 stub。"""
|
||||||
|
if "matplotlib" not in sys.modules:
|
||||||
|
sys.modules["matplotlib"] = types.ModuleType("matplotlib")
|
||||||
|
if "matplotlib.pyplot" not in sys.modules:
|
||||||
|
sys.modules["matplotlib.pyplot"] = types.ModuleType("matplotlib.pyplot")
|
||||||
|
try:
|
||||||
|
from networkx.drawing import nx_agraph # noqa: F401
|
||||||
|
except Exception:
|
||||||
|
nx_drawing = types.ModuleType("networkx.drawing")
|
||||||
|
nx_agraph_mod = types.ModuleType("networkx.drawing.nx_agraph")
|
||||||
|
nx_agraph_mod.to_agraph = lambda _g: None # type: ignore[attr-defined]
|
||||||
|
nx_drawing.nx_agraph = nx_agraph_mod # type: ignore[attr-defined]
|
||||||
|
sys.modules["networkx.drawing"] = nx_drawing
|
||||||
|
sys.modules["networkx.drawing.nx_agraph"] = nx_agraph_mod
|
||||||
|
|
||||||
|
|
||||||
|
_install_fake_optional_deps()
|
||||||
|
|
||||||
|
import pytest # noqa: E402
|
||||||
|
|
||||||
|
from unilabos.workflow.common import build_protocol_graph # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 辅助 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def _nodes_by_template(graph, template_name: str) -> List[Dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{"id": nid, **node}
|
||||||
|
for nid, node in graph.nodes.items()
|
||||||
|
if node.get("template_name") == template_name
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _set_liquid_nodes_split(graph):
|
||||||
|
"""返回 (per_plate_nodes, merged_nodes)。merged 节点 name 以 `_merged_targets_` 开头。"""
|
||||||
|
all_sl = _nodes_by_template(graph, "set_liquid_from_plate")
|
||||||
|
merged = [n for n in all_sl if str(n.get("name", "")).startswith("_merged_targets_")]
|
||||||
|
per_plate = [n for n in all_sl if not str(n.get("name", "")).startswith("_merged_targets_")]
|
||||||
|
return per_plate, merged
|
||||||
|
|
||||||
|
|
||||||
|
def _labware_with_targets(target_keys: List[str], source_keys: List[str] | None = None) -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""构造 labware_info:source 端 1 个 + 任意数量 target plates + tip rack。"""
|
||||||
|
info: Dict[str, Dict[str, Any]] = {}
|
||||||
|
source_keys = source_keys or ["src_1"]
|
||||||
|
for i, sk in enumerate(source_keys, start=1):
|
||||||
|
info[sk] = {
|
||||||
|
"slot": 1 + i - 1, # slot 1 占位(实际可能映射)
|
||||||
|
"well": ["A1"],
|
||||||
|
"labware": "nest_12_reservoir_15ml",
|
||||||
|
"object": "source",
|
||||||
|
}
|
||||||
|
for i, tk in enumerate(target_keys, start=1):
|
||||||
|
info[tk] = {
|
||||||
|
"slot": 2 + i, # 错开 source 使用的 slot
|
||||||
|
"well": ["A1"],
|
||||||
|
"labware": "nest_96_wellplate_2ml_deep",
|
||||||
|
"object": "target",
|
||||||
|
}
|
||||||
|
info["tiprack_12"] = {
|
||||||
|
"slot": 12,
|
||||||
|
"well": [],
|
||||||
|
"labware": "opentrons_96_tiprack_300ul",
|
||||||
|
"object": "tiprack",
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 用例 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_plate_skipped_when_covered_by_merged():
|
||||||
|
"""单 list-targets transfer 覆盖 4 个 target reagent_keys → per-plate 不再出现。"""
|
||||||
|
targets = ["t_A", "t_B", "t_C", "t_D"]
|
||||||
|
labware = _labware_with_targets(targets, source_keys=["src_1"])
|
||||||
|
steps = [
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"parameters": {
|
||||||
|
"sources": "src_1",
|
||||||
|
"targets": targets,
|
||||||
|
"tip_racks": "tiprack_12",
|
||||||
|
"asp_vols": [8.0] * 4,
|
||||||
|
"dis_vols": [8.0] * 4,
|
||||||
|
},
|
||||||
|
"step_number": 1,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
g = build_protocol_graph(
|
||||||
|
labware_info=labware,
|
||||||
|
protocol_steps=steps,
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
|
||||||
|
per_plate, merged = _set_liquid_nodes_split(g)
|
||||||
|
|
||||||
|
# merged 节点:1 个
|
||||||
|
assert len(merged) == 1, f"应有 1 个 merged 节点;实际 {len(merged)}"
|
||||||
|
|
||||||
|
# per-plate 节点:仅 source 1 个(src_1);target 端被全部跳过
|
||||||
|
per_plate_names = {n.get("description", "") for n in per_plate}
|
||||||
|
per_plate_keys = {
|
||||||
|
n.get("description", "").replace("Set liquid: ", "")
|
||||||
|
for n in per_plate
|
||||||
|
}
|
||||||
|
assert "src_1" in per_plate_keys, "source 端 per-plate 必须保留"
|
||||||
|
for tk in targets:
|
||||||
|
assert tk not in per_plate_keys, (
|
||||||
|
f"§14:target reagent_key '{tk}' 已被 merged 覆盖,不应再有 per-plate 节点;"
|
||||||
|
f" 实际 per_plate_keys={per_plate_keys}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_plate_kept_when_also_referenced_by_str_targets():
|
||||||
|
"""R1 缓解:t_A 既被 list-targets 引用,又被 str-targets 引用 → per-plate 必须保留。"""
|
||||||
|
targets_list = ["t_A", "t_B", "t_C"]
|
||||||
|
labware = _labware_with_targets(targets_list, source_keys=["src_1"])
|
||||||
|
steps = [
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"parameters": {
|
||||||
|
"sources": "src_1",
|
||||||
|
"targets": targets_list,
|
||||||
|
"tip_racks": "tiprack_12",
|
||||||
|
"asp_vols": [5.0] * 3,
|
||||||
|
"dis_vols": [5.0] * 3,
|
||||||
|
},
|
||||||
|
"step_number": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"parameters": {
|
||||||
|
"sources": "src_1",
|
||||||
|
"targets": "t_A",
|
||||||
|
"tip_racks": "tiprack_12",
|
||||||
|
"asp_vols": [10.0],
|
||||||
|
"dis_vols": [10.0],
|
||||||
|
},
|
||||||
|
"step_number": 2,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
g = build_protocol_graph(
|
||||||
|
labware_info=labware,
|
||||||
|
protocol_steps=steps,
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
|
||||||
|
per_plate, merged = _set_liquid_nodes_split(g)
|
||||||
|
per_plate_keys = {
|
||||||
|
n.get("description", "").replace("Set liquid: ", "")
|
||||||
|
for n in per_plate
|
||||||
|
}
|
||||||
|
|
||||||
|
assert "t_A" in per_plate_keys, (
|
||||||
|
f"R1:t_A 被 str transfer #2 引用,必须保留 per-plate 节点;"
|
||||||
|
f" 实际 per_plate_keys={per_plate_keys}"
|
||||||
|
)
|
||||||
|
assert "t_B" not in per_plate_keys, "t_B 仅出现在 list-targets,应跳过"
|
||||||
|
assert "t_C" not in per_plate_keys, "t_C 仅出现在 list-targets,应跳过"
|
||||||
|
|
||||||
|
# merged 节点数:1(仅 list-targets transfer #1 生成)
|
||||||
|
assert len(merged) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_str_targets_protocol_unaffected():
|
||||||
|
"""单 slot 协议(全 str-targets)→ 每个 target reagent_key 仍有 per-plate(零回归)。"""
|
||||||
|
labware = _labware_with_targets(["t_A", "t_B"], source_keys=["src_1"])
|
||||||
|
steps = [
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"parameters": {
|
||||||
|
"sources": "src_1",
|
||||||
|
"targets": "t_A",
|
||||||
|
"tip_racks": "tiprack_12",
|
||||||
|
"asp_vols": [10.0],
|
||||||
|
"dis_vols": [10.0],
|
||||||
|
},
|
||||||
|
"step_number": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"parameters": {
|
||||||
|
"sources": "src_1",
|
||||||
|
"targets": "t_B",
|
||||||
|
"tip_racks": "tiprack_12",
|
||||||
|
"asp_vols": [20.0],
|
||||||
|
"dis_vols": [20.0],
|
||||||
|
},
|
||||||
|
"step_number": 2,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
g = build_protocol_graph(
|
||||||
|
labware_info=labware,
|
||||||
|
protocol_steps=steps,
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
|
||||||
|
per_plate, merged = _set_liquid_nodes_split(g)
|
||||||
|
per_plate_keys = {
|
||||||
|
n.get("description", "").replace("Set liquid: ", "")
|
||||||
|
for n in per_plate
|
||||||
|
}
|
||||||
|
|
||||||
|
assert merged == [], "全 str-targets 协议不应触发 merged 节点"
|
||||||
|
assert {"src_1", "t_A", "t_B"}.issubset(per_plate_keys), (
|
||||||
|
f"单 slot 协议每个 reagent_key(含 source/target)都应保留 per-plate;"
|
||||||
|
f" 实际 {per_plate_keys}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_51b9a5_style_node_count():
|
||||||
|
"""大规模场景:N 个 list-targets transfers,每个长度 M(同 source 不同跨板)。
|
||||||
|
|
||||||
|
构造:2 个 source(src_A1、src_A2)+ 9 个 target plates × 2 个 well = 18 target reagent_keys。
|
||||||
|
2 个 transfer:
|
||||||
|
- transfer #1: targets = [t_A1_1, t_A1_2, ..., t_A1_9](同 source src_A1,跨 9 plate)
|
||||||
|
- transfer #2: targets = [t_A2_1, t_A2_2, ..., t_A2_9](同 source src_A2,跨 9 plate)
|
||||||
|
|
||||||
|
期望 set_liquid_from_plate 总节点数 = 2 source per-plate + 2 merged + 0 target per-plate = 4。
|
||||||
|
"""
|
||||||
|
target_keys_a1 = [f"t_A1_{i}" for i in range(1, 10)]
|
||||||
|
target_keys_a2 = [f"t_A2_{i}" for i in range(1, 10)]
|
||||||
|
all_target_keys = target_keys_a1 + target_keys_a2
|
||||||
|
|
||||||
|
labware = _labware_with_targets(
|
||||||
|
all_target_keys,
|
||||||
|
source_keys=["src_A1", "src_A2"],
|
||||||
|
)
|
||||||
|
|
||||||
|
steps = [
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"parameters": {
|
||||||
|
"sources": "src_A1",
|
||||||
|
"targets": target_keys_a1,
|
||||||
|
"tip_racks": "tiprack_12",
|
||||||
|
"asp_vols": [8.3] * 9,
|
||||||
|
"dis_vols": [8.3] * 9,
|
||||||
|
},
|
||||||
|
"step_number": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"parameters": {
|
||||||
|
"sources": "src_A2",
|
||||||
|
"targets": target_keys_a2,
|
||||||
|
"tip_racks": "tiprack_12",
|
||||||
|
"asp_vols": [8.3] * 9,
|
||||||
|
"dis_vols": [8.3] * 9,
|
||||||
|
},
|
||||||
|
"step_number": 2,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
g = build_protocol_graph(
|
||||||
|
labware_info=labware,
|
||||||
|
protocol_steps=steps,
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
|
||||||
|
per_plate, merged = _set_liquid_nodes_split(g)
|
||||||
|
|
||||||
|
assert len(merged) == 2, f"应有 2 个 merged 节点;实际 {len(merged)}"
|
||||||
|
|
||||||
|
per_plate_keys = {
|
||||||
|
n.get("description", "").replace("Set liquid: ", "")
|
||||||
|
for n in per_plate
|
||||||
|
}
|
||||||
|
|
||||||
|
# source 端:2 个 per-plate
|
||||||
|
assert "src_A1" in per_plate_keys and "src_A2" in per_plate_keys, (
|
||||||
|
f"source 端必须有 src_A1 + src_A2 per-plate;实际 {per_plate_keys}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# target 端:18 个全部被跳过
|
||||||
|
for tk in all_target_keys:
|
||||||
|
assert tk not in per_plate_keys, (
|
||||||
|
f"§14:target reagent_key '{tk}' 应被 merged 覆盖并跳过;"
|
||||||
|
f" 实际 per_plate_keys 包含 {tk}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 总节点数 == 2 + 2
|
||||||
|
assert len(per_plate) + len(merged) == 4, (
|
||||||
|
f"set_liquid_from_plate 总节点数应为 4 (2 source + 2 merged + 0 target per-plate);"
|
||||||
|
f" 实际 per_plate={len(per_plate)} merged={len(merged)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_per_plate_always_kept():
|
||||||
|
"""source reagent_keys 不出现在任何 targets 字段中 → per-plate 节点恒保留(与 §14 无关)。"""
|
||||||
|
target_keys = ["t_A", "t_B", "t_C"]
|
||||||
|
labware = _labware_with_targets(target_keys, source_keys=["src_X", "src_Y"])
|
||||||
|
|
||||||
|
steps = [
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"parameters": {
|
||||||
|
"sources": "src_X",
|
||||||
|
"targets": target_keys,
|
||||||
|
"tip_racks": "tiprack_12",
|
||||||
|
"asp_vols": [5.0] * 3,
|
||||||
|
"dis_vols": [5.0] * 3,
|
||||||
|
},
|
||||||
|
"step_number": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"parameters": {
|
||||||
|
"sources": "src_Y",
|
||||||
|
"targets": "t_A",
|
||||||
|
"tip_racks": "tiprack_12",
|
||||||
|
"asp_vols": [10.0],
|
||||||
|
"dis_vols": [10.0],
|
||||||
|
},
|
||||||
|
"step_number": 2,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
g = build_protocol_graph(
|
||||||
|
labware_info=labware,
|
||||||
|
protocol_steps=steps,
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
|
||||||
|
per_plate, _ = _set_liquid_nodes_split(g)
|
||||||
|
per_plate_keys = {
|
||||||
|
n.get("description", "").replace("Set liquid: ", "")
|
||||||
|
for n in per_plate
|
||||||
|
}
|
||||||
|
|
||||||
|
assert "src_X" in per_plate_keys, "source src_X 必须有 per-plate(source 不会被 §14 跳过)"
|
||||||
|
assert "src_Y" in per_plate_keys, "source src_Y 必须有 per-plate"
|
||||||
534
tests/workflow/test_labware_mapping.py
Normal file
534
tests/workflow/test_labware_mapping.py
Normal file
@@ -0,0 +1,534 @@
|
|||||||
|
"""P6 / P6.1 / P6.1.1 `labware_mapping.py` 单元测试 —— 对应 06-labware-mapping-table.md §11.7.7 / §11.8.7。
|
||||||
|
|
||||||
|
这些用例只依赖 `unilabos.workflow.labware_mapping` 自身与 PyYAML,
|
||||||
|
不需要 ROS2 / matplotlib / networkx 等环境,可直接 `pytest tests/workflow/test_labware_mapping.py`。
|
||||||
|
|
||||||
|
P6.1.1 schema(v1.9):
|
||||||
|
- 顶层 key 两段:``kinds`` / ``target_devices``(**P6.1.1 起顶层 `slot_remap` 已不支持**,下沉到 ``target_devices.<device>`` 内)
|
||||||
|
- ``target_devices.default`` 是固定段名,作为兜底物料集,第一版按 prcxi 拷贝填充,**不支持 `models` 子段**
|
||||||
|
- ``target_devices.<device>.models.<model>`` 是可选的型号粒度覆盖(slot_remap / rules)
|
||||||
|
- 旧 schema(顶层 ``vendors`` / ``slot_remap`` 或 rule 含 ``prcxi_class``)会触发 warning + fallback 到 builtin
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import warnings
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||||
|
if str(ROOT_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT_DIR))
|
||||||
|
|
||||||
|
from unilabos.workflow import labware_mapping as lm
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_lru_cache():
|
||||||
|
"""每个用例后清缓存,避免 monkeypatch 跨用例污染。"""
|
||||||
|
yield
|
||||||
|
lm.reload_mapping()
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== slot_remap ====================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"raw,object_type,want",
|
||||||
|
[
|
||||||
|
("4", "", "13"),
|
||||||
|
("8", "", "14"),
|
||||||
|
("12", "trash", "16"),
|
||||||
|
("12", "source", "12"),
|
||||||
|
("1", "", "1"),
|
||||||
|
("", "", ""),
|
||||||
|
(4, "", "13"), # 非字符串入参也应规整
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_remap_slot_basic(raw, object_type, want):
|
||||||
|
assert lm.remap_slot(raw, object_type) == want
|
||||||
|
|
||||||
|
|
||||||
|
def test_remap_slot_none_returns_empty():
|
||||||
|
assert lm.remap_slot(None) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_remap_slot_passthrough_unknown():
|
||||||
|
assert lm.remap_slot("99") == "99"
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== infer_kind ====================
|
||||||
|
|
||||||
|
|
||||||
|
def test_infer_kind_trash_priority():
|
||||||
|
"""`trash` 在 kinds 列表第 1 条 → 优先于含 'rack' 的字符串。"""
|
||||||
|
assert lm.infer_kind("foo_trash_bar") == "trash"
|
||||||
|
assert lm.infer_kind("opentrons_fixed_trash") == "trash"
|
||||||
|
|
||||||
|
|
||||||
|
def test_infer_kind_tiprack_before_tuberack():
|
||||||
|
"""`tiprack` 子串包含 'rack',但应被 tip_rack 规则先抓到(顺序敏感)。"""
|
||||||
|
assert lm.infer_kind("opentrons_96_tiprack_300ul") == "tip_rack"
|
||||||
|
assert lm.infer_kind("opentrons_96_tiprack_20ul") == "tip_rack"
|
||||||
|
|
||||||
|
|
||||||
|
def test_infer_kind_tube_rack_variants():
|
||||||
|
assert (
|
||||||
|
lm.infer_kind("opentrons_24_tuberack_eppendorf_2ml_safelock_snapcap")
|
||||||
|
== "tube_rack"
|
||||||
|
)
|
||||||
|
assert lm.infer_kind("opentrons_10_tuberack_falcon_4x50ml_6x15ml_conical") == "tube_rack"
|
||||||
|
|
||||||
|
|
||||||
|
def test_infer_kind_object_overrides_string():
|
||||||
|
"""object 字段优先:即使字符串看起来像 plate,trash / tiprack 也能强制归类。"""
|
||||||
|
assert lm.infer_kind("anything_at_all", "tiprack") == "tip_rack"
|
||||||
|
assert lm.infer_kind("opentrons_96_wellplate", "trash") == "trash"
|
||||||
|
|
||||||
|
|
||||||
|
def test_infer_kind_default_plate():
|
||||||
|
assert lm.infer_kind("opentrons_96_wellplate_300ul_pcr") == "plate"
|
||||||
|
assert lm.infer_kind("custom_384_wellplate_2200ul") == "plate"
|
||||||
|
|
||||||
|
|
||||||
|
def test_infer_kind_rack_without_tip_is_tube_rack():
|
||||||
|
"""复现历史 `_infer_reagent_kind` 中「含 rack 不含 tip → tube_rack」的语义。"""
|
||||||
|
assert lm.infer_kind("nest_4x6_rack") == "tube_rack"
|
||||||
|
|
||||||
|
|
||||||
|
def test_infer_kind_empty_hint_returns_plate():
|
||||||
|
assert lm.infer_kind("") == "plate"
|
||||||
|
assert lm.infer_kind(None) == "plate" # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== resolve_target_class(target_device="prcxi") ====================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"vol,want",
|
||||||
|
[
|
||||||
|
(1, "PRCXI_10uL_Tips"),
|
||||||
|
(9, "PRCXI_10uL_Tips"),
|
||||||
|
(10, "PRCXI_10uL_Tips"), # 闭区间 ≤10
|
||||||
|
(11, "PRCXI_300ul_Tips"),
|
||||||
|
(200, "PRCXI_300ul_Tips"),
|
||||||
|
(299.9, "PRCXI_300ul_Tips"),
|
||||||
|
(300, "PRCXI_1000uL_Tips"), # 300 上一档(与 <300 半开等价)
|
||||||
|
(500, "PRCXI_1000uL_Tips"),
|
||||||
|
(1000, "PRCXI_1000uL_Tips"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_resolve_tip_volume_buckets(vol, want):
|
||||||
|
assert lm.resolve_target_class("prcxi", "tip_rack", 96, vol) == want
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_tube_rack_holes():
|
||||||
|
assert lm.resolve_target_class("prcxi", "tube_rack", 24, None) == "PRCXI_EP_Adapter"
|
||||||
|
assert lm.resolve_target_class("prcxi", "tube_rack", 10, None) == "PRCXI_EP_Adapter"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_plate_holes():
|
||||||
|
assert lm.resolve_target_class("prcxi", "plate", 96, None) == "PRCXI_BioER_96_wellplate"
|
||||||
|
assert (
|
||||||
|
lm.resolve_target_class("prcxi", "plate", 384, None) == "PRCXI_BioER_384_wellplate"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_plate_unknown_holes_returns_none():
|
||||||
|
"""48 孔板未在 YAML 列出 → None;交给 PRCXI 模板打分匹配 fallback。"""
|
||||||
|
assert lm.resolve_target_class("prcxi", "plate", 48, 2200) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_trash_any():
|
||||||
|
assert lm.resolve_target_class("prcxi", "trash", None, None) == "PRCXI_trash"
|
||||||
|
# trash 规则未约束 hole_count / volume,所以任意值都命中
|
||||||
|
assert lm.resolve_target_class("prcxi", "trash", 0, 0) == "PRCXI_trash"
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== YAML 缺失 / 热加载 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_yaml_uses_builtin(monkeypatch, tmp_path):
|
||||||
|
"""YAML 文件不存在时,应自动落到 `_BUILTIN_DEFAULT`,且打 warning。"""
|
||||||
|
bogus = tmp_path / "no_such_labware_mapping.yaml"
|
||||||
|
monkeypatch.setattr(lm, "_DEFAULT_PATH", bogus)
|
||||||
|
lm._load_mapping.cache_clear()
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
assert lm.remap_slot("4") == "13"
|
||||||
|
assert (
|
||||||
|
lm.resolve_target_class("prcxi", "plate", 96, None)
|
||||||
|
== "PRCXI_BioER_96_wellplate"
|
||||||
|
)
|
||||||
|
assert any("labware_mapping.yaml 未找到" in str(w.message) for w in caught)
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_yaml_uses_builtin(monkeypatch, tmp_path):
|
||||||
|
"""YAML 解析失败也应回退到 builtin,且打 warning。"""
|
||||||
|
bad = tmp_path / "labware_mapping.yaml"
|
||||||
|
bad.write_text("this is :: not valid: yaml: [unclosed", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(lm, "_DEFAULT_PATH", bad)
|
||||||
|
lm._load_mapping.cache_clear()
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
assert lm.remap_slot("4") == "13"
|
||||||
|
assert any(
|
||||||
|
"labware_mapping.yaml 解析失败" in str(w.message)
|
||||||
|
or "labware_mapping.yaml 根不是 dict" in str(w.message)
|
||||||
|
for w in caught
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_yaml_reload_after_edit(monkeypatch, tmp_path):
|
||||||
|
"""临时 YAML 覆盖 + reload_mapping → 新规则生效,且原规则失效(P6.1.1 schema)。"""
|
||||||
|
tmp_yaml = tmp_path / "labware_mapping.yaml"
|
||||||
|
tmp_yaml.write_text(
|
||||||
|
'kinds:\n'
|
||||||
|
" - { pattern: 'trash', kind: trash }\n"
|
||||||
|
" - { pattern: '.*', kind: plate }\n"
|
||||||
|
'target_devices:\n'
|
||||||
|
' default:\n'
|
||||||
|
' slot_remap:\n'
|
||||||
|
' default: {"4": "99"}\n'
|
||||||
|
' by_object: {}\n'
|
||||||
|
' rules:\n'
|
||||||
|
" - { kind: plate, hole_count: 96, class_name: PRCXI_FooPlate }\n"
|
||||||
|
' prcxi:\n'
|
||||||
|
' slot_remap:\n'
|
||||||
|
' default: {"4": "99"}\n'
|
||||||
|
' by_object: {}\n'
|
||||||
|
' rules:\n'
|
||||||
|
" - { kind: plate, hole_count: 96, class_name: PRCXI_FooPlate }\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(lm, "_DEFAULT_PATH", tmp_yaml)
|
||||||
|
lm.reload_mapping()
|
||||||
|
assert lm.remap_slot("4") == "99"
|
||||||
|
assert lm.resolve_target_class("prcxi", "plate", 96, None) == "PRCXI_FooPlate"
|
||||||
|
# 新表里只有 96,没有 384 → None
|
||||||
|
assert lm.resolve_target_class("prcxi", "plate", 384, None) is None
|
||||||
|
# tube_rack / tip_rack 在新表里没规则 → None
|
||||||
|
assert lm.resolve_target_class("prcxi", "tip_rack", 96, 200) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_section_uses_builtin(monkeypatch, tmp_path):
|
||||||
|
"""YAML 缺 `kinds` 段 → 该段使用 builtin,其它段保留用户值(P6.1.1 schema)。"""
|
||||||
|
partial = tmp_path / "labware_mapping.yaml"
|
||||||
|
partial.write_text(
|
||||||
|
'target_devices:\n'
|
||||||
|
' default:\n'
|
||||||
|
' slot_remap:\n'
|
||||||
|
' default: {"4": "88"}\n'
|
||||||
|
' by_object: {}\n'
|
||||||
|
' rules: []\n'
|
||||||
|
' prcxi:\n'
|
||||||
|
' slot_remap:\n'
|
||||||
|
' default: {"4": "88"}\n'
|
||||||
|
' by_object: {}\n'
|
||||||
|
' rules: []\n', # 故意没有 kinds 段
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(lm, "_DEFAULT_PATH", partial)
|
||||||
|
lm._load_mapping.cache_clear()
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
# slot_remap 用 YAML 中的覆盖值
|
||||||
|
assert lm.remap_slot("4") == "88"
|
||||||
|
# kinds 段缺失 → 使用 builtin 的 tiprack 规则
|
||||||
|
assert lm.infer_kind("opentrons_96_tiprack_300ul") == "tip_rack"
|
||||||
|
assert any("缺少 `kinds` 段" in str(w.message) for w in caught)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== P6.1 新增用例 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_target_class_prcxi_tip_buckets():
|
||||||
|
"""PRCXI tip 量程档:≤10 / <300 / 否则 1000(与历史 _tip_prcxi_class_for_max_ul 等价)。"""
|
||||||
|
assert lm.resolve_target_class("prcxi", "tip_rack", 96, 10) == "PRCXI_10uL_Tips"
|
||||||
|
assert lm.resolve_target_class("prcxi", "tip_rack", 96, 200) == "PRCXI_300ul_Tips"
|
||||||
|
assert lm.resolve_target_class("prcxi", "tip_rack", 96, 1000) == "PRCXI_1000uL_Tips"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_target_class_unknown_device_falls_back_to_default_section():
|
||||||
|
"""未声明的 target_device 自动回退到固定段 target_devices.default,打 warning。
|
||||||
|
第一版 default 段内容按 prcxi 拷贝 → 断言:caller 传 'tecan' 时,结果应等于查 default 段。"""
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
# tecan / beckman / 任意未声明名字 → 全部回退到固定段 "default"
|
||||||
|
assert (
|
||||||
|
lm.resolve_target_class("tecan", "tip_rack", 96, 200)
|
||||||
|
== lm.resolve_target_class("default", "tip_rack", 96, 200)
|
||||||
|
== "PRCXI_300ul_Tips" # 第一版 default 段按 prcxi 填,所以值仍是 PRCXI_*
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
lm.resolve_target_class("unknown_xxx", "plate", 96, None)
|
||||||
|
== lm.resolve_target_class("default", "plate", 96, None)
|
||||||
|
)
|
||||||
|
# 至少打 1 次 warning,提示「未声明、已回退到 default 段」
|
||||||
|
assert any(
|
||||||
|
("未在 labware_mapping.yaml" in str(w.message))
|
||||||
|
or ("target_devices.default" in str(w.message))
|
||||||
|
for w in caught
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_target_class_per_device_tip_buckets(tmp_path, monkeypatch):
|
||||||
|
"""**P6.1 核心断言**:不同 target_device 在同一体积下命中不同 tip 量程档(P6.1.1 schema)。"""
|
||||||
|
yaml_path = tmp_path / "labware_mapping.yaml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
'kinds: [{pattern: ".*", kind: plate}]\n'
|
||||||
|
'target_devices:\n'
|
||||||
|
' default:\n'
|
||||||
|
' slot_remap: {default: {}, by_object: {}}\n'
|
||||||
|
' rules:\n'
|
||||||
|
' - {kind: tip_rack, hole_count: 96, volume_max: 10, class_name: PRCXI_10uL_Tips}\n'
|
||||||
|
' - {kind: tip_rack, hole_count: 96, volume_max: 299.9, class_name: PRCXI_300ul_Tips}\n'
|
||||||
|
' - {kind: tip_rack, hole_count: 96, class_name: PRCXI_1000uL_Tips}\n'
|
||||||
|
' prcxi:\n'
|
||||||
|
' slot_remap: {default: {}, by_object: {}}\n'
|
||||||
|
' rules:\n'
|
||||||
|
' - {kind: tip_rack, hole_count: 96, volume_max: 10, class_name: PRCXI_10uL_Tips}\n'
|
||||||
|
' - {kind: tip_rack, hole_count: 96, volume_max: 299.9, class_name: PRCXI_300ul_Tips}\n'
|
||||||
|
' - {kind: tip_rack, hole_count: 96, class_name: PRCXI_1000uL_Tips}\n'
|
||||||
|
' beckman:\n'
|
||||||
|
' slot_remap: {default: {}, by_object: {}}\n'
|
||||||
|
' rules:\n'
|
||||||
|
' - {kind: tip_rack, hole_count: 96, volume_max: 20, class_name: Beckman_20uL_Tips}\n'
|
||||||
|
' - {kind: tip_rack, hole_count: 96, volume_max: 199.9, class_name: Beckman_200uL_Tips}\n'
|
||||||
|
' - {kind: tip_rack, hole_count: 96, class_name: Beckman_1000uL_Tips}\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
|
||||||
|
lm.reload_mapping()
|
||||||
|
|
||||||
|
# 同样的体积 200:prcxi 走 300 档、beckman 已超出 200 档 → 1000 档
|
||||||
|
assert lm.resolve_target_class("prcxi", "tip_rack", 96, 200) == "PRCXI_300ul_Tips"
|
||||||
|
assert lm.resolve_target_class("beckman", "tip_rack", 96, 200) == "Beckman_1000uL_Tips"
|
||||||
|
# 同样的体积 15:prcxi 已超出 10 档 → 300 档;beckman 仍在 20 档
|
||||||
|
assert lm.resolve_target_class("prcxi", "tip_rack", 96, 15) == "PRCXI_300ul_Tips"
|
||||||
|
assert lm.resolve_target_class("beckman", "tip_rack", 96, 15) == "Beckman_20uL_Tips"
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_section_independent_from_prcxi(tmp_path, monkeypatch):
|
||||||
|
"""default 与 prcxi 是两段独立物料集:改 default 不影响 prcxi、改 prcxi 不影响 default。
|
||||||
|
|
||||||
|
断言:把 default 段改成 Generic_Plate96,prcxi 段保持 PRCXI_Plate96 时,
|
||||||
|
caller 传未声明的名字回退到 default 拿 Generic_Plate96,传 prcxi 仍拿 PRCXI_Plate96。
|
||||||
|
"""
|
||||||
|
yaml_path = tmp_path / "labware_mapping.yaml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
'kinds: [{pattern: ".*", kind: plate}]\n'
|
||||||
|
'target_devices:\n'
|
||||||
|
' default:\n' # ← 独立改 default 段
|
||||||
|
' slot_remap: {default: {}, by_object: {}}\n'
|
||||||
|
' rules:\n'
|
||||||
|
' - {kind: plate, hole_count: 96, class_name: Generic_Plate96}\n'
|
||||||
|
' prcxi:\n' # ← prcxi 段保持 PRCXI_*
|
||||||
|
' slot_remap: {default: {}, by_object: {}}\n'
|
||||||
|
' rules:\n'
|
||||||
|
' - {kind: plate, hole_count: 96, class_name: PRCXI_Plate96}\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
|
||||||
|
lm.reload_mapping()
|
||||||
|
|
||||||
|
# caller 传未声明的 tecan → 走 default 段 → Generic_*
|
||||||
|
assert lm.resolve_target_class("tecan", "plate", 96, None) == "Generic_Plate96"
|
||||||
|
# caller 显式传 prcxi → 走 prcxi 段 → PRCXI_*(**不**受 default 影响)
|
||||||
|
assert lm.resolve_target_class("prcxi", "plate", 96, None) == "PRCXI_Plate96"
|
||||||
|
# 显式传 "default" 也合法(caller 可主动选择走 default 段)
|
||||||
|
assert lm.resolve_target_class("default", "plate", 96, None) == "Generic_Plate96"
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_yaml_schema_rejected_with_warning(tmp_path, monkeypatch):
|
||||||
|
"""旧 schema(vendors / prcxi_class)应被拒绝 + warning + 整段 fallback 到 builtin(P6.1.1 schema)。"""
|
||||||
|
legacy = tmp_path / "labware_mapping.yaml"
|
||||||
|
legacy.write_text(
|
||||||
|
'kinds: [{pattern: ".*", kind: plate}]\n'
|
||||||
|
'vendors:\n' # ← 旧顶层 key
|
||||||
|
' opentrons:\n'
|
||||||
|
' rules:\n'
|
||||||
|
" - {kind: plate, hole_count: 96, prcxi_class: PRCXI_FooPlate}\n", # ← 旧字段
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(lm, "_DEFAULT_PATH", legacy)
|
||||||
|
lm._load_mapping.cache_clear()
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
# 整段走 builtin → 96 板还是 PRCXI_BioER_96_wellplate(**不是**用户旧 YAML 中的 PRCXI_FooPlate)
|
||||||
|
assert lm.resolve_target_class("prcxi", "plate", 96, None) == "PRCXI_BioER_96_wellplate"
|
||||||
|
assert any(
|
||||||
|
("旧 schema" in str(w.message))
|
||||||
|
or ("vendors" in str(w.message))
|
||||||
|
or ("prcxi_class" in str(w.message))
|
||||||
|
for w in caught
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_target_class_unknown_kind_returns_none():
|
||||||
|
"""target_device 存在、kind 不存在 → None。"""
|
||||||
|
assert lm.resolve_target_class("prcxi", "reservoir", 12, None) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== P6.1.1 新增用例(slot_remap 按 device + model 分叉) ====================
|
||||||
|
|
||||||
|
|
||||||
|
def test_remap_slot_model_level_overrides_device_level(tmp_path, monkeypatch):
|
||||||
|
"""型号级 slot_remap 优先级 > 厂商级。"""
|
||||||
|
yaml_path = tmp_path / "labware_mapping.yaml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
'kinds: [{pattern: ".*", kind: plate}]\n'
|
||||||
|
'target_devices:\n'
|
||||||
|
' default:\n'
|
||||||
|
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
||||||
|
' rules: []\n'
|
||||||
|
' prcxi:\n'
|
||||||
|
' slot_remap: {default: {"4": "13"}, by_object: {trash: {"12": "16"}}}\n'
|
||||||
|
' rules: []\n'
|
||||||
|
' models:\n'
|
||||||
|
' "4040":\n'
|
||||||
|
' slot_remap: {default: {"4": "16"}, by_object: {}}\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
|
||||||
|
lm.reload_mapping()
|
||||||
|
# device 级(不传 model)→ "13"
|
||||||
|
assert lm.remap_slot("4", target_device="prcxi") == "13"
|
||||||
|
# model "4040" 覆盖 → "16"
|
||||||
|
assert lm.remap_slot("4", target_device="prcxi", target_model="4040") == "16"
|
||||||
|
# model "9320" 未声明 → 静默 fallback 到 device 级 → "13"
|
||||||
|
assert lm.remap_slot("4", target_device="prcxi", target_model="9320") == "13"
|
||||||
|
|
||||||
|
|
||||||
|
def test_remap_slot_model_inherits_device_when_field_missing(tmp_path, monkeypatch):
|
||||||
|
"""model 子段声明但 slot_remap 字段缺失 → 静默继承厂商级;rules 同理。"""
|
||||||
|
yaml_path = tmp_path / "labware_mapping.yaml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
'kinds: [{pattern: ".*", kind: plate}]\n'
|
||||||
|
'target_devices:\n'
|
||||||
|
' default:\n'
|
||||||
|
' slot_remap: {default: {}, by_object: {}}\n'
|
||||||
|
' rules: []\n'
|
||||||
|
' prcxi:\n'
|
||||||
|
' slot_remap: {default: {"4": "13", "8": "14"}, by_object: {}}\n'
|
||||||
|
' rules: [{kind: plate, hole_count: 96, class_name: PRCXI_PlateA}]\n'
|
||||||
|
' models:\n'
|
||||||
|
' "9320":\n'
|
||||||
|
' rules: [{kind: plate, hole_count: 96, class_name: PRCXI_PlateB}]\n', # 仅覆盖 rules,未声明 slot_remap
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
|
||||||
|
lm.reload_mapping()
|
||||||
|
# model 9320 的 slot_remap 缺字段 → 继承 prcxi.slot_remap → "4" → "13"
|
||||||
|
assert lm.remap_slot("4", target_device="prcxi", target_model="9320") == "13"
|
||||||
|
# model 9320 的 rules 覆盖 → PRCXI_PlateB
|
||||||
|
assert (
|
||||||
|
lm.resolve_target_class("prcxi", "plate", 96, None, target_model="9320")
|
||||||
|
== "PRCXI_PlateB"
|
||||||
|
)
|
||||||
|
# 不传 model → 用厂商级 rules → PRCXI_PlateA
|
||||||
|
assert lm.resolve_target_class("prcxi", "plate", 96, None) == "PRCXI_PlateA"
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_top_level_slot_remap_rejected(tmp_path, monkeypatch):
|
||||||
|
"""P6.1.1:顶层 slot_remap 段被视为旧 schema → warning + 整段 fallback 到 builtin。"""
|
||||||
|
legacy = tmp_path / "labware_mapping.yaml"
|
||||||
|
legacy.write_text(
|
||||||
|
'slot_remap:\n' # ← P6.1.1 已不支持的顶层段
|
||||||
|
' default: {"4": "99"}\n'
|
||||||
|
' by_object: {}\n'
|
||||||
|
'kinds: [{pattern: ".*", kind: plate}]\n'
|
||||||
|
'target_devices:\n'
|
||||||
|
' default:\n'
|
||||||
|
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
||||||
|
' rules: []\n'
|
||||||
|
' prcxi:\n'
|
||||||
|
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
||||||
|
' rules: []\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(lm, "_DEFAULT_PATH", legacy)
|
||||||
|
lm._load_mapping.cache_clear()
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
# 整段走 builtin → "4" 仍然 → "13"(builtin 值),**不是** YAML 顶层的 "99"
|
||||||
|
assert lm.remap_slot("4", target_device="prcxi") == "13"
|
||||||
|
assert any(
|
||||||
|
("顶层" in str(w.message) and "slot_remap" in str(w.message))
|
||||||
|
or ("旧 schema" in str(w.message))
|
||||||
|
for w in caught
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_remap_slot_unknown_device_falls_back_with_warning(tmp_path, monkeypatch):
|
||||||
|
"""未声明的 target_device → fallback 到 default.slot_remap + warning(与 resolve_target_class 同语义)。"""
|
||||||
|
yaml_path = tmp_path / "labware_mapping.yaml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
'kinds: [{pattern: ".*", kind: plate}]\n'
|
||||||
|
'target_devices:\n'
|
||||||
|
' default:\n'
|
||||||
|
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
||||||
|
' rules: []\n'
|
||||||
|
' prcxi:\n'
|
||||||
|
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
||||||
|
' rules: []\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
|
||||||
|
lm.reload_mapping()
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
assert lm.remap_slot("4", target_device="tecan") == "13" # fallback 到 default
|
||||||
|
assert any(
|
||||||
|
("tecan" in str(w.message)) or ("target_devices.default" in str(w.message))
|
||||||
|
for w in caught
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_remap_slot_model_only_no_device_passthrough(tmp_path, monkeypatch):
|
||||||
|
"""caller 传 target_model 但 target_device 段不存在 → 直接走 default.slot_remap(model 名忽略)。"""
|
||||||
|
yaml_path = tmp_path / "labware_mapping.yaml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
'kinds: [{pattern: ".*", kind: plate}]\n'
|
||||||
|
'target_devices:\n'
|
||||||
|
' default:\n'
|
||||||
|
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
||||||
|
' rules: []\n', # 没有 prcxi 段
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
|
||||||
|
lm.reload_mapping()
|
||||||
|
with warnings.catch_warnings(record=True):
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
# target_device "prcxi" 不存在、target_model 即使传也忽略 → 走 default
|
||||||
|
assert lm.remap_slot("4", target_device="prcxi", target_model="9320") == "13"
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_section_models_subsection_warns(tmp_path, monkeypatch):
|
||||||
|
"""target_devices.default.models 不被支持 → warning,但 default.slot_remap 仍生效。"""
|
||||||
|
yaml_path = tmp_path / "labware_mapping.yaml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
'kinds: [{pattern: ".*", kind: plate}]\n'
|
||||||
|
'target_devices:\n'
|
||||||
|
' default:\n'
|
||||||
|
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
||||||
|
' rules: []\n'
|
||||||
|
' models:\n' # ← default 段不支持 models
|
||||||
|
' "ghost":\n'
|
||||||
|
' slot_remap: {default: {"4": "99"}, by_object: {}}\n'
|
||||||
|
' prcxi:\n'
|
||||||
|
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
||||||
|
' rules: []\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
|
||||||
|
lm._load_mapping.cache_clear()
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
# default 段的 models 被忽略 → 走 default.slot_remap → "13"(不是 "99")
|
||||||
|
assert lm.remap_slot("4", target_device="tecan", target_model="ghost") == "13"
|
||||||
|
assert any(
|
||||||
|
("default" in str(w.message) and "models" in str(w.message))
|
||||||
|
for w in caught
|
||||||
|
)
|
||||||
178
tests/workflow/test_wf_utils_workflow_name.py
Normal file
178
tests/workflow/test_wf_utils_workflow_name.py
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
"""``unilabos.workflow.wf_utils.upload_workflow`` 工作流名称 fallback 链单元测试。
|
||||||
|
|
||||||
|
对应需求:上传工作流时,**优先取 metadata.workflow_name**;缺失时再回退到顶层
|
||||||
|
``workflow_name``(旧 node-link 形态遗留字段);最后才回退到文件名(去 ``.json`` 后缀)。
|
||||||
|
CLI 显式 ``-n/--workflow_name`` 永远最优先。
|
||||||
|
|
||||||
|
本测试只校验「**名称 fallback 链 + tags fallback 链**」的纯逻辑路径,
|
||||||
|
不实际访问 HTTP / 后端;通过 monkeypatch 把 ``http_client.workflow_import``
|
||||||
|
桩成可观察的捕获函数。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
# 让 import 走 Uni-Lab-OS 包根
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
SRC = ROOT / "unilabos"
|
||||||
|
if str(ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def stub_upload(monkeypatch, tmp_path):
|
||||||
|
"""Monkeypatch ``http_client.workflow_import`` + ``_convert_to_node_link``,
|
||||||
|
返回 (helper, captured) 二元组:
|
||||||
|
|
||||||
|
- ``helper(workflow_data, **upload_kwargs)`` 写入 tmp_path/wf.json
|
||||||
|
并调用 ``upload_workflow``;
|
||||||
|
- ``captured`` 是 dict,记录 ``workflow_import`` 实际收到的 kwargs,
|
||||||
|
以及 ``_convert_to_node_link`` 是否被调过。
|
||||||
|
|
||||||
|
本测试不依赖真实 ``unilabos.app.web``(其级联依赖含 ``fastapi`` 等重型
|
||||||
|
package,本地 dev venv 不必装)。通过在 sys.modules 注入空壳 module 拦截
|
||||||
|
delayed import。
|
||||||
|
"""
|
||||||
|
import types
|
||||||
|
|
||||||
|
captured: Dict[str, Any] = {"workflow_import_kwargs": None, "converted": False}
|
||||||
|
|
||||||
|
def fake_workflow_import(**kwargs): # noqa: ANN003
|
||||||
|
captured["workflow_import_kwargs"] = kwargs
|
||||||
|
return {"code": 0, "data": {"uuid": "fake-uuid", "name": kwargs.get("name")}}
|
||||||
|
|
||||||
|
# 关键:在 wf_utils 触发 `from unilabos.app.web import http_client` 之前
|
||||||
|
# 用空壳 module 占位(避免触发真实 web 包的 fastapi 依赖链)。
|
||||||
|
fake_http_client = types.ModuleType("unilabos.app.web.http_client")
|
||||||
|
fake_http_client.workflow_import = fake_workflow_import # type: ignore[attr-defined]
|
||||||
|
fake_web_pkg = types.ModuleType("unilabos.app.web")
|
||||||
|
fake_web_pkg.http_client = fake_http_client # type: ignore[attr-defined]
|
||||||
|
monkeypatch.setitem(sys.modules, "unilabos.app.web", fake_web_pkg)
|
||||||
|
monkeypatch.setitem(sys.modules, "unilabos.app.web.http_client", fake_http_client)
|
||||||
|
|
||||||
|
from unilabos.workflow import wf_utils
|
||||||
|
|
||||||
|
# _convert_to_node_link 走真实路径会拉重型依赖,这里桩为 node-link 直返回
|
||||||
|
def fake_convert_to_node_link(workflow_file, workflow_data, *, target_device="prcxi", target_model=None):
|
||||||
|
captured["converted"] = True
|
||||||
|
# 返回最小合法 node-link 形态(不带 metadata,模拟当前行为)
|
||||||
|
return {"nodes": [], "edges": [], "workflow_uuid": ""}
|
||||||
|
|
||||||
|
monkeypatch.setattr(wf_utils, "_convert_to_node_link", fake_convert_to_node_link)
|
||||||
|
|
||||||
|
def helper(workflow_data: Dict[str, Any], **upload_kwargs: Any) -> Dict[str, Any]:
|
||||||
|
wf_path = tmp_path / "transfer_actions_sample.json"
|
||||||
|
wf_path.write_text(json.dumps(workflow_data, ensure_ascii=False), encoding="utf-8")
|
||||||
|
return wf_utils.upload_workflow(str(wf_path), **upload_kwargs)
|
||||||
|
|
||||||
|
return helper, captured
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== workflow_name fallback 链 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_workflow_name_wins_over_filename(stub_upload):
|
||||||
|
"""P5 主路径:transfer_actions JSON 含 metadata.workflow_name → 优先于文件名。"""
|
||||||
|
helper, captured = stub_upload
|
||||||
|
data = {
|
||||||
|
"metadata": {"workflow_name": "PCR Prep with Categories", "tags": []},
|
||||||
|
"workflow": [],
|
||||||
|
"reagent": {},
|
||||||
|
}
|
||||||
|
helper(data)
|
||||||
|
kwargs = captured["workflow_import_kwargs"]
|
||||||
|
assert kwargs is not None and captured["converted"] is True
|
||||||
|
assert kwargs["name"] == "PCR Prep with Categories"
|
||||||
|
assert kwargs["workflow_name"] == "PCR Prep with Categories"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_workflow_name_overrides_metadata(stub_upload):
|
||||||
|
"""CLI 显式 -n/--workflow_name 永远最优先。"""
|
||||||
|
helper, captured = stub_upload
|
||||||
|
data = {
|
||||||
|
"metadata": {"workflow_name": "Metadata Wins By Default"},
|
||||||
|
"workflow": [],
|
||||||
|
"reagent": {},
|
||||||
|
}
|
||||||
|
helper(data, workflow_name="CLI Override Name")
|
||||||
|
kwargs = captured["workflow_import_kwargs"]
|
||||||
|
assert kwargs["name"] == "CLI Override Name"
|
||||||
|
assert kwargs["workflow_name"] == "CLI Override Name"
|
||||||
|
|
||||||
|
|
||||||
|
def test_filename_used_when_no_metadata_and_no_legacy(stub_upload):
|
||||||
|
"""P5 之前的旧文件、且无顶层 workflow_name → 回退到去 .json 后缀的文件名。"""
|
||||||
|
helper, captured = stub_upload
|
||||||
|
data = {"workflow": [], "reagent": {}} # 既无 metadata,也无 workflow_name
|
||||||
|
helper(data)
|
||||||
|
kwargs = captured["workflow_import_kwargs"]
|
||||||
|
# 文件名由 fixture 固定为 transfer_actions_sample.json
|
||||||
|
assert kwargs["name"] == "transfer_actions_sample"
|
||||||
|
assert kwargs["workflow_name"] == "transfer_actions_sample"
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_empty_string_falls_back_to_filename(stub_upload):
|
||||||
|
"""metadata.workflow_name 为空字符串(而非缺失)也应回退到文件名。"""
|
||||||
|
helper, captured = stub_upload
|
||||||
|
data = {
|
||||||
|
"metadata": {"workflow_name": " "}, # whitespace-only
|
||||||
|
"workflow": [],
|
||||||
|
"reagent": {},
|
||||||
|
}
|
||||||
|
helper(data)
|
||||||
|
kwargs = captured["workflow_import_kwargs"]
|
||||||
|
assert kwargs["name"] == "transfer_actions_sample"
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_top_level_workflow_name_used_when_metadata_missing(stub_upload, monkeypatch):
|
||||||
|
"""旧 node-link 文件(已是 nodes/edges 形态)顶层 workflow_name → 应被使用。
|
||||||
|
|
||||||
|
覆盖路径:``_is_node_link_format`` 直接命中 → 不走转换 → workflow_data 保留顶层
|
||||||
|
workflow_name;``orig_metadata`` 为空时 fallback 到该字段。
|
||||||
|
"""
|
||||||
|
helper, captured = stub_upload
|
||||||
|
data = {
|
||||||
|
"nodes": [],
|
||||||
|
"edges": [],
|
||||||
|
"workflow_name": "Legacy Top Name",
|
||||||
|
}
|
||||||
|
helper(data)
|
||||||
|
kwargs = captured["workflow_import_kwargs"]
|
||||||
|
assert captured["converted"] is False, "node-link 输入不应触发转换"
|
||||||
|
assert kwargs["name"] == "Legacy Top Name"
|
||||||
|
assert kwargs["workflow_name"] == "Legacy Top Name"
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== tags fallback 链 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_tags_used_when_cli_tags_missing(stub_upload):
|
||||||
|
"""P5 主路径:metadata.tags 在 CLI 未传 tags 时被使用。"""
|
||||||
|
helper, captured = stub_upload
|
||||||
|
data = {
|
||||||
|
"metadata": {"workflow_name": "X", "tags": ["Opentrons", "PCR"]},
|
||||||
|
"workflow": [],
|
||||||
|
"reagent": {},
|
||||||
|
}
|
||||||
|
helper(data)
|
||||||
|
kwargs = captured["workflow_import_kwargs"]
|
||||||
|
assert kwargs["tags"] == ["Opentrons", "PCR"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_tags_override_metadata_tags(stub_upload):
|
||||||
|
"""CLI 显式 --tags 优先于 metadata.tags。"""
|
||||||
|
helper, captured = stub_upload
|
||||||
|
data = {
|
||||||
|
"metadata": {"workflow_name": "X", "tags": ["Opentrons", "PCR"]},
|
||||||
|
"workflow": [],
|
||||||
|
"reagent": {},
|
||||||
|
}
|
||||||
|
helper(data, tags=["CLI", "Wins"])
|
||||||
|
kwargs = captured["workflow_import_kwargs"]
|
||||||
|
assert kwargs["tags"] == ["CLI", "Wins"]
|
||||||
@@ -1 +1 @@
|
|||||||
__version__ = "0.10.19"
|
__version__ = "0.11.1"
|
||||||
|
|||||||
@@ -12,6 +12,15 @@ from typing import Dict, Any, List
|
|||||||
import networkx as nx
|
import networkx as nx
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
# Windows 中文系统 stdout 默认 GBK,无法编码 banner / emoji 日志中的 Unicode 字符
|
||||||
|
# 强制 stdout/stderr 用 UTF-8,避免 print 触发 UnicodeEncodeError 导致进程崩溃
|
||||||
|
if sys.platform == "win32":
|
||||||
|
for _stream in (sys.stdout, sys.stderr):
|
||||||
|
try:
|
||||||
|
_stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
|
||||||
|
except (AttributeError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
# 首先添加项目根目录到路径
|
# 首先添加项目根目录到路径
|
||||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
unilabos_dir = os.path.dirname(os.path.dirname(current_dir))
|
unilabos_dir = os.path.dirname(os.path.dirname(current_dir))
|
||||||
@@ -233,7 +242,7 @@ def parse_args():
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--addr",
|
"--addr",
|
||||||
type=str,
|
type=str,
|
||||||
default="https://uni-lab.bohrium.com/api/v1",
|
default="https://leap-lab.bohrium.com/api/v1",
|
||||||
help="Laboratory backend address",
|
help="Laboratory backend address",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -327,6 +336,27 @@ def parse_args():
|
|||||||
default="",
|
default="",
|
||||||
help="Workflow description, used when publishing the workflow",
|
help="Workflow description, used when publishing the workflow",
|
||||||
)
|
)
|
||||||
|
workflow_parser.add_argument(
|
||||||
|
"--target_device",
|
||||||
|
type=str,
|
||||||
|
default="prcxi",
|
||||||
|
help=(
|
||||||
|
"Target instrument name at vendor granularity (e.g. 'prcxi', 'beckman', 'tecan'). "
|
||||||
|
"Decides which target_devices.<name>.rules section in labware_mapping.yaml is used. "
|
||||||
|
"Unknown names fall back to target_devices.default. Default: 'prcxi'."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
workflow_parser.add_argument(
|
||||||
|
"--target_model",
|
||||||
|
type=str,
|
||||||
|
default=None,
|
||||||
|
help=(
|
||||||
|
"Optional target instrument model name within the same vendor (e.g. '9320', '4040'). "
|
||||||
|
"Used to look up target_devices.<target_device>.models.<target_model>.slot_remap / "
|
||||||
|
".rules for model-specific deck layout or rule overrides. Falls back to the vendor-level "
|
||||||
|
"configuration when omitted or the model is not declared. Default: None."
|
||||||
|
),
|
||||||
|
)
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
@@ -438,10 +468,10 @@ def main():
|
|||||||
if args.addr != parser.get_default("addr"):
|
if args.addr != parser.get_default("addr"):
|
||||||
if args.addr == "test":
|
if args.addr == "test":
|
||||||
print_status("使用测试环境地址", "info")
|
print_status("使用测试环境地址", "info")
|
||||||
HTTPConfig.remote_addr = "https://uni-lab.test.bohrium.com/api/v1"
|
HTTPConfig.remote_addr = "https://leap-lab.test.bohrium.com/api/v1"
|
||||||
elif args.addr == "uat":
|
elif args.addr == "uat":
|
||||||
print_status("使用uat环境地址", "info")
|
print_status("使用uat环境地址", "info")
|
||||||
HTTPConfig.remote_addr = "https://uni-lab.uat.bohrium.com/api/v1"
|
HTTPConfig.remote_addr = "https://leap-lab.uat.bohrium.com/api/v1"
|
||||||
elif args.addr == "local":
|
elif args.addr == "local":
|
||||||
print_status("使用本地环境地址", "info")
|
print_status("使用本地环境地址", "info")
|
||||||
HTTPConfig.remote_addr = "http://127.0.0.1:48197/api/v1"
|
HTTPConfig.remote_addr = "http://127.0.0.1:48197/api/v1"
|
||||||
@@ -553,7 +583,7 @@ def main():
|
|||||||
os._exit(0)
|
os._exit(0)
|
||||||
|
|
||||||
if not BasicConfig.ak or not BasicConfig.sk:
|
if not BasicConfig.ak or not BasicConfig.sk:
|
||||||
print_status("后续运行必须拥有一个实验室,请前往 https://uni-lab.bohrium.com 注册实验室!", "warning")
|
print_status("后续运行必须拥有一个实验室,请前往 https://leap-lab.bohrium.com 注册实验室!", "warning")
|
||||||
os._exit(1)
|
os._exit(1)
|
||||||
graph: nx.Graph
|
graph: nx.Graph
|
||||||
resource_tree_set: ResourceTreeSet
|
resource_tree_set: ResourceTreeSet
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ class HTTPClient:
|
|||||||
auth_secret = BasicConfig.auth_secret()
|
auth_secret = BasicConfig.auth_secret()
|
||||||
self.auth = auth_secret
|
self.auth = auth_secret
|
||||||
info(f"正在使用ak sk作为授权信息:[{auth_secret}]")
|
info(f"正在使用ak sk作为授权信息:[{auth_secret}]")
|
||||||
|
# 复用 TCP/TLS 连接,避免每次请求重新握手
|
||||||
|
self._session = requests.Session()
|
||||||
|
self._session.headers.update({"Authorization": f"Lab {self.auth}"})
|
||||||
info(f"HTTPClient 初始化完成: remote_addr={self.remote_addr}")
|
info(f"HTTPClient 初始化完成: remote_addr={self.remote_addr}")
|
||||||
|
|
||||||
def resource_edge_add(self, resources: List[Dict[str, Any]]) -> requests.Response:
|
def resource_edge_add(self, resources: List[Dict[str, Any]]) -> requests.Response:
|
||||||
@@ -48,7 +51,7 @@ class HTTPClient:
|
|||||||
Returns:
|
Returns:
|
||||||
Response: API响应对象
|
Response: API响应对象
|
||||||
"""
|
"""
|
||||||
response = requests.post(
|
response = self._session.post(
|
||||||
f"{self.remote_addr}/edge/material/edge",
|
f"{self.remote_addr}/edge/material/edge",
|
||||||
json={
|
json={
|
||||||
"edges": resources,
|
"edges": resources,
|
||||||
@@ -75,26 +78,28 @@ class HTTPClient:
|
|||||||
Returns:
|
Returns:
|
||||||
Dict[str, str]: 旧UUID到新UUID的映射关系 {old_uuid: new_uuid}
|
Dict[str, str]: 旧UUID到新UUID的映射关系 {old_uuid: new_uuid}
|
||||||
"""
|
"""
|
||||||
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_add.json"), "w", encoding="utf-8") as f:
|
# dump() 只调用一次,复用给文件保存和 HTTP 请求
|
||||||
payload = {"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid}
|
|
||||||
f.write(json.dumps(payload, indent=4))
|
|
||||||
# 从序列化数据中提取所有节点的UUID(保存旧UUID)
|
|
||||||
old_uuids = {n.res_content.uuid: n for n in resources.all_nodes}
|
|
||||||
nodes_info = [x for xs in resources.dump() for x in xs]
|
nodes_info = [x for xs in resources.dump() for x in xs]
|
||||||
|
old_uuids = {n.res_content.uuid: n for n in resources.all_nodes}
|
||||||
|
payload = {"nodes": nodes_info, "mount_uuid": mount_uuid}
|
||||||
|
body_bytes = _fast_dumps(payload)
|
||||||
|
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_add.json"), "wb") as f:
|
||||||
|
f.write(_fast_dumps_pretty(payload))
|
||||||
|
http_headers = {"Content-Type": "application/json"}
|
||||||
if not self.initialized or first_add:
|
if not self.initialized or first_add:
|
||||||
self.initialized = True
|
self.initialized = True
|
||||||
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
|
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
|
||||||
response = requests.post(
|
response = self._session.post(
|
||||||
f"{self.remote_addr}/edge/material",
|
f"{self.remote_addr}/edge/material",
|
||||||
json={"nodes": nodes_info, "mount_uuid": mount_uuid},
|
data=body_bytes,
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
headers=http_headers,
|
||||||
timeout=60,
|
timeout=60,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
response = requests.put(
|
response = self._session.put(
|
||||||
f"{self.remote_addr}/edge/material",
|
f"{self.remote_addr}/edge/material",
|
||||||
json={"nodes": nodes_info, "mount_uuid": mount_uuid},
|
data=body_bytes,
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
headers=http_headers,
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -133,7 +138,7 @@ class HTTPClient:
|
|||||||
"""
|
"""
|
||||||
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_get.json"), "w", encoding="utf-8") as f:
|
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_get.json"), "w", encoding="utf-8") as f:
|
||||||
f.write(json.dumps({"uuids": uuid_list, "with_children": with_children}, indent=4))
|
f.write(json.dumps({"uuids": uuid_list, "with_children": with_children}, indent=4))
|
||||||
response = requests.post(
|
response = self._session.post(
|
||||||
f"{self.remote_addr}/edge/material/query",
|
f"{self.remote_addr}/edge/material/query",
|
||||||
json={"uuids": uuid_list, "with_children": with_children},
|
json={"uuids": uuid_list, "with_children": with_children},
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
headers={"Authorization": f"Lab {self.auth}"},
|
||||||
@@ -147,6 +152,7 @@ class HTTPClient:
|
|||||||
logger.error(f"查询物料失败: {response.text}")
|
logger.error(f"查询物料失败: {response.text}")
|
||||||
else:
|
else:
|
||||||
data = res["data"]["nodes"]
|
data = res["data"]["nodes"]
|
||||||
|
logger.trace(f"resource_tree_get查询到物料: {data}")
|
||||||
return data
|
return data
|
||||||
else:
|
else:
|
||||||
logger.error(f"查询物料失败: {response.text}")
|
logger.error(f"查询物料失败: {response.text}")
|
||||||
@@ -164,14 +170,14 @@ class HTTPClient:
|
|||||||
if not self.initialized:
|
if not self.initialized:
|
||||||
self.initialized = True
|
self.initialized = True
|
||||||
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
|
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
|
||||||
response = requests.post(
|
response = self._session.post(
|
||||||
f"{self.remote_addr}/lab/material",
|
f"{self.remote_addr}/lab/material",
|
||||||
json={"nodes": resources},
|
json={"nodes": resources},
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
headers={"Authorization": f"Lab {self.auth}"},
|
||||||
timeout=100,
|
timeout=100,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
response = requests.put(
|
response = self._session.put(
|
||||||
f"{self.remote_addr}/lab/material",
|
f"{self.remote_addr}/lab/material",
|
||||||
json={"nodes": resources},
|
json={"nodes": resources},
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
headers={"Authorization": f"Lab {self.auth}"},
|
||||||
@@ -198,7 +204,7 @@ class HTTPClient:
|
|||||||
"""
|
"""
|
||||||
with open(os.path.join(BasicConfig.working_dir, "req_resource_get.json"), "w", encoding="utf-8") as f:
|
with open(os.path.join(BasicConfig.working_dir, "req_resource_get.json"), "w", encoding="utf-8") as f:
|
||||||
f.write(json.dumps({"id": id, "with_children": with_children}, indent=4))
|
f.write(json.dumps({"id": id, "with_children": with_children}, indent=4))
|
||||||
response = requests.get(
|
response = self._session.get(
|
||||||
f"{self.remote_addr}/lab/material",
|
f"{self.remote_addr}/lab/material",
|
||||||
params={"id": id, "with_children": with_children},
|
params={"id": id, "with_children": with_children},
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
headers={"Authorization": f"Lab {self.auth}"},
|
||||||
@@ -239,14 +245,14 @@ class HTTPClient:
|
|||||||
if not self.initialized:
|
if not self.initialized:
|
||||||
self.initialized = True
|
self.initialized = True
|
||||||
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
|
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
|
||||||
response = requests.post(
|
response = self._session.post(
|
||||||
f"{self.remote_addr}/lab/material",
|
f"{self.remote_addr}/lab/material",
|
||||||
json={"nodes": resources},
|
json={"nodes": resources},
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
headers={"Authorization": f"Lab {self.auth}"},
|
||||||
timeout=100,
|
timeout=100,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
response = requests.put(
|
response = self._session.put(
|
||||||
f"{self.remote_addr}/lab/material",
|
f"{self.remote_addr}/lab/material",
|
||||||
json={"nodes": resources},
|
json={"nodes": resources},
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
headers={"Authorization": f"Lab {self.auth}"},
|
||||||
@@ -276,7 +282,7 @@ class HTTPClient:
|
|||||||
with open(file_path, "rb") as file:
|
with open(file_path, "rb") as file:
|
||||||
files = {"files": file}
|
files = {"files": file}
|
||||||
logger.info(f"上传文件: {file_path} 到 {scene}")
|
logger.info(f"上传文件: {file_path} 到 {scene}")
|
||||||
response = requests.post(
|
response = self._session.post(
|
||||||
f"{self.remote_addr}/api/account/file_upload/{scene}",
|
f"{self.remote_addr}/api/account/file_upload/{scene}",
|
||||||
files=files,
|
files=files,
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
headers={"Authorization": f"Lab {self.auth}"},
|
||||||
@@ -316,7 +322,7 @@ class HTTPClient:
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Content-Encoding": "gzip",
|
"Content-Encoding": "gzip",
|
||||||
}
|
}
|
||||||
response = requests.post(
|
response = self._session.post(
|
||||||
f"{self.remote_addr}/lab/resource",
|
f"{self.remote_addr}/lab/resource",
|
||||||
data=compressed_body,
|
data=compressed_body,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
@@ -350,7 +356,7 @@ class HTTPClient:
|
|||||||
Returns:
|
Returns:
|
||||||
Response: API响应对象
|
Response: API响应对象
|
||||||
"""
|
"""
|
||||||
response = requests.get(
|
response = self._session.get(
|
||||||
f"{self.remote_addr}/edge/material/download",
|
f"{self.remote_addr}/edge/material/download",
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
headers={"Authorization": f"Lab {self.auth}"},
|
||||||
timeout=(3, 30),
|
timeout=(3, 30),
|
||||||
@@ -411,7 +417,7 @@ class HTTPClient:
|
|||||||
with open(os.path.join(BasicConfig.working_dir, "req_workflow_upload.json"), "w", encoding="utf-8") as f:
|
with open(os.path.join(BasicConfig.working_dir, "req_workflow_upload.json"), "w", encoding="utf-8") as f:
|
||||||
f.write(json.dumps(payload, indent=4, ensure_ascii=False))
|
f.write(json.dumps(payload, indent=4, ensure_ascii=False))
|
||||||
|
|
||||||
response = requests.post(
|
response = self._session.post(
|
||||||
f"{self.remote_addr}/lab/workflow/owner/import",
|
f"{self.remote_addr}/lab/workflow/owner/import",
|
||||||
json=payload,
|
json=payload,
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
headers={"Authorization": f"Lab {self.auth}"},
|
||||||
|
|||||||
@@ -1269,7 +1269,13 @@ class QueueProcessor:
|
|||||||
if not queued_jobs:
|
if not queued_jobs:
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.debug(f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs")
|
queue_summary = {}
|
||||||
|
for j in queued_jobs:
|
||||||
|
key = f"{j.device_id}/{j.action_name}"
|
||||||
|
queue_summary[key] = queue_summary.get(key, 0) + 1
|
||||||
|
logger.debug(
|
||||||
|
f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs: {queue_summary}"
|
||||||
|
)
|
||||||
|
|
||||||
for job_info in queued_jobs:
|
for job_info in queued_jobs:
|
||||||
# 快照可能已过期:在遍历过程中 end_job() 可能已将此 job 移至 READY,
|
# 快照可能已过期:在遍历过程中 end_job() 可能已将此 job 移至 READY,
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class WSConfig:
|
|||||||
|
|
||||||
# HTTP配置
|
# HTTP配置
|
||||||
class HTTPConfig:
|
class HTTPConfig:
|
||||||
remote_addr = "https://uni-lab.bohrium.com/api/v1"
|
remote_addr = "https://leap-lab.bohrium.com/api/v1"
|
||||||
|
|
||||||
|
|
||||||
# ROS配置
|
# ROS配置
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ from pylabrobot.resources import (
|
|||||||
)
|
)
|
||||||
from typing_extensions import TypedDict
|
from typing_extensions import TypedDict
|
||||||
|
|
||||||
|
from unilabos.devices.liquid_handling.liquid_history import (
|
||||||
|
LiquidHistoryEntry,
|
||||||
|
append_liquid_history as _append_liquid_history,
|
||||||
|
capture_tip_liquid_name as _capture_tip_liquid_name,
|
||||||
|
normalize_liquid_history as _normalize_liquid_history,
|
||||||
|
same_liquid_via_liquids as _same_liquid_via_liquids,
|
||||||
|
same_liquid_via_liquids_pair as _same_liquid_via_liquids_pair,
|
||||||
|
well_current_liquid_name as _well_current_liquid_name,
|
||||||
|
)
|
||||||
from unilabos.devices.liquid_handling.rviz_backend import UniLiquidHandlerRvizBackend
|
from unilabos.devices.liquid_handling.rviz_backend import UniLiquidHandlerRvizBackend
|
||||||
from unilabos.registry.placeholder_type import ResourceSlot
|
from unilabos.registry.placeholder_type import ResourceSlot
|
||||||
from unilabos.resources.resource_tracker import (
|
from unilabos.resources.resource_tracker import (
|
||||||
@@ -61,6 +70,7 @@ class TransferLiquidReturn(TypedDict):
|
|||||||
|
|
||||||
|
|
||||||
class LiquidHandlerMiddleware(LiquidHandler):
|
class LiquidHandlerMiddleware(LiquidHandler):
|
||||||
|
_ros_node: ROS2DeviceNode
|
||||||
def __init__(
|
def __init__(
|
||||||
self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool = False, channel_num: int = 8, **kwargs
|
self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool = False, channel_num: int = 8, **kwargs
|
||||||
):
|
):
|
||||||
@@ -79,6 +89,11 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
|||||||
self._simulate_handler = LiquidHandlerAbstract(self._simulate_backend, deck, False)
|
self._simulate_handler = LiquidHandlerAbstract(self._simulate_backend, deck, False)
|
||||||
super().__init__(backend, deck)
|
super().__init__(backend, deck)
|
||||||
|
|
||||||
|
def post_init(self, ros_node: BaseROS2DeviceNode):
|
||||||
|
self._ros_node = ros_node
|
||||||
|
if getattr(self, "_simulator", False) and getattr(self, "_simulate_handler", None) is not None:
|
||||||
|
self._simulate_handler._ros_node = ros_node
|
||||||
|
|
||||||
async def setup(self, **backend_kwargs):
|
async def setup(self, **backend_kwargs):
|
||||||
if self._simulator:
|
if self._simulator:
|
||||||
return await self._simulate_handler.setup(**backend_kwargs)
|
return await self._simulate_handler.setup(**backend_kwargs)
|
||||||
@@ -152,7 +167,20 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
|||||||
|
|
||||||
if self._simulator:
|
if self._simulator:
|
||||||
return await self._simulate_handler.pick_up_tips(tip_spots, use_channels, offsets, **backend_kwargs)
|
return await self._simulate_handler.pick_up_tips(tip_spots, use_channels, offsets, **backend_kwargs)
|
||||||
return await super().pick_up_tips(tip_spots, use_channels, offsets, **backend_kwargs)
|
# 让 PLR 走标准链路:tracker.remove_tip -> 成功 commit / 失败 rollback,
|
||||||
|
# 由此 TipSpot.has_tip() 自动反映为 False,符合 LiquidHandler 规范。
|
||||||
|
result = await super().pick_up_tips(tip_spots, use_channels, offsets, **backend_kwargs)
|
||||||
|
for tip_spot in tip_spots:
|
||||||
|
tip_spot.empty()
|
||||||
|
if hasattr(self, "_ros_node") and self._ros_node is not None:
|
||||||
|
task = ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{"resources": tip_spots})
|
||||||
|
submit_time = time.time()
|
||||||
|
while not task.done():
|
||||||
|
if time.time() - submit_time > 10:
|
||||||
|
self._ros_node.lab_logger().info(f"pick_up_tips {tip_spots} 超时")
|
||||||
|
break
|
||||||
|
time.sleep(0.01)
|
||||||
|
return result
|
||||||
|
|
||||||
async def drop_tips(
|
async def drop_tips(
|
||||||
self,
|
self,
|
||||||
@@ -215,6 +243,13 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
|||||||
if spread == "":
|
if spread == "":
|
||||||
spread = "custom"
|
spread = "custom"
|
||||||
|
|
||||||
|
# P9 — 在 super().aspirate 之前**预读**每个 source well 的液体名(用于 history 写入);
|
||||||
|
# super().aspirate 会消费 tracker.liquids,aspirate 后再读会拿不到液体身份。
|
||||||
|
# 详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md`` §6.2。
|
||||||
|
liquid_names_before_aspirate: List[str] = [
|
||||||
|
_well_current_liquid_name(res) for res in resources
|
||||||
|
]
|
||||||
|
|
||||||
for i, res in enumerate(resources):
|
for i, res in enumerate(resources):
|
||||||
tracker = getattr(res, "tracker", None)
|
tracker = getattr(res, "tracker", None)
|
||||||
if tracker is None or getattr(tracker, "is_disabled", False):
|
if tracker is None or getattr(tracker, "is_disabled", False):
|
||||||
@@ -244,9 +279,14 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
|||||||
try:
|
try:
|
||||||
tracker.add_liquid(max(need - used, 1.0))
|
tracker.add_liquid(max(need - used, 1.0))
|
||||||
except Exception:
|
except Exception:
|
||||||
history = getattr(tracker, "liquid_history", None)
|
# P9 — 旧版 v2 tuple ``("auto_init", vol)`` 写入升级为 v3 dict,
|
||||||
if isinstance(history, list):
|
# 与 ``_append_liquid_history`` 写入形态保持一致。
|
||||||
history.append(("auto_init", max(fill_vol, need, 1.0)))
|
_append_liquid_history(
|
||||||
|
res,
|
||||||
|
"auto_init",
|
||||||
|
float(max(fill_vol, need, 1.0)),
|
||||||
|
"auto_init",
|
||||||
|
)
|
||||||
|
|
||||||
if self._simulator:
|
if self._simulator:
|
||||||
try:
|
try:
|
||||||
@@ -352,7 +392,7 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
|||||||
else:
|
else:
|
||||||
channels_to_use = use_channels
|
channels_to_use = use_channels
|
||||||
|
|
||||||
for resource, volume, channel in zip(resources, vols, channels_to_use):
|
for i, (resource, volume, channel) in enumerate(zip(resources, vols, channels_to_use)):
|
||||||
sample_uuid_value = getattr(resource, "unilabos_extra", {}).get(EXTRA_SAMPLE_UUID, None)
|
sample_uuid_value = getattr(resource, "unilabos_extra", {}).get(EXTRA_SAMPLE_UUID, None)
|
||||||
res_samples.append({"name": resource.name, "sample_uuid": sample_uuid_value})
|
res_samples.append({"name": resource.name, "sample_uuid": sample_uuid_value})
|
||||||
res_volumes.append(volume)
|
res_volumes.append(volume)
|
||||||
@@ -360,6 +400,21 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
|||||||
EXTRA_SAMPLE_UUID: sample_uuid_value,
|
EXTRA_SAMPLE_UUID: sample_uuid_value,
|
||||||
"volume": volume,
|
"volume": volume,
|
||||||
}
|
}
|
||||||
|
# P9 — aspirate history 写入 source well:volume 取**负数**与 dispense/set 对称
|
||||||
|
# (sum(history.volume) ≈ 残量);name 取 aspirate 前预读的 liquid_name(操作后 tracker
|
||||||
|
# 被 PLR 消费,此时读会拿不到液体身份)。
|
||||||
|
name_before = liquid_names_before_aspirate[i] if i < len(liquid_names_before_aspirate) else ""
|
||||||
|
_append_liquid_history(resource, name_before, -float(volume or 0.0), "aspirate")
|
||||||
|
|
||||||
|
if hasattr(self, "_ros_node") and self._ros_node is not None:
|
||||||
|
task = ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{"resources": resources})
|
||||||
|
submit_time = time.time()
|
||||||
|
while not task.done():
|
||||||
|
if time.time() - submit_time > 10:
|
||||||
|
self._ros_node.lab_logger().info(f"aspirate {resources} 超时")
|
||||||
|
break
|
||||||
|
time.sleep(0.01)
|
||||||
|
|
||||||
return SimpleReturn(samples=res_samples, volumes=res_volumes)
|
return SimpleReturn(samples=res_samples, volumes=res_volumes)
|
||||||
|
|
||||||
async def dispense(
|
async def dispense(
|
||||||
@@ -494,6 +549,20 @@ class LiquidHandlerMiddleware(LiquidHandler):
|
|||||||
resource.unilabos_extra[EXTRA_SAMPLE_UUID] = res_uuid
|
resource.unilabos_extra[EXTRA_SAMPLE_UUID] = res_uuid
|
||||||
res_samples.append({"name": resource.name, EXTRA_SAMPLE_UUID: res_uuid})
|
res_samples.append({"name": resource.name, EXTRA_SAMPLE_UUID: res_uuid})
|
||||||
res_volumes.append(volume)
|
res_volumes.append(volume)
|
||||||
|
# P9 — dispense history 写入 target well:volume 取**正数**;
|
||||||
|
# name 从 target tracker.liquids 末项取(PLR dispense 后 target tracker 顶层就是
|
||||||
|
# 本次新加的液体),volume tracker bypass 路径下 name 可能为空字符串,符合预期。
|
||||||
|
target_liquid_name = _well_current_liquid_name(resource)
|
||||||
|
_append_liquid_history(resource, target_liquid_name, float(volume or 0.0), "dispense")
|
||||||
|
|
||||||
|
if hasattr(self, "_ros_node") and self._ros_node is not None:
|
||||||
|
task = ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{"resources": resources})
|
||||||
|
submit_time = time.time()
|
||||||
|
while not task.done():
|
||||||
|
if time.time() - submit_time > 10:
|
||||||
|
self._ros_node.lab_logger().info(f"dispense {resources} 超时")
|
||||||
|
break
|
||||||
|
time.sleep(0.01)
|
||||||
|
|
||||||
return SimpleReturn(samples=res_samples, volumes=res_volumes)
|
return SimpleReturn(samples=res_samples, volumes=res_volumes)
|
||||||
|
|
||||||
@@ -877,10 +946,18 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
backend_type = backend
|
backend_type = backend
|
||||||
self._simulator = simulator
|
self._simulator = simulator
|
||||||
self.group_info = dict()
|
self.group_info = dict()
|
||||||
|
# P10 v2 — Tip 复用判等开关;默认 on(pop 出 kwargs 避免污染父类签名)。
|
||||||
|
# 详见 ``product_designs/protocol_convert/10-tip-reuse-by-liquid-history.md`` §3.6。
|
||||||
|
self._tip_reuse_by_liquid_name: bool = bool(
|
||||||
|
kwargs.pop("tip_reuse_by_liquid_name", True)
|
||||||
|
)
|
||||||
super().__init__(backend_type, deck, simulator, channel_num, total_height=total_height, **kwargs)
|
super().__init__(backend_type, deck, simulator, channel_num, total_height=total_height, **kwargs)
|
||||||
|
|
||||||
def post_init(self, ros_node: BaseROS2DeviceNode):
|
def post_init(self, ros_node: BaseROS2DeviceNode):
|
||||||
self._ros_node = ros_node
|
super().post_init(ros_node)
|
||||||
|
ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
|
||||||
|
"resources": [self.deck]
|
||||||
|
})
|
||||||
|
|
||||||
async def _resolve_to_plr_resources(
|
async def _resolve_to_plr_resources(
|
||||||
self,
|
self,
|
||||||
@@ -934,7 +1011,9 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
plr_history = getattr(plr_tracker, "liquid_history", None)
|
plr_history = getattr(plr_tracker, "liquid_history", None)
|
||||||
if (isinstance(local_history, list) and len(local_history) == 0
|
if (isinstance(local_history, list) and len(local_history) == 0
|
||||||
and isinstance(plr_history, list) and len(plr_history) > 0):
|
and isinstance(plr_history, list) and len(plr_history) > 0):
|
||||||
local_tracker.liquid_history = list(plr_history)
|
# P9 — 远端 history 归一为 v3 dict(plr_history 可能仍是 v2 tuple)
|
||||||
|
normalized_history = _normalize_liquid_history(plr_history)
|
||||||
|
local_tracker.liquid_history = normalized_history
|
||||||
elif (isinstance(local_history, list) and len(local_history) > 0
|
elif (isinstance(local_history, list) and len(local_history) > 0
|
||||||
and isinstance(plr_history, list) and len(plr_history) == 0):
|
and isinstance(plr_history, list) and len(plr_history) == 0):
|
||||||
# 远端认为容器为空,重置本地 tracker 以保持同步
|
# 远端认为容器为空,重置本地 tracker 以保持同步
|
||||||
@@ -954,11 +1033,12 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
local_history = getattr(tracker, "liquid_history", None)
|
local_history = getattr(tracker, "liquid_history", None)
|
||||||
data = orig_dict.get("data") or {}
|
data = orig_dict.get("data") or {}
|
||||||
dict_history = data.get("liquid_history")
|
dict_history = data.get("liquid_history")
|
||||||
|
# P9 — 多形态升级:v3 dict / v2 tuple / list[str] 全归一为 v3 dict 列表。
|
||||||
|
# 详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md`` §6.4。
|
||||||
if isinstance(local_history, list) and len(local_history) == 0:
|
if isinstance(local_history, list) and len(local_history) == 0:
|
||||||
if isinstance(dict_history, list) and len(dict_history) > 0:
|
if isinstance(dict_history, list) and len(dict_history) > 0:
|
||||||
tracker.liquid_history = [
|
normalized_history = _normalize_liquid_history(dict_history)
|
||||||
(name, float(vol)) for name, vol in dict_history
|
tracker.liquid_history = normalized_history
|
||||||
]
|
|
||||||
elif isinstance(local_history, list) and len(local_history) > 0:
|
elif isinstance(local_history, list) and len(local_history) > 0:
|
||||||
if isinstance(dict_history, list) and len(dict_history) == 0:
|
if isinstance(dict_history, list) and len(dict_history) == 0:
|
||||||
# 调用方认为容器为空,重置本地 tracker
|
# 调用方认为容器为空,重置本地 tracker
|
||||||
@@ -990,63 +1070,324 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
for well, liquid_name, volume in zip(wells, liquid_names, volumes):
|
for well, liquid_name, volume in zip(wells, liquid_names, volumes):
|
||||||
safe_volume = _clamp_volume(well, volume)
|
safe_volume = _clamp_volume(well, volume)
|
||||||
well.set_liquids([(liquid_name, safe_volume)]) # type: ignore
|
well.set_liquids([(liquid_name, safe_volume)]) # type: ignore
|
||||||
|
# P9 — set_liquid 是 history 的"播种"入口(Stage 3 set_liquid_from_plate 节点会调到这里):
|
||||||
|
# 同时为 PLR tracker.liquids 写入 (name, vol) 和为扩展属性 tracker.liquid_history 写入
|
||||||
|
# 结构化 entry,保证后续 OS→Cloud sync 能完整保留液体身份。
|
||||||
|
_append_liquid_history(well, liquid_name, safe_volume, "set")
|
||||||
res_volumes.append(safe_volume)
|
res_volumes.append(safe_volume)
|
||||||
|
|
||||||
return SetLiquidReturn(
|
return SetLiquidReturn(
|
||||||
wells=ResourceTreeSet.from_plr_resources(wells, known_newly_created=False).dump(), volumes=res_volumes # type: ignore
|
wells=ResourceTreeSet.from_plr_resources(wells, known_newly_created=False).dump(), volumes=res_volumes # type: ignore
|
||||||
)
|
)
|
||||||
|
|
||||||
def set_liquid_from_plate(
|
def _resolve_wells_from_plate(
|
||||||
self, plate: ResourceSlot, well_names: list[str], liquid_names: list[str], volumes: list[float]
|
self,
|
||||||
) -> SetLiquidFromPlateReturn:
|
plate: Union[Plate, TubeRack, ResourceSlot],
|
||||||
"""Set the liquid in wells of a plate by well names (e.g., A1, A2, B3).
|
well_names: list[str],
|
||||||
|
) -> list[Well]:
|
||||||
如果 liquid_names 和 volumes 为空,但 plate 和 well_names 不为空,直接返回 plate 和 wells。
|
"""旧签名兼容路径:plate + well_names → 顺序 Well 列表。"""
|
||||||
"""
|
assert issubclass(plate.__class__, Plate) or issubclass(plate.__class__, TubeRack), (
|
||||||
assert issubclass(plate.__class__, Plate) or issubclass(plate.__class__, TubeRack) , f"plate must be a Plate, now: {type(plate)}"
|
f"plate must be a Plate or TubeRack, now: {type(plate)}"
|
||||||
plate: Union[Plate, TubeRack]
|
)
|
||||||
# 根据 well_names 获取对应的 Well 对象
|
|
||||||
if issubclass(plate.__class__, Plate):
|
if issubclass(plate.__class__, Plate):
|
||||||
wells = [plate.get_well(name) for name in well_names]
|
return [plate.get_well(name) for name in well_names] # type: ignore
|
||||||
elif issubclass(plate.__class__, TubeRack):
|
return [plate.get_tube(name) for name in well_names] # type: ignore
|
||||||
wells = [plate.get_tube(name) for name in well_names]
|
|
||||||
res_volumes = []
|
|
||||||
|
|
||||||
# 如果 liquid_names 和 volumes 都为空,直接返回
|
def _coerce_well(self, w: Union[Well, Dict[str, Any]]) -> Well:
|
||||||
|
"""dict → PLR Well:通过 self._ros_node.resource_tracker 同步解析;Well 原样返回。
|
||||||
|
|
||||||
|
约定 dict 至少含 ``uuid`` 或 ``unilabos_uuid`` 字段,与
|
||||||
|
``_resolve_to_plr_resources`` 的入参 schema 对齐。
|
||||||
|
"""
|
||||||
|
if isinstance(w, Well):
|
||||||
|
return w
|
||||||
|
if isinstance(w, dict):
|
||||||
|
uid = w.get("uuid") or w.get("unilabos_uuid")
|
||||||
|
if uid is None:
|
||||||
|
raise TypeError(
|
||||||
|
f"dict 格式的 well 必须包含 uuid 或 unilabos_uuid 字段: {w!r}"
|
||||||
|
)
|
||||||
|
if not hasattr(self, "_ros_node") or self._ros_node is None:
|
||||||
|
raise ValueError(
|
||||||
|
"传入 dict 格式的 wells 时,需通过 post_init 注入 _ros_node,"
|
||||||
|
"才能从物料系统按 uuid 解析为 PLR Well。"
|
||||||
|
)
|
||||||
|
matches = self._ros_node.resource_tracker.figure_resource(
|
||||||
|
{"uuid": uid}, try_mode=True
|
||||||
|
)
|
||||||
|
if not matches:
|
||||||
|
raise ValueError(
|
||||||
|
f"无法解析 well: uuid={uid!r} 未在 resource_tracker 中找到("
|
||||||
|
f"name={w.get('name')!r}, parent={w.get('parent')!r})"
|
||||||
|
)
|
||||||
|
return cast(Well, matches[0])
|
||||||
|
raise TypeError(f"无法解析 well: {w!r}")
|
||||||
|
|
||||||
|
def _set_liquid_grouped_by_plate(
|
||||||
|
self,
|
||||||
|
wells: list[Well],
|
||||||
|
liquid_names: list[str],
|
||||||
|
volumes: list[float],
|
||||||
|
) -> SetLiquidFromPlateReturn:
|
||||||
|
"""按 ``well.parent`` 分桶后多次 ``self.set_liquid``,最终按原顺序拼回 volumes。
|
||||||
|
|
||||||
|
作为 ``set_liquid_from_plate`` 的唯一执行路径(新旧两条入口都收敛到这里)。
|
||||||
|
"""
|
||||||
|
n = len(wells)
|
||||||
|
|
||||||
|
# 收集涉及的 plate 实例(按首次出现顺序),用于返回 plate 字段
|
||||||
|
plate_objs: List[Union[Plate, TubeRack]] = []
|
||||||
|
seen_plates: Set[str] = set()
|
||||||
|
for w in wells:
|
||||||
|
parent = getattr(w, "parent", None)
|
||||||
|
if parent is None:
|
||||||
|
continue
|
||||||
|
pname = getattr(parent, "name", None) or str(id(parent))
|
||||||
|
if pname in seen_plates:
|
||||||
|
continue
|
||||||
|
seen_plates.add(pname)
|
||||||
|
plate_objs.append(cast(Union[Plate, TubeRack], parent))
|
||||||
|
|
||||||
|
# 早返回:liquid_names / volumes 均为空 → 仅回显 wells / plates
|
||||||
if not liquid_names and not volumes:
|
if not liquid_names and not volumes:
|
||||||
return SetLiquidFromPlateReturn(
|
return SetLiquidFromPlateReturn(
|
||||||
plate=ResourceTreeSet.from_plr_resources([plate], known_newly_created=False).dump(), # type: ignore
|
plate=ResourceTreeSet.from_plr_resources(plate_objs, known_newly_created=False).dump() if plate_objs else [], # type: ignore
|
||||||
wells=ResourceTreeSet.from_plr_resources(wells, known_newly_created=False).dump(), # type: ignore
|
wells=ResourceTreeSet.from_plr_resources(wells, known_newly_created=False).dump() if wells else [], # type: ignore
|
||||||
volumes=res_volumes,
|
volumes=[],
|
||||||
)
|
)
|
||||||
|
|
||||||
def _clamp_volume(resource: Union[Well, Container], volume: float) -> float:
|
if len(liquid_names) != n or len(volumes) != n:
|
||||||
# 防止初始化液量超过容器容量,导致后续 dispense 时 free volume 为负
|
raise ValueError(
|
||||||
clamped = max(float(volume), 0.0)
|
f"set_liquid_from_plate: len(wells)={n}, len(liquid_names)={len(liquid_names)}, "
|
||||||
max_volume = getattr(resource, "max_volume", None)
|
f"len(volumes)={len(volumes)} 三者必须等长"
|
||||||
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):
|
# 按 parent 分桶;记录原始 index 以便结果回拼
|
||||||
safe_volume = _clamp_volume(well, volume)
|
buckets: Dict[str, List[int]] = {}
|
||||||
well.set_liquids([(liquid_name, safe_volume)]) # type: ignore
|
for idx, w in enumerate(wells):
|
||||||
res_volumes.append(safe_volume)
|
parent = getattr(w, "parent", None)
|
||||||
|
key = getattr(parent, "name", None) if parent is not None else None
|
||||||
|
key = key if key is not None else "_orphan"
|
||||||
|
buckets.setdefault(key, []).append(idx)
|
||||||
|
|
||||||
task = ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{"resources": wells})
|
res_volumes: List[float] = [0.0] * n
|
||||||
|
|
||||||
|
# 按 plate 顺序串行 set_liquid(避免设备物理碰撞 / 同板批量处理)
|
||||||
|
for plate_key, idxs in buckets.items():
|
||||||
|
sub_wells = [wells[i] for i in idxs]
|
||||||
|
sub_names = [liquid_names[i] for i in idxs]
|
||||||
|
sub_vols = [volumes[i] for i in idxs]
|
||||||
|
sub_ret = self.set_liquid(sub_wells, sub_names, sub_vols)
|
||||||
|
sub_ret_volumes = sub_ret.get("volumes", []) if isinstance(sub_ret, dict) else getattr(sub_ret, "volumes", [])
|
||||||
|
for local_idx, orig_idx in enumerate(idxs):
|
||||||
|
if local_idx < len(sub_ret_volumes):
|
||||||
|
res_volumes[orig_idx] = float(sub_ret_volumes[local_idx])
|
||||||
|
|
||||||
|
# 同步资源到 ROS(每板独立 wells 列表,但 update_resource 一次性提交更高效)
|
||||||
|
if hasattr(self, "_ros_node") and self._ros_node is not None:
|
||||||
|
task = ROS2DeviceNode.run_async_func(
|
||||||
|
self._ros_node.update_resource, True, **{"resources": wells}
|
||||||
|
)
|
||||||
submit_time = time.time()
|
submit_time = time.time()
|
||||||
while not task.done():
|
while not task.done():
|
||||||
if time.time() - submit_time > 10:
|
if time.time() - submit_time > 10:
|
||||||
self._ros_node.lab_logger().info(f"set_liquid_from_plate {plate} 超时")
|
self._ros_node.lab_logger().info(
|
||||||
|
f"set_liquid_from_plate (grouped) 超时, plates={list(buckets.keys())}"
|
||||||
|
)
|
||||||
break
|
break
|
||||||
time.sleep(0.01)
|
time.sleep(0.01)
|
||||||
|
|
||||||
return SetLiquidFromPlateReturn(
|
return SetLiquidFromPlateReturn(
|
||||||
plate=ResourceTreeSet.from_plr_resources([plate], known_newly_created=False).dump(), # type: ignore
|
plate=ResourceTreeSet.from_plr_resources(plate_objs, known_newly_created=False).dump() if plate_objs else [], # type: ignore
|
||||||
wells=ResourceTreeSet.from_plr_resources(wells, known_newly_created=False).dump(), # type: ignore
|
wells=ResourceTreeSet.from_plr_resources(wells, known_newly_created=False).dump(), # type: ignore
|
||||||
volumes=res_volumes,
|
volumes=res_volumes,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def set_liquid_from_plate(
|
||||||
|
self,
|
||||||
|
wells: Optional[Sequence[Union[Well, Dict[str, Any]]]] = None,
|
||||||
|
liquid_names: Optional[list[str]] = None,
|
||||||
|
volumes: Optional[list[float]] = None,
|
||||||
|
*,
|
||||||
|
plate: Optional[Union[Plate, TubeRack, ResourceSlot]] = None,
|
||||||
|
well_names: Optional[list[str]] = None,
|
||||||
|
) -> SetLiquidFromPlateReturn:
|
||||||
|
"""按孔批量设定液体(P3 框选化)。
|
||||||
|
|
||||||
|
优先路径(新签名,推荐):
|
||||||
|
|
||||||
|
set_liquid_from_plate(
|
||||||
|
wells=[well_obj_or_dict, ...],
|
||||||
|
liquid_names=["...", ...],
|
||||||
|
volumes=[v, ...],
|
||||||
|
)
|
||||||
|
|
||||||
|
``wells`` 中元素既可以是 PLR ``Well`` 实例,也可以是含 ``uuid`` 字段的 dict
|
||||||
|
(由 ``resource_tracker`` 同步解析);允许跨多 plate,内部按 ``well.parent``
|
||||||
|
分桶后多次调用 :meth:`set_liquid`。
|
||||||
|
|
||||||
|
兼容路径(旧签名,仅在 ``wells`` 为 ``None`` 时启用):
|
||||||
|
|
||||||
|
set_liquid_from_plate(plate=plate, well_names=["A1","A2",...],
|
||||||
|
liquid_names=[...], volumes=[...])
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
wells
|
||||||
|
待设液的 Well 列表(含 PLR 实例或 dict 引用),跨板允许。
|
||||||
|
liquid_names
|
||||||
|
与 ``wells`` 等长的液体名列表。
|
||||||
|
volumes
|
||||||
|
与 ``wells`` 等长的体积列表(µL);内部会按容器容量上限 clamp。
|
||||||
|
plate, well_names
|
||||||
|
旧调用约定,仅当 ``wells`` 未传时生效。
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# P3 框选化兼容修复:上游 ROS placeholder 在解析
|
||||||
|
# ``wells_identifier`` 边(create_resource.labware → 本节点)时,
|
||||||
|
# 可能直接把单个 PLR Plate 资源 dict 写入 ``wells``,而非
|
||||||
|
# ``list[Well]``。多入边时只保留最后一条(H7),导致 §14 跨板
|
||||||
|
# merged 节点失去除最后 plate 外的入边。
|
||||||
|
#
|
||||||
|
# 检测此 schema 错位并按以下策略恢复:
|
||||||
|
# - 若 liquid_names 全相同 → 单 plate 场景,wells 视为该 plate
|
||||||
|
# 走 plate + well_names 旧路径。
|
||||||
|
# - 若 liquid_names 含 distinct names(§14 merged 跨板场景)→
|
||||||
|
# 按 liquid_names 逐个反查 resource_tracker 得到各自 plate,
|
||||||
|
# 再用 well_names[i] 取 plate.get_well 构造跨板 wells 列表。
|
||||||
|
# ============================================================
|
||||||
|
if (
|
||||||
|
isinstance(wells, dict)
|
||||||
|
and "class" in wells
|
||||||
|
and well_names is not None
|
||||||
|
and (plate is None or (isinstance(plate, list) and len(plate) == 0))
|
||||||
|
):
|
||||||
|
# 判别单 plate vs 跨 plate:liquid_names 是否含 distinct names
|
||||||
|
_ln = list(liquid_names or [])
|
||||||
|
_wn = list(well_names or [])
|
||||||
|
distinct_liquids = set(_ln) if _ln else set()
|
||||||
|
is_cross_plate = (
|
||||||
|
len(distinct_liquids) > 1
|
||||||
|
and len(_ln) == len(_wn)
|
||||||
|
and len(_wn) > 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_cross_plate:
|
||||||
|
# 跨板 merged 场景:优先按 well_names 中的 "<plate_plr_name>/<well>" prefix
|
||||||
|
# 拆解逐个查 plate(common.py §14 fix 把 plate name 编码进 well_names)。
|
||||||
|
# 兜底:若 well_names 不含 "/",按 liquid_names 当 reagent_key 查(通常 miss)。
|
||||||
|
resolved_cross: list[Well] = []
|
||||||
|
cross_resolve_errors: list[str] = []
|
||||||
|
tracker = getattr(self, "_ros_node", None)
|
||||||
|
tracker = tracker.resource_tracker if tracker is not None else None
|
||||||
|
use_prefixed = all(isinstance(wn, str) and "/" in wn for wn in _wn)
|
||||||
|
|
||||||
|
for idx, (reagent_key, w_name) in enumerate(zip(_ln, _wn)):
|
||||||
|
try:
|
||||||
|
plate_instance = None
|
||||||
|
if use_prefixed and "/" in w_name:
|
||||||
|
# 主路径(§14 fix):well_names[i] = "<plate_plr_name>/<well>"
|
||||||
|
plate_plr_name, real_well_name = w_name.rsplit("/", 1)
|
||||||
|
if tracker is not None:
|
||||||
|
figured = tracker.figure_resource(
|
||||||
|
{"name": plate_plr_name}, try_mode=True
|
||||||
|
)
|
||||||
|
if figured:
|
||||||
|
plate_instance = figured[0]
|
||||||
|
actual_well_name = real_well_name
|
||||||
|
else:
|
||||||
|
# 兜底:legacy 形态(well_names 是纯 well 名)
|
||||||
|
actual_well_name = w_name
|
||||||
|
if tracker is not None:
|
||||||
|
figured = tracker.figure_resource(
|
||||||
|
{"name": reagent_key}, try_mode=True
|
||||||
|
)
|
||||||
|
if figured:
|
||||||
|
plate_instance = figured[0]
|
||||||
|
else:
|
||||||
|
figured = tracker.figure_resource(
|
||||||
|
{"id": reagent_key}, try_mode=True
|
||||||
|
)
|
||||||
|
if figured:
|
||||||
|
plate_instance = figured[0]
|
||||||
|
|
||||||
|
if plate_instance is None:
|
||||||
|
cross_resolve_errors.append(
|
||||||
|
f"idx={idx} reagent_key={reagent_key!r} w_name={w_name!r}: resource_tracker miss"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if not (
|
||||||
|
issubclass(plate_instance.__class__, Plate)
|
||||||
|
or issubclass(plate_instance.__class__, TubeRack)
|
||||||
|
):
|
||||||
|
cross_resolve_errors.append(
|
||||||
|
f"idx={idx} reagent_key={reagent_key!r}: not Plate/TubeRack (got {type(plate_instance).__name__})"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if issubclass(plate_instance.__class__, Plate):
|
||||||
|
resolved_cross.append(plate_instance.get_well(actual_well_name))
|
||||||
|
else:
|
||||||
|
resolved_cross.append(plate_instance.get_tube(actual_well_name))
|
||||||
|
except Exception as _e:
|
||||||
|
cross_resolve_errors.append(
|
||||||
|
f"idx={idx} reagent_key={reagent_key!r} well={w_name!r}: {type(_e).__name__}: {_e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(resolved_cross) == len(_wn):
|
||||||
|
return self._set_liquid_grouped_by_plate(
|
||||||
|
resolved_cross,
|
||||||
|
_ln,
|
||||||
|
list(volumes or []),
|
||||||
|
)
|
||||||
|
# 跨板 fallback 解析失败 → 抛清晰错误,避免静默落回单 plate 单 well 错误降级。
|
||||||
|
# 触发原因通常是 legacy 工作流图(common.py §14 fix 之前生成)的 well_names
|
||||||
|
# 缺少 "<plate_plr_name>/<well>" prefix,导致 abstract 层无法跨板定位 plate。
|
||||||
|
raise ValueError(
|
||||||
|
"set_liquid_from_plate: 检测到 P2 v2 跨板 merged 节点"
|
||||||
|
f"(liquid_names 含 {len(distinct_liquids)} 个 distinct names),"
|
||||||
|
"但 well_names 解析失败 / 缺少 '<plate_plr_name>/<well>' prefix。"
|
||||||
|
"这通常是 LEGACY 工作流图(在 §14 well_names prefix fix 之前生成)。"
|
||||||
|
"请用最新版 common.py 重新转换 + 重新上传协议到 Cloud Lab。"
|
||||||
|
f"\n current well_names sample: {_wn[:3]}"
|
||||||
|
f"\n current liquid_names sample: {_ln[:3]}"
|
||||||
|
f"\n cross_resolve errors first3: {cross_resolve_errors[:3]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 单 plate 兼容路径(或跨板解析失败 fallback)
|
||||||
|
plate_data = wells
|
||||||
|
wells = None # 清空,让下面走旧路径
|
||||||
|
try:
|
||||||
|
if hasattr(self, "_ros_node") and self._ros_node is not None:
|
||||||
|
figured = self._ros_node.resource_tracker.figure_resource(
|
||||||
|
{"name": plate_data.get("name")}, try_mode=True
|
||||||
|
)
|
||||||
|
if figured:
|
||||||
|
plate = figured[0]
|
||||||
|
if plate is None or (isinstance(plate, list) and len(plate) == 0):
|
||||||
|
from unilabos.resources.resource_tracker import ResourceTreeSet
|
||||||
|
fallback_tree = ResourceTreeSet.from_raw_dict_list([plate_data])
|
||||||
|
plr_list = fallback_tree.to_plr_resources() if len(fallback_tree.trees) > 0 else []
|
||||||
|
if plr_list:
|
||||||
|
plate = plr_list[0]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if wells is None:
|
||||||
|
if plate is None or well_names is None or (isinstance(plate, list) and len(plate) == 0):
|
||||||
|
raise ValueError(
|
||||||
|
"set_liquid_from_plate: 必须传 wells,或同时传 plate + well_names"
|
||||||
|
)
|
||||||
|
resolved_wells = self._resolve_wells_from_plate(plate, well_names)
|
||||||
|
else:
|
||||||
|
resolved_wells = [self._coerce_well(w) for w in wells]
|
||||||
|
|
||||||
|
return self._set_liquid_grouped_by_plate(
|
||||||
|
resolved_wells,
|
||||||
|
list(liquid_names or []),
|
||||||
|
list(volumes or []),
|
||||||
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# REMOVE LIQUID --------------------------------------------------
|
# REMOVE LIQUID --------------------------------------------------
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
@@ -1446,6 +1787,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
mix_rate: Optional[int] = None,
|
mix_rate: Optional[int] = None,
|
||||||
mix_liquid_height: Optional[float] = None,
|
mix_liquid_height: Optional[float] = None,
|
||||||
delays: Optional[List[int]] = None,
|
delays: Optional[List[int]] = None,
|
||||||
|
pre_aspirate_from_target: Optional[float] = None,
|
||||||
none_keys: List[str] = [],
|
none_keys: List[str] = [],
|
||||||
) -> TransferLiquidReturn:
|
) -> TransferLiquidReturn:
|
||||||
"""Transfer liquid with automatic mode detection.
|
"""Transfer liquid with automatic mode detection.
|
||||||
@@ -1536,11 +1878,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
# if len_dis_vols != num_sources and len_dis_vols != num_targets:
|
# if len_dis_vols != num_sources and len_dis_vols != num_targets:
|
||||||
# raise ValueError(f"dis_vols length must be equal to sources or targets length, but got {len_dis_vols} and {num_sources} and {num_targets}")
|
# raise ValueError(f"dis_vols length must be equal to sources or targets length, but got {len_dis_vols} and {num_sources} and {num_targets}")
|
||||||
|
|
||||||
if len(use_channels) != 8:
|
|
||||||
max_len = max(num_sources, num_targets, len_asp_vols, len_dis_vols)
|
|
||||||
prev_dropped = True # 循环开始前通道上无 tip
|
|
||||||
for i in range(max_len):
|
|
||||||
|
|
||||||
# 辅助函数:
|
# 辅助函数:
|
||||||
# - wrap=True: 返回 [value](用于 liquid_height 等列表参数)
|
# - wrap=True: 返回 [value](用于 liquid_height 等列表参数)
|
||||||
# - wrap=False: 返回 value(用于 mix_* 标量参数)
|
# - wrap=False: 返回 value(用于 mix_* 标量参数)
|
||||||
@@ -1558,6 +1895,16 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
except Exception:
|
except Exception:
|
||||||
return default
|
return default
|
||||||
|
|
||||||
|
# P10 v2 — 读取 tip 复用开关;测试 fixture 跳过 super().__init__ 时
|
||||||
|
# 用 getattr fallback 到 True,保证默认行为一致。
|
||||||
|
tip_reuse_by_liquid_name = bool(getattr(self, "_tip_reuse_by_liquid_name", True))
|
||||||
|
|
||||||
|
if len(use_channels) != 8:
|
||||||
|
max_len = max(num_sources, num_targets, len_asp_vols, len_dis_vols)
|
||||||
|
prev_dropped = True # 循环开始前通道上无 tip
|
||||||
|
current_tip_liquid_name: Optional[str] = None # P10 v2:tip 残液身份
|
||||||
|
for i in range(max_len):
|
||||||
|
|
||||||
# 动态构建参数字典,只传递实际提供的参数
|
# 动态构建参数字典,只传递实际提供的参数
|
||||||
kwargs = {
|
kwargs = {
|
||||||
'sources': [sources[i%num_sources]],
|
'sources': [sources[i%num_sources]],
|
||||||
@@ -1597,25 +1944,45 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
kwargs['mix_liquid_height'] = safe_get(mix_liquid_height, i, wrap=False)
|
kwargs['mix_liquid_height'] = safe_get(mix_liquid_height, i, wrap=False)
|
||||||
if delays is not None:
|
if delays is not None:
|
||||||
kwargs['delays'] = safe_get(delays, i)
|
kwargs['delays'] = safe_get(delays, i)
|
||||||
|
if pre_aspirate_from_target is not None:
|
||||||
|
kwargs['pre_aspirate_from_target'] = safe_get(pre_aspirate_from_target, i)
|
||||||
|
|
||||||
cur_source = sources[i % num_sources]
|
cur_source = sources[i % num_sources]
|
||||||
cur_target = targets[i % num_targets]
|
cur_target = targets[i % num_targets]
|
||||||
|
|
||||||
# drop: 仅当下一轮的 source 和 target 都相同时才保留 tip(下一轮可以复用)
|
# drop: identity-keep(同 PLR Well 对象)OR liquids-equivalence
|
||||||
|
# (cur/next source ``tracker.liquids[-1]`` 同名)→ 任一命中即保留 tip。
|
||||||
drop_tip = True
|
drop_tip = True
|
||||||
if i < max_len - 1:
|
if i < max_len - 1:
|
||||||
next_source = sources[(i + 1) % num_sources]
|
next_source = sources[(i + 1) % num_sources]
|
||||||
next_target = targets[(i + 1) % num_targets]
|
next_target = targets[(i + 1) % num_targets]
|
||||||
if cur_target is next_target and cur_source is next_source:
|
identity_keep = (cur_target is next_target) and (cur_source is next_source)
|
||||||
|
liquids_keep = (
|
||||||
|
tip_reuse_by_liquid_name
|
||||||
|
and _same_liquid_via_liquids_pair(cur_source, next_source)
|
||||||
|
)
|
||||||
|
if identity_keep or liquids_keep:
|
||||||
drop_tip = False
|
drop_tip = False
|
||||||
|
|
||||||
# pick_up: 仅当上一轮保留了 tip(未 drop)且 source 相同时才复用
|
# pick_up: identity-keep(同 PLR Well 对象)OR liquids-equivalence
|
||||||
|
# (cur source ``tracker.liquids[-1]`` 与 tip 残液同名)→ 任一命中即复用 tip。
|
||||||
pick_up_tip = True
|
pick_up_tip = True
|
||||||
if i > 0 and not prev_dropped:
|
if i > 0 and not prev_dropped:
|
||||||
prev_source = sources[(i - 1) % num_sources]
|
prev_source = sources[(i - 1) % num_sources]
|
||||||
if cur_source is prev_source:
|
identity_keep = (cur_source is prev_source)
|
||||||
|
liquids_keep = (
|
||||||
|
tip_reuse_by_liquid_name
|
||||||
|
and _same_liquid_via_liquids(cur_source, current_tip_liquid_name)
|
||||||
|
)
|
||||||
|
if identity_keep or liquids_keep:
|
||||||
pick_up_tip = False
|
pick_up_tip = False
|
||||||
|
|
||||||
|
# P10 v2 时序:tip 残液名必须在 aspirate **之前**预读
|
||||||
|
# (PLR aspirate 顶层归零时会 pop ``tracker.liquids`` 顶层)。
|
||||||
|
pending_tip_name: Optional[str] = None
|
||||||
|
if pick_up_tip:
|
||||||
|
pending_tip_name = _capture_tip_liquid_name(cur_source)
|
||||||
|
|
||||||
prev_dropped = drop_tip
|
prev_dropped = drop_tip
|
||||||
|
|
||||||
kwargs['pick_up'] = pick_up_tip
|
kwargs['pick_up'] = pick_up_tip
|
||||||
@@ -1623,6 +1990,155 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
|
|
||||||
await self._transfer_base_method(**kwargs)
|
await self._transfer_base_method(**kwargs)
|
||||||
|
|
||||||
|
if pick_up_tip:
|
||||||
|
current_tip_liquid_name = pending_tip_name
|
||||||
|
if drop_tip:
|
||||||
|
current_tip_liquid_name = None
|
||||||
|
else:
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# P1 v4 多通道分支:use_channels=[0..7],sources / targets /
|
||||||
|
# asp_vols / dis_vols 长度均为 8 × M(M 为列数 / 8 通道并发批次数)。
|
||||||
|
#
|
||||||
|
# 每段 8 个 wells + 8 个 vols 同时下发给 PLR,一次完成 8 通道
|
||||||
|
# 并发 aspirate/dispense。M 段串行执行,对应 M 次 tip pickup /
|
||||||
|
# 8 通道 transfer 周期。
|
||||||
|
#
|
||||||
|
# flow_rates / blow_out_air_volume / blow_out_air_volume_before /
|
||||||
|
# liquid_height / delays / pre_aspirate_from_target 若长度也是 8M
|
||||||
|
# 则按段同切;若长度恰好为 M(一段一值)则 broadcast 到 8;若长度
|
||||||
|
# 不足则按 idx % len 循环回退。
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
n_src_seg = max(1, num_sources // 8)
|
||||||
|
n_tgt_seg = max(1, num_targets // 8)
|
||||||
|
n_asp_seg = max(1, len_asp_vols // 8)
|
||||||
|
n_dis_seg = max(1, len_dis_vols // 8)
|
||||||
|
max_seg = max(n_src_seg, n_tgt_seg, n_asp_seg, n_dis_seg)
|
||||||
|
|
||||||
|
def _slice8(value, seg_idx):
|
||||||
|
"""从 ``value`` 抽出第 ``seg_idx`` 段(长度 8 的 list)。
|
||||||
|
|
||||||
|
- 若 ``value`` 长度是 8 的倍数 → 按段切 8 元素;
|
||||||
|
- 若 ``value`` 长度等于段数(M) → 取 ``value[seg_idx]`` 并 broadcast 到 8;
|
||||||
|
- 单 scalar / 长度 1 → broadcast 到 8;
|
||||||
|
- 其它 → 循环回退到 ``value[seg_idx % len(value)]`` 并 broadcast。
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return [float(value)] * 8
|
||||||
|
if not isinstance(value, (list, tuple)):
|
||||||
|
return None
|
||||||
|
if len(value) == 0:
|
||||||
|
return None
|
||||||
|
if len(value) % 8 == 0 and len(value) >= 8:
|
||||||
|
n = len(value) // 8
|
||||||
|
s = (seg_idx % n) * 8
|
||||||
|
return list(value[s : s + 8])
|
||||||
|
# 长度 == 段数 → 1 vol per segment,broadcast 到 8 通道
|
||||||
|
item = value[seg_idx % len(value)]
|
||||||
|
return [item] * 8
|
||||||
|
|
||||||
|
prev_dropped = True
|
||||||
|
current_tip_liquid_name: Optional[str] = None # P10 v2:tip 残液身份(段锚孔粒度)
|
||||||
|
for seg in range(max_seg):
|
||||||
|
src_slice = list(sources[(seg % n_src_seg) * 8 : (seg % n_src_seg + 1) * 8]) \
|
||||||
|
if num_sources >= 8 else [sources[seg % num_sources]] * 8
|
||||||
|
tgt_slice = list(targets[(seg % n_tgt_seg) * 8 : (seg % n_tgt_seg + 1) * 8]) \
|
||||||
|
if num_targets >= 8 else [targets[seg % num_targets]] * 8
|
||||||
|
asp_slice = asp_vols[(seg % n_asp_seg) * 8 : (seg % n_asp_seg + 1) * 8] \
|
||||||
|
if len_asp_vols >= 8 else [asp_vols[seg % len_asp_vols]] * 8
|
||||||
|
dis_slice = dis_vols[(seg % n_dis_seg) * 8 : (seg % n_dis_seg + 1) * 8] \
|
||||||
|
if len_dis_vols >= 8 else [dis_vols[seg % len_dis_vols]] * 8
|
||||||
|
|
||||||
|
kwargs = {
|
||||||
|
'sources': src_slice,
|
||||||
|
'targets': tgt_slice,
|
||||||
|
'tip_racks': tip_racks,
|
||||||
|
'use_channels': use_channels,
|
||||||
|
'asp_vols': asp_slice,
|
||||||
|
'dis_vols': dis_slice,
|
||||||
|
}
|
||||||
|
|
||||||
|
if asp_flow_rates is not None:
|
||||||
|
kwargs['asp_flow_rates'] = _slice8(asp_flow_rates, seg)
|
||||||
|
if dis_flow_rates is not None:
|
||||||
|
kwargs['dis_flow_rates'] = _slice8(dis_flow_rates, seg)
|
||||||
|
if offsets is not None:
|
||||||
|
kwargs['offsets'] = _slice8(offsets, seg)
|
||||||
|
if touch_tip is not None:
|
||||||
|
kwargs['touch_tip'] = bool(touch_tip)
|
||||||
|
if liquid_height is not None:
|
||||||
|
kwargs['liquid_height'] = _slice8(liquid_height, seg)
|
||||||
|
if blow_out_air_volume is not None:
|
||||||
|
kwargs['blow_out_air_volume'] = _slice8(blow_out_air_volume, seg)
|
||||||
|
if blow_out_air_volume_before is not None:
|
||||||
|
kwargs['blow_out_air_volume_before'] = _slice8(blow_out_air_volume_before, seg)
|
||||||
|
if spread is not None:
|
||||||
|
kwargs['spread'] = spread
|
||||||
|
# mix_* 仍按标量传递(PLR 多通道 mix 用 use_channels 自动并发 8 wells)
|
||||||
|
if mix_stage is not None:
|
||||||
|
kwargs['mix_stage'] = safe_get(mix_stage, seg, wrap=False)
|
||||||
|
if mix_times is not None:
|
||||||
|
kwargs['mix_times'] = safe_get(mix_times, seg, wrap=False)
|
||||||
|
if mix_vol is not None:
|
||||||
|
kwargs['mix_vol'] = safe_get(mix_vol, seg, wrap=False)
|
||||||
|
if mix_rate is not None:
|
||||||
|
kwargs['mix_rate'] = safe_get(mix_rate, seg, wrap=False)
|
||||||
|
if mix_liquid_height is not None:
|
||||||
|
kwargs['mix_liquid_height'] = safe_get(mix_liquid_height, seg, wrap=False)
|
||||||
|
if delays is not None:
|
||||||
|
kwargs['delays'] = _slice8(delays, seg)
|
||||||
|
if pre_aspirate_from_target is not None:
|
||||||
|
kwargs['pre_aspirate_from_target'] = _slice8(pre_aspirate_from_target, seg)
|
||||||
|
|
||||||
|
# 段间 tip 复用:identity-keep(同段锚孔 PLR Well 对象)OR
|
||||||
|
# liquids-equivalence(段锚孔 ``tracker.liquids[-1]`` 同名)→ 任一命中即保留 tip。
|
||||||
|
# 设计假设:8 通道段内 8 wells 同液(由 P1 multi-channel-flatten 保证)。
|
||||||
|
cur_src_anchor = src_slice[0]
|
||||||
|
cur_tgt_anchor = tgt_slice[0]
|
||||||
|
drop_tip = True
|
||||||
|
if seg < max_seg - 1:
|
||||||
|
next_src_anchor = sources[((seg + 1) % n_src_seg) * 8] \
|
||||||
|
if num_sources >= 8 else sources[(seg + 1) % num_sources]
|
||||||
|
next_tgt_anchor = targets[((seg + 1) % n_tgt_seg) * 8] \
|
||||||
|
if num_targets >= 8 else targets[(seg + 1) % num_targets]
|
||||||
|
identity_keep = (cur_tgt_anchor is next_tgt_anchor) and (cur_src_anchor is next_src_anchor)
|
||||||
|
liquids_keep = (
|
||||||
|
tip_reuse_by_liquid_name
|
||||||
|
and _same_liquid_via_liquids_pair(cur_src_anchor, next_src_anchor)
|
||||||
|
)
|
||||||
|
if identity_keep or liquids_keep:
|
||||||
|
drop_tip = False
|
||||||
|
|
||||||
|
pick_up_tip = True
|
||||||
|
if seg > 0 and not prev_dropped:
|
||||||
|
prev_src_anchor = sources[((seg - 1) % n_src_seg) * 8] \
|
||||||
|
if num_sources >= 8 else sources[(seg - 1) % num_sources]
|
||||||
|
identity_keep = (cur_src_anchor is prev_src_anchor)
|
||||||
|
liquids_keep = (
|
||||||
|
tip_reuse_by_liquid_name
|
||||||
|
and _same_liquid_via_liquids(cur_src_anchor, current_tip_liquid_name)
|
||||||
|
)
|
||||||
|
if identity_keep or liquids_keep:
|
||||||
|
pick_up_tip = False
|
||||||
|
|
||||||
|
# P10 v2 时序:tip 残液名必须在 aspirate **之前**预读
|
||||||
|
pending_tip_name: Optional[str] = None
|
||||||
|
if pick_up_tip:
|
||||||
|
pending_tip_name = _capture_tip_liquid_name(cur_src_anchor)
|
||||||
|
|
||||||
|
prev_dropped = drop_tip
|
||||||
|
|
||||||
|
kwargs['pick_up'] = pick_up_tip
|
||||||
|
kwargs['drop'] = drop_tip
|
||||||
|
|
||||||
|
await self._transfer_base_method(**kwargs)
|
||||||
|
|
||||||
|
if pick_up_tip:
|
||||||
|
current_tip_liquid_name = pending_tip_name
|
||||||
|
if drop_tip:
|
||||||
|
current_tip_liquid_name = None
|
||||||
|
|
||||||
|
|
||||||
return TransferLiquidReturn(
|
return TransferLiquidReturn(
|
||||||
sources=ResourceTreeSet.from_plr_resources(list(sources), known_newly_created=False).dump(), # type: ignore
|
sources=ResourceTreeSet.from_plr_resources(list(sources), known_newly_created=False).dump(), # type: ignore
|
||||||
@@ -1656,23 +2172,70 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
mix_rate = kwargs.get('mix_rate')
|
mix_rate = kwargs.get('mix_rate')
|
||||||
mix_liquid_height = kwargs.get('mix_liquid_height')
|
mix_liquid_height = kwargs.get('mix_liquid_height')
|
||||||
delays = kwargs.get('delays')
|
delays = kwargs.get('delays')
|
||||||
|
pre_aspirate_from_target = kwargs.get('pre_aspirate_from_target')
|
||||||
|
|
||||||
|
# P1 v4 多通道:当 use_channels 长度 > 1(如 8 通道)时,下层
|
||||||
|
# PLR aspirate/dispense 接受「N 个 resources + N 个 vols + N
|
||||||
|
# 个 use_channels」逐通道独立操作;单通道时仍按 `[sources[0]]`
|
||||||
|
# / `[asp_vols[0]]` 单元素列表调用。
|
||||||
|
multi_channel = isinstance(use_channels, (list, tuple)) and len(use_channels) > 1
|
||||||
|
n_ch = len(use_channels) if multi_channel else 1
|
||||||
|
|
||||||
|
def _pad_to_n(lst, n, default=None):
|
||||||
|
"""把 list 截/扩到长度 n;None / 空列表返回 None。"""
|
||||||
|
if lst is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(lst, (list, tuple)) or len(lst) == 0:
|
||||||
|
return None
|
||||||
|
if len(lst) >= n:
|
||||||
|
return list(lst[:n])
|
||||||
|
return list(lst) + [default if default is not None else lst[-1]] * (n - len(lst))
|
||||||
|
|
||||||
|
if multi_channel:
|
||||||
|
asp_resources = list(sources[:n_ch]) if len(sources) >= n_ch else list(sources)
|
||||||
|
dis_resources = list(targets[:n_ch]) if len(targets) >= n_ch else list(targets)
|
||||||
|
asp_vols_arg = list(asp_vols[:n_ch])
|
||||||
|
dis_vols_arg = list(dis_vols[:n_ch])
|
||||||
|
asp_flow_arg = _pad_to_n(asp_flow_rates, n_ch) if asp_flow_rates else None
|
||||||
|
dis_flow_arg = _pad_to_n(dis_flow_rates, n_ch) if dis_flow_rates else None
|
||||||
|
asp_liquid_h = _pad_to_n(liquid_height, n_ch) if liquid_height else None
|
||||||
|
dis_liquid_h = _pad_to_n(liquid_height, n_ch) if liquid_height else None
|
||||||
|
asp_offsets = _pad_to_n(offsets, n_ch) if offsets else None
|
||||||
|
dis_offsets = _pad_to_n(offsets, n_ch) if offsets else None
|
||||||
|
# mix 仍以 anchor well 调用,让 use_channels 在 PLR 内部并发列扩展
|
||||||
|
mix_src_anchor = [sources[0]]
|
||||||
|
mix_tgt_anchor = [targets[0]]
|
||||||
|
else:
|
||||||
|
asp_resources = [sources[0]]
|
||||||
|
dis_resources = [targets[0]]
|
||||||
|
asp_vols_arg = [asp_vols[0]]
|
||||||
|
dis_vols_arg = [dis_vols[0]]
|
||||||
|
asp_flow_arg = [asp_flow_rates[0]] if asp_flow_rates and len(asp_flow_rates) > 0 else None
|
||||||
|
dis_flow_arg = [dis_flow_rates[0]] if dis_flow_rates and len(dis_flow_rates) > 0 else None
|
||||||
|
asp_liquid_h = [liquid_height[0]] if liquid_height and len(liquid_height) > 0 else None
|
||||||
|
dis_liquid_h = [liquid_height[0]] if liquid_height and len(liquid_height) > 0 else None
|
||||||
|
asp_offsets = [offsets[0]] if offsets and len(offsets) > 0 else None
|
||||||
|
dis_offsets = [offsets[0]] if offsets and len(offsets) > 0 else None
|
||||||
|
mix_src_anchor = [sources[0]]
|
||||||
|
mix_tgt_anchor = [targets[0]]
|
||||||
|
|
||||||
tip = []
|
tip = []
|
||||||
if pick_up:
|
if pick_up:
|
||||||
tip.append(self._get_next_tip())
|
tip.append(self._get_next_tip())
|
||||||
await self.pick_up_tips(tip,use_channels=use_channels)
|
await self.pick_up_tips(tip,use_channels=use_channels)
|
||||||
blow_out_air_volume_before_vol = 0.0
|
# P1 v4:blow_before / blow_after 是每通道独立的,列表长度应为 n_ch。
|
||||||
if blow_out_air_volume_before is not None and len(blow_out_air_volume_before) > 0:
|
# 标量化处理(取 first 非零)用于决定是否触发 before-aspirate;下发到
|
||||||
blow_out_air_volume_before_vol = float(blow_out_air_volume_before[0] or 0.0)
|
# PLR 时仍按通道列表传递。
|
||||||
blow_out_air_volume_vol = 0.0
|
blow_before_list = _pad_to_n(blow_out_air_volume_before, n_ch) if blow_out_air_volume_before else None
|
||||||
if blow_out_air_volume is not None and len(blow_out_air_volume) > 0:
|
blow_after_list = _pad_to_n(blow_out_air_volume, n_ch) if blow_out_air_volume else None
|
||||||
blow_out_air_volume_vol = float(blow_out_air_volume[0] or 0.0)
|
blow_out_air_volume_before_vol = float(blow_before_list[0] or 0.0) if blow_before_list else 0.0
|
||||||
|
blow_out_air_volume_vol = float(blow_after_list[0] or 0.0) if blow_after_list else 0.0
|
||||||
# PLR 的 blow_out_air_volume 是空气参数,不计入液体体积。
|
# PLR 的 blow_out_air_volume 是空气参数,不计入液体体积。
|
||||||
# before 空气通过单独预吸实现,after 空气通过 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:
|
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
|
||||||
await self.mix(
|
await self.mix(
|
||||||
targets=[sources[0]],
|
targets=mix_src_anchor,
|
||||||
mix_time=mix_times,
|
mix_time=mix_times,
|
||||||
mix_vol=mix_vol,
|
mix_vol=mix_vol,
|
||||||
offsets=offsets if offsets else None,
|
offsets=offsets if offsets else None,
|
||||||
@@ -1683,18 +2246,20 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
|
|
||||||
if blow_out_air_volume_before_vol > 0:
|
if blow_out_air_volume_before_vol > 0:
|
||||||
source_tracker = getattr(sources[0], "tracker", None)
|
source_tracker = getattr(sources[0], "tracker", None)
|
||||||
source_tracker_was_disabled = bool(getattr(source_tracker, "is_disabled", False))
|
|
||||||
try:
|
try:
|
||||||
if source_tracker is not None and hasattr(source_tracker, "disable"):
|
if source_tracker is not None and hasattr(source_tracker, "disable"):
|
||||||
source_tracker.disable()
|
source_tracker.disable()
|
||||||
await self.aspirate(
|
await self.aspirate(
|
||||||
resources=[sources[0]],
|
resources=asp_resources,
|
||||||
vols=[0],
|
vols=[0] * len(asp_resources),
|
||||||
use_channels=use_channels,
|
use_channels=use_channels,
|
||||||
flow_rates=None,
|
flow_rates=None,
|
||||||
offsets=[Coordinate(x=0, y=0, z=sources[0].get_size_z())],
|
offsets=[Coordinate(x=0, y=0, z=sources[0].get_size_z())] * len(asp_resources),
|
||||||
liquid_height=None,
|
liquid_height=None,
|
||||||
blow_out_air_volume=[blow_out_air_volume_before_vol],
|
blow_out_air_volume=(
|
||||||
|
blow_before_list if multi_channel
|
||||||
|
else [blow_out_air_volume_before_vol]
|
||||||
|
),
|
||||||
spread="custom",
|
spread="custom",
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
@@ -1702,34 +2267,44 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
|
|||||||
source_tracker.enable()
|
source_tracker.enable()
|
||||||
|
|
||||||
await self.aspirate(
|
await self.aspirate(
|
||||||
resources=[sources[0]],
|
resources=asp_resources,
|
||||||
vols=[asp_vols[0]],
|
vols=asp_vols_arg,
|
||||||
use_channels=use_channels,
|
use_channels=use_channels,
|
||||||
flow_rates=[asp_flow_rates[0]] if asp_flow_rates and len(asp_flow_rates) > 0 else None,
|
flow_rates=asp_flow_arg,
|
||||||
offsets=[offsets[0]] if offsets and len(offsets) > 0 else None,
|
offsets=asp_offsets,
|
||||||
liquid_height=[liquid_height[0]] if liquid_height and len(liquid_height) > 0 else None,
|
liquid_height=asp_liquid_h,
|
||||||
blow_out_air_volume=(
|
blow_out_air_volume=(
|
||||||
[blow_out_air_volume_vol] if blow_out_air_volume_vol > 0 else None
|
blow_after_list if (multi_channel and blow_after_list and any((v or 0) > 0 for v in blow_after_list))
|
||||||
|
else ([blow_out_air_volume_vol] if blow_out_air_volume_vol > 0 else None)
|
||||||
),
|
),
|
||||||
spread=spread,
|
spread=spread,
|
||||||
)
|
)
|
||||||
if delays is not None:
|
if delays is not None and len(delays) > 0:
|
||||||
await self.custom_delay(seconds=delays[0])
|
await self.custom_delay(seconds=delays[0])
|
||||||
|
# 合并 before/after 空气体积逐通道;dispense 时一次性吐回。
|
||||||
|
if multi_channel:
|
||||||
|
blow_for_dispense = [
|
||||||
|
float(((blow_after_list[k] if blow_after_list else 0) or 0)
|
||||||
|
+ ((blow_before_list[k] if blow_before_list else 0) or 0))
|
||||||
|
for k in range(n_ch)
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
blow_for_dispense = [blow_out_air_volume_vol + blow_out_air_volume_before_vol]
|
||||||
await self.dispense(
|
await self.dispense(
|
||||||
resources=[targets[0]],
|
resources=dis_resources,
|
||||||
vols=[dis_vols[0]],
|
vols=dis_vols_arg,
|
||||||
use_channels=use_channels,
|
use_channels=use_channels,
|
||||||
flow_rates=[dis_flow_rates[0]] if dis_flow_rates and len(dis_flow_rates) > 0 else None,
|
flow_rates=dis_flow_arg,
|
||||||
offsets=[offsets[0]] if offsets and len(offsets) > 0 else None,
|
offsets=dis_offsets,
|
||||||
blow_out_air_volume=[blow_out_air_volume_vol+blow_out_air_volume_before_vol],
|
blow_out_air_volume=blow_for_dispense,
|
||||||
liquid_height=[liquid_height[0]] if liquid_height and len(liquid_height) > 0 else None,
|
liquid_height=dis_liquid_h,
|
||||||
spread=spread,
|
spread=spread,
|
||||||
)
|
)
|
||||||
if delays is not None and len(delays) > 1:
|
if delays is not None and len(delays) > 1:
|
||||||
await self.custom_delay(seconds=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:
|
if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0:
|
||||||
await self.mix(
|
await self.mix(
|
||||||
targets=[targets[0]],
|
targets=mix_tgt_anchor,
|
||||||
mix_time=mix_times,
|
mix_time=mix_times,
|
||||||
mix_vol=mix_vol,
|
mix_vol=mix_vol,
|
||||||
offsets=offsets if offsets else None,
|
offsets=offsets if offsets else None,
|
||||||
|
|||||||
221
unilabos/devices/liquid_handling/liquid_history.py
Normal file
221
unilabos/devices/liquid_handling/liquid_history.py
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
"""P9 — liquid_history schema v3 与 helper 函数。
|
||||||
|
|
||||||
|
独立模块,**不依赖 pylabrobot**,可在 PLR 环境缺失时单独单测。
|
||||||
|
|
||||||
|
模块由 ``liquid_handler_abstract.py`` 在 runtime 挂载点(set_liquid / aspirate /
|
||||||
|
dispense)调用,且由 ``resource_tracker._augment_states_with_liquid_history`` 在
|
||||||
|
serialize 链路使用。
|
||||||
|
|
||||||
|
详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md``。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, List, Tuple
|
||||||
|
|
||||||
|
from typing_extensions import TypedDict
|
||||||
|
|
||||||
|
|
||||||
|
# liquid_history 元素 schema v3
|
||||||
|
# 详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md`` §6.1。
|
||||||
|
# 旧格式(v2 ``(name, vol)`` 元组、list[str])由 ``normalize_liquid_history`` 升级。
|
||||||
|
class LiquidHistoryEntry(TypedDict, total=False):
|
||||||
|
name: str # 液体名(如 "Plasma";与 P8 reagent.liquid_name 联动;缺省 "")
|
||||||
|
volume: float # 操作体积(µL;aspirate 为负,dispense / set 为正)
|
||||||
|
action: str # "set" / "aspirate" / "dispense" / "legacy" / "auto_init"
|
||||||
|
timestamp: str # ISO8601 UTC(OS runtime 写入时填,前端写入时可省略)
|
||||||
|
|
||||||
|
|
||||||
|
# liquid_history 单 well 上限:超过则滚动丢弃头部
|
||||||
|
# 既限制内存(典型 8 通道 transfer 一次产生 ≤16 条),也防止极端 batch 拖慢前端渲染
|
||||||
|
LIQUID_HISTORY_MAX_ENTRIES = 1000
|
||||||
|
|
||||||
|
|
||||||
|
def well_current_liquid_name(well: Any) -> str:
|
||||||
|
"""从 ``well.tracker.liquids`` 末项读取当前液体名(PLR ``Liquid`` enum / str / None 兼容)。
|
||||||
|
|
||||||
|
P9:作为 ``aspirate`` 写入 history 时 ``name`` 字段的来源。
|
||||||
|
返回 ``""`` 表示未知(不写字面 "unknown",避免被前端误展示)。
|
||||||
|
"""
|
||||||
|
tracker = getattr(well, "tracker", None)
|
||||||
|
if tracker is None:
|
||||||
|
return ""
|
||||||
|
liquids = getattr(tracker, "liquids", None)
|
||||||
|
if not liquids:
|
||||||
|
# PLR 提供 get_liquids() 时优先用之(返回 list[(Liquid|None, vol)])
|
||||||
|
try:
|
||||||
|
liquids = tracker.get_liquids() # type: ignore[attr-defined]
|
||||||
|
except Exception:
|
||||||
|
liquids = None
|
||||||
|
if not liquids:
|
||||||
|
return ""
|
||||||
|
last = liquids[-1]
|
||||||
|
if isinstance(last, (list, tuple)) and last:
|
||||||
|
candidate = last[0]
|
||||||
|
else:
|
||||||
|
candidate = last
|
||||||
|
if candidate is None:
|
||||||
|
return ""
|
||||||
|
name = getattr(candidate, "name", None)
|
||||||
|
if isinstance(name, str) and name:
|
||||||
|
return name
|
||||||
|
if isinstance(candidate, str):
|
||||||
|
return candidate
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def append_liquid_history(
|
||||||
|
well: Any,
|
||||||
|
liquid_name: str,
|
||||||
|
volume: float,
|
||||||
|
action: str,
|
||||||
|
) -> None:
|
||||||
|
"""P9 — 统一写入 ``well.tracker.liquid_history``(PLR 扩展属性)。
|
||||||
|
|
||||||
|
设计要点:
|
||||||
|
- 元素为 v3 dict 形态 ``{name, volume, action, timestamp}``,与
|
||||||
|
:class:`LiquidHistoryEntry` schema 一致。
|
||||||
|
- ``aspirate`` 的 ``volume`` 应为**负数**(与 dispense/set 正数对称,
|
||||||
|
``sum(history.volume)`` ≈ 当前残量)。
|
||||||
|
- ``well`` 无 tracker 或 tracker 不可写时 graceful 静默(避免污染主流程)。
|
||||||
|
- 滚动上限 ``LIQUID_HISTORY_MAX_ENTRIES``:超出时丢弃**头部**(保留最近)。
|
||||||
|
|
||||||
|
详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md`` §6.2。
|
||||||
|
"""
|
||||||
|
tracker = getattr(well, "tracker", None)
|
||||||
|
if tracker is None:
|
||||||
|
return
|
||||||
|
history = getattr(tracker, "liquid_history", None)
|
||||||
|
if not isinstance(history, list):
|
||||||
|
history = []
|
||||||
|
try:
|
||||||
|
tracker.liquid_history = history # type: ignore[attr-defined]
|
||||||
|
except Exception:
|
||||||
|
return # tracker 拒绝写扩展属性(极少见);静默放弃
|
||||||
|
# 兼容修复:PLR VolumeTracker.current_liquids 依赖 tracker.liquid_history 为
|
||||||
|
# list[(name, vol)];若写入 dict 会在 `for name, vol in liquid_history` 时崩溃。
|
||||||
|
# 这里把历史就地归一为 tuple 形态,再 append tuple,避免 unpack ValueError。
|
||||||
|
normalized_pairs: List[Tuple[str, float]] = []
|
||||||
|
for item in history:
|
||||||
|
if isinstance(item, (list, tuple)) and len(item) >= 2:
|
||||||
|
name_val = str(item[0] or "")
|
||||||
|
try:
|
||||||
|
vol_val = float(item[1])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
vol_val = 0.0
|
||||||
|
normalized_pairs.append((name_val, vol_val))
|
||||||
|
elif isinstance(item, dict):
|
||||||
|
name_val = str(item.get("name", ""))
|
||||||
|
try:
|
||||||
|
vol_val = float(item.get("volume", 0.0) or 0.0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
vol_val = 0.0
|
||||||
|
normalized_pairs.append((name_val, vol_val))
|
||||||
|
elif isinstance(item, str):
|
||||||
|
normalized_pairs.append((item, 0.0))
|
||||||
|
history[:] = normalized_pairs
|
||||||
|
entry = (str(liquid_name or ""), float(volume))
|
||||||
|
history.append(entry)
|
||||||
|
overflow = len(history) - LIQUID_HISTORY_MAX_ENTRIES
|
||||||
|
if overflow > 0:
|
||||||
|
del history[:overflow]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# P10 v2 — Tip 复用 ``tracker.liquids`` 等价 helper(详见
|
||||||
|
# ``product_designs/protocol_convert/10-tip-reuse-by-liquid-history.md`` §3.2)
|
||||||
|
#
|
||||||
|
# 设计原则:
|
||||||
|
# - 信号源使用 PLR 原生 ``well.tracker.liquids`` 末项("well 此刻顶层液体"),
|
||||||
|
# 而非 P9 扩展属性 ``liquid_history``;P10 v2 因此不依赖 P9 是否落地。
|
||||||
|
# - 名称比较使用严格字符串相等;空 / "unknown" / "none" 一律保守视为未知 →
|
||||||
|
# 不触发 liquids 复用,落回 identity-only 现状(零回归)。
|
||||||
|
# - 与 P9 现有 ``liquid_names_before_aspirate`` 同模式:aspirate 之前预读
|
||||||
|
# source 当前液体名,避免 PLR 顶层归零时 pop ``liquids`` 拿不到身份。
|
||||||
|
# - 4 个 helper 共同居于本 PLR-free 模块,方便单元测试在不安装 pylabrobot
|
||||||
|
# 的环境下独立运行。
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def is_known_liquid_name(name: Any) -> bool:
|
||||||
|
"""空字符串 / "unknown" / "none" / None 一律视为未知,不触发 liquids 复用。"""
|
||||||
|
if not name:
|
||||||
|
return False
|
||||||
|
if not isinstance(name, str):
|
||||||
|
return False
|
||||||
|
return name.strip().lower() not in {"unknown", "none"}
|
||||||
|
|
||||||
|
|
||||||
|
def same_liquid_via_liquids(well: Any, tip_liquid_name: Any) -> bool:
|
||||||
|
"""tip 残留液体名 vs ``well.tracker.liquids`` 末项 name 严格相等。
|
||||||
|
|
||||||
|
用于 pick_up 决策:判断下一轮要 aspirate 的 well 当前液体是否与 tip 残液同名。
|
||||||
|
任一侧未知(空 / "unknown")→ 返回 ``False``(保守换 tip)。
|
||||||
|
"""
|
||||||
|
if not is_known_liquid_name(tip_liquid_name):
|
||||||
|
return False
|
||||||
|
well_name = well_current_liquid_name(well)
|
||||||
|
if not is_known_liquid_name(well_name):
|
||||||
|
return False
|
||||||
|
return well_name == tip_liquid_name
|
||||||
|
|
||||||
|
|
||||||
|
def same_liquid_via_liquids_pair(cur_well: Any, next_well: Any) -> bool:
|
||||||
|
"""两个 source well 当前 ``tracker.liquids`` 末项是否同名(用于决定 drop 时机)。
|
||||||
|
|
||||||
|
注:必须在 cur_well 的 aspirate **之前**调用;aspirate 不改
|
||||||
|
``liquids[-1].name`` 只改顶层 vol(或顶层归零时 pop),故 cur/next 的判等
|
||||||
|
以 "将要被抽的那一层" 为准。
|
||||||
|
"""
|
||||||
|
cur_name = well_current_liquid_name(cur_well)
|
||||||
|
next_name = well_current_liquid_name(next_well)
|
||||||
|
if not is_known_liquid_name(cur_name) or not is_known_liquid_name(next_name):
|
||||||
|
return False
|
||||||
|
return cur_name == next_name
|
||||||
|
|
||||||
|
|
||||||
|
def capture_tip_liquid_name(source_well: Any) -> "str | None":
|
||||||
|
"""**aspirate 之前** 把 source well 的当前液体名捕获下来,作为本轮 aspirate
|
||||||
|
完成后 tip 上残留液体的身份。
|
||||||
|
|
||||||
|
必须在 ``super().aspirate`` / ``_transfer_base_method`` 调用前读取:PLR
|
||||||
|
aspirate 会从顶层扣减体积,体积归零时 PLR ``VolumeTracker`` 会 pop 顶层
|
||||||
|
``(Liquid, vol)``,事后再读 ``liquids[-1]`` 可能拿到 prev layer 或空 list。
|
||||||
|
详见 ``liquid_handler_abstract.aspirate`` 中 ``liquid_names_before_aspirate``
|
||||||
|
同样的 "预读" 模式。
|
||||||
|
"""
|
||||||
|
name = well_current_liquid_name(source_well)
|
||||||
|
return name if is_known_liquid_name(name) else None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_liquid_history(raw: Any) -> List[Tuple[str, float]]:
|
||||||
|
"""P9 — 把任意旧形态的 liquid_history 升级为 v3 dict 列表。
|
||||||
|
|
||||||
|
兼容输入:
|
||||||
|
- v3 dict: ``[{name, volume, action, timestamp?}, ...]`` 原样返回(字段补全)
|
||||||
|
- v2 tuple: ``[(name, vol), ...]`` → ``action="legacy"``
|
||||||
|
- list[str]: ``["A", "B"]`` → ``volume=0, action="legacy"``
|
||||||
|
- 其它:丢弃该 entry
|
||||||
|
|
||||||
|
详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md`` §6.4。
|
||||||
|
"""
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
return []
|
||||||
|
result: List[Tuple[str, float]] = []
|
||||||
|
for entry in raw:
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
try:
|
||||||
|
vol_val = float(entry.get("volume", 0.0) or 0.0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
vol_val = 0.0
|
||||||
|
result.append((str(entry.get("name", "")), vol_val))
|
||||||
|
elif isinstance(entry, (list, tuple)) and len(entry) >= 2:
|
||||||
|
try:
|
||||||
|
vol_val = float(entry[1])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
vol_val = 0.0
|
||||||
|
result.append((str(entry[0] or ""), vol_val))
|
||||||
|
elif isinstance(entry, str):
|
||||||
|
result.append((entry, 0.0))
|
||||||
|
# 其它类型静默丢弃
|
||||||
|
return result
|
||||||
@@ -149,6 +149,40 @@ class PRCXI9300Deck(Deck):
|
|||||||
pos = self.sites[idx]["position"]
|
pos = self.sites[idx]["position"]
|
||||||
return Coordinate(pos["x"], pos["y"], pos["z"])
|
return Coordinate(pos["x"], pos["y"], pos["z"])
|
||||||
|
|
||||||
|
def get_slot_location(self, slot: Union[int, str]) -> Coordinate:
|
||||||
|
"""根据 slot 标识返回该 slot 的坐标。
|
||||||
|
|
||||||
|
支持的输入:
|
||||||
|
- int: 1-based slot 序号(与 ``assign_child_at_slot`` 一致),1 → sites[0]
|
||||||
|
- str: 纯数字字符串 ``"3"``,或带前缀的 label ``"T3"``(不区分大小写)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: slot 解析失败或越界
|
||||||
|
"""
|
||||||
|
idx: Optional[int] = None
|
||||||
|
if isinstance(slot, int):
|
||||||
|
idx = slot - 1
|
||||||
|
elif isinstance(slot, str):
|
||||||
|
s = slot.strip()
|
||||||
|
if not s:
|
||||||
|
raise ValueError(f"空 slot 标识")
|
||||||
|
digits = s[1:] if s[0].isalpha() else s
|
||||||
|
try:
|
||||||
|
idx = int(digits) - 1
|
||||||
|
except ValueError:
|
||||||
|
# 退而求其次:直接按 label 全等匹配
|
||||||
|
for i, site in enumerate(self.sites):
|
||||||
|
if site.get("label") == s:
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
if idx is None:
|
||||||
|
raise ValueError(f"无法解析 slot 标识: {slot!r}")
|
||||||
|
if idx < 0 or idx >= len(self.sites):
|
||||||
|
raise ValueError(
|
||||||
|
f"slot {slot!r} 超出范围 [1, {len(self.sites)}] (解析为 idx={idx})"
|
||||||
|
)
|
||||||
|
return self._get_site_location(idx)
|
||||||
|
|
||||||
def _get_site_resource(self, idx: int) -> Optional[Resource]:
|
def _get_site_resource(self, idx: int) -> Optional[Resource]:
|
||||||
site_loc = self._get_site_location(idx)
|
site_loc = self._get_site_location(idx)
|
||||||
for child in self.children:
|
for child in self.children:
|
||||||
@@ -444,7 +478,7 @@ class PRCXI9300Trash(Trash):
|
|||||||
|
|
||||||
if name != "trash":
|
if name != "trash":
|
||||||
print(f"Warning: PRCXI9300Trash usually expects name='trash' for backend logic, but got '{name}'.")
|
print(f"Warning: PRCXI9300Trash usually expects name='trash' for backend logic, but got '{name}'.")
|
||||||
super().__init__(name, size_x, size_y, size_z, **kwargs)
|
super().__init__(name, size_x, size_y, size_z, category=category, **kwargs)
|
||||||
self._unilabos_state = {}
|
self._unilabos_state = {}
|
||||||
# 初始化时注入 UUID
|
# 初始化时注入 UUID
|
||||||
if material_info:
|
if material_info:
|
||||||
@@ -533,12 +567,16 @@ class PRCXI9300TubeRack(TubeRack):
|
|||||||
|
|
||||||
# 根据情况传递不同的参数
|
# 根据情况传递不同的参数
|
||||||
if items_to_pass is not None:
|
if items_to_pass is not None:
|
||||||
super().__init__(name, size_x, size_y, size_z, ordered_items=items_to_pass, model=model, **kwargs)
|
super().__init__(
|
||||||
|
name, size_x, size_y, size_z, ordered_items=items_to_pass, category=category, model=model, **kwargs
|
||||||
|
)
|
||||||
elif ordering_param is not None:
|
elif ordering_param is not None:
|
||||||
# 传递 ordering 参数,让 TubeRack 自己创建 Tube 对象
|
# 传递 ordering 参数,让 TubeRack 自己创建 Tube 对象
|
||||||
super().__init__(name, size_x, size_y, size_z, ordering=ordering_param, model=model, **kwargs)
|
super().__init__(
|
||||||
|
name, size_x, size_y, size_z, ordering=ordering_param, category=category, model=model, **kwargs
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
super().__init__(name, size_x, size_y, size_z, model=model, **kwargs)
|
super().__init__(name, size_x, size_y, size_z, category=category, model=model, **kwargs)
|
||||||
|
|
||||||
self._unilabos_state = {}
|
self._unilabos_state = {}
|
||||||
if material_info:
|
if material_info:
|
||||||
@@ -716,6 +754,7 @@ class PRCXI9300PlateAdapter(PlateAdapter):
|
|||||||
adapter_hole_size_x=adapter_hole_size_x,
|
adapter_hole_size_x=adapter_hole_size_x,
|
||||||
adapter_hole_size_y=adapter_hole_size_y,
|
adapter_hole_size_y=adapter_hole_size_y,
|
||||||
adapter_hole_size_z=adapter_hole_size_z,
|
adapter_hole_size_z=adapter_hole_size_z,
|
||||||
|
category=category,
|
||||||
model=model,
|
model=model,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
@@ -803,6 +842,8 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
|||||||
self.xy_coupling = xy_coupling
|
self.xy_coupling = xy_coupling
|
||||||
self._slot_prcxi_positions: Dict[int, Tuple[float, float]] = {}
|
self._slot_prcxi_positions: Dict[int, Tuple[float, float]] = {}
|
||||||
self.calibration_labware_type = calibration_labware_type
|
self.calibration_labware_type = calibration_labware_type
|
||||||
|
self.max_z_pipetting = 185
|
||||||
|
self.max_z_claw = 170
|
||||||
|
|
||||||
if calibration_points is not None:
|
if calibration_points is not None:
|
||||||
self.calibrate_from_points(calibration_points, labware_type=self.calibration_labware_type)
|
self.calibrate_from_points(calibration_points, labware_type=self.calibration_labware_type)
|
||||||
@@ -839,12 +880,65 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
|||||||
)
|
)
|
||||||
super().__init__(backend=self._unilabos_backend, deck=deck, simulator=simulator, channel_num=channel_num)
|
super().__init__(backend=self._unilabos_backend, deck=deck, simulator=simulator, channel_num=channel_num)
|
||||||
self._first_transfer_done = False
|
self._first_transfer_done = False
|
||||||
|
# backend 在做槽位反查时若拿不到 deck,需要回退到 handler.deck,这里建立反向引用
|
||||||
|
self._unilabos_backend._handler = self
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_slot_number(resource) -> Optional[int]:
|
def _get_slot_number(resource) -> Optional[int]:
|
||||||
"""从 resource 的 unilabos_extra["update_resource_site"](如 "T13")或位置反算槽位号。"""
|
"""从 resource 的 unilabos_extra["update_resource_site"](如 "T13")或位置反算槽位号。"""
|
||||||
return _get_slot_number(resource)
|
return _get_slot_number(resource)
|
||||||
|
|
||||||
|
def _top_level_consumable(self, resource):
|
||||||
|
"""从任意 PLR 资源沿 parent 向上找"放在 deck 上的那一层耗材"。"""
|
||||||
|
if resource is None:
|
||||||
|
return None
|
||||||
|
cur = resource
|
||||||
|
while cur is not None:
|
||||||
|
parent = getattr(cur, "parent", None)
|
||||||
|
if isinstance(parent, PRCXI9300Deck):
|
||||||
|
return cur
|
||||||
|
if parent is None:
|
||||||
|
# 已到顶;若 cur 本身就是 deck,没有"耗材"层
|
||||||
|
if isinstance(cur, PRCXI9300Deck):
|
||||||
|
return None
|
||||||
|
return cur
|
||||||
|
cur = parent
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _attach_resources_to_deck_if_needed(self, items: Sequence[Resource]) -> None:
|
||||||
|
"""把通过 _resolve_to_plr_resources 拿回的"游离"耗材自动挂到 self.deck。
|
||||||
|
|
||||||
|
- 已经在 PRCXI9300Deck 上(含 name 同名)的跳过;
|
||||||
|
- 优先按 ``unilabos_extra.update_resource_site`` 的 Tn 解析槽位;
|
||||||
|
- 否则交给 ``Deck.assign_child_resource`` 找空槽。
|
||||||
|
- 任意失败仅打印告警,不中断主流程(backend 仍可走名字兜底)。
|
||||||
|
"""
|
||||||
|
deck = getattr(self, "deck", None)
|
||||||
|
if not isinstance(deck, PRCXI9300Deck):
|
||||||
|
return
|
||||||
|
existing_names = {getattr(c, "name", None) for c in deck.children}
|
||||||
|
for item in items:
|
||||||
|
top = self._top_level_consumable(item)
|
||||||
|
if top is None or not isinstance(top, Resource):
|
||||||
|
continue
|
||||||
|
if isinstance(getattr(top, "parent", None), PRCXI9300Deck):
|
||||||
|
continue
|
||||||
|
top_name = getattr(top, "name", None)
|
||||||
|
if top_name in existing_names:
|
||||||
|
continue
|
||||||
|
spot_idx: Optional[int] = None
|
||||||
|
extra = getattr(top, "unilabos_extra", {}) or {}
|
||||||
|
site = str(extra.get("update_resource_site", ""))
|
||||||
|
if site:
|
||||||
|
digits = "".join(c for c in site if c.isdigit())
|
||||||
|
if digits:
|
||||||
|
spot_idx = int(digits) - 1
|
||||||
|
try:
|
||||||
|
deck.assign_child_resource(top, spot=spot_idx, reassign=False)
|
||||||
|
existing_names.add(top_name)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[PRCXI] 自动挂载到 deck 失败: name={top_name}, site={site or '?'}, err={e}")
|
||||||
|
|
||||||
def _match_and_create_matrix(self):
|
def _match_and_create_matrix(self):
|
||||||
"""首次 transfer_liquid 时,根据 deck 上的 resource 自动匹配耗材并创建 WorkTabletMatrix。"""
|
"""首次 transfer_liquid 时,根据 deck 上的 resource 自动匹配耗材并创建 WorkTabletMatrix。"""
|
||||||
backend = self._unilabos_backend
|
backend = self._unilabos_backend
|
||||||
@@ -962,7 +1056,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
|||||||
slot_pos = self._slot_prcxi_positions[number]
|
slot_pos = self._slot_prcxi_positions[number]
|
||||||
pos.x = slot_pos[0] - child.get_size_x() / 2 + self.left_2_claw.x
|
pos.x = slot_pos[0] - child.get_size_x() / 2 + self.left_2_claw.x
|
||||||
pos.y = slot_pos[1] - child.get_size_y() / 2 + self.left_2_claw.y
|
pos.y = slot_pos[1] - child.get_size_y() / 2 + self.left_2_claw.y
|
||||||
claw_positions.append({"Number": number, "XPos": pos.x, "YPos": pos.y, "ZPos": pos.z})
|
claw_positions.append({"Number": number, "XPos": pos.x, "YPos": pos.y, "ZPos": max(min(pos.z, self.max_z_claw),0)})
|
||||||
|
|
||||||
if child.children:
|
if child.children:
|
||||||
pip_pos = self.plr_pos_to_prcxi(child.children[0])
|
pip_pos = self.plr_pos_to_prcxi(child.children[0])
|
||||||
@@ -978,13 +1072,13 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
|||||||
"Number": number,
|
"Number": number,
|
||||||
"XPos": pip_pos.x,
|
"XPos": pip_pos.x,
|
||||||
"YPos": pip_pos.y,
|
"YPos": pip_pos.y,
|
||||||
"ZPos": pip_pos.z,
|
"ZPos": max(min(pip_pos.z, self.max_z_pipetting),0),
|
||||||
"X_Left": half_x,
|
"X_Left": half_x,
|
||||||
"X_Right": half_x,
|
"X_Right": half_x,
|
||||||
"ZAgainstTheWall": pip_pos.z - z_wall,
|
"ZAgainstTheWall": pip_pos.z - z_wall,
|
||||||
"X2Pos": pip_pos.x + self.right_2_left.x,
|
"X2Pos": pip_pos.x + self.right_2_left.x,
|
||||||
"Y2Pos": pip_pos.y + self.right_2_left.y,
|
"Y2Pos": pip_pos.y + self.right_2_left.y,
|
||||||
"Z2Pos": pip_pos.z + self.right_2_left.z,
|
"Z2Pos": max(min((pip_pos.z + self.right_2_left.z), self.max_z_pipetting),0),
|
||||||
"X2_Left": half_x,
|
"X2_Left": half_x,
|
||||||
"X2_Right": half_x,
|
"X2_Right": half_x,
|
||||||
"ZAgainstTheWall2": pip_pos.z - z_wall,
|
"ZAgainstTheWall2": pip_pos.z - z_wall,
|
||||||
@@ -1097,9 +1191,21 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
|||||||
return super().set_liquid(wells, liquid_names, volumes)
|
return super().set_liquid(wells, liquid_names, volumes)
|
||||||
|
|
||||||
def set_liquid_from_plate(
|
def set_liquid_from_plate(
|
||||||
self, plate: ResourceSlot, well_names: list[str], liquid_names: list[str], volumes: list[float]
|
self,
|
||||||
|
wells: Optional[Sequence[Union[Well, Dict[str, Any]]]] = None,
|
||||||
|
liquid_names: Optional[list[str]] = None,
|
||||||
|
volumes: Optional[list[float]] = None,
|
||||||
|
*,
|
||||||
|
plate: Optional[ResourceSlot] = None,
|
||||||
|
well_names: Optional[list[str]] = None,
|
||||||
) -> SetLiquidFromPlateReturn:
|
) -> SetLiquidFromPlateReturn:
|
||||||
return super().set_liquid_from_plate(plate, well_names, liquid_names, volumes)
|
return super().set_liquid_from_plate(
|
||||||
|
wells=wells,
|
||||||
|
liquid_names=liquid_names,
|
||||||
|
volumes=volumes,
|
||||||
|
plate=plate,
|
||||||
|
well_names=well_names,
|
||||||
|
)
|
||||||
|
|
||||||
def set_group(self, group_name: str, wells: List[Well], volumes: List[float]):
|
def set_group(self, group_name: str, wells: List[Well], volumes: List[float]):
|
||||||
return super().set_group(group_name, wells, volumes)
|
return super().set_group(group_name, wells, volumes)
|
||||||
@@ -1196,6 +1302,27 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
|||||||
none_keys=none_keys,
|
none_keys=none_keys,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _tip_rack_is_10ul_range(rack: TipRack) -> bool:
|
||||||
|
"""判断 tip 盒是否为 10µL 量程(对应右头);优先用孔位上 prototype tip 的 maximal_volume。"""
|
||||||
|
children = getattr(rack, "children", None) or []
|
||||||
|
if children:
|
||||||
|
spot = children[0]
|
||||||
|
tr = getattr(spot, "tracker", None)
|
||||||
|
tip = None
|
||||||
|
if tr is not None:
|
||||||
|
tip = getattr(tr, "_tip", None) or getattr(tr, "tip", None)
|
||||||
|
if tip is None:
|
||||||
|
tip = getattr(spot, "tip", None)
|
||||||
|
mv = getattr(tip, "maximal_volume", None) if tip is not None else None
|
||||||
|
if mv is not None:
|
||||||
|
try:
|
||||||
|
return float(mv) <= 10.0
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
ident = f"{getattr(rack, 'model', '') or ''} {type(rack).__name__}".lower()
|
||||||
|
return "10ul" in ident
|
||||||
|
|
||||||
async def transfer_liquid(
|
async def transfer_liquid(
|
||||||
self,
|
self,
|
||||||
sources: Sequence[Container],
|
sources: Sequence[Container],
|
||||||
@@ -1220,6 +1347,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
|||||||
mix_rate: Optional[int] = None,
|
mix_rate: Optional[int] = None,
|
||||||
mix_liquid_height: Optional[float] = None,
|
mix_liquid_height: Optional[float] = None,
|
||||||
delays: Optional[List[int]] = None,
|
delays: Optional[List[int]] = None,
|
||||||
|
pre_aspirate_from_target: Optional[float] = None,
|
||||||
none_keys: List[str] = [],
|
none_keys: List[str] = [],
|
||||||
) -> TransferLiquidReturn:
|
) -> TransferLiquidReturn:
|
||||||
if not self._first_transfer_done:
|
if not self._first_transfer_done:
|
||||||
@@ -1230,21 +1358,44 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
|||||||
|
|
||||||
_asp_list = asp_vols if isinstance(asp_vols, list) else [asp_vols]
|
_asp_list = asp_vols if isinstance(asp_vols, list) else [asp_vols]
|
||||||
_dis_list = dis_vols if isinstance(dis_vols, list) else [dis_vols]
|
_dis_list = dis_vols if isinstance(dis_vols, list) else [dis_vols]
|
||||||
if all(v <= 10.0 for v in _asp_list) and all(v <= 10.0 for v in _dis_list):
|
|
||||||
use_channels = [1]
|
|
||||||
mix_vol = max(min(mix_vol,10),0) if mix_vol is not None else None
|
|
||||||
sources = await self._resolve_to_plr_resources(sources)
|
sources = await self._resolve_to_plr_resources(sources)
|
||||||
targets = await self._resolve_to_plr_resources(targets)
|
targets = await self._resolve_to_plr_resources(targets)
|
||||||
tip_racks = list(await self._resolve_to_plr_resources(tip_racks))
|
tip_racks = list(await self._resolve_to_plr_resources(tip_racks))
|
||||||
change_slots = []
|
# 远端解析回来的 PLR 实例可能未挂到 self.deck,主动绑定一次,避免 backend 取 plate.parent==None
|
||||||
change_slots.append(sources[0].parent)
|
self._attach_resources_to_deck_if_needed(list(sources) + list(targets) + list(tip_racks))
|
||||||
change_slots.append(targets[0].parent)
|
|
||||||
if isinstance(tip_racks[0], TipRack):
|
if isinstance(tip_racks[0], TipRack):
|
||||||
tip_rack = tip_racks[0]
|
tip_rack = tip_racks[0]
|
||||||
else:
|
else:
|
||||||
tip_rack = tip_racks[0].parent
|
tip_rack = tip_racks[0].parent
|
||||||
|
# 小体积单通道 head 切换:仅当 caller 没显式指定多通道时才生效。
|
||||||
|
# P1 v4 多通道协议(use_channels=[0..7])即便体积 ≤ 10uL 也应保留 8 通道,
|
||||||
|
# 避免把 dis_vols=[8.3]*8 这种「8 通道每孔 8.3uL」的展开退化为单通道串行。
|
||||||
|
small_vols = all(v <= 10.0 for v in _asp_list) and all(v <= 10.0 for v in _dis_list)
|
||||||
|
_explicit_multi = isinstance(use_channels, (list, tuple)) and len(use_channels) > 1
|
||||||
|
if small_vols and self._tip_rack_is_10ul_range(tip_rack) and not _explicit_multi:
|
||||||
|
use_channels = [1]
|
||||||
|
mix_vol = max(min(mix_vol, 10), 0) if mix_vol is not None else None
|
||||||
|
# P2 v2:跨板 transfer_liquid 场景下 sources / targets 列表里可能引用多个 plate
|
||||||
|
# (v1 旧实现只取 [0] 会漏掉 slot 3/5/6 的位置同步)。这里改为遍历所有 source/target
|
||||||
|
# 的 parent plate,按首次出现顺序去重——既保证跨板都能 update_pipetting_position,
|
||||||
|
# 又避免同板多孔重复发送。详见 02-cross-slot-merge.md §3.3.2 / §9.5 step 5。
|
||||||
|
change_slots = []
|
||||||
|
seen_plates = set()
|
||||||
|
|
||||||
change_slots.append(tip_rack)
|
def _push_unique_plate(plate_obj):
|
||||||
|
if plate_obj is None:
|
||||||
|
return
|
||||||
|
pname = getattr(plate_obj, "name", None) or id(plate_obj)
|
||||||
|
if pname in seen_plates:
|
||||||
|
return
|
||||||
|
seen_plates.add(pname)
|
||||||
|
change_slots.append(plate_obj)
|
||||||
|
|
||||||
|
for src in sources:
|
||||||
|
_push_unique_plate(getattr(src, "parent", None))
|
||||||
|
for tgt in targets:
|
||||||
|
_push_unique_plate(getattr(tgt, "parent", None))
|
||||||
|
_push_unique_plate(tip_rack)
|
||||||
|
|
||||||
self.tip_height = tip_rack.children[0].get_size_z()
|
self.tip_height = tip_rack.children[0].get_size_z()
|
||||||
|
|
||||||
@@ -1298,6 +1449,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
|||||||
mix_rate=mix_rate,
|
mix_rate=mix_rate,
|
||||||
mix_liquid_height=mix_liquid_height,
|
mix_liquid_height=mix_liquid_height,
|
||||||
delays=delays,
|
delays=delays,
|
||||||
|
pre_aspirate_from_target=pre_aspirate_from_target,
|
||||||
none_keys=none_keys,
|
none_keys=none_keys,
|
||||||
)
|
)
|
||||||
if self.step_mode:
|
if self.step_mode:
|
||||||
@@ -1453,6 +1605,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
|||||||
_num_channels = 8 # 默认通道数为 8
|
_num_channels = 8 # 默认通道数为 8
|
||||||
_is_reset_ok = False
|
_is_reset_ok = False
|
||||||
_ros_node: BaseROS2DeviceNode
|
_ros_node: BaseROS2DeviceNode
|
||||||
|
_handler: Optional["PRCXI9300Handler"] = None # 由 PRCXI9300Handler.__init__ 注入
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_reset_ok(self) -> bool:
|
def is_reset_ok(self) -> bool:
|
||||||
@@ -1486,13 +1639,52 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
|||||||
self.debug = debug
|
self.debug = debug
|
||||||
self.axis = "Left"
|
self.axis = "Left"
|
||||||
|
|
||||||
@staticmethod
|
def _resolve_deck(self, plate, deck=None) -> Optional["PRCXI9300Deck"]:
|
||||||
def _deck_plate_slot_no(plate, deck) -> int:
|
"""定位 plate 所属的 PRCXI9300Deck:按 deck 入参 → plate 的祖先链 → handler.deck 顺序回退。"""
|
||||||
"""台面板位槽号(1–16):与 PRCXI9300Handler._get_slot_number 一致;无法解析时退回 deck 子项顺序 +1。"""
|
if isinstance(deck, PRCXI9300Deck):
|
||||||
|
return deck
|
||||||
|
cur = plate
|
||||||
|
while cur is not None:
|
||||||
|
if isinstance(cur, PRCXI9300Deck):
|
||||||
|
return cur
|
||||||
|
cur = getattr(cur, "parent", None)
|
||||||
|
if self._handler is not None:
|
||||||
|
handler_deck = getattr(self._handler, "deck", None)
|
||||||
|
if isinstance(handler_deck, PRCXI9300Deck):
|
||||||
|
return handler_deck
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _deck_plate_slot_no(self, plate, deck=None) -> int:
|
||||||
|
"""台面板位槽号(1–16):优先 _get_slot_number;否则沿父链/handler.deck 找到 deck 后取序号+1。"""
|
||||||
sn = PRCXI9300Handler._get_slot_number(plate)
|
sn = PRCXI9300Handler._get_slot_number(plate)
|
||||||
if sn is not None:
|
if sn is not None:
|
||||||
return sn
|
return sn
|
||||||
return deck.children.index(plate) + 1
|
actual_deck = self._resolve_deck(plate, deck)
|
||||||
|
if actual_deck is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"无法定位 {getattr(plate, 'name', '?')} 所在的 PRCXI9300Deck:"
|
||||||
|
"请确认 tip_rack/plate 已挂到 self.deck,或在 unilabos_extra 中提供 update_resource_site=Tn。"
|
||||||
|
)
|
||||||
|
if plate in actual_deck.children:
|
||||||
|
index = actual_deck.children.index(plate)
|
||||||
|
plate_new = actual_deck.children[index]
|
||||||
|
sn = PRCXI9300Handler._get_slot_number(plate_new)
|
||||||
|
if sn is not None:
|
||||||
|
return sn
|
||||||
|
else:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"无法定位 {getattr(plate_new, 'name', '?')} 所在的 PRCXI9300Deck:"
|
||||||
|
f"x: {plate_new.location}"
|
||||||
|
)
|
||||||
|
# 名字兜底(远端解析回来的实例与 deck 上的不是同一对象)
|
||||||
|
plate_name = getattr(plate, "name", None)
|
||||||
|
if plate_name is not None:
|
||||||
|
for i, c in enumerate(actual_deck.children):
|
||||||
|
if getattr(c, "name", None) == plate_name:
|
||||||
|
return i + 1
|
||||||
|
raise RuntimeError(
|
||||||
|
f"{getattr(plate, 'name', '?')} 不在 deck.children 中且无可解析的槽位号"
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _resource_num_items_y(resource) -> int:
|
def _resource_num_items_y(resource) -> int:
|
||||||
@@ -1893,7 +2085,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
|||||||
|
|
||||||
step = self.api_client.Imbibing(
|
step = self.api_client.Imbibing(
|
||||||
axis=axis,
|
axis=axis,
|
||||||
dosage=int(volumes[0]),
|
dosage=float(volumes[0]),
|
||||||
plate_no=PlateNo,
|
plate_no=PlateNo,
|
||||||
is_whole_plate=False,
|
is_whole_plate=False,
|
||||||
hole_row=hole_row,
|
hole_row=hole_row,
|
||||||
@@ -1955,7 +2147,9 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
|||||||
|
|
||||||
assist_fun1 = ""
|
assist_fun1 = ""
|
||||||
if ops[0].blow_out_air_volume is not None:
|
if ops[0].blow_out_air_volume is not None:
|
||||||
assist_fun1 = f"吹样({float(min(max(ops[0].blow_out_air_volume,0),10))}ul)"
|
assist_fun1 = f"吹样({float(min(max(ops[0].blow_out_air_volume,5),10))}ul)"
|
||||||
|
else :
|
||||||
|
assist_fun1 = f"吹样({5.0}ul)"
|
||||||
|
|
||||||
step = self.api_client.Tapping(
|
step = self.api_client.Tapping(
|
||||||
axis=axis,
|
axis=axis,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import json
|
|||||||
import time
|
import time
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Optional, Sequence
|
||||||
|
|
||||||
from moveit_msgs.msg import JointConstraint, Constraints
|
from moveit_msgs.msg import JointConstraint, Constraints
|
||||||
from rclpy.action import ActionClient
|
from rclpy.action import ActionClient
|
||||||
@@ -171,65 +172,61 @@ class MoveitInterface:
|
|||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def pick_and_place(self, command: str):
|
def pick_and_place(
|
||||||
|
self,
|
||||||
|
option: str,
|
||||||
|
move_group: str,
|
||||||
|
status: str,
|
||||||
|
resource: Optional[str] = None,
|
||||||
|
x_distance: Optional[float] = None,
|
||||||
|
y_distance: Optional[float] = None,
|
||||||
|
lift_height: Optional[float] = None,
|
||||||
|
retry: Optional[int] = None,
|
||||||
|
speed: Optional[float] = None,
|
||||||
|
target: Optional[str] = None,
|
||||||
|
constraints: Optional[Sequence[float]] = None,
|
||||||
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Using MoveIt to make the robotic arm pick or place materials to a target point.
|
使用 MoveIt 完成抓取/放置等序列(pick/place/side_pick/side_place)。
|
||||||
|
|
||||||
Args:
|
必选:option, move_group, status。
|
||||||
command: A JSON-formatted string that includes option, target, speed, lift_height, mt_height
|
可选:resource, x_distance, y_distance, lift_height, retry, speed, target, constraints。
|
||||||
|
无返回值;失败时提前 return 或打印异常。
|
||||||
*option (string) : Action type: pick/place/side_pick/side_place
|
|
||||||
*move_group (string): The move group moveit will plan
|
|
||||||
*status(string) : Target pose
|
|
||||||
resource(string) : The target resource
|
|
||||||
x_distance (float) : The distance to the target in x direction(meters)
|
|
||||||
y_distance (float) : The distance to the target in y direction(meters)
|
|
||||||
lift_height (float) : The height at which the material should be lifted(meters)
|
|
||||||
retry (float) : Retry times when moveit plan fails
|
|
||||||
speed (float) : The speed of the movement, speed > 0
|
|
||||||
Returns:
|
|
||||||
None
|
|
||||||
"""
|
"""
|
||||||
result = SendCmd.Result()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cmd_str = str(command).replace("'", '"')
|
if option not in self.move_option:
|
||||||
cmd_dict = json.loads(cmd_str)
|
raise ValueError(f"Invalid option: {option}")
|
||||||
|
|
||||||
if cmd_dict["option"] in self.move_option:
|
option_index = self.move_option.index(option)
|
||||||
option_index = self.move_option.index(cmd_dict["option"])
|
|
||||||
place_flag = option_index % 2
|
place_flag = option_index % 2
|
||||||
|
|
||||||
config = {}
|
config: dict = {"move_group": move_group}
|
||||||
|
if speed is not None:
|
||||||
|
config["speed"] = speed
|
||||||
|
if retry is not None:
|
||||||
|
config["retry"] = retry
|
||||||
|
|
||||||
function_list = []
|
function_list = []
|
||||||
|
joint_positions_ = self.joint_poses[move_group][status]
|
||||||
|
|
||||||
status = cmd_dict["status"]
|
# 夹取 / 放置:绑定 resource 与 parent
|
||||||
joint_positions_ = self.joint_poses[cmd_dict["move_group"]][status]
|
|
||||||
|
|
||||||
config.update({k: cmd_dict[k] for k in ["speed", "retry", "move_group"] if k in cmd_dict})
|
|
||||||
|
|
||||||
# 夹取
|
|
||||||
if not place_flag:
|
if not place_flag:
|
||||||
if "target" in cmd_dict.keys():
|
if target is not None:
|
||||||
function_list.append(lambda: self.resource_manager(cmd_dict["resource"], cmd_dict["target"]))
|
function_list.append(lambda r=resource, t=target: self.resource_manager(r, t))
|
||||||
else:
|
else:
|
||||||
function_list.append(
|
ee = self.moveit2[move_group].end_effector_name
|
||||||
lambda: self.resource_manager(
|
function_list.append(lambda r=resource: self.resource_manager(r, ee))
|
||||||
cmd_dict["resource"], self.moveit2[cmd_dict["move_group"]].end_effector_name
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
function_list.append(lambda: self.resource_manager(cmd_dict["resource"], "world"))
|
function_list.append(lambda r=resource: self.resource_manager(r, "world"))
|
||||||
|
|
||||||
constraints = []
|
joint_constraint_msgs: list = []
|
||||||
if "constraints" in cmd_dict.keys():
|
if constraints is not None:
|
||||||
|
for i, c in enumerate(constraints):
|
||||||
for i in range(len(cmd_dict["constraints"])):
|
v = float(c)
|
||||||
v = float(cmd_dict["constraints"][i])
|
|
||||||
if v > 0:
|
if v > 0:
|
||||||
constraints.append(
|
joint_constraint_msgs.append(
|
||||||
JointConstraint(
|
JointConstraint(
|
||||||
joint_name=self.moveit2[cmd_dict["move_group"]].joint_names[i],
|
joint_name=self.moveit2[move_group].joint_names[i],
|
||||||
position=joint_positions_[i],
|
position=joint_positions_[i],
|
||||||
tolerance_above=v,
|
tolerance_above=v,
|
||||||
tolerance_below=v,
|
tolerance_below=v,
|
||||||
@@ -237,16 +234,15 @@ class MoveitInterface:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if "lift_height" in cmd_dict.keys():
|
if lift_height is not None:
|
||||||
retval = None
|
retval = None
|
||||||
retry = config.get("retry", 10)
|
attempts = config.get("retry", 10)
|
||||||
while retval is None and retry > 0:
|
while retval is None and attempts > 0:
|
||||||
retval = self.moveit2[cmd_dict["move_group"]].compute_fk(joint_positions_)
|
retval = self.moveit2[move_group].compute_fk(joint_positions_)
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
retry -= 1
|
attempts -= 1
|
||||||
if retval is None:
|
if retval is None:
|
||||||
result.success = False
|
raise ValueError("Failed to compute forward kinematics")
|
||||||
return result
|
|
||||||
pose = [retval.pose.position.x, retval.pose.position.y, retval.pose.position.z]
|
pose = [retval.pose.position.x, retval.pose.position.y, retval.pose.position.z]
|
||||||
quaternion = [
|
quaternion = [
|
||||||
retval.pose.orientation.x,
|
retval.pose.orientation.x,
|
||||||
@@ -264,60 +260,57 @@ class MoveitInterface:
|
|||||||
)
|
)
|
||||||
] + function_list
|
] + function_list
|
||||||
|
|
||||||
pose[2] += float(cmd_dict["lift_height"])
|
pose[2] += float(lift_height)
|
||||||
function_list.append(
|
function_list.append(
|
||||||
lambda: self.moveit_task(
|
lambda p=pose.copy(), q=quaternion, cfg=config: self.moveit_task(
|
||||||
position=pose, quaternion=quaternion, **config, cartesian=self.cartesian_flag
|
position=p, quaternion=q, **cfg, cartesian=self.cartesian_flag
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
end_pose = pose
|
end_pose = list(pose)
|
||||||
|
|
||||||
if "x_distance" in cmd_dict.keys() or "y_distance" in cmd_dict.keys():
|
if x_distance is not None or y_distance is not None:
|
||||||
if "x_distance" in cmd_dict.keys():
|
if x_distance is not None:
|
||||||
deep_pose = deepcopy(pose)
|
deep_pose = deepcopy(pose)
|
||||||
deep_pose[0] += float(cmd_dict["x_distance"])
|
deep_pose[0] += float(x_distance)
|
||||||
elif "y_distance" in cmd_dict.keys():
|
elif y_distance is not None:
|
||||||
deep_pose = deepcopy(pose)
|
deep_pose = deepcopy(pose)
|
||||||
deep_pose[1] += float(cmd_dict["y_distance"])
|
deep_pose[1] += float(y_distance)
|
||||||
|
|
||||||
function_list = [
|
function_list = [
|
||||||
lambda: self.moveit_task(
|
lambda p=pose.copy(), q=quaternion, cfg=config: self.moveit_task(
|
||||||
position=pose, quaternion=quaternion, **config, cartesian=self.cartesian_flag
|
position=p, quaternion=q, **cfg, cartesian=self.cartesian_flag
|
||||||
)
|
)
|
||||||
] + function_list
|
] + function_list
|
||||||
function_list.append(
|
function_list.append(
|
||||||
lambda: self.moveit_task(
|
lambda dp=deep_pose.copy(), q=quaternion, cfg=config: self.moveit_task(
|
||||||
position=deep_pose, quaternion=quaternion, **config, cartesian=self.cartesian_flag
|
position=dp, quaternion=q, **cfg, cartesian=self.cartesian_flag
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
end_pose = deep_pose
|
end_pose = list(deep_pose)
|
||||||
|
|
||||||
retval_ik = None
|
retval_ik = None
|
||||||
retry = config.get("retry", 10)
|
attempts_ik = config.get("retry", 10)
|
||||||
while retval_ik is None and retry > 0:
|
while retval_ik is None and attempts_ik > 0:
|
||||||
retval_ik = self.moveit2[cmd_dict["move_group"]].compute_ik(
|
retval_ik = self.moveit2[move_group].compute_ik(
|
||||||
position=end_pose, quat_xyzw=quaternion, constraints=Constraints(joint_constraints=constraints)
|
position=end_pose,
|
||||||
|
quat_xyzw=quaternion,
|
||||||
|
constraints=Constraints(joint_constraints=joint_constraint_msgs),
|
||||||
)
|
)
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
retry -= 1
|
attempts_ik -= 1
|
||||||
if retval_ik is None:
|
if retval_ik is None:
|
||||||
result.success = False
|
raise ValueError("Failed to compute inverse kinematics")
|
||||||
return result
|
|
||||||
position_ = [
|
position_ = [
|
||||||
retval_ik.position[retval_ik.name.index(i)]
|
retval_ik.position[retval_ik.name.index(i)] for i in self.moveit2[move_group].joint_names
|
||||||
for i in self.moveit2[cmd_dict["move_group"]].joint_names
|
|
||||||
]
|
]
|
||||||
|
jn = self.moveit2[move_group].joint_names
|
||||||
function_list = [
|
function_list = [
|
||||||
lambda: self.moveit_joint_task(
|
lambda pos=position_, names=jn, cfg=config: self.moveit_joint_task(
|
||||||
joint_positions=position_,
|
joint_positions=pos, joint_names=names, **cfg
|
||||||
joint_names=self.moveit2[cmd_dict["move_group"]].joint_names,
|
|
||||||
**config,
|
|
||||||
)
|
)
|
||||||
] + function_list
|
] + function_list
|
||||||
else:
|
else:
|
||||||
function_list = [
|
function_list = [lambda cfg=config, jp=joint_positions_: self.moveit_joint_task(**cfg, joint_positions=jp)] + function_list
|
||||||
lambda: self.moveit_joint_task(**config, joint_positions=joint_positions_)
|
|
||||||
] + function_list
|
|
||||||
|
|
||||||
for i in range(len(function_list)):
|
for i in range(len(function_list)):
|
||||||
if i == 0:
|
if i == 0:
|
||||||
@@ -328,16 +321,11 @@ class MoveitInterface:
|
|||||||
re = function_list[i]()
|
re = function_list[i]()
|
||||||
if not re:
|
if not re:
|
||||||
print(i, re)
|
print(i, re)
|
||||||
result.success = False
|
raise ValueError(f"Failed to execute moveit task: {i}")
|
||||||
return result
|
|
||||||
result.success = True
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
|
||||||
self.cartesian_flag = False
|
self.cartesian_flag = False
|
||||||
result.success = False
|
raise e
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def set_status(self, command: str):
|
def set_status(self, command: str):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import time
|
|||||||
import logging
|
import logging
|
||||||
from typing import Union, Dict, Optional
|
from typing import Union, Dict, Optional
|
||||||
|
|
||||||
|
from unilabos.registry.decorators import topic_config
|
||||||
|
|
||||||
|
|
||||||
class VirtualMultiwayValve:
|
class VirtualMultiwayValve:
|
||||||
"""
|
"""
|
||||||
@@ -41,13 +43,11 @@ class VirtualMultiwayValve:
|
|||||||
def target_position(self) -> int:
|
def target_position(self) -> int:
|
||||||
return self._target_position
|
return self._target_position
|
||||||
|
|
||||||
def get_current_position(self) -> int:
|
@property
|
||||||
"""获取当前阀门位置 📍"""
|
@topic_config()
|
||||||
return self._current_position
|
def current_port(self) -> str:
|
||||||
|
"""当前连接的端口名称 🔌"""
|
||||||
def get_current_port(self) -> str:
|
return self.port
|
||||||
"""获取当前连接的端口名称 🔌"""
|
|
||||||
return self._current_position
|
|
||||||
|
|
||||||
def set_position(self, command: Union[int, str]):
|
def set_position(self, command: Union[int, str]):
|
||||||
"""
|
"""
|
||||||
@@ -169,12 +169,14 @@ class VirtualMultiwayValve:
|
|||||||
self._status = "Idle"
|
self._status = "Idle"
|
||||||
self._valve_state = "Closed"
|
self._valve_state = "Closed"
|
||||||
|
|
||||||
close_msg = f"🔒 阀门已关闭,保持在位置 {self._current_position} ({self.get_current_port()})"
|
close_msg = f"🔒 阀门已关闭,保持在位置 {self._current_position} ({self.port})"
|
||||||
self.logger.info(close_msg)
|
self.logger.info(close_msg)
|
||||||
return close_msg
|
return close_msg
|
||||||
|
|
||||||
def get_valve_position(self) -> int:
|
@property
|
||||||
"""获取阀门位置 - 兼容性方法 📍"""
|
@topic_config()
|
||||||
|
def valve_position(self) -> int:
|
||||||
|
"""阀门位置 📍"""
|
||||||
return self._current_position
|
return self._current_position
|
||||||
|
|
||||||
def set_valve_position(self, command: Union[int, str]):
|
def set_valve_position(self, command: Union[int, str]):
|
||||||
@@ -229,19 +231,16 @@ class VirtualMultiwayValve:
|
|||||||
self.logger.info(f"🔄 从端口 {self._current_position} 切换到泵位置...")
|
self.logger.info(f"🔄 从端口 {self._current_position} 切换到泵位置...")
|
||||||
return self.set_to_pump_position()
|
return self.set_to_pump_position()
|
||||||
|
|
||||||
def get_flow_path(self) -> str:
|
@property
|
||||||
"""获取当前流路路径描述 🌊"""
|
@topic_config()
|
||||||
current_port = self.get_current_port()
|
def flow_path(self) -> str:
|
||||||
|
"""当前流路路径描述 🌊"""
|
||||||
if self._current_position == 0:
|
if self._current_position == 0:
|
||||||
flow_path = f"🚰 转移泵已连接 (位置 {self._current_position})"
|
return f"🚰 转移泵已连接 (位置 {self._current_position})"
|
||||||
else:
|
return f"🔌 端口 {self._current_position} 已连接 ({self.current_port})"
|
||||||
flow_path = f"🔌 端口 {self._current_position} 已连接 ({current_port})"
|
|
||||||
|
|
||||||
# 删除debug日志:self.logger.debug(f"🌊 当前流路: {flow_path}")
|
|
||||||
return flow_path
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
current_port = self.get_current_port()
|
current_port = self.current_port
|
||||||
status_emoji = "✅" if self._status == "Idle" else "🔄" if self._status == "Busy" else "❌"
|
status_emoji = "✅" if self._status == "Idle" else "🔄" if self._status == "Busy" else "❌"
|
||||||
|
|
||||||
return f"🔄 VirtualMultiwayValve({status_emoji} 位置: {self._current_position}/{self.max_positions}, 端口: {current_port}, 状态: {self._status})"
|
return f"🔄 VirtualMultiwayValve({status_emoji} 位置: {self._current_position}/{self.max_positions}, 端口: {current_port}, 状态: {self._status})"
|
||||||
@@ -253,7 +252,7 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
print("🔄 === 虚拟九通阀门测试 === ✨")
|
print("🔄 === 虚拟九通阀门测试 === ✨")
|
||||||
print(f"🏠 初始状态: {valve}")
|
print(f"🏠 初始状态: {valve}")
|
||||||
print(f"🌊 当前流路: {valve.get_flow_path()}")
|
print(f"🌊 当前流路: {valve.flow_path}")
|
||||||
|
|
||||||
# 切换到试剂瓶1(1号位)
|
# 切换到试剂瓶1(1号位)
|
||||||
print(f"\n🔌 切换到1号位: {valve.set_position(1)}")
|
print(f"\n🔌 切换到1号位: {valve.set_position(1)}")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import logging
|
|||||||
import time as time_module
|
import time as time_module
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
from unilabos.registry.decorators import topic_config
|
||||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||||
|
|
||||||
class VirtualStirrer:
|
class VirtualStirrer:
|
||||||
@@ -314,9 +315,11 @@ class VirtualStirrer:
|
|||||||
def min_speed(self) -> float:
|
def min_speed(self) -> float:
|
||||||
return self._min_speed
|
return self._min_speed
|
||||||
|
|
||||||
def get_device_info(self) -> Dict[str, Any]:
|
@property
|
||||||
"""获取设备状态信息 📊"""
|
@topic_config()
|
||||||
info = {
|
def device_info(self) -> Dict[str, Any]:
|
||||||
|
"""设备状态快照信息 📊"""
|
||||||
|
return {
|
||||||
"device_id": self.device_id,
|
"device_id": self.device_id,
|
||||||
"status": self.status,
|
"status": self.status,
|
||||||
"operation_mode": self.operation_mode,
|
"operation_mode": self.operation_mode,
|
||||||
@@ -325,12 +328,9 @@ class VirtualStirrer:
|
|||||||
"is_stirring": self.is_stirring,
|
"is_stirring": self.is_stirring,
|
||||||
"remaining_time": self.remaining_time,
|
"remaining_time": self.remaining_time,
|
||||||
"max_speed": self._max_speed,
|
"max_speed": self._max_speed,
|
||||||
"min_speed": self._min_speed
|
"min_speed": self._min_speed,
|
||||||
}
|
}
|
||||||
|
|
||||||
# self.logger.debug(f"📊 设备信息: 模式={self.operation_mode}, 速度={self.current_speed} RPM, 搅拌={self.is_stirring}")
|
|
||||||
return info
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
status_emoji = "✅" if self.operation_mode == "Idle" else "🌪️" if self.operation_mode == "Stirring" else "🛑" if self.operation_mode == "Settling" else "❌"
|
status_emoji = "✅" if self.operation_mode == "Idle" else "🌪️" if self.operation_mode == "Stirring" else "🛑" if self.operation_mode == "Settling" else "❌"
|
||||||
return f"🌪️ VirtualStirrer({status_emoji} {self.device_id}: {self.operation_mode}, {self.current_speed} RPM)"
|
return f"🌪️ VirtualStirrer({status_emoji} {self.device_id}: {self.operation_mode}, {self.current_speed} RPM)"
|
||||||
@@ -4,6 +4,7 @@ from enum import Enum
|
|||||||
from typing import Union, Optional
|
from typing import Union, Optional
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from unilabos.registry.decorators import topic_config
|
||||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||||
|
|
||||||
|
|
||||||
@@ -385,8 +386,10 @@ class VirtualTransferPump:
|
|||||||
"""获取当前体积"""
|
"""获取当前体积"""
|
||||||
return self._current_volume
|
return self._current_volume
|
||||||
|
|
||||||
def get_remaining_capacity(self) -> float:
|
@property
|
||||||
"""获取剩余容量"""
|
@topic_config()
|
||||||
|
def remaining_capacity(self) -> float:
|
||||||
|
"""剩余容量 (ml)"""
|
||||||
return self.max_volume - self._current_volume
|
return self.max_volume - self._current_volume
|
||||||
|
|
||||||
def is_empty(self) -> bool:
|
def is_empty(self) -> bool:
|
||||||
|
|||||||
@@ -14,19 +14,30 @@ Virtual Workbench Device - 模拟工作台设备
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import Dict, Any, Optional, List
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from threading import Lock, RLock
|
from threading import Lock, RLock
|
||||||
|
from typing import Any, Dict, List, Optional, cast
|
||||||
|
|
||||||
from typing_extensions import TypedDict
|
from typing_extensions import TypedDict
|
||||||
|
|
||||||
from unilabos.registry.decorators import (
|
from unilabos.registry.decorators import (
|
||||||
device, action, ActionInputHandle, ActionOutputHandle, DataSource, topic_config, not_action
|
ActionInputHandle,
|
||||||
|
ActionOutputHandle,
|
||||||
|
DataSource,
|
||||||
|
NodeType,
|
||||||
|
action,
|
||||||
|
device,
|
||||||
|
not_action,
|
||||||
|
topic_config,
|
||||||
|
)
|
||||||
|
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
|
||||||
|
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode
|
||||||
|
from unilabos.resources.resource_tracker import (
|
||||||
|
SampleUUIDsType,
|
||||||
|
LabSample,
|
||||||
|
ResourceTreeSet,
|
||||||
)
|
)
|
||||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
|
||||||
from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample
|
|
||||||
|
|
||||||
|
|
||||||
# ============ TypedDict 返回类型定义 ============
|
# ============ TypedDict 返回类型定义 ============
|
||||||
|
|
||||||
@@ -111,6 +122,7 @@ class HeatingStation:
|
|||||||
|
|
||||||
@device(
|
@device(
|
||||||
id="virtual_workbench",
|
id="virtual_workbench",
|
||||||
|
display_name="虚拟工作台",
|
||||||
category=["virtual_device"],
|
category=["virtual_device"],
|
||||||
description="Virtual Workbench with 1 robotic arm and 3 heating stations for concurrent material processing",
|
description="Virtual Workbench with 1 robotic arm and 3 heating stations for concurrent material processing",
|
||||||
)
|
)
|
||||||
@@ -136,7 +148,19 @@ class VirtualWorkbench:
|
|||||||
HEATING_TIME: float = 60.0 # 加热时间(秒)
|
HEATING_TIME: float = 60.0 # 加热时间(秒)
|
||||||
NUM_HEATING_STATIONS: int = 3 # 加热台数量
|
NUM_HEATING_STATIONS: int = 3 # 加热台数量
|
||||||
|
|
||||||
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
|
def __init__(
|
||||||
|
self,
|
||||||
|
device_id: Optional[str] = None,
|
||||||
|
config: Optional[Dict[str, Any]] = None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
初始化虚拟工作台。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_id[设备ID]: 工作台设备实例 ID,默认使用 virtual_workbench。
|
||||||
|
config[设备配置]: 可包含 arm_operation_time、heating_time、num_heating_stations。
|
||||||
|
"""
|
||||||
# 处理可能的不同调用方式
|
# 处理可能的不同调用方式
|
||||||
if device_id is None and "id" in kwargs:
|
if device_id is None and "id" in kwargs:
|
||||||
device_id = kwargs.pop("id")
|
device_id = kwargs.pop("id")
|
||||||
@@ -150,9 +174,13 @@ class VirtualWorkbench:
|
|||||||
self.data: Dict[str, Any] = {}
|
self.data: Dict[str, Any] = {}
|
||||||
|
|
||||||
# 从config中获取可配置参数
|
# 从config中获取可配置参数
|
||||||
self.ARM_OPERATION_TIME = float(self.config.get("arm_operation_time", self.ARM_OPERATION_TIME))
|
self.ARM_OPERATION_TIME = float(
|
||||||
|
self.config.get("arm_operation_time", self.ARM_OPERATION_TIME)
|
||||||
|
)
|
||||||
self.HEATING_TIME = float(self.config.get("heating_time", self.HEATING_TIME))
|
self.HEATING_TIME = float(self.config.get("heating_time", self.HEATING_TIME))
|
||||||
self.NUM_HEATING_STATIONS = int(self.config.get("num_heating_stations", self.NUM_HEATING_STATIONS))
|
self.NUM_HEATING_STATIONS = int(
|
||||||
|
self.config.get("num_heating_stations", self.NUM_HEATING_STATIONS)
|
||||||
|
)
|
||||||
|
|
||||||
# 机械臂状态和锁
|
# 机械臂状态和锁
|
||||||
self._arm_lock = Lock()
|
self._arm_lock = Lock()
|
||||||
@@ -161,7 +189,8 @@ class VirtualWorkbench:
|
|||||||
|
|
||||||
# 加热台状态
|
# 加热台状态
|
||||||
self._heating_stations: Dict[int, HeatingStation] = {
|
self._heating_stations: Dict[int, HeatingStation] = {
|
||||||
i: HeatingStation(station_id=i) for i in range(1, self.NUM_HEATING_STATIONS + 1)
|
i: HeatingStation(station_id=i)
|
||||||
|
for i in range(1, self.NUM_HEATING_STATIONS + 1)
|
||||||
}
|
}
|
||||||
self._stations_lock = RLock()
|
self._stations_lock = RLock()
|
||||||
|
|
||||||
@@ -290,20 +319,292 @@ class VirtualWorkbench:
|
|||||||
self._update_data_status(f"机械臂已释放 (完成: {task})")
|
self._update_data_status(f"机械臂已释放 (完成: {task})")
|
||||||
self.logger.info(f"机械臂已释放 (完成: {task})")
|
self.logger.info(f"机械臂已释放 (完成: {task})")
|
||||||
|
|
||||||
|
@action(
|
||||||
|
always_free=True,
|
||||||
|
node_type=NodeType.MANUAL_CONFIRM,
|
||||||
|
placeholder_keys={"assignee_user_ids": "unilabos_manual_confirm"},
|
||||||
|
goal_default={"timeout_seconds": 3600, "assignee_user_ids": []},
|
||||||
|
feedback_interval=300,
|
||||||
|
handles=[
|
||||||
|
ActionInputHandle(
|
||||||
|
key="target_device",
|
||||||
|
data_type="device_id",
|
||||||
|
label="目标设备",
|
||||||
|
data_key="target_device",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="resource",
|
||||||
|
data_type="resource",
|
||||||
|
label="待转移资源",
|
||||||
|
data_key="resource",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="mount_resource",
|
||||||
|
data_type="resource",
|
||||||
|
label="目标孔位",
|
||||||
|
data_key="mount_resource",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="collector_mass",
|
||||||
|
data_type="collector_mass",
|
||||||
|
label="极流体质量",
|
||||||
|
data_key="collector_mass",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="active_material",
|
||||||
|
data_type="active_material",
|
||||||
|
label="活性物质含量",
|
||||||
|
data_key="active_material",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="capacity",
|
||||||
|
data_type="capacity",
|
||||||
|
label="克容量",
|
||||||
|
data_key="capacity",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="battery_system",
|
||||||
|
data_type="battery_system",
|
||||||
|
label="电池体系",
|
||||||
|
data_key="battery_system",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
# transfer使用
|
||||||
|
ActionOutputHandle(
|
||||||
|
key="target_device",
|
||||||
|
data_type="device_id",
|
||||||
|
label="目标设备",
|
||||||
|
data_key="target_device",
|
||||||
|
data_source=DataSource.EXECUTOR,
|
||||||
|
),
|
||||||
|
ActionOutputHandle(
|
||||||
|
key="resource",
|
||||||
|
data_type="resource",
|
||||||
|
label="待转移资源",
|
||||||
|
data_key="resource.@flatten",
|
||||||
|
data_source=DataSource.EXECUTOR,
|
||||||
|
),
|
||||||
|
ActionOutputHandle(
|
||||||
|
key="mount_resource",
|
||||||
|
data_type="resource",
|
||||||
|
label="目标孔位",
|
||||||
|
data_key="mount_resource.@flatten",
|
||||||
|
data_source=DataSource.EXECUTOR,
|
||||||
|
),
|
||||||
|
# test使用
|
||||||
|
ActionOutputHandle(
|
||||||
|
key="collector_mass",
|
||||||
|
data_type="collector_mass",
|
||||||
|
label="极流体质量",
|
||||||
|
data_key="collector_mass",
|
||||||
|
data_source=DataSource.EXECUTOR,
|
||||||
|
),
|
||||||
|
ActionOutputHandle(
|
||||||
|
key="active_material",
|
||||||
|
data_type="active_material",
|
||||||
|
label="活性物质含量",
|
||||||
|
data_key="active_material",
|
||||||
|
data_source=DataSource.EXECUTOR,
|
||||||
|
),
|
||||||
|
ActionOutputHandle(
|
||||||
|
key="capacity",
|
||||||
|
data_type="capacity",
|
||||||
|
label="克容量",
|
||||||
|
data_key="capacity",
|
||||||
|
data_source=DataSource.EXECUTOR,
|
||||||
|
),
|
||||||
|
ActionOutputHandle(
|
||||||
|
key="battery_system",
|
||||||
|
data_type="battery_system",
|
||||||
|
label="电池体系",
|
||||||
|
data_key="battery_system",
|
||||||
|
data_source=DataSource.EXECUTOR,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def manual_confirm(
|
||||||
|
self,
|
||||||
|
resource: List[ResourceSlot],
|
||||||
|
target_device: DeviceSlot,
|
||||||
|
mount_resource: List[ResourceSlot],
|
||||||
|
collector_mass: List[float],
|
||||||
|
active_material: List[float],
|
||||||
|
capacity: List[float],
|
||||||
|
battery_system: List[str],
|
||||||
|
timeout_seconds: int,
|
||||||
|
assignee_user_ids: list[str],
|
||||||
|
**kwargs,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
人工确认资源转移和扣电测试参数。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
resource[待转移资源]: 需要人工确认的资源列表。
|
||||||
|
target_device[目标设备]: 资源要转移到的目标设备 ID。
|
||||||
|
mount_resource[目标孔位]: 资源要挂载到的目标孔位列表。
|
||||||
|
collector_mass[极流体质量]: 每个样品对应的极流体质量。
|
||||||
|
active_material[活性物质含量]: 每个样品对应的活性物质含量。
|
||||||
|
capacity[克容量]: 每个样品对应的克容量,单位 mAh/g。
|
||||||
|
battery_system[电池体系]: 每个样品对应的电池体系名称。
|
||||||
|
timeout_seconds[超时时间]: 人工确认超时时间,单位秒。
|
||||||
|
assignee_user_ids[确认人]: 指定处理人工确认任务的用户 ID 列表。
|
||||||
|
|
||||||
|
Note:
|
||||||
|
修改的结果无效,是只读的。
|
||||||
|
"""
|
||||||
|
resource_tree = ResourceTreeSet.from_plr_resources(cast(Any, resource)).dump()
|
||||||
|
mount_resource_tree = ResourceTreeSet.from_plr_resources(cast(Any, mount_resource)).dump()
|
||||||
|
kwargs.update(locals())
|
||||||
|
kwargs.pop("kwargs")
|
||||||
|
kwargs.pop("self")
|
||||||
|
kwargs["resource"] = resource_tree
|
||||||
|
kwargs["mount_resource"] = mount_resource_tree
|
||||||
|
kwargs.pop("resource_tree")
|
||||||
|
kwargs.pop("mount_resource_tree")
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
@action(
|
||||||
|
description="转移物料",
|
||||||
|
handles=[
|
||||||
|
ActionInputHandle(
|
||||||
|
key="target_device",
|
||||||
|
data_type="device_id",
|
||||||
|
label="目标设备",
|
||||||
|
data_key="target_device",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="resource",
|
||||||
|
data_type="resource",
|
||||||
|
label="待转移资源",
|
||||||
|
data_key="resource",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="mount_resource",
|
||||||
|
data_type="resource",
|
||||||
|
label="目标孔位",
|
||||||
|
data_key="mount_resource",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def transfer(
|
||||||
|
self,
|
||||||
|
resource: List[ResourceSlot],
|
||||||
|
target_device: DeviceSlot,
|
||||||
|
mount_resource: List[ResourceSlot],
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
转移资源到目标设备。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
resource[待转移资源]: 待转移的资源列表。
|
||||||
|
target_device[目标设备]: 接收资源的目标设备 ID。
|
||||||
|
mount_resource[目标孔位]: 目标设备上的挂载孔位列表。
|
||||||
|
"""
|
||||||
|
future = ROS2DeviceNode.run_async_func(
|
||||||
|
self._ros_node.transfer_resource_to_another,
|
||||||
|
True,
|
||||||
|
**{
|
||||||
|
"plr_resources": resource,
|
||||||
|
"target_device_id": target_device,
|
||||||
|
"target_resources": mount_resource,
|
||||||
|
"sites": [None] * len(mount_resource),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
result = await future
|
||||||
|
return result
|
||||||
|
|
||||||
|
@action(
|
||||||
|
description="扣电测试启动",
|
||||||
|
handles=[
|
||||||
|
ActionInputHandle(
|
||||||
|
key="resource",
|
||||||
|
data_type="resource",
|
||||||
|
label="待转移资源",
|
||||||
|
data_key="resource",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="mount_resource",
|
||||||
|
data_type="resource",
|
||||||
|
label="目标孔位",
|
||||||
|
data_key="mount_resource",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="collector_mass",
|
||||||
|
data_type="collector_mass",
|
||||||
|
label="极流体质量",
|
||||||
|
data_key="collector_mass",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="active_material",
|
||||||
|
data_type="active_material",
|
||||||
|
label="活性物质含量",
|
||||||
|
data_key="active_material",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="capacity",
|
||||||
|
data_type="capacity",
|
||||||
|
label="克容量",
|
||||||
|
data_key="capacity",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="battery_system",
|
||||||
|
data_type="battery_system",
|
||||||
|
label="电池体系",
|
||||||
|
data_key="battery_system",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test(
|
||||||
|
self,
|
||||||
|
resource: List[ResourceSlot],
|
||||||
|
mount_resource: List[ResourceSlot],
|
||||||
|
collector_mass: List[float],
|
||||||
|
active_material: List[float],
|
||||||
|
capacity: List[float],
|
||||||
|
battery_system: list[str],
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
启动扣电测试。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
resource[待测试资源]: 需要进行扣电测试的资源列表。
|
||||||
|
mount_resource[测试孔位]: 扣电测试使用的目标孔位列表。
|
||||||
|
collector_mass[极流体质量]: 每个样品对应的极流体质量。
|
||||||
|
active_material[活性物质含量]: 每个样品对应的活性物质含量。
|
||||||
|
capacity[克容量]: 每个样品对应的克容量,单位 mAh/g。
|
||||||
|
battery_system[电池体系]: 每个样品对应的电池体系名称。
|
||||||
|
"""
|
||||||
|
print(resource)
|
||||||
|
print(mount_resource)
|
||||||
|
print(collector_mass)
|
||||||
|
print(active_material)
|
||||||
|
print(capacity)
|
||||||
|
print(battery_system)
|
||||||
|
|
||||||
@action(
|
@action(
|
||||||
auto_prefix=True,
|
auto_prefix=True,
|
||||||
description="批量准备物料 - 虚拟起始节点, 生成A1-A5物料, 输出5个handle供后续节点使用",
|
description="批量准备物料 - 虚拟起始节点, 生成A1-A5物料, 输出5个handle供后续节点使用",
|
||||||
handles=[
|
handles=[
|
||||||
ActionOutputHandle(key="channel_1", data_type="workbench_material",
|
ActionOutputHandle(key="channel_1", data_type="workbench_material", label="实验1", data_key="material_1", data_source=DataSource.EXECUTOR), # noqa: E501
|
||||||
label="实验1", data_key="material_1", data_source=DataSource.EXECUTOR),
|
ActionOutputHandle(key="channel_2", data_type="workbench_material", label="实验2", data_key="material_2", data_source=DataSource.EXECUTOR), # noqa: E501
|
||||||
ActionOutputHandle(key="channel_2", data_type="workbench_material",
|
ActionOutputHandle(key="channel_3", data_type="workbench_material", label="实验3", data_key="material_3", data_source=DataSource.EXECUTOR), # noqa: E501
|
||||||
label="实验2", data_key="material_2", data_source=DataSource.EXECUTOR),
|
ActionOutputHandle(key="channel_4", data_type="workbench_material", label="实验4", data_key="material_4", data_source=DataSource.EXECUTOR), # noqa: E501
|
||||||
ActionOutputHandle(key="channel_3", data_type="workbench_material",
|
ActionOutputHandle(key="channel_5", data_type="workbench_material", label="实验5", data_key="material_5", data_source=DataSource.EXECUTOR), # noqa: E501
|
||||||
label="实验3", data_key="material_3", data_source=DataSource.EXECUTOR),
|
|
||||||
ActionOutputHandle(key="channel_4", data_type="workbench_material",
|
|
||||||
label="实验4", data_key="material_4", data_source=DataSource.EXECUTOR),
|
|
||||||
ActionOutputHandle(key="channel_5", data_type="workbench_material",
|
|
||||||
label="实验5", data_key="material_5", data_source=DataSource.EXECUTOR),
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def prepare_materials(
|
def prepare_materials(
|
||||||
@@ -316,6 +617,9 @@ class VirtualWorkbench:
|
|||||||
|
|
||||||
作为工作流的起始节点, 生成指定数量的物料编号供后续节点使用。
|
作为工作流的起始节点, 生成指定数量的物料编号供后续节点使用。
|
||||||
输出5个handle (material_1 ~ material_5), 分别对应实验1~5。
|
输出5个handle (material_1 ~ material_5), 分别对应实验1~5。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
count[物料数量]: 要生成的物料数量,默认生成 5 个。
|
||||||
"""
|
"""
|
||||||
materials = [i for i in range(1, count + 1)]
|
materials = [i for i in range(1, count + 1)]
|
||||||
|
|
||||||
@@ -336,7 +640,11 @@ class VirtualWorkbench:
|
|||||||
LabSample(
|
LabSample(
|
||||||
sample_uuid=sample_uuid,
|
sample_uuid=sample_uuid,
|
||||||
oss_path="",
|
oss_path="",
|
||||||
extra={"material_uuid": content} if isinstance(content, str) else (content.serialize() if content else {}),
|
extra=(
|
||||||
|
{"material_uuid": content}
|
||||||
|
if isinstance(content, str)
|
||||||
|
else (content.serialize() if content else {})
|
||||||
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
],
|
],
|
||||||
@@ -346,12 +654,27 @@ class VirtualWorkbench:
|
|||||||
auto_prefix=True,
|
auto_prefix=True,
|
||||||
description="将物料从An位置移动到空闲加热台, 返回分配的加热台ID",
|
description="将物料从An位置移动到空闲加热台, 返回分配的加热台ID",
|
||||||
handles=[
|
handles=[
|
||||||
ActionInputHandle(key="material_input", data_type="workbench_material",
|
ActionInputHandle(
|
||||||
label="物料编号", data_key="material_number", data_source=DataSource.HANDLE),
|
key="material_input",
|
||||||
ActionOutputHandle(key="heating_station_output", data_type="workbench_station",
|
data_type="workbench_material",
|
||||||
label="加热台ID", data_key="station_id", data_source=DataSource.EXECUTOR),
|
label="物料编号",
|
||||||
ActionOutputHandle(key="material_number_output", data_type="workbench_material",
|
data_key="material_number",
|
||||||
label="物料编号", data_key="material_number", data_source=DataSource.EXECUTOR),
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionOutputHandle(
|
||||||
|
key="heating_station_output",
|
||||||
|
data_type="workbench_station",
|
||||||
|
label="加热台ID",
|
||||||
|
data_key="station_id",
|
||||||
|
data_source=DataSource.EXECUTOR,
|
||||||
|
),
|
||||||
|
ActionOutputHandle(
|
||||||
|
key="material_number_output",
|
||||||
|
data_type="workbench_material",
|
||||||
|
label="物料编号",
|
||||||
|
data_key="material_number",
|
||||||
|
data_source=DataSource.EXECUTOR,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def move_to_heating_station(
|
def move_to_heating_station(
|
||||||
@@ -363,6 +686,9 @@ class VirtualWorkbench:
|
|||||||
将物料从An位置移动到加热台
|
将物料从An位置移动到加热台
|
||||||
|
|
||||||
多线程并发调用时, 会竞争机械臂使用权, 并自动查找空闲加热台
|
多线程并发调用时, 会竞争机械臂使用权, 并自动查找空闲加热台
|
||||||
|
|
||||||
|
Args:
|
||||||
|
material_number[物料编号]: 要移动的物料编号,对应 A1、A2 等起始位置。
|
||||||
"""
|
"""
|
||||||
material_id = f"A{material_number}"
|
material_id = f"A{material_number}"
|
||||||
task_desc = f"移动{material_id}到加热台"
|
task_desc = f"移动{material_id}到加热台"
|
||||||
@@ -425,7 +751,8 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
if isinstance(content, str)
|
||||||
|
else (content.serialize() if content else {})
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
@@ -448,7 +775,8 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
if isinstance(content, str)
|
||||||
|
else (content.serialize() if content else {})
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
@@ -460,14 +788,34 @@ class VirtualWorkbench:
|
|||||||
always_free=True,
|
always_free=True,
|
||||||
description="启动指定加热台的加热程序",
|
description="启动指定加热台的加热程序",
|
||||||
handles=[
|
handles=[
|
||||||
ActionInputHandle(key="station_id_input", data_type="workbench_station",
|
ActionInputHandle(
|
||||||
label="加热台ID", data_key="station_id", data_source=DataSource.HANDLE),
|
key="station_id_input",
|
||||||
ActionInputHandle(key="material_number_input", data_type="workbench_material",
|
data_type="workbench_station",
|
||||||
label="物料编号", data_key="material_number", data_source=DataSource.HANDLE),
|
label="加热台ID",
|
||||||
ActionOutputHandle(key="heating_done_station", data_type="workbench_station",
|
data_key="station_id",
|
||||||
label="加热完成-加热台ID", data_key="station_id", data_source=DataSource.EXECUTOR),
|
data_source=DataSource.HANDLE,
|
||||||
ActionOutputHandle(key="heating_done_material", data_type="workbench_material",
|
),
|
||||||
label="加热完成-物料编号", data_key="material_number", data_source=DataSource.EXECUTOR),
|
ActionInputHandle(
|
||||||
|
key="material_number_input",
|
||||||
|
data_type="workbench_material",
|
||||||
|
label="物料编号",
|
||||||
|
data_key="material_number",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionOutputHandle(
|
||||||
|
key="heating_done_station",
|
||||||
|
data_type="workbench_station",
|
||||||
|
label="加热完成-加热台ID",
|
||||||
|
data_key="station_id",
|
||||||
|
data_source=DataSource.EXECUTOR,
|
||||||
|
),
|
||||||
|
ActionOutputHandle(
|
||||||
|
key="heating_done_material",
|
||||||
|
data_type="workbench_material",
|
||||||
|
label="加热完成-物料编号",
|
||||||
|
data_key="material_number",
|
||||||
|
data_source=DataSource.EXECUTOR,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def start_heating(
|
def start_heating(
|
||||||
@@ -478,6 +826,10 @@ class VirtualWorkbench:
|
|||||||
) -> StartHeatingResult:
|
) -> StartHeatingResult:
|
||||||
"""
|
"""
|
||||||
启动指定加热台的加热程序
|
启动指定加热台的加热程序
|
||||||
|
|
||||||
|
Args:
|
||||||
|
station_id[加热台ID]: 要启动加热的加热台编号。
|
||||||
|
material_number[物料编号]: 当前加热台上的物料编号。
|
||||||
"""
|
"""
|
||||||
self.logger.info(f"[加热台{station_id}] 开始加热")
|
self.logger.info(f"[加热台{station_id}] 开始加热")
|
||||||
|
|
||||||
@@ -494,7 +846,8 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
if isinstance(content, str)
|
||||||
|
else (content.serialize() if content else {})
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
@@ -517,7 +870,8 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
if isinstance(content, str)
|
||||||
|
else (content.serialize() if content else {})
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
@@ -537,7 +891,8 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
if isinstance(content, str)
|
||||||
|
else (content.serialize() if content else {})
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
@@ -577,7 +932,9 @@ class VirtualWorkbench:
|
|||||||
self._update_data_status(f"加热台{station_id}加热中: {progress:.1f}%")
|
self._update_data_status(f"加热台{station_id}加热中: {progress:.1f}%")
|
||||||
|
|
||||||
if time.time() - last_countdown_log >= 5.0:
|
if time.time() - last_countdown_log >= 5.0:
|
||||||
self.logger.info(f"[加热台{station_id}] {material_id} 剩余 {remaining:.1f}s")
|
self.logger.info(
|
||||||
|
f"[加热台{station_id}] {material_id} 剩余 {remaining:.1f}s"
|
||||||
|
)
|
||||||
last_countdown_log = time.time()
|
last_countdown_log = time.time()
|
||||||
|
|
||||||
if elapsed >= self.HEATING_TIME:
|
if elapsed >= self.HEATING_TIME:
|
||||||
@@ -594,7 +951,9 @@ class VirtualWorkbench:
|
|||||||
self._active_tasks[material_id]["status"] = "heating_completed"
|
self._active_tasks[material_id]["status"] = "heating_completed"
|
||||||
|
|
||||||
self._update_data_status(f"加热台{station_id}加热完成")
|
self._update_data_status(f"加热台{station_id}加热完成")
|
||||||
self.logger.info(f"[加热台{station_id}] {material_id}加热完成 (用时{self.HEATING_TIME}s)")
|
self.logger.info(
|
||||||
|
f"[加热台{station_id}] {material_id}加热完成 (用时{self.HEATING_TIME}s)"
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -608,7 +967,8 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
if isinstance(content, str)
|
||||||
|
else (content.serialize() if content else {})
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
@@ -619,10 +979,20 @@ class VirtualWorkbench:
|
|||||||
auto_prefix=True,
|
auto_prefix=True,
|
||||||
description="将物料从加热台移动到输出位置Cn",
|
description="将物料从加热台移动到输出位置Cn",
|
||||||
handles=[
|
handles=[
|
||||||
ActionInputHandle(key="output_station_input", data_type="workbench_station",
|
ActionInputHandle(
|
||||||
label="加热台ID", data_key="station_id", data_source=DataSource.HANDLE),
|
key="output_station_input",
|
||||||
ActionInputHandle(key="output_material_input", data_type="workbench_material",
|
data_type="workbench_station",
|
||||||
label="物料编号", data_key="material_number", data_source=DataSource.HANDLE),
|
label="加热台ID",
|
||||||
|
data_key="station_id",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="output_material_input",
|
||||||
|
data_type="workbench_material",
|
||||||
|
label="物料编号",
|
||||||
|
data_key="material_number",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def move_to_output(
|
def move_to_output(
|
||||||
@@ -633,6 +1003,10 @@ class VirtualWorkbench:
|
|||||||
) -> MoveToOutputResult:
|
) -> MoveToOutputResult:
|
||||||
"""
|
"""
|
||||||
将物料从加热台移动到输出位置Cn
|
将物料从加热台移动到输出位置Cn
|
||||||
|
|
||||||
|
Args:
|
||||||
|
station_id[加热台ID]: 已完成加热的加热台编号。
|
||||||
|
material_number[物料编号]: 要移动到输出位置的物料编号,对应 Cn。
|
||||||
"""
|
"""
|
||||||
output_number = material_number
|
output_number = material_number
|
||||||
|
|
||||||
@@ -649,7 +1023,8 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
if isinstance(content, str)
|
||||||
|
else (content.serialize() if content else {})
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
@@ -673,7 +1048,8 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
if isinstance(content, str)
|
||||||
|
else (content.serialize() if content else {})
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
@@ -693,7 +1069,8 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
if isinstance(content, str)
|
||||||
|
else (content.serialize() if content else {})
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
@@ -775,7 +1152,8 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
if isinstance(content, str)
|
||||||
|
else (content.serialize() if content else {})
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
|
|||||||
@@ -632,7 +632,7 @@
|
|||||||
"size_y": 85.8,
|
"size_y": 85.8,
|
||||||
"size_z": 42.66,
|
"size_z": 42.66,
|
||||||
"model": "PRCXI_EP_Adapter",
|
"model": "PRCXI_EP_Adapter",
|
||||||
"category": null,
|
"category": "tube_rack",
|
||||||
"plate_type": null,
|
"plate_type": null,
|
||||||
"material_info": {
|
"material_info": {
|
||||||
"uuid": "e146697c395e4eabb3d6b74f0dd6aaf7",
|
"uuid": "e146697c395e4eabb3d6b74f0dd6aaf7",
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ from typing import Any, Dict, List, Optional, Tuple, Union
|
|||||||
|
|
||||||
MAX_SCAN_DEPTH = 10 # 最大目录递归深度
|
MAX_SCAN_DEPTH = 10 # 最大目录递归深度
|
||||||
MAX_SCAN_FILES = 1000 # 最大扫描文件数量
|
MAX_SCAN_FILES = 1000 # 最大扫描文件数量
|
||||||
_CACHE_VERSION = 1 # 缓存格式版本号,格式变更时递增
|
_CACHE_VERSION = 2 # 缓存格式版本号,格式变更时递增
|
||||||
|
|
||||||
# 合法的装饰器来源模块
|
# 合法的装饰器来源模块
|
||||||
_REGISTRY_DECORATOR_MODULE = "unilabos.registry.decorators"
|
_REGISTRY_DECORATOR_MODULE = "unilabos.registry.decorators"
|
||||||
@@ -258,8 +258,6 @@ def scan_directory(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# File-level parsing
|
# File-level parsing
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -361,6 +359,7 @@ def _parse_file(
|
|||||||
"actions": class_body.get("actions", {}),
|
"actions": class_body.get("actions", {}),
|
||||||
"status_properties": class_body.get("status_properties", {}),
|
"status_properties": class_body.get("status_properties", {}),
|
||||||
"init_params": class_body.get("init_params", []),
|
"init_params": class_body.get("init_params", []),
|
||||||
|
"init_docstring": class_body.get("init_docstring"),
|
||||||
"auto_methods": class_body.get("auto_methods", {}),
|
"auto_methods": class_body.get("auto_methods", {}),
|
||||||
"import_map": import_map,
|
"import_map": import_map,
|
||||||
}
|
}
|
||||||
@@ -497,7 +496,6 @@ def _collect_imports(tree: ast.Module, module_path: str = "") -> Dict[str, str]:
|
|||||||
return import_map
|
return import_map
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Decorator finding & argument extraction
|
# Decorator finding & argument extraction
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -768,6 +766,7 @@ def _extract_class_body(
|
|||||||
"actions": {}, # method_name -> action_info
|
"actions": {}, # method_name -> action_info
|
||||||
"status_properties": {}, # prop_name -> status_info
|
"status_properties": {}, # prop_name -> status_info
|
||||||
"init_params": [], # [{"name": ..., "type": ..., "default": ...}, ...]
|
"init_params": [], # [{"name": ..., "type": ..., "default": ...}, ...]
|
||||||
|
"init_docstring": None,
|
||||||
"auto_methods": {}, # method_name -> method_info (no @action decorator)
|
"auto_methods": {}, # method_name -> method_info (no @action decorator)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -780,6 +779,7 @@ def _extract_class_body(
|
|||||||
# --- __init__ ---
|
# --- __init__ ---
|
||||||
if method_name == "__init__":
|
if method_name == "__init__":
|
||||||
result["init_params"] = _extract_method_params(item, import_map)
|
result["init_params"] = _extract_method_params(item, import_map)
|
||||||
|
result["init_docstring"] = ast.get_docstring(item)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# --- Skip private/dunder ---
|
# --- Skip private/dunder ---
|
||||||
@@ -825,6 +825,7 @@ def _extract_class_body(
|
|||||||
action_args.setdefault("placeholder_keys", {})
|
action_args.setdefault("placeholder_keys", {})
|
||||||
action_args.setdefault("always_free", False)
|
action_args.setdefault("always_free", False)
|
||||||
action_args.setdefault("is_protocol", False)
|
action_args.setdefault("is_protocol", False)
|
||||||
|
action_args.setdefault("feedback_interval", 1.0)
|
||||||
action_args.setdefault("description", "")
|
action_args.setdefault("description", "")
|
||||||
action_args.setdefault("auto_prefix", False)
|
action_args.setdefault("auto_prefix", False)
|
||||||
action_args.setdefault("parent", False)
|
action_args.setdefault("parent", False)
|
||||||
|
|||||||
@@ -343,6 +343,7 @@ def action(
|
|||||||
auto_prefix: bool = False,
|
auto_prefix: bool = False,
|
||||||
parent: bool = False,
|
parent: bool = False,
|
||||||
node_type: Optional["NodeType"] = None,
|
node_type: Optional["NodeType"] = None,
|
||||||
|
feedback_interval: Optional[float] = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
动作方法装饰器
|
动作方法装饰器
|
||||||
@@ -378,6 +379,13 @@ def action(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def decorator(func: F) -> F:
|
def decorator(func: F) -> F:
|
||||||
|
import asyncio as _asyncio
|
||||||
|
|
||||||
|
if _asyncio.iscoroutinefunction(func):
|
||||||
|
@wraps(func)
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
else:
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
return func(*args, **kwargs)
|
return func(*args, **kwargs)
|
||||||
@@ -399,6 +407,8 @@ def action(
|
|||||||
"auto_prefix": auto_prefix,
|
"auto_prefix": auto_prefix,
|
||||||
"parent": parent,
|
"parent": parent,
|
||||||
}
|
}
|
||||||
|
if feedback_interval is not None:
|
||||||
|
meta["feedback_interval"] = feedback_interval
|
||||||
if node_type is not None:
|
if node_type is not None:
|
||||||
meta["node_type"] = node_type.value if isinstance(node_type, NodeType) else str(node_type)
|
meta["node_type"] = node_type.value if isinstance(node_type, NodeType) else str(node_type)
|
||||||
wrapper._action_registry_meta = meta # type: ignore[attr-defined]
|
wrapper._action_registry_meta = meta # type: ignore[attr-defined]
|
||||||
|
|||||||
@@ -51,14 +51,18 @@ Qone_nmr:
|
|||||||
properties:
|
properties:
|
||||||
check_interval:
|
check_interval:
|
||||||
default: 60
|
default: 60
|
||||||
|
description: 检查间隔时间(秒),默认60秒
|
||||||
type: string
|
type: string
|
||||||
expected_count:
|
expected_count:
|
||||||
default: 1
|
default: 1
|
||||||
|
description: 期望生成的.nmr文件数量,默认1个
|
||||||
type: string
|
type: string
|
||||||
monitor_dir:
|
monitor_dir:
|
||||||
|
description: 要监督的目录路径,如果未指定则使用self.monitor_directory
|
||||||
type: string
|
type: string
|
||||||
stability_checks:
|
stability_checks:
|
||||||
default: 3
|
default: 3
|
||||||
|
description: 文件大小稳定性检查次数,默认3次
|
||||||
type: string
|
type: string
|
||||||
required: []
|
required: []
|
||||||
type: object
|
type: object
|
||||||
@@ -85,11 +89,14 @@ Qone_nmr:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
output_dir:
|
output_dir:
|
||||||
|
description: 输出目录(如果未指定,使用self.output_directory)
|
||||||
type: string
|
type: string
|
||||||
string_list:
|
string_list:
|
||||||
|
description: 字符串列表
|
||||||
type: string
|
type: string
|
||||||
txt_encoding:
|
txt_encoding:
|
||||||
default: utf-8
|
default: utf-8
|
||||||
|
description: 文件编码
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
- string_list
|
- string_list
|
||||||
@@ -151,6 +158,13 @@ Qone_nmr:
|
|||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
properties:
|
properties:
|
||||||
string:
|
string:
|
||||||
|
description: '包含多个字符串的输入数据,支持两种格式:
|
||||||
|
|
||||||
|
1. 逗号分隔:如 "A 1 B 2 C 3, X 10 Y 20 Z 30"
|
||||||
|
|
||||||
|
2. 换行分隔:如 "A 1 B 2 C 3
|
||||||
|
|
||||||
|
X 10 Y 20 Z 30"'
|
||||||
type: string
|
type: string
|
||||||
title: StrSingleInput_Goal
|
title: StrSingleInput_Goal
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -491,14 +491,17 @@ bioyond_cell:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
material_names:
|
material_names:
|
||||||
|
description: 物料名称列表;默认使用 [LiPF6, LiDFOB, DTD, LiFSI, LiPO2F2]
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
type: array
|
type: array
|
||||||
type_id:
|
type_id:
|
||||||
default: 3a190ca0-b2f6-9aeb-8067-547e72c11469
|
default: 3a190ca0-b2f6-9aeb-8067-547e72c11469
|
||||||
|
description: 物料类型ID
|
||||||
type: string
|
type: string
|
||||||
warehouse_name:
|
warehouse_name:
|
||||||
default: 粉末加样头堆栈
|
default: 粉末加样头堆栈
|
||||||
|
description: 目标仓库名(用于取位置信息)
|
||||||
type: string
|
type: string
|
||||||
required: []
|
required: []
|
||||||
type: object
|
type: object
|
||||||
@@ -527,12 +530,16 @@ bioyond_cell:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
location_name_or_id:
|
location_name_or_id:
|
||||||
|
description: 具体库位名称(如 A01)或库位 UUID,由用户指定。
|
||||||
type: string
|
type: string
|
||||||
material_name:
|
material_name:
|
||||||
|
description: 物料名称(会优先匹配配置模板)。
|
||||||
type: string
|
type: string
|
||||||
type_id:
|
type_id:
|
||||||
|
description: 物料类型 ID(若为空则尝试从配置推断)。
|
||||||
type: string
|
type: string
|
||||||
warehouse_name:
|
warehouse_name:
|
||||||
|
description: 需要入库的仓库名称;若为空则仅创建不入库。
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
- material_name
|
- material_name
|
||||||
@@ -661,15 +668,20 @@ bioyond_cell:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
board_type:
|
board_type:
|
||||||
|
description: 板类型,如 "5ml分液瓶板"、"配液瓶(小)板"
|
||||||
type: string
|
type: string
|
||||||
bottle_type:
|
bottle_type:
|
||||||
|
description: 瓶类型,如 "5ml分液瓶"、"配液瓶(小)"
|
||||||
type: string
|
type: string
|
||||||
location_code:
|
location_code:
|
||||||
|
description: 库位编号,例如 "A01"
|
||||||
type: string
|
type: string
|
||||||
name:
|
name:
|
||||||
|
description: 物料名称
|
||||||
type: string
|
type: string
|
||||||
warehouse_name:
|
warehouse_name:
|
||||||
default: 手动堆栈
|
default: 手动堆栈
|
||||||
|
description: 仓库名称,默认为 "手动堆栈",支持 "自动堆栈-左"、"自动堆栈-右" 等
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
- name
|
- name
|
||||||
@@ -1956,7 +1968,7 @@ bioyond_cell:
|
|||||||
properties:
|
properties:
|
||||||
source_wh_id:
|
source_wh_id:
|
||||||
default: 3a19debc-84b4-0359-e2d4-b3beea49348b
|
default: 3a19debc-84b4-0359-e2d4-b3beea49348b
|
||||||
description: 来源仓库ID
|
description: 来源仓库 Id (默认为3号仓库)
|
||||||
type: string
|
type: string
|
||||||
source_x:
|
source_x:
|
||||||
default: 1
|
default: 1
|
||||||
@@ -2061,9 +2073,11 @@ bioyond_cell:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
order_code:
|
order_code:
|
||||||
|
description: 任务编号
|
||||||
type: string
|
type: string
|
||||||
timeout:
|
timeout:
|
||||||
default: 36000
|
default: 36000
|
||||||
|
description: 超时时间(秒)
|
||||||
type: integer
|
type: integer
|
||||||
required:
|
required:
|
||||||
- order_code
|
- order_code
|
||||||
@@ -2092,12 +2106,15 @@ bioyond_cell:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
order_code:
|
order_code:
|
||||||
|
description: 任务编号
|
||||||
type: string
|
type: string
|
||||||
poll_interval:
|
poll_interval:
|
||||||
default: 0.5
|
default: 0.5
|
||||||
|
description: 轮询间隔(秒),默认 0.5 秒
|
||||||
type: number
|
type: number
|
||||||
timeout:
|
timeout:
|
||||||
default: 36000
|
default: 36000
|
||||||
|
description: 超时时间(秒)
|
||||||
type: integer
|
type: integer
|
||||||
required:
|
required:
|
||||||
- order_code
|
- order_code
|
||||||
@@ -2154,10 +2171,15 @@ bioyond_cell:
|
|||||||
config:
|
config:
|
||||||
properties:
|
properties:
|
||||||
bioyond_config:
|
bioyond_config:
|
||||||
|
description: '从 JSON 文件加载的 bioyond 配置字典
|
||||||
|
|
||||||
|
包含 api_host, api_key, HTTP_host, HTTP_port 等配置'
|
||||||
type: object
|
type: object
|
||||||
deck:
|
deck:
|
||||||
|
description: Deck 配置(可选,会从 JSON 中自动处理)
|
||||||
type: string
|
type: string
|
||||||
protocol_type:
|
protocol_type:
|
||||||
|
description: 协议类型(可选)
|
||||||
type: string
|
type: string
|
||||||
required: []
|
required: []
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -47,8 +47,10 @@ bioyond_dispensing_station:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
report_request:
|
report_request:
|
||||||
|
description: WorkstationReportRequest 对象,包含任务完成信息
|
||||||
type: string
|
type: string
|
||||||
used_materials:
|
used_materials:
|
||||||
|
description: 物料使用记录列表
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
- report_request
|
- report_request
|
||||||
@@ -102,6 +104,7 @@ bioyond_dispensing_station:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
material_name:
|
material_name:
|
||||||
|
description: 物料名称
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
- material_name
|
- material_name
|
||||||
@@ -611,10 +614,10 @@ bioyond_dispensing_station:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
target_device_id:
|
target_device_id:
|
||||||
description: 目标反应站设备ID(从设备列表中选择,所有转移组都使用同一个目标设备)
|
description: 目标反应站设备ID(所有转移组使用同一个设备)
|
||||||
type: string
|
type: string
|
||||||
transfer_groups:
|
transfer_groups:
|
||||||
description: 转移任务组列表,每组包含物料名称、目标堆栈和目标库位,可以添加多组
|
description: '转移任务组列表,每组包含:'
|
||||||
type: array
|
type: array
|
||||||
required:
|
required:
|
||||||
- target_device_id
|
- target_device_id
|
||||||
@@ -694,10 +697,13 @@ bioyond_dispensing_station:
|
|||||||
config:
|
config:
|
||||||
properties:
|
properties:
|
||||||
config:
|
config:
|
||||||
|
description: 配置字典,应包含material_type_mappings等配置
|
||||||
type: object
|
type: object
|
||||||
deck:
|
deck:
|
||||||
|
description: Deck对象
|
||||||
type: string
|
type: string
|
||||||
protocol_type:
|
protocol_type:
|
||||||
|
description: 协议类型(由ROS系统传递,此处忽略)
|
||||||
type: string
|
type: string
|
||||||
required: []
|
required: []
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ coincellassemblyworkstation_device:
|
|||||||
type: integer
|
type: integer
|
||||||
battery_clean_ignore:
|
battery_clean_ignore:
|
||||||
default: false
|
default: false
|
||||||
description: 是否忽略电池清洁步骤
|
description: 是否忽略电池清洁
|
||||||
type: boolean
|
type: boolean
|
||||||
battery_pressure_mode:
|
battery_pressure_mode:
|
||||||
default: true
|
default: true
|
||||||
@@ -170,21 +170,21 @@ coincellassemblyworkstation_device:
|
|||||||
type: integer
|
type: integer
|
||||||
dual_drop_mode:
|
dual_drop_mode:
|
||||||
default: false
|
default: false
|
||||||
description: 电解液添加模式(false=单次滴液, true=二次滴液)
|
description: 电解液添加模式 (False=单次滴液, True=二次滴液)
|
||||||
type: boolean
|
type: boolean
|
||||||
dual_drop_start_timing:
|
dual_drop_start_timing:
|
||||||
default: false
|
default: false
|
||||||
description: 二次滴液开始滴液时机(false=正极片前, true=正极片后)
|
description: 二次滴液开始滴液时机 (False=正极片前, True=正极片后)
|
||||||
type: boolean
|
type: boolean
|
||||||
dual_drop_suction_timing:
|
dual_drop_suction_timing:
|
||||||
default: false
|
default: false
|
||||||
description: 二次滴液吸液时机(false=正常吸液, true=先吸液)
|
description: 二次滴液吸液时机 (False=正常吸液, True=先吸液)
|
||||||
type: boolean
|
type: boolean
|
||||||
elec_num:
|
elec_num:
|
||||||
description: 电解液瓶数
|
description: 电解液瓶数
|
||||||
type: string
|
type: string
|
||||||
elec_use_num:
|
elec_use_num:
|
||||||
description: 每瓶电解液组装电池数
|
description: 每瓶电解液组装的电池数
|
||||||
type: string
|
type: string
|
||||||
elec_vol:
|
elec_vol:
|
||||||
default: 50
|
default: 50
|
||||||
@@ -196,7 +196,7 @@ coincellassemblyworkstation_device:
|
|||||||
type: string
|
type: string
|
||||||
fujipian_juzhendianwei:
|
fujipian_juzhendianwei:
|
||||||
default: 0
|
default: 0
|
||||||
description: 负极片矩阵点位。盘位置从1开始计数,有效范围:1-8, 13-20 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2)
|
description: 负极片矩阵点位
|
||||||
type: integer
|
type: integer
|
||||||
fujipian_panshu:
|
fujipian_panshu:
|
||||||
default: 0
|
default: 0
|
||||||
@@ -204,7 +204,7 @@ coincellassemblyworkstation_device:
|
|||||||
type: integer
|
type: integer
|
||||||
gemo_juzhendianwei:
|
gemo_juzhendianwei:
|
||||||
default: 0
|
default: 0
|
||||||
description: 隔膜矩阵点位。盘位置从1开始计数,有效范围:1-8, 13-20 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2)
|
description: 隔膜矩阵点位
|
||||||
type: integer
|
type: integer
|
||||||
gemopanshu:
|
gemopanshu:
|
||||||
default: 0
|
default: 0
|
||||||
@@ -216,7 +216,7 @@ coincellassemblyworkstation_device:
|
|||||||
type: boolean
|
type: boolean
|
||||||
qiangtou_juzhendianwei:
|
qiangtou_juzhendianwei:
|
||||||
default: 0
|
default: 0
|
||||||
description: 枪头盒矩阵点位。盘位置从1开始计数,有效范围:1-32, 64-96 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2)
|
description: 枪头盒矩阵点位
|
||||||
type: integer
|
type: integer
|
||||||
required:
|
required:
|
||||||
- elec_num
|
- elec_num
|
||||||
@@ -308,7 +308,13 @@ coincellassemblyworkstation_device:
|
|||||||
properties:
|
properties:
|
||||||
material_search_enable:
|
material_search_enable:
|
||||||
default: false
|
default: false
|
||||||
description: 是否启用物料搜寻功能。设备初始化后会弹出物料搜寻确认弹窗,此参数控制自动点击"是"(启用)或"否"(不启用)。默认为false(不启用物料搜寻)
|
description: '是否启用物料搜寻功能。
|
||||||
|
|
||||||
|
设备初始化后会弹出物料搜寻确认弹窗,
|
||||||
|
|
||||||
|
此参数控制自动点击''是''(启用)或''否''(不启用)。
|
||||||
|
|
||||||
|
默认为False(不启用物料搜寻)。'
|
||||||
type: boolean
|
type: boolean
|
||||||
required: []
|
required: []
|
||||||
type: object
|
type: object
|
||||||
@@ -555,7 +561,7 @@ coincellassemblyworkstation_device:
|
|||||||
type: integer
|
type: integer
|
||||||
battery_clean_ignore:
|
battery_clean_ignore:
|
||||||
default: false
|
default: false
|
||||||
description: 是否忽略电池清洁步骤
|
description: 是否忽略电池清洁
|
||||||
type: boolean
|
type: boolean
|
||||||
battery_pressure_mode:
|
battery_pressure_mode:
|
||||||
default: true
|
default: true
|
||||||
@@ -567,21 +573,21 @@ coincellassemblyworkstation_device:
|
|||||||
type: integer
|
type: integer
|
||||||
dual_drop_mode:
|
dual_drop_mode:
|
||||||
default: false
|
default: false
|
||||||
description: 电解液添加模式(false=单次滴液, true=二次滴液)
|
description: 电解液添加模式 (False=单次滴液, True=二次滴液)
|
||||||
type: boolean
|
type: boolean
|
||||||
dual_drop_start_timing:
|
dual_drop_start_timing:
|
||||||
default: false
|
default: false
|
||||||
description: 二次滴液开始滴液时机(false=正极片前, true=正极片后)
|
description: 二次滴液开始滴液时机 (False=正极片前, True=正极片后)
|
||||||
type: boolean
|
type: boolean
|
||||||
dual_drop_suction_timing:
|
dual_drop_suction_timing:
|
||||||
default: false
|
default: false
|
||||||
description: 二次滴液吸液时机(false=正常吸液, true=先吸液)
|
description: 二次滴液吸液时机 (False=正常吸液, True=先吸液)
|
||||||
type: boolean
|
type: boolean
|
||||||
elec_num:
|
elec_num:
|
||||||
description: 电解液瓶数,如果在workflow中已通过handles连接上游(create_orders的bottle_count输出),则此参数会自动从上游获取,无需手动填写;如果单独使用此函数(没有上游连接),则必须手动填写电解液瓶数
|
description: 电解液瓶数
|
||||||
type: string
|
type: string
|
||||||
elec_use_num:
|
elec_use_num:
|
||||||
description: 每瓶电解液组装电池数
|
description: 每瓶电解液组装的电池数
|
||||||
type: string
|
type: string
|
||||||
elec_vol:
|
elec_vol:
|
||||||
default: 50
|
default: 50
|
||||||
@@ -593,7 +599,7 @@ coincellassemblyworkstation_device:
|
|||||||
type: string
|
type: string
|
||||||
fujipian_juzhendianwei:
|
fujipian_juzhendianwei:
|
||||||
default: 0
|
default: 0
|
||||||
description: 负极片矩阵点位。盘位置从1开始计数,有效范围:1-8, 13-20 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2)
|
description: 负极片矩阵点位
|
||||||
type: integer
|
type: integer
|
||||||
fujipian_panshu:
|
fujipian_panshu:
|
||||||
default: 0
|
default: 0
|
||||||
@@ -601,7 +607,7 @@ coincellassemblyworkstation_device:
|
|||||||
type: integer
|
type: integer
|
||||||
gemo_juzhendianwei:
|
gemo_juzhendianwei:
|
||||||
default: 0
|
default: 0
|
||||||
description: 隔膜矩阵点位。盘位置从1开始计数,有效范围:1-8, 13-20 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2)
|
description: 隔膜矩阵点位
|
||||||
type: integer
|
type: integer
|
||||||
gemopanshu:
|
gemopanshu:
|
||||||
default: 0
|
default: 0
|
||||||
@@ -613,7 +619,7 @@ coincellassemblyworkstation_device:
|
|||||||
type: boolean
|
type: boolean
|
||||||
qiangtou_juzhendianwei:
|
qiangtou_juzhendianwei:
|
||||||
default: 0
|
default: 0
|
||||||
description: 枪头盒矩阵点位。盘位置从1开始计数,有效范围:1-32, 64-96 (写入值比实际位置少1,例如:写0取盘位1,写1取盘位2)
|
description: 枪头盒矩阵点位
|
||||||
type: integer
|
type: integer
|
||||||
required:
|
required:
|
||||||
- elec_num
|
- elec_num
|
||||||
|
|||||||
@@ -31,6 +31,6 @@ hotel.thermo_orbitor_rs2_hotel:
|
|||||||
type: object
|
type: object
|
||||||
model:
|
model:
|
||||||
mesh: thermo_orbitor_rs2_hotel
|
mesh: thermo_orbitor_rs2_hotel
|
||||||
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/thermo_orbitor_rs2_hotel/macro_device.xacro
|
path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/thermo_orbitor_rs2_hotel/macro_device.xacro
|
||||||
type: device
|
type: device
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ xyz_stepper_controller:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
degrees:
|
degrees:
|
||||||
|
description: 角度值
|
||||||
type: number
|
type: number
|
||||||
required:
|
required:
|
||||||
- degrees
|
- degrees
|
||||||
@@ -44,6 +45,7 @@ xyz_stepper_controller:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
axis:
|
axis:
|
||||||
|
description: 电机轴
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
- axis
|
- axis
|
||||||
@@ -71,6 +73,7 @@ xyz_stepper_controller:
|
|||||||
properties:
|
properties:
|
||||||
enable:
|
enable:
|
||||||
default: true
|
default: true
|
||||||
|
description: True为使能,False为失能
|
||||||
type: boolean
|
type: boolean
|
||||||
required: []
|
required: []
|
||||||
type: object
|
type: object
|
||||||
@@ -99,9 +102,11 @@ xyz_stepper_controller:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
axis:
|
axis:
|
||||||
|
description: 电机轴
|
||||||
type: object
|
type: object
|
||||||
enable:
|
enable:
|
||||||
default: true
|
default: true
|
||||||
|
description: True为使能,False为失能
|
||||||
type: boolean
|
type: boolean
|
||||||
required:
|
required:
|
||||||
- axis
|
- axis
|
||||||
@@ -152,6 +157,7 @@ xyz_stepper_controller:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
axis:
|
axis:
|
||||||
|
description: 电机轴
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
- axis
|
- axis
|
||||||
@@ -183,16 +189,21 @@ xyz_stepper_controller:
|
|||||||
properties:
|
properties:
|
||||||
acceleration:
|
acceleration:
|
||||||
default: 1000
|
default: 1000
|
||||||
|
description: 加速度(rpm/s)
|
||||||
type: integer
|
type: integer
|
||||||
axis:
|
axis:
|
||||||
|
description: 电机轴
|
||||||
type: object
|
type: object
|
||||||
position:
|
position:
|
||||||
|
description: 目标位置(步数)
|
||||||
type: integer
|
type: integer
|
||||||
precision:
|
precision:
|
||||||
default: 100
|
default: 100
|
||||||
|
description: 到位精度
|
||||||
type: integer
|
type: integer
|
||||||
speed:
|
speed:
|
||||||
default: 5000
|
default: 5000
|
||||||
|
description: 运行速度(rpm)
|
||||||
type: integer
|
type: integer
|
||||||
required:
|
required:
|
||||||
- axis
|
- axis
|
||||||
@@ -225,16 +236,21 @@ xyz_stepper_controller:
|
|||||||
properties:
|
properties:
|
||||||
acceleration:
|
acceleration:
|
||||||
default: 1000
|
default: 1000
|
||||||
|
description: 加速度
|
||||||
type: integer
|
type: integer
|
||||||
axis:
|
axis:
|
||||||
|
description: 电机轴
|
||||||
type: object
|
type: object
|
||||||
degrees:
|
degrees:
|
||||||
|
description: 目标角度(度)
|
||||||
type: number
|
type: number
|
||||||
precision:
|
precision:
|
||||||
default: 100
|
default: 100
|
||||||
|
description: 精度
|
||||||
type: integer
|
type: integer
|
||||||
speed:
|
speed:
|
||||||
default: 5000
|
default: 5000
|
||||||
|
description: 移动速度
|
||||||
type: integer
|
type: integer
|
||||||
required:
|
required:
|
||||||
- axis
|
- axis
|
||||||
@@ -267,16 +283,21 @@ xyz_stepper_controller:
|
|||||||
properties:
|
properties:
|
||||||
acceleration:
|
acceleration:
|
||||||
default: 1000
|
default: 1000
|
||||||
|
description: 加速度
|
||||||
type: integer
|
type: integer
|
||||||
axis:
|
axis:
|
||||||
|
description: 电机轴
|
||||||
type: object
|
type: object
|
||||||
precision:
|
precision:
|
||||||
default: 100
|
default: 100
|
||||||
|
description: 精度
|
||||||
type: integer
|
type: integer
|
||||||
revolutions:
|
revolutions:
|
||||||
|
description: 目标圈数
|
||||||
type: number
|
type: number
|
||||||
speed:
|
speed:
|
||||||
default: 5000
|
default: 5000
|
||||||
|
description: 移动速度
|
||||||
type: integer
|
type: integer
|
||||||
required:
|
required:
|
||||||
- axis
|
- axis
|
||||||
@@ -309,15 +330,20 @@ xyz_stepper_controller:
|
|||||||
properties:
|
properties:
|
||||||
acceleration:
|
acceleration:
|
||||||
default: 1000
|
default: 1000
|
||||||
|
description: 加速度
|
||||||
type: integer
|
type: integer
|
||||||
speed:
|
speed:
|
||||||
default: 5000
|
default: 5000
|
||||||
|
description: 运行速度
|
||||||
type: integer
|
type: integer
|
||||||
x:
|
x:
|
||||||
|
description: X轴目标位置
|
||||||
type: integer
|
type: integer
|
||||||
y:
|
y:
|
||||||
|
description: Y轴目标位置
|
||||||
type: integer
|
type: integer
|
||||||
z:
|
z:
|
||||||
|
description: Z轴目标位置
|
||||||
type: integer
|
type: integer
|
||||||
required: []
|
required: []
|
||||||
type: object
|
type: object
|
||||||
@@ -350,15 +376,20 @@ xyz_stepper_controller:
|
|||||||
properties:
|
properties:
|
||||||
acceleration:
|
acceleration:
|
||||||
default: 1000
|
default: 1000
|
||||||
|
description: 加速度
|
||||||
type: integer
|
type: integer
|
||||||
speed:
|
speed:
|
||||||
default: 5000
|
default: 5000
|
||||||
|
description: 移动速度
|
||||||
type: integer
|
type: integer
|
||||||
x_deg:
|
x_deg:
|
||||||
|
description: X轴目标角度(度)
|
||||||
type: number
|
type: number
|
||||||
y_deg:
|
y_deg:
|
||||||
|
description: Y轴目标角度(度)
|
||||||
type: number
|
type: number
|
||||||
z_deg:
|
z_deg:
|
||||||
|
description: Z轴目标角度(度)
|
||||||
type: number
|
type: number
|
||||||
required: []
|
required: []
|
||||||
type: object
|
type: object
|
||||||
@@ -391,15 +422,20 @@ xyz_stepper_controller:
|
|||||||
properties:
|
properties:
|
||||||
acceleration:
|
acceleration:
|
||||||
default: 1000
|
default: 1000
|
||||||
|
description: 加速度
|
||||||
type: integer
|
type: integer
|
||||||
speed:
|
speed:
|
||||||
default: 5000
|
default: 5000
|
||||||
|
description: 移动速度
|
||||||
type: integer
|
type: integer
|
||||||
x_rev:
|
x_rev:
|
||||||
|
description: X轴目标圈数
|
||||||
type: number
|
type: number
|
||||||
y_rev:
|
y_rev:
|
||||||
|
description: Y轴目标圈数
|
||||||
type: number
|
type: number
|
||||||
z_rev:
|
z_rev:
|
||||||
|
description: Z轴目标圈数
|
||||||
type: number
|
type: number
|
||||||
required: []
|
required: []
|
||||||
type: object
|
type: object
|
||||||
@@ -427,6 +463,7 @@ xyz_stepper_controller:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
revolutions:
|
revolutions:
|
||||||
|
description: 圈数
|
||||||
type: number
|
type: number
|
||||||
required:
|
required:
|
||||||
- revolutions
|
- revolutions
|
||||||
@@ -456,10 +493,13 @@ xyz_stepper_controller:
|
|||||||
properties:
|
properties:
|
||||||
acceleration:
|
acceleration:
|
||||||
default: 1000
|
default: 1000
|
||||||
|
description: 加速度(rpm/s)
|
||||||
type: integer
|
type: integer
|
||||||
axis:
|
axis:
|
||||||
|
description: 电机轴
|
||||||
type: object
|
type: object
|
||||||
speed:
|
speed:
|
||||||
|
description: 运行速度(rpm),正值正转,负值反转
|
||||||
type: integer
|
type: integer
|
||||||
required:
|
required:
|
||||||
- axis
|
- axis
|
||||||
@@ -487,6 +527,7 @@ xyz_stepper_controller:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
steps:
|
steps:
|
||||||
|
description: 步数
|
||||||
type: integer
|
type: integer
|
||||||
required:
|
required:
|
||||||
- steps
|
- steps
|
||||||
@@ -513,6 +554,7 @@ xyz_stepper_controller:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
steps:
|
steps:
|
||||||
|
description: 步数
|
||||||
type: integer
|
type: integer
|
||||||
required:
|
required:
|
||||||
- steps
|
- steps
|
||||||
@@ -564,9 +606,11 @@ xyz_stepper_controller:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
axis:
|
axis:
|
||||||
|
description: 电机轴
|
||||||
type: object
|
type: object
|
||||||
timeout:
|
timeout:
|
||||||
default: 30.0
|
default: 30.0
|
||||||
|
description: 超时时间(秒)
|
||||||
type: number
|
type: number
|
||||||
required:
|
required:
|
||||||
- axis
|
- axis
|
||||||
@@ -591,11 +635,14 @@ xyz_stepper_controller:
|
|||||||
properties:
|
properties:
|
||||||
baudrate:
|
baudrate:
|
||||||
default: 115200
|
default: 115200
|
||||||
|
description: 波特率
|
||||||
type: integer
|
type: integer
|
||||||
port:
|
port:
|
||||||
|
description: 串口端口名
|
||||||
type: string
|
type: string
|
||||||
timeout:
|
timeout:
|
||||||
default: 1.0
|
default: 1.0
|
||||||
|
description: 通信超时时间
|
||||||
type: number
|
type: number
|
||||||
required:
|
required:
|
||||||
- port
|
- port
|
||||||
|
|||||||
@@ -510,9 +510,11 @@ liquid_handler:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
msg:
|
msg:
|
||||||
|
description: information to be printed
|
||||||
type: string
|
type: string
|
||||||
seconds:
|
seconds:
|
||||||
default: 0
|
default: 0
|
||||||
|
description: seconds to wait
|
||||||
type: string
|
type: string
|
||||||
required: []
|
required: []
|
||||||
type: object
|
type: object
|
||||||
@@ -848,9 +850,11 @@ liquid_handler:
|
|||||||
plate: null
|
plate: null
|
||||||
volumes: null
|
volumes: null
|
||||||
well_names: null
|
well_names: null
|
||||||
|
wells: null
|
||||||
handles: {}
|
handles: {}
|
||||||
placeholder_keys:
|
placeholder_keys:
|
||||||
plate: unilabos_resources
|
plate: unilabos_resources
|
||||||
|
wells: unilabos_resources
|
||||||
result: {}
|
result: {}
|
||||||
schema:
|
schema:
|
||||||
description: ''
|
description: ''
|
||||||
@@ -950,9 +954,89 @@ liquid_handler:
|
|||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
type: array
|
type: array
|
||||||
|
wells:
|
||||||
|
items:
|
||||||
|
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: well
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
required:
|
required:
|
||||||
- plate
|
|
||||||
- well_names
|
|
||||||
- liquid_names
|
- liquid_names
|
||||||
- volumes
|
- volumes
|
||||||
type: object
|
type: object
|
||||||
@@ -2963,15 +3047,22 @@ liquid_handler:
|
|||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
properties:
|
properties:
|
||||||
channel:
|
channel:
|
||||||
|
description: int
|
||||||
maximum: 2147483647
|
maximum: 2147483647
|
||||||
minimum: -2147483648
|
minimum: -2147483648
|
||||||
type: integer
|
type: integer
|
||||||
dis_to_top:
|
dis_to_top:
|
||||||
|
description: 'float
|
||||||
|
|
||||||
|
Height in mm to move to relative to the well top.'
|
||||||
maximum: 1.7976931348623157e+308
|
maximum: 1.7976931348623157e+308
|
||||||
minimum: -1.7976931348623157e+308
|
minimum: -1.7976931348623157e+308
|
||||||
type: number
|
type: number
|
||||||
well:
|
well:
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
|
description: 'Well
|
||||||
|
|
||||||
|
The target well.'
|
||||||
properties:
|
properties:
|
||||||
category:
|
category:
|
||||||
type: string
|
type: string
|
||||||
@@ -4829,11 +4920,13 @@ liquid_handler:
|
|||||||
config:
|
config:
|
||||||
properties:
|
properties:
|
||||||
backend:
|
backend:
|
||||||
|
description: Backend to use.
|
||||||
type: object
|
type: object
|
||||||
channel_num:
|
channel_num:
|
||||||
default: 8
|
default: 8
|
||||||
type: integer
|
type: integer
|
||||||
deck:
|
deck:
|
||||||
|
description: Deck to use.
|
||||||
type: object
|
type: object
|
||||||
simulator:
|
simulator:
|
||||||
default: false
|
default: false
|
||||||
@@ -4883,14 +4976,17 @@ liquid_handler.biomek:
|
|||||||
bind_parent_id:
|
bind_parent_id:
|
||||||
type: string
|
type: string
|
||||||
liquid_input_slot:
|
liquid_input_slot:
|
||||||
|
description: 液体输入槽列表
|
||||||
items:
|
items:
|
||||||
type: integer
|
type: integer
|
||||||
type: array
|
type: array
|
||||||
liquid_type:
|
liquid_type:
|
||||||
|
description: 液体类型列表
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
type: array
|
type: array
|
||||||
liquid_volume:
|
liquid_volume:
|
||||||
|
description: 液体体积列表
|
||||||
items:
|
items:
|
||||||
type: integer
|
type: integer
|
||||||
type: array
|
type: array
|
||||||
@@ -4901,6 +4997,7 @@ liquid_handler.biomek:
|
|||||||
type: object
|
type: object
|
||||||
type: array
|
type: array
|
||||||
slot_on_deck:
|
slot_on_deck:
|
||||||
|
description: 甲板上的槽位
|
||||||
type: integer
|
type: integer
|
||||||
required:
|
required:
|
||||||
- resource_tracker
|
- resource_tracker
|
||||||
@@ -5036,20 +5133,27 @@ liquid_handler.biomek:
|
|||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
properties:
|
properties:
|
||||||
none_keys:
|
none_keys:
|
||||||
|
description: 需要设置为None的键列表
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
type: array
|
type: array
|
||||||
protocol_author:
|
protocol_author:
|
||||||
|
description: 协议作者
|
||||||
type: string
|
type: string
|
||||||
protocol_date:
|
protocol_date:
|
||||||
|
description: 协议日期
|
||||||
type: string
|
type: string
|
||||||
protocol_description:
|
protocol_description:
|
||||||
|
description: 协议描述
|
||||||
type: string
|
type: string
|
||||||
protocol_name:
|
protocol_name:
|
||||||
|
description: 协议名称
|
||||||
type: string
|
type: string
|
||||||
protocol_type:
|
protocol_type:
|
||||||
|
description: 协议类型
|
||||||
type: string
|
type: string
|
||||||
protocol_version:
|
protocol_version:
|
||||||
|
description: 协议版本
|
||||||
type: string
|
type: string
|
||||||
title: LiquidHandlerProtocolCreation_Goal
|
title: LiquidHandlerProtocolCreation_Goal
|
||||||
type: object
|
type: object
|
||||||
@@ -9351,13 +9455,19 @@ liquid_handler.prcxi:
|
|||||||
plate: null
|
plate: null
|
||||||
volumes: null
|
volumes: null
|
||||||
well_names: null
|
well_names: null
|
||||||
|
wells: null
|
||||||
handles:
|
handles:
|
||||||
input:
|
input:
|
||||||
|
- data_key: '@this.0@@@wells'
|
||||||
|
data_source: handle
|
||||||
|
data_type: resource
|
||||||
|
handler_key: wells_identifier
|
||||||
|
label: 待设定液体孔(框选)
|
||||||
- data_key: '@this.0@@@plate'
|
- data_key: '@this.0@@@plate'
|
||||||
data_source: handle
|
data_source: handle
|
||||||
data_type: resource
|
data_type: resource
|
||||||
handler_key: input_plate
|
handler_key: input_plate
|
||||||
label: 待设定液体板
|
label: 待设定液体板(兼容 fallback)
|
||||||
output:
|
output:
|
||||||
- data_key: plate.@flatten
|
- data_key: plate.@flatten
|
||||||
data_source: executor
|
data_source: executor
|
||||||
@@ -9376,6 +9486,7 @@ liquid_handler.prcxi:
|
|||||||
label: 各孔设定体积
|
label: 各孔设定体积
|
||||||
placeholder_keys:
|
placeholder_keys:
|
||||||
plate: unilabos_resources
|
plate: unilabos_resources
|
||||||
|
wells: unilabos_resources
|
||||||
result: {}
|
result: {}
|
||||||
schema:
|
schema:
|
||||||
description: ''
|
description: ''
|
||||||
@@ -9475,9 +9586,89 @@ liquid_handler.prcxi:
|
|||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
type: array
|
type: array
|
||||||
|
wells:
|
||||||
|
items:
|
||||||
|
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: well
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
required:
|
required:
|
||||||
- plate
|
|
||||||
- well_names
|
|
||||||
- liquid_names
|
- liquid_names
|
||||||
- volumes
|
- volumes
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ neware_battery_test_system:
|
|||||||
properties:
|
properties:
|
||||||
filepath:
|
filepath:
|
||||||
default: bts_status.json
|
default: bts_status.json
|
||||||
description: 输出JSON文件路径
|
description: 输出文件路径
|
||||||
type: string
|
type: string
|
||||||
required: []
|
required: []
|
||||||
type: object
|
type: object
|
||||||
@@ -146,7 +146,7 @@ neware_battery_test_system:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
plate_num:
|
plate_num:
|
||||||
description: 盘号 (1 或 2),如果为null则返回所有盘的状态
|
description: 盘号 (1 或 2),如果为None则返回所有盘的状态
|
||||||
type: integer
|
type: integer
|
||||||
required: []
|
required: []
|
||||||
type: object
|
type: object
|
||||||
@@ -237,11 +237,11 @@ neware_battery_test_system:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
csv_path:
|
csv_path:
|
||||||
description: 输入CSV文件的绝对路径
|
description: 输入CSV文件路径
|
||||||
type: string
|
type: string
|
||||||
output_dir:
|
output_dir:
|
||||||
default: .
|
default: .
|
||||||
description: 输出目录(用于存储XML和备份文件),默认当前目录
|
description: 输出目录,用于存储XML文件和备份,默认当前目录
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
- csv_path
|
- csv_path
|
||||||
@@ -302,14 +302,14 @@ neware_battery_test_system:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
backup_dir:
|
backup_dir:
|
||||||
description: 备份目录路径(默认使用最近一次submit_from_csv的backup_dir)
|
description: 备份目录路径,默认使用最近一次 submit_from_csv 的 backup_dir
|
||||||
type: string
|
type: string
|
||||||
file_pattern:
|
file_pattern:
|
||||||
default: '*'
|
default: '*'
|
||||||
description: 文件通配符模式,例如 *.csv 或 Battery_*.nda
|
description: 文件通配符模式,默认 "*" 上传所有文件(例如 "*.csv" 仅上传 CSV 文件)
|
||||||
type: string
|
type: string
|
||||||
oss_prefix:
|
oss_prefix:
|
||||||
description: OSS对象路径前缀(默认使用self.oss_prefix)
|
description: OSS 对象前缀,默认使用类初始化时的配置
|
||||||
type: string
|
type: string
|
||||||
required: []
|
required: []
|
||||||
type: object
|
type: object
|
||||||
@@ -336,19 +336,25 @@ neware_battery_test_system:
|
|||||||
config:
|
config:
|
||||||
properties:
|
properties:
|
||||||
devtype:
|
devtype:
|
||||||
|
description: 设备类型标识
|
||||||
type: string
|
type: string
|
||||||
ip:
|
ip:
|
||||||
|
description: TCP服务器IP地址
|
||||||
type: string
|
type: string
|
||||||
machine_id:
|
machine_id:
|
||||||
default: 1
|
default: 1
|
||||||
|
description: 机器ID
|
||||||
type: integer
|
type: integer
|
||||||
oss_prefix:
|
oss_prefix:
|
||||||
default: neware_backup
|
default: neware_backup
|
||||||
|
description: OSS对象路径前缀,默认"neware_backup"
|
||||||
type: string
|
type: string
|
||||||
oss_upload_enabled:
|
oss_upload_enabled:
|
||||||
default: false
|
default: false
|
||||||
|
description: 是否启用OSS上传功能,默认False
|
||||||
type: boolean
|
type: boolean
|
||||||
port:
|
port:
|
||||||
|
description: TCP端口
|
||||||
type: integer
|
type: integer
|
||||||
size_x:
|
size_x:
|
||||||
default: 50
|
default: 50
|
||||||
@@ -360,6 +366,7 @@ neware_battery_test_system:
|
|||||||
default: 20
|
default: 20
|
||||||
type: number
|
type: number
|
||||||
timeout:
|
timeout:
|
||||||
|
description: 通信超时时间(秒)
|
||||||
type: integer
|
type: integer
|
||||||
required: []
|
required: []
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -207,8 +207,12 @@ separator.homemade:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
condition:
|
condition:
|
||||||
|
description: The condition to be monitored, either 'delta' or 'time'.
|
||||||
type: string
|
type: string
|
||||||
value:
|
value:
|
||||||
|
description: 'The threshold value for the condition.
|
||||||
|
|
||||||
|
`delta > 0.05`, `time > 60`'
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
- condition
|
- condition
|
||||||
@@ -305,12 +309,17 @@ separator.homemade:
|
|||||||
event:
|
event:
|
||||||
type: string
|
type: string
|
||||||
settling_time:
|
settling_time:
|
||||||
|
description: The duration for which to settle after stirring, in
|
||||||
|
seconds. Defaults to 10.
|
||||||
type: string
|
type: string
|
||||||
stir_speed:
|
stir_speed:
|
||||||
|
description: The speed of stirring, in RPM. Defaults to 300.
|
||||||
maximum: 1.7976931348623157e+308
|
maximum: 1.7976931348623157e+308
|
||||||
minimum: -1.7976931348623157e+308
|
minimum: -1.7976931348623157e+308
|
||||||
type: number
|
type: number
|
||||||
stir_time:
|
stir_time:
|
||||||
|
description: The duration for which to stir, in seconds. Defaults
|
||||||
|
to 10.
|
||||||
maximum: 1.7976931348623157e+308
|
maximum: 1.7976931348623157e+308
|
||||||
minimum: -1.7976931348623157e+308
|
minimum: -1.7976931348623157e+308
|
||||||
type: number
|
type: number
|
||||||
|
|||||||
@@ -456,6 +456,7 @@ syringe_pump_with_valve.runze.SY03B-T06:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
volume:
|
volume:
|
||||||
|
description: 'absolute position of the plunger, unit: mL'
|
||||||
type: number
|
type: number
|
||||||
required:
|
required:
|
||||||
- volume
|
- volume
|
||||||
@@ -481,6 +482,7 @@ syringe_pump_with_valve.runze.SY03B-T06:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
volume:
|
volume:
|
||||||
|
description: 'absolute position of the plunger, unit: mL'
|
||||||
type: number
|
type: number
|
||||||
required:
|
required:
|
||||||
- volume
|
- volume
|
||||||
@@ -687,8 +689,10 @@ syringe_pump_with_valve.runze.SY03B-T06:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
max_velocity:
|
max_velocity:
|
||||||
|
description: 'maximum velocity of the plunger, unit: ml/s'
|
||||||
type: number
|
type: number
|
||||||
position:
|
position:
|
||||||
|
description: 'absolute position of the plunger, unit: ml'
|
||||||
type: number
|
type: number
|
||||||
required:
|
required:
|
||||||
- position
|
- position
|
||||||
@@ -1003,6 +1007,7 @@ syringe_pump_with_valve.runze.SY03B-T08:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
volume:
|
volume:
|
||||||
|
description: 'absolute position of the plunger, unit: mL'
|
||||||
type: number
|
type: number
|
||||||
required:
|
required:
|
||||||
- volume
|
- volume
|
||||||
@@ -1028,6 +1033,7 @@ syringe_pump_with_valve.runze.SY03B-T08:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
volume:
|
volume:
|
||||||
|
description: 'absolute position of the plunger, unit: mL'
|
||||||
type: number
|
type: number
|
||||||
required:
|
required:
|
||||||
- volume
|
- volume
|
||||||
@@ -1234,8 +1240,10 @@ syringe_pump_with_valve.runze.SY03B-T08:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
max_velocity:
|
max_velocity:
|
||||||
|
description: 'maximum velocity of the plunger, unit: ml/s'
|
||||||
type: number
|
type: number
|
||||||
position:
|
position:
|
||||||
|
description: 'absolute position of the plunger, unit: ml'
|
||||||
type: number
|
type: number
|
||||||
required:
|
required:
|
||||||
- position
|
- position
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ reaction_station.bioyond:
|
|||||||
type: integer
|
type: integer
|
||||||
end_point:
|
end_point:
|
||||||
default: 0
|
default: 0
|
||||||
description: 终点计时点 (Start=开始前, End=结束后)
|
description: 终点计时点 (Start=0, End=1)
|
||||||
type: integer
|
type: integer
|
||||||
end_step_key:
|
end_step_key:
|
||||||
default: ''
|
default: ''
|
||||||
@@ -40,11 +40,11 @@ reaction_station.bioyond:
|
|||||||
type: string
|
type: string
|
||||||
start_point:
|
start_point:
|
||||||
default: 0
|
default: 0
|
||||||
description: 起点计时点 (Start=开始前, End=结束后)
|
description: 起点计时点 (Start=0, End=1)
|
||||||
type: integer
|
type: integer
|
||||||
start_step_key:
|
start_step_key:
|
||||||
default: ''
|
default: ''
|
||||||
description: 起点步骤Key (例如 "feeding", "liquid", 可选, 默认为空则自动选择)
|
description: 起点步骤Key (可选, 默认为空则自动选择)
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
- duration
|
- duration
|
||||||
@@ -91,6 +91,7 @@ reaction_station.bioyond:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
json_str:
|
json_str:
|
||||||
|
description: 订单参数的JSON字符串
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
- json_str
|
- json_str
|
||||||
@@ -117,6 +118,7 @@ reaction_station.bioyond:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
workflow_ids:
|
workflow_ids:
|
||||||
|
description: 要删除的工作流ID数组
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
type: array
|
type: array
|
||||||
@@ -145,6 +147,7 @@ reaction_station.bioyond:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
json_str:
|
json_str:
|
||||||
|
description: 'JSON格式的字符串,包含:'
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
- json_str
|
- json_str
|
||||||
@@ -197,6 +200,7 @@ reaction_station.bioyond:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
web_workflow_json:
|
web_workflow_json:
|
||||||
|
description: JSON 格式的网页工作流列表
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
- web_workflow_json
|
- web_workflow_json
|
||||||
@@ -228,8 +232,10 @@ reaction_station.bioyond:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
reactor_id:
|
reactor_id:
|
||||||
|
description: 反应器编号 (1-5)
|
||||||
type: integer
|
type: integer
|
||||||
temperature:
|
temperature:
|
||||||
|
description: 目标温度 (°C)
|
||||||
type: number
|
type: number
|
||||||
required:
|
required:
|
||||||
- reactor_id
|
- reactor_id
|
||||||
@@ -257,6 +263,7 @@ reaction_station.bioyond:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
preintake_id:
|
preintake_id:
|
||||||
|
description: 通量ID
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
- preintake_id
|
- preintake_id
|
||||||
@@ -338,6 +345,7 @@ reaction_station.bioyond:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
value:
|
value:
|
||||||
|
description: 工作流 ID 列表
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
type: array
|
type: array
|
||||||
@@ -365,6 +373,7 @@ reaction_station.bioyond:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
workflow_id:
|
workflow_id:
|
||||||
|
description: 工作流ID
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
- workflow_id
|
- workflow_id
|
||||||
@@ -424,11 +433,11 @@ reaction_station.bioyond:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
assign_material_name:
|
assign_material_name:
|
||||||
description: 物料名称(不能为空)
|
description: 物料名称(液体种类)
|
||||||
type: string
|
type: string
|
||||||
temperature:
|
temperature:
|
||||||
default: 25.0
|
default: 25.0
|
||||||
description: 温度设定(°C)
|
description: 温度(C)
|
||||||
type: number
|
type: number
|
||||||
time:
|
time:
|
||||||
default: '90'
|
default: '90'
|
||||||
@@ -436,14 +445,14 @@ reaction_station.bioyond:
|
|||||||
type: string
|
type: string
|
||||||
titration_type:
|
titration_type:
|
||||||
default: '1'
|
default: '1'
|
||||||
description: 是否滴定(NO=否, YES=是)
|
description: 是否滴定(NO=1, YES=2)
|
||||||
type: string
|
type: string
|
||||||
torque_variation:
|
torque_variation:
|
||||||
default: 2
|
default: 2
|
||||||
description: 是否观察 (NO=否, YES=是)
|
description: 是否观察(NO=1, YES=2)
|
||||||
type: integer
|
type: integer
|
||||||
volume:
|
volume:
|
||||||
description: 分液公式(mL)
|
description: 分液量(μL)
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
- assign_material_name
|
- assign_material_name
|
||||||
@@ -525,11 +534,11 @@ reaction_station.bioyond:
|
|||||||
properties:
|
properties:
|
||||||
assign_material_name:
|
assign_material_name:
|
||||||
default: BAPP
|
default: BAPP
|
||||||
description: 物料名称
|
description: 物料名称(试剂瓶位)
|
||||||
type: string
|
type: string
|
||||||
temperature:
|
temperature:
|
||||||
default: 25.0
|
default: 25.0
|
||||||
description: 温度设定(°C)
|
description: 温度设定(C)
|
||||||
type: number
|
type: number
|
||||||
time:
|
time:
|
||||||
default: '0'
|
default: '0'
|
||||||
@@ -537,15 +546,15 @@ reaction_station.bioyond:
|
|||||||
type: string
|
type: string
|
||||||
titration_type:
|
titration_type:
|
||||||
default: '1'
|
default: '1'
|
||||||
description: 是否滴定(NO=否, YES=是)
|
description: 是否滴定(NO=1, YES=2)
|
||||||
type: string
|
type: string
|
||||||
torque_variation:
|
torque_variation:
|
||||||
default: 1
|
default: 1
|
||||||
description: 是否观察 (NO=否, YES=是)
|
description: 是否观察(int类型, 1=否, 2=是)
|
||||||
type: integer
|
type: integer
|
||||||
volume:
|
volume:
|
||||||
default: '350'
|
default: '350'
|
||||||
description: 分液公式(mL)
|
description: 分液质量(g)
|
||||||
type: string
|
type: string
|
||||||
required: []
|
required: []
|
||||||
type: object
|
type: object
|
||||||
@@ -593,26 +602,28 @@ reaction_station.bioyond:
|
|||||||
description: 物料名称
|
description: 物料名称
|
||||||
type: string
|
type: string
|
||||||
solvents:
|
solvents:
|
||||||
description: '溶剂信息对象(可选),包含: additional_solvent(溶剂体积mL), total_liquid_volume(总液体体积mL)。如果提供,将自动计算volume'
|
description: '溶剂信息的字典或JSON字符串(可选),格式如下:
|
||||||
|
|
||||||
|
{'
|
||||||
type: string
|
type: string
|
||||||
temperature:
|
temperature:
|
||||||
default: 25.0
|
default: 25.0
|
||||||
description: 温度设定(°C),默认25.00
|
description: 温度设定(C)
|
||||||
type: number
|
type: number
|
||||||
time:
|
time:
|
||||||
default: '360'
|
default: '360'
|
||||||
description: 观察时间(分钟),默认360
|
description: 观察时间(分钟)
|
||||||
type: string
|
type: string
|
||||||
titration_type:
|
titration_type:
|
||||||
default: '1'
|
default: '1'
|
||||||
description: 是否滴定(NO=否, YES=是),默认NO
|
description: 是否滴定(NO=1, YES=2)
|
||||||
type: string
|
type: string
|
||||||
torque_variation:
|
torque_variation:
|
||||||
default: 2
|
default: 2
|
||||||
description: 是否观察 (NO=否, YES=是),默认YES
|
description: 是否观察(NO=1, YES=2)
|
||||||
type: integer
|
type: integer
|
||||||
volume:
|
volume:
|
||||||
description: 分液量(mL)。可直接提供,或通过solvents参数自动计算
|
description: 分液量(μL),直接指定体积(可选,如果提供solvents则自动计算)
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
- assign_material_name
|
- assign_material_name
|
||||||
@@ -671,33 +682,32 @@ reaction_station.bioyond:
|
|||||||
description: 物料名称
|
description: 物料名称
|
||||||
type: string
|
type: string
|
||||||
extracted_actuals:
|
extracted_actuals:
|
||||||
description: 从报告提取的实际加料量JSON字符串,包含actualTargetWeigh(m二酐滴定)和actualVolume(V二酐滴定)
|
description: 从报告提取的实际加料量JSON字符串,包含actualTargetWeigh和actualVolume
|
||||||
type: string
|
type: string
|
||||||
feeding_order_data:
|
feeding_order_data:
|
||||||
description: 'feeding_order JSON对象,用于获取m二酐值(type为main_anhydride的amount)。示例:
|
description: feeding_order JSON字符串或对象,用于获取m二酐值
|
||||||
{"feeding_order": [{"type": "main_anhydride", "amount": 1.915}]}'
|
|
||||||
type: string
|
type: string
|
||||||
temperature:
|
temperature:
|
||||||
default: 25.0
|
default: 25.0
|
||||||
description: 温度设定(°C),默认25.00
|
description: 温度(C)
|
||||||
type: number
|
type: number
|
||||||
time:
|
time:
|
||||||
default: '90'
|
default: '90'
|
||||||
description: 观察时间(分钟),默认90
|
description: 观察时间(分钟)
|
||||||
type: string
|
type: string
|
||||||
titration_type:
|
titration_type:
|
||||||
default: '2'
|
default: '2'
|
||||||
description: 是否滴定(NO=否, YES=是),默认YES
|
description: 是否滴定(NO=1, YES=2),默认2
|
||||||
type: string
|
type: string
|
||||||
torque_variation:
|
torque_variation:
|
||||||
default: 2
|
default: 2
|
||||||
description: 是否观察 (NO=否, YES=是),默认YES
|
description: 是否观察(NO=1, YES=2)
|
||||||
type: integer
|
type: integer
|
||||||
volume_formula:
|
volume_formula:
|
||||||
description: 分液公式(mL)。可直接提供固定公式,或留空由系统根据x_value、feeding_order_data、extracted_actuals自动生成
|
description: 分液公式(μL),如果提供则直接使用,否则自动计算
|
||||||
type: string
|
type: string
|
||||||
x_value:
|
x_value:
|
||||||
description: 公式中的x值,手工输入,格式为"{{1-2-3}}"(包含双花括号)。用于自动公式计算
|
description: 手工输入的x值,格式如 "1-2-3"
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
- assign_material_name
|
- assign_material_name
|
||||||
@@ -738,7 +748,7 @@ reaction_station.bioyond:
|
|||||||
type: string
|
type: string
|
||||||
temperature:
|
temperature:
|
||||||
default: 25.0
|
default: 25.0
|
||||||
description: 温度设定(°C)
|
description: 温度(C)
|
||||||
type: number
|
type: number
|
||||||
time:
|
time:
|
||||||
default: '0'
|
default: '0'
|
||||||
@@ -746,14 +756,14 @@ reaction_station.bioyond:
|
|||||||
type: string
|
type: string
|
||||||
titration_type:
|
titration_type:
|
||||||
default: '1'
|
default: '1'
|
||||||
description: 是否滴定(NO=否, YES=是)
|
description: 是否滴定(NO=1, YES=2)
|
||||||
type: string
|
type: string
|
||||||
torque_variation:
|
torque_variation:
|
||||||
default: 1
|
default: 1
|
||||||
description: 是否观察 (NO=否, YES=是)
|
description: 是否观察(NO=1, YES=2)
|
||||||
type: integer
|
type: integer
|
||||||
volume_formula:
|
volume_formula:
|
||||||
description: 分液公式(mL)
|
description: 分液公式(μL)
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
- volume_formula
|
- volume_formula
|
||||||
@@ -786,7 +796,7 @@ reaction_station.bioyond:
|
|||||||
description: 任务名称
|
description: 任务名称
|
||||||
type: string
|
type: string
|
||||||
workflow_name:
|
workflow_name:
|
||||||
description: 工作流名称
|
description: 合并后的工作流名称
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
- workflow_name
|
- workflow_name
|
||||||
@@ -819,15 +829,15 @@ reaction_station.bioyond:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
assign_material_name:
|
assign_material_name:
|
||||||
description: 物料名称
|
description: 物料名称(不能为空)
|
||||||
type: string
|
type: string
|
||||||
cutoff:
|
cutoff:
|
||||||
default: '900000'
|
default: '900000'
|
||||||
description: 粘度上限
|
description: 粘度上限(需为有效数字字符串,默认 "900000")
|
||||||
type: string
|
type: string
|
||||||
temperature:
|
temperature:
|
||||||
default: -10.0
|
default: -10.0
|
||||||
description: 温度设定(°C)
|
description: 温度设定(C,范围:-50.00 至 100.00)
|
||||||
type: number
|
type: number
|
||||||
required:
|
required:
|
||||||
- assign_material_name
|
- assign_material_name
|
||||||
@@ -909,11 +919,11 @@ reaction_station.bioyond:
|
|||||||
description: 物料名称(用于获取试剂瓶位ID)
|
description: 物料名称(用于获取试剂瓶位ID)
|
||||||
type: string
|
type: string
|
||||||
material_id:
|
material_id:
|
||||||
description: 粉末类型ID,Salt=盐(21分钟),Flour=面粉(27分钟),BTDA=BTDA(38分钟)
|
description: 粉末类型ID, Salt=1, Flour=2, BTDA=3
|
||||||
type: string
|
type: string
|
||||||
temperature:
|
temperature:
|
||||||
default: 25.0
|
default: 25.0
|
||||||
description: 温度设定(°C)
|
description: 温度设定(C)
|
||||||
type: number
|
type: number
|
||||||
time:
|
time:
|
||||||
default: '0'
|
default: '0'
|
||||||
@@ -921,7 +931,7 @@ reaction_station.bioyond:
|
|||||||
type: string
|
type: string
|
||||||
torque_variation:
|
torque_variation:
|
||||||
default: 1
|
default: 1
|
||||||
description: 是否观察 (NO=否, YES=是)
|
description: 是否观察(NO=1, YES=2)
|
||||||
type: integer
|
type: integer
|
||||||
required:
|
required:
|
||||||
- material_id
|
- material_id
|
||||||
@@ -945,10 +955,13 @@ reaction_station.bioyond:
|
|||||||
config:
|
config:
|
||||||
properties:
|
properties:
|
||||||
config:
|
config:
|
||||||
|
description: 配置字典,应包含workflow_mappings等配置
|
||||||
type: object
|
type: object
|
||||||
deck:
|
deck:
|
||||||
|
description: Deck对象
|
||||||
type: string
|
type: string
|
||||||
protocol_type:
|
protocol_type:
|
||||||
|
description: 协议类型(由ROS系统传递,此处忽略)
|
||||||
type: string
|
type: string
|
||||||
required: []
|
required: []
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -173,48 +173,64 @@ robotic_arm.SCARA_with_slider.moveit.virtual:
|
|||||||
type: object
|
type: object
|
||||||
type: UniLabJsonCommand
|
type: UniLabJsonCommand
|
||||||
pick_and_place:
|
pick_and_place:
|
||||||
feedback:
|
feedback: {}
|
||||||
status: status
|
goal: {}
|
||||||
goal:
|
|
||||||
command: command
|
|
||||||
goal_default:
|
goal_default:
|
||||||
command: ''
|
constraints: null
|
||||||
|
lift_height: null
|
||||||
|
move_group: null
|
||||||
|
option: null
|
||||||
|
resource: null
|
||||||
|
retry: null
|
||||||
|
speed: null
|
||||||
|
status: null
|
||||||
|
target: null
|
||||||
|
x_distance: null
|
||||||
|
y_distance: null
|
||||||
handles: {}
|
handles: {}
|
||||||
placeholder_keys: {}
|
placeholder_keys: {}
|
||||||
result:
|
result: {}
|
||||||
return_info: return_info
|
|
||||||
success: success
|
|
||||||
schema:
|
schema:
|
||||||
description: ''
|
description: pick_and_place 显式参数(UniLabJsonCommand)
|
||||||
properties:
|
properties:
|
||||||
feedback:
|
feedback: {}
|
||||||
additionalProperties: false
|
goal:
|
||||||
properties:
|
properties:
|
||||||
|
constraints:
|
||||||
|
items:
|
||||||
|
type: number
|
||||||
|
type: array
|
||||||
|
lift_height:
|
||||||
|
type: string
|
||||||
|
move_group:
|
||||||
|
type: string
|
||||||
|
option:
|
||||||
|
type: string
|
||||||
|
resource:
|
||||||
|
type: string
|
||||||
|
retry:
|
||||||
|
type: string
|
||||||
|
speed:
|
||||||
|
type: string
|
||||||
status:
|
status:
|
||||||
type: string
|
type: string
|
||||||
title: SendCmd_Feedback
|
target:
|
||||||
type: object
|
|
||||||
goal:
|
|
||||||
additionalProperties: false
|
|
||||||
properties:
|
|
||||||
command:
|
|
||||||
type: string
|
type: string
|
||||||
title: SendCmd_Goal
|
x_distance:
|
||||||
type: object
|
|
||||||
result:
|
|
||||||
additionalProperties: false
|
|
||||||
properties:
|
|
||||||
return_info:
|
|
||||||
type: string
|
type: string
|
||||||
success:
|
y_distance:
|
||||||
type: boolean
|
type: string
|
||||||
title: SendCmd_Result
|
required:
|
||||||
|
- option
|
||||||
|
- move_group
|
||||||
|
- status
|
||||||
type: object
|
type: object
|
||||||
|
result: {}
|
||||||
required:
|
required:
|
||||||
- goal
|
- goal
|
||||||
title: SendCmd
|
title: pick_and_place参数
|
||||||
type: object
|
type: object
|
||||||
type: SendCmd
|
type: UniLabJsonCommand
|
||||||
set_position:
|
set_position:
|
||||||
feedback:
|
feedback:
|
||||||
status: status
|
status: status
|
||||||
@@ -241,6 +257,8 @@ robotic_arm.SCARA_with_slider.moveit.virtual:
|
|||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
properties:
|
properties:
|
||||||
command:
|
command:
|
||||||
|
description: A JSON-formatted string that includes quaternion, speed,
|
||||||
|
position
|
||||||
type: string
|
type: string
|
||||||
title: SendCmd_Goal
|
title: SendCmd_Goal
|
||||||
type: object
|
type: object
|
||||||
@@ -284,6 +302,7 @@ robotic_arm.SCARA_with_slider.moveit.virtual:
|
|||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
properties:
|
properties:
|
||||||
command:
|
command:
|
||||||
|
description: A JSON-formatted string that includes speed
|
||||||
type: string
|
type: string
|
||||||
title: SendCmd_Goal
|
title: SendCmd_Goal
|
||||||
type: object
|
type: object
|
||||||
@@ -329,7 +348,7 @@ robotic_arm.SCARA_with_slider.moveit.virtual:
|
|||||||
type: object
|
type: object
|
||||||
model:
|
model:
|
||||||
mesh: arm_slider
|
mesh: arm_slider
|
||||||
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/arm_slider/macro_device.xacro
|
path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/arm_slider/macro_device.xacro
|
||||||
type: device
|
type: device
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
robotic_arm.UR:
|
robotic_arm.UR:
|
||||||
|
|||||||
@@ -684,48 +684,64 @@ linear_motion.toyo_xyz.sim:
|
|||||||
type: object
|
type: object
|
||||||
type: UniLabJsonCommand
|
type: UniLabJsonCommand
|
||||||
pick_and_place:
|
pick_and_place:
|
||||||
feedback:
|
feedback: {}
|
||||||
status: status
|
goal: {}
|
||||||
goal:
|
|
||||||
command: command
|
|
||||||
goal_default:
|
goal_default:
|
||||||
command: ''
|
constraints: null
|
||||||
|
lift_height: null
|
||||||
|
move_group: null
|
||||||
|
option: null
|
||||||
|
resource: null
|
||||||
|
retry: null
|
||||||
|
speed: null
|
||||||
|
status: null
|
||||||
|
target: null
|
||||||
|
x_distance: null
|
||||||
|
y_distance: null
|
||||||
handles: {}
|
handles: {}
|
||||||
placeholder_keys: {}
|
placeholder_keys: {}
|
||||||
result:
|
result: {}
|
||||||
return_info: return_info
|
|
||||||
success: success
|
|
||||||
schema:
|
schema:
|
||||||
description: ''
|
description: pick_and_place 显式参数(UniLabJsonCommand)
|
||||||
properties:
|
properties:
|
||||||
feedback:
|
feedback: {}
|
||||||
additionalProperties: false
|
goal:
|
||||||
properties:
|
properties:
|
||||||
|
constraints:
|
||||||
|
items:
|
||||||
|
type: number
|
||||||
|
type: array
|
||||||
|
lift_height:
|
||||||
|
type: string
|
||||||
|
move_group:
|
||||||
|
type: string
|
||||||
|
option:
|
||||||
|
type: string
|
||||||
|
resource:
|
||||||
|
type: string
|
||||||
|
retry:
|
||||||
|
type: string
|
||||||
|
speed:
|
||||||
|
type: string
|
||||||
status:
|
status:
|
||||||
type: string
|
type: string
|
||||||
title: SendCmd_Feedback
|
target:
|
||||||
type: object
|
|
||||||
goal:
|
|
||||||
additionalProperties: false
|
|
||||||
properties:
|
|
||||||
command:
|
|
||||||
type: string
|
type: string
|
||||||
title: SendCmd_Goal
|
x_distance:
|
||||||
type: object
|
|
||||||
result:
|
|
||||||
additionalProperties: false
|
|
||||||
properties:
|
|
||||||
return_info:
|
|
||||||
type: string
|
type: string
|
||||||
success:
|
y_distance:
|
||||||
type: boolean
|
type: string
|
||||||
title: SendCmd_Result
|
required:
|
||||||
|
- option
|
||||||
|
- move_group
|
||||||
|
- status
|
||||||
type: object
|
type: object
|
||||||
|
result: {}
|
||||||
required:
|
required:
|
||||||
- goal
|
- goal
|
||||||
title: SendCmd
|
title: pick_and_place参数
|
||||||
type: object
|
type: object
|
||||||
type: SendCmd
|
type: UniLabJsonCommand
|
||||||
set_position:
|
set_position:
|
||||||
feedback:
|
feedback:
|
||||||
status: status
|
status: status
|
||||||
@@ -752,6 +768,8 @@ linear_motion.toyo_xyz.sim:
|
|||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
properties:
|
properties:
|
||||||
command:
|
command:
|
||||||
|
description: A JSON-formatted string that includes quaternion, speed,
|
||||||
|
position
|
||||||
type: string
|
type: string
|
||||||
title: SendCmd_Goal
|
title: SendCmd_Goal
|
||||||
type: object
|
type: object
|
||||||
@@ -795,6 +813,7 @@ linear_motion.toyo_xyz.sim:
|
|||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
properties:
|
properties:
|
||||||
command:
|
command:
|
||||||
|
description: A JSON-formatted string that includes speed
|
||||||
type: string
|
type: string
|
||||||
title: SendCmd_Goal
|
title: SendCmd_Goal
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -2179,6 +2179,7 @@ virtual_multiway_valve:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
port_number:
|
port_number:
|
||||||
|
description: 端口号 (1-8)
|
||||||
type: integer
|
type: integer
|
||||||
required:
|
required:
|
||||||
- port_number
|
- port_number
|
||||||
@@ -2225,6 +2226,7 @@ virtual_multiway_valve:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
port_number:
|
port_number:
|
||||||
|
description: 目标端口号 (1-8)
|
||||||
type: integer
|
type: integer
|
||||||
required:
|
required:
|
||||||
- port_number
|
- port_number
|
||||||
@@ -2261,6 +2263,7 @@ virtual_multiway_valve:
|
|||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
properties:
|
properties:
|
||||||
command:
|
command:
|
||||||
|
description: 目标位置 (0-8) 或位置字符串
|
||||||
type: string
|
type: string
|
||||||
title: SendCmd_Goal
|
title: SendCmd_Goal
|
||||||
type: object
|
type: object
|
||||||
@@ -2304,6 +2307,7 @@ virtual_multiway_valve:
|
|||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
properties:
|
properties:
|
||||||
command:
|
command:
|
||||||
|
description: 目标位置 (0-8) 或位置字符串
|
||||||
type: string
|
type: string
|
||||||
title: SendCmd_Goal
|
title: SendCmd_Goal
|
||||||
type: object
|
type: object
|
||||||
@@ -3960,6 +3964,14 @@ virtual_separator:
|
|||||||
io_type: source
|
io_type: source
|
||||||
label: bottom_phase_out
|
label: bottom_phase_out
|
||||||
side: SOUTH
|
side: SOUTH
|
||||||
|
- data_key: top_outlet
|
||||||
|
data_source: executor
|
||||||
|
data_type: fluid
|
||||||
|
description: 上相(轻相)液体输出口
|
||||||
|
handler_key: topphaseout
|
||||||
|
io_type: source
|
||||||
|
label: top_phase_out
|
||||||
|
side: NORTH
|
||||||
- data_key: mechanical_port
|
- data_key: mechanical_port
|
||||||
data_source: handle
|
data_source: handle
|
||||||
data_type: mechanical
|
data_type: mechanical
|
||||||
@@ -4207,6 +4219,7 @@ virtual_solenoid_valve:
|
|||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
properties:
|
properties:
|
||||||
string:
|
string:
|
||||||
|
description: '"ON"/"OFF" 或 "OPEN"/"CLOSED"'
|
||||||
type: string
|
type: string
|
||||||
title: StrSingleInput_Goal
|
title: StrSingleInput_Goal
|
||||||
type: object
|
type: object
|
||||||
@@ -4250,6 +4263,7 @@ virtual_solenoid_valve:
|
|||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
properties:
|
properties:
|
||||||
command:
|
command:
|
||||||
|
description: '"OPEN"/"CLOSED" 或其他控制命令'
|
||||||
type: string
|
type: string
|
||||||
title: SendCmd_Goal
|
title: SendCmd_Goal
|
||||||
type: object
|
type: object
|
||||||
@@ -4410,16 +4424,20 @@ virtual_solid_dispenser:
|
|||||||
event:
|
event:
|
||||||
type: string
|
type: string
|
||||||
mass:
|
mass:
|
||||||
|
description: 质量字符串 (如 "2.9 g")
|
||||||
type: string
|
type: string
|
||||||
mol:
|
mol:
|
||||||
|
description: 摩尔数字符串 (如 "0.12 mol")
|
||||||
type: string
|
type: string
|
||||||
purpose:
|
purpose:
|
||||||
|
description: 添加目的
|
||||||
type: string
|
type: string
|
||||||
rate_spec:
|
rate_spec:
|
||||||
type: string
|
type: string
|
||||||
ratio:
|
ratio:
|
||||||
type: string
|
type: string
|
||||||
reagent:
|
reagent:
|
||||||
|
description: 试剂名称
|
||||||
type: string
|
type: string
|
||||||
stir:
|
stir:
|
||||||
type: boolean
|
type: boolean
|
||||||
@@ -4431,6 +4449,7 @@ virtual_solid_dispenser:
|
|||||||
type: string
|
type: string
|
||||||
vessel:
|
vessel:
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
|
description: 目标容器
|
||||||
properties:
|
properties:
|
||||||
category:
|
category:
|
||||||
type: string
|
type: string
|
||||||
@@ -5560,8 +5579,10 @@ virtual_transfer_pump:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
velocity:
|
velocity:
|
||||||
|
description: 拉取速度 (ml/s)
|
||||||
type: number
|
type: number
|
||||||
volume:
|
volume:
|
||||||
|
description: 要拉取的体积 (ml)
|
||||||
type: number
|
type: number
|
||||||
required:
|
required:
|
||||||
- volume
|
- volume
|
||||||
@@ -5588,8 +5609,10 @@ virtual_transfer_pump:
|
|||||||
goal:
|
goal:
|
||||||
properties:
|
properties:
|
||||||
velocity:
|
velocity:
|
||||||
|
description: 推出速度 (ml/s)
|
||||||
type: number
|
type: number
|
||||||
volume:
|
volume:
|
||||||
|
description: 要推出的体积 (ml)
|
||||||
type: number
|
type: number
|
||||||
required:
|
required:
|
||||||
- volume
|
- volume
|
||||||
@@ -5685,10 +5708,12 @@ virtual_transfer_pump:
|
|||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
properties:
|
properties:
|
||||||
max_velocity:
|
max_velocity:
|
||||||
|
description: 移动速度 (ml/s)
|
||||||
maximum: 1.7976931348623157e+308
|
maximum: 1.7976931348623157e+308
|
||||||
minimum: -1.7976931348623157e+308
|
minimum: -1.7976931348623157e+308
|
||||||
type: number
|
type: number
|
||||||
position:
|
position:
|
||||||
|
description: 目标位置 (ml)
|
||||||
maximum: 1.7976931348623157e+308
|
maximum: 1.7976931348623157e+308
|
||||||
minimum: -1.7976931348623157e+308
|
minimum: -1.7976931348623157e+308
|
||||||
type: number
|
type: number
|
||||||
@@ -5837,8 +5862,10 @@ virtual_transfer_pump:
|
|||||||
config:
|
config:
|
||||||
properties:
|
properties:
|
||||||
config:
|
config:
|
||||||
|
description: 配置字典,包含max_volume, port等参数
|
||||||
type: object
|
type: object
|
||||||
device_id:
|
device_id:
|
||||||
|
description: 设备ID
|
||||||
type: string
|
type: string
|
||||||
required: []
|
required: []
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -421,7 +421,7 @@ xrd_d7mate:
|
|||||||
type: number
|
type: number
|
||||||
sample_id:
|
sample_id:
|
||||||
default: ''
|
default: ''
|
||||||
description: 样品标识符
|
description: 样品名称
|
||||||
type: string
|
type: string
|
||||||
start_theta:
|
start_theta:
|
||||||
default: 10.0
|
default: 10.0
|
||||||
@@ -433,7 +433,7 @@ xrd_d7mate:
|
|||||||
type: string
|
type: string
|
||||||
wait_minutes:
|
wait_minutes:
|
||||||
default: 3.0
|
default: 3.0
|
||||||
description: 允许上样后等待分钟数
|
description: 在允许上样后、发送样品准备完成前的等待分钟数(默认 3 分钟)
|
||||||
type: number
|
type: number
|
||||||
required: []
|
required: []
|
||||||
title: StartWorkflow_Goal
|
title: StartWorkflow_Goal
|
||||||
@@ -492,12 +492,15 @@ xrd_d7mate:
|
|||||||
properties:
|
properties:
|
||||||
host:
|
host:
|
||||||
default: 127.0.0.1
|
default: 127.0.0.1
|
||||||
|
description: 设备IP地址
|
||||||
type: string
|
type: string
|
||||||
port:
|
port:
|
||||||
default: 6001
|
default: 6001
|
||||||
|
description: 通信端口,默认6001
|
||||||
type: string
|
type: string
|
||||||
timeout:
|
timeout:
|
||||||
default: 10.0
|
default: 10.0
|
||||||
|
description: 超时时间,单位秒
|
||||||
type: string
|
type: string
|
||||||
required: []
|
required: []
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ zhida_gcms:
|
|||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
properties:
|
properties:
|
||||||
string:
|
string:
|
||||||
|
description: Base64编码的CSV数据(ROS2参数名)
|
||||||
type: string
|
type: string
|
||||||
title: StrSingleInput_Goal
|
title: StrSingleInput_Goal
|
||||||
type: object
|
type: object
|
||||||
@@ -257,6 +258,7 @@ zhida_gcms:
|
|||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
properties:
|
properties:
|
||||||
string:
|
string:
|
||||||
|
description: CSV文件路径(ROS2参数名)
|
||||||
type: string
|
type: string
|
||||||
title: StrSingleInput_Goal
|
title: StrSingleInput_Goal
|
||||||
type: object
|
type: object
|
||||||
@@ -289,12 +291,15 @@ zhida_gcms:
|
|||||||
properties:
|
properties:
|
||||||
host:
|
host:
|
||||||
default: 192.168.3.184
|
default: 192.168.3.184
|
||||||
|
description: 设备IP地址,本地部署时可使用'127.0.0.1'
|
||||||
type: string
|
type: string
|
||||||
port:
|
port:
|
||||||
default: 5792
|
default: 5792
|
||||||
|
description: 通信端口,默认5792
|
||||||
type: string
|
type: string
|
||||||
timeout:
|
timeout:
|
||||||
default: 10.0
|
default: 10.0
|
||||||
|
description: 超时时间,单位秒
|
||||||
type: string
|
type: string
|
||||||
required: []
|
required: []
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -238,6 +238,7 @@ class Registry:
|
|||||||
"class_name": "unilabos_class",
|
"class_name": "unilabos_class",
|
||||||
},
|
},
|
||||||
"always_free": True,
|
"always_free": True,
|
||||||
|
"feedback_interval": 300.0,
|
||||||
},
|
},
|
||||||
"test_latency": test_latency_action,
|
"test_latency": test_latency_action,
|
||||||
"auto-test_resource": test_resource_action,
|
"auto-test_resource": test_resource_action,
|
||||||
@@ -270,6 +271,7 @@ class Registry:
|
|||||||
registry_cache.pkl 一个文件中,删除即可完全重置。
|
registry_cache.pkl 一个文件中,删除即可完全重置。
|
||||||
"""
|
"""
|
||||||
import time as _time
|
import time as _time
|
||||||
|
from unilabos.registry.ast_registry_scanner import _CACHE_VERSION as AST_SCAN_CACHE_VERSION
|
||||||
from unilabos.registry.ast_registry_scanner import scan_directory
|
from unilabos.registry.ast_registry_scanner import scan_directory
|
||||||
|
|
||||||
scan_t0 = _time.perf_counter()
|
scan_t0 = _time.perf_counter()
|
||||||
@@ -285,6 +287,10 @@ class Registry:
|
|||||||
# ---- 统一缓存:一个 pkl 包含所有数据 ----
|
# ---- 统一缓存:一个 pkl 包含所有数据 ----
|
||||||
unified_cache = self._load_config_cache()
|
unified_cache = self._load_config_cache()
|
||||||
ast_cache = unified_cache.setdefault("_ast_scan", {"files": {}})
|
ast_cache = unified_cache.setdefault("_ast_scan", {"files": {}})
|
||||||
|
if ast_cache.get("version") != AST_SCAN_CACHE_VERSION:
|
||||||
|
ast_cache = {"version": AST_SCAN_CACHE_VERSION, "files": {}}
|
||||||
|
unified_cache["_ast_scan"] = ast_cache
|
||||||
|
unified_cache.pop("_build_results", None)
|
||||||
|
|
||||||
# 默认:扫描 unilabos 包所在的父目录
|
# 默认:扫描 unilabos 包所在的父目录
|
||||||
pkg_root = Path(__file__).resolve().parent.parent # .../unilabos
|
pkg_root = Path(__file__).resolve().parent.parent # .../unilabos
|
||||||
@@ -560,13 +566,47 @@ class Registry:
|
|||||||
|
|
||||||
return prop_schema
|
return prop_schema
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _apply_docstring_param_metadata(
|
||||||
|
schema: Dict[str, Any],
|
||||||
|
doc_info: Dict[str, Any],
|
||||||
|
field_to_param: Optional[Dict[str, str]] = None,
|
||||||
|
apply_defaults: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Apply parsed docstring display names and descriptions to schema properties."""
|
||||||
|
if not schema or not doc_info:
|
||||||
|
return
|
||||||
|
|
||||||
|
props = schema.get("properties", {})
|
||||||
|
if not isinstance(props, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
param_descs = doc_info.get("params", {}) or {}
|
||||||
|
param_display_names = doc_info.get("param_display_names", {}) or {}
|
||||||
|
for field_name, prop_schema in props.items():
|
||||||
|
if not isinstance(prop_schema, dict):
|
||||||
|
continue
|
||||||
|
param_name = field_to_param.get(field_name, field_name) if field_to_param else field_name
|
||||||
|
if not isinstance(param_name, str):
|
||||||
|
continue
|
||||||
|
param_name = param_name.removesuffix("[]")
|
||||||
|
if param_name in param_display_names:
|
||||||
|
prop_schema["title"] = param_display_names[param_name]
|
||||||
|
elif apply_defaults and not prop_schema.get("title"):
|
||||||
|
prop_schema["title"] = field_name
|
||||||
|
|
||||||
|
if param_name in param_descs:
|
||||||
|
prop_schema["description"] = param_descs[param_name]
|
||||||
|
elif apply_defaults and "description" not in prop_schema:
|
||||||
|
prop_schema["description"] = ""
|
||||||
|
|
||||||
def _generate_unilab_json_command_schema(
|
def _generate_unilab_json_command_schema(
|
||||||
self, method_args: list, docstring: Optional[str] = None,
|
self, method_args: list, docstring: Optional[str] = None,
|
||||||
import_map: Optional[Dict[str, str]] = None,
|
import_map: Optional[Dict[str, str]] = None,
|
||||||
|
apply_doc_defaults: bool = False,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""根据方法参数和 docstring 生成 UniLabJsonCommand schema"""
|
"""根据方法参数和 docstring 生成 UniLabJsonCommand schema"""
|
||||||
doc_info = parse_docstring(docstring)
|
doc_info = parse_docstring(docstring)
|
||||||
param_descs = doc_info.get("params", {})
|
|
||||||
|
|
||||||
schema = {
|
schema = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -597,12 +637,10 @@ class Registry:
|
|||||||
param_name, param_type, param_default, import_map=import_map
|
param_name, param_type, param_default, import_map=import_map
|
||||||
)
|
)
|
||||||
|
|
||||||
if param_name in param_descs:
|
|
||||||
schema["properties"][param_name]["description"] = param_descs[param_name]
|
|
||||||
|
|
||||||
if param_required:
|
if param_required:
|
||||||
schema["required"].append(param_name)
|
schema["required"].append(param_name)
|
||||||
|
|
||||||
|
self._apply_docstring_param_metadata(schema, doc_info, apply_defaults=apply_doc_defaults)
|
||||||
return schema
|
return schema
|
||||||
|
|
||||||
def _generate_status_types_schema(self, status_methods: Dict[str, Any]) -> Dict[str, Any]:
|
def _generate_status_types_schema(self, status_methods: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
@@ -798,6 +836,7 @@ class Registry:
|
|||||||
type_str = "UniLabJsonCommandAsync" if is_async else "UniLabJsonCommand"
|
type_str = "UniLabJsonCommandAsync" if is_async else "UniLabJsonCommand"
|
||||||
params = method_info.get("params", [])
|
params = method_info.get("params", [])
|
||||||
method_doc = method_info.get("docstring")
|
method_doc = method_info.get("docstring")
|
||||||
|
method_doc_info = parse_docstring(method_doc)
|
||||||
goal_schema = self._generate_schema_from_ast_params(params, method_name, method_doc, imap)
|
goal_schema = self._generate_schema_from_ast_params(params, method_name, method_doc, imap)
|
||||||
|
|
||||||
if action_args is not None:
|
if action_args is not None:
|
||||||
@@ -827,10 +866,15 @@ class Registry:
|
|||||||
|
|
||||||
# action handles: 从 @action(handles=[...]) 提取并转换为标准格式
|
# action handles: 从 @action(handles=[...]) 提取并转换为标准格式
|
||||||
raw_handles = (action_args or {}).get("handles")
|
raw_handles = (action_args or {}).get("handles")
|
||||||
handles = normalize_ast_action_handles(raw_handles) if isinstance(raw_handles, list) else (raw_handles or {})
|
handles = (
|
||||||
|
normalize_ast_action_handles(raw_handles)
|
||||||
|
if isinstance(raw_handles, list)
|
||||||
|
else (raw_handles or {})
|
||||||
|
)
|
||||||
|
|
||||||
# placeholder_keys: 优先用装饰器显式配置,否则从参数类型检测
|
# placeholder_keys: 先从参数类型自动检测,再用装饰器显式配置覆盖/补充
|
||||||
pk = (action_args or {}).get("placeholder_keys") or detect_placeholder_keys(params)
|
pk = detect_placeholder_keys(params)
|
||||||
|
pk.update((action_args or {}).get("placeholder_keys") or {})
|
||||||
|
|
||||||
# 从方法返回值类型生成 result schema
|
# 从方法返回值类型生成 result schema
|
||||||
result_schema = None
|
result_schema = None
|
||||||
@@ -845,13 +889,20 @@ class Registry:
|
|||||||
"goal": goal,
|
"goal": goal,
|
||||||
"feedback": (action_args or {}).get("feedback") or {},
|
"feedback": (action_args or {}).get("feedback") or {},
|
||||||
"result": (action_args or {}).get("result") or {},
|
"result": (action_args or {}).get("result") or {},
|
||||||
"schema": wrap_action_schema(goal_schema, action_name, result_schema=result_schema),
|
"schema": wrap_action_schema(
|
||||||
|
goal_schema,
|
||||||
|
action_name,
|
||||||
|
description=(action_args or {}).get("description") or method_doc_info.get("description", ""),
|
||||||
|
result_schema=result_schema,
|
||||||
|
),
|
||||||
"goal_default": goal_default,
|
"goal_default": goal_default,
|
||||||
"handles": handles,
|
"handles": handles,
|
||||||
"placeholder_keys": pk,
|
"placeholder_keys": pk,
|
||||||
}
|
}
|
||||||
if (action_args or {}).get("always_free") or method_info.get("always_free"):
|
if (action_args or {}).get("always_free") or method_info.get("always_free"):
|
||||||
entry["always_free"] = True
|
entry["always_free"] = True
|
||||||
|
_fb_iv = (action_args or {}).get("feedback_interval", method_info.get("feedback_interval", 1.0))
|
||||||
|
entry["feedback_interval"] = _fb_iv
|
||||||
nt = normalize_enum_value((action_args or {}).get("node_type"), NodeType)
|
nt = normalize_enum_value((action_args or {}).get("node_type"), NodeType)
|
||||||
if nt:
|
if nt:
|
||||||
entry["node_type"] = nt
|
entry["node_type"] = nt
|
||||||
@@ -882,7 +933,11 @@ class Registry:
|
|||||||
action_name = f"auto-{action_name}"
|
action_name = f"auto-{action_name}"
|
||||||
|
|
||||||
raw_handles = action_args.get("handles")
|
raw_handles = action_args.get("handles")
|
||||||
handles = normalize_ast_action_handles(raw_handles) if isinstance(raw_handles, list) else (raw_handles or {})
|
handles = (
|
||||||
|
normalize_ast_action_handles(raw_handles)
|
||||||
|
if isinstance(raw_handles, list)
|
||||||
|
else (raw_handles or {})
|
||||||
|
)
|
||||||
|
|
||||||
method_params = method_info.get("params", [])
|
method_params = method_info.get("params", [])
|
||||||
|
|
||||||
@@ -975,20 +1030,34 @@ class Registry:
|
|||||||
"schema": schema,
|
"schema": schema,
|
||||||
"goal_default": goal_default,
|
"goal_default": goal_default,
|
||||||
"handles": handles,
|
"handles": handles,
|
||||||
"placeholder_keys": action_args.get("placeholder_keys") or detect_placeholder_keys(method_params),
|
"placeholder_keys": {
|
||||||
|
**detect_placeholder_keys(method_params),
|
||||||
|
**(action_args.get("placeholder_keys") or {}),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if action_args.get("always_free") or method_info.get("always_free"):
|
if action_args.get("always_free") or method_info.get("always_free"):
|
||||||
action_entry["always_free"] = True
|
action_entry["always_free"] = True
|
||||||
|
_fb_iv = action_args.get("feedback_interval", method_info.get("feedback_interval", 1.0))
|
||||||
|
action_entry["feedback_interval"] = _fb_iv
|
||||||
nt = normalize_enum_value(action_args.get("node_type"), NodeType)
|
nt = normalize_enum_value(action_args.get("node_type"), NodeType)
|
||||||
if nt:
|
if nt:
|
||||||
action_entry["node_type"] = nt
|
action_entry["node_type"] = nt
|
||||||
|
goal_schema_for_docs = action_entry.get("schema", {}).get("properties", {}).get("goal", {})
|
||||||
|
self._apply_docstring_param_metadata(
|
||||||
|
goal_schema_for_docs,
|
||||||
|
parse_docstring(method_info.get("docstring")),
|
||||||
|
goal,
|
||||||
|
apply_defaults=True,
|
||||||
|
)
|
||||||
action_value_mappings[action_name] = action_entry
|
action_value_mappings[action_name] = action_entry
|
||||||
|
|
||||||
action_value_mappings = dict(sorted(action_value_mappings.items()))
|
action_value_mappings = dict(sorted(action_value_mappings.items()))
|
||||||
|
|
||||||
# --- init_param_schema = { config: <init_params>, data: <status_types> } ---
|
# --- init_param_schema = { config: <init_params>, data: <status_types> } ---
|
||||||
init_params = ast_meta.get("init_params", [])
|
init_params = ast_meta.get("init_params", [])
|
||||||
config_schema = self._generate_schema_from_ast_params(init_params, "__init__", import_map=imap)
|
config_schema = self._generate_schema_from_ast_params(
|
||||||
|
init_params, "__init__", ast_meta.get("init_docstring"), import_map=imap
|
||||||
|
)
|
||||||
data_schema = self._generate_status_schema_from_ast(
|
data_schema = self._generate_status_schema_from_ast(
|
||||||
ast_meta.get("status_properties", {}), imap
|
ast_meta.get("status_properties", {}), imap
|
||||||
)
|
)
|
||||||
@@ -1036,7 +1105,6 @@ class Registry:
|
|||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Generate JSON Schema from AST-extracted parameter list."""
|
"""Generate JSON Schema from AST-extracted parameter list."""
|
||||||
doc_info = parse_docstring(docstring)
|
doc_info = parse_docstring(docstring)
|
||||||
param_descs = doc_info.get("params", {})
|
|
||||||
|
|
||||||
schema: Dict[str, Any] = {
|
schema: Dict[str, Any] = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -1066,12 +1134,10 @@ class Registry:
|
|||||||
pname, ptype, pdefault, import_map
|
pname, ptype, pdefault, import_map
|
||||||
)
|
)
|
||||||
|
|
||||||
if pname in param_descs:
|
|
||||||
schema["properties"][pname]["description"] = param_descs[pname]
|
|
||||||
|
|
||||||
if prequired:
|
if prequired:
|
||||||
schema["required"].append(pname)
|
schema["required"].append(pname)
|
||||||
|
|
||||||
|
self._apply_docstring_param_metadata(schema, doc_info, apply_defaults=True)
|
||||||
return schema
|
return schema
|
||||||
|
|
||||||
def _generate_status_schema_from_ast(
|
def _generate_status_schema_from_ast(
|
||||||
@@ -1801,7 +1867,7 @@ class Registry:
|
|||||||
else:
|
else:
|
||||||
action_key = f"auto-{k}"
|
action_key = f"auto-{k}"
|
||||||
goal_schema = self._generate_unilab_json_command_schema(
|
goal_schema = self._generate_unilab_json_command_schema(
|
||||||
v["args"], import_map=enhanced_import_map
|
v["args"], docstring=v.get("docstring"), import_map=enhanced_import_map
|
||||||
)
|
)
|
||||||
ret_type = v.get("return_type", "")
|
ret_type = v.get("return_type", "")
|
||||||
result_schema = None
|
result_schema = None
|
||||||
@@ -1810,7 +1876,13 @@ class Registry:
|
|||||||
"result", ret_type, None, import_map=enhanced_import_map
|
"result", ret_type, None, import_map=enhanced_import_map
|
||||||
)
|
)
|
||||||
old_cfg = old_action_configs.get(action_key) or old_action_configs.get(f"auto-{k}", {})
|
old_cfg = old_action_configs.get(action_key) or old_action_configs.get(f"auto-{k}", {})
|
||||||
new_schema = wrap_action_schema(goal_schema, action_key, result_schema=result_schema)
|
doc_info = parse_docstring(v.get("docstring"))
|
||||||
|
new_schema = wrap_action_schema(
|
||||||
|
goal_schema,
|
||||||
|
action_key,
|
||||||
|
description=doc_info.get("description", ""),
|
||||||
|
result_schema=result_schema,
|
||||||
|
)
|
||||||
old_schema = old_cfg.get("schema", {})
|
old_schema = old_cfg.get("schema", {})
|
||||||
if old_schema:
|
if old_schema:
|
||||||
preserve_field_descriptions(new_schema, old_schema)
|
preserve_field_descriptions(new_schema, old_schema)
|
||||||
@@ -1876,6 +1948,12 @@ class Registry:
|
|||||||
|
|
||||||
merged_pk = dict(old_cfg.get("placeholder_keys", {}))
|
merged_pk = dict(old_cfg.get("placeholder_keys", {}))
|
||||||
merged_pk.update(detect_placeholder_keys(v["args"]))
|
merged_pk.update(detect_placeholder_keys(v["args"]))
|
||||||
|
goal_schema_for_docs = (
|
||||||
|
entry_schema.get("properties", {}).get("goal", {})
|
||||||
|
if isinstance(entry_schema, dict)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
self._apply_docstring_param_metadata(goal_schema_for_docs, doc_info, entry_goal)
|
||||||
|
|
||||||
entry = {
|
entry = {
|
||||||
"type": entry_type,
|
"type": entry_type,
|
||||||
@@ -1896,7 +1974,8 @@ class Registry:
|
|||||||
|
|
||||||
device_config["init_param_schema"] = {}
|
device_config["init_param_schema"] = {}
|
||||||
init_schema = self._generate_unilab_json_command_schema(
|
init_schema = self._generate_unilab_json_command_schema(
|
||||||
enhanced_info["init_params"], "__init__",
|
enhanced_info["init_params"],
|
||||||
|
docstring=enhanced_info.get("init_docstring"),
|
||||||
import_map=enhanced_import_map,
|
import_map=enhanced_import_map,
|
||||||
)
|
)
|
||||||
device_config["init_param_schema"]["config"] = init_schema
|
device_config["init_param_schema"]["config"] = init_schema
|
||||||
@@ -1943,7 +2022,9 @@ class Registry:
|
|||||||
action_str_type_mapping[action_type_str] = target_type
|
action_str_type_mapping[action_type_str] = target_type
|
||||||
if target_type is not None:
|
if target_type is not None:
|
||||||
try:
|
try:
|
||||||
action_config["goal_default"] = ROS2MessageInstance(target_type.Goal()).get_python_dict()
|
action_config["goal_default"] = ROS2MessageInstance(
|
||||||
|
target_type.Goal()
|
||||||
|
).get_python_dict()
|
||||||
except Exception:
|
except Exception:
|
||||||
action_config["goal_default"] = {}
|
action_config["goal_default"] = {}
|
||||||
prev_schema = action_config.get("schema", {})
|
prev_schema = action_config.get("schema", {})
|
||||||
@@ -2135,6 +2216,7 @@ class Registry:
|
|||||||
"unilabos_device_id": {
|
"unilabos_device_id": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "",
|
"default": "",
|
||||||
|
"title": "设备ID",
|
||||||
"description": "UniLabOS设备ID,用于指定执行动作的具体设备实例",
|
"description": "UniLabOS设备ID,用于指定执行动作的具体设备实例",
|
||||||
},
|
},
|
||||||
**schema["properties"]["goal"]["properties"],
|
**schema["properties"]["goal"]["properties"],
|
||||||
@@ -2206,7 +2288,14 @@ class Registry:
|
|||||||
lab_registry = Registry()
|
lab_registry = Registry()
|
||||||
|
|
||||||
|
|
||||||
def build_registry(registry_paths=None, devices_dirs=None, upload_registry=False, check_mode=False, complete_registry=False, external_only=False):
|
def build_registry(
|
||||||
|
registry_paths=None,
|
||||||
|
devices_dirs=None,
|
||||||
|
upload_registry=False,
|
||||||
|
check_mode=False,
|
||||||
|
complete_registry=False,
|
||||||
|
external_only=False,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
构建或获取Registry单例实例
|
构建或获取Registry单例实例
|
||||||
"""
|
"""
|
||||||
@@ -2220,7 +2309,12 @@ def build_registry(registry_paths=None, devices_dirs=None, upload_registry=False
|
|||||||
if path not in current_paths:
|
if path not in current_paths:
|
||||||
lab_registry.registry_paths.append(path)
|
lab_registry.registry_paths.append(path)
|
||||||
|
|
||||||
lab_registry.setup(devices_dirs=devices_dirs, upload_registry=upload_registry, complete_registry=complete_registry, external_only=external_only)
|
lab_registry.setup(
|
||||||
|
devices_dirs=devices_dirs,
|
||||||
|
upload_registry=upload_registry,
|
||||||
|
complete_registry=complete_registry,
|
||||||
|
external_only=external_only,
|
||||||
|
)
|
||||||
|
|
||||||
# 将 AST 扫描的字符串类型替换为实际 ROS2 消息类(仅查找 ROS2 类型,不 import 设备模块)
|
# 将 AST 扫描的字符串类型替换为实际 ROS2 消息类(仅查找 ROS2 类型,不 import 设备模块)
|
||||||
lab_registry.resolve_all_types()
|
lab_registry.resolve_all_types()
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ hplc_plate:
|
|||||||
- 0
|
- 0
|
||||||
- 0
|
- 0
|
||||||
- 3.1416
|
- 3.1416
|
||||||
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/hplc_plate/modal.xacro
|
path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/hplc_plate/modal.xacro
|
||||||
type: resource
|
type: resource
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
plate_96:
|
plate_96:
|
||||||
@@ -39,7 +39,7 @@ plate_96:
|
|||||||
- 0
|
- 0
|
||||||
- 0
|
- 0
|
||||||
- 0
|
- 0
|
||||||
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/plate_96/modal.xacro
|
path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/plate_96/modal.xacro
|
||||||
type: resource
|
type: resource
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
plate_96_high:
|
plate_96_high:
|
||||||
@@ -61,7 +61,7 @@ plate_96_high:
|
|||||||
- 1.5708
|
- 1.5708
|
||||||
- 0
|
- 0
|
||||||
- 1.5708
|
- 1.5708
|
||||||
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/plate_96_high/modal.xacro
|
path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/plate_96_high/modal.xacro
|
||||||
type: resource
|
type: resource
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
tiprack_96_high:
|
tiprack_96_high:
|
||||||
@@ -76,7 +76,7 @@ tiprack_96_high:
|
|||||||
init_param_schema: {}
|
init_param_schema: {}
|
||||||
model:
|
model:
|
||||||
children_mesh: generic_labware_tube_10_75/meshes/0_base.stl
|
children_mesh: generic_labware_tube_10_75/meshes/0_base.stl
|
||||||
children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/generic_labware_tube_10_75/modal.xacro
|
children_mesh_path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/generic_labware_tube_10_75/modal.xacro
|
||||||
children_mesh_tf:
|
children_mesh_tf:
|
||||||
- 0.0018
|
- 0.0018
|
||||||
- 0.0018
|
- 0.0018
|
||||||
@@ -92,7 +92,7 @@ tiprack_96_high:
|
|||||||
- 1.5708
|
- 1.5708
|
||||||
- 0
|
- 0
|
||||||
- 1.5708
|
- 1.5708
|
||||||
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tiprack_96_high/modal.xacro
|
path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tiprack_96_high/modal.xacro
|
||||||
type: resource
|
type: resource
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
tiprack_box:
|
tiprack_box:
|
||||||
@@ -107,7 +107,7 @@ tiprack_box:
|
|||||||
init_param_schema: {}
|
init_param_schema: {}
|
||||||
model:
|
model:
|
||||||
children_mesh: tip/meshes/tip.stl
|
children_mesh: tip/meshes/tip.stl
|
||||||
children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tip/modal.xacro
|
children_mesh_path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tip/modal.xacro
|
||||||
children_mesh_tf:
|
children_mesh_tf:
|
||||||
- 0.0045
|
- 0.0045
|
||||||
- 0.0045
|
- 0.0045
|
||||||
@@ -123,6 +123,6 @@ tiprack_box:
|
|||||||
- 0
|
- 0
|
||||||
- 0
|
- 0
|
||||||
- 0
|
- 0
|
||||||
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tiprack_box/modal.xacro
|
path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tiprack_box/modal.xacro
|
||||||
type: resource
|
type: resource
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ bottle_container:
|
|||||||
init_param_schema: {}
|
init_param_schema: {}
|
||||||
model:
|
model:
|
||||||
children_mesh: bottle/meshes/bottle.stl
|
children_mesh: bottle/meshes/bottle.stl
|
||||||
children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/bottle/modal.xacro
|
children_mesh_path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/bottle/modal.xacro
|
||||||
children_mesh_tf:
|
children_mesh_tf:
|
||||||
- 0.04
|
- 0.04
|
||||||
- 0.04
|
- 0.04
|
||||||
@@ -27,7 +27,7 @@ bottle_container:
|
|||||||
- 0
|
- 0
|
||||||
- 0
|
- 0
|
||||||
- 0
|
- 0
|
||||||
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/bottle_container/modal.xacro
|
path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/bottle_container/modal.xacro
|
||||||
type: resource
|
type: resource
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
tube_container:
|
tube_container:
|
||||||
@@ -43,7 +43,7 @@ tube_container:
|
|||||||
init_param_schema: {}
|
init_param_schema: {}
|
||||||
model:
|
model:
|
||||||
children_mesh: tube/meshes/tube.stl
|
children_mesh: tube/meshes/tube.stl
|
||||||
children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tube/modal.xacro
|
children_mesh_path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tube/modal.xacro
|
||||||
children_mesh_tf:
|
children_mesh_tf:
|
||||||
- 0.017
|
- 0.017
|
||||||
- 0.017
|
- 0.017
|
||||||
@@ -59,6 +59,6 @@ tube_container:
|
|||||||
- 0
|
- 0
|
||||||
- 0
|
- 0
|
||||||
- 0
|
- 0
|
||||||
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tube_container/modal.xacro
|
path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tube_container/modal.xacro
|
||||||
type: resource
|
type: resource
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
|
|||||||
@@ -10,6 +10,6 @@ TransformXYZDeck:
|
|||||||
init_param_schema: {}
|
init_param_schema: {}
|
||||||
model:
|
model:
|
||||||
mesh: liquid_transform_xyz
|
mesh: liquid_transform_xyz
|
||||||
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/liquid_transform_xyz/macro_device.xacro
|
path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/liquid_transform_xyz/macro_device.xacro
|
||||||
type: device
|
type: device
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ OTDeck:
|
|||||||
init_param_schema: {}
|
init_param_schema: {}
|
||||||
model:
|
model:
|
||||||
mesh: opentrons_liquid_handler
|
mesh: opentrons_liquid_handler
|
||||||
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/opentrons_liquid_handler/macro_device.xacro
|
path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/opentrons_liquid_handler/macro_device.xacro
|
||||||
type: device
|
type: device
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
hplc_station:
|
hplc_station:
|
||||||
@@ -25,6 +25,6 @@ hplc_station:
|
|||||||
init_param_schema: {}
|
init_param_schema: {}
|
||||||
model:
|
model:
|
||||||
mesh: hplc_station
|
mesh: hplc_station
|
||||||
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/hplc_station/macro_device.xacro
|
path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/hplc_station/macro_device.xacro
|
||||||
type: device
|
type: device
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ nest_96_wellplate_100ul_pcr_full_skirt:
|
|||||||
init_param_schema: {}
|
init_param_schema: {}
|
||||||
model:
|
model:
|
||||||
children_mesh: generic_labware_tube_10_75/meshes/0_base.stl
|
children_mesh: generic_labware_tube_10_75/meshes/0_base.stl
|
||||||
children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/generic_labware_tube_10_75/modal.xacro
|
children_mesh_path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/generic_labware_tube_10_75/modal.xacro
|
||||||
children_mesh_tf:
|
children_mesh_tf:
|
||||||
- 0.0018
|
- 0.0018
|
||||||
- 0.0018
|
- 0.0018
|
||||||
@@ -125,7 +125,7 @@ nest_96_wellplate_100ul_pcr_full_skirt:
|
|||||||
- -1.5708
|
- -1.5708
|
||||||
- 0
|
- 0
|
||||||
- 1.5708
|
- 1.5708
|
||||||
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro
|
path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro
|
||||||
type: resource
|
type: resource
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
nest_96_wellplate_200ul_flat:
|
nest_96_wellplate_200ul_flat:
|
||||||
@@ -158,7 +158,7 @@ nest_96_wellplate_2ml_deep:
|
|||||||
- -1.5708
|
- -1.5708
|
||||||
- 0
|
- 0
|
||||||
- 1.5708
|
- 1.5708
|
||||||
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro
|
path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro
|
||||||
type: resource
|
type: resource
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
thermoscientificnunc_96_wellplate_1300ul:
|
thermoscientificnunc_96_wellplate_1300ul:
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ opentrons_96_filtertiprack_1000ul:
|
|||||||
- -1.5708
|
- -1.5708
|
||||||
- 0
|
- 0
|
||||||
- 1.5708
|
- 1.5708
|
||||||
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro
|
path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro
|
||||||
type: resource
|
type: resource
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
opentrons_96_filtertiprack_10ul:
|
opentrons_96_filtertiprack_10ul:
|
||||||
|
|||||||
@@ -36,16 +36,40 @@ class ROSMsgNotFound(Exception):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_SECTION_RE = re.compile(r"^(\w[\w\s]*):\s*$")
|
_SECTION_RE = re.compile(r"^(\w[\w\s]*):\s*$")
|
||||||
|
_PARAM_HEADER_RE = re.compile(
|
||||||
|
r"^\s*(?P<name>\w[\w]*)\s*(?:\[(?P<display_name>[^\]]+)\])?(?:\s*\([^)]*\))?\s*$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_docstring_param_header(param_part: str) -> Tuple[str, Optional[str]]:
|
||||||
|
"""Parse ``name[display_name]`` or Google-style ``name (type)``."""
|
||||||
|
match = _PARAM_HEADER_RE.match(param_part.strip())
|
||||||
|
if not match:
|
||||||
|
return param_part.strip().split("(")[0].strip(), None
|
||||||
|
|
||||||
|
display_name = match.group("display_name")
|
||||||
|
if display_name is not None:
|
||||||
|
display_name = display_name.strip() or None
|
||||||
|
return match.group("name").strip(), display_name
|
||||||
|
|
||||||
|
|
||||||
def parse_docstring(docstring: Optional[str]) -> Dict[str, Any]:
|
def parse_docstring(docstring: Optional[str]) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
解析 Google-style docstring,提取描述和参数说明。
|
解析 docstring,提取描述和参数说明。
|
||||||
|
|
||||||
|
支持:
|
||||||
|
- Google-style ``Args:`` / ``Parameters:`` 小节
|
||||||
|
- 直接参数行 ``field: desc``
|
||||||
|
- 带显示名参数行 ``field[Display Name]: desc``
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
{"description": "短描述", "params": {"param1": "参数1描述", ...}}
|
{
|
||||||
|
"description": "短描述",
|
||||||
|
"params": {"param1": "参数1描述", ...},
|
||||||
|
"param_display_names": {"param1": "显示名", ...},
|
||||||
|
}
|
||||||
"""
|
"""
|
||||||
result: Dict[str, Any] = {"description": "", "params": {}}
|
result: Dict[str, Any] = {"description": "", "params": {}, "param_display_names": {}}
|
||||||
if not docstring:
|
if not docstring:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -53,33 +77,53 @@ def parse_docstring(docstring: Optional[str]) -> Dict[str, Any]:
|
|||||||
if not lines:
|
if not lines:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
result["description"] = lines[0].strip()
|
|
||||||
|
|
||||||
in_args = False
|
in_args = False
|
||||||
|
current_section: Optional[str] = None
|
||||||
current_param: Optional[str] = None
|
current_param: Optional[str] = None
|
||||||
|
current_display_name: Optional[str] = None
|
||||||
current_desc_parts: list = []
|
current_desc_parts: list = []
|
||||||
|
|
||||||
for line in lines[1:]:
|
def flush_current_param() -> None:
|
||||||
|
nonlocal current_param, current_display_name, current_desc_parts
|
||||||
|
if current_param is None:
|
||||||
|
return
|
||||||
|
result["params"][current_param] = "\n".join(current_desc_parts).strip()
|
||||||
|
if current_display_name:
|
||||||
|
result["param_display_names"][current_param] = current_display_name
|
||||||
|
current_param = None
|
||||||
|
current_display_name = None
|
||||||
|
current_desc_parts = []
|
||||||
|
|
||||||
|
first_line = lines[0].strip()
|
||||||
|
start_index = 0
|
||||||
|
if not _SECTION_RE.match(first_line) and ":" not in first_line:
|
||||||
|
result["description"] = first_line
|
||||||
|
start_index = 1
|
||||||
|
|
||||||
|
for line in lines[start_index:]:
|
||||||
stripped = line.strip()
|
stripped = line.strip()
|
||||||
|
if not stripped:
|
||||||
|
if current_param is not None:
|
||||||
|
current_desc_parts.append("")
|
||||||
|
continue
|
||||||
|
|
||||||
section_match = _SECTION_RE.match(stripped)
|
section_match = _SECTION_RE.match(stripped)
|
||||||
if section_match:
|
if section_match:
|
||||||
if current_param is not None:
|
flush_current_param()
|
||||||
result["params"][current_param] = "\n".join(current_desc_parts).strip()
|
current_section = section_match.group(1).lower()
|
||||||
current_param = None
|
in_args = current_section in ("args", "arguments", "parameters", "params")
|
||||||
current_desc_parts = []
|
|
||||||
section_name = section_match.group(1).lower()
|
|
||||||
in_args = section_name in ("args", "arguments", "parameters", "params")
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not in_args:
|
parse_as_param = in_args or current_section is None
|
||||||
|
if not parse_as_param:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if ":" in stripped and not stripped.startswith(" "):
|
if ":" in stripped:
|
||||||
if current_param is not None:
|
flush_current_param()
|
||||||
result["params"][current_param] = "\n".join(current_desc_parts).strip()
|
|
||||||
param_part, _, desc_part = stripped.partition(":")
|
param_part, _, desc_part = stripped.partition(":")
|
||||||
param_name = param_part.strip().split("(")[0].strip()
|
param_name, display_name = _parse_docstring_param_header(param_part)
|
||||||
current_param = param_name
|
current_param = param_name
|
||||||
|
current_display_name = display_name
|
||||||
current_desc_parts = [desc_part.strip()]
|
current_desc_parts = [desc_part.strip()]
|
||||||
elif current_param is not None:
|
elif current_param is not None:
|
||||||
aline = line
|
aline = line
|
||||||
@@ -89,8 +133,7 @@ def parse_docstring(docstring: Optional[str]) -> Dict[str, Any]:
|
|||||||
aline = aline[1:]
|
aline = aline[1:]
|
||||||
current_desc_parts.append(aline.strip())
|
current_desc_parts.append(aline.strip())
|
||||||
|
|
||||||
if current_param is not None:
|
flush_current_param()
|
||||||
result["params"][current_param] = "\n".join(current_desc_parts).strip()
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -997,7 +997,7 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict
|
|||||||
logger.debug(f"🔍 [PLR→Bioyond] detail转换: {bottle.name} → PLR(x={site['x']},y={site['y']},id={site.get('identifier','?')}) → Bioyond(x={bioyond_x},y={bioyond_y})")
|
logger.debug(f"🔍 [PLR→Bioyond] detail转换: {bottle.name} → PLR(x={site['x']},y={site['y']},id={site.get('identifier','?')}) → Bioyond(x={bioyond_x},y={bioyond_y})")
|
||||||
|
|
||||||
# 🔥 提取物料名称:从 tracker.liquids 中获取第一个液体的名称(去除PLR系统添加的后缀)
|
# 🔥 提取物料名称:从 tracker.liquids 中获取第一个液体的名称(去除PLR系统添加的后缀)
|
||||||
# tracker.liquids 格式: [(物料名称, 数量), ...]
|
# tracker.liquids 格式: [(物料名称, 数量, 单位), ...]
|
||||||
material_name = bottle_type_info[0] # 默认使用类型名称(如"样品瓶")
|
material_name = bottle_type_info[0] # 默认使用类型名称(如"样品瓶")
|
||||||
if hasattr(bottle, "tracker") and bottle.tracker.liquids:
|
if hasattr(bottle, "tracker") and bottle.tracker.liquids:
|
||||||
# 如果有液体,使用液体的名称
|
# 如果有液体,使用液体的名称
|
||||||
@@ -1015,7 +1015,7 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict
|
|||||||
"typeId": bottle_type_info[1],
|
"typeId": bottle_type_info[1],
|
||||||
"code": bottle.code if hasattr(bottle, "code") else "",
|
"code": bottle.code if hasattr(bottle, "code") else "",
|
||||||
"name": material_name, # 使用物料名称(如"9090"),而不是类型名称("样品瓶")
|
"name": material_name, # 使用物料名称(如"9090"),而不是类型名称("样品瓶")
|
||||||
"quantity": sum(qty for _, qty in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0,
|
"quantity": sum(qty for _, qty, *_ in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0,
|
||||||
"x": bioyond_x,
|
"x": bioyond_x,
|
||||||
"y": bioyond_y,
|
"y": bioyond_y,
|
||||||
"z": 1,
|
"z": 1,
|
||||||
@@ -1075,7 +1075,7 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict
|
|||||||
"barCode": "",
|
"barCode": "",
|
||||||
"name": material_name, # 使用物料名称而不是资源名称
|
"name": material_name, # 使用物料名称而不是资源名称
|
||||||
"unit": default_unit, # 使用配置的单位或默认单位
|
"unit": default_unit, # 使用配置的单位或默认单位
|
||||||
"quantity": sum(qty for _, qty in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0,
|
"quantity": sum(qty for _, qty, *_ in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0,
|
||||||
"Parameters": parameters_json # API 实际要求的字段(必需)
|
"Parameters": parameters_json # API 实际要求的字段(必需)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,42 @@ RETURN_UNILABOS_SAMPLES = "unilabos_samples"
|
|||||||
SampleUUIDsType = Dict[str, Optional["PLRResource"]]
|
SampleUUIDsType = Dict[str, Optional["PLRResource"]]
|
||||||
|
|
||||||
|
|
||||||
|
def _augment_states_with_liquid_history(
|
||||||
|
resource: "PLRResource",
|
||||||
|
states: Dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""P9 — 把 Uni-Lab 在 PLR tracker 上挂的 ``liquid_history`` 扩展属性并入
|
||||||
|
``serialize_all_state()`` 返回的 well state dict。
|
||||||
|
|
||||||
|
PLR 原生 ``serialize_all_state()`` 只输出 ``{liquids, pending_liquids}``,
|
||||||
|
会丢失 ``tracker.liquid_history``。本 helper 递归遍历资源树,把每个有 tracker 的
|
||||||
|
节点的 ``liquid_history`` 写入 ``states[node.name]["liquid_history"]``。
|
||||||
|
|
||||||
|
设计要点:
|
||||||
|
- 不可变:若 ``states[name]`` 已有 ``liquid_history`` 字段则不覆盖(向后兼容)。
|
||||||
|
- 列表浅拷贝:避免运行时 mutation 影响 dump 结果。
|
||||||
|
- 节点无 tracker / tracker 无 ``liquid_history`` 属性 → 跳过(不写默认 ``[]``,
|
||||||
|
否则会污染非 well 节点 state)。
|
||||||
|
|
||||||
|
详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md`` §6.3。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _walk(node: "PLRResource") -> None:
|
||||||
|
name = getattr(node, "name", None)
|
||||||
|
if isinstance(name, str) and name in states:
|
||||||
|
tracker = getattr(node, "tracker", None)
|
||||||
|
if tracker is not None:
|
||||||
|
history = getattr(tracker, "liquid_history", None)
|
||||||
|
if isinstance(history, list):
|
||||||
|
state = states[name]
|
||||||
|
if isinstance(state, dict) and "liquid_history" not in state:
|
||||||
|
state["liquid_history"] = list(history)
|
||||||
|
for child in getattr(node, "children", ()) or ():
|
||||||
|
_walk(child)
|
||||||
|
|
||||||
|
_walk(resource)
|
||||||
|
|
||||||
|
|
||||||
class LabSample(TypedDict):
|
class LabSample(TypedDict):
|
||||||
sample_uuid: str
|
sample_uuid: str
|
||||||
oss_path: str
|
oss_path: str
|
||||||
@@ -577,6 +613,11 @@ class ResourceTreeSet(object):
|
|||||||
|
|
||||||
serialized_data = resource.serialize()
|
serialized_data = resource.serialize()
|
||||||
all_states = resource.serialize_all_state()
|
all_states = resource.serialize_all_state()
|
||||||
|
# P9 — PLR 原生 serialize_all_state 只输出 {liquids, pending_liquids},
|
||||||
|
# 丢弃 Uni-Lab 在 tracker 上挂的扩展属性 liquid_history。在这里把它并回 state dict
|
||||||
|
# 以确保 OS→Cloud sync 链路完整保留液体历史。
|
||||||
|
# 详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md`` §6.3。
|
||||||
|
_augment_states_with_liquid_history(resource, all_states)
|
||||||
|
|
||||||
# 根节点没有父节点,传入 None
|
# 根节点没有父节点,传入 None
|
||||||
root_instance = resource_plr_inner(serialized_data, None, all_states, uuid_list)
|
root_instance = resource_plr_inner(serialized_data, None, all_states, uuid_list)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import json
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
|
from unilabos.utils.tools import fast_dumps_str as _fast_dumps_str, fast_loads as _fast_loads
|
||||||
from typing import (
|
from typing import (
|
||||||
get_type_hints,
|
get_type_hints,
|
||||||
TypeVar,
|
TypeVar,
|
||||||
@@ -78,6 +80,67 @@ if TYPE_CHECKING:
|
|||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
class RclpyAsyncMutex:
|
||||||
|
"""rclpy executor 兼容的异步互斥锁
|
||||||
|
|
||||||
|
通过 executor.create_task 唤醒等待者,避免 timer 的 InvalidHandle 问题。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, name: str = ""):
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._acquired = False
|
||||||
|
self._queue: List[Future] = []
|
||||||
|
self._name = name
|
||||||
|
self._holder: Optional[str] = None
|
||||||
|
|
||||||
|
async def acquire(self, node: "BaseROS2DeviceNode", tag: str = ""):
|
||||||
|
"""获取锁。如果已被占用,则异步等待直到锁释放。"""
|
||||||
|
# t0 = time.time()
|
||||||
|
with self._lock:
|
||||||
|
# qlen = len(self._queue)
|
||||||
|
if not self._acquired:
|
||||||
|
self._acquired = True
|
||||||
|
self._holder = tag
|
||||||
|
# node.lab_logger().debug(
|
||||||
|
# f"[Mutex:{self._name}] 获取锁 tag={tag} (无等待, queue=0)"
|
||||||
|
# )
|
||||||
|
return
|
||||||
|
waiter = Future()
|
||||||
|
self._queue.append(waiter)
|
||||||
|
# node.lab_logger().info(
|
||||||
|
# f"[Mutex:{self._name}] 等待锁 tag={tag} "
|
||||||
|
# f"(holder={self._holder}, queue={qlen + 1})"
|
||||||
|
# )
|
||||||
|
await waiter
|
||||||
|
# wait_ms = (time.time() - t0) * 1000
|
||||||
|
self._holder = tag
|
||||||
|
# node.lab_logger().info(
|
||||||
|
# f"[Mutex:{self._name}] 获取锁 tag={tag} (等了 {wait_ms:.0f}ms)"
|
||||||
|
# )
|
||||||
|
|
||||||
|
def release(self, node: "BaseROS2DeviceNode"):
|
||||||
|
"""释放锁,通过 executor task 唤醒下一个等待者。"""
|
||||||
|
with self._lock:
|
||||||
|
# old_holder = self._holder
|
||||||
|
if self._queue:
|
||||||
|
next_waiter = self._queue.pop(0)
|
||||||
|
# node.lab_logger().debug(
|
||||||
|
# f"[Mutex:{self._name}] 释放锁 holder={old_holder} → 唤醒下一个 (剩余 queue={len(self._queue)})"
|
||||||
|
# )
|
||||||
|
|
||||||
|
async def _wake():
|
||||||
|
if not next_waiter.done():
|
||||||
|
next_waiter.set_result(None)
|
||||||
|
|
||||||
|
rclpy.get_global_executor().create_task(_wake())
|
||||||
|
else:
|
||||||
|
self._acquired = False
|
||||||
|
self._holder = None
|
||||||
|
# node.lab_logger().debug(
|
||||||
|
# f"[Mutex:{self._name}] 释放锁 holder={old_holder} → 空闲"
|
||||||
|
# )
|
||||||
|
|
||||||
|
|
||||||
# 在线设备注册表
|
# 在线设备注册表
|
||||||
registered_devices: Dict[str, "DeviceInfoType"] = {}
|
registered_devices: Dict[str, "DeviceInfoType"] = {}
|
||||||
|
|
||||||
@@ -355,6 +418,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
max_workers=max(len(action_value_mappings), 1), thread_name_prefix=f"ROSDevice{self.device_id}"
|
max_workers=max(len(action_value_mappings), 1), thread_name_prefix=f"ROSDevice{self.device_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self._append_resource_lock = RclpyAsyncMutex(name=f"AR:{device_id}")
|
||||||
|
|
||||||
# 创建资源管理客户端
|
# 创建资源管理客户端
|
||||||
self._resource_clients: Dict[str, Client] = {
|
self._resource_clients: Dict[str, Client] = {
|
||||||
"resource_add": self.create_client(ResourceAdd, "/resources/add", callback_group=self.callback_group),
|
"resource_add": self.create_client(ResourceAdd, "/resources/add", callback_group=self.callback_group),
|
||||||
@@ -378,15 +443,40 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
return res
|
return res
|
||||||
|
|
||||||
async def append_resource(req: SerialCommand_Request, res: SerialCommand_Response):
|
async def append_resource(req: SerialCommand_Request, res: SerialCommand_Response):
|
||||||
|
_cmd = _fast_loads(req.command)
|
||||||
|
_res_name = _cmd.get("resource", [{}])
|
||||||
|
_res_name = (_res_name[0].get("id", "?") if isinstance(_res_name, list) and _res_name
|
||||||
|
else _res_name.get("id", "?") if isinstance(_res_name, dict) else "?")
|
||||||
|
_ar_tag = f"{_res_name}"
|
||||||
|
# _t_enter = time.time()
|
||||||
|
# self.lab_logger().info(f"[AR:{_ar_tag}] 进入 append_resource")
|
||||||
|
await self._append_resource_lock.acquire(self, tag=_ar_tag)
|
||||||
|
# _t_locked = time.time()
|
||||||
|
try:
|
||||||
|
return await _append_resource_inner(req, res, _ar_tag)
|
||||||
|
# _t_done = time.time()
|
||||||
|
# self.lab_logger().info(
|
||||||
|
# f"[AR:{_ar_tag}] 完成 "
|
||||||
|
# f"等锁={(_t_locked - _t_enter) * 1000:.0f}ms "
|
||||||
|
# f"执行={(_t_done - _t_locked) * 1000:.0f}ms "
|
||||||
|
# f"总计={(_t_done - _t_enter) * 1000:.0f}ms"
|
||||||
|
# )
|
||||||
|
except Exception as _ex:
|
||||||
|
self.lab_logger().error(f"[AR:{_ar_tag}] 异常: {_ex}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
self._append_resource_lock.release(self)
|
||||||
|
|
||||||
|
async def _append_resource_inner(req: SerialCommand_Request, res: SerialCommand_Response, _ar_tag: str = ""):
|
||||||
from pylabrobot.resources.deck import Deck
|
from pylabrobot.resources.deck import Deck
|
||||||
from pylabrobot.resources import Coordinate
|
from pylabrobot.resources import Coordinate
|
||||||
from pylabrobot.resources import Plate
|
from pylabrobot.resources import Plate
|
||||||
|
|
||||||
# 物料传输到对应的node节点
|
# _t0 = time.time()
|
||||||
client = self._resource_clients["c2s_update_resource_tree"]
|
client = self._resource_clients["c2s_update_resource_tree"]
|
||||||
request = SerialCommand.Request()
|
request = SerialCommand.Request()
|
||||||
request2 = SerialCommand.Request()
|
request2 = SerialCommand.Request()
|
||||||
command_json = json.loads(req.command)
|
command_json = _fast_loads(req.command)
|
||||||
namespace = command_json["namespace"]
|
namespace = command_json["namespace"]
|
||||||
bind_parent_id = command_json["bind_parent_id"]
|
bind_parent_id = command_json["bind_parent_id"]
|
||||||
edge_device_id = command_json["edge_device_id"]
|
edge_device_id = command_json["edge_device_id"]
|
||||||
@@ -439,7 +529,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
f"更新物料{container_instance.name}出现不支持的数据类型{type(found_resource)} {found_resource}"
|
f"更新物料{container_instance.name}出现不支持的数据类型{type(found_resource)} {found_resource}"
|
||||||
)
|
)
|
||||||
# noinspection PyUnresolvedReferences
|
# noinspection PyUnresolvedReferences
|
||||||
request.command = json.dumps(
|
# _t1 = time.time()
|
||||||
|
# self.lab_logger().debug(
|
||||||
|
# f"[AR:{_ar_tag}] 准备完成 PLR转换+序列化 {((_t1 - _t0) * 1000):.0f}ms, 发送首次上传..."
|
||||||
|
# )
|
||||||
|
request.command = _fast_dumps_str(
|
||||||
{
|
{
|
||||||
"action": "add",
|
"action": "add",
|
||||||
"data": {
|
"data": {
|
||||||
@@ -450,7 +544,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
tree_response: SerialCommand.Response = await client.call_async(request)
|
tree_response: SerialCommand.Response = await client.call_async(request)
|
||||||
uuid_maps = json.loads(tree_response.response)
|
# _t2 = time.time()
|
||||||
|
# self.lab_logger().debug(
|
||||||
|
# f"[AR:{_ar_tag}] 首次上传完成 {((_t2 - _t1) * 1000):.0f}ms"
|
||||||
|
# )
|
||||||
|
uuid_maps = _fast_loads(tree_response.response)
|
||||||
plr_instances = rts.to_plr_resources()
|
plr_instances = rts.to_plr_resources()
|
||||||
for plr_instance in plr_instances:
|
for plr_instance in plr_instances:
|
||||||
self.resource_tracker.loop_update_uuid(plr_instance, uuid_maps)
|
self.resource_tracker.loop_update_uuid(plr_instance, uuid_maps)
|
||||||
@@ -508,7 +606,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
for input_well, liquid_type, liquid_volume, liquid_input_slot in zip(
|
for input_well, liquid_type, liquid_volume, liquid_input_slot in zip(
|
||||||
input_wells, ADD_LIQUID_TYPE, LIQUID_VOLUME, LIQUID_INPUT_SLOT
|
input_wells, ADD_LIQUID_TYPE, LIQUID_VOLUME, LIQUID_INPUT_SLOT
|
||||||
):
|
):
|
||||||
input_well.set_liquids([(liquid_type, liquid_volume, "uL")])
|
input_well.set_liquids([(liquid_type, liquid_volume, "ul")])
|
||||||
final_response["liquid_input_resource_tree"] = ResourceTreeSet.from_plr_resources(
|
final_response["liquid_input_resource_tree"] = ResourceTreeSet.from_plr_resources(
|
||||||
input_wells
|
input_wells
|
||||||
).dump()
|
).dump()
|
||||||
@@ -527,12 +625,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
Coordinate(location["x"], location["y"], location["z"]),
|
Coordinate(location["x"], location["y"], location["z"]),
|
||||||
**other_calling_param,
|
**other_calling_param,
|
||||||
)
|
)
|
||||||
# 调整了液体以及Deck之后要重新Assign
|
|
||||||
# noinspection PyUnresolvedReferences
|
# noinspection PyUnresolvedReferences
|
||||||
rts_with_parent = ResourceTreeSet.from_plr_resources([parent_resource])
|
rts_with_parent = ResourceTreeSet.from_plr_resources([plr_instance])
|
||||||
if rts_with_parent.root_nodes[0].res_content.uuid_parent is None:
|
if rts_with_parent.root_nodes[0].res_content.uuid_parent is None:
|
||||||
rts_with_parent.root_nodes[0].res_content.parent_uuid = self.uuid
|
rts_with_parent.root_nodes[0].res_content.parent_uuid = self.uuid
|
||||||
request.command = json.dumps(
|
request.command = _fast_dumps_str(
|
||||||
{
|
{
|
||||||
"action": "add",
|
"action": "add",
|
||||||
"data": {
|
"data": {
|
||||||
@@ -542,11 +639,22 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
# _t4 = time.time()
|
||||||
|
# self.lab_logger().debug(
|
||||||
|
# f"[AR:{_ar_tag}] 二次上传序列化 {_n_parent}节点 {((_t4 - _t3) * 1000):.0f}ms, 发送中..."
|
||||||
|
# )
|
||||||
tree_response: SerialCommand.Response = await client.call_async(request)
|
tree_response: SerialCommand.Response = await client.call_async(request)
|
||||||
uuid_maps = json.loads(tree_response.response)
|
_raw_resp = tree_response.response if tree_response else ""
|
||||||
|
if _raw_resp:
|
||||||
|
uuid_maps = json.loads(_raw_resp)
|
||||||
|
else:
|
||||||
|
uuid_maps = {}
|
||||||
|
self._lab_logger.warning("Resource tree add 返回空响应,跳过 UUID 映射")
|
||||||
self.resource_tracker.loop_update_uuid(input_resources, uuid_maps)
|
self.resource_tracker.loop_update_uuid(input_resources, uuid_maps)
|
||||||
self._lab_logger.info(f"Resource tree added. UUID mapping: {len(uuid_maps)} nodes")
|
# self._lab_logger.info(
|
||||||
# 这里created_resources不包含parent_resource
|
# f"[AR:{_ar_tag}] 二次上传完成 HTTP={(_t5 - _t4) * 1000:.0f}ms "
|
||||||
|
# f"UUID映射={len(uuid_maps)}节点 总执行={(_t5 - _t0) * 1000:.0f}ms"
|
||||||
|
# )
|
||||||
# 发送给ResourceMeshManager
|
# 发送给ResourceMeshManager
|
||||||
action_client = ActionClient(
|
action_client = ActionClient(
|
||||||
self,
|
self,
|
||||||
@@ -683,7 +791,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
)
|
)
|
||||||
# 发送请求并等待响应
|
# 发送请求并等待响应
|
||||||
response: SerialCommand_Response = await self._resource_clients["resource_get"].call_async(r)
|
response: SerialCommand_Response = await self._resource_clients["resource_get"].call_async(r)
|
||||||
|
if not response.response:
|
||||||
|
raise ValueError(f"查询资源 {resource_id} 失败:服务端返回空响应")
|
||||||
raw_data = json.loads(response.response)
|
raw_data = json.loads(response.response)
|
||||||
|
if not raw_data:
|
||||||
|
raise ValueError(f"查询资源 {resource_id} 失败:返回数据为空")
|
||||||
|
|
||||||
# 转换为 PLR 资源
|
# 转换为 PLR 资源
|
||||||
tree_set = ResourceTreeSet.from_raw_dict_list(raw_data)
|
tree_set = ResourceTreeSet.from_raw_dict_list(raw_data)
|
||||||
@@ -1132,7 +1244,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
if uid is None:
|
if uid is None:
|
||||||
raise ValueError(f"目标物料{target_resource}没有unilabos_uuid属性,无法转运")
|
raise ValueError(f"目标物料{target_resource}没有unilabos_uuid属性,无法转运")
|
||||||
target_uids.append(uid)
|
target_uids.append(uid)
|
||||||
srv_address = f"/srv{target_device_id}/s2c_resource_tree"
|
_ns = target_device_id if target_device_id.startswith("/devices/") else f"/devices/{target_device_id.lstrip('/')}"
|
||||||
|
srv_address = f"/srv{_ns}/s2c_resource_tree"
|
||||||
sclient = self.create_client(SerialCommand, srv_address)
|
sclient = self.create_client(SerialCommand, srv_address)
|
||||||
# 等待服务可用(设置超时)
|
# 等待服务可用(设置超时)
|
||||||
if not sclient.wait_for_service(timeout_sec=5.0):
|
if not sclient.wait_for_service(timeout_sec=5.0):
|
||||||
@@ -1182,7 +1295,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
return False
|
return False
|
||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
self.lab_logger().info(f"资源本地增加到{target_device_id}结果: {response.response}")
|
self.lab_logger().info(f"资源本地增加到{target_device_id}结果: {response.response}")
|
||||||
return None
|
return "转运完成"
|
||||||
|
|
||||||
def register_device(self):
|
def register_device(self):
|
||||||
"""向注册表中注册设备信息"""
|
"""向注册表中注册设备信息"""
|
||||||
@@ -1567,17 +1680,13 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
feedback_msg_types = action_type.Feedback.get_fields_and_field_types()
|
feedback_msg_types = action_type.Feedback.get_fields_and_field_types()
|
||||||
result_msg_types = action_type.Result.get_fields_and_field_types()
|
result_msg_types = action_type.Result.get_fields_and_field_types()
|
||||||
|
|
||||||
while future is not None and not future.done():
|
# 低频 feedback timer(10s),不阻塞完成检测
|
||||||
if goal_handle.is_cancel_requested:
|
_feedback_timer = None
|
||||||
self.lab_logger().info(f"取消动作: {action_name}")
|
|
||||||
future.cancel() # 尝试取消线程池中的任务
|
|
||||||
goal_handle.canceled()
|
|
||||||
return action_type.Result()
|
|
||||||
|
|
||||||
|
def _publish_feedback():
|
||||||
|
if future is not None and not future.done():
|
||||||
self._time_spent = time.time() - time_start
|
self._time_spent = time.time() - time_start
|
||||||
self._time_remaining = time_overall - self._time_spent
|
self._time_remaining = time_overall - self._time_spent
|
||||||
|
|
||||||
# 发布反馈
|
|
||||||
feedback_values = {}
|
feedback_values = {}
|
||||||
for msg_name, attr_name in action_value_mapping["feedback"].items():
|
for msg_name, attr_name in action_value_mapping["feedback"].items():
|
||||||
if hasattr(self.driver_instance, f"get_{attr_name}"):
|
if hasattr(self.driver_instance, f"get_{attr_name}"):
|
||||||
@@ -1586,18 +1695,60 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
feedback_values[msg_name] = method()
|
feedback_values[msg_name] = method()
|
||||||
elif hasattr(self.driver_instance, attr_name):
|
elif hasattr(self.driver_instance, attr_name):
|
||||||
feedback_values[msg_name] = getattr(self.driver_instance, attr_name)
|
feedback_values[msg_name] = getattr(self.driver_instance, attr_name)
|
||||||
|
|
||||||
if self._print_publish:
|
if self._print_publish:
|
||||||
self.lab_logger().info(f"反馈: {feedback_values}")
|
self.lab_logger().info(f"反馈: {feedback_values}")
|
||||||
|
|
||||||
feedback_msg = convert_to_ros_msg_with_mapping(
|
feedback_msg = convert_to_ros_msg_with_mapping(
|
||||||
ros_msg_type=action_type.Feedback(),
|
ros_msg_type=action_type.Feedback(),
|
||||||
obj=feedback_values,
|
obj=feedback_values,
|
||||||
value_mapping=action_value_mapping["feedback"],
|
value_mapping=action_value_mapping["feedback"],
|
||||||
)
|
)
|
||||||
|
|
||||||
goal_handle.publish_feedback(feedback_msg)
|
goal_handle.publish_feedback(feedback_msg)
|
||||||
time.sleep(0.5)
|
|
||||||
|
if action_value_mapping.get("feedback"):
|
||||||
|
_fb_interval = action_value_mapping.get("feedback_interval", 0.5)
|
||||||
|
_feedback_timer = self.create_timer(
|
||||||
|
_fb_interval, _publish_feedback, callback_group=self.callback_group
|
||||||
|
)
|
||||||
|
|
||||||
|
# 等待 action 完成
|
||||||
|
if future is not None:
|
||||||
|
if isinstance(future, Task):
|
||||||
|
# rclpy Task:直接 await,完成瞬间唤醒
|
||||||
|
try:
|
||||||
|
_raw_result = await future
|
||||||
|
except Exception as e:
|
||||||
|
_raw_result = e
|
||||||
|
else:
|
||||||
|
# concurrent.futures.Future(同步 action):用 rclpy 兼容的轮询
|
||||||
|
_poll_future = Future()
|
||||||
|
|
||||||
|
def _on_sync_done(fut):
|
||||||
|
if not _poll_future.done():
|
||||||
|
_poll_future.set_result(None)
|
||||||
|
|
||||||
|
future.add_done_callback(_on_sync_done)
|
||||||
|
await _poll_future
|
||||||
|
try:
|
||||||
|
_raw_result = future.result()
|
||||||
|
except Exception as e:
|
||||||
|
_raw_result = e
|
||||||
|
|
||||||
|
# 确保 execution_error/success 被正确设置(不依赖 done callback 时序)
|
||||||
|
if isinstance(_raw_result, BaseException):
|
||||||
|
if not execution_error:
|
||||||
|
execution_error = traceback.format_exception(
|
||||||
|
type(_raw_result), _raw_result, _raw_result.__traceback__
|
||||||
|
)
|
||||||
|
execution_error = "".join(execution_error)
|
||||||
|
execution_success = False
|
||||||
|
action_return_value = _raw_result
|
||||||
|
elif not execution_error:
|
||||||
|
execution_success = True
|
||||||
|
action_return_value = _raw_result
|
||||||
|
|
||||||
|
# 清理 feedback timer
|
||||||
|
if _feedback_timer is not None:
|
||||||
|
_feedback_timer.cancel()
|
||||||
|
|
||||||
if future is not None and future.cancelled():
|
if future is not None and future.cancelled():
|
||||||
self.lab_logger().info(f"动作 {action_name} 已取消")
|
self.lab_logger().info(f"动作 {action_name} 已取消")
|
||||||
@@ -1606,8 +1757,12 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
# self.lab_logger().info(f"动作执行完成: {action_name}")
|
# self.lab_logger().info(f"动作执行完成: {action_name}")
|
||||||
del future
|
del future
|
||||||
|
|
||||||
|
# 执行失败时跳过物料状态更新
|
||||||
|
if execution_error:
|
||||||
|
execution_success = False
|
||||||
|
|
||||||
# 向Host更新物料当前状态
|
# 向Host更新物料当前状态
|
||||||
if action_name not in ["create_resource_detailed", "create_resource"]:
|
if not execution_error and action_name not in ["create_resource_detailed", "create_resource"]:
|
||||||
for k, v in goal.get_fields_and_field_types().items():
|
for k, v in goal.get_fields_and_field_types().items():
|
||||||
if v not in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]:
|
if v not in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]:
|
||||||
continue
|
continue
|
||||||
@@ -1663,7 +1818,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
|
|
||||||
for attr_name in result_msg_types.keys():
|
for attr_name in result_msg_types.keys():
|
||||||
if attr_name in ["success", "reached_goal"]:
|
if attr_name in ["success", "reached_goal"]:
|
||||||
setattr(result_msg, attr_name, True)
|
setattr(result_msg, attr_name, execution_success)
|
||||||
elif attr_name == "return_info":
|
elif attr_name == "return_info":
|
||||||
setattr(
|
setattr(
|
||||||
result_msg,
|
result_msg,
|
||||||
@@ -1729,8 +1884,23 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
try:
|
try:
|
||||||
function_args[arg_name] = self._convert_resources_sync(resource_data["uuid"])[0]
|
function_args[arg_name] = self._convert_resources_sync(resource_data["uuid"])[0]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# UUID 在资源树中不存在,尝试从传入的完整 dict 直接构建 PLR 资源
|
||||||
|
self.lab_logger().warning(
|
||||||
|
f"UUID查询 {arg_name} 失败,尝试从传入数据直接构建: {e}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
fallback_tree = ResourceTreeSet.from_raw_dict_list([resource_data])
|
||||||
|
if len(fallback_tree.trees) == 0:
|
||||||
|
raise
|
||||||
|
plr_list = fallback_tree.to_plr_resources()
|
||||||
|
if not plr_list:
|
||||||
|
raise
|
||||||
|
plr_res = plr_list[0]
|
||||||
|
figured = self.resource_tracker.figure_resource(plr_res, try_mode=True)
|
||||||
|
function_args[arg_name] = figured[0] if figured else plr_res
|
||||||
|
except Exception:
|
||||||
self.lab_logger().error(
|
self.lab_logger().error(
|
||||||
f"转换ResourceSlot参数 {arg_name} 失败: {e}\n{traceback.format_exc()}"
|
f"转换ResourceSlot参数 {arg_name} 失败(含回退): {e}\n{traceback.format_exc()}"
|
||||||
)
|
)
|
||||||
raise JsonCommandInitError(f"ResourceSlot参数转换失败: {arg_name}")
|
raise JsonCommandInitError(f"ResourceSlot参数转换失败: {arg_name}")
|
||||||
|
|
||||||
@@ -1744,8 +1914,23 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
uuids = [r["uuid"] for r in resource_list if isinstance(r, dict) and "id" in r]
|
uuids = [r["uuid"] for r in resource_list if isinstance(r, dict) and "id" in r]
|
||||||
function_args[arg_name] = self._convert_resources_sync(*uuids) if uuids else []
|
function_args[arg_name] = self._convert_resources_sync(*uuids) if uuids else []
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
self.lab_logger().warning(
|
||||||
|
f"UUID查询列表 {arg_name} 失败,尝试从传入数据直接构建: {e}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
dict_items = [r for r in resource_list if isinstance(r, dict) and "id" in r]
|
||||||
|
fallback_tree = ResourceTreeSet.from_raw_dict_list(dict_items)
|
||||||
|
if len(fallback_tree.trees) == 0:
|
||||||
|
raise
|
||||||
|
plr_list = fallback_tree.to_plr_resources()
|
||||||
|
resolved = []
|
||||||
|
for plr_res in plr_list:
|
||||||
|
figured = self.resource_tracker.figure_resource(plr_res, try_mode=True)
|
||||||
|
resolved.append(figured[0] if figured else plr_res)
|
||||||
|
function_args[arg_name] = resolved
|
||||||
|
except Exception:
|
||||||
self.lab_logger().error(
|
self.lab_logger().error(
|
||||||
f"转换ResourceSlot列表参数 {arg_name} 失败: {e}\n{traceback.format_exc()}"
|
f"转换ResourceSlot列表参数 {arg_name} 失败(含回退): {e}\n{traceback.format_exc()}"
|
||||||
)
|
)
|
||||||
raise JsonCommandInitError(f"ResourceSlot列表参数转换失败: {arg_name}")
|
raise JsonCommandInitError(f"ResourceSlot列表参数转换失败: {arg_name}")
|
||||||
|
|
||||||
@@ -1769,7 +1954,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
raise ValueError("至少需要提供一个 UUID")
|
raise ValueError("至少需要提供一个 UUID")
|
||||||
|
|
||||||
uuids_list = list(uuids)
|
uuids_list = list(uuids)
|
||||||
future = self._resource_clients["c2s_update_resource_tree"].call_async(
|
future: Future = self._resource_clients["c2s_update_resource_tree"].call_async(
|
||||||
SerialCommand.Request(
|
SerialCommand.Request(
|
||||||
command=json.dumps(
|
command=json.dumps(
|
||||||
{
|
{
|
||||||
@@ -1795,6 +1980,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
raise Exception(f"资源查询返回空结果: {uuids_list}")
|
raise Exception(f"资源查询返回空结果: {uuids_list}")
|
||||||
|
|
||||||
raw_data = json.loads(response.response)
|
raw_data = json.loads(response.response)
|
||||||
|
if not raw_data:
|
||||||
|
raise Exception(f"资源原始查询返回空结果: {raw_data}")
|
||||||
|
|
||||||
# 转换为 PLR 资源
|
# 转换为 PLR 资源
|
||||||
tree_set = ResourceTreeSet.from_raw_dict_list(raw_data)
|
tree_set = ResourceTreeSet.from_raw_dict_list(raw_data)
|
||||||
@@ -1816,10 +2003,15 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
|
|
||||||
mapped_plr_resources = []
|
mapped_plr_resources = []
|
||||||
for uuid in uuids_list:
|
for uuid in uuids_list:
|
||||||
|
found = None
|
||||||
for plr_resource in figured_resources:
|
for plr_resource in figured_resources:
|
||||||
r = self.resource_tracker.loop_find_with_uuid(plr_resource, uuid)
|
r = self.resource_tracker.loop_find_with_uuid(plr_resource, uuid)
|
||||||
mapped_plr_resources.append(r)
|
if r is not None:
|
||||||
|
found = r
|
||||||
break
|
break
|
||||||
|
if found is None:
|
||||||
|
raise Exception(f"未能在已解析的资源树中找到 uuid={uuid} 对应的资源")
|
||||||
|
mapped_plr_resources.append(found)
|
||||||
|
|
||||||
return mapped_plr_resources
|
return mapped_plr_resources
|
||||||
|
|
||||||
@@ -1912,16 +2104,27 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
|||||||
f"执行动作时JSON缺少function_name或function_args: {ex}\n原JSON: {string}\n{traceback.format_exc()}"
|
f"执行动作时JSON缺少function_name或function_args: {ex}\n原JSON: {string}\n{traceback.format_exc()}"
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _convert_resource_async(self, resource_data: Dict[str, Any]):
|
async def _convert_resource_async(self, resource_data: "ResourceDictType"):
|
||||||
"""异步转换资源数据为实例"""
|
"""异步转换 ResourceDictType 为 PLR 实例,优先用 uuid 查询"""
|
||||||
# 使用封装的get_resource_with_dir方法获取PLR资源
|
unilabos_uuid = resource_data.get("uuid")
|
||||||
plr_resource = await self.get_resource_with_dir(resource_ids=resource_data["id"], with_children=True)
|
|
||||||
|
if unilabos_uuid:
|
||||||
|
resource_tree = await self.get_resource([unilabos_uuid], with_children=True)
|
||||||
|
plr_resources = resource_tree.to_plr_resources()
|
||||||
|
if plr_resources:
|
||||||
|
plr_resource = plr_resources[0]
|
||||||
|
else:
|
||||||
|
raise ValueError(f"通过 uuid={unilabos_uuid} 查询资源为空")
|
||||||
|
else:
|
||||||
|
res_id = resource_data.get("id") or resource_data.get("name", "")
|
||||||
|
if not res_id:
|
||||||
|
raise ValueError(f"资源数据缺少 uuid 和 id: {list(resource_data.keys())}")
|
||||||
|
plr_resource = await self.get_resource_with_dir(resource_id=res_id, with_children=True)
|
||||||
|
|
||||||
# 通过资源跟踪器获取本地实例
|
# 通过资源跟踪器获取本地实例
|
||||||
res = self.resource_tracker.figure_resource(plr_resource, try_mode=True)
|
res = self.resource_tracker.figure_resource(plr_resource, try_mode=True)
|
||||||
if len(res) == 0:
|
if len(res) == 0:
|
||||||
# todo: 后续通过decoration来区分,减少warning
|
self.lab_logger().warning(f"资源转换未能索引到实例: {resource_data.get('id', '?')},返回新建实例")
|
||||||
self.lab_logger().warning(f"资源转换未能索引到实例: {resource_data},返回新建实例")
|
|
||||||
return plr_resource
|
return plr_resource
|
||||||
elif len(res) == 1:
|
elif len(res) == 1:
|
||||||
return res[0]
|
return res[0]
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import threading
|
|||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
from unilabos.utils.tools import fast_dumps_str as _fast_dumps_str, fast_loads as _fast_loads
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import TYPE_CHECKING, Optional, Dict, Any, List, ClassVar, Set, Union
|
from typing import TYPE_CHECKING, Optional, Dict, Any, List, ClassVar, Set, Union
|
||||||
|
|
||||||
@@ -570,6 +572,102 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
responses.append(response.response)
|
responses.append(response.response)
|
||||||
return responses
|
return responses
|
||||||
|
|
||||||
|
def _lookup_deck_for_slot(self, device_id: str, deck_id: str):
|
||||||
|
"""根据 device_id / deck_id 查找 deck PLR 实例,找不到返回 None。
|
||||||
|
|
||||||
|
优先级:
|
||||||
|
1. ``devices_instances[device_id]`` 上对应 driver 的 ``deck`` 属性(PLR LiquidHandler 的标准属性)
|
||||||
|
2. driver / wrapper / _ros_node 各级 resource_tracker.figure_resource({"id": deck_id})
|
||||||
|
3. host 自己的 ``_resource_tracker``
|
||||||
|
"""
|
||||||
|
log = self.lab_logger()
|
||||||
|
|
||||||
|
def _try_tracker(tracker, src_desc: str):
|
||||||
|
if tracker is None:
|
||||||
|
log.debug(f"[Host Node] _lookup_deck: {src_desc} tracker 为 None,跳过")
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
matches = tracker.figure_resource({"id": deck_id}, try_mode=True)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"[Host Node] _lookup_deck: {src_desc}.figure_resource({deck_id}) 失败: {e}")
|
||||||
|
return None
|
||||||
|
if isinstance(matches, list) and matches:
|
||||||
|
obj = next((m for m in matches if not isinstance(m, dict)), matches[0])
|
||||||
|
if obj is not None and not isinstance(obj, dict):
|
||||||
|
log.debug(f"[Host Node] _lookup_deck: 命中 via {src_desc} -> {type(obj).__name__}")
|
||||||
|
return obj
|
||||||
|
log.debug(f"[Host Node] _lookup_deck: {src_desc} figure_resource 未命中 (matches={matches!r})")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 1) 先按 device_id 拿 driver 上的 deck
|
||||||
|
candidate_ids = []
|
||||||
|
if device_id:
|
||||||
|
candidate_ids.append(device_id)
|
||||||
|
stripped = device_id.lstrip("/")
|
||||||
|
if stripped and stripped != device_id:
|
||||||
|
candidate_ids.append(stripped)
|
||||||
|
tail = device_id.split("/")[-1]
|
||||||
|
if tail and tail not in candidate_ids:
|
||||||
|
candidate_ids.append(tail)
|
||||||
|
|
||||||
|
d = None
|
||||||
|
for did in candidate_ids:
|
||||||
|
d = self.devices_instances.get(did)
|
||||||
|
if d is not None:
|
||||||
|
break
|
||||||
|
if d is None:
|
||||||
|
log.warning(
|
||||||
|
f"[Host Node] _lookup_deck: devices_instances 找不到 device_id={device_id!r} "
|
||||||
|
f"(尝试过 {candidate_ids}); 当前已知: {list(self.devices_instances.keys())}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 真正的 driver 在 wrapper 的 _driver_instance / _ros_node.driver_instance 上
|
||||||
|
driver_candidates = []
|
||||||
|
for attr_path in ("_driver_instance", "_ros_node.driver_instance", "driver_instance"):
|
||||||
|
obj = d
|
||||||
|
for part in attr_path.split("."):
|
||||||
|
obj = getattr(obj, part, None)
|
||||||
|
if obj is None:
|
||||||
|
break
|
||||||
|
if obj is not None and obj not in driver_candidates:
|
||||||
|
driver_candidates.append(obj)
|
||||||
|
|
||||||
|
for drv in driver_candidates:
|
||||||
|
deck = getattr(drv, "deck", None)
|
||||||
|
if deck is not None:
|
||||||
|
deck_name = getattr(deck, "name", None)
|
||||||
|
if deck_name == deck_id:
|
||||||
|
log.debug(
|
||||||
|
f"[Host Node] _lookup_deck: 命中 via {type(drv).__name__}.deck (name={deck_name})"
|
||||||
|
)
|
||||||
|
return deck
|
||||||
|
log.debug(
|
||||||
|
f"[Host Node] _lookup_deck: {type(drv).__name__}.deck.name={deck_name!r} 与 {deck_id!r} 不一致"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 退化:从 wrapper / _ros_node 的 resource_tracker 找
|
||||||
|
tracker_paths = (
|
||||||
|
"resource_tracker",
|
||||||
|
"_ros_node.resource_tracker",
|
||||||
|
)
|
||||||
|
for attr_path in tracker_paths:
|
||||||
|
tracker = d
|
||||||
|
for part in attr_path.split("."):
|
||||||
|
tracker = getattr(tracker, part, None)
|
||||||
|
if tracker is None:
|
||||||
|
break
|
||||||
|
obj = _try_tracker(tracker, f"device({device_id}).{attr_path}")
|
||||||
|
if obj is not None:
|
||||||
|
return obj
|
||||||
|
|
||||||
|
# 2) host 自己的 tracker(一般为空,因为 init 时 device 树被 continue 了)
|
||||||
|
host_tracker = getattr(self, "resource_tracker", None) or getattr(self, "_resource_tracker", None)
|
||||||
|
obj = _try_tracker(host_tracker, "host._resource_tracker")
|
||||||
|
if obj is not None:
|
||||||
|
return obj
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
async def create_resource(
|
async def create_resource(
|
||||||
self,
|
self,
|
||||||
device_id: DeviceSlot,
|
device_id: DeviceSlot,
|
||||||
@@ -583,6 +681,30 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
slot_on_deck: str = "",
|
slot_on_deck: str = "",
|
||||||
) -> CreateResourceReturn:
|
) -> CreateResourceReturn:
|
||||||
# 暂不支持多对同名父子同时存在
|
# 暂不支持多对同名父子同时存在
|
||||||
|
# 如果 slot_on_deck 不是空,并且 bind_locations 全为 0,则尝试通过 deck 的 slot 信息推算真实坐标
|
||||||
|
if slot_on_deck and (
|
||||||
|
(not hasattr(bind_locations, "x") or bind_locations.x == 0)
|
||||||
|
and (not hasattr(bind_locations, "y") or bind_locations.y == 0)
|
||||||
|
and (not hasattr(bind_locations, "z") or bind_locations.z == 0)
|
||||||
|
):
|
||||||
|
# 尝试通过 parent (deck) 查找 slot 坐标,parent 应是deck的id
|
||||||
|
deck_id = parent.split("/")[-1]
|
||||||
|
deck_obj = self._lookup_deck_for_slot(device_id, deck_id)
|
||||||
|
if deck_obj is not None and hasattr(deck_obj, "get_slot_location"):
|
||||||
|
try:
|
||||||
|
slot_location = deck_obj.get_slot_location(slot_on_deck)
|
||||||
|
bind_locations.x = slot_location.x
|
||||||
|
bind_locations.y = slot_location.y
|
||||||
|
bind_locations.z = slot_location.z
|
||||||
|
except Exception as e:
|
||||||
|
self.lab_logger().warning(
|
||||||
|
f"[Host Node] 无法通过deck({deck_id})获取slot({slot_on_deck})位置: {e}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.lab_logger().warning(
|
||||||
|
f"[Host Node] 找不到deck对象({deck_id})或其不支持get_slot_location, 无法修正bind_locations"
|
||||||
|
)
|
||||||
|
|
||||||
res_creation_input = {
|
res_creation_input = {
|
||||||
"id": res_id.split("/")[-1],
|
"id": res_id.split("/")[-1],
|
||||||
"name": res_id.split("/")[-1],
|
"name": res_id.split("/")[-1],
|
||||||
@@ -608,6 +730,45 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
init_new_res = initialize_resource(res_creation_input) # flatten的格式
|
init_new_res = initialize_resource(res_creation_input) # flatten的格式
|
||||||
|
|
||||||
|
# 若 init_new_res 中节点的 pose.position 与 pose.position3d 同时全为 0,
|
||||||
|
# 用上面通过 deck slot 反查得到的 bind_locations 覆盖(位置仍可能是默认 0)
|
||||||
|
bind_xyz = {
|
||||||
|
"x": float(getattr(bind_locations, "x", 0) or 0),
|
||||||
|
"y": float(getattr(bind_locations, "y", 0) or 0),
|
||||||
|
"z": float(getattr(bind_locations, "z", 0) or 0),
|
||||||
|
}
|
||||||
|
if any(v != 0.0 for v in bind_xyz.values()):
|
||||||
|
def _is_zero_xyz(p):
|
||||||
|
if not isinstance(p, dict):
|
||||||
|
return False
|
||||||
|
return (
|
||||||
|
float(p.get("x", 0) or 0) == 0.0
|
||||||
|
and float(p.get("y", 0) or 0) == 0.0
|
||||||
|
and float(p.get("z", 0) or 0) == 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
def _patch_node(node):
|
||||||
|
if not isinstance(node, dict):
|
||||||
|
return
|
||||||
|
pose = node.get("pose")
|
||||||
|
if not isinstance(pose, dict):
|
||||||
|
return
|
||||||
|
pos = pose.get("position")
|
||||||
|
pos3d = pose.get("position3d")
|
||||||
|
if _is_zero_xyz(pos) and _is_zero_xyz(pos3d):
|
||||||
|
pose["position"] = dict(bind_xyz)
|
||||||
|
pose["position3d"] = dict(bind_xyz)
|
||||||
|
|
||||||
|
def _walk(obj):
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
_patch_node(obj)
|
||||||
|
elif isinstance(obj, list):
|
||||||
|
for item in obj:
|
||||||
|
_walk(item)
|
||||||
|
|
||||||
|
_walk(init_new_res)
|
||||||
|
|
||||||
if len(init_new_res) > 1: # 一个物料,多个子节点
|
if len(init_new_res) > 1: # 一个物料,多个子节点
|
||||||
init_new_res = [init_new_res]
|
init_new_res = [init_new_res]
|
||||||
resources: List[Resource] | List[List[Resource]] = init_new_res # initialize_resource已经返回list[dict]
|
resources: List[Resource] | List[List[Resource]] = init_new_res # initialize_resource已经返回list[dict]
|
||||||
@@ -625,22 +786,17 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
response: List[str] = await self.create_resource_detailed(
|
response: List[str] = await self.create_resource_detailed(
|
||||||
resources, device_ids, bind_parent_id, bind_location, other_calling_param
|
resources, device_ids, bind_parent_id, bind_location, other_calling_param
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
|
||||||
assert len(response) == 1, "Create Resource应当只返回一个结果"
|
assert len(response) == 1, "Create Resource应当只返回一个结果"
|
||||||
for i in response:
|
for i in response:
|
||||||
res = json.loads(i)
|
res = json.loads(i)
|
||||||
if "suc" in res:
|
if "suc" in res and not res["suc"]:
|
||||||
raise ValueError(res.get("error"))
|
raise ValueError(res.get("error", "未知错误"))
|
||||||
return res
|
return res
|
||||||
except Exception as ex:
|
raise ValueError(f"创建资源时失败!响应为空")
|
||||||
pass
|
|
||||||
_n = "\n"
|
|
||||||
raise ValueError(f"创建资源时失败!\n{_n.join(response)}")
|
|
||||||
|
|
||||||
def initialize_device(self, device_id: str, device_config: ResourceDictInstance) -> None:
|
def initialize_device(self, device_id: str, device_config: ResourceDictInstance) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -1196,7 +1352,7 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
else:
|
else:
|
||||||
physical_setup_graph.nodes[resource_dict["id"]]["data"].update(resource_dict.get("data", {}))
|
physical_setup_graph.nodes[resource_dict["id"]]["data"].update(resource_dict.get("data", {}))
|
||||||
|
|
||||||
response.response = json.dumps(uuid_mapping) if success else "FAILED"
|
response.response = _fast_dumps_str(uuid_mapping) if success else "FAILED"
|
||||||
self.lab_logger().info(f"[Host Node-Resource] Resource tree add completed, success: {success}")
|
self.lab_logger().info(f"[Host Node-Resource] Resource tree add completed, success: {success}")
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
@@ -1212,6 +1368,7 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
|
|
||||||
resource_response = http_client.resource_tree_get(uuid_list, with_children)
|
resource_response = http_client.resource_tree_get(uuid_list, with_children)
|
||||||
response.response = json.dumps(resource_response)
|
response.response = json.dumps(resource_response)
|
||||||
|
self.lab_logger().trace(f"[Host Node-Resource] Resource tree get request callback {response.response}")
|
||||||
|
|
||||||
async def _resource_tree_action_remove_callback(self, data: dict, response: SerialCommand_Response):
|
async def _resource_tree_action_remove_callback(self, data: dict, response: SerialCommand_Response):
|
||||||
"""
|
"""
|
||||||
@@ -1270,9 +1427,26 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# 解析请求数据
|
# 解析请求数据
|
||||||
data = json.loads(request.command)
|
data = _fast_loads(request.command)
|
||||||
action = data["action"]
|
action = data["action"]
|
||||||
self.lab_logger().info(f"[Host Node-Resource] Resource tree {action} request received")
|
inner = data.get("data", {})
|
||||||
|
if action == "add":
|
||||||
|
mount_uuid = inner.get("mount_uuid", "?")[:8] if isinstance(inner, dict) else "?"
|
||||||
|
tree_data = inner.get("data", []) if isinstance(inner, dict) else inner
|
||||||
|
node_count = len(tree_data) if isinstance(tree_data, list) else "?"
|
||||||
|
source = f"mount={mount_uuid}.. nodes≈{node_count}"
|
||||||
|
elif action in ("get", "remove"):
|
||||||
|
uid_list = inner.get("data", inner) if isinstance(inner, dict) else inner
|
||||||
|
source = f"uuids={len(uid_list) if isinstance(uid_list, list) else '?'}"
|
||||||
|
elif action == "update":
|
||||||
|
tree_data = inner.get("data", []) if isinstance(inner, dict) else inner
|
||||||
|
node_count = len(tree_data) if isinstance(tree_data, list) else "?"
|
||||||
|
source = f"nodes≈{node_count}"
|
||||||
|
else:
|
||||||
|
source = ""
|
||||||
|
self.lab_logger().info(
|
||||||
|
f"[Host Node-Resource] Resource tree {action} request received ({source})"
|
||||||
|
)
|
||||||
data = data["data"]
|
data = data["data"]
|
||||||
if action == "add":
|
if action == "add":
|
||||||
await self._resource_tree_action_add_callback(data, response)
|
await self._resource_tree_action_add_callback(data, response)
|
||||||
|
|||||||
@@ -26,11 +26,11 @@
|
|||||||
"is_9320": true,
|
"is_9320": true,
|
||||||
"timeout": 10,
|
"timeout": 10,
|
||||||
"matrix_id": "",
|
"matrix_id": "",
|
||||||
"simulator": false,
|
"simulator": true,
|
||||||
"channel_num": 2,
|
"channel_num": 2,
|
||||||
"step_mode": true,
|
"step_mode": false,
|
||||||
"calibration_points": {
|
"calibration_points": {
|
||||||
"line_1": [[452.07,21.19], [313.88,21.19], [176.87,21.19], [39.08,21.19]],
|
"line_1": [[452.07,21.19], [313.88,21.19], [177.17,21.19], [39.08,21.19]],
|
||||||
"line_2": [[451.37,116.68], [313.28,116.88], [176.58,116.69], [38.58,117.18]],
|
"line_2": [[451.37,116.68], [313.28,116.88], [176.58,116.69], [38.58,117.18]],
|
||||||
"line_3": [[450.87,212.18], [312.98,212.38], [176.08,212.68], [38.08,213.18]],
|
"line_3": [[450.87,212.18], [312.98,212.38], [176.08,212.68], [38.08,213.18]],
|
||||||
"line_4": [[450.08,307.68], [312.18,307.89], [175.18,308.18], [37.58,309.18]]
|
"line_4": [[450.08,307.68], [312.18,307.89], [175.18,308.18], [37.58,309.18]]
|
||||||
|
|||||||
469
unilabos/test/experiments/virtual_bench.json
Normal file
469
unilabos/test/experiments/virtual_bench.json
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
{
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "workbench_1",
|
||||||
|
"name": "虚拟工作台",
|
||||||
|
"children": [],
|
||||||
|
"parent": null,
|
||||||
|
"type": "device",
|
||||||
|
"class": "virtual_workbench",
|
||||||
|
"position": {
|
||||||
|
"x": 400,
|
||||||
|
"y": 300,
|
||||||
|
"z": 0
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"arm_operation_time": 3.0,
|
||||||
|
"heating_time": 10.0,
|
||||||
|
"num_heating_stations": 3
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"status": "Ready",
|
||||||
|
"arm_state": "idle",
|
||||||
|
"message": "工作台就绪"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "PRCXI",
|
||||||
|
"name": "PRCXI",
|
||||||
|
"type": "device",
|
||||||
|
"class": "liquid_handler.prcxi",
|
||||||
|
"parent": "",
|
||||||
|
"pose": {
|
||||||
|
"size": {
|
||||||
|
"width": 562,
|
||||||
|
"height": 394,
|
||||||
|
"depth": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"axis": "Left",
|
||||||
|
"deck": {
|
||||||
|
"_resource_type": "unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Deck",
|
||||||
|
"_resource_child_name": "PRCXI_Deck"
|
||||||
|
},
|
||||||
|
"host": "10.20.30.184",
|
||||||
|
"port": 9999,
|
||||||
|
"debug": true,
|
||||||
|
"setup": true,
|
||||||
|
"is_9320": true,
|
||||||
|
"timeout": 10,
|
||||||
|
"matrix_id": "5de524d0-3f95-406c-86dd-f83626ebc7cb",
|
||||||
|
"simulator": true,
|
||||||
|
"channel_num": 2
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"reset_ok": true
|
||||||
|
},
|
||||||
|
"schema": {},
|
||||||
|
"description": "",
|
||||||
|
"model": null,
|
||||||
|
"position": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 240,
|
||||||
|
"z": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "PRCXI_Deck",
|
||||||
|
"name": "PRCXI_Deck",
|
||||||
|
"children": [],
|
||||||
|
"parent": "PRCXI",
|
||||||
|
"type": "deck",
|
||||||
|
"class": "",
|
||||||
|
"position": {
|
||||||
|
"x": 10,
|
||||||
|
"y": 10,
|
||||||
|
"z": 0
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"type": "PRCXI9300Deck",
|
||||||
|
"size_x": 542,
|
||||||
|
"size_y": 374,
|
||||||
|
"size_z": 0,
|
||||||
|
"rotation": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"z": 0,
|
||||||
|
"type": "Rotation"
|
||||||
|
},
|
||||||
|
"category": "deck",
|
||||||
|
"barcode": null,
|
||||||
|
"preferred_pickup_location": null,
|
||||||
|
"sites": [
|
||||||
|
{
|
||||||
|
"label": "T1",
|
||||||
|
"visible": true,
|
||||||
|
"occupied_by": null,
|
||||||
|
"position": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"z": 0
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"width": 128.0,
|
||||||
|
"height": 86,
|
||||||
|
"depth": 0
|
||||||
|
},
|
||||||
|
"content_type": [
|
||||||
|
"container",
|
||||||
|
"plate",
|
||||||
|
"tip_rack",
|
||||||
|
"plates",
|
||||||
|
"tip_racks",
|
||||||
|
"tube_rack",
|
||||||
|
"adaptor"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "T2",
|
||||||
|
"visible": true,
|
||||||
|
"occupied_by": null,
|
||||||
|
"position": {
|
||||||
|
"x": 138,
|
||||||
|
"y": 0,
|
||||||
|
"z": 0
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"width": 128.0,
|
||||||
|
"height": 86,
|
||||||
|
"depth": 0
|
||||||
|
},
|
||||||
|
"content_type": [
|
||||||
|
"plate",
|
||||||
|
"tip_rack",
|
||||||
|
"plates",
|
||||||
|
"tip_racks",
|
||||||
|
"tube_rack",
|
||||||
|
"adaptor"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "T3",
|
||||||
|
"visible": true,
|
||||||
|
"occupied_by": null,
|
||||||
|
"position": {
|
||||||
|
"x": 276,
|
||||||
|
"y": 0,
|
||||||
|
"z": 0
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"width": 128.0,
|
||||||
|
"height": 86,
|
||||||
|
"depth": 0
|
||||||
|
},
|
||||||
|
"content_type": [
|
||||||
|
"plate",
|
||||||
|
"tip_rack",
|
||||||
|
"plates",
|
||||||
|
"tip_racks",
|
||||||
|
"tube_rack",
|
||||||
|
"adaptor"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "T4",
|
||||||
|
"visible": true,
|
||||||
|
"occupied_by": null,
|
||||||
|
"position": {
|
||||||
|
"x": 414,
|
||||||
|
"y": 0,
|
||||||
|
"z": 0
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"width": 128.0,
|
||||||
|
"height": 86,
|
||||||
|
"depth": 0
|
||||||
|
},
|
||||||
|
"content_type": [
|
||||||
|
"plate",
|
||||||
|
"tip_rack",
|
||||||
|
"plates",
|
||||||
|
"tip_racks",
|
||||||
|
"tube_rack",
|
||||||
|
"adaptor"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "T5",
|
||||||
|
"visible": true,
|
||||||
|
"occupied_by": null,
|
||||||
|
"position": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 96,
|
||||||
|
"z": 0
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"width": 128.0,
|
||||||
|
"height": 86,
|
||||||
|
"depth": 0
|
||||||
|
},
|
||||||
|
"content_type": [
|
||||||
|
"plate",
|
||||||
|
"tip_rack",
|
||||||
|
"plates",
|
||||||
|
"tip_racks",
|
||||||
|
"tube_rack",
|
||||||
|
"adaptor"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "T6",
|
||||||
|
"visible": true,
|
||||||
|
"occupied_by": null,
|
||||||
|
"position": {
|
||||||
|
"x": 138,
|
||||||
|
"y": 96,
|
||||||
|
"z": 0
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"width": 128.0,
|
||||||
|
"height": 86,
|
||||||
|
"depth": 0
|
||||||
|
},
|
||||||
|
"content_type": [
|
||||||
|
"plate",
|
||||||
|
"tip_rack",
|
||||||
|
"plates",
|
||||||
|
"tip_racks",
|
||||||
|
"tube_rack",
|
||||||
|
"adaptor"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "T7",
|
||||||
|
"visible": true,
|
||||||
|
"occupied_by": null,
|
||||||
|
"position": {
|
||||||
|
"x": 276,
|
||||||
|
"y": 96,
|
||||||
|
"z": 0
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"width": 128.0,
|
||||||
|
"height": 86,
|
||||||
|
"depth": 0
|
||||||
|
},
|
||||||
|
"content_type": [
|
||||||
|
"plate",
|
||||||
|
"tip_rack",
|
||||||
|
"plates",
|
||||||
|
"tip_racks",
|
||||||
|
"tube_rack",
|
||||||
|
"adaptor"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "T8",
|
||||||
|
"visible": true,
|
||||||
|
"occupied_by": null,
|
||||||
|
"position": {
|
||||||
|
"x": 414,
|
||||||
|
"y": 96,
|
||||||
|
"z": 0
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"width": 128.0,
|
||||||
|
"height": 86,
|
||||||
|
"depth": 0
|
||||||
|
},
|
||||||
|
"content_type": [
|
||||||
|
"plate",
|
||||||
|
"tip_rack",
|
||||||
|
"plates",
|
||||||
|
"tip_racks",
|
||||||
|
"tube_rack",
|
||||||
|
"adaptor"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "T9",
|
||||||
|
"visible": true,
|
||||||
|
"occupied_by": null,
|
||||||
|
"position": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 192,
|
||||||
|
"z": 0
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"width": 128.0,
|
||||||
|
"height": 86,
|
||||||
|
"depth": 0
|
||||||
|
},
|
||||||
|
"content_type": [
|
||||||
|
"plate",
|
||||||
|
"tip_rack",
|
||||||
|
"plates",
|
||||||
|
"tip_racks",
|
||||||
|
"tube_rack",
|
||||||
|
"adaptor"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "T10",
|
||||||
|
"visible": true,
|
||||||
|
"occupied_by": null,
|
||||||
|
"position": {
|
||||||
|
"x": 138,
|
||||||
|
"y": 192,
|
||||||
|
"z": 0
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"width": 128.0,
|
||||||
|
"height": 86,
|
||||||
|
"depth": 0
|
||||||
|
},
|
||||||
|
"content_type": [
|
||||||
|
"plate",
|
||||||
|
"tip_rack",
|
||||||
|
"plates",
|
||||||
|
"tip_racks",
|
||||||
|
"tube_rack",
|
||||||
|
"adaptor"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "T11",
|
||||||
|
"visible": true,
|
||||||
|
"occupied_by": null,
|
||||||
|
"position": {
|
||||||
|
"x": 276,
|
||||||
|
"y": 192,
|
||||||
|
"z": 0
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"width": 128.0,
|
||||||
|
"height": 86,
|
||||||
|
"depth": 0
|
||||||
|
},
|
||||||
|
"content_type": [
|
||||||
|
"plate",
|
||||||
|
"tip_rack",
|
||||||
|
"plates",
|
||||||
|
"tip_racks",
|
||||||
|
"tube_rack",
|
||||||
|
"adaptor"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "T12",
|
||||||
|
"visible": true,
|
||||||
|
"occupied_by": null,
|
||||||
|
"position": {
|
||||||
|
"x": 414,
|
||||||
|
"y": 192,
|
||||||
|
"z": 0
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"width": 128.0,
|
||||||
|
"height": 86,
|
||||||
|
"depth": 0
|
||||||
|
},
|
||||||
|
"content_type": [
|
||||||
|
"plate",
|
||||||
|
"tip_rack",
|
||||||
|
"plates",
|
||||||
|
"tip_racks",
|
||||||
|
"tube_rack",
|
||||||
|
"adaptor"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "T13",
|
||||||
|
"visible": true,
|
||||||
|
"occupied_by": null,
|
||||||
|
"position": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 288,
|
||||||
|
"z": 0
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"width": 128.0,
|
||||||
|
"height": 86,
|
||||||
|
"depth": 0
|
||||||
|
},
|
||||||
|
"content_type": [
|
||||||
|
"plate",
|
||||||
|
"tip_rack",
|
||||||
|
"plates",
|
||||||
|
"tip_racks",
|
||||||
|
"tube_rack",
|
||||||
|
"adaptor"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "T14",
|
||||||
|
"visible": true,
|
||||||
|
"occupied_by": null,
|
||||||
|
"position": {
|
||||||
|
"x": 138,
|
||||||
|
"y": 288,
|
||||||
|
"z": 0
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"width": 128.0,
|
||||||
|
"height": 86,
|
||||||
|
"depth": 0
|
||||||
|
},
|
||||||
|
"content_type": [
|
||||||
|
"plate",
|
||||||
|
"tip_rack",
|
||||||
|
"plates",
|
||||||
|
"tip_racks",
|
||||||
|
"tube_rack",
|
||||||
|
"adaptor"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "T15",
|
||||||
|
"visible": true,
|
||||||
|
"occupied_by": null,
|
||||||
|
"position": {
|
||||||
|
"x": 276,
|
||||||
|
"y": 288,
|
||||||
|
"z": 0
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"width": 128.0,
|
||||||
|
"height": 86,
|
||||||
|
"depth": 0
|
||||||
|
},
|
||||||
|
"content_type": [
|
||||||
|
"plate",
|
||||||
|
"tip_rack",
|
||||||
|
"plates",
|
||||||
|
"tip_racks",
|
||||||
|
"tube_rack",
|
||||||
|
"adaptor"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "T16",
|
||||||
|
"visible": true,
|
||||||
|
"occupied_by": null,
|
||||||
|
"position": {
|
||||||
|
"x": 414,
|
||||||
|
"y": 288,
|
||||||
|
"z": 0
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"width": 128.0,
|
||||||
|
"height": 86,
|
||||||
|
"depth": 0
|
||||||
|
},
|
||||||
|
"content_type": [
|
||||||
|
"plate",
|
||||||
|
"tip_rack",
|
||||||
|
"plates",
|
||||||
|
"tip_racks",
|
||||||
|
"tube_rack",
|
||||||
|
"adaptor"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": []
|
||||||
|
}
|
||||||
@@ -33,10 +33,76 @@ _USE_UV: Optional[bool] = None
|
|||||||
def _has_uv() -> bool:
|
def _has_uv() -> bool:
|
||||||
global _USE_UV
|
global _USE_UV
|
||||||
if _USE_UV is None:
|
if _USE_UV is None:
|
||||||
_USE_UV = shutil.which("uv") is not None
|
uv_path = shutil.which("uv")
|
||||||
|
if not uv_path:
|
||||||
|
_USE_UV = False
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
result = subprocess.run([uv_path, "--version"], capture_output=True, text=True, timeout=10)
|
||||||
|
_USE_UV = result.returncode == 0
|
||||||
|
except Exception:
|
||||||
|
_USE_UV = False
|
||||||
return _USE_UV
|
return _USE_UV
|
||||||
|
|
||||||
|
|
||||||
|
def _install_command(installer: str, package: str, upgrade: bool, is_chinese: bool) -> List[str]:
|
||||||
|
if installer == "uv":
|
||||||
|
cmd = ["uv", "pip", "install"]
|
||||||
|
if upgrade:
|
||||||
|
cmd.append("--upgrade")
|
||||||
|
cmd.append(package)
|
||||||
|
if is_chinese:
|
||||||
|
cmd.extend(["--index-url", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"])
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
cmd = [sys.executable, "-m", "pip", "install", "--disable-pip-version-check"]
|
||||||
|
if upgrade:
|
||||||
|
cmd.append("--upgrade")
|
||||||
|
cmd.append(package)
|
||||||
|
if is_chinese:
|
||||||
|
cmd.extend(["-i", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"])
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
def _installer_candidates() -> List[str]:
|
||||||
|
installers: List[str] = []
|
||||||
|
if _has_uv():
|
||||||
|
installers.append("uv")
|
||||||
|
installers.append("pip")
|
||||||
|
return installers
|
||||||
|
|
||||||
|
|
||||||
|
def _git_url_from_requirement(requirement: str) -> Optional[str]:
|
||||||
|
if not requirement.startswith("git+"):
|
||||||
|
return None
|
||||||
|
return requirement[4:].split("#", 1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _repo_dir_name(git_url: str) -> str:
|
||||||
|
repo_name = git_url.rstrip("/").rsplit("/", 1)[-1]
|
||||||
|
return repo_name[:-4] if repo_name.endswith(".git") else repo_name
|
||||||
|
|
||||||
|
|
||||||
|
def _print_manual_git_install_hint(requirement: str) -> None:
|
||||||
|
git_url = _git_url_from_requirement(requirement)
|
||||||
|
if not git_url:
|
||||||
|
return
|
||||||
|
|
||||||
|
repo_dir = _repo_dir_name(git_url)
|
||||||
|
install_cmd = "uv pip install -e ." if _has_uv() else f"{sys.executable} -m pip install -e ."
|
||||||
|
if _is_chinese_locale() and not _has_uv():
|
||||||
|
install_cmd += " -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"
|
||||||
|
|
||||||
|
print_status("Git 依赖自动安装失败,通常是网络连接被重置或代码托管站点暂时不可达。", "warning")
|
||||||
|
print_status("可以手动拉取代码后在本地安装:", "warning")
|
||||||
|
print_status(f" git clone {git_url}", "warning")
|
||||||
|
print_status(f" cd {repo_dir}", "warning")
|
||||||
|
print_status(" git pull", "warning")
|
||||||
|
print_status(f" {install_cmd}", "warning")
|
||||||
|
print_status(f"如果目录 {repo_dir} 已存在,直接进入该目录执行 git pull 后再安装。", "warning")
|
||||||
|
print_status("如果 git clone 仍失败,请切换网络/代理,或从浏览器下载源码后进入源码目录执行本地安装命令。", "warning")
|
||||||
|
|
||||||
|
|
||||||
def _install_packages(
|
def _install_packages(
|
||||||
packages: List[str],
|
packages: List[str],
|
||||||
upgrade: bool = False,
|
upgrade: bool = False,
|
||||||
@@ -53,7 +119,7 @@ def _install_packages(
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
is_chinese = _is_chinese_locale()
|
is_chinese = _is_chinese_locale()
|
||||||
use_uv = _has_uv()
|
installers = _installer_candidates()
|
||||||
failed: List[str] = []
|
failed: List[str] = []
|
||||||
|
|
||||||
for pkg in packages:
|
for pkg in packages:
|
||||||
@@ -63,35 +129,30 @@ def _install_packages(
|
|||||||
else:
|
else:
|
||||||
print_status(f"正在{action_word} {pkg}...", "info")
|
print_status(f"正在{action_word} {pkg}...", "info")
|
||||||
|
|
||||||
if use_uv:
|
pkg_installed = False
|
||||||
cmd = ["uv", "pip", "install"]
|
last_error = "unknown error"
|
||||||
if upgrade:
|
|
||||||
cmd.append("--upgrade")
|
|
||||||
cmd.append(pkg)
|
|
||||||
if is_chinese:
|
|
||||||
cmd.extend(["--index-url", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"])
|
|
||||||
else:
|
|
||||||
cmd = [sys.executable, "-m", "pip", "install"]
|
|
||||||
if upgrade:
|
|
||||||
cmd.append("--upgrade")
|
|
||||||
cmd.append(pkg)
|
|
||||||
if is_chinese:
|
|
||||||
cmd.extend(["-i", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"])
|
|
||||||
|
|
||||||
|
for installer in installers:
|
||||||
|
cmd = _install_command(installer, pkg, upgrade, is_chinese)
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
installer = "uv" if use_uv else "pip"
|
|
||||||
print_status(f"✓ {pkg} {action_word}成功 (via {installer})", "success")
|
print_status(f"✓ {pkg} {action_word}成功 (via {installer})", "success")
|
||||||
else:
|
pkg_installed = True
|
||||||
stderr_short = result.stderr.strip().split("\n")[-1] if result.stderr else "unknown error"
|
break
|
||||||
print_status(f"× {pkg} {action_word}失败: {stderr_short}", "error")
|
|
||||||
failed.append(pkg)
|
last_error = result.stderr.strip().split("\n")[-1] if result.stderr else "unknown error"
|
||||||
|
print_status(f"× {pkg} {action_word}失败 (via {installer}): {last_error}", "warning")
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
print_status(f"× {pkg} {action_word}超时 (300s)", "error")
|
last_error = "timeout after 300s"
|
||||||
failed.append(pkg)
|
print_status(f"× {pkg} {action_word}超时 (via {installer}, 300s)", "warning")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print_status(f"× {pkg} {action_word}异常: {e}", "error")
|
last_error = str(e)
|
||||||
|
print_status(f"× {pkg} {action_word}异常 (via {installer}): {e}", "warning")
|
||||||
|
|
||||||
|
if not pkg_installed:
|
||||||
|
print_status(f"× {pkg} {action_word}失败: {last_error}", "error")
|
||||||
|
_print_manual_git_install_hint(pkg)
|
||||||
failed.append(pkg)
|
failed.append(pkg)
|
||||||
|
|
||||||
if failed:
|
if failed:
|
||||||
@@ -188,7 +249,13 @@ class EnvironmentChecker:
|
|||||||
"crcmod": "crcmod-plus",
|
"crcmod": "crcmod-plus",
|
||||||
}
|
}
|
||||||
|
|
||||||
self.special_packages = {"pylabrobot": "git+https://github.com/Xuwznln/pylabrobot.git"}
|
# 中文 locale 下走 Gitee 镜像,规避 GitHub 拉取失败
|
||||||
|
pylabrobot_url = (
|
||||||
|
"git+https://gitee.com/xuwznln/pylabrobot.git"
|
||||||
|
if _is_chinese_locale()
|
||||||
|
else "git+https://github.com/Xuwznln/pylabrobot.git"
|
||||||
|
)
|
||||||
|
self.special_packages = {"pylabrobot": pylabrobot_url}
|
||||||
|
|
||||||
self.version_requirements = {
|
self.version_requirements = {
|
||||||
"msgcenterpy": "0.1.8",
|
"msgcenterpy": "0.1.8",
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ class ImportManager:
|
|||||||
"ast_analysis_success": False,
|
"ast_analysis_success": False,
|
||||||
"import_map": {},
|
"import_map": {},
|
||||||
"init_params": [],
|
"init_params": [],
|
||||||
|
"init_docstring": None,
|
||||||
"status_methods": {},
|
"status_methods": {},
|
||||||
"action_methods": {},
|
"action_methods": {},
|
||||||
}
|
}
|
||||||
@@ -251,6 +252,7 @@ class ImportManager:
|
|||||||
|
|
||||||
# 映射到统一字段名(与 registry.py complete_registry 消费端一致)
|
# 映射到统一字段名(与 registry.py complete_registry 消费端一致)
|
||||||
result["init_params"] = body.get("init_params", [])
|
result["init_params"] = body.get("init_params", [])
|
||||||
|
result["init_docstring"] = body.get("init_docstring")
|
||||||
result["status_methods"] = body.get("status_properties", {})
|
result["status_methods"] = body.get("status_properties", {})
|
||||||
result["action_methods"] = {
|
result["action_methods"] = {
|
||||||
k: {
|
k: {
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ try:
|
|||||||
default=json_default,
|
default=json_default,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def fast_loads(data) -> dict:
|
||||||
|
"""JSON 反序列化,优先使用 orjson。接受 str / bytes。"""
|
||||||
|
return orjson.loads(data)
|
||||||
|
|
||||||
|
def fast_dumps_str(obj, **kwargs) -> str:
|
||||||
|
"""JSON 序列化为 str,优先使用 orjson。用于需要 str 而非 bytes 的场景(如 ROS msg)。"""
|
||||||
|
return orjson.dumps(obj, option=orjson.OPT_NON_STR_KEYS, default=json_default).decode("utf-8")
|
||||||
|
|
||||||
def normalize_json(info: dict) -> dict:
|
def normalize_json(info: dict) -> dict:
|
||||||
"""经 JSON 序列化/反序列化一轮来清理非标准类型。"""
|
"""经 JSON 序列化/反序列化一轮来清理非标准类型。"""
|
||||||
return orjson.loads(orjson.dumps(info, default=json_default))
|
return orjson.loads(orjson.dumps(info, default=json_default))
|
||||||
@@ -29,6 +37,14 @@ except ImportError:
|
|||||||
def fast_dumps_pretty(obj, **kwargs) -> bytes: # type: ignore[misc]
|
def fast_dumps_pretty(obj, **kwargs) -> bytes: # type: ignore[misc]
|
||||||
return json.dumps(obj, indent=2, ensure_ascii=False, cls=TypeEncoder).encode("utf-8")
|
return json.dumps(obj, indent=2, ensure_ascii=False, cls=TypeEncoder).encode("utf-8")
|
||||||
|
|
||||||
|
def fast_loads(data) -> dict: # type: ignore[misc]
|
||||||
|
if isinstance(data, bytes):
|
||||||
|
data = data.decode("utf-8")
|
||||||
|
return json.loads(data)
|
||||||
|
|
||||||
|
def fast_dumps_str(obj, **kwargs) -> str: # type: ignore[misc]
|
||||||
|
return json.dumps(obj, ensure_ascii=False, cls=TypeEncoder)
|
||||||
|
|
||||||
def normalize_json(info: dict) -> dict: # type: ignore[misc]
|
def normalize_json(info: dict) -> dict: # type: ignore[misc]
|
||||||
return json.loads(json.dumps(info, ensure_ascii=False, cls=TypeEncoder))
|
return json.loads(json.dumps(info, ensure_ascii=False, cls=TypeEncoder))
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
- 遍历所有 reagent,按 slot 去重,为每个唯一的 slot 创建一个板子
|
- 遍历所有 reagent,按 slot 去重,为每个唯一的 slot 创建一个板子
|
||||||
- 所有 create_resource 节点的 parent_uuid 指向 Group 节点,minimized=true
|
- 所有 create_resource 节点的 parent_uuid 指向 Group 节点,minimized=true
|
||||||
- 生成参数:
|
- 生成参数:
|
||||||
res_id / 节点 name / display_name: {匹配后的 prcxi 类名}_slot_{槽位}
|
res_id / 节点 name / display_name: {匹配后的 target 类名}_slot_{槽位}
|
||||||
device_id: /PRCXI
|
device_id: /PRCXI
|
||||||
class_name: 与 res_id 中类型一致(如 PRCXI 384/96 孔板注册类)
|
class_name: 与 res_id 中类型一致(如 PRCXI 384/96 孔板注册类)
|
||||||
parent: /PRCXI/PRCXI_Deck
|
parent: /PRCXI/PRCXI_Deck
|
||||||
@@ -38,12 +38,18 @@
|
|||||||
- 首先创建一个 Group 节点(type="Group", minimized=true),用于包含所有 set_liquid_from_plate 节点
|
- 首先创建一个 Group 节点(type="Group", minimized=true),用于包含所有 set_liquid_from_plate 节点
|
||||||
- 遍历所有 reagent,为每个试剂创建 set_liquid_from_plate 节点
|
- 遍历所有 reagent,为每个试剂创建 set_liquid_from_plate 节点
|
||||||
- 所有 set_liquid_from_plate 节点的 parent_uuid 指向 Group 节点,minimized=true
|
- 所有 set_liquid_from_plate 节点的 parent_uuid 指向 Group 节点,minimized=true
|
||||||
- 生成参数:
|
- 生成参数(P3 框选化,新主路径):
|
||||||
|
wells: [
|
||||||
|
{id, name, parent: labware_id, type: "well"},
|
||||||
|
...
|
||||||
|
](list[dict],每孔一个资源引用;前端通过 placeholder 框选 well 时回填 uuid)
|
||||||
|
liquid_names: ["cell_lines", "cell_lines", "cell_lines"](与 wells 数量一致)
|
||||||
|
volumes: [1e5, 1e5, 1e5](与 wells 数量一致,默认体积)
|
||||||
|
# 兼容字段(旧 runtime / 旧 schema fallback):
|
||||||
plate: [](通过连接传递,来自 create_resource 的 labware)
|
plate: [](通过连接传递,来自 create_resource 的 labware)
|
||||||
well_names: ["A1", "A3", "A5"](来自 reagent 的 well 数组)
|
well_names: ["A1", "A3", "A5"](来自 reagent 的 well 数组)
|
||||||
liquid_names: ["cell_lines", "cell_lines", "cell_lines"](与 well 数量一致)
|
- 输入连接: create_resource (labware) -> set_liquid_from_plate (wells_identifier)
|
||||||
volumes: [1e5, 1e5, 1e5](与 well 数量一致,默认体积)
|
(P3 §3.4.3 简化方案:source_port 仍为 labware;placeholder 内部把 labware.wells.@flatten 映射到 wells 字段)
|
||||||
- 输入连接: create_resource (labware) -> set_liquid_from_plate (input_plate)
|
|
||||||
- 输出端口: output_wells(用于连接 transfer_liquid)
|
- 输出端口: output_wells(用于连接 transfer_liquid)
|
||||||
- 控制流: set_liquid_from_plate 连接在所有 create_resource 之后,通过 ready 端口串联
|
- 控制流: set_liquid_from_plate 连接在所有 create_resource 之后,通过 ready 端口串联
|
||||||
|
|
||||||
@@ -69,7 +75,7 @@
|
|||||||
|
|
||||||
物料流:
|
物料流:
|
||||||
[create_resource] --labware--> [set_liquid_from_plate] --output_wells--> [transfer_liquid] --sources_out/targets_out--> [下一个 transfer_liquid]
|
[create_resource] --labware--> [set_liquid_from_plate] --output_wells--> [transfer_liquid] --sources_out/targets_out--> [下一个 transfer_liquid]
|
||||||
(slot=1) (cell_lines) (input_plate) (sources_identifier) (sources_identifier)
|
(slot=1) (cell_lines) (wells_identifier) (sources_identifier) (sources_identifier)
|
||||||
(slot=4) (Liquid_1) (targets_identifier) (targets_identifier)
|
(slot=4) (Liquid_1) (targets_identifier) (targets_identifier)
|
||||||
|
|
||||||
==================== 端口映射 ====================
|
==================== 端口映射 ====================
|
||||||
@@ -78,8 +84,8 @@ create_resource:
|
|||||||
输出: labware
|
输出: labware
|
||||||
|
|
||||||
set_liquid_from_plate:
|
set_liquid_from_plate:
|
||||||
输入: input_plate
|
输入: wells -> wells_identifier(P3 主路径;input_plate 作旧 schema fallback 仍存在)
|
||||||
输出: output_plate, output_wells
|
输出: output_plate, output_wells, output_volumes
|
||||||
|
|
||||||
transfer_liquid:
|
transfer_liquid:
|
||||||
输入: sources -> sources_identifier, targets -> targets_identifier
|
输入: sources -> sources_identifier, targets -> targets_identifier
|
||||||
@@ -102,11 +108,24 @@ transfer_liquid:
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
|
import warnings
|
||||||
|
|
||||||
import networkx as nx
|
import networkx as nx
|
||||||
from networkx.drawing.nx_agraph import to_agraph
|
from networkx.drawing.nx_agraph import to_agraph
|
||||||
import matplotlib.pyplot as plt
|
import matplotlib.pyplot as plt
|
||||||
from typing import Dict, List, Any, Tuple, Optional
|
from typing import Dict, List, Any, Set, Tuple, Optional
|
||||||
|
|
||||||
|
from unilabos.workflow.labware_mapping import (
|
||||||
|
infer_kind as _yaml_infer_kind,
|
||||||
|
remap_slot as _yaml_remap_slot,
|
||||||
|
resolve_target_class as _yaml_resolve_target_class,
|
||||||
|
)
|
||||||
|
|
||||||
|
# P6.1 默认目标仪器;caller 不显式传 target_device 时使用。
|
||||||
|
# 注意:这里写 "prcxi" 是 P6 历史兜底(与原版 _tip_prcxi_class_for_max_ul、
|
||||||
|
# _apply_prcxi_labware_auto_match 走 PRCXI 模板的语义一致),与 YAML
|
||||||
|
# 顶层是否声明 prcxi 段无关。
|
||||||
|
DEFAULT_TARGET_DEVICE = "prcxi"
|
||||||
|
|
||||||
Json = Dict[str, Any]
|
Json = Dict[str, Any]
|
||||||
|
|
||||||
@@ -142,14 +161,25 @@ PARAM_RENAME_MAPPING = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _map_deck_slot(raw_slot: str, object_type: str = "") -> str:
|
def _map_deck_slot(
|
||||||
"""协议槽位 -> 实际 deck:4→13,8→14,12+trash→16,其余不变。"""
|
raw_slot: str,
|
||||||
s = "" if raw_slot is None else str(raw_slot).strip()
|
object_type: str = "",
|
||||||
if not s:
|
*,
|
||||||
return ""
|
target_device: str = DEFAULT_TARGET_DEVICE,
|
||||||
if s == "12" and (object_type or "").strip().lower() == "trash":
|
target_model: Optional[str] = None,
|
||||||
return "16"
|
) -> str:
|
||||||
return {"4": "13", "8": "14"}.get(s, s)
|
"""协议槽位 -> 实际 deck:默认 4→13,8→14,12+trash→16,其余不变。
|
||||||
|
|
||||||
|
P6.1.1:``slot_remap`` 内嵌在 ``target_devices.<target_device>`` 下,
|
||||||
|
可由 ``target_devices.<target_device>.models.<target_model>.slot_remap`` 进一步覆盖。
|
||||||
|
转调 :func:`labware_mapping.remap_slot`,走 4 段 fallback 链(model → device → default → builtin)。
|
||||||
|
"""
|
||||||
|
return _yaml_remap_slot(
|
||||||
|
raw_slot,
|
||||||
|
object_type,
|
||||||
|
target_device=target_device,
|
||||||
|
target_model=target_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _labware_def_index(labware_defs: Optional[List[Dict[str, Any]]]) -> Dict[str, Dict[str, Any]]:
|
def _labware_def_index(labware_defs: Optional[List[Dict[str, Any]]]) -> Dict[str, Dict[str, Any]]:
|
||||||
@@ -169,27 +199,16 @@ def _labware_hint_text(labware_id: str, item: Dict[str, Any]) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _infer_reagent_kind(labware_id: str, item: Dict[str, Any]) -> str:
|
def _infer_reagent_kind(labware_id: str, item: Dict[str, Any]) -> str:
|
||||||
ot = (item.get("object") or "").strip().lower()
|
"""labware → ``plate / tip_rack / tube_rack / trash``。
|
||||||
if ot == "trash":
|
|
||||||
return "trash"
|
P6:转调 ``labware_mapping.infer_kind``,匹配规则由
|
||||||
if ot == "tiprack":
|
``Uni-Lab-OS/labware_mapping.yaml`` 的 ``kinds`` 段声明(顺序敏感、首个命中胜出)。
|
||||||
return "tip_rack"
|
object 字段(``trash`` / ``tiprack``)优先级保留在 YAML loader 内部。
|
||||||
lid = _labware_hint_text(labware_id, item)
|
"""
|
||||||
if "trash" in lid:
|
return _yaml_infer_kind(
|
||||||
return "trash"
|
_labware_hint_text(labware_id, item),
|
||||||
# tiprack / tip + rack(顺序在 tuberack 之前)
|
(item.get("object") or "") if isinstance(item, dict) else "",
|
||||||
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:
|
def _infer_tube_rack_num_positions(labware_id: str, item: Dict[str, Any]) -> int:
|
||||||
@@ -231,8 +250,15 @@ def _infer_plate_num_children_from_wells(wells: Any) -> Optional[int]:
|
|||||||
|
|
||||||
|
|
||||||
def _infer_plate_num_children_from_labware_hint(labware_id: str, item: Dict[str, Any]) -> Optional[int]:
|
def _infer_plate_num_children_from_labware_hint(labware_id: str, item: Dict[str, Any]) -> Optional[int]:
|
||||||
"""从 labware 命名(如 custom_384_wellplate、nest_96_wellplate)解析孔数,供模板匹配。"""
|
"""从 labware 命名(如 custom_384_wellplate、nest_96_wellplate)解析孔数,供模板匹配。
|
||||||
hint = _labware_hint_text(labware_id, item)
|
|
||||||
|
P6 hint bug 修复(2026-05-22):hint 只用 ``item["labware"]``,**不**拼上
|
||||||
|
``labware_id``(reagent_key 业务名,如 ``samples_6``、``samples_24`` 末尾数字
|
||||||
|
会被宽松正则 ``[_\\s](\\d+)[_\\s]`` 误识别为孔板规格,进而触发
|
||||||
|
``_apply_target_labware_class_auto_match`` fallback 到 PRCXI 4-孔 trough 模板,
|
||||||
|
最终把同 deck 槽位上所有 reagent 的 ``target_class_name`` unify 成错误的 trough class)。
|
||||||
|
"""
|
||||||
|
hint = str(item.get("labware", "") or "").lower()
|
||||||
m = re.search(
|
m = re.search(
|
||||||
r"\b(1536|384|96|48|24|12|6)(\s*[-_]?\s*well|wellplate|_well_)",
|
r"\b(1536|384|96|48|24|12|6)(\s*[-_]?\s*well|wellplate|_well_)",
|
||||||
hint,
|
hint,
|
||||||
@@ -291,20 +317,24 @@ def _flatten_transfer_vols(value: Any) -> List[float]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _tip_prcxi_class_for_max_ul(max_ul: float) -> str:
|
|
||||||
"""按移液最大体积分档推介 PRCXI tip 类名:≤10 µL → 10µL;<300 → 300µL;否则 1000µL。"""
|
|
||||||
if max_ul <= 10:
|
|
||||||
return "PRCXI_10uL_Tips"
|
|
||||||
if max_ul < 300:
|
|
||||||
return "PRCXI_300ul_Tips"
|
|
||||||
return "PRCXI_1000uL_Tips"
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_tip_rack_class_from_transfer_volumes(
|
def _apply_tip_rack_class_from_transfer_volumes(
|
||||||
labware_info: Dict[str, Dict[str, Any]],
|
labware_info: Dict[str, Dict[str, Any]],
|
||||||
protocol_steps_refactored: List[Dict[str, Any]],
|
protocol_steps_refactored: List[Dict[str, Any]],
|
||||||
|
*,
|
||||||
|
target_device: str = DEFAULT_TARGET_DEVICE,
|
||||||
|
target_model: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""根据各 ``transfer_liquid`` 的 asp_vols/dis_vols 为对应 ``tip_racks`` 写入 ``prcxi_class_name``。"""
|
"""根据各 ``transfer_liquid`` 的 asp_vols/dis_vols 为对应 ``tip_racks`` 写入 ``target_class_name``。
|
||||||
|
|
||||||
|
P6.1:tip 量程档不再硬编码 PRCXI 三档,改为查
|
||||||
|
``labware_mapping.yaml`` 的 ``target_devices.<target_device>.rules``(tip_rack
|
||||||
|
+ hole_count=96 + volume_max 闭区间)。YAML 未命中时 fallback 到
|
||||||
|
``CLASS_NAMES_MAPPING['tip_rack']``(保守默认 PRCXI_300ul_Tips)。
|
||||||
|
|
||||||
|
P6.1.1:``target_model`` 透传给 :func:`_yaml_resolve_target_class`,
|
||||||
|
允许同厂商不同型号声明不同 tip 量程档(如 PRCXI 9320 与 4040 用同档,
|
||||||
|
Beckman i7 与 i5 可能用不同档)。
|
||||||
|
"""
|
||||||
tip_to_max_ul: Dict[str, float] = {}
|
tip_to_max_ul: Dict[str, float] = {}
|
||||||
|
|
||||||
for step in protocol_steps_refactored:
|
for step in protocol_steps_refactored:
|
||||||
@@ -326,13 +356,17 @@ def _apply_tip_rack_class_from_transfer_volumes(
|
|||||||
step_max = max(nums)
|
step_max = max(nums)
|
||||||
tip_to_max_ul[tip_key] = max(tip_to_max_ul.get(tip_key, 0.0), step_max)
|
tip_to_max_ul[tip_key] = max(tip_to_max_ul.get(tip_key, 0.0), step_max)
|
||||||
|
|
||||||
|
default_tip_cls = CLASS_NAMES_MAPPING.get("tip_rack", "PRCXI_300ul_Tips")
|
||||||
for tip_key, max_ul in tip_to_max_ul.items():
|
for tip_key, max_ul in tip_to_max_ul.items():
|
||||||
item = labware_info.get(tip_key)
|
item = labware_info.get(tip_key)
|
||||||
if item is None:
|
if item is None:
|
||||||
continue
|
continue
|
||||||
if _infer_reagent_kind(tip_key, item) != "tip_rack":
|
if _infer_reagent_kind(tip_key, item) != "tip_rack":
|
||||||
continue
|
continue
|
||||||
item["prcxi_class_name"] = _tip_prcxi_class_for_max_ul(max_ul)
|
cls = _yaml_resolve_target_class(
|
||||||
|
target_device, "tip_rack", hole_count=96, volume=max_ul, target_model=target_model
|
||||||
|
)
|
||||||
|
item["target_class_name"] = cls if cls else default_tip_cls
|
||||||
|
|
||||||
|
|
||||||
def _volume_template_covers_requirement(template: Dict[str, Any], req: Optional[float], kind: str) -> bool:
|
def _volume_template_covers_requirement(template: Dict[str, Any], req: Optional[float], kind: str) -> bool:
|
||||||
@@ -377,13 +411,24 @@ def _match_score_prcxi_template(
|
|||||||
return hole_diff * 1000 + vol_diff
|
return hole_diff * 1000 + vol_diff
|
||||||
|
|
||||||
|
|
||||||
def _apply_prcxi_labware_auto_match(
|
def _apply_target_labware_class_auto_match(
|
||||||
labware_info: Dict[str, Dict[str, Any]],
|
labware_info: Dict[str, Dict[str, Any]],
|
||||||
labware_defs: Optional[List[Dict[str, Any]]] = None,
|
labware_defs: Optional[List[Dict[str, Any]]] = None,
|
||||||
*,
|
*,
|
||||||
preserve_tip_rack_incoming_class: bool = True,
|
preserve_tip_rack_incoming_class: bool = True,
|
||||||
|
target_device: str = DEFAULT_TARGET_DEVICE,
|
||||||
|
target_model: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""上传构建图前:按孔数+容量将 reagent 条目匹配到 ``prcxi_labware`` 注册类名,写入 ``prcxi_class_name``。
|
"""上传构建图前:按孔数 + 容量将 reagent 条目匹配到目标仪器物料注册类名,写入 ``target_class_name``。
|
||||||
|
|
||||||
|
P6.1 流程:
|
||||||
|
|
||||||
|
1. 先查 ``labware_mapping.yaml`` 的 ``target_devices.<target_device>.rules``
|
||||||
|
(未声明的 target_device 由 :func:`_yaml_resolve_target_class` 自动 fallback
|
||||||
|
到固定段 ``target_devices.default``);命中直接采用。
|
||||||
|
2. YAML 未命中(孔数 / 体积超出表内规则覆盖范围)→ 走 ``prcxi_labware``
|
||||||
|
注册模板打分匹配 fallback,并打 warning 提示「请补到映射表」。
|
||||||
|
|
||||||
若给出需求体积,仅选用模板标称 Volume >= 该值的物料,并在满足条件的模板中选余量最小者。
|
若给出需求体积,仅选用模板标称 Volume >= 该值的物料,并在满足条件的模板中选余量最小者。
|
||||||
|
|
||||||
``preserve_tip_rack_incoming_class=True``(默认)时:**仅 tip_rack** 不做模板匹配,类名由 ``class_name``/``class`` 或
|
``preserve_tip_rack_incoming_class=True``(默认)时:**仅 tip_rack** 不做模板匹配,类名由 ``class_name``/``class`` 或
|
||||||
@@ -394,19 +439,19 @@ def _apply_prcxi_labware_auto_match(
|
|||||||
|
|
||||||
default_prcxi_tip_class = CLASS_NAMES_MAPPING.get("tip_rack", "PRCXI_300ul_Tips")
|
default_prcxi_tip_class = CLASS_NAMES_MAPPING.get("tip_rack", "PRCXI_300ul_Tips")
|
||||||
|
|
||||||
|
# P6.1:模板 fallback 只在 prcxi_labware 可导入且非空时启用;YAML 查表路径**始终**生效。
|
||||||
|
# 这样在最小 Python 环境(无 pylabrobot)下,YAML 命中也能写入 target_class_name。
|
||||||
|
templates: List[Dict[str, Any]] = []
|
||||||
try:
|
try:
|
||||||
from unilabos.devices.liquid_handling.prcxi.prcxi_labware import get_prcxi_labware_template_specs
|
from unilabos.devices.liquid_handling.prcxi.prcxi_labware import get_prcxi_labware_template_specs
|
||||||
|
templates = list(get_prcxi_labware_template_specs() or [])
|
||||||
except Exception:
|
except Exception:
|
||||||
return
|
templates = []
|
||||||
|
|
||||||
templates = get_prcxi_labware_template_specs()
|
|
||||||
if not templates:
|
|
||||||
return
|
|
||||||
|
|
||||||
def_map = _labware_def_index(labware_defs)
|
def_map = _labware_def_index(labware_defs)
|
||||||
|
|
||||||
for labware_id, item in labware_info.items():
|
for labware_id, item in labware_info.items():
|
||||||
if item.get("prcxi_class_name"):
|
if item.get("target_class_name"):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
kind = _infer_reagent_kind(labware_id, item)
|
kind = _infer_reagent_kind(labware_id, item)
|
||||||
@@ -416,12 +461,12 @@ def _apply_prcxi_labware_auto_match(
|
|||||||
if inc_s == default_prcxi_tip_class:
|
if inc_s == default_prcxi_tip_class:
|
||||||
inc_s = ""
|
inc_s = ""
|
||||||
if inc_s:
|
if inc_s:
|
||||||
item["prcxi_class_name"] = inc_s
|
item["target_class_name"] = inc_s
|
||||||
continue
|
continue
|
||||||
|
|
||||||
explicit = item.get("class_name") or item.get("class")
|
explicit = item.get("class_name") or item.get("class")
|
||||||
if explicit and str(explicit).startswith("PRCXI_"):
|
if explicit and str(explicit).startswith("PRCXI_"):
|
||||||
item["prcxi_class_name"] = str(explicit)
|
item["target_class_name"] = str(explicit)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
extra = def_map.get(str(labware_id), {})
|
extra = def_map.get(str(labware_id), {})
|
||||||
@@ -456,6 +501,20 @@ def _apply_prcxi_labware_auto_match(
|
|||||||
if kind == "tip_rack" and child_max_volume_f is 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
|
child_max_volume_f = _tip_volume_hint(item, labware_id) or 300.0
|
||||||
|
|
||||||
|
# P6.1: 先查 labware_mapping.yaml;命中直接采用,跳过 PRCXI 模板打分匹配
|
||||||
|
# P6.1.1: target_model 透传,允许型号级 rules 覆盖
|
||||||
|
yaml_cls = _yaml_resolve_target_class(
|
||||||
|
target_device,
|
||||||
|
kind,
|
||||||
|
hole_count=num_children if kind != "trash" else None,
|
||||||
|
volume=child_max_volume_f,
|
||||||
|
target_model=target_model,
|
||||||
|
)
|
||||||
|
if yaml_cls:
|
||||||
|
item["target_class_name"] = yaml_cls
|
||||||
|
continue
|
||||||
|
|
||||||
|
# YAML 未命中:fallback 到 PRCXI 模板打分匹配(保留历史行为)+ warning 提示补表
|
||||||
candidates = [t for t in templates if t["kind"] == kind]
|
candidates = [t for t in templates if t["kind"] == kind]
|
||||||
if not candidates:
|
if not candidates:
|
||||||
continue
|
continue
|
||||||
@@ -473,21 +532,38 @@ def _apply_prcxi_labware_auto_match(
|
|||||||
best = t
|
best = t
|
||||||
|
|
||||||
if best:
|
if best:
|
||||||
item["prcxi_class_name"] = best["class_name"]
|
item["target_class_name"] = best["class_name"]
|
||||||
|
warnings.warn(
|
||||||
|
f"labware {labware_id!r} (kind={kind}, holes={num_children}, vol={child_max_volume_f}) "
|
||||||
|
f"未在 labware_mapping.yaml 的 target_devices.{target_device}.rules / "
|
||||||
|
f"target_devices.default.rules 中命中,已用 PRCXI 模板兜底 {best['class_name']};"
|
||||||
|
f"建议在 labware_mapping.yaml 中补一条对应规则。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _reconcile_slot_carrier_prcxi_class(
|
def _reconcile_slot_carrier_target_class(
|
||||||
labware_info: Dict[str, Dict[str, Any]],
|
labware_info: Dict[str, Dict[str, Any]],
|
||||||
*,
|
*,
|
||||||
preserve_tip_rack_incoming_class: bool = False,
|
preserve_tip_rack_incoming_class: bool = False,
|
||||||
|
target_device: str = DEFAULT_TARGET_DEVICE,
|
||||||
|
target_model: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""同一 deck 槽位上多条 reagent 时,按载体类型优先级统一 ``prcxi_class_name``,避免先遍历到 96 板后槽位被错误绑定。
|
"""同一 deck 槽位上多条 reagent 时,按载体类型优先级统一 ``target_class_name``,避免先遍历到 96 板后槽位被错误绑定。
|
||||||
|
|
||||||
``preserve_tip_rack_incoming_class=True`` 时:tip_rack 条目不参与同槽类名合并(不被覆盖、也不把 tip 类名扩散到同槽其它条目)。"""
|
``preserve_tip_rack_incoming_class=True`` 时:tip_rack 条目不参与同槽类名合并(不被覆盖、也不把 tip 类名扩散到同槽其它条目)。
|
||||||
|
|
||||||
|
P6.1.1:``target_device`` / ``target_model`` 透传给 :func:`_map_deck_slot`,
|
||||||
|
保证「同槽位归并」按目标仪器型号的实际 deck 物理布局进行。
|
||||||
|
"""
|
||||||
by_slot: Dict[str, List[Tuple[str, Dict[str, Any]]]] = {}
|
by_slot: Dict[str, List[Tuple[str, Dict[str, Any]]]] = {}
|
||||||
for lid, item in labware_info.items():
|
for lid, item in labware_info.items():
|
||||||
ot = item.get("object", "") or ""
|
ot = item.get("object", "") or ""
|
||||||
slot = _map_deck_slot(str(item.get("slot", "")), ot)
|
slot = _map_deck_slot(
|
||||||
|
str(item.get("slot", "")),
|
||||||
|
ot,
|
||||||
|
target_device=target_device,
|
||||||
|
target_model=target_model,
|
||||||
|
)
|
||||||
if not slot:
|
if not slot:
|
||||||
continue
|
continue
|
||||||
by_slot.setdefault(str(slot), []).append((lid, item))
|
by_slot.setdefault(str(slot), []).append((lid, item))
|
||||||
@@ -506,7 +582,7 @@ def _reconcile_slot_carrier_prcxi_class(
|
|||||||
for lid, it in pairs_sorted:
|
for lid, it in pairs_sorted:
|
||||||
if preserve_tip_rack_incoming_class and _infer_reagent_kind(lid, it) == "tip_rack":
|
if preserve_tip_rack_incoming_class and _infer_reagent_kind(lid, it) == "tip_rack":
|
||||||
continue
|
continue
|
||||||
c = it.get("prcxi_class_name")
|
c = it.get("target_class_name")
|
||||||
if c:
|
if c:
|
||||||
best_cls = c
|
best_cls = c
|
||||||
break
|
break
|
||||||
@@ -515,7 +591,7 @@ def _reconcile_slot_carrier_prcxi_class(
|
|||||||
for lid, it in pairs:
|
for lid, it in pairs:
|
||||||
if preserve_tip_rack_incoming_class and _infer_reagent_kind(lid, it) == "tip_rack":
|
if preserve_tip_rack_incoming_class and _infer_reagent_kind(lid, it) == "tip_rack":
|
||||||
continue
|
continue
|
||||||
it["prcxi_class_name"] = best_cls
|
it["target_class_name"] = best_cls
|
||||||
|
|
||||||
|
|
||||||
# ---------------- Graph ----------------
|
# ---------------- Graph ----------------
|
||||||
@@ -726,7 +802,7 @@ def refactor_data(
|
|||||||
"template_name": template_name,
|
"template_name": template_name,
|
||||||
"resource_name": resource_name,
|
"resource_name": resource_name,
|
||||||
"description": step.get("description", step.get("purpose", f"{operation} operation")),
|
"description": step.get("description", step.get("purpose", f"{operation} operation")),
|
||||||
"lab_node_type": "ILab",
|
"lab_node_type": "Transport" if "transfer" in template_name.lower() else "Device",
|
||||||
"param": step.get("parameters", step.get("action_args", {})),
|
"param": step.get("parameters", step.get("action_args", {})),
|
||||||
"footer": f"{template_name}-{resource_name}",
|
"footer": f"{template_name}-{resource_name}",
|
||||||
}
|
}
|
||||||
@@ -737,6 +813,188 @@ def refactor_data(
|
|||||||
return refactored_data
|
return refactored_data
|
||||||
|
|
||||||
|
|
||||||
|
MERGED_TARGETS_SYNTHETIC_PREFIX = "_merged_targets_"
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_set_liquid_coverage(
|
||||||
|
protocol_steps: List[Dict[str, Any]],
|
||||||
|
) -> Tuple[Set[str], Set[str]]:
|
||||||
|
"""P2 v2 §14:预扫描 transfer_liquid 的 ``params.targets``,统计 reagent_key 覆盖关系。
|
||||||
|
|
||||||
|
输入要求:``protocol_steps`` 已经过 :func:`refactor_data` 标准化,每个 step 形如
|
||||||
|
``{"template_name": "transfer_liquid", "param": {"targets": ...}, ...}``。
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
(covered_by_merged, referenced_by_str)
|
||||||
|
``covered_by_merged`` —— 所有出现在某个 ``list[str] targets`` 中的 reagent_keys。
|
||||||
|
``referenced_by_str`` —— 所有以 ``str`` 形态出现在 ``targets`` 中的 reagent_keys。
|
||||||
|
|
||||||
|
用途
|
||||||
|
----
|
||||||
|
第二步循环(``for labware_id, item in labware_info.items()``)根据这两个集合
|
||||||
|
判断某 target reagent_key 是否完全被 ``_emit_merged_set_liquid`` 接管:
|
||||||
|
若 ``key ∈ covered_by_merged ∧ key ∉ referenced_by_str``,则跳过 per-plate
|
||||||
|
``set_liquid_from_plate`` 节点(避免冗余)。
|
||||||
|
|
||||||
|
详见 ``product_designs/protocol_convert/02-cross-slot-merge.md`` §14。
|
||||||
|
"""
|
||||||
|
covered_by_merged: Set[str] = set()
|
||||||
|
referenced_by_str: Set[str] = set()
|
||||||
|
for step in protocol_steps:
|
||||||
|
if step.get("template_name") != "transfer_liquid":
|
||||||
|
continue
|
||||||
|
tgt = (step.get("param") or {}).get("targets")
|
||||||
|
if isinstance(tgt, list):
|
||||||
|
for t in tgt:
|
||||||
|
if isinstance(t, str) and t:
|
||||||
|
covered_by_merged.add(t)
|
||||||
|
elif isinstance(tgt, str) and tgt:
|
||||||
|
referenced_by_str.add(tgt)
|
||||||
|
return covered_by_merged, referenced_by_str
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_merged_set_liquid(
|
||||||
|
G: "WorkflowGraph",
|
||||||
|
target_reagent_keys: List[str],
|
||||||
|
labware_info: Dict[str, Dict[str, Any]],
|
||||||
|
slot_to_create_resource: Dict[str, str],
|
||||||
|
*,
|
||||||
|
set_liquid_group_id: str,
|
||||||
|
merged_index: int,
|
||||||
|
target_device: str,
|
||||||
|
target_model: Optional[str],
|
||||||
|
) -> Tuple[str, str]:
|
||||||
|
"""P2 v2:为含 ``list[str] targets`` 的 transfer_liquid 节点插入一个 merged
|
||||||
|
``set_liquid_from_plate`` 跨板聚合节点。
|
||||||
|
|
||||||
|
详见 ``product_designs/protocol_convert/02-cross-slot-merge.md`` §9.2 / §9.5。
|
||||||
|
|
||||||
|
构造逻辑:
|
||||||
|
|
||||||
|
1. 按 ``target_reagent_keys`` 顺序遍历,逐 key 使用独立 cursor 取
|
||||||
|
``labware_info[key].well[cursor % len(wells)]`` 作为该 dispense 对应的 well 名;
|
||||||
|
wells 列表为空时退化为 ``key`` 本身(不带 ``/<well>`` 后缀)。
|
||||||
|
2. 把每个 dispense 的 ``{id, name, parent: key, type: "well"}`` 顺序压入
|
||||||
|
merged 节点的 ``param.wells``——这是 v2 的「顺序权威」载体(构造期固化)。
|
||||||
|
3. 多入边:对每个 distinct reagent_key 涉及的 plate,从对应 ``create_resource``
|
||||||
|
节点连一条 ``labware → wells_identifier`` 入边(同 plate 不重复连接)。
|
||||||
|
4. 注册一个 synthetic str ``_merged_targets_<idx>``,供 caller 改写
|
||||||
|
``params.targets`` 与 ``resource_last_writer`` 映射。
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
(synthetic_key, merged_node_id)
|
||||||
|
``synthetic_key`` —— 写入到 ``transfer_liquid.params.targets``(str 形态),
|
||||||
|
以及 ``resource_last_writer[synthetic_key] = f"{merged_node_id}:output_wells"``。
|
||||||
|
``merged_node_id`` —— 新插入节点的 UUID。
|
||||||
|
"""
|
||||||
|
# 每个 reagent_key 一个 cursor,按 dispense 顺序推进;mod 处理同 well 重复 dispense
|
||||||
|
cursor: Dict[str, int] = {}
|
||||||
|
merged_wells: List[Dict[str, Any]] = []
|
||||||
|
liquid_names: List[str] = []
|
||||||
|
# P2 v2 §14 fix(2026-05-22):merged 节点的 well_names 用 "<plate_plr_name>/<well>" 形态
|
||||||
|
# 编码每个 dispense 对应的 PLR Plate 实例名,让 abstract 层 fallback 能定位跨板 plate
|
||||||
|
# (否则 ROS placeholder 的 wells_identifier 多入边只保留最后一个 plate,导致跨板信息丢失)。
|
||||||
|
# plate_plr_name 复用 create_resource 节点的命名约定: f"{target_class_name}_slot_{mapped_slot}".
|
||||||
|
well_names_prefixed: List[str] = []
|
||||||
|
for key in target_reagent_keys:
|
||||||
|
info = labware_info.get(key) or {}
|
||||||
|
wells = info.get("well") or []
|
||||||
|
idx = cursor.get(key, 0)
|
||||||
|
if wells:
|
||||||
|
well_name = wells[idx % len(wells)]
|
||||||
|
ref_id = f"{key}/{well_name}"
|
||||||
|
else:
|
||||||
|
well_name = None
|
||||||
|
ref_id = key
|
||||||
|
cursor[key] = idx + 1
|
||||||
|
merged_wells.append({
|
||||||
|
"id": ref_id,
|
||||||
|
"name": ref_id,
|
||||||
|
"parent": key,
|
||||||
|
"type": "well",
|
||||||
|
})
|
||||||
|
# P8(2026-05-24):reagent block 显式 ``liquid_name`` 字段优先,作为写入 PLR
|
||||||
|
# tracker / 前端的真实化学名;缺省时 fallback 到 reagent_key(行为不变)。
|
||||||
|
# 详见 ``product_designs/protocol_convert/08-liquid-name-from-reagent-block.md`` §3.4。
|
||||||
|
ln_value = info.get("liquid_name") or str(key)
|
||||||
|
liquid_names.append(ln_value)
|
||||||
|
|
||||||
|
# 计算 PLR Plate name 给 well_names prefix(跨板 fallback 用)
|
||||||
|
object_type = info.get("object", "") or ""
|
||||||
|
mapped_slot = _map_deck_slot(
|
||||||
|
str(info.get("slot", "")),
|
||||||
|
object_type,
|
||||||
|
target_device=target_device,
|
||||||
|
target_model=target_model,
|
||||||
|
)
|
||||||
|
target_class = info.get("target_class_name") or ""
|
||||||
|
if target_class and mapped_slot and well_name:
|
||||||
|
plate_plr_name = f"{target_class}_slot_{mapped_slot}".replace(" ", "_")
|
||||||
|
well_names_prefixed.append(f"{plate_plr_name}/{well_name}")
|
||||||
|
elif well_name:
|
||||||
|
# target_class 未知时仅写 well 名(abstract 层会走单 plate fallback;
|
||||||
|
# 跨板信息丢失,但至少不破坏单 slot 协议)
|
||||||
|
well_names_prefixed.append(well_name)
|
||||||
|
else:
|
||||||
|
well_names_prefixed.append("")
|
||||||
|
|
||||||
|
merged_node_id = str(uuid.uuid4())
|
||||||
|
synthetic_key = f"{MERGED_TARGETS_SYNTHETIC_PREFIX}{merged_index}"
|
||||||
|
|
||||||
|
G.add_node(
|
||||||
|
merged_node_id,
|
||||||
|
template_name="set_liquid_from_plate",
|
||||||
|
resource_name="liquid_handler.prcxi",
|
||||||
|
name=synthetic_key,
|
||||||
|
display_name=f"MergedTargets({len(set(target_reagent_keys))}p×{len(merged_wells)}w)",
|
||||||
|
description=f"Merged set_liquid_from_plate: targets={target_reagent_keys}",
|
||||||
|
lab_node_type="Reagent",
|
||||||
|
footer="set_liquid_from_plate-liquid_handler.prcxi",
|
||||||
|
device_name=DEVICE_NAME_DEFAULT,
|
||||||
|
type=NODE_TYPE_DEFAULT,
|
||||||
|
parent_uuid=set_liquid_group_id,
|
||||||
|
minimized=True,
|
||||||
|
param={
|
||||||
|
"wells": merged_wells,
|
||||||
|
"liquid_names": liquid_names,
|
||||||
|
# volumes=0:target plate 不预先注入液体,仅占位(同 per-plate set_liquid 行为)。
|
||||||
|
"volumes": [0] * len(merged_wells),
|
||||||
|
# 兼容字段:保留 plate/well_names 让旧 runtime / 旧前端可继续解析
|
||||||
|
"plate": [],
|
||||||
|
# 升级:well_names 元素为 "<plate_plr_name>/<well>" 形态(含跨板 plate 定位信息),
|
||||||
|
# abstract 层 set_liquid_from_plate 的 schema_fallback 会按 "/" 拆解逐个查 plate。
|
||||||
|
"well_names": well_names_prefixed,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 多入边:对每个 distinct plate 接一条 create_resource.labware → wells_identifier
|
||||||
|
seen_keys: set = set()
|
||||||
|
for key in target_reagent_keys:
|
||||||
|
if key in seen_keys:
|
||||||
|
continue
|
||||||
|
seen_keys.add(key)
|
||||||
|
info = labware_info.get(key) or {}
|
||||||
|
object_type = info.get("object", "") or ""
|
||||||
|
mapped_slot = _map_deck_slot(
|
||||||
|
str(info.get("slot", "")),
|
||||||
|
object_type,
|
||||||
|
target_device=target_device,
|
||||||
|
target_model=target_model,
|
||||||
|
)
|
||||||
|
cr_node = slot_to_create_resource.get(mapped_slot)
|
||||||
|
if cr_node:
|
||||||
|
G.add_edge(
|
||||||
|
cr_node,
|
||||||
|
merged_node_id,
|
||||||
|
source_port="labware",
|
||||||
|
target_port="wells_identifier",
|
||||||
|
)
|
||||||
|
|
||||||
|
return synthetic_key, merged_node_id
|
||||||
|
|
||||||
|
|
||||||
def build_protocol_graph(
|
def build_protocol_graph(
|
||||||
labware_info: Dict[str, Dict[str, Any]],
|
labware_info: Dict[str, Dict[str, Any]],
|
||||||
protocol_steps: List[Dict[str, Any]],
|
protocol_steps: List[Dict[str, Any]],
|
||||||
@@ -744,6 +1002,8 @@ def build_protocol_graph(
|
|||||||
action_resource_mapping: Optional[Dict[str, str]] = None,
|
action_resource_mapping: Optional[Dict[str, str]] = None,
|
||||||
labware_defs: Optional[List[Dict[str, Any]]] = None,
|
labware_defs: Optional[List[Dict[str, Any]]] = None,
|
||||||
preserve_tip_rack_incoming_class: bool = False,
|
preserve_tip_rack_incoming_class: bool = False,
|
||||||
|
target_device: str = DEFAULT_TARGET_DEVICE,
|
||||||
|
target_model: Optional[str] = None,
|
||||||
) -> WorkflowGraph:
|
) -> WorkflowGraph:
|
||||||
"""统一的协议图构建函数,根据设备类型自动选择构建逻辑
|
"""统一的协议图构建函数,根据设备类型自动选择构建逻辑
|
||||||
|
|
||||||
@@ -755,9 +1015,16 @@ def build_protocol_graph(
|
|||||||
labware_defs: 可选,``[{"id": "...", "num_wells": 96, "max_volume": 2200}, ...]`` 等,辅助 PRCXI 模板匹配
|
labware_defs: 可选,``[{"id": "...", "num_wells": 96, "max_volume": 2200}, ...]`` 等,辅助 PRCXI 模板匹配
|
||||||
preserve_tip_rack_incoming_class: 默认 True 时**仅 tip_rack** 不跑模板匹配(类名由传入的 class/labware 决定);
|
preserve_tip_rack_incoming_class: 默认 True 时**仅 tip_rack** 不跑模板匹配(类名由传入的 class/labware 决定);
|
||||||
**其它载体**仍按 PRCXI 模板匹配。False 时 **全部**(含 tip_rack)都走模板匹配。
|
**其它载体**仍按 PRCXI 模板匹配。False 时 **全部**(含 tip_rack)都走模板匹配。
|
||||||
|
target_device: P6.1 新增。目标仪器名(厂商粒度,如 ``prcxi`` / ``beckman`` / ``tecan``)。
|
||||||
|
决定查 ``labware_mapping.yaml`` 中 ``target_devices.<target_device>.rules`` 段;未声明的
|
||||||
|
名字由 :func:`labware_mapping.resolve_target_class` 自动 fallback 到固定段
|
||||||
|
``target_devices.default``。默认 ``"prcxi"``(与历史 P6 完全等价)。
|
||||||
|
target_model: P6.1.1 新增。同厂商内的目标型号名(如 ``"9320"`` / ``"4040"``);
|
||||||
|
决定查 ``target_devices.<target_device>.models.<target_model>`` 下的 ``slot_remap`` /
|
||||||
|
``rules`` 覆盖。``None`` 表示不区分型号,走厂商级配置。
|
||||||
|
|
||||||
会先 ``refactor_data`` 规范化步骤,再根据 ``transfer_liquid`` 的 ``asp_vols``/``dis_vols`` 为对应
|
会先 ``refactor_data`` 规范化步骤,再根据 ``transfer_liquid`` 的 ``asp_vols``/``dis_vols`` 为对应
|
||||||
``tip_racks`` 写入 ``prcxi_class_name``(最大体积 ``≤10`` → ``PRCXI_10uL_Tips``,``<300`` → ``PRCXI_300ul_Tips``,
|
``tip_racks`` 写入 ``target_class_name``(最大体积 ``≤10`` → ``PRCXI_10uL_Tips``,``<300`` → ``PRCXI_300ul_Tips``,
|
||||||
否则 ``PRCXI_1000uL_Tips``);无有效体积的步骤不覆盖。
|
否则 ``PRCXI_1000uL_Tips``);无有效体积的步骤不覆盖。
|
||||||
"""
|
"""
|
||||||
G = WorkflowGraph()
|
G = WorkflowGraph()
|
||||||
@@ -765,24 +1032,38 @@ def build_protocol_graph(
|
|||||||
slot_to_create_resource = {} # slot -> create_resource node_id
|
slot_to_create_resource = {} # slot -> create_resource node_id
|
||||||
|
|
||||||
protocol_steps = refactor_data(protocol_steps, action_resource_mapping)
|
protocol_steps = refactor_data(protocol_steps, action_resource_mapping)
|
||||||
_apply_tip_rack_class_from_transfer_volumes(labware_info, protocol_steps)
|
_apply_tip_rack_class_from_transfer_volumes(
|
||||||
|
labware_info,
|
||||||
|
protocol_steps,
|
||||||
|
target_device=target_device,
|
||||||
|
target_model=target_model,
|
||||||
|
)
|
||||||
|
|
||||||
_apply_prcxi_labware_auto_match(
|
_apply_target_labware_class_auto_match(
|
||||||
labware_info,
|
labware_info,
|
||||||
labware_defs,
|
labware_defs,
|
||||||
preserve_tip_rack_incoming_class=preserve_tip_rack_incoming_class,
|
preserve_tip_rack_incoming_class=preserve_tip_rack_incoming_class,
|
||||||
|
target_device=target_device,
|
||||||
|
target_model=target_model,
|
||||||
)
|
)
|
||||||
_reconcile_slot_carrier_prcxi_class(
|
_reconcile_slot_carrier_target_class(
|
||||||
labware_info,
|
labware_info,
|
||||||
preserve_tip_rack_incoming_class=preserve_tip_rack_incoming_class,
|
preserve_tip_rack_incoming_class=preserve_tip_rack_incoming_class,
|
||||||
|
target_device=target_device,
|
||||||
|
target_model=target_model,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ==================== 第一步:按 slot 去重创建 create_resource 节点 ====================
|
# ==================== 第一步:按 slot 去重创建 create_resource 节点 ====================
|
||||||
# 按槽聚合:同一 slot 多条 reagent 时不能只取遍历顺序第一条,否则 tip 的 prcxi_class_name / object 会被其它条目盖住
|
# 按槽聚合:同一 slot 多条 reagent 时不能只取遍历顺序第一条,否则 tip 的 target_class_name / object 会被其它条目盖住
|
||||||
by_slot: Dict[str, List[Tuple[str, Dict[str, Any]]]] = {}
|
by_slot: Dict[str, List[Tuple[str, Dict[str, Any]]]] = {}
|
||||||
for labware_id, item in labware_info.items():
|
for labware_id, item in labware_info.items():
|
||||||
object_type = item.get("object", "") or ""
|
object_type = item.get("object", "") or ""
|
||||||
slot = _map_deck_slot(str(item.get("slot", "")), object_type)
|
slot = _map_deck_slot(
|
||||||
|
str(item.get("slot", "")),
|
||||||
|
object_type,
|
||||||
|
target_device=target_device,
|
||||||
|
target_model=target_model,
|
||||||
|
)
|
||||||
if not slot:
|
if not slot:
|
||||||
continue
|
continue
|
||||||
by_slot.setdefault(slot, []).append((labware_id, item))
|
by_slot.setdefault(slot, []).append((labware_id, item))
|
||||||
@@ -795,25 +1076,25 @@ def build_protocol_graph(
|
|||||||
tip_pairs = [(lid, it) for lid, it in pairs if _ot_tip(it)]
|
tip_pairs = [(lid, it) for lid, it in pairs if _ot_tip(it)]
|
||||||
chosen_lid = ""
|
chosen_lid = ""
|
||||||
chosen_item: Dict[str, Any] = {}
|
chosen_item: Dict[str, Any] = {}
|
||||||
prcxi_val: Optional[str] = None
|
target_class_val: Optional[str] = None
|
||||||
|
|
||||||
scan = tip_pairs if tip_pairs else pairs
|
scan = tip_pairs if tip_pairs else pairs
|
||||||
for lid, it in scan:
|
for lid, it in scan:
|
||||||
c = it.get("prcxi_class_name")
|
c = it.get("target_class_name")
|
||||||
if c:
|
if c:
|
||||||
chosen_lid, chosen_item, prcxi_val = lid, it, str(c)
|
chosen_lid, chosen_item, target_class_val = lid, it, str(c)
|
||||||
break
|
break
|
||||||
if not chosen_lid and scan:
|
if not chosen_lid and scan:
|
||||||
chosen_lid, chosen_item = scan[0]
|
chosen_lid, chosen_item = scan[0]
|
||||||
pv = chosen_item.get("prcxi_class_name")
|
pv = chosen_item.get("target_class_name")
|
||||||
prcxi_val = str(pv) if pv else None
|
target_class_val = str(pv) if pv else None
|
||||||
|
|
||||||
labware = str(chosen_item.get("labware", "") or "")
|
labware = str(chosen_item.get("labware", "") or "")
|
||||||
slots_info[slot] = {
|
slots_info[slot] = {
|
||||||
"labware": labware,
|
"labware": labware,
|
||||||
"labware_id": chosen_lid,
|
"labware_id": chosen_lid,
|
||||||
"object": chosen_item.get("object", "") or "",
|
"object": chosen_item.get("object", "") or "",
|
||||||
"prcxi_class_name": prcxi_val,
|
"target_class_name": target_class_val,
|
||||||
}
|
}
|
||||||
|
|
||||||
# 创建 Group 节点,包含所有 create_resource 节点
|
# 创建 Group 节点,包含所有 create_resource 节点
|
||||||
@@ -838,7 +1119,7 @@ def build_protocol_graph(
|
|||||||
node_id = str(uuid.uuid4())
|
node_id = str(uuid.uuid4())
|
||||||
object_type = info.get("object", "") or ""
|
object_type = info.get("object", "") or ""
|
||||||
ot_lo = str(object_type).strip().lower()
|
ot_lo = str(object_type).strip().lower()
|
||||||
matched = info.get("prcxi_class_name")
|
matched = info.get("target_class_name")
|
||||||
if ot_lo == "trash":
|
if ot_lo == "trash":
|
||||||
res_type_name = "PRCXI_trash"
|
res_type_name = "PRCXI_trash"
|
||||||
elif matched:
|
elif matched:
|
||||||
@@ -898,6 +1179,13 @@ def build_protocol_graph(
|
|||||||
param=None,
|
param=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# P2 v2 §14:预扫描 list-targets / str-targets 覆盖关系,
|
||||||
|
# 第二步循环将跳过被 merged 节点完全接管的 target reagent_keys(避免冗余 per-plate 节点)。
|
||||||
|
# 详见 product_designs/protocol_convert/02-cross-slot-merge.md §14。
|
||||||
|
set_liquid_covered_by_merged, set_liquid_referenced_by_str = _collect_set_liquid_coverage(
|
||||||
|
protocol_steps
|
||||||
|
)
|
||||||
|
|
||||||
set_liquid_index = 0
|
set_liquid_index = 0
|
||||||
|
|
||||||
for labware_id, item in labware_info.items():
|
for labware_id, item in labware_info.items():
|
||||||
@@ -908,7 +1196,23 @@ def build_protocol_graph(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
object_type = item.get("object", "") or ""
|
object_type = item.get("object", "") or ""
|
||||||
slot = _map_deck_slot(str(item.get("slot", "")), object_type)
|
|
||||||
|
# P2 v2 §14:被 merged 节点完全接管的 target reagent_key 跳过 per-plate 创建。
|
||||||
|
# 仅当 object="target" ∧ key ∈ covered_by_merged ∧ key ∉ referenced_by_str 时才跳过;
|
||||||
|
# 共用 key(被 list 与 str 双重引用)必须保留 per-plate,否则 str transfer 失去 output_wells 来源(R1 缓解)。
|
||||||
|
if (
|
||||||
|
object_type == "target"
|
||||||
|
and labware_id in set_liquid_covered_by_merged
|
||||||
|
and labware_id not in set_liquid_referenced_by_str
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
slot = _map_deck_slot(
|
||||||
|
str(item.get("slot", "")),
|
||||||
|
object_type,
|
||||||
|
target_device=target_device,
|
||||||
|
target_model=target_model,
|
||||||
|
)
|
||||||
wells = item.get("well", [])
|
wells = item.get("well", [])
|
||||||
if not wells or not slot:
|
if not wells or not slot:
|
||||||
continue
|
continue
|
||||||
@@ -918,14 +1222,33 @@ def build_protocol_graph(
|
|||||||
well_count = len(wells)
|
well_count = len(wells)
|
||||||
liquid_volume = DEFAULT_LIQUID_VOLUME if object_type == "source" else 0
|
liquid_volume = DEFAULT_LIQUID_VOLUME if object_type == "source" else 0
|
||||||
|
|
||||||
|
# P8(2026-05-24):reagent block 显式 ``liquid_name`` 字段优先于 reagent_key,
|
||||||
|
# 用于写入 PLR tracker / 前端显示的真实化学名(保留空格 / 中文 / 括号等,
|
||||||
|
# **不** 经过 ``replace(" ", "_")``)。缺省时 fallback 到 ``res_id``(行为不变)。
|
||||||
|
# 详见 ``product_designs/protocol_convert/08-liquid-name-from-reagent-block.md`` §3.4。
|
||||||
|
liquid_name_value = str(item.get("liquid_name") or res_id)
|
||||||
|
|
||||||
node_id = str(uuid.uuid4())
|
node_id = str(uuid.uuid4())
|
||||||
set_liquid_index += 1
|
set_liquid_index += 1
|
||||||
prcxi_mat = item.get("prcxi_class_name")
|
target_class = item.get("target_class_name")
|
||||||
if prcxi_mat:
|
if target_class:
|
||||||
sl_node_title = f"{prcxi_mat}_slot_{slot}_{res_id}"
|
sl_node_title = f"{target_class}_slot_{slot}_{res_id}"
|
||||||
else:
|
else:
|
||||||
sl_node_title = f"lab_{res_id.lower()}_slot_{slot}_{set_liquid_index}"
|
sl_node_title = f"lab_{res_id.lower()}_slot_{slot}_{set_liquid_index}"
|
||||||
|
|
||||||
|
# P3 框选化:新主路径 = param.wells(list[dict],每孔一个资源引用),
|
||||||
|
# 端口 target_port="wells_identifier"。
|
||||||
|
# 旧字段(plate / well_names)仍写入 param 作 fallback,便于旧 runtime / 旧 schema 解析。
|
||||||
|
well_resource_refs = [
|
||||||
|
{
|
||||||
|
"id": f"{labware_id}/{w}",
|
||||||
|
"name": f"{labware_id}/{w}",
|
||||||
|
"parent": labware_id,
|
||||||
|
"type": "well",
|
||||||
|
}
|
||||||
|
for w in wells
|
||||||
|
]
|
||||||
|
|
||||||
G.add_node(
|
G.add_node(
|
||||||
node_id,
|
node_id,
|
||||||
template_name="set_liquid_from_plate",
|
template_name="set_liquid_from_plate",
|
||||||
@@ -940,19 +1263,30 @@ def build_protocol_graph(
|
|||||||
parent_uuid=set_liquid_group_id, # 指向 Group 节点
|
parent_uuid=set_liquid_group_id, # 指向 Group 节点
|
||||||
minimized=True, # 折叠显示
|
minimized=True, # 折叠显示
|
||||||
param={
|
param={
|
||||||
"plate": [], # 通过连接传递
|
# P3 新主路径:wells 框选化(list[well_resource_ref])
|
||||||
"well_names": wells, # 孔位名数组,如 ["A1", "A3", "A5"]
|
"wells": well_resource_refs,
|
||||||
"liquid_names": [res_id] * well_count,
|
"liquid_names": [liquid_name_value] * well_count,
|
||||||
"volumes": [liquid_volume] * well_count,
|
"volumes": [liquid_volume] * well_count,
|
||||||
|
# 兼容字段:保留 plate / well_names 以便旧 runtime / 旧前端继续工作;
|
||||||
|
# 新 yaml schema 已将 required 改为 [liquid_names, volumes]
|
||||||
|
"plate": [],
|
||||||
|
"well_names": wells,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# set_liquid_from_plate 之间不需要 ready 连接
|
# set_liquid_from_plate 之间不需要 ready 连接
|
||||||
|
|
||||||
# 物料流:create_resource 的 labware -> set_liquid_from_plate 的 input_plate
|
# 物料流:create_resource 的 labware -> set_liquid_from_plate 的 wells_identifier
|
||||||
|
# (P3 §3.4.3 简化方案:source_port 仍为 labware;目标端口换为 wells_identifier,
|
||||||
|
# placeholder 内部把 labware.wells.@flatten 映射到 wells 字段)
|
||||||
create_res_node_id = slot_to_create_resource.get(slot)
|
create_res_node_id = slot_to_create_resource.get(slot)
|
||||||
if create_res_node_id:
|
if create_res_node_id:
|
||||||
G.add_edge(create_res_node_id, node_id, source_port="labware", target_port="input_plate")
|
G.add_edge(
|
||||||
|
create_res_node_id,
|
||||||
|
node_id,
|
||||||
|
source_port="labware",
|
||||||
|
target_port="wells_identifier",
|
||||||
|
)
|
||||||
|
|
||||||
# set_liquid_from_plate 的输出 output_wells 用于连接 transfer_liquid
|
# set_liquid_from_plate 的输出 output_wells 用于连接 transfer_liquid
|
||||||
resource_last_writer[labware_id] = f"{node_id}:output_wells"
|
resource_last_writer[labware_id] = f"{node_id}:output_wells"
|
||||||
@@ -1001,6 +1335,9 @@ def build_protocol_graph(
|
|||||||
"liquid_height",
|
"liquid_height",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# P2 v2:跨板 transfer_liquid 的 merged set_liquid_from_plate 节点计数器
|
||||||
|
merged_set_liquid_counter = 0
|
||||||
|
|
||||||
# 处理协议步骤
|
# 处理协议步骤
|
||||||
for step in protocol_steps:
|
for step in protocol_steps:
|
||||||
node_id = str(uuid.uuid4())
|
node_id = str(uuid.uuid4())
|
||||||
@@ -1064,6 +1401,53 @@ def build_protocol_graph(
|
|||||||
warnings.append(f"delays 包含无法转换为数字的值: {delay_item},已忽略")
|
warnings.append(f"delays 包含无法转换为数字的值: {delay_item},已忽略")
|
||||||
params["delays"] = normalized_delays
|
params["delays"] = normalized_delays
|
||||||
|
|
||||||
|
# use_channels 输入归一化(P1 多通道意图透传):
|
||||||
|
# - 与 LiquidHandler.transfer_liquid 的 use_channels: Optional[List[int]] 入参对齐
|
||||||
|
# - None / 缺失 / 非 list 一律删除该 key,让 runtime 走自动选头默认逻辑
|
||||||
|
# - 不参与 EXPAND_BY_WELLS_PARAMS:use_channels 是「这条 transfer 用哪些通道」的常量,
|
||||||
|
# 长度由通道数决定(单通道 [0]/[1]、8 通道 [0..7]),与 targets 的 wells 数无关。
|
||||||
|
if "use_channels" in params:
|
||||||
|
uc_value = params["use_channels"]
|
||||||
|
if uc_value is None:
|
||||||
|
params.pop("use_channels")
|
||||||
|
elif isinstance(uc_value, list):
|
||||||
|
try:
|
||||||
|
params["use_channels"] = [int(x) for x in uc_value]
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
warnings.append(f"use_channels 列表中存在无法转换为 int 的值: {uc_value},已忽略")
|
||||||
|
params.pop("use_channels")
|
||||||
|
else:
|
||||||
|
warnings.append(
|
||||||
|
f"use_channels 期望 list[int],实际 {type(uc_value).__name__},已忽略"
|
||||||
|
)
|
||||||
|
params.pop("use_channels")
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# P2 v2 跨板聚合:当 params.targets 是 list[str] 时,插入一个
|
||||||
|
# merged set_liquid_from_plate 节点把跨板 wells 聚合成有序 List[Container],
|
||||||
|
# 然后改写 params.targets 为 synthetic str。详见
|
||||||
|
# product_designs/protocol_convert/02-cross-slot-merge.md §9.2。
|
||||||
|
# ============================================================
|
||||||
|
raw_targets = params.get("targets")
|
||||||
|
if (
|
||||||
|
isinstance(raw_targets, list)
|
||||||
|
and len(raw_targets) > 0
|
||||||
|
and all(isinstance(t, str) and t for t in raw_targets)
|
||||||
|
):
|
||||||
|
synth_key, merged_node_id = _emit_merged_set_liquid(
|
||||||
|
G,
|
||||||
|
raw_targets,
|
||||||
|
labware_info,
|
||||||
|
slot_to_create_resource,
|
||||||
|
set_liquid_group_id=set_liquid_group_id,
|
||||||
|
merged_index=merged_set_liquid_counter,
|
||||||
|
target_device=target_device,
|
||||||
|
target_model=target_model,
|
||||||
|
)
|
||||||
|
merged_set_liquid_counter += 1
|
||||||
|
params["targets"] = synth_key
|
||||||
|
resource_last_writer[synth_key] = f"{merged_node_id}:output_wells"
|
||||||
|
|
||||||
# 处理输入连接
|
# 处理输入连接
|
||||||
for param_key, target_port in INPUT_PORT_MAPPING.items():
|
for param_key, target_port in INPUT_PORT_MAPPING.items():
|
||||||
resource_name = params.get(param_key)
|
resource_name = params.get(param_key)
|
||||||
@@ -1081,9 +1465,21 @@ def build_protocol_graph(
|
|||||||
targets_wells_count = 1
|
targets_wells_count = 1
|
||||||
sources_wells_count = 1
|
sources_wells_count = 1
|
||||||
|
|
||||||
|
# P2 v2:synthetic merged targets key(_merged_targets_<idx>)不在 labware_info 中,
|
||||||
|
# wells 数量从 dis_vols 长度推断,且不打「未在 reagent 中定义」warning。
|
||||||
|
targets_is_synthetic = (
|
||||||
|
isinstance(targets_name, str)
|
||||||
|
and targets_name.startswith(MERGED_TARGETS_SYNTHETIC_PREFIX)
|
||||||
|
)
|
||||||
|
|
||||||
if targets_name and targets_name in labware_info:
|
if targets_name and targets_name in labware_info:
|
||||||
target_wells = labware_info[targets_name].get("well", [])
|
target_wells = labware_info[targets_name].get("well", [])
|
||||||
targets_wells_count = len(target_wells) if target_wells else 1
|
targets_wells_count = len(target_wells) if target_wells else 1
|
||||||
|
elif targets_is_synthetic:
|
||||||
|
# merged set_liquid 的 wells 长度 == dis_vols 长度(顺序权威由 Stage 3 构造期固化)
|
||||||
|
dis_vols_val = params.get("dis_vols")
|
||||||
|
if isinstance(dis_vols_val, list) and dis_vols_val:
|
||||||
|
targets_wells_count = len(dis_vols_val)
|
||||||
elif targets_name:
|
elif targets_name:
|
||||||
warnings.append(f"targets={targets_name} 未在 reagent 中定义")
|
warnings.append(f"targets={targets_name} 未在 reagent 中定义")
|
||||||
|
|
||||||
@@ -1093,13 +1489,28 @@ def build_protocol_graph(
|
|||||||
elif sources_name:
|
elif sources_name:
|
||||||
warnings.append(f"sources={sources_name} 未在 reagent 中定义")
|
warnings.append(f"sources={sources_name} 未在 reagent 中定义")
|
||||||
|
|
||||||
# 检查 sources 和 targets 的 wells 数量是否匹配
|
# 检查 sources 和 targets 的 wells 数量是否匹配(v2 跨板:1:N 是合法的,跳过 warning)
|
||||||
if targets_wells_count != sources_wells_count and targets_name and sources_name:
|
if (
|
||||||
|
targets_wells_count != sources_wells_count
|
||||||
|
and targets_name
|
||||||
|
and sources_name
|
||||||
|
and not targets_is_synthetic
|
||||||
|
and sources_wells_count not in (0, 1)
|
||||||
|
):
|
||||||
warnings.append(f"wells 数量不匹配: sources={sources_wells_count}, targets={targets_wells_count}")
|
warnings.append(f"wells 数量不匹配: sources={sources_wells_count}, targets={targets_wells_count}")
|
||||||
|
|
||||||
# 使用 targets 的 wells 数量来扩展参数
|
# 使用 targets 的 wells 数量来扩展参数
|
||||||
wells_count = targets_wells_count
|
wells_count = targets_wells_count
|
||||||
|
|
||||||
|
# P1 多通道:use_channels 存在且 len > 1(multi 协议)时,
|
||||||
|
# asp_vols / dis_vols 等数组的长度已是 8 × M(Stage 2 复制完毕),
|
||||||
|
# 与 reagent.well 长度(plate=8 / reservoir=1)不一定相等——跳过 wells 长度对齐警告,
|
||||||
|
# 让长度由 use_channels × 列锚条目决定。
|
||||||
|
is_multi_channel = (
|
||||||
|
isinstance(params.get("use_channels"), list)
|
||||||
|
and len(params.get("use_channels", [])) > 1
|
||||||
|
)
|
||||||
|
|
||||||
# 扩展单值参数为数组(根据 targets 的 wells 数量)
|
# 扩展单值参数为数组(根据 targets 的 wells 数量)
|
||||||
for expand_param in EXPAND_BY_WELLS_PARAMS:
|
for expand_param in EXPAND_BY_WELLS_PARAMS:
|
||||||
if expand_param in params:
|
if expand_param in params:
|
||||||
@@ -1107,8 +1518,8 @@ def build_protocol_graph(
|
|||||||
# 如果是单个值,扩展为数组
|
# 如果是单个值,扩展为数组
|
||||||
if not isinstance(value, list):
|
if not isinstance(value, list):
|
||||||
params[expand_param] = [value] * wells_count
|
params[expand_param] = [value] * wells_count
|
||||||
# 如果已经是数组但长度不对,记录警告
|
# 如果已经是数组但长度不对,记录警告(multi 通道场景下跳过)
|
||||||
elif len(value) != wells_count:
|
elif len(value) != wells_count and not is_multi_channel:
|
||||||
warnings.append(f"{expand_param} 数量({len(value)})与 wells({wells_count})不匹配")
|
warnings.append(f"{expand_param} 数量({len(value)})与 wells({wells_count})不匹配")
|
||||||
|
|
||||||
# 如果 sources/targets 已通过连接传递,将参数值改为空数组
|
# 如果 sources/targets 已通过连接传递,将参数值改为空数组
|
||||||
@@ -1140,10 +1551,17 @@ def build_protocol_graph(
|
|||||||
last_control_node_id = node_id
|
last_control_node_id = node_id
|
||||||
|
|
||||||
# 处理输出:更新 resource_last_writer
|
# 处理输出:更新 resource_last_writer
|
||||||
|
# P2 v2:``step.param[param_key]`` 可能是 list[str](跨板 reagent_keys),
|
||||||
|
# 此时为每个 reagent_key 注册 transfer_liquid 的下游 writer,保留多 reagent
|
||||||
|
# 链式 transfer 的能力。
|
||||||
for param_key, output_port in OUTPUT_PORT_MAPPING.items():
|
for param_key, output_port in OUTPUT_PORT_MAPPING.items():
|
||||||
resource_name = step.get("param", {}).get(param_key) # 使用原始参数值
|
raw_value = step.get("param", {}).get(param_key) # 使用原始参数值
|
||||||
if resource_name:
|
if isinstance(raw_value, list):
|
||||||
resource_last_writer[resource_name] = f"{node_id}:{output_port}"
|
for name in raw_value:
|
||||||
|
if isinstance(name, str) and name:
|
||||||
|
resource_last_writer[name] = f"{node_id}:{output_port}"
|
||||||
|
elif raw_value:
|
||||||
|
resource_last_writer[raw_value] = f"{node_id}:{output_port}"
|
||||||
|
|
||||||
return G
|
return G
|
||||||
|
|
||||||
|
|||||||
@@ -21,11 +21,12 @@ JSON 工作流转换模块
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import warnings
|
||||||
from os import PathLike
|
from os import PathLike
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
from unilabos.workflow.common import WorkflowGraph, build_protocol_graph
|
from unilabos.workflow.common import DEFAULT_TARGET_DEVICE, WorkflowGraph, build_protocol_graph
|
||||||
from unilabos.registry.registry import lab_registry
|
from unilabos.registry.registry import lab_registry
|
||||||
|
|
||||||
|
|
||||||
@@ -206,11 +207,40 @@ def normalize_workflow_steps(workflow: List[Dict[str, Any]]) -> List[Dict[str, A
|
|||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _load_json_data(data: Union[str, PathLike, Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
"""统一加载 JSON 输入。
|
||||||
|
|
||||||
|
支持三种形态:
|
||||||
|
1. ``str`` / ``PathLike`` 指向磁盘文件 → ``json.load``
|
||||||
|
2. ``str``(非文件路径)→ ``json.loads`` 解析为 dict
|
||||||
|
3. ``dict`` → 直接返回
|
||||||
|
|
||||||
|
抽出此 helper 是为了让 :func:`convert_from_json` 和
|
||||||
|
:func:`convert_json_to_workflow_envelope` 都能复用,
|
||||||
|
后者需要在传给 :func:`convert_from_json` **之前**先读出顶层
|
||||||
|
``metadata`` 段,而 :func:`convert_from_json` 自身的 schema 校验
|
||||||
|
不感知 ``metadata`` 字段。
|
||||||
|
"""
|
||||||
|
if isinstance(data, (str, PathLike)):
|
||||||
|
path = Path(data)
|
||||||
|
if path.exists():
|
||||||
|
with path.open("r", encoding="utf-8") as fp:
|
||||||
|
return json.load(fp)
|
||||||
|
if isinstance(data, str):
|
||||||
|
return json.loads(data)
|
||||||
|
raise FileNotFoundError(f"文件不存在: {data}")
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return data
|
||||||
|
raise TypeError(f"不支持的数据类型: {type(data)}")
|
||||||
|
|
||||||
|
|
||||||
def convert_from_json(
|
def convert_from_json(
|
||||||
data: Union[str, PathLike, Dict[str, Any]],
|
data: Union[str, PathLike, Dict[str, Any]],
|
||||||
workstation_name: str = DEFAULT_WORKSTATION,
|
workstation_name: str = DEFAULT_WORKSTATION,
|
||||||
validate: bool = True,
|
validate: bool = True,
|
||||||
preserve_tip_rack_incoming_class: bool = False,
|
preserve_tip_rack_incoming_class: bool = False,
|
||||||
|
target_device: str = DEFAULT_TARGET_DEVICE,
|
||||||
|
target_model: Optional[str] = None,
|
||||||
) -> WorkflowGraph:
|
) -> WorkflowGraph:
|
||||||
"""
|
"""
|
||||||
从 JSON 数据或文件转换为 WorkflowGraph
|
从 JSON 数据或文件转换为 WorkflowGraph
|
||||||
@@ -224,6 +254,12 @@ def convert_from_json(
|
|||||||
validate: 是否校验句柄配置,默认 True
|
validate: 是否校验句柄配置,默认 True
|
||||||
preserve_tip_rack_incoming_class: True(默认)时仅 tip_rack 不跑模板、按传入类名/labware;其它载体仍自动匹配。
|
preserve_tip_rack_incoming_class: True(默认)时仅 tip_rack 不跑模板、按传入类名/labware;其它载体仍自动匹配。
|
||||||
False 时全部走模板。JSON 根 ``preserve_tip_rack_incoming_class`` 可覆盖此参数。
|
False 时全部走模板。JSON 根 ``preserve_tip_rack_incoming_class`` 可覆盖此参数。
|
||||||
|
target_device: P6.1 新增。目标仪器名(厂商粒度,如 ``prcxi`` / ``beckman`` / ``tecan``)。
|
||||||
|
决定查 ``labware_mapping.yaml`` 中 ``target_devices.<target_device>.rules`` 段;未声明
|
||||||
|
的名字由 loader 自动 fallback 到固定段 ``target_devices.default``。默认 ``"prcxi"``。
|
||||||
|
target_model: P6.1.1 新增。同厂商内的目标型号名(如 ``"9320"`` / ``"4040"``);
|
||||||
|
决定 ``target_devices.<target_device>.models.<target_model>`` 段的 ``slot_remap`` /
|
||||||
|
``rules`` 覆盖。``None`` 表示走厂商级配置。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
WorkflowGraph: 构建好的工作流图
|
WorkflowGraph: 构建好的工作流图
|
||||||
@@ -233,22 +269,9 @@ def convert_from_json(
|
|||||||
FileNotFoundError: 文件不存在
|
FileNotFoundError: 文件不存在
|
||||||
json.JSONDecodeError: JSON 解析失败
|
json.JSONDecodeError: JSON 解析失败
|
||||||
"""
|
"""
|
||||||
# 处理输入数据
|
json_data = _load_json_data(data)
|
||||||
if isinstance(data, (str, PathLike)):
|
|
||||||
path = Path(data)
|
|
||||||
if path.exists():
|
|
||||||
with path.open("r", encoding="utf-8") as fp:
|
|
||||||
json_data = json.load(fp)
|
|
||||||
elif isinstance(data, str):
|
|
||||||
json_data = json.loads(data)
|
|
||||||
else:
|
|
||||||
raise FileNotFoundError(f"文件不存在: {data}")
|
|
||||||
elif isinstance(data, dict):
|
|
||||||
json_data = data
|
|
||||||
else:
|
|
||||||
raise TypeError(f"不支持的数据类型: {type(data)}")
|
|
||||||
|
|
||||||
# 校验格式
|
# 校验格式(``metadata`` 段为 P5 新增可选顶层字段,不参与校验)
|
||||||
if "workflow" not in json_data or "reagent" not in json_data:
|
if "workflow" not in json_data or "reagent" not in json_data:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"不支持的 JSON 格式。请使用标准格式:\n"
|
"不支持的 JSON 格式。请使用标准格式:\n"
|
||||||
@@ -278,6 +301,8 @@ def convert_from_json(
|
|||||||
action_resource_mapping=ACTION_RESOURCE_MAPPING,
|
action_resource_mapping=ACTION_RESOURCE_MAPPING,
|
||||||
labware_defs=labware_defs,
|
labware_defs=labware_defs,
|
||||||
preserve_tip_rack_incoming_class=preserve,
|
preserve_tip_rack_incoming_class=preserve,
|
||||||
|
target_device=target_device,
|
||||||
|
target_model=target_model,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 校验句柄配置
|
# 校验句柄配置
|
||||||
@@ -296,6 +321,8 @@ def convert_json_to_node_link(
|
|||||||
data: Union[str, PathLike, Dict[str, Any]],
|
data: Union[str, PathLike, Dict[str, Any]],
|
||||||
workstation_name: str = DEFAULT_WORKSTATION,
|
workstation_name: str = DEFAULT_WORKSTATION,
|
||||||
preserve_tip_rack_incoming_class: bool = False,
|
preserve_tip_rack_incoming_class: bool = False,
|
||||||
|
target_device: str = DEFAULT_TARGET_DEVICE,
|
||||||
|
target_model: Optional[str] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
将 JSON 数据转换为 node-link 格式的字典
|
将 JSON 数据转换为 node-link 格式的字典
|
||||||
@@ -303,6 +330,8 @@ def convert_json_to_node_link(
|
|||||||
Args:
|
Args:
|
||||||
data: JSON 文件路径、字典数据、或 JSON 字符串
|
data: JSON 文件路径、字典数据、或 JSON 字符串
|
||||||
workstation_name: 工作站名称,默认 "PRCXi"
|
workstation_name: 工作站名称,默认 "PRCXi"
|
||||||
|
target_device: P6.1 新增,目标仪器名;透传给 :func:`convert_from_json`。
|
||||||
|
target_model: P6.1.1 新增,同厂商内的型号名;透传给 :func:`convert_from_json`。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict: node-link 格式的工作流数据
|
Dict: node-link 格式的工作流数据
|
||||||
@@ -311,6 +340,8 @@ def convert_json_to_node_link(
|
|||||||
data,
|
data,
|
||||||
workstation_name,
|
workstation_name,
|
||||||
preserve_tip_rack_incoming_class=preserve_tip_rack_incoming_class,
|
preserve_tip_rack_incoming_class=preserve_tip_rack_incoming_class,
|
||||||
|
target_device=target_device,
|
||||||
|
target_model=target_model,
|
||||||
)
|
)
|
||||||
return graph.to_node_link_dict()
|
return graph.to_node_link_dict()
|
||||||
|
|
||||||
@@ -319,6 +350,8 @@ def convert_json_to_workflow_list(
|
|||||||
data: Union[str, PathLike, Dict[str, Any]],
|
data: Union[str, PathLike, Dict[str, Any]],
|
||||||
workstation_name: str = DEFAULT_WORKSTATION,
|
workstation_name: str = DEFAULT_WORKSTATION,
|
||||||
preserve_tip_rack_incoming_class: bool = True,
|
preserve_tip_rack_incoming_class: bool = True,
|
||||||
|
target_device: str = DEFAULT_TARGET_DEVICE,
|
||||||
|
target_model: Optional[str] = None,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
将 JSON 数据转换为工作流列表格式
|
将 JSON 数据转换为工作流列表格式
|
||||||
@@ -326,6 +359,8 @@ def convert_json_to_workflow_list(
|
|||||||
Args:
|
Args:
|
||||||
data: JSON 文件路径、字典数据、或 JSON 字符串
|
data: JSON 文件路径、字典数据、或 JSON 字符串
|
||||||
workstation_name: 工作站名称,默认 "PRCXi"
|
workstation_name: 工作站名称,默认 "PRCXi"
|
||||||
|
target_device: P6.1 新增,目标仪器名;透传给 :func:`convert_from_json`。
|
||||||
|
target_model: P6.1.1 新增,同厂商内的型号名;透传给 :func:`convert_from_json`。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List: 工作流节点列表
|
List: 工作流节点列表
|
||||||
@@ -334,5 +369,119 @@ def convert_json_to_workflow_list(
|
|||||||
data,
|
data,
|
||||||
workstation_name,
|
workstation_name,
|
||||||
preserve_tip_rack_incoming_class=preserve_tip_rack_incoming_class,
|
preserve_tip_rack_incoming_class=preserve_tip_rack_incoming_class,
|
||||||
|
target_device=target_device,
|
||||||
|
target_model=target_model,
|
||||||
)
|
)
|
||||||
return graph.to_dict()
|
return graph.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== P5 — Workflow envelope ====================
|
||||||
|
|
||||||
|
|
||||||
|
def convert_json_to_workflow_envelope(
|
||||||
|
data: Union[str, PathLike, Dict[str, Any]],
|
||||||
|
*,
|
||||||
|
target_lab_uuid: str = "",
|
||||||
|
workflow_uuid: str = "",
|
||||||
|
workflow_name: Optional[str] = None,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
tags: Optional[List[str]] = None,
|
||||||
|
workstation_name: str = DEFAULT_WORKSTATION,
|
||||||
|
preserve_tip_rack_incoming_class: bool = False,
|
||||||
|
target_device: str = DEFAULT_TARGET_DEVICE,
|
||||||
|
target_model: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""把 transfer_actions JSON 转换为带「外壳」的 Cloud Lab 上传格式。
|
||||||
|
|
||||||
|
与 :func:`convert_json_to_node_link` 的差异:本函数在 ``nodes / edges``
|
||||||
|
之外补齐了前端 / Cloud 上传接口期望的顶层字段
|
||||||
|
(``target_lab_uuid`` / ``name`` / ``data.workflow_uuid`` /
|
||||||
|
``data.workflow_name`` / ``data.tags``),并保持 ``nodes / edges`` 字节级
|
||||||
|
与 :func:`convert_json_to_node_link` 完全一致。
|
||||||
|
|
||||||
|
参数优先级(自顶向下取首个非空):
|
||||||
|
|
||||||
|
1. 显式传入:``workflow_name`` / ``tags`` / ``name``。
|
||||||
|
2. 输入 JSON 顶层 ``metadata`` 段:``metadata.workflow_name`` /
|
||||||
|
``metadata.tags``(由 Stage 2 ``export_transfer_actions`` 写入)。
|
||||||
|
3. 回退:空字符串 / 空列表,并打 :mod:`warnings` warning。
|
||||||
|
|
||||||
|
UUID 类字段(``target_lab_uuid`` / ``workflow_uuid``)**不**自动生成;
|
||||||
|
缺省保留空字符串,由调用方(前端 / 上传接口)写入。这样转换器输出
|
||||||
|
的同一份协议是字节稳定的,便于 batch diff 与回归。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: JSON 文件路径、字典数据、或 JSON 字符串。
|
||||||
|
支持 P5 新增的顶层 ``metadata`` 字段,缺失时 fallback 空。
|
||||||
|
target_lab_uuid: 目标实验台 UUID;默认空字符串。
|
||||||
|
workflow_uuid: 工作流 UUID;默认空字符串(后端持久化时生成)。
|
||||||
|
workflow_name: 工作流名称;缺省时取 ``metadata.workflow_name``。
|
||||||
|
name: 列表页面展示标题;缺省时镜像 ``workflow_name``。
|
||||||
|
tags: 工作流标签;缺省时取 ``metadata.tags``。
|
||||||
|
workstation_name: 透传给 :func:`convert_from_json`。
|
||||||
|
preserve_tip_rack_incoming_class: 透传给 :func:`convert_from_json`。
|
||||||
|
target_device: P6.1 新增,目标仪器名;透传给 :func:`convert_from_json`。
|
||||||
|
target_model: P6.1.1 新增,同厂商内的型号名;透传给 :func:`convert_from_json`。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
外壳化的 dict::
|
||||||
|
|
||||||
|
{
|
||||||
|
"target_lab_uuid": str,
|
||||||
|
"name": str,
|
||||||
|
"data": {
|
||||||
|
"workflow_uuid": str,
|
||||||
|
"workflow_name": str,
|
||||||
|
"tags": List[str],
|
||||||
|
"nodes": [...],
|
||||||
|
"edges": [...]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
json_data = _load_json_data(data)
|
||||||
|
|
||||||
|
# 1) 解析 P5 新增的顶层 metadata 段
|
||||||
|
meta = json_data.get("metadata") if isinstance(json_data, dict) else None
|
||||||
|
if not isinstance(meta, dict):
|
||||||
|
meta = {}
|
||||||
|
|
||||||
|
resolved_name = workflow_name if workflow_name else str(meta.get("workflow_name") or "")
|
||||||
|
if tags is None:
|
||||||
|
meta_tags = meta.get("tags")
|
||||||
|
resolved_tags: List[str] = list(meta_tags) if isinstance(meta_tags, (list, tuple)) else []
|
||||||
|
else:
|
||||||
|
resolved_tags = list(tags)
|
||||||
|
|
||||||
|
if not resolved_name:
|
||||||
|
warnings.warn(
|
||||||
|
"convert_json_to_workflow_envelope: workflow_name 为空,"
|
||||||
|
"请检查 transfer_actions JSON 的 metadata.workflow_name 或显式传入 workflow_name"
|
||||||
|
)
|
||||||
|
if not resolved_tags:
|
||||||
|
warnings.warn(
|
||||||
|
"convert_json_to_workflow_envelope: tags 为空,"
|
||||||
|
"请检查 README.md 的 ## Categories 段或显式传入 tags"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2) 复用 convert_from_json 构图(metadata 段对图构建透明)
|
||||||
|
graph = convert_from_json(
|
||||||
|
json_data,
|
||||||
|
workstation_name,
|
||||||
|
preserve_tip_rack_incoming_class=preserve_tip_rack_incoming_class,
|
||||||
|
target_device=target_device,
|
||||||
|
target_model=target_model,
|
||||||
|
)
|
||||||
|
node_link = graph.to_node_link_dict()
|
||||||
|
|
||||||
|
# 3) 组装外壳;name 默认镜像 workflow_name,显式传入时覆盖
|
||||||
|
return {
|
||||||
|
"target_lab_uuid": target_lab_uuid,
|
||||||
|
"name": name if name is not None else resolved_name,
|
||||||
|
"data": {
|
||||||
|
"workflow_uuid": workflow_uuid,
|
||||||
|
"workflow_name": resolved_name,
|
||||||
|
"tags": resolved_tags,
|
||||||
|
"nodes": node_link.get("nodes", []),
|
||||||
|
"edges": node_link.get("edges", []),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
443
unilabos/workflow/labware_mapping.py
Normal file
443
unilabos/workflow/labware_mapping.py
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
"""Opentrons → 目标仪器 物料映射表加载与查询(P6 / P6.1 / P6.1.1)。
|
||||||
|
|
||||||
|
YAML 文件位置(默认):``Uni-Lab-OS/labware_mapping.yaml``(项目根,与
|
||||||
|
``pyproject.toml`` 同级,最显眼)。
|
||||||
|
|
||||||
|
模块对外暴露 4 个 API:
|
||||||
|
|
||||||
|
- :func:`remap_slot` ← 替代 ``_map_deck_slot``
|
||||||
|
- :func:`infer_kind` ← 替代 ``_infer_reagent_kind`` 的字符串匹配链
|
||||||
|
- :func:`resolve_target_class` ← 替代 ``_tip_prcxi_class_for_max_ul`` +
|
||||||
|
``_apply_prcxi_labware_auto_match`` 的主路径
|
||||||
|
- :func:`reload_mapping` ← 测试 / 脚本中改 YAML 后清缓存重读
|
||||||
|
|
||||||
|
P6.1.1 关键约定(与 P6.1 不同):
|
||||||
|
|
||||||
|
- YAML 两段顶层 key:``kinds`` / ``target_devices``。
|
||||||
|
顶层 ``slot_remap`` 段**已不支持**;检出 → warning + 整段 fallback 到 :data:`_BUILTIN_DEFAULT`。
|
||||||
|
- ``slot_remap`` 内嵌到 ``target_devices.<device>.slot_remap``,可由
|
||||||
|
``target_devices.<device>.models.<model>.slot_remap`` 进一步按型号覆盖。
|
||||||
|
- ``rules`` 同样支持型号级覆盖(``target_devices.<device>.models.<model>.rules``)。
|
||||||
|
- ``slot_remap`` 与 ``rules`` 共用同一条 4 段 fallback 链(model → device → default → builtin)。
|
||||||
|
- ``target_devices.default`` **不支持** ``models`` 子段;若声明则 loader warning + 忽略。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import warnings
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
# 项目根的查找:__file__ 在 Uni-Lab-OS/unilabos/workflow/labware_mapping.py;
|
||||||
|
# 上溯三级到 Uni-Lab-OS/(parents[0]=workflow, [1]=unilabos, [2]=Uni-Lab-OS)。
|
||||||
|
_DEFAULT_PATH = Path(__file__).resolve().parents[2] / "labware_mapping.yaml"
|
||||||
|
|
||||||
|
# P6.1:兜底段名硬编码为常量。caller 传入的 target_device 在 target_devices
|
||||||
|
# 段下未声明时,自动 fallback 到这个段。
|
||||||
|
_DEFAULT_SECTION = "default"
|
||||||
|
|
||||||
|
# 默认 slot_remap(与原 _map_deck_slot 硬编码一致)。default + prcxi 段共享同一份。
|
||||||
|
_BUILTIN_DEFAULT_SLOT_REMAP: Dict[str, Any] = {
|
||||||
|
"default": {"4": "13", "8": "14"},
|
||||||
|
"by_object": {"trash": {"12": "16"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
# default 段 + prcxi 段的共享规则列表(两段在 YAML 中各自独立,但第一版字节一致)。
|
||||||
|
_BUILTIN_DEFAULT_RULES: List[Dict[str, Any]] = [
|
||||||
|
{"kind": "tip_rack", "hole_count": 96, "volume_max": 10, "class_name": "PRCXI_10uL_Tips"},
|
||||||
|
{"kind": "tip_rack", "hole_count": 96, "volume_max": 299.9, "class_name": "PRCXI_300ul_Tips"},
|
||||||
|
{"kind": "tip_rack", "hole_count": 96, "class_name": "PRCXI_1000uL_Tips"},
|
||||||
|
{"kind": "tube_rack", "hole_count": 24, "class_name": "PRCXI_EP_Adapter"},
|
||||||
|
{"kind": "tube_rack", "hole_count": 10, "class_name": "PRCXI_EP_Adapter"},
|
||||||
|
{"kind": "plate", "hole_count": 96, "class_name": "PRCXI_BioER_96_wellplate"},
|
||||||
|
{"kind": "plate", "hole_count": 384, "class_name": "PRCXI_BioER_384_wellplate"},
|
||||||
|
{"kind": "trash", "class_name": "PRCXI_trash"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _builtin_device_section() -> Dict[str, Any]:
|
||||||
|
"""构造一个独立的 device 段(slot_remap + rules 都是深拷贝),避免段间共享引用。"""
|
||||||
|
return {
|
||||||
|
"slot_remap": {
|
||||||
|
"default": dict(_BUILTIN_DEFAULT_SLOT_REMAP["default"]),
|
||||||
|
"by_object": {k: dict(v) for k, v in _BUILTIN_DEFAULT_SLOT_REMAP["by_object"].items()},
|
||||||
|
},
|
||||||
|
"rules": [dict(r) for r in _BUILTIN_DEFAULT_RULES],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 内置兜底表:当 YAML 文件不存在 / 解析失败 / 检测到旧 schema 时退化使用。
|
||||||
|
# 与 YAML 文件保持同步。default 与 prcxi 段是两份独立的副本(语义独立、内容相同)。
|
||||||
|
_BUILTIN_DEFAULT: Dict[str, Any] = {
|
||||||
|
"kinds": [
|
||||||
|
{"pattern": "trash", "kind": "trash"},
|
||||||
|
{"pattern": r"tiprack|tip[_ ]?rack|opentrons_\d+_tiprack", "kind": "tip_rack"},
|
||||||
|
{"pattern": r"tuberack|tube[_ ]rack|eppendorf.*rack|safelock.*rack", "kind": "tube_rack"},
|
||||||
|
{"pattern": r"(?:^|[^a-z])rack(?:[^a-z]|$)", "kind": "tube_rack"},
|
||||||
|
{"pattern": r".*", "kind": "plate"},
|
||||||
|
],
|
||||||
|
"target_devices": {
|
||||||
|
_DEFAULT_SECTION: _builtin_device_section(),
|
||||||
|
"prcxi": _builtin_device_section(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _has_legacy_schema(data: Dict[str, Any]) -> bool:
|
||||||
|
"""检测旧 schema 痕迹:
|
||||||
|
|
||||||
|
- P6.1 旧 schema:顶层 ``vendors`` 段,或任一 rule 含 ``prcxi_class``。
|
||||||
|
- P6.1.1 旧 schema(**本期新增**):顶层 ``slot_remap`` 段(应内嵌到 target_devices 下)。
|
||||||
|
"""
|
||||||
|
if "vendors" in data:
|
||||||
|
return True
|
||||||
|
# P6.1.1:顶层 slot_remap 段被视为旧 schema
|
||||||
|
if "slot_remap" in data:
|
||||||
|
return True
|
||||||
|
td = data.get("target_devices")
|
||||||
|
if isinstance(td, dict):
|
||||||
|
for sect in td.values():
|
||||||
|
if not isinstance(sect, dict):
|
||||||
|
continue
|
||||||
|
for r in sect.get("rules") or []:
|
||||||
|
if isinstance(r, dict) and "prcxi_class" in r:
|
||||||
|
return True
|
||||||
|
# 也检查 models 内
|
||||||
|
models = sect.get("models") or {}
|
||||||
|
if isinstance(models, dict):
|
||||||
|
for m in models.values():
|
||||||
|
if not isinstance(m, dict):
|
||||||
|
continue
|
||||||
|
for r in m.get("rules") or []:
|
||||||
|
if isinstance(r, dict) and "prcxi_class" in r:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_schema_reason(data: Dict[str, Any]) -> str:
|
||||||
|
"""生成具体的旧 schema 提示,便于用户定位升级点。"""
|
||||||
|
reasons: List[str] = []
|
||||||
|
if "vendors" in data:
|
||||||
|
reasons.append("顶层 `vendors` 段(应改为 `target_devices`)")
|
||||||
|
if "slot_remap" in data:
|
||||||
|
reasons.append("顶层 `slot_remap` 段(应内嵌到 `target_devices.<device>.slot_remap`)")
|
||||||
|
td = data.get("target_devices")
|
||||||
|
if isinstance(td, dict):
|
||||||
|
for sect_name, sect in td.items():
|
||||||
|
if not isinstance(sect, dict):
|
||||||
|
continue
|
||||||
|
for r in sect.get("rules") or []:
|
||||||
|
if isinstance(r, dict) and "prcxi_class" in r:
|
||||||
|
reasons.append(f"`target_devices.{sect_name}.rules` 中含旧字段 `prcxi_class`(应改为 `class_name`)")
|
||||||
|
break
|
||||||
|
models = sect.get("models") or {}
|
||||||
|
if isinstance(models, dict):
|
||||||
|
for m_name, m in models.items():
|
||||||
|
if not isinstance(m, dict):
|
||||||
|
continue
|
||||||
|
for r in m.get("rules") or []:
|
||||||
|
if isinstance(r, dict) and "prcxi_class" in r:
|
||||||
|
reasons.append(
|
||||||
|
f"`target_devices.{sect_name}.models.{m_name}.rules` 中含旧字段 `prcxi_class`"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
return ";".join(reasons) if reasons else "未知"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=4)
|
||||||
|
def _load_mapping(path: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
"""从 YAML 加载映射表;缺文件 / 解析失败 / 旧 schema 时退化到内置默认。
|
||||||
|
|
||||||
|
``path`` 缺省时取项目根 ``labware_mapping.yaml``。结果按路径缓存,
|
||||||
|
重复调用零成本;测试 / 脚本改 YAML 后通过 :func:`reload_mapping` 失效缓存。
|
||||||
|
|
||||||
|
P6.1.1 校验顺序:
|
||||||
|
|
||||||
|
1. 文件存在 + 可 parse + 根 dict
|
||||||
|
2. 旧 schema 检测(含 P6.1 `vendors` / `prcxi_class` + P6.1.1 顶层 `slot_remap`)
|
||||||
|
→ 整段 fallback 到 :data:`_BUILTIN_DEFAULT`
|
||||||
|
3. 两段顶层 key 校验:``kinds`` / ``target_devices``
|
||||||
|
4. ``target_devices`` 下必含 :data:`_DEFAULT_SECTION` 段;缺则该段使用 builtin default 段
|
||||||
|
5. ``target_devices.default.models`` 不允许;若声明则 warning + 删除
|
||||||
|
"""
|
||||||
|
p = Path(path) if path else _DEFAULT_PATH
|
||||||
|
if not p.exists():
|
||||||
|
warnings.warn(f"labware_mapping.yaml 未找到:{p},使用内置默认表")
|
||||||
|
return _BUILTIN_DEFAULT
|
||||||
|
try:
|
||||||
|
with p.open("r", encoding="utf-8") as f:
|
||||||
|
data = yaml.safe_load(f) or {}
|
||||||
|
except Exception as e:
|
||||||
|
warnings.warn(f"labware_mapping.yaml 解析失败:{e},使用内置默认表")
|
||||||
|
return _BUILTIN_DEFAULT
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
warnings.warn(f"labware_mapping.yaml 根不是 dict:{type(data).__name__},使用内置默认表")
|
||||||
|
return _BUILTIN_DEFAULT
|
||||||
|
|
||||||
|
if _has_legacy_schema(data):
|
||||||
|
warnings.warn(
|
||||||
|
"labware_mapping.yaml 检测到旧 schema:"
|
||||||
|
f"{_legacy_schema_reason(data)}。P6.1.1 不再支持;"
|
||||||
|
"请参考 product_designs/protocol_convert/06-labware-mapping-table.md §11.8 升级 schema。"
|
||||||
|
"本次加载整段使用内置默认表。"
|
||||||
|
)
|
||||||
|
return _BUILTIN_DEFAULT
|
||||||
|
|
||||||
|
for key in ("kinds", "target_devices"):
|
||||||
|
if key not in data or data[key] is None:
|
||||||
|
warnings.warn(f"labware_mapping.yaml 缺少 `{key}` 段;该段将使用内置默认")
|
||||||
|
data[key] = _BUILTIN_DEFAULT[key]
|
||||||
|
|
||||||
|
td = data.get("target_devices")
|
||||||
|
if not isinstance(td, dict) or _DEFAULT_SECTION not in td or td.get(_DEFAULT_SECTION) is None:
|
||||||
|
warnings.warn(
|
||||||
|
f"labware_mapping.yaml 缺少必需的 `target_devices.{_DEFAULT_SECTION}` 段;"
|
||||||
|
f"该段将使用内置默认。default 段是 P6.1 的兜底物料集,未来未声明的 "
|
||||||
|
f"target_device 都会回退到它。"
|
||||||
|
)
|
||||||
|
if not isinstance(td, dict):
|
||||||
|
td = {}
|
||||||
|
data["target_devices"] = td
|
||||||
|
td[_DEFAULT_SECTION] = _BUILTIN_DEFAULT["target_devices"][_DEFAULT_SECTION]
|
||||||
|
|
||||||
|
# P6.1.1:target_devices.default 不支持 models 子段
|
||||||
|
default_sect = td.get(_DEFAULT_SECTION)
|
||||||
|
if isinstance(default_sect, dict) and "models" in default_sect:
|
||||||
|
warnings.warn(
|
||||||
|
f"labware_mapping.yaml: `target_devices.{_DEFAULT_SECTION}.models` 不被支持 —— "
|
||||||
|
"型号粒度差异必须落到具体仪器段。该子段将被忽略。"
|
||||||
|
)
|
||||||
|
# 副作用:从 cached data 中删除,避免后续解析误用
|
||||||
|
default_sect.pop("models", None)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def reload_mapping(path: Optional[str] = None) -> None:
|
||||||
|
"""测试或脚本中修改 YAML 后重新加载(失效 lru_cache)。"""
|
||||||
|
_load_mapping.cache_clear()
|
||||||
|
if path is not None:
|
||||||
|
_load_mapping(str(path))
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 4 段 fallback helper:model → device → default → builtin default
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def _resolve_section(
|
||||||
|
field_name: str,
|
||||||
|
target_device: str,
|
||||||
|
target_model: Optional[str],
|
||||||
|
) -> Any:
|
||||||
|
"""4 段 fallback 链解析指定字段(``slot_remap`` / ``rules`` / ...)。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
field_name: ``target_devices.<device>`` 或 ``models.<model>`` 下的字段名,
|
||||||
|
如 ``"slot_remap"`` / ``"rules"``。
|
||||||
|
target_device: caller 传入的目标仪器名(厂商粒度)。
|
||||||
|
target_model: caller 传入的目标型号名;``None`` 表示不区分型号、走厂商级。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
对应字段的值(保留原 dict / list 形态);找不到任何兜底也只返回 ``None``。
|
||||||
|
|
||||||
|
fallback 链:
|
||||||
|
|
||||||
|
1. ``target_devices.<target_device>.models.<target_model>.<field_name>``
|
||||||
|
—— 仅当 ``target_model`` 非空且 model 子段含该字段。
|
||||||
|
2. ``target_devices.<target_device>.<field_name>`` —— 厂商级。
|
||||||
|
3. ``target_devices.default.<field_name>`` —— 兜底段。
|
||||||
|
4. ``_BUILTIN_DEFAULT.target_devices.default.<field_name>`` —— 最终硬编码兜底。
|
||||||
|
|
||||||
|
warning 策略:
|
||||||
|
- caller 传未声明的 ``target_device`` 段(步骤 2 没拿到值且 device 段整体不存在)→ 单次 warning。
|
||||||
|
- caller 传未声明的 ``target_model``(model 名不存在或 model 内缺该字段)→ **静默** fallback
|
||||||
|
(这是常见的"用厂商默认"用法,不应报噪音)。
|
||||||
|
- YAML 误删 default 段(步骤 3 也拿不到值)→ 单次 warning。
|
||||||
|
"""
|
||||||
|
td = _load_mapping().get("target_devices") or {}
|
||||||
|
builtin_td = _BUILTIN_DEFAULT["target_devices"]
|
||||||
|
|
||||||
|
device_sect = td.get(target_device) if isinstance(td, dict) else None
|
||||||
|
device_sect = device_sect if isinstance(device_sect, dict) else None
|
||||||
|
|
||||||
|
# Step 1: model 级
|
||||||
|
if target_model and device_sect is not None:
|
||||||
|
models = device_sect.get("models")
|
||||||
|
if isinstance(models, dict):
|
||||||
|
m = models.get(target_model)
|
||||||
|
if isinstance(m, dict) and m.get(field_name) is not None:
|
||||||
|
return m[field_name]
|
||||||
|
# model 名整体未声明 / 该字段缺失 → 静默 fallback
|
||||||
|
|
||||||
|
# Step 2: device 级
|
||||||
|
if device_sect is not None and device_sect.get(field_name) is not None:
|
||||||
|
return device_sect[field_name]
|
||||||
|
|
||||||
|
# Step 3: default 段
|
||||||
|
if target_device != _DEFAULT_SECTION and device_sect is None:
|
||||||
|
warnings.warn(
|
||||||
|
f"target_device {target_device!r} 未在 labware_mapping.yaml 的 target_devices 中声明,"
|
||||||
|
f"已回退到固定段 target_devices.{_DEFAULT_SECTION}。"
|
||||||
|
f"请在 YAML 中补 target_devices.{target_device}.{field_name}。"
|
||||||
|
)
|
||||||
|
default_sect = td.get(_DEFAULT_SECTION) if isinstance(td, dict) else None
|
||||||
|
if isinstance(default_sect, dict) and default_sect.get(field_name) is not None:
|
||||||
|
return default_sect[field_name]
|
||||||
|
|
||||||
|
# Step 4: builtin default(YAML 误删 default 段时)
|
||||||
|
warnings.warn(
|
||||||
|
f"labware_mapping.yaml 缺少必需的 target_devices.{_DEFAULT_SECTION}.{field_name};"
|
||||||
|
f"本次解析整段使用内置默认表。"
|
||||||
|
)
|
||||||
|
builtin_default = builtin_td.get(_DEFAULT_SECTION) or {}
|
||||||
|
return builtin_default.get(field_name)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 公开 API
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def remap_slot(
|
||||||
|
raw_slot: Any,
|
||||||
|
object_type: str = "",
|
||||||
|
*,
|
||||||
|
target_device: str = "prcxi",
|
||||||
|
target_model: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""协议槽位 → 目标设备 deck 实际位置。等价于历史 ``_map_deck_slot``:
|
||||||
|
|
||||||
|
1. 优先查 ``slot_remap.by_object[object_type][raw]``(如 ``trash`` 的 ``12 → 16``)。
|
||||||
|
2. 否则查 ``slot_remap.default[raw]``(如 ``4 → 13``、``8 → 14``)。
|
||||||
|
3. 否则原样返回。
|
||||||
|
|
||||||
|
P6.1.1:``slot_remap`` 内嵌在 ``target_devices.<target_device>`` 下,
|
||||||
|
可由 ``target_devices.<target_device>.models.<target_model>.slot_remap`` 进一步覆盖。
|
||||||
|
走 :func:`_resolve_section` 的 4 段 fallback 链(model → device → default → builtin)。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw_slot: 协议中的原始槽位标识;接受 ``int`` / ``str`` / ``None``。
|
||||||
|
object_type: ``labware_info[id]['object']`` 的值(如 ``"trash"`` / ``"source"``)。
|
||||||
|
target_device: 目标仪器名(厂商粒度);默认 ``"prcxi"``。
|
||||||
|
target_model: 目标型号名(型号粒度);``None`` 表示不区分型号,走厂商级。
|
||||||
|
"""
|
||||||
|
s = "" if raw_slot is None else str(raw_slot).strip()
|
||||||
|
if not s:
|
||||||
|
return ""
|
||||||
|
cfg = _resolve_section("slot_remap", target_device, target_model) or {}
|
||||||
|
if not isinstance(cfg, dict):
|
||||||
|
return s
|
||||||
|
ot = (object_type or "").strip().lower()
|
||||||
|
by_obj = (cfg.get("by_object") or {}).get(ot) or {}
|
||||||
|
if s in by_obj:
|
||||||
|
return str(by_obj[s])
|
||||||
|
return str((cfg.get("default") or {}).get(s, s))
|
||||||
|
|
||||||
|
|
||||||
|
def infer_kind(labware_hint: str, object_type: str = "") -> str:
|
||||||
|
"""labware 字符串 + object 字段 → ``plate / tip_rack / tube_rack / trash`` 之一。
|
||||||
|
|
||||||
|
与历史 ``_infer_reagent_kind`` 行为对齐:
|
||||||
|
|
||||||
|
- ``object_type == "trash"`` → 直接 ``trash``。
|
||||||
|
- ``object_type == "tiprack"`` → 直接 ``tip_rack``。
|
||||||
|
- 否则按 YAML ``kinds`` 段顺序,对 ``lower(labware_hint)`` 做 ``re.search``;
|
||||||
|
首个命中胜出。
|
||||||
|
- 全不命中 → ``plate``(YAML 默认 ``.*`` 兜底也回到 plate)。
|
||||||
|
|
||||||
|
``kinds`` 段是**全局**的(与 target_device 无关),P6.1.1 起依然保留在顶层。
|
||||||
|
"""
|
||||||
|
ot = (object_type or "").strip().lower()
|
||||||
|
if ot == "trash":
|
||||||
|
return "trash"
|
||||||
|
if ot == "tiprack":
|
||||||
|
return "tip_rack"
|
||||||
|
hint = (labware_hint or "").lower()
|
||||||
|
for rule in _load_mapping().get("kinds") or []:
|
||||||
|
pat = rule.get("pattern")
|
||||||
|
kd = rule.get("kind")
|
||||||
|
if not pat or not kd:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if re.search(pat, hint):
|
||||||
|
return str(kd)
|
||||||
|
except re.error:
|
||||||
|
warnings.warn(f"labware_mapping.yaml: kinds 规则正则不合法 {pat!r},跳过")
|
||||||
|
continue
|
||||||
|
return "plate"
|
||||||
|
|
||||||
|
|
||||||
|
def _match_rules(
|
||||||
|
rules: List[Dict[str, Any]],
|
||||||
|
kind: str,
|
||||||
|
hole_count: Optional[int],
|
||||||
|
volume: Optional[float],
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""在给定 rules 列表内按 kind + hole_count + volume 找首个命中规则的 ``class_name``。
|
||||||
|
|
||||||
|
匹配规则(与 P6 完全相同的语义):
|
||||||
|
|
||||||
|
- ``rule.kind == kind``(严格相等)。
|
||||||
|
- ``rule.hole_count`` 缺失 OR 严格等于传入 ``hole_count``。
|
||||||
|
若传入 ``hole_count is None``,则只要 rule 也未约束 hole_count 即可视为不冲突。
|
||||||
|
- ``volume`` 范围:rule 的 ``volume_min`` / ``volume_max`` 闭区间,二者均可省略。
|
||||||
|
若传入 ``volume is None``,则只要 rule 也未约束 volume 即可视为不冲突。
|
||||||
|
"""
|
||||||
|
for r in rules or []:
|
||||||
|
if r.get("kind") != kind:
|
||||||
|
continue
|
||||||
|
if "hole_count" in r and r["hole_count"] is not None:
|
||||||
|
if hole_count is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if int(r["hole_count"]) != int(hole_count):
|
||||||
|
continue
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
vmin = r.get("volume_min")
|
||||||
|
vmax = r.get("volume_max")
|
||||||
|
if vmin is not None or vmax is not None:
|
||||||
|
if volume is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
vf = float(volume)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if vmin is not None and vf < float(vmin):
|
||||||
|
continue
|
||||||
|
if vmax is not None and vf > float(vmax):
|
||||||
|
continue
|
||||||
|
cls = r.get("class_name")
|
||||||
|
if cls:
|
||||||
|
return str(cls)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_target_class(
|
||||||
|
target_device: str,
|
||||||
|
kind: str,
|
||||||
|
hole_count: Optional[int] = None,
|
||||||
|
volume: Optional[float] = None,
|
||||||
|
*,
|
||||||
|
target_model: Optional[str] = None,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""按 target_device (+ target_model) + kind + hole_count + volume 选首个命中的 ``class_name``。
|
||||||
|
|
||||||
|
P6.1.1 4 段 fallback 链(走 :func:`_resolve_section`,``field_name="rules"``):
|
||||||
|
|
||||||
|
1. 查 ``target_devices.<target_device>.models.<target_model>.rules``,找到首个命中规则 → 返回 ``class_name``。
|
||||||
|
2. 若步骤 1 缺字段 → 查 ``target_devices.<target_device>.rules``。
|
||||||
|
3. 若 ``target_device`` 段不存在(caller 传 YAML 未声明的名字)→
|
||||||
|
查 ``target_devices.default.rules`` + 单次 warning。
|
||||||
|
4. 若 ``default`` 段也不存在 → 走 :data:`_BUILTIN_DEFAULT` 的 default 段 + warning。
|
||||||
|
|
||||||
|
在最终命中的 rules 列表内仍未匹配到(孔数 / 体积超出覆盖范围)→ 返回 ``None``,
|
||||||
|
交给上游 ``_apply_target_labware_class_auto_match`` 走 PRCXI 模板打分匹配 fallback。
|
||||||
|
"""
|
||||||
|
rules = _resolve_section("rules", target_device, target_model)
|
||||||
|
if not isinstance(rules, list):
|
||||||
|
rules = []
|
||||||
|
return _match_rules(rules, kind, hole_count, volume)
|
||||||
@@ -17,21 +17,35 @@ def _is_node_link_format(data: Dict[str, Any]) -> bool:
|
|||||||
return "nodes" in data and "edges" in data
|
return "nodes" in data and "edges" in data
|
||||||
|
|
||||||
|
|
||||||
def _convert_to_node_link(workflow_file: str, workflow_data: Dict[str, Any]) -> Dict[str, Any]:
|
def _convert_to_node_link(
|
||||||
|
workflow_file: str,
|
||||||
|
workflow_data: Dict[str, Any],
|
||||||
|
*,
|
||||||
|
target_device: str = "prcxi",
|
||||||
|
target_model: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
将非 node-link 格式的工作流数据转换为 node-link 格式
|
将非 node-link 格式的工作流数据转换为 node-link 格式
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
workflow_file: 工作流文件路径(用于日志)
|
workflow_file: 工作流文件路径(用于日志)
|
||||||
workflow_data: 原始工作流数据
|
workflow_data: 原始工作流数据
|
||||||
|
target_device: P6.1 新增,目标仪器名;透传给 :func:`convert_json_to_node_link`。
|
||||||
|
target_model: P6.1.1 新增,同厂商内的型号名;透传给 :func:`convert_json_to_node_link`。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
node-link 格式的工作流数据
|
node-link 格式的工作流数据
|
||||||
"""
|
"""
|
||||||
from unilabos.workflow.convert_from_json import convert_json_to_node_link
|
from unilabos.workflow.convert_from_json import convert_json_to_node_link
|
||||||
|
|
||||||
print_status(f"检测到非 node-link 格式,正在转换...", "info")
|
model_hint = f" target_model={target_model}" if target_model else ""
|
||||||
node_link_data = convert_json_to_node_link(workflow_data)
|
print_status(
|
||||||
|
f"检测到非 node-link 格式,正在转换(target_device={target_device}{model_hint})...",
|
||||||
|
"info",
|
||||||
|
)
|
||||||
|
node_link_data = convert_json_to_node_link(
|
||||||
|
workflow_data, target_device=target_device, target_model=target_model
|
||||||
|
)
|
||||||
print_status(f"转换完成", "success")
|
print_status(f"转换完成", "success")
|
||||||
return node_link_data
|
return node_link_data
|
||||||
|
|
||||||
@@ -42,6 +56,8 @@ def upload_workflow(
|
|||||||
tags: Optional[List[str]] = None,
|
tags: Optional[List[str]] = None,
|
||||||
published: bool = False,
|
published: bool = False,
|
||||||
description: str = "",
|
description: str = "",
|
||||||
|
target_device: str = "prcxi",
|
||||||
|
target_model: Optional[str] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
上传工作流到服务器
|
上传工作流到服务器
|
||||||
@@ -58,6 +74,12 @@ def upload_workflow(
|
|||||||
tags: 工作流标签列表,默认为空列表
|
tags: 工作流标签列表,默认为空列表
|
||||||
published: 是否发布工作流,默认为False
|
published: 是否发布工作流,默认为False
|
||||||
description: 工作流描述,发布时使用
|
description: 工作流描述,发布时使用
|
||||||
|
target_device: P6.1 新增,目标仪器名(厂商粒度,如 ``prcxi`` / ``beckman`` / ``tecan``)。
|
||||||
|
决定查 ``labware_mapping.yaml`` 中 ``target_devices.<target_device>.rules`` 段;未声明
|
||||||
|
的名字由 loader 自动 fallback 到固定段 ``target_devices.default``。默认 ``"prcxi"``。
|
||||||
|
target_model: P6.1.1 新增,同厂商内的型号名(如 ``"9320"`` / ``"4040"``);
|
||||||
|
决定 ``target_devices.<target_device>.models.<target_model>`` 段的 ``slot_remap`` /
|
||||||
|
``rules`` 覆盖。``None`` 表示走厂商级配置。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict: API响应数据
|
Dict: API响应数据
|
||||||
@@ -77,18 +99,36 @@ def upload_workflow(
|
|||||||
print_status(f"工作流文件JSON解析失败: {e}", "error")
|
print_status(f"工作流文件JSON解析失败: {e}", "error")
|
||||||
return {"code": -1, "message": f"JSON解析失败: {e}"}
|
return {"code": -1, "message": f"JSON解析失败: {e}"}
|
||||||
|
|
||||||
|
# P5:先把原始 transfer_actions JSON 的顶层 metadata 段抠出来,避免后续
|
||||||
|
# _convert_to_node_link 转换后丢失 metadata.workflow_name / metadata.tags。
|
||||||
|
# 兼容:旧 node-link 文件没有 metadata 段时为空 dict。
|
||||||
|
orig_metadata = workflow_data.get("metadata") if isinstance(workflow_data, dict) else None
|
||||||
|
if not isinstance(orig_metadata, dict):
|
||||||
|
orig_metadata = {}
|
||||||
|
|
||||||
# 从 JSON 文件中提取 description 和 tags(作为 fallback)
|
# 从 JSON 文件中提取 description 和 tags(作为 fallback)
|
||||||
|
# tags fallback 链:CLI 显式 > metadata.tags(P5)> 顶层 tags(旧字段)> 空列表
|
||||||
if not description and "description" in workflow_data:
|
if not description and "description" in workflow_data:
|
||||||
description = workflow_data["description"]
|
description = workflow_data["description"]
|
||||||
print_status(f"从文件中读取 description", "info")
|
print_status(f"从文件中读取 description", "info")
|
||||||
if not tags and "tags" in workflow_data:
|
if not tags:
|
||||||
|
meta_tags = orig_metadata.get("tags")
|
||||||
|
if isinstance(meta_tags, (list, tuple)) and meta_tags:
|
||||||
|
tags = list(meta_tags)
|
||||||
|
print_status(f"从 metadata.tags 读取 tags: {tags}", "info")
|
||||||
|
elif "tags" in workflow_data:
|
||||||
tags = workflow_data["tags"]
|
tags = workflow_data["tags"]
|
||||||
print_status(f"从文件中读取 tags: {tags}", "info")
|
print_status(f"从文件顶层读取 tags: {tags}", "info")
|
||||||
|
|
||||||
# 自动检测并转换格式
|
# 自动检测并转换格式
|
||||||
if not _is_node_link_format(workflow_data):
|
if not _is_node_link_format(workflow_data):
|
||||||
try:
|
try:
|
||||||
workflow_data = _convert_to_node_link(workflow_file, workflow_data)
|
workflow_data = _convert_to_node_link(
|
||||||
|
workflow_file,
|
||||||
|
workflow_data,
|
||||||
|
target_device=target_device,
|
||||||
|
target_model=target_model,
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print_status(f"工作流格式转换失败: {e}", "error")
|
print_status(f"工作流格式转换失败: {e}", "error")
|
||||||
return {"code": -1, "message": f"格式转换失败: {e}"}
|
return {"code": -1, "message": f"格式转换失败: {e}"}
|
||||||
@@ -97,10 +137,29 @@ def upload_workflow(
|
|||||||
nodes = workflow_data.get("nodes", [])
|
nodes = workflow_data.get("nodes", [])
|
||||||
edges = workflow_data.get("edges", [])
|
edges = workflow_data.get("edges", [])
|
||||||
workflow_uuid_val = workflow_data.get("workflow_uuid", str(uuid.uuid4()))
|
workflow_uuid_val = workflow_data.get("workflow_uuid", str(uuid.uuid4()))
|
||||||
wf_name_from_file = workflow_data.get("workflow_name", os.path.basename(workflow_file).replace(".json", ""))
|
|
||||||
|
# 工作流名称 fallback 链(优先级自顶向下,取首个非空):
|
||||||
|
# 1. CLI 显式 -n/--workflow_name
|
||||||
|
# 2. P5 顶层 metadata.workflow_name(transfer_actions JSON 主路径)
|
||||||
|
# 3. 转换后 workflow_data 顶层 workflow_name(旧 node-link 形态遗留字段)
|
||||||
|
# 4. 文件名(去 .json 后缀)兜底
|
||||||
|
meta_wf_name = str(orig_metadata.get("workflow_name") or "").strip()
|
||||||
|
legacy_top_name = str(workflow_data.get("workflow_name") or "").strip()
|
||||||
|
fallback_filename = os.path.basename(workflow_file).replace(".json", "")
|
||||||
|
wf_name_from_file = meta_wf_name or legacy_top_name or fallback_filename
|
||||||
|
|
||||||
# 确定工作流名称
|
# 确定工作流名称
|
||||||
final_name = workflow_name or wf_name_from_file
|
final_name = workflow_name or wf_name_from_file
|
||||||
|
if not workflow_name:
|
||||||
|
if meta_wf_name:
|
||||||
|
print_status(f"使用 metadata.workflow_name: {meta_wf_name}", "info")
|
||||||
|
elif legacy_top_name:
|
||||||
|
print_status(f"使用文件顶层 workflow_name(旧字段): {legacy_top_name}", "info")
|
||||||
|
else:
|
||||||
|
print_status(
|
||||||
|
f"metadata.workflow_name 与顶层 workflow_name 均为空,回退到文件名: {fallback_filename}",
|
||||||
|
"warning",
|
||||||
|
)
|
||||||
|
|
||||||
print_status(f"正在上传工作流: {final_name}", "info")
|
print_status(f"正在上传工作流: {final_name}", "info")
|
||||||
print_status(f" - 节点数量: {len(nodes)}", "info")
|
print_status(f" - 节点数量: {len(nodes)}", "info")
|
||||||
@@ -108,6 +167,9 @@ def upload_workflow(
|
|||||||
print_status(f" - 标签: {tags or []}", "info")
|
print_status(f" - 标签: {tags or []}", "info")
|
||||||
print_status(f" - 描述: {description[:50]}{'...' if len(description) > 50 else ''}", "info")
|
print_status(f" - 描述: {description[:50]}{'...' if len(description) > 50 else ''}", "info")
|
||||||
print_status(f" - 发布状态: {published}", "info")
|
print_status(f" - 发布状态: {published}", "info")
|
||||||
|
print_status(f" - 目标仪器: {target_device}", "info")
|
||||||
|
if target_model:
|
||||||
|
print_status(f" - 目标型号: {target_model}", "info")
|
||||||
|
|
||||||
# 调用 http_client 上传
|
# 调用 http_client 上传
|
||||||
result = http_client.workflow_import(
|
result = http_client.workflow_import(
|
||||||
@@ -137,15 +199,27 @@ def handle_workflow_upload_command(args_dict: Dict[str, Any]) -> None:
|
|||||||
处理 workflow_upload 子命令
|
处理 workflow_upload 子命令
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
args_dict: 命令行参数字典
|
args_dict: 命令行参数字典;
|
||||||
|
- P6.1 新增 ``target_device`` key(缺省 ``"prcxi"``)。
|
||||||
|
- P6.1.1 新增 ``target_model`` key(缺省 ``None``)。
|
||||||
"""
|
"""
|
||||||
workflow_file = args_dict.get("workflow_file")
|
workflow_file = args_dict.get("workflow_file")
|
||||||
workflow_name = args_dict.get("workflow_name")
|
workflow_name = args_dict.get("workflow_name")
|
||||||
tags = args_dict.get("tags", [])
|
tags = args_dict.get("tags", [])
|
||||||
published = args_dict.get("published", False)
|
published = args_dict.get("published", False)
|
||||||
description = args_dict.get("description", "")
|
description = args_dict.get("description", "")
|
||||||
|
target_device = args_dict.get("target_device") or "prcxi"
|
||||||
|
target_model = args_dict.get("target_model") or None
|
||||||
|
|
||||||
if workflow_file:
|
if workflow_file:
|
||||||
upload_workflow(workflow_file, workflow_name, tags, published, description)
|
upload_workflow(
|
||||||
|
workflow_file,
|
||||||
|
workflow_name,
|
||||||
|
tags,
|
||||||
|
published,
|
||||||
|
description,
|
||||||
|
target_device=target_device,
|
||||||
|
target_model=target_model,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
print_status("未指定工作流文件路径,请使用 -f/--workflow_file 参数", "error")
|
print_status("未指定工作流文件路径,请使用 -f/--workflow_file 参数", "error")
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
|
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
|
||||||
<package format="3">
|
<package format="3">
|
||||||
<name>unilabos_msgs</name>
|
<name>unilabos_msgs</name>
|
||||||
<version>0.10.19</version>
|
<version>0.11.1</version>
|
||||||
<description>ROS2 Messages package for unilabos devices</description>
|
<description>ROS2 Messages package for unilabos devices</description>
|
||||||
<maintainer email="changjh@pku.edu.cn">Junhan Chang</maintainer>
|
<maintainer email="changjh@pku.edu.cn">Junhan Chang</maintainer>
|
||||||
<maintainer email="18435084+Xuwznln@users.noreply.github.com">Xuwznln</maintainer>
|
<maintainer email="18435084+Xuwznln@users.noreply.github.com">Xuwznln</maintainer>
|
||||||
|
|||||||
Reference in New Issue
Block a user