mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-03-24 16:59:15 +00:00
Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c001f6a151 | ||
|
|
145fcaae65 | ||
|
|
a79c0a88bf | ||
|
|
06b6f0d804 | ||
|
|
b551e69f64 | ||
|
|
5179a7e48e | ||
|
|
3a2d9e9603 | ||
|
|
a277bd2bed | ||
|
|
176de521b4 | ||
|
|
38c5c267af | ||
|
|
2a5ddd611d | ||
|
|
8580b84167 | ||
|
|
3f80349d7d | ||
|
|
024156848e | ||
|
|
8066c200b9 | ||
|
|
266366cc25 | ||
|
|
121c3985cc | ||
|
|
6ca5c72fc6 | ||
|
|
bc8c49ddda | ||
|
|
28f93737ac | ||
|
|
5dc81ec9be | ||
|
|
13a6795657 | ||
|
|
53219d8b04 | ||
|
|
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 |
@@ -1,160 +0,0 @@
|
|||||||
---
|
|
||||||
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` 标记的方法 → 排除
|
|
||||||
|
|
||||||
### @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 device, action, topic_config, not_action
|
|
||||||
|
|
||||||
@device(id="my_device", category=["my_category"], description="设备描述")
|
|
||||||
class MyDevice:
|
|
||||||
_ros_node: BaseROS2DeviceNode
|
|
||||||
|
|
||||||
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
|
|
||||||
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' 动作"""
|
|
||||||
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>/` 目录下
|
|
||||||
@@ -1,351 +0,0 @@
|
|||||||
---
|
|
||||||
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` |
|
|
||||||
@@ -1,292 +0,0 @@
|
|||||||
# 资源高级参考
|
|
||||||
|
|
||||||
本文件是 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` |
|
|
||||||
@@ -1,626 +0,0 @@
|
|||||||
---
|
|
||||||
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/` 目录下各工作站实现。
|
|
||||||
@@ -1,371 +0,0 @@
|
|||||||
# 工作站高级模式参考
|
|
||||||
|
|
||||||
本文件是 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)
|
|
||||||
```
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
---
|
|
||||||
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://uni-lab.test.bohrium.com` |
|
|
||||||
| `uat` | `https://uni-lab.uat.bohrium.com` |
|
|
||||||
| `local` | `http://127.0.0.1:48197` |
|
|
||||||
| 不传(默认) | `https://uni-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
|
|
||||||
```
|
|
||||||
|
|
||||||
### 执行与汇报
|
|
||||||
|
|
||||||
每次 API 调用后:
|
|
||||||
1. 检查返回 `code`(0 = 成功)
|
|
||||||
2. 记录成功/失败数量
|
|
||||||
3. 全部完成后汇总:「共录入 N 条试剂,成功 X 条,失败 Y 条」
|
|
||||||
4. 如有失败,列出失败的试剂名称和错误信息
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 常见试剂速查表
|
|
||||||
|
|
||||||
| 名称 | CAS | 分子式 | SMILES |
|
|
||||||
|------|-----|--------|--------|
|
|
||||||
| 水 | 7732-18-3 | H2O | O |
|
|
||||||
| 乙醇 | 64-17-5 | C2H6O | CCO |
|
|
||||||
| 甲醇 | 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"}
|
|
||||||
```
|
|
||||||
@@ -1,301 +0,0 @@
|
|||||||
---
|
|
||||||
name: batch-submit-experiment
|
|
||||||
description: Batch submit experiments (notebooks) to Uni-Lab platform — list workflows, generate node_params from registry schemas, submit multiple rounds. Use when the user wants to submit experiments, create notebooks, batch run workflows, or mentions 提交实验/批量实验/notebook/实验轮次.
|
|
||||||
---
|
|
||||||
|
|
||||||
# 批量提交实验指南
|
|
||||||
|
|
||||||
通过云端 API 批量提交实验(notebook),支持多轮实验参数配置。根据 workflow 模板详情和本地设备注册表自动生成 `node_params` 模板。
|
|
||||||
|
|
||||||
## 前置条件(缺一不可)
|
|
||||||
|
|
||||||
使用本指南前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
|
|
||||||
|
|
||||||
### 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://uni-lab.test.bohrium.com` |
|
|
||||||
| `uat` | `https://uni-lab.uat.bohrium.com` |
|
|
||||||
| `local` | `http://127.0.0.1:48197` |
|
|
||||||
| 不传(默认) | `https://uni-lab.bohrium.com` |
|
|
||||||
|
|
||||||
确认后设置:
|
|
||||||
```bash
|
|
||||||
BASE="<根据 addr 确定的 URL>"
|
|
||||||
AUTH="Authorization: Lab <上面命令输出的 token>"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. req_device_registry_upload.json(设备注册表)
|
|
||||||
|
|
||||||
**批量提交实验时需要本地注册表来解析 workflow 节点的参数 schema。**
|
|
||||||
|
|
||||||
按优先级搜索:
|
|
||||||
|
|
||||||
```
|
|
||||||
<workspace 根目录>/unilabos_data/req_device_registry_upload.json
|
|
||||||
<workspace 根目录>/req_device_registry_upload.json
|
|
||||||
```
|
|
||||||
|
|
||||||
也可直接 Glob 搜索:`**/req_device_registry_upload.json`
|
|
||||||
|
|
||||||
找到后**检查文件修改时间**并告知用户。超过 1 天提醒用户是否需要重新启动 `unilab`。
|
|
||||||
|
|
||||||
**如果文件不存在** → 告知用户先运行 `unilab` 启动命令,等注册表生成后再执行。可跳过此步,但将无法自动生成参数模板,需要用户手动填写 `param`。
|
|
||||||
|
|
||||||
### 4. workflow_uuid(目标工作流)
|
|
||||||
|
|
||||||
用户需要提供要提交的 workflow UUID。如果用户不确定,通过 API #2 列出可用 workflow 供选择。
|
|
||||||
|
|
||||||
**四项全部就绪后才可开始。**
|
|
||||||
|
|
||||||
## Session State
|
|
||||||
|
|
||||||
在整个对话过程中,agent 需要记住以下状态,避免重复询问用户:
|
|
||||||
|
|
||||||
- `lab_uuid` — 实验室 UUID(首次通过 API #1 自动获取,**不需要问用户**)
|
|
||||||
- `workflow_uuid` — 工作流 UUID(用户提供或从列表选择)
|
|
||||||
- `workflow_nodes` — workflow 中各 action 节点的 uuid、设备 ID、动作名(从 API #3 获取)
|
|
||||||
|
|
||||||
## 请求约定
|
|
||||||
|
|
||||||
所有请求使用 `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. 列出可用 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`。
|
|
||||||
|
|
||||||
### 3. 获取 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[]`。
|
|
||||||
|
|
||||||
### 4. 提交实验(创建 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>",
|
|
||||||
"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`),不是字符串。无样品时传空数组 `[]`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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 #3 获取) |
|
|
||||||
| `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 #3 获取 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: 确认 workflow_uuid(用户提供或从 GET #2 列表选择)
|
|
||||||
- [ ] Step 5: GET workflow detail (#3) → 提取各节点 uuid、设备ID、动作名
|
|
||||||
- [ ] Step 6: 定位本地注册表 req_device_registry_upload.json
|
|
||||||
- [ ] Step 7: 运行 gen_notebook_params.py 或手动匹配 → 生成 node_params 模板
|
|
||||||
- [ ] Step 8: 引导用户填写每轮的参数(sample_uuids、param、sample_params)
|
|
||||||
- [ ] Step 9: 构建完整请求体 → POST /lab/notebook 提交
|
|
||||||
- [ ] Step 10: 检查返回结果,确认提交成功
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
### 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`)中查找。
|
|
||||||
@@ -1,394 +0,0 @@
|
|||||||
#!/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://uni-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://uni-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",
|
|
||||||
"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()
|
|
||||||
@@ -2,6 +2,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import traceback
|
import traceback
|
||||||
import uuid
|
import uuid
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
import networkx as nx
|
import networkx as nx
|
||||||
@@ -24,15 +25,7 @@ class SimpleGraph:
|
|||||||
|
|
||||||
def add_edge(self, source, target, **attrs):
|
def add_edge(self, source, target, **attrs):
|
||||||
"""添加边"""
|
"""添加边"""
|
||||||
# edge = {"source": source, "target": target, **attrs}
|
edge = {"source": source, "target": target, **attrs}
|
||||||
edge = {
|
|
||||||
"source": source, "target": target,
|
|
||||||
"source_node_uuid": source,
|
|
||||||
"target_node_uuid": target,
|
|
||||||
"source_handle_io": "source",
|
|
||||||
"target_handle_io": "target",
|
|
||||||
**attrs
|
|
||||||
}
|
|
||||||
self.edges.append(edge)
|
self.edges.append(edge)
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
@@ -49,7 +42,6 @@ class SimpleGraph:
|
|||||||
"multigraph": False,
|
"multigraph": False,
|
||||||
"graph": {},
|
"graph": {},
|
||||||
"nodes": nodes_list,
|
"nodes": nodes_list,
|
||||||
"edges": self.edges,
|
|
||||||
"links": self.edges,
|
"links": self.edges,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,8 +58,495 @@ def extract_json_from_markdown(text: str) -> str:
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def convert_to_type(val: str) -> Any:
|
||||||
|
"""将字符串值转换为适当的数据类型"""
|
||||||
|
if val == "True":
|
||||||
|
return True
|
||||||
|
if val == "False":
|
||||||
|
return False
|
||||||
|
if val == "?":
|
||||||
|
return None
|
||||||
|
if val.endswith(" g"):
|
||||||
|
return float(val.split(" ")[0])
|
||||||
|
if val.endswith("mg"):
|
||||||
|
return float(val.split("mg")[0])
|
||||||
|
elif val.endswith("mmol"):
|
||||||
|
return float(val.split("mmol")[0]) / 1000
|
||||||
|
elif val.endswith("mol"):
|
||||||
|
return float(val.split("mol")[0])
|
||||||
|
elif val.endswith("ml"):
|
||||||
|
return float(val.split("ml")[0])
|
||||||
|
elif val.endswith("RPM"):
|
||||||
|
return float(val.split("RPM")[0])
|
||||||
|
elif val.endswith(" °C"):
|
||||||
|
return float(val.split(" ")[0])
|
||||||
|
elif val.endswith(" %"):
|
||||||
|
return float(val.split(" ")[0])
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
def refactor_data(data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||||
|
"""统一的数据重构函数,根据操作类型自动选择模板"""
|
||||||
|
refactored_data = []
|
||||||
|
|
||||||
|
# 定义操作映射,包含生物实验和有机化学的所有操作
|
||||||
|
OPERATION_MAPPING = {
|
||||||
|
# 生物实验操作
|
||||||
|
"transfer_liquid": "SynBioFactory-liquid_handler.prcxi-transfer_liquid",
|
||||||
|
"transfer": "SynBioFactory-liquid_handler.biomek-transfer",
|
||||||
|
"incubation": "SynBioFactory-liquid_handler.biomek-incubation",
|
||||||
|
"move_labware": "SynBioFactory-liquid_handler.biomek-move_labware",
|
||||||
|
"oscillation": "SynBioFactory-liquid_handler.biomek-oscillation",
|
||||||
|
# 有机化学操作
|
||||||
|
"HeatChillToTemp": "SynBioFactory-workstation-HeatChillProtocol",
|
||||||
|
"StopHeatChill": "SynBioFactory-workstation-HeatChillStopProtocol",
|
||||||
|
"StartHeatChill": "SynBioFactory-workstation-HeatChillStartProtocol",
|
||||||
|
"HeatChill": "SynBioFactory-workstation-HeatChillProtocol",
|
||||||
|
"Dissolve": "SynBioFactory-workstation-DissolveProtocol",
|
||||||
|
"Transfer": "SynBioFactory-workstation-TransferProtocol",
|
||||||
|
"Evaporate": "SynBioFactory-workstation-EvaporateProtocol",
|
||||||
|
"Recrystallize": "SynBioFactory-workstation-RecrystallizeProtocol",
|
||||||
|
"Filter": "SynBioFactory-workstation-FilterProtocol",
|
||||||
|
"Dry": "SynBioFactory-workstation-DryProtocol",
|
||||||
|
"Add": "SynBioFactory-workstation-AddProtocol",
|
||||||
|
}
|
||||||
|
|
||||||
|
UNSUPPORTED_OPERATIONS = ["Purge", "Wait", "Stir", "ResetHandling"]
|
||||||
|
|
||||||
|
for step in data:
|
||||||
|
operation = step.get("action")
|
||||||
|
if not operation or operation in UNSUPPORTED_OPERATIONS:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 处理重复操作
|
||||||
|
if operation == "Repeat":
|
||||||
|
times = step.get("times", step.get("parameters", {}).get("times", 1))
|
||||||
|
sub_steps = step.get("steps", step.get("parameters", {}).get("steps", []))
|
||||||
|
for i in range(int(times)):
|
||||||
|
sub_data = refactor_data(sub_steps)
|
||||||
|
refactored_data.extend(sub_data)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 获取模板名称
|
||||||
|
template = OPERATION_MAPPING.get(operation)
|
||||||
|
if not template:
|
||||||
|
# 自动推断模板类型
|
||||||
|
if operation.lower() in ["transfer", "incubation", "move_labware", "oscillation"]:
|
||||||
|
template = f"SynBioFactory-liquid_handler.biomek-{operation}"
|
||||||
|
else:
|
||||||
|
template = f"SynBioFactory-workstation-{operation}Protocol"
|
||||||
|
|
||||||
|
# 创建步骤数据
|
||||||
|
step_data = {
|
||||||
|
"template": template,
|
||||||
|
"description": step.get("description", step.get("purpose", f"{operation} operation")),
|
||||||
|
"lab_node_type": "Device",
|
||||||
|
"parameters": step.get("parameters", step.get("action_args", {})),
|
||||||
|
}
|
||||||
|
refactored_data.append(step_data)
|
||||||
|
|
||||||
|
return refactored_data
|
||||||
|
|
||||||
|
|
||||||
|
def build_protocol_graph(
|
||||||
|
labware_info: List[Dict[str, Any]], protocol_steps: List[Dict[str, Any]], workstation_name: str
|
||||||
|
) -> SimpleGraph:
|
||||||
|
"""统一的协议图构建函数,根据设备类型自动选择构建逻辑"""
|
||||||
|
G = SimpleGraph()
|
||||||
|
resource_last_writer = {}
|
||||||
|
LAB_NAME = "SynBioFactory"
|
||||||
|
|
||||||
|
protocol_steps = refactor_data(protocol_steps)
|
||||||
|
|
||||||
|
# 检查协议步骤中的模板来判断协议类型
|
||||||
|
has_biomek_template = any(
|
||||||
|
("biomek" in step.get("template", "")) or ("prcxi" in step.get("template", ""))
|
||||||
|
for step in protocol_steps
|
||||||
|
)
|
||||||
|
|
||||||
|
if has_biomek_template:
|
||||||
|
# 生物实验协议图构建
|
||||||
|
for labware_id, labware in labware_info.items():
|
||||||
|
node_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
labware_attrs = labware.copy()
|
||||||
|
labware_id = labware_attrs.pop("id", labware_attrs.get("name", f"labware_{uuid.uuid4()}"))
|
||||||
|
labware_attrs["description"] = labware_id
|
||||||
|
labware_attrs["lab_node_type"] = (
|
||||||
|
"Reagent" if "Plate" in str(labware_id) else "Labware" if "Rack" in str(labware_id) else "Sample"
|
||||||
|
)
|
||||||
|
labware_attrs["device_id"] = workstation_name
|
||||||
|
|
||||||
|
G.add_node(node_id, template=f"{LAB_NAME}-host_node-create_resource", **labware_attrs)
|
||||||
|
resource_last_writer[labware_id] = f"{node_id}:labware"
|
||||||
|
|
||||||
|
# 处理协议步骤
|
||||||
|
prev_node = None
|
||||||
|
for i, step in enumerate(protocol_steps):
|
||||||
|
node_id = str(uuid.uuid4())
|
||||||
|
G.add_node(node_id, **step)
|
||||||
|
|
||||||
|
# 添加控制流边
|
||||||
|
if prev_node is not None:
|
||||||
|
G.add_edge(prev_node, node_id, source_port="ready", target_port="ready")
|
||||||
|
prev_node = node_id
|
||||||
|
|
||||||
|
# 处理物料流
|
||||||
|
params = step.get("parameters", {})
|
||||||
|
if "sources" in params and params["sources"] in resource_last_writer:
|
||||||
|
source_node, source_port = resource_last_writer[params["sources"]].split(":")
|
||||||
|
G.add_edge(source_node, node_id, source_port=source_port, target_port="labware")
|
||||||
|
|
||||||
|
if "targets" in params:
|
||||||
|
resource_last_writer[params["targets"]] = f"{node_id}:labware"
|
||||||
|
|
||||||
|
# 添加协议结束节点
|
||||||
|
end_id = str(uuid.uuid4())
|
||||||
|
G.add_node(end_id, template=f"{LAB_NAME}-liquid_handler.biomek-run_protocol")
|
||||||
|
if prev_node is not None:
|
||||||
|
G.add_edge(prev_node, end_id, source_port="ready", target_port="ready")
|
||||||
|
|
||||||
|
else:
|
||||||
|
# 有机化学协议图构建
|
||||||
|
WORKSTATION_ID = workstation_name
|
||||||
|
|
||||||
|
# 为所有labware创建资源节点
|
||||||
|
for item_id, item in labware_info.items():
|
||||||
|
# item_id = item.get("id") or item.get("name", f"item_{uuid.uuid4()}")
|
||||||
|
node_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# 判断节点类型
|
||||||
|
if item.get("type") == "hardware" or "reactor" in str(item_id).lower():
|
||||||
|
if "reactor" not in str(item_id).lower():
|
||||||
|
continue
|
||||||
|
lab_node_type = "Sample"
|
||||||
|
description = f"Prepare Reactor: {item_id}"
|
||||||
|
liquid_type = []
|
||||||
|
liquid_volume = []
|
||||||
|
else:
|
||||||
|
lab_node_type = "Reagent"
|
||||||
|
description = f"Add Reagent to Flask: {item_id}"
|
||||||
|
liquid_type = [item_id]
|
||||||
|
liquid_volume = [1e5]
|
||||||
|
|
||||||
|
G.add_node(
|
||||||
|
node_id,
|
||||||
|
template=f"{LAB_NAME}-host_node-create_resource",
|
||||||
|
description=description,
|
||||||
|
lab_node_type=lab_node_type,
|
||||||
|
res_id=item_id,
|
||||||
|
device_id=WORKSTATION_ID,
|
||||||
|
class_name="container",
|
||||||
|
parent=WORKSTATION_ID,
|
||||||
|
bind_locations={"x": 0.0, "y": 0.0, "z": 0.0},
|
||||||
|
liquid_input_slot=[-1],
|
||||||
|
liquid_type=liquid_type,
|
||||||
|
liquid_volume=liquid_volume,
|
||||||
|
slot_on_deck="",
|
||||||
|
role=item.get("role", ""),
|
||||||
|
)
|
||||||
|
resource_last_writer[item_id] = f"{node_id}:labware"
|
||||||
|
|
||||||
|
last_control_node_id = None
|
||||||
|
|
||||||
|
# 处理协议步骤
|
||||||
|
for step in protocol_steps:
|
||||||
|
node_id = str(uuid.uuid4())
|
||||||
|
G.add_node(node_id, **step)
|
||||||
|
|
||||||
|
# 控制流
|
||||||
|
if last_control_node_id is not None:
|
||||||
|
G.add_edge(last_control_node_id, node_id, source_port="ready", target_port="ready")
|
||||||
|
last_control_node_id = node_id
|
||||||
|
|
||||||
|
# 物料流
|
||||||
|
params = step.get("parameters", {})
|
||||||
|
input_resources = {
|
||||||
|
"Vessel": params.get("vessel"),
|
||||||
|
"ToVessel": params.get("to_vessel"),
|
||||||
|
"FromVessel": params.get("from_vessel"),
|
||||||
|
"reagent": params.get("reagent"),
|
||||||
|
"solvent": params.get("solvent"),
|
||||||
|
"compound": params.get("compound"),
|
||||||
|
"sources": params.get("sources"),
|
||||||
|
"targets": params.get("targets"),
|
||||||
|
}
|
||||||
|
|
||||||
|
for target_port, resource_name in input_resources.items():
|
||||||
|
if resource_name and resource_name in resource_last_writer:
|
||||||
|
source_node, source_port = resource_last_writer[resource_name].split(":")
|
||||||
|
G.add_edge(source_node, node_id, source_port=source_port, target_port=target_port)
|
||||||
|
|
||||||
|
output_resources = {
|
||||||
|
"VesselOut": params.get("vessel"),
|
||||||
|
"FromVesselOut": params.get("from_vessel"),
|
||||||
|
"ToVesselOut": params.get("to_vessel"),
|
||||||
|
"FiltrateOut": params.get("filtrate_vessel"),
|
||||||
|
"reagent": params.get("reagent"),
|
||||||
|
"solvent": params.get("solvent"),
|
||||||
|
"compound": params.get("compound"),
|
||||||
|
"sources_out": params.get("sources"),
|
||||||
|
"targets_out": params.get("targets"),
|
||||||
|
}
|
||||||
|
|
||||||
|
for source_port, resource_name in output_resources.items():
|
||||||
|
if resource_name:
|
||||||
|
resource_last_writer[resource_name] = f"{node_id}:{source_port}"
|
||||||
|
|
||||||
|
return G
|
||||||
|
|
||||||
|
|
||||||
|
def draw_protocol_graph(protocol_graph: SimpleGraph, output_path: str):
|
||||||
|
"""
|
||||||
|
(辅助功能) 使用 networkx 和 matplotlib 绘制协议工作流图,用于可视化。
|
||||||
|
"""
|
||||||
|
if not protocol_graph:
|
||||||
|
print("Cannot draw graph: Graph object is empty.")
|
||||||
|
return
|
||||||
|
|
||||||
|
G = nx.DiGraph()
|
||||||
|
|
||||||
|
for node_id, attrs in protocol_graph.nodes.items():
|
||||||
|
label = attrs.get("description", attrs.get("template", node_id[:8]))
|
||||||
|
G.add_node(node_id, label=label, **attrs)
|
||||||
|
|
||||||
|
for edge in protocol_graph.edges:
|
||||||
|
G.add_edge(edge["source"], edge["target"])
|
||||||
|
|
||||||
|
plt.figure(figsize=(20, 15))
|
||||||
|
try:
|
||||||
|
pos = nx.nx_agraph.graphviz_layout(G, prog="dot")
|
||||||
|
except Exception:
|
||||||
|
pos = nx.shell_layout(G) # Fallback layout
|
||||||
|
|
||||||
|
node_labels = {node: data["label"] for node, data in G.nodes(data=True)}
|
||||||
|
nx.draw(
|
||||||
|
G,
|
||||||
|
pos,
|
||||||
|
with_labels=False,
|
||||||
|
node_size=2500,
|
||||||
|
node_color="skyblue",
|
||||||
|
node_shape="o",
|
||||||
|
edge_color="gray",
|
||||||
|
width=1.5,
|
||||||
|
arrowsize=15,
|
||||||
|
)
|
||||||
|
nx.draw_networkx_labels(G, pos, labels=node_labels, font_size=8, font_weight="bold")
|
||||||
|
|
||||||
|
plt.title("Chemical Protocol Workflow Graph", size=15)
|
||||||
|
plt.savefig(output_path, dpi=300, bbox_inches="tight")
|
||||||
|
plt.close()
|
||||||
|
print(f" - Visualization saved to '{output_path}'")
|
||||||
|
|
||||||
|
|
||||||
|
from networkx.drawing.nx_agraph import to_agraph
|
||||||
|
import re
|
||||||
|
|
||||||
|
COMPASS = {"n","e","s","w","ne","nw","se","sw","c"}
|
||||||
|
|
||||||
|
def _is_compass(port: str) -> bool:
|
||||||
|
return isinstance(port, str) and port.lower() in COMPASS
|
||||||
|
|
||||||
|
def draw_protocol_graph_with_ports(protocol_graph, output_path: str, rankdir: str = "LR"):
|
||||||
|
"""
|
||||||
|
使用 Graphviz 端口语法绘制协议工作流图。
|
||||||
|
- 若边上的 source_port/target_port 是 compass(n/e/s/w/...),直接用 compass。
|
||||||
|
- 否则自动为节点创建 record 形状并定义命名端口 <portname>。
|
||||||
|
最终由 PyGraphviz 渲染并输出到 output_path(后缀决定格式,如 .png/.svg/.pdf)。
|
||||||
|
"""
|
||||||
|
if not protocol_graph:
|
||||||
|
print("Cannot draw graph: Graph object is empty.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1) 先用 networkx 搭建有向图,保留端口属性
|
||||||
|
G = nx.DiGraph()
|
||||||
|
for node_id, attrs in protocol_graph.nodes.items():
|
||||||
|
label = attrs.get("description", attrs.get("template", node_id[:8]))
|
||||||
|
# 保留一个干净的“中心标签”,用于放在 record 的中间槽
|
||||||
|
G.add_node(node_id, _core_label=str(label), **{k:v for k,v in attrs.items() if k not in ("label",)})
|
||||||
|
|
||||||
|
edges_data = []
|
||||||
|
in_ports_by_node = {} # 收集命名输入端口
|
||||||
|
out_ports_by_node = {} # 收集命名输出端口
|
||||||
|
|
||||||
|
for edge in protocol_graph.edges:
|
||||||
|
u = edge["source"]
|
||||||
|
v = edge["target"]
|
||||||
|
sp = edge.get("source_port")
|
||||||
|
tp = edge.get("target_port")
|
||||||
|
|
||||||
|
# 记录到图里(保留原始端口信息)
|
||||||
|
G.add_edge(u, v, source_port=sp, target_port=tp)
|
||||||
|
edges_data.append((u, v, sp, tp))
|
||||||
|
|
||||||
|
# 如果不是 compass,就按“命名端口”先归类,等会儿给节点造 record
|
||||||
|
if sp and not _is_compass(sp):
|
||||||
|
out_ports_by_node.setdefault(u, set()).add(str(sp))
|
||||||
|
if tp and not _is_compass(tp):
|
||||||
|
in_ports_by_node.setdefault(v, set()).add(str(tp))
|
||||||
|
|
||||||
|
# 2) 转为 AGraph,使用 Graphviz 渲染
|
||||||
|
A = to_agraph(G)
|
||||||
|
A.graph_attr.update(rankdir=rankdir, splines="true", concentrate="false", fontsize="10")
|
||||||
|
A.node_attr.update(shape="box", style="rounded,filled", fillcolor="lightyellow", color="#999999", fontname="Helvetica")
|
||||||
|
A.edge_attr.update(arrowsize="0.8", color="#666666")
|
||||||
|
|
||||||
|
# 3) 为需要命名端口的节点设置 record 形状与 label
|
||||||
|
# 左列 = 输入端口;中间 = 核心标签;右列 = 输出端口
|
||||||
|
for n in A.nodes():
|
||||||
|
node = A.get_node(n)
|
||||||
|
core = G.nodes[n].get("_core_label", n)
|
||||||
|
|
||||||
|
in_ports = sorted(in_ports_by_node.get(n, []))
|
||||||
|
out_ports = sorted(out_ports_by_node.get(n, []))
|
||||||
|
|
||||||
|
# 如果该节点涉及命名端口,则用 record;否则保留原 box
|
||||||
|
if in_ports or out_ports:
|
||||||
|
def port_fields(ports):
|
||||||
|
if not ports:
|
||||||
|
return " " # 必须留一个空槽占位
|
||||||
|
# 每个端口一个小格子,<p> name
|
||||||
|
return "|".join(f"<{re.sub(r'[^A-Za-z0-9_:.|-]', '_', p)}> {p}" for p in ports)
|
||||||
|
|
||||||
|
left = port_fields(in_ports)
|
||||||
|
right = port_fields(out_ports)
|
||||||
|
|
||||||
|
# 三栏:左(入) | 中(节点名) | 右(出)
|
||||||
|
record_label = f"{{ {left} | {core} | {right} }}"
|
||||||
|
node.attr.update(shape="record", label=record_label)
|
||||||
|
else:
|
||||||
|
# 没有命名端口:普通盒子,显示核心标签
|
||||||
|
node.attr.update(label=str(core))
|
||||||
|
|
||||||
|
# 4) 给边设置 headport / tailport
|
||||||
|
# - 若端口为 compass:直接用 compass(e.g., headport="e")
|
||||||
|
# - 若端口为命名端口:使用在 record 中定义的 <port> 名(同名即可)
|
||||||
|
for (u, v, sp, tp) in edges_data:
|
||||||
|
e = A.get_edge(u, v)
|
||||||
|
|
||||||
|
# Graphviz 属性:tail 是源,head 是目标
|
||||||
|
if sp:
|
||||||
|
if _is_compass(sp):
|
||||||
|
e.attr["tailport"] = sp.lower()
|
||||||
|
else:
|
||||||
|
# 与 record label 中 <port> 名一致;特殊字符已在 label 中做了清洗
|
||||||
|
e.attr["tailport"] = re.sub(r'[^A-Za-z0-9_:.|-]', '_', str(sp))
|
||||||
|
|
||||||
|
if tp:
|
||||||
|
if _is_compass(tp):
|
||||||
|
e.attr["headport"] = tp.lower()
|
||||||
|
else:
|
||||||
|
e.attr["headport"] = re.sub(r'[^A-Za-z0-9_:.|-]', '_', str(tp))
|
||||||
|
|
||||||
|
# 可选:若想让边更贴边缘,可设置 constraint/spline 等
|
||||||
|
# e.attr["arrowhead"] = "vee"
|
||||||
|
|
||||||
|
# 5) 输出
|
||||||
|
A.draw(output_path, prog="dot")
|
||||||
|
print(f" - Port-aware workflow rendered to '{output_path}'")
|
||||||
|
|
||||||
|
|
||||||
|
def flatten_xdl_procedure(procedure_elem: ET.Element) -> List[ET.Element]:
|
||||||
|
"""展平嵌套的XDL程序结构"""
|
||||||
|
flattened_operations = []
|
||||||
|
TEMP_UNSUPPORTED_PROTOCOL = ["Purge", "Wait", "Stir", "ResetHandling"]
|
||||||
|
|
||||||
|
def extract_operations(element: ET.Element):
|
||||||
|
if element.tag not in ["Prep", "Reaction", "Workup", "Purification", "Procedure"]:
|
||||||
|
if element.tag not in TEMP_UNSUPPORTED_PROTOCOL:
|
||||||
|
flattened_operations.append(element)
|
||||||
|
|
||||||
|
for child in element:
|
||||||
|
extract_operations(child)
|
||||||
|
|
||||||
|
for child in procedure_elem:
|
||||||
|
extract_operations(child)
|
||||||
|
|
||||||
|
return flattened_operations
|
||||||
|
|
||||||
|
|
||||||
|
def parse_xdl_content(xdl_content: str) -> tuple:
|
||||||
|
"""解析XDL内容"""
|
||||||
|
try:
|
||||||
|
xdl_content_cleaned = "".join(c for c in xdl_content if c.isprintable())
|
||||||
|
root = ET.fromstring(xdl_content_cleaned)
|
||||||
|
|
||||||
|
synthesis_elem = root.find("Synthesis")
|
||||||
|
if synthesis_elem is None:
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
# 解析硬件组件
|
||||||
|
hardware_elem = synthesis_elem.find("Hardware")
|
||||||
|
hardware = []
|
||||||
|
if hardware_elem is not None:
|
||||||
|
hardware = [{"id": c.get("id"), "type": c.get("type")} for c in hardware_elem.findall("Component")]
|
||||||
|
|
||||||
|
# 解析试剂
|
||||||
|
reagents_elem = synthesis_elem.find("Reagents")
|
||||||
|
reagents = []
|
||||||
|
if reagents_elem is not None:
|
||||||
|
reagents = [{"name": r.get("name"), "role": r.get("role", "")} for r in reagents_elem.findall("Reagent")]
|
||||||
|
|
||||||
|
# 解析程序
|
||||||
|
procedure_elem = synthesis_elem.find("Procedure")
|
||||||
|
if procedure_elem is None:
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
flattened_operations = flatten_xdl_procedure(procedure_elem)
|
||||||
|
return hardware, reagents, flattened_operations
|
||||||
|
|
||||||
|
except ET.ParseError as e:
|
||||||
|
raise ValueError(f"Invalid XDL format: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def convert_xdl_to_dict(xdl_content: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
将XDL XML格式转换为标准的字典格式
|
||||||
|
|
||||||
|
Args:
|
||||||
|
xdl_content: XDL XML内容
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
转换结果,包含步骤和器材信息
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
hardware, reagents, flattened_operations = parse_xdl_content(xdl_content)
|
||||||
|
if hardware is None:
|
||||||
|
return {"error": "Failed to parse XDL content", "success": False}
|
||||||
|
|
||||||
|
# 将XDL元素转换为字典格式
|
||||||
|
steps_data = []
|
||||||
|
for elem in flattened_operations:
|
||||||
|
# 转换参数类型
|
||||||
|
parameters = {}
|
||||||
|
for key, val in elem.attrib.items():
|
||||||
|
converted_val = convert_to_type(val)
|
||||||
|
if converted_val is not None:
|
||||||
|
parameters[key] = converted_val
|
||||||
|
|
||||||
|
step_dict = {
|
||||||
|
"operation": elem.tag,
|
||||||
|
"parameters": parameters,
|
||||||
|
"description": elem.get("purpose", f"Operation: {elem.tag}"),
|
||||||
|
}
|
||||||
|
steps_data.append(step_dict)
|
||||||
|
|
||||||
|
# 合并硬件和试剂为统一的labware_info格式
|
||||||
|
labware_data = []
|
||||||
|
labware_data.extend({"id": hw["id"], "type": "hardware", **hw} for hw in hardware)
|
||||||
|
labware_data.extend({"name": reagent["name"], "type": "reagent", **reagent} for reagent in reagents)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"steps": steps_data,
|
||||||
|
"labware": labware_data,
|
||||||
|
"message": f"Successfully converted XDL to dict format. Found {len(steps_data)} steps and {len(labware_data)} labware items.",
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"XDL conversion failed: {str(e)}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return {"error": error_msg, "success": False}
|
||||||
|
|
||||||
|
|
||||||
def create_workflow(
|
def create_workflow(
|
||||||
|
|||||||
@@ -264,12 +264,6 @@ def parse_args():
|
|||||||
default=False,
|
default=False,
|
||||||
help="Test mode: all actions simulate execution and return mock results without running real hardware",
|
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(
|
parser.add_argument(
|
||||||
"--extra_resource",
|
"--extra_resource",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
@@ -348,18 +342,11 @@ def main():
|
|||||||
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, check_device_package_requirements
|
from unilabos.utils.environment_check import check_environment
|
||||||
|
|
||||||
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")
|
||||||
|
|
||||||
@@ -490,7 +477,19 @@ def main():
|
|||||||
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)
|
||||||
@@ -499,14 +498,12 @@ def main():
|
|||||||
# check_mode 和 upload_registry 都会执行实际 import 验证
|
# check_mode 和 upload_registry 都会执行实际 import 验证
|
||||||
devices_dirs = args_dict.get("devices", None)
|
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
|
||||||
external_only = args_dict.get("external_devices_only", False)
|
|
||||||
lab_registry = build_registry(
|
lab_registry = build_registry(
|
||||||
registry_paths=args_dict["registry_path"],
|
registry_paths=args_dict["registry_path"],
|
||||||
devices_dirs=devices_dirs,
|
devices_dirs=devices_dirs,
|
||||||
upload_registry=BasicConfig.upload_registry,
|
upload_registry=BasicConfig.upload_registry,
|
||||||
check_mode=check_mode,
|
check_mode=check_mode,
|
||||||
complete_registry=complete_registry,
|
complete_registry=complete_registry,
|
||||||
external_only=external_only,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check mode: 注册表验证完成后直接退出
|
# Check mode: 注册表验证完成后直接退出
|
||||||
@@ -516,20 +513,6 @@ def main():
|
|||||||
print_status(f"Check mode: 注册表验证完成 ({device_count} 设备, {resource_count} 资源),退出", "info")
|
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
|
# Step 1: 上传全部注册表到服务端,同步保存到 unilabos_data
|
||||||
if BasicConfig.upload_registry:
|
if BasicConfig.upload_registry:
|
||||||
if BasicConfig.ak and BasicConfig.sk:
|
if BasicConfig.ak and BasicConfig.sk:
|
||||||
@@ -627,10 +610,6 @@ def main():
|
|||||||
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
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
"""虚拟样品演示设备 — 用于前端 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")
|
|
||||||
@@ -139,7 +139,6 @@ def scan_directory(
|
|||||||
executor: ThreadPoolExecutor = None,
|
executor: ThreadPoolExecutor = None,
|
||||||
exclude_files: Optional[set] = None,
|
exclude_files: Optional[set] = None,
|
||||||
cache: Optional[Dict[str, Any]] = None,
|
cache: Optional[Dict[str, Any]] = None,
|
||||||
include_files: Optional[List[Union[str, Path]]] = None,
|
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Recursively scan .py files under *root_dir* for @device and @resource
|
Recursively scan .py files under *root_dir* for @device and @resource
|
||||||
@@ -165,7 +164,6 @@ def scan_directory(
|
|||||||
exclude_files: 要排除的文件名集合 (如 {"lab_resources.py"})
|
exclude_files: 要排除的文件名集合 (如 {"lab_resources.py"})
|
||||||
cache: Mutable cache dict (``load_scan_cache()`` result). Hits are read
|
cache: Mutable cache dict (``load_scan_cache()`` result). Hits are read
|
||||||
from here; misses are written back so the caller can persist later.
|
from here; misses are written back so the caller can persist later.
|
||||||
include_files: 指定扫描的文件列表,提供时跳过目录递归收集,直接扫描这些文件。
|
|
||||||
"""
|
"""
|
||||||
if executor is None:
|
if executor is None:
|
||||||
raise ValueError("executor is required and must not be None")
|
raise ValueError("executor is required and must not be None")
|
||||||
@@ -177,10 +175,7 @@ def scan_directory(
|
|||||||
python_path = Path(python_path).resolve()
|
python_path = Path(python_path).resolve()
|
||||||
|
|
||||||
# --- Collect files (depth/count limited) ---
|
# --- Collect files (depth/count limited) ---
|
||||||
if include_files is not None:
|
py_files = _collect_py_files(root_dir, max_depth=max_depth, max_files=max_files, exclude_files=exclude_files)
|
||||||
py_files = [Path(f).resolve() for f in include_files if Path(f).resolve().exists()]
|
|
||||||
else:
|
|
||||||
py_files = _collect_py_files(root_dir, max_depth=max_depth, max_files=max_files, exclude_files=exclude_files)
|
|
||||||
|
|
||||||
cache_files: Dict[str, Any] = cache.get("files", {}) if cache else {}
|
cache_files: Dict[str, Any] = cache.get("files", {}) if cache else {}
|
||||||
|
|
||||||
@@ -679,17 +674,14 @@ def _resolve_name(name: str, import_map: Dict[str, str]) -> str:
|
|||||||
return name
|
return name
|
||||||
|
|
||||||
|
|
||||||
_DECORATOR_ENUM_CLASSES = frozenset({"Side", "DataSource", "NodeType"})
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_attribute(node: ast.Attribute, import_map: Dict[str, str]) -> str:
|
def _resolve_attribute(node: ast.Attribute, import_map: Dict[str, str]) -> str:
|
||||||
"""
|
"""
|
||||||
Resolve an attribute access like Side.NORTH or DataSource.HANDLE.
|
Resolve an attribute access like Side.NORTH or DataSource.HANDLE.
|
||||||
|
|
||||||
对于来自 ``unilabos.registry.decorators`` 的枚举类 (Side / DataSource / NodeType),
|
Returns a string like "NORTH" for enum values, or
|
||||||
直接返回枚举成员名 (如 ``"NORTH"`` / ``"HANDLE"`` / ``"MANUAL_CONFIRM"``),
|
"module.path:Class.attr" for imported references.
|
||||||
省去消费端二次 rsplit 解析。其它 import 仍返回完整模块路径。
|
|
||||||
"""
|
"""
|
||||||
|
# Get the full dotted path
|
||||||
parts = []
|
parts = []
|
||||||
current = node
|
current = node
|
||||||
while isinstance(current, ast.Attribute):
|
while isinstance(current, ast.Attribute):
|
||||||
@@ -699,20 +691,21 @@ def _resolve_attribute(node: ast.Attribute, import_map: Dict[str, str]) -> str:
|
|||||||
parts.append(current.id)
|
parts.append(current.id)
|
||||||
|
|
||||||
parts.reverse()
|
parts.reverse()
|
||||||
# parts = ["Side", "NORTH"] or ["DataSource", "HANDLE"] or ["NodeType", "MANUAL_CONFIRM"]
|
# parts = ["Side", "NORTH"] or ["DataSource", "HANDLE"]
|
||||||
|
|
||||||
if len(parts) >= 2:
|
if len(parts) >= 2:
|
||||||
base = parts[0]
|
base = parts[0]
|
||||||
attr = ".".join(parts[1:])
|
attr = ".".join(parts[1:])
|
||||||
|
|
||||||
if base in _DECORATOR_ENUM_CLASSES:
|
# If the base is an imported name, resolve it
|
||||||
source = import_map.get(base, "")
|
|
||||||
if not source or _REGISTRY_DECORATOR_MODULE in source:
|
|
||||||
return parts[-1]
|
|
||||||
|
|
||||||
if base in import_map:
|
if base in import_map:
|
||||||
return f"{import_map[base]}.{attr}"
|
return f"{import_map[base]}.{attr}"
|
||||||
|
|
||||||
|
# For known enum-like patterns, return just the value
|
||||||
|
# e.g. Side.NORTH -> "NORTH"
|
||||||
|
if base in ("Side", "DataSource"):
|
||||||
|
return parts[-1]
|
||||||
|
|
||||||
return ".".join(parts)
|
return ".".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Usage:
|
|||||||
device, action, resource,
|
device, action, resource,
|
||||||
InputHandle, OutputHandle,
|
InputHandle, OutputHandle,
|
||||||
ActionInputHandle, ActionOutputHandle,
|
ActionInputHandle, ActionOutputHandle,
|
||||||
HardwareInterface, Side, DataSource, NodeType,
|
HardwareInterface, Side, DataSource,
|
||||||
)
|
)
|
||||||
|
|
||||||
@device(
|
@device(
|
||||||
@@ -73,13 +73,6 @@ class DataSource(str, Enum):
|
|||||||
EXECUTOR = "executor" # 从执行器输出数据 (用于 OutputHandle)
|
EXECUTOR = "executor" # 从执行器输出数据 (用于 OutputHandle)
|
||||||
|
|
||||||
|
|
||||||
class NodeType(str, Enum):
|
|
||||||
"""动作的节点类型(用于区分 ILab 节点和人工确认节点等)"""
|
|
||||||
|
|
||||||
ILAB = "ILab"
|
|
||||||
MANUAL_CONFIRM = "manual_confirm"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Device / Resource Handle (设备/资源级别端口, 序列化时包含 io_type)
|
# Device / Resource Handle (设备/资源级别端口, 序列化时包含 io_type)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -342,7 +335,6 @@ def action(
|
|||||||
description: str = "",
|
description: str = "",
|
||||||
auto_prefix: bool = False,
|
auto_prefix: bool = False,
|
||||||
parent: bool = False,
|
parent: bool = False,
|
||||||
node_type: Optional["NodeType"] = None,
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
动作方法装饰器
|
动作方法装饰器
|
||||||
@@ -373,8 +365,6 @@ def action(
|
|||||||
description: 动作描述
|
description: 动作描述
|
||||||
auto_prefix: 若为 True,动作名使用 auto-{method_name} 形式(与无 @action 时一致)
|
auto_prefix: 若为 True,动作名使用 auto-{method_name} 形式(与无 @action 时一致)
|
||||||
parent: 若为 True,当方法参数为空 (*args, **kwargs) 时,通过 MRO 从父类获取真实方法参数
|
parent: 若为 True,当方法参数为空 (*args, **kwargs) 时,通过 MRO 从父类获取真实方法参数
|
||||||
node_type: 动作的节点类型 (NodeType.ILAB / NodeType.MANUAL_CONFIRM)。
|
|
||||||
不填写时不写入注册表。
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def decorator(func: F) -> F:
|
def decorator(func: F) -> F:
|
||||||
@@ -399,8 +389,6 @@ def action(
|
|||||||
"auto_prefix": auto_prefix,
|
"auto_prefix": auto_prefix,
|
||||||
"parent": parent,
|
"parent": parent,
|
||||||
}
|
}
|
||||||
if node_type is not None:
|
|
||||||
meta["node_type"] = node_type.value if isinstance(node_type, NodeType) else str(node_type)
|
|
||||||
wrapper._action_registry_meta = meta # type: ignore[attr-defined]
|
wrapper._action_registry_meta = meta # type: ignore[attr-defined]
|
||||||
|
|
||||||
# 设置 _is_always_free 保持与旧 @always_free 装饰器兼容
|
# 设置 _is_always_free 保持与旧 @always_free 装饰器兼容
|
||||||
@@ -527,38 +515,6 @@ def clear_registry():
|
|||||||
_registered_resources.clear()
|
_registered_resources.clear()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 枚举值归一化
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_enum_value(raw: Any, enum_cls) -> Optional[str]:
|
|
||||||
"""将 AST 提取的枚举成员名 / YAML 值字符串 / 旧格式长路径统一归一化为枚举值。
|
|
||||||
|
|
||||||
适用于 Side、DataSource、NodeType 等继承自 ``str, Enum`` 的装饰器枚举。
|
|
||||||
|
|
||||||
处理以下格式:
|
|
||||||
- "MANUAL_CONFIRM" → NodeType["MANUAL_CONFIRM"].value = "manual_confirm"
|
|
||||||
- "manual_confirm" → NodeType("manual_confirm").value = "manual_confirm"
|
|
||||||
- "HANDLE" → DataSource["HANDLE"].value = "handle"
|
|
||||||
- "NORTH" → Side["NORTH"].value = "NORTH"
|
|
||||||
- 旧缓存长路径 "unilabos...NodeType.MANUAL_CONFIRM" → 先 rsplit 再查找
|
|
||||||
"""
|
|
||||||
if not raw:
|
|
||||||
return None
|
|
||||||
raw_str = str(raw)
|
|
||||||
if "." in raw_str:
|
|
||||||
raw_str = raw_str.rsplit(".", 1)[-1]
|
|
||||||
try:
|
|
||||||
return enum_cls[raw_str].value
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
return enum_cls(raw_str).value
|
|
||||||
except ValueError:
|
|
||||||
return raw_str
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# topic_config / not_action / always_free 装饰器
|
# topic_config / not_action / always_free 装饰器
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -2804,203 +2804,6 @@ virtual_rotavap:
|
|||||||
- vacuum_pressure
|
- vacuum_pressure
|
||||||
type: object
|
type: object
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
virtual_sample_demo:
|
|
||||||
category:
|
|
||||||
- virtual_device
|
|
||||||
class:
|
|
||||||
action_value_mappings:
|
|
||||||
analyze_readings:
|
|
||||||
feedback: {}
|
|
||||||
goal:
|
|
||||||
readings: readings
|
|
||||||
samples: samples
|
|
||||||
goal_default:
|
|
||||||
readings: null
|
|
||||||
samples: null
|
|
||||||
handles:
|
|
||||||
input:
|
|
||||||
- data_key: readings
|
|
||||||
data_source: handle
|
|
||||||
data_type: sample_list
|
|
||||||
handler_key: readings_in
|
|
||||||
label: 测量读数
|
|
||||||
- data_key: samples
|
|
||||||
data_source: handle
|
|
||||||
data_type: sample_index
|
|
||||||
handler_key: samples_in
|
|
||||||
label: 样品索引
|
|
||||||
output:
|
|
||||||
- data_key: scores
|
|
||||||
data_source: executor
|
|
||||||
data_type: sample_list
|
|
||||||
handler_key: scores_out
|
|
||||||
label: 分析得分
|
|
||||||
- data_key: passed
|
|
||||||
data_source: executor
|
|
||||||
data_type: sample_list
|
|
||||||
handler_key: passed_out
|
|
||||||
label: 是否通过
|
|
||||||
- data_key: samples
|
|
||||||
data_source: executor
|
|
||||||
data_type: sample_index
|
|
||||||
handler_key: samples_result_out
|
|
||||||
label: 样品索引
|
|
||||||
placeholder_keys: {}
|
|
||||||
result: {}
|
|
||||||
schema:
|
|
||||||
description: 对 split_and_measure 输出做二次分析,入参和出参都带 samples 列
|
|
||||||
properties:
|
|
||||||
feedback:
|
|
||||||
title: AnalyzeReadings_Feedback
|
|
||||||
goal:
|
|
||||||
properties:
|
|
||||||
readings:
|
|
||||||
description: 测量读数(来自 split_and_measure)
|
|
||||||
items:
|
|
||||||
type: number
|
|
||||||
type: array
|
|
||||||
samples:
|
|
||||||
description: 每行归属的输入样品 index (0-based)
|
|
||||||
items:
|
|
||||||
type: integer
|
|
||||||
type: array
|
|
||||||
required:
|
|
||||||
- readings
|
|
||||||
- samples
|
|
||||||
title: AnalyzeReadings_Goal
|
|
||||||
type: object
|
|
||||||
result:
|
|
||||||
title: AnalyzeReadings_Result
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- goal
|
|
||||||
title: analyze_readings参数
|
|
||||||
type: object
|
|
||||||
type: UniLabJsonCommandAsync
|
|
||||||
measure_samples:
|
|
||||||
feedback: {}
|
|
||||||
goal:
|
|
||||||
concentrations: concentrations
|
|
||||||
goal_default:
|
|
||||||
concentrations: null
|
|
||||||
handles:
|
|
||||||
output:
|
|
||||||
- data_key: concentrations
|
|
||||||
data_source: executor
|
|
||||||
data_type: sample_list
|
|
||||||
handler_key: concentrations_out
|
|
||||||
label: 浓度列表
|
|
||||||
- data_key: absorbance
|
|
||||||
data_source: executor
|
|
||||||
data_type: sample_list
|
|
||||||
handler_key: absorbance_out
|
|
||||||
label: 吸光度列表
|
|
||||||
placeholder_keys: {}
|
|
||||||
result: {}
|
|
||||||
schema:
|
|
||||||
description: 模拟光度测量,入参出参等长
|
|
||||||
properties:
|
|
||||||
feedback:
|
|
||||||
title: MeasureSamples_Feedback
|
|
||||||
goal:
|
|
||||||
properties:
|
|
||||||
concentrations:
|
|
||||||
description: 样品浓度列表
|
|
||||||
items:
|
|
||||||
type: number
|
|
||||||
type: array
|
|
||||||
required:
|
|
||||||
- concentrations
|
|
||||||
title: MeasureSamples_Goal
|
|
||||||
type: object
|
|
||||||
result:
|
|
||||||
title: MeasureSamples_Result
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- goal
|
|
||||||
title: measure_samples参数
|
|
||||||
type: object
|
|
||||||
type: UniLabJsonCommandAsync
|
|
||||||
split_and_measure:
|
|
||||||
feedback: {}
|
|
||||||
goal:
|
|
||||||
split_count: split_count
|
|
||||||
volumes: volumes
|
|
||||||
goal_default:
|
|
||||||
split_count: 3
|
|
||||||
volumes: null
|
|
||||||
handles:
|
|
||||||
output:
|
|
||||||
- data_key: readings
|
|
||||||
data_source: executor
|
|
||||||
data_type: sample_list
|
|
||||||
handler_key: readings_out
|
|
||||||
label: 测量读数
|
|
||||||
- data_key: samples
|
|
||||||
data_source: executor
|
|
||||||
data_type: sample_index
|
|
||||||
handler_key: samples_out
|
|
||||||
label: 样品索引
|
|
||||||
- data_key: volumes
|
|
||||||
data_source: executor
|
|
||||||
data_type: sample_list
|
|
||||||
handler_key: volumes_out
|
|
||||||
label: 均分体积
|
|
||||||
placeholder_keys: {}
|
|
||||||
result: {}
|
|
||||||
schema:
|
|
||||||
description: 均分样品后逐份测量,输出带 samples 列标注归属
|
|
||||||
properties:
|
|
||||||
feedback:
|
|
||||||
title: SplitAndMeasure_Feedback
|
|
||||||
goal:
|
|
||||||
properties:
|
|
||||||
split_count:
|
|
||||||
default: 3
|
|
||||||
description: 每个样品均分的份数
|
|
||||||
type: integer
|
|
||||||
volumes:
|
|
||||||
description: 样品体积列表
|
|
||||||
items:
|
|
||||||
type: number
|
|
||||||
type: array
|
|
||||||
required:
|
|
||||||
- volumes
|
|
||||||
title: SplitAndMeasure_Goal
|
|
||||||
type: object
|
|
||||||
result:
|
|
||||||
title: SplitAndMeasure_Result
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- goal
|
|
||||||
title: split_and_measure参数
|
|
||||||
type: object
|
|
||||||
type: UniLabJsonCommandAsync
|
|
||||||
module: unilabos.devices.virtual.virtual_sample_demo:VirtualSampleDemo
|
|
||||||
status_types:
|
|
||||||
status: str
|
|
||||||
type: python
|
|
||||||
config_info: []
|
|
||||||
description: Virtual sample tracking demo device
|
|
||||||
handles: []
|
|
||||||
icon: ''
|
|
||||||
init_param_schema:
|
|
||||||
config:
|
|
||||||
properties:
|
|
||||||
config:
|
|
||||||
type: object
|
|
||||||
device_id:
|
|
||||||
type: string
|
|
||||||
required: []
|
|
||||||
type: object
|
|
||||||
data:
|
|
||||||
properties:
|
|
||||||
status:
|
|
||||||
type: string
|
|
||||||
required:
|
|
||||||
- status
|
|
||||||
type: object
|
|
||||||
version: 1.0.0
|
|
||||||
virtual_separator:
|
virtual_separator:
|
||||||
category:
|
category:
|
||||||
- virtual_device
|
- virtual_device
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ from unilabos.registry.decorators import (
|
|||||||
is_not_action,
|
is_not_action,
|
||||||
is_always_free,
|
is_always_free,
|
||||||
get_topic_config,
|
get_topic_config,
|
||||||
NodeType,
|
|
||||||
normalize_enum_value,
|
|
||||||
)
|
)
|
||||||
from unilabos.registry.utils import (
|
from unilabos.registry.utils import (
|
||||||
ROSMsgNotFound,
|
ROSMsgNotFound,
|
||||||
@@ -114,7 +112,7 @@ class Registry:
|
|||||||
# 统一入口
|
# 统一入口
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def setup(self, devices_dirs=None, upload_registry=False, complete_registry=False, external_only=False):
|
def setup(self, devices_dirs=None, upload_registry=False, complete_registry=False):
|
||||||
"""统一构建注册表入口。"""
|
"""统一构建注册表入口。"""
|
||||||
if self._setup_called:
|
if self._setup_called:
|
||||||
logger.critical("[UniLab Registry] setup方法已被调用过,不允许多次调用")
|
logger.critical("[UniLab Registry] setup方法已被调用过,不允许多次调用")
|
||||||
@@ -125,27 +123,24 @@ class Registry:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 1. AST 静态扫描 (快速, 无需 import)
|
# 1. AST 静态扫描 (快速, 无需 import)
|
||||||
self._run_ast_scan(devices_dirs, upload_registry=upload_registry, external_only=external_only)
|
self._run_ast_scan(devices_dirs, upload_registry=upload_registry)
|
||||||
|
|
||||||
# 2. Host node 内置设备
|
# 2. Host node 内置设备
|
||||||
self._setup_host_node()
|
self._setup_host_node()
|
||||||
|
|
||||||
# 3. YAML 注册表加载 (兼容旧格式) — external_only 模式下跳过
|
# 3. YAML 注册表加载 (兼容旧格式)
|
||||||
if external_only:
|
self.registry_paths = [Path(path).absolute() for path in self.registry_paths]
|
||||||
logger.info("[UniLab Registry] external_only 模式: 跳过 YAML 注册表加载")
|
for i, path in enumerate(self.registry_paths):
|
||||||
else:
|
sys_path = path.parent
|
||||||
self.registry_paths = [Path(path).absolute() for path in self.registry_paths]
|
logger.trace(f"[UniLab Registry] Path {i+1}/{len(self.registry_paths)}: {sys_path}")
|
||||||
for i, path in enumerate(self.registry_paths):
|
sys.path.append(str(sys_path))
|
||||||
sys_path = path.parent
|
self.load_device_types(path, complete_registry=complete_registry)
|
||||||
logger.trace(f"[UniLab Registry] Path {i+1}/{len(self.registry_paths)}: {sys_path}")
|
if BasicConfig.enable_resource_load:
|
||||||
sys.path.append(str(sys_path))
|
self.load_resource_types(path, upload_registry, complete_registry=complete_registry)
|
||||||
self.load_device_types(path, complete_registry=complete_registry)
|
else:
|
||||||
if BasicConfig.enable_resource_load:
|
logger.warning(
|
||||||
self.load_resource_types(path, upload_registry, complete_registry=complete_registry)
|
"[UniLab Registry] 资源加载已禁用 (enable_resource_load=False),跳过资源注册表加载"
|
||||||
else:
|
)
|
||||||
logger.warning(
|
|
||||||
"[UniLab Registry] 资源加载已禁用 (enable_resource_load=False),跳过资源注册表加载"
|
|
||||||
)
|
|
||||||
self._startup_executor.shutdown(wait=True)
|
self._startup_executor.shutdown(wait=True)
|
||||||
self._startup_executor = None
|
self._startup_executor = None
|
||||||
self._setup_called = True
|
self._setup_called = True
|
||||||
@@ -161,10 +156,9 @@ class Registry:
|
|||||||
ast_entry = self.device_type_registry.get("host_node", {})
|
ast_entry = self.device_type_registry.get("host_node", {})
|
||||||
ast_actions = ast_entry.get("class", {}).get("action_value_mappings", {})
|
ast_actions = ast_entry.get("class", {}).get("action_value_mappings", {})
|
||||||
|
|
||||||
# 取出 AST 生成的 action entries, 补充特定覆写
|
# 取出 AST 生成的 auto-method entries, 补充特定覆写
|
||||||
test_latency_action = ast_actions.get("auto-test_latency", {})
|
test_latency_action = ast_actions.get("auto-test_latency", {})
|
||||||
test_resource_action = ast_actions.get("auto-test_resource", {})
|
test_resource_action = ast_actions.get("auto-test_resource", {})
|
||||||
manual_confirm_action = ast_actions.get("manual_confirm", {})
|
|
||||||
test_resource_action["handles"] = {
|
test_resource_action["handles"] = {
|
||||||
"input": [
|
"input": [
|
||||||
{
|
{
|
||||||
@@ -240,7 +234,6 @@ class Registry:
|
|||||||
},
|
},
|
||||||
"test_latency": test_latency_action,
|
"test_latency": test_latency_action,
|
||||||
"auto-test_resource": test_resource_action,
|
"auto-test_resource": test_resource_action,
|
||||||
"manual_confirm": manual_confirm_action,
|
|
||||||
},
|
},
|
||||||
"init_params": {},
|
"init_params": {},
|
||||||
},
|
},
|
||||||
@@ -260,7 +253,7 @@ class Registry:
|
|||||||
# AST 静态扫描
|
# AST 静态扫描
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _run_ast_scan(self, devices_dirs=None, upload_registry=False, external_only=False):
|
def _run_ast_scan(self, devices_dirs=None, upload_registry=False):
|
||||||
"""
|
"""
|
||||||
执行 AST 静态扫描,从 Python 代码中提取 @device / @resource 装饰器元数据。
|
执行 AST 静态扫描,从 Python 代码中提取 @device / @resource 装饰器元数据。
|
||||||
无需 import 任何驱动模块,速度极快。
|
无需 import 任何驱动模块,速度极快。
|
||||||
@@ -305,30 +298,16 @@ class Registry:
|
|||||||
extra_dirs.append(d_path)
|
extra_dirs.append(d_path)
|
||||||
|
|
||||||
# 主扫描
|
# 主扫描
|
||||||
if external_only:
|
exclude_files = {"lab_resources.py"} if not BasicConfig.extra_resource else None
|
||||||
core_files = [
|
scan_result = scan_directory(
|
||||||
pkg_root / "ros" / "nodes" / "presets" / "host_node.py",
|
scan_root, python_path=python_path, executor=self._startup_executor,
|
||||||
pkg_root / "resources" / "container.py",
|
exclude_files=exclude_files, cache=ast_cache,
|
||||||
]
|
)
|
||||||
scan_result = scan_directory(
|
if exclude_files:
|
||||||
scan_root, python_path=python_path, executor=self._startup_executor,
|
|
||||||
cache=ast_cache, include_files=core_files,
|
|
||||||
)
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[UniLab Registry] external_only 模式: 仅扫描核心文件 "
|
f"[UniLab Registry] 排除扫描文件: {exclude_files} "
|
||||||
f"({', '.join(f.name for f in core_files)})"
|
f"(可通过 --extra_resource 启用加载)"
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
exclude_files = {"lab_resources.py"} if not BasicConfig.extra_resource else None
|
|
||||||
scan_result = scan_directory(
|
|
||||||
scan_root, python_path=python_path, executor=self._startup_executor,
|
|
||||||
exclude_files=exclude_files, cache=ast_cache,
|
|
||||||
)
|
|
||||||
if exclude_files:
|
|
||||||
logger.info(
|
|
||||||
f"[UniLab Registry] 排除扫描文件: {exclude_files} "
|
|
||||||
f"(可通过 --extra_resource 启用加载)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 合并缓存统计
|
# 合并缓存统计
|
||||||
total_stats = scan_result.pop("_cache_stats", {"hits": 0, "misses": 0, "total": 0})
|
total_stats = scan_result.pop("_cache_stats", {"hits": 0, "misses": 0, "total": 0})
|
||||||
@@ -851,9 +830,6 @@ class Registry:
|
|||||||
}
|
}
|
||||||
if (action_args or {}).get("always_free") or method_info.get("always_free"):
|
if (action_args or {}).get("always_free") or method_info.get("always_free"):
|
||||||
entry["always_free"] = True
|
entry["always_free"] = True
|
||||||
nt = normalize_enum_value((action_args or {}).get("node_type"), NodeType)
|
|
||||||
if nt:
|
|
||||||
entry["node_type"] = nt
|
|
||||||
return action_name, entry
|
return action_name, entry
|
||||||
|
|
||||||
# 1) auto- actions
|
# 1) auto- actions
|
||||||
@@ -978,9 +954,6 @@ class Registry:
|
|||||||
}
|
}
|
||||||
if action_args.get("always_free") or method_info.get("always_free"):
|
if action_args.get("always_free") or method_info.get("always_free"):
|
||||||
action_entry["always_free"] = True
|
action_entry["always_free"] = True
|
||||||
nt = normalize_enum_value(action_args.get("node_type"), NodeType)
|
|
||||||
if nt:
|
|
||||||
action_entry["node_type"] = nt
|
|
||||||
action_value_mappings[action_name] = action_entry
|
action_value_mappings[action_name] = action_entry
|
||||||
|
|
||||||
action_value_mappings = dict(sorted(action_value_mappings.items()))
|
action_value_mappings = dict(sorted(action_value_mappings.items()))
|
||||||
@@ -1163,7 +1136,7 @@ class Registry:
|
|||||||
return Path(BasicConfig.working_dir) / "registry_cache.pkl"
|
return Path(BasicConfig.working_dir) / "registry_cache.pkl"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
_CACHE_VERSION = 4
|
_CACHE_VERSION = 3
|
||||||
|
|
||||||
def _load_config_cache(self) -> dict:
|
def _load_config_cache(self) -> dict:
|
||||||
import pickle
|
import pickle
|
||||||
@@ -1561,9 +1534,9 @@ class Registry:
|
|||||||
del resource_info["config_info"]
|
del resource_info["config_info"]
|
||||||
if "file_path" in resource_info:
|
if "file_path" in resource_info:
|
||||||
del resource_info["file_path"]
|
del resource_info["file_path"]
|
||||||
|
complete_data[resource_id] = copy.deepcopy(dict(sorted(resource_info.items())))
|
||||||
resource_info["registry_type"] = "resource"
|
resource_info["registry_type"] = "resource"
|
||||||
resource_info["file_path"] = str(file.absolute()).replace("\\", "/")
|
resource_info["file_path"] = str(file.absolute()).replace("\\", "/")
|
||||||
complete_data[resource_id] = copy.deepcopy(dict(sorted(resource_info.items())))
|
|
||||||
|
|
||||||
for rid in skip_ids:
|
for rid in skip_ids:
|
||||||
data.pop(rid, None)
|
data.pop(rid, None)
|
||||||
@@ -1888,9 +1861,6 @@ class Registry:
|
|||||||
}
|
}
|
||||||
if v.get("always_free"):
|
if v.get("always_free"):
|
||||||
entry["always_free"] = True
|
entry["always_free"] = True
|
||||||
old_node_type = old_cfg.get("node_type")
|
|
||||||
if old_node_type in [NodeType.ILAB.value, NodeType.MANUAL_CONFIRM.value]:
|
|
||||||
entry["node_type"] = old_node_type
|
|
||||||
device_config["class"]["action_value_mappings"][action_key] = entry
|
device_config["class"]["action_value_mappings"][action_key] = entry
|
||||||
|
|
||||||
device_config["init_param_schema"] = {}
|
device_config["init_param_schema"] = {}
|
||||||
@@ -2205,7 +2175,7 @@ class Registry:
|
|||||||
lab_registry = Registry()
|
lab_registry = Registry()
|
||||||
|
|
||||||
|
|
||||||
def build_registry(registry_paths=None, devices_dirs=None, upload_registry=False, check_mode=False, complete_registry=False, external_only=False):
|
def build_registry(registry_paths=None, devices_dirs=None, upload_registry=False, check_mode=False, complete_registry=False):
|
||||||
"""
|
"""
|
||||||
构建或获取Registry单例实例
|
构建或获取Registry单例实例
|
||||||
"""
|
"""
|
||||||
@@ -2219,7 +2189,7 @@ def build_registry(registry_paths=None, devices_dirs=None, upload_registry=False
|
|||||||
if path not in current_paths:
|
if path not in current_paths:
|
||||||
lab_registry.registry_paths.append(path)
|
lab_registry.registry_paths.append(path)
|
||||||
|
|
||||||
lab_registry.setup(devices_dirs=devices_dirs, upload_registry=upload_registry, complete_registry=complete_registry, external_only=external_only)
|
lab_registry.setup(devices_dirs=devices_dirs, upload_registry=upload_registry, complete_registry=complete_registry)
|
||||||
|
|
||||||
# 将 AST 扫描的字符串类型替换为实际 ROS2 消息类(仅查找 ROS2 类型,不 import 设备模块)
|
# 将 AST 扫描的字符串类型替换为实际 ROS2 消息类(仅查找 ROS2 类型,不 import 设备模块)
|
||||||
lab_registry.resolve_all_types()
|
lab_registry.resolve_all_types()
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ from typing import Any, Dict, List, Optional, Tuple, Union
|
|||||||
from msgcenterpy.instances.typed_dict_instance import TypedDictMessageInstance
|
from msgcenterpy.instances.typed_dict_instance import TypedDictMessageInstance
|
||||||
|
|
||||||
from unilabos.utils.cls_creator import import_class
|
from unilabos.utils.cls_creator import import_class
|
||||||
from unilabos.registry.decorators import Side, DataSource, normalize_enum_value
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -488,7 +487,10 @@ def normalize_ast_handles(handles_raw: Any) -> List[Dict[str, Any]]:
|
|||||||
}
|
}
|
||||||
side = h.get("side")
|
side = h.get("side")
|
||||||
if side:
|
if side:
|
||||||
entry["side"] = normalize_enum_value(side, Side) or side
|
if isinstance(side, str) and "." in side:
|
||||||
|
val = side.rsplit(".", 1)[-1]
|
||||||
|
side = val.lower() if val in ("LEFT", "RIGHT", "TOP", "BOTTOM") else val
|
||||||
|
entry["side"] = side
|
||||||
label = h.get("label")
|
label = h.get("label")
|
||||||
if label:
|
if label:
|
||||||
entry["label"] = label
|
entry["label"] = label
|
||||||
@@ -497,7 +499,10 @@ def normalize_ast_handles(handles_raw: Any) -> List[Dict[str, Any]]:
|
|||||||
entry["data_key"] = data_key
|
entry["data_key"] = data_key
|
||||||
data_source = h.get("data_source")
|
data_source = h.get("data_source")
|
||||||
if data_source:
|
if data_source:
|
||||||
entry["data_source"] = normalize_enum_value(data_source, DataSource) or data_source
|
if isinstance(data_source, str) and "." in data_source:
|
||||||
|
val = data_source.rsplit(".", 1)[-1]
|
||||||
|
data_source = val.lower() if val in ("HANDLE", "EXECUTOR") else val
|
||||||
|
entry["data_source"] = data_source
|
||||||
description = h.get("description")
|
description = h.get("description")
|
||||||
if description:
|
if description:
|
||||||
entry["description"] = description
|
entry["description"] = description
|
||||||
@@ -532,12 +537,17 @@ def normalize_ast_action_handles(handles_raw: Any) -> Dict[str, Any]:
|
|||||||
"data_type": h.get("data_type", ""),
|
"data_type": h.get("data_type", ""),
|
||||||
"label": h.get("label", ""),
|
"label": h.get("label", ""),
|
||||||
}
|
}
|
||||||
_FIELD_ENUM_MAP = {"side": Side, "data_source": DataSource}
|
|
||||||
for opt_key in ("side", "data_key", "data_source", "description", "io_type"):
|
for opt_key in ("side", "data_key", "data_source", "description", "io_type"):
|
||||||
val = h.get(opt_key)
|
val = h.get(opt_key)
|
||||||
if val is not None:
|
if val is not None:
|
||||||
if opt_key in _FIELD_ENUM_MAP:
|
# Only resolve enum-style refs (e.g. DataSource.HANDLE -> handle) for data_source/side
|
||||||
val = normalize_enum_value(val, _FIELD_ENUM_MAP[opt_key]) or val
|
# data_key values like "wells.@flatten", "@this.0@@@plate" must be preserved as-is
|
||||||
|
if (
|
||||||
|
isinstance(val, str)
|
||||||
|
and "." in val
|
||||||
|
and opt_key not in ("io_type", "data_key")
|
||||||
|
):
|
||||||
|
val = val.rsplit(".", 1)[-1].lower()
|
||||||
entry[opt_key] = val
|
entry[opt_key] = val
|
||||||
|
|
||||||
# io_type: only add when explicitly set; do not default output to "sink" (YAML convention omits it)
|
# io_type: only add when explicitly set; do not default output to "sink" (YAML convention omits it)
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ def canonicalize_nodes_data(
|
|||||||
Returns:
|
Returns:
|
||||||
ResourceTreeSet: 标准化后的资源树集合
|
ResourceTreeSet: 标准化后的资源树集合
|
||||||
"""
|
"""
|
||||||
print_status(f"{len(nodes)} Resources loaded", "info")
|
print_status(f"{len(nodes)} Resources loaded:", "info")
|
||||||
|
|
||||||
# 第一步:基本预处理(处理graphml的label字段)
|
# 第一步:基本预处理(处理graphml的label字段)
|
||||||
outer_host_node_id = None
|
outer_host_node_id = None
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from unilabos_msgs.srv import (
|
|||||||
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response
|
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response
|
||||||
from unique_identifier_msgs.msg import UUID
|
from unique_identifier_msgs.msg import UUID
|
||||||
|
|
||||||
from unilabos.registry.decorators import device, action, NodeType
|
from unilabos.registry.decorators import device
|
||||||
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
|
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
|
||||||
from unilabos.registry.registry import lab_registry
|
from unilabos.registry.registry import lab_registry
|
||||||
from unilabos.resources.container import RegularContainer
|
from unilabos.resources.container import RegularContainer
|
||||||
@@ -1621,10 +1621,6 @@ class HostNode(BaseROS2DeviceNode):
|
|||||||
}
|
}
|
||||||
return res
|
return res
|
||||||
|
|
||||||
@action(always_free=True, node_type=NodeType.MANUAL_CONFIRM)
|
|
||||||
def manual_confirm(self, **kwargs) -> dict:
|
|
||||||
return kwargs
|
|
||||||
|
|
||||||
def test_resource(
|
def test_resource(
|
||||||
self,
|
self,
|
||||||
sample_uuids: SampleUUIDsType,
|
sample_uuids: SampleUUIDsType,
|
||||||
|
|||||||
@@ -6,180 +6,20 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import importlib
|
import importlib
|
||||||
import locale
|
import locale
|
||||||
import shutil
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from unilabos.utils.banner_print import print_status
|
from unilabos.utils.banner_print import print_status
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 底层安装工具
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _is_chinese_locale() -> bool:
|
|
||||||
try:
|
|
||||||
lang = locale.getdefaultlocale()[0]
|
|
||||||
return bool(lang and ("zh" in lang.lower() or "chinese" in lang.lower()))
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
_USE_UV: Optional[bool] = None
|
|
||||||
|
|
||||||
|
|
||||||
def _has_uv() -> bool:
|
|
||||||
global _USE_UV
|
|
||||||
if _USE_UV is None:
|
|
||||||
_USE_UV = shutil.which("uv") is not None
|
|
||||||
return _USE_UV
|
|
||||||
|
|
||||||
|
|
||||||
def _install_packages(
|
|
||||||
packages: List[str],
|
|
||||||
upgrade: bool = False,
|
|
||||||
label: str = "",
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
安装/升级一组包。优先 uv pip install,回退 sys pip。
|
|
||||||
逐个安装,任意一个失败不影响后续包。
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if all succeeded, False otherwise.
|
|
||||||
"""
|
|
||||||
if not packages:
|
|
||||||
return True
|
|
||||||
|
|
||||||
is_chinese = _is_chinese_locale()
|
|
||||||
use_uv = _has_uv()
|
|
||||||
failed: List[str] = []
|
|
||||||
|
|
||||||
for pkg in packages:
|
|
||||||
action_word = "升级" if upgrade else "安装"
|
|
||||||
if label:
|
|
||||||
print_status(f"[{label}] 正在{action_word} {pkg}...", "info")
|
|
||||||
else:
|
|
||||||
print_status(f"正在{action_word} {pkg}...", "info")
|
|
||||||
|
|
||||||
if use_uv:
|
|
||||||
cmd = ["uv", "pip", "install"]
|
|
||||||
if upgrade:
|
|
||||||
cmd.append("--upgrade")
|
|
||||||
cmd.append(pkg)
|
|
||||||
if is_chinese:
|
|
||||||
cmd.extend(["--index-url", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"])
|
|
||||||
else:
|
|
||||||
cmd = [sys.executable, "-m", "pip", "install"]
|
|
||||||
if upgrade:
|
|
||||||
cmd.append("--upgrade")
|
|
||||||
cmd.append(pkg)
|
|
||||||
if is_chinese:
|
|
||||||
cmd.extend(["-i", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"])
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
|
||||||
if result.returncode == 0:
|
|
||||||
installer = "uv" if use_uv else "pip"
|
|
||||||
print_status(f"✓ {pkg} {action_word}成功 (via {installer})", "success")
|
|
||||||
else:
|
|
||||||
stderr_short = result.stderr.strip().split("\n")[-1] if result.stderr else "unknown error"
|
|
||||||
print_status(f"× {pkg} {action_word}失败: {stderr_short}", "error")
|
|
||||||
failed.append(pkg)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
print_status(f"× {pkg} {action_word}超时 (300s)", "error")
|
|
||||||
failed.append(pkg)
|
|
||||||
except Exception as e:
|
|
||||||
print_status(f"× {pkg} {action_word}异常: {e}", "error")
|
|
||||||
failed.append(pkg)
|
|
||||||
|
|
||||||
if failed:
|
|
||||||
print_status(f"有 {len(failed)} 个包操作失败: {', '.join(failed)}", "error")
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# requirements.txt 安装(可多次调用)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def install_requirements_txt(req_path: str | Path, label: str = "") -> bool:
|
|
||||||
"""
|
|
||||||
读取一个 requirements.txt 文件,检查缺失的包并安装。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
req_path: requirements.txt 文件路径
|
|
||||||
label: 日志前缀标签(如 "device_package_sim")
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if all ok, False if any install failed.
|
|
||||||
"""
|
|
||||||
req_path = Path(req_path)
|
|
||||||
if not req_path.exists():
|
|
||||||
return True
|
|
||||||
|
|
||||||
tag = label or req_path.parent.name
|
|
||||||
print_status(f"[{tag}] 检查依赖: {req_path}", "info")
|
|
||||||
|
|
||||||
reqs: List[str] = []
|
|
||||||
with open(req_path, "r", encoding="utf-8") as f:
|
|
||||||
for line in f:
|
|
||||||
line = line.strip()
|
|
||||||
if line and not line.startswith("#") and not line.startswith("-"):
|
|
||||||
reqs.append(line)
|
|
||||||
|
|
||||||
if not reqs:
|
|
||||||
return True
|
|
||||||
|
|
||||||
missing: List[str] = []
|
|
||||||
for req in reqs:
|
|
||||||
pkg_import = req.split(">=")[0].split("==")[0].split("<")[0].split("[")[0].split(">")[0].strip()
|
|
||||||
pkg_import = pkg_import.replace("-", "_")
|
|
||||||
try:
|
|
||||||
importlib.import_module(pkg_import)
|
|
||||||
except ImportError:
|
|
||||||
missing.append(req)
|
|
||||||
|
|
||||||
if not missing:
|
|
||||||
print_status(f"[{tag}] ✓ 依赖检查通过 ({len(reqs)} 个包)", "success")
|
|
||||||
return True
|
|
||||||
|
|
||||||
print_status(f"[{tag}] 缺失 {len(missing)} 个依赖: {', '.join(missing)}", "warning")
|
|
||||||
return _install_packages(missing, label=tag)
|
|
||||||
|
|
||||||
|
|
||||||
def check_device_package_requirements(devices_dirs: list[str]) -> bool:
|
|
||||||
"""
|
|
||||||
检查 --devices 指定的所有外部设备包目录中的 requirements.txt。
|
|
||||||
对每个目录查找 requirements.txt(先在目录内找,再在父目录找)。
|
|
||||||
"""
|
|
||||||
if not devices_dirs:
|
|
||||||
return True
|
|
||||||
|
|
||||||
all_ok = True
|
|
||||||
for d in devices_dirs:
|
|
||||||
d_path = Path(d).resolve()
|
|
||||||
req_file = d_path / "requirements.txt"
|
|
||||||
if not req_file.exists():
|
|
||||||
req_file = d_path.parent / "requirements.txt"
|
|
||||||
if not req_file.exists():
|
|
||||||
continue
|
|
||||||
if not install_requirements_txt(req_file, label=d_path.name):
|
|
||||||
all_ok = False
|
|
||||||
|
|
||||||
return all_ok
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# UniLabOS 核心环境检查
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class EnvironmentChecker:
|
class EnvironmentChecker:
|
||||||
"""环境检查器"""
|
"""环境检查器"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
# 定义必需的包及其安装名称的映射
|
||||||
self.required_packages = {
|
self.required_packages = {
|
||||||
|
# 包导入名 : pip安装名
|
||||||
|
# "pymodbus.framer.FramerType": "pymodbus==3.9.2",
|
||||||
"websockets": "websockets",
|
"websockets": "websockets",
|
||||||
"msgcenterpy": "msgcenterpy",
|
"msgcenterpy": "msgcenterpy",
|
||||||
"orjson": "orjson",
|
"orjson": "orjson",
|
||||||
@@ -188,17 +28,33 @@ class EnvironmentChecker:
|
|||||||
"crcmod": "crcmod-plus",
|
"crcmod": "crcmod-plus",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 特殊安装包(需要特殊处理的包)
|
||||||
self.special_packages = {"pylabrobot": "git+https://github.com/Xuwznln/pylabrobot.git"}
|
self.special_packages = {"pylabrobot": "git+https://github.com/Xuwznln/pylabrobot.git"}
|
||||||
|
|
||||||
|
# 包版本要求(包名: 最低版本)
|
||||||
self.version_requirements = {
|
self.version_requirements = {
|
||||||
"msgcenterpy": "0.1.8",
|
"msgcenterpy": "0.1.8", # msgcenterpy 最低版本要求
|
||||||
}
|
}
|
||||||
|
|
||||||
self.missing_packages: List[tuple] = []
|
self.missing_packages = []
|
||||||
self.failed_installs: List[tuple] = []
|
self.failed_installs = []
|
||||||
self.packages_need_upgrade: List[tuple] = []
|
self.packages_need_upgrade = []
|
||||||
|
|
||||||
|
# 检测系统语言
|
||||||
|
self.is_chinese = self._is_chinese_locale()
|
||||||
|
|
||||||
|
def _is_chinese_locale(self) -> bool:
|
||||||
|
"""检测系统是否为中文环境"""
|
||||||
|
try:
|
||||||
|
lang = locale.getdefaultlocale()[0]
|
||||||
|
if lang and ("zh" in lang.lower() or "chinese" in lang.lower()):
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
def check_package_installed(self, package_name: str) -> bool:
|
def check_package_installed(self, package_name: str) -> bool:
|
||||||
|
"""检查包是否已安装"""
|
||||||
try:
|
try:
|
||||||
importlib.import_module(package_name)
|
importlib.import_module(package_name)
|
||||||
return True
|
return True
|
||||||
@@ -206,6 +62,7 @@ class EnvironmentChecker:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def get_package_version(self, package_name: str) -> str | None:
|
def get_package_version(self, package_name: str) -> str | None:
|
||||||
|
"""获取已安装包的版本"""
|
||||||
try:
|
try:
|
||||||
module = importlib.import_module(package_name)
|
module = importlib.import_module(package_name)
|
||||||
return getattr(module, "__version__", None)
|
return getattr(module, "__version__", None)
|
||||||
@@ -213,32 +70,88 @@ class EnvironmentChecker:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def compare_version(self, current: str, required: str) -> bool:
|
def compare_version(self, current: str, required: str) -> bool:
|
||||||
|
"""
|
||||||
|
比较版本号
|
||||||
|
Returns:
|
||||||
|
True: current >= required
|
||||||
|
False: current < required
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
current_parts = [int(x) for x in current.split(".")]
|
current_parts = [int(x) for x in current.split(".")]
|
||||||
required_parts = [int(x) for x in required.split(".")]
|
required_parts = [int(x) for x in required.split(".")]
|
||||||
|
|
||||||
|
# 补齐长度
|
||||||
max_len = max(len(current_parts), len(required_parts))
|
max_len = max(len(current_parts), len(required_parts))
|
||||||
current_parts.extend([0] * (max_len - len(current_parts)))
|
current_parts.extend([0] * (max_len - len(current_parts)))
|
||||||
required_parts.extend([0] * (max_len - len(required_parts)))
|
required_parts.extend([0] * (max_len - len(required_parts)))
|
||||||
|
|
||||||
return current_parts >= required_parts
|
return current_parts >= required_parts
|
||||||
except Exception:
|
except Exception:
|
||||||
return True
|
return True # 如果无法比较,假设版本满足要求
|
||||||
|
|
||||||
|
def install_package(self, package_name: str, pip_name: str, upgrade: bool = False) -> bool:
|
||||||
|
"""安装包"""
|
||||||
|
try:
|
||||||
|
action = "升级" if upgrade else "安装"
|
||||||
|
print_status(f"正在{action} {package_name} ({pip_name})...", "info")
|
||||||
|
|
||||||
|
# 构建安装命令
|
||||||
|
cmd = [sys.executable, "-m", "pip", "install"]
|
||||||
|
|
||||||
|
# 如果是升级操作,添加 --upgrade 参数
|
||||||
|
if upgrade:
|
||||||
|
cmd.append("--upgrade")
|
||||||
|
|
||||||
|
cmd.append(pip_name)
|
||||||
|
|
||||||
|
# 如果是中文环境,使用清华镜像源
|
||||||
|
if self.is_chinese:
|
||||||
|
cmd.extend(["-i", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"])
|
||||||
|
|
||||||
|
# 执行安装
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) # 5分钟超时
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
print_status(f"✓ {package_name} {action}成功", "success")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print_status(f"× {package_name} {action}失败: {result.stderr}", "error")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
print_status(f"× {package_name} {action}超时", "error")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print_status(f"× {package_name} {action}异常: {str(e)}", "error")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def upgrade_package(self, package_name: str, pip_name: str) -> bool:
|
||||||
|
"""升级包"""
|
||||||
|
return self.install_package(package_name, pip_name, upgrade=True)
|
||||||
|
|
||||||
def check_all_packages(self) -> bool:
|
def check_all_packages(self) -> bool:
|
||||||
|
"""检查所有必需的包"""
|
||||||
print_status("开始检查环境依赖...", "info")
|
print_status("开始检查环境依赖...", "info")
|
||||||
|
|
||||||
|
# 检查常规包
|
||||||
for import_name, pip_name in self.required_packages.items():
|
for import_name, pip_name in self.required_packages.items():
|
||||||
if not self.check_package_installed(import_name):
|
if not self.check_package_installed(import_name):
|
||||||
self.missing_packages.append((import_name, pip_name))
|
self.missing_packages.append((import_name, pip_name))
|
||||||
elif import_name in self.version_requirements:
|
else:
|
||||||
current_version = self.get_package_version(import_name)
|
# 检查版本要求
|
||||||
required_version = self.version_requirements[import_name]
|
if import_name in self.version_requirements:
|
||||||
if current_version and not self.compare_version(current_version, required_version):
|
current_version = self.get_package_version(import_name)
|
||||||
print_status(
|
required_version = self.version_requirements[import_name]
|
||||||
f"{import_name} 版本过低 (当前: {current_version}, 需要: >={required_version})",
|
|
||||||
"warning",
|
|
||||||
)
|
|
||||||
self.packages_need_upgrade.append((import_name, pip_name))
|
|
||||||
|
|
||||||
|
if current_version:
|
||||||
|
if not self.compare_version(current_version, required_version):
|
||||||
|
print_status(
|
||||||
|
f"{import_name} 版本过低 (当前: {current_version}, 需要: >={required_version})",
|
||||||
|
"warning",
|
||||||
|
)
|
||||||
|
self.packages_need_upgrade.append((import_name, pip_name))
|
||||||
|
|
||||||
|
# 检查特殊包
|
||||||
for package_name, install_url in self.special_packages.items():
|
for package_name, install_url in self.special_packages.items():
|
||||||
if not self.check_package_installed(package_name):
|
if not self.check_package_installed(package_name):
|
||||||
self.missing_packages.append((package_name, install_url))
|
self.missing_packages.append((package_name, install_url))
|
||||||
@@ -257,6 +170,7 @@ class EnvironmentChecker:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def install_missing_packages(self, auto_install: bool = True) -> bool:
|
def install_missing_packages(self, auto_install: bool = True) -> bool:
|
||||||
|
"""安装缺失的包"""
|
||||||
if not self.missing_packages and not self.packages_need_upgrade:
|
if not self.missing_packages and not self.packages_need_upgrade:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -264,36 +178,62 @@ class EnvironmentChecker:
|
|||||||
if self.missing_packages:
|
if self.missing_packages:
|
||||||
print_status("缺失以下包:", "warning")
|
print_status("缺失以下包:", "warning")
|
||||||
for import_name, pip_name in self.missing_packages:
|
for import_name, pip_name in self.missing_packages:
|
||||||
print_status(f" - {import_name} ({pip_name})", "warning")
|
print_status(f" - {import_name} (pip install {pip_name})", "warning")
|
||||||
if self.packages_need_upgrade:
|
if self.packages_need_upgrade:
|
||||||
print_status("需要升级以下包:", "warning")
|
print_status("需要升级以下包:", "warning")
|
||||||
for import_name, pip_name in self.packages_need_upgrade:
|
for import_name, pip_name in self.packages_need_upgrade:
|
||||||
print_status(f" - {import_name} ({pip_name})", "warning")
|
print_status(f" - {import_name} (pip install --upgrade {pip_name})", "warning")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# 安装缺失的包
|
||||||
if self.missing_packages:
|
if self.missing_packages:
|
||||||
pkgs = [pip_name for _, pip_name in self.missing_packages]
|
print_status(f"开始自动安装 {len(self.missing_packages)} 个缺失的包...", "info")
|
||||||
if not _install_packages(pkgs, label="unilabos"):
|
|
||||||
self.failed_installs.extend(self.missing_packages)
|
|
||||||
|
|
||||||
|
success_count = 0
|
||||||
|
for import_name, pip_name in self.missing_packages:
|
||||||
|
if self.install_package(import_name, pip_name):
|
||||||
|
success_count += 1
|
||||||
|
else:
|
||||||
|
self.failed_installs.append((import_name, pip_name))
|
||||||
|
|
||||||
|
print_status(f"✓ 成功安装 {success_count}/{len(self.missing_packages)} 个包", "success")
|
||||||
|
|
||||||
|
# 升级需要更新的包
|
||||||
if self.packages_need_upgrade:
|
if self.packages_need_upgrade:
|
||||||
pkgs = [pip_name for _, pip_name in self.packages_need_upgrade]
|
print_status(f"开始自动升级 {len(self.packages_need_upgrade)} 个包...", "info")
|
||||||
if not _install_packages(pkgs, upgrade=True, label="unilabos"):
|
|
||||||
self.failed_installs.extend(self.packages_need_upgrade)
|
|
||||||
|
|
||||||
return not self.failed_installs
|
upgrade_success_count = 0
|
||||||
|
for import_name, pip_name in self.packages_need_upgrade:
|
||||||
|
if self.upgrade_package(import_name, pip_name):
|
||||||
|
upgrade_success_count += 1
|
||||||
|
else:
|
||||||
|
self.failed_installs.append((import_name, pip_name))
|
||||||
|
|
||||||
|
print_status(f"✓ 成功升级 {upgrade_success_count}/{len(self.packages_need_upgrade)} 个包", "success")
|
||||||
|
|
||||||
|
if self.failed_installs:
|
||||||
|
print_status(f"有 {len(self.failed_installs)} 个包操作失败:", "error")
|
||||||
|
for import_name, pip_name in self.failed_installs:
|
||||||
|
print_status(f" - {import_name} ({pip_name})", "error")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
def verify_installation(self) -> bool:
|
def verify_installation(self) -> bool:
|
||||||
|
"""验证安装结果"""
|
||||||
if not self.missing_packages and not self.packages_need_upgrade:
|
if not self.missing_packages and not self.packages_need_upgrade:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
print_status("验证安装结果...", "info")
|
print_status("验证安装结果...", "info")
|
||||||
|
|
||||||
failed_verification = []
|
failed_verification = []
|
||||||
|
|
||||||
|
# 验证新安装的包
|
||||||
for import_name, pip_name in self.missing_packages:
|
for import_name, pip_name in self.missing_packages:
|
||||||
if not self.check_package_installed(import_name):
|
if not self.check_package_installed(import_name):
|
||||||
failed_verification.append((import_name, pip_name))
|
failed_verification.append((import_name, pip_name))
|
||||||
|
|
||||||
|
# 验证升级的包
|
||||||
for import_name, pip_name in self.packages_need_upgrade:
|
for import_name, pip_name in self.packages_need_upgrade:
|
||||||
if not self.check_package_installed(import_name):
|
if not self.check_package_installed(import_name):
|
||||||
failed_verification.append((import_name, pip_name))
|
failed_verification.append((import_name, pip_name))
|
||||||
@@ -330,14 +270,17 @@ def check_environment(auto_install: bool = True, show_details: bool = True) -> b
|
|||||||
"""
|
"""
|
||||||
checker = EnvironmentChecker()
|
checker = EnvironmentChecker()
|
||||||
|
|
||||||
|
# 检查包
|
||||||
if checker.check_all_packages():
|
if checker.check_all_packages():
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
# 安装缺失的包
|
||||||
if not checker.install_missing_packages(auto_install):
|
if not checker.install_missing_packages(auto_install):
|
||||||
if show_details:
|
if show_details:
|
||||||
print_status("请手动安装缺失的包后重新启动程序", "error")
|
print_status("请手动安装缺失的包后重新启动程序", "error")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# 验证安装
|
||||||
if not checker.verify_installation():
|
if not checker.verify_installation():
|
||||||
if show_details:
|
if show_details:
|
||||||
print_status("安装验证失败,请检查网络连接或手动安装", "error")
|
print_status("安装验证失败,请检查网络连接或手动安装", "error")
|
||||||
@@ -347,12 +290,14 @@ def check_environment(auto_install: bool = True, show_details: bool = True) -> b
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
# 命令行参数解析
|
||||||
parser = argparse.ArgumentParser(description="UniLabOS 环境依赖检查工具")
|
parser = argparse.ArgumentParser(description="UniLabOS 环境依赖检查工具")
|
||||||
parser.add_argument("--no-auto-install", action="store_true", help="仅检查环境,不自动安装缺失的包")
|
parser.add_argument("--no-auto-install", action="store_true", help="仅检查环境,不自动安装缺失的包")
|
||||||
parser.add_argument("--silent", action="store_true", help="静默模式,不显示详细信息")
|
parser.add_argument("--silent", action="store_true", help="静默模式,不显示详细信息")
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# 执行环境检查
|
||||||
auto_install = not args.no_auto_install
|
auto_install = not args.no_auto_install
|
||||||
show_details = not args.silent
|
show_details = not args.silent
|
||||||
|
|
||||||
|
|||||||
@@ -80,12 +80,11 @@ def get_result_info_str(error: str, suc: bool, return_value=None) -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
JSON字符串格式的结果信息
|
JSON字符串格式的结果信息
|
||||||
"""
|
"""
|
||||||
# 请在返回的字典中使用 unilabos_samples进行返回
|
samples = None
|
||||||
# samples = None
|
if isinstance(return_value, dict):
|
||||||
# if isinstance(return_value, dict):
|
if "samples" in return_value:
|
||||||
# if "samples" in return_value and type(return_value["samples"]) in [list, tuple] and type(return_value["samples"][0]) == dict:
|
samples = return_value.pop("samples")
|
||||||
# samples = return_value.pop("samples")
|
result_info = {"error": error, "suc": suc, "return_value": return_value, "samples": samples}
|
||||||
result_info = {"error": error, "suc": suc, "return_value": return_value}
|
|
||||||
|
|
||||||
return json.dumps(result_info, ensure_ascii=False, cls=ResultInfoEncoder)
|
return json.dumps(result_info, ensure_ascii=False, cls=ResultInfoEncoder)
|
||||||
|
|
||||||
|
|||||||
@@ -1,241 +0,0 @@
|
|||||||
import ast
|
|
||||||
import json
|
|
||||||
from typing import Dict, List, Any, Tuple, Optional
|
|
||||||
|
|
||||||
from .common import WorkflowGraph, RegistryAdapter
|
|
||||||
|
|
||||||
Json = Dict[str, Any]
|
|
||||||
|
|
||||||
# ---------------- Converter ----------------
|
|
||||||
|
|
||||||
class DeviceMethodConverter:
|
|
||||||
"""
|
|
||||||
- 字段统一:resource_name(原 device_class)、template_name(原 action_key)
|
|
||||||
- params 单层;inputs 使用 'params.' 前缀
|
|
||||||
- SimpleGraph.add_workflow_node 负责变量连线与边
|
|
||||||
"""
|
|
||||||
def __init__(self, device_registry: Optional[Dict[str, Any]] = None):
|
|
||||||
self.graph = WorkflowGraph()
|
|
||||||
self.variable_sources: Dict[str, Dict[str, Any]] = {} # var -> {node_id, output_name}
|
|
||||||
self.instance_to_resource: Dict[str, Optional[str]] = {} # 实例名 -> resource_name
|
|
||||||
self.node_id_counter: int = 0
|
|
||||||
self.registry = RegistryAdapter(device_registry or {})
|
|
||||||
|
|
||||||
# ---- helpers ----
|
|
||||||
def _new_node_id(self) -> int:
|
|
||||||
nid = self.node_id_counter
|
|
||||||
self.node_id_counter += 1
|
|
||||||
return nid
|
|
||||||
|
|
||||||
def _assign_targets(self, targets) -> List[str]:
|
|
||||||
names: List[str] = []
|
|
||||||
import ast
|
|
||||||
if isinstance(targets, ast.Tuple):
|
|
||||||
for elt in targets.elts:
|
|
||||||
if isinstance(elt, ast.Name):
|
|
||||||
names.append(elt.id)
|
|
||||||
elif isinstance(targets, ast.Name):
|
|
||||||
names.append(targets.id)
|
|
||||||
return names
|
|
||||||
|
|
||||||
def _extract_device_instantiation(self, node) -> Optional[Tuple[str, str]]:
|
|
||||||
import ast
|
|
||||||
if not isinstance(node.value, ast.Call):
|
|
||||||
return None
|
|
||||||
callee = node.value.func
|
|
||||||
if isinstance(callee, ast.Name):
|
|
||||||
class_name = callee.id
|
|
||||||
elif isinstance(callee, ast.Attribute) and isinstance(callee.value, ast.Name):
|
|
||||||
class_name = callee.attr
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
if isinstance(node.targets[0], ast.Name):
|
|
||||||
instance = node.targets[0].id
|
|
||||||
return instance, class_name
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _extract_call(self, call) -> Tuple[str, str, Dict[str, Any], str]:
|
|
||||||
import ast
|
|
||||||
owner_name, method_name, call_kind = "", "", "func"
|
|
||||||
if isinstance(call.func, ast.Attribute):
|
|
||||||
method_name = call.func.attr
|
|
||||||
if isinstance(call.func.value, ast.Name):
|
|
||||||
owner_name = call.func.value.id
|
|
||||||
call_kind = "instance" if owner_name in self.instance_to_resource else "class_or_module"
|
|
||||||
elif isinstance(call.func.value, ast.Attribute) and isinstance(call.func.value.value, ast.Name):
|
|
||||||
owner_name = call.func.value.attr
|
|
||||||
call_kind = "class_or_module"
|
|
||||||
elif isinstance(call.func, ast.Name):
|
|
||||||
method_name = call.func.id
|
|
||||||
call_kind = "func"
|
|
||||||
|
|
||||||
def pack(node):
|
|
||||||
if isinstance(node, ast.Name):
|
|
||||||
return {"type": "variable", "value": node.id}
|
|
||||||
if isinstance(node, ast.Constant):
|
|
||||||
return {"type": "constant", "value": node.value}
|
|
||||||
if isinstance(node, ast.Dict):
|
|
||||||
return {"type": "dict", "value": self._parse_dict(node)}
|
|
||||||
if isinstance(node, ast.List):
|
|
||||||
return {"type": "list", "value": self._parse_list(node)}
|
|
||||||
return {"type": "raw", "value": ast.unparse(node) if hasattr(ast, "unparse") else str(node)}
|
|
||||||
|
|
||||||
args: Dict[str, Any] = {}
|
|
||||||
pos: List[Any] = []
|
|
||||||
for a in call.args:
|
|
||||||
pos.append(pack(a))
|
|
||||||
for kw in call.keywords:
|
|
||||||
args[kw.arg] = pack(kw.value)
|
|
||||||
if pos:
|
|
||||||
args["_positional"] = pos
|
|
||||||
return owner_name, method_name, args, call_kind
|
|
||||||
|
|
||||||
def _parse_dict(self, node) -> Dict[str, Any]:
|
|
||||||
import ast
|
|
||||||
out: Dict[str, Any] = {}
|
|
||||||
for k, v in zip(node.keys, node.values):
|
|
||||||
if isinstance(k, ast.Constant):
|
|
||||||
key = str(k.value)
|
|
||||||
if isinstance(v, ast.Name):
|
|
||||||
out[key] = f"var:{v.id}"
|
|
||||||
elif isinstance(v, ast.Constant):
|
|
||||||
out[key] = v.value
|
|
||||||
elif isinstance(v, ast.Dict):
|
|
||||||
out[key] = self._parse_dict(v)
|
|
||||||
elif isinstance(v, ast.List):
|
|
||||||
out[key] = self._parse_list(v)
|
|
||||||
return out
|
|
||||||
|
|
||||||
def _parse_list(self, node) -> List[Any]:
|
|
||||||
import ast
|
|
||||||
out: List[Any] = []
|
|
||||||
for elt in node.elts:
|
|
||||||
if isinstance(elt, ast.Name):
|
|
||||||
out.append(f"var:{elt.id}")
|
|
||||||
elif isinstance(elt, ast.Constant):
|
|
||||||
out.append(elt.value)
|
|
||||||
elif isinstance(elt, ast.Dict):
|
|
||||||
out.append(self._parse_dict(elt))
|
|
||||||
elif isinstance(elt, ast.List):
|
|
||||||
out.append(self._parse_list(elt))
|
|
||||||
return out
|
|
||||||
|
|
||||||
def _normalize_var_tokens(self, x: Any) -> Any:
|
|
||||||
if isinstance(x, str) and x.startswith("var:"):
|
|
||||||
return {"__var__": x[4:]}
|
|
||||||
if isinstance(x, list):
|
|
||||||
return [self._normalize_var_tokens(i) for i in x]
|
|
||||||
if isinstance(x, dict):
|
|
||||||
return {k: self._normalize_var_tokens(v) for k, v in x.items()}
|
|
||||||
return x
|
|
||||||
|
|
||||||
def _make_params_payload(self, resource_name: Optional[str], template_name: str, call_args: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
input_keys = self.registry.get_action_input_keys(resource_name, template_name) if resource_name else []
|
|
||||||
defaults = self.registry.get_action_goal_default(resource_name, template_name) if resource_name else {}
|
|
||||||
params: Dict[str, Any] = dict(defaults)
|
|
||||||
|
|
||||||
def unpack(p):
|
|
||||||
t, v = p.get("type"), p.get("value")
|
|
||||||
if t == "variable":
|
|
||||||
return {"__var__": v}
|
|
||||||
if t == "dict":
|
|
||||||
return self._normalize_var_tokens(v)
|
|
||||||
if t == "list":
|
|
||||||
return self._normalize_var_tokens(v)
|
|
||||||
return v
|
|
||||||
|
|
||||||
for k, p in call_args.items():
|
|
||||||
if k == "_positional":
|
|
||||||
continue
|
|
||||||
params[k] = unpack(p)
|
|
||||||
|
|
||||||
pos = call_args.get("_positional", [])
|
|
||||||
if pos:
|
|
||||||
if input_keys:
|
|
||||||
for i, p in enumerate(pos):
|
|
||||||
if i >= len(input_keys):
|
|
||||||
break
|
|
||||||
name = input_keys[i]
|
|
||||||
if name in params:
|
|
||||||
continue
|
|
||||||
params[name] = unpack(p)
|
|
||||||
else:
|
|
||||||
for i, p in enumerate(pos):
|
|
||||||
params[f"arg_{i}"] = unpack(p)
|
|
||||||
return params
|
|
||||||
|
|
||||||
# ---- handlers ----
|
|
||||||
def _on_assign(self, stmt):
|
|
||||||
import ast
|
|
||||||
inst = self._extract_device_instantiation(stmt)
|
|
||||||
if inst:
|
|
||||||
instance, code_class = inst
|
|
||||||
resource_name = self.registry.resolve_resource_by_classname(code_class)
|
|
||||||
self.instance_to_resource[instance] = resource_name
|
|
||||||
return
|
|
||||||
|
|
||||||
if isinstance(stmt.value, ast.Call):
|
|
||||||
owner, method, call_args, kind = self._extract_call(stmt.value)
|
|
||||||
if kind == "instance":
|
|
||||||
device_key = owner
|
|
||||||
resource_name = self.instance_to_resource.get(owner)
|
|
||||||
else:
|
|
||||||
device_key = owner
|
|
||||||
resource_name = self.registry.resolve_resource_by_classname(owner)
|
|
||||||
|
|
||||||
module = self.registry.get_device_module(resource_name)
|
|
||||||
params = self._make_params_payload(resource_name, method, call_args)
|
|
||||||
|
|
||||||
nid = self._new_node_id()
|
|
||||||
self.graph.add_workflow_node(
|
|
||||||
nid,
|
|
||||||
device_key=device_key,
|
|
||||||
resource_name=resource_name, # ✅
|
|
||||||
module=module,
|
|
||||||
template_name=method, # ✅
|
|
||||||
params=params,
|
|
||||||
variable_sources=self.variable_sources,
|
|
||||||
add_ready_if_no_vars=True,
|
|
||||||
prev_node_id=(nid - 1) if nid > 0 else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
out_vars = self._assign_targets(stmt.targets[0])
|
|
||||||
for var in out_vars:
|
|
||||||
self.variable_sources[var] = {"node_id": nid, "output_name": "result"}
|
|
||||||
|
|
||||||
def _on_expr(self, stmt):
|
|
||||||
import ast
|
|
||||||
if not isinstance(stmt.value, ast.Call):
|
|
||||||
return
|
|
||||||
owner, method, call_args, kind = self._extract_call(stmt.value)
|
|
||||||
if kind == "instance":
|
|
||||||
device_key = owner
|
|
||||||
resource_name = self.instance_to_resource.get(owner)
|
|
||||||
else:
|
|
||||||
device_key = owner
|
|
||||||
resource_name = self.registry.resolve_resource_by_classname(owner)
|
|
||||||
|
|
||||||
module = self.registry.get_device_module(resource_name)
|
|
||||||
params = self._make_params_payload(resource_name, method, call_args)
|
|
||||||
|
|
||||||
nid = self._new_node_id()
|
|
||||||
self.graph.add_workflow_node(
|
|
||||||
nid,
|
|
||||||
device_key=device_key,
|
|
||||||
resource_name=resource_name, # ✅
|
|
||||||
module=module,
|
|
||||||
template_name=method, # ✅
|
|
||||||
params=params,
|
|
||||||
variable_sources=self.variable_sources,
|
|
||||||
add_ready_if_no_vars=True,
|
|
||||||
prev_node_id=(nid - 1) if nid > 0 else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
def convert(self, python_code: str):
|
|
||||||
tree = ast.parse(python_code)
|
|
||||||
for stmt in tree.body:
|
|
||||||
if isinstance(stmt, ast.Assign):
|
|
||||||
self._on_assign(stmt)
|
|
||||||
elif isinstance(stmt, ast.Expr):
|
|
||||||
self._on_expr(stmt)
|
|
||||||
return self
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
from typing import List, Any, Dict
|
|
||||||
import xml.etree.ElementTree as ET
|
|
||||||
|
|
||||||
|
|
||||||
def convert_to_type(val: str) -> Any:
|
|
||||||
"""将字符串值转换为适当的数据类型"""
|
|
||||||
if val == "True":
|
|
||||||
return True
|
|
||||||
if val == "False":
|
|
||||||
return False
|
|
||||||
if val == "?":
|
|
||||||
return None
|
|
||||||
if val.endswith(" g"):
|
|
||||||
return float(val.split(" ")[0])
|
|
||||||
if val.endswith("mg"):
|
|
||||||
return float(val.split("mg")[0])
|
|
||||||
elif val.endswith("mmol"):
|
|
||||||
return float(val.split("mmol")[0]) / 1000
|
|
||||||
elif val.endswith("mol"):
|
|
||||||
return float(val.split("mol")[0])
|
|
||||||
elif val.endswith("ml"):
|
|
||||||
return float(val.split("ml")[0])
|
|
||||||
elif val.endswith("RPM"):
|
|
||||||
return float(val.split("RPM")[0])
|
|
||||||
elif val.endswith(" °C"):
|
|
||||||
return float(val.split(" ")[0])
|
|
||||||
elif val.endswith(" %"):
|
|
||||||
return float(val.split(" ")[0])
|
|
||||||
return val
|
|
||||||
|
|
||||||
|
|
||||||
def flatten_xdl_procedure(procedure_elem: ET.Element) -> List[ET.Element]:
|
|
||||||
"""展平嵌套的XDL程序结构"""
|
|
||||||
flattened_operations = []
|
|
||||||
TEMP_UNSUPPORTED_PROTOCOL = ["Purge", "Wait", "Stir", "ResetHandling"]
|
|
||||||
|
|
||||||
def extract_operations(element: ET.Element):
|
|
||||||
if element.tag not in ["Prep", "Reaction", "Workup", "Purification", "Procedure"]:
|
|
||||||
if element.tag not in TEMP_UNSUPPORTED_PROTOCOL:
|
|
||||||
flattened_operations.append(element)
|
|
||||||
|
|
||||||
for child in element:
|
|
||||||
extract_operations(child)
|
|
||||||
|
|
||||||
for child in procedure_elem:
|
|
||||||
extract_operations(child)
|
|
||||||
|
|
||||||
return flattened_operations
|
|
||||||
|
|
||||||
|
|
||||||
def parse_xdl_content(xdl_content: str) -> tuple:
|
|
||||||
"""解析XDL内容"""
|
|
||||||
try:
|
|
||||||
xdl_content_cleaned = "".join(c for c in xdl_content if c.isprintable())
|
|
||||||
root = ET.fromstring(xdl_content_cleaned)
|
|
||||||
|
|
||||||
synthesis_elem = root.find("Synthesis")
|
|
||||||
if synthesis_elem is None:
|
|
||||||
return None, None, None
|
|
||||||
|
|
||||||
# 解析硬件组件
|
|
||||||
hardware_elem = synthesis_elem.find("Hardware")
|
|
||||||
hardware = []
|
|
||||||
if hardware_elem is not None:
|
|
||||||
hardware = [{"id": c.get("id"), "type": c.get("type")} for c in hardware_elem.findall("Component")]
|
|
||||||
|
|
||||||
# 解析试剂
|
|
||||||
reagents_elem = synthesis_elem.find("Reagents")
|
|
||||||
reagents = []
|
|
||||||
if reagents_elem is not None:
|
|
||||||
reagents = [{"name": r.get("name"), "role": r.get("role", "")} for r in reagents_elem.findall("Reagent")]
|
|
||||||
|
|
||||||
# 解析程序
|
|
||||||
procedure_elem = synthesis_elem.find("Procedure")
|
|
||||||
if procedure_elem is None:
|
|
||||||
return None, None, None
|
|
||||||
|
|
||||||
flattened_operations = flatten_xdl_procedure(procedure_elem)
|
|
||||||
return hardware, reagents, flattened_operations
|
|
||||||
|
|
||||||
except ET.ParseError as e:
|
|
||||||
raise ValueError(f"Invalid XDL format: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def convert_xdl_to_dict(xdl_content: str) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
将XDL XML格式转换为标准的字典格式
|
|
||||||
|
|
||||||
Args:
|
|
||||||
xdl_content: XDL XML内容
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
转换结果,包含步骤和器材信息
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
hardware, reagents, flattened_operations = parse_xdl_content(xdl_content)
|
|
||||||
if hardware is None:
|
|
||||||
return {"error": "Failed to parse XDL content", "success": False}
|
|
||||||
|
|
||||||
# 将XDL元素转换为字典格式
|
|
||||||
steps_data = []
|
|
||||||
for elem in flattened_operations:
|
|
||||||
# 转换参数类型
|
|
||||||
parameters = {}
|
|
||||||
for key, val in elem.attrib.items():
|
|
||||||
converted_val = convert_to_type(val)
|
|
||||||
if converted_val is not None:
|
|
||||||
parameters[key] = converted_val
|
|
||||||
|
|
||||||
step_dict = {
|
|
||||||
"operation": elem.tag,
|
|
||||||
"parameters": parameters,
|
|
||||||
"description": elem.get("purpose", f"Operation: {elem.tag}"),
|
|
||||||
}
|
|
||||||
steps_data.append(step_dict)
|
|
||||||
|
|
||||||
# 合并硬件和试剂为统一的labware_info格式
|
|
||||||
labware_data = []
|
|
||||||
labware_data.extend({"id": hw["id"], "type": "hardware", **hw} for hw in hardware)
|
|
||||||
labware_data.extend({"name": reagent["name"], "type": "reagent", **reagent} for reagent in reagents)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"steps": steps_data,
|
|
||||||
"labware": labware_data,
|
|
||||||
"message": f"Successfully converted XDL to dict format. Found {len(steps_data)} steps and {len(labware_data)} labware items.",
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = f"XDL conversion failed: {str(e)}"
|
|
||||||
return {"error": error_msg, "success": False}
|
|
||||||
Reference in New Issue
Block a user