mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-05-25 11:49:59 +00:00
Compare commits
247 Commits
a5b5497dd0
...
prcix9320
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5be601177e | ||
|
|
ad05e8c73e | ||
|
|
940abc3664 | ||
|
|
6288e37464 | ||
|
|
3aed75bc8b | ||
|
|
acb2dc9359 | ||
|
|
f22c3f4c42 | ||
|
|
7df67ea9f3 | ||
|
|
4d3a41ed0d | ||
|
|
58997f0654 | ||
|
|
fbfc3e30fb | ||
|
|
1d1c1367df | ||
|
|
56d25b88bd | ||
|
|
95f3e0b291 | ||
|
|
c91b600e90 | ||
|
|
49b3c850f9 | ||
|
|
9b706236f6 | ||
|
|
9f60e65b6d | ||
|
|
59aa991988 | ||
|
|
aff340de84 | ||
|
|
25c94af755 | ||
|
|
2fd4270831 | ||
|
|
0d41d83ce5 | ||
|
|
9a6f744afd | ||
|
|
8164d990cc | ||
|
|
5c9c8a4ee9 | ||
|
|
68ef739f4a | ||
|
|
29a484f16f | ||
|
|
a48985720c | ||
|
|
14cf4ddc0d | ||
|
|
ad66fc1841 | ||
|
|
6b3f9756a0 | ||
|
|
afddc6e40c | ||
|
|
861a012747 | ||
|
|
edd67e4880 | ||
|
|
d13d3f7dfe | ||
|
|
ee63e95f50 | ||
|
|
1ab1ed69d4 | ||
|
|
ad2e5a1c04 | ||
|
|
71d35d31af | ||
|
|
7f4b57f589 | ||
|
|
0c667e68e6 | ||
|
|
9430be51a4 | ||
|
|
a187a57430 | ||
|
|
68029217de | ||
|
|
792504e08c | ||
|
|
dbf5df6e4d | ||
|
|
f10c0343ce | ||
|
|
8b6553bdd9 | ||
|
|
04c0564366 | ||
|
|
e7a4afd6b5 | ||
|
|
f18f6d82fc | ||
|
|
b7c726635c | ||
|
|
c809912fd3 | ||
|
|
d956b27e9f | ||
|
|
ff1e21fcd8 | ||
|
|
b9d9666003 | ||
|
|
d776550a4b | ||
|
|
3d8123849a | ||
|
|
9d65718f37 | ||
|
|
35bcf6765d | ||
|
|
d2f204c5b0 | ||
|
|
d8922884b1 | ||
|
|
427afe83d4 | ||
|
|
23c2e3b2f7 | ||
|
|
59c26265e9 | ||
|
|
4c2adea55a | ||
|
|
0f6264503a | ||
|
|
2c554182d3 | ||
|
|
cdbca70222 | ||
|
|
1a267729e4 | ||
|
|
b11f6eac55 | ||
|
|
d85ff540c4 | ||
|
|
6d319d91ff | ||
|
|
3155b2f97e | ||
|
|
e5e30a1c7d | ||
|
|
4e82f62327 | ||
|
|
95d3456214 | ||
|
|
38bf95b13c | ||
|
|
5f45a0b81b | ||
|
|
f2c0bec02c | ||
|
|
e0394bf414 | ||
|
|
975a56415a | ||
|
|
cadbe87e3f | ||
|
|
b993c1f590 | ||
|
|
e0fae94c10 | ||
|
|
b5cd181ac1 | ||
|
|
6bf9a319c7 | ||
|
|
5c047beb83 | ||
|
|
b40c087143 | ||
|
|
74f0d5ee65 | ||
|
|
7f1cc3b2a5 | ||
|
|
2596d48a2f | ||
|
|
3f160c2049 | ||
|
|
2ac1a3242a | ||
|
|
5d208c832b | ||
|
|
786498904d | ||
|
|
a9ea9f425d | ||
|
|
b3bc951cae | ||
|
|
01df4f1115 | ||
|
|
a54e7c0f23 | ||
|
|
e5015cd5e0 | ||
|
|
ca985f92ab | ||
|
|
514373c164 | ||
|
|
41be9e4e19 | ||
|
|
fcea02585a | ||
|
|
e1074f06d2 | ||
|
|
0dc273f366 | ||
|
|
2e5fac26b3 | ||
|
|
07cf690897 | ||
|
|
cfea27460a | ||
|
|
b7d3e980a9 | ||
|
|
5c2da9b793 | ||
|
|
45efbfcd12 | ||
|
|
8da6fdfd0b | ||
|
|
29ea9909a5 | ||
|
|
f9ed6cb3fb | ||
|
|
f38f3dfc89 | ||
|
|
699a0b3ce7 | ||
|
|
cf3a20ae79 | ||
|
|
ee6307a568 | ||
|
|
8a0116c852 | ||
|
|
cdf0652020 | ||
|
|
60073ff139 | ||
|
|
d3f59913b0 | ||
|
|
a9053b822f | ||
|
|
d238c2ab8b | ||
|
|
9a7d5c7c82 | ||
|
|
f6d46e669d | ||
|
|
abf5555e37 | ||
|
|
4f7d431c0b | ||
|
|
341a1b537c | ||
|
|
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.17
|
version: 0.11.1
|
||||||
|
|
||||||
source:
|
source:
|
||||||
path: ../../unilabos
|
path: ../../unilabos
|
||||||
@@ -46,13 +46,15 @@ requirements:
|
|||||||
- jinja2
|
- jinja2
|
||||||
- requests
|
- requests
|
||||||
- uvicorn
|
- uvicorn
|
||||||
- opcua # [not osx]
|
- if: not osx
|
||||||
|
then:
|
||||||
|
- opcua
|
||||||
- pyserial
|
- pyserial
|
||||||
- pandas
|
- pandas
|
||||||
- pymodbus
|
- pymodbus
|
||||||
- matplotlib
|
- matplotlib
|
||||||
- pylibftdi
|
- pylibftdi
|
||||||
- uni-lab::unilabos-env ==0.10.17
|
- 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.17
|
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.17
|
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.17
|
- 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
|
||||||
196
.cursor/skills/add-device/SKILL.md
Normal file
196
.cursor/skills/add-device/SKILL.md
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
---
|
||||||
|
name: add-device
|
||||||
|
description: Guide for adding new devices to Uni-Lab-OS (接入新设备). Uses @device decorator + AST auto-scanning instead of manual YAML. Walks through device category, communication protocol, driver creation with decorators, and graph file setup. Use when the user wants to add/integrate a new device, create a device driver, write a device class, or mentions 接入设备/添加设备/设备驱动/物模型.
|
||||||
|
---
|
||||||
|
|
||||||
|
# 添加新设备到 Uni-Lab-OS
|
||||||
|
|
||||||
|
**第一步:** 使用 Read 工具读取 `docs/ai_guides/add_device.md`,获取完整的设备接入指南。
|
||||||
|
|
||||||
|
该指南包含设备类别(物模型)列表、通信协议模板、常见错误检查清单等。搜索 `unilabos/devices/` 获取已有设备的实现参考。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 装饰器参考
|
||||||
|
|
||||||
|
### @device — 设备类装饰器
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.registry.decorators import device
|
||||||
|
|
||||||
|
# 单设备
|
||||||
|
@device(
|
||||||
|
id="my_device.vendor", # 注册表唯一标识(必填)
|
||||||
|
category=["temperature"], # 分类标签列表(必填)
|
||||||
|
description="设备描述", # 设备描述
|
||||||
|
display_name="显示名称", # UI 显示名称(默认用 id)
|
||||||
|
icon="DeviceIcon.webp", # 图标文件名
|
||||||
|
version="1.0.0", # 版本号
|
||||||
|
device_type="python", # "python" 或 "ros2"
|
||||||
|
handles=[...], # 端口列表(InputHandle / OutputHandle)
|
||||||
|
model={...}, # 3D 模型配置
|
||||||
|
hardware_interface=HardwareInterface(...), # 硬件通信接口
|
||||||
|
)
|
||||||
|
|
||||||
|
# 多设备(同一个类注册多个设备 ID,各自有不同的 handles 等配置)
|
||||||
|
@device(
|
||||||
|
ids=["pump.vendor.model_A", "pump.vendor.model_B"],
|
||||||
|
id_meta={
|
||||||
|
"pump.vendor.model_A": {"handles": [...], "description": "型号 A"},
|
||||||
|
"pump.vendor.model_B": {"handles": [...], "description": "型号 B"},
|
||||||
|
},
|
||||||
|
category=["pump_and_valve"],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### @action — 动作方法装饰器
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.registry.decorators import action
|
||||||
|
|
||||||
|
@action # 无参:注册为 UniLabJsonCommand 动作
|
||||||
|
@action() # 同上
|
||||||
|
@action(description="执行操作") # 带描述
|
||||||
|
@action(
|
||||||
|
action_type=HeatChill, # 指定 ROS Action 消息类型
|
||||||
|
goal={"temperature": "temp"}, # Goal 字段映射
|
||||||
|
feedback={}, # Feedback 字段映射
|
||||||
|
result={}, # Result 字段映射
|
||||||
|
handles=[...], # 动作级别端口
|
||||||
|
goal_default={"temp": 25.0}, # Goal 默认值
|
||||||
|
placeholder_keys={...}, # 参数占位符
|
||||||
|
always_free=True, # 不受排队限制
|
||||||
|
auto_prefix=True, # 强制使用 auto- 前缀
|
||||||
|
parent=True, # 从父类 MRO 获取参数签名
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**自动识别规则:**
|
||||||
|
- 带 `@action` 的公开方法 → 注册为动作(方法名即动作名)
|
||||||
|
- **不带 `@action` 的公开方法** → 自动注册为 `auto-{方法名}` 动作
|
||||||
|
- `_` 开头的方法 → 不扫描
|
||||||
|
- `@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 — 状态属性配置
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.registry.decorators import topic_config
|
||||||
|
|
||||||
|
@property
|
||||||
|
@topic_config(
|
||||||
|
period=5.0, # 发布周期(秒),默认 5.0
|
||||||
|
print_publish=False, # 是否打印发布日志
|
||||||
|
qos=10, # QoS 深度,默认 10
|
||||||
|
name="custom_name", # 自定义发布名称(默认用属性名)
|
||||||
|
)
|
||||||
|
def temperature(self) -> float:
|
||||||
|
return self.data.get("temperature", 0.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 辅助装饰器
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.registry.decorators import not_action, always_free
|
||||||
|
|
||||||
|
@not_action # 标记为非动作(post_init、辅助方法等)
|
||||||
|
@always_free # 标记为不受排队限制(查询类操作)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 设备模板
|
||||||
|
|
||||||
|
```python
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||||
|
from unilabos.registry.decorators import action, device, not_action, topic_config
|
||||||
|
|
||||||
|
@device(
|
||||||
|
id="my_device",
|
||||||
|
category=["my_category"],
|
||||||
|
description="设备描述",
|
||||||
|
display_name="设备显示名",
|
||||||
|
)
|
||||||
|
class MyDevice:
|
||||||
|
"""设备类说明。"""
|
||||||
|
|
||||||
|
_ros_node: BaseROS2DeviceNode
|
||||||
|
|
||||||
|
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.config = config or {}
|
||||||
|
self.logger = logging.getLogger(f"MyDevice.{self.device_id}")
|
||||||
|
self.data: Dict[str, Any] = {"status": "Idle"}
|
||||||
|
|
||||||
|
@not_action
|
||||||
|
def post_init(self, ros_node: BaseROS2DeviceNode) -> None:
|
||||||
|
self._ros_node = ros_node
|
||||||
|
|
||||||
|
@action
|
||||||
|
async def initialize(self) -> bool:
|
||||||
|
self.data["status"] = "Ready"
|
||||||
|
return True
|
||||||
|
|
||||||
|
@action
|
||||||
|
async def cleanup(self) -> bool:
|
||||||
|
self.data["status"] = "Offline"
|
||||||
|
return True
|
||||||
|
|
||||||
|
@action(description="执行操作")
|
||||||
|
def my_action(self, param: float = 0.0, name: str = "") -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
带 @action 装饰器 → 注册为 'my_action' 动作。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
param[操作数值]: 操作使用的数值参数。
|
||||||
|
name[操作名称]: 操作名称或备注。
|
||||||
|
"""
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
def get_info(self) -> Dict[str, Any]:
|
||||||
|
"""无 @action → 自动注册为 'auto-get_info' 动作"""
|
||||||
|
return {"device_id": self.device_id}
|
||||||
|
|
||||||
|
@property
|
||||||
|
@topic_config()
|
||||||
|
def status(self) -> str:
|
||||||
|
return self.data.get("status", "Idle")
|
||||||
|
|
||||||
|
@property
|
||||||
|
@topic_config(period=2.0)
|
||||||
|
def temperature(self) -> float:
|
||||||
|
return self.data.get("temperature", 0.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 要点
|
||||||
|
|
||||||
|
- `_ros_node: BaseROS2DeviceNode` 类型标注放在类体顶部
|
||||||
|
- `__init__` 签名固定为 `(self, device_id=None, config=None, **kwargs)`
|
||||||
|
- `post_init` 用 `@not_action` 标记,参数类型标注为 `BaseROS2DeviceNode`
|
||||||
|
- 运行时状态存储在 `self.data` 字典中
|
||||||
|
- 设备文件放在 `unilabos/devices/<category>/` 目录下
|
||||||
351
.cursor/skills/add-resource/SKILL.md
Normal file
351
.cursor/skills/add-resource/SKILL.md
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
---
|
||||||
|
name: add-resource
|
||||||
|
description: Guide for adding new resources (materials, bottles, carriers, decks, warehouses) to Uni-Lab-OS (添加新物料/资源). Uses @resource decorator for AST auto-scanning. Covers Bottle, Carrier, Deck, WareHouse definitions. Use when the user wants to add resources, define materials, create a deck layout, add bottles/carriers/plates, or mentions 物料/资源/resource/bottle/carrier/deck/plate/warehouse.
|
||||||
|
---
|
||||||
|
|
||||||
|
# 添加新物料资源
|
||||||
|
|
||||||
|
Uni-Lab-OS 的资源体系基于 PyLabRobot,通过扩展实现 Bottle、Carrier、WareHouse、Deck 等实验室物料管理。使用 `@resource` 装饰器注册,AST 自动扫描生成注册表条目。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 资源类型
|
||||||
|
|
||||||
|
| 类型 | 基类 | 用途 | 示例 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| **Bottle** | `Well` (PyLabRobot) | 单个容器(瓶、小瓶、烧杯、反应器) | 试剂瓶、粉末瓶 |
|
||||||
|
| **BottleCarrier** | `ItemizedCarrier` | 多槽位载架(放多个 Bottle) | 6 位试剂架、枪头盒 |
|
||||||
|
| **WareHouse** | `ItemizedCarrier` | 堆栈/仓库(放多个 Carrier) | 4x4 堆栈 |
|
||||||
|
| **Deck** | `Deck` (PyLabRobot) | 工作站台面(放多个 WareHouse) | 反应站 Deck |
|
||||||
|
|
||||||
|
**层级关系:** `Deck` → `WareHouse` → `BottleCarrier` → `Bottle`
|
||||||
|
|
||||||
|
WareHouse 本质上和 Site 是同一概念 — 都是定义一组固定的放置位(slot),只不过 WareHouse 多嵌套了一层 Deck。两者都需要开发者根据实际物理尺寸自行计算各 slot 的偏移坐标。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## @resource 装饰器
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.registry.decorators import resource
|
||||||
|
|
||||||
|
@resource(
|
||||||
|
id="my_resource_id", # 注册表唯一标识(必填)
|
||||||
|
category=["bottles"], # 分类标签列表(必填)
|
||||||
|
description="资源描述",
|
||||||
|
icon="", # 图标
|
||||||
|
version="1.0.0",
|
||||||
|
handles=[...], # 端口列表(InputHandle / OutputHandle)
|
||||||
|
model={...}, # 3D 模型配置
|
||||||
|
class_type="pylabrobot", # "python" / "pylabrobot" / "unilabos"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 创建规范
|
||||||
|
|
||||||
|
### 命名规则
|
||||||
|
|
||||||
|
1. **`name` 参数作为前缀**:所有工厂函数必须接受 `name: str` 参数,创建子物料时以 `name` 作为前缀,确保实例名在运行时全局唯一
|
||||||
|
2. **Bottle 命名约定**:试剂瓶-Bottle,烧杯-Beaker,烧瓶-Flask,小瓶-Vial
|
||||||
|
3. **函数名 = `@resource(id=...)`**:工厂函数名与注册表 id 保持一致
|
||||||
|
|
||||||
|
### 子物料命名示例
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Carrier 内部的 sites 用 name 前缀
|
||||||
|
for k, v in sites.items():
|
||||||
|
v.name = f"{name}_{v.name}" # "堆栈1左_A01", "堆栈1左_B02" ...
|
||||||
|
|
||||||
|
# Carrier 中放置 Bottle 时用 name 前缀
|
||||||
|
carrier[0] = My_Reagent_Bottle(f"{name}_flask_1") # "堆栈1左_flask_1"
|
||||||
|
carrier[i] = My_Solid_Vial(f"{name}_vial_{ordering[i]}") # "堆栈1左_vial_A1"
|
||||||
|
|
||||||
|
# create_homogeneous_resources 使用 name_prefix
|
||||||
|
sites=create_homogeneous_resources(
|
||||||
|
klass=ResourceHolder,
|
||||||
|
locations=[...],
|
||||||
|
name_prefix=name, # 自动生成 "{name}_0", "{name}_1" ...
|
||||||
|
)
|
||||||
|
|
||||||
|
# Deck setup 中用仓库名称作为 name 传入
|
||||||
|
self.warehouses = {
|
||||||
|
"堆栈1左": my_warehouse_4x4("堆栈1左"), # WareHouse.name = "堆栈1左"
|
||||||
|
"试剂堆栈": my_reagent_stack("试剂堆栈"), # WareHouse.name = "试剂堆栈"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 其他规范
|
||||||
|
|
||||||
|
- **max_volume 单位为 μL**:500mL = 500000
|
||||||
|
- **尺寸单位为 mm**:`diameter`, `height`, `size_x/y/z`, `dx/dy/dz`
|
||||||
|
- **BottleCarrier 必须设置 `num_items_x/y/z`**:用于前端渲染布局
|
||||||
|
- **Deck 的 `__init__` 必须接受 `setup=False`**:图文件中 `config.setup=true` 触发 `setup()`
|
||||||
|
- **按项目分组文件**:同一工作站的资源放在 `unilabos/resources/<project>/` 下
|
||||||
|
- **`__init__` 必须接受 `serialize()` 输出的所有字段**:`serialize()` 输出会作为 `config` 回传到 `__init__`,因此必须通过显式参数或 `**kwargs` 接受,否则反序列化会报错
|
||||||
|
- **持久化运行时状态用 `serialize_state()`**:通过 `_unilabos_state` 字典存储可变信息(如物料内容、液体量),只存 JSON 可序列化的基本类型
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 资源模板
|
||||||
|
|
||||||
|
### Bottle
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.registry.decorators import resource
|
||||||
|
from unilabos.resources.itemized_carrier import Bottle
|
||||||
|
|
||||||
|
|
||||||
|
@resource(id="My_Reagent_Bottle", category=["bottles"], description="我的试剂瓶")
|
||||||
|
def My_Reagent_Bottle(
|
||||||
|
name: str,
|
||||||
|
diameter: float = 70.0,
|
||||||
|
height: float = 120.0,
|
||||||
|
max_volume: float = 500000.0,
|
||||||
|
barcode: str = None,
|
||||||
|
) -> Bottle:
|
||||||
|
return Bottle(
|
||||||
|
name=name,
|
||||||
|
diameter=diameter,
|
||||||
|
height=height,
|
||||||
|
max_volume=max_volume,
|
||||||
|
barcode=barcode,
|
||||||
|
model="My_Reagent_Bottle",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Bottle 参数:**
|
||||||
|
- `name`: 实例名称(运行时唯一,由上层 Carrier 以前缀方式传入)
|
||||||
|
- `diameter`: 瓶体直径 (mm)
|
||||||
|
- `height`: 瓶体高度 (mm)
|
||||||
|
- `max_volume`: 最大容积(**μL**,500mL = 500000)
|
||||||
|
- `barcode`: 条形码(可选)
|
||||||
|
|
||||||
|
### BottleCarrier
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pylabrobot.resources import ResourceHolder
|
||||||
|
from pylabrobot.resources.carrier import create_ordered_items_2d
|
||||||
|
from unilabos.resources.itemized_carrier import BottleCarrier
|
||||||
|
from unilabos.registry.decorators import resource
|
||||||
|
|
||||||
|
|
||||||
|
@resource(id="My_6SlotCarrier", category=["bottle_carriers"], description="六槽位载架")
|
||||||
|
def My_6SlotCarrier(name: str) -> BottleCarrier:
|
||||||
|
sites = create_ordered_items_2d(
|
||||||
|
klass=ResourceHolder,
|
||||||
|
num_items_x=3, num_items_y=2,
|
||||||
|
dx=10.0, dy=10.0, dz=5.0,
|
||||||
|
item_dx=42.0, item_dy=35.0,
|
||||||
|
size_x=20.0, size_y=20.0, size_z=50.0,
|
||||||
|
)
|
||||||
|
# 子 site 用 name 作为前缀
|
||||||
|
for k, v in sites.items():
|
||||||
|
v.name = f"{name}_{v.name}"
|
||||||
|
|
||||||
|
carrier = BottleCarrier(
|
||||||
|
name=name, size_x=146.0, size_y=80.0, size_z=55.0,
|
||||||
|
sites=sites, model="My_6SlotCarrier",
|
||||||
|
)
|
||||||
|
carrier.num_items_x = 3
|
||||||
|
carrier.num_items_y = 2
|
||||||
|
carrier.num_items_z = 1
|
||||||
|
|
||||||
|
# 放置 Bottle 时用 name 作为前缀
|
||||||
|
ordering = ["A1", "B1", "A2", "B2", "A3", "B3"]
|
||||||
|
for i in range(6):
|
||||||
|
carrier[i] = My_Reagent_Bottle(f"{name}_vial_{ordering[i]}")
|
||||||
|
return carrier
|
||||||
|
```
|
||||||
|
|
||||||
|
### WareHouse / Deck 放置位
|
||||||
|
|
||||||
|
WareHouse 和 Site 本质上是同一概念:都是定义一组固定放置位(slot),根据物理尺寸自行批量计算偏移坐标。WareHouse 只是多嵌套了一层 Deck 而已。推荐开发者直接根据实物测量数据计算各 slot 偏移量。
|
||||||
|
|
||||||
|
#### WareHouse(使用 warehouse_factory)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.resources.warehouse import warehouse_factory
|
||||||
|
from unilabos.registry.decorators import resource
|
||||||
|
|
||||||
|
|
||||||
|
@resource(id="my_warehouse_4x4", category=["warehouse"], description="4x4 堆栈仓库")
|
||||||
|
def my_warehouse_4x4(name: str) -> "WareHouse":
|
||||||
|
return warehouse_factory(
|
||||||
|
name=name,
|
||||||
|
num_items_x=4, num_items_y=4, num_items_z=1,
|
||||||
|
dx=10.0, dy=10.0, dz=10.0, # 第一个 slot 的起始偏移
|
||||||
|
item_dx=147.0, item_dy=106.0, item_dz=130.0, # slot 间距
|
||||||
|
resource_size_x=127.0, resource_size_y=85.0, resource_size_z=100.0, # slot 尺寸
|
||||||
|
model="my_warehouse_4x4",
|
||||||
|
col_offset=0, # 列标签起始偏移(0 → A01, 4 → A05)
|
||||||
|
layout="row-major", # "row-major" 行优先 / "col-major" 列优先 / "vertical-col-major" 竖向
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`warehouse_factory` 参数说明:
|
||||||
|
- `dx/dy/dz`:第一个 slot 相对 WareHouse 原点的偏移(mm)
|
||||||
|
- `item_dx/item_dy/item_dz`:相邻 slot 间距(mm),需根据实际物理间距测量
|
||||||
|
- `resource_size_x/y/z`:每个 slot 的可放置区域尺寸
|
||||||
|
- `layout`:影响 slot 标签和坐标映射
|
||||||
|
- `"row-major"`:A01,A02,...,B01,B02,...(行优先,适合横向排列)
|
||||||
|
- `"col-major"`:A01,B01,...,A02,B02,...(列优先)
|
||||||
|
- `"vertical-col-major"`:竖向排列,y 坐标反向
|
||||||
|
|
||||||
|
#### Deck 组装 WareHouse
|
||||||
|
|
||||||
|
Deck 通过 `setup()` 将多个 WareHouse 放置到指定坐标:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pylabrobot.resources import Deck, Coordinate
|
||||||
|
from unilabos.registry.decorators import resource
|
||||||
|
|
||||||
|
|
||||||
|
@resource(id="MyStation_Deck", category=["deck"], description="我的工作站 Deck")
|
||||||
|
class MyStation_Deck(Deck):
|
||||||
|
def __init__(self, name="MyStation_Deck", size_x=2700.0, size_y=1080.0, size_z=1500.0,
|
||||||
|
category="deck", setup=False, **kwargs) -> None:
|
||||||
|
super().__init__(name=name, size_x=size_x, size_y=size_y, size_z=size_z)
|
||||||
|
if setup:
|
||||||
|
self.setup()
|
||||||
|
|
||||||
|
def setup(self) -> None:
|
||||||
|
self.warehouses = {
|
||||||
|
"堆栈1左": my_warehouse_4x4("堆栈1左"),
|
||||||
|
"堆栈1右": my_warehouse_4x4("堆栈1右"),
|
||||||
|
}
|
||||||
|
self.warehouse_locations = {
|
||||||
|
"堆栈1左": Coordinate(-200.0, 400.0, 0.0), # 自行测量计算
|
||||||
|
"堆栈1右": Coordinate(2350.0, 400.0, 0.0),
|
||||||
|
}
|
||||||
|
for wh_name, wh in self.warehouses.items():
|
||||||
|
self.assign_child_resource(wh, location=self.warehouse_locations[wh_name])
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Site 模式(前端定向放置)
|
||||||
|
|
||||||
|
适用于有固定孔位/槽位的设备(如移液站 PRCXI 9300),Deck 通过 `sites` 列表定义前端展示的放置位,前端据此渲染可拖拽的孔位布局:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import collections
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
from pylabrobot.resources import Deck, Resource, Coordinate
|
||||||
|
from unilabos.registry.decorators import resource
|
||||||
|
|
||||||
|
|
||||||
|
@resource(id="MyLabDeck", category=["deck"], description="带 Site 定向放置的 Deck")
|
||||||
|
class MyLabDeck(Deck):
|
||||||
|
# 根据设备台面实测批量计算各 slot 坐标偏移
|
||||||
|
_DEFAULT_SITE_POSITIONS = [
|
||||||
|
(0, 0, 0), (138, 0, 0), (276, 0, 0), (414, 0, 0), # T1-T4
|
||||||
|
(0, 96, 0), (138, 96, 0), (276, 96, 0), (414, 96, 0), # T5-T8
|
||||||
|
]
|
||||||
|
_DEFAULT_SITE_SIZE = {"width": 128.0, "height": 86.0, "depth": 0}
|
||||||
|
_DEFAULT_CONTENT_TYPE = ["plate", "tip_rack", "tube_rack", "adaptor"]
|
||||||
|
|
||||||
|
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
|
||||||
|
sites: Optional[List[Dict[str, Any]]] = None, **kwargs):
|
||||||
|
super().__init__(size_x, size_y, size_z, name)
|
||||||
|
if sites is not None:
|
||||||
|
self.sites = [dict(s) for s in sites]
|
||||||
|
else:
|
||||||
|
self.sites = []
|
||||||
|
for i, (x, y, z) in enumerate(self._DEFAULT_SITE_POSITIONS):
|
||||||
|
self.sites.append({
|
||||||
|
"label": f"T{i + 1}", # 前端显示的槽位标签
|
||||||
|
"visible": True, # 是否在前端可见
|
||||||
|
"position": {"x": x, "y": y, "z": z}, # 槽位物理坐标
|
||||||
|
"size": dict(self._DEFAULT_SITE_SIZE), # 槽位尺寸
|
||||||
|
"content_type": list(self._DEFAULT_CONTENT_TYPE), # 允许放入的物料类型
|
||||||
|
})
|
||||||
|
self._ordering = collections.OrderedDict(
|
||||||
|
(site["label"], None) for site in self.sites
|
||||||
|
)
|
||||||
|
|
||||||
|
def assign_child_resource(self, resource: Resource,
|
||||||
|
location: Optional[Coordinate] = None,
|
||||||
|
reassign: bool = True,
|
||||||
|
spot: Optional[int] = None):
|
||||||
|
idx = spot
|
||||||
|
if spot is None:
|
||||||
|
for i, site in enumerate(self.sites):
|
||||||
|
if site.get("label") == resource.name:
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
if idx is None:
|
||||||
|
for i in range(len(self.sites)):
|
||||||
|
if self._get_site_resource(i) is None:
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
if idx is None:
|
||||||
|
raise ValueError(f"No available site for '{resource.name}'")
|
||||||
|
loc = Coordinate(**self.sites[idx]["position"])
|
||||||
|
super().assign_child_resource(resource, location=loc, reassign=reassign)
|
||||||
|
|
||||||
|
def serialize(self) -> dict:
|
||||||
|
data = super().serialize()
|
||||||
|
sites_out = []
|
||||||
|
for i, site in enumerate(self.sites):
|
||||||
|
occupied = self._get_site_resource(i)
|
||||||
|
sites_out.append({
|
||||||
|
"label": site["label"],
|
||||||
|
"visible": site.get("visible", True),
|
||||||
|
"occupied_by": occupied.name if occupied else None,
|
||||||
|
"position": site["position"],
|
||||||
|
"size": site["size"],
|
||||||
|
"content_type": site["content_type"],
|
||||||
|
})
|
||||||
|
data["sites"] = sites_out
|
||||||
|
return data
|
||||||
|
```
|
||||||
|
|
||||||
|
**Site 字段说明:**
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `label` | str | 槽位标签(如 `"T1"`),前端显示名称,也用于匹配 resource.name |
|
||||||
|
| `visible` | bool | 是否在前端可见 |
|
||||||
|
| `position` | dict | 物理坐标 `{x, y, z}`(mm),需自行测量计算偏移 |
|
||||||
|
| `size` | dict | 槽位尺寸 `{width, height, depth}`(mm) |
|
||||||
|
| `content_type` | list | 允许放入的物料类型,如 `["plate", "tip_rack", "tube_rack", "adaptor"]` |
|
||||||
|
|
||||||
|
**参考实现:** `unilabos/devices/liquid_handling/prcxi/prcxi.py` 中的 `PRCXI9300Deck`(4x4 共 16 个 site)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件位置
|
||||||
|
|
||||||
|
```
|
||||||
|
unilabos/resources/
|
||||||
|
├── <project>/ # 按项目分组
|
||||||
|
│ ├── bottles.py # Bottle 工厂函数
|
||||||
|
│ ├── bottle_carriers.py # Carrier 工厂函数
|
||||||
|
│ ├── warehouses.py # WareHouse 工厂函数
|
||||||
|
│ └── decks.py # Deck 类定义
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 资源可导入
|
||||||
|
python -c "from unilabos.resources.my_project.bottles import My_Reagent_Bottle; print(My_Reagent_Bottle('test'))"
|
||||||
|
|
||||||
|
# 启动测试(AST 自动扫描)
|
||||||
|
unilab -g <graph>.json
|
||||||
|
```
|
||||||
|
|
||||||
|
仅在以下情况仍需 YAML:第三方库资源(如 pylabrobot 内置资源,无 `@resource` 装饰器)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 关键路径
|
||||||
|
|
||||||
|
| 内容 | 路径 |
|
||||||
|
|------|------|
|
||||||
|
| Bottle/Carrier 基类 | `unilabos/resources/itemized_carrier.py` |
|
||||||
|
| WareHouse 基类 + 工厂 | `unilabos/resources/warehouse.py` |
|
||||||
|
| PLR 注册 | `unilabos/resources/plr_additional_res_reg.py` |
|
||||||
|
| 装饰器定义 | `unilabos/registry/decorators.py` |
|
||||||
292
.cursor/skills/add-resource/reference.md
Normal file
292
.cursor/skills/add-resource/reference.md
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
# 资源高级参考
|
||||||
|
|
||||||
|
本文件是 SKILL.md 的补充,包含类继承体系、序列化/反序列化、Bioyond 物料同步、非瓶类资源和仓库工厂模式。Agent 在需要实现这些功能时按需阅读。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 类继承体系
|
||||||
|
|
||||||
|
```
|
||||||
|
PyLabRobot
|
||||||
|
├── Resource (PLR 基类)
|
||||||
|
│ ├── Well
|
||||||
|
│ │ └── Bottle (unilabos) → 瓶/小瓶/烧杯/反应器
|
||||||
|
│ ├── Deck
|
||||||
|
│ │ └── 自定义 Deck 类 (unilabos) → 工作站台面
|
||||||
|
│ ├── ResourceHolder → 槽位占位符
|
||||||
|
│ └── Container
|
||||||
|
│ └── Battery (unilabos) → 组装好的电池
|
||||||
|
│
|
||||||
|
├── ItemizedCarrier (unilabos, 继承 Resource)
|
||||||
|
│ ├── BottleCarrier (unilabos) → 瓶载架
|
||||||
|
│ └── WareHouse (unilabos) → 堆栈仓库
|
||||||
|
│
|
||||||
|
├── ItemizedResource (PLR)
|
||||||
|
│ └── MagazineHolder (unilabos) → 子弹夹载架
|
||||||
|
│
|
||||||
|
└── ResourceStack (PLR)
|
||||||
|
└── Magazine (unilabos) → 子弹夹洞位
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bottle 类细节
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Bottle(Well):
|
||||||
|
def __init__(self, name, diameter, height, max_volume,
|
||||||
|
size_x=0.0, size_y=0.0, size_z=0.0,
|
||||||
|
barcode=None, category="container", model=None, **kwargs):
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
size_x=diameter, # PLR 用 diameter 作为 size_x/size_y
|
||||||
|
size_y=diameter,
|
||||||
|
size_z=height, # PLR 用 height 作为 size_z
|
||||||
|
max_volume=max_volume,
|
||||||
|
category=category,
|
||||||
|
model=model,
|
||||||
|
bottom_type="flat",
|
||||||
|
cross_section_type="circle"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
注意 `size_x = size_y = diameter`,`size_z = height`。
|
||||||
|
|
||||||
|
### ItemizedCarrier 核心方法
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `__getitem__(identifier)` | 通过索引或 Excel 标识(如 `"A01"`)访问槽位 |
|
||||||
|
| `__setitem__(identifier, resource)` | 向槽位放入资源 |
|
||||||
|
| `get_child_identifier(child)` | 获取子资源的标识符 |
|
||||||
|
| `capacity` | 总槽位数 |
|
||||||
|
| `sites` | 所有槽位字典 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 序列化与反序列化
|
||||||
|
|
||||||
|
### PLR ↔ UniLab 转换
|
||||||
|
|
||||||
|
| 函数 | 位置 | 方向 |
|
||||||
|
|------|------|------|
|
||||||
|
| `ResourceTreeSet.from_plr_resources(resources)` | `resource_tracker.py` | PLR → UniLab |
|
||||||
|
| `ResourceTreeSet.to_plr_resources()` | `resource_tracker.py` | UniLab → PLR |
|
||||||
|
|
||||||
|
### `from_plr_resources` 流程
|
||||||
|
|
||||||
|
```
|
||||||
|
PLR Resource
|
||||||
|
↓ build_uuid_mapping (递归生成 UUID)
|
||||||
|
↓ resource.serialize() → dict
|
||||||
|
↓ resource.serialize_all_state() → states
|
||||||
|
↓ resource_plr_inner (递归构建 ResourceDictInstance)
|
||||||
|
ResourceTreeSet
|
||||||
|
```
|
||||||
|
|
||||||
|
关键:每个 PLR 资源通过 `unilabos_uuid` 属性携带 UUID,`unilabos_extra` 携带扩展数据(如 `class` 名)。
|
||||||
|
|
||||||
|
### `to_plr_resources` 流程
|
||||||
|
|
||||||
|
```
|
||||||
|
ResourceTreeSet
|
||||||
|
↓ collect_node_data (收集 UUID、状态、扩展数据)
|
||||||
|
↓ node_to_plr_dict (转为 PLR 字典格式)
|
||||||
|
↓ find_subclass(type_name, PLRResource) (查找 PLR 子类)
|
||||||
|
↓ sub_cls.deserialize(plr_dict) (反序列化)
|
||||||
|
↓ loop_set_uuid, loop_set_extra (递归设置 UUID 和扩展)
|
||||||
|
PLR Resource
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bottle 序列化
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Bottle(Well):
|
||||||
|
def serialize(self) -> dict:
|
||||||
|
data = super().serialize()
|
||||||
|
return {**data, "diameter": self.diameter, "height": self.height}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def deserialize(cls, data: dict, allow_marshal=False):
|
||||||
|
barcode_data = data.pop("barcode", None)
|
||||||
|
instance = super().deserialize(data, allow_marshal=allow_marshal)
|
||||||
|
if barcode_data and isinstance(barcode_data, str):
|
||||||
|
instance.barcode = barcode_data
|
||||||
|
return instance
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Bioyond 物料同步
|
||||||
|
|
||||||
|
### 双向转换函数
|
||||||
|
|
||||||
|
| 函数 | 位置 | 方向 |
|
||||||
|
|------|------|------|
|
||||||
|
| `resource_bioyond_to_plr(materials, type_mapping, deck)` | `graphio.py` | Bioyond → PLR |
|
||||||
|
| `resource_plr_to_bioyond(resources, type_mapping, warehouse_mapping)` | `graphio.py` | PLR → Bioyond |
|
||||||
|
|
||||||
|
### `resource_bioyond_to_plr` 流程
|
||||||
|
|
||||||
|
```
|
||||||
|
Bioyond 物料列表
|
||||||
|
↓ reverse_type_mapping: {typeName → (model, UUID)}
|
||||||
|
↓ 对每个物料:
|
||||||
|
typeName → 查映射 → model (如 "BIOYOND_PolymerStation_Reactor")
|
||||||
|
initialize_resource({"name": unique_name, "class": model})
|
||||||
|
↓ 设置 unilabos_extra (material_bioyond_id, material_bioyond_name 等)
|
||||||
|
↓ 处理 detail (子物料/坐标)
|
||||||
|
↓ 按 locationName 放入 deck.warehouses 对应槽位
|
||||||
|
PLR 资源列表
|
||||||
|
```
|
||||||
|
|
||||||
|
### `resource_plr_to_bioyond` 流程
|
||||||
|
|
||||||
|
```
|
||||||
|
PLR 资源列表
|
||||||
|
↓ 遍历每个资源:
|
||||||
|
载架(capacity > 1): 生成 details 子物料 + 坐标
|
||||||
|
单瓶: 直接映射
|
||||||
|
↓ type_mapping 查找 typeId
|
||||||
|
↓ warehouse_mapping 查找位置 UUID
|
||||||
|
↓ 组装 Bioyond 格式 (name, typeName, typeId, quantity, Parameters, locations)
|
||||||
|
Bioyond 物料列表
|
||||||
|
```
|
||||||
|
|
||||||
|
### BioyondResourceSynchronizer
|
||||||
|
|
||||||
|
工作站通过 `ResourceSynchronizer` 自动同步物料:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||||
|
def sync_from_external(self) -> bool:
|
||||||
|
all_data = []
|
||||||
|
all_data.extend(api_client.stock_material('{"typeMode": 0}')) # 耗材
|
||||||
|
all_data.extend(api_client.stock_material('{"typeMode": 1}')) # 样品
|
||||||
|
all_data.extend(api_client.stock_material('{"typeMode": 2}')) # 试剂
|
||||||
|
unilab_resources = resource_bioyond_to_plr(
|
||||||
|
all_data,
|
||||||
|
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
|
||||||
|
deck=self.workstation.deck
|
||||||
|
)
|
||||||
|
# 更新 deck 上的资源
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 非瓶类资源
|
||||||
|
|
||||||
|
### ElectrodeSheet(极片)
|
||||||
|
|
||||||
|
路径:`unilabos/resources/battery/electrode_sheet.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ElectrodeSheet(ResourcePLR):
|
||||||
|
"""片状材料(极片、隔膜、弹片、垫片等)"""
|
||||||
|
_unilabos_state = {
|
||||||
|
"diameter": 0.0,
|
||||||
|
"thickness": 0.0,
|
||||||
|
"mass": 0.0,
|
||||||
|
"material_type": "",
|
||||||
|
"color": "",
|
||||||
|
"info": "",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
工厂函数:`PositiveCan`, `PositiveElectrode`, `NegativeCan`, `NegativeElectrode`, `SpringWasher`, `FlatWasher`, `AluminumFoil`
|
||||||
|
|
||||||
|
### Battery(电池)
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Battery(Container):
|
||||||
|
"""组装好的电池"""
|
||||||
|
_unilabos_state = {
|
||||||
|
"color": "",
|
||||||
|
"electrolyte_name": "",
|
||||||
|
"open_circuit_voltage": 0.0,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Magazine / MagazineHolder(子弹夹)
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Magazine(ResourceStack):
|
||||||
|
"""子弹夹洞位,可堆叠 ElectrodeSheet"""
|
||||||
|
# direction, max_sheets
|
||||||
|
|
||||||
|
class MagazineHolder(ItemizedResource):
|
||||||
|
"""多洞位子弹夹"""
|
||||||
|
# hole_diameter, hole_depth, max_sheets_per_hole
|
||||||
|
```
|
||||||
|
|
||||||
|
工厂函数 `magazine_factory()` 用 `create_homogeneous_resources` 生成洞位,可选预填 `ElectrodeSheet` 或 `Battery`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 仓库工厂模式参考
|
||||||
|
|
||||||
|
### 实际 warehouse 工厂函数示例
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 行优先 4x4 仓库
|
||||||
|
def bioyond_warehouse_1x4x4(name: str) -> WareHouse:
|
||||||
|
return warehouse_factory(
|
||||||
|
name=name,
|
||||||
|
num_items_x=4, num_items_y=4, num_items_z=1,
|
||||||
|
dx=10.0, dy=10.0, dz=10.0,
|
||||||
|
item_dx=147.0, item_dy=106.0, item_dz=130.0,
|
||||||
|
layout="row-major", # A01,A02,A03,A04, B01,...
|
||||||
|
)
|
||||||
|
|
||||||
|
# 右侧 4x4 仓库(列名偏移)
|
||||||
|
def bioyond_warehouse_1x4x4_right(name: str) -> WareHouse:
|
||||||
|
return warehouse_factory(
|
||||||
|
name=name,
|
||||||
|
num_items_x=4, num_items_y=4, num_items_z=1,
|
||||||
|
dx=10.0, dy=10.0, dz=10.0,
|
||||||
|
item_dx=147.0, item_dy=106.0, item_dz=130.0,
|
||||||
|
col_offset=4, # A05,A06,A07,A08
|
||||||
|
layout="row-major",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 竖向仓库(站内试剂存放)
|
||||||
|
def bioyond_warehouse_reagent_storage(name: str) -> WareHouse:
|
||||||
|
return warehouse_factory(
|
||||||
|
name=name,
|
||||||
|
num_items_x=1, num_items_y=2, num_items_z=1,
|
||||||
|
dx=10.0, dy=10.0, dz=10.0,
|
||||||
|
item_dx=147.0, item_dy=106.0, item_dz=130.0,
|
||||||
|
layout="vertical-col-major",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 行偏移(F 行开始)
|
||||||
|
def bioyond_warehouse_5x3x1(name: str, row_offset: int = 0) -> WareHouse:
|
||||||
|
return warehouse_factory(
|
||||||
|
name=name,
|
||||||
|
num_items_x=3, num_items_y=5, num_items_z=1,
|
||||||
|
dx=10.0, dy=10.0, dz=10.0,
|
||||||
|
item_dx=159.0, item_dy=183.0, item_dz=130.0,
|
||||||
|
row_offset=row_offset, # 0→A行起,5→F行起
|
||||||
|
layout="row-major",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### layout 类型说明
|
||||||
|
|
||||||
|
| layout | 命名顺序 | 适用场景 |
|
||||||
|
|--------|---------|---------|
|
||||||
|
| `col-major` (默认) | A01,B01,C01,D01, A02,B02,... | 列优先,标准堆栈 |
|
||||||
|
| `row-major` | A01,A02,A03,A04, B01,B02,... | 行优先,Bioyond 前端展示 |
|
||||||
|
| `vertical-col-major` | 竖向排列,标签从底部开始 | 竖向仓库(试剂存放、测密度) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 关键路径
|
||||||
|
|
||||||
|
| 内容 | 路径 |
|
||||||
|
|------|------|
|
||||||
|
| Bottle/Carrier 基类 | `unilabos/resources/itemized_carrier.py` |
|
||||||
|
| WareHouse 类 + 工厂 | `unilabos/resources/warehouse.py` |
|
||||||
|
| ResourceTreeSet 转换 | `unilabos/resources/resource_tracker.py` |
|
||||||
|
| Bioyond 物料转换 | `unilabos/resources/graphio.py` |
|
||||||
|
| Bioyond 仓库定义 | `unilabos/resources/bioyond/warehouses.py` |
|
||||||
|
| 电池资源 | `unilabos/resources/battery/` |
|
||||||
|
| PLR 注册 | `unilabos/resources/plr_additional_res_reg.py` |
|
||||||
626
.cursor/skills/add-workstation/SKILL.md
Normal file
626
.cursor/skills/add-workstation/SKILL.md
Normal file
@@ -0,0 +1,626 @@
|
|||||||
|
---
|
||||||
|
name: add-workstation
|
||||||
|
description: Guide for adding new workstations to Uni-Lab-OS (接入新工作站). Uses @device decorator + AST auto-scanning. Walks through workstation type, sub-device composition, driver creation, deck setup, and graph file. Use when the user wants to add a workstation, create a workstation driver, configure a station with sub-devices, or mentions 工作站/工站/station/workstation.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Uni-Lab-OS 工作站接入指南
|
||||||
|
|
||||||
|
工作站(workstation)是组合多个子设备的大型设备,拥有独立的物料管理系统和工作流引擎。使用 `@device` 装饰器注册,AST 自动扫描生成注册表。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 工作站类型
|
||||||
|
|
||||||
|
| 类型 | 基类 | 适用场景 |
|
||||||
|
| ------------------- | ----------------- | ---------------------------------- |
|
||||||
|
| **Protocol 工作站** | `ProtocolNode` | 标准化学操作协议(泵转移、过滤等) |
|
||||||
|
| **外部系统工作站** | `WorkstationBase` | 与外部 LIMS/MES 对接 |
|
||||||
|
| **硬件控制工作站** | `WorkstationBase` | 直接控制 PLC/硬件 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## @device 装饰器(工作站)
|
||||||
|
|
||||||
|
工作站也使用 `@device` 装饰器注册,参数与普通设备一致:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@device(
|
||||||
|
id="my_workstation", # 注册表唯一标识(必填)
|
||||||
|
category=["workstation"], # 分类标签
|
||||||
|
description="我的工作站",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
如果一个工作站类支持多个具体变体,可使用 `ids` / `id_meta`,与设备的用法相同(参见 add-device SKILL)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 工作站驱动模板
|
||||||
|
|
||||||
|
### 模板 A:基于外部系统的工作站
|
||||||
|
|
||||||
|
```python
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
from pylabrobot.resources import Deck
|
||||||
|
|
||||||
|
from unilabos.registry.decorators import device, topic_config, not_action
|
||||||
|
from unilabos.devices.workstation.workstation_base import WorkstationBase
|
||||||
|
|
||||||
|
try:
|
||||||
|
from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
|
||||||
|
except ImportError:
|
||||||
|
ROS2WorkstationNode = None
|
||||||
|
|
||||||
|
|
||||||
|
@device(id="my_workstation", category=["workstation"], description="我的工作站")
|
||||||
|
class MyWorkstation(WorkstationBase):
|
||||||
|
_ros_node: "ROS2WorkstationNode"
|
||||||
|
|
||||||
|
def __init__(self, config=None, deck=None, protocol_type=None, **kwargs):
|
||||||
|
super().__init__(deck=deck, **kwargs)
|
||||||
|
self.config = config or {}
|
||||||
|
self.logger = logging.getLogger("MyWorkstation")
|
||||||
|
self.api_host = self.config.get("api_host", "")
|
||||||
|
self._status = "Idle"
|
||||||
|
|
||||||
|
@not_action
|
||||||
|
def post_init(self, ros_node: "ROS2WorkstationNode"):
|
||||||
|
super().post_init(ros_node)
|
||||||
|
self._ros_node = ros_node
|
||||||
|
|
||||||
|
async def scheduler_start(self, **kwargs) -> Dict[str, Any]:
|
||||||
|
"""注册为工作站动作"""
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
async def create_order(self, json_str: str, **kwargs) -> Dict[str, Any]:
|
||||||
|
"""注册为工作站动作"""
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
@property
|
||||||
|
@topic_config()
|
||||||
|
def workflow_sequence(self) -> str:
|
||||||
|
return "[]"
|
||||||
|
|
||||||
|
@property
|
||||||
|
@topic_config()
|
||||||
|
def material_info(self) -> str:
|
||||||
|
return "{}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模板 B:Protocol 工作站
|
||||||
|
|
||||||
|
直接使用 `ProtocolNode`,通常不需要自定义驱动类:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.devices.workstation.workstation_base import ProtocolNode
|
||||||
|
```
|
||||||
|
|
||||||
|
在图文件中配置 `protocol_type` 即可。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 子设备访问(sub_devices)
|
||||||
|
|
||||||
|
工站初始化子设备后,所有子设备实例存储在 `self._ros_node.sub_devices` 字典中(key 为设备 id,value 为 `ROS2DeviceNode` 实例)。工站的驱动类可以直接获取子设备实例来调用其方法:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 在工站驱动类的方法中访问子设备
|
||||||
|
sub = self._ros_node.sub_devices["pump_1"]
|
||||||
|
|
||||||
|
# .driver_instance — 子设备的驱动实例(即设备 Python 类的实例)
|
||||||
|
sub.driver_instance.some_method(arg1, arg2)
|
||||||
|
|
||||||
|
# .ros_node_instance — 子设备的 ROS2 节点实例
|
||||||
|
sub.ros_node_instance._action_value_mappings # 查看子设备支持的 action
|
||||||
|
```
|
||||||
|
|
||||||
|
**常见用法**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MyWorkstation(WorkstationBase):
|
||||||
|
def my_protocol(self, **kwargs):
|
||||||
|
# 获取子设备驱动实例
|
||||||
|
pump = self._ros_node.sub_devices["pump_1"].driver_instance
|
||||||
|
heater = self._ros_node.sub_devices["heater_1"].driver_instance
|
||||||
|
|
||||||
|
# 直接调用子设备方法
|
||||||
|
pump.aspirate(volume=100)
|
||||||
|
heater.set_temperature(80)
|
||||||
|
```
|
||||||
|
|
||||||
|
> 参考实现:`unilabos/devices/workstation/bioyond_studio/reaction_station/reaction_station.py` 中通过 `self._ros_node.sub_devices.get(reactor_id)` 获取子反应器实例并更新数据。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 硬件通信接口(hardware_interface)
|
||||||
|
|
||||||
|
硬件控制型工作站通常需要通过串口(Serial)、Modbus 等通信协议控制多个子设备。Uni-Lab-OS 通过 **通信设备代理** 机制实现端口共享:一个串口只创建一个 `serial` 节点,多个子设备共享这个通信实例。
|
||||||
|
|
||||||
|
### 工作原理
|
||||||
|
|
||||||
|
`ROS2WorkstationNode` 初始化时分两轮遍历子设备(`workstation.py`):
|
||||||
|
|
||||||
|
**第一轮 — 初始化所有子设备**:按 `children` 顺序调用 `initialize_device()`,通信设备(`serial_` / `io_` 开头的 id)优先完成初始化,创建 `serial.Serial()` 实例。其他子设备此时 `self.hardware_interface = "serial_pump"`(字符串)。
|
||||||
|
|
||||||
|
**第二轮 — 代理替换**:遍历所有已初始化的子设备,读取子设备的 `_hardware_interface` 配置:
|
||||||
|
|
||||||
|
```
|
||||||
|
hardware_interface = d.ros_node_instance._hardware_interface
|
||||||
|
# → {"name": "hardware_interface", "read": "send_command", "write": "send_command"}
|
||||||
|
```
|
||||||
|
|
||||||
|
1. 取 `name` 字段对应的属性值:`name_value = getattr(driver, hardware_interface["name"])`
|
||||||
|
- 如果 `name_value` 是字符串且该字符串是某个子设备的 id → 触发代理替换
|
||||||
|
2. 从通信设备获取真正的 `read`/`write` 方法
|
||||||
|
3. 用 `setattr(driver, read_method, _read)` 将通信设备的方法绑定到子设备上
|
||||||
|
|
||||||
|
因此:
|
||||||
|
|
||||||
|
- **通信设备 id 必须与子设备 config 中填的字符串完全一致**(如 `"serial_pump"`)
|
||||||
|
- **通信设备 id 必须以 `serial_` 或 `io_` 开头**(否则第一轮不会被识别为通信设备)
|
||||||
|
- **通信设备必须在 `children` 列表中排在最前面**,确保先初始化
|
||||||
|
|
||||||
|
### HardwareInterface 参数说明
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.registry.decorators import HardwareInterface
|
||||||
|
|
||||||
|
HardwareInterface(
|
||||||
|
name="hardware_interface", # __init__ 中接收通信实例的属性名
|
||||||
|
read="send_command", # 通信设备上暴露的读方法名
|
||||||
|
write="send_command", # 通信设备上暴露的写方法名
|
||||||
|
extra_info=["list_ports"], # 可选:额外暴露的方法
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**`name` 字段的含义**:对应设备类 `__init__` 中,用于保存通信实例的**属性名**。系统据此知道要替换哪个属性。大部分设备直接用 `"hardware_interface"`,也可以自定义(如 `"io_device_port"`)。
|
||||||
|
|
||||||
|
### 示例 1:泵(name="hardware_interface")
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.registry.decorators import device, HardwareInterface
|
||||||
|
|
||||||
|
@device(
|
||||||
|
id="my_pump",
|
||||||
|
category=["pump_and_valve"],
|
||||||
|
hardware_interface=HardwareInterface(
|
||||||
|
name="hardware_interface",
|
||||||
|
read="send_command",
|
||||||
|
write="send_command",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
class MyPump:
|
||||||
|
def __init__(self, port=None, address="1", **kwargs):
|
||||||
|
# name="hardware_interface" → 系统替换 self.hardware_interface
|
||||||
|
self.hardware_interface = port # 初始为字符串 "serial_pump",启动后被替换为 Serial 实例
|
||||||
|
self.address = address
|
||||||
|
|
||||||
|
def send_command(self, command: str):
|
||||||
|
full_command = f"/{self.address}{command}\r\n"
|
||||||
|
self.hardware_interface.write(bytearray(full_command, "ascii"))
|
||||||
|
return self.hardware_interface.read_until(b"\n")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例 2:电磁阀(name="io_device_port",自定义属性名)
|
||||||
|
|
||||||
|
```python
|
||||||
|
@device(
|
||||||
|
id="solenoid_valve",
|
||||||
|
category=["pump_and_valve"],
|
||||||
|
hardware_interface=HardwareInterface(
|
||||||
|
name="io_device_port", # 自定义属性名 → 系统替换 self.io_device_port
|
||||||
|
read="read_io_coil",
|
||||||
|
write="write_io_coil",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
class SolenoidValve:
|
||||||
|
def __init__(self, io_device_port: str = None, **kwargs):
|
||||||
|
# name="io_device_port" → 图文件 config 中用 "io_device_port": "io_board_1"
|
||||||
|
self.io_device_port = io_device_port # 初始为字符串,系统替换为 Modbus 实例
|
||||||
|
```
|
||||||
|
|
||||||
|
### Serial 通信设备(class="serial")
|
||||||
|
|
||||||
|
`serial` 是 Uni-Lab-OS 内置的通信代理设备,代码位于 `unilabos/ros/nodes/presets/serial_node.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from serial import Serial, SerialException
|
||||||
|
from threading import Lock
|
||||||
|
|
||||||
|
class ROS2SerialNode(BaseROS2DeviceNode):
|
||||||
|
def __init__(self, device_id, registry_name, port: str, baudrate: int = 9600, **kwargs):
|
||||||
|
self.port = port
|
||||||
|
self.baudrate = baudrate
|
||||||
|
self._hardware_interface = {
|
||||||
|
"name": "hardware_interface",
|
||||||
|
"write": "send_command",
|
||||||
|
"read": "read_data",
|
||||||
|
}
|
||||||
|
self._query_lock = Lock()
|
||||||
|
|
||||||
|
self.hardware_interface = Serial(baudrate=baudrate, port=port)
|
||||||
|
|
||||||
|
BaseROS2DeviceNode.__init__(
|
||||||
|
self, driver_instance=self, registry_name=registry_name,
|
||||||
|
device_id=device_id, status_types={}, action_value_mappings={},
|
||||||
|
hardware_interface=self._hardware_interface, print_publish=False,
|
||||||
|
)
|
||||||
|
self.create_service(SerialCommand, "serialwrite", self.handle_serial_request)
|
||||||
|
|
||||||
|
def send_command(self, command: str):
|
||||||
|
with self._query_lock:
|
||||||
|
self.hardware_interface.write(bytearray(f"{command}\n", "ascii"))
|
||||||
|
return self.hardware_interface.read_until(b"\n").decode()
|
||||||
|
|
||||||
|
def read_data(self):
|
||||||
|
with self._query_lock:
|
||||||
|
return self.hardware_interface.read_until(b"\n").decode()
|
||||||
|
```
|
||||||
|
|
||||||
|
在图文件中使用 `"class": "serial"` 即可创建串口代理:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "serial_pump",
|
||||||
|
"class": "serial",
|
||||||
|
"parent": "my_station",
|
||||||
|
"config": { "port": "COM7", "baudrate": 9600 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 图文件配置
|
||||||
|
|
||||||
|
**通信设备必须在 `children` 列表中排在最前面**,确保先于其他子设备初始化:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "my_station",
|
||||||
|
"class": "workstation",
|
||||||
|
"children": ["serial_pump", "pump_1", "pump_2"],
|
||||||
|
"config": { "protocol_type": ["PumpTransferProtocol"] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "serial_pump",
|
||||||
|
"class": "serial",
|
||||||
|
"parent": "my_station",
|
||||||
|
"config": { "port": "COM7", "baudrate": 9600 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pump_1",
|
||||||
|
"class": "syringe_pump_with_valve.runze.SY03B-T08",
|
||||||
|
"parent": "my_station",
|
||||||
|
"config": { "port": "serial_pump", "address": "1", "max_volume": 25.0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pump_2",
|
||||||
|
"class": "syringe_pump_with_valve.runze.SY03B-T08",
|
||||||
|
"parent": "my_station",
|
||||||
|
"config": { "port": "serial_pump", "address": "2", "max_volume": 25.0 }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"source": "pump_1",
|
||||||
|
"target": "serial_pump",
|
||||||
|
"type": "communication",
|
||||||
|
"port": { "pump_1": "port", "serial_pump": "port" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "pump_2",
|
||||||
|
"target": "serial_pump",
|
||||||
|
"type": "communication",
|
||||||
|
"port": { "pump_2": "port", "serial_pump": "port" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 通信协议速查
|
||||||
|
|
||||||
|
| 协议 | config 参数 | 依赖包 | 通信设备 class |
|
||||||
|
| -------------------- | ------------------------------ | ---------- | -------------------------- |
|
||||||
|
| Serial (RS232/RS485) | `port`, `baudrate` | `pyserial` | `serial` |
|
||||||
|
| Modbus RTU | `port`, `baudrate`, `slave_id` | `pymodbus` | `device_comms/modbus_plc/` |
|
||||||
|
| Modbus TCP | `host`, `port`, `slave_id` | `pymodbus` | `device_comms/modbus_plc/` |
|
||||||
|
| TCP Socket | `host`, `port` | stdlib | 自定义 |
|
||||||
|
| HTTP API | `url`, `token` | `requests` | `device_comms/rpc.py` |
|
||||||
|
|
||||||
|
参考实现:`unilabos/test/experiments/Grignard_flow_batchreact_single_pumpvalve.json`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deck 与物料生命周期
|
||||||
|
|
||||||
|
### 1. Deck 入参与两种初始化模式
|
||||||
|
|
||||||
|
系统根据设备节点 `config.deck` 的写法,自动反序列化 Deck 实例后传入 `__init__` 的 `deck` 参数。目前 `deck` 是固定字段名,只支持一个主 Deck。建议一个设备拥有一个台面,台面上抽象二级、三级子物料。
|
||||||
|
|
||||||
|
有两种初始化模式:
|
||||||
|
|
||||||
|
#### init 初始化(推荐)
|
||||||
|
|
||||||
|
`config.deck` 直接包含 `_resource_type` + `_resource_child_name`,系统先用 Deck 节点的 `config` 调用 Deck 类的 `__init__` 反序列化,再将实例传入设备的 `deck` 参数。子物料随 Deck 的 `children` 一起反序列化。
|
||||||
|
|
||||||
|
```json
|
||||||
|
"config": {
|
||||||
|
"deck": {
|
||||||
|
"_resource_type": "unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Deck",
|
||||||
|
"_resource_child_name": "PRCXI_Deck"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### deserialize 初始化
|
||||||
|
|
||||||
|
`config.deck` 用 `data` 包裹一层,系统走 `deserialize` 路径,可传入更多参数(如 `allow_marshal` 等):
|
||||||
|
|
||||||
|
```json
|
||||||
|
"config": {
|
||||||
|
"deck": {
|
||||||
|
"data": {
|
||||||
|
"_resource_child_name": "YB_Bioyond_Deck",
|
||||||
|
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_YB_Deck"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
没有特殊需求时推荐 init 初始化。
|
||||||
|
|
||||||
|
#### config.deck 字段说明
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `_resource_type` | Deck 类的完整模块路径(`module:ClassName`) |
|
||||||
|
| `_resource_child_name` | 对应图文件中 Deck 节点的 `id`,建立父子关联 |
|
||||||
|
|
||||||
|
#### 设备 __init__ 接收
|
||||||
|
|
||||||
|
```python
|
||||||
|
def __init__(self, config=None, deck=None, protocol_type=None, **kwargs):
|
||||||
|
super().__init__(deck=deck, **kwargs)
|
||||||
|
# deck 已经是反序列化后的 Deck 实例
|
||||||
|
# → PRCXI9300Deck / BIOYOND_YB_Deck 等
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Deck 节点(图文件中)
|
||||||
|
|
||||||
|
Deck 节点作为设备的 `children` 之一,`parent` 指向设备 id:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "PRCXI_Deck",
|
||||||
|
"parent": "PRCXI",
|
||||||
|
"type": "deck",
|
||||||
|
"class": "",
|
||||||
|
"children": [],
|
||||||
|
"config": {
|
||||||
|
"type": "PRCXI9300Deck",
|
||||||
|
"size_x": 542, "size_y": 374, "size_z": 0,
|
||||||
|
"category": "deck",
|
||||||
|
"sites": [...]
|
||||||
|
},
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `config` 中的字段会传入 Deck 类的 `__init__`(因此 `__init__` 必须能接受所有 `serialize()` 输出的字段)
|
||||||
|
- `children` 初始为空时,由同步器或手动初始化填充
|
||||||
|
- `config.type` 填 Deck 类名
|
||||||
|
|
||||||
|
### 2. Deck 为空时自行初始化
|
||||||
|
|
||||||
|
如果 Deck 节点的 `children` 为空,工作站需在 `post_init` 或首次同步时自行初始化内容:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@not_action
|
||||||
|
def post_init(self, ros_node):
|
||||||
|
super().post_init(ros_node)
|
||||||
|
if self.deck and not self.deck.children:
|
||||||
|
self._initialize_default_deck()
|
||||||
|
|
||||||
|
def _initialize_default_deck(self):
|
||||||
|
from my_labware import My_TipRack, My_Plate
|
||||||
|
self.deck.assign_child_resource(My_TipRack("T1"), spot=0)
|
||||||
|
self.deck.assign_child_resource(My_Plate("T2"), spot=1)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 物料双向同步
|
||||||
|
|
||||||
|
当工作站对接外部系统(LIMS/MES)时,需要实现 `ResourceSynchronizer` 处理双向物料同步:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.devices.workstation.workstation_base import ResourceSynchronizer
|
||||||
|
|
||||||
|
class MyResourceSynchronizer(ResourceSynchronizer):
|
||||||
|
def sync_from_external(self) -> bool:
|
||||||
|
"""从外部系统同步到 self.workstation.deck"""
|
||||||
|
external_data = self._query_external_materials()
|
||||||
|
# 以外部工站为准:根据外部数据反向创建 PLR 资源实例
|
||||||
|
for item in external_data:
|
||||||
|
cls = self._resolve_resource_class(item["type"])
|
||||||
|
resource = cls(name=item["name"], **item["params"])
|
||||||
|
self.workstation.deck.assign_child_resource(resource, spot=item["slot"])
|
||||||
|
return True
|
||||||
|
|
||||||
|
def sync_to_external(self, resource) -> bool:
|
||||||
|
"""将 UniLab 侧物料变更同步到外部系统"""
|
||||||
|
# 以 UniLab 为准:将 PLR 资源转为外部格式并推送
|
||||||
|
external_format = self._convert_to_external(resource)
|
||||||
|
return self._push_to_external(external_format)
|
||||||
|
|
||||||
|
def handle_external_change(self, change_info) -> bool:
|
||||||
|
"""处理外部系统主动推送的变更"""
|
||||||
|
return True
|
||||||
|
```
|
||||||
|
|
||||||
|
同步策略取决于业务场景:
|
||||||
|
|
||||||
|
- **以外部工站为准**:从外部 API 查询物料数据,反向创建对应的 PLR 资源实例放到 Deck 上
|
||||||
|
- **以 UniLab 为准**:UniLab 侧的物料变更通过 `sync_to_external` 推送到外部系统
|
||||||
|
|
||||||
|
在工作站 `post_init` 中初始化同步器:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@not_action
|
||||||
|
def post_init(self, ros_node):
|
||||||
|
super().post_init(ros_node)
|
||||||
|
self.resource_synchronizer = MyResourceSynchronizer(self)
|
||||||
|
self.resource_synchronizer.sync_from_external()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 序列化与持久化(serialize / serialize_state)
|
||||||
|
|
||||||
|
资源类需正确实现序列化,系统据此完成持久化和前端同步。
|
||||||
|
|
||||||
|
**`serialize()`** — 输出资源的结构信息(`config` 层),反序列化时作为 `__init__` 的入参回传。因此 **`__init__` 必须通过 `**kwargs`接受`serialize()` 输出的所有字段\*\*,即使当前不使用:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MyDeck(Deck):
|
||||||
|
def __init__(self, name, size_x, size_y, size_z,
|
||||||
|
sites=None, # serialize() 输出的字段
|
||||||
|
rotation=None, # serialize() 输出的字段
|
||||||
|
barcode=None, # serialize() 输出的字段
|
||||||
|
**kwargs): # 兜底:接受所有未知的 serialize 字段
|
||||||
|
super().__init__(size_x, size_y, size_z, name)
|
||||||
|
# ...
|
||||||
|
|
||||||
|
def serialize(self) -> dict:
|
||||||
|
data = super().serialize()
|
||||||
|
data["sites"] = [...] # 自定义字段
|
||||||
|
return data
|
||||||
|
```
|
||||||
|
|
||||||
|
**`serialize_state()`** — 输出资源的运行时状态(`data` 层),用于持久化可变信息。`data` 中的内容会被正确保存和恢复:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MyPlate(Plate):
|
||||||
|
def __init__(self, name, size_x, size_y, size_z,
|
||||||
|
material_info=None, **kwargs):
|
||||||
|
super().__init__(name, size_x, size_y, size_z, **kwargs)
|
||||||
|
self._unilabos_state = {}
|
||||||
|
if material_info:
|
||||||
|
self._unilabos_state["Material"] = material_info
|
||||||
|
|
||||||
|
def serialize_state(self) -> Dict[str, Any]:
|
||||||
|
data = super().serialize_state()
|
||||||
|
data.update(self._unilabos_state)
|
||||||
|
return data
|
||||||
|
```
|
||||||
|
|
||||||
|
关键要点:
|
||||||
|
|
||||||
|
- `serialize()` 输出的所有字段都会作为 `config` 回传到 `__init__`,所以 `__init__` 必须能接受它们(显式声明或 `**kwargs`)
|
||||||
|
- `serialize_state()` 输出的 `data` 用于持久化运行时状态(如物料信息、液体量等)
|
||||||
|
- `_unilabos_state` 中只存可 JSON 序列化的基本类型(str, int, float, bool, list, dict, None)
|
||||||
|
|
||||||
|
### 5. 子物料自动同步
|
||||||
|
|
||||||
|
子物料(Bottle、Plate、TipRack 等)放到 Deck 上后,系统会自动将其同步到前端的 Deck 视图。只需保证资源类正确实现了 `serialize()` / `serialize_state()` 和反序列化即可。
|
||||||
|
|
||||||
|
### 6. 图文件配置(参考 prcxi_9320_slim.json)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "my_station",
|
||||||
|
"type": "device",
|
||||||
|
"class": "my_workstation",
|
||||||
|
"config": {
|
||||||
|
"deck": {
|
||||||
|
"_resource_type": "unilabos.resources.my_module:MyDeck",
|
||||||
|
"_resource_child_name": "my_deck"
|
||||||
|
},
|
||||||
|
"host": "10.20.30.1",
|
||||||
|
"port": 9999
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "my_deck",
|
||||||
|
"parent": "my_station",
|
||||||
|
"type": "deck",
|
||||||
|
"class": "",
|
||||||
|
"children": [],
|
||||||
|
"config": {
|
||||||
|
"type": "MyLabDeck",
|
||||||
|
"size_x": 542,
|
||||||
|
"size_y": 374,
|
||||||
|
"size_z": 0,
|
||||||
|
"category": "deck",
|
||||||
|
"sites": [
|
||||||
|
{
|
||||||
|
"label": "T1",
|
||||||
|
"visible": true,
|
||||||
|
"occupied_by": null,
|
||||||
|
"position": { "x": 0, "y": 0, "z": 0 },
|
||||||
|
"size": { "width": 128.0, "height": 86, "depth": 0 },
|
||||||
|
"content_type": ["plate", "tip_rack", "tube_rack", "adaptor"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edges": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Deck 节点要点:
|
||||||
|
|
||||||
|
- `config.type` 填 Deck 类名(如 `"PRCXI9300Deck"`)
|
||||||
|
- `config.sites` 完整列出所有 site(从 Deck 类的 `serialize()` 输出获取)
|
||||||
|
- `children` 初始为空(由同步器或手动初始化填充)
|
||||||
|
- 设备节点 `config.deck._resource_type` 指向 Deck 类的完整模块路径
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 子设备
|
||||||
|
|
||||||
|
子设备按标准设备接入流程创建(参见 add-device SKILL),使用 `@device` 装饰器。
|
||||||
|
|
||||||
|
子设备约束:
|
||||||
|
|
||||||
|
- 图文件中 `parent` 指向工作站 ID
|
||||||
|
- 在工作站 `children` 数组中列出
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 关键规则
|
||||||
|
|
||||||
|
1. **`__init__` 必须接受 `deck` 和 `**kwargs`** — `WorkstationBase.**init**`需要`deck` 参数
|
||||||
|
2. **Deck 通过 `config.deck._resource_type` 反序列化传入** — 不要在 `__init__` 中手动创建 Deck
|
||||||
|
3. **Deck 为空时自行初始化内容** — 在 `post_init` 中检查并填充默认物料
|
||||||
|
4. **外部同步实现 `ResourceSynchronizer`** — `sync_from_external` / `sync_to_external`
|
||||||
|
5. **通过 `self._children` 访问子设备** — 不要自行维护子设备引用
|
||||||
|
6. **`post_init` 中启动后台服务** — 不要在 `__init__` 中启动网络连接
|
||||||
|
7. **异步方法使用 `await self._ros_node.sleep()`** — 禁止 `time.sleep()` 和 `asyncio.sleep()`
|
||||||
|
8. **使用 `@not_action` 标记非动作方法** — `post_init`, `initialize`, `cleanup`
|
||||||
|
9. **子物料保证正确 serialize/deserialize** — 系统自动同步到前端 Deck 视图
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 模块可导入
|
||||||
|
python -c "from unilabos.devices.workstation.<name>.<name> import <ClassName>"
|
||||||
|
|
||||||
|
# 启动测试(AST 自动扫描)
|
||||||
|
unilab -g <graph>.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 现有工作站参考
|
||||||
|
|
||||||
|
| 工作站 | 驱动类 | 类型 |
|
||||||
|
| -------------- | ----------------------------- | -------- |
|
||||||
|
| Protocol 通用 | `ProtocolNode` | Protocol |
|
||||||
|
| Bioyond 反应站 | `BioyondReactionStation` | 外部系统 |
|
||||||
|
| 纽扣电池组装 | `CoinCellAssemblyWorkstation` | 硬件控制 |
|
||||||
|
|
||||||
|
参考路径:`unilabos/devices/workstation/` 目录下各工作站实现。
|
||||||
371
.cursor/skills/add-workstation/reference.md
Normal file
371
.cursor/skills/add-workstation/reference.md
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
# 工作站高级模式参考
|
||||||
|
|
||||||
|
本文件是 SKILL.md 的补充,包含外部系统集成、物料同步、配置结构等高级模式。
|
||||||
|
Agent 在需要实现这些功能时按需阅读。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 外部系统集成模式
|
||||||
|
|
||||||
|
### 1.1 RPC 客户端
|
||||||
|
|
||||||
|
与外部 LIMS/MES 系统通信的标准模式。继承 `BaseRequest`,所有接口统一用 POST。
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.device_comms.rpc import BaseRequest
|
||||||
|
|
||||||
|
|
||||||
|
class MySystemRPC(BaseRequest):
|
||||||
|
"""外部系统 RPC 客户端"""
|
||||||
|
|
||||||
|
def __init__(self, host: str, api_key: str):
|
||||||
|
super().__init__(host)
|
||||||
|
self.api_key = api_key
|
||||||
|
|
||||||
|
def _request(self, endpoint: str, data: dict = None) -> dict:
|
||||||
|
return self.post(
|
||||||
|
url=f"{self.host}/api/{endpoint}",
|
||||||
|
params={
|
||||||
|
"apiKey": self.api_key,
|
||||||
|
"requestTime": self.get_current_time_iso8601(),
|
||||||
|
"data": data or {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def query_status(self) -> dict:
|
||||||
|
return self._request("status/query")
|
||||||
|
|
||||||
|
def create_order(self, order_data: dict) -> dict:
|
||||||
|
return self._request("order/create", order_data)
|
||||||
|
```
|
||||||
|
|
||||||
|
参考:`unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py`(`BioyondV1RPC`)
|
||||||
|
|
||||||
|
### 1.2 HTTP 回调服务
|
||||||
|
|
||||||
|
接收外部系统报送的标准模式。使用 `WorkstationHTTPService`,在 `post_init` 中启动。
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
|
||||||
|
|
||||||
|
|
||||||
|
class MyWorkstation(WorkstationBase):
|
||||||
|
def __init__(self, config=None, deck=None, **kwargs):
|
||||||
|
super().__init__(deck=deck, **kwargs)
|
||||||
|
self.config = config or {}
|
||||||
|
http_cfg = self.config.get("http_service_config", {})
|
||||||
|
self._http_service_config = {
|
||||||
|
"host": http_cfg.get("http_service_host", "127.0.0.1"),
|
||||||
|
"port": http_cfg.get("http_service_port", 8080),
|
||||||
|
}
|
||||||
|
self.http_service = None
|
||||||
|
|
||||||
|
def post_init(self, ros_node):
|
||||||
|
super().post_init(ros_node)
|
||||||
|
self.http_service = WorkstationHTTPService(
|
||||||
|
workstation_instance=self,
|
||||||
|
host=self._http_service_config["host"],
|
||||||
|
port=self._http_service_config["port"],
|
||||||
|
)
|
||||||
|
self.http_service.start()
|
||||||
|
```
|
||||||
|
|
||||||
|
**HTTP 服务路由**(固定端点,由 `WorkstationHTTPHandler` 自动分发):
|
||||||
|
|
||||||
|
| 端点 | 调用的工作站方法 |
|
||||||
|
|------|-----------------|
|
||||||
|
| `/report/step_finish` | `process_step_finish_report(report_request)` |
|
||||||
|
| `/report/sample_finish` | `process_sample_finish_report(report_request)` |
|
||||||
|
| `/report/order_finish` | `process_order_finish_report(report_request, used_materials)` |
|
||||||
|
| `/report/material_change` | `process_material_change_report(report_data)` |
|
||||||
|
| `/report/error_handling` | `handle_external_error(error_data)` |
|
||||||
|
|
||||||
|
实现对应方法即可接收回调:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def process_step_finish_report(self, report_request) -> Dict[str, Any]:
|
||||||
|
"""处理步骤完成报告"""
|
||||||
|
step_name = report_request.data.get("stepName")
|
||||||
|
return {"success": True, "message": f"步骤 {step_name} 已处理"}
|
||||||
|
|
||||||
|
def process_order_finish_report(self, report_request, used_materials) -> Dict[str, Any]:
|
||||||
|
"""处理订单完成报告"""
|
||||||
|
order_code = report_request.data.get("orderCode")
|
||||||
|
return {"success": True}
|
||||||
|
```
|
||||||
|
|
||||||
|
参考:`unilabos/devices/workstation/workstation_http_service.py`
|
||||||
|
|
||||||
|
### 1.3 连接监控
|
||||||
|
|
||||||
|
独立线程周期性检测外部系统连接状态,状态变化时发布 ROS 事件。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ConnectionMonitor:
|
||||||
|
def __init__(self, workstation, check_interval=30):
|
||||||
|
self.workstation = workstation
|
||||||
|
self.check_interval = check_interval
|
||||||
|
self._running = False
|
||||||
|
self._thread = None
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self._running = True
|
||||||
|
self._thread = threading.Thread(target=self._monitor_loop, daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def _monitor_loop(self):
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
# 调用外部系统接口检测连接
|
||||||
|
self.workstation.hardware_interface.ping()
|
||||||
|
status = "online"
|
||||||
|
except Exception:
|
||||||
|
status = "offline"
|
||||||
|
time.sleep(self.check_interval)
|
||||||
|
```
|
||||||
|
|
||||||
|
参考:`unilabos/devices/workstation/bioyond_studio/station.py`(`ConnectionMonitor`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Config 结构模式
|
||||||
|
|
||||||
|
工作站的 `config` 在图文件中定义,传入 `__init__`。以下是常见字段模式:
|
||||||
|
|
||||||
|
### 2.1 外部系统连接
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"api_host": "http://192.168.1.100:8080",
|
||||||
|
"api_key": "YOUR_API_KEY"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 HTTP 回调服务
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"http_service_config": {
|
||||||
|
"http_service_host": "127.0.0.1",
|
||||||
|
"http_service_port": 8080
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 物料类型映射
|
||||||
|
|
||||||
|
将 PLR 资源类名映射到外部系统的物料类型(名称 + UUID)。用于双向物料转换。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"material_type_mappings": {
|
||||||
|
"PLR_ResourceClassName": ["外部系统显示名", "external-type-uuid"],
|
||||||
|
"BIOYOND_PolymerStation_Reactor": ["反应器", "3a14233b-902d-0d7b-..."]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 仓库映射
|
||||||
|
|
||||||
|
将仓库名映射到外部系统的仓库 UUID 和库位 UUID。用于入库/出库操作。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"warehouse_mapping": {
|
||||||
|
"仓库名": {
|
||||||
|
"uuid": "warehouse-uuid",
|
||||||
|
"site_uuids": {
|
||||||
|
"A01": "site-uuid-A01",
|
||||||
|
"A02": "site-uuid-A02"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.5 工作流映射
|
||||||
|
|
||||||
|
将内部工作流名映射到外部系统的工作流 ID。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"workflow_mappings": {
|
||||||
|
"internal_workflow_name": "external-workflow-uuid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.6 物料默认参数
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"material_default_parameters": {
|
||||||
|
"NMP": {
|
||||||
|
"unit": "毫升",
|
||||||
|
"density": "1.03",
|
||||||
|
"densityUnit": "g/mL",
|
||||||
|
"description": "N-甲基吡咯烷酮"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 资源同步机制
|
||||||
|
|
||||||
|
### 3.1 ResourceSynchronizer
|
||||||
|
|
||||||
|
抽象基类,用于与外部物料系统双向同步。定义在 `workstation_base.py`。
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.devices.workstation.workstation_base import ResourceSynchronizer
|
||||||
|
|
||||||
|
|
||||||
|
class MyResourceSynchronizer(ResourceSynchronizer):
|
||||||
|
def __init__(self, workstation, api_client):
|
||||||
|
super().__init__(workstation)
|
||||||
|
self.api_client = api_client
|
||||||
|
|
||||||
|
def sync_from_external(self) -> bool:
|
||||||
|
"""从外部系统拉取物料到 deck"""
|
||||||
|
external_materials = self.api_client.list_materials()
|
||||||
|
for material in external_materials:
|
||||||
|
plr_resource = self._convert_to_plr(material)
|
||||||
|
self.workstation.deck.assign_child_resource(plr_resource, coordinate)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def sync_to_external(self, plr_resource) -> bool:
|
||||||
|
"""将 deck 中的物料变更推送到外部系统"""
|
||||||
|
external_data = self._convert_from_plr(plr_resource)
|
||||||
|
self.api_client.update_material(external_data)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def handle_external_change(self, change_info) -> bool:
|
||||||
|
"""处理外部系统推送的物料变更"""
|
||||||
|
return True
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 update_resource — 上传资源树到云端
|
||||||
|
|
||||||
|
将 PLR Deck 序列化后通过 ROS 服务上传。典型使用场景:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 在 post_init 中上传初始 deck
|
||||||
|
from unilabos.ros.nodes.base_device_node import ROS2DeviceNode
|
||||||
|
|
||||||
|
ROS2DeviceNode.run_async_func(
|
||||||
|
self._ros_node.update_resource, True,
|
||||||
|
**{"resources": [self.deck]}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 在动作方法中更新特定资源
|
||||||
|
ROS2DeviceNode.run_async_func(
|
||||||
|
self._ros_node.update_resource, True,
|
||||||
|
**{"resources": [updated_plate]}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 工作流序列管理
|
||||||
|
|
||||||
|
工作站通过 `workflow_sequence` 属性管理任务队列(JSON 字符串形式)。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MyWorkstation(WorkstationBase):
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self._workflow_sequence = []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def workflow_sequence(self) -> str:
|
||||||
|
"""返回 JSON 字符串,ROS 自动发布"""
|
||||||
|
import json
|
||||||
|
return json.dumps(self._workflow_sequence)
|
||||||
|
|
||||||
|
async def append_to_workflow_sequence(self, workflow_name: str) -> Dict[str, Any]:
|
||||||
|
"""添加工作流到队列"""
|
||||||
|
self._workflow_sequence.append({
|
||||||
|
"name": workflow_name,
|
||||||
|
"status": "pending",
|
||||||
|
"created_at": time.time(),
|
||||||
|
})
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
async def clear_workflows(self) -> Dict[str, Any]:
|
||||||
|
"""清空工作流队列"""
|
||||||
|
self._workflow_sequence = []
|
||||||
|
return {"success": True}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 站间物料转移
|
||||||
|
|
||||||
|
工作站之间转移物料的模式。通过 ROS ActionClient 调用目标站的动作。
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def transfer_materials_to_another_station(
|
||||||
|
self,
|
||||||
|
target_device_id: str,
|
||||||
|
transfer_groups: list,
|
||||||
|
**kwargs,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""将物料转移到另一个工作站"""
|
||||||
|
target_node = self._children.get(target_device_id)
|
||||||
|
if not target_node:
|
||||||
|
# 通过 ROS 节点查找非子设备的目标站
|
||||||
|
pass
|
||||||
|
|
||||||
|
for group in transfer_groups:
|
||||||
|
resource = self.find_resource_by_name(group["resource_name"])
|
||||||
|
# 从本站 deck 移除
|
||||||
|
resource.unassign()
|
||||||
|
# 调用目标站的接收方法
|
||||||
|
# ...
|
||||||
|
|
||||||
|
return {"success": True, "transferred": len(transfer_groups)}
|
||||||
|
```
|
||||||
|
|
||||||
|
参考:`BioyondDispensingStation.transfer_materials_to_reaction_station`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. post_init 完整模式
|
||||||
|
|
||||||
|
`post_init` 是工作站初始化的关键阶段,此时 ROS 节点和子设备已就绪。
|
||||||
|
|
||||||
|
```python
|
||||||
|
def post_init(self, ros_node):
|
||||||
|
super().post_init(ros_node)
|
||||||
|
|
||||||
|
# 1. 初始化外部系统客户端(此时 config 已可用)
|
||||||
|
self.rpc_client = MySystemRPC(
|
||||||
|
host=self.config.get("api_host"),
|
||||||
|
api_key=self.config.get("api_key"),
|
||||||
|
)
|
||||||
|
self.hardware_interface = self.rpc_client
|
||||||
|
|
||||||
|
# 2. 启动连接监控
|
||||||
|
self.connection_monitor = ConnectionMonitor(self)
|
||||||
|
self.connection_monitor.start()
|
||||||
|
|
||||||
|
# 3. 启动 HTTP 回调服务
|
||||||
|
if hasattr(self, '_http_service_config'):
|
||||||
|
self.http_service = WorkstationHTTPService(
|
||||||
|
workstation_instance=self,
|
||||||
|
host=self._http_service_config["host"],
|
||||||
|
port=self._http_service_config["port"],
|
||||||
|
)
|
||||||
|
self.http_service.start()
|
||||||
|
|
||||||
|
# 4. 上传 deck 到云端
|
||||||
|
ROS2DeviceNode.run_async_func(
|
||||||
|
self._ros_node.update_resource, True,
|
||||||
|
**{"resources": [self.deck]}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. 初始化资源同步器(可选)
|
||||||
|
self.resource_synchronizer = MyResourceSynchronizer(self, self.rpc_client)
|
||||||
|
```
|
||||||
261
.cursor/skills/batch-insert-reagent/SKILL.md
Normal file
261
.cursor/skills/batch-insert-reagent/SKILL.md
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
---
|
||||||
|
name: batch-insert-reagent
|
||||||
|
description: Batch insert reagents into Uni-Lab platform — add chemicals with CAS, SMILES, supplier info. Use when the user wants to add reagents, insert chemicals, batch register reagents, or mentions 录入试剂/添加试剂/试剂入库/reagent.
|
||||||
|
---
|
||||||
|
|
||||||
|
# 批量录入试剂 Skill
|
||||||
|
|
||||||
|
通过云端 API 批量录入试剂信息,支持逐条或批量操作。
|
||||||
|
|
||||||
|
## 前置条件(缺一不可)
|
||||||
|
|
||||||
|
使用本 skill 前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
|
||||||
|
|
||||||
|
### 1. ak / sk → AUTH
|
||||||
|
|
||||||
|
询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。
|
||||||
|
|
||||||
|
生成 AUTH token(任选一种方式):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 方式一:Python 一行生成
|
||||||
|
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>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. --addr → BASE URL
|
||||||
|
|
||||||
|
| `--addr` 值 | BASE |
|
||||||
|
| ------------ | ----------------------------------- |
|
||||||
|
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||||
|
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||||
|
| `local` | `http://127.0.0.1:48197` |
|
||||||
|
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||||
|
|
||||||
|
确认后设置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BASE="<根据 addr 确定的 URL>"
|
||||||
|
AUTH="Authorization: Lab <gen_auth.py 输出的 token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**两项全部就绪后才可发起 API 请求。**
|
||||||
|
|
||||||
|
## Session State
|
||||||
|
|
||||||
|
- `lab_uuid` — 实验室 UUID(首次通过 API #1 自动获取,**不需要问用户**)
|
||||||
|
|
||||||
|
## 请求约定
|
||||||
|
|
||||||
|
所有请求使用 `curl -s`,POST 需加 `Content-Type: application/json`。
|
||||||
|
|
||||||
|
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名),示例中的 `curl` 均指 `curl.exe`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### 1. 获取实验室信息(自动获取 lab_uuid)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
||||||
|
```
|
||||||
|
|
||||||
|
返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
记住 `data.uuid` 为 `lab_uuid`。
|
||||||
|
|
||||||
|
### 2. 录入试剂
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$BASE/api/v1/lab/reagent" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"lab_uuid": "<lab_uuid>",
|
||||||
|
"cas": "<CAS号>",
|
||||||
|
"name": "<试剂名称>",
|
||||||
|
"molecular_formula": "<分子式>",
|
||||||
|
"smiles": "<SMILES>",
|
||||||
|
"stock_in_quantity": <入库数量>,
|
||||||
|
"unit": "<单位字符串>",
|
||||||
|
"supplier": "<供应商>",
|
||||||
|
"production_date": "<生产日期 ISO 8601>",
|
||||||
|
"expiry_date": "<过期日期 ISO 8601>"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
返回成功时包含试剂 UUID:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"code": 0, "data": {"uuid": "xxx", ...}}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 试剂字段说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 | 示例 |
|
||||||
|
| ------------------- | ------ | ---- | ----------------------------- | ------------------------ |
|
||||||
|
| `lab_uuid` | string | 是 | 实验室 UUID(从 API #1 获取) | `"8511c672-..."` |
|
||||||
|
| `cas` | string | 是 | CAS 注册号 | `"7732-18-3"` |
|
||||||
|
| `name` | string | 是 | 试剂中文/英文名称 | `"水"` |
|
||||||
|
| `molecular_formula` | string | 是 | 分子式 | `"H2O"` |
|
||||||
|
| `smiles` | string | 是 | SMILES 表示 | `"O"` |
|
||||||
|
| `stock_in_quantity` | number | 是 | 入库数量 | `10` |
|
||||||
|
| `unit` | string | 是 | 单位(字符串,见下表) | `"mL"` |
|
||||||
|
| `supplier` | string | 否 | 供应商名称 | `"国药集团"` |
|
||||||
|
| `production_date` | string | 否 | 生产日期(ISO 8601) | `"2025-11-18T00:00:00Z"` |
|
||||||
|
| `expiry_date` | string | 否 | 过期日期(ISO 8601) | `"2026-11-18T00:00:00Z"` |
|
||||||
|
|
||||||
|
### unit 单位值
|
||||||
|
|
||||||
|
| 值 | 单位 |
|
||||||
|
| ------ | ---- |
|
||||||
|
| `"mL"` | 毫升 |
|
||||||
|
| `"L"` | 升 |
|
||||||
|
| `"g"` | 克 |
|
||||||
|
| `"kg"` | 千克 |
|
||||||
|
| `"瓶"` | 瓶 |
|
||||||
|
|
||||||
|
> 根据试剂状态选择:液体用 `"mL"` / `"L"`,固体用 `"g"` / `"kg"`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 批量录入策略
|
||||||
|
|
||||||
|
### 方式一:用户提供 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Agent 自动为每条补充 `lab_uuid`、`production_date`、`expiry_date` 等字段后逐条提交。
|
||||||
|
|
||||||
|
Agent 循环调用 API #2 逐条录入,每条记录一次 API 调用。
|
||||||
|
|
||||||
|
### 方式二:用户逐个描述
|
||||||
|
|
||||||
|
用户口头描述试剂(如「帮我录入 500mL 的无水乙醇,Sigma 的」),agent 自行补全字段:
|
||||||
|
|
||||||
|
1. 根据名称查找 CAS 号、分子式、SMILES(参考下方速查表或自行推断)
|
||||||
|
2. 构建完整的请求体
|
||||||
|
3. 向用户确认后提交
|
||||||
|
|
||||||
|
### 方式三:从 CSV/表格批量导入
|
||||||
|
|
||||||
|
用户提供 CSV 或表格文件路径,agent 读取并解析:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 期望的 CSV 格式(首行为表头)
|
||||||
|
cas,name,molecular_formula,smiles,stock_in_quantity,unit,supplier,production_date,expiry_date
|
||||||
|
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 调用后:
|
||||||
|
|
||||||
|
1. 检查返回 `code`(0 = 成功)
|
||||||
|
2. 记录成功/失败数量
|
||||||
|
3. 全部完成后汇总:「共录入 N 条试剂,成功 X 条,失败 Y 条」
|
||||||
|
4. 如有失败,列出失败的试剂名称和错误信息
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见试剂速查表
|
||||||
|
|
||||||
|
| 名称 | CAS | 分子式 | SMILES |
|
||||||
|
| --------------------- | --------- | ---------- | ------------------------------------ |
|
||||||
|
| 水 | 7732-18-3 | H2O | O |
|
||||||
|
| 乙醇 | 64-17-5 | C2H6O | CCO |
|
||||||
|
| 乙酸 | 64-19-7 | C2H4O2 | CC(O)=O |
|
||||||
|
| 甲醇 | 67-56-1 | CH4O | CO |
|
||||||
|
| 丙酮 | 67-64-1 | C3H6O | CC(C)=O |
|
||||||
|
| 二甲基亚砜(DMSO) | 67-68-5 | C2H6OS | CS(C)=O |
|
||||||
|
| 乙酸乙酯 | 141-78-6 | C4H8O2 | CCOC(C)=O |
|
||||||
|
| 二氯甲烷 | 75-09-2 | CH2Cl2 | ClCCl |
|
||||||
|
| 四氢呋喃(THF) | 109-99-9 | C4H8O | C1CCOC1 |
|
||||||
|
| N,N-二甲基甲酰胺(DMF) | 68-12-2 | C3H7NO | CN(C)C=O |
|
||||||
|
| 氯仿 | 67-66-3 | CHCl3 | ClC(Cl)Cl |
|
||||||
|
| 乙腈 | 75-05-8 | C2H3N | CC#N |
|
||||||
|
| 甲苯 | 108-88-3 | C7H8 | Cc1ccccc1 |
|
||||||
|
| 正己烷 | 110-54-3 | C6H14 | CCCCCC |
|
||||||
|
| 异丙醇 | 67-63-0 | C3H8O | CC(C)O |
|
||||||
|
| 盐酸 | 7647-01-0 | HCl | Cl |
|
||||||
|
| 硫酸 | 7664-93-9 | H2SO4 | OS(O)(=O)=O |
|
||||||
|
| 氢氧化钠 | 1310-73-2 | NaOH | [Na]O |
|
||||||
|
| 碳酸钠 | 497-19-8 | Na2CO3 | [Na]OC([O-])=O.[Na+] |
|
||||||
|
| 氯化钠 | 7647-14-5 | NaCl | [Na]Cl |
|
||||||
|
| 乙二胺四乙酸(EDTA) | 60-00-4 | C10H16N2O8 | OC(=O)CN(CCN(CC(O)=O)CC(O)=O)CC(O)=O |
|
||||||
|
|
||||||
|
> 此表仅供快速参考。对于不在表中的试剂,agent 应根据化学知识推断或提示用户补充。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 完整工作流 Checklist
|
||||||
|
|
||||||
|
```
|
||||||
|
Task Progress:
|
||||||
|
- [ ] Step 1: 确认 ak/sk → 生成 AUTH token
|
||||||
|
- [ ] Step 2: 确认 --addr → 设置 BASE URL
|
||||||
|
- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid
|
||||||
|
- [ ] Step 4: 收集试剂信息(用户提供列表/逐个描述/CSV文件)
|
||||||
|
- [ ] Step 5: 补全缺失字段(CAS、分子式、SMILES 等)
|
||||||
|
- [ ] Step 6: 向用户确认待录入的试剂列表
|
||||||
|
- [ ] Step 7: 循环调用 POST /lab/reagent 逐条录入(每条需含 lab_uuid)
|
||||||
|
- [ ] Step 8: 汇总结果(成功/失败数量及详情)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 完整示例
|
||||||
|
|
||||||
|
用户说:「帮我录入 3 种试剂:500mL 无水乙醇、1kg 氯化钠、2L 去离子水」
|
||||||
|
|
||||||
|
Agent 构建的请求序列:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 第 1 条
|
||||||
|
{"lab_uuid": "8511c672-...", "cas": "64-17-5", "name": "无水乙醇", "molecular_formula": "C2H6O", "smiles": "CCO", "stock_in_quantity": 500, "unit": "mL", "supplier": "国药集团", "production_date": "2025-01-01T00:00:00Z", "expiry_date": "2026-01-01T00:00:00Z"}
|
||||||
|
|
||||||
|
// 第 2 条
|
||||||
|
{"lab_uuid": "8511c672-...", "cas": "7647-14-5", "name": "氯化钠", "molecular_formula": "NaCl", "smiles": "[Na]Cl", "stock_in_quantity": 1, "unit": "kg", "supplier": "", "production_date": "2025-01-01T00:00:00Z", "expiry_date": "2026-01-01T00:00:00Z"}
|
||||||
|
|
||||||
|
// 第 3 条
|
||||||
|
{"lab_uuid": "8511c672-...", "cas": "7732-18-3", "name": "去离子水", "molecular_formula": "H2O", "smiles": "O", "stock_in_quantity": 2, "unit": "L", "supplier": "", "production_date": "2025-01-01T00:00:00Z", "expiry_date": "2026-01-01T00:00:00Z"}
|
||||||
|
```
|
||||||
360
.cursor/skills/batch-submit-experiment/SKILL.md
Normal file
360
.cursor/skills/batch-submit-experiment/SKILL.md
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
---
|
||||||
|
name: batch-submit-experiment
|
||||||
|
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 批量提交实验指南
|
||||||
|
|
||||||
|
通过 Uni-Lab 云端 API 批量提交实验(notebook),支持多轮实验参数配置。根据 workflow 模板详情和本地设备注册表自动生成 `node_params` 模板。
|
||||||
|
|
||||||
|
> **重要**:本指南中的 `Authorization: Lab <token>` 是 **Uni-Lab 平台专用的认证方式**,`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。
|
||||||
|
|
||||||
|
## 前置条件(缺一不可)
|
||||||
|
|
||||||
|
使用本指南前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
|
||||||
|
|
||||||
|
### 1. ak / sk → AUTH
|
||||||
|
|
||||||
|
询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。
|
||||||
|
|
||||||
|
生成 AUTH token(任选一种方式):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 方式一: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>
|
||||||
|
|
||||||
|
# 方式二:手动计算
|
||||||
|
# base64(ak:sk) → Authorization: Lab <token>
|
||||||
|
# ⚠️ 这里的 "Lab" 是 Uni-Lab 平台的 auth scheme,绝对不能用 "Basic" 替代
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. --addr → BASE URL
|
||||||
|
|
||||||
|
| `--addr` 值 | BASE |
|
||||||
|
| ------------ | ----------------------------------- |
|
||||||
|
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||||
|
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||||
|
| `local` | `http://127.0.0.1:48197` |
|
||||||
|
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||||
|
|
||||||
|
确认后设置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BASE="<根据 addr 确定的 URL>"
|
||||||
|
# ⚠️ Auth scheme 必须是 "Lab"(Uni-Lab 专用),不是 "Basic"
|
||||||
|
AUTH="Authorization: Lab <上面命令输出的 token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. req_device_registry_upload.json(设备注册表)
|
||||||
|
|
||||||
|
**批量提交实验时需要本地注册表来解析 workflow 节点的参数 schema。**
|
||||||
|
|
||||||
|
**必须先用 Glob 工具搜索文件**,不要直接猜测路径:
|
||||||
|
|
||||||
|
```
|
||||||
|
Glob: **/req_device_registry_upload.json
|
||||||
|
```
|
||||||
|
|
||||||
|
常见位置(仅供参考,以 Glob 实际结果为准):
|
||||||
|
- `<workspace>/unilabos_data/req_device_registry_upload.json`
|
||||||
|
- `<workspace>/req_device_registry_upload.json`
|
||||||
|
|
||||||
|
找到后**检查文件修改时间**并告知用户。超过 1 天提醒用户是否需要重新启动 `unilab`。
|
||||||
|
|
||||||
|
**如果 Glob 搜索无结果** → 告知用户先运行 `unilab` 启动命令,等注册表生成后再执行。可跳过此步,但将无法自动生成参数模板,需要用户手动填写 `param`。
|
||||||
|
|
||||||
|
### 4. workflow_uuid(目标工作流)
|
||||||
|
|
||||||
|
用户需要提供要提交的 workflow UUID。如果用户不确定,通过 API #3 列出可用 workflow 供选择。
|
||||||
|
|
||||||
|
**四项全部就绪后才可开始。**
|
||||||
|
|
||||||
|
## Session State
|
||||||
|
|
||||||
|
在整个对话过程中,agent 需要记住以下状态,避免重复询问用户:
|
||||||
|
|
||||||
|
- `lab_uuid` — 实验室 UUID(首次通过 API #1 自动获取,**不需要问用户**)
|
||||||
|
- `project_uuid` — 项目 UUID(通过 API #2 列出项目列表,**让用户选择**)
|
||||||
|
- `workflow_uuid` — 工作流 UUID(用户提供或从列表选择)
|
||||||
|
- `workflow_nodes` — workflow 中各 action 节点的 uuid、设备 ID、动作名(从 API #4 获取)
|
||||||
|
|
||||||
|
## 请求约定
|
||||||
|
|
||||||
|
所有请求使用 `curl -s`,POST 需加 `Content-Type: application/json`。
|
||||||
|
|
||||||
|
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名),示例中的 `curl` 均指 `curl.exe`。
|
||||||
|
>
|
||||||
|
> **PowerShell JSON 传参**:PowerShell 中 `-d '{"key":"value"}'` 会因引号转义失败。请将 JSON 写入临时文件,用 `-d '@tmp_body.json'`(单引号包裹 `@`,否则会被解析为 splatting 运算符)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### 1. 获取实验室信息(自动获取 lab_uuid)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
||||||
|
```
|
||||||
|
|
||||||
|
返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
记住 `data.uuid` 为 `lab_uuid`。
|
||||||
|
|
||||||
|
### 2. 列出实验室项目(让用户选择项目)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X GET "$BASE/api/v1/lab/project/list?lab_uuid=$lab_uuid" -H "$AUTH"
|
||||||
|
```
|
||||||
|
|
||||||
|
返回:
|
||||||
|
|
||||||
|
```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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X GET "$BASE/api/v1/lab/workflow/workflows?page=1&page_size=20&lab_uuid=$lab_uuid" -H "$AUTH"
|
||||||
|
```
|
||||||
|
|
||||||
|
返回 workflow 列表,展示给用户选择。列出每个 workflow 的 `uuid` 和 `name`。
|
||||||
|
|
||||||
|
### 4. 获取 workflow 模板详情
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH"
|
||||||
|
```
|
||||||
|
|
||||||
|
返回 workflow 的完整结构,包含所有 action 节点信息。需要从响应中提取:
|
||||||
|
|
||||||
|
- 每个 action 节点的 `node_uuid`
|
||||||
|
- 每个节点对应的设备 ID(`resource_template_name`)
|
||||||
|
- 每个节点的动作名(`node_template_name`)
|
||||||
|
- 每个节点的现有参数(`param`)
|
||||||
|
|
||||||
|
> **注意**:此 API 返回格式可能因版本不同而有差异。首次调用时,先打印完整响应分析结构,再提取节点信息。常见的节点字段路径为 `data.nodes[]` 或 `data.workflow_nodes[]`。
|
||||||
|
|
||||||
|
### 5. 提交实验(创建 notebook)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$BASE/api/v1/lab/notebook" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '<request_body>'
|
||||||
|
```
|
||||||
|
|
||||||
|
请求体结构:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"lab_uuid": "<lab_uuid>",
|
||||||
|
"project_uuid": "<project_uuid>",
|
||||||
|
"workflow_uuid": "<workflow_uuid>",
|
||||||
|
"name": "<实验名称>",
|
||||||
|
"node_params": [
|
||||||
|
{
|
||||||
|
"sample_uuids": ["<样品UUID1>", "<样品UUID2>"],
|
||||||
|
"datas": [
|
||||||
|
{
|
||||||
|
"node_uuid": "<workflow中的节点UUID>",
|
||||||
|
"param": {},
|
||||||
|
"sample_params": [
|
||||||
|
{
|
||||||
|
"container_uuid": "<容器UUID>",
|
||||||
|
"sample_value": {
|
||||||
|
"liquid_names": "<液体名称>",
|
||||||
|
"volumes": 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意**:`sample_uuids` 必须是 **UUID 数组**(`[]uuid.UUID`),不是字符串。无样品时传空数组 `[]`。
|
||||||
|
|
||||||
|
### 6. 查询 notebook 状态
|
||||||
|
|
||||||
|
提交成功后,使用返回的 notebook UUID 查询执行状态:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X GET "$BASE/api/v1/lab/notebook/status?uuid=$notebook_uuid" -H "$AUTH"
|
||||||
|
```
|
||||||
|
|
||||||
|
提交后应**立即查询一次**状态,确认 notebook 已被正确接收并开始调度。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notebook 请求体详解
|
||||||
|
|
||||||
|
### node_params 结构
|
||||||
|
|
||||||
|
`node_params` 是一个数组,**每个元素代表一轮实验**:
|
||||||
|
|
||||||
|
- 要跑 2 轮 → `node_params` 有 2 个元素
|
||||||
|
- 要跑 N 轮 → `node_params` 有 N 个元素
|
||||||
|
|
||||||
|
### 每轮的字段
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| -------------- | ------------- | ----------------------------------------- |
|
||||||
|
| `sample_uuids` | array\<uuid\> | 该轮实验的样品 UUID 数组,无样品时传 `[]` |
|
||||||
|
| `datas` | array | 该轮中每个 workflow 节点的参数配置 |
|
||||||
|
|
||||||
|
### datas 中每个节点
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --------------- | ------ | -------------------------------------------- |
|
||||||
|
| `node_uuid` | string | workflow 模板中的节点 UUID(从 API #4 获取) |
|
||||||
|
| `param` | object | 动作参数(根据本地注册表 schema 填写) |
|
||||||
|
| `sample_params` | array | 样品相关参数(液体名、体积等) |
|
||||||
|
|
||||||
|
### sample_params 中每条
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| ---------------- | ------ | ---------------------------------------------------- |
|
||||||
|
| `container_uuid` | string | 容器 UUID |
|
||||||
|
| `sample_value` | object | 样品值,如 `{"liquid_names": "水", "volumes": 1000}` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 从本地注册表生成 param 模板
|
||||||
|
|
||||||
|
### 自动方式 — 运行脚本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/gen_notebook_params.py \
|
||||||
|
--auth <token> \
|
||||||
|
--base <BASE_URL> \
|
||||||
|
--workflow-uuid <workflow_uuid> \
|
||||||
|
[--registry <path/to/req_device_registry_upload.json>] \
|
||||||
|
[--rounds <轮次数>] \
|
||||||
|
[--output <输出文件路径>]
|
||||||
|
```
|
||||||
|
|
||||||
|
> 脚本位于本文档同级目录下的 `scripts/gen_notebook_params.py`。
|
||||||
|
|
||||||
|
脚本会:
|
||||||
|
|
||||||
|
1. 调用 workflow detail API 获取所有 action 节点
|
||||||
|
2. 读取本地注册表,为每个节点查找对应的 action schema
|
||||||
|
3. 生成 `notebook_template.json`,包含:
|
||||||
|
- 完整 `node_params` 骨架
|
||||||
|
- 每个节点的 param 字段及类型说明
|
||||||
|
- `_schema_info` 辅助信息(不提交,仅供参考)
|
||||||
|
|
||||||
|
### 手动方式
|
||||||
|
|
||||||
|
如果脚本不可用或注册表不存在:
|
||||||
|
|
||||||
|
1. 调用 API #4 获取 workflow 详情
|
||||||
|
2. 找到每个 action 节点的 `node_uuid`
|
||||||
|
3. 在本地注册表中查找对应设备的 `action_value_mappings`:
|
||||||
|
```
|
||||||
|
resources[].id == <device_id>
|
||||||
|
→ resources[].class.action_value_mappings.<action_name>.schema.properties.goal.properties
|
||||||
|
```
|
||||||
|
4. 将 schema 中的 properties 作为 `param` 的字段模板
|
||||||
|
5. 按轮次复制 `node_params` 元素,让用户填写每轮的具体值
|
||||||
|
|
||||||
|
### 注册表结构参考
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"resources": [
|
||||||
|
{
|
||||||
|
"id": "liquid_handler.prcxi",
|
||||||
|
"class": {
|
||||||
|
"module": "unilabos.devices.xxx:ClassName",
|
||||||
|
"action_value_mappings": {
|
||||||
|
"transfer_liquid": {
|
||||||
|
"type": "LiquidHandlerTransfer",
|
||||||
|
"schema": {
|
||||||
|
"properties": {
|
||||||
|
"goal": {
|
||||||
|
"properties": {
|
||||||
|
"asp_vols": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "number" }
|
||||||
|
},
|
||||||
|
"sources": { "type": "array" }
|
||||||
|
},
|
||||||
|
"required": ["asp_vols", "sources"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"goal_default": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`param` 填写时,使用 `goal.properties` 中的字段名和类型。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 完整工作流 Checklist
|
||||||
|
|
||||||
|
```
|
||||||
|
Task Progress:
|
||||||
|
- [ ] Step 1: 确认 ak/sk → 生成 AUTH token
|
||||||
|
- [ ] Step 2: 确认 --addr → 设置 BASE URL
|
||||||
|
- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid
|
||||||
|
- [ ] Step 4: GET /lab/project/list → 列出项目,让用户选择 → 获取 project_uuid
|
||||||
|
- [ ] Step 5: 确认 workflow_uuid(用户提供或从 GET #3 列表选择)
|
||||||
|
- [ ] Step 6: GET workflow detail (#4) → 提取各节点 uuid、设备ID、动作名
|
||||||
|
- [ ] Step 7: 定位本地注册表 req_device_registry_upload.json
|
||||||
|
- [ ] Step 8: 运行 gen_notebook_params.py 或手动匹配 → 生成 node_params 模板
|
||||||
|
- [ ] Step 9: 引导用户填写每轮的参数(sample_uuids、param、sample_params)
|
||||||
|
- [ ] Step 10: 构建完整请求体(含 project_uuid)→ POST /lab/notebook 提交
|
||||||
|
- [ ] Step 11: 检查返回结果,记录 notebook UUID
|
||||||
|
- [ ] Step 12: GET /lab/notebook/status → 查询 notebook 状态,确认已调度
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q: workflow 中有多个节点,每轮都要填所有节点的参数吗?
|
||||||
|
|
||||||
|
是的。`datas` 数组中需要包含该轮实验涉及的每个 workflow 节点的参数。通常每个 action 节点都需要一条 `datas` 记录。
|
||||||
|
|
||||||
|
### Q: 多轮实验的参数完全不同吗?
|
||||||
|
|
||||||
|
通常每轮的 `param`(设备动作参数)可能相同或相似,但 `sample_uuids` 和 `sample_params`(样品信息)每轮不同。脚本生成模板时会按轮次复制骨架,用户只需修改差异部分。
|
||||||
|
|
||||||
|
### Q: 如何获取 sample_uuids 和 container_uuid?
|
||||||
|
|
||||||
|
这些 UUID 通常来自实验室的样品管理系统。向用户询问,或从资源树(API `GET /lab/material/download/$lab_uuid`)中查找。
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
从 workflow 模板详情 + 本地设备注册表生成 notebook 提交用的 node_params 模板。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python gen_notebook_params.py --auth <token> --base <url> --workflow-uuid <uuid> [选项]
|
||||||
|
|
||||||
|
选项:
|
||||||
|
--auth <token> Lab token(base64(ak:sk) 的结果,不含 "Lab " 前缀)
|
||||||
|
--base <url> API 基础 URL(如 https://leap-lab.test.bohrium.com)
|
||||||
|
--workflow-uuid <uuid> 目标 workflow 的 UUID
|
||||||
|
--registry <path> 本地注册表文件路径(默认自动搜索)
|
||||||
|
--rounds <n> 实验轮次数(默认 1)
|
||||||
|
--output <path> 输出模板文件路径(默认 notebook_template.json)
|
||||||
|
--dump-response 打印 workflow detail API 的原始响应(调试用)
|
||||||
|
|
||||||
|
示例:
|
||||||
|
python gen_notebook_params.py \\
|
||||||
|
--auth YTFmZDlkNGUtxxxx \\
|
||||||
|
--base https://leap-lab.test.bohrium.com \\
|
||||||
|
--workflow-uuid abc-123-def \\
|
||||||
|
--rounds 2
|
||||||
|
"""
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
|
||||||
|
REGISTRY_FILENAME = "req_device_registry_upload.json"
|
||||||
|
|
||||||
|
|
||||||
|
def find_registry(explicit_path=None):
|
||||||
|
"""查找本地注册表文件,逻辑同 extract_device_actions.py"""
|
||||||
|
if explicit_path:
|
||||||
|
if os.path.isfile(explicit_path):
|
||||||
|
return explicit_path
|
||||||
|
if os.path.isdir(explicit_path):
|
||||||
|
fp = os.path.join(explicit_path, REGISTRY_FILENAME)
|
||||||
|
if os.path.isfile(fp):
|
||||||
|
return fp
|
||||||
|
print(f"警告: 指定的注册表路径不存在: {explicit_path}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
candidates = [
|
||||||
|
os.path.join("unilabos_data", REGISTRY_FILENAME),
|
||||||
|
REGISTRY_FILENAME,
|
||||||
|
]
|
||||||
|
for c in candidates:
|
||||||
|
if os.path.isfile(c):
|
||||||
|
return c
|
||||||
|
|
||||||
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
workspace_root = os.path.normpath(os.path.join(script_dir, "..", "..", ".."))
|
||||||
|
for c in candidates:
|
||||||
|
path = os.path.join(workspace_root, c)
|
||||||
|
if os.path.isfile(path):
|
||||||
|
return path
|
||||||
|
|
||||||
|
cwd = os.getcwd()
|
||||||
|
for _ in range(5):
|
||||||
|
parent = os.path.dirname(cwd)
|
||||||
|
if parent == cwd:
|
||||||
|
break
|
||||||
|
cwd = parent
|
||||||
|
for c in candidates:
|
||||||
|
path = os.path.join(cwd, c)
|
||||||
|
if os.path.isfile(path):
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_registry(path):
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def build_registry_index(registry_data):
|
||||||
|
"""构建 device_id → action_value_mappings 的索引"""
|
||||||
|
index = {}
|
||||||
|
for res in registry_data.get("resources", []):
|
||||||
|
rid = res.get("id", "")
|
||||||
|
avm = res.get("class", {}).get("action_value_mappings", {})
|
||||||
|
if rid and avm:
|
||||||
|
index[rid] = avm
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
|
def flatten_goal_schema(action_data):
|
||||||
|
"""从 action_value_mappings 条目中提取 goal 层的 schema"""
|
||||||
|
schema = action_data.get("schema", {})
|
||||||
|
goal_schema = schema.get("properties", {}).get("goal", {})
|
||||||
|
return goal_schema if goal_schema else schema
|
||||||
|
|
||||||
|
|
||||||
|
def build_param_template(goal_schema):
|
||||||
|
"""根据 goal schema 生成 param 模板,含类型标注"""
|
||||||
|
properties = goal_schema.get("properties", {})
|
||||||
|
required = set(goal_schema.get("required", []))
|
||||||
|
template = {}
|
||||||
|
for field_name, field_def in properties.items():
|
||||||
|
if field_name == "unilabos_device_id":
|
||||||
|
continue
|
||||||
|
ftype = field_def.get("type", "any")
|
||||||
|
default = field_def.get("default")
|
||||||
|
if default is not None:
|
||||||
|
template[field_name] = default
|
||||||
|
elif ftype == "string":
|
||||||
|
template[field_name] = f"$TODO ({ftype}, {'required' if field_name in required else 'optional'})"
|
||||||
|
elif ftype == "number" or ftype == "integer":
|
||||||
|
template[field_name] = 0
|
||||||
|
elif ftype == "boolean":
|
||||||
|
template[field_name] = False
|
||||||
|
elif ftype == "array":
|
||||||
|
template[field_name] = []
|
||||||
|
elif ftype == "object":
|
||||||
|
template[field_name] = {}
|
||||||
|
else:
|
||||||
|
template[field_name] = f"$TODO ({ftype})"
|
||||||
|
return template
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_workflow_detail(base_url, auth_token, workflow_uuid):
|
||||||
|
"""调用 workflow detail API"""
|
||||||
|
url = f"{base_url}/api/v1/lab/workflow/template/detail/{workflow_uuid}"
|
||||||
|
req = Request(url, method="GET")
|
||||||
|
req.add_header("Authorization", f"Lab {auth_token}")
|
||||||
|
try:
|
||||||
|
with urlopen(req, timeout=30) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
except HTTPError as e:
|
||||||
|
body = e.read().decode("utf-8", errors="replace")
|
||||||
|
print(f"API 错误 {e.code}: {body}")
|
||||||
|
return None
|
||||||
|
except URLError as e:
|
||||||
|
print(f"网络错误: {e.reason}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_nodes_from_response(response):
|
||||||
|
"""
|
||||||
|
从 workflow detail 响应中提取 action 节点列表。
|
||||||
|
适配多种可能的响应格式。
|
||||||
|
|
||||||
|
返回: [(node_uuid, resource_template_name, node_template_name, existing_param), ...]
|
||||||
|
"""
|
||||||
|
data = response.get("data", response)
|
||||||
|
|
||||||
|
search_keys = ["nodes", "workflow_nodes", "node_list", "steps"]
|
||||||
|
nodes_raw = None
|
||||||
|
for key in search_keys:
|
||||||
|
if key in data and isinstance(data[key], list):
|
||||||
|
nodes_raw = data[key]
|
||||||
|
break
|
||||||
|
|
||||||
|
if nodes_raw is None:
|
||||||
|
if isinstance(data, list):
|
||||||
|
nodes_raw = data
|
||||||
|
else:
|
||||||
|
for v in data.values():
|
||||||
|
if isinstance(v, list) and len(v) > 0 and isinstance(v[0], dict):
|
||||||
|
nodes_raw = v
|
||||||
|
break
|
||||||
|
|
||||||
|
if not nodes_raw:
|
||||||
|
print("警告: 未能从响应中提取节点列表")
|
||||||
|
print("响应顶层 keys:", list(data.keys()) if isinstance(data, dict) else type(data).__name__)
|
||||||
|
return []
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for node in nodes_raw:
|
||||||
|
if not isinstance(node, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
node_uuid = (
|
||||||
|
node.get("uuid")
|
||||||
|
or node.get("node_uuid")
|
||||||
|
or node.get("id")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
resource_name = (
|
||||||
|
node.get("resource_template_name")
|
||||||
|
or node.get("device_id")
|
||||||
|
or node.get("resource_name")
|
||||||
|
or node.get("device_name")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
template_name = (
|
||||||
|
node.get("node_template_name")
|
||||||
|
or node.get("action_name")
|
||||||
|
or node.get("template_name")
|
||||||
|
or node.get("action")
|
||||||
|
or node.get("name")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
existing_param = node.get("param", {}) or {}
|
||||||
|
|
||||||
|
if node_uuid:
|
||||||
|
result.append((node_uuid, resource_name, template_name, existing_param))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def generate_template(nodes, registry_index, rounds):
|
||||||
|
"""生成 notebook 提交模板"""
|
||||||
|
node_params = []
|
||||||
|
schema_info = {}
|
||||||
|
|
||||||
|
datas_template = []
|
||||||
|
for node_uuid, resource_name, template_name, existing_param in nodes:
|
||||||
|
param_template = {}
|
||||||
|
matched = False
|
||||||
|
|
||||||
|
if resource_name and template_name and resource_name in registry_index:
|
||||||
|
avm = registry_index[resource_name]
|
||||||
|
if template_name in avm:
|
||||||
|
goal_schema = flatten_goal_schema(avm[template_name])
|
||||||
|
param_template = build_param_template(goal_schema)
|
||||||
|
goal_default = avm[template_name].get("goal_default", {})
|
||||||
|
if goal_default:
|
||||||
|
for k, v in goal_default.items():
|
||||||
|
if k in param_template and v is not None:
|
||||||
|
param_template[k] = v
|
||||||
|
matched = True
|
||||||
|
|
||||||
|
schema_info[node_uuid] = {
|
||||||
|
"device_id": resource_name,
|
||||||
|
"action_name": template_name,
|
||||||
|
"action_type": avm[template_name].get("type", ""),
|
||||||
|
"schema_properties": list(goal_schema.get("properties", {}).keys()),
|
||||||
|
"required": goal_schema.get("required", []),
|
||||||
|
}
|
||||||
|
|
||||||
|
if not matched and existing_param:
|
||||||
|
param_template = existing_param
|
||||||
|
|
||||||
|
if not matched and not existing_param:
|
||||||
|
schema_info[node_uuid] = {
|
||||||
|
"device_id": resource_name,
|
||||||
|
"action_name": template_name,
|
||||||
|
"warning": "未在本地注册表中找到匹配的 action schema",
|
||||||
|
}
|
||||||
|
|
||||||
|
datas_template.append({
|
||||||
|
"node_uuid": node_uuid,
|
||||||
|
"param": param_template,
|
||||||
|
"sample_params": [
|
||||||
|
{
|
||||||
|
"container_uuid": "$TODO_CONTAINER_UUID",
|
||||||
|
"sample_value": {
|
||||||
|
"liquid_names": "$TODO_LIQUID_NAME",
|
||||||
|
"volumes": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
for i in range(rounds):
|
||||||
|
node_params.append({
|
||||||
|
"sample_uuids": f"$TODO_SAMPLE_UUID_ROUND_{i + 1}",
|
||||||
|
"datas": copy.deepcopy(datas_template),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"lab_uuid": "$TODO_LAB_UUID",
|
||||||
|
"project_uuid": "$TODO_PROJECT_UUID",
|
||||||
|
"workflow_uuid": "$TODO_WORKFLOW_UUID",
|
||||||
|
"name": "$TODO_EXPERIMENT_NAME",
|
||||||
|
"node_params": node_params,
|
||||||
|
"_schema_info(仅参考,提交时删除)": schema_info,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv):
|
||||||
|
"""简单的参数解析"""
|
||||||
|
opts = {
|
||||||
|
"auth": None,
|
||||||
|
"base": None,
|
||||||
|
"workflow_uuid": None,
|
||||||
|
"registry": None,
|
||||||
|
"rounds": 1,
|
||||||
|
"output": "notebook_template.json",
|
||||||
|
"dump_response": False,
|
||||||
|
}
|
||||||
|
i = 0
|
||||||
|
while i < len(argv):
|
||||||
|
arg = argv[i]
|
||||||
|
if arg == "--auth" and i + 1 < len(argv):
|
||||||
|
opts["auth"] = argv[i + 1]
|
||||||
|
i += 2
|
||||||
|
elif arg == "--base" and i + 1 < len(argv):
|
||||||
|
opts["base"] = argv[i + 1].rstrip("/")
|
||||||
|
i += 2
|
||||||
|
elif arg == "--workflow-uuid" and i + 1 < len(argv):
|
||||||
|
opts["workflow_uuid"] = argv[i + 1]
|
||||||
|
i += 2
|
||||||
|
elif arg == "--registry" and i + 1 < len(argv):
|
||||||
|
opts["registry"] = argv[i + 1]
|
||||||
|
i += 2
|
||||||
|
elif arg == "--rounds" and i + 1 < len(argv):
|
||||||
|
opts["rounds"] = int(argv[i + 1])
|
||||||
|
i += 2
|
||||||
|
elif arg == "--output" and i + 1 < len(argv):
|
||||||
|
opts["output"] = argv[i + 1]
|
||||||
|
i += 2
|
||||||
|
elif arg == "--dump-response":
|
||||||
|
opts["dump_response"] = True
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
print(f"未知参数: {arg}")
|
||||||
|
i += 1
|
||||||
|
return opts
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
opts = parse_args(sys.argv[1:])
|
||||||
|
|
||||||
|
if not opts["auth"] or not opts["base"] or not opts["workflow_uuid"]:
|
||||||
|
print("用法:")
|
||||||
|
print(" python gen_notebook_params.py --auth <token> --base <url> --workflow-uuid <uuid> [选项]")
|
||||||
|
print()
|
||||||
|
print("必需参数:")
|
||||||
|
print(" --auth <token> Lab token(base64(ak:sk))")
|
||||||
|
print(" --base <url> API 基础 URL")
|
||||||
|
print(" --workflow-uuid <uuid> 目标 workflow UUID")
|
||||||
|
print()
|
||||||
|
print("可选参数:")
|
||||||
|
print(" --registry <path> 注册表文件路径(默认自动搜索)")
|
||||||
|
print(" --rounds <n> 实验轮次数(默认 1)")
|
||||||
|
print(" --output <path> 输出文件路径(默认 notebook_template.json)")
|
||||||
|
print(" --dump-response 打印 API 原始响应")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 1. 查找并加载本地注册表
|
||||||
|
registry_path = find_registry(opts["registry"])
|
||||||
|
registry_index = {}
|
||||||
|
if registry_path:
|
||||||
|
mtime = os.path.getmtime(registry_path)
|
||||||
|
gen_time = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
print(f"注册表: {registry_path} (生成时间: {gen_time})")
|
||||||
|
registry_data = load_registry(registry_path)
|
||||||
|
registry_index = build_registry_index(registry_data)
|
||||||
|
print(f"已索引 {len(registry_index)} 个设备的 action schemas")
|
||||||
|
else:
|
||||||
|
print("警告: 未找到本地注册表,将跳过 param 模板生成")
|
||||||
|
print(" 提交时需要手动填写各节点的 param 字段")
|
||||||
|
|
||||||
|
# 2. 获取 workflow 详情
|
||||||
|
print(f"\n正在获取 workflow 详情: {opts['workflow_uuid']}")
|
||||||
|
response = fetch_workflow_detail(opts["base"], opts["auth"], opts["workflow_uuid"])
|
||||||
|
if not response:
|
||||||
|
print("错误: 无法获取 workflow 详情")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if opts["dump_response"]:
|
||||||
|
print("\n=== API 原始响应 ===")
|
||||||
|
print(json.dumps(response, indent=2, ensure_ascii=False)[:5000])
|
||||||
|
print("=== 响应结束(截断至 5000 字符) ===\n")
|
||||||
|
|
||||||
|
# 3. 提取节点
|
||||||
|
nodes = extract_nodes_from_response(response)
|
||||||
|
if not nodes:
|
||||||
|
print("错误: 未能从 workflow 中提取任何 action 节点")
|
||||||
|
print("请使用 --dump-response 查看原始响应结构")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"\n找到 {len(nodes)} 个 action 节点:")
|
||||||
|
print(f" {'节点 UUID':<40} {'设备 ID':<30} {'动作名':<25} {'Schema'}")
|
||||||
|
print(" " + "-" * 110)
|
||||||
|
for node_uuid, resource_name, template_name, _ in nodes:
|
||||||
|
matched = "✓" if (resource_name in registry_index and
|
||||||
|
template_name in registry_index.get(resource_name, {})) else "✗"
|
||||||
|
print(f" {node_uuid:<40} {resource_name:<30} {template_name:<25} {matched}")
|
||||||
|
|
||||||
|
# 4. 生成模板
|
||||||
|
template = generate_template(nodes, registry_index, opts["rounds"])
|
||||||
|
template["workflow_uuid"] = opts["workflow_uuid"]
|
||||||
|
|
||||||
|
output_path = opts["output"]
|
||||||
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(template, f, indent=2, ensure_ascii=False)
|
||||||
|
print(f"\n模板已写入: {output_path}")
|
||||||
|
print(f" 轮次数: {opts['rounds']}")
|
||||||
|
print(f" 节点数/轮: {len(nodes)}")
|
||||||
|
print()
|
||||||
|
print("下一步:")
|
||||||
|
print(" 1. 打开模板文件,将 $TODO 占位符替换为实际值")
|
||||||
|
print(" 2. 删除 _schema_info 字段(仅供参考)")
|
||||||
|
print(" 3. 使用 POST /api/v1/lab/notebook 提交")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
500
.cursor/skills/create-device-skill/SKILL.md
Normal file
500
.cursor/skills/create-device-skill/SKILL.md
Normal file
@@ -0,0 +1,500 @@
|
|||||||
|
---
|
||||||
|
name: create-device-skill
|
||||||
|
description: Create a skill for any Uni-Lab device by extracting action schemas from the device registry. Use when the user wants to create a new device skill, add device API documentation, or set up action schemas for a device.
|
||||||
|
---
|
||||||
|
|
||||||
|
# 创建设备 Skill 指南
|
||||||
|
|
||||||
|
本 meta-skill 教你如何为任意 Uni-Lab-OS 设备创建完整的 API 操作技能(参考 `unilab-device-api` 的成功案例)。
|
||||||
|
|
||||||
|
## 数据源
|
||||||
|
|
||||||
|
- **设备注册表**: `unilabos_data/req_device_registry_upload.json`
|
||||||
|
- **结构**: `{ "resources": [{ "id": "<device_id>", "class": { "module": "<python_module:ClassName>", "action_value_mappings": { ... } } }] }`
|
||||||
|
- **生成时机**: `unilab` 启动并完成注册表上传后自动生成
|
||||||
|
- **module 字段**: 格式 `unilabos.devices.xxx.yyy:ClassName`,可转为源码路径 `unilabos/devices/xxx/yyy.py`,阅读源码可了解参数含义和设备行为
|
||||||
|
|
||||||
|
## 创建流程
|
||||||
|
|
||||||
|
### Step 0 — 收集必备信息(缺一不可,否则询问后终止)
|
||||||
|
|
||||||
|
开始前**必须**确认以下 4 项信息全部就绪。如果用户未提供任何一项,**立即询问并终止当前流程**,等用户补齐后再继续。
|
||||||
|
|
||||||
|
向用户提问:「请提供你的 unilab 启动参数,我需要以下信息:」
|
||||||
|
|
||||||
|
#### 必备项 ①:ak / sk(认证凭据)
|
||||||
|
|
||||||
|
来源:启动命令的 `--ak` `--sk` 参数,或 config.py 中的 `ak = "..."` `sk = "..."`。
|
||||||
|
|
||||||
|
获取后立即生成 AUTH token:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python ./scripts/gen_auth.py <ak> <sk>
|
||||||
|
# 或从 config.py 提取
|
||||||
|
python ./scripts/gen_auth.py --config <config.py>
|
||||||
|
```
|
||||||
|
|
||||||
|
认证算法:`base64(ak:sk)` → `Authorization: Lab <token>`
|
||||||
|
|
||||||
|
#### 必备项 ②:--addr(目标环境)
|
||||||
|
|
||||||
|
决定 API 请求发往哪个服务器。从启动命令的 `--addr` 参数获取:
|
||||||
|
|
||||||
|
| `--addr` 值 | BASE URL |
|
||||||
|
| -------------- | ----------------------------------- |
|
||||||
|
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||||
|
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||||
|
| `local` | `http://127.0.0.1:48197` |
|
||||||
|
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||||
|
| 其他自定义 URL | 直接使用该 URL |
|
||||||
|
|
||||||
|
#### 必备项 ③:req_device_registry_upload.json(设备注册表)
|
||||||
|
|
||||||
|
数据文件由 `unilab` 启动时自动生成,需要定位它:
|
||||||
|
|
||||||
|
**推断 working_dir**(即 `unilabos_data` 所在目录):
|
||||||
|
|
||||||
|
| 条件 | working_dir 取值 |
|
||||||
|
| -------------------- | -------------------------------------------------------- |
|
||||||
|
| 传了 `--working_dir` | `<working_dir>/unilabos_data/`(若子目录已存在则直接用) |
|
||||||
|
| 仅传了 `--config` | `<config 文件所在目录>/unilabos_data/` |
|
||||||
|
| 都没传 | `<当前工作目录>/unilabos_data/` |
|
||||||
|
|
||||||
|
**按优先级搜索文件**:
|
||||||
|
|
||||||
|
```
|
||||||
|
<推断的 working_dir>/unilabos_data/req_device_registry_upload.json
|
||||||
|
<推断的 working_dir>/req_device_registry_upload.json
|
||||||
|
<workspace 根目录>/unilabos_data/req_device_registry_upload.json
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以直接 Glob 搜索:`**/req_device_registry_upload.json`
|
||||||
|
|
||||||
|
找到后**必须检查文件修改时间**并告知用户:「找到注册表文件 `<路径>`,生成于 `<时间>`。请确认这是最近一次启动生成的。」超过 1 天提醒用户是否需要重新启动 `unilab`。
|
||||||
|
|
||||||
|
**如果文件不存在** → 告知用户先运行 `unilab` 启动命令,等日志出现 `注册表响应数据已保存` 后再执行本流程。**终止。**
|
||||||
|
|
||||||
|
#### 必备项 ④:目标设备
|
||||||
|
|
||||||
|
用户需要明确要为哪个设备创建 skill。可以是设备名称(如「PRCXI 移液站」)或 device_id(如 `liquid_handler.prcxi`)。
|
||||||
|
|
||||||
|
如果用户不确定,运行提取脚本列出所有设备供选择:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python ./scripts/extract_device_actions.py --registry <找到的文件路径>
|
||||||
|
```
|
||||||
|
|
||||||
|
**四项全部就绪后才进入 Step 1。**
|
||||||
|
|
||||||
|
### Step 1 — 列出可用设备
|
||||||
|
|
||||||
|
运行提取脚本,列出所有设备及 action 数量和 Python 源码路径,让用户选择:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 自动搜索(默认在 unilabos_data/ 和当前目录查找)
|
||||||
|
python ./scripts/extract_device_actions.py
|
||||||
|
|
||||||
|
# 指定注册表文件路径
|
||||||
|
python ./scripts/extract_device_actions.py --registry <path/to/req_device_registry_upload.json>
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本输出包含每个设备的 **Python 源码路径**(从 `class.module` 转换),可用于后续阅读源码理解参数含义。
|
||||||
|
|
||||||
|
### Step 2 — 提取 Action Schema
|
||||||
|
|
||||||
|
用户选择设备后,运行提取脚本:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./skills/<skill-name>/actions/
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本会显示设备的 Python 源码路径和类名,方便阅读源码了解参数含义。
|
||||||
|
|
||||||
|
每个 action 生成一个 JSON 文件,包含:
|
||||||
|
|
||||||
|
- `type` — 作为 API 调用的 `action_type`
|
||||||
|
- `schema` — 完整 JSON Schema(含 `properties.goal.properties` 参数定义)
|
||||||
|
- `goal` — goal 字段映射(含占位符 `$placeholder`)
|
||||||
|
- `goal_default` — 默认值
|
||||||
|
|
||||||
|
### Step 3 — 写 action-index.md
|
||||||
|
|
||||||
|
按模板为每个 action 写条目(**必须包含 `action_type`**):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### `<action_name>`
|
||||||
|
|
||||||
|
<用途描述(一句话)>
|
||||||
|
|
||||||
|
- **action_type**: `<从 actions/<name>.json 的 type 字段获取>`
|
||||||
|
- **Schema**: [`actions/<filename>.json`](actions/<filename>.json)
|
||||||
|
- **核心参数**: `param1`, `param2`(从 schema.required 获取)
|
||||||
|
- **可选参数**: `param3`, `param4`
|
||||||
|
- **占位符字段**: `field`(需填入物料信息,值以 `$` 开头)
|
||||||
|
```
|
||||||
|
|
||||||
|
描述规则:
|
||||||
|
|
||||||
|
- **每个 action 必须标注 `action_type`**(从 JSON 的 `type` 字段读取),这是 API #9 调用时的必填参数,传错会导致任务永远卡住
|
||||||
|
- 从 `schema.properties` 读参数列表(schema 已提升为 goal 内容)
|
||||||
|
- 从 `schema.required` 区分核心/可选参数
|
||||||
|
- 按功能分类(移液、枪头、外设等)
|
||||||
|
- 标注 `placeholder_keys` 中的字段类型:
|
||||||
|
- `unilabos_resources` → **ResourceSlot**,填入 `{id, name, uuid}`(id 是路径格式,从资源树取物料节点)
|
||||||
|
- `unilabos_devices` → **DeviceSlot**,填入路径字符串如 `"/host_node"`(从资源树筛选 type=device)
|
||||||
|
- `unilabos_nodes` → **NodeSlot**,填入路径字符串如 `"/PRCXI/PRCXI_Deck"`(资源树中任意节点)
|
||||||
|
- `unilabos_class` → **ClassSlot**,填入类名字符串如 `"container"`(从注册表查找)
|
||||||
|
- `unilabos_formulation` → **FormulationSlot**,填入配方数组 `[{well_name, liquids: [{name, volume}]}]`(well_name 为目标物料的 name)
|
||||||
|
- array 类型字段 → `[{id, name, uuid}, ...]`
|
||||||
|
- 特殊:`create_resource` 的 `res_id`(ResourceSlot)可填不存在的路径
|
||||||
|
|
||||||
|
### Step 4 — 写 SKILL.md
|
||||||
|
|
||||||
|
直接复用 `unilab-device-api` 的 API 模板,修改:
|
||||||
|
|
||||||
|
- 设备名称
|
||||||
|
- Action 数量
|
||||||
|
- 目录列表
|
||||||
|
- Session state 中的 `device_name`
|
||||||
|
- **AUTH 头** — 使用 Step 0 中 `gen_auth.py` 生成的 `Authorization: Lab <token>`(不要硬编码 `Api` 类型的 key)
|
||||||
|
- **Python 源码路径** — 在 SKILL.md 开头注明设备对应的源码文件,方便参考参数含义
|
||||||
|
- **Slot 字段表** — 列出本设备哪些 action 的哪些字段需要填入 Slot(物料/设备/节点/类名)
|
||||||
|
- **action_type 速查表** — 在 API #9 说明后面紧跟一个表格,列出每个 action 对应的 `action_type` 值(从 JSON `type` 字段提取),方便 agent 快速查找而无需打开 JSON 文件
|
||||||
|
|
||||||
|
API 模板结构:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 设备信息
|
||||||
|
|
||||||
|
- device_id, Python 源码路径, 设备类名
|
||||||
|
|
||||||
|
## 前置条件(缺一不可)
|
||||||
|
|
||||||
|
- ak/sk → AUTH, --addr → BASE URL
|
||||||
|
|
||||||
|
## 请求约定
|
||||||
|
|
||||||
|
- Windows 平台必须用 curl.exe(非 PowerShell 的 curl 别名)
|
||||||
|
|
||||||
|
## Session State
|
||||||
|
|
||||||
|
- lab_uuid(通过 GET /edge/lab/info 直接获取,不要问用户), device_name
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
# - #1 GET /edge/lab/info → 直接拿到 lab_uuid
|
||||||
|
|
||||||
|
# - #2 创建工作流 POST /lab/workflow/owner → 拼 URL 告知用户
|
||||||
|
|
||||||
|
# - #3 创建节点 POST /edge/workflow/node
|
||||||
|
|
||||||
|
# body: {workflow_uuid, resource_template_name: "<device_id>", node_template_name: "<action_name>"}
|
||||||
|
|
||||||
|
# - #4 删除节点 DELETE /lab/workflow/nodes
|
||||||
|
|
||||||
|
# - #5 更新节点参数 PATCH /lab/workflow/node
|
||||||
|
|
||||||
|
# - #6 查询节点 handles POST /lab/workflow/node-handles
|
||||||
|
|
||||||
|
# body: {node_uuids: ["uuid1","uuid2"]} → 返回各节点的 handle_uuid
|
||||||
|
|
||||||
|
# - #7 批量创建边 POST /lab/workflow/edges
|
||||||
|
|
||||||
|
# body: {edges: [{source_node_uuid, target_node_uuid, source_handle_uuid, target_handle_uuid}]}
|
||||||
|
|
||||||
|
# - #8 启动工作流 POST /lab/workflow/{uuid}/run
|
||||||
|
|
||||||
|
# - #9 运行设备单动作 POST /lab/mcp/run/action(⚠️ action_type 必须从 action-index.md 或 actions/<name>.json 的 type 字段获取,传错会导致任务永远卡住)
|
||||||
|
|
||||||
|
# - #10 查询任务状态 GET /lab/mcp/task/{task_uuid}
|
||||||
|
|
||||||
|
# - #11 运行工作流单节点 POST /lab/mcp/run/workflow/action
|
||||||
|
|
||||||
|
# - #12 获取资源树 GET /lab/material/download/{lab_uuid}
|
||||||
|
|
||||||
|
# - #13 获取工作流模板详情 GET /lab/workflow/template/detail/{workflow_uuid}
|
||||||
|
|
||||||
|
# 返回 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 填写规则
|
||||||
|
|
||||||
|
- unilabos_resources → ResourceSlot → {"id":"/path/name","name":"name","uuid":"xxx"}
|
||||||
|
- unilabos_devices → DeviceSlot → "/parent/device" 路径字符串
|
||||||
|
- unilabos_nodes → NodeSlot → "/parent/node" 路径字符串
|
||||||
|
- unilabos_class → ClassSlot → "class_name" 字符串
|
||||||
|
- unilabos_formulation → FormulationSlot → [{well_name, liquids: [{name, volume}]}] 配方数组
|
||||||
|
- 特例:create_resource 的 res_id 允许填不存在的路径
|
||||||
|
- 列出本设备所有 Slot 字段、类型及含义
|
||||||
|
|
||||||
|
## 渐进加载策略
|
||||||
|
|
||||||
|
## 完整工作流 Checklist
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5 — 验证
|
||||||
|
|
||||||
|
检查文件完整性:
|
||||||
|
|
||||||
|
- [ ] `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 字段表
|
||||||
|
- [ ] `action-index.md` 列出所有 action 并有描述
|
||||||
|
- [ ] `actions/` 目录中每个 action 有对应 JSON 文件
|
||||||
|
- [ ] JSON 文件包含 `type`, `schema`(已提升为 goal 内容), `goal`, `goal_default`, `placeholder_keys` 字段
|
||||||
|
- [ ] 描述能让 agent 判断该用哪个 action
|
||||||
|
|
||||||
|
## Action JSON 文件结构
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "LiquidHandlerTransfer", // → API 的 action_type
|
||||||
|
"goal": { // goal 字段映射
|
||||||
|
"sources": "sources",
|
||||||
|
"targets": "targets",
|
||||||
|
"tip_racks": "tip_racks",
|
||||||
|
"asp_vols": "asp_vols"
|
||||||
|
},
|
||||||
|
"schema": { // ← 直接是 goal 的 schema(已提升)
|
||||||
|
"type": "object",
|
||||||
|
"properties": { // 参数定义(即请求中 goal 的字段)
|
||||||
|
"sources": { "type": "array", "items": { "type": "object" } },
|
||||||
|
"targets": { "type": "array", "items": { "type": "object" } },
|
||||||
|
"asp_vols": { "type": "array", "items": { "type": "number" } }
|
||||||
|
},
|
||||||
|
"required": [...],
|
||||||
|
"_unilabos_placeholder_info": { // ← Slot 类型标记
|
||||||
|
"sources": "unilabos_resources",
|
||||||
|
"targets": "unilabos_resources",
|
||||||
|
"tip_racks": "unilabos_resources"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"goal_default": { ... }, // 默认值
|
||||||
|
"placeholder_keys": { // ← 汇总所有 Slot 字段
|
||||||
|
"sources": "unilabos_resources", // ResourceSlot
|
||||||
|
"targets": "unilabos_resources",
|
||||||
|
"tip_racks": "unilabos_resources",
|
||||||
|
"target_device_id": "unilabos_devices" // DeviceSlot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意**:`schema` 已由脚本从原始 `schema.properties.goal` 提升为顶层,直接包含参数定义。
|
||||||
|
> `schema.properties` 中的字段即为 API 创建节点返回的 `data.param` 中的字段,PATCH 更新时直接修改 `param` 即可。
|
||||||
|
|
||||||
|
## Placeholder Slot 类型体系
|
||||||
|
|
||||||
|
`placeholder_keys` / `_unilabos_placeholder_info` 中有 5 种值,对应不同的填写方式:
|
||||||
|
|
||||||
|
| placeholder 值 | Slot 类型 | 填写格式 | 选取范围 |
|
||||||
|
| ---------------------- | --------------- | ----------------------------------------------------- | ----------------------------------------- |
|
||||||
|
| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅**物料**节点(不含设备) |
|
||||||
|
| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅**设备**节点(type=device),路径字符串 |
|
||||||
|
| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | **设备 + 物料**,即所有节点,路径字符串 |
|
||||||
|
| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已上报的资源类 name |
|
||||||
|
| `unilabos_formulation` | FormulationSlot | `[{well_name, liquids: [{name, volume}]}]` | 资源树中物料节点的 **name**,配合液体配方 |
|
||||||
|
|
||||||
|
### ResourceSlot(`unilabos_resources`)
|
||||||
|
|
||||||
|
最常见的类型。从资源树中选取**物料**节点(孔板、枪头盒、试剂槽等):
|
||||||
|
|
||||||
|
- 单个:`{"id": "/workstation/container1", "name": "container1", "uuid": "ff149a9a-..."}`
|
||||||
|
- 数组:`[{"id": "/path/a", "name": "a", "uuid": "xxx"}, ...]`
|
||||||
|
- `id` 从 parent 计算的路径格式,根据 action 语义选择正确的物料
|
||||||
|
|
||||||
|
> **特例**:`create_resource` 的 `res_id`,目标物料可能尚不存在,直接填期望路径,不需要 uuid。
|
||||||
|
|
||||||
|
### DeviceSlot / NodeSlot / ClassSlot
|
||||||
|
|
||||||
|
- **DeviceSlot**(`unilabos_devices`):路径字符串如 `"/host_node"`,仅 type=device 的节点
|
||||||
|
- **NodeSlot**(`unilabos_nodes`):路径字符串如 `"/PRCXI/PRCXI_Deck"`,设备 + 物料均可选
|
||||||
|
- **ClassSlot**(`unilabos_class`):类名字符串如 `"container"`,从 `req_resource_registry_upload.json` 查找
|
||||||
|
|
||||||
|
### FormulationSlot(`unilabos_formulation`)
|
||||||
|
|
||||||
|
描述**液体配方**:向哪些容器中加入哪些液体及体积。
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"sample_uuid": "",
|
||||||
|
"well_name": "bottle_A1",
|
||||||
|
"liquids": [{ "name": "LiPF6", "volume": 0.6 }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `well_name` — 目标物料的 **name**(从资源树取,不是 `id` 路径)
|
||||||
|
- `liquids[]` — 液体列表,每条含 `name`(试剂名)和 `volume`(体积,单位由上下文决定;pylabrobot 内部统一 uL)
|
||||||
|
- `sample_uuid` — 样品 UUID,无样品传 `""`
|
||||||
|
- 与 ResourceSlot 的区别:ResourceSlot 指向物料本身,FormulationSlot 引用物料名并附带配方信息
|
||||||
|
|
||||||
|
### 通过 API #12 获取资源树
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
|
||||||
|
```
|
||||||
|
|
||||||
|
注意 `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 | 用户指定 | 更新扩展数据 |
|
||||||
|
|
||||||
|
> 只传需要更新的字段,未传的字段保持不变。
|
||||||
|
|
||||||
|
## 最终目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
./<skill-name>/
|
||||||
|
├── SKILL.md # API 端点 + 渐进加载指引
|
||||||
|
├── action-index.md # 动作索引:描述/用途/核心参数
|
||||||
|
└── actions/ # 每个 action 的完整 JSON Schema
|
||||||
|
├── action1.json
|
||||||
|
├── action2.json
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
从 req_device_registry_upload.json 中提取指定设备的 action schema。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
# 列出所有设备及 action 数量(自动搜索注册表文件)
|
||||||
|
python extract_device_actions.py
|
||||||
|
|
||||||
|
# 指定注册表文件路径
|
||||||
|
python extract_device_actions.py --registry <path/to/req_device_registry_upload.json>
|
||||||
|
|
||||||
|
# 提取指定设备的 action 到目录
|
||||||
|
python extract_device_actions.py <device_id> <output_dir>
|
||||||
|
python extract_device_actions.py --registry <path> <device_id> <output_dir>
|
||||||
|
|
||||||
|
示例:
|
||||||
|
python extract_device_actions.py --registry unilabos_data/req_device_registry_upload.json
|
||||||
|
python extract_device_actions.py liquid_handler.prcxi .cursor/skills/unilab-device-api/actions/
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
REGISTRY_FILENAME = "req_device_registry_upload.json"
|
||||||
|
|
||||||
|
def find_registry(explicit_path=None):
|
||||||
|
"""
|
||||||
|
查找 req_device_registry_upload.json 文件。
|
||||||
|
|
||||||
|
搜索优先级:
|
||||||
|
1. 用户通过 --registry 显式指定的路径
|
||||||
|
2. <cwd>/unilabos_data/req_device_registry_upload.json
|
||||||
|
3. <cwd>/req_device_registry_upload.json
|
||||||
|
4. <script所在目录>/../../.. (workspace根) 下的 unilabos_data/
|
||||||
|
5. 向上逐级搜索父目录(最多 5 层)
|
||||||
|
"""
|
||||||
|
if explicit_path:
|
||||||
|
if os.path.isfile(explicit_path):
|
||||||
|
return explicit_path
|
||||||
|
if os.path.isdir(explicit_path):
|
||||||
|
fp = os.path.join(explicit_path, REGISTRY_FILENAME)
|
||||||
|
if os.path.isfile(fp):
|
||||||
|
return fp
|
||||||
|
print(f"警告: 指定的路径不存在: {explicit_path}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
candidates = [
|
||||||
|
os.path.join("unilabos_data", REGISTRY_FILENAME),
|
||||||
|
REGISTRY_FILENAME,
|
||||||
|
]
|
||||||
|
|
||||||
|
for c in candidates:
|
||||||
|
if os.path.isfile(c):
|
||||||
|
return c
|
||||||
|
|
||||||
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
workspace_root = os.path.normpath(os.path.join(script_dir, "..", "..", ".."))
|
||||||
|
for c in candidates:
|
||||||
|
path = os.path.join(workspace_root, c)
|
||||||
|
if os.path.isfile(path):
|
||||||
|
return path
|
||||||
|
|
||||||
|
cwd = os.getcwd()
|
||||||
|
for _ in range(5):
|
||||||
|
parent = os.path.dirname(cwd)
|
||||||
|
if parent == cwd:
|
||||||
|
break
|
||||||
|
cwd = parent
|
||||||
|
for c in candidates:
|
||||||
|
path = os.path.join(cwd, c)
|
||||||
|
if os.path.isfile(path):
|
||||||
|
return path
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def load_registry(path):
|
||||||
|
with open(path, 'r', encoding='utf-8') as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
def list_devices(data):
|
||||||
|
"""列出所有包含 action_value_mappings 的设备,同时返回 module 路径"""
|
||||||
|
resources = data.get('resources', [])
|
||||||
|
devices = []
|
||||||
|
for res in resources:
|
||||||
|
rid = res.get('id', '')
|
||||||
|
cls = res.get('class', {})
|
||||||
|
avm = cls.get('action_value_mappings', {})
|
||||||
|
module = cls.get('module', '')
|
||||||
|
if avm:
|
||||||
|
devices.append((rid, len(avm), module))
|
||||||
|
return devices
|
||||||
|
|
||||||
|
def flatten_schema_to_goal(action_data):
|
||||||
|
"""将 schema 中嵌套的 goal 内容提升为顶层 schema,去掉 feedback/result 包装"""
|
||||||
|
schema = action_data.get('schema', {})
|
||||||
|
goal_schema = schema.get('properties', {}).get('goal', {})
|
||||||
|
if goal_schema:
|
||||||
|
action_data = dict(action_data)
|
||||||
|
action_data['schema'] = goal_schema
|
||||||
|
return action_data
|
||||||
|
|
||||||
|
|
||||||
|
def extract_actions(data, device_id, output_dir):
|
||||||
|
"""提取指定设备的 action schema 到独立 JSON 文件"""
|
||||||
|
resources = data.get('resources', [])
|
||||||
|
for res in resources:
|
||||||
|
if res.get('id') == device_id:
|
||||||
|
cls = res.get('class', {})
|
||||||
|
module = cls.get('module', '')
|
||||||
|
avm = cls.get('action_value_mappings', {})
|
||||||
|
if not avm:
|
||||||
|
print(f"设备 {device_id} 没有 action_value_mappings")
|
||||||
|
return []
|
||||||
|
|
||||||
|
if module:
|
||||||
|
py_path = module.split(":")[0].replace(".", "/") + ".py"
|
||||||
|
class_name = module.split(":")[-1] if ":" in module else ""
|
||||||
|
print(f"Python 源码: {py_path}")
|
||||||
|
if class_name:
|
||||||
|
print(f"设备类: {class_name}")
|
||||||
|
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
written = []
|
||||||
|
for action_name in sorted(avm.keys()):
|
||||||
|
action_data = flatten_schema_to_goal(avm[action_name])
|
||||||
|
filename = action_name.replace('-', '_') + '.json'
|
||||||
|
filepath = os.path.join(output_dir, filename)
|
||||||
|
with open(filepath, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(action_data, f, indent=2, ensure_ascii=False)
|
||||||
|
written.append(filename)
|
||||||
|
print(f" {filepath}")
|
||||||
|
return written
|
||||||
|
|
||||||
|
print(f"设备 {device_id} 未找到")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = sys.argv[1:]
|
||||||
|
explicit_registry = None
|
||||||
|
|
||||||
|
if "--registry" in args:
|
||||||
|
idx = args.index("--registry")
|
||||||
|
if idx + 1 < len(args):
|
||||||
|
explicit_registry = args[idx + 1]
|
||||||
|
args = args[:idx] + args[idx + 2:]
|
||||||
|
else:
|
||||||
|
print("错误: --registry 需要指定路径")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
registry_path = find_registry(explicit_registry)
|
||||||
|
if not registry_path:
|
||||||
|
print(f"错误: 找不到 {REGISTRY_FILENAME}")
|
||||||
|
print()
|
||||||
|
print("解决方法:")
|
||||||
|
print(" 1. 先运行 unilab 启动命令,等待注册表生成")
|
||||||
|
print(" 2. 用 --registry 指定文件路径:")
|
||||||
|
print(f" python {sys.argv[0]} --registry <path/to/{REGISTRY_FILENAME}>")
|
||||||
|
print()
|
||||||
|
print("搜索过的路径:")
|
||||||
|
for p in [
|
||||||
|
os.path.join("unilabos_data", REGISTRY_FILENAME),
|
||||||
|
REGISTRY_FILENAME,
|
||||||
|
os.path.join("<workspace_root>", "unilabos_data", REGISTRY_FILENAME),
|
||||||
|
]:
|
||||||
|
print(f" - {p}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"注册表: {registry_path}")
|
||||||
|
mtime = os.path.getmtime(registry_path)
|
||||||
|
gen_time = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
size_mb = os.path.getsize(registry_path) / (1024 * 1024)
|
||||||
|
print(f"生成时间: {gen_time} (文件大小: {size_mb:.1f} MB)")
|
||||||
|
data = load_registry(registry_path)
|
||||||
|
|
||||||
|
if len(args) == 0:
|
||||||
|
devices = list_devices(data)
|
||||||
|
print(f"\n找到 {len(devices)} 个设备:")
|
||||||
|
print(f"{'设备 ID':<50} {'Actions':>7} {'Python 模块'}")
|
||||||
|
print("-" * 120)
|
||||||
|
for did, count, module in sorted(devices, key=lambda x: x[0]):
|
||||||
|
py_path = module.split(":")[0].replace(".", "/") + ".py" if module else ""
|
||||||
|
print(f"{did:<50} {count:>7} {py_path}")
|
||||||
|
|
||||||
|
elif len(args) == 2:
|
||||||
|
device_id = args[0]
|
||||||
|
output_dir = args[1]
|
||||||
|
print(f"\n提取 {device_id} 的 actions 到 {output_dir}/")
|
||||||
|
written = extract_actions(data, device_id, output_dir)
|
||||||
|
if written:
|
||||||
|
print(f"\n共写入 {len(written)} 个 action 文件")
|
||||||
|
|
||||||
|
else:
|
||||||
|
print("用法:")
|
||||||
|
print(" python extract_device_actions.py [--registry <path>] # 列出设备")
|
||||||
|
print(" python extract_device_actions.py [--registry <path>] <device_id> <dir> # 提取 actions")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
69
.cursor/skills/create-device-skill/scripts/gen_auth.py
Normal file
69
.cursor/skills/create-device-skill/scripts/gen_auth.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
从 ak/sk 生成 UniLab API Authorization header。
|
||||||
|
|
||||||
|
算法: base64(ak:sk) → "Authorization: Lab <token>"
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python gen_auth.py <ak> <sk>
|
||||||
|
python gen_auth.py --config <config.py>
|
||||||
|
|
||||||
|
示例:
|
||||||
|
python gen_auth.py myak mysk
|
||||||
|
python gen_auth.py --config experiments/config.py
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def gen_auth(ak: str, sk: str) -> str:
|
||||||
|
token = base64.b64encode(f"{ak}:{sk}".encode("utf-8")).decode("utf-8")
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def extract_from_config(config_path: str) -> tuple:
|
||||||
|
"""从 config.py 中提取 ak 和 sk"""
|
||||||
|
with open(config_path, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
ak_match = re.search(r'''ak\s*=\s*["']([^"']+)["']''', content)
|
||||||
|
sk_match = re.search(r'''sk\s*=\s*["']([^"']+)["']''', content)
|
||||||
|
if not ak_match or not sk_match:
|
||||||
|
return None, None
|
||||||
|
return ak_match.group(1), sk_match.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = sys.argv[1:]
|
||||||
|
|
||||||
|
if len(args) == 2 and args[0] == "--config":
|
||||||
|
ak, sk = extract_from_config(args[1])
|
||||||
|
if not ak or not sk:
|
||||||
|
print(f"错误: 在 {args[1]} 中未找到 ak/sk 配置")
|
||||||
|
print("期望格式: ak = \"xxx\" sk = \"xxx\"")
|
||||||
|
sys.exit(1)
|
||||||
|
print(f"配置文件: {args[1]}")
|
||||||
|
elif len(args) == 2:
|
||||||
|
ak, sk = args
|
||||||
|
else:
|
||||||
|
print("用法:")
|
||||||
|
print(" python gen_auth.py <ak> <sk>")
|
||||||
|
print(" python gen_auth.py --config <config.py>")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
token = gen_auth(ak, sk)
|
||||||
|
print(f"ak: {ak}")
|
||||||
|
print(f"sk: {sk}")
|
||||||
|
print()
|
||||||
|
print(f"Authorization header:")
|
||||||
|
print(f" Authorization: Lab {token}")
|
||||||
|
print()
|
||||||
|
print(f"curl 用法:")
|
||||||
|
print(f' curl -H "Authorization: Lab {token}" ...')
|
||||||
|
print()
|
||||||
|
print(f"Shell 变量:")
|
||||||
|
print(f' AUTH="Authorization: Lab {token}"')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
284
.cursor/skills/submit-agent-result/SKILL.md
Normal file
284
.cursor/skills/submit-agent-result/SKILL.md
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
---
|
||||||
|
name: submit-agent-result
|
||||||
|
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 提交历史实验记录指南
|
||||||
|
|
||||||
|
通过 Uni-Lab 云端 API 向已创建的 notebook 提交实验结果数据(agent_result)。支持从 JSON / CSV 文件读取数据,整合后提交。
|
||||||
|
|
||||||
|
> **重要**:本指南中的 `Authorization: Lab <token>` 是 **Uni-Lab 平台专用的认证方式**,`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。
|
||||||
|
|
||||||
|
## 前置条件(缺一不可)
|
||||||
|
|
||||||
|
使用本指南前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
|
||||||
|
|
||||||
|
### 1. ak / sk → AUTH
|
||||||
|
|
||||||
|
询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。
|
||||||
|
|
||||||
|
生成 AUTH token:
|
||||||
|
|
||||||
|
```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>
|
||||||
|
```
|
||||||
|
|
||||||
|
输出即为 token 值,拼接为 `Authorization: Lab <token>`(`Lab` 是 Uni-Lab 平台 auth scheme,不可替换为 `Basic`)。
|
||||||
|
|
||||||
|
### 2. --addr → BASE URL
|
||||||
|
|
||||||
|
| `--addr` 值 | BASE |
|
||||||
|
| ------------ | ----------------------------------- |
|
||||||
|
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||||
|
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||||
|
| `local` | `http://127.0.0.1:48197` |
|
||||||
|
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||||
|
|
||||||
|
确认后设置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BASE="<根据 addr 确定的 URL>"
|
||||||
|
# ⚠️ Auth scheme 必须是 "Lab"(Uni-Lab 专用),不是 "Basic"
|
||||||
|
AUTH="Authorization: Lab <上面命令输出的 token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. notebook_uuid(**必须询问用户**)
|
||||||
|
|
||||||
|
**必须主动询问用户**:「请提供要提交结果的 notebook UUID。」
|
||||||
|
|
||||||
|
notebook_uuid 来自之前通过「批量提交实验」创建的实验批次,即 `POST /api/v1/lab/notebook` 返回的 `data.uuid`。
|
||||||
|
|
||||||
|
如果用户不记得,可提示:
|
||||||
|
|
||||||
|
- 查看之前的对话记录中创建 notebook 时返回的 UUID
|
||||||
|
- 或通过平台页面查找对应的 notebook
|
||||||
|
|
||||||
|
**绝不能跳过此步骤,没有 notebook_uuid 无法提交。**
|
||||||
|
|
||||||
|
### 4. 实验结果数据
|
||||||
|
|
||||||
|
用户需要提供实验结果数据,支持以下方式:
|
||||||
|
|
||||||
|
| 方式 | 说明 |
|
||||||
|
| --------- | ----------------------------------------------- |
|
||||||
|
| JSON 文件 | 直接作为 `agent_result` 的内容合并 |
|
||||||
|
| CSV 文件 | 转为 `{"文件名": [行数据...]}` 格式 |
|
||||||
|
| 手动指定 | 用户直接告知 key-value 数据,由 agent 构建 JSON |
|
||||||
|
|
||||||
|
**四项全部就绪后才可开始。**
|
||||||
|
|
||||||
|
## Session State
|
||||||
|
|
||||||
|
在整个对话过程中,agent 需要记住以下状态:
|
||||||
|
|
||||||
|
- `lab_uuid` — 实验室 UUID(通过 API #1 自动获取,**不需要问用户**)
|
||||||
|
- `notebook_uuid` — 目标 notebook UUID(**必须询问用户**)
|
||||||
|
|
||||||
|
## 请求约定
|
||||||
|
|
||||||
|
所有请求使用 `curl -s`,PUT 需加 `Content-Type: application/json`。
|
||||||
|
|
||||||
|
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名),示例中的 `curl` 均指 `curl.exe`。
|
||||||
|
>
|
||||||
|
> **PowerShell JSON 传参**:PowerShell 中 `-d '{"key":"value"}'` 会因引号转义失败。请将 JSON 写入临时文件,用 `-d '@tmp_body.json'`(单引号包裹 `@`,否则 `@` 会被 PowerShell 解析为 splatting 运算符导致报错)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### 1. 获取实验室信息(自动获取 lab_uuid)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
||||||
|
```
|
||||||
|
|
||||||
|
返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
记住 `data.uuid` 为 `lab_uuid`。
|
||||||
|
|
||||||
|
### 2. 提交实验结果(agent_result)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X PUT "$BASE/api/v1/lab/notebook/agent-result" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '<request_body>'
|
||||||
|
```
|
||||||
|
|
||||||
|
请求体结构:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"notebook_uuid": "<notebook_uuid>",
|
||||||
|
"agent_result": {
|
||||||
|
"<key1>": "<value1>",
|
||||||
|
"<key2>": 123,
|
||||||
|
"<nested_key>": {"a": 1, "b": 2},
|
||||||
|
"<array_key>": [{"col1": "v1", "col2": "v2"}, ...]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意**:HTTP 方法是 **PUT**(不是 POST)。
|
||||||
|
|
||||||
|
#### 必要字段
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --------------- | ------------- | ------------------------------------------- |
|
||||||
|
| `notebook_uuid` | string (UUID) | 目标 notebook 的 UUID,从批量提交实验时获取 |
|
||||||
|
| `agent_result` | object | 实验结果数据,任意 JSON 对象 |
|
||||||
|
|
||||||
|
#### agent_result 内容格式
|
||||||
|
|
||||||
|
`agent_result` 接受**任意 JSON 对象**,常见格式:
|
||||||
|
|
||||||
|
**简单键值对**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"avg_rtt_ms": 12.5,
|
||||||
|
"status": "success",
|
||||||
|
"test_count": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**包含嵌套结构**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"summary": { "total": 100, "passed": 98, "failed": 2 },
|
||||||
|
"measurements": [
|
||||||
|
{ "sample_id": "S001", "value": 3.14, "unit": "mg/mL" },
|
||||||
|
{ "sample_id": "S002", "value": 2.71, "unit": "mg/mL" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**从 CSV 文件导入**(脚本自动转换):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"experiment_data": [
|
||||||
|
{ "温度": 25, "压力": 101.3, "产率": 0.85 },
|
||||||
|
{ "温度": 30, "压力": 101.3, "产率": 0.91 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 整合脚本
|
||||||
|
|
||||||
|
本文档同级目录下的 `scripts/prepare_agent_result.py` 可自动读取文件并构建请求体。
|
||||||
|
|
||||||
|
### 用法
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/prepare_agent_result.py \
|
||||||
|
--notebook-uuid <uuid> \
|
||||||
|
--files data1.json data2.csv \
|
||||||
|
[--auth <token>] \
|
||||||
|
[--base <BASE_URL>] \
|
||||||
|
[--submit] \
|
||||||
|
[--output <output.json>]
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 必选 | 说明 |
|
||||||
|
| ----------------- | ---------- | ----------------------------------------------- |
|
||||||
|
| `--notebook-uuid` | 是 | 目标 notebook UUID |
|
||||||
|
| `--files` | 是 | 输入文件路径(支持多个,JSON / CSV) |
|
||||||
|
| `--auth` | 提交时必选 | Lab token(base64(ak:sk)) |
|
||||||
|
| `--base` | 提交时必选 | API base URL |
|
||||||
|
| `--submit` | 否 | 加上此标志则直接提交到云端 |
|
||||||
|
| `--output` | 否 | 输出 JSON 路径(默认 `agent_result_body.json`) |
|
||||||
|
|
||||||
|
### 文件合并规则
|
||||||
|
|
||||||
|
| 文件类型 | 合并方式 |
|
||||||
|
| --------------------- | -------------------------------------------- |
|
||||||
|
| `.json`(dict) | 字段直接合并到 `agent_result` 顶层 |
|
||||||
|
| `.json`(list/other) | 以文件名为 key 放入 `agent_result` |
|
||||||
|
| `.csv` | 以文件名(不含扩展名)为 key,值为行对象数组 |
|
||||||
|
|
||||||
|
多个文件的字段会合并。JSON dict 中的重复 key 后者覆盖前者。
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 仅生成请求体文件(不提交)
|
||||||
|
python scripts/prepare_agent_result.py \
|
||||||
|
--notebook-uuid 73c67dca-c8cc-4936-85a0-329106aa7cca \
|
||||||
|
--files results.json measurements.csv
|
||||||
|
|
||||||
|
# 生成并直接提交
|
||||||
|
python scripts/prepare_agent_result.py \
|
||||||
|
--notebook-uuid 73c67dca-c8cc-4936-85a0-329106aa7cca \
|
||||||
|
--files results.json \
|
||||||
|
--auth YTFmZDlkNGUt... \
|
||||||
|
--base https://leap-lab.test.bohrium.com \
|
||||||
|
--submit
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 手动构建方式
|
||||||
|
|
||||||
|
如果不使用脚本,也可手动构建请求体:
|
||||||
|
|
||||||
|
1. 将实验结果数据组装为 JSON 对象
|
||||||
|
2. 写入临时文件:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"notebook_uuid": "<uuid>",
|
||||||
|
"agent_result": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 用 curl 提交:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X PUT "$BASE/api/v1/lab/notebook/agent-result" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d '@tmp_body.json'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 完整工作流 Checklist
|
||||||
|
|
||||||
|
```
|
||||||
|
Task Progress:
|
||||||
|
- [ ] Step 1: 确认 ak/sk → 生成 AUTH token
|
||||||
|
- [ ] Step 2: 确认 --addr → 设置 BASE URL
|
||||||
|
- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid
|
||||||
|
- [ ] Step 4: **询问用户** notebook_uuid(必须,不可跳过)
|
||||||
|
- [ ] Step 5: 确认实验结果数据来源(文件路径或手动数据)
|
||||||
|
- [ ] Step 6: 运行 prepare_agent_result.py 或手动构建请求体
|
||||||
|
- [ ] Step 7: PUT /lab/notebook/agent-result 提交
|
||||||
|
- [ ] Step 8: 检查返回结果,确认提交成功
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q: notebook_uuid 从哪里获取?
|
||||||
|
|
||||||
|
从之前「批量提交实验」时 `POST /api/v1/lab/notebook` 的返回值 `data.uuid` 获取。也可以在平台 UI 中查找对应的 notebook。
|
||||||
|
|
||||||
|
### Q: agent_result 有固定的 schema 吗?
|
||||||
|
|
||||||
|
没有严格 schema,接受任意 JSON 对象。但建议包含有意义的字段名和结构化数据,方便后续分析。
|
||||||
|
|
||||||
|
### Q: 可以多次提交同一个 notebook 的结果吗?
|
||||||
|
|
||||||
|
可以,后续提交会覆盖之前的 agent_result。
|
||||||
|
|
||||||
|
### Q: 认证方式是 Lab 还是 Api?
|
||||||
|
|
||||||
|
本指南统一使用 `Authorization: Lab <base64(ak:sk)>` 方式(`Lab` 是 Uni-Lab 平台的 auth scheme,**绝不能用 `Basic` 替代**)。如果用户有独立的 API Key,也可用 `Authorization: Api <key>` 替代。
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""
|
||||||
|
读取实验结果文件(JSON / CSV),整合为 agent_result 请求体并可选提交。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python prepare_agent_result.py \
|
||||||
|
--notebook-uuid <uuid> \
|
||||||
|
--files data1.json data2.csv \
|
||||||
|
[--auth <Lab token>] \
|
||||||
|
[--base <BASE_URL>] \
|
||||||
|
[--submit] \
|
||||||
|
[--output <output.json>]
|
||||||
|
|
||||||
|
支持的输入文件格式:
|
||||||
|
- .json → 直接作为 dict 合并
|
||||||
|
- .csv → 转为 {"filename": [row_dict, ...]} 格式
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
|
||||||
|
def read_json_file(filepath: str) -> Dict[str, Any]:
|
||||||
|
with open(filepath, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def read_csv_file(filepath: str) -> List[Dict[str, Any]]:
|
||||||
|
rows = []
|
||||||
|
with open(filepath, "r", encoding="utf-8-sig") as f:
|
||||||
|
reader = csv.DictReader(f)
|
||||||
|
for row in reader:
|
||||||
|
converted = {}
|
||||||
|
for k, v in row.items():
|
||||||
|
try:
|
||||||
|
converted[k] = int(v)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
try:
|
||||||
|
converted[k] = float(v)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
converted[k] = v
|
||||||
|
rows.append(converted)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def merge_files(filepaths: List[str]) -> Dict[str, Any]:
|
||||||
|
"""将多个文件合并为一个 agent_result dict"""
|
||||||
|
merged: Dict[str, Any] = {}
|
||||||
|
for fp in filepaths:
|
||||||
|
path = Path(fp)
|
||||||
|
ext = path.suffix.lower()
|
||||||
|
key = path.stem
|
||||||
|
|
||||||
|
if ext == ".json":
|
||||||
|
data = read_json_file(fp)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
merged.update(data)
|
||||||
|
else:
|
||||||
|
merged[key] = data
|
||||||
|
elif ext == ".csv":
|
||||||
|
merged[key] = read_csv_file(fp)
|
||||||
|
else:
|
||||||
|
print(f"[警告] 不支持的文件格式: {fp},跳过", file=sys.stderr)
|
||||||
|
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def build_request_body(notebook_uuid: str, agent_result: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"notebook_uuid": notebook_uuid,
|
||||||
|
"agent_result": agent_result,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def submit(base: str, auth: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
except ImportError:
|
||||||
|
print("[错误] 提交需要 requests 库: pip install requests", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
url = f"{base}/api/v1/lab/notebook/agent-result"
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Lab {auth}",
|
||||||
|
}
|
||||||
|
resp = requests.put(url, json=body, headers=headers, timeout=30)
|
||||||
|
return {"status_code": resp.status_code, "body": resp.json() if resp.headers.get("content-type", "").startswith("application/json") else resp.text}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="整合实验结果文件并构建 agent_result 请求体")
|
||||||
|
parser.add_argument("--notebook-uuid", required=True, help="目标 notebook UUID")
|
||||||
|
parser.add_argument("--files", nargs="+", required=True, help="输入文件路径(JSON / CSV)")
|
||||||
|
parser.add_argument("--auth", help="Lab token(base64(ak:sk))")
|
||||||
|
parser.add_argument("--base", help="API base URL")
|
||||||
|
parser.add_argument("--submit", action="store_true", help="直接提交到云端")
|
||||||
|
parser.add_argument("--output", default="agent_result_body.json", help="输出 JSON 文件路径")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
for fp in args.files:
|
||||||
|
if not os.path.exists(fp):
|
||||||
|
print(f"[错误] 文件不存在: {fp}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
agent_result = merge_files(args.files)
|
||||||
|
body = build_request_body(args.notebook_uuid, agent_result)
|
||||||
|
|
||||||
|
with open(args.output, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(body, f, ensure_ascii=False, indent=2)
|
||||||
|
print(f"[完成] 请求体已保存: {args.output}")
|
||||||
|
print(f" notebook_uuid: {args.notebook_uuid}")
|
||||||
|
print(f" agent_result 字段数: {len(agent_result)}")
|
||||||
|
print(f" 合并文件数: {len(args.files)}")
|
||||||
|
|
||||||
|
if args.submit:
|
||||||
|
if not args.auth or not args.base:
|
||||||
|
print("[错误] 提交需要 --auth 和 --base 参数", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
print(f"\n[提交] PUT {args.base}/api/v1/lab/notebook/agent-result ...")
|
||||||
|
result = submit(args.base, args.auth, body)
|
||||||
|
print(f" HTTP {result['status_code']}")
|
||||||
|
print(f" 响应: {json.dumps(result['body'], ensure_ascii=False)}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
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"
|
|
||||||
4
.github/workflows/ci-check.yml
vendored
4
.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: |
|
||||||
@@ -49,7 +49,7 @@ jobs:
|
|||||||
uv pip uninstall enum34 || echo enum34 not installed, skipping
|
uv pip uninstall enum34 || echo enum34 not installed, skipping
|
||||||
uv pip install .
|
uv pip install .
|
||||||
|
|
||||||
- name: Run check mode (complete_registry)
|
- name: Run check mode (AST registry validation)
|
||||||
run: |
|
run: |
|
||||||
call conda activate check-env
|
call conda activate check-env
|
||||||
echo Running check mode...
|
echo Running check mode...
|
||||||
|
|||||||
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: |
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -5,6 +5,7 @@ output/
|
|||||||
unilabos_data/
|
unilabos_data/
|
||||||
pyrightconfig.json
|
pyrightconfig.json
|
||||||
.cursorignore
|
.cursorignore
|
||||||
|
device_package*/
|
||||||
## Python
|
## Python
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
@@ -251,3 +252,5 @@ ros-humble-unilabos-msgs-0.9.13-h6403a04_5.tar.bz2
|
|||||||
test_config.py
|
test_config.py
|
||||||
|
|
||||||
|
|
||||||
|
/.claude
|
||||||
|
/.cursor
|
||||||
|
|||||||
170
AGENTS.md
Normal file
170
AGENTS.md
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
Also follow the monorepo-level rules in `../AGENTS.md`.
|
||||||
|
|
||||||
|
## Build & Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install in editable mode (requires mamba env with python 3.11)
|
||||||
|
pip install -e .
|
||||||
|
uv pip install -r unilabos/utils/requirements.txt
|
||||||
|
|
||||||
|
# Run with a device graph
|
||||||
|
unilab --graph <graph.json> --config <config.py> --backend ros
|
||||||
|
unilab --graph <graph.json> --config <config.py> --backend simple # no ROS2 needed
|
||||||
|
|
||||||
|
# Common CLI flags
|
||||||
|
unilab --app_bridges websocket fastapi # communication bridges
|
||||||
|
unilab --test_mode # simulate hardware, no real execution
|
||||||
|
unilab --check_mode # CI validation of registry imports
|
||||||
|
unilab --skip_env_check # skip auto-install of dependencies
|
||||||
|
unilab --visual rviz|web|disable # visualization mode
|
||||||
|
unilab --is_slave # run as slave node
|
||||||
|
|
||||||
|
# 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> --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
|
||||||
|
pytest tests/ # all tests
|
||||||
|
pytest tests/resources/test_resourcetreeset.py # single test file
|
||||||
|
pytest tests/resources/test_resourcetreeset.py::TestClassName::test_method # single test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Startup Flow
|
||||||
|
|
||||||
|
`unilab` CLI → `unilabos/app/main.py:main()` → loads config → builds registry → reads device graph (JSON/GraphML) → starts backend thread (ROS2/simple) → starts FastAPI web server + WebSocket client.
|
||||||
|
|
||||||
|
### Core Layers
|
||||||
|
|
||||||
|
**Registry** (`unilabos/registry/`): Singleton `Registry` class discovers and catalogs all device types, resource types, and communication devices from YAML definitions. Device types live in `registry/devices/*.yaml`, resources in `registry/resources/`, comms in `registry/device_comms/`. The registry resolves class paths to actual Python classes via `utils/import_manager.py`.
|
||||||
|
|
||||||
|
**Resource Tracking** (`unilabos/resources/resource_tracker.py`): Pydantic-based `ResourceDict` → `ResourceDictInstance` → `ResourceTreeSet` hierarchy. `ResourceTreeSet` is the canonical in-memory representation of all devices and resources, used throughout the system. Graph I/O is in `resources/graphio.py` (reads JSON/GraphML device topology files into `nx.Graph` + `ResourceTreeSet`).
|
||||||
|
|
||||||
|
**Device Drivers** (`unilabos/devices/`): 30+ hardware drivers organized by device type (liquid_handling, hplc, balance, arm, etc.). Each driver is a Python class that gets wrapped by `ros/device_node_wrapper.py:ros2_device_node()` to become a ROS2 node with publishers, subscribers, and action servers.
|
||||||
|
|
||||||
|
**ROS2 Layer** (`unilabos/ros/`): `device_node_wrapper.py` dynamically wraps any device class into `ROS2DeviceNode` (defined in `ros/nodes/base_device_node.py`). Preset node types in `ros/nodes/presets/` include `host_node`, `controller_node`, `workstation`, `serial_node`, `camera`. Messages use custom `unilabos_msgs` (pre-built, distributed via releases).
|
||||||
|
|
||||||
|
**Protocol Compilation** (`unilabos/compile/`): 20+ protocol compilers (add, centrifuge, dissolve, filter, heatchill, stir, pump, etc.) that transform YAML protocol definitions into executable sequences.
|
||||||
|
|
||||||
|
**Communication** (`unilabos/device_comms/`): Hardware communication adapters — OPC-UA client, Modbus PLC, RPC, and a universal driver. `app/communication.py` provides a factory pattern for WebSocket client connections to the cloud.
|
||||||
|
|
||||||
|
**Web/API** (`unilabos/app/web/`): FastAPI server with REST API (`api.py`), Jinja2 template pages (`pages.py`), and HTTP client for cloud communication (`client.py`). Runs on port 8002 by default.
|
||||||
|
|
||||||
|
### Configuration System
|
||||||
|
|
||||||
|
- **Config classes** in `unilabos/config/config.py`: `BasicConfig`, `WSConfig`, `HTTPConfig`, `ROSConfig` — all class-level attributes, loaded from Python config files
|
||||||
|
- Config files are `.py` files with matching class names (see `config/example_config.py`)
|
||||||
|
- Environment variables override with prefix `UNILABOS_` (e.g., `UNILABOS_BASICCONFIG_PORT=9000`)
|
||||||
|
- Device topology defined in graph files (JSON with node-link format, or GraphML)
|
||||||
|
|
||||||
|
### Key Data Flow
|
||||||
|
|
||||||
|
1. Graph file → `graphio.read_node_link_json()` → `(nx.Graph, ResourceTreeSet, resource_links)`
|
||||||
|
2. `ResourceTreeSet` + `Registry` → `initialize_device.initialize_device_from_dict()` → `ROS2DeviceNode` instances
|
||||||
|
3. Device nodes communicate via ROS2 topics/actions or direct Python calls (simple backend)
|
||||||
|
4. Cloud sync via WebSocket (`app/ws_client.py`) and HTTP (`app/web/client.py`)
|
||||||
|
|
||||||
|
### Test Data
|
||||||
|
|
||||||
|
Example device graphs and experiment configs are in `unilabos/test/experiments/` (not `tests/`). Registry test fixtures in `unilabos/test/registry/`.
|
||||||
|
|
||||||
|
### 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 comments and log messages in simplified Chinese
|
||||||
|
- Python 3.11+, type hints expected
|
||||||
|
- Pydantic models for data validation (`resource_tracker.py`)
|
||||||
|
- Singleton pattern via `@singleton` decorator (`utils/decorator.py`)
|
||||||
|
- Dynamic class loading via `utils/import_manager.py` — device classes resolved at runtime from registry YAML paths
|
||||||
|
- CLI argument dashes auto-converted to underscores for consistency
|
||||||
|
|
||||||
|
## Licensing
|
||||||
|
|
||||||
|
- Framework code: GPL-3.0
|
||||||
|
- Device drivers (`unilabos/devices/`): DP Technology Proprietary License — do not redistribute
|
||||||
@@ -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`
|
||||||
@@ -275,15 +275,15 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
**命令行方式(推荐):**
|
**命令行方式(推荐):**
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ Python 类设备驱动在完成注册表后可以直接在 Uni-Lab 中使用,
|
|||||||
**示例:**
|
**示例:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
from unilabos.registry.decorators import device, topic_config
|
||||||
|
|
||||||
|
@device(id="mock_gripper", category=["gripper"], description="Mock Gripper")
|
||||||
class MockGripper:
|
class MockGripper:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._position: float = 0.0
|
self._position: float = 0.0
|
||||||
@@ -23,19 +26,23 @@ class MockGripper:
|
|||||||
self._status = "Idle"
|
self._status = "Idle"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@topic_config() # 添加 @topic_config 才会定时广播
|
||||||
def position(self) -> float:
|
def position(self) -> float:
|
||||||
return self._position
|
return self._position
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@topic_config()
|
||||||
def velocity(self) -> float:
|
def velocity(self) -> float:
|
||||||
return self._velocity
|
return self._velocity
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@topic_config()
|
||||||
def torque(self) -> float:
|
def torque(self) -> float:
|
||||||
return self._torque
|
return self._torque
|
||||||
|
|
||||||
# 会被自动识别的设备属性,接入 Uni-Lab 时会定时对外广播
|
# 使用 @topic_config 装饰的属性,接入 Uni-Lab 时会定时对外广播
|
||||||
@property
|
@property
|
||||||
|
@topic_config(period=2.0) # 可自定义发布周期
|
||||||
def status(self) -> str:
|
def status(self) -> str:
|
||||||
return self._status
|
return self._status
|
||||||
|
|
||||||
@@ -149,7 +156,7 @@ my_device: # 设备唯一标识符
|
|||||||
|
|
||||||
系统会自动分析您的 Python 驱动类并生成:
|
系统会自动分析您的 Python 驱动类并生成:
|
||||||
|
|
||||||
- `status_types`:从 `@property` 装饰的方法自动识别状态属性
|
- `status_types`:从 `@topic_config` 装饰的 `@property` 或方法自动识别状态属性
|
||||||
- `action_value_mappings`:从类方法自动生成动作映射
|
- `action_value_mappings`:从类方法自动生成动作映射
|
||||||
- `init_param_schema`:从 `__init__` 方法分析初始化参数
|
- `init_param_schema`:从 `__init__` 方法分析初始化参数
|
||||||
- `schema`:前端显示用的属性类型定义
|
- `schema`:前端显示用的属性类型定义
|
||||||
@@ -179,7 +186,9 @@ Uni-Lab 设备驱动是一个 Python 类,需要遵循以下结构:
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
|
from unilabos.registry.decorators import device, topic_config
|
||||||
|
|
||||||
|
@device(id="my_device", category=["general"], description="My Device")
|
||||||
class MyDevice:
|
class MyDevice:
|
||||||
"""设备类文档字符串
|
"""设备类文档字符串
|
||||||
|
|
||||||
@@ -198,8 +207,9 @@ class MyDevice:
|
|||||||
# 初始化硬件连接
|
# 初始化硬件连接
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@topic_config() # 必须添加 @topic_config 才会广播
|
||||||
def status(self) -> str:
|
def status(self) -> str:
|
||||||
"""设备状态(会自动广播)"""
|
"""设备状态(通过 @topic_config 广播)"""
|
||||||
return self._status
|
return self._status
|
||||||
|
|
||||||
def my_action(self, param: float) -> Dict[str, Any]:
|
def my_action(self, param: float) -> Dict[str, Any]:
|
||||||
@@ -217,34 +227,61 @@ class MyDevice:
|
|||||||
|
|
||||||
## 状态属性 vs 动作方法
|
## 状态属性 vs 动作方法
|
||||||
|
|
||||||
### 状态属性(@property)
|
### 状态属性(@property + @topic_config)
|
||||||
|
|
||||||
状态属性会被自动识别并定期广播:
|
状态属性需要同时使用 `@property` 和 `@topic_config` 装饰器才会被识别并定期广播:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
from unilabos.registry.decorators import topic_config
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@topic_config() # 必须添加,否则不会广播
|
||||||
def temperature(self) -> float:
|
def temperature(self) -> float:
|
||||||
"""当前温度"""
|
"""当前温度"""
|
||||||
return self._read_temperature()
|
return self._read_temperature()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@topic_config(period=2.0) # 可自定义发布周期(秒)
|
||||||
def status(self) -> str:
|
def status(self) -> str:
|
||||||
"""设备状态: idle, running, error"""
|
"""设备状态: idle, running, error"""
|
||||||
return self._status
|
return self._status
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@topic_config(name="ready") # 可自定义发布名称
|
||||||
def is_ready(self) -> bool:
|
def is_ready(self) -> bool:
|
||||||
"""设备是否就绪"""
|
"""设备是否就绪"""
|
||||||
return self._status == "idle"
|
return self._status == "idle"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
也可以使用普通方法(非 @property)配合 `@topic_config`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@topic_config(period=10.0)
|
||||||
|
def get_sensor_data(self) -> Dict[str, float]:
|
||||||
|
"""获取传感器数据(get_ 前缀会自动去除,发布名为 sensor_data)"""
|
||||||
|
return {"temp": self._temp, "humidity": self._humidity}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`@topic_config` 参数**:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 默认值 | 说明 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| `period` | float | 5.0 | 发布周期(秒) |
|
||||||
|
| `print_publish` | bool | 节点默认 | 是否打印发布日志 |
|
||||||
|
| `qos` | int | 10 | QoS 深度 |
|
||||||
|
| `name` | str | None | 自定义发布名称 |
|
||||||
|
|
||||||
|
**发布名称优先级**:`@topic_config(name=...)` > `get_` 前缀去除 > 方法名
|
||||||
|
|
||||||
**特点**:
|
**特点**:
|
||||||
|
|
||||||
- 使用`@property`装饰器
|
- 必须使用 `@topic_config` 装饰器
|
||||||
- 只读,不能有参数
|
- 支持 `@property` 和普通方法
|
||||||
- 自动添加到注册表的`status_types`
|
- 添加到注册表的 `status_types`
|
||||||
- 定期发布到 ROS2 topic
|
- 定期发布到 ROS2 topic
|
||||||
|
|
||||||
|
> **⚠️ 重要:** 仅有 `@property` 装饰器而没有 `@topic_config` 的属性**不会**被广播。这是一个 Breaking Change。
|
||||||
|
|
||||||
### 动作方法
|
### 动作方法
|
||||||
|
|
||||||
动作方法是设备可以执行的操作:
|
动作方法是设备可以执行的操作:
|
||||||
@@ -497,6 +534,7 @@ class LiquidHandler:
|
|||||||
self._status = "idle"
|
self._status = "idle"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@topic_config()
|
||||||
def status(self) -> str:
|
def status(self) -> str:
|
||||||
return self._status
|
return self._status
|
||||||
|
|
||||||
@@ -886,7 +924,52 @@ class MyDevice:
|
|||||||
|
|
||||||
## 最佳实践
|
## 最佳实践
|
||||||
|
|
||||||
### 1. 类型注解
|
### 1. 使用 `@device` 装饰器标识设备类
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.registry.decorators import device
|
||||||
|
|
||||||
|
@device(id="my_device", category=["heating"], description="My Heating Device", icon="heater.webp")
|
||||||
|
class MyDevice:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
- `id`:设备唯一标识符,用于注册表匹配
|
||||||
|
- `category`:分类列表,前端用于分组显示
|
||||||
|
- `description`:设备描述
|
||||||
|
- `icon`:图标文件名(可选)
|
||||||
|
|
||||||
|
### 2. 使用 `@topic_config` 声明需要广播的状态
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.registry.decorators import topic_config
|
||||||
|
|
||||||
|
# ✓ @property + @topic_config → 会广播
|
||||||
|
@property
|
||||||
|
@topic_config(period=2.0)
|
||||||
|
def temperature(self) -> float:
|
||||||
|
return self._temp
|
||||||
|
|
||||||
|
# ✓ 普通方法 + @topic_config → 会广播(get_ 前缀自动去除)
|
||||||
|
@topic_config(period=10.0)
|
||||||
|
def get_sensor_data(self) -> Dict[str, float]:
|
||||||
|
return {"temp": self._temp}
|
||||||
|
|
||||||
|
# ✓ 使用 name 参数自定义发布名称
|
||||||
|
@property
|
||||||
|
@topic_config(name="ready")
|
||||||
|
def is_ready(self) -> bool:
|
||||||
|
return self._status == "idle"
|
||||||
|
|
||||||
|
# ✗ 仅有 @property,没有 @topic_config → 不会广播
|
||||||
|
@property
|
||||||
|
def internal_state(self) -> str:
|
||||||
|
return self._state
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意:** 与 `@property` 连用时,`@topic_config` 必须放在 `@property` 下面。
|
||||||
|
|
||||||
|
### 3. 类型注解
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from typing import Dict, Any, Optional, List
|
from typing import Dict, Any, Optional, List
|
||||||
@@ -901,7 +984,7 @@ def method(
|
|||||||
pass
|
pass
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 文档字符串
|
### 4. 文档字符串
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def method(self, param: float) -> Dict[str, Any]:
|
def method(self, param: float) -> Dict[str, Any]:
|
||||||
@@ -923,7 +1006,7 @@ def method(self, param: float) -> Dict[str, Any]:
|
|||||||
pass
|
pass
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. 配置验证
|
### 5. 配置验证
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def __init__(self, config: Dict[str, Any]):
|
def __init__(self, config: Dict[str, Any]):
|
||||||
@@ -937,7 +1020,7 @@ def __init__(self, config: Dict[str, Any]):
|
|||||||
self.baudrate = config['baudrate']
|
self.baudrate = config['baudrate']
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. 资源清理
|
### 6. 资源清理
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def __del__(self):
|
def __del__(self):
|
||||||
@@ -946,7 +1029,7 @@ def __del__(self):
|
|||||||
self.connection.close()
|
self.connection.close()
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. 设计前端友好的返回值
|
### 7. 设计前端友好的返回值
|
||||||
|
|
||||||
**记住:返回值会直接显示在 Web 界面**
|
**记住:返回值会直接显示在 Web 界面**
|
||||||
|
|
||||||
|
|||||||
@@ -422,18 +422,20 @@ placeholder_keys:
|
|||||||
|
|
||||||
### status_types
|
### status_types
|
||||||
|
|
||||||
系统会扫描你的 Python 类,从状态方法(property 或 get\_方法)自动生成这部分:
|
系统会扫描你的 Python 类,从带有 `@topic_config` 装饰器的 `@property` 或方法自动生成这部分:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
status_types:
|
status_types:
|
||||||
current_temperature: float # 从 get_current_temperature() 或 @property current_temperature
|
current_temperature: float # 从 @topic_config 装饰的 @property 或方法
|
||||||
is_heating: bool # 从 get_is_heating() 或 @property is_heating
|
is_heating: bool
|
||||||
status: str # 从 get_status() 或 @property status
|
status: str
|
||||||
```
|
```
|
||||||
|
|
||||||
**注意事项**:
|
**注意事项**:
|
||||||
|
|
||||||
- 系统会查找所有 `get_` 开头的方法和 `@property` 装饰的属性
|
- 仅有带 `@topic_config` 装饰器的 `@property` 或方法才会被识别为状态属性
|
||||||
|
- 没有 `@topic_config` 的 `@property` 不会生成 status_types,也不会广播
|
||||||
|
- `get_` 前缀的方法名会自动去除前缀(如 `get_temperature` → `temperature`)
|
||||||
- 类型会自动转成相应的类型(如 `str`、`float`、`bool`)
|
- 类型会自动转成相应的类型(如 `str`、`float`、`bool`)
|
||||||
- 如果类型是 `Any`、`None` 或未知的,默认使用 `String`
|
- 如果类型是 `Any`、`None` 或未知的,默认使用 `String`
|
||||||
|
|
||||||
@@ -537,11 +539,13 @@ class AdvancedLiquidHandler:
|
|||||||
self._temperature = 25.0
|
self._temperature = 25.0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@topic_config()
|
||||||
def status(self) -> str:
|
def status(self) -> str:
|
||||||
"""设备状态"""
|
"""设备状态"""
|
||||||
return self._status
|
return self._status
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@topic_config()
|
||||||
def temperature(self) -> float:
|
def temperature(self) -> float:
|
||||||
"""当前温度"""
|
"""当前温度"""
|
||||||
return self._temperature
|
return self._temperature
|
||||||
@@ -809,21 +813,23 @@ my_temperature_controller:
|
|||||||
你的设备类需要符合以下要求:
|
你的设备类需要符合以下要求:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from unilabos.common.device_base import DeviceBase
|
from unilabos.registry.decorators import device, topic_config
|
||||||
|
|
||||||
class MyDevice(DeviceBase):
|
@device(id="my_device", category=["temperature"], description="My Device")
|
||||||
|
class MyDevice:
|
||||||
def __init__(self, config):
|
def __init__(self, config):
|
||||||
"""初始化,参数会自动分析到 init_param_schema.config"""
|
"""初始化,参数会自动分析到 init_param_schema.config"""
|
||||||
super().__init__(config)
|
|
||||||
self.port = config.get('port', '/dev/ttyUSB0')
|
self.port = config.get('port', '/dev/ttyUSB0')
|
||||||
|
|
||||||
# 状态方法(会自动生成到 status_types)
|
# 状态方法(必须添加 @topic_config 才会生成到 status_types 并广播)
|
||||||
@property
|
@property
|
||||||
|
@topic_config()
|
||||||
def status(self):
|
def status(self):
|
||||||
"""返回设备状态"""
|
"""返回设备状态"""
|
||||||
return "idle"
|
return "idle"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@topic_config()
|
||||||
def temperature(self):
|
def temperature(self):
|
||||||
"""返回当前温度"""
|
"""返回当前温度"""
|
||||||
return 25.0
|
return 25.0
|
||||||
@@ -1039,7 +1045,34 @@ resource.type # "resource"
|
|||||||
|
|
||||||
### 代码规范
|
### 代码规范
|
||||||
|
|
||||||
1. **始终使用类型注解**
|
1. **使用 `@device` 装饰器标识设备类**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.registry.decorators import device
|
||||||
|
|
||||||
|
@device(id="my_device", category=["heating"], description="My Device")
|
||||||
|
class MyDevice:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **使用 `@topic_config` 声明广播属性**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.registry.decorators import topic_config
|
||||||
|
|
||||||
|
# ✓ 需要广播的状态属性
|
||||||
|
@property
|
||||||
|
@topic_config(period=2.0)
|
||||||
|
def temperature(self) -> float:
|
||||||
|
return self._temp
|
||||||
|
|
||||||
|
# ✗ 仅有 @property 不会广播
|
||||||
|
@property
|
||||||
|
def internal_counter(self) -> int:
|
||||||
|
return self._counter
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **始终使用类型注解**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# ✓ 好
|
# ✓ 好
|
||||||
@@ -1051,7 +1084,7 @@ def method(self, resource, device):
|
|||||||
pass
|
pass
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **提供有意义的参数名**
|
4. **提供有意义的参数名**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# ✓ 好 - 清晰的参数名
|
# ✓ 好 - 清晰的参数名
|
||||||
@@ -1063,7 +1096,7 @@ def transfer(self, r1: ResourceSlot, r2: ResourceSlot):
|
|||||||
pass
|
pass
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **使用 Optional 表示可选参数**
|
5. **使用 Optional 表示可选参数**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -1076,7 +1109,7 @@ def method(
|
|||||||
pass
|
pass
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **添加详细的文档字符串**
|
6. **添加详细的文档字符串**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def method(
|
def method(
|
||||||
@@ -1096,13 +1129,13 @@ def method(
|
|||||||
pass
|
pass
|
||||||
```
|
```
|
||||||
|
|
||||||
5. **方法命名规范**
|
7. **方法命名规范**
|
||||||
|
|
||||||
- 状态方法使用 `@property` 装饰器或 `get_` 前缀
|
- 状态方法使用 `@property` + `@topic_config` 装饰器,或普通方法 + `@topic_config`
|
||||||
- 动作方法使用动词开头
|
- 动作方法使用动词开头
|
||||||
- 保持命名清晰、一致
|
- 保持命名清晰、一致
|
||||||
|
|
||||||
6. **完善的错误处理**
|
8. **完善的错误处理**
|
||||||
- 实现完善的错误处理
|
- 实现完善的错误处理
|
||||||
- 添加日志记录
|
- 添加日志记录
|
||||||
- 提供有意义的错误信息
|
- 提供有意义的错误信息
|
||||||
|
|||||||
@@ -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) │
|
||||||
└────────────────────┬─────────────────────────┘
|
└────────────────────┬─────────────────────────┘
|
||||||
@@ -221,10 +221,10 @@ Laboratory A Laboratory B
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 实验室A
|
# 实验室A
|
||||||
unilab --ak your_ak --sk your_sk --upload_registry --use_remote_resource
|
unilab --ak your_ak --sk your_sk --upload_registry
|
||||||
|
|
||||||
# 实验室B
|
# 实验室B
|
||||||
unilab --ak your_ak --sk your_sk --upload_registry --use_remote_resource
|
unilab --ak your_ak --sk your_sk --upload_registry
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -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
@@ -33,11 +33,11 @@
|
|||||||
|
|
||||||
**选择合适的安装包:**
|
**选择合适的安装包:**
|
||||||
|
|
||||||
| 安装包 | 适用场景 | 包含组件 |
|
| 安装包 | 适用场景 | 包含组件 |
|
||||||
|--------|----------|----------|
|
| --------------- | ---------------------------- | --------------------------------------------- |
|
||||||
| `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
|
||||||
|
|
||||||
@@ -452,8 +451,9 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
|
|||||||
**操作步骤:**
|
**操作步骤:**
|
||||||
|
|
||||||
1. 将两个 `container` 拖拽到 `workstation` 中
|
1. 将两个 `container` 拖拽到 `workstation` 中
|
||||||
2. 将 `virtual_transfer_pump` 拖拽到 `workstation` 中
|
2. 将 `virtual_multiway_valve` 拖拽到 `workstation` 中
|
||||||
3. 在画布上连接它们(建立父子关系)
|
3. 将 `virtual_transfer_pump` 拖拽到 `workstation` 中
|
||||||
|
4. 在画布上连接它们(建立父子关系)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -817,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` 自动检测中文环境,中文系统自动使用清华镜像
|
||||||
@@ -1795,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. **测试部署**:
|
||||||
|
|
||||||
- 连接真实硬件
|
- 连接真实硬件
|
||||||
- 空跑测试
|
- 空跑测试
|
||||||
- 小规模试验
|
- 小规模试验
|
||||||
@@ -1870,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. 保存到云端
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 275 KiB After Width: | Height: | Size: 415 KiB |
@@ -22,7 +22,6 @@ options:
|
|||||||
--is_slave Run the backend as slave node (without host privileges).
|
--is_slave Run the backend as slave node (without host privileges).
|
||||||
--slave_no_host Skip waiting for host service in slave mode
|
--slave_no_host Skip waiting for host service in slave mode
|
||||||
--upload_registry Upload registry information when starting unilab
|
--upload_registry Upload registry information when starting unilab
|
||||||
--use_remote_resource Use remote resources when starting unilab
|
|
||||||
--config CONFIG Configuration file path, supports .py format Python config files
|
--config CONFIG Configuration file path, supports .py format Python config files
|
||||||
--port PORT Port for web service information page
|
--port PORT Port for web service information page
|
||||||
--disable_browser Disable opening information page on startup
|
--disable_browser Disable opening information page on startup
|
||||||
@@ -55,7 +54,6 @@ Uni-Lab 的启动过程分为以下几个阶段:
|
|||||||
您可以直接跟随 unilabos 的提示进行,无需查阅本节
|
您可以直接跟随 unilabos 的提示进行,无需查阅本节
|
||||||
|
|
||||||
- **工作目录设置**:
|
- **工作目录设置**:
|
||||||
|
|
||||||
- 如果当前目录以 `unilabos_data` 结尾,则使用当前目录
|
- 如果当前目录以 `unilabos_data` 结尾,则使用当前目录
|
||||||
- 否则使用 `当前目录/unilabos_data` 作为工作目录
|
- 否则使用 `当前目录/unilabos_data` 作为工作目录
|
||||||
- 可通过 `--working_dir` 指定自定义工作目录
|
- 可通过 `--working_dir` 指定自定义工作目录
|
||||||
@@ -69,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
|
||||||
|
|
||||||
@@ -85,7 +83,7 @@ Uni-Lab 的启动过程分为以下几个阶段:
|
|||||||
支持两种方式:
|
支持两种方式:
|
||||||
|
|
||||||
- **本地文件**:使用 `-g` 指定图谱文件(支持 JSON 和 GraphML 格式)
|
- **本地文件**:使用 `-g` 指定图谱文件(支持 JSON 和 GraphML 格式)
|
||||||
- **远程资源**:使用 `--use_remote_resource` 从云端获取
|
- **远程资源**:不指定本地文件即可
|
||||||
|
|
||||||
### 7. 注册表构建
|
### 7. 注册表构建
|
||||||
|
|
||||||
@@ -177,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. 设置工作目录
|
||||||
|
|
||||||
@@ -196,7 +194,7 @@ unilab --config path/to/your/config.py
|
|||||||
unilab --ak your_ak --sk your_sk -g path/to/graph.json --upload_registry
|
unilab --ak your_ak --sk your_sk -g path/to/graph.json --upload_registry
|
||||||
|
|
||||||
# 使用远程资源启动
|
# 使用远程资源启动
|
||||||
unilab --ak your_ak --sk your_sk --use_remote_resource
|
unilab --ak your_ak --sk your_sk
|
||||||
|
|
||||||
# 更新注册表
|
# 更新注册表
|
||||||
unilab --ak your_ak --sk your_sk --complete_registry
|
unilab --ak your_ak --sk your_sk --complete_registry
|
||||||
@@ -217,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.17
|
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.17"
|
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.17',
|
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.17"
|
__version__ = "0.11.1"
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
"""Entry point for `python -m unilabos`."""
|
|
||||||
|
|
||||||
from unilabos.app.main import main
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
|
import platform
|
||||||
import shutil
|
import shutil
|
||||||
import signal
|
import signal
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -10,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))
|
||||||
@@ -24,6 +35,84 @@ from unilabos.config.config import load_config, BasicConfig, HTTPConfig
|
|||||||
_restart_requested: bool = False
|
_restart_requested: bool = False
|
||||||
_restart_reason: str = ""
|
_restart_reason: str = ""
|
||||||
|
|
||||||
|
RESTART_EXIT_CODE = 42
|
||||||
|
|
||||||
|
|
||||||
|
def _build_child_argv():
|
||||||
|
"""Build sys.argv for child process, stripping supervisor-only arguments."""
|
||||||
|
result = []
|
||||||
|
skip_next = False
|
||||||
|
for arg in sys.argv:
|
||||||
|
if skip_next:
|
||||||
|
skip_next = False
|
||||||
|
continue
|
||||||
|
if arg in ("--restart_mode", "--restart-mode"):
|
||||||
|
continue
|
||||||
|
if arg in ("--auto_restart_count", "--auto-restart-count"):
|
||||||
|
skip_next = True
|
||||||
|
continue
|
||||||
|
if arg.startswith("--auto_restart_count=") or arg.startswith("--auto-restart-count="):
|
||||||
|
continue
|
||||||
|
result.append(arg)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _run_as_supervisor(max_restarts: int):
|
||||||
|
"""
|
||||||
|
Supervisor process that spawns and monitors child processes.
|
||||||
|
|
||||||
|
Similar to Uvicorn's --reload: the supervisor itself does no heavy work,
|
||||||
|
it only launches the real process as a child and restarts it when the child
|
||||||
|
exits with RESTART_EXIT_CODE.
|
||||||
|
"""
|
||||||
|
child_argv = [sys.executable] + _build_child_argv()
|
||||||
|
restart_count = 0
|
||||||
|
|
||||||
|
print_status(
|
||||||
|
f"[Supervisor] Restart mode enabled (max restarts: {max_restarts}), "
|
||||||
|
f"child command: {' '.join(child_argv)}",
|
||||||
|
"info",
|
||||||
|
)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
print_status(
|
||||||
|
f"[Supervisor] Launching process (restart {restart_count}/{max_restarts})...",
|
||||||
|
"info",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
process = subprocess.Popen(child_argv)
|
||||||
|
exit_code = process.wait()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print_status("[Supervisor] Interrupted, terminating child process...", "info")
|
||||||
|
process.terminate()
|
||||||
|
try:
|
||||||
|
process.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
process.kill()
|
||||||
|
process.wait()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if exit_code == RESTART_EXIT_CODE:
|
||||||
|
restart_count += 1
|
||||||
|
if restart_count > max_restarts:
|
||||||
|
print_status(
|
||||||
|
f"[Supervisor] Maximum restart count ({max_restarts}) reached, exiting",
|
||||||
|
"warning",
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
print_status(
|
||||||
|
f"[Supervisor] Child requested restart ({restart_count}/{max_restarts}), restarting in 2s...",
|
||||||
|
"info",
|
||||||
|
)
|
||||||
|
time.sleep(2)
|
||||||
|
else:
|
||||||
|
if exit_code != 0:
|
||||||
|
print_status(f"[Supervisor] Child exited with code {exit_code}", "warning")
|
||||||
|
else:
|
||||||
|
print_status("[Supervisor] Child exited normally", "info")
|
||||||
|
sys.exit(exit_code)
|
||||||
|
|
||||||
|
|
||||||
def load_config_from_file(config_path):
|
def load_config_from_file(config_path):
|
||||||
if config_path is None:
|
if config_path is None:
|
||||||
@@ -65,6 +154,13 @@ def parse_args():
|
|||||||
action="append",
|
action="append",
|
||||||
help="Path to the registry directory",
|
help="Path to the registry directory",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--devices",
|
||||||
|
type=str,
|
||||||
|
default=None,
|
||||||
|
action="append",
|
||||||
|
help="Path to Python code directory for AST-based device/resource scanning",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--working_dir",
|
"--working_dir",
|
||||||
type=str,
|
type=str,
|
||||||
@@ -146,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(
|
||||||
@@ -154,23 +250,53 @@ def parse_args():
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Skip environment dependency check on startup",
|
help="Skip environment dependency check on startup",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
|
||||||
"--complete_registry",
|
|
||||||
action="store_true",
|
|
||||||
default=False,
|
|
||||||
help="Complete registry information",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--check_mode",
|
"--check_mode",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
default=False,
|
default=False,
|
||||||
help="Run in check mode for CI: validates registry imports and ensures no file changes",
|
help="Run in check mode for CI: validates registry imports and ensures no file changes",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--complete_registry",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help="Complete and rewrite YAML registry files using AST analysis results",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--no_update_feedback",
|
"--no_update_feedback",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Disable sending update feedback to server",
|
help="Disable sending update feedback to server",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--test_mode",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help="Test mode: all actions simulate execution and return mock results without running real hardware",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--external_devices_only",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help="Only load external device packages (--devices), skip built-in unilabos/devices/ scanning and YAML device registry",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--extra_resource",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help="Load extra lab_ prefixed labware resources (529 auto-generated definitions from lab_resources.py)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--restart_mode",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help="Enable supervisor mode: automatically restart the process when triggered via WebSocket",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--auto_restart_count",
|
||||||
|
type=int,
|
||||||
|
default=500,
|
||||||
|
help="Maximum number of automatic restarts in restart mode (default: 500)",
|
||||||
|
)
|
||||||
# workflow upload subcommand
|
# workflow upload subcommand
|
||||||
workflow_parser = subparsers.add_parser(
|
workflow_parser = subparsers.add_parser(
|
||||||
"workflow_upload",
|
"workflow_upload",
|
||||||
@@ -204,6 +330,33 @@ def parse_args():
|
|||||||
default=False,
|
default=False,
|
||||||
help="Whether to publish the workflow (default: False)",
|
help="Whether to publish the workflow (default: False)",
|
||||||
)
|
)
|
||||||
|
workflow_parser.add_argument(
|
||||||
|
"--description",
|
||||||
|
type=str,
|
||||||
|
default="",
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
@@ -215,68 +368,88 @@ def main():
|
|||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
args_dict = vars(args)
|
args_dict = vars(args)
|
||||||
|
|
||||||
|
# Supervisor mode: spawn child processes and monitor for restart
|
||||||
|
if args_dict.get("restart_mode", False):
|
||||||
|
_run_as_supervisor(args_dict.get("auto_restart_count", 5))
|
||||||
|
return
|
||||||
|
|
||||||
# 环境检查 - 检查并自动安装必需的包 (可选)
|
# 环境检查 - 检查并自动安装必需的包 (可选)
|
||||||
skip_env_check = args_dict.get("skip_env_check", False)
|
skip_env_check = args_dict.get("skip_env_check", False)
|
||||||
check_mode = args_dict.get("check_mode", False)
|
check_mode = args_dict.get("check_mode", False)
|
||||||
|
|
||||||
if not skip_env_check:
|
if not skip_env_check:
|
||||||
from unilabos.utils.environment_check import check_environment
|
from unilabos.utils.environment_check import check_environment, check_device_package_requirements
|
||||||
|
|
||||||
if not check_environment(auto_install=True):
|
if not check_environment(auto_install=True):
|
||||||
print_status("环境检查失败,程序退出", "error")
|
print_status("环境检查失败,程序退出", "error")
|
||||||
os._exit(1)
|
os._exit(1)
|
||||||
|
|
||||||
|
# 第一次设备包依赖检查:build_registry 之前,确保 import map 可用
|
||||||
|
devices_dirs_for_req = args_dict.get("devices", None)
|
||||||
|
if devices_dirs_for_req:
|
||||||
|
if not check_device_package_requirements(devices_dirs_for_req):
|
||||||
|
print_status("设备包依赖检查失败,程序退出", "error")
|
||||||
|
os._exit(1)
|
||||||
else:
|
else:
|
||||||
print_status("跳过环境依赖检查", "warning")
|
print_status("跳过环境依赖检查", "warning")
|
||||||
|
|
||||||
# 加载配置文件,优先加载config,然后从env读取
|
# 加载配置文件,优先加载config,然后从env读取
|
||||||
config_path = args_dict.get("config")
|
config_path = args_dict.get("config")
|
||||||
|
|
||||||
if check_mode:
|
# === 解析 working_dir ===
|
||||||
args_dict["working_dir"] = os.path.abspath(os.getcwd())
|
# 规则1: working_dir 传入 → 检测 unilabos_data 子目录,已是则不修改
|
||||||
# 当 skip_env_check 时,默认使用当前目录作为 working_dir
|
# 规则2: 仅 config_path 传入 → 用其父目录作为 working_dir
|
||||||
if skip_env_check and not args_dict.get("working_dir") and not config_path:
|
# 规则4: 两者都传入 → 各用各的,但 working_dir 仍做 unilabos_data 子目录检测
|
||||||
|
raw_working_dir = args_dict.get("working_dir")
|
||||||
|
if raw_working_dir:
|
||||||
|
working_dir = os.path.abspath(raw_working_dir)
|
||||||
|
elif config_path and os.path.exists(config_path):
|
||||||
|
working_dir = os.path.dirname(os.path.abspath(config_path))
|
||||||
|
else:
|
||||||
working_dir = os.path.abspath(os.getcwd())
|
working_dir = os.path.abspath(os.getcwd())
|
||||||
print_status(f"跳过环境检查模式:使用当前目录作为工作目录 {working_dir}", "info")
|
|
||||||
# 检查当前目录是否有 local_config.py
|
# unilabos_data 子目录自动检测
|
||||||
local_config_in_cwd = os.path.join(working_dir, "local_config.py")
|
if os.path.basename(working_dir) != "unilabos_data":
|
||||||
if os.path.exists(local_config_in_cwd):
|
unilabos_data_sub = os.path.join(working_dir, "unilabos_data")
|
||||||
config_path = local_config_in_cwd
|
if os.path.isdir(unilabos_data_sub):
|
||||||
|
working_dir = unilabos_data_sub
|
||||||
|
elif not raw_working_dir and not (config_path and os.path.exists(config_path)):
|
||||||
|
# 未显式指定路径,默认使用 cwd/unilabos_data
|
||||||
|
working_dir = os.path.abspath(os.path.join(os.getcwd(), "unilabos_data"))
|
||||||
|
|
||||||
|
# === 解析 config_path ===
|
||||||
|
if config_path and not os.path.exists(config_path):
|
||||||
|
# config_path 传入但不存在,尝试在 working_dir 中查找
|
||||||
|
candidate = os.path.join(working_dir, "local_config.py")
|
||||||
|
if os.path.exists(candidate):
|
||||||
|
config_path = candidate
|
||||||
|
print_status(f"在工作目录中发现配置文件: {config_path}", "info")
|
||||||
|
else:
|
||||||
|
print_status(
|
||||||
|
f"配置文件 {config_path} 不存在,工作目录 {working_dir} 中也未找到 local_config.py,"
|
||||||
|
f"请通过 --config 传入 local_config.py 文件路径",
|
||||||
|
"error",
|
||||||
|
)
|
||||||
|
os._exit(1)
|
||||||
|
elif not config_path:
|
||||||
|
# 规则3: 未传入 config_path,尝试 working_dir/local_config.py
|
||||||
|
candidate = os.path.join(working_dir, "local_config.py")
|
||||||
|
if os.path.exists(candidate):
|
||||||
|
config_path = candidate
|
||||||
print_status(f"发现本地配置文件: {config_path}", "info")
|
print_status(f"发现本地配置文件: {config_path}", "info")
|
||||||
else:
|
else:
|
||||||
print_status(f"未指定config路径,可通过 --config 传入 local_config.py 文件路径", "info")
|
print_status(f"未指定config路径,可通过 --config 传入 local_config.py 文件路径", "info")
|
||||||
elif os.getcwd().endswith("unilabos_data"):
|
print_status(f"您是否为第一次使用?并将当前路径 {working_dir} 作为工作目录? (Y/n)", "info")
|
||||||
working_dir = os.path.abspath(os.getcwd())
|
if check_mode or input() != "n":
|
||||||
else:
|
os.makedirs(working_dir, exist_ok=True)
|
||||||
working_dir = os.path.abspath(os.path.join(os.getcwd(), "unilabos_data"))
|
config_path = os.path.join(working_dir, "local_config.py")
|
||||||
|
shutil.copy(
|
||||||
if args_dict.get("working_dir"):
|
os.path.join(os.path.dirname(os.path.dirname(__file__)), "config", "example_config.py"),
|
||||||
working_dir = args_dict.get("working_dir", "")
|
config_path,
|
||||||
if config_path and not os.path.exists(config_path):
|
|
||||||
config_path = os.path.join(working_dir, "local_config.py")
|
|
||||||
if not os.path.exists(config_path):
|
|
||||||
print_status(
|
|
||||||
f"当前工作目录 {working_dir} 未找到local_config.py,请通过 --config 传入 local_config.py 文件路径",
|
|
||||||
"error",
|
|
||||||
)
|
)
|
||||||
|
print_status(f"已创建 local_config.py 路径: {config_path}", "info")
|
||||||
|
else:
|
||||||
os._exit(1)
|
os._exit(1)
|
||||||
elif config_path and os.path.exists(config_path):
|
|
||||||
working_dir = os.path.dirname(config_path)
|
|
||||||
elif os.path.exists(working_dir) and os.path.exists(os.path.join(working_dir, "local_config.py")):
|
|
||||||
config_path = os.path.join(working_dir, "local_config.py")
|
|
||||||
elif not skip_env_check and not config_path and (
|
|
||||||
not os.path.exists(working_dir) or not os.path.exists(os.path.join(working_dir, "local_config.py"))
|
|
||||||
):
|
|
||||||
print_status(f"未指定config路径,可通过 --config 传入 local_config.py 文件路径", "info")
|
|
||||||
print_status(f"您是否为第一次使用?并将当前路径 {working_dir} 作为工作目录? (Y/n)", "info")
|
|
||||||
if input() != "n":
|
|
||||||
os.makedirs(working_dir, exist_ok=True)
|
|
||||||
config_path = os.path.join(working_dir, "local_config.py")
|
|
||||||
shutil.copy(
|
|
||||||
os.path.join(os.path.dirname(os.path.dirname(__file__)), "config", "example_config.py"), config_path
|
|
||||||
)
|
|
||||||
print_status(f"已创建 local_config.py 路径: {config_path}", "info")
|
|
||||||
else:
|
|
||||||
os._exit(1)
|
|
||||||
|
|
||||||
# 加载配置文件 (check_mode 跳过)
|
# 加载配置文件 (check_mode 跳过)
|
||||||
print_status(f"当前工作目录为 {working_dir}", "info")
|
print_status(f"当前工作目录为 {working_dir}", "info")
|
||||||
@@ -288,15 +461,17 @@ def main():
|
|||||||
|
|
||||||
if hasattr(BasicConfig, "log_level"):
|
if hasattr(BasicConfig, "log_level"):
|
||||||
logger.info(f"Log level set to '{BasicConfig.log_level}' from config file.")
|
logger.info(f"Log level set to '{BasicConfig.log_level}' from config file.")
|
||||||
configure_logger(loglevel=BasicConfig.log_level, working_dir=working_dir)
|
file_path = configure_logger(loglevel=BasicConfig.log_level, working_dir=working_dir)
|
||||||
|
if file_path is not None:
|
||||||
|
logger.info(f"[LOG_FILE] {file_path}")
|
||||||
|
|
||||||
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"
|
||||||
@@ -332,46 +507,66 @@ def main():
|
|||||||
BasicConfig.slave_no_host = args_dict.get("slave_no_host", False)
|
BasicConfig.slave_no_host = args_dict.get("slave_no_host", False)
|
||||||
BasicConfig.upload_registry = args_dict.get("upload_registry", False)
|
BasicConfig.upload_registry = args_dict.get("upload_registry", False)
|
||||||
BasicConfig.no_update_feedback = args_dict.get("no_update_feedback", False)
|
BasicConfig.no_update_feedback = args_dict.get("no_update_feedback", False)
|
||||||
|
BasicConfig.test_mode = args_dict.get("test_mode", False)
|
||||||
|
if BasicConfig.test_mode:
|
||||||
|
print_status("启用测试模式:所有动作将模拟执行,不调用真实硬件", "warning")
|
||||||
|
BasicConfig.extra_resource = args_dict.get("extra_resource", False)
|
||||||
|
if BasicConfig.extra_resource:
|
||||||
|
print_status("启用额外资源加载:将加载lab_开头的labware资源定义", "info")
|
||||||
BasicConfig.communication_protocol = "websocket"
|
BasicConfig.communication_protocol = "websocket"
|
||||||
machine_name = os.popen("hostname").read().strip()
|
machine_name = platform.node()
|
||||||
machine_name = "".join([c if c.isalnum() or c == "_" else "_" for c in machine_name])
|
machine_name = "".join([c if c.isalnum() or c == "_" else "_" for c in machine_name])
|
||||||
BasicConfig.machine_name = machine_name
|
BasicConfig.machine_name = machine_name
|
||||||
BasicConfig.vis_2d_enable = args_dict["2d_vis"]
|
BasicConfig.vis_2d_enable = args_dict["2d_vis"]
|
||||||
BasicConfig.check_mode = check_mode
|
BasicConfig.check_mode = check_mode
|
||||||
|
|
||||||
from unilabos.resources.graphio import (
|
|
||||||
read_node_link_json,
|
|
||||||
read_graphml,
|
|
||||||
dict_from_graph,
|
|
||||||
)
|
|
||||||
from unilabos.app.communication import get_communication_client
|
|
||||||
from unilabos.registry.registry import build_registry
|
from unilabos.registry.registry import build_registry
|
||||||
from unilabos.app.backend import start_backend
|
|
||||||
from unilabos.app.web import http_client
|
|
||||||
from unilabos.app.web import start_server
|
|
||||||
from unilabos.app.register import register_devices_and_resources
|
|
||||||
from unilabos.resources.graphio import modify_to_backend_format
|
|
||||||
from unilabos.resources.resource_tracker import ResourceTreeSet, ResourceDict
|
|
||||||
|
|
||||||
# 显示启动横幅
|
# 显示启动横幅
|
||||||
print_unilab_banner(args_dict)
|
print_unilab_banner(args_dict)
|
||||||
|
|
||||||
# 注册表 - check_mode 时强制启用 complete_registry
|
# Step 0: AST 分析优先 + YAML 注册表加载
|
||||||
|
# check_mode 和 upload_registry 都会执行实际 import 验证
|
||||||
|
devices_dirs = args_dict.get("devices", None)
|
||||||
complete_registry = args_dict.get("complete_registry", False) or check_mode
|
complete_registry = args_dict.get("complete_registry", False) or check_mode
|
||||||
lab_registry = build_registry(args_dict["registry_path"], complete_registry, BasicConfig.upload_registry)
|
external_only = args_dict.get("external_devices_only", False)
|
||||||
|
lab_registry = build_registry(
|
||||||
|
registry_paths=args_dict["registry_path"],
|
||||||
|
devices_dirs=devices_dirs,
|
||||||
|
upload_registry=BasicConfig.upload_registry,
|
||||||
|
check_mode=check_mode,
|
||||||
|
complete_registry=complete_registry,
|
||||||
|
external_only=external_only,
|
||||||
|
)
|
||||||
|
|
||||||
# Check mode: complete_registry 完成后直接退出,git diff 检测由 CI workflow 执行
|
# Check mode: 注册表验证完成后直接退出
|
||||||
if check_mode:
|
if check_mode:
|
||||||
print_status("Check mode: complete_registry 完成,退出", "info")
|
device_count = len(lab_registry.device_type_registry)
|
||||||
|
resource_count = len(lab_registry.resource_type_registry)
|
||||||
|
print_status(f"Check mode: 注册表验证完成 ({device_count} 设备, {resource_count} 资源),退出", "info")
|
||||||
os._exit(0)
|
os._exit(0)
|
||||||
|
|
||||||
|
# 以下导入依赖 ROS2 环境,check_mode 已退出不需要
|
||||||
|
from unilabos.resources.graphio import (
|
||||||
|
read_node_link_json,
|
||||||
|
read_graphml,
|
||||||
|
dict_from_graph,
|
||||||
|
modify_to_backend_format,
|
||||||
|
)
|
||||||
|
from unilabos.app.communication import get_communication_client
|
||||||
|
from unilabos.app.backend import start_backend
|
||||||
|
from unilabos.app.web import http_client
|
||||||
|
from unilabos.app.web import start_server
|
||||||
|
from unilabos.app.register import register_devices_and_resources
|
||||||
|
from unilabos.resources.resource_tracker import ResourceTreeSet, ResourceDict
|
||||||
|
|
||||||
|
# Step 1: 上传全部注册表到服务端,同步保存到 unilabos_data
|
||||||
if BasicConfig.upload_registry:
|
if BasicConfig.upload_registry:
|
||||||
# 设备注册到服务端 - 需要 ak 和 sk
|
|
||||||
if BasicConfig.ak and BasicConfig.sk:
|
if BasicConfig.ak and BasicConfig.sk:
|
||||||
print_status("开始注册设备到服务端...", "info")
|
# print_status("开始注册设备到服务端...", "info")
|
||||||
try:
|
try:
|
||||||
register_devices_and_resources(lab_registry)
|
register_devices_and_resources(lab_registry)
|
||||||
print_status("设备注册完成", "info")
|
# print_status("设备注册完成", "info")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print_status(f"设备注册失败: {e}", "error")
|
print_status(f"设备注册失败: {e}", "error")
|
||||||
else:
|
else:
|
||||||
@@ -388,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
|
||||||
@@ -456,12 +651,16 @@ def main():
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# 如果从远端获取了物料信息,则与本地物料进行同步
|
# 如果从远端获取了物料信息,则与本地物料进行同步
|
||||||
if request_startup_json and "nodes" in request_startup_json:
|
if file_path is not None and request_startup_json and "nodes" in request_startup_json:
|
||||||
print_status("开始同步远端物料到本地...", "info")
|
print_status("开始同步远端物料到本地...", "info")
|
||||||
remote_tree_set = ResourceTreeSet.from_raw_dict_list(request_startup_json["nodes"])
|
remote_tree_set = ResourceTreeSet.from_raw_dict_list(request_startup_json["nodes"])
|
||||||
resource_tree_set.merge_remote_resources(remote_tree_set)
|
resource_tree_set.merge_remote_resources(remote_tree_set)
|
||||||
print_status("远端物料同步完成", "info")
|
print_status("远端物料同步完成", "info")
|
||||||
|
|
||||||
|
# 第二次设备包依赖检查:云端物料同步后,community 包可能引入新的 requirements
|
||||||
|
# TODO: 当 community device package 功能上线后,在这里调用
|
||||||
|
# install_requirements_txt(community_pkg_path / "requirements.txt", label="community.xxx")
|
||||||
|
|
||||||
# 使用 ResourceTreeSet 代替 list
|
# 使用 ResourceTreeSet 代替 list
|
||||||
args_dict["resources_config"] = resource_tree_set
|
args_dict["resources_config"] = resource_tree_set
|
||||||
args_dict["devices_config"] = resource_tree_set
|
args_dict["devices_config"] = resource_tree_set
|
||||||
@@ -553,6 +752,10 @@ def main():
|
|||||||
open_browser=not args_dict["disable_browser"],
|
open_browser=not args_dict["disable_browser"],
|
||||||
port=BasicConfig.port,
|
port=BasicConfig.port,
|
||||||
)
|
)
|
||||||
|
if restart_requested:
|
||||||
|
print_status("[Main] Restart requested, cleaning up...", "info")
|
||||||
|
cleanup_for_restart()
|
||||||
|
os._exit(RESTART_EXIT_CODE)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class JobAddReq(BaseModel):
|
|||||||
action_type: str = Field(
|
action_type: str = Field(
|
||||||
examples=["unilabos_msgs.action._str_single_input.StrSingleInput"], description="action type", default=""
|
examples=["unilabos_msgs.action._str_single_input.StrSingleInput"], description="action type", default=""
|
||||||
)
|
)
|
||||||
sample_material: dict = Field(examples=[{"string": "string"}], description="sample uuid to material uuid", default_factory=dict)
|
sample_material: dict = Field(examples=[{"string": "string"}], description="sample uuid to material uuid")
|
||||||
action_args: dict = Field(examples=[{"string": "string"}], description="action arguments", default_factory=dict)
|
action_args: dict = Field(examples=[{"string": "string"}], description="action arguments", default_factory=dict)
|
||||||
task_id: str = Field(examples=["task_id"], description="task uuid (auto-generated if empty)", default="")
|
task_id: str = Field(examples=["task_id"], description="task uuid (auto-generated if empty)", default="")
|
||||||
job_id: str = Field(examples=["job_id"], description="goal uuid (auto-generated if empty)", default="")
|
job_id: str = Field(examples=["job_id"], description="goal uuid (auto-generated if empty)", default="")
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import json
|
|
||||||
import time
|
import time
|
||||||
from typing import Optional, Tuple, Dict, Any
|
from typing import Any, Dict, Optional, Tuple
|
||||||
|
|
||||||
from unilabos.utils.log import logger
|
from unilabos.utils.log import logger
|
||||||
from unilabos.utils.type_check import TypeEncoder
|
from unilabos.utils.tools import normalize_json as _normalize_device
|
||||||
|
|
||||||
|
|
||||||
def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[Tuple[Dict[str, Any], Dict[str, Any]]]:
|
def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[Tuple[Dict[str, Any], Dict[str, Any]]]:
|
||||||
@@ -11,50 +10,63 @@ def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[
|
|||||||
注册设备和资源到服务器(仅支持HTTP)
|
注册设备和资源到服务器(仅支持HTTP)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# 注册资源信息 - 使用HTTP方式
|
|
||||||
from unilabos.app.web.client import http_client
|
from unilabos.app.web.client import http_client
|
||||||
|
|
||||||
logger.info("[UniLab Register] 开始注册设备和资源...")
|
logger.info("[UniLab Register] 开始注册设备和资源...")
|
||||||
|
|
||||||
# 注册设备信息
|
|
||||||
devices_to_register = {}
|
devices_to_register = {}
|
||||||
for device_info in lab_registry.obtain_registry_device_info():
|
for device_info in lab_registry.obtain_registry_device_info():
|
||||||
devices_to_register[device_info["id"]] = json.loads(
|
devices_to_register[device_info["id"]] = _normalize_device(device_info)
|
||||||
json.dumps(device_info, ensure_ascii=False, cls=TypeEncoder)
|
logger.trace(f"[UniLab Register] 收集设备: {device_info['id']}")
|
||||||
)
|
|
||||||
logger.debug(f"[UniLab Register] 收集设备: {device_info['id']}")
|
|
||||||
|
|
||||||
resources_to_register = {}
|
resources_to_register = {}
|
||||||
for resource_info in lab_registry.obtain_registry_resource_info():
|
for resource_info in lab_registry.obtain_registry_resource_info():
|
||||||
resources_to_register[resource_info["id"]] = resource_info
|
resources_to_register[resource_info["id"]] = resource_info
|
||||||
logger.debug(f"[UniLab Register] 收集资源: {resource_info['id']}")
|
logger.trace(f"[UniLab Register] 收集资源: {resource_info['id']}")
|
||||||
|
|
||||||
if gather_only:
|
if gather_only:
|
||||||
return devices_to_register, resources_to_register
|
return devices_to_register, resources_to_register
|
||||||
# 注册设备
|
|
||||||
if devices_to_register:
|
if devices_to_register:
|
||||||
try:
|
try:
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
response = http_client.resource_registry({"resources": list(devices_to_register.values())})
|
response = http_client.resource_registry(
|
||||||
|
{"resources": list(devices_to_register.values())},
|
||||||
|
tag="device_registry",
|
||||||
|
)
|
||||||
cost_time = time.time() - start_time
|
cost_time = time.time() - start_time
|
||||||
if response.status_code in [200, 201]:
|
res_data = response.json() if response.status_code == 200 else {}
|
||||||
logger.info(f"[UniLab Register] 成功注册 {len(devices_to_register)} 个设备 {cost_time}ms")
|
skipped = res_data.get("data", {}).get("skipped", False)
|
||||||
|
if skipped:
|
||||||
|
logger.info(
|
||||||
|
f"[UniLab Register] 设备注册跳过(内容未变化)"
|
||||||
|
f" {len(devices_to_register)} 个 {cost_time:.3f}s"
|
||||||
|
)
|
||||||
|
elif response.status_code in [200, 201]:
|
||||||
|
logger.info(f"[UniLab Register] 成功注册 {len(devices_to_register)} 个设备 {cost_time:.3f}s")
|
||||||
else:
|
else:
|
||||||
logger.error(f"[UniLab Register] 设备注册失败: {response.status_code}, {response.text} {cost_time}ms")
|
logger.error(f"[UniLab Register] 设备注册失败: {response.status_code}, {response.text} {cost_time:.3f}s")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[UniLab Register] 设备注册异常: {e}")
|
logger.error(f"[UniLab Register] 设备注册异常: {e}")
|
||||||
|
|
||||||
# 注册资源
|
|
||||||
if resources_to_register:
|
if resources_to_register:
|
||||||
try:
|
try:
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
response = http_client.resource_registry({"resources": list(resources_to_register.values())})
|
response = http_client.resource_registry(
|
||||||
|
{"resources": list(resources_to_register.values())},
|
||||||
|
tag="resource_registry",
|
||||||
|
)
|
||||||
cost_time = time.time() - start_time
|
cost_time = time.time() - start_time
|
||||||
if response.status_code in [200, 201]:
|
res_data = response.json() if response.status_code == 200 else {}
|
||||||
logger.info(f"[UniLab Register] 成功注册 {len(resources_to_register)} 个资源 {cost_time}ms")
|
skipped = res_data.get("data", {}).get("skipped", False)
|
||||||
|
if skipped:
|
||||||
|
logger.info(
|
||||||
|
f"[UniLab Register] 资源注册跳过(内容未变化)"
|
||||||
|
f" {len(resources_to_register)} 个 {cost_time:.3f}s"
|
||||||
|
)
|
||||||
|
elif response.status_code in [200, 201]:
|
||||||
|
logger.info(f"[UniLab Register] 成功注册 {len(resources_to_register)} 个资源 {cost_time:.3f}s")
|
||||||
else:
|
else:
|
||||||
logger.error(f"[UniLab Register] 资源注册失败: {response.status_code}, {response.text} {cost_time}ms")
|
logger.error(f"[UniLab Register] 资源注册失败: {response.status_code}, {response.text} {cost_time:.3f}s")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[UniLab Register] 资源注册异常: {e}")
|
logger.error(f"[UniLab Register] 资源注册异常: {e}")
|
||||||
|
|
||||||
logger.info("[UniLab Register] 设备和资源注册完成.")
|
|
||||||
|
|||||||
@@ -1052,7 +1052,7 @@ async def handle_file_import(websocket: WebSocket, request_data: dict):
|
|||||||
"result": {},
|
"result": {},
|
||||||
"schema": lab_registry._generate_unilab_json_command_schema(v["args"], k),
|
"schema": lab_registry._generate_unilab_json_command_schema(v["args"], k),
|
||||||
"goal_default": {i["name"]: i["default"] for i in v["args"]},
|
"goal_default": {i["name"]: i["default"] for i in v["args"]},
|
||||||
"handles": [],
|
"handles": {},
|
||||||
}
|
}
|
||||||
# 不生成已配置action的动作
|
# 不生成已配置action的动作
|
||||||
for k, v in enhanced_info["action_methods"].items()
|
for k, v in enhanced_info["action_methods"].items()
|
||||||
@@ -1340,5 +1340,5 @@ def setup_api_routes(app):
|
|||||||
# 启动广播任务
|
# 启动广播任务
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup_event():
|
async def startup_event():
|
||||||
asyncio.create_task(broadcast_device_status())
|
asyncio.create_task(broadcast_device_status(), name="web-api-startup-device")
|
||||||
asyncio.create_task(broadcast_status_page_data())
|
asyncio.create_task(broadcast_status_page_data(), name="web-api-startup-status")
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ HTTP客户端模块
|
|||||||
|
|
||||||
提供与远程服务器通信的客户端功能,只有host需要用
|
提供与远程服务器通信的客户端功能,只有host需要用
|
||||||
"""
|
"""
|
||||||
|
import gzip
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
|
|
||||||
|
from unilabos.utils.tools import fast_dumps as _fast_dumps, fast_dumps_pretty as _fast_dumps_pretty
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from unilabos.resources.resource_tracker import ResourceTreeSet
|
from unilabos.resources.resource_tracker import ResourceTreeSet
|
||||||
from unilabos.utils.log import info
|
from unilabos.utils.log import info
|
||||||
@@ -34,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:
|
||||||
@@ -46,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,
|
||||||
@@ -73,25 +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}
|
nodes_info = [x for xs in resources.dump() for x in xs]
|
||||||
f.write(json.dumps(payload, indent=4))
|
|
||||||
# 从序列化数据中提取所有节点的UUID(保存旧UUID)
|
|
||||||
old_uuids = {n.res_content.uuid: n for n in resources.all_nodes}
|
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": [x for xs in resources.dump() for x in xs], "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": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid},
|
data=body_bytes,
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
headers=http_headers,
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -109,6 +117,7 @@ class HTTPClient:
|
|||||||
uuid_mapping[i["uuid"]] = i["cloud_uuid"]
|
uuid_mapping[i["uuid"]] = i["cloud_uuid"]
|
||||||
else:
|
else:
|
||||||
logger.error(f"添加物料失败: {response.text}")
|
logger.error(f"添加物料失败: {response.text}")
|
||||||
|
logger.trace(f"添加物料失败: {nodes_info}")
|
||||||
for u, n in old_uuids.items():
|
for u, n in old_uuids.items():
|
||||||
if u in uuid_mapping:
|
if u in uuid_mapping:
|
||||||
n.res_content.uuid = uuid_mapping[u]
|
n.res_content.uuid = uuid_mapping[u]
|
||||||
@@ -129,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}"},
|
||||||
@@ -143,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}")
|
||||||
@@ -160,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}"},
|
||||||
@@ -194,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}"},
|
||||||
@@ -235,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}"},
|
||||||
@@ -272,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}"},
|
||||||
@@ -280,22 +290,54 @@ class HTTPClient:
|
|||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def resource_registry(self, registry_data: Dict[str, Any] | List[Dict[str, Any]]) -> requests.Response:
|
def resource_registry(
|
||||||
|
self, registry_data: Dict[str, Any] | List[Dict[str, Any]], tag: str = "registry",
|
||||||
|
) -> requests.Response:
|
||||||
"""
|
"""
|
||||||
注册资源到服务器
|
注册资源到服务器,同步保存请求/响应到 unilabos_data
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
registry_data: 注册表数据,格式为 {resource_id: resource_info} / [{resource_info}]
|
registry_data: 注册表数据,格式为 {resource_id: resource_info} / [{resource_info}]
|
||||||
|
tag: 保存文件的标签后缀 (如 "device_registry" / "resource_registry")
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Response: API响应对象
|
Response: API响应对象
|
||||||
"""
|
"""
|
||||||
response = requests.post(
|
# 序列化一次,同时用于保存和发送
|
||||||
|
json_bytes = _fast_dumps(registry_data)
|
||||||
|
|
||||||
|
# 保存请求数据到 unilabos_data
|
||||||
|
req_path = os.path.join(BasicConfig.working_dir, f"req_{tag}_upload.json")
|
||||||
|
try:
|
||||||
|
os.makedirs(BasicConfig.working_dir, exist_ok=True)
|
||||||
|
with open(req_path, "wb") as f:
|
||||||
|
f.write(_fast_dumps_pretty(registry_data))
|
||||||
|
logger.trace(f"注册表请求数据已保存: {req_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"保存注册表请求数据失败: {e}")
|
||||||
|
|
||||||
|
compressed_body = gzip.compress(json_bytes)
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Lab {self.auth}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Content-Encoding": "gzip",
|
||||||
|
}
|
||||||
|
response = self._session.post(
|
||||||
f"{self.remote_addr}/lab/resource",
|
f"{self.remote_addr}/lab/resource",
|
||||||
json=registry_data,
|
data=compressed_body,
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
headers=headers,
|
||||||
timeout=30,
|
timeout=30,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 保存响应数据到 unilabos_data
|
||||||
|
res_path = os.path.join(BasicConfig.working_dir, f"res_{tag}_upload.json")
|
||||||
|
try:
|
||||||
|
with open(res_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(f"{response.status_code}\n{response.text}")
|
||||||
|
logger.trace(f"注册表响应数据已保存: {res_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"保存注册表响应数据失败: {e}")
|
||||||
|
|
||||||
if response.status_code not in [200, 201]:
|
if response.status_code not in [200, 201]:
|
||||||
logger.error(f"注册资源失败: {response.status_code}, {response.text}")
|
logger.error(f"注册资源失败: {response.status_code}, {response.text}")
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
@@ -314,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),
|
||||||
@@ -343,9 +385,10 @@ class HTTPClient:
|
|||||||
edges: List[Dict[str, Any]],
|
edges: List[Dict[str, Any]],
|
||||||
tags: Optional[List[str]] = None,
|
tags: Optional[List[str]] = None,
|
||||||
published: bool = False,
|
published: bool = False,
|
||||||
|
description: str = "",
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
导入工作流到服务器
|
导入工作流到服务器,如果 published 为 True,则额外发起发布请求
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: 工作流名称(顶层)
|
name: 工作流名称(顶层)
|
||||||
@@ -355,6 +398,7 @@ class HTTPClient:
|
|||||||
edges: 工作流边列表
|
edges: 工作流边列表
|
||||||
tags: 工作流标签列表,默认为空列表
|
tags: 工作流标签列表,默认为空列表
|
||||||
published: 是否发布工作流,默认为False
|
published: 是否发布工作流,默认为False
|
||||||
|
description: 工作流描述,发布时使用
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict: API响应数据,包含 code 和 data (uuid, name)
|
Dict: API响应数据,包含 code 和 data (uuid, name)
|
||||||
@@ -367,14 +411,13 @@ class HTTPClient:
|
|||||||
"nodes": nodes,
|
"nodes": nodes,
|
||||||
"edges": edges,
|
"edges": edges,
|
||||||
"tags": tags if tags is not None else [],
|
"tags": tags if tags is not None else [],
|
||||||
"published": published,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
# 保存请求到文件
|
# 保存请求到文件
|
||||||
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}"},
|
||||||
@@ -388,11 +431,51 @@ class HTTPClient:
|
|||||||
res = response.json()
|
res = response.json()
|
||||||
if "code" in res and res["code"] != 0:
|
if "code" in res and res["code"] != 0:
|
||||||
logger.error(f"导入工作流失败: {response.text}")
|
logger.error(f"导入工作流失败: {response.text}")
|
||||||
|
return res
|
||||||
|
# 导入成功后,如果需要发布则额外发起发布请求
|
||||||
|
if published:
|
||||||
|
imported_uuid = res.get("data", {}).get("uuid", workflow_uuid)
|
||||||
|
publish_res = self.workflow_publish(imported_uuid, description)
|
||||||
|
res["publish_result"] = publish_res
|
||||||
return res
|
return res
|
||||||
else:
|
else:
|
||||||
logger.error(f"导入工作流失败: {response.status_code}, {response.text}")
|
logger.error(f"导入工作流失败: {response.status_code}, {response.text}")
|
||||||
return {"code": response.status_code, "message": response.text}
|
return {"code": response.status_code, "message": response.text}
|
||||||
|
|
||||||
|
def workflow_publish(self, workflow_uuid: str, description: str = "") -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
发布工作流
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workflow_uuid: 工作流UUID
|
||||||
|
description: 工作流描述
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict: API响应数据
|
||||||
|
"""
|
||||||
|
payload = {
|
||||||
|
"uuid": workflow_uuid,
|
||||||
|
"description": description,
|
||||||
|
"published": True,
|
||||||
|
}
|
||||||
|
logger.info(f"正在发布工作流: {workflow_uuid}")
|
||||||
|
response = requests.patch(
|
||||||
|
f"{self.remote_addr}/lab/workflow/owner",
|
||||||
|
json=payload,
|
||||||
|
headers={"Authorization": f"Lab {self.auth}"},
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
res = response.json()
|
||||||
|
if "code" in res and res["code"] != 0:
|
||||||
|
logger.error(f"发布工作流失败: {response.text}")
|
||||||
|
else:
|
||||||
|
logger.info(f"工作流发布成功: {workflow_uuid}")
|
||||||
|
return res
|
||||||
|
else:
|
||||||
|
logger.error(f"发布工作流失败: {response.status_code}, {response.text}")
|
||||||
|
return {"code": response.status_code, "message": response.text}
|
||||||
|
|
||||||
|
|
||||||
# 创建默认客户端实例
|
# 创建默认客户端实例
|
||||||
http_client = HTTPClient()
|
http_client = HTTPClient()
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ def setup_server() -> FastAPI:
|
|||||||
# 设置页面路由
|
# 设置页面路由
|
||||||
try:
|
try:
|
||||||
setup_web_pages(pages)
|
setup_web_pages(pages)
|
||||||
info("[Web] 已加载Web UI模块")
|
# info("[Web] 已加载Web UI模块")
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
info(f"[Web] 未找到Web页面模块: {str(e)}")
|
info(f"[Web] 未找到Web页面模块: {str(e)}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -138,7 +138,7 @@ def start_server(host: str = "0.0.0.0", port: int = 8002, open_browser: bool = T
|
|||||||
server_thread = threading.Thread(target=server.run, daemon=True, name="uvicorn_server")
|
server_thread = threading.Thread(target=server.run, daemon=True, name="uvicorn_server")
|
||||||
server_thread.start()
|
server_thread.start()
|
||||||
|
|
||||||
info("[Web] Server started, monitoring for restart requests...")
|
# info("[Web] Server started, monitoring for restart requests...")
|
||||||
|
|
||||||
# 监控重启标志
|
# 监控重启标志
|
||||||
import unilabos.app.main as main_module
|
import unilabos.app.main as main_module
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from enum import Enum
|
|||||||
from typing_extensions import TypedDict
|
from typing_extensions import TypedDict
|
||||||
|
|
||||||
from unilabos.app.model import JobAddReq
|
from unilabos.app.model import JobAddReq
|
||||||
|
from unilabos.resources.resource_tracker import ResourceDictType
|
||||||
from unilabos.ros.nodes.presets.host_node import HostNode
|
from unilabos.ros.nodes.presets.host_node import HostNode
|
||||||
from unilabos.utils.type_check import serialize_result_info
|
from unilabos.utils.type_check import serialize_result_info
|
||||||
from unilabos.app.communication import BaseCommunicationClient
|
from unilabos.app.communication import BaseCommunicationClient
|
||||||
@@ -76,6 +77,7 @@ class JobInfo:
|
|||||||
start_time: float
|
start_time: float
|
||||||
last_update_time: float = field(default_factory=time.time)
|
last_update_time: float = field(default_factory=time.time)
|
||||||
ready_timeout: Optional[float] = None # READY状态的超时时间
|
ready_timeout: Optional[float] = None # READY状态的超时时间
|
||||||
|
always_free: bool = False # 是否为永久闲置动作(不受排队限制)
|
||||||
|
|
||||||
def update_timestamp(self):
|
def update_timestamp(self):
|
||||||
"""更新最后更新时间"""
|
"""更新最后更新时间"""
|
||||||
@@ -127,6 +129,15 @@ class DeviceActionManager:
|
|||||||
# 总是将job添加到all_jobs中
|
# 总是将job添加到all_jobs中
|
||||||
self.all_jobs[job_info.job_id] = job_info
|
self.all_jobs[job_info.job_id] = job_info
|
||||||
|
|
||||||
|
# always_free的动作不受排队限制,直接设为READY
|
||||||
|
if job_info.always_free:
|
||||||
|
job_info.status = JobStatus.READY
|
||||||
|
job_info.update_timestamp()
|
||||||
|
job_info.set_ready_timeout(10)
|
||||||
|
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||||
|
logger.trace(f"[DeviceActionManager] Job {job_log} always_free, start immediately")
|
||||||
|
return True
|
||||||
|
|
||||||
# 检查是否有正在执行或准备执行的任务
|
# 检查是否有正在执行或准备执行的任务
|
||||||
if device_key in self.active_jobs:
|
if device_key in self.active_jobs:
|
||||||
# 有正在执行或准备执行的任务,加入队列
|
# 有正在执行或准备执行的任务,加入队列
|
||||||
@@ -176,11 +187,15 @@ class DeviceActionManager:
|
|||||||
logger.error(f"[DeviceActionManager] Job {job_log} is not in READY status, current: {job_info.status}")
|
logger.error(f"[DeviceActionManager] Job {job_log} is not in READY status, current: {job_info.status}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 检查设备上是否是这个job
|
# always_free的job不需要检查active_jobs
|
||||||
if device_key not in self.active_jobs or self.active_jobs[device_key].job_id != job_id:
|
if not job_info.always_free:
|
||||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
# 检查设备上是否是这个job
|
||||||
logger.error(f"[DeviceActionManager] Job {job_log} is not the active job for {device_key}")
|
if device_key not in self.active_jobs or self.active_jobs[device_key].job_id != job_id:
|
||||||
return False
|
job_log = format_job_log(
|
||||||
|
job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name
|
||||||
|
)
|
||||||
|
logger.error(f"[DeviceActionManager] Job {job_log} is not the active job for {device_key}")
|
||||||
|
return False
|
||||||
|
|
||||||
# 开始执行任务,将状态从READY转换为STARTED
|
# 开始执行任务,将状态从READY转换为STARTED
|
||||||
job_info.status = JobStatus.STARTED
|
job_info.status = JobStatus.STARTED
|
||||||
@@ -203,6 +218,13 @@ class DeviceActionManager:
|
|||||||
job_info = self.all_jobs[job_id]
|
job_info = self.all_jobs[job_id]
|
||||||
device_key = job_info.device_action_key
|
device_key = job_info.device_action_key
|
||||||
|
|
||||||
|
# always_free的job直接清理,不影响队列
|
||||||
|
if job_info.always_free:
|
||||||
|
job_info.status = JobStatus.ENDED
|
||||||
|
job_info.update_timestamp()
|
||||||
|
del self.all_jobs[job_id]
|
||||||
|
return None
|
||||||
|
|
||||||
# 移除活跃任务
|
# 移除活跃任务
|
||||||
if device_key in self.active_jobs and self.active_jobs[device_key].job_id == job_id:
|
if device_key in self.active_jobs and self.active_jobs[device_key].job_id == job_id:
|
||||||
del self.active_jobs[device_key]
|
del self.active_jobs[device_key]
|
||||||
@@ -234,9 +256,14 @@ class DeviceActionManager:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def get_active_jobs(self) -> List[JobInfo]:
|
def get_active_jobs(self) -> List[JobInfo]:
|
||||||
"""获取所有正在执行的任务"""
|
"""获取所有正在执行的任务(含active_jobs和always_free的STARTED job)"""
|
||||||
with self.lock:
|
with self.lock:
|
||||||
return list(self.active_jobs.values())
|
jobs = list(self.active_jobs.values())
|
||||||
|
# 补充 always_free 的 STARTED job(它们不在 active_jobs 中)
|
||||||
|
for job in self.all_jobs.values():
|
||||||
|
if job.always_free and job.status == JobStatus.STARTED and job not in jobs:
|
||||||
|
jobs.append(job)
|
||||||
|
return jobs
|
||||||
|
|
||||||
def get_queued_jobs(self) -> List[JobInfo]:
|
def get_queued_jobs(self) -> List[JobInfo]:
|
||||||
"""获取所有排队中的任务"""
|
"""获取所有排队中的任务"""
|
||||||
@@ -261,6 +288,14 @@ class DeviceActionManager:
|
|||||||
job_info = self.all_jobs[job_id]
|
job_info = self.all_jobs[job_id]
|
||||||
device_key = job_info.device_action_key
|
device_key = job_info.device_action_key
|
||||||
|
|
||||||
|
# always_free的job直接清理
|
||||||
|
if job_info.always_free:
|
||||||
|
job_info.status = JobStatus.ENDED
|
||||||
|
del self.all_jobs[job_id]
|
||||||
|
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||||
|
logger.trace(f"[DeviceActionManager] Always-free job {job_log} cancelled")
|
||||||
|
return True
|
||||||
|
|
||||||
# 如果是正在执行的任务
|
# 如果是正在执行的任务
|
||||||
if device_key in self.active_jobs and self.active_jobs[device_key].job_id == job_id:
|
if device_key in self.active_jobs and self.active_jobs[device_key].job_id == job_id:
|
||||||
# 清理active job状态
|
# 清理active job状态
|
||||||
@@ -334,13 +369,18 @@ class DeviceActionManager:
|
|||||||
timeout_jobs = []
|
timeout_jobs = []
|
||||||
|
|
||||||
with self.lock:
|
with self.lock:
|
||||||
# 统计READY状态的任务数量
|
# 收集所有需要检查的 READY 任务(active_jobs + always_free READY jobs)
|
||||||
ready_jobs_count = sum(1 for job in self.active_jobs.values() if job.status == JobStatus.READY)
|
ready_candidates = list(self.active_jobs.values())
|
||||||
|
for job in self.all_jobs.values():
|
||||||
|
if job.always_free and job.status == JobStatus.READY and job not in ready_candidates:
|
||||||
|
ready_candidates.append(job)
|
||||||
|
|
||||||
|
ready_jobs_count = sum(1 for job in ready_candidates if job.status == JobStatus.READY)
|
||||||
if ready_jobs_count > 0:
|
if ready_jobs_count > 0:
|
||||||
logger.trace(f"[DeviceActionManager] Checking {ready_jobs_count} READY jobs for timeout") # type: ignore # noqa: E501
|
logger.trace(f"[DeviceActionManager] Checking {ready_jobs_count} READY jobs for timeout") # type: ignore # noqa: E501
|
||||||
|
|
||||||
# 找到所有超时的READY任务(只检测,不处理)
|
# 找到所有超时的READY任务(只检测,不处理)
|
||||||
for job_info in self.active_jobs.values():
|
for job_info in ready_candidates:
|
||||||
if job_info.is_ready_timeout():
|
if job_info.is_ready_timeout():
|
||||||
timeout_jobs.append(job_info)
|
timeout_jobs.append(job_info)
|
||||||
job_log = format_job_log(
|
job_log = format_job_log(
|
||||||
@@ -369,6 +409,7 @@ class MessageProcessor:
|
|||||||
# 线程控制
|
# 线程控制
|
||||||
self.is_running = False
|
self.is_running = False
|
||||||
self.thread = None
|
self.thread = None
|
||||||
|
self._loop = None # asyncio event loop引用,用于外部关闭websocket
|
||||||
self.reconnect_count = 0
|
self.reconnect_count = 0
|
||||||
|
|
||||||
logger.info(f"[MessageProcessor] Initialized for URL: {websocket_url}")
|
logger.info(f"[MessageProcessor] Initialized for URL: {websocket_url}")
|
||||||
@@ -395,22 +436,31 @@ class MessageProcessor:
|
|||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
"""停止消息处理线程"""
|
"""停止消息处理线程"""
|
||||||
self.is_running = False
|
self.is_running = False
|
||||||
|
# 主动关闭websocket以快速中断消息接收循环
|
||||||
|
ws = self.websocket
|
||||||
|
loop = self._loop
|
||||||
|
if ws and loop and loop.is_running():
|
||||||
|
try:
|
||||||
|
asyncio.run_coroutine_threadsafe(ws.close(), loop)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if self.thread and self.thread.is_alive():
|
if self.thread and self.thread.is_alive():
|
||||||
self.thread.join(timeout=2)
|
self.thread.join(timeout=2)
|
||||||
logger.info("[MessageProcessor] Stopped")
|
logger.info("[MessageProcessor] Stopped")
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
"""运行消息处理主循环"""
|
"""运行消息处理主循环"""
|
||||||
loop = asyncio.new_event_loop()
|
self._loop = asyncio.new_event_loop()
|
||||||
try:
|
try:
|
||||||
asyncio.set_event_loop(loop)
|
asyncio.set_event_loop(self._loop)
|
||||||
loop.run_until_complete(self._connection_handler())
|
self._loop.run_until_complete(self._connection_handler())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[MessageProcessor] Thread error: {str(e)}")
|
logger.error(f"[MessageProcessor] Thread error: {str(e)}")
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
finally:
|
finally:
|
||||||
if loop:
|
if self._loop:
|
||||||
loop.close()
|
self._loop.close()
|
||||||
|
self._loop = None
|
||||||
|
|
||||||
async def _connection_handler(self):
|
async def _connection_handler(self):
|
||||||
"""处理WebSocket连接和重连逻辑"""
|
"""处理WebSocket连接和重连逻辑"""
|
||||||
@@ -427,8 +477,10 @@ class MessageProcessor:
|
|||||||
async with websockets.connect(
|
async with websockets.connect(
|
||||||
self.websocket_url,
|
self.websocket_url,
|
||||||
ssl=ssl_context,
|
ssl=ssl_context,
|
||||||
|
open_timeout=20,
|
||||||
ping_interval=WSConfig.ping_interval,
|
ping_interval=WSConfig.ping_interval,
|
||||||
ping_timeout=10,
|
ping_timeout=10,
|
||||||
|
close_timeout=5,
|
||||||
additional_headers={
|
additional_headers={
|
||||||
"Authorization": f"Lab {BasicConfig.auth_secret()}",
|
"Authorization": f"Lab {BasicConfig.auth_secret()}",
|
||||||
"EdgeSession": f"{self.session_id}",
|
"EdgeSession": f"{self.session_id}",
|
||||||
@@ -439,81 +491,94 @@ class MessageProcessor:
|
|||||||
self.connected = True
|
self.connected = True
|
||||||
self.reconnect_count = 0
|
self.reconnect_count = 0
|
||||||
|
|
||||||
logger.trace(f"[MessageProcessor] Connected to {self.websocket_url}")
|
logger.info(f"[MessageProcessor] 已连接到 {self.websocket_url}")
|
||||||
|
|
||||||
# 启动发送协程
|
# 启动发送协程
|
||||||
send_task = asyncio.create_task(self._send_handler())
|
send_task = asyncio.create_task(self._send_handler(), name="websocket-send_task")
|
||||||
|
|
||||||
|
# 每次连接(含重连)后重新向服务端注册,
|
||||||
|
# 否则服务端不知道客户端已上线,不会推送消息。
|
||||||
|
if self.websocket_client:
|
||||||
|
self.websocket_client.publish_host_ready()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 接收消息循环
|
# 接收消息循环
|
||||||
await self._message_handler()
|
await self._message_handler()
|
||||||
finally:
|
finally:
|
||||||
|
# 必须在 async with __aexit__ 之前停止 send_task,
|
||||||
|
# 否则 send_task 会在关闭握手期间继续发送数据,
|
||||||
|
# 干扰 websockets 库的内部清理,导致 task 泄漏。
|
||||||
|
self.connected = False
|
||||||
send_task.cancel()
|
send_task.cancel()
|
||||||
try:
|
try:
|
||||||
await send_task
|
await send_task
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
self.connected = False
|
|
||||||
|
|
||||||
except websockets.exceptions.ConnectionClosed:
|
except websockets.exceptions.ConnectionClosed:
|
||||||
logger.warning("[MessageProcessor] Connection closed")
|
logger.warning("[MessageProcessor] 与服务端连接中断")
|
||||||
self.connected = False
|
except TimeoutError:
|
||||||
|
logger.warning(
|
||||||
|
f"[MessageProcessor] 与服务端连接通信超时 (已尝试 {self.reconnect_count + 1} 次),请检查您的网络状况"
|
||||||
|
)
|
||||||
|
except websockets.exceptions.InvalidStatus as e:
|
||||||
|
logger.warning(
|
||||||
|
f"[MessageProcessor] 收到服务端注册码 {e.response.status_code}, 上一进程可能还未退出"
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[MessageProcessor] Connection error: {str(e)}")
|
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
self.connected = False
|
logger.error(f"[MessageProcessor] 尝试重连时出错 {str(e)}")
|
||||||
finally:
|
finally:
|
||||||
|
self.connected = False
|
||||||
self.websocket = None
|
self.websocket = None
|
||||||
|
|
||||||
# 重连逻辑
|
# 重连逻辑
|
||||||
if self.is_running and self.reconnect_count < WSConfig.max_reconnect_attempts:
|
if not self.is_running:
|
||||||
|
break
|
||||||
|
if self.reconnect_count < WSConfig.max_reconnect_attempts:
|
||||||
self.reconnect_count += 1
|
self.reconnect_count += 1
|
||||||
|
backoff = WSConfig.reconnect_interval
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[MessageProcessor] Reconnecting in {WSConfig.reconnect_interval}s "
|
f"[MessageProcessor] 即将在 {backoff} 秒后重连 (已尝试 {self.reconnect_count}/{WSConfig.max_reconnect_attempts})"
|
||||||
f"(attempt {self.reconnect_count}/{WSConfig.max_reconnect_attempts})"
|
|
||||||
)
|
)
|
||||||
await asyncio.sleep(WSConfig.reconnect_interval)
|
await asyncio.sleep(backoff)
|
||||||
elif self.reconnect_count >= WSConfig.max_reconnect_attempts:
|
else:
|
||||||
logger.error("[MessageProcessor] Max reconnection attempts reached")
|
logger.error("[MessageProcessor] Max reconnection attempts reached")
|
||||||
break
|
break
|
||||||
else:
|
|
||||||
self.reconnect_count -= 1
|
|
||||||
|
|
||||||
async def _message_handler(self):
|
async def _message_handler(self):
|
||||||
"""处理接收到的消息"""
|
"""处理接收到的消息。
|
||||||
|
|
||||||
|
ConnectionClosed 不在此处捕获,让其向上传播到 _connection_handler,
|
||||||
|
以便 async with websockets.connect() 的 __aexit__ 能感知连接已断,
|
||||||
|
正确清理内部 task,避免 task 泄漏。
|
||||||
|
"""
|
||||||
if not self.websocket:
|
if not self.websocket:
|
||||||
logger.error("[MessageProcessor] WebSocket connection is None")
|
logger.error("[MessageProcessor] WebSocket connection is None")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
async for message in self.websocket:
|
||||||
async for message in self.websocket:
|
try:
|
||||||
try:
|
data = json.loads(message)
|
||||||
data = json.loads(message)
|
message_type = data.get("action", "")
|
||||||
message_type = data.get("action", "")
|
message_data = data.get("data")
|
||||||
message_data = data.get("data")
|
if self.session_id and self.session_id == data.get("edge_session"):
|
||||||
if self.session_id and self.session_id == data.get("edge_session"):
|
await self._process_message(message_type, message_data)
|
||||||
await self._process_message(message_type, message_data)
|
else:
|
||||||
|
if message_type.endswith("_material"):
|
||||||
|
logger.trace(
|
||||||
|
f"[MessageProcessor] 收到一条归属 {data.get('edge_session')} 的旧消息:{data}"
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"[MessageProcessor] 跳过了一条归属 {data.get('edge_session')} 的旧消息: {data.get('action')}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
if message_type.endswith("_material"):
|
await self._process_message(message_type, message_data)
|
||||||
logger.trace(
|
except json.JSONDecodeError:
|
||||||
f"[MessageProcessor] 收到一条归属 {data.get('edge_session')} 的旧消息:{data}"
|
logger.error(f"[MessageProcessor] Invalid JSON received: {message}")
|
||||||
)
|
except Exception as e:
|
||||||
logger.debug(
|
logger.error(f"[MessageProcessor] Error processing message: {str(e)}")
|
||||||
f"[MessageProcessor] 跳过了一条归属 {data.get('edge_session')} 的旧消息: {data.get('action')}"
|
logger.error(traceback.format_exc())
|
||||||
)
|
|
||||||
else:
|
|
||||||
await self._process_message(message_type, message_data)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
logger.error(f"[MessageProcessor] Invalid JSON received: {message}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[MessageProcessor] Error processing message: {str(e)}")
|
|
||||||
logger.error(traceback.format_exc())
|
|
||||||
|
|
||||||
except websockets.exceptions.ConnectionClosed:
|
|
||||||
logger.info("[MessageProcessor] Message handler stopped - connection closed")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[MessageProcessor] Message handler error: {str(e)}")
|
|
||||||
logger.error(traceback.format_exc())
|
|
||||||
|
|
||||||
async def _send_handler(self):
|
async def _send_handler(self):
|
||||||
"""处理发送队列中的消息"""
|
"""处理发送队列中的消息"""
|
||||||
@@ -562,6 +627,7 @@ class MessageProcessor:
|
|||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
logger.debug("[MessageProcessor] Send handler cancelled")
|
logger.debug("[MessageProcessor] Send handler cancelled")
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[MessageProcessor] Fatal error in send handler: {str(e)}")
|
logger.error(f"[MessageProcessor] Fatal error in send handler: {str(e)}")
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
@@ -593,6 +659,10 @@ class MessageProcessor:
|
|||||||
# elif message_type == "session_id":
|
# elif message_type == "session_id":
|
||||||
# self.session_id = message_data.get("session_id")
|
# self.session_id = message_data.get("session_id")
|
||||||
# logger.info(f"[MessageProcessor] Session ID: {self.session_id}")
|
# logger.info(f"[MessageProcessor] Session ID: {self.session_id}")
|
||||||
|
elif message_type == "add_device":
|
||||||
|
await self._handle_device_manage(message_data, "add")
|
||||||
|
elif message_type == "remove_device":
|
||||||
|
await self._handle_device_manage(message_data, "remove")
|
||||||
elif message_type == "request_restart":
|
elif message_type == "request_restart":
|
||||||
await self._handle_request_restart(message_data)
|
await self._handle_request_restart(message_data)
|
||||||
else:
|
else:
|
||||||
@@ -608,6 +678,24 @@ class MessageProcessor:
|
|||||||
if host_node:
|
if host_node:
|
||||||
host_node.handle_pong_response(pong_data)
|
host_node.handle_pong_response(pong_data)
|
||||||
|
|
||||||
|
def _check_action_always_free(self, device_id: str, action_name: str) -> bool:
|
||||||
|
"""检查该action是否标记为always_free,通过HostNode统一的_action_value_mappings查找"""
|
||||||
|
try:
|
||||||
|
host_node = HostNode.get_instance(0)
|
||||||
|
if not host_node:
|
||||||
|
return False
|
||||||
|
# noinspection PyProtectedMember
|
||||||
|
action_mappings = host_node._action_value_mappings.get(device_id)
|
||||||
|
if not action_mappings:
|
||||||
|
return False
|
||||||
|
# 尝试直接匹配或 auto- 前缀匹配
|
||||||
|
for key in [action_name, f"auto-{action_name}"]:
|
||||||
|
if key in action_mappings:
|
||||||
|
return action_mappings[key].get("always_free", False)
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
async def _handle_query_action_state(self, data: Dict[str, Any]):
|
async def _handle_query_action_state(self, data: Dict[str, Any]):
|
||||||
"""处理query_action_state消息"""
|
"""处理query_action_state消息"""
|
||||||
device_id = data.get("device_id", "")
|
device_id = data.get("device_id", "")
|
||||||
@@ -622,6 +710,9 @@ class MessageProcessor:
|
|||||||
|
|
||||||
device_action_key = f"/devices/{device_id}/{action_name}"
|
device_action_key = f"/devices/{device_id}/{action_name}"
|
||||||
|
|
||||||
|
# 检查action是否为always_free
|
||||||
|
action_always_free = self._check_action_always_free(device_id, action_name)
|
||||||
|
|
||||||
# 创建任务信息
|
# 创建任务信息
|
||||||
job_info = JobInfo(
|
job_info = JobInfo(
|
||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
@@ -631,6 +722,7 @@ class MessageProcessor:
|
|||||||
device_action_key=device_action_key,
|
device_action_key=device_action_key,
|
||||||
status=JobStatus.QUEUE,
|
status=JobStatus.QUEUE,
|
||||||
start_time=time.time(),
|
start_time=time.time(),
|
||||||
|
always_free=action_always_free,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 添加到设备管理器
|
# 添加到设备管理器
|
||||||
@@ -657,9 +749,37 @@ class MessageProcessor:
|
|||||||
async def _handle_job_start(self, data: Dict[str, Any]):
|
async def _handle_job_start(self, data: Dict[str, Any]):
|
||||||
"""处理job_start消息"""
|
"""处理job_start消息"""
|
||||||
try:
|
try:
|
||||||
|
if not data.get("sample_material"):
|
||||||
|
data["sample_material"] = {}
|
||||||
req = JobAddReq(**data)
|
req = JobAddReq(**data)
|
||||||
|
|
||||||
job_log = format_job_log(req.job_id, req.task_id, req.device_id, req.action)
|
job_log = format_job_log(req.job_id, req.task_id, req.device_id, req.action)
|
||||||
|
|
||||||
|
# 服务端对always_free动作可能跳过query_action_state直接发job_start,
|
||||||
|
# 此时job尚未注册,需要自动补注册
|
||||||
|
existing_job = self.device_manager.get_job_info(req.job_id)
|
||||||
|
if not existing_job:
|
||||||
|
action_name = req.action
|
||||||
|
device_action_key = f"/devices/{req.device_id}/{action_name}"
|
||||||
|
action_always_free = self._check_action_always_free(req.device_id, action_name)
|
||||||
|
|
||||||
|
if action_always_free:
|
||||||
|
job_info = JobInfo(
|
||||||
|
job_id=req.job_id,
|
||||||
|
task_id=req.task_id,
|
||||||
|
device_id=req.device_id,
|
||||||
|
action_name=action_name,
|
||||||
|
device_action_key=device_action_key,
|
||||||
|
status=JobStatus.QUEUE,
|
||||||
|
start_time=time.time(),
|
||||||
|
always_free=True,
|
||||||
|
)
|
||||||
|
self.device_manager.add_queue_request(job_info)
|
||||||
|
logger.info(f"[MessageProcessor] Job {job_log} always_free, auto-registered from direct job_start")
|
||||||
|
else:
|
||||||
|
logger.error(f"[MessageProcessor] Job {job_log} not registered (missing query_action_state)")
|
||||||
|
return
|
||||||
|
|
||||||
success = self.device_manager.start_job(req.job_id)
|
success = self.device_manager.start_job(req.job_id)
|
||||||
if not success:
|
if not success:
|
||||||
logger.error(f"[MessageProcessor] Failed to start job {job_log}")
|
logger.error(f"[MessageProcessor] Failed to start job {job_log}")
|
||||||
@@ -905,6 +1025,37 @@ class MessageProcessor:
|
|||||||
)
|
)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
|
async def _handle_device_manage(self, device_list: list[ResourceDictType], action: str):
|
||||||
|
"""Handle add_device / remove_device from LabGo server."""
|
||||||
|
if not device_list:
|
||||||
|
return
|
||||||
|
|
||||||
|
for item in device_list:
|
||||||
|
target_node_id = item.get("target_node_id", "host_node")
|
||||||
|
|
||||||
|
def _notify(target_id: str, act: str, cfg: ResourceDictType):
|
||||||
|
try:
|
||||||
|
host_node = HostNode.get_instance(timeout=5)
|
||||||
|
if not host_node:
|
||||||
|
logger.error(f"[DeviceManage] HostNode not available for {act}_device")
|
||||||
|
return
|
||||||
|
success = host_node.notify_device_manage(target_id, act, cfg)
|
||||||
|
if success:
|
||||||
|
logger.info(f"[DeviceManage] {act}_device completed on {target_id}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"[DeviceManage] {act}_device failed on {target_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[DeviceManage] Error in {act}_device: {e}")
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=_notify,
|
||||||
|
args=(target_node_id, action, item),
|
||||||
|
daemon=True,
|
||||||
|
name=f"DeviceManage-{action}-{item.get('id', '')}",
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
async def _handle_request_restart(self, data: Dict[str, Any]):
|
async def _handle_request_restart(self, data: Dict[str, Any]):
|
||||||
"""
|
"""
|
||||||
处理重启请求
|
处理重启请求
|
||||||
@@ -916,10 +1067,9 @@ class MessageProcessor:
|
|||||||
logger.info(f"[MessageProcessor] Received restart request, reason: {reason}, delay: {delay}s")
|
logger.info(f"[MessageProcessor] Received restart request, reason: {reason}, delay: {delay}s")
|
||||||
|
|
||||||
# 发送确认消息
|
# 发送确认消息
|
||||||
if self.websocket_client:
|
self.send_message(
|
||||||
await self.websocket_client.send_message(
|
{"action": "restart_acknowledged", "data": {"reason": reason, "delay": delay}}
|
||||||
{"action": "restart_acknowledged", "data": {"reason": reason, "delay": delay}}
|
)
|
||||||
)
|
|
||||||
|
|
||||||
# 设置全局重启标志
|
# 设置全局重启标志
|
||||||
import unilabos.app.main as main_module
|
import unilabos.app.main as main_module
|
||||||
@@ -963,7 +1113,7 @@ class MessageProcessor:
|
|||||||
"task_id": task_id,
|
"task_id": task_id,
|
||||||
"job_id": job_id,
|
"job_id": job_id,
|
||||||
"free": free,
|
"free": free,
|
||||||
"need_more": need_more,
|
"need_more": need_more + 1,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1021,6 +1171,7 @@ class QueueProcessor:
|
|||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
"""停止队列处理线程"""
|
"""停止队列处理线程"""
|
||||||
self.is_running = False
|
self.is_running = False
|
||||||
|
self.queue_update_event.set() # 立即唤醒等待中的线程
|
||||||
if self.thread and self.thread.is_alive():
|
if self.thread and self.thread.is_alive():
|
||||||
self.thread.join(timeout=2)
|
self.thread.join(timeout=2)
|
||||||
logger.info("[QueueProcessor] Stopped")
|
logger.info("[QueueProcessor] Stopped")
|
||||||
@@ -1102,7 +1253,7 @@ class QueueProcessor:
|
|||||||
"task_id": job_info.task_id,
|
"task_id": job_info.task_id,
|
||||||
"job_id": job_info.job_id,
|
"job_id": job_info.job_id,
|
||||||
"free": False,
|
"free": False,
|
||||||
"need_more": 10,
|
"need_more": 10 + 1,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
self.message_processor.send_message(message)
|
self.message_processor.send_message(message)
|
||||||
@@ -1118,9 +1269,20 @@ 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,
|
||||||
|
# 此时不应再发送 busy/need_more,否则会覆盖已发出的 free=True 通知
|
||||||
|
if job_info.status != JobStatus.QUEUE:
|
||||||
|
continue
|
||||||
|
|
||||||
message = {
|
message = {
|
||||||
"action": "report_action_state",
|
"action": "report_action_state",
|
||||||
"data": {
|
"data": {
|
||||||
@@ -1130,7 +1292,7 @@ class QueueProcessor:
|
|||||||
"task_id": job_info.task_id,
|
"task_id": job_info.task_id,
|
||||||
"job_id": job_info.job_id,
|
"job_id": job_info.job_id,
|
||||||
"free": False,
|
"free": False,
|
||||||
"need_more": 10,
|
"need_more": 10 + 1,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
success = self.message_processor.send_message(message)
|
success = self.message_processor.send_message(message)
|
||||||
@@ -1213,6 +1375,10 @@ class WebSocketClient(BaseCommunicationClient):
|
|||||||
self.message_processor = MessageProcessor(self.websocket_url, self.send_queue, self.device_manager)
|
self.message_processor = MessageProcessor(self.websocket_url, self.send_queue, self.device_manager)
|
||||||
self.queue_processor = QueueProcessor(self.device_manager, self.message_processor)
|
self.queue_processor = QueueProcessor(self.device_manager, self.message_processor)
|
||||||
|
|
||||||
|
# running状态debounce缓存: {job_id: (last_send_timestamp, last_feedback_data)}
|
||||||
|
self._job_running_last_sent: Dict[str, tuple] = {}
|
||||||
|
self._job_running_debounce_interval: float = 10.0 # 秒
|
||||||
|
|
||||||
# 设置相互引用
|
# 设置相互引用
|
||||||
self.message_processor.set_queue_processor(self.queue_processor)
|
self.message_processor.set_queue_processor(self.queue_processor)
|
||||||
self.message_processor.set_websocket_client(self)
|
self.message_processor.set_websocket_client(self)
|
||||||
@@ -1269,8 +1435,8 @@ class WebSocketClient(BaseCommunicationClient):
|
|||||||
message = {"action": "normal_exit", "data": {"session_id": session_id}}
|
message = {"action": "normal_exit", "data": {"session_id": session_id}}
|
||||||
self.message_processor.send_message(message)
|
self.message_processor.send_message(message)
|
||||||
logger.info(f"[WebSocketClient] Sent normal_exit message with session_id: {session_id}")
|
logger.info(f"[WebSocketClient] Sent normal_exit message with session_id: {session_id}")
|
||||||
# 给一点时间让消息发送出去
|
# send_handler 每100ms检查一次队列,等300ms足以让消息发出
|
||||||
time.sleep(1)
|
time.sleep(0.3)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[WebSocketClient] Failed to send normal_exit message: {str(e)}")
|
logger.warning(f"[WebSocketClient] Failed to send normal_exit message: {str(e)}")
|
||||||
|
|
||||||
@@ -1312,22 +1478,32 @@ class WebSocketClient(BaseCommunicationClient):
|
|||||||
logger.debug(f"[WebSocketClient] Not connected, cannot publish job status for job_id: {item.job_id}")
|
logger.debug(f"[WebSocketClient] Not connected, cannot publish job status for job_id: {item.job_id}")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name)
|
||||||
|
|
||||||
# 拦截最终结果状态,与原版本逻辑一致
|
# 拦截最终结果状态,与原版本逻辑一致
|
||||||
if status in ["success", "failed"]:
|
if status in ["success", "failed"]:
|
||||||
|
self._job_running_last_sent.pop(item.job_id, None)
|
||||||
|
|
||||||
host_node = HostNode.get_instance(0)
|
host_node = HostNode.get_instance(0)
|
||||||
if host_node:
|
if host_node:
|
||||||
# 从HostNode的device_action_status中移除job_id
|
|
||||||
try:
|
try:
|
||||||
host_node._device_action_status[item.device_action_key].job_ids.pop(item.job_id, None)
|
host_node._device_action_status[item.device_action_key].job_ids.pop(item.job_id, None)
|
||||||
except (KeyError, AttributeError):
|
except (KeyError, AttributeError):
|
||||||
logger.warning(f"[WebSocketClient] Failed to remove job {item.job_id} from HostNode status")
|
logger.warning(f"[WebSocketClient] Failed to remove job {item.job_id} from HostNode status")
|
||||||
|
|
||||||
# logger.debug(f"[WebSocketClient] Intercepting final status for job_id: {item.job_id} - {status}")
|
|
||||||
|
|
||||||
# 通知队列处理器job完成(包括timeout的job)
|
|
||||||
self.queue_processor.handle_job_completed(item.job_id, status)
|
self.queue_processor.handle_job_completed(item.job_id, status)
|
||||||
|
|
||||||
# 发送job状态消息
|
# running状态按job_id做debounce,内容变化时仍然上报
|
||||||
|
if status == "running":
|
||||||
|
now = time.time()
|
||||||
|
cached = self._job_running_last_sent.get(item.job_id)
|
||||||
|
if cached is not None:
|
||||||
|
last_ts, last_data = cached
|
||||||
|
if now - last_ts < self._job_running_debounce_interval and last_data == feedback_data:
|
||||||
|
logger.trace(f"[WebSocketClient] Job status debounced (skip): {job_log} - {status}")
|
||||||
|
return
|
||||||
|
self._job_running_last_sent[item.job_id] = (now, feedback_data)
|
||||||
|
|
||||||
message = {
|
message = {
|
||||||
"action": "job_status",
|
"action": "job_status",
|
||||||
"data": {
|
"data": {
|
||||||
@@ -1343,7 +1519,6 @@ class WebSocketClient(BaseCommunicationClient):
|
|||||||
}
|
}
|
||||||
self.message_processor.send_message(message)
|
self.message_processor.send_message(message)
|
||||||
|
|
||||||
job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name)
|
|
||||||
logger.trace(f"[WebSocketClient] Job status published: {job_log} - {status}")
|
logger.trace(f"[WebSocketClient] Job status published: {job_log} - {status}")
|
||||||
|
|
||||||
def send_ping(self, ping_id: str, timestamp: float) -> None:
|
def send_ping(self, ping_id: str, timestamp: float) -> None:
|
||||||
|
|||||||
@@ -95,8 +95,29 @@ def get_vessel_liquid_volume(G: nx.DiGraph, vessel: str) -> float:
|
|||||||
return total_volume
|
return total_volume
|
||||||
|
|
||||||
|
|
||||||
def is_integrated_pump(node_name):
|
def is_integrated_pump(node_class: str, node_name: str = "") -> bool:
|
||||||
return "pump" in node_name and "valve" in node_name
|
"""
|
||||||
|
判断是否为泵阀一体设备
|
||||||
|
"""
|
||||||
|
class_lower = (node_class or "").lower()
|
||||||
|
name_lower = (node_name or "").lower()
|
||||||
|
|
||||||
|
if "pump" not in class_lower and "pump" not in name_lower:
|
||||||
|
return False
|
||||||
|
|
||||||
|
integrated_markers = [
|
||||||
|
"valve",
|
||||||
|
"pump_valve",
|
||||||
|
"pumpvalve",
|
||||||
|
"integrated",
|
||||||
|
"transfer_pump",
|
||||||
|
]
|
||||||
|
|
||||||
|
for marker in integrated_markers:
|
||||||
|
if marker in class_lower or marker in name_lower:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def find_connected_pump(G, valve_node):
|
def find_connected_pump(G, valve_node):
|
||||||
@@ -186,7 +207,9 @@ def build_pump_valve_maps(G, pump_backbone):
|
|||||||
debug_print(f"🔧 过滤后的骨架: {filtered_backbone}")
|
debug_print(f"🔧 过滤后的骨架: {filtered_backbone}")
|
||||||
|
|
||||||
for node in filtered_backbone:
|
for node in filtered_backbone:
|
||||||
if is_integrated_pump(G.nodes[node]["class"]):
|
node_data = G.nodes.get(node, {})
|
||||||
|
node_class = node_data.get("class", "") or ""
|
||||||
|
if is_integrated_pump(node_class, node):
|
||||||
pumps_from_node[node] = node
|
pumps_from_node[node] = node
|
||||||
valve_from_node[node] = node
|
valve_from_node[node] = node
|
||||||
debug_print(f" - 集成泵-阀: {node}")
|
debug_print(f" - 集成泵-阀: {node}")
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ class BasicConfig:
|
|||||||
disable_browser = False # 禁止浏览器自动打开
|
disable_browser = False # 禁止浏览器自动打开
|
||||||
port = 8002 # 本地HTTP服务
|
port = 8002 # 本地HTTP服务
|
||||||
check_mode = False # CI 检查模式,用于验证 registry 导入和文件一致性
|
check_mode = False # CI 检查模式,用于验证 registry 导入和文件一致性
|
||||||
|
test_mode = False # 测试模式,所有动作不实际执行,返回模拟结果
|
||||||
|
extra_resource = False # 是否加载lab_开头的额外资源
|
||||||
# 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'
|
# 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'
|
||||||
log_level: Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "DEBUG"
|
log_level: Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "DEBUG"
|
||||||
|
|
||||||
@@ -39,12 +41,12 @@ class BasicConfig:
|
|||||||
class WSConfig:
|
class WSConfig:
|
||||||
reconnect_interval = 5 # 重连间隔(秒)
|
reconnect_interval = 5 # 重连间隔(秒)
|
||||||
max_reconnect_attempts = 999 # 最大重连次数
|
max_reconnect_attempts = 999 # 最大重连次数
|
||||||
ping_interval = 30 # ping间隔(秒)
|
ping_interval = 20 # ping间隔(秒)
|
||||||
|
|
||||||
|
|
||||||
# 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配置
|
||||||
@@ -145,5 +147,5 @@ def load_config(config_path=None):
|
|||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
exit(1)
|
exit(1)
|
||||||
else:
|
else:
|
||||||
config_path = os.path.join(os.path.dirname(__file__), "local_config.py")
|
config_path = os.path.join(os.path.dirname(__file__), "example_config.py")
|
||||||
load_config(config_path)
|
load_config(config_path)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
from abc import abstractmethod
|
from abc import abstractmethod
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
import inspect
|
import inspect
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -19,10 +19,11 @@ from rclpy.node import Node
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
class LiquidHandlerJointPublisher(BaseROS2DeviceNode):
|
class LiquidHandlerJointPublisher(BaseROS2DeviceNode):
|
||||||
def __init__(self,resources_config:list, resource_tracker, rate=50, device_id:str = "lh_joint_publisher", **kwargs):
|
def __init__(self,resources_config:list, resource_tracker, rate=50, device_id:str = "lh_joint_publisher", registry_name: str = "lh_joint_publisher", **kwargs):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
driver_instance=self,
|
driver_instance=self,
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
|
registry_name=registry_name,
|
||||||
status_types={},
|
status_types={},
|
||||||
action_value_mappings={},
|
action_value_mappings={},
|
||||||
hardware_interface={},
|
hardware_interface={},
|
||||||
|
|||||||
@@ -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,173 +172,160 @@ 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}
|
||||||
function_list = []
|
if speed is not None:
|
||||||
|
config["speed"] = speed
|
||||||
|
if retry is not None:
|
||||||
|
config["retry"] = retry
|
||||||
|
|
||||||
status = cmd_dict["status"]
|
function_list = []
|
||||||
joint_positions_ = self.joint_poses[cmd_dict["move_group"]][status]
|
joint_positions_ = self.joint_poses[move_group][status]
|
||||||
|
|
||||||
config.update({k: cmd_dict[k] for k in ["speed", "retry", "move_group"] if k in cmd_dict})
|
# 夹取 / 放置:绑定 resource 与 parent
|
||||||
|
if not place_flag:
|
||||||
|
if target is not None:
|
||||||
|
function_list.append(lambda r=resource, t=target: self.resource_manager(r, t))
|
||||||
|
else:
|
||||||
|
ee = self.moveit2[move_group].end_effector_name
|
||||||
|
function_list.append(lambda r=resource: self.resource_manager(r, ee))
|
||||||
|
else:
|
||||||
|
function_list.append(lambda r=resource: self.resource_manager(r, "world"))
|
||||||
|
|
||||||
# 夹取
|
joint_constraint_msgs: list = []
|
||||||
if not place_flag:
|
if constraints is not None:
|
||||||
if "target" in cmd_dict.keys():
|
for i, c in enumerate(constraints):
|
||||||
function_list.append(lambda: self.resource_manager(cmd_dict["resource"], cmd_dict["target"]))
|
v = float(c)
|
||||||
else:
|
if v > 0:
|
||||||
function_list.append(
|
joint_constraint_msgs.append(
|
||||||
lambda: self.resource_manager(
|
JointConstraint(
|
||||||
cmd_dict["resource"], self.moveit2[cmd_dict["move_group"]].end_effector_name
|
joint_name=self.moveit2[move_group].joint_names[i],
|
||||||
|
position=joint_positions_[i],
|
||||||
|
tolerance_above=v,
|
||||||
|
tolerance_below=v,
|
||||||
|
weight=1.0,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
function_list.append(lambda: self.resource_manager(cmd_dict["resource"], "world"))
|
|
||||||
|
|
||||||
constraints = []
|
if lift_height is not None:
|
||||||
if "constraints" in cmd_dict.keys():
|
retval = None
|
||||||
|
attempts = config.get("retry", 10)
|
||||||
|
while retval is None and attempts > 0:
|
||||||
|
retval = self.moveit2[move_group].compute_fk(joint_positions_)
|
||||||
|
time.sleep(0.1)
|
||||||
|
attempts -= 1
|
||||||
|
if retval is None:
|
||||||
|
raise ValueError("Failed to compute forward kinematics")
|
||||||
|
pose = [retval.pose.position.x, retval.pose.position.y, retval.pose.position.z]
|
||||||
|
quaternion = [
|
||||||
|
retval.pose.orientation.x,
|
||||||
|
retval.pose.orientation.y,
|
||||||
|
retval.pose.orientation.z,
|
||||||
|
retval.pose.orientation.w,
|
||||||
|
]
|
||||||
|
|
||||||
for i in range(len(cmd_dict["constraints"])):
|
function_list = [
|
||||||
v = float(cmd_dict["constraints"][i])
|
lambda: self.moveit_task(
|
||||||
if v > 0:
|
position=[retval.pose.position.x, retval.pose.position.y, retval.pose.position.z],
|
||||||
constraints.append(
|
quaternion=quaternion,
|
||||||
JointConstraint(
|
**config,
|
||||||
joint_name=self.moveit2[cmd_dict["move_group"]].joint_names[i],
|
cartesian=self.cartesian_flag,
|
||||||
position=joint_positions_[i],
|
)
|
||||||
tolerance_above=v,
|
] + function_list
|
||||||
tolerance_below=v,
|
|
||||||
weight=1.0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if "lift_height" in cmd_dict.keys():
|
pose[2] += float(lift_height)
|
||||||
retval = None
|
function_list.append(
|
||||||
retry = config.get("retry", 10)
|
lambda p=pose.copy(), q=quaternion, cfg=config: self.moveit_task(
|
||||||
while retval is None and retry > 0:
|
position=p, quaternion=q, **cfg, cartesian=self.cartesian_flag
|
||||||
retval = self.moveit2[cmd_dict["move_group"]].compute_fk(joint_positions_)
|
)
|
||||||
time.sleep(0.1)
|
)
|
||||||
retry -= 1
|
end_pose = list(pose)
|
||||||
if retval is None:
|
|
||||||
result.success = False
|
if x_distance is not None or y_distance is not None:
|
||||||
return result
|
if x_distance is not None:
|
||||||
pose = [retval.pose.position.x, retval.pose.position.y, retval.pose.position.z]
|
deep_pose = deepcopy(pose)
|
||||||
quaternion = [
|
deep_pose[0] += float(x_distance)
|
||||||
retval.pose.orientation.x,
|
elif y_distance is not None:
|
||||||
retval.pose.orientation.y,
|
deep_pose = deepcopy(pose)
|
||||||
retval.pose.orientation.z,
|
deep_pose[1] += float(y_distance)
|
||||||
retval.pose.orientation.w,
|
|
||||||
]
|
|
||||||
|
|
||||||
function_list = [
|
function_list = [
|
||||||
lambda: self.moveit_task(
|
lambda p=pose.copy(), q=quaternion, cfg=config: self.moveit_task(
|
||||||
position=[retval.pose.position.x, retval.pose.position.y, retval.pose.position.z],
|
position=p, quaternion=q, **cfg, cartesian=self.cartesian_flag
|
||||||
quaternion=quaternion,
|
|
||||||
**config,
|
|
||||||
cartesian=self.cartesian_flag,
|
|
||||||
)
|
)
|
||||||
] + function_list
|
] + function_list
|
||||||
|
|
||||||
pose[2] += float(cmd_dict["lift_height"])
|
|
||||||
function_list.append(
|
function_list.append(
|
||||||
lambda: self.moveit_task(
|
lambda dp=deep_pose.copy(), q=quaternion, cfg=config: self.moveit_task(
|
||||||
position=pose, quaternion=quaternion, **config, cartesian=self.cartesian_flag
|
position=dp, quaternion=q, **cfg, cartesian=self.cartesian_flag
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
end_pose = pose
|
end_pose = list(deep_pose)
|
||||||
|
|
||||||
if "x_distance" in cmd_dict.keys() or "y_distance" in cmd_dict.keys():
|
retval_ik = None
|
||||||
if "x_distance" in cmd_dict.keys():
|
attempts_ik = config.get("retry", 10)
|
||||||
deep_pose = deepcopy(pose)
|
while retval_ik is None and attempts_ik > 0:
|
||||||
deep_pose[0] += float(cmd_dict["x_distance"])
|
retval_ik = self.moveit2[move_group].compute_ik(
|
||||||
elif "y_distance" in cmd_dict.keys():
|
position=end_pose,
|
||||||
deep_pose = deepcopy(pose)
|
quat_xyzw=quaternion,
|
||||||
deep_pose[1] += float(cmd_dict["y_distance"])
|
constraints=Constraints(joint_constraints=joint_constraint_msgs),
|
||||||
|
)
|
||||||
|
time.sleep(0.1)
|
||||||
|
attempts_ik -= 1
|
||||||
|
if retval_ik is None:
|
||||||
|
raise ValueError("Failed to compute inverse kinematics")
|
||||||
|
position_ = [
|
||||||
|
retval_ik.position[retval_ik.name.index(i)] for i in self.moveit2[move_group].joint_names
|
||||||
|
]
|
||||||
|
jn = self.moveit2[move_group].joint_names
|
||||||
|
function_list = [
|
||||||
|
lambda pos=position_, names=jn, cfg=config: self.moveit_joint_task(
|
||||||
|
joint_positions=pos, joint_names=names, **cfg
|
||||||
|
)
|
||||||
|
] + function_list
|
||||||
|
else:
|
||||||
|
function_list = [lambda cfg=config, jp=joint_positions_: self.moveit_joint_task(**cfg, joint_positions=jp)] + function_list
|
||||||
|
|
||||||
function_list = [
|
for i in range(len(function_list)):
|
||||||
lambda: self.moveit_task(
|
if i == 0:
|
||||||
position=pose, quaternion=quaternion, **config, cartesian=self.cartesian_flag
|
self.cartesian_flag = False
|
||||||
)
|
|
||||||
] + function_list
|
|
||||||
function_list.append(
|
|
||||||
lambda: self.moveit_task(
|
|
||||||
position=deep_pose, quaternion=quaternion, **config, cartesian=self.cartesian_flag
|
|
||||||
)
|
|
||||||
)
|
|
||||||
end_pose = deep_pose
|
|
||||||
|
|
||||||
retval_ik = None
|
|
||||||
retry = config.get("retry", 10)
|
|
||||||
while retval_ik is None and retry > 0:
|
|
||||||
retval_ik = self.moveit2[cmd_dict["move_group"]].compute_ik(
|
|
||||||
position=end_pose, quat_xyzw=quaternion, constraints=Constraints(joint_constraints=constraints)
|
|
||||||
)
|
|
||||||
time.sleep(0.1)
|
|
||||||
retry -= 1
|
|
||||||
if retval_ik is None:
|
|
||||||
result.success = False
|
|
||||||
return result
|
|
||||||
position_ = [
|
|
||||||
retval_ik.position[retval_ik.name.index(i)]
|
|
||||||
for i in self.moveit2[cmd_dict["move_group"]].joint_names
|
|
||||||
]
|
|
||||||
function_list = [
|
|
||||||
lambda: self.moveit_joint_task(
|
|
||||||
joint_positions=position_,
|
|
||||||
joint_names=self.moveit2[cmd_dict["move_group"]].joint_names,
|
|
||||||
**config,
|
|
||||||
)
|
|
||||||
] + function_list
|
|
||||||
else:
|
else:
|
||||||
function_list = [
|
self.cartesian_flag = True
|
||||||
lambda: self.moveit_joint_task(**config, joint_positions=joint_positions_)
|
|
||||||
] + function_list
|
|
||||||
|
|
||||||
for i in range(len(function_list)):
|
re = function_list[i]()
|
||||||
if i == 0:
|
if not re:
|
||||||
self.cartesian_flag = False
|
print(i, re)
|
||||||
else:
|
raise ValueError(f"Failed to execute moveit task: {i}")
|
||||||
self.cartesian_flag = True
|
|
||||||
|
|
||||||
re = function_list[i]()
|
|
||||||
if not re:
|
|
||||||
print(i, re)
|
|
||||||
result.success = False
|
|
||||||
return result
|
|
||||||
result.success = True
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
|
||||||
self.cartesian_flag = False
|
self.cartesian_flag = False
|
||||||
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)}")
|
||||||
|
|||||||
88
unilabos/devices/virtual/virtual_sample_demo.py
Normal file
88
unilabos/devices/virtual/virtual_sample_demo.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"""虚拟样品演示设备 — 用于前端 sample tracking 功能的极简 demo"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class VirtualSampleDemo:
|
||||||
|
"""虚拟样品追踪演示设备,提供两种典型返回模式:
|
||||||
|
- measure_samples: 等长输入输出 (前端按 index 自动对齐)
|
||||||
|
- split_and_measure: 输出比输入长,附带 samples 列标注归属
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
|
||||||
|
if device_id is None and "id" in kwargs:
|
||||||
|
device_id = kwargs.pop("id")
|
||||||
|
if config is None and "config" in kwargs:
|
||||||
|
config = kwargs.pop("config")
|
||||||
|
|
||||||
|
self.device_id = device_id or "unknown_sample_demo"
|
||||||
|
self.config = config or {}
|
||||||
|
self.logger = logging.getLogger(f"VirtualSampleDemo.{self.device_id}")
|
||||||
|
self.data: Dict[str, Any] = {"status": "Idle"}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Action 1: 等长输入输出,无 samples 列
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
async def measure_samples(self, concentrations: List[float]) -> Dict[str, Any]:
|
||||||
|
"""模拟光度测量。absorbance = concentration * 0.05 + noise
|
||||||
|
|
||||||
|
入参和出参 list 长度相等,前端按 index 自动对齐。
|
||||||
|
"""
|
||||||
|
self.logger.info(f"measure_samples: concentrations={concentrations}")
|
||||||
|
absorbance = [round(c * 0.05 + random.gauss(0, 0.005), 4) for c in concentrations]
|
||||||
|
return {"concentrations": concentrations, "absorbance": absorbance}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Action 2: 输出比输入长,带 samples 列
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
async def split_and_measure(self, volumes: List[float], split_count: int = 3) -> Dict[str, Any]:
|
||||||
|
"""将每个样品均分为 split_count 份后逐份测量。
|
||||||
|
|
||||||
|
返回的 list 长度 = len(volumes) * split_count,
|
||||||
|
附带 samples 列标注每行属于第几个输入样品 (0-based index)。
|
||||||
|
"""
|
||||||
|
self.logger.info(f"split_and_measure: volumes={volumes}, split_count={split_count}")
|
||||||
|
out_volumes: List[float] = []
|
||||||
|
readings: List[float] = []
|
||||||
|
samples: List[int] = []
|
||||||
|
|
||||||
|
for idx, vol in enumerate(volumes):
|
||||||
|
split_vol = round(vol / split_count, 2)
|
||||||
|
for _ in range(split_count):
|
||||||
|
out_volumes.append(split_vol)
|
||||||
|
readings.append(round(random.uniform(0.1, 1.0), 4))
|
||||||
|
samples.append(idx)
|
||||||
|
|
||||||
|
return {"volumes": out_volumes, "readings": readings, "unilabos_samples": samples}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Action 3: 入参和出参都带 samples 列(不等长)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
async def analyze_readings(self, readings: List[float], samples: List[int]) -> Dict[str, Any]:
|
||||||
|
"""对 split_and_measure 的输出做二次分析。
|
||||||
|
|
||||||
|
入参 readings/samples 长度相同但 > 原始样品数,
|
||||||
|
出参同样带 samples 列,长度与入参一致。
|
||||||
|
"""
|
||||||
|
self.logger.info(f"analyze_readings: readings={readings}, samples={samples}")
|
||||||
|
scores: List[float] = []
|
||||||
|
passed: List[bool] = []
|
||||||
|
threshold = 0.4
|
||||||
|
|
||||||
|
for r in readings:
|
||||||
|
score = round(r * 100 + random.gauss(0, 2), 2)
|
||||||
|
scores.append(score)
|
||||||
|
passed.append(r >= threshold)
|
||||||
|
|
||||||
|
return {"scores": scores, "passed": passed, "unilabos_samples": samples}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 状态属性
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@property
|
||||||
|
def status(self) -> str:
|
||||||
|
return self.data.get("status", "Idle")
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
@@ -31,14 +32,14 @@ class VirtualTransferPump:
|
|||||||
|
|
||||||
# 从config或kwargs中获取参数,确保类型正确
|
# 从config或kwargs中获取参数,确保类型正确
|
||||||
if config:
|
if config:
|
||||||
self.max_volume = float(config.get('max_volume', 25.0))
|
self.max_volume = float(config.get("max_volume", 25.0))
|
||||||
self.port = config.get('port', 'VIRTUAL')
|
self.port = config.get("port", "VIRTUAL")
|
||||||
else:
|
else:
|
||||||
self.max_volume = float(kwargs.get('max_volume', 25.0))
|
self.max_volume = float(kwargs.get("max_volume", 25.0))
|
||||||
self.port = kwargs.get('port', 'VIRTUAL')
|
self.port = kwargs.get("port", "VIRTUAL")
|
||||||
|
|
||||||
self._transfer_rate = float(kwargs.get('transfer_rate', 0))
|
self._transfer_rate = float(kwargs.get("transfer_rate", 0))
|
||||||
self.mode = kwargs.get('mode', VirtualPumpMode.Normal)
|
self.mode = kwargs.get("mode", VirtualPumpMode.Normal)
|
||||||
|
|
||||||
# 状态变量 - 确保都是正确类型
|
# 状态变量 - 确保都是正确类型
|
||||||
self._status = "Idle"
|
self._status = "Idle"
|
||||||
@@ -54,7 +55,9 @@ class VirtualTransferPump:
|
|||||||
self.logger = logging.getLogger(f"VirtualTransferPump.{self.device_id}")
|
self.logger = logging.getLogger(f"VirtualTransferPump.{self.device_id}")
|
||||||
|
|
||||||
print(f"🚰 === 虚拟转移泵 {self.device_id} 已创建 === ✨")
|
print(f"🚰 === 虚拟转移泵 {self.device_id} 已创建 === ✨")
|
||||||
print(f"💨 快速模式: {'启用' if self._fast_mode else '禁用'} | 移动时间: {self._fast_move_time}s | 喷射时间: {self._fast_dispense_time}s")
|
print(
|
||||||
|
f"💨 快速模式: {'启用' if self._fast_mode else '禁用'} | 移动时间: {self._fast_move_time}s | 喷射时间: {self._fast_dispense_time}s"
|
||||||
|
)
|
||||||
print(f"📊 最大容量: {self.max_volume}mL | 端口: {self.port}")
|
print(f"📊 最大容量: {self.max_volume}mL | 端口: {self.port}")
|
||||||
|
|
||||||
def post_init(self, ros_node: BaseROS2DeviceNode):
|
def post_init(self, ros_node: BaseROS2DeviceNode):
|
||||||
@@ -189,7 +192,9 @@ class VirtualTransferPump:
|
|||||||
operation_emoji = "📍"
|
operation_emoji = "📍"
|
||||||
|
|
||||||
self.logger.info(f"🎯 SET_POSITION: {operation_type} {operation_emoji}")
|
self.logger.info(f"🎯 SET_POSITION: {operation_type} {operation_emoji}")
|
||||||
self.logger.info(f" 📍 位置: {self._position:.2f}mL → {target_position:.2f}mL (移动 {volume_to_move:.2f}mL)")
|
self.logger.info(
|
||||||
|
f" 📍 位置: {self._position:.2f}mL → {target_position:.2f}mL (移动 {volume_to_move:.2f}mL)"
|
||||||
|
)
|
||||||
self.logger.info(f" 🌊 速度: {velocity:.2f} mL/s")
|
self.logger.info(f" 🌊 速度: {velocity:.2f} mL/s")
|
||||||
self.logger.info(f" ⏰ 预计时间: {display_duration:.2f}s")
|
self.logger.info(f" ⏰ 预计时间: {display_duration:.2f}s")
|
||||||
|
|
||||||
@@ -207,7 +212,11 @@ class VirtualTransferPump:
|
|||||||
for i in range(steps + 1):
|
for i in range(steps + 1):
|
||||||
# 计算当前位置和进度
|
# 计算当前位置和进度
|
||||||
progress = (i / steps) * 100 if steps > 0 else 100
|
progress = (i / steps) * 100 if steps > 0 else 100
|
||||||
current_pos = start_position + (target_position - start_position) * (i / steps) if steps > 0 else target_position
|
current_pos = (
|
||||||
|
start_position + (target_position - start_position) * (i / steps)
|
||||||
|
if steps > 0
|
||||||
|
else target_position
|
||||||
|
)
|
||||||
|
|
||||||
# 更新状态
|
# 更新状态
|
||||||
if i < steps:
|
if i < steps:
|
||||||
@@ -244,7 +253,9 @@ class VirtualTransferPump:
|
|||||||
|
|
||||||
# 📊 最终状态日志
|
# 📊 最终状态日志
|
||||||
if volume_to_move > 0.01:
|
if volume_to_move > 0.01:
|
||||||
self.logger.info(f"🎉 SET_POSITION 完成! 📍 最终位置: {self._position:.2f}mL | 💧 当前体积: {self._current_volume:.2f}mL")
|
self.logger.info(
|
||||||
|
f"🎉 SET_POSITION 完成! 📍 最终位置: {self._position:.2f}mL | 💧 当前体积: {self._current_volume:.2f}mL"
|
||||||
|
)
|
||||||
|
|
||||||
# 返回符合action定义的结果
|
# 返回符合action定义的结果
|
||||||
return {
|
return {
|
||||||
@@ -252,7 +263,7 @@ class VirtualTransferPump:
|
|||||||
"message": f"✅ 成功移动到位置 {self._position:.2f}mL ({operation_type})",
|
"message": f"✅ 成功移动到位置 {self._position:.2f}mL ({operation_type})",
|
||||||
"final_position": self._position,
|
"final_position": self._position,
|
||||||
"final_volume": self._current_volume,
|
"final_volume": self._current_volume,
|
||||||
"operation_type": operation_type
|
"operation_type": operation_type,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -262,7 +273,7 @@ class VirtualTransferPump:
|
|||||||
"success": False,
|
"success": False,
|
||||||
"message": error_msg,
|
"message": error_msg,
|
||||||
"final_position": self._position,
|
"final_position": self._position,
|
||||||
"final_volume": self._current_volume
|
"final_volume": self._current_volume,
|
||||||
}
|
}
|
||||||
|
|
||||||
# 其他泵操作方法
|
# 其他泵操作方法
|
||||||
@@ -375,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:
|
||||||
@@ -388,7 +401,9 @@ class VirtualTransferPump:
|
|||||||
return self._current_volume >= (self.max_volume - 0.01) # 允许小量误差
|
return self._current_volume >= (self.max_volume - 0.01) # 允许小量误差
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"VirtualTransferPump({self.device_id}: {self._current_volume:.2f}/{self.max_volume} ml, {self._status})"
|
return (
|
||||||
|
f"VirtualTransferPump({self.device_id}: {self._current_volume:.2f}/{self.max_volume} ml, {self._status})"
|
||||||
|
)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return self.__str__()
|
return self.__str__()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user