mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-05-23 13:06:06 +00:00
Compare commits
78 Commits
feat/3d_la
...
v0.10.18
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -3,7 +3,7 @@
|
||||
|
||||
package:
|
||||
name: unilabos
|
||||
version: 0.10.19
|
||||
version: 0.10.18
|
||||
|
||||
source:
|
||||
path: ../../unilabos
|
||||
@@ -54,7 +54,7 @@ requirements:
|
||||
- pymodbus
|
||||
- matplotlib
|
||||
- pylibftdi
|
||||
- uni-lab::unilabos-env ==0.10.19
|
||||
- uni-lab::unilabos-env ==0.10.18
|
||||
|
||||
about:
|
||||
repository: https://github.com/deepmodeling/Uni-Lab-OS
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
package:
|
||||
name: unilabos-env
|
||||
version: 0.10.19
|
||||
version: 0.10.18
|
||||
|
||||
build:
|
||||
noarch: generic
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
package:
|
||||
name: unilabos-full
|
||||
version: 0.10.19
|
||||
version: 0.10.18
|
||||
|
||||
build:
|
||||
noarch: generic
|
||||
@@ -11,7 +11,7 @@ build:
|
||||
requirements:
|
||||
run:
|
||||
# Base unilabos package (includes unilabos-env)
|
||||
- uni-lab::unilabos ==0.10.19
|
||||
- uni-lab::unilabos ==0.10.18
|
||||
# Documentation tools
|
||||
- sphinx
|
||||
- sphinx_rtd_theme
|
||||
|
||||
@@ -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,325 +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, check notebook status. Use when the user wants to submit experiments, create notebooks, batch run workflows, check experiment status, 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 #3 列出可用 workflow 供选择。
|
||||
|
||||
**四项全部就绪后才可开始。**
|
||||
|
||||
## Session State
|
||||
|
||||
在整个对话过程中,agent 需要记住以下状态,避免重复询问用户:
|
||||
|
||||
- `lab_uuid` — 实验室 UUID(首次通过 API #1 自动获取,**不需要问用户**)
|
||||
- `project_uuid` — 项目 UUID(通过 API #2 列出项目列表,**让用户选择**)
|
||||
- `workflow_uuid` — 工作流 UUID(用户提供或从列表选择)
|
||||
- `workflow_nodes` — workflow 中各 action 节点的 uuid、设备 ID、动作名(从 API #4 获取)
|
||||
|
||||
## 请求约定
|
||||
|
||||
所有请求使用 `curl -s`,POST 需加 `Content-Type: application/json`。
|
||||
|
||||
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名),示例中的 `curl` 均指 `curl.exe`。
|
||||
>
|
||||
> **PowerShell JSON 传参**:PowerShell 中 `-d '{"key":"value"}'` 会因引号转义失败。请将 JSON 写入临时文件,用 `-d '@tmp_body.json'`(单引号包裹 `@`,否则会被解析为 splatting 运算符)。
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### 1. 获取实验室信息(自动获取 lab_uuid)
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}}
|
||||
```
|
||||
|
||||
记住 `data.uuid` 为 `lab_uuid`。
|
||||
|
||||
### 2. 列出实验室项目(让用户选择项目)
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/project/list?lab_uuid=$lab_uuid" -H "$AUTH"
|
||||
```
|
||||
|
||||
返回项目列表,展示给用户选择。列出每个项目的 `uuid` 和 `name`。
|
||||
|
||||
用户**必须**选择一个项目,记住 `project_uuid`,后续创建 notebook 时需要提供。
|
||||
|
||||
### 3. 列出可用 workflow
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/workflow/workflows?page=1&page_size=20&lab_uuid=$lab_uuid" -H "$AUTH"
|
||||
```
|
||||
|
||||
返回 workflow 列表,展示给用户选择。列出每个 workflow 的 `uuid` 和 `name`。
|
||||
|
||||
### 4. 获取 workflow 模板详情
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH"
|
||||
```
|
||||
|
||||
返回 workflow 的完整结构,包含所有 action 节点信息。需要从响应中提取:
|
||||
- 每个 action 节点的 `node_uuid`
|
||||
- 每个节点对应的设备 ID(`resource_template_name`)
|
||||
- 每个节点的动作名(`node_template_name`)
|
||||
- 每个节点的现有参数(`param`)
|
||||
|
||||
> **注意**:此 API 返回格式可能因版本不同而有差异。首次调用时,先打印完整响应分析结构,再提取节点信息。常见的节点字段路径为 `data.nodes[]` 或 `data.workflow_nodes[]`。
|
||||
|
||||
### 5. 提交实验(创建 notebook)
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/notebook" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '<request_body>'
|
||||
```
|
||||
|
||||
请求体结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"lab_uuid": "<lab_uuid>",
|
||||
"project_uuid": "<project_uuid>",
|
||||
"workflow_uuid": "<workflow_uuid>",
|
||||
"name": "<实验名称>",
|
||||
"node_params": [
|
||||
{
|
||||
"sample_uuids": ["<样品UUID1>", "<样品UUID2>"],
|
||||
"datas": [
|
||||
{
|
||||
"node_uuid": "<workflow中的节点UUID>",
|
||||
"param": {},
|
||||
"sample_params": [
|
||||
{
|
||||
"container_uuid": "<容器UUID>",
|
||||
"sample_value": {
|
||||
"liquid_names": "<液体名称>",
|
||||
"volumes": 1000
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:`sample_uuids` 必须是 **UUID 数组**(`[]uuid.UUID`),不是字符串。无样品时传空数组 `[]`。
|
||||
|
||||
### 6. 查询 notebook 状态
|
||||
|
||||
提交成功后,使用返回的 notebook UUID 查询执行状态:
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/notebook/status?uuid=$notebook_uuid" -H "$AUTH"
|
||||
```
|
||||
|
||||
提交后应**立即查询一次**状态,确认 notebook 已被正确接收并开始调度。
|
||||
|
||||
---
|
||||
|
||||
## Notebook 请求体详解
|
||||
|
||||
### node_params 结构
|
||||
|
||||
`node_params` 是一个数组,**每个元素代表一轮实验**:
|
||||
|
||||
- 要跑 2 轮 → `node_params` 有 2 个元素
|
||||
- 要跑 N 轮 → `node_params` 有 N 个元素
|
||||
|
||||
### 每轮的字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `sample_uuids` | array\<uuid\> | 该轮实验的样品 UUID 数组,无样品时传 `[]` |
|
||||
| `datas` | array | 该轮中每个 workflow 节点的参数配置 |
|
||||
|
||||
### datas 中每个节点
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `node_uuid` | string | workflow 模板中的节点 UUID(从 API #4 获取) |
|
||||
| `param` | object | 动作参数(根据本地注册表 schema 填写) |
|
||||
| `sample_params` | array | 样品相关参数(液体名、体积等) |
|
||||
|
||||
### sample_params 中每条
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `container_uuid` | string | 容器 UUID |
|
||||
| `sample_value` | object | 样品值,如 `{"liquid_names": "水", "volumes": 1000}` |
|
||||
|
||||
---
|
||||
|
||||
## 从本地注册表生成 param 模板
|
||||
|
||||
### 自动方式 — 运行脚本
|
||||
|
||||
```bash
|
||||
python scripts/gen_notebook_params.py \
|
||||
--auth <token> \
|
||||
--base <BASE_URL> \
|
||||
--workflow-uuid <workflow_uuid> \
|
||||
[--registry <path/to/req_device_registry_upload.json>] \
|
||||
[--rounds <轮次数>] \
|
||||
[--output <输出文件路径>]
|
||||
```
|
||||
|
||||
> 脚本位于本文档同级目录下的 `scripts/gen_notebook_params.py`。
|
||||
|
||||
脚本会:
|
||||
1. 调用 workflow detail API 获取所有 action 节点
|
||||
2. 读取本地注册表,为每个节点查找对应的 action schema
|
||||
3. 生成 `notebook_template.json`,包含:
|
||||
- 完整 `node_params` 骨架
|
||||
- 每个节点的 param 字段及类型说明
|
||||
- `_schema_info` 辅助信息(不提交,仅供参考)
|
||||
|
||||
### 手动方式
|
||||
|
||||
如果脚本不可用或注册表不存在:
|
||||
|
||||
1. 调用 API #4 获取 workflow 详情
|
||||
2. 找到每个 action 节点的 `node_uuid`
|
||||
3. 在本地注册表中查找对应设备的 `action_value_mappings`:
|
||||
```
|
||||
resources[].id == <device_id>
|
||||
→ resources[].class.action_value_mappings.<action_name>.schema.properties.goal.properties
|
||||
```
|
||||
4. 将 schema 中的 properties 作为 `param` 的字段模板
|
||||
5. 按轮次复制 `node_params` 元素,让用户填写每轮的具体值
|
||||
|
||||
### 注册表结构参考
|
||||
|
||||
```json
|
||||
{
|
||||
"resources": [
|
||||
{
|
||||
"id": "liquid_handler.prcxi",
|
||||
"class": {
|
||||
"module": "unilabos.devices.xxx:ClassName",
|
||||
"action_value_mappings": {
|
||||
"transfer_liquid": {
|
||||
"type": "LiquidHandlerTransfer",
|
||||
"schema": {
|
||||
"properties": {
|
||||
"goal": {
|
||||
"properties": {
|
||||
"asp_vols": {"type": "array", "items": {"type": "number"}},
|
||||
"sources": {"type": "array"}
|
||||
},
|
||||
"required": ["asp_vols", "sources"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"goal_default": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`param` 填写时,使用 `goal.properties` 中的字段名和类型。
|
||||
|
||||
---
|
||||
|
||||
## 完整工作流 Checklist
|
||||
|
||||
```
|
||||
Task Progress:
|
||||
- [ ] Step 1: 确认 ak/sk → 生成 AUTH token
|
||||
- [ ] Step 2: 确认 --addr → 设置 BASE URL
|
||||
- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid
|
||||
- [ ] Step 4: GET /lab/project/list → 列出项目,让用户选择 → 获取 project_uuid
|
||||
- [ ] Step 5: 确认 workflow_uuid(用户提供或从 GET #3 列表选择)
|
||||
- [ ] Step 6: GET workflow detail (#4) → 提取各节点 uuid、设备ID、动作名
|
||||
- [ ] Step 7: 定位本地注册表 req_device_registry_upload.json
|
||||
- [ ] Step 8: 运行 gen_notebook_params.py 或手动匹配 → 生成 node_params 模板
|
||||
- [ ] Step 9: 引导用户填写每轮的参数(sample_uuids、param、sample_params)
|
||||
- [ ] Step 10: 构建完整请求体(含 project_uuid)→ POST /lab/notebook 提交
|
||||
- [ ] Step 11: 检查返回结果,记录 notebook UUID
|
||||
- [ ] Step 12: GET /lab/notebook/status → 查询 notebook 状态,确认已调度
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: workflow 中有多个节点,每轮都要填所有节点的参数吗?
|
||||
|
||||
是的。`datas` 数组中需要包含该轮实验涉及的每个 workflow 节点的参数。通常每个 action 节点都需要一条 `datas` 记录。
|
||||
|
||||
### Q: 多轮实验的参数完全不同吗?
|
||||
|
||||
通常每轮的 `param`(设备动作参数)可能相同或相似,但 `sample_uuids` 和 `sample_params`(样品信息)每轮不同。脚本生成模板时会按轮次复制骨架,用户只需修改差异部分。
|
||||
|
||||
### Q: 如何获取 sample_uuids 和 container_uuid?
|
||||
|
||||
这些 UUID 通常来自实验室的样品管理系统。向用户询问,或从资源树(API `GET /lab/material/download/$lab_uuid`)中查找。
|
||||
@@ -1,395 +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",
|
||||
"project_uuid": "$TODO_PROJECT_UUID",
|
||||
"workflow_uuid": "$TODO_WORKFLOW_UUID",
|
||||
"name": "$TODO_EXPERIMENT_NAME",
|
||||
"node_params": node_params,
|
||||
"_schema_info(仅参考,提交时删除)": schema_info,
|
||||
}
|
||||
|
||||
|
||||
def parse_args(argv):
|
||||
"""简单的参数解析"""
|
||||
opts = {
|
||||
"auth": None,
|
||||
"base": None,
|
||||
"workflow_uuid": None,
|
||||
"registry": None,
|
||||
"rounds": 1,
|
||||
"output": "notebook_template.json",
|
||||
"dump_response": False,
|
||||
}
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
arg = argv[i]
|
||||
if arg == "--auth" and i + 1 < len(argv):
|
||||
opts["auth"] = argv[i + 1]
|
||||
i += 2
|
||||
elif arg == "--base" and i + 1 < len(argv):
|
||||
opts["base"] = argv[i + 1].rstrip("/")
|
||||
i += 2
|
||||
elif arg == "--workflow-uuid" and i + 1 < len(argv):
|
||||
opts["workflow_uuid"] = argv[i + 1]
|
||||
i += 2
|
||||
elif arg == "--registry" and i + 1 < len(argv):
|
||||
opts["registry"] = argv[i + 1]
|
||||
i += 2
|
||||
elif arg == "--rounds" and i + 1 < len(argv):
|
||||
opts["rounds"] = int(argv[i + 1])
|
||||
i += 2
|
||||
elif arg == "--output" and i + 1 < len(argv):
|
||||
opts["output"] = argv[i + 1]
|
||||
i += 2
|
||||
elif arg == "--dump-response":
|
||||
opts["dump_response"] = True
|
||||
i += 1
|
||||
else:
|
||||
print(f"未知参数: {arg}")
|
||||
i += 1
|
||||
return opts
|
||||
|
||||
|
||||
def main():
|
||||
opts = parse_args(sys.argv[1:])
|
||||
|
||||
if not opts["auth"] or not opts["base"] or not opts["workflow_uuid"]:
|
||||
print("用法:")
|
||||
print(" python gen_notebook_params.py --auth <token> --base <url> --workflow-uuid <uuid> [选项]")
|
||||
print()
|
||||
print("必需参数:")
|
||||
print(" --auth <token> Lab token(base64(ak:sk))")
|
||||
print(" --base <url> API 基础 URL")
|
||||
print(" --workflow-uuid <uuid> 目标 workflow UUID")
|
||||
print()
|
||||
print("可选参数:")
|
||||
print(" --registry <path> 注册表文件路径(默认自动搜索)")
|
||||
print(" --rounds <n> 实验轮次数(默认 1)")
|
||||
print(" --output <path> 输出文件路径(默认 notebook_template.json)")
|
||||
print(" --dump-response 打印 API 原始响应")
|
||||
sys.exit(1)
|
||||
|
||||
# 1. 查找并加载本地注册表
|
||||
registry_path = find_registry(opts["registry"])
|
||||
registry_index = {}
|
||||
if registry_path:
|
||||
mtime = os.path.getmtime(registry_path)
|
||||
gen_time = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
|
||||
print(f"注册表: {registry_path} (生成时间: {gen_time})")
|
||||
registry_data = load_registry(registry_path)
|
||||
registry_index = build_registry_index(registry_data)
|
||||
print(f"已索引 {len(registry_index)} 个设备的 action schemas")
|
||||
else:
|
||||
print("警告: 未找到本地注册表,将跳过 param 模板生成")
|
||||
print(" 提交时需要手动填写各节点的 param 字段")
|
||||
|
||||
# 2. 获取 workflow 详情
|
||||
print(f"\n正在获取 workflow 详情: {opts['workflow_uuid']}")
|
||||
response = fetch_workflow_detail(opts["base"], opts["auth"], opts["workflow_uuid"])
|
||||
if not response:
|
||||
print("错误: 无法获取 workflow 详情")
|
||||
sys.exit(1)
|
||||
|
||||
if opts["dump_response"]:
|
||||
print("\n=== API 原始响应 ===")
|
||||
print(json.dumps(response, indent=2, ensure_ascii=False)[:5000])
|
||||
print("=== 响应结束(截断至 5000 字符) ===\n")
|
||||
|
||||
# 3. 提取节点
|
||||
nodes = extract_nodes_from_response(response)
|
||||
if not nodes:
|
||||
print("错误: 未能从 workflow 中提取任何 action 节点")
|
||||
print("请使用 --dump-response 查看原始响应结构")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"\n找到 {len(nodes)} 个 action 节点:")
|
||||
print(f" {'节点 UUID':<40} {'设备 ID':<30} {'动作名':<25} {'Schema'}")
|
||||
print(" " + "-" * 110)
|
||||
for node_uuid, resource_name, template_name, _ in nodes:
|
||||
matched = "✓" if (resource_name in registry_index and
|
||||
template_name in registry_index.get(resource_name, {})) else "✗"
|
||||
print(f" {node_uuid:<40} {resource_name:<30} {template_name:<25} {matched}")
|
||||
|
||||
# 4. 生成模板
|
||||
template = generate_template(nodes, registry_index, opts["rounds"])
|
||||
template["workflow_uuid"] = opts["workflow_uuid"]
|
||||
|
||||
output_path = opts["output"]
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(template, f, indent=2, ensure_ascii=False)
|
||||
print(f"\n模板已写入: {output_path}")
|
||||
print(f" 轮次数: {opts['rounds']}")
|
||||
print(f" 节点数/轮: {len(nodes)}")
|
||||
print()
|
||||
print("下一步:")
|
||||
print(" 1. 打开模板文件,将 $TODO 占位符替换为实际值")
|
||||
print(" 2. 删除 _schema_info 字段(仅供参考)")
|
||||
print(" 3. 使用 POST /api/v1/lab/notebook 提交")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,380 +0,0 @@
|
||||
---
|
||||
name: create-device-skill
|
||||
description: Create a skill for any Uni-Lab device by extracting action schemas from the device registry. Use when the user wants to create a new device skill, add device API documentation, or set up action schemas for a device.
|
||||
---
|
||||
|
||||
# 创建设备 Skill 指南
|
||||
|
||||
本 meta-skill 教你如何为任意 Uni-Lab-OS 设备创建完整的 API 操作技能(参考 `unilab-device-api` 的成功案例)。
|
||||
|
||||
## 数据源
|
||||
|
||||
- **设备注册表**: `unilabos_data/req_device_registry_upload.json`
|
||||
- **结构**: `{ "resources": [{ "id": "<device_id>", "class": { "module": "<python_module:ClassName>", "action_value_mappings": { ... } } }] }`
|
||||
- **生成时机**: `unilab` 启动并完成注册表上传后自动生成
|
||||
- **module 字段**: 格式 `unilabos.devices.xxx.yyy:ClassName`,可转为源码路径 `unilabos/devices/xxx/yyy.py`,阅读源码可了解参数含义和设备行为
|
||||
|
||||
## 创建流程
|
||||
|
||||
### Step 0 — 收集必备信息(缺一不可,否则询问后终止)
|
||||
|
||||
开始前**必须**确认以下 4 项信息全部就绪。如果用户未提供任何一项,**立即询问并终止当前流程**,等用户补齐后再继续。
|
||||
|
||||
向用户提问:「请提供你的 unilab 启动参数,我需要以下信息:」
|
||||
|
||||
#### 必备项 ①:ak / sk(认证凭据)
|
||||
|
||||
来源:启动命令的 `--ak` `--sk` 参数,或 config.py 中的 `ak = "..."` `sk = "..."`。
|
||||
|
||||
获取后立即生成 AUTH token:
|
||||
|
||||
```bash
|
||||
python ./scripts/gen_auth.py <ak> <sk>
|
||||
# 或从 config.py 提取
|
||||
python ./scripts/gen_auth.py --config <config.py>
|
||||
```
|
||||
|
||||
认证算法:`base64(ak:sk)` → `Authorization: Lab <token>`
|
||||
|
||||
#### 必备项 ②:--addr(目标环境)
|
||||
|
||||
决定 API 请求发往哪个服务器。从启动命令的 `--addr` 参数获取:
|
||||
|
||||
| `--addr` 值 | BASE URL |
|
||||
|-------------|----------|
|
||||
| `test` | `https://uni-lab.test.bohrium.com` |
|
||||
| `uat` | `https://uni-lab.uat.bohrium.com` |
|
||||
| `local` | `http://127.0.0.1:48197` |
|
||||
| 不传(默认) | `https://uni-lab.bohrium.com` |
|
||||
| 其他自定义 URL | 直接使用该 URL |
|
||||
|
||||
#### 必备项 ③:req_device_registry_upload.json(设备注册表)
|
||||
|
||||
数据文件由 `unilab` 启动时自动生成,需要定位它:
|
||||
|
||||
**推断 working_dir**(即 `unilabos_data` 所在目录):
|
||||
|
||||
| 条件 | working_dir 取值 |
|
||||
|------|------------------|
|
||||
| 传了 `--working_dir` | `<working_dir>/unilabos_data/`(若子目录已存在则直接用) |
|
||||
| 仅传了 `--config` | `<config 文件所在目录>/unilabos_data/` |
|
||||
| 都没传 | `<当前工作目录>/unilabos_data/` |
|
||||
|
||||
**按优先级搜索文件**:
|
||||
|
||||
```
|
||||
<推断的 working_dir>/unilabos_data/req_device_registry_upload.json
|
||||
<推断的 working_dir>/req_device_registry_upload.json
|
||||
<workspace 根目录>/unilabos_data/req_device_registry_upload.json
|
||||
```
|
||||
|
||||
也可以直接 Glob 搜索:`**/req_device_registry_upload.json`
|
||||
|
||||
找到后**必须检查文件修改时间**并告知用户:「找到注册表文件 `<路径>`,生成于 `<时间>`。请确认这是最近一次启动生成的。」超过 1 天提醒用户是否需要重新启动 `unilab`。
|
||||
|
||||
**如果文件不存在** → 告知用户先运行 `unilab` 启动命令,等日志出现 `注册表响应数据已保存` 后再执行本流程。**终止。**
|
||||
|
||||
#### 必备项 ④:目标设备
|
||||
|
||||
用户需要明确要为哪个设备创建 skill。可以是设备名称(如「PRCXI 移液站」)或 device_id(如 `liquid_handler.prcxi`)。
|
||||
|
||||
如果用户不确定,运行提取脚本列出所有设备供选择:
|
||||
|
||||
```bash
|
||||
python ./scripts/extract_device_actions.py --registry <找到的文件路径>
|
||||
```
|
||||
|
||||
#### 完整示例
|
||||
|
||||
用户提供:
|
||||
|
||||
```
|
||||
--ak a1fd9d4e-xxxx-xxxx-xxxx-d9a69c09f0fd
|
||||
--sk 136ff5c6-xxxx-xxxx-xxxx-a03e301f827b
|
||||
--addr test
|
||||
--port 8003
|
||||
--disable_browser
|
||||
```
|
||||
|
||||
从中提取:
|
||||
- ✅ ak/sk → 运行 `gen_auth.py` 得到 `AUTH="Authorization: Lab YTFmZDlk..."`
|
||||
- ✅ addr=test → `BASE=https://uni-lab.test.bohrium.com`
|
||||
- ✅ 搜索 `unilabos_data/req_device_registry_upload.json` → 找到并确认时间
|
||||
- ✅ 用户指明目标设备 → 如 `liquid_handler.prcxi`
|
||||
|
||||
**四项全部就绪后才进入 Step 1。**
|
||||
|
||||
### Step 1 — 列出可用设备
|
||||
|
||||
运行提取脚本,列出所有设备及 action 数量和 Python 源码路径,让用户选择:
|
||||
|
||||
```bash
|
||||
# 自动搜索(默认在 unilabos_data/ 和当前目录查找)
|
||||
python ./scripts/extract_device_actions.py
|
||||
|
||||
# 指定注册表文件路径
|
||||
python ./scripts/extract_device_actions.py --registry <path/to/req_device_registry_upload.json>
|
||||
```
|
||||
|
||||
脚本输出包含每个设备的 **Python 源码路径**(从 `class.module` 转换),可用于后续阅读源码理解参数含义。
|
||||
|
||||
### Step 2 — 提取 Action Schema
|
||||
|
||||
用户选择设备后,运行提取脚本:
|
||||
|
||||
```bash
|
||||
python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./skills/<skill-name>/actions/
|
||||
```
|
||||
|
||||
脚本会显示设备的 Python 源码路径和类名,方便阅读源码了解参数含义。
|
||||
|
||||
每个 action 生成一个 JSON 文件,包含:
|
||||
- `type` — 作为 API 调用的 `action_type`
|
||||
- `schema` — 完整 JSON Schema(含 `properties.goal.properties` 参数定义)
|
||||
- `goal` — goal 字段映射(含占位符 `$placeholder`)
|
||||
- `goal_default` — 默认值
|
||||
|
||||
### Step 3 — 写 action-index.md
|
||||
|
||||
按模板为每个 action 写条目:
|
||||
|
||||
```markdown
|
||||
### `<action_name>`
|
||||
|
||||
<用途描述(一句话)>
|
||||
|
||||
- **Schema**: [`actions/<filename>.json`](actions/<filename>.json)
|
||||
- **核心参数**: `param1`, `param2`(从 schema.required 获取)
|
||||
- **可选参数**: `param3`, `param4`
|
||||
- **占位符字段**: `field`(需填入物料信息,值以 `$` 开头)
|
||||
```
|
||||
|
||||
描述规则:
|
||||
- 从 `schema.properties` 读参数列表(schema 已提升为 goal 内容)
|
||||
- 从 `schema.required` 区分核心/可选参数
|
||||
- 按功能分类(移液、枪头、外设等)
|
||||
- 标注 `placeholder_keys` 中的字段类型:
|
||||
- `unilabos_resources` → **ResourceSlot**,填入 `{id, name, uuid}`(id 是路径格式,从资源树取物料节点)
|
||||
- `unilabos_devices` → **DeviceSlot**,填入路径字符串如 `"/host_node"`(从资源树筛选 type=device)
|
||||
- `unilabos_nodes` → **NodeSlot**,填入路径字符串如 `"/PRCXI/PRCXI_Deck"`(资源树中任意节点)
|
||||
- `unilabos_class` → **ClassSlot**,填入类名字符串如 `"container"`(从注册表查找)
|
||||
- `unilabos_formulation` → **FormulationSlot**,填入配方数组 `[{well_name, liquids: [{name, volume}]}]`(well_name 为目标物料的 name)
|
||||
- array 类型字段 → `[{id, name, uuid}, ...]`
|
||||
- 特殊:`create_resource` 的 `res_id`(ResourceSlot)可填不存在的路径
|
||||
|
||||
### Step 4 — 写 SKILL.md
|
||||
|
||||
直接复用 `unilab-device-api` 的 API 模板,修改:
|
||||
- 设备名称
|
||||
- Action 数量
|
||||
- 目录列表
|
||||
- Session state 中的 `device_name`
|
||||
- **AUTH 头** — 使用 Step 0 中 `gen_auth.py` 生成的 `Authorization: Lab <token>`(不要硬编码 `Api` 类型的 key)
|
||||
- **Python 源码路径** — 在 SKILL.md 开头注明设备对应的源码文件,方便参考参数含义
|
||||
- **Slot 字段表** — 列出本设备哪些 action 的哪些字段需要填入 Slot(物料/设备/节点/类名)
|
||||
|
||||
API 模板结构:
|
||||
|
||||
```markdown
|
||||
## 设备信息
|
||||
- device_id, Python 源码路径, 设备类名
|
||||
|
||||
## 前置条件(缺一不可)
|
||||
- ak/sk → AUTH, --addr → BASE URL
|
||||
|
||||
## 请求约定
|
||||
- Windows 平台必须用 curl.exe(非 PowerShell 的 curl 别名)
|
||||
|
||||
## Session State
|
||||
- lab_uuid(通过 GET /edge/lab/info 直接获取,不要问用户), device_name
|
||||
|
||||
## API Endpoints
|
||||
# - #1 GET /edge/lab/info → 直接拿到 lab_uuid
|
||||
# - #2 创建工作流 POST /lab/workflow/owner → 拼 URL 告知用户
|
||||
# - #3 创建节点 POST /edge/workflow/node
|
||||
# body: {workflow_uuid, resource_template_name: "<device_id>", node_template_name: "<action_name>"}
|
||||
# - #4 删除节点 DELETE /lab/workflow/nodes
|
||||
# - #5 更新节点参数 PATCH /lab/workflow/node
|
||||
# - #6 查询节点 handles POST /lab/workflow/node-handles
|
||||
# body: {node_uuids: ["uuid1","uuid2"]} → 返回各节点的 handle_uuid
|
||||
# - #7 批量创建边 POST /lab/workflow/edges
|
||||
# body: {edges: [{source_node_uuid, target_node_uuid, source_handle_uuid, target_handle_uuid}]}
|
||||
# - #8 启动工作流 POST /lab/workflow/{uuid}/run
|
||||
# - #9 运行设备单动作 POST /lab/mcp/run/action
|
||||
# - #10 查询任务状态 GET /lab/mcp/task/{task_uuid}
|
||||
# - #11 运行工作流单节点 POST /lab/mcp/run/workflow/action
|
||||
# - #12 获取资源树 GET /lab/material/download/{lab_uuid}
|
||||
# - #13 获取工作流模板详情 GET /lab/workflow/template/detail/{workflow_uuid}
|
||||
# 返回 workflow 完整结构:data.nodes[] 含每个节点的 uuid、name、param、device_name、handles
|
||||
|
||||
## Placeholder Slot 填写规则
|
||||
- unilabos_resources → ResourceSlot → {"id":"/path/name","name":"name","uuid":"xxx"}
|
||||
- unilabos_devices → DeviceSlot → "/parent/device" 路径字符串
|
||||
- unilabos_nodes → NodeSlot → "/parent/node" 路径字符串
|
||||
- unilabos_class → ClassSlot → "class_name" 字符串
|
||||
- unilabos_formulation → FormulationSlot → [{well_name, liquids: [{name, volume}]}] 配方数组
|
||||
- 特例:create_resource 的 res_id 允许填不存在的路径
|
||||
- 列出本设备所有 Slot 字段、类型及含义
|
||||
|
||||
## 渐进加载策略
|
||||
## 完整工作流 Checklist
|
||||
```
|
||||
|
||||
### Step 5 — 验证
|
||||
|
||||
检查文件完整性:
|
||||
- [ ] `SKILL.md` 包含 API endpoint(#1 获取 lab_uuid、#2-#7 工作流/节点/边、#8-#11 运行/查询、#12 资源树、#13 工作流模板详情)
|
||||
- [ ] `SKILL.md` 包含 Placeholder Slot 填写规则(ResourceSlot / DeviceSlot / NodeSlot / ClassSlot / FormulationSlot + create_resource 特例)和本设备的 Slot 字段表
|
||||
- [ ] `action-index.md` 列出所有 action 并有描述
|
||||
- [ ] `actions/` 目录中每个 action 有对应 JSON 文件
|
||||
- [ ] JSON 文件包含 `type`, `schema`(已提升为 goal 内容), `goal`, `goal_default`, `placeholder_keys` 字段
|
||||
- [ ] 描述能让 agent 判断该用哪个 action
|
||||
|
||||
## Action JSON 文件结构
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "LiquidHandlerTransfer", // → API 的 action_type
|
||||
"goal": { // goal 字段映射
|
||||
"sources": "sources",
|
||||
"targets": "targets",
|
||||
"tip_racks": "tip_racks",
|
||||
"asp_vols": "asp_vols"
|
||||
},
|
||||
"schema": { // ← 直接是 goal 的 schema(已提升)
|
||||
"type": "object",
|
||||
"properties": { // 参数定义(即请求中 goal 的字段)
|
||||
"sources": { "type": "array", "items": { "type": "object" } },
|
||||
"targets": { "type": "array", "items": { "type": "object" } },
|
||||
"asp_vols": { "type": "array", "items": { "type": "number" } }
|
||||
},
|
||||
"required": [...],
|
||||
"_unilabos_placeholder_info": { // ← Slot 类型标记
|
||||
"sources": "unilabos_resources",
|
||||
"targets": "unilabos_resources",
|
||||
"tip_racks": "unilabos_resources"
|
||||
}
|
||||
},
|
||||
"goal_default": { ... }, // 默认值
|
||||
"placeholder_keys": { // ← 汇总所有 Slot 字段
|
||||
"sources": "unilabos_resources", // ResourceSlot
|
||||
"targets": "unilabos_resources",
|
||||
"tip_racks": "unilabos_resources",
|
||||
"target_device_id": "unilabos_devices" // DeviceSlot
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:`schema` 已由脚本从原始 `schema.properties.goal` 提升为顶层,直接包含参数定义。
|
||||
> `schema.properties` 中的字段即为 API 创建节点返回的 `data.param` 中的字段,PATCH 更新时直接修改 `param` 即可。
|
||||
|
||||
## Placeholder Slot 类型体系
|
||||
|
||||
`placeholder_keys` / `_unilabos_placeholder_info` 中有 5 种值,对应不同的填写方式:
|
||||
|
||||
| placeholder 值 | Slot 类型 | 填写格式 | 选取范围 |
|
||||
|---------------|-----------|---------|---------|
|
||||
| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅**物料**节点(不含设备) |
|
||||
| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅**设备**节点(type=device),路径字符串 |
|
||||
| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | **设备 + 物料**,即所有节点,路径字符串 |
|
||||
| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已上报的资源类 name |
|
||||
| `unilabos_formulation` | FormulationSlot | `[{well_name, liquids: [{name, volume}]}]` | 资源树中物料节点的 **name**,配合液体配方 |
|
||||
|
||||
### ResourceSlot(`unilabos_resources`)
|
||||
|
||||
最常见的类型。从资源树中选取**物料**节点(孔板、枪头盒、试剂槽等):
|
||||
|
||||
```json
|
||||
{"id": "/workstation/container1", "name": "container1", "uuid": "ff149a9a-2cb8-419d-8db5-d3ba056fb3c2"}
|
||||
```
|
||||
|
||||
- 单个(schema type=object):`{"id": "/path/name", "name": "name", "uuid": "xxx"}`
|
||||
- 数组(schema type=array):`[{"id": "/path/a", "name": "a", "uuid": "xxx"}, ...]`
|
||||
- `id` 本身是从 parent 计算的路径格式
|
||||
- 根据 action 语义选择正确的物料(如 `sources` = 液体来源,`targets` = 目标位置)
|
||||
|
||||
> **特例**:`create_resource` 的 `res_id` 字段,目标物料可能**尚不存在**,此时直接填写期望的路径(如 `"/workstation/container1"`),不需要 uuid。
|
||||
|
||||
### DeviceSlot(`unilabos_devices`)
|
||||
|
||||
填写**设备路径字符串**。从资源树中筛选 type=device 的节点,从 parent 计算路径:
|
||||
|
||||
```
|
||||
"/host_node"
|
||||
"/bioyond_cell/reaction_station"
|
||||
```
|
||||
|
||||
- 只填路径字符串,不需要 `{id, uuid}` 对象
|
||||
- 根据 action 语义选择正确的设备(如 `target_device_id` = 目标设备)
|
||||
|
||||
### NodeSlot(`unilabos_nodes`)
|
||||
|
||||
范围 = 设备 + 物料。即资源树中**所有节点**都可以选,填写**路径字符串**:
|
||||
|
||||
```
|
||||
"/PRCXI/PRCXI_Deck"
|
||||
```
|
||||
|
||||
- 使用场景:当参数既可能指向物料也可能指向设备时(如 `PumpTransferProtocol` 的 `from_vessel`/`to_vessel`,`create_resource` 的 `parent`)
|
||||
|
||||
### ClassSlot(`unilabos_class`)
|
||||
|
||||
填写注册表中已上报的**资源类 name**。从本地 `req_resource_registry_upload.json` 中查找:
|
||||
|
||||
```
|
||||
"container"
|
||||
```
|
||||
|
||||
### FormulationSlot(`unilabos_formulation`)
|
||||
|
||||
描述**液体配方**:向哪些物料容器中加入哪些液体及体积。填写为**对象数组**:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"sample_uuid": "",
|
||||
"well_name": "YB_PrepBottle_15mL_Carrier_bottle_A1",
|
||||
"liquids": [
|
||||
{ "name": "LiPF6", "volume": 0.6 },
|
||||
{ "name": "DMC", "volume": 1.2 }
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
#### 字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `sample_uuid` | string | 样品 UUID,无样品时传空字符串 `""` |
|
||||
| `well_name` | string | 目标物料容器的 **name**(从资源树中取物料节点的 `name` 字段,如瓶子、孔位名称) |
|
||||
| `liquids` | array | 要加入的液体列表 |
|
||||
| `liquids[].name` | string | 液体名称(如试剂名、溶剂名) |
|
||||
| `liquids[].volume` | number | 液体体积(单位由设备决定,通常为 mL) |
|
||||
|
||||
#### 填写规则
|
||||
|
||||
- `well_name` 必须是资源树中已存在的物料节点 `name`(不是 `id` 路径),通过 API #12 获取资源树后筛选
|
||||
- 每个数组元素代表一个目标容器的配方
|
||||
- 一个容器可以加入多种液体(`liquids` 数组多条记录)
|
||||
- 与 ResourceSlot 的区别:ResourceSlot 填 `{id, name, uuid}` 指向物料本身;FormulationSlot 用 `well_name` 引用物料,并附带液体配方信息
|
||||
|
||||
### 通过 API #12 获取资源树
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
|
||||
```
|
||||
|
||||
注意 `lab_uuid` 在路径中(不是查询参数)。资源树返回所有节点,每个节点包含 `id`(路径格式)、`name`、`uuid`、`type`、`parent` 等字段。填写 Slot 时需根据 placeholder 类型筛选正确的节点。
|
||||
|
||||
## 最终目录结构
|
||||
|
||||
```
|
||||
./<skill-name>/
|
||||
├── SKILL.md # API 端点 + 渐进加载指引
|
||||
├── action-index.md # 动作索引:描述/用途/核心参数
|
||||
└── actions/ # 每个 action 的完整 JSON Schema
|
||||
├── action1.json
|
||||
├── action2.json
|
||||
└── ...
|
||||
```
|
||||
@@ -1,200 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
从 req_device_registry_upload.json 中提取指定设备的 action schema。
|
||||
|
||||
用法:
|
||||
# 列出所有设备及 action 数量(自动搜索注册表文件)
|
||||
python extract_device_actions.py
|
||||
|
||||
# 指定注册表文件路径
|
||||
python extract_device_actions.py --registry <path/to/req_device_registry_upload.json>
|
||||
|
||||
# 提取指定设备的 action 到目录
|
||||
python extract_device_actions.py <device_id> <output_dir>
|
||||
python extract_device_actions.py --registry <path> <device_id> <output_dir>
|
||||
|
||||
示例:
|
||||
python extract_device_actions.py --registry unilabos_data/req_device_registry_upload.json
|
||||
python extract_device_actions.py liquid_handler.prcxi .cursor/skills/unilab-device-api/actions/
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
REGISTRY_FILENAME = "req_device_registry_upload.json"
|
||||
|
||||
def find_registry(explicit_path=None):
|
||||
"""
|
||||
查找 req_device_registry_upload.json 文件。
|
||||
|
||||
搜索优先级:
|
||||
1. 用户通过 --registry 显式指定的路径
|
||||
2. <cwd>/unilabos_data/req_device_registry_upload.json
|
||||
3. <cwd>/req_device_registry_upload.json
|
||||
4. <script所在目录>/../../.. (workspace根) 下的 unilabos_data/
|
||||
5. 向上逐级搜索父目录(最多 5 层)
|
||||
"""
|
||||
if explicit_path:
|
||||
if os.path.isfile(explicit_path):
|
||||
return explicit_path
|
||||
if os.path.isdir(explicit_path):
|
||||
fp = os.path.join(explicit_path, REGISTRY_FILENAME)
|
||||
if os.path.isfile(fp):
|
||||
return fp
|
||||
print(f"警告: 指定的路径不存在: {explicit_path}")
|
||||
return None
|
||||
|
||||
candidates = [
|
||||
os.path.join("unilabos_data", REGISTRY_FILENAME),
|
||||
REGISTRY_FILENAME,
|
||||
]
|
||||
|
||||
for c in candidates:
|
||||
if os.path.isfile(c):
|
||||
return c
|
||||
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
workspace_root = os.path.normpath(os.path.join(script_dir, "..", "..", ".."))
|
||||
for c in candidates:
|
||||
path = os.path.join(workspace_root, c)
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
|
||||
cwd = os.getcwd()
|
||||
for _ in range(5):
|
||||
parent = os.path.dirname(cwd)
|
||||
if parent == cwd:
|
||||
break
|
||||
cwd = parent
|
||||
for c in candidates:
|
||||
path = os.path.join(cwd, c)
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
|
||||
return None
|
||||
|
||||
def load_registry(path):
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
def list_devices(data):
|
||||
"""列出所有包含 action_value_mappings 的设备,同时返回 module 路径"""
|
||||
resources = data.get('resources', [])
|
||||
devices = []
|
||||
for res in resources:
|
||||
rid = res.get('id', '')
|
||||
cls = res.get('class', {})
|
||||
avm = cls.get('action_value_mappings', {})
|
||||
module = cls.get('module', '')
|
||||
if avm:
|
||||
devices.append((rid, len(avm), module))
|
||||
return devices
|
||||
|
||||
def flatten_schema_to_goal(action_data):
|
||||
"""将 schema 中嵌套的 goal 内容提升为顶层 schema,去掉 feedback/result 包装"""
|
||||
schema = action_data.get('schema', {})
|
||||
goal_schema = schema.get('properties', {}).get('goal', {})
|
||||
if goal_schema:
|
||||
action_data = dict(action_data)
|
||||
action_data['schema'] = goal_schema
|
||||
return action_data
|
||||
|
||||
|
||||
def extract_actions(data, device_id, output_dir):
|
||||
"""提取指定设备的 action schema 到独立 JSON 文件"""
|
||||
resources = data.get('resources', [])
|
||||
for res in resources:
|
||||
if res.get('id') == device_id:
|
||||
cls = res.get('class', {})
|
||||
module = cls.get('module', '')
|
||||
avm = cls.get('action_value_mappings', {})
|
||||
if not avm:
|
||||
print(f"设备 {device_id} 没有 action_value_mappings")
|
||||
return []
|
||||
|
||||
if module:
|
||||
py_path = module.split(":")[0].replace(".", "/") + ".py"
|
||||
class_name = module.split(":")[-1] if ":" in module else ""
|
||||
print(f"Python 源码: {py_path}")
|
||||
if class_name:
|
||||
print(f"设备类: {class_name}")
|
||||
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
written = []
|
||||
for action_name in sorted(avm.keys()):
|
||||
action_data = flatten_schema_to_goal(avm[action_name])
|
||||
filename = action_name.replace('-', '_') + '.json'
|
||||
filepath = os.path.join(output_dir, filename)
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
json.dump(action_data, f, indent=2, ensure_ascii=False)
|
||||
written.append(filename)
|
||||
print(f" {filepath}")
|
||||
return written
|
||||
|
||||
print(f"设备 {device_id} 未找到")
|
||||
return []
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
explicit_registry = None
|
||||
|
||||
if "--registry" in args:
|
||||
idx = args.index("--registry")
|
||||
if idx + 1 < len(args):
|
||||
explicit_registry = args[idx + 1]
|
||||
args = args[:idx] + args[idx + 2:]
|
||||
else:
|
||||
print("错误: --registry 需要指定路径")
|
||||
sys.exit(1)
|
||||
|
||||
registry_path = find_registry(explicit_registry)
|
||||
if not registry_path:
|
||||
print(f"错误: 找不到 {REGISTRY_FILENAME}")
|
||||
print()
|
||||
print("解决方法:")
|
||||
print(" 1. 先运行 unilab 启动命令,等待注册表生成")
|
||||
print(" 2. 用 --registry 指定文件路径:")
|
||||
print(f" python {sys.argv[0]} --registry <path/to/{REGISTRY_FILENAME}>")
|
||||
print()
|
||||
print("搜索过的路径:")
|
||||
for p in [
|
||||
os.path.join("unilabos_data", REGISTRY_FILENAME),
|
||||
REGISTRY_FILENAME,
|
||||
os.path.join("<workspace_root>", "unilabos_data", REGISTRY_FILENAME),
|
||||
]:
|
||||
print(f" - {p}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"注册表: {registry_path}")
|
||||
mtime = os.path.getmtime(registry_path)
|
||||
gen_time = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
|
||||
size_mb = os.path.getsize(registry_path) / (1024 * 1024)
|
||||
print(f"生成时间: {gen_time} (文件大小: {size_mb:.1f} MB)")
|
||||
data = load_registry(registry_path)
|
||||
|
||||
if len(args) == 0:
|
||||
devices = list_devices(data)
|
||||
print(f"\n找到 {len(devices)} 个设备:")
|
||||
print(f"{'设备 ID':<50} {'Actions':>7} {'Python 模块'}")
|
||||
print("-" * 120)
|
||||
for did, count, module in sorted(devices, key=lambda x: x[0]):
|
||||
py_path = module.split(":")[0].replace(".", "/") + ".py" if module else ""
|
||||
print(f"{did:<50} {count:>7} {py_path}")
|
||||
|
||||
elif len(args) == 2:
|
||||
device_id = args[0]
|
||||
output_dir = args[1]
|
||||
print(f"\n提取 {device_id} 的 actions 到 {output_dir}/")
|
||||
written = extract_actions(data, device_id, output_dir)
|
||||
if written:
|
||||
print(f"\n共写入 {len(written)} 个 action 文件")
|
||||
|
||||
else:
|
||||
print("用法:")
|
||||
print(" python extract_device_actions.py [--registry <path>] # 列出设备")
|
||||
print(" python extract_device_actions.py [--registry <path>] <device_id> <dir> # 提取 actions")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,69 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
从 ak/sk 生成 UniLab API Authorization header。
|
||||
|
||||
算法: base64(ak:sk) → "Authorization: Lab <token>"
|
||||
|
||||
用法:
|
||||
python gen_auth.py <ak> <sk>
|
||||
python gen_auth.py --config <config.py>
|
||||
|
||||
示例:
|
||||
python gen_auth.py myak mysk
|
||||
python gen_auth.py --config experiments/config.py
|
||||
"""
|
||||
import base64
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def gen_auth(ak: str, sk: str) -> str:
|
||||
token = base64.b64encode(f"{ak}:{sk}".encode("utf-8")).decode("utf-8")
|
||||
return token
|
||||
|
||||
|
||||
def extract_from_config(config_path: str) -> tuple:
|
||||
"""从 config.py 中提取 ak 和 sk"""
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
ak_match = re.search(r'''ak\s*=\s*["']([^"']+)["']''', content)
|
||||
sk_match = re.search(r'''sk\s*=\s*["']([^"']+)["']''', content)
|
||||
if not ak_match or not sk_match:
|
||||
return None, None
|
||||
return ak_match.group(1), sk_match.group(1)
|
||||
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
|
||||
if len(args) == 2 and args[0] == "--config":
|
||||
ak, sk = extract_from_config(args[1])
|
||||
if not ak or not sk:
|
||||
print(f"错误: 在 {args[1]} 中未找到 ak/sk 配置")
|
||||
print("期望格式: ak = \"xxx\" sk = \"xxx\"")
|
||||
sys.exit(1)
|
||||
print(f"配置文件: {args[1]}")
|
||||
elif len(args) == 2:
|
||||
ak, sk = args
|
||||
else:
|
||||
print("用法:")
|
||||
print(" python gen_auth.py <ak> <sk>")
|
||||
print(" python gen_auth.py --config <config.py>")
|
||||
sys.exit(1)
|
||||
|
||||
token = gen_auth(ak, sk)
|
||||
print(f"ak: {ak}")
|
||||
print(f"sk: {sk}")
|
||||
print()
|
||||
print(f"Authorization header:")
|
||||
print(f" Authorization: Lab {token}")
|
||||
print()
|
||||
print(f"curl 用法:")
|
||||
print(f' curl -H "Authorization: Lab {token}" ...')
|
||||
print()
|
||||
print(f"Shell 变量:")
|
||||
print(f' AUTH="Authorization: Lab {token}"')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,275 +0,0 @@
|
||||
---
|
||||
name: submit-agent-result
|
||||
description: Submit historical experiment results (agent_result) to Uni-Lab notebook — read data files, assemble JSON payload, PUT to cloud API. Use when the user wants to submit experiment results, upload agent results, report experiment data, or mentions agent_result/实验结果/历史记录/notebook结果.
|
||||
---
|
||||
|
||||
# 提交历史实验记录指南
|
||||
|
||||
通过云端 API 向已创建的 notebook 提交实验结果数据(agent_result)。支持从 JSON / CSV 文件读取数据,整合后提交。
|
||||
|
||||
## 前置条件(缺一不可)
|
||||
|
||||
使用本指南前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
|
||||
|
||||
### 1. ak / sk → AUTH
|
||||
|
||||
询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。
|
||||
|
||||
生成 AUTH token:
|
||||
|
||||
```bash
|
||||
python -c "import base64,sys; print(base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
|
||||
```
|
||||
|
||||
输出即为 token 值,拼接为 `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. notebook_uuid(**必须询问用户**)
|
||||
|
||||
**必须主动询问用户**:「请提供要提交结果的 notebook UUID。」
|
||||
|
||||
notebook_uuid 来自之前通过「批量提交实验」创建的实验批次,即 `POST /api/v1/lab/notebook` 返回的 `data.uuid`。
|
||||
|
||||
如果用户不记得,可提示:
|
||||
- 查看之前的对话记录中创建 notebook 时返回的 UUID
|
||||
- 或通过平台页面查找对应的 notebook
|
||||
|
||||
**绝不能跳过此步骤,没有 notebook_uuid 无法提交。**
|
||||
|
||||
### 4. 实验结果数据
|
||||
|
||||
用户需要提供实验结果数据,支持以下方式:
|
||||
|
||||
| 方式 | 说明 |
|
||||
|------|------|
|
||||
| JSON 文件 | 直接作为 `agent_result` 的内容合并 |
|
||||
| CSV 文件 | 转为 `{"文件名": [行数据...]}` 格式 |
|
||||
| 手动指定 | 用户直接告知 key-value 数据,由 agent 构建 JSON |
|
||||
|
||||
**四项全部就绪后才可开始。**
|
||||
|
||||
## Session State
|
||||
|
||||
在整个对话过程中,agent 需要记住以下状态:
|
||||
|
||||
- `lab_uuid` — 实验室 UUID(通过 API #1 自动获取,**不需要问用户**)
|
||||
- `notebook_uuid` — 目标 notebook UUID(**必须询问用户**)
|
||||
|
||||
## 请求约定
|
||||
|
||||
所有请求使用 `curl -s`,PUT 需加 `Content-Type: application/json`。
|
||||
|
||||
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名),示例中的 `curl` 均指 `curl.exe`。
|
||||
>
|
||||
> **PowerShell JSON 传参**:PowerShell 中 `-d '{"key":"value"}'` 会因引号转义失败。请将 JSON 写入临时文件,用 `-d '@tmp_body.json'`(单引号包裹 `@`,否则 `@` 会被 PowerShell 解析为 splatting 运算符导致报错)。
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### 1. 获取实验室信息(自动获取 lab_uuid)
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}}
|
||||
```
|
||||
|
||||
记住 `data.uuid` 为 `lab_uuid`。
|
||||
|
||||
### 2. 提交实验结果(agent_result)
|
||||
|
||||
```bash
|
||||
curl -s -X PUT "$BASE/api/v1/lab/notebook/agent-result" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '<request_body>'
|
||||
```
|
||||
|
||||
请求体结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"notebook_uuid": "<notebook_uuid>",
|
||||
"agent_result": {
|
||||
"<key1>": "<value1>",
|
||||
"<key2>": 123,
|
||||
"<nested_key>": {"a": 1, "b": 2},
|
||||
"<array_key>": [{"col1": "v1", "col2": "v2"}, ...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:HTTP 方法是 **PUT**(不是 POST)。
|
||||
|
||||
#### 必要字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `notebook_uuid` | string (UUID) | 目标 notebook 的 UUID,从批量提交实验时获取 |
|
||||
| `agent_result` | object | 实验结果数据,任意 JSON 对象 |
|
||||
|
||||
#### agent_result 内容格式
|
||||
|
||||
`agent_result` 接受**任意 JSON 对象**,常见格式:
|
||||
|
||||
**简单键值对**:
|
||||
```json
|
||||
{
|
||||
"avg_rtt_ms": 12.5,
|
||||
"status": "success",
|
||||
"test_count": 5
|
||||
}
|
||||
```
|
||||
|
||||
**包含嵌套结构**:
|
||||
```json
|
||||
{
|
||||
"summary": {"total": 100, "passed": 98, "failed": 2},
|
||||
"measurements": [
|
||||
{"sample_id": "S001", "value": 3.14, "unit": "mg/mL"},
|
||||
{"sample_id": "S002", "value": 2.71, "unit": "mg/mL"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**从 CSV 文件导入**(脚本自动转换):
|
||||
```json
|
||||
{
|
||||
"experiment_data": [
|
||||
{"温度": 25, "压力": 101.3, "产率": 0.85},
|
||||
{"温度": 30, "压力": 101.3, "产率": 0.91}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 整合脚本
|
||||
|
||||
本文档同级目录下的 `scripts/prepare_agent_result.py` 可自动读取文件并构建请求体。
|
||||
|
||||
### 用法
|
||||
|
||||
```bash
|
||||
python scripts/prepare_agent_result.py \
|
||||
--notebook-uuid <uuid> \
|
||||
--files data1.json data2.csv \
|
||||
[--auth <token>] \
|
||||
[--base <BASE_URL>] \
|
||||
[--submit] \
|
||||
[--output <output.json>]
|
||||
```
|
||||
|
||||
| 参数 | 必选 | 说明 |
|
||||
|------|------|------|
|
||||
| `--notebook-uuid` | 是 | 目标 notebook UUID |
|
||||
| `--files` | 是 | 输入文件路径(支持多个,JSON / CSV) |
|
||||
| `--auth` | 提交时必选 | Lab token(base64(ak:sk)) |
|
||||
| `--base` | 提交时必选 | API base URL |
|
||||
| `--submit` | 否 | 加上此标志则直接提交到云端 |
|
||||
| `--output` | 否 | 输出 JSON 路径(默认 `agent_result_body.json`) |
|
||||
|
||||
### 文件合并规则
|
||||
|
||||
| 文件类型 | 合并方式 |
|
||||
|----------|----------|
|
||||
| `.json`(dict) | 字段直接合并到 `agent_result` 顶层 |
|
||||
| `.json`(list/other) | 以文件名为 key 放入 `agent_result` |
|
||||
| `.csv` | 以文件名(不含扩展名)为 key,值为行对象数组 |
|
||||
|
||||
多个文件的字段会合并。JSON dict 中的重复 key 后者覆盖前者。
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 仅生成请求体文件(不提交)
|
||||
python scripts/prepare_agent_result.py \
|
||||
--notebook-uuid 73c67dca-c8cc-4936-85a0-329106aa7cca \
|
||||
--files results.json measurements.csv
|
||||
|
||||
# 生成并直接提交
|
||||
python scripts/prepare_agent_result.py \
|
||||
--notebook-uuid 73c67dca-c8cc-4936-85a0-329106aa7cca \
|
||||
--files results.json \
|
||||
--auth YTFmZDlkNGUt... \
|
||||
--base https://uni-lab.test.bohrium.com \
|
||||
--submit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 手动构建方式
|
||||
|
||||
如果不使用脚本,也可手动构建请求体:
|
||||
|
||||
1. 将实验结果数据组装为 JSON 对象
|
||||
2. 写入临时文件:
|
||||
|
||||
```json
|
||||
{
|
||||
"notebook_uuid": "<uuid>",
|
||||
"agent_result": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
3. 用 curl 提交:
|
||||
|
||||
```bash
|
||||
curl -s -X PUT "$BASE/api/v1/lab/notebook/agent-result" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '@tmp_body.json'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完整工作流 Checklist
|
||||
|
||||
```
|
||||
Task Progress:
|
||||
- [ ] Step 1: 确认 ak/sk → 生成 AUTH token
|
||||
- [ ] Step 2: 确认 --addr → 设置 BASE URL
|
||||
- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid
|
||||
- [ ] Step 4: **询问用户** notebook_uuid(必须,不可跳过)
|
||||
- [ ] Step 5: 确认实验结果数据来源(文件路径或手动数据)
|
||||
- [ ] Step 6: 运行 prepare_agent_result.py 或手动构建请求体
|
||||
- [ ] Step 7: PUT /lab/notebook/agent-result 提交
|
||||
- [ ] Step 8: 检查返回结果,确认提交成功
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: notebook_uuid 从哪里获取?
|
||||
|
||||
从之前「批量提交实验」时 `POST /api/v1/lab/notebook` 的返回值 `data.uuid` 获取。也可以在平台 UI 中查找对应的 notebook。
|
||||
|
||||
### Q: agent_result 有固定的 schema 吗?
|
||||
|
||||
没有严格 schema,接受任意 JSON 对象。但建议包含有意义的字段名和结构化数据,方便后续分析。
|
||||
|
||||
### Q: 可以多次提交同一个 notebook 的结果吗?
|
||||
|
||||
可以,后续提交会覆盖之前的 agent_result。
|
||||
|
||||
### Q: 认证方式是 Lab 还是 Api?
|
||||
|
||||
本指南统一使用 `Authorization: Lab <base64(ak:sk)>` 方式。如果用户有独立的 API Key,也可用 `Authorization: Api <key>` 替代。
|
||||
@@ -1,133 +0,0 @@
|
||||
"""
|
||||
读取实验结果文件(JSON / CSV),整合为 agent_result 请求体并可选提交。
|
||||
|
||||
用法:
|
||||
python prepare_agent_result.py \
|
||||
--notebook-uuid <uuid> \
|
||||
--files data1.json data2.csv \
|
||||
[--auth <Lab token>] \
|
||||
[--base <BASE_URL>] \
|
||||
[--submit] \
|
||||
[--output <output.json>]
|
||||
|
||||
支持的输入文件格式:
|
||||
- .json → 直接作为 dict 合并
|
||||
- .csv → 转为 {"filename": [row_dict, ...]} 格式
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
def read_json_file(filepath: str) -> Dict[str, Any]:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def read_csv_file(filepath: str) -> List[Dict[str, Any]]:
|
||||
rows = []
|
||||
with open(filepath, "r", encoding="utf-8-sig") as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
converted = {}
|
||||
for k, v in row.items():
|
||||
try:
|
||||
converted[k] = int(v)
|
||||
except (ValueError, TypeError):
|
||||
try:
|
||||
converted[k] = float(v)
|
||||
except (ValueError, TypeError):
|
||||
converted[k] = v
|
||||
rows.append(converted)
|
||||
return rows
|
||||
|
||||
|
||||
def merge_files(filepaths: List[str]) -> Dict[str, Any]:
|
||||
"""将多个文件合并为一个 agent_result dict"""
|
||||
merged: Dict[str, Any] = {}
|
||||
for fp in filepaths:
|
||||
path = Path(fp)
|
||||
ext = path.suffix.lower()
|
||||
key = path.stem
|
||||
|
||||
if ext == ".json":
|
||||
data = read_json_file(fp)
|
||||
if isinstance(data, dict):
|
||||
merged.update(data)
|
||||
else:
|
||||
merged[key] = data
|
||||
elif ext == ".csv":
|
||||
merged[key] = read_csv_file(fp)
|
||||
else:
|
||||
print(f"[警告] 不支持的文件格式: {fp},跳过", file=sys.stderr)
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
def build_request_body(notebook_uuid: str, agent_result: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {
|
||||
"notebook_uuid": notebook_uuid,
|
||||
"agent_result": agent_result,
|
||||
}
|
||||
|
||||
|
||||
def submit(base: str, auth: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
print("[错误] 提交需要 requests 库: pip install requests", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
url = f"{base}/api/v1/lab/notebook/agent-result"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Lab {auth}",
|
||||
}
|
||||
resp = requests.put(url, json=body, headers=headers, timeout=30)
|
||||
return {"status_code": resp.status_code, "body": resp.json() if resp.headers.get("content-type", "").startswith("application/json") else resp.text}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="整合实验结果文件并构建 agent_result 请求体")
|
||||
parser.add_argument("--notebook-uuid", required=True, help="目标 notebook UUID")
|
||||
parser.add_argument("--files", nargs="+", required=True, help="输入文件路径(JSON / CSV)")
|
||||
parser.add_argument("--auth", help="Lab token(base64(ak:sk))")
|
||||
parser.add_argument("--base", help="API base URL")
|
||||
parser.add_argument("--submit", action="store_true", help="直接提交到云端")
|
||||
parser.add_argument("--output", default="agent_result_body.json", help="输出 JSON 文件路径")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
for fp in args.files:
|
||||
if not os.path.exists(fp):
|
||||
print(f"[错误] 文件不存在: {fp}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
agent_result = merge_files(args.files)
|
||||
body = build_request_body(args.notebook_uuid, agent_result)
|
||||
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
json.dump(body, f, ensure_ascii=False, indent=2)
|
||||
print(f"[完成] 请求体已保存: {args.output}")
|
||||
print(f" notebook_uuid: {args.notebook_uuid}")
|
||||
print(f" agent_result 字段数: {len(agent_result)}")
|
||||
print(f" 合并文件数: {len(args.files)}")
|
||||
|
||||
if args.submit:
|
||||
if not args.auth or not args.base:
|
||||
print("[错误] 提交需要 --auth 和 --base 参数", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"\n[提交] PUT {args.base}/api/v1/lab/notebook/agent-result ...")
|
||||
result = submit(args.base, args.auth, body)
|
||||
print(f" HTTP {result['status_code']}")
|
||||
print(f" 响应: {json.dumps(result['body'], ensure_ascii=False)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
2
.github/workflows/ci-check.yml
vendored
2
.github/workflows/ci-check.yml
vendored
@@ -49,7 +49,7 @@ jobs:
|
||||
uv pip uninstall enum34 || echo enum34 not installed, skipping
|
||||
uv pip install .
|
||||
|
||||
- name: Run check mode (AST registry validation)
|
||||
- name: Run check mode (complete_registry)
|
||||
run: |
|
||||
call conda activate check-env
|
||||
echo Running check mode...
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,7 +5,6 @@ output/
|
||||
unilabos_data/
|
||||
pyrightconfig.json
|
||||
.cursorignore
|
||||
device_package*/
|
||||
## Python
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
|
||||
87
AGENTS.md
87
AGENTS.md
@@ -1,87 +0,0 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
Also follow the monorepo-level rules in `../AGENTS.md`.
|
||||
|
||||
## Build & Development
|
||||
|
||||
```bash
|
||||
# Install in editable mode (requires mamba env with python 3.11)
|
||||
pip install -e .
|
||||
uv pip install -r unilabos/utils/requirements.txt
|
||||
|
||||
# Run with a device graph
|
||||
unilab --graph <graph.json> --config <config.py> --backend ros
|
||||
unilab --graph <graph.json> --config <config.py> --backend simple # no ROS2 needed
|
||||
|
||||
# Common CLI flags
|
||||
unilab --app_bridges websocket fastapi # communication bridges
|
||||
unilab --test_mode # simulate hardware, no real execution
|
||||
unilab --check_mode # CI validation of registry imports
|
||||
unilab --skip_env_check # skip auto-install of dependencies
|
||||
unilab --visual rviz|web|disable # visualization mode
|
||||
unilab --is_slave # run as slave node
|
||||
|
||||
# Workflow upload subcommand
|
||||
unilab workflow_upload -f <workflow.json> -n <name> --tags tag1 tag2
|
||||
|
||||
# Tests
|
||||
pytest tests/ # all tests
|
||||
pytest tests/resources/test_resourcetreeset.py # single test file
|
||||
pytest tests/resources/test_resourcetreeset.py::TestClassName::test_method # single test
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Startup Flow
|
||||
|
||||
`unilab` CLI → `unilabos/app/main.py:main()` → loads config → builds registry → reads device graph (JSON/GraphML) → starts backend thread (ROS2/simple) → starts FastAPI web server + WebSocket client.
|
||||
|
||||
### Core Layers
|
||||
|
||||
**Registry** (`unilabos/registry/`): Singleton `Registry` class discovers and catalogs all device types, resource types, and communication devices from YAML definitions. Device types live in `registry/devices/*.yaml`, resources in `registry/resources/`, comms in `registry/device_comms/`. The registry resolves class paths to actual Python classes via `utils/import_manager.py`.
|
||||
|
||||
**Resource Tracking** (`unilabos/resources/resource_tracker.py`): Pydantic-based `ResourceDict` → `ResourceDictInstance` → `ResourceTreeSet` hierarchy. `ResourceTreeSet` is the canonical in-memory representation of all devices and resources, used throughout the system. Graph I/O is in `resources/graphio.py` (reads JSON/GraphML device topology files into `nx.Graph` + `ResourceTreeSet`).
|
||||
|
||||
**Device Drivers** (`unilabos/devices/`): 30+ hardware drivers organized by device type (liquid_handling, hplc, balance, arm, etc.). Each driver is a Python class that gets wrapped by `ros/device_node_wrapper.py:ros2_device_node()` to become a ROS2 node with publishers, subscribers, and action servers.
|
||||
|
||||
**ROS2 Layer** (`unilabos/ros/`): `device_node_wrapper.py` dynamically wraps any device class into `ROS2DeviceNode` (defined in `ros/nodes/base_device_node.py`). Preset node types in `ros/nodes/presets/` include `host_node`, `controller_node`, `workstation`, `serial_node`, `camera`. Messages use custom `unilabos_msgs` (pre-built, distributed via releases).
|
||||
|
||||
**Protocol Compilation** (`unilabos/compile/`): 20+ protocol compilers (add, centrifuge, dissolve, filter, heatchill, stir, pump, etc.) that transform YAML protocol definitions into executable sequences.
|
||||
|
||||
**Communication** (`unilabos/device_comms/`): Hardware communication adapters — OPC-UA client, Modbus PLC, RPC, and a universal driver. `app/communication.py` provides a factory pattern for WebSocket client connections to the cloud.
|
||||
|
||||
**Web/API** (`unilabos/app/web/`): FastAPI server with REST API (`api.py`), Jinja2 template pages (`pages.py`), and HTTP client for cloud communication (`client.py`). Runs on port 8002 by default.
|
||||
|
||||
### Configuration System
|
||||
|
||||
- **Config classes** in `unilabos/config/config.py`: `BasicConfig`, `WSConfig`, `HTTPConfig`, `ROSConfig` — all class-level attributes, loaded from Python config files
|
||||
- Config files are `.py` files with matching class names (see `config/example_config.py`)
|
||||
- Environment variables override with prefix `UNILABOS_` (e.g., `UNILABOS_BASICCONFIG_PORT=9000`)
|
||||
- Device topology defined in graph files (JSON with node-link format, or GraphML)
|
||||
|
||||
### Key Data Flow
|
||||
|
||||
1. Graph file → `graphio.read_node_link_json()` → `(nx.Graph, ResourceTreeSet, resource_links)`
|
||||
2. `ResourceTreeSet` + `Registry` → `initialize_device.initialize_device_from_dict()` → `ROS2DeviceNode` instances
|
||||
3. Device nodes communicate via ROS2 topics/actions or direct Python calls (simple backend)
|
||||
4. Cloud sync via WebSocket (`app/ws_client.py`) and HTTP (`app/web/client.py`)
|
||||
|
||||
### Test Data
|
||||
|
||||
Example device graphs and experiment configs are in `unilabos/test/experiments/` (not `tests/`). Registry test fixtures in `unilabos/test/registry/`.
|
||||
|
||||
## Code Conventions
|
||||
|
||||
- Code comments and log messages in simplified Chinese
|
||||
- Python 3.11+, type hints expected
|
||||
- Pydantic models for data validation (`resource_tracker.py`)
|
||||
- Singleton pattern via `@singleton` decorator (`utils/decorator.py`)
|
||||
- Dynamic class loading via `utils/import_manager.py` — device classes resolved at runtime from registry YAML paths
|
||||
- CLI argument dashes auto-converted to underscores for consistency
|
||||
|
||||
## Licensing
|
||||
|
||||
- Framework code: GPL-3.0
|
||||
- Device drivers (`unilabos/devices/`): DP Technology Proprietary License — do not redistribute
|
||||
@@ -15,9 +15,6 @@ Python 类设备驱动在完成注册表后可以直接在 Uni-Lab 中使用,
|
||||
**示例:**
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import device, topic_config
|
||||
|
||||
@device(id="mock_gripper", category=["gripper"], description="Mock Gripper")
|
||||
class MockGripper:
|
||||
def __init__(self):
|
||||
self._position: float = 0.0
|
||||
@@ -26,23 +23,19 @@ class MockGripper:
|
||||
self._status = "Idle"
|
||||
|
||||
@property
|
||||
@topic_config() # 添加 @topic_config 才会定时广播
|
||||
def position(self) -> float:
|
||||
return self._position
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def velocity(self) -> float:
|
||||
return self._velocity
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def torque(self) -> float:
|
||||
return self._torque
|
||||
|
||||
# 使用 @topic_config 装饰的属性,接入 Uni-Lab 时会定时对外广播
|
||||
# 会被自动识别的设备属性,接入 Uni-Lab 时会定时对外广播
|
||||
@property
|
||||
@topic_config(period=2.0) # 可自定义发布周期
|
||||
def status(self) -> str:
|
||||
return self._status
|
||||
|
||||
@@ -156,7 +149,7 @@ my_device: # 设备唯一标识符
|
||||
|
||||
系统会自动分析您的 Python 驱动类并生成:
|
||||
|
||||
- `status_types`:从 `@topic_config` 装饰的 `@property` 或方法自动识别状态属性
|
||||
- `status_types`:从 `@property` 装饰的方法自动识别状态属性
|
||||
- `action_value_mappings`:从类方法自动生成动作映射
|
||||
- `init_param_schema`:从 `__init__` 方法分析初始化参数
|
||||
- `schema`:前端显示用的属性类型定义
|
||||
@@ -186,9 +179,7 @@ Uni-Lab 设备驱动是一个 Python 类,需要遵循以下结构:
|
||||
|
||||
```python
|
||||
from typing import Dict, Any
|
||||
from unilabos.registry.decorators import device, topic_config
|
||||
|
||||
@device(id="my_device", category=["general"], description="My Device")
|
||||
class MyDevice:
|
||||
"""设备类文档字符串
|
||||
|
||||
@@ -207,9 +198,8 @@ class MyDevice:
|
||||
# 初始化硬件连接
|
||||
|
||||
@property
|
||||
@topic_config() # 必须添加 @topic_config 才会广播
|
||||
def status(self) -> str:
|
||||
"""设备状态(通过 @topic_config 广播)"""
|
||||
"""设备状态(会自动广播)"""
|
||||
return self._status
|
||||
|
||||
def my_action(self, param: float) -> Dict[str, Any]:
|
||||
@@ -227,61 +217,34 @@ class MyDevice:
|
||||
|
||||
## 状态属性 vs 动作方法
|
||||
|
||||
### 状态属性(@property + @topic_config)
|
||||
### 状态属性(@property)
|
||||
|
||||
状态属性需要同时使用 `@property` 和 `@topic_config` 装饰器才会被识别并定期广播:
|
||||
状态属性会被自动识别并定期广播:
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import topic_config
|
||||
|
||||
@property
|
||||
@topic_config() # 必须添加,否则不会广播
|
||||
def temperature(self) -> float:
|
||||
"""当前温度"""
|
||||
return self._read_temperature()
|
||||
|
||||
@property
|
||||
@topic_config(period=2.0) # 可自定义发布周期(秒)
|
||||
def status(self) -> str:
|
||||
"""设备状态: idle, running, error"""
|
||||
return self._status
|
||||
|
||||
@property
|
||||
@topic_config(name="ready") # 可自定义发布名称
|
||||
def is_ready(self) -> bool:
|
||||
"""设备是否就绪"""
|
||||
return self._status == "idle"
|
||||
```
|
||||
|
||||
也可以使用普通方法(非 @property)配合 `@topic_config`:
|
||||
|
||||
```python
|
||||
@topic_config(period=10.0)
|
||||
def get_sensor_data(self) -> Dict[str, float]:
|
||||
"""获取传感器数据(get_ 前缀会自动去除,发布名为 sensor_data)"""
|
||||
return {"temp": self._temp, "humidity": self._humidity}
|
||||
```
|
||||
|
||||
**`@topic_config` 参数**:
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `period` | float | 5.0 | 发布周期(秒) |
|
||||
| `print_publish` | bool | 节点默认 | 是否打印发布日志 |
|
||||
| `qos` | int | 10 | QoS 深度 |
|
||||
| `name` | str | None | 自定义发布名称 |
|
||||
|
||||
**发布名称优先级**:`@topic_config(name=...)` > `get_` 前缀去除 > 方法名
|
||||
|
||||
**特点**:
|
||||
|
||||
- 必须使用 `@topic_config` 装饰器
|
||||
- 支持 `@property` 和普通方法
|
||||
- 添加到注册表的 `status_types`
|
||||
- 使用`@property`装饰器
|
||||
- 只读,不能有参数
|
||||
- 自动添加到注册表的`status_types`
|
||||
- 定期发布到 ROS2 topic
|
||||
|
||||
> **⚠️ 重要:** 仅有 `@property` 装饰器而没有 `@topic_config` 的属性**不会**被广播。这是一个 Breaking Change。
|
||||
|
||||
### 动作方法
|
||||
|
||||
动作方法是设备可以执行的操作:
|
||||
@@ -534,7 +497,6 @@ class LiquidHandler:
|
||||
self._status = "idle"
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def status(self) -> str:
|
||||
return self._status
|
||||
|
||||
@@ -924,52 +886,7 @@ class MyDevice:
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 使用 `@device` 装饰器标识设备类
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import device
|
||||
|
||||
@device(id="my_device", category=["heating"], description="My Heating Device", icon="heater.webp")
|
||||
class MyDevice:
|
||||
...
|
||||
```
|
||||
|
||||
- `id`:设备唯一标识符,用于注册表匹配
|
||||
- `category`:分类列表,前端用于分组显示
|
||||
- `description`:设备描述
|
||||
- `icon`:图标文件名(可选)
|
||||
|
||||
### 2. 使用 `@topic_config` 声明需要广播的状态
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import topic_config
|
||||
|
||||
# ✓ @property + @topic_config → 会广播
|
||||
@property
|
||||
@topic_config(period=2.0)
|
||||
def temperature(self) -> float:
|
||||
return self._temp
|
||||
|
||||
# ✓ 普通方法 + @topic_config → 会广播(get_ 前缀自动去除)
|
||||
@topic_config(period=10.0)
|
||||
def get_sensor_data(self) -> Dict[str, float]:
|
||||
return {"temp": self._temp}
|
||||
|
||||
# ✓ 使用 name 参数自定义发布名称
|
||||
@property
|
||||
@topic_config(name="ready")
|
||||
def is_ready(self) -> bool:
|
||||
return self._status == "idle"
|
||||
|
||||
# ✗ 仅有 @property,没有 @topic_config → 不会广播
|
||||
@property
|
||||
def internal_state(self) -> str:
|
||||
return self._state
|
||||
```
|
||||
|
||||
> **注意:** 与 `@property` 连用时,`@topic_config` 必须放在 `@property` 下面。
|
||||
|
||||
### 3. 类型注解
|
||||
### 1. 类型注解
|
||||
|
||||
```python
|
||||
from typing import Dict, Any, Optional, List
|
||||
@@ -984,7 +901,7 @@ def method(
|
||||
pass
|
||||
```
|
||||
|
||||
### 4. 文档字符串
|
||||
### 2. 文档字符串
|
||||
|
||||
```python
|
||||
def method(self, param: float) -> Dict[str, Any]:
|
||||
@@ -1006,7 +923,7 @@ def method(self, param: float) -> Dict[str, Any]:
|
||||
pass
|
||||
```
|
||||
|
||||
### 5. 配置验证
|
||||
### 3. 配置验证
|
||||
|
||||
```python
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
@@ -1020,7 +937,7 @@ def __init__(self, config: Dict[str, Any]):
|
||||
self.baudrate = config['baudrate']
|
||||
```
|
||||
|
||||
### 6. 资源清理
|
||||
### 4. 资源清理
|
||||
|
||||
```python
|
||||
def __del__(self):
|
||||
@@ -1029,7 +946,7 @@ def __del__(self):
|
||||
self.connection.close()
|
||||
```
|
||||
|
||||
### 7. 设计前端友好的返回值
|
||||
### 5. 设计前端友好的返回值
|
||||
|
||||
**记住:返回值会直接显示在 Web 界面**
|
||||
|
||||
|
||||
@@ -422,20 +422,18 @@ placeholder_keys:
|
||||
|
||||
### status_types
|
||||
|
||||
系统会扫描你的 Python 类,从带有 `@topic_config` 装饰器的 `@property` 或方法自动生成这部分:
|
||||
系统会扫描你的 Python 类,从状态方法(property 或 get\_方法)自动生成这部分:
|
||||
|
||||
```yaml
|
||||
status_types:
|
||||
current_temperature: float # 从 @topic_config 装饰的 @property 或方法
|
||||
is_heating: bool
|
||||
status: str
|
||||
current_temperature: float # 从 get_current_temperature() 或 @property current_temperature
|
||||
is_heating: bool # 从 get_is_heating() 或 @property is_heating
|
||||
status: str # 从 get_status() 或 @property status
|
||||
```
|
||||
|
||||
**注意事项**:
|
||||
|
||||
- 仅有带 `@topic_config` 装饰器的 `@property` 或方法才会被识别为状态属性
|
||||
- 没有 `@topic_config` 的 `@property` 不会生成 status_types,也不会广播
|
||||
- `get_` 前缀的方法名会自动去除前缀(如 `get_temperature` → `temperature`)
|
||||
- 系统会查找所有 `get_` 开头的方法和 `@property` 装饰的属性
|
||||
- 类型会自动转成相应的类型(如 `str`、`float`、`bool`)
|
||||
- 如果类型是 `Any`、`None` 或未知的,默认使用 `String`
|
||||
|
||||
@@ -539,13 +537,11 @@ class AdvancedLiquidHandler:
|
||||
self._temperature = 25.0
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def status(self) -> str:
|
||||
"""设备状态"""
|
||||
return self._status
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def temperature(self) -> float:
|
||||
"""当前温度"""
|
||||
return self._temperature
|
||||
@@ -813,23 +809,21 @@ my_temperature_controller:
|
||||
你的设备类需要符合以下要求:
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import device, topic_config
|
||||
from unilabos.common.device_base import DeviceBase
|
||||
|
||||
@device(id="my_device", category=["temperature"], description="My Device")
|
||||
class MyDevice:
|
||||
class MyDevice(DeviceBase):
|
||||
def __init__(self, config):
|
||||
"""初始化,参数会自动分析到 init_param_schema.config"""
|
||||
super().__init__(config)
|
||||
self.port = config.get('port', '/dev/ttyUSB0')
|
||||
|
||||
# 状态方法(必须添加 @topic_config 才会生成到 status_types 并广播)
|
||||
# 状态方法(会自动生成到 status_types)
|
||||
@property
|
||||
@topic_config()
|
||||
def status(self):
|
||||
"""返回设备状态"""
|
||||
return "idle"
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def temperature(self):
|
||||
"""返回当前温度"""
|
||||
return 25.0
|
||||
@@ -1045,34 +1039,7 @@ resource.type # "resource"
|
||||
|
||||
### 代码规范
|
||||
|
||||
1. **使用 `@device` 装饰器标识设备类**
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import device
|
||||
|
||||
@device(id="my_device", category=["heating"], description="My Device")
|
||||
class MyDevice:
|
||||
...
|
||||
```
|
||||
|
||||
2. **使用 `@topic_config` 声明广播属性**
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import topic_config
|
||||
|
||||
# ✓ 需要广播的状态属性
|
||||
@property
|
||||
@topic_config(period=2.0)
|
||||
def temperature(self) -> float:
|
||||
return self._temp
|
||||
|
||||
# ✗ 仅有 @property 不会广播
|
||||
@property
|
||||
def internal_counter(self) -> int:
|
||||
return self._counter
|
||||
```
|
||||
|
||||
3. **始终使用类型注解**
|
||||
1. **始终使用类型注解**
|
||||
|
||||
```python
|
||||
# ✓ 好
|
||||
@@ -1084,7 +1051,7 @@ def method(self, resource, device):
|
||||
pass
|
||||
```
|
||||
|
||||
4. **提供有意义的参数名**
|
||||
2. **提供有意义的参数名**
|
||||
|
||||
```python
|
||||
# ✓ 好 - 清晰的参数名
|
||||
@@ -1096,7 +1063,7 @@ def transfer(self, r1: ResourceSlot, r2: ResourceSlot):
|
||||
pass
|
||||
```
|
||||
|
||||
5. **使用 Optional 表示可选参数**
|
||||
3. **使用 Optional 表示可选参数**
|
||||
|
||||
```python
|
||||
from typing import Optional
|
||||
@@ -1109,7 +1076,7 @@ def method(
|
||||
pass
|
||||
```
|
||||
|
||||
6. **添加详细的文档字符串**
|
||||
4. **添加详细的文档字符串**
|
||||
|
||||
```python
|
||||
def method(
|
||||
@@ -1129,13 +1096,13 @@ def method(
|
||||
pass
|
||||
```
|
||||
|
||||
7. **方法命名规范**
|
||||
5. **方法命名规范**
|
||||
|
||||
- 状态方法使用 `@property` + `@topic_config` 装饰器,或普通方法 + `@topic_config`
|
||||
- 状态方法使用 `@property` 装饰器或 `get_` 前缀
|
||||
- 动作方法使用动词开头
|
||||
- 保持命名清晰、一致
|
||||
|
||||
8. **完善的错误处理**
|
||||
6. **完善的错误处理**
|
||||
- 实现完善的错误处理
|
||||
- 添加日志记录
|
||||
- 提供有意义的错误信息
|
||||
|
||||
@@ -221,10 +221,10 @@ Laboratory A Laboratory B
|
||||
|
||||
```bash
|
||||
# 实验室A
|
||||
unilab --ak your_ak --sk your_sk --upload_registry
|
||||
unilab --ak your_ak --sk your_sk --upload_registry --use_remote_resource
|
||||
|
||||
# 实验室B
|
||||
unilab --ak your_ak --sk your_sk --upload_registry
|
||||
unilab --ak your_ak --sk your_sk --upload_registry --use_remote_resource
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -22,6 +22,7 @@ options:
|
||||
--is_slave Run the backend as slave node (without host privileges).
|
||||
--slave_no_host Skip waiting for host service in slave mode
|
||||
--upload_registry Upload registry information when starting unilab
|
||||
--use_remote_resource Use remote resources when starting unilab
|
||||
--config CONFIG Configuration file path, supports .py format Python config files
|
||||
--port PORT Port for web service information page
|
||||
--disable_browser Disable opening information page on startup
|
||||
@@ -84,7 +85,7 @@ Uni-Lab 的启动过程分为以下几个阶段:
|
||||
支持两种方式:
|
||||
|
||||
- **本地文件**:使用 `-g` 指定图谱文件(支持 JSON 和 GraphML 格式)
|
||||
- **远程资源**:不指定本地文件即可
|
||||
- **远程资源**:使用 `--use_remote_resource` 从云端获取
|
||||
|
||||
### 7. 注册表构建
|
||||
|
||||
@@ -195,7 +196,7 @@ unilab --config path/to/your/config.py
|
||||
unilab --ak your_ak --sk your_sk -g path/to/graph.json --upload_registry
|
||||
|
||||
# 使用远程资源启动
|
||||
unilab --ak your_ak --sk your_sk
|
||||
unilab --ak your_ak --sk your_sk --use_remote_resource
|
||||
|
||||
# 更新注册表
|
||||
unilab --ak your_ak --sk your_sk --complete_registry
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: ros-humble-unilabos-msgs
|
||||
version: 0.10.19
|
||||
version: 0.10.18
|
||||
source:
|
||||
path: ../../unilabos_msgs
|
||||
target_directory: src
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: unilabos
|
||||
version: "0.10.19"
|
||||
version: "0.10.18"
|
||||
|
||||
source:
|
||||
path: ../..
|
||||
|
||||
@@ -2,6 +2,7 @@ import json
|
||||
import logging
|
||||
import traceback
|
||||
import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import networkx as nx
|
||||
@@ -24,15 +25,7 @@ class SimpleGraph:
|
||||
|
||||
def add_edge(self, source, 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
|
||||
}
|
||||
edge = {"source": source, "target": target, **attrs}
|
||||
self.edges.append(edge)
|
||||
|
||||
def to_dict(self):
|
||||
@@ -49,7 +42,6 @@ class SimpleGraph:
|
||||
"multigraph": False,
|
||||
"graph": {},
|
||||
"nodes": nodes_list,
|
||||
"edges": self.edges,
|
||||
"links": self.edges,
|
||||
}
|
||||
|
||||
@@ -66,8 +58,495 @@ def extract_json_from_markdown(text: str) -> str:
|
||||
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(
|
||||
|
||||
2
setup.py
2
setup.py
@@ -4,7 +4,7 @@ package_name = 'unilabos'
|
||||
|
||||
setup(
|
||||
name=package_name,
|
||||
version='0.10.19',
|
||||
version='0.10.18',
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
install_requires=['setuptools'],
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.10.19"
|
||||
__version__ = "0.10.18"
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
@@ -26,84 +24,6 @@ from unilabos.config.config import load_config, BasicConfig, HTTPConfig
|
||||
_restart_requested: bool = False
|
||||
_restart_reason: str = ""
|
||||
|
||||
RESTART_EXIT_CODE = 42
|
||||
|
||||
|
||||
def _build_child_argv():
|
||||
"""Build sys.argv for child process, stripping supervisor-only arguments."""
|
||||
result = []
|
||||
skip_next = False
|
||||
for arg in sys.argv:
|
||||
if skip_next:
|
||||
skip_next = False
|
||||
continue
|
||||
if arg in ("--restart_mode", "--restart-mode"):
|
||||
continue
|
||||
if arg in ("--auto_restart_count", "--auto-restart-count"):
|
||||
skip_next = True
|
||||
continue
|
||||
if arg.startswith("--auto_restart_count=") or arg.startswith("--auto-restart-count="):
|
||||
continue
|
||||
result.append(arg)
|
||||
return result
|
||||
|
||||
|
||||
def _run_as_supervisor(max_restarts: int):
|
||||
"""
|
||||
Supervisor process that spawns and monitors child processes.
|
||||
|
||||
Similar to Uvicorn's --reload: the supervisor itself does no heavy work,
|
||||
it only launches the real process as a child and restarts it when the child
|
||||
exits with RESTART_EXIT_CODE.
|
||||
"""
|
||||
child_argv = [sys.executable] + _build_child_argv()
|
||||
restart_count = 0
|
||||
|
||||
print_status(
|
||||
f"[Supervisor] Restart mode enabled (max restarts: {max_restarts}), "
|
||||
f"child command: {' '.join(child_argv)}",
|
||||
"info",
|
||||
)
|
||||
|
||||
while True:
|
||||
print_status(
|
||||
f"[Supervisor] Launching process (restart {restart_count}/{max_restarts})...",
|
||||
"info",
|
||||
)
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(child_argv)
|
||||
exit_code = process.wait()
|
||||
except KeyboardInterrupt:
|
||||
print_status("[Supervisor] Interrupted, terminating child process...", "info")
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
sys.exit(1)
|
||||
|
||||
if exit_code == RESTART_EXIT_CODE:
|
||||
restart_count += 1
|
||||
if restart_count > max_restarts:
|
||||
print_status(
|
||||
f"[Supervisor] Maximum restart count ({max_restarts}) reached, exiting",
|
||||
"warning",
|
||||
)
|
||||
sys.exit(1)
|
||||
print_status(
|
||||
f"[Supervisor] Child requested restart ({restart_count}/{max_restarts}), restarting in 2s...",
|
||||
"info",
|
||||
)
|
||||
time.sleep(2)
|
||||
else:
|
||||
if exit_code != 0:
|
||||
print_status(f"[Supervisor] Child exited with code {exit_code}", "warning")
|
||||
else:
|
||||
print_status("[Supervisor] Child exited normally", "info")
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
def load_config_from_file(config_path):
|
||||
if config_path is None:
|
||||
@@ -145,13 +65,6 @@ def parse_args():
|
||||
action="append",
|
||||
help="Path to the registry directory",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--devices",
|
||||
type=str,
|
||||
default=None,
|
||||
action="append",
|
||||
help="Path to Python code directory for AST-based device/resource scanning",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--working_dir",
|
||||
type=str,
|
||||
@@ -241,18 +154,18 @@ def parse_args():
|
||||
action="store_true",
|
||||
help="Skip environment dependency check on startup",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--complete_registry",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Complete registry information",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--check_mode",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Run in check mode for CI: validates registry imports and ensures no file changes",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--complete_registry",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Complete and rewrite YAML registry files using AST analysis results",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no_update_feedback",
|
||||
action="store_true",
|
||||
@@ -264,30 +177,6 @@ def parse_args():
|
||||
default=False,
|
||||
help="Test mode: all actions simulate execution and return mock results without running real hardware",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--external_devices_only",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Only load external device packages (--devices), skip built-in unilabos/devices/ scanning and YAML device registry",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--extra_resource",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Load extra lab_ prefixed labware resources (529 auto-generated definitions from lab_resources.py)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--restart_mode",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Enable supervisor mode: automatically restart the process when triggered via WebSocket",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--auto_restart_count",
|
||||
type=int,
|
||||
default=500,
|
||||
help="Maximum number of automatic restarts in restart mode (default: 500)",
|
||||
)
|
||||
# workflow upload subcommand
|
||||
workflow_parser = subparsers.add_parser(
|
||||
"workflow_upload",
|
||||
@@ -338,28 +227,16 @@ def main():
|
||||
args = parser.parse_args()
|
||||
args_dict = vars(args)
|
||||
|
||||
# Supervisor mode: spawn child processes and monitor for restart
|
||||
if args_dict.get("restart_mode", False):
|
||||
_run_as_supervisor(args_dict.get("auto_restart_count", 5))
|
||||
return
|
||||
|
||||
# 环境检查 - 检查并自动安装必需的包 (可选)
|
||||
skip_env_check = args_dict.get("skip_env_check", False)
|
||||
check_mode = args_dict.get("check_mode", False)
|
||||
|
||||
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):
|
||||
print_status("环境检查失败,程序退出", "error")
|
||||
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:
|
||||
print_status("跳过环境依赖检查", "warning")
|
||||
|
||||
@@ -480,63 +357,46 @@ def main():
|
||||
BasicConfig.test_mode = args_dict.get("test_mode", False)
|
||||
if BasicConfig.test_mode:
|
||||
print_status("启用测试模式:所有动作将模拟执行,不调用真实硬件", "warning")
|
||||
BasicConfig.extra_resource = args_dict.get("extra_resource", False)
|
||||
if BasicConfig.extra_resource:
|
||||
print_status("启用额外资源加载:将加载lab_开头的labware资源定义", "info")
|
||||
BasicConfig.communication_protocol = "websocket"
|
||||
machine_name = platform.node()
|
||||
machine_name = os.popen("hostname").read().strip()
|
||||
machine_name = "".join([c if c.isalnum() or c == "_" else "_" for c in machine_name])
|
||||
BasicConfig.machine_name = machine_name
|
||||
BasicConfig.vis_2d_enable = args_dict["2d_vis"]
|
||||
BasicConfig.check_mode = check_mode
|
||||
|
||||
from unilabos.registry.registry import build_registry
|
||||
|
||||
# 显示启动横幅
|
||||
print_unilab_banner(args_dict)
|
||||
|
||||
# Step 0: AST 分析优先 + YAML 注册表加载
|
||||
# check_mode 和 upload_registry 都会执行实际 import 验证
|
||||
devices_dirs = args_dict.get("devices", None)
|
||||
complete_registry = args_dict.get("complete_registry", False) or check_mode
|
||||
external_only = args_dict.get("external_devices_only", False)
|
||||
lab_registry = build_registry(
|
||||
registry_paths=args_dict["registry_path"],
|
||||
devices_dirs=devices_dirs,
|
||||
upload_registry=BasicConfig.upload_registry,
|
||||
check_mode=check_mode,
|
||||
complete_registry=complete_registry,
|
||||
external_only=external_only,
|
||||
)
|
||||
|
||||
# Check mode: 注册表验证完成后直接退出
|
||||
if check_mode:
|
||||
device_count = len(lab_registry.device_type_registry)
|
||||
resource_count = len(lab_registry.resource_type_registry)
|
||||
print_status(f"Check mode: 注册表验证完成 ({device_count} 设备, {resource_count} 资源),退出", "info")
|
||||
os._exit(0)
|
||||
|
||||
# 以下导入依赖 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.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
|
||||
|
||||
# Step 1: 上传全部注册表到服务端,同步保存到 unilabos_data
|
||||
# 显示启动横幅
|
||||
print_unilab_banner(args_dict)
|
||||
|
||||
# 注册表 - check_mode 时强制启用 complete_registry
|
||||
complete_registry = args_dict.get("complete_registry", False) or check_mode
|
||||
lab_registry = build_registry(args_dict["registry_path"], complete_registry, BasicConfig.upload_registry)
|
||||
|
||||
# Check mode: complete_registry 完成后直接退出,git diff 检测由 CI workflow 执行
|
||||
if check_mode:
|
||||
print_status("Check mode: complete_registry 完成,退出", "info")
|
||||
os._exit(0)
|
||||
|
||||
if BasicConfig.upload_registry:
|
||||
# 设备注册到服务端 - 需要 ak 和 sk
|
||||
if BasicConfig.ak and BasicConfig.sk:
|
||||
# print_status("开始注册设备到服务端...", "info")
|
||||
print_status("开始注册设备到服务端...", "info")
|
||||
try:
|
||||
register_devices_and_resources(lab_registry)
|
||||
# print_status("设备注册完成", "info")
|
||||
print_status("设备注册完成", "info")
|
||||
except Exception as e:
|
||||
print_status(f"设备注册失败: {e}", "error")
|
||||
else:
|
||||
@@ -621,16 +481,12 @@ def main():
|
||||
continue
|
||||
|
||||
# 如果从远端获取了物料信息,则与本地物料进行同步
|
||||
if file_path is not None and request_startup_json and "nodes" in request_startup_json:
|
||||
if request_startup_json and "nodes" in request_startup_json:
|
||||
print_status("开始同步远端物料到本地...", "info")
|
||||
remote_tree_set = ResourceTreeSet.from_raw_dict_list(request_startup_json["nodes"])
|
||||
resource_tree_set.merge_remote_resources(remote_tree_set)
|
||||
print_status("远端物料同步完成", "info")
|
||||
|
||||
# 第二次设备包依赖检查:云端物料同步后,community 包可能引入新的 requirements
|
||||
# TODO: 当 community device package 功能上线后,在这里调用
|
||||
# install_requirements_txt(community_pkg_path / "requirements.txt", label="community.xxx")
|
||||
|
||||
# 使用 ResourceTreeSet 代替 list
|
||||
args_dict["resources_config"] = resource_tree_set
|
||||
args_dict["devices_config"] = resource_tree_set
|
||||
@@ -722,10 +578,6 @@ def main():
|
||||
open_browser=not args_dict["disable_browser"],
|
||||
port=BasicConfig.port,
|
||||
)
|
||||
if restart_requested:
|
||||
print_status("[Main] Restart requested, cleaning up...", "info")
|
||||
cleanup_for_restart()
|
||||
os._exit(RESTART_EXIT_CODE)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import json
|
||||
import time
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from typing import Optional, Tuple, Dict, Any
|
||||
|
||||
from unilabos.utils.log import logger
|
||||
from unilabos.utils.tools import normalize_json as _normalize_device
|
||||
from unilabos.utils.type_check import TypeEncoder
|
||||
|
||||
|
||||
def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[Tuple[Dict[str, Any], Dict[str, Any]]]:
|
||||
@@ -10,63 +11,50 @@ def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[
|
||||
注册设备和资源到服务器(仅支持HTTP)
|
||||
"""
|
||||
|
||||
# 注册资源信息 - 使用HTTP方式
|
||||
from unilabos.app.web.client import http_client
|
||||
|
||||
logger.info("[UniLab Register] 开始注册设备和资源...")
|
||||
|
||||
# 注册设备信息
|
||||
devices_to_register = {}
|
||||
for device_info in lab_registry.obtain_registry_device_info():
|
||||
devices_to_register[device_info["id"]] = _normalize_device(device_info)
|
||||
logger.trace(f"[UniLab Register] 收集设备: {device_info['id']}")
|
||||
devices_to_register[device_info["id"]] = json.loads(
|
||||
json.dumps(device_info, ensure_ascii=False, cls=TypeEncoder)
|
||||
)
|
||||
logger.debug(f"[UniLab Register] 收集设备: {device_info['id']}")
|
||||
|
||||
resources_to_register = {}
|
||||
for resource_info in lab_registry.obtain_registry_resource_info():
|
||||
resources_to_register[resource_info["id"]] = resource_info
|
||||
logger.trace(f"[UniLab Register] 收集资源: {resource_info['id']}")
|
||||
logger.debug(f"[UniLab Register] 收集资源: {resource_info['id']}")
|
||||
|
||||
if gather_only:
|
||||
return devices_to_register, resources_to_register
|
||||
|
||||
# 注册设备
|
||||
if devices_to_register:
|
||||
try:
|
||||
start_time = time.time()
|
||||
response = http_client.resource_registry(
|
||||
{"resources": list(devices_to_register.values())},
|
||||
tag="device_registry",
|
||||
)
|
||||
response = http_client.resource_registry({"resources": list(devices_to_register.values())})
|
||||
cost_time = time.time() - start_time
|
||||
res_data = response.json() if response.status_code == 200 else {}
|
||||
skipped = res_data.get("data", {}).get("skipped", False)
|
||||
if skipped:
|
||||
logger.info(
|
||||
f"[UniLab Register] 设备注册跳过(内容未变化)"
|
||||
f" {len(devices_to_register)} 个 {cost_time:.3f}s"
|
||||
)
|
||||
elif response.status_code in [200, 201]:
|
||||
logger.info(f"[UniLab Register] 成功注册 {len(devices_to_register)} 个设备 {cost_time:.3f}s")
|
||||
if response.status_code in [200, 201]:
|
||||
logger.info(f"[UniLab Register] 成功注册 {len(devices_to_register)} 个设备 {cost_time}s")
|
||||
else:
|
||||
logger.error(f"[UniLab Register] 设备注册失败: {response.status_code}, {response.text} {cost_time:.3f}s")
|
||||
logger.error(f"[UniLab Register] 设备注册失败: {response.status_code}, {response.text} {cost_time}s")
|
||||
except Exception as e:
|
||||
logger.error(f"[UniLab Register] 设备注册异常: {e}")
|
||||
|
||||
# 注册资源
|
||||
if resources_to_register:
|
||||
try:
|
||||
start_time = time.time()
|
||||
response = http_client.resource_registry(
|
||||
{"resources": list(resources_to_register.values())},
|
||||
tag="resource_registry",
|
||||
)
|
||||
response = http_client.resource_registry({"resources": list(resources_to_register.values())})
|
||||
cost_time = time.time() - start_time
|
||||
res_data = response.json() if response.status_code == 200 else {}
|
||||
skipped = res_data.get("data", {}).get("skipped", False)
|
||||
if skipped:
|
||||
logger.info(
|
||||
f"[UniLab Register] 资源注册跳过(内容未变化)"
|
||||
f" {len(resources_to_register)} 个 {cost_time:.3f}s"
|
||||
)
|
||||
elif response.status_code in [200, 201]:
|
||||
logger.info(f"[UniLab Register] 成功注册 {len(resources_to_register)} 个资源 {cost_time:.3f}s")
|
||||
if response.status_code in [200, 201]:
|
||||
logger.info(f"[UniLab Register] 成功注册 {len(resources_to_register)} 个资源 {cost_time}s")
|
||||
else:
|
||||
logger.error(f"[UniLab Register] 资源注册失败: {response.status_code}, {response.text} {cost_time:.3f}s")
|
||||
logger.error(f"[UniLab Register] 资源注册失败: {response.status_code}, {response.text} {cost_time}s")
|
||||
except Exception as e:
|
||||
logger.error(f"[UniLab Register] 资源注册异常: {e}")
|
||||
|
||||
logger.info("[UniLab Register] 设备和资源注册完成.")
|
||||
|
||||
@@ -1052,7 +1052,7 @@ async def handle_file_import(websocket: WebSocket, request_data: dict):
|
||||
"result": {},
|
||||
"schema": lab_registry._generate_unilab_json_command_schema(v["args"], k),
|
||||
"goal_default": {i["name"]: i["default"] for i in v["args"]},
|
||||
"handles": {},
|
||||
"handles": [],
|
||||
}
|
||||
# 不生成已配置action的动作
|
||||
for k, v in enhanced_info["action_methods"].items()
|
||||
@@ -1340,5 +1340,5 @@ def setup_api_routes(app):
|
||||
# 启动广播任务
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
asyncio.create_task(broadcast_device_status(), name="web-api-startup-device")
|
||||
asyncio.create_task(broadcast_status_page_data(), name="web-api-startup-status")
|
||||
asyncio.create_task(broadcast_device_status())
|
||||
asyncio.create_task(broadcast_status_page_data())
|
||||
|
||||
@@ -3,13 +3,11 @@ HTTP客户端模块
|
||||
|
||||
提供与远程服务器通信的客户端功能,只有host需要用
|
||||
"""
|
||||
import gzip
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
from unilabos.utils.tools import fast_dumps as _fast_dumps, fast_dumps_pretty as _fast_dumps_pretty
|
||||
|
||||
import requests
|
||||
from unilabos.resources.resource_tracker import ResourceTreeSet
|
||||
from unilabos.utils.log import info
|
||||
@@ -80,20 +78,19 @@ class HTTPClient:
|
||||
f.write(json.dumps(payload, indent=4))
|
||||
# 从序列化数据中提取所有节点的UUID(保存旧UUID)
|
||||
old_uuids = {n.res_content.uuid: n for n in resources.all_nodes}
|
||||
nodes_info = [x for xs in resources.dump() for x in xs]
|
||||
if not self.initialized or first_add:
|
||||
self.initialized = True
|
||||
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
|
||||
response = requests.post(
|
||||
f"{self.remote_addr}/edge/material",
|
||||
json={"nodes": nodes_info, "mount_uuid": mount_uuid},
|
||||
json={"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid},
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
timeout=60,
|
||||
)
|
||||
else:
|
||||
response = requests.put(
|
||||
f"{self.remote_addr}/edge/material",
|
||||
json={"nodes": nodes_info, "mount_uuid": mount_uuid},
|
||||
json={"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid},
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
timeout=10,
|
||||
)
|
||||
@@ -112,7 +109,6 @@ class HTTPClient:
|
||||
uuid_mapping[i["uuid"]] = i["cloud_uuid"]
|
||||
else:
|
||||
logger.error(f"添加物料失败: {response.text}")
|
||||
logger.trace(f"添加物料失败: {nodes_info}")
|
||||
for u, n in old_uuids.items():
|
||||
if u in uuid_mapping:
|
||||
n.res_content.uuid = uuid_mapping[u]
|
||||
@@ -284,54 +280,22 @@ class HTTPClient:
|
||||
)
|
||||
return response
|
||||
|
||||
def resource_registry(
|
||||
self, registry_data: Dict[str, Any] | List[Dict[str, Any]], tag: str = "registry",
|
||||
) -> requests.Response:
|
||||
def resource_registry(self, registry_data: Dict[str, Any] | List[Dict[str, Any]]) -> requests.Response:
|
||||
"""
|
||||
注册资源到服务器,同步保存请求/响应到 unilabos_data
|
||||
注册资源到服务器
|
||||
|
||||
Args:
|
||||
registry_data: 注册表数据,格式为 {resource_id: resource_info} / [{resource_info}]
|
||||
tag: 保存文件的标签后缀 (如 "device_registry" / "resource_registry")
|
||||
|
||||
Returns:
|
||||
Response: API响应对象
|
||||
"""
|
||||
# 序列化一次,同时用于保存和发送
|
||||
json_bytes = _fast_dumps(registry_data)
|
||||
|
||||
# 保存请求数据到 unilabos_data
|
||||
req_path = os.path.join(BasicConfig.working_dir, f"req_{tag}_upload.json")
|
||||
try:
|
||||
os.makedirs(BasicConfig.working_dir, exist_ok=True)
|
||||
with open(req_path, "wb") as f:
|
||||
f.write(_fast_dumps_pretty(registry_data))
|
||||
logger.trace(f"注册表请求数据已保存: {req_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"保存注册表请求数据失败: {e}")
|
||||
|
||||
compressed_body = gzip.compress(json_bytes)
|
||||
headers = {
|
||||
"Authorization": f"Lab {self.auth}",
|
||||
"Content-Type": "application/json",
|
||||
"Content-Encoding": "gzip",
|
||||
}
|
||||
response = requests.post(
|
||||
f"{self.remote_addr}/lab/resource",
|
||||
data=compressed_body,
|
||||
headers=headers,
|
||||
json=registry_data,
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
# 保存响应数据到 unilabos_data
|
||||
res_path = os.path.join(BasicConfig.working_dir, f"res_{tag}_upload.json")
|
||||
try:
|
||||
with open(res_path, "w", encoding="utf-8") as f:
|
||||
f.write(f"{response.status_code}\n{response.text}")
|
||||
logger.trace(f"注册表响应数据已保存: {res_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"保存注册表响应数据失败: {e}")
|
||||
|
||||
if response.status_code not in [200, 201]:
|
||||
logger.error(f"注册资源失败: {response.status_code}, {response.text}")
|
||||
if response.status_code == 200:
|
||||
|
||||
@@ -86,7 +86,7 @@ def setup_server() -> FastAPI:
|
||||
# 设置页面路由
|
||||
try:
|
||||
setup_web_pages(pages)
|
||||
# info("[Web] 已加载Web UI模块")
|
||||
info("[Web] 已加载Web UI模块")
|
||||
except ImportError as e:
|
||||
info(f"[Web] 未找到Web页面模块: {str(e)}")
|
||||
except Exception as e:
|
||||
@@ -138,7 +138,7 @@ def start_server(host: str = "0.0.0.0", port: int = 8002, open_browser: bool = T
|
||||
server_thread = threading.Thread(target=server.run, daemon=True, name="uvicorn_server")
|
||||
server_thread.start()
|
||||
|
||||
# info("[Web] Server started, monitoring for restart requests...")
|
||||
info("[Web] Server started, monitoring for restart requests...")
|
||||
|
||||
# 监控重启标志
|
||||
import unilabos.app.main as main_module
|
||||
|
||||
@@ -26,7 +26,6 @@ from enum import Enum
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from unilabos.app.model import JobAddReq
|
||||
from unilabos.resources.resource_tracker import ResourceDictType
|
||||
from unilabos.ros.nodes.presets.host_node import HostNode
|
||||
from unilabos.utils.type_check import serialize_result_info
|
||||
from unilabos.app.communication import BaseCommunicationClient
|
||||
@@ -409,7 +408,6 @@ class MessageProcessor:
|
||||
# 线程控制
|
||||
self.is_running = False
|
||||
self.thread = None
|
||||
self._loop = None # asyncio event loop引用,用于外部关闭websocket
|
||||
self.reconnect_count = 0
|
||||
|
||||
logger.info(f"[MessageProcessor] Initialized for URL: {websocket_url}")
|
||||
@@ -436,31 +434,22 @@ class MessageProcessor:
|
||||
def stop(self) -> None:
|
||||
"""停止消息处理线程"""
|
||||
self.is_running = False
|
||||
# 主动关闭websocket以快速中断消息接收循环
|
||||
ws = self.websocket
|
||||
loop = self._loop
|
||||
if ws and loop and loop.is_running():
|
||||
try:
|
||||
asyncio.run_coroutine_threadsafe(ws.close(), loop)
|
||||
except Exception:
|
||||
pass
|
||||
if self.thread and self.thread.is_alive():
|
||||
self.thread.join(timeout=2)
|
||||
logger.info("[MessageProcessor] Stopped")
|
||||
|
||||
def _run(self):
|
||||
"""运行消息处理主循环"""
|
||||
self._loop = asyncio.new_event_loop()
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
asyncio.set_event_loop(self._loop)
|
||||
self._loop.run_until_complete(self._connection_handler())
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(self._connection_handler())
|
||||
except Exception as e:
|
||||
logger.error(f"[MessageProcessor] Thread error: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
finally:
|
||||
if self._loop:
|
||||
self._loop.close()
|
||||
self._loop = None
|
||||
if loop:
|
||||
loop.close()
|
||||
|
||||
async def _connection_handler(self):
|
||||
"""处理WebSocket连接和重连逻辑"""
|
||||
@@ -477,10 +466,8 @@ class MessageProcessor:
|
||||
async with websockets.connect(
|
||||
self.websocket_url,
|
||||
ssl=ssl_context,
|
||||
open_timeout=20,
|
||||
ping_interval=WSConfig.ping_interval,
|
||||
ping_timeout=10,
|
||||
close_timeout=5,
|
||||
additional_headers={
|
||||
"Authorization": f"Lab {BasicConfig.auth_secret()}",
|
||||
"EdgeSession": f"{self.session_id}",
|
||||
@@ -491,98 +478,85 @@ class MessageProcessor:
|
||||
self.connected = True
|
||||
self.reconnect_count = 0
|
||||
|
||||
logger.info(f"[MessageProcessor] 已连接到 {self.websocket_url}")
|
||||
logger.info(f"[MessageProcessor] Connected to {self.websocket_url}")
|
||||
|
||||
# 启动发送协程
|
||||
send_task = asyncio.create_task(self._send_handler(), name="websocket-send_task")
|
||||
|
||||
# 每次连接(含重连)后重新向服务端注册,
|
||||
# 否则服务端不知道客户端已上线,不会推送消息。
|
||||
if self.websocket_client:
|
||||
self.websocket_client.publish_host_ready()
|
||||
send_task = asyncio.create_task(self._send_handler())
|
||||
|
||||
try:
|
||||
# 接收消息循环
|
||||
await self._message_handler()
|
||||
finally:
|
||||
# 必须在 async with __aexit__ 之前停止 send_task,
|
||||
# 否则 send_task 会在关闭握手期间继续发送数据,
|
||||
# 干扰 websockets 库的内部清理,导致 task 泄漏。
|
||||
self.connected = False
|
||||
send_task.cancel()
|
||||
try:
|
||||
await send_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self.connected = False
|
||||
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
logger.warning("[MessageProcessor] 与服务端连接中断")
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
f"[MessageProcessor] 与服务端连接通信超时 (已尝试 {self.reconnect_count + 1} 次),请检查您的网络状况"
|
||||
)
|
||||
except websockets.exceptions.InvalidStatus as e:
|
||||
logger.warning(
|
||||
f"[MessageProcessor] 收到服务端注册码 {e.response.status_code}, 上一进程可能还未退出"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
logger.error(f"[MessageProcessor] 尝试重连时出错 {str(e)}")
|
||||
finally:
|
||||
logger.warning("[MessageProcessor] Connection closed")
|
||||
self.connected = False
|
||||
except Exception as e:
|
||||
logger.error(f"[MessageProcessor] Connection error: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
self.connected = False
|
||||
finally:
|
||||
self.websocket = None
|
||||
|
||||
# 重连逻辑
|
||||
if not self.is_running:
|
||||
break
|
||||
if self.reconnect_count < WSConfig.max_reconnect_attempts:
|
||||
if self.is_running and self.reconnect_count < WSConfig.max_reconnect_attempts:
|
||||
self.reconnect_count += 1
|
||||
backoff = WSConfig.reconnect_interval
|
||||
logger.info(
|
||||
f"[MessageProcessor] 即将在 {backoff} 秒后重连 (已尝试 {self.reconnect_count}/{WSConfig.max_reconnect_attempts})"
|
||||
f"[MessageProcessor] Reconnecting in {WSConfig.reconnect_interval}s "
|
||||
f"(attempt {self.reconnect_count}/{WSConfig.max_reconnect_attempts})"
|
||||
)
|
||||
await asyncio.sleep(backoff)
|
||||
else:
|
||||
await asyncio.sleep(WSConfig.reconnect_interval)
|
||||
elif self.reconnect_count >= WSConfig.max_reconnect_attempts:
|
||||
logger.error("[MessageProcessor] Max reconnection attempts reached")
|
||||
break
|
||||
else:
|
||||
self.reconnect_count -= 1
|
||||
|
||||
async def _message_handler(self):
|
||||
"""处理接收到的消息。
|
||||
|
||||
ConnectionClosed 不在此处捕获,让其向上传播到 _connection_handler,
|
||||
以便 async with websockets.connect() 的 __aexit__ 能感知连接已断,
|
||||
正确清理内部 task,避免 task 泄漏。
|
||||
"""
|
||||
"""处理接收到的消息"""
|
||||
if not self.websocket:
|
||||
logger.error("[MessageProcessor] WebSocket connection is None")
|
||||
return
|
||||
|
||||
async for message in self.websocket:
|
||||
try:
|
||||
data = json.loads(message)
|
||||
message_type = data.get("action", "")
|
||||
message_data = data.get("data")
|
||||
if self.session_id and self.session_id == data.get("edge_session"):
|
||||
await self._process_message(message_type, message_data)
|
||||
else:
|
||||
if message_type.endswith("_material"):
|
||||
logger.trace(
|
||||
f"[MessageProcessor] 收到一条归属 {data.get('edge_session')} 的旧消息:{data}"
|
||||
)
|
||||
logger.debug(
|
||||
f"[MessageProcessor] 跳过了一条归属 {data.get('edge_session')} 的旧消息: {data.get('action')}"
|
||||
)
|
||||
else:
|
||||
try:
|
||||
async for message in self.websocket:
|
||||
try:
|
||||
data = json.loads(message)
|
||||
message_type = data.get("action", "")
|
||||
message_data = data.get("data")
|
||||
if self.session_id and self.session_id == data.get("edge_session"):
|
||||
await self._process_message(message_type, message_data)
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"[MessageProcessor] Invalid JSON received: {message}")
|
||||
except Exception as e:
|
||||
logger.error(f"[MessageProcessor] Error processing message: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
else:
|
||||
if message_type.endswith("_material"):
|
||||
logger.trace(
|
||||
f"[MessageProcessor] 收到一条归属 {data.get('edge_session')} 的旧消息:{data}"
|
||||
)
|
||||
logger.debug(
|
||||
f"[MessageProcessor] 跳过了一条归属 {data.get('edge_session')} 的旧消息: {data.get('action')}"
|
||||
)
|
||||
else:
|
||||
await self._process_message(message_type, message_data)
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"[MessageProcessor] Invalid JSON received: {message}")
|
||||
except Exception as e:
|
||||
logger.error(f"[MessageProcessor] Error processing message: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
logger.info("[MessageProcessor] Message handler stopped - connection closed")
|
||||
except Exception as e:
|
||||
logger.error(f"[MessageProcessor] Message handler error: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
async def _send_handler(self):
|
||||
"""处理发送队列中的消息"""
|
||||
logger.trace("[MessageProcessor] Send handler started")
|
||||
logger.debug("[MessageProcessor] Send handler started")
|
||||
|
||||
try:
|
||||
while self.connected and self.websocket:
|
||||
@@ -627,7 +601,6 @@ class MessageProcessor:
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("[MessageProcessor] Send handler cancelled")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[MessageProcessor] Fatal error in send handler: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
@@ -659,10 +632,6 @@ class MessageProcessor:
|
||||
# elif message_type == "session_id":
|
||||
# self.session_id = message_data.get("session_id")
|
||||
# logger.info(f"[MessageProcessor] Session ID: {self.session_id}")
|
||||
elif message_type == "add_device":
|
||||
await self._handle_device_manage(message_data, "add")
|
||||
elif message_type == "remove_device":
|
||||
await self._handle_device_manage(message_data, "remove")
|
||||
elif message_type == "request_restart":
|
||||
await self._handle_request_restart(message_data)
|
||||
else:
|
||||
@@ -754,32 +723,6 @@ class MessageProcessor:
|
||||
req = JobAddReq(**data)
|
||||
|
||||
job_log = format_job_log(req.job_id, req.task_id, req.device_id, req.action)
|
||||
|
||||
# 服务端对always_free动作可能跳过query_action_state直接发job_start,
|
||||
# 此时job尚未注册,需要自动补注册
|
||||
existing_job = self.device_manager.get_job_info(req.job_id)
|
||||
if not existing_job:
|
||||
action_name = req.action
|
||||
device_action_key = f"/devices/{req.device_id}/{action_name}"
|
||||
action_always_free = self._check_action_always_free(req.device_id, action_name)
|
||||
|
||||
if action_always_free:
|
||||
job_info = JobInfo(
|
||||
job_id=req.job_id,
|
||||
task_id=req.task_id,
|
||||
device_id=req.device_id,
|
||||
action_name=action_name,
|
||||
device_action_key=device_action_key,
|
||||
status=JobStatus.QUEUE,
|
||||
start_time=time.time(),
|
||||
always_free=True,
|
||||
)
|
||||
self.device_manager.add_queue_request(job_info)
|
||||
logger.info(f"[MessageProcessor] Job {job_log} always_free, auto-registered from direct job_start")
|
||||
else:
|
||||
logger.error(f"[MessageProcessor] Job {job_log} not registered (missing query_action_state)")
|
||||
return
|
||||
|
||||
success = self.device_manager.start_job(req.job_id)
|
||||
if not success:
|
||||
logger.error(f"[MessageProcessor] Failed to start job {job_log}")
|
||||
@@ -1025,37 +968,6 @@ class MessageProcessor:
|
||||
)
|
||||
thread.start()
|
||||
|
||||
async def _handle_device_manage(self, device_list: list[ResourceDictType], action: str):
|
||||
"""Handle add_device / remove_device from LabGo server."""
|
||||
if not device_list:
|
||||
return
|
||||
|
||||
for item in device_list:
|
||||
target_node_id = item.get("target_node_id", "host_node")
|
||||
|
||||
def _notify(target_id: str, act: str, cfg: ResourceDictType):
|
||||
try:
|
||||
host_node = HostNode.get_instance(timeout=5)
|
||||
if not host_node:
|
||||
logger.error(f"[DeviceManage] HostNode not available for {act}_device")
|
||||
return
|
||||
success = host_node.notify_device_manage(target_id, act, cfg)
|
||||
if success:
|
||||
logger.info(f"[DeviceManage] {act}_device completed on {target_id}")
|
||||
else:
|
||||
logger.warning(f"[DeviceManage] {act}_device failed on {target_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"[DeviceManage] Error in {act}_device: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
thread = threading.Thread(
|
||||
target=_notify,
|
||||
args=(target_node_id, action, item),
|
||||
daemon=True,
|
||||
name=f"DeviceManage-{action}-{item.get('id', '')}",
|
||||
)
|
||||
thread.start()
|
||||
|
||||
async def _handle_request_restart(self, data: Dict[str, Any]):
|
||||
"""
|
||||
处理重启请求
|
||||
@@ -1067,9 +979,10 @@ class MessageProcessor:
|
||||
logger.info(f"[MessageProcessor] Received restart request, reason: {reason}, delay: {delay}s")
|
||||
|
||||
# 发送确认消息
|
||||
self.send_message(
|
||||
{"action": "restart_acknowledged", "data": {"reason": reason, "delay": delay}}
|
||||
)
|
||||
if self.websocket_client:
|
||||
await self.websocket_client.send_message(
|
||||
{"action": "restart_acknowledged", "data": {"reason": reason, "delay": delay}}
|
||||
)
|
||||
|
||||
# 设置全局重启标志
|
||||
import unilabos.app.main as main_module
|
||||
@@ -1113,7 +1026,7 @@ class MessageProcessor:
|
||||
"task_id": task_id,
|
||||
"job_id": job_id,
|
||||
"free": free,
|
||||
"need_more": need_more + 1,
|
||||
"need_more": need_more,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1171,14 +1084,13 @@ class QueueProcessor:
|
||||
def stop(self) -> None:
|
||||
"""停止队列处理线程"""
|
||||
self.is_running = False
|
||||
self.queue_update_event.set() # 立即唤醒等待中的线程
|
||||
if self.thread and self.thread.is_alive():
|
||||
self.thread.join(timeout=2)
|
||||
logger.info("[QueueProcessor] Stopped")
|
||||
|
||||
def _run(self):
|
||||
"""运行队列处理主循环"""
|
||||
logger.trace("[QueueProcessor] Queue processor started")
|
||||
logger.debug("[QueueProcessor] Queue processor started")
|
||||
|
||||
while self.is_running:
|
||||
try:
|
||||
@@ -1253,7 +1165,7 @@ class QueueProcessor:
|
||||
"task_id": job_info.task_id,
|
||||
"job_id": job_info.job_id,
|
||||
"free": False,
|
||||
"need_more": 10 + 1,
|
||||
"need_more": 10,
|
||||
},
|
||||
}
|
||||
self.message_processor.send_message(message)
|
||||
@@ -1286,7 +1198,7 @@ class QueueProcessor:
|
||||
"task_id": job_info.task_id,
|
||||
"job_id": job_info.job_id,
|
||||
"free": False,
|
||||
"need_more": 10 + 1,
|
||||
"need_more": 10,
|
||||
},
|
||||
}
|
||||
success = self.message_processor.send_message(message)
|
||||
@@ -1369,10 +1281,6 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
self.message_processor = MessageProcessor(self.websocket_url, self.send_queue, self.device_manager)
|
||||
self.queue_processor = QueueProcessor(self.device_manager, self.message_processor)
|
||||
|
||||
# running状态debounce缓存: {job_id: (last_send_timestamp, last_feedback_data)}
|
||||
self._job_running_last_sent: Dict[str, tuple] = {}
|
||||
self._job_running_debounce_interval: float = 10.0 # 秒
|
||||
|
||||
# 设置相互引用
|
||||
self.message_processor.set_queue_processor(self.queue_processor)
|
||||
self.message_processor.set_websocket_client(self)
|
||||
@@ -1397,6 +1305,7 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
else:
|
||||
url = f"{scheme}://{parsed.netloc}/api/v1/ws/schedule"
|
||||
|
||||
logger.debug(f"[WebSocketClient] URL: {url}")
|
||||
return url
|
||||
|
||||
def start(self) -> None:
|
||||
@@ -1409,11 +1318,13 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
logger.error("[WebSocketClient] WebSocket URL not configured")
|
||||
return
|
||||
|
||||
logger.info(f"[WebSocketClient] Starting connection to {self.websocket_url}")
|
||||
|
||||
# 启动两个核心线程
|
||||
self.message_processor.start()
|
||||
self.queue_processor.start()
|
||||
|
||||
logger.trace("[WebSocketClient] All threads started")
|
||||
logger.info("[WebSocketClient] All threads started")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止WebSocket客户端"""
|
||||
@@ -1429,8 +1340,8 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
message = {"action": "normal_exit", "data": {"session_id": session_id}}
|
||||
self.message_processor.send_message(message)
|
||||
logger.info(f"[WebSocketClient] Sent normal_exit message with session_id: {session_id}")
|
||||
# send_handler 每100ms检查一次队列,等300ms足以让消息发出
|
||||
time.sleep(0.3)
|
||||
# 给一点时间让消息发送出去
|
||||
time.sleep(1)
|
||||
except Exception as e:
|
||||
logger.warning(f"[WebSocketClient] Failed to send normal_exit message: {str(e)}")
|
||||
|
||||
@@ -1472,32 +1383,22 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
logger.debug(f"[WebSocketClient] Not connected, cannot publish job status for job_id: {item.job_id}")
|
||||
return
|
||||
|
||||
job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name)
|
||||
|
||||
# 拦截最终结果状态,与原版本逻辑一致
|
||||
if status in ["success", "failed"]:
|
||||
self._job_running_last_sent.pop(item.job_id, None)
|
||||
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node:
|
||||
# 从HostNode的device_action_status中移除job_id
|
||||
try:
|
||||
host_node._device_action_status[item.device_action_key].job_ids.pop(item.job_id, None)
|
||||
except (KeyError, AttributeError):
|
||||
logger.warning(f"[WebSocketClient] Failed to remove job {item.job_id} from HostNode status")
|
||||
|
||||
# logger.debug(f"[WebSocketClient] Intercepting final status for job_id: {item.job_id} - {status}")
|
||||
|
||||
# 通知队列处理器job完成(包括timeout的job)
|
||||
self.queue_processor.handle_job_completed(item.job_id, status)
|
||||
|
||||
# running状态按job_id做debounce,内容变化时仍然上报
|
||||
if status == "running":
|
||||
now = time.time()
|
||||
cached = self._job_running_last_sent.get(item.job_id)
|
||||
if cached is not None:
|
||||
last_ts, last_data = cached
|
||||
if now - last_ts < self._job_running_debounce_interval and last_data == feedback_data:
|
||||
logger.trace(f"[WebSocketClient] Job status debounced (skip): {job_log} - {status}")
|
||||
return
|
||||
self._job_running_last_sent[item.job_id] = (now, feedback_data)
|
||||
|
||||
# 发送job状态消息
|
||||
message = {
|
||||
"action": "job_status",
|
||||
"data": {
|
||||
@@ -1513,6 +1414,7 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
}
|
||||
self.message_processor.send_message(message)
|
||||
|
||||
job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name)
|
||||
logger.trace(f"[WebSocketClient] Job status published: {job_log} - {status}")
|
||||
|
||||
def send_ping(self, ping_id: str, timestamp: float) -> None:
|
||||
|
||||
@@ -24,7 +24,6 @@ class BasicConfig:
|
||||
port = 8002 # 本地HTTP服务
|
||||
check_mode = False # CI 检查模式,用于验证 registry 导入和文件一致性
|
||||
test_mode = False # 测试模式,所有动作不实际执行,返回模拟结果
|
||||
extra_resource = False # 是否加载lab_开头的额外资源
|
||||
# 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'
|
||||
log_level: Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "DEBUG"
|
||||
|
||||
@@ -41,7 +40,7 @@ class BasicConfig:
|
||||
class WSConfig:
|
||||
reconnect_interval = 5 # 重连间隔(秒)
|
||||
max_reconnect_attempts = 999 # 最大重连次数
|
||||
ping_interval = 20 # ping间隔(秒)
|
||||
ping_interval = 30 # ping间隔(秒)
|
||||
|
||||
|
||||
# HTTP配置
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
from abc import abstractmethod
|
||||
from functools import wraps
|
||||
import inspect
|
||||
|
||||
@@ -55,7 +55,6 @@ from unilabos.devices.liquid_handling.liquid_handler_abstract import (
|
||||
TransferLiquidReturn,
|
||||
)
|
||||
from unilabos.registry.placeholder_type import ResourceSlot
|
||||
from unilabos.resources.resource_tracker import ResourceTreeSet
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
|
||||
|
||||
@@ -91,103 +90,20 @@ class PRCXI9300Deck(Deck):
|
||||
该类定义了 PRCXI 9300 的工作台布局和槽位信息。
|
||||
"""
|
||||
|
||||
# T1-T16 默认位置 (4列×4行)
|
||||
_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
|
||||
(0, 192, 0), (138, 192, 0), (276, 192, 0), (414, 192, 0), # T9-T12
|
||||
(0, 288, 0), (138, 288, 0), (276, 288, 0), (414, 288, 0), # T13-T16
|
||||
]
|
||||
_DEFAULT_SITE_SIZE = {"width": 128.0, "height": 86, "depth": 0}
|
||||
_DEFAULT_CONTENT_TYPE = ["plate", "tip_rack", "plates", "tip_racks", "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):
|
||||
def __init__(self, name: str, size_x: float, size_y: float, size_z: float, **kwargs):
|
||||
super().__init__(size_x, size_y, size_z, name)
|
||||
if sites is not None:
|
||||
self.sites: List[Dict[str, Any]] = [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),
|
||||
})
|
||||
# _ordering: label -> None, 用于外部通过 list(keys()).index(site) 将 Tn 转换为 spot index
|
||||
self._ordering = collections.OrderedDict(
|
||||
(site["label"], None) for site in self.sites
|
||||
)
|
||||
|
||||
def _get_site_location(self, idx: int) -> Coordinate:
|
||||
pos = self.sites[idx]["position"]
|
||||
return Coordinate(pos["x"], pos["y"], pos["z"])
|
||||
|
||||
def _get_site_resource(self, idx: int) -> Optional[Resource]:
|
||||
site_loc = self._get_site_location(idx)
|
||||
for child in self.children:
|
||||
if child.location == site_loc:
|
||||
return child
|
||||
return None
|
||||
|
||||
def assign_child_resource(
|
||||
self,
|
||||
resource: Resource,
|
||||
location: Optional[Coordinate] = None,
|
||||
reassign: bool = True,
|
||||
spot: Optional[int] = None,
|
||||
):
|
||||
idx = spot
|
||||
if spot is not None:
|
||||
idx = spot
|
||||
else:
|
||||
for i, site in enumerate(self.sites):
|
||||
site_loc = self._get_site_location(i)
|
||||
if site.get("label") == resource.name:
|
||||
idx = i
|
||||
break
|
||||
if location is not None and site_loc == location:
|
||||
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 on deck '{self.name}' for resource '{resource.name}'")
|
||||
|
||||
if not reassign and self._get_site_resource(idx) is not None:
|
||||
raise ValueError(f"Site {idx} ('{self.sites[idx]['label']}') is already occupied")
|
||||
|
||||
loc = self._get_site_location(idx)
|
||||
super().assign_child_resource(resource, location=loc, reassign=reassign)
|
||||
self.slots = [None] * 16 # PRCXI 9300/9320 最大有 16 个槽位
|
||||
self.slot_locations = [Coordinate(0, 0, 0)] * 16
|
||||
|
||||
def assign_child_at_slot(self, resource: Resource, slot: int, reassign: bool = False) -> None:
|
||||
self.assign_child_resource(resource, spot=slot - 1, reassign=reassign)
|
||||
if self.slots[slot - 1] is not None and not reassign:
|
||||
raise ValueError(f"Spot {slot} is already occupied")
|
||||
|
||||
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 is not None else None,
|
||||
"position": site["position"],
|
||||
"size": site["size"],
|
||||
"content_type": site["content_type"],
|
||||
})
|
||||
data["sites"] = sites_out
|
||||
return data
|
||||
self.slots[slot - 1] = resource
|
||||
super().assign_child_resource(resource, location=self.slot_locations[slot - 1])
|
||||
|
||||
|
||||
class PRCXI9300Container(Container):
|
||||
class PRCXI9300Container(Plate):
|
||||
"""PRCXI 9300 的专用 Container 类,继承自 Plate,用于槽位定位和未知模块。
|
||||
|
||||
该类定义了 PRCXI 9300 的工作台布局和槽位信息。
|
||||
@@ -200,10 +116,11 @@ class PRCXI9300Container(Container):
|
||||
size_y: float,
|
||||
size_z: float,
|
||||
category: str,
|
||||
ordering: collections.OrderedDict,
|
||||
model: Optional[str] = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(name, size_x, size_y, size_z, category=category, model=model)
|
||||
super().__init__(name, size_x, size_y, size_z, category=category, ordering=ordering, model=model)
|
||||
self._unilabos_state = {}
|
||||
|
||||
def load_state(self, state: Dict[str, Any]) -> None:
|
||||
@@ -634,7 +551,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
deck: PRCXI9300Deck,
|
||||
deck: Deck,
|
||||
host: str,
|
||||
port: int,
|
||||
timeout: float,
|
||||
@@ -648,16 +565,16 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
is_9320=False,
|
||||
):
|
||||
tablets_info = []
|
||||
for site_id in range(len(deck.sites)):
|
||||
child = deck._get_site_resource(site_id)
|
||||
# 如果放其他类型的物料,是不可以的
|
||||
if hasattr(child, "_unilabos_state") and "Material" in child._unilabos_state:
|
||||
number = site_id + 1
|
||||
tablets_info.append(
|
||||
WorkTablets(
|
||||
Number=number, Code=f"T{number}", Material=child._unilabos_state["Material"]
|
||||
count = 0
|
||||
for child in deck.children:
|
||||
if child.children:
|
||||
if "Material" in child.children[0]._unilabos_state:
|
||||
number = int(child.name.replace("T", ""))
|
||||
tablets_info.append(
|
||||
WorkTablets(
|
||||
Number=number, Code=f"T{number}", Material=child.children[0]._unilabos_state["Material"]
|
||||
)
|
||||
)
|
||||
)
|
||||
if is_9320:
|
||||
print("当前设备是9320")
|
||||
# 始终初始化 step_mode 属性
|
||||
|
||||
@@ -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")
|
||||
@@ -1,15 +1,15 @@
|
||||
"""
|
||||
Virtual Workbench Device - 模拟工作台设备
|
||||
包含:
|
||||
包含:
|
||||
- 1个机械臂 (每次操作3s, 独占锁)
|
||||
- 3个加热台 (每次加热10s, 可并行)
|
||||
|
||||
工作流程:
|
||||
1. A1-A5 物料同时启动, 竞争机械臂
|
||||
工作流程:
|
||||
1. A1-A5 物料同时启动,竞争机械臂
|
||||
2. 机械臂将物料移动到空闲加热台
|
||||
3. 加热完成后, 机械臂将物料移动到C1-C5
|
||||
3. 加热完成后,机械臂将物料移动到C1-C5
|
||||
|
||||
注意: 调用来自线程池, 使用 threading.Lock 进行同步
|
||||
注意:调用来自线程池,使用 threading.Lock 进行同步
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -21,11 +21,9 @@ from threading import Lock, RLock
|
||||
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from unilabos.registry.decorators import (
|
||||
device, action, ActionInputHandle, ActionOutputHandle, DataSource, topic_config, not_action
|
||||
)
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample
|
||||
from unilabos.utils.decorator import not_action, always_free
|
||||
from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample, RETURN_UNILABOS_SAMPLES
|
||||
|
||||
|
||||
# ============ TypedDict 返回类型定义 ============
|
||||
@@ -59,8 +57,6 @@ class MoveToOutputResult(TypedDict):
|
||||
success: bool
|
||||
station_id: int
|
||||
material_id: str
|
||||
output_position: str
|
||||
message: str
|
||||
unilabos_samples: List[LabSample]
|
||||
|
||||
|
||||
@@ -85,9 +81,9 @@ class HeatingStationState(Enum):
|
||||
"""加热台状态枚举"""
|
||||
|
||||
IDLE = "idle" # 空闲
|
||||
OCCUPIED = "occupied" # 已放置物料, 等待加热
|
||||
OCCUPIED = "occupied" # 已放置物料,等待加热
|
||||
HEATING = "heating" # 加热中
|
||||
COMPLETED = "completed" # 加热完成, 等待取走
|
||||
COMPLETED = "completed" # 加热完成,等待取走
|
||||
|
||||
|
||||
class ArmState(Enum):
|
||||
@@ -109,24 +105,19 @@ class HeatingStation:
|
||||
heating_progress: float = 0.0
|
||||
|
||||
|
||||
@device(
|
||||
id="virtual_workbench",
|
||||
category=["virtual_device"],
|
||||
description="Virtual Workbench with 1 robotic arm and 3 heating stations for concurrent material processing",
|
||||
)
|
||||
class VirtualWorkbench:
|
||||
"""
|
||||
Virtual Workbench Device - 虚拟工作台设备
|
||||
|
||||
模拟一个包含1个机械臂和3个加热台的工作站
|
||||
- 机械臂操作耗时3秒, 同一时间只能执行一个操作
|
||||
- 加热台加热耗时10秒, 3个加热台可并行工作
|
||||
- 机械臂操作耗时3秒,同一时间只能执行一个操作
|
||||
- 加热台加热耗时10秒,3个加热台可并行工作
|
||||
|
||||
工作流:
|
||||
1. 物料A1-A5并发启动(线程池), 竞争机械臂使用权
|
||||
2. 获取机械臂后, 查找空闲加热台
|
||||
3. 机械臂将物料放入加热台, 开始加热
|
||||
4. 加热完成后, 机械臂将物料移动到目标位置Cn
|
||||
1. 物料A1-A5并发启动(线程池),竞争机械臂使用权
|
||||
2. 获取机械臂后,查找空闲加热台
|
||||
3. 机械臂将物料放入加热台,开始加热
|
||||
4. 加热完成后,机械臂将物料移动到目标位置Cn
|
||||
"""
|
||||
|
||||
_ros_node: BaseROS2DeviceNode
|
||||
@@ -154,19 +145,19 @@ class VirtualWorkbench:
|
||||
self.HEATING_TIME = float(self.config.get("heating_time", self.HEATING_TIME))
|
||||
self.NUM_HEATING_STATIONS = int(self.config.get("num_heating_stations", self.NUM_HEATING_STATIONS))
|
||||
|
||||
# 机械臂状态和锁
|
||||
# 机械臂状态和锁 (使用threading.Lock)
|
||||
self._arm_lock = Lock()
|
||||
self._arm_state = ArmState.IDLE
|
||||
self._arm_current_task: Optional[str] = None
|
||||
|
||||
# 加热台状态
|
||||
# 加热台状态 (station_id -> HeatingStation) - 立即初始化,不依赖initialize()
|
||||
self._heating_stations: Dict[int, HeatingStation] = {
|
||||
i: HeatingStation(station_id=i) for i in range(1, self.NUM_HEATING_STATIONS + 1)
|
||||
}
|
||||
self._stations_lock = RLock()
|
||||
self._stations_lock = RLock() # 可重入锁,保护加热台状态
|
||||
|
||||
# 任务追踪
|
||||
self._active_tasks: Dict[str, Dict[str, Any]] = {}
|
||||
self._active_tasks: Dict[str, Dict[str, Any]] = {} # material_id -> task_info
|
||||
self._tasks_lock = Lock()
|
||||
|
||||
# 处理其他kwargs参数
|
||||
@@ -192,6 +183,7 @@ class VirtualWorkbench:
|
||||
"""初始化虚拟工作台"""
|
||||
self.logger.info(f"初始化虚拟工作台 {self.device_id}")
|
||||
|
||||
# 重置加热台状态 (已在__init__中创建,这里重置为初始状态)
|
||||
with self._stations_lock:
|
||||
for station in self._heating_stations.values():
|
||||
station.state = HeatingStationState.IDLE
|
||||
@@ -199,6 +191,7 @@ class VirtualWorkbench:
|
||||
station.material_number = None
|
||||
station.heating_progress = 0.0
|
||||
|
||||
# 初始化状态
|
||||
self.data.update(
|
||||
{
|
||||
"status": "Ready",
|
||||
@@ -264,7 +257,11 @@ class VirtualWorkbench:
|
||||
self.data["message"] = message
|
||||
|
||||
def _find_available_heating_station(self) -> Optional[int]:
|
||||
"""查找空闲的加热台"""
|
||||
"""查找空闲的加热台
|
||||
|
||||
Returns:
|
||||
空闲加热台ID,如果没有则返回None
|
||||
"""
|
||||
with self._stations_lock:
|
||||
for station_id, station in self._heating_stations.items():
|
||||
if station.state == HeatingStationState.IDLE:
|
||||
@@ -272,12 +269,23 @@ class VirtualWorkbench:
|
||||
return None
|
||||
|
||||
def _acquire_arm(self, task_description: str) -> bool:
|
||||
"""获取机械臂使用权(阻塞直到获取)"""
|
||||
"""获取机械臂使用权(阻塞直到获取)
|
||||
|
||||
Args:
|
||||
task_description: 任务描述,用于日志
|
||||
|
||||
Returns:
|
||||
是否成功获取
|
||||
"""
|
||||
self.logger.info(f"[{task_description}] 等待获取机械臂...")
|
||||
|
||||
# 阻塞等待获取锁
|
||||
self._arm_lock.acquire()
|
||||
|
||||
self._arm_state = ArmState.BUSY
|
||||
self._arm_current_task = task_description
|
||||
self._update_data_status(f"机械臂执行: {task_description}")
|
||||
|
||||
self.logger.info(f"[{task_description}] 成功获取机械臂使用权")
|
||||
return True
|
||||
|
||||
@@ -290,22 +298,6 @@ class VirtualWorkbench:
|
||||
self._update_data_status(f"机械臂已释放 (完成: {task})")
|
||||
self.logger.info(f"机械臂已释放 (完成: {task})")
|
||||
|
||||
@action(
|
||||
auto_prefix=True,
|
||||
description="批量准备物料 - 虚拟起始节点, 生成A1-A5物料, 输出5个handle供后续节点使用",
|
||||
handles=[
|
||||
ActionOutputHandle(key="channel_1", data_type="workbench_material",
|
||||
label="实验1", data_key="material_1", data_source=DataSource.EXECUTOR),
|
||||
ActionOutputHandle(key="channel_2", data_type="workbench_material",
|
||||
label="实验2", data_key="material_2", data_source=DataSource.EXECUTOR),
|
||||
ActionOutputHandle(key="channel_3", data_type="workbench_material",
|
||||
label="实验3", data_key="material_3", data_source=DataSource.EXECUTOR),
|
||||
ActionOutputHandle(key="channel_4", data_type="workbench_material",
|
||||
label="实验4", data_key="material_4", data_source=DataSource.EXECUTOR),
|
||||
ActionOutputHandle(key="channel_5", data_type="workbench_material",
|
||||
label="实验5", data_key="material_5", data_source=DataSource.EXECUTOR),
|
||||
],
|
||||
)
|
||||
def prepare_materials(
|
||||
self,
|
||||
sample_uuids: SampleUUIDsType,
|
||||
@@ -314,14 +306,19 @@ class VirtualWorkbench:
|
||||
"""
|
||||
批量准备物料 - 虚拟起始节点
|
||||
|
||||
作为工作流的起始节点, 生成指定数量的物料编号供后续节点使用。
|
||||
输出5个handle (material_1 ~ material_5), 分别对应实验1~5。
|
||||
作为工作流的起始节点,生成指定数量的物料编号供后续节点使用。
|
||||
输出5个handle (material_1 ~ material_5),分别对应实验1~5。
|
||||
|
||||
Args:
|
||||
count: 待生成的物料数量,默认5 (生成 A1-A5)
|
||||
|
||||
Returns:
|
||||
PrepareMaterialsResult: 包含 material_1 ~ material_5 用于传递给 move_to_heating_station
|
||||
"""
|
||||
# 生成物料列表 A1 - A{count}
|
||||
materials = [i for i in range(1, count + 1)]
|
||||
|
||||
self.logger.info(
|
||||
f"[准备物料] 生成 {count} 个物料: A1-A{count} -> material_1~material_{count}"
|
||||
)
|
||||
self.logger.info(f"[准备物料] 生成 {count} 个物料: " f"A1-A{count} -> material_1~material_{count}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -332,28 +329,9 @@ class VirtualWorkbench:
|
||||
"material_4": materials[3] if len(materials) > 3 else 0,
|
||||
"material_5": materials[4] if len(materials) > 4 else 0,
|
||||
"message": f"已准备 {count} 个物料: A1-A{count}",
|
||||
"unilabos_samples": [
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra={"material_uuid": content} if isinstance(content, str) else (content.serialize() if content else {}),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
"unilabos_samples": [LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for sample_uuid, content in sample_uuids.items()]
|
||||
}
|
||||
|
||||
@action(
|
||||
auto_prefix=True,
|
||||
description="将物料从An位置移动到空闲加热台, 返回分配的加热台ID",
|
||||
handles=[
|
||||
ActionInputHandle(key="material_input", data_type="workbench_material",
|
||||
label="物料编号", data_key="material_number", data_source=DataSource.HANDLE),
|
||||
ActionOutputHandle(key="heating_station_output", data_type="workbench_station",
|
||||
label="加热台ID", data_key="station_id", data_source=DataSource.EXECUTOR),
|
||||
ActionOutputHandle(key="material_number_output", data_type="workbench_material",
|
||||
label="物料编号", data_key="material_number", data_source=DataSource.EXECUTOR),
|
||||
],
|
||||
)
|
||||
def move_to_heating_station(
|
||||
self,
|
||||
sample_uuids: SampleUUIDsType,
|
||||
@@ -362,12 +340,20 @@ class VirtualWorkbench:
|
||||
"""
|
||||
将物料从An位置移动到加热台
|
||||
|
||||
多线程并发调用时, 会竞争机械臂使用权, 并自动查找空闲加热台
|
||||
多线程并发调用时,会竞争机械臂使用权,并自动查找空闲加热台
|
||||
|
||||
Args:
|
||||
material_number: 物料编号 (1-5)
|
||||
|
||||
Returns:
|
||||
MoveToHeatingStationResult: 包含 station_id, material_number 等用于传递给下一个节点
|
||||
"""
|
||||
# 根据物料编号生成物料ID
|
||||
material_id = f"A{material_number}"
|
||||
task_desc = f"移动{material_id}到加热台"
|
||||
self.logger.info(f"[任务] {task_desc} - 开始执行")
|
||||
|
||||
# 记录任务
|
||||
with self._tasks_lock:
|
||||
self._active_tasks[material_id] = {
|
||||
"status": "waiting_for_arm",
|
||||
@@ -375,27 +361,33 @@ class VirtualWorkbench:
|
||||
}
|
||||
|
||||
try:
|
||||
# 步骤1: 等待获取机械臂使用权(竞争)
|
||||
with self._tasks_lock:
|
||||
self._active_tasks[material_id]["status"] = "waiting_for_arm"
|
||||
self._acquire_arm(task_desc)
|
||||
|
||||
# 步骤2: 查找空闲加热台
|
||||
with self._tasks_lock:
|
||||
self._active_tasks[material_id]["status"] = "finding_station"
|
||||
station_id = None
|
||||
|
||||
# 循环等待直到找到空闲加热台
|
||||
while station_id is None:
|
||||
station_id = self._find_available_heating_station()
|
||||
if station_id is None:
|
||||
self.logger.info(f"[{material_id}] 没有空闲加热台, 等待中...")
|
||||
self.logger.info(f"[{material_id}] 没有空闲加热台,等待中...")
|
||||
# 释放机械臂,等待后重试
|
||||
self._release_arm()
|
||||
time.sleep(0.5)
|
||||
self._acquire_arm(task_desc)
|
||||
|
||||
# 步骤3: 占用加热台 - 立即标记为OCCUPIED,防止其他任务选择同一加热台
|
||||
with self._stations_lock:
|
||||
self._heating_stations[station_id].state = HeatingStationState.OCCUPIED
|
||||
self._heating_stations[station_id].current_material = material_id
|
||||
self._heating_stations[station_id].material_number = material_number
|
||||
|
||||
# 步骤4: 模拟机械臂移动操作 (3秒)
|
||||
with self._tasks_lock:
|
||||
self._active_tasks[material_id]["status"] = "arm_moving"
|
||||
self._active_tasks[material_id]["assigned_station"] = station_id
|
||||
@@ -403,11 +395,11 @@ class VirtualWorkbench:
|
||||
|
||||
time.sleep(self.ARM_OPERATION_TIME)
|
||||
|
||||
# 步骤5: 放入加热台完成
|
||||
self._update_data_status(f"{material_id}已放入加热台{station_id}")
|
||||
self.logger.info(
|
||||
f"[{material_id}] 已放入加热台{station_id} (用时{self.ARM_OPERATION_TIME}s)"
|
||||
)
|
||||
self.logger.info(f"[{material_id}] 已放入加热台{station_id} (用时{self.ARM_OPERATION_TIME}s)")
|
||||
|
||||
# 释放机械臂
|
||||
self._release_arm()
|
||||
|
||||
with self._tasks_lock:
|
||||
@@ -420,16 +412,8 @@ class VirtualWorkbench:
|
||||
"material_number": material_number,
|
||||
"message": f"{material_id}已成功移动到加热台{station_id}",
|
||||
"unilabos_samples": [
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||
sample_uuid, content in sample_uuids.items()]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
@@ -443,33 +427,11 @@ class VirtualWorkbench:
|
||||
"material_number": material_number,
|
||||
"message": f"移动失败: {str(e)}",
|
||||
"unilabos_samples": [
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||
sample_uuid, content in sample_uuids.items()]
|
||||
}
|
||||
|
||||
@action(
|
||||
auto_prefix=True,
|
||||
always_free=True,
|
||||
description="启动指定加热台的加热程序",
|
||||
handles=[
|
||||
ActionInputHandle(key="station_id_input", data_type="workbench_station",
|
||||
label="加热台ID", data_key="station_id", data_source=DataSource.HANDLE),
|
||||
ActionInputHandle(key="material_number_input", data_type="workbench_material",
|
||||
label="物料编号", data_key="material_number", data_source=DataSource.HANDLE),
|
||||
ActionOutputHandle(key="heating_done_station", data_type="workbench_station",
|
||||
label="加热完成-加热台ID", data_key="station_id", data_source=DataSource.EXECUTOR),
|
||||
ActionOutputHandle(key="heating_done_material", data_type="workbench_material",
|
||||
label="加热完成-物料编号", data_key="material_number", data_source=DataSource.EXECUTOR),
|
||||
],
|
||||
)
|
||||
@always_free
|
||||
def start_heating(
|
||||
self,
|
||||
sample_uuids: SampleUUIDsType,
|
||||
@@ -478,6 +440,13 @@ class VirtualWorkbench:
|
||||
) -> StartHeatingResult:
|
||||
"""
|
||||
启动指定加热台的加热程序
|
||||
|
||||
Args:
|
||||
station_id: 加热台ID (1-3),从 move_to_heating_station 的 handle 传入
|
||||
material_number: 物料编号,从 move_to_heating_station 的 handle 传入
|
||||
|
||||
Returns:
|
||||
StartHeatingResult: 包含 station_id, material_number 等用于传递给下一个节点
|
||||
"""
|
||||
self.logger.info(f"[加热台{station_id}] 开始加热")
|
||||
|
||||
@@ -489,16 +458,8 @@ class VirtualWorkbench:
|
||||
"material_number": material_number,
|
||||
"message": f"无效的加热台ID: {station_id}",
|
||||
"unilabos_samples": [
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||
sample_uuid, content in sample_uuids.items()]
|
||||
}
|
||||
|
||||
with self._stations_lock:
|
||||
@@ -512,16 +473,8 @@ class VirtualWorkbench:
|
||||
"material_number": material_number,
|
||||
"message": f"加热台{station_id}上没有物料",
|
||||
"unilabos_samples": [
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||
sample_uuid, content in sample_uuids.items()]
|
||||
}
|
||||
|
||||
if station.state == HeatingStationState.HEATING:
|
||||
@@ -532,20 +485,13 @@ class VirtualWorkbench:
|
||||
"material_number": material_number,
|
||||
"message": f"加热台{station_id}已经在加热中",
|
||||
"unilabos_samples": [
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||
sample_uuid, content in sample_uuids.items()]
|
||||
}
|
||||
|
||||
material_id = station.current_material
|
||||
|
||||
# 开始加热
|
||||
station.state = HeatingStationState.HEATING
|
||||
station.heating_start_time = time.time()
|
||||
station.heating_progress = 0.0
|
||||
@@ -556,6 +502,7 @@ class VirtualWorkbench:
|
||||
|
||||
self._update_data_status(f"加热台{station_id}开始加热{material_id}")
|
||||
|
||||
# 打印当前所有正在加热的台位
|
||||
with self._stations_lock:
|
||||
heating_list = [
|
||||
f"加热台{sid}:{s.current_material}"
|
||||
@@ -564,6 +511,7 @@ class VirtualWorkbench:
|
||||
]
|
||||
self.logger.info(f"[并行加热] 当前同时加热中: {', '.join(heating_list)}")
|
||||
|
||||
# 模拟加热过程
|
||||
start_time = time.time()
|
||||
last_countdown_log = start_time
|
||||
while True:
|
||||
@@ -576,6 +524,7 @@ class VirtualWorkbench:
|
||||
|
||||
self._update_data_status(f"加热台{station_id}加热中: {progress:.1f}%")
|
||||
|
||||
# 每5秒打印一次倒计时
|
||||
if time.time() - last_countdown_log >= 5.0:
|
||||
self.logger.info(f"[加热台{station_id}] {material_id} 剩余 {remaining:.1f}s")
|
||||
last_countdown_log = time.time()
|
||||
@@ -585,6 +534,7 @@ class VirtualWorkbench:
|
||||
|
||||
time.sleep(1.0)
|
||||
|
||||
# 加热完成
|
||||
with self._stations_lock:
|
||||
self._heating_stations[station_id].state = HeatingStationState.COMPLETED
|
||||
self._heating_stations[station_id].heating_progress = 100.0
|
||||
@@ -603,28 +553,10 @@ class VirtualWorkbench:
|
||||
"material_number": material_number,
|
||||
"message": f"加热台{station_id}加热完成",
|
||||
"unilabos_samples": [
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||
sample_uuid, content in sample_uuids.items()]
|
||||
}
|
||||
|
||||
@action(
|
||||
auto_prefix=True,
|
||||
description="将物料从加热台移动到输出位置Cn",
|
||||
handles=[
|
||||
ActionInputHandle(key="output_station_input", data_type="workbench_station",
|
||||
label="加热台ID", data_key="station_id", data_source=DataSource.HANDLE),
|
||||
ActionInputHandle(key="output_material_input", data_type="workbench_material",
|
||||
label="物料编号", data_key="material_number", data_source=DataSource.HANDLE),
|
||||
],
|
||||
)
|
||||
def move_to_output(
|
||||
self,
|
||||
sample_uuids: SampleUUIDsType,
|
||||
@@ -633,8 +565,15 @@ class VirtualWorkbench:
|
||||
) -> MoveToOutputResult:
|
||||
"""
|
||||
将物料从加热台移动到输出位置Cn
|
||||
|
||||
Args:
|
||||
station_id: 加热台ID (1-3),从 start_heating 的 handle 传入
|
||||
material_number: 物料编号,从 start_heating 的 handle 传入,用于确定输出位置 Cn
|
||||
|
||||
Returns:
|
||||
MoveToOutputResult: 包含执行结果
|
||||
"""
|
||||
output_number = material_number
|
||||
output_number = material_number # 物料编号决定输出位置
|
||||
|
||||
if station_id not in self._heating_stations:
|
||||
return {
|
||||
@@ -644,16 +583,8 @@ class VirtualWorkbench:
|
||||
"output_position": f"C{output_number}",
|
||||
"message": f"无效的加热台ID: {station_id}",
|
||||
"unilabos_samples": [
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||
sample_uuid, content in sample_uuids.items()]
|
||||
}
|
||||
|
||||
with self._stations_lock:
|
||||
@@ -668,16 +599,8 @@ class VirtualWorkbench:
|
||||
"output_position": f"C{output_number}",
|
||||
"message": f"加热台{station_id}上没有物料",
|
||||
"unilabos_samples": [
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||
sample_uuid, content in sample_uuids.items()]
|
||||
}
|
||||
|
||||
if station.state != HeatingStationState.COMPLETED:
|
||||
@@ -688,16 +611,8 @@ class VirtualWorkbench:
|
||||
"output_position": f"C{output_number}",
|
||||
"message": f"加热台{station_id}尚未完成加热 (当前状态: {station.state.value})",
|
||||
"unilabos_samples": [
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||
sample_uuid, content in sample_uuids.items()]
|
||||
}
|
||||
|
||||
output_position = f"C{output_number}"
|
||||
@@ -709,17 +624,18 @@ class VirtualWorkbench:
|
||||
if material_id in self._active_tasks:
|
||||
self._active_tasks[material_id]["status"] = "waiting_for_arm_output"
|
||||
|
||||
# 获取机械臂
|
||||
self._acquire_arm(task_desc)
|
||||
|
||||
with self._tasks_lock:
|
||||
if material_id in self._active_tasks:
|
||||
self._active_tasks[material_id]["status"] = "arm_moving_to_output"
|
||||
|
||||
self.logger.info(
|
||||
f"[{material_id}] 机械臂正在从加热台{station_id}取出并移动到{output_position}..."
|
||||
)
|
||||
# 模拟机械臂操作 (3秒)
|
||||
self.logger.info(f"[{material_id}] 机械臂正在从加热台{station_id}取出并移动到{output_position}...")
|
||||
time.sleep(self.ARM_OPERATION_TIME)
|
||||
|
||||
# 清空加热台
|
||||
with self._stations_lock:
|
||||
self._heating_stations[station_id].state = HeatingStationState.IDLE
|
||||
self._heating_stations[station_id].current_material = None
|
||||
@@ -727,17 +643,17 @@ class VirtualWorkbench:
|
||||
self._heating_stations[station_id].heating_progress = 0.0
|
||||
self._heating_stations[station_id].heating_start_time = None
|
||||
|
||||
# 释放机械臂
|
||||
self._release_arm()
|
||||
|
||||
# 任务完成
|
||||
with self._tasks_lock:
|
||||
if material_id in self._active_tasks:
|
||||
self._active_tasks[material_id]["status"] = "completed"
|
||||
self._active_tasks[material_id]["end_time"] = time.time()
|
||||
|
||||
self._update_data_status(f"{material_id}已移动到{output_position}")
|
||||
self.logger.info(
|
||||
f"[{material_id}] 已成功移动到{output_position} (用时{self.ARM_OPERATION_TIME}s)"
|
||||
)
|
||||
self.logger.info(f"[{material_id}] 已成功移动到{output_position} (用时{self.ARM_OPERATION_TIME}s)")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -746,17 +662,8 @@ class VirtualWorkbench:
|
||||
"output_position": output_position,
|
||||
"message": f"{material_id}已成功移动到{output_position}",
|
||||
"unilabos_samples": [
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str)
|
||||
else (content.serialize() if content is not None else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||
sample_uuid, content in sample_uuids.items()]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
@@ -770,105 +677,83 @@ class VirtualWorkbench:
|
||||
"output_position": output_position,
|
||||
"message": f"移动失败: {str(e)}",
|
||||
"unilabos_samples": [
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||
sample_uuid, content in sample_uuids.items()]
|
||||
}
|
||||
|
||||
# ============ 状态属性 ============
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def status(self) -> str:
|
||||
return self.data.get("status", "Unknown")
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def arm_state(self) -> str:
|
||||
return self._arm_state.value
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def arm_current_task(self) -> str:
|
||||
return self._arm_current_task or ""
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def heating_station_1_state(self) -> str:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(1)
|
||||
return station.state.value if station else "unknown"
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def heating_station_1_material(self) -> str:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(1)
|
||||
return station.current_material or "" if station else ""
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def heating_station_1_progress(self) -> float:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(1)
|
||||
return station.heating_progress if station else 0.0
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def heating_station_2_state(self) -> str:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(2)
|
||||
return station.state.value if station else "unknown"
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def heating_station_2_material(self) -> str:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(2)
|
||||
return station.current_material or "" if station else ""
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def heating_station_2_progress(self) -> float:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(2)
|
||||
return station.heating_progress if station else 0.0
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def heating_station_3_state(self) -> str:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(3)
|
||||
return station.state.value if station else "unknown"
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def heating_station_3_material(self) -> str:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(3)
|
||||
return station.current_material or "" if station else ""
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def heating_station_3_progress(self) -> float:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(3)
|
||||
return station.heating_progress if station else 0.0
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def active_tasks_count(self) -> int:
|
||||
with self._tasks_lock:
|
||||
return len(self._active_tasks)
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def message(self) -> str:
|
||||
return self.data.get("message", "")
|
||||
|
||||
@@ -1,634 +0,0 @@
|
||||
# Layout Optimizer Handover
|
||||
|
||||
**Date**: 2026-04-10 | **Branch**: `feat/3d_layout_and_visualize` | **Commit**: `99dc821a` | **Tests**: 270 (260 pass + 10 LLM skip w/o API key)
|
||||
|
||||
This package is a standalone lab layout optimizer. It takes a device list + constraints and returns optimized placements. Your integration points are the HTTP API and the LLM skill document.
|
||||
|
||||
---
|
||||
|
||||
## 1. Full Pipeline Overview
|
||||
|
||||
```
|
||||
User NL request
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐ skill doc: llm_skill/layout_intent_translator.md
|
||||
│ LLM Agent │◄── + device list from scene (GET /devices)
|
||||
│ (your side) │ + schema discovery (GET /interpret/schema)
|
||||
└────────┬────────┘
|
||||
│ structured intents JSON
|
||||
▼
|
||||
POST /interpret ← intent_interpreter.py (pure translation)
|
||||
│
|
||||
│ { constraints, translations, workflow_edges, errors }
|
||||
▼
|
||||
User confirms ← translations have human-readable explanations
|
||||
│
|
||||
▼
|
||||
POST /optimize ← full pipeline below
|
||||
│
|
||||
┌────┴─────────────────────────────────────────┐
|
||||
│ 1. Device catalog (device_catalog.py) │
|
||||
│ footprints.json → Device objects │
|
||||
│ bbox, height, openings per device │
|
||||
│ │
|
||||
│ 2. Seeder (seeders.py) │
|
||||
│ Force-directed initial placement │
|
||||
│ Presets: compact_outward, spread_inward, │
|
||||
│ workflow_cluster, row_fallback │
|
||||
│ Accounts for openings, workflow edges │
|
||||
│ │
|
||||
│ 3. DE Optimizer (optimizer.py) │
|
||||
│ Custom DE loop (best1bin/currenttobest1bin│
|
||||
│ /rand1bin strategies) │
|
||||
│ 3N-dim: [x0, y0, θ0, x1, y1, θ1, ...] │
|
||||
│ Broad-phase AABB sweep (broad_phase.py) │
|
||||
│ θ lattice snap in joint discrete mode │
|
||||
│ Cost = hard_penalties + soft_penalties │
|
||||
│ Graduated collision penalties (not binary) │
|
||||
│ │
|
||||
│ 4. θ snap (optimizer.snap_theta) │
|
||||
│ Snap near-cardinal angles to 0/90/180/270 │
|
||||
│ (opt-in via snap_cardinal=True) │
|
||||
│ │
|
||||
│ 5. Final eval (constraints.py) │
|
||||
│ Binary pass/fail for response.success │
|
||||
└──────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
{ placements, cost, success }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. API Reference
|
||||
|
||||
### `POST /interpret` — LLM intent → constraints
|
||||
|
||||
Translates semantic intents into optimizer constraints. The LLM agent calls this after translating user NL.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"intents": [
|
||||
{
|
||||
"intent": "reachable_by",
|
||||
"params": {"arm": "arm_slider", "targets": ["opentrons_liquid_handler", "inheco_odtc_96xl"]},
|
||||
"description": "Robot arm must reach these devices"
|
||||
},
|
||||
{
|
||||
"intent": "workflow_hint",
|
||||
"params": {"workflow": "pcr", "devices": ["device_a", "device_b", "device_c"]},
|
||||
"description": "PCR workflow order"
|
||||
},
|
||||
{
|
||||
"intent": "close_together",
|
||||
"params": {"devices": ["device_a", "device_b"], "priority": "high"},
|
||||
"description": "Keep these close"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"constraints": [
|
||||
{"type": "hard", "rule_name": "reachability", "params": {"arm_id": "arm_slider", "target_device_id": "opentrons_liquid_handler"}, "weight": 1.0},
|
||||
...
|
||||
],
|
||||
"translations": [
|
||||
{
|
||||
"source_intent": "reachable_by",
|
||||
"source_description": "Robot arm must reach these devices",
|
||||
"source_params": {"arm": "arm_slider", "targets": ["..."]},
|
||||
"generated_constraints": [...],
|
||||
"explanation": "机械臂 'arm_slider' 需要能够到达 2 个目标设备",
|
||||
"confidence": "high"
|
||||
}
|
||||
],
|
||||
"workflow_edges": [["device_a", "device_b"], ["device_b", "device_c"]],
|
||||
"errors": []
|
||||
}
|
||||
```
|
||||
|
||||
The `constraints` and `workflow_edges` arrays pass directly to `/optimize` — no transformation needed.
|
||||
|
||||
### `GET /interpret/schema` — LLM discovery
|
||||
|
||||
Returns all 11 intent types with parameter specs. LLM agent should call this before translating.
|
||||
|
||||
### `POST /optimize` — Run layout optimization
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"devices": [
|
||||
{"id": "thermo_orbitor_rs2_hotel", "name": "Plate Hotel", "device_type": "static"},
|
||||
{"id": "arm_slider", "name": "Robot Arm", "device_type": "articulation"},
|
||||
...
|
||||
],
|
||||
"lab": {"width": 6.0, "depth": 4.0},
|
||||
"constraints": [...],
|
||||
"workflow_edges": [["device_a", "device_b"]],
|
||||
"seeder": "compact_outward",
|
||||
"run_de": true,
|
||||
"maxiter": 200,
|
||||
"seed": 42,
|
||||
"angle_granularity": 4,
|
||||
"snap_cardinal": false,
|
||||
"strategy": "currenttobest1bin",
|
||||
"mutation": [0.5, 1.0],
|
||||
"theta_mutation": null,
|
||||
"recombination": 0.7,
|
||||
"crossover_mode": "device"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"placements": [
|
||||
{
|
||||
"device_id": "thermo_orbitor_rs2_hotel",
|
||||
"uuid": "thermo_orbitor_rs2_hotel",
|
||||
"position": {"x": 1.33, "y": 2.35, "z": 0.0},
|
||||
"rotation": {"x": 0.0, "y": 0.0, "z": 1.5708}
|
||||
},
|
||||
...
|
||||
],
|
||||
"cost": 0.0,
|
||||
"success": true,
|
||||
"seeder_used": "compact_outward",
|
||||
"de_ran": true
|
||||
}
|
||||
```
|
||||
|
||||
`position`/`rotation` format matches Cloud's `CommonPositionType`. `rotation.z` is θ in radians.
|
||||
|
||||
**DE hyperparameters:**
|
||||
| Param | Default | Description |
|
||||
|-------|---------|-------------|
|
||||
| `strategy` | `"currenttobest1bin"` | DE mutation strategy (`best1bin`, `currenttobest1bin`, `rand1bin`) |
|
||||
| `mutation` | `[0.5, 1.0]` | Dithered F range for position dimensions |
|
||||
| `theta_mutation` | `null` (same as `mutation`) | Separate F range for θ dimensions (decoupled mutation) |
|
||||
| `recombination` | `0.7` | Crossover probability |
|
||||
| `crossover_mode` | `"device"` | `"device"` = per-device CR, `"dimension"` = per-dimension CR |
|
||||
| `angle_granularity` | `null` | `4`/`8`/`12`/`24` — snaps θ to a discrete lattice during DE (joint mode). `4` = axis-aligned (0/90/180/270). `null` = continuous θ |
|
||||
| `snap_cardinal` | `false` | Post-DE snap to nearest cardinal angle with collision rollback |
|
||||
|
||||
### Scene State API
|
||||
|
||||
Shared scene state between the LLM agent and the frontend. The agent pushes layout results here; the frontend polls for updates.
|
||||
|
||||
#### `GET /scene/lab` / `POST /scene/lab` — Lab dimensions
|
||||
|
||||
**GET** returns current lab dimensions. **POST** sets them (frontend sends this when user changes lab size).
|
||||
|
||||
```json
|
||||
{"width": 6.0, "depth": 4.0}
|
||||
```
|
||||
|
||||
#### `GET /scene/placements` / `POST /scene/placements` / `DELETE /scene/placements`
|
||||
|
||||
**GET** returns current placements + a version counter. Frontend polls this every 1s and re-renders when version changes.
|
||||
|
||||
```json
|
||||
{"version": 3, "placements": [...]}
|
||||
```
|
||||
|
||||
**POST** pushes new placements (from `/optimize` result or agent). Bumps version.
|
||||
|
||||
**DELETE** clears all placements (resets scene).
|
||||
|
||||
### `GET /devices` — Device catalog
|
||||
|
||||
Returns all known devices with bbox, openings, model paths. The LLM agent should receive this list as context so it can resolve fuzzy device names.
|
||||
|
||||
### `GET /health`
|
||||
|
||||
Returns `{"status": "ok"}`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Intent Types (11 total)
|
||||
|
||||
| Intent | Params | Generates | Type |
|
||||
|--------|--------|-----------|------|
|
||||
| `reachable_by` | `arm` (str), `targets` (list[str]) | `reachability` per target | hard |
|
||||
| `close_together` | `devices` (list[str]), `priority` (low/medium/high) | `minimize_distance` per pair | soft |
|
||||
| `far_apart` | `devices` (list[str]), `priority` | `maximize_distance` per pair | soft |
|
||||
| `keep_adjacent` | `devices` (list[str]), `priority` | `minimize_distance` per pair | soft |
|
||||
| `max_distance` | `device_a`, `device_b`, `distance` (float m) | `distance_less_than` | hard |
|
||||
| `min_distance` | `device_a`, `device_b`, `distance` (float m) | `distance_greater_than` | hard |
|
||||
| `min_spacing` | `min_gap` (float m, default 0.3) | `min_spacing` | hard |
|
||||
| `workflow_hint` | `workflow` (str), `devices` (ordered list[str]) | `minimize_distance` consecutive + `workflow_edges` | soft |
|
||||
| `face_outward` | (none) | `prefer_orientation_mode` outward | soft |
|
||||
| `face_inward` | (none) | `prefer_orientation_mode` inward | soft |
|
||||
| `align_cardinal` | (none) | `prefer_aligned` | soft |
|
||||
|
||||
Intent priorities are baked into the final emitted constraint `weight` during interpretation. The caller only sees the resulting weight, not a separate constraint-level priority field.
|
||||
|
||||
---
|
||||
|
||||
## 4. LLM Integration Guide
|
||||
|
||||
### What You Need to Build (Your Side)
|
||||
|
||||
The LLM agent that converts user natural language → structured intents JSON. We provide:
|
||||
|
||||
1. **Skill document** (`llm_skill/layout_intent_translator.md`) — system prompt for the LLM. Contains intent schema, device name resolution rules, translation rules, and PCR workflow examples.
|
||||
|
||||
2. **Runtime schema** (`GET /interpret/schema`) — machine-readable intent specs. LLM agent should call this for discovery.
|
||||
|
||||
3. **Device context** — before translating, feed the LLM the scene's device list (from `GET /devices` or your scene state). The LLM uses this to resolve fuzzy names like "PCR machine" → `inheco_odtc_96xl`.
|
||||
|
||||
### Integration Flow
|
||||
|
||||
```
|
||||
1. User enters NL request in Cloud UI
|
||||
2. Your LLM agent receives:
|
||||
- User message
|
||||
- Scene device list (id, name, type, bbox)
|
||||
- Skill doc as system prompt
|
||||
- Optional: GET /interpret/schema for discovery
|
||||
3. LLM outputs: {"intents": [...]}
|
||||
4. POST /interpret with LLM output
|
||||
5. Show user the translations for confirmation
|
||||
6. POST /optimize with confirmed constraints + workflow_edges
|
||||
7. Apply placements to scene
|
||||
```
|
||||
|
||||
### Device Name Resolution (handled by LLM, not by optimizer)
|
||||
|
||||
The skill doc teaches the LLM to match fuzzy names:
|
||||
- "PCR machine" / "thermal cycler" → `inheco_odtc_96xl`
|
||||
- "liquid handler" / "pipetting robot" → `opentrons_liquid_handler`
|
||||
- "plate hotel" / "storage" → `thermo_orbitor_rs2_hotel`
|
||||
- "robot arm" / "the arm" → device with `type: articulation`
|
||||
- "plate sealer" → `agilent_plateloc`
|
||||
|
||||
No search endpoint needed — the device list is already in context.
|
||||
|
||||
### Tested LLM Outputs
|
||||
|
||||
We tested with Claude Sonnet (via subagent, no API key required). Examples:
|
||||
|
||||
**Input**: "Take plate from hotel, prepare sample in the pipetting robot, seal it, then run thermal cycling. The arm handles all transfers. Keep liquid handler and sealer close, minimum 15cm gap."
|
||||
|
||||
**LLM produced**: `reachable_by` (arm→4 devices), `workflow_hint` (correct PCR order), `close_together` (high, LH+sealer), `min_distance` (0.15m, LH+sealer)
|
||||
|
||||
**Input**: "I want an automatic PCR lab, make it compact and neat"
|
||||
|
||||
**LLM produced**: `reachable_by`, `workflow_hint`, `close_together` (all devices), `min_spacing` (0.05m), `align_cardinal`
|
||||
|
||||
All outputs pass through `/interpret` → `/optimize` successfully.
|
||||
|
||||
---
|
||||
|
||||
## 5. Constraint System Details
|
||||
|
||||
### Hard Constraints (cost = ∞ on violation)
|
||||
|
||||
| Rule Name | Params | What it checks |
|
||||
|-----------|--------|---------------|
|
||||
| `no_collision` | (default, always on) | OBB-SAT pairwise collision between all devices |
|
||||
| `within_bounds` | (default, always on) | All devices within lab boundary |
|
||||
| `reachability` | `arm_id`, `target_device_id` | Target center within arm reach radius |
|
||||
| `distance_less_than` | `device_a`, `device_b`, `distance` | OBB edge-to-edge distance ≤ threshold |
|
||||
| `distance_greater_than` | `device_a`, `device_b`, `distance` | OBB edge-to-edge distance ≥ threshold |
|
||||
| `min_spacing` | `min_gap` | All device pairs have ≥ min_gap edge-to-edge |
|
||||
|
||||
### Soft Constraints (weighted penalty)
|
||||
|
||||
| Rule Name | Params | What it minimizes |
|
||||
|-----------|--------|------------------|
|
||||
| `minimize_distance` | `device_a`, `device_b` | OBB edge-to-edge distance × weight |
|
||||
| `maximize_distance` | `device_a`, `device_b` | 1/(distance+ε) × weight |
|
||||
| `prefer_orientation_mode` | `mode` (outward/inward) | Angle between opening direction and ideal direction |
|
||||
| `prefer_aligned` | (none) | Deviation from nearest 90° angle |
|
||||
| `prefer_seeder_orientation` | (none) | Deviation from seeder-assigned θ |
|
||||
| `crossing_penalty` | (auto, part of `reachability` eval) | Segment-OBB intersection length of opening-to-arm path blocked by other devices (Cyrus-Beck clipping via `obb.segment_obb_intersection_length`) |
|
||||
|
||||
### Weight Normalization
|
||||
|
||||
| Constant | Value | Meaning |
|
||||
|----------|-------|---------|
|
||||
| `DEFAULT_WEIGHT_DISTANCE` | 100.0 | 1 cm → penalty 1.0 |
|
||||
| `DEFAULT_WEIGHT_ANGLE` | 60.0 | 5° → penalty ~1.0 |
|
||||
| `HARD_MULTIPLIER` | 5.0 | Hard constraint penalty multiplier during graduated DE |
|
||||
|
||||
Constraints support a `priority` field (`critical` / `high` / `normal` / `low`) with multipliers 5× / 2× / 1× / 0.5×.
|
||||
|
||||
### Graduated Penalties (DE internals)
|
||||
|
||||
Default hard constraints (collision, boundary) use **graduated penalties** during DE optimization — proportional to penetration depth / overshoot distance. This gives DE a smooth gradient instead of binary inf. Final evaluation uses binary mode for pass/fail reporting.
|
||||
|
||||
---
|
||||
|
||||
## 6. Checker Architecture (Mock → Real)
|
||||
|
||||
```
|
||||
interfaces.py (Protocol definitions)
|
||||
├── CollisionChecker.check(placements) → collisions
|
||||
├── CollisionChecker.check_bounds(placements, w, d) → out_of_bounds
|
||||
└── ReachabilityChecker.is_reachable(arm_id, arm_pose, target) → bool
|
||||
|
||||
mock_checkers.py (current, no ROS)
|
||||
├── MockCollisionChecker — OBB SAT
|
||||
└── MockReachabilityChecker — Euclidean distance, 100m fallback for unknown arms
|
||||
|
||||
ros_checkers.py (for ROS2/MoveIt2 integration)
|
||||
├── MoveItCollisionChecker — python-fcl direct + OBB fallback
|
||||
└── IKFastReachabilityChecker — precomputed voxel O(1) + live IK fallback
|
||||
└── create_checkers(mode) — factory, controlled by LAYOUT_CHECKER_MODE env var
|
||||
```
|
||||
|
||||
To switch to real checkers: `LAYOUT_CHECKER_MODE=moveit` + pass MoveIt2 instance.
|
||||
|
||||
---
|
||||
|
||||
## 7. File Inventory
|
||||
|
||||
### Core Pipeline
|
||||
| File | Lines | Purpose |
|
||||
|------|-------|---------|
|
||||
| `models.py` | 97 | Dataclasses: Device, Lab, Placement, Constraint, Intent, Opening |
|
||||
| `device_catalog.py` | 303 | Loads devices from footprints.json + uni-lab-assets + registry |
|
||||
| `footprints.json` | 183KB | 499 device bounding boxes, heights, openings (offline extracted) |
|
||||
| `seeders.py` | 331 | Force-directed initial layout with presets |
|
||||
| `optimizer.py` | 1056 | Custom DE loop: per-device crossover, θ wrapping, discrete angle lattice, multi-strategy |
|
||||
| `broad_phase.py` | 66 | 2-axis sweep-and-prune AABB broad phase for collision pair pruning |
|
||||
| `constraints.py` | 627 | Unified constraint evaluation (hard + soft + graduated + crossing penalty) |
|
||||
| `obb.py` | 257 | OBB geometry: corners, overlap SAT, min_distance, penetration_depth, segment intersection |
|
||||
| `intent_interpreter.py` | 366 | 11 intent handlers, pure translation, no side effects |
|
||||
| `server.py` | 743 | FastAPI: /interpret, /optimize, /devices, /scene/* endpoints |
|
||||
| `lab_parser.py` | 50 | Parse lab floor plan JSON to Lab dataclass |
|
||||
|
||||
### Reference / Utilities
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `extract_footprints.py` | How footprints.json was generated (offline STL/GLB → 2D bbox extraction via trimesh) |
|
||||
| `generate_asset_registry.py` | Generate YAML registry entries for uni-lab-assets devices not already registered |
|
||||
|
||||
### Integration Layer
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `interfaces.py` | Protocol definitions for CollisionChecker / ReachabilityChecker |
|
||||
| `mock_checkers.py` | Dev-mode checkers (OBB collision, Euclidean reachability) |
|
||||
| `ros_checkers.py` | MoveIt2/IKFast adapters for real collision + reachability |
|
||||
|
||||
### LLM
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `llm_skill/layout_intent_translator.md` | System prompt for LLM: intent schema, device resolution, translation rules, examples |
|
||||
| `llm_skill/demo_agent.md` | LLM agent orchestration instructions for demo (GET /devices → intents → /interpret → /optimize → /scene/placements) |
|
||||
|
||||
### Demo / Frontend
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `static/lab3d.html` | Three.js 3D visualization frontend (1227 lines): device library, drag-to-add, auto layout, scene polling |
|
||||
|
||||
### Configuration
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `pyproject.toml` | Package deps: scipy, numpy, fastapi, uvicorn, pydantic |
|
||||
|
||||
### Tests (270 total: 260 pass + 10 skip without API key)
|
||||
| File | Tests | Coverage |
|
||||
|------|-------|----------|
|
||||
| `test_intent_interpreter.py` | 19 | All 11 handlers, validation, priority, multi-intent |
|
||||
| `test_interpret_api.py` | 6 | /interpret and /interpret/schema endpoints |
|
||||
| `test_e2e_pcr_pipeline.py` | 12 | Full pipeline: interpret → optimize → verify placements |
|
||||
| `test_llm_skill.py` | 10 | Real LLM fuzzy input → structured output (needs ANTHROPIC_API_KEY) |
|
||||
| `test_constraints.py` | 30 | Constraint evaluation, hard/soft, graduated penalties, crossing penalty |
|
||||
| `test_optimizer.py` | 50 | DE optimizer, vector encoding, bounds, discrete angles, strategies |
|
||||
| `test_mock_checkers.py` | 15 | MockCollisionChecker, MockReachabilityChecker |
|
||||
| `test_ros_checkers.py` | 40 | MoveIt2/IKFast adapter tests |
|
||||
| `test_seeders.py` | 12 | Force-directed seeder presets |
|
||||
| `test_device_catalog.py` | 25 | Device loading, footprint merging |
|
||||
| `test_obb.py` | 18 | OBB geometry functions, segment intersection |
|
||||
| `test_bugfixes_v2.py` | 28 | Regression: duplicate IDs, orientation, min_spacing, cardinal snap defaults |
|
||||
| `test_broad_phase.py` | 5 | Sweep-and-prune AABB broad phase |
|
||||
|
||||
---
|
||||
|
||||
## 8. How to Run
|
||||
|
||||
### Quick Start
|
||||
```bash
|
||||
# Install
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# Run server
|
||||
uvicorn unilabos.layout_optimizer.server:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
# Run server with debug logging (shows DE cost breakdown per generation)
|
||||
LAYOUT_DEBUG=1 uvicorn unilabos.layout_optimizer.server:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
# Run tests
|
||||
pytest unilabos/layout_optimizer/tests/ -v
|
||||
|
||||
# Run LLM skill tests (needs API key)
|
||||
ANTHROPIC_API_KEY=sk-... pytest unilabos/layout_optimizer/tests/test_llm_skill.py -v
|
||||
```
|
||||
|
||||
**Log files**: All requests are logged to `unilabos/layout_optimizer/logs/{YYYYMMDD_HHMMSS}.log` at DEBUG level (frontend polling GET /scene/placements excluded).
|
||||
|
||||
### Dependencies
|
||||
- Python ≥ 3.10
|
||||
- scipy, numpy, fastapi, uvicorn, pydantic
|
||||
- Optional: anthropic (for LLM skill tests)
|
||||
- Optional: python-fcl (for real collision checking, not needed for mock mode)
|
||||
|
||||
### Environment Variables
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `UNI_LAB_ASSETS_DIR` | `../uni-lab-assets` | Path to device 3D models |
|
||||
| `UNI_LAB_OS_DEVICE_MESH_DIR` | `Uni-Lab-OS/unilabos/device_mesh/devices` | Registry device meshes |
|
||||
| `LAYOUT_CHECKER_MODE` | `mock` | `mock` or `moveit` for checker selection |
|
||||
| `LAYOUT_DEBUG` | (unset) | Set to `1` for DEBUG-level console logging (DE cost breakdown per generation) |
|
||||
| `ANTHROPIC_API_KEY` | (none) | For LLM skill tests |
|
||||
|
||||
---
|
||||
|
||||
## 9. Known Limitations
|
||||
|
||||
1. **Mock reachability**: `MockReachabilityChecker` uses 100m fallback for unknown arm IDs — effectively "always reachable" for mock mode. Real arm reach requires `ros_checkers.py` with MoveIt2.
|
||||
|
||||
2. **No real LLM in tests**: `test_llm_skill.py` tests are skipped without `ANTHROPIC_API_KEY`. We verified with Claude Sonnet subagent that the skill doc produces correct output for PCR workflow scenarios.
|
||||
|
||||
3. **Opening data coverage**: 289/499 devices have opening direction annotations. Devices without openings default to local -Y as front with no alignment penalty.
|
||||
|
||||
4. **Single lab room**: No multi-room or corridor support yet. Lab is a single rectangle with optional rectangular obstacles.
|
||||
|
||||
5. **Intent interpreter is stateless**: It translates intents one-by-one with no cross-referencing between them. Duplicate/conflicting constraints are the LLM's responsibility to avoid.
|
||||
|
||||
6. **`align_weight` and `snap_cardinal` default to off**: `prefer_aligned` weight defaults to 0 (was `DEFAULT_WEIGHT_ANGLE=60`) and `snap_theta_safe` is opt-in via `snap_cardinal=True`. Both remain available when explicitly requested via `align_cardinal` intent or API param.
|
||||
|
||||
7. **Hybrid angle mode deprecated**: The angle-first hybrid mode (separate angle sweep + position-only DE) has been replaced by joint discrete mode as the default when `angle_granularity` is set. Joint mode snaps θ to the discrete lattice within the normal 3N DE loop.
|
||||
|
||||
---
|
||||
|
||||
## 10. Quick Verification (curl)
|
||||
|
||||
```bash
|
||||
# 1. Health check
|
||||
curl http://localhost:8000/health
|
||||
|
||||
# 2. Schema discovery
|
||||
curl http://localhost:8000/interpret/schema | python3 -m json.tool
|
||||
|
||||
# 3. Interpret PCR workflow
|
||||
curl -X POST http://localhost:8000/interpret \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"intents": [
|
||||
{"intent": "reachable_by", "params": {"arm": "arm_slider", "targets": ["opentrons_liquid_handler", "inheco_odtc_96xl"]}, "description": "arm reaches targets"},
|
||||
{"intent": "workflow_hint", "params": {"workflow": "pcr", "devices": ["thermo_orbitor_rs2_hotel", "opentrons_liquid_handler", "agilent_plateloc", "inheco_odtc_96xl"]}, "description": "PCR order"}
|
||||
]
|
||||
}' | python3 -m json.tool
|
||||
|
||||
# 4. Optimize (use constraints from step 3)
|
||||
curl -X POST http://localhost:8000/optimize \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"devices": [
|
||||
{"id": "thermo_orbitor_rs2_hotel", "name": "Plate Hotel"},
|
||||
{"id": "arm_slider", "name": "Robot Arm", "device_type": "articulation"},
|
||||
{"id": "opentrons_liquid_handler", "name": "Liquid Handler"},
|
||||
{"id": "agilent_plateloc", "name": "Plate Sealer"},
|
||||
{"id": "inheco_odtc_96xl", "name": "Thermal Cycler"}
|
||||
],
|
||||
"lab": {"width": 6.0, "depth": 4.0},
|
||||
"constraints": [
|
||||
{"type": "hard", "rule_name": "reachability", "params": {"arm_id": "arm_slider", "target_device_id": "opentrons_liquid_handler"}, "weight": 1.0},
|
||||
{"type": "hard", "rule_name": "reachability", "params": {"arm_id": "arm_slider", "target_device_id": "inheco_odtc_96xl"}, "weight": 1.0},
|
||||
{"type": "soft", "rule_name": "minimize_distance", "params": {"device_a": "thermo_orbitor_rs2_hotel", "device_b": "opentrons_liquid_handler"}, "weight": 3.0},
|
||||
{"type": "soft", "rule_name": "minimize_distance", "params": {"device_a": "opentrons_liquid_handler", "device_b": "agilent_plateloc"}, "weight": 3.0},
|
||||
{"type": "soft", "rule_name": "minimize_distance", "params": {"device_a": "agilent_plateloc", "device_b": "inheco_odtc_96xl"}, "weight": 3.0}
|
||||
],
|
||||
"workflow_edges": [
|
||||
["thermo_orbitor_rs2_hotel", "opentrons_liquid_handler"],
|
||||
["opentrons_liquid_handler", "agilent_plateloc"],
|
||||
["agilent_plateloc", "inheco_odtc_96xl"]
|
||||
],
|
||||
"run_de": true,
|
||||
"angle_granularity": 4,
|
||||
"maxiter": 100,
|
||||
"seed": 42
|
||||
}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Demo Setup
|
||||
|
||||
This section documents the device processing pipeline, test frontend, and LLM agent demo for the layout optimizer.
|
||||
|
||||
### 11.1 Device Processing Pipeline
|
||||
|
||||
How devices go from 3D meshes to collision footprints:
|
||||
|
||||
1. **Source data**:
|
||||
- `uni-lab-assets/` repository: GLB/STL 3D models + XACRO robot descriptions
|
||||
- `Uni-Lab-OS/device_mesh/devices/` registry: device metadata directories
|
||||
|
||||
2. **Extraction** (`extract_footprints.py`):
|
||||
- Load meshes via `trimesh` (STL for geometry, GLB for display)
|
||||
- Compute oriented bounding box (OBB): width, depth, height
|
||||
- Apply GLB root node rotation to align with world frame
|
||||
- Detect openings from XACRO `<joint type="fixed">` elements containing "socket" in name
|
||||
- Compute opening direction: centroid of socket origins → cardinal direction mapping
|
||||
- Manual overrides for devices with non-standard opening patterns (`MANUAL_OPENINGS` dict)
|
||||
- Write results to `footprints.json` (499 devices, 183KB)
|
||||
|
||||
3. **Catalog merging** (`device_catalog.py`):
|
||||
- Load `footprints.json` (OBB + openings)
|
||||
- Load `uni-lab-assets/data.json` (asset tree structure)
|
||||
- Load `Uni-Lab-OS/device_mesh/devices/` (registry devices)
|
||||
- Merge: registry devices get priority for metadata, but assets' 3D model paths preferred
|
||||
- Fallback sizes: `KNOWN_SIZES` dict provides manual dimensions when trimesh extraction fails
|
||||
|
||||
4. **Standalone filtering** (`server.py:_is_standalone_device`):
|
||||
- Bbox >30cm = device (standalone equipment)
|
||||
- Bbox <5cm = consumable (plates, tubes, tips)
|
||||
- 5-30cm = keyword heuristic (check name for "plate", "tube", "tip", "rack")
|
||||
|
||||
### 11.2 Test Frontend (`static/lab3d.html`)
|
||||
|
||||
Interactive 3D lab layout visualization and design tool (1227 lines).
|
||||
|
||||
**Technology stack**:
|
||||
- Three.js v0.169.0 (ES modules from esm.sh CDN)
|
||||
- WebGL renderer with PCF soft shadow maps, ACES filmic tone mapping
|
||||
- OrbitControls for camera interaction
|
||||
|
||||
**Features**:
|
||||
- **Device library**: Left sidebar with search/filter, toggle between devices and consumables
|
||||
- **Drag-to-add**: Click device in library → adds to scene with random position
|
||||
- **Selected devices panel**: Right panel lists all placed devices, click to remove
|
||||
- **Lab dimensions**: Width × Depth inputs (meters), collision margin slider
|
||||
- **View modes**: 3D perspective (default) and top-down orthographic
|
||||
- **Grid system**: 0.5m grid with lab boundary highlighting
|
||||
- **Device visualization**: Box geometry with emissive materials, edge highlights, CSS2D labels
|
||||
- **Opening markers**: Orange arrows and semi-transparent strips showing device access directions
|
||||
- **Auto Layout button**: Calls `POST /optimize` with current devices + constraints
|
||||
- **Scene polling**: 1-second polling of `GET /scene/placements` for agent-pushed updates (version-based change detection)
|
||||
- **Smooth animation**: Lerp interpolation for device placement changes
|
||||
|
||||
**Backend integration**:
|
||||
- `GET /devices` — Load device catalog on startup
|
||||
- `POST /optimize` — Send devices + constraints, receive placements
|
||||
- `POST /scene/lab` — Push lab dimensions when changed
|
||||
- `GET /scene/placements` — Poll every 1s for agent-pushed updates
|
||||
|
||||
**Key JavaScript functions**:
|
||||
- `loadDeviceCatalog()` — Fetch device list, build catalog with color pool
|
||||
- `createDeviceMesh(deviceId, uuid)` — Create Three.js Group with body, edges, opening markers
|
||||
- `addDevice(deviceId)` / `removeDevice(uuid)` — Manage selected devices
|
||||
- `runLayout()` — Call backend `/optimize` or local bin packing fallback
|
||||
- `animatePlacement(uuid, tx, tz, theta)` — Smooth lerp to target position
|
||||
- `setView('3d' | 'top')` — Switch camera perspective
|
||||
|
||||
### 11.3 LLM Agent Demo (`llm_skill/demo_agent.md`)
|
||||
|
||||
LLM agent orchestration instructions for natural language lab layout design.
|
||||
|
||||
**Agent workflow**:
|
||||
1. `GET /devices` — Fetch device catalog for context
|
||||
2. Parse user natural language request
|
||||
3. Build structured intents JSON (using `layout_intent_translator.md` skill)
|
||||
4. `POST /interpret` — Translate intents to constraints
|
||||
5. `POST /optimize` — Run layout optimization
|
||||
6. `POST /scene/placements` — Push results to shared scene state
|
||||
7. Frontend auto-updates via polling (no manual refresh needed)
|
||||
|
||||
**Example user requests**:
|
||||
- "Design a PCR lab with robot arm automation, keep it compact"
|
||||
- "Place liquid handler, thermal cycler, and plate sealer. Arm must reach all devices."
|
||||
- "Add a plate hotel, make sure it's close to the liquid handler"
|
||||
|
||||
### 11.4 Running the Demo
|
||||
|
||||
```bash
|
||||
# Start the server
|
||||
uvicorn unilabos.layout_optimizer.server:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
# Open in browser
|
||||
# http://localhost:8000/
|
||||
|
||||
# Use Claude Code with demo_agent.md skill to orchestrate via natural language
|
||||
# The agent will call the API endpoints and push results to /scene/placements
|
||||
# The frontend will automatically update via polling
|
||||
```
|
||||
|
||||
**Demo flow**:
|
||||
1. Open `http://localhost:8000/` in browser
|
||||
2. Frontend loads device catalog and displays 3D scene
|
||||
3. Use Claude Code with `demo_agent.md` skill to send natural language requests
|
||||
4. Agent translates request → intents → constraints → optimization → scene update
|
||||
5. Frontend polls `/scene/placements` every 1s and animates changes
|
||||
6. User can manually add/remove devices or adjust lab size in the UI
|
||||
7. Click "Auto Layout" to re-optimize with current devices
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
"""Layout Optimizer — AI 实验室布局自动排布。
|
||||
|
||||
独立开发包,无 ROS 依赖。集成阶段合并到 Uni-Lab-OS。
|
||||
"""
|
||||
|
||||
from .models import Constraint, Device, Lab, Opening, Placement
|
||||
from .optimizer import optimize
|
||||
|
||||
__all__ = ["Device", "Lab", "Opening", "Placement", "Constraint", "optimize"]
|
||||
@@ -1,66 +0,0 @@
|
||||
"""2 轴 sweep-and-prune 宽相碰撞检测。
|
||||
|
||||
对每个设备计算旋转后的 AABB,先沿 x 轴排序并剪枝,
|
||||
再用 y 轴交叠过滤。返回候选碰撞对(索引对列表),
|
||||
供后续 OBB SAT 精确检测使用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .models import Device, Placement
|
||||
|
||||
|
||||
def sweep_and_prune_pairs(
|
||||
devices: list[Device],
|
||||
placements: list[Placement],
|
||||
) -> list[tuple[int, int]]:
|
||||
"""2 轴 sweep-and-prune,返回 AABB 交叠的索引对。
|
||||
|
||||
Args:
|
||||
devices: 设备列表,与 placements 一一对应。
|
||||
placements: 布局位姿列表。
|
||||
|
||||
Returns:
|
||||
候选碰撞对列表,每个元素为 (i, j),
|
||||
i < j,索引对应 placements 原始顺序。
|
||||
"""
|
||||
n = len(devices)
|
||||
if n < 2:
|
||||
return []
|
||||
|
||||
# --- 计算每个设备旋转后的 AABB ---
|
||||
aabbs: list[tuple[float, float, float, float]] = []
|
||||
for dev, pl in zip(devices, placements):
|
||||
hw, hd = pl.rotated_bbox(dev)
|
||||
aabbs.append((pl.x - hw, pl.x + hw, pl.y - hd, pl.y + hd))
|
||||
|
||||
# --- 按 xmin 排序,保留原始索引映射 ---
|
||||
sorted_indices = sorted(range(n), key=lambda k: aabbs[k][0])
|
||||
|
||||
# --- 扫描 x 轴,y 轴过滤 ---
|
||||
candidates: list[tuple[int, int]] = []
|
||||
for si in range(len(sorted_indices)):
|
||||
i = sorted_indices[si]
|
||||
x_min_i, x_max_i, y_min_i, y_max_i = aabbs[i]
|
||||
for sj in range(si + 1, len(sorted_indices)):
|
||||
j = sorted_indices[sj]
|
||||
x_min_j, _x_max_j, y_min_j, y_max_j = aabbs[j]
|
||||
# 由于按 xmin 排序,x_min_j >= x_min_i
|
||||
if x_min_j > x_max_i:
|
||||
break # 后续设备 xmin 更大,不可能与 i 在 x 轴交叠
|
||||
# x 轴交叠确认,检查 y 轴
|
||||
if y_min_i <= y_max_j and y_min_j <= y_max_i:
|
||||
# 保证输出 (min_idx, max_idx) 方便去重和测试
|
||||
pair = (min(i, j), max(i, j))
|
||||
candidates.append(pair)
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
def broad_phase_device_pairs(
|
||||
devices: list[Device],
|
||||
placements: list[Placement],
|
||||
) -> list[tuple[str, str]]:
|
||||
"""返回候选碰撞对的 device_id 字符串元组列表。"""
|
||||
index_pairs = sweep_and_prune_pairs(devices, placements)
|
||||
return [(placements[i].device_id, placements[j].device_id) for i, j in index_pairs]
|
||||
@@ -1,626 +0,0 @@
|
||||
"""约束体系:硬约束 / 软约束定义与统一评估。
|
||||
|
||||
硬约束违反 → cost = inf(方案直接淘汰)
|
||||
软约束违反 → 加权 penalty 累加到 cost
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .broad_phase import sweep_and_prune_pairs
|
||||
from .models import Constraint, Device, Lab, Placement
|
||||
from .obb import (
|
||||
nearest_point_on_obb,
|
||||
obb_corners,
|
||||
obb_min_distance,
|
||||
obb_penetration_depth,
|
||||
segment_obb_intersection_length,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
|
||||
from .interfaces import CollisionChecker, ReachabilityChecker
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 归一化默认权重 — 1cm距离违规 ≈ 5°角度违规 的惩罚量级
|
||||
DEFAULT_WEIGHT_DISTANCE: float = 100.0 # 1cm → penalty 1.0
|
||||
DEFAULT_WEIGHT_ANGLE: float = 60.0 # 5° → penalty ~1.0
|
||||
|
||||
# 硬约束graduated模式下的惩罚倍数
|
||||
HARD_MULTIPLIER: float = 5.0
|
||||
|
||||
# 优先级等级对应的权重乘数
|
||||
PRIORITY_MULTIPLIERS: dict[str, float] = {
|
||||
"critical": 5.0,
|
||||
"high": 2.0,
|
||||
"normal": 1.0,
|
||||
"low": 0.5,
|
||||
}
|
||||
|
||||
|
||||
def evaluate_constraints(
|
||||
devices: list[Device],
|
||||
placements: list[Placement],
|
||||
lab: Lab,
|
||||
constraints: list[Constraint],
|
||||
collision_checker: CollisionChecker,
|
||||
reachability_checker: ReachabilityChecker | None = None,
|
||||
*,
|
||||
graduated: bool = True,
|
||||
) -> float:
|
||||
"""统一评估所有约束,返回总 cost。
|
||||
|
||||
Args:
|
||||
devices: 设备列表(与 placements 一一对应)
|
||||
placements: 当前布局方案
|
||||
lab: 实验室平面图
|
||||
constraints: 约束规则列表
|
||||
collision_checker: 碰撞检测实例
|
||||
reachability_checker: 可达性检测实例(可选)
|
||||
graduated: True=比例惩罚(DE优化用),False=二值inf(最终pass/fail用)
|
||||
|
||||
Returns:
|
||||
总 cost。硬约束违反在非graduated模式返回 inf,否则为加权 penalty 之和。
|
||||
"""
|
||||
device_map = {d.id: d for d in devices}
|
||||
placement_map = {p.device_id: p for p in placements}
|
||||
|
||||
total_cost = 0.0
|
||||
|
||||
for c in constraints:
|
||||
cost = _evaluate_single(
|
||||
c, device_map, placement_map, lab, collision_checker, reachability_checker,
|
||||
graduated=graduated,
|
||||
)
|
||||
if math.isinf(cost):
|
||||
return math.inf
|
||||
total_cost += cost
|
||||
|
||||
return total_cost
|
||||
|
||||
|
||||
def evaluate_default_hard_constraints(
|
||||
devices: list[Device],
|
||||
placements: list[Placement],
|
||||
lab: Lab,
|
||||
collision_checker: CollisionChecker,
|
||||
*,
|
||||
graduated: bool = True,
|
||||
collision_weight: float = DEFAULT_WEIGHT_DISTANCE * HARD_MULTIPLIER, # 500
|
||||
boundary_weight: float = DEFAULT_WEIGHT_DISTANCE * HARD_MULTIPLIER, # 500
|
||||
) -> float:
|
||||
"""评估默认硬约束(碰撞 + 边界),无需显式声明约束列表。
|
||||
|
||||
始终生效,用于 cost function 的基础检查。
|
||||
|
||||
When graduated=True (default), returns a penalty proportional to the
|
||||
severity of each violation instead of binary inf. This gives DE a
|
||||
smooth gradient so it can fix specific collision pairs instead of
|
||||
discarding near-optimal layouts entirely.
|
||||
|
||||
When graduated=False, uses the legacy binary inf behaviour.
|
||||
"""
|
||||
if not graduated:
|
||||
return _evaluate_hard_binary(devices, placements, lab, collision_checker)
|
||||
|
||||
device_map = {d.id: d for d in devices}
|
||||
cost = 0.0
|
||||
|
||||
# Graduated collision penalty: 2 轴 sweep-and-prune 宽相 + OBB SAT 精确检测
|
||||
candidate_pairs = sweep_and_prune_pairs(devices, placements)
|
||||
for i, j in candidate_pairs:
|
||||
di, dj = device_map[placements[i].device_id], device_map[placements[j].device_id]
|
||||
ci = obb_corners(placements[i].x, placements[i].y,
|
||||
di.bbox[0], di.bbox[1], placements[i].theta)
|
||||
cj = obb_corners(placements[j].x, placements[j].y,
|
||||
dj.bbox[0], dj.bbox[1], placements[j].theta)
|
||||
depth = obb_penetration_depth(ci, cj)
|
||||
if depth > 0:
|
||||
cost += collision_weight * depth
|
||||
|
||||
# Graduated boundary penalty: sum of overshoot distances (rotation-aware)
|
||||
for p in placements:
|
||||
dev = device_map[p.device_id]
|
||||
hw, hd = p.rotated_bbox(dev)
|
||||
# How far each edge exceeds the lab boundary
|
||||
overshoot = 0.0
|
||||
overshoot += max(0.0, hw - p.x) # left wall
|
||||
overshoot += max(0.0, (p.x + hw) - lab.width) # right wall
|
||||
overshoot += max(0.0, hd - p.y) # bottom wall
|
||||
overshoot += max(0.0, (p.y + hd) - lab.depth) # top wall
|
||||
cost += boundary_weight * overshoot
|
||||
|
||||
return cost
|
||||
|
||||
|
||||
def _evaluate_hard_binary(
|
||||
devices: list[Device],
|
||||
placements: list[Placement],
|
||||
lab: Lab,
|
||||
collision_checker: CollisionChecker,
|
||||
) -> float:
|
||||
"""Legacy binary hard-constraint evaluation (inf or 0)."""
|
||||
checker_placements = _to_checker_format(devices, placements)
|
||||
|
||||
collisions = collision_checker.check(checker_placements)
|
||||
if collisions:
|
||||
return math.inf
|
||||
|
||||
if hasattr(collision_checker, "check_bounds"):
|
||||
oob = collision_checker.check_bounds(checker_placements, lab.width, lab.depth)
|
||||
if oob:
|
||||
return math.inf
|
||||
|
||||
return 0.0
|
||||
|
||||
|
||||
def _evaluate_single(
|
||||
constraint: Constraint,
|
||||
device_map: dict[str, Device],
|
||||
placement_map: dict[str, Placement],
|
||||
lab: Lab,
|
||||
collision_checker: CollisionChecker,
|
||||
reachability_checker: ReachabilityChecker | None,
|
||||
*,
|
||||
graduated: bool = True,
|
||||
) -> float:
|
||||
"""评估单条约束规则。
|
||||
|
||||
graduated=True 时硬约束返回比例惩罚(DE用),
|
||||
graduated=False 时硬约束返回 inf(最终 pass/fail)。
|
||||
"""
|
||||
rule = constraint.rule_name
|
||||
params = constraint.params
|
||||
is_hard = constraint.type == "hard"
|
||||
|
||||
effective_weight = constraint.weight
|
||||
|
||||
if rule == "no_collision":
|
||||
checker_placements = _to_checker_format_from_maps(device_map, placement_map)
|
||||
collisions = collision_checker.check(checker_placements)
|
||||
if collisions:
|
||||
if is_hard and not graduated:
|
||||
return math.inf
|
||||
w = effective_weight * (HARD_MULTIPLIER if is_hard else 1.0)
|
||||
return w * len(collisions)
|
||||
return 0.0
|
||||
|
||||
if rule == "within_bounds":
|
||||
checker_placements = _to_checker_format_from_maps(device_map, placement_map)
|
||||
if hasattr(collision_checker, "check_bounds"):
|
||||
oob = collision_checker.check_bounds(
|
||||
checker_placements, lab.width, lab.depth
|
||||
)
|
||||
if oob:
|
||||
if is_hard and not graduated:
|
||||
return math.inf
|
||||
w = effective_weight * (HARD_MULTIPLIER if is_hard else 1.0)
|
||||
return w * len(oob)
|
||||
return 0.0
|
||||
|
||||
if rule == "distance_less_than":
|
||||
a_id, b_id = params["device_a"], params["device_b"]
|
||||
max_dist = params["distance"]
|
||||
da, db = device_map.get(a_id), device_map.get(b_id)
|
||||
pa, pb = placement_map.get(a_id), placement_map.get(b_id)
|
||||
missing_cost = _missing_reference_cost(constraint, placement_map, a_id, b_id)
|
||||
if missing_cost is not None:
|
||||
return missing_cost
|
||||
if da and db:
|
||||
dist = _device_distance_obb(da, pa, db, pb)
|
||||
else:
|
||||
dist = _device_distance_center(pa, pb) or 0.0
|
||||
if dist > max_dist:
|
||||
if is_hard and not graduated:
|
||||
return math.inf
|
||||
w = effective_weight * (HARD_MULTIPLIER if is_hard else 1.0)
|
||||
return w * (dist - max_dist)
|
||||
return 0.0
|
||||
|
||||
if rule == "distance_greater_than":
|
||||
a_id, b_id = params["device_a"], params["device_b"]
|
||||
min_dist = params["distance"]
|
||||
da, db = device_map.get(a_id), device_map.get(b_id)
|
||||
pa, pb = placement_map.get(a_id), placement_map.get(b_id)
|
||||
missing_cost = _missing_reference_cost(constraint, placement_map, a_id, b_id)
|
||||
if missing_cost is not None:
|
||||
return missing_cost
|
||||
if da and db:
|
||||
dist = _device_distance_obb(da, pa, db, pb)
|
||||
else:
|
||||
dist = _device_distance_center(pa, pb) or 0.0
|
||||
if dist < min_dist:
|
||||
if is_hard and not graduated:
|
||||
return math.inf
|
||||
w = effective_weight * (HARD_MULTIPLIER if is_hard else 1.0)
|
||||
return w * (min_dist - dist)
|
||||
return 0.0
|
||||
|
||||
if rule == "minimize_distance":
|
||||
a_id, b_id = params["device_a"], params["device_b"]
|
||||
da, db = device_map.get(a_id), device_map.get(b_id)
|
||||
pa, pb = placement_map.get(a_id), placement_map.get(b_id)
|
||||
missing_cost = _missing_reference_cost(constraint, placement_map, a_id, b_id)
|
||||
if missing_cost is not None:
|
||||
return missing_cost
|
||||
if da and db:
|
||||
dist = _device_distance_obb(da, pa, db, pb)
|
||||
else:
|
||||
dist = _device_distance_center(pa, pb) or 0.0
|
||||
return effective_weight * dist
|
||||
|
||||
if rule == "maximize_distance":
|
||||
a_id, b_id = params["device_a"], params["device_b"]
|
||||
da, db = device_map.get(a_id), device_map.get(b_id)
|
||||
pa, pb = placement_map.get(a_id), placement_map.get(b_id)
|
||||
missing_cost = _missing_reference_cost(constraint, placement_map, a_id, b_id)
|
||||
if missing_cost is not None:
|
||||
return missing_cost
|
||||
if da and db:
|
||||
dist = _device_distance_obb(da, pa, db, pb)
|
||||
else:
|
||||
dist = _device_distance_center(pa, pb) or 0.0
|
||||
max_possible = math.sqrt(lab.width**2 + lab.depth**2)
|
||||
return effective_weight * (max_possible - dist)
|
||||
|
||||
if rule == "min_spacing":
|
||||
min_gap = params.get("min_gap", 0.0)
|
||||
all_placements = list(placement_map.values())
|
||||
total_penalty = 0.0
|
||||
for i in range(len(all_placements)):
|
||||
for j in range(i + 1, len(all_placements)):
|
||||
pi, pj = all_placements[i], all_placements[j]
|
||||
di = device_map.get(pi.device_id)
|
||||
dj = device_map.get(pj.device_id)
|
||||
if di and dj:
|
||||
dist = _device_distance_obb(di, pi, dj, pj)
|
||||
else:
|
||||
dist = _device_distance_center(pi, pj) or 0.0
|
||||
if dist < min_gap:
|
||||
total_penalty += (min_gap - dist)
|
||||
if total_penalty > 0:
|
||||
if is_hard and not graduated:
|
||||
return math.inf
|
||||
w = effective_weight * (HARD_MULTIPLIER if is_hard else 1.0)
|
||||
return w * total_penalty
|
||||
return 0.0
|
||||
|
||||
if rule == "reachability":
|
||||
if reachability_checker is None:
|
||||
return 0.0
|
||||
arm_id = params["arm_id"]
|
||||
target_device_id = params["target_device_id"]
|
||||
arm_p = placement_map.get(arm_id)
|
||||
target_p = placement_map.get(target_device_id)
|
||||
missing_cost = _missing_reference_cost(
|
||||
constraint, placement_map, arm_id, target_device_id,
|
||||
)
|
||||
if missing_cost is not None:
|
||||
return missing_cost
|
||||
arm_dev = device_map.get(arm_id)
|
||||
target_dev = device_map.get(target_device_id)
|
||||
|
||||
# opening surface center → nearest point on arm OBB
|
||||
if arm_dev and target_dev:
|
||||
opening_pt = _opening_surface_center(target_dev, target_p)
|
||||
arm_corners = obb_corners(
|
||||
arm_p.x, arm_p.y, arm_dev.bbox[0], arm_dev.bbox[1], arm_p.theta,
|
||||
)
|
||||
nearest = nearest_point_on_obb(opening_pt[0], opening_pt[1], arm_corners)
|
||||
dist = math.sqrt((opening_pt[0] - nearest[0])**2 + (opening_pt[1] - nearest[1])**2)
|
||||
else:
|
||||
opening_pt = (target_p.x, target_p.y)
|
||||
nearest = (arm_p.x, arm_p.y)
|
||||
dist = _device_distance_center(arm_p, target_p) or 0.0
|
||||
|
||||
# 交叉惩罚始终计算(soft, 不依赖可达性结果)
|
||||
crossing_cost = _crossing_penalty(
|
||||
opening_pt, nearest,
|
||||
arm_id, target_device_id,
|
||||
device_map, placement_map,
|
||||
)
|
||||
|
||||
arm_pose = {"x": arm_p.x, "y": arm_p.y, "theta": arm_p.theta}
|
||||
target_point = {"x": target_p.x, "y": target_p.y, "z": 0.0}
|
||||
target_point["_obb_dist"] = dist
|
||||
if not reachability_checker.is_reachable(arm_id, arm_pose, target_point):
|
||||
if is_hard and not graduated:
|
||||
return math.inf
|
||||
# Graduated: overshoot penalty + crossing cost
|
||||
max_reach = reachability_checker.arm_reach.get(arm_id, 2.0)
|
||||
overshoot = max(0.0, dist - max_reach)
|
||||
w = effective_weight * (HARD_MULTIPLIER if is_hard else 1.0)
|
||||
return w * overshoot * 10.0 + crossing_cost
|
||||
|
||||
return crossing_cost
|
||||
|
||||
if rule == "prefer_aligned":
|
||||
alignment_cost = sum(
|
||||
(1 - math.cos(4 * p.theta)) / 2 for p in placement_map.values()
|
||||
)
|
||||
if is_hard:
|
||||
if not graduated:
|
||||
return math.inf if alignment_cost > 1e-6 else 0.0
|
||||
return HARD_MULTIPLIER * effective_weight * alignment_cost
|
||||
return effective_weight * alignment_cost
|
||||
|
||||
if rule == "prefer_seeder_orientation":
|
||||
target_thetas = params.get("target_thetas", {})
|
||||
cost = 0.0
|
||||
for dev_id, target in target_thetas.items():
|
||||
p = placement_map.get(dev_id)
|
||||
if p is None:
|
||||
continue
|
||||
# Circular distance: (1 - cos(diff)) / 2 gives 0..1 range
|
||||
diff = p.theta - target
|
||||
cost += (1 - math.cos(diff)) / 2
|
||||
return effective_weight * cost
|
||||
|
||||
if rule == "prefer_orientation_mode":
|
||||
mode = params.get("mode", "outward")
|
||||
center_x = lab.width / 2
|
||||
center_y = lab.depth / 2
|
||||
cost = 0.0
|
||||
for dev_id, p in placement_map.items():
|
||||
dev = device_map.get(dev_id)
|
||||
if dev is None:
|
||||
continue
|
||||
target = _desired_theta(
|
||||
p.x, p.y, center_x, center_y, dev, mode,
|
||||
)
|
||||
if target is None:
|
||||
continue
|
||||
diff = p.theta - target
|
||||
cost += (1 - math.cos(diff)) / 2
|
||||
return effective_weight * cost
|
||||
|
||||
# 未知约束类型,忽略
|
||||
return 0.0
|
||||
|
||||
|
||||
def _desired_theta(
|
||||
x: float, y: float,
|
||||
center_x: float, center_y: float,
|
||||
device: Device, mode: str,
|
||||
) -> float | None:
|
||||
"""Compute desired theta for outward/inward facing at the given position."""
|
||||
dx = x - center_x
|
||||
dy = y - center_y
|
||||
if abs(dx) < 1e-9 and abs(dy) < 1e-9:
|
||||
return None # At center, no preferred direction
|
||||
angle_to_device = math.atan2(dy, dx)
|
||||
front = device.openings[0].direction if device.openings else (0.0, -1.0)
|
||||
front_angle = math.atan2(front[1], front[0])
|
||||
if mode == "outward":
|
||||
target = angle_to_device
|
||||
elif mode == "inward":
|
||||
target = angle_to_device + math.pi
|
||||
else:
|
||||
return None
|
||||
return (target - front_angle) % (2 * math.pi)
|
||||
|
||||
|
||||
def _device_distance_center(a: Placement | None, b: Placement | None) -> float | None:
|
||||
"""计算两设备中心的欧几里得距离(后备方法)。"""
|
||||
if a is None or b is None:
|
||||
return None
|
||||
return math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2)
|
||||
|
||||
|
||||
def _device_distance_obb(
|
||||
device_a: Device, placement_a: Placement,
|
||||
device_b: Device, placement_b: Placement,
|
||||
) -> float:
|
||||
"""Minimum edge-to-edge distance between two devices using OBB."""
|
||||
corners_a = obb_corners(
|
||||
placement_a.x, placement_a.y,
|
||||
device_a.bbox[0], device_a.bbox[1],
|
||||
placement_a.theta,
|
||||
)
|
||||
corners_b = obb_corners(
|
||||
placement_b.x, placement_b.y,
|
||||
device_b.bbox[0], device_b.bbox[1],
|
||||
placement_b.theta,
|
||||
)
|
||||
return obb_min_distance(corners_a, corners_b)
|
||||
|
||||
|
||||
def _to_checker_format(
|
||||
devices: list[Device], placements: list[Placement]
|
||||
) -> list[dict]:
|
||||
"""转换为 CollisionChecker.check() 接受的格式。"""
|
||||
device_map = {d.id: d for d in devices}
|
||||
result = []
|
||||
for p in placements:
|
||||
dev = device_map.get(p.device_id)
|
||||
if dev is None:
|
||||
continue
|
||||
result.append({"id": p.device_id, "bbox": dev.bbox, "pos": (p.x, p.y, p.theta)})
|
||||
return result
|
||||
|
||||
|
||||
def _to_checker_format_from_maps(
|
||||
device_map: dict[str, Device], placement_map: dict[str, Placement]
|
||||
) -> list[dict]:
|
||||
"""从 map 转换为 CollisionChecker.check() 接受的格式。"""
|
||||
result = []
|
||||
for dev_id, p in placement_map.items():
|
||||
dev = device_map.get(dev_id)
|
||||
if dev is None:
|
||||
continue
|
||||
result.append({"id": dev_id, "bbox": dev.bbox, "pos": (p.x, p.y, p.theta)})
|
||||
return result
|
||||
|
||||
|
||||
def _opening_surface_center(
|
||||
device: Device, placement: Placement,
|
||||
) -> tuple[float, float]:
|
||||
"""Return the world-space center of the device's opening surface.
|
||||
|
||||
Computes where the opening direction intersects the device's bbox boundary,
|
||||
then transforms to world coordinates. For a device facing away from the arm,
|
||||
this point is on the far side — making the distance to the arm larger,
|
||||
which naturally penalizes wrong orientation.
|
||||
"""
|
||||
front = device.openings[0].direction if device.openings else (0.0, -1.0)
|
||||
dx, dy = front
|
||||
w, h = device.bbox
|
||||
|
||||
# Scale factor to reach bbox edge in the opening direction
|
||||
scales = []
|
||||
if abs(dx) > 1e-9:
|
||||
scales.append((w / 2) / abs(dx))
|
||||
if abs(dy) > 1e-9:
|
||||
scales.append((h / 2) / abs(dy))
|
||||
scale = min(scales) if scales else 0.0
|
||||
|
||||
# Opening center in local frame
|
||||
local_x = dx * scale
|
||||
local_y = dy * scale
|
||||
|
||||
# Rotate to world frame and translate
|
||||
cos_t = math.cos(placement.theta)
|
||||
sin_t = math.sin(placement.theta)
|
||||
world_x = placement.x + local_x * cos_t - local_y * sin_t
|
||||
world_y = placement.y + local_x * sin_t + local_y * cos_t
|
||||
return (world_x, world_y)
|
||||
|
||||
|
||||
def evaluate_default_hard_constraints_breakdown(
|
||||
devices: list[Device],
|
||||
placements: list[Placement],
|
||||
lab: Lab,
|
||||
collision_checker: CollisionChecker,
|
||||
*,
|
||||
collision_weight: float = DEFAULT_WEIGHT_DISTANCE * HARD_MULTIPLIER,
|
||||
boundary_weight: float = DEFAULT_WEIGHT_DISTANCE * HARD_MULTIPLIER,
|
||||
) -> dict[str, float]:
|
||||
"""与 evaluate_default_hard_constraints 逻辑相同,但返回分项明细。"""
|
||||
device_map = {d.id: d for d in devices}
|
||||
collision_cost = 0.0
|
||||
boundary_cost = 0.0
|
||||
|
||||
candidate_pairs = sweep_and_prune_pairs(devices, placements)
|
||||
for i, j in candidate_pairs:
|
||||
di, dj = device_map[placements[i].device_id], device_map[placements[j].device_id]
|
||||
ci = obb_corners(placements[i].x, placements[i].y,
|
||||
di.bbox[0], di.bbox[1], placements[i].theta)
|
||||
cj = obb_corners(placements[j].x, placements[j].y,
|
||||
dj.bbox[0], dj.bbox[1], placements[j].theta)
|
||||
depth = obb_penetration_depth(ci, cj)
|
||||
if depth > 0:
|
||||
collision_cost += collision_weight * depth
|
||||
|
||||
for p in placements:
|
||||
dev = device_map[p.device_id]
|
||||
hw, hd = p.rotated_bbox(dev)
|
||||
overshoot = 0.0
|
||||
overshoot += max(0.0, hw - p.x)
|
||||
overshoot += max(0.0, (p.x + hw) - lab.width)
|
||||
overshoot += max(0.0, hd - p.y)
|
||||
overshoot += max(0.0, (p.y + hd) - lab.depth)
|
||||
boundary_cost += boundary_weight * overshoot
|
||||
|
||||
return {
|
||||
"collision": collision_cost,
|
||||
"boundary": boundary_cost,
|
||||
"total": collision_cost + boundary_cost,
|
||||
"collision_weight": collision_weight,
|
||||
"boundary_weight": boundary_weight,
|
||||
}
|
||||
|
||||
|
||||
def evaluate_constraints_breakdown(
|
||||
devices: list[Device],
|
||||
placements: list[Placement],
|
||||
lab: Lab,
|
||||
constraints: list[Constraint],
|
||||
collision_checker: CollisionChecker,
|
||||
reachability_checker: ReachabilityChecker | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""与 evaluate_constraints 逻辑相同,但返回每条约束的分项明细。"""
|
||||
device_map = {d.id: d for d in devices}
|
||||
placement_map = {p.device_id: p for p in placements}
|
||||
|
||||
results = []
|
||||
for c in constraints:
|
||||
cost = _evaluate_single(
|
||||
c, device_map, placement_map, lab, collision_checker, reachability_checker,
|
||||
graduated=True,
|
||||
)
|
||||
results.append({
|
||||
"name": _constraint_display_name(c),
|
||||
"rule": c.rule_name,
|
||||
"type": c.type,
|
||||
"cost": cost,
|
||||
"weight": c.weight,
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def _missing_reference_cost(
|
||||
constraint: Constraint,
|
||||
placement_map: dict[str, Placement],
|
||||
*device_ids: str,
|
||||
) -> float | None:
|
||||
"""当约束引用不存在的设备时返回对应 cost。"""
|
||||
missing = sorted({device_id for device_id in device_ids if device_id not in placement_map})
|
||||
if not missing:
|
||||
return None
|
||||
|
||||
logger.warning(
|
||||
"Constraint %s references missing device IDs: %s",
|
||||
constraint.rule_name,
|
||||
", ".join(missing),
|
||||
)
|
||||
if constraint.type == "hard":
|
||||
return math.inf
|
||||
return 0.0
|
||||
|
||||
|
||||
def _constraint_display_name(c: Constraint) -> str:
|
||||
"""为约束生成可读的显示名称。"""
|
||||
params = c.params
|
||||
if c.rule_name in (
|
||||
"distance_less_than", "distance_greater_than",
|
||||
"minimize_distance", "maximize_distance",
|
||||
):
|
||||
return f"{c.rule_name}({params.get('device_a', '?')}, {params.get('device_b', '?')})"
|
||||
if c.rule_name == "reachability":
|
||||
return f"reachability({params.get('arm_id', '?')}, {params.get('target_device_id', '?')})"
|
||||
if c.rule_name == "min_spacing":
|
||||
return f"min_spacing(gap={params.get('min_gap', '?')})"
|
||||
if c.rule_name == "prefer_orientation_mode":
|
||||
return f"prefer_orientation_mode({params.get('mode', '?')})"
|
||||
return c.rule_name
|
||||
|
||||
|
||||
def _crossing_penalty(
|
||||
opening_pt: tuple[float, float],
|
||||
arm_nearest_pt: tuple[float, float],
|
||||
arm_id: str,
|
||||
target_id: str,
|
||||
device_map: dict[str, Device],
|
||||
placement_map: dict[str, Placement],
|
||||
) -> float:
|
||||
"""交叉惩罚:其他设备 OBB 遮挡 opening→arm 路径的长度加权 penalty。
|
||||
|
||||
Soft penalty,权重 = DEFAULT_WEIGHT_DISTANCE * 穿过各遮挡设备 OBB 的线段长度之和。
|
||||
始终生效(不论可达性是否通过),为 DE 提供清晰的梯度信号。
|
||||
"""
|
||||
cost = 0.0
|
||||
for dev_id, p in placement_map.items():
|
||||
if dev_id == arm_id or dev_id == target_id:
|
||||
continue
|
||||
dev = device_map.get(dev_id)
|
||||
if dev is None:
|
||||
continue
|
||||
corners = obb_corners(p.x, p.y, dev.bbox[0], dev.bbox[1], p.theta)
|
||||
crossing_len = segment_obb_intersection_length(opening_pt, arm_nearest_pt, corners)
|
||||
cost += DEFAULT_WEIGHT_DISTANCE * crossing_len
|
||||
return cost
|
||||
@@ -1,302 +0,0 @@
|
||||
"""双源设备目录:从 uni-lab-assets 和 Uni-Lab-OS registry 加载设备。
|
||||
|
||||
数据流:
|
||||
footprints.json (离线提取) + data.json (资产树) + registry device_mesh dirs
|
||||
→ merge → Device 列表
|
||||
|
||||
footprints.json 由 extract_footprints.py 生成,包含碰撞包围盒、开口方向等。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from .models import Device, Opening
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 默认路径(相对于本文件)
|
||||
_THIS_DIR = Path(__file__).resolve().parent
|
||||
_DEFAULT_FOOTPRINTS = _THIS_DIR / "footprints.json"
|
||||
|
||||
# 手动后备尺寸(trimesh 不可用时)
|
||||
KNOWN_SIZES: dict[str, tuple[float, float]] = {
|
||||
"elite_cs66_arm": (0.20, 0.20),
|
||||
"elite_cs612_arm": (0.20, 0.20),
|
||||
"ot2": (0.62, 0.50),
|
||||
"agilent_bravo": (0.80, 0.65),
|
||||
"thermo_orbitor_rs2": (0.45, 0.55),
|
||||
"hplc_station": (0.60, 0.50),
|
||||
"1_3m_hamilton_table": (1.30, 0.75),
|
||||
}
|
||||
|
||||
DEFAULT_BBOX: tuple[float, float] = (0.6, 0.4)
|
||||
|
||||
# ---------- footprints.json 加载 ----------
|
||||
|
||||
_footprints_cache: dict[str, dict] | None = None
|
||||
|
||||
|
||||
def load_footprints(path: str | Path = _DEFAULT_FOOTPRINTS) -> dict[str, dict]:
|
||||
"""加载 footprints.json 并缓存。"""
|
||||
global _footprints_cache
|
||||
if _footprints_cache is not None:
|
||||
return _footprints_cache
|
||||
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
logger.warning("footprints.json not found at %s", p)
|
||||
_footprints_cache = {}
|
||||
return _footprints_cache
|
||||
|
||||
with open(p) as f:
|
||||
_footprints_cache = json.load(f)
|
||||
logger.info("Loaded %d footprints from %s", len(_footprints_cache), p)
|
||||
return _footprints_cache
|
||||
|
||||
|
||||
def reset_footprints_cache() -> None:
|
||||
"""清除缓存(测试用)。"""
|
||||
global _footprints_cache
|
||||
_footprints_cache = None
|
||||
|
||||
|
||||
# ---------- 从 footprints 构建 Device ----------
|
||||
|
||||
|
||||
def _footprint_to_device(
|
||||
device_id: str,
|
||||
fp: dict,
|
||||
name: str = "",
|
||||
models_url_prefix: str = "/models",
|
||||
) -> Device:
|
||||
"""从 footprints.json 条目创建 Device。"""
|
||||
bbox = tuple(fp.get("bbox", DEFAULT_BBOX))
|
||||
openings = [
|
||||
Opening(direction=tuple(o["direction"]), label=o.get("label", ""))
|
||||
for o in fp.get("openings", [])
|
||||
]
|
||||
|
||||
model_file = fp.get("model_file", "")
|
||||
model_path = f"{models_url_prefix}/{device_id}/{model_file}" if model_file else ""
|
||||
model_type = fp.get("model_type", "")
|
||||
|
||||
thumb_file = fp.get("thumbnail_file", "")
|
||||
thumbnail_url = f"{models_url_prefix}/{device_id}/{thumb_file}" if thumb_file else ""
|
||||
|
||||
return Device(
|
||||
id=device_id,
|
||||
name=name or device_id.replace("_", " ").title(),
|
||||
bbox=bbox,
|
||||
device_type="articulation" if "robot" in device_id or "arm" in device_id or "flex" in device_id else "static",
|
||||
height=fp.get("height", 0.4),
|
||||
origin_offset=tuple(fp.get("origin_offset", [0.0, 0.0])),
|
||||
openings=openings,
|
||||
source=fp.get("source", "manual"),
|
||||
model_path=model_path,
|
||||
model_type=model_type,
|
||||
thumbnail_url=thumbnail_url,
|
||||
)
|
||||
|
||||
|
||||
# ---------- 从 data.json 加载 ----------
|
||||
|
||||
|
||||
def load_devices_from_assets(
|
||||
data_json_path: str | Path,
|
||||
footprints: dict[str, dict] | None = None,
|
||||
models_url_prefix: str = "/models",
|
||||
) -> list[Device]:
|
||||
"""从 uni-lab-assets 的 data.json 加载设备列表。
|
||||
|
||||
如果设备在 footprints 中有条目,使用真实尺寸;否则使用默认值。
|
||||
"""
|
||||
path = Path(data_json_path)
|
||||
if not path.exists():
|
||||
logger.warning("data.json not found at %s, returning empty list", path)
|
||||
return []
|
||||
|
||||
if footprints is None:
|
||||
footprints = load_footprints()
|
||||
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
|
||||
devices: list[Device] = []
|
||||
_flatten_tree(data, devices, footprints, models_url_prefix)
|
||||
return devices
|
||||
|
||||
|
||||
def _flatten_tree(
|
||||
nodes: list[dict],
|
||||
result: list[Device],
|
||||
footprints: dict[str, dict],
|
||||
models_url_prefix: str,
|
||||
) -> None:
|
||||
"""递归遍历树形结构,提取叶节点为 Device。"""
|
||||
for node in nodes:
|
||||
if "children" in node:
|
||||
_flatten_tree(node["children"], result, footprints, models_url_prefix)
|
||||
elif "id" in node:
|
||||
device_id = node["id"]
|
||||
name = node.get("label", device_id)
|
||||
|
||||
if device_id in footprints:
|
||||
dev = _footprint_to_device(
|
||||
device_id, footprints[device_id], name, models_url_prefix
|
||||
)
|
||||
else:
|
||||
bbox = KNOWN_SIZES.get(device_id, DEFAULT_BBOX)
|
||||
dev = Device(id=device_id, name=name, bbox=bbox, source="assets")
|
||||
|
||||
result.append(dev)
|
||||
|
||||
|
||||
# ---------- 从 registry 加载 ----------
|
||||
|
||||
|
||||
def load_devices_from_registry(
|
||||
device_mesh_dir: str | Path,
|
||||
footprints: dict[str, dict] | None = None,
|
||||
models_url_prefix: str = "/models",
|
||||
) -> list[Device]:
|
||||
"""从 Uni-Lab-OS device_mesh/devices/ 加载 registry 设备。"""
|
||||
d = Path(device_mesh_dir)
|
||||
if not d.exists():
|
||||
logger.warning("Registry dir not found at %s", d)
|
||||
return []
|
||||
|
||||
if footprints is None:
|
||||
footprints = load_footprints()
|
||||
|
||||
devices: list[Device] = []
|
||||
for entry in sorted(d.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
device_id = entry.name
|
||||
if device_id in footprints:
|
||||
dev = _footprint_to_device(
|
||||
device_id, footprints[device_id], models_url_prefix=models_url_prefix
|
||||
)
|
||||
dev.source = "registry"
|
||||
else:
|
||||
bbox = KNOWN_SIZES.get(device_id, DEFAULT_BBOX)
|
||||
dev = Device(id=device_id, name=device_id.replace("_", " ").title(), bbox=bbox, source="registry")
|
||||
devices.append(dev)
|
||||
|
||||
return devices
|
||||
|
||||
|
||||
# ---------- 合并与去重 ----------
|
||||
|
||||
|
||||
def merge_device_lists(
|
||||
registry_devices: list[Device],
|
||||
asset_devices: list[Device],
|
||||
) -> list[Device]:
|
||||
"""合并双源设备列表,registry 优先。
|
||||
|
||||
对于同时存在于两个源的设备,使用 registry 条目的元数据,
|
||||
但优先使用 assets 的 3D 模型路径和缩略图。
|
||||
"""
|
||||
merged: dict[str, Device] = {}
|
||||
|
||||
for dev in asset_devices:
|
||||
merged[dev.id] = dev
|
||||
|
||||
for dev in registry_devices:
|
||||
if dev.id in merged:
|
||||
# registry 元数据优先,但保留 assets 的模型/缩略图
|
||||
asset_dev = merged[dev.id]
|
||||
dev.model_path = dev.model_path or asset_dev.model_path
|
||||
dev.model_type = dev.model_type or asset_dev.model_type
|
||||
dev.thumbnail_url = dev.thumbnail_url or asset_dev.thumbnail_url
|
||||
if dev.bbox == DEFAULT_BBOX and asset_dev.bbox != DEFAULT_BBOX:
|
||||
dev.bbox = asset_dev.bbox
|
||||
dev.height = asset_dev.height
|
||||
dev.origin_offset = asset_dev.origin_offset
|
||||
dev.openings = asset_dev.openings
|
||||
dev.source = "registry"
|
||||
merged[dev.id] = dev
|
||||
|
||||
return list(merged.values())
|
||||
|
||||
|
||||
# ---------- 统一解析器 ----------
|
||||
|
||||
|
||||
def resolve_device(
|
||||
device_id: str,
|
||||
footprints: dict[str, dict] | None = None,
|
||||
models_url_prefix: str = "/models",
|
||||
) -> Device | None:
|
||||
"""按 ID 查找单个设备。先查 footprints,再查 KNOWN_SIZES。"""
|
||||
if footprints is None:
|
||||
footprints = load_footprints()
|
||||
|
||||
if device_id in footprints:
|
||||
return _footprint_to_device(
|
||||
device_id, footprints[device_id], models_url_prefix=models_url_prefix
|
||||
)
|
||||
|
||||
if device_id in KNOWN_SIZES:
|
||||
bbox = KNOWN_SIZES[device_id]
|
||||
return Device(id=device_id, name=device_id.replace("_", " ").title(), bbox=bbox, source="manual")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ---------- 向后兼容 ----------
|
||||
|
||||
|
||||
def create_devices_from_list(
|
||||
device_specs: list[dict],
|
||||
) -> list[Device]:
|
||||
"""从 API 请求中的设备列表创建 Device 对象(向后兼容)。
|
||||
|
||||
Args:
|
||||
device_specs: [{"id": str, "name": str, "size": [w, d], "uuid": str}, ...]
|
||||
size 可选,缺失时使用 footprints 或默认值。
|
||||
"""
|
||||
footprints = load_footprints()
|
||||
devices = []
|
||||
catalog_counts = Counter(spec["id"] for spec in device_specs)
|
||||
catalog_seen: Counter[str] = Counter()
|
||||
|
||||
for spec in device_specs:
|
||||
catalog_id = spec["id"]
|
||||
catalog_seen[catalog_id] += 1
|
||||
instance_idx = catalog_seen[catalog_id]
|
||||
if catalog_counts[catalog_id] > 1 and instance_idx > 1:
|
||||
dev_id = f"{catalog_id}#{instance_idx}"
|
||||
else:
|
||||
dev_id = catalog_id
|
||||
size = spec.get("size")
|
||||
if size:
|
||||
bbox = (float(size[0]), float(size[1]))
|
||||
elif catalog_id in footprints:
|
||||
bbox = tuple(footprints[catalog_id].get("bbox", DEFAULT_BBOX))
|
||||
else:
|
||||
bbox = KNOWN_SIZES.get(catalog_id, DEFAULT_BBOX)
|
||||
|
||||
openings = []
|
||||
if catalog_id in footprints:
|
||||
openings = [
|
||||
Opening(direction=tuple(o["direction"]), label=o.get("label", ""))
|
||||
for o in footprints[catalog_id].get("openings", [])
|
||||
]
|
||||
|
||||
devices.append(
|
||||
Device(
|
||||
id=dev_id,
|
||||
name=spec.get("name", catalog_id),
|
||||
bbox=bbox,
|
||||
device_type=spec.get("device_type", "static"),
|
||||
openings=openings,
|
||||
uuid=spec.get("uuid", ""),
|
||||
)
|
||||
)
|
||||
return devices
|
||||
@@ -1,559 +0,0 @@
|
||||
"""从 STL/GLB 网格提取设备足迹(碰撞包围盒)。
|
||||
|
||||
运行方式:
|
||||
conda activate phase3
|
||||
python -m layout_optimizer.extract_footprints
|
||||
|
||||
输出 footprints.json 供 device_catalog.py 和 2D 规划器使用。
|
||||
|
||||
GLB root node rotation:
|
||||
每个设备的 GLB 文件包含根节点旋转四元数,定义 STL 原生坐标到 glTF Y-up
|
||||
约定的轴映射。extract_one_device() 读取 GLB JSON,提取旋转矩阵,
|
||||
应用到 STL 包围盒后按 glTF 约定提取 2D 足迹 (X=width, Z=depth, Y=height)。
|
||||
GLB scale 不应用——STL 文件已是米制坐标。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 测试设备的开口方向(手动标注)
|
||||
# direction 为设备局部坐标系中的单位向量,[0, -1] 表示设备正前方
|
||||
MANUAL_OPENINGS: dict[str, list[dict]] = {
|
||||
"agilent_bravo": [{"direction": [0, -1], "label": "front_plate_slot"}],
|
||||
"opentrons_liquid_handler": [{"direction": [0, -1], "label": "front_deck"}],
|
||||
"opentrons_flex": [{"direction": [0, -1], "label": "front_deck"}],
|
||||
"thermo_orbitor_rs2_hotel": [{"direction": [0, -1], "label": "front_door"}],
|
||||
"hamilton_star": [{"direction": [0, -1], "label": "front_deck"}],
|
||||
"tecan_spark_plate_reader": [{"direction": [0, -1], "label": "front_slot"}],
|
||||
"highres_bio_plate_hotel_12": [{"direction": [0, -1], "label": "front_shelf"}],
|
||||
"beckman_coulter_orbital_shaker_alp": [],
|
||||
"liconic_str44_incubator": [{"direction": [0, -1], "label": "front_door"}],
|
||||
"elite_robot": [], # 机械臂,无开口
|
||||
}
|
||||
|
||||
# 手动尺寸后备(trimesh 提取失败时使用)
|
||||
FALLBACK_SIZES: dict[str, tuple[float, float, float]] = {
|
||||
"elite_robot": (0.20, 0.20, 0.10),
|
||||
"elite_cs66_arm": (0.20, 0.20, 0.10),
|
||||
"elite_cs612_arm": (0.20, 0.20, 0.10),
|
||||
}
|
||||
|
||||
|
||||
def extract_openings_from_xacro(
|
||||
xacro_path: Path,
|
||||
bbox_center_xy: tuple[float, float],
|
||||
bbox_size_xy: tuple[float, float],
|
||||
) -> list[dict]:
|
||||
"""从 XACRO 文件自动提取设备开口方向。
|
||||
|
||||
解析 fixed joint 中包含 "socket" 的关节,计算其 XY 质心,与包围盒中心比较,
|
||||
映射到最近的基本方向。
|
||||
|
||||
Args:
|
||||
xacro_path: modal.xacro 文件路径
|
||||
bbox_center_xy: 包围盒 XY 中心 (cx, cy)
|
||||
bbox_size_xy: 包围盒 XY 尺寸 (w, d)
|
||||
|
||||
Returns:
|
||||
[{"direction": [dx, dy], "label": "auto_xacro"}] 或 []
|
||||
"""
|
||||
# --- 方法1: ElementTree 解析(忽略 xacro 命名空间) ---
|
||||
socket_positions: list[tuple[float, float]] = []
|
||||
|
||||
try:
|
||||
xacro_text = xacro_path.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
# 去掉 xacro 命名空间前缀,避免 ElementTree 解析失败
|
||||
xacro_text_clean = re.sub(r'\bxacro:', '', xacro_text)
|
||||
|
||||
root = ET.fromstring(xacro_text_clean)
|
||||
|
||||
for joint in root.iter("joint"):
|
||||
joint_name = joint.get("name", "")
|
||||
joint_type = joint.get("type", "")
|
||||
if "socket" not in joint_name.lower():
|
||||
continue
|
||||
if joint_type != "fixed":
|
||||
continue
|
||||
origin = joint.find("origin")
|
||||
if origin is None:
|
||||
continue
|
||||
xyz_str = origin.get("xyz", "")
|
||||
if not xyz_str:
|
||||
continue
|
||||
parts = xyz_str.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
try:
|
||||
x = float(parts[0])
|
||||
y = float(parts[1])
|
||||
socket_positions.append((x, y))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
except ET.ParseError as e:
|
||||
logger.debug("ElementTree parse error for %s: %s — falling back to regex", xacro_path, e)
|
||||
|
||||
# --- 方法2: 正则表达式后备(当 ElementTree 失败或无结果时) ---
|
||||
if not socket_positions:
|
||||
try:
|
||||
xacro_text = xacro_path.read_text(encoding="utf-8", errors="replace")
|
||||
# 匹配包含 "socket" 的 joint 块,提取 origin xyz
|
||||
joint_blocks = re.findall(
|
||||
r'<joint\s[^>]*name=["\'][^"\']*socket[^"\']*["\'][^>]*>.*?</joint>',
|
||||
xacro_text,
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
for block in joint_blocks:
|
||||
# 只处理 fixed 类型
|
||||
if 'type="fixed"' not in block and "type='fixed'" not in block:
|
||||
continue
|
||||
xyz_match = re.search(r'<origin[^>]*xyz=["\']([^"\']+)["\']', block)
|
||||
if not xyz_match:
|
||||
continue
|
||||
parts = xyz_match.group(1).split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
try:
|
||||
x = float(parts[0])
|
||||
y = float(parts[1])
|
||||
socket_positions.append((x, y))
|
||||
except ValueError:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug("Regex fallback also failed for %s: %s", xacro_path, e)
|
||||
|
||||
if not socket_positions:
|
||||
return []
|
||||
|
||||
# 计算 socket XY 质心
|
||||
cx_sock = sum(p[0] for p in socket_positions) / len(socket_positions)
|
||||
cy_sock = sum(p[1] for p in socket_positions) / len(socket_positions)
|
||||
|
||||
# 方向向量:从包围盒中心指向 socket 质心
|
||||
dx = cx_sock - bbox_center_xy[0]
|
||||
dy = cy_sock - bbox_center_xy[1]
|
||||
|
||||
# 如果 socket 质心非常靠近包围盒中心(<5% 尺寸),判断为顶部装载
|
||||
threshold = 0.05 * max(bbox_size_xy[0], bbox_size_xy[1], 1e-6)
|
||||
if math.hypot(dx, dy) < threshold:
|
||||
logger.debug(
|
||||
"%s: socket centroid too close to bbox center (dist=%.4f, threshold=%.4f) → top-loading",
|
||||
xacro_path.parent.name,
|
||||
math.hypot(dx, dy),
|
||||
threshold,
|
||||
)
|
||||
return []
|
||||
|
||||
# 映射到最近基本方向
|
||||
# socket 质心指示交互区在设备哪一侧,而 opening direction 是从该面
|
||||
# 向外的法线方向(与质心偏移同向),这里的 dx/dy 已经是从包围盒中心
|
||||
# 指向 socket 区域的方向,即 opening 朝外的方向
|
||||
# 注意:在 uni-lab-assets 中,大多数设备 front 在 Y=0 而 body 在 -Y,
|
||||
# 所以 socket 集中在 +Y 侧(靠近 Y=0 前端),bbox 中心在 -Y/2。
|
||||
# 方向 center→socket = +Y,但 "opening faces front" 在手动标注中
|
||||
# 写作 [0, -1](法线向外=向操作者方向)。
|
||||
# 因此需要取反:opening direction = -(center→socket)
|
||||
if abs(dx) >= abs(dy):
|
||||
cardinal = [-1, 0] if dx > 0 else [1, 0]
|
||||
else:
|
||||
cardinal = [0, -1] if dy > 0 else [0, 1]
|
||||
|
||||
logger.debug(
|
||||
"%s: %d socket joints → centroid=(%.3f, %.3f) dir=%s",
|
||||
xacro_path.parent.name,
|
||||
len(socket_positions),
|
||||
cx_sock,
|
||||
cy_sock,
|
||||
cardinal,
|
||||
)
|
||||
return [{"direction": cardinal, "label": "auto_xacro"}]
|
||||
|
||||
|
||||
def _find_mesh_files(device_dir: Path) -> list[Path]:
|
||||
"""查找设备目录中的所有 STL/GLB 网格文件。"""
|
||||
mesh_files: list[Path] = []
|
||||
meshes_dir = device_dir / "meshes"
|
||||
if not meshes_dir.exists():
|
||||
return mesh_files
|
||||
|
||||
# uni-lab-assets 结构: meshes/*.stl, meshes/*.glb
|
||||
for f in meshes_dir.iterdir():
|
||||
if f.suffix.lower() in (".stl", ".glb"):
|
||||
mesh_files.append(f)
|
||||
|
||||
# registry 结构: meshes/<variant>/collision/*.stl
|
||||
if not mesh_files:
|
||||
for variant_dir in meshes_dir.iterdir():
|
||||
if variant_dir.is_dir():
|
||||
collision_dir = variant_dir / "collision"
|
||||
if collision_dir.exists():
|
||||
for f in collision_dir.iterdir():
|
||||
if f.suffix.lower() == ".stl":
|
||||
mesh_files.append(f)
|
||||
if mesh_files:
|
||||
break # 使用找到的第一个变体
|
||||
|
||||
return sorted(mesh_files)
|
||||
|
||||
|
||||
def _find_best_model_file(device_dir: Path) -> tuple[str, str]:
|
||||
"""找到最佳可展示的模型文件。优先 GLB > STL。
|
||||
|
||||
Returns:
|
||||
(relative_path, model_type) e.g. ("meshes/0_base.glb", "gltf")
|
||||
"""
|
||||
meshes_dir = device_dir / "meshes"
|
||||
if not meshes_dir.exists():
|
||||
return "", ""
|
||||
|
||||
glbs = sorted(meshes_dir.glob("*.glb"))
|
||||
if glbs:
|
||||
return f"meshes/{glbs[0].name}", "gltf"
|
||||
|
||||
stls = sorted(f for f in meshes_dir.glob("*.stl") if f.suffix == ".stl")
|
||||
if not stls:
|
||||
stls = sorted(f for f in meshes_dir.glob("*.STL"))
|
||||
if stls:
|
||||
return f"meshes/{stls[0].name}", "stl"
|
||||
|
||||
return "", ""
|
||||
|
||||
|
||||
def _find_thumbnail(device_dir: Path) -> str:
|
||||
"""查找设备目录中的第一个 PNG 缩略图。"""
|
||||
pngs = sorted(device_dir.glob("*.png"))
|
||||
if pngs:
|
||||
return pngs[0].name
|
||||
return ""
|
||||
|
||||
|
||||
def _read_glb_json(glb_path: Path) -> dict | None:
|
||||
"""Read the JSON chunk from a GLB (Binary glTF) file.
|
||||
|
||||
GLB structure: 12-byte header + chunks. Chunk 0 is JSON.
|
||||
Returns parsed dict or None on failure.
|
||||
"""
|
||||
try:
|
||||
with open(glb_path, "rb") as f:
|
||||
header = f.read(12)
|
||||
if len(header) < 12:
|
||||
return None
|
||||
magic, version, length = struct.unpack("<III", header)
|
||||
if magic != 0x46546C67: # 'glTF'
|
||||
return None
|
||||
chunk_header = f.read(8)
|
||||
if len(chunk_header) < 8:
|
||||
return None
|
||||
chunk_length, chunk_type = struct.unpack("<II", chunk_header)
|
||||
if chunk_type != 0x4E4F534A: # 'JSON'
|
||||
return None
|
||||
json_bytes = f.read(chunk_length)
|
||||
return json.loads(json_bytes)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to read GLB JSON from %s: %s", glb_path, e)
|
||||
return None
|
||||
|
||||
|
||||
def _quat_to_matrix(q: list[float]) -> list[list[float]]:
|
||||
"""Convert quaternion [x, y, z, w] to 3×3 rotation matrix."""
|
||||
x, y, z, w = q
|
||||
return [
|
||||
[1 - 2*(y*y + z*z), 2*(x*y - z*w), 2*(x*z + y*w)],
|
||||
[ 2*(x*y + z*w), 1 - 2*(x*x + z*z), 2*(y*z - x*w)],
|
||||
[ 2*(x*z - y*w), 2*(y*z + x*w), 1 - 2*(x*x + y*y)],
|
||||
]
|
||||
|
||||
|
||||
def _get_glb_root_rotation(device_dir: Path) -> list[list[float]] | None:
|
||||
"""Extract root node rotation matrix from the first GLB in device_dir/meshes/.
|
||||
|
||||
Only rotation is extracted — GLB scale is NOT applied because STL files
|
||||
are already in meters while GLB scale converts GLB mesh units (often mm)
|
||||
to scene units. Since we read STL directly, scale is irrelevant.
|
||||
|
||||
Returns 3×3 rotation matrix or None if no GLB or no rotation found.
|
||||
"""
|
||||
meshes_dir = device_dir / "meshes"
|
||||
if not meshes_dir.exists():
|
||||
return None
|
||||
glbs = sorted(meshes_dir.glob("*.glb"))
|
||||
if not glbs:
|
||||
return None
|
||||
|
||||
gltf = _read_glb_json(glbs[0])
|
||||
if gltf is None:
|
||||
return None
|
||||
|
||||
nodes = gltf.get("nodes", [])
|
||||
if not nodes:
|
||||
return None
|
||||
|
||||
root = nodes[0]
|
||||
rotation = root.get("rotation")
|
||||
if rotation is None:
|
||||
return None
|
||||
|
||||
# Skip identity quaternion [0,0,0,1]
|
||||
x, y, z, w = rotation
|
||||
if abs(x) < 1e-9 and abs(y) < 1e-9 and abs(z) < 1e-9 and abs(w - 1.0) < 1e-9:
|
||||
return None
|
||||
|
||||
return _quat_to_matrix(rotation)
|
||||
|
||||
|
||||
def _apply_rotation_to_bbox(
|
||||
stl_min: list[float], stl_max: list[float],
|
||||
rot: list[list[float]],
|
||||
) -> tuple[list[float], list[float]]:
|
||||
"""Apply rotation to an axis-aligned bounding box.
|
||||
|
||||
Transforms all 8 corners of the STL AABB through rotation,
|
||||
then computes the new AABB in glTF space.
|
||||
"""
|
||||
corners = []
|
||||
for x in (stl_min[0], stl_max[0]):
|
||||
for y in (stl_min[1], stl_max[1]):
|
||||
for z in (stl_min[2], stl_max[2]):
|
||||
tx = rot[0][0]*x + rot[0][1]*y + rot[0][2]*z
|
||||
ty = rot[1][0]*x + rot[1][1]*y + rot[1][2]*z
|
||||
tz = rot[2][0]*x + rot[2][1]*y + rot[2][2]*z
|
||||
corners.append((tx, ty, tz))
|
||||
|
||||
xs = [c[0] for c in corners]
|
||||
ys = [c[1] for c in corners]
|
||||
zs = [c[2] for c in corners]
|
||||
return [min(xs), min(ys), min(zs)], [max(xs), max(ys), max(zs)]
|
||||
|
||||
|
||||
def extract_one_device(device_dir: Path) -> dict | None:
|
||||
"""提取单个设备的足迹信息。"""
|
||||
try:
|
||||
import trimesh
|
||||
except ImportError:
|
||||
logger.error("trimesh not installed. Run: pip install trimesh")
|
||||
return None
|
||||
|
||||
mesh_files = _find_mesh_files(device_dir)
|
||||
if not mesh_files:
|
||||
return None
|
||||
|
||||
# 加载所有网格部件并计算联合包围盒
|
||||
meshes = []
|
||||
for f in mesh_files:
|
||||
try:
|
||||
m = trimesh.load(str(f), force="mesh")
|
||||
if hasattr(m, "bounds") and m.bounds is not None:
|
||||
meshes.append(m)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load %s: %s", f, e)
|
||||
|
||||
if not meshes:
|
||||
return None
|
||||
|
||||
if len(meshes) == 1:
|
||||
combined = meshes[0]
|
||||
else:
|
||||
combined = trimesh.util.concatenate(meshes)
|
||||
|
||||
bounds = combined.bounds
|
||||
stl_min = [float(bounds[0][i]) for i in range(3)]
|
||||
stl_max = [float(bounds[1][i]) for i in range(3)]
|
||||
|
||||
# 应用 GLB 根节点旋转到 STL 包围盒(scale 不应用 — STL 已是米制)
|
||||
# glTF 约定: X=right, Y=up, Z=forward → 2D 足迹取 X 和 Z, 高度取 Y
|
||||
rot = _get_glb_root_rotation(device_dir)
|
||||
if rot is not None:
|
||||
t_min, t_max = _apply_rotation_to_bbox(stl_min, stl_max, rot)
|
||||
t_size = [t_max[i] - t_min[i] for i in range(3)]
|
||||
t_center = [(t_min[i] + t_max[i]) / 2 for i in range(3)]
|
||||
# glTF Y-up: X=width, Z=depth, Y=height
|
||||
bbox_w = round(t_size[0], 4)
|
||||
bbox_d = round(t_size[2], 4)
|
||||
height = round(t_size[1], 4)
|
||||
origin_offset = [round(t_center[0], 4), round(t_center[2], 4)]
|
||||
logger.debug(
|
||||
"%s: GLB rotation applied → bbox=[%.3f, %.3f] height=%.3f",
|
||||
device_dir.name, bbox_w, bbox_d, height,
|
||||
)
|
||||
else:
|
||||
# 无 GLB 或 identity rotation → 沿用原始 STL 坐标 (X=width, Y=depth, Z=height)
|
||||
size = [stl_max[i] - stl_min[i] for i in range(3)]
|
||||
center = [(stl_min[i] + stl_max[i]) / 2 for i in range(3)]
|
||||
bbox_w = round(size[0], 4)
|
||||
bbox_d = round(size[1], 4)
|
||||
height = round(size[2], 4)
|
||||
origin_offset = [round(center[0], 4), round(center[1], 4)]
|
||||
|
||||
model_file, model_type = _find_best_model_file(device_dir)
|
||||
thumbnail_file = _find_thumbnail(device_dir)
|
||||
|
||||
device_id = device_dir.name
|
||||
|
||||
# 确定 openings:手动标注优先,否则尝试从 XACRO 自动提取
|
||||
# 注意:XACRO socket 坐标是 STL 原生坐标系,这里传入变换后的 bbox
|
||||
if device_id in MANUAL_OPENINGS:
|
||||
openings = MANUAL_OPENINGS[device_id]
|
||||
else:
|
||||
xacro_path = device_dir / "modal.xacro"
|
||||
if xacro_path.exists():
|
||||
openings = extract_openings_from_xacro(
|
||||
xacro_path,
|
||||
bbox_center_xy=(origin_offset[0], origin_offset[1]),
|
||||
bbox_size_xy=(bbox_w, bbox_d),
|
||||
)
|
||||
else:
|
||||
openings = []
|
||||
|
||||
result: dict = {
|
||||
"bbox": [bbox_w, bbox_d],
|
||||
"height": height,
|
||||
"origin_offset": origin_offset,
|
||||
"model_file": model_file,
|
||||
"model_type": model_type,
|
||||
"thumbnail_file": thumbnail_file,
|
||||
"openings": openings,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def extract_all(
|
||||
assets_dir: Path | None = None,
|
||||
registry_dir: Path | None = None,
|
||||
device_ids: list[str] | None = None,
|
||||
) -> dict[str, dict]:
|
||||
"""提取所有(或指定)设备的足迹。
|
||||
|
||||
Args:
|
||||
assets_dir: uni-lab-assets/device_models/ 路径
|
||||
registry_dir: Uni-Lab-OS/unilabos/device_mesh/devices/ 路径
|
||||
device_ids: 仅提取指定设备(None = 全部扫描)
|
||||
|
||||
Returns:
|
||||
{device_id: footprint_dict}
|
||||
"""
|
||||
results: dict[str, dict] = {}
|
||||
|
||||
dirs_to_scan: list[tuple[Path, str]] = []
|
||||
|
||||
if assets_dir and assets_dir.exists():
|
||||
for d in sorted(assets_dir.iterdir()):
|
||||
if d.is_dir() and (device_ids is None or d.name in device_ids):
|
||||
dirs_to_scan.append((d, "assets"))
|
||||
|
||||
if registry_dir and registry_dir.exists():
|
||||
for d in sorted(registry_dir.iterdir()):
|
||||
if d.is_dir() and (device_ids is None or d.name in device_ids):
|
||||
if d.name not in results: # assets 已有的不重复扫描
|
||||
dirs_to_scan.append((d, "registry"))
|
||||
|
||||
for device_dir, source in dirs_to_scan:
|
||||
device_id = device_dir.name
|
||||
if device_id in results:
|
||||
continue
|
||||
|
||||
footprint = extract_one_device(device_dir)
|
||||
if footprint:
|
||||
footprint["source"] = source
|
||||
results[device_id] = footprint
|
||||
logger.info(
|
||||
"Extracted %s: bbox=%s height=%.3f source=%s",
|
||||
device_id,
|
||||
footprint["bbox"],
|
||||
footprint["height"],
|
||||
source,
|
||||
)
|
||||
|
||||
# 统计自动提取的 openings 数量
|
||||
auto_xacro_count = sum(
|
||||
1
|
||||
for fp in results.values()
|
||||
if any(o.get("label") == "auto_xacro" for o in fp.get("openings", []))
|
||||
)
|
||||
logger.info(
|
||||
"Auto-extracted openings from XACRO for %d / %d devices",
|
||||
auto_xacro_count,
|
||||
len(results),
|
||||
)
|
||||
|
||||
# 手动后备
|
||||
for dev_id, (w, d, h) in FALLBACK_SIZES.items():
|
||||
if dev_id not in results:
|
||||
results[dev_id] = {
|
||||
"bbox": [w, d],
|
||||
"height": h,
|
||||
"origin_offset": [0.0, 0.0],
|
||||
"model_file": "",
|
||||
"model_type": "",
|
||||
"thumbnail_file": "",
|
||||
"openings": MANUAL_OPENINGS.get(dev_id, []),
|
||||
"source": "manual",
|
||||
}
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Extract device footprints from STL/GLB meshes"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--assets-dir",
|
||||
type=Path,
|
||||
default=Path(__file__).resolve().parent.parent / "uni-lab-assets" / "device_models",
|
||||
help="Path to uni-lab-assets/device_models/",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--registry-dir",
|
||||
type=Path,
|
||||
default=Path(__file__).resolve().parent / "Uni-Lab-OS" / "unilabos" / "device_mesh" / "devices",
|
||||
help="Path to Uni-Lab-OS device_mesh/devices/",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=Path,
|
||||
default=Path(__file__).resolve().parent / "footprints.json",
|
||||
help="Output JSON path",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--devices",
|
||||
nargs="*",
|
||||
default=None,
|
||||
help="Only extract these device IDs (default: all)",
|
||||
)
|
||||
parser.add_argument("-v", "--verbose", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if args.verbose else logging.INFO,
|
||||
format="%(levelname)s: %(message)s",
|
||||
)
|
||||
|
||||
logger.info("Assets dir: %s (exists=%s)", args.assets_dir, args.assets_dir.exists())
|
||||
logger.info("Registry dir: %s (exists=%s)", args.registry_dir, args.registry_dir.exists())
|
||||
|
||||
results = extract_all(
|
||||
assets_dir=args.assets_dir,
|
||||
registry_dir=args.registry_dir,
|
||||
device_ids=args.devices,
|
||||
)
|
||||
|
||||
with open(args.output, "w") as f:
|
||||
json.dump(results, f, indent=2, ensure_ascii=False)
|
||||
|
||||
logger.info("Wrote %d devices to %s", len(results), args.output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,187 +0,0 @@
|
||||
"""
|
||||
Generate a YAML registry file for all devices in uni-lab-assets that don't
|
||||
already have a registry entry (identified by model.mesh value).
|
||||
|
||||
Output: Uni-Lab-OS/unilabos/registry/devices/asset_models.yaml
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paths (resolved relative to this script's location)
|
||||
# ---------------------------------------------------------------------------
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
|
||||
ASSETS_DIR = REPO_ROOT.parent / "uni-lab-assets" / "device_models"
|
||||
REGISTRY_DIR = REPO_ROOT / "Uni-Lab-OS" / "unilabos" / "registry" / "devices"
|
||||
OUTPUT_FILE = REGISTRY_DIR / "asset_models.yaml"
|
||||
|
||||
OSS_BASE = (
|
||||
"https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices"
|
||||
)
|
||||
CONTAINER_CLASS = (
|
||||
"unilabos.devices.resource_container.container:HotelContainer"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 1 — collect mesh names already present in the registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def collect_registered_meshes() -> set[str]:
|
||||
"""Return the set of mesh values found in all existing registry YAML files."""
|
||||
registered: set[str] = set()
|
||||
for yaml_file in REGISTRY_DIR.glob("*.yaml"):
|
||||
# Skip the output file itself so the script is idempotent
|
||||
if yaml_file == OUTPUT_FILE:
|
||||
continue
|
||||
try:
|
||||
with yaml_file.open("r", encoding="utf-8") as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
except Exception as exc:
|
||||
print(f" [warn] Could not parse {yaml_file.name}: {exc}")
|
||||
continue
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
for _key, entry in data.items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
model = entry.get("model")
|
||||
if isinstance(model, dict):
|
||||
mesh = model.get("mesh")
|
||||
if mesh:
|
||||
registered.add(str(mesh))
|
||||
return registered
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 2 — scan uni-lab-assets/device_models/
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def scan_asset_devices() -> list[dict]:
|
||||
"""
|
||||
Return a list of device dicts for every subfolder that has a modal.xacro.
|
||||
Each dict has keys: folder_name, description.
|
||||
"""
|
||||
devices = []
|
||||
if not ASSETS_DIR.is_dir():
|
||||
raise FileNotFoundError(f"Assets directory not found: {ASSETS_DIR}")
|
||||
|
||||
for device_dir in sorted(ASSETS_DIR.iterdir()):
|
||||
if not device_dir.is_dir():
|
||||
continue
|
||||
|
||||
folder_name = device_dir.name
|
||||
|
||||
# modal.xacro is required
|
||||
if not (device_dir / "modal.xacro").exists():
|
||||
continue
|
||||
|
||||
# Read optional meta.json
|
||||
description = folder_name
|
||||
meta_path = device_dir / "meta.json"
|
||||
if meta_path.exists():
|
||||
try:
|
||||
with meta_path.open("r", encoding="utf-8") as fh:
|
||||
meta = json.load(fh)
|
||||
# Use name field if present; otherwise fall back to folder name
|
||||
description = meta.get("name", folder_name)
|
||||
except Exception as exc:
|
||||
print(f" [warn] Could not parse {meta_path}: {exc}")
|
||||
|
||||
devices.append(
|
||||
{
|
||||
"folder_name": folder_name,
|
||||
"description": description,
|
||||
}
|
||||
)
|
||||
|
||||
return devices
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 3 — build registry entry for a single device
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_entry(folder_name: str, description: str) -> dict:
|
||||
return {
|
||||
"category": ["asset_model"],
|
||||
"class": {
|
||||
"action_value_mappings": {},
|
||||
"module": CONTAINER_CLASS,
|
||||
"status_types": {},
|
||||
"type": "python",
|
||||
},
|
||||
"config_info": [],
|
||||
"description": description,
|
||||
"handles": [],
|
||||
"icon": "",
|
||||
"init_param_schema": {
|
||||
"config": {
|
||||
"properties": {},
|
||||
"required": [],
|
||||
"type": "object",
|
||||
},
|
||||
"data": {
|
||||
"properties": {},
|
||||
"required": [],
|
||||
"type": "object",
|
||||
},
|
||||
},
|
||||
"model": {
|
||||
"mesh": folder_name,
|
||||
"path": f"{OSS_BASE}/{folder_name}/macro_device.xacro",
|
||||
"type": "device",
|
||||
},
|
||||
"version": "1.0.0",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
print("Scanning existing registry for registered meshes...")
|
||||
registered_meshes = collect_registered_meshes()
|
||||
print(f" Found {len(registered_meshes)} already-registered mesh(es).")
|
||||
|
||||
print(f"\nScanning asset devices in: {ASSETS_DIR}")
|
||||
all_devices = scan_asset_devices()
|
||||
print(f" Found {len(all_devices)} device folder(s) with modal.xacro.")
|
||||
|
||||
registry: dict[str, dict] = {}
|
||||
skipped = 0
|
||||
generated = 0
|
||||
|
||||
for device in all_devices:
|
||||
folder_name = device["folder_name"]
|
||||
if folder_name in registered_meshes:
|
||||
skipped += 1
|
||||
continue
|
||||
key = f"asset_model.{folder_name}"
|
||||
registry[key] = build_entry(folder_name, device["description"])
|
||||
generated += 1
|
||||
|
||||
print(f"\nWriting {generated} new entr(ies) to: {OUTPUT_FILE}")
|
||||
OUTPUT_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with OUTPUT_FILE.open("w", encoding="utf-8") as fh:
|
||||
yaml.dump(
|
||||
registry,
|
||||
fh,
|
||||
default_flow_style=False,
|
||||
allow_unicode=True,
|
||||
sort_keys=False,
|
||||
)
|
||||
|
||||
print("\n--- Summary ---")
|
||||
print(f" Total devices found (with modal.xacro): {len(all_devices)}")
|
||||
print(f" Already registered (skipped): {skipped}")
|
||||
print(f" Newly generated: {generated}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,373 +0,0 @@
|
||||
"""意图解释器:将语义化意图翻译为 Constraint 列表。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .constraints import PRIORITY_MULTIPLIERS
|
||||
from .models import Constraint, Intent
|
||||
|
||||
# 优先级权重映射
|
||||
_PRIORITY_WEIGHTS: dict[str, float] = {"low": 1.0, "medium": 3.0, "high": 8.0}
|
||||
_DEFAULT_WEIGHT = _PRIORITY_WEIGHTS["medium"]
|
||||
|
||||
|
||||
def _priority_key(priority: str) -> str:
|
||||
"""将 intent priority 映射到 constraint 权重等级。"""
|
||||
return "normal" if priority == "medium" else priority
|
||||
|
||||
|
||||
def _final_weight(base_weight: float, priority: str) -> float:
|
||||
"""在解释阶段直接烘焙优先级乘数。"""
|
||||
return base_weight * PRIORITY_MULTIPLIERS.get(priority, 1.0)
|
||||
|
||||
|
||||
@dataclass
|
||||
class InterpretResult:
|
||||
"""意图解释结果。"""
|
||||
|
||||
constraints: list[Constraint] = field(default_factory=list)
|
||||
translations: list[dict] = field(default_factory=list)
|
||||
errors: list[str] = field(default_factory=list)
|
||||
workflow_edges: list[list[str]] = field(default_factory=list)
|
||||
|
||||
|
||||
def _handle_reachable_by(intent: Intent, result: InterpretResult) -> None:
|
||||
"""reachable_by:机械臂必须能到达指定设备列表。"""
|
||||
arm = intent.params.get("arm")
|
||||
targets = intent.params.get("targets", [])
|
||||
|
||||
if arm is None:
|
||||
result.errors.append(f"reachable_by: 缺少必要参数 'arm'")
|
||||
return
|
||||
if not targets:
|
||||
result.errors.append(f"reachable_by: 参数 'targets' 不能为空")
|
||||
return
|
||||
|
||||
generated: list[dict] = []
|
||||
for target in targets:
|
||||
c = Constraint(
|
||||
type="hard",
|
||||
rule_name="reachability",
|
||||
params={"arm_id": arm, "target_device_id": target},
|
||||
weight=_final_weight(1.0, "critical"),
|
||||
)
|
||||
result.constraints.append(c)
|
||||
generated.append({"type": c.type, "rule_name": c.rule_name, "params": c.params, "weight": c.weight})
|
||||
|
||||
result.translations.append({
|
||||
"source_intent": intent.intent,
|
||||
"source_description": intent.description,
|
||||
"source_params": intent.params,
|
||||
"generated_constraints": generated,
|
||||
"explanation": f"机械臂 '{arm}' 需要能够到达 {len(targets)} 个目标设备",
|
||||
})
|
||||
|
||||
|
||||
def _handle_close_together(intent: Intent, result: InterpretResult) -> None:
|
||||
"""close_together:设备组内两两最小化距离。"""
|
||||
devices: list[str] = intent.params.get("devices", [])
|
||||
priority: str = intent.params.get("priority", "medium")
|
||||
|
||||
if len(devices) < 2:
|
||||
result.errors.append(f"close_together: 参数 'devices' 至少需要 2 个设备,当前 {len(devices)} 个")
|
||||
return
|
||||
|
||||
weight = _final_weight(
|
||||
_PRIORITY_WEIGHTS.get(priority, _DEFAULT_WEIGHT),
|
||||
_priority_key(priority),
|
||||
)
|
||||
generated: list[dict] = []
|
||||
for dev_a, dev_b in itertools.combinations(devices, 2):
|
||||
c = Constraint(
|
||||
type="soft",
|
||||
rule_name="minimize_distance",
|
||||
params={"device_a": dev_a, "device_b": dev_b},
|
||||
weight=weight,
|
||||
)
|
||||
result.constraints.append(c)
|
||||
generated.append({"type": c.type, "rule_name": c.rule_name, "params": c.params, "weight": c.weight})
|
||||
|
||||
result.translations.append({
|
||||
"source_intent": intent.intent,
|
||||
"source_description": intent.description,
|
||||
"source_params": intent.params,
|
||||
"generated_constraints": generated,
|
||||
"explanation": f"设备组 {devices} 应尽量靠近(优先级: {priority})",
|
||||
})
|
||||
|
||||
|
||||
def _handle_far_apart(intent: Intent, result: InterpretResult) -> None:
|
||||
"""far_apart:设备组内两两最大化距离。"""
|
||||
devices: list[str] = intent.params.get("devices", [])
|
||||
priority: str = intent.params.get("priority", "medium")
|
||||
|
||||
if len(devices) < 2:
|
||||
result.errors.append(f"far_apart: 参数 'devices' 至少需要 2 个设备,当前 {len(devices)} 个")
|
||||
return
|
||||
|
||||
weight = _final_weight(
|
||||
_PRIORITY_WEIGHTS.get(priority, _DEFAULT_WEIGHT),
|
||||
_priority_key(priority),
|
||||
)
|
||||
generated: list[dict] = []
|
||||
for dev_a, dev_b in itertools.combinations(devices, 2):
|
||||
c = Constraint(
|
||||
type="soft",
|
||||
rule_name="maximize_distance",
|
||||
params={"device_a": dev_a, "device_b": dev_b},
|
||||
weight=weight,
|
||||
)
|
||||
result.constraints.append(c)
|
||||
generated.append({"type": c.type, "rule_name": c.rule_name, "params": c.params, "weight": c.weight})
|
||||
|
||||
result.translations.append({
|
||||
"source_intent": intent.intent,
|
||||
"source_description": intent.description,
|
||||
"source_params": intent.params,
|
||||
"generated_constraints": generated,
|
||||
"explanation": f"设备组 {devices} 应尽量分散(优先级: {priority})",
|
||||
})
|
||||
|
||||
|
||||
def _handle_max_distance(intent: Intent, result: InterpretResult) -> None:
|
||||
"""max_distance:两设备间距不超过指定值。"""
|
||||
device_a = intent.params.get("device_a")
|
||||
device_b = intent.params.get("device_b")
|
||||
distance = intent.params.get("distance")
|
||||
|
||||
if device_a is None or device_b is None or distance is None:
|
||||
result.errors.append(
|
||||
f"max_distance: 缺少必要参数,需要 'device_a'、'device_b' 和 'distance',"
|
||||
f"当前: device_a={device_a}, device_b={device_b}, distance={distance}"
|
||||
)
|
||||
return
|
||||
|
||||
c = Constraint(
|
||||
type="hard",
|
||||
rule_name="distance_less_than",
|
||||
params={"device_a": device_a, "device_b": device_b, "distance": distance},
|
||||
weight=_final_weight(1.0, "normal"),
|
||||
)
|
||||
result.constraints.append(c)
|
||||
|
||||
result.translations.append({
|
||||
"source_intent": intent.intent,
|
||||
"source_description": intent.description,
|
||||
"source_params": intent.params,
|
||||
"generated_constraints": [{"type": c.type, "rule_name": c.rule_name, "params": c.params, "weight": c.weight}],
|
||||
"explanation": f"设备 '{device_a}' 与 '{device_b}' 之间的距离不得超过 {distance} 米",
|
||||
})
|
||||
|
||||
|
||||
def _handle_min_distance(intent: Intent, result: InterpretResult) -> None:
|
||||
"""min_distance:两设备间距不小于指定值。"""
|
||||
device_a = intent.params.get("device_a")
|
||||
device_b = intent.params.get("device_b")
|
||||
distance = intent.params.get("distance")
|
||||
|
||||
if device_a is None or device_b is None or distance is None:
|
||||
result.errors.append(
|
||||
f"min_distance: 缺少必要参数,需要 'device_a'、'device_b' 和 'distance',"
|
||||
f"当前: device_a={device_a}, device_b={device_b}, distance={distance}"
|
||||
)
|
||||
return
|
||||
|
||||
c = Constraint(
|
||||
type="hard",
|
||||
rule_name="distance_greater_than",
|
||||
params={"device_a": device_a, "device_b": device_b, "distance": distance},
|
||||
weight=_final_weight(1.0, "normal"),
|
||||
)
|
||||
result.constraints.append(c)
|
||||
|
||||
result.translations.append({
|
||||
"source_intent": intent.intent,
|
||||
"source_description": intent.description,
|
||||
"source_params": intent.params,
|
||||
"generated_constraints": [{"type": c.type, "rule_name": c.rule_name, "params": c.params, "weight": c.weight}],
|
||||
"explanation": f"设备 '{device_a}' 与 '{device_b}' 之间的距离不得小于 {distance} 米",
|
||||
})
|
||||
|
||||
|
||||
def _handle_min_spacing(intent: Intent, result: InterpretResult) -> None:
|
||||
"""min_spacing:所有设备之间的最小间隙。"""
|
||||
min_gap: float = intent.params.get("min_gap", 0.3)
|
||||
|
||||
c = Constraint(
|
||||
type="hard",
|
||||
rule_name="min_spacing",
|
||||
params={"min_gap": min_gap},
|
||||
weight=_final_weight(1.0, "high"),
|
||||
)
|
||||
result.constraints.append(c)
|
||||
|
||||
result.translations.append({
|
||||
"source_intent": intent.intent,
|
||||
"source_description": intent.description,
|
||||
"source_params": intent.params,
|
||||
"generated_constraints": [{"type": c.type, "rule_name": c.rule_name, "params": c.params, "weight": c.weight}],
|
||||
"explanation": f"所有设备之间至少保持 {min_gap} 米的间隙",
|
||||
})
|
||||
|
||||
|
||||
def _handle_face_outward(intent: Intent, result: InterpretResult) -> None:
|
||||
"""face_outward:设备朝向偏好为向外。"""
|
||||
c = Constraint(
|
||||
type="soft",
|
||||
rule_name="prefer_orientation_mode",
|
||||
params={"mode": "outward"},
|
||||
weight=_final_weight(1.0, "low"),
|
||||
)
|
||||
result.constraints.append(c)
|
||||
|
||||
result.translations.append({
|
||||
"source_intent": intent.intent,
|
||||
"source_description": intent.description,
|
||||
"source_params": intent.params,
|
||||
"generated_constraints": [{"type": c.type, "rule_name": c.rule_name, "params": c.params, "weight": c.weight}],
|
||||
"explanation": "设备开口偏好朝向实验室外侧",
|
||||
})
|
||||
|
||||
|
||||
def _handle_face_inward(intent: Intent, result: InterpretResult) -> None:
|
||||
"""face_inward:设备朝向偏好为向内。"""
|
||||
c = Constraint(
|
||||
type="soft",
|
||||
rule_name="prefer_orientation_mode",
|
||||
params={"mode": "inward"},
|
||||
weight=_final_weight(1.0, "low"),
|
||||
)
|
||||
result.constraints.append(c)
|
||||
|
||||
result.translations.append({
|
||||
"source_intent": intent.intent,
|
||||
"source_description": intent.description,
|
||||
"source_params": intent.params,
|
||||
"generated_constraints": [{"type": c.type, "rule_name": c.rule_name, "params": c.params, "weight": c.weight}],
|
||||
"explanation": "设备开口偏好朝向实验室内侧",
|
||||
})
|
||||
|
||||
|
||||
def _handle_align_cardinal(intent: Intent, result: InterpretResult) -> None:
|
||||
"""align_cardinal:设备偏好对齐到主轴方向。"""
|
||||
c = Constraint(
|
||||
type="soft",
|
||||
rule_name="prefer_aligned",
|
||||
params={},
|
||||
weight=_final_weight(1.0, "low"),
|
||||
)
|
||||
result.constraints.append(c)
|
||||
|
||||
result.translations.append({
|
||||
"source_intent": intent.intent,
|
||||
"source_description": intent.description,
|
||||
"source_params": intent.params,
|
||||
"generated_constraints": [{"type": c.type, "rule_name": c.rule_name, "params": c.params, "weight": c.weight}],
|
||||
"explanation": "设备偏好与实验室主轴对齐(0°/90°/180°/270°)",
|
||||
})
|
||||
|
||||
|
||||
def _handle_keep_adjacent(intent: Intent, result: InterpretResult) -> None:
|
||||
"""keep_adjacent:两个设备保持相邻(同 close_together 逻辑,支持 priority 映射)。"""
|
||||
devices: list[str] = intent.params.get("devices", [])
|
||||
priority: str = intent.params.get("priority", "medium")
|
||||
|
||||
if len(devices) < 2:
|
||||
result.errors.append(f"keep_adjacent: 参数 'devices' 至少需要 2 个设备,当前 {len(devices)} 个")
|
||||
return
|
||||
|
||||
weight = _final_weight(
|
||||
_PRIORITY_WEIGHTS.get(priority, _DEFAULT_WEIGHT),
|
||||
_priority_key(priority),
|
||||
)
|
||||
generated: list[dict] = []
|
||||
for dev_a, dev_b in itertools.combinations(devices, 2):
|
||||
c = Constraint(
|
||||
type="soft",
|
||||
rule_name="minimize_distance",
|
||||
params={"device_a": dev_a, "device_b": dev_b},
|
||||
weight=weight,
|
||||
)
|
||||
result.constraints.append(c)
|
||||
generated.append({"type": c.type, "rule_name": c.rule_name, "params": c.params, "weight": c.weight})
|
||||
|
||||
result.translations.append({
|
||||
"source_intent": intent.intent,
|
||||
"source_description": intent.description,
|
||||
"source_params": intent.params,
|
||||
"generated_constraints": generated,
|
||||
"explanation": f"设备组 {devices} 应保持相邻(优先级: {priority})",
|
||||
})
|
||||
|
||||
|
||||
def _handle_workflow_hint(intent: Intent, result: InterpretResult) -> None:
|
||||
"""workflow_hint:工作流顺序暗示,相邻步骤设备靠近。"""
|
||||
workflow: str = intent.params.get("workflow", "")
|
||||
devices: list[str] = intent.params.get("devices", [])
|
||||
|
||||
if len(devices) < 2:
|
||||
result.errors.append(
|
||||
f"workflow_hint: 参数 'devices' 至少需要 2 个设备,当前 {len(devices)} 个"
|
||||
)
|
||||
return
|
||||
|
||||
generated: list[dict] = []
|
||||
for dev_a, dev_b in zip(devices[:-1], devices[1:]):
|
||||
c = Constraint(
|
||||
type="soft",
|
||||
rule_name="minimize_distance",
|
||||
params={"device_a": dev_a, "device_b": dev_b},
|
||||
weight=_final_weight(1.0, "normal"),
|
||||
)
|
||||
result.constraints.append(c)
|
||||
generated.append({"type": c.type, "rule_name": c.rule_name, "params": c.params, "weight": c.weight})
|
||||
result.workflow_edges.append([dev_a, dev_b])
|
||||
|
||||
result.translations.append({
|
||||
"source_intent": intent.intent,
|
||||
"source_description": intent.description,
|
||||
"source_params": intent.params,
|
||||
"generated_constraints": generated,
|
||||
"explanation": f"工作流 '{workflow}' 中相邻步骤设备应靠近",
|
||||
"confidence": "low",
|
||||
})
|
||||
|
||||
|
||||
# 意图处理器分发表
|
||||
_HANDLERS: dict[str, Callable[[Intent, InterpretResult], None]] = {
|
||||
"reachable_by": _handle_reachable_by,
|
||||
"close_together": _handle_close_together,
|
||||
"far_apart": _handle_far_apart,
|
||||
"max_distance": _handle_max_distance,
|
||||
"min_distance": _handle_min_distance,
|
||||
"min_spacing": _handle_min_spacing,
|
||||
"face_outward": _handle_face_outward,
|
||||
"face_inward": _handle_face_inward,
|
||||
"align_cardinal": _handle_align_cardinal,
|
||||
"keep_adjacent": _handle_keep_adjacent,
|
||||
"workflow_hint": _handle_workflow_hint,
|
||||
}
|
||||
|
||||
|
||||
def interpret_intents(intents: list[Intent]) -> InterpretResult:
|
||||
"""将意图列表翻译为约束列表。
|
||||
|
||||
Args:
|
||||
intents: 语义化意图列表(通常由 LLM 生成)
|
||||
|
||||
Returns:
|
||||
InterpretResult,包含约束、翻译记录、错误信息和工作流边
|
||||
"""
|
||||
result = InterpretResult()
|
||||
|
||||
for intent in intents:
|
||||
handler = _HANDLERS.get(intent.intent)
|
||||
if handler is None:
|
||||
result.errors.append(f"未知意图类型: '{intent.intent}',跳过处理")
|
||||
continue
|
||||
handler(intent, result)
|
||||
|
||||
return result
|
||||
@@ -1,41 +0,0 @@
|
||||
"""Protocol 接口定义,隔离 ROS 依赖。
|
||||
|
||||
开发阶段使用 mock_checkers.py 中的 Mock 实现,
|
||||
集成阶段替换为 ros_checkers.py 中的 MoveIt2 / IKFast 实现。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class CollisionChecker(Protocol):
|
||||
"""碰撞检测接口。"""
|
||||
|
||||
def check(self, placements: list[dict]) -> list[tuple[str, str]]:
|
||||
"""返回碰撞设备对列表。
|
||||
|
||||
Args:
|
||||
placements: [{"id": str, "bbox": (w, d), "pos": (x, y, θ)}, ...]
|
||||
|
||||
Returns:
|
||||
[("device_a", "device_b"), ...] 存在碰撞的设备对
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class ReachabilityChecker(Protocol):
|
||||
"""可达性检测接口。"""
|
||||
|
||||
def is_reachable(self, arm_id: str, arm_pose: dict, target: dict) -> bool:
|
||||
"""判断机械臂在给定位姿下能否到达目标点。
|
||||
|
||||
Args:
|
||||
arm_id: 机械臂设备 ID
|
||||
arm_pose: {"x": float, "y": float, "theta": float}
|
||||
target: {"x": float, "y": float, "z": float}
|
||||
|
||||
Returns:
|
||||
True 如果可达
|
||||
"""
|
||||
...
|
||||
@@ -1,49 +0,0 @@
|
||||
"""解析实验室平面图 JSON。
|
||||
|
||||
简单格式:
|
||||
{
|
||||
"width": 6.0,
|
||||
"depth": 4.0,
|
||||
"obstacles": [
|
||||
{"x": 2.0, "y": 0.0, "width": 0.1, "depth": 1.0}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from .models import Lab, Obstacle
|
||||
|
||||
|
||||
def parse_lab(data: dict) -> Lab:
|
||||
"""从字典解析实验室平面图。"""
|
||||
obstacles = []
|
||||
for obs in data.get("obstacles", []):
|
||||
obstacles.append(
|
||||
Obstacle(
|
||||
x=float(obs["x"]),
|
||||
y=float(obs["y"]),
|
||||
width=float(obs["width"]),
|
||||
depth=float(obs["depth"]),
|
||||
)
|
||||
)
|
||||
return Lab(
|
||||
width=float(data["width"]),
|
||||
depth=float(data["depth"]),
|
||||
obstacles=obstacles,
|
||||
)
|
||||
|
||||
|
||||
def load_lab_from_file(path: str | Path) -> Lab:
|
||||
"""从 JSON 文件加载实验室平面图。"""
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
return parse_lab(data)
|
||||
|
||||
|
||||
def create_simple_lab(width: float, depth: float) -> Lab:
|
||||
"""创建一个无障碍物的简单矩形实验室。"""
|
||||
return Lab(width=width, depth=depth)
|
||||
@@ -1,187 +0,0 @@
|
||||
# Demo Agent — Lab Layout Orchestrator
|
||||
|
||||
You are a lab layout agent for a recorded demo. Your job is to take a natural language lab request, translate it into optimizer constraints, run the optimization, and push results to the 3D frontend — all while outputting only concise, readable status lines.
|
||||
|
||||
## CRITICAL OUTPUT RULES
|
||||
|
||||
- Output ONLY short status lines. No markdown fences. No raw JSON. No explanations.
|
||||
- Every HTTP call uses `curl -s` (silent). Never show curl output to the user.
|
||||
- Parse responses internally. Extract only the fields needed for your status lines.
|
||||
- Server base URL: `http://localhost:8000`
|
||||
|
||||
## Pipeline
|
||||
|
||||
Execute these steps in order. Print the status line shown after each step.
|
||||
|
||||
### Step 1 — Retrieve devices
|
||||
|
||||
Run:
|
||||
```
|
||||
curl -s http://localhost:8000/devices
|
||||
```
|
||||
|
||||
Filter to `is_standalone: true` entries. Count them. Build an id→name lookup.
|
||||
|
||||
Print:
|
||||
```
|
||||
retrieving devices... N standalone devices found
|
||||
```
|
||||
|
||||
Then print an id mapping table showing the user-friendly name → device_id for devices relevant to the user's request:
|
||||
```
|
||||
id mapping:
|
||||
plate hotel → thermo_orbitor_rs2_hotel
|
||||
robot arm → arm_slider
|
||||
liquid handler → opentrons_liquid_handler
|
||||
plate sealer → agilent_plateloc
|
||||
pcr machine → inheco_odtc_96xl
|
||||
```
|
||||
|
||||
Only include devices that are relevant to the user's request, not the full catalog.
|
||||
|
||||
### Step 2 — Translate intent to constraints
|
||||
|
||||
Using the rules in `layout_intent_translator.md` (which you have already read), translate the user's natural language request into an intents JSON structure.
|
||||
|
||||
Do NOT print the JSON. Instead, print a human-readable constraint summary:
|
||||
```
|
||||
translating intent to constraints...
|
||||
constraints:
|
||||
hard: arm_slider must reach 4 devices
|
||||
hard: min spacing 0.05m between all devices
|
||||
soft: workflow order hotel → liquid handler → sealer → pcr
|
||||
soft: all devices close together (high priority)
|
||||
soft: align to cardinal directions
|
||||
```
|
||||
|
||||
### Step 3 — Interpret intents
|
||||
|
||||
Send the intents JSON to the interpret endpoint:
|
||||
```
|
||||
curl -s -X POST http://localhost:8000/interpret \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{ "intents": [...] }'
|
||||
```
|
||||
|
||||
Capture the `constraints` and `workflow_edges` arrays from the response. Do NOT print anything for this step — it's a silent validation.
|
||||
|
||||
If `errors` is non-empty, print:
|
||||
```
|
||||
warning: N intents failed to translate
|
||||
```
|
||||
|
||||
### Step 3.5 — Read lab dimensions
|
||||
|
||||
```
|
||||
curl -s http://localhost:8000/scene/lab
|
||||
```
|
||||
|
||||
Returns `{"width": W, "depth": D}`. Use these values for the optimize request. Do NOT print anything for this step.
|
||||
|
||||
### Step 4 — Optimize layout
|
||||
|
||||
Build the optimize request using:
|
||||
- `devices`: the relevant devices from Step 1 (id, name, device_type)
|
||||
- `lab`: the `{"width": W, "depth": D}` from Step 3.5
|
||||
- `constraints`: from Step 3 interpret response
|
||||
- `workflow_edges`: from Step 3 interpret response
|
||||
- `seeder`: `"compact_outward"` (default)
|
||||
- `seeder_overrides`: generally not needed. Cardinal alignment is handled by the `align_cardinal` intent (generates `prefer_aligned` constraint). Do NOT use `align_weight` in seeder_overrides — it is deprecated.
|
||||
- `snap_cardinal`: `false` (default). Set `true` only if user explicitly requests snapping to 0/90/180/270.
|
||||
- `run_de`: `true`
|
||||
- `maxiter`: `200`
|
||||
- `seed`: `42`
|
||||
|
||||
Run:
|
||||
```
|
||||
curl -s -X POST http://localhost:8000/optimize \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{ ... }'
|
||||
```
|
||||
|
||||
Print:
|
||||
```
|
||||
optimizing layout (DE, 200 iterations)...
|
||||
optimization complete — cost: X.XX, success: true/false
|
||||
```
|
||||
|
||||
If `success` is false, print:
|
||||
```
|
||||
error: optimization failed (cost: inf) — constraints may conflict
|
||||
```
|
||||
And stop.
|
||||
|
||||
### Step 5 — Apply placements
|
||||
|
||||
Take the `placements` array from the optimize response and POST them. Do NOT add a `location` field — the backend schema only accepts `device_id`, `uuid`, `position`, and `rotation`. Extra fields will cause validation errors.
|
||||
|
||||
```
|
||||
curl -s -X POST http://localhost:8000/scene/placements \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{ "placements": [
|
||||
{
|
||||
"device_id": "...",
|
||||
"uuid": "...",
|
||||
"position": {"x": ..., "y": ..., "z": ...},
|
||||
"rotation": {"x": ..., "y": ..., "z": ...}
|
||||
}
|
||||
] }'
|
||||
```
|
||||
|
||||
**Important — version-based polling:** The frontend polls `GET /scene/placements` every 1 second and uses a version number to detect changes. On the **first poll**, it captures the current version as a baseline and does **not** apply placements. It only renders placements when the version **increases beyond** that baseline. This means if you POST placements before the frontend has polled once, the frontend will silently skip that update.
|
||||
|
||||
**Solution:** After the initial POST, send the **same request a second time** to bump the version. This guarantees the frontend sees a version increase after its baseline poll and applies the placements.
|
||||
|
||||
Print:
|
||||
```
|
||||
applying placements to scene...
|
||||
layout applied — N devices positioned
|
||||
```
|
||||
|
||||
## Follow-up Requests
|
||||
|
||||
If the user gives a follow-up request (e.g., "now move the sealer farther from the thermal cycler"):
|
||||
|
||||
1. Print a `---` separator
|
||||
2. Keep the same device list (no need to re-fetch)
|
||||
3. Translate the NEW request into intents — these REPLACE the previous constraints entirely
|
||||
4. Run Steps 3–5 again with the new constraints
|
||||
5. Same output format
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Server unreachable: `error: server unreachable at localhost:8000`
|
||||
- Optimize fails: `error: optimization failed (cost: inf) — constraints may conflict`
|
||||
- After any error, stop and wait for user input.
|
||||
|
||||
## Device Name Resolution
|
||||
|
||||
You have `layout_intent_translator.md` loaded as context. Use its device name resolution rules to match user's informal names (e.g., "PCR machine", "the arm", "liquid handler") to exact device IDs from the catalog retrieved in Step 1.
|
||||
|
||||
## Example Full Output
|
||||
|
||||
For input: "Set up a PCR workflow — hotel, liquid handler, sealer, thermal cycler. The arm handles all transfers. Keep it compact."
|
||||
|
||||
```
|
||||
retrieving devices... 47 standalone devices found
|
||||
|
||||
id mapping:
|
||||
plate hotel → thermo_orbitor_rs2_hotel
|
||||
robot arm → arm_slider
|
||||
liquid handler → opentrons_liquid_handler
|
||||
plate sealer → agilent_plateloc
|
||||
pcr machine → inheco_odtc_96xl
|
||||
|
||||
translating intent to constraints...
|
||||
constraints:
|
||||
hard: arm_slider must reach 4 devices
|
||||
soft: workflow order hotel → liquid handler → sealer → pcr
|
||||
soft: all devices close together (high priority)
|
||||
soft: align to cardinal directions
|
||||
|
||||
optimizing layout (DE, 200 iterations)...
|
||||
optimization complete — cost: 0.00, success: true
|
||||
|
||||
applying placements to scene...
|
||||
layout applied — 5 devices positioned
|
||||
```
|
||||
@@ -1,312 +0,0 @@
|
||||
# Layout Intent Translator — LLM Skill
|
||||
|
||||
You are a lab layout intent translator. Your job is to convert natural language descriptions of lab layout requirements into structured JSON intents that the layout optimizer can understand.
|
||||
|
||||
## Your Role
|
||||
|
||||
Users describe their lab needs in natural language. You must:
|
||||
1. Identify devices by their IDs from the provided device list
|
||||
2. Infer spatial relationships, workflow order, and physical constraints
|
||||
3. Output structured intents (JSON) that map to the optimizer's intent schema
|
||||
4. Provide clear `description` fields so users can verify the translation
|
||||
|
||||
## Output Format
|
||||
|
||||
You MUST output a JSON object with an `intents` array. Each intent has:
|
||||
|
||||
```json
|
||||
{
|
||||
"intents": [
|
||||
{
|
||||
"intent": "<intent_type>",
|
||||
"params": { ... },
|
||||
"description": "Human-readable explanation of what this intent means"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Available Intent Types
|
||||
|
||||
### `reachable_by` — Robot arm must reach devices
|
||||
```json
|
||||
{
|
||||
"intent": "reachable_by",
|
||||
"params": {
|
||||
"arm": "arm_device_id",
|
||||
"targets": ["device_a", "device_b"]
|
||||
},
|
||||
"description": "Robot arm must be able to reach device A and device B"
|
||||
}
|
||||
```
|
||||
**When to use:** Any time a robot arm transfers items between devices, all those devices must be reachable.
|
||||
|
||||
### `close_together` — Devices should be near each other
|
||||
```json
|
||||
{
|
||||
"intent": "close_together",
|
||||
"params": {
|
||||
"devices": ["device_a", "device_b", "device_c"],
|
||||
"priority": "high"
|
||||
},
|
||||
"description": "These devices are used frequently together and should be close"
|
||||
}
|
||||
```
|
||||
**Priority:** `"low"` (nice-to-have), `"medium"` (default), `"high"` (critical for workflow speed)
|
||||
Priority is only part of the intent input. The interpreter automatically bakes it into the emitted constraint `weight`; there is no separate constraint-level `priority` field in `/interpret` output or `/optimize` input.
|
||||
|
||||
### `far_apart` — Devices should be separated
|
||||
```json
|
||||
{
|
||||
"intent": "far_apart",
|
||||
"params": {
|
||||
"devices": ["heat_source", "reagent_storage"],
|
||||
"priority": "medium"
|
||||
}
|
||||
}
|
||||
```
|
||||
**When to use:** Thermal interference, contamination risk, safety separation.
|
||||
|
||||
### `keep_adjacent` — Devices should stay adjacent
|
||||
```json
|
||||
{
|
||||
"intent": "keep_adjacent",
|
||||
"params": {
|
||||
"devices": ["device_a", "device_b"],
|
||||
"priority": "high"
|
||||
}
|
||||
}
|
||||
```
|
||||
**When to use:** User explicitly asks for a pair or group to stay side-by-side / adjacent. This currently maps to the same optimizer behavior as `close_together`, but is semantically more precise.
|
||||
|
||||
### `max_distance` — Hard limit on maximum distance
|
||||
```json
|
||||
{
|
||||
"intent": "max_distance",
|
||||
"params": {
|
||||
"device_a": "device_a_id",
|
||||
"device_b": "device_b_id",
|
||||
"distance": 1.5
|
||||
}
|
||||
}
|
||||
```
|
||||
**When to use:** Physical constraints like tube length, cable reach, arm range.
|
||||
|
||||
### `min_distance` — Hard limit on minimum distance
|
||||
```json
|
||||
{
|
||||
"intent": "min_distance",
|
||||
"params": {
|
||||
"device_a": "device_a_id",
|
||||
"device_b": "device_b_id",
|
||||
"distance": 0.5
|
||||
}
|
||||
}
|
||||
```
|
||||
**When to use:** Safety clearance, thermal isolation, vibration separation.
|
||||
|
||||
### `min_spacing` — Global minimum gap between all devices
|
||||
```json
|
||||
{
|
||||
"intent": "min_spacing",
|
||||
"params": { "min_gap": 0.3 }
|
||||
}
|
||||
```
|
||||
**When to use:** General accessibility, maintenance clearance.
|
||||
|
||||
### `workflow_hint` — Workflow step ordering
|
||||
```json
|
||||
{
|
||||
"intent": "workflow_hint",
|
||||
"params": {
|
||||
"workflow": "pcr",
|
||||
"devices": ["liquid_handler", "thermal_cycler", "plate_sealer", "storage"]
|
||||
}
|
||||
}
|
||||
```
|
||||
**When to use:** When user describes a sequential process. Devices are listed in workflow order. Consecutive devices will be placed near each other.
|
||||
|
||||
### `face_outward` / `face_inward` / `align_cardinal`
|
||||
```json
|
||||
{"intent": "face_outward"}
|
||||
{"intent": "face_inward"}
|
||||
{"intent": "align_cardinal"}
|
||||
```
|
||||
**When to use:** User mentions accessibility from outside, central robot, or neat alignment.
|
||||
|
||||
## Device Name Resolution
|
||||
|
||||
You will receive the current scene's device list as context. This is the **only** source of valid device IDs. Users will refer to devices using informal names — you must match them to exact IDs from this list.
|
||||
|
||||
### Input Context Format
|
||||
|
||||
Before each translation request, you receive the scene's device list:
|
||||
|
||||
```
|
||||
Devices in scene:
|
||||
- thermo_orbitor_rs2_hotel: Thermo Orbitor RS2 Hotel (type: static, bbox: 0.68×0.52m)
|
||||
- arm_slider: Arm Slider (type: articulation, bbox: 1.20×0.30m)
|
||||
- opentrons_liquid_handler: Opentrons Liquid Handler (type: static, bbox: 0.65×0.60m)
|
||||
- agilent_plateloc: Agilent PlateLoc (type: static, bbox: 0.35×0.40m)
|
||||
- inheco_odtc_96xl: Inheco ODTC 96XL (type: static, bbox: 0.30×0.35m)
|
||||
```
|
||||
|
||||
### Matching Rules
|
||||
|
||||
1. **Exact match first**: If user says "arm_slider", match directly
|
||||
2. **Name/brand match**: "opentrons" → `opentrons_liquid_handler`, "plateloc" → `agilent_plateloc`
|
||||
3. **Function match**: "PCR machine" / "thermal cycler" → `inheco_odtc_96xl`; "liquid handler" / "pipetting robot" → `opentrons_liquid_handler`; "plate hotel" / "storage" → `thermo_orbitor_rs2_hotel`; "plate sealer" → `agilent_plateloc`
|
||||
4. **Type match**: "robot arm" / "the arm" → look for `device_type: articulation`
|
||||
5. **Ambiguous**: If multiple devices could match, list candidates in the `description` field and pick the most likely one. If truly ambiguous, return an error intent asking the user to clarify.
|
||||
|
||||
### Duplicate Device Convention
|
||||
|
||||
When the same catalog device appears multiple times in the scene:
|
||||
|
||||
- first instance keeps the bare catalog ID, e.g. `plate_reader`
|
||||
- second and later instances use `#N`, e.g. `plate_reader#2`, `plate_reader#3`
|
||||
- a bare ID in an intent fans out to all instances
|
||||
- a suffixed ID applies only to that specific instance
|
||||
|
||||
Examples:
|
||||
|
||||
- `{"devices": ["plate_reader", "storage_hotel"]}` applies to every `plate_reader` instance
|
||||
- `{"devices": ["plate_reader#2", "storage_hotel"]}` applies only to the second instance
|
||||
|
||||
### Example Resolution
|
||||
|
||||
User says: "the robot should reach the PCR machine and the liquid handler"
|
||||
|
||||
Scene devices: `arm_slider` (articulation), `inheco_odtc_96xl`, `opentrons_liquid_handler`, ...
|
||||
|
||||
Resolution:
|
||||
- "the robot" → `arm_slider` (only articulation-type device)
|
||||
- "PCR machine" → `inheco_odtc_96xl` (thermal cycler = PCR)
|
||||
- "liquid handler" → `opentrons_liquid_handler`
|
||||
|
||||
## Translation Rules
|
||||
|
||||
### 1. Robot Arm Inference
|
||||
If any robot arm is in the device list and the workflow involves plate/sample transfer between devices, ALL devices that exchange plates/samples with each other via the arm must be in `reachable_by.targets`.
|
||||
|
||||
### 2. Workflow Order
|
||||
When a user describes a process (e.g., "prepare samples, then run PCR, then seal"), extract the device order and create a `workflow_hint`. The device order follows the sample processing path.
|
||||
|
||||
### 3. Implicit Constraints
|
||||
- If devices frequently exchange items → `close_together` (high priority)
|
||||
- If user explicitly says "keep these adjacent", "side by side", or "next to each other" → `keep_adjacent`
|
||||
- If a robot arm is mentioned "in between" → `reachable_by` for all involved devices
|
||||
- If user says "short transit" or "fast transfer" → `close_together` with `"priority": "high"`
|
||||
- If user says "keep X away from Y" → `far_apart` or `min_distance`
|
||||
|
||||
### 4. Don't Over-Constrain
|
||||
- Only add constraints the user's description implies
|
||||
- When unsure about priority, use `"medium"`
|
||||
- For workflow_hint, confidence is inherently `"low"` — the optimizer notes this
|
||||
|
||||
## Example: PCR Workflow
|
||||
|
||||
**User input:**
|
||||
> "Take plate from hotel, prepare sample in opentrons, seal plate then pcr cycle, arm_slider handles all transfers"
|
||||
|
||||
**Device list provided:**
|
||||
- `thermo_orbitor_rs2_hotel` (plate hotel/storage)
|
||||
- `arm_slider` (robot arm on linear rail)
|
||||
- `opentrons_liquid_handler` (liquid handling/pipetting)
|
||||
- `agilent_plateloc` (plate sealer)
|
||||
- `inheco_odtc_96xl` (thermal cycler for PCR)
|
||||
|
||||
**Your output:**
|
||||
```json
|
||||
{
|
||||
"intents": [
|
||||
{
|
||||
"intent": "reachable_by",
|
||||
"params": {
|
||||
"arm": "arm_slider",
|
||||
"targets": [
|
||||
"thermo_orbitor_rs2_hotel",
|
||||
"opentrons_liquid_handler",
|
||||
"agilent_plateloc",
|
||||
"inheco_odtc_96xl"
|
||||
]
|
||||
},
|
||||
"description": "arm_slider must reach all devices since it handles all plate transfers"
|
||||
},
|
||||
{
|
||||
"intent": "workflow_hint",
|
||||
"params": {
|
||||
"workflow": "pcr",
|
||||
"devices": [
|
||||
"thermo_orbitor_rs2_hotel",
|
||||
"opentrons_liquid_handler",
|
||||
"agilent_plateloc",
|
||||
"inheco_odtc_96xl"
|
||||
]
|
||||
},
|
||||
"description": "PCR workflow order: hotel → liquid handler → plate sealer → thermal cycler"
|
||||
},
|
||||
{
|
||||
"intent": "close_together",
|
||||
"params": {
|
||||
"devices": ["opentrons_liquid_handler", "agilent_plateloc"],
|
||||
"priority": "high"
|
||||
},
|
||||
"description": "Sealing happens immediately after sample prep — minimize transit time"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Reasoning:**
|
||||
- The arm handles ALL transfers → all 4 devices in reachable_by targets
|
||||
- User described a clear sequence → workflow_hint in that order
|
||||
- "seal plate then pcr" implies sealing is immediately after prep → close_together for the pair with high priority
|
||||
|
||||
## Example: Simple Proximity Request
|
||||
|
||||
**User input:**
|
||||
> "Keep the thermal cycler close to the plate sealer, at most 1 meter apart"
|
||||
|
||||
**Your output:**
|
||||
```json
|
||||
{
|
||||
"intents": [
|
||||
{
|
||||
"intent": "max_distance",
|
||||
"params": {
|
||||
"device_a": "inheco_odtc_96xl",
|
||||
"device_b": "agilent_plateloc",
|
||||
"distance": 1.0
|
||||
},
|
||||
"description": "Thermal cycler and plate sealer must be within 1 meter"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## API Integration
|
||||
|
||||
### Discovery
|
||||
Call `GET /interpret/schema` to get the current list of available intent types and their parameter specifications. Always check this before translating, as new intent types may be added.
|
||||
|
||||
### Translation
|
||||
Send your output to `POST /interpret`:
|
||||
```
|
||||
POST /interpret
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"intents": [ ... your translated intents ... ]
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
The endpoint returns:
|
||||
- `constraints` — ready to pass to `/optimize`
|
||||
- `translations` — human-readable mapping of each intent to generated constraints
|
||||
- `workflow_edges` — extracted workflow connections
|
||||
- `errors` — any intents that failed to translate
|
||||
|
||||
### Optimization
|
||||
After user confirms the translation, pass `constraints` and `workflow_edges` to `POST /optimize` along with the device list and lab dimensions.
|
||||
@@ -1,110 +0,0 @@
|
||||
"""Mock 检测器:无 ROS 依赖的简化碰撞与可达性检测。
|
||||
|
||||
碰撞检测基于 OBB SAT(O(n²) 两两比较)。
|
||||
可达性检测基于最大臂展半径的欧几里得距离判断。
|
||||
|
||||
集成阶段由 ros_checkers.py 中的 MoveItCollisionChecker / IKFastReachabilityChecker 替代。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from .obb import obb_corners, obb_overlap
|
||||
|
||||
|
||||
class MockCollisionChecker:
|
||||
"""基于 OBB SAT 的碰撞检测。
|
||||
|
||||
输入格式与 CollisionChecker Protocol 一致:
|
||||
placements: [{"id": str, "bbox": (w, d), "pos": (x, y, θ)}, ...]
|
||||
"""
|
||||
|
||||
def check(self, placements: list[dict]) -> list[tuple[str, str]]:
|
||||
"""返回所有碰撞的设备对。"""
|
||||
collisions: list[tuple[str, str]] = []
|
||||
n = len(placements)
|
||||
for i in range(n):
|
||||
for j in range(i + 1, n):
|
||||
a, b = placements[i], placements[j]
|
||||
corners_a = obb_corners(
|
||||
a["pos"][0], a["pos"][1],
|
||||
a["bbox"][0], a["bbox"][1],
|
||||
a["pos"][2] if len(a["pos"]) > 2 else 0.0,
|
||||
)
|
||||
corners_b = obb_corners(
|
||||
b["pos"][0], b["pos"][1],
|
||||
b["bbox"][0], b["bbox"][1],
|
||||
b["pos"][2] if len(b["pos"]) > 2 else 0.0,
|
||||
)
|
||||
if obb_overlap(corners_a, corners_b):
|
||||
collisions.append((a["id"], b["id"]))
|
||||
return collisions
|
||||
|
||||
def check_bounds(
|
||||
self, placements: list[dict], lab_width: float, lab_depth: float
|
||||
) -> list[str]:
|
||||
"""返回超出实验室边界的设备 ID 列表。"""
|
||||
out_of_bounds: list[str] = []
|
||||
for p in placements:
|
||||
hw, hd = self._rotated_half_extents(p)
|
||||
x, y = p["pos"][:2]
|
||||
if x - hw < 0 or x + hw > lab_width or y - hd < 0 or y + hd > lab_depth:
|
||||
out_of_bounds.append(p["id"])
|
||||
return out_of_bounds
|
||||
|
||||
@staticmethod
|
||||
def _rotated_half_extents(p: dict) -> tuple[float, float]:
|
||||
"""计算旋转后 AABB 的半宽和半深。"""
|
||||
w, d = p["bbox"]
|
||||
theta = p["pos"][2] if len(p["pos"]) > 2 else 0.0
|
||||
cos_t = abs(math.cos(theta))
|
||||
sin_t = abs(math.sin(theta))
|
||||
half_w = (w * cos_t + d * sin_t) / 2
|
||||
half_d = (w * sin_t + d * cos_t) / 2
|
||||
return half_w, half_d
|
||||
|
||||
|
||||
class MockReachabilityChecker:
|
||||
"""基于最大臂展半径的简化可达性判断。
|
||||
|
||||
内置常见 Elite CS 系列机械臂的臂展参数。
|
||||
自定义臂展可通过构造参数传入。
|
||||
"""
|
||||
|
||||
# 默认臂展参数(单位:米)
|
||||
DEFAULT_ARM_REACH: dict[str, float] = {
|
||||
"elite_cs63": 0.624,
|
||||
"elite_cs66": 0.914,
|
||||
"elite_cs612": 1.304,
|
||||
"elite_cs620": 1.800,
|
||||
"arm_slider": 0.3, # 线性导轨臂:1.07 body 2.14m × 0.35m,reach ≈ half length
|
||||
}
|
||||
|
||||
# 未知型号回退臂展:realistic default for lab-scale arms
|
||||
DEFAULT_FALLBACK_REACH: float = 0.4
|
||||
|
||||
def __init__(self, arm_reach: dict[str, float] | None = None):
|
||||
self.arm_reach = {**self.DEFAULT_ARM_REACH, **(arm_reach or {})}
|
||||
|
||||
def is_reachable(self, arm_id: str, arm_pose: dict, target: dict) -> bool:
|
||||
"""判断目标点是否在机械臂最大臂展半径内。
|
||||
|
||||
Uses OBB edge-to-edge distance when available (passed as _obb_dist),
|
||||
otherwise falls back to center-to-center Euclidean distance.
|
||||
|
||||
Args:
|
||||
arm_id: 机械臂型号 ID(用于查臂展)
|
||||
arm_pose: {"x": float, "y": float, "theta": float}
|
||||
target: {"x": float, "y": float, "z": float, "_obb_dist": float (optional)}
|
||||
|
||||
Returns:
|
||||
True 如果目标在臂展半径内
|
||||
"""
|
||||
max_reach = self.arm_reach.get(arm_id, self.DEFAULT_FALLBACK_REACH)
|
||||
if "_obb_dist" in target:
|
||||
return target["_obb_dist"] <= max_reach
|
||||
dx = target["x"] - arm_pose["x"]
|
||||
dy = target["y"] - arm_pose["y"]
|
||||
dist_sq = dx**2 + dy**2
|
||||
return dist_sq <= max_reach**2
|
||||
@@ -1,96 +0,0 @@
|
||||
"""数据模型定义:Device, Lab, Placement, Constraint 及 API 请求/响应。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
|
||||
|
||||
@dataclass
|
||||
class Opening:
|
||||
"""设备的访问开口(用于方向约束)。"""
|
||||
|
||||
# 设备局部坐标系中的方向单位向量,如 (0, -1) = 正前方
|
||||
direction: tuple[float, float] = (0.0, -1.0)
|
||||
label: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Device:
|
||||
"""设备描述。"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
# 碰撞包围盒 (width along X, depth along Y),单位:米
|
||||
bbox: tuple[float, float] = (0.6, 0.4)
|
||||
device_type: Literal["static", "articulation", "rigid"] = "static"
|
||||
# 以下为可选扩展字段(向后兼容)
|
||||
height: float = 0.4
|
||||
origin_offset: tuple[float, float] = (0.0, 0.0)
|
||||
openings: list[Opening] = field(default_factory=list)
|
||||
source: Literal["registry", "assets", "manual"] = "manual"
|
||||
model_path: str = ""
|
||||
model_type: str = ""
|
||||
thumbnail_url: str = ""
|
||||
uuid: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Obstacle:
|
||||
"""实验室内固定障碍物(矩形)。"""
|
||||
|
||||
x: float
|
||||
y: float
|
||||
width: float
|
||||
depth: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class Lab:
|
||||
"""实验室平面图。"""
|
||||
|
||||
width: float # X 方向,单位:米
|
||||
depth: float # Y 方向,单位:米
|
||||
obstacles: list[Obstacle] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Placement:
|
||||
"""单个设备的布局位姿。"""
|
||||
|
||||
device_id: str
|
||||
x: float
|
||||
y: float
|
||||
theta: float # 旋转角,弧度
|
||||
uuid: str = "" # 前端分配的唯一标识,透传不生成
|
||||
|
||||
def rotated_bbox(self, device: Device) -> tuple[float, float]:
|
||||
"""返回旋转后的 AABB 尺寸 (half_w, half_h)。"""
|
||||
w, d = device.bbox
|
||||
cos_t = abs(math.cos(self.theta))
|
||||
sin_t = abs(math.sin(self.theta))
|
||||
half_w = (w * cos_t + d * sin_t) / 2
|
||||
half_h = (w * sin_t + d * cos_t) / 2
|
||||
return half_w, half_h
|
||||
|
||||
|
||||
@dataclass
|
||||
class Constraint:
|
||||
"""约束规则。"""
|
||||
|
||||
type: Literal["hard", "soft"]
|
||||
rule_name: str
|
||||
# 规则参数,含义因 rule_name 而异
|
||||
params: dict = field(default_factory=dict)
|
||||
# 仅 soft 约束使用
|
||||
weight: float = 1.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class Intent:
|
||||
"""LLM 可生成的语义化意图,由 interpreter 翻译为 Constraint 列表。"""
|
||||
|
||||
intent: str # 意图类型,如 "reachable_by", "close_together"
|
||||
params: dict = field(default_factory=dict)
|
||||
description: str = "" # 可选的自然语言描述(用于审计/调试)
|
||||
@@ -1,257 +0,0 @@
|
||||
"""OBB (Oriented Bounding Box) geometry: corners, overlap (SAT), minimum distance."""
|
||||
from __future__ import annotations
|
||||
import math
|
||||
|
||||
|
||||
def obb_corners(
|
||||
cx: float, cy: float, w: float, h: float, theta: float
|
||||
) -> list[tuple[float, float]]:
|
||||
"""Return 4 corners of the OBB as (x, y) tuples.
|
||||
|
||||
Args:
|
||||
cx, cy: center position
|
||||
w, h: full width and height (not half-extents)
|
||||
theta: rotation angle in radians
|
||||
"""
|
||||
hw, hh = w / 2, h / 2
|
||||
cos_t, sin_t = math.cos(theta), math.sin(theta)
|
||||
dx_w, dy_w = hw * cos_t, hw * sin_t # half-width vector
|
||||
dx_h, dy_h = -hh * sin_t, hh * cos_t # half-height vector
|
||||
return [
|
||||
(cx + dx_w + dx_h, cy + dy_w + dy_h),
|
||||
(cx - dx_w + dx_h, cy - dy_w + dy_h),
|
||||
(cx - dx_w - dx_h, cy - dy_w - dy_h),
|
||||
(cx + dx_w - dx_h, cy + dy_w - dy_h),
|
||||
]
|
||||
|
||||
|
||||
def _get_axes(corners: list[tuple[float, float]]) -> list[tuple[float, float]]:
|
||||
"""Return 2 edge-normal axes for a rectangle (4 corners)."""
|
||||
axes = []
|
||||
for i in range(2): # Only need 2 axes for a rectangle
|
||||
edge_x = corners[i + 1][0] - corners[i][0]
|
||||
edge_y = corners[i + 1][1] - corners[i][1]
|
||||
length = math.sqrt(edge_x**2 + edge_y**2)
|
||||
if length > 0:
|
||||
axes.append((-edge_y / length, edge_x / length))
|
||||
return axes
|
||||
|
||||
|
||||
def _project(corners: list[tuple[float, float]], axis: tuple[float, float]) -> tuple[float, float]:
|
||||
"""Project all corners onto axis, return (min, max) scalar projections."""
|
||||
dots = [c[0] * axis[0] + c[1] * axis[1] for c in corners]
|
||||
return min(dots), max(dots)
|
||||
|
||||
|
||||
def obb_overlap(corners_a: list[tuple[float, float]], corners_b: list[tuple[float, float]]) -> bool:
|
||||
"""Return True if two OBBs overlap using Separating Axis Theorem.
|
||||
|
||||
Uses strict inequality (touching edges = no overlap).
|
||||
"""
|
||||
for axis in _get_axes(corners_a) + _get_axes(corners_b):
|
||||
min_a, max_a = _project(corners_a, axis)
|
||||
min_b, max_b = _project(corners_b, axis)
|
||||
if max_a <= min_b or max_b <= min_a:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _point_to_segment_dist_sq(
|
||||
px: float, py: float,
|
||||
ax: float, ay: float,
|
||||
bx: float, by: float,
|
||||
) -> float:
|
||||
"""Squared distance from point (px,py) to line segment (ax,ay)-(bx,by)."""
|
||||
dx, dy = bx - ax, by - ay
|
||||
len_sq = dx * dx + dy * dy
|
||||
if len_sq == 0:
|
||||
return (px - ax) ** 2 + (py - ay) ** 2
|
||||
t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / len_sq))
|
||||
proj_x, proj_y = ax + t * dx, ay + t * dy
|
||||
return (px - proj_x) ** 2 + (py - proj_y) ** 2
|
||||
|
||||
|
||||
def obb_penetration_depth(
|
||||
corners_a: list[tuple[float, float]],
|
||||
corners_b: list[tuple[float, float]],
|
||||
) -> float:
|
||||
"""Minimum penetration depth between two OBBs (SAT-based).
|
||||
|
||||
Returns 0.0 if not overlapping. Otherwise returns the minimum overlap
|
||||
along any separating axis — the smallest push needed to separate them.
|
||||
"""
|
||||
min_overlap = float("inf")
|
||||
for axis in _get_axes(corners_a) + _get_axes(corners_b):
|
||||
min_a, max_a = _project(corners_a, axis)
|
||||
min_b, max_b = _project(corners_b, axis)
|
||||
overlap = min(max_a - min_b, max_b - min_a)
|
||||
if overlap <= 0:
|
||||
return 0.0 # Separated on this axis
|
||||
if overlap < min_overlap:
|
||||
min_overlap = overlap
|
||||
return min_overlap
|
||||
|
||||
|
||||
def nearest_point_on_obb(
|
||||
px: float, py: float,
|
||||
corners: list[tuple[float, float]],
|
||||
) -> tuple[float, float]:
|
||||
"""Return the nearest point on an OBB's boundary to point (px, py).
|
||||
|
||||
If the point is inside the OBB, returns the nearest edge point.
|
||||
"""
|
||||
best_x, best_y = corners[0]
|
||||
best_dist_sq = float("inf")
|
||||
n = len(corners)
|
||||
for i in range(n):
|
||||
ax, ay = corners[i]
|
||||
bx, by = corners[(i + 1) % n]
|
||||
dx, dy = bx - ax, by - ay
|
||||
len_sq = dx * dx + dy * dy
|
||||
if len_sq == 0:
|
||||
proj_x, proj_y = ax, ay
|
||||
else:
|
||||
t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / len_sq))
|
||||
proj_x, proj_y = ax + t * dx, ay + t * dy
|
||||
d_sq = (px - proj_x) ** 2 + (py - proj_y) ** 2
|
||||
if d_sq < best_dist_sq:
|
||||
best_dist_sq = d_sq
|
||||
best_x, best_y = proj_x, proj_y
|
||||
return best_x, best_y
|
||||
|
||||
|
||||
def segment_intersects_obb(
|
||||
p1: tuple[float, float],
|
||||
p2: tuple[float, float],
|
||||
corners: list[tuple[float, float]],
|
||||
) -> bool:
|
||||
"""Return True if line segment p1-p2 intersects the OBB (convex polygon).
|
||||
|
||||
Uses separating axis theorem on the Minkowski difference:
|
||||
test segment against each edge of the polygon + segment normal.
|
||||
"""
|
||||
# Quick: test each edge of the OBB against the segment
|
||||
n = len(corners)
|
||||
for i in range(n):
|
||||
ax, ay = corners[i]
|
||||
bx, by = corners[(i + 1) % n]
|
||||
if _segments_intersect(p1[0], p1[1], p2[0], p2[1], ax, ay, bx, by):
|
||||
return True
|
||||
# Also check if segment is fully inside the OBB
|
||||
if _point_in_convex(p1, corners) or _point_in_convex(p2, corners):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _segments_intersect(
|
||||
ax: float, ay: float, bx: float, by: float,
|
||||
cx: float, cy: float, dx: float, dy: float,
|
||||
) -> bool:
|
||||
"""Return True if segment AB intersects segment CD (proper or endpoint)."""
|
||||
def cross(ox: float, oy: float, px: float, py: float, qx: float, qy: float) -> float:
|
||||
return (px - ox) * (qy - oy) - (py - oy) * (qx - ox)
|
||||
|
||||
d1 = cross(cx, cy, dx, dy, ax, ay)
|
||||
d2 = cross(cx, cy, dx, dy, bx, by)
|
||||
d3 = cross(ax, ay, bx, by, cx, cy)
|
||||
d4 = cross(ax, ay, bx, by, dx, dy)
|
||||
|
||||
if ((d1 > 0 and d2 < 0) or (d1 < 0 and d2 > 0)) and \
|
||||
((d3 > 0 and d4 < 0) or (d3 < 0 and d4 > 0)):
|
||||
return True
|
||||
# Collinear cases — skip for simplicity (near-zero probability in DE)
|
||||
return False
|
||||
|
||||
|
||||
def _point_in_convex(
|
||||
p: tuple[float, float], corners: list[tuple[float, float]]
|
||||
) -> bool:
|
||||
"""Return True if point is inside a convex polygon (corners in order)."""
|
||||
n = len(corners)
|
||||
sign = None
|
||||
for i in range(n):
|
||||
ax, ay = corners[i]
|
||||
bx, by = corners[(i + 1) % n]
|
||||
cross = (bx - ax) * (p[1] - ay) - (by - ay) * (p[0] - ax)
|
||||
if abs(cross) < 1e-12:
|
||||
continue
|
||||
s = cross > 0
|
||||
if sign is None:
|
||||
sign = s
|
||||
elif s != sign:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def segment_obb_intersection_length(
|
||||
p1: tuple[float, float],
|
||||
p2: tuple[float, float],
|
||||
corners: list[tuple[float, float]],
|
||||
) -> float:
|
||||
"""线段 p1-p2 与 OBB(凸多边形)的交集长度。
|
||||
|
||||
Cyrus-Beck 线段裁剪算法。corners 假定为 CCW 顺序(obb_corners 生成)。
|
||||
无交集返回 0.0。
|
||||
"""
|
||||
dx = p2[0] - p1[0]
|
||||
dy = p2[1] - p1[1]
|
||||
seg_len_sq = dx * dx + dy * dy
|
||||
if seg_len_sq < 1e-24:
|
||||
return 0.0
|
||||
|
||||
t_enter = 0.0
|
||||
t_exit = 1.0
|
||||
n = len(corners)
|
||||
|
||||
for i in range(n):
|
||||
ax, ay = corners[i]
|
||||
bx, by = corners[(i + 1) % n]
|
||||
# CCW 多边形边的外法线: (ey, -ex), e = b - a
|
||||
ex, ey = bx - ax, by - ay
|
||||
nx, ny = ey, -ex
|
||||
|
||||
denom = nx * dx + ny * dy
|
||||
numer = nx * (p1[0] - ax) + ny * (p1[1] - ay)
|
||||
|
||||
if abs(denom) < 1e-12:
|
||||
if numer > 0:
|
||||
return 0.0 # 在此边外侧且平行
|
||||
continue
|
||||
|
||||
t = -numer / denom
|
||||
if denom < 0:
|
||||
t_enter = max(t_enter, t) # 进入
|
||||
else:
|
||||
t_exit = min(t_exit, t) # 退出
|
||||
|
||||
if t_enter > t_exit:
|
||||
return 0.0
|
||||
|
||||
if t_enter >= t_exit:
|
||||
return 0.0
|
||||
|
||||
return (t_exit - t_enter) * math.sqrt(seg_len_sq)
|
||||
|
||||
|
||||
def obb_min_distance(
|
||||
corners_a: list[tuple[float, float]],
|
||||
corners_b: list[tuple[float, float]],
|
||||
) -> float:
|
||||
"""Minimum distance between two OBBs (convex polygons).
|
||||
|
||||
Returns 0.0 if overlapping or touching.
|
||||
"""
|
||||
if obb_overlap(corners_a, corners_b):
|
||||
return 0.0
|
||||
|
||||
min_dist_sq = float("inf")
|
||||
for poly, other in [(corners_a, corners_b), (corners_b, corners_a)]:
|
||||
n = len(other)
|
||||
for px, py in poly:
|
||||
for i in range(n):
|
||||
ax, ay = other[i]
|
||||
bx, by = other[(i + 1) % n]
|
||||
d_sq = _point_to_segment_dist_sq(px, py, ax, ay, bx, by)
|
||||
if d_sq < min_dist_sq:
|
||||
min_dist_sq = d_sq
|
||||
return math.sqrt(min_dist_sq)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,144 +0,0 @@
|
||||
"""初始布局生成:Pencil MCP 接口 + 行列式回退。
|
||||
|
||||
策略:
|
||||
1. 尝试调用 Pencil AI MCP 生成初始布局
|
||||
2. 若 Pencil 不可用或失败,回退到行列式放置算法
|
||||
|
||||
行列式回退逻辑:
|
||||
- 设备按面积从大到小排序
|
||||
- 沿 X 轴逐个放置,行满(超出 lab.width)则换行
|
||||
- 设备间保留 margin 间距
|
||||
- 所有设备 θ=0(朝向不变)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from .models import Device, Lab, Placement
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 设备间最小间距(米)
|
||||
DEFAULT_MARGIN = 0.3
|
||||
|
||||
|
||||
def generate_initial_layout(
|
||||
devices: list[Device],
|
||||
lab: Lab,
|
||||
margin: float = DEFAULT_MARGIN,
|
||||
) -> list[Placement]:
|
||||
"""生成初始布局方案。
|
||||
|
||||
优先尝试 Pencil MCP,失败则回退到行列式放置。
|
||||
|
||||
Args:
|
||||
devices: 待放置的设备列表
|
||||
lab: 实验室平面图
|
||||
margin: 设备间最小间距
|
||||
|
||||
Returns:
|
||||
初始布局 Placement 列表
|
||||
"""
|
||||
# 尝试 Pencil MCP
|
||||
pencil_result = _try_pencil(devices, lab)
|
||||
if pencil_result is not None:
|
||||
logger.info("Using Pencil AI generated layout")
|
||||
return pencil_result
|
||||
|
||||
# 回退到行列式
|
||||
logger.info("Pencil unavailable, using row-based fallback layout")
|
||||
return generate_fallback(devices, lab, margin)
|
||||
|
||||
|
||||
def _try_pencil(
|
||||
devices: list[Device],
|
||||
lab: Lab,
|
||||
) -> list[Placement] | None:
|
||||
"""尝试通过 Pencil AI MCP 生成布局。
|
||||
|
||||
当前 Pencil MCP 不可用,返回 None 触发回退。
|
||||
未来集成时,此函数应:
|
||||
1. 将设备 2D 投影 + 实验室平面图序列化为 Pencil 输入格式
|
||||
2. 调用 mcp__pencil_* 工具
|
||||
3. 解析返回的布局方案为 Placement 列表
|
||||
|
||||
预留接口参数:
|
||||
- devices: 设备列表(id, bbox)
|
||||
- lab: 实验室尺寸
|
||||
"""
|
||||
# TODO: 当 Pencil MCP 可用时实现
|
||||
# 预期调用方式:
|
||||
# pencil_input = {
|
||||
# "floor_plan": {"width": lab.width, "depth": lab.depth},
|
||||
# "items": [{"id": d.id, "width": d.bbox[0], "depth": d.bbox[1]} for d in devices],
|
||||
# }
|
||||
# result = mcp__pencil_layout(pencil_input)
|
||||
# return [Placement(device_id=r["id"], x=r["x"], y=r["y"], theta=r["theta"]) for r in result]
|
||||
return None
|
||||
|
||||
|
||||
def generate_fallback(
|
||||
devices: list[Device],
|
||||
lab: Lab,
|
||||
margin: float = DEFAULT_MARGIN,
|
||||
) -> list[Placement]:
|
||||
"""行列式回退布局:按面积从大到小排序,逐行放置。
|
||||
|
||||
放置规则:
|
||||
- 设备中心坐标,从左上角开始
|
||||
- 每行从 margin + half_width 开始
|
||||
- 行满(下一个设备右边缘超出 lab.width - margin)则换行
|
||||
- 行高取该行最大设备深度
|
||||
|
||||
Args:
|
||||
devices: 待放置的设备列表
|
||||
lab: 实验室平面图
|
||||
margin: 设备间最小间距
|
||||
|
||||
Returns:
|
||||
Placement 列表。若实验室空间不足,剩余设备堆叠在右下角并记录警告。
|
||||
"""
|
||||
if not devices:
|
||||
return []
|
||||
|
||||
# 按面积从大到小排序
|
||||
sorted_devices = sorted(devices, key=lambda d: d.bbox[0] * d.bbox[1], reverse=True)
|
||||
|
||||
placements: list[Placement] = []
|
||||
cursor_x = margin
|
||||
cursor_y = margin
|
||||
row_height = 0.0
|
||||
|
||||
for dev in sorted_devices:
|
||||
w, d = dev.bbox
|
||||
half_w = w / 2
|
||||
half_d = d / 2
|
||||
|
||||
# 检查当前行是否放得下
|
||||
if cursor_x + half_w + margin > lab.width and placements:
|
||||
# 换行
|
||||
cursor_x = margin
|
||||
cursor_y += row_height + margin
|
||||
row_height = 0.0
|
||||
|
||||
# 设备中心位置
|
||||
cx = cursor_x + half_w
|
||||
cy = cursor_y + half_d
|
||||
|
||||
# 检查是否超出实验室深度
|
||||
if cy + half_d + margin > lab.depth:
|
||||
logger.warning(
|
||||
"Lab space insufficient for device '%s' (%s), "
|
||||
"placing at overflow position",
|
||||
dev.id,
|
||||
dev.bbox,
|
||||
)
|
||||
|
||||
placements.append(Placement(device_id=dev.id, x=cx, y=cy, theta=0.0))
|
||||
|
||||
# 更新游标
|
||||
cursor_x = cx + half_w + margin
|
||||
row_height = max(row_height, d)
|
||||
|
||||
return placements
|
||||
@@ -1,29 +0,0 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "layout-optimizer"
|
||||
version = "0.1.0"
|
||||
description = "AI laboratory layout optimizer for Uni-Lab Phase 3"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"scipy>=1.10",
|
||||
"numpy>=1.24",
|
||||
"fastapi>=0.100",
|
||||
"uvicorn>=0.20",
|
||||
"pydantic>=2.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.0",
|
||||
"httpx>=0.24",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["layout_optimizer*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
@@ -1,433 +0,0 @@
|
||||
"""ROS2/MoveIt2 碰撞检测与 IKFast 可达性检测适配器。
|
||||
|
||||
集成阶段替换 mock_checkers.py 中的 Mock 实现,
|
||||
依赖 Uni-Lab-OS 的 moveit2.py 提供的 MoveIt2 Python 接口。
|
||||
|
||||
用法:
|
||||
from .ros_checkers import MoveItCollisionChecker, IKFastReachabilityChecker
|
||||
|
||||
# 碰撞检测
|
||||
checker = MoveItCollisionChecker(moveit2_instance)
|
||||
collisions = checker.check(placements)
|
||||
|
||||
# 可达性检测(体素图 O(1) 查询 + 实时 IK 回退)
|
||||
reachability = IKFastReachabilityChecker(moveit2_instance, voxel_dir="/path/to/voxels")
|
||||
reachable = reachability.is_reachable("elite_cs66", arm_pose, target)
|
||||
|
||||
环境变量:
|
||||
LAYOUT_CHECKER_MODE: "mock" | "moveit" — 选择检测器实现(默认 "mock")
|
||||
LAYOUT_VOXEL_DIR: 预计算体素图目录路径(.npz 文件)
|
||||
|
||||
前置条件:
|
||||
- ROS2 + MoveIt2 运行中
|
||||
- moveit2.py 中的 MoveIt2 实例已初始化
|
||||
- 命名规范:碰撞对象使用 {device_id}_ 前缀
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .obb import obb_corners, obb_overlap
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------- 坐标变换辅助 ----------
|
||||
|
||||
|
||||
def _yaw_to_quat(theta: float) -> tuple[float, float, float, float]:
|
||||
"""将 2D 旋转角(绕 Z 轴弧度)转换为四元数 (x, y, z, w)。"""
|
||||
return (0.0, 0.0, math.sin(theta / 2), math.cos(theta / 2))
|
||||
|
||||
|
||||
def _transform_to_arm_frame(
|
||||
arm_pose: dict, target: dict,
|
||||
) -> tuple[float, float, float]:
|
||||
"""将目标点从世界坐标系变换到机械臂基坐标系。
|
||||
|
||||
Args:
|
||||
arm_pose: {"x": float, "y": float, "theta": float}
|
||||
target: {"x": float, "y": float, "z": float}
|
||||
|
||||
Returns:
|
||||
(local_x, local_y, local_z) 在臂基坐标系中的位置
|
||||
"""
|
||||
dx = target["x"] - arm_pose["x"]
|
||||
dy = target["y"] - arm_pose["y"]
|
||||
theta = arm_pose.get("theta", 0.0)
|
||||
cos_t = math.cos(-theta)
|
||||
sin_t = math.sin(-theta)
|
||||
local_x = dx * cos_t - dy * sin_t
|
||||
local_y = dx * sin_t + dy * cos_t
|
||||
local_z = target.get("z", 0.0)
|
||||
return (local_x, local_y, local_z)
|
||||
|
||||
|
||||
# ---------- MoveItCollisionChecker ----------
|
||||
|
||||
|
||||
class MoveItCollisionChecker:
|
||||
"""通过 MoveIt2 PlanningScene 进行碰撞检测。
|
||||
|
||||
工作流程:
|
||||
1. 将所有设备同步为 MoveIt2 碰撞盒({device_id}_ 前缀)
|
||||
2. 使用 python-fcl 进行精确两两碰撞检测(若可用)
|
||||
3. 若 FCL 不可用,回退到 OBB SAT 检测
|
||||
|
||||
同步到 MoveIt2 确保机器人运动规划也能感知设备布局。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
moveit2: Any,
|
||||
*,
|
||||
default_height: float = 0.4,
|
||||
sync_to_scene: bool = True,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
moveit2: Uni-Lab-OS moveit2.py 中的 MoveIt2 实例
|
||||
default_height: 碰撞盒默认高度(米)
|
||||
sync_to_scene: 是否同步碰撞对象到 MoveIt2 规划场景
|
||||
"""
|
||||
self._moveit2 = moveit2
|
||||
self._default_height = default_height
|
||||
self._sync_to_scene = sync_to_scene
|
||||
self._fcl_available = self._check_fcl()
|
||||
|
||||
@staticmethod
|
||||
def _check_fcl() -> bool:
|
||||
"""检查 python-fcl 是否可用。"""
|
||||
try:
|
||||
import fcl # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
def check(self, placements: list[dict]) -> list[tuple[str, str]]:
|
||||
"""返回碰撞设备对列表。
|
||||
|
||||
Args:
|
||||
placements: [{"id": str, "bbox": (w, d), "pos": (x, y, θ)}, ...]
|
||||
|
||||
Returns:
|
||||
[("device_a", "device_b"), ...] 存在碰撞的设备对
|
||||
"""
|
||||
# 同步到 MoveIt2 规划场景
|
||||
if self._sync_to_scene:
|
||||
self._sync_collision_objects(placements)
|
||||
|
||||
# 碰撞检测
|
||||
if self._fcl_available:
|
||||
return self._check_with_fcl(placements)
|
||||
return self._check_with_obb(placements)
|
||||
|
||||
def check_bounds(
|
||||
self, placements: list[dict], lab_width: float, lab_depth: float,
|
||||
) -> list[str]:
|
||||
"""返回超出实验室边界的设备 ID 列表。"""
|
||||
out_of_bounds: list[str] = []
|
||||
for p in placements:
|
||||
hw, hd = self._rotated_half_extents(p)
|
||||
x, y = p["pos"][:2]
|
||||
if x - hw < 0 or x + hw > lab_width or y - hd < 0 or y + hd > lab_depth:
|
||||
out_of_bounds.append(p["id"])
|
||||
return out_of_bounds
|
||||
|
||||
def _sync_collision_objects(self, placements: list[dict]) -> None:
|
||||
"""将设备布局同步到 MoveIt2 规划场景。
|
||||
|
||||
使用 {device_id}_ 前缀命名碰撞对象。
|
||||
"""
|
||||
for p in placements:
|
||||
obj_id = f"{p['id']}_"
|
||||
w, d = p["bbox"]
|
||||
x, y = p["pos"][:2]
|
||||
theta = p["pos"][2] if len(p["pos"]) > 2 else 0.0
|
||||
h = self._default_height
|
||||
|
||||
try:
|
||||
self._moveit2.add_collision_box(
|
||||
id=obj_id,
|
||||
size=(w, d, h),
|
||||
position=(x, y, h / 2),
|
||||
quat_xyzw=_yaw_to_quat(theta),
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Failed to sync collision object %s", obj_id, exc_info=True)
|
||||
|
||||
def _check_with_fcl(self, placements: list[dict]) -> list[tuple[str, str]]:
|
||||
"""使用 python-fcl 进行精确碰撞检测。"""
|
||||
import fcl
|
||||
|
||||
objects: list[tuple[str, Any]] = []
|
||||
for p in placements:
|
||||
w, d = p["bbox"]
|
||||
h = self._default_height
|
||||
x, y = p["pos"][:2]
|
||||
theta = p["pos"][2] if len(p["pos"]) > 2 else 0.0
|
||||
|
||||
geom = fcl.Box(w, d, h)
|
||||
tf = fcl.Transform(
|
||||
_yaw_to_rotation_matrix(theta),
|
||||
np.array([x, y, h / 2]),
|
||||
)
|
||||
obj = fcl.CollisionObject(geom, tf)
|
||||
objects.append((p["id"], obj))
|
||||
|
||||
collisions: list[tuple[str, str]] = []
|
||||
n = len(objects)
|
||||
for i in range(n):
|
||||
for j in range(i + 1, n):
|
||||
id_a, obj_a = objects[i]
|
||||
id_b, obj_b = objects[j]
|
||||
request = fcl.CollisionRequest()
|
||||
result = fcl.CollisionResult()
|
||||
ret = fcl.collide(obj_a, obj_b, request, result)
|
||||
if ret > 0:
|
||||
collisions.append((id_a, id_b))
|
||||
|
||||
return collisions
|
||||
|
||||
def _check_with_obb(self, placements: list[dict]) -> list[tuple[str, str]]:
|
||||
"""OBB SAT 回退检测(与 MockCollisionChecker 相同算法)。"""
|
||||
collisions: list[tuple[str, str]] = []
|
||||
n = len(placements)
|
||||
for i in range(n):
|
||||
for j in range(i + 1, n):
|
||||
a, b = placements[i], placements[j]
|
||||
corners_a = obb_corners(
|
||||
a["pos"][0], a["pos"][1],
|
||||
a["bbox"][0], a["bbox"][1],
|
||||
a["pos"][2] if len(a["pos"]) > 2 else 0.0,
|
||||
)
|
||||
corners_b = obb_corners(
|
||||
b["pos"][0], b["pos"][1],
|
||||
b["bbox"][0], b["bbox"][1],
|
||||
b["pos"][2] if len(b["pos"]) > 2 else 0.0,
|
||||
)
|
||||
if obb_overlap(corners_a, corners_b):
|
||||
collisions.append((a["id"], b["id"]))
|
||||
return collisions
|
||||
|
||||
@staticmethod
|
||||
def _rotated_half_extents(p: dict) -> tuple[float, float]:
|
||||
"""计算旋转后 AABB 的半宽和半深。"""
|
||||
w, d = p["bbox"]
|
||||
theta = p["pos"][2] if len(p["pos"]) > 2 else 0.0
|
||||
cos_t = abs(math.cos(theta))
|
||||
sin_t = abs(math.sin(theta))
|
||||
half_w = (w * cos_t + d * sin_t) / 2
|
||||
half_d = (w * sin_t + d * cos_t) / 2
|
||||
return half_w, half_d
|
||||
|
||||
|
||||
# ---------- IKFastReachabilityChecker ----------
|
||||
|
||||
|
||||
class IKFastReachabilityChecker:
|
||||
"""基于 MoveIt2 compute_ik 和预计算体素图的可达性检测。
|
||||
|
||||
双模式:
|
||||
1. 体素图模式(O(1)):从 .npz 文件加载预计算可达性网格,
|
||||
将目标点变换到臂基坐标系后直接查表。
|
||||
2. 实时 IK 模式(~5ms/call):调用 MoveIt2.compute_ik(),
|
||||
支持约束感知的精确可达性判断。
|
||||
|
||||
优先使用体素图,无匹配时回退到实时 IK。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
moveit2: Any = None,
|
||||
*,
|
||||
voxel_dir: str | Path | None = None,
|
||||
voxel_resolution: float = 0.01,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
moveit2: MoveIt2 实例(用于实时 IK 回退)
|
||||
voxel_dir: 预计算体素图目录(.npz 文件,文件名 = arm_id)
|
||||
voxel_resolution: 体素分辨率(米),用于坐标 → 索引转换
|
||||
"""
|
||||
self._moveit2 = moveit2
|
||||
self._voxel_resolution = voxel_resolution
|
||||
self._voxel_maps: dict[str, _VoxelMap] = {}
|
||||
|
||||
if voxel_dir is not None:
|
||||
self._load_voxel_maps(Path(voxel_dir))
|
||||
|
||||
def is_reachable(self, arm_id: str, arm_pose: dict, target: dict) -> bool:
|
||||
"""判断机械臂在给定位姿下能否到达目标点。
|
||||
|
||||
Args:
|
||||
arm_id: 机械臂设备 ID
|
||||
arm_pose: {"x": float, "y": float, "theta": float}
|
||||
target: {"x": float, "y": float, "z": float}
|
||||
|
||||
Returns:
|
||||
True 如果可达
|
||||
"""
|
||||
local = _transform_to_arm_frame(arm_pose, target)
|
||||
|
||||
# 1. 体素图查询(O(1))
|
||||
if arm_id in self._voxel_maps:
|
||||
return self._check_voxel(arm_id, local)
|
||||
|
||||
# 2. 实时 IK 回退
|
||||
if self._moveit2 is not None:
|
||||
return self._check_live_ik(local)
|
||||
|
||||
# 无可用检测方式,乐观返回(记录警告)
|
||||
logger.warning(
|
||||
"No reachability checker available for arm %s, returning True", arm_id,
|
||||
)
|
||||
return True
|
||||
|
||||
def _load_voxel_maps(self, voxel_dir: Path) -> None:
|
||||
"""加载目录下所有 .npz 体素图文件。
|
||||
|
||||
文件格式:{arm_id}.npz,包含:
|
||||
- "grid": bool ndarray (nx, ny, nz) — True 表示可达
|
||||
- "origin": float ndarray (3,) — 网格原点(臂基坐标系)
|
||||
- "resolution": float — 体素分辨率(米)
|
||||
"""
|
||||
if not voxel_dir.exists():
|
||||
logger.warning("Voxel directory does not exist: %s", voxel_dir)
|
||||
return
|
||||
|
||||
for npz_file in voxel_dir.glob("*.npz"):
|
||||
arm_id = npz_file.stem
|
||||
try:
|
||||
data = np.load(str(npz_file))
|
||||
grid = data["grid"].astype(bool)
|
||||
origin = data["origin"].astype(float)
|
||||
resolution = float(data.get("resolution", self._voxel_resolution))
|
||||
self._voxel_maps[arm_id] = _VoxelMap(
|
||||
grid=grid, origin=origin, resolution=resolution,
|
||||
)
|
||||
logger.info(
|
||||
"Loaded voxel map for %s: shape=%s, resolution=%.3f",
|
||||
arm_id, grid.shape, resolution,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Failed to load voxel map %s", npz_file, exc_info=True)
|
||||
|
||||
def _check_voxel(self, arm_id: str, local: tuple[float, float, float]) -> bool:
|
||||
"""通过体素网格查询可达性。"""
|
||||
vm = self._voxel_maps[arm_id]
|
||||
ix = int(round((local[0] - vm.origin[0]) / vm.resolution))
|
||||
iy = int(round((local[1] - vm.origin[1]) / vm.resolution))
|
||||
iz = int(round((local[2] - vm.origin[2]) / vm.resolution))
|
||||
|
||||
if (
|
||||
0 <= ix < vm.grid.shape[0]
|
||||
and 0 <= iy < vm.grid.shape[1]
|
||||
and 0 <= iz < vm.grid.shape[2]
|
||||
):
|
||||
return bool(vm.grid[ix, iy, iz])
|
||||
|
||||
# 超出体素图范围 → 不可达
|
||||
return False
|
||||
|
||||
def _check_live_ik(self, local: tuple[float, float, float]) -> bool:
|
||||
"""调用 MoveIt2.compute_ik() 进行实时可达性检测。
|
||||
|
||||
compute_ik 返回 JointState(成功)或 None(不可达)。
|
||||
使用默认朝下姿态(四元数 0, 1, 0, 0 即绕 X 轴旋转 180°)。
|
||||
"""
|
||||
# 目标姿态:末端执行器朝下
|
||||
quat_xyzw = (0.0, 1.0, 0.0, 0.0)
|
||||
try:
|
||||
result = self._moveit2.compute_ik(
|
||||
position=local,
|
||||
quat_xyzw=quat_xyzw,
|
||||
)
|
||||
return result is not None
|
||||
except Exception:
|
||||
logger.warning("compute_ik call failed", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
# ---------- 体素图数据类 ----------
|
||||
|
||||
|
||||
class _VoxelMap:
|
||||
"""预计算可达性体素网格。"""
|
||||
|
||||
__slots__ = ("grid", "origin", "resolution")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
grid: np.ndarray,
|
||||
origin: np.ndarray,
|
||||
resolution: float,
|
||||
):
|
||||
self.grid = grid
|
||||
self.origin = origin
|
||||
self.resolution = resolution
|
||||
|
||||
|
||||
# ---------- FCL 辅助 ----------
|
||||
|
||||
|
||||
def _yaw_to_rotation_matrix(theta: float) -> np.ndarray:
|
||||
"""绕 Z 轴旋转矩阵(3×3)。"""
|
||||
c, s = math.cos(theta), math.sin(theta)
|
||||
return np.array([
|
||||
[c, -s, 0.0],
|
||||
[s, c, 0.0],
|
||||
[0.0, 0.0, 1.0],
|
||||
])
|
||||
|
||||
|
||||
# ---------- 工厂函数 ----------
|
||||
|
||||
|
||||
def create_checkers(
|
||||
moveit2: Any = None,
|
||||
*,
|
||||
mode: str | None = None,
|
||||
voxel_dir: str | None = None,
|
||||
) -> tuple[Any, Any]:
|
||||
"""根据环境变量或参数创建检测器实例。
|
||||
|
||||
Args:
|
||||
moveit2: MoveIt2 实例(moveit 模式必需)
|
||||
mode: "mock" | "moveit"(默认从 LAYOUT_CHECKER_MODE 环境变量读取)
|
||||
voxel_dir: 体素图目录(默认从 LAYOUT_VOXEL_DIR 环境变量读取)
|
||||
|
||||
Returns:
|
||||
(collision_checker, reachability_checker)
|
||||
"""
|
||||
if mode is None:
|
||||
mode = os.getenv("LAYOUT_CHECKER_MODE", "mock")
|
||||
|
||||
if mode == "moveit":
|
||||
if moveit2 is None:
|
||||
raise ValueError("MoveIt2 instance required for 'moveit' checker mode")
|
||||
|
||||
if voxel_dir is None:
|
||||
voxel_dir = os.getenv("LAYOUT_VOXEL_DIR")
|
||||
|
||||
collision = MoveItCollisionChecker(moveit2)
|
||||
reachability = IKFastReachabilityChecker(
|
||||
moveit2, voxel_dir=voxel_dir,
|
||||
)
|
||||
logger.info("Using MoveIt2 checkers (voxel_dir=%s)", voxel_dir)
|
||||
return collision, reachability
|
||||
|
||||
# 默认:mock 模式
|
||||
from .mock_checkers import MockCollisionChecker, MockReachabilityChecker
|
||||
|
||||
logger.info("Using mock checkers")
|
||||
return MockCollisionChecker(), MockReachabilityChecker()
|
||||
@@ -1,331 +0,0 @@
|
||||
"""Force-directed seeder engine with named parameter presets.
|
||||
|
||||
Produces initial device placements for the layout optimizer.
|
||||
Different layout strategies (compact, spread, workflow-aware) are
|
||||
parameter configurations of the same force-directed simulation engine.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
from .models import Device, Lab, Placement
|
||||
from .obb import obb_corners, obb_overlap, obb_min_distance
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SeederParams:
|
||||
"""Parameters for the force-directed seeder engine."""
|
||||
boundary_attraction: float = 0.0 # >0 push to walls, <0 push to center
|
||||
mutual_repulsion: float = 1.0 # inter-device repulsion strength
|
||||
edge_attraction: float = 0.0 # workflow edge attraction (Stage 2)
|
||||
orientation_mode: str = "none" # "outward" | "inward" | "none"
|
||||
|
||||
|
||||
PRESETS: dict[str, SeederParams | None] = {
|
||||
"compact_outward": SeederParams(
|
||||
boundary_attraction=-1.0, mutual_repulsion=0.5, orientation_mode="outward",
|
||||
),
|
||||
"spread_inward": SeederParams(
|
||||
boundary_attraction=1.0, mutual_repulsion=1.0, orientation_mode="inward",
|
||||
),
|
||||
"workflow_cluster": SeederParams(
|
||||
boundary_attraction=-0.5, mutual_repulsion=0.5,
|
||||
edge_attraction=1.0, orientation_mode="outward",
|
||||
),
|
||||
"row_fallback": None, # Delegates to generate_fallback()
|
||||
}
|
||||
|
||||
|
||||
def resolve_seeder_params(
|
||||
preset_name: str, overrides: dict | None = None,
|
||||
) -> SeederParams | None:
|
||||
"""Look up preset by name and apply overrides."""
|
||||
if preset_name not in PRESETS:
|
||||
raise ValueError(
|
||||
f"Unknown seeder preset '{preset_name}'. "
|
||||
f"Available: {list(PRESETS.keys())}"
|
||||
)
|
||||
params = PRESETS[preset_name]
|
||||
if params is None or not overrides:
|
||||
return params
|
||||
return replace(params, **{k: v for k, v in overrides.items() if hasattr(params, k)})
|
||||
|
||||
|
||||
def seed_layout(
|
||||
devices: list[Device],
|
||||
lab: Lab,
|
||||
params: SeederParams | None,
|
||||
edges: list[list[str]] | None = None,
|
||||
) -> list[Placement]:
|
||||
"""Generate initial device placements using force-directed simulation.
|
||||
|
||||
Args:
|
||||
devices: devices to place
|
||||
lab: lab dimensions
|
||||
params: seeder parameters (None = row_fallback)
|
||||
edges: workflow edges as [device_a_id, device_b_id] pairs (Stage 2)
|
||||
|
||||
Returns:
|
||||
list of Placement objects, one per device
|
||||
"""
|
||||
if not devices:
|
||||
return []
|
||||
|
||||
if params is None:
|
||||
from .pencil_integration import generate_fallback
|
||||
return generate_fallback(devices, lab)
|
||||
|
||||
return _force_simulation(devices, lab, params, edges)
|
||||
|
||||
|
||||
def _force_simulation(
|
||||
devices: list[Device],
|
||||
lab: Lab,
|
||||
params: SeederParams,
|
||||
edges: list[list[str]] | None = None,
|
||||
max_iter: int = 80,
|
||||
dt: float = 0.05,
|
||||
damping: float = 0.8,
|
||||
) -> list[Placement]:
|
||||
"""Run force-directed simulation to produce initial placements."""
|
||||
n = len(devices)
|
||||
center_x, center_y = lab.width / 2, lab.depth / 2
|
||||
|
||||
# Initialize positions: grid layout within lab bounds
|
||||
cols = max(1, int(math.ceil(math.sqrt(n))))
|
||||
rows_count = max(1, math.ceil(n / cols))
|
||||
positions = [] # (x, y) per device
|
||||
for i, dev in enumerate(devices):
|
||||
row, col = divmod(i, cols)
|
||||
margin = 0.3
|
||||
x = margin + (col + 0.5) * (lab.width - 2 * margin) / cols
|
||||
y = margin + (row + 0.5) * (lab.depth - 2 * margin) / rows_count
|
||||
x = min(max(x, dev.bbox[0] / 2), lab.width - dev.bbox[0] / 2)
|
||||
y = min(max(y, dev.bbox[1] / 2), lab.depth - dev.bbox[1] / 2)
|
||||
positions.append([x, y])
|
||||
|
||||
# Initialize orientations
|
||||
thetas = [0.0] * n
|
||||
|
||||
# Build edge lookup for Stage 2
|
||||
edge_set: set[tuple[int, int]] = set()
|
||||
if edges and params.edge_attraction > 0:
|
||||
id_to_idx = {d.id: i for i, d in enumerate(devices)}
|
||||
for e in edges:
|
||||
if len(e) == 2 and e[0] in id_to_idx and e[1] in id_to_idx:
|
||||
edge_set.add((id_to_idx[e[0]], id_to_idx[e[1]]))
|
||||
|
||||
converged = False
|
||||
for iteration in range(max_iter):
|
||||
forces = [[0.0, 0.0] for _ in range(n)]
|
||||
total_force = 0.0
|
||||
|
||||
# 1. Boundary force
|
||||
for i in range(n):
|
||||
dx = positions[i][0] - center_x
|
||||
dy = positions[i][1] - center_y
|
||||
dist_to_center = math.sqrt(dx * dx + dy * dy) + 1e-9
|
||||
f = params.boundary_attraction
|
||||
forces[i][0] += f * dx / dist_to_center
|
||||
forces[i][1] += f * dy / dist_to_center
|
||||
|
||||
# 2. Mutual repulsion (OBB edge-to-edge)
|
||||
for i in range(n):
|
||||
for j in range(i + 1, n):
|
||||
ci = obb_corners(
|
||||
positions[i][0], positions[i][1],
|
||||
devices[i].bbox[0], devices[i].bbox[1], thetas[i],
|
||||
)
|
||||
cj = obb_corners(
|
||||
positions[j][0], positions[j][1],
|
||||
devices[j].bbox[0], devices[j].bbox[1], thetas[j],
|
||||
)
|
||||
dist = obb_min_distance(ci, cj)
|
||||
if dist < 1e-9:
|
||||
dist = 0.01 # Prevent division by zero for overlapping
|
||||
dx = positions[i][0] - positions[j][0]
|
||||
dy = positions[i][1] - positions[j][1]
|
||||
d_center = math.sqrt(dx * dx + dy * dy) + 1e-9
|
||||
repulsion = params.mutual_repulsion / (dist * dist + 0.1)
|
||||
fx = repulsion * dx / d_center
|
||||
fy = repulsion * dy / d_center
|
||||
forces[i][0] += fx
|
||||
forces[i][1] += fy
|
||||
forces[j][0] -= fx
|
||||
forces[j][1] -= fy
|
||||
|
||||
# 3. Edge attraction (Stage 2)
|
||||
if params.edge_attraction > 0:
|
||||
for i_idx, j_idx in edge_set:
|
||||
dx = positions[j_idx][0] - positions[i_idx][0]
|
||||
dy = positions[j_idx][1] - positions[i_idx][1]
|
||||
dist = math.sqrt(dx * dx + dy * dy) + 1e-9
|
||||
f = params.edge_attraction * dist * 0.1
|
||||
forces[i_idx][0] += f * dx / dist
|
||||
forces[i_idx][1] += f * dy / dist
|
||||
forces[j_idx][0] -= f * dx / dist
|
||||
forces[j_idx][1] -= f * dy / dist
|
||||
|
||||
# 4. Update positions (Euler + damping)
|
||||
for i in range(n):
|
||||
positions[i][0] += forces[i][0] * dt * damping
|
||||
positions[i][1] += forces[i][1] * dt * damping
|
||||
total_force += math.sqrt(forces[i][0]**2 + forces[i][1]**2)
|
||||
|
||||
# 5. Update orientations
|
||||
if params.orientation_mode != "none":
|
||||
for i in range(n):
|
||||
thetas[i] = _compute_orientation(
|
||||
positions[i][0], positions[i][1],
|
||||
center_x, center_y,
|
||||
devices[i], params.orientation_mode,
|
||||
)
|
||||
|
||||
# 6. Clamp to lab bounds
|
||||
for i in range(n):
|
||||
hw, hh = devices[i].bbox[0] / 2, devices[i].bbox[1] / 2
|
||||
positions[i][0] = max(hw, min(lab.width - hw, positions[i][0]))
|
||||
positions[i][1] = max(hh, min(lab.depth - hh, positions[i][1]))
|
||||
|
||||
if total_force < 0.01 * n:
|
||||
converged = True
|
||||
logger.info("Force simulation converged at iteration %d", iteration)
|
||||
break
|
||||
|
||||
if not converged:
|
||||
logger.info("Force simulation reached max iterations (%d)", max_iter)
|
||||
|
||||
placements = [
|
||||
Placement(device_id=devices[i].id, x=positions[i][0], y=positions[i][1], theta=thetas[i])
|
||||
for i in range(n)
|
||||
]
|
||||
|
||||
# Log initial collision count
|
||||
initial_collisions = _count_collisions(devices, placements)
|
||||
logger.info("Seeder: %d initial collision pairs before resolution", initial_collisions)
|
||||
|
||||
# Collision resolution pass
|
||||
placements = _resolve_collisions(devices, placements, lab, max_passes=5)
|
||||
|
||||
# Log diagnostics
|
||||
final_collisions = _count_collisions(devices, placements)
|
||||
no_openings = sum(1 for d in devices if not d.openings)
|
||||
logger.info(
|
||||
"Seeder complete: %d devices, %d without openings, %d collision pairs remaining",
|
||||
n, no_openings, final_collisions,
|
||||
)
|
||||
|
||||
return placements
|
||||
|
||||
|
||||
def _compute_orientation(
|
||||
x: float, y: float,
|
||||
center_x: float, center_y: float,
|
||||
device: Device,
|
||||
mode: str,
|
||||
) -> float:
|
||||
"""Compute theta so the device's front faces outward or inward."""
|
||||
dx = x - center_x
|
||||
dy = y - center_y
|
||||
if abs(dx) < 1e-9 and abs(dy) < 1e-9:
|
||||
return 0.0
|
||||
|
||||
angle_to_device = math.atan2(dy, dx)
|
||||
|
||||
if device.openings:
|
||||
front = device.openings[0].direction
|
||||
else:
|
||||
front = (0.0, -1.0) # Default: -Y is front
|
||||
|
||||
front_angle = math.atan2(front[1], front[0])
|
||||
|
||||
if mode == "outward":
|
||||
target = angle_to_device
|
||||
elif mode == "inward":
|
||||
target = angle_to_device + math.pi
|
||||
else:
|
||||
return 0.0
|
||||
|
||||
return (target - front_angle) % (2 * math.pi)
|
||||
|
||||
|
||||
def _count_collisions(devices: list[Device], placements: list[Placement]) -> int:
|
||||
"""Count OBB collision pairs (for diagnostics logging)."""
|
||||
n = len(devices)
|
||||
count = 0
|
||||
for i in range(n):
|
||||
for j in range(i + 1, n):
|
||||
ci = obb_corners(placements[i].x, placements[i].y,
|
||||
devices[i].bbox[0], devices[i].bbox[1], placements[i].theta)
|
||||
cj = obb_corners(placements[j].x, placements[j].y,
|
||||
devices[j].bbox[0], devices[j].bbox[1], placements[j].theta)
|
||||
if obb_overlap(ci, cj):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def _resolve_collisions(
|
||||
devices: list[Device],
|
||||
placements: list[Placement],
|
||||
lab: Lab,
|
||||
max_passes: int = 5,
|
||||
) -> list[Placement]:
|
||||
"""Push overlapping devices apart. Returns new placement list."""
|
||||
positions = [[p.x, p.y] for p in placements]
|
||||
thetas = [p.theta for p in placements]
|
||||
n = len(devices)
|
||||
|
||||
for pass_num in range(max_passes):
|
||||
has_collision = False
|
||||
for i in range(n):
|
||||
for j in range(i + 1, n):
|
||||
ci = obb_corners(
|
||||
positions[i][0], positions[i][1],
|
||||
devices[i].bbox[0], devices[i].bbox[1], thetas[i],
|
||||
)
|
||||
cj = obb_corners(
|
||||
positions[j][0], positions[j][1],
|
||||
devices[j].bbox[0], devices[j].bbox[1], thetas[j],
|
||||
)
|
||||
if obb_overlap(ci, cj):
|
||||
has_collision = True
|
||||
dx = positions[i][0] - positions[j][0]
|
||||
dy = positions[i][1] - positions[j][1]
|
||||
dist = math.sqrt(dx * dx + dy * dy) + 1e-9
|
||||
push = 0.5 * (
|
||||
max(devices[i].bbox[0], devices[i].bbox[1])
|
||||
+ max(devices[j].bbox[0], devices[j].bbox[1])
|
||||
) / 4
|
||||
positions[i][0] += push * dx / dist
|
||||
positions[i][1] += push * dy / dist
|
||||
positions[j][0] -= push * dx / dist
|
||||
positions[j][1] -= push * dy / dist
|
||||
|
||||
# Clamp to bounds (rotation-aware AABB half-extents)
|
||||
for i in range(n):
|
||||
cos_t = abs(math.cos(thetas[i]))
|
||||
sin_t = abs(math.sin(thetas[i]))
|
||||
hw = (devices[i].bbox[0] * cos_t + devices[i].bbox[1] * sin_t) / 2
|
||||
hh = (devices[i].bbox[0] * sin_t + devices[i].bbox[1] * cos_t) / 2
|
||||
positions[i][0] = max(hw, min(lab.width - hw, positions[i][0]))
|
||||
positions[i][1] = max(hh, min(lab.depth - hh, positions[i][1]))
|
||||
|
||||
if not has_collision:
|
||||
logger.info("Collision resolution complete after %d passes", pass_num + 1)
|
||||
break
|
||||
else:
|
||||
logger.warning(
|
||||
"Collision resolution: %d passes exhausted, collisions may remain",
|
||||
max_passes,
|
||||
)
|
||||
|
||||
return [
|
||||
Placement(device_id=placements[i].device_id,
|
||||
x=positions[i][0], y=positions[i][1],
|
||||
theta=thetas[i], uuid=placements[i].uuid)
|
||||
for i in range(n)
|
||||
]
|
||||
@@ -1,742 +0,0 @@
|
||||
"""FastAPI 开发服务器。
|
||||
|
||||
开发阶段独立运行于 localhost:8000,前端通过 CORS 调用。
|
||||
集成阶段合并到 Uni-Lab-OS 的 FastAPI 服务中。
|
||||
|
||||
运行方式:
|
||||
uvicorn unilabos.layout_optimizer.server:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
调试模式(启用 DEBUG 日志,含优化器逐代 cost 明细):
|
||||
LAYOUT_DEBUG=1 uvicorn unilabos.layout_optimizer.server:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
日志文件:
|
||||
自动写入 layout_optimizer/logs/{YYYYMMDD_HHMMSS}.log(始终 DEBUG 级别)。
|
||||
前端 1s 轮询的 GET /scene/placements 200 行不写入日志文件。
|
||||
|
||||
前端访问:
|
||||
http://localhost:8000/
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
import itertools
|
||||
import logging
|
||||
import logging.handlers
|
||||
import math
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .constraints import DEFAULT_WEIGHT_ANGLE # noqa: F401 — kept for external use
|
||||
from .device_catalog import (
|
||||
create_devices_from_list,
|
||||
load_devices_from_assets,
|
||||
load_devices_from_registry,
|
||||
load_footprints,
|
||||
merge_device_lists,
|
||||
)
|
||||
from .lab_parser import parse_lab
|
||||
from .intent_interpreter import InterpretResult, interpret_intents
|
||||
from .models import Constraint, Intent
|
||||
from .optimizer import optimize
|
||||
|
||||
_console_level = logging.DEBUG if os.getenv("LAYOUT_DEBUG") else logging.INFO
|
||||
# root logger must be DEBUG so the file handler receives all records;
|
||||
# console output level is controlled separately via its handler.
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
# basicConfig creates a default StreamHandler — set its level to the console level
|
||||
for _h in logging.getLogger().handlers:
|
||||
if isinstance(_h, logging.StreamHandler):
|
||||
_h.setLevel(_console_level)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# --- 文件日志:实时写入 logs/ 目录,按启动时间命名 ---
|
||||
_LOG_DIR = Path(__file__).parent / "logs"
|
||||
_LOG_DIR.mkdir(exist_ok=True)
|
||||
_log_file = _LOG_DIR / f"{datetime.now():%Y%m%d_%H%M%S}.log"
|
||||
|
||||
|
||||
class _PollingFilter(logging.Filter):
|
||||
"""过滤掉前端 1s 轮询产生的 GET /scene/placements 日志行。"""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
msg = record.getMessage()
|
||||
if "GET /scene/placements" in msg and "200" in msg:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
_file_handler = logging.FileHandler(_log_file, encoding="utf-8")
|
||||
_file_handler.setLevel(logging.DEBUG)
|
||||
_file_handler.setFormatter(
|
||||
logging.Formatter("%(asctime)s %(levelname)-5s [%(name)s] %(message)s")
|
||||
)
|
||||
_file_handler.addFilter(_PollingFilter())
|
||||
logging.getLogger().addHandler(_file_handler)
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
|
||||
# 可配置路径
|
||||
# __file__ -> Uni-Lab-OS/unilabos/layout_optimizer/server.py
|
||||
_UNILABOS_DIR = Path(__file__).resolve().parent.parent # .../Uni-Lab-OS/unilabos/
|
||||
|
||||
UNI_LAB_ASSETS_DIR = Path(
|
||||
os.getenv("UNI_LAB_ASSETS_DIR", str(_UNILABOS_DIR.parent.parent.parent / "uni-lab-assets"))
|
||||
)
|
||||
UNI_LAB_ASSETS_MODELS_DIR = UNI_LAB_ASSETS_DIR / "device_models"
|
||||
UNI_LAB_ASSETS_DATA_JSON = UNI_LAB_ASSETS_DIR / "data.json"
|
||||
UNI_LAB_OS_DEVICE_MESH_DIR = Path(
|
||||
os.getenv(
|
||||
"UNI_LAB_OS_DEVICE_MESH_DIR",
|
||||
str(_UNILABOS_DIR / "device_mesh" / "devices"),
|
||||
)
|
||||
)
|
||||
|
||||
app = FastAPI(title="Layout Optimizer", version="0.2.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # 开发阶段允许所有来源
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 挂载静态文件目录
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
# 挂载 3D 模型和缩略图
|
||||
if UNI_LAB_ASSETS_MODELS_DIR.exists():
|
||||
app.mount("/models", StaticFiles(directory=str(UNI_LAB_ASSETS_MODELS_DIR)), name="models")
|
||||
logger.info("Mounted /models from %s", UNI_LAB_ASSETS_MODELS_DIR)
|
||||
else:
|
||||
logger.warning("uni-lab-assets models dir not found: %s", UNI_LAB_ASSETS_MODELS_DIR)
|
||||
|
||||
|
||||
# ---------- 设备目录缓存 ----------
|
||||
|
||||
_device_cache: list[dict] | None = None
|
||||
_DEVICE_PARAM_KEYS = {"device_a", "device_b", "arm_id", "target_device_id", "device"}
|
||||
|
||||
|
||||
# 消耗品/配件关键词(不独立放置于实验台)
|
||||
_CONSUMABLE_KEYWORDS = {
|
||||
"plate", "well", "tube", "tip", "reservoir", "carrier", "nest",
|
||||
"adapter", "trough", "magnet_module", "magnet_plate", "rack", "lid",
|
||||
"seal", "cap", "vial", "flask", "dish", "block", "strip", "insert",
|
||||
"gasket", "pad", "grid_segment", "spacer", "diti_tray",
|
||||
}
|
||||
# 但包含这些关键词的是独立设备,不是消耗品
|
||||
_DEVICE_KEYWORDS = {
|
||||
"reader", "handler", "hotel", "washer", "stacker", "sealer", "labeler",
|
||||
"centrifuge", "incubator", "shaker", "robot", "arm", "flex", "dispenser",
|
||||
"printer", "scanner", "analyzer", "fluorometer", "spectrophotometer",
|
||||
"thermocycler", "module",
|
||||
}
|
||||
|
||||
|
||||
def _is_standalone_device(device_id: str, bbox: tuple[float, float]) -> bool:
|
||||
"""判断设备是否独立放置于实验台(非消耗品/配件)。"""
|
||||
mx = max(bbox[0], bbox[1])
|
||||
mn = min(bbox[0], bbox[1])
|
||||
if mx >= 0.30:
|
||||
return True # 大于 30cm 一定是独立设备
|
||||
if mx < 0.05:
|
||||
return False # 小于 5cm 一定是消耗品
|
||||
lower = device_id.lower()
|
||||
# 非常扁平(一维 < 3cm)的几乎都是配件/载具,即使名称匹配设备关键词
|
||||
if mn < 0.03:
|
||||
return False
|
||||
# 先检查消耗品关键词(如果匹配,再看是否有设备关键词覆盖)
|
||||
is_consumable_name = any(kw in lower for kw in _CONSUMABLE_KEYWORDS)
|
||||
is_device_name = any(kw in lower for kw in _DEVICE_KEYWORDS)
|
||||
if is_consumable_name and not is_device_name:
|
||||
return False
|
||||
if is_device_name:
|
||||
return True
|
||||
# 默认:>= 15cm 视为设备
|
||||
return mx >= 0.15
|
||||
|
||||
|
||||
def _build_device_list() -> list[dict]:
|
||||
"""构建合并后的设备列表(缓存)。"""
|
||||
global _device_cache
|
||||
if _device_cache is not None:
|
||||
return _device_cache
|
||||
|
||||
footprints = load_footprints()
|
||||
|
||||
registry = load_devices_from_registry(UNI_LAB_OS_DEVICE_MESH_DIR, footprints)
|
||||
assets = load_devices_from_assets(UNI_LAB_ASSETS_DATA_JSON, footprints)
|
||||
|
||||
merged = merge_device_lists(registry, assets)
|
||||
|
||||
_device_cache = [
|
||||
{
|
||||
"id": d.id,
|
||||
"name": d.name,
|
||||
"device_type": d.device_type,
|
||||
"source": d.source,
|
||||
"bbox": list(d.bbox),
|
||||
"height": d.height,
|
||||
"origin_offset": list(d.origin_offset),
|
||||
"openings": [
|
||||
{"direction": list(o.direction), "label": o.label}
|
||||
for o in d.openings
|
||||
],
|
||||
"model_path": d.model_path,
|
||||
"model_type": d.model_type,
|
||||
"thumbnail_url": d.thumbnail_url,
|
||||
"is_standalone": _is_standalone_device(d.id, d.bbox),
|
||||
}
|
||||
for d in merged
|
||||
]
|
||||
standalone = sum(1 for d in _device_cache if d["is_standalone"])
|
||||
logger.info("Built device catalog: %d devices (%d standalone)", len(_device_cache), standalone)
|
||||
return _device_cache
|
||||
|
||||
|
||||
def _catalog_id_from_internal(device_id: str) -> str:
|
||||
"""内部实例 ID → catalog ID。"""
|
||||
return device_id.split("#", 1)[0]
|
||||
|
||||
|
||||
def _expand_constraints_for_duplicates(
|
||||
constraints: list[Constraint], devices: list,
|
||||
) -> list[Constraint]:
|
||||
"""将引用 bare catalog ID 的约束扩展到所有重复实例。"""
|
||||
catalog_instances: dict[str, list[str]] = defaultdict(list)
|
||||
for dev in devices:
|
||||
catalog_instances[_catalog_id_from_internal(dev.id)].append(dev.id)
|
||||
|
||||
expanded_constraints: list[Constraint] = []
|
||||
for constraint in constraints:
|
||||
fan_out_keys: list[str] = []
|
||||
fan_out_values: list[list[str]] = []
|
||||
|
||||
for key in _DEVICE_PARAM_KEYS:
|
||||
if key not in constraint.params:
|
||||
continue
|
||||
ref_id = constraint.params[key]
|
||||
if "#" in ref_id:
|
||||
continue
|
||||
instances = catalog_instances.get(ref_id, [])
|
||||
if len(instances) > 1:
|
||||
fan_out_keys.append(key)
|
||||
fan_out_values.append(instances)
|
||||
logger.info(
|
||||
"Fan-out: %s %s=%s -> %d instances",
|
||||
constraint.rule_name, key, ref_id, len(instances),
|
||||
)
|
||||
|
||||
if not fan_out_keys:
|
||||
expanded_constraints.append(constraint)
|
||||
continue
|
||||
|
||||
for combo in itertools.product(*fan_out_values):
|
||||
new_params = dict(constraint.params)
|
||||
for key, internal_id in zip(fan_out_keys, combo):
|
||||
new_params[key] = internal_id
|
||||
expanded_constraints.append(
|
||||
Constraint(
|
||||
type=constraint.type,
|
||||
rule_name=constraint.rule_name,
|
||||
params=new_params,
|
||||
weight=constraint.weight,
|
||||
)
|
||||
)
|
||||
|
||||
return expanded_constraints
|
||||
|
||||
|
||||
def _maybe_add_prefer_aligned_constraint(
|
||||
constraints: list[Constraint], align_weight: float,
|
||||
) -> list[Constraint]:
|
||||
"""仅在用户未显式提供 prefer_aligned 时注入对齐约束。"""
|
||||
if align_weight <= 0:
|
||||
return constraints
|
||||
|
||||
if any(c.rule_name == "prefer_aligned" for c in constraints):
|
||||
logger.info("Skipping auto-injected prefer_aligned because one already exists")
|
||||
return constraints
|
||||
|
||||
constraints.append(
|
||||
Constraint(
|
||||
type="soft",
|
||||
rule_name="prefer_aligned",
|
||||
weight=align_weight,
|
||||
)
|
||||
)
|
||||
return constraints
|
||||
|
||||
|
||||
# ---------- 路由 ----------
|
||||
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def root():
|
||||
return RedirectResponse(url="/lab3d")
|
||||
|
||||
|
||||
@app.get("/lab3d", include_in_schema=False)
|
||||
async def lab3d_ui():
|
||||
return FileResponse(STATIC_DIR / "lab3d.html")
|
||||
|
||||
|
||||
@app.get("/devices")
|
||||
async def list_devices(source: str = "all"):
|
||||
"""返回合并后的设备目录。?source=registry|assets|all"""
|
||||
devices = _build_device_list()
|
||||
if source != "all":
|
||||
devices = [d for d in devices if d["source"] == source]
|
||||
return devices
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ---------- 意图解释 API ----------
|
||||
|
||||
|
||||
class IntentSpec(BaseModel):
|
||||
intent: str
|
||||
params: dict = {}
|
||||
description: str = ""
|
||||
|
||||
|
||||
class TranslationEntry(BaseModel):
|
||||
source_intent: str
|
||||
source_description: str
|
||||
source_params: dict
|
||||
generated_constraints: list[dict]
|
||||
explanation: str
|
||||
confidence: str = "high"
|
||||
|
||||
|
||||
class InterpretRequest(BaseModel):
|
||||
intents: list[IntentSpec]
|
||||
|
||||
|
||||
class InterpretResponse(BaseModel):
|
||||
constraints: list[dict]
|
||||
translations: list[TranslationEntry]
|
||||
workflow_edges: list[list[str]]
|
||||
errors: list[str]
|
||||
|
||||
|
||||
@app.post("/interpret", response_model=InterpretResponse)
|
||||
async def run_interpret(request: InterpretRequest):
|
||||
"""将语义化意图翻译为约束列表,供用户确认后传入 /optimize。"""
|
||||
logger.info("Interpret request: %d intents", len(request.intents))
|
||||
|
||||
intents = [
|
||||
Intent(
|
||||
intent=i.intent,
|
||||
params=i.params,
|
||||
description=i.description,
|
||||
)
|
||||
for i in request.intents
|
||||
]
|
||||
|
||||
result: InterpretResult = interpret_intents(intents)
|
||||
|
||||
return InterpretResponse(
|
||||
constraints=[
|
||||
{"type": c.type, "rule_name": c.rule_name, "params": c.params, "weight": c.weight}
|
||||
for c in result.constraints
|
||||
],
|
||||
translations=[
|
||||
TranslationEntry(
|
||||
source_intent=t["source_intent"],
|
||||
source_description=t.get("source_description", ""),
|
||||
source_params=t.get("source_params", {}),
|
||||
generated_constraints=t["generated_constraints"],
|
||||
explanation=t["explanation"],
|
||||
confidence=t.get("confidence", "high"),
|
||||
)
|
||||
for t in result.translations
|
||||
],
|
||||
workflow_edges=result.workflow_edges,
|
||||
errors=result.errors,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/interpret/schema")
|
||||
async def interpret_schema():
|
||||
"""返回可用意图类型及其参数规范,供 LLM agent 发现和使用。"""
|
||||
return {
|
||||
"description": "Layout optimizer intent schema. LLM agents should translate user requests into these intents.",
|
||||
"intents": {
|
||||
"reachable_by": {
|
||||
"description": "Robot arm must be able to reach all target devices",
|
||||
"params": {
|
||||
"arm": {"type": "string", "required": True, "description": "Device ID of robot arm"},
|
||||
"targets": {"type": "list[string]", "required": True, "description": "Device IDs the arm must reach"},
|
||||
},
|
||||
"generates": "hard reachability constraint per target",
|
||||
},
|
||||
"close_together": {
|
||||
"description": "Group of devices should be placed near each other",
|
||||
"params": {
|
||||
"devices": {"type": "list[string]", "required": True, "description": "Device IDs (min 2)"},
|
||||
"priority": {"type": "string", "required": False, "default": "medium", "enum": ["low", "medium", "high"]},
|
||||
},
|
||||
"generates": "soft minimize_distance for each pair",
|
||||
},
|
||||
"far_apart": {
|
||||
"description": "Devices should be placed far from each other",
|
||||
"params": {
|
||||
"devices": {"type": "list[string]", "required": True, "description": "Device IDs (min 2)"},
|
||||
"priority": {"type": "string", "required": False, "default": "medium", "enum": ["low", "medium", "high"]},
|
||||
},
|
||||
"generates": "soft maximize_distance for each pair",
|
||||
},
|
||||
"keep_adjacent": {
|
||||
"description": "Devices should stay adjacent, similar to close_together",
|
||||
"params": {
|
||||
"devices": {"type": "list[string]", "required": True, "description": "Device IDs (min 2)"},
|
||||
"priority": {"type": "string", "required": False, "default": "medium", "enum": ["low", "medium", "high"]},
|
||||
},
|
||||
"generates": "soft minimize_distance for each pair",
|
||||
},
|
||||
"max_distance": {
|
||||
"description": "Two devices must be within a maximum distance",
|
||||
"params": {
|
||||
"device_a": {"type": "string", "required": True},
|
||||
"device_b": {"type": "string", "required": True},
|
||||
"distance": {"type": "float", "required": True, "description": "Max edge-to-edge distance in meters"},
|
||||
},
|
||||
"generates": "hard distance_less_than",
|
||||
},
|
||||
"min_distance": {
|
||||
"description": "Two devices must be at least a minimum distance apart",
|
||||
"params": {
|
||||
"device_a": {"type": "string", "required": True},
|
||||
"device_b": {"type": "string", "required": True},
|
||||
"distance": {"type": "float", "required": True, "description": "Min edge-to-edge distance in meters"},
|
||||
},
|
||||
"generates": "hard distance_greater_than",
|
||||
},
|
||||
"min_spacing": {
|
||||
"description": "Minimum gap between all device pairs",
|
||||
"params": {
|
||||
"min_gap": {"type": "float", "required": False, "default": 0.3, "description": "Minimum gap in meters"},
|
||||
},
|
||||
"generates": "hard min_spacing",
|
||||
},
|
||||
"workflow_hint": {
|
||||
"description": "Workflow step order — consecutive devices should be near each other",
|
||||
"params": {
|
||||
"workflow": {"type": "string", "required": False, "description": "Workflow name (e.g. 'pcr')"},
|
||||
"devices": {"type": "list[string]", "required": True, "description": "Ordered device IDs following workflow steps"},
|
||||
},
|
||||
"generates": "soft minimize_distance for consecutive pairs + workflow_edges",
|
||||
},
|
||||
"face_outward": {
|
||||
"description": "Devices should face outward from lab center",
|
||||
"params": {},
|
||||
"generates": "soft prefer_orientation_mode outward",
|
||||
},
|
||||
"face_inward": {
|
||||
"description": "Devices should face inward toward lab center",
|
||||
"params": {},
|
||||
"generates": "soft prefer_orientation_mode inward",
|
||||
},
|
||||
"align_cardinal": {
|
||||
"description": "Devices should align to cardinal directions (0/90/180/270 degrees)",
|
||||
"params": {},
|
||||
"generates": "soft prefer_aligned",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------- 优化 API ----------
|
||||
|
||||
|
||||
class DeviceSpec(BaseModel):
|
||||
id: str
|
||||
name: str = ""
|
||||
size: list[float] | None = None
|
||||
device_type: str = "static"
|
||||
uuid: str = ""
|
||||
|
||||
|
||||
class ConstraintSpec(BaseModel):
|
||||
type: str # "hard" or "soft"
|
||||
rule_name: str
|
||||
params: dict = {}
|
||||
weight: float = 1.0
|
||||
|
||||
|
||||
class LabSpec(BaseModel):
|
||||
width: float
|
||||
depth: float
|
||||
obstacles: list[dict] = []
|
||||
|
||||
|
||||
class OptimizeRequest(BaseModel):
|
||||
devices: list[DeviceSpec]
|
||||
lab: LabSpec
|
||||
constraints: list[ConstraintSpec] = []
|
||||
seeder: str = "compact_outward"
|
||||
seeder_overrides: dict = {}
|
||||
run_de: bool = True
|
||||
workflow_edges: list[list[str]] = []
|
||||
maxiter: int = 200
|
||||
seed: int | None = None
|
||||
snap_cardinal: bool = False
|
||||
angle_granularity: int | None = None
|
||||
arm_reach: dict[str, float] = {}
|
||||
# DE 超参数
|
||||
strategy: str = "currenttobest1bin"
|
||||
angle_mode: str = "joint"
|
||||
mutation: list[float] = [0.5, 1.0]
|
||||
theta_mutation: list[float] | None = None
|
||||
recombination: float = 0.7
|
||||
crossover_mode: str = "device"
|
||||
|
||||
|
||||
class PositionXYZ(BaseModel):
|
||||
x: float
|
||||
y: float
|
||||
z: float
|
||||
|
||||
|
||||
class PlacementResult(BaseModel):
|
||||
device_id: str
|
||||
uuid: str
|
||||
position: PositionXYZ
|
||||
rotation: PositionXYZ
|
||||
|
||||
|
||||
class OptimizeResponse(BaseModel):
|
||||
placements: list[PlacementResult]
|
||||
cost: float
|
||||
success: bool
|
||||
seeder_used: str = ""
|
||||
de_ran: bool = True
|
||||
|
||||
|
||||
@app.post("/optimize", response_model=OptimizeResponse)
|
||||
async def run_optimize(request: OptimizeRequest):
|
||||
"""接收设备列表+约束,返回最优布局方案。"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from .constraints import evaluate_default_hard_constraints, evaluate_constraints
|
||||
from .mock_checkers import MockCollisionChecker, MockReachabilityChecker
|
||||
from .optimizer import optimize, snap_theta, snap_theta_safe
|
||||
from .seeders import resolve_seeder_params, seed_layout
|
||||
|
||||
logger.info(
|
||||
"Optimize request: %d devices, lab %.1f×%.1f, %d constraints, seeder=%s, run_de=%s, angle_granularity=%s",
|
||||
len(request.devices),
|
||||
request.lab.width,
|
||||
request.lab.depth,
|
||||
len(request.constraints),
|
||||
request.seeder,
|
||||
request.run_de,
|
||||
request.angle_granularity,
|
||||
)
|
||||
|
||||
if request.angle_granularity not in (None, 4, 8, 12, 24):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="angle_granularity must be one of: 4, 8, 12, 24",
|
||||
)
|
||||
|
||||
# 转换输入
|
||||
devices = create_devices_from_list(
|
||||
[d.model_dump() for d in request.devices]
|
||||
)
|
||||
id_to_catalog = {dev.id: _catalog_id_from_internal(dev.id) for dev in devices}
|
||||
id_to_uuid = {dev.id: (dev.uuid or dev.id) for dev in devices}
|
||||
lab = parse_lab(request.lab.model_dump())
|
||||
constraints = [
|
||||
Constraint(
|
||||
type=c.type,
|
||||
rule_name=c.rule_name,
|
||||
params=c.params,
|
||||
weight=c.weight,
|
||||
)
|
||||
for c in request.constraints
|
||||
]
|
||||
constraints = _expand_constraints_for_duplicates(constraints, devices)
|
||||
|
||||
# 1. Resolve seeder
|
||||
try:
|
||||
params = resolve_seeder_params(request.seeder, request.seeder_overrides or None)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
# 2. Seed
|
||||
seed_placements = seed_layout(
|
||||
devices, lab, params,
|
||||
request.workflow_edges or None,
|
||||
)
|
||||
|
||||
# 3. Auto-inject alignment soft constraint (opt-in via seeder_overrides)
|
||||
if request.run_de and seed_placements:
|
||||
# prefer_aligned: penalize non-cardinal angles(默认关闭,用户可通过 align_cardinal intent 或 seeder_overrides 开启)
|
||||
constraints = _maybe_add_prefer_aligned_constraint(
|
||||
constraints,
|
||||
request.seeder_overrides.get("align_weight", 0),
|
||||
)
|
||||
|
||||
# 4. Validate DE hyperparameters
|
||||
if request.strategy not in {"currenttobest1bin", "best1bin", "rand1bin"}:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"strategy must be one of: currenttobest1bin, best1bin, rand1bin (got {request.strategy!r})",
|
||||
)
|
||||
if request.angle_mode not in {"joint", "hybrid"}:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"angle_mode must be one of: joint, hybrid (got {request.angle_mode!r})",
|
||||
)
|
||||
if request.crossover_mode not in {"device", "dimension"}:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"crossover_mode must be one of: device, dimension (got {request.crossover_mode!r})",
|
||||
)
|
||||
if len(request.mutation) != 2 or request.mutation[0] > request.mutation[1]:
|
||||
raise HTTPException(status_code=400, detail="mutation must be [F_min, F_max] with F_min <= F_max")
|
||||
if request.mutation[0] < 0 or request.mutation[1] > 2.0:
|
||||
raise HTTPException(status_code=400, detail="mutation values must be in [0, 2.0]")
|
||||
if request.theta_mutation is not None:
|
||||
if len(request.theta_mutation) != 2 or request.theta_mutation[0] > request.theta_mutation[1]:
|
||||
raise HTTPException(status_code=400, detail="theta_mutation must be [F_min, F_max] with F_min <= F_max")
|
||||
if request.theta_mutation[0] < 0 or request.theta_mutation[1] > 2.0:
|
||||
raise HTTPException(status_code=400, detail="theta_mutation values must be in [0, 2.0]")
|
||||
if not (0 <= request.recombination <= 1.0):
|
||||
raise HTTPException(status_code=400, detail="recombination must be in [0, 1.0]")
|
||||
|
||||
# 5. Conditional Differential Evolution
|
||||
de_ran = False
|
||||
checker = MockCollisionChecker()
|
||||
reachability_checker = MockReachabilityChecker(request.arm_reach or None)
|
||||
if request.run_de:
|
||||
result_placements = optimize(
|
||||
devices=devices,
|
||||
lab=lab,
|
||||
constraints=constraints,
|
||||
collision_checker=checker,
|
||||
reachability_checker=reachability_checker,
|
||||
seed_placements=seed_placements,
|
||||
maxiter=request.maxiter,
|
||||
seed=request.seed,
|
||||
strategy=request.strategy,
|
||||
workflow_edges=request.workflow_edges or None,
|
||||
angle_granularity=request.angle_granularity,
|
||||
angle_mode=request.angle_mode,
|
||||
mutation=tuple(request.mutation),
|
||||
theta_mutation=tuple(request.theta_mutation) if request.theta_mutation else None,
|
||||
recombination=request.recombination,
|
||||
crossover_mode=request.crossover_mode,
|
||||
)
|
||||
de_ran = True
|
||||
else:
|
||||
result_placements = seed_placements
|
||||
|
||||
# 5. θ snap post-processing(opt-in,默认关闭)
|
||||
if request.snap_cardinal and request.angle_granularity is None:
|
||||
result_placements = snap_theta_safe(result_placements, devices, lab, checker)
|
||||
elif request.snap_cardinal and request.angle_granularity is not None:
|
||||
logger.info(
|
||||
"snap_cardinal ignored because angle_granularity=%s already constrains theta",
|
||||
request.angle_granularity,
|
||||
)
|
||||
|
||||
# 6. Evaluate final cost (binary mode for pass/fail reporting)
|
||||
final_cost = evaluate_default_hard_constraints(
|
||||
devices, result_placements, lab, checker, graduated=False,
|
||||
)
|
||||
# 也检查用户硬约束(binary 模式)
|
||||
if constraints and not math.isinf(final_cost):
|
||||
user_hard_cost = evaluate_constraints(
|
||||
devices, result_placements, lab, constraints, checker, reachability_checker,
|
||||
graduated=False,
|
||||
)
|
||||
if math.isinf(user_hard_cost):
|
||||
final_cost = math.inf
|
||||
|
||||
return OptimizeResponse(
|
||||
placements=[
|
||||
PlacementResult(
|
||||
device_id=id_to_catalog.get(p.device_id, p.device_id),
|
||||
uuid=id_to_uuid.get(p.device_id, p.device_id),
|
||||
position=PositionXYZ(x=round(p.x, 4), y=round(p.y, 4), z=0.0),
|
||||
rotation=PositionXYZ(x=0.0, y=0.0, z=round(p.theta, 4)),
|
||||
)
|
||||
for p in result_placements
|
||||
],
|
||||
cost=final_cost,
|
||||
success=not math.isinf(final_cost),
|
||||
seeder_used=request.seeder,
|
||||
de_ran=de_ran,
|
||||
)
|
||||
|
||||
|
||||
# ---------- 场景状态 API(演示用) ----------
|
||||
|
||||
|
||||
_scene_state: dict = {"version": 0, "placements": []}
|
||||
_lab_state: dict = {"width": 4.0, "depth": 4.0}
|
||||
|
||||
|
||||
class LabDimensions(BaseModel):
|
||||
width: float
|
||||
depth: float
|
||||
|
||||
|
||||
@app.get("/scene/lab")
|
||||
async def get_lab_dimensions():
|
||||
"""返回当前实验室尺寸(前端推送,agent 读取)。"""
|
||||
return _lab_state
|
||||
|
||||
|
||||
@app.post("/scene/lab")
|
||||
async def set_lab_dimensions(dims: LabDimensions):
|
||||
"""前端在加载和尺寸变更时推送。"""
|
||||
_lab_state["width"] = dims.width
|
||||
_lab_state["depth"] = dims.depth
|
||||
return _lab_state
|
||||
|
||||
|
||||
class ScenePlacementsRequest(BaseModel):
|
||||
placements: list[PlacementResult]
|
||||
|
||||
|
||||
@app.post("/scene/placements")
|
||||
async def set_scene_placements(request: ScenePlacementsRequest):
|
||||
"""Agent 写入布局结果,前端轮询读取。"""
|
||||
_scene_state["version"] += 1
|
||||
_scene_state["placements"] = [p.model_dump() for p in request.placements]
|
||||
logger.info(
|
||||
"Scene placements updated: version=%d, count=%d",
|
||||
_scene_state["version"],
|
||||
len(request.placements),
|
||||
)
|
||||
return {"version": _scene_state["version"], "count": len(request.placements)}
|
||||
|
||||
|
||||
@app.get("/scene/placements")
|
||||
async def get_scene_placements():
|
||||
"""前端轮询此端点,检测 version 变化后应用布局。"""
|
||||
return _scene_state
|
||||
|
||||
|
||||
@app.delete("/scene/placements")
|
||||
async def clear_scene_placements():
|
||||
"""重置场景状态(重录时使用)。"""
|
||||
_scene_state["version"] = 0
|
||||
_scene_state["placements"] = []
|
||||
return {"version": 0, "placements": []}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +0,0 @@
|
||||
[
|
||||
{"id": "arm_1", "name": "Elite CS66 Arm", "size": [0.20, 0.20], "device_type": "articulation"},
|
||||
{"id": "liquid_handler", "name": "Agilent Bravo", "size": [0.80, 0.65], "device_type": "static"},
|
||||
{"id": "centrifuge", "name": "Centrifuge", "size": [0.50, 0.50], "device_type": "static"},
|
||||
{"id": "plate_hotel", "name": "Thermo Orbitor RS2", "size": [0.45, 0.55], "device_type": "static"},
|
||||
{"id": "hplc", "name": "HPLC Station", "size": [0.60, 0.50], "device_type": "static"}
|
||||
]
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"width": 5.0,
|
||||
"depth": 4.0,
|
||||
"obstacles": [
|
||||
{"x": 2.5, "y": 0.0, "width": 0.1, "depth": 0.5}
|
||||
]
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
"""Tests for broad_phase.py — 2 轴 sweep-and-prune 宽相碰撞检测。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from ..broad_phase import broad_phase_device_pairs, sweep_and_prune_pairs
|
||||
from ..models import Device, Placement
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试用辅助函数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_device(device_id: str, w: float = 0.6, d: float = 0.4) -> Device:
|
||||
"""创建简单测试设备。"""
|
||||
return Device(id=device_id, name=device_id, bbox=(w, d))
|
||||
|
||||
|
||||
def _make_placement(
|
||||
device_id: str, x: float, y: float, theta: float = 0.0
|
||||
) -> Placement:
|
||||
"""创建简单测试放置。"""
|
||||
return Placement(device_id=device_id, x=x, y=y, theta=theta)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试类
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNoOverlap:
|
||||
"""两台设备距离足够远,宽相不返回候选对。"""
|
||||
|
||||
def test_no_overlap_returns_empty(self):
|
||||
"""水平方向间距远大于 AABB 尺寸 → 0 候选对。"""
|
||||
devices = [_make_device("A", 1.0, 1.0), _make_device("B", 1.0, 1.0)]
|
||||
placements = [
|
||||
_make_placement("A", 0.0, 0.0),
|
||||
_make_placement("B", 10.0, 0.0),
|
||||
]
|
||||
pairs = sweep_and_prune_pairs(devices, placements)
|
||||
assert pairs == []
|
||||
|
||||
|
||||
class TestOverlapping:
|
||||
"""两台设备 AABB 明显重叠。"""
|
||||
|
||||
def test_overlapping_devices_returned(self):
|
||||
"""两台 1×1 设备中心距 0.5m → 1 候选对。"""
|
||||
devices = [_make_device("A", 1.0, 1.0), _make_device("B", 1.0, 1.0)]
|
||||
placements = [
|
||||
_make_placement("A", 0.0, 0.0),
|
||||
_make_placement("B", 0.5, 0.0),
|
||||
]
|
||||
pairs = sweep_and_prune_pairs(devices, placements)
|
||||
assert len(pairs) == 1
|
||||
assert pairs[0] == (0, 1)
|
||||
|
||||
|
||||
class TestXOverlapYNoOverlap:
|
||||
"""x 轴投影交叠但 y 轴不交叠,应被 y 轴检查剪枝。"""
|
||||
|
||||
def test_x_overlap_y_no_overlap(self):
|
||||
"""水平接近但垂直方向偏移足够大 → 0 候选对。"""
|
||||
devices = [_make_device("A", 2.0, 1.0), _make_device("B", 2.0, 1.0)]
|
||||
placements = [
|
||||
_make_placement("A", 0.0, 0.0),
|
||||
_make_placement("B", 0.5, 5.0), # x 轴交叠但 y 轴相距很远
|
||||
]
|
||||
pairs = sweep_and_prune_pairs(devices, placements)
|
||||
assert pairs == []
|
||||
|
||||
|
||||
class TestTouchingDevices:
|
||||
"""AABB 恰好接触(边缘距离 = 0)应作为候选对返回。"""
|
||||
|
||||
def test_touching_devices_included(self):
|
||||
"""两个 1×1 设备中心距恰好为 1.0(半宽 0.5 + 0.5 = 1.0),
|
||||
AABB 边界接触 → 应包含在候选对中(<= 判定)。"""
|
||||
devices = [_make_device("A", 1.0, 1.0), _make_device("B", 1.0, 1.0)]
|
||||
placements = [
|
||||
_make_placement("A", 0.0, 0.0),
|
||||
_make_placement("B", 1.0, 0.0), # xmax_A = 0.5, xmin_B = 0.5 → 接触
|
||||
]
|
||||
pairs = sweep_and_prune_pairs(devices, placements)
|
||||
# 接触算作潜在碰撞,安全起见需保留
|
||||
assert len(pairs) == 1
|
||||
assert pairs[0] == (0, 1)
|
||||
|
||||
|
||||
class TestMultipleDevices:
|
||||
"""4 台设备验证精确的候选对列表。"""
|
||||
|
||||
def test_multiple_devices_correct_pairs(self):
|
||||
"""排列 4 台设备,只有特定配对 AABB 交叠。
|
||||
|
||||
布局(1×1 设备):
|
||||
A(0,0) B(0.8,0) — A-B 交叠(中心距 0.8 < 1.0)
|
||||
C(0,5) — 远离 A、B
|
||||
D(0.9,5) — C-D 交叠(中心距 0.9 < 1.0)
|
||||
|
||||
期望候选对: (A,B) 和 (C,D)。
|
||||
"""
|
||||
devices = [
|
||||
_make_device("A", 1.0, 1.0),
|
||||
_make_device("B", 1.0, 1.0),
|
||||
_make_device("C", 1.0, 1.0),
|
||||
_make_device("D", 1.0, 1.0),
|
||||
]
|
||||
placements = [
|
||||
_make_placement("A", 0.0, 0.0),
|
||||
_make_placement("B", 0.8, 0.0),
|
||||
_make_placement("C", 0.0, 5.0),
|
||||
_make_placement("D", 0.9, 5.0),
|
||||
]
|
||||
pairs = sweep_and_prune_pairs(devices, placements)
|
||||
pair_set = set(pairs)
|
||||
assert (0, 1) in pair_set # A-B
|
||||
assert (2, 3) in pair_set # C-D
|
||||
assert len(pair_set) == 2
|
||||
|
||||
|
||||
class TestRotatedDeviceAabb:
|
||||
"""旋转设备导致 AABB 变大,命中候选对。"""
|
||||
|
||||
def test_rotated_device_aabb(self):
|
||||
"""一台窄长设备 (2.0×0.2):
|
||||
- 未旋转时 AABB 半宽 = 1.0,两设备中心距 2.5 → 不交叠
|
||||
- 旋转 90° 后 AABB 半宽 = 0.1,半深 = 1.0 → 仍不交叠
|
||||
- 旋转 45° 后 AABB 半宽 ≈ (2*cos45 + 0.2*sin45)/2 ≈ 0.778
|
||||
但另一台放在 x=1.6,半宽 = 1.0
|
||||
所以 xmax_A = 0 + 0.778 = 0.778 < 1.6 - 1.0 = 0.6 → 不够
|
||||
|
||||
更好的方案:用中心距 1.5,未旋转时不交叠,旋转后交叠。
|
||||
未旋转: half_w_A = 0.3 (bbox 0.6x2.0), half_w_B = 0.3
|
||||
A: xmax = 0 + 0.3 = 0.3, B: xmin = 1.5 - 0.3 = 1.2 → 不交叠
|
||||
旋转 90°: A 的 bbox (0.6, 2.0) → half_w = (0.6*0 + 2.0*1)/2 = 1.0
|
||||
A: xmax = 0 + 1.0 = 1.0, B: xmin = 1.5 - 0.3 = 1.2 → 仍不交叠
|
||||
|
||||
用 bbox (0.4, 2.0),间距 1.2:
|
||||
未旋转: half_w = 0.2, xmax_A = 0.2, xmin_B = 1.2 - 0.2 = 1.0 → 不交叠
|
||||
旋转 45°: half_w = (0.4*cos45 + 2.0*sin45)/2 = (0.283+1.414)/2 = 0.849
|
||||
xmax_A = 0.849, xmin_B = 1.2 - 0.2 = 1.0 → 不交叠
|
||||
|
||||
间距 0.8:
|
||||
未旋转: xmax_A = 0.2, xmin_B = 0.8 - 0.2 = 0.6 → 不交叠 ✓
|
||||
旋转 45°: xmax_A = 0.849, xmin_B = 0.6 → 交叠 ✓ (0.849 > 0.6)
|
||||
y 轴: half_d_A_rot = (0.4*sin45 + 2.0*cos45)/2 = 0.849, half_d_B = 1.0
|
||||
ymax_A = 0.849, ymin_B = -1.0 → 交叠 ✓
|
||||
"""
|
||||
dev_narrow = _make_device("narrow", 0.4, 2.0)
|
||||
dev_normal = _make_device("normal", 0.4, 2.0)
|
||||
# 未旋转:不交叠
|
||||
placements_no_rot = [
|
||||
_make_placement("narrow", 0.0, 0.0, theta=0.0),
|
||||
_make_placement("normal", 0.8, 0.0, theta=0.0),
|
||||
]
|
||||
assert sweep_and_prune_pairs([dev_narrow, dev_normal], placements_no_rot) == []
|
||||
|
||||
# narrow 旋转 45° → AABB 变大 → 交叠
|
||||
placements_rot = [
|
||||
_make_placement("narrow", 0.0, 0.0, theta=math.pi / 4),
|
||||
_make_placement("normal", 0.8, 0.0, theta=0.0),
|
||||
]
|
||||
pairs = sweep_and_prune_pairs([dev_narrow, dev_normal], placements_rot)
|
||||
assert len(pairs) == 1
|
||||
|
||||
|
||||
class TestOriginalIndices:
|
||||
"""验证返回的索引对应 placements 原始顺序而非排序后顺序。"""
|
||||
|
||||
def test_sorted_output_preserves_original_indices(self):
|
||||
"""故意让 placements 按 x 坐标逆序排列,
|
||||
验证返回的索引仍是原始顺序。"""
|
||||
devices = [
|
||||
_make_device("A", 1.0, 1.0),
|
||||
_make_device("B", 1.0, 1.0),
|
||||
_make_device("C", 1.0, 1.0),
|
||||
]
|
||||
# 逆序排列:C 在最左,A 在最右
|
||||
placements = [
|
||||
_make_placement("A", 5.0, 0.0), # idx 0, 最右
|
||||
_make_placement("B", 4.5, 0.0), # idx 1, 中间(与 A 交叠)
|
||||
_make_placement("C", 0.0, 0.0), # idx 2, 最左(独立)
|
||||
]
|
||||
pairs = sweep_and_prune_pairs(devices, placements)
|
||||
# A(idx=0) 和 B(idx=1) AABB 交叠,索引应为 (0, 1)
|
||||
assert len(pairs) == 1
|
||||
assert pairs[0] == (0, 1)
|
||||
|
||||
# 同时验证 broad_phase_device_pairs 返回正确 device_id
|
||||
id_pairs = broad_phase_device_pairs(devices, placements)
|
||||
assert id_pairs == [("A", "B")]
|
||||
|
||||
|
||||
class TestPairCountReduction:
|
||||
"""大规模随机测试:宽相候选对数应远小于 N*(N-1)/2。"""
|
||||
|
||||
def test_pair_count_reduction(self):
|
||||
"""N=15 台设备随机放置在 10×10 实验室 → 候选对数显著少于全量。"""
|
||||
random.seed(42)
|
||||
n = 15
|
||||
devices = [_make_device(f"D{i}", 0.5, 0.5) for i in range(n)]
|
||||
placements = [
|
||||
_make_placement(f"D{i}", random.uniform(0, 10), random.uniform(0, 10))
|
||||
for i in range(n)
|
||||
]
|
||||
pairs = sweep_and_prune_pairs(devices, placements)
|
||||
full_pairs = n * (n - 1) // 2 # = 105
|
||||
# 在 10×10 区域放 15 台 0.5×0.5 设备,交叠率应很低
|
||||
assert len(pairs) < full_pairs
|
||||
# 额外断言:候选对数不超过全量的一半(保守判定)
|
||||
assert len(pairs) < full_pairs * 0.5
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""边界情况。"""
|
||||
|
||||
def test_empty_input(self):
|
||||
"""空列表 → 空结果。"""
|
||||
assert sweep_and_prune_pairs([], []) == []
|
||||
|
||||
def test_single_device(self):
|
||||
"""单台设备 → 无候选对。"""
|
||||
devices = [_make_device("A")]
|
||||
placements = [_make_placement("A", 0.0, 0.0)]
|
||||
assert sweep_and_prune_pairs(devices, placements) == []
|
||||
|
||||
def test_identical_positions(self):
|
||||
"""两台设备完全重叠 → 1 候选对。"""
|
||||
devices = [_make_device("A", 1.0, 1.0), _make_device("B", 1.0, 1.0)]
|
||||
placements = [
|
||||
_make_placement("A", 0.0, 0.0),
|
||||
_make_placement("B", 0.0, 0.0),
|
||||
]
|
||||
pairs = sweep_and_prune_pairs(devices, placements)
|
||||
assert len(pairs) == 1
|
||||
@@ -1,435 +0,0 @@
|
||||
"""Regression tests for V2 Stage 1 bugfixes.
|
||||
|
||||
Covers:
|
||||
- Duplicate device ID stacking (catalog ID + #N internal IDs)
|
||||
- DE orientation preservation (prefer_orientation_mode constraint)
|
||||
- prefer_aligned auto-injection and adjustability
|
||||
- Preset switch reorientation
|
||||
- min_spacing with duplicate catalog IDs
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from ..constraints import evaluate_constraints
|
||||
from ..mock_checkers import MockCollisionChecker
|
||||
from ..models import Constraint, Device, Lab, Opening, Placement
|
||||
from ..obb import obb_corners, obb_overlap
|
||||
from ..optimizer import (
|
||||
_placements_to_vector,
|
||||
_vector_to_placements,
|
||||
optimize,
|
||||
snap_theta,
|
||||
)
|
||||
from ..seeders import resolve_seeder_params, seed_layout
|
||||
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────
|
||||
|
||||
def _ot(uid: str) -> Device:
|
||||
return Device(
|
||||
id=uid, name="Opentrons Liquid Handler",
|
||||
bbox=(0.6243, 0.5672), openings=[Opening(direction=(0.0, -1.0))],
|
||||
)
|
||||
|
||||
def _tecan(uid: str) -> Device:
|
||||
return Device(
|
||||
id=uid, name="Tecan EVO 100",
|
||||
bbox=(0.8121, 0.8574), openings=[Opening(direction=(0.0, -1.0))],
|
||||
)
|
||||
|
||||
def _facing_dot(p: Placement, device: Device, lab: Lab) -> float:
|
||||
"""Dot product of rotated front vector with vector from center to device.
|
||||
Positive = outward, negative = inward."""
|
||||
cx, cy = lab.width / 2, lab.depth / 2
|
||||
dx, dy = p.x - cx, p.y - cy
|
||||
front = device.openings[0].direction if device.openings else (0.0, -1.0)
|
||||
rf_x = math.cos(p.theta) * front[0] - math.sin(p.theta) * front[1]
|
||||
rf_y = math.sin(p.theta) * front[0] + math.cos(p.theta) * front[1]
|
||||
return rf_x * dx + rf_y * dy
|
||||
|
||||
def _has_collision(devices, placements):
|
||||
for i in range(len(devices)):
|
||||
for j in range(i + 1, len(devices)):
|
||||
ci = obb_corners(placements[i].x, placements[i].y,
|
||||
devices[i].bbox[0], devices[i].bbox[1], placements[i].theta)
|
||||
cj = obb_corners(placements[j].x, placements[j].y,
|
||||
devices[j].bbox[0], devices[j].bbox[1], placements[j].theta)
|
||||
if obb_overlap(ci, cj):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ── Bug 1: Duplicate device ID stacking ────────────────
|
||||
|
||||
class TestDuplicateDeviceIDs:
|
||||
"""When two instances of the same catalog device are placed,
|
||||
unique uuid-based IDs must prevent dict-key collisions."""
|
||||
|
||||
def test_vector_roundtrip_preserves_unique_positions(self):
|
||||
"""_placements_to_vector → _vector_to_placements with unique IDs."""
|
||||
devices = [_ot("uuid-a"), _ot("uuid-b")]
|
||||
placements = [
|
||||
Placement(device_id="uuid-a", x=0.5, y=0.5, theta=0.0),
|
||||
Placement(device_id="uuid-b", x=1.5, y=1.5, theta=1.0),
|
||||
]
|
||||
vec = _placements_to_vector(placements, devices)
|
||||
decoded = _vector_to_placements(vec, devices)
|
||||
assert decoded[0].x == pytest.approx(0.5)
|
||||
assert decoded[1].x == pytest.approx(1.5)
|
||||
|
||||
def test_min_spacing_detects_stacked_unique_ids(self):
|
||||
"""min_spacing should detect two devices at the same position
|
||||
when they have unique IDs."""
|
||||
devices = [_ot("uuid-a"), _ot("uuid-b")]
|
||||
stacked = [
|
||||
Placement(device_id="uuid-a", x=1.0, y=1.0, theta=0.0),
|
||||
Placement(device_id="uuid-b", x=1.0, y=1.0, theta=0.0),
|
||||
]
|
||||
lab = Lab(width=5, depth=5)
|
||||
constraints = [Constraint(type="hard", rule_name="min_spacing",
|
||||
params={"min_gap": 0.05})]
|
||||
# graduated=True (default): 返回有限惩罚
|
||||
cost = evaluate_constraints(devices, stacked, lab, constraints,
|
||||
MockCollisionChecker())
|
||||
assert cost > 0
|
||||
assert not math.isinf(cost)
|
||||
# graduated=False: binary inf
|
||||
cost_binary = evaluate_constraints(devices, stacked, lab, constraints,
|
||||
MockCollisionChecker(),
|
||||
graduated=False)
|
||||
assert math.isinf(cost_binary)
|
||||
|
||||
def test_create_devices_uses_catalog_id_with_suffixes(self):
|
||||
"""create_devices_from_list should keep catalog IDs and suffix duplicates."""
|
||||
from ..device_catalog import create_devices_from_list
|
||||
specs = [
|
||||
{"id": "opentrons_liquid_handler", "uuid": "abc-123"},
|
||||
{"id": "opentrons_liquid_handler", "uuid": "def-456"},
|
||||
]
|
||||
devices = create_devices_from_list(specs)
|
||||
assert devices[0].id == "opentrons_liquid_handler"
|
||||
assert devices[1].id == "opentrons_liquid_handler#2"
|
||||
assert devices[0].uuid == "abc-123"
|
||||
assert devices[1].uuid == "def-456"
|
||||
# Both should have the same bbox from footprints
|
||||
assert devices[0].bbox == devices[1].bbox
|
||||
|
||||
def test_create_devices_fallback_no_uuid(self):
|
||||
"""Without uuid, Device.id falls back to catalog id."""
|
||||
from ..device_catalog import create_devices_from_list
|
||||
specs = [{"id": "opentrons_liquid_handler"}]
|
||||
devices = create_devices_from_list(specs)
|
||||
assert devices[0].id == "opentrons_liquid_handler"
|
||||
|
||||
|
||||
# ── Bug 2 & 4: DE orientation preservation ─────────────
|
||||
|
||||
class TestOrientationWithDE:
|
||||
"""DE must preserve seeder orientation direction (outward/inward)
|
||||
via the prefer_orientation_mode constraint."""
|
||||
|
||||
def _run_de_with_orientation(self, mode, seed_val=42):
|
||||
devices = [_ot("ot1"), _ot("ot2"), _tecan("tecan")]
|
||||
lab = Lab(width=2.0, depth=2.0)
|
||||
params = resolve_seeder_params(
|
||||
"compact_outward" if mode == "outward" else "spread_inward"
|
||||
)
|
||||
seed = seed_layout(devices, lab, params)
|
||||
constraints = [
|
||||
Constraint(type="hard", rule_name="min_spacing",
|
||||
params={"min_gap": 0.05}),
|
||||
Constraint(type="soft", rule_name="prefer_orientation_mode",
|
||||
params={"mode": mode}, weight=5.0),
|
||||
Constraint(type="soft", rule_name="prefer_aligned", weight=2.0),
|
||||
]
|
||||
result = optimize(devices, lab, constraints, seed_placements=seed,
|
||||
maxiter=200, seed=seed_val)
|
||||
result = snap_theta(result)
|
||||
return devices, lab, result
|
||||
|
||||
def test_compact_outward_de_faces_outward(self):
|
||||
devices, lab, result = self._run_de_with_orientation("outward")
|
||||
for i, p in enumerate(result):
|
||||
dot = _facing_dot(p, devices[i], lab)
|
||||
assert dot > 0, (
|
||||
f"{p.device_id} faces inward (dot={dot:.3f}) "
|
||||
f"at ({p.x:.2f},{p.y:.2f}) theta={math.degrees(p.theta):.0f}°"
|
||||
)
|
||||
|
||||
def test_spread_inward_de_faces_inward(self):
|
||||
devices, lab, result = self._run_de_with_orientation("inward")
|
||||
for i, p in enumerate(result):
|
||||
dot = _facing_dot(p, devices[i], lab)
|
||||
assert dot < 0, (
|
||||
f"{p.device_id} faces outward (dot={dot:.3f}) "
|
||||
f"at ({p.x:.2f},{p.y:.2f}) theta={math.degrees(p.theta):.0f}°"
|
||||
)
|
||||
|
||||
def test_switching_preset_changes_orientation(self):
|
||||
"""Switching from outward to inward should produce opposite facing."""
|
||||
_, lab, out_result = self._run_de_with_orientation("outward")
|
||||
devices_in, _, in_result = self._run_de_with_orientation("inward")
|
||||
# At least one device should have different facing
|
||||
out_dots = [_facing_dot(p, devices_in[i], lab) for i, p in enumerate(out_result)]
|
||||
in_dots = [_facing_dot(p, devices_in[i], lab) for i, p in enumerate(in_result)]
|
||||
# Outward: all positive; inward: all negative
|
||||
assert all(d > 0 for d in out_dots), f"outward dots: {out_dots}"
|
||||
assert all(d < 0 for d in in_dots), f"inward dots: {in_dots}"
|
||||
|
||||
def test_no_collision_after_de(self):
|
||||
devices, lab, result = self._run_de_with_orientation("outward")
|
||||
assert not _has_collision(devices, result)
|
||||
|
||||
|
||||
# ── Bug 3: prefer_aligned & prefer_orientation_mode ────
|
||||
|
||||
class TestOrientationConstraints:
|
||||
"""Test the new constraint rules directly."""
|
||||
|
||||
def test_prefer_orientation_mode_outward_zero_at_correct(self):
|
||||
"""Zero cost when device faces outward from center."""
|
||||
device = _ot("a")
|
||||
# Device to the right of center, front pointing right
|
||||
# front=(0,-1), theta=pi/2 → rotated front = (1, 0) = rightward
|
||||
lab = Lab(width=4, depth=4)
|
||||
placements = [Placement("a", 3.0, 2.0, math.pi / 2)]
|
||||
constraint = Constraint(
|
||||
type="soft", rule_name="prefer_orientation_mode",
|
||||
params={"mode": "outward"}, weight=1.0,
|
||||
)
|
||||
cost = evaluate_constraints(
|
||||
[device], placements, lab, [constraint], MockCollisionChecker(),
|
||||
)
|
||||
assert cost == pytest.approx(0.0, abs=0.01)
|
||||
|
||||
def test_prefer_orientation_mode_outward_penalty_at_inward(self):
|
||||
"""High cost when device faces inward (opposite of outward)."""
|
||||
device = _ot("a")
|
||||
# Device to the right of center, front pointing left (inward)
|
||||
# front=(0,-1), theta=3*pi/2 → rotated front = (-1, 0) = leftward
|
||||
lab = Lab(width=4, depth=4)
|
||||
placements = [Placement("a", 3.0, 2.0, 3 * math.pi / 2)]
|
||||
constraint = Constraint(
|
||||
type="soft", rule_name="prefer_orientation_mode",
|
||||
params={"mode": "outward"}, weight=1.0,
|
||||
)
|
||||
cost = evaluate_constraints(
|
||||
[device], placements, lab, [constraint], MockCollisionChecker(),
|
||||
)
|
||||
# 180° off → (1 - cos(pi)) / 2 = 1.0
|
||||
assert cost == pytest.approx(1.0, abs=0.05)
|
||||
|
||||
def test_prefer_orientation_mode_inward(self):
|
||||
"""Zero cost when device faces inward."""
|
||||
device = _ot("a")
|
||||
# Device to the right of center, front pointing left (inward)
|
||||
lab = Lab(width=4, depth=4)
|
||||
placements = [Placement("a", 3.0, 2.0, 3 * math.pi / 2)]
|
||||
constraint = Constraint(
|
||||
type="soft", rule_name="prefer_orientation_mode",
|
||||
params={"mode": "inward"}, weight=1.0,
|
||||
)
|
||||
cost = evaluate_constraints(
|
||||
[device], placements, lab, [constraint], MockCollisionChecker(),
|
||||
)
|
||||
assert cost == pytest.approx(0.0, abs=0.01)
|
||||
|
||||
def test_prefer_seeder_orientation_zero_at_target(self):
|
||||
"""Zero cost when theta matches target."""
|
||||
device = Device(id="a", name="A", bbox=(0.5, 0.5))
|
||||
lab = Lab(width=4, depth=4)
|
||||
placements = [Placement("a", 2, 2, 1.5)]
|
||||
constraint = Constraint(
|
||||
type="soft", rule_name="prefer_seeder_orientation",
|
||||
params={"target_thetas": {"a": 1.5}}, weight=1.0,
|
||||
)
|
||||
cost = evaluate_constraints(
|
||||
[device], placements, lab, [constraint], MockCollisionChecker(),
|
||||
)
|
||||
assert cost == pytest.approx(0.0, abs=1e-9)
|
||||
|
||||
def test_prefer_seeder_orientation_penalty_at_deviation(self):
|
||||
"""Non-zero cost when theta deviates from target."""
|
||||
device = Device(id="a", name="A", bbox=(0.5, 0.5))
|
||||
lab = Lab(width=4, depth=4)
|
||||
placements = [Placement("a", 2, 2, math.pi)] # pi away from 0
|
||||
constraint = Constraint(
|
||||
type="soft", rule_name="prefer_seeder_orientation",
|
||||
params={"target_thetas": {"a": 0.0}}, weight=1.0,
|
||||
)
|
||||
cost = evaluate_constraints(
|
||||
[device], placements, lab, [constraint], MockCollisionChecker(),
|
||||
)
|
||||
# (1 - cos(pi)) / 2 = 1.0
|
||||
assert cost == pytest.approx(1.0)
|
||||
|
||||
|
||||
# ── API endpoint regression ────────────────────────────
|
||||
|
||||
class TestEndpointOrientation:
|
||||
"""Test that /optimize injects orientation constraints."""
|
||||
|
||||
def test_endpoint_with_de_injects_orientation(self):
|
||||
from fastapi.testclient import TestClient
|
||||
from ..server import app
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.post("/optimize", json={
|
||||
"devices": [
|
||||
{"id": "opentrons_liquid_handler", "uuid": "u1"},
|
||||
{"id": "opentrons_liquid_handler", "uuid": "u2"},
|
||||
],
|
||||
"lab": {"width": 3, "depth": 3},
|
||||
"seeder": "compact_outward",
|
||||
"run_de": True,
|
||||
"maxiter": 50,
|
||||
"seed": 42,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# Both devices should have unique uuids in response
|
||||
uuids = [p["uuid"] for p in data["placements"]]
|
||||
assert len(set(uuids)) == 2, f"Expected 2 unique uuids, got {uuids}"
|
||||
|
||||
def test_endpoint_orientation_weight_override(self):
|
||||
from fastapi.testclient import TestClient
|
||||
from ..server import app
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.post("/optimize", json={
|
||||
"devices": [{"id": "opentrons_liquid_handler", "uuid": "u1"}],
|
||||
"lab": {"width": 3, "depth": 3},
|
||||
"seeder": "compact_outward",
|
||||
"seeder_overrides": {"orientation_weight": 10, "align_weight": 0},
|
||||
"run_de": True,
|
||||
"maxiter": 50,
|
||||
"seed": 42,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_endpoint_align_weight_zero_disables(self):
|
||||
"""Setting align_weight=0 should not inject prefer_aligned."""
|
||||
from fastapi.testclient import TestClient
|
||||
from ..server import app
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.post("/optimize", json={
|
||||
"devices": [{"id": "opentrons_liquid_handler", "uuid": "u1"}],
|
||||
"lab": {"width": 3, "depth": 3},
|
||||
"seeder": "compact_outward",
|
||||
"seeder_overrides": {"align_weight": 0},
|
||||
"run_de": True,
|
||||
"maxiter": 50,
|
||||
"seed": 42,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ── Broader scenario tests ─────────────────────────────
|
||||
|
||||
class TestScenarios:
|
||||
"""End-to-end scenarios similar to user's real usage."""
|
||||
|
||||
def test_user_scenario_2ot_1tecan_compact_outward(self):
|
||||
"""User's exact scenario: 2 OT + 1 Tecan in 2m×2m, compact outward."""
|
||||
devices = [_ot("ot1"), _ot("ot2"), _tecan("tecan")]
|
||||
lab = Lab(width=2.0, depth=2.0)
|
||||
params = resolve_seeder_params("compact_outward")
|
||||
seed = seed_layout(devices, lab, params)
|
||||
constraints = [
|
||||
Constraint(type="hard", rule_name="min_spacing",
|
||||
params={"min_gap": 0.05}),
|
||||
Constraint(type="soft", rule_name="prefer_orientation_mode",
|
||||
params={"mode": "outward"}, weight=5.0),
|
||||
Constraint(type="soft", rule_name="prefer_aligned", weight=2.0),
|
||||
]
|
||||
result = optimize(devices, lab, constraints, seed_placements=seed,
|
||||
maxiter=200, seed=42)
|
||||
result = snap_theta(result)
|
||||
# No stacking
|
||||
assert not _has_collision(devices, result)
|
||||
# All outward
|
||||
for i, p in enumerate(result):
|
||||
assert _facing_dot(p, devices[i], lab) > 0
|
||||
|
||||
def test_4_medium_devices_mixed_openings(self):
|
||||
"""4 devices with different opening directions."""
|
||||
devices = [
|
||||
Device(id="d0", name="D0", bbox=(0.5, 0.3), openings=[Opening((1, 0))]),
|
||||
Device(id="d1", name="D1", bbox=(0.5, 0.3), openings=[Opening((-1, 0))]),
|
||||
Device(id="d2", name="D2", bbox=(0.5, 0.3), openings=[Opening((0, -1))]),
|
||||
Device(id="d3", name="D3", bbox=(0.5, 0.3), openings=[Opening((0, 1))]),
|
||||
]
|
||||
lab = Lab(width=3.0, depth=3.0)
|
||||
params = resolve_seeder_params("compact_outward")
|
||||
seed = seed_layout(devices, lab, params)
|
||||
constraints = [
|
||||
Constraint(type="hard", rule_name="min_spacing",
|
||||
params={"min_gap": 0.05}),
|
||||
Constraint(type="soft", rule_name="prefer_orientation_mode",
|
||||
params={"mode": "outward"}, weight=5.0),
|
||||
Constraint(type="soft", rule_name="prefer_aligned", weight=2.0),
|
||||
]
|
||||
result = optimize(devices, lab, constraints, seed_placements=seed,
|
||||
maxiter=200, seed=42)
|
||||
result = snap_theta(result)
|
||||
assert not _has_collision(devices, result)
|
||||
for i, p in enumerate(result):
|
||||
assert _facing_dot(p, devices[i], lab) > 0
|
||||
|
||||
|
||||
# ── V2 Stage 1: 默认关闭 cardinal snap/alignment ────────
|
||||
|
||||
class TestV2Stage1Bugfixes:
|
||||
"""align_weight 默认为 0,snap_cardinal 默认关闭。"""
|
||||
|
||||
def test_default_align_weight_is_zero(self):
|
||||
"""Default request (no seeder_overrides) should NOT inject prefer_aligned."""
|
||||
from fastapi.testclient import TestClient
|
||||
from ..server import app
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.post("/optimize", json={
|
||||
"devices": [{"id": "opentrons_liquid_handler", "uuid": "u1"}],
|
||||
"lab": {"width": 3, "depth": 3},
|
||||
"seeder": "compact_outward",
|
||||
"run_de": True,
|
||||
"maxiter": 50,
|
||||
"seed": 42,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_snap_cardinal_off_by_default(self):
|
||||
"""Default request should NOT snap theta to cardinal."""
|
||||
from fastapi.testclient import TestClient
|
||||
from ..server import app
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.post("/optimize", json={
|
||||
"devices": [{"id": "opentrons_liquid_handler", "uuid": "u1"}],
|
||||
"lab": {"width": 3, "depth": 3},
|
||||
"seeder": "compact_outward",
|
||||
"run_de": True,
|
||||
"maxiter": 10,
|
||||
"seed": 42,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_snap_cardinal_opt_in(self):
|
||||
"""snap_cardinal=True should be accepted and snap angles."""
|
||||
from fastapi.testclient import TestClient
|
||||
from ..server import app
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.post("/optimize", json={
|
||||
"devices": [{"id": "opentrons_liquid_handler", "uuid": "u1"}],
|
||||
"lab": {"width": 3, "depth": 3},
|
||||
"seeder": "compact_outward",
|
||||
"snap_cardinal": True,
|
||||
"run_de": True,
|
||||
"maxiter": 10,
|
||||
"seed": 42,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
@@ -1,505 +0,0 @@
|
||||
"""约束体系测试。"""
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from ..constraints import (
|
||||
_crossing_penalty,
|
||||
_opening_surface_center,
|
||||
DEFAULT_WEIGHT_DISTANCE,
|
||||
evaluate_constraints,
|
||||
evaluate_default_hard_constraints,
|
||||
)
|
||||
from ..mock_checkers import MockCollisionChecker, MockReachabilityChecker
|
||||
from ..models import Constraint, Device, Opening, Placement, Lab
|
||||
from ..obb import nearest_point_on_obb, obb_corners
|
||||
|
||||
|
||||
def _make_devices():
|
||||
return [
|
||||
Device(id="a", name="Device A", bbox=(0.5, 0.5)),
|
||||
Device(id="b", name="Device B", bbox=(0.5, 0.5)),
|
||||
]
|
||||
|
||||
|
||||
def _make_lab():
|
||||
return Lab(width=5.0, depth=4.0)
|
||||
|
||||
|
||||
class TestDefaultHardConstraints:
|
||||
def test_no_collision_passes(self):
|
||||
"""无碰撞的布局应返回 0。"""
|
||||
devices = _make_devices()
|
||||
placements = [
|
||||
Placement("a", 1.0, 1.0, 0.0),
|
||||
Placement("b", 3.0, 3.0, 0.0),
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
cost = evaluate_default_hard_constraints(devices, placements, _make_lab(), checker)
|
||||
assert cost == 0.0
|
||||
|
||||
def test_collision_returns_graduated_penalty(self):
|
||||
"""碰撞布局应返回正的graduated penalty(非inf)。"""
|
||||
devices = _make_devices()
|
||||
placements = [
|
||||
Placement("a", 1.0, 1.0, 0.0),
|
||||
Placement("b", 1.2, 1.0, 0.0),
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
cost = evaluate_default_hard_constraints(devices, placements, _make_lab(), checker)
|
||||
assert cost > 0
|
||||
assert not math.isinf(cost)
|
||||
|
||||
def test_collision_returns_inf_binary_mode(self):
|
||||
"""Binary mode: 碰撞布局应返回 inf。"""
|
||||
devices = _make_devices()
|
||||
placements = [
|
||||
Placement("a", 1.0, 1.0, 0.0),
|
||||
Placement("b", 1.2, 1.0, 0.0),
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
cost = evaluate_default_hard_constraints(
|
||||
devices, placements, _make_lab(), checker, graduated=False,
|
||||
)
|
||||
assert math.isinf(cost)
|
||||
|
||||
def test_out_of_bounds_returns_graduated_penalty(self):
|
||||
"""越界布局应返回正的graduated penalty(非inf)。"""
|
||||
devices = _make_devices()
|
||||
placements = [
|
||||
Placement("a", 0.1, 0.1, 0.0), # 左下角越界
|
||||
Placement("b", 3.0, 3.0, 0.0),
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
cost = evaluate_default_hard_constraints(devices, placements, _make_lab(), checker)
|
||||
assert cost > 0
|
||||
assert not math.isinf(cost)
|
||||
|
||||
def test_out_of_bounds_returns_inf_binary_mode(self):
|
||||
"""Binary mode: 越界布局应返回 inf。"""
|
||||
devices = _make_devices()
|
||||
placements = [
|
||||
Placement("a", 0.1, 0.1, 0.0),
|
||||
Placement("b", 3.0, 3.0, 0.0),
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
cost = evaluate_default_hard_constraints(
|
||||
devices, placements, _make_lab(), checker, graduated=False,
|
||||
)
|
||||
assert math.isinf(cost)
|
||||
|
||||
def test_worse_collision_higher_cost(self):
|
||||
"""Deeper penetration should produce higher cost."""
|
||||
devices = _make_devices()
|
||||
checker = MockCollisionChecker()
|
||||
lab = _make_lab()
|
||||
# Small overlap
|
||||
cost_small = evaluate_default_hard_constraints(
|
||||
devices, [Placement("a", 1.0, 1.0, 0.0), Placement("b", 1.4, 1.0, 0.0)],
|
||||
lab, checker,
|
||||
)
|
||||
# Large overlap
|
||||
cost_large = evaluate_default_hard_constraints(
|
||||
devices, [Placement("a", 1.0, 1.0, 0.0), Placement("b", 1.1, 1.0, 0.0)],
|
||||
lab, checker,
|
||||
)
|
||||
assert cost_large > cost_small > 0
|
||||
|
||||
|
||||
class TestUserConstraints:
|
||||
def test_distance_less_than_satisfied(self):
|
||||
"""距离约束满足时 cost=0。"""
|
||||
devices = _make_devices()
|
||||
placements = [
|
||||
Placement("a", 1.0, 1.0, 0.0),
|
||||
Placement("b", 1.5, 1.0, 0.0),
|
||||
]
|
||||
constraints = [
|
||||
Constraint(type="hard", rule_name="distance_less_than",
|
||||
params={"device_a": "a", "device_b": "b", "distance": 1.0})
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
reachability = MockReachabilityChecker()
|
||||
cost = evaluate_constraints(
|
||||
devices, placements, _make_lab(), constraints, checker, reachability
|
||||
)
|
||||
assert cost == 0.0
|
||||
|
||||
def test_distance_less_than_violated_hard(self):
|
||||
"""硬距离约束违反:graduated模式返回有限惩罚,binary模式返回inf。"""
|
||||
devices = _make_devices()
|
||||
placements = [
|
||||
Placement("a", 1.0, 1.0, 0.0),
|
||||
Placement("b", 4.0, 3.0, 0.0),
|
||||
]
|
||||
constraints = [
|
||||
Constraint(type="hard", rule_name="distance_less_than",
|
||||
params={"device_a": "a", "device_b": "b", "distance": 1.0})
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
# graduated=True (default): 有限惩罚
|
||||
cost = evaluate_constraints(
|
||||
devices, placements, _make_lab(), constraints, checker
|
||||
)
|
||||
assert cost > 0
|
||||
assert not math.isinf(cost)
|
||||
# graduated=False: binary inf
|
||||
cost_binary = evaluate_constraints(
|
||||
devices, placements, _make_lab(), constraints, checker,
|
||||
graduated=False,
|
||||
)
|
||||
assert math.isinf(cost_binary)
|
||||
|
||||
def test_minimize_distance_cost(self):
|
||||
"""minimize_distance 约束应返回正比于距离的 cost。"""
|
||||
devices = _make_devices()
|
||||
placements = [
|
||||
Placement("a", 1.0, 1.0, 0.0),
|
||||
Placement("b", 3.0, 1.0, 0.0),
|
||||
]
|
||||
constraints = [
|
||||
Constraint(type="soft", rule_name="minimize_distance",
|
||||
params={"device_a": "a", "device_b": "b"}, weight=2.0)
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
cost = evaluate_constraints(
|
||||
devices, placements, _make_lab(), constraints, checker
|
||||
)
|
||||
# edge-to-edge distance = 2.0 - 0.25 - 0.25 = 1.5, weight = 2.0 → cost = 3.0
|
||||
assert abs(cost - 3.0) < 0.01
|
||||
|
||||
def test_reachability_constraint(self):
|
||||
"""可达性约束:目标在臂展内应通过(不返回 inf)。
|
||||
|
||||
Opening-faces-arm penalty may add a small soft cost when the
|
||||
target's opening doesn't face the arm, but it must not cause
|
||||
hard failure (inf).
|
||||
"""
|
||||
devices = [
|
||||
Device(id="arm", name="Arm", bbox=(0.2, 0.2), device_type="articulation"),
|
||||
Device(id="target", name="Target", bbox=(0.5, 0.5)),
|
||||
]
|
||||
placements = [
|
||||
Placement("arm", 1.0, 1.0, 0.0),
|
||||
Placement("target", 1.5, 1.0, 0.0),
|
||||
]
|
||||
constraints = [
|
||||
Constraint(type="hard", rule_name="reachability",
|
||||
params={"arm_id": "arm", "target_device_id": "target"})
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
reachability = MockReachabilityChecker(arm_reach={"arm": 1.0})
|
||||
cost = evaluate_constraints(
|
||||
devices, placements, _make_lab(), constraints, checker, reachability
|
||||
)
|
||||
assert not math.isinf(cost) # reachable → no hard failure
|
||||
|
||||
def test_reachability_constraint_violated(self):
|
||||
"""可达性约束:目标超出臂展 — graduated返回有限惩罚,binary返回inf。"""
|
||||
devices = [
|
||||
Device(id="arm", name="Arm", bbox=(0.2, 0.2), device_type="articulation"),
|
||||
Device(id="target", name="Target", bbox=(0.5, 0.5)),
|
||||
]
|
||||
placements = [
|
||||
Placement("arm", 1.0, 1.0, 0.0),
|
||||
Placement("target", 4.0, 3.0, 0.0),
|
||||
]
|
||||
constraints = [
|
||||
Constraint(type="hard", rule_name="reachability",
|
||||
params={"arm_id": "arm", "target_device_id": "target"})
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
reachability = MockReachabilityChecker(arm_reach={"arm": 1.0})
|
||||
# graduated=True (default): 有限惩罚
|
||||
cost = evaluate_constraints(
|
||||
devices, placements, _make_lab(), constraints, checker, reachability
|
||||
)
|
||||
assert cost > 0
|
||||
assert not math.isinf(cost)
|
||||
# graduated=False: binary inf
|
||||
cost_binary = evaluate_constraints(
|
||||
devices, placements, _make_lab(), constraints, checker, reachability,
|
||||
graduated=False,
|
||||
)
|
||||
assert math.isinf(cost_binary)
|
||||
|
||||
|
||||
def test_distance_less_than_uses_edge_to_edge():
|
||||
"""distance_less_than should measure edge-to-edge, not center-to-center.
|
||||
|
||||
Two devices: centers 3m apart, each 2m wide → edge gap = 1m.
|
||||
Constraint: distance_less_than 1.5m (edge-to-edge).
|
||||
Old center-to-center: 3m > 1.5m → violation.
|
||||
New edge-to-edge: 1m < 1.5m → satisfied.
|
||||
"""
|
||||
devices = [
|
||||
Device(id="a", name="A", bbox=(2.0, 1.0)),
|
||||
Device(id="b", name="B", bbox=(2.0, 1.0)),
|
||||
]
|
||||
placements = [
|
||||
Placement(device_id="a", x=1.0, y=1.0, theta=0.0),
|
||||
Placement(device_id="b", x=4.0, y=1.0, theta=0.0),
|
||||
]
|
||||
lab = Lab(width=10, depth=10)
|
||||
constraint = Constraint(
|
||||
type="soft", rule_name="distance_less_than",
|
||||
params={"device_a": "a", "device_b": "b", "distance": 1.5},
|
||||
weight=1.0,
|
||||
)
|
||||
checker = MockCollisionChecker()
|
||||
cost = evaluate_constraints(devices, placements, lab, [constraint], checker)
|
||||
assert cost == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_prefer_aligned_zero_at_cardinal():
|
||||
"""prefer_aligned cost = 0 when all devices at 0/90/180/270°."""
|
||||
devices = [Device(id="a", name="A", bbox=(1.0, 1.0))]
|
||||
lab = Lab(width=10, depth=10)
|
||||
checker = MockCollisionChecker()
|
||||
for angle in [0, math.pi / 2, math.pi, 3 * math.pi / 2]:
|
||||
placements = [Placement(device_id="a", x=5, y=5, theta=angle)]
|
||||
constraint = Constraint(type="soft", rule_name="prefer_aligned", weight=1.0)
|
||||
cost = evaluate_constraints(devices, placements, lab, [constraint], checker)
|
||||
assert cost == pytest.approx(0.0, abs=1e-9)
|
||||
|
||||
|
||||
def test_prefer_aligned_max_at_45():
|
||||
"""prefer_aligned cost is maximum when device at 45°."""
|
||||
devices = [Device(id="a", name="A", bbox=(1.0, 1.0))]
|
||||
placements = [Placement(device_id="a", x=5, y=5, theta=math.pi / 4)]
|
||||
lab = Lab(width=10, depth=10)
|
||||
constraint = Constraint(type="soft", rule_name="prefer_aligned", weight=1.0)
|
||||
checker = MockCollisionChecker()
|
||||
cost = evaluate_constraints(devices, placements, lab, [constraint], checker)
|
||||
# (1 - cos(4 * pi/4)) / 2 = (1 - cos(pi)) / 2 = (1 - (-1)) / 2 = 1.0
|
||||
assert cost == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_prefer_aligned_sums_over_devices():
|
||||
"""Cost sums across all devices."""
|
||||
devices = [
|
||||
Device(id="a", name="A", bbox=(1.0, 1.0)),
|
||||
Device(id="b", name="B", bbox=(1.0, 1.0)),
|
||||
]
|
||||
placements = [
|
||||
Placement(device_id="a", x=2, y=2, theta=math.pi / 4), # cost = 1.0
|
||||
Placement(device_id="b", x=7, y=7, theta=math.pi / 4), # cost = 1.0
|
||||
]
|
||||
lab = Lab(width=10, depth=10)
|
||||
constraint = Constraint(type="soft", rule_name="prefer_aligned", weight=2.0)
|
||||
checker = MockCollisionChecker()
|
||||
cost = evaluate_constraints(devices, placements, lab, [constraint], checker)
|
||||
# 2 devices × 1.0 × weight 2.0 = 4.0
|
||||
assert cost == pytest.approx(4.0)
|
||||
|
||||
|
||||
class TestGraduatedHardConstraints:
|
||||
"""graduated 模式下硬约束返回比例惩罚而非 inf。"""
|
||||
|
||||
def test_hard_reachability_graduated_finite(self):
|
||||
"""graduated=True: 硬可达性返回有限惩罚。"""
|
||||
devices = [
|
||||
Device(id="arm", name="Arm", bbox=(0.2, 0.2), device_type="articulation"),
|
||||
Device(id="t", name="Target", bbox=(0.5, 0.5)),
|
||||
]
|
||||
placements = [
|
||||
Placement("arm", 1.0, 1.0, 0.0),
|
||||
Placement("t", 4.0, 3.0, 0.0),
|
||||
]
|
||||
constraints = [
|
||||
Constraint(type="hard", rule_name="reachability",
|
||||
params={"arm_id": "arm", "target_device_id": "t"}, weight=1.0)
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
reach = MockReachabilityChecker(arm_reach={"arm": 1.0})
|
||||
cost = evaluate_constraints(
|
||||
devices, placements, _make_lab(), constraints, checker, reach,
|
||||
graduated=True,
|
||||
)
|
||||
assert cost > 0
|
||||
assert not math.isinf(cost)
|
||||
|
||||
def test_hard_reachability_binary_inf(self):
|
||||
"""graduated=False: 硬可达性返回 inf。"""
|
||||
devices = [
|
||||
Device(id="arm", name="Arm", bbox=(0.2, 0.2), device_type="articulation"),
|
||||
Device(id="t", name="Target", bbox=(0.5, 0.5)),
|
||||
]
|
||||
placements = [
|
||||
Placement("arm", 1.0, 1.0, 0.0),
|
||||
Placement("t", 4.0, 3.0, 0.0),
|
||||
]
|
||||
constraints = [
|
||||
Constraint(type="hard", rule_name="reachability",
|
||||
params={"arm_id": "arm", "target_device_id": "t"}, weight=1.0)
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
reach = MockReachabilityChecker(arm_reach={"arm": 1.0})
|
||||
cost = evaluate_constraints(
|
||||
devices, placements, _make_lab(), constraints, checker, reach,
|
||||
graduated=False,
|
||||
)
|
||||
assert math.isinf(cost)
|
||||
|
||||
def test_hard_min_spacing_graduated_sums_all_pairs(self):
|
||||
"""graduated模式:min_spacing 对所有违规对求和(不只第一对)。"""
|
||||
devices = [
|
||||
Device(id="a", name="A", bbox=(0.5, 0.5)),
|
||||
Device(id="b", name="B", bbox=(0.5, 0.5)),
|
||||
Device(id="c", name="C", bbox=(0.5, 0.5)),
|
||||
]
|
||||
# 三个设备间距都小于 min_gap=1.0
|
||||
placements = [
|
||||
Placement("a", 1.0, 2.0, 0.0),
|
||||
Placement("b", 1.3, 2.0, 0.0), # OBB 边缘距 a 约 0.3
|
||||
Placement("c", 1.6, 2.0, 0.0), # OBB 边缘距 b 约 0.3, 距 a 约 0.6
|
||||
]
|
||||
constraints = [
|
||||
Constraint(type="hard", rule_name="min_spacing",
|
||||
params={"min_gap": 1.0}, weight=1.0)
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
cost = evaluate_constraints(
|
||||
devices, placements, _make_lab(), constraints, checker,
|
||||
graduated=True,
|
||||
)
|
||||
# 应大于 0 且有限(累加多对违规)
|
||||
assert cost > 0
|
||||
assert not math.isinf(cost)
|
||||
|
||||
def test_hard_min_spacing_binary_inf(self):
|
||||
"""graduated=False: min_spacing 违规返回 inf。"""
|
||||
devices = _make_devices()
|
||||
placements = [
|
||||
Placement("a", 1.0, 2.0, 0.0),
|
||||
Placement("b", 1.3, 2.0, 0.0),
|
||||
]
|
||||
constraints = [
|
||||
Constraint(type="hard", rule_name="min_spacing",
|
||||
params={"min_gap": 1.0}, weight=1.0)
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
cost = evaluate_constraints(
|
||||
devices, placements, _make_lab(), constraints, checker,
|
||||
graduated=False,
|
||||
)
|
||||
assert math.isinf(cost)
|
||||
|
||||
def test_hard_distance_less_than_graduated(self):
|
||||
"""graduated模式:distance_less_than 硬约束返回比例惩罚。"""
|
||||
devices = _make_devices()
|
||||
placements = [
|
||||
Placement("a", 1.0, 2.0, 0.0),
|
||||
Placement("b", 4.0, 2.0, 0.0),
|
||||
]
|
||||
constraints = [
|
||||
Constraint(type="hard", rule_name="distance_less_than",
|
||||
params={"device_a": "a", "device_b": "b", "distance": 0.5},
|
||||
weight=2.0)
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
cost = evaluate_constraints(
|
||||
devices, placements, _make_lab(), constraints, checker,
|
||||
graduated=True,
|
||||
)
|
||||
# HARD_MULTIPLIER(5) × weight(2) × overshoot > 0
|
||||
assert cost > 0
|
||||
assert not math.isinf(cost)
|
||||
|
||||
def test_graduated_default_is_true(self):
|
||||
"""不传 graduated 参数时默认使用 graduated 模式。"""
|
||||
devices = _make_devices()
|
||||
placements = [
|
||||
Placement("a", 1.0, 2.0, 0.0),
|
||||
Placement("b", 4.0, 2.0, 0.0),
|
||||
]
|
||||
constraints = [
|
||||
Constraint(type="hard", rule_name="distance_less_than",
|
||||
params={"device_a": "a", "device_b": "b", "distance": 0.5},
|
||||
weight=1.0)
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
# 不指定 graduated — 默认应为 True → 有限惩罚
|
||||
cost = evaluate_constraints(
|
||||
devices, placements, _make_lab(), constraints, checker,
|
||||
)
|
||||
assert not math.isinf(cost)
|
||||
|
||||
|
||||
class TestCrossingPenalty:
|
||||
"""_crossing_penalty: 交叉长度加权的 soft penalty。"""
|
||||
|
||||
def _make_device(self, dev_id, bbox=(0.5, 0.5), direction=(0.0, -1.0)):
|
||||
return Device(
|
||||
id=dev_id, name=dev_id, device_type="static",
|
||||
bbox=bbox, height=0.3,
|
||||
openings=[Opening(direction=direction, label="front")],
|
||||
)
|
||||
|
||||
def test_no_blockers_returns_zero(self):
|
||||
"""arm 与 target 之间无遮挡设备 → 交叉代价为 0。"""
|
||||
arm = self._make_device("arm", bbox=(2.14, 0.35))
|
||||
target = self._make_device("target")
|
||||
arm_p = Placement(device_id="arm", x=2.0, y=1.0, theta=0.0)
|
||||
target_p = Placement(device_id="target", x=0.5, y=1.0, theta=3.14159)
|
||||
device_map = {"arm": arm, "target": target}
|
||||
placement_map = {"arm": arm_p, "target": target_p}
|
||||
|
||||
opening_pt = _opening_surface_center(target, target_p)
|
||||
arm_corners = obb_corners(arm_p.x, arm_p.y, arm.bbox[0], arm.bbox[1], arm_p.theta)
|
||||
nearest = nearest_point_on_obb(opening_pt[0], opening_pt[1], arm_corners)
|
||||
|
||||
cost = _crossing_penalty(
|
||||
opening_pt, nearest,
|
||||
"arm", "target",
|
||||
device_map, placement_map,
|
||||
)
|
||||
assert cost == 0.0
|
||||
|
||||
def test_one_blocker_proportional_to_length(self):
|
||||
"""一个遮挡设备 → cost = DEFAULT_WEIGHT_DISTANCE * 穿过长度。"""
|
||||
arm = self._make_device("arm", bbox=(2.14, 0.35))
|
||||
target = self._make_device("target")
|
||||
blocker = self._make_device("blocker", bbox=(0.5, 0.5))
|
||||
arm_p = Placement(device_id="arm", x=3.0, y=1.0, theta=0.0)
|
||||
target_p = Placement(device_id="target", x=0.0, y=1.0, theta=0.0)
|
||||
blocker_p = Placement(device_id="blocker", x=1.5, y=1.0, theta=0.0)
|
||||
device_map = {"arm": arm, "target": target, "blocker": blocker}
|
||||
placement_map = {"arm": arm_p, "target": target_p, "blocker": blocker_p}
|
||||
|
||||
opening_pt = _opening_surface_center(target, target_p)
|
||||
arm_corners = obb_corners(arm_p.x, arm_p.y, arm.bbox[0], arm.bbox[1], arm_p.theta)
|
||||
nearest = nearest_point_on_obb(opening_pt[0], opening_pt[1], arm_corners)
|
||||
|
||||
cost = _crossing_penalty(
|
||||
opening_pt, nearest,
|
||||
"arm", "target",
|
||||
device_map, placement_map,
|
||||
)
|
||||
# blocker 宽 0.5m,theta=0,路径水平 → 穿过长度 ≈ 0.5m
|
||||
# cost = DEFAULT_WEIGHT_DISTANCE * 0.5 = 100 * 0.5 = 50
|
||||
assert cost > 0
|
||||
assert abs(cost - DEFAULT_WEIGHT_DISTANCE * 0.5) < DEFAULT_WEIGHT_DISTANCE * 0.1
|
||||
|
||||
def test_blocker_off_path_returns_zero(self):
|
||||
"""不在路径上的设备 → 交叉代价为 0。"""
|
||||
arm = self._make_device("arm", bbox=(2.14, 0.35))
|
||||
target = self._make_device("target")
|
||||
bystander = self._make_device("bystander", bbox=(0.5, 0.5))
|
||||
arm_p = Placement(device_id="arm", x=3.0, y=1.0, theta=0.0)
|
||||
target_p = Placement(device_id="target", x=0.0, y=1.0, theta=0.0)
|
||||
bystander_p = Placement(device_id="bystander", x=1.5, y=3.0, theta=0.0)
|
||||
device_map = {"arm": arm, "target": target, "bystander": bystander}
|
||||
placement_map = {"arm": arm_p, "target": target_p, "bystander": bystander_p}
|
||||
|
||||
opening_pt = _opening_surface_center(target, target_p)
|
||||
arm_corners = obb_corners(arm_p.x, arm_p.y, arm.bbox[0], arm.bbox[1], arm_p.theta)
|
||||
nearest = nearest_point_on_obb(opening_pt[0], opening_pt[1], arm_corners)
|
||||
|
||||
cost = _crossing_penalty(
|
||||
opening_pt, nearest,
|
||||
"arm", "target",
|
||||
device_map, placement_map,
|
||||
)
|
||||
assert cost == 0.0
|
||||
@@ -1,234 +0,0 @@
|
||||
"""device_catalog 双源加载测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from ..device_catalog import (
|
||||
_DEFAULT_FOOTPRINTS,
|
||||
create_devices_from_list,
|
||||
load_devices_from_assets,
|
||||
load_devices_from_registry,
|
||||
load_footprints,
|
||||
merge_device_lists,
|
||||
reset_footprints_cache,
|
||||
resolve_device,
|
||||
)
|
||||
|
||||
# ---------- fixtures ----------
|
||||
|
||||
# LeapLab/layout_optimizer/tests/ → LeapLab/ → DPTech/
|
||||
_LEAPLAB = Path(__file__).resolve().parent.parent.parent
|
||||
_DPTECH = _LEAPLAB.parent
|
||||
DATA_JSON = _DPTECH / "uni-lab-assets" / "data.json"
|
||||
REGISTRY_DIR = _LEAPLAB / "Uni-Lab-OS" / "unilabos" / "device_mesh" / "devices"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_cache():
|
||||
"""每个测试前清除缓存。"""
|
||||
reset_footprints_cache()
|
||||
yield
|
||||
reset_footprints_cache()
|
||||
|
||||
|
||||
# ---------- footprints ----------
|
||||
|
||||
|
||||
class TestLoadFootprints:
|
||||
def test_load_footprints_exists(self):
|
||||
fp = load_footprints(_DEFAULT_FOOTPRINTS)
|
||||
assert isinstance(fp, dict)
|
||||
assert len(fp) > 0
|
||||
|
||||
def test_footprint_structure(self):
|
||||
fp = load_footprints()
|
||||
for dev_id, entry in fp.items():
|
||||
assert "bbox" in entry, f"{dev_id} missing bbox"
|
||||
assert len(entry["bbox"]) == 2
|
||||
assert "height" in entry
|
||||
assert "origin_offset" in entry
|
||||
assert "openings" in entry
|
||||
|
||||
def test_known_device_in_footprints(self):
|
||||
fp = load_footprints()
|
||||
assert "agilent_bravo" in fp
|
||||
bbox = fp["agilent_bravo"]["bbox"]
|
||||
assert 0.5 < bbox[0] < 1.0 # width ~0.65m
|
||||
assert 0.5 < bbox[1] < 1.0 # depth ~0.70m
|
||||
|
||||
def test_nonexistent_path_returns_empty(self):
|
||||
reset_footprints_cache()
|
||||
fp = load_footprints("/nonexistent/footprints.json")
|
||||
assert fp == {}
|
||||
|
||||
|
||||
# ---------- assets 加载 ----------
|
||||
|
||||
|
||||
class TestLoadFromAssets:
|
||||
@pytest.mark.skipif(not DATA_JSON.exists(), reason="data.json not found")
|
||||
def test_load_returns_devices(self):
|
||||
devices = load_devices_from_assets(DATA_JSON)
|
||||
assert len(devices) > 0
|
||||
|
||||
@pytest.mark.skipif(not DATA_JSON.exists(), reason="data.json not found")
|
||||
def test_known_device_has_real_bbox(self):
|
||||
devices = load_devices_from_assets(DATA_JSON)
|
||||
bravo = next((d for d in devices if d.id == "agilent_bravo"), None)
|
||||
assert bravo is not None
|
||||
assert bravo.bbox != (0.6, 0.4) # 不是默认值
|
||||
assert bravo.source == "assets"
|
||||
|
||||
def test_missing_data_json(self):
|
||||
devices = load_devices_from_assets("/nonexistent/data.json")
|
||||
assert devices == []
|
||||
|
||||
|
||||
# ---------- registry 加载 ----------
|
||||
|
||||
|
||||
class TestLoadFromRegistry:
|
||||
@pytest.mark.skipif(not REGISTRY_DIR.exists(), reason="registry dir not found")
|
||||
def test_load_returns_devices(self):
|
||||
devices = load_devices_from_registry(REGISTRY_DIR)
|
||||
assert len(devices) > 0
|
||||
|
||||
@pytest.mark.skipif(not REGISTRY_DIR.exists(), reason="registry dir not found")
|
||||
def test_elite_robot_present(self):
|
||||
devices = load_devices_from_registry(REGISTRY_DIR)
|
||||
elite = next((d for d in devices if d.id == "elite_robot"), None)
|
||||
assert elite is not None
|
||||
assert elite.source == "registry"
|
||||
|
||||
def test_missing_dir(self):
|
||||
devices = load_devices_from_registry("/nonexistent/")
|
||||
assert devices == []
|
||||
|
||||
|
||||
# ---------- 合并与去重 ----------
|
||||
|
||||
|
||||
class TestMergeDedup:
|
||||
def test_registry_wins_dedup(self):
|
||||
from ..models import Device
|
||||
|
||||
reg = [Device(id="ot2", name="OT-2 Registry", bbox=(0.62, 0.50), source="registry")]
|
||||
asset = [Device(id="ot2", name="OT-2 Assets", bbox=(0.62, 0.50), source="assets")]
|
||||
merged = merge_device_lists(reg, asset)
|
||||
ot2 = next(d for d in merged if d.id == "ot2")
|
||||
assert ot2.source == "registry"
|
||||
assert ot2.name == "OT-2 Registry"
|
||||
|
||||
def test_merge_preserves_unique(self):
|
||||
from ..models import Device
|
||||
|
||||
reg = [Device(id="elite", name="Elite", source="registry")]
|
||||
asset = [Device(id="bravo", name="Bravo", source="assets")]
|
||||
merged = merge_device_lists(reg, asset)
|
||||
ids = {d.id for d in merged}
|
||||
assert ids == {"elite", "bravo"}
|
||||
|
||||
def test_registry_inherits_asset_model(self):
|
||||
from ..models import Device
|
||||
|
||||
reg = [Device(id="ot2", name="OT-2", source="registry", model_path="")]
|
||||
asset = [Device(id="ot2", name="OT-2", source="assets", model_path="/models/ot2/mesh.glb")]
|
||||
merged = merge_device_lists(reg, asset)
|
||||
ot2 = next(d for d in merged if d.id == "ot2")
|
||||
assert ot2.model_path == "/models/ot2/mesh.glb"
|
||||
|
||||
|
||||
# ---------- resolve_device ----------
|
||||
|
||||
|
||||
class TestResolveDevice:
|
||||
def test_known_device(self):
|
||||
dev = resolve_device("agilent_bravo")
|
||||
assert dev is not None
|
||||
assert dev.id == "agilent_bravo"
|
||||
assert dev.bbox != (0.6, 0.4)
|
||||
|
||||
def test_fallback_known_sizes(self):
|
||||
dev = resolve_device("ot2")
|
||||
assert dev is not None
|
||||
assert dev.bbox == (0.62, 0.50)
|
||||
|
||||
def test_unknown_device_returns_none(self):
|
||||
dev = resolve_device("totally_unknown_device_xyz")
|
||||
assert dev is None
|
||||
|
||||
|
||||
# ---------- create_devices_from_list (向后兼容) ----------
|
||||
|
||||
|
||||
class TestCreateDevicesFromList:
|
||||
def test_basic(self):
|
||||
specs = [{"id": "test_dev", "name": "Test"}]
|
||||
devs = create_devices_from_list(specs)
|
||||
assert len(devs) == 1
|
||||
assert devs[0].id == "test_dev"
|
||||
|
||||
def test_with_explicit_size(self):
|
||||
specs = [{"id": "custom", "name": "Custom", "size": [1.0, 0.5]}]
|
||||
devs = create_devices_from_list(specs)
|
||||
assert devs[0].bbox == (1.0, 0.5)
|
||||
|
||||
def test_footprint_size_used_when_no_explicit(self):
|
||||
specs = [{"id": "agilent_bravo", "name": "Bravo"}]
|
||||
devs = create_devices_from_list(specs)
|
||||
assert devs[0].bbox != (0.6, 0.4) # 使用 footprints 中的真实尺寸
|
||||
|
||||
def test_duplicate_catalog_ids_use_suffixes_and_store_uuid(self):
|
||||
specs = [
|
||||
{"id": "opentrons_liquid_handler", "uuid": "u1"},
|
||||
{"id": "opentrons_liquid_handler", "uuid": "u2"},
|
||||
]
|
||||
devs = create_devices_from_list(specs)
|
||||
assert [dev.id for dev in devs] == [
|
||||
"opentrons_liquid_handler",
|
||||
"opentrons_liquid_handler#2",
|
||||
]
|
||||
assert [dev.uuid for dev in devs] == ["u1", "u2"]
|
||||
|
||||
|
||||
# ---------- server endpoint (需要 httpx) ----------
|
||||
|
||||
|
||||
class TestDevicesEndpoint:
|
||||
def test_get_devices(self):
|
||||
try:
|
||||
from fastapi.testclient import TestClient
|
||||
except ImportError:
|
||||
pytest.skip("fastapi testclient not available")
|
||||
|
||||
from ..server import app
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.get("/devices")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list)
|
||||
# 可能为空(取决于 uni-lab-assets 是否在预期路径)
|
||||
if len(data) > 0:
|
||||
first = data[0]
|
||||
assert "id" in first
|
||||
assert "bbox" in first
|
||||
assert "source" in first
|
||||
|
||||
def test_filter_by_source(self):
|
||||
try:
|
||||
from fastapi.testclient import TestClient
|
||||
except ImportError:
|
||||
pytest.skip("fastapi testclient not available")
|
||||
|
||||
from ..server import app
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.get("/devices?source=registry")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
for d in data:
|
||||
assert d["source"] == "registry"
|
||||
@@ -1,263 +0,0 @@
|
||||
"""End-to-end pipeline test: intents → interpret → optimize → verify.
|
||||
|
||||
Tests each stage boundary independently so failures are easy to localize.
|
||||
Uses real PCR workflow devices with footprints from the catalog.
|
||||
"""
|
||||
import math
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ..server import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
# -- Scene: 5 PCR devices the user has already placed in the scene --
|
||||
|
||||
PCR_DEVICES = [
|
||||
{"id": "thermo_orbitor_rs2_hotel", "name": "Plate Hotel", "device_type": "static"},
|
||||
{"id": "arm_slider", "name": "Robot Arm", "device_type": "articulation"},
|
||||
{"id": "opentrons_liquid_handler", "name": "Liquid Handler", "device_type": "static"},
|
||||
{"id": "agilent_plateloc", "name": "Plate Sealer", "device_type": "static"},
|
||||
{"id": "inheco_odtc_96xl", "name": "Thermal Cycler", "device_type": "static"},
|
||||
]
|
||||
|
||||
PCR_LAB = {"width": 6.0, "depth": 4.0}
|
||||
|
||||
# -- Stage 1: simulated LLM output (what the LLM would produce from NL) --
|
||||
# User said: "take plate from hotel, prepare sample in opentrons,
|
||||
# seal plate then pcr cycle, arm_slider handles transfers"
|
||||
|
||||
LLM_INTENTS = [
|
||||
{
|
||||
"intent": "reachable_by",
|
||||
"params": {
|
||||
"arm": "arm_slider",
|
||||
"targets": [
|
||||
"thermo_orbitor_rs2_hotel",
|
||||
"opentrons_liquid_handler",
|
||||
"agilent_plateloc",
|
||||
"inheco_odtc_96xl",
|
||||
],
|
||||
},
|
||||
"description": "arm_slider must reach all workflow devices",
|
||||
},
|
||||
{
|
||||
"intent": "workflow_hint",
|
||||
"params": {
|
||||
"workflow": "pcr",
|
||||
"devices": [
|
||||
"thermo_orbitor_rs2_hotel",
|
||||
"opentrons_liquid_handler",
|
||||
"agilent_plateloc",
|
||||
"inheco_odtc_96xl",
|
||||
],
|
||||
},
|
||||
"description": "PCR order: hotel → liquid handler → sealer → thermal cycler",
|
||||
},
|
||||
{
|
||||
"intent": "close_together",
|
||||
"params": {
|
||||
"devices": ["opentrons_liquid_handler", "agilent_plateloc"],
|
||||
"priority": "high",
|
||||
},
|
||||
"description": "Seal immediately after sample prep",
|
||||
},
|
||||
{
|
||||
"intent": "min_spacing",
|
||||
"params": {"min_gap": 0.15},
|
||||
"description": "Minimum 15cm gap for accessibility",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class TestStage1Interpret:
|
||||
"""Stage 1: /interpret translates intents → constraints."""
|
||||
|
||||
def test_interpret_returns_correct_constraint_count(self):
|
||||
resp = client.post("/interpret", json={"intents": LLM_INTENTS})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# 4 reachability + 3 workflow minimize + 1 close minimize + 1 min_spacing = 9
|
||||
assert len(data["constraints"]) == 9
|
||||
assert len(data["errors"]) == 0
|
||||
|
||||
def test_interpret_has_translations_for_each_intent(self):
|
||||
resp = client.post("/interpret", json={"intents": LLM_INTENTS})
|
||||
data = resp.json()
|
||||
assert len(data["translations"]) == len(LLM_INTENTS)
|
||||
# 每个 translation 都有 explanation
|
||||
for t in data["translations"]:
|
||||
assert t["explanation"] != ""
|
||||
|
||||
def test_interpret_extracts_workflow_edges(self):
|
||||
resp = client.post("/interpret", json={"intents": LLM_INTENTS})
|
||||
data = resp.json()
|
||||
assert len(data["workflow_edges"]) == 3
|
||||
assert ["thermo_orbitor_rs2_hotel", "opentrons_liquid_handler"] in data["workflow_edges"]
|
||||
assert ["opentrons_liquid_handler", "agilent_plateloc"] in data["workflow_edges"]
|
||||
assert ["agilent_plateloc", "inheco_odtc_96xl"] in data["workflow_edges"]
|
||||
|
||||
def test_interpret_constraint_types_correct(self):
|
||||
resp = client.post("/interpret", json={"intents": LLM_INTENTS})
|
||||
data = resp.json()
|
||||
constraints = data["constraints"]
|
||||
by_rule = {}
|
||||
for c in constraints:
|
||||
by_rule.setdefault(c["rule_name"], []).append(c)
|
||||
assert len(by_rule["reachability"]) == 4
|
||||
assert all(c["type"] == "hard" for c in by_rule["reachability"])
|
||||
assert len(by_rule["minimize_distance"]) == 4 # 3 workflow + 1 close
|
||||
assert all(c["type"] == "soft" for c in by_rule["minimize_distance"])
|
||||
assert len(by_rule["min_spacing"]) == 1
|
||||
assert by_rule["min_spacing"][0]["type"] == "hard"
|
||||
|
||||
|
||||
class TestStage2Optimize:
|
||||
"""Stage 2: pipe /interpret output into /optimize → placements."""
|
||||
|
||||
@pytest.fixture()
|
||||
def interpret_result(self):
|
||||
resp = client.post("/interpret", json={"intents": LLM_INTENTS})
|
||||
return resp.json()
|
||||
|
||||
def test_optimize_accepts_interpret_output(self, interpret_result):
|
||||
"""Constraints + workflow_edges from /interpret are valid /optimize input."""
|
||||
resp = client.post("/optimize", json={
|
||||
"devices": PCR_DEVICES,
|
||||
"lab": PCR_LAB,
|
||||
"constraints": interpret_result["constraints"],
|
||||
"workflow_edges": interpret_result["workflow_edges"],
|
||||
"run_de": False, # seeder only — fast
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["placements"]) == 5
|
||||
assert data["success"] is True
|
||||
|
||||
def test_optimize_with_de(self, interpret_result):
|
||||
"""Full DE optimization completes without error."""
|
||||
resp = client.post("/optimize", json={
|
||||
"devices": PCR_DEVICES,
|
||||
"lab": PCR_LAB,
|
||||
"constraints": interpret_result["constraints"],
|
||||
"workflow_edges": interpret_result["workflow_edges"],
|
||||
"run_de": True,
|
||||
"maxiter": 50, # reduced for test speed
|
||||
"seed": 42,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["placements"]) == 5
|
||||
assert data["de_ran"] is True
|
||||
|
||||
|
||||
class TestStage3VerifyPlacements:
|
||||
"""Stage 3: verify optimized placements satisfy constraint intent."""
|
||||
|
||||
@pytest.fixture()
|
||||
def placements(self):
|
||||
# Full pipeline: interpret → optimize (with DE), all intents including reachability
|
||||
# MockReachabilityChecker uses large fallback reach for unknown arms like arm_slider
|
||||
interpret_resp = client.post("/interpret", json={"intents": LLM_INTENTS})
|
||||
interpret_data = interpret_resp.json()
|
||||
|
||||
optimize_resp = client.post("/optimize", json={
|
||||
"devices": PCR_DEVICES,
|
||||
"lab": PCR_LAB,
|
||||
"constraints": interpret_data["constraints"],
|
||||
"workflow_edges": interpret_data["workflow_edges"],
|
||||
"run_de": True,
|
||||
"maxiter": 50,
|
||||
"seed": 42,
|
||||
})
|
||||
return {p["device_id"]: p for p in optimize_resp.json()["placements"]}
|
||||
|
||||
def test_all_devices_placed(self, placements):
|
||||
expected_ids = {d["id"] for d in PCR_DEVICES}
|
||||
assert set(placements.keys()) == expected_ids
|
||||
|
||||
def test_all_within_lab_bounds(self, placements):
|
||||
for dev_id, p in placements.items():
|
||||
assert 0 <= p["position"]["x"] <= PCR_LAB["width"], f"{dev_id} x out of bounds"
|
||||
assert 0 <= p["position"]["y"] <= PCR_LAB["depth"], f"{dev_id} y out of bounds"
|
||||
|
||||
def test_no_hard_constraint_violation(self):
|
||||
"""Full pipeline with all intents including reachability converges cleanly.
|
||||
|
||||
MockReachabilityChecker now includes arm_slider in the default reach table
|
||||
(1.07m). Binary final evaluation checks all hard constraints including
|
||||
user-defined reachability.
|
||||
"""
|
||||
interpret_data = client.post("/interpret", json={"intents": LLM_INTENTS}).json()
|
||||
|
||||
optimize_resp = client.post("/optimize", json={
|
||||
"devices": PCR_DEVICES,
|
||||
"lab": PCR_LAB,
|
||||
"constraints": interpret_data["constraints"],
|
||||
"workflow_edges": interpret_data["workflow_edges"],
|
||||
"run_de": True,
|
||||
"maxiter": 100,
|
||||
"seed": 42,
|
||||
"snap_cardinal": True,
|
||||
"seeder_overrides": {"align_weight": 60},
|
||||
})
|
||||
data = optimize_resp.json()
|
||||
assert data["success"] is True
|
||||
assert not math.isinf(data["cost"])
|
||||
|
||||
def test_workflow_neighbors_closer_than_diagonal(self, placements):
|
||||
"""Workflow-adjacent devices should be closer than lab diagonal (basic sanity)."""
|
||||
max_diagonal = math.sqrt(PCR_LAB["width"] ** 2 + PCR_LAB["depth"] ** 2)
|
||||
workflow_pairs = [
|
||||
("thermo_orbitor_rs2_hotel", "opentrons_liquid_handler"),
|
||||
("opentrons_liquid_handler", "agilent_plateloc"),
|
||||
("agilent_plateloc", "inheco_odtc_96xl"),
|
||||
]
|
||||
for a_id, b_id in workflow_pairs:
|
||||
a, b = placements[a_id], placements[b_id]
|
||||
dist = math.sqrt(
|
||||
(a["position"]["x"] - b["position"]["x"]) ** 2
|
||||
+ (a["position"]["y"] - b["position"]["y"]) ** 2
|
||||
)
|
||||
# 应该远小于对角线(workflow minimize_distance 约束)
|
||||
assert dist < max_diagonal * 0.8, (
|
||||
f"Workflow pair {a_id}↔{b_id} distance {dist:.2f}m "
|
||||
f"exceeds 80% of diagonal {max_diagonal:.2f}m"
|
||||
)
|
||||
|
||||
|
||||
class TestPipelineStageIsolation:
|
||||
"""Verify each stage's output format is valid input for the next stage."""
|
||||
|
||||
def test_interpret_output_schema_matches_optimize_input(self):
|
||||
"""constraints from /interpret have all fields /optimize expects."""
|
||||
resp = client.post("/interpret", json={"intents": LLM_INTENTS})
|
||||
data = resp.json()
|
||||
|
||||
for c in data["constraints"]:
|
||||
assert "type" in c
|
||||
assert "rule_name" in c
|
||||
assert "params" in c
|
||||
assert "weight" in c
|
||||
assert c["type"] in ("hard", "soft")
|
||||
|
||||
for edge in data["workflow_edges"]:
|
||||
assert isinstance(edge, list)
|
||||
assert len(edge) == 2
|
||||
|
||||
def test_round_trip_no_data_loss(self):
|
||||
"""Interpret → optimize → check that all device IDs survive the pipeline."""
|
||||
interpret_resp = client.post("/interpret", json={"intents": LLM_INTENTS})
|
||||
interpret_data = interpret_resp.json()
|
||||
|
||||
optimize_resp = client.post("/optimize", json={
|
||||
"devices": PCR_DEVICES,
|
||||
"lab": PCR_LAB,
|
||||
"constraints": interpret_data["constraints"],
|
||||
"workflow_edges": interpret_data["workflow_edges"],
|
||||
"run_de": False,
|
||||
})
|
||||
result_ids = {p["device_id"] for p in optimize_resp.json()["placements"]}
|
||||
input_ids = {d["id"] for d in PCR_DEVICES}
|
||||
assert result_ids == input_ids
|
||||
@@ -1,210 +0,0 @@
|
||||
"""Intent interpreter tests — PCR workflow devices."""
|
||||
import pytest
|
||||
|
||||
from ..intent_interpreter import interpret_intents
|
||||
from ..models import Intent
|
||||
|
||||
|
||||
# --- reachable_by ---
|
||||
|
||||
def test_reachable_by_generates_hard_reachability():
|
||||
intents = [Intent(
|
||||
intent="reachable_by",
|
||||
params={"arm": "arm_slider", "targets": ["opentrons_liquid_handler", "inheco_odtc_96xl"]},
|
||||
description="Robot arm must reach liquid handler and thermal cycler",
|
||||
)]
|
||||
result = interpret_intents(intents)
|
||||
assert len(result.constraints) == 2
|
||||
assert all(c.rule_name == "reachability" for c in result.constraints)
|
||||
assert all(c.type == "hard" for c in result.constraints)
|
||||
assert result.constraints[0].params == {"arm_id": "arm_slider", "target_device_id": "opentrons_liquid_handler"}
|
||||
assert result.constraints[1].params == {"arm_id": "arm_slider", "target_device_id": "inheco_odtc_96xl"}
|
||||
assert len(result.translations) == 1
|
||||
assert len(result.translations[0]["generated_constraints"]) == 2
|
||||
|
||||
|
||||
def test_reachable_by_missing_arm():
|
||||
result = interpret_intents([Intent(intent="reachable_by", params={"targets": ["a"]})])
|
||||
assert len(result.constraints) == 0
|
||||
assert len(result.errors) == 1
|
||||
assert "arm" in result.errors[0].lower()
|
||||
|
||||
|
||||
def test_reachable_by_empty_targets():
|
||||
result = interpret_intents([Intent(intent="reachable_by", params={"arm": "arm_slider", "targets": []})])
|
||||
assert len(result.constraints) == 0
|
||||
assert len(result.errors) == 1
|
||||
assert "targets" in result.errors[0].lower()
|
||||
|
||||
|
||||
# --- close_together ---
|
||||
|
||||
def test_close_together_generates_minimize_distance():
|
||||
intents = [Intent(intent="close_together", params={
|
||||
"devices": ["opentrons_liquid_handler", "inheco_odtc_96xl", "agilent_plateloc"],
|
||||
})]
|
||||
result = interpret_intents(intents)
|
||||
assert len(result.constraints) == 3 # C(3,2) = 3 pairs
|
||||
assert all(c.rule_name == "minimize_distance" for c in result.constraints)
|
||||
assert all(c.type == "soft" for c in result.constraints)
|
||||
|
||||
|
||||
def test_close_together_priority_scales_weight():
|
||||
low = interpret_intents([Intent(intent="close_together", params={"devices": ["a", "b"], "priority": "low"})])
|
||||
high = interpret_intents([Intent(intent="close_together", params={"devices": ["a", "b"], "priority": "high"})])
|
||||
assert high.constraints[0].weight > low.constraints[0].weight
|
||||
assert high.constraints[0].weight == pytest.approx(16.0)
|
||||
assert low.constraints[0].weight == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_close_together_single_device_error():
|
||||
result = interpret_intents([Intent(intent="close_together", params={"devices": ["a"]})])
|
||||
assert len(result.errors) == 1
|
||||
|
||||
|
||||
# --- far_apart ---
|
||||
|
||||
def test_far_apart_generates_maximize_distance():
|
||||
result = interpret_intents([Intent(intent="far_apart", params={
|
||||
"devices": ["inheco_odtc_96xl", "thermo_orbitor_rs2_hotel"],
|
||||
})])
|
||||
assert len(result.constraints) == 1
|
||||
assert result.constraints[0].rule_name == "maximize_distance"
|
||||
|
||||
|
||||
# --- max_distance / min_distance ---
|
||||
|
||||
def test_max_distance_generates_distance_less_than():
|
||||
result = interpret_intents([Intent(intent="max_distance", params={
|
||||
"device_a": "opentrons_liquid_handler", "device_b": "inheco_odtc_96xl", "distance": 1.5,
|
||||
})])
|
||||
assert len(result.constraints) == 1
|
||||
c = result.constraints[0]
|
||||
assert c.rule_name == "distance_less_than"
|
||||
assert c.type == "hard"
|
||||
assert c.params["distance"] == 1.5
|
||||
|
||||
|
||||
def test_min_distance_generates_distance_greater_than():
|
||||
result = interpret_intents([Intent(intent="min_distance", params={
|
||||
"device_a": "inheco_odtc_96xl", "device_b": "thermo_orbitor_rs2_hotel", "distance": 2.0,
|
||||
})])
|
||||
c = result.constraints[0]
|
||||
assert c.rule_name == "distance_greater_than"
|
||||
assert c.type == "hard"
|
||||
assert c.params["distance"] == 2.0
|
||||
|
||||
|
||||
def test_max_distance_zero_is_valid():
|
||||
"""distance=0 is falsy but valid — must not be rejected."""
|
||||
result = interpret_intents([Intent(intent="max_distance", params={
|
||||
"device_a": "a", "device_b": "b", "distance": 0,
|
||||
})])
|
||||
assert len(result.constraints) == 1
|
||||
assert len(result.errors) == 0
|
||||
|
||||
|
||||
def test_max_distance_missing_param():
|
||||
result = interpret_intents([Intent(intent="max_distance", params={"device_a": "a"})])
|
||||
assert len(result.errors) == 1
|
||||
assert len(result.constraints) == 0
|
||||
|
||||
|
||||
# --- orientation ---
|
||||
|
||||
def test_face_outward():
|
||||
result = interpret_intents([Intent(intent="face_outward")])
|
||||
assert result.constraints[0].rule_name == "prefer_orientation_mode"
|
||||
assert result.constraints[0].params["mode"] == "outward"
|
||||
|
||||
|
||||
def test_face_inward():
|
||||
result = interpret_intents([Intent(intent="face_inward")])
|
||||
assert result.constraints[0].params["mode"] == "inward"
|
||||
|
||||
|
||||
def test_align_cardinal():
|
||||
result = interpret_intents([Intent(intent="align_cardinal")])
|
||||
assert result.constraints[0].rule_name == "prefer_aligned"
|
||||
assert result.constraints[0].weight == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_keep_adjacent_generates_minimize_distance():
|
||||
result = interpret_intents([Intent(intent="keep_adjacent", params={
|
||||
"devices": ["opentrons_liquid_handler", "agilent_plateloc"],
|
||||
"priority": "high",
|
||||
})])
|
||||
assert len(result.constraints) == 1
|
||||
assert result.constraints[0].rule_name == "minimize_distance"
|
||||
assert result.constraints[0].weight == pytest.approx(16.0)
|
||||
|
||||
|
||||
# --- min_spacing ---
|
||||
|
||||
def test_min_spacing():
|
||||
result = interpret_intents([Intent(intent="min_spacing", params={"min_gap": 0.3})])
|
||||
c = result.constraints[0]
|
||||
assert c.rule_name == "min_spacing"
|
||||
assert c.type == "hard"
|
||||
assert c.params["min_gap"] == 0.3
|
||||
|
||||
|
||||
# --- workflow_hint (PCR scenario) ---
|
||||
|
||||
def test_workflow_hint_pcr():
|
||||
"""PCR workflow: pipette → thermal cycler → plate sealer → storage."""
|
||||
intents = [Intent(
|
||||
intent="workflow_hint",
|
||||
params={
|
||||
"workflow": "pcr",
|
||||
"devices": [
|
||||
"opentrons_liquid_handler",
|
||||
"inheco_odtc_96xl",
|
||||
"agilent_plateloc",
|
||||
"thermo_orbitor_rs2_hotel",
|
||||
],
|
||||
},
|
||||
)]
|
||||
result = interpret_intents(intents)
|
||||
assert len(result.constraints) == 3 # 4 devices → 3 consecutive pairs
|
||||
assert all(c.rule_name == "minimize_distance" for c in result.constraints)
|
||||
assert len(result.workflow_edges) == 3
|
||||
assert ["opentrons_liquid_handler", "inheco_odtc_96xl"] in result.workflow_edges
|
||||
assert result.translations[0]["confidence"] == "low"
|
||||
|
||||
|
||||
def test_workflow_hint_single_device_error():
|
||||
result = interpret_intents([Intent(intent="workflow_hint", params={"workflow": "test", "devices": ["a"]})])
|
||||
assert len(result.errors) == 1
|
||||
|
||||
|
||||
# --- unknown intent ---
|
||||
|
||||
def test_unknown_intent():
|
||||
result = interpret_intents([Intent(intent="nonexistent")])
|
||||
assert len(result.constraints) == 0
|
||||
assert len(result.errors) == 1
|
||||
assert "nonexistent" in result.errors[0]
|
||||
|
||||
|
||||
# --- multi-intent combination ---
|
||||
|
||||
def test_full_pcr_scenario():
|
||||
"""Arm reachability + close together for full PCR setup."""
|
||||
intents = [
|
||||
Intent(intent="reachable_by", params={
|
||||
"arm": "arm_slider",
|
||||
"targets": [
|
||||
"opentrons_liquid_handler", "inheco_odtc_96xl",
|
||||
"agilent_plateloc", "thermo_orbitor_rs2_hotel",
|
||||
],
|
||||
}),
|
||||
Intent(intent="close_together", params={
|
||||
"devices": ["opentrons_liquid_handler", "inheco_odtc_96xl"],
|
||||
"priority": "high",
|
||||
}),
|
||||
]
|
||||
result = interpret_intents(intents)
|
||||
assert len(result.constraints) == 5 # 4 reachability + 1 minimize_distance
|
||||
assert len(result.translations) == 2
|
||||
assert len(result.errors) == 0
|
||||
@@ -1,134 +0,0 @@
|
||||
"""Tests for /interpret and /interpret/schema API endpoints."""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ..server import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_interpret_reachable_by():
|
||||
resp = client.post("/interpret", json={
|
||||
"intents": [
|
||||
{
|
||||
"intent": "reachable_by",
|
||||
"params": {
|
||||
"arm": "arm_slider",
|
||||
"targets": ["opentrons_liquid_handler", "inheco_odtc_96xl"],
|
||||
},
|
||||
"description": "Arm must reach liquid handler and thermal cycler",
|
||||
}
|
||||
]
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["constraints"]) == 2
|
||||
assert all(c["rule_name"] == "reachability" for c in data["constraints"])
|
||||
assert len(data["translations"]) == 1
|
||||
assert data["translations"][0]["source_intent"] == "reachable_by"
|
||||
assert len(data["errors"]) == 0
|
||||
|
||||
|
||||
def test_interpret_pcr_workflow():
|
||||
"""Full PCR: reachability + workflow_hint + close_together."""
|
||||
resp = client.post("/interpret", json={
|
||||
"intents": [
|
||||
{
|
||||
"intent": "reachable_by",
|
||||
"params": {
|
||||
"arm": "arm_slider",
|
||||
"targets": [
|
||||
"opentrons_liquid_handler",
|
||||
"inheco_odtc_96xl",
|
||||
"agilent_plateloc",
|
||||
"thermo_orbitor_rs2_hotel",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"intent": "workflow_hint",
|
||||
"params": {
|
||||
"workflow": "pcr",
|
||||
"devices": [
|
||||
"opentrons_liquid_handler",
|
||||
"inheco_odtc_96xl",
|
||||
"agilent_plateloc",
|
||||
"thermo_orbitor_rs2_hotel",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"intent": "close_together",
|
||||
"params": {
|
||||
"devices": ["opentrons_liquid_handler", "inheco_odtc_96xl"],
|
||||
"priority": "high",
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# 4 reachability + 3 workflow + 1 close = 8
|
||||
assert len(data["constraints"]) == 8
|
||||
assert len(data["workflow_edges"]) == 3
|
||||
assert len(data["translations"]) == 3
|
||||
assert len(data["errors"]) == 0
|
||||
|
||||
|
||||
def test_interpret_returns_errors_for_bad_intents():
|
||||
resp = client.post("/interpret", json={
|
||||
"intents": [
|
||||
{"intent": "reachable_by", "params": {}},
|
||||
{"intent": "nonexistent_intent"},
|
||||
]
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["errors"]) == 2
|
||||
assert len(data["constraints"]) == 0
|
||||
|
||||
|
||||
def test_interpret_empty_intents():
|
||||
resp = client.post("/interpret", json={"intents": []})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["constraints"] == []
|
||||
assert data["translations"] == []
|
||||
assert data["errors"] == []
|
||||
|
||||
|
||||
def test_interpret_schema_returns_all_intents():
|
||||
resp = client.get("/interpret/schema")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
intents = data["intents"]
|
||||
expected = {
|
||||
"reachable_by", "close_together", "far_apart", "keep_adjacent",
|
||||
"max_distance", "min_distance", "min_spacing",
|
||||
"workflow_hint", "face_outward", "face_inward", "align_cardinal",
|
||||
}
|
||||
assert set(intents.keys()) == expected
|
||||
|
||||
|
||||
def test_interpret_constraints_passable_to_optimize():
|
||||
"""Constraints from /interpret should be directly usable in /optimize."""
|
||||
# Step 1: interpret
|
||||
interpret_resp = client.post("/interpret", json={
|
||||
"intents": [
|
||||
{"intent": "close_together", "params": {"devices": ["dev_a", "dev_b"]}},
|
||||
]
|
||||
})
|
||||
constraints = interpret_resp.json()["constraints"]
|
||||
|
||||
# Step 2: pass to optimize (verify it accepts the format)
|
||||
optimize_resp = client.post("/optimize", json={
|
||||
"devices": [
|
||||
{"id": "dev_a", "name": "Device A", "size": [0.5, 0.4]},
|
||||
{"id": "dev_b", "name": "Device B", "size": [0.5, 0.4]},
|
||||
],
|
||||
"lab": {"width": 4.0, "depth": 3.0},
|
||||
"constraints": constraints,
|
||||
"run_de": False,
|
||||
})
|
||||
assert optimize_resp.status_code == 200
|
||||
assert len(optimize_resp.json()["placements"]) == 2
|
||||
@@ -1,277 +0,0 @@
|
||||
"""LLM 技能文档测试:用真实 LLM 验证模糊用户输入 → 结构化意图的翻译质量。
|
||||
|
||||
需要 ANTHROPIC_API_KEY 环境变量。无 key 时自动跳过。
|
||||
测试覆盖:设备名模糊匹配、工作流顺序推理、约束类型选择、JSON 格式正确性。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
HAS_API_KEY = bool(os.environ.get("ANTHROPIC_API_KEY"))
|
||||
pytestmark = pytest.mark.skipif(not HAS_API_KEY, reason="ANTHROPIC_API_KEY not set")
|
||||
|
||||
# 读取技能文档
|
||||
_SKILL_DOC_PATH = os.path.join(
|
||||
os.path.dirname(__file__), "..", "llm_skill", "layout_intent_translator.md"
|
||||
)
|
||||
|
||||
# PCR 场景设备列表(模拟用户场景中已有的设备)
|
||||
SCENE_DEVICE_LIST = """\
|
||||
Devices in scene:
|
||||
- thermo_orbitor_rs2_hotel: Thermo Orbitor RS2 Hotel (type: static, bbox: 0.68×0.52m)
|
||||
- arm_slider: Arm Slider (type: articulation, bbox: 1.20×0.30m)
|
||||
- opentrons_liquid_handler: Opentrons Liquid Handler (type: static, bbox: 0.65×0.60m)
|
||||
- agilent_plateloc: Agilent PlateLoc (type: static, bbox: 0.35×0.40m)
|
||||
- inheco_odtc_96xl: Inheco ODTC 96XL (type: static, bbox: 0.30×0.35m)
|
||||
"""
|
||||
|
||||
VALID_DEVICE_IDS = {
|
||||
"thermo_orbitor_rs2_hotel",
|
||||
"arm_slider",
|
||||
"opentrons_liquid_handler",
|
||||
"agilent_plateloc",
|
||||
"inheco_odtc_96xl",
|
||||
}
|
||||
|
||||
VALID_INTENT_TYPES = {
|
||||
"reachable_by", "close_together", "far_apart", "max_distance",
|
||||
"min_distance", "min_spacing", "workflow_hint",
|
||||
"face_outward", "face_inward", "align_cardinal",
|
||||
}
|
||||
|
||||
|
||||
def _call_llm(user_message: str) -> dict:
|
||||
"""调用 LLM,使用技能文档作为 system prompt,返回解析后的 JSON。"""
|
||||
import anthropic
|
||||
|
||||
with open(_SKILL_DOC_PATH) as f:
|
||||
skill_doc = f.read()
|
||||
|
||||
client = anthropic.Anthropic()
|
||||
response = client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=2000,
|
||||
system=skill_doc,
|
||||
messages=[
|
||||
{"role": "user", "content": f"{SCENE_DEVICE_LIST}\n\n{user_message}"},
|
||||
],
|
||||
)
|
||||
|
||||
# 从 response 中提取 JSON
|
||||
text = response.content[0].text
|
||||
# LLM 可能返回 ```json ... ``` 包裹的 JSON
|
||||
if "```json" in text:
|
||||
text = text.split("```json")[1].split("```")[0]
|
||||
elif "```" in text:
|
||||
text = text.split("```")[1].split("```")[0]
|
||||
|
||||
return json.loads(text.strip())
|
||||
|
||||
|
||||
def _extract_all_device_ids(intents: list[dict]) -> set[str]:
|
||||
"""从意图列表中提取所有引用的设备 ID。"""
|
||||
ids = set()
|
||||
for intent in intents:
|
||||
params = intent.get("params", {})
|
||||
if "arm" in params:
|
||||
ids.add(params["arm"])
|
||||
for key in ("targets", "devices"):
|
||||
if key in params:
|
||||
ids.update(params[key])
|
||||
for key in ("device_a", "device_b"):
|
||||
if key in params:
|
||||
ids.add(params[key])
|
||||
return ids
|
||||
|
||||
|
||||
class TestLLMFuzzyDeviceResolution:
|
||||
"""测试 LLM 能否将模糊设备名映射到精确 ID。"""
|
||||
|
||||
def test_pcr_machine_resolves_to_inheco(self):
|
||||
"""'PCR machine' 应解析为 inheco_odtc_96xl。"""
|
||||
result = _call_llm(
|
||||
"Keep the PCR machine close to the plate sealer"
|
||||
)
|
||||
intents = result["intents"]
|
||||
all_ids = _extract_all_device_ids(intents)
|
||||
assert "inheco_odtc_96xl" in all_ids, f"Expected inheco_odtc_96xl in {all_ids}"
|
||||
assert "agilent_plateloc" in all_ids, f"Expected agilent_plateloc in {all_ids}"
|
||||
|
||||
def test_robot_resolves_to_articulation_type(self):
|
||||
"""'the robot' / 'robot arm' 应解析为 arm_slider(唯一 articulation 类型)。"""
|
||||
result = _call_llm(
|
||||
"The robot should be able to reach the liquid handler and the storage hotel"
|
||||
)
|
||||
intents = result["intents"]
|
||||
all_ids = _extract_all_device_ids(intents)
|
||||
assert "arm_slider" in all_ids, f"Expected arm_slider in {all_ids}"
|
||||
assert "opentrons_liquid_handler" in all_ids
|
||||
assert "thermo_orbitor_rs2_hotel" in all_ids
|
||||
|
||||
def test_all_resolved_ids_are_valid(self):
|
||||
"""LLM 输出的所有设备 ID 必须来自场景设备列表。"""
|
||||
result = _call_llm(
|
||||
"Take plate from hotel, prepare sample in the pipetting robot, "
|
||||
"seal it, then run thermal cycling. The arm handles all transfers."
|
||||
)
|
||||
intents = result["intents"]
|
||||
all_ids = _extract_all_device_ids(intents)
|
||||
invalid = all_ids - VALID_DEVICE_IDS
|
||||
assert not invalid, f"LLM produced invalid device IDs: {invalid}"
|
||||
|
||||
|
||||
class TestLLMWorkflowInterpretation:
|
||||
"""测试 LLM 对工作流描述的理解和翻译。"""
|
||||
|
||||
def test_pcr_workflow_full(self):
|
||||
"""完整 PCR 工作流描述应生成 reachable_by + workflow_hint + close_together。"""
|
||||
result = _call_llm(
|
||||
"I need to set up a PCR workflow: take plate from the hotel, "
|
||||
"prepare the sample in the liquid handler, seal the plate, "
|
||||
"then run the thermal cycler. The robot arm handles all plate transfers. "
|
||||
"Keep the liquid handler and sealer close together."
|
||||
)
|
||||
intents = result["intents"]
|
||||
intent_types = {i["intent"] for i in intents}
|
||||
|
||||
# 应包含核心意图类型
|
||||
assert "reachable_by" in intent_types, f"Missing reachable_by in {intent_types}"
|
||||
assert "workflow_hint" in intent_types, f"Missing workflow_hint in {intent_types}"
|
||||
|
||||
# reachable_by 应包含所有工作流设备作为 targets
|
||||
reach_intents = [i for i in intents if i["intent"] == "reachable_by"]
|
||||
assert len(reach_intents) >= 1
|
||||
reach_targets = set()
|
||||
for ri in reach_intents:
|
||||
reach_targets.update(ri["params"].get("targets", []))
|
||||
# 至少液体处理器和热循环仪应在可达范围内
|
||||
assert "opentrons_liquid_handler" in reach_targets
|
||||
assert "inheco_odtc_96xl" in reach_targets
|
||||
|
||||
def test_workflow_device_order(self):
|
||||
"""workflow_hint 的设备顺序应反映工作流步骤。"""
|
||||
result = _call_llm(
|
||||
"PCR process: first the hotel dispenses a plate, then the opentrons "
|
||||
"prepares the sample, next the plateloc seals it, finally the thermal "
|
||||
"cycler runs PCR. Generate a workflow hint."
|
||||
)
|
||||
intents = result["intents"]
|
||||
wf_intents = [i for i in intents if i["intent"] == "workflow_hint"]
|
||||
assert len(wf_intents) >= 1, f"No workflow_hint found in {[i['intent'] for i in intents]}"
|
||||
|
||||
devices = wf_intents[0]["params"]["devices"]
|
||||
# 验证顺序:hotel → liquid_handler → plateloc → thermal_cycler
|
||||
hotel_idx = devices.index("thermo_orbitor_rs2_hotel")
|
||||
lh_idx = devices.index("opentrons_liquid_handler")
|
||||
seal_idx = devices.index("agilent_plateloc")
|
||||
tc_idx = devices.index("inheco_odtc_96xl")
|
||||
assert hotel_idx < lh_idx < seal_idx < tc_idx, (
|
||||
f"Wrong workflow order: {devices}"
|
||||
)
|
||||
|
||||
|
||||
class TestLLMOutputFormat:
|
||||
"""测试 LLM 输出格式的正确性。"""
|
||||
|
||||
def test_output_has_intents_array(self):
|
||||
"""输出必须有 intents 数组。"""
|
||||
result = _call_llm("Keep all devices at least 30cm apart")
|
||||
assert "intents" in result
|
||||
assert isinstance(result["intents"], list)
|
||||
assert len(result["intents"]) > 0
|
||||
|
||||
def test_each_intent_has_required_fields(self):
|
||||
"""每个意图必须有 intent、params、description。"""
|
||||
result = _call_llm(
|
||||
"The robot arm should reach the liquid handler. "
|
||||
"Keep the thermal cycler away from the plate hotel."
|
||||
)
|
||||
for intent in result["intents"]:
|
||||
assert "intent" in intent, f"Missing 'intent' field: {intent}"
|
||||
assert "params" in intent, f"Missing 'params' field: {intent}"
|
||||
assert "description" in intent, f"Missing 'description' field: {intent}"
|
||||
|
||||
def test_intent_types_are_valid(self):
|
||||
"""所有意图类型必须是已知类型。"""
|
||||
result = _call_llm(
|
||||
"Set up a compact PCR line: hotel → liquid handler → sealer → thermal cycler. "
|
||||
"Robot arm handles transfers. Align everything neatly."
|
||||
)
|
||||
for intent in result["intents"]:
|
||||
assert intent["intent"] in VALID_INTENT_TYPES, (
|
||||
f"Unknown intent type: {intent['intent']}"
|
||||
)
|
||||
|
||||
|
||||
class TestLLMInterpretThenOptimize:
|
||||
"""端到端:LLM 翻译 → /interpret → /optimize → 验证布局。"""
|
||||
|
||||
def test_llm_output_accepted_by_interpret_endpoint(self):
|
||||
"""LLM 输出应能直接被 /interpret 端点接受。"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ..server import app
|
||||
|
||||
test_client = TestClient(app)
|
||||
|
||||
llm_result = _call_llm(
|
||||
"Take plate from hotel, prepare sample in opentrons, "
|
||||
"seal plate then pcr cycle, arm_slider handles all transfers. "
|
||||
"Keep liquid handler and sealer close."
|
||||
)
|
||||
|
||||
# /interpret 应接受 LLM 输出
|
||||
resp = test_client.post("/interpret", json=llm_result)
|
||||
assert resp.status_code == 200, f"Interpret failed: {resp.text}"
|
||||
data = resp.json()
|
||||
assert len(data["constraints"]) > 0, "No constraints generated"
|
||||
assert len(data["errors"]) == 0, f"Interpretation errors: {data['errors']}"
|
||||
|
||||
def test_full_pipeline_llm_to_placement(self):
|
||||
"""LLM 翻译 → interpret → optimize → 所有设备有 placement。"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ..server import app
|
||||
|
||||
test_client = TestClient(app)
|
||||
|
||||
# Stage 1: LLM 翻译
|
||||
llm_result = _call_llm(
|
||||
"I want a PCR workflow lab. Take plate from the hotel, pipette in the "
|
||||
"liquid handler, seal with the plateloc, then thermal cycle. "
|
||||
"The robot arm does all transfers between devices. "
|
||||
"Minimum 15cm gap between everything."
|
||||
)
|
||||
|
||||
# Stage 2: interpret
|
||||
interpret_resp = test_client.post("/interpret", json=llm_result)
|
||||
assert interpret_resp.status_code == 200
|
||||
interpret_data = interpret_resp.json()
|
||||
assert len(interpret_data["errors"]) == 0
|
||||
|
||||
# Stage 3: optimize
|
||||
pcr_devices = [
|
||||
{"id": "thermo_orbitor_rs2_hotel", "name": "Plate Hotel", "device_type": "static"},
|
||||
{"id": "arm_slider", "name": "Robot Arm", "device_type": "articulation"},
|
||||
{"id": "opentrons_liquid_handler", "name": "Liquid Handler", "device_type": "static"},
|
||||
{"id": "agilent_plateloc", "name": "Plate Sealer", "device_type": "static"},
|
||||
{"id": "inheco_odtc_96xl", "name": "Thermal Cycler", "device_type": "static"},
|
||||
]
|
||||
optimize_resp = test_client.post("/optimize", json={
|
||||
"devices": pcr_devices,
|
||||
"lab": {"width": 6.0, "depth": 4.0},
|
||||
"constraints": interpret_data["constraints"],
|
||||
"workflow_edges": interpret_data.get("workflow_edges", []),
|
||||
"run_de": True,
|
||||
"maxiter": 50,
|
||||
"seed": 42,
|
||||
})
|
||||
assert optimize_resp.status_code == 200
|
||||
data = optimize_resp.json()
|
||||
|
||||
# Stage 4: 验证所有设备都有 placement
|
||||
placed_ids = {p["device_id"] for p in data["placements"]}
|
||||
expected_ids = {d["id"] for d in pcr_devices}
|
||||
assert placed_ids == expected_ids
|
||||
assert data["success"] is True
|
||||
@@ -1,138 +0,0 @@
|
||||
"""MockCollisionChecker 和 MockReachabilityChecker 测试。"""
|
||||
|
||||
import math
|
||||
|
||||
from ..mock_checkers import MockCollisionChecker, MockReachabilityChecker
|
||||
|
||||
|
||||
class TestMockCollisionChecker:
|
||||
def setup_method(self):
|
||||
self.checker = MockCollisionChecker()
|
||||
|
||||
def test_no_collision_far_apart(self):
|
||||
"""两个设备距离足够远,不碰撞。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (0.5, 0.5), "pos": (1.0, 1.0, 0.0)},
|
||||
{"id": "b", "bbox": (0.5, 0.5), "pos": (3.0, 3.0, 0.0)},
|
||||
]
|
||||
assert self.checker.check(placements) == []
|
||||
|
||||
def test_collision_overlapping(self):
|
||||
"""两个设备重叠,应检测到碰撞。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (1.0, 1.0), "pos": (1.0, 1.0, 0.0)},
|
||||
{"id": "b", "bbox": (1.0, 1.0), "pos": (1.5, 1.0, 0.0)},
|
||||
]
|
||||
collisions = self.checker.check(placements)
|
||||
assert ("a", "b") in collisions
|
||||
|
||||
def test_collision_touching_edges(self):
|
||||
"""两设备恰好边缘接触,不算碰撞(< 而非 <=)。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (1.0, 1.0), "pos": (0.5, 0.5, 0.0)},
|
||||
{"id": "b", "bbox": (1.0, 1.0), "pos": (1.5, 0.5, 0.0)},
|
||||
]
|
||||
collisions = self.checker.check(placements)
|
||||
assert collisions == []
|
||||
|
||||
def test_collision_with_rotation(self):
|
||||
"""旋转后的设备 OBB 可能导致碰撞。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (1.0, 0.2), "pos": (1.0, 1.0, math.pi / 4)},
|
||||
{"id": "b", "bbox": (0.5, 0.5), "pos": (1.4, 1.0, 0.0)}, # closer: OBB overlap
|
||||
]
|
||||
collisions = self.checker.check(placements)
|
||||
assert ("a", "b") in collisions
|
||||
|
||||
def test_no_collision_with_rotation(self):
|
||||
"""旋转后仍不碰撞。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (1.0, 0.2), "pos": (1.0, 1.0, math.pi / 4)},
|
||||
{"id": "b", "bbox": (0.5, 0.5), "pos": (2.0, 1.0, 0.0)},
|
||||
]
|
||||
collisions = self.checker.check(placements)
|
||||
assert collisions == []
|
||||
|
||||
def test_check_bounds_within(self):
|
||||
"""设备在边界内。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (0.5, 0.5), "pos": (1.0, 1.0, 0.0)},
|
||||
]
|
||||
assert self.checker.check_bounds(placements, 5.0, 5.0) == []
|
||||
|
||||
def test_check_bounds_outside(self):
|
||||
"""设备超出边界。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (1.0, 1.0), "pos": (0.2, 0.2, 0.0)},
|
||||
]
|
||||
oob = self.checker.check_bounds(placements, 5.0, 5.0)
|
||||
assert "a" in oob
|
||||
|
||||
def test_three_devices_multiple_collisions(self):
|
||||
"""三个设备,两两碰撞。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (1.0, 1.0), "pos": (1.0, 1.0, 0.0)},
|
||||
{"id": "b", "bbox": (1.0, 1.0), "pos": (1.3, 1.0, 0.0)},
|
||||
{"id": "c", "bbox": (1.0, 1.0), "pos": (1.6, 1.0, 0.0)},
|
||||
]
|
||||
collisions = self.checker.check(placements)
|
||||
assert ("a", "b") in collisions
|
||||
assert ("b", "c") in collisions
|
||||
|
||||
|
||||
def test_obb_collision_rotated_no_false_positive():
|
||||
"""A rotated narrow device should NOT collide with a nearby device
|
||||
that the old AABB method would have flagged as colliding.
|
||||
|
||||
Old AABB expands footprint; OBB is precise.
|
||||
"""
|
||||
checker = MockCollisionChecker()
|
||||
# Narrow device (2.0 x 0.5) rotated 45°:
|
||||
# AABB would be ~1.77 x 1.77, OBB is the actual narrow rectangle
|
||||
placements = [
|
||||
{"id": "narrow", "bbox": (2.0, 0.5), "pos": (3.0, 3.0, math.pi / 4)},
|
||||
{"id": "nearby", "bbox": (0.5, 0.5), "pos": (4.5, 3.0, 0.0)},
|
||||
]
|
||||
collisions = checker.check(placements)
|
||||
# With OBB: no collision (the narrow rotated box doesn't reach)
|
||||
assert ("narrow", "nearby") not in collisions and ("nearby", "narrow") not in collisions
|
||||
|
||||
|
||||
class TestMockReachabilityChecker:
|
||||
def setup_method(self):
|
||||
self.checker = MockReachabilityChecker()
|
||||
|
||||
def test_reachable_within_radius(self):
|
||||
"""目标在臂展半径内。"""
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
target = {"x": 0.5, "y": 0.5, "z": 0.0}
|
||||
assert self.checker.is_reachable("elite_cs66", arm_pose, target)
|
||||
|
||||
def test_not_reachable_outside_radius(self):
|
||||
"""目标超出臂展半径。"""
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
target = {"x": 2.0, "y": 2.0, "z": 0.0}
|
||||
assert not self.checker.is_reachable("elite_cs66", arm_pose, target)
|
||||
|
||||
def test_reachable_at_boundary(self):
|
||||
"""目标恰好在臂展边界上(应可达)。"""
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
target = {"x": 0.914, "y": 0.0, "z": 0.0}
|
||||
assert self.checker.is_reachable("elite_cs66", arm_pose, target)
|
||||
|
||||
def test_unknown_arm_uses_default(self):
|
||||
"""未知型号使用 1.0m 回退臂展(realistic lab-scale default)。"""
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
# Within 1.0m fallback reach
|
||||
target_near = {"x": 0.8, "y": 0.0, "z": 0.0}
|
||||
assert self.checker.is_reachable("unknown_arm", arm_pose, target_near)
|
||||
# Beyond 1.0m fallback reach
|
||||
target_far = {"x": 1.5, "y": 0.0, "z": 0.0}
|
||||
assert not self.checker.is_reachable("unknown_arm", arm_pose, target_far)
|
||||
|
||||
def test_custom_arm_reach(self):
|
||||
"""自定义臂展参数。"""
|
||||
checker = MockReachabilityChecker(arm_reach={"custom_arm": 1.5})
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
target = {"x": 1.4, "y": 0.0, "z": 0.0}
|
||||
assert checker.is_reachable("custom_arm", arm_pose, target)
|
||||
@@ -1,156 +0,0 @@
|
||||
"""Tests for OBB (Oriented Bounding Box) geometry utilities."""
|
||||
import math
|
||||
import pytest
|
||||
from ..obb import obb_corners, obb_overlap, obb_min_distance, segment_obb_intersection_length
|
||||
|
||||
|
||||
class TestObbCorners:
|
||||
"""obb_corners(cx, cy, w, h, theta) → 4 corner points of the rotated rectangle."""
|
||||
|
||||
def test_no_rotation(self):
|
||||
"""Axis-aligned box at origin: corners at ±half extents."""
|
||||
corners = obb_corners(0, 0, 2.0, 1.0, 0.0)
|
||||
assert len(corners) == 4
|
||||
xs = sorted(c[0] for c in corners)
|
||||
ys = sorted(c[1] for c in corners)
|
||||
assert xs == pytest.approx([-1.0, -1.0, 1.0, 1.0])
|
||||
assert ys == pytest.approx([-0.5, -0.5, 0.5, 0.5])
|
||||
|
||||
def test_90_degree_rotation(self):
|
||||
"""90° rotation swaps width and height extents."""
|
||||
corners = obb_corners(0, 0, 2.0, 1.0, math.pi / 2)
|
||||
xs = sorted(c[0] for c in corners)
|
||||
ys = sorted(c[1] for c in corners)
|
||||
assert xs == pytest.approx([-0.5, -0.5, 0.5, 0.5])
|
||||
assert ys == pytest.approx([-1.0, -1.0, 1.0, 1.0])
|
||||
|
||||
def test_offset_center(self):
|
||||
"""Corners shift by (cx, cy)."""
|
||||
corners = obb_corners(3.0, 2.0, 2.0, 1.0, 0.0)
|
||||
xs = sorted(c[0] for c in corners)
|
||||
ys = sorted(c[1] for c in corners)
|
||||
assert xs == pytest.approx([2.0, 2.0, 4.0, 4.0])
|
||||
assert ys == pytest.approx([1.5, 1.5, 2.5, 2.5])
|
||||
|
||||
def test_45_degree_rotation(self):
|
||||
"""45° rotation: corners on diagonals."""
|
||||
corners = obb_corners(0, 0, 2.0, 2.0, math.pi / 4)
|
||||
for cx, cy in corners:
|
||||
dist = math.sqrt(cx**2 + cy**2)
|
||||
assert dist == pytest.approx(math.sqrt(2), abs=1e-9)
|
||||
|
||||
|
||||
class TestObbOverlap:
|
||||
"""obb_overlap(corners_a, corners_b) → True if the two OBBs overlap."""
|
||||
|
||||
def test_separated_boxes(self):
|
||||
"""Two boxes far apart: no overlap."""
|
||||
a = obb_corners(0, 0, 1.0, 1.0, 0.0)
|
||||
b = obb_corners(5, 0, 1.0, 1.0, 0.0)
|
||||
assert obb_overlap(a, b) is False
|
||||
|
||||
def test_overlapping_boxes(self):
|
||||
"""Two boxes sharing space: overlap."""
|
||||
a = obb_corners(0, 0, 2.0, 2.0, 0.0)
|
||||
b = obb_corners(1, 0, 2.0, 2.0, 0.0)
|
||||
assert obb_overlap(a, b) is True
|
||||
|
||||
def test_touching_edges_no_overlap(self):
|
||||
"""Boxes touching at edge: no overlap (strict <, not <=)."""
|
||||
a = obb_corners(0, 0, 2.0, 2.0, 0.0)
|
||||
b = obb_corners(2.0, 0, 2.0, 2.0, 0.0)
|
||||
assert obb_overlap(a, b) is False
|
||||
|
||||
def test_rotated_overlap(self):
|
||||
"""One box rotated 45°, overlapping the other."""
|
||||
a = obb_corners(0, 0, 2.0, 2.0, 0.0)
|
||||
b = obb_corners(1.0, 1.0, 2.0, 2.0, math.pi / 4)
|
||||
assert obb_overlap(a, b) is True
|
||||
|
||||
def test_rotated_no_overlap(self):
|
||||
"""One box rotated 45°, separated from the other."""
|
||||
a = obb_corners(0, 0, 1.0, 1.0, 0.0)
|
||||
b = obb_corners(3, 0, 1.0, 1.0, math.pi / 4)
|
||||
assert obb_overlap(a, b) is False
|
||||
|
||||
def test_identical_boxes(self):
|
||||
"""Same position and size: overlap."""
|
||||
a = obb_corners(1, 1, 1.0, 1.0, 0.0)
|
||||
b = obb_corners(1, 1, 1.0, 1.0, 0.0)
|
||||
assert obb_overlap(a, b) is True
|
||||
|
||||
|
||||
class TestObbMinDistance:
|
||||
"""obb_min_distance(corners_a, corners_b) → minimum edge-to-edge distance."""
|
||||
|
||||
def test_overlapping_returns_zero(self):
|
||||
"""Overlapping boxes: distance = 0."""
|
||||
a = obb_corners(0, 0, 2.0, 2.0, 0.0)
|
||||
b = obb_corners(1, 0, 2.0, 2.0, 0.0)
|
||||
assert obb_min_distance(a, b) == pytest.approx(0.0)
|
||||
|
||||
def test_separated_axis_aligned(self):
|
||||
"""Two axis-aligned boxes with 2m gap."""
|
||||
a = obb_corners(0, 0, 2.0, 2.0, 0.0) # edges at x=±1
|
||||
b = obb_corners(4, 0, 2.0, 2.0, 0.0) # edges at x=3,5
|
||||
# Gap = 3 - 1 = 2.0
|
||||
assert obb_min_distance(a, b) == pytest.approx(2.0)
|
||||
|
||||
def test_diagonal_separation(self):
|
||||
"""Boxes separated diagonally: distance to nearest corner."""
|
||||
a = obb_corners(0, 0, 2.0, 2.0, 0.0) # corners at (±1, ±1)
|
||||
b = obb_corners(4, 4, 2.0, 2.0, 0.0) # corners at (3..5, 3..5)
|
||||
# Nearest corners: (1,1) to (3,3) → sqrt(8) ≈ 2.828
|
||||
assert obb_min_distance(a, b) == pytest.approx(math.sqrt(8), abs=0.01)
|
||||
|
||||
def test_rotated_separation(self):
|
||||
"""One rotated box separated from axis-aligned box."""
|
||||
a = obb_corners(0, 0, 1.0, 1.0, 0.0)
|
||||
b = obb_corners(3, 0, 1.0, 1.0, math.pi / 4)
|
||||
dist = obb_min_distance(a, b)
|
||||
assert dist > 0
|
||||
|
||||
def test_touching_returns_zero(self):
|
||||
"""Touching edges: distance = 0."""
|
||||
a = obb_corners(0, 0, 2.0, 2.0, 0.0)
|
||||
b = obb_corners(2.0, 0, 2.0, 2.0, 0.0)
|
||||
assert obb_min_distance(a, b) == pytest.approx(0.0)
|
||||
|
||||
|
||||
class TestSegmentOBBIntersectionLength:
|
||||
"""segment_obb_intersection_length: Cyrus-Beck clipping."""
|
||||
|
||||
def test_segment_fully_outside(self):
|
||||
corners = obb_corners(0, 0, 2, 2, 0)
|
||||
length = segment_obb_intersection_length((-5, 3), (5, 3), corners)
|
||||
assert length == 0.0
|
||||
|
||||
def test_segment_fully_inside(self):
|
||||
corners = obb_corners(0, 0, 4, 4, 0)
|
||||
length = segment_obb_intersection_length((-0.5, 0), (0.5, 0), corners)
|
||||
assert abs(length - 1.0) < 1e-6
|
||||
|
||||
def test_segment_crosses_through(self):
|
||||
corners = obb_corners(0, 0, 2, 2, 0)
|
||||
length = segment_obb_intersection_length((-5, 0), (5, 0), corners)
|
||||
assert abs(length - 2.0) < 1e-6
|
||||
|
||||
def test_segment_partial_overlap(self):
|
||||
corners = obb_corners(0, 0, 2, 2, 0)
|
||||
length = segment_obb_intersection_length((0, 0), (5, 0), corners)
|
||||
assert abs(length - 1.0) < 1e-6
|
||||
|
||||
def test_rotated_obb(self):
|
||||
corners = obb_corners(0, 0, 2, 2, math.pi / 4)
|
||||
length = segment_obb_intersection_length((-3, 0), (3, 0), corners)
|
||||
expected = 2 * math.sqrt(2)
|
||||
assert abs(length - expected) < 1e-4
|
||||
|
||||
def test_zero_length_segment(self):
|
||||
corners = obb_corners(0, 0, 2, 2, 0)
|
||||
assert segment_obb_intersection_length((0, 0), (0, 0), corners) == 0.0
|
||||
|
||||
def test_parallel_outside(self):
|
||||
corners = obb_corners(0, 0, 2, 2, 0)
|
||||
length = segment_obb_intersection_length((-5, 2), (5, 2), corners)
|
||||
assert length == 0.0
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,430 +0,0 @@
|
||||
"""MoveItCollisionChecker 和 IKFastReachabilityChecker 测试。
|
||||
|
||||
使用 unittest.mock 模拟 MoveIt2 实例,验证适配器逻辑,
|
||||
无需 ROS2 / MoveIt2 运行环境。
|
||||
"""
|
||||
|
||||
import math
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from ..ros_checkers import (
|
||||
IKFastReachabilityChecker,
|
||||
MoveItCollisionChecker,
|
||||
_transform_to_arm_frame,
|
||||
_yaw_to_quat,
|
||||
_yaw_to_rotation_matrix,
|
||||
create_checkers,
|
||||
)
|
||||
|
||||
|
||||
# ---------- 辅助函数测试 ----------
|
||||
|
||||
|
||||
class TestYawToQuat:
|
||||
def test_zero_rotation(self):
|
||||
"""零旋转 → 单位四元数。"""
|
||||
q = _yaw_to_quat(0.0)
|
||||
assert q == pytest.approx((0.0, 0.0, 0.0, 1.0))
|
||||
|
||||
def test_90_degrees(self):
|
||||
"""90° → (0, 0, sin(π/4), cos(π/4))。"""
|
||||
q = _yaw_to_quat(math.pi / 2)
|
||||
expected = (0.0, 0.0, math.sin(math.pi / 4), math.cos(math.pi / 4))
|
||||
assert q == pytest.approx(expected)
|
||||
|
||||
def test_180_degrees(self):
|
||||
"""180° → (0, 0, 1, 0)。"""
|
||||
q = _yaw_to_quat(math.pi)
|
||||
assert q == pytest.approx((0.0, 0.0, 1.0, 0.0), abs=1e-10)
|
||||
|
||||
|
||||
class TestTransformToArmFrame:
|
||||
def test_identity_transform(self):
|
||||
"""臂在原点无旋转,目标在 (1, 0, 0.5)。"""
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
target = {"x": 1.0, "y": 0.0, "z": 0.5}
|
||||
local = _transform_to_arm_frame(arm_pose, target)
|
||||
assert local == pytest.approx((1.0, 0.0, 0.5))
|
||||
|
||||
def test_translation_only(self):
|
||||
"""臂在 (2, 3) 无旋转,目标在 (3, 4, 0)。"""
|
||||
arm_pose = {"x": 2.0, "y": 3.0, "theta": 0.0}
|
||||
target = {"x": 3.0, "y": 4.0, "z": 0.0}
|
||||
local = _transform_to_arm_frame(arm_pose, target)
|
||||
assert local == pytest.approx((1.0, 1.0, 0.0))
|
||||
|
||||
def test_rotation_90(self):
|
||||
"""臂旋转 90°,目标在臂前方。"""
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": math.pi / 2}
|
||||
target = {"x": 0.0, "y": 1.0, "z": 0.0}
|
||||
local = _transform_to_arm_frame(arm_pose, target)
|
||||
# 世界 Y+ 在臂坐标系中变成 X+
|
||||
assert local[0] == pytest.approx(1.0, abs=1e-10)
|
||||
assert local[1] == pytest.approx(0.0, abs=1e-10)
|
||||
|
||||
|
||||
class TestYawToRotationMatrix:
|
||||
def test_identity(self):
|
||||
"""零旋转 → 单位矩阵。"""
|
||||
R = _yaw_to_rotation_matrix(0.0)
|
||||
np.testing.assert_allclose(R, np.eye(3), atol=1e-10)
|
||||
|
||||
def test_90_degrees(self):
|
||||
"""90° 旋转矩阵。"""
|
||||
R = _yaw_to_rotation_matrix(math.pi / 2)
|
||||
expected = np.array([
|
||||
[0.0, -1.0, 0.0],
|
||||
[1.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0],
|
||||
])
|
||||
np.testing.assert_allclose(R, expected, atol=1e-10)
|
||||
|
||||
|
||||
# ---------- MoveItCollisionChecker 测试 ----------
|
||||
|
||||
|
||||
class TestMoveItCollisionChecker:
|
||||
def setup_method(self):
|
||||
self.moveit2 = MagicMock()
|
||||
# 禁用 FCL,使用 OBB 回退(测试环境无需 python-fcl)
|
||||
self.checker = MoveItCollisionChecker(
|
||||
self.moveit2, sync_to_scene=True,
|
||||
)
|
||||
self.checker._fcl_available = False
|
||||
|
||||
def test_no_collision_far_apart(self):
|
||||
"""两个设备距离足够远,不碰撞。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (0.5, 0.5), "pos": (1.0, 1.0, 0.0)},
|
||||
{"id": "b", "bbox": (0.5, 0.5), "pos": (3.0, 3.0, 0.0)},
|
||||
]
|
||||
assert self.checker.check(placements) == []
|
||||
|
||||
def test_collision_overlapping(self):
|
||||
"""两个设备重叠,应检测到碰撞。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (1.0, 1.0), "pos": (1.0, 1.0, 0.0)},
|
||||
{"id": "b", "bbox": (1.0, 1.0), "pos": (1.5, 1.0, 0.0)},
|
||||
]
|
||||
collisions = self.checker.check(placements)
|
||||
assert ("a", "b") in collisions
|
||||
|
||||
def test_collision_with_rotation(self):
|
||||
"""旋转后的碰撞检测。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (1.0, 0.2), "pos": (1.0, 1.0, math.pi / 4)},
|
||||
{"id": "b", "bbox": (0.5, 0.5), "pos": (1.4, 1.0, 0.0)},
|
||||
]
|
||||
collisions = self.checker.check(placements)
|
||||
assert ("a", "b") in collisions
|
||||
|
||||
def test_syncs_collision_objects(self):
|
||||
"""验证 check() 调用 add_collision_box 同步到 MoveIt2。"""
|
||||
placements = [
|
||||
{"id": "dev_a", "bbox": (0.6, 0.8), "pos": (1.0, 2.0, 0.5)},
|
||||
]
|
||||
self.checker.check(placements)
|
||||
|
||||
self.moveit2.add_collision_box.assert_called_once()
|
||||
call_kwargs = self.moveit2.add_collision_box.call_args
|
||||
# 验证使用 {device_id}_ 前缀
|
||||
assert call_kwargs.kwargs["id"] == "dev_a_"
|
||||
# 验证 size = (w, d, h)
|
||||
assert call_kwargs.kwargs["size"] == (0.6, 0.8, 0.4)
|
||||
|
||||
def test_device_id_prefix(self):
|
||||
"""碰撞对象名称使用 {device_id}_ 前缀。"""
|
||||
placements = [
|
||||
{"id": "robot_arm", "bbox": (0.3, 0.3), "pos": (1.0, 1.0, 0.0)},
|
||||
{"id": "centrifuge", "bbox": (0.5, 0.5), "pos": (3.0, 3.0, 0.0)},
|
||||
]
|
||||
self.checker.check(placements)
|
||||
|
||||
calls = self.moveit2.add_collision_box.call_args_list
|
||||
ids = [c.kwargs["id"] for c in calls]
|
||||
assert "robot_arm_" in ids
|
||||
assert "centrifuge_" in ids
|
||||
|
||||
def test_sync_failure_does_not_crash(self):
|
||||
"""add_collision_box 异常不影响碰撞检测结果。"""
|
||||
self.moveit2.add_collision_box.side_effect = RuntimeError("service unavailable")
|
||||
placements = [
|
||||
{"id": "a", "bbox": (0.5, 0.5), "pos": (1.0, 1.0, 0.0)},
|
||||
{"id": "b", "bbox": (0.5, 0.5), "pos": (3.0, 3.0, 0.0)},
|
||||
]
|
||||
# 不应抛异常
|
||||
collisions = self.checker.check(placements)
|
||||
assert collisions == []
|
||||
|
||||
def test_check_bounds_within(self):
|
||||
"""设备在边界内。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (0.5, 0.5), "pos": (1.0, 1.0, 0.0)},
|
||||
]
|
||||
assert self.checker.check_bounds(placements, 5.0, 5.0) == []
|
||||
|
||||
def test_check_bounds_outside(self):
|
||||
"""设备超出边界。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (1.0, 1.0), "pos": (0.2, 0.2, 0.0)},
|
||||
]
|
||||
oob = self.checker.check_bounds(placements, 5.0, 5.0)
|
||||
assert "a" in oob
|
||||
|
||||
def test_no_sync_mode(self):
|
||||
"""sync_to_scene=False 时不调用 add_collision_box。"""
|
||||
checker = MoveItCollisionChecker(
|
||||
self.moveit2, sync_to_scene=False,
|
||||
)
|
||||
checker._fcl_available = False
|
||||
placements = [
|
||||
{"id": "a", "bbox": (0.5, 0.5), "pos": (1.0, 1.0, 0.0)},
|
||||
]
|
||||
checker.check(placements)
|
||||
self.moveit2.add_collision_box.assert_not_called()
|
||||
|
||||
def test_touching_edges_no_collision(self):
|
||||
"""恰好边缘接触,不算碰撞。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (1.0, 1.0), "pos": (0.5, 0.5, 0.0)},
|
||||
{"id": "b", "bbox": (1.0, 1.0), "pos": (1.5, 0.5, 0.0)},
|
||||
]
|
||||
collisions = self.checker.check(placements)
|
||||
assert collisions == []
|
||||
|
||||
def test_three_devices_multiple_collisions(self):
|
||||
"""三个设备,相邻碰撞。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (1.0, 1.0), "pos": (1.0, 1.0, 0.0)},
|
||||
{"id": "b", "bbox": (1.0, 1.0), "pos": (1.3, 1.0, 0.0)},
|
||||
{"id": "c", "bbox": (1.0, 1.0), "pos": (1.6, 1.0, 0.0)},
|
||||
]
|
||||
collisions = self.checker.check(placements)
|
||||
assert ("a", "b") in collisions
|
||||
assert ("b", "c") in collisions
|
||||
|
||||
|
||||
# ---------- IKFastReachabilityChecker 测试 ----------
|
||||
|
||||
|
||||
class TestIKFastReachabilityCheckerVoxel:
|
||||
"""体素图模式测试。"""
|
||||
|
||||
def _create_voxel_dir(self, tmp_path: Path, arm_id: str = "elite_cs66") -> Path:
|
||||
"""创建包含体素图的临时目录。"""
|
||||
# 创建一个简单的体素网格:中心区域可达
|
||||
grid = np.zeros((100, 100, 50), dtype=bool)
|
||||
# 标记中心 60x60x30 区域为可达
|
||||
grid[20:80, 20:80, 10:40] = True
|
||||
|
||||
origin = np.array([-0.5, -0.5, 0.0])
|
||||
resolution = 0.01
|
||||
|
||||
npz_path = tmp_path / f"{arm_id}.npz"
|
||||
np.savez(str(npz_path), grid=grid, origin=origin, resolution=resolution)
|
||||
return tmp_path
|
||||
|
||||
def test_reachable_in_voxel(self, tmp_path):
|
||||
"""目标在体素图可达区域内。"""
|
||||
voxel_dir = self._create_voxel_dir(tmp_path)
|
||||
checker = IKFastReachabilityChecker(voxel_dir=voxel_dir)
|
||||
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
# 中心区域:local = (0.0, 0.0, 0.2) → ix=50, iy=50, iz=20 → 可达
|
||||
target = {"x": 0.0, "y": 0.0, "z": 0.2}
|
||||
assert checker.is_reachable("elite_cs66", arm_pose, target)
|
||||
|
||||
def test_not_reachable_outside_voxel(self, tmp_path):
|
||||
"""目标在体素图不可达区域。"""
|
||||
voxel_dir = self._create_voxel_dir(tmp_path)
|
||||
checker = IKFastReachabilityChecker(voxel_dir=voxel_dir)
|
||||
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
# 边缘区域:local = (-0.45, -0.45, 0.0) → ix=5, iy=5, iz=0 → 不可达
|
||||
target = {"x": -0.45, "y": -0.45, "z": 0.0}
|
||||
assert not checker.is_reachable("elite_cs66", arm_pose, target)
|
||||
|
||||
def test_out_of_bounds_not_reachable(self, tmp_path):
|
||||
"""目标超出体素图范围。"""
|
||||
voxel_dir = self._create_voxel_dir(tmp_path)
|
||||
checker = IKFastReachabilityChecker(voxel_dir=voxel_dir)
|
||||
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
target = {"x": 5.0, "y": 5.0, "z": 0.0}
|
||||
assert not checker.is_reachable("elite_cs66", arm_pose, target)
|
||||
|
||||
def test_arm_rotation_transforms_target(self, tmp_path):
|
||||
"""臂旋转后目标变换到臂坐标系。"""
|
||||
voxel_dir = self._create_voxel_dir(tmp_path)
|
||||
checker = IKFastReachabilityChecker(voxel_dir=voxel_dir)
|
||||
|
||||
# 臂旋转 90°,目标在世界 Y+ 方向 → 臂坐标系 X+ 方向
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": math.pi / 2}
|
||||
# 世界 (0, 0.1, 0.2) → 臂坐标系 (0.1, 0, 0.2) → 在可达范围
|
||||
target = {"x": 0.0, "y": 0.1, "z": 0.2}
|
||||
assert checker.is_reachable("elite_cs66", arm_pose, target)
|
||||
|
||||
def test_unknown_arm_no_voxel_no_moveit(self, tmp_path):
|
||||
"""未知臂型且无 MoveIt2,乐观返回 True。"""
|
||||
voxel_dir = self._create_voxel_dir(tmp_path)
|
||||
checker = IKFastReachabilityChecker(voxel_dir=voxel_dir)
|
||||
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
target = {"x": 0.5, "y": 0.0, "z": 0.0}
|
||||
assert checker.is_reachable("unknown_arm", arm_pose, target)
|
||||
|
||||
def test_missing_voxel_dir(self):
|
||||
"""体素目录不存在不报错。"""
|
||||
checker = IKFastReachabilityChecker(voxel_dir="/nonexistent/path")
|
||||
assert len(checker._voxel_maps) == 0
|
||||
|
||||
|
||||
class TestIKFastReachabilityCheckerLiveIK:
|
||||
"""实时 IK 模式测试。"""
|
||||
|
||||
def test_reachable_via_ik(self):
|
||||
"""compute_ik 返回 JointState → 可达。"""
|
||||
moveit2 = MagicMock()
|
||||
moveit2.compute_ik.return_value = MagicMock() # 非 None → 成功
|
||||
|
||||
checker = IKFastReachabilityChecker(moveit2)
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
target = {"x": 0.5, "y": 0.0, "z": 0.3}
|
||||
assert checker.is_reachable("elite_cs66", arm_pose, target)
|
||||
|
||||
def test_not_reachable_via_ik(self):
|
||||
"""compute_ik 返回 None → 不可达。"""
|
||||
moveit2 = MagicMock()
|
||||
moveit2.compute_ik.return_value = None
|
||||
|
||||
checker = IKFastReachabilityChecker(moveit2)
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
target = {"x": 5.0, "y": 5.0, "z": 0.0}
|
||||
assert not checker.is_reachable("elite_cs66", arm_pose, target)
|
||||
|
||||
def test_ik_exception_returns_false(self):
|
||||
"""compute_ik 抛异常 → 不可达。"""
|
||||
moveit2 = MagicMock()
|
||||
moveit2.compute_ik.side_effect = RuntimeError("service timeout")
|
||||
|
||||
checker = IKFastReachabilityChecker(moveit2)
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
target = {"x": 0.5, "y": 0.0, "z": 0.0}
|
||||
assert not checker.is_reachable("elite_cs66", arm_pose, target)
|
||||
|
||||
def test_ik_called_with_correct_position(self):
|
||||
"""验证 compute_ik 接收正确的臂坐标系位置。"""
|
||||
moveit2 = MagicMock()
|
||||
moveit2.compute_ik.return_value = MagicMock()
|
||||
|
||||
checker = IKFastReachabilityChecker(moveit2)
|
||||
arm_pose = {"x": 1.0, "y": 2.0, "theta": 0.0}
|
||||
target = {"x": 1.5, "y": 2.3, "z": 0.4}
|
||||
checker.is_reachable("elite_cs66", arm_pose, target)
|
||||
|
||||
call_kwargs = moveit2.compute_ik.call_args.kwargs
|
||||
assert call_kwargs["position"] == pytest.approx((0.5, 0.3, 0.4))
|
||||
|
||||
def test_voxel_takes_priority_over_live_ik(self, tmp_path):
|
||||
"""有体素图时优先使用体素查询,不调用 compute_ik。"""
|
||||
# 创建体素图
|
||||
grid = np.ones((10, 10, 10), dtype=bool)
|
||||
origin = np.array([-0.05, -0.05, 0.0])
|
||||
np.savez(
|
||||
str(tmp_path / "test_arm.npz"),
|
||||
grid=grid, origin=origin, resolution=0.01,
|
||||
)
|
||||
|
||||
moveit2 = MagicMock()
|
||||
checker = IKFastReachabilityChecker(moveit2, voxel_dir=tmp_path)
|
||||
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
target = {"x": 0.0, "y": 0.0, "z": 0.05}
|
||||
checker.is_reachable("test_arm", arm_pose, target)
|
||||
|
||||
moveit2.compute_ik.assert_not_called()
|
||||
|
||||
|
||||
# ---------- create_checkers 工厂函数测试 ----------
|
||||
|
||||
|
||||
class TestCreateCheckers:
|
||||
def test_mock_mode(self):
|
||||
"""mock 模式返回 Mock 检测器。"""
|
||||
from ..mock_checkers import (
|
||||
MockCollisionChecker,
|
||||
MockReachabilityChecker,
|
||||
)
|
||||
|
||||
collision, reachability = create_checkers(mode="mock")
|
||||
assert isinstance(collision, MockCollisionChecker)
|
||||
assert isinstance(reachability, MockReachabilityChecker)
|
||||
|
||||
def test_moveit_mode(self):
|
||||
"""moveit 模式返回 MoveIt2 检测器。"""
|
||||
moveit2 = MagicMock()
|
||||
collision, reachability = create_checkers(moveit2, mode="moveit")
|
||||
assert isinstance(collision, MoveItCollisionChecker)
|
||||
assert isinstance(reachability, IKFastReachabilityChecker)
|
||||
|
||||
def test_moveit_mode_requires_instance(self):
|
||||
"""moveit 模式无实例时抛异常。"""
|
||||
with pytest.raises(ValueError, match="MoveIt2 instance required"):
|
||||
create_checkers(mode="moveit")
|
||||
|
||||
def test_default_mode_is_mock(self):
|
||||
"""默认使用 mock 模式。"""
|
||||
from ..mock_checkers import MockCollisionChecker
|
||||
|
||||
collision, _ = create_checkers()
|
||||
assert isinstance(collision, MockCollisionChecker)
|
||||
|
||||
def test_env_var_override(self, monkeypatch):
|
||||
"""LAYOUT_CHECKER_MODE 环境变量覆盖默认值。"""
|
||||
moveit2 = MagicMock()
|
||||
monkeypatch.setenv("LAYOUT_CHECKER_MODE", "moveit")
|
||||
collision, _ = create_checkers(moveit2)
|
||||
assert isinstance(collision, MoveItCollisionChecker)
|
||||
|
||||
|
||||
# ---------- Protocol 兼容性测试 ----------
|
||||
|
||||
|
||||
class TestProtocolConformance:
|
||||
"""验证适配器满足 Protocol 接口签名。"""
|
||||
|
||||
def test_collision_checker_has_check(self):
|
||||
"""MoveItCollisionChecker 实现 check(placements) 方法。"""
|
||||
moveit2 = MagicMock()
|
||||
checker = MoveItCollisionChecker(moveit2, sync_to_scene=False)
|
||||
checker._fcl_available = False
|
||||
placements = [
|
||||
{"id": "a", "bbox": (0.5, 0.5), "pos": (1.0, 1.0, 0.0)},
|
||||
]
|
||||
result = checker.check(placements)
|
||||
assert isinstance(result, list)
|
||||
|
||||
def test_reachability_checker_has_is_reachable(self):
|
||||
"""IKFastReachabilityChecker 实现 is_reachable(arm_id, arm_pose, target) 方法。"""
|
||||
checker = IKFastReachabilityChecker()
|
||||
result = checker.is_reachable(
|
||||
"arm_id",
|
||||
{"x": 0.0, "y": 0.0, "theta": 0.0},
|
||||
{"x": 0.5, "y": 0.0, "z": 0.0},
|
||||
)
|
||||
assert isinstance(result, bool)
|
||||
|
||||
def test_collision_checker_has_check_bounds(self):
|
||||
"""MoveItCollisionChecker 实现 check_bounds 方法。"""
|
||||
moveit2 = MagicMock()
|
||||
checker = MoveItCollisionChecker(moveit2, sync_to_scene=False)
|
||||
placements = [
|
||||
{"id": "a", "bbox": (0.5, 0.5), "pos": (1.0, 1.0, 0.0)},
|
||||
]
|
||||
result = checker.check_bounds(placements, 5.0, 5.0)
|
||||
assert isinstance(result, list)
|
||||
@@ -1,113 +0,0 @@
|
||||
"""Tests for the force-directed seeder engine."""
|
||||
import math
|
||||
import pytest
|
||||
from ..seeders import SeederParams, PRESETS, seed_layout
|
||||
from ..models import Device, Lab, Placement
|
||||
|
||||
|
||||
class TestSeederParams:
|
||||
def test_presets_exist(self):
|
||||
assert "compact_outward" in PRESETS
|
||||
assert "spread_inward" in PRESETS
|
||||
assert "row_fallback" in PRESETS
|
||||
|
||||
def test_compact_has_negative_boundary(self):
|
||||
assert PRESETS["compact_outward"].boundary_attraction < 0
|
||||
|
||||
def test_spread_has_positive_boundary(self):
|
||||
assert PRESETS["spread_inward"].boundary_attraction > 0
|
||||
|
||||
|
||||
class TestSeedLayout:
|
||||
"""seed_layout must return valid placements: within bounds, one per device."""
|
||||
|
||||
def _make_devices(self, n: int) -> list[Device]:
|
||||
return [Device(id=f"d{i}", name=f"Device {i}", bbox=(0.6, 0.4)) for i in range(n)]
|
||||
|
||||
def test_returns_one_placement_per_device(self):
|
||||
devices = self._make_devices(5)
|
||||
lab = Lab(width=5.0, depth=4.0)
|
||||
result = seed_layout(devices, lab, PRESETS["compact_outward"])
|
||||
assert len(result) == 5
|
||||
ids = {p.device_id for p in result}
|
||||
assert ids == {f"d{i}" for i in range(5)}
|
||||
|
||||
def test_placements_within_bounds(self):
|
||||
devices = self._make_devices(5)
|
||||
lab = Lab(width=5.0, depth=4.0)
|
||||
for preset_name in ["compact_outward", "spread_inward"]:
|
||||
result = seed_layout(devices, lab, PRESETS[preset_name])
|
||||
for p in result:
|
||||
assert 0 <= p.x <= lab.width, f"{preset_name}: x={p.x} out of bounds"
|
||||
assert 0 <= p.y <= lab.depth, f"{preset_name}: y={p.y} out of bounds"
|
||||
|
||||
def test_empty_devices(self):
|
||||
result = seed_layout([], Lab(width=5, depth=4), PRESETS["compact_outward"])
|
||||
assert result == []
|
||||
|
||||
def test_single_device(self):
|
||||
devices = self._make_devices(1)
|
||||
lab = Lab(width=5.0, depth=4.0)
|
||||
result = seed_layout(devices, lab, PRESETS["compact_outward"])
|
||||
assert len(result) == 1
|
||||
assert 0 <= result[0].x <= lab.width
|
||||
assert 0 <= result[0].y <= lab.depth
|
||||
|
||||
def test_row_fallback_delegates(self):
|
||||
"""row_fallback preset uses generate_fallback, not force engine."""
|
||||
devices = self._make_devices(3)
|
||||
lab = Lab(width=5.0, depth=4.0)
|
||||
# row_fallback is None in PRESETS; seed_layout detects and delegates
|
||||
result = seed_layout(devices, lab, None) # None = row_fallback
|
||||
assert len(result) == 3
|
||||
|
||||
def test_lab_too_small_returns_results_not_crash(self):
|
||||
"""When space is insufficient, seeder still returns placements (may have collisions)."""
|
||||
devices = [Device(id=f"d{i}", name=f"D{i}", bbox=(1.0, 1.0)) for i in range(20)]
|
||||
lab = Lab(width=2.0, depth=2.0) # Way too small for 20 1m×1m devices
|
||||
result = seed_layout(devices, lab, PRESETS["compact_outward"])
|
||||
assert len(result) == 20 # All placed, even if overlapping
|
||||
for p in result:
|
||||
assert 0 <= p.x <= lab.width
|
||||
assert 0 <= p.y <= lab.depth
|
||||
|
||||
def test_compact_clusters_toward_center(self):
|
||||
"""compact_outward should place devices closer to center than spread_inward."""
|
||||
devices = self._make_devices(4)
|
||||
lab = Lab(width=8.0, depth=8.0)
|
||||
center_x, center_y = lab.width / 2, lab.depth / 2
|
||||
|
||||
compact = seed_layout(devices, lab, PRESETS["compact_outward"])
|
||||
spread = seed_layout(devices, lab, PRESETS["spread_inward"])
|
||||
|
||||
avg_dist_compact = sum(
|
||||
math.sqrt((p.x - center_x)**2 + (p.y - center_y)**2) for p in compact
|
||||
) / len(compact)
|
||||
avg_dist_spread = sum(
|
||||
math.sqrt((p.x - center_x)**2 + (p.y - center_y)**2) for p in spread
|
||||
) / len(spread)
|
||||
|
||||
assert avg_dist_compact < avg_dist_spread
|
||||
|
||||
|
||||
class TestOrientation:
|
||||
"""Orientation modes should set theta based on position relative to center."""
|
||||
|
||||
def test_outward_orientation_sets_theta(self):
|
||||
"""compact_outward: devices should have non-zero theta."""
|
||||
devices = [
|
||||
Device(id="a", name="A", bbox=(0.6, 0.4)),
|
||||
Device(id="b", name="B", bbox=(0.6, 0.4)),
|
||||
]
|
||||
lab = Lab(width=5.0, depth=4.0)
|
||||
result = seed_layout(devices, lab, PRESETS["compact_outward"])
|
||||
thetas = [p.theta for p in result]
|
||||
assert any(t != 0.0 for t in thetas) or len(devices) == 1
|
||||
|
||||
def test_none_orientation_keeps_zero(self):
|
||||
"""orientation_mode='none': all thetas stay 0."""
|
||||
devices = [Device(id="a", name="A", bbox=(0.6, 0.4))]
|
||||
lab = Lab(width=5.0, depth=4.0)
|
||||
params = SeederParams(boundary_attraction=0.0, orientation_mode="none")
|
||||
result = seed_layout(devices, lab, params)
|
||||
assert result[0].theta == 0.0
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,658 +0,0 @@
|
||||
"""
|
||||
装饰器注册表系统
|
||||
|
||||
通过 @device, @action, @resource 装饰器替代 YAML 配置文件来定义设备/动作/资源注册表信息。
|
||||
|
||||
Usage:
|
||||
from unilabos.registry.decorators import (
|
||||
device, action, resource,
|
||||
InputHandle, OutputHandle,
|
||||
ActionInputHandle, ActionOutputHandle,
|
||||
HardwareInterface, Side, DataSource, NodeType,
|
||||
)
|
||||
|
||||
@device(
|
||||
id="solenoid_valve.mock",
|
||||
category=["pump_and_valve"],
|
||||
description="模拟电磁阀设备",
|
||||
handles=[
|
||||
InputHandle(key="in", data_type="fluid", label="in", side=Side.NORTH),
|
||||
OutputHandle(key="out", data_type="fluid", label="out", side=Side.SOUTH),
|
||||
],
|
||||
hardware_interface=HardwareInterface(
|
||||
name="hardware_interface",
|
||||
read="send_command",
|
||||
write="send_command",
|
||||
),
|
||||
)
|
||||
class SolenoidValveMock:
|
||||
@action(action_type=EmptyIn)
|
||||
def close(self):
|
||||
...
|
||||
|
||||
@action(
|
||||
handles=[
|
||||
ActionInputHandle(key="in", data_type="fluid", label="in"),
|
||||
ActionOutputHandle(key="out", data_type="fluid", label="out"),
|
||||
],
|
||||
)
|
||||
def set_valve_position(self, position):
|
||||
...
|
||||
|
||||
# 无 @action 装饰器 => auto- 前缀动作
|
||||
def is_open(self):
|
||||
...
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, Dict, List, Optional, TypeVar
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 枚举
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Side(str, Enum):
|
||||
"""UI 上 Handle 的显示位置"""
|
||||
|
||||
NORTH = "NORTH"
|
||||
SOUTH = "SOUTH"
|
||||
EAST = "EAST"
|
||||
WEST = "WEST"
|
||||
|
||||
|
||||
class DataSource(str, Enum):
|
||||
"""Handle 的数据来源"""
|
||||
|
||||
HANDLE = "handle" # 从上游 handle 获取数据 (用于 InputHandle)
|
||||
EXECUTOR = "executor" # 从执行器输出数据 (用于 OutputHandle)
|
||||
|
||||
|
||||
class NodeType(str, Enum):
|
||||
"""动作的节点类型(用于区分 ILab 节点和人工确认节点等)"""
|
||||
|
||||
ILAB = "ILab"
|
||||
MANUAL_CONFIRM = "manual_confirm"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device / Resource Handle (设备/资源级别端口, 序列化时包含 io_type)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _DeviceHandleBase(BaseModel):
|
||||
"""设备/资源端口基类 (内部使用)"""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
key: str = Field(serialization_alias="handler_key")
|
||||
data_type: str
|
||||
label: str
|
||||
side: Optional[Side] = None
|
||||
data_key: Optional[str] = None
|
||||
data_source: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
# 子类覆盖
|
||||
io_type: str = ""
|
||||
|
||||
def to_registry_dict(self) -> Dict[str, Any]:
|
||||
return self.model_dump(by_alias=True, exclude_none=True)
|
||||
|
||||
|
||||
class InputHandle(_DeviceHandleBase):
|
||||
"""
|
||||
输入端口 (io_type="target"), 用于 @device / @resource handles
|
||||
|
||||
Example:
|
||||
InputHandle(key="in", data_type="fluid", label="in", side=Side.NORTH)
|
||||
"""
|
||||
|
||||
io_type: str = "target"
|
||||
|
||||
|
||||
class OutputHandle(_DeviceHandleBase):
|
||||
"""
|
||||
输出端口 (io_type="source"), 用于 @device / @resource handles
|
||||
|
||||
Example:
|
||||
OutputHandle(key="out", data_type="fluid", label="out", side=Side.SOUTH)
|
||||
"""
|
||||
|
||||
io_type: str = "source"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Action Handle (动作级别端口, 序列化时不含 io_type, 按类型自动分组)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _ActionHandleBase(BaseModel):
|
||||
"""动作端口基类 (内部使用)"""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
key: str = Field(serialization_alias="handler_key")
|
||||
data_type: str
|
||||
label: str
|
||||
side: Optional[Side] = None
|
||||
data_key: Optional[str] = None
|
||||
data_source: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
io_type: Optional[str] = None # source/sink (dataflow) or target/source (device-style)
|
||||
|
||||
def to_registry_dict(self) -> Dict[str, Any]:
|
||||
return self.model_dump(by_alias=True, exclude_none=True)
|
||||
|
||||
|
||||
class ActionInputHandle(_ActionHandleBase):
|
||||
"""
|
||||
动作输入端口, 用于 @action handles, 序列化后归入 "input" 组
|
||||
|
||||
Example:
|
||||
ActionInputHandle(
|
||||
key="material_input", data_type="workbench_material",
|
||||
label="物料编号", data_key="material_number", data_source="handle",
|
||||
)
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ActionOutputHandle(_ActionHandleBase):
|
||||
"""
|
||||
动作输出端口, 用于 @action handles, 序列化后归入 "output" 组
|
||||
|
||||
Example:
|
||||
ActionOutputHandle(
|
||||
key="station_output", data_type="workbench_station",
|
||||
label="加热台ID", data_key="station_id", data_source="executor",
|
||||
)
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HardwareInterface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class HardwareInterface(BaseModel):
|
||||
"""
|
||||
硬件通信接口定义
|
||||
|
||||
描述设备与底层硬件通信的方式 (串口、Modbus 等)。
|
||||
|
||||
Example:
|
||||
HardwareInterface(name="hardware_interface", read="send_command", write="send_command")
|
||||
"""
|
||||
|
||||
name: str
|
||||
read: Optional[str] = None
|
||||
write: Optional[str] = None
|
||||
extra_info: Optional[List[str]] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 全局注册表 -- 记录所有被装饰器标记的类/函数
|
||||
# ---------------------------------------------------------------------------
|
||||
_registered_devices: Dict[str, type] = {} # device_id -> class
|
||||
_registered_resources: Dict[str, Any] = {} # resource_id -> class or function
|
||||
|
||||
|
||||
def _device_handles_to_list(
|
||||
handles: Optional[List[_DeviceHandleBase]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""将设备/资源 Handle 列表序列化为字典列表 (含 io_type)"""
|
||||
if handles is None:
|
||||
return []
|
||||
return [h.to_registry_dict() for h in handles]
|
||||
|
||||
|
||||
def _action_handles_to_dict(
|
||||
handles: Optional[List[_ActionHandleBase]],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
将动作 Handle 列表序列化为 {"input": [...], "output": [...]} 格式。
|
||||
|
||||
ActionInputHandle => "input", ActionOutputHandle => "output"
|
||||
"""
|
||||
if handles is None:
|
||||
return {}
|
||||
input_list = [h.to_registry_dict() for h in handles if isinstance(h, ActionInputHandle)]
|
||||
output_list = [h.to_registry_dict() for h in handles if isinstance(h, ActionOutputHandle)]
|
||||
result: Dict[str, Any] = {}
|
||||
if input_list:
|
||||
result["input"] = input_list
|
||||
if output_list:
|
||||
result["output"] = output_list
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# @device 类装饰器
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# noinspection PyShadowingBuiltins
|
||||
def device(
|
||||
id: Optional[str] = None,
|
||||
ids: Optional[List[str]] = None,
|
||||
id_meta: Optional[Dict[str, Dict[str, Any]]] = None,
|
||||
category: Optional[List[str]] = None,
|
||||
description: str = "",
|
||||
display_name: str = "",
|
||||
icon: str = "",
|
||||
version: str = "1.0.0",
|
||||
handles: Optional[List[_DeviceHandleBase]] = None,
|
||||
model: Optional[Dict[str, Any]] = None,
|
||||
device_type: str = "python",
|
||||
hardware_interface: Optional[HardwareInterface] = None,
|
||||
):
|
||||
"""
|
||||
设备类装饰器
|
||||
|
||||
将类标记为一个 UniLab-OS 设备,并附加注册表元数据。
|
||||
|
||||
支持两种模式:
|
||||
1. 单设备: id="xxx", category=[...]
|
||||
2. 多设备: ids=["id1","id2"], id_meta={"id1":{handles:[...]}, "id2":{...}}
|
||||
|
||||
Args:
|
||||
id: 单设备时的注册表唯一标识
|
||||
ids: 多设备时的 id 列表,与 id_meta 配合使用
|
||||
id_meta: 每个 device_id 的覆盖元数据 (handles/description/icon/model)
|
||||
category: 设备分类标签列表 (必填)
|
||||
description: 设备描述
|
||||
display_name: 人类可读的设备显示名称,缺失时默认使用 id
|
||||
icon: 图标路径
|
||||
version: 版本号
|
||||
handles: 设备端口列表 (单设备或 id_meta 未覆盖时使用)
|
||||
model: 可选的 3D 模型配置
|
||||
device_type: 设备实现类型 ("python" / "ros2")
|
||||
hardware_interface: 硬件通信接口 (HardwareInterface)
|
||||
"""
|
||||
# Resolve device ids
|
||||
if ids is not None:
|
||||
device_ids = list(ids)
|
||||
if not device_ids:
|
||||
raise ValueError("@device ids 不能为空")
|
||||
id_meta = id_meta or {}
|
||||
elif id is not None:
|
||||
device_ids = [id]
|
||||
id_meta = {}
|
||||
else:
|
||||
raise ValueError("@device 必须提供 id 或 ids")
|
||||
|
||||
if category is None:
|
||||
raise ValueError("@device category 必填")
|
||||
|
||||
base_meta = {
|
||||
"category": category,
|
||||
"description": description,
|
||||
"display_name": display_name,
|
||||
"icon": icon,
|
||||
"version": version,
|
||||
"handles": _device_handles_to_list(handles),
|
||||
"model": model,
|
||||
"device_type": device_type,
|
||||
"hardware_interface": (hardware_interface.model_dump(exclude_none=True) if hardware_interface else None),
|
||||
}
|
||||
|
||||
def decorator(cls):
|
||||
cls._device_registry_meta = base_meta
|
||||
cls._device_registry_id_meta = id_meta
|
||||
cls._device_registry_ids = device_ids
|
||||
|
||||
for did in device_ids:
|
||||
if did in _registered_devices:
|
||||
raise ValueError(f"@device id 重复: '{did}' 已被 {_registered_devices[did]} 注册")
|
||||
_registered_devices[did] = cls
|
||||
|
||||
return cls
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# @action 方法装饰器
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 区分 "用户没传 action_type" 和 "用户传了 None"
|
||||
_ACTION_TYPE_UNSET = object()
|
||||
|
||||
|
||||
# noinspection PyShadowingNames
|
||||
def action(
|
||||
action_type: Any = _ACTION_TYPE_UNSET,
|
||||
goal: Optional[Dict[str, str]] = None,
|
||||
feedback: Optional[Dict[str, str]] = None,
|
||||
result: Optional[Dict[str, str]] = None,
|
||||
handles: Optional[List[_ActionHandleBase]] = None,
|
||||
goal_default: Optional[Dict[str, Any]] = None,
|
||||
placeholder_keys: Optional[Dict[str, str]] = None,
|
||||
always_free: bool = False,
|
||||
is_protocol: bool = False,
|
||||
description: str = "",
|
||||
auto_prefix: bool = False,
|
||||
parent: bool = False,
|
||||
node_type: Optional["NodeType"] = None,
|
||||
):
|
||||
"""
|
||||
动作方法装饰器
|
||||
|
||||
标记方法为注册表动作。有三种用法:
|
||||
1. @action(action_type=EmptyIn, ...) -- 非 auto, 使用指定 ROS Action 类型
|
||||
2. @action() -- 非 auto, UniLabJsonCommand (从方法签名生成 schema)
|
||||
3. 不加 @action -- auto- 前缀, UniLabJsonCommand
|
||||
|
||||
Protocol 用法:
|
||||
@action(action_type=Add, is_protocol=True)
|
||||
def AddProtocol(self): ...
|
||||
标记该动作为高级协议 (protocol),运行时通过 ROS Action 路由到
|
||||
protocol generator 执行。action_type 指向 unilabos_msgs 的 Action 类型。
|
||||
|
||||
Args:
|
||||
action_type: ROS Action 消息类型 (如 EmptyIn, SendCmd, HeatChill).
|
||||
不传/默认 = UniLabJsonCommand (非 auto).
|
||||
goal: Goal 字段映射 (ROS字段名 -> 设备参数名).
|
||||
protocol 模式下可留空,系统自动生成 identity 映射.
|
||||
feedback: Feedback 字段映射
|
||||
result: Result 字段映射
|
||||
handles: 动作端口列表 (ActionInputHandle / ActionOutputHandle)
|
||||
goal_default: Goal 字段默认值映射 (字段名 -> 默认值), 与自动生成的 goal_default 合并
|
||||
placeholder_keys: 参数占位符配置
|
||||
always_free: 是否为永久闲置动作 (不受排队限制)
|
||||
is_protocol: 是否为工作站协议 (protocol)。True 时运行时走 protocol generator 路径。
|
||||
description: 动作描述
|
||||
auto_prefix: 若为 True,动作名使用 auto-{method_name} 形式(与无 @action 时一致)
|
||||
parent: 若为 True,当方法参数为空 (*args, **kwargs) 时,通过 MRO 从父类获取真实方法参数
|
||||
node_type: 动作的节点类型 (NodeType.ILAB / NodeType.MANUAL_CONFIRM)。
|
||||
不填写时不写入注册表。
|
||||
"""
|
||||
|
||||
def decorator(func: F) -> F:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
# action_type 为哨兵值 => 用户没传, 视为 None (UniLabJsonCommand)
|
||||
resolved_type = None if action_type is _ACTION_TYPE_UNSET else action_type
|
||||
|
||||
meta = {
|
||||
"action_type": resolved_type,
|
||||
"goal": goal or {},
|
||||
"feedback": feedback or {},
|
||||
"result": result or {},
|
||||
"handles": _action_handles_to_dict(handles),
|
||||
"goal_default": goal_default or {},
|
||||
"placeholder_keys": placeholder_keys or {},
|
||||
"always_free": always_free,
|
||||
"is_protocol": is_protocol,
|
||||
"description": description,
|
||||
"auto_prefix": auto_prefix,
|
||||
"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]
|
||||
|
||||
# 设置 _is_always_free 保持与旧 @always_free 装饰器兼容
|
||||
if always_free:
|
||||
wrapper._is_always_free = True # type: ignore[attr-defined]
|
||||
|
||||
return wrapper # type: ignore[return-value]
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def get_action_meta(func) -> Optional[Dict[str, Any]]:
|
||||
"""获取方法上的 @action 装饰器元数据"""
|
||||
return getattr(func, "_action_registry_meta", None)
|
||||
|
||||
|
||||
def has_action_decorator(func) -> bool:
|
||||
"""检查函数是否带有 @action 装饰器"""
|
||||
return hasattr(func, "_action_registry_meta")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# @resource 类/函数装饰器
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def resource(
|
||||
id: str,
|
||||
category: List[str],
|
||||
description: str = "",
|
||||
icon: str = "",
|
||||
version: str = "1.0.0",
|
||||
handles: Optional[List[_DeviceHandleBase]] = None,
|
||||
model: Optional[Dict[str, Any]] = None,
|
||||
class_type: str = "pylabrobot",
|
||||
):
|
||||
"""
|
||||
资源类/函数装饰器
|
||||
|
||||
将类或工厂函数标记为一个 UniLab-OS 资源,附加注册表元数据。
|
||||
|
||||
Args:
|
||||
id: 注册表唯一标识 (必填, 不可重复)
|
||||
category: 资源分类标签列表 (必填)
|
||||
description: 资源描述
|
||||
icon: 图标路径
|
||||
version: 版本号
|
||||
handles: 端口列表 (InputHandle / OutputHandle)
|
||||
model: 可选的 3D 模型配置
|
||||
class_type: 资源实现类型 ("python" / "pylabrobot" / "unilabos")
|
||||
"""
|
||||
|
||||
def decorator(obj):
|
||||
meta = {
|
||||
"resource_id": id,
|
||||
"category": category,
|
||||
"description": description,
|
||||
"icon": icon,
|
||||
"version": version,
|
||||
"handles": _device_handles_to_list(handles),
|
||||
"model": model,
|
||||
"class_type": class_type,
|
||||
}
|
||||
obj._resource_registry_meta = meta
|
||||
|
||||
if id in _registered_resources:
|
||||
raise ValueError(f"@resource id 重复: '{id}' 已被 {_registered_resources[id]} 注册")
|
||||
_registered_resources[id] = obj
|
||||
|
||||
return obj
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def get_device_meta(cls, device_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
获取类上的 @device 装饰器元数据。
|
||||
|
||||
当 device_id 存在且类使用 ids+id_meta 时,返回合并后的 meta
|
||||
(base_meta 与 id_meta[device_id] 深度合并)。
|
||||
"""
|
||||
base = getattr(cls, "_device_registry_meta", None)
|
||||
if base is None:
|
||||
return None
|
||||
id_meta = getattr(cls, "_device_registry_id_meta", None) or {}
|
||||
if device_id is None or device_id not in id_meta:
|
||||
result = dict(base)
|
||||
ids = getattr(cls, "_device_registry_ids", None)
|
||||
result["device_id"] = device_id if device_id is not None else (ids[0] if ids else None)
|
||||
return result
|
||||
|
||||
overrides = id_meta[device_id]
|
||||
result = dict(base)
|
||||
result["device_id"] = device_id
|
||||
for key in ["handles", "description", "icon", "model"]:
|
||||
if key in overrides:
|
||||
val = overrides[key]
|
||||
if key == "handles" and isinstance(val, list):
|
||||
# handles 必须是 Handle 对象列表
|
||||
result[key] = [h.to_registry_dict() for h in val]
|
||||
else:
|
||||
result[key] = val
|
||||
return result
|
||||
|
||||
|
||||
def get_resource_meta(obj) -> Optional[Dict[str, Any]]:
|
||||
"""获取对象上的 @resource 装饰器元数据"""
|
||||
return getattr(obj, "_resource_registry_meta", None)
|
||||
|
||||
|
||||
def get_all_registered_devices() -> Dict[str, type]:
|
||||
"""获取所有已注册的设备类"""
|
||||
return _registered_devices.copy()
|
||||
|
||||
|
||||
def get_all_registered_resources() -> Dict[str, Any]:
|
||||
"""获取所有已注册的资源"""
|
||||
return _registered_resources.copy()
|
||||
|
||||
|
||||
def clear_registry():
|
||||
"""清空全局注册表 (用于测试)"""
|
||||
_registered_devices.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 装饰器
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def topic_config(
|
||||
period: Optional[float] = None,
|
||||
print_publish: Optional[bool] = None,
|
||||
qos: Optional[int] = None,
|
||||
name: Optional[str] = None,
|
||||
) -> Callable[[F], F]:
|
||||
"""
|
||||
Topic发布配置装饰器
|
||||
|
||||
用于装饰 get_{attr_name} 方法或 @property,控制对应属性的ROS topic发布行为。
|
||||
|
||||
Args:
|
||||
period: 发布周期(秒)。None 表示使用默认值 5.0
|
||||
print_publish: 是否打印发布日志。None 表示使用节点默认配置
|
||||
qos: QoS深度配置。None 表示使用默认值 10
|
||||
name: 自定义发布名称。None 表示使用方法名(去掉 get_ 前缀)
|
||||
|
||||
Note:
|
||||
与 @property 连用时,@topic_config 必须放在 @property 下面,
|
||||
这样装饰器执行顺序为:先 topic_config 添加配置,再 property 包装。
|
||||
"""
|
||||
|
||||
def decorator(func: F) -> F:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
wrapper._topic_period = period # type: ignore[attr-defined]
|
||||
wrapper._topic_print_publish = print_publish # type: ignore[attr-defined]
|
||||
wrapper._topic_qos = qos # type: ignore[attr-defined]
|
||||
wrapper._topic_name = name # type: ignore[attr-defined]
|
||||
wrapper._has_topic_config = True # type: ignore[attr-defined]
|
||||
|
||||
return wrapper # type: ignore[return-value]
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def get_topic_config(func) -> dict:
|
||||
"""获取函数上的 topic 配置 (period, print_publish, qos, name)"""
|
||||
if hasattr(func, "_has_topic_config") and getattr(func, "_has_topic_config", False):
|
||||
return {
|
||||
"period": getattr(func, "_topic_period", None),
|
||||
"print_publish": getattr(func, "_topic_print_publish", None),
|
||||
"qos": getattr(func, "_topic_qos", None),
|
||||
"name": getattr(func, "_topic_name", None),
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
def always_free(func: F) -> F:
|
||||
"""
|
||||
标记动作为永久闲置(不受busy队列限制)的装饰器
|
||||
|
||||
被此装饰器标记的 action 方法,在执行时不会受到设备级别的排队限制,
|
||||
任何时候请求都可以立即执行。适用于查询类、状态读取类等轻量级操作。
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
wrapper._is_always_free = True # type: ignore[attr-defined]
|
||||
|
||||
return wrapper # type: ignore[return-value]
|
||||
|
||||
|
||||
def is_always_free(func) -> bool:
|
||||
"""检查函数是否被标记为永久闲置"""
|
||||
return getattr(func, "_is_always_free", False)
|
||||
|
||||
|
||||
def not_action(func: F) -> F:
|
||||
"""
|
||||
标记方法为非动作的装饰器
|
||||
|
||||
用于装饰 driver 类中的方法,使其在注册表扫描时不被识别为动作。
|
||||
适用于辅助方法、内部工具方法等不应暴露为设备动作的公共方法。
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
wrapper._is_not_action = True # type: ignore[attr-defined]
|
||||
|
||||
return wrapper # type: ignore[return-value]
|
||||
|
||||
|
||||
def is_not_action(func) -> bool:
|
||||
"""检查函数是否被标记为非动作"""
|
||||
return getattr(func, "_is_not_action", False)
|
||||
@@ -13,18 +13,21 @@ Qone_nmr:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: EmptyIn_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: EmptyIn_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: EmptyIn_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -68,6 +71,31 @@ Qone_nmr:
|
||||
title: monitor_folder_for_new_content参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-post_init:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
ros_node: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
ros_node:
|
||||
type: string
|
||||
required:
|
||||
- ros_node
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: post_init参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-strings_to_txt:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
@@ -110,18 +138,21 @@ Qone_nmr:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: EmptyIn_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: EmptyIn_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: EmptyIn_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -136,31 +167,32 @@ Qone_nmr:
|
||||
goal_default:
|
||||
string: ''
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
success: success
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: StrSingleInput_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
string:
|
||||
type: string
|
||||
required:
|
||||
- string
|
||||
title: StrSingleInput_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
title: StrSingleInput_Result
|
||||
type: object
|
||||
required:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,8 +22,7 @@ bioyond_cell:
|
||||
required:
|
||||
- xlsx_path
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: auto_batch_outbound_from_xlsx参数
|
||||
@@ -491,9 +490,7 @@ bioyond_cell:
|
||||
goal:
|
||||
properties:
|
||||
material_names:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
type: string
|
||||
type_id:
|
||||
default: 3a190ca0-b2f6-9aeb-8067-547e72c11469
|
||||
type: string
|
||||
@@ -502,8 +499,7 @@ bioyond_cell:
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: create_and_inbound_materials参数
|
||||
@@ -539,8 +535,7 @@ bioyond_cell:
|
||||
- type_id
|
||||
- warehouse_name
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: create_material参数
|
||||
@@ -561,16 +556,11 @@ bioyond_cell:
|
||||
goal:
|
||||
properties:
|
||||
mappings:
|
||||
additionalProperties:
|
||||
type: object
|
||||
type: object
|
||||
required:
|
||||
- mappings
|
||||
type: object
|
||||
result:
|
||||
items:
|
||||
type: object
|
||||
type: array
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: create_materials参数
|
||||
@@ -602,8 +592,7 @@ bioyond_cell:
|
||||
required:
|
||||
- xlsx_path
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: create_orders参数
|
||||
@@ -635,8 +624,7 @@ bioyond_cell:
|
||||
required:
|
||||
- xlsx_path
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: create_orders_v2参数
|
||||
@@ -677,8 +665,7 @@ bioyond_cell:
|
||||
- bottle_type
|
||||
- location_code
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: create_sample参数
|
||||
@@ -731,8 +718,7 @@ bioyond_cell:
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: order_list_v2参数
|
||||
@@ -835,8 +821,7 @@ bioyond_cell:
|
||||
required:
|
||||
- material_obj
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: report_material_change参数
|
||||
@@ -890,8 +875,7 @@ bioyond_cell:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: scheduler_continue参数
|
||||
@@ -912,8 +896,7 @@ bioyond_cell:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: scheduler_reset参数
|
||||
@@ -934,8 +917,7 @@ bioyond_cell:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: scheduler_start参数
|
||||
@@ -1380,8 +1362,7 @@ bioyond_cell:
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: scheduler_start_and_auto_feeding参数
|
||||
@@ -1826,8 +1807,7 @@ bioyond_cell:
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: scheduler_start_and_auto_feeding_v2参数
|
||||
@@ -1848,8 +1828,7 @@ bioyond_cell:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: scheduler_stop参数
|
||||
@@ -1871,15 +1850,12 @@ bioyond_cell:
|
||||
properties:
|
||||
items:
|
||||
items:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
type: array
|
||||
required:
|
||||
- items
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: storage_batch_inbound参数
|
||||
@@ -1908,8 +1884,7 @@ bioyond_cell:
|
||||
- material_id
|
||||
- location_id
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: storage_inbound参数
|
||||
@@ -1930,8 +1905,7 @@ bioyond_cell:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: transfer_1_to_2参数
|
||||
@@ -1972,8 +1946,7 @@ bioyond_cell:
|
||||
type: integer
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: transfer_3_to_2参数
|
||||
@@ -2010,8 +1983,7 @@ bioyond_cell:
|
||||
type: integer
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: transfer_3_to_2_to_1参数
|
||||
@@ -2035,11 +2007,10 @@ bioyond_cell:
|
||||
ip:
|
||||
type: string
|
||||
port:
|
||||
type: integer
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: update_push_ip参数
|
||||
@@ -2068,8 +2039,7 @@ bioyond_cell:
|
||||
required:
|
||||
- order_code
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: wait_for_order_finish参数
|
||||
@@ -2102,8 +2072,7 @@ bioyond_cell:
|
||||
required:
|
||||
- order_code
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: wait_for_order_finish_polling参数
|
||||
@@ -2135,8 +2104,7 @@ bioyond_cell:
|
||||
type: integer
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: wait_for_transfer_task参数
|
||||
@@ -2144,7 +2112,8 @@ bioyond_cell:
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.devices.workstation.bioyond_studio.bioyond_cell.bioyond_cell_workstation:BioyondCellWorkstation
|
||||
status_types:
|
||||
device_id: ''
|
||||
device_id: String
|
||||
material_info: dict
|
||||
type: python
|
||||
config_info: []
|
||||
description: ''
|
||||
@@ -2165,7 +2134,11 @@ bioyond_cell:
|
||||
properties:
|
||||
device_id:
|
||||
type: string
|
||||
material_info:
|
||||
type: object
|
||||
required:
|
||||
- device_id
|
||||
- material_info
|
||||
type: object
|
||||
registry_type: device
|
||||
version: 1.0.0
|
||||
|
||||
@@ -24,8 +24,7 @@ bioyond_dispensing_station:
|
||||
required:
|
||||
- data
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: brief_step_parameters参数
|
||||
@@ -54,8 +53,7 @@ bioyond_dispensing_station:
|
||||
- report_request
|
||||
- used_materials
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: process_order_finish_report参数
|
||||
@@ -80,8 +78,7 @@ bioyond_dispensing_station:
|
||||
required:
|
||||
- order_id
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: project_order_report参数
|
||||
@@ -131,8 +128,7 @@ bioyond_dispensing_station:
|
||||
required:
|
||||
- workflow_id
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: workflow_sample_locations参数
|
||||
@@ -148,12 +144,12 @@ bioyond_dispensing_station:
|
||||
temperature: temperature
|
||||
titration: titration
|
||||
goal_default:
|
||||
delay_time: null
|
||||
hold_m_name: null
|
||||
delay_time: '600'
|
||||
hold_m_name: ''
|
||||
liquid_material_name: NMP
|
||||
speed: null
|
||||
temperature: null
|
||||
titration: null
|
||||
speed: '400'
|
||||
temperature: '40'
|
||||
titration: ''
|
||||
handles:
|
||||
input:
|
||||
- data_key: titration
|
||||
@@ -169,16 +165,20 @@ bioyond_dispensing_station:
|
||||
handler_key: BATCH_CREATE_RESULT
|
||||
io_type: sink
|
||||
label: Complete Batch Create Result JSON (contains order_codes and order_ids)
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
schema:
|
||||
description: 批量创建90%10%小瓶投料任务。从计算节点接收titration数据,包含物料名称、主称固体质量、滴定固体质量和滴定溶剂体积。返回的return_info中包含order_codes和order_ids列表。
|
||||
properties:
|
||||
feedback:
|
||||
properties: {}
|
||||
required: []
|
||||
title: BatchCreate9010VialFeedingTasks_Feedback
|
||||
type: object
|
||||
goal:
|
||||
properties:
|
||||
delay_time:
|
||||
default: '600'
|
||||
description: 延迟时间(秒),默认600
|
||||
type: string
|
||||
hold_m_name:
|
||||
@@ -189,9 +189,11 @@ bioyond_dispensing_station:
|
||||
description: 10%物料的液体物料名称,默认为"NMP"
|
||||
type: string
|
||||
speed:
|
||||
default: '400'
|
||||
description: 搅拌速度,默认400
|
||||
type: string
|
||||
temperature:
|
||||
default: '40'
|
||||
description: 温度(℃),默认40
|
||||
type: string
|
||||
titration:
|
||||
@@ -200,14 +202,21 @@ bioyond_dispensing_station:
|
||||
type: string
|
||||
required:
|
||||
- titration
|
||||
- hold_m_name
|
||||
title: BatchCreate9010VialFeedingTasks_Goal
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
return_info:
|
||||
description: 批量任务创建结果汇总JSON字符串,包含total(总数)、success(成功数)、failed(失败数)、order_codes(任务编码数组)、order_ids(任务ID数组)、details(每个任务的详细信息)
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: BatchCreate9010VialFeedingTasks_Result
|
||||
type: string
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: batch_create_90_10_vial_feeding_tasks参数
|
||||
title: BatchCreate9010VialFeedingTasks
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
batch_create_diamine_solution_tasks:
|
||||
@@ -219,11 +228,11 @@ bioyond_dispensing_station:
|
||||
speed: speed
|
||||
temperature: temperature
|
||||
goal_default:
|
||||
delay_time: null
|
||||
delay_time: '600'
|
||||
liquid_material_name: NMP
|
||||
solutions: null
|
||||
speed: null
|
||||
temperature: null
|
||||
solutions: ''
|
||||
speed: '400'
|
||||
temperature: '20'
|
||||
handles:
|
||||
input:
|
||||
- data_key: solutions
|
||||
@@ -239,16 +248,20 @@ bioyond_dispensing_station:
|
||||
handler_key: BATCH_CREATE_RESULT
|
||||
io_type: sink
|
||||
label: Complete Batch Create Result JSON (contains order_codes and order_ids)
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
schema:
|
||||
description: 批量创建二胺溶液配置任务。自动为多个二胺样品创建溶液配置任务,每个任务包含固体物料称量、溶剂添加、搅拌混合等步骤。返回的return_info中包含order_codes和order_ids列表。
|
||||
properties:
|
||||
feedback:
|
||||
properties: {}
|
||||
required: []
|
||||
title: BatchCreateDiamineSolutionTasks_Feedback
|
||||
type: object
|
||||
goal:
|
||||
properties:
|
||||
delay_time:
|
||||
default: '600'
|
||||
description: 溶液配置完成后的延迟时间(秒),用于充分混合和溶解,默认600秒
|
||||
type: string
|
||||
liquid_material_name:
|
||||
@@ -262,9 +275,11 @@ bioyond_dispensing_station:
|
||||
4.5, "solvent_volume": 18}]'
|
||||
type: string
|
||||
speed:
|
||||
default: '400'
|
||||
description: 搅拌速度(rpm),用于混合溶液,默认400转/分钟
|
||||
type: string
|
||||
temperature:
|
||||
default: '20'
|
||||
description: 配置温度(℃),溶液配置过程的目标温度,默认20℃(室温)
|
||||
type: string
|
||||
required:
|
||||
@@ -272,11 +287,17 @@ bioyond_dispensing_station:
|
||||
title: BatchCreateDiamineSolutionTasks_Goal
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
return_info:
|
||||
description: 批量任务创建结果汇总JSON字符串,包含total(总数)、success(成功数)、failed(失败数)、order_codes(任务编码数组)、order_ids(任务ID数组)、details(每个任务的详细信息)
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: BatchCreateDiamineSolutionTasks_Result
|
||||
type: string
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: batch_create_diamine_solution_tasks参数
|
||||
title: BatchCreateDiamineSolutionTasks
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
compute_experiment_design:
|
||||
@@ -288,7 +309,7 @@ bioyond_dispensing_station:
|
||||
wt_percent: wt_percent
|
||||
goal_default:
|
||||
m_tot: '70'
|
||||
ratio: null
|
||||
ratio: ''
|
||||
titration_percent: '0.03'
|
||||
wt_percent: '0.25'
|
||||
handles:
|
||||
@@ -317,8 +338,12 @@ bioyond_dispensing_station:
|
||||
handler_key: feeding_order
|
||||
io_type: sink
|
||||
label: Feeding Order Data From Calculation Node
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
result:
|
||||
feeding_order: feeding_order
|
||||
return_info: return_info
|
||||
solutions: solutions
|
||||
solvents: solvents
|
||||
titration: titration
|
||||
schema:
|
||||
description: 计算实验设计,输出solutions/titration/solvents/feeding_order用于后续节点。
|
||||
properties:
|
||||
@@ -331,7 +356,7 @@ bioyond_dispensing_station:
|
||||
type: string
|
||||
ratio:
|
||||
description: 组分摩尔比的对象,保持输入顺序,如{"MDA":1,"BTDA":1}
|
||||
type: object
|
||||
type: string
|
||||
titration_percent:
|
||||
default: '0.03'
|
||||
description: 滴定比例(10%部分)
|
||||
@@ -346,23 +371,14 @@ bioyond_dispensing_station:
|
||||
result:
|
||||
properties:
|
||||
feeding_order:
|
||||
items: {}
|
||||
title: Feeding Order
|
||||
type: array
|
||||
return_info:
|
||||
title: Return Info
|
||||
type: string
|
||||
solutions:
|
||||
items: {}
|
||||
title: Solutions
|
||||
type: array
|
||||
solvents:
|
||||
additionalProperties: true
|
||||
title: Solvents
|
||||
type: object
|
||||
titration:
|
||||
additionalProperties: true
|
||||
title: Titration
|
||||
type: object
|
||||
required:
|
||||
- solutions
|
||||
@@ -370,11 +386,11 @@ bioyond_dispensing_station:
|
||||
- solvents
|
||||
- feeding_order
|
||||
- return_info
|
||||
title: ComputeExperimentDesignReturn
|
||||
title: ComputeExperimentDesign_Result
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: compute_experiment_design参数
|
||||
title: ComputeExperimentDesign
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
create_90_10_vial_feeding_task:
|
||||
@@ -428,18 +444,17 @@ bioyond_dispensing_station:
|
||||
speed: ''
|
||||
temperature: ''
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: DispenStationVialFeed_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
delay_time:
|
||||
type: string
|
||||
@@ -487,13 +502,38 @@ bioyond_dispensing_station:
|
||||
type: string
|
||||
temperature:
|
||||
type: string
|
||||
required:
|
||||
- order_name
|
||||
- percent_90_1_assign_material_name
|
||||
- percent_90_1_target_weigh
|
||||
- percent_90_2_assign_material_name
|
||||
- percent_90_2_target_weigh
|
||||
- percent_90_3_assign_material_name
|
||||
- percent_90_3_target_weigh
|
||||
- percent_10_1_assign_material_name
|
||||
- percent_10_1_target_weigh
|
||||
- percent_10_1_volume
|
||||
- percent_10_1_liquid_material_name
|
||||
- percent_10_2_assign_material_name
|
||||
- percent_10_2_target_weigh
|
||||
- percent_10_2_volume
|
||||
- percent_10_2_liquid_material_name
|
||||
- percent_10_3_assign_material_name
|
||||
- percent_10_3_target_weigh
|
||||
- percent_10_3_volume
|
||||
- percent_10_3_liquid_material_name
|
||||
- speed
|
||||
- temperature
|
||||
- delay_time
|
||||
- hold_m_name
|
||||
title: DispenStationVialFeed_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: DispenStationVialFeed_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -524,18 +564,17 @@ bioyond_dispensing_station:
|
||||
temperature: ''
|
||||
volume: ''
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: DispenStationSolnPrep_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
delay_time:
|
||||
type: string
|
||||
@@ -555,13 +594,24 @@ bioyond_dispensing_station:
|
||||
type: string
|
||||
volume:
|
||||
type: string
|
||||
required:
|
||||
- order_name
|
||||
- material_name
|
||||
- target_weigh
|
||||
- volume
|
||||
- liquid_material_name
|
||||
- speed
|
||||
- temperature
|
||||
- delay_time
|
||||
- hold_m_name
|
||||
title: DispenStationSolnPrep_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: DispenStationSolnPrep_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -574,8 +624,8 @@ bioyond_dispensing_station:
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
schema:
|
||||
description: 启动调度器 - 启动Bioyond配液站的任务调度器,开始执行队列中的任务
|
||||
properties:
|
||||
@@ -585,6 +635,12 @@ bioyond_dispensing_station:
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
return_info:
|
||||
description: 调度器启动结果,成功返回1,失败返回0
|
||||
type: integer
|
||||
required:
|
||||
- return_info
|
||||
title: scheduler_start结果
|
||||
type: object
|
||||
required:
|
||||
@@ -598,8 +654,8 @@ bioyond_dispensing_station:
|
||||
target_device_id: target_device_id
|
||||
transfer_groups: transfer_groups
|
||||
goal_default:
|
||||
target_device_id: null
|
||||
transfer_groups: null
|
||||
target_device_id: ''
|
||||
transfer_groups: ''
|
||||
handles: {}
|
||||
placeholder_keys:
|
||||
target_device_id: unilabos_devices
|
||||
@@ -615,13 +671,32 @@ bioyond_dispensing_station:
|
||||
type: string
|
||||
transfer_groups:
|
||||
description: 转移任务组列表,每组包含物料名称、目标堆栈和目标库位,可以添加多组
|
||||
items:
|
||||
properties:
|
||||
materials:
|
||||
description: 物料名称(手动输入,系统将通过RPC查询验证)
|
||||
type: string
|
||||
target_sites:
|
||||
description: 目标库位(手动输入,如"A01")
|
||||
type: string
|
||||
target_stack:
|
||||
description: 目标堆栈名称(从列表选择)
|
||||
enum:
|
||||
- 堆栈1左
|
||||
- 堆栈1右
|
||||
- 站内试剂存放堆栈
|
||||
type: string
|
||||
required:
|
||||
- materials
|
||||
- target_stack
|
||||
- target_sites
|
||||
type: object
|
||||
type: array
|
||||
required:
|
||||
- target_device_id
|
||||
- transfer_groups
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: transfer_materials_to_reaction_station参数
|
||||
@@ -634,9 +709,9 @@ bioyond_dispensing_station:
|
||||
check_interval: check_interval
|
||||
timeout: timeout
|
||||
goal_default:
|
||||
batch_create_result: null
|
||||
check_interval: 10
|
||||
timeout: 7200
|
||||
batch_create_result: ''
|
||||
check_interval: '10'
|
||||
timeout: '7200'
|
||||
handles:
|
||||
input:
|
||||
- data_key: batch_create_result
|
||||
@@ -652,35 +727,47 @@ bioyond_dispensing_station:
|
||||
handler_key: batch_reports_result
|
||||
io_type: sink
|
||||
label: Batch Order Completion Reports
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
schema:
|
||||
description: 同时等待多个任务完成并获取所有实验报告。从上游batch_create任务接收包含order_codes和order_ids的结果对象,并行监控所有任务状态并返回每个任务的报告。
|
||||
properties:
|
||||
feedback:
|
||||
properties: {}
|
||||
required: []
|
||||
title: WaitForMultipleOrdersAndGetReports_Feedback
|
||||
type: object
|
||||
goal:
|
||||
properties:
|
||||
batch_create_result:
|
||||
description: 批量创建任务的返回结果对象,包含order_codes和order_ids数组。从上游batch_create节点通过handle传递
|
||||
type: string
|
||||
check_interval:
|
||||
default: 10
|
||||
default: '10'
|
||||
description: 检查任务状态的时间间隔(秒),默认每10秒检查一次所有待完成任务
|
||||
type: integer
|
||||
type: string
|
||||
timeout:
|
||||
default: 7200
|
||||
default: '7200'
|
||||
description: 等待超时时间(秒),默认7200秒(2小时)。超过此时间未完成的任务将标记为timeout
|
||||
type: integer
|
||||
required: []
|
||||
type: string
|
||||
required:
|
||||
- batch_create_result
|
||||
title: WaitForMultipleOrdersAndGetReports_Goal
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
return_info:
|
||||
description: 'JSON格式的批量任务完成信息,包含: total(总数), completed(成功数), timeout(超时数),
|
||||
error(错误数), elapsed_time(总耗时), reports(报告数组,每个元素包含order_code,
|
||||
order_id, status, completion_status, report, elapsed_time)'
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: WaitForMultipleOrdersAndGetReports_Result
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: wait_for_multiple_orders_and_get_reports参数
|
||||
title: WaitForMultipleOrdersAndGetReports
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.devices.workstation.bioyond_studio.dispensing_station.dispensing_station:BioyondDispensingStation
|
||||
|
||||
81
unilabos/registry/devices/camera.yaml
Normal file
81
unilabos/registry/devices/camera.yaml
Normal file
@@ -0,0 +1,81 @@
|
||||
camera:
|
||||
category:
|
||||
- camera
|
||||
class:
|
||||
action_value_mappings:
|
||||
auto-destroy_node:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 用于安全地关闭摄像头设备,释放摄像头资源,停止视频采集和发布服务。调用此函数将清理OpenCV摄像头连接并销毁ROS2节点。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: destroy_node参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-timer_callback:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 定时器回调函数的参数schema。此函数负责定期采集摄像头视频帧,将OpenCV格式的图像转换为ROS Image消息格式,并发布到指定的视频话题。默认以10Hz频率执行,确保视频流的连续性和实时性。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: timer_callback参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.ros.nodes.presets.camera:VideoPublisher
|
||||
status_types: {}
|
||||
type: ros2
|
||||
config_info: []
|
||||
description: VideoPublisher摄像头设备节点,用于实时视频采集和流媒体发布。该设备通过OpenCV连接本地摄像头(如USB摄像头、内置摄像头等),定时采集视频帧并将其转换为ROS2的sensor_msgs/Image消息格式发布到视频话题。主要用于实验室自动化系统中的视觉监控、图像分析、实时观察等应用场景。支持可配置的摄像头索引、发布频率等参数。
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema:
|
||||
config:
|
||||
properties:
|
||||
camera_index:
|
||||
default: 0
|
||||
type: string
|
||||
device_id:
|
||||
default: video_publisher
|
||||
type: string
|
||||
device_uuid:
|
||||
default: ''
|
||||
type: string
|
||||
period:
|
||||
default: 0.1
|
||||
type: number
|
||||
registry_name:
|
||||
default: ''
|
||||
type: string
|
||||
resource_tracker:
|
||||
type: object
|
||||
required: []
|
||||
type: object
|
||||
data:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
version: 1.0.0
|
||||
@@ -18,7 +18,7 @@ cameracontroller_device:
|
||||
goal:
|
||||
properties:
|
||||
config:
|
||||
type: object
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
@@ -42,8 +42,7 @@ cameracontroller_device:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: stop参数
|
||||
@@ -51,7 +50,7 @@ cameracontroller_device:
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.devices.cameraSII.cameraUSB:CameraController
|
||||
status_types:
|
||||
status: Dict[str, Any]
|
||||
status: dict
|
||||
type: python
|
||||
config_info: []
|
||||
description: Uni-Lab-OS 摄像头驱动(Linux USB 摄像头版,无 PTZ)
|
||||
@@ -104,4 +103,5 @@ cameracontroller_device:
|
||||
required:
|
||||
- status
|
||||
type: object
|
||||
registry_type: device
|
||||
version: 1.0.0
|
||||
|
||||
@@ -141,26 +141,30 @@ hplc.agilent:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
title: SendCmd_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
command:
|
||||
type: string
|
||||
required:
|
||||
- command
|
||||
title: SendCmd_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
title: SendCmd_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -171,6 +175,7 @@ hplc.agilent:
|
||||
module: unilabos.devices.hplc.AgilentHPLC:HPLCDriver
|
||||
status_types:
|
||||
could_run: bool
|
||||
data_file: String
|
||||
device_status: str
|
||||
driver_init_ok: bool
|
||||
finish_status: str
|
||||
@@ -194,6 +199,10 @@ hplc.agilent:
|
||||
properties:
|
||||
could_run:
|
||||
type: boolean
|
||||
data_file:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
device_status:
|
||||
type: string
|
||||
driver_init_ok:
|
||||
@@ -207,13 +216,14 @@ hplc.agilent:
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- could_run
|
||||
- device_status
|
||||
- driver_init_ok
|
||||
- finish_status
|
||||
- is_running
|
||||
- status_text
|
||||
- device_status
|
||||
- could_run
|
||||
- driver_init_ok
|
||||
- is_running
|
||||
- success
|
||||
- finish_status
|
||||
- data_file
|
||||
type: object
|
||||
version: 1.0.0
|
||||
hplc.agilent-zhida:
|
||||
@@ -226,25 +236,26 @@ hplc.agilent-zhida:
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: EmptyIn_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: EmptyIn_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: EmptyIn_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -304,18 +315,21 @@ hplc.agilent-zhida:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: EmptyIn_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: EmptyIn_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: EmptyIn_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -327,35 +341,35 @@ hplc.agilent-zhida:
|
||||
feedback: {}
|
||||
goal:
|
||||
string: string
|
||||
text: text
|
||||
goal_default:
|
||||
string: ''
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
success: success
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: StrSingleInput_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
string:
|
||||
type: string
|
||||
required:
|
||||
- string
|
||||
title: StrSingleInput_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
title: StrSingleInput_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -393,7 +407,7 @@ hplc.agilent-zhida:
|
||||
status:
|
||||
type: object
|
||||
required:
|
||||
- methods
|
||||
- status
|
||||
- methods
|
||||
type: object
|
||||
version: 1.0.0
|
||||
|
||||
@@ -120,41 +120,42 @@ raman.home_made:
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
raman_cmd:
|
||||
feedback:
|
||||
status: status
|
||||
feedback: {}
|
||||
goal:
|
||||
command: command
|
||||
goal_default:
|
||||
command: ''
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
success: success
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
title: SendCmd_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
command:
|
||||
type: string
|
||||
required:
|
||||
- command
|
||||
title: SendCmd_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
title: SendCmd_Result
|
||||
type: object
|
||||
required:
|
||||
|
||||
@@ -19,8 +19,7 @@ separator.chinwe:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: connect参数
|
||||
@@ -66,145 +65,135 @@ separator.chinwe:
|
||||
required:
|
||||
- command_dict
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: execute_command_from_outer参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
motor_rotate_quarter:
|
||||
feedback: {}
|
||||
goal:
|
||||
direction: 顺时针
|
||||
motor_id: 4
|
||||
speed: 60
|
||||
goal_default:
|
||||
direction: 顺时针
|
||||
motor_id: null
|
||||
speed: 60
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 电机旋转 1/4 圈
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
direction:
|
||||
default: 顺时针
|
||||
description: 旋转方向
|
||||
enum:
|
||||
- 顺时针
|
||||
- 逆时针
|
||||
type: string
|
||||
motor_id:
|
||||
default: '4'
|
||||
description: 选择电机 (4:搅拌, 5:旋钮)
|
||||
type: integer
|
||||
enum:
|
||||
- '4'
|
||||
- '5'
|
||||
type: string
|
||||
speed:
|
||||
default: 60
|
||||
description: 速度 (RPM)
|
||||
type: integer
|
||||
required:
|
||||
- motor_id
|
||||
- speed
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: motor_rotate_quarter参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
motor_run_continuous:
|
||||
feedback: {}
|
||||
goal:
|
||||
direction: 顺时针
|
||||
motor_id: 4
|
||||
speed: 60
|
||||
goal_default:
|
||||
direction: 顺时针
|
||||
motor_id: null
|
||||
speed: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 电机一直旋转 (速度模式)
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
direction:
|
||||
default: 顺时针
|
||||
description: 旋转方向
|
||||
enum:
|
||||
- 顺时针
|
||||
- 逆时针
|
||||
type: string
|
||||
motor_id:
|
||||
default: '4'
|
||||
description: 选择电机 (4:搅拌, 5:旋钮)
|
||||
type: integer
|
||||
enum:
|
||||
- '4'
|
||||
- '5'
|
||||
type: string
|
||||
speed:
|
||||
default: 60
|
||||
description: 速度 (RPM)
|
||||
type: integer
|
||||
required:
|
||||
- motor_id
|
||||
- speed
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: motor_run_continuous参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
motor_stop:
|
||||
feedback: {}
|
||||
goal:
|
||||
motor_id: 4
|
||||
goal_default:
|
||||
motor_id: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 停止指定步进电机
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
motor_id:
|
||||
default: '4'
|
||||
description: 选择电机
|
||||
enum:
|
||||
- '4'
|
||||
- '5'
|
||||
title: '注: 4=搅拌, 5=旋钮'
|
||||
type: integer
|
||||
type: string
|
||||
required:
|
||||
- motor_id
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: motor_stop参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
pump_aspirate:
|
||||
feedback: {}
|
||||
goal:
|
||||
pump_id: 1
|
||||
valve_port: 1
|
||||
volume: 1000
|
||||
goal_default:
|
||||
pump_id: null
|
||||
valve_port: null
|
||||
volume: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 注射泵吸液
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
pump_id:
|
||||
default: '1'
|
||||
description: 选择泵
|
||||
type: integer
|
||||
enum:
|
||||
- '1'
|
||||
- '2'
|
||||
- '3'
|
||||
type: string
|
||||
valve_port:
|
||||
default: '1'
|
||||
description: 阀门端口
|
||||
type: integer
|
||||
enum:
|
||||
- '1'
|
||||
- '2'
|
||||
- '3'
|
||||
- '4'
|
||||
- '5'
|
||||
- '6'
|
||||
- '7'
|
||||
- '8'
|
||||
type: string
|
||||
volume:
|
||||
default: 1000
|
||||
description: 吸液步数
|
||||
type: integer
|
||||
required:
|
||||
@@ -212,38 +201,41 @@ separator.chinwe:
|
||||
- volume
|
||||
- valve_port
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: pump_aspirate参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
pump_dispense:
|
||||
feedback: {}
|
||||
goal:
|
||||
pump_id: 1
|
||||
valve_port: 1
|
||||
volume: 1000
|
||||
goal_default:
|
||||
pump_id: null
|
||||
valve_port: null
|
||||
volume: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 注射泵排液
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
pump_id:
|
||||
default: '1'
|
||||
description: 选择泵
|
||||
type: integer
|
||||
enum:
|
||||
- '1'
|
||||
- '2'
|
||||
- '3'
|
||||
type: string
|
||||
valve_port:
|
||||
default: '1'
|
||||
description: 阀门端口
|
||||
type: integer
|
||||
enum:
|
||||
- '1'
|
||||
- '2'
|
||||
- '3'
|
||||
- '4'
|
||||
- '5'
|
||||
- '6'
|
||||
- '7'
|
||||
- '8'
|
||||
type: string
|
||||
volume:
|
||||
default: 1000
|
||||
description: 排液步数
|
||||
type: integer
|
||||
required:
|
||||
@@ -251,152 +243,121 @@ separator.chinwe:
|
||||
- volume
|
||||
- valve_port
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: pump_dispense参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
pump_initialize:
|
||||
feedback: {}
|
||||
goal:
|
||||
drain_port: 0
|
||||
output_port: 0
|
||||
pump_id: 1
|
||||
speed: 10
|
||||
goal_default:
|
||||
drain_port: 0
|
||||
output_port: 0
|
||||
pump_id: null
|
||||
speed: 10
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 初始化指定注射泵
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
drain_port:
|
||||
default: 0
|
||||
description: 排液口索引
|
||||
type: string
|
||||
type: integer
|
||||
output_port:
|
||||
default: 0
|
||||
description: 输出口索引
|
||||
type: string
|
||||
pump_id:
|
||||
description: 选择泵
|
||||
title: '注: 1号泵, 2号泵, 3号泵'
|
||||
type: integer
|
||||
pump_id:
|
||||
default: '1'
|
||||
description: 选择泵
|
||||
enum:
|
||||
- '1'
|
||||
- '2'
|
||||
- '3'
|
||||
title: '注: 1号泵, 2号泵, 3号泵'
|
||||
type: string
|
||||
speed:
|
||||
default: 10
|
||||
description: 运动速度
|
||||
type: string
|
||||
type: integer
|
||||
required:
|
||||
- pump_id
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: pump_initialize参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
pump_valve:
|
||||
feedback: {}
|
||||
goal:
|
||||
port: 1
|
||||
pump_id: 1
|
||||
goal_default:
|
||||
port: null
|
||||
pump_id: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 切换指定泵的阀门端口
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
port:
|
||||
default: '1'
|
||||
description: 阀门端口号 (1-8)
|
||||
type: integer
|
||||
enum:
|
||||
- '1'
|
||||
- '2'
|
||||
- '3'
|
||||
- '4'
|
||||
- '5'
|
||||
- '6'
|
||||
- '7'
|
||||
- '8'
|
||||
type: string
|
||||
pump_id:
|
||||
default: '1'
|
||||
description: 选择泵
|
||||
type: integer
|
||||
enum:
|
||||
- '1'
|
||||
- '2'
|
||||
- '3'
|
||||
type: string
|
||||
required:
|
||||
- pump_id
|
||||
- port
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: pump_valve参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
wait_sensor_level:
|
||||
feedback: {}
|
||||
goal:
|
||||
target_state: 有液
|
||||
timeout: 30
|
||||
goal_default:
|
||||
target_state: 有液
|
||||
timeout: 30
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 等待传感器液位条件
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
target_state:
|
||||
default: 有液
|
||||
description: 目标液位状态
|
||||
enum:
|
||||
- 有液
|
||||
- 无液
|
||||
type: string
|
||||
timeout:
|
||||
default: 30
|
||||
description: 超时时间 (秒)
|
||||
type: integer
|
||||
required: []
|
||||
required:
|
||||
- target_state
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: wait_sensor_level参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
wait_time:
|
||||
feedback: {}
|
||||
goal:
|
||||
duration: 10
|
||||
goal_default:
|
||||
duration: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 等待指定时间
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
duration:
|
||||
default: 10
|
||||
description: 等待时间 (秒)
|
||||
type: integer
|
||||
required:
|
||||
- duration
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: wait_time参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.devices.separator.chinwe:ChinweDevice
|
||||
status_types:
|
||||
@@ -445,8 +406,8 @@ separator.chinwe:
|
||||
sensor_rssi:
|
||||
type: integer
|
||||
required:
|
||||
- is_connected
|
||||
- sensor_level
|
||||
- sensor_rssi
|
||||
- is_connected
|
||||
type: object
|
||||
version: 2.1.0
|
||||
|
||||
@@ -64,8 +64,7 @@ coincellassemblyworkstation_device:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: fun_wuliao_test参数
|
||||
@@ -110,8 +109,7 @@ coincellassemblyworkstation_device:
|
||||
- elec_num
|
||||
- elec_use_num
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_allpack_cmd参数
|
||||
@@ -222,8 +220,7 @@ coincellassemblyworkstation_device:
|
||||
- elec_num
|
||||
- elec_use_num
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_allpack_cmd_simp参数
|
||||
@@ -312,8 +309,7 @@ coincellassemblyworkstation_device:
|
||||
type: boolean
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_device_init_auto_start_combined参数
|
||||
@@ -355,8 +351,7 @@ coincellassemblyworkstation_device:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_device_stop参数
|
||||
@@ -381,8 +376,7 @@ coincellassemblyworkstation_device:
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_get_msg_cmd参数
|
||||
@@ -436,8 +430,7 @@ coincellassemblyworkstation_device:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_send_finished_cmd参数
|
||||
@@ -474,8 +467,7 @@ coincellassemblyworkstation_device:
|
||||
- assembly_type
|
||||
- assembly_pressure
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_send_msg_cmd参数
|
||||
@@ -619,8 +611,7 @@ coincellassemblyworkstation_device:
|
||||
- elec_num
|
||||
- elec_use_num
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: func_sendbottle_allpack_multi参数
|
||||
@@ -672,6 +663,31 @@ coincellassemblyworkstation_device:
|
||||
title: modify_deck_name参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-post_init:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
ros_node: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
ros_node:
|
||||
type: object
|
||||
required:
|
||||
- ros_node
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: post_init参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-qiming_coin_cell_code:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
@@ -719,8 +735,7 @@ coincellassemblyworkstation_device:
|
||||
required:
|
||||
- fujipian_panshu
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: qiming_coin_cell_code参数
|
||||
@@ -811,24 +826,25 @@ coincellassemblyworkstation_device:
|
||||
sys_status:
|
||||
type: string
|
||||
required:
|
||||
- sys_status
|
||||
- sys_mode
|
||||
- request_rec_msg_status
|
||||
- request_send_msg_status
|
||||
- data_assembly_coin_cell_num
|
||||
- data_assembly_pressure
|
||||
- data_assembly_time
|
||||
- data_open_circuit_voltage
|
||||
- data_axis_x_pos
|
||||
- data_axis_y_pos
|
||||
- data_axis_z_pos
|
||||
- data_coin_cell_code
|
||||
- data_coin_num
|
||||
- data_electrolyte_code
|
||||
- data_electrolyte_volume
|
||||
- data_glove_box_o2_content
|
||||
- data_glove_box_pressure
|
||||
- data_glove_box_water_content
|
||||
- data_open_circuit_voltage
|
||||
- data_pole_weight
|
||||
- request_rec_msg_status
|
||||
- request_send_msg_status
|
||||
- sys_mode
|
||||
- sys_status
|
||||
- data_assembly_pressure
|
||||
- data_electrolyte_volume
|
||||
- data_coin_num
|
||||
- data_coin_cell_code
|
||||
- data_electrolyte_code
|
||||
- data_glove_box_pressure
|
||||
- data_glove_box_o2_content
|
||||
- data_glove_box_water_content
|
||||
type: object
|
||||
registry_type: device
|
||||
version: 1.0.0
|
||||
|
||||
@@ -50,25 +50,26 @@ gas_source.mock:
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: EmptyIn_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: EmptyIn_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: EmptyIn_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -81,25 +82,26 @@ gas_source.mock:
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: EmptyIn_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: EmptyIn_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: EmptyIn_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -114,31 +116,32 @@ gas_source.mock:
|
||||
goal_default:
|
||||
string: ''
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
success: success
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: StrSingleInput_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
string:
|
||||
type: string
|
||||
required:
|
||||
- string
|
||||
title: StrSingleInput_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
title: StrSingleInput_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -229,25 +232,26 @@ vacuum_pump.mock:
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: EmptyIn_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: EmptyIn_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: EmptyIn_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -260,25 +264,26 @@ vacuum_pump.mock:
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: EmptyIn_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: EmptyIn_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: EmptyIn_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -293,31 +298,32 @@ vacuum_pump.mock:
|
||||
goal_default:
|
||||
string: ''
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
success: success
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: StrSingleInput_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
string:
|
||||
type: string
|
||||
required:
|
||||
- string
|
||||
title: StrSingleInput_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
title: StrSingleInput_Result
|
||||
type: object
|
||||
required:
|
||||
|
||||
@@ -5,7 +5,7 @@ hotel.thermo_orbitor_rs2_hotel:
|
||||
action_value_mappings: {}
|
||||
module: unilabos.devices.resource_container.container:HotelContainer
|
||||
status_types:
|
||||
rotation: ''
|
||||
rotation: String
|
||||
type: python
|
||||
config_info: []
|
||||
description: Thermo Orbitor RS2 Hotel容器设备,用于实验室样品的存储和管理。该设备通过HotelContainer类实现容器的旋转控制和状态监控,主要用于存储实验样品、试剂瓶或其他实验器具,支持旋转功能以便于样品的自动化存取。适用于需要有序存储和快速访问大量样品的实验室自动化场景。
|
||||
|
||||
@@ -22,8 +22,7 @@ xyz_stepper_controller:
|
||||
required:
|
||||
- degrees
|
||||
type: object
|
||||
result:
|
||||
type: integer
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: degrees_to_steps参数
|
||||
@@ -48,8 +47,7 @@ xyz_stepper_controller:
|
||||
required:
|
||||
- axis
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: emergency_stop参数
|
||||
@@ -74,10 +72,7 @@ xyz_stepper_controller:
|
||||
type: boolean
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: enable_all_axes参数
|
||||
@@ -106,8 +101,7 @@ xyz_stepper_controller:
|
||||
required:
|
||||
- axis
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: enable_motor参数
|
||||
@@ -128,10 +122,7 @@ xyz_stepper_controller:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: home_all_axes参数
|
||||
@@ -156,8 +147,7 @@ xyz_stepper_controller:
|
||||
required:
|
||||
- axis
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: home_axis参数
|
||||
@@ -198,8 +188,7 @@ xyz_stepper_controller:
|
||||
- axis
|
||||
- position
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: move_to_position参数
|
||||
@@ -240,8 +229,7 @@ xyz_stepper_controller:
|
||||
- axis
|
||||
- degrees
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: move_to_position_degrees参数
|
||||
@@ -282,8 +270,7 @@ xyz_stepper_controller:
|
||||
- axis
|
||||
- revolutions
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: move_to_position_revolutions参数
|
||||
@@ -314,17 +301,14 @@ xyz_stepper_controller:
|
||||
default: 5000
|
||||
type: integer
|
||||
x:
|
||||
type: integer
|
||||
type: string
|
||||
y:
|
||||
type: integer
|
||||
type: string
|
||||
z:
|
||||
type: integer
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: move_xyz参数
|
||||
@@ -355,17 +339,14 @@ xyz_stepper_controller:
|
||||
default: 5000
|
||||
type: integer
|
||||
x_deg:
|
||||
type: number
|
||||
type: string
|
||||
y_deg:
|
||||
type: number
|
||||
type: string
|
||||
z_deg:
|
||||
type: number
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: move_xyz_degrees参数
|
||||
@@ -396,17 +377,14 @@ xyz_stepper_controller:
|
||||
default: 5000
|
||||
type: integer
|
||||
x_rev:
|
||||
type: number
|
||||
type: string
|
||||
y_rev:
|
||||
type: number
|
||||
type: string
|
||||
z_rev:
|
||||
type: number
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: move_xyz_revolutions参数
|
||||
@@ -431,8 +409,7 @@ xyz_stepper_controller:
|
||||
required:
|
||||
- revolutions
|
||||
type: object
|
||||
result:
|
||||
type: integer
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: revolutions_to_steps参数
|
||||
@@ -465,8 +442,7 @@ xyz_stepper_controller:
|
||||
- axis
|
||||
- speed
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: set_speed_mode参数
|
||||
@@ -491,8 +467,7 @@ xyz_stepper_controller:
|
||||
required:
|
||||
- steps
|
||||
type: object
|
||||
result:
|
||||
type: number
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: steps_to_degrees参数
|
||||
@@ -517,8 +492,7 @@ xyz_stepper_controller:
|
||||
required:
|
||||
- steps
|
||||
type: object
|
||||
result:
|
||||
type: number
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: steps_to_revolutions参数
|
||||
@@ -539,10 +513,7 @@ xyz_stepper_controller:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: stop_all_axes参数
|
||||
@@ -571,8 +542,7 @@ xyz_stepper_controller:
|
||||
required:
|
||||
- axis
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: wait_for_completion参数
|
||||
@@ -580,7 +550,8 @@ xyz_stepper_controller:
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.devices.liquid_handling.laiyu.drivers.xyz_stepper_driver:XYZStepperController
|
||||
status_types:
|
||||
all_positions: Dict[MotorAxis, MotorPosition]
|
||||
all_positions: dict
|
||||
motor_status: unilabos.devices.liquid_handling.laiyu.drivers.xyz_stepper_driver:MotorPosition
|
||||
type: python
|
||||
config_info: []
|
||||
description: 新XYZ控制器
|
||||
@@ -603,10 +574,12 @@ xyz_stepper_controller:
|
||||
data:
|
||||
properties:
|
||||
all_positions:
|
||||
additionalProperties:
|
||||
type: object
|
||||
type: object
|
||||
motor_status:
|
||||
type: object
|
||||
required:
|
||||
- motor_status
|
||||
- all_positions
|
||||
type: object
|
||||
registry_type: device
|
||||
version: 1.0.0
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,31 @@ neware_battery_test_system:
|
||||
- battery_test
|
||||
class:
|
||||
action_value_mappings:
|
||||
auto-post_init:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
ros_node: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
ros_node:
|
||||
type: string
|
||||
required:
|
||||
- ros_node
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: post_init参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-print_status_summary:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
@@ -41,8 +66,7 @@ neware_battery_test_system:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
type: boolean
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: test_connection参数
|
||||
@@ -53,8 +77,9 @@ neware_battery_test_system:
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
success: success
|
||||
schema:
|
||||
description: 调试方法:显示所有资源的实际名称
|
||||
properties:
|
||||
@@ -64,10 +89,19 @@ neware_battery_test_system:
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
return_info:
|
||||
description: 资源调试信息
|
||||
type: string
|
||||
success:
|
||||
description: 是否成功
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: debug_resource_names参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
export_status_json:
|
||||
@@ -77,8 +111,9 @@ neware_battery_test_system:
|
||||
goal_default:
|
||||
filepath: bts_status.json
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
success: success
|
||||
schema:
|
||||
description: 导出当前状态数据到JSON文件
|
||||
properties:
|
||||
@@ -92,10 +127,19 @@ neware_battery_test_system:
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
return_info:
|
||||
description: 导出操作结果信息
|
||||
type: string
|
||||
success:
|
||||
description: 导出是否成功
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: export_status_json参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
get_device_summary:
|
||||
@@ -137,8 +181,10 @@ neware_battery_test_system:
|
||||
goal_default:
|
||||
plate_num: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
result:
|
||||
plate_data: plate_data
|
||||
return_info: return_info
|
||||
success: success
|
||||
schema:
|
||||
description: 获取指定盘或所有盘的状态信息
|
||||
properties:
|
||||
@@ -147,14 +193,29 @@ neware_battery_test_system:
|
||||
properties:
|
||||
plate_num:
|
||||
description: 盘号 (1 或 2),如果为null则返回所有盘的状态
|
||||
maximum: 2
|
||||
minimum: 1
|
||||
type: integer
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
plate_data:
|
||||
description: 盘状态数据(单盘或所有盘)
|
||||
type: object
|
||||
return_info:
|
||||
description: 操作结果信息
|
||||
type: string
|
||||
success:
|
||||
description: 查询是否成功
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
- plate_data
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: get_plate_status参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
print_status_summary_action:
|
||||
@@ -162,8 +223,9 @@ neware_battery_test_system:
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
success: success
|
||||
schema:
|
||||
description: 打印通道状态摘要信息到控制台
|
||||
properties:
|
||||
@@ -173,21 +235,28 @@ neware_battery_test_system:
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
return_info:
|
||||
description: 打印操作结果信息
|
||||
type: string
|
||||
success:
|
||||
description: 打印是否成功
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: print_status_summary_action参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
query_plate_action:
|
||||
feedback: {}
|
||||
goal:
|
||||
plate_id: plate_id
|
||||
string: string
|
||||
string: plate_id
|
||||
goal_default:
|
||||
string: ''
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
success: success
|
||||
@@ -195,23 +264,27 @@ neware_battery_test_system:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: StrSingleInput_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
string:
|
||||
type: string
|
||||
required:
|
||||
- string
|
||||
title: StrSingleInput_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
title: StrSingleInput_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -225,11 +298,13 @@ neware_battery_test_system:
|
||||
csv_path: string
|
||||
output_dir: string
|
||||
goal_default:
|
||||
csv_path: null
|
||||
csv_path: ''
|
||||
output_dir: .
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
submitted_count: submitted_count
|
||||
success: success
|
||||
schema:
|
||||
description: 从CSV文件批量提交Neware测试任务
|
||||
properties:
|
||||
@@ -240,17 +315,31 @@ neware_battery_test_system:
|
||||
description: 输入CSV文件的绝对路径
|
||||
type: string
|
||||
output_dir:
|
||||
default: .
|
||||
description: 输出目录(用于存储XML和备份文件),默认当前目录
|
||||
type: string
|
||||
required:
|
||||
- csv_path
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
return_info:
|
||||
description: 执行结果详细信息
|
||||
type: string
|
||||
submitted_count:
|
||||
description: 成功提交的任务数量
|
||||
type: integer
|
||||
success:
|
||||
description: 是否成功
|
||||
type: boolean
|
||||
total_count:
|
||||
description: CSV文件中的总行数
|
||||
type: integer
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: submit_from_csv参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
test_connection_action:
|
||||
@@ -258,8 +347,9 @@ neware_battery_test_system:
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
success: success
|
||||
schema:
|
||||
description: 测试与电池测试系统的TCP连接
|
||||
properties:
|
||||
@@ -269,10 +359,19 @@ neware_battery_test_system:
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
return_info:
|
||||
description: 连接测试结果信息
|
||||
type: string
|
||||
success:
|
||||
description: 连接测试是否成功
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: test_connection_action参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
upload_backup_to_oss:
|
||||
@@ -293,8 +392,12 @@ neware_battery_test_system:
|
||||
handler_key: uploaded_files
|
||||
io_type: sink
|
||||
label: Uploaded Files (with standard flow info)
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
result:
|
||||
failed_files: failed_files
|
||||
return_info: return_info
|
||||
success: success
|
||||
total_count: total_count
|
||||
uploaded_count: uploaded_count
|
||||
schema:
|
||||
description: 上传备份文件到阿里云OSS
|
||||
properties:
|
||||
@@ -314,17 +417,65 @@ neware_battery_test_system:
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
failed_files:
|
||||
description: 上传失败的文件名列表
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
return_info:
|
||||
description: 上传操作结果信息
|
||||
type: string
|
||||
success:
|
||||
description: 上传是否成功
|
||||
type: boolean
|
||||
total_count:
|
||||
description: 总文件数
|
||||
type: integer
|
||||
uploaded_count:
|
||||
description: 成功上传的文件数
|
||||
type: integer
|
||||
uploaded_files:
|
||||
description: 成功上传的文件详情列表
|
||||
items:
|
||||
properties:
|
||||
Battery_Code:
|
||||
description: 电池编码
|
||||
type: string
|
||||
Electrolyte_Code:
|
||||
description: 电解液编码
|
||||
type: string
|
||||
filename:
|
||||
description: 文件名
|
||||
type: string
|
||||
url:
|
||||
description: OSS下载链接
|
||||
type: string
|
||||
required:
|
||||
- filename
|
||||
- url
|
||||
- Battery_Code
|
||||
- Electrolyte_Code
|
||||
type: object
|
||||
type: array
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
- uploaded_count
|
||||
- total_count
|
||||
- failed_files
|
||||
- uploaded_files
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: upload_backup_to_oss参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.devices.neware_battery_test_system.neware_battery_test_system:NewareBatteryTestSystem
|
||||
status_types:
|
||||
channel_status: Dict[int, Dict]
|
||||
connection_info: Dict[str, str]
|
||||
channel_status: dict
|
||||
connection_info: dict
|
||||
device_summary: dict
|
||||
plate_status: dict
|
||||
status: str
|
||||
total_channels: int
|
||||
type: python
|
||||
@@ -366,24 +517,23 @@ neware_battery_test_system:
|
||||
data:
|
||||
properties:
|
||||
channel_status:
|
||||
additionalProperties:
|
||||
type: object
|
||||
type: object
|
||||
connection_info:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
device_summary:
|
||||
type: object
|
||||
plate_status:
|
||||
type: object
|
||||
status:
|
||||
type: string
|
||||
total_channels:
|
||||
type: integer
|
||||
required:
|
||||
- status
|
||||
- channel_status
|
||||
- connection_info
|
||||
- device_summary
|
||||
- status
|
||||
- total_channels
|
||||
- plate_status
|
||||
- device_summary
|
||||
type: object
|
||||
version: 1.0.0
|
||||
|
||||
@@ -142,7 +142,8 @@ opcua_example:
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.device_comms.opcua_client.client:OpcUaClient
|
||||
status_types: {}
|
||||
status_types:
|
||||
node_value: String
|
||||
type: python
|
||||
config_info: []
|
||||
description: null
|
||||
@@ -166,7 +167,10 @@ opcua_example:
|
||||
- url
|
||||
type: object
|
||||
data:
|
||||
properties: {}
|
||||
required: []
|
||||
properties:
|
||||
node_value:
|
||||
type: string
|
||||
required:
|
||||
- node_value
|
||||
type: object
|
||||
version: 1.0.0
|
||||
|
||||
@@ -80,8 +80,7 @@ opsky_ATR30007:
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: run_once参数
|
||||
|
||||
@@ -100,41 +100,42 @@ rotavap.one:
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
set_timer:
|
||||
feedback:
|
||||
status: status
|
||||
feedback: {}
|
||||
goal:
|
||||
command: command
|
||||
goal_default:
|
||||
command: ''
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
success: success
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
title: SendCmd_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
command:
|
||||
type: string
|
||||
required:
|
||||
- command
|
||||
title: SendCmd_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
title: SendCmd_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -249,13 +250,9 @@ separator.homemade:
|
||||
feedback:
|
||||
status: status
|
||||
goal:
|
||||
event: event
|
||||
settling_time: settling_time
|
||||
stir_speed: stir_speed
|
||||
stir_time: stir_time
|
||||
time: time
|
||||
time_spec: time_spec
|
||||
vessel: vessel
|
||||
stir_time: stir_time,
|
||||
goal_default:
|
||||
event: ''
|
||||
settling_time: ''
|
||||
@@ -284,42 +281,34 @@ separator.homemade:
|
||||
sample_id: ''
|
||||
type: ''
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
message: message
|
||||
return_info: return_info
|
||||
success: success
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
title: Stir_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
event:
|
||||
type: string
|
||||
settling_time:
|
||||
type: string
|
||||
stir_speed:
|
||||
maximum: 1.7976931348623157e+308
|
||||
minimum: -1.7976931348623157e+308
|
||||
type: number
|
||||
stir_time:
|
||||
maximum: 1.7976931348623157e+308
|
||||
minimum: -1.7976931348623157e+308
|
||||
type: number
|
||||
time:
|
||||
type: string
|
||||
time_spec:
|
||||
type: string
|
||||
vessel:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
category:
|
||||
type: string
|
||||
@@ -338,26 +327,16 @@ separator.homemade:
|
||||
parent:
|
||||
type: string
|
||||
pose:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
orientation:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
w:
|
||||
maximum: 1.7976931348623157e+308
|
||||
minimum: -1.7976931348623157e+308
|
||||
type: number
|
||||
x:
|
||||
maximum: 1.7976931348623157e+308
|
||||
minimum: -1.7976931348623157e+308
|
||||
type: number
|
||||
y:
|
||||
maximum: 1.7976931348623157e+308
|
||||
minimum: -1.7976931348623157e+308
|
||||
type: number
|
||||
z:
|
||||
maximum: 1.7976931348623157e+308
|
||||
minimum: -1.7976931348623157e+308
|
||||
type: number
|
||||
required:
|
||||
- x
|
||||
@@ -367,19 +346,12 @@ separator.homemade:
|
||||
title: orientation
|
||||
type: object
|
||||
position:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
x:
|
||||
maximum: 1.7976931348623157e+308
|
||||
minimum: -1.7976931348623157e+308
|
||||
type: number
|
||||
y:
|
||||
maximum: 1.7976931348623157e+308
|
||||
minimum: -1.7976931348623157e+308
|
||||
type: number
|
||||
z:
|
||||
maximum: 1.7976931348623157e+308
|
||||
minimum: -1.7976931348623157e+308
|
||||
type: number
|
||||
required:
|
||||
- x
|
||||
@@ -409,10 +381,17 @@ separator.homemade:
|
||||
- data
|
||||
title: vessel
|
||||
type: object
|
||||
required:
|
||||
- vessel
|
||||
- time
|
||||
- event
|
||||
- time_spec
|
||||
- stir_time
|
||||
- stir_speed
|
||||
- settling_time
|
||||
title: Stir_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
@@ -420,6 +399,10 @@ separator.homemade:
|
||||
type: string
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- success
|
||||
- message
|
||||
- return_info
|
||||
title: Stir_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -435,34 +418,36 @@ separator.homemade:
|
||||
goal_default:
|
||||
command: ''
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
success: success
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
title: SendCmd_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
command:
|
||||
type: string
|
||||
required:
|
||||
- command
|
||||
title: SendCmd_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
title: SendCmd_Result
|
||||
type: object
|
||||
required:
|
||||
|
||||
@@ -28,6 +28,31 @@ post_process_station:
|
||||
title: load_config参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-post_init:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
ros_node: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
ros_node:
|
||||
type: string
|
||||
required:
|
||||
- ros_node
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: post_init参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-print_cache_stats:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
@@ -79,41 +104,42 @@ post_process_station:
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
disconnect:
|
||||
feedback:
|
||||
status: status
|
||||
feedback: {}
|
||||
goal:
|
||||
command: command
|
||||
command: {}
|
||||
goal_default:
|
||||
command: ''
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
success: success
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
title: SendCmd_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
command:
|
||||
type: string
|
||||
required:
|
||||
- command
|
||||
title: SendCmd_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
title: SendCmd_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -123,41 +149,42 @@ post_process_station:
|
||||
type: SendCmd
|
||||
read_node:
|
||||
feedback:
|
||||
status: status
|
||||
result: result
|
||||
goal:
|
||||
command: command
|
||||
node_name: node_name
|
||||
command: node_name
|
||||
goal_default:
|
||||
command: ''
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
success: success
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
title: SendCmd_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
command:
|
||||
type: string
|
||||
required:
|
||||
- command
|
||||
title: SendCmd_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
title: SendCmd_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -256,19 +283,17 @@ post_process_station:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: PostProcessTriggerClean_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
acetone_inner_wall_cleaning_count:
|
||||
maximum: 2147483647
|
||||
minimum: -2147483648
|
||||
type: integer
|
||||
acetone_inner_wall_cleaning_injection:
|
||||
maximum: 1.7976931348623157e+308
|
||||
minimum: -1.7976931348623157e+308
|
||||
type: number
|
||||
acetone_inner_wall_cleaning_waste_time:
|
||||
maximum: 2147483647
|
||||
@@ -279,8 +304,6 @@ post_process_station:
|
||||
minimum: -2147483648
|
||||
type: integer
|
||||
acetone_outer_wall_cleaning_injection:
|
||||
maximum: 1.7976931348623157e+308
|
||||
minimum: -1.7976931348623157e+308
|
||||
type: number
|
||||
acetone_outer_wall_cleaning_wait_time:
|
||||
maximum: 2147483647
|
||||
@@ -299,8 +322,6 @@ post_process_station:
|
||||
minimum: -2147483648
|
||||
type: integer
|
||||
acetone_stirrer_cleaning_injection:
|
||||
maximum: 1.7976931348623157e+308
|
||||
minimum: -1.7976931348623157e+308
|
||||
type: number
|
||||
acetone_stirrer_cleaning_wait_time:
|
||||
maximum: 2147483647
|
||||
@@ -327,8 +348,6 @@ post_process_station:
|
||||
minimum: -2147483648
|
||||
type: integer
|
||||
nmp_inner_wall_cleaning_injection:
|
||||
maximum: 1.7976931348623157e+308
|
||||
minimum: -1.7976931348623157e+308
|
||||
type: number
|
||||
nmp_inner_wall_cleaning_waste_time:
|
||||
maximum: 2147483647
|
||||
@@ -339,8 +358,6 @@ post_process_station:
|
||||
minimum: -2147483648
|
||||
type: integer
|
||||
nmp_outer_wall_cleaning_injection:
|
||||
maximum: 1.7976931348623157e+308
|
||||
minimum: -1.7976931348623157e+308
|
||||
type: number
|
||||
nmp_outer_wall_cleaning_wait_time:
|
||||
maximum: 2147483647
|
||||
@@ -359,8 +376,6 @@ post_process_station:
|
||||
minimum: -2147483648
|
||||
type: integer
|
||||
nmp_stirrer_cleaning_injection:
|
||||
maximum: 1.7976931348623157e+308
|
||||
minimum: -1.7976931348623157e+308
|
||||
type: number
|
||||
nmp_stirrer_cleaning_wait_time:
|
||||
maximum: 2147483647
|
||||
@@ -379,8 +394,6 @@ post_process_station:
|
||||
minimum: -2147483648
|
||||
type: integer
|
||||
water_inner_wall_cleaning_injection:
|
||||
maximum: 1.7976931348623157e+308
|
||||
minimum: -1.7976931348623157e+308
|
||||
type: number
|
||||
water_inner_wall_cleaning_waste_time:
|
||||
maximum: 2147483647
|
||||
@@ -391,8 +404,6 @@ post_process_station:
|
||||
minimum: -2147483648
|
||||
type: integer
|
||||
water_outer_wall_cleaning_injection:
|
||||
maximum: 1.7976931348623157e+308
|
||||
minimum: -1.7976931348623157e+308
|
||||
type: number
|
||||
water_outer_wall_cleaning_wait_time:
|
||||
maximum: 2147483647
|
||||
@@ -411,8 +422,6 @@ post_process_station:
|
||||
minimum: -2147483648
|
||||
type: integer
|
||||
water_stirrer_cleaning_injection:
|
||||
maximum: 1.7976931348623157e+308
|
||||
minimum: -1.7976931348623157e+308
|
||||
type: number
|
||||
water_stirrer_cleaning_wait_time:
|
||||
maximum: 2147483647
|
||||
@@ -422,13 +431,55 @@ post_process_station:
|
||||
maximum: 2147483647
|
||||
minimum: -2147483648
|
||||
type: integer
|
||||
required:
|
||||
- nmp_outer_wall_cleaning_injection
|
||||
- nmp_outer_wall_cleaning_count
|
||||
- nmp_outer_wall_cleaning_wait_time
|
||||
- nmp_outer_wall_cleaning_waste_time
|
||||
- nmp_inner_wall_cleaning_injection
|
||||
- nmp_inner_wall_cleaning_count
|
||||
- nmp_pump_cleaning_suction_count
|
||||
- nmp_inner_wall_cleaning_waste_time
|
||||
- nmp_stirrer_cleaning_injection
|
||||
- nmp_stirrer_cleaning_count
|
||||
- nmp_stirrer_cleaning_wait_time
|
||||
- nmp_stirrer_cleaning_waste_time
|
||||
- water_outer_wall_cleaning_injection
|
||||
- water_outer_wall_cleaning_count
|
||||
- water_outer_wall_cleaning_wait_time
|
||||
- water_outer_wall_cleaning_waste_time
|
||||
- water_inner_wall_cleaning_injection
|
||||
- water_inner_wall_cleaning_count
|
||||
- water_pump_cleaning_suction_count
|
||||
- water_inner_wall_cleaning_waste_time
|
||||
- water_stirrer_cleaning_injection
|
||||
- water_stirrer_cleaning_count
|
||||
- water_stirrer_cleaning_wait_time
|
||||
- water_stirrer_cleaning_waste_time
|
||||
- acetone_outer_wall_cleaning_injection
|
||||
- acetone_outer_wall_cleaning_count
|
||||
- acetone_outer_wall_cleaning_wait_time
|
||||
- acetone_outer_wall_cleaning_waste_time
|
||||
- acetone_inner_wall_cleaning_injection
|
||||
- acetone_inner_wall_cleaning_count
|
||||
- acetone_pump_cleaning_suction_count
|
||||
- acetone_inner_wall_cleaning_waste_time
|
||||
- acetone_stirrer_cleaning_injection
|
||||
- acetone_stirrer_cleaning_count
|
||||
- acetone_stirrer_cleaning_wait_time
|
||||
- acetone_stirrer_cleaning_waste_time
|
||||
- pipe_blowing_time
|
||||
- injection_pump_forward_empty_suction_count
|
||||
- injection_pump_reverse_empty_suction_count
|
||||
- filtration_liquid_selection
|
||||
title: PostProcessTriggerClean_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: PostProcessTriggerClean_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -451,11 +502,11 @@ post_process_station:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: PostProcessGrab_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
raw_tank_number:
|
||||
maximum: 2147483647
|
||||
@@ -465,13 +516,17 @@ post_process_station:
|
||||
maximum: 2147483647
|
||||
minimum: -2147483648
|
||||
type: integer
|
||||
required:
|
||||
- reaction_tank_number
|
||||
- raw_tank_number
|
||||
title: PostProcessGrab_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: PostProcessGrab_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -518,15 +573,13 @@ post_process_station:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: true
|
||||
properties: {}
|
||||
required: []
|
||||
title: PostProcessTriggerPostPro_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
atomization_fast_speed:
|
||||
maximum: 1.7976931348623157e+308
|
||||
minimum: -1.7976931348623157e+308
|
||||
type: number
|
||||
atomization_pressure_kpa:
|
||||
maximum: 2147483647
|
||||
@@ -541,12 +594,8 @@ post_process_station:
|
||||
minimum: -2147483648
|
||||
type: integer
|
||||
first_wash_water_amount:
|
||||
maximum: 1.7976931348623157e+308
|
||||
minimum: -1.7976931348623157e+308
|
||||
type: number
|
||||
initial_water_amount:
|
||||
maximum: 1.7976931348623157e+308
|
||||
minimum: -1.7976931348623157e+308
|
||||
type: number
|
||||
injection_pump_push_speed:
|
||||
maximum: 2147483647
|
||||
@@ -573,20 +622,32 @@ post_process_station:
|
||||
minimum: -2147483648
|
||||
type: integer
|
||||
second_wash_water_amount:
|
||||
maximum: 1.7976931348623157e+308
|
||||
minimum: -1.7976931348623157e+308
|
||||
type: number
|
||||
wash_slow_speed:
|
||||
maximum: 1.7976931348623157e+308
|
||||
minimum: -1.7976931348623157e+308
|
||||
type: number
|
||||
required:
|
||||
- atomization_fast_speed
|
||||
- wash_slow_speed
|
||||
- injection_pump_suction_speed
|
||||
- injection_pump_push_speed
|
||||
- raw_liquid_suction_count
|
||||
- first_wash_water_amount
|
||||
- second_wash_water_amount
|
||||
- first_powder_mixing_tim
|
||||
- second_powder_mixing_time
|
||||
- first_powder_wash_count
|
||||
- second_powder_wash_count
|
||||
- initial_water_amount
|
||||
- pre_filtration_mixing_time
|
||||
- atomization_pressure_kpa
|
||||
title: PostProcessTriggerPostPro_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: PostProcessTriggerPostPro_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -608,26 +669,30 @@ post_process_station:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
title: SendCmd_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
command:
|
||||
type: string
|
||||
required:
|
||||
- command
|
||||
title: SendCmd_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
title: SendCmd_Result
|
||||
type: object
|
||||
required:
|
||||
@@ -637,7 +702,8 @@ post_process_station:
|
||||
type: SendCmd
|
||||
module: unilabos.devices.workstation.post_process.post_process:OpcUaClient
|
||||
status_types:
|
||||
cache_stats: Dict[str, Any]
|
||||
cache_stats: dict
|
||||
node_value: String
|
||||
type: python
|
||||
config_info: []
|
||||
description: 后处理站
|
||||
@@ -652,9 +718,7 @@ post_process_station:
|
||||
config_path:
|
||||
type: string
|
||||
deck:
|
||||
anyOf:
|
||||
- type: object
|
||||
- type: object
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
subscription_interval:
|
||||
@@ -674,7 +738,10 @@ post_process_station:
|
||||
properties:
|
||||
cache_stats:
|
||||
type: object
|
||||
node_value:
|
||||
type: string
|
||||
required:
|
||||
- node_value
|
||||
- cache_stats
|
||||
type: object
|
||||
version: 1.0.0
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user