mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-05-25 10:40:00 +00:00
Compare commits
34 Commits
prcix9320
...
865dd87556
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
865dd87556 | ||
|
|
86f1640efb | ||
|
|
99ee27bfc2 | ||
|
|
e8f54d50f9 | ||
|
|
201b1064d7 | ||
|
|
2ebe35e70e | ||
|
|
717f236332 | ||
|
|
79c0815b70 | ||
|
|
f431d61d85 | ||
|
|
3af86a07f2 | ||
|
|
d1713fcca1 | ||
|
|
52b460466d | ||
|
|
7efccbc688 | ||
|
|
dc1de44b19 | ||
|
|
4581ee1eeb | ||
|
|
620cb8435f | ||
|
|
83565038cb | ||
|
|
01d281189a | ||
|
|
db22156d77 | ||
|
|
20342c6484 | ||
|
|
008c355754 | ||
|
|
0895252bc1 | ||
|
|
3e43359460 | ||
|
|
73add2dc06 | ||
|
|
dd21d93151 | ||
|
|
e11c3533c7 | ||
|
|
ed952e8a44 | ||
|
|
467f0b1115 | ||
|
|
91928a87ac | ||
|
|
d7850b050b | ||
|
|
dff70bd72b | ||
|
|
03e3719b18 | ||
|
|
41a018febc | ||
|
|
7505e024f3 |
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
package:
|
package:
|
||||||
name: unilabos
|
name: unilabos
|
||||||
version: 0.11.1
|
version: 0.10.19
|
||||||
|
|
||||||
source:
|
source:
|
||||||
path: ../../unilabos
|
path: ../../unilabos
|
||||||
@@ -54,7 +54,7 @@ requirements:
|
|||||||
- pymodbus
|
- pymodbus
|
||||||
- matplotlib
|
- matplotlib
|
||||||
- pylibftdi
|
- pylibftdi
|
||||||
- uni-lab::unilabos-env ==0.11.1
|
- uni-lab::unilabos-env ==0.10.19
|
||||||
|
|
||||||
about:
|
about:
|
||||||
repository: https://github.com/deepmodeling/Uni-Lab-OS
|
repository: https://github.com/deepmodeling/Uni-Lab-OS
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
package:
|
package:
|
||||||
name: unilabos-env
|
name: unilabos-env
|
||||||
version: 0.11.1
|
version: 0.10.19
|
||||||
|
|
||||||
build:
|
build:
|
||||||
noarch: generic
|
noarch: generic
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
package:
|
package:
|
||||||
name: unilabos-full
|
name: unilabos-full
|
||||||
version: 0.11.1
|
version: 0.10.19
|
||||||
|
|
||||||
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.11.1
|
- uni-lab::unilabos ==0.10.19
|
||||||
# Documentation tools
|
# Documentation tools
|
||||||
- sphinx
|
- sphinx
|
||||||
- sphinx_rtd_theme
|
- sphinx_rtd_theme
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
@echo off
|
|
||||||
setlocal enabledelayedexpansion
|
|
||||||
|
|
||||||
REM upgrade pip
|
|
||||||
"%PREFIX%\python.exe" -m pip install --upgrade pip
|
|
||||||
|
|
||||||
REM install extra deps
|
|
||||||
"%PREFIX%\python.exe" -m pip install paho-mqtt opentrons_shared_data
|
|
||||||
"%PREFIX%\python.exe" -m pip install git+https://github.com/Xuwznln/pylabrobot.git
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euxo pipefail
|
|
||||||
|
|
||||||
# make sure pip is available
|
|
||||||
"$PREFIX/bin/python" -m pip install --upgrade pip
|
|
||||||
|
|
||||||
# install extra deps
|
|
||||||
"$PREFIX/bin/python" -m pip install paho-mqtt opentrons_shared_data
|
|
||||||
"$PREFIX/bin/python" -m pip install git+https://github.com/Xuwznln/pylabrobot.git
|
|
||||||
@@ -71,22 +71,6 @@ 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
|
||||||
@@ -121,27 +105,13 @@ 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 action, device, not_action, topic_config
|
from unilabos.registry.decorators import device, action, topic_config, not_action
|
||||||
|
|
||||||
@device(
|
@device(id="my_device", category=["my_category"], description="设备描述")
|
||||||
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}")
|
||||||
@@ -163,13 +133,7 @@ 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]:
|
||||||
|
|||||||
251
.cursor/skills/host-node/SKILL.md
Normal file
251
.cursor/skills/host-node/SKILL.md
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
---
|
||||||
|
name: host-node
|
||||||
|
description: Operate Uni-Lab host node via REST API — create resources, test latency, test resource tree, manual confirm. Use when the user mentions host_node, creating resources, resource management, testing latency, or any host node operation.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Host Node API Skill
|
||||||
|
|
||||||
|
## 设备信息
|
||||||
|
|
||||||
|
- **device_id**: `host_node`
|
||||||
|
- **Python 源码**: `unilabos/ros/nodes/presets/host_node.py`
|
||||||
|
- **设备类**: `HostNode`
|
||||||
|
- **动作数**: 4(`create_resource`, `test_latency`, `auto-test_resource`, `manual_confirm`)
|
||||||
|
|
||||||
|
## 前置条件(缺一不可)
|
||||||
|
|
||||||
|
使用本 skill 前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
|
||||||
|
|
||||||
|
### 1. ak / sk → AUTH
|
||||||
|
|
||||||
|
从启动参数 `--ak` `--sk` 或 config.py 中获取,生成 token:`base64(ak:sk)` → `Authorization: Lab <token>`
|
||||||
|
|
||||||
|
### 2. --addr → BASE URL
|
||||||
|
|
||||||
|
| `--addr` 值 | BASE |
|
||||||
|
| ------------ | ----------------------------------- |
|
||||||
|
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||||
|
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||||
|
| `local` | `http://127.0.0.1:48197` |
|
||||||
|
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||||
|
|
||||||
|
确认后设置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BASE="<根据 addr 确定的 URL>"
|
||||||
|
AUTH="Authorization: Lab <token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**两项全部就绪后才可发起 API 请求。**
|
||||||
|
|
||||||
|
## Session State
|
||||||
|
|
||||||
|
在整个对话过程中,agent 需要记住以下状态,避免重复询问用户:
|
||||||
|
|
||||||
|
- `lab_uuid` — 实验室 UUID(首次通过 API #1 自动获取,**不需要问用户**)
|
||||||
|
- `device_name` — `host_node`
|
||||||
|
|
||||||
|
## 请求约定
|
||||||
|
|
||||||
|
所有请求使用 `curl -s`,POST/PATCH/DELETE 需加 `Content-Type: application/json`。
|
||||||
|
|
||||||
|
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### 1. 获取实验室信息(自动获取 lab_uuid)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
||||||
|
```
|
||||||
|
|
||||||
|
返回 `data.uuid` 为 `lab_uuid`,`data.name` 为 `lab_name`。
|
||||||
|
|
||||||
|
### 2. 创建工作流
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$BASE/api/v1/lab/workflow/owner" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"<名称>","lab_uuid":"<lab_uuid>","description":"<描述>"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
返回 `data.uuid` 为 `workflow_uuid`。创建成功后告知用户链接:`$BASE/laboratory/$lab_uuid/workflow/$workflow_uuid`
|
||||||
|
|
||||||
|
### 3. 创建节点
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$BASE/api/v1/edge/workflow/node" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"workflow_uuid":"<workflow_uuid>","resource_template_name":"host_node","node_template_name":"<action_name>"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
- `resource_template_name` 固定为 `host_node`
|
||||||
|
- `node_template_name` — action 名称(如 `create_resource`, `test_latency`)
|
||||||
|
|
||||||
|
### 4. 删除节点
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X DELETE "$BASE/api/v1/lab/workflow/nodes" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"node_uuids":["<uuid1>"],"workflow_uuid":"<workflow_uuid>"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 更新节点参数
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X PATCH "$BASE/api/v1/lab/workflow/node" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"workflow_uuid":"<wf_uuid>","uuid":"<node_uuid>","param":{...}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
`param` 直接使用创建节点返回的 `data.param` 结构,修改需要填入的字段值。参考 [action-index.md](action-index.md) 确定哪些字段是 Slot。
|
||||||
|
|
||||||
|
### 6. 查询节点 handles
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$BASE/api/v1/lab/workflow/node-handles" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"node_uuids":["<node_uuid_1>","<node_uuid_2>"]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. 批量创建边
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$BASE/api/v1/lab/workflow/edges" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"edges":[{"source_node_uuid":"<uuid>","target_node_uuid":"<uuid>","source_handle_uuid":"<uuid>","target_handle_uuid":"<uuid>"}]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. 启动工作流
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$BASE/api/v1/lab/workflow/<workflow_uuid>/run" -H "$AUTH"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. 运行设备单动作
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$BASE/api/v1/lab/mcp/run/action" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"lab_uuid":"<lab_uuid>","device_id":"host_node","action":"<action_name>","action_type":"<type>","param":{...}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
`param` 直接放 goal 里的属性,**不要**再包一层 `{"goal": {...}}`。
|
||||||
|
|
||||||
|
> **WARNING: `action_type` 必须正确,传错会导致任务永远卡住无法完成。** 从下表或 `actions/<name>.json` 的 `type` 字段获取。
|
||||||
|
|
||||||
|
#### action_type 速查表
|
||||||
|
|
||||||
|
| action | action_type |
|
||||||
|
|--------|-------------|
|
||||||
|
| `test_latency` | `UniLabJsonCommand` |
|
||||||
|
| `create_resource` | `ResourceCreateFromOuterEasy` |
|
||||||
|
| `auto-test_resource` | `UniLabJsonCommand` |
|
||||||
|
| `manual_confirm` | `UniLabJsonCommand` |
|
||||||
|
|
||||||
|
### 10. 查询任务状态
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X GET "$BASE/api/v1/lab/mcp/task/<task_uuid>" -H "$AUTH"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11. 运行工作流单节点
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$BASE/api/v1/lab/mcp/run/workflow/action" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"node_uuid":"<node_uuid>"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12. 获取资源树(物料信息)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
|
||||||
|
```
|
||||||
|
|
||||||
|
注意 `lab_uuid` 在路径中。返回 `data.nodes[]` 含所有节点(设备 + 物料),每个节点含 `name`、`uuid`、`type`、`parent`。
|
||||||
|
|
||||||
|
### 13. 获取工作流模板详情
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH"
|
||||||
|
```
|
||||||
|
|
||||||
|
> 必须使用 `/lab/workflow/template/detail/{uuid}`,其他路径会返回 404。
|
||||||
|
|
||||||
|
### 14. 按名称查询物料模板
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=<template_name>" -H "$AUTH"
|
||||||
|
```
|
||||||
|
|
||||||
|
返回 `data.uuid` 为 `res_template_uuid`,用于 API #15。
|
||||||
|
|
||||||
|
### 15. 创建物料节点
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$BASE/api/v1/edge/material/node" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"res_template_uuid":"<uuid>","name":"<名称>","display_name":"<显示名>","parent_uuid":"<父节点uuid>","data":{...}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 16. 更新物料节点
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X PUT "$BASE/api/v1/edge/material/node" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"uuid":"<节点uuid>","display_name":"<新名称>","data":{...}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Placeholder Slot 填写规则
|
||||||
|
|
||||||
|
| `placeholder_keys` 值 | Slot 类型 | 填写格式 | 选取范围 |
|
||||||
|
| --------------------- | ------------ | ----------------------------------------------------- | ---------------------- |
|
||||||
|
| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅物料节点(非设备) |
|
||||||
|
| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅设备节点(type=device) |
|
||||||
|
| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | 所有节点(设备 + 物料) |
|
||||||
|
| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已注册的资源类 |
|
||||||
|
|
||||||
|
### host_node 设备的 Slot 字段表
|
||||||
|
|
||||||
|
| Action | 字段 | Slot 类型 | 说明 |
|
||||||
|
| ----------------- | ----------- | ------------ | ------------------------------ |
|
||||||
|
| `create_resource` | `res_id` | ResourceSlot | 新资源路径(可填不存在的路径) |
|
||||||
|
| `create_resource` | `device_id` | DeviceSlot | 归属设备 |
|
||||||
|
| `create_resource` | `parent` | NodeSlot | 父节点路径 |
|
||||||
|
| `create_resource` | `class_name`| ClassSlot | 资源类名如 `"container"` |
|
||||||
|
| `auto-test_resource` | `resource` | ResourceSlot | 单个测试物料 |
|
||||||
|
| `auto-test_resource` | `resources` | ResourceSlot | 测试物料数组 |
|
||||||
|
| `auto-test_resource` | `device` | DeviceSlot | 测试设备 |
|
||||||
|
| `auto-test_resource` | `devices` | DeviceSlot | 测试设备 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 渐进加载策略
|
||||||
|
|
||||||
|
1. **SKILL.md**(本文件)— API 端点 + session state 管理
|
||||||
|
2. **[action-index.md](action-index.md)** — 按分类浏览 4 个动作的描述和核心参数
|
||||||
|
3. **[actions/\<name\>.json](actions/)** — 仅在需要构建具体请求时,加载对应 action 的完整 JSON Schema
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 完整工作流 Checklist
|
||||||
|
|
||||||
|
```
|
||||||
|
Task Progress:
|
||||||
|
- [ ] Step 1: GET /edge/lab/info 获取 lab_uuid
|
||||||
|
- [ ] Step 2: 获取资源树 (GET #12) → 记住可用物料
|
||||||
|
- [ ] Step 3: 读 action-index.md 确定要用的 action 名
|
||||||
|
- [ ] Step 4: 创建工作流 (POST #2) → 记住 workflow_uuid,告知用户链接
|
||||||
|
- [ ] Step 5: 创建节点 (POST #3, resource_template_name=host_node) → 记住 node_uuid + data.param
|
||||||
|
- [ ] Step 6: 根据 _unilabos_placeholder_info 和资源树,填写 data.param 中的 Slot 字段
|
||||||
|
- [ ] Step 7: 更新节点参数 (PATCH #5)
|
||||||
|
- [ ] Step 8: 查询节点 handles (POST #6) → 获取各节点的 handle_uuid
|
||||||
|
- [ ] Step 9: 批量创建边 (POST #7) → 用 handle_uuid 连接节点
|
||||||
|
- [ ] Step 10: 启动工作流 (POST #8) 或运行单节点 (POST #11)
|
||||||
|
- [ ] Step 11: 查询任务状态 (GET #10) 确认完成
|
||||||
|
```
|
||||||
58
.cursor/skills/host-node/action-index.md
Normal file
58
.cursor/skills/host-node/action-index.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Action Index — host_node
|
||||||
|
|
||||||
|
4 个动作,按功能分类。每个动作的完整 JSON Schema 在 `actions/<name>.json`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 资源管理
|
||||||
|
|
||||||
|
### `create_resource`
|
||||||
|
|
||||||
|
在资源树中创建新资源(容器、物料等),支持指定位置、类型和初始液体
|
||||||
|
|
||||||
|
- **action_type**: `ResourceCreateFromOuterEasy`
|
||||||
|
- **Schema**: [`actions/create_resource.json`](actions/create_resource.json)
|
||||||
|
- **可选参数**: `res_id`, `device_id`, `class_name`, `parent`, `bind_locations`, `liquid_input_slot`, `liquid_type`, `liquid_volume`, `slot_on_deck`
|
||||||
|
- **占位符字段**:
|
||||||
|
- `res_id` — **ResourceSlot**(特例:目标物料可能尚不存在,直接填期望路径)
|
||||||
|
- `device_id` — **DeviceSlot**,填路径字符串如 `"/host_node"`
|
||||||
|
- `parent` — **NodeSlot**,填路径字符串如 `"/workstation/deck"`
|
||||||
|
- `class_name` — **ClassSlot**,填类名如 `"container"`
|
||||||
|
|
||||||
|
### `auto-test_resource`
|
||||||
|
|
||||||
|
测试资源系统,返回当前资源树和设备列表
|
||||||
|
|
||||||
|
- **action_type**: `UniLabJsonCommand`
|
||||||
|
- **Schema**: [`actions/test_resource.json`](actions/test_resource.json)
|
||||||
|
- **可选参数**: `resource`, `resources`, `device`, `devices`
|
||||||
|
- **占位符字段**:
|
||||||
|
- `resource` — **ResourceSlot**,单个物料节点 `{id, name, uuid}`
|
||||||
|
- `resources` — **ResourceSlot**,物料节点数组 `[{id, name, uuid}, ...]`
|
||||||
|
- `device` — **DeviceSlot**,设备路径字符串
|
||||||
|
- `devices` — **DeviceSlot**,设备路径字符串
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 系统工具
|
||||||
|
|
||||||
|
### `test_latency`
|
||||||
|
|
||||||
|
测试设备通信延迟,返回 RTT、时间差、任务延迟等指标
|
||||||
|
|
||||||
|
- **action_type**: `UniLabJsonCommand`
|
||||||
|
- **Schema**: [`actions/test_latency.json`](actions/test_latency.json)
|
||||||
|
- **参数**: 无(零参数调用)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 人工确认
|
||||||
|
|
||||||
|
### `manual_confirm`
|
||||||
|
|
||||||
|
创建人工确认节点,等待用户手动确认后继续
|
||||||
|
|
||||||
|
- **action_type**: `UniLabJsonCommand`
|
||||||
|
- **Schema**: [`actions/manual_confirm.json`](actions/manual_confirm.json)
|
||||||
|
- **核心参数**: `timeout_seconds`(超时时间,秒), `assignee_user_ids`(指派用户 ID 列表)
|
||||||
|
- **占位符字段**: `assignee_user_ids` — `unilabos_manual_confirm` 类型
|
||||||
93
.cursor/skills/host-node/actions/create_resource.json
Normal file
93
.cursor/skills/host-node/actions/create_resource.json
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
{
|
||||||
|
"type": "ResourceCreateFromOuterEasy",
|
||||||
|
"goal": {
|
||||||
|
"res_id": "res_id",
|
||||||
|
"class_name": "class_name",
|
||||||
|
"parent": "parent",
|
||||||
|
"device_id": "device_id",
|
||||||
|
"bind_locations": "bind_locations",
|
||||||
|
"liquid_input_slot": "liquid_input_slot[]",
|
||||||
|
"liquid_type": "liquid_type[]",
|
||||||
|
"liquid_volume": "liquid_volume[]",
|
||||||
|
"slot_on_deck": "slot_on_deck"
|
||||||
|
},
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"res_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"device_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"class_name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"parent": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"bind_locations": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"x": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"y": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"z": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"z"
|
||||||
|
],
|
||||||
|
"title": "bind_locations",
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"liquid_input_slot": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"liquid_type": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"liquid_volume": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"slot_on_deck": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [],
|
||||||
|
"_unilabos_placeholder_info": {
|
||||||
|
"res_id": "unilabos_resources",
|
||||||
|
"device_id": "unilabos_devices",
|
||||||
|
"parent": "unilabos_nodes",
|
||||||
|
"class_name": "unilabos_class"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"goal_default": {},
|
||||||
|
"placeholder_keys": {
|
||||||
|
"res_id": "unilabos_resources",
|
||||||
|
"device_id": "unilabos_devices",
|
||||||
|
"parent": "unilabos_nodes",
|
||||||
|
"class_name": "unilabos_class"
|
||||||
|
}
|
||||||
|
}
|
||||||
32
.cursor/skills/host-node/actions/manual_confirm.json
Normal file
32
.cursor/skills/host-node/actions/manual_confirm.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"type": "UniLabJsonCommand",
|
||||||
|
"goal": {
|
||||||
|
"timeout_seconds": "timeout_seconds",
|
||||||
|
"assignee_user_ids": "assignee_user_ids"
|
||||||
|
},
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"timeout_seconds": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"assignee_user_ids": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"timeout_seconds",
|
||||||
|
"assignee_user_ids"
|
||||||
|
],
|
||||||
|
"_unilabos_placeholder_info": {
|
||||||
|
"assignee_user_ids": "unilabos_manual_confirm"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"goal_default": {},
|
||||||
|
"placeholder_keys": {
|
||||||
|
"assignee_user_ids": "unilabos_manual_confirm"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
.cursor/skills/host-node/actions/test_latency.json
Normal file
11
.cursor/skills/host-node/actions/test_latency.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"type": "UniLabJsonCommand",
|
||||||
|
"goal": {},
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
"required": []
|
||||||
|
},
|
||||||
|
"goal_default": {},
|
||||||
|
"placeholder_keys": {}
|
||||||
|
}
|
||||||
255
.cursor/skills/host-node/actions/test_resource.json
Normal file
255
.cursor/skills/host-node/actions/test_resource.json
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
{
|
||||||
|
"type": "UniLabJsonCommand",
|
||||||
|
"goal": {
|
||||||
|
"resource": "resource",
|
||||||
|
"resources": "resources",
|
||||||
|
"device": "device",
|
||||||
|
"devices": "devices"
|
||||||
|
},
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"resource": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sample_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"children": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parent": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"pose": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"position": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"x": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"y": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"z": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"z"
|
||||||
|
],
|
||||||
|
"title": "position",
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"orientation": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"x": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"y": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"z": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"w": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"z",
|
||||||
|
"w"
|
||||||
|
],
|
||||||
|
"title": "orientation",
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"position",
|
||||||
|
"orientation"
|
||||||
|
],
|
||||||
|
"title": "pose",
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "resource"
|
||||||
|
},
|
||||||
|
"resources": {
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sample_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"children": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parent": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"pose": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"position": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"x": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"y": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"z": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"z"
|
||||||
|
],
|
||||||
|
"title": "position",
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"orientation": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"x": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"y": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"z": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"w": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"z",
|
||||||
|
"w"
|
||||||
|
],
|
||||||
|
"title": "orientation",
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"position",
|
||||||
|
"orientation"
|
||||||
|
],
|
||||||
|
"title": "pose",
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "resources"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"device": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "device reference"
|
||||||
|
},
|
||||||
|
"devices": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "device reference"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [],
|
||||||
|
"_unilabos_placeholder_info": {
|
||||||
|
"resource": "unilabos_resources",
|
||||||
|
"resources": "unilabos_resources",
|
||||||
|
"device": "unilabos_devices",
|
||||||
|
"devices": "unilabos_devices"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"goal_default": {},
|
||||||
|
"placeholder_keys": {
|
||||||
|
"resource": "unilabos_resources",
|
||||||
|
"resources": "unilabos_resources",
|
||||||
|
"device": "unilabos_devices",
|
||||||
|
"devices": "unilabos_devices"
|
||||||
|
}
|
||||||
|
}
|
||||||
272
.cursor/skills/virtual-workbench/SKILL.md
Normal file
272
.cursor/skills/virtual-workbench/SKILL.md
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
---
|
||||||
|
name: virtual-workbench
|
||||||
|
description: Operate Virtual Workbench via REST API — prepare materials, move to heating stations, start heating, move to output, transfer resources. Use when the user mentions virtual workbench, virtual_workbench, 虚拟工作台, heating stations, material processing, or workbench operations.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Virtual Workbench API Skill
|
||||||
|
|
||||||
|
## 设备信息
|
||||||
|
|
||||||
|
- **device_id**: `virtual_workbench`
|
||||||
|
- **Python 源码**: `unilabos/devices/virtual/workbench.py`
|
||||||
|
- **设备类**: `VirtualWorkbench`
|
||||||
|
- **动作数**: 6(`auto-prepare_materials`, `auto-move_to_heating_station`, `auto-start_heating`, `auto-move_to_output`, `transfer`, `manual_confirm`)
|
||||||
|
- **设备描述**: 模拟工作台,包含 1 个机械臂(每次操作 2s,独占锁)和 3 个加热台(每次加热 60s,可并行)
|
||||||
|
|
||||||
|
### 典型工作流程
|
||||||
|
|
||||||
|
1. `prepare_materials` — 生成 A1-A5 物料(5 个 output handle)
|
||||||
|
2. `move_to_heating_station` — 物料并发竞争机械臂,移动到空闲加热台
|
||||||
|
3. `start_heating` — 启动加热(3 个加热台可并行)
|
||||||
|
4. `move_to_output` — 加热完成后移到输出位置 Cn
|
||||||
|
|
||||||
|
## 前置条件(缺一不可)
|
||||||
|
|
||||||
|
使用本 skill 前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
|
||||||
|
|
||||||
|
### 1. ak / sk → AUTH
|
||||||
|
|
||||||
|
从启动参数 `--ak` `--sk` 或 config.py 中获取,生成 token:`base64(ak:sk)` → `Authorization: Lab <token>`
|
||||||
|
|
||||||
|
### 2. --addr → BASE URL
|
||||||
|
|
||||||
|
| `--addr` 值 | BASE |
|
||||||
|
| ------------ | ----------------------------------- |
|
||||||
|
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||||
|
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||||
|
| `local` | `http://127.0.0.1:48197` |
|
||||||
|
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||||
|
|
||||||
|
确认后设置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BASE="<根据 addr 确定的 URL>"
|
||||||
|
AUTH="Authorization: Lab <token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**两项全部就绪后才可发起 API 请求。**
|
||||||
|
|
||||||
|
## Session State
|
||||||
|
|
||||||
|
- `lab_uuid` — 实验室 UUID(首次通过 API #1 自动获取,**不需要问用户**)
|
||||||
|
- `device_name` — `virtual_workbench`
|
||||||
|
|
||||||
|
## 请求约定
|
||||||
|
|
||||||
|
所有请求使用 `curl -s`,POST/PATCH/DELETE 需加 `Content-Type: application/json`。
|
||||||
|
|
||||||
|
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### 1. 获取实验室信息(自动获取 lab_uuid)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
||||||
|
```
|
||||||
|
|
||||||
|
返回 `data.uuid` 为 `lab_uuid`,`data.name` 为 `lab_name`。
|
||||||
|
|
||||||
|
### 2. 创建工作流
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$BASE/api/v1/lab/workflow/owner" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"<名称>","lab_uuid":"<lab_uuid>","description":"<描述>"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
返回 `data.uuid` 为 `workflow_uuid`。创建成功后告知用户链接:`$BASE/laboratory/$lab_uuid/workflow/$workflow_uuid`
|
||||||
|
|
||||||
|
### 3. 创建节点
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$BASE/api/v1/edge/workflow/node" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"workflow_uuid":"<workflow_uuid>","resource_template_name":"virtual_workbench","node_template_name":"<action_name>"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
- `resource_template_name` 固定为 `virtual_workbench`
|
||||||
|
- `node_template_name` — action 名称(如 `auto-prepare_materials`, `auto-move_to_heating_station`)
|
||||||
|
|
||||||
|
### 4. 删除节点
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X DELETE "$BASE/api/v1/lab/workflow/nodes" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"node_uuids":["<uuid1>"],"workflow_uuid":"<workflow_uuid>"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 更新节点参数
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X PATCH "$BASE/api/v1/lab/workflow/node" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"workflow_uuid":"<wf_uuid>","uuid":"<node_uuid>","param":{...}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
参考 [action-index.md](action-index.md) 确定哪些字段是 Slot。
|
||||||
|
|
||||||
|
### 6. 查询节点 handles
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$BASE/api/v1/lab/workflow/node-handles" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"node_uuids":["<node_uuid_1>","<node_uuid_2>"]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. 批量创建边
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$BASE/api/v1/lab/workflow/edges" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"edges":[{"source_node_uuid":"<uuid>","target_node_uuid":"<uuid>","source_handle_uuid":"<uuid>","target_handle_uuid":"<uuid>"}]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. 启动工作流
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$BASE/api/v1/lab/workflow/<workflow_uuid>/run" -H "$AUTH"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. 运行设备单动作
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$BASE/api/v1/lab/mcp/run/action" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"lab_uuid":"<lab_uuid>","device_id":"virtual_workbench","action":"<action_name>","action_type":"<type>","param":{...}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
`param` 直接放 goal 里的属性,**不要**再包一层 `{"goal": {...}}`。
|
||||||
|
|
||||||
|
> **WARNING: `action_type` 必须正确,传错会导致任务永远卡住无法完成。** 从下表或 `actions/<name>.json` 的 `type` 字段获取。
|
||||||
|
|
||||||
|
#### action_type 速查表
|
||||||
|
|
||||||
|
| action | action_type |
|
||||||
|
|--------|-------------|
|
||||||
|
| `auto-prepare_materials` | `UniLabJsonCommand` |
|
||||||
|
| `auto-move_to_heating_station` | `UniLabJsonCommand` |
|
||||||
|
| `auto-start_heating` | `UniLabJsonCommand` |
|
||||||
|
| `auto-move_to_output` | `UniLabJsonCommand` |
|
||||||
|
| `transfer` | `UniLabJsonCommandAsync` |
|
||||||
|
| `manual_confirm` | `UniLabJsonCommand` |
|
||||||
|
|
||||||
|
### 10. 查询任务状态
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X GET "$BASE/api/v1/lab/mcp/task/<task_uuid>" -H "$AUTH"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11. 运行工作流单节点
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$BASE/api/v1/lab/mcp/run/workflow/action" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"node_uuid":"<node_uuid>"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12. 获取资源树(物料信息)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
|
||||||
|
```
|
||||||
|
|
||||||
|
注意 `lab_uuid` 在路径中。返回 `data.nodes[]` 含所有节点(设备 + 物料),每个节点含 `name`、`uuid`、`type`、`parent`。
|
||||||
|
|
||||||
|
### 13. 获取工作流模板详情
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH"
|
||||||
|
```
|
||||||
|
|
||||||
|
> 必须使用 `/lab/workflow/template/detail/{uuid}`,其他路径会返回 404。
|
||||||
|
|
||||||
|
### 14. 按名称查询物料模板
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=<template_name>" -H "$AUTH"
|
||||||
|
```
|
||||||
|
|
||||||
|
返回 `data.uuid` 为 `res_template_uuid`,用于 API #15。
|
||||||
|
|
||||||
|
### 15. 创建物料节点
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$BASE/api/v1/edge/material/node" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"res_template_uuid":"<uuid>","name":"<名称>","display_name":"<显示名>","parent_uuid":"<父节点uuid>","data":{...}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 16. 更新物料节点
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X PUT "$BASE/api/v1/edge/material/node" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{"uuid":"<节点uuid>","display_name":"<新名称>","data":{...}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Placeholder Slot 填写规则
|
||||||
|
|
||||||
|
| `placeholder_keys` 值 | Slot 类型 | 填写格式 | 选取范围 |
|
||||||
|
| --------------------- | ------------ | ----------------------------------------------------- | ---------------------- |
|
||||||
|
| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅物料节点(非设备) |
|
||||||
|
| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅设备节点(type=device) |
|
||||||
|
| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | 所有节点(设备 + 物料) |
|
||||||
|
| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已注册的资源类 |
|
||||||
|
|
||||||
|
### virtual_workbench 设备的 Slot 字段表
|
||||||
|
|
||||||
|
| Action | 字段 | Slot 类型 | 说明 |
|
||||||
|
| ----------------- | ---------------- | ------------ | -------------------- |
|
||||||
|
| `transfer` | `resource` | ResourceSlot | 待转移物料数组 |
|
||||||
|
| `transfer` | `target_device` | DeviceSlot | 目标设备路径 |
|
||||||
|
| `transfer` | `mount_resource` | ResourceSlot | 目标孔位数组 |
|
||||||
|
| `manual_confirm` | `resource` | ResourceSlot | 确认用物料数组 |
|
||||||
|
| `manual_confirm` | `target_device` | DeviceSlot | 确认用目标设备 |
|
||||||
|
| `manual_confirm` | `mount_resource` | ResourceSlot | 确认用目标孔位数组 |
|
||||||
|
|
||||||
|
> `prepare_materials`、`move_to_heating_station`、`start_heating`、`move_to_output` 这 4 个动作**无 Slot 字段**,参数为纯数值/整数。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 渐进加载策略
|
||||||
|
|
||||||
|
1. **SKILL.md**(本文件)— API 端点 + session state 管理 + 设备工作流概览
|
||||||
|
2. **[action-index.md](action-index.md)** — 按分类浏览 6 个动作的描述和核心参数
|
||||||
|
3. **[actions/\<name\>.json](actions/)** — 仅在需要构建具体请求时,加载对应 action 的完整 JSON Schema
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 完整工作流 Checklist
|
||||||
|
|
||||||
|
```
|
||||||
|
Task Progress:
|
||||||
|
- [ ] Step 1: GET /edge/lab/info 获取 lab_uuid
|
||||||
|
- [ ] Step 2: 获取资源树 (GET #12) → 记住可用物料
|
||||||
|
- [ ] Step 3: 读 action-index.md 确定要用的 action 名
|
||||||
|
- [ ] Step 4: 创建工作流 (POST #2) → 记住 workflow_uuid,告知用户链接
|
||||||
|
- [ ] Step 5: 创建节点 (POST #3, resource_template_name=virtual_workbench) → 记住 node_uuid + data.param
|
||||||
|
- [ ] Step 6: 根据 _unilabos_placeholder_info 和资源树,填写 data.param 中的 Slot 字段
|
||||||
|
- [ ] Step 7: 更新节点参数 (PATCH #5)
|
||||||
|
- [ ] Step 8: 查询节点 handles (POST #6) → 获取各节点的 handle_uuid
|
||||||
|
- [ ] Step 9: 批量创建边 (POST #7) → 用 handle_uuid 连接节点
|
||||||
|
- [ ] Step 10: 启动工作流 (POST #8) 或运行单节点 (POST #11)
|
||||||
|
- [ ] Step 11: 查询任务状态 (GET #10) 确认完成
|
||||||
|
```
|
||||||
|
|
||||||
|
### 典型 5 物料并发加热工作流示例
|
||||||
|
|
||||||
|
```
|
||||||
|
prepare_materials (count=5)
|
||||||
|
├─ channel_1 → move_to_heating_station (material_number=1) → start_heating → move_to_output
|
||||||
|
├─ channel_2 → move_to_heating_station (material_number=2) → start_heating → move_to_output
|
||||||
|
├─ channel_3 → move_to_heating_station (material_number=3) → start_heating → move_to_output
|
||||||
|
├─ channel_4 → move_to_heating_station (material_number=4) → start_heating → move_to_output
|
||||||
|
└─ channel_5 → move_to_heating_station (material_number=5) → start_heating → move_to_output
|
||||||
|
```
|
||||||
|
|
||||||
|
创建节点时,`prepare_materials` 的 5 个 output handle(`channel_1` ~ `channel_5`)分别连接到 5 个 `move_to_heating_station` 节点的 `material_input` handle。每个 `move_to_heating_station` 的 `heating_station_output` 和 `material_number_output` 连接到对应 `start_heating` 的 `station_id_input` 和 `material_number_input`。
|
||||||
76
.cursor/skills/virtual-workbench/action-index.md
Normal file
76
.cursor/skills/virtual-workbench/action-index.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Action Index — virtual_workbench
|
||||||
|
|
||||||
|
6 个动作,按功能分类。每个动作的完整 JSON Schema 在 `actions/<name>.json`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 物料准备
|
||||||
|
|
||||||
|
### `auto-prepare_materials`
|
||||||
|
|
||||||
|
批量准备物料(虚拟起始节点),生成 A1-A5 物料编号,输出 5 个 handle 供后续节点使用
|
||||||
|
|
||||||
|
- **action_type**: `UniLabJsonCommand`
|
||||||
|
- **Schema**: [`actions/prepare_materials.json`](actions/prepare_materials.json)
|
||||||
|
- **可选参数**: `count`(物料数量,默认 5)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 机械臂 & 加热台操作
|
||||||
|
|
||||||
|
### `auto-move_to_heating_station`
|
||||||
|
|
||||||
|
将物料从 An 位置移动到空闲加热台(竞争机械臂,自动查找空闲加热台)
|
||||||
|
|
||||||
|
- **action_type**: `UniLabJsonCommand`
|
||||||
|
- **Schema**: [`actions/move_to_heating_station.json`](actions/move_to_heating_station.json)
|
||||||
|
- **核心参数**: `material_number`(物料编号,integer)
|
||||||
|
|
||||||
|
### `auto-start_heating`
|
||||||
|
|
||||||
|
启动指定加热台的加热程序(可并行,3 个加热台同时工作)
|
||||||
|
|
||||||
|
- **action_type**: `UniLabJsonCommand`
|
||||||
|
- **Schema**: [`actions/start_heating.json`](actions/start_heating.json)
|
||||||
|
- **核心参数**: `station_id`(加热台 ID),`material_number`(物料编号)
|
||||||
|
|
||||||
|
### `auto-move_to_output`
|
||||||
|
|
||||||
|
将加热完成的物料从加热台移动到输出位置 Cn
|
||||||
|
|
||||||
|
- **action_type**: `UniLabJsonCommand`
|
||||||
|
- **Schema**: [`actions/move_to_output.json`](actions/move_to_output.json)
|
||||||
|
- **核心参数**: `station_id`(加热台 ID),`material_number`(物料编号)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 物料转移
|
||||||
|
|
||||||
|
### `transfer`
|
||||||
|
|
||||||
|
异步转移物料到目标设备(通过 ROS 资源转移)
|
||||||
|
|
||||||
|
- **action_type**: `UniLabJsonCommandAsync`
|
||||||
|
- **Schema**: [`actions/transfer.json`](actions/transfer.json)
|
||||||
|
- **核心参数**: `resource`, `target_device`, `mount_resource`
|
||||||
|
- **占位符字段**:
|
||||||
|
- `resource` — **ResourceSlot**,待转移的物料数组 `[{id, name, uuid}, ...]`
|
||||||
|
- `target_device` — **DeviceSlot**,目标设备路径字符串
|
||||||
|
- `mount_resource` — **ResourceSlot**,目标孔位数组 `[{id, name, uuid}, ...]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 人工确认
|
||||||
|
|
||||||
|
### `manual_confirm`
|
||||||
|
|
||||||
|
创建人工确认节点,等待用户手动确认后继续(含物料转移上下文)
|
||||||
|
|
||||||
|
- **action_type**: `UniLabJsonCommand`
|
||||||
|
- **Schema**: [`actions/manual_confirm.json`](actions/manual_confirm.json)
|
||||||
|
- **核心参数**: `resource`, `target_device`, `mount_resource`, `timeout_seconds`, `assignee_user_ids`
|
||||||
|
- **占位符字段**:
|
||||||
|
- `resource` — **ResourceSlot**,物料数组
|
||||||
|
- `target_device` — **DeviceSlot**,目标设备路径
|
||||||
|
- `mount_resource` — **ResourceSlot**,目标孔位数组
|
||||||
|
- `assignee_user_ids` — `unilabos_manual_confirm` 类型
|
||||||
270
.cursor/skills/virtual-workbench/actions/manual_confirm.json
Normal file
270
.cursor/skills/virtual-workbench/actions/manual_confirm.json
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
{
|
||||||
|
"type": "UniLabJsonCommand",
|
||||||
|
"goal": {
|
||||||
|
"resource": "resource",
|
||||||
|
"target_device": "target_device",
|
||||||
|
"mount_resource": "mount_resource",
|
||||||
|
"timeout_seconds": "timeout_seconds",
|
||||||
|
"assignee_user_ids": "assignee_user_ids"
|
||||||
|
},
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"resource": {
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sample_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"children": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parent": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"pose": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"position": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"x": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"y": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"z": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"z"
|
||||||
|
],
|
||||||
|
"title": "position",
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"orientation": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"x": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"y": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"z": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"w": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"z",
|
||||||
|
"w"
|
||||||
|
],
|
||||||
|
"title": "orientation",
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"position",
|
||||||
|
"orientation"
|
||||||
|
],
|
||||||
|
"title": "pose",
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "resource"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"target_device": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "device reference"
|
||||||
|
},
|
||||||
|
"mount_resource": {
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sample_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"children": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parent": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"pose": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"position": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"x": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"y": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"z": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"z"
|
||||||
|
],
|
||||||
|
"title": "position",
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"orientation": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"x": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"y": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"z": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"w": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"z",
|
||||||
|
"w"
|
||||||
|
],
|
||||||
|
"title": "orientation",
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"position",
|
||||||
|
"orientation"
|
||||||
|
],
|
||||||
|
"title": "pose",
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "mount_resource"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"timeout_seconds": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"assignee_user_ids": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"resource",
|
||||||
|
"target_device",
|
||||||
|
"mount_resource",
|
||||||
|
"timeout_seconds",
|
||||||
|
"assignee_user_ids"
|
||||||
|
],
|
||||||
|
"_unilabos_placeholder_info": {
|
||||||
|
"resource": "unilabos_resources",
|
||||||
|
"target_device": "unilabos_devices",
|
||||||
|
"mount_resource": "unilabos_resources",
|
||||||
|
"assignee_user_ids": "unilabos_manual_confirm"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"goal_default": {},
|
||||||
|
"placeholder_keys": {
|
||||||
|
"resource": "unilabos_resources",
|
||||||
|
"target_device": "unilabos_devices",
|
||||||
|
"mount_resource": "unilabos_resources",
|
||||||
|
"assignee_user_ids": "unilabos_manual_confirm"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"type": "UniLabJsonCommand",
|
||||||
|
"goal": {
|
||||||
|
"material_number": "material_number"
|
||||||
|
},
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"material_number": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"material_number"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"goal_default": {},
|
||||||
|
"placeholder_keys": {}
|
||||||
|
}
|
||||||
24
.cursor/skills/virtual-workbench/actions/move_to_output.json
Normal file
24
.cursor/skills/virtual-workbench/actions/move_to_output.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"type": "UniLabJsonCommand",
|
||||||
|
"goal": {
|
||||||
|
"station_id": "station_id",
|
||||||
|
"material_number": "material_number"
|
||||||
|
},
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"station_id": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"material_number": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"station_id",
|
||||||
|
"material_number"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"goal_default": {},
|
||||||
|
"placeholder_keys": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"type": "UniLabJsonCommand",
|
||||||
|
"goal": {
|
||||||
|
"count": "count"
|
||||||
|
},
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"count": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": []
|
||||||
|
},
|
||||||
|
"goal_default": {
|
||||||
|
"count": 5
|
||||||
|
},
|
||||||
|
"placeholder_keys": {}
|
||||||
|
}
|
||||||
24
.cursor/skills/virtual-workbench/actions/start_heating.json
Normal file
24
.cursor/skills/virtual-workbench/actions/start_heating.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"type": "UniLabJsonCommand",
|
||||||
|
"goal": {
|
||||||
|
"station_id": "station_id",
|
||||||
|
"material_number": "material_number"
|
||||||
|
},
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"station_id": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"material_number": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"station_id",
|
||||||
|
"material_number"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"goal_default": {},
|
||||||
|
"placeholder_keys": {}
|
||||||
|
}
|
||||||
255
.cursor/skills/virtual-workbench/actions/transfer.json
Normal file
255
.cursor/skills/virtual-workbench/actions/transfer.json
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
{
|
||||||
|
"type": "UniLabJsonCommandAsync",
|
||||||
|
"goal": {
|
||||||
|
"resource": "resource",
|
||||||
|
"target_device": "target_device",
|
||||||
|
"mount_resource": "mount_resource"
|
||||||
|
},
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"resource": {
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sample_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"children": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parent": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"pose": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"position": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"x": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"y": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"z": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"z"
|
||||||
|
],
|
||||||
|
"title": "position",
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"orientation": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"x": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"y": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"z": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"w": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"z",
|
||||||
|
"w"
|
||||||
|
],
|
||||||
|
"title": "orientation",
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"position",
|
||||||
|
"orientation"
|
||||||
|
],
|
||||||
|
"title": "pose",
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "resource"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"target_device": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "device reference"
|
||||||
|
},
|
||||||
|
"mount_resource": {
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sample_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"children": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parent": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"pose": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"position": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"x": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"y": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"z": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"z"
|
||||||
|
],
|
||||||
|
"title": "position",
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"orientation": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"x": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"y": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"z": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
},
|
||||||
|
"w": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": -1.7976931348623157e+308,
|
||||||
|
"maximum": 1.7976931348623157e+308
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"z",
|
||||||
|
"w"
|
||||||
|
],
|
||||||
|
"title": "orientation",
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"position",
|
||||||
|
"orientation"
|
||||||
|
],
|
||||||
|
"title": "pose",
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "mount_resource"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"resource",
|
||||||
|
"target_device",
|
||||||
|
"mount_resource"
|
||||||
|
],
|
||||||
|
"_unilabos_placeholder_info": {
|
||||||
|
"resource": "unilabos_resources",
|
||||||
|
"target_device": "unilabos_devices",
|
||||||
|
"mount_resource": "unilabos_resources"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"goal_default": {},
|
||||||
|
"placeholder_keys": {
|
||||||
|
"resource": "unilabos_resources",
|
||||||
|
"target_device": "unilabos_devices",
|
||||||
|
"mount_resource": "unilabos_resources"
|
||||||
|
}
|
||||||
|
}
|
||||||
483
.cursor/skills/yibin-electrolyte-submit/SKILL.md
Normal file
483
.cursor/skills/yibin-electrolyte-submit/SKILL.md
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
---
|
||||||
|
name: yibin-electrolyte-submit
|
||||||
|
description: >-
|
||||||
|
通过 Uni-Lab Notebook API 向宜宾电解液工站提交实验,覆盖配液分液(Bioyond LIMS)、
|
||||||
|
扣电组装(CoinCellAssembly)、扣电测试全流程。
|
||||||
|
包含 Excel 解析、formulation 构建、工作流节点参数填写、notebook 提交与状态轮询。
|
||||||
|
Use when the user wants to submit electrolyte experiments, assemble or test coin cells,
|
||||||
|
parse experiment Excel files, build notebook payloads, or mentions
|
||||||
|
宜宾/配液/分液/扣电/电解液实验/notebook提交/CoinCell/BioyondLIMS.
|
||||||
|
---
|
||||||
|
|
||||||
|
# 宜宾电解液产线 API 操作指南
|
||||||
|
|
||||||
|
本 skill 覆盖两个设备的完整操作流程:
|
||||||
|
1. **配液分液工站** (`bioyond_cell_workstation`) — Bioyond LIMS 配液/分液/转运
|
||||||
|
2. **扣电组装站** (`BatteryStation`) — Modbus PLC 扣电组装/数据采集
|
||||||
|
|
||||||
|
## 设备信息
|
||||||
|
|
||||||
|
| 属性 | 配液分液工站 | 扣电组装站 |
|
||||||
|
|------|------------|-----------|
|
||||||
|
| device_id | `bioyond_cell_workstation` | `BatteryStation` |
|
||||||
|
| 显示名 | 配液分液工站 | 扣电工作站 |
|
||||||
|
| 源码 | `unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py` | `unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py` |
|
||||||
|
| 类名 | `BioyondCellWorkstation` | `CoinCellAssemblyWorkstation` |
|
||||||
|
| 通讯 | HTTP REST (Bioyond LIMS API) | Modbus TCP (PLC 寄存器) |
|
||||||
|
|
||||||
|
## 前置条件
|
||||||
|
|
||||||
|
### 认证信息
|
||||||
|
|
||||||
|
```
|
||||||
|
AUTH="Authorization: Lab OTdlY2FkNmUtZmZmMi00YjhiLThhOWEtNWM5ODAyOTJmOTUxOmU0OGM2YWJkLTA4ZmEtNDFjMy04NzhhLTc4M2FiODlhZjYxMw=="
|
||||||
|
BASE="https://uni-lab.test.bohrium.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
来源:`--ak 97ecad6e-fff2-4b8b-8a9a-5c980292f951 --sk e48c6abd-08fa-41c3-878a-783ab89af613 --addr test`
|
||||||
|
|
||||||
|
### 启动 unilab(云端模式)
|
||||||
|
|
||||||
|
> **重要**:提交实验前必须确保 unilab 正在运行且已连接云端 WebSocket。
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:PYTHONIOENCODING="utf-8"
|
||||||
|
conda activate newunilab2603
|
||||||
|
cd D:\UniLabdev\Uni-Lab-OS\unilabos\devices\workstation
|
||||||
|
unilab -g D:\UniLabdev\Uni-Lab-OS\yibin_electrolyte_config.json --ak 97ecad6e-fff2-4b8b-8a9a-5c980292f951 --sk e48c6abd-08fa-41c3-878a-783ab89af613 --upload_registry --addr test --disable_browser --skip_env_check
|
||||||
|
```
|
||||||
|
|
||||||
|
**启动要点**:
|
||||||
|
1. 必须先激活虚拟环境 `newunilab2603`
|
||||||
|
2. 工作目录切到 `unilabos/devices/workstation`(设备驱动所在目录)
|
||||||
|
3. `--upload_registry` 将 64 个设备 + 142 个资源注册到云端
|
||||||
|
4. `--skip_env_check` + `PYTHONIOENCODING=utf-8` 避免 Windows GBK 编码崩溃
|
||||||
|
5. 启动后后台运行,等待日志出现 `Application startup complete` 和 `Host node ready signal published with 3 devices`
|
||||||
|
|
||||||
|
**验证连接成功的标志**:
|
||||||
|
- 日志出现 `[MessageProcessor] ... wss://uni-lab.test.bohrium.com/api/v1/ws/schedule`
|
||||||
|
- 日志出现 `[WebSocketClient] Host node ready signal published with 3 devices`
|
||||||
|
- 日志出现 `Resource tree add completed`(资源树同步完成)
|
||||||
|
|
||||||
|
### 云端物料上架与入库(启动后必做)
|
||||||
|
|
||||||
|
> **在提交实验之前,必须提醒用户完成以下云端操作,否则实验会因物料缺失而失败。**
|
||||||
|
|
||||||
|
1. **拖拽上料**:在云端 UI(`$BASE/laboratory/<lab_uuid>`)的资源树视图中,将物料拖拽到对应的仓库/库位上。unilab 启动后资源树会自动同步到云端,但物料的**上架位置**需要用户在 UI 上手动确认或调整。
|
||||||
|
|
||||||
|
2. **确认配液物料入库**:确保所有配液实验需要的试剂(如 LiPF6、EC、DMC、EMC 等)已在 LIMS 系统中完成入库。可通过以下方式验证:
|
||||||
|
- 云端 UI 资源树中对应仓库(如"粉末加样头堆栈"、"配液站内试剂仓库")下有物料节点
|
||||||
|
- 或通过 API #8 获取资源树后检查物料节点是否存在
|
||||||
|
|
||||||
|
3. **告知 AI 可以提交**:用户完成上述操作后,告知 AI "物料已上架,可以提交实验",AI 再执行 notebook 提交流程。
|
||||||
|
|
||||||
|
**提醒话术模板**(AI 应在启动成功后发送给用户):
|
||||||
|
```
|
||||||
|
unilab 已成功启动并连接云端。提交实验前请完成以下操作:
|
||||||
|
1. 在云端 UI 上确认资源树中的物料位置,必要时拖拽调整上料位
|
||||||
|
2. 确保配液所需的试剂(粉末、液体)已在 LIMS 中完成入库
|
||||||
|
3. 完成后告诉我,我将为您提交实验
|
||||||
|
```
|
||||||
|
|
||||||
|
### 生成 Action Schema(首次使用)
|
||||||
|
|
||||||
|
启动 unilab 后,在 `unilabos_data/` 目录下会生成 `req_device_registry_upload.json`。运行以下命令提取两个设备的 action JSON:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python .cursor/skills/create-device-skill/scripts/extract_device_actions.py --registry unilabos_data/req_device_registry_upload.json bioyond_cell_workstation .cursor/skills/yibin-electrolyte-submit/actions/
|
||||||
|
python .cursor/skills/create-device-skill/scripts/extract_device_actions.py --registry unilabos_data/req_device_registry_upload.json BatteryStation .cursor/skills/yibin-electrolyte-submit/actions/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 请求约定
|
||||||
|
|
||||||
|
- Windows 平台**必须用 `curl.exe`**(非 PowerShell 的 curl 别名)
|
||||||
|
- 所有请求带 `$AUTH` 头
|
||||||
|
- URL 格式:`$BASE/api/v1/<endpoint>`
|
||||||
|
- POST/PATCH 请求体写入临时 JSON 文件后用 `-d '@tmp.json'` 传参(避免 PowerShell 转义问题)
|
||||||
|
- 本地 API 基址:`http://127.0.0.1:8002/api/v1/`
|
||||||
|
|
||||||
|
## Session State
|
||||||
|
|
||||||
|
每次会话开始时,依次获取以下信息:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. lab_uuid
|
||||||
|
curl.exe -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
||||||
|
# → data.uuid → $lab_uuid
|
||||||
|
|
||||||
|
# 2. project_uuid
|
||||||
|
curl.exe -s -X GET "$BASE/api/v1/lab/project/list?lab_uuid=$lab_uuid" -H "$AUTH"
|
||||||
|
# → data.items[].uuid/name → 让用户选择或取唯一项 → $project_uuid
|
||||||
|
```
|
||||||
|
|
||||||
|
## 工作流模板(重要)
|
||||||
|
|
||||||
|
> **必须向用户索要已有的工作流模板 UUID 或 URL,不要自行创建。**
|
||||||
|
>
|
||||||
|
> 原因:通过 `edge/workflow/node` API 创建节点会报 `resource_node_template not found`——
|
||||||
|
> 云端的工作流节点模板系统和设备注册表是独立的,需要用户在云端 UI 上预先配置好工作流模板。
|
||||||
|
|
||||||
|
**获取方式**:
|
||||||
|
- 用户提供工作流页面 URL,如 `$BASE/laboratory/<lab_uuid>/workflow/<workflow_uuid>`
|
||||||
|
- 从 URL 中提取 `workflow_uuid`
|
||||||
|
- 用 API 获取模板详情:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/lab/workflow/template/detail/<workflow_uuid>
|
||||||
|
```
|
||||||
|
|
||||||
|
返回 `data.nodes[]`:每个节点的 uuid、name、param、device_name、handles、disabled。
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
```
|
||||||
|
工作流 URL: https://uni-lab.test.bohrium.com/laboratory/e9ed9102-d709-4741-b7a0-d1e8578e2065/workflow/b49f80d9-58d6-4456-a521-56f4dd39cda0
|
||||||
|
→ workflow_uuid = b49f80d9-58d6-4456-a521-56f4dd39cda0
|
||||||
|
```
|
||||||
|
|
||||||
|
从模板详情中提取**未 disabled** 的节点的 `uuid` 和 `name`,后续提交 notebook 时使用。
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### #1 获取 lab_uuid
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/edge/lab/info
|
||||||
|
```
|
||||||
|
|
||||||
|
### #2 列出项目
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/lab/project/list?lab_uuid=$lab_uuid
|
||||||
|
```
|
||||||
|
|
||||||
|
返回 `data.items[]`,取 `uuid` 和 `name`。
|
||||||
|
|
||||||
|
### #3 获取工作流模板详情
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/lab/workflow/template/detail/<workflow_uuid>
|
||||||
|
```
|
||||||
|
|
||||||
|
返回 `data.nodes[]`:每个节点的 uuid、name、param、device_name、handles。
|
||||||
|
提取活跃节点(`disabled != true`)的 `uuid` 用于构建 notebook 请求。
|
||||||
|
|
||||||
|
### #4 提交实验(创建 notebook)— 核心 API
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/lab/notebook
|
||||||
|
Body: {
|
||||||
|
"lab_uuid": "<lab_uuid>",
|
||||||
|
"project_uuid": "<project_uuid>",
|
||||||
|
"workflow_uuid": "<workflow_uuid>",
|
||||||
|
"name": "<实验名称>",
|
||||||
|
"node_params": [
|
||||||
|
{
|
||||||
|
"sample_uuids": [],
|
||||||
|
"datas": [
|
||||||
|
{
|
||||||
|
"node_uuid": "<模板中的节点UUID>",
|
||||||
|
"param": { <参数键值对> },
|
||||||
|
"sample_params": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键注意事项**:
|
||||||
|
- `node_params` 是数组,每个元素代表一轮实验
|
||||||
|
- `datas` 中每个节点对应模板中的一个活跃节点
|
||||||
|
- `param` 中的字段名**必须使用 Python 函数参数名**,不能用模板中存储的 LIMS 字段名(见下方映射表)
|
||||||
|
|
||||||
|
### #5 查询 notebook 状态
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/lab/notebook/status?uuid=<notebook_uuid>
|
||||||
|
```
|
||||||
|
|
||||||
|
| status | 含义 |
|
||||||
|
|--------|------|
|
||||||
|
| `running` | 执行中 |
|
||||||
|
| `success` | 成功 |
|
||||||
|
| `fail` | 失败 |
|
||||||
|
|
||||||
|
### #6 运行设备单动作(本地 API)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST http://127.0.0.1:8002/api/v1/job/add
|
||||||
|
Body: {
|
||||||
|
"device_id": "<device_id>",
|
||||||
|
"action": "<action_name>",
|
||||||
|
"action_args": { <参数键值对> },
|
||||||
|
"sample_material": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
本地 API 可自动解析 `action_type`,无需手动指定。适用于快速调试或云端未连接时。
|
||||||
|
|
||||||
|
### #7 查询本地任务状态
|
||||||
|
|
||||||
|
```
|
||||||
|
GET http://127.0.0.1:8002/api/v1/job/<job_id>/status
|
||||||
|
```
|
||||||
|
|
||||||
|
| status | 含义 |
|
||||||
|
|--------|------|
|
||||||
|
| 0 | UNKNOWN |
|
||||||
|
| 1 | ACCEPTED |
|
||||||
|
| 2 | EXECUTING |
|
||||||
|
| 4 | SUCCEEDED |
|
||||||
|
| 5 | CANCELED |
|
||||||
|
| 6 | ABORTED |
|
||||||
|
|
||||||
|
### #8 获取资源树
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/lab/material/download/<lab_uuid>
|
||||||
|
```
|
||||||
|
|
||||||
|
返回所有节点(`id`, `name`, `uuid`, `type`, `parent`)。填写 Slot 字段时用此接口筛选节点。
|
||||||
|
|
||||||
|
## Placeholder Slot 填写规则
|
||||||
|
|
||||||
|
action JSON 中 `placeholder_keys` 标记了哪些字段需要填 Slot:
|
||||||
|
|
||||||
|
| placeholder 值 | Slot 类型 | 填写格式 |
|
||||||
|
|---------------|-----------|---------|
|
||||||
|
| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` |
|
||||||
|
| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` 路径字符串 |
|
||||||
|
| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` 路径字符串 |
|
||||||
|
| `unilabos_class` | ClassSlot | `"class_name"` 字符串 |
|
||||||
|
| `unilabos_formulation` | FormulationSlot | `[{well_name, liquids: [{name, volume}]}]` |
|
||||||
|
|
||||||
|
### ResourceSlot 填写
|
||||||
|
|
||||||
|
从 API #8 资源树中筛选**物料**节点:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"id": "/bioyond_cell_workstation/YB_Bioyond_Deck/自动堆栈-左", "name": "自动堆栈-左", "uuid": "3a19debc-..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
数组字段:`[{id, name, uuid}, ...]`
|
||||||
|
特例:`create_resource` 的 `res_id` 允许填不存在的路径。
|
||||||
|
|
||||||
|
### DeviceSlot 填写
|
||||||
|
|
||||||
|
从资源树筛选 `type=device` 的节点,填路径字符串:
|
||||||
|
|
||||||
|
```
|
||||||
|
"/BatteryStation"
|
||||||
|
"/bioyond_cell_workstation"
|
||||||
|
```
|
||||||
|
|
||||||
|
### FormulationSlot 填写
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"sample_uuid": "",
|
||||||
|
"well_name": "YB_PrepBottle_15mL_Carrier_bottle_A1",
|
||||||
|
"liquids": [
|
||||||
|
{ "name": "LiPF6", "mass": 12.5 },
|
||||||
|
{ "name": "EC", "mass": 50.0 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
`well_name` 从资源树中取物料节点的 `name`。
|
||||||
|
|
||||||
|
## 参数名映射(重要的坑)
|
||||||
|
|
||||||
|
> 工作流模板中存储的参数名和 Python 函数实际接受的参数名**不一定相同**。
|
||||||
|
> 提交 notebook 时必须使用 **Python 函数参数名**。
|
||||||
|
|
||||||
|
### `create_orders_formulation` 参数映射
|
||||||
|
|
||||||
|
| 模板中的 param 键 | 实际 Python 参数名 | 说明 |
|
||||||
|
|-------------------|-------------------|------|
|
||||||
|
| `pouch_cell_info` | `pouch_cell_volume` | 软包组装分液体积 (mL) |
|
||||||
|
| `conductivity_info` | `conductivity_volume` | 电导测试分液体积 (mL) |
|
||||||
|
| `load_shedding_info` | `coin_cell_volume` | 扣电组装分液体积 (mL) |
|
||||||
|
| `formulation` | `formulation` | 配方数组(名称一致) |
|
||||||
|
| `batch_id` | `batch_id` | 批次号(名称一致) |
|
||||||
|
| `bottle_type` | `bottle_type` | 配液瓶类型(名称一致) |
|
||||||
|
| `mix_time` | `mix_time` | 混匀时间(秒)(名称一致) |
|
||||||
|
| `conductivity_bottle_count` | `conductivity_bottle_count` | 电导瓶数(名称一致) |
|
||||||
|
|
||||||
|
当从模板中读到 `param` 包含 `pouch_cell_info` 等 LIMS 字段名时,提交 notebook 时要用右列的 Python 函数参数名。否则会报 `TypeError: got an unexpected keyword argument`。
|
||||||
|
|
||||||
|
## 典型工作流
|
||||||
|
|
||||||
|
### 方式一:通过 Notebook API 批量提交(推荐)
|
||||||
|
|
||||||
|
**适用场景**:多组配方的批量实验,云端管理实验记录
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 向用户索要工作流模板 URL(不要自行创建)
|
||||||
|
2. 获取 lab_uuid(API #1)和 project_uuid(API #2)
|
||||||
|
3. 获取工作流模板详情(API #3),提取活跃节点 UUID
|
||||||
|
4. 解析用户提供的 Excel 文件,构建 formulation 数组
|
||||||
|
5. 提交 notebook(API #4)
|
||||||
|
6. 轮询 notebook 状态(API #5)直到完成
|
||||||
|
```
|
||||||
|
|
||||||
|
**Excel 解析规则**:
|
||||||
|
- 全局参数在第一个数据行:`batch_id`、`bottle_type`、`mix_time`、`coin_cell_volume`、`pouch_cell_volume`、`conductivity_volume`、`conductivity_bottle_count`
|
||||||
|
- 配方列从"试剂名1"开始,交替排列:试剂名列 + 质量列(以 `(g)` 结尾)
|
||||||
|
- 每行一个配方,`order_name` = 配方ID列
|
||||||
|
- formulation 中每个配方的 materials 数组只包含 `mass > 0` 的试剂
|
||||||
|
|
||||||
|
**node_params 构建**:所有配方放入同一个 round 的同一个 datas 条目中,因为只有一个节点(`create_orders_formulation`)。
|
||||||
|
|
||||||
|
### 方式二:设备单步操作(本地 API)
|
||||||
|
|
||||||
|
**适用场景**:调试、快速测试
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 确保 unilab 已在本地启动
|
||||||
|
2. 通过 POST http://127.0.0.1:8002/api/v1/job/add 提交任务
|
||||||
|
3. 通过 GET /api/v1/job/<job_id>/status 查询状态
|
||||||
|
```
|
||||||
|
|
||||||
|
### 设备操作流程:配液 → 转运 → 扣电
|
||||||
|
|
||||||
|
```
|
||||||
|
1. [配液站] scheduler_start_and_auto_feeding → 启动调度 + 上料
|
||||||
|
2. [配液站] create_orders_formulation → 创建配液实验(配方输入)
|
||||||
|
3. [配液站] transfer_3_to_2_to_1_auto → 分液瓶板转运到扣电站
|
||||||
|
4. [扣电站] func_pack_device_init_auto_start_combined → 初始化+自动+启动
|
||||||
|
5. [扣电站] func_sendbottle_allpack_multi → 发送瓶数+批量组装
|
||||||
|
```
|
||||||
|
|
||||||
|
## 云端使用心得
|
||||||
|
|
||||||
|
### 环境准备
|
||||||
|
- Windows 必须设置 `$env:PYTHONIOENCODING="utf-8"` 防止编码崩溃
|
||||||
|
- 使用 `--skip_env_check` 跳过依赖检查,加快启动
|
||||||
|
- 工作目录建议在 `unilabos/devices/workstation` 下启动
|
||||||
|
|
||||||
|
### 连接与注册
|
||||||
|
- `--upload_registry` 会自动将设备和资源注册到云端
|
||||||
|
- WebSocket 连接建立后,本地和云端的资源树会自动同步
|
||||||
|
- 注册成功后用户需在云端 UI 完成**物料拖放上架**操作
|
||||||
|
- 如果 unilab 断开重连,资源树会重新同步
|
||||||
|
|
||||||
|
### 工作流模板
|
||||||
|
- **不要自行调用 API 创建工作流或节点**——云端工作流节点模板需要预配置
|
||||||
|
- 始终向用户索要已有的工作流模板 URL
|
||||||
|
- 从 URL 中提取 `workflow_uuid`,通过 API #3 获取详情
|
||||||
|
- 模板中 `disabled: true` 的节点跳过,只处理活跃节点
|
||||||
|
|
||||||
|
### Notebook 实验提交
|
||||||
|
- Notebook 是云端管理实验的标准方式
|
||||||
|
- 一个 notebook 可包含多轮(`node_params` 数组),每轮可包含多组参数
|
||||||
|
- 提交后通过 API #5 轮询状态,LIMS 配液流程通常需要较长时间(8 个配方约 30-60 分钟)
|
||||||
|
- 实验进度可在云端 UI 和本地 unilab 日志中同步查看
|
||||||
|
|
||||||
|
### 常见错误
|
||||||
|
| 错误 | 原因 | 解决 |
|
||||||
|
|------|------|------|
|
||||||
|
| `edge not started error` | unilab 未连接云端 WebSocket | 检查 unilab 是否在运行、重启 |
|
||||||
|
| `resource_node_template not found` | 云端没有该设备的工作流模板 | 向用户索要已有模板,不要自行创建 |
|
||||||
|
| `got an unexpected keyword argument` | 参数名用了模板字段名而非 Python 函数参数名 | 参照上方映射表转换 |
|
||||||
|
| `UnicodeEncodeError: 'gbk'` | Windows 默认编码不支持特殊字符 | 设置 `PYTHONIOENCODING=utf-8` |
|
||||||
|
| `parse parameter error` | 云端 API 字段名错误 | `device_id` (非 `device_name`)、`action` (非 `action_name`)、必须带 `action_type` |
|
||||||
|
|
||||||
|
## 渐进加载策略
|
||||||
|
|
||||||
|
1. 先读本文件了解 API 端点、参数映射和云端注意事项
|
||||||
|
2. 需要具体 action 参数时,读 [action-index.md](action-index.md) 查找 action 名称和核心参数
|
||||||
|
3. 需要完整 schema 时,读 `actions/<action_name>.json`(需先运行提取命令生成)
|
||||||
|
4. 需要理解参数含义时,读设备源码
|
||||||
|
|
||||||
|
## 完整 Notebook 提交 Checklist
|
||||||
|
|
||||||
|
```
|
||||||
|
- [ ] 确认 unilab 已在本地启动并连接云端 WebSocket
|
||||||
|
- [ ] 提醒用户在云端 UI 拖拽上料、确认物料位置
|
||||||
|
- [ ] 提醒用户确认配液所需试剂已在 LIMS 完成入库
|
||||||
|
- [ ] 等待用户确认物料就绪后再继续
|
||||||
|
- [ ] 向用户索要工作流模板 URL → 提取 workflow_uuid
|
||||||
|
- [ ] 获取 lab_uuid(API #1)
|
||||||
|
- [ ] 获取 project_uuid(API #2)
|
||||||
|
- [ ] 获取工作流模板详情(API #3),提取活跃节点 UUID
|
||||||
|
- [ ] 解析用户 Excel 文件 → 构建 formulation + 全局参数
|
||||||
|
- [ ] 注意参数名映射(模板字段名 → Python 函数参数名)
|
||||||
|
- [ ] 提交 notebook(API #4)
|
||||||
|
- [ ] 轮询 notebook 状态(API #5)直到完成
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 真实场景:宜宾产线 Excel 提交提示词模板
|
||||||
|
|
||||||
|
> 以下为已验证可用的标准提示词,适用于配液-分液-扣电全流程。
|
||||||
|
|
||||||
|
### 场景说明
|
||||||
|
|
||||||
|
- unilab 运行在本地 Windows 机器(miniforge 环境),连接云端 WebSocket
|
||||||
|
- AI(Cursor / OpenClaw)在任意设备上,通过云端 API 操作,**不需要本地 127.0.0.1**
|
||||||
|
- 工作流为 5 节点串联:`create_orders_formulation` → `transfer_3_to_2_to_1_auto` → `func_pack_device_init_auto_start_combined` → `func_sendbottle_allpack_multi` → `transfer_1_to_2`
|
||||||
|
|
||||||
|
### 已知固定参数(宜宾产线)
|
||||||
|
|
||||||
|
```
|
||||||
|
BASE = https://uni-lab.test.bohrium.com
|
||||||
|
lab_uuid = e9ed9102-d709-4741-b7a0-d1e8578e2065
|
||||||
|
project = YiBinElectrolyte (bc5224b4-8120-4765-9961-9dfc1802a1f6)
|
||||||
|
workflow = 配液分液formulation全流程 (2bc59938-db79-4415-ac2d-9897ef125f2f)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 工作流节点 UUID(固定,无需重新查询)
|
||||||
|
|
||||||
|
| 顺序 | action | node_uuid |
|
||||||
|
|------|--------|-----------|
|
||||||
|
| Step1 | auto-create_orders_formulation | `ece6744a-81ac-4ae4-8cd1-1c8eeda1dab6` |
|
||||||
|
| Step2 | auto-transfer_3_to_2_to_1_auto | `1c37a8dd-5ba0-413d-81db-94b9c936a171` |
|
||||||
|
| Step3 | auto-func_pack_device_init_auto_start_combined | `97a676a2-d257-4479-9096-073b40300970` |
|
||||||
|
| Step4 | auto-func_sendbottle_allpack_multi | `cf69017a-d29c-4aad-a63b-309d63dac2e9` |
|
||||||
|
| Step5 | auto-transfer_1_to_2 | `80d1c1aa-dbc3-4601-86b7-5c22a992dd9e` |
|
||||||
|
|
||||||
|
### 标准提示词
|
||||||
|
|
||||||
|
```
|
||||||
|
请使用 yibin-electrolyte-submit skill,提交以下实验:
|
||||||
|
|
||||||
|
工作流模板 URL:https://uni-lab.test.bohrium.com/laboratory/e9ed9102-d709-4741-b7a0-d1e8578e2065/workflow/2bc59938-db79-4415-ac2d-9897ef125f2f
|
||||||
|
Excel 文件路径:<粘贴或上传 xlsx 路径>
|
||||||
|
|
||||||
|
注意事项:
|
||||||
|
- lab_uuid、project_uuid、workflow节点UUID均已固定,无需重新查询
|
||||||
|
- 直接解析 Excel → 构建 payload → 提交
|
||||||
|
- mix_time 传标量整数即可(已兼容)
|
||||||
|
- 试剂名以 Excel 为准,注意区分 LiDFOB / LiDOFB 等拼写
|
||||||
|
- csv_export_path 取 Excel 中 csv_export_path 列的值
|
||||||
|
- 提交后告知 notebook UUID,无需自动轮询(实验耗时较长)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Excel 列结构说明(experment_template_0415sim-*.xlsx)
|
||||||
|
|
||||||
|
| 列范围 | 内容 |
|
||||||
|
|--------|------|
|
||||||
|
| C | batch_id |
|
||||||
|
| D | bottle_type |
|
||||||
|
| E-H | coin_cell_volume / conductivity_bottle_count / conductivity_volume / csv_export_path |
|
||||||
|
| I-T | 试剂名+质量 交替排列(最多6对)|
|
||||||
|
| U | mix_time |
|
||||||
|
| V | order_name(每行配方的订单号)|
|
||||||
|
| W | pouch_cell_volume |
|
||||||
|
| X-Y | target_device / target_location(Step2参数)|
|
||||||
|
| AA | material_search_enable(Step3参数)|
|
||||||
|
| AB-AS | 扣电站参数(Step4)|
|
||||||
|
|
||||||
|
### CSV 导出说明
|
||||||
|
|
||||||
|
每次 `create_orders_formulation` 完成后,在 `csv_export_path` 目录下生成:
|
||||||
|
```
|
||||||
|
electrolyte_orders_<YYYYMMDD_HHMMSS>.csv
|
||||||
|
```
|
||||||
|
列:`orderCode, orderName, 配液瓶类型, 配液瓶二维码, 分液瓶类型, 分液瓶二维码, 目标配液质量比, 真实配液质量比, 时间`
|
||||||
|
|
||||||
|
> **注意**:barCode 为 `null` 或 `"nullBarCode123456"` 是正常现象,表示 LIMS 中该物料尚未扫码。配液瓶缺失通常是因为物料未放在手动传递窗(`locationId` 前缀 `3a19deae-2c7a-`)。
|
||||||
295
.cursor/skills/yibin-electrolyte-submit/action-index.md
Normal file
295
.cursor/skills/yibin-electrolyte-submit/action-index.md
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
# Action 索引
|
||||||
|
|
||||||
|
> Action JSON 文件需运行提取命令生成,详见 [SKILL.md](SKILL.md) 中「生成 Action Schema」。
|
||||||
|
> 以下描述和参数信息基于源码分析。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 配液分液工站 (`bioyond_cell_workstation`)
|
||||||
|
|
||||||
|
源码:`unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py`
|
||||||
|
|
||||||
|
### 调度控制
|
||||||
|
|
||||||
|
#### `scheduler_start`
|
||||||
|
|
||||||
|
启动 Bioyond LIMS 调度系统
|
||||||
|
|
||||||
|
- **核心参数**: 无(仅需 apiKey/requestTime,由设备内部处理)
|
||||||
|
- **返回**: LIMS 响应 `{code, message, data}`
|
||||||
|
|
||||||
|
#### `scheduler_stop`
|
||||||
|
|
||||||
|
停止调度
|
||||||
|
|
||||||
|
- **核心参数**: 无
|
||||||
|
|
||||||
|
#### `scheduler_continue`
|
||||||
|
|
||||||
|
继续调度(从暂停状态恢复)
|
||||||
|
|
||||||
|
- **核心参数**: 无
|
||||||
|
|
||||||
|
#### `scheduler_reset`
|
||||||
|
|
||||||
|
复位调度
|
||||||
|
|
||||||
|
- **核心参数**: 无
|
||||||
|
|
||||||
|
#### `scheduler_start_and_auto_feeding`
|
||||||
|
|
||||||
|
**组合操作**:启动调度 + 自动化上料(4号→3号手套箱)
|
||||||
|
|
||||||
|
- **核心参数**: `xlsx_path`(Excel 物料模板路径,可选)
|
||||||
|
- **可选参数**: WH4 加样头面 12 个点位(materialName + quantity)、WH4 原液瓶面 9 个点位(materialName + quantity + materialType + targetWH)、WH3 人工堆栈 15 个点位(materialType + materialId + quantity)
|
||||||
|
- **流程**: 先 `scheduler_start()`,成功后执行 `auto_feeding4to3()`
|
||||||
|
- **备注**: 支持 Excel 模式和手动参数模式,Excel 路径存在时优先使用 Excel
|
||||||
|
|
||||||
|
### 物料上料/下料
|
||||||
|
|
||||||
|
#### `auto_feeding4to3`
|
||||||
|
|
||||||
|
自动化上料:从 4 号手套箱转运物料到 3 号手套箱
|
||||||
|
|
||||||
|
- **核心参数**: `xlsx_path`(Excel 物料模板路径)
|
||||||
|
- **可选参数**: 同 `scheduler_start_and_auto_feeding` 的 WH4/WH3 点位参数
|
||||||
|
- **返回**: 等待上料任务完成后返回结果
|
||||||
|
|
||||||
|
#### `auto_batch_outbound_from_xlsx`
|
||||||
|
|
||||||
|
自动化下料(从 Excel 读取下料信息)
|
||||||
|
|
||||||
|
- **核心参数**: `xlsx_path`(Excel 下料模板)
|
||||||
|
- **Excel 列**: locationId, warehouseId, 数量, x, y, z
|
||||||
|
|
||||||
|
### 物料管理
|
||||||
|
|
||||||
|
#### `create_and_inbound_materials`
|
||||||
|
|
||||||
|
批量创建固体物料并入库
|
||||||
|
|
||||||
|
- **核心参数**: `material_names`(物料名称列表,默认 `["LiPF6", "LiDFOB", "DTD", "LiFSI", "LiPO2F2"]`)
|
||||||
|
- **可选参数**: `type_id`(物料类型ID), `warehouse_name`(目标仓库,默认 "粉末加样头堆栈")
|
||||||
|
- **流程**: 创建物料 → 批量入库 → 同步
|
||||||
|
|
||||||
|
#### `create_material`
|
||||||
|
|
||||||
|
创建单个物料并可选入库
|
||||||
|
|
||||||
|
- **核心参数**: `material_name`, `type_id`, `warehouse_name`
|
||||||
|
- **可选参数**: `location_name_or_id`(库位编号如 "A01" 或 UUID)
|
||||||
|
|
||||||
|
#### `create_sample`
|
||||||
|
|
||||||
|
创建配液板物料(含子瓶)并入库
|
||||||
|
|
||||||
|
- **核心参数**: `name`, `board_type`(如 "5ml分液瓶板"), `bottle_type`(如 "5ml分液瓶"), `location_code`(如 "A01")
|
||||||
|
- **可选参数**: `warehouse_name`(默认 "手动堆栈")
|
||||||
|
- **备注**: 自动创建 2x4=8 个子瓶
|
||||||
|
|
||||||
|
#### `storage_inbound`
|
||||||
|
|
||||||
|
单个物料入库
|
||||||
|
|
||||||
|
- **核心参数**: `material_id`, `location_id`
|
||||||
|
|
||||||
|
#### `storage_batch_inbound`
|
||||||
|
|
||||||
|
批量物料入库
|
||||||
|
|
||||||
|
- **核心参数**: `items`(`[{materialId, locationId}, ...]`)
|
||||||
|
|
||||||
|
### 配液实验
|
||||||
|
|
||||||
|
#### `create_orders`
|
||||||
|
|
||||||
|
从 Excel 文件创建配液实验订单
|
||||||
|
|
||||||
|
- **核心参数**: `xlsx_path`(Excel 文件路径)
|
||||||
|
- **Excel 列**: 配方ID, 创建日期, 配液瓶类型, 混匀时间(s), 扣电组装分液体积, 软包组装分液体积, 电导测试分液体积, 电导测试分液瓶数, 以及所有以 `(g)` 结尾的物料列
|
||||||
|
- **流程**: 解析 Excel → 提交订单 → 等待全部完成 → 计算质量比 → 提取分液瓶板 → 创建资源树对象
|
||||||
|
- **返回**: `{status, total_orders, bottle_count, reports, mass_ratios, vial_plates}`
|
||||||
|
|
||||||
|
#### `create_orders_formulation`
|
||||||
|
|
||||||
|
从配方列表创建配液实验订单(前端/API 输入版本)
|
||||||
|
|
||||||
|
- **核心参数**: `formulation`(配方数组)
|
||||||
|
- **可选参数**: `batch_id`, `bottle_type`(默认 "配液小瓶"), `mix_time`(秒,列表), `coin_cell_volume`, `pouch_cell_volume`, `conductivity_volume`, `conductivity_bottle_count`
|
||||||
|
- **formulation 格式**:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"order_name": "配方A",
|
||||||
|
"materials": [
|
||||||
|
{"name": "LiPF6", "mass": 12.5},
|
||||||
|
{"name": "EC", "mass": 50.0},
|
||||||
|
{"name": "DMC", "mass": 37.5}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
- **返回**: 同 `create_orders`
|
||||||
|
|
||||||
|
### 物料转运
|
||||||
|
|
||||||
|
#### `transfer_3_to_2_to_1_auto`
|
||||||
|
|
||||||
|
**自动转运**:从 create_orders 结果中自动定位分液瓶板并转运到目标设备
|
||||||
|
|
||||||
|
- **核心参数**: `vial_plates`(分液瓶板列表,来自 create_orders 返回的 `vial_plates`)
|
||||||
|
- **可选参数**: `target_device`(默认 "BatteryStation"), `target_location`(默认 "bottle_rack_6x2"), `mass_ratios`(配方信息)
|
||||||
|
- **流程**: 遍历瓶板 → 解析 locationId → 调用 LIMS 转运 API → 更新资源树
|
||||||
|
- **返回**: `{total, success, failed, results}`
|
||||||
|
|
||||||
|
#### `transfer_3_to_2_to_1`
|
||||||
|
|
||||||
|
3→2→1 物料转运(手动指定坐标)
|
||||||
|
|
||||||
|
- **核心参数**: `source_wh_id`, `source_x`, `source_y`, `source_z`
|
||||||
|
|
||||||
|
#### `transfer_3_to_2`
|
||||||
|
|
||||||
|
3→2 物料转运
|
||||||
|
|
||||||
|
- **核心参数**: `source_wh_id`, `source_x`, `source_y`, `source_z`
|
||||||
|
|
||||||
|
#### `transfer_1_to_2`
|
||||||
|
|
||||||
|
1→2 物料转运
|
||||||
|
|
||||||
|
- **核心参数**: 无
|
||||||
|
|
||||||
|
### 查询
|
||||||
|
|
||||||
|
#### `order_list_v2`
|
||||||
|
|
||||||
|
批量查询实验报告
|
||||||
|
|
||||||
|
- **可选参数**: `timeType`, `beginTime`, `endTime`, `status`(60=运行中, 80=完成, 90=失败), `filter`, `skipCount`, `pageCount`, `sorting`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 扣电组装站 (`BatteryStation`)
|
||||||
|
|
||||||
|
源码:`unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py`
|
||||||
|
|
||||||
|
### 设备控制(组合操作)
|
||||||
|
|
||||||
|
#### `func_pack_device_init_auto_start_combined`
|
||||||
|
|
||||||
|
**组合操作**:设备初始化 → 物料搜寻确认 → 切换自动模式 → 启动
|
||||||
|
|
||||||
|
- **核心参数**: `material_search_enable`(是否启用物料搜寻,默认 `False`)
|
||||||
|
- **前置检查**: REG_UNILAB_INTERACT=False, COIL_GB_L_IGNORE_CMD=False, 所有握手寄存器无残留
|
||||||
|
- **流程**: 手动模式 → 初始化命令 → 监测物料搜寻弹窗并自动处理 → 自动模式 → 启动
|
||||||
|
- **返回**: `True`/`False`
|
||||||
|
- **备注**: 第一次运行必须调用此函数;后续批次调用 `func_sendbottle_allpack_multi`
|
||||||
|
|
||||||
|
### 批量组装
|
||||||
|
|
||||||
|
#### `func_sendbottle_allpack_multi`
|
||||||
|
|
||||||
|
**发送瓶数 + 批量组装**(适用于第二批次及后续批次)
|
||||||
|
|
||||||
|
- **核心参数**: `elec_num`(电解液瓶数), `elec_use_num`(每瓶组装电池数), `elec_vol`(电解液吸液量 μL,默认 50)
|
||||||
|
- **可选参数**:
|
||||||
|
- 双滴模式:`dual_drop_mode`(bool), `dual_drop_first_volume`(μL), `dual_drop_suction_timing`(bool), `dual_drop_start_timing`(bool)
|
||||||
|
- 组装参数:`assembly_type`(7=不用铝箔垫/8=用), `assembly_pressure`(N,默认 4200)
|
||||||
|
- 物料参数:`fujipian_panshu`, `fujipian_juzhendianwei`, `gemopanshu`, `gemo_juzhendianwei`, `qiangtou_juzhendianwei`
|
||||||
|
- 开关:`lvbodian`(铝箔垫片), `battery_pressure_mode`(压力模式), `battery_clean_ignore`(忽略清洁)
|
||||||
|
- 其他:`file_path`(CSV保存路径), `formulations`(配方信息,用于CSV追溯)
|
||||||
|
- **流程**: 发送瓶数触发物料搬运 → 设置PLC参数 → 循环(等待PLC请求→下发参数→读取电池数据→写入CSV→更新资源树)→ 完成握手
|
||||||
|
- **返回**: `{success, total_batteries, batteries, summary}`
|
||||||
|
- **备注**: 设备已初始化后直接调用;`formulations` 来自 create_orders 的 `mass_ratios`
|
||||||
|
|
||||||
|
#### `func_allpack_cmd`
|
||||||
|
|
||||||
|
全套组装(基础版本,含断点续传)
|
||||||
|
|
||||||
|
- **核心参数**: `elec_num`, `elec_use_num`, `elec_vol`, `assembly_type`, `assembly_pressure`, `file_path`
|
||||||
|
- **返回**: `{success, total_batteries, batteries, summary}`
|
||||||
|
|
||||||
|
#### `func_allpack_cmd_simp`
|
||||||
|
|
||||||
|
增强版组装(含双滴模式 + 负极片/隔膜/枪头参数)
|
||||||
|
|
||||||
|
- **核心参数**: 同 `func_sendbottle_allpack_multi`
|
||||||
|
- **备注**: 被 `func_sendbottle_allpack_multi` 内部调用
|
||||||
|
|
||||||
|
### 设备控制(单步操作)
|
||||||
|
|
||||||
|
#### `func_pack_device_init`
|
||||||
|
|
||||||
|
设备初始化(手动模式 → 初始化 → 复位标志)
|
||||||
|
|
||||||
|
#### `func_pack_device_auto`
|
||||||
|
|
||||||
|
切换自动模式
|
||||||
|
|
||||||
|
#### `func_pack_device_start`
|
||||||
|
|
||||||
|
启动设备
|
||||||
|
|
||||||
|
#### `func_pack_device_stop`
|
||||||
|
|
||||||
|
设备停止
|
||||||
|
|
||||||
|
#### `func_pack_send_bottle_num`
|
||||||
|
|
||||||
|
发送电解液瓶数(触发物料搬运)
|
||||||
|
|
||||||
|
- **核心参数**: `bottle_num`(瓶数)
|
||||||
|
|
||||||
|
### PLC 参数设置
|
||||||
|
|
||||||
|
#### `qiming_coin_cell_code`
|
||||||
|
|
||||||
|
设置组装物料参数
|
||||||
|
|
||||||
|
- **核心参数**: `fujipian_panshu`(负极片盘数)
|
||||||
|
- **可选参数**: `fujipian_juzhendianwei`, `gemopanshu`, `gemo_juzhendianwei`, `lvbodian`, `battery_pressure_mode`, `battery_pressure`, `battery_clean_ignore`
|
||||||
|
|
||||||
|
### 数据采集
|
||||||
|
|
||||||
|
#### `func_read_data_and_output`
|
||||||
|
|
||||||
|
持续数据采集并导出 CSV(后台循环运行)
|
||||||
|
|
||||||
|
- **核心参数**: `file_path`(CSV 保存目录)
|
||||||
|
- **采集字段**: 开路电压, 极片质量, 组装时间, 压制力, 电解液加注量, 电池类型, 电解液二维码, 电池二维码
|
||||||
|
|
||||||
|
#### `func_stop_read_data`
|
||||||
|
|
||||||
|
停止 CSV 数据采集
|
||||||
|
|
||||||
|
### 设备状态属性(只读)
|
||||||
|
|
||||||
|
| 属性 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `sys_status` | str | 设备状态(启动中/停止中/复位中/初始化中) |
|
||||||
|
| `sys_mode` | str | 设备模式(手动/自动) |
|
||||||
|
| `data_assembly_coin_cell_num` | int | 已完成电池数量 |
|
||||||
|
| `data_assembly_time` | float | 单颗电池组装时间(秒) |
|
||||||
|
| `data_open_circuit_voltage` | float | 开路电压(V) |
|
||||||
|
| `data_pole_weight` | float | 正极片称重(g) |
|
||||||
|
| `data_glove_box_pressure` | float | 手套箱压力(mbar) |
|
||||||
|
| `data_glove_box_o2_content` | float | 手套箱氧含量(ppm) |
|
||||||
|
| `data_glove_box_water_content` | float | 手套箱水含量(ppm) |
|
||||||
|
| `data_coin_cell_code` | str | 电池二维码 |
|
||||||
|
| `data_electrolyte_code` | str | 电解液二维码 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 配置参考
|
||||||
|
|
||||||
|
设备图文件 `yibin_electrolyte_config.json` 中的仓库映射(`warehouse_mapping`):
|
||||||
|
|
||||||
|
| 仓库名称 | 说明 | 典型操作 |
|
||||||
|
|---------|------|---------|
|
||||||
|
| 粉末加样头堆栈 | 20 个点位 (A01-T01) | `create_and_inbound_materials` 入库目标 |
|
||||||
|
| 配液站内试剂仓库 | 9 个点位 (A01-C03) | 试剂存储 |
|
||||||
|
| 自动堆栈-左 | 4 个点位 | 分液瓶板存放,`transfer_3_to_2_to_1_auto` 的源位置 |
|
||||||
|
| 自动堆栈-右 | 4 个点位 | 分液瓶板存放 |
|
||||||
|
| 手动传递窗左/右 | 各 15 个点位 | 人工上料/下料 |
|
||||||
|
| 4号手套箱内部堆栈 | 12 个点位 | `auto_feeding4to3` 的源位置 |
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
.conda
|
|
||||||
# .github
|
|
||||||
.idea
|
|
||||||
# .vscode
|
|
||||||
output
|
|
||||||
pylabrobot_repo
|
|
||||||
recipes
|
|
||||||
scripts
|
|
||||||
service
|
|
||||||
temp
|
|
||||||
# unilabos/test
|
|
||||||
# unilabos/app/web
|
|
||||||
unilabos/device_mesh
|
|
||||||
unilabos_data
|
|
||||||
unilabos_msgs
|
|
||||||
unilabos.egg-info
|
|
||||||
CONTRIBUTORS
|
|
||||||
# LICENSE
|
|
||||||
MANIFEST.in
|
|
||||||
pyrightconfig.json
|
|
||||||
# README.md
|
|
||||||
# README_zh.md
|
|
||||||
setup.py
|
|
||||||
setup.cfg
|
|
||||||
.gitattrubutes
|
|
||||||
**/__pycache__
|
|
||||||
19
.github/dependabot.yml
vendored
Normal file
19
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
# GitHub Actions
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
target-branch: "dev"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
day: "monday"
|
||||||
|
time: "06:00"
|
||||||
|
open-pull-requests-limit: 5
|
||||||
|
reviewers:
|
||||||
|
- "msgcenterpy-team"
|
||||||
|
labels:
|
||||||
|
- "dependencies"
|
||||||
|
- "github-actions"
|
||||||
|
commit-message:
|
||||||
|
prefix: "ci"
|
||||||
|
include: "scope"
|
||||||
2
.github/workflows/ci-check.yml
vendored
2
.github/workflows/ci-check.yml
vendored
@@ -38,7 +38,7 @@ jobs:
|
|||||||
- name: Install ROS dependencies, uv and unilabos-msgs
|
- name: Install ROS dependencies, uv and unilabos-msgs
|
||||||
run: |
|
run: |
|
||||||
echo Installing ROS dependencies...
|
echo Installing ROS dependencies...
|
||||||
mamba install -n check-env --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
|
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
|
||||||
|
|
||||||
- name: Install pip dependencies and unilabos
|
- name: Install pip dependencies and unilabos
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
77
.github/workflows/conda-pack-build.yml
vendored
77
.github/workflows/conda-pack-build.yml
vendored
@@ -1,10 +1,6 @@
|
|||||||
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:
|
||||||
@@ -25,16 +21,6 @@ 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:
|
||||||
@@ -68,9 +54,7 @@ jobs:
|
|||||||
id: should_build
|
id: should_build
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
if [[ "${{ github.event_name }}" != "workflow_dispatch" ]]; then
|
if [[ -z "${{ github.event.inputs.platforms }}" ]]; 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
|
||||||
@@ -81,7 +65,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 || github.event.workflow_run.head_sha || github.ref }}
|
ref: ${{ github.event.inputs.branch }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup Miniforge (with mamba)
|
- name: Setup Miniforge (with mamba)
|
||||||
@@ -91,7 +75,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
|
channels: conda-forge,robostack-staging,uni-lab,defaults
|
||||||
channel-priority: flexible
|
channel-priority: flexible
|
||||||
activate-environment: unilab
|
activate-environment: unilab
|
||||||
auto-update-conda: false
|
auto-update-conda: false
|
||||||
@@ -102,13 +86,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: ${{ env.BUILD_FULL }}
|
echo Build full: ${{ github.event.inputs.build_full }}
|
||||||
if "${{ env.BUILD_FULL }}"=="true" (
|
if "${{ github.event.inputs.build_full }}"=="true" (
|
||||||
echo Installing unilabos-full ^(complete package^)...
|
echo Installing unilabos-full ^(complete package^)...
|
||||||
mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos-full conda-pack zstandard -y
|
mamba install -n unilab uni-lab::unilabos-full conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
|
||||||
) else (
|
) else (
|
||||||
echo Installing unilabos ^(minimal package^)...
|
echo Installing unilabos ^(minimal package^)...
|
||||||
mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos conda-pack zstandard -y
|
mamba install -n unilab uni-lab::unilabos conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
|
||||||
)
|
)
|
||||||
|
|
||||||
- name: Install conda-pack, unilabos and dependencies (Unix)
|
- name: Install conda-pack, unilabos and dependencies (Unix)
|
||||||
@@ -117,13 +101,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: ${{ env.BUILD_FULL }}"
|
echo "Build full: ${{ github.event.inputs.build_full }}"
|
||||||
if [[ "${{ env.BUILD_FULL }}" == "true" ]]; then
|
if [[ "${{ github.event.inputs.build_full }}" == "true" ]]; then
|
||||||
echo "Installing unilabos-full (complete package)..."
|
echo "Installing unilabos-full (complete package)..."
|
||||||
mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos-full conda-pack zstandard -y
|
mamba install -n unilab uni-lab::unilabos-full conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
|
||||||
else
|
else
|
||||||
echo "Installing unilabos (minimal package)..."
|
echo "Installing unilabos (minimal package)..."
|
||||||
mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos conda-pack zstandard -y
|
mamba install -n unilab uni-lab::unilabos conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Get latest ros-humble-unilabos-msgs version (Windows)
|
- name: Get latest ros-humble-unilabos-msgs version (Windows)
|
||||||
@@ -150,27 +134,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 --override-channels -c uni-lab -c robostack-staging -c conda-forge ros-humble-unilabos-msgs || echo Search completed
|
mamba search ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge || 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 --override-channels -c uni-lab -c robostack-staging -c conda-forge ros-humble-unilabos-msgs -y || echo Already at 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
|
||||||
|
|
||||||
- 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 --override-channels -c uni-lab -c robostack-staging -c conda-forge ros-humble-unilabos-msgs || echo "Search completed"
|
mamba search ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge || 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 --override-channels -c uni-lab -c robostack-staging -c conda-forge ros-humble-unilabos-msgs -y || echo "Already at 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"
|
||||||
|
|
||||||
- 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 (ref: ${{ env.PACKAGE_REF }})...
|
echo Installing unilabos from source (branch: ${{ github.event.inputs.branch }})...
|
||||||
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
|
||||||
@@ -181,7 +165,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 (ref: ${{ env.PACKAGE_REF }})..."
|
echo "Installing unilabos from source (branch: ${{ github.event.inputs.branch }})..."
|
||||||
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
|
||||||
@@ -242,9 +226,7 @@ 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...
|
||||||
for /f "delims=" %%i in ('mamba run -n unilab python -c "import os; print(os.environ['CONDA_PREFIX'])"') do set "UNILAB_PREFIX=%%i"
|
mamba activate unilab && 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:
|
||||||
dir unilab-env-${{ matrix.platform }}.tar.gz
|
dir unilab-env-${{ matrix.platform }}.tar.gz
|
||||||
|
|
||||||
@@ -253,9 +235,8 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
echo "Packing unilab environment with conda-pack..."
|
echo "Packing unilab environment with conda-pack..."
|
||||||
UNILAB_PREFIX="$(mamba run -n unilab python -c 'import os; print(os.environ["CONDA_PREFIX"])')"
|
mamba install conda-pack -c conda-forge -y
|
||||||
echo "Packing environment at: $UNILAB_PREFIX"
|
conda pack -n unilab -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files
|
||||||
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
|
||||||
|
|
||||||
@@ -286,7 +267,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 }} ${{ env.PACKAGE_REF }} dist-package\README.txt
|
python scripts\create_readme.py ${{ matrix.platform }} ${{ github.event.inputs.branch }} dist-package\README.txt
|
||||||
|
|
||||||
echo.
|
echo.
|
||||||
echo Distribution package contents:
|
echo Distribution package contents:
|
||||||
@@ -322,7 +303,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 }} ${{ env.PACKAGE_REF }} dist-package/README.txt
|
python scripts/create_readme.py ${{ matrix.platform }} ${{ github.event.inputs.branch }} dist-package/README.txt
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Distribution package contents:"
|
echo "Distribution package contents:"
|
||||||
@@ -333,7 +314,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 }}-${{ env.PACKAGE_REF }}
|
name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }}
|
||||||
path: dist-package/
|
path: dist-package/
|
||||||
retention-days: 90
|
retention-days: 90
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
@@ -345,9 +326,9 @@ jobs:
|
|||||||
echo Build Summary
|
echo Build Summary
|
||||||
echo ==========================================
|
echo ==========================================
|
||||||
echo Platform: ${{ matrix.platform }}
|
echo Platform: ${{ matrix.platform }}
|
||||||
echo Branch: ${{ env.PACKAGE_REF }}
|
echo Branch: ${{ github.event.inputs.branch }}
|
||||||
echo Python version: 3.11.14
|
echo Python version: 3.11.14
|
||||||
if "${{ env.BUILD_FULL }}"=="true" (
|
if "${{ github.event.inputs.build_full }}"=="true" (
|
||||||
echo Package: unilabos-full ^(complete^)
|
echo Package: unilabos-full ^(complete^)
|
||||||
) else (
|
) else (
|
||||||
echo Package: unilabos ^(minimal^)
|
echo Package: unilabos ^(minimal^)
|
||||||
@@ -356,7 +337,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 }}-${{ env.PACKAGE_REF }}
|
echo Artifact name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }}
|
||||||
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
|
||||||
@@ -370,9 +351,9 @@ jobs:
|
|||||||
echo "Build Summary"
|
echo "Build Summary"
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo "Platform: ${{ matrix.platform }}"
|
echo "Platform: ${{ matrix.platform }}"
|
||||||
echo "Branch: ${{ env.PACKAGE_REF }}"
|
echo "Branch: ${{ github.event.inputs.branch }}"
|
||||||
echo "Python version: 3.11.14"
|
echo "Python version: 3.11.14"
|
||||||
if [[ "${{ env.BUILD_FULL }}" == "true" ]]; then
|
if [[ "${{ github.event.inputs.build_full }}" == "true" ]]; then
|
||||||
echo "Package: unilabos-full (complete)"
|
echo "Package: unilabos-full (complete)"
|
||||||
else
|
else
|
||||||
echo "Package: unilabos (minimal)"
|
echo "Package: unilabos (minimal)"
|
||||||
@@ -381,7 +362,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 }}-${{ env.PACKAGE_REF }}"
|
echo "Artifact name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }}"
|
||||||
echo ""
|
echo ""
|
||||||
echo "After download:"
|
echo "After download:"
|
||||||
echo " install_unilab.sh"
|
echo " install_unilab.sh"
|
||||||
|
|||||||
4
.github/workflows/deploy-docs.yml
vendored
4
.github/workflows/deploy-docs.yml
vendored
@@ -56,7 +56,7 @@ jobs:
|
|||||||
miniforge-version: latest
|
miniforge-version: latest
|
||||||
use-mamba: true
|
use-mamba: true
|
||||||
python-version: '3.11.14'
|
python-version: '3.11.14'
|
||||||
channels: conda-forge,robostack-staging,uni-lab
|
channels: conda-forge,robostack-staging,uni-lab,defaults
|
||||||
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 --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos -y
|
mamba install -n unilab uni-lab::unilabos -c uni-lab -c robostack-staging -c conda-forge -y
|
||||||
|
|
||||||
- name: Install latest unilabos from source
|
- name: Install latest unilabos from source
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
22
.github/workflows/multi-platform-build.yml
vendored
22
.github/workflows/multi-platform-build.yml
vendored
@@ -10,9 +10,6 @@ on:
|
|||||||
# 支持 tag 推送(不依赖 CI Check)
|
# 支持 tag 推送(不依赖 CI Check)
|
||||||
push:
|
push:
|
||||||
tags: ['v*']
|
tags: ['v*']
|
||||||
# GitHub Release 发布时自动构建并上传
|
|
||||||
release:
|
|
||||||
types: [published]
|
|
||||||
# 手动触发
|
# 手动触发
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
@@ -83,7 +80,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.event.release.tag_name || github.ref }}
|
ref: ${{ github.event.workflow_run.head_sha || github.ref }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Check if platform should be built
|
- name: Check if platform should be built
|
||||||
@@ -99,13 +96,12 @@ jobs:
|
|||||||
echo "should_build=false" >> $GITHUB_OUTPUT
|
echo "should_build=false" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Setup Miniforge
|
- name: Setup Miniconda
|
||||||
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:
|
||||||
miniforge-version: latest
|
miniconda-version: 'latest'
|
||||||
use-mamba: true
|
channels: conda-forge,robostack-staging,defaults
|
||||||
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
|
||||||
@@ -114,7 +110,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: |
|
||||||
mamba install --override-channels -c conda-forge rattler-build anaconda-client -y
|
conda install -c conda-forge rattler-build anaconda-client
|
||||||
|
|
||||||
- name: Show environment info
|
- name: Show environment info
|
||||||
if: steps.should_build.outputs.should_build == 'true'
|
if: steps.should_build.outputs.should_build == 'true'
|
||||||
@@ -161,13 +157,7 @@ jobs:
|
|||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
- name: Upload to Anaconda.org (unilab organization)
|
- name: Upload to Anaconda.org (unilab organization)
|
||||||
if: |
|
if: steps.should_build.outputs.should_build == 'true' && github.event.inputs.upload_to_anaconda == 'true'
|
||||||
steps.should_build.outputs.should_build == 'true' &&
|
|
||||||
(
|
|
||||||
github.event_name == 'release' ||
|
|
||||||
startsWith(github.ref, 'refs/tags/') ||
|
|
||||||
github.event.inputs.upload_to_anaconda == 'true'
|
|
||||||
)
|
|
||||||
run: |
|
run: |
|
||||||
for package in $(find ./output -name "*.conda"); do
|
for package in $(find ./output -name "*.conda"); do
|
||||||
echo "Uploading $package to unilab organization..."
|
echo "Uploading $package to unilab organization..."
|
||||||
|
|||||||
57
.github/workflows/unilabos-conda-build.yml
vendored
57
.github/workflows/unilabos-conda-build.yml
vendored
@@ -1,10 +1,14 @@
|
|||||||
name: UniLabOS Conda Build
|
name: UniLabOS Conda Build
|
||||||
|
|
||||||
on:
|
on:
|
||||||
# 在 Multi-Platform Conda Build 成功上传 msgs 后自动触发
|
# 在 CI Check 成功后自动触发
|
||||||
workflow_run:
|
workflow_run:
|
||||||
workflows: ["Multi-Platform Conda Build"]
|
workflows: ["CI Check"]
|
||||||
types: [completed]
|
types: [completed]
|
||||||
|
branches: [main, dev]
|
||||||
|
# 标签推送时直接触发(发布版本)
|
||||||
|
push:
|
||||||
|
tags: ['v*']
|
||||||
# 手动触发
|
# 手动触发
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
@@ -29,30 +33,30 @@ on:
|
|||||||
type: boolean
|
type: boolean
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# 等待上游 msgs 构建完成的 job (仅用于 workflow_run 触发)
|
# 等待 CI Check 完成的 job (仅用于 workflow_run 触发)
|
||||||
wait-for-upstream:
|
wait-for-ci:
|
||||||
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 upstream workflow status
|
- name: Check CI status
|
||||||
id: check
|
id: check
|
||||||
run: |
|
run: |
|
||||||
if [[ "${{ github.event.workflow_run.conclusion }}" == "success" && ( "${{ github.event.workflow_run.event }}" == "release" || "${{ github.event.workflow_run.event }}" == "push" ) ]]; then
|
if [[ "${{ github.event.workflow_run.conclusion }}" == "success" ]]; then
|
||||||
echo "should_continue=true" >> $GITHUB_OUTPUT
|
echo "should_continue=true" >> $GITHUB_OUTPUT
|
||||||
echo "Multi-Platform Conda Build passed for release/tag, proceeding with UniLabOS build"
|
echo "CI Check passed, proceeding with build"
|
||||||
else
|
else
|
||||||
echo "should_continue=false" >> $GITHUB_OUTPUT
|
echo "should_continue=false" >> $GITHUB_OUTPUT
|
||||||
echo "Upstream workflow is not a successful release/tag build (status: ${{ github.event.workflow_run.conclusion }}, event: ${{ github.event.workflow_run.event }}), skipping build"
|
echo "CI Check did not succeed (status: ${{ github.event.workflow_run.conclusion }}), skipping build"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
build:
|
build:
|
||||||
needs: [wait-for-upstream]
|
needs: [wait-for-ci]
|
||||||
# 运行条件:workflow_run 触发且上游成功,或者手动触发
|
# 运行条件:workflow_run 触发且 CI 成功,或者其他触发方式
|
||||||
if: |
|
if: |
|
||||||
always() &&
|
always() &&
|
||||||
(needs.wait-for-upstream.result == 'skipped' || needs.wait-for-upstream.outputs.should_continue == 'true')
|
(needs.wait-for-ci.result == 'skipped' || needs.wait-for-ci.outputs.should_continue == 'true')
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -75,7 +79,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
# 如果是 workflow_run 触发,使用上游 conda 包构建的 commit
|
# 如果是 workflow_run 触发,使用触发 CI Check 的 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
|
||||||
|
|
||||||
@@ -92,13 +96,12 @@ jobs:
|
|||||||
echo "should_build=false" >> $GITHUB_OUTPUT
|
echo "should_build=false" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Setup Miniforge
|
- name: Setup Miniconda
|
||||||
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:
|
||||||
miniforge-version: latest
|
miniconda-version: 'latest'
|
||||||
use-mamba: true
|
channels: conda-forge,robostack-staging,uni-lab,defaults
|
||||||
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
|
||||||
@@ -107,7 +110,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: |
|
||||||
mamba install --override-channels -c conda-forge rattler-build anaconda-client -y
|
conda install -c conda-forge rattler-build anaconda-client
|
||||||
|
|
||||||
- name: Show environment info
|
- name: Show environment info
|
||||||
if: steps.should_build.outputs.should_build == 'true'
|
if: steps.should_build.outputs.should_build == 'true'
|
||||||
@@ -116,11 +119,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_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' }}"
|
echo "Build full package: ${{ github.event.inputs.build_full || 'false' }}"
|
||||||
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_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' }}" == "true" ]]; then
|
if [[ "${{ github.event.inputs.build_full }}" == "true" ]]; then
|
||||||
echo " - unilabos-full (complete package)"
|
echo " - unilabos-full (complete package)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -131,12 +134,7 @@ 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: |
|
if: steps.should_build.outputs.should_build == 'true' && github.event.inputs.upload_to_anaconda == 'true'
|
||||||
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
|
||||||
@@ -151,12 +149,7 @@ 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: |
|
if: steps.should_build.outputs.should_build == 'true' && github.event.inputs.upload_to_anaconda == 'true'
|
||||||
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
|
||||||
@@ -166,7 +159,6 @@ 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 }}..."
|
||||||
@@ -175,7 +167,6 @@ 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: |
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -251,6 +251,7 @@ ros-humble-unilabos-msgs-0.9.13-h6403a04_5.tar.bz2
|
|||||||
*.bz2
|
*.bz2
|
||||||
test_config.py
|
test_config.py
|
||||||
|
|
||||||
|
# Local config files with secrets
|
||||||
/.claude
|
yibin_coin_cell_only_config.json
|
||||||
/.cursor
|
yibin_electrolyte_config.json
|
||||||
|
yibin_electrolyte_only_config.json
|
||||||
|
|||||||
72
260415csv_export_walkthrough.md
Normal file
72
260415csv_export_walkthrough.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# CSV 导出功能变更概要
|
||||||
|
|
||||||
|
## 修改的文件
|
||||||
|
|
||||||
|
### 1. [bioyond_cell_workstation.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py)
|
||||||
|
|
||||||
|
#### 新增导入
|
||||||
|
- `import csv` 和 `import os`(L14-15)
|
||||||
|
|
||||||
|
#### 新增方法
|
||||||
|
|
||||||
|
| 方法 | 功能 |
|
||||||
|
|------|------|
|
||||||
|
| `_extract_prep_bottle_from_report` | 从 order_finish 报文提取**配液瓶**信息(每订单最多1个) |
|
||||||
|
| `_extract_vial_bottles_from_report` | 从 order_finish 报文提取**分液瓶**信息(每订单可多个,返回数组) |
|
||||||
|
| `_export_order_csv` | 汇总所有信息写入 CSV 文件 |
|
||||||
|
|
||||||
|
#### 配液瓶筛选逻辑 (`_extract_prep_bottle_from_report`)
|
||||||
|
- `typemode="1"`, `realQuantity=1`, `usedQuantity=1`
|
||||||
|
- `locationId` 以 `3a19deae-2c7a-` 开头(手动传递窗)
|
||||||
|
- LIMS API 二次确认:`typeName` 含"配液瓶(小)"或"配液瓶(大)"
|
||||||
|
|
||||||
|
#### 分液瓶筛选逻辑 (`_extract_vial_bottles_from_report`)
|
||||||
|
- `typemode="1"`, `realQuantity=1`, `usedQuantity=1`
|
||||||
|
- `locationId` 以 `3a19debc-84b5-` 或 `3a19debe-5200` 开头(自动堆栈-左/右)
|
||||||
|
- LIMS API 二次确认:`typeName` 为"5ml分液瓶"或"20ml分液瓶"
|
||||||
|
- **返回数组**,支持 1×5ml + n×20ml 的组合
|
||||||
|
|
||||||
|
#### 修改的方法
|
||||||
|
|
||||||
|
| 方法 | 变更 |
|
||||||
|
|------|------|
|
||||||
|
| `_submit_and_wait_orders` | 新增配液瓶+分液瓶提取步骤,将 `prep_bottles` 和 `vial_bottles` 存入 `final_result` |
|
||||||
|
| `create_orders` | 添加 `csv_export_path` 参数,末尾调用 `_export_order_csv` |
|
||||||
|
| `create_orders_formulation` | 添加 `csv_export_path` 参数,末尾调用 `_export_order_csv` |
|
||||||
|
|
||||||
|
#### CSV 输出格式
|
||||||
|
```
|
||||||
|
orderCode, orderName, 配液瓶类型, 配液瓶二维码, 分液瓶类型, 分液瓶二维码, 目标配液质量比, 真实配液质量比, 时间
|
||||||
|
```
|
||||||
|
- 单个分液瓶时直接写值;多个分液瓶时类型和二维码用 JSON 数组表示
|
||||||
|
- CSV 编码使用 `utf-8-sig`(兼容 Excel 打开)
|
||||||
|
- `csv_export_path` 默认为空字符串,不传则不导出(向后兼容)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. [bioyond_cell.yaml](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/registry/devices/bioyond_cell.yaml)
|
||||||
|
|
||||||
|
为两个 action 注册了 `csv_export_path` 参数:
|
||||||
|
|
||||||
|
- `auto-create_orders`: `goal_default` + `schema.properties.goal.properties` 中添加 `csv_export_path`
|
||||||
|
- `auto-create_orders_formulation`: 同上
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. [coin_cell_assembly.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py) 的 CSV 改动与全流程追溯
|
||||||
|
|
||||||
|
在 `bioyond_cell_workstation.py` 的 `_submit_and_wait_orders` 最后阶段,提取 `prep_bottles`(配液瓶)和 `vial_bottles`(分液瓶)的条码并随 `mass_ratios` 数组一起下发给各下游工站(例如扣电组装站),实现跨站的全流程配方追溯。
|
||||||
|
|
||||||
|
并在扣电站生成的 `date_xxx.csv` 中,**替换并新增**了以下列:
|
||||||
|
- 移除了原有的 `formulation_order_code` 与合并的 `formulation_ratio` 列。
|
||||||
|
- 新增 `orderName` 导出
|
||||||
|
- 新增 `prep_bottle_barcode`(奔曜传递的配液瓶二维码)
|
||||||
|
- 新增 `vial_bottle_barcodes`(奔曜传递的分液瓶二维码,多瓶时存 JSON 数组)
|
||||||
|
- 新增 `target_mass_ratio` 理论目标质量比
|
||||||
|
- 新增 `real_mass_ratio` 实际称量真实质量比
|
||||||
|
|
||||||
|
*注意:这与操作人员在手套箱内扫码传入扣电站的 `electrolyte_code` 是单独记录的,方便做数据核对。*
|
||||||
|
|
||||||
|
## 向后兼容性
|
||||||
|
- `csv_export_path` 默认值为 `""`(空字符串),现有调用不受影响
|
||||||
|
- 新增的 `prep_bottles` 和 `vial_bottles` 字段为 `final_result` 和 `mass_ratios` 内部的新增附属字段,不破坏现有数据结构。
|
||||||
85
AGENTS.md
85
AGENTS.md
@@ -23,11 +23,8 @@ 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(P6.1 新增 --target_device;P6.1.1 新增 --target_model)
|
# Workflow upload subcommand
|
||||||
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
|
||||||
@@ -75,86 +72,6 @@ pytest tests/resources/test_resourcetreeset.py::TestClassName::test_method # si
|
|||||||
|
|
||||||
Example device graphs and experiment configs are in `unilabos/test/experiments/` (not `tests/`). Registry test fixtures in `unilabos/test/registry/`.
|
Example device graphs and experiment configs are in `unilabos/test/experiments/` (not `tests/`). Registry test fixtures in `unilabos/test/registry/`.
|
||||||
|
|
||||||
### Labware Mapping Table (`labware_mapping.yaml`) — P6 + P6.1 + P6.1.1
|
|
||||||
|
|
||||||
Opentrons → 目标仪器(PRCXI / Beckman / Tecan ...)的「槽位重映射 + labware 归类 +
|
|
||||||
class_name 选择」全部外化到项目根的
|
|
||||||
[`labware_mapping.yaml`](./labware_mapping.yaml)(与 `pyproject.toml` 同级,最显眼的位置)。
|
|
||||||
要新增 SKU、新厂商、新型号、或调整 tip 量程档时,**只改 YAML,不改 Python**。
|
|
||||||
|
|
||||||
- **YAML 两段顶层语义**(P6.1.1 起 `slot_remap` 已下沉到 `target_devices` 内):
|
|
||||||
- `kinds` — 顺序敏感的 regex;把 labware 字符串归到 `trash / tip_rack / tube_rack / plate`。**全局段**,与目标仪器无关。
|
|
||||||
- `target_devices.<name>` — 按目标仪器组织的规则段,内含三个字段:
|
|
||||||
- `slot_remap` — 替代历史 `_map_deck_slot`(例:`4 → 13`、`8 → 14`、`12+trash → 16`)。
|
|
||||||
- `rules` — 顺序敏感的「`kind + hole_count + volume_min/volume_max` → `class_name`」规则,首个命中胜出。
|
|
||||||
- `models.<model_name>` — 可选的型号粒度覆盖(slot_remap / rules);缺失字段自动继承厂商级。
|
|
||||||
- **`target_devices` 内段名约定**:
|
|
||||||
- `default` — **固定段名**,兜底物料集 + 兜底 `slot_remap`。caller 传入的 `target_device` 在 `target_devices`
|
|
||||||
下未声明时,自动 fallback 到此段(loader 单次 warning,下游消费方零感知)。
|
|
||||||
**第一版按 prcxi 内容拷贝填充**(值仍是 `PRCXI_*`),但与 prcxi 段在 YAML 中
|
|
||||||
各自独立,可独立演进。**`default` 不支持 `models` 子段**——型号粒度差异必须落到具体仪器段。
|
|
||||||
- `prcxi` / `beckman` / `tecan` / ... — 具体仪器段(厂商粒度);caller 显式
|
|
||||||
`--target_device <name>` 时命中。可在 `models.<model>` 下声明同厂商不同型号的差异。
|
|
||||||
- **4 段 fallback 链**(`slot_remap` / `rules` 共用):
|
|
||||||
1. `target_devices.<device>.models.<model>.<field>`(caller 同时传 device + model)
|
|
||||||
2. `target_devices.<device>.<field>`(厂商级;步骤 1 缺字段时静默 fallback)
|
|
||||||
3. `target_devices.default.<field>`(caller 传未声明 device,或步骤 2 缺字段;打 warning)
|
|
||||||
4. `_BUILTIN_DEFAULT.target_devices.default.<field>`(YAML 误删 default 段时的最后兜底)
|
|
||||||
- **CLI 用法**:
|
|
||||||
- P6.1:`unilab workflow_upload -f <workflow.json> --target_device prcxi`
|
|
||||||
(`--target_device` snake-case,默认 `prcxi`;未声明的名字自动 fallback 到 `default` 段)。
|
|
||||||
- P6.1.1:可加 `--target_model <name>`(snake,可省略,默认 `None`)。
|
|
||||||
例:`unilab workflow_upload -f <workflow.json> --target_device prcxi --target_model 9320`。
|
|
||||||
- **入口代码**:`unilabos/workflow/labware_mapping.py` 暴露 `remap_slot` / `infer_kind` /
|
|
||||||
`resolve_target_class` / `reload_mapping`。
|
|
||||||
API 签名(P6.1.1):
|
|
||||||
- `remap_slot(raw_slot, object_type="", *, target_device="prcxi", target_model=None)`
|
|
||||||
- `resolve_target_class(target_device, kind, hole_count=None, volume=None, *, target_model=None)`
|
|
||||||
`workflow/common.py` 中 `_map_deck_slot` / `_infer_reagent_kind` /
|
|
||||||
`_apply_tip_rack_class_from_transfer_volumes` / `_apply_target_labware_class_auto_match` /
|
|
||||||
`_reconcile_slot_carrier_target_class` 都已转调 YAML 并透传 `target_device` / `target_model`;
|
|
||||||
YAML 未命中(孔数 / 体积超出 default 段覆盖范围)时 fallback 到
|
|
||||||
`prcxi_labware.get_prcxi_labware_template_specs` 的模板打分匹配,并打 warning 提示「请补到映射表」。
|
|
||||||
- **`labware_info` 字段重命名**:P6 的 `prcxi_class_name` → P6.1 的 `target_class_name`,
|
|
||||||
13 处全部同步刷新;旧 schema(顶层 `vendors` / `slot_remap` 或任一 rule 内 `prcxi_class`)
|
|
||||||
会触发 loader warning 并整段 fallback 到 builtin 默认表。
|
|
||||||
- **测试**:
|
|
||||||
- `pytest tests/workflow/test_labware_mapping.py` —— 45 项单元测试(含 P6.1 + P6.1.1 用例:
|
|
||||||
`test_remap_slot_model_level_overrides_device_level`、
|
|
||||||
`test_remap_slot_model_inherits_device_when_field_missing`、
|
|
||||||
`test_legacy_top_level_slot_remap_rejected`、
|
|
||||||
`test_default_section_models_subsection_warns` 等)。
|
|
||||||
- `pytest tests/workflow/test_build_protocol_graph_target_device.py` —— 6 项集成
|
|
||||||
测试(默认 / 显式 prcxi / unknown 段 fallback / per-device tip class / 字段重命名 /
|
|
||||||
P6.1.1 model-level slot_remap)。
|
|
||||||
- **设计文档**:[`product_designs/protocol_convert/06-labware-mapping-table.md`](../product_designs/protocol_convert/06-labware-mapping-table.md)
|
|
||||||
(§11.7 = P6.1 多目标仪器选择,§11.8 = P6.1.1 槽位映射按厂商+型号分叉)。
|
|
||||||
|
|
||||||
### P2 跨 slot transfer_liquid 合并(v2,已落地)
|
|
||||||
|
|
||||||
当一次 phase 中存在「单源吸取 → 跨多个 plate 分发」(典型 `steps/51b9a5.json` 9 plate × 12 well = 108 条 1:1 dispense),Stage 2 + Stage 3 现在能把它折叠成 **1 个 merged set_liquid_from_plate + 1 个 transfer_liquid** 节点。
|
|
||||||
|
|
||||||
- **Stage 2**([`Protocols/protocol_converter/change_to_transfer_group.py`](../Protocols/protocol_converter/change_to_transfer_group.py)):
|
|
||||||
- `_pair_mergeable` 只要求源 slot / tip 量程档 / use_channels 一致;不再要求 `_target_slot` 相同。
|
|
||||||
- `_merge_two_transfer_actions` 维护 `_target_slots: list[int]`(与 `_target_wells` 平行,每次 dispense 一条)。
|
|
||||||
- `export_transfer_actions` 通过 `_register_target_reagent_key` 统一注册 reagent_key:跨 slot 时按 `_target_slots` 顺序拼出 `action_args.targets: list[str]`(同板退化为 `str`)。
|
|
||||||
- 末尾 `pop` 全部 `_` 前缀字段(包括新增的 `_target_slots`)。
|
|
||||||
- **Stage 3**([`Uni-Lab-OS/unilabos/workflow/common.py`](unilabos/workflow/common.py)):
|
|
||||||
- 新增 `_emit_merged_set_liquid(...)`:对 `params.targets: list[str]` 的 transfer_liquid 节点,在其上游插入一个 **merged `set_liquid_from_plate`** 跨板聚合器;其 `param.wells` 是按 dispense 顺序通过 cursor 走 `reagent[key].well` 得出的有序跨板 well refs;多入边(每 plate 一条 `create_resource.labware → wells_identifier`),单出边(`output_wells → transfer_liquid.targets_identifier`)。
|
|
||||||
- 把 `params["targets"]` 改写为 synthetic str `_merged_targets_<idx>` 并注册 `resource_last_writer`,保证 INPUT_PORT_MAPPING 走 P3 既有的单边路径。
|
|
||||||
- `OUTPUT_PORT_MAPPING` 在原始 `step.param.targets` 为 `list[str]` 时为每个 reagent_key 分别注册 transfer_liquid 的下游 writer。
|
|
||||||
- **PRCXI runtime**([`prcxi/prcxi.py`](unilabos/devices/liquid_handling/prcxi/prcxi.py)):`change_slots` 改为遍历所有 source / target 的 parent plate 并按 plate name 去重(跨板 4 个 plate 都能 `update_pipetting_position`)。
|
|
||||||
- **`liquid_handler_abstract.transfer_liquid`**:**完全不改动**,主循环 `i % num_targets` 与单边 + 单 list 完全兼容。
|
|
||||||
|
|
||||||
CLI 行为不变:现有 `unilab workflow_upload -f <workflow.json> ...` 一切照旧;跨 slot 协议自动走 v2 路径。
|
|
||||||
|
|
||||||
测试:
|
|
||||||
- `pytest Protocols/protocol_converter/tests/test_cross_slot_merge.py` — Stage 2 单测 10 项。
|
|
||||||
- `pytest tests/workflow/test_common_cross_slot_v2.py` — Stage 3 集成测试 6 项。
|
|
||||||
- `pytest tests/devices/liquid_handling/test_set_liquid_from_plate_cross_plate.py` — device 跨板单测 6 项(pylabrobot 不全时优雅 skip)。
|
|
||||||
|
|
||||||
设计文档:[`product_designs/protocol_convert/02-cross-slot-merge.md`](../product_designs/protocol_convert/02-cross-slot-merge.md)(§9 v2 设计 + §11 落地记录)。
|
|
||||||
|
|
||||||
## Code Conventions
|
## Code Conventions
|
||||||
|
|
||||||
- Code comments and log messages in simplified Chinese
|
- Code comments and log messages in simplified Chinese
|
||||||
|
|||||||
168
CHANGES_2026_03_24.md
Normal file
168
CHANGES_2026_03_24.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# 变更说明 2026-03-24
|
||||||
|
|
||||||
|
## 问题背景
|
||||||
|
|
||||||
|
`BioyondElectrolyteDeck`(原 `BIOYOND_YB_Deck`)迁移后,前端物料未能正常上传/同步。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 修复内容
|
||||||
|
|
||||||
|
### 1. `unilabos/resources/bioyond/decks.py`
|
||||||
|
|
||||||
|
- 补回 `setup: bool = False` 参数及 `if setup: self.setup()` 逻辑,与旧版 `BIOYOND_YB_Deck` 保持一致
|
||||||
|
- 工厂函数 `bioyond_electrolyte_deck` 保留显式调用 `deck.setup()`,避免重复初始化
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 修复前(缺少 setup 参数,无法通过 setup=True 触发初始化)
|
||||||
|
def __init__(self, name, size_x, size_y, size_z, category):
|
||||||
|
super().__init__(...)
|
||||||
|
|
||||||
|
# 修复后
|
||||||
|
def __init__(self, name, size_x, size_y, size_z, category, setup: bool = False):
|
||||||
|
super().__init__(...)
|
||||||
|
if setup:
|
||||||
|
self.setup()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. `unilabos/resources/graphio.py`
|
||||||
|
|
||||||
|
- 修复 `resource_bioyond_to_plr` 中两处 `bottle.tracker.liquids` 直接赋值导致的崩溃
|
||||||
|
- `ResourceHolder`(如枪头盒的 TipSpot 槽位)没有 `tracker` 属性,直接访问会抛出 `AttributeError`,阻断整个 Bioyond 同步流程
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 修复前
|
||||||
|
bottle.tracker.liquids = [...]
|
||||||
|
|
||||||
|
# 修复后
|
||||||
|
if hasattr(bottle, 'tracker') and bottle.tracker is not None:
|
||||||
|
bottle.tracker.liquids = [...]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. `unilabos/app/main.py`
|
||||||
|
|
||||||
|
- 保留 `file_path is not None` 条件不变(已还原),并补充注释说明原因
|
||||||
|
- 该逻辑只在**本地文件模式**下有意义:本地 graph 文件只含设备结构,远端有已保存物料,merge 才能将两者合并
|
||||||
|
- 远端模式(`file_path=None`)下,`resource_tree_set` 和 `request_startup_json` 来自同一份数据,merge 为空操作,条件是否加 `file_path is not None` 对结果没有影响
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. `unilabos/devices/workstation/bioyond_studio/station.py` ⭐ 核心修复
|
||||||
|
|
||||||
|
- 当 deck 通过反序列化创建时,不会自动调用 `setup()`,导致 `deck.children` 为空,`warehouses` 始终是 `{}`
|
||||||
|
- 增加兜底逻辑:仓库扫描后仍为空,则主动调用 `deck.setup()` 初始化仓库
|
||||||
|
- 这是导致所有物料放置失败(`warehouse '...' 在deck中不存在。可用warehouses: []`)的根本原因
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 新增兜底
|
||||||
|
if not self.deck.warehouses and hasattr(self.deck, "setup") and callable(self.deck.setup):
|
||||||
|
logger.info("Deck 无仓库子节点,调用 setup() 初始化仓库")
|
||||||
|
self.deck.setup()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充修复 2026-03-25:依华扣电组装工站子物料未上传
|
||||||
|
|
||||||
|
### 问题
|
||||||
|
|
||||||
|
`CoinCellAssemblyWorkstation.post_init` 直接上传空 deck,未调用 `deck.setup()`,导致:
|
||||||
|
- 前端子物料(成品弹夹、料盘、瓶架等)不显示
|
||||||
|
- 运行时 `self.deck.get_resource("成品弹夹")` 抛出 `ResourceNotFoundError`
|
||||||
|
|
||||||
|
### 修复文件
|
||||||
|
|
||||||
|
**`unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py`**
|
||||||
|
- `YihuaCoinCellDeck.__init__` 补回 `setup: bool = False` 参数及 `if setup: self.setup()` 逻辑
|
||||||
|
|
||||||
|
**`unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py`**
|
||||||
|
- `post_init` 中增加与 Bioyond 工站相同的兜底逻辑:deck 无子节点时调用 `deck.setup()` 初始化
|
||||||
|
|
||||||
|
```python
|
||||||
|
# post_init 中新增
|
||||||
|
if self.deck and not self.deck.children and hasattr(self.deck, "setup") and callable(self.deck.setup):
|
||||||
|
logger.info("YihuaCoinCellDeck 无子节点,调用 setup() 初始化")
|
||||||
|
self.deck.setup()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 联动 Bug:`MaterialPlate.create_with_holes` 构造顺序错误
|
||||||
|
|
||||||
|
**现象**:`deck.setup()` 被调用后,启动时抛出:
|
||||||
|
```
|
||||||
|
设备后初始化失败: Must specify either `ordered_items` or `ordering`.
|
||||||
|
```
|
||||||
|
|
||||||
|
**根因**:`create_with_holes` 原来的逻辑是先构造空的 `MaterialPlate` 实例,再 assign 洞位:
|
||||||
|
```python
|
||||||
|
# 旧(错误):cls(...) 时 ordered_items=None → ItemizedResource.__init__ 立即报错
|
||||||
|
plate = cls(name=name, ...) # ← 这里就崩了
|
||||||
|
holes = create_ordered_items_2d(...) # ← 根本没走到这里
|
||||||
|
for hole_name, hole in holes.items():
|
||||||
|
plate.assign_child_resource(...)
|
||||||
|
```
|
||||||
|
pylabrobot 的 `ItemizedResource.__init__` 强制要求 `ordered_items` 和 `ordering` 必须有一个不为 `None`,空构造直接失败。
|
||||||
|
|
||||||
|
**修复**:先建洞位,再作为 `ordered_items` 传给构造函数:
|
||||||
|
```python
|
||||||
|
# 新(正确):先建洞位,再一次性传入构造函数
|
||||||
|
holes = create_ordered_items_2d(klass=MaterialHole, num_items_x=4, ...)
|
||||||
|
return cls(name=name, ..., ordered_items=holes)
|
||||||
|
```
|
||||||
|
|
||||||
|
> 此 bug 此前未被触发,是因为 `deck.setup()` 从未被调用到——正是上面 `post_init` 兜底修复引出的联动问题。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 补充修复 2026-03-25:3→2→1 转运资源同步失败
|
||||||
|
|
||||||
|
### 问题
|
||||||
|
|
||||||
|
配液工站(Bioyond)完成分液后,调用 `transfer_3_to_2_to_1_auto` 将分液瓶板转运到扣电工站(BatteryStation)。物理 LIMS 转运成功,但数字孪生资源树同步始终失败:
|
||||||
|
```
|
||||||
|
[资源同步] ❌ 失败: 目标设备 'BatteryStation' 中未找到资源 'bottle_rack_6x2'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 根因
|
||||||
|
|
||||||
|
`_get_resource_from_device` 方法负责跨设备查找资源对象,有两个问题:
|
||||||
|
|
||||||
|
1. **原始路径完全失效**:尝试 `from unilabos.app.ros2_app import get_device_plr_resource_by_name`,但该模块不存在,`ImportError` 被 `except Exception: pass` 静默吞掉
|
||||||
|
2. **降级路径搜错地方**:遍历 `self._plr_resources`(Bioyond 自己的资源),不可能找到 BatteryStation 的 `bottle_rack_6x2`
|
||||||
|
|
||||||
|
### 修复文件
|
||||||
|
|
||||||
|
**`unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py`**
|
||||||
|
|
||||||
|
改用全局设备注册表 `registered_devices` 跨设备访问目标 deck:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 修复前(失效)
|
||||||
|
from unilabos.app.ros2_app import get_device_plr_resource_by_name # 模块不存在
|
||||||
|
return get_device_plr_resource_by_name(device_id, resource_name)
|
||||||
|
|
||||||
|
# 修复后
|
||||||
|
from unilabos.ros.nodes.base_device_node import registered_devices
|
||||||
|
device_info = registered_devices.get(device_id)
|
||||||
|
if device_info is not None:
|
||||||
|
driver = device_info.get("driver_instance") # TypedDict 是 dict,必须用 .get()
|
||||||
|
if driver is not None:
|
||||||
|
deck = getattr(driver, "deck", None)
|
||||||
|
if deck is not None:
|
||||||
|
res = deck.get_resource(resource_name)
|
||||||
|
```
|
||||||
|
|
||||||
|
关键细节:`DeviceInfoType` 是 `TypedDict`(即普通 `dict`),必须用 `device_info.get("driver_instance")` 而非 `getattr(device_info, "driver_instance", None)`——后者对字典永远返回 `None`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 根本原因分析
|
||||||
|
|
||||||
|
旧版以**本地文件模式**启动(有 `graph` 文件),deck 在启动前已通过 `merge_remote_resources` 获得仓库子节点,反序列化时能正确恢复 warehouses。
|
||||||
|
|
||||||
|
新版以**远端模式**启动(`file_path=None`),deck 反序列化时没有仓库子节点,`station.py` 扫描为空,所有物料的 warehouse 匹配失败,Bioyond 同步的 16 个资源全部无法放置到对应仓库位,前端不显示。
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,140 +0,0 @@
|
|||||||
# Opentrons → 目标仪器 物料映射表(P6.1.1)
|
|
||||||
#
|
|
||||||
# 两段顶层 key(P6.1.1 起 slot_remap 从顶层下沉到 target_devices 内):
|
|
||||||
# kinds : labware 字符串 → kind 归类(与目标仪器无关,**保留全局**)
|
|
||||||
# target_devices : 按目标仪器 + 型号组织;rule = kind + hole_count + volume_min/max → class_name;
|
|
||||||
# slot_remap 也内嵌在 target_devices 下(按 deck 物理布局变化)
|
|
||||||
#
|
|
||||||
# target_devices 段内结构:
|
|
||||||
# target_devices.<device>: # 厂商段(必填)
|
|
||||||
# slot_remap: {...} # 厂商级默认 slot 映射(缺失 → 继承 default 段)
|
|
||||||
# rules: [...] # 厂商级规则(缺失 → 继承 default 段)
|
|
||||||
# models: # 同厂商多型号(可选;缺失 = 仅厂商级,不区分型号)
|
|
||||||
# <model_name>: # 型号子段
|
|
||||||
# slot_remap: {...} # 型号级覆盖(缺失 → 继承厂商级)
|
|
||||||
# rules: [...] # 型号级覆盖(缺失 → 继承厂商级)
|
|
||||||
#
|
|
||||||
# 段名约定:
|
|
||||||
# target_devices.default : 兜底物料集 + 兜底 slot_remap。caller 传未声明的 target_device 时使用此段。
|
|
||||||
# **不支持 models 子段**(型号粒度差异必须落到具体仪器段,否则歧义)。
|
|
||||||
# target_devices.<name> : 具体仪器段(prcxi / beckman / tecan ...)。
|
|
||||||
#
|
|
||||||
# 解析链(remap_slot / resolve_target_class 共用,字段级 fallback):
|
|
||||||
# 1. target_devices.<device>.models.<model>.<field> (caller 同时传 device + model)
|
|
||||||
# 2. target_devices.<device>.<field> (caller 传 device,或步骤 1 缺字段)
|
|
||||||
# 3. target_devices.default.<field> (caller 传未声明 device,或步骤 2 缺字段)
|
|
||||||
# 4. _BUILTIN_DEFAULT.target_devices.default.<field> (YAML 误删 default 段时的最后兜底)
|
|
||||||
#
|
|
||||||
# 编辑建议:
|
|
||||||
# 1. 顺序敏感:kinds 与 rules 内首个命中胜出;窄规则在前、宽规则在后。
|
|
||||||
# 2. volume_min / volume_max 是闭区间(µL)。任一字段可省略;都省略 = 不限制体积。
|
|
||||||
# 3. notes 仅作注释,不参与匹配。
|
|
||||||
# 4. 新增目标仪器:复制 target_devices.prcxi 段、改 device 名、改 slot_remap + rules。
|
|
||||||
# 5. 同厂商不同型号:在 target_devices.<device>.models.<model> 下显式覆盖差异字段;
|
|
||||||
# 没声明的字段自动继承厂商级。
|
|
||||||
# 6. P6.1.1 不再支持顶层 slot_remap;检出顶层 slot_remap → warning + fallback 到 builtin。
|
|
||||||
#
|
|
||||||
# 设计文档:product_designs/protocol_convert/06-labware-mapping-table.md(§11.8)
|
|
||||||
|
|
||||||
kinds:
|
|
||||||
# 顺序敏感的 regex;第一个命中胜出
|
|
||||||
# 注意:trash 必须在 tip_rack 之前;tip_rack 必须在 tube_rack 之前("tuberack" 含 "rack")
|
|
||||||
- { pattern: "trash", kind: trash }
|
|
||||||
- { pattern: "tiprack|tip[_ ]?rack|opentrons_\\d+_tiprack", kind: tip_rack }
|
|
||||||
- { pattern: "tuberack|tube[_ ]rack|eppendorf.*rack|safelock.*rack", kind: tube_rack }
|
|
||||||
# 「<labware> 含 'rack' 但不含 'tip'」也归到 tube_rack(与历史 _infer_reagent_kind 行为一致)
|
|
||||||
- { pattern: "(?:^|[^a-z])rack(?:[^a-z]|$)", kind: tube_rack }
|
|
||||||
- { pattern: ".*", kind: plate }
|
|
||||||
|
|
||||||
target_devices:
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────
|
|
||||||
# default:兜底物料集 + 兜底 slot_remap。
|
|
||||||
# caller 传未声明的 target_device 时使用本段;**不支持 models 子段**。
|
|
||||||
# 第一版内容按 prcxi 拷贝填充(值仍是 PRCXI_*),但语义独立,可独立演进。
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────
|
|
||||||
default:
|
|
||||||
notes: "默认兜底物料集;caller 传未声明 target_device 时使用此段。第一版按 prcxi 拷贝填充。"
|
|
||||||
slot_remap:
|
|
||||||
# raw slot → deck slot;与对象类型无关
|
|
||||||
default:
|
|
||||||
"4": "13"
|
|
||||||
"8": "14"
|
|
||||||
# 按 object 字段覆盖 default
|
|
||||||
by_object:
|
|
||||||
trash:
|
|
||||||
"12": "16"
|
|
||||||
rules:
|
|
||||||
# ─ tip rack(默认量程档:≤10 / <300 / 否则 1000) ─
|
|
||||||
- { kind: tip_rack, hole_count: 96, volume_max: 10, class_name: PRCXI_10uL_Tips }
|
|
||||||
- { kind: tip_rack, hole_count: 96, volume_max: 299.9, class_name: PRCXI_300ul_Tips }
|
|
||||||
- { kind: tip_rack, hole_count: 96, class_name: PRCXI_1000uL_Tips }
|
|
||||||
# ─ tube rack ─
|
|
||||||
- { kind: tube_rack, hole_count: 24, class_name: PRCXI_EP_Adapter, notes: "Eppendorf 1.5/2 mL 24 位 4×6" }
|
|
||||||
- { kind: tube_rack, hole_count: 10, class_name: PRCXI_EP_Adapter, notes: "Falcon 4x50 + 6x15 mL(10 位兼容 4×6 适配器)" }
|
|
||||||
# ─ plate ─
|
|
||||||
- { kind: plate, hole_count: 96, class_name: PRCXI_BioER_96_wellplate }
|
|
||||||
- { kind: plate, hole_count: 384, class_name: PRCXI_BioER_384_wellplate }
|
|
||||||
# ─ trash ─
|
|
||||||
- { kind: trash, class_name: PRCXI_trash }
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────
|
|
||||||
# prcxi:PRCXI 仪器专用段。caller 显式传 --target_device prcxi 时命中此段。
|
|
||||||
# 厂商级 slot_remap + rules 适用于"未声明 model"的调用;
|
|
||||||
# models 子段下声明同厂商不同型号的 deck 物理布局差异。
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────
|
|
||||||
prcxi:
|
|
||||||
slot_remap:
|
|
||||||
# PRCXI 多数型号通用的 deck 物理布局映射
|
|
||||||
default:
|
|
||||||
"4": "13"
|
|
||||||
"8": "14"
|
|
||||||
by_object:
|
|
||||||
trash:
|
|
||||||
"12": "16"
|
|
||||||
rules:
|
|
||||||
# ─ tip rack(PRCXI 量程档:≤10 / <300 / 否则 1000) ─
|
|
||||||
- { kind: tip_rack, hole_count: 96, volume_max: 10, class_name: PRCXI_10uL_Tips }
|
|
||||||
- { kind: tip_rack, hole_count: 96, volume_max: 299.9, class_name: PRCXI_300ul_Tips }
|
|
||||||
- { kind: tip_rack, hole_count: 96, class_name: PRCXI_1000uL_Tips }
|
|
||||||
# ─ tube rack ─
|
|
||||||
- { kind: tube_rack, hole_count: 24, class_name: PRCXI_EP_Adapter, notes: "Eppendorf 1.5/2 mL 24 位 4×6" }
|
|
||||||
- { kind: tube_rack, hole_count: 10, class_name: PRCXI_EP_Adapter, notes: "Falcon 4x50 + 6x15 mL(10 位兼容 4×6 适配器)" }
|
|
||||||
# ─ plate ─
|
|
||||||
- { kind: plate, hole_count: 96, class_name: PRCXI_BioER_96_wellplate }
|
|
||||||
- { kind: plate, hole_count: 384, class_name: PRCXI_BioER_384_wellplate }
|
|
||||||
# ─ trash ─
|
|
||||||
- { kind: trash, class_name: PRCXI_trash }
|
|
||||||
models:
|
|
||||||
# PRCXI 9320 —— 与厂商级完全一致(空 dict 仅作为合法 model 名占位)。
|
|
||||||
# caller `--target_model 9320` 时所有字段继承厂商级 prcxi 段。
|
|
||||||
"9320": {}
|
|
||||||
# 演示:假想 PRCXI 4040 把 slot 4 物理位换到 16、trash 槽换到 20。
|
|
||||||
# 仅 slot_remap 不同;rules 与厂商级一致 → 不重复声明(自动继承)。
|
|
||||||
"4040":
|
|
||||||
slot_remap:
|
|
||||||
default:
|
|
||||||
"4": "16"
|
|
||||||
"8": "14"
|
|
||||||
by_object:
|
|
||||||
trash:
|
|
||||||
"12": "20"
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────
|
|
||||||
# 演示:未来加新仪器只复制 prcxi 段、改 device 名 + slot_remap + rules。
|
|
||||||
# 特别注意 tip 量程档可与 PRCXI 不同。
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────
|
|
||||||
# beckman:
|
|
||||||
# slot_remap:
|
|
||||||
# default: {"4": "13"}
|
|
||||||
# by_object: {trash: {"12": "16"}}
|
|
||||||
# rules:
|
|
||||||
# - { kind: tip_rack, hole_count: 96, volume_max: 20, class_name: Beckman_20uL_Tips }
|
|
||||||
# - { kind: tip_rack, hole_count: 96, volume_max: 199.9, class_name: Beckman_200uL_Tips }
|
|
||||||
# - { kind: tip_rack, hole_count: 96, class_name: Beckman_1000uL_Tips }
|
|
||||||
# - { kind: tube_rack, hole_count: 24, class_name: Beckman_24_TubeRack }
|
|
||||||
# - { kind: plate, hole_count: 96, class_name: Beckman_BioMek_96_wellplate }
|
|
||||||
# - { kind: trash, class_name: Beckman_Trash }
|
|
||||||
# models:
|
|
||||||
# "i7":
|
|
||||||
# slot_remap:
|
|
||||||
# default: {"4": "13", "5": "14"} # 假想 i7 多一个 slot 重映射
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
channel_sources:
|
channel_sources:
|
||||||
- robostack,robostack-staging,conda-forge
|
- robostack,robostack-staging,conda-forge,defaults
|
||||||
|
|
||||||
gazebo:
|
gazebo:
|
||||||
- '11'
|
- '11'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package:
|
package:
|
||||||
name: ros-humble-unilabos-msgs
|
name: ros-humble-unilabos-msgs
|
||||||
version: 0.11.1
|
version: 0.10.19
|
||||||
source:
|
source:
|
||||||
path: ../../unilabos_msgs
|
path: ../../unilabos_msgs
|
||||||
target_directory: src
|
target_directory: src
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package:
|
package:
|
||||||
name: unilabos
|
name: unilabos
|
||||||
version: "0.11.1"
|
version: "0.10.19"
|
||||||
|
|
||||||
source:
|
source:
|
||||||
path: ../..
|
path: ../..
|
||||||
|
|||||||
2
setup.py
2
setup.py
@@ -4,7 +4,7 @@ package_name = 'unilabos'
|
|||||||
|
|
||||||
setup(
|
setup(
|
||||||
name=package_name,
|
name=package_name,
|
||||||
version='0.11.1',
|
version='0.10.19',
|
||||||
packages=find_packages(),
|
packages=find_packages(),
|
||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
install_requires=['setuptools'],
|
install_requires=['setuptools'],
|
||||||
|
|||||||
@@ -1,539 +0,0 @@
|
|||||||
import pytest
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import asyncio
|
|
||||||
import collections
|
|
||||||
from typing import List, Dict, Any
|
|
||||||
|
|
||||||
from pylabrobot.resources import Coordinate
|
|
||||||
from pylabrobot.resources.opentrons.tip_racks import opentrons_96_tiprack_300ul, opentrons_96_tiprack_10ul
|
|
||||||
from pylabrobot.resources.opentrons.plates import corning_96_wellplate_360ul_flat, nest_96_wellplate_2ml_deep
|
|
||||||
|
|
||||||
from unilabos.devices.liquid_handling.prcxi.prcxi import (
|
|
||||||
PRCXI9300Deck,
|
|
||||||
PRCXI9300Container,
|
|
||||||
PRCXI9300Trash,
|
|
||||||
PRCXI9300Handler,
|
|
||||||
PRCXI9300Backend,
|
|
||||||
DefaultLayout,
|
|
||||||
Material,
|
|
||||||
WorkTablets,
|
|
||||||
MatrixInfo
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def prcxi_materials() -> Dict[str, Any]:
|
|
||||||
"""加载 PRCXI 物料数据"""
|
|
||||||
print("加载 PRCXI 物料数据...")
|
|
||||||
material_path = os.path.join(os.path.dirname(__file__), "..", "..", "unilabos", "devices", "liquid_handling", "prcxi", "prcxi_material.json")
|
|
||||||
with open(material_path, "r", encoding="utf-8") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
print(f"加载了 {len(data)} 条物料数据")
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def prcxi_9300_deck() -> PRCXI9300Deck:
|
|
||||||
"""创建 PRCXI 9300 工作台"""
|
|
||||||
return PRCXI9300Deck(name="PRCXI_Deck_9300", size_x=100, size_y=100, size_z=100, model="9300")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def prcxi_9320_deck() -> PRCXI9300Deck:
|
|
||||||
"""创建 PRCXI 9320 工作台"""
|
|
||||||
return PRCXI9300Deck(name="PRCXI_Deck_9320", size_x=100, size_y=100, size_z=100, model="9320")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def prcxi_9300_handler(prcxi_9300_deck) -> PRCXI9300Handler:
|
|
||||||
"""创建 PRCXI 9300 处理器(模拟模式)"""
|
|
||||||
return PRCXI9300Handler(
|
|
||||||
deck=prcxi_9300_deck,
|
|
||||||
host="192.168.1.201",
|
|
||||||
port=9999,
|
|
||||||
timeout=10.0,
|
|
||||||
channel_num=8,
|
|
||||||
axis="Left",
|
|
||||||
setup=False,
|
|
||||||
debug=True,
|
|
||||||
simulator=True,
|
|
||||||
matrix_id="test-matrix-9300"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def prcxi_9320_handler(prcxi_9320_deck) -> PRCXI9300Handler:
|
|
||||||
"""创建 PRCXI 9320 处理器(模拟模式)"""
|
|
||||||
return PRCXI9300Handler(
|
|
||||||
deck=prcxi_9320_deck,
|
|
||||||
host="192.168.1.201",
|
|
||||||
port=9999,
|
|
||||||
timeout=10.0,
|
|
||||||
channel_num=1,
|
|
||||||
axis="Right",
|
|
||||||
setup=False,
|
|
||||||
debug=True,
|
|
||||||
simulator=True,
|
|
||||||
matrix_id="test-matrix-9320",
|
|
||||||
is_9320=True
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def tip_rack_300ul(prcxi_materials) -> PRCXI9300Container:
|
|
||||||
"""创建 300μL 枪头盒"""
|
|
||||||
tip_rack = PRCXI9300Container(
|
|
||||||
name="tip_rack_300ul",
|
|
||||||
size_x=50,
|
|
||||||
size_y=50,
|
|
||||||
size_z=10,
|
|
||||||
category="tip_rack",
|
|
||||||
ordering=collections.OrderedDict()
|
|
||||||
)
|
|
||||||
tip_rack.load_state({
|
|
||||||
"Material": {
|
|
||||||
"uuid": prcxi_materials["300μL Tip头"]["uuid"],
|
|
||||||
"Code": "ZX-001-300",
|
|
||||||
"Name": "300μL Tip头"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return tip_rack
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def tip_rack_10ul(prcxi_materials) -> PRCXI9300Container:
|
|
||||||
"""创建 10μL 枪头盒"""
|
|
||||||
tip_rack = PRCXI9300Container(
|
|
||||||
name="tip_rack_10ul",
|
|
||||||
size_x=50,
|
|
||||||
size_y=50,
|
|
||||||
size_z=10,
|
|
||||||
category="tip_rack",
|
|
||||||
ordering=collections.OrderedDict()
|
|
||||||
)
|
|
||||||
tip_rack.load_state({
|
|
||||||
"Material": {
|
|
||||||
"uuid": prcxi_materials["10μL加长 Tip头"]["uuid"],
|
|
||||||
"Code": "ZX-001-10+",
|
|
||||||
"Name": "10μL加长 Tip头"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return tip_rack
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def well_plate_96(prcxi_materials) -> PRCXI9300Container:
|
|
||||||
"""创建 96 孔板"""
|
|
||||||
plate = PRCXI9300Container(
|
|
||||||
name="well_plate_96",
|
|
||||||
size_x=50,
|
|
||||||
size_y=50,
|
|
||||||
size_z=10,
|
|
||||||
category="plate",
|
|
||||||
ordering=collections.OrderedDict()
|
|
||||||
)
|
|
||||||
plate.load_state({
|
|
||||||
"Material": {
|
|
||||||
"uuid": prcxi_materials["96深孔板"]["uuid"],
|
|
||||||
"Code": "ZX-019-2.2",
|
|
||||||
"Name": "96深孔板"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return plate
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def deep_well_plate(prcxi_materials) -> PRCXI9300Container:
|
|
||||||
"""创建深孔板"""
|
|
||||||
plate = PRCXI9300Container(
|
|
||||||
name="deep_well_plate",
|
|
||||||
size_x=50,
|
|
||||||
size_y=50,
|
|
||||||
size_z=10,
|
|
||||||
category="plate",
|
|
||||||
ordering=collections.OrderedDict()
|
|
||||||
)
|
|
||||||
plate.load_state({
|
|
||||||
"Material": {
|
|
||||||
"uuid": prcxi_materials["96深孔板"]["uuid"],
|
|
||||||
"Code": "ZX-019-2.2",
|
|
||||||
"Name": "96深孔板"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return plate
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def trash_container(prcxi_materials) -> PRCXI9300Trash:
|
|
||||||
"""创建垃圾桶"""
|
|
||||||
trash = PRCXI9300Trash(name="trash", size_x=50, size_y=50, size_z=10, category="trash")
|
|
||||||
trash.load_state({
|
|
||||||
"Material": {
|
|
||||||
"uuid": prcxi_materials["废弃槽"]["uuid"]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return trash
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def default_layout_9300() -> DefaultLayout:
|
|
||||||
"""创建 PRCXI 9300 默认布局"""
|
|
||||||
return DefaultLayout("PRCXI9300")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def default_layout_9320() -> DefaultLayout:
|
|
||||||
"""创建 PRCXI 9320 默认布局"""
|
|
||||||
return DefaultLayout("PRCXI9320")
|
|
||||||
|
|
||||||
|
|
||||||
class TestPRCXIDeckSetup:
|
|
||||||
"""测试 PRCXI 工作台设置功能"""
|
|
||||||
|
|
||||||
def test_prcxi_9300_deck_creation(self, prcxi_9300_deck):
|
|
||||||
"""测试 PRCXI 9300 工作台创建"""
|
|
||||||
assert prcxi_9300_deck.name == "PRCXI_Deck_9300"
|
|
||||||
assert len(prcxi_9300_deck.sites) == 6
|
|
||||||
assert prcxi_9300_deck._size_x == 100
|
|
||||||
assert prcxi_9300_deck._size_y == 100
|
|
||||||
assert prcxi_9300_deck._size_z == 100
|
|
||||||
|
|
||||||
def test_prcxi_9320_deck_creation(self, prcxi_9320_deck):
|
|
||||||
"""测试 PRCXI 9320 工作台创建"""
|
|
||||||
assert prcxi_9320_deck.name == "PRCXI_Deck_9320"
|
|
||||||
assert len(prcxi_9320_deck.sites) == 16
|
|
||||||
assert prcxi_9320_deck._size_x == 100
|
|
||||||
assert prcxi_9320_deck._size_y == 100
|
|
||||||
assert prcxi_9320_deck._size_z == 100
|
|
||||||
|
|
||||||
def test_container_assignment(self, prcxi_9300_deck, tip_rack_300ul, well_plate_96, trash_container):
|
|
||||||
"""测试容器分配到工作台"""
|
|
||||||
# 分配枪头盒
|
|
||||||
prcxi_9300_deck.assign_child_resource(tip_rack_300ul, location=Coordinate(0, 0, 0))
|
|
||||||
assert tip_rack_300ul in prcxi_9300_deck.children
|
|
||||||
|
|
||||||
# 分配孔板
|
|
||||||
prcxi_9300_deck.assign_child_resource(well_plate_96, location=Coordinate(0, 0, 0))
|
|
||||||
assert well_plate_96 in prcxi_9300_deck.children
|
|
||||||
|
|
||||||
# 分配垃圾桶
|
|
||||||
prcxi_9300_deck.assign_child_resource(trash_container, location=Coordinate(0, 0, 0))
|
|
||||||
assert trash_container in prcxi_9300_deck.children
|
|
||||||
|
|
||||||
def test_container_material_loading(self, tip_rack_300ul, well_plate_96, prcxi_materials):
|
|
||||||
"""测试容器物料信息加载"""
|
|
||||||
# 测试枪头盒物料信息
|
|
||||||
tip_material = tip_rack_300ul._unilabos_state["Material"]
|
|
||||||
assert tip_material["uuid"] == prcxi_materials["300μL Tip头"]["uuid"]
|
|
||||||
assert tip_material["Name"] == "300μL Tip头"
|
|
||||||
|
|
||||||
# 测试孔板物料信息
|
|
||||||
plate_material = well_plate_96._unilabos_state["Material"]
|
|
||||||
assert plate_material["uuid"] == prcxi_materials["96深孔板"]["uuid"]
|
|
||||||
assert plate_material["Name"] == "96深孔板"
|
|
||||||
|
|
||||||
|
|
||||||
class TestPRCXISingleStepOperations:
|
|
||||||
"""测试 PRCXI 单步操作功能"""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_pick_up_tips_single_channel(self, prcxi_9320_handler, prcxi_9320_deck, tip_rack_10ul):
|
|
||||||
"""测试单通道拾取枪头"""
|
|
||||||
# 将枪头盒添加到工作台
|
|
||||||
prcxi_9320_deck.assign_child_resource(tip_rack_10ul, location=Coordinate(0, 0, 0))
|
|
||||||
|
|
||||||
# 初始化处理器
|
|
||||||
await prcxi_9320_handler.setup()
|
|
||||||
|
|
||||||
# 设置枪头盒
|
|
||||||
prcxi_9320_handler.set_tiprack([tip_rack_10ul])
|
|
||||||
|
|
||||||
# 创建模拟的枪头位置
|
|
||||||
from pylabrobot.resources import TipSpot, Tip
|
|
||||||
tip = Tip(has_filter=False, total_tip_length=10, maximal_volume=10, fitting_depth=5)
|
|
||||||
tip_spot = TipSpot("A1", size_x=1, size_y=1, size_z=1, make_tip=lambda: tip)
|
|
||||||
tip_rack_10ul.assign_child_resource(tip_spot, location=Coordinate(0, 0, 0))
|
|
||||||
|
|
||||||
# 直接测试后端方法
|
|
||||||
from pylabrobot.liquid_handling import Pickup
|
|
||||||
pickup = Pickup(resource=tip_spot, offset=None, tip=tip)
|
|
||||||
await prcxi_9320_handler._unilabos_backend.pick_up_tips([pickup], [0])
|
|
||||||
|
|
||||||
# 验证步骤已添加到待办列表
|
|
||||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
|
||||||
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
|
||||||
assert step["Function"] == "Load"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_pick_up_tips_multi_channel(self, prcxi_9300_handler, tip_rack_300ul):
|
|
||||||
"""测试多通道拾取枪头"""
|
|
||||||
# 设置枪头盒
|
|
||||||
prcxi_9300_handler.set_tiprack([tip_rack_300ul])
|
|
||||||
|
|
||||||
# 拾取8个枪头
|
|
||||||
tip_spots = tip_rack_300ul.children[:8]
|
|
||||||
await prcxi_9300_handler.pick_up_tips(tip_spots, [0, 1, 2, 3, 4, 5, 6, 7])
|
|
||||||
|
|
||||||
# 验证步骤已添加到待办列表
|
|
||||||
assert len(prcxi_9300_handler._unilabos_backend.steps_todo_list) == 1
|
|
||||||
step = prcxi_9300_handler._unilabos_backend.steps_todo_list[0]
|
|
||||||
assert step["Function"] == "Load"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_aspirate_single_channel(self, prcxi_9320_handler, well_plate_96):
|
|
||||||
"""测试单通道吸取液体"""
|
|
||||||
# 设置液体
|
|
||||||
well = well_plate_96.get_item("A1")
|
|
||||||
prcxi_9320_handler.set_liquid([well], ["water"], [50])
|
|
||||||
|
|
||||||
# 吸取液体
|
|
||||||
await prcxi_9320_handler.aspirate([well], [50], [0])
|
|
||||||
|
|
||||||
# 验证步骤已添加到待办列表
|
|
||||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
|
||||||
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
|
||||||
assert step["Function"] == "Imbibing"
|
|
||||||
assert step["DosageNum"] == 50
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_dispense_single_channel(self, prcxi_9320_handler, well_plate_96):
|
|
||||||
"""测试单通道分配液体"""
|
|
||||||
# 分配液体
|
|
||||||
well = well_plate_96.get_item("A1")
|
|
||||||
await prcxi_9320_handler.dispense([well], [25], [0])
|
|
||||||
|
|
||||||
# 验证步骤已添加到待办列表
|
|
||||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
|
||||||
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
|
||||||
assert step["Function"] == "Tapping"
|
|
||||||
assert step["DosageNum"] == 25
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_mix_single_channel(self, prcxi_9320_handler, well_plate_96):
|
|
||||||
"""测试单通道混合液体"""
|
|
||||||
# 混合液体
|
|
||||||
well = well_plate_96.get_item("A1")
|
|
||||||
await prcxi_9320_handler.mix([well], mix_time=3, mix_vol=50)
|
|
||||||
|
|
||||||
# 验证步骤已添加到待办列表
|
|
||||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
|
||||||
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
|
||||||
assert step["Function"] == "Blending"
|
|
||||||
assert step["BlendingTimes"] == 3
|
|
||||||
assert step["DosageNum"] == 50
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_drop_tips_to_trash(self, prcxi_9320_handler, trash_container):
|
|
||||||
"""测试丢弃枪头到垃圾桶"""
|
|
||||||
# 丢弃枪头
|
|
||||||
await prcxi_9320_handler.drop_tips([trash_container], [0])
|
|
||||||
|
|
||||||
# 验证步骤已添加到待办列表
|
|
||||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
|
||||||
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
|
||||||
assert step["Function"] == "UnLoad"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_discard_tips(self, prcxi_9320_handler):
|
|
||||||
"""测试丢弃枪头"""
|
|
||||||
# 丢弃枪头
|
|
||||||
await prcxi_9320_handler.discard_tips([0])
|
|
||||||
|
|
||||||
# 验证步骤已添加到待办列表
|
|
||||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
|
||||||
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
|
||||||
assert step["Function"] == "UnLoad"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_liquid_transfer_workflow(self, prcxi_9320_handler, tip_rack_10ul, well_plate_96):
|
|
||||||
"""测试完整的液体转移工作流程"""
|
|
||||||
# 设置枪头盒和液体
|
|
||||||
prcxi_9320_handler.set_tiprack([tip_rack_10ul])
|
|
||||||
source_well = well_plate_96.get_item("A1")
|
|
||||||
target_well = well_plate_96.get_item("B1")
|
|
||||||
prcxi_9320_handler.set_liquid([source_well], ["water"], [100])
|
|
||||||
|
|
||||||
# 创建协议
|
|
||||||
await prcxi_9320_handler.create_protocol(protocol_name="Test Transfer Protocol")
|
|
||||||
|
|
||||||
# 执行转移流程
|
|
||||||
tip_spot = tip_rack_10ul.get_item("A1")
|
|
||||||
await prcxi_9320_handler.pick_up_tips([tip_spot], [0])
|
|
||||||
await prcxi_9320_handler.aspirate([source_well], [50], [0])
|
|
||||||
await prcxi_9320_handler.dispense([target_well], [50], [0])
|
|
||||||
await prcxi_9320_handler.discard_tips([0])
|
|
||||||
|
|
||||||
# 验证所有步骤都已添加
|
|
||||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 4
|
|
||||||
functions = [step["Function"] for step in prcxi_9320_handler._unilabos_backend.steps_todo_list]
|
|
||||||
assert functions == ["Load", "Imbibing", "Tapping", "UnLoad"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestPRCXILayoutRecommendation:
|
|
||||||
"""测试 PRCXI 板位推荐功能"""
|
|
||||||
|
|
||||||
def test_9300_layout_creation(self, default_layout_9300):
|
|
||||||
"""测试 PRCXI 9300 布局创建"""
|
|
||||||
layout_info = default_layout_9300.get_layout()
|
|
||||||
assert layout_info["rows"] == 2
|
|
||||||
assert layout_info["columns"] == 3
|
|
||||||
assert len(layout_info["layout"]) == 6
|
|
||||||
assert layout_info["trash_slot"] == 6
|
|
||||||
assert "waste_liquid_slot" not in layout_info
|
|
||||||
|
|
||||||
def test_9320_layout_creation(self, default_layout_9320):
|
|
||||||
"""测试 PRCXI 9320 布局创建"""
|
|
||||||
layout_info = default_layout_9320.get_layout()
|
|
||||||
assert layout_info["rows"] == 4
|
|
||||||
assert layout_info["columns"] == 4
|
|
||||||
assert len(layout_info["layout"]) == 16
|
|
||||||
assert layout_info["trash_slot"] == 16
|
|
||||||
assert layout_info["waste_liquid_slot"] == 12
|
|
||||||
|
|
||||||
def test_layout_recommendation_9320(self, default_layout_9320, prcxi_materials):
|
|
||||||
"""测试 PRCXI 9320 板位推荐功能"""
|
|
||||||
# 添加物料信息
|
|
||||||
default_layout_9320.add_lab_resource(prcxi_materials)
|
|
||||||
|
|
||||||
# 推荐布局
|
|
||||||
needs = [
|
|
||||||
("reagent_1", "96 细胞培养皿", 3),
|
|
||||||
("reagent_2", "12道储液槽", 1),
|
|
||||||
("reagent_3", "200μL Tip头", 7),
|
|
||||||
("reagent_4", "10μL加长 Tip头", 1),
|
|
||||||
]
|
|
||||||
|
|
||||||
matrix_layout, layout_list = default_layout_9320.recommend_layout(needs)
|
|
||||||
|
|
||||||
# 验证返回结果
|
|
||||||
assert "MatrixId" in matrix_layout
|
|
||||||
assert "MatrixName" in matrix_layout
|
|
||||||
assert "MatrixCount" in matrix_layout
|
|
||||||
assert "WorkTablets" in matrix_layout
|
|
||||||
assert len(layout_list) == 12 # 3+1+7+1 = 12个位置
|
|
||||||
|
|
||||||
# 验证推荐的位置不包含预留位置
|
|
||||||
reserved_positions = {12, 16}
|
|
||||||
recommended_positions = [item["positions"] for item in layout_list]
|
|
||||||
for pos in recommended_positions:
|
|
||||||
assert pos not in reserved_positions
|
|
||||||
|
|
||||||
def test_layout_recommendation_insufficient_space(self, default_layout_9320, prcxi_materials):
|
|
||||||
"""测试板位推荐空间不足的情况"""
|
|
||||||
# 添加物料信息
|
|
||||||
default_layout_9320.add_lab_resource(prcxi_materials)
|
|
||||||
|
|
||||||
# 尝试推荐超过可用空间的布局
|
|
||||||
needs = [
|
|
||||||
("reagent_1", "96 细胞培养皿", 15), # 需要15个位置,但只有14个可用
|
|
||||||
]
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="需要 .* 个位置,但只有 .* 个可用位置"):
|
|
||||||
default_layout_9320.recommend_layout(needs)
|
|
||||||
|
|
||||||
def test_layout_recommendation_material_not_found(self, default_layout_9320, prcxi_materials):
|
|
||||||
"""测试板位推荐物料不存在的情况"""
|
|
||||||
# 添加物料信息
|
|
||||||
default_layout_9320.add_lab_resource(prcxi_materials)
|
|
||||||
|
|
||||||
# 尝试推荐不存在的物料
|
|
||||||
needs = [
|
|
||||||
("reagent_1", "不存在的物料", 1),
|
|
||||||
]
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Material .* not found in lab resources"):
|
|
||||||
default_layout_9320.recommend_layout(needs)
|
|
||||||
|
|
||||||
|
|
||||||
class TestPRCXIBackendOperations:
|
|
||||||
"""测试 PRCXI 后端操作功能"""
|
|
||||||
|
|
||||||
def test_backend_initialization(self, prcxi_9300_handler):
|
|
||||||
"""测试后端初始化"""
|
|
||||||
backend = prcxi_9300_handler._unilabos_backend
|
|
||||||
assert isinstance(backend, PRCXI9300Backend)
|
|
||||||
assert backend._num_channels == 8
|
|
||||||
assert backend.debug is True
|
|
||||||
|
|
||||||
def test_protocol_creation(self, prcxi_9300_handler):
|
|
||||||
"""测试协议创建"""
|
|
||||||
backend = prcxi_9300_handler._unilabos_backend
|
|
||||||
backend.create_protocol("Test Protocol")
|
|
||||||
assert backend.protocol_name == "Test Protocol"
|
|
||||||
assert len(backend.steps_todo_list) == 0
|
|
||||||
|
|
||||||
def test_channel_validation(self):
|
|
||||||
"""测试通道验证"""
|
|
||||||
# 测试正确的8通道配置
|
|
||||||
valid_channels = [0, 1, 2, 3, 4, 5, 6, 7]
|
|
||||||
result = PRCXI9300Backend.check_channels(valid_channels)
|
|
||||||
assert result == valid_channels
|
|
||||||
|
|
||||||
# 测试错误的通道配置
|
|
||||||
invalid_channels = [0, 1, 2, 3]
|
|
||||||
result = PRCXI9300Backend.check_channels(invalid_channels)
|
|
||||||
assert result == [0, 1, 2, 3, 4, 5, 6, 7]
|
|
||||||
|
|
||||||
def test_matrix_info_creation(self, prcxi_9300_handler):
|
|
||||||
"""测试矩阵信息创建"""
|
|
||||||
backend = prcxi_9300_handler._unilabos_backend
|
|
||||||
backend.create_protocol("Test Protocol")
|
|
||||||
|
|
||||||
# 模拟运行协议时的矩阵信息创建
|
|
||||||
run_time = 1234567890
|
|
||||||
matrix_info = MatrixInfo(
|
|
||||||
MatrixId=f"{int(run_time)}",
|
|
||||||
MatrixName=f"protocol_{run_time}",
|
|
||||||
MatrixCount=len(backend.tablets_info),
|
|
||||||
WorkTablets=backend.tablets_info,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert matrix_info["MatrixId"] == str(int(run_time))
|
|
||||||
assert matrix_info["MatrixName"] == f"protocol_{run_time}"
|
|
||||||
assert "WorkTablets" in matrix_info
|
|
||||||
|
|
||||||
|
|
||||||
class TestPRCXIContainerOperations:
|
|
||||||
"""测试 PRCXI 容器操作功能"""
|
|
||||||
|
|
||||||
def test_container_serialization(self, tip_rack_300ul):
|
|
||||||
"""测试容器序列化"""
|
|
||||||
serialized = tip_rack_300ul.serialize_state()
|
|
||||||
assert "Material" in serialized
|
|
||||||
assert serialized["Material"]["Name"] == "300μL Tip头"
|
|
||||||
|
|
||||||
def test_container_deserialization(self, tip_rack_300ul):
|
|
||||||
"""测试容器反序列化"""
|
|
||||||
# 序列化
|
|
||||||
serialized = tip_rack_300ul.serialize_state()
|
|
||||||
|
|
||||||
# 创建新容器并反序列化
|
|
||||||
new_tip_rack = PRCXI9300Container(
|
|
||||||
name="new_tip_rack",
|
|
||||||
size_x=50,
|
|
||||||
size_y=50,
|
|
||||||
size_z=10,
|
|
||||||
category="tip_rack",
|
|
||||||
ordering=collections.OrderedDict()
|
|
||||||
)
|
|
||||||
new_tip_rack.load_state(serialized)
|
|
||||||
|
|
||||||
assert new_tip_rack._unilabos_state["Material"]["Name"] == "300μL Tip头"
|
|
||||||
|
|
||||||
def test_trash_container_creation(self, prcxi_materials):
|
|
||||||
"""测试垃圾桶容器创建"""
|
|
||||||
trash = PRCXI9300Trash(name="trash", size_x=50, size_y=50, size_z=10, category="trash")
|
|
||||||
trash.load_state({
|
|
||||||
"Material": {
|
|
||||||
"uuid": prcxi_materials["废弃槽"]["uuid"]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
assert trash.name == "trash"
|
|
||||||
assert trash._unilabos_state["Material"]["uuid"] == prcxi_materials["废弃槽"]["uuid"]
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# 运行测试
|
|
||||||
pytest.main([__file__, "-v"])
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# Liquid handling 集成测试
|
|
||||||
|
|
||||||
`test_transfer_liquid.py` 现在会调用 PRCXI 的 RViz 仿真 backend,运行前请确保:
|
|
||||||
|
|
||||||
1. 已安装包含 `pylabrobot`、`rclpy` 的运行环境;
|
|
||||||
2. 启动 ROS 依赖(`rviz` 可选,但是 `rviz_backend` 会创建 ROS 节点);
|
|
||||||
3. 在 shell 中设置 `UNILAB_SIM_TEST=1`,否则 pytest 会自动跳过这些慢速用例:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export UNILAB_SIM_TEST=1
|
|
||||||
pytest tests/devices/liquid_handling/test_transfer_liquid.py -m slow
|
|
||||||
```
|
|
||||||
|
|
||||||
如果只需验证逻辑层(不依赖仿真),可以直接运行 `tests/devices/liquid_handling/unit_test.py`,该文件使用 Fake backend,适合作为 CI 的快速测试。***
|
|
||||||
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
"""P9 — ``liquid_history`` schema v3 + helper 单元测试。
|
|
||||||
|
|
||||||
测试覆盖:
|
|
||||||
- :func:`append_liquid_history`:写 v3 entry / tracker 缺失 graceful / 滚动上限
|
|
||||||
- :func:`normalize_liquid_history`:v3 dict / v2 tuple / list[str] / 混合 / 非法
|
|
||||||
- :func:`well_current_liquid_name`:tracker.liquids 末项 / get_liquids fallback / 缺失
|
|
||||||
|
|
||||||
注:``LiquidHandlerAbstract.set_liquid`` 写 history 的集成("set" action)覆盖
|
|
||||||
逻辑相同(直接调用 :func:`append_liquid_history`),由本测试间接验证;端到端走 PLR
|
|
||||||
真实 ``Well.set_liquids`` 的集成测试在 ``tests/devices/liquid_handling/unit_test.py``
|
|
||||||
范围内随 PLR 环境就绪后增补,本 P9 提交保持解耦。
|
|
||||||
|
|
||||||
详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md`` §8。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any, List, Tuple
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
# liquid_history 模块**不依赖** pylabrobot,可在 PLR 环境缺失时独立 import / 单测。
|
|
||||||
from unilabos.devices.liquid_handling.liquid_history import (
|
|
||||||
LIQUID_HISTORY_MAX_ENTRIES,
|
|
||||||
LiquidHistoryEntry,
|
|
||||||
append_liquid_history,
|
|
||||||
normalize_liquid_history,
|
|
||||||
well_current_liquid_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Fixtures:DummyTracker / DummyWell(避免引入真实 PLR Well/VolumeTracker 依赖)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class DummyTracker:
|
|
||||||
"""模拟 PLR VolumeTracker:仅暴露 P9 hook 关心的字段。"""
|
|
||||||
|
|
||||||
liquid_history: List[Any] = field(default_factory=list)
|
|
||||||
liquids: List[Tuple[Any, float]] = field(default_factory=list)
|
|
||||||
max_volume: float = 200.0
|
|
||||||
is_disabled: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class DummyWell:
|
|
||||||
"""模拟 PLR Well:仅暴露 ``tracker``。"""
|
|
||||||
|
|
||||||
name: str = "well_A1"
|
|
||||||
max_volume: float = 200.0
|
|
||||||
tracker: DummyTracker = field(default_factory=DummyTracker)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# append_liquid_history
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestAppendLiquidHistory:
|
|
||||||
def test_append_creates_v3_entry(self) -> None:
|
|
||||||
well = DummyWell()
|
|
||||||
append_liquid_history(well, "Plasma", 100.0, "set")
|
|
||||||
|
|
||||||
assert len(well.tracker.liquid_history) == 1
|
|
||||||
entry = well.tracker.liquid_history[0]
|
|
||||||
assert entry["name"] == "Plasma"
|
|
||||||
assert entry["volume"] == 100.0
|
|
||||||
assert entry["action"] == "set"
|
|
||||||
assert "timestamp" in entry and isinstance(entry["timestamp"], str)
|
|
||||||
|
|
||||||
def test_append_aspirate_negative_volume(self) -> None:
|
|
||||||
well = DummyWell()
|
|
||||||
append_liquid_history(well, "Water", -50.0, "aspirate")
|
|
||||||
|
|
||||||
assert well.tracker.liquid_history[0]["volume"] == -50.0
|
|
||||||
assert well.tracker.liquid_history[0]["action"] == "aspirate"
|
|
||||||
|
|
||||||
def test_append_with_empty_name_keeps_empty_string(self) -> None:
|
|
||||||
"""name 为空时应写入 ``""`` 而非字面 "unknown"(避免视觉混淆 bottom_type)。"""
|
|
||||||
well = DummyWell()
|
|
||||||
append_liquid_history(well, "", 50.0, "dispense")
|
|
||||||
|
|
||||||
assert well.tracker.liquid_history[0]["name"] == ""
|
|
||||||
|
|
||||||
def test_append_with_none_name_normalized_to_empty_string(self) -> None:
|
|
||||||
well = DummyWell()
|
|
||||||
append_liquid_history(well, None, 50.0, "dispense") # type: ignore[arg-type]
|
|
||||||
|
|
||||||
assert well.tracker.liquid_history[0]["name"] == ""
|
|
||||||
|
|
||||||
def test_append_initializes_history_if_missing(self) -> None:
|
|
||||||
"""tracker 没有 liquid_history 属性时 helper 自动创建空 list 并写入。"""
|
|
||||||
well = DummyWell()
|
|
||||||
del well.tracker.liquid_history # 模拟全新 PLR tracker
|
|
||||||
append_liquid_history(well, "X", 10.0, "set")
|
|
||||||
|
|
||||||
assert hasattr(well.tracker, "liquid_history")
|
|
||||||
assert len(well.tracker.liquid_history) == 1
|
|
||||||
|
|
||||||
def test_append_no_tracker_is_graceful(self) -> None:
|
|
||||||
"""well 无 tracker 时静默不抛(保护主流程)。"""
|
|
||||||
|
|
||||||
class NoTrackerWell:
|
|
||||||
name = "no_tracker"
|
|
||||||
|
|
||||||
well = NoTrackerWell()
|
|
||||||
append_liquid_history(well, "X", 10.0, "set") # 不应抛
|
|
||||||
assert not hasattr(well, "tracker")
|
|
||||||
|
|
||||||
def test_append_action_defaults_to_legacy_when_empty(self) -> None:
|
|
||||||
well = DummyWell()
|
|
||||||
append_liquid_history(well, "X", 1.0, "")
|
|
||||||
|
|
||||||
assert well.tracker.liquid_history[0]["action"] == "legacy"
|
|
||||||
|
|
||||||
def test_append_respects_max_entries_rolling(self) -> None:
|
|
||||||
"""超过 ``LIQUID_HISTORY_MAX_ENTRIES`` 时丢弃头部,保留最近 entries。"""
|
|
||||||
well = DummyWell()
|
|
||||||
well.tracker.liquid_history = [
|
|
||||||
{"name": f"old_{i}"} for i in range(LIQUID_HISTORY_MAX_ENTRIES + 5)
|
|
||||||
]
|
|
||||||
append_liquid_history(well, "newest", 1.0, "set")
|
|
||||||
|
|
||||||
assert len(well.tracker.liquid_history) == LIQUID_HISTORY_MAX_ENTRIES
|
|
||||||
assert well.tracker.liquid_history[-1]["name"] == "newest"
|
|
||||||
assert well.tracker.liquid_history[0]["name"] != "old_0"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# normalize_liquid_history
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestNormalizeLiquidHistory:
|
|
||||||
def test_v3_dict_passthrough_with_field_defaults(self) -> None:
|
|
||||||
raw = [{"name": "A", "volume": 100, "action": "set", "timestamp": "2026-05-22T00:00:00Z"}]
|
|
||||||
result = normalize_liquid_history(raw)
|
|
||||||
|
|
||||||
assert result == [{
|
|
||||||
"name": "A",
|
|
||||||
"volume": 100.0,
|
|
||||||
"action": "set",
|
|
||||||
"timestamp": "2026-05-22T00:00:00Z",
|
|
||||||
}]
|
|
||||||
|
|
||||||
def test_v3_dict_missing_optional_fields_filled_with_defaults(self) -> None:
|
|
||||||
raw = [{"name": "A"}]
|
|
||||||
result = normalize_liquid_history(raw)
|
|
||||||
|
|
||||||
assert result == [{"name": "A", "volume": 0.0, "action": "legacy"}]
|
|
||||||
assert "timestamp" not in result[0]
|
|
||||||
|
|
||||||
def test_v2_tuple_upgraded_to_v3_legacy(self) -> None:
|
|
||||||
raw = [("A", 100), ("B", 50.5)]
|
|
||||||
result = normalize_liquid_history(raw)
|
|
||||||
|
|
||||||
assert result == [
|
|
||||||
{"name": "A", "volume": 100.0, "action": "legacy"},
|
|
||||||
{"name": "B", "volume": 50.5, "action": "legacy"},
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_list_of_strings_upgraded(self) -> None:
|
|
||||||
raw = ["A", "B"]
|
|
||||||
result = normalize_liquid_history(raw)
|
|
||||||
|
|
||||||
assert result == [
|
|
||||||
{"name": "A", "volume": 0.0, "action": "legacy"},
|
|
||||||
{"name": "B", "volume": 0.0, "action": "legacy"},
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_mixed_input_normalized(self) -> None:
|
|
||||||
raw = [
|
|
||||||
{"name": "A", "volume": 1, "action": "set"},
|
|
||||||
("B", 2),
|
|
||||||
"C",
|
|
||||||
]
|
|
||||||
result = normalize_liquid_history(raw)
|
|
||||||
|
|
||||||
assert [e["name"] for e in result] == ["A", "B", "C"]
|
|
||||||
assert [e["action"] for e in result] == ["set", "legacy", "legacy"]
|
|
||||||
|
|
||||||
def test_invalid_entries_dropped(self) -> None:
|
|
||||||
raw = [42, None, {"name": "A"}, ("only_one",)]
|
|
||||||
result = normalize_liquid_history(raw)
|
|
||||||
|
|
||||||
# 只保留 {"name": "A"} 这一条;其它都被丢弃
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0]["name"] == "A"
|
|
||||||
assert result[0]["volume"] == 0.0 # 缺省补 0
|
|
||||||
|
|
||||||
def test_non_list_input_returns_empty(self) -> None:
|
|
||||||
assert normalize_liquid_history(None) == []
|
|
||||||
assert normalize_liquid_history("not_a_list") == []
|
|
||||||
assert normalize_liquid_history({"name": "X"}) == []
|
|
||||||
|
|
||||||
def test_tuple_with_unconvertible_volume_falls_back_to_zero(self) -> None:
|
|
||||||
raw = [("A", "not_a_number")]
|
|
||||||
result = normalize_liquid_history(raw)
|
|
||||||
|
|
||||||
assert result[0]["volume"] == 0.0
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# well_current_liquid_name
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestWellCurrentLiquidName:
|
|
||||||
def test_returns_last_liquid_name_from_tuple(self) -> None:
|
|
||||||
well = DummyWell()
|
|
||||||
well.tracker.liquids = [("Water", 50.0), ("Plasma", 100.0)]
|
|
||||||
assert well_current_liquid_name(well) == "Plasma"
|
|
||||||
|
|
||||||
def test_returns_enum_like_name_attr(self) -> None:
|
|
||||||
class FakeLiquid:
|
|
||||||
name = "ETHANOL"
|
|
||||||
|
|
||||||
well = DummyWell()
|
|
||||||
well.tracker.liquids = [(FakeLiquid(), 100.0)]
|
|
||||||
assert well_current_liquid_name(well) == "ETHANOL"
|
|
||||||
|
|
||||||
def test_empty_liquids_returns_empty_string(self) -> None:
|
|
||||||
well = DummyWell()
|
|
||||||
well.tracker.liquids = []
|
|
||||||
assert well_current_liquid_name(well) == ""
|
|
||||||
|
|
||||||
def test_no_tracker_returns_empty_string(self) -> None:
|
|
||||||
class NoTrackerWell:
|
|
||||||
name = "x"
|
|
||||||
|
|
||||||
assert well_current_liquid_name(NoTrackerWell()) == ""
|
|
||||||
|
|
||||||
def test_none_liquid_returns_empty_string(self) -> None:
|
|
||||||
well = DummyWell()
|
|
||||||
well.tracker.liquids = [(None, 100.0)]
|
|
||||||
assert well_current_liquid_name(well) == ""
|
|
||||||
|
|
||||||
def test_string_liquid_returned_as_is(self) -> None:
|
|
||||||
well = DummyWell()
|
|
||||||
well.tracker.liquids = ["Saline"]
|
|
||||||
assert well_current_liquid_name(well) == "Saline"
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
"""P2 v2 跨板能力验证 —— device 层 ``set_liquid_from_plate`` 单测。
|
|
||||||
|
|
||||||
对应 ``product_designs/protocol_convert/02-cross-slot-merge.md`` §9.1 / §9.5 step 6.3。
|
|
||||||
|
|
||||||
本测试聚焦于 **`_set_liquid_grouped_by_plate`** 已天然支持跨板 wells 的能力(v2 设计
|
|
||||||
的核心依据):
|
|
||||||
|
|
||||||
- 输入 ``wells`` 列表来自多个 plate(每板各一/多个 well)时,``set_liquid`` 应按 plate
|
|
||||||
分桶串行调用,每板一次(plate-bucket 顺序按 first-occurrence)。
|
|
||||||
- 同板内多孔归到同一桶。
|
|
||||||
- 返回 ``volumes`` 按 **输入 index 顺序**回拼,与 wells 一致 —— 这是 v2 Stage 3
|
|
||||||
merged ``set_liquid_from_plate.output_wells`` 的顺序权威来源。
|
|
||||||
- ``Well.set_liquids`` 在 ``set_liquid`` 链内被逐孔调用,与 PLR 实现的预期接口一致。
|
|
||||||
|
|
||||||
为了避免引入完整 PLR 资源树,测试用 duck-typed ``DummyWell`` / ``DummyPlate`` +
|
|
||||||
``ResourceTreeSet`` 的 monkeypatch(dump 直接返回输入列表)。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import List, Tuple
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------------
|
|
||||||
# 跨环境兼容:与现有 ``tests/devices/liquid_handling/test_transfer_liquid.py`` 一致,
|
|
||||||
# 本测试通过 import ``unilabos.devices.liquid_handling.liquid_handler_abstract``
|
|
||||||
# 拉起 pylabrobot 链;某些本地开发机的 pylabrobot 版本与代码库要求不一致,
|
|
||||||
# 会在 import 阶段抛 ``ImportError``。这里用 ``importorskip`` 优雅跳过,让
|
|
||||||
# CI(统一 pylabrobot 版本)跑全;纯逻辑测试(Stage 2 / Stage 3)不受影响。
|
|
||||||
# ----------------------------------------------------------------------
|
|
||||||
LiquidHandlerAbstract = pytest.importorskip(
|
|
||||||
"unilabos.devices.liquid_handling.liquid_handler_abstract",
|
|
||||||
reason="pylabrobot 链未完整可用,跳过 device 单测;CI 上请保证 pylabrobot ≥ 项目要求版本",
|
|
||||||
exc_type=ImportError,
|
|
||||||
).LiquidHandlerAbstract
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== Duck-typed PLR-like 资源 ====================
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class DummyPlate:
|
|
||||||
name: str
|
|
||||||
|
|
||||||
def __repr__(self) -> str: # pragma: no cover
|
|
||||||
return f"DummyPlate({self.name})"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class DummyWell:
|
|
||||||
name: str
|
|
||||||
parent: DummyPlate
|
|
||||||
max_volume: float = 1000.0
|
|
||||||
liquid_history: List[Tuple[str, float]] = field(default_factory=list)
|
|
||||||
|
|
||||||
def set_liquids(self, items):
|
|
||||||
"""模拟 PLR ``Well.set_liquids([(name, vol), ...])`` 接口。"""
|
|
||||||
for name, vol in items:
|
|
||||||
self.liquid_history.append((str(name), float(vol)))
|
|
||||||
|
|
||||||
def __repr__(self) -> str: # pragma: no cover
|
|
||||||
return f"DummyWell({self.parent.name}/{self.name})"
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== fixture:装一台 FakeLiquidHandler ====================
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def patched_resource_tree(monkeypatch):
|
|
||||||
"""patch ``ResourceTreeSet.from_plr_resources`` 使其接受 duck-typed wells/plates。
|
|
||||||
|
|
||||||
返回的对象只要带 ``.dump()`` 即可(``_set_liquid_grouped_by_plate`` 仅消费该方法)。
|
|
||||||
"""
|
|
||||||
from unilabos.devices.liquid_handling import liquid_handler_abstract as lha
|
|
||||||
|
|
||||||
class _FakeTree:
|
|
||||||
def __init__(self, items):
|
|
||||||
self._items = items
|
|
||||||
|
|
||||||
def dump(self):
|
|
||||||
return [
|
|
||||||
{"name": getattr(x, "name", None), "type": type(x).__name__}
|
|
||||||
for x in self._items
|
|
||||||
]
|
|
||||||
|
|
||||||
def _fake_from_plr_resources(items, known_newly_created=False): # noqa: ARG001
|
|
||||||
return _FakeTree(list(items))
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
lha.ResourceTreeSet,
|
|
||||||
"from_plr_resources",
|
|
||||||
staticmethod(_fake_from_plr_resources),
|
|
||||||
)
|
|
||||||
return lha
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def handler(patched_resource_tree):
|
|
||||||
"""构造一台最小 LiquidHandlerAbstract 实例,绕过真实 backend / deck。"""
|
|
||||||
|
|
||||||
class _FakeHandler(LiquidHandlerAbstract):
|
|
||||||
def __init__(self):
|
|
||||||
# 不调用 super().__init__,避免真实硬件/后端依赖
|
|
||||||
self.channel_num = 8
|
|
||||||
self.support_touch_tip = True
|
|
||||||
|
|
||||||
return _FakeHandler()
|
|
||||||
|
|
||||||
|
|
||||||
def _wells_grid(plate_name: str, well_names: List[str]) -> List[DummyWell]:
|
|
||||||
plate = DummyPlate(name=plate_name)
|
|
||||||
return [DummyWell(name=w, parent=plate) for w in well_names]
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== 用例 ====================
|
|
||||||
|
|
||||||
|
|
||||||
def test_grouped_by_plate_single_plate_set_liquid_inline(handler):
|
|
||||||
"""单 plate 多孔:set_liquids 按 wells 顺序逐项调用,volumes 回拼一致。"""
|
|
||||||
wells = _wells_grid("plate_slot2", ["A1", "A2", "A3"])
|
|
||||||
ret = handler._set_liquid_grouped_by_plate(
|
|
||||||
wells=wells,
|
|
||||||
liquid_names=["reagent_X"] * 3,
|
|
||||||
volumes=[10.0, 20.0, 30.0],
|
|
||||||
)
|
|
||||||
|
|
||||||
# 每个 well 的 liquid_history 各 1 条
|
|
||||||
for w, expected_vol in zip(wells, [10.0, 20.0, 30.0]):
|
|
||||||
assert w.liquid_history == [("reagent_X", expected_vol)]
|
|
||||||
|
|
||||||
# 返回 volumes 顺序与输入一致
|
|
||||||
assert ret.volumes == [10.0, 20.0, 30.0]
|
|
||||||
|
|
||||||
|
|
||||||
def test_grouped_by_plate_cross_plate_buckets_by_parent(handler):
|
|
||||||
"""跨板 wells 列表 → 按 first-occurrence plate 顺序分桶,每板单独 set_liquid。
|
|
||||||
|
|
||||||
51b9a5 简化(每板 1 孔):4 plate × 1 well = 4 set_liquids 调用。
|
|
||||||
"""
|
|
||||||
p2 = _wells_grid("plate_slot2", ["A1"])
|
|
||||||
p3 = _wells_grid("plate_slot3", ["A1"])
|
|
||||||
p5 = _wells_grid("plate_slot5", ["A1"])
|
|
||||||
p6 = _wells_grid("plate_slot6", ["A1"])
|
|
||||||
wells = p2 + p3 + p5 + p6
|
|
||||||
|
|
||||||
ret = handler._set_liquid_grouped_by_plate(
|
|
||||||
wells=wells,
|
|
||||||
liquid_names=["l1"] * 4,
|
|
||||||
volumes=[8.3] * 4,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 每个 well 都被 set_liquids 设过
|
|
||||||
for w in wells:
|
|
||||||
assert w.liquid_history == [("l1", 8.3)], f"well {w.parent.name}/{w.name} 未正确设液"
|
|
||||||
|
|
||||||
# volumes 顺序与输入对齐
|
|
||||||
assert ret.volumes == [8.3, 8.3, 8.3, 8.3]
|
|
||||||
|
|
||||||
# plate dump 应含 4 个 plate(按 first-occurrence)
|
|
||||||
plate_dump = ret.plate
|
|
||||||
plate_names = [p["name"] for p in plate_dump]
|
|
||||||
assert plate_names == ["plate_slot2", "plate_slot3", "plate_slot5", "plate_slot6"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_grouped_by_plate_interleaved_cross_plate_preserves_input_order(handler):
|
|
||||||
"""交错跨板:wells=[p2.A1, p3.A1, p2.A2, p5.A1] → volumes 顺序按输入回拼。
|
|
||||||
|
|
||||||
内部仍按 plate 分桶执行 set_liquid(per-plate 串行),但返回顺序遵循输入 index。
|
|
||||||
"""
|
|
||||||
p2 = DummyPlate(name="plate_slot2")
|
|
||||||
p3 = DummyPlate(name="plate_slot3")
|
|
||||||
p5 = DummyPlate(name="plate_slot5")
|
|
||||||
w_p2_a1 = DummyWell(name="A1", parent=p2)
|
|
||||||
w_p2_a2 = DummyWell(name="A2", parent=p2)
|
|
||||||
w_p3_a1 = DummyWell(name="A1", parent=p3)
|
|
||||||
w_p5_a1 = DummyWell(name="A1", parent=p5)
|
|
||||||
|
|
||||||
wells = [w_p2_a1, w_p3_a1, w_p2_a2, w_p5_a1]
|
|
||||||
ret = handler._set_liquid_grouped_by_plate(
|
|
||||||
wells=wells,
|
|
||||||
liquid_names=["l1"] * 4,
|
|
||||||
volumes=[10.0, 20.0, 30.0, 40.0],
|
|
||||||
)
|
|
||||||
|
|
||||||
# 每个 well 都被设液
|
|
||||||
assert w_p2_a1.liquid_history == [("l1", 10.0)]
|
|
||||||
assert w_p3_a1.liquid_history == [("l1", 20.0)]
|
|
||||||
assert w_p2_a2.liquid_history == [("l1", 30.0)]
|
|
||||||
assert w_p5_a1.liquid_history == [("l1", 40.0)]
|
|
||||||
|
|
||||||
# 返回 volumes 严格按输入 index 顺序回拼
|
|
||||||
assert ret.volumes == [10.0, 20.0, 30.0, 40.0]
|
|
||||||
|
|
||||||
# plate dump:按 first-occurrence(plate_slot2 第 1 次出现于 idx=0,plate_slot3 idx=1,plate_slot5 idx=3)
|
|
||||||
plate_names = [p["name"] for p in ret.plate]
|
|
||||||
assert plate_names == ["plate_slot2", "plate_slot3", "plate_slot5"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_grouped_by_plate_volumes_clamped_to_max_volume(handler):
|
|
||||||
"""``set_liquid`` 会按 ``max_volume`` 做 clamp,防止初始化液量超容器容量。"""
|
|
||||||
plate = DummyPlate(name="plate_slot2")
|
|
||||||
well = DummyWell(name="A1", parent=plate, max_volume=200.0)
|
|
||||||
|
|
||||||
ret = handler._set_liquid_grouped_by_plate(
|
|
||||||
wells=[well],
|
|
||||||
liquid_names=["overflow"],
|
|
||||||
volumes=[500.0], # 超过 max_volume=200
|
|
||||||
)
|
|
||||||
|
|
||||||
assert well.liquid_history == [("overflow", 200.0)]
|
|
||||||
assert ret.volumes == [200.0]
|
|
||||||
|
|
||||||
|
|
||||||
def test_grouped_by_plate_empty_names_short_circuit(handler):
|
|
||||||
"""``liquid_names`` 与 ``volumes`` 均为空:早返回,wells 列表回显但不设液。"""
|
|
||||||
wells = _wells_grid("plate_slot2", ["A1", "A2"])
|
|
||||||
ret = handler._set_liquid_grouped_by_plate(
|
|
||||||
wells=wells,
|
|
||||||
liquid_names=[],
|
|
||||||
volumes=[],
|
|
||||||
)
|
|
||||||
# 不调用 set_liquids
|
|
||||||
assert all(w.liquid_history == [] for w in wells)
|
|
||||||
assert ret.volumes == []
|
|
||||||
# wells dump 仍返回输入列表
|
|
||||||
assert [w["name"] for w in ret.wells] == ["A1", "A2"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_grouped_by_plate_length_mismatch_raises(handler):
|
|
||||||
"""wells / liquid_names / volumes 长度不一致应直接 raise(防御性校验)。"""
|
|
||||||
wells = _wells_grid("plate_slot2", ["A1", "A2"])
|
|
||||||
with pytest.raises(ValueError, match=r"必须等长"):
|
|
||||||
handler._set_liquid_grouped_by_plate(
|
|
||||||
wells=wells,
|
|
||||||
liquid_names=["r"] * 2,
|
|
||||||
volumes=[10.0], # 长度 1,不匹配
|
|
||||||
)
|
|
||||||
@@ -1,566 +0,0 @@
|
|||||||
"""P10 v2 — Tip 复用 ``tracker.liquids`` 等价规则单元测试。
|
|
||||||
|
|
||||||
测试覆盖(详见 ``product_designs/protocol_convert/10-tip-reuse-by-liquid-history.md`` §5):
|
|
||||||
|
|
||||||
- Helper:``is_known_liquid_name`` / ``same_liquid_via_liquids`` /
|
|
||||||
``same_liquid_via_liquids_pair`` / ``capture_tip_liquid_name``(4 helper
|
|
||||||
位于 ``liquid_history.py``,PLR-free 模块)。
|
|
||||||
- 单通道 transfer_liquid 主循环:identity-keep / liquids-keep / 配置开关 /
|
|
||||||
未知 name 保守换 tip / aspirate 顶层归零时序。
|
|
||||||
- 8 通道分支:段锚孔 liquids-keep。
|
|
||||||
- 跨节点边界:两个独立 transfer_liquid 调用状态隔离。
|
|
||||||
|
|
||||||
helper 测试独立于 PLR,可在 ``pylabrobot`` 缺失环境下单独运行;端到端
|
|
||||||
``transfer_liquid`` 主循环测试需要 PLR 环境(沿用 ``test_transfer_liquid.py`` 的
|
|
||||||
``FakeLiquidHandler`` 模式:跳过 ``super().__init__``,仅 stub 4 类方法记录调用)。
|
|
||||||
若 PLR import 失败则自动 skip 端到端测试,保留 helper 测试结果。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any, Iterable, List, Optional, Sequence, Tuple
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
# P10 v2 helper 位于 PLR-free 模块,无论 pylabrobot 是否安装都能 import。
|
|
||||||
from unilabos.devices.liquid_handling.liquid_history import (
|
|
||||||
capture_tip_liquid_name,
|
|
||||||
is_known_liquid_name,
|
|
||||||
same_liquid_via_liquids,
|
|
||||||
same_liquid_via_liquids_pair,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 端到端测试依赖 PLR 完整环境;若 import 失败(例如本地 PLR 版本不匹配),
|
|
||||||
# 整段端到端测试自动 skip,但 helper 测试照常执行。
|
|
||||||
try:
|
|
||||||
from unilabos.devices.liquid_handling.liquid_handler_abstract import (
|
|
||||||
LiquidHandlerAbstract,
|
|
||||||
)
|
|
||||||
|
|
||||||
_PLR_AVAILABLE = True
|
|
||||||
_PLR_IMPORT_ERROR: Optional[Exception] = None
|
|
||||||
except Exception as exc: # pragma: no cover - 环境相关
|
|
||||||
LiquidHandlerAbstract = None # type: ignore[assignment, misc]
|
|
||||||
_PLR_AVAILABLE = False
|
|
||||||
_PLR_IMPORT_ERROR = exc
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Fixtures:DummyTracker / DummyWell / DummyTipSpot / FakeLiquidHandler
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class DummyTracker:
|
|
||||||
"""模拟 PLR ``VolumeTracker``:仅暴露 P10 v2 关心的 ``liquids`` 字段。"""
|
|
||||||
|
|
||||||
liquids: List[Tuple[Any, float]] = field(default_factory=list)
|
|
||||||
max_volume: float = 200.0
|
|
||||||
is_disabled: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class DummyWell:
|
|
||||||
"""模拟 PLR ``Well``:仅暴露 ``tracker``。"""
|
|
||||||
|
|
||||||
name: str = "well"
|
|
||||||
tracker: DummyTracker = field(default_factory=DummyTracker)
|
|
||||||
|
|
||||||
def __repr__(self) -> str: # pragma: no cover
|
|
||||||
return f"DummyWell({self.name})"
|
|
||||||
|
|
||||||
|
|
||||||
def make_well(name: str, liquid_name: Optional[str] = None, vol: float = 100.0) -> DummyWell:
|
|
||||||
"""构造一个 well;若指定 ``liquid_name`` 则写入 ``tracker.liquids`` 顶层。"""
|
|
||||||
well = DummyWell(name=name, tracker=DummyTracker())
|
|
||||||
if liquid_name is not None:
|
|
||||||
well.tracker.liquids = [(liquid_name, vol)]
|
|
||||||
return well
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class DummyTipSpot:
|
|
||||||
name: str
|
|
||||||
|
|
||||||
|
|
||||||
def make_tip_iter(n: int = 256) -> Iterable[List[DummyTipSpot]]:
|
|
||||||
for i in range(n):
|
|
||||||
yield [DummyTipSpot(f"tip_{i}")]
|
|
||||||
|
|
||||||
|
|
||||||
# E2E 测试用的 base:PLR 可用时是 ``LiquidHandlerAbstract``,否则 fallback 到
|
|
||||||
# ``object`` 让模块仍能 import;带 ``LiquidHandlerAbstract`` 的 e2e 测试用
|
|
||||||
# ``skipif`` 跳过。
|
|
||||||
_FakeBase = LiquidHandlerAbstract if _PLR_AVAILABLE else object
|
|
||||||
|
|
||||||
|
|
||||||
class FakeLiquidHandler(_FakeBase): # type: ignore[misc, valid-type]
|
|
||||||
"""不初始化真实 backend/deck;仅记录 transfer_liquid 内部 4 类调用序列。
|
|
||||||
|
|
||||||
P10 v2 测试关心 ``pick_up_tips`` / ``discard_tips`` 的触发次数 + 顺序,
|
|
||||||
以推断 tip 是否被复用(一次 pick_up_tips 多次 aspirate/dispense → 复用)。
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, channel_num: int = 1, tip_reuse_by_liquid_name: bool = True):
|
|
||||||
# 不调用 super().__init__,避免硬件 / ROS / PLR Deck 初始化。
|
|
||||||
self.channel_num = channel_num
|
|
||||||
self.support_touch_tip = True
|
|
||||||
self.current_tip = iter(make_tip_iter(2048))
|
|
||||||
self.calls: List[Tuple[str, Any]] = []
|
|
||||||
self._tip_reuse_by_liquid_name: bool = tip_reuse_by_liquid_name
|
|
||||||
|
|
||||||
def set_tiprack(self, tip_racks):
|
|
||||||
if not tip_racks:
|
|
||||||
return
|
|
||||||
# 跳过真实 set_tiprack(依赖 PLR Deck)
|
|
||||||
return
|
|
||||||
|
|
||||||
async def pick_up_tips(self, tip_spots, use_channels=None, offsets=None, **kw):
|
|
||||||
self.calls.append(("pick_up_tips", {"tips": list(tip_spots), "use_channels": use_channels}))
|
|
||||||
|
|
||||||
async def aspirate(
|
|
||||||
self,
|
|
||||||
resources: Sequence[Any],
|
|
||||||
vols: List[float],
|
|
||||||
use_channels: Optional[List[int]] = None,
|
|
||||||
flow_rates: Optional[List[Optional[float]]] = None,
|
|
||||||
offsets: Any = None,
|
|
||||||
liquid_height: Any = None,
|
|
||||||
blow_out_air_volume: Any = None,
|
|
||||||
spread: str = "wide",
|
|
||||||
**backend_kwargs,
|
|
||||||
):
|
|
||||||
self.calls.append(
|
|
||||||
("aspirate", {"resources": list(resources), "vols": list(vols)})
|
|
||||||
)
|
|
||||||
|
|
||||||
async def dispense(
|
|
||||||
self,
|
|
||||||
resources: Sequence[Any],
|
|
||||||
vols: List[float],
|
|
||||||
use_channels: Optional[List[int]] = None,
|
|
||||||
flow_rates: Optional[List[Optional[float]]] = None,
|
|
||||||
offsets: Any = None,
|
|
||||||
liquid_height: Any = None,
|
|
||||||
blow_out_air_volume: Any = None,
|
|
||||||
spread: str = "wide",
|
|
||||||
**backend_kwargs,
|
|
||||||
):
|
|
||||||
self.calls.append(
|
|
||||||
("dispense", {"resources": list(resources), "vols": list(vols)})
|
|
||||||
)
|
|
||||||
|
|
||||||
async def discard_tips(self, use_channels=None, *args, **kwargs):
|
|
||||||
self.calls.append(("discard_tips", {"use_channels": use_channels}))
|
|
||||||
|
|
||||||
|
|
||||||
class AspiratePopFakeLiquidHandler(FakeLiquidHandler):
|
|
||||||
"""T11 专用:aspirate 时模拟 PLR "顶层归零时 pop ``tracker.liquids`` 顶层" 的行为。
|
|
||||||
|
|
||||||
用于验证 P10 v2 的关键时序约束:tip name 必须在 aspirate **之前**预读,
|
|
||||||
否则 aspirate 后再读 ``tracker.liquids[-1]`` 会拿不到液体身份。
|
|
||||||
"""
|
|
||||||
|
|
||||||
async def aspirate(self, resources, vols, **kwargs):
|
|
||||||
await super().aspirate(resources, vols, **kwargs)
|
|
||||||
# 模拟 PLR 顶层归零时 pop:对每个 source well,若 liquids 非空则 pop 顶层
|
|
||||||
for r in resources:
|
|
||||||
tracker = getattr(r, "tracker", None)
|
|
||||||
if tracker is not None and tracker.liquids:
|
|
||||||
tracker.liquids.pop()
|
|
||||||
|
|
||||||
|
|
||||||
def run(coro):
|
|
||||||
return asyncio.run(coro)
|
|
||||||
|
|
||||||
|
|
||||||
def call_names(lh: FakeLiquidHandler) -> List[str]:
|
|
||||||
return [c[0] for c in lh.calls]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Helper 单元测试
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestIsKnownLiquidName:
|
|
||||||
def test_empty_string_is_unknown(self) -> None:
|
|
||||||
assert is_known_liquid_name("") is False
|
|
||||||
|
|
||||||
def test_none_is_unknown(self) -> None:
|
|
||||||
assert is_known_liquid_name(None) is False
|
|
||||||
|
|
||||||
def test_literal_unknown_is_unknown(self) -> None:
|
|
||||||
assert is_known_liquid_name("unknown") is False
|
|
||||||
assert is_known_liquid_name("UNKNOWN") is False
|
|
||||||
assert is_known_liquid_name(" Unknown ") is False
|
|
||||||
|
|
||||||
def test_literal_none_string_is_unknown(self) -> None:
|
|
||||||
assert is_known_liquid_name("none") is False
|
|
||||||
assert is_known_liquid_name("None") is False
|
|
||||||
|
|
||||||
def test_real_liquid_name_is_known(self) -> None:
|
|
||||||
assert is_known_liquid_name("PBS") is True
|
|
||||||
assert is_known_liquid_name("Tris HCl") is True
|
|
||||||
assert is_known_liquid_name("Liquid_3") is True
|
|
||||||
|
|
||||||
|
|
||||||
class TestSameLiquidViaLiquids:
|
|
||||||
def test_well_and_tip_same_name_match(self) -> None:
|
|
||||||
well = make_well("A1", "PBS")
|
|
||||||
assert same_liquid_via_liquids(well, "PBS") is True
|
|
||||||
|
|
||||||
def test_well_and_tip_different_names_no_match(self) -> None:
|
|
||||||
well = make_well("A1", "PBS")
|
|
||||||
assert same_liquid_via_liquids(well, "Tris HCl") is False
|
|
||||||
|
|
||||||
def test_tip_unknown_returns_false(self) -> None:
|
|
||||||
well = make_well("A1", "PBS")
|
|
||||||
assert same_liquid_via_liquids(well, None) is False
|
|
||||||
assert same_liquid_via_liquids(well, "") is False
|
|
||||||
assert same_liquid_via_liquids(well, "unknown") is False
|
|
||||||
|
|
||||||
def test_well_empty_liquids_returns_false(self) -> None:
|
|
||||||
well = make_well("A1", liquid_name=None) # 不写 liquids
|
|
||||||
assert same_liquid_via_liquids(well, "PBS") is False
|
|
||||||
|
|
||||||
def test_well_unknown_literal_returns_false(self) -> None:
|
|
||||||
well = make_well("A1", "unknown")
|
|
||||||
assert same_liquid_via_liquids(well, "unknown") is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestSameLiquidViaLiquidsPair:
|
|
||||||
def test_two_wells_same_name_match(self) -> None:
|
|
||||||
a = make_well("A1", "PBS")
|
|
||||||
b = make_well("B1", "PBS")
|
|
||||||
assert same_liquid_via_liquids_pair(a, b) is True
|
|
||||||
|
|
||||||
def test_two_wells_different_names_no_match(self) -> None:
|
|
||||||
a = make_well("A1", "PBS")
|
|
||||||
b = make_well("B1", "Tris HCl")
|
|
||||||
assert same_liquid_via_liquids_pair(a, b) is False
|
|
||||||
|
|
||||||
def test_either_well_empty_returns_false(self) -> None:
|
|
||||||
a = make_well("A1", "PBS")
|
|
||||||
b = make_well("B1", liquid_name=None)
|
|
||||||
assert same_liquid_via_liquids_pair(a, b) is False
|
|
||||||
assert same_liquid_via_liquids_pair(b, a) is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestCaptureTipLiquidName:
|
|
||||||
def test_known_name_returned(self) -> None:
|
|
||||||
well = make_well("A1", "PBS")
|
|
||||||
assert capture_tip_liquid_name(well) == "PBS"
|
|
||||||
|
|
||||||
def test_empty_well_returns_none(self) -> None:
|
|
||||||
well = make_well("A1", liquid_name=None)
|
|
||||||
assert capture_tip_liquid_name(well) is None
|
|
||||||
|
|
||||||
def test_unknown_literal_returns_none(self) -> None:
|
|
||||||
well = make_well("A1", "unknown")
|
|
||||||
assert capture_tip_liquid_name(well) is None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# T1–T12 端到端测试(单通道 transfer_liquid 主循环)
|
|
||||||
#
|
|
||||||
# 需要 PLR 完整环境(``pylabrobot.liquid_handling.LiquidHandlerBackend`` 等)。
|
|
||||||
# 若 PLR import 失败则整段 skip,helper 测试照常运行。
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_skip_if_no_plr = pytest.mark.skipif(
|
|
||||||
not _PLR_AVAILABLE,
|
|
||||||
reason=f"pylabrobot import failed: {_PLR_IMPORT_ERROR}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@_skip_if_no_plr
|
|
||||||
class TestSingleChannelTipReuse:
|
|
||||||
"""覆盖 §5 矩阵 T1 / T2 / T3 / T4 / T5 / T6 / T8 / T10 / T11。"""
|
|
||||||
|
|
||||||
def test_T1_identity_hit_reuses_tip(self) -> None:
|
|
||||||
"""T1:连续 2 轮同 source/target → identity-keep 命中,复用 tip。"""
|
|
||||||
lh = FakeLiquidHandler(channel_num=1)
|
|
||||||
src = make_well("S0", "PBS")
|
|
||||||
tgt = make_well("T0")
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=[src, src],
|
|
||||||
targets=[tgt, tgt],
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=[0],
|
|
||||||
asp_vols=[1, 1],
|
|
||||||
dis_vols=[1, 1],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
# 2 次 transfer,但 identity-keep → 仅 1 次 pick_up_tips / 1 次 discard_tips
|
|
||||||
assert call_names(lh).count("pick_up_tips") == 1
|
|
||||||
assert call_names(lh).count("discard_tips") == 1
|
|
||||||
assert call_names(lh).count("aspirate") == 2
|
|
||||||
assert call_names(lh).count("dispense") == 2
|
|
||||||
|
|
||||||
def test_T2_liquids_hit_across_plates(self) -> None:
|
|
||||||
"""T2:9 个独立 source well(不同 PLR Well 对象)都装 PBS → identity 全 fail,liquids-keep 全命中。"""
|
|
||||||
lh = FakeLiquidHandler(channel_num=1)
|
|
||||||
sources = [make_well(f"S{i}", "PBS") for i in range(9)]
|
|
||||||
targets = [make_well(f"T{i}") for i in range(9)]
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=targets,
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=[0],
|
|
||||||
asp_vols=[1] * 9,
|
|
||||||
dis_vols=[1] * 9,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
# 9 个 source 物理上同液 → 整段共用 1 个 tip
|
|
||||||
assert call_names(lh).count("pick_up_tips") == 1
|
|
||||||
assert call_names(lh).count("discard_tips") == 1
|
|
||||||
assert call_names(lh).count("aspirate") == 9
|
|
||||||
assert call_names(lh).count("dispense") == 9
|
|
||||||
|
|
||||||
def test_T3_liquids_hit_same_plate_different_wells(self) -> None:
|
|
||||||
"""T3:同 plate 上 A1-H1 都装 PBS(8 个不同 Well 对象)→ identity 全 fail,liquids-keep 命中。"""
|
|
||||||
lh = FakeLiquidHandler(channel_num=1)
|
|
||||||
sources = [make_well(f"A{i}", "PBS") for i in range(1, 9)]
|
|
||||||
targets = [make_well(f"T{i}") for i in range(8)]
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=targets,
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=[0],
|
|
||||||
asp_vols=[1] * 8,
|
|
||||||
dis_vols=[1] * 8,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
assert call_names(lh).count("pick_up_tips") == 1
|
|
||||||
assert call_names(lh).count("discard_tips") == 1
|
|
||||||
|
|
||||||
def test_T4_liquids_not_match_forces_tip_change(self) -> None:
|
|
||||||
"""T4:A1=PBS,B1=Tris HCl → liquids 名不等,强制换 tip。"""
|
|
||||||
lh = FakeLiquidHandler(channel_num=1)
|
|
||||||
sources = [make_well("A1", "PBS"), make_well("B1", "Tris HCl")]
|
|
||||||
targets = [make_well("T0"), make_well("T1")]
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=targets,
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=[0],
|
|
||||||
asp_vols=[1, 1],
|
|
||||||
dis_vols=[1, 1],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
# 2 次完全独立的 transfer:2 次 pick_up / 2 次 discard
|
|
||||||
assert call_names(lh).count("pick_up_tips") == 2
|
|
||||||
assert call_names(lh).count("discard_tips") == 2
|
|
||||||
|
|
||||||
def test_T5_empty_liquids_forces_tip_change(self) -> None:
|
|
||||||
"""T5:source 从未调过 set_liquids(liquids 空)→ 视为未知,强制换 tip。"""
|
|
||||||
lh = FakeLiquidHandler(channel_num=1)
|
|
||||||
sources = [make_well("A1"), make_well("B1")] # 没装液体名
|
|
||||||
targets = [make_well("T0"), make_well("T1")]
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=targets,
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=[0],
|
|
||||||
asp_vols=[1, 1],
|
|
||||||
dis_vols=[1, 1],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
assert call_names(lh).count("pick_up_tips") == 2
|
|
||||||
assert call_names(lh).count("discard_tips") == 2
|
|
||||||
|
|
||||||
def test_T6_switch_off_disables_liquids_keep(self) -> None:
|
|
||||||
"""T6:tip_reuse_by_liquid_name=False,T2 场景退化为 identity-only,强制换 tip。"""
|
|
||||||
lh = FakeLiquidHandler(channel_num=1, tip_reuse_by_liquid_name=False)
|
|
||||||
sources = [make_well(f"S{i}", "PBS") for i in range(9)]
|
|
||||||
targets = [make_well(f"T{i}") for i in range(9)]
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=targets,
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=[0],
|
|
||||||
asp_vols=[1] * 9,
|
|
||||||
dis_vols=[1] * 9,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
# 关闭开关后 → 退化为 identity-only,9 次独立换 tip
|
|
||||||
assert call_names(lh).count("pick_up_tips") == 9
|
|
||||||
assert call_names(lh).count("discard_tips") == 9
|
|
||||||
|
|
||||||
def test_T8_mix_style_same_source_reuses_via_identity(self) -> None:
|
|
||||||
"""T8:单 source 反复 aspirate/dispense → identity-keep 命中(mix-style)。"""
|
|
||||||
lh = FakeLiquidHandler(channel_num=1)
|
|
||||||
src = make_well("S0", "Methanol")
|
|
||||||
tgt = make_well("T0")
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=[src, src, src],
|
|
||||||
targets=[tgt, tgt, tgt],
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=[0],
|
|
||||||
asp_vols=[1, 1, 1],
|
|
||||||
dis_vols=[1, 1, 1],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
assert call_names(lh).count("pick_up_tips") == 1
|
|
||||||
assert call_names(lh).count("discard_tips") == 1
|
|
||||||
|
|
||||||
def test_T10_unknown_literal_treated_as_unknown(self) -> None:
|
|
||||||
"""T10:``tracker.liquids = [("unknown", v)]``(兼容旧数据)→ 视为未知,强制换 tip。"""
|
|
||||||
lh = FakeLiquidHandler(channel_num=1)
|
|
||||||
sources = [make_well("A1", "unknown"), make_well("B1", "unknown")]
|
|
||||||
targets = [make_well("T0"), make_well("T1")]
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=targets,
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=[0],
|
|
||||||
asp_vols=[1, 1],
|
|
||||||
dis_vols=[1, 1],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
assert call_names(lh).count("pick_up_tips") == 2
|
|
||||||
assert call_names(lh).count("discard_tips") == 2
|
|
||||||
|
|
||||||
def test_T11_aspirate_pop_timing_pre_read(self) -> None:
|
|
||||||
"""T11:aspirate 顶层归零 → PLR pop ``tracker.liquids`` 顶层;
|
|
||||||
验证 P10 v2 ``pending_tip_name`` 必须在 aspirate **之前**预读才能命中下一轮。
|
|
||||||
"""
|
|
||||||
lh = AspiratePopFakeLiquidHandler(channel_num=1)
|
|
||||||
sources = [make_well(f"S{i}", "PBS") for i in range(3)]
|
|
||||||
targets = [make_well(f"T{i}") for i in range(3)]
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=targets,
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=[0],
|
|
||||||
asp_vols=[1] * 3,
|
|
||||||
dis_vols=[1] * 3,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
# 即使 aspirate 后 source.tracker.liquids 被 pop,pending_tip_name 已捕获 "PBS"
|
|
||||||
# → 下一轮 source 仍是 PBS(aspirate 还没发生),liquids-keep 命中
|
|
||||||
# → 整段 1 次 pick_up_tips
|
|
||||||
assert call_names(lh).count("pick_up_tips") == 1
|
|
||||||
assert call_names(lh).count("discard_tips") == 1
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# T7:跨节点边界(两个独立 transfer_liquid 调用,状态隔离)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@_skip_if_no_plr
|
|
||||||
class TestCrossNodeBoundary:
|
|
||||||
"""T7:两个 transfer_liquid 节点之间不复用 tip(每次调用初始化 current_tip_liquid_name=None)。"""
|
|
||||||
|
|
||||||
def test_T7_two_calls_dont_share_tip_state(self) -> None:
|
|
||||||
lh = FakeLiquidHandler(channel_num=1)
|
|
||||||
src_a = make_well("A_src", "PBS")
|
|
||||||
tgt_a = make_well("A_tgt")
|
|
||||||
src_b = make_well("B_src", "PBS") # 同名液,但不同 well
|
|
||||||
tgt_b = make_well("B_tgt")
|
|
||||||
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=[src_a],
|
|
||||||
targets=[tgt_a],
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=[0],
|
|
||||||
asp_vols=[1],
|
|
||||||
dis_vols=[1],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=[src_b],
|
|
||||||
targets=[tgt_b],
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=[0],
|
|
||||||
asp_vols=[1],
|
|
||||||
dis_vols=[1],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
# 两次调用各自独立换 tip → 2 次 pick_up_tips / 2 次 discard_tips
|
|
||||||
assert call_names(lh).count("pick_up_tips") == 2
|
|
||||||
assert call_names(lh).count("discard_tips") == 2
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# T9:8 通道段锚孔 liquids-keep
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@_skip_if_no_plr
|
|
||||||
class TestEightChannelSegmentTipReuse:
|
|
||||||
"""T9:8 通道分段,连续两段 src_slice[0] 同名 → 段间不换 tip。"""
|
|
||||||
|
|
||||||
def test_T9_two_segments_same_anchor_liquid(self) -> None:
|
|
||||||
lh = FakeLiquidHandler(channel_num=8)
|
|
||||||
# 16 个 source wells,分 2 段;段 1 锚孔 = sources[0],段 2 锚孔 = sources[8]
|
|
||||||
sources = [make_well(f"S{i}", "PBS") for i in range(16)]
|
|
||||||
targets = [make_well(f"T{i}") for i in range(16)]
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=targets,
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=list(range(8)),
|
|
||||||
asp_vols=[1] * 16,
|
|
||||||
dis_vols=[1] * 16,
|
|
||||||
mix_times=0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
# 2 段都同液 → liquids-keep 命中 → 仅 1 次 pick_up_tips
|
|
||||||
assert call_names(lh).count("pick_up_tips") == 1
|
|
||||||
assert call_names(lh).count("discard_tips") == 1
|
|
||||||
|
|
||||||
def test_T9b_two_segments_different_anchor_liquid_forces_tip_change(self) -> None:
|
|
||||||
"""T9b:段 1 锚孔 = PBS,段 2 锚孔 = Tris → 段间强制换 tip。"""
|
|
||||||
lh = FakeLiquidHandler(channel_num=8)
|
|
||||||
seg1 = [make_well(f"S{i}", "PBS") for i in range(8)]
|
|
||||||
seg2 = [make_well(f"S{i + 8}", "Tris HCl") for i in range(8)]
|
|
||||||
sources = seg1 + seg2
|
|
||||||
targets = [make_well(f"T{i}") for i in range(16)]
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=targets,
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=list(range(8)),
|
|
||||||
asp_vols=[1] * 16,
|
|
||||||
dis_vols=[1] * 16,
|
|
||||||
mix_times=0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
# 2 段不同液 → 2 次独立换 tip
|
|
||||||
assert call_names(lh).count("pick_up_tips") == 2
|
|
||||||
assert call_names(lh).count("discard_tips") == 2
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 配置开关默认值 / 实例字段读取
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@_skip_if_no_plr
|
|
||||||
class TestConfigDefault:
|
|
||||||
def test_default_switch_is_on(self) -> None:
|
|
||||||
"""默认 ``_tip_reuse_by_liquid_name`` 应为 True(测试 fixture 显式 default 一致)。"""
|
|
||||||
lh = FakeLiquidHandler()
|
|
||||||
assert lh._tip_reuse_by_liquid_name is True
|
|
||||||
|
|
||||||
def test_switch_off_takes_effect(self) -> None:
|
|
||||||
lh = FakeLiquidHandler(tip_reuse_by_liquid_name=False)
|
|
||||||
assert lh._tip_reuse_by_liquid_name is False
|
|
||||||
@@ -39,11 +39,6 @@ class FakeLiquidHandler(LiquidHandlerAbstract):
|
|||||||
self.current_tip = iter(make_tip_iter())
|
self.current_tip = iter(make_tip_iter())
|
||||||
self.calls: List[Tuple[str, Any]] = []
|
self.calls: List[Tuple[str, Any]] = []
|
||||||
|
|
||||||
def set_tiprack(self, tip_racks):
|
|
||||||
if not tip_racks:
|
|
||||||
return
|
|
||||||
super().set_tiprack(tip_racks)
|
|
||||||
|
|
||||||
async def pick_up_tips(self, tip_spots, use_channels=None, offsets=None, **backend_kwargs):
|
async def pick_up_tips(self, tip_spots, use_channels=None, offsets=None, **backend_kwargs):
|
||||||
self.calls.append(("pick_up_tips", {"tips": list(tip_spots), "use_channels": use_channels}))
|
self.calls.append(("pick_up_tips", {"tips": list(tip_spots), "use_channels": use_channels}))
|
||||||
|
|
||||||
|
|||||||
@@ -1,608 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any, Iterable, List, Optional, Sequence, Tuple
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class DummyContainer:
|
|
||||||
name: str
|
|
||||||
|
|
||||||
def __repr__(self) -> str: # pragma: no cover
|
|
||||||
return f"DummyContainer({self.name})"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class DummyTipSpot:
|
|
||||||
name: str
|
|
||||||
|
|
||||||
def __repr__(self) -> str: # pragma: no cover
|
|
||||||
return f"DummyTipSpot({self.name})"
|
|
||||||
|
|
||||||
|
|
||||||
def make_tip_iter(n: int = 256) -> Iterable[List[DummyTipSpot]]:
|
|
||||||
"""Yield lists so code can safely call `tip.extend(next(self.current_tip))`."""
|
|
||||||
for i in range(n):
|
|
||||||
yield [DummyTipSpot(f"tip_{i}")]
|
|
||||||
|
|
||||||
|
|
||||||
class FakeLiquidHandler(LiquidHandlerAbstract):
|
|
||||||
"""不初始化真实 backend/deck;仅用来记录 transfer_liquid 内部调用序列。"""
|
|
||||||
|
|
||||||
def __init__(self, channel_num: int = 8):
|
|
||||||
# 不调用 super().__init__,避免真实硬件/后端依赖
|
|
||||||
self.channel_num = channel_num
|
|
||||||
self.support_touch_tip = True
|
|
||||||
self.current_tip = iter(make_tip_iter())
|
|
||||||
self.calls: List[Tuple[str, Any]] = []
|
|
||||||
|
|
||||||
def set_tiprack(self, tip_racks):
|
|
||||||
# transfer_liquid 总会调用 set_tiprack;测试用 Dummy 枪头时 tip_racks 为空,需保留自种子的 current_tip
|
|
||||||
if not tip_racks:
|
|
||||||
return
|
|
||||||
super().set_tiprack(tip_racks)
|
|
||||||
|
|
||||||
async def pick_up_tips(self, tip_spots, use_channels=None, offsets=None, **backend_kwargs):
|
|
||||||
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),
|
|
||||||
"use_channels": list(use_channels) if use_channels is not None else None,
|
|
||||||
"flow_rates": list(flow_rates) if flow_rates is not None else None,
|
|
||||||
"offsets": list(offsets) if offsets is not None else None,
|
|
||||||
"liquid_height": list(liquid_height) if liquid_height is not None else None,
|
|
||||||
"blow_out_air_volume": list(blow_out_air_volume) if blow_out_air_volume is not None else None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
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),
|
|
||||||
"use_channels": list(use_channels) if use_channels is not None else None,
|
|
||||||
"flow_rates": list(flow_rates) if flow_rates is not None else None,
|
|
||||||
"offsets": list(offsets) if offsets is not None else None,
|
|
||||||
"liquid_height": list(liquid_height) if liquid_height is not None else None,
|
|
||||||
"blow_out_air_volume": list(blow_out_air_volume) if blow_out_air_volume is not None else None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def discard_tips(self, use_channels=None, *args, **kwargs):
|
|
||||||
# 有的分支是 discard_tips(use_channels=[0]),有的分支是 discard_tips([0..7])(位置参数)
|
|
||||||
self.calls.append(("discard_tips", {"use_channels": list(use_channels) if use_channels is not None else None}))
|
|
||||||
|
|
||||||
async def custom_delay(self, seconds=0, msg=None):
|
|
||||||
self.calls.append(("custom_delay", {"seconds": seconds, "msg": msg}))
|
|
||||||
|
|
||||||
async def touch_tip(self, targets):
|
|
||||||
# 原实现会访问 targets.get_size_x() 等;测试里只记录调用
|
|
||||||
self.calls.append(("touch_tip", {"targets": targets}))
|
|
||||||
|
|
||||||
def run(coro):
|
|
||||||
return asyncio.run(coro)
|
|
||||||
|
|
||||||
|
|
||||||
def test_one_to_one_single_channel_basic_calls():
|
|
||||||
lh = FakeLiquidHandler(channel_num=1)
|
|
||||||
lh.current_tip = iter(make_tip_iter(64))
|
|
||||||
|
|
||||||
sources = [DummyContainer(f"S{i}") for i in range(3)]
|
|
||||||
targets = [DummyContainer(f"T{i}") for i in range(3)]
|
|
||||||
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=targets,
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=[0],
|
|
||||||
asp_vols=[1, 2, 3],
|
|
||||||
dis_vols=[4, 5, 6],
|
|
||||||
mix_times=None, # 应该仍能执行(不 mix)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert [c[0] for c in lh.calls].count("pick_up_tips") == 3
|
|
||||||
assert [c[0] for c in lh.calls].count("aspirate") == 3
|
|
||||||
assert [c[0] for c in lh.calls].count("dispense") == 3
|
|
||||||
assert [c[0] for c in lh.calls].count("discard_tips") == 3
|
|
||||||
|
|
||||||
# 每次 aspirate/dispense 都是单孔列表
|
|
||||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
|
||||||
assert aspirates[0]["resources"] == [sources[0]]
|
|
||||||
assert aspirates[0]["vols"] == [1.0]
|
|
||||||
|
|
||||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
|
||||||
assert dispenses[2]["resources"] == [targets[2]]
|
|
||||||
assert dispenses[2]["vols"] == [6.0]
|
|
||||||
|
|
||||||
|
|
||||||
def test_one_to_one_single_channel_before_stage_mixes_prior_to_aspirate():
|
|
||||||
lh = FakeLiquidHandler(channel_num=1)
|
|
||||||
lh.current_tip = iter(make_tip_iter(16))
|
|
||||||
|
|
||||||
source = DummyContainer("S0")
|
|
||||||
target = DummyContainer("T0")
|
|
||||||
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=[source],
|
|
||||||
targets=[target],
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=[0],
|
|
||||||
asp_vols=[5],
|
|
||||||
dis_vols=[5],
|
|
||||||
mix_stage="before",
|
|
||||||
mix_times=1,
|
|
||||||
mix_vol=3,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
aspirate_calls = [(idx, payload) for idx, (name, payload) in enumerate(lh.calls) if name == "aspirate"]
|
|
||||||
assert len(aspirate_calls) >= 2
|
|
||||||
mix_idx, mix_payload = aspirate_calls[0]
|
|
||||||
assert mix_payload["resources"] == [target]
|
|
||||||
assert mix_payload["vols"] == [3]
|
|
||||||
transfer_idx, transfer_payload = aspirate_calls[1]
|
|
||||||
assert transfer_payload["resources"] == [source]
|
|
||||||
assert mix_idx < transfer_idx
|
|
||||||
|
|
||||||
|
|
||||||
def test_one_to_one_eight_channel_groups_by_8():
|
|
||||||
lh = FakeLiquidHandler(channel_num=8)
|
|
||||||
lh.current_tip = iter(make_tip_iter(256))
|
|
||||||
|
|
||||||
sources = [DummyContainer(f"S{i}") for i in range(16)]
|
|
||||||
targets = [DummyContainer(f"T{i}") for i in range(16)]
|
|
||||||
asp_vols = list(range(1, 17))
|
|
||||||
dis_vols = list(range(101, 117))
|
|
||||||
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=targets,
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=list(range(8)),
|
|
||||||
asp_vols=asp_vols,
|
|
||||||
dis_vols=dis_vols,
|
|
||||||
mix_times=0, # 触发逻辑但不 mix
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# 16 个任务 -> 2 组,每组 8 通道一起做
|
|
||||||
assert [c[0] for c in lh.calls].count("pick_up_tips") == 2
|
|
||||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
|
||||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
|
||||||
assert len(aspirates) == 2
|
|
||||||
assert len(dispenses) == 2
|
|
||||||
|
|
||||||
assert aspirates[0]["resources"] == sources[0:8]
|
|
||||||
assert aspirates[0]["vols"] == [float(v) for v in asp_vols[0:8]]
|
|
||||||
assert dispenses[1]["resources"] == targets[8:16]
|
|
||||||
assert dispenses[1]["vols"] == [float(v) for v in dis_vols[8:16]]
|
|
||||||
|
|
||||||
|
|
||||||
def test_one_to_one_eight_channel_requires_multiple_of_8_targets():
|
|
||||||
lh = FakeLiquidHandler(channel_num=8)
|
|
||||||
lh.current_tip = iter(make_tip_iter(64))
|
|
||||||
|
|
||||||
sources = [DummyContainer(f"S{i}") for i in range(9)]
|
|
||||||
targets = [DummyContainer(f"T{i}") for i in range(9)]
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="multiple of 8"):
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=targets,
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=list(range(8)),
|
|
||||||
asp_vols=[1] * 9,
|
|
||||||
dis_vols=[1] * 9,
|
|
||||||
mix_times=0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_one_to_one_eight_channel_parameter_lists_are_chunked_per_8():
|
|
||||||
lh = FakeLiquidHandler(channel_num=8)
|
|
||||||
lh.current_tip = iter(make_tip_iter(512))
|
|
||||||
|
|
||||||
sources = [DummyContainer(f"S{i}") for i in range(16)]
|
|
||||||
targets = [DummyContainer(f"T{i}") for i in range(16)]
|
|
||||||
asp_vols = [i + 1 for i in range(16)]
|
|
||||||
dis_vols = [200 + i for i in range(16)]
|
|
||||||
asp_flow_rates = [0.1 * (i + 1) for i in range(16)]
|
|
||||||
dis_flow_rates = [0.2 * (i + 1) for i in range(16)]
|
|
||||||
offsets = [f"offset_{i}" for i in range(16)]
|
|
||||||
liquid_heights = [i * 0.5 for i in range(16)]
|
|
||||||
blow_out_air_volume = [i + 0.05 for i in range(16)]
|
|
||||||
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=targets,
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=list(range(8)),
|
|
||||||
asp_vols=asp_vols,
|
|
||||||
dis_vols=dis_vols,
|
|
||||||
asp_flow_rates=asp_flow_rates,
|
|
||||||
dis_flow_rates=dis_flow_rates,
|
|
||||||
offsets=offsets,
|
|
||||||
liquid_height=liquid_heights,
|
|
||||||
blow_out_air_volume=blow_out_air_volume,
|
|
||||||
mix_times=0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
|
||||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
|
||||||
assert len(aspirates) == len(dispenses) == 2
|
|
||||||
|
|
||||||
for batch_idx in range(2):
|
|
||||||
start = batch_idx * 8
|
|
||||||
end = start + 8
|
|
||||||
asp_call = aspirates[batch_idx]
|
|
||||||
dis_call = dispenses[batch_idx]
|
|
||||||
assert asp_call["resources"] == sources[start:end]
|
|
||||||
assert asp_call["flow_rates"] == asp_flow_rates[start:end]
|
|
||||||
assert asp_call["offsets"] == offsets[start:end]
|
|
||||||
assert asp_call["liquid_height"] == liquid_heights[start:end]
|
|
||||||
assert asp_call["blow_out_air_volume"] == blow_out_air_volume[start:end]
|
|
||||||
assert dis_call["flow_rates"] == dis_flow_rates[start:end]
|
|
||||||
assert dis_call["offsets"] == offsets[start:end]
|
|
||||||
assert dis_call["liquid_height"] == liquid_heights[start:end]
|
|
||||||
assert dis_call["blow_out_air_volume"] == blow_out_air_volume[start:end]
|
|
||||||
|
|
||||||
|
|
||||||
def test_one_to_one_eight_channel_handles_32_tasks_four_batches():
|
|
||||||
lh = FakeLiquidHandler(channel_num=8)
|
|
||||||
lh.current_tip = iter(make_tip_iter(1024))
|
|
||||||
|
|
||||||
sources = [DummyContainer(f"S{i}") for i in range(32)]
|
|
||||||
targets = [DummyContainer(f"T{i}") for i in range(32)]
|
|
||||||
asp_vols = [i + 1 for i in range(32)]
|
|
||||||
dis_vols = [300 + i for i in range(32)]
|
|
||||||
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=targets,
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=list(range(8)),
|
|
||||||
asp_vols=asp_vols,
|
|
||||||
dis_vols=dis_vols,
|
|
||||||
mix_times=0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
pick_calls = [name for name, _ in lh.calls if name == "pick_up_tips"]
|
|
||||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
|
||||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
|
||||||
assert len(pick_calls) == 4
|
|
||||||
assert len(aspirates) == len(dispenses) == 4
|
|
||||||
assert aspirates[0]["resources"] == sources[0:8]
|
|
||||||
assert aspirates[-1]["resources"] == sources[24:32]
|
|
||||||
assert dispenses[0]["resources"] == targets[0:8]
|
|
||||||
assert dispenses[-1]["resources"] == targets[24:32]
|
|
||||||
|
|
||||||
|
|
||||||
def test_one_to_many_single_channel_aspirates_total_when_asp_vol_too_small():
|
|
||||||
lh = FakeLiquidHandler(channel_num=1)
|
|
||||||
lh.current_tip = iter(make_tip_iter(64))
|
|
||||||
|
|
||||||
source = DummyContainer("SRC")
|
|
||||||
targets = [DummyContainer(f"T{i}") for i in range(3)]
|
|
||||||
dis_vols = [10, 20, 30] # sum=60
|
|
||||||
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=[source],
|
|
||||||
targets=targets,
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=[0],
|
|
||||||
asp_vols=10, # 小于 sum(dis_vols) -> 应吸 60
|
|
||||||
dis_vols=dis_vols,
|
|
||||||
mix_times=0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
|
||||||
assert len(aspirates) == 1
|
|
||||||
assert aspirates[0]["resources"] == [source]
|
|
||||||
assert aspirates[0]["vols"] == [60.0]
|
|
||||||
assert aspirates[0]["use_channels"] == [0]
|
|
||||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
|
||||||
assert [d["vols"][0] for d in dispenses] == [10.0, 20.0, 30.0]
|
|
||||||
|
|
||||||
|
|
||||||
def test_one_to_many_eight_channel_basic():
|
|
||||||
lh = FakeLiquidHandler(channel_num=8)
|
|
||||||
lh.current_tip = iter(make_tip_iter(128))
|
|
||||||
|
|
||||||
source = DummyContainer("SRC")
|
|
||||||
targets = [DummyContainer(f"T{i}") for i in range(8)]
|
|
||||||
dis_vols = [i + 1 for i in range(8)]
|
|
||||||
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=[source],
|
|
||||||
targets=targets,
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=list(range(8)),
|
|
||||||
asp_vols=999, # one-to-many 8ch 会按 dis_vols 吸(每通道各自)
|
|
||||||
dis_vols=dis_vols,
|
|
||||||
mix_times=0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
|
||||||
assert aspirates[0]["resources"] == [source] * 8
|
|
||||||
assert aspirates[0]["vols"] == [float(v) for v in dis_vols]
|
|
||||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
|
||||||
assert dispenses[0]["resources"] == targets
|
|
||||||
assert dispenses[0]["vols"] == [float(v) for v in dis_vols]
|
|
||||||
|
|
||||||
|
|
||||||
def test_many_to_one_single_channel_standard_dispense_equals_asp_by_default():
|
|
||||||
lh = FakeLiquidHandler(channel_num=1)
|
|
||||||
lh.current_tip = iter(make_tip_iter(128))
|
|
||||||
|
|
||||||
sources = [DummyContainer(f"S{i}") for i in range(3)]
|
|
||||||
target = DummyContainer("T")
|
|
||||||
asp_vols = [5, 6, 7]
|
|
||||||
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=[target],
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=[0],
|
|
||||||
asp_vols=asp_vols,
|
|
||||||
dis_vols=1, # many-to-one 允许标量;非比例模式下实际每次分液=对应 asp_vol
|
|
||||||
mix_times=0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
|
||||||
assert [d["vols"][0] for d in dispenses] == [float(v) for v in asp_vols]
|
|
||||||
assert all(d["resources"] == [target] for d in dispenses)
|
|
||||||
|
|
||||||
|
|
||||||
def test_many_to_one_single_channel_before_stage_mixes_target_once():
|
|
||||||
lh = FakeLiquidHandler(channel_num=1)
|
|
||||||
lh.current_tip = iter(make_tip_iter(128))
|
|
||||||
|
|
||||||
sources = [DummyContainer("S0"), DummyContainer("S1")]
|
|
||||||
target = DummyContainer("T")
|
|
||||||
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=[target],
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=[0],
|
|
||||||
asp_vols=[5, 6],
|
|
||||||
dis_vols=1,
|
|
||||||
mix_stage="before",
|
|
||||||
mix_times=2,
|
|
||||||
mix_vol=4,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
aspirate_calls = [(idx, payload) for idx, (name, payload) in enumerate(lh.calls) if name == "aspirate"]
|
|
||||||
assert len(aspirate_calls) >= 1
|
|
||||||
mix_idx, mix_payload = aspirate_calls[0]
|
|
||||||
assert mix_payload["resources"] == [target]
|
|
||||||
assert mix_payload["vols"] == [4]
|
|
||||||
# 第一個 mix 之後會真正開始吸 source
|
|
||||||
assert any(call["resources"] == [sources[0]] for _, call in aspirate_calls[1:])
|
|
||||||
|
|
||||||
|
|
||||||
def test_many_to_one_single_channel_proportional_mixing_uses_dis_vols_per_source():
|
|
||||||
lh = FakeLiquidHandler(channel_num=1)
|
|
||||||
lh.current_tip = iter(make_tip_iter(128))
|
|
||||||
|
|
||||||
sources = [DummyContainer(f"S{i}") for i in range(3)]
|
|
||||||
target = DummyContainer("T")
|
|
||||||
asp_vols = [5, 6, 7]
|
|
||||||
dis_vols = [1, 2, 3]
|
|
||||||
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=[target],
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=[0],
|
|
||||||
asp_vols=asp_vols,
|
|
||||||
dis_vols=dis_vols, # 比例模式
|
|
||||||
mix_times=0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
|
||||||
assert [d["vols"][0] for d in dispenses] == [float(v) for v in dis_vols]
|
|
||||||
|
|
||||||
|
|
||||||
def test_many_to_one_eight_channel_basic():
|
|
||||||
lh = FakeLiquidHandler(channel_num=8)
|
|
||||||
lh.current_tip = iter(make_tip_iter(256))
|
|
||||||
|
|
||||||
sources = [DummyContainer(f"S{i}") for i in range(8)]
|
|
||||||
target = DummyContainer("T")
|
|
||||||
asp_vols = [10 + i for i in range(8)]
|
|
||||||
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=[target],
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=list(range(8)),
|
|
||||||
asp_vols=asp_vols,
|
|
||||||
dis_vols=999, # 非比例模式下每通道分液=对应 asp_vol
|
|
||||||
mix_times=0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
|
||||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
|
||||||
assert aspirates[0]["resources"] == sources
|
|
||||||
assert aspirates[0]["vols"] == [float(v) for v in asp_vols]
|
|
||||||
assert dispenses[0]["resources"] == [target] * 8
|
|
||||||
assert dispenses[0]["vols"] == [float(v) for v in asp_vols]
|
|
||||||
|
|
||||||
|
|
||||||
def test_transfer_liquid_mode_detection_unsupported_shape_raises():
|
|
||||||
lh = FakeLiquidHandler(channel_num=8)
|
|
||||||
lh.current_tip = iter(make_tip_iter(64))
|
|
||||||
|
|
||||||
sources = [DummyContainer("S0"), DummyContainer("S1")]
|
|
||||||
targets = [DummyContainer("T0"), DummyContainer("T1"), DummyContainer("T2")]
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Unsupported transfer mode"):
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=targets,
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=[0],
|
|
||||||
asp_vols=[1, 1],
|
|
||||||
dis_vols=[1, 1, 1],
|
|
||||||
mix_times=0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_mix_single_target_produces_matching_cycles():
|
|
||||||
lh = FakeLiquidHandler(channel_num=1)
|
|
||||||
target = DummyContainer("T_mix")
|
|
||||||
|
|
||||||
run(lh.mix(targets=[target], mix_time=2, mix_vol=5))
|
|
||||||
|
|
||||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
|
||||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
|
||||||
assert len(aspirates) == len(dispenses) == 2
|
|
||||||
assert all(call["resources"] == [target] for call in aspirates)
|
|
||||||
assert all(call["vols"] == [5] for call in aspirates)
|
|
||||||
assert all(call["resources"] == [target] for call in dispenses)
|
|
||||||
assert all(call["vols"] == [5] for call in dispenses)
|
|
||||||
|
|
||||||
|
|
||||||
def test_mix_multiple_targets_supports_per_target_offsets():
|
|
||||||
lh = FakeLiquidHandler(channel_num=1)
|
|
||||||
targets = [DummyContainer("T0"), DummyContainer("T1")]
|
|
||||||
offsets = ["left", "right"]
|
|
||||||
heights = [0.1, 0.2]
|
|
||||||
rates = [0.5, 1.0]
|
|
||||||
|
|
||||||
run(
|
|
||||||
lh.mix(
|
|
||||||
targets=targets,
|
|
||||||
mix_time=1,
|
|
||||||
mix_vol=3,
|
|
||||||
offsets=offsets,
|
|
||||||
height_to_bottom=heights,
|
|
||||||
mix_rate=rates,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
|
||||||
assert len(aspirates) == 2
|
|
||||||
assert aspirates[0]["resources"] == [targets[0]]
|
|
||||||
assert aspirates[0]["offsets"] == [offsets[0]]
|
|
||||||
assert aspirates[0]["liquid_height"] == [heights[0]]
|
|
||||||
assert aspirates[0]["flow_rates"] == [rates[0]]
|
|
||||||
assert aspirates[1]["resources"] == [targets[1]]
|
|
||||||
assert aspirates[1]["offsets"] == [offsets[1]]
|
|
||||||
assert aspirates[1]["liquid_height"] == [heights[1]]
|
|
||||||
assert aspirates[1]["flow_rates"] == [rates[1]]
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_tiprack_per_type_resumes_first_physical_rack():
|
|
||||||
"""同型号多次 set_tiprack 时接续第一盒剩余孔位,而非从新盒 A1 开始。"""
|
|
||||||
from pylabrobot.liquid_handling import LiquidHandlerChatterboxBackend
|
|
||||||
from pylabrobot.resources import Deck, Tip, TipRack, TipSpot, create_equally_spaced
|
|
||||||
|
|
||||||
mk = lambda: Tip(
|
|
||||||
has_filter=False, total_tip_length=10.0, maximal_volume=300.0, fitting_depth=2.0
|
|
||||||
)
|
|
||||||
|
|
||||||
class TipTypeAlpha(TipRack):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class TipTypeBeta(TipRack):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def make_rack(cls: type, name: str) -> TipRack:
|
|
||||||
items = create_equally_spaced(
|
|
||||||
TipSpot,
|
|
||||||
num_items_x=12,
|
|
||||||
num_items_y=2,
|
|
||||||
dx=0,
|
|
||||||
dy=0,
|
|
||||||
dz=0,
|
|
||||||
item_dx=9,
|
|
||||||
item_dy=9,
|
|
||||||
size_x=1,
|
|
||||||
size_y=1,
|
|
||||||
make_tip=mk,
|
|
||||||
)
|
|
||||||
return cls(name, 120, 40, 10, items=items)
|
|
||||||
|
|
||||||
rack1 = make_rack(TipTypeAlpha, "rack_phys_1")
|
|
||||||
rack2 = make_rack(TipTypeBeta, "rack_phys_2")
|
|
||||||
rack3 = make_rack(TipTypeAlpha, "rack_phys_3")
|
|
||||||
|
|
||||||
lh = LiquidHandlerAbstract(
|
|
||||||
LiquidHandlerChatterboxBackend(1), Deck(), channel_num=1, simulator=False
|
|
||||||
)
|
|
||||||
flat1 = lh._flatten_tips_from_one(rack1)
|
|
||||||
assert len(flat1) == 24
|
|
||||||
|
|
||||||
lh.set_tiprack([rack1])
|
|
||||||
for i in range(12):
|
|
||||||
assert lh._get_next_tip() is flat1[i]
|
|
||||||
|
|
||||||
lh.set_tiprack([rack2])
|
|
||||||
spot_b = lh._get_next_tip()
|
|
||||||
assert "rack_phys_2" in spot_b.name
|
|
||||||
|
|
||||||
lh.set_tiprack([rack3])
|
|
||||||
spot_resume = lh._get_next_tip()
|
|
||||||
assert spot_resume is flat1[12], "第三次同型号应接续 rack1 第二行首孔,而非 rack3 首孔"
|
|
||||||
assert spot_resume is not lh._flatten_tips_from_one(rack3)[0]
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
"""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"] == []
|
|
||||||
@@ -1,351 +0,0 @@
|
|||||||
"""P6.1 / P6.1.1 `build_protocol_graph` 集成测试 —— 对应 06-labware-mapping-table.md §11.7.7 C / §11.8.7 C。
|
|
||||||
|
|
||||||
6 条用例:
|
|
||||||
|
|
||||||
- `test_build_graph_default_target_device_prcxi` —— 不传 target_device 时默认 "prcxi",
|
|
||||||
与 P6 等价(PRCXI_* class_name)。
|
|
||||||
- `test_build_graph_explicit_target_device_prcxi` —— 显式 "prcxi" 与默认完全等价。
|
|
||||||
- `test_build_graph_target_device_unknown_falls_back_to_default_section` —— 未声明的
|
|
||||||
target_device 由 loader 自动 fallback 到 ``target_devices.default``;第一版 default
|
|
||||||
段按 prcxi 拷贝,所以结果应与 "prcxi" 完全一致。
|
|
||||||
- `test_build_graph_per_device_tip_class` —— 临时 YAML 同时声明 prcxi 与 beckman tip
|
|
||||||
量程档;同一 transfer_liquid 在 target_device="prcxi" / "beckman" 下命中不同 class。
|
|
||||||
- `test_field_renamed_target_class_name` —— `labware_info` 写入的字段是
|
|
||||||
`target_class_name`,**旧字段 `prcxi_class_name` 不存在**。
|
|
||||||
- `test_build_graph_model_level_slot_remap` —— P6.1.1:``target_model`` 透传到
|
|
||||||
``_map_deck_slot`` 后改变 create_resource 的 slot(同厂商不同型号 deck 物理布局不同)。
|
|
||||||
|
|
||||||
本测试在导入 common.py 之前 mock 掉 matplotlib / networkx.drawing.nx_agraph,避免在
|
|
||||||
没有图形依赖的最小 Python 环境下也能跑(与 P6 批量回归脚本同样的策略)。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import types
|
|
||||||
import warnings
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
|
||||||
if str(ROOT_DIR) not in sys.path:
|
|
||||||
sys.path.insert(0, str(ROOT_DIR))
|
|
||||||
|
|
||||||
|
|
||||||
def _install_fake_optional_deps() -> None:
|
|
||||||
"""安装 matplotlib / networkx.drawing.nx_agraph 的 fake 实现,避免本地环境硬依赖。
|
|
||||||
|
|
||||||
common.py 在模块级 import 这些库做可视化辅助;build_protocol_graph 主路径不会真用到。
|
|
||||||
fake 模块只需要满足 ``from X import Y`` 的查找即可。
|
|
||||||
"""
|
|
||||||
if "matplotlib" not in sys.modules:
|
|
||||||
fake_matplotlib = types.ModuleType("matplotlib")
|
|
||||||
sys.modules["matplotlib"] = fake_matplotlib
|
|
||||||
if "matplotlib.pyplot" not in sys.modules:
|
|
||||||
fake_plt = types.ModuleType("matplotlib.pyplot")
|
|
||||||
sys.modules["matplotlib.pyplot"] = fake_plt
|
|
||||||
# networkx.drawing.nx_agraph.to_agraph 依赖 pygraphviz;不可用时给个空 stub
|
|
||||||
try:
|
|
||||||
from networkx.drawing import nx_agraph # noqa: F401
|
|
||||||
except Exception:
|
|
||||||
nx_drawing = types.ModuleType("networkx.drawing")
|
|
||||||
nx_agraph_mod = types.ModuleType("networkx.drawing.nx_agraph")
|
|
||||||
|
|
||||||
def _to_agraph(_g): # type: ignore[no-untyped-def]
|
|
||||||
raise RuntimeError("nx_agraph fake — not used in build_protocol_graph main path")
|
|
||||||
|
|
||||||
nx_agraph_mod.to_agraph = _to_agraph # type: ignore[attr-defined]
|
|
||||||
nx_drawing.nx_agraph = nx_agraph_mod # type: ignore[attr-defined]
|
|
||||||
sys.modules["networkx.drawing"] = nx_drawing
|
|
||||||
sys.modules["networkx.drawing.nx_agraph"] = nx_agraph_mod
|
|
||||||
|
|
||||||
|
|
||||||
_install_fake_optional_deps()
|
|
||||||
|
|
||||||
from unilabos.workflow import labware_mapping as lm # noqa: E402
|
|
||||||
from unilabos.workflow.common import build_protocol_graph # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _reset_mapping_cache():
|
|
||||||
"""每个用例后清 lru_cache,避免跨用例污染。"""
|
|
||||||
yield
|
|
||||||
lm.reload_mapping()
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== 公共 fixture:最小 transfer_liquid 协议 ====================
|
|
||||||
|
|
||||||
|
|
||||||
def _minimal_labware_info() -> dict:
|
|
||||||
"""返回最小可用的 labware_info(mutable,每个 case 独立 build 一份)。
|
|
||||||
|
|
||||||
包含 tip rack + 24-tube rack + 96 wellplate(slot 1/2/3),覆盖 P6.1 主要 kind。
|
|
||||||
tube rack / plate 显式声明 ``num_wells``,避免在无 labware_defs / 无 prcxi_labware 模板
|
|
||||||
时通过 well-count 启发式(well_n=3)误判孔数;与真实协议中 labware_defs 提供 num_wells
|
|
||||||
的行为对齐。
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
"tips": {
|
|
||||||
"slot": 1,
|
|
||||||
"well": [],
|
|
||||||
"labware": "opentrons_96_tiprack_300ul",
|
|
||||||
"object": "tiprack",
|
|
||||||
},
|
|
||||||
"samples": {
|
|
||||||
"slot": 2,
|
|
||||||
"well": ["A1", "A2", "A3"],
|
|
||||||
"labware": "opentrons_24_tuberack_eppendorf_2ml_safelock_snapcap",
|
|
||||||
"object": "source",
|
|
||||||
"num_wells": 24,
|
|
||||||
},
|
|
||||||
"plate_target": {
|
|
||||||
"slot": 3,
|
|
||||||
"well": ["A1", "A2", "A3"],
|
|
||||||
"labware": "opentrons_96_wellplate_300ul_pcr",
|
|
||||||
"object": "target",
|
|
||||||
"num_wells": 96,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _minimal_protocol_steps() -> list:
|
|
||||||
"""最小 transfer_liquid 协议步骤:asp_vols/dis_vols 最大 200 µL → PRCXI 300ul 档。"""
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"action": "transfer_liquid",
|
|
||||||
"parameters": {
|
|
||||||
"sources": "samples",
|
|
||||||
"targets": "plate_target",
|
|
||||||
"tip_racks": "tips",
|
|
||||||
"asp_vols": [200.0, 200.0, 200.0],
|
|
||||||
"dis_vols": [200.0, 200.0, 200.0],
|
|
||||||
},
|
|
||||||
"step_number": 1,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _collect_create_resource_classes(graph) -> dict:
|
|
||||||
"""从工作流图中提取每个 create_resource 节点的 ``slot_on_deck → class_name``。"""
|
|
||||||
out: dict = {}
|
|
||||||
for _nid, node in graph.nodes.items():
|
|
||||||
if node.get("template_name") != "create_resource":
|
|
||||||
continue
|
|
||||||
param = node.get("param") or {}
|
|
||||||
slot = str(param.get("slot_on_deck") or "")
|
|
||||||
cls = str(param.get("class_name") or "")
|
|
||||||
if slot:
|
|
||||||
out[slot] = cls
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== 5 条核心用例 ====================
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_graph_default_target_device_prcxi():
|
|
||||||
"""不传 target_device → 默认 "prcxi" → 与 P6 等价(PRCXI_* class_name)。"""
|
|
||||||
labware_info = _minimal_labware_info()
|
|
||||||
g = build_protocol_graph(
|
|
||||||
labware_info=labware_info,
|
|
||||||
protocol_steps=_minimal_protocol_steps(),
|
|
||||||
workstation_name="PRCXI",
|
|
||||||
)
|
|
||||||
classes = _collect_create_resource_classes(g)
|
|
||||||
assert classes["1"] == "PRCXI_300ul_Tips" # 200 µL → 300 档
|
|
||||||
assert classes["2"] == "PRCXI_EP_Adapter" # 24-tube rack
|
|
||||||
assert classes["3"] == "PRCXI_BioER_96_wellplate" # 96 wellplate
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_graph_explicit_target_device_prcxi():
|
|
||||||
"""显式传 target_device="prcxi" 应与默认完全等价。"""
|
|
||||||
labware_info_a = _minimal_labware_info()
|
|
||||||
labware_info_b = _minimal_labware_info()
|
|
||||||
g_default = build_protocol_graph(
|
|
||||||
labware_info=labware_info_a,
|
|
||||||
protocol_steps=_minimal_protocol_steps(),
|
|
||||||
workstation_name="PRCXI",
|
|
||||||
)
|
|
||||||
g_prcxi = build_protocol_graph(
|
|
||||||
labware_info=labware_info_b,
|
|
||||||
protocol_steps=_minimal_protocol_steps(),
|
|
||||||
workstation_name="PRCXI",
|
|
||||||
target_device="prcxi",
|
|
||||||
)
|
|
||||||
assert _collect_create_resource_classes(g_default) == _collect_create_resource_classes(g_prcxi)
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_graph_target_device_unknown_falls_back_to_default_section():
|
|
||||||
"""未声明的 target_device → loader 自动 fallback 到固定段 target_devices.default + warning。
|
|
||||||
|
|
||||||
第一版 default 段按 prcxi 拷贝填充 → 结果应与 target_device="prcxi" 完全等价(PRCXI_*)。
|
|
||||||
"""
|
|
||||||
labware_info_a = _minimal_labware_info()
|
|
||||||
labware_info_b = _minimal_labware_info()
|
|
||||||
g_prcxi = build_protocol_graph(
|
|
||||||
labware_info=labware_info_a,
|
|
||||||
protocol_steps=_minimal_protocol_steps(),
|
|
||||||
workstation_name="PRCXI",
|
|
||||||
target_device="prcxi",
|
|
||||||
)
|
|
||||||
with warnings.catch_warnings(record=True) as caught:
|
|
||||||
warnings.simplefilter("always")
|
|
||||||
g_unknown = build_protocol_graph(
|
|
||||||
labware_info=labware_info_b,
|
|
||||||
protocol_steps=_minimal_protocol_steps(),
|
|
||||||
workstation_name="PRCXI",
|
|
||||||
target_device="unknown_xxx",
|
|
||||||
)
|
|
||||||
assert _collect_create_resource_classes(g_unknown) == _collect_create_resource_classes(g_prcxi)
|
|
||||||
# loader 至少打 1 次 warning 提示「未声明、已回退到 default」
|
|
||||||
assert any(
|
|
||||||
("未在 labware_mapping.yaml" in str(w.message))
|
|
||||||
or ("target_devices.default" in str(w.message))
|
|
||||||
for w in caught
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_graph_per_device_tip_class(tmp_path, monkeypatch):
|
|
||||||
"""同一 protocol,target_device="prcxi" / "beckman" 在 200µL 下命中不同 tip 档(P6.1.1 schema)。"""
|
|
||||||
yaml_path = tmp_path / "labware_mapping.yaml"
|
|
||||||
yaml_path.write_text(
|
|
||||||
'kinds:\n'
|
|
||||||
' - {pattern: "trash", kind: trash}\n'
|
|
||||||
' - {pattern: "tiprack|tip[_ ]?rack|opentrons_\\\\d+_tiprack", kind: tip_rack}\n'
|
|
||||||
' - {pattern: "tuberack|tube[_ ]rack|eppendorf.*rack|safelock.*rack", kind: tube_rack}\n'
|
|
||||||
' - {pattern: ".*", kind: plate}\n'
|
|
||||||
'target_devices:\n'
|
|
||||||
' default:\n'
|
|
||||||
' slot_remap: {default: {"4": "13", "8": "14"}, by_object: {trash: {"12": "16"}}}\n'
|
|
||||||
' rules:\n'
|
|
||||||
' - {kind: tip_rack, hole_count: 96, volume_max: 10, class_name: PRCXI_10uL_Tips}\n'
|
|
||||||
' - {kind: tip_rack, hole_count: 96, volume_max: 299.9, class_name: PRCXI_300ul_Tips}\n'
|
|
||||||
' - {kind: tip_rack, hole_count: 96, class_name: PRCXI_1000uL_Tips}\n'
|
|
||||||
' - {kind: tube_rack, hole_count: 24, class_name: PRCXI_EP_Adapter}\n'
|
|
||||||
' - {kind: plate, hole_count: 96, class_name: PRCXI_BioER_96_wellplate}\n'
|
|
||||||
' prcxi:\n'
|
|
||||||
' slot_remap: {default: {"4": "13", "8": "14"}, by_object: {trash: {"12": "16"}}}\n'
|
|
||||||
' rules:\n'
|
|
||||||
' - {kind: tip_rack, hole_count: 96, volume_max: 10, class_name: PRCXI_10uL_Tips}\n'
|
|
||||||
' - {kind: tip_rack, hole_count: 96, volume_max: 299.9, class_name: PRCXI_300ul_Tips}\n'
|
|
||||||
' - {kind: tip_rack, hole_count: 96, class_name: PRCXI_1000uL_Tips}\n'
|
|
||||||
' - {kind: tube_rack, hole_count: 24, class_name: PRCXI_EP_Adapter}\n'
|
|
||||||
' - {kind: plate, hole_count: 96, class_name: PRCXI_BioER_96_wellplate}\n'
|
|
||||||
' beckman:\n'
|
|
||||||
' slot_remap: {default: {"4": "13"}, by_object: {trash: {"12": "16"}}}\n'
|
|
||||||
' rules:\n'
|
|
||||||
' - {kind: tip_rack, hole_count: 96, volume_max: 20, class_name: Beckman_20uL_Tips}\n'
|
|
||||||
' - {kind: tip_rack, hole_count: 96, volume_max: 199.9, class_name: Beckman_200uL_Tips}\n'
|
|
||||||
' - {kind: tip_rack, hole_count: 96, class_name: Beckman_1000uL_Tips}\n'
|
|
||||||
' - {kind: tube_rack, hole_count: 24, class_name: Beckman_24_TubeRack}\n'
|
|
||||||
' - {kind: plate, hole_count: 96, class_name: Beckman_BioMek_96_wellplate}\n',
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
|
|
||||||
lm.reload_mapping()
|
|
||||||
|
|
||||||
g_prcxi = build_protocol_graph(
|
|
||||||
labware_info=_minimal_labware_info(),
|
|
||||||
protocol_steps=_minimal_protocol_steps(),
|
|
||||||
workstation_name="PRCXI",
|
|
||||||
target_device="prcxi",
|
|
||||||
)
|
|
||||||
g_beckman = build_protocol_graph(
|
|
||||||
labware_info=_minimal_labware_info(),
|
|
||||||
protocol_steps=_minimal_protocol_steps(),
|
|
||||||
workstation_name="PRCXI",
|
|
||||||
target_device="beckman",
|
|
||||||
)
|
|
||||||
|
|
||||||
classes_prcxi = _collect_create_resource_classes(g_prcxi)
|
|
||||||
classes_beckman = _collect_create_resource_classes(g_beckman)
|
|
||||||
|
|
||||||
# 200 µL:prcxi 走 300 档;beckman 200 档已超 → 1000 档
|
|
||||||
assert classes_prcxi["1"] == "PRCXI_300ul_Tips"
|
|
||||||
assert classes_beckman["1"] == "Beckman_1000uL_Tips"
|
|
||||||
# plate / tube rack 也按 target_device 输出对应厂商类
|
|
||||||
assert classes_prcxi["2"] == "PRCXI_EP_Adapter"
|
|
||||||
assert classes_beckman["2"] == "Beckman_24_TubeRack"
|
|
||||||
assert classes_prcxi["3"] == "PRCXI_BioER_96_wellplate"
|
|
||||||
assert classes_beckman["3"] == "Beckman_BioMek_96_wellplate"
|
|
||||||
|
|
||||||
|
|
||||||
def test_field_renamed_target_class_name():
|
|
||||||
"""`labware_info` 写入的字段是 `target_class_name`;旧字段 `prcxi_class_name` 不存在。"""
|
|
||||||
labware_info = _minimal_labware_info()
|
|
||||||
build_protocol_graph(
|
|
||||||
labware_info=labware_info,
|
|
||||||
protocol_steps=_minimal_protocol_steps(),
|
|
||||||
workstation_name="PRCXI",
|
|
||||||
)
|
|
||||||
for lid, item in labware_info.items():
|
|
||||||
assert "target_class_name" in item, f"{lid!r} 缺少 target_class_name 字段"
|
|
||||||
assert "prcxi_class_name" not in item, f"{lid!r} 残留了旧字段 prcxi_class_name"
|
|
||||||
assert item["target_class_name"], f"{lid!r} target_class_name 为空"
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== P6.1.1 新增集成测试 ====================
|
|
||||||
|
|
||||||
|
|
||||||
def _labware_info_slot4_plate() -> dict:
|
|
||||||
"""slot=4 的 96 板:用来验证 target_model 透传后 slot_remap 改变 create_resource 的槽位。"""
|
|
||||||
return {
|
|
||||||
"plate_slot4": {
|
|
||||||
"slot": 4,
|
|
||||||
"well": ["A1"],
|
|
||||||
"labware": "opentrons_96_wellplate_300ul_pcr",
|
|
||||||
"object": "target",
|
|
||||||
"num_wells": 96,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_graph_model_level_slot_remap(tmp_path, monkeypatch):
|
|
||||||
"""P6.1.1:target_model 透传到 _map_deck_slot 后改变 create_resource 的 slot_on_deck。
|
|
||||||
|
|
||||||
YAML 中 prcxi 厂商级 slot_remap 4→13;模型 "4040" 显式覆盖 4→16。
|
|
||||||
同一份 labware_info(slot=4)build 出的两份图,slot_on_deck 应分别为 "13" 与 "16"。
|
|
||||||
"""
|
|
||||||
yaml_path = tmp_path / "labware_mapping.yaml"
|
|
||||||
yaml_path.write_text(
|
|
||||||
'kinds: [{pattern: ".*", kind: plate}]\n'
|
|
||||||
'target_devices:\n'
|
|
||||||
' default:\n'
|
|
||||||
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
|
||||||
' rules: [{kind: plate, hole_count: 96, class_name: PRCXI_BioER_96_wellplate}]\n'
|
|
||||||
' prcxi:\n'
|
|
||||||
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
|
||||||
' rules: [{kind: plate, hole_count: 96, class_name: PRCXI_BioER_96_wellplate}]\n'
|
|
||||||
' models:\n'
|
|
||||||
' "4040":\n'
|
|
||||||
' slot_remap: {default: {"4": "16"}, by_object: {}}\n',
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
|
|
||||||
lm.reload_mapping()
|
|
||||||
|
|
||||||
g_default = build_protocol_graph(
|
|
||||||
labware_info=_labware_info_slot4_plate(),
|
|
||||||
protocol_steps=[],
|
|
||||||
workstation_name="PRCXI",
|
|
||||||
target_device="prcxi",
|
|
||||||
)
|
|
||||||
g_model_4040 = build_protocol_graph(
|
|
||||||
labware_info=_labware_info_slot4_plate(),
|
|
||||||
protocol_steps=[],
|
|
||||||
workstation_name="PRCXI",
|
|
||||||
target_device="prcxi",
|
|
||||||
target_model="4040",
|
|
||||||
)
|
|
||||||
|
|
||||||
classes_default = _collect_create_resource_classes(g_default)
|
|
||||||
classes_4040 = _collect_create_resource_classes(g_model_4040)
|
|
||||||
|
|
||||||
# 厂商级(无 model)→ slot 4 → "13"
|
|
||||||
assert "13" in classes_default, f"未找到 slot 13,实际生成的 slots: {list(classes_default)}"
|
|
||||||
assert "16" not in classes_default
|
|
||||||
# 模型 4040 → slot 4 → "16"
|
|
||||||
assert "16" in classes_4040, f"未找到 slot 16,实际生成的 slots: {list(classes_4040)}"
|
|
||||||
assert "13" not in classes_4040
|
|
||||||
# class_name 不变(rules 继承厂商级)
|
|
||||||
assert classes_default["13"] == "PRCXI_BioER_96_wellplate"
|
|
||||||
assert classes_4040["16"] == "PRCXI_BioER_96_wellplate"
|
|
||||||
@@ -1,369 +0,0 @@
|
|||||||
"""P2 v2 跨 slot transfer_liquid 合并 —— Stage 3 (`workflow/common.py`) 集成测试。
|
|
||||||
|
|
||||||
对应 ``product_designs/protocol_convert/02-cross-slot-merge.md`` §9.5 step 6.2。
|
|
||||||
|
|
||||||
v2 设计要点(与本测试用例的映射)
|
|
||||||
-----------------------------------
|
|
||||||
当 transfer_liquid 节点 ``params.targets`` 是 ``list[str]`` 时,``build_protocol_graph``
|
|
||||||
在该 transfer_liquid 之前**插入一个 merged ``set_liquid_from_plate`` 节点**:
|
|
||||||
|
|
||||||
- merged 节点的 ``param.wells`` 是按 ``params.targets`` 顺序通过 cursor 拼出来的有序跨板
|
|
||||||
well refs(每个元素是 ``{id, name, parent: reagent_key, type: "well"}``)。
|
|
||||||
- merged 节点接收来自每个涉及 plate 的 ``create_resource`` 节点的多入边
|
|
||||||
(``labware`` → ``wells_identifier``)。
|
|
||||||
- merged 节点的 ``output_wells`` 通过**单条边**连到 transfer_liquid 的 ``targets_identifier``。
|
|
||||||
- transfer_liquid 节点的 ``params.targets`` 被改写为 synthetic key
|
|
||||||
``_merged_targets_<idx>``(runtime 不消费 list 形态),保证 INPUT_PORT_MAPPING 走单边路径。
|
|
||||||
|
|
||||||
用例
|
|
||||||
----
|
|
||||||
- ``test_emit_merged_set_liquid_basic`` — 4 个 distinct reagent_key(51b9a5 主场景)。
|
|
||||||
- ``test_emit_merged_set_liquid_repeat_key`` — 同 reagent_key 重复(同板多孔)。
|
|
||||||
- ``test_emit_merged_set_liquid_mixed`` — 跨板混合 + 同板重复(cursor 推进)。
|
|
||||||
- ``test_emit_merged_set_liquid_8ch`` — 与 P1 multi-channel 复合(8 通道 cross-slot)。
|
|
||||||
- ``test_transfer_liquid_targets_rewrite`` — transfer_liquid 节点改写后只剩 1 条
|
|
||||||
``targets_identifier`` 入边;params.targets 不再是 list。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import types
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict, List
|
|
||||||
|
|
||||||
|
|
||||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
|
||||||
if str(ROOT_DIR) not in sys.path:
|
|
||||||
sys.path.insert(0, str(ROOT_DIR))
|
|
||||||
|
|
||||||
|
|
||||||
def _install_fake_optional_deps() -> None:
|
|
||||||
"""与 test_build_protocol_graph_target_device.py 一致的可选依赖 stub。"""
|
|
||||||
if "matplotlib" not in sys.modules:
|
|
||||||
sys.modules["matplotlib"] = types.ModuleType("matplotlib")
|
|
||||||
if "matplotlib.pyplot" not in sys.modules:
|
|
||||||
sys.modules["matplotlib.pyplot"] = types.ModuleType("matplotlib.pyplot")
|
|
||||||
try:
|
|
||||||
from networkx.drawing import nx_agraph # noqa: F401
|
|
||||||
except Exception:
|
|
||||||
nx_drawing = types.ModuleType("networkx.drawing")
|
|
||||||
nx_agraph_mod = types.ModuleType("networkx.drawing.nx_agraph")
|
|
||||||
nx_agraph_mod.to_agraph = lambda _g: None # type: ignore[attr-defined]
|
|
||||||
nx_drawing.nx_agraph = nx_agraph_mod # type: ignore[attr-defined]
|
|
||||||
sys.modules["networkx.drawing"] = nx_drawing
|
|
||||||
sys.modules["networkx.drawing.nx_agraph"] = nx_agraph_mod
|
|
||||||
|
|
||||||
|
|
||||||
_install_fake_optional_deps()
|
|
||||||
|
|
||||||
import pytest # noqa: E402
|
|
||||||
|
|
||||||
from unilabos.workflow.common import build_protocol_graph # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== 测试辅助:从工作流图中提取节点/边 ====================
|
|
||||||
|
|
||||||
|
|
||||||
def _nodes_by_template(graph, template_name: str) -> List[Dict[str, Any]]:
|
|
||||||
return [
|
|
||||||
{"id": nid, **node}
|
|
||||||
for nid, node in graph.nodes.items()
|
|
||||||
if node.get("template_name") == template_name
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _create_resource_by_slot(graph) -> Dict[str, str]:
|
|
||||||
"""slot_on_deck (str) -> create_resource 节点 ID。"""
|
|
||||||
out: Dict[str, str] = {}
|
|
||||||
for nid, node in graph.nodes.items():
|
|
||||||
if node.get("template_name") == "create_resource":
|
|
||||||
slot = str(node.get("param", {}).get("slot_on_deck") or "")
|
|
||||||
if slot:
|
|
||||||
out[slot] = nid
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _edges_to(graph, target_id: str) -> List[Dict[str, Any]]:
|
|
||||||
return [e for e in graph.edges if e["target"] == target_id]
|
|
||||||
|
|
||||||
|
|
||||||
def _edges_from(graph, source_id: str) -> List[Dict[str, Any]]:
|
|
||||||
return [e for e in graph.edges if e["source"] == source_id]
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== fixture:构造跨板 labware + steps ====================
|
|
||||||
|
|
||||||
|
|
||||||
def _cross_slot_labware_info() -> Dict[str, Dict[str, Any]]:
|
|
||||||
"""51b9a5 简化:slot1 source + slot2/3/5/6 target plates + slot12 tip。"""
|
|
||||||
return {
|
|
||||||
"l1": {
|
|
||||||
"slot": 1,
|
|
||||||
"well": ["A1"],
|
|
||||||
"labware": "nest_12_reservoir_15ml",
|
|
||||||
"object": "source",
|
|
||||||
},
|
|
||||||
"plate_slot2": {
|
|
||||||
"slot": 2,
|
|
||||||
"well": ["A1"],
|
|
||||||
"labware": "nest_96_wellplate_2ml_deep",
|
|
||||||
"object": "target",
|
|
||||||
},
|
|
||||||
"plate_slot3": {
|
|
||||||
"slot": 3,
|
|
||||||
"well": ["A1"],
|
|
||||||
"labware": "nest_96_wellplate_2ml_deep",
|
|
||||||
"object": "target",
|
|
||||||
},
|
|
||||||
"plate_slot5": {
|
|
||||||
"slot": 5,
|
|
||||||
"well": ["A1"],
|
|
||||||
"labware": "nest_96_wellplate_2ml_deep",
|
|
||||||
"object": "target",
|
|
||||||
},
|
|
||||||
"plate_slot6": {
|
|
||||||
"slot": 6,
|
|
||||||
"well": ["A1"],
|
|
||||||
"labware": "nest_96_wellplate_2ml_deep",
|
|
||||||
"object": "target",
|
|
||||||
},
|
|
||||||
"tiprack_12": {
|
|
||||||
"slot": 12,
|
|
||||||
"well": [],
|
|
||||||
"labware": "opentrons_96_tiprack_300ul",
|
|
||||||
"object": "tiprack",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _cross_slot_protocol_steps(targets: List[str], dis_vols: List[float]) -> List[Dict[str, Any]]:
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"action": "transfer_liquid",
|
|
||||||
"parameters": {
|
|
||||||
"sources": "l1",
|
|
||||||
"targets": targets,
|
|
||||||
"tip_racks": "tiprack_12",
|
|
||||||
"asp_vols": dis_vols.copy(),
|
|
||||||
"dis_vols": dis_vols.copy(),
|
|
||||||
},
|
|
||||||
"step_number": 1,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== 用例 ====================
|
|
||||||
|
|
||||||
|
|
||||||
def test_emit_merged_set_liquid_basic():
|
|
||||||
"""51b9a5 主场景:targets=[A,B,C,D] → 1 merged set_liquid 节点
|
|
||||||
+ 4 条入边(来自 4 个 distinct create_resource)+ 1 条出边(去 transfer_liquid)。
|
|
||||||
"""
|
|
||||||
targets = ["plate_slot2", "plate_slot3", "plate_slot5", "plate_slot6"]
|
|
||||||
dis_vols = [8.3, 8.3, 8.3, 8.3]
|
|
||||||
g = build_protocol_graph(
|
|
||||||
labware_info=_cross_slot_labware_info(),
|
|
||||||
protocol_steps=_cross_slot_protocol_steps(targets, dis_vols),
|
|
||||||
workstation_name="PRCXI",
|
|
||||||
)
|
|
||||||
|
|
||||||
set_liquid_nodes = _nodes_by_template(g, "set_liquid_from_plate")
|
|
||||||
merged_nodes = [n for n in set_liquid_nodes if str(n.get("name", "")).startswith("_merged_targets_")]
|
|
||||||
assert len(merged_nodes) == 1, (
|
|
||||||
f"应有且仅有 1 个 merged set_liquid_from_plate 节点(v2 跨板聚合器);"
|
|
||||||
f" 实际找到 {len(merged_nodes)}: {[n.get('name') for n in merged_nodes]}"
|
|
||||||
)
|
|
||||||
merged = merged_nodes[0]
|
|
||||||
merged_id = merged["id"]
|
|
||||||
|
|
||||||
# param.wells:长度 4,每元素的 parent 是对应 reagent_key
|
|
||||||
wells = merged.get("param", {}).get("wells") or []
|
|
||||||
assert len(wells) == 4
|
|
||||||
assert [w["parent"] for w in wells] == targets, "merged.wells 顺序必须严格按 targets 列表"
|
|
||||||
# well 字段映射到 reagent.well[0](都是 "A1")
|
|
||||||
for w, key in zip(wells, targets):
|
|
||||||
assert w["id"].endswith("/A1"), f"well id 应包含 well 名: {w}"
|
|
||||||
assert w["parent"] == key
|
|
||||||
|
|
||||||
# 入边:4 条来自 distinct create_resource 节点(slot 2/3/5/6),target_port=wells_identifier
|
|
||||||
cr_by_slot = _create_resource_by_slot(g)
|
|
||||||
in_edges = _edges_to(g, merged_id)
|
|
||||||
in_sources = {e["source"] for e in in_edges if e.get("target_handle_key") == "wells_identifier"}
|
|
||||||
expected_sources = {cr_by_slot[s] for s in ("2", "3", "5", "6")}
|
|
||||||
assert in_sources == expected_sources, (
|
|
||||||
f"merged 节点应接收 4 个 distinct create_resource 的 wells_identifier 边;"
|
|
||||||
f" 实际 {in_sources} vs 期望 {expected_sources}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 出边:1 条到 transfer_liquid(targets_identifier)
|
|
||||||
transfer_nodes = _nodes_by_template(g, "transfer_liquid")
|
|
||||||
assert len(transfer_nodes) == 1
|
|
||||||
transfer_id = transfer_nodes[0]["id"]
|
|
||||||
out_to_transfer = [
|
|
||||||
e for e in _edges_from(g, merged_id)
|
|
||||||
if e["target"] == transfer_id and e.get("target_handle_key") == "targets_identifier"
|
|
||||||
]
|
|
||||||
assert len(out_to_transfer) == 1, (
|
|
||||||
f"merged 节点应向 transfer_liquid.targets_identifier 发出唯一 1 条边;"
|
|
||||||
f" 实际 {len(out_to_transfer)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_emit_merged_set_liquid_repeat_key():
|
|
||||||
"""同 reagent_key 重复(同板多孔):targets=[A,A,A] + reagent.A.well=[A1,A2,A3]
|
|
||||||
→ merged.wells 顺序 = [A/A1, A/A2, A/A3](cursor 推进取每个 well)。
|
|
||||||
"""
|
|
||||||
labware = _cross_slot_labware_info()
|
|
||||||
labware["plate_slot2"]["well"] = ["A1", "A2", "A3"]
|
|
||||||
|
|
||||||
targets = ["plate_slot2", "plate_slot2", "plate_slot2"]
|
|
||||||
dis_vols = [10.0, 20.0, 30.0]
|
|
||||||
g = build_protocol_graph(
|
|
||||||
labware_info=labware,
|
|
||||||
protocol_steps=_cross_slot_protocol_steps(targets, dis_vols),
|
|
||||||
workstation_name="PRCXI",
|
|
||||||
)
|
|
||||||
|
|
||||||
merged_nodes = [
|
|
||||||
n for n in _nodes_by_template(g, "set_liquid_from_plate")
|
|
||||||
if str(n.get("name", "")).startswith("_merged_targets_")
|
|
||||||
]
|
|
||||||
assert len(merged_nodes) == 1
|
|
||||||
wells = merged_nodes[0]["param"]["wells"]
|
|
||||||
assert [w["id"].rsplit("/", 1)[-1] for w in wells] == ["A1", "A2", "A3"], (
|
|
||||||
"cursor 应依次取 reagent.A.well[0/1/2]"
|
|
||||||
)
|
|
||||||
assert all(w["parent"] == "plate_slot2" for w in wells)
|
|
||||||
|
|
||||||
|
|
||||||
def test_emit_merged_set_liquid_mixed():
|
|
||||||
"""跨板 + 同板重复:targets=[A,B,A,C] + reagent.A.well=[A1,A2]
|
|
||||||
→ merged.wells = [A/A1, B/A1, A/A2, C/A1]。
|
|
||||||
"""
|
|
||||||
labware = _cross_slot_labware_info()
|
|
||||||
labware["plate_slot2"]["well"] = ["A1", "A2"]
|
|
||||||
|
|
||||||
targets = ["plate_slot2", "plate_slot3", "plate_slot2", "plate_slot5"]
|
|
||||||
dis_vols = [10.0, 20.0, 30.0, 40.0]
|
|
||||||
g = build_protocol_graph(
|
|
||||||
labware_info=labware,
|
|
||||||
protocol_steps=_cross_slot_protocol_steps(targets, dis_vols),
|
|
||||||
workstation_name="PRCXI",
|
|
||||||
)
|
|
||||||
|
|
||||||
merged_nodes = [
|
|
||||||
n for n in _nodes_by_template(g, "set_liquid_from_plate")
|
|
||||||
if str(n.get("name", "")).startswith("_merged_targets_")
|
|
||||||
]
|
|
||||||
assert len(merged_nodes) == 1
|
|
||||||
wells = merged_nodes[0]["param"]["wells"]
|
|
||||||
ids = [(w["parent"], w["id"].rsplit("/", 1)[-1]) for w in wells]
|
|
||||||
assert ids == [
|
|
||||||
("plate_slot2", "A1"),
|
|
||||||
("plate_slot3", "A1"),
|
|
||||||
("plate_slot2", "A2"),
|
|
||||||
("plate_slot5", "A1"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_emit_merged_set_liquid_8ch():
|
|
||||||
"""与 P1 multi-channel 复合:targets=[A]*8+[B]*8(每列 8 通道)。
|
|
||||||
|
|
||||||
merged.wells 长度 16,前 8 全 plate_slot2 的 8 个 well,后 8 全 plate_slot3 的 8 个 well。
|
|
||||||
"""
|
|
||||||
labware = _cross_slot_labware_info()
|
|
||||||
# 8 通道场景 reagent.well 已被 P1 multi 展开为长度 8
|
|
||||||
labware["plate_slot2"]["well"] = [f"{r}1" for r in "ABCDEFGH"]
|
|
||||||
labware["plate_slot3"]["well"] = [f"{r}1" for r in "ABCDEFGH"]
|
|
||||||
|
|
||||||
targets = ["plate_slot2"] * 8 + ["plate_slot3"] * 8
|
|
||||||
dis_vols = [5.0] * 16
|
|
||||||
g = build_protocol_graph(
|
|
||||||
labware_info=labware,
|
|
||||||
protocol_steps=_cross_slot_protocol_steps(targets, dis_vols),
|
|
||||||
workstation_name="PRCXI",
|
|
||||||
)
|
|
||||||
|
|
||||||
merged_nodes = [
|
|
||||||
n for n in _nodes_by_template(g, "set_liquid_from_plate")
|
|
||||||
if str(n.get("name", "")).startswith("_merged_targets_")
|
|
||||||
]
|
|
||||||
assert len(merged_nodes) == 1
|
|
||||||
wells = merged_nodes[0]["param"]["wells"]
|
|
||||||
assert len(wells) == 16
|
|
||||||
# 前 8 全 plate_slot2,后 8 全 plate_slot3(满足 cross-slot × 8ch 列对齐约束)
|
|
||||||
assert all(w["parent"] == "plate_slot2" for w in wells[:8])
|
|
||||||
assert all(w["parent"] == "plate_slot3" for w in wells[8:])
|
|
||||||
# well 名顺序:A1..H1 重复两遍
|
|
||||||
assert [w["id"].rsplit("/", 1)[-1] for w in wells[:8]] == [f"{r}1" for r in "ABCDEFGH"]
|
|
||||||
assert [w["id"].rsplit("/", 1)[-1] for w in wells[8:]] == [f"{r}1" for r in "ABCDEFGH"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_transfer_liquid_targets_rewrite():
|
|
||||||
"""transfer_liquid 节点改写后只剩 1 条 targets_identifier 入边;params.targets 不再是 list。"""
|
|
||||||
targets = ["plate_slot2", "plate_slot3", "plate_slot5", "plate_slot6"]
|
|
||||||
dis_vols = [8.3, 8.3, 8.3, 8.3]
|
|
||||||
g = build_protocol_graph(
|
|
||||||
labware_info=_cross_slot_labware_info(),
|
|
||||||
protocol_steps=_cross_slot_protocol_steps(targets, dis_vols),
|
|
||||||
workstation_name="PRCXI",
|
|
||||||
)
|
|
||||||
|
|
||||||
transfer_nodes = _nodes_by_template(g, "transfer_liquid")
|
|
||||||
assert len(transfer_nodes) == 1
|
|
||||||
tnode = transfer_nodes[0]
|
|
||||||
transfer_id = tnode["id"]
|
|
||||||
|
|
||||||
# params.targets:v2 中 list 形态在 INPUT_PORT_MAPPING 处理后被清空([])或为单字符串
|
|
||||||
# (不再是原始 list[str]——避免下游 runtime 对其再做无序聚合)
|
|
||||||
tparams = tnode.get("param", {}) or {}
|
|
||||||
assert not isinstance(tparams.get("targets"), list) or tparams.get("targets") == [], (
|
|
||||||
f"v2:params.targets 不再是非空 list;实际 {tparams.get('targets')!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# targets_identifier 端口:只有 1 条入边
|
|
||||||
in_targets_edges = [
|
|
||||||
e for e in _edges_to(g, transfer_id)
|
|
||||||
if e.get("target_handle_key") == "targets_identifier"
|
|
||||||
]
|
|
||||||
assert len(in_targets_edges) == 1, (
|
|
||||||
f"v2:transfer_liquid.targets_identifier 必须是单入边(来自 merged set_liquid);"
|
|
||||||
f" 实际 {len(in_targets_edges)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 这条入边的源端口必须是 output_wells
|
|
||||||
edge = in_targets_edges[0]
|
|
||||||
assert edge.get("source_handle_key") == "output_wells"
|
|
||||||
|
|
||||||
|
|
||||||
def test_str_targets_no_merged_node_emitted():
|
|
||||||
"""对照组:targets 为 str(单 reagent) → 不插入 merged set_liquid_from_plate 节点。
|
|
||||||
|
|
||||||
保证 v2 改造**只**对 list 形态触发,单 reagent 走 P3 原有 per-plate set_liquid 路径。
|
|
||||||
"""
|
|
||||||
labware = _cross_slot_labware_info()
|
|
||||||
labware["plate_slot2"]["well"] = ["A1", "A2", "A3"]
|
|
||||||
|
|
||||||
steps = [
|
|
||||||
{
|
|
||||||
"action": "transfer_liquid",
|
|
||||||
"parameters": {
|
|
||||||
"sources": "l1",
|
|
||||||
"targets": "plate_slot2", # ← 单 str,非 list
|
|
||||||
"tip_racks": "tiprack_12",
|
|
||||||
"asp_vols": [8.3, 8.3, 8.3],
|
|
||||||
"dis_vols": [8.3, 8.3, 8.3],
|
|
||||||
},
|
|
||||||
"step_number": 1,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
g = build_protocol_graph(
|
|
||||||
labware_info=labware,
|
|
||||||
protocol_steps=steps,
|
|
||||||
workstation_name="PRCXI",
|
|
||||||
)
|
|
||||||
merged_nodes = [
|
|
||||||
n for n in _nodes_by_template(g, "set_liquid_from_plate")
|
|
||||||
if str(n.get("name", "")).startswith("_merged_targets_")
|
|
||||||
]
|
|
||||||
assert merged_nodes == [], "str 形态 targets 不应触发 v2 merged 聚合节点"
|
|
||||||
@@ -1,452 +0,0 @@
|
|||||||
"""P8 — Stage 3 (``workflow/common.py``) 写入 ``set_liquid_from_plate.param.liquid_names`` 时
|
|
||||||
优先取 ``reagent[key].liquid_name``,缺省时 fallback 到 reagent_key。
|
|
||||||
|
|
||||||
对应 ``product_designs/protocol_convert/08-liquid-name-from-reagent-block.md`` §3.4 + §5。
|
|
||||||
|
|
||||||
设计要点
|
|
||||||
--------
|
|
||||||
- ``reagent[key].liquid_name`` 是 P8 新增的**可选**字段,承载真实化学名(与 reagent_key
|
|
||||||
解耦:reagent_key 仍是数据流引用名 / 业务别名,``liquid_name`` 是写入 PLR tracker /
|
|
||||||
前端的 human-readable 名称)。
|
|
||||||
- ``liquid_name`` 来源优先级:Stage 0 mock ``Well.load_liquid(liquid=...)`` 实参 >
|
|
||||||
README 语义词 > 不写(Stage 3 fallback 到 reagent_key)。
|
|
||||||
- ``liquid_name`` 保留空格 / 中文 / 括号等原字符,**不**做 snake_case / underscore 替换。
|
|
||||||
- 旧 JSON(无 ``liquid_name`` 字段)行为完全不变(设计点 §7.A)。
|
|
||||||
|
|
||||||
测试用例
|
|
||||||
--------
|
|
||||||
- ``test_per_plate_fallback_when_no_liquid_name`` —— 缺省 fallback:
|
|
||||||
reagent 块无 ``liquid_name`` → liquid_names[i] == reagent_key(与 P8 前一致)。
|
|
||||||
- ``test_per_plate_uses_explicit_liquid_name`` —— 显式 liquid_name:
|
|
||||||
liquid_names[i] == "EDTA Plasma"。
|
|
||||||
- ``test_per_plate_preserves_spaces_and_special_chars`` —— 含空格 / 括号:
|
|
||||||
liquid_names[i] 不被 ``replace(" ", "_")`` 处理(不同于 reagent_key 用的 res_id)。
|
|
||||||
- ``test_merged_node_uses_explicit_liquid_name_per_dispense`` —— merged 节点
|
|
||||||
每个 dispense 独立取 ``liquid_name or key``,部分有部分无能共存。
|
|
||||||
- ``test_liquid_name_independent_of_reagent_key_normalization`` —— 与 P4 共存:
|
|
||||||
reagent_key 仍是 ``samples_2`` 等去重后缀,但 liquid_names 写的是真实化学名。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import types
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict, List
|
|
||||||
|
|
||||||
|
|
||||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
|
||||||
if str(ROOT_DIR) not in sys.path:
|
|
||||||
sys.path.insert(0, str(ROOT_DIR))
|
|
||||||
|
|
||||||
|
|
||||||
def _install_fake_optional_deps() -> None:
|
|
||||||
"""与 test_common_set_liquid_dedup.py 一致的可选依赖 stub。"""
|
|
||||||
if "matplotlib" not in sys.modules:
|
|
||||||
sys.modules["matplotlib"] = types.ModuleType("matplotlib")
|
|
||||||
if "matplotlib.pyplot" not in sys.modules:
|
|
||||||
sys.modules["matplotlib.pyplot"] = types.ModuleType("matplotlib.pyplot")
|
|
||||||
try:
|
|
||||||
from networkx.drawing import nx_agraph # noqa: F401
|
|
||||||
except Exception:
|
|
||||||
nx_drawing = types.ModuleType("networkx.drawing")
|
|
||||||
nx_agraph_mod = types.ModuleType("networkx.drawing.nx_agraph")
|
|
||||||
nx_agraph_mod.to_agraph = lambda _g: None # type: ignore[attr-defined]
|
|
||||||
nx_drawing.nx_agraph = nx_agraph_mod # type: ignore[attr-defined]
|
|
||||||
sys.modules["networkx.drawing"] = nx_drawing
|
|
||||||
sys.modules["networkx.drawing.nx_agraph"] = nx_agraph_mod
|
|
||||||
|
|
||||||
|
|
||||||
_install_fake_optional_deps()
|
|
||||||
|
|
||||||
import pytest # noqa: E402
|
|
||||||
|
|
||||||
from unilabos.workflow.common import build_protocol_graph # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== 辅助 ====================
|
|
||||||
|
|
||||||
|
|
||||||
def _set_liquid_nodes(graph) -> List[Dict[str, Any]]:
|
|
||||||
return [
|
|
||||||
{"id": nid, **node}
|
|
||||||
for nid, node in graph.nodes.items()
|
|
||||||
if node.get("template_name") == "set_liquid_from_plate"
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _per_plate_for(graph, reagent_key: str) -> Dict[str, Any]:
|
|
||||||
"""根据 ``description = "Set liquid: <reagent_key>"`` 反查 per-plate 节点。"""
|
|
||||||
for n in _set_liquid_nodes(graph):
|
|
||||||
if n.get("description") == f"Set liquid: {reagent_key}":
|
|
||||||
return n
|
|
||||||
raise AssertionError(f"未找到 per-plate set_liquid_from_plate(reagent_key={reagent_key!r})")
|
|
||||||
|
|
||||||
|
|
||||||
def _merged_nodes(graph) -> List[Dict[str, Any]]:
|
|
||||||
return [
|
|
||||||
n for n in _set_liquid_nodes(graph)
|
|
||||||
if str(n.get("name", "")).startswith("_merged_targets_")
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _make_source_target_labware(
|
|
||||||
*,
|
|
||||||
source_key: str = "src_1",
|
|
||||||
source_liquid_name: str | None = None,
|
|
||||||
target_keys: List[str] | None = None,
|
|
||||||
target_liquid_names: Dict[str, str] | None = None,
|
|
||||||
) -> Dict[str, Dict[str, Any]]:
|
|
||||||
"""构造 1 个 source + N 个 target reagent + 1 个 tip rack。
|
|
||||||
|
|
||||||
``*_liquid_name`` 为 None / 缺省时**不**写入 ``liquid_name`` 字段,
|
|
||||||
模拟旧 schema / mock 未给 liquid_name 的真实回归场景。
|
|
||||||
"""
|
|
||||||
info: Dict[str, Dict[str, Any]] = {}
|
|
||||||
source_entry: Dict[str, Any] = {
|
|
||||||
"slot": 1,
|
|
||||||
"well": ["A1"],
|
|
||||||
"labware": "nest_12_reservoir_15ml",
|
|
||||||
"object": "source",
|
|
||||||
}
|
|
||||||
if source_liquid_name is not None:
|
|
||||||
source_entry["liquid_name"] = source_liquid_name
|
|
||||||
info[source_key] = source_entry
|
|
||||||
|
|
||||||
target_keys = target_keys or ["t_A"]
|
|
||||||
target_liquid_names = target_liquid_names or {}
|
|
||||||
for i, tk in enumerate(target_keys, start=1):
|
|
||||||
entry: Dict[str, Any] = {
|
|
||||||
"slot": 2 + i,
|
|
||||||
"well": ["A1"],
|
|
||||||
"labware": "nest_96_wellplate_2ml_deep",
|
|
||||||
"object": "target",
|
|
||||||
}
|
|
||||||
if tk in target_liquid_names:
|
|
||||||
entry["liquid_name"] = target_liquid_names[tk]
|
|
||||||
info[tk] = entry
|
|
||||||
|
|
||||||
info["tiprack_12"] = {
|
|
||||||
"slot": 12,
|
|
||||||
"well": [],
|
|
||||||
"labware": "opentrons_96_tiprack_300ul",
|
|
||||||
"object": "tiprack",
|
|
||||||
}
|
|
||||||
return info
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== T1 缺省 fallback ====================
|
|
||||||
|
|
||||||
|
|
||||||
def test_per_plate_fallback_when_no_liquid_name():
|
|
||||||
"""reagent block 无 ``liquid_name`` 字段 → liquid_names[i] == reagent_key(P8 前行为)。"""
|
|
||||||
labware = _make_source_target_labware(
|
|
||||||
source_key="src_1",
|
|
||||||
target_keys=["t_A"],
|
|
||||||
# 都不给 liquid_name
|
|
||||||
)
|
|
||||||
steps = [
|
|
||||||
{
|
|
||||||
"action": "transfer_liquid",
|
|
||||||
"parameters": {
|
|
||||||
"sources": "src_1",
|
|
||||||
"targets": "t_A",
|
|
||||||
"tip_racks": "tiprack_12",
|
|
||||||
"asp_vols": [10.0],
|
|
||||||
"dis_vols": [10.0],
|
|
||||||
},
|
|
||||||
"step_number": 1,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
g = build_protocol_graph(
|
|
||||||
labware_info=labware,
|
|
||||||
protocol_steps=steps,
|
|
||||||
workstation_name="PRCXI",
|
|
||||||
)
|
|
||||||
|
|
||||||
src_node = _per_plate_for(g, "src_1")
|
|
||||||
tgt_node = _per_plate_for(g, "t_A")
|
|
||||||
assert src_node["param"]["liquid_names"] == ["src_1"], (
|
|
||||||
f"无 liquid_name 时 source per-plate 应 fallback 到 reagent_key;"
|
|
||||||
f" 实际 {src_node['param']['liquid_names']}"
|
|
||||||
)
|
|
||||||
assert tgt_node["param"]["liquid_names"] == ["t_A"], (
|
|
||||||
f"无 liquid_name 时 target per-plate 应 fallback 到 reagent_key;"
|
|
||||||
f" 实际 {tgt_node['param']['liquid_names']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== T2 显式 liquid_name ====================
|
|
||||||
|
|
||||||
|
|
||||||
def test_per_plate_uses_explicit_liquid_name():
|
|
||||||
"""reagent block 含 ``liquid_name`` → liquid_names[i] 用该值(不是 reagent_key)。"""
|
|
||||||
labware = _make_source_target_labware(
|
|
||||||
source_key="src_1",
|
|
||||||
source_liquid_name="EDTA Plasma",
|
|
||||||
target_keys=["t_A"],
|
|
||||||
target_liquid_names={"t_A": "PBS Diluent"},
|
|
||||||
)
|
|
||||||
steps = [
|
|
||||||
{
|
|
||||||
"action": "transfer_liquid",
|
|
||||||
"parameters": {
|
|
||||||
"sources": "src_1",
|
|
||||||
"targets": "t_A",
|
|
||||||
"tip_racks": "tiprack_12",
|
|
||||||
"asp_vols": [10.0],
|
|
||||||
"dis_vols": [10.0],
|
|
||||||
},
|
|
||||||
"step_number": 1,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
g = build_protocol_graph(
|
|
||||||
labware_info=labware,
|
|
||||||
protocol_steps=steps,
|
|
||||||
workstation_name="PRCXI",
|
|
||||||
)
|
|
||||||
|
|
||||||
src_node = _per_plate_for(g, "src_1")
|
|
||||||
tgt_node = _per_plate_for(g, "t_A")
|
|
||||||
assert src_node["param"]["liquid_names"] == ["EDTA Plasma"], (
|
|
||||||
f"source per-plate 应使用 reagent.liquid_name;实际 {src_node['param']['liquid_names']}"
|
|
||||||
)
|
|
||||||
assert tgt_node["param"]["liquid_names"] == ["PBS Diluent"], (
|
|
||||||
f"target per-plate 应使用 reagent.liquid_name;实际 {tgt_node['param']['liquid_names']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== T3 空格 / 括号 ====================
|
|
||||||
|
|
||||||
|
|
||||||
def test_per_plate_preserves_spaces_and_special_chars():
|
|
||||||
"""``liquid_name`` 保留空格 / 括号 / 中文等原字符,不被 replace(' ', '_') 处理。
|
|
||||||
|
|
||||||
这条与 reagent_key 走 ``res_id = str(labware_id).replace(' ', '_')`` 的语义不同。
|
|
||||||
"""
|
|
||||||
labware = _make_source_target_labware(
|
|
||||||
source_key="src_1",
|
|
||||||
source_liquid_name="Tris HCl pH 8.0 (1×)",
|
|
||||||
target_keys=["t_A"],
|
|
||||||
target_liquid_names={"t_A": "稀释液 A"},
|
|
||||||
)
|
|
||||||
steps = [
|
|
||||||
{
|
|
||||||
"action": "transfer_liquid",
|
|
||||||
"parameters": {
|
|
||||||
"sources": "src_1",
|
|
||||||
"targets": "t_A",
|
|
||||||
"tip_racks": "tiprack_12",
|
|
||||||
"asp_vols": [10.0],
|
|
||||||
"dis_vols": [10.0],
|
|
||||||
},
|
|
||||||
"step_number": 1,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
g = build_protocol_graph(
|
|
||||||
labware_info=labware,
|
|
||||||
protocol_steps=steps,
|
|
||||||
workstation_name="PRCXI",
|
|
||||||
)
|
|
||||||
|
|
||||||
src_node = _per_plate_for(g, "src_1")
|
|
||||||
tgt_node = _per_plate_for(g, "t_A")
|
|
||||||
|
|
||||||
assert src_node["param"]["liquid_names"] == ["Tris HCl pH 8.0 (1×)"], (
|
|
||||||
f"空格 / 括号应原样保留;实际 {src_node['param']['liquid_names']}"
|
|
||||||
)
|
|
||||||
assert tgt_node["param"]["liquid_names"] == ["稀释液 A"], (
|
|
||||||
f"中文应原样保留;实际 {tgt_node['param']['liquid_names']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# reagent_key 自身仍受 ``res_id = replace(' ', '_')`` 影响,
|
|
||||||
# 但本测试 reagent_key 不含空格,故 sl_node_title 仍以 reagent_key 为根。
|
|
||||||
# 这里仅断言 liquid_names 字段独立于 reagent_key normalize。
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== T4 merged 节点跨板部分有部分无 ====================
|
|
||||||
|
|
||||||
|
|
||||||
def test_merged_node_uses_explicit_liquid_name_per_dispense():
|
|
||||||
"""merged 节点 ``liquid_names`` 与 list-targets 同长,每个元素独立取
|
|
||||||
``reagent[key].liquid_name or key``:本例 3 个 target,2 个有显式名、1 个无。
|
|
||||||
"""
|
|
||||||
labware = _make_source_target_labware(
|
|
||||||
source_key="src_1",
|
|
||||||
target_keys=["t_A", "t_B", "t_C"],
|
|
||||||
target_liquid_names={
|
|
||||||
"t_A": "Plasma",
|
|
||||||
# t_B 无 liquid_name
|
|
||||||
"t_C": "Buffer X",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
steps = [
|
|
||||||
{
|
|
||||||
"action": "transfer_liquid",
|
|
||||||
"parameters": {
|
|
||||||
"sources": "src_1",
|
|
||||||
"targets": ["t_A", "t_B", "t_C"],
|
|
||||||
"tip_racks": "tiprack_12",
|
|
||||||
"asp_vols": [5.0] * 3,
|
|
||||||
"dis_vols": [5.0] * 3,
|
|
||||||
},
|
|
||||||
"step_number": 1,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
g = build_protocol_graph(
|
|
||||||
labware_info=labware,
|
|
||||||
protocol_steps=steps,
|
|
||||||
workstation_name="PRCXI",
|
|
||||||
)
|
|
||||||
|
|
||||||
merged = _merged_nodes(g)
|
|
||||||
assert len(merged) == 1, f"应有 1 个 merged 节点,实际 {len(merged)}"
|
|
||||||
liquid_names = merged[0]["param"]["liquid_names"]
|
|
||||||
assert liquid_names == ["Plasma", "t_B", "Buffer X"], (
|
|
||||||
f"merged 每 dispense 独立取 liquid_name or key;实际 {liquid_names}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== T5 与 P4 reagent_key 后缀共存 ====================
|
|
||||||
|
|
||||||
|
|
||||||
def test_liquid_name_independent_of_reagent_key_normalization():
|
|
||||||
"""P4 命名链产生 ``samples_2`` 这种带后缀的 reagent_key(跨板去重);
|
|
||||||
P8 ``liquid_name`` 应保持原始化学名,**不**带 P4 的去重后缀。
|
|
||||||
|
|
||||||
构造:2 个 target reagent_keys ``samples`` / ``samples_2``(不同 slot,
|
|
||||||
模拟跨板同液体被 Stage 2 去重),都标 liquid_name="Bacterial Culture"。
|
|
||||||
"""
|
|
||||||
labware = _make_source_target_labware(
|
|
||||||
source_key="src_1",
|
|
||||||
target_keys=["samples", "samples_2"],
|
|
||||||
target_liquid_names={
|
|
||||||
"samples": "Bacterial Culture",
|
|
||||||
"samples_2": "Bacterial Culture",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
steps = [
|
|
||||||
{
|
|
||||||
"action": "transfer_liquid",
|
|
||||||
"parameters": {
|
|
||||||
"sources": "src_1",
|
|
||||||
"targets": ["samples", "samples_2"],
|
|
||||||
"tip_racks": "tiprack_12",
|
|
||||||
"asp_vols": [5.0, 5.0],
|
|
||||||
"dis_vols": [5.0, 5.0],
|
|
||||||
},
|
|
||||||
"step_number": 1,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
g = build_protocol_graph(
|
|
||||||
labware_info=labware,
|
|
||||||
protocol_steps=steps,
|
|
||||||
workstation_name="PRCXI",
|
|
||||||
)
|
|
||||||
|
|
||||||
merged = _merged_nodes(g)
|
|
||||||
assert len(merged) == 1
|
|
||||||
liquid_names = merged[0]["param"]["liquid_names"]
|
|
||||||
assert liquid_names == ["Bacterial Culture", "Bacterial Culture"], (
|
|
||||||
f"P8 liquid_name 应与 P4 reagent_key 后缀解耦:同液体的两个 reagent_key 应得相同"
|
|
||||||
f" liquid_name;实际 {liquid_names}"
|
|
||||||
)
|
|
||||||
# 同时 reagent_key 仍是 samples / samples_2(不变)
|
|
||||||
wells = merged[0]["param"]["wells"]
|
|
||||||
parents = [w["parent"] for w in wells]
|
|
||||||
assert parents == ["samples", "samples_2"], (
|
|
||||||
f"merged wells.parent 应等于 list-targets reagent_keys;实际 {parents}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== T6 source per-plate / target per-plate 同步生效 ====================
|
|
||||||
|
|
||||||
|
|
||||||
def test_both_source_and_target_per_plate_use_liquid_name():
|
|
||||||
"""str-targets 路径(无 merged)下,source 和 target 都走 per-plate emit,
|
|
||||||
各自独立取 ``liquid_name``。"""
|
|
||||||
labware = _make_source_target_labware(
|
|
||||||
source_key="src_1",
|
|
||||||
source_liquid_name="Reagent A",
|
|
||||||
target_keys=["t_A"],
|
|
||||||
target_liquid_names={"t_A": "Reagent B"},
|
|
||||||
)
|
|
||||||
steps = [
|
|
||||||
{
|
|
||||||
"action": "transfer_liquid",
|
|
||||||
"parameters": {
|
|
||||||
"sources": "src_1",
|
|
||||||
"targets": "t_A", # str-targets,不触发 merged
|
|
||||||
"tip_racks": "tiprack_12",
|
|
||||||
"asp_vols": [10.0],
|
|
||||||
"dis_vols": [10.0],
|
|
||||||
},
|
|
||||||
"step_number": 1,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
g = build_protocol_graph(
|
|
||||||
labware_info=labware,
|
|
||||||
protocol_steps=steps,
|
|
||||||
workstation_name="PRCXI",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert _merged_nodes(g) == [], "str-targets 不应产生 merged 节点"
|
|
||||||
src_node = _per_plate_for(g, "src_1")
|
|
||||||
tgt_node = _per_plate_for(g, "t_A")
|
|
||||||
assert src_node["param"]["liquid_names"] == ["Reagent A"]
|
|
||||||
assert tgt_node["param"]["liquid_names"] == ["Reagent B"]
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== T7 多孔同 reagent → 整列 liquid_names 一致 ====================
|
|
||||||
|
|
||||||
|
|
||||||
def test_multi_well_reagent_replicates_liquid_name():
|
|
||||||
"""1 个 reagent 含 8 wells(multi-channel 扩展场景)→ liquid_names 应是
|
|
||||||
``[liquid_name] * 8``,与 wells 长度一致。"""
|
|
||||||
labware: Dict[str, Dict[str, Any]] = {
|
|
||||||
"src_1": {
|
|
||||||
"slot": 1,
|
|
||||||
"well": ["A1", "B1", "C1", "D1", "E1", "F1", "G1", "H1"],
|
|
||||||
"labware": "nest_96_wellplate_100ul_pcr_full_skirt",
|
|
||||||
"object": "source",
|
|
||||||
"liquid_name": "Mastermix",
|
|
||||||
},
|
|
||||||
"t_A": {
|
|
||||||
"slot": 3,
|
|
||||||
"well": ["A1"],
|
|
||||||
"labware": "nest_96_wellplate_2ml_deep",
|
|
||||||
"object": "target",
|
|
||||||
},
|
|
||||||
"tiprack_12": {
|
|
||||||
"slot": 12,
|
|
||||||
"well": [],
|
|
||||||
"labware": "opentrons_96_tiprack_300ul",
|
|
||||||
"object": "tiprack",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
steps = [
|
|
||||||
{
|
|
||||||
"action": "transfer_liquid",
|
|
||||||
"parameters": {
|
|
||||||
"sources": "src_1",
|
|
||||||
"targets": "t_A",
|
|
||||||
"tip_racks": "tiprack_12",
|
|
||||||
"asp_vols": [10.0],
|
|
||||||
"dis_vols": [10.0],
|
|
||||||
},
|
|
||||||
"step_number": 1,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
g = build_protocol_graph(
|
|
||||||
labware_info=labware,
|
|
||||||
protocol_steps=steps,
|
|
||||||
workstation_name="PRCXI",
|
|
||||||
)
|
|
||||||
|
|
||||||
src_node = _per_plate_for(g, "src_1")
|
|
||||||
liquid_names = src_node["param"]["liquid_names"]
|
|
||||||
assert liquid_names == ["Mastermix"] * 8, (
|
|
||||||
f"per-plate 应把 liquid_name 复制 well_count 份;实际 {liquid_names}"
|
|
||||||
)
|
|
||||||
# 同时 wells / volumes 长度一致
|
|
||||||
assert len(src_node["param"]["wells"]) == 8
|
|
||||||
assert len(src_node["param"]["volumes"]) == 8
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
"""P6 §17 hint bug —— `_infer_plate_num_children_from_labware_hint` 误把
|
|
||||||
reagent_id 末尾数字(如 ``samples_6`` 的 ``_6``)当作孔板规格,导致
|
|
||||||
``_apply_target_labware_class_auto_match`` fallback 到 PRCXI 4-孔 trough 模板。
|
|
||||||
|
|
||||||
跨板 fix(P2 v2 §14)把 plate name 作为 prefix 编码进 ``well_names`` 之后,
|
|
||||||
runtime 调用 ``plate.get_well("A5")`` 严格定位 well,trough plate 上不存在
|
|
||||||
``A5`` 会直接 IndexError,使得这个隐藏多年的孔数推断 bug 浮出。
|
|
||||||
|
|
||||||
修复策略(方案 A)
|
|
||||||
-----
|
|
||||||
hint 只用 ``item.get("labware", "")``,**不再**拼上 ``labware_id``(reagent_key
|
|
||||||
是业务名,不应参与孔板规格推断)。
|
|
||||||
|
|
||||||
测试矩阵
|
|
||||||
----
|
|
||||||
- ``test_reagent_key_numeric_suffix_must_not_match_hint`` —— samples_6 / samples_24 /
|
|
||||||
samples_96 + nunc_rectangular_agar_plate → hint 返回 None(labware string 不带孔数信息)。
|
|
||||||
- ``test_labware_string_X_well_correctly_inferred`` —— labware="nest_96_wellplate..." → 96;
|
|
||||||
"custom_384_wellplate" → 384;"nest_24_wellplate_2ml_pcr" → 24。
|
|
||||||
- ``test_apply_does_not_classify_samples_6_as_trough`` —— 集成:构造 Agar Plating-like
|
|
||||||
reagent block(slot 8 上 12 个 samples_X,X 末尾含 6/24/96),跑
|
|
||||||
``_apply_target_labware_class_auto_match`` 后,samples_6/24 不再得到 trough class。
|
|
||||||
- ``test_real_labware_96_wellplate_still_inferred_via_labware_str`` —— 即便 labware_id
|
|
||||||
与孔数无关,``nest_96_wellplate_100ul_pcr_full_skirt`` 这种 labware 命名仍应被识别为 96。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import types
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
|
||||||
if str(ROOT_DIR) not in sys.path:
|
|
||||||
sys.path.insert(0, str(ROOT_DIR))
|
|
||||||
|
|
||||||
|
|
||||||
def _install_fake_optional_deps() -> None:
|
|
||||||
if "matplotlib" not in sys.modules:
|
|
||||||
sys.modules["matplotlib"] = types.ModuleType("matplotlib")
|
|
||||||
if "matplotlib.pyplot" not in sys.modules:
|
|
||||||
sys.modules["matplotlib.pyplot"] = types.ModuleType("matplotlib.pyplot")
|
|
||||||
|
|
||||||
|
|
||||||
_install_fake_optional_deps()
|
|
||||||
|
|
||||||
import pytest # noqa: E402
|
|
||||||
|
|
||||||
from unilabos.workflow.common import ( # noqa: E402
|
|
||||||
_apply_target_labware_class_auto_match,
|
|
||||||
_infer_plate_num_children_from_labware_hint,
|
|
||||||
_reconcile_slot_carrier_target_class,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== unit:hint 函数本身 ====================
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"labware_id",
|
|
||||||
["samples_6", "samples_24", "samples_96", "samples_12", "samples_48"],
|
|
||||||
)
|
|
||||||
def test_reagent_key_numeric_suffix_must_not_match_hint(labware_id):
|
|
||||||
"""reagent_id 末尾的孔数关键字数字不应被识别为孔板规格。"""
|
|
||||||
item = {
|
|
||||||
"slot": 8,
|
|
||||||
"well": ["A5"],
|
|
||||||
"labware": "nunc_rectangular_agar_plate",
|
|
||||||
"object": "target",
|
|
||||||
}
|
|
||||||
assert _infer_plate_num_children_from_labware_hint(labware_id, item) is None, (
|
|
||||||
f"reagent_id {labware_id!r} 不应被识别为孔板规格 "
|
|
||||||
f"(其末尾数字应当被忽略;labware string 不含 96/384/etc 关键字)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"labware_str,expected",
|
|
||||||
[
|
|
||||||
("nest_96_wellplate_100ul_pcr_full_skirt", 96),
|
|
||||||
("custom_384_wellplate", 384),
|
|
||||||
("nest_24_wellplate_2ml_pcr", 24),
|
|
||||||
("custom_48_wellplate", 48),
|
|
||||||
("opentrons_12_wellplate_15ml", 12),
|
|
||||||
("nest_6_wellplate_5ml", 6),
|
|
||||||
("nunc_rectangular_agar_plate", None),
|
|
||||||
("", None),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_labware_string_well_count_inferred(labware_str, expected):
|
|
||||||
item = {"labware": labware_str}
|
|
||||||
assert (
|
|
||||||
_infer_plate_num_children_from_labware_hint("samples", item) == expected
|
|
||||||
), f"labware {labware_str!r} 应推断为 {expected!r}"
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== integration:模拟 Agar Plating ====================
|
|
||||||
|
|
||||||
|
|
||||||
def _agar_plating_reagent_block():
|
|
||||||
"""反推自 unilabos_data/req_workflow_upload.json:12 列 × 9 reagent per step。
|
|
||||||
|
|
||||||
slot 8 (mapped 14) 上 12 个 reagent_keys: samples_6, samples_15, samples_24,
|
|
||||||
samples_33, samples_42, samples_51, samples_60, samples_69, samples_78,
|
|
||||||
samples_87, samples_96, samples_105.
|
|
||||||
"""
|
|
||||||
info = {}
|
|
||||||
slot_for_idx = {0: 3, 1: 4, 2: 5, 3: 6, 4: 7, 5: 8, 6: 9, 7: 10, 8: 11}
|
|
||||||
cols = [f"A{i + 1}" for i in range(12)]
|
|
||||||
for col_i, col in enumerate(cols):
|
|
||||||
for di in range(9):
|
|
||||||
n = col_i * 9 + di + 1
|
|
||||||
key = "samples" if n == 1 else f"samples_{n}"
|
|
||||||
info[key] = {
|
|
||||||
"slot": slot_for_idx[di],
|
|
||||||
"well": [col],
|
|
||||||
"labware": "nunc_rectangular_agar_plate",
|
|
||||||
"object": "target",
|
|
||||||
}
|
|
||||||
for i in range(12):
|
|
||||||
key = "sources" if i == 0 else f"sources_{i + 1}"
|
|
||||||
info[key] = {
|
|
||||||
"slot": 2,
|
|
||||||
"well": [cols[i]],
|
|
||||||
"labware": "nest_96_wellplate_100ul_pcr_full_skirt",
|
|
||||||
"object": "source",
|
|
||||||
}
|
|
||||||
info["tiprack_1"] = {
|
|
||||||
"slot": 1,
|
|
||||||
"well": None,
|
|
||||||
"labware": "opentrons_96_tiprack_10ul",
|
|
||||||
"object": "tiprack",
|
|
||||||
}
|
|
||||||
info["trash"] = {
|
|
||||||
"slot": 12,
|
|
||||||
"well": None,
|
|
||||||
"labware": "opentrons_1_trash_1100ml_fixed",
|
|
||||||
"object": "trash",
|
|
||||||
}
|
|
||||||
return info
|
|
||||||
|
|
||||||
|
|
||||||
def test_apply_does_not_classify_samples_6_as_trough():
|
|
||||||
"""集成回归:Agar Plating-like reagent block 跑完类匹配 + slot 统一后,
|
|
||||||
slot 8 上 12 个 reagent 不应得到 4-孔 trough class。"""
|
|
||||||
info = _agar_plating_reagent_block()
|
|
||||||
_apply_target_labware_class_auto_match(
|
|
||||||
info, preserve_tip_rack_incoming_class=True, target_device="prcxi"
|
|
||||||
)
|
|
||||||
_reconcile_slot_carrier_target_class(
|
|
||||||
info, preserve_tip_rack_incoming_class=True, target_device="prcxi"
|
|
||||||
)
|
|
||||||
slot8_keys = [
|
|
||||||
"samples_6", "samples_15", "samples_24", "samples_33",
|
|
||||||
"samples_42", "samples_51", "samples_60", "samples_69",
|
|
||||||
"samples_78", "samples_87", "samples_96", "samples_105",
|
|
||||||
]
|
|
||||||
for k in slot8_keys:
|
|
||||||
cls = info[k].get("target_class_name") or ""
|
|
||||||
assert "trough" not in cls.lower(), (
|
|
||||||
f"reagent {k} 被误识别为 trough class: {cls!r};"
|
|
||||||
"这通常是 hint 误把 reagent_id 末尾数字当孔板规格"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_real_labware_96_wellplate_still_inferred_via_labware_str():
|
|
||||||
"""labware string 含 96_wellplate 时应该正常识别为 96,不被 fix 破坏。"""
|
|
||||||
item = {
|
|
||||||
"slot": 2,
|
|
||||||
"well": ["A1"],
|
|
||||||
"labware": "nest_96_wellplate_100ul_pcr_full_skirt",
|
|
||||||
"object": "source",
|
|
||||||
}
|
|
||||||
assert _infer_plate_num_children_from_labware_hint("sources", item) == 96
|
|
||||||
@@ -1,379 +0,0 @@
|
|||||||
"""P2 v2 §14 set_liquid_from_plate 去重 —— Stage 3 (`workflow/common.py`) 集成测试。
|
|
||||||
|
|
||||||
对应 ``product_designs/protocol_convert/02-cross-slot-merge.md`` §14(2026-05-22 plan)。
|
|
||||||
|
|
||||||
§14 设计要点
|
|
||||||
-----------------
|
|
||||||
当 ``transfer_liquid.params.targets`` 是 ``list[str]`` 时,``_emit_merged_set_liquid``
|
|
||||||
已经为该 transfer 插入一个 merged ``set_liquid_from_plate`` 节点,
|
|
||||||
其 ``param.wells`` 聚合了 list 中所有 reagent_keys 的跨板 wells。
|
|
||||||
|
|
||||||
§14 之前:第二步循环(``for labware_id, item in labware_info.items()``)仍然为
|
|
||||||
list-targets 中出现的每个 reagent_key 创建一个 per-plate ``set_liquid_from_plate`` 节点,
|
|
||||||
导致**节点冗余**(per-plate 节点的 ``output_wells`` 对 transfer_liquid 的
|
|
||||||
``targets_identifier`` 边毫无贡献 —— transfer_liquid 单边只接 merged 节点)。
|
|
||||||
|
|
||||||
§14 改造:在第二步循环**之前**预扫描 protocol_steps,收集
|
|
||||||
``set_liquid_covered_by_merged: Set[str]``(出现在某个 list[str] targets 中的所有 keys)
|
|
||||||
与 ``set_liquid_referenced_by_str: Set[str]``(出现在 str targets 中的所有 keys)。
|
|
||||||
循环内对 ``object="target"`` 且 ``key ∈ covered ∧ key ∉ referenced_by_str`` 的 reagent_key
|
|
||||||
**跳过** per-plate 节点创建。
|
|
||||||
|
|
||||||
测试用例
|
|
||||||
----
|
|
||||||
- ``test_per_plate_skipped_when_covered_by_merged`` —— list-targets 覆盖的
|
|
||||||
target reagent_keys 不再产生 per-plate set_liquid_from_plate。
|
|
||||||
- ``test_per_plate_kept_when_also_referenced_by_str_targets`` —— R1 缓解:
|
|
||||||
同时被 list-targets 和 str-targets 引用的 reagent_key 仍保留 per-plate。
|
|
||||||
- ``test_str_targets_protocol_unaffected`` —— 单 slot 协议(仅 str-targets)
|
|
||||||
节点数完全不变(回归防护)。
|
|
||||||
- ``test_51b9a5_style_node_count`` —— 12 list-targets × len=9 大规模场景:
|
|
||||||
set_liquid_from_plate 总节点数 = source per-plate + merged + 0 target per-plate。
|
|
||||||
- ``test_source_per_plate_always_kept`` —— source 端不受 §14 影响:source
|
|
||||||
reagent_keys 不出现在 targets 字段中,per-plate 节点恒在。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import types
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict, List
|
|
||||||
|
|
||||||
|
|
||||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
|
||||||
if str(ROOT_DIR) not in sys.path:
|
|
||||||
sys.path.insert(0, str(ROOT_DIR))
|
|
||||||
|
|
||||||
|
|
||||||
def _install_fake_optional_deps() -> None:
|
|
||||||
"""与 test_common_cross_slot_v2.py 一致的可选依赖 stub。"""
|
|
||||||
if "matplotlib" not in sys.modules:
|
|
||||||
sys.modules["matplotlib"] = types.ModuleType("matplotlib")
|
|
||||||
if "matplotlib.pyplot" not in sys.modules:
|
|
||||||
sys.modules["matplotlib.pyplot"] = types.ModuleType("matplotlib.pyplot")
|
|
||||||
try:
|
|
||||||
from networkx.drawing import nx_agraph # noqa: F401
|
|
||||||
except Exception:
|
|
||||||
nx_drawing = types.ModuleType("networkx.drawing")
|
|
||||||
nx_agraph_mod = types.ModuleType("networkx.drawing.nx_agraph")
|
|
||||||
nx_agraph_mod.to_agraph = lambda _g: None # type: ignore[attr-defined]
|
|
||||||
nx_drawing.nx_agraph = nx_agraph_mod # type: ignore[attr-defined]
|
|
||||||
sys.modules["networkx.drawing"] = nx_drawing
|
|
||||||
sys.modules["networkx.drawing.nx_agraph"] = nx_agraph_mod
|
|
||||||
|
|
||||||
|
|
||||||
_install_fake_optional_deps()
|
|
||||||
|
|
||||||
import pytest # noqa: E402
|
|
||||||
|
|
||||||
from unilabos.workflow.common import build_protocol_graph # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== 辅助 ====================
|
|
||||||
|
|
||||||
|
|
||||||
def _nodes_by_template(graph, template_name: str) -> List[Dict[str, Any]]:
|
|
||||||
return [
|
|
||||||
{"id": nid, **node}
|
|
||||||
for nid, node in graph.nodes.items()
|
|
||||||
if node.get("template_name") == template_name
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _set_liquid_nodes_split(graph):
|
|
||||||
"""返回 (per_plate_nodes, merged_nodes)。merged 节点 name 以 `_merged_targets_` 开头。"""
|
|
||||||
all_sl = _nodes_by_template(graph, "set_liquid_from_plate")
|
|
||||||
merged = [n for n in all_sl if str(n.get("name", "")).startswith("_merged_targets_")]
|
|
||||||
per_plate = [n for n in all_sl if not str(n.get("name", "")).startswith("_merged_targets_")]
|
|
||||||
return per_plate, merged
|
|
||||||
|
|
||||||
|
|
||||||
def _labware_with_targets(target_keys: List[str], source_keys: List[str] | None = None) -> Dict[str, Dict[str, Any]]:
|
|
||||||
"""构造 labware_info:source 端 1 个 + 任意数量 target plates + tip rack。"""
|
|
||||||
info: Dict[str, Dict[str, Any]] = {}
|
|
||||||
source_keys = source_keys or ["src_1"]
|
|
||||||
for i, sk in enumerate(source_keys, start=1):
|
|
||||||
info[sk] = {
|
|
||||||
"slot": 1 + i - 1, # slot 1 占位(实际可能映射)
|
|
||||||
"well": ["A1"],
|
|
||||||
"labware": "nest_12_reservoir_15ml",
|
|
||||||
"object": "source",
|
|
||||||
}
|
|
||||||
for i, tk in enumerate(target_keys, start=1):
|
|
||||||
info[tk] = {
|
|
||||||
"slot": 2 + i, # 错开 source 使用的 slot
|
|
||||||
"well": ["A1"],
|
|
||||||
"labware": "nest_96_wellplate_2ml_deep",
|
|
||||||
"object": "target",
|
|
||||||
}
|
|
||||||
info["tiprack_12"] = {
|
|
||||||
"slot": 12,
|
|
||||||
"well": [],
|
|
||||||
"labware": "opentrons_96_tiprack_300ul",
|
|
||||||
"object": "tiprack",
|
|
||||||
}
|
|
||||||
return info
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== 用例 ====================
|
|
||||||
|
|
||||||
|
|
||||||
def test_per_plate_skipped_when_covered_by_merged():
|
|
||||||
"""单 list-targets transfer 覆盖 4 个 target reagent_keys → per-plate 不再出现。"""
|
|
||||||
targets = ["t_A", "t_B", "t_C", "t_D"]
|
|
||||||
labware = _labware_with_targets(targets, source_keys=["src_1"])
|
|
||||||
steps = [
|
|
||||||
{
|
|
||||||
"action": "transfer_liquid",
|
|
||||||
"parameters": {
|
|
||||||
"sources": "src_1",
|
|
||||||
"targets": targets,
|
|
||||||
"tip_racks": "tiprack_12",
|
|
||||||
"asp_vols": [8.0] * 4,
|
|
||||||
"dis_vols": [8.0] * 4,
|
|
||||||
},
|
|
||||||
"step_number": 1,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
g = build_protocol_graph(
|
|
||||||
labware_info=labware,
|
|
||||||
protocol_steps=steps,
|
|
||||||
workstation_name="PRCXI",
|
|
||||||
)
|
|
||||||
|
|
||||||
per_plate, merged = _set_liquid_nodes_split(g)
|
|
||||||
|
|
||||||
# merged 节点:1 个
|
|
||||||
assert len(merged) == 1, f"应有 1 个 merged 节点;实际 {len(merged)}"
|
|
||||||
|
|
||||||
# per-plate 节点:仅 source 1 个(src_1);target 端被全部跳过
|
|
||||||
per_plate_names = {n.get("description", "") for n in per_plate}
|
|
||||||
per_plate_keys = {
|
|
||||||
n.get("description", "").replace("Set liquid: ", "")
|
|
||||||
for n in per_plate
|
|
||||||
}
|
|
||||||
assert "src_1" in per_plate_keys, "source 端 per-plate 必须保留"
|
|
||||||
for tk in targets:
|
|
||||||
assert tk not in per_plate_keys, (
|
|
||||||
f"§14:target reagent_key '{tk}' 已被 merged 覆盖,不应再有 per-plate 节点;"
|
|
||||||
f" 实际 per_plate_keys={per_plate_keys}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_per_plate_kept_when_also_referenced_by_str_targets():
|
|
||||||
"""R1 缓解:t_A 既被 list-targets 引用,又被 str-targets 引用 → per-plate 必须保留。"""
|
|
||||||
targets_list = ["t_A", "t_B", "t_C"]
|
|
||||||
labware = _labware_with_targets(targets_list, source_keys=["src_1"])
|
|
||||||
steps = [
|
|
||||||
{
|
|
||||||
"action": "transfer_liquid",
|
|
||||||
"parameters": {
|
|
||||||
"sources": "src_1",
|
|
||||||
"targets": targets_list,
|
|
||||||
"tip_racks": "tiprack_12",
|
|
||||||
"asp_vols": [5.0] * 3,
|
|
||||||
"dis_vols": [5.0] * 3,
|
|
||||||
},
|
|
||||||
"step_number": 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "transfer_liquid",
|
|
||||||
"parameters": {
|
|
||||||
"sources": "src_1",
|
|
||||||
"targets": "t_A",
|
|
||||||
"tip_racks": "tiprack_12",
|
|
||||||
"asp_vols": [10.0],
|
|
||||||
"dis_vols": [10.0],
|
|
||||||
},
|
|
||||||
"step_number": 2,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
g = build_protocol_graph(
|
|
||||||
labware_info=labware,
|
|
||||||
protocol_steps=steps,
|
|
||||||
workstation_name="PRCXI",
|
|
||||||
)
|
|
||||||
|
|
||||||
per_plate, merged = _set_liquid_nodes_split(g)
|
|
||||||
per_plate_keys = {
|
|
||||||
n.get("description", "").replace("Set liquid: ", "")
|
|
||||||
for n in per_plate
|
|
||||||
}
|
|
||||||
|
|
||||||
assert "t_A" in per_plate_keys, (
|
|
||||||
f"R1:t_A 被 str transfer #2 引用,必须保留 per-plate 节点;"
|
|
||||||
f" 实际 per_plate_keys={per_plate_keys}"
|
|
||||||
)
|
|
||||||
assert "t_B" not in per_plate_keys, "t_B 仅出现在 list-targets,应跳过"
|
|
||||||
assert "t_C" not in per_plate_keys, "t_C 仅出现在 list-targets,应跳过"
|
|
||||||
|
|
||||||
# merged 节点数:1(仅 list-targets transfer #1 生成)
|
|
||||||
assert len(merged) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_str_targets_protocol_unaffected():
|
|
||||||
"""单 slot 协议(全 str-targets)→ 每个 target reagent_key 仍有 per-plate(零回归)。"""
|
|
||||||
labware = _labware_with_targets(["t_A", "t_B"], source_keys=["src_1"])
|
|
||||||
steps = [
|
|
||||||
{
|
|
||||||
"action": "transfer_liquid",
|
|
||||||
"parameters": {
|
|
||||||
"sources": "src_1",
|
|
||||||
"targets": "t_A",
|
|
||||||
"tip_racks": "tiprack_12",
|
|
||||||
"asp_vols": [10.0],
|
|
||||||
"dis_vols": [10.0],
|
|
||||||
},
|
|
||||||
"step_number": 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "transfer_liquid",
|
|
||||||
"parameters": {
|
|
||||||
"sources": "src_1",
|
|
||||||
"targets": "t_B",
|
|
||||||
"tip_racks": "tiprack_12",
|
|
||||||
"asp_vols": [20.0],
|
|
||||||
"dis_vols": [20.0],
|
|
||||||
},
|
|
||||||
"step_number": 2,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
g = build_protocol_graph(
|
|
||||||
labware_info=labware,
|
|
||||||
protocol_steps=steps,
|
|
||||||
workstation_name="PRCXI",
|
|
||||||
)
|
|
||||||
|
|
||||||
per_plate, merged = _set_liquid_nodes_split(g)
|
|
||||||
per_plate_keys = {
|
|
||||||
n.get("description", "").replace("Set liquid: ", "")
|
|
||||||
for n in per_plate
|
|
||||||
}
|
|
||||||
|
|
||||||
assert merged == [], "全 str-targets 协议不应触发 merged 节点"
|
|
||||||
assert {"src_1", "t_A", "t_B"}.issubset(per_plate_keys), (
|
|
||||||
f"单 slot 协议每个 reagent_key(含 source/target)都应保留 per-plate;"
|
|
||||||
f" 实际 {per_plate_keys}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_51b9a5_style_node_count():
|
|
||||||
"""大规模场景:N 个 list-targets transfers,每个长度 M(同 source 不同跨板)。
|
|
||||||
|
|
||||||
构造:2 个 source(src_A1、src_A2)+ 9 个 target plates × 2 个 well = 18 target reagent_keys。
|
|
||||||
2 个 transfer:
|
|
||||||
- transfer #1: targets = [t_A1_1, t_A1_2, ..., t_A1_9](同 source src_A1,跨 9 plate)
|
|
||||||
- transfer #2: targets = [t_A2_1, t_A2_2, ..., t_A2_9](同 source src_A2,跨 9 plate)
|
|
||||||
|
|
||||||
期望 set_liquid_from_plate 总节点数 = 2 source per-plate + 2 merged + 0 target per-plate = 4。
|
|
||||||
"""
|
|
||||||
target_keys_a1 = [f"t_A1_{i}" for i in range(1, 10)]
|
|
||||||
target_keys_a2 = [f"t_A2_{i}" for i in range(1, 10)]
|
|
||||||
all_target_keys = target_keys_a1 + target_keys_a2
|
|
||||||
|
|
||||||
labware = _labware_with_targets(
|
|
||||||
all_target_keys,
|
|
||||||
source_keys=["src_A1", "src_A2"],
|
|
||||||
)
|
|
||||||
|
|
||||||
steps = [
|
|
||||||
{
|
|
||||||
"action": "transfer_liquid",
|
|
||||||
"parameters": {
|
|
||||||
"sources": "src_A1",
|
|
||||||
"targets": target_keys_a1,
|
|
||||||
"tip_racks": "tiprack_12",
|
|
||||||
"asp_vols": [8.3] * 9,
|
|
||||||
"dis_vols": [8.3] * 9,
|
|
||||||
},
|
|
||||||
"step_number": 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "transfer_liquid",
|
|
||||||
"parameters": {
|
|
||||||
"sources": "src_A2",
|
|
||||||
"targets": target_keys_a2,
|
|
||||||
"tip_racks": "tiprack_12",
|
|
||||||
"asp_vols": [8.3] * 9,
|
|
||||||
"dis_vols": [8.3] * 9,
|
|
||||||
},
|
|
||||||
"step_number": 2,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
g = build_protocol_graph(
|
|
||||||
labware_info=labware,
|
|
||||||
protocol_steps=steps,
|
|
||||||
workstation_name="PRCXI",
|
|
||||||
)
|
|
||||||
|
|
||||||
per_plate, merged = _set_liquid_nodes_split(g)
|
|
||||||
|
|
||||||
assert len(merged) == 2, f"应有 2 个 merged 节点;实际 {len(merged)}"
|
|
||||||
|
|
||||||
per_plate_keys = {
|
|
||||||
n.get("description", "").replace("Set liquid: ", "")
|
|
||||||
for n in per_plate
|
|
||||||
}
|
|
||||||
|
|
||||||
# source 端:2 个 per-plate
|
|
||||||
assert "src_A1" in per_plate_keys and "src_A2" in per_plate_keys, (
|
|
||||||
f"source 端必须有 src_A1 + src_A2 per-plate;实际 {per_plate_keys}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# target 端:18 个全部被跳过
|
|
||||||
for tk in all_target_keys:
|
|
||||||
assert tk not in per_plate_keys, (
|
|
||||||
f"§14:target reagent_key '{tk}' 应被 merged 覆盖并跳过;"
|
|
||||||
f" 实际 per_plate_keys 包含 {tk}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 总节点数 == 2 + 2
|
|
||||||
assert len(per_plate) + len(merged) == 4, (
|
|
||||||
f"set_liquid_from_plate 总节点数应为 4 (2 source + 2 merged + 0 target per-plate);"
|
|
||||||
f" 实际 per_plate={len(per_plate)} merged={len(merged)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_source_per_plate_always_kept():
|
|
||||||
"""source reagent_keys 不出现在任何 targets 字段中 → per-plate 节点恒保留(与 §14 无关)。"""
|
|
||||||
target_keys = ["t_A", "t_B", "t_C"]
|
|
||||||
labware = _labware_with_targets(target_keys, source_keys=["src_X", "src_Y"])
|
|
||||||
|
|
||||||
steps = [
|
|
||||||
{
|
|
||||||
"action": "transfer_liquid",
|
|
||||||
"parameters": {
|
|
||||||
"sources": "src_X",
|
|
||||||
"targets": target_keys,
|
|
||||||
"tip_racks": "tiprack_12",
|
|
||||||
"asp_vols": [5.0] * 3,
|
|
||||||
"dis_vols": [5.0] * 3,
|
|
||||||
},
|
|
||||||
"step_number": 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "transfer_liquid",
|
|
||||||
"parameters": {
|
|
||||||
"sources": "src_Y",
|
|
||||||
"targets": "t_A",
|
|
||||||
"tip_racks": "tiprack_12",
|
|
||||||
"asp_vols": [10.0],
|
|
||||||
"dis_vols": [10.0],
|
|
||||||
},
|
|
||||||
"step_number": 2,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
g = build_protocol_graph(
|
|
||||||
labware_info=labware,
|
|
||||||
protocol_steps=steps,
|
|
||||||
workstation_name="PRCXI",
|
|
||||||
)
|
|
||||||
|
|
||||||
per_plate, _ = _set_liquid_nodes_split(g)
|
|
||||||
per_plate_keys = {
|
|
||||||
n.get("description", "").replace("Set liquid: ", "")
|
|
||||||
for n in per_plate
|
|
||||||
}
|
|
||||||
|
|
||||||
assert "src_X" in per_plate_keys, "source src_X 必须有 per-plate(source 不会被 §14 跳过)"
|
|
||||||
assert "src_Y" in per_plate_keys, "source src_Y 必须有 per-plate"
|
|
||||||
@@ -1,534 +0,0 @@
|
|||||||
"""P6 / P6.1 / P6.1.1 `labware_mapping.py` 单元测试 —— 对应 06-labware-mapping-table.md §11.7.7 / §11.8.7。
|
|
||||||
|
|
||||||
这些用例只依赖 `unilabos.workflow.labware_mapping` 自身与 PyYAML,
|
|
||||||
不需要 ROS2 / matplotlib / networkx 等环境,可直接 `pytest tests/workflow/test_labware_mapping.py`。
|
|
||||||
|
|
||||||
P6.1.1 schema(v1.9):
|
|
||||||
- 顶层 key 两段:``kinds`` / ``target_devices``(**P6.1.1 起顶层 `slot_remap` 已不支持**,下沉到 ``target_devices.<device>`` 内)
|
|
||||||
- ``target_devices.default`` 是固定段名,作为兜底物料集,第一版按 prcxi 拷贝填充,**不支持 `models` 子段**
|
|
||||||
- ``target_devices.<device>.models.<model>`` 是可选的型号粒度覆盖(slot_remap / rules)
|
|
||||||
- 旧 schema(顶层 ``vendors`` / ``slot_remap`` 或 rule 含 ``prcxi_class``)会触发 warning + fallback 到 builtin
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import warnings
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
|
||||||
if str(ROOT_DIR) not in sys.path:
|
|
||||||
sys.path.insert(0, str(ROOT_DIR))
|
|
||||||
|
|
||||||
from unilabos.workflow import labware_mapping as lm
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _reset_lru_cache():
|
|
||||||
"""每个用例后清缓存,避免 monkeypatch 跨用例污染。"""
|
|
||||||
yield
|
|
||||||
lm.reload_mapping()
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== slot_remap ====================
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"raw,object_type,want",
|
|
||||||
[
|
|
||||||
("4", "", "13"),
|
|
||||||
("8", "", "14"),
|
|
||||||
("12", "trash", "16"),
|
|
||||||
("12", "source", "12"),
|
|
||||||
("1", "", "1"),
|
|
||||||
("", "", ""),
|
|
||||||
(4, "", "13"), # 非字符串入参也应规整
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_remap_slot_basic(raw, object_type, want):
|
|
||||||
assert lm.remap_slot(raw, object_type) == want
|
|
||||||
|
|
||||||
|
|
||||||
def test_remap_slot_none_returns_empty():
|
|
||||||
assert lm.remap_slot(None) == ""
|
|
||||||
|
|
||||||
|
|
||||||
def test_remap_slot_passthrough_unknown():
|
|
||||||
assert lm.remap_slot("99") == "99"
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== infer_kind ====================
|
|
||||||
|
|
||||||
|
|
||||||
def test_infer_kind_trash_priority():
|
|
||||||
"""`trash` 在 kinds 列表第 1 条 → 优先于含 'rack' 的字符串。"""
|
|
||||||
assert lm.infer_kind("foo_trash_bar") == "trash"
|
|
||||||
assert lm.infer_kind("opentrons_fixed_trash") == "trash"
|
|
||||||
|
|
||||||
|
|
||||||
def test_infer_kind_tiprack_before_tuberack():
|
|
||||||
"""`tiprack` 子串包含 'rack',但应被 tip_rack 规则先抓到(顺序敏感)。"""
|
|
||||||
assert lm.infer_kind("opentrons_96_tiprack_300ul") == "tip_rack"
|
|
||||||
assert lm.infer_kind("opentrons_96_tiprack_20ul") == "tip_rack"
|
|
||||||
|
|
||||||
|
|
||||||
def test_infer_kind_tube_rack_variants():
|
|
||||||
assert (
|
|
||||||
lm.infer_kind("opentrons_24_tuberack_eppendorf_2ml_safelock_snapcap")
|
|
||||||
== "tube_rack"
|
|
||||||
)
|
|
||||||
assert lm.infer_kind("opentrons_10_tuberack_falcon_4x50ml_6x15ml_conical") == "tube_rack"
|
|
||||||
|
|
||||||
|
|
||||||
def test_infer_kind_object_overrides_string():
|
|
||||||
"""object 字段优先:即使字符串看起来像 plate,trash / tiprack 也能强制归类。"""
|
|
||||||
assert lm.infer_kind("anything_at_all", "tiprack") == "tip_rack"
|
|
||||||
assert lm.infer_kind("opentrons_96_wellplate", "trash") == "trash"
|
|
||||||
|
|
||||||
|
|
||||||
def test_infer_kind_default_plate():
|
|
||||||
assert lm.infer_kind("opentrons_96_wellplate_300ul_pcr") == "plate"
|
|
||||||
assert lm.infer_kind("custom_384_wellplate_2200ul") == "plate"
|
|
||||||
|
|
||||||
|
|
||||||
def test_infer_kind_rack_without_tip_is_tube_rack():
|
|
||||||
"""复现历史 `_infer_reagent_kind` 中「含 rack 不含 tip → tube_rack」的语义。"""
|
|
||||||
assert lm.infer_kind("nest_4x6_rack") == "tube_rack"
|
|
||||||
|
|
||||||
|
|
||||||
def test_infer_kind_empty_hint_returns_plate():
|
|
||||||
assert lm.infer_kind("") == "plate"
|
|
||||||
assert lm.infer_kind(None) == "plate" # type: ignore[arg-type]
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== resolve_target_class(target_device="prcxi") ====================
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"vol,want",
|
|
||||||
[
|
|
||||||
(1, "PRCXI_10uL_Tips"),
|
|
||||||
(9, "PRCXI_10uL_Tips"),
|
|
||||||
(10, "PRCXI_10uL_Tips"), # 闭区间 ≤10
|
|
||||||
(11, "PRCXI_300ul_Tips"),
|
|
||||||
(200, "PRCXI_300ul_Tips"),
|
|
||||||
(299.9, "PRCXI_300ul_Tips"),
|
|
||||||
(300, "PRCXI_1000uL_Tips"), # 300 上一档(与 <300 半开等价)
|
|
||||||
(500, "PRCXI_1000uL_Tips"),
|
|
||||||
(1000, "PRCXI_1000uL_Tips"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_resolve_tip_volume_buckets(vol, want):
|
|
||||||
assert lm.resolve_target_class("prcxi", "tip_rack", 96, vol) == want
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_tube_rack_holes():
|
|
||||||
assert lm.resolve_target_class("prcxi", "tube_rack", 24, None) == "PRCXI_EP_Adapter"
|
|
||||||
assert lm.resolve_target_class("prcxi", "tube_rack", 10, None) == "PRCXI_EP_Adapter"
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_plate_holes():
|
|
||||||
assert lm.resolve_target_class("prcxi", "plate", 96, None) == "PRCXI_BioER_96_wellplate"
|
|
||||||
assert (
|
|
||||||
lm.resolve_target_class("prcxi", "plate", 384, None) == "PRCXI_BioER_384_wellplate"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_plate_unknown_holes_returns_none():
|
|
||||||
"""48 孔板未在 YAML 列出 → None;交给 PRCXI 模板打分匹配 fallback。"""
|
|
||||||
assert lm.resolve_target_class("prcxi", "plate", 48, 2200) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_trash_any():
|
|
||||||
assert lm.resolve_target_class("prcxi", "trash", None, None) == "PRCXI_trash"
|
|
||||||
# trash 规则未约束 hole_count / volume,所以任意值都命中
|
|
||||||
assert lm.resolve_target_class("prcxi", "trash", 0, 0) == "PRCXI_trash"
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== YAML 缺失 / 热加载 ====================
|
|
||||||
|
|
||||||
|
|
||||||
def test_missing_yaml_uses_builtin(monkeypatch, tmp_path):
|
|
||||||
"""YAML 文件不存在时,应自动落到 `_BUILTIN_DEFAULT`,且打 warning。"""
|
|
||||||
bogus = tmp_path / "no_such_labware_mapping.yaml"
|
|
||||||
monkeypatch.setattr(lm, "_DEFAULT_PATH", bogus)
|
|
||||||
lm._load_mapping.cache_clear()
|
|
||||||
with warnings.catch_warnings(record=True) as caught:
|
|
||||||
warnings.simplefilter("always")
|
|
||||||
assert lm.remap_slot("4") == "13"
|
|
||||||
assert (
|
|
||||||
lm.resolve_target_class("prcxi", "plate", 96, None)
|
|
||||||
== "PRCXI_BioER_96_wellplate"
|
|
||||||
)
|
|
||||||
assert any("labware_mapping.yaml 未找到" in str(w.message) for w in caught)
|
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_yaml_uses_builtin(monkeypatch, tmp_path):
|
|
||||||
"""YAML 解析失败也应回退到 builtin,且打 warning。"""
|
|
||||||
bad = tmp_path / "labware_mapping.yaml"
|
|
||||||
bad.write_text("this is :: not valid: yaml: [unclosed", encoding="utf-8")
|
|
||||||
monkeypatch.setattr(lm, "_DEFAULT_PATH", bad)
|
|
||||||
lm._load_mapping.cache_clear()
|
|
||||||
with warnings.catch_warnings(record=True) as caught:
|
|
||||||
warnings.simplefilter("always")
|
|
||||||
assert lm.remap_slot("4") == "13"
|
|
||||||
assert any(
|
|
||||||
"labware_mapping.yaml 解析失败" in str(w.message)
|
|
||||||
or "labware_mapping.yaml 根不是 dict" in str(w.message)
|
|
||||||
for w in caught
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_yaml_reload_after_edit(monkeypatch, tmp_path):
|
|
||||||
"""临时 YAML 覆盖 + reload_mapping → 新规则生效,且原规则失效(P6.1.1 schema)。"""
|
|
||||||
tmp_yaml = tmp_path / "labware_mapping.yaml"
|
|
||||||
tmp_yaml.write_text(
|
|
||||||
'kinds:\n'
|
|
||||||
" - { pattern: 'trash', kind: trash }\n"
|
|
||||||
" - { pattern: '.*', kind: plate }\n"
|
|
||||||
'target_devices:\n'
|
|
||||||
' default:\n'
|
|
||||||
' slot_remap:\n'
|
|
||||||
' default: {"4": "99"}\n'
|
|
||||||
' by_object: {}\n'
|
|
||||||
' rules:\n'
|
|
||||||
" - { kind: plate, hole_count: 96, class_name: PRCXI_FooPlate }\n"
|
|
||||||
' prcxi:\n'
|
|
||||||
' slot_remap:\n'
|
|
||||||
' default: {"4": "99"}\n'
|
|
||||||
' by_object: {}\n'
|
|
||||||
' rules:\n'
|
|
||||||
" - { kind: plate, hole_count: 96, class_name: PRCXI_FooPlate }\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(lm, "_DEFAULT_PATH", tmp_yaml)
|
|
||||||
lm.reload_mapping()
|
|
||||||
assert lm.remap_slot("4") == "99"
|
|
||||||
assert lm.resolve_target_class("prcxi", "plate", 96, None) == "PRCXI_FooPlate"
|
|
||||||
# 新表里只有 96,没有 384 → None
|
|
||||||
assert lm.resolve_target_class("prcxi", "plate", 384, None) is None
|
|
||||||
# tube_rack / tip_rack 在新表里没规则 → None
|
|
||||||
assert lm.resolve_target_class("prcxi", "tip_rack", 96, 200) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_missing_section_uses_builtin(monkeypatch, tmp_path):
|
|
||||||
"""YAML 缺 `kinds` 段 → 该段使用 builtin,其它段保留用户值(P6.1.1 schema)。"""
|
|
||||||
partial = tmp_path / "labware_mapping.yaml"
|
|
||||||
partial.write_text(
|
|
||||||
'target_devices:\n'
|
|
||||||
' default:\n'
|
|
||||||
' slot_remap:\n'
|
|
||||||
' default: {"4": "88"}\n'
|
|
||||||
' by_object: {}\n'
|
|
||||||
' rules: []\n'
|
|
||||||
' prcxi:\n'
|
|
||||||
' slot_remap:\n'
|
|
||||||
' default: {"4": "88"}\n'
|
|
||||||
' by_object: {}\n'
|
|
||||||
' rules: []\n', # 故意没有 kinds 段
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(lm, "_DEFAULT_PATH", partial)
|
|
||||||
lm._load_mapping.cache_clear()
|
|
||||||
with warnings.catch_warnings(record=True) as caught:
|
|
||||||
warnings.simplefilter("always")
|
|
||||||
# slot_remap 用 YAML 中的覆盖值
|
|
||||||
assert lm.remap_slot("4") == "88"
|
|
||||||
# kinds 段缺失 → 使用 builtin 的 tiprack 规则
|
|
||||||
assert lm.infer_kind("opentrons_96_tiprack_300ul") == "tip_rack"
|
|
||||||
assert any("缺少 `kinds` 段" in str(w.message) for w in caught)
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== P6.1 新增用例 ====================
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_target_class_prcxi_tip_buckets():
|
|
||||||
"""PRCXI tip 量程档:≤10 / <300 / 否则 1000(与历史 _tip_prcxi_class_for_max_ul 等价)。"""
|
|
||||||
assert lm.resolve_target_class("prcxi", "tip_rack", 96, 10) == "PRCXI_10uL_Tips"
|
|
||||||
assert lm.resolve_target_class("prcxi", "tip_rack", 96, 200) == "PRCXI_300ul_Tips"
|
|
||||||
assert lm.resolve_target_class("prcxi", "tip_rack", 96, 1000) == "PRCXI_1000uL_Tips"
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_target_class_unknown_device_falls_back_to_default_section():
|
|
||||||
"""未声明的 target_device 自动回退到固定段 target_devices.default,打 warning。
|
|
||||||
第一版 default 段内容按 prcxi 拷贝 → 断言:caller 传 'tecan' 时,结果应等于查 default 段。"""
|
|
||||||
with warnings.catch_warnings(record=True) as caught:
|
|
||||||
warnings.simplefilter("always")
|
|
||||||
# tecan / beckman / 任意未声明名字 → 全部回退到固定段 "default"
|
|
||||||
assert (
|
|
||||||
lm.resolve_target_class("tecan", "tip_rack", 96, 200)
|
|
||||||
== lm.resolve_target_class("default", "tip_rack", 96, 200)
|
|
||||||
== "PRCXI_300ul_Tips" # 第一版 default 段按 prcxi 填,所以值仍是 PRCXI_*
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
lm.resolve_target_class("unknown_xxx", "plate", 96, None)
|
|
||||||
== lm.resolve_target_class("default", "plate", 96, None)
|
|
||||||
)
|
|
||||||
# 至少打 1 次 warning,提示「未声明、已回退到 default 段」
|
|
||||||
assert any(
|
|
||||||
("未在 labware_mapping.yaml" in str(w.message))
|
|
||||||
or ("target_devices.default" in str(w.message))
|
|
||||||
for w in caught
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_target_class_per_device_tip_buckets(tmp_path, monkeypatch):
|
|
||||||
"""**P6.1 核心断言**:不同 target_device 在同一体积下命中不同 tip 量程档(P6.1.1 schema)。"""
|
|
||||||
yaml_path = tmp_path / "labware_mapping.yaml"
|
|
||||||
yaml_path.write_text(
|
|
||||||
'kinds: [{pattern: ".*", kind: plate}]\n'
|
|
||||||
'target_devices:\n'
|
|
||||||
' default:\n'
|
|
||||||
' slot_remap: {default: {}, by_object: {}}\n'
|
|
||||||
' rules:\n'
|
|
||||||
' - {kind: tip_rack, hole_count: 96, volume_max: 10, class_name: PRCXI_10uL_Tips}\n'
|
|
||||||
' - {kind: tip_rack, hole_count: 96, volume_max: 299.9, class_name: PRCXI_300ul_Tips}\n'
|
|
||||||
' - {kind: tip_rack, hole_count: 96, class_name: PRCXI_1000uL_Tips}\n'
|
|
||||||
' prcxi:\n'
|
|
||||||
' slot_remap: {default: {}, by_object: {}}\n'
|
|
||||||
' rules:\n'
|
|
||||||
' - {kind: tip_rack, hole_count: 96, volume_max: 10, class_name: PRCXI_10uL_Tips}\n'
|
|
||||||
' - {kind: tip_rack, hole_count: 96, volume_max: 299.9, class_name: PRCXI_300ul_Tips}\n'
|
|
||||||
' - {kind: tip_rack, hole_count: 96, class_name: PRCXI_1000uL_Tips}\n'
|
|
||||||
' beckman:\n'
|
|
||||||
' slot_remap: {default: {}, by_object: {}}\n'
|
|
||||||
' rules:\n'
|
|
||||||
' - {kind: tip_rack, hole_count: 96, volume_max: 20, class_name: Beckman_20uL_Tips}\n'
|
|
||||||
' - {kind: tip_rack, hole_count: 96, volume_max: 199.9, class_name: Beckman_200uL_Tips}\n'
|
|
||||||
' - {kind: tip_rack, hole_count: 96, class_name: Beckman_1000uL_Tips}\n',
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
|
|
||||||
lm.reload_mapping()
|
|
||||||
|
|
||||||
# 同样的体积 200:prcxi 走 300 档、beckman 已超出 200 档 → 1000 档
|
|
||||||
assert lm.resolve_target_class("prcxi", "tip_rack", 96, 200) == "PRCXI_300ul_Tips"
|
|
||||||
assert lm.resolve_target_class("beckman", "tip_rack", 96, 200) == "Beckman_1000uL_Tips"
|
|
||||||
# 同样的体积 15:prcxi 已超出 10 档 → 300 档;beckman 仍在 20 档
|
|
||||||
assert lm.resolve_target_class("prcxi", "tip_rack", 96, 15) == "PRCXI_300ul_Tips"
|
|
||||||
assert lm.resolve_target_class("beckman", "tip_rack", 96, 15) == "Beckman_20uL_Tips"
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_section_independent_from_prcxi(tmp_path, monkeypatch):
|
|
||||||
"""default 与 prcxi 是两段独立物料集:改 default 不影响 prcxi、改 prcxi 不影响 default。
|
|
||||||
|
|
||||||
断言:把 default 段改成 Generic_Plate96,prcxi 段保持 PRCXI_Plate96 时,
|
|
||||||
caller 传未声明的名字回退到 default 拿 Generic_Plate96,传 prcxi 仍拿 PRCXI_Plate96。
|
|
||||||
"""
|
|
||||||
yaml_path = tmp_path / "labware_mapping.yaml"
|
|
||||||
yaml_path.write_text(
|
|
||||||
'kinds: [{pattern: ".*", kind: plate}]\n'
|
|
||||||
'target_devices:\n'
|
|
||||||
' default:\n' # ← 独立改 default 段
|
|
||||||
' slot_remap: {default: {}, by_object: {}}\n'
|
|
||||||
' rules:\n'
|
|
||||||
' - {kind: plate, hole_count: 96, class_name: Generic_Plate96}\n'
|
|
||||||
' prcxi:\n' # ← prcxi 段保持 PRCXI_*
|
|
||||||
' slot_remap: {default: {}, by_object: {}}\n'
|
|
||||||
' rules:\n'
|
|
||||||
' - {kind: plate, hole_count: 96, class_name: PRCXI_Plate96}\n',
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
|
|
||||||
lm.reload_mapping()
|
|
||||||
|
|
||||||
# caller 传未声明的 tecan → 走 default 段 → Generic_*
|
|
||||||
assert lm.resolve_target_class("tecan", "plate", 96, None) == "Generic_Plate96"
|
|
||||||
# caller 显式传 prcxi → 走 prcxi 段 → PRCXI_*(**不**受 default 影响)
|
|
||||||
assert lm.resolve_target_class("prcxi", "plate", 96, None) == "PRCXI_Plate96"
|
|
||||||
# 显式传 "default" 也合法(caller 可主动选择走 default 段)
|
|
||||||
assert lm.resolve_target_class("default", "plate", 96, None) == "Generic_Plate96"
|
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_yaml_schema_rejected_with_warning(tmp_path, monkeypatch):
|
|
||||||
"""旧 schema(vendors / prcxi_class)应被拒绝 + warning + 整段 fallback 到 builtin(P6.1.1 schema)。"""
|
|
||||||
legacy = tmp_path / "labware_mapping.yaml"
|
|
||||||
legacy.write_text(
|
|
||||||
'kinds: [{pattern: ".*", kind: plate}]\n'
|
|
||||||
'vendors:\n' # ← 旧顶层 key
|
|
||||||
' opentrons:\n'
|
|
||||||
' rules:\n'
|
|
||||||
" - {kind: plate, hole_count: 96, prcxi_class: PRCXI_FooPlate}\n", # ← 旧字段
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(lm, "_DEFAULT_PATH", legacy)
|
|
||||||
lm._load_mapping.cache_clear()
|
|
||||||
with warnings.catch_warnings(record=True) as caught:
|
|
||||||
warnings.simplefilter("always")
|
|
||||||
# 整段走 builtin → 96 板还是 PRCXI_BioER_96_wellplate(**不是**用户旧 YAML 中的 PRCXI_FooPlate)
|
|
||||||
assert lm.resolve_target_class("prcxi", "plate", 96, None) == "PRCXI_BioER_96_wellplate"
|
|
||||||
assert any(
|
|
||||||
("旧 schema" in str(w.message))
|
|
||||||
or ("vendors" in str(w.message))
|
|
||||||
or ("prcxi_class" in str(w.message))
|
|
||||||
for w in caught
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_target_class_unknown_kind_returns_none():
|
|
||||||
"""target_device 存在、kind 不存在 → None。"""
|
|
||||||
assert lm.resolve_target_class("prcxi", "reservoir", 12, None) is None
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== P6.1.1 新增用例(slot_remap 按 device + model 分叉) ====================
|
|
||||||
|
|
||||||
|
|
||||||
def test_remap_slot_model_level_overrides_device_level(tmp_path, monkeypatch):
|
|
||||||
"""型号级 slot_remap 优先级 > 厂商级。"""
|
|
||||||
yaml_path = tmp_path / "labware_mapping.yaml"
|
|
||||||
yaml_path.write_text(
|
|
||||||
'kinds: [{pattern: ".*", kind: plate}]\n'
|
|
||||||
'target_devices:\n'
|
|
||||||
' default:\n'
|
|
||||||
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
|
||||||
' rules: []\n'
|
|
||||||
' prcxi:\n'
|
|
||||||
' slot_remap: {default: {"4": "13"}, by_object: {trash: {"12": "16"}}}\n'
|
|
||||||
' rules: []\n'
|
|
||||||
' models:\n'
|
|
||||||
' "4040":\n'
|
|
||||||
' slot_remap: {default: {"4": "16"}, by_object: {}}\n',
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
|
|
||||||
lm.reload_mapping()
|
|
||||||
# device 级(不传 model)→ "13"
|
|
||||||
assert lm.remap_slot("4", target_device="prcxi") == "13"
|
|
||||||
# model "4040" 覆盖 → "16"
|
|
||||||
assert lm.remap_slot("4", target_device="prcxi", target_model="4040") == "16"
|
|
||||||
# model "9320" 未声明 → 静默 fallback 到 device 级 → "13"
|
|
||||||
assert lm.remap_slot("4", target_device="prcxi", target_model="9320") == "13"
|
|
||||||
|
|
||||||
|
|
||||||
def test_remap_slot_model_inherits_device_when_field_missing(tmp_path, monkeypatch):
|
|
||||||
"""model 子段声明但 slot_remap 字段缺失 → 静默继承厂商级;rules 同理。"""
|
|
||||||
yaml_path = tmp_path / "labware_mapping.yaml"
|
|
||||||
yaml_path.write_text(
|
|
||||||
'kinds: [{pattern: ".*", kind: plate}]\n'
|
|
||||||
'target_devices:\n'
|
|
||||||
' default:\n'
|
|
||||||
' slot_remap: {default: {}, by_object: {}}\n'
|
|
||||||
' rules: []\n'
|
|
||||||
' prcxi:\n'
|
|
||||||
' slot_remap: {default: {"4": "13", "8": "14"}, by_object: {}}\n'
|
|
||||||
' rules: [{kind: plate, hole_count: 96, class_name: PRCXI_PlateA}]\n'
|
|
||||||
' models:\n'
|
|
||||||
' "9320":\n'
|
|
||||||
' rules: [{kind: plate, hole_count: 96, class_name: PRCXI_PlateB}]\n', # 仅覆盖 rules,未声明 slot_remap
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
|
|
||||||
lm.reload_mapping()
|
|
||||||
# model 9320 的 slot_remap 缺字段 → 继承 prcxi.slot_remap → "4" → "13"
|
|
||||||
assert lm.remap_slot("4", target_device="prcxi", target_model="9320") == "13"
|
|
||||||
# model 9320 的 rules 覆盖 → PRCXI_PlateB
|
|
||||||
assert (
|
|
||||||
lm.resolve_target_class("prcxi", "plate", 96, None, target_model="9320")
|
|
||||||
== "PRCXI_PlateB"
|
|
||||||
)
|
|
||||||
# 不传 model → 用厂商级 rules → PRCXI_PlateA
|
|
||||||
assert lm.resolve_target_class("prcxi", "plate", 96, None) == "PRCXI_PlateA"
|
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_top_level_slot_remap_rejected(tmp_path, monkeypatch):
|
|
||||||
"""P6.1.1:顶层 slot_remap 段被视为旧 schema → warning + 整段 fallback 到 builtin。"""
|
|
||||||
legacy = tmp_path / "labware_mapping.yaml"
|
|
||||||
legacy.write_text(
|
|
||||||
'slot_remap:\n' # ← P6.1.1 已不支持的顶层段
|
|
||||||
' default: {"4": "99"}\n'
|
|
||||||
' by_object: {}\n'
|
|
||||||
'kinds: [{pattern: ".*", kind: plate}]\n'
|
|
||||||
'target_devices:\n'
|
|
||||||
' default:\n'
|
|
||||||
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
|
||||||
' rules: []\n'
|
|
||||||
' prcxi:\n'
|
|
||||||
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
|
||||||
' rules: []\n',
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(lm, "_DEFAULT_PATH", legacy)
|
|
||||||
lm._load_mapping.cache_clear()
|
|
||||||
with warnings.catch_warnings(record=True) as caught:
|
|
||||||
warnings.simplefilter("always")
|
|
||||||
# 整段走 builtin → "4" 仍然 → "13"(builtin 值),**不是** YAML 顶层的 "99"
|
|
||||||
assert lm.remap_slot("4", target_device="prcxi") == "13"
|
|
||||||
assert any(
|
|
||||||
("顶层" in str(w.message) and "slot_remap" in str(w.message))
|
|
||||||
or ("旧 schema" in str(w.message))
|
|
||||||
for w in caught
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_remap_slot_unknown_device_falls_back_with_warning(tmp_path, monkeypatch):
|
|
||||||
"""未声明的 target_device → fallback 到 default.slot_remap + warning(与 resolve_target_class 同语义)。"""
|
|
||||||
yaml_path = tmp_path / "labware_mapping.yaml"
|
|
||||||
yaml_path.write_text(
|
|
||||||
'kinds: [{pattern: ".*", kind: plate}]\n'
|
|
||||||
'target_devices:\n'
|
|
||||||
' default:\n'
|
|
||||||
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
|
||||||
' rules: []\n'
|
|
||||||
' prcxi:\n'
|
|
||||||
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
|
||||||
' rules: []\n',
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
|
|
||||||
lm.reload_mapping()
|
|
||||||
with warnings.catch_warnings(record=True) as caught:
|
|
||||||
warnings.simplefilter("always")
|
|
||||||
assert lm.remap_slot("4", target_device="tecan") == "13" # fallback 到 default
|
|
||||||
assert any(
|
|
||||||
("tecan" in str(w.message)) or ("target_devices.default" in str(w.message))
|
|
||||||
for w in caught
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_remap_slot_model_only_no_device_passthrough(tmp_path, monkeypatch):
|
|
||||||
"""caller 传 target_model 但 target_device 段不存在 → 直接走 default.slot_remap(model 名忽略)。"""
|
|
||||||
yaml_path = tmp_path / "labware_mapping.yaml"
|
|
||||||
yaml_path.write_text(
|
|
||||||
'kinds: [{pattern: ".*", kind: plate}]\n'
|
|
||||||
'target_devices:\n'
|
|
||||||
' default:\n'
|
|
||||||
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
|
||||||
' rules: []\n', # 没有 prcxi 段
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
|
|
||||||
lm.reload_mapping()
|
|
||||||
with warnings.catch_warnings(record=True):
|
|
||||||
warnings.simplefilter("always")
|
|
||||||
# target_device "prcxi" 不存在、target_model 即使传也忽略 → 走 default
|
|
||||||
assert lm.remap_slot("4", target_device="prcxi", target_model="9320") == "13"
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_section_models_subsection_warns(tmp_path, monkeypatch):
|
|
||||||
"""target_devices.default.models 不被支持 → warning,但 default.slot_remap 仍生效。"""
|
|
||||||
yaml_path = tmp_path / "labware_mapping.yaml"
|
|
||||||
yaml_path.write_text(
|
|
||||||
'kinds: [{pattern: ".*", kind: plate}]\n'
|
|
||||||
'target_devices:\n'
|
|
||||||
' default:\n'
|
|
||||||
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
|
||||||
' rules: []\n'
|
|
||||||
' models:\n' # ← default 段不支持 models
|
|
||||||
' "ghost":\n'
|
|
||||||
' slot_remap: {default: {"4": "99"}, by_object: {}}\n'
|
|
||||||
' prcxi:\n'
|
|
||||||
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
|
||||||
' rules: []\n',
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
|
|
||||||
lm._load_mapping.cache_clear()
|
|
||||||
with warnings.catch_warnings(record=True) as caught:
|
|
||||||
warnings.simplefilter("always")
|
|
||||||
# default 段的 models 被忽略 → 走 default.slot_remap → "13"(不是 "99")
|
|
||||||
assert lm.remap_slot("4", target_device="tecan", target_model="ghost") == "13"
|
|
||||||
assert any(
|
|
||||||
("default" in str(w.message) and "models" in str(w.message))
|
|
||||||
for w in caught
|
|
||||||
)
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
"""``unilabos.workflow.wf_utils.upload_workflow`` 工作流名称 fallback 链单元测试。
|
|
||||||
|
|
||||||
对应需求:上传工作流时,**优先取 metadata.workflow_name**;缺失时再回退到顶层
|
|
||||||
``workflow_name``(旧 node-link 形态遗留字段);最后才回退到文件名(去 ``.json`` 后缀)。
|
|
||||||
CLI 显式 ``-n/--workflow_name`` 永远最优先。
|
|
||||||
|
|
||||||
本测试只校验「**名称 fallback 链 + tags fallback 链**」的纯逻辑路径,
|
|
||||||
不实际访问 HTTP / 后端;通过 monkeypatch 把 ``http_client.workflow_import``
|
|
||||||
桩成可观察的捕获函数。
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
# 让 import 走 Uni-Lab-OS 包根
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
SRC = ROOT / "unilabos"
|
|
||||||
if str(ROOT) not in sys.path:
|
|
||||||
sys.path.insert(0, str(ROOT))
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def stub_upload(monkeypatch, tmp_path):
|
|
||||||
"""Monkeypatch ``http_client.workflow_import`` + ``_convert_to_node_link``,
|
|
||||||
返回 (helper, captured) 二元组:
|
|
||||||
|
|
||||||
- ``helper(workflow_data, **upload_kwargs)`` 写入 tmp_path/wf.json
|
|
||||||
并调用 ``upload_workflow``;
|
|
||||||
- ``captured`` 是 dict,记录 ``workflow_import`` 实际收到的 kwargs,
|
|
||||||
以及 ``_convert_to_node_link`` 是否被调过。
|
|
||||||
|
|
||||||
本测试不依赖真实 ``unilabos.app.web``(其级联依赖含 ``fastapi`` 等重型
|
|
||||||
package,本地 dev venv 不必装)。通过在 sys.modules 注入空壳 module 拦截
|
|
||||||
delayed import。
|
|
||||||
"""
|
|
||||||
import types
|
|
||||||
|
|
||||||
captured: Dict[str, Any] = {"workflow_import_kwargs": None, "converted": False}
|
|
||||||
|
|
||||||
def fake_workflow_import(**kwargs): # noqa: ANN003
|
|
||||||
captured["workflow_import_kwargs"] = kwargs
|
|
||||||
return {"code": 0, "data": {"uuid": "fake-uuid", "name": kwargs.get("name")}}
|
|
||||||
|
|
||||||
# 关键:在 wf_utils 触发 `from unilabos.app.web import http_client` 之前
|
|
||||||
# 用空壳 module 占位(避免触发真实 web 包的 fastapi 依赖链)。
|
|
||||||
fake_http_client = types.ModuleType("unilabos.app.web.http_client")
|
|
||||||
fake_http_client.workflow_import = fake_workflow_import # type: ignore[attr-defined]
|
|
||||||
fake_web_pkg = types.ModuleType("unilabos.app.web")
|
|
||||||
fake_web_pkg.http_client = fake_http_client # type: ignore[attr-defined]
|
|
||||||
monkeypatch.setitem(sys.modules, "unilabos.app.web", fake_web_pkg)
|
|
||||||
monkeypatch.setitem(sys.modules, "unilabos.app.web.http_client", fake_http_client)
|
|
||||||
|
|
||||||
from unilabos.workflow import wf_utils
|
|
||||||
|
|
||||||
# _convert_to_node_link 走真实路径会拉重型依赖,这里桩为 node-link 直返回
|
|
||||||
def fake_convert_to_node_link(workflow_file, workflow_data, *, target_device="prcxi", target_model=None):
|
|
||||||
captured["converted"] = True
|
|
||||||
# 返回最小合法 node-link 形态(不带 metadata,模拟当前行为)
|
|
||||||
return {"nodes": [], "edges": [], "workflow_uuid": ""}
|
|
||||||
|
|
||||||
monkeypatch.setattr(wf_utils, "_convert_to_node_link", fake_convert_to_node_link)
|
|
||||||
|
|
||||||
def helper(workflow_data: Dict[str, Any], **upload_kwargs: Any) -> Dict[str, Any]:
|
|
||||||
wf_path = tmp_path / "transfer_actions_sample.json"
|
|
||||||
wf_path.write_text(json.dumps(workflow_data, ensure_ascii=False), encoding="utf-8")
|
|
||||||
return wf_utils.upload_workflow(str(wf_path), **upload_kwargs)
|
|
||||||
|
|
||||||
return helper, captured
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== workflow_name fallback 链 ====================
|
|
||||||
|
|
||||||
|
|
||||||
def test_metadata_workflow_name_wins_over_filename(stub_upload):
|
|
||||||
"""P5 主路径:transfer_actions JSON 含 metadata.workflow_name → 优先于文件名。"""
|
|
||||||
helper, captured = stub_upload
|
|
||||||
data = {
|
|
||||||
"metadata": {"workflow_name": "PCR Prep with Categories", "tags": []},
|
|
||||||
"workflow": [],
|
|
||||||
"reagent": {},
|
|
||||||
}
|
|
||||||
helper(data)
|
|
||||||
kwargs = captured["workflow_import_kwargs"]
|
|
||||||
assert kwargs is not None and captured["converted"] is True
|
|
||||||
assert kwargs["name"] == "PCR Prep with Categories"
|
|
||||||
assert kwargs["workflow_name"] == "PCR Prep with Categories"
|
|
||||||
|
|
||||||
|
|
||||||
def test_cli_workflow_name_overrides_metadata(stub_upload):
|
|
||||||
"""CLI 显式 -n/--workflow_name 永远最优先。"""
|
|
||||||
helper, captured = stub_upload
|
|
||||||
data = {
|
|
||||||
"metadata": {"workflow_name": "Metadata Wins By Default"},
|
|
||||||
"workflow": [],
|
|
||||||
"reagent": {},
|
|
||||||
}
|
|
||||||
helper(data, workflow_name="CLI Override Name")
|
|
||||||
kwargs = captured["workflow_import_kwargs"]
|
|
||||||
assert kwargs["name"] == "CLI Override Name"
|
|
||||||
assert kwargs["workflow_name"] == "CLI Override Name"
|
|
||||||
|
|
||||||
|
|
||||||
def test_filename_used_when_no_metadata_and_no_legacy(stub_upload):
|
|
||||||
"""P5 之前的旧文件、且无顶层 workflow_name → 回退到去 .json 后缀的文件名。"""
|
|
||||||
helper, captured = stub_upload
|
|
||||||
data = {"workflow": [], "reagent": {}} # 既无 metadata,也无 workflow_name
|
|
||||||
helper(data)
|
|
||||||
kwargs = captured["workflow_import_kwargs"]
|
|
||||||
# 文件名由 fixture 固定为 transfer_actions_sample.json
|
|
||||||
assert kwargs["name"] == "transfer_actions_sample"
|
|
||||||
assert kwargs["workflow_name"] == "transfer_actions_sample"
|
|
||||||
|
|
||||||
|
|
||||||
def test_metadata_empty_string_falls_back_to_filename(stub_upload):
|
|
||||||
"""metadata.workflow_name 为空字符串(而非缺失)也应回退到文件名。"""
|
|
||||||
helper, captured = stub_upload
|
|
||||||
data = {
|
|
||||||
"metadata": {"workflow_name": " "}, # whitespace-only
|
|
||||||
"workflow": [],
|
|
||||||
"reagent": {},
|
|
||||||
}
|
|
||||||
helper(data)
|
|
||||||
kwargs = captured["workflow_import_kwargs"]
|
|
||||||
assert kwargs["name"] == "transfer_actions_sample"
|
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_top_level_workflow_name_used_when_metadata_missing(stub_upload, monkeypatch):
|
|
||||||
"""旧 node-link 文件(已是 nodes/edges 形态)顶层 workflow_name → 应被使用。
|
|
||||||
|
|
||||||
覆盖路径:``_is_node_link_format`` 直接命中 → 不走转换 → workflow_data 保留顶层
|
|
||||||
workflow_name;``orig_metadata`` 为空时 fallback 到该字段。
|
|
||||||
"""
|
|
||||||
helper, captured = stub_upload
|
|
||||||
data = {
|
|
||||||
"nodes": [],
|
|
||||||
"edges": [],
|
|
||||||
"workflow_name": "Legacy Top Name",
|
|
||||||
}
|
|
||||||
helper(data)
|
|
||||||
kwargs = captured["workflow_import_kwargs"]
|
|
||||||
assert captured["converted"] is False, "node-link 输入不应触发转换"
|
|
||||||
assert kwargs["name"] == "Legacy Top Name"
|
|
||||||
assert kwargs["workflow_name"] == "Legacy Top Name"
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== tags fallback 链 ====================
|
|
||||||
|
|
||||||
|
|
||||||
def test_metadata_tags_used_when_cli_tags_missing(stub_upload):
|
|
||||||
"""P5 主路径:metadata.tags 在 CLI 未传 tags 时被使用。"""
|
|
||||||
helper, captured = stub_upload
|
|
||||||
data = {
|
|
||||||
"metadata": {"workflow_name": "X", "tags": ["Opentrons", "PCR"]},
|
|
||||||
"workflow": [],
|
|
||||||
"reagent": {},
|
|
||||||
}
|
|
||||||
helper(data)
|
|
||||||
kwargs = captured["workflow_import_kwargs"]
|
|
||||||
assert kwargs["tags"] == ["Opentrons", "PCR"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_cli_tags_override_metadata_tags(stub_upload):
|
|
||||||
"""CLI 显式 --tags 优先于 metadata.tags。"""
|
|
||||||
helper, captured = stub_upload
|
|
||||||
data = {
|
|
||||||
"metadata": {"workflow_name": "X", "tags": ["Opentrons", "PCR"]},
|
|
||||||
"workflow": [],
|
|
||||||
"reagent": {},
|
|
||||||
}
|
|
||||||
helper(data, tags=["CLI", "Wins"])
|
|
||||||
kwargs = captured["workflow_import_kwargs"]
|
|
||||||
assert kwargs["tags"] == ["CLI", "Wins"]
|
|
||||||
@@ -1 +1 @@
|
|||||||
__version__ = "0.11.1"
|
__version__ = "0.10.19"
|
||||||
|
|||||||
6
unilabos/__main__.py
Normal file
6
unilabos/__main__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""Entry point for `python -m unilabos`."""
|
||||||
|
|
||||||
|
from unilabos.app.main import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -12,15 +12,6 @@ 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))
|
||||||
@@ -336,27 +327,6 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -651,6 +621,8 @@ def main():
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# 如果从远端获取了物料信息,则与本地物料进行同步
|
# 如果从远端获取了物料信息,则与本地物料进行同步
|
||||||
|
# 仅在本地文件模式下有意义:本地文件只含设备结构,远端有已保存的物料,需要 merge
|
||||||
|
# 远端模式下 resource_tree_set 与 request_startup_json 来自同一份数据,merge 为空操作
|
||||||
if file_path is not None and request_startup_json and "nodes" in request_startup_json:
|
if file_path is not None and request_startup_json and "nodes" in request_startup_json:
|
||||||
print_status("开始同步远端物料到本地...", "info")
|
print_status("开始同步远端物料到本地...", "info")
|
||||||
remote_tree_set = ResourceTreeSet.from_raw_dict_list(request_startup_json["nodes"])
|
remote_tree_set = ResourceTreeSet.from_raw_dict_list(request_startup_json["nodes"])
|
||||||
|
|||||||
@@ -58,14 +58,14 @@ class JobResultStore:
|
|||||||
feedback=feedback or {},
|
feedback=feedback or {},
|
||||||
timestamp=time.time(),
|
timestamp=time.time(),
|
||||||
)
|
)
|
||||||
logger.debug(f"[JobResultStore] Stored result for job {job_id[:8]}, status={status}")
|
logger.trace(f"[JobResultStore] Stored result for job {job_id[:8]}, status={status}")
|
||||||
|
|
||||||
def get_and_remove(self, job_id: str) -> Optional[JobResult]:
|
def get_and_remove(self, job_id: str) -> Optional[JobResult]:
|
||||||
"""获取并删除任务结果"""
|
"""获取并删除任务结果"""
|
||||||
with self._results_lock:
|
with self._results_lock:
|
||||||
result = self._results.pop(job_id, None)
|
result = self._results.pop(job_id, None)
|
||||||
if result:
|
if result:
|
||||||
logger.debug(f"[JobResultStore] Retrieved and removed result for job {job_id[:8]}")
|
logger.trace(f"[JobResultStore] Retrieved and removed result for job {job_id[:8]}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def get_result(self, job_id: str) -> Optional[JobResult]:
|
def get_result(self, job_id: str) -> Optional[JobResult]:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -43,7 +43,7 @@ class Base(ABC):
|
|||||||
self._type = typ
|
self._type = typ
|
||||||
self._data_type = data_type
|
self._data_type = data_type
|
||||||
self._node: Optional[Node] = None
|
self._node: Optional[Node] = None
|
||||||
|
|
||||||
def _get_node(self) -> Node:
|
def _get_node(self) -> Node:
|
||||||
if self._node is None:
|
if self._node is None:
|
||||||
try:
|
try:
|
||||||
@@ -66,7 +66,7 @@ class Base(ABC):
|
|||||||
# 直接以字符串形式处理
|
# 直接以字符串形式处理
|
||||||
if isinstance(nid, str):
|
if isinstance(nid, str):
|
||||||
nid = nid.strip()
|
nid = nid.strip()
|
||||||
|
|
||||||
# 处理包含类名的格式,如 'StringNodeId(ns=4;s=...)' 或 'NumericNodeId(ns=2;i=...)'
|
# 处理包含类名的格式,如 'StringNodeId(ns=4;s=...)' 或 'NumericNodeId(ns=2;i=...)'
|
||||||
# 提取括号内的内容
|
# 提取括号内的内容
|
||||||
match_wrapped = re.match(r'(String|Numeric|Byte|Guid|TwoByteNode|FourByteNode)NodeId\((.*)\)', nid)
|
match_wrapped = re.match(r'(String|Numeric|Byte|Guid|TwoByteNode|FourByteNode)NodeId\((.*)\)', nid)
|
||||||
@@ -116,16 +116,16 @@ class Base(ABC):
|
|||||||
def read(self) -> Tuple[Any, bool]:
|
def read(self) -> Tuple[Any, bool]:
|
||||||
"""读取节点值,返回(值, 是否出错)"""
|
"""读取节点值,返回(值, 是否出错)"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def write(self, value: Any) -> bool:
|
def write(self, value: Any) -> bool:
|
||||||
"""写入节点值,返回是否出错"""
|
"""写入节点值,返回是否出错"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def type(self) -> NodeType:
|
def type(self) -> NodeType:
|
||||||
return self._type
|
return self._type
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def node_id(self) -> str:
|
def node_id(self) -> str:
|
||||||
return self._node_id
|
return self._node_id
|
||||||
@@ -210,15 +210,15 @@ class Method(Base):
|
|||||||
super().__init__(client, name, node_id, NodeType.METHOD, data_type)
|
super().__init__(client, name, node_id, NodeType.METHOD, data_type)
|
||||||
self._parent_node_id = parent_node_id
|
self._parent_node_id = parent_node_id
|
||||||
self._parent_node = None
|
self._parent_node = None
|
||||||
|
|
||||||
def _get_parent_node(self) -> Node:
|
def _get_parent_node(self) -> Node:
|
||||||
if self._parent_node is None:
|
if self._parent_node is None:
|
||||||
try:
|
try:
|
||||||
# 处理父节点ID,使用与_get_node相同的解析逻辑
|
# 处理父节点ID,使用与_get_node相同的解析逻辑
|
||||||
import re
|
import re
|
||||||
|
|
||||||
nid = self._parent_node_id
|
nid = self._parent_node_id
|
||||||
|
|
||||||
# 如果已经是 NodeId 对象,直接使用
|
# 如果已经是 NodeId 对象,直接使用
|
||||||
try:
|
try:
|
||||||
from opcua.ua import NodeId as UaNodeId
|
from opcua.ua import NodeId as UaNodeId
|
||||||
@@ -227,16 +227,16 @@ class Method(Base):
|
|||||||
return self._parent_node
|
return self._parent_node
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# 字符串处理
|
# 字符串处理
|
||||||
if isinstance(nid, str):
|
if isinstance(nid, str):
|
||||||
nid = nid.strip()
|
nid = nid.strip()
|
||||||
|
|
||||||
# 处理包含类名的格式
|
# 处理包含类名的格式
|
||||||
match_wrapped = re.match(r'(String|Numeric|Byte|Guid|TwoByteNode|FourByteNode)NodeId\((.*)\)', nid)
|
match_wrapped = re.match(r'(String|Numeric|Byte|Guid|TwoByteNode|FourByteNode)NodeId\((.*)\)', nid)
|
||||||
if match_wrapped:
|
if match_wrapped:
|
||||||
nid = match_wrapped.group(2).strip()
|
nid = match_wrapped.group(2).strip()
|
||||||
|
|
||||||
# 常见短格式
|
# 常见短格式
|
||||||
if re.match(r'^ns=\d+;[is]=', nid):
|
if re.match(r'^ns=\d+;[is]=', nid):
|
||||||
self._parent_node = self._client.get_node(nid)
|
self._parent_node = self._client.get_node(nid)
|
||||||
@@ -271,7 +271,7 @@ class Method(Base):
|
|||||||
def write(self, value: Any) -> bool:
|
def write(self, value: Any) -> bool:
|
||||||
"""方法节点不支持写入操作"""
|
"""方法节点不支持写入操作"""
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def call(self, *args) -> Tuple[Any, bool]:
|
def call(self, *args) -> Tuple[Any, bool]:
|
||||||
"""调用方法,返回(返回值, 是否出错)"""
|
"""调用方法,返回(返回值, 是否出错)"""
|
||||||
try:
|
try:
|
||||||
@@ -285,7 +285,7 @@ class Method(Base):
|
|||||||
class Object(Base):
|
class Object(Base):
|
||||||
def __init__(self, client: Client, name: str, node_id: str):
|
def __init__(self, client: Client, name: str, node_id: str):
|
||||||
super().__init__(client, name, node_id, NodeType.OBJECT, None)
|
super().__init__(client, name, node_id, NodeType.OBJECT, None)
|
||||||
|
|
||||||
def read(self) -> Tuple[Any, bool]:
|
def read(self) -> Tuple[Any, bool]:
|
||||||
"""对象节点不支持直接读取操作"""
|
"""对象节点不支持直接读取操作"""
|
||||||
return None, True
|
return None, True
|
||||||
@@ -293,7 +293,7 @@ class Object(Base):
|
|||||||
def write(self, value: Any) -> bool:
|
def write(self, value: Any) -> bool:
|
||||||
"""对象节点不支持直接写入操作"""
|
"""对象节点不支持直接写入操作"""
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def get_children(self) -> Tuple[List[Node], bool]:
|
def get_children(self) -> Tuple[List[Node], bool]:
|
||||||
"""获取子节点列表,返回(子节点列表, 是否出错)"""
|
"""获取子节点列表,返回(子节点列表, 是否出错)"""
|
||||||
try:
|
try:
|
||||||
@@ -301,4 +301,4 @@ class Object(Base):
|
|||||||
return children, False
|
return children, False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"获取对象 {self._name} 的子节点失败: {e}")
|
print(f"获取对象 {self._name} 的子节点失败: {e}")
|
||||||
return [], True
|
return [], True
|
||||||
|
|||||||
@@ -201,42 +201,17 @@ class ResourceVisualization:
|
|||||||
self.moveit_controllers_yaml['moveit_simple_controller_manager'][f"{name}_{controller_name}"] = moveit_dict['moveit_simple_controller_manager'][controller_name]
|
self.moveit_controllers_yaml['moveit_simple_controller_manager'][f"{name}_{controller_name}"] = moveit_dict['moveit_simple_controller_manager'][controller_name]
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _ensure_ros2_env() -> dict:
|
|
||||||
"""确保 ROS2 环境变量正确设置,返回可用于子进程的 env dict"""
|
|
||||||
import sys
|
|
||||||
env = dict(os.environ)
|
|
||||||
conda_prefix = os.path.dirname(os.path.dirname(sys.executable))
|
|
||||||
|
|
||||||
if "AMENT_PREFIX_PATH" not in env or not env["AMENT_PREFIX_PATH"].strip():
|
|
||||||
candidate = os.pathsep.join([conda_prefix, os.path.join(conda_prefix, "Library")])
|
|
||||||
env["AMENT_PREFIX_PATH"] = candidate
|
|
||||||
os.environ["AMENT_PREFIX_PATH"] = candidate
|
|
||||||
|
|
||||||
extra_bin_dirs = [
|
|
||||||
os.path.join(conda_prefix, "Library", "bin"),
|
|
||||||
os.path.join(conda_prefix, "Library", "lib"),
|
|
||||||
os.path.join(conda_prefix, "Scripts"),
|
|
||||||
conda_prefix,
|
|
||||||
]
|
|
||||||
current_path = env.get("PATH", "")
|
|
||||||
for d in extra_bin_dirs:
|
|
||||||
if d not in current_path:
|
|
||||||
current_path = d + os.pathsep + current_path
|
|
||||||
env["PATH"] = current_path
|
|
||||||
os.environ["PATH"] = current_path
|
|
||||||
|
|
||||||
return env
|
|
||||||
|
|
||||||
def create_launch_description(self) -> LaunchDescription:
|
def create_launch_description(self) -> LaunchDescription:
|
||||||
"""
|
"""
|
||||||
创建launch描述,包含robot_state_publisher和move_group节点
|
创建launch描述,包含robot_state_publisher和move_group节点
|
||||||
|
|
||||||
|
Args:
|
||||||
|
urdf_str: URDF文本
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
LaunchDescription: launch描述对象
|
LaunchDescription: launch描述对象
|
||||||
"""
|
"""
|
||||||
launch_env = self._ensure_ros2_env()
|
# 检查ROS 2环境变量
|
||||||
|
|
||||||
if "AMENT_PREFIX_PATH" not in os.environ:
|
if "AMENT_PREFIX_PATH" not in os.environ:
|
||||||
raise OSError(
|
raise OSError(
|
||||||
"ROS 2环境未正确设置。需要设置 AMENT_PREFIX_PATH 环境变量。\n"
|
"ROS 2环境未正确设置。需要设置 AMENT_PREFIX_PATH 环境变量。\n"
|
||||||
@@ -315,7 +290,7 @@ class ResourceVisualization:
|
|||||||
{"robot_description": robot_description},
|
{"robot_description": robot_description},
|
||||||
ros2_controllers,
|
ros2_controllers,
|
||||||
],
|
],
|
||||||
env=launch_env,
|
env=dict(os.environ)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
for controller in self.moveit_controllers_yaml['moveit_simple_controller_manager']['controller_names']:
|
for controller in self.moveit_controllers_yaml['moveit_simple_controller_manager']['controller_names']:
|
||||||
@@ -325,7 +300,7 @@ class ResourceVisualization:
|
|||||||
executable="spawner",
|
executable="spawner",
|
||||||
arguments=[f"{controller}", "--controller-manager", f"controller_manager"],
|
arguments=[f"{controller}", "--controller-manager", f"controller_manager"],
|
||||||
output="screen",
|
output="screen",
|
||||||
env=launch_env,
|
env=dict(os.environ)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
controllers.append(
|
controllers.append(
|
||||||
@@ -334,7 +309,7 @@ class ResourceVisualization:
|
|||||||
executable="spawner",
|
executable="spawner",
|
||||||
arguments=["joint_state_broadcaster", "--controller-manager", f"controller_manager"],
|
arguments=["joint_state_broadcaster", "--controller-manager", f"controller_manager"],
|
||||||
output="screen",
|
output="screen",
|
||||||
env=launch_env,
|
env=dict(os.environ)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
for i in controllers:
|
for i in controllers:
|
||||||
@@ -342,6 +317,7 @@ class ResourceVisualization:
|
|||||||
else:
|
else:
|
||||||
ros2_controllers = None
|
ros2_controllers = None
|
||||||
|
|
||||||
|
# 创建robot_state_publisher节点
|
||||||
robot_state_publisher = nd(
|
robot_state_publisher = nd(
|
||||||
package='robot_state_publisher',
|
package='robot_state_publisher',
|
||||||
executable='robot_state_publisher',
|
executable='robot_state_publisher',
|
||||||
@@ -351,8 +327,9 @@ class ResourceVisualization:
|
|||||||
'robot_description': robot_description,
|
'robot_description': robot_description,
|
||||||
'use_sim_time': False
|
'use_sim_time': False
|
||||||
},
|
},
|
||||||
|
# kinematics_dict
|
||||||
],
|
],
|
||||||
env=launch_env,
|
env=dict(os.environ)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -384,7 +361,7 @@ class ResourceVisualization:
|
|||||||
executable='move_group',
|
executable='move_group',
|
||||||
output='screen',
|
output='screen',
|
||||||
parameters=moveit_params,
|
parameters=moveit_params,
|
||||||
env=launch_env,
|
env=dict(os.environ)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -402,11 +379,13 @@ class ResourceVisualization:
|
|||||||
arguments=['-d', f"{str(self.mesh_path)}/view_robot.rviz"],
|
arguments=['-d', f"{str(self.mesh_path)}/view_robot.rviz"],
|
||||||
output='screen',
|
output='screen',
|
||||||
parameters=[
|
parameters=[
|
||||||
{'robot_description_kinematics': kinematics_dict},
|
{'robot_description_kinematics': kinematics_dict,
|
||||||
|
},
|
||||||
robot_description_planning,
|
robot_description_planning,
|
||||||
planning_pipelines,
|
planning_pipelines,
|
||||||
|
|
||||||
],
|
],
|
||||||
env=launch_env,
|
env=dict(os.environ)
|
||||||
)
|
)
|
||||||
self.launch_description.add_action(rviz_node)
|
self.launch_description.add_action(rviz_node)
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,221 +0,0 @@
|
|||||||
"""P9 — liquid_history schema v3 与 helper 函数。
|
|
||||||
|
|
||||||
独立模块,**不依赖 pylabrobot**,可在 PLR 环境缺失时单独单测。
|
|
||||||
|
|
||||||
模块由 ``liquid_handler_abstract.py`` 在 runtime 挂载点(set_liquid / aspirate /
|
|
||||||
dispense)调用,且由 ``resource_tracker._augment_states_with_liquid_history`` 在
|
|
||||||
serialize 链路使用。
|
|
||||||
|
|
||||||
详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md``。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, List, Tuple
|
|
||||||
|
|
||||||
from typing_extensions import TypedDict
|
|
||||||
|
|
||||||
|
|
||||||
# liquid_history 元素 schema v3
|
|
||||||
# 详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md`` §6.1。
|
|
||||||
# 旧格式(v2 ``(name, vol)`` 元组、list[str])由 ``normalize_liquid_history`` 升级。
|
|
||||||
class LiquidHistoryEntry(TypedDict, total=False):
|
|
||||||
name: str # 液体名(如 "Plasma";与 P8 reagent.liquid_name 联动;缺省 "")
|
|
||||||
volume: float # 操作体积(µL;aspirate 为负,dispense / set 为正)
|
|
||||||
action: str # "set" / "aspirate" / "dispense" / "legacy" / "auto_init"
|
|
||||||
timestamp: str # ISO8601 UTC(OS runtime 写入时填,前端写入时可省略)
|
|
||||||
|
|
||||||
|
|
||||||
# liquid_history 单 well 上限:超过则滚动丢弃头部
|
|
||||||
# 既限制内存(典型 8 通道 transfer 一次产生 ≤16 条),也防止极端 batch 拖慢前端渲染
|
|
||||||
LIQUID_HISTORY_MAX_ENTRIES = 1000
|
|
||||||
|
|
||||||
|
|
||||||
def well_current_liquid_name(well: Any) -> str:
|
|
||||||
"""从 ``well.tracker.liquids`` 末项读取当前液体名(PLR ``Liquid`` enum / str / None 兼容)。
|
|
||||||
|
|
||||||
P9:作为 ``aspirate`` 写入 history 时 ``name`` 字段的来源。
|
|
||||||
返回 ``""`` 表示未知(不写字面 "unknown",避免被前端误展示)。
|
|
||||||
"""
|
|
||||||
tracker = getattr(well, "tracker", None)
|
|
||||||
if tracker is None:
|
|
||||||
return ""
|
|
||||||
liquids = getattr(tracker, "liquids", None)
|
|
||||||
if not liquids:
|
|
||||||
# PLR 提供 get_liquids() 时优先用之(返回 list[(Liquid|None, vol)])
|
|
||||||
try:
|
|
||||||
liquids = tracker.get_liquids() # type: ignore[attr-defined]
|
|
||||||
except Exception:
|
|
||||||
liquids = None
|
|
||||||
if not liquids:
|
|
||||||
return ""
|
|
||||||
last = liquids[-1]
|
|
||||||
if isinstance(last, (list, tuple)) and last:
|
|
||||||
candidate = last[0]
|
|
||||||
else:
|
|
||||||
candidate = last
|
|
||||||
if candidate is None:
|
|
||||||
return ""
|
|
||||||
name = getattr(candidate, "name", None)
|
|
||||||
if isinstance(name, str) and name:
|
|
||||||
return name
|
|
||||||
if isinstance(candidate, str):
|
|
||||||
return candidate
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def append_liquid_history(
|
|
||||||
well: Any,
|
|
||||||
liquid_name: str,
|
|
||||||
volume: float,
|
|
||||||
action: str,
|
|
||||||
) -> None:
|
|
||||||
"""P9 — 统一写入 ``well.tracker.liquid_history``(PLR 扩展属性)。
|
|
||||||
|
|
||||||
设计要点:
|
|
||||||
- 元素为 v3 dict 形态 ``{name, volume, action, timestamp}``,与
|
|
||||||
:class:`LiquidHistoryEntry` schema 一致。
|
|
||||||
- ``aspirate`` 的 ``volume`` 应为**负数**(与 dispense/set 正数对称,
|
|
||||||
``sum(history.volume)`` ≈ 当前残量)。
|
|
||||||
- ``well`` 无 tracker 或 tracker 不可写时 graceful 静默(避免污染主流程)。
|
|
||||||
- 滚动上限 ``LIQUID_HISTORY_MAX_ENTRIES``:超出时丢弃**头部**(保留最近)。
|
|
||||||
|
|
||||||
详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md`` §6.2。
|
|
||||||
"""
|
|
||||||
tracker = getattr(well, "tracker", None)
|
|
||||||
if tracker is None:
|
|
||||||
return
|
|
||||||
history = getattr(tracker, "liquid_history", None)
|
|
||||||
if not isinstance(history, list):
|
|
||||||
history = []
|
|
||||||
try:
|
|
||||||
tracker.liquid_history = history # type: ignore[attr-defined]
|
|
||||||
except Exception:
|
|
||||||
return # tracker 拒绝写扩展属性(极少见);静默放弃
|
|
||||||
# 兼容修复:PLR VolumeTracker.current_liquids 依赖 tracker.liquid_history 为
|
|
||||||
# list[(name, vol)];若写入 dict 会在 `for name, vol in liquid_history` 时崩溃。
|
|
||||||
# 这里把历史就地归一为 tuple 形态,再 append tuple,避免 unpack ValueError。
|
|
||||||
normalized_pairs: List[Tuple[str, float]] = []
|
|
||||||
for item in history:
|
|
||||||
if isinstance(item, (list, tuple)) and len(item) >= 2:
|
|
||||||
name_val = str(item[0] or "")
|
|
||||||
try:
|
|
||||||
vol_val = float(item[1])
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
vol_val = 0.0
|
|
||||||
normalized_pairs.append((name_val, vol_val))
|
|
||||||
elif isinstance(item, dict):
|
|
||||||
name_val = str(item.get("name", ""))
|
|
||||||
try:
|
|
||||||
vol_val = float(item.get("volume", 0.0) or 0.0)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
vol_val = 0.0
|
|
||||||
normalized_pairs.append((name_val, vol_val))
|
|
||||||
elif isinstance(item, str):
|
|
||||||
normalized_pairs.append((item, 0.0))
|
|
||||||
history[:] = normalized_pairs
|
|
||||||
entry = (str(liquid_name or ""), float(volume))
|
|
||||||
history.append(entry)
|
|
||||||
overflow = len(history) - LIQUID_HISTORY_MAX_ENTRIES
|
|
||||||
if overflow > 0:
|
|
||||||
del history[:overflow]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# P10 v2 — Tip 复用 ``tracker.liquids`` 等价 helper(详见
|
|
||||||
# ``product_designs/protocol_convert/10-tip-reuse-by-liquid-history.md`` §3.2)
|
|
||||||
#
|
|
||||||
# 设计原则:
|
|
||||||
# - 信号源使用 PLR 原生 ``well.tracker.liquids`` 末项("well 此刻顶层液体"),
|
|
||||||
# 而非 P9 扩展属性 ``liquid_history``;P10 v2 因此不依赖 P9 是否落地。
|
|
||||||
# - 名称比较使用严格字符串相等;空 / "unknown" / "none" 一律保守视为未知 →
|
|
||||||
# 不触发 liquids 复用,落回 identity-only 现状(零回归)。
|
|
||||||
# - 与 P9 现有 ``liquid_names_before_aspirate`` 同模式:aspirate 之前预读
|
|
||||||
# source 当前液体名,避免 PLR 顶层归零时 pop ``liquids`` 拿不到身份。
|
|
||||||
# - 4 个 helper 共同居于本 PLR-free 模块,方便单元测试在不安装 pylabrobot
|
|
||||||
# 的环境下独立运行。
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def is_known_liquid_name(name: Any) -> bool:
|
|
||||||
"""空字符串 / "unknown" / "none" / None 一律视为未知,不触发 liquids 复用。"""
|
|
||||||
if not name:
|
|
||||||
return False
|
|
||||||
if not isinstance(name, str):
|
|
||||||
return False
|
|
||||||
return name.strip().lower() not in {"unknown", "none"}
|
|
||||||
|
|
||||||
|
|
||||||
def same_liquid_via_liquids(well: Any, tip_liquid_name: Any) -> bool:
|
|
||||||
"""tip 残留液体名 vs ``well.tracker.liquids`` 末项 name 严格相等。
|
|
||||||
|
|
||||||
用于 pick_up 决策:判断下一轮要 aspirate 的 well 当前液体是否与 tip 残液同名。
|
|
||||||
任一侧未知(空 / "unknown")→ 返回 ``False``(保守换 tip)。
|
|
||||||
"""
|
|
||||||
if not is_known_liquid_name(tip_liquid_name):
|
|
||||||
return False
|
|
||||||
well_name = well_current_liquid_name(well)
|
|
||||||
if not is_known_liquid_name(well_name):
|
|
||||||
return False
|
|
||||||
return well_name == tip_liquid_name
|
|
||||||
|
|
||||||
|
|
||||||
def same_liquid_via_liquids_pair(cur_well: Any, next_well: Any) -> bool:
|
|
||||||
"""两个 source well 当前 ``tracker.liquids`` 末项是否同名(用于决定 drop 时机)。
|
|
||||||
|
|
||||||
注:必须在 cur_well 的 aspirate **之前**调用;aspirate 不改
|
|
||||||
``liquids[-1].name`` 只改顶层 vol(或顶层归零时 pop),故 cur/next 的判等
|
|
||||||
以 "将要被抽的那一层" 为准。
|
|
||||||
"""
|
|
||||||
cur_name = well_current_liquid_name(cur_well)
|
|
||||||
next_name = well_current_liquid_name(next_well)
|
|
||||||
if not is_known_liquid_name(cur_name) or not is_known_liquid_name(next_name):
|
|
||||||
return False
|
|
||||||
return cur_name == next_name
|
|
||||||
|
|
||||||
|
|
||||||
def capture_tip_liquid_name(source_well: Any) -> "str | None":
|
|
||||||
"""**aspirate 之前** 把 source well 的当前液体名捕获下来,作为本轮 aspirate
|
|
||||||
完成后 tip 上残留液体的身份。
|
|
||||||
|
|
||||||
必须在 ``super().aspirate`` / ``_transfer_base_method`` 调用前读取:PLR
|
|
||||||
aspirate 会从顶层扣减体积,体积归零时 PLR ``VolumeTracker`` 会 pop 顶层
|
|
||||||
``(Liquid, vol)``,事后再读 ``liquids[-1]`` 可能拿到 prev layer 或空 list。
|
|
||||||
详见 ``liquid_handler_abstract.aspirate`` 中 ``liquid_names_before_aspirate``
|
|
||||||
同样的 "预读" 模式。
|
|
||||||
"""
|
|
||||||
name = well_current_liquid_name(source_well)
|
|
||||||
return name if is_known_liquid_name(name) else None
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_liquid_history(raw: Any) -> List[Tuple[str, float]]:
|
|
||||||
"""P9 — 把任意旧形态的 liquid_history 升级为 v3 dict 列表。
|
|
||||||
|
|
||||||
兼容输入:
|
|
||||||
- v3 dict: ``[{name, volume, action, timestamp?}, ...]`` 原样返回(字段补全)
|
|
||||||
- v2 tuple: ``[(name, vol), ...]`` → ``action="legacy"``
|
|
||||||
- list[str]: ``["A", "B"]`` → ``volume=0, action="legacy"``
|
|
||||||
- 其它:丢弃该 entry
|
|
||||||
|
|
||||||
详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md`` §6.4。
|
|
||||||
"""
|
|
||||||
if not isinstance(raw, list):
|
|
||||||
return []
|
|
||||||
result: List[Tuple[str, float]] = []
|
|
||||||
for entry in raw:
|
|
||||||
if isinstance(entry, dict):
|
|
||||||
try:
|
|
||||||
vol_val = float(entry.get("volume", 0.0) or 0.0)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
vol_val = 0.0
|
|
||||||
result.append((str(entry.get("name", "")), vol_val))
|
|
||||||
elif isinstance(entry, (list, tuple)) and len(entry) >= 2:
|
|
||||||
try:
|
|
||||||
vol_val = float(entry[1])
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
vol_val = 0.0
|
|
||||||
result.append((str(entry[0] or ""), vol_val))
|
|
||||||
elif isinstance(entry, str):
|
|
||||||
result.append((entry, 0.0))
|
|
||||||
# 其它类型静默丢弃
|
|
||||||
return result
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,150 +0,0 @@
|
|||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from .prcxi import PRCXI9300ModuleSite
|
|
||||||
|
|
||||||
|
|
||||||
class PRCXI9300FunctionalModule(PRCXI9300ModuleSite):
|
|
||||||
"""
|
|
||||||
PRCXI 9300 功能模块基类(加热/冷却/震荡/加热震荡/磁吸等)。
|
|
||||||
|
|
||||||
设计目标:
|
|
||||||
- 作为一个可以在工作台上拖拽摆放的实体资源(继承自 PRCXI9300ModuleSite -> ItemizedCarrier)。
|
|
||||||
- 顶面存在一个站点(site),可吸附标准板类资源(plate / tip_rack / tube_rack 等)。
|
|
||||||
- 支持注入 `material_info` (UUID 等),并且在 serialize_state 时做安全过滤。
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
size_x: float,
|
|
||||||
size_y: float,
|
|
||||||
size_z: float,
|
|
||||||
module_type: Optional[str] = None,
|
|
||||||
category: str = "module",
|
|
||||||
model: Optional[str] = None,
|
|
||||||
material_info: Optional[Dict[str, Any]] = None,
|
|
||||||
**kwargs: Any,
|
|
||||||
):
|
|
||||||
super().__init__(
|
|
||||||
name=name,
|
|
||||||
size_x=size_x,
|
|
||||||
size_y=size_y,
|
|
||||||
size_z=size_z,
|
|
||||||
material_info=material_info,
|
|
||||||
model=model,
|
|
||||||
category=category,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 记录模块类型(加热 / 冷却 / 震荡 / 加热震荡 / 磁吸)
|
|
||||||
self.module_type = module_type or "generic"
|
|
||||||
|
|
||||||
# 与 PRCXI9300PlateAdapter 一致,使用 _unilabos_state 保存扩展信息
|
|
||||||
if not hasattr(self, "_unilabos_state") or self._unilabos_state is None:
|
|
||||||
self._unilabos_state = {}
|
|
||||||
|
|
||||||
# super().__init__ 已经在有 material_info 时写入 "Material",这里仅确保存在
|
|
||||||
if material_info is not None and "Material" not in self._unilabos_state:
|
|
||||||
self._unilabos_state["Material"] = material_info
|
|
||||||
|
|
||||||
# 额外标记 category 和模块类型,便于前端或上层逻辑区分
|
|
||||||
self._unilabos_state.setdefault("category", category)
|
|
||||||
self._unilabos_state["module_type"] = module_type
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# 具体功能模块定义
|
|
||||||
# 这里的尺寸和 material_info 目前为占位参数,后续可根据实际测量/JSON 配置进行更新。
|
|
||||||
# 顶面站点尺寸与模块外形一致,保证可以吸附标准 96 板/储液槽等。
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
def PRCXI_Heating_Module(name: str) -> PRCXI9300FunctionalModule:
|
|
||||||
"""加热模块(顶面可吸附标准板)。"""
|
|
||||||
return PRCXI9300FunctionalModule(
|
|
||||||
name=name,
|
|
||||||
size_x=127.76,
|
|
||||||
size_y=85.48,
|
|
||||||
size_z=40.0,
|
|
||||||
module_type="heating",
|
|
||||||
model="PRCXI_Heating_Module",
|
|
||||||
material_info={
|
|
||||||
"uuid": "TODO-HEATING-MODULE-UUID",
|
|
||||||
"Code": "HEAT-MOD",
|
|
||||||
"Name": "PRCXI 加热模块",
|
|
||||||
"SupplyType": 3,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def PRCXI_MetalCooling_Module(name: str) -> PRCXI9300FunctionalModule:
|
|
||||||
"""金属冷却模块(顶面可吸附标准板)。"""
|
|
||||||
return PRCXI9300FunctionalModule(
|
|
||||||
name=name,
|
|
||||||
size_x=127.76,
|
|
||||||
size_y=85.48,
|
|
||||||
size_z=40.0,
|
|
||||||
module_type="metal_cooling",
|
|
||||||
model="PRCXI_MetalCooling_Module",
|
|
||||||
material_info={
|
|
||||||
"uuid": "TODO-METAL-COOLING-MODULE-UUID",
|
|
||||||
"Code": "METAL-COOL-MOD",
|
|
||||||
"Name": "PRCXI 金属冷却模块",
|
|
||||||
"SupplyType": 3,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def PRCXI_Shaking_Module(name: str) -> PRCXI9300FunctionalModule:
|
|
||||||
"""震荡模块(顶面可吸附标准板)。"""
|
|
||||||
return PRCXI9300FunctionalModule(
|
|
||||||
name=name,
|
|
||||||
size_x=127.76,
|
|
||||||
size_y=85.48,
|
|
||||||
size_z=50.0,
|
|
||||||
module_type="shaking",
|
|
||||||
model="PRCXI_Shaking_Module",
|
|
||||||
material_info={
|
|
||||||
"uuid": "TODO-SHAKING-MODULE-UUID",
|
|
||||||
"Code": "SHAKE-MOD",
|
|
||||||
"Name": "PRCXI 震荡模块",
|
|
||||||
"SupplyType": 3,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def PRCXI_Heating_Shaking_Module(name: str) -> PRCXI9300FunctionalModule:
|
|
||||||
"""加热震荡模块(顶面可吸附标准板)。"""
|
|
||||||
return PRCXI9300FunctionalModule(
|
|
||||||
name=name,
|
|
||||||
size_x=127.76,
|
|
||||||
size_y=85.48,
|
|
||||||
size_z=55.0,
|
|
||||||
module_type="heating_shaking",
|
|
||||||
model="PRCXI_Heating_Shaking_Module",
|
|
||||||
material_info={
|
|
||||||
"uuid": "TODO-HEATING-SHAKING-MODULE-UUID",
|
|
||||||
"Code": "HEAT-SHAKE-MOD",
|
|
||||||
"Name": "PRCXI 加热震荡模块",
|
|
||||||
"SupplyType": 3,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def PRCXI_Magnetic_Module(name: str) -> PRCXI9300FunctionalModule:
|
|
||||||
"""磁吸模块(顶面可吸附标准板)。"""
|
|
||||||
return PRCXI9300FunctionalModule(
|
|
||||||
name=name,
|
|
||||||
size_x=127.76,
|
|
||||||
size_y=85.48,
|
|
||||||
size_z=30.0,
|
|
||||||
module_type="magnetic",
|
|
||||||
model="PRCXI_Magnetic_Module",
|
|
||||||
material_info={
|
|
||||||
"uuid": "TODO-MAGNETIC-MODULE-UUID",
|
|
||||||
"Code": "MAG-MOD",
|
|
||||||
"Name": "PRCXI 磁吸模块",
|
|
||||||
"SupplyType": 3,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
@@ -59,7 +59,6 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend):
|
|||||||
self.total_height = total_height
|
self.total_height = total_height
|
||||||
self.joint_config = kwargs.get("joint_config", None)
|
self.joint_config = kwargs.get("joint_config", None)
|
||||||
self.lh_device_id = kwargs.get("lh_device_id", "lh_joint_publisher")
|
self.lh_device_id = kwargs.get("lh_device_id", "lh_joint_publisher")
|
||||||
self.simulate_rviz = kwargs.get("simulate_rviz", False)
|
|
||||||
if not rclpy.ok():
|
if not rclpy.ok():
|
||||||
rclpy.init()
|
rclpy.init()
|
||||||
self.joint_state_publisher = None
|
self.joint_state_publisher = None
|
||||||
@@ -70,7 +69,7 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend):
|
|||||||
self.joint_state_publisher = LiquidHandlerJointPublisher(
|
self.joint_state_publisher = LiquidHandlerJointPublisher(
|
||||||
joint_config=self.joint_config,
|
joint_config=self.joint_config,
|
||||||
lh_device_id=self.lh_device_id,
|
lh_device_id=self.lh_device_id,
|
||||||
simulate_rviz=self.simulate_rviz)
|
simulate_rviz=True)
|
||||||
|
|
||||||
# 启动ROS executor
|
# 启动ROS executor
|
||||||
self.executor = rclpy.executors.MultiThreadedExecutor()
|
self.executor = rclpy.executors.MultiThreadedExecutor()
|
||||||
|
|||||||
@@ -219,10 +219,10 @@ device = NewareBatteryTestSystem(
|
|||||||
|
|
||||||
#### 步骤 2:提交测试任务
|
#### 步骤 2:提交测试任务
|
||||||
|
|
||||||
使用 `submit_from_csv` 提交测试任务:
|
使用 `submit_from_csv_export_ndax` 提交测试任务:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
result = device.submit_from_csv(
|
result = device.submit_from_csv_export_ndax(
|
||||||
csv_path="test_data.csv",
|
csv_path="test_data.csv",
|
||||||
output_dir="D:/neware_output"
|
output_dir="D:/neware_output"
|
||||||
)
|
)
|
||||||
@@ -489,7 +489,7 @@ A: 重新获取新的 Token 并更新环境变量 `UNI_LAB_AUTH_TOKEN`。
|
|||||||
**Q: 可以自定义上传路径吗?**
|
**Q: 可以自定义上传路径吗?**
|
||||||
A: 当前版本路径由统一 API 自动分配,`oss_prefix` 参数暂不使用(保留接口兼容性)。
|
A: 当前版本路径由统一 API 自动分配,`oss_prefix` 参数暂不使用(保留接口兼容性)。
|
||||||
|
|
||||||
**Q: 为什么不在 `submit_from_csv` 中自动上传?**
|
**Q: 为什么不在 `submit_from_csv_export_ndax` 中自动上传?**
|
||||||
A: 因为备份文件在测试进行中逐步生成,方法返回时可能文件尚未完全生成,因此提供独立的上传方法更灵活。
|
A: 因为备份文件在测试进行中逐步生成,方法返回时可能文件尚未完全生成,因此提供独立的上传方法更灵活。
|
||||||
|
|
||||||
**Q: 上传后如何访问文件?**
|
**Q: 上传后如何访问文件?**
|
||||||
|
|||||||
@@ -230,10 +230,10 @@ device = NewareBatteryTestSystem(
|
|||||||
|
|
||||||
#### Step 2: Submit Test Tasks
|
#### Step 2: Submit Test Tasks
|
||||||
|
|
||||||
Use `submit_from_csv` to submit test tasks:
|
Use `submit_from_csv_export_ndax` to submit test tasks:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
result = device.submit_from_csv(
|
result = device.submit_from_csv_export_ndax(
|
||||||
csv_path="test_data.csv",
|
csv_path="test_data.csv",
|
||||||
output_dir="D:/neware_output"
|
output_dir="D:/neware_output"
|
||||||
)
|
)
|
||||||
@@ -500,7 +500,7 @@ A: Obtain a new API Key and update the `UNI_LAB_AUTH_TOKEN` environment variable
|
|||||||
**Q: Can I customize upload paths?**
|
**Q: Can I customize upload paths?**
|
||||||
A: Current version has paths automatically assigned by unified API. `oss_prefix` parameter is currently unused (retained for interface compatibility).
|
A: Current version has paths automatically assigned by unified API. `oss_prefix` parameter is currently unused (retained for interface compatibility).
|
||||||
|
|
||||||
**Q: Why not auto-upload in `submit_from_csv`?**
|
**Q: Why not auto-upload in `submit_from_csv_export_ndax`?**
|
||||||
A: Because backup files are generated progressively during testing, they may not be fully generated when the method returns. A separate upload method provides more flexibility.
|
A: Because backup files are generated progressively during testing, they may not be fully generated when the method returns. A separate upload method provides more flexibility.
|
||||||
|
|
||||||
**Q: How to access files after upload?**
|
**Q: How to access files after upload?**
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"config": {
|
"config": {
|
||||||
"ip": "127.0.0.1",
|
"ip": "127.0.0.1",
|
||||||
"port": 502,
|
"port": 502,
|
||||||
"machine_id": 1,
|
"machine_ids": [1, 2, 3, 4, 5, 6, 86],
|
||||||
"devtype": "27",
|
"devtype": "27",
|
||||||
"timeout": 20,
|
"timeout": 20,
|
||||||
"size_x": 500.0,
|
"size_x": 500.0,
|
||||||
@@ -26,10 +26,10 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"功能说明": "新威电池测试系统,提供720通道监控和CSV批量提交功能",
|
"功能说明": "新威电池测试系统,提供720通道监控和CSV批量提交功能",
|
||||||
"监控功能": "支持720个通道的实时状态监控、2盘电池物料管理、状态导出等",
|
"监控功能": "支持720个通道的实时状态监控、2盘电池物料管理、状态导出等",
|
||||||
"提交功能": "通过submit_from_csv action从CSV文件批量提交测试任务。CSV必须包含: Battery_Code, Pole_Weight, 集流体质量, 活性物质含量, 克容量mah/g, 电池体系, 设备号, 排号, 通道号"
|
"提交功能": "通过submit_from_csv action从CSV文件批量提交测试任务(NDA备份),或通过submit_from_csv_export_excel action提交并备份为Excel格式。CSV必须包含: Battery_Code, Pole_Weight, 集流体质量, 活性物质含量, 克容量mah/g, 电池体系, 设备号, 排号, 通道号"
|
||||||
},
|
},
|
||||||
"children": []
|
"children": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"links": []
|
"links": []
|
||||||
}
|
}
|
||||||
|
|||||||
1644
unilabos/devices/neware_battery_test_system/generate_xml_content.py
Normal file
1644
unilabos/devices/neware_battery_test_system/generate_xml_content.py
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
56
unilabos/devices/neware_battery_test_system/neware_driver.py
Normal file
56
unilabos/devices/neware_battery_test_system/neware_driver.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import socket
|
||||||
|
END_MARKS = [b"\r\n#\r\n", b"</bts>"] # 读到任一标志即可判定完整响应
|
||||||
|
|
||||||
|
def build_start_command(devid, subdevid, chlid, CoinID,
|
||||||
|
ip_in_xml="127.0.0.1",
|
||||||
|
devtype:int=27,
|
||||||
|
recipe_path:str=f"D:\\HHM_test\\A001.xml",
|
||||||
|
backup_dir:str=f"D:\\HHM_test\\backup",
|
||||||
|
filetype:int=1) -> str:
|
||||||
|
"""
|
||||||
|
filetype: 备份文件类型。0=NDA(新威原生),1=Excel。默认 1。
|
||||||
|
"""
|
||||||
|
lines = [
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
'<bts version="1.0">',
|
||||||
|
' <cmd>start</cmd>',
|
||||||
|
' <list count="1">',
|
||||||
|
f' <start ip="{ip_in_xml}" devtype="{devtype}" devid="{devid}" subdevid="{subdevid}" chlid="{chlid}" barcode="{CoinID}">{recipe_path}</start>',
|
||||||
|
f' <backup backupdir="{backup_dir}" remotedir="" filenametype="1" customfilename="" createdirbydate="0" filetype="{int(filetype)}" backupontime="1" backupontimeinterval="1" backupfree="0" />',
|
||||||
|
' </list>',
|
||||||
|
'</bts>',
|
||||||
|
]
|
||||||
|
# TCP 模式:请求必须以 #\r\n 结束(协议要求)
|
||||||
|
return "\r\n".join(lines) + "\r\n#\r\n"
|
||||||
|
|
||||||
|
def recv_until_marks(sock: socket.socket, timeout=60):
|
||||||
|
sock.settimeout(timeout) # 上限给足,协议允许到 30s:contentReference[oaicite:2]{index=2}
|
||||||
|
buf = bytearray()
|
||||||
|
while True:
|
||||||
|
chunk = sock.recv(8192)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
buf += chunk
|
||||||
|
# 读到结束标志就停,避免等对端断开
|
||||||
|
for m in END_MARKS:
|
||||||
|
if m in buf:
|
||||||
|
return bytes(buf)
|
||||||
|
# 保险:读到完整 XML 结束标签也停
|
||||||
|
if b"</bts>" in buf:
|
||||||
|
return bytes(buf)
|
||||||
|
return bytes(buf)
|
||||||
|
|
||||||
|
def start_test(ip="127.0.0.1", port=502, devid=3, subdevid=2, chlid=1, CoinID="A001", recipe_path=f"D:\\HHM_test\\A001.xml", backup_dir=f"D:\\HHM_test\\backup", filetype:int=1):
|
||||||
|
"""
|
||||||
|
filetype: 备份文件类型,0=NDA,1=Excel。默认 1。
|
||||||
|
"""
|
||||||
|
xml_cmd = build_start_command(devid=devid, subdevid=subdevid, chlid=chlid, CoinID=CoinID, recipe_path=recipe_path, backup_dir=backup_dir, filetype=filetype)
|
||||||
|
#print(xml_cmd)
|
||||||
|
with socket.create_connection((ip, port), timeout=60) as s:
|
||||||
|
s.sendall(xml_cmd.encode("utf-8"))
|
||||||
|
data = recv_until_marks(s, timeout=60)
|
||||||
|
return data.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
resp = start_test(ip="127.0.0.1", port=502, devid=4, subdevid=10, chlid=1, CoinID="A001", recipe_path=f"D:\\HHM_test\\A001.xml", backup_dir=f"D:\\HHM_test\\backup")
|
||||||
|
print(resp)
|
||||||
@@ -42,7 +42,6 @@ class LiquidHandlerJointPublisher(Node):
|
|||||||
while self.resource_action is None:
|
while self.resource_action is None:
|
||||||
self.resource_action = self.check_tf_update_actions()
|
self.resource_action = self.check_tf_update_actions()
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
self.get_logger().info(f'Waiting for TfUpdate server: {self.resource_action}')
|
|
||||||
|
|
||||||
self.resource_action_client = ActionClient(self, SendCmd, self.resource_action)
|
self.resource_action_client = ActionClient(self, SendCmd, self.resource_action)
|
||||||
while not self.resource_action_client.wait_for_server(timeout_sec=1.0):
|
while not self.resource_action_client.wait_for_server(timeout_sec=1.0):
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ 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
|
||||||
@@ -172,160 +171,173 @@ class MoveitInterface:
|
|||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def pick_and_place(
|
def pick_and_place(self, command: str):
|
||||||
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:
|
|
||||||
"""
|
"""
|
||||||
使用 MoveIt 完成抓取/放置等序列(pick/place/side_pick/side_place)。
|
Using MoveIt to make the robotic arm pick or place materials to a target point.
|
||||||
|
|
||||||
必选:option, move_group, status。
|
Args:
|
||||||
可选:resource, x_distance, y_distance, lift_height, retry, speed, target, constraints。
|
command: A JSON-formatted string that includes option, target, speed, lift_height, mt_height
|
||||||
无返回值;失败时提前 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:
|
||||||
if option not in self.move_option:
|
cmd_str = str(command).replace("'", '"')
|
||||||
raise ValueError(f"Invalid option: {option}")
|
cmd_dict = json.loads(cmd_str)
|
||||||
|
|
||||||
option_index = self.move_option.index(option)
|
if cmd_dict["option"] in self.move_option:
|
||||||
place_flag = option_index % 2
|
option_index = self.move_option.index(cmd_dict["option"])
|
||||||
|
place_flag = option_index % 2
|
||||||
|
|
||||||
config: dict = {"move_group": move_group}
|
config = {}
|
||||||
if speed is not None:
|
function_list = []
|
||||||
config["speed"] = speed
|
|
||||||
if retry is not None:
|
|
||||||
config["retry"] = retry
|
|
||||||
|
|
||||||
function_list = []
|
status = cmd_dict["status"]
|
||||||
joint_positions_ = self.joint_poses[move_group][status]
|
joint_positions_ = self.joint_poses[cmd_dict["move_group"]][status]
|
||||||
|
|
||||||
# 夹取 / 放置:绑定 resource 与 parent
|
config.update({k: cmd_dict[k] for k in ["speed", "retry", "move_group"] if k in cmd_dict})
|
||||||
if not place_flag:
|
|
||||||
if target is not None:
|
|
||||||
function_list.append(lambda r=resource, t=target: self.resource_manager(r, t))
|
|
||||||
else:
|
|
||||||
ee = self.moveit2[move_group].end_effector_name
|
|
||||||
function_list.append(lambda r=resource: self.resource_manager(r, ee))
|
|
||||||
else:
|
|
||||||
function_list.append(lambda r=resource: self.resource_manager(r, "world"))
|
|
||||||
|
|
||||||
joint_constraint_msgs: list = []
|
# 夹取
|
||||||
if constraints is not None:
|
if not place_flag:
|
||||||
for i, c in enumerate(constraints):
|
if "target" in cmd_dict.keys():
|
||||||
v = float(c)
|
function_list.append(lambda: self.resource_manager(cmd_dict["resource"], cmd_dict["target"]))
|
||||||
if v > 0:
|
else:
|
||||||
joint_constraint_msgs.append(
|
function_list.append(
|
||||||
JointConstraint(
|
lambda: self.resource_manager(
|
||||||
joint_name=self.moveit2[move_group].joint_names[i],
|
cmd_dict["resource"], self.moveit2[cmd_dict["move_group"]].end_effector_name
|
||||||
position=joint_positions_[i],
|
|
||||||
tolerance_above=v,
|
|
||||||
tolerance_below=v,
|
|
||||||
weight=1.0,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
function_list.append(lambda: self.resource_manager(cmd_dict["resource"], "world"))
|
||||||
|
|
||||||
if lift_height is not None:
|
constraints = []
|
||||||
retval = None
|
if "constraints" in cmd_dict.keys():
|
||||||
attempts = config.get("retry", 10)
|
|
||||||
while retval is None and attempts > 0:
|
|
||||||
retval = self.moveit2[move_group].compute_fk(joint_positions_)
|
|
||||||
time.sleep(0.1)
|
|
||||||
attempts -= 1
|
|
||||||
if retval is None:
|
|
||||||
raise ValueError("Failed to compute forward kinematics")
|
|
||||||
pose = [retval.pose.position.x, retval.pose.position.y, retval.pose.position.z]
|
|
||||||
quaternion = [
|
|
||||||
retval.pose.orientation.x,
|
|
||||||
retval.pose.orientation.y,
|
|
||||||
retval.pose.orientation.z,
|
|
||||||
retval.pose.orientation.w,
|
|
||||||
]
|
|
||||||
|
|
||||||
function_list = [
|
for i in range(len(cmd_dict["constraints"])):
|
||||||
lambda: self.moveit_task(
|
v = float(cmd_dict["constraints"][i])
|
||||||
position=[retval.pose.position.x, retval.pose.position.y, retval.pose.position.z],
|
if v > 0:
|
||||||
quaternion=quaternion,
|
constraints.append(
|
||||||
**config,
|
JointConstraint(
|
||||||
cartesian=self.cartesian_flag,
|
joint_name=self.moveit2[cmd_dict["move_group"]].joint_names[i],
|
||||||
)
|
position=joint_positions_[i],
|
||||||
] + function_list
|
tolerance_above=v,
|
||||||
|
tolerance_below=v,
|
||||||
|
weight=1.0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
pose[2] += float(lift_height)
|
if "lift_height" in cmd_dict.keys():
|
||||||
function_list.append(
|
retval = None
|
||||||
lambda p=pose.copy(), q=quaternion, cfg=config: self.moveit_task(
|
retry = config.get("retry", 10)
|
||||||
position=p, quaternion=q, **cfg, cartesian=self.cartesian_flag
|
while retval is None and retry > 0:
|
||||||
)
|
retval = self.moveit2[cmd_dict["move_group"]].compute_fk(joint_positions_)
|
||||||
)
|
time.sleep(0.1)
|
||||||
end_pose = list(pose)
|
retry -= 1
|
||||||
|
if retval is None:
|
||||||
if x_distance is not None or y_distance is not None:
|
result.success = False
|
||||||
if x_distance is not None:
|
return result
|
||||||
deep_pose = deepcopy(pose)
|
pose = [retval.pose.position.x, retval.pose.position.y, retval.pose.position.z]
|
||||||
deep_pose[0] += float(x_distance)
|
quaternion = [
|
||||||
elif y_distance is not None:
|
retval.pose.orientation.x,
|
||||||
deep_pose = deepcopy(pose)
|
retval.pose.orientation.y,
|
||||||
deep_pose[1] += float(y_distance)
|
retval.pose.orientation.z,
|
||||||
|
retval.pose.orientation.w,
|
||||||
|
]
|
||||||
|
|
||||||
function_list = [
|
function_list = [
|
||||||
lambda p=pose.copy(), q=quaternion, cfg=config: self.moveit_task(
|
lambda: self.moveit_task(
|
||||||
position=p, quaternion=q, **cfg, cartesian=self.cartesian_flag
|
position=[retval.pose.position.x, retval.pose.position.y, retval.pose.position.z],
|
||||||
|
quaternion=quaternion,
|
||||||
|
**config,
|
||||||
|
cartesian=self.cartesian_flag,
|
||||||
)
|
)
|
||||||
] + function_list
|
] + function_list
|
||||||
|
|
||||||
|
pose[2] += float(cmd_dict["lift_height"])
|
||||||
function_list.append(
|
function_list.append(
|
||||||
lambda dp=deep_pose.copy(), q=quaternion, cfg=config: self.moveit_task(
|
lambda: self.moveit_task(
|
||||||
position=dp, quaternion=q, **cfg, cartesian=self.cartesian_flag
|
position=pose, quaternion=quaternion, **config, cartesian=self.cartesian_flag
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
end_pose = list(deep_pose)
|
end_pose = pose
|
||||||
|
|
||||||
retval_ik = None
|
if "x_distance" in cmd_dict.keys() or "y_distance" in cmd_dict.keys():
|
||||||
attempts_ik = config.get("retry", 10)
|
if "x_distance" in cmd_dict.keys():
|
||||||
while retval_ik is None and attempts_ik > 0:
|
deep_pose = deepcopy(pose)
|
||||||
retval_ik = self.moveit2[move_group].compute_ik(
|
deep_pose[0] += float(cmd_dict["x_distance"])
|
||||||
position=end_pose,
|
elif "y_distance" in cmd_dict.keys():
|
||||||
quat_xyzw=quaternion,
|
deep_pose = deepcopy(pose)
|
||||||
constraints=Constraints(joint_constraints=joint_constraint_msgs),
|
deep_pose[1] += float(cmd_dict["y_distance"])
|
||||||
)
|
|
||||||
time.sleep(0.1)
|
|
||||||
attempts_ik -= 1
|
|
||||||
if retval_ik is None:
|
|
||||||
raise ValueError("Failed to compute inverse kinematics")
|
|
||||||
position_ = [
|
|
||||||
retval_ik.position[retval_ik.name.index(i)] for i in self.moveit2[move_group].joint_names
|
|
||||||
]
|
|
||||||
jn = self.moveit2[move_group].joint_names
|
|
||||||
function_list = [
|
|
||||||
lambda pos=position_, names=jn, cfg=config: self.moveit_joint_task(
|
|
||||||
joint_positions=pos, joint_names=names, **cfg
|
|
||||||
)
|
|
||||||
] + function_list
|
|
||||||
else:
|
|
||||||
function_list = [lambda cfg=config, jp=joint_positions_: self.moveit_joint_task(**cfg, joint_positions=jp)] + function_list
|
|
||||||
|
|
||||||
for i in range(len(function_list)):
|
function_list = [
|
||||||
if i == 0:
|
lambda: self.moveit_task(
|
||||||
self.cartesian_flag = False
|
position=pose, quaternion=quaternion, **config, cartesian=self.cartesian_flag
|
||||||
|
)
|
||||||
|
] + function_list
|
||||||
|
function_list.append(
|
||||||
|
lambda: self.moveit_task(
|
||||||
|
position=deep_pose, quaternion=quaternion, **config, cartesian=self.cartesian_flag
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end_pose = deep_pose
|
||||||
|
|
||||||
|
retval_ik = None
|
||||||
|
retry = config.get("retry", 10)
|
||||||
|
while retval_ik is None and retry > 0:
|
||||||
|
retval_ik = self.moveit2[cmd_dict["move_group"]].compute_ik(
|
||||||
|
position=end_pose, quat_xyzw=quaternion, constraints=Constraints(joint_constraints=constraints)
|
||||||
|
)
|
||||||
|
time.sleep(0.1)
|
||||||
|
retry -= 1
|
||||||
|
if retval_ik is None:
|
||||||
|
result.success = False
|
||||||
|
return result
|
||||||
|
position_ = [
|
||||||
|
retval_ik.position[retval_ik.name.index(i)]
|
||||||
|
for i in self.moveit2[cmd_dict["move_group"]].joint_names
|
||||||
|
]
|
||||||
|
function_list = [
|
||||||
|
lambda: self.moveit_joint_task(
|
||||||
|
joint_positions=position_,
|
||||||
|
joint_names=self.moveit2[cmd_dict["move_group"]].joint_names,
|
||||||
|
**config,
|
||||||
|
)
|
||||||
|
] + function_list
|
||||||
else:
|
else:
|
||||||
self.cartesian_flag = True
|
function_list = [
|
||||||
|
lambda: self.moveit_joint_task(**config, joint_positions=joint_positions_)
|
||||||
|
] + function_list
|
||||||
|
|
||||||
re = function_list[i]()
|
for i in range(len(function_list)):
|
||||||
if not re:
|
if i == 0:
|
||||||
print(i, re)
|
self.cartesian_flag = False
|
||||||
raise ValueError(f"Failed to execute moveit task: {i}")
|
else:
|
||||||
|
self.cartesian_flag = True
|
||||||
|
|
||||||
|
re = function_list[i]()
|
||||||
|
if not re:
|
||||||
|
print(i, re)
|
||||||
|
result.success = False
|
||||||
|
return result
|
||||||
|
result.success = True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
self.cartesian_flag = False
|
self.cartesian_flag = False
|
||||||
raise e
|
result.success = False
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
def set_status(self, command: str):
|
def set_status(self, command: str):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ 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:
|
||||||
"""
|
"""
|
||||||
@@ -43,11 +41,13 @@ class VirtualMultiwayValve:
|
|||||||
def target_position(self) -> int:
|
def target_position(self) -> int:
|
||||||
return self._target_position
|
return self._target_position
|
||||||
|
|
||||||
@property
|
def get_current_position(self) -> int:
|
||||||
@topic_config()
|
"""获取当前阀门位置 📍"""
|
||||||
def current_port(self) -> str:
|
return self._current_position
|
||||||
"""当前连接的端口名称 🔌"""
|
|
||||||
return self.port
|
def get_current_port(self) -> str:
|
||||||
|
"""获取当前连接的端口名称 🔌"""
|
||||||
|
return self._current_position
|
||||||
|
|
||||||
def set_position(self, command: Union[int, str]):
|
def set_position(self, command: Union[int, str]):
|
||||||
"""
|
"""
|
||||||
@@ -169,14 +169,12 @@ class VirtualMultiwayValve:
|
|||||||
self._status = "Idle"
|
self._status = "Idle"
|
||||||
self._valve_state = "Closed"
|
self._valve_state = "Closed"
|
||||||
|
|
||||||
close_msg = f"🔒 阀门已关闭,保持在位置 {self._current_position} ({self.port})"
|
close_msg = f"🔒 阀门已关闭,保持在位置 {self._current_position} ({self.get_current_port()})"
|
||||||
self.logger.info(close_msg)
|
self.logger.info(close_msg)
|
||||||
return close_msg
|
return close_msg
|
||||||
|
|
||||||
@property
|
def get_valve_position(self) -> int:
|
||||||
@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]):
|
||||||
@@ -231,16 +229,19 @@ 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()
|
||||||
|
|
||||||
@property
|
def get_flow_path(self) -> str:
|
||||||
@topic_config()
|
"""获取当前流路路径描述 🌊"""
|
||||||
def flow_path(self) -> str:
|
current_port = self.get_current_port()
|
||||||
"""当前流路路径描述 🌊"""
|
|
||||||
if self._current_position == 0:
|
if self._current_position == 0:
|
||||||
return f"🚰 转移泵已连接 (位置 {self._current_position})"
|
flow_path = f"🚰 转移泵已连接 (位置 {self._current_position})"
|
||||||
return f"🔌 端口 {self._current_position} 已连接 ({self.current_port})"
|
else:
|
||||||
|
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.current_port
|
current_port = self.get_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})"
|
||||||
@@ -252,7 +253,7 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
print("🔄 === 虚拟九通阀门测试 === ✨")
|
print("🔄 === 虚拟九通阀门测试 === ✨")
|
||||||
print(f"🏠 初始状态: {valve}")
|
print(f"🏠 初始状态: {valve}")
|
||||||
print(f"🌊 当前流路: {valve.flow_path}")
|
print(f"🌊 当前流路: {valve.get_flow_path()}")
|
||||||
|
|
||||||
# 切换到试剂瓶1(1号位)
|
# 切换到试剂瓶1(1号位)
|
||||||
print(f"\n🔌 切换到1号位: {valve.set_position(1)}")
|
print(f"\n🔌 切换到1号位: {valve.set_position(1)}")
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ 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:
|
||||||
@@ -315,11 +314,9 @@ class VirtualStirrer:
|
|||||||
def min_speed(self) -> float:
|
def min_speed(self) -> float:
|
||||||
return self._min_speed
|
return self._min_speed
|
||||||
|
|
||||||
@property
|
def get_device_info(self) -> Dict[str, Any]:
|
||||||
@topic_config()
|
"""获取设备状态信息 📊"""
|
||||||
def device_info(self) -> Dict[str, Any]:
|
info = {
|
||||||
"""设备状态快照信息 📊"""
|
|
||||||
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,
|
||||||
@@ -328,9 +325,12 @@ class VirtualStirrer:
|
|||||||
"is_stirring": self.is_stirring,
|
"is_stirring": self.is_stirring,
|
||||||
"remaining_time": self.remaining_time,
|
"remaining_time": self.remaining_time,
|
||||||
"max_speed": self._max_speed,
|
"max_speed": self._max_speed,
|
||||||
"min_speed": self._min_speed,
|
"min_speed": self._min_speed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# self.logger.debug(f"📊 设备信息: 模式={self.operation_mode}, 速度={self.current_speed} RPM, 搅拌={self.is_stirring}")
|
||||||
|
return info
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
status_emoji = "✅" if self.operation_mode == "Idle" else "🌪️" if self.operation_mode == "Stirring" else "🛑" if self.operation_mode == "Settling" else "❌"
|
status_emoji = "✅" if self.operation_mode == "Idle" else "🌪️" if self.operation_mode == "Stirring" else "🛑" if self.operation_mode == "Settling" else "❌"
|
||||||
return f"🌪️ VirtualStirrer({status_emoji} {self.device_id}: {self.operation_mode}, {self.current_speed} RPM)"
|
return f"🌪️ VirtualStirrer({status_emoji} {self.device_id}: {self.operation_mode}, {self.current_speed} RPM)"
|
||||||
@@ -4,7 +4,6 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -386,10 +385,8 @@ class VirtualTransferPump:
|
|||||||
"""获取当前体积"""
|
"""获取当前体积"""
|
||||||
return self._current_volume
|
return self._current_volume
|
||||||
|
|
||||||
@property
|
def get_remaining_capacity(self) -> float:
|
||||||
@topic_config()
|
"""获取剩余容量"""
|
||||||
def remaining_capacity(self) -> float:
|
|
||||||
"""剩余容量 (ml)"""
|
|
||||||
return self.max_volume - self._current_volume
|
return self.max_volume - self._current_volume
|
||||||
|
|
||||||
def is_empty(self) -> bool:
|
def is_empty(self) -> bool:
|
||||||
|
|||||||
@@ -14,30 +14,20 @@ 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 (
|
||||||
ActionInputHandle,
|
device, action, ActionInputHandle, ActionOutputHandle, DataSource, topic_config, not_action, NodeType
|
||||||
ActionOutputHandle,
|
|
||||||
DataSource,
|
|
||||||
NodeType,
|
|
||||||
action,
|
|
||||||
device,
|
|
||||||
not_action,
|
|
||||||
topic_config,
|
|
||||||
)
|
)
|
||||||
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
|
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
|
||||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode
|
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode
|
||||||
from unilabos.resources.resource_tracker import (
|
from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample, ResourceTreeSet
|
||||||
SampleUUIDsType,
|
|
||||||
LabSample,
|
|
||||||
ResourceTreeSet,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ============ TypedDict 返回类型定义 ============
|
# ============ TypedDict 返回类型定义 ============
|
||||||
|
|
||||||
@@ -122,7 +112,6 @@ 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",
|
||||||
)
|
)
|
||||||
@@ -148,19 +137,7 @@ class VirtualWorkbench:
|
|||||||
HEATING_TIME: float = 60.0 # 加热时间(秒)
|
HEATING_TIME: float = 60.0 # 加热时间(秒)
|
||||||
NUM_HEATING_STATIONS: int = 3 # 加热台数量
|
NUM_HEATING_STATIONS: int = 3 # 加热台数量
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
|
||||||
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")
|
||||||
@@ -174,13 +151,9 @@ class VirtualWorkbench:
|
|||||||
self.data: Dict[str, Any] = {}
|
self.data: Dict[str, Any] = {}
|
||||||
|
|
||||||
# 从config中获取可配置参数
|
# 从config中获取可配置参数
|
||||||
self.ARM_OPERATION_TIME = float(
|
self.ARM_OPERATION_TIME = float(self.config.get("arm_operation_time", self.ARM_OPERATION_TIME))
|
||||||
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.NUM_HEATING_STATIONS = int(self.config.get("num_heating_stations", self.NUM_HEATING_STATIONS))
|
||||||
self.config.get("num_heating_stations", self.NUM_HEATING_STATIONS)
|
|
||||||
)
|
|
||||||
|
|
||||||
# 机械臂状态和锁
|
# 机械臂状态和锁
|
||||||
self._arm_lock = Lock()
|
self._arm_lock = Lock()
|
||||||
@@ -189,8 +162,7 @@ class VirtualWorkbench:
|
|||||||
|
|
||||||
# 加热台状态
|
# 加热台状态
|
||||||
self._heating_stations: Dict[int, HeatingStation] = {
|
self._heating_stations: Dict[int, HeatingStation] = {
|
||||||
i: HeatingStation(station_id=i)
|
i: HeatingStation(station_id=i) for i in range(1, self.NUM_HEATING_STATIONS + 1)
|
||||||
for i in range(1, self.NUM_HEATING_STATIONS + 1)
|
|
||||||
}
|
}
|
||||||
self._stations_lock = RLock()
|
self._stations_lock = RLock()
|
||||||
|
|
||||||
@@ -320,113 +292,45 @@ class VirtualWorkbench:
|
|||||||
self.logger.info(f"机械臂已释放 (完成: {task})")
|
self.logger.info(f"机械臂已释放 (完成: {task})")
|
||||||
|
|
||||||
@action(
|
@action(
|
||||||
always_free=True,
|
always_free=True, node_type=NodeType.MANUAL_CONFIRM, placeholder_keys={
|
||||||
node_type=NodeType.MANUAL_CONFIRM,
|
"assignee_user_ids": "unilabos_manual_confirm"
|
||||||
placeholder_keys={"assignee_user_ids": "unilabos_manual_confirm"},
|
}, goal_default={
|
||||||
goal_default={"timeout_seconds": 3600, "assignee_user_ids": []},
|
"timeout_seconds": 3600,
|
||||||
feedback_interval=300,
|
"assignee_user_ids": []
|
||||||
|
}, feedback_interval=300,
|
||||||
handles=[
|
handles=[
|
||||||
ActionInputHandle(
|
ActionInputHandle(key="target_device", data_type="device_id",
|
||||||
key="target_device",
|
label="目标设备", data_key="target_device", data_source=DataSource.HANDLE),
|
||||||
data_type="device_id",
|
ActionInputHandle(key="resource", data_type="resource",
|
||||||
label="目标设备",
|
label="待转移资源", data_key="resource", data_source=DataSource.HANDLE),
|
||||||
data_key="target_device",
|
ActionInputHandle(key="mount_resource", data_type="resource",
|
||||||
data_source=DataSource.HANDLE,
|
label="目标孔位", data_key="mount_resource", data_source=DataSource.HANDLE),
|
||||||
),
|
|
||||||
ActionInputHandle(
|
ActionInputHandle(key="collector_mass", data_type="collector_mass",
|
||||||
key="resource",
|
label="极流体质量", data_key="collector_mass", data_source=DataSource.HANDLE),
|
||||||
data_type="resource",
|
ActionInputHandle(key="active_material", data_type="active_material",
|
||||||
label="待转移资源",
|
label="活性物质含量", data_key="active_material", data_source=DataSource.HANDLE),
|
||||||
data_key="resource",
|
ActionInputHandle(key="capacity", data_type="capacity",
|
||||||
data_source=DataSource.HANDLE,
|
label="克容量", data_key="capacity", data_source=DataSource.HANDLE),
|
||||||
),
|
ActionInputHandle(key="battery_system", data_type="battery_system",
|
||||||
ActionInputHandle(
|
label="电池体系", data_key="battery_system", data_source=DataSource.HANDLE),
|
||||||
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使用
|
# transfer使用
|
||||||
ActionOutputHandle(
|
ActionOutputHandle(key="target_device", data_type="device_id",
|
||||||
key="target_device",
|
label="目标设备", data_key="target_device", data_source=DataSource.EXECUTOR),
|
||||||
data_type="device_id",
|
ActionOutputHandle(key="resource", data_type="resource",
|
||||||
label="目标设备",
|
label="待转移资源", data_key="resource.@flatten", data_source=DataSource.EXECUTOR),
|
||||||
data_key="target_device",
|
ActionOutputHandle(key="mount_resource", data_type="resource",
|
||||||
data_source=DataSource.EXECUTOR,
|
label="目标孔位", data_key="mount_resource.@flatten", 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使用
|
# test使用
|
||||||
ActionOutputHandle(
|
ActionOutputHandle(key="collector_mass", data_type="collector_mass",
|
||||||
key="collector_mass",
|
label="极流体质量", data_key="collector_mass", data_source=DataSource.EXECUTOR),
|
||||||
data_type="collector_mass",
|
ActionOutputHandle(key="active_material", data_type="active_material",
|
||||||
label="极流体质量",
|
label="活性物质含量", data_key="active_material", data_source=DataSource.EXECUTOR),
|
||||||
data_key="collector_mass",
|
ActionOutputHandle(key="capacity", data_type="capacity",
|
||||||
data_source=DataSource.EXECUTOR,
|
label="克容量", data_key="capacity", data_source=DataSource.EXECUTOR),
|
||||||
),
|
ActionOutputHandle(key="battery_system", data_type="battery_system",
|
||||||
ActionOutputHandle(
|
label="电池体系", data_key="battery_system", data_source=DataSource.EXECUTOR),
|
||||||
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(
|
def manual_confirm(
|
||||||
self,
|
self,
|
||||||
@@ -439,156 +343,67 @@ class VirtualWorkbench:
|
|||||||
battery_system: List[str],
|
battery_system: List[str],
|
||||||
timeout_seconds: int,
|
timeout_seconds: int,
|
||||||
assignee_user_ids: list[str],
|
assignee_user_ids: list[str],
|
||||||
**kwargs,
|
**kwargs
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
人工确认资源转移和扣电测试参数。
|
timeout_seconds: 超时时间(秒),默认3600秒
|
||||||
|
collector_mass: 极流体质量
|
||||||
Args:
|
active_material: 活性物质含量
|
||||||
resource[待转移资源]: 需要人工确认的资源列表。
|
capacity: 克容量(mAh/g)
|
||||||
target_device[目标设备]: 资源要转移到的目标设备 ID。
|
battery_system: 电池体系
|
||||||
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()
|
resource = ResourceTreeSet.from_plr_resources(resource).dump()
|
||||||
mount_resource_tree = ResourceTreeSet.from_plr_resources(cast(Any, mount_resource)).dump()
|
mount_resource = ResourceTreeSet.from_plr_resources(mount_resource).dump()
|
||||||
kwargs.update(locals())
|
kwargs.update(locals())
|
||||||
kwargs.pop("kwargs")
|
kwargs.pop("kwargs")
|
||||||
kwargs.pop("self")
|
kwargs.pop("self")
|
||||||
kwargs["resource"] = resource_tree
|
|
||||||
kwargs["mount_resource"] = mount_resource_tree
|
|
||||||
kwargs.pop("resource_tree")
|
|
||||||
kwargs.pop("mount_resource_tree")
|
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
@action(
|
@action(
|
||||||
description="转移物料",
|
description="转移物料",
|
||||||
handles=[
|
handles=[
|
||||||
ActionInputHandle(
|
ActionInputHandle(key="target_device", data_type="device_id",
|
||||||
key="target_device",
|
label="目标设备", data_key="target_device", data_source=DataSource.HANDLE),
|
||||||
data_type="device_id",
|
ActionInputHandle(key="resource", data_type="resource",
|
||||||
label="目标设备",
|
label="待转移资源", data_key="resource", data_source=DataSource.HANDLE),
|
||||||
data_key="target_device",
|
ActionInputHandle(key="mount_resource", data_type="resource",
|
||||||
data_source=DataSource.HANDLE,
|
label="目标孔位", data_key="mount_resource", 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(
|
async def transfer(self, resource: List[ResourceSlot], target_device: DeviceSlot, mount_resource: List[ResourceSlot]):
|
||||||
self,
|
future = ROS2DeviceNode.run_async_func(self._ros_node.transfer_resource_to_another, True,
|
||||||
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,
|
"plr_resources": resource,
|
||||||
"target_device_id": target_device,
|
"target_device_id": target_device,
|
||||||
"target_resources": mount_resource,
|
"target_resources": mount_resource,
|
||||||
"sites": [None] * len(mount_resource),
|
"sites": [None] * len(mount_resource),
|
||||||
},
|
})
|
||||||
)
|
|
||||||
result = await future
|
result = await future
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@action(
|
@action(
|
||||||
description="扣电测试启动",
|
description="扣电测试启动",
|
||||||
handles=[
|
handles=[
|
||||||
ActionInputHandle(
|
ActionInputHandle(key="resource", data_type="resource",
|
||||||
key="resource",
|
label="待转移资源", data_key="resource", data_source=DataSource.HANDLE),
|
||||||
data_type="resource",
|
ActionInputHandle(key="mount_resource", data_type="resource",
|
||||||
label="待转移资源",
|
label="目标孔位", data_key="mount_resource", data_source=DataSource.HANDLE),
|
||||||
data_key="resource",
|
|
||||||
data_source=DataSource.HANDLE,
|
ActionInputHandle(key="collector_mass", data_type="collector_mass",
|
||||||
),
|
label="极流体质量", data_key="collector_mass", data_source=DataSource.HANDLE),
|
||||||
ActionInputHandle(
|
ActionInputHandle(key="active_material", data_type="active_material",
|
||||||
key="mount_resource",
|
label="活性物质含量", data_key="active_material", data_source=DataSource.HANDLE),
|
||||||
data_type="resource",
|
ActionInputHandle(key="capacity", data_type="capacity",
|
||||||
label="目标孔位",
|
label="克容量", data_key="capacity", data_source=DataSource.HANDLE),
|
||||||
data_key="mount_resource",
|
ActionInputHandle(key="battery_system", data_type="battery_system",
|
||||||
data_source=DataSource.HANDLE,
|
label="电池体系", data_key="battery_system", 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(
|
async def test(
|
||||||
self,
|
self, resource: List[ResourceSlot], mount_resource: List[ResourceSlot], collector_mass: List[float], active_material: List[float], capacity: List[float], battery_system: list[str]
|
||||||
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(resource)
|
||||||
print(mount_resource)
|
print(mount_resource)
|
||||||
print(collector_mass)
|
print(collector_mass)
|
||||||
@@ -600,11 +415,16 @@ class VirtualWorkbench:
|
|||||||
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", label="实验1", data_key="material_1", data_source=DataSource.EXECUTOR), # noqa: E501
|
ActionOutputHandle(key="channel_1", data_type="workbench_material",
|
||||||
ActionOutputHandle(key="channel_2", data_type="workbench_material", label="实验2", data_key="material_2", data_source=DataSource.EXECUTOR), # noqa: E501
|
label="实验1", data_key="material_1", data_source=DataSource.EXECUTOR),
|
||||||
ActionOutputHandle(key="channel_3", data_type="workbench_material", label="实验3", data_key="material_3", data_source=DataSource.EXECUTOR), # noqa: E501
|
ActionOutputHandle(key="channel_2", data_type="workbench_material",
|
||||||
ActionOutputHandle(key="channel_4", data_type="workbench_material", label="实验4", data_key="material_4", data_source=DataSource.EXECUTOR), # noqa: E501
|
label="实验2", data_key="material_2", data_source=DataSource.EXECUTOR),
|
||||||
ActionOutputHandle(key="channel_5", data_type="workbench_material", label="实验5", data_key="material_5", data_source=DataSource.EXECUTOR), # noqa: E501
|
ActionOutputHandle(key="channel_3", data_type="workbench_material",
|
||||||
|
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(
|
||||||
@@ -617,9 +437,6 @@ 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)]
|
||||||
|
|
||||||
@@ -640,11 +457,7 @@ class VirtualWorkbench:
|
|||||||
LabSample(
|
LabSample(
|
||||||
sample_uuid=sample_uuid,
|
sample_uuid=sample_uuid,
|
||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra={"material_uuid": content} if isinstance(content, str) else (content.serialize() if content else {}),
|
||||||
{"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()
|
||||||
],
|
],
|
||||||
@@ -654,27 +467,12 @@ class VirtualWorkbench:
|
|||||||
auto_prefix=True,
|
auto_prefix=True,
|
||||||
description="将物料从An位置移动到空闲加热台, 返回分配的加热台ID",
|
description="将物料从An位置移动到空闲加热台, 返回分配的加热台ID",
|
||||||
handles=[
|
handles=[
|
||||||
ActionInputHandle(
|
ActionInputHandle(key="material_input", data_type="workbench_material",
|
||||||
key="material_input",
|
label="物料编号", data_key="material_number", data_source=DataSource.HANDLE),
|
||||||
data_type="workbench_material",
|
ActionOutputHandle(key="heating_station_output", data_type="workbench_station",
|
||||||
label="物料编号",
|
label="加热台ID", data_key="station_id", data_source=DataSource.EXECUTOR),
|
||||||
data_key="material_number",
|
ActionOutputHandle(key="material_number_output", data_type="workbench_material",
|
||||||
data_source=DataSource.HANDLE,
|
label="物料编号", data_key="material_number", data_source=DataSource.EXECUTOR),
|
||||||
),
|
|
||||||
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(
|
||||||
@@ -686,9 +484,6 @@ 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}到加热台"
|
||||||
@@ -751,8 +546,7 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str)
|
if isinstance(content, str) else (content.serialize() if content else {})
|
||||||
else (content.serialize() if content else {})
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
@@ -775,8 +569,7 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str)
|
if isinstance(content, str) else (content.serialize() if content else {})
|
||||||
else (content.serialize() if content else {})
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
@@ -788,34 +581,14 @@ class VirtualWorkbench:
|
|||||||
always_free=True,
|
always_free=True,
|
||||||
description="启动指定加热台的加热程序",
|
description="启动指定加热台的加热程序",
|
||||||
handles=[
|
handles=[
|
||||||
ActionInputHandle(
|
ActionInputHandle(key="station_id_input", data_type="workbench_station",
|
||||||
key="station_id_input",
|
label="加热台ID", data_key="station_id", data_source=DataSource.HANDLE),
|
||||||
data_type="workbench_station",
|
ActionInputHandle(key="material_number_input", data_type="workbench_material",
|
||||||
label="加热台ID",
|
label="物料编号", data_key="material_number", data_source=DataSource.HANDLE),
|
||||||
data_key="station_id",
|
ActionOutputHandle(key="heating_done_station", data_type="workbench_station",
|
||||||
data_source=DataSource.HANDLE,
|
label="加热完成-加热台ID", data_key="station_id", data_source=DataSource.EXECUTOR),
|
||||||
),
|
ActionOutputHandle(key="heating_done_material", data_type="workbench_material",
|
||||||
ActionInputHandle(
|
label="加热完成-物料编号", data_key="material_number", data_source=DataSource.EXECUTOR),
|
||||||
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(
|
||||||
@@ -826,10 +599,6 @@ class VirtualWorkbench:
|
|||||||
) -> StartHeatingResult:
|
) -> StartHeatingResult:
|
||||||
"""
|
"""
|
||||||
启动指定加热台的加热程序
|
启动指定加热台的加热程序
|
||||||
|
|
||||||
Args:
|
|
||||||
station_id[加热台ID]: 要启动加热的加热台编号。
|
|
||||||
material_number[物料编号]: 当前加热台上的物料编号。
|
|
||||||
"""
|
"""
|
||||||
self.logger.info(f"[加热台{station_id}] 开始加热")
|
self.logger.info(f"[加热台{station_id}] 开始加热")
|
||||||
|
|
||||||
@@ -846,8 +615,7 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str)
|
if isinstance(content, str) else (content.serialize() if content else {})
|
||||||
else (content.serialize() if content else {})
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
@@ -870,8 +638,7 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str)
|
if isinstance(content, str) else (content.serialize() if content else {})
|
||||||
else (content.serialize() if content else {})
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
@@ -891,8 +658,7 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str)
|
if isinstance(content, str) else (content.serialize() if content else {})
|
||||||
else (content.serialize() if content else {})
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
@@ -932,9 +698,7 @@ 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(
|
self.logger.info(f"[加热台{station_id}] {material_id} 剩余 {remaining:.1f}s")
|
||||||
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:
|
||||||
@@ -951,9 +715,7 @@ 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(
|
self.logger.info(f"[加热台{station_id}] {material_id}加热完成 (用时{self.HEATING_TIME}s)")
|
||||||
f"[加热台{station_id}] {material_id}加热完成 (用时{self.HEATING_TIME}s)"
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -967,8 +729,7 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str)
|
if isinstance(content, str) else (content.serialize() if content else {})
|
||||||
else (content.serialize() if content else {})
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
@@ -979,20 +740,10 @@ class VirtualWorkbench:
|
|||||||
auto_prefix=True,
|
auto_prefix=True,
|
||||||
description="将物料从加热台移动到输出位置Cn",
|
description="将物料从加热台移动到输出位置Cn",
|
||||||
handles=[
|
handles=[
|
||||||
ActionInputHandle(
|
ActionInputHandle(key="output_station_input", data_type="workbench_station",
|
||||||
key="output_station_input",
|
label="加热台ID", data_key="station_id", data_source=DataSource.HANDLE),
|
||||||
data_type="workbench_station",
|
ActionInputHandle(key="output_material_input", data_type="workbench_material",
|
||||||
label="加热台ID",
|
label="物料编号", data_key="material_number", data_source=DataSource.HANDLE),
|
||||||
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(
|
||||||
@@ -1003,10 +754,6 @@ class VirtualWorkbench:
|
|||||||
) -> MoveToOutputResult:
|
) -> MoveToOutputResult:
|
||||||
"""
|
"""
|
||||||
将物料从加热台移动到输出位置Cn
|
将物料从加热台移动到输出位置Cn
|
||||||
|
|
||||||
Args:
|
|
||||||
station_id[加热台ID]: 已完成加热的加热台编号。
|
|
||||||
material_number[物料编号]: 要移动到输出位置的物料编号,对应 Cn。
|
|
||||||
"""
|
"""
|
||||||
output_number = material_number
|
output_number = material_number
|
||||||
|
|
||||||
@@ -1023,8 +770,7 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str)
|
if isinstance(content, str) else (content.serialize() if content else {})
|
||||||
else (content.serialize() if content else {})
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
@@ -1048,8 +794,7 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str)
|
if isinstance(content, str) else (content.serialize() if content else {})
|
||||||
else (content.serialize() if content else {})
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
@@ -1069,8 +814,7 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str)
|
if isinstance(content, str) else (content.serialize() if content else {})
|
||||||
else (content.serialize() if content else {})
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
@@ -1152,8 +896,7 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str)
|
if isinstance(content, str) else (content.serialize() if content else {})
|
||||||
else (content.serialize() if content else {})
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
# 工作站抽象基类物料系统架构说明
|
# 工作站抽象基类物料系统架构说明
|
||||||
|
|
||||||
## 设计理念
|
|
||||||
|
|
||||||
基于用户需求"请你帮我系统思考一下,工作站抽象基类的物料系统基类该如何构建",我们最终确定了一个**PyLabRobot Deck为中心**的简化架构。
|
|
||||||
|
|
||||||
### 核心原则
|
### 核心原则
|
||||||
|
|
||||||
1. **PyLabRobot为物料管理核心**:使用PyLabRobot的Deck系统作为物料管理的基础,利用其成熟的Resource体系
|
1. **PyLabRobot为物料管理核心**:使用PyLabRobot的Deck系统作为物料管理的基础,利用其成熟的Resource体系
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# Bioyond Cell 工作站 - 多订单返回示例
|
||||||
|
|
||||||
|
本文档说明了 `create_orders` 函数如何收集并返回所有订单的完成报文。
|
||||||
|
|
||||||
|
## 问题描述
|
||||||
|
|
||||||
|
之前的实现只会等待并返回第一个订单的完成报文,如果有多个订单(例如从 Excel 解析出 3 个订单),只能得到第一个订单的推送信息。
|
||||||
|
|
||||||
|
## 解决方案
|
||||||
|
|
||||||
|
修改后的 `create_orders` 函数现在会:
|
||||||
|
|
||||||
|
1. **提取所有 orderCode**:从 LIMS 接口返回的 `data` 列表中提取所有订单编号
|
||||||
|
2. **逐个等待完成**:遍历所有 orderCode,调用 `wait_for_order_finish` 等待每个订单完成
|
||||||
|
3. **收集所有报文**:将每个订单的完成报文存入 `all_reports` 列表
|
||||||
|
4. **统一返回**:返回包含所有订单报文的 JSON 格式数据
|
||||||
|
|
||||||
|
## 返回格式
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "all_completed",
|
||||||
|
"total_orders": 3,
|
||||||
|
"reports": [
|
||||||
|
{
|
||||||
|
"token": "",
|
||||||
|
"request_time": "2025-12-24T15:32:09.2148671+08:00",
|
||||||
|
"data": {
|
||||||
|
"orderId": "3a1e614d-a082-c44a-60be-68647a35e6f1",
|
||||||
|
"orderCode": "BSO2025122400024",
|
||||||
|
"orderName": "DP20251224001",
|
||||||
|
"status": "30",
|
||||||
|
"workflowStatus": "completed",
|
||||||
|
"usedMaterials": [...]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"token": "",
|
||||||
|
"request_time": "2025-12-24T15:32:09.9999039+08:00",
|
||||||
|
"data": {
|
||||||
|
"orderId": "3a1e614d-a0a2-f7a9-9360-610021c9479d",
|
||||||
|
"orderCode": "BSO2025122400025",
|
||||||
|
"orderName": "DP20251224002",
|
||||||
|
"status": "30",
|
||||||
|
"workflowStatus": "completed",
|
||||||
|
"usedMaterials": [...]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"token": "",
|
||||||
|
"request_time": "2025-12-24T15:34:00.4139986+08:00",
|
||||||
|
"data": {
|
||||||
|
"orderId": "3a1e614d-a0cd-81ca-9f7f-2f4e93af01cd",
|
||||||
|
"orderCode": "BSO2025122400026",
|
||||||
|
"orderName": "DP20251224003",
|
||||||
|
"status": "30",
|
||||||
|
"workflowStatus": "completed",
|
||||||
|
"usedMaterials": [...]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"original_response": {...}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 调用 create_orders
|
||||||
|
result = workstation.create_orders("20251224.xlsx")
|
||||||
|
|
||||||
|
# 访问返回数据
|
||||||
|
print(f"总订单数: {result['total_orders']}")
|
||||||
|
print(f"状态: {result['status']}")
|
||||||
|
|
||||||
|
# 遍历所有订单的报文
|
||||||
|
for i, report in enumerate(result['reports'], 1):
|
||||||
|
order_data = report.get('data', {})
|
||||||
|
print(f"\n订单 {i}:")
|
||||||
|
print(f" orderCode: {order_data.get('orderCode')}")
|
||||||
|
print(f" orderName: {order_data.get('orderName')}")
|
||||||
|
print(f" status: {order_data.get('status')}")
|
||||||
|
print(f" 使用物料数: {len(order_data.get('usedMaterials', []))}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 控制台输出示例
|
||||||
|
|
||||||
|
```
|
||||||
|
[create_orders] 即将提交订单数量: 3
|
||||||
|
[create_orders] 接口返回: {...}
|
||||||
|
[create_orders] 等待 3 个订单完成: ['BSO2025122400024', 'BSO2025122400025', 'BSO2025122400026']
|
||||||
|
[create_orders] 正在等待第 1/3 个订单: BSO2025122400024
|
||||||
|
[create_orders] ✓ 订单 BSO2025122400024 完成
|
||||||
|
[create_orders] 正在等待第 2/3 个订单: BSO2025122400025
|
||||||
|
[create_orders] ✓ 订单 BSO2025122400025 完成
|
||||||
|
[create_orders] 正在等待第 3/3 个订单: BSO2025122400026
|
||||||
|
[create_orders] ✓ 订单 BSO2025122400026 完成
|
||||||
|
[create_orders] 所有订单已完成,共收集 3 个报文
|
||||||
|
实验记录本========================create_orders========================
|
||||||
|
返回报文数量: 3
|
||||||
|
报文 1: orderCode=BSO2025122400024, status=30
|
||||||
|
报文 2: orderCode=BSO2025122400025, status=30
|
||||||
|
报文 3: orderCode=BSO2025122400026, status=30
|
||||||
|
========================
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键改进
|
||||||
|
|
||||||
|
1. ✅ **等待所有订单**:不再只等待第一个订单,而是遍历所有 orderCode
|
||||||
|
2. ✅ **收集完整报文**:每个订单的完整推送报文都被保存在 `reports` 数组中
|
||||||
|
3. ✅ **详细日志**:清晰显示正在等待哪个订单,以及完成情况
|
||||||
|
4. ✅ **错误处理**:即使某个订单失败,也会记录其状态信息
|
||||||
|
5. ✅ **统一格式**:返回的 JSON 格式便于后续处理和分析
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
# BioyondCellWorkstation JSON 配置迁移经验总结
|
||||||
|
|
||||||
|
**日期**: 2026-01-13
|
||||||
|
**目的**: 从 `config.py` 迁移到 JSON 配置文件
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题背景
|
||||||
|
|
||||||
|
原系统通过 `config.py` 管理配置,导致:
|
||||||
|
1. HTTP 服务重复启动(父类 `BioyondWorkstation` 和子类都启动)
|
||||||
|
2. 配置分散在代码中,不便于管理
|
||||||
|
3. 无法通过 JSON 统一配置所有参数
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 解决方案:嵌套配置结构
|
||||||
|
|
||||||
|
### JSON 结构设计
|
||||||
|
|
||||||
|
**正确示例** (嵌套在 `config` 中):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nodes": [{
|
||||||
|
"id": "bioyond_cell_workstation",
|
||||||
|
"config": {
|
||||||
|
"deck": {...},
|
||||||
|
"protocol_type": [],
|
||||||
|
"bioyond_config": {
|
||||||
|
"api_host": "http://172.16.11.219:44388",
|
||||||
|
"api_key": "8A819E5C",
|
||||||
|
"timeout": 30,
|
||||||
|
"HTTP_host": "172.16.11.206",
|
||||||
|
"HTTP_port": 8080,
|
||||||
|
"debug_mode": false,
|
||||||
|
"material_type_mappings": {...},
|
||||||
|
"warehouse_mapping": {...},
|
||||||
|
"solid_liquid_mappings": {...}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"data": {}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键点**:
|
||||||
|
- ✅ `bioyond_config` 放在 `config` 中(会传递到 `__init__`)
|
||||||
|
- ❌ **不要**放在 `data` 中(`data` 是运行时状态,不会传递)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Python 代码适配
|
||||||
|
|
||||||
|
### 1. 修改 `BioyondCellWorkstation.__init__` 签名
|
||||||
|
|
||||||
|
**文件**: `bioyond_cell_workstation.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
def __init__(self, bioyond_config: dict = None, deck=None, protocol_type=None, **kwargs):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
bioyond_config: 从 JSON 加载的配置字典
|
||||||
|
deck: Deck 配置
|
||||||
|
protocol_type: 协议类型
|
||||||
|
"""
|
||||||
|
# 验证配置
|
||||||
|
if bioyond_config is None:
|
||||||
|
raise ValueError("需要 bioyond_config 参数")
|
||||||
|
|
||||||
|
# 保存配置
|
||||||
|
self.bioyond_config = bioyond_config
|
||||||
|
|
||||||
|
# 设置 HTTP 服务去重标志
|
||||||
|
self.bioyond_config["_disable_auto_http_service"] = True
|
||||||
|
|
||||||
|
# 调用父类
|
||||||
|
super().__init__(bioyond_config=self.bioyond_config, deck=deck, **kwargs)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 替换全局变量引用
|
||||||
|
|
||||||
|
**修改前**(使用全局变量):
|
||||||
|
```python
|
||||||
|
from config import MATERIAL_TYPE_MAPPINGS, WAREHOUSE_MAPPING
|
||||||
|
|
||||||
|
def create_sample(self, board_type, ...):
|
||||||
|
carrier_type_id = MATERIAL_TYPE_MAPPINGS[board_type][1]
|
||||||
|
location_id = WAREHOUSE_MAPPING[warehouse_name]["site_uuids"][location_code]
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改后**(从配置读取):
|
||||||
|
```python
|
||||||
|
def create_sample(self, board_type, ...):
|
||||||
|
carrier_type_id = self.bioyond_config['material_type_mappings'][board_type][1]
|
||||||
|
location_id = self.bioyond_config['warehouse_mapping'][warehouse_name]["site_uuids"][location_code]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 修复父类配置访问
|
||||||
|
|
||||||
|
在 `station.py` 中安全访问配置默认值:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 修改前(会 KeyError)
|
||||||
|
self._http_service_config = {
|
||||||
|
"host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG["http_service_host"])
|
||||||
|
}
|
||||||
|
|
||||||
|
# 修改后(安全访问)
|
||||||
|
self._http_service_config = {
|
||||||
|
"host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG.get("http_service_host", ""))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见陷阱
|
||||||
|
|
||||||
|
### ❌ 错误1:将配置放在 `data` 字段
|
||||||
|
```json
|
||||||
|
"config": {"deck": {...}},
|
||||||
|
"data": {"bioyond_config": {...}} // ❌ 不会传递到 __init__
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ 错误2:扁平化配置(已废弃方案)
|
||||||
|
虽然扁平化也能工作,但不推荐:
|
||||||
|
```json
|
||||||
|
"config": {
|
||||||
|
"deck": {...},
|
||||||
|
"api_host": "...", // ❌ 不够清晰
|
||||||
|
"api_key": "...",
|
||||||
|
"HTTP_host": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ 错误3:忘记替换全局变量引用
|
||||||
|
代码中直接使用 `MATERIAL_TYPE_MAPPINGS` 等全局变量会导致 `NameError`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 云端同步注意事项
|
||||||
|
|
||||||
|
使用 `--upload_registry` 时,云端配置可能覆盖本地配置:
|
||||||
|
- 首次上传时确保 JSON 完整
|
||||||
|
- 或使用新的 `ak/sk` 避免旧配置干扰
|
||||||
|
- 调试时可暂时移除 `--upload_registry` 参数
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验证清单
|
||||||
|
|
||||||
|
启动成功后应看到:
|
||||||
|
```
|
||||||
|
✅ 从 JSON 配置加载 bioyond_config 成功
|
||||||
|
API Host: http://...
|
||||||
|
HTTP Service: ...
|
||||||
|
✅ BioyondCellWorkstation 初始化完成
|
||||||
|
Loaded ResourceTreeSet with ... nodes
|
||||||
|
```
|
||||||
|
|
||||||
|
运行时不应出现:
|
||||||
|
- ❌ `NameError: name 'MATERIAL_TYPE_MAPPINGS' is not defined`
|
||||||
|
- ❌ `KeyError: 'http_service_host'`
|
||||||
|
- ❌ `bioyond_config 缺少必需参数`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 调试经验
|
||||||
|
|
||||||
|
1. **添加调试日志**查看参数传递链路:
|
||||||
|
- `graphio.py`: JSON 加载后的 config 内容
|
||||||
|
- `initialize_device.py`: `device_config.res_content.config` 的键
|
||||||
|
- `bioyond_cell_workstation.py`: `__init__` 接收到的参数
|
||||||
|
|
||||||
|
2. **config vs data 区别**:
|
||||||
|
- `config`: 初始化参数,传递给 `__init__`
|
||||||
|
- `data`: 运行时状态,不传递给 `__init__`
|
||||||
|
|
||||||
|
3. **参数名必须匹配**:
|
||||||
|
- JSON 中的键名必须与 `__init__` 参数名完全一致
|
||||||
|
|
||||||
|
4. **调试代码清理**:完成后记得删除调试日志(🔍 DEBUG 标记)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 修改文件清单
|
||||||
|
|
||||||
|
| 文件 | 修改内容 |
|
||||||
|
|------|----------|
|
||||||
|
| `yibin_electrolyte_config.json` | 创建嵌套 `config.bioyond_config` 结构 |
|
||||||
|
| `bioyond_cell_workstation.py` | 修改 `__init__` 接收 `bioyond_config`,替换所有全局变量引用 |
|
||||||
|
| `station.py` | 安全访问 `HTTP_SERVICE_CONFIG` 默认值 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 参考代码位置
|
||||||
|
|
||||||
|
- JSON 配置示例: `yibin_electrolyte_config.json` L12-L353
|
||||||
|
- `__init__` 实现: `bioyond_cell_workstation.py` L39-L94
|
||||||
|
- 全局变量替换示例: `bioyond_cell_workstation.py` L2005, L1863, L1966
|
||||||
|
- HTTP 服务配置: `station.py` L629-L634
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**总结**: 使用嵌套结构将所有配置放在 `config.bioyond_config` 中,修改 `__init__` 直接接收该参数,并替换所有全局变量引用为 `self.bioyond_config` 访问。
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
# BioyondCell 配置迁移修改总结
|
||||||
|
|
||||||
|
**日期**: 2026-01-13
|
||||||
|
**目标**: 从 `config.py` 完全迁移到 JSON 配置,消除所有全局变量依赖
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 修改概览
|
||||||
|
|
||||||
|
本次修改完成了 BioyondCell 模块从 Python 配置文件到 JSON 配置的完整迁移,并清理了所有对 `config.py` 全局变量的依赖。
|
||||||
|
|
||||||
|
### 核心成果
|
||||||
|
|
||||||
|
- ✅ 完全移除对 `config.py` 的导入依赖
|
||||||
|
- ✅ 使用嵌套 JSON 结构 `config.bioyond_config`
|
||||||
|
- ✅ 修复 7 处 `bioyond_cell_workstation.py` 中的全局变量引用
|
||||||
|
- ✅ 修复 3 处其他文件中的全局变量引用
|
||||||
|
- ✅ HTTP 服务去重机制完善
|
||||||
|
- ✅ 系统成功启动并正常运行
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 修改文件清单
|
||||||
|
|
||||||
|
### 1. JSON 配置文件
|
||||||
|
|
||||||
|
**文件**: `yibin_electrolyte_config.json`
|
||||||
|
|
||||||
|
**修改**:
|
||||||
|
- 采用嵌套结构将所有配置放在 `config.bioyond_config` 中
|
||||||
|
- 包含:`api_host`, `api_key`, `HTTP_host`, `HTTP_port`, `material_type_mappings`, `warehouse_mapping`, `solid_liquid_mappings` 等
|
||||||
|
|
||||||
|
**示例结构**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nodes": [{
|
||||||
|
"id": "bioyond_cell_workstation",
|
||||||
|
"config": {
|
||||||
|
"deck": {...},
|
||||||
|
"protocol_type": [],
|
||||||
|
"bioyond_config": {
|
||||||
|
"api_host": "http://172.16.11.219:44388",
|
||||||
|
"api_key": "8A819E5C",
|
||||||
|
"HTTP_host": "172.16.11.206",
|
||||||
|
"HTTP_port": 8080,
|
||||||
|
"material_type_mappings": {...},
|
||||||
|
"warehouse_mapping": {...},
|
||||||
|
"solid_liquid_mappings": {...}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. bioyond_cell_workstation.py
|
||||||
|
|
||||||
|
**位置**: `unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py`
|
||||||
|
|
||||||
|
#### 修改 A: `__init__` 方法签名 (L39-99)
|
||||||
|
|
||||||
|
**修改前**:
|
||||||
|
```python
|
||||||
|
def __init__(self, deck=None, protocol_type=None, **kwargs):
|
||||||
|
# 从 kwargs 收集配置字段
|
||||||
|
self.bioyond_config = {}
|
||||||
|
for field in bioyond_field_names:
|
||||||
|
if field in kwargs:
|
||||||
|
self.bioyond_config[field] = kwargs.pop(field)
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改后**:
|
||||||
|
```python
|
||||||
|
def __init__(self, bioyond_config: dict = None, deck=None, protocol_type=None, **kwargs):
|
||||||
|
"""直接接收 bioyond_config 参数"""
|
||||||
|
if bioyond_config is None:
|
||||||
|
raise ValueError("需要 bioyond_config 参数")
|
||||||
|
|
||||||
|
self.bioyond_config = bioyond_config
|
||||||
|
|
||||||
|
# 设置 HTTP 服务去重标志
|
||||||
|
self.bioyond_config["_disable_auto_http_service"] = True
|
||||||
|
|
||||||
|
super().__init__(bioyond_config=self.bioyond_config, deck=deck, **kwargs)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 修改 B: 替换全局变量引用 (7 处)
|
||||||
|
|
||||||
|
| 位置 | 原代码 | 修改后 |
|
||||||
|
|------|--------|--------|
|
||||||
|
| L2005 | `MATERIAL_TYPE_MAPPINGS[board_type][1]` | `self.bioyond_config['material_type_mappings'][board_type][1]` |
|
||||||
|
| L2006 | `MATERIAL_TYPE_MAPPINGS[bottle_type][1]` | `self.bioyond_config['material_type_mappings'][bottle_type][1]` |
|
||||||
|
| L2009 | `WAREHOUSE_MAPPING` | `self.bioyond_config['warehouse_mapping']` |
|
||||||
|
| L2013 | `WAREHOUSE_MAPPING[warehouse_name]` | `self.bioyond_config['warehouse_mapping'][warehouse_name]` |
|
||||||
|
| L2017 | `WAREHOUSE_MAPPING[warehouse_name]["site_uuids"]` | `self.bioyond_config['warehouse_mapping'][warehouse_name]["site_uuids"]` |
|
||||||
|
| L1863 | `SOLID_LIQUID_MAPPINGS.get(material_name)` | `self.bioyond_config.get('solid_liquid_mappings', {}).get(material_name)` |
|
||||||
|
| L1966, L1976 | `MATERIAL_TYPE_MAPPINGS.items()` | `self.bioyond_config['material_type_mappings'].items()` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. station.py
|
||||||
|
|
||||||
|
**位置**: `unilabos/devices/workstation/bioyond_studio/station.py`
|
||||||
|
|
||||||
|
#### 修改 A: 删除 config 导入 (L26-28)
|
||||||
|
|
||||||
|
**修改前**:
|
||||||
|
```python
|
||||||
|
from unilabos.devices.workstation.bioyond_studio.config import (
|
||||||
|
API_CONFIG, WORKFLOW_MAPPINGS, MATERIAL_TYPE_MAPPINGS, WAREHOUSE_MAPPING, HTTP_SERVICE_CONFIG
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改后**:
|
||||||
|
```python
|
||||||
|
# 已删除此导入
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 修改 B: `_create_communication_module` 方法 (L691-702)
|
||||||
|
|
||||||
|
**修改前**:
|
||||||
|
```python
|
||||||
|
def _create_communication_module(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||||
|
default_config = {
|
||||||
|
**API_CONFIG,
|
||||||
|
"workflow_mappings": WORKFLOW_MAPPINGS,
|
||||||
|
"material_type_mappings": MATERIAL_TYPE_MAPPINGS,
|
||||||
|
"warehouse_mapping": WAREHOUSE_MAPPING
|
||||||
|
}
|
||||||
|
if config:
|
||||||
|
self.bioyond_config = {**default_config, **config}
|
||||||
|
else:
|
||||||
|
self.bioyond_config = default_config
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改后**:
|
||||||
|
```python
|
||||||
|
def _create_communication_module(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||||
|
"""创建Bioyond通信模块"""
|
||||||
|
# 使用传入的 config 参数(来自 bioyond_config)
|
||||||
|
# 不再依赖全局变量 API_CONFIG 等
|
||||||
|
if config:
|
||||||
|
self.bioyond_config = config
|
||||||
|
else:
|
||||||
|
# 如果没有传入配置,创建空配置(用于测试或兼容性)
|
||||||
|
self.bioyond_config = {}
|
||||||
|
|
||||||
|
self.hardware_interface = BioyondV1RPC(self.bioyond_config)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 修改 C: HTTP 服务配置 (L627-632)
|
||||||
|
|
||||||
|
**修改前**:
|
||||||
|
```python
|
||||||
|
self._http_service_config = {
|
||||||
|
"host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG.get("http_service_host", "")),
|
||||||
|
"port": bioyond_config.get("http_service_port", HTTP_SERVICE_CONFIG.get("http_service_port", 0))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改后**:
|
||||||
|
```python
|
||||||
|
self._http_service_config = {
|
||||||
|
"host": bioyond_config.get("http_service_host", bioyond_config.get("HTTP_host", "")),
|
||||||
|
"port": bioyond_config.get("http_service_port", bioyond_config.get("HTTP_port", 0))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. bioyond_rpc.py
|
||||||
|
|
||||||
|
**位置**: `unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py`
|
||||||
|
|
||||||
|
#### 修改 A: 删除 config 导入 (L12)
|
||||||
|
|
||||||
|
**修改前**:
|
||||||
|
```python
|
||||||
|
from unilabos.devices.workstation.bioyond_studio.config import LOCATION_MAPPING
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改后**:
|
||||||
|
```python
|
||||||
|
# 已删除此导入
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 修改 B: `material_outbound` 方法 (L278-280)
|
||||||
|
|
||||||
|
**修改前**:
|
||||||
|
```python
|
||||||
|
def material_outbound(self, material_id: str, location_name: str, quantity: int) -> dict:
|
||||||
|
"""指定库位出库物料(通过库位名称)"""
|
||||||
|
location_id = LOCATION_MAPPING.get(location_name, location_name)
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改后**:
|
||||||
|
```python
|
||||||
|
def material_outbound(self, material_id: str, location_name: str, quantity: int) -> dict:
|
||||||
|
"""指定库位出库物料(通过库位名称)"""
|
||||||
|
# location_name 参数实际上应该直接是 location_id (UUID)
|
||||||
|
location_id = location_name
|
||||||
|
```
|
||||||
|
|
||||||
|
**说明**: `LOCATION_MAPPING` 在 `config-0113.py` 中本来就是空字典 `{}`,所以直接使用 `location_name` 逻辑等价。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 关键设计决策
|
||||||
|
|
||||||
|
### 1. 嵌套 vs 扁平配置
|
||||||
|
|
||||||
|
**选择**: 嵌套结构 `config.bioyond_config`
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- ✅ 语义清晰,配置分组明确
|
||||||
|
- ✅ 参数传递直观,直接对应 `__init__` 参数
|
||||||
|
- ✅ 易于维护,不需要硬编码字段列表
|
||||||
|
- ✅ 符合 UniLab 设计模式
|
||||||
|
|
||||||
|
### 2. HTTP 服务去重
|
||||||
|
|
||||||
|
**实现**: 子类设置 `_disable_auto_http_service` 标志
|
||||||
|
|
||||||
|
```python
|
||||||
|
# bioyond_cell_workstation.py
|
||||||
|
self.bioyond_config["_disable_auto_http_service"] = True
|
||||||
|
|
||||||
|
# station.py (post_init)
|
||||||
|
if self.bioyond_config.get("_disable_auto_http_service"):
|
||||||
|
logger.info("子类已自行管理HTTP服务,跳过自动启动")
|
||||||
|
return
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 全局变量替换策略
|
||||||
|
|
||||||
|
**原则**: 所有配置从 `self.bioyond_config` 获取
|
||||||
|
|
||||||
|
**模式**:
|
||||||
|
```python
|
||||||
|
# 修改前
|
||||||
|
from config import MATERIAL_TYPE_MAPPINGS
|
||||||
|
carrier_type_id = MATERIAL_TYPE_MAPPINGS[board_type][1]
|
||||||
|
|
||||||
|
# 修改后
|
||||||
|
carrier_type_id = self.bioyond_config['material_type_mappings'][board_type][1]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 验证结果
|
||||||
|
|
||||||
|
### 启动成功日志
|
||||||
|
```
|
||||||
|
✅ 从 JSON 配置加载 bioyond_config 成功
|
||||||
|
API Host: http://172.16.11.219:44388
|
||||||
|
HTTP Service: 172.16.11.206:8080
|
||||||
|
🔧 已设置 _disable_auto_http_service 标志,防止 HTTP 服务重复启动
|
||||||
|
✅ BioyondCellWorkstation 初始化完成
|
||||||
|
Loaded ResourceTreeSet with 1 trees, 1785 total nodes
|
||||||
|
```
|
||||||
|
|
||||||
|
### 功能验证
|
||||||
|
- ✅ 订单创建 (`create_orders_v2`)
|
||||||
|
- ✅ 质量比计算
|
||||||
|
- ✅ 物料转移 (`transfer_3_to_2_to_1`)
|
||||||
|
- ✅ HTTP 报送接收 (step_finish, sample_finish, order_finish)
|
||||||
|
- ✅ 等待机制 (`wait_for_order_finish`)
|
||||||
|
- ✅ 仓库 UUID 映射
|
||||||
|
- ✅ 物料类型映射
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 相关文档
|
||||||
|
|
||||||
|
- **配置迁移经验**: `2026-01-13_JSON配置迁移经验.md`
|
||||||
|
- **任务清单**: `C:\Users\AndyXie\.gemini\antigravity\brain\...\task.md`
|
||||||
|
- **实施计划**: `C:\Users\AndyXie\.gemini\antigravity\brain\...\implementation_plan.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 注意事项
|
||||||
|
|
||||||
|
### 其他工作站模块
|
||||||
|
|
||||||
|
以下文件仍在使用 `config.py` 全局变量(未包含在本次修改中):
|
||||||
|
- `reaction_station.py` - 使用 `API_CONFIG`
|
||||||
|
- `experiment.py` - 使用 `API_CONFIG`, `WORKFLOW_MAPPINGS`, `MATERIAL_TYPE_MAPPINGS`
|
||||||
|
- `dispensing_station.py` - 使用 `API_CONFIG`, `WAREHOUSE_MAPPING`
|
||||||
|
- `station.py` L176, L177, L529, L530 - 动态导入 `WAREHOUSE_MAPPING`
|
||||||
|
|
||||||
|
**建议**: 后续可以统一迁移这些模块到 JSON 配置。
|
||||||
|
|
||||||
|
### config.py 文件
|
||||||
|
|
||||||
|
`config.py` 文件已恢复但**不再被 bioyond_cell 使用**。可以:
|
||||||
|
- 保留作为其他模块的参考
|
||||||
|
- 或者完全删除(如果其他模块也迁移完成)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 下一步建议
|
||||||
|
|
||||||
|
1. **清理调试代码** ✅ (已完成)
|
||||||
|
2. **提交代码到 Git**
|
||||||
|
3. **迁移其他工作站模块** (可选)
|
||||||
|
4. **更新文档和启动脚本**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**修改完成日期**: 2026-01-13
|
||||||
|
**系统状态**: ✅ 稳定运行
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,157 @@
|
|||||||
|
# 批量出库 Excel 模板使用说明
|
||||||
|
|
||||||
|
**文件**: `outbound_template.xlsx`
|
||||||
|
**用途**: 配合 `auto_batch_outbound_from_xlsx()` 方法进行批量出库操作
|
||||||
|
**API 端点**: `/api/lims/storage/auto-batch-out-bound`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Excel 列说明
|
||||||
|
|
||||||
|
| 列名 | 说明 | 示例 | 必填 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `locationId` | **库位 ID(UUID)** | `3a19da43-57b5-294f-d663-154a1cc32270` | ✅ 是 |
|
||||||
|
| `warehouseId` | **仓库 ID 或名称** | `配液站内试剂仓库` | ✅ 是 |
|
||||||
|
| `quantity` | **出库数量** | `1.0`, `2.0` | ✅ 是 |
|
||||||
|
| `x` | **X 坐标(库位横向位置)** | `1`, `2`, `3` | ✅ 是 |
|
||||||
|
| `y` | **Y 坐标(库位纵向位置)** | `1`, `2`, `3` | ✅ 是 |
|
||||||
|
| `z` | **Z 坐标(库位层数/高度)** | `1`, `2`, `3` | ✅ 是 |
|
||||||
|
| `备注说明` | 可选备注信息 | `配液站内试剂仓库-A01` | ❌ 否 |
|
||||||
|
|
||||||
|
### 📐 坐标说明
|
||||||
|
|
||||||
|
**x, y, z** 是库位在仓库内的**三维坐标**:
|
||||||
|
|
||||||
|
```
|
||||||
|
仓库(例如 WH4)
|
||||||
|
├── Z=1(第1层/加样头面)
|
||||||
|
│ ├── X=1, Y=1(位置 A)
|
||||||
|
│ ├── X=2, Y=1(位置 B)
|
||||||
|
│ ├── X=3, Y=1(位置 C)
|
||||||
|
│ └── ...
|
||||||
|
│
|
||||||
|
└── Z=2(第2层/原液瓶面)
|
||||||
|
├── X=1, Y=1(位置 A)
|
||||||
|
├── X=2, Y=1(位置 B)
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
- **warehouseId**: 指定哪个仓库(WH3, WH4, 配液站等)
|
||||||
|
- **x, y, z**: 在该仓库内的三维坐标
|
||||||
|
- **locationId**: 该坐标位置的唯一 UUID
|
||||||
|
|
||||||
|
### 🎯 起点与终点
|
||||||
|
|
||||||
|
**重要说明**:批量出库模板**只规定了出库的"起点"**(从哪里取物料),**没有指定"终点"**(放到哪里)。
|
||||||
|
|
||||||
|
```
|
||||||
|
出库流程:
|
||||||
|
起点(Excel 指定) → ?终点(LIMS/工作流决定)
|
||||||
|
↓
|
||||||
|
locationId, x, y, z → 由 LIMS 系统或当前工作流自动分配
|
||||||
|
```
|
||||||
|
|
||||||
|
**终点由以下方式确定:**
|
||||||
|
- **LIMS 系统自动分配**:根据当前任务自动规划目标位置
|
||||||
|
- **工作流预定义**:在创建出库任务时已绑定目标位置
|
||||||
|
- **暂存区**:默认放到出库暂存区,等待下一步操作
|
||||||
|
|
||||||
|
💡 **对比**:上料操作(`auto_feeding4to3`)则有 `targetWH` 参数可以指定目标仓库
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 如何获取 UUID?
|
||||||
|
|
||||||
|
### 方法 1:从配置文件获取
|
||||||
|
|
||||||
|
参考 `yibin_electrolyte_config.json` 中的 `warehouse_mapping`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"warehouse_mapping": {
|
||||||
|
"配液站内试剂仓库": {
|
||||||
|
"site_uuids": {
|
||||||
|
"A01": "3a19da43-57b5-294f-d663-154a1cc32270",
|
||||||
|
"B01": "3a19da43-57b5-7394-5f49-54efe2c9bef2",
|
||||||
|
"C01": "3a19da43-57b5-5e75-552f-8dbd0ad1075f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"手动堆栈": {
|
||||||
|
"site_uuids": {
|
||||||
|
"A01": "3a19deae-2c7a-36f5-5e41-02c5b66feaea",
|
||||||
|
"A02": "3a19deae-2c7a-dc6d-c41e-ef285d946cfe"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方法 2:通过 API 查询
|
||||||
|
|
||||||
|
```python
|
||||||
|
material_info = hardware_interface.material_id_query(workflow_id)
|
||||||
|
locations = material_info.get("locations", [])
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 填写示例
|
||||||
|
|
||||||
|
### 示例 1:从配液站内试剂仓库出库
|
||||||
|
|
||||||
|
| locationId | warehouseId | quantity | x | y | z | 备注说明 |
|
||||||
|
|------------|-------------|----------|---|---|---|----------|
|
||||||
|
| `3a19da43-57b5-294f-d663-154a1cc32270` | 配液站内试剂仓库 | 1 | 1 | 1 | 1 | A01 位置 |
|
||||||
|
| `3a19da43-57b5-7394-5f49-54efe2c9bef2` | 配液站内试剂仓库 | 2 | 2 | 1 | 1 | B01 位置 |
|
||||||
|
|
||||||
|
### 示例 2:从手动堆栈出库
|
||||||
|
|
||||||
|
| locationId | warehouseId | quantity | x | y | z | 备注说明 |
|
||||||
|
|------------|-------------|----------|---|---|---|----------|
|
||||||
|
| `3a19deae-2c7a-36f5-5e41-02c5b66feaea` | 手动堆栈 | 1 | 1 | 1 | 1 | A01 |
|
||||||
|
| `3a19deae-2c7a-dc6d-c41e-ef285d946cfe` | 手动堆栈 | 1 | 1 | 2 | 1 | A02 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💻 使用方法
|
||||||
|
|
||||||
|
```python
|
||||||
|
from bioyond_cell_workstation import BioyondCellWorkstation
|
||||||
|
|
||||||
|
# 初始化工作站
|
||||||
|
workstation = BioyondCellWorkstation(config=config, deck=deck)
|
||||||
|
|
||||||
|
# 调用批量出库方法
|
||||||
|
result = workstation.auto_batch_outbound_from_xlsx(
|
||||||
|
xlsx_path="outbound_template.xlsx"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 注意事项
|
||||||
|
|
||||||
|
1. **locationId 必须是有效的 UUID**,不能使用库位名称
|
||||||
|
2. **x, y, z 坐标必须与 locationId 对应**,表示该库位在仓库内的位置
|
||||||
|
3. **quantity 必须是数字**,可以是整数或浮点数
|
||||||
|
4. Excel 文件必须包含表头行
|
||||||
|
5. 空行会被自动跳过
|
||||||
|
6. 确保 UUID 与实际库位对应,否则 API 会报错
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 相关文件
|
||||||
|
|
||||||
|
- **配置文件**: `yibin_electrolyte_config.json`
|
||||||
|
- **Python 代码**: `bioyond_cell_workstation.py` (L630-695)
|
||||||
|
- **生成脚本**: `create_outbound_template.py`
|
||||||
|
- **上料模板**: `material_template.xlsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 重新生成模板
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda activate newunilab
|
||||||
|
python create_outbound_template.py
|
||||||
|
```
|
||||||
@@ -9,7 +9,7 @@ from datetime import datetime, timezone
|
|||||||
from unilabos.device_comms.rpc import BaseRequest
|
from unilabos.device_comms.rpc import BaseRequest
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any
|
||||||
import json
|
import json
|
||||||
from unilabos.devices.workstation.bioyond_studio.config import LOCATION_MAPPING
|
|
||||||
|
|
||||||
|
|
||||||
class SimpleLogger:
|
class SimpleLogger:
|
||||||
@@ -49,6 +49,14 @@ class BioyondV1RPC(BaseRequest):
|
|||||||
self.config = config
|
self.config = config
|
||||||
self.api_key = config["api_key"]
|
self.api_key = config["api_key"]
|
||||||
self.host = config["api_host"]
|
self.host = config["api_host"]
|
||||||
|
|
||||||
|
# 初始化 location_mapping
|
||||||
|
# 直接从 warehouse_mapping 构建,确保数据源所谓的单一和结构化
|
||||||
|
self.location_mapping = {}
|
||||||
|
warehouse_mapping = self.config.get("warehouse_mapping", {})
|
||||||
|
for warehouse_name, warehouse_config in warehouse_mapping.items():
|
||||||
|
if "site_uuids" in warehouse_config:
|
||||||
|
self.location_mapping.update(warehouse_config["site_uuids"])
|
||||||
self._logger = SimpleLogger()
|
self._logger = SimpleLogger()
|
||||||
self.material_cache = {}
|
self.material_cache = {}
|
||||||
self._load_material_cache()
|
self._load_material_cache()
|
||||||
@@ -176,7 +184,40 @@ class BioyondV1RPC(BaseRequest):
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
print(f"add material data: {response['data']}")
|
print(f"add material data: {response['data']}")
|
||||||
return response.get("data", {})
|
|
||||||
|
# 自动更新缓存
|
||||||
|
data = response.get("data", {})
|
||||||
|
if data:
|
||||||
|
if isinstance(data, str):
|
||||||
|
# 如果返回的是字符串,通常是ID
|
||||||
|
mat_id = data
|
||||||
|
name = params.get("name")
|
||||||
|
else:
|
||||||
|
# 如果返回的是字典,尝试获取name和id
|
||||||
|
name = data.get("name") or params.get("name")
|
||||||
|
mat_id = data.get("id")
|
||||||
|
|
||||||
|
if name and mat_id:
|
||||||
|
self.material_cache[name] = mat_id
|
||||||
|
print(f"已自动更新缓存: {name} -> {mat_id}")
|
||||||
|
|
||||||
|
# 处理返回数据中的 details (如果有)
|
||||||
|
# 有些 API 返回结构可能直接包含 details,或者在 data 字段中
|
||||||
|
details = data.get("details", []) if isinstance(data, dict) else []
|
||||||
|
if not details and isinstance(data, dict):
|
||||||
|
details = data.get("detail", [])
|
||||||
|
|
||||||
|
if details:
|
||||||
|
for detail in details:
|
||||||
|
d_name = detail.get("name")
|
||||||
|
# 尝试从不同字段获取 ID
|
||||||
|
d_id = detail.get("id") or detail.get("detailMaterialId")
|
||||||
|
|
||||||
|
if d_name and d_id:
|
||||||
|
self.material_cache[d_name] = d_id
|
||||||
|
print(f"已自动更新 detail 缓存: {d_name} -> {d_id}")
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
def query_matial_type_id(self, data) -> list:
|
def query_matial_type_id(self, data) -> list:
|
||||||
"""查找物料typeid"""
|
"""查找物料typeid"""
|
||||||
@@ -203,7 +244,7 @@ class BioyondV1RPC(BaseRequest):
|
|||||||
params={
|
params={
|
||||||
"apiKey": self.api_key,
|
"apiKey": self.api_key,
|
||||||
"requestTime": self.get_current_time_iso8601(),
|
"requestTime": self.get_current_time_iso8601(),
|
||||||
"data": {},
|
"data": 0,
|
||||||
})
|
})
|
||||||
if not response or response['code'] != 1:
|
if not response or response['code'] != 1:
|
||||||
return []
|
return []
|
||||||
@@ -273,11 +314,19 @@ class BioyondV1RPC(BaseRequest):
|
|||||||
|
|
||||||
if not response or response['code'] != 1:
|
if not response or response['code'] != 1:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
# 自动更新缓存 - 移除被删除的物料
|
||||||
|
for name, mid in list(self.material_cache.items()):
|
||||||
|
if mid == material_id:
|
||||||
|
del self.material_cache[name]
|
||||||
|
print(f"已从缓存移除物料: {name}")
|
||||||
|
break
|
||||||
|
|
||||||
return response.get("data", {})
|
return response.get("data", {})
|
||||||
|
|
||||||
def material_outbound(self, material_id: str, location_name: str, quantity: int) -> dict:
|
def material_outbound(self, material_id: str, location_name: str, quantity: int) -> dict:
|
||||||
"""指定库位出库物料(通过库位名称)"""
|
"""指定库位出库物料(通过库位名称)"""
|
||||||
location_id = LOCATION_MAPPING.get(location_name, location_name)
|
location_id = self.location_mapping.get(location_name, location_name)
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
"materialId": material_id,
|
"materialId": material_id,
|
||||||
@@ -1103,6 +1152,10 @@ class BioyondV1RPC(BaseRequest):
|
|||||||
for detail_material in detail_materials:
|
for detail_material in detail_materials:
|
||||||
detail_name = detail_material.get("name")
|
detail_name = detail_material.get("name")
|
||||||
detail_id = detail_material.get("detailMaterialId")
|
detail_id = detail_material.get("detailMaterialId")
|
||||||
|
if not detail_id:
|
||||||
|
# 尝试其他可能的字段
|
||||||
|
detail_id = detail_material.get("id")
|
||||||
|
|
||||||
if detail_name and detail_id:
|
if detail_name and detail_id:
|
||||||
self.material_cache[detail_name] = detail_id
|
self.material_cache[detail_name] = detail_id
|
||||||
print(f"加载detail材料: {detail_name} -> ID: {detail_id}")
|
print(f"加载detail材料: {detail_name} -> ID: {detail_id}")
|
||||||
@@ -1123,6 +1176,14 @@ class BioyondV1RPC(BaseRequest):
|
|||||||
print(f"从缓存找到材料: {material_name_or_id} -> ID: {material_id}")
|
print(f"从缓存找到材料: {material_name_or_id} -> ID: {material_id}")
|
||||||
return material_id
|
return material_id
|
||||||
|
|
||||||
|
# 如果缓存中没有,尝试刷新缓存
|
||||||
|
print(f"缓存中未找到材料 '{material_name_or_id}',尝试刷新缓存...")
|
||||||
|
self.refresh_material_cache()
|
||||||
|
if material_name_or_id in self.material_cache:
|
||||||
|
material_id = self.material_cache[material_name_or_id]
|
||||||
|
print(f"刷新缓存后找到材料: {material_name_or_id} -> ID: {material_id}")
|
||||||
|
return material_id
|
||||||
|
|
||||||
print(f"警告: 未在缓存中找到材料名称 '{material_name_or_id}',将使用原值")
|
print(f"警告: 未在缓存中找到材料名称 '{material_name_or_id}',将使用原值")
|
||||||
return material_name_or_id
|
return material_name_or_id
|
||||||
|
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
# config.py
|
|
||||||
"""
|
|
||||||
配置文件 - 包含所有配置信息和映射关系
|
|
||||||
"""
|
|
||||||
|
|
||||||
# API配置
|
|
||||||
API_CONFIG = {
|
|
||||||
"api_key": "",
|
|
||||||
"api_host": ""
|
|
||||||
}
|
|
||||||
|
|
||||||
# 工作流映射配置
|
|
||||||
WORKFLOW_MAPPINGS = {
|
|
||||||
"reactor_taken_out": "",
|
|
||||||
"reactor_taken_in": "",
|
|
||||||
"Solid_feeding_vials": "",
|
|
||||||
"Liquid_feeding_vials(non-titration)": "",
|
|
||||||
"Liquid_feeding_solvents": "",
|
|
||||||
"Liquid_feeding(titration)": "",
|
|
||||||
"liquid_feeding_beaker": "",
|
|
||||||
"Drip_back": "",
|
|
||||||
}
|
|
||||||
|
|
||||||
# 工作流名称到DisplaySectionName的映射
|
|
||||||
WORKFLOW_TO_SECTION_MAP = {
|
|
||||||
'reactor_taken_in': '反应器放入',
|
|
||||||
'liquid_feeding_beaker': '液体投料-烧杯',
|
|
||||||
'Liquid_feeding_vials(non-titration)': '液体投料-小瓶(非滴定)',
|
|
||||||
'Liquid_feeding_solvents': '液体投料-溶剂',
|
|
||||||
'Solid_feeding_vials': '固体投料-小瓶',
|
|
||||||
'Liquid_feeding(titration)': '液体投料-滴定',
|
|
||||||
'reactor_taken_out': '反应器取出'
|
|
||||||
}
|
|
||||||
|
|
||||||
# 库位映射配置
|
|
||||||
WAREHOUSE_MAPPING = {
|
|
||||||
"粉末堆栈": {
|
|
||||||
"uuid": "",
|
|
||||||
"site_uuids": {
|
|
||||||
# 样品板
|
|
||||||
"A1": "3a14198e-6929-31f0-8a22-0f98f72260df",
|
|
||||||
"A2": "3a14198e-6929-4379-affa-9a2935c17f99",
|
|
||||||
"A3": "3a14198e-6929-56da-9a1c-7f5fbd4ae8af",
|
|
||||||
"A4": "3a14198e-6929-5e99-2b79-80720f7cfb54",
|
|
||||||
"B1": "3a14198e-6929-f525-9a1b-1857552b28ee",
|
|
||||||
"B2": "3a14198e-6929-bf98-0fd5-26e1d68bf62d",
|
|
||||||
"B3": "3a14198e-6929-2d86-a468-602175a2b5aa",
|
|
||||||
"B4": "3a14198e-6929-1a98-ae57-e97660c489ad",
|
|
||||||
# 分装板
|
|
||||||
"C1": "3a14198e-6929-46fe-841e-03dd753f1e4a",
|
|
||||||
"C2": "3a14198e-6929-1bc9-a9bd-3b7ca66e7f95",
|
|
||||||
"C3": "3a14198e-6929-72ac-32ce-9b50245682b8",
|
|
||||||
"C4": "3a14198e-6929-3bd8-e6c7-4a9fd93be118",
|
|
||||||
"D1": "3a14198e-6929-8a0b-b686-6f4a2955c4e2",
|
|
||||||
"D2": "3a14198e-6929-dde1-fc78-34a84b71afdf",
|
|
||||||
"D3": "3a14198e-6929-a0ec-5f15-c0f9f339f963",
|
|
||||||
"D4": "3a14198e-6929-7ac8-915a-fea51cb2e884"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"溶液堆栈": {
|
|
||||||
"uuid": "",
|
|
||||||
"site_uuids": {
|
|
||||||
"A1": "3a14198e-d724-e036-afdc-2ae39a7f3383",
|
|
||||||
"A2": "3a14198e-d724-afa4-fc82-0ac8a9016791",
|
|
||||||
"A3": "3a14198e-d724-ca48-bb9e-7e85751e55b6",
|
|
||||||
"A4": "3a14198e-d724-df6d-5e32-5483b3cab583",
|
|
||||||
"B1": "3a14198e-d724-d818-6d4f-5725191a24b5",
|
|
||||||
"B2": "3a14198e-d724-be8a-5e0b-012675e195c6",
|
|
||||||
"B3": "3a14198e-d724-cc1e-5c2c-228a130f40a8",
|
|
||||||
"B4": "3a14198e-d724-1e28-c885-574c3df468d0",
|
|
||||||
"C1": "3a14198e-d724-b5bb-adf3-4c5a0da6fb31",
|
|
||||||
"C2": "3a14198e-d724-ab4e-48cb-817c3c146707",
|
|
||||||
"C3": "3a14198e-d724-7f18-1853-39d0c62e1d33",
|
|
||||||
"C4": "3a14198e-d724-28a2-a760-baa896f46b66",
|
|
||||||
"D1": "3a14198e-d724-d378-d266-2508a224a19f",
|
|
||||||
"D2": "3a14198e-d724-f56e-468b-0110a8feb36a",
|
|
||||||
"D3": "3a14198e-d724-0cf1-dea9-a1f40fe7e13c",
|
|
||||||
"D4": "3a14198e-d724-0ddd-9654-f9352a421de9"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"试剂堆栈": {
|
|
||||||
"uuid": "",
|
|
||||||
"site_uuids": {
|
|
||||||
"A1": "3a14198c-c2cf-8b40-af28-b467808f1c36",
|
|
||||||
"A2": "3a14198c-c2d0-f3e7-871a-e470d144296f",
|
|
||||||
"A3": "3a14198c-c2d0-dc7d-b8d0-e1d88cee3094",
|
|
||||||
"A4": "3a14198c-c2d0-2070-efc8-44e245f10c6f",
|
|
||||||
"B1": "3a14198c-c2d0-354f-39ad-642e1a72fcb8",
|
|
||||||
"B2": "3a14198c-c2d0-1559-105d-0ea30682cab4",
|
|
||||||
"B3": "3a14198c-c2d0-725e-523d-34c037ac2440",
|
|
||||||
"B4": "3a14198c-c2d0-efce-0939-69ca5a7dfd39"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# 物料类型配置
|
|
||||||
MATERIAL_TYPE_MAPPINGS = {
|
|
||||||
"烧杯": ("BIOYOND_PolymerStation_1FlaskCarrier", "3a14196b-24f2-ca49-9081-0cab8021bf1a"),
|
|
||||||
"试剂瓶": ("BIOYOND_PolymerStation_1BottleCarrier", ""),
|
|
||||||
"样品板": ("BIOYOND_PolymerStation_6StockCarrier", "3a14196e-b7a0-a5da-1931-35f3000281e9"),
|
|
||||||
"分装板": ("BIOYOND_PolymerStation_6VialCarrier", "3a14196e-5dfe-6e21-0c79-fe2036d052c4"),
|
|
||||||
"样品瓶": ("BIOYOND_PolymerStation_Solid_Stock", "3a14196a-cf7d-8aea-48d8-b9662c7dba94"),
|
|
||||||
"90%分装小瓶": ("BIOYOND_PolymerStation_Solid_Vial", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea"),
|
|
||||||
"10%分装小瓶": ("BIOYOND_PolymerStation_Liquid_Vial", "3a14196c-76be-2279-4e22-7310d69aed68"),
|
|
||||||
}
|
|
||||||
|
|
||||||
# 步骤参数配置(各工作流的步骤UUID)
|
|
||||||
WORKFLOW_STEP_IDS = {
|
|
||||||
"reactor_taken_in": {
|
|
||||||
"config": ""
|
|
||||||
},
|
|
||||||
"liquid_feeding_beaker": {
|
|
||||||
"liquid": "",
|
|
||||||
"observe": ""
|
|
||||||
},
|
|
||||||
"liquid_feeding_vials_non_titration": {
|
|
||||||
"liquid": "",
|
|
||||||
"observe": ""
|
|
||||||
},
|
|
||||||
"liquid_feeding_solvents": {
|
|
||||||
"liquid": "",
|
|
||||||
"observe": ""
|
|
||||||
},
|
|
||||||
"solid_feeding_vials": {
|
|
||||||
"feeding": "",
|
|
||||||
"observe": ""
|
|
||||||
},
|
|
||||||
"liquid_feeding_titration": {
|
|
||||||
"liquid": "",
|
|
||||||
"observe": ""
|
|
||||||
},
|
|
||||||
"drip_back": {
|
|
||||||
"liquid": "",
|
|
||||||
"observe": ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LOCATION_MAPPING = {}
|
|
||||||
|
|
||||||
ACTION_NAMES = {}
|
|
||||||
|
|
||||||
HTTP_SERVICE_CONFIG = {}
|
|
||||||
329
unilabos/devices/workstation/bioyond_studio/config.py.deprecated
Normal file
329
unilabos/devices/workstation/bioyond_studio/config.py.deprecated
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
# config.py
|
||||||
|
"""
|
||||||
|
Bioyond工作站配置文件
|
||||||
|
包含API配置、工作流映射、物料类型映射、仓库库位映射等所有配置信息
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unilabos.resources.bioyond.decks import BIOYOND_PolymerReactionStation_Deck
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 基础配置
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# API配置
|
||||||
|
API_CONFIG = {
|
||||||
|
"api_key": "DE9BDDA0",
|
||||||
|
"api_host": "http://192.168.1.200:44402"
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTP 报送服务配置
|
||||||
|
HTTP_SERVICE_CONFIG = {
|
||||||
|
"http_service_host": "127.0.0.1", # 监听地址
|
||||||
|
"http_service_port": 8080, # 监听端口
|
||||||
|
}
|
||||||
|
|
||||||
|
# Deck配置 - 反应站工作台配置
|
||||||
|
DECK_CONFIG = BIOYOND_PolymerReactionStation_Deck(setup=True)
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 工作流配置
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 工作流ID映射
|
||||||
|
WORKFLOW_MAPPINGS = {
|
||||||
|
"reactor_taken_out": "3a16081e-4788-ca37-eff4-ceed8d7019d1",
|
||||||
|
"reactor_taken_in": "3a160df6-76b3-0957-9eb0-cb496d5721c6",
|
||||||
|
"Solid_feeding_vials": "3a160877-87e7-7699-7bc6-ec72b05eb5e6",
|
||||||
|
"Liquid_feeding_vials(non-titration)": "3a167d99-6158-c6f0-15b5-eb030f7d8e47",
|
||||||
|
"Liquid_feeding_solvents": "3a160824-0665-01ed-285a-51ef817a9046",
|
||||||
|
"Liquid_feeding(titration)": "3a16082a-96ac-0449-446a-4ed39f3365b6",
|
||||||
|
"liquid_feeding_beaker": "3a16087e-124f-8ddb-8ec1-c2dff09ca784",
|
||||||
|
"Drip_back": "3a162cf9-6aac-565a-ddd7-682ba1796a4a",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 工作流名称到显示名称的映射
|
||||||
|
WORKFLOW_TO_SECTION_MAP = {
|
||||||
|
'reactor_taken_in': '反应器放入',
|
||||||
|
'reactor_taken_out': '反应器取出',
|
||||||
|
'Solid_feeding_vials': '固体投料-小瓶',
|
||||||
|
'Liquid_feeding_vials(non-titration)': '液体投料-小瓶(非滴定)',
|
||||||
|
'Liquid_feeding_solvents': '液体投料-溶剂',
|
||||||
|
'Liquid_feeding(titration)': '液体投料-滴定',
|
||||||
|
'liquid_feeding_beaker': '液体投料-烧杯',
|
||||||
|
'Drip_back': '液体回滴'
|
||||||
|
}
|
||||||
|
|
||||||
|
# 工作流步骤ID配置
|
||||||
|
WORKFLOW_STEP_IDS = {
|
||||||
|
"reactor_taken_in": {
|
||||||
|
"config": "60a06f85-c5b3-29eb-180f-4f62dd7e2154"
|
||||||
|
},
|
||||||
|
"liquid_feeding_beaker": {
|
||||||
|
"liquid": "6808cda7-fee7-4092-97f0-5f9c2ffa60e3",
|
||||||
|
"observe": "1753c0de-dffc-4ee6-8458-805a2e227362"
|
||||||
|
},
|
||||||
|
"liquid_feeding_vials_non_titration": {
|
||||||
|
"liquid": "62ea6e95-3d5d-43db-bc1e-9a1802673861",
|
||||||
|
"observe": "3a167d99-6172-b67b-5f22-a7892197142e"
|
||||||
|
},
|
||||||
|
"liquid_feeding_solvents": {
|
||||||
|
"liquid": "1fcea355-2545-462b-b727-350b69a313bf",
|
||||||
|
"observe": "0553dfb3-9ac5-4ace-8e00-2f11029919a8"
|
||||||
|
},
|
||||||
|
"solid_feeding_vials": {
|
||||||
|
"feeding": "f7ae7448-4f20-4c1d-8096-df6fbadd787a",
|
||||||
|
"observe": "263c7ed5-7277-426b-bdff-d6fbf77bcc05"
|
||||||
|
},
|
||||||
|
"liquid_feeding_titration": {
|
||||||
|
"liquid": "a00ec41b-e666-4422-9c20-bfcd3cd15c54",
|
||||||
|
"observe": "ac738ff6-4c58-4155-87b1-d6f65a2c9ab5"
|
||||||
|
},
|
||||||
|
"drip_back": {
|
||||||
|
"liquid": "371be86a-ab77-4769-83e5-54580547c48a",
|
||||||
|
"observe": "ce024b9d-bd20-47b8-9f78-ca5ce7f44cf1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 工作流动作名称配置
|
||||||
|
ACTION_NAMES = {
|
||||||
|
"reactor_taken_in": {
|
||||||
|
"config": "通量-配置",
|
||||||
|
"stirring": "反应模块-开始搅拌"
|
||||||
|
},
|
||||||
|
"solid_feeding_vials": {
|
||||||
|
"feeding": "粉末加样模块-投料",
|
||||||
|
"observe": "反应模块-观察搅拌结果"
|
||||||
|
},
|
||||||
|
"liquid_feeding_vials_non_titration": {
|
||||||
|
"liquid": "稀释液瓶加液位-液体投料",
|
||||||
|
"observe": "反应模块-滴定结果观察"
|
||||||
|
},
|
||||||
|
"liquid_feeding_solvents": {
|
||||||
|
"liquid": "试剂AB放置位-试剂吸液分液",
|
||||||
|
"observe": "反应模块-观察搅拌结果"
|
||||||
|
},
|
||||||
|
"liquid_feeding_titration": {
|
||||||
|
"liquid": "稀释液瓶加液位-稀释液吸液分液",
|
||||||
|
"observe": "反应模块-滴定结果观察"
|
||||||
|
},
|
||||||
|
"liquid_feeding_beaker": {
|
||||||
|
"liquid": "烧杯溶液放置位-烧杯吸液分液",
|
||||||
|
"observe": "反应模块-观察搅拌结果"
|
||||||
|
},
|
||||||
|
"drip_back": {
|
||||||
|
"liquid": "试剂AB放置位-试剂吸液分液",
|
||||||
|
"observe": "反应模块-向下滴定结果观察"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 仓库配置
|
||||||
|
# ============================================================================
|
||||||
|
# 说明:
|
||||||
|
# - 出库和入库操作都需要UUID
|
||||||
|
WAREHOUSE_MAPPING = {
|
||||||
|
# ========== 反应站仓库 ==========
|
||||||
|
|
||||||
|
# 堆栈1左 - 反应站左侧堆栈 (4行×4列=16个库位, A01~D04)
|
||||||
|
"堆栈1左": {
|
||||||
|
"uuid": "3a14aa17-0d49-dce4-486e-4b5c85c8b366",
|
||||||
|
"site_uuids": {
|
||||||
|
"A01": "3a14aa17-0d49-11d7-a6e1-f236b3e5e5a3",
|
||||||
|
"A02": "3a14aa17-0d49-4bc5-8836-517b75473f5f",
|
||||||
|
"A03": "3a14aa17-0d49-c2bc-6222-5cee8d2d94f8",
|
||||||
|
"A04": "3a14aa17-0d49-3ce2-8e9a-008c38d116fb",
|
||||||
|
"B01": "3a14aa17-0d49-f49c-6b66-b27f185a3b32",
|
||||||
|
"B02": "3a14aa17-0d49-cf46-df85-a979c9c9920c",
|
||||||
|
"B03": "3a14aa17-0d49-7698-4a23-f7ffb7d48ba3",
|
||||||
|
"B04": "3a14aa17-0d49-1231-99be-d5870e6478e9",
|
||||||
|
"C01": "3a14aa17-0d49-be34-6fae-4aed9d48b70b",
|
||||||
|
"C02": "3a14aa17-0d49-11d7-0897-34921dcf6b7c",
|
||||||
|
"C03": "3a14aa17-0d49-9840-0bd5-9c63c1bb2c29",
|
||||||
|
"C04": "3a14aa17-0d49-8335-3bff-01da69ea4911",
|
||||||
|
"D01": "3a14aa17-0d49-2bea-c8e5-2b32094935d5",
|
||||||
|
"D02": "3a14aa17-0d49-cff4-e9e8-5f5f0bc1ef32",
|
||||||
|
"D03": "3a14aa17-0d49-4948-cb0a-78f30d1ca9b8",
|
||||||
|
"D04": "3a14aa17-0d49-fd2f-9dfb-a29b11e84099",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
# 堆栈1右 - 反应站右侧堆栈 (4行×4列=16个库位, A05~D08)
|
||||||
|
"堆栈1右": {
|
||||||
|
"uuid": "3a14aa17-0d49-dce4-486e-4b5c85c8b366",
|
||||||
|
"site_uuids": {
|
||||||
|
"A05": "3a14aa17-0d49-2c61-edc8-72a8ca7192dd",
|
||||||
|
"A06": "3a14aa17-0d49-60c8-2b00-40b17198f397",
|
||||||
|
"A07": "3a14aa17-0d49-ec5b-0b75-634dce8eed25",
|
||||||
|
"A08": "3a14aa17-0d49-3ec9-55b3-f3189c4ec53d",
|
||||||
|
"B05": "3a14aa17-0d49-6a4e-abcf-4c113eaaeaad",
|
||||||
|
"B06": "3a14aa17-0d49-e3f6-2dd6-28c2e8194fbe",
|
||||||
|
"B07": "3a14aa17-0d49-11a6-b861-ee895121bf52",
|
||||||
|
"B08": "3a14aa17-0d49-9c7d-1145-d554a6e482f0",
|
||||||
|
"C05": "3a14aa17-0d49-45c4-7a34-5105bc3e2368",
|
||||||
|
"C06": "3a14aa17-0d49-867e-39ab-31b3fe9014be",
|
||||||
|
"C07": "3a14aa17-0d49-ec56-c4b4-39fd9b2131e7",
|
||||||
|
"C08": "3a14aa17-0d49-1128-d7d9-ffb1231c98c0",
|
||||||
|
"D05": "3a14aa17-0d49-e843-f961-ea173326a14b",
|
||||||
|
"D06": "3a14aa17-0d49-4d26-a985-f188359c4f8b",
|
||||||
|
"D07": "3a14aa17-0d49-223a-b520-bc092bb42fe0",
|
||||||
|
"D08": "3a14aa17-0d49-4fa3-401a-6a444e1cca22",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
# 站内试剂存放堆栈
|
||||||
|
"站内试剂存放堆栈": {
|
||||||
|
"uuid": "3a14aa3b-9fab-9d8e-d1a7-828f01f51f0c",
|
||||||
|
"site_uuids": {
|
||||||
|
"A01": "3a14aa3b-9fab-adac-7b9c-e1ee446b51d5",
|
||||||
|
"A02": "3a14aa3b-9fab-ca72-febc-b7c304476c78"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
# 测量小瓶仓库(测密度)
|
||||||
|
"测量小瓶仓库": {
|
||||||
|
"uuid": "3a15012f-705b-c0de-3f9e-950c205f9921",
|
||||||
|
"site_uuids": {
|
||||||
|
"A01": "3a15012f-705e-0524-3161-c523b5aebc97",
|
||||||
|
"A02": "3a15012f-705e-7cd1-32ab-ad4fd1ab75c8",
|
||||||
|
"A03": "3a15012f-705e-a5d6-edac-bdbfec236260",
|
||||||
|
"B01": "3a15012f-705e-e0ee-80e0-10a6b3fc500d",
|
||||||
|
"B02": "3a15012f-705e-e499-180d-de06d60d0b21",
|
||||||
|
"B03": "3a15012f-705e-eff6-63f1-09f742096b26"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
# 站内Tip盒堆栈 - 用于存放枪头盒 (耗材)
|
||||||
|
"站内Tip盒堆栈": {
|
||||||
|
"uuid": "3a14aa3a-2d3c-b5c1-9ddf-7c4a957d459a",
|
||||||
|
"site_uuids": {
|
||||||
|
"A01": "3a14aa3a-2d3d-e700-411a-0ddf85e1f18a",
|
||||||
|
"A02": "3a14aa3a-2d3d-a7ce-099a-d5632fdafa24",
|
||||||
|
"A03": "3a14aa3a-2d3d-bdf6-a702-c60b38b08501",
|
||||||
|
"B01": "3a14aa3a-2d3d-d704-f076-2a8d5bc72cb8",
|
||||||
|
"B02": "3a14aa3a-2d3d-c350-2526-0778d173a5ac",
|
||||||
|
"B03": "3a14aa3a-2d3d-bc38-b356-f0de2e44e0c7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
# ========== 配液站仓库 ==========
|
||||||
|
"粉末堆栈": {
|
||||||
|
"uuid": "3a14198e-6928-121f-7ca6-88ad3ae7e6a0",
|
||||||
|
"site_uuids": {
|
||||||
|
"A01": "3a14198e-6929-31f0-8a22-0f98f72260df",
|
||||||
|
"A02": "3a14198e-6929-4379-affa-9a2935c17f99",
|
||||||
|
"A03": "3a14198e-6929-56da-9a1c-7f5fbd4ae8af",
|
||||||
|
"A04": "3a14198e-6929-5e99-2b79-80720f7cfb54",
|
||||||
|
"B01": "3a14198e-6929-f525-9a1b-1857552b28ee",
|
||||||
|
"B02": "3a14198e-6929-bf98-0fd5-26e1d68bf62d",
|
||||||
|
"B03": "3a14198e-6929-2d86-a468-602175a2b5aa",
|
||||||
|
"B04": "3a14198e-6929-1a98-ae57-e97660c489ad",
|
||||||
|
"C01": "3a14198e-6929-46fe-841e-03dd753f1e4a",
|
||||||
|
"C02": "3a14198e-6929-72ac-32ce-9b50245682b8",
|
||||||
|
"C03": "3a14198e-6929-8a0b-b686-6f4a2955c4e2",
|
||||||
|
"C04": "3a14198e-6929-a0ec-5f15-c0f9f339f963",
|
||||||
|
"D01": "3a14198e-6929-1bc9-a9bd-3b7ca66e7f95",
|
||||||
|
"D02": "3a14198e-6929-3bd8-e6c7-4a9fd93be118",
|
||||||
|
"D03": "3a14198e-6929-dde1-fc78-34a84b71afdf",
|
||||||
|
"D04": "3a14198e-6929-7ac8-915a-fea51cb2e884"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"溶液堆栈": {
|
||||||
|
"uuid": "3a14198e-d723-2c13-7d12-50143e190a23",
|
||||||
|
"site_uuids": {
|
||||||
|
"A01": "3a14198e-d724-e036-afdc-2ae39a7f3383",
|
||||||
|
"A02": "3a14198e-d724-d818-6d4f-5725191a24b5",
|
||||||
|
"A03": "3a14198e-d724-b5bb-adf3-4c5a0da6fb31",
|
||||||
|
"A04": "3a14198e-d724-d378-d266-2508a224a19f",
|
||||||
|
"B01": "3a14198e-d724-afa4-fc82-0ac8a9016791",
|
||||||
|
"B02": "3a14198e-d724-be8a-5e0b-012675e195c6",
|
||||||
|
"B03": "3a14198e-d724-ab4e-48cb-817c3c146707",
|
||||||
|
"B04": "3a14198e-d724-f56e-468b-0110a8feb36a",
|
||||||
|
"C01": "3a14198e-d724-ca48-bb9e-7e85751e55b6",
|
||||||
|
"C02": "3a14198e-d724-cc1e-5c2c-228a130f40a8",
|
||||||
|
"C03": "3a14198e-d724-7f18-1853-39d0c62e1d33",
|
||||||
|
"C04": "3a14198e-d724-0cf1-dea9-a1f40fe7e13c",
|
||||||
|
"D01": "3a14198e-d724-df6d-5e32-5483b3cab583",
|
||||||
|
"D02": "3a14198e-d724-1e28-c885-574c3df468d0",
|
||||||
|
"D03": "3a14198e-d724-28a2-a760-baa896f46b66",
|
||||||
|
"D04": "3a14198e-d724-0ddd-9654-f9352a421de9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"试剂堆栈": {
|
||||||
|
"uuid": "3a14198c-c2cc-0290-e086-44a428fba248",
|
||||||
|
"site_uuids": {
|
||||||
|
"A01": "3a14198c-c2cf-8b40-af28-b467808f1c36", # x=1, y=1, code=0001-0001
|
||||||
|
"A02": "3a14198c-c2d0-dc7d-b8d0-e1d88cee3094", # x=1, y=2, code=0001-0002
|
||||||
|
"A03": "3a14198c-c2d0-354f-39ad-642e1a72fcb8", # x=1, y=3, code=0001-0003
|
||||||
|
"A04": "3a14198c-c2d0-725e-523d-34c037ac2440", # x=1, y=4, code=0001-0004
|
||||||
|
"B01": "3a14198c-c2d0-f3e7-871a-e470d144296f", # x=2, y=1, code=0001-0005
|
||||||
|
"B02": "3a14198c-c2d0-2070-efc8-44e245f10c6f", # x=2, y=2, code=0001-0006
|
||||||
|
"B03": "3a14198c-c2d0-1559-105d-0ea30682cab4", # x=2, y=3, code=0001-0007
|
||||||
|
"B04": "3a14198c-c2d0-efce-0939-69ca5a7dfd39" # x=2, y=4, code=0001-0008
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 物料类型配置
|
||||||
|
# ============================================================================
|
||||||
|
# 说明:
|
||||||
|
# - 格式: PyLabRobot资源类型名称 → Bioyond系统typeId的UUID
|
||||||
|
# - 这个映射基于 resource.model 属性 (不是显示名称!)
|
||||||
|
# - UUID为空表示该类型暂未在Bioyond系统中定义
|
||||||
|
MATERIAL_TYPE_MAPPINGS = {
|
||||||
|
# ================================================配液站资源============================================================
|
||||||
|
# ==================================================样品===============================================================
|
||||||
|
"BIOYOND_PolymerStation_1FlaskCarrier": ("烧杯", "3a14196b-24f2-ca49-9081-0cab8021bf1a"), # 配液站-样品-烧杯
|
||||||
|
"BIOYOND_PolymerStation_1BottleCarrier": ("试剂瓶", "3a14196b-8bcf-a460-4f74-23f21ca79e72"), # 配液站-样品-试剂瓶
|
||||||
|
"BIOYOND_PolymerStation_6StockCarrier": ("分装板", "3a14196e-5dfe-6e21-0c79-fe2036d052c4"), # 配液站-样品-分装板
|
||||||
|
"BIOYOND_PolymerStation_Liquid_Vial": ("10%分装小瓶", "3a14196c-76be-2279-4e22-7310d69aed68"), # 配液站-样品-分装板-第一排小瓶
|
||||||
|
"BIOYOND_PolymerStation_Solid_Vial": ("90%分装小瓶", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea"), # 配液站-样品-分装板-第二排小瓶
|
||||||
|
# ==================================================试剂===============================================================
|
||||||
|
"BIOYOND_PolymerStation_8StockCarrier": ("样品板", "3a14196e-b7a0-a5da-1931-35f3000281e9"), # 配液站-试剂-样品板(8孔)
|
||||||
|
"BIOYOND_PolymerStation_Solid_Stock": ("样品瓶", "3a14196a-cf7d-8aea-48d8-b9662c7dba94"), # 配液站-试剂-样品板-样品瓶
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 动态生成的库位UUID映射(从WAREHOUSE_MAPPING中提取)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
LOCATION_MAPPING = {}
|
||||||
|
for warehouse_name, warehouse_config in WAREHOUSE_MAPPING.items():
|
||||||
|
if "site_uuids" in warehouse_config:
|
||||||
|
LOCATION_MAPPING.update(warehouse_config["site_uuids"])
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 物料默认参数配置
|
||||||
|
# ============================================================================
|
||||||
|
# 说明:
|
||||||
|
# - 为特定物料名称自动添加默认参数(如密度、分子量、单位等)
|
||||||
|
# - 格式: 物料名称 → {参数字典}
|
||||||
|
# - 在创建或更新物料时,会自动合并这些参数到 Parameters 字段
|
||||||
|
# - unit: 物料的计量单位(会用于 unit 字段)
|
||||||
|
# - density/densityUnit: 密度信息(会添加到 Parameters 中)
|
||||||
|
|
||||||
|
MATERIAL_DEFAULT_PARAMETERS = {
|
||||||
|
# 溶剂类
|
||||||
|
"NMP": {
|
||||||
|
"unit": "毫升",
|
||||||
|
"density": "1.03",
|
||||||
|
"densityUnit": "g/mL",
|
||||||
|
"description": "N-甲基吡咯烷酮 (N-Methyl-2-pyrrolidone)"
|
||||||
|
},
|
||||||
|
# 可以继续添加其他物料...
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 物料类型默认参数配置
|
||||||
|
# ============================================================================
|
||||||
|
# 说明:
|
||||||
|
# - 为特定物料类型(UUID)自动添加默认参数
|
||||||
|
# - 格式: Bioyond类型UUID → {参数字典}
|
||||||
|
# - 优先级低于按名称匹配的配置
|
||||||
|
MATERIAL_TYPE_PARAMETERS = {
|
||||||
|
# 示例:
|
||||||
|
# "3a14196b-24f2-ca49-9081-0cab8021bf1a": { # 烧杯
|
||||||
|
# "unit": "个"
|
||||||
|
# }
|
||||||
|
}
|
||||||
@@ -4,7 +4,8 @@ import time
|
|||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List
|
||||||
from typing_extensions import TypedDict
|
from typing_extensions import TypedDict
|
||||||
import requests
|
import requests
|
||||||
from unilabos.devices.workstation.bioyond_studio.config import API_CONFIG
|
import pint
|
||||||
|
|
||||||
|
|
||||||
from unilabos.devices.workstation.bioyond_studio.bioyond_rpc import BioyondException
|
from unilabos.devices.workstation.bioyond_studio.bioyond_rpc import BioyondException
|
||||||
from unilabos.devices.workstation.bioyond_studio.station import BioyondWorkstation
|
from unilabos.devices.workstation.bioyond_studio.station import BioyondWorkstation
|
||||||
@@ -25,13 +26,89 @@ class ComputeExperimentDesignReturn(TypedDict):
|
|||||||
class BioyondDispensingStation(BioyondWorkstation):
|
class BioyondDispensingStation(BioyondWorkstation):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
config,
|
config: dict = None,
|
||||||
# 桌子
|
deck=None,
|
||||||
deck,
|
protocol_type=None,
|
||||||
*args,
|
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
super().__init__(config, deck, *args, **kwargs)
|
"""初始化配液站
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: 配置字典,应包含material_type_mappings等配置
|
||||||
|
deck: Deck对象
|
||||||
|
protocol_type: 协议类型(由ROS系统传递,此处忽略)
|
||||||
|
**kwargs: 其他可能的参数
|
||||||
|
"""
|
||||||
|
if config is None:
|
||||||
|
config = {}
|
||||||
|
|
||||||
|
# 将 kwargs 合并到 config 中 (处理扁平化配置如 api_key)
|
||||||
|
config.update(kwargs)
|
||||||
|
|
||||||
|
if deck is None and config:
|
||||||
|
deck = config.get('deck')
|
||||||
|
|
||||||
|
# 🔧 修复: 确保 Deck 上的 warehouses 具有正确的 UUID (必须在 super().__init__ 之前执行,因为父类会触发同步)
|
||||||
|
# 从配置中读取 warehouse_mapping,并应用到实际的 deck 资源上
|
||||||
|
if config and "warehouse_mapping" in config and deck:
|
||||||
|
warehouse_mapping = config["warehouse_mapping"]
|
||||||
|
print(f"正在根据配置更新 Deck warehouse UUIDs... (共有 {len(warehouse_mapping)} 个配置)")
|
||||||
|
|
||||||
|
user_deck = deck
|
||||||
|
# 初始化 warehouses 字典
|
||||||
|
if not hasattr(user_deck, "warehouses") or user_deck.warehouses is None:
|
||||||
|
user_deck.warehouses = {}
|
||||||
|
|
||||||
|
# 1. 尝试从 children 中查找匹配的资源
|
||||||
|
for child in user_deck.children:
|
||||||
|
# 简单判断: 如果名字在 mapping 中,就认为是 warehouse
|
||||||
|
if child.name in warehouse_mapping:
|
||||||
|
user_deck.warehouses[child.name] = child
|
||||||
|
print(f" - 从子资源中找到 warehouse: {child.name}")
|
||||||
|
|
||||||
|
# 2. 如果还是没找到,且 Deck 类有 setup 方法,尝试调用 setup (针对 Deck 对象正确但未初始化的情况)
|
||||||
|
if not user_deck.warehouses and hasattr(user_deck, "setup"):
|
||||||
|
print(" - 尝试调用 deck.setup() 初始化仓库...")
|
||||||
|
try:
|
||||||
|
user_deck.setup()
|
||||||
|
# setup 后重新检查
|
||||||
|
if hasattr(user_deck, "warehouses") and user_deck.warehouses:
|
||||||
|
print(f" - setup() 成功,找到 {len(user_deck.warehouses)} 个仓库")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" - 调用 setup() 失败: {e}")
|
||||||
|
|
||||||
|
# 3. 如果仍然为空,可能需要手动创建 (仅针对特定已知的 Deck 类型进行补救,这里暂时只打印警告)
|
||||||
|
if not user_deck.warehouses:
|
||||||
|
print(" - ⚠️ 仍然无法找到任何 warehouse 资源!")
|
||||||
|
|
||||||
|
for wh_name, wh_config in warehouse_mapping.items():
|
||||||
|
target_uuid = wh_config.get("uuid")
|
||||||
|
|
||||||
|
# 尝试在 deck.warehouses 中查找
|
||||||
|
wh_resource = None
|
||||||
|
if hasattr(user_deck, "warehouses") and wh_name in user_deck.warehouses:
|
||||||
|
wh_resource = user_deck.warehouses[wh_name]
|
||||||
|
|
||||||
|
# 如果没找到,尝试在所有子资源中查找
|
||||||
|
if not wh_resource:
|
||||||
|
wh_resource = user_deck.get_resource(wh_name)
|
||||||
|
|
||||||
|
if wh_resource:
|
||||||
|
if target_uuid:
|
||||||
|
current_uuid = getattr(wh_resource, "uuid", None)
|
||||||
|
print(f"✅ 更新仓库 '{wh_name}' UUID: {current_uuid} -> {target_uuid}")
|
||||||
|
|
||||||
|
# 动态添加 uuid 属性
|
||||||
|
wh_resource.uuid = target_uuid
|
||||||
|
# 同时也确保 category 正确,避免 graphio 识别错误
|
||||||
|
# wh_resource.category = "warehouse"
|
||||||
|
else:
|
||||||
|
print(f"⚠️ 仓库 '{wh_name}' 在配置中没有 UUID")
|
||||||
|
else:
|
||||||
|
print(f"❌ 在 Deck 中未找到配置的仓库: '{wh_name}'")
|
||||||
|
|
||||||
|
super().__init__(bioyond_config=config, deck=deck)
|
||||||
|
|
||||||
# self.config = config
|
# self.config = config
|
||||||
# self.api_key = config["api_key"]
|
# self.api_key = config["api_key"]
|
||||||
# self.host = config["api_host"]
|
# self.host = config["api_host"]
|
||||||
@@ -43,6 +120,41 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
# 用于跟踪任务完成状态的字典: {orderCode: {status, order_id, timestamp}}
|
# 用于跟踪任务完成状态的字典: {orderCode: {status, order_id, timestamp}}
|
||||||
self.order_completion_status = {}
|
self.order_completion_status = {}
|
||||||
|
|
||||||
|
# 初始化 pint 单位注册表
|
||||||
|
self.ureg = pint.UnitRegistry()
|
||||||
|
|
||||||
|
# 化合物信息
|
||||||
|
self.compound_info = {
|
||||||
|
"MolWt": {
|
||||||
|
"MDA": 108.14 * self.ureg.g / self.ureg.mol,
|
||||||
|
"TDA": 122.16 * self.ureg.g / self.ureg.mol,
|
||||||
|
"PAPP": 521.62 * self.ureg.g / self.ureg.mol,
|
||||||
|
"BTDA": 322.23 * self.ureg.g / self.ureg.mol,
|
||||||
|
"BPDA": 294.22 * self.ureg.g / self.ureg.mol,
|
||||||
|
"6FAP": 366.26 * self.ureg.g / self.ureg.mol,
|
||||||
|
"PMDA": 218.12 * self.ureg.g / self.ureg.mol,
|
||||||
|
"MPDA": 108.14 * self.ureg.g / self.ureg.mol,
|
||||||
|
"SIDA": 248.51 * self.ureg.g / self.ureg.mol,
|
||||||
|
"ODA": 200.236 * self.ureg.g / self.ureg.mol,
|
||||||
|
"4,4'-ODA": 200.236 * self.ureg.g / self.ureg.mol,
|
||||||
|
"134": 292.34 * self.ureg.g / self.ureg.mol,
|
||||||
|
},
|
||||||
|
"FuncGroup": {
|
||||||
|
"MDA": "Amine",
|
||||||
|
"TDA": "Amine",
|
||||||
|
"PAPP": "Amine",
|
||||||
|
"BTDA": "Anhydride",
|
||||||
|
"BPDA": "Anhydride",
|
||||||
|
"6FAP": "Amine",
|
||||||
|
"MPDA": "Amine",
|
||||||
|
"SIDA": "Amine",
|
||||||
|
"PMDA": "Anhydride",
|
||||||
|
"ODA": "Amine",
|
||||||
|
"4,4'-ODA": "Amine",
|
||||||
|
"134": "Amine",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
def _post_project_api(self, endpoint: str, data: Any) -> Dict[str, Any]:
|
def _post_project_api(self, endpoint: str, data: Any) -> Dict[str, Any]:
|
||||||
"""项目接口通用POST调用
|
"""项目接口通用POST调用
|
||||||
|
|
||||||
@@ -54,7 +166,7 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
dict: 服务端响应,失败时返回 {code:0,message,...}
|
dict: 服务端响应,失败时返回 {code:0,message,...}
|
||||||
"""
|
"""
|
||||||
request_data = {
|
request_data = {
|
||||||
"apiKey": API_CONFIG["api_key"],
|
"apiKey": self.bioyond_config["api_key"],
|
||||||
"requestTime": self.hardware_interface.get_current_time_iso8601(),
|
"requestTime": self.hardware_interface.get_current_time_iso8601(),
|
||||||
"data": data
|
"data": data
|
||||||
}
|
}
|
||||||
@@ -85,7 +197,7 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
dict: 服务端响应,失败时返回 {code:0,message,...}
|
dict: 服务端响应,失败时返回 {code:0,message,...}
|
||||||
"""
|
"""
|
||||||
request_data = {
|
request_data = {
|
||||||
"apiKey": API_CONFIG["api_key"],
|
"apiKey": self.bioyond_config["api_key"],
|
||||||
"requestTime": self.hardware_interface.get_current_time_iso8601(),
|
"requestTime": self.hardware_interface.get_current_time_iso8601(),
|
||||||
"data": data
|
"data": data
|
||||||
}
|
}
|
||||||
@@ -118,20 +230,22 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
ratio = json.loads(ratio)
|
ratio = json.loads(ratio)
|
||||||
except Exception:
|
except Exception:
|
||||||
ratio = {}
|
ratio = {}
|
||||||
root = str(Path(__file__).resolve().parents[3])
|
|
||||||
if root not in sys.path:
|
|
||||||
sys.path.append(root)
|
|
||||||
try:
|
|
||||||
mod = importlib.import_module("tem.compute")
|
|
||||||
except Exception as e:
|
|
||||||
raise BioyondException(f"无法导入计算模块: {e}")
|
|
||||||
try:
|
try:
|
||||||
wp = float(wt_percent) if isinstance(wt_percent, str) else wt_percent
|
wp = float(wt_percent) if isinstance(wt_percent, str) else wt_percent
|
||||||
mt = float(m_tot) if isinstance(m_tot, str) else m_tot
|
mt = float(m_tot) if isinstance(m_tot, str) else m_tot
|
||||||
tp = float(titration_percent) if isinstance(titration_percent, str) else titration_percent
|
tp = float(titration_percent) if isinstance(titration_percent, str) else titration_percent
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise BioyondException(f"参数解析失败: {e}")
|
raise BioyondException(f"参数解析失败: {e}")
|
||||||
res = mod.generate_experiment_design(ratio=ratio, wt_percent=wp, m_tot=mt, titration_percent=tp)
|
|
||||||
|
# 2. 调用内部计算方法
|
||||||
|
res = self._generate_experiment_design(
|
||||||
|
ratio=ratio,
|
||||||
|
wt_percent=wp,
|
||||||
|
m_tot=mt,
|
||||||
|
titration_percent=tp
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. 构造返回结果
|
||||||
out = {
|
out = {
|
||||||
"solutions": res.get("solutions", []),
|
"solutions": res.get("solutions", []),
|
||||||
"titration": res.get("titration", {}),
|
"titration": res.get("titration", {}),
|
||||||
@@ -140,11 +254,248 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
"return_info": json.dumps(res, ensure_ascii=False)
|
"return_info": json.dumps(res, ensure_ascii=False)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|
||||||
except BioyondException:
|
except BioyondException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise BioyondException(str(e))
|
raise BioyondException(str(e))
|
||||||
|
|
||||||
|
def _generate_experiment_design(
|
||||||
|
self,
|
||||||
|
ratio: dict,
|
||||||
|
wt_percent: float = 0.25,
|
||||||
|
m_tot: float = 70,
|
||||||
|
titration_percent: float = 0.03,
|
||||||
|
) -> dict:
|
||||||
|
"""内部方法:生成实验设计
|
||||||
|
|
||||||
|
根据FuncGroup自动区分二胺和二酐,每种二胺单独配溶液,严格按照ratio顺序投料。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
ratio: 化合物配比字典,格式: {"compound_name": ratio_value}
|
||||||
|
wt_percent: 固体重量百分比
|
||||||
|
m_tot: 反应混合物总质量(g)
|
||||||
|
titration_percent: 滴定溶液百分比
|
||||||
|
|
||||||
|
返回:
|
||||||
|
包含实验设计详细参数的字典
|
||||||
|
"""
|
||||||
|
# 溶剂密度
|
||||||
|
ρ_solvent = 1.03 * self.ureg.g / self.ureg.ml
|
||||||
|
# 二酐溶解度
|
||||||
|
solubility = 0.02 * self.ureg.g / self.ureg.ml
|
||||||
|
# 投入固体时最小溶剂体积
|
||||||
|
V_min = 30 * self.ureg.ml
|
||||||
|
m_tot = m_tot * self.ureg.g
|
||||||
|
|
||||||
|
# 保持ratio中的顺序
|
||||||
|
compound_names = list(ratio.keys())
|
||||||
|
compound_ratios = list(ratio.values())
|
||||||
|
|
||||||
|
# 验证所有化合物是否在 compound_info 中定义
|
||||||
|
undefined_compounds = [name for name in compound_names if name not in self.compound_info["MolWt"]]
|
||||||
|
if undefined_compounds:
|
||||||
|
available = list(self.compound_info["MolWt"].keys())
|
||||||
|
raise ValueError(
|
||||||
|
f"以下化合物未在 compound_info 中定义: {undefined_compounds}。"
|
||||||
|
f"可用的化合物: {available}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取各化合物的分子量和官能团类型
|
||||||
|
molecular_weights = [self.compound_info["MolWt"][name] for name in compound_names]
|
||||||
|
func_groups = [self.compound_info["FuncGroup"][name] for name in compound_names]
|
||||||
|
|
||||||
|
# 记录化合物信息用于调试
|
||||||
|
self.hardware_interface._logger.info(f"化合物名称: {compound_names}")
|
||||||
|
self.hardware_interface._logger.info(f"官能团类型: {func_groups}")
|
||||||
|
|
||||||
|
# 按原始顺序分离二胺和二酐
|
||||||
|
ordered_compounds = list(zip(compound_names, compound_ratios, molecular_weights, func_groups))
|
||||||
|
diamine_compounds = [(name, ratio_val, mw, i) for i, (name, ratio_val, mw, fg) in enumerate(ordered_compounds) if fg == "Amine"]
|
||||||
|
anhydride_compounds = [(name, ratio_val, mw, i) for i, (name, ratio_val, mw, fg) in enumerate(ordered_compounds) if fg == "Anhydride"]
|
||||||
|
|
||||||
|
if not diamine_compounds or not anhydride_compounds:
|
||||||
|
raise ValueError(
|
||||||
|
f"需要同时包含二胺(Amine)和二酐(Anhydride)化合物。"
|
||||||
|
f"当前二胺: {[c[0] for c in diamine_compounds]}, "
|
||||||
|
f"当前二酐: {[c[0] for c in anhydride_compounds]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 计算加权平均分子量 (基于摩尔比)
|
||||||
|
total_molar_ratio = sum(compound_ratios)
|
||||||
|
weighted_molecular_weight = sum(ratio_val * mw for ratio_val, mw in zip(compound_ratios, molecular_weights))
|
||||||
|
|
||||||
|
# 取最后一个二酐用于滴定
|
||||||
|
titration_anhydride = anhydride_compounds[-1]
|
||||||
|
solid_anhydrides = anhydride_compounds[:-1] if len(anhydride_compounds) > 1 else []
|
||||||
|
|
||||||
|
# 二胺溶液配制参数 - 每种二胺单独配制
|
||||||
|
diamine_solutions = []
|
||||||
|
total_diamine_volume = 0 * self.ureg.ml
|
||||||
|
|
||||||
|
# 计算反应物的总摩尔量
|
||||||
|
n_reactant = m_tot * wt_percent / weighted_molecular_weight
|
||||||
|
|
||||||
|
for name, ratio_val, mw, order_index in diamine_compounds:
|
||||||
|
# 跳过 SIDA
|
||||||
|
if name == "SIDA":
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 计算该二胺需要的摩尔数
|
||||||
|
n_diamine_needed = n_reactant * ratio_val
|
||||||
|
|
||||||
|
# 二胺溶液配制参数 (每种二胺固定配制参数)
|
||||||
|
m_diamine_solid = 5.0 * self.ureg.g # 每种二胺固体质量
|
||||||
|
V_solvent_for_this = 20 * self.ureg.ml # 每种二胺溶剂体积
|
||||||
|
m_solvent_for_this = ρ_solvent * V_solvent_for_this
|
||||||
|
|
||||||
|
# 计算该二胺溶液的浓度
|
||||||
|
c_diamine = (m_diamine_solid / mw) / V_solvent_for_this
|
||||||
|
|
||||||
|
# 计算需要移取的溶液体积
|
||||||
|
V_diamine_needed = n_diamine_needed / c_diamine
|
||||||
|
|
||||||
|
diamine_solutions.append({
|
||||||
|
"name": name,
|
||||||
|
"order": order_index,
|
||||||
|
"solid_mass": m_diamine_solid.magnitude,
|
||||||
|
"solvent_volume": V_solvent_for_this.magnitude,
|
||||||
|
"concentration": c_diamine.magnitude,
|
||||||
|
"volume_needed": V_diamine_needed.magnitude,
|
||||||
|
"molar_ratio": ratio_val
|
||||||
|
})
|
||||||
|
|
||||||
|
total_diamine_volume += V_diamine_needed
|
||||||
|
|
||||||
|
# 按原始顺序排序
|
||||||
|
diamine_solutions.sort(key=lambda x: x["order"])
|
||||||
|
|
||||||
|
# 计算滴定二酐的质量
|
||||||
|
titration_name, titration_ratio, titration_mw, _ = titration_anhydride
|
||||||
|
m_titration_anhydride = n_reactant * titration_ratio * titration_mw
|
||||||
|
m_titration_90 = m_titration_anhydride * (1 - titration_percent)
|
||||||
|
m_titration_10 = m_titration_anhydride * titration_percent
|
||||||
|
|
||||||
|
# 计算其他固体二酐的质量 (按顺序)
|
||||||
|
solid_anhydride_masses = []
|
||||||
|
for name, ratio_val, mw, order_index in solid_anhydrides:
|
||||||
|
mass = n_reactant * ratio_val * mw
|
||||||
|
solid_anhydride_masses.append({
|
||||||
|
"name": name,
|
||||||
|
"order": order_index,
|
||||||
|
"mass": mass.magnitude,
|
||||||
|
"molar_ratio": ratio_val
|
||||||
|
})
|
||||||
|
|
||||||
|
# 按原始顺序排序
|
||||||
|
solid_anhydride_masses.sort(key=lambda x: x["order"])
|
||||||
|
|
||||||
|
# 计算溶剂用量
|
||||||
|
total_diamine_solution_mass = sum(
|
||||||
|
sol["volume_needed"] * ρ_solvent for sol in diamine_solutions
|
||||||
|
) * self.ureg.ml
|
||||||
|
|
||||||
|
# 预估滴定溶剂量、计算补加溶剂量
|
||||||
|
m_solvent_titration = m_titration_10 / solubility * ρ_solvent
|
||||||
|
m_solvent_add = m_tot * (1 - wt_percent) - total_diamine_solution_mass - m_solvent_titration
|
||||||
|
|
||||||
|
# 检查最小溶剂体积要求
|
||||||
|
total_liquid_volume = (total_diamine_solution_mass + m_solvent_add) / ρ_solvent
|
||||||
|
m_tot_min = V_min / total_liquid_volume * m_tot
|
||||||
|
|
||||||
|
# 如果需要,按比例放大
|
||||||
|
scale_factor = 1.0
|
||||||
|
if m_tot_min > m_tot:
|
||||||
|
scale_factor = (m_tot_min / m_tot).magnitude
|
||||||
|
m_titration_90 *= scale_factor
|
||||||
|
m_titration_10 *= scale_factor
|
||||||
|
m_solvent_add *= scale_factor
|
||||||
|
m_solvent_titration *= scale_factor
|
||||||
|
|
||||||
|
# 更新二胺溶液用量
|
||||||
|
for sol in diamine_solutions:
|
||||||
|
sol["volume_needed"] *= scale_factor
|
||||||
|
|
||||||
|
# 更新固体二酐用量
|
||||||
|
for anhydride in solid_anhydride_masses:
|
||||||
|
anhydride["mass"] *= scale_factor
|
||||||
|
|
||||||
|
m_tot = m_tot_min
|
||||||
|
|
||||||
|
# 生成投料顺序
|
||||||
|
feeding_order = []
|
||||||
|
|
||||||
|
# 1. 固体二酐 (按顺序)
|
||||||
|
for anhydride in solid_anhydride_masses:
|
||||||
|
feeding_order.append({
|
||||||
|
"step": len(feeding_order) + 1,
|
||||||
|
"type": "solid_anhydride",
|
||||||
|
"name": anhydride["name"],
|
||||||
|
"amount": anhydride["mass"],
|
||||||
|
"order": anhydride["order"]
|
||||||
|
})
|
||||||
|
|
||||||
|
# 2. 二胺溶液 (按顺序)
|
||||||
|
for sol in diamine_solutions:
|
||||||
|
feeding_order.append({
|
||||||
|
"step": len(feeding_order) + 1,
|
||||||
|
"type": "diamine_solution",
|
||||||
|
"name": sol["name"],
|
||||||
|
"amount": sol["volume_needed"],
|
||||||
|
"order": sol["order"]
|
||||||
|
})
|
||||||
|
|
||||||
|
# 3. 主要二酐粉末
|
||||||
|
feeding_order.append({
|
||||||
|
"step": len(feeding_order) + 1,
|
||||||
|
"type": "main_anhydride",
|
||||||
|
"name": titration_name,
|
||||||
|
"amount": m_titration_90.magnitude,
|
||||||
|
"order": titration_anhydride[3]
|
||||||
|
})
|
||||||
|
|
||||||
|
# 4. 补加溶剂
|
||||||
|
if m_solvent_add > 0:
|
||||||
|
feeding_order.append({
|
||||||
|
"step": len(feeding_order) + 1,
|
||||||
|
"type": "additional_solvent",
|
||||||
|
"name": "溶剂",
|
||||||
|
"amount": m_solvent_add.magnitude,
|
||||||
|
"order": 999
|
||||||
|
})
|
||||||
|
|
||||||
|
# 5. 滴定二酐溶液
|
||||||
|
feeding_order.append({
|
||||||
|
"step": len(feeding_order) + 1,
|
||||||
|
"type": "titration_anhydride",
|
||||||
|
"name": f"{titration_name} 滴定液",
|
||||||
|
"amount": m_titration_10.magnitude,
|
||||||
|
"titration_solvent": m_solvent_titration.magnitude,
|
||||||
|
"order": titration_anhydride[3]
|
||||||
|
})
|
||||||
|
|
||||||
|
# 返回实验设计结果
|
||||||
|
results = {
|
||||||
|
"total_mass": m_tot.magnitude,
|
||||||
|
"scale_factor": scale_factor,
|
||||||
|
"solutions": diamine_solutions,
|
||||||
|
"solids": solid_anhydride_masses,
|
||||||
|
"titration": {
|
||||||
|
"name": titration_name,
|
||||||
|
"main_portion": m_titration_90.magnitude,
|
||||||
|
"titration_portion": m_titration_10.magnitude,
|
||||||
|
"titration_solvent": m_solvent_titration.magnitude,
|
||||||
|
},
|
||||||
|
"solvents": {
|
||||||
|
"additional_solvent": m_solvent_add.magnitude,
|
||||||
|
"total_liquid_volume": total_liquid_volume.magnitude
|
||||||
|
},
|
||||||
|
"feeding_order": feeding_order,
|
||||||
|
"minimum_required_mass": m_tot_min.magnitude
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
# 90%10%小瓶投料任务创建方法
|
# 90%10%小瓶投料任务创建方法
|
||||||
def create_90_10_vial_feeding_task(self,
|
def create_90_10_vial_feeding_task(self,
|
||||||
order_name: str = None,
|
order_name: str = None,
|
||||||
@@ -961,6 +1312,108 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
'actualVolume': actual_volume
|
'actualVolume': actual_volume
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _simplify_report(self, report) -> Dict[str, Any]:
|
||||||
|
"""简化实验报告,只保留关键信息,去除冗余的工作流参数"""
|
||||||
|
if not isinstance(report, dict):
|
||||||
|
return report
|
||||||
|
|
||||||
|
data = report.get('data', {})
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return report
|
||||||
|
|
||||||
|
# 提取关键信息
|
||||||
|
simplified = {
|
||||||
|
'name': data.get('name'),
|
||||||
|
'code': data.get('code'),
|
||||||
|
'requester': data.get('requester'),
|
||||||
|
'workflowName': data.get('workflowName'),
|
||||||
|
'workflowStep': data.get('workflowStep'),
|
||||||
|
'requestTime': data.get('requestTime'),
|
||||||
|
'startPreparationTime': data.get('startPreparationTime'),
|
||||||
|
'completeTime': data.get('completeTime'),
|
||||||
|
'useTime': data.get('useTime'),
|
||||||
|
'status': data.get('status'),
|
||||||
|
'statusName': data.get('statusName'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 提取物料信息(简化版)
|
||||||
|
pre_intakes = data.get('preIntakes', [])
|
||||||
|
if pre_intakes and isinstance(pre_intakes, list):
|
||||||
|
first_intake = pre_intakes[0]
|
||||||
|
sample_materials = first_intake.get('sampleMaterials', [])
|
||||||
|
|
||||||
|
# 简化物料信息
|
||||||
|
simplified_materials = []
|
||||||
|
for material in sample_materials:
|
||||||
|
if isinstance(material, dict):
|
||||||
|
mat_info = {
|
||||||
|
'materialName': material.get('materialName'),
|
||||||
|
'materialTypeName': material.get('materialTypeName'),
|
||||||
|
'materialCode': material.get('materialCode'),
|
||||||
|
'materialLocation': material.get('materialLocation'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 解析parameters中的关键信息(如密度、加料历史等)
|
||||||
|
params_str = material.get('parameters', '{}')
|
||||||
|
try:
|
||||||
|
params = json.loads(params_str) if isinstance(params_str, str) else params_str
|
||||||
|
if isinstance(params, dict):
|
||||||
|
# 只保留关键参数
|
||||||
|
if 'density' in params:
|
||||||
|
mat_info['density'] = params['density']
|
||||||
|
if 'feedingHistory' in params:
|
||||||
|
mat_info['feedingHistory'] = params['feedingHistory']
|
||||||
|
if 'liquidVolume' in params:
|
||||||
|
mat_info['liquidVolume'] = params['liquidVolume']
|
||||||
|
if 'm_diamine_tot' in params:
|
||||||
|
mat_info['m_diamine_tot'] = params['m_diamine_tot']
|
||||||
|
if 'wt_diamine' in params:
|
||||||
|
mat_info['wt_diamine'] = params['wt_diamine']
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
simplified_materials.append(mat_info)
|
||||||
|
|
||||||
|
simplified['sampleMaterials'] = simplified_materials
|
||||||
|
|
||||||
|
# 提取extraProperties中的实际值
|
||||||
|
extra_props = first_intake.get('extraProperties', {})
|
||||||
|
if isinstance(extra_props, dict):
|
||||||
|
simplified_extra = {}
|
||||||
|
for key, value in extra_props.items():
|
||||||
|
try:
|
||||||
|
parsed_value = json.loads(value) if isinstance(value, str) else value
|
||||||
|
simplified_extra[key] = parsed_value
|
||||||
|
except:
|
||||||
|
simplified_extra[key] = value
|
||||||
|
simplified['extraProperties'] = simplified_extra
|
||||||
|
|
||||||
|
return {
|
||||||
|
'data': simplified,
|
||||||
|
'code': report.get('code'),
|
||||||
|
'message': report.get('message'),
|
||||||
|
'timestamp': report.get('timestamp')
|
||||||
|
}
|
||||||
|
|
||||||
|
def scheduler_start(self) -> dict:
|
||||||
|
"""启动调度器 - 启动Bioyond工作站的任务调度器,开始执行队列中的任务
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: 包含return_info的字典,return_info为整型(1=成功)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
BioyondException: 调度器启动失败时抛出异常
|
||||||
|
"""
|
||||||
|
result = self.hardware_interface.scheduler_start()
|
||||||
|
self.hardware_interface._logger.info(f"调度器启动结果: {result}")
|
||||||
|
|
||||||
|
if result != 1:
|
||||||
|
error_msg = "启动调度器失败: 有未处理错误,调度无法启动。请检查Bioyond系统状态。"
|
||||||
|
self.hardware_interface._logger.error(error_msg)
|
||||||
|
raise BioyondException(error_msg)
|
||||||
|
|
||||||
|
return {"return_info": result}
|
||||||
|
|
||||||
# 等待多个任务完成并获取实验报告
|
# 等待多个任务完成并获取实验报告
|
||||||
def wait_for_multiple_orders_and_get_reports(self,
|
def wait_for_multiple_orders_and_get_reports(self,
|
||||||
batch_create_result: str = None,
|
batch_create_result: str = None,
|
||||||
@@ -1002,7 +1455,12 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
|
|
||||||
# 验证batch_create_result参数
|
# 验证batch_create_result参数
|
||||||
if not batch_create_result or batch_create_result == "":
|
if not batch_create_result or batch_create_result == "":
|
||||||
raise BioyondException("batch_create_result参数为空,请确保从batch_create节点正确连接handle")
|
raise BioyondException(
|
||||||
|
"batch_create_result参数为空,请确保:\n"
|
||||||
|
"1. batch_create节点与wait节点之间正确连接了handle\n"
|
||||||
|
"2. batch_create节点成功执行并返回了结果\n"
|
||||||
|
"3. 检查上游batch_create任务是否成功创建了订单"
|
||||||
|
)
|
||||||
|
|
||||||
# 解析batch_create_result JSON对象
|
# 解析batch_create_result JSON对象
|
||||||
try:
|
try:
|
||||||
@@ -1031,7 +1489,17 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
|
|
||||||
# 验证提取的数据
|
# 验证提取的数据
|
||||||
if not order_codes:
|
if not order_codes:
|
||||||
raise BioyondException("batch_create_result中未找到order_codes字段或为空")
|
self.hardware_interface._logger.error(
|
||||||
|
f"batch_create任务未生成任何订单。batch_create_result内容: {batch_create_result}"
|
||||||
|
)
|
||||||
|
raise BioyondException(
|
||||||
|
"batch_create_result中未找到order_codes或为空。\n"
|
||||||
|
"可能的原因:\n"
|
||||||
|
"1. batch_create任务执行失败(检查任务是否报错)\n"
|
||||||
|
"2. 物料配置问题(如'物料样品板分配失败')\n"
|
||||||
|
"3. Bioyond系统状态异常\n"
|
||||||
|
f"请检查batch_create任务的执行结果"
|
||||||
|
)
|
||||||
if not order_ids:
|
if not order_ids:
|
||||||
raise BioyondException("batch_create_result中未找到order_ids字段或为空")
|
raise BioyondException("batch_create_result中未找到order_ids字段或为空")
|
||||||
|
|
||||||
@@ -1114,6 +1582,8 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
self.hardware_interface._logger.info(
|
self.hardware_interface._logger.info(
|
||||||
f"成功获取任务 {order_code} 的实验报告"
|
f"成功获取任务 {order_code} 的实验报告"
|
||||||
)
|
)
|
||||||
|
# 简化报告,去除冗余信息
|
||||||
|
report = self._simplify_report(report)
|
||||||
|
|
||||||
reports.append({
|
reports.append({
|
||||||
"order_code": order_code,
|
"order_code": order_code,
|
||||||
@@ -1288,7 +1758,7 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
f"开始执行批量物料转移: {len(transfer_groups)}组任务 -> {target_device_id}"
|
f"开始执行批量物料转移: {len(transfer_groups)}组任务 -> {target_device_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
from .config import WAREHOUSE_MAPPING
|
warehouse_mapping = self.bioyond_config.get("warehouse_mapping", {})
|
||||||
results = []
|
results = []
|
||||||
successful_count = 0
|
successful_count = 0
|
||||||
failed_count = 0
|
failed_count = 0
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ Bioyond Workstation Implementation
|
|||||||
"""
|
"""
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
|
import threading
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, Any, List, Optional, Union
|
from typing import Dict, Any, List, Optional, Union
|
||||||
import json
|
import json
|
||||||
@@ -23,12 +24,94 @@ from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
|
|||||||
from unilabos.ros.msgs.message_converter import convert_to_ros_msg, Float64, String
|
from unilabos.ros.msgs.message_converter import convert_to_ros_msg, Float64, String
|
||||||
from pylabrobot.resources.resource import Resource as ResourcePLR
|
from pylabrobot.resources.resource import Resource as ResourcePLR
|
||||||
|
|
||||||
from unilabos.devices.workstation.bioyond_studio.config import (
|
|
||||||
API_CONFIG, WORKFLOW_MAPPINGS, MATERIAL_TYPE_MAPPINGS, WAREHOUSE_MAPPING, HTTP_SERVICE_CONFIG
|
|
||||||
)
|
|
||||||
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
|
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionMonitor:
|
||||||
|
"""Bioyond连接监控器"""
|
||||||
|
def __init__(self, workstation, check_interval=30):
|
||||||
|
self.workstation = workstation
|
||||||
|
self.check_interval = check_interval
|
||||||
|
self._running = False
|
||||||
|
self._thread = None
|
||||||
|
self._last_status = "unknown"
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
self._running = True
|
||||||
|
self._thread = threading.Thread(target=self._monitor_loop, daemon=True, name="BioyondConnectionMonitor")
|
||||||
|
self._thread.start()
|
||||||
|
logger.info("Bioyond连接监控器已启动")
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._running = False
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=2)
|
||||||
|
logger.info("Bioyond连接监控器已停止")
|
||||||
|
|
||||||
|
def _monitor_loop(self):
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
# 使用 lightweight API 检查连接
|
||||||
|
# query_matial_type_list 是比较快的查询
|
||||||
|
start_time = time.time()
|
||||||
|
result = self.workstation.hardware_interface.material_type_list()
|
||||||
|
|
||||||
|
status = "online" if result else "offline"
|
||||||
|
msg = "Connection established" if status == "online" else "Failed to get material type list"
|
||||||
|
|
||||||
|
if status != self._last_status:
|
||||||
|
logger.info(f"Bioyond连接状态变更: {self._last_status} -> {status}")
|
||||||
|
self._publish_event(status, msg)
|
||||||
|
self._last_status = status
|
||||||
|
|
||||||
|
# 发布心跳 (可选,或者只在状态变更时发布)
|
||||||
|
# self._publish_event(status, msg)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Bioyond连接检查异常: {e}")
|
||||||
|
if self._last_status != "error":
|
||||||
|
self._publish_event("error", str(e))
|
||||||
|
self._last_status = "error"
|
||||||
|
|
||||||
|
time.sleep(self.check_interval)
|
||||||
|
|
||||||
|
def _publish_event(self, status, message):
|
||||||
|
try:
|
||||||
|
if hasattr(self.workstation, "_ros_node") and self.workstation._ros_node:
|
||||||
|
event_data = {
|
||||||
|
"status": status,
|
||||||
|
"message": message,
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
# 动态发布消息,需要在 ROS2DeviceNode 中有对应支持
|
||||||
|
# 这里假设通用事件发布机制,使用 String 类型的 topic
|
||||||
|
# 话题: /<namespace>/events/device_status
|
||||||
|
ns = self.workstation._ros_node.namespace
|
||||||
|
topic = f"{ns}/events/device_status"
|
||||||
|
|
||||||
|
# 使用 ROS2DeviceNode 的发布功能
|
||||||
|
# 如果没有预定义的 publisher,需要动态创建
|
||||||
|
# 注意:workstation base node 可能没有自动创建 arbitrary publishers 的机制
|
||||||
|
# 这里我们先尝试用 String json 发布
|
||||||
|
|
||||||
|
# 在 ROS2DeviceNode 中通常需要先 create_publisher
|
||||||
|
# 为了简单起见,我们检查是否已有 publisher,没有则创建
|
||||||
|
if not hasattr(self.workstation, "_device_status_pub"):
|
||||||
|
self.workstation._device_status_pub = self.workstation._ros_node.create_publisher(
|
||||||
|
String, topic, 10
|
||||||
|
)
|
||||||
|
|
||||||
|
self.workstation._device_status_pub.publish(
|
||||||
|
convert_to_ros_msg(String, json.dumps(event_data, ensure_ascii=False))
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"发布设备状态事件失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
class BioyondResourceSynchronizer(ResourceSynchronizer):
|
class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||||
"""Bioyond资源同步器
|
"""Bioyond资源同步器
|
||||||
|
|
||||||
@@ -174,9 +257,8 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
|||||||
else:
|
else:
|
||||||
logger.info(f"[同步→Bioyond] ➕ 物料不存在于 Bioyond,将创建新物料并入库")
|
logger.info(f"[同步→Bioyond] ➕ 物料不存在于 Bioyond,将创建新物料并入库")
|
||||||
|
|
||||||
# 第1步:获取仓库配置
|
# 第1步:从配置中获取仓库配置
|
||||||
from .config import WAREHOUSE_MAPPING
|
warehouse_mapping = self.workstation.bioyond_config.get("warehouse_mapping", {})
|
||||||
warehouse_mapping = WAREHOUSE_MAPPING
|
|
||||||
|
|
||||||
# 确定目标仓库名称
|
# 确定目标仓库名称
|
||||||
parent_name = None
|
parent_name = None
|
||||||
@@ -238,14 +320,20 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
|||||||
# 第2步:转换为 Bioyond 格式
|
# 第2步:转换为 Bioyond 格式
|
||||||
logger.info(f"[同步→Bioyond] 🔄 转换物料为 Bioyond 格式...")
|
logger.info(f"[同步→Bioyond] 🔄 转换物料为 Bioyond 格式...")
|
||||||
|
|
||||||
# 导入物料默认参数配置
|
# 从配置中获取物料默认参数
|
||||||
from .config import MATERIAL_DEFAULT_PARAMETERS
|
material_default_params = self.workstation.bioyond_config.get("material_default_parameters", {})
|
||||||
|
material_type_params = self.workstation.bioyond_config.get("material_type_parameters", {})
|
||||||
|
|
||||||
|
# 合并参数配置:物料名称参数 + typeId参数(转换为 type:<uuid> 格式)
|
||||||
|
merged_params = material_default_params.copy()
|
||||||
|
for type_id, params in material_type_params.items():
|
||||||
|
merged_params[f"type:{type_id}"] = params
|
||||||
|
|
||||||
bioyond_material = resource_plr_to_bioyond(
|
bioyond_material = resource_plr_to_bioyond(
|
||||||
[resource],
|
[resource],
|
||||||
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
|
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
|
||||||
warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"],
|
warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"],
|
||||||
material_params=MATERIAL_DEFAULT_PARAMETERS
|
material_params=merged_params
|
||||||
)[0]
|
)[0]
|
||||||
|
|
||||||
logger.info(f"[同步→Bioyond] 🔧 准备覆盖locations字段,目标仓库: {parent_name}, 库位: {update_site}, UUID: {target_location_uuid[:8]}...")
|
logger.info(f"[同步→Bioyond] 🔧 准备覆盖locations字段,目标仓库: {parent_name}, 库位: {update_site}, UUID: {target_location_uuid[:8]}...")
|
||||||
@@ -468,13 +556,20 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
|||||||
return material_bioyond_id
|
return material_bioyond_id
|
||||||
|
|
||||||
# 转换为 Bioyond 格式
|
# 转换为 Bioyond 格式
|
||||||
from .config import MATERIAL_DEFAULT_PARAMETERS
|
# 从配置中获取物料默认参数
|
||||||
|
material_default_params = self.workstation.bioyond_config.get("material_default_parameters", {})
|
||||||
|
material_type_params = self.workstation.bioyond_config.get("material_type_parameters", {})
|
||||||
|
|
||||||
|
# 合并参数配置:物料名称参数 + typeId参数(转换为 type:<uuid> 格式)
|
||||||
|
merged_params = material_default_params.copy()
|
||||||
|
for type_id, params in material_type_params.items():
|
||||||
|
merged_params[f"type:{type_id}"] = params
|
||||||
|
|
||||||
bioyond_material = resource_plr_to_bioyond(
|
bioyond_material = resource_plr_to_bioyond(
|
||||||
[resource],
|
[resource],
|
||||||
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
|
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
|
||||||
warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"],
|
warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"],
|
||||||
material_params=MATERIAL_DEFAULT_PARAMETERS
|
material_params=merged_params
|
||||||
)[0]
|
)[0]
|
||||||
|
|
||||||
# ⚠️ 关键:创建物料时不设置 locations,让 Bioyond 系统暂不分配库位
|
# ⚠️ 关键:创建物料时不设置 locations,让 Bioyond 系统暂不分配库位
|
||||||
@@ -528,8 +623,7 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
|||||||
logger.info(f"[物料入库] 目标库位: {update_site}")
|
logger.info(f"[物料入库] 目标库位: {update_site}")
|
||||||
|
|
||||||
# 获取仓库配置和目标库位 UUID
|
# 获取仓库配置和目标库位 UUID
|
||||||
from .config import WAREHOUSE_MAPPING
|
warehouse_mapping = self.workstation.bioyond_config.get("warehouse_mapping", {})
|
||||||
warehouse_mapping = WAREHOUSE_MAPPING
|
|
||||||
|
|
||||||
parent_name = None
|
parent_name = None
|
||||||
target_location_uuid = None
|
target_location_uuid = None
|
||||||
@@ -584,6 +678,44 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
集成Bioyond物料管理的工作站实现
|
集成Bioyond物料管理的工作站实现
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def _publish_task_status(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
task_type: str,
|
||||||
|
status: str,
|
||||||
|
result: dict = None,
|
||||||
|
progress: float = 0.0,
|
||||||
|
task_code: str = None
|
||||||
|
):
|
||||||
|
"""发布任务状态事件"""
|
||||||
|
try:
|
||||||
|
if not getattr(self, "_ros_node", None):
|
||||||
|
return
|
||||||
|
|
||||||
|
event_data = {
|
||||||
|
"task_id": task_id,
|
||||||
|
"task_code": task_code,
|
||||||
|
"task_type": task_type,
|
||||||
|
"status": status,
|
||||||
|
"progress": progress,
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
if result:
|
||||||
|
event_data["result"] = result
|
||||||
|
|
||||||
|
topic = f"{self._ros_node.namespace}/events/task_status"
|
||||||
|
|
||||||
|
if not hasattr(self, "_task_status_pub"):
|
||||||
|
self._task_status_pub = self._ros_node.create_publisher(
|
||||||
|
String, topic, 10
|
||||||
|
)
|
||||||
|
|
||||||
|
self._task_status_pub.publish(
|
||||||
|
convert_to_ros_msg(String, json.dumps(event_data, ensure_ascii=False))
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"发布任务状态事件失败: {e}")
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
bioyond_config: Optional[Dict[str, Any]] = None,
|
bioyond_config: Optional[Dict[str, Any]] = None,
|
||||||
@@ -605,15 +737,32 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
raise ValueError("Deck 配置不能为空,请在配置文件中添加正确的 deck 配置")
|
raise ValueError("Deck 配置不能为空,请在配置文件中添加正确的 deck 配置")
|
||||||
|
|
||||||
# 初始化 warehouses 属性
|
# 初始化 warehouses 属性
|
||||||
self.deck.warehouses = {}
|
if not hasattr(self.deck, "warehouses") or self.deck.warehouses is None:
|
||||||
for resource in self.deck.children:
|
self.deck.warehouses = {}
|
||||||
if isinstance(resource, WareHouse):
|
|
||||||
self.deck.warehouses[resource.name] = resource
|
|
||||||
|
|
||||||
# 创建通信模块
|
# 仅当 warehouses 为空时尝试重新扫描(避免覆盖子类的修复)
|
||||||
|
if not self.deck.warehouses:
|
||||||
|
for resource in self.deck.children:
|
||||||
|
# 兼容性增强: 只要是仓库类别或者是 WareHouse 实例均可
|
||||||
|
is_warehouse = isinstance(resource, WareHouse) or getattr(resource, "category", "") == "warehouse"
|
||||||
|
|
||||||
|
# 如果配置中有定义,也可以认定为 warehouse
|
||||||
|
if not is_warehouse and "warehouse_mapping" in bioyond_config:
|
||||||
|
if resource.name in bioyond_config["warehouse_mapping"]:
|
||||||
|
is_warehouse = True
|
||||||
|
|
||||||
|
if is_warehouse:
|
||||||
|
self.deck.warehouses[resource.name] = resource
|
||||||
|
# 确保 category 被正确设置,方便后续使用
|
||||||
|
if getattr(resource, "category", "") != "warehouse":
|
||||||
|
try:
|
||||||
|
resource.category = "warehouse"
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 创建通信模块;同步器将在 post_init 中初始化并执行首次同步
|
||||||
self._create_communication_module(bioyond_config)
|
self._create_communication_module(bioyond_config)
|
||||||
self.resource_synchronizer = BioyondResourceSynchronizer(self)
|
self.resource_synchronizer = None
|
||||||
self.resource_synchronizer.sync_from_external()
|
|
||||||
|
|
||||||
# TODO: self._ros_node里面拿属性
|
# TODO: self._ros_node里面拿属性
|
||||||
|
|
||||||
@@ -627,18 +776,22 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
self._set_workflow_mappings(bioyond_config["workflow_mappings"])
|
self._set_workflow_mappings(bioyond_config["workflow_mappings"])
|
||||||
|
|
||||||
# 准备 HTTP 报送接收服务配置(延迟到 post_init 启动)
|
# 准备 HTTP 报送接收服务配置(延迟到 post_init 启动)
|
||||||
# 从 bioyond_config 中获取,如果没有则使用 HTTP_SERVICE_CONFIG 的默认值
|
# 从 bioyond_config 中的 http_service_config 获取
|
||||||
|
http_service_cfg = bioyond_config.get("http_service_config", {})
|
||||||
self._http_service_config = {
|
self._http_service_config = {
|
||||||
"host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG["http_service_host"]),
|
"host": http_service_cfg.get("http_service_host", "127.0.0.1"),
|
||||||
"port": bioyond_config.get("http_service_port", HTTP_SERVICE_CONFIG["http_service_port"])
|
"port": http_service_cfg.get("http_service_port", 8080)
|
||||||
}
|
}
|
||||||
self.http_service = None # 将在 post_init 中启动
|
self.http_service = None # 将在 post_init 启动
|
||||||
|
self.connection_monitor = None # 将在 post_init 启动
|
||||||
|
|
||||||
logger.info(f"Bioyond工作站初始化完成")
|
logger.info(f"Bioyond工作站初始化完成")
|
||||||
|
|
||||||
def __del__(self):
|
def __del__(self):
|
||||||
"""析构函数:清理资源,停止 HTTP 服务"""
|
"""析构函数:清理资源,停止 HTTP 服务"""
|
||||||
try:
|
try:
|
||||||
|
if hasattr(self, 'connection_monitor') and self.connection_monitor:
|
||||||
|
self.connection_monitor.stop()
|
||||||
if hasattr(self, 'http_service') and self.http_service is not None:
|
if hasattr(self, 'http_service') and self.http_service is not None:
|
||||||
logger.info("正在停止 HTTP 报送服务...")
|
logger.info("正在停止 HTTP 报送服务...")
|
||||||
self.http_service.stop()
|
self.http_service.stop()
|
||||||
@@ -648,8 +801,28 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
def post_init(self, ros_node: ROS2WorkstationNode):
|
def post_init(self, ros_node: ROS2WorkstationNode):
|
||||||
self._ros_node = ros_node
|
self._ros_node = ros_node
|
||||||
|
|
||||||
|
# Deck 为空时(反序列化未恢复子节点),主动调用 setup() 初始化仓库
|
||||||
|
if self.deck and not self.deck.children and hasattr(self.deck, "setup") and callable(self.deck.setup):
|
||||||
|
logger.info("Deck 无仓库子节点,调用 setup() 初始化仓库")
|
||||||
|
self.deck.setup()
|
||||||
|
|
||||||
|
# 初始化同步器并执行首次同步(需在仓库初始化之后)
|
||||||
|
self.resource_synchronizer = BioyondResourceSynchronizer(self)
|
||||||
|
self.resource_synchronizer.sync_from_external()
|
||||||
|
|
||||||
|
# 启动连接监控
|
||||||
|
try:
|
||||||
|
self.connection_monitor = ConnectionMonitor(self)
|
||||||
|
self.connection_monitor.start()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"启动连接监控失败: {e}")
|
||||||
|
|
||||||
# 启动 HTTP 报送接收服务(现在 device_id 已可用)
|
# 启动 HTTP 报送接收服务(现在 device_id 已可用)
|
||||||
if hasattr(self, '_http_service_config'):
|
# ⚠️ 检查子类是否已经自己管理 HTTP 服务
|
||||||
|
if self.bioyond_config.get("_disable_auto_http_service"):
|
||||||
|
logger.info("🔧 检测到 _disable_auto_http_service 标志,跳过自动启动 HTTP 服务")
|
||||||
|
logger.info(" 子类(BioyondCellWorkstation)已自行管理 HTTP 服务")
|
||||||
|
elif hasattr(self, '_http_service_config'):
|
||||||
try:
|
try:
|
||||||
self.http_service = WorkstationHTTPService(
|
self.http_service = WorkstationHTTPService(
|
||||||
workstation_instance=self,
|
workstation_instance=self,
|
||||||
@@ -688,19 +861,14 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
|
|
||||||
def _create_communication_module(self, config: Optional[Dict[str, Any]] = None) -> None:
|
def _create_communication_module(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||||
"""创建Bioyond通信模块"""
|
"""创建Bioyond通信模块"""
|
||||||
# 创建默认配置
|
# 直接使用传入的配置,不再使用默认值
|
||||||
default_config = {
|
# 所有配置必须从 JSON 文件中提供
|
||||||
**API_CONFIG,
|
|
||||||
"workflow_mappings": WORKFLOW_MAPPINGS,
|
|
||||||
"material_type_mappings": MATERIAL_TYPE_MAPPINGS,
|
|
||||||
"warehouse_mapping": WAREHOUSE_MAPPING
|
|
||||||
}
|
|
||||||
|
|
||||||
# 如果传入了 config,合并配置(config 中的值会覆盖默认值)
|
|
||||||
if config:
|
if config:
|
||||||
self.bioyond_config = {**default_config, **config}
|
self.bioyond_config = config
|
||||||
else:
|
else:
|
||||||
self.bioyond_config = default_config
|
# 如果没有配置,使用空字典(会导致后续错误,但这是预期的)
|
||||||
|
self.bioyond_config = {}
|
||||||
|
print("警告: 未提供 bioyond_config,请确保在 JSON 配置文件中提供完整配置")
|
||||||
|
|
||||||
self.hardware_interface = BioyondV1RPC(self.bioyond_config)
|
self.hardware_interface = BioyondV1RPC(self.bioyond_config)
|
||||||
|
|
||||||
@@ -1014,7 +1182,15 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
|
|
||||||
workflow_id = self._get_workflow(actual_workflow_name)
|
workflow_id = self._get_workflow(actual_workflow_name)
|
||||||
if workflow_id:
|
if workflow_id:
|
||||||
self.workflow_sequence.append(workflow_id)
|
# 兼容 BioyondReactionStation 中 workflow_sequence 被重写为 property 的情况
|
||||||
|
if isinstance(self.workflow_sequence, list):
|
||||||
|
self.workflow_sequence.append(workflow_id)
|
||||||
|
elif hasattr(self, "_cached_workflow_sequence") and isinstance(self._cached_workflow_sequence, list):
|
||||||
|
self._cached_workflow_sequence.append(workflow_id)
|
||||||
|
else:
|
||||||
|
print(f"❌ 无法添加工作流: workflow_sequence 类型错误 {type(self.workflow_sequence)}")
|
||||||
|
return False
|
||||||
|
|
||||||
print(f"添加工作流到执行顺序: {actual_workflow_name} -> {workflow_id}")
|
print(f"添加工作流到执行顺序: {actual_workflow_name} -> {workflow_id}")
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
@@ -1215,6 +1391,22 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
# TODO: 根据实际业务需求处理步骤完成逻辑
|
# TODO: 根据实际业务需求处理步骤完成逻辑
|
||||||
# 例如:更新数据库、触发后续流程等
|
# 例如:更新数据库、触发后续流程等
|
||||||
|
|
||||||
|
# 发布任务状态事件 (running/progress update)
|
||||||
|
self._publish_task_status(
|
||||||
|
task_id=data.get('orderCode'), # 使用 OrderCode 作为关联 ID
|
||||||
|
task_code=data.get('orderCode'),
|
||||||
|
task_type="bioyond_step",
|
||||||
|
status="running",
|
||||||
|
progress=0.5, # 步骤完成视为任务进行中
|
||||||
|
result={"step_name": data.get('stepName'), "step_id": data.get('stepId')}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 更新物料信息
|
||||||
|
# 步骤完成后,物料状态可能发生变化(如位置、用量等),触发同步
|
||||||
|
logger.info(f"[步骤完成报送] 触发物料同步...")
|
||||||
|
self.resource_synchronizer.sync_from_external()
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"processed": True,
|
"processed": True,
|
||||||
"step_id": data.get('stepId'),
|
"step_id": data.get('stepId'),
|
||||||
@@ -1249,6 +1441,17 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
|
|
||||||
# TODO: 根据实际业务需求处理通量完成逻辑
|
# TODO: 根据实际业务需求处理通量完成逻辑
|
||||||
|
|
||||||
|
# 发布任务状态事件
|
||||||
|
self._publish_task_status(
|
||||||
|
task_id=data.get('orderCode'),
|
||||||
|
task_code=data.get('orderCode'),
|
||||||
|
task_type="bioyond_sample",
|
||||||
|
status="running",
|
||||||
|
progress=0.7,
|
||||||
|
result={"sample_id": data.get('sampleId'), "status": status_desc}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"processed": True,
|
"processed": True,
|
||||||
"sample_id": data.get('sampleId'),
|
"sample_id": data.get('sampleId'),
|
||||||
@@ -1288,6 +1491,32 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
# TODO: 根据实际业务需求处理任务完成逻辑
|
# TODO: 根据实际业务需求处理任务完成逻辑
|
||||||
# 例如:更新物料库存、生成报表等
|
# 例如:更新物料库存、生成报表等
|
||||||
|
|
||||||
|
# 映射状态到事件状态
|
||||||
|
event_status = "completed"
|
||||||
|
if str(data.get('status')) in ["-11", "-12"]:
|
||||||
|
event_status = "error"
|
||||||
|
elif str(data.get('status')) == "30":
|
||||||
|
event_status = "completed"
|
||||||
|
else:
|
||||||
|
event_status = "running" # 其他状态视为运行中(或根据实际定义)
|
||||||
|
|
||||||
|
# 发布任务状态事件
|
||||||
|
self._publish_task_status(
|
||||||
|
task_id=data.get('orderCode'),
|
||||||
|
task_code=data.get('orderCode'),
|
||||||
|
task_type="bioyond_order",
|
||||||
|
status=event_status,
|
||||||
|
progress=1.0 if event_status in ["completed", "error"] else 0.9,
|
||||||
|
result={"order_name": data.get('orderName'), "status": status_desc, "materials_count": len(used_materials)}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 更新物料信息
|
||||||
|
# 任务完成后,且状态为完成时,触发同步以更新最终物料状态
|
||||||
|
if event_status == "completed":
|
||||||
|
logger.info(f"[任务完成报送] 触发物料同步...")
|
||||||
|
self.resource_synchronizer.sync_from_external()
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"processed": True,
|
"processed": True,
|
||||||
"order_code": data.get('orderCode'),
|
"order_code": data.get('orderCode'),
|
||||||
|
|||||||
219
unilabos/devices/workstation/changelog_2026-03-12.md
Normal file
219
unilabos/devices/workstation/changelog_2026-03-12.md
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
# 代码变更说明 — 2026-03-12
|
||||||
|
|
||||||
|
> 本次变更基于 `implementation_plan_v2.md` 执行,目标:**物理几何结构初始化与物料内容物填充彻底解耦**,消除 PLR 反序列化时的 `Resource already assigned to deck` 错误,并修复若干运行时新增问题。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、物料系统标准化重构(主线任务)
|
||||||
|
|
||||||
|
### 1. `unilabos/resources/battery/magazine.py`
|
||||||
|
|
||||||
|
**改动**:`MagazineHolder_6_Cathode`、`MagazineHolder_6_Anode`、`MagazineHolder_4_Cathode` 三个工厂函数的 `klasses` 参数改为 `None`。
|
||||||
|
|
||||||
|
**原因**:原来三个工厂函数在初始化时就向洞位填满极片对象(`ElectrodeSheet`),导致 PLR 反序列化时"几何结构已创建子节点 + DB 再次 assign"双重冲突。
|
||||||
|
|
||||||
|
**原则**:物料余量改由寄存器直读(阶段 F),资源树不再追踪每个极片实体。`MagazineHolder_6_Battery` 原本就是 `klasses=None`,三者现在保持一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. `unilabos/resources/battery/magazine.py`(追加,响应重复 UUID 问题)
|
||||||
|
|
||||||
|
**改动**:为 `Magazine`(洞位类)新增 `serialize` 和 `deserialize` 重写:
|
||||||
|
- `serialize`:序列化时强制将 `children` 置空,不再把极片写回数据库。
|
||||||
|
- `deserialize`:反序列化时强制忽略 `children` 字段,阻止数据库中旧极片记录被恢复。
|
||||||
|
|
||||||
|
**原因**:数据库中遗留有旧的 `ElectrodeSheet` 记录(`A1_sheet100` 等),启动时被 PLR 反序列化进来,导致同一 UUID 出现在多个 Magazine 洞位中,触发 `发现重复的uuid` 错误。此修复从源头截断旧数据,经过一次完整的"启动 → 资源树写回"后,数据库旧极片记录也会被干净覆盖。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. `unilabos/resources/battery/bottle_carriers.py`
|
||||||
|
|
||||||
|
**改动**:删除 `YIHUA_Electrolyte_12VialCarrier` 末尾的 12 瓶填充循环及对应 `import`。
|
||||||
|
|
||||||
|
**原因**:`bottle_rack_6x2` 和 `bottle_rack_6x2_2` 应初始化为空载架,瓶子由 Bioyond 侧实际转运后再填入。原来初始化时直接塞满 `YB_pei_ye_xiao_Bottle`,反序列化时产生重复 assign。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. `unilabos/resources/bioyond/decks.py`
|
||||||
|
|
||||||
|
**改动**:
|
||||||
|
- 将 `BIOYOND_YB_Deck` 重命名为 `BioyondElectrolyteDeck`,保留 `BIOYOND_YB_Deck` 作为向后兼容别名。
|
||||||
|
- 工厂函数 `YB_Deck()` 重命名为 `bioyond_electrolyte_deck()`,保留 `YB_Deck` 作为别名。
|
||||||
|
- `BIOYOND_PolymerReactionStation_Deck`、`BIOYOND_PolymerPreparationStation_Deck`、`BioyondElectrolyteDeck` 三个 Deck 类:
|
||||||
|
- 移除 `__init__` 中的 `setup: bool = False` 参数及 `if setup: self.setup()` 调用。
|
||||||
|
- 删除临时 `deserialize` 补丁(该补丁是为了强制 `setup=False`,根本原因消除后不再需要)。
|
||||||
|
|
||||||
|
**原因**:`setup` 参数导致 PLR 反序列化时先通过 `__init__` 创建所有子资源,再从 JSON `children` 字段再次 assign,产生 `already assigned to deck` 错误。正确模式:`__init__` 只初始化自身几何,`setup()` 由工厂函数调用,反序列化由 PLR 从 DB 数据重建子资源。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. `unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py`
|
||||||
|
|
||||||
|
**改动**:
|
||||||
|
- `CoincellDeck` 重命名为 `YihuaCoinCellDeck`,保留 `CoincellDeck` 作为向后兼容别名。
|
||||||
|
- 工厂函数 `YH_Deck()` 重命名为 `yihua_coin_cell_deck()`,保留 `YH_Deck` 作为别名。
|
||||||
|
- 移除 `YihuaCoinCellDeck.__init__` 中的 `setup: bool = False` 参数及调用,删除 `deserialize` 补丁(原因同 decks.py)。
|
||||||
|
- `MaterialPlate.__init__` 移除 `fill` 参数和 `fill=True` 分支,新增类方法 `MaterialPlate.create_with_holes()` 作为"带洞位"的工厂方法,`setup()` 改为调用该工厂方法。
|
||||||
|
- `YihuaCoinCellDeck.setup()` 末尾新增 `electrolyte_buffer`(`ResourceStack`)接驳槽,用于接收来自 Bioyond 侧的分液瓶板,命名与 `bioyond_cell_workstation.py` 中 `sites=["electrolyte_buffer"]` 一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. `unilabos/resources/resource_tracker.py`
|
||||||
|
|
||||||
|
**改动 1**:`to_plr_resources` 中,`load_all_state` 调用前预填 `Container` 类资源缺失的键:
|
||||||
|
|
||||||
|
```python
|
||||||
|
state.setdefault("liquid_history", [])
|
||||||
|
state.setdefault("pending_liquids", {})
|
||||||
|
```
|
||||||
|
|
||||||
|
**原因**:新版 PLR 要求 `Container` 状态中必须包含这两个键,旧数据库记录缺失时 `load_all_state` 会抛出 `KeyError`。
|
||||||
|
|
||||||
|
**改动 2**:`_validate_tree` 中,遇到重复 UUID 时改为自动重新分配新 UUID 并打 `WARNING`,不再直接抛异常崩溃。
|
||||||
|
|
||||||
|
**原因**:旧数据库中存在多个同名同 UUID 的极片对象(历史脏数据),严格校验会导致节点无法启动。改为 WARNING + 自动修复,确保启动成功,下次资源树写回后脏数据自然清除。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. `unilabos/resources/itemized_carrier.py`
|
||||||
|
|
||||||
|
**改动**:将原来的 `idx is None` 兜底补丁(静默调用 `super().assign_child_resource`,不更新槽位追踪)替换为两段式逻辑:
|
||||||
|
|
||||||
|
1. **XY 近似匹配**(容差 2mm):精确三维坐标匹配失败时,仅对比 XY 二维坐标,找到最近槽位后用槽位的正确坐标(含 Z)完成 assign,并打 `WARNING`。
|
||||||
|
2. **XY 也失败才抛异常**:给出详细的槽位列表和传入坐标,便于问题排查。
|
||||||
|
|
||||||
|
**原因**:数据库中存储的资源坐标 Z=0,而 `warehouse_factory` 定义的槽位 Z=dz(如 10mm)。精确匹配永远失败,原补丁静默兜底掩盖了这一问题。近似匹配修复了 Z 偏移,同时保留了真正异常时的报错能力。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. `unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py`
|
||||||
|
|
||||||
|
**改动 1**:更新导入:`BIOYOND_YB_Deck` → `BioyondElectrolyteDeck, bioyond_electrolyte_deck`。
|
||||||
|
|
||||||
|
**改动 2**:`__main__` 入口处改为调用 `bioyond_electrolyte_deck(name="YB_Deck")`。
|
||||||
|
|
||||||
|
**改动 3**:新增 `_get_resource_from_device(device_id, resource_name)` 方法,用于从目标设备的资源树中动态查找 PLR 资源对象(带降级回退逻辑)。
|
||||||
|
|
||||||
|
**改动 4**:跨站转运逻辑中,将原来"创建 `size=1,1,1` 的虚拟 `ResourcePLR` + 硬编码 UUID"的方式,改为通过 `_get_resource_from_device` 从目标设备获取真实的 `electrolyte_buffer` 资源对象。
|
||||||
|
|
||||||
|
**原因**:原代码使用硬编码 UUID 的虚拟资源作为转运目标,该对象在 YihuaCoinCellDeck 的资源树中不存在,转移后资源树状态混乱。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. `unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py`
|
||||||
|
|
||||||
|
**改动 1**:更新导入:`CoincellDeck` → `YihuaCoinCellDeck, yihua_coin_cell_deck`,`__main__` 入口改为调用 `yihua_coin_cell_deck()`。
|
||||||
|
|
||||||
|
**改动 2**:新增 10 个 `@property`,实现对依华扣电工站 Modbus 寄存器的直读:
|
||||||
|
|
||||||
|
| 属性名 | 寄存器地址 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `data_10mm_positive_plate_remaining` | 520 | 10mm正极片余量 |
|
||||||
|
| `data_12mm_positive_plate_remaining` | 522 | 12mm正极片余量 |
|
||||||
|
| `data_16mm_positive_plate_remaining` | 524 | 16mm正极片余量 |
|
||||||
|
| `data_aluminum_foil_remaining` | 526 | 铝箔余量 |
|
||||||
|
| `data_positive_shell_remaining` | 528 | 正极壳余量 |
|
||||||
|
| `data_flat_washer_remaining` | 530 | 平垫余量 |
|
||||||
|
| `data_negative_shell_remaining` | 532 | 负极壳余量 |
|
||||||
|
| `data_spring_washer_remaining` | 534 | 弹垫余量 |
|
||||||
|
| `data_finished_battery_remaining_capacity` | 536 | 成品电池余量 |
|
||||||
|
| `data_finished_battery_ng_remaining_capacity` | 538 | 成品电池NG槽余量 |
|
||||||
|
|
||||||
|
**原因**:`coin_cell_workstation.yaml` 的 `status_types` 中定义了这 10 个属性,但代码中从未实现,导致每次前端轮询时均报 `AttributeError`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、配置与注册表更新
|
||||||
|
|
||||||
|
### 10. `yibin_electrolyte_config.json`
|
||||||
|
- `BIOYOND_YB_Deck` → `BioyondElectrolyteDeck`(class、type、_resource_type 三处)
|
||||||
|
- `CoincellDeck` → `YihuaCoinCellDeck`(class、type、_resource_type 三处)
|
||||||
|
- 移除 `"setup": true` 字段
|
||||||
|
|
||||||
|
### 11. `yibin_coin_cell_only_config.json`
|
||||||
|
- `CoincellDeck` → `YihuaCoinCellDeck`
|
||||||
|
- 移除 `"setup": true`
|
||||||
|
|
||||||
|
### 12. `yibin_electrolyte_only_config.json`
|
||||||
|
- `BIOYOND_YB_Deck` → `BioyondElectrolyteDeck`
|
||||||
|
- 移除 `"setup": true`
|
||||||
|
|
||||||
|
### 13. `unilabos/registry/resources/bioyond/deck.yaml`
|
||||||
|
- `BIOYOND_YB_Deck` → `BioyondElectrolyteDeck`,工厂函数路径更新为 `bioyond_electrolyte_deck`
|
||||||
|
- `CoincellDeck` → `YihuaCoinCellDeck`,工厂函数路径更新为 `yihua_coin_cell_deck`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、独立 Bug 修复
|
||||||
|
|
||||||
|
### 14. `unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly_b.csv`
|
||||||
|
|
||||||
|
**改动**:10 条余量寄存器记录的 `DataType` 列从 `REAL` 改为 `FLOAT32`。
|
||||||
|
|
||||||
|
**原因**:`REAL` 是 IEC 61131-3 PLC 工程师惯用名称,但 pymodbus 的 `DATATYPE` 枚举只有 `FLOAT32`,`DataType['REAL']` 查表时抛 `KeyError: 'REAL'`,导致 `CoinCellAssemblyWorkstation` 节点启动失败。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、运行期新增 Bug 修复(第二轮,2026-03-12 18:12 日志)
|
||||||
|
|
||||||
|
### 15. `unilabos/devices/workstation/bioyond_studio/station.py`
|
||||||
|
|
||||||
|
**改动**:第 261 行 `self.bioyond_config` → `self.workstation.bioyond_config`。
|
||||||
|
|
||||||
|
**原因**:`BioyondResourceSynchronizer.sync_to_external` 内部误用了 `self.bioyond_config`,而该类从未设置此属性(应通过 `self.workstation.bioyond_config` 访问)。触发场景:用户在前端将任意物料拖入仓库时,同步到 Bioyond 必定抛出 `AttributeError: 'BioyondResourceSynchronizer' object has no attribute 'bioyond_config'`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 16. `unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py`
|
||||||
|
|
||||||
|
**改动**:`_get_type_id_by_name` 方法新增"直接英文 key 命中"分支:
|
||||||
|
|
||||||
|
- **原逻辑**:仅按 `value[0]`(中文名,如 `"5ml分液瓶板"`)遍历比较。
|
||||||
|
- **新逻辑**:先以 `type_name` 直接查找 `material_type_mappings` 字典 key(英文 model 名,如 `"YB_Vial_5mL_Carrier"`),命中则立即返回 UUID;否则再按中文名兜底遍历。
|
||||||
|
|
||||||
|
**原因**:`resource_tree_transfer` 将 `plr_resource.model`(英文 key)作为 `board_type` / `bottle_type` 传给 `create_sample`,后者再调用 `_get_type_id_by_name`。旧版函数只按中文名查,导致英文 key 永远匹配不到 → `ValueError: 未找到板类型 'YB_Vial_5mL_Carrier' 的配置`。新函数兼容两种查找方式,同时保持向后兼容。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、运行期新增 Bug 修复(第三轮,2026-03-12 20:30 日志)
|
||||||
|
|
||||||
|
### 17. `unilabos/resources/resource_tracker.py`(追加)
|
||||||
|
|
||||||
|
**改动**:在 `to_plr_resources` 中,`sub_cls.deserialize` 调用前新增 `_deduplicate_plr_dict(plr_dict)` 预处理函数。
|
||||||
|
|
||||||
|
**函数逻辑**:递归遍历整个 `plr_dict` 树,在**全树范围**对 `children` 列表按 `name` 去重——保留首次出现的同名节点,跳过重复项并打 `WARNING`。
|
||||||
|
|
||||||
|
**根本原因**:
|
||||||
|
1. 用户通过前端将 `YB_Vial_5mL_Carrier` 拖入仓库 E01,carrier 及其子 vial(`YB_Vial_5mL_Carrier_vial_A1` 等)被写入数据库。
|
||||||
|
2. 随后 `sync_from_external`(Bioyond 定期同步)以**新 UUID** 重新创建同名 carrier 并赋给同一槽位,PLR 内存树中的旧 carrier 被替换,但**数据库旧记录未被清除**。
|
||||||
|
3. 下次重启时,数据库同一 `WareHouse` 下存在两条同名 `BottleCarrier`(不同 UUID),`node_to_plr_dict` 将二者都放入 `children` 列表,PLR 反序列化第二个 carrier 时子 vial 命名冲突,抛出 `ValueError: Resource with name 'YB_Vial_5mL_Carrier_vial_A1' already exists in the tree.`,整个 deck 无法加载,系统启动失败。
|
||||||
|
|
||||||
|
**连锁错误(随根因修复自动消除)**:
|
||||||
|
- `TypeError: Deck.__init__() got an unexpected keyword argument 'data'` — deck 加载失败后 `driver_creator.py` 触发降级路径,参数类型错误
|
||||||
|
- `AttributeError: 'ResourceDictInstance' object has no attribute 'copy'` — 另一条降级路径失败
|
||||||
|
- `ValueError: Deck 配置不能为空` — 所有 deck 创建路径失败,`deck=None` 传入工作站
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **验证状态**:2026-03-12 20:56 日志确认系统正常运行,无新增 ERROR 级错误。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、变更文件汇总(最终)
|
||||||
|
|
||||||
|
| 文件 | 变更类型 | 轮次 |
|
||||||
|
|---|---|---|
|
||||||
|
| `resources/battery/magazine.py` | 重构 + Bug 修复(极片子节点解耦 + 旧数据清理) | 第一轮 |
|
||||||
|
| `resources/battery/bottle_carriers.py` | 重构(移除初始化时自动填瓶) | 第一轮 |
|
||||||
|
| `resources/bioyond/decks.py` | 重构 + 重命名(BioyondElectrolyteDeck) | 第一轮 |
|
||||||
|
| `devices/workstation/coin_cell_assembly/YB_YH_materials.py` | 重构 + 重命名(YihuaCoinCellDeck)+ 新增 electrolyte_buffer 槽位 | 第一轮 |
|
||||||
|
| `resources/resource_tracker.py` | Bug 修复 × 3(Container 状态键预填 + 重复 UUID 自动修复 + 树级名称去重) | 第一/三轮 |
|
||||||
|
| `resources/itemized_carrier.py` | Bug 修复(XY 近似坐标匹配,修复 Z 偏移) | 第一轮 |
|
||||||
|
| `devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py` | 重构 + Bug 修复(跨站转运 + 类型映射双模式查找) | 第一/二轮 |
|
||||||
|
| `devices/workstation/bioyond_studio/station.py` | Bug 修复(sync_to_external 属性访问路径) | 第二轮 |
|
||||||
|
| `devices/workstation/coin_cell_assembly/coin_cell_assembly.py` | 新增 10 个 Modbus 余量属性 + 更新导入 | 第一轮 |
|
||||||
|
| `yibin_electrolyte_config.json` | 配置更新(类名 + 移除 setup) | 第一轮 |
|
||||||
|
| `yibin_coin_cell_only_config.json` | 配置更新(类名 + 移除 setup) | 第一轮 |
|
||||||
|
| `yibin_electrolyte_only_config.json` | 配置更新(类名 + 移除 setup) | 第一轮 |
|
||||||
|
| `registry/resources/bioyond/deck.yaml` | 注册表更新(类名 + 工厂函数路径) | 第一轮 |
|
||||||
|
| `devices/workstation/coin_cell_assembly/coin_cell_assembly_b.csv` | Bug 修复(REAL → FLOAT32) | 第一轮 |
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Modbus CSV 地址映射说明
|
||||||
|
|
||||||
|
本文档说明 `coin_cell_assembly_a.csv` 文件如何将命名节点映射到实际的 Modbus 地址,以及如何在代码中使用它们。
|
||||||
|
|
||||||
|
## 1. CSV 文件结构
|
||||||
|
|
||||||
|
地址表文件位于同级目录下:`coin_cell_assembly_a.csv`
|
||||||
|
|
||||||
|
每一行定义了一个 Modbus 节点,包含以下关键列:
|
||||||
|
|
||||||
|
| 列名 | 说明 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| **Name** | **节点名称** (代码中引用的 Key) | `COIL_ALUMINUM_FOIL` |
|
||||||
|
| **DataType** | 数据类型 (BOOL, INT16, FLOAT32, STRING) | `BOOL` |
|
||||||
|
| **Comment** | 注释说明 | `使用铝箔垫` |
|
||||||
|
| **Attribute** | 属性 (通常留空或用于额外标记) | |
|
||||||
|
| **DeviceType** | Modbus 寄存器类型 (`coil`, `hold_register`) | `coil` |
|
||||||
|
| **Address** | **Modbus 地址** (十进制) | `8340` |
|
||||||
|
|
||||||
|
### 示例行 (铝箔垫片)
|
||||||
|
|
||||||
|
```csv
|
||||||
|
COIL_ALUMINUM_FOIL,BOOL,,使用铝箔垫,,coil,8340,
|
||||||
|
```
|
||||||
|
|
||||||
|
- **名称**: `COIL_ALUMINUM_FOIL`
|
||||||
|
- **类型**: `coil` (线圈,读写单个位)
|
||||||
|
- **地址**: `8340`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 加载与注册流程
|
||||||
|
|
||||||
|
在 `coin_cell_assembly.py` 的初始化代码中:
|
||||||
|
|
||||||
|
1. **加载 CSV**: `BaseClient.load_csv()` 读取 CSV 并解析每行定义。
|
||||||
|
2. **注册节点**: `modbus_client.register_node_list()` 将解析后的节点注册到 Modbus 客户端实例中。
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 代码位置: coin_cell_assembly.py (L174-175)
|
||||||
|
self.nodes = BaseClient.load_csv(os.path.join(os.path.dirname(__file__), 'coin_cell_assembly_a.csv'))
|
||||||
|
self.client = modbus_client.register_node_list(self.nodes)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 代码中的使用方式
|
||||||
|
|
||||||
|
注册后,通过 `self.client.use_node('节点名称')` 即可获取该节点对象并进行读写操作,无需关心具体地址。
|
||||||
|
|
||||||
|
### 控制铝箔垫片 (COIL_ALUMINUM_FOIL)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 代码位置: qiming_coin_cell_code 函数 (L1048)
|
||||||
|
self.client.use_node('COIL_ALUMINUM_FOIL').write(not lvbodian)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **写入 True**: 对应 Modbus 功能码 05 (Write Single Coil),向地址 `8340` 写入 `1` (ON)。
|
||||||
|
- **写入 False**: 向地址 `8340` 写入 `0` (OFF)。
|
||||||
|
|
||||||
|
> **注意**: 代码中使用了 `not lvbodian`,这意味着逻辑是反转的。如果 `lvbodian` 参数为 `True` (默认),写入的是 `False` (不使用铝箔垫)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 地址转换注意事项 (Modbus vs PLC)
|
||||||
|
|
||||||
|
CSV 中的 `Address` 列(如 `8340`)是 **Modbus 协议地址**。
|
||||||
|
|
||||||
|
如果使用 InoProShop (汇川 PLC 编程软件),看到的可能是 **PLC 内部地址** (如 `%QX...` 或 `%MW...`)。这两者之间通常需要转换。
|
||||||
|
|
||||||
|
### 常见的转换规则 (示例)
|
||||||
|
|
||||||
|
- **Coil (线圈) %QX**:
|
||||||
|
- `Modbus地址 = 字节地址 * 8 + 位偏移`
|
||||||
|
- *例子*: `%QX834.0` -> `834 * 8 + 0` = `6672`
|
||||||
|
- *注意*: 如果 CSV 中配置的是 `8340`,这可能是一个自定义映射,或者是基于不同规则(如直接对应 Word 地址的某种映射,或者可能就是地址写错了/使用了非标准映射)。
|
||||||
|
|
||||||
|
- **Register (寄存器) %MW**:
|
||||||
|
- 通常直接对应,或者有偏移量 (如 Modbus 40001 = PLC MW0)。
|
||||||
|
|
||||||
|
### 验证方法
|
||||||
|
由于 `test_unilab_interact.py` 中发现 `8450` (CSV风格) 不工作,而 `6760` (%QX845.0 计算值) 工作正常,**建议对 CSV 中的其他地址也进行核实**,特别是像 `8340` 这样以 0 结尾看起来像是 "字节地址+0" 的数值,可能实际上应该是 `%QX834.0` 对应的 `6672`。
|
||||||
|
|
||||||
|
如果发现设备控制无反应,请尝试按照标准的 Modbus 计算方式转换 PLC 地址。
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
# 2026-01-13 物料搜寻确认弹窗自动处理功能
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本次更新为设备初始化流程添加了**物料搜寻确认弹窗自动检测与处理功能**。在设备初始化过程中,PLC 会弹出物料搜寻确认对话框,现在系统可以根据用户参数自动点击"是"或"否"按钮,无需手动干预。
|
||||||
|
|
||||||
|
## 背景问题
|
||||||
|
|
||||||
|
### 原有流程
|
||||||
|
1. 调用 `func_pack_device_init_auto_start_combined()` 初始化设备
|
||||||
|
2. PLC 在初始化过程中弹出物料搜寻确认对话框
|
||||||
|
3. **需要人工手动点击**"是"或"否"按钮
|
||||||
|
4. PLC 继续完成初始化并启动
|
||||||
|
|
||||||
|
### 存在的问题
|
||||||
|
- 需要人工干预,无法实现全自动化
|
||||||
|
- 影响批量生产效率
|
||||||
|
- 容易遗忘点击导致流程卡住
|
||||||
|
|
||||||
|
## 解决方案
|
||||||
|
|
||||||
|
### 新增 Modbus 地址配置
|
||||||
|
|
||||||
|
在 `coin_cell_assembly_b.csv` 第 69-71 行添加三个 coil:
|
||||||
|
|
||||||
|
| Name | DeviceType | Address | 说明 |
|
||||||
|
|------|-----------|---------|------|
|
||||||
|
| COIL_MATERIAL_SEARCH_DIALOG_APPEAR | coil | 6470 | 物料搜寻确认弹窗画面是否出现 |
|
||||||
|
| COIL_MATERIAL_SEARCH_CONFIRM_YES | coil | 6480 | 初始化物料搜寻确认按钮"是" |
|
||||||
|
| COIL_MATERIAL_SEARCH_CONFIRM_NO | coil | 6490 | 初始化物料搜寻确认按钮"否" |
|
||||||
|
|
||||||
|
**Modbus 地址转换:**
|
||||||
|
- CSV 6470 → Modbus 5176 (弹窗出现)
|
||||||
|
- CSV 6480 → Modbus 5184 (按钮"是")
|
||||||
|
- CSV 6490 → Modbus 5192 (按钮"否")
|
||||||
|
|
||||||
|
## 代码修改详情
|
||||||
|
|
||||||
|
### 1. coin_cell_assembly.py
|
||||||
|
|
||||||
|
#### 1.1 新增辅助方法 `_handle_material_search_dialog()`
|
||||||
|
|
||||||
|
**位置:** 第 799-901 行
|
||||||
|
|
||||||
|
**功能:**
|
||||||
|
- 监测物料搜寻确认弹窗是否出现(Coil 5176)
|
||||||
|
- 根据 `enable_search` 参数自动点击对应按钮
|
||||||
|
- 使用**脉冲模式**模拟真实按钮操作:`True` → 保持 0.5 秒 → `False`
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `enable_search: bool` - True=点击"是"(启用物料搜寻), False=点击"否"(不启用)
|
||||||
|
- `timeout: int = 30` - 等待弹窗出现的最大时间(秒)
|
||||||
|
|
||||||
|
**逻辑流程:**
|
||||||
|
```python
|
||||||
|
1. 监测 COIL_MATERIAL_SEARCH_DIALOG_APPEAR (每 0.5 秒检查一次)
|
||||||
|
2. 检测到弹窗出现 (Coil = True)
|
||||||
|
3. 选择按钮:
|
||||||
|
- enable_search=True → COIL_MATERIAL_SEARCH_CONFIRM_YES
|
||||||
|
- enable_search=False → COIL_MATERIAL_SEARCH_CONFIRM_NO
|
||||||
|
4. 执行脉冲操作:
|
||||||
|
- 写入 True (按下按钮)
|
||||||
|
- 等待 0.5 秒
|
||||||
|
- 写入 False (释放按钮)
|
||||||
|
- 验证状态
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.2 修改 `func_pack_device_init_auto_start_combined()`
|
||||||
|
|
||||||
|
**位置:** 第 904-1115 行
|
||||||
|
|
||||||
|
**主要改动:**
|
||||||
|
|
||||||
|
1. **添加新参数**
|
||||||
|
```python
|
||||||
|
def func_pack_device_init_auto_start_combined(
|
||||||
|
self,
|
||||||
|
material_search_enable: bool = False # 新增参数
|
||||||
|
) -> bool:
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **内联初始化逻辑并集成弹窗检测**
|
||||||
|
- 不再调用 `self.func_pack_device_init()`
|
||||||
|
- 将初始化逻辑直接实现在函数内
|
||||||
|
- **在等待初始化完成的循环中实时检测弹窗**
|
||||||
|
- 避免死锁:PLC 等待弹窗确认 ↔ 代码等待初始化完成
|
||||||
|
|
||||||
|
3. **关键代码片段**
|
||||||
|
```python
|
||||||
|
# 等待初始化完成,同时检测物料搜寻弹窗
|
||||||
|
while (self._sys_init_status()) == False:
|
||||||
|
# 检查超时
|
||||||
|
if time.time() - start_wait > max_wait_time:
|
||||||
|
raise RuntimeError(f"初始化超时")
|
||||||
|
|
||||||
|
# 如果还没处理弹窗,检测弹窗是否出现
|
||||||
|
if not dialog_handled:
|
||||||
|
dialog_state = self.client.use_node('COIL_MATERIAL_SEARCH_DIALOG_APPEAR').read(1)
|
||||||
|
if dialog_actual: # 弹窗出现
|
||||||
|
# 执行脉冲按钮点击
|
||||||
|
button_node.write(True) # 按下
|
||||||
|
time.sleep(0.5) # 保持
|
||||||
|
button_node.write(False) # 释放
|
||||||
|
dialog_handled = True
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **步骤调整**
|
||||||
|
- 步骤 0: 前置条件检查
|
||||||
|
- 步骤 1: 设备初始化(**包含弹窗检测**)
|
||||||
|
- 步骤 1.5: 已在步骤 1 中完成
|
||||||
|
- 步骤 2: 切换自动模式
|
||||||
|
- 步骤 3: 启动设备
|
||||||
|
|
||||||
|
### 2. coin_cell_workstation.yaml
|
||||||
|
|
||||||
|
**位置:** 第 292-312 行
|
||||||
|
|
||||||
|
**修改内容:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
auto-func_pack_device_init_auto_start_combined:
|
||||||
|
goal_default:
|
||||||
|
material_search_enable: false # 新增默认值
|
||||||
|
|
||||||
|
schema:
|
||||||
|
description: 组合函数:设备初始化 + 物料搜寻确认 + 切换自动模式 + 启动。初始化过程中会自动检测物料搜寻确认弹窗,并根据参数自动点击"是"或"否"按钮
|
||||||
|
|
||||||
|
goal:
|
||||||
|
properties:
|
||||||
|
material_search_enable: # 新增参数配置
|
||||||
|
default: false
|
||||||
|
description: 是否启用物料搜寻功能。设备初始化后会弹出物料搜寻确认弹窗,此参数控制自动点击"是"(启用)或"否"(不启用)。默认为false(不启用物料搜寻)
|
||||||
|
type: boolean
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 测试脚本(已创建,用户已删除)
|
||||||
|
|
||||||
|
#### 3.1 test_material_search_dialog.py
|
||||||
|
- 从 CSV 动态加载 Modbus 地址
|
||||||
|
- 支持 4 种测试模式:
|
||||||
|
- `query` - 查询所有状态
|
||||||
|
- `dialog <0|1>` - 设置弹窗出现/消失
|
||||||
|
- `yes` - 脉冲点击"是"按钮
|
||||||
|
- `no` - 脉冲点击"否"按钮
|
||||||
|
- 兼容 pymodbus 3.x API
|
||||||
|
|
||||||
|
#### 3.2 更新其他测试脚本
|
||||||
|
- `test_coin_cell_reset.py` - 更新为 pymodbus 3.x API
|
||||||
|
- `test_unilab_interact.py` - 更新为 pymodbus 3.x API
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
### 参数说明
|
||||||
|
|
||||||
|
| 参数 | 类型 | 默认值 | 说明 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| `material_search_enable` | boolean | `false` | 是否启用物料搜寻功能 |
|
||||||
|
|
||||||
|
### 调用示例
|
||||||
|
|
||||||
|
#### 1. 不启用物料搜寻(默认)
|
||||||
|
```python
|
||||||
|
# 默认参数,点击"否"按钮
|
||||||
|
await device.func_pack_device_init_auto_start_combined()
|
||||||
|
```
|
||||||
|
|
||||||
|
或在 YAML workflow 中:
|
||||||
|
```yaml
|
||||||
|
# 使用默认值 false,不启用物料搜寻
|
||||||
|
- BatteryStation/auto-func_pack_device_init_auto_start_combined: {}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 启用物料搜寻
|
||||||
|
```python
|
||||||
|
# 显式设置为 True,点击"是"按钮
|
||||||
|
await device.func_pack_device_init_auto_start_combined(
|
||||||
|
material_search_enable=True
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
或在 YAML workflow 中:
|
||||||
|
```yaml
|
||||||
|
- BatteryStation/auto-func_pack_device_init_auto_start_combined:
|
||||||
|
goal:
|
||||||
|
material_search_enable: true # 启用物料搜寻
|
||||||
|
```
|
||||||
|
|
||||||
|
## 执行日志示例
|
||||||
|
|
||||||
|
```
|
||||||
|
26-01-13 [21:32:44] [INFO] 开始组合操作:设备初始化 → 物料搜寻确认 → 自动模式 → 启动
|
||||||
|
26-01-13 [21:32:44] [INFO] 【步骤 0/4】前置条件检查...
|
||||||
|
26-01-13 [21:32:44] [INFO] ✓ REG_UNILAB_INTERACT 检查通过
|
||||||
|
26-01-13 [21:32:44] [INFO] ✓ COIL_GB_L_IGNORE_CMD 检查通过
|
||||||
|
26-01-13 [21:32:44] [INFO] 【步骤 1/4】设备初始化...
|
||||||
|
26-01-13 [21:32:44] [INFO] 切换手动模式...
|
||||||
|
26-01-13 [21:32:46] [INFO] 发送初始化命令...
|
||||||
|
26-01-13 [21:32:47] [INFO] 等待初始化完成(同时监测物料搜寻弹窗)...
|
||||||
|
26-01-13 [21:33:05] [INFO] ✓ 在初始化过程中检测到物料搜寻确认弹窗!
|
||||||
|
26-01-13 [21:33:05] [INFO] 用户选择: 不启用物料搜寻(点击否)
|
||||||
|
26-01-13 [21:33:05] [INFO] → 按下按钮 '否'
|
||||||
|
26-01-13 [21:33:06] [INFO] → 释放按钮 '否'
|
||||||
|
26-01-13 [21:33:07] [INFO] ✓ 成功处理物料搜寻确认弹窗(选择: 否)
|
||||||
|
26-01-13 [21:33:08] [INFO] ✓ 初始化状态完成
|
||||||
|
26-01-13 [21:33:12] [INFO] ✓ 设备初始化完成
|
||||||
|
26-01-13 [21:33:12] [INFO] 【步骤 1.5/4】物料搜寻确认已在初始化过程中完成
|
||||||
|
26-01-13 [21:33:12] [INFO] 【步骤 2/4】切换自动模式...
|
||||||
|
26-01-13 [21:33:15] [INFO] ✓ 切换自动模式完成
|
||||||
|
26-01-13 [21:33:15] [INFO] 【步骤 3/4】启动设备...
|
||||||
|
26-01-13 [21:33:18] [INFO] ✓ 启动设备完成
|
||||||
|
26-01-13 [21:33:18] [INFO] 组合操作完成:设备已成功初始化、确认物料搜寻、切换自动模式并启动
|
||||||
|
```
|
||||||
|
|
||||||
|
## 技术要点
|
||||||
|
|
||||||
|
### 1. 脉冲模式按钮操作
|
||||||
|
模拟真实按钮按压过程:
|
||||||
|
1. 写入 `True` (按下)
|
||||||
|
2. 保持 0.5 秒
|
||||||
|
3. 写入 `False` (释放)
|
||||||
|
4. 验证状态
|
||||||
|
|
||||||
|
### 2. 避免死锁
|
||||||
|
**问题:** PLC 在初始化过程中等待弹窗确认,而代码等待初始化完成
|
||||||
|
**解决:** 在初始化等待循环中实时检测弹窗,一旦出现立即处理
|
||||||
|
|
||||||
|
### 3. 超时保护
|
||||||
|
- 弹窗检测超时:30 秒(在 `_handle_material_search_dialog` 中)
|
||||||
|
- 初始化超时:120 秒(在 `func_pack_device_init_auto_start_combined` 中)
|
||||||
|
|
||||||
|
### 4. PyModbus 3.x API 兼容
|
||||||
|
所有 Modbus 操作使用 keyword arguments:
|
||||||
|
```python
|
||||||
|
# 读取
|
||||||
|
client.read_coils(address=5176, count=1)
|
||||||
|
|
||||||
|
# 写入
|
||||||
|
client.write_coil(address=5184, value=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 向后兼容性
|
||||||
|
|
||||||
|
### 保留的原有函数
|
||||||
|
- `func_pack_device_init()` - 单独的初始化函数,不包含弹窗处理
|
||||||
|
- 仍可在 YAML 中通过 `auto-func_pack_device_init` 调用
|
||||||
|
- 用于不需要自动处理弹窗的场景
|
||||||
|
|
||||||
|
### 新增的功能
|
||||||
|
- 在 `func_pack_device_init_auto_start_combined()` 中集成弹窗处理
|
||||||
|
- 通过参数控制,默认行为与之前兼容(点击"否")
|
||||||
|
|
||||||
|
## 验证测试
|
||||||
|
|
||||||
|
### 测试场景
|
||||||
|
|
||||||
|
#### 场景 1:默认参数(不启用物料搜寻)
|
||||||
|
```bash
|
||||||
|
# 调用时不传参数
|
||||||
|
BatteryStation/auto-func_pack_device_init_auto_start_combined: {}
|
||||||
|
```
|
||||||
|
**预期结果:**
|
||||||
|
- ✅ 检测到弹窗
|
||||||
|
- ✅ 自动点击"否"按钮
|
||||||
|
- ✅ 初始化完成并启动成功
|
||||||
|
|
||||||
|
#### 场景 2:启用物料搜寻
|
||||||
|
```bash
|
||||||
|
# 设置 material_search_enable=true
|
||||||
|
BatteryStation/auto-func_pack_device_init_auto_start_combined:
|
||||||
|
goal:
|
||||||
|
material_search_enable: true
|
||||||
|
```
|
||||||
|
**预期结果:**
|
||||||
|
- ✅ 检测到弹窗
|
||||||
|
- ✅ 自动点击"是"按钮
|
||||||
|
- ✅ 初始化完成并启动成功
|
||||||
|
|
||||||
|
### 实际测试结果
|
||||||
|
|
||||||
|
**测试时间:** 2026-01-13 21:32:43
|
||||||
|
**测试参数:** `material_search_enable: false`
|
||||||
|
**测试结果:** ✅ 成功
|
||||||
|
|
||||||
|
**关键时间节点:**
|
||||||
|
- 21:33:05 - 检测到弹窗
|
||||||
|
- 21:33:05 - 按下"否"按钮
|
||||||
|
- 21:33:06 - 释放"否"按钮
|
||||||
|
- 21:33:07 - 弹窗处理完成
|
||||||
|
- 21:33:08 - 初始化状态完成
|
||||||
|
- 21:33:18 - 整个流程完成
|
||||||
|
|
||||||
|
**总耗时:** 约 35 秒(包含初始化全过程)
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **CSV 配置依赖**
|
||||||
|
- 确保 `coin_cell_assembly_b.csv` 包含 69-71 行的 coil 配置
|
||||||
|
- 地址转换逻辑:`modbus_addr = (csv_addr // 10) * 8 + (csv_addr % 10)`
|
||||||
|
|
||||||
|
2. **默认行为**
|
||||||
|
- 默认 `material_search_enable=false`,即不启用物料搜寻
|
||||||
|
- 如需启用,必须显式设置为 `true`
|
||||||
|
|
||||||
|
3. **日志级别**
|
||||||
|
- 弹窗检测过程中的 `waiting for init_cmd` 使用 DEBUG 级别
|
||||||
|
- 关键操作(检测到弹窗、按钮操作)使用 INFO 级别
|
||||||
|
|
||||||
|
4. **原有函数保留**
|
||||||
|
- `func_pack_device_init()` 仍然可用,但不包含弹窗处理
|
||||||
|
- 如果单独调用此函数,仍需手动处理弹窗
|
||||||
|
|
||||||
|
## 文件清单
|
||||||
|
|
||||||
|
### 修改的文件
|
||||||
|
1. `d:\UniLabdev\Uni-Lab-OS\unilabos\devices\workstation\coin_cell_assembly\coin_cell_assembly.py`
|
||||||
|
- 新增 `_handle_material_search_dialog()` 方法
|
||||||
|
- 修改 `func_pack_device_init_auto_start_combined()` 函数
|
||||||
|
|
||||||
|
2. `d:\UniLabdev\Uni-Lab-OS\unilabos\registry\devices\coin_cell_workstation.yaml`
|
||||||
|
- 更新 `auto-func_pack_device_init_auto_start_combined` 配置
|
||||||
|
- 添加 `material_search_enable` 参数说明
|
||||||
|
|
||||||
|
3. `d:\UniLabdev\Uni-Lab-OS\unilabos\devices\workstation\coin_cell_assembly\coin_cell_assembly_b.csv`
|
||||||
|
- 第 69-71 行添加三个 coil 配置
|
||||||
|
|
||||||
|
### 创建的测试文件(已删除)
|
||||||
|
1. `test_material_search_dialog.py` - 物料搜寻弹窗测试脚本
|
||||||
|
2. `test_coin_cell_reset.py` - 复位功能测试(更新为 pymodbus 3.x)
|
||||||
|
3. `test_unilab_interact.py` - Unilab 交互测试(更新为 pymodbus 3.x)
|
||||||
|
|
||||||
|
## 总结
|
||||||
|
|
||||||
|
本次更新成功实现了设备初始化过程中物料搜寻确认弹窗的自动化处理,主要优势:
|
||||||
|
|
||||||
|
✅ **全自动化** - 无需人工干预
|
||||||
|
✅ **参数可配** - 灵活控制是否启用物料搜寻
|
||||||
|
✅ **实时检测** - 在初始化等待循环中检测,避免死锁
|
||||||
|
✅ **脉冲模式** - 模拟真实按钮操作
|
||||||
|
✅ **向后兼容** - 保留原有函数,不影响现有流程
|
||||||
|
✅ **完整日志** - 详细记录每一步操作
|
||||||
|
✅ **超时保护** - 防止无限等待
|
||||||
|
|
||||||
|
该功能已通过实际测试验证,可投入生产使用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**文档版本:** 1.0
|
||||||
|
**创建日期:** 2026-01-13
|
||||||
|
**作者:** Antigravity AI Assistant
|
||||||
|
**最后更新:** 2026-01-13 21:36
|
||||||
@@ -0,0 +1,649 @@
|
|||||||
|
"""
|
||||||
|
纽扣电池组装工作站物料类定义
|
||||||
|
Button Battery Assembly Station Resource Classes
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import OrderedDict
|
||||||
|
from typing import Any, Dict, List, Optional, TypedDict, Union, cast
|
||||||
|
|
||||||
|
from pylabrobot.resources.coordinate import Coordinate
|
||||||
|
from pylabrobot.resources.container import Container
|
||||||
|
from pylabrobot.resources.deck import Deck
|
||||||
|
from pylabrobot.resources.itemized_resource import ItemizedResource
|
||||||
|
from pylabrobot.resources.resource import Resource
|
||||||
|
from pylabrobot.resources.resource_stack import ResourceStack
|
||||||
|
from pylabrobot.resources.tip_rack import TipRack, TipSpot
|
||||||
|
from pylabrobot.resources.trash import Trash
|
||||||
|
from pylabrobot.resources.utils import create_ordered_items_2d
|
||||||
|
|
||||||
|
from unilabos.resources.battery.magazine import MagazineHolder_4_Cathode, MagazineHolder_6_Cathode, MagazineHolder_6_Anode, MagazineHolder_6_Battery
|
||||||
|
from unilabos.resources.battery.bottle_carriers import YIHUA_Electrolyte_12VialCarrier
|
||||||
|
from unilabos.resources.battery.electrode_sheet import ElectrodeSheet
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: 这个应该只能放一个极片
|
||||||
|
class MaterialHoleState(TypedDict):
|
||||||
|
diameter: int
|
||||||
|
depth: int
|
||||||
|
max_sheets: int
|
||||||
|
info: Optional[str] # 附加信息
|
||||||
|
|
||||||
|
class MaterialHole(Resource):
|
||||||
|
"""料板洞位类"""
|
||||||
|
children: List[ElectrodeSheet] = []
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
size_x: float,
|
||||||
|
size_y: float,
|
||||||
|
size_z: float,
|
||||||
|
category: str = "material_hole",
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
size_x=size_x,
|
||||||
|
size_y=size_y,
|
||||||
|
size_z=size_z,
|
||||||
|
category=category,
|
||||||
|
)
|
||||||
|
self._unilabos_state: MaterialHoleState = MaterialHoleState(
|
||||||
|
diameter=20,
|
||||||
|
depth=10,
|
||||||
|
max_sheets=1,
|
||||||
|
info=None
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_all_sheet_info(self):
|
||||||
|
info_list = []
|
||||||
|
for sheet in self.children:
|
||||||
|
info_list.append(sheet._unilabos_state["info"])
|
||||||
|
return info_list
|
||||||
|
|
||||||
|
#这个函数函数好像没用,一般不会集中赋值质量
|
||||||
|
def set_all_sheet_mass(self):
|
||||||
|
for sheet in self.children:
|
||||||
|
sheet._unilabos_state["mass"] = 0.5 # 示例:设置质量为0.5g
|
||||||
|
|
||||||
|
def load_state(self, state: Dict[str, Any]) -> None:
|
||||||
|
"""格式不变"""
|
||||||
|
super().load_state(state)
|
||||||
|
self._unilabos_state = state
|
||||||
|
|
||||||
|
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""格式不变"""
|
||||||
|
data = super().serialize_state()
|
||||||
|
data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等)
|
||||||
|
return data
|
||||||
|
#移动极片前先取出对象
|
||||||
|
def get_sheet_with_name(self, name: str) -> Optional[ElectrodeSheet]:
|
||||||
|
for sheet in self.children:
|
||||||
|
if sheet.name == name:
|
||||||
|
return sheet
|
||||||
|
return None
|
||||||
|
|
||||||
|
def has_electrode_sheet(self) -> bool:
|
||||||
|
"""检查洞位是否有极片"""
|
||||||
|
return len(self.children) > 0
|
||||||
|
|
||||||
|
def assign_child_resource(
|
||||||
|
self,
|
||||||
|
resource: ElectrodeSheet,
|
||||||
|
location: Optional[Coordinate],
|
||||||
|
reassign: bool = True,
|
||||||
|
):
|
||||||
|
"""放置极片"""
|
||||||
|
# TODO: 这里要改,diameter找不到,加入._unilabos_state后应该没问题
|
||||||
|
#if resource._unilabos_state["diameter"] > self._unilabos_state["diameter"]:
|
||||||
|
# raise ValueError(f"极片直径 {resource._unilabos_state['diameter']} 超过洞位直径 {self._unilabos_state['diameter']}")
|
||||||
|
#if len(self.children) >= self._unilabos_state["max_sheets"]:
|
||||||
|
# raise ValueError(f"洞位已满,无法放置更多极片")
|
||||||
|
super().assign_child_resource(resource, location, reassign)
|
||||||
|
|
||||||
|
# 根据children的编号取物料对象。
|
||||||
|
def get_electrode_sheet_info(self, index: int) -> ElectrodeSheet:
|
||||||
|
return self.children[index]
|
||||||
|
|
||||||
|
|
||||||
|
class MaterialPlateState(TypedDict):
|
||||||
|
hole_spacing_x: float
|
||||||
|
hole_spacing_y: float
|
||||||
|
hole_diameter: float
|
||||||
|
info: Optional[str] # 附加信息
|
||||||
|
|
||||||
|
class MaterialPlate(ItemizedResource[MaterialHole]):
|
||||||
|
"""料板类 - 4x4个洞位,每个洞位放1个极片"""
|
||||||
|
|
||||||
|
children: List[MaterialHole]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
size_x: float,
|
||||||
|
size_y: float,
|
||||||
|
size_z: float,
|
||||||
|
ordered_items: Optional[Dict[str, MaterialHole]] = None,
|
||||||
|
ordering: Optional[OrderedDict[str, str]] = None,
|
||||||
|
category: str = "material_plate",
|
||||||
|
model: Optional[str] = None,
|
||||||
|
):
|
||||||
|
"""初始化料板(不主动填充洞位,由工厂方法或反序列化恢复)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 料板名称
|
||||||
|
size_x: 长度 (mm)
|
||||||
|
size_y: 宽度 (mm)
|
||||||
|
size_z: 高度 (mm)
|
||||||
|
category: 类别
|
||||||
|
model: 型号
|
||||||
|
"""
|
||||||
|
self._unilabos_state: MaterialPlateState = MaterialPlateState(
|
||||||
|
hole_spacing_x=24.0,
|
||||||
|
hole_spacing_y=24.0,
|
||||||
|
hole_diameter=20.0,
|
||||||
|
info="",
|
||||||
|
)
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
size_x=size_x,
|
||||||
|
size_y=size_y,
|
||||||
|
size_z=size_z,
|
||||||
|
ordered_items=ordered_items,
|
||||||
|
ordering=ordering,
|
||||||
|
category=category,
|
||||||
|
model=model,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_with_holes(
|
||||||
|
cls,
|
||||||
|
name: str,
|
||||||
|
size_x: float,
|
||||||
|
size_y: float,
|
||||||
|
size_z: float,
|
||||||
|
category: str = "material_plate",
|
||||||
|
model: Optional[str] = None,
|
||||||
|
) -> "MaterialPlate":
|
||||||
|
"""工厂方法:创建带 4x4 洞位的料板(仅用于初始 setup,不在反序列化路径调用)"""
|
||||||
|
# 默认洞位间距(与 _unilabos_state 默认值保持一致)
|
||||||
|
hole_spacing_x = 24.0
|
||||||
|
hole_spacing_y = 24.0
|
||||||
|
# 先建洞位,再作为 ordered_items 传入构造函数
|
||||||
|
# (ItemizedResource.__init__ 要求 ordered_items 或 ordering 二选一必须有值)
|
||||||
|
holes = create_ordered_items_2d(
|
||||||
|
klass=MaterialHole,
|
||||||
|
num_items_x=4,
|
||||||
|
num_items_y=4,
|
||||||
|
dx=(size_x - 4 * hole_spacing_x) / 2,
|
||||||
|
dy=(size_y - 4 * hole_spacing_y) / 2,
|
||||||
|
dz=size_z,
|
||||||
|
item_dx=hole_spacing_x,
|
||||||
|
item_dy=hole_spacing_y,
|
||||||
|
size_x=16,
|
||||||
|
size_y=16,
|
||||||
|
size_z=16,
|
||||||
|
)
|
||||||
|
return cls(
|
||||||
|
name=name, size_x=size_x, size_y=size_y, size_z=size_z,
|
||||||
|
ordered_items=holes, category=category, model=model,
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_locations(self):
|
||||||
|
# TODO:调多次相加
|
||||||
|
holes = create_ordered_items_2d(
|
||||||
|
klass=MaterialHole,
|
||||||
|
num_items_x=4,
|
||||||
|
num_items_y=4,
|
||||||
|
dx=(self._size_x - 3 * self._unilabos_state["hole_spacing_x"]) / 2, # 居中
|
||||||
|
dy=(self._size_y - 3 * self._unilabos_state["hole_spacing_y"]) / 2, # 居中
|
||||||
|
dz=self._size_z,
|
||||||
|
item_dx=self._unilabos_state["hole_spacing_x"],
|
||||||
|
item_dy=self._unilabos_state["hole_spacing_y"],
|
||||||
|
size_x = 1,
|
||||||
|
size_y = 1,
|
||||||
|
size_z = 1,
|
||||||
|
)
|
||||||
|
for item, original_item in zip(holes.items(), self.children):
|
||||||
|
original_item.location = item[1].location
|
||||||
|
|
||||||
|
|
||||||
|
class PlateSlot(ResourceStack):
|
||||||
|
"""板槽位类 - 1个槽上能堆放8个板,移板只能操作最上方的板"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
size_x: float,
|
||||||
|
size_y: float,
|
||||||
|
size_z: float,
|
||||||
|
max_plates: int = 8,
|
||||||
|
category: str = "plate_slot",
|
||||||
|
model: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""初始化板槽位
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 槽位名称
|
||||||
|
max_plates: 最大板数量
|
||||||
|
category: 类别
|
||||||
|
"""
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
direction="z", # Z方向堆叠
|
||||||
|
resources=[],
|
||||||
|
)
|
||||||
|
self.max_plates = max_plates
|
||||||
|
self.category = category
|
||||||
|
|
||||||
|
def can_add_plate(self) -> bool:
|
||||||
|
"""检查是否可以添加板"""
|
||||||
|
return len(self.children) < self.max_plates
|
||||||
|
|
||||||
|
def add_plate(self, plate: MaterialPlate) -> None:
|
||||||
|
"""添加料板"""
|
||||||
|
if not self.can_add_plate():
|
||||||
|
raise ValueError(f"槽位 {self.name} 已满,无法添加更多板")
|
||||||
|
self.assign_child_resource(plate)
|
||||||
|
|
||||||
|
def get_top_plate(self) -> MaterialPlate:
|
||||||
|
"""获取最上方的板"""
|
||||||
|
if len(self.children) == 0:
|
||||||
|
raise ValueError(f"槽位 {self.name} 为空")
|
||||||
|
return cast(MaterialPlate, self.get_top_item())
|
||||||
|
|
||||||
|
def take_top_plate(self) -> MaterialPlate:
|
||||||
|
"""取出最上方的板"""
|
||||||
|
top_plate = self.get_top_plate()
|
||||||
|
self.unassign_child_resource(top_plate)
|
||||||
|
return top_plate
|
||||||
|
|
||||||
|
def can_access_for_picking(self) -> bool:
|
||||||
|
"""检查是否可以进行取料操作(只有最上方的板能进行取料操作)"""
|
||||||
|
return len(self.children) > 0
|
||||||
|
|
||||||
|
def serialize(self) -> dict:
|
||||||
|
return {
|
||||||
|
**super().serialize(),
|
||||||
|
"max_plates": self.max_plates,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#是一种类型注解,不用self
|
||||||
|
class BatteryState(TypedDict):
|
||||||
|
"""电池状态字典"""
|
||||||
|
diameter: float
|
||||||
|
height: float
|
||||||
|
assembly_pressure: float
|
||||||
|
electrolyte_volume: float
|
||||||
|
electrolyte_name: str
|
||||||
|
|
||||||
|
class Battery(Resource):
|
||||||
|
"""电池类 - 可容纳极片"""
|
||||||
|
children: List[ElectrodeSheet] = []
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
size_x=1,
|
||||||
|
size_y=1,
|
||||||
|
size_z=1,
|
||||||
|
category: str = "battery",
|
||||||
|
):
|
||||||
|
"""初始化电池
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 电池名称
|
||||||
|
diameter: 直径 (mm)
|
||||||
|
height: 高度 (mm)
|
||||||
|
max_volume: 最大容量 (μL)
|
||||||
|
barcode: 二维码编号
|
||||||
|
category: 类别
|
||||||
|
model: 型号
|
||||||
|
"""
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
size_x=1,
|
||||||
|
size_y=1,
|
||||||
|
size_z=1,
|
||||||
|
category=category,
|
||||||
|
)
|
||||||
|
self._unilabos_state: BatteryState = BatteryState(
|
||||||
|
diameter = 1.0,
|
||||||
|
height = 1.0,
|
||||||
|
assembly_pressure = 1.0,
|
||||||
|
electrolyte_volume = 1.0,
|
||||||
|
electrolyte_name = "DP001"
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_electrolyte_with_bottle(self, bottle: Bottle) -> bool:
|
||||||
|
to_add_name = bottle._unilabos_state["electrolyte_name"]
|
||||||
|
if bottle.aspirate_electrolyte(10):
|
||||||
|
if self.add_electrolyte(to_add_name, 10):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
bottle._unilabos_state["electrolyte_volume"] += 10
|
||||||
|
|
||||||
|
def set_electrolyte(self, name: str, volume: float) -> None:
|
||||||
|
"""设置电解液信息"""
|
||||||
|
self._unilabos_state["electrolyte_name"] = name
|
||||||
|
self._unilabos_state["electrolyte_volume"] = volume
|
||||||
|
#这个应该没用,不会有加了后再加的事情
|
||||||
|
def add_electrolyte(self, name: str, volume: float) -> bool:
|
||||||
|
"""添加电解液信息"""
|
||||||
|
if name != self._unilabos_state["electrolyte_name"]:
|
||||||
|
return False
|
||||||
|
self._unilabos_state["electrolyte_volume"] += volume
|
||||||
|
|
||||||
|
def load_state(self, state: Dict[str, Any]) -> None:
|
||||||
|
"""格式不变"""
|
||||||
|
super().load_state(state)
|
||||||
|
self._unilabos_state = state
|
||||||
|
|
||||||
|
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""格式不变"""
|
||||||
|
data = super().serialize_state()
|
||||||
|
data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等)
|
||||||
|
return data
|
||||||
|
|
||||||
|
# 电解液作为属性放进去
|
||||||
|
|
||||||
|
class BatteryPressSlotState(TypedDict):
|
||||||
|
"""电池状态字典"""
|
||||||
|
diameter: float =20.0
|
||||||
|
depth: float = 4.0
|
||||||
|
|
||||||
|
class BatteryPressSlot(Resource):
|
||||||
|
"""电池压制槽类 - 设备,可容纳一个电池"""
|
||||||
|
children: List[Battery] = []
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str = "BatteryPressSlot",
|
||||||
|
category: str = "battery_press_slot",
|
||||||
|
):
|
||||||
|
"""初始化电池压制槽
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 压制槽名称
|
||||||
|
diameter: 直径 (mm)
|
||||||
|
depth: 深度 (mm)
|
||||||
|
category: 类别
|
||||||
|
model: 型号
|
||||||
|
"""
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
size_x=10,
|
||||||
|
size_y=12,
|
||||||
|
size_z=13,
|
||||||
|
category=category,
|
||||||
|
)
|
||||||
|
self._unilabos_state: BatteryPressSlotState = BatteryPressSlotState()
|
||||||
|
|
||||||
|
def has_battery(self) -> bool:
|
||||||
|
"""检查是否有电池"""
|
||||||
|
return len(self.children) > 0
|
||||||
|
|
||||||
|
def load_state(self, state: Dict[str, Any]) -> None:
|
||||||
|
"""格式不变"""
|
||||||
|
super().load_state(state)
|
||||||
|
self._unilabos_state = state
|
||||||
|
|
||||||
|
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""格式不变"""
|
||||||
|
data = super().serialize_state()
|
||||||
|
data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def assign_child_resource(
|
||||||
|
self,
|
||||||
|
resource: Battery,
|
||||||
|
location: Optional[Coordinate],
|
||||||
|
reassign: bool = True,
|
||||||
|
):
|
||||||
|
"""放置极片"""
|
||||||
|
# TODO: 让高京看下槽位只有一个电池时是否这么写。
|
||||||
|
if self.has_battery():
|
||||||
|
raise ValueError(f"槽位已含有一个电池,无法再放置其他电池")
|
||||||
|
super().assign_child_resource(resource, location, reassign)
|
||||||
|
|
||||||
|
# 根据children的编号取物料对象。
|
||||||
|
def get_battery_info(self, index: int) -> Battery:
|
||||||
|
return self.children[0]
|
||||||
|
|
||||||
|
|
||||||
|
def TipBox64(
|
||||||
|
name: str,
|
||||||
|
size_x: float = 127.8,
|
||||||
|
size_y: float = 85.5,
|
||||||
|
size_z: float = 60.0,
|
||||||
|
category: str = "tip_rack",
|
||||||
|
model: Optional[str] = None,
|
||||||
|
):
|
||||||
|
"""64孔枪头盒类"""
|
||||||
|
from pylabrobot.resources.tip import Tip
|
||||||
|
|
||||||
|
# 创建12x8=96个枪头位
|
||||||
|
def make_tip():
|
||||||
|
return Tip(
|
||||||
|
has_filter=False,
|
||||||
|
total_tip_length=20.0,
|
||||||
|
maximal_volume=1000, # 1mL
|
||||||
|
fitting_depth=8.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
tip_spots = create_ordered_items_2d(
|
||||||
|
klass=TipSpot,
|
||||||
|
num_items_x=12,
|
||||||
|
num_items_y=8,
|
||||||
|
dx=8.0,
|
||||||
|
dy=8.0,
|
||||||
|
dz=0.0,
|
||||||
|
item_dx=9.0,
|
||||||
|
item_dy=9.0,
|
||||||
|
size_x=10,
|
||||||
|
size_y=10,
|
||||||
|
size_z=0.0,
|
||||||
|
make_tip=make_tip,
|
||||||
|
)
|
||||||
|
idx_available = list(range(0, 32)) + list(range(64, 96))
|
||||||
|
tip_spots_available = {k: v for i, (k, v) in enumerate(tip_spots.items()) if i in idx_available}
|
||||||
|
tip_rack = TipRack(
|
||||||
|
name=name,
|
||||||
|
size_x=size_x,
|
||||||
|
size_y=size_y,
|
||||||
|
size_z=size_z,
|
||||||
|
# ordered_items=tip_spots_available,
|
||||||
|
ordered_items=tip_spots,
|
||||||
|
category=category,
|
||||||
|
model=model,
|
||||||
|
with_tips=False,
|
||||||
|
)
|
||||||
|
tip_rack.set_tip_state([True]*32 + [False]*32 + [True]*32) # 前32和后32个有枪头,中间32个无枪头
|
||||||
|
return tip_rack
|
||||||
|
|
||||||
|
|
||||||
|
class WasteTipBoxstate(TypedDict):
|
||||||
|
""""废枪头盒状态字典"""
|
||||||
|
max_tips: int = 100
|
||||||
|
tip_count: int = 0
|
||||||
|
|
||||||
|
#枪头不是一次性的(同一溶液则反复使用),根据寄存器判断
|
||||||
|
class WasteTipBox(Trash):
|
||||||
|
"""废枪头盒类 - 100个枪头容量"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
size_x: float = 127.8,
|
||||||
|
size_y: float = 85.5,
|
||||||
|
size_z: float = 60.0,
|
||||||
|
material_z_thickness=0,
|
||||||
|
max_volume=float("inf"),
|
||||||
|
category="trash",
|
||||||
|
model=None,
|
||||||
|
compute_volume_from_height=None,
|
||||||
|
compute_height_from_volume=None,
|
||||||
|
):
|
||||||
|
"""初始化废枪头盒
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 废枪头盒名称
|
||||||
|
size_x: 长度 (mm)
|
||||||
|
size_y: 宽度 (mm)
|
||||||
|
size_z: 高度 (mm)
|
||||||
|
max_tips: 最大枪头容量
|
||||||
|
category: 类别
|
||||||
|
model: 型号
|
||||||
|
"""
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
size_x=size_x,
|
||||||
|
size_y=size_y,
|
||||||
|
size_z=size_z,
|
||||||
|
category=category,
|
||||||
|
model=model,
|
||||||
|
)
|
||||||
|
self._unilabos_state: WasteTipBoxstate = WasteTipBoxstate()
|
||||||
|
|
||||||
|
def add_tip(self) -> None:
|
||||||
|
"""添加废枪头"""
|
||||||
|
if self._unilabos_state["tip_count"] >= self._unilabos_state["max_tips"]:
|
||||||
|
raise ValueError(f"废枪头盒 {self.name} 已满")
|
||||||
|
self._unilabos_state["tip_count"] += 1
|
||||||
|
|
||||||
|
def get_tip_count(self) -> int:
|
||||||
|
"""获取枪头数量"""
|
||||||
|
return self._unilabos_state["tip_count"]
|
||||||
|
|
||||||
|
def empty(self) -> None:
|
||||||
|
"""清空废枪头盒"""
|
||||||
|
self._unilabos_state["tip_count"] = 0
|
||||||
|
|
||||||
|
|
||||||
|
def load_state(self, state: Dict[str, Any]) -> None:
|
||||||
|
"""格式不变"""
|
||||||
|
super().load_state(state)
|
||||||
|
self._unilabos_state = state
|
||||||
|
|
||||||
|
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""格式不变"""
|
||||||
|
data = super().serialize_state()
|
||||||
|
data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class YihuaCoinCellDeck(Deck):
|
||||||
|
"""依华纽扣电池组装工作站台面类"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str = "coin_cell_deck",
|
||||||
|
size_x: float = 1450.0,
|
||||||
|
size_y: float = 1450.0,
|
||||||
|
size_z: float = 100.0,
|
||||||
|
origin: Coordinate = Coordinate(-2200, 0, 0),
|
||||||
|
category: str = "coin_cell_deck",
|
||||||
|
setup: bool = False,
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
size_x=1450.0,
|
||||||
|
size_y=1450.0,
|
||||||
|
size_z=100.0,
|
||||||
|
origin=origin,
|
||||||
|
)
|
||||||
|
if setup:
|
||||||
|
self.setup()
|
||||||
|
|
||||||
|
def setup(self) -> None:
|
||||||
|
"""设置工作站的标准布局 - 包含子弹夹、料盘、瓶架等完整配置"""
|
||||||
|
# ====================================== 子弹夹 ============================================
|
||||||
|
|
||||||
|
# 正极片(4个洞位,2x2布局)
|
||||||
|
zhengji_zip = MagazineHolder_4_Cathode("正极&铝箔弹夹")
|
||||||
|
self.assign_child_resource(zhengji_zip, Coordinate(x=402.0, y=830.0, z=0))
|
||||||
|
|
||||||
|
# 正极壳、平垫片(6个洞位,2x2+2布局)
|
||||||
|
zhengjike_zip = MagazineHolder_6_Cathode("正极壳&平垫片弹夹")
|
||||||
|
self.assign_child_resource(zhengjike_zip, Coordinate(x=566.0, y=272.0, z=0))
|
||||||
|
|
||||||
|
# 负极壳、弹垫片(6个洞位,2x2+2布局)
|
||||||
|
fujike_zip = MagazineHolder_6_Anode("负极壳&弹垫片弹夹")
|
||||||
|
self.assign_child_resource(fujike_zip, Coordinate(x=474.0, y=276.0, z=0))
|
||||||
|
|
||||||
|
# 成品弹夹(6个洞位,3x2布局)
|
||||||
|
chengpindanjia_zip = MagazineHolder_6_Battery("成品弹夹")
|
||||||
|
self.assign_child_resource(chengpindanjia_zip, Coordinate(x=260.0, y=156.0, z=0))
|
||||||
|
|
||||||
|
# ====================================== 物料板 ============================================
|
||||||
|
# 创建物料板(料盘carrier)- 4x4布局
|
||||||
|
# 负极料盘
|
||||||
|
fujiliaopan = MaterialPlate.create_with_holes(name="负极料盘", size_x=120, size_y=100, size_z=10.0)
|
||||||
|
self.assign_child_resource(fujiliaopan, Coordinate(x=708.0, y=794.0, z=0))
|
||||||
|
|
||||||
|
# 隔膜料盘
|
||||||
|
gemoliaopan = MaterialPlate.create_with_holes(name="隔膜料盘", size_x=120, size_y=100, size_z=10.0)
|
||||||
|
self.assign_child_resource(gemoliaopan, Coordinate(x=718.0, y=918.0, z=0))
|
||||||
|
# for i in range(16):
|
||||||
|
# gemopian = ElectrodeSheet(name=f"{gemoliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
|
||||||
|
# gemoliaopan.children[i].assign_child_resource(gemopian, location=None)
|
||||||
|
|
||||||
|
# ====================================== 瓶架、移液枪 ============================================
|
||||||
|
# 在台面上放置 3x4 瓶架、6x2 瓶架 与 64孔移液枪头盒
|
||||||
|
# 奔耀上料5ml分液瓶小板 - 由奔曜跨站转运而来,不单独写,但是这里应该有一个堆栈用于摆放分液瓶小板
|
||||||
|
|
||||||
|
# bottle_rack_3x4 = BottleRack(
|
||||||
|
# name="bottle_rack_3x4",
|
||||||
|
# size_x=210.0,
|
||||||
|
# size_y=140.0,
|
||||||
|
# size_z=100.0,
|
||||||
|
# num_items_x=2,
|
||||||
|
# num_items_y=4,
|
||||||
|
# position_spacing=35.0,
|
||||||
|
# orientation="vertical",
|
||||||
|
# )
|
||||||
|
# self.assign_child_resource(bottle_rack_3x4, Coordinate(x=1542.0, y=717.0, z=0))
|
||||||
|
|
||||||
|
# 电解液缓存位 - 6x2布局
|
||||||
|
bottle_rack_6x2 = YIHUA_Electrolyte_12VialCarrier(name="bottle_rack_6x2")
|
||||||
|
self.assign_child_resource(bottle_rack_6x2, Coordinate(x=1050.0, y=358.0, z=0))
|
||||||
|
# 电解液回收位6x2
|
||||||
|
bottle_rack_6x2_2 = YIHUA_Electrolyte_12VialCarrier(name="bottle_rack_6x2_2")
|
||||||
|
self.assign_child_resource(bottle_rack_6x2_2, Coordinate(x=914.0, y=358.0, z=0))
|
||||||
|
|
||||||
|
tip_box = TipBox64(name="tip_box_64")
|
||||||
|
self.assign_child_resource(tip_box, Coordinate(x=782.0, y=514.0, z=0))
|
||||||
|
|
||||||
|
waste_tip_box = WasteTipBox(name="waste_tip_box")
|
||||||
|
self.assign_child_resource(waste_tip_box, Coordinate(x=778.0, y=622.0, z=0))
|
||||||
|
|
||||||
|
# 分液瓶板接驳区 - 接收来自 BioyondElectrolyte 侧的完整 Vial Carrier 板
|
||||||
|
# 命名 electrolyte_buffer 与 bioyond_cell_workstation.py 中 sites=["electrolyte_buffer"] 对应
|
||||||
|
electrolyte_buffer = ResourceStack(
|
||||||
|
name="electrolyte_buffer",
|
||||||
|
direction="z",
|
||||||
|
resources=[],
|
||||||
|
)
|
||||||
|
self.assign_child_resource(electrolyte_buffer, Coordinate(x=1050.0, y=700.0, z=0))
|
||||||
|
|
||||||
|
|
||||||
|
def yihua_coin_cell_deck(name: str = "coin_cell_deck") -> YihuaCoinCellDeck:
|
||||||
|
deck = YihuaCoinCellDeck(name=name)
|
||||||
|
deck.setup()
|
||||||
|
return deck
|
||||||
|
|
||||||
|
|
||||||
|
# 向后兼容别名,日后废弃
|
||||||
|
CoincellDeck = YihuaCoinCellDeck
|
||||||
|
|
||||||
|
def YH_Deck(name: str = "") -> YihuaCoinCellDeck:
|
||||||
|
return yihua_coin_cell_deck(name=name or "coin_cell_deck")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
deck = create_coin_cell_deck()
|
||||||
|
print(deck)
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
88
unilabos/devices/workstation/implementation_plan.md
Normal file
88
unilabos/devices/workstation/implementation_plan.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# 物料系统标准化重构方案
|
||||||
|
|
||||||
|
根据开发者的反馈,本方案旨在遵循“标准化而非绕过”的原则,对资源类(Deck、Carrier、Magazine)进行重构。核心目标是将物理结构的初始化与物料/极片的初始填充逻辑解耦,彻底解决反序列化过程中的初始化冲突。
|
||||||
|
|
||||||
|
## 拟议变更
|
||||||
|
|
||||||
|
### [参考] PRCXI9300 标准化模式
|
||||||
|
#### [参考文件] [prcxi.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/liquid_handling/prcxi/prcxi.py)
|
||||||
|
* **PRCXI9300Deck**: 演示了如何在 `serialize` 中导出 `sites` 元数据,以及如何在 `assign_child_resource` 中实现稳健的槽位匹配(支持按名称、坐标或索引匹配)。
|
||||||
|
* **PRCXI9300Container**: 演示了标准的 `load_state` 和 `serialize_state` 模式,确保业务状态(如 `Material` UUID)能正确往返序列化。
|
||||||
|
|
||||||
|
### [组件] 台面 (Decks)
|
||||||
|
#### [修改] [decks.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/bioyond/decks.py)
|
||||||
|
* 将 `BIOYOND_YB_Deck` 重命名为 **`BioyondElectrolyteDeck`**,对应工厂函数 `YB_Deck()` 重命名为 **`bioyond_electrolyte_deck()`**。
|
||||||
|
* `BIOYOND_PolymerReactionStation_Deck` 和 `BIOYOND_PolymerPreparationStation_Deck` **保持不变**。
|
||||||
|
* 以上三个 Deck 的 `__init__` 中均移除 `setup` 参数和 `setup()` 调用,删除临时的 `deserialize` 重写。
|
||||||
|
|
||||||
|
#### [修改 + 重命名] [YB_YH_materials.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py) → `yihua_coin_cell_materials.py`
|
||||||
|
* 将 `CoincellDeck` 重命名为 **`YihuaCoinCellDeck`**,对应工厂函数 `YH_Deck()` 重命名为 **`yihua_coin_cell_deck()`**。
|
||||||
|
* 从 `YihuaCoinCellDeck.__init__` 中移除 `setup` 参数和 `setup()` 调用,删除临时的 `deserialize` 重写。
|
||||||
|
|
||||||
|
### [组件] 容器类与弹夹 (Itemized Carriers & Magazines)
|
||||||
|
#### [修改] [magazine.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/battery/magazine.py)
|
||||||
|
* 重构 `magazine_factory`:将创建 `MagazineHolder` 几何结构(空槽位)的过程与填充 `ElectrodeSheet` 物料的过程分离。
|
||||||
|
* 确保 `MagazineHolder` 和 `Magazine` 的 `__init__` 过程中不主动创建任何内容物。
|
||||||
|
|
||||||
|
#### [修改] [warehouse.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/warehouse.py)
|
||||||
|
* 确保 `WareHouse` 类和 `warehouse_factory` 遵循相同模式:先初始化几何结构,内容物另行处理。
|
||||||
|
|
||||||
|
#### [修改] [itemized_carrier.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/itemized_carrier.py)
|
||||||
|
* 移除之前添加的 `idx is None` 兜底补丁。
|
||||||
|
* 修复命名规范,确保 `assign_child_resource` 在反序列化时能准确匹配资源。
|
||||||
|
|
||||||
|
### [组件] 状态兼容性 (State Compatibility)
|
||||||
|
#### [修改] [resource_tracker.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/resource_tracker.py)
|
||||||
|
* 在 `to_plr_resources` 方法中调用 `load_all_state` 之前,预处理 `all_states` 字典。
|
||||||
|
* 对于 `Container` 类型的资源,如果其状态中缺少 `liquid_history` 或 `pending_liquids` 等 PLR 新版本要求的键,则填充默认值(如空列表/字典),防止反序列化中断。
|
||||||
|
|
||||||
|
### [组件] 料盘 (Material Plates)
|
||||||
|
#### [修改] [YB_YH_materials.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py)
|
||||||
|
* 重构 `MaterialPlate`:不在 `__init__` 中直接调用 `create_ordered_items_2d`。
|
||||||
|
* 重构 `YIHUA_Electrolyte_12VialCarrier`:将其修改为标准的基类定义或在工厂方法中彻底剥离内部 12 个 `YB_pei_ye_xiao_Bottle` 的强制初始化,以防反序列化冲突。
|
||||||
|
|
||||||
|
### [组件] 跨站转运与分液瓶板 (Vial Plate Transfer)
|
||||||
|
#### [修改] [bioyond_cell_workstation.py] & [YB_YH_materials.py]
|
||||||
|
* **分析**:目前的 `bioyond_cell_workstation.py` 在执行转移时,是用 `sites=["electrolyte_buffer"]` 试图把整块 `YB_Vial_5mL_Carrier` 板转移给目标。但由于实际工艺中,配液站将分液瓶板传往扣电工站后,是由扣电工站的机械臂**逐瓶抓取**并放入内部的 `bottle_rack_6x2`(电解液缓存位),用完后再放入 `bottle_rack_6x2_2`(废液位),因此配液站的这一次“跨工位资源树转移”在逻辑上存在偏差:目标槽位不应该是装单瓶的载体 `bottle_rack`。
|
||||||
|
* **修复方案**:
|
||||||
|
1. **目标端 (Yihua 侧)**:
|
||||||
|
* 在 `YB_YH_materials.py` 中为从配液站传过来的“分液瓶板”本身设置一个接驳专用的 `PlateSlot`(或者单纯直接移到 Deck 指定坐标)。这个位置负责真正在资源树层级上合法接收配液站传过来的完整 Board。
|
||||||
|
* 重构 `YIHUA_Electrolyte_12VialCarrier`:为了防止初始化反序列化冲突,取消内部在 `__init__` 中自动填充满 12 个 `YB_pei_ye_xiao_Bottle` 实例的逻辑。`bottle_rack_6x2` 和 `bottle_rack_6x2_2` 初始化时均应为空。
|
||||||
|
2. **转运端 (Bioyond 侧)**:
|
||||||
|
* 修改 `bioyond_cell_workstation.py` 的资源树数字转运代码,将其转移目标对应到 Yihua 侧新设立的“分液瓶板接驳区域”资源,或者干脆只更新资源树坐标位置(使其脱离 Bioyond Deck 加入 Yihua Deck),而不再强行挂载到一个无法容纳 Carrier 的 `bottle_rack_6x2` 内部。
|
||||||
|
|
||||||
|
### [组件] 依华扣电组装工站物料余量监控 (Material Monitoring)
|
||||||
|
#### [修改] 寄存器直读与前端集成
|
||||||
|
* **物理对象保留但虚化追踪**:原有的实体台面对象(如 `MaterialPlate`、`MagazineHolder` 各类型及其对应的洞位坐标)**仍然保留并使用**。保留它们是为了给机器臂提供基础的物理空间取放标定,以及作为前端页面的可视和可交互区块。
|
||||||
|
* **内部物料免追踪**:既然余量完全由寄存器接管,**我们将不再在这些弹夹或洞位内部显式生成、塞入和追踪每一个具体的极片或外壳对象 (如 `ElectrodeSheet` 等)**。这恰好与我们的重构主旨(不主动在 `__init__` 建子物料以避开反序列化冲突)完美结合,进一步极大地减轻了后台资源树对象的复杂度。
|
||||||
|
* **监控方式变更**:放弃现有的物料余量方式,直接读取依华扣电组装工站开放的寄存器地址以获取准确余量。
|
||||||
|
* **前端界面集成**:在前端界面点击负极壳、弹垫片等弹夹的 data view 时,直接读取并显示寄存器中的各自余量。
|
||||||
|
* **新增寄存器映射** (参考 `coin_cell_assembly_b.csv`):
|
||||||
|
* `10mm正极片剩余物料数量(R)`:`read hold_register 520` (REAL)
|
||||||
|
* `12mm正极片剩余物料数量(R)`:`read hold_register 522` (REAL)
|
||||||
|
* `16mm正极片剩余物料数量(R)`:`read hold_register 524` (REAL)
|
||||||
|
* `铝箔剩余物料数量(R)`:`read hold_register 526` (REAL)
|
||||||
|
* `正极壳剩余物料数量(R)`:`read hold_register 528` (REAL)
|
||||||
|
* `平垫剩余物料数量(R)`:`read hold_register 530` (REAL)
|
||||||
|
* `负极壳剩余物料数量(R)`:`read hold_register 532` (REAL)
|
||||||
|
* `弹垫剩余物料数量(R)`:`read hold_register 534` (REAL)
|
||||||
|
* `成品电池剩余可容纳数量(R)`:`read hold_register 536` (REAL)
|
||||||
|
* `成品电池NG槽剩余可容纳数量(R)`:`read hold_register 538` (REAL)
|
||||||
|
|
||||||
|
### [配置] JSON 配置文件 (Configuration Files)
|
||||||
|
#### [修改] 资源类型名称更新
|
||||||
|
* 更新以下配置文件,将其中的 `BIOYOND_YB_Deck` 替换为新的类名 **`BioyondElectrolyteDeck`**,以及将 `coin_cell_deck` 替换为 **`YihuaCoinCellDeck`**:
|
||||||
|
* `yibin_electrolyte_config.json`
|
||||||
|
* `yibin_coin_cell_only_config.json`
|
||||||
|
* `yibin_electrolyte_only_config.json`
|
||||||
|
|
||||||
|
## 验证计划
|
||||||
|
|
||||||
|
### 自动化测试
|
||||||
|
* 对重构后的类运行 `pylabrobot` 序列化/反序列化测试,确保状态能够完美恢复。
|
||||||
|
* 检查各工作站节点启动时是否仍存在 `ValueError: Resource '...' already assigned to deck` 报错。
|
||||||
|
* 检查 `resource_tracker` 中是否仍存在重复 UUID 报错。
|
||||||
|
|
||||||
|
### 手动验证
|
||||||
|
* 重启各工作站节点,验证资源树是否能根据数据库数据正确还还原。
|
||||||
|
* 验证“自动”与“手动”传输窗资源在台面上的分配是否正确。
|
||||||
388
unilabos/devices/workstation/implementation_plan_v2.md
Normal file
388
unilabos/devices/workstation/implementation_plan_v2.md
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
# 物料系统标准化重构方案 v2(增强版)
|
||||||
|
|
||||||
|
> **基于原始方案 (`implementation_plan.md`) 的补充与细化**。
|
||||||
|
> 本文档在原方案基础上:①增加当前代码现状核查结果;②明确各任务的执行顺序与文件级改动;③新增注意事项与回归测试命令。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 核心原则(保持不变)
|
||||||
|
|
||||||
|
"**物理几何结构初始化(Deck / Carrier / Magazine 的 `__init__`)与物料内容物填充(`setup()` / `klasses` 参数)必须彻底解耦**",以消除 PLR 反序列化时的 `Resource already assigned to deck` 错误。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 当前代码现状核查(2026-03-12)
|
||||||
|
|
||||||
|
| 文件 | 计划要求 | 当前状态 | 是否完成 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `resources/bioyond/decks.py` | 重命名类;移除 `setup` 参数和 `deserialize` 补丁 | 仍是 `BIOYOND_YB_Deck`;`setup` 参数和 `deserialize` 均存在 | ❌ |
|
||||||
|
| `coin_cell_assembly/YB_YH_materials.py` | 重命名类;文件迁移;移除补丁 | 仍是 `CoincellDeck`;`setup` 参数和 `deserialize` 均存在 | ❌ |
|
||||||
|
| `resources/battery/magazine.py` | `magazine_factory` 不主动填充物料 | `MagazineHolder_6_Cathode` / `_6_Anode` / `_4_Cathode` 仍传 `klasses`,初始化时填满极片 | ❌ |
|
||||||
|
| `resources/battery/bottle_carriers.py` | `YIHUA_Electrolyte_12VialCarrier` 初始化时不填充瓶子 | 第 54-55 行仍循环填充 12 个 `YB_pei_ye_xiao_Bottle` | ❌ |
|
||||||
|
| `resources/itemized_carrier.py` | 移除 `idx is None` 兜底补丁 | 第 182-190 行仍保留该兜底逻辑 | ❌(待前置任务完成后移除) |
|
||||||
|
| `resources/resource_tracker.py` | `load_all_state` 前预填 `Container` 缺失键 | 第 616 行直接调用,无预处理 | ❌ |
|
||||||
|
| `bioyond_cell_workstation.py` | 修正跨站转运目标为合法接驳槽 | 第 1563 行仍 `sites=["electrolyte_buffer"]`,目标 UUID 为硬编码虚拟资源 | ❌ |
|
||||||
|
| `yibin_*.json` 配置文件 | 更新类名 | 仍使用 `BIOYOND_YB_Deck` / `CoincellDeck` | ❌ |
|
||||||
|
| `registry/resources/bioyond/deck.yaml` | 更新类名(原计划未提及) | 仍使用 `BIOYOND_YB_Deck` / `CoincellDeck` | ❌(**新增**) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 执行顺序(含依赖关系)
|
||||||
|
|
||||||
|
```
|
||||||
|
阶段 A(底层资源类)
|
||||||
|
A1. magazine.py — 移除 klasses 填充
|
||||||
|
A2. bottle_carriers.py — 移除瓶子填充
|
||||||
|
|
||||||
|
阶段 B(Deck 层)
|
||||||
|
B1. decks.py — 移除 setup 参数和 deserialize 补丁;重命名
|
||||||
|
B2. YB_YH_materials.py → 重命名;移除 CoincellDeck 的 setup 参数和 deserialize 补丁
|
||||||
|
|
||||||
|
阶段 C(状态兼容)
|
||||||
|
C1. resource_tracker.py — 预填 Container 缺失键
|
||||||
|
C2. itemized_carrier.py — 移除 idx is None 兜底补丁(B 阶段完成后)
|
||||||
|
|
||||||
|
阶段 D(跨站转运修复)
|
||||||
|
D1. YB_YH_materials.py 新增 vial_plate_dock(接驳专用槽)
|
||||||
|
D2. bioyond_cell_workstation.py 修正 transfer 目标
|
||||||
|
|
||||||
|
阶段 E(配置与注册表)
|
||||||
|
E1. yibin_*.json 更新类名
|
||||||
|
E2. registry/resources/bioyond/deck.yaml 更新类名
|
||||||
|
E3. coin_cell_assembly.py 更新导入路径(若文件重命名)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 分阶段详细说明
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段 A — 底层资源类
|
||||||
|
|
||||||
|
#### A1. `unilabos/resources/battery/magazine.py`
|
||||||
|
|
||||||
|
**问题**:`MagazineHolder_6_Cathode`、`MagazineHolder_6_Anode`、`MagazineHolder_4_Cathode` 在调用 `magazine_factory` 时传入 `klasses`,导致每次 `__init__` 就填满极片,反序列化时重复添加。
|
||||||
|
|
||||||
|
**修改**:
|
||||||
|
|
||||||
|
- 将三个函数中的 `klasses=[...]` 改为 `klasses=None`(与 `MagazineHolder_6_Battery` 保持一致)。
|
||||||
|
- **理由**:物料余量已由寄存器管理(见阶段 F),不需要在资源树中追踪每一个极片。
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 修改前(MagazineHolder_6_Cathode 举例)
|
||||||
|
klasses=[FlatWasher, PositiveCan, PositiveCan, FlatWasher, PositiveCan, PositiveCan],
|
||||||
|
|
||||||
|
# 修改后
|
||||||
|
klasses=None,
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意**:`magazine_factory` 中 `klasses` 参数及循环体代码保留(仍可按需在非序列化场景使用),只是各具体工厂函数不再传入。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### A2. `unilabos/resources/battery/bottle_carriers.py`
|
||||||
|
|
||||||
|
**问题**:`YIHUA_Electrolyte_12VialCarrier` 第 54-55 行在工厂函数末尾循环填充 12 个瓶子。
|
||||||
|
|
||||||
|
**修改**:删除以下两行:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 删除
|
||||||
|
for i in range(12):
|
||||||
|
carrier[i] = YB_pei_ye_xiao_Bottle(f"{name}_vial_{i+1}")
|
||||||
|
```
|
||||||
|
|
||||||
|
**理由**:`bottle_rack_6x2` 和 `bottle_rack_6x2_2` 均应初始化为空,瓶子由 Bioyond 侧实际转运后再填入。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段 B — Deck 层重构
|
||||||
|
|
||||||
|
#### B1. `unilabos/resources/bioyond/decks.py`
|
||||||
|
|
||||||
|
**改动列表**:
|
||||||
|
|
||||||
|
1. **重命名** `BIOYOND_YB_Deck` → `BioyondElectrolyteDeck`
|
||||||
|
2. **重命名** `YB_Deck()` 工厂函数 → `bioyond_electrolyte_deck()`
|
||||||
|
3. **移除** `__init__` 中的 `setup: bool = False` 参数及 `if setup: self.setup()` 调用
|
||||||
|
4. **删除** `deserialize` 方法重写(该临时补丁在 `setup` 参数移除后自然失效,继续保留反而掩盖问题)
|
||||||
|
5. `BIOYOND_PolymerReactionStation_Deck` 和 `BIOYOND_PolymerPreparationStation_Deck` 同步执行第 3、4 步
|
||||||
|
|
||||||
|
**重构后初始化模式**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class BioyondElectrolyteDeck(Deck):
|
||||||
|
def __init__(self, name: str = "YB_Deck", ...):
|
||||||
|
super().__init__(name=name, ...)
|
||||||
|
# ❌ 不调用 self.setup()
|
||||||
|
# PLR 反序列化时只会调用 __init__,然后从 children JSON 重建子资源
|
||||||
|
|
||||||
|
def setup(self) -> None:
|
||||||
|
# 完整的子资源初始化逻辑保留在这里,只由工厂函数调用
|
||||||
|
...
|
||||||
|
|
||||||
|
def bioyond_electrolyte_deck(name: str) -> BioyondElectrolyteDeck:
|
||||||
|
deck = BioyondElectrolyteDeck(name=name)
|
||||||
|
deck.setup() # ✅ 工厂函数负责填充
|
||||||
|
return deck
|
||||||
|
```
|
||||||
|
|
||||||
|
**同步修改**:
|
||||||
|
- `bioyond_cell_workstation.py` 第 20 行:
|
||||||
|
```python
|
||||||
|
# 修改前
|
||||||
|
from unilabos.resources.bioyond.decks import BIOYOND_YB_Deck
|
||||||
|
# 修改后
|
||||||
|
from unilabos.resources.bioyond.decks import BioyondElectrolyteDeck
|
||||||
|
```
|
||||||
|
- 同文件第 2440 行:`BIOYOND_YB_Deck(setup=True)` → `bioyond_electrolyte_deck(name="YB_Deck")`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### B2. `unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py`
|
||||||
|
|
||||||
|
**改动列表**:
|
||||||
|
|
||||||
|
1. **重命名** `CoincellDeck` → `YihuaCoinCellDeck`
|
||||||
|
2. **重命名** `YH_Deck()` → `yihua_coin_cell_deck()`(可保留 `YH_Deck` 作为兼容别名,日后废弃)
|
||||||
|
3. **移除** `CoincellDeck.__init__` 中 `setup: bool = False` 参数及调用
|
||||||
|
4. **删除** `CoincellDeck.deserialize` 重写方法
|
||||||
|
5. `MaterialPlate.__init__` 中移除 `fill` 参数,始终不主动调用 `create_ordered_items_2d`(当前 `fill=False` 路径已正确,只需删除 `fill=True` 分支)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 修改前(MaterialPlate.__init__ 片段)
|
||||||
|
if fill:
|
||||||
|
super().__init__(..., ordered_items=holes, ...)
|
||||||
|
else:
|
||||||
|
super().__init__(..., ordered_items=ordered_items, ...)
|
||||||
|
|
||||||
|
# 修改后(始终走 "不填充" 路径)
|
||||||
|
super().__init__(..., ordered_items=ordered_items, ...)
|
||||||
|
# holes 的创建代码整体移入独立工厂方法
|
||||||
|
```
|
||||||
|
|
||||||
|
**同步修改**:
|
||||||
|
- `coin_cell_assembly.py` 第 20 行导入:
|
||||||
|
```python
|
||||||
|
# 修改前
|
||||||
|
from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import CoincellDeck
|
||||||
|
# 修改后
|
||||||
|
from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import YihuaCoinCellDeck
|
||||||
|
```
|
||||||
|
- 同文件第 2245 行:`CoincellDeck(setup=True, name="coin_cell_deck")` → `yihua_coin_cell_deck(name="coin_cell_deck")`
|
||||||
|
- 文件重命名(可选):`YB_YH_materials.py` → `yihua_coin_cell_materials.py`(若重命名,所有 import 路径需全局替换)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段 C — 状态兼容
|
||||||
|
|
||||||
|
#### C1. `unilabos/resources/resource_tracker.py`
|
||||||
|
|
||||||
|
**问题**:第 616 行直接调用 `plr_resource.load_all_state(all_states)`,若 `Container` 类资源的 `data` 字段缺少 `liquid_history` 或 `pending_liquids`,PLR 新版本会抛出 `KeyError`。
|
||||||
|
|
||||||
|
**修改**:在第 616 行前插入预处理:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 在 load_all_state 调用前预填缺失键
|
||||||
|
from pylabrobot.resources.container import Container as PLRContainer
|
||||||
|
for res_name, state in all_states.items():
|
||||||
|
if state and isinstance(state, dict):
|
||||||
|
# Container 类型要求这两个键存在
|
||||||
|
state.setdefault("liquid_history", [])
|
||||||
|
state.setdefault("pending_liquids", {})
|
||||||
|
|
||||||
|
plr_resource.load_all_state(all_states)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### C2. `unilabos/resources/itemized_carrier.py`
|
||||||
|
|
||||||
|
**前提**:B1、B2 阶段完成,Deck 类名与资源命名规范已对齐后再执行此步。
|
||||||
|
|
||||||
|
**修改**:删除第 182-190 行的兜底补丁:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 删除以下整个 if 块
|
||||||
|
if idx is None:
|
||||||
|
fallback_location = location if location is not None else Coordinate.zero()
|
||||||
|
super().assign_child_resource(resource, location=fallback_location, reassign=reassign)
|
||||||
|
return
|
||||||
|
```
|
||||||
|
|
||||||
|
**替代**:改为抛出带诊断信息的异常,便于后续问题排查:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if idx is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"[ItemizedCarrier] 无法为资源 '{resource.name}' 找到匹配的槽位。"
|
||||||
|
f"已知槽位:{list(self.child_locations.keys())},"
|
||||||
|
f"传入坐标:{location}"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段 D — 跨站转运修复
|
||||||
|
|
||||||
|
#### D1. `YB_YH_materials.py` — 新增分液瓶板接驳槽
|
||||||
|
|
||||||
|
在 `YihuaCoinCellDeck.setup()` 中,新增一个专用于接收 Bioyond 侧传来的完整分液瓶板的 `ResourceStack`(或 `PlateSlot`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 在 setup() 末尾追加
|
||||||
|
from pylabrobot.resources.resource_stack import ResourceStack
|
||||||
|
|
||||||
|
vial_plate_dock = ResourceStack(
|
||||||
|
name="electrolyte_buffer", # 保持与 bioyond_cell_workstation.py 的 sites 键一致
|
||||||
|
direction="z",
|
||||||
|
resources=[],
|
||||||
|
)
|
||||||
|
self.assign_child_resource(vial_plate_dock, Coordinate(x=1050.0, y=700.0, z=0))
|
||||||
|
```
|
||||||
|
|
||||||
|
> **说明**:槽位命名 `electrolyte_buffer` 与 `bioyond_cell_workstation.py` 现有的 `sites=["electrolyte_buffer"]` 对应,减少改动量。如改名,D2 需同步。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### D2. `bioyond_cell_workstation.py` — 修正 transfer 目标
|
||||||
|
|
||||||
|
**问题**:第 1545-1552 行创建了一个 `size=1,1,1` 的虚拟 `ResourcePLR` 并硬编码 UUID,这个对象在 YihuaCoinCellDeck 的资源树中不存在,导致转移后资源树状态混乱。
|
||||||
|
|
||||||
|
**修改**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 修改前:创建虚拟目标资源
|
||||||
|
target_resource_obj = ResourcePLR(name=target_location, size_x=1.0, ...)
|
||||||
|
target_resource_obj.unilabos_uuid = "550e8400-e29b-41d4-a716-446655440001" # 硬编码
|
||||||
|
|
||||||
|
# 修改后:通过 ROS2/设备注册表查询真实资源
|
||||||
|
# (需要从 target_device 的资源树中取出 electrolyte_buffer 的真实对象)
|
||||||
|
target_resource_obj = self._get_resource_from_device(
|
||||||
|
device_id=target_device,
|
||||||
|
resource_name=target_location
|
||||||
|
)
|
||||||
|
if target_resource_obj is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"目标设备 {target_device} 中未找到资源 '{target_location}',"
|
||||||
|
f"请确认 YihuaCoinCellDeck.setup() 中已添加 electrolyte_buffer 槽位"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
> **说明**:`_get_resource_from_device` 需根据现有 ROS2 资源同步机制实现,或复用已有的 `get_plr_resource_by_name` 类似方法。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段 E — 配置与注册表
|
||||||
|
|
||||||
|
#### E1. `yibin_electrolyte_config.json` / `yibin_coin_cell_only_config.json` / `yibin_electrolyte_only_config.json`
|
||||||
|
|
||||||
|
全局替换以下字符串:
|
||||||
|
|
||||||
|
| 旧值 | 新值 |
|
||||||
|
|---|---|
|
||||||
|
| `BIOYOND_YB_Deck` | `BioyondElectrolyteDeck` |
|
||||||
|
| `unilabos.resources.bioyond.decks:BIOYOND_YB_Deck` | `unilabos.resources.bioyond.decks:BioyondElectrolyteDeck` |
|
||||||
|
| `CoincellDeck` | `YihuaCoinCellDeck` |
|
||||||
|
| `unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials:CoincellDeck` | 若文件已重命名:`unilabos.devices.workstation.coin_cell_assembly.yihua_coin_cell_materials:YihuaCoinCellDeck` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### E2. `unilabos/registry/resources/bioyond/deck.yaml`(**原计划未覆盖,新增**)
|
||||||
|
|
||||||
|
当前第 25 行和第 37 行仍使用旧类名,需同步更新:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# 修改前
|
||||||
|
BIOYOND_YB_Deck:
|
||||||
|
...
|
||||||
|
CoincellDeck:
|
||||||
|
...
|
||||||
|
|
||||||
|
# 修改后
|
||||||
|
BioyondElectrolyteDeck:
|
||||||
|
...
|
||||||
|
YihuaCoinCellDeck:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段 F — 物料余量监控集成(原计划第5节细化)
|
||||||
|
|
||||||
|
**目标**:弃用资源树内极片对象计数,改为直读依华扣电工站寄存器。
|
||||||
|
|
||||||
|
#### F1. `coin_cell_assembly/coin_cell_assembly.py` — 新增寄存器读取方法
|
||||||
|
|
||||||
|
参考 `coin_cell_assembly_b.csv` 中的地址,封装读取工具方法:
|
||||||
|
|
||||||
|
```python
|
||||||
|
MATERIAL_REGISTER_MAP = {
|
||||||
|
"10mm正极片": (520, "REAL"),
|
||||||
|
"12mm正极片": (522, "REAL"),
|
||||||
|
"16mm正极片": (524, "REAL"),
|
||||||
|
"铝箔": (526, "REAL"),
|
||||||
|
"正极壳": (528, "REAL"),
|
||||||
|
"平垫": (530, "REAL"),
|
||||||
|
"负极壳": (532, "REAL"),
|
||||||
|
"弹垫": (534, "REAL"),
|
||||||
|
"成品容量": (536, "REAL"),
|
||||||
|
"成品NG容量": (538, "REAL"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_material_remaining(self, material_name: str) -> float:
|
||||||
|
"""通过寄存器直读指定物料的剩余数量"""
|
||||||
|
if material_name not in MATERIAL_REGISTER_MAP:
|
||||||
|
raise KeyError(f"未知物料名称: {material_name}")
|
||||||
|
address, dtype = MATERIAL_REGISTER_MAP[material_name]
|
||||||
|
return self.read_hold_register(address, dtype) # 复用现有 Modbus 读取方法
|
||||||
|
```
|
||||||
|
|
||||||
|
#### F2. 前端 data view 集成
|
||||||
|
|
||||||
|
- 前端点击 `MagazineHolder` 类资源的 data view 时,调用后端 `get_material_remaining` 接口(而非读取 `children` 长度)。
|
||||||
|
- 具体接口路径和前端调用代码需与前端开发同步,本文档不作具体实现约定。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 验证计划(细化)
|
||||||
|
|
||||||
|
### 4.1 单元测试(自动化)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 序列化/反序列化往返测试
|
||||||
|
python -m pytest unilabos/test/ -k "serial" -v
|
||||||
|
|
||||||
|
# 特别检查以下错误消失:
|
||||||
|
# - ValueError: Resource '...' already assigned to deck
|
||||||
|
# - KeyError: 'liquid_history'
|
||||||
|
# - 重复 UUID 报错
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 集成测试(手动)
|
||||||
|
|
||||||
|
按以下顺序逐步验证,确保每步正常后再进行下一步:
|
||||||
|
|
||||||
|
1. **单独启动 `BatteryStation` 节点**,检查 `CoincellDeck`(现 `YihuaCoinCellDeck`)能否从数据库状态正确还原,无 `already assigned` 报错。
|
||||||
|
2. **单独启动 `BioyondElectrolyte` 节点**,检查 `BioyondElectrolyteDeck` 反序列化正常。
|
||||||
|
3. **同时启动两个节点**,模拟执行一次分液→扣电的完整跨站转运,确认:
|
||||||
|
- `electrolyte_buffer` 槽位正确接收分液瓶板。
|
||||||
|
- `bottle_rack_6x2` 初始为空,不出现虚拟瓶子。
|
||||||
|
4. **重启两个节点**(模拟断电恢复),确认资源树从数据库还原后,`electrolyte_buffer` 中仍持有正确的分液瓶板对象。
|
||||||
|
5. **寄存器余量读取**:手动触发 `get_material_remaining("负极壳")`,确认返回值与设备显示一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 与原计划的差异对照
|
||||||
|
|
||||||
|
| 维度 | 原计划 | 本文档新增/修订 |
|
||||||
|
|---|---|---|
|
||||||
|
| 执行顺序 | 未排序 | 明确 A→B→C→D→E→F 的依赖顺序 |
|
||||||
|
| `itemized_carrier.py` | 移除兜底补丁 | 补充:替换为带诊断信息的异常,便于排查 |
|
||||||
|
| `bottle_carriers.py` | 提及 `YIHUA_Electrolyte_12VialCarrier` 需修改 | 明确:删除第 54-55 行的瓶子填充循环 |
|
||||||
|
| `MaterialPlate` | 提及移除 `fill` 参数 | 说明保留 `fill=False` 路径;整体删除 `fill=True` 分支 |
|
||||||
|
| `deck.yaml` | 未提及 | **新增**:该注册文件也需要同步更新类名 |
|
||||||
|
| `resource_tracker.py` | 简略描述 | 提供具体的 `setdefault` 预处理代码示例 |
|
||||||
|
| 跨站转运 | 描述了问题和方向 | 细化:新增 `electrolyte_buffer` 槽位的具体名称和坐标;修正 `transfer` 目标查找方式 |
|
||||||
|
| 验证计划 | 简述目标 | 提供具体测试命令和逐步手动验证流程 |
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user