Compare commits

...

7 Commits

Author SHA1 Message Date
q434343
5be601177e 更新transfer部分
框选set liquid
跨slot的工作流合并
工作流名称,tag修勾
液体名称使用真实液体名称
2026-05-25 16:03:55 +08:00
q434343
ad05e8c73e 修改Z轴最大值无法赋予的bug, 2026-05-18 02:34:29 +08:00
q434343
940abc3664 Merge branch 'dev' into prcix9320 2026-05-14 17:54:24 +08:00
q434343
6288e37464 演示时修改的部分代码 2026-05-14 17:49:48 +08:00
q434343
3aed75bc8b protocol转换方法修改 2026-05-07 20:15:52 +08:00
q434343
acb2dc9359 初始化完成后上传更新deck 2026-04-14 16:57:33 +08:00
q434343
f22c3f4c42 修改移液部分代码,如果没有10ul枪头,则不使用10ul移液枪 2026-04-14 15:35:04 +08:00
91 changed files with 9343 additions and 1221 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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]:

View File

@@ -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>"
@@ -65,7 +66,7 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
返回: 返回:
```json ```json
{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}} { "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } }
``` ```
记住 `data.uuid``lab_uuid` 记住 `data.uuid``lab_uuid`
@@ -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 |

View File

@@ -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目标工作流
@@ -93,7 +99,7 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
返回: 返回:
```json ```json
{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}} { "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } }
``` ```
记住 `data.uuid``lab_uuid` 记住 `data.uuid``lab_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,8 +302,11 @@ python scripts/gen_notebook_params.py \
"properties": { "properties": {
"goal": { "goal": {
"properties": { "properties": {
"asp_vols": {"type": "array", "items": {"type": "number"}}, "asp_vols": {
"sources": {"type": "array"} "type": "array",
"items": { "type": "number" }
},
"sources": { "type": "array" }
}, },
"required": ["asp_vols", "sources"] "required": ["asp_vols", "sources"]
} }

View File

@@ -7,7 +7,7 @@
选项: 选项:
--auth <token> Lab tokenbase64(ak:sk) 的结果,不含 "Lab " 前缀) --auth <token> Lab tokenbase64(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
""" """

View File

@@ -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 | 用户指定 | 更新扩展数据 |
> 只传需要更新的字段,未传的字段保持不变。
## 最终目录结构 ## 最终目录结构

View File

@@ -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 |
@@ -90,7 +96,7 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
返回: 返回:
```json ```json
{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}} { "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } }
``` ```
记住 `data.uuid``lab_uuid` 记住 `data.uuid``lab_uuid`
@@ -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,22 +147,24 @@ 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 },
"measurements": [ "measurements": [
{"sample_id": "S001", "value": 3.14, "unit": "mg/mL"}, { "sample_id": "S001", "value": 3.14, "unit": "mg/mL" },
{"sample_id": "S002", "value": 2.71, "unit": "mg/mL"} { "sample_id": "S002", "value": 2.71, "unit": "mg/mL" }
] ]
} }
``` ```
**从 CSV 文件导入**(脚本自动转换): **从 CSV 文件导入**(脚本自动转换):
```json ```json
{ {
"experiment_data": [ "experiment_data": [
{"温度": 25, "压力": 101.3, "产率": 0.85}, { "温度": 25, "压力": 101.3, "产率": 0.85 },
{"温度": 30, "压力": 101.3, "产率": 0.91} { "温度": 30, "压力": 101.3, "产率": 0.91 }
] ]
} }
``` ```
@@ -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 tokenbase64(ak:sk) | | `--auth` | 提交时必选 | Lab tokenbase64(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>` 替代。

View File

@@ -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: |

View File

@@ -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"

View File

@@ -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: |

View File

@@ -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..."

View File

@@ -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: |

View File

@@ -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 subcommandP6.1 新增 --target_deviceP6.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 dispenseStage 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

View File

@@ -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
![copy_aksk.gif](image/copy_aksk.gif) ![copy_aksk.gif](image/copy_aksk.gif)
@@ -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
``` ```
**命令行方式(推荐):** **命令行方式(推荐):**

View File

@@ -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后查看日志

View File

@@ -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 ActionJSON 指令 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

View File

@@ -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)
--- ---

View File

@@ -626,7 +626,7 @@ unilab
**云端图文件管理**: **云端图文件管理**:
1. 登录 https://uni-lab.bohrium.com 1. 登录 https://leap-lab.bohrium.com
2. 进入"设备配置" 2. 进入"设备配置"
3. 创建或编辑配置 3. 创建或编辑配置
4. 保存到云端 4. 保存到云端

View File

@@ -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
View File

@@ -0,0 +1,140 @@
# Opentrons → 目标仪器 物料映射表P6.1.1
#
# 两段顶层 keyP6.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 mL10 位兼容 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 }
# ─────────────────────────────────────────────────────────────────────────
# prcxiPRCXI 仪器专用段。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 rackPRCXI 量程档≤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 mL10 位兼容 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 重映射

View File

