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

|

|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ class WSConfig:
|
|||||||
|
|
||||||
# HTTP配置
|
# HTTP配置
|
||||||
class HTTPConfig:
|
class HTTPConfig:
|
||||||
remote_addr = "https://uni-lab.bohrium.com/api/v1" # 远程服务器地址
|
remote_addr = "https://leap-lab.bohrium.com/api/v1" # 远程服务器地址
|
||||||
|
|
||||||
# ROS配置
|
# ROS配置
|
||||||
class ROSConfig:
|
class ROSConfig:
|
||||||
@@ -209,8 +209,8 @@ unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis -g grap
|
|||||||
|
|
||||||
`--addr` 参数支持以下预设值,会自动转换为对应的完整 URL:
|
`--addr` 参数支持以下预设值,会自动转换为对应的完整 URL:
|
||||||
|
|
||||||
- `test` → `https://uni-lab.test.bohrium.com/api/v1`
|
- `test` → `https://leap-lab.test.bohrium.com/api/v1`
|
||||||
- `uat` → `https://uni-lab.uat.bohrium.com/api/v1`
|
- `uat` → `https://leap-lab.uat.bohrium.com/api/v1`
|
||||||
- `local` → `http://127.0.0.1:48197/api/v1`
|
- `local` → `http://127.0.0.1:48197/api/v1`
|
||||||
- 其他值 → 直接使用作为完整 URL
|
- 其他值 → 直接使用作为完整 URL
|
||||||
|
|
||||||
@@ -248,7 +248,7 @@ unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis -g grap
|
|||||||
|
|
||||||
`ak` 和 `sk` 是必需的认证参数:
|
`ak` 和 `sk` 是必需的认证参数:
|
||||||
|
|
||||||
1. **获取方式**:在 [Uni-Lab 官网](https://uni-lab.bohrium.com) 注册实验室后获得
|
1. **获取方式**:在 [Uni-Lab 官网](https://leap-lab.bohrium.com) 注册实验室后获得
|
||||||
2. **配置方式**:
|
2. **配置方式**:
|
||||||
- **命令行参数**:`--ak "your_key" --sk "your_secret"`(最高优先级,推荐)
|
- **命令行参数**:`--ak "your_key" --sk "your_secret"`(最高优先级,推荐)
|
||||||
- **环境变量**:`UNILABOS_BASICCONFIG_AK` 和 `UNILABOS_BASICCONFIG_SK`
|
- **环境变量**:`UNILABOS_BASICCONFIG_AK` 和 `UNILABOS_BASICCONFIG_SK`
|
||||||
@@ -276,14 +276,14 @@ WebSocket 是 Uni-Lab 的主要通信方式:
|
|||||||
HTTP 客户端配置用于与云端服务通信:
|
HTTP 客户端配置用于与云端服务通信:
|
||||||
|
|
||||||
| 参数 | 类型 | 默认值 | 说明 |
|
| 参数 | 类型 | 默认值 | 说明 |
|
||||||
| ------------- | ---- | -------------------------------------- | ------------ |
|
| ------------- | ---- | --------------------------------------- | ------------ |
|
||||||
| `remote_addr` | str | `"https://uni-lab.bohrium.com/api/v1"` | 远程服务地址 |
|
| `remote_addr` | str | `"https://leap-lab.bohrium.com/api/v1"` | 远程服务地址 |
|
||||||
|
|
||||||
**预设环境地址**:
|
**预设环境地址**:
|
||||||
|
|
||||||
- 生产环境:`https://uni-lab.bohrium.com/api/v1`(默认)
|
- 生产环境:`https://leap-lab.bohrium.com/api/v1`(默认)
|
||||||
- 测试环境:`https://uni-lab.test.bohrium.com/api/v1`
|
- 测试环境:`https://leap-lab.test.bohrium.com/api/v1`
|
||||||
- UAT 环境:`https://uni-lab.uat.bohrium.com/api/v1`
|
- UAT 环境:`https://leap-lab.uat.bohrium.com/api/v1`
|
||||||
- 本地环境:`http://127.0.0.1:48197/api/v1`
|
- 本地环境:`http://127.0.0.1:48197/api/v1`
|
||||||
|
|
||||||
### 4. ROSConfig - ROS 配置
|
### 4. ROSConfig - ROS 配置
|
||||||
@@ -401,7 +401,7 @@ export UNILABOS_WSCONFIG_RECONNECT_INTERVAL="10"
|
|||||||
export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS="500"
|
export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS="500"
|
||||||
|
|
||||||
# 设置HTTP配置
|
# 设置HTTP配置
|
||||||
export UNILABOS_HTTPCONFIG_REMOTE_ADDR="https://uni-lab.test.bohrium.com/api/v1"
|
export UNILABOS_HTTPCONFIG_REMOTE_ADDR="https://leap-lab.test.bohrium.com/api/v1"
|
||||||
```
|
```
|
||||||
|
|
||||||
## 配置文件使用方法
|
## 配置文件使用方法
|
||||||
@@ -484,13 +484,13 @@ export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS=100
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
class HTTPConfig:
|
class HTTPConfig:
|
||||||
remote_addr = "https://uni-lab.test.bohrium.com/api/v1"
|
remote_addr = "https://leap-lab.test.bohrium.com/api/v1"
|
||||||
```
|
```
|
||||||
|
|
||||||
**环境变量方式:**
|
**环境变量方式:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export UNILABOS_HTTPCONFIG_REMOTE_ADDR=https://uni-lab.test.bohrium.com/api/v1
|
export UNILABOS_HTTPCONFIG_REMOTE_ADDR=https://leap-lab.test.bohrium.com/api/v1
|
||||||
```
|
```
|
||||||
|
|
||||||
**命令行方式(推荐):**
|
**命令行方式(推荐):**
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ Uni-Lab-OS 支持多种部署模式:
|
|||||||
```
|
```
|
||||||
┌──────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────┐
|
||||||
│ Cloud Platform/Self-hosted Platform │
|
│ Cloud Platform/Self-hosted Platform │
|
||||||
│ uni-lab.bohrium.com │
|
│ leap-lab.bohrium.com │
|
||||||
│ (Resource Management, Task Scheduling, │
|
│ (Resource Management, Task Scheduling, │
|
||||||
│ Monitoring) │
|
│ Monitoring) │
|
||||||
└────────────────────┬─────────────────────────┘
|
└────────────────────┬─────────────────────────┘
|
||||||
@@ -444,7 +444,7 @@ ros2 daemon stop && ros2 daemon start
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 测试云端连接
|
# 测试云端连接
|
||||||
curl https://uni-lab.bohrium.com/api/v1/health
|
curl https://leap-lab.bohrium.com/api/v1/health
|
||||||
|
|
||||||
# 测试WebSocket
|
# 测试WebSocket
|
||||||
# 启动Uni-Lab后查看日志
|
# 启动Uni-Lab后查看日志
|
||||||
|
|||||||
1331
docs/moveit2_integration_summary.md
Normal file
1331
docs/moveit2_integration_summary.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -34,7 +34,7 @@
|
|||||||
**选择合适的安装包:**
|
**选择合适的安装包:**
|
||||||
|
|
||||||
| 安装包 | 适用场景 | 包含组件 |
|
| 安装包 | 适用场景 | 包含组件 |
|
||||||
|--------|----------|----------|
|
| --------------- | ---------------------------- | --------------------------------------------- |
|
||||||
| `unilabos` | **推荐大多数用户**,生产部署 | 完整安装包,开箱即用 |
|
| `unilabos` | **推荐大多数用户**,生产部署 | 完整安装包,开箱即用 |
|
||||||
| `unilabos-env` | 开发者(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos |
|
| `unilabos-env` | 开发者(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos |
|
||||||
| `unilabos-full` | 仿真/可视化 | unilabos + 完整 ROS2 桌面版 + Gazebo + MoveIt |
|
| `unilabos-full` | 仿真/可视化 | unilabos + 完整 ROS2 桌面版 + Gazebo + MoveIt |
|
||||||
@@ -66,6 +66,7 @@ mamba install uni-lab::unilabos-full -c robostack-staging -c conda-forge
|
|||||||
```
|
```
|
||||||
|
|
||||||
**选择建议:**
|
**选择建议:**
|
||||||
|
|
||||||
- **日常使用/生产部署**:使用 `unilabos`(推荐),完整功能,开箱即用
|
- **日常使用/生产部署**:使用 `unilabos`(推荐),完整功能,开箱即用
|
||||||
- **开发者**:使用 `unilabos-env` + `pip install -e .` + `uv pip install -r unilabos/utils/requirements.txt`,代码修改立即生效
|
- **开发者**:使用 `unilabos-env` + `pip install -e .` + `uv pip install -r unilabos/utils/requirements.txt`,代码修改立即生效
|
||||||
- **仿真/可视化**:使用 `unilabos-full`,含 Gazebo、rviz2、MoveIt
|
- **仿真/可视化**:使用 `unilabos-full`,含 Gazebo、rviz2、MoveIt
|
||||||
@@ -88,7 +89,7 @@ python -c "from unilabos_msgs.msg import Resource; print('ROS msgs OK')"
|
|||||||
|
|
||||||
#### 2.1 注册实验室账号
|
#### 2.1 注册实验室账号
|
||||||
|
|
||||||
1. 访问 [https://uni-lab.bohrium.com](https://uni-lab.bohrium.com)
|
1. 访问 [https://leap-lab.bohrium.com](https://leap-lab.bohrium.com)
|
||||||
2. 注册账号并登录
|
2. 注册账号并登录
|
||||||
3. 创建新实验室
|
3. 创建新实验室
|
||||||
|
|
||||||
@@ -297,7 +298,7 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
|
|||||||
|
|
||||||
#### 5.2 访问 Web 界面
|
#### 5.2 访问 Web 界面
|
||||||
|
|
||||||
启动系统后,访问[https://uni-lab.bohrium.com](https://uni-lab.bohrium.com)
|
启动系统后,访问[https://leap-lab.bohrium.com](https://leap-lab.bohrium.com)
|
||||||
|
|
||||||
#### 5.3 添加设备和物料
|
#### 5.3 添加设备和物料
|
||||||
|
|
||||||
@@ -306,12 +307,10 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
|
|||||||
**示例场景:** 创建一个简单的液体转移实验
|
**示例场景:** 创建一个简单的液体转移实验
|
||||||
|
|
||||||
1. **添加工作站(必需):**
|
1. **添加工作站(必需):**
|
||||||
|
|
||||||
- 在"仪器设备"中找到 `work_station`
|
- 在"仪器设备"中找到 `work_station`
|
||||||
- 添加 `workstation` x1
|
- 添加 `workstation` x1
|
||||||
|
|
||||||
2. **添加虚拟转移泵:**
|
2. **添加虚拟转移泵:**
|
||||||
|
|
||||||
- 在"仪器设备"中找到 `virtual_device`
|
- 在"仪器设备"中找到 `virtual_device`
|
||||||
- 添加 `virtual_transfer_pump` x1
|
- 添加 `virtual_transfer_pump` x1
|
||||||
|
|
||||||
@@ -818,6 +817,7 @@ uv pip install -r unilabos/utils/requirements.txt
|
|||||||
```
|
```
|
||||||
|
|
||||||
**为什么使用这种方式?**
|
**为什么使用这种方式?**
|
||||||
|
|
||||||
- `unilabos-env` 提供 ROS2 核心组件和 uv(通过 conda 安装,避免编译)
|
- `unilabos-env` 提供 ROS2 核心组件和 uv(通过 conda 安装,避免编译)
|
||||||
- `unilabos/utils/requirements.txt` 包含所有运行时需要的 pip 依赖
|
- `unilabos/utils/requirements.txt` 包含所有运行时需要的 pip 依赖
|
||||||
- `dev_install.py` 自动检测中文环境,中文系统自动使用清华镜像
|
- `dev_install.py` 自动检测中文环境,中文系统自动使用清华镜像
|
||||||
@@ -1796,32 +1796,27 @@ unilab --ak your_ak --sk your_sk -g graph.json \
|
|||||||
**详细步骤:**
|
**详细步骤:**
|
||||||
|
|
||||||
1. **需求分析**:
|
1. **需求分析**:
|
||||||
|
|
||||||
- 明确实验流程
|
- 明确实验流程
|
||||||
- 列出所需设备和物料
|
- 列出所需设备和物料
|
||||||
- 设计工作流程图
|
- 设计工作流程图
|
||||||
|
|
||||||
2. **环境搭建**:
|
2. **环境搭建**:
|
||||||
|
|
||||||
- 安装 Uni-Lab-OS
|
- 安装 Uni-Lab-OS
|
||||||
- 创建实验室账号
|
- 创建实验室账号
|
||||||
- 准备开发工具(IDE、Git)
|
- 准备开发工具(IDE、Git)
|
||||||
|
|
||||||
3. **原型验证**:
|
3. **原型验证**:
|
||||||
|
|
||||||
- 使用虚拟设备测试流程
|
- 使用虚拟设备测试流程
|
||||||
- 验证工作流逻辑
|
- 验证工作流逻辑
|
||||||
- 调整参数
|
- 调整参数
|
||||||
|
|
||||||
4. **迭代开发**:
|
4. **迭代开发**:
|
||||||
|
|
||||||
- 实现自定义设备驱动(同时撰写单点函数测试)
|
- 实现自定义设备驱动(同时撰写单点函数测试)
|
||||||
- 编写注册表
|
- 编写注册表
|
||||||
- 单元测试
|
- 单元测试
|
||||||
- 集成测试
|
- 集成测试
|
||||||
|
|
||||||
5. **测试部署**:
|
5. **测试部署**:
|
||||||
|
|
||||||
- 连接真实硬件
|
- 连接真实硬件
|
||||||
- 空跑测试
|
- 空跑测试
|
||||||
- 小规模试验
|
- 小规模试验
|
||||||
@@ -1871,7 +1866,7 @@ unilab --ak your_ak --sk your_sk -g graph.json \
|
|||||||
#### 14.5 社区支持
|
#### 14.5 社区支持
|
||||||
|
|
||||||
- **GitHub Issues**:[https://github.com/deepmodeling/Uni-Lab-OS/issues](https://github.com/deepmodeling/Uni-Lab-OS/issues)
|
- **GitHub Issues**:[https://github.com/deepmodeling/Uni-Lab-OS/issues](https://github.com/deepmodeling/Uni-Lab-OS/issues)
|
||||||
- **官方网站**:[https://uni-lab.bohrium.com](https://uni-lab.bohrium.com)
|
- **官方网站**:[https://leap-lab.bohrium.com](https://leap-lab.bohrium.com)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -626,7 +626,7 @@ unilab
|
|||||||
|
|
||||||
**云端图文件管理**:
|
**云端图文件管理**:
|
||||||
|
|
||||||
1. 登录 https://uni-lab.bohrium.com
|
1. 登录 https://leap-lab.bohrium.com
|
||||||
2. 进入"设备配置"
|
2. 进入"设备配置"
|
||||||
3. 创建或编辑配置
|
3. 创建或编辑配置
|
||||||
4. 保存到云端
|
4. 保存到云端
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ Uni-Lab 的启动过程分为以下几个阶段:
|
|||||||
您可以直接跟随 unilabos 的提示进行,无需查阅本节
|
您可以直接跟随 unilabos 的提示进行,无需查阅本节
|
||||||
|
|
||||||
- **工作目录设置**:
|
- **工作目录设置**:
|
||||||
|
|
||||||
- 如果当前目录以 `unilabos_data` 结尾,则使用当前目录
|
- 如果当前目录以 `unilabos_data` 结尾,则使用当前目录
|
||||||
- 否则使用 `当前目录/unilabos_data` 作为工作目录
|
- 否则使用 `当前目录/unilabos_data` 作为工作目录
|
||||||
- 可通过 `--working_dir` 指定自定义工作目录
|
- 可通过 `--working_dir` 指定自定义工作目录
|
||||||
@@ -68,8 +67,8 @@ Uni-Lab 的启动过程分为以下几个阶段:
|
|||||||
|
|
||||||
支持多种后端环境:
|
支持多种后端环境:
|
||||||
|
|
||||||
- `--addr test`:测试环境 (`https://uni-lab.test.bohrium.com/api/v1`)
|
- `--addr test`:测试环境 (`https://leap-lab.test.bohrium.com/api/v1`)
|
||||||
- `--addr uat`:UAT 环境 (`https://uni-lab.uat.bohrium.com/api/v1`)
|
- `--addr uat`:UAT 环境 (`https://leap-lab.uat.bohrium.com/api/v1`)
|
||||||
- `--addr local`:本地环境 (`http://127.0.0.1:48197/api/v1`)
|
- `--addr local`:本地环境 (`http://127.0.0.1:48197/api/v1`)
|
||||||
- 自定义地址:直接指定完整 URL
|
- 自定义地址:直接指定完整 URL
|
||||||
|
|
||||||
@@ -176,7 +175,7 @@ unilab --config path/to/your/config.py
|
|||||||
|
|
||||||
如果是首次使用,系统会:
|
如果是首次使用,系统会:
|
||||||
|
|
||||||
1. 提示前往 https://uni-lab.bohrium.com 注册实验室
|
1. 提示前往 https://leap-lab.bohrium.com 注册实验室
|
||||||
2. 引导创建配置文件
|
2. 引导创建配置文件
|
||||||
3. 设置工作目录
|
3. 设置工作目录
|
||||||
|
|
||||||
@@ -216,7 +215,7 @@ unilab --ak your_ak --sk your_sk --port 8080 --disable_browser
|
|||||||
|
|
||||||
如果提示 "后续运行必须拥有一个实验室",请确保:
|
如果提示 "后续运行必须拥有一个实验室",请确保:
|
||||||
|
|
||||||
- 已在 https://uni-lab.bohrium.com 注册实验室
|
- 已在 https://leap-lab.bohrium.com 注册实验室
|
||||||
- 正确设置了 `--ak` 和 `--sk` 参数
|
- 正确设置了 `--ak` 和 `--sk` 参数
|
||||||
- 配置文件中包含正确的认证信息
|
- 配置文件中包含正确的认证信息
|
||||||
|
|
||||||
|
|||||||
140
labware_mapping.yaml
Normal file
140
labware_mapping.yaml
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
# Opentrons → 目标仪器 物料映射表(P6.1.1)
|
||||||
|
#
|
||||||
|
# 两段顶层 key(P6.1.1 起 slot_remap 从顶层下沉到 target_devices 内):
|
||||||
|
# kinds : labware 字符串 → kind 归类(与目标仪器无关,**保留全局**)
|
||||||
|
# target_devices : 按目标仪器 + 型号组织;rule = kind + hole_count + volume_min/max → class_name;
|
||||||
|
# slot_remap 也内嵌在 target_devices 下(按 deck 物理布局变化)
|
||||||
|
#
|
||||||
|
# target_devices 段内结构:
|
||||||
|
# target_devices.<device>: # 厂商段(必填)
|
||||||
|
# slot_remap: {...} # 厂商级默认 slot 映射(缺失 → 继承 default 段)
|
||||||
|
# rules: [...] # 厂商级规则(缺失 → 继承 default 段)
|
||||||
|
# models: # 同厂商多型号(可选;缺失 = 仅厂商级,不区分型号)
|
||||||
|
# <model_name>: # 型号子段
|
||||||
|
# slot_remap: {...} # 型号级覆盖(缺失 → 继承厂商级)
|
||||||
|
# rules: [...] # 型号级覆盖(缺失 → 继承厂商级)
|
||||||
|
#
|
||||||
|
# 段名约定:
|
||||||
|
# target_devices.default : 兜底物料集 + 兜底 slot_remap。caller 传未声明的 target_device 时使用此段。
|
||||||
|
# **不支持 models 子段**(型号粒度差异必须落到具体仪器段,否则歧义)。
|
||||||
|
# target_devices.<name> : 具体仪器段(prcxi / beckman / tecan ...)。
|
||||||
|
#
|
||||||
|
# 解析链(remap_slot / resolve_target_class 共用,字段级 fallback):
|
||||||
|
# 1. target_devices.<device>.models.<model>.<field> (caller 同时传 device + model)
|
||||||
|
# 2. target_devices.<device>.<field> (caller 传 device,或步骤 1 缺字段)
|
||||||
|
# 3. target_devices.default.<field> (caller 传未声明 device,或步骤 2 缺字段)
|
||||||
|
# 4. _BUILTIN_DEFAULT.target_devices.default.<field> (YAML 误删 default 段时的最后兜底)
|
||||||
|
#
|
||||||
|
# 编辑建议:
|
||||||
|
# 1. 顺序敏感:kinds 与 rules 内首个命中胜出;窄规则在前、宽规则在后。
|
||||||
|
# 2. volume_min / volume_max 是闭区间(µL)。任一字段可省略;都省略 = 不限制体积。
|
||||||
|
# 3. notes 仅作注释,不参与匹配。
|
||||||
|
# 4. 新增目标仪器:复制 target_devices.prcxi 段、改 device 名、改 slot_remap + rules。
|
||||||
|
# 5. 同厂商不同型号:在 target_devices.<device>.models.<model> 下显式覆盖差异字段;
|
||||||
|
# 没声明的字段自动继承厂商级。
|
||||||
|
# 6. P6.1.1 不再支持顶层 slot_remap;检出顶层 slot_remap → warning + fallback 到 builtin。
|
||||||
|
#
|
||||||
|
# 设计文档:product_designs/protocol_convert/06-labware-mapping-table.md(§11.8)
|
||||||
|
|
||||||
|
kinds:
|
||||||
|
# 顺序敏感的 regex;第一个命中胜出
|
||||||
|
# 注意:trash 必须在 tip_rack 之前;tip_rack 必须在 tube_rack 之前("tuberack" 含 "rack")
|
||||||
|
- { pattern: "trash", kind: trash }
|
||||||
|
- { pattern: "tiprack|tip[_ ]?rack|opentrons_\\d+_tiprack", kind: tip_rack }
|
||||||
|
- { pattern: "tuberack|tube[_ ]rack|eppendorf.*rack|safelock.*rack", kind: tube_rack }
|
||||||
|
# 「<labware> 含 'rack' 但不含 'tip'」也归到 tube_rack(与历史 _infer_reagent_kind 行为一致)
|
||||||
|
- { pattern: "(?:^|[^a-z])rack(?:[^a-z]|$)", kind: tube_rack }
|
||||||
|
- { pattern: ".*", kind: plate }
|
||||||
|
|
||||||
|
target_devices:
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
# default:兜底物料集 + 兜底 slot_remap。
|
||||||
|
# caller 传未声明的 target_device 时使用本段;**不支持 models 子段**。
|
||||||
|
# 第一版内容按 prcxi 拷贝填充(值仍是 PRCXI_*),但语义独立,可独立演进。
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
default:
|
||||||
|
notes: "默认兜底物料集;caller 传未声明 target_device 时使用此段。第一版按 prcxi 拷贝填充。"
|
||||||
|
slot_remap:
|
||||||
|
# raw slot → deck slot;与对象类型无关
|
||||||
|
default:
|
||||||
|
"4": "13"
|
||||||
|
"8": "14"
|
||||||
|
# 按 object 字段覆盖 default
|
||||||
|
by_object:
|
||||||
|
trash:
|
||||||
|
"12": "16"
|
||||||
|
rules:
|
||||||
|
# ─ tip rack(默认量程档:≤10 / <300 / 否则 1000) ─
|
||||||
|
- { kind: tip_rack, hole_count: 96, volume_max: 10, class_name: PRCXI_10uL_Tips }
|
||||||
|
- { kind: tip_rack, hole_count: 96, volume_max: 299.9, class_name: PRCXI_300ul_Tips }
|
||||||
|
- { kind: tip_rack, hole_count: 96, class_name: PRCXI_1000uL_Tips }
|
||||||
|
# ─ tube rack ─
|
||||||
|
- { kind: tube_rack, hole_count: 24, class_name: PRCXI_EP_Adapter, notes: "Eppendorf 1.5/2 mL 24 位 4×6" }
|
||||||
|
- { kind: tube_rack, hole_count: 10, class_name: PRCXI_EP_Adapter, notes: "Falcon 4x50 + 6x15 mL(10 位兼容 4×6 适配器)" }
|
||||||
|
# ─ plate ─
|
||||||
|
- { kind: plate, hole_count: 96, class_name: PRCXI_BioER_96_wellplate }
|
||||||
|
- { kind: plate, hole_count: 384, class_name: PRCXI_BioER_384_wellplate }
|
||||||
|
# ─ trash ─
|
||||||
|
- { kind: trash, class_name: PRCXI_trash }
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
# prcxi:PRCXI 仪器专用段。caller 显式传 --target_device prcxi 时命中此段。
|
||||||
|
# 厂商级 slot_remap + rules 适用于"未声明 model"的调用;
|
||||||
|
# models 子段下声明同厂商不同型号的 deck 物理布局差异。
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
prcxi:
|
||||||
|
slot_remap:
|
||||||
|
# PRCXI 多数型号通用的 deck 物理布局映射
|
||||||
|
default:
|
||||||
|
"4": "13"
|
||||||
|
"8": "14"
|
||||||
|
by_object:
|
||||||
|
trash:
|
||||||
|
"12": "16"
|
||||||
|
rules:
|
||||||
|
# ─ tip rack(PRCXI 量程档:≤10 / <300 / 否则 1000) ─
|
||||||
|
- { kind: tip_rack, hole_count: 96, volume_max: 10, class_name: PRCXI_10uL_Tips }
|
||||||
|
- { kind: tip_rack, hole_count: 96, volume_max: 299.9, class_name: PRCXI_300ul_Tips }
|
||||||
|
- { kind: tip_rack, hole_count: 96, class_name: PRCXI_1000uL_Tips }
|
||||||
|
# ─ tube rack ─
|
||||||
|
- { kind: tube_rack, hole_count: 24, class_name: PRCXI_EP_Adapter, notes: "Eppendorf 1.5/2 mL 24 位 4×6" }
|
||||||
|
- { kind: tube_rack, hole_count: 10, class_name: PRCXI_EP_Adapter, notes: "Falcon 4x50 + 6x15 mL(10 位兼容 4×6 适配器)" }
|
||||||
|
# ─ plate ─
|
||||||
|
- { kind: plate, hole_count: 96, class_name: PRCXI_BioER_96_wellplate }
|
||||||
|
- { kind: plate, hole_count: 384, class_name: PRCXI_BioER_384_wellplate }
|
||||||
|
# ─ trash ─
|
||||||
|
- { kind: trash, class_name: PRCXI_trash }
|
||||||
|
models:
|
||||||
|
# PRCXI 9320 —— 与厂商级完全一致(空 dict 仅作为合法 model 名占位)。
|
||||||
|
# caller `--target_model 9320` 时所有字段继承厂商级 prcxi 段。
|
||||||
|
"9320": {}
|
||||||
|
# 演示:假想 PRCXI 4040 把 slot 4 物理位换到 16、trash 槽换到 20。
|
||||||
|
# 仅 slot_remap 不同;rules 与厂商级一致 → 不重复声明(自动继承)。
|
||||||
|
"4040":
|
||||||
|
slot_remap:
|
||||||
|
default:
|
||||||
|
"4": "16"
|
||||||
|
"8": "14"
|
||||||
|
by_object:
|
||||||
|
trash:
|
||||||
|
"12": "20"
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
# 演示:未来加新仪器只复制 prcxi 段、改 device 名 + slot_remap + rules。
|
||||||
|
# 特别注意 tip 量程档可与 PRCXI 不同。
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
# beckman:
|
||||||
|
# slot_remap:
|
||||||
|
# default: {"4": "13"}
|
||||||
|
# by_object: {trash: {"12": "16"}}
|
||||||
|
# rules:
|
||||||
|
# - { kind: tip_rack, hole_count: 96, volume_max: 20, class_name: Beckman_20uL_Tips }
|
||||||
|
# - { kind: tip_rack, hole_count: 96, volume_max: 199.9, class_name: Beckman_200uL_Tips }
|
||||||
|
# - { kind: tip_rack, hole_count: 96, class_name: Beckman_1000uL_Tips }
|
||||||
|
# - { kind: tube_rack, hole_count: 24, class_name: Beckman_24_TubeRack }
|
||||||
|
# - { kind: plate, hole_count: 96, class_name: Beckman_BioMek_96_wellplate }
|
||||||
|
# - { kind: trash, class_name: Beckman_Trash }
|
||||||
|
# models:
|
||||||
|
# "i7":
|
||||||
|
# slot_remap:
|
||||||
|
# default: {"4": "13", "5": "14"} # 假想 i7 多一个 slot 重映射
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
channel_sources:
|
channel_sources:
|
||||||
- robostack,robostack-staging,conda-forge,defaults
|
- robostack,robostack-staging,conda-forge
|
||||||
|
|
||||||
gazebo:
|
gazebo:
|
||||||
- '11'
|
- '11'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package:
|
package:
|
||||||
name: ros-humble-unilabos-msgs
|
name: ros-humble-unilabos-msgs
|
||||||
version: 0.10.19
|
version: 0.11.1
|
||||||
source:
|
source:
|
||||||
path: ../../unilabos_msgs
|
path: ../../unilabos_msgs
|
||||||
target_directory: src
|
target_directory: src
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package:
|
package:
|
||||||
name: unilabos
|
name: unilabos
|
||||||
version: "0.10.19"
|
version: "0.11.1"
|
||||||
|
|
||||||
source:
|
source:
|
||||||
path: ../..
|
path: ../..
|
||||||
|
|||||||
2
setup.py
2
setup.py
@@ -4,7 +4,7 @@ package_name = 'unilabos'
|
|||||||
|
|
||||||
setup(
|
setup(
|
||||||
name=package_name,
|
name=package_name,
|
||||||
version='0.10.19',
|
version='0.11.1',
|
||||||
packages=find_packages(),
|
packages=find_packages(),
|
||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
install_requires=['setuptools'],
|
install_requires=['setuptools'],
|
||||||
|
|||||||
539
test/devices/test_prcxi.py
Normal file
539
test/devices/test_prcxi.py
Normal file
@@ -0,0 +1,539 @@
|
|||||||
|
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"])
|
||||||
15
tests/devices/liquid_handling/README.md
Normal file
15
tests/devices/liquid_handling/README.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# 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 的快速测试。***
|
||||||
|
|
||||||
244
tests/devices/liquid_handling/test_liquid_history.py
Normal file
244
tests/devices/liquid_handling/test_liquid_history.py
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
"""P9 — ``liquid_history`` schema v3 + helper 单元测试。
|
||||||
|
|
||||||
|
测试覆盖:
|
||||||
|
- :func:`append_liquid_history`:写 v3 entry / tracker 缺失 graceful / 滚动上限
|
||||||
|
- :func:`normalize_liquid_history`:v3 dict / v2 tuple / list[str] / 混合 / 非法
|
||||||
|
- :func:`well_current_liquid_name`:tracker.liquids 末项 / get_liquids fallback / 缺失
|
||||||
|
|
||||||
|
注:``LiquidHandlerAbstract.set_liquid`` 写 history 的集成("set" action)覆盖
|
||||||
|
逻辑相同(直接调用 :func:`append_liquid_history`),由本测试间接验证;端到端走 PLR
|
||||||
|
真实 ``Well.set_liquids`` 的集成测试在 ``tests/devices/liquid_handling/unit_test.py``
|
||||||
|
范围内随 PLR 环境就绪后增补,本 P9 提交保持解耦。
|
||||||
|
|
||||||
|
详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md`` §8。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, List, Tuple
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# liquid_history 模块**不依赖** pylabrobot,可在 PLR 环境缺失时独立 import / 单测。
|
||||||
|
from unilabos.devices.liquid_handling.liquid_history import (
|
||||||
|
LIQUID_HISTORY_MAX_ENTRIES,
|
||||||
|
LiquidHistoryEntry,
|
||||||
|
append_liquid_history,
|
||||||
|
normalize_liquid_history,
|
||||||
|
well_current_liquid_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures:DummyTracker / DummyWell(避免引入真实 PLR Well/VolumeTracker 依赖)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DummyTracker:
|
||||||
|
"""模拟 PLR VolumeTracker:仅暴露 P9 hook 关心的字段。"""
|
||||||
|
|
||||||
|
liquid_history: List[Any] = field(default_factory=list)
|
||||||
|
liquids: List[Tuple[Any, float]] = field(default_factory=list)
|
||||||
|
max_volume: float = 200.0
|
||||||
|
is_disabled: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DummyWell:
|
||||||
|
"""模拟 PLR Well:仅暴露 ``tracker``。"""
|
||||||
|
|
||||||
|
name: str = "well_A1"
|
||||||
|
max_volume: float = 200.0
|
||||||
|
tracker: DummyTracker = field(default_factory=DummyTracker)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# append_liquid_history
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppendLiquidHistory:
|
||||||
|
def test_append_creates_v3_entry(self) -> None:
|
||||||
|
well = DummyWell()
|
||||||
|
append_liquid_history(well, "Plasma", 100.0, "set")
|
||||||
|
|
||||||
|
assert len(well.tracker.liquid_history) == 1
|
||||||
|
entry = well.tracker.liquid_history[0]
|
||||||
|
assert entry["name"] == "Plasma"
|
||||||
|
assert entry["volume"] == 100.0
|
||||||
|
assert entry["action"] == "set"
|
||||||
|
assert "timestamp" in entry and isinstance(entry["timestamp"], str)
|
||||||
|
|
||||||
|
def test_append_aspirate_negative_volume(self) -> None:
|
||||||
|
well = DummyWell()
|
||||||
|
append_liquid_history(well, "Water", -50.0, "aspirate")
|
||||||
|
|
||||||
|
assert well.tracker.liquid_history[0]["volume"] == -50.0
|
||||||
|
assert well.tracker.liquid_history[0]["action"] == "aspirate"
|
||||||
|
|
||||||
|
def test_append_with_empty_name_keeps_empty_string(self) -> None:
|
||||||
|
"""name 为空时应写入 ``""`` 而非字面 "unknown"(避免视觉混淆 bottom_type)。"""
|
||||||
|
well = DummyWell()
|
||||||
|
append_liquid_history(well, "", 50.0, "dispense")
|
||||||
|
|
||||||
|
assert well.tracker.liquid_history[0]["name"] == ""
|
||||||
|
|
||||||
|
def test_append_with_none_name_normalized_to_empty_string(self) -> None:
|
||||||
|
well = DummyWell()
|
||||||
|
append_liquid_history(well, None, 50.0, "dispense") # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert well.tracker.liquid_history[0]["name"] == ""
|
||||||
|
|
||||||
|
def test_append_initializes_history_if_missing(self) -> None:
|
||||||
|
"""tracker 没有 liquid_history 属性时 helper 自动创建空 list 并写入。"""
|
||||||
|
well = DummyWell()
|
||||||
|
del well.tracker.liquid_history # 模拟全新 PLR tracker
|
||||||
|
append_liquid_history(well, "X", 10.0, "set")
|
||||||
|
|
||||||
|
assert hasattr(well.tracker, "liquid_history")
|
||||||
|
assert len(well.tracker.liquid_history) == 1
|
||||||
|
|
||||||
|
def test_append_no_tracker_is_graceful(self) -> None:
|
||||||
|
"""well 无 tracker 时静默不抛(保护主流程)。"""
|
||||||
|
|
||||||
|
class NoTrackerWell:
|
||||||
|
name = "no_tracker"
|
||||||
|
|
||||||
|
well = NoTrackerWell()
|
||||||
|
append_liquid_history(well, "X", 10.0, "set") # 不应抛
|
||||||
|
assert not hasattr(well, "tracker")
|
||||||
|
|
||||||
|
def test_append_action_defaults_to_legacy_when_empty(self) -> None:
|
||||||
|
well = DummyWell()
|
||||||
|
append_liquid_history(well, "X", 1.0, "")
|
||||||
|
|
||||||
|
assert well.tracker.liquid_history[0]["action"] == "legacy"
|
||||||
|
|
||||||
|
def test_append_respects_max_entries_rolling(self) -> None:
|
||||||
|
"""超过 ``LIQUID_HISTORY_MAX_ENTRIES`` 时丢弃头部,保留最近 entries。"""
|
||||||
|
well = DummyWell()
|
||||||
|
well.tracker.liquid_history = [
|
||||||
|
{"name": f"old_{i}"} for i in range(LIQUID_HISTORY_MAX_ENTRIES + 5)
|
||||||
|
]
|
||||||
|
append_liquid_history(well, "newest", 1.0, "set")
|
||||||
|
|
||||||
|
assert len(well.tracker.liquid_history) == LIQUID_HISTORY_MAX_ENTRIES
|
||||||
|
assert well.tracker.liquid_history[-1]["name"] == "newest"
|
||||||
|
assert well.tracker.liquid_history[0]["name"] != "old_0"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# normalize_liquid_history
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeLiquidHistory:
|
||||||
|
def test_v3_dict_passthrough_with_field_defaults(self) -> None:
|
||||||
|
raw = [{"name": "A", "volume": 100, "action": "set", "timestamp": "2026-05-22T00:00:00Z"}]
|
||||||
|
result = normalize_liquid_history(raw)
|
||||||
|
|
||||||
|
assert result == [{
|
||||||
|
"name": "A",
|
||||||
|
"volume": 100.0,
|
||||||
|
"action": "set",
|
||||||
|
"timestamp": "2026-05-22T00:00:00Z",
|
||||||
|
}]
|
||||||
|
|
||||||
|
def test_v3_dict_missing_optional_fields_filled_with_defaults(self) -> None:
|
||||||
|
raw = [{"name": "A"}]
|
||||||
|
result = normalize_liquid_history(raw)
|
||||||
|
|
||||||
|
assert result == [{"name": "A", "volume": 0.0, "action": "legacy"}]
|
||||||
|
assert "timestamp" not in result[0]
|
||||||
|
|
||||||
|
def test_v2_tuple_upgraded_to_v3_legacy(self) -> None:
|
||||||
|
raw = [("A", 100), ("B", 50.5)]
|
||||||
|
result = normalize_liquid_history(raw)
|
||||||
|
|
||||||
|
assert result == [
|
||||||
|
{"name": "A", "volume": 100.0, "action": "legacy"},
|
||||||
|
{"name": "B", "volume": 50.5, "action": "legacy"},
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_list_of_strings_upgraded(self) -> None:
|
||||||
|
raw = ["A", "B"]
|
||||||
|
result = normalize_liquid_history(raw)
|
||||||
|
|
||||||
|
assert result == [
|
||||||
|
{"name": "A", "volume": 0.0, "action": "legacy"},
|
||||||
|
{"name": "B", "volume": 0.0, "action": "legacy"},
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_mixed_input_normalized(self) -> None:
|
||||||
|
raw = [
|
||||||
|
{"name": "A", "volume": 1, "action": "set"},
|
||||||
|
("B", 2),
|
||||||
|
"C",
|
||||||
|
]
|
||||||
|
result = normalize_liquid_history(raw)
|
||||||
|
|
||||||
|
assert [e["name"] for e in result] == ["A", "B", "C"]
|
||||||
|
assert [e["action"] for e in result] == ["set", "legacy", "legacy"]
|
||||||
|
|
||||||
|
def test_invalid_entries_dropped(self) -> None:
|
||||||
|
raw = [42, None, {"name": "A"}, ("only_one",)]
|
||||||
|
result = normalize_liquid_history(raw)
|
||||||
|
|
||||||
|
# 只保留 {"name": "A"} 这一条;其它都被丢弃
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["name"] == "A"
|
||||||
|
assert result[0]["volume"] == 0.0 # 缺省补 0
|
||||||
|
|
||||||
|
def test_non_list_input_returns_empty(self) -> None:
|
||||||
|
assert normalize_liquid_history(None) == []
|
||||||
|
assert normalize_liquid_history("not_a_list") == []
|
||||||
|
assert normalize_liquid_history({"name": "X"}) == []
|
||||||
|
|
||||||
|
def test_tuple_with_unconvertible_volume_falls_back_to_zero(self) -> None:
|
||||||
|
raw = [("A", "not_a_number")]
|
||||||
|
result = normalize_liquid_history(raw)
|
||||||
|
|
||||||
|
assert result[0]["volume"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# well_current_liquid_name
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestWellCurrentLiquidName:
|
||||||
|
def test_returns_last_liquid_name_from_tuple(self) -> None:
|
||||||
|
well = DummyWell()
|
||||||
|
well.tracker.liquids = [("Water", 50.0), ("Plasma", 100.0)]
|
||||||
|
assert well_current_liquid_name(well) == "Plasma"
|
||||||
|
|
||||||
|
def test_returns_enum_like_name_attr(self) -> None:
|
||||||
|
class FakeLiquid:
|
||||||
|
name = "ETHANOL"
|
||||||
|
|
||||||
|
well = DummyWell()
|
||||||
|
well.tracker.liquids = [(FakeLiquid(), 100.0)]
|
||||||
|
assert well_current_liquid_name(well) == "ETHANOL"
|
||||||
|
|
||||||
|
def test_empty_liquids_returns_empty_string(self) -> None:
|
||||||
|
well = DummyWell()
|
||||||
|
well.tracker.liquids = []
|
||||||
|
assert well_current_liquid_name(well) == ""
|
||||||
|
|
||||||
|
def test_no_tracker_returns_empty_string(self) -> None:
|
||||||
|
class NoTrackerWell:
|
||||||
|
name = "x"
|
||||||
|
|
||||||
|
assert well_current_liquid_name(NoTrackerWell()) == ""
|
||||||
|
|
||||||
|
def test_none_liquid_returns_empty_string(self) -> None:
|
||||||
|
well = DummyWell()
|
||||||
|
well.tracker.liquids = [(None, 100.0)]
|
||||||
|
assert well_current_liquid_name(well) == ""
|
||||||
|
|
||||||
|
def test_string_liquid_returned_as_is(self) -> None:
|
||||||
|
well = DummyWell()
|
||||||
|
well.tracker.liquids = ["Saline"]
|
||||||
|
assert well_current_liquid_name(well) == "Saline"
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
"""P2 v2 跨板能力验证 —— device 层 ``set_liquid_from_plate`` 单测。
|
||||||
|
|
||||||
|
对应 ``product_designs/protocol_convert/02-cross-slot-merge.md`` §9.1 / §9.5 step 6.3。
|
||||||
|
|
||||||
|
本测试聚焦于 **`_set_liquid_grouped_by_plate`** 已天然支持跨板 wells 的能力(v2 设计
|
||||||
|
的核心依据):
|
||||||
|
|
||||||
|
- 输入 ``wells`` 列表来自多个 plate(每板各一/多个 well)时,``set_liquid`` 应按 plate
|
||||||
|
分桶串行调用,每板一次(plate-bucket 顺序按 first-occurrence)。
|
||||||
|
- 同板内多孔归到同一桶。
|
||||||
|
- 返回 ``volumes`` 按 **输入 index 顺序**回拼,与 wells 一致 —— 这是 v2 Stage 3
|
||||||
|
merged ``set_liquid_from_plate.output_wells`` 的顺序权威来源。
|
||||||
|
- ``Well.set_liquids`` 在 ``set_liquid`` 链内被逐孔调用,与 PLR 实现的预期接口一致。
|
||||||
|
|
||||||
|
为了避免引入完整 PLR 资源树,测试用 duck-typed ``DummyWell`` / ``DummyPlate`` +
|
||||||
|
``ResourceTreeSet`` 的 monkeypatch(dump 直接返回输入列表)。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import List, Tuple
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 跨环境兼容:与现有 ``tests/devices/liquid_handling/test_transfer_liquid.py`` 一致,
|
||||||
|
# 本测试通过 import ``unilabos.devices.liquid_handling.liquid_handler_abstract``
|
||||||
|
# 拉起 pylabrobot 链;某些本地开发机的 pylabrobot 版本与代码库要求不一致,
|
||||||
|
# 会在 import 阶段抛 ``ImportError``。这里用 ``importorskip`` 优雅跳过,让
|
||||||
|
# CI(统一 pylabrobot 版本)跑全;纯逻辑测试(Stage 2 / Stage 3)不受影响。
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
LiquidHandlerAbstract = pytest.importorskip(
|
||||||
|
"unilabos.devices.liquid_handling.liquid_handler_abstract",
|
||||||
|
reason="pylabrobot 链未完整可用,跳过 device 单测;CI 上请保证 pylabrobot ≥ 项目要求版本",
|
||||||
|
exc_type=ImportError,
|
||||||
|
).LiquidHandlerAbstract
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Duck-typed PLR-like 资源 ====================
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DummyPlate:
|
||||||
|
name: str
|
||||||
|
|
||||||
|
def __repr__(self) -> str: # pragma: no cover
|
||||||
|
return f"DummyPlate({self.name})"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DummyWell:
|
||||||
|
name: str
|
||||||
|
parent: DummyPlate
|
||||||
|
max_volume: float = 1000.0
|
||||||
|
liquid_history: List[Tuple[str, float]] = field(default_factory=list)
|
||||||
|
|
||||||
|
def set_liquids(self, items):
|
||||||
|
"""模拟 PLR ``Well.set_liquids([(name, vol), ...])`` 接口。"""
|
||||||
|
for name, vol in items:
|
||||||
|
self.liquid_history.append((str(name), float(vol)))
|
||||||
|
|
||||||
|
def __repr__(self) -> str: # pragma: no cover
|
||||||
|
return f"DummyWell({self.parent.name}/{self.name})"
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== fixture:装一台 FakeLiquidHandler ====================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def patched_resource_tree(monkeypatch):
|
||||||
|
"""patch ``ResourceTreeSet.from_plr_resources`` 使其接受 duck-typed wells/plates。
|
||||||
|
|
||||||
|
返回的对象只要带 ``.dump()`` 即可(``_set_liquid_grouped_by_plate`` 仅消费该方法)。
|
||||||
|
"""
|
||||||
|
from unilabos.devices.liquid_handling import liquid_handler_abstract as lha
|
||||||
|
|
||||||
|
class _FakeTree:
|
||||||
|
def __init__(self, items):
|
||||||
|
self._items = items
|
||||||
|
|
||||||
|
def dump(self):
|
||||||
|
return [
|
||||||
|
{"name": getattr(x, "name", None), "type": type(x).__name__}
|
||||||
|
for x in self._items
|
||||||
|
]
|
||||||
|
|
||||||
|
def _fake_from_plr_resources(items, known_newly_created=False): # noqa: ARG001
|
||||||
|
return _FakeTree(list(items))
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
lha.ResourceTreeSet,
|
||||||
|
"from_plr_resources",
|
||||||
|
staticmethod(_fake_from_plr_resources),
|
||||||
|
)
|
||||||
|
return lha
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def handler(patched_resource_tree):
|
||||||
|
"""构造一台最小 LiquidHandlerAbstract 实例,绕过真实 backend / deck。"""
|
||||||
|
|
||||||
|
class _FakeHandler(LiquidHandlerAbstract):
|
||||||
|
def __init__(self):
|
||||||
|
# 不调用 super().__init__,避免真实硬件/后端依赖
|
||||||
|
self.channel_num = 8
|
||||||
|
self.support_touch_tip = True
|
||||||
|
|
||||||
|
return _FakeHandler()
|
||||||
|
|
||||||
|
|
||||||
|
def _wells_grid(plate_name: str, well_names: List[str]) -> List[DummyWell]:
|
||||||
|
plate = DummyPlate(name=plate_name)
|
||||||
|
return [DummyWell(name=w, parent=plate) for w in well_names]
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 用例 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def test_grouped_by_plate_single_plate_set_liquid_inline(handler):
|
||||||
|
"""单 plate 多孔:set_liquids 按 wells 顺序逐项调用,volumes 回拼一致。"""
|
||||||
|
wells = _wells_grid("plate_slot2", ["A1", "A2", "A3"])
|
||||||
|
ret = handler._set_liquid_grouped_by_plate(
|
||||||
|
wells=wells,
|
||||||
|
liquid_names=["reagent_X"] * 3,
|
||||||
|
volumes=[10.0, 20.0, 30.0],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 每个 well 的 liquid_history 各 1 条
|
||||||
|
for w, expected_vol in zip(wells, [10.0, 20.0, 30.0]):
|
||||||
|
assert w.liquid_history == [("reagent_X", expected_vol)]
|
||||||
|
|
||||||
|
# 返回 volumes 顺序与输入一致
|
||||||
|
assert ret.volumes == [10.0, 20.0, 30.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_grouped_by_plate_cross_plate_buckets_by_parent(handler):
|
||||||
|
"""跨板 wells 列表 → 按 first-occurrence plate 顺序分桶,每板单独 set_liquid。
|
||||||
|
|
||||||
|
51b9a5 简化(每板 1 孔):4 plate × 1 well = 4 set_liquids 调用。
|
||||||
|
"""
|
||||||
|
p2 = _wells_grid("plate_slot2", ["A1"])
|
||||||
|
p3 = _wells_grid("plate_slot3", ["A1"])
|
||||||
|
p5 = _wells_grid("plate_slot5", ["A1"])
|
||||||
|
p6 = _wells_grid("plate_slot6", ["A1"])
|
||||||
|
wells = p2 + p3 + p5 + p6
|
||||||
|
|
||||||
|
ret = handler._set_liquid_grouped_by_plate(
|
||||||
|
wells=wells,
|
||||||
|
liquid_names=["l1"] * 4,
|
||||||
|
volumes=[8.3] * 4,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 每个 well 都被 set_liquids 设过
|
||||||
|
for w in wells:
|
||||||
|
assert w.liquid_history == [("l1", 8.3)], f"well {w.parent.name}/{w.name} 未正确设液"
|
||||||
|
|
||||||
|
# volumes 顺序与输入对齐
|
||||||
|
assert ret.volumes == [8.3, 8.3, 8.3, 8.3]
|
||||||
|
|
||||||
|
# plate dump 应含 4 个 plate(按 first-occurrence)
|
||||||
|
plate_dump = ret.plate
|
||||||
|
plate_names = [p["name"] for p in plate_dump]
|
||||||
|
assert plate_names == ["plate_slot2", "plate_slot3", "plate_slot5", "plate_slot6"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_grouped_by_plate_interleaved_cross_plate_preserves_input_order(handler):
|
||||||
|
"""交错跨板:wells=[p2.A1, p3.A1, p2.A2, p5.A1] → volumes 顺序按输入回拼。
|
||||||
|
|
||||||
|
内部仍按 plate 分桶执行 set_liquid(per-plate 串行),但返回顺序遵循输入 index。
|
||||||
|
"""
|
||||||
|
p2 = DummyPlate(name="plate_slot2")
|
||||||
|
p3 = DummyPlate(name="plate_slot3")
|
||||||
|
p5 = DummyPlate(name="plate_slot5")
|
||||||
|
w_p2_a1 = DummyWell(name="A1", parent=p2)
|
||||||
|
w_p2_a2 = DummyWell(name="A2", parent=p2)
|
||||||
|
w_p3_a1 = DummyWell(name="A1", parent=p3)
|
||||||
|
w_p5_a1 = DummyWell(name="A1", parent=p5)
|
||||||
|
|
||||||
|
wells = [w_p2_a1, w_p3_a1, w_p2_a2, w_p5_a1]
|
||||||
|
ret = handler._set_liquid_grouped_by_plate(
|
||||||
|
wells=wells,
|
||||||
|
liquid_names=["l1"] * 4,
|
||||||
|
volumes=[10.0, 20.0, 30.0, 40.0],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 每个 well 都被设液
|
||||||
|
assert w_p2_a1.liquid_history == [("l1", 10.0)]
|
||||||
|
assert w_p3_a1.liquid_history == [("l1", 20.0)]
|
||||||
|
assert w_p2_a2.liquid_history == [("l1", 30.0)]
|
||||||
|
assert w_p5_a1.liquid_history == [("l1", 40.0)]
|
||||||
|
|
||||||
|
# 返回 volumes 严格按输入 index 顺序回拼
|
||||||
|
assert ret.volumes == [10.0, 20.0, 30.0, 40.0]
|
||||||
|
|
||||||
|
# plate dump:按 first-occurrence(plate_slot2 第 1 次出现于 idx=0,plate_slot3 idx=1,plate_slot5 idx=3)
|
||||||
|
plate_names = [p["name"] for p in ret.plate]
|
||||||
|
assert plate_names == ["plate_slot2", "plate_slot3", "plate_slot5"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_grouped_by_plate_volumes_clamped_to_max_volume(handler):
|
||||||
|
"""``set_liquid`` 会按 ``max_volume`` 做 clamp,防止初始化液量超容器容量。"""
|
||||||
|
plate = DummyPlate(name="plate_slot2")
|
||||||
|
well = DummyWell(name="A1", parent=plate, max_volume=200.0)
|
||||||
|
|
||||||
|
ret = handler._set_liquid_grouped_by_plate(
|
||||||
|
wells=[well],
|
||||||
|
liquid_names=["overflow"],
|
||||||
|
volumes=[500.0], # 超过 max_volume=200
|
||||||
|
)
|
||||||
|
|
||||||
|
assert well.liquid_history == [("overflow", 200.0)]
|
||||||
|
assert ret.volumes == [200.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_grouped_by_plate_empty_names_short_circuit(handler):
|
||||||
|
"""``liquid_names`` 与 ``volumes`` 均为空:早返回,wells 列表回显但不设液。"""
|
||||||
|
wells = _wells_grid("plate_slot2", ["A1", "A2"])
|
||||||
|
ret = handler._set_liquid_grouped_by_plate(
|
||||||
|
wells=wells,
|
||||||
|
liquid_names=[],
|
||||||
|
volumes=[],
|
||||||
|
)
|
||||||
|
# 不调用 set_liquids
|
||||||
|
assert all(w.liquid_history == [] for w in wells)
|
||||||
|
assert ret.volumes == []
|
||||||
|
# wells dump 仍返回输入列表
|
||||||
|
assert [w["name"] for w in ret.wells] == ["A1", "A2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_grouped_by_plate_length_mismatch_raises(handler):
|
||||||
|
"""wells / liquid_names / volumes 长度不一致应直接 raise(防御性校验)。"""
|
||||||
|
wells = _wells_grid("plate_slot2", ["A1", "A2"])
|
||||||
|
with pytest.raises(ValueError, match=r"必须等长"):
|
||||||
|
handler._set_liquid_grouped_by_plate(
|
||||||
|
wells=wells,
|
||||||
|
liquid_names=["r"] * 2,
|
||||||
|
volumes=[10.0], # 长度 1,不匹配
|
||||||
|
)
|
||||||
566
tests/devices/liquid_handling/test_tip_reuse_by_liquid_name.py
Normal file
566
tests/devices/liquid_handling/test_tip_reuse_by_liquid_name.py
Normal file
@@ -0,0 +1,566 @@
|
|||||||
|
"""P10 v2 — Tip 复用 ``tracker.liquids`` 等价规则单元测试。
|
||||||
|
|
||||||
|
测试覆盖(详见 ``product_designs/protocol_convert/10-tip-reuse-by-liquid-history.md`` §5):
|
||||||
|
|
||||||
|
- Helper:``is_known_liquid_name`` / ``same_liquid_via_liquids`` /
|
||||||
|
``same_liquid_via_liquids_pair`` / ``capture_tip_liquid_name``(4 helper
|
||||||
|
位于 ``liquid_history.py``,PLR-free 模块)。
|
||||||
|
- 单通道 transfer_liquid 主循环:identity-keep / liquids-keep / 配置开关 /
|
||||||
|
未知 name 保守换 tip / aspirate 顶层归零时序。
|
||||||
|
- 8 通道分支:段锚孔 liquids-keep。
|
||||||
|
- 跨节点边界:两个独立 transfer_liquid 调用状态隔离。
|
||||||
|
|
||||||
|
helper 测试独立于 PLR,可在 ``pylabrobot`` 缺失环境下单独运行;端到端
|
||||||
|
``transfer_liquid`` 主循环测试需要 PLR 环境(沿用 ``test_transfer_liquid.py`` 的
|
||||||
|
``FakeLiquidHandler`` 模式:跳过 ``super().__init__``,仅 stub 4 类方法记录调用)。
|
||||||
|
若 PLR import 失败则自动 skip 端到端测试,保留 helper 测试结果。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Iterable, List, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# P10 v2 helper 位于 PLR-free 模块,无论 pylabrobot 是否安装都能 import。
|
||||||
|
from unilabos.devices.liquid_handling.liquid_history import (
|
||||||
|
capture_tip_liquid_name,
|
||||||
|
is_known_liquid_name,
|
||||||
|
same_liquid_via_liquids,
|
||||||
|
same_liquid_via_liquids_pair,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 端到端测试依赖 PLR 完整环境;若 import 失败(例如本地 PLR 版本不匹配),
|
||||||
|
# 整段端到端测试自动 skip,但 helper 测试照常执行。
|
||||||
|
try:
|
||||||
|
from unilabos.devices.liquid_handling.liquid_handler_abstract import (
|
||||||
|
LiquidHandlerAbstract,
|
||||||
|
)
|
||||||
|
|
||||||
|
_PLR_AVAILABLE = True
|
||||||
|
_PLR_IMPORT_ERROR: Optional[Exception] = None
|
||||||
|
except Exception as exc: # pragma: no cover - 环境相关
|
||||||
|
LiquidHandlerAbstract = None # type: ignore[assignment, misc]
|
||||||
|
_PLR_AVAILABLE = False
|
||||||
|
_PLR_IMPORT_ERROR = exc
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures:DummyTracker / DummyWell / DummyTipSpot / FakeLiquidHandler
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DummyTracker:
|
||||||
|
"""模拟 PLR ``VolumeTracker``:仅暴露 P10 v2 关心的 ``liquids`` 字段。"""
|
||||||
|
|
||||||
|
liquids: List[Tuple[Any, float]] = field(default_factory=list)
|
||||||
|
max_volume: float = 200.0
|
||||||
|
is_disabled: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DummyWell:
|
||||||
|
"""模拟 PLR ``Well``:仅暴露 ``tracker``。"""
|
||||||
|
|
||||||
|
name: str = "well"
|
||||||
|
tracker: DummyTracker = field(default_factory=DummyTracker)
|
||||||
|
|
||||||
|
def __repr__(self) -> str: # pragma: no cover
|
||||||
|
return f"DummyWell({self.name})"
|
||||||
|
|
||||||
|
|
||||||
|
def make_well(name: str, liquid_name: Optional[str] = None, vol: float = 100.0) -> DummyWell:
|
||||||
|
"""构造一个 well;若指定 ``liquid_name`` 则写入 ``tracker.liquids`` 顶层。"""
|
||||||
|
well = DummyWell(name=name, tracker=DummyTracker())
|
||||||
|
if liquid_name is not None:
|
||||||
|
well.tracker.liquids = [(liquid_name, vol)]
|
||||||
|
return well
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DummyTipSpot:
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
def make_tip_iter(n: int = 256) -> Iterable[List[DummyTipSpot]]:
|
||||||
|
for i in range(n):
|
||||||
|
yield [DummyTipSpot(f"tip_{i}")]
|
||||||
|
|
||||||
|
|
||||||
|
# E2E 测试用的 base:PLR 可用时是 ``LiquidHandlerAbstract``,否则 fallback 到
|
||||||
|
# ``object`` 让模块仍能 import;带 ``LiquidHandlerAbstract`` 的 e2e 测试用
|
||||||
|
# ``skipif`` 跳过。
|
||||||
|
_FakeBase = LiquidHandlerAbstract if _PLR_AVAILABLE else object
|
||||||
|
|
||||||
|
|
||||||
|
class FakeLiquidHandler(_FakeBase): # type: ignore[misc, valid-type]
|
||||||
|
"""不初始化真实 backend/deck;仅记录 transfer_liquid 内部 4 类调用序列。
|
||||||
|
|
||||||
|
P10 v2 测试关心 ``pick_up_tips`` / ``discard_tips`` 的触发次数 + 顺序,
|
||||||
|
以推断 tip 是否被复用(一次 pick_up_tips 多次 aspirate/dispense → 复用)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, channel_num: int = 1, tip_reuse_by_liquid_name: bool = True):
|
||||||
|
# 不调用 super().__init__,避免硬件 / ROS / PLR Deck 初始化。
|
||||||
|
self.channel_num = channel_num
|
||||||
|
self.support_touch_tip = True
|
||||||
|
self.current_tip = iter(make_tip_iter(2048))
|
||||||
|
self.calls: List[Tuple[str, Any]] = []
|
||||||
|
self._tip_reuse_by_liquid_name: bool = tip_reuse_by_liquid_name
|
||||||
|
|
||||||
|
def set_tiprack(self, tip_racks):
|
||||||
|
if not tip_racks:
|
||||||
|
return
|
||||||
|
# 跳过真实 set_tiprack(依赖 PLR Deck)
|
||||||
|
return
|
||||||
|
|
||||||
|
async def pick_up_tips(self, tip_spots, use_channels=None, offsets=None, **kw):
|
||||||
|
self.calls.append(("pick_up_tips", {"tips": list(tip_spots), "use_channels": use_channels}))
|
||||||
|
|
||||||
|
async def aspirate(
|
||||||
|
self,
|
||||||
|
resources: Sequence[Any],
|
||||||
|
vols: List[float],
|
||||||
|
use_channels: Optional[List[int]] = None,
|
||||||
|
flow_rates: Optional[List[Optional[float]]] = None,
|
||||||
|
offsets: Any = None,
|
||||||
|
liquid_height: Any = None,
|
||||||
|
blow_out_air_volume: Any = None,
|
||||||
|
spread: str = "wide",
|
||||||
|
**backend_kwargs,
|
||||||
|
):
|
||||||
|
self.calls.append(
|
||||||
|
("aspirate", {"resources": list(resources), "vols": list(vols)})
|
||||||
|
)
|
||||||
|
|
||||||
|
async def dispense(
|
||||||
|
self,
|
||||||
|
resources: Sequence[Any],
|
||||||
|
vols: List[float],
|
||||||
|
use_channels: Optional[List[int]] = None,
|
||||||
|
flow_rates: Optional[List[Optional[float]]] = None,
|
||||||
|
offsets: Any = None,
|
||||||
|
liquid_height: Any = None,
|
||||||
|
blow_out_air_volume: Any = None,
|
||||||
|
spread: str = "wide",
|
||||||
|
**backend_kwargs,
|
||||||
|
):
|
||||||
|
self.calls.append(
|
||||||
|
("dispense", {"resources": list(resources), "vols": list(vols)})
|
||||||
|
)
|
||||||
|
|
||||||
|
async def discard_tips(self, use_channels=None, *args, **kwargs):
|
||||||
|
self.calls.append(("discard_tips", {"use_channels": use_channels}))
|
||||||
|
|
||||||
|
|
||||||
|
class AspiratePopFakeLiquidHandler(FakeLiquidHandler):
|
||||||
|
"""T11 专用:aspirate 时模拟 PLR "顶层归零时 pop ``tracker.liquids`` 顶层" 的行为。
|
||||||
|
|
||||||
|
用于验证 P10 v2 的关键时序约束:tip name 必须在 aspirate **之前**预读,
|
||||||
|
否则 aspirate 后再读 ``tracker.liquids[-1]`` 会拿不到液体身份。
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def aspirate(self, resources, vols, **kwargs):
|
||||||
|
await super().aspirate(resources, vols, **kwargs)
|
||||||
|
# 模拟 PLR 顶层归零时 pop:对每个 source well,若 liquids 非空则 pop 顶层
|
||||||
|
for r in resources:
|
||||||
|
tracker = getattr(r, "tracker", None)
|
||||||
|
if tracker is not None and tracker.liquids:
|
||||||
|
tracker.liquids.pop()
|
||||||
|
|
||||||
|
|
||||||
|
def run(coro):
|
||||||
|
return asyncio.run(coro)
|
||||||
|
|
||||||
|
|
||||||
|
def call_names(lh: FakeLiquidHandler) -> List[str]:
|
||||||
|
return [c[0] for c in lh.calls]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper 单元测试
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsKnownLiquidName:
|
||||||
|
def test_empty_string_is_unknown(self) -> None:
|
||||||
|
assert is_known_liquid_name("") is False
|
||||||
|
|
||||||
|
def test_none_is_unknown(self) -> None:
|
||||||
|
assert is_known_liquid_name(None) is False
|
||||||
|
|
||||||
|
def test_literal_unknown_is_unknown(self) -> None:
|
||||||
|
assert is_known_liquid_name("unknown") is False
|
||||||
|
assert is_known_liquid_name("UNKNOWN") is False
|
||||||
|
assert is_known_liquid_name(" Unknown ") is False
|
||||||
|
|
||||||
|
def test_literal_none_string_is_unknown(self) -> None:
|
||||||
|
assert is_known_liquid_name("none") is False
|
||||||
|
assert is_known_liquid_name("None") is False
|
||||||
|
|
||||||
|
def test_real_liquid_name_is_known(self) -> None:
|
||||||
|
assert is_known_liquid_name("PBS") is True
|
||||||
|
assert is_known_liquid_name("Tris HCl") is True
|
||||||
|
assert is_known_liquid_name("Liquid_3") is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestSameLiquidViaLiquids:
|
||||||
|
def test_well_and_tip_same_name_match(self) -> None:
|
||||||
|
well = make_well("A1", "PBS")
|
||||||
|
assert same_liquid_via_liquids(well, "PBS") is True
|
||||||
|
|
||||||
|
def test_well_and_tip_different_names_no_match(self) -> None:
|
||||||
|
well = make_well("A1", "PBS")
|
||||||
|
assert same_liquid_via_liquids(well, "Tris HCl") is False
|
||||||
|
|
||||||
|
def test_tip_unknown_returns_false(self) -> None:
|
||||||
|
well = make_well("A1", "PBS")
|
||||||
|
assert same_liquid_via_liquids(well, None) is False
|
||||||
|
assert same_liquid_via_liquids(well, "") is False
|
||||||
|
assert same_liquid_via_liquids(well, "unknown") is False
|
||||||
|
|
||||||
|
def test_well_empty_liquids_returns_false(self) -> None:
|
||||||
|
well = make_well("A1", liquid_name=None) # 不写 liquids
|
||||||
|
assert same_liquid_via_liquids(well, "PBS") is False
|
||||||
|
|
||||||
|
def test_well_unknown_literal_returns_false(self) -> None:
|
||||||
|
well = make_well("A1", "unknown")
|
||||||
|
assert same_liquid_via_liquids(well, "unknown") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestSameLiquidViaLiquidsPair:
|
||||||
|
def test_two_wells_same_name_match(self) -> None:
|
||||||
|
a = make_well("A1", "PBS")
|
||||||
|
b = make_well("B1", "PBS")
|
||||||
|
assert same_liquid_via_liquids_pair(a, b) is True
|
||||||
|
|
||||||
|
def test_two_wells_different_names_no_match(self) -> None:
|
||||||
|
a = make_well("A1", "PBS")
|
||||||
|
b = make_well("B1", "Tris HCl")
|
||||||
|
assert same_liquid_via_liquids_pair(a, b) is False
|
||||||
|
|
||||||
|
def test_either_well_empty_returns_false(self) -> None:
|
||||||
|
a = make_well("A1", "PBS")
|
||||||
|
b = make_well("B1", liquid_name=None)
|
||||||
|
assert same_liquid_via_liquids_pair(a, b) is False
|
||||||
|
assert same_liquid_via_liquids_pair(b, a) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestCaptureTipLiquidName:
|
||||||
|
def test_known_name_returned(self) -> None:
|
||||||
|
well = make_well("A1", "PBS")
|
||||||
|
assert capture_tip_liquid_name(well) == "PBS"
|
||||||
|
|
||||||
|
def test_empty_well_returns_none(self) -> None:
|
||||||
|
well = make_well("A1", liquid_name=None)
|
||||||
|
assert capture_tip_liquid_name(well) is None
|
||||||
|
|
||||||
|
def test_unknown_literal_returns_none(self) -> None:
|
||||||
|
well = make_well("A1", "unknown")
|
||||||
|
assert capture_tip_liquid_name(well) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# T1–T12 端到端测试(单通道 transfer_liquid 主循环)
|
||||||
|
#
|
||||||
|
# 需要 PLR 完整环境(``pylabrobot.liquid_handling.LiquidHandlerBackend`` 等)。
|
||||||
|
# 若 PLR import 失败则整段 skip,helper 测试照常运行。
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_skip_if_no_plr = pytest.mark.skipif(
|
||||||
|
not _PLR_AVAILABLE,
|
||||||
|
reason=f"pylabrobot import failed: {_PLR_IMPORT_ERROR}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@_skip_if_no_plr
|
||||||
|
class TestSingleChannelTipReuse:
|
||||||
|
"""覆盖 §5 矩阵 T1 / T2 / T3 / T4 / T5 / T6 / T8 / T10 / T11。"""
|
||||||
|
|
||||||
|
def test_T1_identity_hit_reuses_tip(self) -> None:
|
||||||
|
"""T1:连续 2 轮同 source/target → identity-keep 命中,复用 tip。"""
|
||||||
|
lh = FakeLiquidHandler(channel_num=1)
|
||||||
|
src = make_well("S0", "PBS")
|
||||||
|
tgt = make_well("T0")
|
||||||
|
run(
|
||||||
|
lh.transfer_liquid(
|
||||||
|
sources=[src, src],
|
||||||
|
targets=[tgt, tgt],
|
||||||
|
tip_racks=[],
|
||||||
|
use_channels=[0],
|
||||||
|
asp_vols=[1, 1],
|
||||||
|
dis_vols=[1, 1],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# 2 次 transfer,但 identity-keep → 仅 1 次 pick_up_tips / 1 次 discard_tips
|
||||||
|
assert call_names(lh).count("pick_up_tips") == 1
|
||||||
|
assert call_names(lh).count("discard_tips") == 1
|
||||||
|
assert call_names(lh).count("aspirate") == 2
|
||||||
|
assert call_names(lh).count("dispense") == 2
|
||||||
|
|
||||||
|
def test_T2_liquids_hit_across_plates(self) -> None:
|
||||||
|
"""T2:9 个独立 source well(不同 PLR Well 对象)都装 PBS → identity 全 fail,liquids-keep 全命中。"""
|
||||||
|
lh = FakeLiquidHandler(channel_num=1)
|
||||||
|
sources = [make_well(f"S{i}", "PBS") for i in range(9)]
|
||||||
|
targets = [make_well(f"T{i}") for i in range(9)]
|
||||||
|
run(
|
||||||
|
lh.transfer_liquid(
|
||||||
|
sources=sources,
|
||||||
|
targets=targets,
|
||||||
|
tip_racks=[],
|
||||||
|
use_channels=[0],
|
||||||
|
asp_vols=[1] * 9,
|
||||||
|
dis_vols=[1] * 9,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# 9 个 source 物理上同液 → 整段共用 1 个 tip
|
||||||
|
assert call_names(lh).count("pick_up_tips") == 1
|
||||||
|
assert call_names(lh).count("discard_tips") == 1
|
||||||
|
assert call_names(lh).count("aspirate") == 9
|
||||||
|
assert call_names(lh).count("dispense") == 9
|
||||||
|
|
||||||
|
def test_T3_liquids_hit_same_plate_different_wells(self) -> None:
|
||||||
|
"""T3:同 plate 上 A1-H1 都装 PBS(8 个不同 Well 对象)→ identity 全 fail,liquids-keep 命中。"""
|
||||||
|
lh = FakeLiquidHandler(channel_num=1)
|
||||||
|
sources = [make_well(f"A{i}", "PBS") for i in range(1, 9)]
|
||||||
|
targets = [make_well(f"T{i}") for i in range(8)]
|
||||||
|
run(
|
||||||
|
lh.transfer_liquid(
|
||||||
|
sources=sources,
|
||||||
|
targets=targets,
|
||||||
|
tip_racks=[],
|
||||||
|
use_channels=[0],
|
||||||
|
asp_vols=[1] * 8,
|
||||||
|
dis_vols=[1] * 8,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert call_names(lh).count("pick_up_tips") == 1
|
||||||
|
assert call_names(lh).count("discard_tips") == 1
|
||||||
|
|
||||||
|
def test_T4_liquids_not_match_forces_tip_change(self) -> None:
|
||||||
|
"""T4:A1=PBS,B1=Tris HCl → liquids 名不等,强制换 tip。"""
|
||||||
|
lh = FakeLiquidHandler(channel_num=1)
|
||||||
|
sources = [make_well("A1", "PBS"), make_well("B1", "Tris HCl")]
|
||||||
|
targets = [make_well("T0"), make_well("T1")]
|
||||||
|
run(
|
||||||
|
lh.transfer_liquid(
|
||||||
|
sources=sources,
|
||||||
|
targets=targets,
|
||||||
|
tip_racks=[],
|
||||||
|
use_channels=[0],
|
||||||
|
asp_vols=[1, 1],
|
||||||
|
dis_vols=[1, 1],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# 2 次完全独立的 transfer:2 次 pick_up / 2 次 discard
|
||||||
|
assert call_names(lh).count("pick_up_tips") == 2
|
||||||
|
assert call_names(lh).count("discard_tips") == 2
|
||||||
|
|
||||||
|
def test_T5_empty_liquids_forces_tip_change(self) -> None:
|
||||||
|
"""T5:source 从未调过 set_liquids(liquids 空)→ 视为未知,强制换 tip。"""
|
||||||
|
lh = FakeLiquidHandler(channel_num=1)
|
||||||
|
sources = [make_well("A1"), make_well("B1")] # 没装液体名
|
||||||
|
targets = [make_well("T0"), make_well("T1")]
|
||||||
|
run(
|
||||||
|
lh.transfer_liquid(
|
||||||
|
sources=sources,
|
||||||
|
targets=targets,
|
||||||
|
tip_racks=[],
|
||||||
|
use_channels=[0],
|
||||||
|
asp_vols=[1, 1],
|
||||||
|
dis_vols=[1, 1],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert call_names(lh).count("pick_up_tips") == 2
|
||||||
|
assert call_names(lh).count("discard_tips") == 2
|
||||||
|
|
||||||
|
def test_T6_switch_off_disables_liquids_keep(self) -> None:
|
||||||
|
"""T6:tip_reuse_by_liquid_name=False,T2 场景退化为 identity-only,强制换 tip。"""
|
||||||
|
lh = FakeLiquidHandler(channel_num=1, tip_reuse_by_liquid_name=False)
|
||||||
|
sources = [make_well(f"S{i}", "PBS") for i in range(9)]
|
||||||
|
targets = [make_well(f"T{i}") for i in range(9)]
|
||||||
|
run(
|
||||||
|
lh.transfer_liquid(
|
||||||
|
sources=sources,
|
||||||
|
targets=targets,
|
||||||
|
tip_racks=[],
|
||||||
|
use_channels=[0],
|
||||||
|
asp_vols=[1] * 9,
|
||||||
|
dis_vols=[1] * 9,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# 关闭开关后 → 退化为 identity-only,9 次独立换 tip
|
||||||
|
assert call_names(lh).count("pick_up_tips") == 9
|
||||||
|
assert call_names(lh).count("discard_tips") == 9
|
||||||
|
|
||||||
|
def test_T8_mix_style_same_source_reuses_via_identity(self) -> None:
|
||||||
|
"""T8:单 source 反复 aspirate/dispense → identity-keep 命中(mix-style)。"""
|
||||||
|
lh = FakeLiquidHandler(channel_num=1)
|
||||||
|
src = make_well("S0", "Methanol")
|
||||||
|
tgt = make_well("T0")
|
||||||
|
run(
|
||||||
|
lh.transfer_liquid(
|
||||||
|
sources=[src, src, src],
|
||||||
|
targets=[tgt, tgt, tgt],
|
||||||
|
tip_racks=[],
|
||||||
|
use_channels=[0],
|
||||||
|
asp_vols=[1, 1, 1],
|
||||||
|
dis_vols=[1, 1, 1],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert call_names(lh).count("pick_up_tips") == 1
|
||||||
|
assert call_names(lh).count("discard_tips") == 1
|
||||||
|
|
||||||
|
def test_T10_unknown_literal_treated_as_unknown(self) -> None:
|
||||||
|
"""T10:``tracker.liquids = [("unknown", v)]``(兼容旧数据)→ 视为未知,强制换 tip。"""
|
||||||
|
lh = FakeLiquidHandler(channel_num=1)
|
||||||
|
sources = [make_well("A1", "unknown"), make_well("B1", "unknown")]
|
||||||
|
targets = [make_well("T0"), make_well("T1")]
|
||||||
|
run(
|
||||||
|
lh.transfer_liquid(
|
||||||
|
sources=sources,
|
||||||
|
targets=targets,
|
||||||
|
tip_racks=[],
|
||||||
|
use_channels=[0],
|
||||||
|
asp_vols=[1, 1],
|
||||||
|
dis_vols=[1, 1],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert call_names(lh).count("pick_up_tips") == 2
|
||||||
|
assert call_names(lh).count("discard_tips") == 2
|
||||||
|
|
||||||
|
def test_T11_aspirate_pop_timing_pre_read(self) -> None:
|
||||||
|
"""T11:aspirate 顶层归零 → PLR pop ``tracker.liquids`` 顶层;
|
||||||
|
验证 P10 v2 ``pending_tip_name`` 必须在 aspirate **之前**预读才能命中下一轮。
|
||||||
|
"""
|
||||||
|
lh = AspiratePopFakeLiquidHandler(channel_num=1)
|
||||||
|
sources = [make_well(f"S{i}", "PBS") for i in range(3)]
|
||||||
|
targets = [make_well(f"T{i}") for i in range(3)]
|
||||||
|
run(
|
||||||
|
lh.transfer_liquid(
|
||||||
|
sources=sources,
|
||||||
|
targets=targets,
|
||||||
|
tip_racks=[],
|
||||||
|
use_channels=[0],
|
||||||
|
asp_vols=[1] * 3,
|
||||||
|
dis_vols=[1] * 3,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# 即使 aspirate 后 source.tracker.liquids 被 pop,pending_tip_name 已捕获 "PBS"
|
||||||
|
# → 下一轮 source 仍是 PBS(aspirate 还没发生),liquids-keep 命中
|
||||||
|
# → 整段 1 次 pick_up_tips
|
||||||
|
assert call_names(lh).count("pick_up_tips") == 1
|
||||||
|
assert call_names(lh).count("discard_tips") == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# T7:跨节点边界(两个独立 transfer_liquid 调用,状态隔离)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@_skip_if_no_plr
|
||||||
|
class TestCrossNodeBoundary:
|
||||||
|
"""T7:两个 transfer_liquid 节点之间不复用 tip(每次调用初始化 current_tip_liquid_name=None)。"""
|
||||||
|
|
||||||
|
def test_T7_two_calls_dont_share_tip_state(self) -> None:
|
||||||
|
lh = FakeLiquidHandler(channel_num=1)
|
||||||
|
src_a = make_well("A_src", "PBS")
|
||||||
|
tgt_a = make_well("A_tgt")
|
||||||
|
src_b = make_well("B_src", "PBS") # 同名液,但不同 well
|
||||||
|
tgt_b = make_well("B_tgt")
|
||||||
|
|
||||||
|
run(
|
||||||
|
lh.transfer_liquid(
|
||||||
|
sources=[src_a],
|
||||||
|
targets=[tgt_a],
|
||||||
|
tip_racks=[],
|
||||||
|
use_channels=[0],
|
||||||
|
asp_vols=[1],
|
||||||
|
dis_vols=[1],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
run(
|
||||||
|
lh.transfer_liquid(
|
||||||
|
sources=[src_b],
|
||||||
|
targets=[tgt_b],
|
||||||
|
tip_racks=[],
|
||||||
|
use_channels=[0],
|
||||||
|
asp_vols=[1],
|
||||||
|
dis_vols=[1],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# 两次调用各自独立换 tip → 2 次 pick_up_tips / 2 次 discard_tips
|
||||||
|
assert call_names(lh).count("pick_up_tips") == 2
|
||||||
|
assert call_names(lh).count("discard_tips") == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# T9:8 通道段锚孔 liquids-keep
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@_skip_if_no_plr
|
||||||
|
class TestEightChannelSegmentTipReuse:
|
||||||
|
"""T9:8 通道分段,连续两段 src_slice[0] 同名 → 段间不换 tip。"""
|
||||||
|
|
||||||
|
def test_T9_two_segments_same_anchor_liquid(self) -> None:
|
||||||
|
lh = FakeLiquidHandler(channel_num=8)
|
||||||
|
# 16 个 source wells,分 2 段;段 1 锚孔 = sources[0],段 2 锚孔 = sources[8]
|
||||||
|
sources = [make_well(f"S{i}", "PBS") for i in range(16)]
|
||||||
|
targets = [make_well(f"T{i}") for i in range(16)]
|
||||||
|
run(
|
||||||
|
lh.transfer_liquid(
|
||||||
|
sources=sources,
|
||||||
|
targets=targets,
|
||||||
|
tip_racks=[],
|
||||||
|
use_channels=list(range(8)),
|
||||||
|
asp_vols=[1] * 16,
|
||||||
|
dis_vols=[1] * 16,
|
||||||
|
mix_times=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# 2 段都同液 → liquids-keep 命中 → 仅 1 次 pick_up_tips
|
||||||
|
assert call_names(lh).count("pick_up_tips") == 1
|
||||||
|
assert call_names(lh).count("discard_tips") == 1
|
||||||
|
|
||||||
|
def test_T9b_two_segments_different_anchor_liquid_forces_tip_change(self) -> None:
|
||||||
|
"""T9b:段 1 锚孔 = PBS,段 2 锚孔 = Tris → 段间强制换 tip。"""
|
||||||
|
lh = FakeLiquidHandler(channel_num=8)
|
||||||
|
seg1 = [make_well(f"S{i}", "PBS") for i in range(8)]
|
||||||
|
seg2 = [make_well(f"S{i + 8}", "Tris HCl") for i in range(8)]
|
||||||
|
sources = seg1 + seg2
|
||||||
|
targets = [make_well(f"T{i}") for i in range(16)]
|
||||||
|
run(
|
||||||
|
lh.transfer_liquid(
|
||||||
|
sources=sources,
|
||||||
|
targets=targets,
|
||||||
|
tip_racks=[],
|
||||||
|
use_channels=list(range(8)),
|
||||||
|
asp_vols=[1] * 16,
|
||||||
|
dis_vols=[1] * 16,
|
||||||
|
mix_times=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# 2 段不同液 → 2 次独立换 tip
|
||||||
|
assert call_names(lh).count("pick_up_tips") == 2
|
||||||
|
assert call_names(lh).count("discard_tips") == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 配置开关默认值 / 实例字段读取
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@_skip_if_no_plr
|
||||||
|
class TestConfigDefault:
|
||||||
|
def test_default_switch_is_on(self) -> None:
|
||||||
|
"""默认 ``_tip_reuse_by_liquid_name`` 应为 True(测试 fixture 显式 default 一致)。"""
|
||||||
|
lh = FakeLiquidHandler()
|
||||||
|
assert lh._tip_reuse_by_liquid_name is True
|
||||||
|
|
||||||
|
def test_switch_off_takes_effect(self) -> None:
|
||||||
|
lh = FakeLiquidHandler(tip_reuse_by_liquid_name=False)
|
||||||
|
assert lh._tip_reuse_by_liquid_name is False
|
||||||
@@ -39,6 +39,11 @@ 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}))
|
||||||
|
|
||||||
|
|||||||
608
tests/devices/liquid_handling/unit_test.py
Normal file
608
tests/devices/liquid_handling/unit_test.py
Normal file
@@ -0,0 +1,608 @@
|
|||||||
|
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]
|
||||||
|
|
||||||
|
|
||||||
137
tests/resources/test_resource_tracker_history.py
Normal file
137
tests/resources/test_resource_tracker_history.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
"""P9 — ``_augment_states_with_liquid_history`` 单元测试(OS→Cloud sync 链路 Phase C)。
|
||||||
|
|
||||||
|
详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md`` §6.3 / §8 T4。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from unilabos.resources.resource_tracker import _augment_states_with_liquid_history
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures:纯 dataclass 模拟 PLR 资源树(避免引入 PLR 真实实例化)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FakeTracker:
|
||||||
|
liquid_history: Any = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FakeResource:
|
||||||
|
name: str
|
||||||
|
tracker: Any = None
|
||||||
|
children: List["FakeResource"] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestAugmentStatesWithLiquidHistory:
|
||||||
|
def test_single_well_history_attached(self) -> None:
|
||||||
|
well = FakeResource("well_A1", tracker=FakeTracker(liquid_history=[
|
||||||
|
{"name": "Plasma", "volume": 100, "action": "set"}
|
||||||
|
]))
|
||||||
|
states: Dict[str, Any] = {"well_A1": {"liquids": [], "pending_liquids": []}}
|
||||||
|
|
||||||
|
_augment_states_with_liquid_history(well, states)
|
||||||
|
|
||||||
|
assert "liquid_history" in states["well_A1"]
|
||||||
|
assert states["well_A1"]["liquid_history"] == [
|
||||||
|
{"name": "Plasma", "volume": 100, "action": "set"}
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_recursive_walk_attaches_to_all_wells(self) -> None:
|
||||||
|
"""resource 树有多层时,每个有 tracker 的节点都会被并入 states。"""
|
||||||
|
wells = [
|
||||||
|
FakeResource(f"well_{i}", tracker=FakeTracker(liquid_history=[
|
||||||
|
{"name": f"L_{i}", "volume": i * 10, "action": "set"}
|
||||||
|
]))
|
||||||
|
for i in range(3)
|
||||||
|
]
|
||||||
|
plate = FakeResource("plate", children=wells)
|
||||||
|
deck = FakeResource("deck", children=[plate])
|
||||||
|
states: Dict[str, Any] = {
|
||||||
|
"deck": {"liquids": []},
|
||||||
|
"plate": {"liquids": []},
|
||||||
|
"well_0": {"liquids": []},
|
||||||
|
"well_1": {"liquids": []},
|
||||||
|
"well_2": {"liquids": []},
|
||||||
|
}
|
||||||
|
|
||||||
|
_augment_states_with_liquid_history(deck, states)
|
||||||
|
|
||||||
|
assert states["well_0"]["liquid_history"] == [{"name": "L_0", "volume": 0, "action": "set"}]
|
||||||
|
assert states["well_1"]["liquid_history"] == [{"name": "L_1", "volume": 10, "action": "set"}]
|
||||||
|
assert states["well_2"]["liquid_history"] == [{"name": "L_2", "volume": 20, "action": "set"}]
|
||||||
|
|
||||||
|
def test_no_tracker_node_skipped(self) -> None:
|
||||||
|
"""没有 tracker 的节点(如 deck 自身)跳过,state dict 不被污染。"""
|
||||||
|
deck = FakeResource("deck") # tracker=None
|
||||||
|
states: Dict[str, Any] = {"deck": {"some_field": 1}}
|
||||||
|
|
||||||
|
_augment_states_with_liquid_history(deck, states)
|
||||||
|
|
||||||
|
assert "liquid_history" not in states["deck"]
|
||||||
|
|
||||||
|
def test_existing_liquid_history_in_state_not_overwritten(self) -> None:
|
||||||
|
"""state 已经有 liquid_history 字段(例如 PLR 升级未来支持了)→ 不覆盖。"""
|
||||||
|
well = FakeResource("well_A1", tracker=FakeTracker(liquid_history=[
|
||||||
|
{"name": "Plasma", "volume": 100, "action": "set"}
|
||||||
|
]))
|
||||||
|
states: Dict[str, Any] = {"well_A1": {"liquid_history": ["preexisting"]}}
|
||||||
|
|
||||||
|
_augment_states_with_liquid_history(well, states)
|
||||||
|
|
||||||
|
assert states["well_A1"]["liquid_history"] == ["preexisting"]
|
||||||
|
|
||||||
|
def test_history_is_shallow_copied(self) -> None:
|
||||||
|
"""augment 后的 history 应是独立 list(避免运行时 mutate 污染 dump 结果)。"""
|
||||||
|
original_history = [{"name": "X", "volume": 1, "action": "set"}]
|
||||||
|
well = FakeResource("well_A1", tracker=FakeTracker(liquid_history=original_history))
|
||||||
|
states: Dict[str, Any] = {"well_A1": {}}
|
||||||
|
|
||||||
|
_augment_states_with_liquid_history(well, states)
|
||||||
|
|
||||||
|
# mutate runtime history 不应反映到 augmented state
|
||||||
|
original_history.append({"name": "Y", "volume": 2, "action": "set"})
|
||||||
|
assert len(states["well_A1"]["liquid_history"]) == 1
|
||||||
|
|
||||||
|
def test_node_not_in_states_silently_skipped(self) -> None:
|
||||||
|
"""resource 树中的节点 name 不在 ``states`` 字典里 → 静默跳过。"""
|
||||||
|
well = FakeResource("well_orphan", tracker=FakeTracker(liquid_history=[
|
||||||
|
{"name": "X", "volume": 1, "action": "set"}
|
||||||
|
]))
|
||||||
|
states: Dict[str, Any] = {"well_A1": {}}
|
||||||
|
|
||||||
|
_augment_states_with_liquid_history(well, states)
|
||||||
|
|
||||||
|
# 不应该新增 well_orphan 键,也不应污染 well_A1
|
||||||
|
assert "well_orphan" not in states
|
||||||
|
assert "liquid_history" not in states["well_A1"]
|
||||||
|
|
||||||
|
def test_non_list_liquid_history_skipped(self) -> None:
|
||||||
|
"""tracker.liquid_history 非 list 时(异常情况)→ 跳过,不写入 state。"""
|
||||||
|
well = FakeResource("well_A1", tracker=FakeTracker(liquid_history="broken"))
|
||||||
|
states: Dict[str, Any] = {"well_A1": {}}
|
||||||
|
|
||||||
|
_augment_states_with_liquid_history(well, states)
|
||||||
|
|
||||||
|
assert "liquid_history" not in states["well_A1"]
|
||||||
|
|
||||||
|
def test_empty_history_still_written(self) -> None:
|
||||||
|
"""tracker.liquid_history = [] 是合法状态 → 应写入空 list(表示"未有任何液体操作")。"""
|
||||||
|
well = FakeResource("well_A1", tracker=FakeTracker(liquid_history=[]))
|
||||||
|
states: Dict[str, Any] = {"well_A1": {}}
|
||||||
|
|
||||||
|
_augment_states_with_liquid_history(well, states)
|
||||||
|
|
||||||
|
assert states["well_A1"]["liquid_history"] == []
|
||||||
351
tests/workflow/test_build_protocol_graph_target_device.py
Normal file
351
tests/workflow/test_build_protocol_graph_target_device.py
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
"""P6.1 / P6.1.1 `build_protocol_graph` 集成测试 —— 对应 06-labware-mapping-table.md §11.7.7 C / §11.8.7 C。
|
||||||
|
|
||||||
|
6 条用例:
|
||||||
|
|
||||||
|
- `test_build_graph_default_target_device_prcxi` —— 不传 target_device 时默认 "prcxi",
|
||||||
|
与 P6 等价(PRCXI_* class_name)。
|
||||||
|
- `test_build_graph_explicit_target_device_prcxi` —— 显式 "prcxi" 与默认完全等价。
|
||||||
|
- `test_build_graph_target_device_unknown_falls_back_to_default_section` —— 未声明的
|
||||||
|
target_device 由 loader 自动 fallback 到 ``target_devices.default``;第一版 default
|
||||||
|
段按 prcxi 拷贝,所以结果应与 "prcxi" 完全一致。
|
||||||
|
- `test_build_graph_per_device_tip_class` —— 临时 YAML 同时声明 prcxi 与 beckman tip
|
||||||
|
量程档;同一 transfer_liquid 在 target_device="prcxi" / "beckman" 下命中不同 class。
|
||||||
|
- `test_field_renamed_target_class_name` —— `labware_info` 写入的字段是
|
||||||
|
`target_class_name`,**旧字段 `prcxi_class_name` 不存在**。
|
||||||
|
- `test_build_graph_model_level_slot_remap` —— P6.1.1:``target_model`` 透传到
|
||||||
|
``_map_deck_slot`` 后改变 create_resource 的 slot(同厂商不同型号 deck 物理布局不同)。
|
||||||
|
|
||||||
|
本测试在导入 common.py 之前 mock 掉 matplotlib / networkx.drawing.nx_agraph,避免在
|
||||||
|
没有图形依赖的最小 Python 环境下也能跑(与 P6 批量回归脚本同样的策略)。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import warnings
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||||
|
if str(ROOT_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT_DIR))
|
||||||
|
|
||||||
|
|
||||||
|
def _install_fake_optional_deps() -> None:
|
||||||
|
"""安装 matplotlib / networkx.drawing.nx_agraph 的 fake 实现,避免本地环境硬依赖。
|
||||||
|
|
||||||
|
common.py 在模块级 import 这些库做可视化辅助;build_protocol_graph 主路径不会真用到。
|
||||||
|
fake 模块只需要满足 ``from X import Y`` 的查找即可。
|
||||||
|
"""
|
||||||
|
if "matplotlib" not in sys.modules:
|
||||||
|
fake_matplotlib = types.ModuleType("matplotlib")
|
||||||
|
sys.modules["matplotlib"] = fake_matplotlib
|
||||||
|
if "matplotlib.pyplot" not in sys.modules:
|
||||||
|
fake_plt = types.ModuleType("matplotlib.pyplot")
|
||||||
|
sys.modules["matplotlib.pyplot"] = fake_plt
|
||||||
|
# networkx.drawing.nx_agraph.to_agraph 依赖 pygraphviz;不可用时给个空 stub
|
||||||
|
try:
|
||||||
|
from networkx.drawing import nx_agraph # noqa: F401
|
||||||
|
except Exception:
|
||||||
|
nx_drawing = types.ModuleType("networkx.drawing")
|
||||||
|
nx_agraph_mod = types.ModuleType("networkx.drawing.nx_agraph")
|
||||||
|
|
||||||
|
def _to_agraph(_g): # type: ignore[no-untyped-def]
|
||||||
|
raise RuntimeError("nx_agraph fake — not used in build_protocol_graph main path")
|
||||||
|
|
||||||
|
nx_agraph_mod.to_agraph = _to_agraph # type: ignore[attr-defined]
|
||||||
|
nx_drawing.nx_agraph = nx_agraph_mod # type: ignore[attr-defined]
|
||||||
|
sys.modules["networkx.drawing"] = nx_drawing
|
||||||
|
sys.modules["networkx.drawing.nx_agraph"] = nx_agraph_mod
|
||||||
|
|
||||||
|
|
||||||
|
_install_fake_optional_deps()
|
||||||
|
|
||||||
|
from unilabos.workflow import labware_mapping as lm # noqa: E402
|
||||||
|
from unilabos.workflow.common import build_protocol_graph # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_mapping_cache():
|
||||||
|
"""每个用例后清 lru_cache,避免跨用例污染。"""
|
||||||
|
yield
|
||||||
|
lm.reload_mapping()
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 公共 fixture:最小 transfer_liquid 协议 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def _minimal_labware_info() -> dict:
|
||||||
|
"""返回最小可用的 labware_info(mutable,每个 case 独立 build 一份)。
|
||||||
|
|
||||||
|
包含 tip rack + 24-tube rack + 96 wellplate(slot 1/2/3),覆盖 P6.1 主要 kind。
|
||||||
|
tube rack / plate 显式声明 ``num_wells``,避免在无 labware_defs / 无 prcxi_labware 模板
|
||||||
|
时通过 well-count 启发式(well_n=3)误判孔数;与真实协议中 labware_defs 提供 num_wells
|
||||||
|
的行为对齐。
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"tips": {
|
||||||
|
"slot": 1,
|
||||||
|
"well": [],
|
||||||
|
"labware": "opentrons_96_tiprack_300ul",
|
||||||
|
"object": "tiprack",
|
||||||
|
},
|
||||||
|
"samples": {
|
||||||
|
"slot": 2,
|
||||||
|
"well": ["A1", "A2", "A3"],
|
||||||
|
"labware": "opentrons_24_tuberack_eppendorf_2ml_safelock_snapcap",
|
||||||
|
"object": "source",
|
||||||
|
"num_wells": 24,
|
||||||
|
},
|
||||||
|
"plate_target": {
|
||||||
|
"slot": 3,
|
||||||
|
"well": ["A1", "A2", "A3"],
|
||||||
|
"labware": "opentrons_96_wellplate_300ul_pcr",
|
||||||
|
"object": "target",
|
||||||
|
"num_wells": 96,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _minimal_protocol_steps() -> list:
|
||||||
|
"""最小 transfer_liquid 协议步骤:asp_vols/dis_vols 最大 200 µL → PRCXI 300ul 档。"""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"parameters": {
|
||||||
|
"sources": "samples",
|
||||||
|
"targets": "plate_target",
|
||||||
|
"tip_racks": "tips",
|
||||||
|
"asp_vols": [200.0, 200.0, 200.0],
|
||||||
|
"dis_vols": [200.0, 200.0, 200.0],
|
||||||
|
},
|
||||||
|
"step_number": 1,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_create_resource_classes(graph) -> dict:
|
||||||
|
"""从工作流图中提取每个 create_resource 节点的 ``slot_on_deck → class_name``。"""
|
||||||
|
out: dict = {}
|
||||||
|
for _nid, node in graph.nodes.items():
|
||||||
|
if node.get("template_name") != "create_resource":
|
||||||
|
continue
|
||||||
|
param = node.get("param") or {}
|
||||||
|
slot = str(param.get("slot_on_deck") or "")
|
||||||
|
cls = str(param.get("class_name") or "")
|
||||||
|
if slot:
|
||||||
|
out[slot] = cls
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 5 条核心用例 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_graph_default_target_device_prcxi():
|
||||||
|
"""不传 target_device → 默认 "prcxi" → 与 P6 等价(PRCXI_* class_name)。"""
|
||||||
|
labware_info = _minimal_labware_info()
|
||||||
|
g = build_protocol_graph(
|
||||||
|
labware_info=labware_info,
|
||||||
|
protocol_steps=_minimal_protocol_steps(),
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
classes = _collect_create_resource_classes(g)
|
||||||
|
assert classes["1"] == "PRCXI_300ul_Tips" # 200 µL → 300 档
|
||||||
|
assert classes["2"] == "PRCXI_EP_Adapter" # 24-tube rack
|
||||||
|
assert classes["3"] == "PRCXI_BioER_96_wellplate" # 96 wellplate
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_graph_explicit_target_device_prcxi():
|
||||||
|
"""显式传 target_device="prcxi" 应与默认完全等价。"""
|
||||||
|
labware_info_a = _minimal_labware_info()
|
||||||
|
labware_info_b = _minimal_labware_info()
|
||||||
|
g_default = build_protocol_graph(
|
||||||
|
labware_info=labware_info_a,
|
||||||
|
protocol_steps=_minimal_protocol_steps(),
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
g_prcxi = build_protocol_graph(
|
||||||
|
labware_info=labware_info_b,
|
||||||
|
protocol_steps=_minimal_protocol_steps(),
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
target_device="prcxi",
|
||||||
|
)
|
||||||
|
assert _collect_create_resource_classes(g_default) == _collect_create_resource_classes(g_prcxi)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_graph_target_device_unknown_falls_back_to_default_section():
|
||||||
|
"""未声明的 target_device → loader 自动 fallback 到固定段 target_devices.default + warning。
|
||||||
|
|
||||||
|
第一版 default 段按 prcxi 拷贝填充 → 结果应与 target_device="prcxi" 完全等价(PRCXI_*)。
|
||||||
|
"""
|
||||||
|
labware_info_a = _minimal_labware_info()
|
||||||
|
labware_info_b = _minimal_labware_info()
|
||||||
|
g_prcxi = build_protocol_graph(
|
||||||
|
labware_info=labware_info_a,
|
||||||
|
protocol_steps=_minimal_protocol_steps(),
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
target_device="prcxi",
|
||||||
|
)
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
g_unknown = build_protocol_graph(
|
||||||
|
labware_info=labware_info_b,
|
||||||
|
protocol_steps=_minimal_protocol_steps(),
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
target_device="unknown_xxx",
|
||||||
|
)
|
||||||
|
assert _collect_create_resource_classes(g_unknown) == _collect_create_resource_classes(g_prcxi)
|
||||||
|
# loader 至少打 1 次 warning 提示「未声明、已回退到 default」
|
||||||
|
assert any(
|
||||||
|
("未在 labware_mapping.yaml" in str(w.message))
|
||||||
|
or ("target_devices.default" in str(w.message))
|
||||||
|
for w in caught
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_graph_per_device_tip_class(tmp_path, monkeypatch):
|
||||||
|
"""同一 protocol,target_device="prcxi" / "beckman" 在 200µL 下命中不同 tip 档(P6.1.1 schema)。"""
|
||||||
|
yaml_path = tmp_path / "labware_mapping.yaml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
'kinds:\n'
|
||||||
|
' - {pattern: "trash", kind: trash}\n'
|
||||||
|
' - {pattern: "tiprack|tip[_ ]?rack|opentrons_\\\\d+_tiprack", kind: tip_rack}\n'
|
||||||
|
' - {pattern: "tuberack|tube[_ ]rack|eppendorf.*rack|safelock.*rack", kind: tube_rack}\n'
|
||||||
|
' - {pattern: ".*", kind: plate}\n'
|
||||||
|
'target_devices:\n'
|
||||||
|
' default:\n'
|
||||||
|
' slot_remap: {default: {"4": "13", "8": "14"}, by_object: {trash: {"12": "16"}}}\n'
|
||||||
|
' rules:\n'
|
||||||
|
' - {kind: tip_rack, hole_count: 96, volume_max: 10, class_name: PRCXI_10uL_Tips}\n'
|
||||||
|
' - {kind: tip_rack, hole_count: 96, volume_max: 299.9, class_name: PRCXI_300ul_Tips}\n'
|
||||||
|
' - {kind: tip_rack, hole_count: 96, class_name: PRCXI_1000uL_Tips}\n'
|
||||||
|
' - {kind: tube_rack, hole_count: 24, class_name: PRCXI_EP_Adapter}\n'
|
||||||
|
' - {kind: plate, hole_count: 96, class_name: PRCXI_BioER_96_wellplate}\n'
|
||||||
|
' prcxi:\n'
|
||||||
|
' slot_remap: {default: {"4": "13", "8": "14"}, by_object: {trash: {"12": "16"}}}\n'
|
||||||
|
' rules:\n'
|
||||||
|
' - {kind: tip_rack, hole_count: 96, volume_max: 10, class_name: PRCXI_10uL_Tips}\n'
|
||||||
|
' - {kind: tip_rack, hole_count: 96, volume_max: 299.9, class_name: PRCXI_300ul_Tips}\n'
|
||||||
|
' - {kind: tip_rack, hole_count: 96, class_name: PRCXI_1000uL_Tips}\n'
|
||||||
|
' - {kind: tube_rack, hole_count: 24, class_name: PRCXI_EP_Adapter}\n'
|
||||||
|
' - {kind: plate, hole_count: 96, class_name: PRCXI_BioER_96_wellplate}\n'
|
||||||
|
' beckman:\n'
|
||||||
|
' slot_remap: {default: {"4": "13"}, by_object: {trash: {"12": "16"}}}\n'
|
||||||
|
' rules:\n'
|
||||||
|
' - {kind: tip_rack, hole_count: 96, volume_max: 20, class_name: Beckman_20uL_Tips}\n'
|
||||||
|
' - {kind: tip_rack, hole_count: 96, volume_max: 199.9, class_name: Beckman_200uL_Tips}\n'
|
||||||
|
' - {kind: tip_rack, hole_count: 96, class_name: Beckman_1000uL_Tips}\n'
|
||||||
|
' - {kind: tube_rack, hole_count: 24, class_name: Beckman_24_TubeRack}\n'
|
||||||
|
' - {kind: plate, hole_count: 96, class_name: Beckman_BioMek_96_wellplate}\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
|
||||||
|
lm.reload_mapping()
|
||||||
|
|
||||||
|
g_prcxi = build_protocol_graph(
|
||||||
|
labware_info=_minimal_labware_info(),
|
||||||
|
protocol_steps=_minimal_protocol_steps(),
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
target_device="prcxi",
|
||||||
|
)
|
||||||
|
g_beckman = build_protocol_graph(
|
||||||
|
labware_info=_minimal_labware_info(),
|
||||||
|
protocol_steps=_minimal_protocol_steps(),
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
target_device="beckman",
|
||||||
|
)
|
||||||
|
|
||||||
|
classes_prcxi = _collect_create_resource_classes(g_prcxi)
|
||||||
|
classes_beckman = _collect_create_resource_classes(g_beckman)
|
||||||
|
|
||||||
|
# 200 µL:prcxi 走 300 档;beckman 200 档已超 → 1000 档
|
||||||
|
assert classes_prcxi["1"] == "PRCXI_300ul_Tips"
|
||||||
|
assert classes_beckman["1"] == "Beckman_1000uL_Tips"
|
||||||
|
# plate / tube rack 也按 target_device 输出对应厂商类
|
||||||
|
assert classes_prcxi["2"] == "PRCXI_EP_Adapter"
|
||||||
|
assert classes_beckman["2"] == "Beckman_24_TubeRack"
|
||||||
|
assert classes_prcxi["3"] == "PRCXI_BioER_96_wellplate"
|
||||||
|
assert classes_beckman["3"] == "Beckman_BioMek_96_wellplate"
|
||||||
|
|
||||||
|
|
||||||
|
def test_field_renamed_target_class_name():
|
||||||
|
"""`labware_info` 写入的字段是 `target_class_name`;旧字段 `prcxi_class_name` 不存在。"""
|
||||||
|
labware_info = _minimal_labware_info()
|
||||||
|
build_protocol_graph(
|
||||||
|
labware_info=labware_info,
|
||||||
|
protocol_steps=_minimal_protocol_steps(),
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
for lid, item in labware_info.items():
|
||||||
|
assert "target_class_name" in item, f"{lid!r} 缺少 target_class_name 字段"
|
||||||
|
assert "prcxi_class_name" not in item, f"{lid!r} 残留了旧字段 prcxi_class_name"
|
||||||
|
assert item["target_class_name"], f"{lid!r} target_class_name 为空"
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== P6.1.1 新增集成测试 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def _labware_info_slot4_plate() -> dict:
|
||||||
|
"""slot=4 的 96 板:用来验证 target_model 透传后 slot_remap 改变 create_resource 的槽位。"""
|
||||||
|
return {
|
||||||
|
"plate_slot4": {
|
||||||
|
"slot": 4,
|
||||||
|
"well": ["A1"],
|
||||||
|
"labware": "opentrons_96_wellplate_300ul_pcr",
|
||||||
|
"object": "target",
|
||||||
|
"num_wells": 96,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_graph_model_level_slot_remap(tmp_path, monkeypatch):
|
||||||
|
"""P6.1.1:target_model 透传到 _map_deck_slot 后改变 create_resource 的 slot_on_deck。
|
||||||
|
|
||||||
|
YAML 中 prcxi 厂商级 slot_remap 4→13;模型 "4040" 显式覆盖 4→16。
|
||||||
|
同一份 labware_info(slot=4)build 出的两份图,slot_on_deck 应分别为 "13" 与 "16"。
|
||||||
|
"""
|
||||||
|
yaml_path = tmp_path / "labware_mapping.yaml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
'kinds: [{pattern: ".*", kind: plate}]\n'
|
||||||
|
'target_devices:\n'
|
||||||
|
' default:\n'
|
||||||
|
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
||||||
|
' rules: [{kind: plate, hole_count: 96, class_name: PRCXI_BioER_96_wellplate}]\n'
|
||||||
|
' prcxi:\n'
|
||||||
|
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
||||||
|
' rules: [{kind: plate, hole_count: 96, class_name: PRCXI_BioER_96_wellplate}]\n'
|
||||||
|
' models:\n'
|
||||||
|
' "4040":\n'
|
||||||
|
' slot_remap: {default: {"4": "16"}, by_object: {}}\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
|
||||||
|
lm.reload_mapping()
|
||||||
|
|
||||||
|
g_default = build_protocol_graph(
|
||||||
|
labware_info=_labware_info_slot4_plate(),
|
||||||
|
protocol_steps=[],
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
target_device="prcxi",
|
||||||
|
)
|
||||||
|
g_model_4040 = build_protocol_graph(
|
||||||
|
labware_info=_labware_info_slot4_plate(),
|
||||||
|
protocol_steps=[],
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
target_device="prcxi",
|
||||||
|
target_model="4040",
|
||||||
|
)
|
||||||
|
|
||||||
|
classes_default = _collect_create_resource_classes(g_default)
|
||||||
|
classes_4040 = _collect_create_resource_classes(g_model_4040)
|
||||||
|
|
||||||
|
# 厂商级(无 model)→ slot 4 → "13"
|
||||||
|
assert "13" in classes_default, f"未找到 slot 13,实际生成的 slots: {list(classes_default)}"
|
||||||
|
assert "16" not in classes_default
|
||||||
|
# 模型 4040 → slot 4 → "16"
|
||||||
|
assert "16" in classes_4040, f"未找到 slot 16,实际生成的 slots: {list(classes_4040)}"
|
||||||
|
assert "13" not in classes_4040
|
||||||
|
# class_name 不变(rules 继承厂商级)
|
||||||
|
assert classes_default["13"] == "PRCXI_BioER_96_wellplate"
|
||||||
|
assert classes_4040["16"] == "PRCXI_BioER_96_wellplate"
|
||||||
369
tests/workflow/test_common_cross_slot_v2.py
Normal file
369
tests/workflow/test_common_cross_slot_v2.py
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
"""P2 v2 跨 slot transfer_liquid 合并 —— Stage 3 (`workflow/common.py`) 集成测试。
|
||||||
|
|
||||||
|
对应 ``product_designs/protocol_convert/02-cross-slot-merge.md`` §9.5 step 6.2。
|
||||||
|
|
||||||
|
v2 设计要点(与本测试用例的映射)
|
||||||
|
-----------------------------------
|
||||||
|
当 transfer_liquid 节点 ``params.targets`` 是 ``list[str]`` 时,``build_protocol_graph``
|
||||||
|
在该 transfer_liquid 之前**插入一个 merged ``set_liquid_from_plate`` 节点**:
|
||||||
|
|
||||||
|
- merged 节点的 ``param.wells`` 是按 ``params.targets`` 顺序通过 cursor 拼出来的有序跨板
|
||||||
|
well refs(每个元素是 ``{id, name, parent: reagent_key, type: "well"}``)。
|
||||||
|
- merged 节点接收来自每个涉及 plate 的 ``create_resource`` 节点的多入边
|
||||||
|
(``labware`` → ``wells_identifier``)。
|
||||||
|
- merged 节点的 ``output_wells`` 通过**单条边**连到 transfer_liquid 的 ``targets_identifier``。
|
||||||
|
- transfer_liquid 节点的 ``params.targets`` 被改写为 synthetic key
|
||||||
|
``_merged_targets_<idx>``(runtime 不消费 list 形态),保证 INPUT_PORT_MAPPING 走单边路径。
|
||||||
|
|
||||||
|
用例
|
||||||
|
----
|
||||||
|
- ``test_emit_merged_set_liquid_basic`` — 4 个 distinct reagent_key(51b9a5 主场景)。
|
||||||
|
- ``test_emit_merged_set_liquid_repeat_key`` — 同 reagent_key 重复(同板多孔)。
|
||||||
|
- ``test_emit_merged_set_liquid_mixed`` — 跨板混合 + 同板重复(cursor 推进)。
|
||||||
|
- ``test_emit_merged_set_liquid_8ch`` — 与 P1 multi-channel 复合(8 通道 cross-slot)。
|
||||||
|
- ``test_transfer_liquid_targets_rewrite`` — transfer_liquid 节点改写后只剩 1 条
|
||||||
|
``targets_identifier`` 入边;params.targets 不再是 list。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||||
|
if str(ROOT_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT_DIR))
|
||||||
|
|
||||||
|
|
||||||
|
def _install_fake_optional_deps() -> None:
|
||||||
|
"""与 test_build_protocol_graph_target_device.py 一致的可选依赖 stub。"""
|
||||||
|
if "matplotlib" not in sys.modules:
|
||||||
|
sys.modules["matplotlib"] = types.ModuleType("matplotlib")
|
||||||
|
if "matplotlib.pyplot" not in sys.modules:
|
||||||
|
sys.modules["matplotlib.pyplot"] = types.ModuleType("matplotlib.pyplot")
|
||||||
|
try:
|
||||||
|
from networkx.drawing import nx_agraph # noqa: F401
|
||||||
|
except Exception:
|
||||||
|
nx_drawing = types.ModuleType("networkx.drawing")
|
||||||
|
nx_agraph_mod = types.ModuleType("networkx.drawing.nx_agraph")
|
||||||
|
nx_agraph_mod.to_agraph = lambda _g: None # type: ignore[attr-defined]
|
||||||
|
nx_drawing.nx_agraph = nx_agraph_mod # type: ignore[attr-defined]
|
||||||
|
sys.modules["networkx.drawing"] = nx_drawing
|
||||||
|
sys.modules["networkx.drawing.nx_agraph"] = nx_agraph_mod
|
||||||
|
|
||||||
|
|
||||||
|
_install_fake_optional_deps()
|
||||||
|
|
||||||
|
import pytest # noqa: E402
|
||||||
|
|
||||||
|
from unilabos.workflow.common import build_protocol_graph # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 测试辅助:从工作流图中提取节点/边 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def _nodes_by_template(graph, template_name: str) -> List[Dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{"id": nid, **node}
|
||||||
|
for nid, node in graph.nodes.items()
|
||||||
|
if node.get("template_name") == template_name
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _create_resource_by_slot(graph) -> Dict[str, str]:
|
||||||
|
"""slot_on_deck (str) -> create_resource 节点 ID。"""
|
||||||
|
out: Dict[str, str] = {}
|
||||||
|
for nid, node in graph.nodes.items():
|
||||||
|
if node.get("template_name") == "create_resource":
|
||||||
|
slot = str(node.get("param", {}).get("slot_on_deck") or "")
|
||||||
|
if slot:
|
||||||
|
out[slot] = nid
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _edges_to(graph, target_id: str) -> List[Dict[str, Any]]:
|
||||||
|
return [e for e in graph.edges if e["target"] == target_id]
|
||||||
|
|
||||||
|
|
||||||
|
def _edges_from(graph, source_id: str) -> List[Dict[str, Any]]:
|
||||||
|
return [e for e in graph.edges if e["source"] == source_id]
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== fixture:构造跨板 labware + steps ====================
|
||||||
|
|
||||||
|
|
||||||
|
def _cross_slot_labware_info() -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""51b9a5 简化:slot1 source + slot2/3/5/6 target plates + slot12 tip。"""
|
||||||
|
return {
|
||||||
|
"l1": {
|
||||||
|
"slot": 1,
|
||||||
|
"well": ["A1"],
|
||||||
|
"labware": "nest_12_reservoir_15ml",
|
||||||
|
"object": "source",
|
||||||
|
},
|
||||||
|
"plate_slot2": {
|
||||||
|
"slot": 2,
|
||||||
|
"well": ["A1"],
|
||||||
|
"labware": "nest_96_wellplate_2ml_deep",
|
||||||
|
"object": "target",
|
||||||
|
},
|
||||||
|
"plate_slot3": {
|
||||||
|
"slot": 3,
|
||||||
|
"well": ["A1"],
|
||||||
|
"labware": "nest_96_wellplate_2ml_deep",
|
||||||
|
"object": "target",
|
||||||
|
},
|
||||||
|
"plate_slot5": {
|
||||||
|
"slot": 5,
|
||||||
|
"well": ["A1"],
|
||||||
|
"labware": "nest_96_wellplate_2ml_deep",
|
||||||
|
"object": "target",
|
||||||
|
},
|
||||||
|
"plate_slot6": {
|
||||||
|
"slot": 6,
|
||||||
|
"well": ["A1"],
|
||||||
|
"labware": "nest_96_wellplate_2ml_deep",
|
||||||
|
"object": "target",
|
||||||
|
},
|
||||||
|
"tiprack_12": {
|
||||||
|
"slot": 12,
|
||||||
|
"well": [],
|
||||||
|
"labware": "opentrons_96_tiprack_300ul",
|
||||||
|
"object": "tiprack",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _cross_slot_protocol_steps(targets: List[str], dis_vols: List[float]) -> List[Dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"parameters": {
|
||||||
|
"sources": "l1",
|
||||||
|
"targets": targets,
|
||||||
|
"tip_racks": "tiprack_12",
|
||||||
|
"asp_vols": dis_vols.copy(),
|
||||||
|
"dis_vols": dis_vols.copy(),
|
||||||
|
},
|
||||||
|
"step_number": 1,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 用例 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def test_emit_merged_set_liquid_basic():
|
||||||
|
"""51b9a5 主场景:targets=[A,B,C,D] → 1 merged set_liquid 节点
|
||||||
|
+ 4 条入边(来自 4 个 distinct create_resource)+ 1 条出边(去 transfer_liquid)。
|
||||||
|
"""
|
||||||
|
targets = ["plate_slot2", "plate_slot3", "plate_slot5", "plate_slot6"]
|
||||||
|
dis_vols = [8.3, 8.3, 8.3, 8.3]
|
||||||
|
g = build_protocol_graph(
|
||||||
|
labware_info=_cross_slot_labware_info(),
|
||||||
|
protocol_steps=_cross_slot_protocol_steps(targets, dis_vols),
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
|
||||||
|
set_liquid_nodes = _nodes_by_template(g, "set_liquid_from_plate")
|
||||||
|
merged_nodes = [n for n in set_liquid_nodes if str(n.get("name", "")).startswith("_merged_targets_")]
|
||||||
|
assert len(merged_nodes) == 1, (
|
||||||
|
f"应有且仅有 1 个 merged set_liquid_from_plate 节点(v2 跨板聚合器);"
|
||||||
|
f" 实际找到 {len(merged_nodes)}: {[n.get('name') for n in merged_nodes]}"
|
||||||
|
)
|
||||||
|
merged = merged_nodes[0]
|
||||||
|
merged_id = merged["id"]
|
||||||
|
|
||||||
|
# param.wells:长度 4,每元素的 parent 是对应 reagent_key
|
||||||
|
wells = merged.get("param", {}).get("wells") or []
|
||||||
|
assert len(wells) == 4
|
||||||
|
assert [w["parent"] for w in wells] == targets, "merged.wells 顺序必须严格按 targets 列表"
|
||||||
|
# well 字段映射到 reagent.well[0](都是 "A1")
|
||||||
|
for w, key in zip(wells, targets):
|
||||||
|
assert w["id"].endswith("/A1"), f"well id 应包含 well 名: {w}"
|
||||||
|
assert w["parent"] == key
|
||||||
|
|
||||||
|
# 入边:4 条来自 distinct create_resource 节点(slot 2/3/5/6),target_port=wells_identifier
|
||||||
|
cr_by_slot = _create_resource_by_slot(g)
|
||||||
|
in_edges = _edges_to(g, merged_id)
|
||||||
|
in_sources = {e["source"] for e in in_edges if e.get("target_handle_key") == "wells_identifier"}
|
||||||
|
expected_sources = {cr_by_slot[s] for s in ("2", "3", "5", "6")}
|
||||||
|
assert in_sources == expected_sources, (
|
||||||
|
f"merged 节点应接收 4 个 distinct create_resource 的 wells_identifier 边;"
|
||||||
|
f" 实际 {in_sources} vs 期望 {expected_sources}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 出边:1 条到 transfer_liquid(targets_identifier)
|
||||||
|
transfer_nodes = _nodes_by_template(g, "transfer_liquid")
|
||||||
|
assert len(transfer_nodes) == 1
|
||||||
|
transfer_id = transfer_nodes[0]["id"]
|
||||||
|
out_to_transfer = [
|
||||||
|
e for e in _edges_from(g, merged_id)
|
||||||
|
if e["target"] == transfer_id and e.get("target_handle_key") == "targets_identifier"
|
||||||
|
]
|
||||||
|
assert len(out_to_transfer) == 1, (
|
||||||
|
f"merged 节点应向 transfer_liquid.targets_identifier 发出唯一 1 条边;"
|
||||||
|
f" 实际 {len(out_to_transfer)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_emit_merged_set_liquid_repeat_key():
|
||||||
|
"""同 reagent_key 重复(同板多孔):targets=[A,A,A] + reagent.A.well=[A1,A2,A3]
|
||||||
|
→ merged.wells 顺序 = [A/A1, A/A2, A/A3](cursor 推进取每个 well)。
|
||||||
|
"""
|
||||||
|
labware = _cross_slot_labware_info()
|
||||||
|
labware["plate_slot2"]["well"] = ["A1", "A2", "A3"]
|
||||||
|
|
||||||
|
targets = ["plate_slot2", "plate_slot2", "plate_slot2"]
|
||||||
|
dis_vols = [10.0, 20.0, 30.0]
|
||||||
|
g = build_protocol_graph(
|
||||||
|
labware_info=labware,
|
||||||
|
protocol_steps=_cross_slot_protocol_steps(targets, dis_vols),
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
|
||||||
|
merged_nodes = [
|
||||||
|
n for n in _nodes_by_template(g, "set_liquid_from_plate")
|
||||||
|
if str(n.get("name", "")).startswith("_merged_targets_")
|
||||||
|
]
|
||||||
|
assert len(merged_nodes) == 1
|
||||||
|
wells = merged_nodes[0]["param"]["wells"]
|
||||||
|
assert [w["id"].rsplit("/", 1)[-1] for w in wells] == ["A1", "A2", "A3"], (
|
||||||
|
"cursor 应依次取 reagent.A.well[0/1/2]"
|
||||||
|
)
|
||||||
|
assert all(w["parent"] == "plate_slot2" for w in wells)
|
||||||
|
|
||||||
|
|
||||||
|
def test_emit_merged_set_liquid_mixed():
|
||||||
|
"""跨板 + 同板重复:targets=[A,B,A,C] + reagent.A.well=[A1,A2]
|
||||||
|
→ merged.wells = [A/A1, B/A1, A/A2, C/A1]。
|
||||||
|
"""
|
||||||
|
labware = _cross_slot_labware_info()
|
||||||
|
labware["plate_slot2"]["well"] = ["A1", "A2"]
|
||||||
|
|
||||||
|
targets = ["plate_slot2", "plate_slot3", "plate_slot2", "plate_slot5"]
|
||||||
|
dis_vols = [10.0, 20.0, 30.0, 40.0]
|
||||||
|
g = build_protocol_graph(
|
||||||
|
labware_info=labware,
|
||||||
|
protocol_steps=_cross_slot_protocol_steps(targets, dis_vols),
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
|
||||||
|
merged_nodes = [
|
||||||
|
n for n in _nodes_by_template(g, "set_liquid_from_plate")
|
||||||
|
if str(n.get("name", "")).startswith("_merged_targets_")
|
||||||
|
]
|
||||||
|
assert len(merged_nodes) == 1
|
||||||
|
wells = merged_nodes[0]["param"]["wells"]
|
||||||
|
ids = [(w["parent"], w["id"].rsplit("/", 1)[-1]) for w in wells]
|
||||||
|
assert ids == [
|
||||||
|
("plate_slot2", "A1"),
|
||||||
|
("plate_slot3", "A1"),
|
||||||
|
("plate_slot2", "A2"),
|
||||||
|
("plate_slot5", "A1"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_emit_merged_set_liquid_8ch():
|
||||||
|
"""与 P1 multi-channel 复合:targets=[A]*8+[B]*8(每列 8 通道)。
|
||||||
|
|
||||||
|
merged.wells 长度 16,前 8 全 plate_slot2 的 8 个 well,后 8 全 plate_slot3 的 8 个 well。
|
||||||
|
"""
|
||||||
|
labware = _cross_slot_labware_info()
|
||||||
|
# 8 通道场景 reagent.well 已被 P1 multi 展开为长度 8
|
||||||
|
labware["plate_slot2"]["well"] = [f"{r}1" for r in "ABCDEFGH"]
|
||||||
|
labware["plate_slot3"]["well"] = [f"{r}1" for r in "ABCDEFGH"]
|
||||||
|
|
||||||
|
targets = ["plate_slot2"] * 8 + ["plate_slot3"] * 8
|
||||||
|
dis_vols = [5.0] * 16
|
||||||
|
g = build_protocol_graph(
|
||||||
|
labware_info=labware,
|
||||||
|
protocol_steps=_cross_slot_protocol_steps(targets, dis_vols),
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
|
||||||
|
merged_nodes = [
|
||||||
|
n for n in _nodes_by_template(g, "set_liquid_from_plate")
|
||||||
|
if str(n.get("name", "")).startswith("_merged_targets_")
|
||||||
|
]
|
||||||
|
assert len(merged_nodes) == 1
|
||||||
|
wells = merged_nodes[0]["param"]["wells"]
|
||||||
|
assert len(wells) == 16
|
||||||
|
# 前 8 全 plate_slot2,后 8 全 plate_slot3(满足 cross-slot × 8ch 列对齐约束)
|
||||||
|
assert all(w["parent"] == "plate_slot2" for w in wells[:8])
|
||||||
|
assert all(w["parent"] == "plate_slot3" for w in wells[8:])
|
||||||
|
# well 名顺序:A1..H1 重复两遍
|
||||||
|
assert [w["id"].rsplit("/", 1)[-1] for w in wells[:8]] == [f"{r}1" for r in "ABCDEFGH"]
|
||||||
|
assert [w["id"].rsplit("/", 1)[-1] for w in wells[8:]] == [f"{r}1" for r in "ABCDEFGH"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_transfer_liquid_targets_rewrite():
|
||||||
|
"""transfer_liquid 节点改写后只剩 1 条 targets_identifier 入边;params.targets 不再是 list。"""
|
||||||
|
targets = ["plate_slot2", "plate_slot3", "plate_slot5", "plate_slot6"]
|
||||||
|
dis_vols = [8.3, 8.3, 8.3, 8.3]
|
||||||
|
g = build_protocol_graph(
|
||||||
|
labware_info=_cross_slot_labware_info(),
|
||||||
|
protocol_steps=_cross_slot_protocol_steps(targets, dis_vols),
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
|
||||||
|
transfer_nodes = _nodes_by_template(g, "transfer_liquid")
|
||||||
|
assert len(transfer_nodes) == 1
|
||||||
|
tnode = transfer_nodes[0]
|
||||||
|
transfer_id = tnode["id"]
|
||||||
|
|
||||||
|
# params.targets:v2 中 list 形态在 INPUT_PORT_MAPPING 处理后被清空([])或为单字符串
|
||||||
|
# (不再是原始 list[str]——避免下游 runtime 对其再做无序聚合)
|
||||||
|
tparams = tnode.get("param", {}) or {}
|
||||||
|
assert not isinstance(tparams.get("targets"), list) or tparams.get("targets") == [], (
|
||||||
|
f"v2:params.targets 不再是非空 list;实际 {tparams.get('targets')!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# targets_identifier 端口:只有 1 条入边
|
||||||
|
in_targets_edges = [
|
||||||
|
e for e in _edges_to(g, transfer_id)
|
||||||
|
if e.get("target_handle_key") == "targets_identifier"
|
||||||
|
]
|
||||||
|
assert len(in_targets_edges) == 1, (
|
||||||
|
f"v2:transfer_liquid.targets_identifier 必须是单入边(来自 merged set_liquid);"
|
||||||
|
f" 实际 {len(in_targets_edges)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 这条入边的源端口必须是 output_wells
|
||||||
|
edge = in_targets_edges[0]
|
||||||
|
assert edge.get("source_handle_key") == "output_wells"
|
||||||
|
|
||||||
|
|
||||||
|
def test_str_targets_no_merged_node_emitted():
|
||||||
|
"""对照组:targets 为 str(单 reagent) → 不插入 merged set_liquid_from_plate 节点。
|
||||||
|
|
||||||
|
保证 v2 改造**只**对 list 形态触发,单 reagent 走 P3 原有 per-plate set_liquid 路径。
|
||||||
|
"""
|
||||||
|
labware = _cross_slot_labware_info()
|
||||||
|
labware["plate_slot2"]["well"] = ["A1", "A2", "A3"]
|
||||||
|
|
||||||
|
steps = [
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"parameters": {
|
||||||
|
"sources": "l1",
|
||||||
|
"targets": "plate_slot2", # ← 单 str,非 list
|
||||||
|
"tip_racks": "tiprack_12",
|
||||||
|
"asp_vols": [8.3, 8.3, 8.3],
|
||||||
|
"dis_vols": [8.3, 8.3, 8.3],
|
||||||
|
},
|
||||||
|
"step_number": 1,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
g = build_protocol_graph(
|
||||||
|
labware_info=labware,
|
||||||
|
protocol_steps=steps,
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
merged_nodes = [
|
||||||
|
n for n in _nodes_by_template(g, "set_liquid_from_plate")
|
||||||
|
if str(n.get("name", "")).startswith("_merged_targets_")
|
||||||
|
]
|
||||||
|
assert merged_nodes == [], "str 形态 targets 不应触发 v2 merged 聚合节点"
|
||||||
452
tests/workflow/test_common_liquid_name_from_reagent.py
Normal file
452
tests/workflow/test_common_liquid_name_from_reagent.py
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
"""P8 — Stage 3 (``workflow/common.py``) 写入 ``set_liquid_from_plate.param.liquid_names`` 时
|
||||||
|
优先取 ``reagent[key].liquid_name``,缺省时 fallback 到 reagent_key。
|
||||||
|
|
||||||
|
对应 ``product_designs/protocol_convert/08-liquid-name-from-reagent-block.md`` §3.4 + §5。
|
||||||
|
|
||||||
|
设计要点
|
||||||
|
--------
|
||||||
|
- ``reagent[key].liquid_name`` 是 P8 新增的**可选**字段,承载真实化学名(与 reagent_key
|
||||||
|
解耦:reagent_key 仍是数据流引用名 / 业务别名,``liquid_name`` 是写入 PLR tracker /
|
||||||
|
前端的 human-readable 名称)。
|
||||||
|
- ``liquid_name`` 来源优先级:Stage 0 mock ``Well.load_liquid(liquid=...)`` 实参 >
|
||||||
|
README 语义词 > 不写(Stage 3 fallback 到 reagent_key)。
|
||||||
|
- ``liquid_name`` 保留空格 / 中文 / 括号等原字符,**不**做 snake_case / underscore 替换。
|
||||||
|
- 旧 JSON(无 ``liquid_name`` 字段)行为完全不变(设计点 §7.A)。
|
||||||
|
|
||||||
|
测试用例
|
||||||
|
--------
|
||||||
|
- ``test_per_plate_fallback_when_no_liquid_name`` —— 缺省 fallback:
|
||||||
|
reagent 块无 ``liquid_name`` → liquid_names[i] == reagent_key(与 P8 前一致)。
|
||||||
|
- ``test_per_plate_uses_explicit_liquid_name`` —— 显式 liquid_name:
|
||||||
|
liquid_names[i] == "EDTA Plasma"。
|
||||||
|
- ``test_per_plate_preserves_spaces_and_special_chars`` —— 含空格 / 括号:
|
||||||
|
liquid_names[i] 不被 ``replace(" ", "_")`` 处理(不同于 reagent_key 用的 res_id)。
|
||||||
|
- ``test_merged_node_uses_explicit_liquid_name_per_dispense`` —— merged 节点
|
||||||
|
每个 dispense 独立取 ``liquid_name or key``,部分有部分无能共存。
|
||||||
|
- ``test_liquid_name_independent_of_reagent_key_normalization`` —— 与 P4 共存:
|
||||||
|
reagent_key 仍是 ``samples_2`` 等去重后缀,但 liquid_names 写的是真实化学名。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||||
|
if str(ROOT_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT_DIR))
|
||||||
|
|
||||||
|
|
||||||
|
def _install_fake_optional_deps() -> None:
|
||||||
|
"""与 test_common_set_liquid_dedup.py 一致的可选依赖 stub。"""
|
||||||
|
if "matplotlib" not in sys.modules:
|
||||||
|
sys.modules["matplotlib"] = types.ModuleType("matplotlib")
|
||||||
|
if "matplotlib.pyplot" not in sys.modules:
|
||||||
|
sys.modules["matplotlib.pyplot"] = types.ModuleType("matplotlib.pyplot")
|
||||||
|
try:
|
||||||
|
from networkx.drawing import nx_agraph # noqa: F401
|
||||||
|
except Exception:
|
||||||
|
nx_drawing = types.ModuleType("networkx.drawing")
|
||||||
|
nx_agraph_mod = types.ModuleType("networkx.drawing.nx_agraph")
|
||||||
|
nx_agraph_mod.to_agraph = lambda _g: None # type: ignore[attr-defined]
|
||||||
|
nx_drawing.nx_agraph = nx_agraph_mod # type: ignore[attr-defined]
|
||||||
|
sys.modules["networkx.drawing"] = nx_drawing
|
||||||
|
sys.modules["networkx.drawing.nx_agraph"] = nx_agraph_mod
|
||||||
|
|
||||||
|
|
||||||
|
_install_fake_optional_deps()
|
||||||
|
|
||||||
|
import pytest # noqa: E402
|
||||||
|
|
||||||
|
from unilabos.workflow.common import build_protocol_graph # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 辅助 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def _set_liquid_nodes(graph) -> List[Dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{"id": nid, **node}
|
||||||
|
for nid, node in graph.nodes.items()
|
||||||
|
if node.get("template_name") == "set_liquid_from_plate"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _per_plate_for(graph, reagent_key: str) -> Dict[str, Any]:
|
||||||
|
"""根据 ``description = "Set liquid: <reagent_key>"`` 反查 per-plate 节点。"""
|
||||||
|
for n in _set_liquid_nodes(graph):
|
||||||
|
if n.get("description") == f"Set liquid: {reagent_key}":
|
||||||
|
return n
|
||||||
|
raise AssertionError(f"未找到 per-plate set_liquid_from_plate(reagent_key={reagent_key!r})")
|
||||||
|
|
||||||
|
|
||||||
|
def _merged_nodes(graph) -> List[Dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
n for n in _set_liquid_nodes(graph)
|
||||||
|
if str(n.get("name", "")).startswith("_merged_targets_")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _make_source_target_labware(
|
||||||
|
*,
|
||||||
|
source_key: str = "src_1",
|
||||||
|
source_liquid_name: str | None = None,
|
||||||
|
target_keys: List[str] | None = None,
|
||||||
|
target_liquid_names: Dict[str, str] | None = None,
|
||||||
|
) -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""构造 1 个 source + N 个 target reagent + 1 个 tip rack。
|
||||||
|
|
||||||
|
``*_liquid_name`` 为 None / 缺省时**不**写入 ``liquid_name`` 字段,
|
||||||
|
模拟旧 schema / mock 未给 liquid_name 的真实回归场景。
|
||||||
|
"""
|
||||||
|
info: Dict[str, Dict[str, Any]] = {}
|
||||||
|
source_entry: Dict[str, Any] = {
|
||||||
|
"slot": 1,
|
||||||
|
"well": ["A1"],
|
||||||
|
"labware": "nest_12_reservoir_15ml",
|
||||||
|
"object": "source",
|
||||||
|
}
|
||||||
|
if source_liquid_name is not None:
|
||||||
|
source_entry["liquid_name"] = source_liquid_name
|
||||||
|
info[source_key] = source_entry
|
||||||
|
|
||||||
|
target_keys = target_keys or ["t_A"]
|
||||||
|
target_liquid_names = target_liquid_names or {}
|
||||||
|
for i, tk in enumerate(target_keys, start=1):
|
||||||
|
entry: Dict[str, Any] = {
|
||||||
|
"slot": 2 + i,
|
||||||
|
"well": ["A1"],
|
||||||
|
"labware": "nest_96_wellplate_2ml_deep",
|
||||||
|
"object": "target",
|
||||||
|
}
|
||||||
|
if tk in target_liquid_names:
|
||||||
|
entry["liquid_name"] = target_liquid_names[tk]
|
||||||
|
info[tk] = entry
|
||||||
|
|
||||||
|
info["tiprack_12"] = {
|
||||||
|
"slot": 12,
|
||||||
|
"well": [],
|
||||||
|
"labware": "opentrons_96_tiprack_300ul",
|
||||||
|
"object": "tiprack",
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== T1 缺省 fallback ====================
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_plate_fallback_when_no_liquid_name():
|
||||||
|
"""reagent block 无 ``liquid_name`` 字段 → liquid_names[i] == reagent_key(P8 前行为)。"""
|
||||||
|
labware = _make_source_target_labware(
|
||||||
|
source_key="src_1",
|
||||||
|
target_keys=["t_A"],
|
||||||
|
# 都不给 liquid_name
|
||||||
|
)
|
||||||
|
steps = [
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"parameters": {
|
||||||
|
"sources": "src_1",
|
||||||
|
"targets": "t_A",
|
||||||
|
"tip_racks": "tiprack_12",
|
||||||
|
"asp_vols": [10.0],
|
||||||
|
"dis_vols": [10.0],
|
||||||
|
},
|
||||||
|
"step_number": 1,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
g = build_protocol_graph(
|
||||||
|
labware_info=labware,
|
||||||
|
protocol_steps=steps,
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
|
||||||
|
src_node = _per_plate_for(g, "src_1")
|
||||||
|
tgt_node = _per_plate_for(g, "t_A")
|
||||||
|
assert src_node["param"]["liquid_names"] == ["src_1"], (
|
||||||
|
f"无 liquid_name 时 source per-plate 应 fallback 到 reagent_key;"
|
||||||
|
f" 实际 {src_node['param']['liquid_names']}"
|
||||||
|
)
|
||||||
|
assert tgt_node["param"]["liquid_names"] == ["t_A"], (
|
||||||
|
f"无 liquid_name 时 target per-plate 应 fallback 到 reagent_key;"
|
||||||
|
f" 实际 {tgt_node['param']['liquid_names']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== T2 显式 liquid_name ====================
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_plate_uses_explicit_liquid_name():
|
||||||
|
"""reagent block 含 ``liquid_name`` → liquid_names[i] 用该值(不是 reagent_key)。"""
|
||||||
|
labware = _make_source_target_labware(
|
||||||
|
source_key="src_1",
|
||||||
|
source_liquid_name="EDTA Plasma",
|
||||||
|
target_keys=["t_A"],
|
||||||
|
target_liquid_names={"t_A": "PBS Diluent"},
|
||||||
|
)
|
||||||
|
steps = [
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"parameters": {
|
||||||
|
"sources": "src_1",
|
||||||
|
"targets": "t_A",
|
||||||
|
"tip_racks": "tiprack_12",
|
||||||
|
"asp_vols": [10.0],
|
||||||
|
"dis_vols": [10.0],
|
||||||
|
},
|
||||||
|
"step_number": 1,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
g = build_protocol_graph(
|
||||||
|
labware_info=labware,
|
||||||
|
protocol_steps=steps,
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
|
||||||
|
src_node = _per_plate_for(g, "src_1")
|
||||||
|
tgt_node = _per_plate_for(g, "t_A")
|
||||||
|
assert src_node["param"]["liquid_names"] == ["EDTA Plasma"], (
|
||||||
|
f"source per-plate 应使用 reagent.liquid_name;实际 {src_node['param']['liquid_names']}"
|
||||||
|
)
|
||||||
|
assert tgt_node["param"]["liquid_names"] == ["PBS Diluent"], (
|
||||||
|
f"target per-plate 应使用 reagent.liquid_name;实际 {tgt_node['param']['liquid_names']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== T3 空格 / 括号 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_plate_preserves_spaces_and_special_chars():
|
||||||
|
"""``liquid_name`` 保留空格 / 括号 / 中文等原字符,不被 replace(' ', '_') 处理。
|
||||||
|
|
||||||
|
这条与 reagent_key 走 ``res_id = str(labware_id).replace(' ', '_')`` 的语义不同。
|
||||||
|
"""
|
||||||
|
labware = _make_source_target_labware(
|
||||||
|
source_key="src_1",
|
||||||
|
source_liquid_name="Tris HCl pH 8.0 (1×)",
|
||||||
|
target_keys=["t_A"],
|
||||||
|
target_liquid_names={"t_A": "稀释液 A"},
|
||||||
|
)
|
||||||
|
steps = [
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"parameters": {
|
||||||
|
"sources": "src_1",
|
||||||
|
"targets": "t_A",
|
||||||
|
"tip_racks": "tiprack_12",
|
||||||
|
"asp_vols": [10.0],
|
||||||
|
"dis_vols": [10.0],
|
||||||
|
},
|
||||||
|
"step_number": 1,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
g = build_protocol_graph(
|
||||||
|
labware_info=labware,
|
||||||
|
protocol_steps=steps,
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
|
||||||
|
src_node = _per_plate_for(g, "src_1")
|
||||||
|
tgt_node = _per_plate_for(g, "t_A")
|
||||||
|
|
||||||
|
assert src_node["param"]["liquid_names"] == ["Tris HCl pH 8.0 (1×)"], (
|
||||||
|
f"空格 / 括号应原样保留;实际 {src_node['param']['liquid_names']}"
|
||||||
|
)
|
||||||
|
assert tgt_node["param"]["liquid_names"] == ["稀释液 A"], (
|
||||||
|
f"中文应原样保留;实际 {tgt_node['param']['liquid_names']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# reagent_key 自身仍受 ``res_id = replace(' ', '_')`` 影响,
|
||||||
|
# 但本测试 reagent_key 不含空格,故 sl_node_title 仍以 reagent_key 为根。
|
||||||
|
# 这里仅断言 liquid_names 字段独立于 reagent_key normalize。
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== T4 merged 节点跨板部分有部分无 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def test_merged_node_uses_explicit_liquid_name_per_dispense():
|
||||||
|
"""merged 节点 ``liquid_names`` 与 list-targets 同长,每个元素独立取
|
||||||
|
``reagent[key].liquid_name or key``:本例 3 个 target,2 个有显式名、1 个无。
|
||||||
|
"""
|
||||||
|
labware = _make_source_target_labware(
|
||||||
|
source_key="src_1",
|
||||||
|
target_keys=["t_A", "t_B", "t_C"],
|
||||||
|
target_liquid_names={
|
||||||
|
"t_A": "Plasma",
|
||||||
|
# t_B 无 liquid_name
|
||||||
|
"t_C": "Buffer X",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
steps = [
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"parameters": {
|
||||||
|
"sources": "src_1",
|
||||||
|
"targets": ["t_A", "t_B", "t_C"],
|
||||||
|
"tip_racks": "tiprack_12",
|
||||||
|
"asp_vols": [5.0] * 3,
|
||||||
|
"dis_vols": [5.0] * 3,
|
||||||
|
},
|
||||||
|
"step_number": 1,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
g = build_protocol_graph(
|
||||||
|
labware_info=labware,
|
||||||
|
protocol_steps=steps,
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
|
||||||
|
merged = _merged_nodes(g)
|
||||||
|
assert len(merged) == 1, f"应有 1 个 merged 节点,实际 {len(merged)}"
|
||||||
|
liquid_names = merged[0]["param"]["liquid_names"]
|
||||||
|
assert liquid_names == ["Plasma", "t_B", "Buffer X"], (
|
||||||
|
f"merged 每 dispense 独立取 liquid_name or key;实际 {liquid_names}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== T5 与 P4 reagent_key 后缀共存 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def test_liquid_name_independent_of_reagent_key_normalization():
|
||||||
|
"""P4 命名链产生 ``samples_2`` 这种带后缀的 reagent_key(跨板去重);
|
||||||
|
P8 ``liquid_name`` 应保持原始化学名,**不**带 P4 的去重后缀。
|
||||||
|
|
||||||
|
构造:2 个 target reagent_keys ``samples`` / ``samples_2``(不同 slot,
|
||||||
|
模拟跨板同液体被 Stage 2 去重),都标 liquid_name="Bacterial Culture"。
|
||||||
|
"""
|
||||||
|
labware = _make_source_target_labware(
|
||||||
|
source_key="src_1",
|
||||||
|
target_keys=["samples", "samples_2"],
|
||||||
|
target_liquid_names={
|
||||||
|
"samples": "Bacterial Culture",
|
||||||
|
"samples_2": "Bacterial Culture",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
steps = [
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"parameters": {
|
||||||
|
"sources": "src_1",
|
||||||
|
"targets": ["samples", "samples_2"],
|
||||||
|
"tip_racks": "tiprack_12",
|
||||||
|
"asp_vols": [5.0, 5.0],
|
||||||
|
"dis_vols": [5.0, 5.0],
|
||||||
|
},
|
||||||
|
"step_number": 1,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
g = build_protocol_graph(
|
||||||
|
labware_info=labware,
|
||||||
|
protocol_steps=steps,
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
|
||||||
|
merged = _merged_nodes(g)
|
||||||
|
assert len(merged) == 1
|
||||||
|
liquid_names = merged[0]["param"]["liquid_names"]
|
||||||
|
assert liquid_names == ["Bacterial Culture", "Bacterial Culture"], (
|
||||||
|
f"P8 liquid_name 应与 P4 reagent_key 后缀解耦:同液体的两个 reagent_key 应得相同"
|
||||||
|
f" liquid_name;实际 {liquid_names}"
|
||||||
|
)
|
||||||
|
# 同时 reagent_key 仍是 samples / samples_2(不变)
|
||||||
|
wells = merged[0]["param"]["wells"]
|
||||||
|
parents = [w["parent"] for w in wells]
|
||||||
|
assert parents == ["samples", "samples_2"], (
|
||||||
|
f"merged wells.parent 应等于 list-targets reagent_keys;实际 {parents}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== T6 source per-plate / target per-plate 同步生效 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def test_both_source_and_target_per_plate_use_liquid_name():
|
||||||
|
"""str-targets 路径(无 merged)下,source 和 target 都走 per-plate emit,
|
||||||
|
各自独立取 ``liquid_name``。"""
|
||||||
|
labware = _make_source_target_labware(
|
||||||
|
source_key="src_1",
|
||||||
|
source_liquid_name="Reagent A",
|
||||||
|
target_keys=["t_A"],
|
||||||
|
target_liquid_names={"t_A": "Reagent B"},
|
||||||
|
)
|
||||||
|
steps = [
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"parameters": {
|
||||||
|
"sources": "src_1",
|
||||||
|
"targets": "t_A", # str-targets,不触发 merged
|
||||||
|
"tip_racks": "tiprack_12",
|
||||||
|
"asp_vols": [10.0],
|
||||||
|
"dis_vols": [10.0],
|
||||||
|
},
|
||||||
|
"step_number": 1,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
g = build_protocol_graph(
|
||||||
|
labware_info=labware,
|
||||||
|
protocol_steps=steps,
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _merged_nodes(g) == [], "str-targets 不应产生 merged 节点"
|
||||||
|
src_node = _per_plate_for(g, "src_1")
|
||||||
|
tgt_node = _per_plate_for(g, "t_A")
|
||||||
|
assert src_node["param"]["liquid_names"] == ["Reagent A"]
|
||||||
|
assert tgt_node["param"]["liquid_names"] == ["Reagent B"]
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== T7 多孔同 reagent → 整列 liquid_names 一致 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_well_reagent_replicates_liquid_name():
|
||||||
|
"""1 个 reagent 含 8 wells(multi-channel 扩展场景)→ liquid_names 应是
|
||||||
|
``[liquid_name] * 8``,与 wells 长度一致。"""
|
||||||
|
labware: Dict[str, Dict[str, Any]] = {
|
||||||
|
"src_1": {
|
||||||
|
"slot": 1,
|
||||||
|
"well": ["A1", "B1", "C1", "D1", "E1", "F1", "G1", "H1"],
|
||||||
|
"labware": "nest_96_wellplate_100ul_pcr_full_skirt",
|
||||||
|
"object": "source",
|
||||||
|
"liquid_name": "Mastermix",
|
||||||
|
},
|
||||||
|
"t_A": {
|
||||||
|
"slot": 3,
|
||||||
|
"well": ["A1"],
|
||||||
|
"labware": "nest_96_wellplate_2ml_deep",
|
||||||
|
"object": "target",
|
||||||
|
},
|
||||||
|
"tiprack_12": {
|
||||||
|
"slot": 12,
|
||||||
|
"well": [],
|
||||||
|
"labware": "opentrons_96_tiprack_300ul",
|
||||||
|
"object": "tiprack",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
steps = [
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"parameters": {
|
||||||
|
"sources": "src_1",
|
||||||
|
"targets": "t_A",
|
||||||
|
"tip_racks": "tiprack_12",
|
||||||
|
"asp_vols": [10.0],
|
||||||
|
"dis_vols": [10.0],
|
||||||
|
},
|
||||||
|
"step_number": 1,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
g = build_protocol_graph(
|
||||||
|
labware_info=labware,
|
||||||
|
protocol_steps=steps,
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
|
||||||
|
src_node = _per_plate_for(g, "src_1")
|
||||||
|
liquid_names = src_node["param"]["liquid_names"]
|
||||||
|
assert liquid_names == ["Mastermix"] * 8, (
|
||||||
|
f"per-plate 应把 liquid_name 复制 well_count 份;实际 {liquid_names}"
|
||||||
|
)
|
||||||
|
# 同时 wells / volumes 长度一致
|
||||||
|
assert len(src_node["param"]["wells"]) == 8
|
||||||
|
assert len(src_node["param"]["volumes"]) == 8
|
||||||
174
tests/workflow/test_common_plate_num_children_hint.py
Normal file
174
tests/workflow/test_common_plate_num_children_hint.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"""P6 §17 hint bug —— `_infer_plate_num_children_from_labware_hint` 误把
|
||||||
|
reagent_id 末尾数字(如 ``samples_6`` 的 ``_6``)当作孔板规格,导致
|
||||||
|
``_apply_target_labware_class_auto_match`` fallback 到 PRCXI 4-孔 trough 模板。
|
||||||
|
|
||||||
|
跨板 fix(P2 v2 §14)把 plate name 作为 prefix 编码进 ``well_names`` 之后,
|
||||||
|
runtime 调用 ``plate.get_well("A5")`` 严格定位 well,trough plate 上不存在
|
||||||
|
``A5`` 会直接 IndexError,使得这个隐藏多年的孔数推断 bug 浮出。
|
||||||
|
|
||||||
|
修复策略(方案 A)
|
||||||
|
-----
|
||||||
|
hint 只用 ``item.get("labware", "")``,**不再**拼上 ``labware_id``(reagent_key
|
||||||
|
是业务名,不应参与孔板规格推断)。
|
||||||
|
|
||||||
|
测试矩阵
|
||||||
|
----
|
||||||
|
- ``test_reagent_key_numeric_suffix_must_not_match_hint`` —— samples_6 / samples_24 /
|
||||||
|
samples_96 + nunc_rectangular_agar_plate → hint 返回 None(labware string 不带孔数信息)。
|
||||||
|
- ``test_labware_string_X_well_correctly_inferred`` —— labware="nest_96_wellplate..." → 96;
|
||||||
|
"custom_384_wellplate" → 384;"nest_24_wellplate_2ml_pcr" → 24。
|
||||||
|
- ``test_apply_does_not_classify_samples_6_as_trough`` —— 集成:构造 Agar Plating-like
|
||||||
|
reagent block(slot 8 上 12 个 samples_X,X 末尾含 6/24/96),跑
|
||||||
|
``_apply_target_labware_class_auto_match`` 后,samples_6/24 不再得到 trough class。
|
||||||
|
- ``test_real_labware_96_wellplate_still_inferred_via_labware_str`` —— 即便 labware_id
|
||||||
|
与孔数无关,``nest_96_wellplate_100ul_pcr_full_skirt`` 这种 labware 命名仍应被识别为 96。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||||
|
if str(ROOT_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT_DIR))
|
||||||
|
|
||||||
|
|
||||||
|
def _install_fake_optional_deps() -> None:
|
||||||
|
if "matplotlib" not in sys.modules:
|
||||||
|
sys.modules["matplotlib"] = types.ModuleType("matplotlib")
|
||||||
|
if "matplotlib.pyplot" not in sys.modules:
|
||||||
|
sys.modules["matplotlib.pyplot"] = types.ModuleType("matplotlib.pyplot")
|
||||||
|
|
||||||
|
|
||||||
|
_install_fake_optional_deps()
|
||||||
|
|
||||||
|
import pytest # noqa: E402
|
||||||
|
|
||||||
|
from unilabos.workflow.common import ( # noqa: E402
|
||||||
|
_apply_target_labware_class_auto_match,
|
||||||
|
_infer_plate_num_children_from_labware_hint,
|
||||||
|
_reconcile_slot_carrier_target_class,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== unit:hint 函数本身 ====================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"labware_id",
|
||||||
|
["samples_6", "samples_24", "samples_96", "samples_12", "samples_48"],
|
||||||
|
)
|
||||||
|
def test_reagent_key_numeric_suffix_must_not_match_hint(labware_id):
|
||||||
|
"""reagent_id 末尾的孔数关键字数字不应被识别为孔板规格。"""
|
||||||
|
item = {
|
||||||
|
"slot": 8,
|
||||||
|
"well": ["A5"],
|
||||||
|
"labware": "nunc_rectangular_agar_plate",
|
||||||
|
"object": "target",
|
||||||
|
}
|
||||||
|
assert _infer_plate_num_children_from_labware_hint(labware_id, item) is None, (
|
||||||
|
f"reagent_id {labware_id!r} 不应被识别为孔板规格 "
|
||||||
|
f"(其末尾数字应当被忽略;labware string 不含 96/384/etc 关键字)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"labware_str,expected",
|
||||||
|
[
|
||||||
|
("nest_96_wellplate_100ul_pcr_full_skirt", 96),
|
||||||
|
("custom_384_wellplate", 384),
|
||||||
|
("nest_24_wellplate_2ml_pcr", 24),
|
||||||
|
("custom_48_wellplate", 48),
|
||||||
|
("opentrons_12_wellplate_15ml", 12),
|
||||||
|
("nest_6_wellplate_5ml", 6),
|
||||||
|
("nunc_rectangular_agar_plate", None),
|
||||||
|
("", None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_labware_string_well_count_inferred(labware_str, expected):
|
||||||
|
item = {"labware": labware_str}
|
||||||
|
assert (
|
||||||
|
_infer_plate_num_children_from_labware_hint("samples", item) == expected
|
||||||
|
), f"labware {labware_str!r} 应推断为 {expected!r}"
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== integration:模拟 Agar Plating ====================
|
||||||
|
|
||||||
|
|
||||||
|
def _agar_plating_reagent_block():
|
||||||
|
"""反推自 unilabos_data/req_workflow_upload.json:12 列 × 9 reagent per step。
|
||||||
|
|
||||||
|
slot 8 (mapped 14) 上 12 个 reagent_keys: samples_6, samples_15, samples_24,
|
||||||
|
samples_33, samples_42, samples_51, samples_60, samples_69, samples_78,
|
||||||
|
samples_87, samples_96, samples_105.
|
||||||
|
"""
|
||||||
|
info = {}
|
||||||
|
slot_for_idx = {0: 3, 1: 4, 2: 5, 3: 6, 4: 7, 5: 8, 6: 9, 7: 10, 8: 11}
|
||||||
|
cols = [f"A{i + 1}" for i in range(12)]
|
||||||
|
for col_i, col in enumerate(cols):
|
||||||
|
for di in range(9):
|
||||||
|
n = col_i * 9 + di + 1
|
||||||
|
key = "samples" if n == 1 else f"samples_{n}"
|
||||||
|
info[key] = {
|
||||||
|
"slot": slot_for_idx[di],
|
||||||
|
"well": [col],
|
||||||
|
"labware": "nunc_rectangular_agar_plate",
|
||||||
|
"object": "target",
|
||||||
|
}
|
||||||
|
for i in range(12):
|
||||||
|
key = "sources" if i == 0 else f"sources_{i + 1}"
|
||||||
|
info[key] = {
|
||||||
|
"slot": 2,
|
||||||
|
"well": [cols[i]],
|
||||||
|
"labware": "nest_96_wellplate_100ul_pcr_full_skirt",
|
||||||
|
"object": "source",
|
||||||
|
}
|
||||||
|
info["tiprack_1"] = {
|
||||||
|
"slot": 1,
|
||||||
|
"well": None,
|
||||||
|
"labware": "opentrons_96_tiprack_10ul",
|
||||||
|
"object": "tiprack",
|
||||||
|
}
|
||||||
|
info["trash"] = {
|
||||||
|
"slot": 12,
|
||||||
|
"well": None,
|
||||||
|
"labware": "opentrons_1_trash_1100ml_fixed",
|
||||||
|
"object": "trash",
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_does_not_classify_samples_6_as_trough():
|
||||||
|
"""集成回归:Agar Plating-like reagent block 跑完类匹配 + slot 统一后,
|
||||||
|
slot 8 上 12 个 reagent 不应得到 4-孔 trough class。"""
|
||||||
|
info = _agar_plating_reagent_block()
|
||||||
|
_apply_target_labware_class_auto_match(
|
||||||
|
info, preserve_tip_rack_incoming_class=True, target_device="prcxi"
|
||||||
|
)
|
||||||
|
_reconcile_slot_carrier_target_class(
|
||||||
|
info, preserve_tip_rack_incoming_class=True, target_device="prcxi"
|
||||||
|
)
|
||||||
|
slot8_keys = [
|
||||||
|
"samples_6", "samples_15", "samples_24", "samples_33",
|
||||||
|
"samples_42", "samples_51", "samples_60", "samples_69",
|
||||||
|
"samples_78", "samples_87", "samples_96", "samples_105",
|
||||||
|
]
|
||||||
|
for k in slot8_keys:
|
||||||
|
cls = info[k].get("target_class_name") or ""
|
||||||
|
assert "trough" not in cls.lower(), (
|
||||||
|
f"reagent {k} 被误识别为 trough class: {cls!r};"
|
||||||
|
"这通常是 hint 误把 reagent_id 末尾数字当孔板规格"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_labware_96_wellplate_still_inferred_via_labware_str():
|
||||||
|
"""labware string 含 96_wellplate 时应该正常识别为 96,不被 fix 破坏。"""
|
||||||
|
item = {
|
||||||
|
"slot": 2,
|
||||||
|
"well": ["A1"],
|
||||||
|
"labware": "nest_96_wellplate_100ul_pcr_full_skirt",
|
||||||
|
"object": "source",
|
||||||
|
}
|
||||||
|
assert _infer_plate_num_children_from_labware_hint("sources", item) == 96
|
||||||
379
tests/workflow/test_common_set_liquid_dedup.py
Normal file
379
tests/workflow/test_common_set_liquid_dedup.py
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
"""P2 v2 §14 set_liquid_from_plate 去重 —— Stage 3 (`workflow/common.py`) 集成测试。
|
||||||
|
|
||||||
|
对应 ``product_designs/protocol_convert/02-cross-slot-merge.md`` §14(2026-05-22 plan)。
|
||||||
|
|
||||||
|
§14 设计要点
|
||||||
|
-----------------
|
||||||
|
当 ``transfer_liquid.params.targets`` 是 ``list[str]`` 时,``_emit_merged_set_liquid``
|
||||||
|
已经为该 transfer 插入一个 merged ``set_liquid_from_plate`` 节点,
|
||||||
|
其 ``param.wells`` 聚合了 list 中所有 reagent_keys 的跨板 wells。
|
||||||
|
|
||||||
|
§14 之前:第二步循环(``for labware_id, item in labware_info.items()``)仍然为
|
||||||
|
list-targets 中出现的每个 reagent_key 创建一个 per-plate ``set_liquid_from_plate`` 节点,
|
||||||
|
导致**节点冗余**(per-plate 节点的 ``output_wells`` 对 transfer_liquid 的
|
||||||
|
``targets_identifier`` 边毫无贡献 —— transfer_liquid 单边只接 merged 节点)。
|
||||||
|
|
||||||
|
§14 改造:在第二步循环**之前**预扫描 protocol_steps,收集
|
||||||
|
``set_liquid_covered_by_merged: Set[str]``(出现在某个 list[str] targets 中的所有 keys)
|
||||||
|
与 ``set_liquid_referenced_by_str: Set[str]``(出现在 str targets 中的所有 keys)。
|
||||||
|
循环内对 ``object="target"`` 且 ``key ∈ covered ∧ key ∉ referenced_by_str`` 的 reagent_key
|
||||||
|
**跳过** per-plate 节点创建。
|
||||||
|
|
||||||
|
测试用例
|
||||||
|
----
|
||||||
|
- ``test_per_plate_skipped_when_covered_by_merged`` —— list-targets 覆盖的
|
||||||
|
target reagent_keys 不再产生 per-plate set_liquid_from_plate。
|
||||||
|
- ``test_per_plate_kept_when_also_referenced_by_str_targets`` —— R1 缓解:
|
||||||
|
同时被 list-targets 和 str-targets 引用的 reagent_key 仍保留 per-plate。
|
||||||
|
- ``test_str_targets_protocol_unaffected`` —— 单 slot 协议(仅 str-targets)
|
||||||
|
节点数完全不变(回归防护)。
|
||||||
|
- ``test_51b9a5_style_node_count`` —— 12 list-targets × len=9 大规模场景:
|
||||||
|
set_liquid_from_plate 总节点数 = source per-plate + merged + 0 target per-plate。
|
||||||
|
- ``test_source_per_plate_always_kept`` —— source 端不受 §14 影响:source
|
||||||
|
reagent_keys 不出现在 targets 字段中,per-plate 节点恒在。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||||
|
if str(ROOT_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT_DIR))
|
||||||
|
|
||||||
|
|
||||||
|
def _install_fake_optional_deps() -> None:
|
||||||
|
"""与 test_common_cross_slot_v2.py 一致的可选依赖 stub。"""
|
||||||
|
if "matplotlib" not in sys.modules:
|
||||||
|
sys.modules["matplotlib"] = types.ModuleType("matplotlib")
|
||||||
|
if "matplotlib.pyplot" not in sys.modules:
|
||||||
|
sys.modules["matplotlib.pyplot"] = types.ModuleType("matplotlib.pyplot")
|
||||||
|
try:
|
||||||
|
from networkx.drawing import nx_agraph # noqa: F401
|
||||||
|
except Exception:
|
||||||
|
nx_drawing = types.ModuleType("networkx.drawing")
|
||||||
|
nx_agraph_mod = types.ModuleType("networkx.drawing.nx_agraph")
|
||||||
|
nx_agraph_mod.to_agraph = lambda _g: None # type: ignore[attr-defined]
|
||||||
|
nx_drawing.nx_agraph = nx_agraph_mod # type: ignore[attr-defined]
|
||||||
|
sys.modules["networkx.drawing"] = nx_drawing
|
||||||
|
sys.modules["networkx.drawing.nx_agraph"] = nx_agraph_mod
|
||||||
|
|
||||||
|
|
||||||
|
_install_fake_optional_deps()
|
||||||
|
|
||||||
|
import pytest # noqa: E402
|
||||||
|
|
||||||
|
from unilabos.workflow.common import build_protocol_graph # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 辅助 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def _nodes_by_template(graph, template_name: str) -> List[Dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{"id": nid, **node}
|
||||||
|
for nid, node in graph.nodes.items()
|
||||||
|
if node.get("template_name") == template_name
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _set_liquid_nodes_split(graph):
|
||||||
|
"""返回 (per_plate_nodes, merged_nodes)。merged 节点 name 以 `_merged_targets_` 开头。"""
|
||||||
|
all_sl = _nodes_by_template(graph, "set_liquid_from_plate")
|
||||||
|
merged = [n for n in all_sl if str(n.get("name", "")).startswith("_merged_targets_")]
|
||||||
|
per_plate = [n for n in all_sl if not str(n.get("name", "")).startswith("_merged_targets_")]
|
||||||
|
return per_plate, merged
|
||||||
|
|
||||||
|
|
||||||
|
def _labware_with_targets(target_keys: List[str], source_keys: List[str] | None = None) -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""构造 labware_info:source 端 1 个 + 任意数量 target plates + tip rack。"""
|
||||||
|
info: Dict[str, Dict[str, Any]] = {}
|
||||||
|
source_keys = source_keys or ["src_1"]
|
||||||
|
for i, sk in enumerate(source_keys, start=1):
|
||||||
|
info[sk] = {
|
||||||
|
"slot": 1 + i - 1, # slot 1 占位(实际可能映射)
|
||||||
|
"well": ["A1"],
|
||||||
|
"labware": "nest_12_reservoir_15ml",
|
||||||
|
"object": "source",
|
||||||
|
}
|
||||||
|
for i, tk in enumerate(target_keys, start=1):
|
||||||
|
info[tk] = {
|
||||||
|
"slot": 2 + i, # 错开 source 使用的 slot
|
||||||
|
"well": ["A1"],
|
||||||
|
"labware": "nest_96_wellplate_2ml_deep",
|
||||||
|
"object": "target",
|
||||||
|
}
|
||||||
|
info["tiprack_12"] = {
|
||||||
|
"slot": 12,
|
||||||
|
"well": [],
|
||||||
|
"labware": "opentrons_96_tiprack_300ul",
|
||||||
|
"object": "tiprack",
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 用例 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_plate_skipped_when_covered_by_merged():
|
||||||
|
"""单 list-targets transfer 覆盖 4 个 target reagent_keys → per-plate 不再出现。"""
|
||||||
|
targets = ["t_A", "t_B", "t_C", "t_D"]
|
||||||
|
labware = _labware_with_targets(targets, source_keys=["src_1"])
|
||||||
|
steps = [
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"parameters": {
|
||||||
|
"sources": "src_1",
|
||||||
|
"targets": targets,
|
||||||
|
"tip_racks": "tiprack_12",
|
||||||
|
"asp_vols": [8.0] * 4,
|
||||||
|
"dis_vols": [8.0] * 4,
|
||||||
|
},
|
||||||
|
"step_number": 1,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
g = build_protocol_graph(
|
||||||
|
labware_info=labware,
|
||||||
|
protocol_steps=steps,
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
|
||||||
|
per_plate, merged = _set_liquid_nodes_split(g)
|
||||||
|
|
||||||
|
# merged 节点:1 个
|
||||||
|
assert len(merged) == 1, f"应有 1 个 merged 节点;实际 {len(merged)}"
|
||||||
|
|
||||||
|
# per-plate 节点:仅 source 1 个(src_1);target 端被全部跳过
|
||||||
|
per_plate_names = {n.get("description", "") for n in per_plate}
|
||||||
|
per_plate_keys = {
|
||||||
|
n.get("description", "").replace("Set liquid: ", "")
|
||||||
|
for n in per_plate
|
||||||
|
}
|
||||||
|
assert "src_1" in per_plate_keys, "source 端 per-plate 必须保留"
|
||||||
|
for tk in targets:
|
||||||
|
assert tk not in per_plate_keys, (
|
||||||
|
f"§14:target reagent_key '{tk}' 已被 merged 覆盖,不应再有 per-plate 节点;"
|
||||||
|
f" 实际 per_plate_keys={per_plate_keys}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_plate_kept_when_also_referenced_by_str_targets():
|
||||||
|
"""R1 缓解:t_A 既被 list-targets 引用,又被 str-targets 引用 → per-plate 必须保留。"""
|
||||||
|
targets_list = ["t_A", "t_B", "t_C"]
|
||||||
|
labware = _labware_with_targets(targets_list, source_keys=["src_1"])
|
||||||
|
steps = [
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"parameters": {
|
||||||
|
"sources": "src_1",
|
||||||
|
"targets": targets_list,
|
||||||
|
"tip_racks": "tiprack_12",
|
||||||
|
"asp_vols": [5.0] * 3,
|
||||||
|
"dis_vols": [5.0] * 3,
|
||||||
|
},
|
||||||
|
"step_number": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"parameters": {
|
||||||
|
"sources": "src_1",
|
||||||
|
"targets": "t_A",
|
||||||
|
"tip_racks": "tiprack_12",
|
||||||
|
"asp_vols": [10.0],
|
||||||
|
"dis_vols": [10.0],
|
||||||
|
},
|
||||||
|
"step_number": 2,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
g = build_protocol_graph(
|
||||||
|
labware_info=labware,
|
||||||
|
protocol_steps=steps,
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
|
||||||
|
per_plate, merged = _set_liquid_nodes_split(g)
|
||||||
|
per_plate_keys = {
|
||||||
|
n.get("description", "").replace("Set liquid: ", "")
|
||||||
|
for n in per_plate
|
||||||
|
}
|
||||||
|
|
||||||
|
assert "t_A" in per_plate_keys, (
|
||||||
|
f"R1:t_A 被 str transfer #2 引用,必须保留 per-plate 节点;"
|
||||||
|
f" 实际 per_plate_keys={per_plate_keys}"
|
||||||
|
)
|
||||||
|
assert "t_B" not in per_plate_keys, "t_B 仅出现在 list-targets,应跳过"
|
||||||
|
assert "t_C" not in per_plate_keys, "t_C 仅出现在 list-targets,应跳过"
|
||||||
|
|
||||||
|
# merged 节点数:1(仅 list-targets transfer #1 生成)
|
||||||
|
assert len(merged) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_str_targets_protocol_unaffected():
|
||||||
|
"""单 slot 协议(全 str-targets)→ 每个 target reagent_key 仍有 per-plate(零回归)。"""
|
||||||
|
labware = _labware_with_targets(["t_A", "t_B"], source_keys=["src_1"])
|
||||||
|
steps = [
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"parameters": {
|
||||||
|
"sources": "src_1",
|
||||||
|
"targets": "t_A",
|
||||||
|
"tip_racks": "tiprack_12",
|
||||||
|
"asp_vols": [10.0],
|
||||||
|
"dis_vols": [10.0],
|
||||||
|
},
|
||||||
|
"step_number": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"parameters": {
|
||||||
|
"sources": "src_1",
|
||||||
|
"targets": "t_B",
|
||||||
|
"tip_racks": "tiprack_12",
|
||||||
|
"asp_vols": [20.0],
|
||||||
|
"dis_vols": [20.0],
|
||||||
|
},
|
||||||
|
"step_number": 2,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
g = build_protocol_graph(
|
||||||
|
labware_info=labware,
|
||||||
|
protocol_steps=steps,
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
|
||||||
|
per_plate, merged = _set_liquid_nodes_split(g)
|
||||||
|
per_plate_keys = {
|
||||||
|
n.get("description", "").replace("Set liquid: ", "")
|
||||||
|
for n in per_plate
|
||||||
|
}
|
||||||
|
|
||||||
|
assert merged == [], "全 str-targets 协议不应触发 merged 节点"
|
||||||
|
assert {"src_1", "t_A", "t_B"}.issubset(per_plate_keys), (
|
||||||
|
f"单 slot 协议每个 reagent_key(含 source/target)都应保留 per-plate;"
|
||||||
|
f" 实际 {per_plate_keys}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_51b9a5_style_node_count():
|
||||||
|
"""大规模场景:N 个 list-targets transfers,每个长度 M(同 source 不同跨板)。
|
||||||
|
|
||||||
|
构造:2 个 source(src_A1、src_A2)+ 9 个 target plates × 2 个 well = 18 target reagent_keys。
|
||||||
|
2 个 transfer:
|
||||||
|
- transfer #1: targets = [t_A1_1, t_A1_2, ..., t_A1_9](同 source src_A1,跨 9 plate)
|
||||||
|
- transfer #2: targets = [t_A2_1, t_A2_2, ..., t_A2_9](同 source src_A2,跨 9 plate)
|
||||||
|
|
||||||
|
期望 set_liquid_from_plate 总节点数 = 2 source per-plate + 2 merged + 0 target per-plate = 4。
|
||||||
|
"""
|
||||||
|
target_keys_a1 = [f"t_A1_{i}" for i in range(1, 10)]
|
||||||
|
target_keys_a2 = [f"t_A2_{i}" for i in range(1, 10)]
|
||||||
|
all_target_keys = target_keys_a1 + target_keys_a2
|
||||||
|
|
||||||
|
labware = _labware_with_targets(
|
||||||
|
all_target_keys,
|
||||||
|
source_keys=["src_A1", "src_A2"],
|
||||||
|
)
|
||||||
|
|
||||||
|
steps = [
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"parameters": {
|
||||||
|
"sources": "src_A1",
|
||||||
|
"targets": target_keys_a1,
|
||||||
|
"tip_racks": "tiprack_12",
|
||||||
|
"asp_vols": [8.3] * 9,
|
||||||
|
"dis_vols": [8.3] * 9,
|
||||||
|
},
|
||||||
|
"step_number": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"parameters": {
|
||||||
|
"sources": "src_A2",
|
||||||
|
"targets": target_keys_a2,
|
||||||
|
"tip_racks": "tiprack_12",
|
||||||
|
"asp_vols": [8.3] * 9,
|
||||||
|
"dis_vols": [8.3] * 9,
|
||||||
|
},
|
||||||
|
"step_number": 2,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
g = build_protocol_graph(
|
||||||
|
labware_info=labware,
|
||||||
|
protocol_steps=steps,
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
|
||||||
|
per_plate, merged = _set_liquid_nodes_split(g)
|
||||||
|
|
||||||
|
assert len(merged) == 2, f"应有 2 个 merged 节点;实际 {len(merged)}"
|
||||||
|
|
||||||
|
per_plate_keys = {
|
||||||
|
n.get("description", "").replace("Set liquid: ", "")
|
||||||
|
for n in per_plate
|
||||||
|
}
|
||||||
|
|
||||||
|
# source 端:2 个 per-plate
|
||||||
|
assert "src_A1" in per_plate_keys and "src_A2" in per_plate_keys, (
|
||||||
|
f"source 端必须有 src_A1 + src_A2 per-plate;实际 {per_plate_keys}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# target 端:18 个全部被跳过
|
||||||
|
for tk in all_target_keys:
|
||||||
|
assert tk not in per_plate_keys, (
|
||||||
|
f"§14:target reagent_key '{tk}' 应被 merged 覆盖并跳过;"
|
||||||
|
f" 实际 per_plate_keys 包含 {tk}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 总节点数 == 2 + 2
|
||||||
|
assert len(per_plate) + len(merged) == 4, (
|
||||||
|
f"set_liquid_from_plate 总节点数应为 4 (2 source + 2 merged + 0 target per-plate);"
|
||||||
|
f" 实际 per_plate={len(per_plate)} merged={len(merged)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_per_plate_always_kept():
|
||||||
|
"""source reagent_keys 不出现在任何 targets 字段中 → per-plate 节点恒保留(与 §14 无关)。"""
|
||||||
|
target_keys = ["t_A", "t_B", "t_C"]
|
||||||
|
labware = _labware_with_targets(target_keys, source_keys=["src_X", "src_Y"])
|
||||||
|
|
||||||
|
steps = [
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"parameters": {
|
||||||
|
"sources": "src_X",
|
||||||
|
"targets": target_keys,
|
||||||
|
"tip_racks": "tiprack_12",
|
||||||
|
"asp_vols": [5.0] * 3,
|
||||||
|
"dis_vols": [5.0] * 3,
|
||||||
|
},
|
||||||
|
"step_number": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "transfer_liquid",
|
||||||
|
"parameters": {
|
||||||
|
"sources": "src_Y",
|
||||||
|
"targets": "t_A",
|
||||||
|
"tip_racks": "tiprack_12",
|
||||||
|
"asp_vols": [10.0],
|
||||||
|
"dis_vols": [10.0],
|
||||||
|
},
|
||||||
|
"step_number": 2,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
g = build_protocol_graph(
|
||||||
|
labware_info=labware,
|
||||||
|
protocol_steps=steps,
|
||||||
|
workstation_name="PRCXI",
|
||||||
|
)
|
||||||
|
|
||||||
|
per_plate, _ = _set_liquid_nodes_split(g)
|
||||||
|
per_plate_keys = {
|
||||||
|
n.get("description", "").replace("Set liquid: ", "")
|
||||||
|
for n in per_plate
|
||||||
|
}
|
||||||
|
|
||||||
|
assert "src_X" in per_plate_keys, "source src_X 必须有 per-plate(source 不会被 §14 跳过)"
|
||||||
|
assert "src_Y" in per_plate_keys, "source src_Y 必须有 per-plate"
|
||||||
534
tests/workflow/test_labware_mapping.py
Normal file
534
tests/workflow/test_labware_mapping.py
Normal file
@@ -0,0 +1,534 @@
|
|||||||
|
"""P6 / P6.1 / P6.1.1 `labware_mapping.py` 单元测试 —— 对应 06-labware-mapping-table.md §11.7.7 / §11.8.7。
|
||||||
|
|
||||||
|
这些用例只依赖 `unilabos.workflow.labware_mapping` 自身与 PyYAML,
|
||||||
|
不需要 ROS2 / matplotlib / networkx 等环境,可直接 `pytest tests/workflow/test_labware_mapping.py`。
|
||||||
|
|
||||||
|
P6.1.1 schema(v1.9):
|
||||||
|
- 顶层 key 两段:``kinds`` / ``target_devices``(**P6.1.1 起顶层 `slot_remap` 已不支持**,下沉到 ``target_devices.<device>`` 内)
|
||||||
|
- ``target_devices.default`` 是固定段名,作为兜底物料集,第一版按 prcxi 拷贝填充,**不支持 `models` 子段**
|
||||||
|
- ``target_devices.<device>.models.<model>`` 是可选的型号粒度覆盖(slot_remap / rules)
|
||||||
|
- 旧 schema(顶层 ``vendors`` / ``slot_remap`` 或 rule 含 ``prcxi_class``)会触发 warning + fallback 到 builtin
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import warnings
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||||
|
if str(ROOT_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT_DIR))
|
||||||
|
|
||||||
|
from unilabos.workflow import labware_mapping as lm
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_lru_cache():
|
||||||
|
"""每个用例后清缓存,避免 monkeypatch 跨用例污染。"""
|
||||||
|
yield
|
||||||
|
lm.reload_mapping()
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== slot_remap ====================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"raw,object_type,want",
|
||||||
|
[
|
||||||
|
("4", "", "13"),
|
||||||
|
("8", "", "14"),
|
||||||
|
("12", "trash", "16"),
|
||||||
|
("12", "source", "12"),
|
||||||
|
("1", "", "1"),
|
||||||
|
("", "", ""),
|
||||||
|
(4, "", "13"), # 非字符串入参也应规整
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_remap_slot_basic(raw, object_type, want):
|
||||||
|
assert lm.remap_slot(raw, object_type) == want
|
||||||
|
|
||||||
|
|
||||||
|
def test_remap_slot_none_returns_empty():
|
||||||
|
assert lm.remap_slot(None) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_remap_slot_passthrough_unknown():
|
||||||
|
assert lm.remap_slot("99") == "99"
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== infer_kind ====================
|
||||||
|
|
||||||
|
|
||||||
|
def test_infer_kind_trash_priority():
|
||||||
|
"""`trash` 在 kinds 列表第 1 条 → 优先于含 'rack' 的字符串。"""
|
||||||
|
assert lm.infer_kind("foo_trash_bar") == "trash"
|
||||||
|
assert lm.infer_kind("opentrons_fixed_trash") == "trash"
|
||||||
|
|
||||||
|
|
||||||
|
def test_infer_kind_tiprack_before_tuberack():
|
||||||
|
"""`tiprack` 子串包含 'rack',但应被 tip_rack 规则先抓到(顺序敏感)。"""
|
||||||
|
assert lm.infer_kind("opentrons_96_tiprack_300ul") == "tip_rack"
|
||||||
|
assert lm.infer_kind("opentrons_96_tiprack_20ul") == "tip_rack"
|
||||||
|
|
||||||
|
|
||||||
|
def test_infer_kind_tube_rack_variants():
|
||||||
|
assert (
|
||||||
|
lm.infer_kind("opentrons_24_tuberack_eppendorf_2ml_safelock_snapcap")
|
||||||
|
== "tube_rack"
|
||||||
|
)
|
||||||
|
assert lm.infer_kind("opentrons_10_tuberack_falcon_4x50ml_6x15ml_conical") == "tube_rack"
|
||||||
|
|
||||||
|
|
||||||
|
def test_infer_kind_object_overrides_string():
|
||||||
|
"""object 字段优先:即使字符串看起来像 plate,trash / tiprack 也能强制归类。"""
|
||||||
|
assert lm.infer_kind("anything_at_all", "tiprack") == "tip_rack"
|
||||||
|
assert lm.infer_kind("opentrons_96_wellplate", "trash") == "trash"
|
||||||
|
|
||||||
|
|
||||||
|
def test_infer_kind_default_plate():
|
||||||
|
assert lm.infer_kind("opentrons_96_wellplate_300ul_pcr") == "plate"
|
||||||
|
assert lm.infer_kind("custom_384_wellplate_2200ul") == "plate"
|
||||||
|
|
||||||
|
|
||||||
|
def test_infer_kind_rack_without_tip_is_tube_rack():
|
||||||
|
"""复现历史 `_infer_reagent_kind` 中「含 rack 不含 tip → tube_rack」的语义。"""
|
||||||
|
assert lm.infer_kind("nest_4x6_rack") == "tube_rack"
|
||||||
|
|
||||||
|
|
||||||
|
def test_infer_kind_empty_hint_returns_plate():
|
||||||
|
assert lm.infer_kind("") == "plate"
|
||||||
|
assert lm.infer_kind(None) == "plate" # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== resolve_target_class(target_device="prcxi") ====================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"vol,want",
|
||||||
|
[
|
||||||
|
(1, "PRCXI_10uL_Tips"),
|
||||||
|
(9, "PRCXI_10uL_Tips"),
|
||||||
|
(10, "PRCXI_10uL_Tips"), # 闭区间 ≤10
|
||||||
|
(11, "PRCXI_300ul_Tips"),
|
||||||
|
(200, "PRCXI_300ul_Tips"),
|
||||||
|
(299.9, "PRCXI_300ul_Tips"),
|
||||||
|
(300, "PRCXI_1000uL_Tips"), # 300 上一档(与 <300 半开等价)
|
||||||
|
(500, "PRCXI_1000uL_Tips"),
|
||||||
|
(1000, "PRCXI_1000uL_Tips"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_resolve_tip_volume_buckets(vol, want):
|
||||||
|
assert lm.resolve_target_class("prcxi", "tip_rack", 96, vol) == want
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_tube_rack_holes():
|
||||||
|
assert lm.resolve_target_class("prcxi", "tube_rack", 24, None) == "PRCXI_EP_Adapter"
|
||||||
|
assert lm.resolve_target_class("prcxi", "tube_rack", 10, None) == "PRCXI_EP_Adapter"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_plate_holes():
|
||||||
|
assert lm.resolve_target_class("prcxi", "plate", 96, None) == "PRCXI_BioER_96_wellplate"
|
||||||
|
assert (
|
||||||
|
lm.resolve_target_class("prcxi", "plate", 384, None) == "PRCXI_BioER_384_wellplate"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_plate_unknown_holes_returns_none():
|
||||||
|
"""48 孔板未在 YAML 列出 → None;交给 PRCXI 模板打分匹配 fallback。"""
|
||||||
|
assert lm.resolve_target_class("prcxi", "plate", 48, 2200) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_trash_any():
|
||||||
|
assert lm.resolve_target_class("prcxi", "trash", None, None) == "PRCXI_trash"
|
||||||
|
# trash 规则未约束 hole_count / volume,所以任意值都命中
|
||||||
|
assert lm.resolve_target_class("prcxi", "trash", 0, 0) == "PRCXI_trash"
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== YAML 缺失 / 热加载 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_yaml_uses_builtin(monkeypatch, tmp_path):
|
||||||
|
"""YAML 文件不存在时,应自动落到 `_BUILTIN_DEFAULT`,且打 warning。"""
|
||||||
|
bogus = tmp_path / "no_such_labware_mapping.yaml"
|
||||||
|
monkeypatch.setattr(lm, "_DEFAULT_PATH", bogus)
|
||||||
|
lm._load_mapping.cache_clear()
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
assert lm.remap_slot("4") == "13"
|
||||||
|
assert (
|
||||||
|
lm.resolve_target_class("prcxi", "plate", 96, None)
|
||||||
|
== "PRCXI_BioER_96_wellplate"
|
||||||
|
)
|
||||||
|
assert any("labware_mapping.yaml 未找到" in str(w.message) for w in caught)
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_yaml_uses_builtin(monkeypatch, tmp_path):
|
||||||
|
"""YAML 解析失败也应回退到 builtin,且打 warning。"""
|
||||||
|
bad = tmp_path / "labware_mapping.yaml"
|
||||||
|
bad.write_text("this is :: not valid: yaml: [unclosed", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(lm, "_DEFAULT_PATH", bad)
|
||||||
|
lm._load_mapping.cache_clear()
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
assert lm.remap_slot("4") == "13"
|
||||||
|
assert any(
|
||||||
|
"labware_mapping.yaml 解析失败" in str(w.message)
|
||||||
|
or "labware_mapping.yaml 根不是 dict" in str(w.message)
|
||||||
|
for w in caught
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_yaml_reload_after_edit(monkeypatch, tmp_path):
|
||||||
|
"""临时 YAML 覆盖 + reload_mapping → 新规则生效,且原规则失效(P6.1.1 schema)。"""
|
||||||
|
tmp_yaml = tmp_path / "labware_mapping.yaml"
|
||||||
|
tmp_yaml.write_text(
|
||||||
|
'kinds:\n'
|
||||||
|
" - { pattern: 'trash', kind: trash }\n"
|
||||||
|
" - { pattern: '.*', kind: plate }\n"
|
||||||
|
'target_devices:\n'
|
||||||
|
' default:\n'
|
||||||
|
' slot_remap:\n'
|
||||||
|
' default: {"4": "99"}\n'
|
||||||
|
' by_object: {}\n'
|
||||||
|
' rules:\n'
|
||||||
|
" - { kind: plate, hole_count: 96, class_name: PRCXI_FooPlate }\n"
|
||||||
|
' prcxi:\n'
|
||||||
|
' slot_remap:\n'
|
||||||
|
' default: {"4": "99"}\n'
|
||||||
|
' by_object: {}\n'
|
||||||
|
' rules:\n'
|
||||||
|
" - { kind: plate, hole_count: 96, class_name: PRCXI_FooPlate }\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(lm, "_DEFAULT_PATH", tmp_yaml)
|
||||||
|
lm.reload_mapping()
|
||||||
|
assert lm.remap_slot("4") == "99"
|
||||||
|
assert lm.resolve_target_class("prcxi", "plate", 96, None) == "PRCXI_FooPlate"
|
||||||
|
# 新表里只有 96,没有 384 → None
|
||||||
|
assert lm.resolve_target_class("prcxi", "plate", 384, None) is None
|
||||||
|
# tube_rack / tip_rack 在新表里没规则 → None
|
||||||
|
assert lm.resolve_target_class("prcxi", "tip_rack", 96, 200) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_section_uses_builtin(monkeypatch, tmp_path):
|
||||||
|
"""YAML 缺 `kinds` 段 → 该段使用 builtin,其它段保留用户值(P6.1.1 schema)。"""
|
||||||
|
partial = tmp_path / "labware_mapping.yaml"
|
||||||
|
partial.write_text(
|
||||||
|
'target_devices:\n'
|
||||||
|
' default:\n'
|
||||||
|
' slot_remap:\n'
|
||||||
|
' default: {"4": "88"}\n'
|
||||||
|
' by_object: {}\n'
|
||||||
|
' rules: []\n'
|
||||||
|
' prcxi:\n'
|
||||||
|
' slot_remap:\n'
|
||||||
|
' default: {"4": "88"}\n'
|
||||||
|
' by_object: {}\n'
|
||||||
|
' rules: []\n', # 故意没有 kinds 段
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(lm, "_DEFAULT_PATH", partial)
|
||||||
|
lm._load_mapping.cache_clear()
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
# slot_remap 用 YAML 中的覆盖值
|
||||||
|
assert lm.remap_slot("4") == "88"
|
||||||
|
# kinds 段缺失 → 使用 builtin 的 tiprack 规则
|
||||||
|
assert lm.infer_kind("opentrons_96_tiprack_300ul") == "tip_rack"
|
||||||
|
assert any("缺少 `kinds` 段" in str(w.message) for w in caught)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== P6.1 新增用例 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_target_class_prcxi_tip_buckets():
|
||||||
|
"""PRCXI tip 量程档:≤10 / <300 / 否则 1000(与历史 _tip_prcxi_class_for_max_ul 等价)。"""
|
||||||
|
assert lm.resolve_target_class("prcxi", "tip_rack", 96, 10) == "PRCXI_10uL_Tips"
|
||||||
|
assert lm.resolve_target_class("prcxi", "tip_rack", 96, 200) == "PRCXI_300ul_Tips"
|
||||||
|
assert lm.resolve_target_class("prcxi", "tip_rack", 96, 1000) == "PRCXI_1000uL_Tips"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_target_class_unknown_device_falls_back_to_default_section():
|
||||||
|
"""未声明的 target_device 自动回退到固定段 target_devices.default,打 warning。
|
||||||
|
第一版 default 段内容按 prcxi 拷贝 → 断言:caller 传 'tecan' 时,结果应等于查 default 段。"""
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
# tecan / beckman / 任意未声明名字 → 全部回退到固定段 "default"
|
||||||
|
assert (
|
||||||
|
lm.resolve_target_class("tecan", "tip_rack", 96, 200)
|
||||||
|
== lm.resolve_target_class("default", "tip_rack", 96, 200)
|
||||||
|
== "PRCXI_300ul_Tips" # 第一版 default 段按 prcxi 填,所以值仍是 PRCXI_*
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
lm.resolve_target_class("unknown_xxx", "plate", 96, None)
|
||||||
|
== lm.resolve_target_class("default", "plate", 96, None)
|
||||||
|
)
|
||||||
|
# 至少打 1 次 warning,提示「未声明、已回退到 default 段」
|
||||||
|
assert any(
|
||||||
|
("未在 labware_mapping.yaml" in str(w.message))
|
||||||
|
or ("target_devices.default" in str(w.message))
|
||||||
|
for w in caught
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_target_class_per_device_tip_buckets(tmp_path, monkeypatch):
|
||||||
|
"""**P6.1 核心断言**:不同 target_device 在同一体积下命中不同 tip 量程档(P6.1.1 schema)。"""
|
||||||
|
yaml_path = tmp_path / "labware_mapping.yaml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
'kinds: [{pattern: ".*", kind: plate}]\n'
|
||||||
|
'target_devices:\n'
|
||||||
|
' default:\n'
|
||||||
|
' slot_remap: {default: {}, by_object: {}}\n'
|
||||||
|
' rules:\n'
|
||||||
|
' - {kind: tip_rack, hole_count: 96, volume_max: 10, class_name: PRCXI_10uL_Tips}\n'
|
||||||
|
' - {kind: tip_rack, hole_count: 96, volume_max: 299.9, class_name: PRCXI_300ul_Tips}\n'
|
||||||
|
' - {kind: tip_rack, hole_count: 96, class_name: PRCXI_1000uL_Tips}\n'
|
||||||
|
' prcxi:\n'
|
||||||
|
' slot_remap: {default: {}, by_object: {}}\n'
|
||||||
|
' rules:\n'
|
||||||
|
' - {kind: tip_rack, hole_count: 96, volume_max: 10, class_name: PRCXI_10uL_Tips}\n'
|
||||||
|
' - {kind: tip_rack, hole_count: 96, volume_max: 299.9, class_name: PRCXI_300ul_Tips}\n'
|
||||||
|
' - {kind: tip_rack, hole_count: 96, class_name: PRCXI_1000uL_Tips}\n'
|
||||||
|
' beckman:\n'
|
||||||
|
' slot_remap: {default: {}, by_object: {}}\n'
|
||||||
|
' rules:\n'
|
||||||
|
' - {kind: tip_rack, hole_count: 96, volume_max: 20, class_name: Beckman_20uL_Tips}\n'
|
||||||
|
' - {kind: tip_rack, hole_count: 96, volume_max: 199.9, class_name: Beckman_200uL_Tips}\n'
|
||||||
|
' - {kind: tip_rack, hole_count: 96, class_name: Beckman_1000uL_Tips}\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
|
||||||
|
lm.reload_mapping()
|
||||||
|
|
||||||
|
# 同样的体积 200:prcxi 走 300 档、beckman 已超出 200 档 → 1000 档
|
||||||
|
assert lm.resolve_target_class("prcxi", "tip_rack", 96, 200) == "PRCXI_300ul_Tips"
|
||||||
|
assert lm.resolve_target_class("beckman", "tip_rack", 96, 200) == "Beckman_1000uL_Tips"
|
||||||
|
# 同样的体积 15:prcxi 已超出 10 档 → 300 档;beckman 仍在 20 档
|
||||||
|
assert lm.resolve_target_class("prcxi", "tip_rack", 96, 15) == "PRCXI_300ul_Tips"
|
||||||
|
assert lm.resolve_target_class("beckman", "tip_rack", 96, 15) == "Beckman_20uL_Tips"
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_section_independent_from_prcxi(tmp_path, monkeypatch):
|
||||||
|
"""default 与 prcxi 是两段独立物料集:改 default 不影响 prcxi、改 prcxi 不影响 default。
|
||||||
|
|
||||||
|
断言:把 default 段改成 Generic_Plate96,prcxi 段保持 PRCXI_Plate96 时,
|
||||||
|
caller 传未声明的名字回退到 default 拿 Generic_Plate96,传 prcxi 仍拿 PRCXI_Plate96。
|
||||||
|
"""
|
||||||
|
yaml_path = tmp_path / "labware_mapping.yaml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
'kinds: [{pattern: ".*", kind: plate}]\n'
|
||||||
|
'target_devices:\n'
|
||||||
|
' default:\n' # ← 独立改 default 段
|
||||||
|
' slot_remap: {default: {}, by_object: {}}\n'
|
||||||
|
' rules:\n'
|
||||||
|
' - {kind: plate, hole_count: 96, class_name: Generic_Plate96}\n'
|
||||||
|
' prcxi:\n' # ← prcxi 段保持 PRCXI_*
|
||||||
|
' slot_remap: {default: {}, by_object: {}}\n'
|
||||||
|
' rules:\n'
|
||||||
|
' - {kind: plate, hole_count: 96, class_name: PRCXI_Plate96}\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
|
||||||
|
lm.reload_mapping()
|
||||||
|
|
||||||
|
# caller 传未声明的 tecan → 走 default 段 → Generic_*
|
||||||
|
assert lm.resolve_target_class("tecan", "plate", 96, None) == "Generic_Plate96"
|
||||||
|
# caller 显式传 prcxi → 走 prcxi 段 → PRCXI_*(**不**受 default 影响)
|
||||||
|
assert lm.resolve_target_class("prcxi", "plate", 96, None) == "PRCXI_Plate96"
|
||||||
|
# 显式传 "default" 也合法(caller 可主动选择走 default 段)
|
||||||
|
assert lm.resolve_target_class("default", "plate", 96, None) == "Generic_Plate96"
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_yaml_schema_rejected_with_warning(tmp_path, monkeypatch):
|
||||||
|
"""旧 schema(vendors / prcxi_class)应被拒绝 + warning + 整段 fallback 到 builtin(P6.1.1 schema)。"""
|
||||||
|
legacy = tmp_path / "labware_mapping.yaml"
|
||||||
|
legacy.write_text(
|
||||||
|
'kinds: [{pattern: ".*", kind: plate}]\n'
|
||||||
|
'vendors:\n' # ← 旧顶层 key
|
||||||
|
' opentrons:\n'
|
||||||
|
' rules:\n'
|
||||||
|
" - {kind: plate, hole_count: 96, prcxi_class: PRCXI_FooPlate}\n", # ← 旧字段
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(lm, "_DEFAULT_PATH", legacy)
|
||||||
|
lm._load_mapping.cache_clear()
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
# 整段走 builtin → 96 板还是 PRCXI_BioER_96_wellplate(**不是**用户旧 YAML 中的 PRCXI_FooPlate)
|
||||||
|
assert lm.resolve_target_class("prcxi", "plate", 96, None) == "PRCXI_BioER_96_wellplate"
|
||||||
|
assert any(
|
||||||
|
("旧 schema" in str(w.message))
|
||||||
|
or ("vendors" in str(w.message))
|
||||||
|
or ("prcxi_class" in str(w.message))
|
||||||
|
for w in caught
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_target_class_unknown_kind_returns_none():
|
||||||
|
"""target_device 存在、kind 不存在 → None。"""
|
||||||
|
assert lm.resolve_target_class("prcxi", "reservoir", 12, None) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== P6.1.1 新增用例(slot_remap 按 device + model 分叉) ====================
|
||||||
|
|
||||||
|
|
||||||
|
def test_remap_slot_model_level_overrides_device_level(tmp_path, monkeypatch):
|
||||||
|
"""型号级 slot_remap 优先级 > 厂商级。"""
|
||||||
|
yaml_path = tmp_path / "labware_mapping.yaml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
'kinds: [{pattern: ".*", kind: plate}]\n'
|
||||||
|
'target_devices:\n'
|
||||||
|
' default:\n'
|
||||||
|
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
||||||
|
' rules: []\n'
|
||||||
|
' prcxi:\n'
|
||||||
|
' slot_remap: {default: {"4": "13"}, by_object: {trash: {"12": "16"}}}\n'
|
||||||
|
' rules: []\n'
|
||||||
|
' models:\n'
|
||||||
|
' "4040":\n'
|
||||||
|
' slot_remap: {default: {"4": "16"}, by_object: {}}\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
|
||||||
|
lm.reload_mapping()
|
||||||
|
# device 级(不传 model)→ "13"
|
||||||
|
assert lm.remap_slot("4", target_device="prcxi") == "13"
|
||||||
|
# model "4040" 覆盖 → "16"
|
||||||
|
assert lm.remap_slot("4", target_device="prcxi", target_model="4040") == "16"
|
||||||
|
# model "9320" 未声明 → 静默 fallback 到 device 级 → "13"
|
||||||
|
assert lm.remap_slot("4", target_device="prcxi", target_model="9320") == "13"
|
||||||
|
|
||||||
|
|
||||||
|
def test_remap_slot_model_inherits_device_when_field_missing(tmp_path, monkeypatch):
|
||||||
|
"""model 子段声明但 slot_remap 字段缺失 → 静默继承厂商级;rules 同理。"""
|
||||||
|
yaml_path = tmp_path / "labware_mapping.yaml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
'kinds: [{pattern: ".*", kind: plate}]\n'
|
||||||
|
'target_devices:\n'
|
||||||
|
' default:\n'
|
||||||
|
' slot_remap: {default: {}, by_object: {}}\n'
|
||||||
|
' rules: []\n'
|
||||||
|
' prcxi:\n'
|
||||||
|
' slot_remap: {default: {"4": "13", "8": "14"}, by_object: {}}\n'
|
||||||
|
' rules: [{kind: plate, hole_count: 96, class_name: PRCXI_PlateA}]\n'
|
||||||
|
' models:\n'
|
||||||
|
' "9320":\n'
|
||||||
|
' rules: [{kind: plate, hole_count: 96, class_name: PRCXI_PlateB}]\n', # 仅覆盖 rules,未声明 slot_remap
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
|
||||||
|
lm.reload_mapping()
|
||||||
|
# model 9320 的 slot_remap 缺字段 → 继承 prcxi.slot_remap → "4" → "13"
|
||||||
|
assert lm.remap_slot("4", target_device="prcxi", target_model="9320") == "13"
|
||||||
|
# model 9320 的 rules 覆盖 → PRCXI_PlateB
|
||||||
|
assert (
|
||||||
|
lm.resolve_target_class("prcxi", "plate", 96, None, target_model="9320")
|
||||||
|
== "PRCXI_PlateB"
|
||||||
|
)
|
||||||
|
# 不传 model → 用厂商级 rules → PRCXI_PlateA
|
||||||
|
assert lm.resolve_target_class("prcxi", "plate", 96, None) == "PRCXI_PlateA"
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_top_level_slot_remap_rejected(tmp_path, monkeypatch):
|
||||||
|
"""P6.1.1:顶层 slot_remap 段被视为旧 schema → warning + 整段 fallback 到 builtin。"""
|
||||||
|
legacy = tmp_path / "labware_mapping.yaml"
|
||||||
|
legacy.write_text(
|
||||||
|
'slot_remap:\n' # ← P6.1.1 已不支持的顶层段
|
||||||
|
' default: {"4": "99"}\n'
|
||||||
|
' by_object: {}\n'
|
||||||
|
'kinds: [{pattern: ".*", kind: plate}]\n'
|
||||||
|
'target_devices:\n'
|
||||||
|
' default:\n'
|
||||||
|
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
||||||
|
' rules: []\n'
|
||||||
|
' prcxi:\n'
|
||||||
|
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
||||||
|
' rules: []\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(lm, "_DEFAULT_PATH", legacy)
|
||||||
|
lm._load_mapping.cache_clear()
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
# 整段走 builtin → "4" 仍然 → "13"(builtin 值),**不是** YAML 顶层的 "99"
|
||||||
|
assert lm.remap_slot("4", target_device="prcxi") == "13"
|
||||||
|
assert any(
|
||||||
|
("顶层" in str(w.message) and "slot_remap" in str(w.message))
|
||||||
|
or ("旧 schema" in str(w.message))
|
||||||
|
for w in caught
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_remap_slot_unknown_device_falls_back_with_warning(tmp_path, monkeypatch):
|
||||||
|
"""未声明的 target_device → fallback 到 default.slot_remap + warning(与 resolve_target_class 同语义)。"""
|
||||||
|
yaml_path = tmp_path / "labware_mapping.yaml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
'kinds: [{pattern: ".*", kind: plate}]\n'
|
||||||
|
'target_devices:\n'
|
||||||
|
' default:\n'
|
||||||
|
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
||||||
|
' rules: []\n'
|
||||||
|
' prcxi:\n'
|
||||||
|
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
||||||
|
' rules: []\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
|
||||||
|
lm.reload_mapping()
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
assert lm.remap_slot("4", target_device="tecan") == "13" # fallback 到 default
|
||||||
|
assert any(
|
||||||
|
("tecan" in str(w.message)) or ("target_devices.default" in str(w.message))
|
||||||
|
for w in caught
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_remap_slot_model_only_no_device_passthrough(tmp_path, monkeypatch):
|
||||||
|
"""caller 传 target_model 但 target_device 段不存在 → 直接走 default.slot_remap(model 名忽略)。"""
|
||||||
|
yaml_path = tmp_path / "labware_mapping.yaml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
'kinds: [{pattern: ".*", kind: plate}]\n'
|
||||||
|
'target_devices:\n'
|
||||||
|
' default:\n'
|
||||||
|
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
||||||
|
' rules: []\n', # 没有 prcxi 段
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
|
||||||
|
lm.reload_mapping()
|
||||||
|
with warnings.catch_warnings(record=True):
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
# target_device "prcxi" 不存在、target_model 即使传也忽略 → 走 default
|
||||||
|
assert lm.remap_slot("4", target_device="prcxi", target_model="9320") == "13"
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_section_models_subsection_warns(tmp_path, monkeypatch):
|
||||||
|
"""target_devices.default.models 不被支持 → warning,但 default.slot_remap 仍生效。"""
|
||||||
|
yaml_path = tmp_path / "labware_mapping.yaml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
'kinds: [{pattern: ".*", kind: plate}]\n'
|
||||||
|
'target_devices:\n'
|
||||||
|
' default:\n'
|
||||||
|
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
||||||
|
' rules: []\n'
|
||||||
|
' models:\n' # ← default 段不支持 models
|
||||||
|
' "ghost":\n'
|
||||||
|
' slot_remap: {default: {"4": "99"}, by_object: {}}\n'
|
||||||
|
' prcxi:\n'
|
||||||
|
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
|
||||||
|
' rules: []\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
|
||||||
|
lm._load_mapping.cache_clear()
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
# default 段的 models 被忽略 → 走 default.slot_remap → "13"(不是 "99")
|
||||||
|
assert lm.remap_slot("4", target_device="tecan", target_model="ghost") == "13"
|
||||||
|
assert any(
|
||||||
|
("default" in str(w.message) and "models" in str(w.message))
|
||||||
|
for w in caught
|
||||||
|
)
|
||||||
178
tests/workflow/test_wf_utils_workflow_name.py
Normal file
178
tests/workflow/test_wf_utils_workflow_name.py
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
"""``unilabos.workflow.wf_utils.upload_workflow`` 工作流名称 fallback 链单元测试。
|
||||||
|
|
||||||
|
对应需求:上传工作流时,**优先取 metadata.workflow_name**;缺失时再回退到顶层
|
||||||
|
``workflow_name``(旧 node-link 形态遗留字段);最后才回退到文件名(去 ``.json`` 后缀)。
|
||||||
|
CLI 显式 ``-n/--workflow_name`` 永远最优先。
|
||||||
|
|
||||||
|
本测试只校验「**名称 fallback 链 + tags fallback 链**」的纯逻辑路径,
|
||||||
|
不实际访问 HTTP / 后端;通过 monkeypatch 把 ``http_client.workflow_import``
|
||||||
|
桩成可观察的捕获函数。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
# 让 import 走 Uni-Lab-OS 包根
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
SRC = ROOT / "unilabos"
|
||||||
|
if str(ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def stub_upload(monkeypatch, tmp_path):
|
||||||
|
"""Monkeypatch ``http_client.workflow_import`` + ``_convert_to_node_link``,
|
||||||
|
返回 (helper, captured) 二元组:
|
||||||
|
|
||||||
|
- ``helper(workflow_data, **upload_kwargs)`` 写入 tmp_path/wf.json
|
||||||
|
并调用 ``upload_workflow``;
|
||||||
|
- ``captured`` 是 dict,记录 ``workflow_import`` 实际收到的 kwargs,
|
||||||
|
以及 ``_convert_to_node_link`` 是否被调过。
|
||||||
|
|
||||||
|
本测试不依赖真实 ``unilabos.app.web``(其级联依赖含 ``fastapi`` 等重型
|
||||||
|
package,本地 dev venv 不必装)。通过在 sys.modules 注入空壳 module 拦截
|
||||||
|
delayed import。
|
||||||
|
"""
|
||||||
|
import types
|
||||||
|
|
||||||
|
captured: Dict[str, Any] = {"workflow_import_kwargs": None, "converted": False}
|
||||||
|
|
||||||
|
def fake_workflow_import(**kwargs): # noqa: ANN003
|
||||||
|
captured["workflow_import_kwargs"] = kwargs
|
||||||
|
return {"code": 0, "data": {"uuid": "fake-uuid", "name": kwargs.get("name")}}
|
||||||
|
|
||||||
|
# 关键:在 wf_utils 触发 `from unilabos.app.web import http_client` 之前
|
||||||
|
# 用空壳 module 占位(避免触发真实 web 包的 fastapi 依赖链)。
|
||||||
|
fake_http_client = types.ModuleType("unilabos.app.web.http_client")
|
||||||
|
fake_http_client.workflow_import = fake_workflow_import # type: ignore[attr-defined]
|
||||||
|
fake_web_pkg = types.ModuleType("unilabos.app.web")
|
||||||
|
fake_web_pkg.http_client = fake_http_client # type: ignore[attr-defined]
|
||||||
|
monkeypatch.setitem(sys.modules, "unilabos.app.web", fake_web_pkg)
|
||||||
|
monkeypatch.setitem(sys.modules, "unilabos.app.web.http_client", fake_http_client)
|
||||||
|
|
||||||
|
from unilabos.workflow import wf_utils
|
||||||
|
|
||||||
|
# _convert_to_node_link 走真实路径会拉重型依赖,这里桩为 node-link 直返回
|
||||||
|
def fake_convert_to_node_link(workflow_file, workflow_data, *, target_device="prcxi", target_model=None):
|
||||||
|
captured["converted"] = True
|
||||||
|
# 返回最小合法 node-link 形态(不带 metadata,模拟当前行为)
|
||||||
|
return {"nodes": [], "edges": [], "workflow_uuid": ""}
|
||||||
|
|
||||||
|
monkeypatch.setattr(wf_utils, "_convert_to_node_link", fake_convert_to_node_link)
|
||||||
|
|
||||||
|
def helper(workflow_data: Dict[str, Any], **upload_kwargs: Any) -> Dict[str, Any]:
|
||||||
|
wf_path = tmp_path / "transfer_actions_sample.json"
|
||||||
|
wf_path.write_text(json.dumps(workflow_data, ensure_ascii=False), encoding="utf-8")
|
||||||
|
return wf_utils.upload_workflow(str(wf_path), **upload_kwargs)
|
||||||
|
|
||||||
|
return helper, captured
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== workflow_name fallback 链 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_workflow_name_wins_over_filename(stub_upload):
|
||||||
|
"""P5 主路径:transfer_actions JSON 含 metadata.workflow_name → 优先于文件名。"""
|
||||||
|
helper, captured = stub_upload
|
||||||
|
data = {
|
||||||
|
"metadata": {"workflow_name": "PCR Prep with Categories", "tags": []},
|
||||||
|
"workflow": [],
|
||||||
|
"reagent": {},
|
||||||
|
}
|
||||||
|
helper(data)
|
||||||
|
kwargs = captured["workflow_import_kwargs"]
|
||||||
|
assert kwargs is not None and captured["converted"] is True
|
||||||
|
assert kwargs["name"] == "PCR Prep with Categories"
|
||||||
|
assert kwargs["workflow_name"] == "PCR Prep with Categories"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_workflow_name_overrides_metadata(stub_upload):
|
||||||
|
"""CLI 显式 -n/--workflow_name 永远最优先。"""
|
||||||
|
helper, captured = stub_upload
|
||||||
|
data = {
|
||||||
|
"metadata": {"workflow_name": "Metadata Wins By Default"},
|
||||||
|
"workflow": [],
|
||||||
|
"reagent": {},
|
||||||
|
}
|
||||||
|
helper(data, workflow_name="CLI Override Name")
|
||||||
|
kwargs = captured["workflow_import_kwargs"]
|
||||||
|
assert kwargs["name"] == "CLI Override Name"
|
||||||
|
assert kwargs["workflow_name"] == "CLI Override Name"
|
||||||
|
|
||||||
|
|
||||||
|
def test_filename_used_when_no_metadata_and_no_legacy(stub_upload):
|
||||||
|
"""P5 之前的旧文件、且无顶层 workflow_name → 回退到去 .json 后缀的文件名。"""
|
||||||
|
helper, captured = stub_upload
|
||||||
|
data = {"workflow": [], "reagent": {}} # 既无 metadata,也无 workflow_name
|
||||||
|
helper(data)
|
||||||
|
kwargs = captured["workflow_import_kwargs"]
|
||||||
|
# 文件名由 fixture 固定为 transfer_actions_sample.json
|
||||||
|
assert kwargs["name"] == "transfer_actions_sample"
|
||||||
|
assert kwargs["workflow_name"] == "transfer_actions_sample"
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_empty_string_falls_back_to_filename(stub_upload):
|
||||||
|
"""metadata.workflow_name 为空字符串(而非缺失)也应回退到文件名。"""
|
||||||
|
helper, captured = stub_upload
|
||||||
|
data = {
|
||||||
|
"metadata": {"workflow_name": " "}, # whitespace-only
|
||||||
|
"workflow": [],
|
||||||
|
"reagent": {},
|
||||||
|
}
|
||||||
|
helper(data)
|
||||||
|
kwargs = captured["workflow_import_kwargs"]
|
||||||
|
assert kwargs["name"] == "transfer_actions_sample"
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_top_level_workflow_name_used_when_metadata_missing(stub_upload, monkeypatch):
|
||||||
|
"""旧 node-link 文件(已是 nodes/edges 形态)顶层 workflow_name → 应被使用。
|
||||||
|
|
||||||
|
覆盖路径:``_is_node_link_format`` 直接命中 → 不走转换 → workflow_data 保留顶层
|
||||||
|
workflow_name;``orig_metadata`` 为空时 fallback 到该字段。
|
||||||
|
"""
|
||||||
|
helper, captured = stub_upload
|
||||||
|
data = {
|
||||||
|
"nodes": [],
|
||||||
|
"edges": [],
|
||||||
|
"workflow_name": "Legacy Top Name",
|
||||||
|
}
|
||||||
|
helper(data)
|
||||||
|
kwargs = captured["workflow_import_kwargs"]
|
||||||
|
assert captured["converted"] is False, "node-link 输入不应触发转换"
|
||||||
|
assert kwargs["name"] == "Legacy Top Name"
|
||||||
|
assert kwargs["workflow_name"] == "Legacy Top Name"
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== tags fallback 链 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_tags_used_when_cli_tags_missing(stub_upload):
|
||||||
|
"""P5 主路径:metadata.tags 在 CLI 未传 tags 时被使用。"""
|
||||||
|
helper, captured = stub_upload
|
||||||
|
data = {
|
||||||
|
"metadata": {"workflow_name": "X", "tags": ["Opentrons", "PCR"]},
|
||||||
|
"workflow": [],
|
||||||
|
"reagent": {},
|
||||||
|
}
|
||||||
|
helper(data)
|
||||||
|
kwargs = captured["workflow_import_kwargs"]
|
||||||
|
assert kwargs["tags"] == ["Opentrons", "PCR"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_tags_override_metadata_tags(stub_upload):
|
||||||
|
"""CLI 显式 --tags 优先于 metadata.tags。"""
|
||||||
|
helper, captured = stub_upload
|
||||||
|
data = {
|
||||||
|
"metadata": {"workflow_name": "X", "tags": ["Opentrons", "PCR"]},
|
||||||
|
"workflow": [],
|
||||||
|
"reagent": {},
|
||||||
|
}
|
||||||
|
helper(data, tags=["CLI", "Wins"])
|
||||||
|
kwargs = captured["workflow_import_kwargs"]
|
||||||
|
assert kwargs["tags"] == ["CLI", "Wins"]
|
||||||
@@ -1 +1 @@
|
|||||||
__version__ = "0.10.19"
|
__version__ = "0.11.1"
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
"""Entry point for `python -m unilabos`."""
|
|
||||||
|
|
||||||
from unilabos.app.main import main
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -12,6 +12,15 @@ from typing import Dict, Any, List
|
|||||||
import networkx as nx
|
import networkx as nx
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
# Windows 中文系统 stdout 默认 GBK,无法编码 banner / emoji 日志中的 Unicode 字符
|
||||||
|
# 强制 stdout/stderr 用 UTF-8,避免 print 触发 UnicodeEncodeError 导致进程崩溃
|
||||||
|
if sys.platform == "win32":
|
||||||
|
for _stream in (sys.stdout, sys.stderr):
|
||||||
|
try:
|
||||||
|
_stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
|
||||||
|
except (AttributeError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
# 首先添加项目根目录到路径
|
# 首先添加项目根目录到路径
|
||||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
unilabos_dir = os.path.dirname(os.path.dirname(current_dir))
|
unilabos_dir = os.path.dirname(os.path.dirname(current_dir))
|
||||||
@@ -233,7 +242,7 @@ def parse_args():
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--addr",
|
"--addr",
|
||||||
type=str,
|
type=str,
|
||||||
default="https://uni-lab.bohrium.com/api/v1",
|
default="https://leap-lab.bohrium.com/api/v1",
|
||||||
help="Laboratory backend address",
|
help="Laboratory backend address",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -327,6 +336,27 @@ def parse_args():
|
|||||||
default="",
|
default="",
|
||||||
help="Workflow description, used when publishing the workflow",
|
help="Workflow description, used when publishing the workflow",
|
||||||
)
|
)
|
||||||
|
workflow_parser.add_argument(
|
||||||
|
"--target_device",
|
||||||
|
type=str,
|
||||||
|
default="prcxi",
|
||||||
|
help=(
|
||||||
|
"Target instrument name at vendor granularity (e.g. 'prcxi', 'beckman', 'tecan'). "
|
||||||
|
"Decides which target_devices.<name>.rules section in labware_mapping.yaml is used. "
|
||||||
|
"Unknown names fall back to target_devices.default. Default: 'prcxi'."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
workflow_parser.add_argument(
|
||||||
|
"--target_model",
|
||||||
|
type=str,
|
||||||
|
default=None,
|
||||||
|
help=(
|
||||||
|
"Optional target instrument model name within the same vendor (e.g. '9320', '4040'). "
|
||||||
|
"Used to look up target_devices.<target_device>.models.<target_model>.slot_remap / "
|
||||||
|
".rules for model-specific deck layout or rule overrides. Falls back to the vendor-level "
|
||||||
|
"configuration when omitted or the model is not declared. Default: None."
|
||||||
|
),
|
||||||
|
)
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
@@ -438,10 +468,10 @@ def main():
|
|||||||
if args.addr != parser.get_default("addr"):
|
if args.addr != parser.get_default("addr"):
|
||||||
if args.addr == "test":
|
if args.addr == "test":
|
||||||
print_status("使用测试环境地址", "info")
|
print_status("使用测试环境地址", "info")
|
||||||
HTTPConfig.remote_addr = "https://uni-lab.test.bohrium.com/api/v1"
|
HTTPConfig.remote_addr = "https://leap-lab.test.bohrium.com/api/v1"
|
||||||
elif args.addr == "uat":
|
elif args.addr == "uat":
|
||||||
print_status("使用uat环境地址", "info")
|
print_status("使用uat环境地址", "info")
|
||||||
HTTPConfig.remote_addr = "https://uni-lab.uat.bohrium.com/api/v1"
|
HTTPConfig.remote_addr = "https://leap-lab.uat.bohrium.com/api/v1"
|
||||||
elif args.addr == "local":
|
elif args.addr == "local":
|
||||||
print_status("使用本地环境地址", "info")
|
print_status("使用本地环境地址", "info")
|
||||||
HTTPConfig.remote_addr = "http://127.0.0.1:48197/api/v1"
|
HTTPConfig.remote_addr = "http://127.0.0.1:48197/api/v1"
|
||||||
@@ -553,7 +583,7 @@ def main():
|
|||||||
os._exit(0)
|
os._exit(0)
|
||||||
|
|
||||||
if not BasicConfig.ak or not BasicConfig.sk:
|
if not BasicConfig.ak or not BasicConfig.sk:
|
||||||
print_status("后续运行必须拥有一个实验室,请前往 https://uni-lab.bohrium.com 注册实验室!", "warning")
|
print_status("后续运行必须拥有一个实验室,请前往 https://leap-lab.bohrium.com 注册实验室!", "warning")
|
||||||
os._exit(1)
|
os._exit(1)
|
||||||
graph: nx.Graph
|
graph: nx.Graph
|
||||||
resource_tree_set: ResourceTreeSet
|
resource_tree_set: ResourceTreeSet
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ class HTTPClient:
|
|||||||
auth_secret = BasicConfig.auth_secret()
|
auth_secret = BasicConfig.auth_secret()
|
||||||
self.auth = auth_secret
|
self.auth = auth_secret
|
||||||
info(f"正在使用ak sk作为授权信息:[{auth_secret}]")
|
info(f"正在使用ak sk作为授权信息:[{auth_secret}]")
|
||||||
|
# 复用 TCP/TLS 连接,避免每次请求重新握手
|
||||||
|
self._session = requests.Session()
|
||||||
|
self._session.headers.update({"Authorization": f"Lab {self.auth}"})
|
||||||
info(f"HTTPClient 初始化完成: remote_addr={self.remote_addr}")
|
info(f"HTTPClient 初始化完成: remote_addr={self.remote_addr}")
|
||||||
|
|
||||||
def resource_edge_add(self, resources: List[Dict[str, Any]]) -> requests.Response:
|
def resource_edge_add(self, resources: List[Dict[str, Any]]) -> requests.Response:
|
||||||
@@ -48,7 +51,7 @@ class HTTPClient:
|
|||||||
Returns:
|
Returns:
|
||||||
Response: API响应对象
|
Response: API响应对象
|
||||||
"""
|
"""
|
||||||
response = requests.post(
|
response = self._session.post(
|
||||||
f"{self.remote_addr}/edge/material/edge",
|
f"{self.remote_addr}/edge/material/edge",
|
||||||
json={
|
json={
|
||||||
"edges": resources,
|
"edges": resources,
|
||||||
@@ -75,26 +78,28 @@ class HTTPClient:
|
|||||||
Returns:
|
Returns:
|
||||||
Dict[str, str]: 旧UUID到新UUID的映射关系 {old_uuid: new_uuid}
|
Dict[str, str]: 旧UUID到新UUID的映射关系 {old_uuid: new_uuid}
|
||||||
"""
|
"""
|
||||||
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_add.json"), "w", encoding="utf-8") as f:
|
# dump() 只调用一次,复用给文件保存和 HTTP 请求
|
||||||
payload = {"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid}
|
|
||||||
f.write(json.dumps(payload, indent=4))
|
|
||||||
# 从序列化数据中提取所有节点的UUID(保存旧UUID)
|
|
||||||
old_uuids = {n.res_content.uuid: n for n in resources.all_nodes}
|
|
||||||
nodes_info = [x for xs in resources.dump() for x in xs]
|
nodes_info = [x for xs in resources.dump() for x in xs]
|
||||||
|
old_uuids = {n.res_content.uuid: n for n in resources.all_nodes}
|
||||||
|
payload = {"nodes": nodes_info, "mount_uuid": mount_uuid}
|
||||||
|
body_bytes = _fast_dumps(payload)
|
||||||
|
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_add.json"), "wb") as f:
|
||||||
|
f.write(_fast_dumps_pretty(payload))
|
||||||
|
http_headers = {"Content-Type": "application/json"}
|
||||||
if not self.initialized or first_add:
|
if not self.initialized or first_add:
|
||||||
self.initialized = True
|
self.initialized = True
|
||||||
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
|
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
|
||||||
response = requests.post(
|
response = self._session.post(
|
||||||
f"{self.remote_addr}/edge/material",
|
f"{self.remote_addr}/edge/material",
|
||||||
json={"nodes": nodes_info, "mount_uuid": mount_uuid},
|
data=body_bytes,
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
headers=http_headers,
|
||||||
timeout=60,
|
timeout=60,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
response = requests.put(
|
response = self._session.put(
|
||||||
f"{self.remote_addr}/edge/material",
|
f"{self.remote_addr}/edge/material",
|
||||||
json={"nodes": nodes_info, "mount_uuid": mount_uuid},
|
data=body_bytes,
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
headers=http_headers,
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -133,7 +138,7 @@ class HTTPClient:
|
|||||||
"""
|
"""
|
||||||
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_get.json"), "w", encoding="utf-8") as f:
|
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_get.json"), "w", encoding="utf-8") as f:
|
||||||
f.write(json.dumps({"uuids": uuid_list, "with_children": with_children}, indent=4))
|
f.write(json.dumps({"uuids": uuid_list, "with_children": with_children}, indent=4))
|
||||||
response = requests.post(
|
response = self._session.post(
|
||||||
f"{self.remote_addr}/edge/material/query",
|
f"{self.remote_addr}/edge/material/query",
|
||||||
json={"uuids": uuid_list, "with_children": with_children},
|
json={"uuids": uuid_list, "with_children": with_children},
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
headers={"Authorization": f"Lab {self.auth}"},
|
||||||
@@ -147,6 +152,7 @@ class HTTPClient:
|
|||||||
logger.error(f"查询物料失败: {response.text}")
|
logger.error(f"查询物料失败: {response.text}")
|
||||||
else:
|
else:
|
||||||
data = res["data"]["nodes"]
|
data = res["data"]["nodes"]
|
||||||
|
logger.trace(f"resource_tree_get查询到物料: {data}")
|
||||||
return data
|
return data
|
||||||
else:
|
else:
|
||||||
logger.error(f"查询物料失败: {response.text}")
|
logger.error(f"查询物料失败: {response.text}")
|
||||||
@@ -164,14 +170,14 @@ class HTTPClient:
|
|||||||
if not self.initialized:
|
if not self.initialized:
|
||||||
self.initialized = True
|
self.initialized = True
|
||||||
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
|
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
|
||||||
response = requests.post(
|
response = self._session.post(
|
||||||
f"{self.remote_addr}/lab/material",
|
f"{self.remote_addr}/lab/material",
|
||||||
json={"nodes": resources},
|
json={"nodes": resources},
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
headers={"Authorization": f"Lab {self.auth}"},
|
||||||
timeout=100,
|
timeout=100,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
response = requests.put(
|
response = self._session.put(
|
||||||
f"{self.remote_addr}/lab/material",
|
f"{self.remote_addr}/lab/material",
|
||||||
json={"nodes": resources},
|
json={"nodes": resources},
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
headers={"Authorization": f"Lab {self.auth}"},
|
||||||
@@ -198,7 +204,7 @@ class HTTPClient:
|
|||||||
"""
|
"""
|
||||||
with open(os.path.join(BasicConfig.working_dir, "req_resource_get.json"), "w", encoding="utf-8") as f:
|
with open(os.path.join(BasicConfig.working_dir, "req_resource_get.json"), "w", encoding="utf-8") as f:
|
||||||
f.write(json.dumps({"id": id, "with_children": with_children}, indent=4))
|
f.write(json.dumps({"id": id, "with_children": with_children}, indent=4))
|
||||||
response = requests.get(
|
response = self._session.get(
|
||||||
f"{self.remote_addr}/lab/material",
|
f"{self.remote_addr}/lab/material",
|
||||||
params={"id": id, "with_children": with_children},
|
params={"id": id, "with_children": with_children},
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
headers={"Authorization": f"Lab {self.auth}"},
|
||||||
@@ -239,14 +245,14 @@ class HTTPClient:
|
|||||||
if not self.initialized:
|
if not self.initialized:
|
||||||
self.initialized = True
|
self.initialized = True
|
||||||
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
|
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
|
||||||
response = requests.post(
|
response = self._session.post(
|
||||||
f"{self.remote_addr}/lab/material",
|
f"{self.remote_addr}/lab/material",
|
||||||
json={"nodes": resources},
|
json={"nodes": resources},
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
headers={"Authorization": f"Lab {self.auth}"},
|
||||||
timeout=100,
|
timeout=100,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
response = requests.put(
|
response = self._session.put(
|
||||||
f"{self.remote_addr}/lab/material",
|
f"{self.remote_addr}/lab/material",
|
||||||
json={"nodes": resources},
|
json={"nodes": resources},
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
headers={"Authorization": f"Lab {self.auth}"},
|
||||||
@@ -276,7 +282,7 @@ class HTTPClient:
|
|||||||
with open(file_path, "rb") as file:
|
with open(file_path, "rb") as file:
|
||||||
files = {"files": file}
|
files = {"files": file}
|
||||||
logger.info(f"上传文件: {file_path} 到 {scene}")
|
logger.info(f"上传文件: {file_path} 到 {scene}")
|
||||||
response = requests.post(
|
response = self._session.post(
|
||||||
f"{self.remote_addr}/api/account/file_upload/{scene}",
|
f"{self.remote_addr}/api/account/file_upload/{scene}",
|
||||||
files=files,
|
files=files,
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
headers={"Authorization": f"Lab {self.auth}"},
|
||||||
@@ -316,7 +322,7 @@ class HTTPClient:
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Content-Encoding": "gzip",
|
"Content-Encoding": "gzip",
|
||||||
}
|
}
|
||||||
response = requests.post(
|
response = self._session.post(
|
||||||
f"{self.remote_addr}/lab/resource",
|
f"{self.remote_addr}/lab/resource",
|
||||||
data=compressed_body,
|
data=compressed_body,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
@@ -350,7 +356,7 @@ class HTTPClient:
|
|||||||
Returns:
|
Returns:
|
||||||
Response: API响应对象
|
Response: API响应对象
|
||||||
"""
|
"""
|
||||||
response = requests.get(
|
response = self._session.get(
|
||||||
f"{self.remote_addr}/edge/material/download",
|
f"{self.remote_addr}/edge/material/download",
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
headers={"Authorization": f"Lab {self.auth}"},
|
||||||
timeout=(3, 30),
|
timeout=(3, 30),
|
||||||
@@ -411,7 +417,7 @@ class HTTPClient:
|
|||||||
with open(os.path.join(BasicConfig.working_dir, "req_workflow_upload.json"), "w", encoding="utf-8") as f:
|
with open(os.path.join(BasicConfig.working_dir, "req_workflow_upload.json"), "w", encoding="utf-8") as f:
|
||||||
f.write(json.dumps(payload, indent=4, ensure_ascii=False))
|
f.write(json.dumps(payload, indent=4, ensure_ascii=False))
|
||||||
|
|
||||||
response = requests.post(
|
response = self._session.post(
|
||||||
f"{self.remote_addr}/lab/workflow/owner/import",
|
f"{self.remote_addr}/lab/workflow/owner/import",
|
||||||
json=payload,
|
json=payload,
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
headers={"Authorization": f"Lab {self.auth}"},
|
||||||
|
|||||||
@@ -58,14 +58,14 @@ class JobResultStore:
|
|||||||
feedback=feedback or {},
|
feedback=feedback or {},
|
||||||
timestamp=time.time(),
|
timestamp=time.time(),
|
||||||
)
|
)
|
||||||
logger.trace(f"[JobResultStore] Stored result for job {job_id[:8]}, status={status}")
|
logger.debug(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.trace(f"[JobResultStore] Retrieved and removed result for job {job_id[:8]}")
|
logger.debug(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]:
|
||||||
|
|||||||
@@ -1269,7 +1269,13 @@ class QueueProcessor:
|
|||||||
if not queued_jobs:
|
if not queued_jobs:
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.debug(f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs")
|
queue_summary = {}
|
||||||
|
for j in queued_jobs:
|
||||||
|
key = f"{j.device_id}/{j.action_name}"
|
||||||
|
queue_summary[key] = queue_summary.get(key, 0) + 1
|
||||||
|
logger.debug(
|
||||||
|
f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs: {queue_summary}"
|
||||||
|
)
|
||||||
|
|
||||||
for job_info in queued_jobs:
|
for job_info in queued_jobs:
|
||||||
# 快照可能已过期:在遍历过程中 end_job() 可能已将此 job 移至 READY,
|
# 快照可能已过期:在遍历过程中 end_job() 可能已将此 job 移至 READY,
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class WSConfig:
|
|||||||
|
|
||||||
# HTTP配置
|
# HTTP配置
|
||||||
class HTTPConfig:
|
class HTTPConfig:
|
||||||
remote_addr = "https://uni-lab.bohrium.com/api/v1"
|
remote_addr = "https://leap-lab.bohrium.com/api/v1"
|
||||||
|
|
||||||
|
|
||||||
# ROS配置
|
# ROS配置
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -201,17 +201,42 @@ class ResourceVisualization:
|
|||||||
self.moveit_controllers_yaml['moveit_simple_controller_manager'][f"{name}_{controller_name}"] = moveit_dict['moveit_simple_controller_manager'][controller_name]
|
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描述对象
|
||||||
"""
|
"""
|
||||||
# 检查ROS 2环境变量
|
launch_env = self._ensure_ros2_env()
|
||||||
|
|
||||||
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"
|
||||||
@@ -290,7 +315,7 @@ class ResourceVisualization:
|
|||||||
{"robot_description": robot_description},
|
{"robot_description": robot_description},
|
||||||
ros2_controllers,
|
ros2_controllers,
|
||||||
],
|
],
|
||||||
env=dict(os.environ)
|
env=launch_env,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
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']:
|
||||||
@@ -300,7 +325,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=dict(os.environ)
|
env=launch_env,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
controllers.append(
|
controllers.append(
|
||||||
@@ -309,7 +334,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=dict(os.environ)
|
env=launch_env,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
for i in controllers:
|
for i in controllers:
|
||||||
@@ -317,7 +342,6 @@ 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',
|
||||||
@@ -327,9 +351,8 @@ class ResourceVisualization:
|
|||||||
'robot_description': robot_description,
|
'robot_description': robot_description,
|
||||||
'use_sim_time': False
|
'use_sim_time': False
|
||||||
},
|
},
|
||||||
# kinematics_dict
|
|
||||||
],
|
],
|
||||||
env=dict(os.environ)
|
env=launch_env,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -361,7 +384,7 @@ class ResourceVisualization:
|
|||||||
executable='move_group',
|
executable='move_group',
|
||||||
output='screen',
|
output='screen',
|
||||||
parameters=moveit_params,
|
parameters=moveit_params,
|
||||||
env=dict(os.environ)
|
env=launch_env,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -379,13 +402,11 @@ class ResourceVisualization:
|
|||||||
arguments=['-d', f"{str(self.mesh_path)}/view_robot.rviz"],
|
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=dict(os.environ)
|
env=launch_env,
|
||||||
)
|
)
|
||||||
self.launch_description.add_action(rviz_node)
|
self.launch_description.add_action(rviz_node)
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
221
unilabos/devices/liquid_handling/liquid_history.py
Normal file
221
unilabos/devices/liquid_handling/liquid_history.py
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
"""P9 — liquid_history schema v3 与 helper 函数。
|
||||||
|
|
||||||
|
独立模块,**不依赖 pylabrobot**,可在 PLR 环境缺失时单独单测。
|
||||||
|
|
||||||
|
模块由 ``liquid_handler_abstract.py`` 在 runtime 挂载点(set_liquid / aspirate /
|
||||||
|
dispense)调用,且由 ``resource_tracker._augment_states_with_liquid_history`` 在
|
||||||
|
serialize 链路使用。
|
||||||
|
|
||||||
|
详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md``。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, List, Tuple
|
||||||
|
|
||||||
|
from typing_extensions import TypedDict
|
||||||
|
|
||||||
|
|
||||||
|
# liquid_history 元素 schema v3
|
||||||
|
# 详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md`` §6.1。
|
||||||
|
# 旧格式(v2 ``(name, vol)`` 元组、list[str])由 ``normalize_liquid_history`` 升级。
|
||||||
|
class LiquidHistoryEntry(TypedDict, total=False):
|
||||||
|
name: str # 液体名(如 "Plasma";与 P8 reagent.liquid_name 联动;缺省 "")
|
||||||
|
volume: float # 操作体积(µL;aspirate 为负,dispense / set 为正)
|
||||||
|
action: str # "set" / "aspirate" / "dispense" / "legacy" / "auto_init"
|
||||||
|
timestamp: str # ISO8601 UTC(OS runtime 写入时填,前端写入时可省略)
|
||||||
|
|
||||||
|
|
||||||
|
# liquid_history 单 well 上限:超过则滚动丢弃头部
|
||||||
|
# 既限制内存(典型 8 通道 transfer 一次产生 ≤16 条),也防止极端 batch 拖慢前端渲染
|
||||||
|
LIQUID_HISTORY_MAX_ENTRIES = 1000
|
||||||
|
|
||||||
|
|
||||||
|
def well_current_liquid_name(well: Any) -> str:
|
||||||
|
"""从 ``well.tracker.liquids`` 末项读取当前液体名(PLR ``Liquid`` enum / str / None 兼容)。
|
||||||
|
|
||||||
|
P9:作为 ``aspirate`` 写入 history 时 ``name`` 字段的来源。
|
||||||
|
返回 ``""`` 表示未知(不写字面 "unknown",避免被前端误展示)。
|
||||||
|
"""
|
||||||
|
tracker = getattr(well, "tracker", None)
|
||||||
|
if tracker is None:
|
||||||
|
return ""
|
||||||
|
liquids = getattr(tracker, "liquids", None)
|
||||||
|
if not liquids:
|
||||||
|
# PLR 提供 get_liquids() 时优先用之(返回 list[(Liquid|None, vol)])
|
||||||
|
try:
|
||||||
|
liquids = tracker.get_liquids() # type: ignore[attr-defined]
|
||||||
|
except Exception:
|
||||||
|
liquids = None
|
||||||
|
if not liquids:
|
||||||
|
return ""
|
||||||
|
last = liquids[-1]
|
||||||
|
if isinstance(last, (list, tuple)) and last:
|
||||||
|
candidate = last[0]
|
||||||
|
else:
|
||||||
|
candidate = last
|
||||||
|
if candidate is None:
|
||||||
|
return ""
|
||||||
|
name = getattr(candidate, "name", None)
|
||||||
|
if isinstance(name, str) and name:
|
||||||
|
return name
|
||||||
|
if isinstance(candidate, str):
|
||||||
|
return candidate
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def append_liquid_history(
|
||||||
|
well: Any,
|
||||||
|
liquid_name: str,
|
||||||
|
volume: float,
|
||||||
|
action: str,
|
||||||
|
) -> None:
|
||||||
|
"""P9 — 统一写入 ``well.tracker.liquid_history``(PLR 扩展属性)。
|
||||||
|
|
||||||
|
设计要点:
|
||||||
|
- 元素为 v3 dict 形态 ``{name, volume, action, timestamp}``,与
|
||||||
|
:class:`LiquidHistoryEntry` schema 一致。
|
||||||
|
- ``aspirate`` 的 ``volume`` 应为**负数**(与 dispense/set 正数对称,
|
||||||
|
``sum(history.volume)`` ≈ 当前残量)。
|
||||||
|
- ``well`` 无 tracker 或 tracker 不可写时 graceful 静默(避免污染主流程)。
|
||||||
|
- 滚动上限 ``LIQUID_HISTORY_MAX_ENTRIES``:超出时丢弃**头部**(保留最近)。
|
||||||
|
|
||||||
|
详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md`` §6.2。
|
||||||
|
"""
|
||||||
|
tracker = getattr(well, "tracker", None)
|
||||||
|
if tracker is None:
|
||||||
|
return
|
||||||
|
history = getattr(tracker, "liquid_history", None)
|
||||||
|
if not isinstance(history, list):
|
||||||
|
history = []
|
||||||
|
try:
|
||||||
|
tracker.liquid_history = history # type: ignore[attr-defined]
|
||||||
|
except Exception:
|
||||||
|
return # tracker 拒绝写扩展属性(极少见);静默放弃
|
||||||
|
# 兼容修复:PLR VolumeTracker.current_liquids 依赖 tracker.liquid_history 为
|
||||||
|
# list[(name, vol)];若写入 dict 会在 `for name, vol in liquid_history` 时崩溃。
|
||||||
|
# 这里把历史就地归一为 tuple 形态,再 append tuple,避免 unpack ValueError。
|
||||||
|
normalized_pairs: List[Tuple[str, float]] = []
|
||||||
|
for item in history:
|
||||||
|
if isinstance(item, (list, tuple)) and len(item) >= 2:
|
||||||
|
name_val = str(item[0] or "")
|
||||||
|
try:
|
||||||
|
vol_val = float(item[1])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
vol_val = 0.0
|
||||||
|
normalized_pairs.append((name_val, vol_val))
|
||||||
|
elif isinstance(item, dict):
|
||||||
|
name_val = str(item.get("name", ""))
|
||||||
|
try:
|
||||||
|
vol_val = float(item.get("volume", 0.0) or 0.0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
vol_val = 0.0
|
||||||
|
normalized_pairs.append((name_val, vol_val))
|
||||||
|
elif isinstance(item, str):
|
||||||
|
normalized_pairs.append((item, 0.0))
|
||||||
|
history[:] = normalized_pairs
|
||||||
|
entry = (str(liquid_name or ""), float(volume))
|
||||||
|
history.append(entry)
|
||||||
|
overflow = len(history) - LIQUID_HISTORY_MAX_ENTRIES
|
||||||
|
if overflow > 0:
|
||||||
|
del history[:overflow]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# P10 v2 — Tip 复用 ``tracker.liquids`` 等价 helper(详见
|
||||||
|
# ``product_designs/protocol_convert/10-tip-reuse-by-liquid-history.md`` §3.2)
|
||||||
|
#
|
||||||
|
# 设计原则:
|
||||||
|
# - 信号源使用 PLR 原生 ``well.tracker.liquids`` 末项("well 此刻顶层液体"),
|
||||||
|
# 而非 P9 扩展属性 ``liquid_history``;P10 v2 因此不依赖 P9 是否落地。
|
||||||
|
# - 名称比较使用严格字符串相等;空 / "unknown" / "none" 一律保守视为未知 →
|
||||||
|
# 不触发 liquids 复用,落回 identity-only 现状(零回归)。
|
||||||
|
# - 与 P9 现有 ``liquid_names_before_aspirate`` 同模式:aspirate 之前预读
|
||||||
|
# source 当前液体名,避免 PLR 顶层归零时 pop ``liquids`` 拿不到身份。
|
||||||
|
# - 4 个 helper 共同居于本 PLR-free 模块,方便单元测试在不安装 pylabrobot
|
||||||
|
# 的环境下独立运行。
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def is_known_liquid_name(name: Any) -> bool:
|
||||||
|
"""空字符串 / "unknown" / "none" / None 一律视为未知,不触发 liquids 复用。"""
|
||||||
|
if not name:
|
||||||
|
return False
|
||||||
|
if not isinstance(name, str):
|
||||||
|
return False
|
||||||
|
return name.strip().lower() not in {"unknown", "none"}
|
||||||
|
|
||||||
|
|
||||||
|
def same_liquid_via_liquids(well: Any, tip_liquid_name: Any) -> bool:
|
||||||
|
"""tip 残留液体名 vs ``well.tracker.liquids`` 末项 name 严格相等。
|
||||||
|
|
||||||
|
用于 pick_up 决策:判断下一轮要 aspirate 的 well 当前液体是否与 tip 残液同名。
|
||||||
|
任一侧未知(空 / "unknown")→ 返回 ``False``(保守换 tip)。
|
||||||
|
"""
|
||||||
|
if not is_known_liquid_name(tip_liquid_name):
|
||||||
|
return False
|
||||||
|
well_name = well_current_liquid_name(well)
|
||||||
|
if not is_known_liquid_name(well_name):
|
||||||
|
return False
|
||||||
|
return well_name == tip_liquid_name
|
||||||
|
|
||||||
|
|
||||||
|
def same_liquid_via_liquids_pair(cur_well: Any, next_well: Any) -> bool:
|
||||||
|
"""两个 source well 当前 ``tracker.liquids`` 末项是否同名(用于决定 drop 时机)。
|
||||||
|
|
||||||
|
注:必须在 cur_well 的 aspirate **之前**调用;aspirate 不改
|
||||||
|
``liquids[-1].name`` 只改顶层 vol(或顶层归零时 pop),故 cur/next 的判等
|
||||||
|
以 "将要被抽的那一层" 为准。
|
||||||
|
"""
|
||||||
|
cur_name = well_current_liquid_name(cur_well)
|
||||||
|
next_name = well_current_liquid_name(next_well)
|
||||||
|
if not is_known_liquid_name(cur_name) or not is_known_liquid_name(next_name):
|
||||||
|
return False
|
||||||
|
return cur_name == next_name
|
||||||
|
|
||||||
|
|
||||||
|
def capture_tip_liquid_name(source_well: Any) -> "str | None":
|
||||||
|
"""**aspirate 之前** 把 source well 的当前液体名捕获下来,作为本轮 aspirate
|
||||||
|
完成后 tip 上残留液体的身份。
|
||||||
|
|
||||||
|
必须在 ``super().aspirate`` / ``_transfer_base_method`` 调用前读取:PLR
|
||||||
|
aspirate 会从顶层扣减体积,体积归零时 PLR ``VolumeTracker`` 会 pop 顶层
|
||||||
|
``(Liquid, vol)``,事后再读 ``liquids[-1]`` 可能拿到 prev layer 或空 list。
|
||||||
|
详见 ``liquid_handler_abstract.aspirate`` 中 ``liquid_names_before_aspirate``
|
||||||
|
同样的 "预读" 模式。
|
||||||
|
"""
|
||||||
|
name = well_current_liquid_name(source_well)
|
||||||
|
return name if is_known_liquid_name(name) else None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_liquid_history(raw: Any) -> List[Tuple[str, float]]:
|
||||||
|
"""P9 — 把任意旧形态的 liquid_history 升级为 v3 dict 列表。
|
||||||
|
|
||||||
|
兼容输入:
|
||||||
|
- v3 dict: ``[{name, volume, action, timestamp?}, ...]`` 原样返回(字段补全)
|
||||||
|
- v2 tuple: ``[(name, vol), ...]`` → ``action="legacy"``
|
||||||
|
- list[str]: ``["A", "B"]`` → ``volume=0, action="legacy"``
|
||||||
|
- 其它:丢弃该 entry
|
||||||
|
|
||||||
|
详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md`` §6.4。
|
||||||
|
"""
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
return []
|
||||||
|
result: List[Tuple[str, float]] = []
|
||||||
|
for entry in raw:
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
try:
|
||||||
|
vol_val = float(entry.get("volume", 0.0) or 0.0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
vol_val = 0.0
|
||||||
|
result.append((str(entry.get("name", "")), vol_val))
|
||||||
|
elif isinstance(entry, (list, tuple)) and len(entry) >= 2:
|
||||||
|
try:
|
||||||
|
vol_val = float(entry[1])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
vol_val = 0.0
|
||||||
|
result.append((str(entry[0] or ""), vol_val))
|
||||||
|
elif isinstance(entry, str):
|
||||||
|
result.append((entry, 0.0))
|
||||||
|
# 其它类型静默丢弃
|
||||||
|
return result
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
150
unilabos/devices/liquid_handling/prcxi/prcxi_modules.py
Normal file
150
unilabos/devices/liquid_handling/prcxi/prcxi_modules.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
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,6 +59,7 @@ 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
|
||||||
@@ -69,7 +70,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=True)
|
simulate_rviz=self.simulate_rviz)
|
||||||
|
|
||||||
# 启动ROS executor
|
# 启动ROS executor
|
||||||
self.executor = rclpy.executors.MultiThreadedExecutor()
|
self.executor = rclpy.executors.MultiThreadedExecutor()
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ 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,6 +2,7 @@ import json
|
|||||||
import time
|
import time
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Optional, Sequence
|
||||||
|
|
||||||
from moveit_msgs.msg import JointConstraint, Constraints
|
from moveit_msgs.msg import JointConstraint, Constraints
|
||||||
from rclpy.action import ActionClient
|
from rclpy.action import ActionClient
|
||||||
@@ -171,65 +172,61 @@ class MoveitInterface:
|
|||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def pick_and_place(self, command: str):
|
def pick_and_place(
|
||||||
|
self,
|
||||||
|
option: str,
|
||||||
|
move_group: str,
|
||||||
|
status: str,
|
||||||
|
resource: Optional[str] = None,
|
||||||
|
x_distance: Optional[float] = None,
|
||||||
|
y_distance: Optional[float] = None,
|
||||||
|
lift_height: Optional[float] = None,
|
||||||
|
retry: Optional[int] = None,
|
||||||
|
speed: Optional[float] = None,
|
||||||
|
target: Optional[str] = None,
|
||||||
|
constraints: Optional[Sequence[float]] = None,
|
||||||
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Using MoveIt to make the robotic arm pick or place materials to a target point.
|
使用 MoveIt 完成抓取/放置等序列(pick/place/side_pick/side_place)。
|
||||||
|
|
||||||
Args:
|
必选:option, move_group, status。
|
||||||
command: A JSON-formatted string that includes option, target, speed, lift_height, mt_height
|
可选:resource, x_distance, y_distance, lift_height, retry, speed, target, constraints。
|
||||||
|
无返回值;失败时提前 return 或打印异常。
|
||||||
*option (string) : Action type: pick/place/side_pick/side_place
|
|
||||||
*move_group (string): The move group moveit will plan
|
|
||||||
*status(string) : Target pose
|
|
||||||
resource(string) : The target resource
|
|
||||||
x_distance (float) : The distance to the target in x direction(meters)
|
|
||||||
y_distance (float) : The distance to the target in y direction(meters)
|
|
||||||
lift_height (float) : The height at which the material should be lifted(meters)
|
|
||||||
retry (float) : Retry times when moveit plan fails
|
|
||||||
speed (float) : The speed of the movement, speed > 0
|
|
||||||
Returns:
|
|
||||||
None
|
|
||||||
"""
|
"""
|
||||||
result = SendCmd.Result()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cmd_str = str(command).replace("'", '"')
|
if option not in self.move_option:
|
||||||
cmd_dict = json.loads(cmd_str)
|
raise ValueError(f"Invalid option: {option}")
|
||||||
|
|
||||||
if cmd_dict["option"] in self.move_option:
|
option_index = self.move_option.index(option)
|
||||||
option_index = self.move_option.index(cmd_dict["option"])
|
|
||||||
place_flag = option_index % 2
|
place_flag = option_index % 2
|
||||||
|
|
||||||
config = {}
|
config: dict = {"move_group": move_group}
|
||||||
|
if speed is not None:
|
||||||
|
config["speed"] = speed
|
||||||
|
if retry is not None:
|
||||||
|
config["retry"] = retry
|
||||||
|
|
||||||
function_list = []
|
function_list = []
|
||||||
|
joint_positions_ = self.joint_poses[move_group][status]
|
||||||
|
|
||||||
status = cmd_dict["status"]
|
# 夹取 / 放置:绑定 resource 与 parent
|
||||||
joint_positions_ = self.joint_poses[cmd_dict["move_group"]][status]
|
|
||||||
|
|
||||||
config.update({k: cmd_dict[k] for k in ["speed", "retry", "move_group"] if k in cmd_dict})
|
|
||||||
|
|
||||||
# 夹取
|
|
||||||
if not place_flag:
|
if not place_flag:
|
||||||
if "target" in cmd_dict.keys():
|
if target is not None:
|
||||||
function_list.append(lambda: self.resource_manager(cmd_dict["resource"], cmd_dict["target"]))
|
function_list.append(lambda r=resource, t=target: self.resource_manager(r, t))
|
||||||
else:
|
else:
|
||||||
function_list.append(
|
ee = self.moveit2[move_group].end_effector_name
|
||||||
lambda: self.resource_manager(
|
function_list.append(lambda r=resource: self.resource_manager(r, ee))
|
||||||
cmd_dict["resource"], self.moveit2[cmd_dict["move_group"]].end_effector_name
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
function_list.append(lambda: self.resource_manager(cmd_dict["resource"], "world"))
|
function_list.append(lambda r=resource: self.resource_manager(r, "world"))
|
||||||
|
|
||||||
constraints = []
|
joint_constraint_msgs: list = []
|
||||||
if "constraints" in cmd_dict.keys():
|
if constraints is not None:
|
||||||
|
for i, c in enumerate(constraints):
|
||||||
for i in range(len(cmd_dict["constraints"])):
|
v = float(c)
|
||||||
v = float(cmd_dict["constraints"][i])
|
|
||||||
if v > 0:
|
if v > 0:
|
||||||
constraints.append(
|
joint_constraint_msgs.append(
|
||||||
JointConstraint(
|
JointConstraint(
|
||||||
joint_name=self.moveit2[cmd_dict["move_group"]].joint_names[i],
|
joint_name=self.moveit2[move_group].joint_names[i],
|
||||||
position=joint_positions_[i],
|
position=joint_positions_[i],
|
||||||
tolerance_above=v,
|
tolerance_above=v,
|
||||||
tolerance_below=v,
|
tolerance_below=v,
|
||||||
@@ -237,16 +234,15 @@ class MoveitInterface:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if "lift_height" in cmd_dict.keys():
|
if lift_height is not None:
|
||||||
retval = None
|
retval = None
|
||||||
retry = config.get("retry", 10)
|
attempts = config.get("retry", 10)
|
||||||
while retval is None and retry > 0:
|
while retval is None and attempts > 0:
|
||||||
retval = self.moveit2[cmd_dict["move_group"]].compute_fk(joint_positions_)
|
retval = self.moveit2[move_group].compute_fk(joint_positions_)
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
retry -= 1
|
attempts -= 1
|
||||||
if retval is None:
|
if retval is None:
|
||||||
result.success = False
|
raise ValueError("Failed to compute forward kinematics")
|
||||||
return result
|
|
||||||
pose = [retval.pose.position.x, retval.pose.position.y, retval.pose.position.z]
|
pose = [retval.pose.position.x, retval.pose.position.y, retval.pose.position.z]
|
||||||
quaternion = [
|
quaternion = [
|
||||||
retval.pose.orientation.x,
|
retval.pose.orientation.x,
|
||||||
@@ -264,60 +260,57 @@ class MoveitInterface:
|
|||||||
)
|
)
|
||||||
] + function_list
|
] + function_list
|
||||||
|
|
||||||
pose[2] += float(cmd_dict["lift_height"])
|
pose[2] += float(lift_height)
|
||||||
function_list.append(
|
function_list.append(
|
||||||
lambda: self.moveit_task(
|
lambda p=pose.copy(), q=quaternion, cfg=config: self.moveit_task(
|
||||||
position=pose, quaternion=quaternion, **config, cartesian=self.cartesian_flag
|
position=p, quaternion=q, **cfg, cartesian=self.cartesian_flag
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
end_pose = pose
|
end_pose = list(pose)
|
||||||
|
|
||||||
if "x_distance" in cmd_dict.keys() or "y_distance" in cmd_dict.keys():
|
if x_distance is not None or y_distance is not None:
|
||||||
if "x_distance" in cmd_dict.keys():
|
if x_distance is not None:
|
||||||
deep_pose = deepcopy(pose)
|
deep_pose = deepcopy(pose)
|
||||||
deep_pose[0] += float(cmd_dict["x_distance"])
|
deep_pose[0] += float(x_distance)
|
||||||
elif "y_distance" in cmd_dict.keys():
|
elif y_distance is not None:
|
||||||
deep_pose = deepcopy(pose)
|
deep_pose = deepcopy(pose)
|
||||||
deep_pose[1] += float(cmd_dict["y_distance"])
|
deep_pose[1] += float(y_distance)
|
||||||
|
|
||||||
function_list = [
|
function_list = [
|
||||||
lambda: self.moveit_task(
|
lambda p=pose.copy(), q=quaternion, cfg=config: self.moveit_task(
|
||||||
position=pose, quaternion=quaternion, **config, cartesian=self.cartesian_flag
|
position=p, quaternion=q, **cfg, cartesian=self.cartesian_flag
|
||||||
)
|
)
|
||||||
] + function_list
|
] + function_list
|
||||||
function_list.append(
|
function_list.append(
|
||||||
lambda: self.moveit_task(
|
lambda dp=deep_pose.copy(), q=quaternion, cfg=config: self.moveit_task(
|
||||||
position=deep_pose, quaternion=quaternion, **config, cartesian=self.cartesian_flag
|
position=dp, quaternion=q, **cfg, cartesian=self.cartesian_flag
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
end_pose = deep_pose
|
end_pose = list(deep_pose)
|
||||||
|
|
||||||
retval_ik = None
|
retval_ik = None
|
||||||
retry = config.get("retry", 10)
|
attempts_ik = config.get("retry", 10)
|
||||||
while retval_ik is None and retry > 0:
|
while retval_ik is None and attempts_ik > 0:
|
||||||
retval_ik = self.moveit2[cmd_dict["move_group"]].compute_ik(
|
retval_ik = self.moveit2[move_group].compute_ik(
|
||||||
position=end_pose, quat_xyzw=quaternion, constraints=Constraints(joint_constraints=constraints)
|
position=end_pose,
|
||||||
|
quat_xyzw=quaternion,
|
||||||
|
constraints=Constraints(joint_constraints=joint_constraint_msgs),
|
||||||
)
|
)
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
retry -= 1
|
attempts_ik -= 1
|
||||||
if retval_ik is None:
|
if retval_ik is None:
|
||||||
result.success = False
|
raise ValueError("Failed to compute inverse kinematics")
|
||||||
return result
|
|
||||||
position_ = [
|
position_ = [
|
||||||
retval_ik.position[retval_ik.name.index(i)]
|
retval_ik.position[retval_ik.name.index(i)] for i in self.moveit2[move_group].joint_names
|
||||||
for i in self.moveit2[cmd_dict["move_group"]].joint_names
|
|
||||||
]
|
]
|
||||||
|
jn = self.moveit2[move_group].joint_names
|
||||||
function_list = [
|
function_list = [
|
||||||
lambda: self.moveit_joint_task(
|
lambda pos=position_, names=jn, cfg=config: self.moveit_joint_task(
|
||||||
joint_positions=position_,
|
joint_positions=pos, joint_names=names, **cfg
|
||||||
joint_names=self.moveit2[cmd_dict["move_group"]].joint_names,
|
|
||||||
**config,
|
|
||||||
)
|
)
|
||||||
] + function_list
|
] + function_list
|
||||||
else:
|
else:
|
||||||
function_list = [
|
function_list = [lambda cfg=config, jp=joint_positions_: self.moveit_joint_task(**cfg, joint_positions=jp)] + function_list
|
||||||
lambda: self.moveit_joint_task(**config, joint_positions=joint_positions_)
|
|
||||||
] + function_list
|
|
||||||
|
|
||||||
for i in range(len(function_list)):
|
for i in range(len(function_list)):
|
||||||
if i == 0:
|
if i == 0:
|
||||||
@@ -328,16 +321,11 @@ class MoveitInterface:
|
|||||||
re = function_list[i]()
|
re = function_list[i]()
|
||||||
if not re:
|
if not re:
|
||||||
print(i, re)
|
print(i, re)
|
||||||
result.success = False
|
raise ValueError(f"Failed to execute moveit task: {i}")
|
||||||
return result
|
|
||||||
result.success = True
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
|
||||||
self.cartesian_flag = False
|
self.cartesian_flag = False
|
||||||
result.success = False
|
raise e
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def set_status(self, command: str):
|
def set_status(self, command: str):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import time
|
|||||||
import logging
|
import logging
|
||||||
from typing import Union, Dict, Optional
|
from typing import Union, Dict, Optional
|
||||||
|
|
||||||
|
from unilabos.registry.decorators import topic_config
|
||||||
|
|
||||||
|
|
||||||
class VirtualMultiwayValve:
|
class VirtualMultiwayValve:
|
||||||
"""
|
"""
|
||||||
@@ -41,13 +43,11 @@ class VirtualMultiwayValve:
|
|||||||
def target_position(self) -> int:
|
def target_position(self) -> int:
|
||||||
return self._target_position
|
return self._target_position
|
||||||
|
|
||||||
def get_current_position(self) -> int:
|
@property
|
||||||
"""获取当前阀门位置 📍"""
|
@topic_config()
|
||||||
return self._current_position
|
def current_port(self) -> str:
|
||||||
|
"""当前连接的端口名称 🔌"""
|
||||||
def get_current_port(self) -> str:
|
return self.port
|
||||||
"""获取当前连接的端口名称 🔌"""
|
|
||||||
return self._current_position
|
|
||||||
|
|
||||||
def set_position(self, command: Union[int, str]):
|
def set_position(self, command: Union[int, str]):
|
||||||
"""
|
"""
|
||||||
@@ -169,12 +169,14 @@ class VirtualMultiwayValve:
|
|||||||
self._status = "Idle"
|
self._status = "Idle"
|
||||||
self._valve_state = "Closed"
|
self._valve_state = "Closed"
|
||||||
|
|
||||||
close_msg = f"🔒 阀门已关闭,保持在位置 {self._current_position} ({self.get_current_port()})"
|
close_msg = f"🔒 阀门已关闭,保持在位置 {self._current_position} ({self.port})"
|
||||||
self.logger.info(close_msg)
|
self.logger.info(close_msg)
|
||||||
return close_msg
|
return close_msg
|
||||||
|
|
||||||
def get_valve_position(self) -> int:
|
@property
|
||||||
"""获取阀门位置 - 兼容性方法 📍"""
|
@topic_config()
|
||||||
|
def valve_position(self) -> int:
|
||||||
|
"""阀门位置 📍"""
|
||||||
return self._current_position
|
return self._current_position
|
||||||
|
|
||||||
def set_valve_position(self, command: Union[int, str]):
|
def set_valve_position(self, command: Union[int, str]):
|
||||||
@@ -229,19 +231,16 @@ class VirtualMultiwayValve:
|
|||||||
self.logger.info(f"🔄 从端口 {self._current_position} 切换到泵位置...")
|
self.logger.info(f"🔄 从端口 {self._current_position} 切换到泵位置...")
|
||||||
return self.set_to_pump_position()
|
return self.set_to_pump_position()
|
||||||
|
|
||||||
def get_flow_path(self) -> str:
|
@property
|
||||||
"""获取当前流路路径描述 🌊"""
|
@topic_config()
|
||||||
current_port = self.get_current_port()
|
def flow_path(self) -> str:
|
||||||
|
"""当前流路路径描述 🌊"""
|
||||||
if self._current_position == 0:
|
if self._current_position == 0:
|
||||||
flow_path = f"🚰 转移泵已连接 (位置 {self._current_position})"
|
return f"🚰 转移泵已连接 (位置 {self._current_position})"
|
||||||
else:
|
return f"🔌 端口 {self._current_position} 已连接 ({self.current_port})"
|
||||||
flow_path = f"🔌 端口 {self._current_position} 已连接 ({current_port})"
|
|
||||||
|
|
||||||
# 删除debug日志:self.logger.debug(f"🌊 当前流路: {flow_path}")
|
|
||||||
return flow_path
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
current_port = self.get_current_port()
|
current_port = self.current_port
|
||||||
status_emoji = "✅" if self._status == "Idle" else "🔄" if self._status == "Busy" else "❌"
|
status_emoji = "✅" if self._status == "Idle" else "🔄" if self._status == "Busy" else "❌"
|
||||||
|
|
||||||
return f"🔄 VirtualMultiwayValve({status_emoji} 位置: {self._current_position}/{self.max_positions}, 端口: {current_port}, 状态: {self._status})"
|
return f"🔄 VirtualMultiwayValve({status_emoji} 位置: {self._current_position}/{self.max_positions}, 端口: {current_port}, 状态: {self._status})"
|
||||||
@@ -253,7 +252,7 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
print("🔄 === 虚拟九通阀门测试 === ✨")
|
print("🔄 === 虚拟九通阀门测试 === ✨")
|
||||||
print(f"🏠 初始状态: {valve}")
|
print(f"🏠 初始状态: {valve}")
|
||||||
print(f"🌊 当前流路: {valve.get_flow_path()}")
|
print(f"🌊 当前流路: {valve.flow_path}")
|
||||||
|
|
||||||
# 切换到试剂瓶1(1号位)
|
# 切换到试剂瓶1(1号位)
|
||||||
print(f"\n🔌 切换到1号位: {valve.set_position(1)}")
|
print(f"\n🔌 切换到1号位: {valve.set_position(1)}")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import logging
|
|||||||
import time as time_module
|
import time as time_module
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
from unilabos.registry.decorators import topic_config
|
||||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||||
|
|
||||||
class VirtualStirrer:
|
class VirtualStirrer:
|
||||||
@@ -314,9 +315,11 @@ class VirtualStirrer:
|
|||||||
def min_speed(self) -> float:
|
def min_speed(self) -> float:
|
||||||
return self._min_speed
|
return self._min_speed
|
||||||
|
|
||||||
def get_device_info(self) -> Dict[str, Any]:
|
@property
|
||||||
"""获取设备状态信息 📊"""
|
@topic_config()
|
||||||
info = {
|
def device_info(self) -> Dict[str, Any]:
|
||||||
|
"""设备状态快照信息 📊"""
|
||||||
|
return {
|
||||||
"device_id": self.device_id,
|
"device_id": self.device_id,
|
||||||
"status": self.status,
|
"status": self.status,
|
||||||
"operation_mode": self.operation_mode,
|
"operation_mode": self.operation_mode,
|
||||||
@@ -325,12 +328,9 @@ class VirtualStirrer:
|
|||||||
"is_stirring": self.is_stirring,
|
"is_stirring": self.is_stirring,
|
||||||
"remaining_time": self.remaining_time,
|
"remaining_time": self.remaining_time,
|
||||||
"max_speed": self._max_speed,
|
"max_speed": self._max_speed,
|
||||||
"min_speed": self._min_speed
|
"min_speed": self._min_speed,
|
||||||
}
|
}
|
||||||
|
|
||||||
# self.logger.debug(f"📊 设备信息: 模式={self.operation_mode}, 速度={self.current_speed} RPM, 搅拌={self.is_stirring}")
|
|
||||||
return info
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
status_emoji = "✅" if self.operation_mode == "Idle" else "🌪️" if self.operation_mode == "Stirring" else "🛑" if self.operation_mode == "Settling" else "❌"
|
status_emoji = "✅" if self.operation_mode == "Idle" else "🌪️" if self.operation_mode == "Stirring" else "🛑" if self.operation_mode == "Settling" else "❌"
|
||||||
return f"🌪️ VirtualStirrer({status_emoji} {self.device_id}: {self.operation_mode}, {self.current_speed} RPM)"
|
return f"🌪️ VirtualStirrer({status_emoji} {self.device_id}: {self.operation_mode}, {self.current_speed} RPM)"
|
||||||
@@ -4,6 +4,7 @@ from enum import Enum
|
|||||||
from typing import Union, Optional
|
from typing import Union, Optional
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from unilabos.registry.decorators import topic_config
|
||||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||||
|
|
||||||
|
|
||||||
@@ -385,8 +386,10 @@ class VirtualTransferPump:
|
|||||||
"""获取当前体积"""
|
"""获取当前体积"""
|
||||||
return self._current_volume
|
return self._current_volume
|
||||||
|
|
||||||
def get_remaining_capacity(self) -> float:
|
@property
|
||||||
"""获取剩余容量"""
|
@topic_config()
|
||||||
|
def remaining_capacity(self) -> float:
|
||||||
|
"""剩余容量 (ml)"""
|
||||||
return self.max_volume - self._current_volume
|
return self.max_volume - self._current_volume
|
||||||
|
|
||||||
def is_empty(self) -> bool:
|
def is_empty(self) -> bool:
|
||||||
|
|||||||
@@ -14,19 +14,30 @@ Virtual Workbench Device - 模拟工作台设备
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import Dict, Any, Optional, List
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from threading import Lock, RLock
|
from threading import Lock, RLock
|
||||||
|
from typing import Any, Dict, List, Optional, cast
|
||||||
|
|
||||||
from typing_extensions import TypedDict
|
from typing_extensions import TypedDict
|
||||||
|
|
||||||
from unilabos.registry.decorators import (
|
from unilabos.registry.decorators import (
|
||||||
device, action, ActionInputHandle, ActionOutputHandle, DataSource, topic_config, not_action
|
ActionInputHandle,
|
||||||
|
ActionOutputHandle,
|
||||||
|
DataSource,
|
||||||
|
NodeType,
|
||||||
|
action,
|
||||||
|
device,
|
||||||
|
not_action,
|
||||||
|
topic_config,
|
||||||
|
)
|
||||||
|
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
|
||||||
|
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode
|
||||||
|
from unilabos.resources.resource_tracker import (
|
||||||
|
SampleUUIDsType,
|
||||||
|
LabSample,
|
||||||
|
ResourceTreeSet,
|
||||||
)
|
)
|
||||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
|
||||||
from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample
|
|
||||||
|
|
||||||
|
|
||||||
# ============ TypedDict 返回类型定义 ============
|
# ============ TypedDict 返回类型定义 ============
|
||||||
|
|
||||||
@@ -111,6 +122,7 @@ class HeatingStation:
|
|||||||
|
|
||||||
@device(
|
@device(
|
||||||
id="virtual_workbench",
|
id="virtual_workbench",
|
||||||
|
display_name="虚拟工作台",
|
||||||
category=["virtual_device"],
|
category=["virtual_device"],
|
||||||
description="Virtual Workbench with 1 robotic arm and 3 heating stations for concurrent material processing",
|
description="Virtual Workbench with 1 robotic arm and 3 heating stations for concurrent material processing",
|
||||||
)
|
)
|
||||||
@@ -136,7 +148,19 @@ class VirtualWorkbench:
|
|||||||
HEATING_TIME: float = 60.0 # 加热时间(秒)
|
HEATING_TIME: float = 60.0 # 加热时间(秒)
|
||||||
NUM_HEATING_STATIONS: int = 3 # 加热台数量
|
NUM_HEATING_STATIONS: int = 3 # 加热台数量
|
||||||
|
|
||||||
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
|
def __init__(
|
||||||
|
self,
|
||||||
|
device_id: Optional[str] = None,
|
||||||
|
config: Optional[Dict[str, Any]] = None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
初始化虚拟工作台。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_id[设备ID]: 工作台设备实例 ID,默认使用 virtual_workbench。
|
||||||
|
config[设备配置]: 可包含 arm_operation_time、heating_time、num_heating_stations。
|
||||||
|
"""
|
||||||
# 处理可能的不同调用方式
|
# 处理可能的不同调用方式
|
||||||
if device_id is None and "id" in kwargs:
|
if device_id is None and "id" in kwargs:
|
||||||
device_id = kwargs.pop("id")
|
device_id = kwargs.pop("id")
|
||||||
@@ -150,9 +174,13 @@ class VirtualWorkbench:
|
|||||||
self.data: Dict[str, Any] = {}
|
self.data: Dict[str, Any] = {}
|
||||||
|
|
||||||
# 从config中获取可配置参数
|
# 从config中获取可配置参数
|
||||||
self.ARM_OPERATION_TIME = float(self.config.get("arm_operation_time", self.ARM_OPERATION_TIME))
|
self.ARM_OPERATION_TIME = float(
|
||||||
|
self.config.get("arm_operation_time", self.ARM_OPERATION_TIME)
|
||||||
|
)
|
||||||
self.HEATING_TIME = float(self.config.get("heating_time", self.HEATING_TIME))
|
self.HEATING_TIME = float(self.config.get("heating_time", self.HEATING_TIME))
|
||||||
self.NUM_HEATING_STATIONS = int(self.config.get("num_heating_stations", self.NUM_HEATING_STATIONS))
|
self.NUM_HEATING_STATIONS = int(
|
||||||
|
self.config.get("num_heating_stations", self.NUM_HEATING_STATIONS)
|
||||||
|
)
|
||||||
|
|
||||||
# 机械臂状态和锁
|
# 机械臂状态和锁
|
||||||
self._arm_lock = Lock()
|
self._arm_lock = Lock()
|
||||||
@@ -161,7 +189,8 @@ class VirtualWorkbench:
|
|||||||
|
|
||||||
# 加热台状态
|
# 加热台状态
|
||||||
self._heating_stations: Dict[int, HeatingStation] = {
|
self._heating_stations: Dict[int, HeatingStation] = {
|
||||||
i: HeatingStation(station_id=i) for i in range(1, self.NUM_HEATING_STATIONS + 1)
|
i: HeatingStation(station_id=i)
|
||||||
|
for i in range(1, self.NUM_HEATING_STATIONS + 1)
|
||||||
}
|
}
|
||||||
self._stations_lock = RLock()
|
self._stations_lock = RLock()
|
||||||
|
|
||||||
@@ -290,20 +319,292 @@ class VirtualWorkbench:
|
|||||||
self._update_data_status(f"机械臂已释放 (完成: {task})")
|
self._update_data_status(f"机械臂已释放 (完成: {task})")
|
||||||
self.logger.info(f"机械臂已释放 (完成: {task})")
|
self.logger.info(f"机械臂已释放 (完成: {task})")
|
||||||
|
|
||||||
|
@action(
|
||||||
|
always_free=True,
|
||||||
|
node_type=NodeType.MANUAL_CONFIRM,
|
||||||
|
placeholder_keys={"assignee_user_ids": "unilabos_manual_confirm"},
|
||||||
|
goal_default={"timeout_seconds": 3600, "assignee_user_ids": []},
|
||||||
|
feedback_interval=300,
|
||||||
|
handles=[
|
||||||
|
ActionInputHandle(
|
||||||
|
key="target_device",
|
||||||
|
data_type="device_id",
|
||||||
|
label="目标设备",
|
||||||
|
data_key="target_device",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="resource",
|
||||||
|
data_type="resource",
|
||||||
|
label="待转移资源",
|
||||||
|
data_key="resource",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="mount_resource",
|
||||||
|
data_type="resource",
|
||||||
|
label="目标孔位",
|
||||||
|
data_key="mount_resource",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="collector_mass",
|
||||||
|
data_type="collector_mass",
|
||||||
|
label="极流体质量",
|
||||||
|
data_key="collector_mass",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="active_material",
|
||||||
|
data_type="active_material",
|
||||||
|
label="活性物质含量",
|
||||||
|
data_key="active_material",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="capacity",
|
||||||
|
data_type="capacity",
|
||||||
|
label="克容量",
|
||||||
|
data_key="capacity",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="battery_system",
|
||||||
|
data_type="battery_system",
|
||||||
|
label="电池体系",
|
||||||
|
data_key="battery_system",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
# transfer使用
|
||||||
|
ActionOutputHandle(
|
||||||
|
key="target_device",
|
||||||
|
data_type="device_id",
|
||||||
|
label="目标设备",
|
||||||
|
data_key="target_device",
|
||||||
|
data_source=DataSource.EXECUTOR,
|
||||||
|
),
|
||||||
|
ActionOutputHandle(
|
||||||
|
key="resource",
|
||||||
|
data_type="resource",
|
||||||
|
label="待转移资源",
|
||||||
|
data_key="resource.@flatten",
|
||||||
|
data_source=DataSource.EXECUTOR,
|
||||||
|
),
|
||||||
|
ActionOutputHandle(
|
||||||
|
key="mount_resource",
|
||||||
|
data_type="resource",
|
||||||
|
label="目标孔位",
|
||||||
|
data_key="mount_resource.@flatten",
|
||||||
|
data_source=DataSource.EXECUTOR,
|
||||||
|
),
|
||||||
|
# test使用
|
||||||
|
ActionOutputHandle(
|
||||||
|
key="collector_mass",
|
||||||
|
data_type="collector_mass",
|
||||||
|
label="极流体质量",
|
||||||
|
data_key="collector_mass",
|
||||||
|
data_source=DataSource.EXECUTOR,
|
||||||
|
),
|
||||||
|
ActionOutputHandle(
|
||||||
|
key="active_material",
|
||||||
|
data_type="active_material",
|
||||||
|
label="活性物质含量",
|
||||||
|
data_key="active_material",
|
||||||
|
data_source=DataSource.EXECUTOR,
|
||||||
|
),
|
||||||
|
ActionOutputHandle(
|
||||||
|
key="capacity",
|
||||||
|
data_type="capacity",
|
||||||
|
label="克容量",
|
||||||
|
data_key="capacity",
|
||||||
|
data_source=DataSource.EXECUTOR,
|
||||||
|
),
|
||||||
|
ActionOutputHandle(
|
||||||
|
key="battery_system",
|
||||||
|
data_type="battery_system",
|
||||||
|
label="电池体系",
|
||||||
|
data_key="battery_system",
|
||||||
|
data_source=DataSource.EXECUTOR,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def manual_confirm(
|
||||||
|
self,
|
||||||
|
resource: List[ResourceSlot],
|
||||||
|
target_device: DeviceSlot,
|
||||||
|
mount_resource: List[ResourceSlot],
|
||||||
|
collector_mass: List[float],
|
||||||
|
active_material: List[float],
|
||||||
|
capacity: List[float],
|
||||||
|
battery_system: List[str],
|
||||||
|
timeout_seconds: int,
|
||||||
|
assignee_user_ids: list[str],
|
||||||
|
**kwargs,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
人工确认资源转移和扣电测试参数。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
resource[待转移资源]: 需要人工确认的资源列表。
|
||||||
|
target_device[目标设备]: 资源要转移到的目标设备 ID。
|
||||||
|
mount_resource[目标孔位]: 资源要挂载到的目标孔位列表。
|
||||||
|
collector_mass[极流体质量]: 每个样品对应的极流体质量。
|
||||||
|
active_material[活性物质含量]: 每个样品对应的活性物质含量。
|
||||||
|
capacity[克容量]: 每个样品对应的克容量,单位 mAh/g。
|
||||||
|
battery_system[电池体系]: 每个样品对应的电池体系名称。
|
||||||
|
timeout_seconds[超时时间]: 人工确认超时时间,单位秒。
|
||||||
|
assignee_user_ids[确认人]: 指定处理人工确认任务的用户 ID 列表。
|
||||||
|
|
||||||
|
Note:
|
||||||
|
修改的结果无效,是只读的。
|
||||||
|
"""
|
||||||
|
resource_tree = ResourceTreeSet.from_plr_resources(cast(Any, resource)).dump()
|
||||||
|
mount_resource_tree = ResourceTreeSet.from_plr_resources(cast(Any, mount_resource)).dump()
|
||||||
|
kwargs.update(locals())
|
||||||
|
kwargs.pop("kwargs")
|
||||||
|
kwargs.pop("self")
|
||||||
|
kwargs["resource"] = resource_tree
|
||||||
|
kwargs["mount_resource"] = mount_resource_tree
|
||||||
|
kwargs.pop("resource_tree")
|
||||||
|
kwargs.pop("mount_resource_tree")
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
@action(
|
||||||
|
description="转移物料",
|
||||||
|
handles=[
|
||||||
|
ActionInputHandle(
|
||||||
|
key="target_device",
|
||||||
|
data_type="device_id",
|
||||||
|
label="目标设备",
|
||||||
|
data_key="target_device",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="resource",
|
||||||
|
data_type="resource",
|
||||||
|
label="待转移资源",
|
||||||
|
data_key="resource",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="mount_resource",
|
||||||
|
data_type="resource",
|
||||||
|
label="目标孔位",
|
||||||
|
data_key="mount_resource",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def transfer(
|
||||||
|
self,
|
||||||
|
resource: List[ResourceSlot],
|
||||||
|
target_device: DeviceSlot,
|
||||||
|
mount_resource: List[ResourceSlot],
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
转移资源到目标设备。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
resource[待转移资源]: 待转移的资源列表。
|
||||||
|
target_device[目标设备]: 接收资源的目标设备 ID。
|
||||||
|
mount_resource[目标孔位]: 目标设备上的挂载孔位列表。
|
||||||
|
"""
|
||||||
|
future = ROS2DeviceNode.run_async_func(
|
||||||
|
self._ros_node.transfer_resource_to_another,
|
||||||
|
True,
|
||||||
|
**{
|
||||||
|
"plr_resources": resource,
|
||||||
|
"target_device_id": target_device,
|
||||||
|
"target_resources": mount_resource,
|
||||||
|
"sites": [None] * len(mount_resource),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
result = await future
|
||||||
|
return result
|
||||||
|
|
||||||
|
@action(
|
||||||
|
description="扣电测试启动",
|
||||||
|
handles=[
|
||||||
|
ActionInputHandle(
|
||||||
|
key="resource",
|
||||||
|
data_type="resource",
|
||||||
|
label="待转移资源",
|
||||||
|
data_key="resource",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="mount_resource",
|
||||||
|
data_type="resource",
|
||||||
|
label="目标孔位",
|
||||||
|
data_key="mount_resource",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="collector_mass",
|
||||||
|
data_type="collector_mass",
|
||||||
|
label="极流体质量",
|
||||||
|
data_key="collector_mass",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="active_material",
|
||||||
|
data_type="active_material",
|
||||||
|
label="活性物质含量",
|
||||||
|
data_key="active_material",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="capacity",
|
||||||
|
data_type="capacity",
|
||||||
|
label="克容量",
|
||||||
|
data_key="capacity",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="battery_system",
|
||||||
|
data_type="battery_system",
|
||||||
|
label="电池体系",
|
||||||
|
data_key="battery_system",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test(
|
||||||
|
self,
|
||||||
|
resource: List[ResourceSlot],
|
||||||
|
mount_resource: List[ResourceSlot],
|
||||||
|
collector_mass: List[float],
|
||||||
|
active_material: List[float],
|
||||||
|
capacity: List[float],
|
||||||
|
battery_system: list[str],
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
启动扣电测试。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
resource[待测试资源]: 需要进行扣电测试的资源列表。
|
||||||
|
mount_resource[测试孔位]: 扣电测试使用的目标孔位列表。
|
||||||
|
collector_mass[极流体质量]: 每个样品对应的极流体质量。
|
||||||
|
active_material[活性物质含量]: 每个样品对应的活性物质含量。
|
||||||
|
capacity[克容量]: 每个样品对应的克容量,单位 mAh/g。
|
||||||
|
battery_system[电池体系]: 每个样品对应的电池体系名称。
|
||||||
|
"""
|
||||||
|
print(resource)
|
||||||
|
print(mount_resource)
|
||||||
|
print(collector_mass)
|
||||||
|
print(active_material)
|
||||||
|
print(capacity)
|
||||||
|
print(battery_system)
|
||||||
|
|
||||||
@action(
|
@action(
|
||||||
auto_prefix=True,
|
auto_prefix=True,
|
||||||
description="批量准备物料 - 虚拟起始节点, 生成A1-A5物料, 输出5个handle供后续节点使用",
|
description="批量准备物料 - 虚拟起始节点, 生成A1-A5物料, 输出5个handle供后续节点使用",
|
||||||
handles=[
|
handles=[
|
||||||
ActionOutputHandle(key="channel_1", data_type="workbench_material",
|
ActionOutputHandle(key="channel_1", data_type="workbench_material", label="实验1", data_key="material_1", data_source=DataSource.EXECUTOR), # noqa: E501
|
||||||
label="实验1", data_key="material_1", data_source=DataSource.EXECUTOR),
|
ActionOutputHandle(key="channel_2", data_type="workbench_material", label="实验2", data_key="material_2", data_source=DataSource.EXECUTOR), # noqa: E501
|
||||||
ActionOutputHandle(key="channel_2", data_type="workbench_material",
|
ActionOutputHandle(key="channel_3", data_type="workbench_material", label="实验3", data_key="material_3", data_source=DataSource.EXECUTOR), # noqa: E501
|
||||||
label="实验2", data_key="material_2", data_source=DataSource.EXECUTOR),
|
ActionOutputHandle(key="channel_4", data_type="workbench_material", label="实验4", data_key="material_4", data_source=DataSource.EXECUTOR), # noqa: E501
|
||||||
ActionOutputHandle(key="channel_3", data_type="workbench_material",
|
ActionOutputHandle(key="channel_5", data_type="workbench_material", label="实验5", data_key="material_5", data_source=DataSource.EXECUTOR), # noqa: E501
|
||||||
label="实验3", data_key="material_3", data_source=DataSource.EXECUTOR),
|
|
||||||
ActionOutputHandle(key="channel_4", data_type="workbench_material",
|
|
||||||
label="实验4", data_key="material_4", data_source=DataSource.EXECUTOR),
|
|
||||||
ActionOutputHandle(key="channel_5", data_type="workbench_material",
|
|
||||||
label="实验5", data_key="material_5", data_source=DataSource.EXECUTOR),
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def prepare_materials(
|
def prepare_materials(
|
||||||
@@ -316,6 +617,9 @@ class VirtualWorkbench:
|
|||||||
|
|
||||||
作为工作流的起始节点, 生成指定数量的物料编号供后续节点使用。
|
作为工作流的起始节点, 生成指定数量的物料编号供后续节点使用。
|
||||||
输出5个handle (material_1 ~ material_5), 分别对应实验1~5。
|
输出5个handle (material_1 ~ material_5), 分别对应实验1~5。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
count[物料数量]: 要生成的物料数量,默认生成 5 个。
|
||||||
"""
|
"""
|
||||||
materials = [i for i in range(1, count + 1)]
|
materials = [i for i in range(1, count + 1)]
|
||||||
|
|
||||||
@@ -336,7 +640,11 @@ class VirtualWorkbench:
|
|||||||
LabSample(
|
LabSample(
|
||||||
sample_uuid=sample_uuid,
|
sample_uuid=sample_uuid,
|
||||||
oss_path="",
|
oss_path="",
|
||||||
extra={"material_uuid": content} if isinstance(content, str) else (content.serialize() if content else {}),
|
extra=(
|
||||||
|
{"material_uuid": content}
|
||||||
|
if isinstance(content, str)
|
||||||
|
else (content.serialize() if content else {})
|
||||||
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
],
|
],
|
||||||
@@ -346,12 +654,27 @@ class VirtualWorkbench:
|
|||||||
auto_prefix=True,
|
auto_prefix=True,
|
||||||
description="将物料从An位置移动到空闲加热台, 返回分配的加热台ID",
|
description="将物料从An位置移动到空闲加热台, 返回分配的加热台ID",
|
||||||
handles=[
|
handles=[
|
||||||
ActionInputHandle(key="material_input", data_type="workbench_material",
|
ActionInputHandle(
|
||||||
label="物料编号", data_key="material_number", data_source=DataSource.HANDLE),
|
key="material_input",
|
||||||
ActionOutputHandle(key="heating_station_output", data_type="workbench_station",
|
data_type="workbench_material",
|
||||||
label="加热台ID", data_key="station_id", data_source=DataSource.EXECUTOR),
|
label="物料编号",
|
||||||
ActionOutputHandle(key="material_number_output", data_type="workbench_material",
|
data_key="material_number",
|
||||||
label="物料编号", data_key="material_number", data_source=DataSource.EXECUTOR),
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionOutputHandle(
|
||||||
|
key="heating_station_output",
|
||||||
|
data_type="workbench_station",
|
||||||
|
label="加热台ID",
|
||||||
|
data_key="station_id",
|
||||||
|
data_source=DataSource.EXECUTOR,
|
||||||
|
),
|
||||||
|
ActionOutputHandle(
|
||||||
|
key="material_number_output",
|
||||||
|
data_type="workbench_material",
|
||||||
|
label="物料编号",
|
||||||
|
data_key="material_number",
|
||||||
|
data_source=DataSource.EXECUTOR,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def move_to_heating_station(
|
def move_to_heating_station(
|
||||||
@@ -363,6 +686,9 @@ class VirtualWorkbench:
|
|||||||
将物料从An位置移动到加热台
|
将物料从An位置移动到加热台
|
||||||
|
|
||||||
多线程并发调用时, 会竞争机械臂使用权, 并自动查找空闲加热台
|
多线程并发调用时, 会竞争机械臂使用权, 并自动查找空闲加热台
|
||||||
|
|
||||||
|
Args:
|
||||||
|
material_number[物料编号]: 要移动的物料编号,对应 A1、A2 等起始位置。
|
||||||
"""
|
"""
|
||||||
material_id = f"A{material_number}"
|
material_id = f"A{material_number}"
|
||||||
task_desc = f"移动{material_id}到加热台"
|
task_desc = f"移动{material_id}到加热台"
|
||||||
@@ -425,7 +751,8 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
if isinstance(content, str)
|
||||||
|
else (content.serialize() if content else {})
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
@@ -448,7 +775,8 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
if isinstance(content, str)
|
||||||
|
else (content.serialize() if content else {})
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
@@ -460,14 +788,34 @@ class VirtualWorkbench:
|
|||||||
always_free=True,
|
always_free=True,
|
||||||
description="启动指定加热台的加热程序",
|
description="启动指定加热台的加热程序",
|
||||||
handles=[
|
handles=[
|
||||||
ActionInputHandle(key="station_id_input", data_type="workbench_station",
|
ActionInputHandle(
|
||||||
label="加热台ID", data_key="station_id", data_source=DataSource.HANDLE),
|
key="station_id_input",
|
||||||
ActionInputHandle(key="material_number_input", data_type="workbench_material",
|
data_type="workbench_station",
|
||||||
label="物料编号", data_key="material_number", data_source=DataSource.HANDLE),
|
label="加热台ID",
|
||||||
ActionOutputHandle(key="heating_done_station", data_type="workbench_station",
|
data_key="station_id",
|
||||||
label="加热完成-加热台ID", data_key="station_id", data_source=DataSource.EXECUTOR),
|
data_source=DataSource.HANDLE,
|
||||||
ActionOutputHandle(key="heating_done_material", data_type="workbench_material",
|
),
|
||||||
label="加热完成-物料编号", data_key="material_number", data_source=DataSource.EXECUTOR),
|
ActionInputHandle(
|
||||||
|
key="material_number_input",
|
||||||
|
data_type="workbench_material",
|
||||||
|
label="物料编号",
|
||||||
|
data_key="material_number",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionOutputHandle(
|
||||||
|
key="heating_done_station",
|
||||||
|
data_type="workbench_station",
|
||||||
|
label="加热完成-加热台ID",
|
||||||
|
data_key="station_id",
|
||||||
|
data_source=DataSource.EXECUTOR,
|
||||||
|
),
|
||||||
|
ActionOutputHandle(
|
||||||
|
key="heating_done_material",
|
||||||
|
data_type="workbench_material",
|
||||||
|
label="加热完成-物料编号",
|
||||||
|
data_key="material_number",
|
||||||
|
data_source=DataSource.EXECUTOR,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def start_heating(
|
def start_heating(
|
||||||
@@ -478,6 +826,10 @@ class VirtualWorkbench:
|
|||||||
) -> StartHeatingResult:
|
) -> StartHeatingResult:
|
||||||
"""
|
"""
|
||||||
启动指定加热台的加热程序
|
启动指定加热台的加热程序
|
||||||
|
|
||||||
|
Args:
|
||||||
|
station_id[加热台ID]: 要启动加热的加热台编号。
|
||||||
|
material_number[物料编号]: 当前加热台上的物料编号。
|
||||||
"""
|
"""
|
||||||
self.logger.info(f"[加热台{station_id}] 开始加热")
|
self.logger.info(f"[加热台{station_id}] 开始加热")
|
||||||
|
|
||||||
@@ -494,7 +846,8 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
if isinstance(content, str)
|
||||||
|
else (content.serialize() if content else {})
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
@@ -517,7 +870,8 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
if isinstance(content, str)
|
||||||
|
else (content.serialize() if content else {})
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
@@ -537,7 +891,8 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
if isinstance(content, str)
|
||||||
|
else (content.serialize() if content else {})
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
@@ -577,7 +932,9 @@ class VirtualWorkbench:
|
|||||||
self._update_data_status(f"加热台{station_id}加热中: {progress:.1f}%")
|
self._update_data_status(f"加热台{station_id}加热中: {progress:.1f}%")
|
||||||
|
|
||||||
if time.time() - last_countdown_log >= 5.0:
|
if time.time() - last_countdown_log >= 5.0:
|
||||||
self.logger.info(f"[加热台{station_id}] {material_id} 剩余 {remaining:.1f}s")
|
self.logger.info(
|
||||||
|
f"[加热台{station_id}] {material_id} 剩余 {remaining:.1f}s"
|
||||||
|
)
|
||||||
last_countdown_log = time.time()
|
last_countdown_log = time.time()
|
||||||
|
|
||||||
if elapsed >= self.HEATING_TIME:
|
if elapsed >= self.HEATING_TIME:
|
||||||
@@ -594,7 +951,9 @@ class VirtualWorkbench:
|
|||||||
self._active_tasks[material_id]["status"] = "heating_completed"
|
self._active_tasks[material_id]["status"] = "heating_completed"
|
||||||
|
|
||||||
self._update_data_status(f"加热台{station_id}加热完成")
|
self._update_data_status(f"加热台{station_id}加热完成")
|
||||||
self.logger.info(f"[加热台{station_id}] {material_id}加热完成 (用时{self.HEATING_TIME}s)")
|
self.logger.info(
|
||||||
|
f"[加热台{station_id}] {material_id}加热完成 (用时{self.HEATING_TIME}s)"
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -608,7 +967,8 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
if isinstance(content, str)
|
||||||
|
else (content.serialize() if content else {})
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
@@ -619,10 +979,20 @@ class VirtualWorkbench:
|
|||||||
auto_prefix=True,
|
auto_prefix=True,
|
||||||
description="将物料从加热台移动到输出位置Cn",
|
description="将物料从加热台移动到输出位置Cn",
|
||||||
handles=[
|
handles=[
|
||||||
ActionInputHandle(key="output_station_input", data_type="workbench_station",
|
ActionInputHandle(
|
||||||
label="加热台ID", data_key="station_id", data_source=DataSource.HANDLE),
|
key="output_station_input",
|
||||||
ActionInputHandle(key="output_material_input", data_type="workbench_material",
|
data_type="workbench_station",
|
||||||
label="物料编号", data_key="material_number", data_source=DataSource.HANDLE),
|
label="加热台ID",
|
||||||
|
data_key="station_id",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
|
ActionInputHandle(
|
||||||
|
key="output_material_input",
|
||||||
|
data_type="workbench_material",
|
||||||
|
label="物料编号",
|
||||||
|
data_key="material_number",
|
||||||
|
data_source=DataSource.HANDLE,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def move_to_output(
|
def move_to_output(
|
||||||
@@ -633,6 +1003,10 @@ class VirtualWorkbench:
|
|||||||
) -> MoveToOutputResult:
|
) -> MoveToOutputResult:
|
||||||
"""
|
"""
|
||||||
将物料从加热台移动到输出位置Cn
|
将物料从加热台移动到输出位置Cn
|
||||||
|
|
||||||
|
Args:
|
||||||
|
station_id[加热台ID]: 已完成加热的加热台编号。
|
||||||
|
material_number[物料编号]: 要移动到输出位置的物料编号,对应 Cn。
|
||||||
"""
|
"""
|
||||||
output_number = material_number
|
output_number = material_number
|
||||||
|
|
||||||
@@ -649,7 +1023,8 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
if isinstance(content, str)
|
||||||
|
else (content.serialize() if content else {})
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
@@ -673,7 +1048,8 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
if isinstance(content, str)
|
||||||
|
else (content.serialize() if content else {})
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
@@ -693,7 +1069,8 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
if isinstance(content, str)
|
||||||
|
else (content.serialize() if content else {})
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
@@ -775,7 +1152,8 @@ class VirtualWorkbench:
|
|||||||
oss_path="",
|
oss_path="",
|
||||||
extra=(
|
extra=(
|
||||||
{"material_uuid": content}
|
{"material_uuid": content}
|
||||||
if isinstance(content, str) else (content.serialize() if content else {})
|
if isinstance(content, str)
|
||||||
|
else (content.serialize() if content else {})
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
for sample_uuid, content in sample_uuids.items()
|
for sample_uuid, content in sample_uuids.items()
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# 工作站抽象基类物料系统架构说明
|
# 工作站抽象基类物料系统架构说明
|
||||||
|
|
||||||
|
## 设计理念
|
||||||
|
|
||||||
|
基于用户需求"请你帮我系统思考一下,工作站抽象基类的物料系统基类该如何构建",我们最终确定了一个**PyLabRobot Deck为中心**的简化架构。
|
||||||
|
|
||||||
### 核心原则
|
### 核心原则
|
||||||
|
|
||||||
1. **PyLabRobot为物料管理核心**:使用PyLabRobot的Deck系统作为物料管理的基础,利用其成熟的Resource体系
|
1. **PyLabRobot为物料管理核心**:使用PyLabRobot的Deck系统作为物料管理的基础,利用其成熟的Resource体系
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
# 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 格式便于后续处理和分析
|
|
||||||
Binary file not shown.
@@ -1,204 +0,0 @@
|
|||||||
# 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` 访问。
|
|
||||||
@@ -1,312 +0,0 @@
|
|||||||
# 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
Binary file not shown.
Binary file not shown.
@@ -1,157 +0,0 @@
|
|||||||
# 批量出库 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,14 +49,6 @@ 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()
|
||||||
@@ -184,40 +176,7 @@ 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"""
|
||||||
@@ -244,7 +203,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": 0,
|
"data": {},
|
||||||
})
|
})
|
||||||
if not response or response['code'] != 1:
|
if not response or response['code'] != 1:
|
||||||
return []
|
return []
|
||||||
@@ -314,19 +273,11 @@ 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 = self.location_mapping.get(location_name, location_name)
|
location_id = LOCATION_MAPPING.get(location_name, location_name)
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
"materialId": material_id,
|
"materialId": material_id,
|
||||||
@@ -1152,10 +1103,6 @@ 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}")
|
||||||
@@ -1176,14 +1123,6 @@ 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
|
||||||
|
|
||||||
|
|||||||
142
unilabos/devices/workstation/bioyond_studio/config.py
Normal file
142
unilabos/devices/workstation/bioyond_studio/config.py
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# 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 = {}
|
||||||
@@ -1,329 +0,0 @@
|
|||||||
# 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,8 +4,7 @@ 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
|
||||||
import pint
|
from unilabos.devices.workstation.bioyond_studio.config import API_CONFIG
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
@@ -26,89 +25,13 @@ class ComputeExperimentDesignReturn(TypedDict):
|
|||||||
class BioyondDispensingStation(BioyondWorkstation):
|
class BioyondDispensingStation(BioyondWorkstation):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
config: dict = None,
|
config,
|
||||||
deck=None,
|
# 桌子
|
||||||
protocol_type=None,
|
deck,
|
||||||
|
*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"]
|
||||||
@@ -120,41 +43,6 @@ 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调用
|
||||||
|
|
||||||
@@ -166,7 +54,7 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
dict: 服务端响应,失败时返回 {code:0,message,...}
|
dict: 服务端响应,失败时返回 {code:0,message,...}
|
||||||
"""
|
"""
|
||||||
request_data = {
|
request_data = {
|
||||||
"apiKey": self.bioyond_config["api_key"],
|
"apiKey": API_CONFIG["api_key"],
|
||||||
"requestTime": self.hardware_interface.get_current_time_iso8601(),
|
"requestTime": self.hardware_interface.get_current_time_iso8601(),
|
||||||
"data": data
|
"data": data
|
||||||
}
|
}
|
||||||
@@ -197,7 +85,7 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
dict: 服务端响应,失败时返回 {code:0,message,...}
|
dict: 服务端响应,失败时返回 {code:0,message,...}
|
||||||
"""
|
"""
|
||||||
request_data = {
|
request_data = {
|
||||||
"apiKey": self.bioyond_config["api_key"],
|
"apiKey": API_CONFIG["api_key"],
|
||||||
"requestTime": self.hardware_interface.get_current_time_iso8601(),
|
"requestTime": self.hardware_interface.get_current_time_iso8601(),
|
||||||
"data": data
|
"data": data
|
||||||
}
|
}
|
||||||
@@ -230,22 +118,20 @@ 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", {}),
|
||||||
@@ -254,248 +140,11 @@ 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,
|
||||||
@@ -1312,108 +961,6 @@ 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,
|
||||||
@@ -1455,12 +1002,7 @@ 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(
|
raise BioyondException("batch_create_result参数为空,请确保从batch_create节点正确连接handle")
|
||||||
"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:
|
||||||
@@ -1489,17 +1031,7 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
|
|
||||||
# 验证提取的数据
|
# 验证提取的数据
|
||||||
if not order_codes:
|
if not order_codes:
|
||||||
self.hardware_interface._logger.error(
|
raise BioyondException("batch_create_result中未找到order_codes字段或为空")
|
||||||
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字段或为空")
|
||||||
|
|
||||||
@@ -1582,8 +1114,6 @@ 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,
|
||||||
@@ -1758,7 +1288,7 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
f"开始执行批量物料转移: {len(transfer_groups)}组任务 -> {target_device_id}"
|
f"开始执行批量物料转移: {len(transfer_groups)}组任务 -> {target_device_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
warehouse_mapping = self.bioyond_config.get("warehouse_mapping", {})
|
from .config import 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,7 +6,6 @@ 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
|
||||||
@@ -24,94 +23,12 @@ 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资源同步器
|
||||||
|
|
||||||
@@ -257,8 +174,9 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
|||||||
else:
|
else:
|
||||||
logger.info(f"[同步→Bioyond] ➕ 物料不存在于 Bioyond,将创建新物料并入库")
|
logger.info(f"[同步→Bioyond] ➕ 物料不存在于 Bioyond,将创建新物料并入库")
|
||||||
|
|
||||||
# 第1步:从配置中获取仓库配置
|
# 第1步:获取仓库配置
|
||||||
warehouse_mapping = self.bioyond_config.get("warehouse_mapping", {})
|
from .config import WAREHOUSE_MAPPING
|
||||||
|
warehouse_mapping = WAREHOUSE_MAPPING
|
||||||
|
|
||||||
# 确定目标仓库名称
|
# 确定目标仓库名称
|
||||||
parent_name = None
|
parent_name = None
|
||||||
@@ -320,20 +238,14 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
|||||||
# 第2步:转换为 Bioyond 格式
|
# 第2步:转换为 Bioyond 格式
|
||||||
logger.info(f"[同步→Bioyond] 🔄 转换物料为 Bioyond 格式...")
|
logger.info(f"[同步→Bioyond] 🔄 转换物料为 Bioyond 格式...")
|
||||||
|
|
||||||
# 从配置中获取物料默认参数
|
# 导入物料默认参数配置
|
||||||
material_default_params = self.workstation.bioyond_config.get("material_default_parameters", {})
|
from .config import 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=merged_params
|
material_params=MATERIAL_DEFAULT_PARAMETERS
|
||||||
)[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]}...")
|
||||||
@@ -556,20 +468,13 @@ 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=merged_params
|
material_params=MATERIAL_DEFAULT_PARAMETERS
|
||||||
)[0]
|
)[0]
|
||||||
|
|
||||||
# ⚠️ 关键:创建物料时不设置 locations,让 Bioyond 系统暂不分配库位
|
# ⚠️ 关键:创建物料时不设置 locations,让 Bioyond 系统暂不分配库位
|
||||||
@@ -623,7 +528,8 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
|||||||
logger.info(f"[物料入库] 目标库位: {update_site}")
|
logger.info(f"[物料入库] 目标库位: {update_site}")
|
||||||
|
|
||||||
# 获取仓库配置和目标库位 UUID
|
# 获取仓库配置和目标库位 UUID
|
||||||
warehouse_mapping = self.workstation.bioyond_config.get("warehouse_mapping", {})
|
from .config import WAREHOUSE_MAPPING
|
||||||
|
warehouse_mapping = WAREHOUSE_MAPPING
|
||||||
|
|
||||||
parent_name = None
|
parent_name = None
|
||||||
target_location_uuid = None
|
target_location_uuid = None
|
||||||
@@ -678,44 +584,6 @@ 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,
|
||||||
@@ -737,28 +605,10 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
raise ValueError("Deck 配置不能为空,请在配置文件中添加正确的 deck 配置")
|
raise ValueError("Deck 配置不能为空,请在配置文件中添加正确的 deck 配置")
|
||||||
|
|
||||||
# 初始化 warehouses 属性
|
# 初始化 warehouses 属性
|
||||||
if not hasattr(self.deck, "warehouses") or self.deck.warehouses is None:
|
|
||||||
self.deck.warehouses = {}
|
self.deck.warehouses = {}
|
||||||
|
|
||||||
# 仅当 warehouses 为空时尝试重新扫描(避免覆盖子类的修复)
|
|
||||||
if not self.deck.warehouses:
|
|
||||||
for resource in self.deck.children:
|
for resource in self.deck.children:
|
||||||
# 兼容性增强: 只要是仓库类别或者是 WareHouse 实例均可
|
if isinstance(resource, 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
|
self.deck.warehouses[resource.name] = resource
|
||||||
# 确保 category 被正确设置,方便后续使用
|
|
||||||
if getattr(resource, "category", "") != "warehouse":
|
|
||||||
try:
|
|
||||||
resource.category = "warehouse"
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 创建通信模块
|
# 创建通信模块
|
||||||
self._create_communication_module(bioyond_config)
|
self._create_communication_module(bioyond_config)
|
||||||
@@ -777,22 +627,18 @@ 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": http_service_cfg.get("http_service_host", "127.0.0.1"),
|
"host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG["http_service_host"]),
|
||||||
"port": http_service_cfg.get("http_service_port", 8080)
|
"port": bioyond_config.get("http_service_port", HTTP_SERVICE_CONFIG["http_service_port"])
|
||||||
}
|
}
|
||||||
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()
|
||||||
@@ -802,19 +648,8 @@ 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
|
||||||
|
|
||||||
# 启动连接监控
|
|
||||||
try:
|
|
||||||
self.connection_monitor = ConnectionMonitor(self)
|
|
||||||
self.connection_monitor.start()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"启动连接监控失败: {e}")
|
|
||||||
|
|
||||||
# 启动 HTTP 报送接收服务(现在 device_id 已可用)
|
# 启动 HTTP 报送接收服务(现在 device_id 已可用)
|
||||||
# ⚠️ 检查子类是否已经自己管理 HTTP 服务
|
if hasattr(self, '_http_service_config'):
|
||||||
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,
|
||||||
@@ -853,14 +688,19 @@ 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通信模块"""
|
||||||
# 直接使用传入的配置,不再使用默认值
|
# 创建默认配置
|
||||||
# 所有配置必须从 JSON 文件中提供
|
default_config = {
|
||||||
|
**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 = config
|
self.bioyond_config = {**default_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)
|
||||||
|
|
||||||
@@ -1174,15 +1014,7 @@ 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:
|
||||||
# 兼容 BioyondReactionStation 中 workflow_sequence 被重写为 property 的情况
|
|
||||||
if isinstance(self.workflow_sequence, list):
|
|
||||||
self.workflow_sequence.append(workflow_id)
|
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
|
||||||
@@ -1383,22 +1215,6 @@ 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'),
|
||||||
@@ -1433,17 +1249,6 @@ 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'),
|
||||||
@@ -1483,32 +1288,6 @@ 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'),
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
# 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 地址。
|
|
||||||
@@ -1,352 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,645 +0,0 @@
|
|||||||
"""
|
|
||||||
纽扣电池组装工作站物料类定义
|
|
||||||
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,
|
|
||||||
fill: bool = False
|
|
||||||
):
|
|
||||||
"""初始化料板
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: 料板名称
|
|
||||||
size_x: 长度 (mm)
|
|
||||||
size_y: 宽度 (mm)
|
|
||||||
size_z: 高度 (mm)
|
|
||||||
hole_diameter: 洞直径 (mm)
|
|
||||||
hole_depth: 洞深度 (mm)
|
|
||||||
hole_spacing_x: X方向洞位间距 (mm)
|
|
||||||
hole_spacing_y: Y方向洞位间距 (mm)
|
|
||||||
number: 编号
|
|
||||||
category: 类别
|
|
||||||
model: 型号
|
|
||||||
"""
|
|
||||||
self._unilabos_state: MaterialPlateState = MaterialPlateState(
|
|
||||||
hole_spacing_x=24.0,
|
|
||||||
hole_spacing_y=24.0,
|
|
||||||
hole_diameter=20.0,
|
|
||||||
info="",
|
|
||||||
)
|
|
||||||
# 创建4x4的洞位
|
|
||||||
# TODO: 这里要改,对应不同形状
|
|
||||||
holes = create_ordered_items_2d(
|
|
||||||
klass=MaterialHole,
|
|
||||||
num_items_x=4,
|
|
||||||
num_items_y=4,
|
|
||||||
dx=(size_x - 4 * self._unilabos_state["hole_spacing_x"]) / 2, # 居中
|
|
||||||
dy=(size_y - 4 * self._unilabos_state["hole_spacing_y"]) / 2, # 居中
|
|
||||||
dz=size_z,
|
|
||||||
item_dx=self._unilabos_state["hole_spacing_x"],
|
|
||||||
item_dy=self._unilabos_state["hole_spacing_y"],
|
|
||||||
size_x = 16,
|
|
||||||
size_y = 16,
|
|
||||||
size_z = 16,
|
|
||||||
)
|
|
||||||
if fill:
|
|
||||||
super().__init__(
|
|
||||||
name=name,
|
|
||||||
size_x=size_x,
|
|
||||||
size_y=size_y,
|
|
||||||
size_z=size_z,
|
|
||||||
ordered_items=holes,
|
|
||||||
category=category,
|
|
||||||
model=model,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
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 CoincellDeck(Deck):
|
|
||||||
"""纽扣电池组装工作站台面类"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
name: str = "coin_cell_deck",
|
|
||||||
size_x: float = 1450.0, # 1m
|
|
||||||
size_y: float = 1450.0, # 1m
|
|
||||||
size_z: float = 100.0, # 0.9m
|
|
||||||
origin: Coordinate = Coordinate(-2200, 0, 0),
|
|
||||||
category: str = "coin_cell_deck",
|
|
||||||
setup: bool = False, # 是否自动执行 setup
|
|
||||||
):
|
|
||||||
"""初始化纽扣电池组装工作站台面
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: 台面名称
|
|
||||||
size_x: 长度 (mm) - 1m
|
|
||||||
size_y: 宽度 (mm) - 1m
|
|
||||||
size_z: 高度 (mm) - 0.9m
|
|
||||||
origin: 原点坐标
|
|
||||||
category: 类别
|
|
||||||
setup: 是否自动执行 setup 配置标准布局
|
|
||||||
"""
|
|
||||||
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(name="负极料盘", size_x=120, size_y=100, size_z=10.0, fill=True)
|
|
||||||
self.assign_child_resource(fujiliaopan, Coordinate(x=708.0, y=794.0, z=0))
|
|
||||||
# for i in range(16):
|
|
||||||
# fujipian = ElectrodeSheet(name=f"{fujiliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
|
|
||||||
# fujiliaopan.children[i].assign_child_resource(fujipian, location=None)
|
|
||||||
|
|
||||||
# 隔膜料盘
|
|
||||||
gemoliaopan = MaterialPlate(name="隔膜料盘", size_x=120, size_y=100, size_z=10.0, fill=True)
|
|
||||||
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))
|
|
||||||
|
|
||||||
|
|
||||||
def YH_Deck(name=""):
|
|
||||||
cd = CoincellDeck(name=name)
|
|
||||||
cd.setup()
|
|
||||||
return cd
|
|
||||||
|
|
||||||
|
|
||||||
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
@@ -1,133 +0,0 @@
|
|||||||
Name,DataType,InitValue,Comment,Attribute,DeviceType,Address,
|
|
||||||
COIL_SYS_START_CMD,BOOL,,,,coil,8010,
|
|
||||||
COIL_SYS_STOP_CMD,BOOL,,,,coil,8020,
|
|
||||||
COIL_SYS_RESET_CMD,BOOL,,,,coil,8030,
|
|
||||||
COIL_SYS_HAND_CMD,BOOL,,,,coil,8040,
|
|
||||||
COIL_SYS_AUTO_CMD,BOOL,,,,coil,8050,
|
|
||||||
COIL_SYS_INIT_CMD,BOOL,,,,coil,8060,
|
|
||||||
COIL_UNILAB_SEND_MSG_SUCC_CMD,BOOL,,,,coil,8700,
|
|
||||||
COIL_UNILAB_REC_MSG_SUCC_CMD,BOOL,,,,coil,8710,unilab_rec_msg_succ_cmd
|
|
||||||
COIL_SYS_START_STATUS,BOOL,,,,coil,8210,
|
|
||||||
COIL_SYS_STOP_STATUS,BOOL,,,,coil,8220,
|
|
||||||
COIL_SYS_RESET_STATUS,BOOL,,,,coil,8230,
|
|
||||||
COIL_SYS_HAND_STATUS,BOOL,,,,coil,8240,
|
|
||||||
COIL_SYS_AUTO_STATUS,BOOL,,,,coil,8250,
|
|
||||||
COIL_SYS_INIT_STATUS,BOOL,,,,coil,8260,
|
|
||||||
COIL_REQUEST_REC_MSG_STATUS,BOOL,,,,coil,8500,
|
|
||||||
COIL_REQUEST_SEND_MSG_STATUS,BOOL,,,,coil,8510,request_send_msg_status
|
|
||||||
REG_MSG_ELECTROLYTE_USE_NUM,INT16,,,,hold_register,11000,
|
|
||||||
REG_MSG_ELECTROLYTE_NUM,INT16,,,,hold_register,11002,unilab_send_msg_electrolyte_num
|
|
||||||
REG_MSG_ELECTROLYTE_VOLUME,INT16,,,,hold_register,11004,unilab_send_msg_electrolyte_vol
|
|
||||||
REG_MSG_ASSEMBLY_TYPE,INT16,,,,hold_register,11006,unilab_send_msg_assembly_type
|
|
||||||
REG_MSG_ASSEMBLY_PRESSURE,INT16,,,,hold_register,11008,unilab_send_msg_assembly_pressure
|
|
||||||
REG_DATA_ASSEMBLY_COIN_CELL_NUM,INT16,,,,hold_register,10000,data_assembly_coin_cell_num
|
|
||||||
REG_DATA_OPEN_CIRCUIT_VOLTAGE,FLOAT32,,,,hold_register,10002,data_open_circuit_voltage
|
|
||||||
REG_DATA_AXIS_X_POS,FLOAT32,,,,hold_register,10004,
|
|
||||||
REG_DATA_AXIS_Y_POS,FLOAT32,,,,hold_register,10006,
|
|
||||||
REG_DATA_AXIS_Z_POS,FLOAT32,,,,hold_register,10008,
|
|
||||||
REG_DATA_POLE_WEIGHT,FLOAT32,,,,hold_register,10010,data_pole_weight
|
|
||||||
REG_DATA_ASSEMBLY_PER_TIME,FLOAT32,,,,hold_register,10012,data_assembly_time
|
|
||||||
REG_DATA_ASSEMBLY_PRESSURE,INT16,,,,hold_register,10014,data_assembly_pressure
|
|
||||||
REG_DATA_ELECTROLYTE_VOLUME,INT16,,,,hold_register,10016,data_electrolyte_volume
|
|
||||||
REG_DATA_COIN_NUM,INT16,,,,hold_register,10018,data_coin_num
|
|
||||||
REG_DATA_ELECTROLYTE_CODE,STRING,,,,hold_register,10020,data_electrolyte_code()
|
|
||||||
REG_DATA_COIN_CELL_CODE,STRING,,,,hold_register,10030,data_coin_cell_code()
|
|
||||||
REG_DATA_STACK_VISON_CODE,STRING,,,,hold_register,12004,data_stack_vision_code()
|
|
||||||
REG_DATA_GLOVE_BOX_PRESSURE,FLOAT32,,,,hold_register,10050,data_glove_box_pressure
|
|
||||||
REG_DATA_GLOVE_BOX_WATER_CONTENT,FLOAT32,,,,hold_register,10052,data_glove_box_water_content
|
|
||||||
REG_DATA_GLOVE_BOX_O2_CONTENT,FLOAT32,,,,hold_register,10054,data_glove_box_o2_content
|
|
||||||
UNILAB_SEND_ELECTROLYTE_BOTTLE_NUM,BOOL,,,,coil,8720,
|
|
||||||
UNILAB_RECE_ELECTROLYTE_BOTTLE_NUM,BOOL,,,,coil,8520,
|
|
||||||
REG_MSG_ELECTROLYTE_NUM_USED,INT16,,,,hold_register,496,
|
|
||||||
REG_DATA_ELECTROLYTE_USE_NUM,INT16,,,,hold_register,10000,
|
|
||||||
UNILAB_SEND_FINISHED_CMD,BOOL,,,,coil,8730,
|
|
||||||
UNILAB_RECE_FINISHED_CMD,BOOL,,,,coil,8530,
|
|
||||||
REG_DATA_ASSEMBLY_TYPE,INT16,,,,hold_register,10018,ASSEMBLY_TYPE7or8
|
|
||||||
REG_UNILAB_INTERACT,BOOL,,,,coil,8450,
|
|
||||||
,,,,,coil,8320,
|
|
||||||
COIL_ALUMINUM_FOIL,BOOL,,,,coil,8340,
|
|
||||||
REG_MSG_NE_PLATE_MATRIX,INT16,,,,hold_register,440,
|
|
||||||
REG_MSG_SEPARATOR_PLATE_MATRIX,INT16,,,,hold_register,450,
|
|
||||||
REG_MSG_TIP_BOX_MATRIX,INT16,,,,hold_register,480,
|
|
||||||
REG_MSG_NE_PLATE_NUM,INT16,,,,hold_register,443,
|
|
||||||
REG_MSG_SEPARATOR_PLATE_NUM,INT16,,,,hold_register,453,
|
|
||||||
REG_MSG_PRESS_MODE,BOOL,,,,coil,8360,
|
|
||||||
,BOOL,,,,coil,8300,
|
|
||||||
,BOOL,,,,coil,8310,
|
|
||||||
COIL_GB_L_IGNORE_CMD,BOOL,,,,coil,8320,
|
|
||||||
COIL_GB_R_IGNORE_CMD,BOOL,,,,coil,8420,
|
|
||||||
,BOOL,,,,coil,8350,
|
|
||||||
COIL_ELECTROLYTE_DUAL_DROP_MODE,BOOL,,,,coil,8370,
|
|
||||||
,BOOL,,,,coil,8380,
|
|
||||||
,BOOL,,,,coil,8390,
|
|
||||||
,BOOL,,,,coil,8400,
|
|
||||||
,BOOL,,,,coil,8410,
|
|
||||||
REG_MSG_DUAL_DROP_FIRST_VOLUME,INT16,,,,hold_register,4001,
|
|
||||||
COIL_DUAL_DROP_SUCTION_TIMING,BOOL,,,,coil,8430,
|
|
||||||
COIL_DUAL_DROP_START_TIMING,BOOL,,,,coil,8470,
|
|
||||||
REG_MSG_BATTERY_CLEAN_IGNORE,BOOL,,,,coil,8460,
|
|
||||||
COIL_MATERIAL_SEARCH_DIALOG_APPEAR,BOOL,,,,coil,6470,
|
|
||||||
COIL_MATERIAL_SEARCH_CONFIRM_YES,BOOL,,,,coil,6480,
|
|
||||||
COIL_MATERIAL_SEARCH_CONFIRM_NO,BOOL,,,,coil,6490,
|
|
||||||
COIL_ALARM_100_SYSTEM_ERROR,BOOL,,,,coil,1000,异常100-系统异常
|
|
||||||
COIL_ALARM_101_EMERGENCY_STOP,BOOL,,,,coil,1010,异常101-急停
|
|
||||||
COIL_ALARM_111_GLOVEBOX_EMERGENCY_STOP,BOOL,,,,coil,1110,异常111-手套箱急停
|
|
||||||
COIL_ALARM_112_GLOVEBOX_GRATING_BLOCKED,BOOL,,,,coil,1120,异常112-手套箱内光栅遮挡
|
|
||||||
COIL_ALARM_160_PIPETTE_TIP_SHORTAGE,BOOL,,,,coil,1600,异常160-移液枪头缺料
|
|
||||||
COIL_ALARM_161_POSITIVE_SHELL_SHORTAGE,BOOL,,,,coil,1610,异常161-正极壳缺料
|
|
||||||
COIL_ALARM_162_ALUMINUM_FOIL_SHORTAGE,BOOL,,,,coil,1620,异常162-铝箔垫缺料
|
|
||||||
COIL_ALARM_163_POSITIVE_PLATE_SHORTAGE,BOOL,,,,coil,1630,异常163-正极片缺料
|
|
||||||
COIL_ALARM_164_SEPARATOR_SHORTAGE,BOOL,,,,coil,1640,异常164-隔膜缺料
|
|
||||||
COIL_ALARM_165_NEGATIVE_PLATE_SHORTAGE,BOOL,,,,coil,1650,异常165-负极片缺料
|
|
||||||
COIL_ALARM_166_FLAT_WASHER_SHORTAGE,BOOL,,,,coil,1660,异常166-平垫缺料
|
|
||||||
COIL_ALARM_167_SPRING_WASHER_SHORTAGE,BOOL,,,,coil,1670,异常167-弹垫缺料
|
|
||||||
COIL_ALARM_168_NEGATIVE_SHELL_SHORTAGE,BOOL,,,,coil,1680,异常168-负极壳缺料
|
|
||||||
COIL_ALARM_169_FINISHED_BATTERY_FULL,BOOL,,,,coil,1690,异常169-成品电池满料
|
|
||||||
COIL_ALARM_201_SERVO_AXIS_01_ERROR,BOOL,,,,coil,2010,异常201-伺服轴01异常
|
|
||||||
COIL_ALARM_202_SERVO_AXIS_02_ERROR,BOOL,,,,coil,2020,异常202-伺服轴02异常
|
|
||||||
COIL_ALARM_203_SERVO_AXIS_03_ERROR,BOOL,,,,coil,2030,异常203-伺服轴03异常
|
|
||||||
COIL_ALARM_204_SERVO_AXIS_04_ERROR,BOOL,,,,coil,2040,异常204-伺服轴04异常
|
|
||||||
COIL_ALARM_205_SERVO_AXIS_05_ERROR,BOOL,,,,coil,2050,异常205-伺服轴05异常
|
|
||||||
COIL_ALARM_206_SERVO_AXIS_06_ERROR,BOOL,,,,coil,2060,异常206-伺服轴06异常
|
|
||||||
COIL_ALARM_207_SERVO_AXIS_07_ERROR,BOOL,,,,coil,2070,异常207-伺服轴07异常
|
|
||||||
COIL_ALARM_208_SERVO_AXIS_08_ERROR,BOOL,,,,coil,2080,异常208-伺服轴08异常
|
|
||||||
COIL_ALARM_209_SERVO_AXIS_09_ERROR,BOOL,,,,coil,2090,异常209-伺服轴09异常
|
|
||||||
COIL_ALARM_210_SERVO_AXIS_10_ERROR,BOOL,,,,coil,2100,异常210-伺服轴10异常
|
|
||||||
COIL_ALARM_211_SERVO_AXIS_11_ERROR,BOOL,,,,coil,2110,异常211-伺服轴11异常
|
|
||||||
COIL_ALARM_212_SERVO_AXIS_12_ERROR,BOOL,,,,coil,2120,异常212-伺服轴12异常
|
|
||||||
COIL_ALARM_213_SERVO_AXIS_13_ERROR,BOOL,,,,coil,2130,异常213-伺服轴13异常
|
|
||||||
COIL_ALARM_214_SERVO_AXIS_14_ERROR,BOOL,,,,coil,2140,异常214-伺服轴14异常
|
|
||||||
COIL_ALARM_250_OTHER_COMPONENT_ERROR,BOOL,,,,coil,2500,异常250-其他元件异常
|
|
||||||
COIL_ALARM_251_PIPETTE_COMM_ERROR,BOOL,,,,coil,2510,异常251-移液枪通讯异常
|
|
||||||
COIL_ALARM_252_PIPETTE_ALARM,BOOL,,,,coil,2520,异常252-移液枪报警
|
|
||||||
COIL_ALARM_256_ELECTRIC_GRIPPER_ERROR,BOOL,,,,coil,2560,异常256-电爪异常
|
|
||||||
COIL_ALARM_262_RB_UNKNOWN_POSITION_ERROR,BOOL,,,,coil,2620,异常262-RB报警:未知点位错误
|
|
||||||
COIL_ALARM_263_RB_XYZ_PARAM_LIMIT_ERROR,BOOL,,,,coil,2630,异常263-RB报警:X、Y、Z参数超限制
|
|
||||||
COIL_ALARM_264_RB_VISION_PARAM_ERROR,BOOL,,,,coil,2640,异常264-RB报警:视觉参数误差过大
|
|
||||||
COIL_ALARM_265_RB_NOZZLE_1_PICK_FAIL,BOOL,,,,coil,2650,异常265-RB报警:1#吸嘴取料失败
|
|
||||||
COIL_ALARM_266_RB_NOZZLE_2_PICK_FAIL,BOOL,,,,coil,2660,异常266-RB报警:2#吸嘴取料失败
|
|
||||||
COIL_ALARM_267_RB_NOZZLE_3_PICK_FAIL,BOOL,,,,coil,2670,异常267-RB报警:3#吸嘴取料失败
|
|
||||||
COIL_ALARM_268_RB_NOZZLE_4_PICK_FAIL,BOOL,,,,coil,2680,异常268-RB报警:4#吸嘴取料失败
|
|
||||||
COIL_ALARM_269_RB_TRAY_PICK_FAIL,BOOL,,,,coil,2690,异常269-RB报警:取物料盘失败
|
|
||||||
COIL_ALARM_280_RB_COLLISION_ERROR,BOOL,,,,coil,2800,异常280-RB碰撞异常
|
|
||||||
COIL_ALARM_290_VISION_SYSTEM_COMM_ERROR,BOOL,,,,coil,2900,异常290-视觉系统通讯异常
|
|
||||||
COIL_ALARM_291_VISION_ALIGNMENT_NG,BOOL,,,,coil,2910,异常291-视觉对位NG异常
|
|
||||||
COIL_ALARM_292_BARCODE_SCANNER_COMM_ERROR,BOOL,,,,coil,2920,异常292-扫码枪通讯异常
|
|
||||||
COIL_ALARM_310_OCV_TRANSFER_NOZZLE_SUCTION_ERROR,BOOL,,,,coil,3100,异常310-开电移载吸嘴吸真空异常
|
|
||||||
COIL_ALARM_311_OCV_TRANSFER_NOZZLE_BREAK_ERROR,BOOL,,,,coil,3110,异常311-开电移载吸嘴破真空异常
|
|
||||||
COIL_ALARM_312_WEIGHT_TRANSFER_NOZZLE_SUCTION_ERROR,BOOL,,,,coil,3120,异常312-称重移载吸嘴吸真空异常
|
|
||||||
COIL_ALARM_313_WEIGHT_TRANSFER_NOZZLE_BREAK_ERROR,BOOL,,,,coil,3130,异常313-称重移载吸嘴破真空异常
|
|
||||||
COIL_ALARM_340_OCV_NOZZLE_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3400,异常340-开路电压吸嘴移载气缸异常
|
|
||||||
COIL_ALARM_342_OCV_NOZZLE_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3420,异常342-开路电压吸嘴升降气缸异常
|
|
||||||
COIL_ALARM_344_OCV_CRIMPING_CYLINDER_ERROR,BOOL,,,,coil,3440,异常344-开路电压旋压气缸异常
|
|
||||||
COIL_ALARM_350_WEIGHT_NOZZLE_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3500,异常350-称重吸嘴移载气缸异常
|
|
||||||
COIL_ALARM_352_WEIGHT_NOZZLE_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3520,异常352-称重吸嘴升降气缸异常
|
|
||||||
COIL_ALARM_354_CLEANING_CLOTH_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3540,异常354-清洗无尘布移载气缸异常
|
|
||||||
COIL_ALARM_356_CLEANING_CLOTH_PRESS_CYLINDER_ERROR,BOOL,,,,coil,3560,异常356-清洗无尘布压紧气缸异常
|
|
||||||
COIL_ALARM_360_ELECTROLYTE_BOTTLE_POSITION_CYLINDER_ERROR,BOOL,,,,coil,3600,异常360-电解液瓶定位气缸异常
|
|
||||||
COIL_ALARM_362_PIPETTE_TIP_BOX_POSITION_CYLINDER_ERROR,BOOL,,,,coil,3620,异常362-移液枪头盒定位气缸异常
|
|
||||||
COIL_ALARM_364_REAGENT_BOTTLE_GRIPPER_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3640,异常364-试剂瓶夹爪升降气缸异常
|
|
||||||
COIL_ALARM_366_REAGENT_BOTTLE_GRIPPER_CYLINDER_ERROR,BOOL,,,,coil,3660,异常366-试剂瓶夹爪气缸异常
|
|
||||||
COIL_ALARM_370_PRESS_MODULE_BLOW_CYLINDER_ERROR,BOOL,,,,coil,3700,异常370-压制模块吹气气缸异常
|
|
||||||
COIL_ALARM_151_ELECTROLYTE_BOTTLE_POSITION_ERROR,BOOL,,,,coil,1510,异常151-电解液瓶定位在籍异常
|
|
||||||
COIL_ALARM_152_ELECTROLYTE_BOTTLE_CAP_ERROR,BOOL,,,,coil,1520,异常152-电解液瓶盖在籍异常
|
|
||||||
|
14472
unilabos/devices/workstation/coin_cell_assembly/new_cellconfig4.json
Normal file
14472
unilabos/devices/workstation/coin_cell_assembly/new_cellconfig4.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -459,12 +459,12 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
|
|||||||
# 验证必需字段
|
# 验证必需字段
|
||||||
if 'brand' in request_data:
|
if 'brand' in request_data:
|
||||||
if request_data['brand'] == "bioyond": # 奔曜
|
if request_data['brand'] == "bioyond": # 奔曜
|
||||||
material_data = request_data["text"]
|
error_msg = request_data["text"]
|
||||||
logger.info(f"收到奔曜物料变更报送: {material_data}")
|
logger.info(f"收到奔曜错误处理报送: {error_msg}")
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
success=True,
|
success=True,
|
||||||
message=f"物料变更报送已收到: {material_data}",
|
message=f"错误处理报送已收到: {error_msg}",
|
||||||
acknowledgment_id=f"MATERIAL_{int(time.time() * 1000)}_{material_data.get('id', 'unknown')}",
|
acknowledgment_id=f"ERROR_{int(time.time() * 1000)}_{error_msg.get('action_id', 'unknown')}",
|
||||||
data=None
|
data=None
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|||||||
1
unilabos/labware_manager/__init__.py
Normal file
1
unilabos/labware_manager/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# PRCXI 耗材管理 Web 应用
|
||||||
4
unilabos/labware_manager/__main__.py
Normal file
4
unilabos/labware_manager/__main__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""启动入口: python -m unilabos.labware_manager"""
|
||||||
|
from unilabos.labware_manager.app import main
|
||||||
|
|
||||||
|
main()
|
||||||
196
unilabos/labware_manager/app.py
Normal file
196
unilabos/labware_manager/app.py
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
"""FastAPI 应用 + CRUD API + 启动入口。
|
||||||
|
|
||||||
|
用法: python -m unilabos.labware_manager.app
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, Query, Request
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from unilabos.labware_manager.models import LabwareDB, LabwareItem
|
||||||
|
|
||||||
|
_HERE = Path(__file__).resolve().parent
|
||||||
|
_DB_PATH = _HERE / "labware_db.json"
|
||||||
|
|
||||||
|
app = FastAPI(title="PRCXI 耗材管理", version="1.0")
|
||||||
|
|
||||||
|
# 静态文件 + 模板
|
||||||
|
app.mount("/static", StaticFiles(directory=str(_HERE / "static")), name="static")
|
||||||
|
templates = Jinja2Templates(directory=str(_HERE / "templates"))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- DB 读写 ----------
|
||||||
|
|
||||||
|
def _load_db() -> LabwareDB:
|
||||||
|
if not _DB_PATH.exists():
|
||||||
|
return LabwareDB()
|
||||||
|
with open(_DB_PATH, "r", encoding="utf-8") as f:
|
||||||
|
return LabwareDB(**json.load(f))
|
||||||
|
|
||||||
|
|
||||||
|
def _save_db(db: LabwareDB) -> None:
|
||||||
|
with open(_DB_PATH, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(db.model_dump(), f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 页面路由 ----------
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
async def index_page(request: Request):
|
||||||
|
db = _load_db()
|
||||||
|
# 按 type 分组
|
||||||
|
groups = {}
|
||||||
|
for item in db.items:
|
||||||
|
groups.setdefault(item.type, []).append(item)
|
||||||
|
return templates.TemplateResponse("index.html", {
|
||||||
|
"request": request,
|
||||||
|
"groups": groups,
|
||||||
|
"total": len(db.items),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/labware/new", response_class=HTMLResponse)
|
||||||
|
async def new_page(request: Request, type: str = "plate"):
|
||||||
|
return templates.TemplateResponse("edit.html", {
|
||||||
|
"request": request,
|
||||||
|
"item": None,
|
||||||
|
"labware_type": type,
|
||||||
|
"is_new": True,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/labware/{item_id}", response_class=HTMLResponse)
|
||||||
|
async def detail_page(request: Request, item_id: str):
|
||||||
|
db = _load_db()
|
||||||
|
item = _find_item(db, item_id)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(404, "耗材不存在")
|
||||||
|
return templates.TemplateResponse("detail.html", {
|
||||||
|
"request": request,
|
||||||
|
"item": item,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/labware/{item_id}/edit", response_class=HTMLResponse)
|
||||||
|
async def edit_page(request: Request, item_id: str):
|
||||||
|
db = _load_db()
|
||||||
|
item = _find_item(db, item_id)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(404, "耗材不存在")
|
||||||
|
return templates.TemplateResponse("edit.html", {
|
||||||
|
"request": request,
|
||||||
|
"item": item,
|
||||||
|
"labware_type": item.type,
|
||||||
|
"is_new": False,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- API 端点 ----------
|
||||||
|
|
||||||
|
@app.get("/api/labware")
|
||||||
|
async def api_list_labware():
|
||||||
|
db = _load_db()
|
||||||
|
return {"items": [item.model_dump() for item in db.items]}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/labware")
|
||||||
|
async def api_create_labware(request: Request):
|
||||||
|
data = await request.json()
|
||||||
|
db = _load_db()
|
||||||
|
item = LabwareItem(**data)
|
||||||
|
# 确保 id 唯一
|
||||||
|
existing_ids = {it.id for it in db.items}
|
||||||
|
while item.id in existing_ids:
|
||||||
|
import uuid
|
||||||
|
item.id = uuid.uuid4().hex[:8]
|
||||||
|
db.items.append(item)
|
||||||
|
_save_db(db)
|
||||||
|
return {"status": "ok", "id": item.id}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/labware/{item_id}")
|
||||||
|
async def api_update_labware(item_id: str, request: Request):
|
||||||
|
data = await request.json()
|
||||||
|
db = _load_db()
|
||||||
|
for i, it in enumerate(db.items):
|
||||||
|
if it.id == item_id or it.function_name == item_id:
|
||||||
|
updated = LabwareItem(**{**it.model_dump(), **data, "id": it.id})
|
||||||
|
db.items[i] = updated
|
||||||
|
_save_db(db)
|
||||||
|
return {"status": "ok", "id": it.id}
|
||||||
|
raise HTTPException(404, "耗材不存在")
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/labware/{item_id}")
|
||||||
|
async def api_delete_labware(item_id: str):
|
||||||
|
db = _load_db()
|
||||||
|
original_len = len(db.items)
|
||||||
|
db.items = [it for it in db.items if it.id != item_id and it.function_name != item_id]
|
||||||
|
if len(db.items) == original_len:
|
||||||
|
raise HTTPException(404, "耗材不存在")
|
||||||
|
_save_db(db)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/generate-code")
|
||||||
|
async def api_generate_code(request: Request):
|
||||||
|
body = await request.json() if await request.body() else {}
|
||||||
|
test_mode = body.get("test_mode", True)
|
||||||
|
db = _load_db()
|
||||||
|
if not db.items:
|
||||||
|
raise HTTPException(400, "数据库为空,请先导入")
|
||||||
|
|
||||||
|
from unilabos.labware_manager.codegen import generate_code
|
||||||
|
from unilabos.labware_manager.yaml_gen import generate_yaml
|
||||||
|
|
||||||
|
py_path = generate_code(db, test_mode=test_mode)
|
||||||
|
yaml_paths = generate_yaml(db, test_mode=test_mode)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"python_file": str(py_path),
|
||||||
|
"yaml_files": [str(p) for p in yaml_paths],
|
||||||
|
"test_mode": test_mode,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/import-from-code")
|
||||||
|
async def api_import_from_code():
|
||||||
|
from unilabos.labware_manager.importer import import_from_code, save_db
|
||||||
|
db = import_from_code()
|
||||||
|
save_db(db)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"count": len(db.items),
|
||||||
|
"items": [{"function_name": it.function_name, "type": it.type} for it in db.items],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 辅助函数 ----------
|
||||||
|
|
||||||
|
def _find_item(db: LabwareDB, item_id: str) -> Optional[LabwareItem]:
|
||||||
|
for item in db.items:
|
||||||
|
if item.id == item_id or item.function_name == item_id:
|
||||||
|
return item
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 启动入口 ----------
|
||||||
|
|
||||||
|
def main():
|
||||||
|
import uvicorn
|
||||||
|
port = int(os.environ.get("LABWARE_PORT", "8010"))
|
||||||
|
print(f"PRCXI 耗材管理 → http://localhost:{port}")
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=port)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
451
unilabos/labware_manager/codegen.py
Normal file
451
unilabos/labware_manager/codegen.py
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
"""JSON → prcxi_labware.py 代码生成。
|
||||||
|
|
||||||
|
读取 labware_db.json,输出完整的 prcxi_labware.py(或 prcxi_labware_test.py)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from unilabos.labware_manager.models import LabwareDB, LabwareItem
|
||||||
|
|
||||||
|
_TARGET_DIR = Path(__file__).resolve().parents[1] / "devices" / "liquid_handling" / "prcxi"
|
||||||
|
|
||||||
|
# ---------- 固定头部 ----------
|
||||||
|
_HEADER = '''\
|
||||||
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||||
|
from pylabrobot.resources import Tube, Coordinate
|
||||||
|
from pylabrobot.resources.well import Well, WellBottomType, CrossSectionType
|
||||||
|
from pylabrobot.resources.tip import Tip, TipCreator
|
||||||
|
from pylabrobot.resources.tip_rack import TipRack, TipSpot
|
||||||
|
from pylabrobot.resources.utils import create_ordered_items_2d
|
||||||
|
from pylabrobot.resources.height_volume_functions import (
|
||||||
|
compute_height_from_volume_rectangle,
|
||||||
|
compute_volume_from_height_rectangle,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .prcxi import PRCXI9300Plate, PRCXI9300TipRack, PRCXI9300Trash, PRCXI9300TubeRack, PRCXI9300PlateAdapter
|
||||||
|
|
||||||
|
def _make_tip_helper(volume: float, length: float, depth: float) -> Tip:
|
||||||
|
"""
|
||||||
|
PLR 的 Tip 类参数名为: maximal_volume, total_tip_length, fitting_depth
|
||||||
|
"""
|
||||||
|
return Tip(
|
||||||
|
has_filter=False, # 默认无滤芯
|
||||||
|
maximal_volume=volume,
|
||||||
|
total_tip_length=length,
|
||||||
|
fitting_depth=depth
|
||||||
|
)
|
||||||
|
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_plate(item: LabwareItem) -> str:
|
||||||
|
"""生成 Plate 类型的工厂函数代码。"""
|
||||||
|
lines = []
|
||||||
|
fn = item.function_name
|
||||||
|
doc = item.docstring or f"Code: {item.material_info.Code}"
|
||||||
|
|
||||||
|
has_vf = item.volume_functions is not None
|
||||||
|
|
||||||
|
if has_vf:
|
||||||
|
# 有 volume_functions 时需要 well_kwargs 方式
|
||||||
|
vf = item.volume_functions
|
||||||
|
well = item.well
|
||||||
|
grid = item.grid
|
||||||
|
|
||||||
|
lines.append(f'def {fn}(name: str) -> PRCXI9300Plate:')
|
||||||
|
lines.append(f' """')
|
||||||
|
for dl in doc.split('\n'):
|
||||||
|
lines.append(f' {dl}')
|
||||||
|
lines.append(f' """')
|
||||||
|
|
||||||
|
# 计算 well_size 变量
|
||||||
|
lines.append(f' well_size_x = {well.size_x}')
|
||||||
|
lines.append(f' well_size_y = {well.size_y}')
|
||||||
|
|
||||||
|
lines.append(f' well_kwargs = {{')
|
||||||
|
lines.append(f' "size_x": well_size_x,')
|
||||||
|
lines.append(f' "size_y": well_size_y,')
|
||||||
|
lines.append(f' "size_z": {well.size_z},')
|
||||||
|
lines.append(f' "bottom_type": WellBottomType.{well.bottom_type},')
|
||||||
|
if well.cross_section_type and well.cross_section_type != "CIRCLE":
|
||||||
|
lines.append(f' "cross_section_type": CrossSectionType.{well.cross_section_type},')
|
||||||
|
lines.append(f' "compute_height_from_volume": lambda liquid_volume: compute_height_from_volume_rectangle(')
|
||||||
|
lines.append(f' liquid_volume=liquid_volume, well_length=well_size_x, well_width=well_size_y')
|
||||||
|
lines.append(f' ),')
|
||||||
|
lines.append(f' "compute_volume_from_height": lambda liquid_height: compute_volume_from_height_rectangle(')
|
||||||
|
lines.append(f' liquid_height=liquid_height, well_length=well_size_x, well_width=well_size_y')
|
||||||
|
lines.append(f' ),')
|
||||||
|
if well.material_z_thickness is not None:
|
||||||
|
lines.append(f' "material_z_thickness": {well.material_z_thickness},')
|
||||||
|
lines.append(f' }}')
|
||||||
|
lines.append(f'')
|
||||||
|
lines.append(f' return PRCXI9300Plate(')
|
||||||
|
lines.append(f' name=name,')
|
||||||
|
lines.append(f' size_x={item.size_x},')
|
||||||
|
lines.append(f' size_y={item.size_y},')
|
||||||
|
lines.append(f' size_z={item.size_z},')
|
||||||
|
lines.append(f' lid=None,')
|
||||||
|
lines.append(f' model="{item.model}",')
|
||||||
|
lines.append(f' category="plate",')
|
||||||
|
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))},')
|
||||||
|
lines.append(f' ordered_items=create_ordered_items_2d(')
|
||||||
|
lines.append(f' Well,')
|
||||||
|
lines.append(f' num_items_x={grid.num_items_x},')
|
||||||
|
lines.append(f' num_items_y={grid.num_items_y},')
|
||||||
|
lines.append(f' dx={grid.dx},')
|
||||||
|
lines.append(f' dy={grid.dy},')
|
||||||
|
lines.append(f' dz={grid.dz},')
|
||||||
|
lines.append(f' item_dx={grid.item_dx},')
|
||||||
|
lines.append(f' item_dy={grid.item_dy},')
|
||||||
|
lines.append(f' **well_kwargs,')
|
||||||
|
lines.append(f' ),')
|
||||||
|
lines.append(f' )')
|
||||||
|
else:
|
||||||
|
# 普通 plate
|
||||||
|
well = item.well
|
||||||
|
grid = item.grid
|
||||||
|
|
||||||
|
lines.append(f'def {fn}(name: str) -> PRCXI9300Plate:')
|
||||||
|
lines.append(f' """')
|
||||||
|
for dl in doc.split('\n'):
|
||||||
|
lines.append(f' {dl}')
|
||||||
|
lines.append(f' """')
|
||||||
|
lines.append(f' return PRCXI9300Plate(')
|
||||||
|
lines.append(f' name=name,')
|
||||||
|
lines.append(f' size_x={item.size_x},')
|
||||||
|
lines.append(f' size_y={item.size_y},')
|
||||||
|
lines.append(f' size_z={item.size_z},')
|
||||||
|
if item.plate_type:
|
||||||
|
lines.append(f' plate_type="{item.plate_type}",')
|
||||||
|
lines.append(f' model="{item.model}",')
|
||||||
|
lines.append(f' category="plate",')
|
||||||
|
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))},')
|
||||||
|
if grid and well:
|
||||||
|
lines.append(f' ordered_items=create_ordered_items_2d(')
|
||||||
|
lines.append(f' Well,')
|
||||||
|
lines.append(f' num_items_x={grid.num_items_x},')
|
||||||
|
lines.append(f' num_items_y={grid.num_items_y},')
|
||||||
|
lines.append(f' dx={grid.dx},')
|
||||||
|
lines.append(f' dy={grid.dy},')
|
||||||
|
lines.append(f' dz={grid.dz},')
|
||||||
|
lines.append(f' item_dx={grid.item_dx},')
|
||||||
|
lines.append(f' item_dy={grid.item_dy},')
|
||||||
|
lines.append(f' size_x={well.size_x},')
|
||||||
|
lines.append(f' size_y={well.size_y},')
|
||||||
|
lines.append(f' size_z={well.size_z},')
|
||||||
|
if well.max_volume is not None:
|
||||||
|
lines.append(f' max_volume={well.max_volume},')
|
||||||
|
if well.material_z_thickness is not None:
|
||||||
|
lines.append(f' material_z_thickness={well.material_z_thickness},')
|
||||||
|
if well.bottom_type and well.bottom_type != "FLAT":
|
||||||
|
lines.append(f' bottom_type=WellBottomType.{well.bottom_type},')
|
||||||
|
if well.cross_section_type:
|
||||||
|
lines.append(f' cross_section_type=CrossSectionType.{well.cross_section_type},')
|
||||||
|
lines.append(f' ),')
|
||||||
|
lines.append(f' )')
|
||||||
|
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_tip_rack(item: LabwareItem) -> str:
|
||||||
|
"""生成 TipRack 工厂函数代码。"""
|
||||||
|
lines = []
|
||||||
|
fn = item.function_name
|
||||||
|
doc = item.docstring or f"Code: {item.material_info.Code}"
|
||||||
|
grid = item.grid
|
||||||
|
tip = item.tip
|
||||||
|
|
||||||
|
lines.append(f'def {fn}(name: str) -> PRCXI9300TipRack:')
|
||||||
|
lines.append(f' """')
|
||||||
|
for dl in doc.split('\n'):
|
||||||
|
lines.append(f' {dl}')
|
||||||
|
lines.append(f' """')
|
||||||
|
lines.append(f' return PRCXI9300TipRack(')
|
||||||
|
lines.append(f' name=name,')
|
||||||
|
lines.append(f' size_x={item.size_x},')
|
||||||
|
lines.append(f' size_y={item.size_y},')
|
||||||
|
lines.append(f' size_z={item.size_z},')
|
||||||
|
lines.append(f' model="{item.model}",')
|
||||||
|
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))},')
|
||||||
|
if grid and tip:
|
||||||
|
lines.append(f' ordered_items=create_ordered_items_2d(')
|
||||||
|
lines.append(f' TipSpot,')
|
||||||
|
lines.append(f' num_items_x={grid.num_items_x},')
|
||||||
|
lines.append(f' num_items_y={grid.num_items_y},')
|
||||||
|
lines.append(f' dx={grid.dx},')
|
||||||
|
lines.append(f' dy={grid.dy},')
|
||||||
|
lines.append(f' dz={grid.dz},')
|
||||||
|
lines.append(f' item_dx={grid.item_dx},')
|
||||||
|
lines.append(f' item_dy={grid.item_dy},')
|
||||||
|
lines.append(f' size_x={tip.spot_size_x},')
|
||||||
|
lines.append(f' size_y={tip.spot_size_y},')
|
||||||
|
lines.append(f' size_z={tip.spot_size_z},')
|
||||||
|
lines.append(f' make_tip=lambda: _make_tip_helper(volume={tip.tip_volume}, length={tip.tip_length}, depth={tip.tip_fitting_depth})')
|
||||||
|
lines.append(f' )')
|
||||||
|
lines.append(f' )')
|
||||||
|
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_trash(item: LabwareItem) -> str:
|
||||||
|
"""生成 Trash 工厂函数代码。"""
|
||||||
|
lines = []
|
||||||
|
fn = item.function_name
|
||||||
|
doc = item.docstring or f"Code: {item.material_info.Code}"
|
||||||
|
|
||||||
|
lines.append(f'def {fn}(name: str = "trash") -> PRCXI9300Trash:')
|
||||||
|
lines.append(f' """')
|
||||||
|
for dl in doc.split('\n'):
|
||||||
|
lines.append(f' {dl}')
|
||||||
|
lines.append(f' """')
|
||||||
|
lines.append(f' return PRCXI9300Trash(')
|
||||||
|
lines.append(f' name="trash",')
|
||||||
|
lines.append(f' size_x={item.size_x},')
|
||||||
|
lines.append(f' size_y={item.size_y},')
|
||||||
|
lines.append(f' size_z={item.size_z},')
|
||||||
|
lines.append(f' category="trash",')
|
||||||
|
lines.append(f' model="{item.model}",')
|
||||||
|
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))}')
|
||||||
|
lines.append(f' )')
|
||||||
|
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_tube_rack(item: LabwareItem) -> str:
|
||||||
|
"""生成 TubeRack 工厂函数代码。"""
|
||||||
|
lines = []
|
||||||
|
fn = item.function_name
|
||||||
|
doc = item.docstring or f"Code: {item.material_info.Code}"
|
||||||
|
grid = item.grid
|
||||||
|
tube = item.tube
|
||||||
|
|
||||||
|
lines.append(f'def {fn}(name: str) -> PRCXI9300TubeRack:')
|
||||||
|
lines.append(f' """')
|
||||||
|
for dl in doc.split('\n'):
|
||||||
|
lines.append(f' {dl}')
|
||||||
|
lines.append(f' """')
|
||||||
|
lines.append(f' return PRCXI9300TubeRack(')
|
||||||
|
lines.append(f' name=name,')
|
||||||
|
lines.append(f' size_x={item.size_x},')
|
||||||
|
lines.append(f' size_y={item.size_y},')
|
||||||
|
lines.append(f' size_z={item.size_z},')
|
||||||
|
lines.append(f' model="{item.model}",')
|
||||||
|
lines.append(f' category="tube_rack",')
|
||||||
|
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))},')
|
||||||
|
if grid and tube:
|
||||||
|
lines.append(f' ordered_items=create_ordered_items_2d(')
|
||||||
|
lines.append(f' Tube,')
|
||||||
|
lines.append(f' num_items_x={grid.num_items_x},')
|
||||||
|
lines.append(f' num_items_y={grid.num_items_y},')
|
||||||
|
lines.append(f' dx={grid.dx},')
|
||||||
|
lines.append(f' dy={grid.dy},')
|
||||||
|
lines.append(f' dz={grid.dz},')
|
||||||
|
lines.append(f' item_dx={grid.item_dx},')
|
||||||
|
lines.append(f' item_dy={grid.item_dy},')
|
||||||
|
lines.append(f' size_x={tube.size_x},')
|
||||||
|
lines.append(f' size_y={tube.size_y},')
|
||||||
|
lines.append(f' size_z={tube.size_z},')
|
||||||
|
lines.append(f' max_volume={tube.max_volume}')
|
||||||
|
lines.append(f' )')
|
||||||
|
lines.append(f' )')
|
||||||
|
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_plate_adapter(item: LabwareItem) -> str:
|
||||||
|
"""生成 PlateAdapter 工厂函数代码。"""
|
||||||
|
lines = []
|
||||||
|
fn = item.function_name
|
||||||
|
doc = item.docstring or f"Code: {item.material_info.Code}"
|
||||||
|
|
||||||
|
lines.append(f'def {fn}(name: str) -> PRCXI9300PlateAdapter:')
|
||||||
|
lines.append(f' """ {doc} """')
|
||||||
|
lines.append(f' return PRCXI9300PlateAdapter(')
|
||||||
|
lines.append(f' name=name,')
|
||||||
|
lines.append(f' size_x={item.size_x},')
|
||||||
|
lines.append(f' size_y={item.size_y},')
|
||||||
|
lines.append(f' size_z={item.size_z},')
|
||||||
|
if item.model:
|
||||||
|
lines.append(f' model="{item.model}",')
|
||||||
|
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))}')
|
||||||
|
lines.append(f' )')
|
||||||
|
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_dict(d: dict) -> str:
|
||||||
|
"""格式化字典为 Python 代码片段。"""
|
||||||
|
parts = []
|
||||||
|
for k, v in d.items():
|
||||||
|
if isinstance(v, str):
|
||||||
|
parts.append(f'"{k}": "{v}"')
|
||||||
|
elif v is None:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
parts.append(f'"{k}": {v}')
|
||||||
|
return '{' + ', '.join(parts) + '}'
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_template_factory_kinds(items: List[LabwareItem]) -> str:
|
||||||
|
"""生成 PRCXI_TEMPLATE_FACTORY_KINDS 列表。"""
|
||||||
|
lines = ['PRCXI_TEMPLATE_FACTORY_KINDS: List[Tuple[Callable[..., Any], str]] = [']
|
||||||
|
for item in items:
|
||||||
|
if item.include_in_template_matching and item.template_kind:
|
||||||
|
lines.append(f' ({item.function_name}, "{item.template_kind}"),')
|
||||||
|
lines.append(']')
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_footer() -> str:
|
||||||
|
"""生成文件尾部的模板相关代码。"""
|
||||||
|
return '''
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 协议上传 / workflow 用:与设备端耗材字典字段对齐的模板描述(供 common 自动匹配)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_PRCXI_TEMPLATE_SPECS_CACHE: Optional[List[Dict[str, Any]]] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_prcxi_resource(factory: Callable[..., Any]) -> Any:
|
||||||
|
probe = "__unilab_template_probe__"
|
||||||
|
if factory.__name__ == "PRCXI_trash":
|
||||||
|
return factory()
|
||||||
|
return factory(probe)
|
||||||
|
|
||||||
|
|
||||||
|
def _first_child_capacity_for_match(resource: Any) -> float:
|
||||||
|
"""Well max_volume 或 Tip 的 maximal_volume,用于与设备端 Volume 类似的打分。"""
|
||||||
|
ch = getattr(resource, "children", None) or []
|
||||||
|
if not ch:
|
||||||
|
return 0.0
|
||||||
|
c0 = ch[0]
|
||||||
|
mv = getattr(c0, "max_volume", None)
|
||||||
|
if mv is not None:
|
||||||
|
return float(mv)
|
||||||
|
tip = getattr(c0, "tip", None)
|
||||||
|
if tip is not None:
|
||||||
|
mv2 = getattr(tip, "maximal_volume", None)
|
||||||
|
if mv2 is not None:
|
||||||
|
return float(mv2)
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def get_prcxi_labware_template_specs() -> List[Dict[str, Any]]:
|
||||||
|
"""返回与 ``prcxi._match_and_create_matrix`` 中耗材字段兼容的模板列表,用于按孔数+容量打分。"""
|
||||||
|
global _PRCXI_TEMPLATE_SPECS_CACHE
|
||||||
|
if _PRCXI_TEMPLATE_SPECS_CACHE is not None:
|
||||||
|
return _PRCXI_TEMPLATE_SPECS_CACHE
|
||||||
|
|
||||||
|
out: List[Dict[str, Any]] = []
|
||||||
|
for factory, kind in PRCXI_TEMPLATE_FACTORY_KINDS:
|
||||||
|
try:
|
||||||
|
r = _probe_prcxi_resource(factory)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
nx = int(getattr(r, "num_items_x", None) or 0)
|
||||||
|
ny = int(getattr(r, "num_items_y", None) or 0)
|
||||||
|
nchild = len(getattr(r, "children", []) or [])
|
||||||
|
hole_count = nx * ny if nx > 0 and ny > 0 else nchild
|
||||||
|
hole_row = ny if nx > 0 and ny > 0 else 0
|
||||||
|
hole_col = nx if nx > 0 and ny > 0 else 0
|
||||||
|
mi = getattr(r, "material_info", None) or {}
|
||||||
|
vol = _first_child_capacity_for_match(r)
|
||||||
|
menum = mi.get("materialEnum")
|
||||||
|
if menum is None and kind == "tip_rack":
|
||||||
|
menum = 1
|
||||||
|
elif menum is None and kind == "trash":
|
||||||
|
menum = 6
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"class_name": factory.__name__,
|
||||||
|
"kind": kind,
|
||||||
|
"materialEnum": menum,
|
||||||
|
"HoleRow": hole_row,
|
||||||
|
"HoleColum": hole_col,
|
||||||
|
"Volume": vol,
|
||||||
|
"hole_count": hole_count,
|
||||||
|
"material_uuid": mi.get("uuid"),
|
||||||
|
"material_code": mi.get("Code"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_PRCXI_TEMPLATE_SPECS_CACHE = out
|
||||||
|
return out
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def generate_code(db: LabwareDB, test_mode: bool = True) -> Path:
|
||||||
|
"""生成 prcxi_labware.py (或 _test.py),返回输出文件路径。"""
|
||||||
|
suffix = "_test" if test_mode else ""
|
||||||
|
out_path = _TARGET_DIR / f"prcxi_labware{suffix}.py"
|
||||||
|
|
||||||
|
# 备份
|
||||||
|
if out_path.exists():
|
||||||
|
bak = out_path.with_suffix(".py.bak")
|
||||||
|
shutil.copy2(out_path, bak)
|
||||||
|
|
||||||
|
# 按类型分组的生成器
|
||||||
|
generators = {
|
||||||
|
"plate": _gen_plate,
|
||||||
|
"tip_rack": _gen_tip_rack,
|
||||||
|
"trash": _gen_trash,
|
||||||
|
"tube_rack": _gen_tube_rack,
|
||||||
|
"plate_adapter": _gen_plate_adapter,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 按 type 分段
|
||||||
|
sections = {
|
||||||
|
"plate": [],
|
||||||
|
"tip_rack": [],
|
||||||
|
"trash": [],
|
||||||
|
"tube_rack": [],
|
||||||
|
"plate_adapter": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for item in db.items:
|
||||||
|
gen = generators.get(item.type)
|
||||||
|
if gen:
|
||||||
|
sections[item.type].append(gen(item))
|
||||||
|
|
||||||
|
# 组装完整文件
|
||||||
|
parts = [_HEADER]
|
||||||
|
|
||||||
|
section_titles = {
|
||||||
|
"plate": "# =========================================================================\n# Plates\n# =========================================================================",
|
||||||
|
"tip_rack": "# =========================================================================\n# Tip Racks\n# =========================================================================",
|
||||||
|
"trash": "# =========================================================================\n# Trash\n# =========================================================================",
|
||||||
|
"tube_rack": "# =========================================================================\n# Tube Racks\n# =========================================================================",
|
||||||
|
"plate_adapter": "# =========================================================================\n# Plate Adapters\n# =========================================================================",
|
||||||
|
}
|
||||||
|
|
||||||
|
for type_key in ["plate", "tip_rack", "trash", "tube_rack", "plate_adapter"]:
|
||||||
|
if sections[type_key]:
|
||||||
|
parts.append(section_titles[type_key])
|
||||||
|
for code in sections[type_key]:
|
||||||
|
parts.append(code)
|
||||||
|
|
||||||
|
# Template factory kinds
|
||||||
|
parts.append("")
|
||||||
|
parts.append(_gen_template_factory_kinds(db.items))
|
||||||
|
|
||||||
|
# Footer
|
||||||
|
parts.append(_gen_footer())
|
||||||
|
|
||||||
|
content = '\n'.join(parts)
|
||||||
|
out_path.write_text(content, encoding="utf-8")
|
||||||
|
return out_path
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
from unilabos.labware_manager.importer import load_db
|
||||||
|
db = load_db()
|
||||||
|
if not db.items:
|
||||||
|
print("labware_db.json 为空,请先运行 importer.py")
|
||||||
|
else:
|
||||||
|
out = generate_code(db, test_mode=True)
|
||||||
|
print(f"已生成 {out} ({len(db.items)} 个工厂函数)")
|
||||||
474
unilabos/labware_manager/importer.py
Normal file
474
unilabos/labware_manager/importer.py
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
"""从现有 prcxi_labware.py + registry YAML 导入耗材数据到 labware_db.json。
|
||||||
|
|
||||||
|
策略:
|
||||||
|
1. 实例化每个工厂函数 → 提取物理尺寸、material_info、children
|
||||||
|
2. AST 解析源码 → 提取 docstring、volume_function 参数、plate_type
|
||||||
|
3. 从 children[0].location 反推 dx/dy/dz,相邻位置差推 item_dx/item_dy
|
||||||
|
4. 同时读取现有 YAML → 提取 registry_category / description
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
# 将项目根目录加入 sys.path 以便 import
|
||||||
|
_PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
if str(_PROJECT_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_PROJECT_ROOT))
|
||||||
|
|
||||||
|
from unilabos.labware_manager.models import (
|
||||||
|
AdapterInfo,
|
||||||
|
GridInfo,
|
||||||
|
LabwareDB,
|
||||||
|
LabwareItem,
|
||||||
|
MaterialInfo,
|
||||||
|
TipInfo,
|
||||||
|
TubeInfo,
|
||||||
|
VolumeFunctions,
|
||||||
|
WellInfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------- 路径常量 ----------
|
||||||
|
_LABWARE_PY = Path(__file__).resolve().parents[1] / "devices" / "liquid_handling" / "prcxi" / "prcxi_labware.py"
|
||||||
|
_REGISTRY_DIR = Path(__file__).resolve().parents[1] / "registry" / "resources" / "prcxi"
|
||||||
|
_DB_PATH = Path(__file__).resolve().parent / "labware_db.json"
|
||||||
|
|
||||||
|
# YAML 文件名 → type 映射
|
||||||
|
_YAML_MAP: Dict[str, str] = {
|
||||||
|
"plates.yaml": "plate",
|
||||||
|
"tip_racks.yaml": "tip_rack",
|
||||||
|
"trash.yaml": "trash",
|
||||||
|
"tube_racks.yaml": "tube_rack",
|
||||||
|
"plate_adapters.yaml": "plate_adapter",
|
||||||
|
}
|
||||||
|
|
||||||
|
# PRCXI_TEMPLATE_FACTORY_KINDS 中列出的函数名(include_in_template_matching=True)
|
||||||
|
_TEMPLATE_FACTORY_NAMES = {
|
||||||
|
"PRCXI_BioER_96_wellplate", "PRCXI_nest_1_troughplate",
|
||||||
|
"PRCXI_BioRad_384_wellplate", "PRCXI_AGenBio_4_troughplate",
|
||||||
|
"PRCXI_nest_12_troughplate", "PRCXI_CellTreat_96_wellplate",
|
||||||
|
"PRCXI_10ul_eTips", "PRCXI_300ul_Tips",
|
||||||
|
"PRCXI_PCR_Plate_200uL_nonskirted", "PRCXI_PCR_Plate_200uL_semiskirted",
|
||||||
|
"PRCXI_PCR_Plate_200uL_skirted", "PRCXI_trash",
|
||||||
|
"PRCXI_96_DeepWell", "PRCXI_EP_Adapter",
|
||||||
|
"PRCXI_1250uL_Tips", "PRCXI_10uL_Tips",
|
||||||
|
"PRCXI_1000uL_Tips", "PRCXI_200uL_Tips",
|
||||||
|
"PRCXI_48_DeepWell",
|
||||||
|
}
|
||||||
|
|
||||||
|
# template_kind 对应
|
||||||
|
_TEMPLATE_KINDS: Dict[str, str] = {
|
||||||
|
"PRCXI_BioER_96_wellplate": "plate",
|
||||||
|
"PRCXI_nest_1_troughplate": "plate",
|
||||||
|
"PRCXI_BioRad_384_wellplate": "plate",
|
||||||
|
"PRCXI_AGenBio_4_troughplate": "plate",
|
||||||
|
"PRCXI_nest_12_troughplate": "plate",
|
||||||
|
"PRCXI_CellTreat_96_wellplate": "plate",
|
||||||
|
"PRCXI_10ul_eTips": "tip_rack",
|
||||||
|
"PRCXI_300ul_Tips": "tip_rack",
|
||||||
|
"PRCXI_PCR_Plate_200uL_nonskirted": "plate",
|
||||||
|
"PRCXI_PCR_Plate_200uL_semiskirted": "plate",
|
||||||
|
"PRCXI_PCR_Plate_200uL_skirted": "plate",
|
||||||
|
"PRCXI_trash": "trash",
|
||||||
|
"PRCXI_96_DeepWell": "plate",
|
||||||
|
"PRCXI_EP_Adapter": "tube_rack",
|
||||||
|
"PRCXI_1250uL_Tips": "tip_rack",
|
||||||
|
"PRCXI_10uL_Tips": "tip_rack",
|
||||||
|
"PRCXI_1000uL_Tips": "tip_rack",
|
||||||
|
"PRCXI_200uL_Tips": "tip_rack",
|
||||||
|
"PRCXI_48_DeepWell": "plate",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_registry_info() -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""读取所有 registry YAML 文件,返回 {function_name: {category, description}} 映射。"""
|
||||||
|
info: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for fname, ltype in _YAML_MAP.items():
|
||||||
|
fpath = _REGISTRY_DIR / fname
|
||||||
|
if not fpath.exists():
|
||||||
|
continue
|
||||||
|
with open(fpath, "r", encoding="utf-8") as f:
|
||||||
|
data = yaml.safe_load(f) or {}
|
||||||
|
for func_name, entry in data.items():
|
||||||
|
info[func_name] = {
|
||||||
|
"registry_category": entry.get("category", ["prcxi", ltype.replace("plate_adapter", "plate_adapters")]),
|
||||||
|
"registry_description": entry.get("description", ""),
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ast_info() -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""AST 解析 prcxi_labware.py,提取每个工厂函数的 docstring 和 volume_function 参数。"""
|
||||||
|
source = _LABWARE_PY.read_text(encoding="utf-8")
|
||||||
|
tree = ast.parse(source)
|
||||||
|
result: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if not isinstance(node, ast.FunctionDef):
|
||||||
|
continue
|
||||||
|
fname = node.name
|
||||||
|
if not fname.startswith("PRCXI_"):
|
||||||
|
continue
|
||||||
|
if fname.startswith("_"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
info: Dict[str, Any] = {"docstring": "", "volume_functions": None, "plate_type": None}
|
||||||
|
|
||||||
|
# docstring
|
||||||
|
doc = ast.get_docstring(node)
|
||||||
|
if doc:
|
||||||
|
info["docstring"] = doc.strip()
|
||||||
|
|
||||||
|
# 搜索函数体中的 plate_type 赋值和 volume_function 参数
|
||||||
|
func_source = ast.get_source_segment(source, node) or ""
|
||||||
|
|
||||||
|
# plate_type
|
||||||
|
m = re.search(r'plate_type\s*=\s*["\']([^"\']+)["\']', func_source)
|
||||||
|
if m:
|
||||||
|
info["plate_type"] = m.group(1)
|
||||||
|
|
||||||
|
# volume_functions: 检查 compute_height_from_volume_rectangle
|
||||||
|
if "compute_height_from_volume_rectangle" in func_source:
|
||||||
|
# 提取 well_length 和 well_width
|
||||||
|
vf: Dict[str, Any] = {"type": "rectangle"}
|
||||||
|
# 尝试从 lambda 中提取
|
||||||
|
wl_match = re.search(r'well_length\s*=\s*([\w_.]+)', func_source)
|
||||||
|
ww_match = re.search(r'well_width\s*=\s*([\w_.]+)', func_source)
|
||||||
|
if wl_match:
|
||||||
|
vf["well_length_var"] = wl_match.group(1)
|
||||||
|
if ww_match:
|
||||||
|
vf["well_width_var"] = ww_match.group(1)
|
||||||
|
info["volume_functions"] = vf
|
||||||
|
|
||||||
|
result[fname] = info
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_factory(factory_func) -> Any:
|
||||||
|
"""实例化工厂函数获取 resource 对象。"""
|
||||||
|
if factory_func.__name__ == "PRCXI_trash":
|
||||||
|
return factory_func()
|
||||||
|
return factory_func("__probe__")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_size(resource, attr: str) -> float:
|
||||||
|
"""获取 PLR Resource 的尺寸(兼容 size_x 和 _size_x)。"""
|
||||||
|
val = getattr(resource, attr, None)
|
||||||
|
if val is None:
|
||||||
|
val = getattr(resource, f"_{attr}", None)
|
||||||
|
if val is None:
|
||||||
|
val = getattr(resource, f"get_{attr}", lambda: 0)()
|
||||||
|
return float(val or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_grid_from_children(resource) -> Optional[Dict[str, Any]]:
|
||||||
|
"""从 resource.children 提取网格信息。"""
|
||||||
|
children = getattr(resource, "children", None) or []
|
||||||
|
if not children:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 获取 num_items_x, num_items_y
|
||||||
|
num_x = getattr(resource, "num_items_x", None)
|
||||||
|
num_y = getattr(resource, "num_items_y", None)
|
||||||
|
if num_x is None or num_y is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
c0 = children[0]
|
||||||
|
loc0 = getattr(c0, "location", None)
|
||||||
|
dx = loc0.x if loc0 else 0.0
|
||||||
|
dy_raw = loc0.y if loc0 else 0.0 # 这是 PLR 布局后的位置,不是输入参数
|
||||||
|
dz = loc0.z if loc0 else 0.0
|
||||||
|
|
||||||
|
# 推算 item_dx, item_dy
|
||||||
|
item_dx = 9.0
|
||||||
|
item_dy = 9.0
|
||||||
|
if len(children) > 1:
|
||||||
|
c1 = children[1]
|
||||||
|
loc1 = getattr(c1, "location", None)
|
||||||
|
if loc1 and loc0:
|
||||||
|
diff_x = abs(loc1.x - loc0.x)
|
||||||
|
diff_y = abs(loc1.y - loc0.y)
|
||||||
|
if diff_x > 0.1:
|
||||||
|
item_dx = diff_x
|
||||||
|
if diff_y > 0.1:
|
||||||
|
item_dy = diff_y
|
||||||
|
|
||||||
|
# 如果 num_items_y > 1 且 num_items_x > 1, 找列间距
|
||||||
|
if int(num_y) > 1 and int(num_x) > 1 and len(children) >= int(num_y) + 1:
|
||||||
|
cn = children[int(num_y)]
|
||||||
|
locn = getattr(cn, "location", None)
|
||||||
|
if locn and loc0:
|
||||||
|
col_diff = abs(locn.x - loc0.x)
|
||||||
|
row_diff = abs(children[1].location.y - loc0.y) if len(children) > 1 else item_dy
|
||||||
|
if col_diff > 0.1:
|
||||||
|
item_dx = col_diff
|
||||||
|
if row_diff > 0.1:
|
||||||
|
item_dy = row_diff
|
||||||
|
|
||||||
|
# PLR create_ordered_items_2d 的 Y 轴排列是倒序的:
|
||||||
|
# child[0].y = dy_param + (num_y - 1) * item_dy (最上面一行)
|
||||||
|
# 因此反推原始 dy 参数:
|
||||||
|
dy = dy_raw - (int(num_y) - 1) * item_dy
|
||||||
|
|
||||||
|
return {
|
||||||
|
"num_items_x": int(num_x),
|
||||||
|
"num_items_y": int(num_y),
|
||||||
|
"dx": round(dx, 4),
|
||||||
|
"dy": round(dy, 4),
|
||||||
|
"dz": round(dz, 4),
|
||||||
|
"item_dx": round(item_dx, 4),
|
||||||
|
"item_dy": round(item_dy, 4),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_well_info(child) -> Dict[str, Any]:
|
||||||
|
"""从 Well/TipSpot/Tube 子对象提取信息。"""
|
||||||
|
# material_z_thickness 在 PLR 中如果未设置会抛 NotImplementedError
|
||||||
|
mzt = None
|
||||||
|
try:
|
||||||
|
mzt = child.material_z_thickness
|
||||||
|
except (NotImplementedError, AttributeError):
|
||||||
|
mzt = getattr(child, "_material_z_thickness", None)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"size_x": round(_get_size(child, "size_x"), 4),
|
||||||
|
"size_y": round(_get_size(child, "size_y"), 4),
|
||||||
|
"size_z": round(_get_size(child, "size_z"), 4),
|
||||||
|
"max_volume": getattr(child, "max_volume", None),
|
||||||
|
"bottom_type": getattr(child, "bottom_type", None),
|
||||||
|
"cross_section_type": getattr(child, "cross_section_type", None),
|
||||||
|
"material_z_thickness": mzt,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def import_from_code() -> LabwareDB:
|
||||||
|
"""执行完整的导入流程,返回 LabwareDB 对象。"""
|
||||||
|
# 1. 加载 registry 信息
|
||||||
|
reg_info = _load_registry_info()
|
||||||
|
|
||||||
|
# 2. AST 解析源码
|
||||||
|
ast_info = _parse_ast_info()
|
||||||
|
|
||||||
|
# 3. 导入工厂模块(通过包路径避免 relative import 问题)
|
||||||
|
import importlib
|
||||||
|
mod = importlib.import_module("unilabos.devices.liquid_handling.prcxi.prcxi_labware")
|
||||||
|
|
||||||
|
# 4. 获取 PRCXI_TEMPLATE_FACTORY_KINDS 列出的函数
|
||||||
|
factory_kinds = getattr(mod, "PRCXI_TEMPLATE_FACTORY_KINDS", [])
|
||||||
|
template_func_names = {f.__name__ for f, _k in factory_kinds}
|
||||||
|
|
||||||
|
# 5. 收集所有 PRCXI_ 开头的工厂函数
|
||||||
|
all_factories: List[Tuple[str, Any]] = []
|
||||||
|
for attr_name in dir(mod):
|
||||||
|
if attr_name.startswith("PRCXI_") and not attr_name.startswith("_"):
|
||||||
|
obj = getattr(mod, attr_name)
|
||||||
|
if callable(obj) and not isinstance(obj, type):
|
||||||
|
all_factories.append((attr_name, obj))
|
||||||
|
|
||||||
|
# 按源码行号排序
|
||||||
|
all_factories.sort(key=lambda x: getattr(x[1], "__code__", None) and x[1].__code__.co_firstlineno or 0)
|
||||||
|
|
||||||
|
items: List[LabwareItem] = []
|
||||||
|
|
||||||
|
for func_name, factory in all_factories:
|
||||||
|
try:
|
||||||
|
resource = _probe_factory(factory)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"跳过 {func_name}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 确定类型
|
||||||
|
type_name = "plate"
|
||||||
|
class_name = type(resource).__name__
|
||||||
|
if "TipRack" in class_name:
|
||||||
|
type_name = "tip_rack"
|
||||||
|
elif "Trash" in class_name:
|
||||||
|
type_name = "trash"
|
||||||
|
elif "TubeRack" in class_name:
|
||||||
|
type_name = "tube_rack"
|
||||||
|
elif "PlateAdapter" in class_name:
|
||||||
|
type_name = "plate_adapter"
|
||||||
|
|
||||||
|
# material_info
|
||||||
|
state = getattr(resource, "_unilabos_state", {}) or {}
|
||||||
|
mat = state.get("Material", {})
|
||||||
|
mat_info = MaterialInfo(
|
||||||
|
uuid=mat.get("uuid", ""),
|
||||||
|
Code=mat.get("Code", ""),
|
||||||
|
Name=mat.get("Name", ""),
|
||||||
|
materialEnum=mat.get("materialEnum"),
|
||||||
|
SupplyType=mat.get("SupplyType"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# AST 信息
|
||||||
|
ast_data = ast_info.get(func_name, {})
|
||||||
|
docstring = ast_data.get("docstring", "")
|
||||||
|
plate_type = ast_data.get("plate_type")
|
||||||
|
|
||||||
|
# Registry 信息
|
||||||
|
reg = reg_info.get(func_name, {})
|
||||||
|
registry_category = reg.get("registry_category", ["prcxi", _type_to_yaml_subcategory(type_name)])
|
||||||
|
registry_description = reg.get("registry_description", f'{mat_info.Name} (Code: {mat_info.Code})')
|
||||||
|
|
||||||
|
# 构建 item
|
||||||
|
item = LabwareItem(
|
||||||
|
id=func_name.lower().replace("prcxi_", "")[:8] or func_name[:8],
|
||||||
|
type=type_name,
|
||||||
|
function_name=func_name,
|
||||||
|
docstring=docstring,
|
||||||
|
size_x=round(_get_size(resource, "size_x"), 4),
|
||||||
|
size_y=round(_get_size(resource, "size_y"), 4),
|
||||||
|
size_z=round(_get_size(resource, "size_z"), 4),
|
||||||
|
model=getattr(resource, "model", None),
|
||||||
|
category=getattr(resource, "category", type_name),
|
||||||
|
plate_type=plate_type,
|
||||||
|
material_info=mat_info,
|
||||||
|
registry_category=registry_category,
|
||||||
|
registry_description=registry_description,
|
||||||
|
include_in_template_matching=func_name in template_func_names,
|
||||||
|
template_kind=_TEMPLATE_KINDS.get(func_name),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 提取子项信息
|
||||||
|
children = getattr(resource, "children", None) or []
|
||||||
|
grid_data = _extract_grid_from_children(resource)
|
||||||
|
|
||||||
|
if type_name == "plate" and children:
|
||||||
|
if grid_data:
|
||||||
|
item.grid = GridInfo(**grid_data)
|
||||||
|
c0 = children[0]
|
||||||
|
well_data = _extract_well_info(c0)
|
||||||
|
bt = well_data.get("bottom_type")
|
||||||
|
if bt is not None:
|
||||||
|
bt = bt.name if hasattr(bt, "name") else str(bt)
|
||||||
|
else:
|
||||||
|
bt = "FLAT"
|
||||||
|
cst = well_data.get("cross_section_type")
|
||||||
|
if cst is not None:
|
||||||
|
cst = cst.name if hasattr(cst, "name") else str(cst)
|
||||||
|
else:
|
||||||
|
cst = "CIRCLE"
|
||||||
|
item.well = WellInfo(
|
||||||
|
size_x=well_data["size_x"],
|
||||||
|
size_y=well_data["size_y"],
|
||||||
|
size_z=well_data["size_z"],
|
||||||
|
max_volume=well_data.get("max_volume"),
|
||||||
|
bottom_type=bt,
|
||||||
|
cross_section_type=cst,
|
||||||
|
material_z_thickness=well_data.get("material_z_thickness"),
|
||||||
|
)
|
||||||
|
# volume_functions
|
||||||
|
vf = ast_data.get("volume_functions")
|
||||||
|
if vf:
|
||||||
|
# 需要实际获取 well 尺寸作为 volume_function 参数
|
||||||
|
item.volume_functions = VolumeFunctions(
|
||||||
|
type="rectangle",
|
||||||
|
well_length=well_data["size_x"],
|
||||||
|
well_width=well_data["size_y"],
|
||||||
|
)
|
||||||
|
|
||||||
|
elif type_name == "tip_rack" and children:
|
||||||
|
if grid_data:
|
||||||
|
item.grid = GridInfo(**grid_data)
|
||||||
|
c0 = children[0]
|
||||||
|
tip_obj = getattr(c0, "tip", None)
|
||||||
|
tip_volume = 300.0
|
||||||
|
tip_length = 60.0
|
||||||
|
tip_depth = 51.0
|
||||||
|
tip_filter = False
|
||||||
|
if tip_obj:
|
||||||
|
tip_volume = getattr(tip_obj, "maximal_volume", 300.0)
|
||||||
|
tip_length = getattr(tip_obj, "total_tip_length", 60.0)
|
||||||
|
tip_depth = getattr(tip_obj, "fitting_depth", 51.0)
|
||||||
|
tip_filter = getattr(tip_obj, "has_filter", False)
|
||||||
|
item.tip = TipInfo(
|
||||||
|
spot_size_x=round(_get_size(c0, "size_x"), 4),
|
||||||
|
spot_size_y=round(_get_size(c0, "size_y"), 4),
|
||||||
|
spot_size_z=round(_get_size(c0, "size_z"), 4),
|
||||||
|
tip_volume=tip_volume,
|
||||||
|
tip_length=tip_length,
|
||||||
|
tip_fitting_depth=tip_depth,
|
||||||
|
has_filter=tip_filter,
|
||||||
|
)
|
||||||
|
# 计算 tip_above_rack_length = tip_length - (size_z - dz)
|
||||||
|
if grid_data:
|
||||||
|
_dz = grid_data.get("dz", 0.0)
|
||||||
|
_above = tip_length - (item.size_z - _dz)
|
||||||
|
item.tip.tip_above_rack_length = round(_above, 4) if _above > 0 else None
|
||||||
|
|
||||||
|
elif type_name == "tube_rack" and children:
|
||||||
|
if grid_data:
|
||||||
|
item.grid = GridInfo(**grid_data)
|
||||||
|
c0 = children[0]
|
||||||
|
item.tube = TubeInfo(
|
||||||
|
size_x=round(_get_size(c0, "size_x"), 4),
|
||||||
|
size_y=round(_get_size(c0, "size_y"), 4),
|
||||||
|
size_z=round(_get_size(c0, "size_z"), 4),
|
||||||
|
max_volume=getattr(c0, "max_volume", 1500.0) or 1500.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif type_name == "plate_adapter":
|
||||||
|
# 提取 adapter 参数
|
||||||
|
ahx = getattr(resource, "adapter_hole_size_x", 127.76)
|
||||||
|
ahy = getattr(resource, "adapter_hole_size_y", 85.48)
|
||||||
|
ahz = getattr(resource, "adapter_hole_size_z", 10.0)
|
||||||
|
adx = getattr(resource, "dx", None)
|
||||||
|
ady = getattr(resource, "dy", None)
|
||||||
|
adz = getattr(resource, "dz", 0.0)
|
||||||
|
item.adapter = AdapterInfo(
|
||||||
|
adapter_hole_size_x=ahx,
|
||||||
|
adapter_hole_size_y=ahy,
|
||||||
|
adapter_hole_size_z=ahz,
|
||||||
|
dx=adx,
|
||||||
|
dy=ady,
|
||||||
|
dz=adz,
|
||||||
|
)
|
||||||
|
|
||||||
|
items.append(item)
|
||||||
|
|
||||||
|
return LabwareDB(items=items)
|
||||||
|
|
||||||
|
|
||||||
|
def _type_to_yaml_subcategory(type_name: str) -> str:
|
||||||
|
mapping = {
|
||||||
|
"plate": "plates",
|
||||||
|
"tip_rack": "tip_racks",
|
||||||
|
"trash": "trash",
|
||||||
|
"tube_rack": "tube_racks",
|
||||||
|
"plate_adapter": "plate_adapters",
|
||||||
|
}
|
||||||
|
return mapping.get(type_name, type_name)
|
||||||
|
|
||||||
|
|
||||||
|
def save_db(db: LabwareDB, path: Optional[Path] = None) -> Path:
|
||||||
|
"""保存 LabwareDB 到 JSON 文件。"""
|
||||||
|
out = path or _DB_PATH
|
||||||
|
with open(out, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(db.model_dump(), f, ensure_ascii=False, indent=2)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def load_db(path: Optional[Path] = None) -> LabwareDB:
|
||||||
|
"""从 JSON 文件加载 LabwareDB。"""
|
||||||
|
src = path or _DB_PATH
|
||||||
|
if not src.exists():
|
||||||
|
return LabwareDB()
|
||||||
|
with open(src, "r", encoding="utf-8") as f:
|
||||||
|
return LabwareDB(**json.load(f))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
db = import_from_code()
|
||||||
|
out = save_db(db)
|
||||||
|
print(f"已导入 {len(db.items)} 个耗材 → {out}")
|
||||||
|
for item in db.items:
|
||||||
|
print(f" [{item.type:14s}] {item.function_name}")
|
||||||
1316
unilabos/labware_manager/labware_db.json
Normal file
1316
unilabos/labware_manager/labware_db.json
Normal file
File diff suppressed because it is too large
Load Diff
126
unilabos/labware_manager/models.py
Normal file
126
unilabos/labware_manager/models.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
"""Pydantic 数据模型,描述所有 PRCXI 耗材类型的 JSON 结构。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid as _uuid
|
||||||
|
from typing import Any, Dict, List, Literal, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class MaterialInfo(BaseModel):
|
||||||
|
uuid: str = ""
|
||||||
|
Code: str = ""
|
||||||
|
Name: str = ""
|
||||||
|
materialEnum: Optional[int] = None
|
||||||
|
SupplyType: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class GridInfo(BaseModel):
|
||||||
|
"""孔位网格排列参数"""
|
||||||
|
num_items_x: int = 12
|
||||||
|
num_items_y: int = 8
|
||||||
|
dx: float = 0.0
|
||||||
|
dy: float = 0.0
|
||||||
|
dz: float = 0.0
|
||||||
|
item_dx: float = 9.0
|
||||||
|
item_dy: float = 9.0
|
||||||
|
|
||||||
|
|
||||||
|
class WellInfo(BaseModel):
|
||||||
|
"""孔参数 (Plate)"""
|
||||||
|
size_x: float = 8.0
|
||||||
|
size_y: float = 8.0
|
||||||
|
size_z: float = 10.0
|
||||||
|
max_volume: Optional[float] = None
|
||||||
|
bottom_type: str = "FLAT" # V / U / FLAT
|
||||||
|
cross_section_type: str = "CIRCLE" # CIRCLE / RECTANGLE
|
||||||
|
material_z_thickness: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
class VolumeFunctions(BaseModel):
|
||||||
|
"""体积-高度计算函数参数 (矩形 well)"""
|
||||||
|
type: str = "rectangle"
|
||||||
|
well_length: float = 0.0
|
||||||
|
well_width: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class TipInfo(BaseModel):
|
||||||
|
"""枪头参数 (TipRack)"""
|
||||||
|
spot_size_x: float = 7.0
|
||||||
|
spot_size_y: float = 7.0
|
||||||
|
spot_size_z: float = 0.0
|
||||||
|
tip_volume: float = 300.0
|
||||||
|
tip_length: float = 60.0
|
||||||
|
tip_fitting_depth: float = 51.0
|
||||||
|
tip_above_rack_length: Optional[float] = None
|
||||||
|
has_filter: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class TubeInfo(BaseModel):
|
||||||
|
"""管参数 (TubeRack)"""
|
||||||
|
size_x: float = 10.6
|
||||||
|
size_y: float = 10.6
|
||||||
|
size_z: float = 40.0
|
||||||
|
max_volume: float = 1500.0
|
||||||
|
|
||||||
|
|
||||||
|
class AdapterInfo(BaseModel):
|
||||||
|
"""适配器参数 (PlateAdapter)"""
|
||||||
|
adapter_hole_size_x: float = 127.76
|
||||||
|
adapter_hole_size_y: float = 85.48
|
||||||
|
adapter_hole_size_z: float = 10.0
|
||||||
|
dx: Optional[float] = None
|
||||||
|
dy: Optional[float] = None
|
||||||
|
dz: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
LabwareType = Literal["plate", "tip_rack", "trash", "tube_rack", "plate_adapter"]
|
||||||
|
|
||||||
|
|
||||||
|
class LabwareItem(BaseModel):
|
||||||
|
"""一个耗材条目的完整 JSON 表示"""
|
||||||
|
|
||||||
|
id: str = Field(default_factory=lambda: _uuid.uuid4().hex[:8])
|
||||||
|
type: LabwareType = "plate"
|
||||||
|
function_name: str = ""
|
||||||
|
docstring: str = ""
|
||||||
|
|
||||||
|
# 物理尺寸
|
||||||
|
size_x: float = 127.0
|
||||||
|
size_y: float = 85.0
|
||||||
|
size_z: float = 20.0
|
||||||
|
model: Optional[str] = None
|
||||||
|
category: Optional[str] = None
|
||||||
|
plate_type: Optional[str] = None # non-skirted / semi-skirted / skirted
|
||||||
|
|
||||||
|
# 材料信息
|
||||||
|
material_info: MaterialInfo = Field(default_factory=MaterialInfo)
|
||||||
|
|
||||||
|
# Registry 字段
|
||||||
|
registry_category: List[str] = Field(default_factory=lambda: ["prcxi", "plates"])
|
||||||
|
registry_description: str = ""
|
||||||
|
|
||||||
|
# Plate 特有
|
||||||
|
grid: Optional[GridInfo] = None
|
||||||
|
well: Optional[WellInfo] = None
|
||||||
|
volume_functions: Optional[VolumeFunctions] = None
|
||||||
|
|
||||||
|
# TipRack 特有
|
||||||
|
tip: Optional[TipInfo] = None
|
||||||
|
|
||||||
|
# TubeRack 特有
|
||||||
|
tube: Optional[TubeInfo] = None
|
||||||
|
|
||||||
|
# PlateAdapter 特有
|
||||||
|
adapter: Optional[AdapterInfo] = None
|
||||||
|
|
||||||
|
# 模板匹配
|
||||||
|
include_in_template_matching: bool = False
|
||||||
|
template_kind: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class LabwareDB(BaseModel):
|
||||||
|
"""整个 labware_db.json 的结构"""
|
||||||
|
version: str = "1.0"
|
||||||
|
items: List[LabwareItem] = Field(default_factory=list)
|
||||||
292
unilabos/labware_manager/static/form_handler.js
Normal file
292
unilabos/labware_manager/static/form_handler.js
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
/**
|
||||||
|
* form_handler.js — 动态表单逻辑 + 实时预览
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 根据类型显示/隐藏对应的表单段
|
||||||
|
function onTypeChange() {
|
||||||
|
const type = document.getElementById('f-type').value;
|
||||||
|
const sections = {
|
||||||
|
grid: ['plate', 'tip_rack', 'tube_rack'],
|
||||||
|
well: ['plate'],
|
||||||
|
tip: ['tip_rack'],
|
||||||
|
tube: ['tube_rack'],
|
||||||
|
adapter: ['plate_adapter'],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [sec, types] of Object.entries(sections)) {
|
||||||
|
const el = document.getElementById('section-' + sec);
|
||||||
|
if (el) el.style.display = types.includes(type) ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// plate_type 行只对 plate 显示
|
||||||
|
const ptRow = document.getElementById('row-plate_type');
|
||||||
|
if (ptRow) ptRow.style.display = type === 'plate' ? 'block' : 'none';
|
||||||
|
|
||||||
|
updatePreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从表单收集数据
|
||||||
|
function collectFormData() {
|
||||||
|
const g = id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return null;
|
||||||
|
if (el.type === 'checkbox') return el.checked;
|
||||||
|
if (el.type === 'number') return el.value === '' ? null : parseFloat(el.value);
|
||||||
|
return el.value || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const type = g('f-type');
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
type: type,
|
||||||
|
function_name: g('f-function_name') || 'PRCXI_new',
|
||||||
|
model: g('f-model'),
|
||||||
|
docstring: g('f-docstring') || '',
|
||||||
|
plate_type: type === 'plate' ? g('f-plate_type') : null,
|
||||||
|
size_x: g('f-size_x') || 127,
|
||||||
|
size_y: g('f-size_y') || 85,
|
||||||
|
size_z: g('f-size_z') || 20,
|
||||||
|
material_info: {
|
||||||
|
uuid: g('f-mi_uuid') || '',
|
||||||
|
Code: g('f-mi_code') || '',
|
||||||
|
Name: g('f-mi_name') || '',
|
||||||
|
materialEnum: g('f-mi_menum'),
|
||||||
|
SupplyType: g('f-mi_stype'),
|
||||||
|
},
|
||||||
|
registry_category: (g('f-reg_cat') || 'prcxi,plates').split(',').map(s => s.trim()),
|
||||||
|
registry_description: g('f-reg_desc') || '',
|
||||||
|
include_in_template_matching: g('f-in_tpl') || false,
|
||||||
|
template_kind: g('f-tpl_kind') || null,
|
||||||
|
grid: null,
|
||||||
|
well: null,
|
||||||
|
tip: null,
|
||||||
|
tube: null,
|
||||||
|
adapter: null,
|
||||||
|
volume_functions: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Grid
|
||||||
|
if (['plate', 'tip_rack', 'tube_rack'].includes(type)) {
|
||||||
|
data.grid = {
|
||||||
|
num_items_x: g('f-grid_nx') || 12,
|
||||||
|
num_items_y: g('f-grid_ny') || 8,
|
||||||
|
dx: g('f-grid_dx') || 0,
|
||||||
|
dy: g('f-grid_dy') || 0,
|
||||||
|
dz: g('f-grid_dz') || 0,
|
||||||
|
item_dx: g('f-grid_idx') || 9,
|
||||||
|
item_dy: g('f-grid_idy') || 9,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Well
|
||||||
|
if (type === 'plate') {
|
||||||
|
data.well = {
|
||||||
|
size_x: g('f-well_sx') || 8,
|
||||||
|
size_y: g('f-well_sy') || 8,
|
||||||
|
size_z: g('f-well_sz') || 10,
|
||||||
|
max_volume: g('f-well_vol'),
|
||||||
|
material_z_thickness: g('f-well_mzt'),
|
||||||
|
bottom_type: g('f-well_bt') || 'FLAT',
|
||||||
|
cross_section_type: g('f-well_cs') || 'CIRCLE',
|
||||||
|
};
|
||||||
|
if (g('f-has_vf')) {
|
||||||
|
data.volume_functions = {
|
||||||
|
type: 'rectangle',
|
||||||
|
well_length: data.well.size_x,
|
||||||
|
well_width: data.well.size_y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tip
|
||||||
|
if (type === 'tip_rack') {
|
||||||
|
data.tip = {
|
||||||
|
spot_size_x: g('f-tip_sx') || 7,
|
||||||
|
spot_size_y: g('f-tip_sy') || 7,
|
||||||
|
spot_size_z: g('f-tip_sz') || 0,
|
||||||
|
tip_volume: g('f-tip_vol') || 300,
|
||||||
|
tip_length: g('f-tip_len') || 60,
|
||||||
|
tip_fitting_depth: g('f-tip_dep') || 51,
|
||||||
|
tip_above_rack_length: g('f-tip_above'),
|
||||||
|
has_filter: g('f-tip_filter') || false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tube
|
||||||
|
if (type === 'tube_rack') {
|
||||||
|
data.tube = {
|
||||||
|
size_x: g('f-tube_sx') || 10.6,
|
||||||
|
size_y: g('f-tube_sy') || 10.6,
|
||||||
|
size_z: g('f-tube_sz') || 40,
|
||||||
|
max_volume: g('f-tube_vol') || 1500,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adapter
|
||||||
|
if (type === 'plate_adapter') {
|
||||||
|
data.adapter = {
|
||||||
|
adapter_hole_size_x: g('f-adp_hsx') || 127.76,
|
||||||
|
adapter_hole_size_y: g('f-adp_hsy') || 85.48,
|
||||||
|
adapter_hole_size_z: g('f-adp_hsz') || 10,
|
||||||
|
dx: g('f-adp_dx'),
|
||||||
|
dy: g('f-adp_dy'),
|
||||||
|
dz: g('f-adp_dz') || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 实时预览 (debounce)
|
||||||
|
let _previewTimer = null;
|
||||||
|
function updatePreview() {
|
||||||
|
if (_previewTimer) clearTimeout(_previewTimer);
|
||||||
|
_previewTimer = setTimeout(() => {
|
||||||
|
const data = collectFormData();
|
||||||
|
const topEl = document.getElementById('svg-topdown');
|
||||||
|
const sideEl = document.getElementById('svg-side');
|
||||||
|
if (topEl) renderTopDown(topEl, data);
|
||||||
|
if (sideEl) renderSideProfile(sideEl, data);
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 给所有表单元素绑定 input 事件
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const form = document.getElementById('labware-form');
|
||||||
|
if (!form) return;
|
||||||
|
form.addEventListener('input', updatePreview);
|
||||||
|
form.addEventListener('change', updatePreview);
|
||||||
|
|
||||||
|
// tip_above_rack_length 与 dz 互算
|
||||||
|
// 公式: tip_length = tip_above_rack_length + size_z - dz
|
||||||
|
// 规则: 填 tip_above → 自动算 dz;填 dz → 自动算 tip_above
|
||||||
|
// 改 size_z / tip_length → 优先重算 tip_above(若有值),否则算 dz
|
||||||
|
|
||||||
|
function _getVal(id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
return (el && el.value !== '') ? parseFloat(el.value) : null;
|
||||||
|
}
|
||||||
|
function _setVal(id, v) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.value = Math.round(v * 1000) / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoCalcTipAbove(changedId) {
|
||||||
|
const typeEl = document.getElementById('f-type');
|
||||||
|
if (!typeEl || typeEl.value !== 'tip_rack') return;
|
||||||
|
|
||||||
|
const tipLen = _getVal('f-tip_len');
|
||||||
|
const sizeZ = _getVal('f-size_z');
|
||||||
|
const dz = _getVal('f-grid_dz');
|
||||||
|
const above = _getVal('f-tip_above');
|
||||||
|
|
||||||
|
// 需要 tip_length 和 size_z 才能计算
|
||||||
|
if (tipLen == null || sizeZ == null) return;
|
||||||
|
|
||||||
|
if (changedId === 'f-tip_above') {
|
||||||
|
// 用户填了 tip_above → 算 dz
|
||||||
|
if (above != null) _setVal('f-grid_dz', above + sizeZ - tipLen);
|
||||||
|
} else if (changedId === 'f-grid_dz') {
|
||||||
|
// 用户填了 dz → 算 tip_above
|
||||||
|
if (dz != null) _setVal('f-tip_above', tipLen - sizeZ + dz);
|
||||||
|
} else {
|
||||||
|
// size_z 或 tip_length 变了 → 优先重算 tip_above(若已有值或 dz 已有值)
|
||||||
|
if (dz != null) {
|
||||||
|
_setVal('f-tip_above', tipLen - sizeZ + dz);
|
||||||
|
} else if (above != null) {
|
||||||
|
_setVal('f-grid_dz', above + sizeZ - tipLen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绑定 input 事件
|
||||||
|
for (const id of ['f-tip_len', 'f-size_z', 'f-grid_dz', 'f-tip_above']) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.addEventListener('input', () => autoCalcTipAbove(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑已有 tip_rack 条目时自动补算 tip_above_rack_length
|
||||||
|
const typeEl = document.getElementById('f-type');
|
||||||
|
if (typeEl && typeEl.value === 'tip_rack') {
|
||||||
|
autoCalcTipAbove('f-grid_dz');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 自动居中:根据板尺寸和孔阵列参数计算 dx/dy
|
||||||
|
function autoCenter() {
|
||||||
|
const g = id => { const el = document.getElementById(id); return el && el.value !== '' ? parseFloat(el.value) : 0; };
|
||||||
|
|
||||||
|
const sizeX = g('f-size_x') || 127;
|
||||||
|
const sizeY = g('f-size_y') || 85;
|
||||||
|
const nx = g('f-grid_nx') || 1;
|
||||||
|
const ny = g('f-grid_ny') || 1;
|
||||||
|
const itemDx = g('f-grid_idx') || 9;
|
||||||
|
const itemDy = g('f-grid_idy') || 9;
|
||||||
|
|
||||||
|
// 根据当前耗材类型确定子元素尺寸
|
||||||
|
const type = document.getElementById('f-type').value;
|
||||||
|
let childSx = 0, childSy = 0;
|
||||||
|
if (type === 'plate') {
|
||||||
|
childSx = g('f-well_sx') || 8;
|
||||||
|
childSy = g('f-well_sy') || 8;
|
||||||
|
} else if (type === 'tip_rack') {
|
||||||
|
childSx = g('f-tip_sx') || 7;
|
||||||
|
childSy = g('f-tip_sy') || 7;
|
||||||
|
} else if (type === 'tube_rack') {
|
||||||
|
childSx = g('f-tube_sx') || 10.6;
|
||||||
|
childSy = g('f-tube_sy') || 10.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// dx = (板宽 - 孔阵列总占宽) / 2
|
||||||
|
const dx = (sizeX - (nx - 1) * itemDx - childSx) / 2;
|
||||||
|
const dy = (sizeY - (ny - 1) * itemDy - childSy) / 2;
|
||||||
|
|
||||||
|
const elDx = document.getElementById('f-grid_dx');
|
||||||
|
const elDy = document.getElementById('f-grid_dy');
|
||||||
|
if (elDx) elDx.value = Math.round(dx * 100) / 100;
|
||||||
|
if (elDy) elDy.value = Math.round(dy * 100) / 100;
|
||||||
|
|
||||||
|
updatePreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存
|
||||||
|
function showMsg(text, ok) {
|
||||||
|
const el = document.getElementById('status-msg');
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = text;
|
||||||
|
el.className = 'status-msg ' + (ok ? 'msg-ok' : 'msg-err');
|
||||||
|
el.style.display = 'block';
|
||||||
|
setTimeout(() => el.style.display = 'none', 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveForm() {
|
||||||
|
const data = collectFormData();
|
||||||
|
|
||||||
|
let url, method;
|
||||||
|
if (typeof IS_NEW !== 'undefined' && IS_NEW) {
|
||||||
|
url = '/api/labware';
|
||||||
|
method = 'POST';
|
||||||
|
} else {
|
||||||
|
url = '/api/labware/' + (typeof ITEM_ID !== 'undefined' ? ITEM_ID : '');
|
||||||
|
method = 'PUT';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await fetch(url, {
|
||||||
|
method: method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.status === 'ok') {
|
||||||
|
showMsg('保存成功', true);
|
||||||
|
if (IS_NEW) {
|
||||||
|
setTimeout(() => location.href = '/labware/' + data.function_name, 500);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showMsg('保存失败: ' + JSON.stringify(d), false);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showMsg('请求错误: ' + e.message, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
450
unilabos/labware_manager/static/labware_viz.js
Normal file
450
unilabos/labware_manager/static/labware_viz.js
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
/**
|
||||||
|
* labware_viz.js — PRCXI 耗材 SVG 2D 可视化渲染引擎
|
||||||
|
*
|
||||||
|
* renderTopDown(container, itemData) — 俯视图
|
||||||
|
* renderSideProfile(container, itemData) — 侧面截面图
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TYPE_COLORS = {
|
||||||
|
plate: '#3b82f6',
|
||||||
|
tip_rack: '#10b981',
|
||||||
|
tube_rack: '#f59e0b',
|
||||||
|
trash: '#ef4444',
|
||||||
|
plate_adapter: '#8b5cf6',
|
||||||
|
};
|
||||||
|
|
||||||
|
function _svgNS() { return 'http://www.w3.org/2000/svg'; }
|
||||||
|
|
||||||
|
function _makeSVG(w, h) {
|
||||||
|
const svg = document.createElementNS(_svgNS(), 'svg');
|
||||||
|
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
|
||||||
|
svg.setAttribute('width', '100%');
|
||||||
|
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
||||||
|
svg.style.background = '#fff';
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _rect(svg, x, y, w, h, fill, stroke, rx) {
|
||||||
|
const r = document.createElementNS(_svgNS(), 'rect');
|
||||||
|
r.setAttribute('x', x); r.setAttribute('y', y);
|
||||||
|
r.setAttribute('width', w); r.setAttribute('height', h);
|
||||||
|
r.setAttribute('fill', fill || 'none');
|
||||||
|
r.setAttribute('stroke', stroke || '#333');
|
||||||
|
r.setAttribute('stroke-width', '0.5');
|
||||||
|
if (rx) r.setAttribute('rx', rx);
|
||||||
|
svg.appendChild(r);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _circle(svg, cx, cy, r, fill, stroke) {
|
||||||
|
const c = document.createElementNS(_svgNS(), 'circle');
|
||||||
|
c.setAttribute('cx', cx); c.setAttribute('cy', cy);
|
||||||
|
c.setAttribute('r', r);
|
||||||
|
c.setAttribute('fill', fill || 'none');
|
||||||
|
c.setAttribute('stroke', stroke || '#333');
|
||||||
|
c.setAttribute('stroke-width', '0.4');
|
||||||
|
svg.appendChild(c);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _text(svg, x, y, txt, size, anchor, fill) {
|
||||||
|
const t = document.createElementNS(_svgNS(), 'text');
|
||||||
|
t.setAttribute('x', x); t.setAttribute('y', y);
|
||||||
|
t.setAttribute('font-size', size || '3');
|
||||||
|
t.setAttribute('text-anchor', anchor || 'middle');
|
||||||
|
t.setAttribute('fill', fill || '#666');
|
||||||
|
t.setAttribute('font-family', 'sans-serif');
|
||||||
|
t.textContent = txt;
|
||||||
|
svg.appendChild(t);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _line(svg, x1, y1, x2, y2, stroke, dash) {
|
||||||
|
const l = document.createElementNS(_svgNS(), 'line');
|
||||||
|
l.setAttribute('x1', x1); l.setAttribute('y1', y1);
|
||||||
|
l.setAttribute('x2', x2); l.setAttribute('y2', y2);
|
||||||
|
l.setAttribute('stroke', stroke || '#999');
|
||||||
|
l.setAttribute('stroke-width', '0.3');
|
||||||
|
if (dash) l.setAttribute('stroke-dasharray', dash);
|
||||||
|
svg.appendChild(l);
|
||||||
|
return l;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _title(el, txt) {
|
||||||
|
const t = document.createElementNS(_svgNS(), 'title');
|
||||||
|
t.textContent = txt;
|
||||||
|
el.appendChild(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 俯视图 ====================
|
||||||
|
function renderTopDown(container, data) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
const pad = 18;
|
||||||
|
const sx = data.size_x || 127;
|
||||||
|
const sy = data.size_y || 85;
|
||||||
|
const w = sx + pad * 2;
|
||||||
|
const h = sy + pad * 2;
|
||||||
|
const svg = _makeSVG(w, h);
|
||||||
|
|
||||||
|
const color = TYPE_COLORS[data.type] || '#3b82f6';
|
||||||
|
const lightColor = color + '22';
|
||||||
|
|
||||||
|
// 板子外轮廓
|
||||||
|
_rect(svg, pad, pad, sx, sy, lightColor, color, 3);
|
||||||
|
|
||||||
|
// 尺寸标注
|
||||||
|
_text(svg, pad + sx / 2, pad - 4, `${sx} mm`, '3.5', 'middle', '#333');
|
||||||
|
// Y 尺寸 (竖直)
|
||||||
|
const yt = document.createElementNS(_svgNS(), 'text');
|
||||||
|
yt.setAttribute('x', pad - 5);
|
||||||
|
yt.setAttribute('y', pad + sy / 2);
|
||||||
|
yt.setAttribute('font-size', '3.5');
|
||||||
|
yt.setAttribute('text-anchor', 'middle');
|
||||||
|
yt.setAttribute('fill', '#333');
|
||||||
|
yt.setAttribute('font-family', 'sans-serif');
|
||||||
|
yt.setAttribute('transform', `rotate(-90, ${pad - 5}, ${pad + sy / 2})`);
|
||||||
|
yt.textContent = `${sy} mm`;
|
||||||
|
svg.appendChild(yt);
|
||||||
|
|
||||||
|
const grid = data.grid;
|
||||||
|
const well = data.well;
|
||||||
|
const tip = data.tip;
|
||||||
|
const tube = data.tube;
|
||||||
|
|
||||||
|
if (grid && (well || tip || tube)) {
|
||||||
|
const nx = grid.num_items_x || 1;
|
||||||
|
const ny = grid.num_items_y || 1;
|
||||||
|
const dx = grid.dx || 0;
|
||||||
|
const dy = grid.dy || 0;
|
||||||
|
const idx = grid.item_dx || 9;
|
||||||
|
const idy = grid.item_dy || 9;
|
||||||
|
|
||||||
|
const child = well || tip || tube;
|
||||||
|
const csx = child.size_x || child.spot_size_x || 8;
|
||||||
|
const csy = child.size_y || child.spot_size_y || 8;
|
||||||
|
|
||||||
|
const isCircle = well ? (well.cross_section_type === 'CIRCLE') : (!!tip);
|
||||||
|
|
||||||
|
// 行列标签
|
||||||
|
for (let col = 0; col < nx; col++) {
|
||||||
|
const cx = pad + dx + csx / 2 + col * idx;
|
||||||
|
_text(svg, cx, pad + sy + 5, String(col + 1), '2.5', 'middle', '#999');
|
||||||
|
}
|
||||||
|
const rowLabels = 'ABCDEFGHIJKLMNOP';
|
||||||
|
for (let row = 0; row < ny; row++) {
|
||||||
|
const cy = pad + dy + csy / 2 + row * idy;
|
||||||
|
_text(svg, pad - 4, cy + 1, rowLabels[row] || String(row), '2.5', 'middle', '#999');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制孔位
|
||||||
|
for (let col = 0; col < nx; col++) {
|
||||||
|
for (let row = 0; row < ny; row++) {
|
||||||
|
const cx = pad + dx + csx / 2 + col * idx;
|
||||||
|
const cy = pad + dy + csy / 2 + row * idy;
|
||||||
|
|
||||||
|
let el;
|
||||||
|
if (isCircle) {
|
||||||
|
const r = Math.min(csx, csy) / 2;
|
||||||
|
el = _circle(svg, cx, cy, r, '#fff', color);
|
||||||
|
} else {
|
||||||
|
el = _rect(svg, cx - csx / 2, cy - csy / 2, csx, csy, '#fff', color);
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = (rowLabels[row] || '') + String(col + 1);
|
||||||
|
_title(el, `${label}: ${csx}x${csy} mm`);
|
||||||
|
|
||||||
|
// hover 效果
|
||||||
|
el.style.cursor = 'pointer';
|
||||||
|
el.addEventListener('mouseenter', () => el.setAttribute('fill', color + '44'));
|
||||||
|
el.addEventListener('mouseleave', () => el.setAttribute('fill', '#fff'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dx / dy 标注(板边到首个子元素左上角)
|
||||||
|
const dimColor = '#e67e22';
|
||||||
|
const firstLeft = pad + dx; // 首列子元素左边 X
|
||||||
|
const firstTop = pad + dy; // 首行子元素上边 Y
|
||||||
|
if (dx > 0.1) {
|
||||||
|
// dx: 板左边 → 首列子元素左边,画在第一行子元素中心高度
|
||||||
|
const annY = firstTop + csy / 2;
|
||||||
|
_line(svg, pad, annY, firstLeft, annY, dimColor, '1,1');
|
||||||
|
_line(svg, pad, annY - 2, pad, annY + 2, dimColor);
|
||||||
|
_line(svg, firstLeft, annY - 2, firstLeft, annY + 2, dimColor);
|
||||||
|
_text(svg, pad + dx / 2, annY - 2, `dx=${dx}`, '2.5', 'middle', dimColor);
|
||||||
|
}
|
||||||
|
if (dy > 0.1) {
|
||||||
|
// dy: 板上边 → 首行子元素上边,画在第一列子元素中心宽度
|
||||||
|
const annX = firstLeft + csx / 2;
|
||||||
|
_line(svg, annX, pad, annX, firstTop, dimColor, '1,1');
|
||||||
|
_line(svg, annX - 2, pad, annX + 2, pad, dimColor);
|
||||||
|
_line(svg, annX - 2, firstTop, annX + 2, firstTop, dimColor);
|
||||||
|
_text(svg, annX + 4, pad + dy / 2 + 1, `dy=${dy}`, '2.5', 'start', dimColor);
|
||||||
|
}
|
||||||
|
} else if (data.type === 'plate_adapter' && data.adapter) {
|
||||||
|
// 绘制适配器凹槽
|
||||||
|
const adp = data.adapter;
|
||||||
|
const ahx = adp.adapter_hole_size_x || 127;
|
||||||
|
const ahy = adp.adapter_hole_size_y || 85;
|
||||||
|
const adx = adp.dx != null ? adp.dx : (sx - ahx) / 2;
|
||||||
|
const ady = adp.dy != null ? adp.dy : (sy - ahy) / 2;
|
||||||
|
_rect(svg, pad + adx, pad + ady, ahx, ahy, '#f0f0ff', '#8b5cf6', 2);
|
||||||
|
_text(svg, pad + adx + ahx / 2, pad + ady + ahy / 2, `${ahx}x${ahy}`, '4', 'middle', '#8b5cf6');
|
||||||
|
} else if (data.type === 'trash') {
|
||||||
|
// 简单标记
|
||||||
|
_text(svg, pad + sx / 2, pad + sy / 2, 'TRASH', '8', 'middle', '#ef4444');
|
||||||
|
}
|
||||||
|
|
||||||
|
container.appendChild(svg);
|
||||||
|
_enableZoomPan(svg, `0 0 ${w} ${h}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 侧面截面图 ====================
|
||||||
|
function renderSideProfile(container, data) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
const pad = 20;
|
||||||
|
const sx = data.size_x || 127;
|
||||||
|
const sz = data.size_z || 20;
|
||||||
|
|
||||||
|
// 按比例缩放,侧面以 X-Z 面
|
||||||
|
const scaleH = Math.max(1, sz / 60); // 让较矮的板子不会太小
|
||||||
|
|
||||||
|
// 计算枪头露出高度(仅 tip_rack)
|
||||||
|
const tip = data.tip;
|
||||||
|
const grid = data.grid;
|
||||||
|
let tipAbove = 0;
|
||||||
|
if (data.type === 'tip_rack' && tip) {
|
||||||
|
if (tip.tip_above_rack_length != null && tip.tip_above_rack_length > 0) {
|
||||||
|
tipAbove = tip.tip_above_rack_length;
|
||||||
|
} else if (tip.tip_length && grid) {
|
||||||
|
const dz = grid.dz || 0;
|
||||||
|
const calc = tip.tip_length - (sz - dz);
|
||||||
|
if (calc > 0) tipAbove = calc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawW = sx;
|
||||||
|
const drawH = sz;
|
||||||
|
const w = drawW + pad * 2 + 30; // 额外空间给标注
|
||||||
|
const h = drawH + tipAbove + pad * 2 + 10;
|
||||||
|
const svg = _makeSVG(w, h);
|
||||||
|
|
||||||
|
const color = TYPE_COLORS[data.type] || '#3b82f6';
|
||||||
|
const baseY = pad + tipAbove + drawH; // 底部 Y
|
||||||
|
const rackTopY = pad + tipAbove; // rack 顶部 Y
|
||||||
|
|
||||||
|
// 板壳矩形
|
||||||
|
_rect(svg, pad, rackTopY, drawW, drawH, color + '15', color);
|
||||||
|
|
||||||
|
// 尺寸标注
|
||||||
|
// X 方向
|
||||||
|
_line(svg, pad, baseY + 5, pad + drawW, baseY + 5, '#333');
|
||||||
|
_text(svg, pad + drawW / 2, baseY + 12, `${sx} mm`, '3.5', 'middle', '#333');
|
||||||
|
|
||||||
|
// Z 方向
|
||||||
|
_line(svg, pad + drawW + 5, rackTopY, pad + drawW + 5, baseY, '#333');
|
||||||
|
const zt = document.createElementNS(_svgNS(), 'text');
|
||||||
|
zt.setAttribute('x', pad + drawW + 12);
|
||||||
|
zt.setAttribute('y', rackTopY + drawH / 2);
|
||||||
|
zt.setAttribute('font-size', '3.5');
|
||||||
|
zt.setAttribute('text-anchor', 'middle');
|
||||||
|
zt.setAttribute('fill', '#333');
|
||||||
|
zt.setAttribute('font-family', 'sans-serif');
|
||||||
|
zt.setAttribute('transform', `rotate(-90, ${pad + drawW + 12}, ${rackTopY + drawH / 2})`);
|
||||||
|
zt.textContent = `${sz} mm`;
|
||||||
|
svg.appendChild(zt);
|
||||||
|
|
||||||
|
const well = data.well;
|
||||||
|
const tube = data.tube;
|
||||||
|
|
||||||
|
if (grid && (well || tip || tube)) {
|
||||||
|
const dx = grid.dx || 0;
|
||||||
|
const dz = grid.dz || 0;
|
||||||
|
const idx = grid.item_dx || 9;
|
||||||
|
const nx = Math.min(grid.num_items_x || 1, 24); // 最多画24列
|
||||||
|
const dimColor = '#e67e22';
|
||||||
|
|
||||||
|
const child = well || tube;
|
||||||
|
const childTip = tip;
|
||||||
|
|
||||||
|
if (child) {
|
||||||
|
const csx = child.size_x || 8;
|
||||||
|
const csz = child.size_z || 10;
|
||||||
|
const bt = well ? (well.bottom_type || 'FLAT') : 'FLAT';
|
||||||
|
|
||||||
|
// 画几个代表性的孔截面
|
||||||
|
const nDraw = Math.min(nx, 12);
|
||||||
|
for (let i = 0; i < nDraw; i++) {
|
||||||
|
const cx = pad + dx + csx / 2 + i * idx;
|
||||||
|
const topZ = baseY - dz - csz;
|
||||||
|
const botZ = baseY - dz;
|
||||||
|
|
||||||
|
// 孔壁
|
||||||
|
_rect(svg, cx - csx / 2, topZ, csx, csz, '#e0e8ff', color, 0.5);
|
||||||
|
|
||||||
|
// 底部形状
|
||||||
|
if (bt === 'V') {
|
||||||
|
// V 底 三角
|
||||||
|
const triH = Math.min(csx / 2, csz * 0.3);
|
||||||
|
const p = document.createElementNS(_svgNS(), 'polygon');
|
||||||
|
p.setAttribute('points',
|
||||||
|
`${cx - csx / 2},${botZ - triH} ${cx},${botZ} ${cx + csx / 2},${botZ - triH}`);
|
||||||
|
p.setAttribute('fill', color + '33');
|
||||||
|
p.setAttribute('stroke', color);
|
||||||
|
p.setAttribute('stroke-width', '0.3');
|
||||||
|
svg.appendChild(p);
|
||||||
|
} else if (bt === 'U') {
|
||||||
|
// U 底 圆弧
|
||||||
|
const arcR = csx / 2;
|
||||||
|
const p = document.createElementNS(_svgNS(), 'path');
|
||||||
|
p.setAttribute('d', `M ${cx - csx / 2} ${botZ - arcR} A ${arcR} ${arcR} 0 0 0 ${cx + csx / 2} ${botZ - arcR}`);
|
||||||
|
p.setAttribute('fill', color + '33');
|
||||||
|
p.setAttribute('stroke', color);
|
||||||
|
p.setAttribute('stroke-width', '0.3');
|
||||||
|
svg.appendChild(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dz 标注
|
||||||
|
if (dz > 0) {
|
||||||
|
const lx = pad + dx + 0.5 * idx * nDraw + csx / 2 + 5;
|
||||||
|
_line(svg, lx, baseY, lx, baseY - dz, dimColor, '1,1');
|
||||||
|
_line(svg, lx - 2, baseY, lx + 2, baseY, dimColor);
|
||||||
|
_line(svg, lx - 2, baseY - dz, lx + 2, baseY - dz, dimColor);
|
||||||
|
_text(svg, lx + 4, baseY - dz / 2 + 1, `dz=${dz}`, '2.5', 'start', dimColor);
|
||||||
|
}
|
||||||
|
// dx 标注
|
||||||
|
if (dx > 0.1) {
|
||||||
|
const annY = rackTopY + 4;
|
||||||
|
_line(svg, pad, annY, pad + dx, annY, dimColor, '1,1');
|
||||||
|
_line(svg, pad, annY - 2, pad, annY + 2, dimColor);
|
||||||
|
_line(svg, pad + dx, annY - 2, pad + dx, annY + 2, dimColor);
|
||||||
|
_text(svg, pad + dx / 2, annY - 2, `dx=${dx}`, '2.5', 'middle', dimColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (childTip) {
|
||||||
|
// 枪头截面
|
||||||
|
const tipLen = childTip.tip_length || 50;
|
||||||
|
const nDraw = Math.min(nx, 12);
|
||||||
|
for (let i = 0; i < nDraw; i++) {
|
||||||
|
const cx = pad + dx + 3.5 + i * idx;
|
||||||
|
// 枪头顶部 = rack顶部 - 露出长度
|
||||||
|
const tipTopZ = rackTopY - tipAbove;
|
||||||
|
const drawLen = Math.min(tipLen, sz - dz + tipAbove);
|
||||||
|
|
||||||
|
// 枪头轮廓 (梯形)
|
||||||
|
const topW = 4;
|
||||||
|
const botW = 1.5;
|
||||||
|
const p = document.createElementNS(_svgNS(), 'polygon');
|
||||||
|
p.setAttribute('points',
|
||||||
|
`${cx - topW / 2},${tipTopZ} ${cx + topW / 2},${tipTopZ} ${cx + botW / 2},${tipTopZ + drawLen} ${cx - botW / 2},${tipTopZ + drawLen}`);
|
||||||
|
p.setAttribute('fill', '#10b98133');
|
||||||
|
p.setAttribute('stroke', '#10b981');
|
||||||
|
p.setAttribute('stroke-width', '0.3');
|
||||||
|
svg.appendChild(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
// dz 标注
|
||||||
|
if (dz > 0) {
|
||||||
|
const lx = pad + dx + nDraw * idx + 5;
|
||||||
|
_line(svg, lx, baseY, lx, baseY - dz, dimColor, '1,1');
|
||||||
|
_line(svg, lx - 2, baseY, lx + 2, baseY, dimColor);
|
||||||
|
_line(svg, lx - 2, baseY - dz, lx + 2, baseY - dz, dimColor);
|
||||||
|
_text(svg, lx + 4, baseY - dz / 2 + 1, `dz=${dz}`, '2.5', 'start', dimColor);
|
||||||
|
}
|
||||||
|
// dx 标注
|
||||||
|
if (dx > 0.1) {
|
||||||
|
const annY = rackTopY + 4;
|
||||||
|
_line(svg, pad, annY, pad + dx, annY, dimColor, '1,1');
|
||||||
|
_line(svg, pad, annY - 2, pad, annY + 2, dimColor);
|
||||||
|
_line(svg, pad + dx, annY - 2, pad + dx, annY + 2, dimColor);
|
||||||
|
_text(svg, pad + dx / 2, annY - 2, `dx=${dx}`, '2.5', 'middle', dimColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 露出长度标注线
|
||||||
|
if (tipAbove > 0) {
|
||||||
|
const annotX = pad + dx + nDraw * idx + 8;
|
||||||
|
// rack 顶部水平参考线
|
||||||
|
_line(svg, annotX - 3, rackTopY, annotX + 3, rackTopY, '#10b981');
|
||||||
|
// 枪头顶部水平参考线
|
||||||
|
_line(svg, annotX - 3, rackTopY - tipAbove, annotX + 3, rackTopY - tipAbove, '#10b981');
|
||||||
|
// 竖直标注线
|
||||||
|
_line(svg, annotX, rackTopY - tipAbove, annotX, rackTopY, '#10b981', '1,1');
|
||||||
|
_text(svg, annotX + 2, rackTopY - tipAbove / 2 + 1, `露出=${Math.round(tipAbove * 100) / 100}mm`, '2.5', 'start', '#10b981');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (data.type === 'plate_adapter' && data.adapter) {
|
||||||
|
const adp = data.adapter;
|
||||||
|
const ahz = adp.adapter_hole_size_z || 10;
|
||||||
|
const adz = adp.dz || 0;
|
||||||
|
const adx_val = adp.dx != null ? adp.dx : (sx - (adp.adapter_hole_size_x || 127)) / 2;
|
||||||
|
const ahx = adp.adapter_hole_size_x || 127;
|
||||||
|
|
||||||
|
// 凹槽截面
|
||||||
|
_rect(svg, pad + adx_val, rackTopY + adz, ahx, ahz, '#ede9fe', '#8b5cf6');
|
||||||
|
_text(svg, pad + adx_val + ahx / 2, rackTopY + adz + ahz / 2 + 1, `hole: ${ahz}mm deep`, '3', 'middle', '#8b5cf6');
|
||||||
|
} else if (data.type === 'trash') {
|
||||||
|
_text(svg, pad + drawW / 2, rackTopY + drawH / 2, 'TRASH', '8', 'middle', '#ef4444');
|
||||||
|
}
|
||||||
|
|
||||||
|
container.appendChild(svg);
|
||||||
|
_enableZoomPan(svg, `0 0 ${w} ${h}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 缩放 & 平移 ====================
|
||||||
|
function _enableZoomPan(svgEl, origViewBox) {
|
||||||
|
const parts = origViewBox.split(' ').map(Number);
|
||||||
|
let vx = parts[0], vy = parts[1], vw = parts[2], vh = parts[3];
|
||||||
|
const origVx = vx, origVy = vy, origW = vw, origH = vh;
|
||||||
|
const MIN_SCALE = 0.5, MAX_SCALE = 5;
|
||||||
|
|
||||||
|
function applyViewBox() {
|
||||||
|
svgEl.setAttribute('viewBox', `${vx} ${vy} ${vw} ${vh}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetView() {
|
||||||
|
vx = origVx; vy = origVy; vw = origW; vh = origH;
|
||||||
|
applyViewBox();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将 resetView 挂到 svg 元素上,方便外部调用
|
||||||
|
svgEl._resetView = resetView;
|
||||||
|
|
||||||
|
svgEl.addEventListener('wheel', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.ctrlKey) {
|
||||||
|
// pinch / ctrl+scroll → 缩放
|
||||||
|
const factor = e.deltaY > 0 ? 1.08 : 1 / 1.08;
|
||||||
|
const newW = vw * factor;
|
||||||
|
const newH = vh * factor;
|
||||||
|
// 限制缩放范围
|
||||||
|
if (newW < origW / MAX_SCALE || newW > origW * (1 / MIN_SCALE)) return;
|
||||||
|
// 以鼠标位置为缩放中心
|
||||||
|
const rect = svgEl.getBoundingClientRect();
|
||||||
|
const mx = (e.clientX - rect.left) / rect.width;
|
||||||
|
const my = (e.clientY - rect.top) / rect.height;
|
||||||
|
vx += (vw - newW) * mx;
|
||||||
|
vy += (vh - newH) * my;
|
||||||
|
vw = newW;
|
||||||
|
vh = newH;
|
||||||
|
} else {
|
||||||
|
// 普通滚轮 → 平移
|
||||||
|
const panSpeed = vw * 0.002;
|
||||||
|
vx += e.deltaX * panSpeed;
|
||||||
|
vy += e.deltaY * panSpeed;
|
||||||
|
}
|
||||||
|
applyViewBox();
|
||||||
|
}, { passive: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回中按钮:重置指定容器内 SVG 的 viewBox
|
||||||
|
function resetSvgView(containerId) {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
if (!container) return;
|
||||||
|
const svg = container.querySelector('svg');
|
||||||
|
if (svg && svg._resetView) svg._resetView();
|
||||||
|
}
|
||||||
295
unilabos/labware_manager/static/style.css
Normal file
295
unilabos/labware_manager/static/style.css
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
/* PRCXI 耗材管理 - 全局样式 */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--c-primary: #3b82f6;
|
||||||
|
--c-primary-dark: #2563eb;
|
||||||
|
--c-danger: #ef4444;
|
||||||
|
--c-warning: #f59e0b;
|
||||||
|
--c-success: #10b981;
|
||||||
|
--c-gray-50: #f9fafb;
|
||||||
|
--c-gray-100: #f3f4f6;
|
||||||
|
--c-gray-200: #e5e7eb;
|
||||||
|
--c-gray-300: #d1d5db;
|
||||||
|
--c-gray-500: #6b7280;
|
||||||
|
--c-gray-700: #374151;
|
||||||
|
--c-gray-900: #111827;
|
||||||
|
--radius: 8px;
|
||||||
|
--shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--c-gray-50);
|
||||||
|
color: var(--c-gray-900);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部导航 */
|
||||||
|
.topbar {
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid var(--c-gray-200);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 24px;
|
||||||
|
height: 56px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.topbar .logo {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--c-gray-900);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 容器 */
|
||||||
|
.container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 页头 */
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.page-header h1 { font-size: 1.5rem; }
|
||||||
|
.header-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* 按钮 */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn-sm { padding: 4px 10px; font-size: 0.8rem; }
|
||||||
|
.btn-primary { background: var(--c-primary); color: #fff; }
|
||||||
|
.btn-primary:hover { background: var(--c-primary-dark); }
|
||||||
|
.btn-outline { background: #fff; color: var(--c-gray-700); border-color: var(--c-gray-300); }
|
||||||
|
.btn-outline:hover { background: var(--c-gray-100); }
|
||||||
|
.btn-danger { background: var(--c-danger); color: #fff; }
|
||||||
|
.btn-danger:hover { background: #dc2626; }
|
||||||
|
.btn-warning { background: var(--c-warning); color: #fff; }
|
||||||
|
.btn-warning:hover { background: #d97706; }
|
||||||
|
|
||||||
|
/* 徽章 */
|
||||||
|
.badge {
|
||||||
|
background: var(--c-gray-200);
|
||||||
|
color: var(--c-gray-700);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 状态消息 */
|
||||||
|
.status-msg {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.msg-ok { background: #d1fae5; color: #065f46; }
|
||||||
|
.msg-err { background: #fee2e2; color: #991b1b; }
|
||||||
|
|
||||||
|
/* 类型分段 */
|
||||||
|
.type-section { margin-bottom: 32px; }
|
||||||
|
.type-section h2 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.type-dot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 卡片网格 */
|
||||||
|
.card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 耗材卡片 */
|
||||||
|
.labware-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--c-gray-200);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: box-shadow 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
.labware-card:hover {
|
||||||
|
border-color: var(--c-primary);
|
||||||
|
box-shadow: 0 4px 12px rgba(59,130,246,0.15);
|
||||||
|
}
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.card-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--c-gray-900);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.card-body { font-size: 0.85rem; color: var(--c-gray-500); }
|
||||||
|
.card-info { margin-bottom: 2px; }
|
||||||
|
.card-info .label { color: var(--c-gray-700); font-weight: 500; }
|
||||||
|
.card-footer {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
border-top: 1px solid var(--c-gray-100);
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标签 */
|
||||||
|
.tag {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.tag-tpl { background: #dbeafe; color: #1e40af; }
|
||||||
|
.tag-plate { background: #dbeafe; color: #1e40af; }
|
||||||
|
.tag-tip_rack { background: #d1fae5; color: #065f46; }
|
||||||
|
.tag-trash { background: #fee2e2; color: #991b1b; }
|
||||||
|
.tag-tube_rack { background: #fef3c7; color: #92400e; }
|
||||||
|
.tag-plate_adapter { background: #ede9fe; color: #5b21b6; }
|
||||||
|
|
||||||
|
/* 详情页布局 */
|
||||||
|
.detail-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.detail-layout { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
.detail-info, .detail-viz { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
|
||||||
|
.info-card, .viz-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--c-gray-200);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.info-card h3, .viz-card h3 {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: var(--c-gray-700);
|
||||||
|
border-bottom: 1px solid var(--c-gray-100);
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-table { width: 100%; font-size: 0.85rem; }
|
||||||
|
.info-table td { padding: 4px 8px; border-bottom: 1px solid var(--c-gray-100); }
|
||||||
|
.info-table .label { color: var(--c-gray-500); font-weight: 500; width: 140px; }
|
||||||
|
.info-table code { background: var(--c-gray-100); padding: 1px 4px; border-radius: 3px; font-size: 0.8rem; }
|
||||||
|
.info-table code.small { font-size: 0.7rem; }
|
||||||
|
|
||||||
|
/* SVG 容器 */
|
||||||
|
#svg-topdown, #svg-side {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#svg-topdown svg, #svg-side svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 编辑页布局 */
|
||||||
|
.edit-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.edit-layout { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
.edit-form { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.edit-preview { display: flex; flex-direction: column; gap: 16px; position: sticky; top: 72px; align-self: start; }
|
||||||
|
|
||||||
|
/* 表单 */
|
||||||
|
.form-section {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--c-gray-200);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.form-section h3 {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: var(--c-gray-700);
|
||||||
|
}
|
||||||
|
.form-row { margin-bottom: 10px; }
|
||||||
|
.form-row label { display: block; font-size: 0.8rem; color: var(--c-gray-500); margin-bottom: 4px; font-weight: 500; }
|
||||||
|
.form-row input, .form-row select, .form-row textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--c-gray-300);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.form-row input:focus, .form-row select:focus, .form-row textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--c-primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
|
||||||
|
}
|
||||||
|
.form-row-2, .form-row-3 { display: grid; gap: 12px; margin-bottom: 10px; }
|
||||||
|
.form-row-2 { grid-template-columns: 1fr 1fr; }
|
||||||
|
.form-row-3 { grid-template-columns: 1fr 1fr 1fr; }
|
||||||
|
.form-row-2 label, .form-row-3 label { display: block; font-size: 0.8rem; color: var(--c-gray-500); margin-bottom: 4px; font-weight: 500; }
|
||||||
|
.form-row-2 input, .form-row-2 select,
|
||||||
|
.form-row-3 input, .form-row-3 select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--c-gray-300);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.form-row-2 input:focus, .form-row-3 input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--c-primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 双语标签中文部分 */
|
||||||
|
.label-cn { color: var(--c-gray-400, #9ca3af); font-weight: 400; margin-left: 4px; }
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user