@@ -1,5 +1,5 @@
channel_sources: channel_sources:
- robostack,robostack-staging,conda-forge,defaults - robostack,robostack-staging,conda-forge
gazebo: gazebo:
- '11' - '11'

View File

@@ -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

View File

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

View File

@@ -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'],

View 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,
)
# ---------------------------------------------------------------------------
# FixturesDummyTracker / 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"

View File

@@ -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`` 的 monkeypatchdump 直接返回输入列表)。
"""
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_liquidper-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-occurrenceplate_slot2 第 1 次出现于 idx=0plate_slot3 idx=1plate_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不匹配
)

View 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
# ---------------------------------------------------------------------------
# FixturesDummyTracker / 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 测试用的 basePLR 可用时是 ``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
# ---------------------------------------------------------------------------
# T1T12 端到端测试(单通道 transfer_liquid 主循环)
#
# 需要 PLR 完整环境(``pylabrobot.liquid_handling.LiquidHandlerBackend`` 等)。
# 若 PLR import 失败则整段 skiphelper 测试照常运行。
# ---------------------------------------------------------------------------
_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:
"""T29 个独立 source well不同 PLR Well 对象)都装 PBS → identity 全 failliquids-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 都装 PBS8 个不同 Well 对象)→ identity 全 failliquids-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:
"""T4A1=PBSB1=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 次完全独立的 transfer2 次 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:
"""T5source 从未调过 set_liquidsliquids 空)→ 视为未知,强制换 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:
"""T6tip_reuse_by_liquid_name=FalseT2 场景退化为 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-only9 次独立换 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:
"""T11aspirate 顶层归零 → 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 被 poppending_tip_name 已捕获 "PBS"
# → 下一轮 source 仍是 PBSaspirate 还没发生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
# ---------------------------------------------------------------------------
# T98 通道段锚孔 liquids-keep
# ---------------------------------------------------------------------------
@_skip_if_no_plr
class TestEightChannelSegmentTipReuse:
"""T98 通道分段,连续两段 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

View 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"] == []

View 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_infomutable每个 case 独立 build 一份)。
包含 tip rack + 24-tube rack + 96 wellplateslot 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):
"""同一 protocoltarget_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 µLprcxi 走 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.1target_model 透传到 _map_deck_slot 后改变 create_resource 的 slot_on_deck。
YAML 中 prcxi 厂商级 slot_remap 4→13模型 "4040" 显式覆盖 4→16。
同一份 labware_infoslot=4build 出的两份图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"

View 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_key51b9a5 主场景)。
- ``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/6target_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_liquidtargets_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.targetsv2 中 list 形态在 INPUT_PORT_MAPPING 处理后被清空([])或为单字符串
# (不再是原始 list[str]——避免下游 runtime 对其再做无序聚合)
tparams = tnode.get("param", {}) or {}
assert not isinstance(tparams.get("targets"), list) or tparams.get("targets") == [], (
f"v2params.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"v2transfer_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 聚合节点"

View 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_keyP8 前行为)。"""
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 个 target2 个有显式名、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 路径(无 mergedsource 和 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 wellsmulti-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

View 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 模板。
跨板 fixP2 v2 §14把 plate name 作为 prefix 编码进 ``well_names`` 之后,
runtime 调用 ``plate.get_well("A5")`` 严格定位 welltrough 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 返回 Nonelabware 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 blockslot 8 上 12 个 samples_XX 末尾含 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,
)
# ==================== unithint 函数本身 ====================
@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.json12 列 × 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

View 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`` §142026-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_infosource 端 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_1target 端被全部跳过
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"§14target 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"R1t_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 个 sourcesrc_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"§14target 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-platesource 不会被 §14 跳过)"
assert "src_Y" in per_plate_keys, "source src_Y 必须有 per-plate"

View 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 schemav1.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 字段优先:即使字符串看起来像 platetrash / 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_classtarget_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()
# 同样的体积 200prcxi 走 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"
# 同样的体积 15prcxi 已超出 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_Plate96prcxi 段保持 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):
"""旧 schemavendors / prcxi_class应被拒绝 + warning + 整段 fallback 到 builtinP6.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_remapmodel 名忽略)。"""
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
)

View 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"]

View File

@@ -1 +1 @@
__version__ = "0.10.19" __version__ = "0.11.1"

View File

@@ -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

View File

@@ -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}"},

View File

@@ -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

View File

@@ -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配置

View File

@@ -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.liquidsaspirate 后再读会拿不到液体身份。
# 详见 ``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 wellvolume 取**负数**与 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 wellvolume 取**正数**
# 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 复用判等开关;默认 onpop 出 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 dictplr_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 跨 plateliquid_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
# 拆解逐个查 platecommon.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 fixwell_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 v2tip 残液身份
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 × MM 为列数 / 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 segmentbroadcast 到 8 通道
item = value[seg_idx % len(value)]
return [item] * 8
prev_dropped = True
current_tip_liquid_name: Optional[str] = None # P10 v2tip 残液身份(段锚孔粒度)
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 截/扩到长度 nNone / 空列表返回 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 v4blow_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,

View 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 # 操作体积µLaspirate 为负dispense / set 为正)
action: str # "set" / "aspirate" / "dispense" / "legacy" / "auto_init"
timestamp: str # ISO8601 UTCOS 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

View File

@@ -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 顺序回退。"""
"""台面板位槽号116与 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:
"""台面板位槽号116优先 _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,

View File

@@ -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):
""" """

View File

@@ -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}")
# 切换到试剂瓶11号位 # 切换到试剂瓶11号位
print(f"\n🔌 切换到1号位: {valve.set_position(1)}") print(f"\n🔌 切换到1号位: {valve.set_position(1)}")

View File

@@ -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)"

View File

@@ -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:

View File

@@ -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()

View File

@@ -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",

View File

@@ -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)

View File

@@ -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]

View File

@@ -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

View File

@@ -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,19 +1968,19 @@ 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
description: 来源位置X坐标 description: 来源位置 X 坐标
type: integer type: integer
source_y: source_y:
default: 1 default: 1
description: 来源位置Y坐标 description: 来源位置 Y 坐标
type: integer type: integer
source_z: source_z:
default: 1 default: 1
description: 来源位置Z坐标 description: 来源位置 Z 坐标
type: integer type: integer
required: [] required: []
type: object type: object
@@ -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

View File

@@ -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

View File

@@ -150,15 +150,15 @@ coincellassemblyworkstation_device:
properties: properties:
assembly_pressure: assembly_pressure:
default: 4200 default: 4200
description: 电池压制力(N) description: 电池压制力 (N)
type: integer type: integer
assembly_type: assembly_type:
default: 7 default: 7
description: 组装类型(7=不用铝箔垫, 8=使用铝箔垫) description: 组装类型 (7=不用铝箔垫, 8=使用铝箔垫)
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
@@ -166,29 +166,29 @@ coincellassemblyworkstation_device:
type: boolean type: boolean
dual_drop_first_volume: dual_drop_first_volume:
default: 25 default: 25
description: 二次滴液第一次排液体积(μL) description: 二次滴液第一次排液体积 (μL)
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
description: 电解液吸液量(μL) description: 电解液吸液量 (μL)
type: integer type: integer
file_path: file_path:
default: /Users/sml/work default: /Users/sml/work
@@ -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
@@ -547,15 +553,15 @@ coincellassemblyworkstation_device:
properties: properties:
assembly_pressure: assembly_pressure:
default: 4200 default: 4200
description: 电池压制力(N) description: 电池压制力 (N)
type: integer type: integer
assembly_type: assembly_type:
default: 7 default: 7
description: 组装类型(7=不用铝箔垫, 8=使用铝箔垫) description: 组装类型 (7=不用铝箔垫, 8=使用铝箔垫)
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
@@ -563,29 +569,29 @@ coincellassemblyworkstation_device:
type: boolean type: boolean
dual_drop_first_volume: dual_drop_first_volume:
default: 25 default: 25
description: 二次滴液第一次排液体积(μL) description: 二次滴液第一次排液体积 (μL)
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
description: 电解液吸液量(μL) description: 电解液吸液量 (μL)
type: integer type: integer
file_path: file_path:
default: /Users/sml/work default: /Users/sml/work
@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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_csvbackup_dir description: 备份目录路径默认使用最近一次 submit_from_csvbackup_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

View File

@@ -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

View File

@@ -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

View File

@@ -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: 粉末类型IDSalt=21分钟Flour=面粉27分钟BTDA=BTDA38分钟 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

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -409,11 +409,11 @@ xrd_d7mate:
properties: properties:
end_theta: end_theta:
default: 80.0 default: 80.0
description: 结束角度≥5.5°且必须大于start_theta description: 结束角度≥5.5°,且必须大于 start_theta
type: number type: number
exp_time: exp_time:
default: 0.1 default: 0.1
description: 曝光时间0.1-5.0秒) description: 曝光时间0.1-5.0 秒)
type: number type: number
increment: increment:
default: 0.05 default: 0.05
@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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:

View File

@@ -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

View File

@@ -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 实际要求的字段(必需)
} }

View File

@@ -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)

View File

@@ -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 timer10s不阻塞完成检测
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]

View File

@@ -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)

View File

@@ -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]]

View 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": []
}

View File

@@ -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",

View File

@@ -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: {

View File

@@ -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))

View File

@@ -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 仍为 labwareplaceholder 内部把 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_identifierP3 主路径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(
"""协议槽位 -> 实际 deck4→138→1412+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→138→1412+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-22hint 只用 ``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.1tip 量程档不再硬编码 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 环境(无 pylabrobotYAML 命中也能写入 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 fix2026-05-22merged 节点的 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",
})
# P82026-05-24reagent 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=0target 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
# P82026-05-24reagent 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.wellslist[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_PARAMSuse_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 v2synthetic 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 > 1multi 协议)时,
# asp_vols / dis_vols 等数组的长度已是 8 × MStage 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

View File

@@ -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", []),
},
}

View 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.1target_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 helpermodel → 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 defaultYAML 误删 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)

View File

@@ -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.tagsP5> 顶层 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_nametransfer_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")

View File

@@ -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>