Compare commits

..

16 Commits

Author SHA1 Message Date
yexiaozhou
c61dfb60e8 (layout optimizer) update README.md 2026-04-13 10:25:51 +08:00
yexiaozhou
cf0cbb990d Merge branch 'dev' into feat/3d_layout_and_visualize 2026-04-10 16:16:22 +08:00
yexiaozhou
99dc821a01 refactor(layout_optimizer): DE optimizer — discrete angles, strategy fixes, decoupled mutation, API exposure
- Extract _compute_mutant helper with circular angle diff (fixes 0/2π boundary bug)
- Fix currenttobest1bin (remove non-standard noise term), add rand1bin strategy
- Decoupled mutation: independent F ranges for position vs theta
- Configurable crossover mode: per-device (default) or per-dimension
- Discrete angle snapping in normal 3N DE (joint mode, replaces hybrid as default)
- Stop auto-injecting prefer_orientation_mode into DE
- Expose DE hyperparameters (mutation, theta_mutation, recombination, strategy, angle_mode) via API
2026-04-10 14:41:13 +08:00
yexiaozhou
a7a6d77d7a fix(layout_optimizer): apply code review follow-ups 2026-04-03 01:42:22 +08:00
yexiaozhou
00bdf9b822 feat(layout_optimizer): add angle-first hybrid discrete-theta mode 2026-04-03 01:09:00 +08:00
yexiaozhou
306b787aa7 fix(layout_optimizer): update arm_slider reach value and improve scene poll version handling 2026-04-03 00:43:40 +08:00
yexiaozhou
5b3f317867 Merge branch 'rescue-layout-opt-detached' into feat/3d_layout_and_visualize 2026-04-02 16:32:27 +08:00
yexiaozhou
b0e98ccf2b docs(layout_optimizer): deprecate align_weight in demo_agent.md
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 13:52:18 +08:00
yexiaozhou
b04dc8dd4a feat(layout_optimizer): default cardinal snap and alignment to off
align_weight defaults to 0 (was DEFAULT_WEIGHT_ANGLE=60).
snap_theta_safe is opt-in via snap_cardinal=True (was always-on).
Both remain available when explicitly requested.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 13:48:34 +08:00
yexiaozhou
f4c0e40a25 feat(layout_optimizer): crossing penalty weighted by intersection length
Replace _line_of_sight_penalty (flat per-blocker) with _crossing_penalty
(DEFAULT_WEIGHT_DISTANCE * crossing_length). Uses opening→arm-OBB
endpoints. Applied regardless of reachability pass/fail.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 13:33:38 +08:00
yexiaozhou
569ac4a931 feat(layout_optimizer): add segment_obb_intersection_length (Cyrus-Beck clipping)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 13:24:45 +08:00
yexiaozhou
31e79e9aff chore(DE): add debug mode and detailed log regarding cost changes 2026-04-02 12:52:44 +08:00
yexiaozhou
6e1b26a754 fix(server): update path configuration for asset directories 2026-04-01 22:07:44 +08:00
yexiaozhou
9ef24b7768 feat(layout_optimizer): DE optimizer V2 — custom loop, graduated hard constraints, broad phase
Replace scipy differential_evolution with custom DE loop for per-device
crossover, circular θ wrapping, and configurable mutation strategy
(currenttobest1bin default, best1bin as turbo mode).

Key improvements:
- Graduate ALL hard constraints during DE (proportional penalty instead of
  flat inf), giving DE smooth gradient for reachability, min_spacing, etc.
  Binary inf preserved for final pass/fail reporting.
- 2-axis sweep-and-prune AABB broad phase for collision pair pruning
- Multi-seed injection from multiple seeder presets + Gaussian variants
- snap_theta_safe: collision-check after angle snapping, revert on violation
- Weight normalization (100 distance / 60 angle / 5× hard multiplier)
- Constraint priority field (critical/high/normal/low → weight multiplier)
  with LLM intent interpreter setting priority per constraint type
- Final success field now checks user hard constraints in binary mode
- arm_slider added to mock checker reach table (1.07m)

Tests: 202 passed, 24 new tests added (optimizer 7, constraints 6, broad_phase 11)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 00:32:34 +08:00
yexiaozhou
64eeed56a1 feat: add layout_optimizer package for automatic layout of devices
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 09:30:40 +08:00
yexiaozhou
3f75ca4ea3 feat: add generated asset_models registry 2026-03-31 09:30:40 +08:00
133 changed files with 40044 additions and 9093 deletions

View File

@@ -3,7 +3,7 @@
package:
name: unilabos
version: 0.11.1
version: 0.10.19
source:
path: ../../unilabos
@@ -54,7 +54,7 @@ requirements:
- pymodbus
- matplotlib
- pylibftdi
- uni-lab::unilabos-env ==0.11.1
- uni-lab::unilabos-env ==0.10.19
about:
repository: https://github.com/deepmodeling/Uni-Lab-OS

View File

@@ -2,7 +2,7 @@
package:
name: unilabos-env
version: 0.11.1
version: 0.10.19
build:
noarch: generic

View File

@@ -3,7 +3,7 @@
package:
name: unilabos-full
version: 0.11.1
version: 0.10.19
build:
noarch: generic
@@ -11,7 +11,7 @@ build:
requirements:
run:
# Base unilabos package (includes unilabos-env)
- uni-lab::unilabos ==0.11.1
- uni-lab::unilabos ==0.10.19
# Documentation tools
- sphinx
- sphinx_rtd_theme

View File

@@ -1,328 +0,0 @@
---
description: 设备驱动开发规范
globs: ["unilabos/devices/**/*.py"]
---
# 设备驱动开发规范
## 目录结构
```
unilabos/devices/
├── virtual/ # 虚拟设备(用于测试)
│ ├── virtual_stirrer.py
│ └── virtual_centrifuge.py
├── liquid_handling/ # 液体处理设备
├── balance/ # 天平设备
├── hplc/ # HPLC设备
├── pump_and_valve/ # 泵和阀门
├── temperature/ # 温度控制设备
├── workstation/ # 工作站(组合设备)
└── ...
```
## 设备类完整模板
```python
import asyncio
import logging
import time as time_module
from typing import Dict, Any, Optional
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
class MyDevice:
"""
设备类描述
Attributes:
device_id: 设备唯一标识
config: 设备配置字典
data: 设备状态数据
"""
_ros_node: BaseROS2DeviceNode
def __init__(
self,
device_id: str = None,
config: Dict[str, Any] = None,
**kwargs
):
"""
初始化设备
Args:
device_id: 设备ID
config: 配置字典
**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_device"
self.config = config or {}
self.data = {}
# 从config读取参数
self.port = self.config.get('port') or kwargs.get('port', 'COM1')
self._max_value = self.config.get('max_value', 1000.0)
# 初始化日志
self.logger = logging.getLogger(f"MyDevice.{self.device_id}")
self.logger.info(f"设备 {self.device_id} 已创建")
def post_init(self, ros_node: BaseROS2DeviceNode):
"""
ROS节点注入 - 在ROS节点创建后调用
Args:
ros_node: ROS2设备节点实例
"""
self._ros_node = ros_node
async def initialize(self) -> bool:
"""
初始化设备 - 连接硬件、设置初始状态
Returns:
bool: 初始化是否成功
"""
self.logger.info(f"初始化设备 {self.device_id}")
try:
# 执行硬件初始化
# await self._connect_hardware()
# 设置初始状态
self.data.update({
"status": "待机",
"is_running": False,
"current_value": 0.0,
})
self.logger.info(f"设备 {self.device_id} 初始化完成")
return True
except Exception as e:
self.logger.error(f"初始化失败: {e}")
self.data["status"] = f"错误: {e}"
return False
async def cleanup(self) -> bool:
"""
清理设备 - 断开连接、释放资源
Returns:
bool: 清理是否成功
"""
self.logger.info(f"清理设备 {self.device_id}")
self.data.update({
"status": "离线",
"is_running": False,
})
return True
# ==================== 设备动作 ====================
async def execute_action(
self,
param1: float,
param2: str = "",
**kwargs
) -> bool:
"""
执行设备动作
Args:
param1: 参数1
param2: 参数2可选
Returns:
bool: 动作是否成功
"""
# 类型转换和验证
try:
param1 = float(param1)
except (ValueError, TypeError) as e:
self.logger.error(f"参数类型错误: {e}")
return False
# 参数验证
if param1 > self._max_value:
self.logger.error(f"参数超出范围: {param1} > {self._max_value}")
return False
self.logger.info(f"执行动作: param1={param1}, param2={param2}")
# 更新状态
self.data.update({
"status": "运行中",
"is_running": True,
})
# 执行动作(带进度反馈)
duration = 10.0 # 秒
start_time = time_module.time()
while True:
elapsed = time_module.time() - start_time
remaining = max(0, duration - elapsed)
progress = min(100, (elapsed / duration) * 100)
self.data.update({
"status": f"运行中: {progress:.0f}%",
"remaining_time": remaining,
})
if remaining <= 0:
break
await self._ros_node.sleep(1.0)
# 完成
self.data.update({
"status": "完成",
"is_running": False,
})
self.logger.info("动作执行完成")
return True
# ==================== 状态属性 ====================
@property
def status(self) -> str:
"""设备状态 - 自动发布为ROS Topic"""
return self.data.get("status", "未知")
@property
def is_running(self) -> bool:
"""是否正在运行"""
return self.data.get("is_running", False)
@property
def current_value(self) -> float:
"""当前值"""
return self.data.get("current_value", 0.0)
# ==================== 辅助方法 ====================
def get_device_info(self) -> Dict[str, Any]:
"""获取设备信息"""
return {
"device_id": self.device_id,
"status": self.status,
"is_running": self.is_running,
"current_value": self.current_value,
}
def __str__(self) -> str:
return f"MyDevice({self.device_id}: {self.status})"
```
## 关键规则
### 1. 参数处理
所有动作方法的参数都可能以字符串形式传入,必须进行类型转换:
```python
async def my_action(self, value: float, **kwargs) -> bool:
# 始终进行类型转换
try:
value = float(value)
except (ValueError, TypeError) as e:
self.logger.error(f"参数类型错误: {e}")
return False
```
### 2. vessel 参数处理
vessel 参数可能是字符串ID或字典
```python
def extract_vessel_id(vessel: Union[str, dict]) -> str:
if isinstance(vessel, dict):
return vessel.get("id", "")
return str(vessel) if vessel else ""
```
### 3. 状态更新
使用 `self.data` 字典存储状态,属性读取状态:
```python
# 更新状态
self.data["status"] = "运行中"
self.data["current_speed"] = 300.0
# 读取状态(通过属性)
@property
def status(self) -> str:
return self.data.get("status", "待机")
```
### 4. 异步等待
使用 ROS 节点的 sleep 方法:
```python
# 正确
await self._ros_node.sleep(1.0)
# 避免(除非在纯 Python 测试环境)
await asyncio.sleep(1.0)
```
### 5. 进度反馈
长时间运行的操作需要提供进度反馈:
```python
while remaining > 0:
progress = (elapsed / total_time) * 100
self.data["status"] = f"运行中: {progress:.0f}%"
self.data["remaining_time"] = remaining
await self._ros_node.sleep(1.0)
```
## 虚拟设备
虚拟设备用于测试和演示,放在 `unilabos/devices/virtual/` 目录:
- 类名以 `Virtual` 开头
- 文件名以 `virtual_` 开头
- 模拟真实设备的行为和时序
- 使用表情符号增强日志可读性(可选)
## 工作站设备
工作站是组合多个设备的复杂设备:
```python
from unilabos.devices.workstation.workstation_base import WorkstationBase
class MyWorkstation(WorkstationBase):
"""组合工作站"""
async def execute_workflow(self, workflow: Dict[str, Any]) -> bool:
"""执行工作流"""
pass
```
## 设备注册
设备类开发完成后,需要在注册表中注册:
1. 创建/编辑 `unilabos/registry/devices/my_category.yaml`
2. 添加设备配置(参考 `virtual_device.yaml`
3. 运行 `--complete_registry` 自动生成 schema

View File

@@ -1,240 +0,0 @@
---
description: 协议编译器开发规范
globs: ["unilabos/compile/**/*.py"]
---
# 协议编译器开发规范
## 概述
协议编译器负责将高级实验操作(如 Stir、Add、Filter编译为设备可执行的动作序列。
## 文件命名
- 位置: `unilabos/compile/`
- 命名: `{operation}_protocol.py`
- 示例: `stir_protocol.py`, `add_protocol.py`, `filter_protocol.py`
## 协议函数模板
```python
from typing import List, Dict, Any, Union
import networkx as nx
import logging
from .utils.unit_parser import parse_time_input
from .utils.vessel_parser import extract_vessel_id
logger = logging.getLogger(__name__)
def generate_{operation}_protocol(
G: nx.DiGraph,
vessel: Union[str, dict],
param1: Union[str, float] = "0",
param2: float = 0.0,
**kwargs
) -> List[Dict[str, Any]]:
"""
生成{操作}协议序列
Args:
G: 物理拓扑图 (NetworkX DiGraph)
vessel: 容器ID或Resource字典
param1: 参数1支持字符串单位如 "5 min"
param2: 参数2
**kwargs: 其他参数
Returns:
List[Dict]: 动作序列
Raises:
ValueError: 参数无效时
"""
# 1. 提取 vessel_id
vessel_id = extract_vessel_id(vessel)
# 2. 验证参数
if not vessel_id:
raise ValueError("vessel 参数不能为空")
if vessel_id not in G.nodes():
raise ValueError(f"容器 '{vessel_id}' 不存在于系统中")
# 3. 解析参数(支持单位)
parsed_param1 = parse_time_input(param1) # "5 min" -> 300.0
# 4. 查找设备
device_id = find_connected_device(G, vessel_id, device_type="my_device")
# 5. 生成动作序列
action_sequence = []
action = {
"device_id": device_id,
"action_name": "my_action",
"action_kwargs": {
"vessel": {"id": vessel_id}, # 始终使用字典格式
"param1": float(parsed_param1),
"param2": float(param2),
}
}
action_sequence.append(action)
logger.info(f"生成协议: {len(action_sequence)} 个动作")
return action_sequence
def find_connected_device(
G: nx.DiGraph,
vessel_id: str,
device_type: str = ""
) -> str:
"""
查找与容器相连的设备
Args:
G: 拓扑图
vessel_id: 容器ID
device_type: 设备类型关键字
Returns:
str: 设备ID
"""
# 查找所有匹配类型的设备
device_nodes = []
for node in G.nodes():
node_class = G.nodes[node].get('class', '') or ''
if device_type.lower() in node_class.lower():
device_nodes.append(node)
# 检查连接
if vessel_id and device_nodes:
for device in device_nodes:
if G.has_edge(device, vessel_id) or G.has_edge(vessel_id, device):
return device
# 返回第一个可用设备
if device_nodes:
return device_nodes[0]
# 默认设备
return f"{device_type}_1"
```
## 关键规则
### 1. vessel 参数处理
vessel 参数可能是字符串或字典,需要统一处理:
```python
def extract_vessel_id(vessel: Union[str, dict]) -> str:
"""提取vessel_id"""
if isinstance(vessel, dict):
# 可能是 {"id": "xxx"} 或完整 Resource 对象
return vessel.get("id", list(vessel.values())[0].get("id", ""))
return str(vessel) if vessel else ""
```
### 2. action_kwargs 中的 vessel
始终使用 `{"id": vessel_id}` 格式传递 vessel
```python
# 正确
"action_kwargs": {
"vessel": {"id": vessel_id}, # 字符串ID包装为字典
}
# 避免
"action_kwargs": {
"vessel": vessel_resource, # 不要传递完整 Resource 对象
}
```
### 3. 单位解析
使用 `parse_time_input` 解析时间参数:
```python
from .utils.unit_parser import parse_time_input
# 支持格式: "5 min", "1 h", "300", "1.5 hours"
time_seconds = parse_time_input("5 min") # -> 300.0
time_seconds = parse_time_input(120) # -> 120.0
time_seconds = parse_time_input("1 h") # -> 3600.0
```
### 4. 参数验证
所有参数必须进行验证和类型转换:
```python
# 验证范围
if speed < 10.0 or speed > 1500.0:
logger.warning(f"速度 {speed} 超出范围,修正为 300")
speed = 300.0
# 类型转换
param = float(param) if not isinstance(param, (int, float)) else param
```
### 5. 日志记录
使用项目日志记录器:
```python
logger = logging.getLogger(__name__)
def generate_protocol(...):
logger.info(f"开始生成协议...")
logger.debug(f"参数: vessel={vessel_id}, time={time}")
logger.warning(f"参数修正: {old_value} -> {new_value}")
```
## 便捷函数
为常用操作提供便捷函数:
```python
def stir_briefly(G: nx.DiGraph, vessel: Union[str, dict],
speed: float = 300.0) -> List[Dict[str, Any]]:
"""短时间搅拌30秒"""
return generate_stir_protocol(G, vessel, time="30", stir_speed=speed)
def stir_vigorously(G: nx.DiGraph, vessel: Union[str, dict],
time: str = "5 min") -> List[Dict[str, Any]]:
"""剧烈搅拌"""
return generate_stir_protocol(G, vessel, time=time, stir_speed=800.0)
```
## 测试函数
每个协议文件应包含测试函数:
```python
def test_{operation}_protocol():
"""测试协议生成"""
# 测试参数处理
vessel_dict = {"id": "flask_1", "name": "反应瓶1"}
vessel_id = extract_vessel_id(vessel_dict)
assert vessel_id == "flask_1"
# 测试单位解析
time_s = parse_time_input("5 min")
assert time_s == 300.0
if __name__ == "__main__":
test_{operation}_protocol()
```
## 现有协议参考
- `stir_protocol.py` - 搅拌操作
- `add_protocol.py` - 添加物料
- `filter_protocol.py` - 过滤操作
- `heatchill_protocol.py` - 加热/冷却
- `separate_protocol.py` - 分离操作
- `evaporate_protocol.py` - 蒸发操作

View File

@@ -1,319 +0,0 @@
---
description: 注册表配置规范 (YAML)
globs: ["unilabos/registry/**/*.yaml"]
---
# 注册表配置规范
## 概述
注册表使用 YAML 格式定义设备和资源类型,是 Uni-Lab-OS 的核心配置系统。
## 目录结构
```
unilabos/registry/
├── devices/ # 设备类型注册
│ ├── virtual_device.yaml
│ ├── liquid_handler.yaml
│ └── ...
├── device_comms/ # 通信设备配置
│ ├── communication_devices.yaml
│ └── modbus_ioboard.yaml
└── resources/ # 资源类型注册
├── bioyond/
├── organic/
├── opentrons/
└── ...
```
## 设备注册表格式
### 基本结构
```yaml
device_type_id:
# 基本信息
description: "设备描述"
version: "1.0.0"
category:
- category_name
icon: "icon_device.webp"
# 类配置
class:
module: "unilabos.devices.my_module:MyClass"
type: python
# 状态类型(属性 -> ROS消息类型
status_types:
status: String
temperature: Float64
is_running: Bool
# 动作映射
action_value_mappings:
action_name:
type: UniLabJsonCommand # 或 UniLabJsonCommandAsync
goal: {}
feedback: {}
result: {}
schema: {...}
handles: {}
```
### action_value_mappings 详细格式
```yaml
action_value_mappings:
# 同步动作
my_sync_action:
type: UniLabJsonCommand
goal:
param1: param1
param2: param2
feedback: {}
result:
success: success
message: message
goal_default:
param1: 0.0
param2: ""
handles: {}
placeholder_keys:
device_param: unilabos_devices # 设备选择器
resource_param: unilabos_resources # 资源选择器
schema:
title: "动作名称参数"
description: "动作描述"
type: object
properties:
goal:
type: object
properties:
param1:
type: number
param2:
type: string
required:
- param1
feedback: {}
result:
type: object
properties:
success:
type: boolean
message:
type: string
required:
- goal
# 异步动作
my_async_action:
type: UniLabJsonCommandAsync
goal: {}
feedback:
progress: progress
current_status: status
result:
success: success
schema: {...}
```
### 自动生成的动作
以 `auto-` 开头的动作由系统自动生成:
```yaml
action_value_mappings:
auto-initialize:
type: UniLabJsonCommandAsync
goal: {}
feedback: {}
result: {}
schema: {...}
auto-cleanup:
type: UniLabJsonCommandAsync
goal: {}
feedback: {}
result: {}
schema: {...}
```
### handles 配置
用于工作流编辑器中的数据流连接:
```yaml
handles:
input:
- handler_key: "input_resource"
data_type: "resource"
label: "输入资源"
data_source: "handle"
data_key: "resources"
output:
- handler_key: "output_labware"
data_type: "resource"
label: "输出器皿"
data_source: "executor"
data_key: "created_resource.@flatten"
```
## 资源注册表格式
```yaml
resource_type_id:
description: "资源描述"
version: "1.0.0"
category:
- category_name
icon: ""
handles: []
init_param_schema: {}
class:
module: "unilabos.resources.my_module:MyResource"
type: pylabrobot # 或 python
```
### PyLabRobot 资源示例
```yaml
BIOYOND_Electrolyte_6VialCarrier:
category:
- bottle_carriers
- bioyond
class:
module: "unilabos.resources.bioyond.bottle_carriers:BIOYOND_Electrolyte_6VialCarrier"
type: pylabrobot
version: "1.0.0"
```
## 状态类型映射
Python 类型到 ROS 消息类型的映射:
| Python 类型 | ROS 消息类型 |
|------------|-------------|
| `str` | `String` |
| `bool` | `Bool` |
| `int` | `Int64` |
| `float` | `Float64` |
| `list` | `String` (序列化) |
| `dict` | `String` (序列化) |
## 自动完善注册表
使用 `--complete_registry` 参数自动生成 schema
```bash
python -m unilabos.app.main --complete_registry
```
这会:
1. 扫描设备类的方法签名
2. 自动生成 `auto-` 前缀的动作
3. 生成 JSON Schema
4. 更新 YAML 文件
## 验证规则
1. **device_type_id** 必须唯一
2. **module** 路径必须正确可导入
3. **status_types** 的类型必须是有效的 ROS 消息类型
4. **schema** 必须是有效的 JSON Schema
## 示例:完整设备配置
```yaml
virtual_stirrer:
category:
- virtual_device
description: "虚拟搅拌器设备"
version: "1.0.0"
icon: "icon_stirrer.webp"
handles: []
init_param_schema: {}
class:
module: "unilabos.devices.virtual.virtual_stirrer:VirtualStirrer"
type: python
status_types:
status: String
operation_mode: String
current_speed: Float64
is_stirring: Bool
remaining_time: Float64
action_value_mappings:
auto-initialize:
type: UniLabJsonCommandAsync
goal: {}
feedback: {}
result: {}
schema:
title: "initialize参数"
type: object
properties:
goal:
type: object
properties: {}
feedback: {}
result: {}
required:
- goal
stir:
type: UniLabJsonCommandAsync
goal:
stir_time: stir_time
stir_speed: stir_speed
settling_time: settling_time
feedback:
current_speed: current_speed
remaining_time: remaining_time
result:
success: success
goal_default:
stir_time: 60.0
stir_speed: 300.0
settling_time: 30.0
handles: {}
schema:
title: "stir参数"
description: "搅拌操作"
type: object
properties:
goal:
type: object
properties:
stir_time:
type: number
description: "搅拌时间(秒)"
stir_speed:
type: number
description: "搅拌速度RPM"
settling_time:
type: number
description: "沉降时间(秒)"
required:
- stir_time
- stir_speed
feedback:
type: object
properties:
current_speed:
type: number
remaining_time:
type: number
result:
type: object
properties:
success:
type: boolean
required:
- goal
```

View File

@@ -1,233 +0,0 @@
---
description: ROS 2 集成开发规范
globs: ["unilabos/ros/**/*.py", "**/*_node.py"]
---
# ROS 2 集成开发规范
## 概述
Uni-Lab-OS 使用 ROS 2 作为设备通信中间件,基于 rclpy 实现。
## 核心组件
### BaseROS2DeviceNode
设备节点基类,提供:
- ROS Topic 自动发布(状态属性)
- Action Server 自动创建(设备动作)
- 资源管理服务
- 异步任务调度
```python
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
```
### 消息转换器
```python
from unilabos.ros.msgs.message_converter import (
convert_to_ros_msg,
convert_from_ros_msg_with_mapping,
msg_converter_manager,
ros_action_to_json_schema,
ros_message_to_json_schema,
)
```
## 设备与 ROS 集成
### post_init 方法
设备类必须实现 `post_init` 方法接收 ROS 节点:
```python
class MyDevice:
_ros_node: BaseROS2DeviceNode
def post_init(self, ros_node: BaseROS2DeviceNode):
"""ROS节点注入"""
self._ros_node = ros_node
```
### 状态属性发布
设备的 `@property` 属性会自动发布为 ROS Topic
```python
class MyDevice:
@property
def temperature(self) -> float:
return self._temperature
# 自动发布到 /{namespace}/temperature Topic
```
### Topic 配置装饰器
```python
from unilabos.utils.decorator import topic_config
class MyDevice:
@property
@topic_config(period=1.0, print_publish=False, qos=10)
def fast_data(self) -> float:
"""高频数据 - 每秒发布一次"""
return self._fast_data
@property
@topic_config(period=5.0)
def slow_data(self) -> str:
"""低频数据 - 每5秒发布一次"""
return self._slow_data
```
### 订阅装饰器
```python
from unilabos.utils.decorator import subscribe
class MyDevice:
@subscribe(topic="/external/sensor_data", qos=10)
def on_sensor_data(self, msg):
"""订阅外部Topic"""
self._sensor_value = msg.data
```
## 异步操作
### 使用 ROS 节点睡眠
```python
# 推荐使用ROS节点的睡眠方法
await self._ros_node.sleep(1.0)
# 不推荐直接使用asyncio可能导致回调阻塞
await asyncio.sleep(1.0)
```
### 获取事件循环
```python
from unilabos.ros.x.rclpyx import get_event_loop
loop = get_event_loop()
```
## 消息类型
### unilabos_msgs 包
```python
from unilabos_msgs.msg import Resource
from unilabos_msgs.srv import (
ResourceAdd,
ResourceDelete,
ResourceUpdate,
ResourceList,
SerialCommand,
)
from unilabos_msgs.action import SendCmd
```
### Resource 消息结构
```python
Resource:
id: str
name: str
category: str
type: str
parent: str
children: List[str]
config: str # JSON字符串
data: str # JSON字符串
sample_id: str
pose: Pose
```
## 日志适配器
```python
from unilabos.utils.log import info, debug, warning, error, trace
class MyDevice:
def __init__(self):
# 创建设备专属日志器
self.logger = logging.getLogger(f"MyDevice.{self.device_id}")
```
ROSLoggerAdapter 同时向自定义日志和 ROS 日志发送消息。
## Action Server
设备动作自动创建为 ROS Action Server
```yaml
# 在注册表中配置
action_value_mappings:
my_action:
type: UniLabJsonCommandAsync # 异步Action
goal: {...}
feedback: {...}
result: {...}
```
### Action 类型
- **UniLabJsonCommand**: 同步动作
- **UniLabJsonCommandAsync**: 异步动作支持feedback
## 服务客户端
```python
from rclpy.client import Client
# 调用其他节点的服务
response = await self._ros_node.call_service(
service_name="/other_node/service",
request=MyServiceRequest(...)
)
```
## 命名空间
设备节点使用命名空间隔离:
```
/{device_id}/ # 设备命名空间
/{device_id}/status # 状态Topic
/{device_id}/temperature # 温度Topic
/{device_id}/my_action # 动作Server
```
## 调试
### 查看 Topic
```bash
ros2 topic list
ros2 topic echo /{device_id}/status
```
### 查看 Action
```bash
ros2 action list
ros2 action info /{device_id}/my_action
```
### 查看 Service
```bash
ros2 service list
ros2 service call /{device_id}/resource_list unilabos_msgs/srv/ResourceList
```
## 最佳实践
1. **状态属性命名**: 使用蛇形命名法snake_case
2. **Topic 频率**: 根据数据变化频率调整,避免过高频率
3. **Action 反馈**: 长时间操作提供进度反馈
4. **错误处理**: 使用 try-except 捕获并记录错误
5. **资源清理**: 在 cleanup 方法中正确清理资源

View File

@@ -1,357 +0,0 @@
---
description: 测试开发规范
globs: ["tests/**/*.py", "**/test_*.py"]
---
# 测试开发规范
## 目录结构
```
tests/
├── __init__.py
├── devices/ # 设备测试
│ └── liquid_handling/
│ └── test_transfer_liquid.py
├── resources/ # 资源测试
│ ├── test_bottle_carrier.py
│ └── test_resourcetreeset.py
├── ros/ # ROS消息测试
│ └── msgs/
│ ├── test_basic.py
│ ├── test_conversion.py
│ └── test_mapping.py
└── workflow/ # 工作流测试
└── merge_workflow.py
```
## 测试框架
使用 pytest 作为测试框架:
```bash
# 运行所有测试
pytest tests/
# 运行特定测试文件
pytest tests/resources/test_bottle_carrier.py
# 运行特定测试函数
pytest tests/resources/test_bottle_carrier.py::test_bottle_carrier
# 显示详细输出
pytest -v tests/
# 显示打印输出
pytest -s tests/
```
## 测试文件模板
```python
import pytest
from typing import List, Dict, Any
# 导入被测试的模块
from unilabos.resources.bioyond.bottle_carriers import (
BIOYOND_Electrolyte_6VialCarrier,
)
from unilabos.resources.bioyond.bottles import (
BIOYOND_PolymerStation_Solid_Vial,
)
class TestBottleCarrier:
"""BottleCarrier 测试类"""
def setup_method(self):
"""每个测试方法前执行"""
self.carrier = BIOYOND_Electrolyte_6VialCarrier("test_carrier")
def teardown_method(self):
"""每个测试方法后执行"""
pass
def test_carrier_creation(self):
"""测试载架创建"""
assert self.carrier.name == "test_carrier"
assert len(self.carrier.sites) == 6
def test_bottle_placement(self):
"""测试瓶子放置"""
bottle = BIOYOND_PolymerStation_Solid_Vial("test_bottle")
# 测试逻辑...
assert bottle.name == "test_bottle"
def test_standalone_function():
"""独立测试函数"""
result = some_function()
assert result is True
# 参数化测试
@pytest.mark.parametrize("input,expected", [
("5 min", 300.0),
("1 h", 3600.0),
("120", 120.0),
(60, 60.0),
])
def test_time_parsing(input, expected):
"""测试时间解析"""
from unilabos.compile.utils.unit_parser import parse_time_input
assert parse_time_input(input) == expected
# 异常测试
def test_invalid_input_raises_error():
"""测试无效输入抛出异常"""
with pytest.raises(ValueError) as exc_info:
invalid_function("bad_input")
assert "invalid" in str(exc_info.value).lower()
# 跳过条件测试
@pytest.mark.skipif(
not os.environ.get("ROS_DISTRO"),
reason="需要ROS环境"
)
def test_ros_feature():
"""需要ROS环境的测试"""
pass
```
## 设备测试
### 虚拟设备测试
```python
import pytest
import asyncio
from unittest.mock import MagicMock, AsyncMock
from unilabos.devices.virtual.virtual_stirrer import VirtualStirrer
class TestVirtualStirrer:
"""VirtualStirrer 测试"""
@pytest.fixture
def stirrer(self):
"""创建测试用搅拌器"""
device = VirtualStirrer(
device_id="test_stirrer",
config={"max_speed": 1500.0, "min_speed": 50.0}
)
# Mock ROS节点
mock_node = MagicMock()
mock_node.sleep = AsyncMock(return_value=None)
device.post_init(mock_node)
return device
@pytest.mark.asyncio
async def test_initialize(self, stirrer):
"""测试初始化"""
result = await stirrer.initialize()
assert result is True
assert stirrer.status == "待机中"
@pytest.mark.asyncio
async def test_stir_action(self, stirrer):
"""测试搅拌动作"""
await stirrer.initialize()
result = await stirrer.stir(
stir_time=5.0,
stir_speed=300.0,
settling_time=2.0
)
assert result is True
assert stirrer.operation_mode == "Completed"
@pytest.mark.asyncio
async def test_stir_invalid_speed(self, stirrer):
"""测试无效速度"""
await stirrer.initialize()
# 速度超出范围
result = await stirrer.stir(
stir_time=5.0,
stir_speed=2000.0, # 超过max_speed
settling_time=0.0
)
assert result is False
assert "错误" in stirrer.status
```
### 异步测试配置
```python
# conftest.py
import pytest
import asyncio
@pytest.fixture(scope="session")
def event_loop():
"""创建事件循环"""
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
```
## 资源测试
```python
import pytest
from unilabos.resources.resource_tracker import (
ResourceTreeSet,
ResourceTreeInstance,
)
def test_resource_tree_creation():
"""测试资源树创建"""
tree_set = ResourceTreeSet()
# 添加资源
resource = {"id": "res_1", "name": "Resource 1"}
tree_set.add_resource(resource)
# 验证
assert len(tree_set.all_nodes) == 1
assert tree_set.get_resource("res_1") is not None
def test_resource_tree_merge():
"""测试资源树合并"""
local_set = ResourceTreeSet()
remote_set = ResourceTreeSet()
# 设置数据...
local_set.merge_remote_resources(remote_set)
# 验证合并结果...
```
## ROS 消息测试
```python
import pytest
from unilabos.ros.msgs.message_converter import (
convert_to_ros_msg,
convert_from_ros_msg_with_mapping,
msg_converter_manager,
)
def test_message_conversion():
"""测试消息转换"""
# Python -> ROS
python_data = {"id": "test", "value": 42}
ros_msg = convert_to_ros_msg(python_data, MyMsgType)
assert ros_msg.id == "test"
assert ros_msg.value == 42
# ROS -> Python
result = convert_from_ros_msg_with_mapping(ros_msg, mapping)
assert result["id"] == "test"
```
## 协议测试
```python
import pytest
import networkx as nx
from unilabos.compile.stir_protocol import (
generate_stir_protocol,
extract_vessel_id,
)
@pytest.fixture
def topology_graph():
"""创建测试拓扑图"""
G = nx.DiGraph()
G.add_node("flask_1", **{"class": "flask"})
G.add_node("stirrer_1", **{"class": "virtual_stirrer"})
G.add_edge("stirrer_1", "flask_1")
return G
def test_generate_stir_protocol(topology_graph):
"""测试搅拌协议生成"""
actions = generate_stir_protocol(
G=topology_graph,
vessel="flask_1",
time="5 min",
stir_speed=300.0
)
assert len(actions) == 1
assert actions[0]["device_id"] == "stirrer_1"
assert actions[0]["action_name"] == "stir"
def test_extract_vessel_id():
"""测试vessel_id提取"""
# 字典格式
assert extract_vessel_id({"id": "flask_1"}) == "flask_1"
# 字符串格式
assert extract_vessel_id("flask_2") == "flask_2"
# 空值
assert extract_vessel_id("") == ""
```
## 测试标记
```python
# 慢速测试
@pytest.mark.slow
def test_long_running():
pass
# 需要网络
@pytest.mark.network
def test_network_call():
pass
# 需要ROS
@pytest.mark.ros
def test_ros_feature():
pass
```
运行特定标记的测试:
```bash
pytest -m "not slow" # 排除慢速测试
pytest -m ros # 仅ROS测试
```
## 覆盖率
```bash
# 生成覆盖率报告
pytest --cov=unilabos tests/
# HTML报告
pytest --cov=unilabos --cov-report=html tests/
```
## 最佳实践
1. **测试命名**: `test_{功能}_{场景}_{预期结果}`
2. **独立性**: 每个测试独立运行,不依赖其他测试
3. **Mock外部依赖**: 使用 unittest.mock 模拟外部服务
4. **参数化**: 使用 `@pytest.mark.parametrize` 减少重复代码
5. **fixtures**: 使用 fixtures 共享测试设置
6. **断言清晰**: 每个断言只验证一件事

View File

@@ -1,353 +0,0 @@
---
description: Uni-Lab-OS 实验室自动化平台开发规范 - 核心规则
globs: ["**/*.py", "**/*.yaml", "**/*.json"]
---
# Uni-Lab-OS 项目开发规范
## 项目概述
Uni-Lab-OS 是一个实验室自动化操作系统,用于连接和控制各种实验设备,实现实验工作流的自动化和标准化。
## 技术栈
- **Python 3.11** - 核心开发语言
- **ROS 2** - 设备通信中间件 (rclpy)
- **Conda/Mamba** - 包管理 (robostack-staging, conda-forge)
- **FastAPI** - Web API 服务
- **WebSocket** - 实时通信
- **NetworkX** - 拓扑图管理
- **YAML** - 配置和注册表定义
- **PyLabRobot** - 实验室自动化库集成
- **pytest** - 测试框架
- **asyncio** - 异步编程
## 项目结构
```
unilabos/
├── app/ # 应用入口、Web服务、后端
├── compile/ # 协议编译器 (stir, add, filter 等)
├── config/ # 配置管理
├── devices/ # 设备驱动 (真实/虚拟)
├── device_comms/ # 设备通信协议
├── device_mesh/ # 3D网格和可视化
├── registry/ # 设备和资源类型注册表 (YAML)
├── resources/ # 资源定义
├── ros/ # ROS 2 集成
├── utils/ # 工具函数
└── workflow/ # 工作流管理
```
## 代码规范
### Python 风格
1. **类型注解**:所有函数必须使用类型注解
```python
def transfer_liquid(
source: str,
destination: str,
volume: float,
**kwargs
) -> List[Dict[str, Any]]:
```
2. **Docstring**:使用 Google 风格的文档字符串
```python
def initialize(self) -> bool:
"""
初始化设备
Returns:
bool: 初始化是否成功
"""
```
3. **导入顺序**
- 标准库
- 第三方库
- ROS 相关 (rclpy, unilabos_msgs)
- 项目内部模块
### 异步编程
1. 设备操作方法使用 `async def`
2. 使用 `await self._ros_node.sleep()` 而非 `asyncio.sleep()`
3. 长时间运行操作需提供进度反馈
```python
async def stir(self, stir_time: float, stir_speed: float, **kwargs) -> bool:
"""执行搅拌操作"""
start_time = time_module.time()
while True:
elapsed = time_module.time() - start_time
remaining = max(0, stir_time - elapsed)
self.data.update({
"remaining_time": remaining,
"status": f"搅拌中: {stir_speed} RPM"
})
if remaining <= 0:
break
await self._ros_node.sleep(1.0)
return True
```
### 日志规范
使用项目自定义日志系统:
```python
from unilabos.utils.log import logger, info, debug, warning, error, trace
# 在设备类中使用
self.logger = logging.getLogger(f"DeviceName.{self.device_id}")
self.logger.info("设备初始化完成")
```
## 设备驱动开发
### 设备类结构
```python
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
class MyDevice:
"""设备驱动类"""
_ros_node: BaseROS2DeviceNode
def __init__(self, device_id: str = None, config: Dict[str, Any] = None, **kwargs):
self.device_id = device_id or "unknown_device"
self.config = config or {}
self.data = {} # 设备状态数据
def post_init(self, ros_node: BaseROS2DeviceNode):
"""ROS节点注入"""
self._ros_node = ros_node
async def initialize(self) -> bool:
"""初始化设备"""
pass
async def cleanup(self) -> bool:
"""清理设备"""
pass
# 状态属性 - 自动发布为 ROS Topic
@property
def status(self) -> str:
return self.data.get("status", "待机")
```
### 状态属性装饰器
```python
from unilabos.utils.decorator import topic_config
class MyDevice:
@property
@topic_config(period=1.0, qos=10) # 每秒发布一次
def temperature(self) -> float:
return self._temperature
```
### 虚拟设备
虚拟设备放置在 `unilabos/devices/virtual/` 目录下,命名为 `virtual_*.py`
## 注册表配置
### 设备注册表 (YAML)
位置: `unilabos/registry/devices/*.yaml`
```yaml
my_device_type:
category:
- my_category
description: "设备描述"
version: "1.0.0"
class:
module: "unilabos.devices.my_device:MyDevice"
type: python
status_types:
status: String
temperature: Float64
action_value_mappings:
auto-initialize:
type: UniLabJsonCommandAsync
goal: {}
feedback: {}
result: {}
schema: {...}
```
### 资源注册表 (YAML)
位置: `unilabos/registry/resources/**/*.yaml`
```yaml
my_container:
category:
- container
class:
module: "unilabos.resources.my_resource:MyContainer"
type: pylabrobot
version: "1.0.0"
```
## 协议编译器
位置: `unilabos/compile/*_protocol.py`
### 协议生成函数模板
```python
from typing import List, Dict, Any, Union
import networkx as nx
def generate_my_protocol(
G: nx.DiGraph,
vessel: Union[str, dict],
param1: float = 0.0,
**kwargs
) -> List[Dict[str, Any]]:
"""
生成操作协议序列
Args:
G: 物理拓扑图
vessel: 容器ID或字典
param1: 参数1
Returns:
List[Dict]: 动作序列
"""
# 提取vessel_id
vessel_id = vessel if isinstance(vessel, str) else vessel.get("id", "")
# 查找设备
device_id = find_connected_device(G, vessel_id)
# 生成动作
action_sequence = [{
"device_id": device_id,
"action_name": "my_action",
"action_kwargs": {
"vessel": {"id": vessel_id},
"param1": float(param1)
}
}]
return action_sequence
```
## 测试规范
### 测试文件位置
- 单元测试: `tests/` 目录
- 设备测试: `tests/devices/`
- 资源测试: `tests/resources/`
- ROS消息测试: `tests/ros/msgs/`
### 测试命名
```python
# tests/devices/my_device/test_my_device.py
import pytest
def test_device_initialization():
"""测试设备初始化"""
pass
def test_device_action():
"""测试设备动作"""
pass
```
## 错误处理
```python
from unilabos.utils.exception import UniLabException
try:
result = await device.execute_action()
except ValueError as e:
self.logger.error(f"参数错误: {e}")
self.data["status"] = "错误: 参数无效"
return False
except Exception as e:
self.logger.error(f"执行失败: {e}")
raise
```
## 配置管理
```python
from unilabos.config.config import BasicConfig, HTTPConfig
# 读取配置
port = BasicConfig.port
is_host = BasicConfig.is_host_mode
# 配置文件: local_config.py
```
## 常用工具
### 单例模式
```python
from unilabos.utils.decorator import singleton
@singleton
class MyManager:
pass
```
### 类型检查
```python
from unilabos.utils.type_check import NoAliasDumper
yaml.dump(data, f, Dumper=NoAliasDumper)
```
### 导入管理
```python
from unilabos.utils.import_manager import get_class
device_class = get_class("unilabos.devices.my_device:MyDevice")
```
## Git 提交规范
提交信息格式:
```
<type>(<scope>): <subject>
<body>
```
类型:
- `feat`: 新功能
- `fix`: 修复bug
- `docs`: 文档更新
- `refactor`: 重构
- `test`: 测试相关
- `chore`: 构建/工具相关
示例:
```
feat(devices): 添加虚拟搅拌器设备
- 实现VirtualStirrer类
- 支持定时搅拌和持续搅拌模式
- 添加速度验证逻辑
```

View File

@@ -27,15 +27,14 @@ python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{
### 2. --addr → BASE URL
| `--addr` | BASE |
| ------------ | ----------------------------------- |
| `test` | `https://leap-lab.test.bohrium.com` |
| `uat` | `https://leap-lab.uat.bohrium.com` |
| `local` | `http://127.0.0.1:48197` |
| 不传(默认) | `https://leap-lab.bohrium.com` |
| `--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>"
@@ -66,7 +65,7 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
返回:
```json
{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } }
{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}}
```
记住 `data.uuid``lab_uuid`
@@ -91,7 +90,6 @@ curl -s -X POST "$BASE/api/v1/lab/reagent" \
```
返回成功时包含试剂 UUID
```json
{"code": 0, "data": {"uuid": "xxx", ...}}
```
@@ -100,28 +98,28 @@ curl -s -X POST "$BASE/api/v1/lab/reagent" \
## 试剂字段说明
| 字段 | 类型 | 必填 | 说明 | 示例 |
| ------------------- | ------ | ---- | ----------------------------- | ------------------------ |
| `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"` |
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `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"` | 克 |
| `"L"` | 升 |
| `"g"` | 克 |
| `"kg"` | 千克 |
| `"瓶"` | 瓶 |
| `"瓶"` | 瓶 |
> 根据试剂状态选择:液体用 `"mL"` / `"L"`,固体用 `"g"` / `"kg"`。
@@ -135,22 +133,8 @@ curl -s -X POST "$BASE/api/v1/lab/reagent" \
```json
[
{
"cas": "7732-18-3",
"name": "水",
"molecular_formula": "H2O",
"smiles": "O",
"stock_in_quantity": 10,
"unit": "mL"
},
{
"cas": "64-17-5",
"name": "乙醇",
"molecular_formula": "C2H6O",
"smiles": "CCO",
"stock_in_quantity": 5,
"unit": "L"
}
{"cas": "7732-18-3", "name": "水", "molecular_formula": "H2O", "smiles": "O", "stock_in_quantity": 10, "unit": "mL"},
{"cas": "64-17-5", "name": "乙醇", "molecular_formula": "C2H6O", "smiles": "CCO", "stock_in_quantity": 5, "unit": "L"}
]
```
@@ -176,20 +160,9 @@ cas,name,molecular_formula,smiles,stock_in_quantity,unit,supplier,production_dat
7732-18-3,水,H2O,O,10,mL,农夫山泉,2025-11-18T00:00:00Z,2026-11-18T00:00:00Z
```
### 日期格式规则(重要)
所有日期字段(`production_date``expiry_date`**必须**使用 ISO 8601 完整格式:`YYYY-MM-DDTHH:MM:SSZ`
- 用户输入 `2025-03-01` → 转换为 `"2025-03-01T00:00:00Z"`
- 用户输入 `2025/9/1` → 转换为 `"2025-09-01T00:00:00Z"`
- 用户未提供日期 → 使用当天日期 + `T00:00:00Z`,有效期默认 +1 年
**禁止**发送不带时间部分的日期字符串(如 `"2025-03-01"`API 会拒绝。
### 执行与汇报
每次 API 调用后:
1. 检查返回 `code`0 = 成功)
2. 记录成功/失败数量
3. 全部完成后汇总:「共录入 N 条试剂,成功 X 条,失败 Y 条」
@@ -199,29 +172,28 @@ cas,name,molecular_formula,smiles,stock_in_quantity,unit,supplier,production_dat
## 常见试剂速查表
| 名称 | CAS | 分子式 | SMILES |
| --------------------- | --------- | ---------- | ------------------------------------ |
| 水 | 7732-18-3 | H2O | O |
| 乙醇 | 64-17-5 | C2H6O | CCO |
| 乙酸 | 64-19-7 | C2H4O2 | CC(O)=O |
| 甲醇 | 67-56-1 | CH4O | CO |
| 丙酮 | 67-64-1 | C3H6O | CC(C)=O |
| 二甲基亚砜(DMSO) | 67-68-5 | C2H6OS | CS(C)=O |
| 乙酸乙酯 | 141-78-6 | C4H8O2 | CCOC(C)=O |
| 二氯甲烷 | 75-09-2 | CH2Cl2 | ClCCl |
| 四氢呋喃(THF) | 109-99-9 | C4H8O | C1CCOC1 |
| N,N-二甲基甲酰胺(DMF) | 68-12-2 | C3H7NO | CN(C)C=O |
| 氯仿 | 67-66-3 | CHCl3 | ClC(Cl)Cl |
| 乙腈 | 75-05-8 | C2H3N | CC#N |
| 甲苯 | 108-88-3 | C7H8 | Cc1ccccc1 |
| 正己烷 | 110-54-3 | C6H14 | CCCCCC |
| 异丙醇 | 67-63-0 | C3H8O | CC(C)O |
| | 7647-01-0 | HCl | Cl |
| 硫酸 | 7664-93-9 | H2SO4 | OS(O)(=O)=O |
| 氢氧化钠 | 1310-73-2 | NaOH | [Na]O |
| 碳酸钠 | 497-19-8 | Na2CO3 | [Na]OC([O-])=O.[Na+] |
| 氯化钠 | 7647-14-5 | NaCl | [Na]Cl |
| 乙二胺四乙酸(EDTA) | 60-00-4 | C10H16N2O8 | OC(=O)CN(CCN(CC(O)=O)CC(O)=O)CC(O)=O |
| 名称 | 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 应根据化学知识推断或提示用户补充。

View File

@@ -1,13 +1,11 @@
---
name: batch-submit-experiment
description: Batch submit experiments (notebooks) to the Uni-Lab cloud platform (leap-lab) — list workflows, generate node_params from registry schemas, submit multiple rounds, check notebook status. Use when the user wants to submit experiments, create notebooks, batch run workflows, check experiment status, or mentions 提交实验/批量实验/notebook/实验轮次/实验状态.
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/实验轮次/实验状态.
---
# Uni-Lab 批量提交实验指南
# 批量提交实验指南
通过 Uni-Lab 云端 API 批量提交实验notebook支持多轮实验参数配置。根据 workflow 模板详情和本地设备注册表自动生成 `node_params` 模板。
> **重要**:本指南中的 `Authorization: Lab <token>` 是 **Uni-Lab 平台专用的认证方式**`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。
通过云端 API 批量提交实验notebook支持多轮实验参数配置。根据 workflow 模板详情和本地设备注册表自动生成 `node_params` 模板。
## 前置条件(缺一不可)
@@ -20,28 +18,25 @@ description: Batch submit experiments (notebooks) to the Uni-Lab cloud platform
生成 AUTH token任选一种方式
```bash
# 方式一Python 一行生成注意scheme 是 "Lab" 不是 "Basic"
# 方式一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>
# ⚠️ 这里的 "Lab" 是 Uni-Lab 平台的 auth scheme绝对不能用 "Basic" 替代
```
### 2. --addr → BASE URL
| `--addr` | BASE |
| ------------ | ----------------------------------- |
| `test` | `https://leap-lab.test.bohrium.com` |
| `uat` | `https://leap-lab.uat.bohrium.com` |
| `local` | `http://127.0.0.1:48197` |
| 不传(默认) | `https://leap-lab.bohrium.com` |
| `--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 scheme 必须是 "Lab"Uni-Lab 专用),不是 "Basic"
AUTH="Authorization: Lab <上面命令输出的 token>"
```
@@ -49,19 +44,18 @@ AUTH="Authorization: Lab <上面命令输出的 token>"
**批量提交实验时需要本地注册表来解析 workflow 节点的参数 schema。**
**必须先用 Glob 工具搜索文件**,不要直接猜测路径
按优先级搜索
```
Glob: **/req_device_registry_upload.json
<workspace 根目录>/unilabos_data/req_device_registry_upload.json
<workspace 根目录>/req_device_registry_upload.json
```
常见位置(仅供参考,以 Glob 实际结果为准):
- `<workspace>/unilabos_data/req_device_registry_upload.json`
- `<workspace>/req_device_registry_upload.json`
也可直接 Glob 搜索:`**/req_device_registry_upload.json`
找到后**检查文件修改时间**并告知用户。超过 1 天提醒用户是否需要重新启动 `unilab`
**如果 Glob 搜索无结果** → 告知用户先运行 `unilab` 启动命令,等注册表生成后再执行。可跳过此步,但将无法自动生成参数模板,需要用户手动填写 `param`
**如果文件不存在** → 告知用户先运行 `unilab` 启动命令,等注册表生成后再执行。可跳过此步,但将无法自动生成参数模板,需要用户手动填写 `param`
### 4. workflow_uuid目标工作流
@@ -99,7 +93,7 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
返回:
```json
{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } }
{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}}
```
记住 `data.uuid``lab_uuid`
@@ -110,33 +104,9 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
curl -s -X GET "$BASE/api/v1/lab/project/list?lab_uuid=$lab_uuid" -H "$AUTH"
```
返回
返回项目列表,展示给用户选择。列出每个项目的 `uuid``name`
```json
{
"code": 0,
"data": {
"items": [
{
"uuid": "1b3f249a-...",
"name": "bt",
"description": null,
"status": "active",
"created_at": "2026-04-09T14:31:28+08:00"
},
{
"uuid": "b6366243-...",
"name": "default",
"description": "默认项目",
"status": "active",
"created_at": "2026-03-26T11:13:36+08:00"
}
]
}
}
```
展示 `data.items[]` 中每个项目的 `name``uuid`,让用户选择。用户**必须**选择一个项目,记住 `project_uuid`(即选中项目的 `uuid`),后续创建 notebook 时需要提供。
用户**必须**选择一个项目,记住 `project_uuid`,后续创建 notebook 时需要提供。
### 3. 列出可用 workflow
@@ -153,7 +123,6 @@ curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$A
```
返回 workflow 的完整结构,包含所有 action 节点信息。需要从响应中提取:
- 每个 action 节点的 `node_uuid`
- 每个节点对应的设备 ID`resource_template_name`
- 每个节点的动作名(`node_template_name`
@@ -173,30 +142,30 @@ curl -s -X POST "$BASE/api/v1/lab/notebook" \
```json
{
"lab_uuid": "<lab_uuid>",
"project_uuid": "<project_uuid>",
"workflow_uuid": "<workflow_uuid>",
"name": "<实验名称>",
"node_params": [
{
"sample_uuids": ["<样品UUID1>", "<样品UUID2>"],
"datas": [
"lab_uuid": "<lab_uuid>",
"project_uuid": "<project_uuid>",
"workflow_uuid": "<workflow_uuid>",
"name": "<实验名称>",
"node_params": [
{
"node_uuid": "<workflow中的节点UUID>",
"param": {},
"sample_params": [
{
"container_uuid": "<容器UUID>",
"sample_value": {
"liquid_names": "<液体名称>",
"volumes": 1000
}
}
]
"sample_uuids": ["<样品UUID1>", "<样品UUID2>"],
"datas": [
{
"node_uuid": "<workflow中的节点UUID>",
"param": {},
"sample_params": [
{
"container_uuid": "<容器UUID>",
"sample_value": {
"liquid_names": "<液体名称>",
"volumes": 1000
}
}
]
}
]
}
]
}
]
]
}
```
@@ -225,25 +194,25 @@ curl -s -X GET "$BASE/api/v1/lab/notebook/status?uuid=$notebook_uuid" -H "$AUTH"
### 每轮的字段
| 字段 | 类型 | 说明 |
| -------------- | ------------- | ----------------------------------------- |
| 字段 | 类型 | 说明 |
|------|------|------|
| `sample_uuids` | array\<uuid\> | 该轮实验的样品 UUID 数组,无样品时传 `[]` |
| `datas` | array | 该轮中每个 workflow 节点的参数配置 |
| `datas` | array | 该轮中每个 workflow 节点的参数配置 |
### datas 中每个节点
| 字段 | 类型 | 说明 |
| --------------- | ------ | -------------------------------------------- |
| `node_uuid` | string | workflow 模板中的节点 UUID从 API #4 获取) |
| `param` | object | 动作参数(根据本地注册表 schema 填写) |
| `sample_params` | array | 样品相关参数(液体名、体积等) |
| 字段 | 类型 | 说明 |
|------|------|------|
| `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}` |
| 字段 | 类型 | 说明 |
|------|------|------|
| `container_uuid` | string | 容器 UUID |
| `sample_value` | object | 样品值,如 `{"liquid_names": "水", "volumes": 1000}` |
---
@@ -264,7 +233,6 @@ python scripts/gen_notebook_params.py \
> 脚本位于本文档同级目录下的 `scripts/gen_notebook_params.py`。
脚本会:
1. 调用 workflow detail API 获取所有 action 节点
2. 读取本地注册表,为每个节点查找对应的 action schema
3. 生成 `notebook_template.json`,包含:
@@ -302,11 +270,8 @@ python scripts/gen_notebook_params.py \
"properties": {
"goal": {
"properties": {
"asp_vols": {
"type": "array",
"items": { "type": "number" }
},
"sources": { "type": "array" }
"asp_vols": {"type": "array", "items": {"type": "number"}},
"sources": {"type": "array"}
},
"required": ["asp_vols", "sources"]
}

View File

@@ -7,7 +7,7 @@
选项:
--auth <token> Lab tokenbase64(ak:sk) 的结果,不含 "Lab " 前缀)
--base <url> API 基础 URL如 https://leap-lab.test.bohrium.com
--base <url> API 基础 URL如 https://uni-lab.test.bohrium.com
--workflow-uuid <uuid> 目标 workflow 的 UUID
--registry <path> 本地注册表文件路径(默认自动搜索)
--rounds <n> 实验轮次数(默认 1
@@ -17,7 +17,7 @@
示例:
python gen_notebook_params.py \\
--auth YTFmZDlkNGUtxxxx \\
--base https://leap-lab.test.bohrium.com \\
--base https://uni-lab.test.bohrium.com \\
--workflow-uuid abc-123-def \\
--rounds 2
"""

View File

@@ -40,13 +40,13 @@ python ./scripts/gen_auth.py --config <config.py>
决定 API 请求发往哪个服务器。从启动命令的 `--addr` 参数获取:
| `--addr` | BASE URL |
| -------------- | ----------------------------------- |
| `test` | `https://leap-lab.test.bohrium.com` |
| `uat` | `https://leap-lab.uat.bohrium.com` |
| `local` | `http://127.0.0.1:48197` |
| 不传(默认) | `https://leap-lab.bohrium.com` |
| 其他自定义 URL | 直接使用该 URL |
| `--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设备注册表
@@ -54,11 +54,11 @@ python ./scripts/gen_auth.py --config <config.py>
**推断 working_dir**(即 `unilabos_data` 所在目录):
| 条件 | working_dir 取值 |
| -------------------- | -------------------------------------------------------- |
| 条件 | working_dir 取值 |
|------|------------------|
| 传了 `--working_dir` | `<working_dir>/unilabos_data/`(若子目录已存在则直接用) |
| 仅传了 `--config` | `<config 文件所在目录>/unilabos_data/` |
| 都没传 | `<当前工作目录>/unilabos_data/` |
| 仅传了 `--config` | `<config 文件所在目录>/unilabos_data/` |
| 都没传 | `<当前工作目录>/unilabos_data/` |
**按优先级搜索文件**
@@ -84,6 +84,24 @@ python ./scripts/gen_auth.py --config <config.py>
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 — 列出可用设备
@@ -111,7 +129,6 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
脚本会显示设备的 Python 源码路径和类名,方便阅读源码了解参数含义。
每个 action 生成一个 JSON 文件,包含:
- `type` — 作为 API 调用的 `action_type`
- `schema` — 完整 JSON Schema`properties.goal.properties` 参数定义)
- `goal` — goal 字段映射(含占位符 `$placeholder`
@@ -119,14 +136,13 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
### Step 3 — 写 action-index.md
按模板为每个 action 写条目**必须包含 `action_type`**
按模板为每个 action 写条目:
```markdown
### `<action_name>`
<用途描述(一句话)>
- **action_type**: `<从 actions/<name>.json 的 type 字段获取>`
- **Schema**: [`actions/<filename>.json`](actions/<filename>.json)
- **核心参数**: `param1`, `param2`(从 schema.required 获取)
- **可选参数**: `param3`, `param4`
@@ -134,8 +150,6 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
```
描述规则:
- **每个 action 必须标注 `action_type`**(从 JSON 的 `type` 字段读取),这是 API #9 调用时的必填参数,传错会导致任务永远卡住
-`schema.properties` 读参数列表schema 已提升为 goal 内容)
-`schema.required` 区分核心/可选参数
- 按功能分类(移液、枪头、外设等)
@@ -151,7 +165,6 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
### Step 4 — 写 SKILL.md
直接复用 `unilab-device-api` 的 API 模板,修改:
- 设备名称
- Action 数量
- 目录列表
@@ -159,77 +172,42 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
- **AUTH 头** — 使用 Step 0 中 `gen_auth.py` 生成的 `Authorization: Lab <token>`(不要硬编码 `Api` 类型的 key
- **Python 源码路径** — 在 SKILL.md 开头注明设备对应的源码文件,方便参考参数含义
- **Slot 字段表** — 列出本设备哪些 action 的哪些字段需要填入 Slot物料/设备/节点/类名)
- **action_type 速查表** — 在 API #9 说明后面紧跟一个表格,列出每个 action 对应的 `action_type` 值(从 JSON `type` 字段提取),方便 agent 快速查找而无需打开 JSON 文件
API 模板结构:
```markdown
## 设备信息
- device_id, Python 源码路径, 设备类名
## 前置条件(缺一不可)
- ak/sk → AUTH, --addr → BASE URL
## 请求约定
- Windows 平台必须用 curl.exe非 PowerShell 的 curl 别名)
## Session State
- lab_uuid通过 GET /edge/lab/info 直接获取,不要问用户), device_name
## API Endpoints
# - #1 GET /edge/lab/info → 直接拿到 lab_uuid
# - #2 创建工作流 POST /lab/workflow/owner → 拼 URL 告知用户
# - #3 创建节点 POST /edge/workflow/node
# body: {workflow_uuid, resource_template_name: "<device_id>", node_template_name: "<action_name>"}
# - #4 删除节点 DELETE /lab/workflow/nodes
# - #5 更新节点参数 PATCH /lab/workflow/node
# - #6 查询节点 handles POST /lab/workflow/node-handles
# body: {node_uuids: ["uuid1","uuid2"]} → 返回各节点的 handle_uuid
# - #7 批量创建边 POST /lab/workflow/edges
# body: {edges: [{source_node_uuid, target_node_uuid, source_handle_uuid, target_handle_uuid}]}
# - #8 启动工作流 POST /lab/workflow/{uuid}/run
# - #9 运行设备单动作 POST /lab/mcp/run/action action_type 必须从 action-index.md 或 actions/<name>.json 的 type 字段获取,传错会导致任务永远卡住)
# - #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
# - #14 按名称查询物料模板 GET /lab/material/template/by-name?lab_uuid=&name=
# 返回 res_template_uuid用于 #15 创建物料时的必填字段
# - #15 创建物料节点 POST /edge/material/node
# body: {res_template_uuid(从#14获取), name(自定义), display_name, parent_uuid?(从#12获取), ...}
# - #16 更新物料节点 PUT /edge/material/node
# body: {uuid(从#12获取), display_name?, description?, init_param_data?, data?, ...}
# 返回 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" 路径字符串
@@ -239,15 +217,13 @@ API 模板结构:
- 列出本设备所有 Slot 字段、类型及含义
## 渐进加载策略
## 完整工作流 Checklist
```
### Step 5 — 验证
检查文件完整性:
- [ ] `SKILL.md` 包含 API endpoint#1 获取 lab_uuid、#2-#7 工作流/节点/边、#8-#11 运行/查询、#12 资源树、#13 工作流模板详情、#14-#16 物料管理)
- [ ] `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 文件
@@ -296,48 +272,92 @@ API 模板结构:
`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**,配合液体配方 |
| placeholder 值 | Slot 类型 | 填写格式 | 选取范围 |
|---------------|-----------|---------|---------|
| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅**物料**节点(不含设备) |
| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅**设备**节点type=device路径字符串 |
| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | **设备 + 物料**,即所有节点,路径字符串 |
| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已上报的资源类 name |
| `unilabos_formulation` | FormulationSlot | `[{well_name, liquids: [{name, volume}]}]` | 资源树中物料节点的 **name**,配合液体配方 |
### ResourceSlot`unilabos_resources`
最常见的类型。从资源树中选取**物料**节点(孔板、枪头盒、试剂槽等):
- 单个:`{"id": "/workstation/container1", "name": "container1", "uuid": "ff149a9a-..."}`
- 数组:`[{"id": "/path/a", "name": "a", "uuid": "xxx"}, ...]`
- `id` 从 parent 计算的路径格式,根据 action 语义选择正确的物料
```json
{"id": "/workstation/container1", "name": "container1", "uuid": "ff149a9a-2cb8-419d-8db5-d3ba056fb3c2"}
```
> **特例**`create_resource` 的 `res_id`,目标物料可能尚不存在,直接填期望路径,不需要 uuid。
- 单个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` = 目标位置)
### DeviceSlot / NodeSlot / ClassSlot
> **特例**`create_resource` 的 `res_id` 字段,目标物料可能**尚不存在**,此时直接填写期望的路径(如 `"/workstation/container1"`),不需要 uuid。
- **DeviceSlot**`unilabos_devices`):路径字符串如 `"/host_node"`,仅 type=device 的节点
- **NodeSlot**`unilabos_nodes`):路径字符串如 `"/PRCXI/PRCXI_Deck"`,设备 + 物料均可选
- **ClassSlot**`unilabos_class`):类名字符串如 `"container"`,从 `req_resource_registry_upload.json` 查找
### 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": "bottle_A1",
"liquids": [{ "name": "LiPF6", "volume": 0.6 }]
"well_name": "YB_PrepBottle_15mL_Carrier_bottle_A1",
"liquids": [
{ "name": "LiPF6", "volume": 0.6 },
{ "name": "DMC", "volume": 1.2 }
]
}
]
```
- `well_name` — 目标物料的 **name**(从资源树取,不是 `id` 路径)
- `liquids[]` — 液体列表,每条含 `name`(试剂名)和 `volume`体积单位由上下文决定pylabrobot 内部统一 uL
- `sample_uuid` — 样品 UUID无样品传 `""`
- 与 ResourceSlot 的区别ResourceSlot 指向物料本身FormulationSlot 引用物料名并附带配方信息
#### 字段说明
| 字段 | 类型 | 说明 |
|------|------|------|
| `sample_uuid` | string | 样品 UUID无样品时传空字符串 `""` |
| `well_name` | string | 目标物料容器的 **name**(从资源树中取物料节点的 `name` 字段,如瓶子、孔位名称) |
| `liquids` | array | 要加入的液体列表 |
| `liquids[].name` | string | 液体名称(如试剂名、溶剂名) |
| `liquids[].volume` | number | 液体体积(单位由设备决定,通常为 mL |
#### 填写规则
- `well_name` 必须是资源树中已存在的物料节点 `name`(不是 `id` 路径),通过 API #12 获取资源树后筛选
- 每个数组元素代表一个目标容器的配方
- 一个容器可以加入多种液体(`liquids` 数组多条记录)
- 与 ResourceSlot 的区别ResourceSlot 填 `{id, name, uuid}` 指向物料本身FormulationSlot 用 `well_name` 引用物料,并附带液体配方信息
### 通过 API #12 获取资源树
@@ -345,147 +365,7 @@ API 模板结构:
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
```
注意 `lab_uuid` 在路径中(不是查询参数)。返回结构:
```json
{
"code": 0,
"data": {
"nodes": [
{"name": "host_node", "uuid": "c3ec1e68-...", "type": "device", "parent": ""},
{"name": "PRCXI", "uuid": "e249c9a6-...", "type": "device", "parent": ""},
{"name": "PRCXI_Deck", "uuid": "fb6a8b71-...", "type": "deck", "parent": "PRCXI"}
],
"edges": [...]
}
}
```
- `data.nodes[]` — 所有节点(设备 + 物料),每个节点含 `name``uuid``type``parent`
- `type` 区分设备(`device`)和物料(`deck``container``resource` 等)
- `parent` 为父节点名称(空字符串表示顶级)
- 填写 Slot 时根据 placeholder 类型筛选ResourceSlot 取非 device 节点DeviceSlot 取 device 节点
- 创建/更新物料时:`parent_uuid` 取父节点的 `uuid`,更新目标的 `uuid` 取节点自身的 `uuid`
## 物料管理 API
设备 Skill 除了设备动作外,还需支持物料节点的创建和参数设定,用于在资源树中动态管理物料。
典型流程:先通过 **#14 按名称查询模板** 获取 `res_template_uuid` → 再通过 **#15 创建物料** → 之后可通过 **#16 更新物料** 修改属性。更新时需要的 `uuid``parent_uuid` 均从 **#12 资源树下载** 获取。
### API #14 — 按名称查询物料模板
创建物料前,需要先获取物料模板的 UUID。通过模板名称查询
```bash
curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=<template_name>" -H "$AUTH"
```
| 参数 | 必填 | 说明 |
| ---------- | ------ | -------------------------------- |
| `lab_uuid` | **是** | 实验室 UUID从 API #1 获取) |
| `name` | **是** | 物料模板名称(如 `"container"` |
返回 `code: 0` 时,**`data.uuid`** 即为 `res_template_uuid`,用于 API #15 创建物料。返回还包含 `name``resource_type``handles``config_infos` 等模板元信息。
模板不存在时返回 `code: 10002``data` 为空对象。模板名称来自资源注册表中已注册的资源类型。
### API #15 — 创建物料节点
```bash
curl -s -X POST "$BASE/api/v1/edge/material/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '<request_body>'
```
请求体:
```json
{
"res_template_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"name": "my_custom_bottle",
"display_name": "自定义瓶子",
"parent_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"type": "",
"init_param_data": {},
"schema": {},
"data": {
"liquids": [["water", 1000, "uL"]],
"max_volume": 50000
},
"plate_well_datas": {},
"plate_reagent_datas": {},
"pose": {},
"model": {}
}
```
| 字段 | 必填 | 类型 | 数据来源 | 说明 |
| --------------------- | ------ | ------------- | ----------------------------------- | -------------------------------------- |
| `res_template_uuid` | **是** | string (UUID) | **API #14** 按名称查询获取 | 物料模板 UUID |
| `name` | 否 | string | **用户自定义** | 节点名称(标识符),可自由命名 |
| `display_name` | 否 | string | 用户自定义 | 显示名称UI 展示用) |
| `parent_uuid` | 否 | string (UUID) | **API #12** 资源树中父节点的 `uuid` | 父节点,为空则创建顶级节点 |
| `type` | 否 | string | 从模板继承 | 节点类型 |
| `init_param_data` | 否 | object | 用户指定 | 初始化参数,覆盖模板默认值 |
| `data` | 否 | object | 用户指定 | 节点数据container 见下方 data 格式 |
| `plate_well_datas` | 否 | object | 用户指定 | 孔板子节点数据(创建带孔位的板时使用) |
| `plate_reagent_datas` | 否 | object | 用户指定 | 试剂关联数据 |
| `schema` | 否 | object | 从模板继承 | 自定义 schema不传则从模板继承 |
| `pose` | 否 | object | 用户指定 | 位姿信息 |
| `model` | 否 | object | 用户指定 | 3D 模型信息 |
#### container 的 `data` 格式
> **体积单位统一为 uL微升**。pylabrobot 体系中所有体积值(`max_volume`、`liquids` 中的 volume均为 uL。外部如果是 mL 需乘 1000 转换。
```json
{
"liquids": [["water", 1000, "uL"], ["ethanol", 500, "uL"]],
"max_volume": 50000
}
```
- `liquids` — 液体列表,每条为 `[液体名称, 体积(uL), 单位字符串]`
- `max_volume` — 容器最大容量uL如 50 mL = 50000 uL
### API #16 — 更新物料节点
```bash
curl -s -X PUT "$BASE/api/v1/edge/material/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '<request_body>'
```
请求体:
```json
{
"uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"parent_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"display_name": "新显示名称",
"description": "新描述",
"init_param_data": {},
"data": {},
"pose": {},
"schema": {},
"extra": {}
}
```
| 字段 | 必填 | 类型 | 数据来源 | 说明 |
| ----------------- | ------ | ------------- | ------------------------------------- | ---------------- |
| `uuid` | **是** | string (UUID) | **API #12** 资源树中目标节点的 `uuid` | 要更新的物料节点 |
| `parent_uuid` | 否 | string (UUID) | API #12 资源树 | 移动到新父节点 |
| `display_name` | 否 | string | 用户指定 | 更新显示名称 |
| `description` | 否 | string | 用户指定 | 更新描述 |
| `init_param_data` | 否 | object | 用户指定 | 更新初始化参数 |
| `data` | 否 | object | 用户指定 | 更新节点数据 |
| `pose` | 否 | object | 用户指定 | 更新位姿 |
| `schema` | 否 | object | 用户指定 | 更新 schema |
| `extra` | 否 | object | 用户指定 | 更新扩展数据 |
> 只传需要更新的字段,未传的字段保持不变。
注意 `lab_uuid` 在路径中(不是查询参数)。资源树返回所有节点,每个节点包含 `id`(路径格式)、`name``uuid``type``parent` 等字段。填写 Slot 时需根据 placeholder 类型筛选正确的节点。
## 最终目录结构

View File

@@ -1,251 +0,0 @@
---
name: host-node
description: Operate Uni-Lab host node via REST API — create resources, test latency, test resource tree, manual confirm. Use when the user mentions host_node, creating resources, resource management, testing latency, or any host node operation.
---
# Host Node API Skill
## 设备信息
- **device_id**: `host_node`
- **Python 源码**: `unilabos/ros/nodes/presets/host_node.py`
- **设备类**: `HostNode`
- **动作数**: 4`create_resource`, `test_latency`, `auto-test_resource`, `manual_confirm`
## 前置条件(缺一不可)
使用本 skill 前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
### 1. ak / sk → AUTH
从启动参数 `--ak` `--sk` 或 config.py 中获取,生成 token`base64(ak:sk)``Authorization: Lab <token>`
### 2. --addr → BASE URL
| `--addr` 值 | BASE |
| ------------ | ----------------------------------- |
| `test` | `https://leap-lab.test.bohrium.com` |
| `uat` | `https://leap-lab.uat.bohrium.com` |
| `local` | `http://127.0.0.1:48197` |
| 不传(默认) | `https://leap-lab.bohrium.com` |
确认后设置:
```bash
BASE="<根据 addr 确定的 URL>"
AUTH="Authorization: Lab <token>"
```
**两项全部就绪后才可发起 API 请求。**
## Session State
在整个对话过程中agent 需要记住以下状态,避免重复询问用户:
- `lab_uuid` — 实验室 UUID首次通过 API #1 自动获取,**不需要问用户**
- `device_name``host_node`
## 请求约定
所有请求使用 `curl -s`POST/PATCH/DELETE 需加 `Content-Type: application/json`
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名)。
---
## API Endpoints
### 1. 获取实验室信息(自动获取 lab_uuid
```bash
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
```
返回 `data.uuid``lab_uuid``data.name``lab_name`
### 2. 创建工作流
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/owner" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"name":"<名称>","lab_uuid":"<lab_uuid>","description":"<描述>"}'
```
返回 `data.uuid``workflow_uuid`。创建成功后告知用户链接:`$BASE/laboratory/$lab_uuid/workflow/$workflow_uuid`
### 3. 创建节点
```bash
curl -s -X POST "$BASE/api/v1/edge/workflow/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"workflow_uuid":"<workflow_uuid>","resource_template_name":"host_node","node_template_name":"<action_name>"}'
```
- `resource_template_name` 固定为 `host_node`
- `node_template_name` — action 名称(如 `create_resource`, `test_latency`
### 4. 删除节点
```bash
curl -s -X DELETE "$BASE/api/v1/lab/workflow/nodes" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"node_uuids":["<uuid1>"],"workflow_uuid":"<workflow_uuid>"}'
```
### 5. 更新节点参数
```bash
curl -s -X PATCH "$BASE/api/v1/lab/workflow/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"workflow_uuid":"<wf_uuid>","uuid":"<node_uuid>","param":{...}}'
```
`param` 直接使用创建节点返回的 `data.param` 结构,修改需要填入的字段值。参考 [action-index.md](action-index.md) 确定哪些字段是 Slot。
### 6. 查询节点 handles
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/node-handles" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"node_uuids":["<node_uuid_1>","<node_uuid_2>"]}'
```
### 7. 批量创建边
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/edges" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"edges":[{"source_node_uuid":"<uuid>","target_node_uuid":"<uuid>","source_handle_uuid":"<uuid>","target_handle_uuid":"<uuid>"}]}'
```
### 8. 启动工作流
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/<workflow_uuid>/run" -H "$AUTH"
```
### 9. 运行设备单动作
```bash
curl -s -X POST "$BASE/api/v1/lab/mcp/run/action" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"lab_uuid":"<lab_uuid>","device_id":"host_node","action":"<action_name>","action_type":"<type>","param":{...}}'
```
`param` 直接放 goal 里的属性,**不要**再包一层 `{"goal": {...}}`
> **WARNING: `action_type` 必须正确,传错会导致任务永远卡住无法完成。** 从下表或 `actions/<name>.json` 的 `type` 字段获取。
#### action_type 速查表
| action | action_type |
|--------|-------------|
| `test_latency` | `UniLabJsonCommand` |
| `create_resource` | `ResourceCreateFromOuterEasy` |
| `auto-test_resource` | `UniLabJsonCommand` |
| `manual_confirm` | `UniLabJsonCommand` |
### 10. 查询任务状态
```bash
curl -s -X GET "$BASE/api/v1/lab/mcp/task/<task_uuid>" -H "$AUTH"
```
### 11. 运行工作流单节点
```bash
curl -s -X POST "$BASE/api/v1/lab/mcp/run/workflow/action" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"node_uuid":"<node_uuid>"}'
```
### 12. 获取资源树(物料信息)
```bash
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
```
注意 `lab_uuid` 在路径中。返回 `data.nodes[]` 含所有节点(设备 + 物料),每个节点含 `name``uuid``type``parent`
### 13. 获取工作流模板详情
```bash
curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH"
```
> 必须使用 `/lab/workflow/template/detail/{uuid}`,其他路径会返回 404。
### 14. 按名称查询物料模板
```bash
curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=<template_name>" -H "$AUTH"
```
返回 `data.uuid``res_template_uuid`,用于 API #15
### 15. 创建物料节点
```bash
curl -s -X POST "$BASE/api/v1/edge/material/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"res_template_uuid":"<uuid>","name":"<名称>","display_name":"<显示名>","parent_uuid":"<父节点uuid>","data":{...}}'
```
### 16. 更新物料节点
```bash
curl -s -X PUT "$BASE/api/v1/edge/material/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"uuid":"<节点uuid>","display_name":"<新名称>","data":{...}}'
```
---
## Placeholder Slot 填写规则
| `placeholder_keys` 值 | Slot 类型 | 填写格式 | 选取范围 |
| --------------------- | ------------ | ----------------------------------------------------- | ---------------------- |
| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅物料节点(非设备) |
| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅设备节点type=device |
| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | 所有节点(设备 + 物料) |
| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已注册的资源类 |
### host_node 设备的 Slot 字段表
| Action | 字段 | Slot 类型 | 说明 |
| ----------------- | ----------- | ------------ | ------------------------------ |
| `create_resource` | `res_id` | ResourceSlot | 新资源路径(可填不存在的路径) |
| `create_resource` | `device_id` | DeviceSlot | 归属设备 |
| `create_resource` | `parent` | NodeSlot | 父节点路径 |
| `create_resource` | `class_name`| ClassSlot | 资源类名如 `"container"` |
| `auto-test_resource` | `resource` | ResourceSlot | 单个测试物料 |
| `auto-test_resource` | `resources` | ResourceSlot | 测试物料数组 |
| `auto-test_resource` | `device` | DeviceSlot | 测试设备 |
| `auto-test_resource` | `devices` | DeviceSlot | 测试设备 |
---
## 渐进加载策略
1. **SKILL.md**(本文件)— API 端点 + session state 管理
2. **[action-index.md](action-index.md)** — 按分类浏览 4 个动作的描述和核心参数
3. **[actions/\<name\>.json](actions/)** — 仅在需要构建具体请求时,加载对应 action 的完整 JSON Schema
---
## 完整工作流 Checklist
```
Task Progress:
- [ ] Step 1: GET /edge/lab/info 获取 lab_uuid
- [ ] Step 2: 获取资源树 (GET #12) → 记住可用物料
- [ ] Step 3: 读 action-index.md 确定要用的 action 名
- [ ] Step 4: 创建工作流 (POST #2) → 记住 workflow_uuid告知用户链接
- [ ] Step 5: 创建节点 (POST #3, resource_template_name=host_node) → 记住 node_uuid + data.param
- [ ] Step 6: 根据 _unilabos_placeholder_info 和资源树,填写 data.param 中的 Slot 字段
- [ ] Step 7: 更新节点参数 (PATCH #5)
- [ ] Step 8: 查询节点 handles (POST #6) → 获取各节点的 handle_uuid
- [ ] Step 9: 批量创建边 (POST #7) → 用 handle_uuid 连接节点
- [ ] Step 10: 启动工作流 (POST #8) 或运行单节点 (POST #11)
- [ ] Step 11: 查询任务状态 (GET #10) 确认完成
```

View File

@@ -1,58 +0,0 @@
# Action Index — host_node
4 个动作,按功能分类。每个动作的完整 JSON Schema 在 `actions/<name>.json`
---
## 资源管理
### `create_resource`
在资源树中创建新资源(容器、物料等),支持指定位置、类型和初始液体
- **action_type**: `ResourceCreateFromOuterEasy`
- **Schema**: [`actions/create_resource.json`](actions/create_resource.json)
- **可选参数**: `res_id`, `device_id`, `class_name`, `parent`, `bind_locations`, `liquid_input_slot`, `liquid_type`, `liquid_volume`, `slot_on_deck`
- **占位符字段**:
- `res_id`**ResourceSlot**(特例:目标物料可能尚不存在,直接填期望路径)
- `device_id`**DeviceSlot**,填路径字符串如 `"/host_node"`
- `parent`**NodeSlot**,填路径字符串如 `"/workstation/deck"`
- `class_name`**ClassSlot**,填类名如 `"container"`
### `auto-test_resource`
测试资源系统,返回当前资源树和设备列表
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/test_resource.json`](actions/test_resource.json)
- **可选参数**: `resource`, `resources`, `device`, `devices`
- **占位符字段**:
- `resource`**ResourceSlot**,单个物料节点 `{id, name, uuid}`
- `resources`**ResourceSlot**,物料节点数组 `[{id, name, uuid}, ...]`
- `device`**DeviceSlot**,设备路径字符串
- `devices`**DeviceSlot**,设备路径字符串
---
## 系统工具
### `test_latency`
测试设备通信延迟,返回 RTT、时间差、任务延迟等指标
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/test_latency.json`](actions/test_latency.json)
- **参数**: 无(零参数调用)
---
## 人工确认
### `manual_confirm`
创建人工确认节点,等待用户手动确认后继续
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/manual_confirm.json`](actions/manual_confirm.json)
- **核心参数**: `timeout_seconds`(超时时间,秒), `assignee_user_ids`(指派用户 ID 列表)
- **占位符字段**: `assignee_user_ids``unilabos_manual_confirm` 类型

View File

@@ -1,93 +0,0 @@
{
"type": "ResourceCreateFromOuterEasy",
"goal": {
"res_id": "res_id",
"class_name": "class_name",
"parent": "parent",
"device_id": "device_id",
"bind_locations": "bind_locations",
"liquid_input_slot": "liquid_input_slot[]",
"liquid_type": "liquid_type[]",
"liquid_volume": "liquid_volume[]",
"slot_on_deck": "slot_on_deck"
},
"schema": {
"type": "object",
"properties": {
"res_id": {
"type": "string"
},
"device_id": {
"type": "string"
},
"class_name": {
"type": "string"
},
"parent": {
"type": "string"
},
"bind_locations": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z"
],
"title": "bind_locations",
"additionalProperties": false
},
"liquid_input_slot": {
"type": "array",
"items": {
"type": "integer"
}
},
"liquid_type": {
"type": "array",
"items": {
"type": "string"
}
},
"liquid_volume": {
"type": "array",
"items": {
"type": "number"
}
},
"slot_on_deck": {
"type": "string"
}
},
"required": [],
"_unilabos_placeholder_info": {
"res_id": "unilabos_resources",
"device_id": "unilabos_devices",
"parent": "unilabos_nodes",
"class_name": "unilabos_class"
}
},
"goal_default": {},
"placeholder_keys": {
"res_id": "unilabos_resources",
"device_id": "unilabos_devices",
"parent": "unilabos_nodes",
"class_name": "unilabos_class"
}
}

View File

@@ -1,32 +0,0 @@
{
"type": "UniLabJsonCommand",
"goal": {
"timeout_seconds": "timeout_seconds",
"assignee_user_ids": "assignee_user_ids"
},
"schema": {
"type": "object",
"properties": {
"timeout_seconds": {
"type": "integer"
},
"assignee_user_ids": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"timeout_seconds",
"assignee_user_ids"
],
"_unilabos_placeholder_info": {
"assignee_user_ids": "unilabos_manual_confirm"
}
},
"goal_default": {},
"placeholder_keys": {
"assignee_user_ids": "unilabos_manual_confirm"
}
}

View File

@@ -1,11 +0,0 @@
{
"type": "UniLabJsonCommand",
"goal": {},
"schema": {
"type": "object",
"properties": {},
"required": []
},
"goal_default": {},
"placeholder_keys": {}
}

View File

@@ -1,255 +0,0 @@
{
"type": "UniLabJsonCommand",
"goal": {
"resource": "resource",
"resources": "resources",
"device": "device",
"devices": "devices"
},
"schema": {
"type": "object",
"properties": {
"resource": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"sample_id": {
"type": "string"
},
"children": {
"type": "array",
"items": {
"type": "string"
}
},
"parent": {
"type": "string"
},
"type": {
"type": "string"
},
"category": {
"type": "string"
},
"pose": {
"type": "object",
"properties": {
"position": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z"
],
"title": "position",
"additionalProperties": false
},
"orientation": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"w": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z",
"w"
],
"title": "orientation",
"additionalProperties": false
}
},
"required": [
"position",
"orientation"
],
"title": "pose",
"additionalProperties": false
},
"config": {
"type": "string"
},
"data": {
"type": "string"
}
},
"title": "resource"
},
"resources": {
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"sample_id": {
"type": "string"
},
"children": {
"type": "array",
"items": {
"type": "string"
}
},
"parent": {
"type": "string"
},
"type": {
"type": "string"
},
"category": {
"type": "string"
},
"pose": {
"type": "object",
"properties": {
"position": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z"
],
"title": "position",
"additionalProperties": false
},
"orientation": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"w": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z",
"w"
],
"title": "orientation",
"additionalProperties": false
}
},
"required": [
"position",
"orientation"
],
"title": "pose",
"additionalProperties": false
},
"config": {
"type": "string"
},
"data": {
"type": "string"
}
},
"title": "resources"
},
"type": "array"
},
"device": {
"type": "string",
"description": "device reference"
},
"devices": {
"type": "string",
"description": "device reference"
}
},
"required": [],
"_unilabos_placeholder_info": {
"resource": "unilabos_resources",
"resources": "unilabos_resources",
"device": "unilabos_devices",
"devices": "unilabos_devices"
}
},
"goal_default": {},
"placeholder_keys": {
"resource": "unilabos_resources",
"resources": "unilabos_resources",
"device": "unilabos_devices",
"devices": "unilabos_devices"
}
}

View File

@@ -1,13 +1,11 @@
---
name: submit-agent-result
description: Submit historical experiment results (agent_result) to Uni-Lab cloud platform (leap-lab) notebook — read data files, assemble JSON payload, PUT to cloud API. Use when the user wants to submit experiment results, upload agent results, report experiment data, or mentions agent_result/实验结果/历史记录/notebook结果.
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结果.
---
# Uni-Lab 提交历史实验记录指南
# 提交历史实验记录指南
通过 Uni-Lab 云端 API 向已创建的 notebook 提交实验结果数据agent_result。支持从 JSON / CSV 文件读取数据,整合后提交。
> **重要**:本指南中的 `Authorization: Lab <token>` 是 **Uni-Lab 平台专用的认证方式**`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。
通过云端 API 向已创建的 notebook 提交实验结果数据agent_result。支持从 JSON / CSV 文件读取数据,整合后提交。
## 前置条件(缺一不可)
@@ -20,26 +18,23 @@ description: Submit historical experiment results (agent_result) to Uni-Lab clou
生成 AUTH token
```bash
# ⚠️ 注意scheme 是 "Lab"Uni-Lab 专用),不是 "Basic"
python -c "import base64,sys; print(base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
```
输出即为 token 值,拼接为 `Authorization: Lab <token>``Lab` 是 Uni-Lab 平台 auth scheme不可替换为 `Basic`
输出即为 token 值,拼接为 `Authorization: Lab <token>`
### 2. --addr → BASE URL
| `--addr` | BASE |
| ------------ | ----------------------------------- |
| `test` | `https://leap-lab.test.bohrium.com` |
| `uat` | `https://leap-lab.uat.bohrium.com` |
| `local` | `http://127.0.0.1:48197` |
| 不传(默认) | `https://leap-lab.bohrium.com` |
| `--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 scheme 必须是 "Lab"Uni-Lab 专用),不是 "Basic"
AUTH="Authorization: Lab <上面命令输出的 token>"
```
@@ -50,7 +45,6 @@ AUTH="Authorization: Lab <上面命令输出的 token>"
notebook_uuid 来自之前通过「批量提交实验」创建的实验批次,即 `POST /api/v1/lab/notebook` 返回的 `data.uuid`
如果用户不记得,可提示:
- 查看之前的对话记录中创建 notebook 时返回的 UUID
- 或通过平台页面查找对应的 notebook
@@ -60,11 +54,11 @@ notebook_uuid 来自之前通过「批量提交实验」创建的实验批次,
用户需要提供实验结果数据,支持以下方式:
| 方式 | 说明 |
| --------- | ----------------------------------------------- |
| JSON 文件 | 直接作为 `agent_result` 的内容合并 |
| CSV 文件 | 转为 `{"文件名": [行数据...]}` 格式 |
| 手动指定 | 用户直接告知 key-value 数据,由 agent 构建 JSON |
| 方式 | 说明 |
|------|------|
| JSON 文件 | 直接作为 `agent_result` 的内容合并 |
| CSV 文件 | 转为 `{"文件名": [行数据...]}` 格式 |
| 手动指定 | 用户直接告知 key-value 数据,由 agent 构建 JSON |
**四项全部就绪后才可开始。**
@@ -96,7 +90,7 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
返回:
```json
{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } }
{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}}
```
记住 `data.uuid``lab_uuid`
@@ -127,45 +121,42 @@ curl -s -X PUT "$BASE/api/v1/lab/notebook/agent-result" \
#### 必要字段
| 字段 | 类型 | 说明 |
| --------------- | ------------- | ------------------------------------------- |
| 字段 | 类型 | 说明 |
|------|------|------|
| `notebook_uuid` | string (UUID) | 目标 notebook 的 UUID从批量提交实验时获取 |
| `agent_result` | object | 实验结果数据,任意 JSON 对象 |
| `agent_result` | object | 实验结果数据,任意 JSON 对象 |
#### agent_result 内容格式
`agent_result` 接受**任意 JSON 对象**,常见格式:
**简单键值对**
```json
{
"avg_rtt_ms": 12.5,
"status": "success",
"test_count": 5
"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" }
]
"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 }
]
"experiment_data": [
{"温度": 25, "压力": 101.3, "产率": 0.85},
{"温度": 30, "压力": 101.3, "产率": 0.91}
]
}
```
@@ -187,22 +178,22 @@ python scripts/prepare_agent_result.py \
[--output <output.json>]
```
| 参数 | 必选 | 说明 |
| ----------------- | ---------- | ----------------------------------------------- |
| `--notebook-uuid` | 是 | 目标 notebook UUID |
| `--files` | 是 | 输入文件路径支持多个JSON / CSV |
| `--auth` | 提交时必选 | Lab tokenbase64(ak:sk) |
| `--base` | 提交时必选 | API base URL |
| `--submit` | 否 | 加上此标志则直接提交到云端 |
| `--output` | 否 | 输出 JSON 路径(默认 `agent_result_body.json` |
| 参数 | 必选 | 说明 |
|------|------|------|
| `--notebook-uuid` | 是 | 目标 notebook UUID |
| `--files` | 是 | 输入文件路径支持多个JSON / CSV |
| `--auth` | 提交时必选 | Lab tokenbase64(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 | 字段直接合并到 `agent_result` 顶层 |
| `.json`list/other | 以文件名为 key 放入 `agent_result` |
| `.csv` | 以文件名(不含扩展名)为 key值为行对象数组 |
多个文件的字段会合并。JSON dict 中的重复 key 后者覆盖前者。
@@ -219,7 +210,7 @@ python scripts/prepare_agent_result.py \
--notebook-uuid 73c67dca-c8cc-4936-85a0-329106aa7cca \
--files results.json \
--auth YTFmZDlkNGUt... \
--base https://leap-lab.test.bohrium.com \
--base https://uni-lab.test.bohrium.com \
--submit
```
@@ -281,4 +272,4 @@ Task Progress:
### Q: 认证方式是 Lab 还是 Api
本指南统一使用 `Authorization: Lab <base64(ak:sk)>` 方式`Lab` 是 Uni-Lab 平台的 auth scheme**绝不能用 `Basic` 替代**。如果用户有独立的 API Key也可用 `Authorization: Api <key>` 替代。
本指南统一使用 `Authorization: Lab <base64(ak:sk)>` 方式。如果用户有独立的 API Key也可用 `Authorization: Api <key>` 替代。

View File

@@ -1,272 +0,0 @@
---
name: virtual-workbench
description: Operate Virtual Workbench via REST API — prepare materials, move to heating stations, start heating, move to output, transfer resources. Use when the user mentions virtual workbench, virtual_workbench, 虚拟工作台, heating stations, material processing, or workbench operations.
---
# Virtual Workbench API Skill
## 设备信息
- **device_id**: `virtual_workbench`
- **Python 源码**: `unilabos/devices/virtual/workbench.py`
- **设备类**: `VirtualWorkbench`
- **动作数**: 6`auto-prepare_materials`, `auto-move_to_heating_station`, `auto-start_heating`, `auto-move_to_output`, `transfer`, `manual_confirm`
- **设备描述**: 模拟工作台,包含 1 个机械臂(每次操作 2s独占锁和 3 个加热台(每次加热 60s可并行
### 典型工作流程
1. `prepare_materials` — 生成 A1-A5 物料5 个 output handle
2. `move_to_heating_station` — 物料并发竞争机械臂,移动到空闲加热台
3. `start_heating` — 启动加热3 个加热台可并行)
4. `move_to_output` — 加热完成后移到输出位置 Cn
## 前置条件(缺一不可)
使用本 skill 前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
### 1. ak / sk → AUTH
从启动参数 `--ak` `--sk` 或 config.py 中获取,生成 token`base64(ak:sk)``Authorization: Lab <token>`
### 2. --addr → BASE URL
| `--addr` 值 | BASE |
| ------------ | ----------------------------------- |
| `test` | `https://leap-lab.test.bohrium.com` |
| `uat` | `https://leap-lab.uat.bohrium.com` |
| `local` | `http://127.0.0.1:48197` |
| 不传(默认) | `https://leap-lab.bohrium.com` |
确认后设置:
```bash
BASE="<根据 addr 确定的 URL>"
AUTH="Authorization: Lab <token>"
```
**两项全部就绪后才可发起 API 请求。**
## Session State
- `lab_uuid` — 实验室 UUID首次通过 API #1 自动获取,**不需要问用户**
- `device_name``virtual_workbench`
## 请求约定
所有请求使用 `curl -s`POST/PATCH/DELETE 需加 `Content-Type: application/json`
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名)。
---
## API Endpoints
### 1. 获取实验室信息(自动获取 lab_uuid
```bash
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
```
返回 `data.uuid``lab_uuid``data.name``lab_name`
### 2. 创建工作流
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/owner" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"name":"<名称>","lab_uuid":"<lab_uuid>","description":"<描述>"}'
```
返回 `data.uuid``workflow_uuid`。创建成功后告知用户链接:`$BASE/laboratory/$lab_uuid/workflow/$workflow_uuid`
### 3. 创建节点
```bash
curl -s -X POST "$BASE/api/v1/edge/workflow/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"workflow_uuid":"<workflow_uuid>","resource_template_name":"virtual_workbench","node_template_name":"<action_name>"}'
```
- `resource_template_name` 固定为 `virtual_workbench`
- `node_template_name` — action 名称(如 `auto-prepare_materials`, `auto-move_to_heating_station`
### 4. 删除节点
```bash
curl -s -X DELETE "$BASE/api/v1/lab/workflow/nodes" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"node_uuids":["<uuid1>"],"workflow_uuid":"<workflow_uuid>"}'
```
### 5. 更新节点参数
```bash
curl -s -X PATCH "$BASE/api/v1/lab/workflow/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"workflow_uuid":"<wf_uuid>","uuid":"<node_uuid>","param":{...}}'
```
参考 [action-index.md](action-index.md) 确定哪些字段是 Slot。
### 6. 查询节点 handles
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/node-handles" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"node_uuids":["<node_uuid_1>","<node_uuid_2>"]}'
```
### 7. 批量创建边
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/edges" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"edges":[{"source_node_uuid":"<uuid>","target_node_uuid":"<uuid>","source_handle_uuid":"<uuid>","target_handle_uuid":"<uuid>"}]}'
```
### 8. 启动工作流
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/<workflow_uuid>/run" -H "$AUTH"
```
### 9. 运行设备单动作
```bash
curl -s -X POST "$BASE/api/v1/lab/mcp/run/action" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"lab_uuid":"<lab_uuid>","device_id":"virtual_workbench","action":"<action_name>","action_type":"<type>","param":{...}}'
```
`param` 直接放 goal 里的属性,**不要**再包一层 `{"goal": {...}}`
> **WARNING: `action_type` 必须正确,传错会导致任务永远卡住无法完成。** 从下表或 `actions/<name>.json` 的 `type` 字段获取。
#### action_type 速查表
| action | action_type |
|--------|-------------|
| `auto-prepare_materials` | `UniLabJsonCommand` |
| `auto-move_to_heating_station` | `UniLabJsonCommand` |
| `auto-start_heating` | `UniLabJsonCommand` |
| `auto-move_to_output` | `UniLabJsonCommand` |
| `transfer` | `UniLabJsonCommandAsync` |
| `manual_confirm` | `UniLabJsonCommand` |
### 10. 查询任务状态
```bash
curl -s -X GET "$BASE/api/v1/lab/mcp/task/<task_uuid>" -H "$AUTH"
```
### 11. 运行工作流单节点
```bash
curl -s -X POST "$BASE/api/v1/lab/mcp/run/workflow/action" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"node_uuid":"<node_uuid>"}'
```
### 12. 获取资源树(物料信息)
```bash
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
```
注意 `lab_uuid` 在路径中。返回 `data.nodes[]` 含所有节点(设备 + 物料),每个节点含 `name``uuid``type``parent`
### 13. 获取工作流模板详情
```bash
curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH"
```
> 必须使用 `/lab/workflow/template/detail/{uuid}`,其他路径会返回 404。
### 14. 按名称查询物料模板
```bash
curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=<template_name>" -H "$AUTH"
```
返回 `data.uuid``res_template_uuid`,用于 API #15
### 15. 创建物料节点
```bash
curl -s -X POST "$BASE/api/v1/edge/material/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"res_template_uuid":"<uuid>","name":"<名称>","display_name":"<显示名>","parent_uuid":"<父节点uuid>","data":{...}}'
```
### 16. 更新物料节点
```bash
curl -s -X PUT "$BASE/api/v1/edge/material/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"uuid":"<节点uuid>","display_name":"<新名称>","data":{...}}'
```
---
## Placeholder Slot 填写规则
| `placeholder_keys` 值 | Slot 类型 | 填写格式 | 选取范围 |
| --------------------- | ------------ | ----------------------------------------------------- | ---------------------- |
| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅物料节点(非设备) |
| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅设备节点type=device |
| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | 所有节点(设备 + 物料) |
| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已注册的资源类 |
### virtual_workbench 设备的 Slot 字段表
| Action | 字段 | Slot 类型 | 说明 |
| ----------------- | ---------------- | ------------ | -------------------- |
| `transfer` | `resource` | ResourceSlot | 待转移物料数组 |
| `transfer` | `target_device` | DeviceSlot | 目标设备路径 |
| `transfer` | `mount_resource` | ResourceSlot | 目标孔位数组 |
| `manual_confirm` | `resource` | ResourceSlot | 确认用物料数组 |
| `manual_confirm` | `target_device` | DeviceSlot | 确认用目标设备 |
| `manual_confirm` | `mount_resource` | ResourceSlot | 确认用目标孔位数组 |
> `prepare_materials`、`move_to_heating_station`、`start_heating`、`move_to_output` 这 4 个动作**无 Slot 字段**,参数为纯数值/整数。
---
## 渐进加载策略
1. **SKILL.md**(本文件)— API 端点 + session state 管理 + 设备工作流概览
2. **[action-index.md](action-index.md)** — 按分类浏览 6 个动作的描述和核心参数
3. **[actions/\<name\>.json](actions/)** — 仅在需要构建具体请求时,加载对应 action 的完整 JSON Schema
---
## 完整工作流 Checklist
```
Task Progress:
- [ ] Step 1: GET /edge/lab/info 获取 lab_uuid
- [ ] Step 2: 获取资源树 (GET #12) → 记住可用物料
- [ ] Step 3: 读 action-index.md 确定要用的 action 名
- [ ] Step 4: 创建工作流 (POST #2) → 记住 workflow_uuid告知用户链接
- [ ] Step 5: 创建节点 (POST #3, resource_template_name=virtual_workbench) → 记住 node_uuid + data.param
- [ ] Step 6: 根据 _unilabos_placeholder_info 和资源树,填写 data.param 中的 Slot 字段
- [ ] Step 7: 更新节点参数 (PATCH #5)
- [ ] Step 8: 查询节点 handles (POST #6) → 获取各节点的 handle_uuid
- [ ] Step 9: 批量创建边 (POST #7) → 用 handle_uuid 连接节点
- [ ] Step 10: 启动工作流 (POST #8) 或运行单节点 (POST #11)
- [ ] Step 11: 查询任务状态 (GET #10) 确认完成
```
### 典型 5 物料并发加热工作流示例
```
prepare_materials (count=5)
├─ channel_1 → move_to_heating_station (material_number=1) → start_heating → move_to_output
├─ channel_2 → move_to_heating_station (material_number=2) → start_heating → move_to_output
├─ channel_3 → move_to_heating_station (material_number=3) → start_heating → move_to_output
├─ channel_4 → move_to_heating_station (material_number=4) → start_heating → move_to_output
└─ channel_5 → move_to_heating_station (material_number=5) → start_heating → move_to_output
```
创建节点时,`prepare_materials` 的 5 个 output handle`channel_1` ~ `channel_5`)分别连接到 5 个 `move_to_heating_station` 节点的 `material_input` handle。每个 `move_to_heating_station``heating_station_output``material_number_output` 连接到对应 `start_heating``station_id_input``material_number_input`

View File

@@ -1,76 +0,0 @@
# Action Index — virtual_workbench
6 个动作,按功能分类。每个动作的完整 JSON Schema 在 `actions/<name>.json`
---
## 物料准备
### `auto-prepare_materials`
批量准备物料(虚拟起始节点),生成 A1-A5 物料编号,输出 5 个 handle 供后续节点使用
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/prepare_materials.json`](actions/prepare_materials.json)
- **可选参数**: `count`(物料数量,默认 5
---
## 机械臂 & 加热台操作
### `auto-move_to_heating_station`
将物料从 An 位置移动到空闲加热台(竞争机械臂,自动查找空闲加热台)
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/move_to_heating_station.json`](actions/move_to_heating_station.json)
- **核心参数**: `material_number`物料编号integer
### `auto-start_heating`
启动指定加热台的加热程序可并行3 个加热台同时工作)
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/start_heating.json`](actions/start_heating.json)
- **核心参数**: `station_id`(加热台 ID`material_number`(物料编号)
### `auto-move_to_output`
将加热完成的物料从加热台移动到输出位置 Cn
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/move_to_output.json`](actions/move_to_output.json)
- **核心参数**: `station_id`(加热台 ID`material_number`(物料编号)
---
## 物料转移
### `transfer`
异步转移物料到目标设备(通过 ROS 资源转移)
- **action_type**: `UniLabJsonCommandAsync`
- **Schema**: [`actions/transfer.json`](actions/transfer.json)
- **核心参数**: `resource`, `target_device`, `mount_resource`
- **占位符字段**:
- `resource`**ResourceSlot**,待转移的物料数组 `[{id, name, uuid}, ...]`
- `target_device`**DeviceSlot**,目标设备路径字符串
- `mount_resource`**ResourceSlot**,目标孔位数组 `[{id, name, uuid}, ...]`
---
## 人工确认
### `manual_confirm`
创建人工确认节点,等待用户手动确认后继续(含物料转移上下文)
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/manual_confirm.json`](actions/manual_confirm.json)
- **核心参数**: `resource`, `target_device`, `mount_resource`, `timeout_seconds`, `assignee_user_ids`
- **占位符字段**:
- `resource`**ResourceSlot**,物料数组
- `target_device`**DeviceSlot**,目标设备路径
- `mount_resource`**ResourceSlot**,目标孔位数组
- `assignee_user_ids``unilabos_manual_confirm` 类型

View File

@@ -1,270 +0,0 @@
{
"type": "UniLabJsonCommand",
"goal": {
"resource": "resource",
"target_device": "target_device",
"mount_resource": "mount_resource",
"timeout_seconds": "timeout_seconds",
"assignee_user_ids": "assignee_user_ids"
},
"schema": {
"type": "object",
"properties": {
"resource": {
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"sample_id": {
"type": "string"
},
"children": {
"type": "array",
"items": {
"type": "string"
}
},
"parent": {
"type": "string"
},
"type": {
"type": "string"
},
"category": {
"type": "string"
},
"pose": {
"type": "object",
"properties": {
"position": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z"
],
"title": "position",
"additionalProperties": false
},
"orientation": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"w": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z",
"w"
],
"title": "orientation",
"additionalProperties": false
}
},
"required": [
"position",
"orientation"
],
"title": "pose",
"additionalProperties": false
},
"config": {
"type": "string"
},
"data": {
"type": "string"
}
},
"title": "resource"
},
"type": "array"
},
"target_device": {
"type": "string",
"description": "device reference"
},
"mount_resource": {
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"sample_id": {
"type": "string"
},
"children": {
"type": "array",
"items": {
"type": "string"
}
},
"parent": {
"type": "string"
},
"type": {
"type": "string"
},
"category": {
"type": "string"
},
"pose": {
"type": "object",
"properties": {
"position": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z"
],
"title": "position",
"additionalProperties": false
},
"orientation": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"w": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z",
"w"
],
"title": "orientation",
"additionalProperties": false
}
},
"required": [
"position",
"orientation"
],
"title": "pose",
"additionalProperties": false
},
"config": {
"type": "string"
},
"data": {
"type": "string"
}
},
"title": "mount_resource"
},
"type": "array"
},
"timeout_seconds": {
"type": "integer"
},
"assignee_user_ids": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"resource",
"target_device",
"mount_resource",
"timeout_seconds",
"assignee_user_ids"
],
"_unilabos_placeholder_info": {
"resource": "unilabos_resources",
"target_device": "unilabos_devices",
"mount_resource": "unilabos_resources",
"assignee_user_ids": "unilabos_manual_confirm"
}
},
"goal_default": {},
"placeholder_keys": {
"resource": "unilabos_resources",
"target_device": "unilabos_devices",
"mount_resource": "unilabos_resources",
"assignee_user_ids": "unilabos_manual_confirm"
}
}

View File

@@ -1,19 +0,0 @@
{
"type": "UniLabJsonCommand",
"goal": {
"material_number": "material_number"
},
"schema": {
"type": "object",
"properties": {
"material_number": {
"type": "integer"
}
},
"required": [
"material_number"
]
},
"goal_default": {},
"placeholder_keys": {}
}

View File

@@ -1,24 +0,0 @@
{
"type": "UniLabJsonCommand",
"goal": {
"station_id": "station_id",
"material_number": "material_number"
},
"schema": {
"type": "object",
"properties": {
"station_id": {
"type": "integer"
},
"material_number": {
"type": "integer"
}
},
"required": [
"station_id",
"material_number"
]
},
"goal_default": {},
"placeholder_keys": {}
}

View File

@@ -1,20 +0,0 @@
{
"type": "UniLabJsonCommand",
"goal": {
"count": "count"
},
"schema": {
"type": "object",
"properties": {
"count": {
"type": "integer",
"default": 5
}
},
"required": []
},
"goal_default": {
"count": 5
},
"placeholder_keys": {}
}

View File

@@ -1,24 +0,0 @@
{
"type": "UniLabJsonCommand",
"goal": {
"station_id": "station_id",
"material_number": "material_number"
},
"schema": {
"type": "object",
"properties": {
"station_id": {
"type": "integer"
},
"material_number": {
"type": "integer"
}
},
"required": [
"station_id",
"material_number"
]
},
"goal_default": {},
"placeholder_keys": {}
}

View File

@@ -1,255 +0,0 @@
{
"type": "UniLabJsonCommandAsync",
"goal": {
"resource": "resource",
"target_device": "target_device",
"mount_resource": "mount_resource"
},
"schema": {
"type": "object",
"properties": {
"resource": {
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"sample_id": {
"type": "string"
},
"children": {
"type": "array",
"items": {
"type": "string"
}
},
"parent": {
"type": "string"
},
"type": {
"type": "string"
},
"category": {
"type": "string"
},
"pose": {
"type": "object",
"properties": {
"position": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z"
],
"title": "position",
"additionalProperties": false
},
"orientation": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"w": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z",
"w"
],
"title": "orientation",
"additionalProperties": false
}
},
"required": [
"position",
"orientation"
],
"title": "pose",
"additionalProperties": false
},
"config": {
"type": "string"
},
"data": {
"type": "string"
}
},
"title": "resource"
},
"type": "array"
},
"target_device": {
"type": "string",
"description": "device reference"
},
"mount_resource": {
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"sample_id": {
"type": "string"
},
"children": {
"type": "array",
"items": {
"type": "string"
}
},
"parent": {
"type": "string"
},
"type": {
"type": "string"
},
"category": {
"type": "string"
},
"pose": {
"type": "object",
"properties": {
"position": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z"
],
"title": "position",
"additionalProperties": false
},
"orientation": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"w": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z",
"w"
],
"title": "orientation",
"additionalProperties": false
}
},
"required": [
"position",
"orientation"
],
"title": "pose",
"additionalProperties": false
},
"config": {
"type": "string"
},
"data": {
"type": "string"
}
},
"title": "mount_resource"
},
"type": "array"
}
},
"required": [
"resource",
"target_device",
"mount_resource"
],
"_unilabos_placeholder_info": {
"resource": "unilabos_resources",
"target_device": "unilabos_devices",
"mount_resource": "unilabos_resources"
}
},
"goal_default": {},
"placeholder_keys": {
"resource": "unilabos_resources",
"target_device": "unilabos_devices",
"mount_resource": "unilabos_resources"
}
}

View File

@@ -1,188 +0,0 @@
# ============================================================
# Uni-Lab-OS Cursor Ignore 配置,控制 Cursor AI 的文件索引范围
# ============================================================
# ==================== 敏感配置文件 ====================
# 本地配置(可能包含密钥)
**/local_config.py
test_config.py
local_test*.py
# 环境变量和密钥
.env
.env.*
**/.certs/
*.pem
*.key
credentials.json
secrets.yaml
# ==================== 二进制和 3D 模型文件 ====================
# 3D 模型文件(无需索引)
*.stl
*.dae
*.glb
*.gltf
*.obj
*.fbx
*.blend
# URDF/Xacro 机器人描述文件大型XML
*.xacro
# 图片文件
*.png
*.jpg
*.jpeg
*.gif
*.webp
*.ico
*.svg
*.bmp
# 压缩包
*.zip
*.tar
*.tar.gz
*.tgz
*.bz2
*.rar
*.7z
# ==================== Python 生成文件 ====================
__pycache__/
*.py[cod]
*$py.class
*.so
*.pyd
*.egg
*.egg-info/
.eggs/
dist/
build/
*.manifest
*.spec
# ==================== IDE 和编辑器 ====================
.idea/
.vscode/
*.swp
*.swo
*~
.#*
# ==================== 测试和覆盖率 ====================
.pytest_cache/
.coverage
.coverage.*
htmlcov/
.tox/
.nox/
coverage.xml
*.cover
# ==================== 虚拟环境 ====================
.venv/
venv/
env/
ENV/
# ==================== ROS 2 生成文件 ====================
# ROS 构建目录
build/
install/
log/
logs/
devel/
# ROS 消息生成
msg_gen/
srv_gen/
msg/*Action.msg
msg/*ActionFeedback.msg
msg/*ActionGoal.msg
msg/*ActionResult.msg
msg/*Feedback.msg
msg/*Goal.msg
msg/*Result.msg
msg/_*.py
srv/_*.py
build_isolated/
devel_isolated/
# ROS 动态配置
*.cfgc
/cfg/cpp/
/cfg/*.py
# ==================== 项目特定目录 ====================
# 工作数据目录
unilabos_data/
# 临时和输出目录
temp/
output/
cursor_docs/
configs/
# 文档构建
docs/_build/
/site
# ==================== 大型数据文件 ====================
# 点云数据
*.pcd
# GraphML 图形文件
*.graphml
# 日志文件
*.log
# 数据库
*.sqlite3
*.db
# Jupyter 检查点
.ipynb_checkpoints/
# ==================== 设备网格资源 ====================
# 3D 网格文件目录(包含大量 STL/DAE 文件)
unilabos/device_mesh/devices/**/*.stl
unilabos/device_mesh/devices/**/*.dae
unilabos/device_mesh/resources/**/*.stl
unilabos/device_mesh/resources/**/*.glb
unilabos/device_mesh/resources/**/*.xacro
# RViz 配置
*.rviz
# ==================== 系统文件 ====================
.DS_Store
Thumbs.db
desktop.ini
# ==================== 锁文件 ====================
poetry.lock
Pipfile.lock
pdm.lock
package-lock.json
yarn.lock
# ==================== 类型检查缓存 ====================
.mypy_cache/
.dmypy.json
.pytype/
.pyre/
pyrightconfig.json
# ==================== 其他 ====================
# Catkin
CATKIN_IGNORE
# Eclipse/Qt
.project
.cproject
CMakeLists.txt.user
*.user
qtcreator-*

View File

@@ -1,11 +0,0 @@
## 设备接入
当被要求添加设备驱动时,参考 `docs/ai_guides/add_device.md`
该指南包含完整的模板和已有设备接口参考。
## 关键规则
- 动作方法的参数名是接口契约,不可重命名
- `status` 字符串必须与同类已有设备一致
- `self.data` 必须在 `__init__` 中预填充所有属性字段
- 异步方法中使用 `await self._ros_node.sleep()`,禁止 `time.sleep()`

View File

@@ -12,7 +12,7 @@ Uni-Lab 使用 Python 格式的配置文件(`.py`),默认为 `unilabos_dat
**获取方式:**
进入 [Uni-Lab 实验室](https://leap-lab.bohrium.com),点击左下角的头像,在实验室详情中获取所在实验室的 ak 和 sk
进入 [Uni-Lab 实验室](https://uni-lab.bohrium.com),点击左下角的头像,在实验室详情中获取所在实验室的 ak 和 sk
![copy_aksk.gif](image/copy_aksk.gif)
@@ -69,7 +69,7 @@ class WSConfig:
# HTTP配置
class HTTPConfig:
remote_addr = "https://leap-lab.bohrium.com/api/v1" # 远程服务器地址
remote_addr = "https://uni-lab.bohrium.com/api/v1" # 远程服务器地址
# ROS配置
class ROSConfig:
@@ -209,8 +209,8 @@ unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis -g grap
`--addr` 参数支持以下预设值,会自动转换为对应的完整 URL
- `test``https://leap-lab.test.bohrium.com/api/v1`
- `uat``https://leap-lab.uat.bohrium.com/api/v1`
- `test``https://uni-lab.test.bohrium.com/api/v1`
- `uat``https://uni-lab.uat.bohrium.com/api/v1`
- `local``http://127.0.0.1:48197/api/v1`
- 其他值 → 直接使用作为完整 URL
@@ -248,7 +248,7 @@ unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis -g grap
`ak``sk` 是必需的认证参数:
1. **获取方式**:在 [Uni-Lab 官网](https://leap-lab.bohrium.com) 注册实验室后获得
1. **获取方式**:在 [Uni-Lab 官网](https://uni-lab.bohrium.com) 注册实验室后获得
2. **配置方式**
- **命令行参数**`--ak "your_key" --sk "your_secret"`(最高优先级,推荐)
- **环境变量**`UNILABOS_BASICCONFIG_AK``UNILABOS_BASICCONFIG_SK`
@@ -275,15 +275,15 @@ WebSocket 是 Uni-Lab 的主要通信方式:
HTTP 客户端配置用于与云端服务通信:
| 参数 | 类型 | 默认值 | 说明 |
| ------------- | ---- | --------------------------------------- | ------------ |
| `remote_addr` | str | `"https://leap-lab.bohrium.com/api/v1"` | 远程服务地址 |
| 参数 | 类型 | 默认值 | 说明 |
| ------------- | ---- | -------------------------------------- | ------------ |
| `remote_addr` | str | `"https://uni-lab.bohrium.com/api/v1"` | 远程服务地址 |
**预设环境地址**
- 生产环境:`https://leap-lab.bohrium.com/api/v1`(默认)
- 测试环境:`https://leap-lab.test.bohrium.com/api/v1`
- UAT 环境:`https://leap-lab.uat.bohrium.com/api/v1`
- 生产环境:`https://uni-lab.bohrium.com/api/v1`(默认)
- 测试环境:`https://uni-lab.test.bohrium.com/api/v1`
- UAT 环境:`https://uni-lab.uat.bohrium.com/api/v1`
- 本地环境:`http://127.0.0.1:48197/api/v1`
### 4. ROSConfig - ROS 配置
@@ -401,7 +401,7 @@ export UNILABOS_WSCONFIG_RECONNECT_INTERVAL="10"
export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS="500"
# 设置HTTP配置
export UNILABOS_HTTPCONFIG_REMOTE_ADDR="https://leap-lab.test.bohrium.com/api/v1"
export UNILABOS_HTTPCONFIG_REMOTE_ADDR="https://uni-lab.test.bohrium.com/api/v1"
```
## 配置文件使用方法
@@ -484,13 +484,13 @@ export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS=100
```python
class HTTPConfig:
remote_addr = "https://leap-lab.test.bohrium.com/api/v1"
remote_addr = "https://uni-lab.test.bohrium.com/api/v1"
```
**环境变量方式:**
```bash
export UNILABOS_HTTPCONFIG_REMOTE_ADDR=https://leap-lab.test.bohrium.com/api/v1
export UNILABOS_HTTPCONFIG_REMOTE_ADDR=https://uni-lab.test.bohrium.com/api/v1
```
**命令行方式(推荐):**

File diff suppressed because it is too large Load Diff

View File

@@ -1,344 +0,0 @@
# Uni-Lab-OS 设备接入 Agent — 提示词模板
> 本文件提供一套可直接复制使用的 Agent 系统提示词,以及各平台的配置说明。
> 提示词模板与 `add_device.md`(领域知识)配合使用,前者控制 Agent 行为,后者提供完整的技术细节。
---
## 系统提示词模板
以下内容可直接作为系统提示词 / Instructions / Custom Instructions 使用。`{{...}}` 标记的变量根据平台替换。
---
### 开始复制 ↓
```
你是 Uni-Lab-OS 设备接入专家。你的任务是帮助用户将新的实验室硬件设备接入 Uni-Lab-OS 系统。
你能做的事:
- 根据用户描述生成完整的设备驱动代码Python、注册表YAML和实验图文件JSON
- 解读用户提供的通信协议文档、SDK 代码、或口述的指令格式
- 诊断已有驱动代码的接口对齐问题
你不能做的事:
- 凭空猜测硬件私有通信指令(必须从用户提供的资料中获取)
- 替代真实硬件联调测试
## 知识来源
{{KNOWLEDGE_LOADING}}
## 工作流程
当用户要求接入新设备时,严格按以下流程执行。每个暂停点必须等待用户确认后再继续。
### 阶段 1设备画像交互
向用户收集以下三个信息,可以一次性提问:
1. **设备类别** — 属于以下哪一种?
- temperature温控、pump_and_valve泵阀、motor电机
- heaterstirrer加热搅拌、balance天平、sensor传感器
- liquid_handling液体处理、robot_arm机械臂、workstation工作站
- virtual虚拟设备、custom自定义
- 如果是 pump_and_valve进一步确认子类型注射泵 / 电磁阀 / 蠕动泵
2. **设备英文名称** — 用于文件名和类名(如 my_heater、runze_sy03b
3. **通信协议** — Serial(RS232/RS485) / Modbus RTU / Modbus TCP / TCP Socket / HTTP API / OPC UA / 无通信(虚拟)
⏸️ **暂停:等待用户回答后继续**
### 阶段 2指令协议收集交互
根据上一步确定的通信协议,引导用户提供指令信息:
- 如果用户有 **SDK/驱动代码**:请用户提供代码文件,你从中提取通信逻辑
- 如果用户有 **协议文档**请用户提供文档PDF/图片/文本),你从中解析指令格式
- 如果用户 **口头描述**:针对每个标准动作逐一确认硬件指令
- 如果是 **标准协议**Modbus 寄存器表、SCPI请用户提供寄存器/指令映射
- 如果是 **虚拟设备**:跳过此阶段
⏸️ **暂停:确认已获取足够的指令协议信息**
### 阶段 3确认摘要
在开始生成代码前,向用户展示你的理解摘要:
```
设备接入摘要:
- 设备名称:<name>
- 设备类别:<category><subtype>
- 通信协议:<protocol>
- 指令来源:<source>
- 将要实现的属性:<list>
- 将要实现的动作:<list>
- 同类已有设备:<existing>(将对齐其接口)
```
⏸️ **暂停:用户确认"没问题"后再生成代码**
### 阶段 4自动生成无需暂停
按以下顺序自动执行:
1. **对齐同类设备接口**(指南第四步)
- 查阅指南中的「现有设备接口快照」或搜索仓库注册表
- 确保所有已有设备的 status_types 和动作方法都被覆盖
- 参数名必须完全一致
2. **生成驱动代码** — `unilabos/devices/<category>/<name>.py`
3. **生成注册表** — `unilabos/registry/devices/<name>.yaml`(最小配置)
4. **生成图文件** — `unilabos/test/experiments/graph_example_<name>.json`
### 阶段 5验证输出
生成完成后,逐项检查对齐验证清单并展示结果:
```
对齐验证清单:
- [x] 所有动作方法的参数名与已有设备完全一致
- [x] status 属性返回的字符串值与已有设备一致
- [x] 已有设备的所有 status_types 字段都有对应 @property
- [x] 已有设备的所有非 auto- 前缀的 action 都有对应方法
- [x] self.data 在 __init__ 中已预填充所有属性字段的默认值
- [x] 串口/二进制协议的响应解析先定位帧起始标记
```
如果有未通过的项,主动修复后再展示。
## 硬约束(违反任何一条都会导致设备接入失败)
1. **禁止重命名参数** — 动作方法的参数名(如 volume、position、max_velocity是接口契约框架通过参数名分派调用。绝不能加后缀如 volume_ml、改名如 speed_ml_s。单位写在 docstring 中。
2. **status 字符串必须一致** — 如果同类已有设备用英文(如 "Idle" / "Busy"),新驱动必须用相同的字符串,不能改为中文(如 "就绪")。
3. **self.data 必须预填充** — 不能用空字典 {}。框架在 initialize() 之前就可能读取属性值。每个 @property 对应的键都必须在 __init__ 中有初始值。
4. **禁止跳过接口对齐** — 对齐同类设备接口是强制步骤。缺失的属性和动作会导致设备在工作流中不可互换。
5. **串口解析先找帧头** — RS-485 总线上响应前常有回声/噪声字节。必须先定位帧起始标记(如 /、0xFE禁止用硬编码索引直接解析。
6. **异步等待用 _ros_node.sleep** — 在 async 方法中使用 await self._ros_node.sleep(),禁止 time.sleep()(阻塞事件循环)和 asyncio.sleep()。
7. **物理单位对外暴露** — 对外参数使用用户友好的物理单位mL、°C、RPM驱动内部负责转换到硬件原始值步数、Hz、寄存器值
## 代码骨架参考
所有设备驱动遵循以下结构:
```python
import logging
import time as time_module
from typing import Dict, Any
try:
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
except ImportError:
BaseROS2DeviceNode = None
class MyDevice:
_ros_node: "BaseROS2DeviceNode"
def __init__(self, device_id: str = None, config: 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_device"
self.config = config or {}
self.logger = logging.getLogger(f"MyDevice.{self.device_id}")
self.data = {
"status": "Idle",
# 所有 @property 的键都必须在此预填充
}
def post_init(self, ros_node: "BaseROS2DeviceNode"):
self._ros_node = ros_node
async def initialize(self) -> bool:
self.data["status"] = "Idle"
return True
async def cleanup(self) -> bool:
self.data["status"] = "Offline"
return True
@property
def status(self) -> str:
return self.data.get("status", "Idle")
```
## 注册表最小配置
```yaml
my_device:
class:
module: unilabos.devices.<category>.<file>:MyDevice
type: python
```
启动时 --complete_registry 自动生成 status_types 和 action_value_mappings。
## 图文件模板
```json
{
"nodes": [
{
"id": "my_device_1",
"name": "设备名称",
"children": [],
"parent": null,
"type": "device",
"class": "my_device",
"position": {"x": 0, "y": 0, "z": 0},
"config": {},
"data": {}
}
]
}
```
## 现有设备接口快照(对齐用)
对齐时参考以下已有设备接口。如果能联网,优先从 GitHub 获取最新版本:
https://github.com/dptech-corp/Uni-Lab-OS/tree/main/unilabos/registry/devices/
### pump_and_valve — 注射泵
已有设备syringe_pump_with_valve.runze.SY03B-T06
属性status(str, "Idle"/"Busy"), valve_position(str), position(float, mL), max_velocity(float, mL/s), mode(int), plunger_position(String), velocity_grade(String), velocity_init(String), velocity_end(String)
方法签名(参数名不可改):
- initialize()
- set_valve_position(position)
- set_position(position: float, max_velocity: float = None)
- pull_plunger(volume: float)
- push_plunger(volume: float)
- set_max_velocity(velocity: float)
- set_velocity_grade(velocity)
- stop_operation()
### pump_and_valve — 电磁阀
属性status(str), valve_position(str)
方法open(), close(), set_valve_position(position), is_open(), is_closed()
### temperature
属性status(str), temp(float, °C), temp_target(float, °C), stir_speed(float, RPM), temp_warning(float, °C)
### motor
属性status(str), position(int)
### sensor
属性level(bool), rssi(int)
```
### 结束复制 ↑
---
## `{{KNOWLEDGE_LOADING}}` 变量替换
根据平台能力,将提示词中的 `{{KNOWLEDGE_LOADING}}` 替换为以下对应内容:
### 方案 A有知识库Custom GPT / Claude Project
```
你的知识库中包含 add_device.md 文件,这是完整的设备接入指南。
执行工作流时,参考该文件获取物模型模板、通信协议代码片段、指令协议模式和常见错误检查清单。
本提示词中的「现有设备接口快照」和「硬约束」是从指南中提炼的关键内容,以确保即使知识库检索不完整也能正确工作。
```
### 方案 B有联网能力
```
执行工作流前,从以下 URL 获取完整的设备接入指南:
https://raw.githubusercontent.com/dptech-corp/Uni-Lab-OS/main/docs/ai_guides/add_device.md
该指南包含物模型模板、通信协议代码片段、指令协议模式和常见错误检查清单。
如果无法访问 URL使用本提示词中内联的「现有设备接口快照」和「代码骨架参考」作为兜底。
```
### 方案 C无知识库、无联网
```
完整的设备接入指南需要用户在对话中提供。
如果用户未主动提供,请在阶段 1 开始前询问:
"请将 add_device.md 的内容粘贴到对话中,或上传该文件。如果没有该文件,我将使用内置的精简规则工作。"
本提示词已内联了最关键的内容(硬约束 + 代码骨架 + 接口快照),足以生成基本正确的驱动。
但完整指南包含更多物模型模板和通信协议代码片段,能显著提升生成质量。
```
---
## 各平台配置指南
### OpenAI Custom GPT
1. 进入 https://chat.openai.com/gpts/editor
2. **Name**Uni-Lab-OS 设备接入助手
3. **Description**:帮助用户将实验室硬件设备接入 Uni-Lab-OS 系统,自动生成驱动代码、注册表和图文件。
4. **Instructions**:粘贴上方系统提示词,`{{KNOWLEDGE_LOADING}}` 替换为方案 A
5. **Knowledge**:上传 `docs/ai_guides/add_device.md`
6. **Capabilities**:开启 Code Interpreter用于代码验证
7. **Conversation starters**
- "我要接入一个新的注射泵"
- "帮我把这个 SDK 包装成 UniLab 驱动"
- "检查我的设备驱动有没有接口问题"
### Claude Project
1. 创建新 Project
2. **Custom Instructions**:粘贴系统提示词,`{{KNOWLEDGE_LOADING}}` 替换为方案 A
3. **Project Knowledge**:上传 `docs/ai_guides/add_device.md`
### API AgentLangChain / AutoGen / 自建框架)
```python
system_prompt = """
<粘贴完整系统提示词,{{KNOWLEDGE_LOADING}} 替换为方案 B>
"""
# 如果框架支持工具调用,可注册以下工具:
tools = [
{
"name": "fetch_device_guide",
"description": "获取最新的 Uni-Lab-OS 设备接入指南",
"url": "https://raw.githubusercontent.com/dptech-corp/Uni-Lab-OS/main/docs/ai_guides/add_device.md"
},
{
"name": "fetch_registry",
"description": "获取最新的设备注册表",
"url": "https://raw.githubusercontent.com/dptech-corp/Uni-Lab-OS/main/unilabos/registry/devices/{category}.yaml"
},
]
```
### Cursor Agent Mode
无需使用本模板。Cursor 中使用已有的 `.cursor/skills/add-device/SKILL.md`,它会自动读取 `docs/ai_guides/add_device.md` 并利用 Cursor 的工具能力Grep 搜索注册表、AskQuestion 收集信息等)。
### 纯网页对话ChatGPT / Claude 无 Project
1. 第一条消息粘贴系统提示词(`{{KNOWLEDGE_LOADING}}` 替换为方案 C
2. 第二条消息上传或粘贴 `add_device.md`
3. 第三条消息开始描述设备
---
## 维护说明
- **硬约束更新**:如果 `add_device.md` 中新增了禁止事项或常见错误,需要同步更新本模板的「硬约束」部分
- **接口快照更新**:新增设备类别或已有设备接口变更时,需要同步更新本模板的「现有设备接口快照」部分
- **工作流调整**:如果接入流程发生变化(新增步骤、合并步骤),需要同步调整「工作流程」部分
- 本模板与 `add_device.md` 是**互补关系**:模板定义 Agent 行为,指南提供领域知识。两者独立维护

View File

@@ -18,15 +18,13 @@ Uni-Lab 开发团队在仓库中提供了 3 个样例:
- 单一机械设备**电夹爪**,通讯协议可见 [增广夹爪通讯协议](https://doc.rmaxis.com/docs/communication/fieldbus/),驱动代码位于 `unilabos/devices/gripper/rmaxis_v4.py`
- 单一通信设备**IO 板卡**,驱动代码位于 `unilabos/device_comms/gripper/SRND_16_IO.py`
- 执行多设备复杂任务逻辑的**PLC**Uni-Lab 提供了基于地址表的接入方式和点动工作流编写,测试代码位于 `unilabos/device_comms/modbus_plc/test/test_workflow.py`。详细框架说明请参考 {doc}`plc_framework`
- 执行多设备复杂任务逻辑的**PLC**Uni-Lab 提供了基于地址表的接入方式和点动工作流编写,测试代码位于 `unilabos/device_comms/modbus_plc/test/test_workflow.py`
---
## 其他工业通信协议CANopen, Ethernet, OPCUA...
Uni-Lab 已实现基于 OPC UA 协议的 PLC 接管框架,用于后处理工站等项目。与 Modbus 框架相比OPC UA 框架额外提供了自动节点发现、订阅推送、断线重连等特性。详细说明请参考 {doc}`plc_framework`
其他协议CANopen、EtherCAT 等)【敬请期待】
【敬请期待】
## 没有接口的老设备老软件:使用 PyWinAuto

View File

@@ -23,7 +23,7 @@ Uni-Lab-OS 支持多种部署模式:
```
┌──────────────────────────────────────────────┐
│ Cloud Platform/Self-hosted Platform │
leap-lab.bohrium.com │
uni-lab.bohrium.com │
│ (Resource Management, Task Scheduling, │
│ Monitoring) │
└────────────────────┬─────────────────────────┘
@@ -444,7 +444,7 @@ ros2 daemon stop && ros2 daemon start
```bash
# 测试云端连接
curl https://leap-lab.bohrium.com/api/v1/health
curl https://uni-lab.bohrium.com/api/v1/health
# 测试WebSocket
# 启动Uni-Lab后查看日志

View File

@@ -1,281 +0,0 @@
# PLC 设备接管框架
> 本文档面向初次接触 UniLab-OS 的开发者,介绍系统如何通过工业协议"接管"连接并控制PLC 设备。
## 什么是"PLC 接管"
**PLC**可编程逻辑控制器是工厂设备的控制核心驱动机械臂、泵、阀门等硬件。UniLab-OS 通过网络协议直接读写 PLC 内部变量,从而控制设备运行:
```
UniLab-OSPython ←通信协议→ PLC ←电信号→ 电机/气缸/传感器
```
UniLab-OS 提供两套接管框架,对应两种工业协议:
| 框架 | 协议 | 应用项目 | 核心文件 |
| --------------- | ---------------- | ------------------ | ----------------------------------------------------------- |
| **Modbus 框架** | Modbus TCP / RTU | 扣式电池装配工站 | `unilabos/device_comms/modbus_plc/client.py` |
| **OPC UA 框架** | OPC UA | 后处理工站(怀柔) | `unilabos/devices/workstation/post_process/post_process.py` |
两套框架**设计思想完全一致**,底层通信协议不同。理解一个,另一个基本触类旁通。
---
## 核心概念
### 节点Node
节点是 PLC 内部一个具体的变量地址,可以理解为 PLC 的一个输入/输出端口。
| 属性 | 说明 | 示例 |
| ---- | -------------------------------------- | -------------------- |
| 名称 | 人类可读标识 | `COIL_SYS_START_CMD` |
| 地址 | PLC 内存地址 | `0x0064` |
| 类型 | Coil / HoldRegister / InputRegister 等 | `coil` |
```
PLC 内存空间
├── Coil 区: True / False ← 控制开关量(启动/停止/复位)
├── Hold Reg: 120, 35.5 … ← 存参数值(速度、位置)
└── Input Reg: 99.8, 42 … ← 只读传感器数据
```
### 动作生命周期Action Lifecycle
每个设备动作被拆分为 4 个阶段,用 `try/finally` 保证安全性:
```python
try:
init(...) # 写入参数(速度、位置等)— 可选
start(...) # 发触发信号 + 轮询等待完成
stop(...) # 复位触发信号(成功时执行)
except:
is_err = True
finally:
cleanup(...) # 无论成败都执行,作为安全兜底
```
| 阶段 | 何时执行 | 典型内容 |
| --------- | ----------------------- | ------------------------------------ |
| `init` | 成功路径(可选) | 写运动速度 = 20.0 |
| `start` | 成功路径 | 写启动位 = True等待完成位 = True |
| `stop` | 成功路径 | 写启动位 = False正常复位 |
| `cleanup` | **无论成败**finally | 安全兜底复位,防止异常时设备持续运动 |
> **为什么 `cleanup` 无论成败都执行?**
> 若 `start` 阶段因传感器故障抛出异常,`stop` 会被跳过PLC 触发位仍为 `True`——设备可能持续运动。`cleanup` 放在 `finally` 块中,作为最后的安全保障,确保 PLC 一定被复位到安全状态。实际上大多数动作将 `cleanup` 设为 `null`,由 `stop` 负责正常复位即可。
---
## Modbus 框架
**核心文件**`unilabos/device_comms/modbus_plc/client.py`
**参考实现**`unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py`
### 连接与节点注册
```python
from unilabos.device_comms.modbus_plc.client import TCPClient, BaseClient
# 1. 建立 TCP 连接
client = TCPClient(addr="172.16.28.102", port=502)
client.client.connect()
# 2. 从 CSV 加载节点定义
nodes = BaseClient.load_csv("coin_cell_assembly_b.csv")
# 3. 注册节点,之后可按名称访问
client.register_node_list(nodes)
# 访问节点
client.use_node('COIL_SYS_START_CMD').write(True)
value, err = client.use_node('COIL_SYS_START_STATUS').read(1)
```
**CSV 格式**`Name` / `DeviceType` / `Address` / `DataType`
| Name | DeviceType | Address | DataType |
| ------------------ | ------------- | ------- | -------- |
| COIL_SYS_START_CMD | coil | 100 | INT16 |
| REG_SPEED | hold_register | 200 | FLOAT32 |
### 三段式接管流程(扣式电池工站)
PLC 设备通常需要按固定顺序切换模式,以扣式电池工站为例:
```
Python PLC
│── 写 HAND_CMD = True ─────────>│ 切换到手动模式
│<─ 读 HAND_STATUS = True ────────│ 确认进入手动
│── 写 INIT_CMD = True ──────────>│ 执行初始化
│<─ 读 INIT_STATUS = True ─────────│ 初始化完成
│── 写 HAND_CMD = False ──────────>│ 复位(脉冲信号)
│── 写 INIT_CMD = False ──────────>│ 复位
│── 写 AUTO_CMD = True ───────────>│ 切换自动模式
│<─ 读 AUTO_STATUS = True ─────────│ 自动模式就绪
│── 写 AUTO_CMD = False ──────────>│ 复位
│── 写 START_CMD = True ──────────>│ 开始运行
│<─ 读 START_STATUS = True ────────│ 运行确认
│── 写 START_CMD = False ──────────>│ 复位
```
> **脉冲信号模式**:命令写 `True` → 等待 PLC 状态位确认 → 命令写回 `False`,这是大多数 PLC 的标准触发方式,而不是保持高电平。
### JSON 配置方式
Modbus 框架支持纯 JSON 配置,无需手写 Python 流程:
```json
{
"register_node_list_from_csv_path": {"path": "M01.csv"},
"create_flow": [
{
"name": "归位",
"action": [{
"address_function_to_create": [
{"func_name": "pos_tip", "node_name": "M01_idlepos_coil_w", "mode": "write", "value": true},
{"func_name": "pos_tip_read", "node_name": "M01_idlepos_coil_r", "mode": "read", "value": 1},
{"func_name": "manual_stop", "node_name": "M01_manual_stop_coil_r","mode": "read", "value": 1}
],
"create_init_function": {"func_name": "idel_init", "node_name": "M01_idlepos_velocity_rw", "mode": "write", "value": 20.0},
"create_start_function": {
"func_name": "idel_position",
"write_functions": ["pos_tip"],
"condition_functions": ["pos_tip_read", "manual_stop"],
"stop_condition_expression": "pos_tip_read[0] and manual_stop[0]"
},
"create_stop_function": {"func_name": "idel_stop", "node_name": "M01_idlepos_coil_w", "mode": "write", "value": false},
"create_cleanup_function": null
}]
}
],
"execute_flow": ["归位"]
}
```
执行:
```python
client.execute_procedure_from_json(json_data)
```
---
## OPC UA 框架
**核心文件**`unilabos/devices/workstation/post_process/post_process.py`
**参考实现**`unilabos/devices/workstation/post_process/opcua_huairou.json`
### 与 Modbus 的主要区别
| 特性 | Modbus | OPC UA |
| ---------- | -------------------- | --------------------------------- |
| 节点发现 | 手动填写 CSV 地址 | **自动遍历**服务器节点树 |
| 数据获取 | 轮询(主动问) | **订阅推送**(有变化时通知) |
| 节点标识 | 数字地址(如 `100` | 字符串 NodeId`ns=2;s=速度` |
| 断线处理 | 无 | **后台监控线程**自动重连 |
| 认证安全 | 无 | 支持用户名/密码 |
| 工作流调用 | 手动调用 | **自动注册为实例方法** |
### 连接与节点发现
```python
from unilabos.devices.workstation.post_process.post_process import OpcUaClient
client = OpcUaClient(
url="opc.tcp://192.168.1.100:4840",
username="admin", # 可选
password="123456", # 可选
config_path="opcua_huairou.json", # 自动加载工作流配置
cache_timeout=5.0, # 节点值缓存 5 秒
subscription_interval=500, # 每 500ms 接收推送
)
# 节点自动通过订阅保持最新值,读取直接查本地缓存
value = client.get_node_value("grab_complete")
```
### JSON 配置方式
```json
{
"register_node_list_from_csv_path": {"path": "opcua_nodes_huairou.csv"},
"create_flow": [
{
"name": "trigger_grab_action",
"description": "触发反应罐及原料罐抓取动作",
"parameters": ["reaction_tank_number", "raw_tank_number"],
"action": [{
"init_function": {
"func_name": "init_grab_params",
"write_nodes": ["reaction_tank_number", "raw_tank_number"]
},
"start_function": {
"func_name": "start_grab",
"write_nodes": {"grab_trigger": true},
"condition_nodes": ["grab_complete"],
"stop_condition_expression": "grab_complete == True",
"timeout_seconds": 999999.0
},
"stop_function": {
"func_name": "stop_grab",
"write_nodes": {"grab_trigger": false}
}
}]
}
]
}
```
配置加载后,工作流自动注册为实例方法:
```python
# 直接调用,传入参数,框架自动写入对应节点
client.trigger_grab_action(reaction_tank_number=2, raw_tank_number=3)
```
---
## 新增设备快速上手
### 使用 Modbus 框架
```
1. 从 PLC 工程师处拿到地址表,按格式填写 CSVName/DeviceType/Address/DataType
2. 继承 BaseClient在 __init__ 中连接并注册节点
3. 参考 coin_cell_assembly.py 编写三段式接管函数(手动→初始化→自动→启动)
4. 或直接编写 JSON 配置,调用 execute_procedure_from_json()
```
### 使用 OPC UA 框架
```
1. 确认设备支持 OPC UA拿到服务器 URL格式opc.tcp://IP:PORT
2. 准备 CSV 节点定义文件(可选,也可让框架自动发现)
3. 编写 JSON 配置:定义 parameters、init/start/stop 函数
4. 实例化 OpcUaClient传入 config_path直接调用自动注册的工作流方法
```
---
## 常见问题
**Q: `node {name} is not registered` 报错?**
A: 节点名不在 CSV 或未调用 `register_node_list_from_csv_path()`
**Q: 程序卡死在 `while not status(): sleep(1)`**
A: PLC 未返回预期完成信号。检查PLC 是否在正确运行模式、状态位地址是否正确、PLC 有无报警。
**Q: OPC UA 连接成功但读不到节点?**
A: 检查节点名称是否与服务器显示名一致(区分中英文)。可调用 `_find_nodes()` 打印服务器全部节点。
**Q: 应该选 Modbus 还是 OPC UA**
A: 取决于设备支持的协议,由 PLC 工程师决定。OPC UA 功能更完整,条件允许优先选择。
---
## 下一步
- {doc}`add_device` - 将驱动集成进 UniLab-OS 设备节点
- {doc}`add_action` - 为设备添加可调度的动作指令
- {doc}`add_yaml` - 编写设备注册表 YAML 文件

View File

@@ -17,9 +17,6 @@ developer_guide/http_api.md
developer_guide/networking_overview.md
developer_guide/add_device.md
developer_guide/add_action.md
developer_guide/add_old_device.md
developer_guide/plc_framework.md
developer_guide/add_protocol.md
developer_guide/add_registry.md
developer_guide/add_yaml.md
developer_guide/action_includes.md

View File

@@ -33,11 +33,11 @@
**选择合适的安装包:**
| 安装包 | 适用场景 | 包含组件 |
| --------------- | ---------------------------- | --------------------------------------------- |
| `unilabos` | **推荐大多数用户**,生产部署 | 完整安装包,开箱即用 |
| `unilabos-env` | 开发者(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos |
| `unilabos-full` | 仿真/可视化 | unilabos + 完整 ROS2 桌面版 + Gazebo + MoveIt |
| 安装包 | 适用场景 | 包含组件 |
|--------|----------|----------|
| `unilabos` | **推荐大多数用户**,生产部署 | 完整安装包,开箱即用 |
| `unilabos-env` | 开发者(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos |
| `unilabos-full` | 仿真/可视化 | unilabos + 完整 ROS2 桌面版 + Gazebo + MoveIt |
**关键步骤:**
@@ -66,7 +66,6 @@ mamba install uni-lab::unilabos-full -c robostack-staging -c conda-forge
```
**选择建议:**
- **日常使用/生产部署**:使用 `unilabos`(推荐),完整功能,开箱即用
- **开发者**:使用 `unilabos-env` + `pip install -e .` + `uv pip install -r unilabos/utils/requirements.txt`,代码修改立即生效
- **仿真/可视化**:使用 `unilabos-full`,含 Gazebo、rviz2、MoveIt
@@ -89,7 +88,7 @@ python -c "from unilabos_msgs.msg import Resource; print('ROS msgs OK')"
#### 2.1 注册实验室账号
1. 访问 [https://leap-lab.bohrium.com](https://leap-lab.bohrium.com)
1. 访问 [https://uni-lab.bohrium.com](https://uni-lab.bohrium.com)
2. 注册账号并登录
3. 创建新实验室
@@ -298,7 +297,7 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
#### 5.2 访问 Web 界面
启动系统后,访问[https://leap-lab.bohrium.com](https://leap-lab.bohrium.com)
启动系统后,访问[https://uni-lab.bohrium.com](https://uni-lab.bohrium.com)
#### 5.3 添加设备和物料
@@ -307,10 +306,12 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
**示例场景:** 创建一个简单的液体转移实验
1. **添加工作站(必需):**
- 在"仪器设备"中找到 `work_station`
- 添加 `workstation` x1
2. **添加虚拟转移泵:**
- 在"仪器设备"中找到 `virtual_device`
- 添加 `virtual_transfer_pump` x1
@@ -817,7 +818,6 @@ uv pip install -r unilabos/utils/requirements.txt
```
**为什么使用这种方式?**
- `unilabos-env` 提供 ROS2 核心组件和 uv通过 conda 安装,避免编译)
- `unilabos/utils/requirements.txt` 包含所有运行时需要的 pip 依赖
- `dev_install.py` 自动检测中文环境,中文系统自动使用清华镜像
@@ -1796,27 +1796,32 @@ unilab --ak your_ak --sk your_sk -g graph.json \
**详细步骤:**
1. **需求分析**
- 明确实验流程
- 列出所需设备和物料
- 设计工作流程图
2. **环境搭建**
- 安装 Uni-Lab-OS
- 创建实验室账号
- 准备开发工具IDE、Git
3. **原型验证**
- 使用虚拟设备测试流程
- 验证工作流逻辑
- 调整参数
4. **迭代开发**
- 实现自定义设备驱动(同时撰写单点函数测试)
- 编写注册表
- 单元测试
- 集成测试
5. **测试部署**
- 连接真实硬件
- 空跑测试
- 小规模试验
@@ -1866,7 +1871,7 @@ unilab --ak your_ak --sk your_sk -g graph.json \
#### 14.5 社区支持
- **GitHub Issues**[https://github.com/deepmodeling/Uni-Lab-OS/issues](https://github.com/deepmodeling/Uni-Lab-OS/issues)
- **官方网站**[https://leap-lab.bohrium.com](https://leap-lab.bohrium.com)
- **官方网站**[https://uni-lab.bohrium.com](https://uni-lab.bohrium.com)
---

View File

@@ -626,7 +626,7 @@ unilab
**云端图文件管理**:
1. 登录 https://leap-lab.bohrium.com
1. 登录 https://uni-lab.bohrium.com
2. 进入"设备配置"
3. 创建或编辑配置
4. 保存到云端

View File

@@ -54,6 +54,7 @@ Uni-Lab 的启动过程分为以下几个阶段:
您可以直接跟随 unilabos 的提示进行,无需查阅本节
- **工作目录设置**
- 如果当前目录以 `unilabos_data` 结尾,则使用当前目录
- 否则使用 `当前目录/unilabos_data` 作为工作目录
- 可通过 `--working_dir` 指定自定义工作目录
@@ -67,8 +68,8 @@ Uni-Lab 的启动过程分为以下几个阶段:
支持多种后端环境:
- `--addr test`:测试环境 (`https://leap-lab.test.bohrium.com/api/v1`)
- `--addr uat`UAT 环境 (`https://leap-lab.uat.bohrium.com/api/v1`)
- `--addr test`:测试环境 (`https://uni-lab.test.bohrium.com/api/v1`)
- `--addr uat`UAT 环境 (`https://uni-lab.uat.bohrium.com/api/v1`)
- `--addr local`:本地环境 (`http://127.0.0.1:48197/api/v1`)
- 自定义地址:直接指定完整 URL
@@ -175,7 +176,7 @@ unilab --config path/to/your/config.py
如果是首次使用,系统会:
1. 提示前往 https://leap-lab.bohrium.com 注册实验室
1. 提示前往 https://uni-lab.bohrium.com 注册实验室
2. 引导创建配置文件
3. 设置工作目录
@@ -215,7 +216,7 @@ unilab --ak your_ak --sk your_sk --port 8080 --disable_browser
如果提示 "后续运行必须拥有一个实验室",请确保:
- 已在 https://leap-lab.bohrium.com 注册实验室
- 已在 https://uni-lab.bohrium.com 注册实验室
- 正确设置了 `--ak``--sk` 参数
- 配置文件中包含正确的认证信息

View File

@@ -1,6 +1,6 @@
package:
name: ros-humble-unilabos-msgs
version: 0.11.1
version: 0.10.19
source:
path: ../../unilabos_msgs
target_directory: src

View File

@@ -1,6 +1,6 @@
package:
name: unilabos
version: "0.11.1"
version: "0.10.19"
source:
path: ../..

View File

@@ -4,7 +4,7 @@ package_name = 'unilabos'
setup(
name=package_name,
version='0.11.1',
version='0.10.19',
packages=find_packages(),
include_package_data=True,
install_requires=['setuptools'],

View File

@@ -1 +1 @@
__version__ = "0.11.1"
__version__ = "0.10.19"

View File

@@ -12,15 +12,6 @@ from typing import Dict, Any, List
import networkx as nx
import yaml
# Windows 中文系统 stdout 默认 GBK无法编码 banner / emoji 日志中的 Unicode 字符
# 强制 stdout/stderr 用 UTF-8避免 print 触发 UnicodeEncodeError 导致进程崩溃
if sys.platform == "win32":
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
except (AttributeError, OSError):
pass
# 首先添加项目根目录到路径
current_dir = os.path.dirname(os.path.abspath(__file__))
unilabos_dir = os.path.dirname(os.path.dirname(current_dir))
@@ -242,7 +233,7 @@ def parse_args():
parser.add_argument(
"--addr",
type=str,
default="https://leap-lab.bohrium.com/api/v1",
default="https://uni-lab.bohrium.com/api/v1",
help="Laboratory backend address",
)
parser.add_argument(
@@ -447,10 +438,10 @@ def main():
if args.addr != parser.get_default("addr"):
if args.addr == "test":
print_status("使用测试环境地址", "info")
HTTPConfig.remote_addr = "https://leap-lab.test.bohrium.com/api/v1"
HTTPConfig.remote_addr = "https://uni-lab.test.bohrium.com/api/v1"
elif args.addr == "uat":
print_status("使用uat环境地址", "info")
HTTPConfig.remote_addr = "https://leap-lab.uat.bohrium.com/api/v1"
HTTPConfig.remote_addr = "https://uni-lab.uat.bohrium.com/api/v1"
elif args.addr == "local":
print_status("使用本地环境地址", "info")
HTTPConfig.remote_addr = "http://127.0.0.1:48197/api/v1"
@@ -562,7 +553,7 @@ def main():
os._exit(0)
if not BasicConfig.ak or not BasicConfig.sk:
print_status("后续运行必须拥有一个实验室,请前往 https://leap-lab.bohrium.com 注册实验室!", "warning")
print_status("后续运行必须拥有一个实验室,请前往 https://uni-lab.bohrium.com 注册实验室!", "warning")
os._exit(1)
graph: nx.Graph
resource_tree_set: ResourceTreeSet

View File

@@ -36,9 +36,6 @@ class HTTPClient:
auth_secret = BasicConfig.auth_secret()
self.auth = auth_secret
info(f"正在使用ak sk作为授权信息[{auth_secret}]")
# 复用 TCP/TLS 连接,避免每次请求重新握手
self._session = requests.Session()
self._session.headers.update({"Authorization": f"Lab {self.auth}"})
info(f"HTTPClient 初始化完成: remote_addr={self.remote_addr}")
def resource_edge_add(self, resources: List[Dict[str, Any]]) -> requests.Response:
@@ -51,7 +48,7 @@ class HTTPClient:
Returns:
Response: API响应对象
"""
response = self._session.post(
response = requests.post(
f"{self.remote_addr}/edge/material/edge",
json={
"edges": resources,
@@ -78,28 +75,26 @@ class HTTPClient:
Returns:
Dict[str, str]: 旧UUID到新UUID的映射关系 {old_uuid: new_uuid}
"""
# dump() 只调用一次,复用给文件保存和 HTTP 请求
nodes_info = [x for xs in resources.dump() for x in xs]
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_add.json"), "w", encoding="utf-8") as f:
payload = {"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid}
f.write(json.dumps(payload, indent=4))
# 从序列化数据中提取所有节点的UUID保存旧UUID
old_uuids = {n.res_content.uuid: n for n in resources.all_nodes}
payload = {"nodes": nodes_info, "mount_uuid": mount_uuid}
body_bytes = _fast_dumps(payload)
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_add.json"), "wb") as f:
f.write(_fast_dumps_pretty(payload))
http_headers = {"Content-Type": "application/json"}
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 = self._session.post(
response = requests.post(
f"{self.remote_addr}/edge/material",
data=body_bytes,
headers=http_headers,
json={"nodes": nodes_info, "mount_uuid": mount_uuid},
headers={"Authorization": f"Lab {self.auth}"},
timeout=60,
)
else:
response = self._session.put(
response = requests.put(
f"{self.remote_addr}/edge/material",
data=body_bytes,
headers=http_headers,
json={"nodes": nodes_info, "mount_uuid": mount_uuid},
headers={"Authorization": f"Lab {self.auth}"},
timeout=10,
)
@@ -138,7 +133,7 @@ class HTTPClient:
"""
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_get.json"), "w", encoding="utf-8") as f:
f.write(json.dumps({"uuids": uuid_list, "with_children": with_children}, indent=4))
response = self._session.post(
response = requests.post(
f"{self.remote_addr}/edge/material/query",
json={"uuids": uuid_list, "with_children": with_children},
headers={"Authorization": f"Lab {self.auth}"},
@@ -152,7 +147,6 @@ class HTTPClient:
logger.error(f"查询物料失败: {response.text}")
else:
data = res["data"]["nodes"]
logger.trace(f"resource_tree_get查询到物料: {data}")
return data
else:
logger.error(f"查询物料失败: {response.text}")
@@ -170,14 +164,14 @@ class HTTPClient:
if not self.initialized:
self.initialized = True
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
response = self._session.post(
response = requests.post(
f"{self.remote_addr}/lab/material",
json={"nodes": resources},
headers={"Authorization": f"Lab {self.auth}"},
timeout=100,
)
else:
response = self._session.put(
response = requests.put(
f"{self.remote_addr}/lab/material",
json={"nodes": resources},
headers={"Authorization": f"Lab {self.auth}"},
@@ -204,7 +198,7 @@ class HTTPClient:
"""
with open(os.path.join(BasicConfig.working_dir, "req_resource_get.json"), "w", encoding="utf-8") as f:
f.write(json.dumps({"id": id, "with_children": with_children}, indent=4))
response = self._session.get(
response = requests.get(
f"{self.remote_addr}/lab/material",
params={"id": id, "with_children": with_children},
headers={"Authorization": f"Lab {self.auth}"},
@@ -245,14 +239,14 @@ class HTTPClient:
if not self.initialized:
self.initialized = True
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
response = self._session.post(
response = requests.post(
f"{self.remote_addr}/lab/material",
json={"nodes": resources},
headers={"Authorization": f"Lab {self.auth}"},
timeout=100,
)
else:
response = self._session.put(
response = requests.put(
f"{self.remote_addr}/lab/material",
json={"nodes": resources},
headers={"Authorization": f"Lab {self.auth}"},
@@ -282,7 +276,7 @@ class HTTPClient:
with open(file_path, "rb") as file:
files = {"files": file}
logger.info(f"上传文件: {file_path}{scene}")
response = self._session.post(
response = requests.post(
f"{self.remote_addr}/api/account/file_upload/{scene}",
files=files,
headers={"Authorization": f"Lab {self.auth}"},
@@ -322,7 +316,7 @@ class HTTPClient:
"Content-Type": "application/json",
"Content-Encoding": "gzip",
}
response = self._session.post(
response = requests.post(
f"{self.remote_addr}/lab/resource",
data=compressed_body,
headers=headers,
@@ -356,7 +350,7 @@ class HTTPClient:
Returns:
Response: API响应对象
"""
response = self._session.get(
response = requests.get(
f"{self.remote_addr}/edge/material/download",
headers={"Authorization": f"Lab {self.auth}"},
timeout=(3, 30),
@@ -417,7 +411,7 @@ class HTTPClient:
with open(os.path.join(BasicConfig.working_dir, "req_workflow_upload.json"), "w", encoding="utf-8") as f:
f.write(json.dumps(payload, indent=4, ensure_ascii=False))
response = self._session.post(
response = requests.post(
f"{self.remote_addr}/lab/workflow/owner/import",
json=payload,
headers={"Authorization": f"Lab {self.auth}"},

View File

@@ -1269,13 +1269,7 @@ class QueueProcessor:
if not queued_jobs:
return
queue_summary = {}
for j in queued_jobs:
key = f"{j.device_id}/{j.action_name}"
queue_summary[key] = queue_summary.get(key, 0) + 1
logger.debug(
f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs: {queue_summary}"
)
logger.debug(f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs")
for job_info in queued_jobs:
# 快照可能已过期:在遍历过程中 end_job() 可能已将此 job 移至 READY

View File

@@ -46,7 +46,7 @@ class WSConfig:
# HTTP配置
class HTTPConfig:
remote_addr = "https://leap-lab.bohrium.com/api/v1"
remote_addr = "https://uni-lab.bohrium.com/api/v1"
# ROS配置

View File

@@ -1,7 +1,9 @@
"""
LaiYu液体处理设备后端模块
提供设备后端接口和实现
"""
from .laiyu_v_backend import UniLiquidHandlerLaiyuBackend
from .laiyu_backend import LaiYuLiquidBackend, create_laiyu_backend
__all__ = ['UniLiquidHandlerLaiyuBackend']
__all__ = ['LaiYuLiquidBackend', 'create_laiyu_backend']

View File

@@ -0,0 +1,334 @@
"""
LaiYu液体处理设备后端实现
提供设备的后端接口和控制逻辑
"""
import logging
from typing import Dict, Any, Optional, List
from abc import ABC, abstractmethod
# 尝试导入PyLabRobot后端
try:
from pylabrobot.liquid_handling.backends import LiquidHandlerBackend
PYLABROBOT_AVAILABLE = True
except ImportError:
PYLABROBOT_AVAILABLE = False
# 创建模拟后端基类
class LiquidHandlerBackend:
def __init__(self, name: str):
self.name = name
self.is_connected = False
def connect(self):
"""连接设备"""
pass
def disconnect(self):
"""断开连接"""
pass
class LaiYuLiquidBackend(LiquidHandlerBackend):
"""LaiYu液体处理设备后端"""
def __init__(self, name: str = "LaiYu_Liquid_Backend"):
"""
初始化LaiYu液体处理设备后端
Args:
name: 后端名称
"""
if PYLABROBOT_AVAILABLE:
# PyLabRobot 的 LiquidHandlerBackend 不接受参数
super().__init__()
else:
# 模拟版本接受 name 参数
super().__init__(name)
self.name = name
self.logger = logging.getLogger(__name__)
self.is_connected = False
self.device_info = {
"name": "LaiYu液体处理设备",
"version": "1.0.0",
"manufacturer": "LaiYu",
"model": "LaiYu_Liquid_Handler"
}
def connect(self) -> bool:
"""
连接到LaiYu液体处理设备
Returns:
bool: 连接是否成功
"""
try:
self.logger.info("正在连接到LaiYu液体处理设备...")
# 这里应该实现实际的设备连接逻辑
# 目前返回模拟连接成功
self.is_connected = True
self.logger.info("成功连接到LaiYu液体处理设备")
return True
except Exception as e:
self.logger.error(f"连接LaiYu液体处理设备失败: {e}")
self.is_connected = False
return False
def disconnect(self) -> bool:
"""
断开与LaiYu液体处理设备的连接
Returns:
bool: 断开连接是否成功
"""
try:
self.logger.info("正在断开与LaiYu液体处理设备的连接...")
# 这里应该实现实际的设备断开连接逻辑
self.is_connected = False
self.logger.info("成功断开与LaiYu液体处理设备的连接")
return True
except Exception as e:
self.logger.error(f"断开LaiYu液体处理设备连接失败: {e}")
return False
def is_device_connected(self) -> bool:
"""
检查设备是否已连接
Returns:
bool: 设备是否已连接
"""
return self.is_connected
def get_device_info(self) -> Dict[str, Any]:
"""
获取设备信息
Returns:
Dict[str, Any]: 设备信息字典
"""
return self.device_info.copy()
def home_device(self) -> bool:
"""
设备归零操作
Returns:
bool: 归零是否成功
"""
if not self.is_connected:
self.logger.error("设备未连接,无法执行归零操作")
return False
try:
self.logger.info("正在执行设备归零操作...")
# 这里应该实现实际的设备归零逻辑
self.logger.info("设备归零操作完成")
return True
except Exception as e:
self.logger.error(f"设备归零操作失败: {e}")
return False
def aspirate(self, volume: float, location: Dict[str, Any]) -> bool:
"""
吸液操作
Args:
volume: 吸液体积 (微升)
location: 吸液位置信息
Returns:
bool: 吸液是否成功
"""
if not self.is_connected:
self.logger.error("设备未连接,无法执行吸液操作")
return False
try:
self.logger.info(f"正在执行吸液操作: 体积={volume}μL, 位置={location}")
# 这里应该实现实际的吸液逻辑
self.logger.info("吸液操作完成")
return True
except Exception as e:
self.logger.error(f"吸液操作失败: {e}")
return False
def dispense(self, volume: float, location: Dict[str, Any]) -> bool:
"""
排液操作
Args:
volume: 排液体积 (微升)
location: 排液位置信息
Returns:
bool: 排液是否成功
"""
if not self.is_connected:
self.logger.error("设备未连接,无法执行排液操作")
return False
try:
self.logger.info(f"正在执行排液操作: 体积={volume}μL, 位置={location}")
# 这里应该实现实际的排液逻辑
self.logger.info("排液操作完成")
return True
except Exception as e:
self.logger.error(f"排液操作失败: {e}")
return False
def pick_up_tip(self, location: Dict[str, Any]) -> bool:
"""
取枪头操作
Args:
location: 枪头位置信息
Returns:
bool: 取枪头是否成功
"""
if not self.is_connected:
self.logger.error("设备未连接,无法执行取枪头操作")
return False
try:
self.logger.info(f"正在执行取枪头操作: 位置={location}")
# 这里应该实现实际的取枪头逻辑
self.logger.info("取枪头操作完成")
return True
except Exception as e:
self.logger.error(f"取枪头操作失败: {e}")
return False
def drop_tip(self, location: Dict[str, Any]) -> bool:
"""
丢弃枪头操作
Args:
location: 丢弃位置信息
Returns:
bool: 丢弃枪头是否成功
"""
if not self.is_connected:
self.logger.error("设备未连接,无法执行丢弃枪头操作")
return False
try:
self.logger.info(f"正在执行丢弃枪头操作: 位置={location}")
# 这里应该实现实际的丢弃枪头逻辑
self.logger.info("丢弃枪头操作完成")
return True
except Exception as e:
self.logger.error(f"丢弃枪头操作失败: {e}")
return False
def move_to(self, location: Dict[str, Any]) -> bool:
"""
移动到指定位置
Args:
location: 目标位置信息
Returns:
bool: 移动是否成功
"""
if not self.is_connected:
self.logger.error("设备未连接,无法执行移动操作")
return False
try:
self.logger.info(f"正在移动到位置: {location}")
# 这里应该实现实际的移动逻辑
self.logger.info("移动操作完成")
return True
except Exception as e:
self.logger.error(f"移动操作失败: {e}")
return False
def get_status(self) -> Dict[str, Any]:
"""
获取设备状态
Returns:
Dict[str, Any]: 设备状态信息
"""
return {
"connected": self.is_connected,
"device_info": self.device_info,
"status": "ready" if self.is_connected else "disconnected"
}
# PyLabRobot 抽象方法实现
def stop(self):
"""停止所有操作"""
self.logger.info("停止所有操作")
pass
@property
def num_channels(self) -> int:
"""返回通道数量"""
return 1 # 单通道移液器
def can_pick_up_tip(self, tip_rack, tip_position) -> bool:
"""检查是否可以拾取吸头"""
return True # 简化实现总是返回True
def pick_up_tips(self, tip_rack, tip_positions):
"""拾取多个吸头"""
self.logger.info(f"拾取吸头: {tip_positions}")
pass
def drop_tips(self, tip_rack, tip_positions):
"""丢弃多个吸头"""
self.logger.info(f"丢弃吸头: {tip_positions}")
pass
def pick_up_tips96(self, tip_rack):
"""拾取96个吸头"""
self.logger.info("拾取96个吸头")
pass
def drop_tips96(self, tip_rack):
"""丢弃96个吸头"""
self.logger.info("丢弃96个吸头")
pass
def aspirate96(self, volume, plate, well_positions):
"""96通道吸液"""
self.logger.info(f"96通道吸液: 体积={volume}")
pass
def dispense96(self, volume, plate, well_positions):
"""96通道排液"""
self.logger.info(f"96通道排液: 体积={volume}")
pass
def pick_up_resource(self, resource, location):
"""拾取资源"""
self.logger.info(f"拾取资源: {resource}")
pass
def drop_resource(self, resource, location):
"""放置资源"""
self.logger.info(f"放置资源: {resource}")
pass
def move_picked_up_resource(self, resource, location):
"""移动已拾取的资源"""
self.logger.info(f"移动资源: {resource}{location}")
pass
def create_laiyu_backend(name: str = "LaiYu_Liquid_Backend") -> LaiYuLiquidBackend:
"""
创建LaiYu液体处理设备后端实例
Args:
name: 后端名称
Returns:
LaiYuLiquidBackend: 后端实例
"""
return LaiYuLiquidBackend(name)

View File

@@ -1,307 +1,385 @@
"""LaiYu PLR 后端 — 对齐路径 B 硬件交互模式
硬件初始化顺序与 laiyu_liquid_station.py (路径 B) 一致:
1. XYZController(auto_connect=True) — 先开串口
2. PipetteController.connect_shared() — 共享 XYZ 的串口 / 锁
3. home_all_axes() + pipette.initialize()
"""
import logging
import json
from typing import List, Optional, Union
from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend
from pylabrobot.liquid_handling.backends.backend import (
LiquidHandlerBackend,
)
from pylabrobot.liquid_handling.standard import (
Drop,
DropTipRack,
MultiHeadAspirationContainer,
MultiHeadAspirationPlate,
MultiHeadDispenseContainer,
MultiHeadDispensePlate,
Pickup,
PickupTipRack,
ResourceDrop,
ResourceMove,
ResourcePickup,
SingleChannelAspiration,
SingleChannelDispense,
Drop,
DropTipRack,
MultiHeadAspirationContainer,
MultiHeadAspirationPlate,
MultiHeadDispenseContainer,
MultiHeadDispensePlate,
Pickup,
PickupTipRack,
ResourceDrop,
ResourceMove,
ResourcePickup,
SingleChannelAspiration,
SingleChannelDispense,
)
from pylabrobot.resources import Resource, Tip
from unilabos.devices.liquid_handling.laiyu.controllers.xyz_controller import XYZController
from unilabos.devices.liquid_handling.laiyu.controllers.pipette_controller import (
PipetteController,
TipStatus,
)
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import JointState
import time
from rclpy.action import ActionClient
from unilabos_msgs.action import SendCmd
import re
logger = logging.getLogger(__name__)
from unilabos.devices.ros_dev.liquid_handler_joint_publisher import JointStatePublisher
from unilabos.devices.liquid_handling.laiyu.controllers.pipette_controller import PipetteController, TipStatus
class UniLiquidHandlerLaiyuBackend(LiquidHandlerBackend):
"""LaiYu 硬件后端 — PLR Backend 接口实现"""
"""Chatter box backend for device-free testing. Prints out all operations."""
def __init__(
self,
num_channels: int = 1,
tip_length: float = 0,
total_height: float = 310,
port: str = "/dev/ttyUSB0",
baudrate: int = 115200,
pipette_address: int = 4,
):
super().__init__()
self._num_channels = num_channels
self.tip_length = tip_length
self.total_height = total_height
_pip_length = 5
_vol_length = 8
_resource_length = 20
_offset_length = 16
_flow_rate_length = 10
_blowout_length = 10
_lld_z_length = 10
_kwargs_length = 15
_tip_type_length = 12
_max_volume_length = 16
_fitting_depth_length = 20
_tip_length_length = 16
# _pickup_method_length = 20
_filter_length = 10
# 保存配置,延迟到 setup() 再创建硬件对象
self._port = port
self._baudrate = baudrate
self._pipette_address = pipette_address
def __init__(self, num_channels: int = 8 , tip_length: float = 0 , total_height: float = 310, port: str = "/dev/ttyUSB0"):
"""Initialize a chatter box backend."""
super().__init__()
self._num_channels = num_channels
self.tip_length = tip_length
self.total_height = total_height
# rclpy.init()
if not rclpy.ok():
rclpy.init()
self.joint_state_publisher = None
self.hardware_interface = PipetteController(port=port)
self._xyz: Optional[XYZController] = None
self._pipette_ctrl: Optional[PipetteController] = None
self._ros_node = None
async def setup(self):
# self.joint_state_publisher = JointStatePublisher()
# self.hardware_interface.xyz_controller.connect_device()
# self.hardware_interface.xyz_controller.home_all_axes()
await super().setup()
self.hardware_interface.connect()
self.hardware_interface.initialize()
# ------------------------------------------------------------------ lifecycle
print("Setting up the liquid handler.")
def post_init(self, ros_node):
"""接收 ROS 节点引用(由 Handler.post_init 调用)"""
self._ros_node = ros_node
async def stop(self):
print("Stopping the liquid handler.")
async def setup(self):
"""按路径 B 顺序初始化硬件"""
await super().setup()
def serialize(self) -> dict:
return {**super().serialize(), "num_channels": self.num_channels}
# 1. XYZ 先开串口
self._xyz = XYZController(
port=self._port,
baudrate=self._baudrate,
auto_connect=True,
)
if not self._xyz.is_connected:
raise RuntimeError("XYZ 控制器连接失败")
def pipette_aspirate(self, volume: float, flow_rate: float):
# 2. PipetteController 共享 XYZ 串口
self._pipette_ctrl = PipetteController(
port=self._port,
address=self._pipette_address,
)
self._pipette_ctrl.connect_shared(
serial_conn=self._xyz.serial_conn,
serial_lock=self._xyz.serial_lock,
xyz_controller=self._xyz,
)
self.hardware_interface.pipette.set_max_speed(flow_rate)
res = self.hardware_interface.pipette.aspirate(volume=volume)
if not res:
self.hardware_interface.logger.error("吸取失败,当前体积: {self.hardware_interface.current_volume}")
return
# 3. 回零 + 移液器初始化
self._xyz.home_all_axes()
self._pipette_ctrl.initialize()
self.hardware_interface.current_volume += volume
logger.info("LaiYu 后端硬件初始化完成")
def pipette_dispense(self, volume: float, flow_rate: float):
async def stop(self):
"""正确断开硬件"""
try:
if self._pipette_ctrl:
self._pipette_ctrl.disconnect_shared()
if self._xyz:
self._xyz.disconnect()
logger.info("LaiYu 后端硬件已断开")
except Exception as e:
logger.error(f"停止后端失败: {e}")
self.hardware_interface.pipette.set_max_speed(flow_rate)
res = self.hardware_interface.pipette.dispense(volume=volume)
if not res:
self.hardware_interface.logger.error("排液失败,当前体积: {self.hardware_interface.current_volume}")
return
self.hardware_interface.current_volume -= volume
# ------------------------------------------------------------------ helpers
@property
def num_channels(self) -> int:
return self._num_channels
def _plr_to_machine_coords(self, resource, offset):
"""PLR Resource 坐标 → 机器坐标 (倒置龙门架: total_height - z, -y)"""
coordinate = resource.get_absolute_location(x="c", y="c")
x = coordinate.x + offset.x
y = coordinate.y + offset.y
z_plr = coordinate.z + offset.z
return x, -y, self.total_height - (z_plr + self.tip_length)
async def assigned_resource_callback(self, resource: Resource):
print(f"Resource {resource.name} was assigned to the liquid handler.")
def _pipette_aspirate(self, volume: float, flow_rate: float):
self._pipette_ctrl.pipette.set_max_speed(flow_rate)
res = self._pipette_ctrl.pipette.aspirate(volume=volume)
if not res:
logger.error(f"吸取失败,当前体积: {self._pipette_ctrl.current_volume}")
return
self._pipette_ctrl.current_volume += volume
async def unassigned_resource_callback(self, name: str):
print(f"Resource {name} was unassigned from the liquid handler.")
def _pipette_dispense(self, volume: float, flow_rate: float):
self._pipette_ctrl.pipette.set_max_speed(flow_rate)
res = self._pipette_ctrl.pipette.dispense(volume=volume)
if not res:
logger.error(f"排液失败,当前体积: {self._pipette_ctrl.current_volume}")
return
self._pipette_ctrl.current_volume -= volume
async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int], **backend_kwargs):
print("Picking up tips:")
# print(ops.tip)
header = (
f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{'tip type':<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
f"{'max volume (µL)':<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
f"{'fitting depth (mm)':<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
f"{'tip length (mm)':<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
# f"{'pickup method':<{ChatterboxBackend._pickup_method_length}} "
f"{'filter':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
)
# print(header)
# ------------------------------------------------------------------ properties
for op, channel in zip(ops, use_channels):
offset = f"{round(op.offset.x, 1)},{round(op.offset.y, 1)},{round(op.offset.z, 1)}"
row = (
f" p{channel}: "
f"{op.resource.name[-30:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{op.tip.__class__.__name__:<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
f"{op.tip.maximal_volume:<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
f"{op.tip.fitting_depth:<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
f"{op.tip.total_tip_length:<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
# f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} "
f"{'Yes' if op.tip.has_filter else 'No':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
)
# print(row)
# print(op.resource.get_absolute_location())
self.tip_length = ops[0].tip.total_tip_length
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
offset_xyz = ops[0].offset
x = coordinate.x + offset_xyz.x
y = coordinate.y + offset_xyz.y
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
# print("moving")
self.hardware_interface._update_tip_status()
if self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED:
print("已有枪头,无需重复拾取")
return
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200)
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height,speed=100)
# self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "pick",channels=use_channels)
# goback()
def serialize(self) -> dict:
return {**super().serialize(), "num_channels": self.num_channels}
@property
def num_channels(self) -> int:
return self._num_channels
# ------------------------------------------------------------------ resource callbacks
async def assigned_resource_callback(self, resource: Resource):
logger.info(f"Resource {resource.name} was assigned to the liquid handler.")
async def drop_tips(self, ops: List[Drop], use_channels: List[int], **backend_kwargs):
print("Dropping tips:")
header = (
f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{'tip type':<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
f"{'max volume (µL)':<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
f"{'fitting depth (mm)':<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
f"{'tip length (mm)':<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
# f"{'pickup method':<{ChatterboxBackend._pickup_method_length}} "
f"{'filter':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
)
# print(header)
async def unassigned_resource_callback(self, name: str):
logger.info(f"Resource {name} was unassigned from the liquid handler.")
for op, channel in zip(ops, use_channels):
offset = f"{round(op.offset.x, 1)},{round(op.offset.y, 1)},{round(op.offset.z, 1)}"
row = (
f" p{channel}: "
f"{op.resource.name[-30:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{op.tip.__class__.__name__:<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
f"{op.tip.maximal_volume:<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
f"{op.tip.fitting_depth:<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
f"{op.tip.total_tip_length:<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
# f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} "
f"{'Yes' if op.tip.has_filter else 'No':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
)
# print(row)
# ------------------------------------------------------------------ pick_up_tips
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
offset_xyz = ops[0].offset
x = coordinate.x + offset_xyz.x
y = coordinate.y + offset_xyz.y
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z -20
# print(x, y, z)
# print("moving")
self.hardware_interface._update_tip_status()
if self.hardware_interface.tip_status == TipStatus.NO_TIP:
print("无枪头,无需丢弃")
return
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200)
self.hardware_interface.eject_tip
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height)
async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int], **backend_kwargs):
tip = ops[0].tip
self.tip_length = tip.total_tip_length
x, y, z_top = self._plr_to_machine_coords(ops[0].resource, ops[0].offset)
async def aspirate(
self,
ops: List[SingleChannelAspiration],
use_channels: List[int],
**backend_kwargs,
):
print("Aspirating:")
header = (
f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
f"{'vol(ul)':<{UniLiquidHandlerLaiyuBackend._vol_length}} "
f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{'flow rate':<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
f"{'blowout':<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
f"{'lld_z':<{UniLiquidHandlerLaiyuBackend._lld_z_length}} "
# f"{'liquids':<20}" # TODO: add liquids
)
for key in backend_kwargs:
header += f"{key:<{UniLiquidHandlerLaiyuBackend._kwargs_length}} "[-16:]
# print(header)
self._pipette_ctrl._update_tip_status()
if self._pipette_ctrl.tip_status == TipStatus.TIP_ATTACHED:
logger.warning("已有枪头,无需重复拾取")
return
for o, p in zip(ops, use_channels):
offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}"
row = (
f" p{p}: "
f"{o.volume:<{UniLiquidHandlerLaiyuBackend._vol_length}} "
f"{o.resource.name[-20:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{str(o.flow_rate):<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
f"{str(o.blow_out_air_volume):<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
f"{str(o.liquid_height):<{UniLiquidHandlerLaiyuBackend._lld_z_length}} "
# f"{o.liquids if o.liquids is not None else 'none'}"
)
for key, value in backend_kwargs.items():
if isinstance(value, list) and all(isinstance(v, bool) for v in value):
value = "".join("T" if v else "F" for v in value)
if isinstance(value, list):
value = "".join(map(str, value))
row += f" {value:<15}"
# print(row)
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
offset_xyz = ops[0].offset
x = coordinate.x + offset_xyz.x
y = coordinate.y + offset_xyz.y
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
# print(x, y, z)
# print("moving")
try:
# 1. 移到枪头正上方
self._xyz.move_to_work_coord_safe(x=x, y=y, z=z_top, speed=200)
# 2. 下压到套枪头深度fitting_depth 是枪头套入长度)
z_pickup = z_top + tip.fitting_depth
self._xyz.move_to_work_coord_safe(z=z_pickup, speed=100)
# 3. 退回安全高度
self._xyz.move_to_work_coord_safe(
z=self._xyz.machine_config.safe_z_height, speed=100
)
except Exception as e:
logger.error(f"pick_up_tips 移动失败: {e}")
raise
# 判断枪头是否存在
self.hardware_interface._update_tip_status()
if not self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED:
print("无枪头,无法吸液")
return
# 判断吸液量是否超过枪头容量
flow_rate = backend_kwargs["flow_rate"] if "flow_rate" in backend_kwargs else 500
blow_out_air_volume = backend_kwargs["blow_out_air_volume"] if "blow_out_air_volume" in backend_kwargs else 0
if self.hardware_interface.current_volume + ops[0].volume + blow_out_air_volume > self.hardware_interface.max_volume:
self.hardware_interface.logger.error(f"吸液量超过枪头容量: {self.hardware_interface.current_volume + ops[0].volume} > {self.hardware_interface.max_volume}")
return
# ------------------------------------------------------------------ drop_tips
# 移动到吸液位置
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200)
self.pipette_aspirate(volume=ops[0].volume, flow_rate=flow_rate)
async def drop_tips(self, ops: List[Drop], use_channels: List[int], **backend_kwargs):
x, y, z = self._plr_to_machine_coords(ops[0].resource, ops[0].offset)
z -= 20 # 额外下移补偿
self._pipette_ctrl._update_tip_status()
if self._pipette_ctrl.tip_status == TipStatus.NO_TIP:
logger.warning("无枪头,无需丢弃")
return
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height)
if blow_out_air_volume >0:
self.pipette_aspirate(volume=blow_out_air_volume, flow_rate=flow_rate)
try:
self._xyz.move_to_work_coord_safe(x=x, y=y, z=z, speed=200)
self._pipette_ctrl.eject_tip() # 修复: 原来缺少 ()
self._xyz.move_to_work_coord_safe(
z=self._xyz.machine_config.safe_z_height
)
except Exception as e:
logger.error(f"drop_tips 失败: {e}")
raise
# ------------------------------------------------------------------ aspirate
async def aspirate(
self,
ops: List[SingleChannelAspiration],
use_channels: List[int],
**backend_kwargs,
):
x, y, z = self._plr_to_machine_coords(ops[0].resource, ops[0].offset)
self._pipette_ctrl._update_tip_status()
if self._pipette_ctrl.tip_status != TipStatus.TIP_ATTACHED:
raise RuntimeError("无枪头,无法吸液")
async def dispense(
self,
ops: List[SingleChannelDispense],
use_channels: List[int],
**backend_kwargs,
):
# print("Dispensing:")
header = (
f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
f"{'vol(ul)':<{UniLiquidHandlerLaiyuBackend._vol_length}} "
f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{'flow rate':<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
f"{'blowout':<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
f"{'lld_z':<{UniLiquidHandlerLaiyuBackend._lld_z_length}} "
# f"{'liquids':<20}" # TODO: add liquids
)
for key in backend_kwargs:
header += f"{key:<{UniLiquidHandlerLaiyuBackend._kwargs_length}} "[-16:]
# print(header)
flow_rate = backend_kwargs.get("flow_rate", 500)
blow_out_air_volume = backend_kwargs.get("blow_out_air_volume", 0)
for o, p in zip(ops, use_channels):
offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}"
row = (
f" p{p}: "
f"{o.volume:<{UniLiquidHandlerLaiyuBackend._vol_length}} "
f"{o.resource.name[-20:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{str(o.flow_rate):<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
f"{str(o.blow_out_air_volume):<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
f"{str(o.liquid_height):<{UniLiquidHandlerLaiyuBackend._lld_z_length}} "
# f"{o.liquids if o.liquids is not None else 'none'}"
)
for key, value in backend_kwargs.items():
if isinstance(value, list) and all(isinstance(v, bool) for v in value):
value = "".join("T" if v else "F" for v in value)
if isinstance(value, list):
value = "".join(map(str, value))
row += f" {value:<{UniLiquidHandlerLaiyuBackend._kwargs_length}}"
# print(row)
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
offset_xyz = ops[0].offset
x = coordinate.x + offset_xyz.x
y = coordinate.y + offset_xyz.y
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
# print(x, y, z)
# print("moving")
if (
self._pipette_ctrl.current_volume + ops[0].volume + blow_out_air_volume
> self._pipette_ctrl.max_volume
):
raise RuntimeError(
f"吸液量超过枪头容量: "
f"{self._pipette_ctrl.current_volume + ops[0].volume} > {self._pipette_ctrl.max_volume}"
)
# 判断枪头是否存在
self.hardware_interface._update_tip_status()
if not self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED:
print("无枪头,无法排液")
return
# 判断排液量是否超过枪头容量
flow_rate = backend_kwargs["flow_rate"] if "flow_rate" in backend_kwargs else 500
blow_out_air_volume = backend_kwargs["blow_out_air_volume"] if "blow_out_air_volume" in backend_kwargs else 0
if self.hardware_interface.current_volume - ops[0].volume - blow_out_air_volume < 0:
self.hardware_interface.logger.error(f"排液量超过枪头容量: {self.hardware_interface.current_volume - ops[0].volume - blow_out_air_volume} < 0")
return
self._xyz.move_to_work_coord_safe(x=x, y=y, z=z, speed=200)
self._pipette_aspirate(volume=ops[0].volume, flow_rate=flow_rate)
# 移动到排液位置
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200)
self.pipette_dispense(volume=ops[0].volume, flow_rate=flow_rate)
self._xyz.move_to_work_coord_safe(
z=self._xyz.machine_config.safe_z_height
)
if blow_out_air_volume > 0:
self._pipette_aspirate(volume=blow_out_air_volume, flow_rate=flow_rate)
# ------------------------------------------------------------------ dispense
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height)
if blow_out_air_volume > 0:
self.pipette_dispense(volume=blow_out_air_volume, flow_rate=flow_rate)
# self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "",channels=use_channels)
async def dispense(
self,
ops: List[SingleChannelDispense],
use_channels: List[int],
**backend_kwargs,
):
x, y, z = self._plr_to_machine_coords(ops[0].resource, ops[0].offset)
async def pick_up_tips96(self, pickup: PickupTipRack, **backend_kwargs):
print(f"Picking up tips from {pickup.resource.name}.")
self._pipette_ctrl._update_tip_status()
if self._pipette_ctrl.tip_status != TipStatus.TIP_ATTACHED:
raise RuntimeError("无枪头,无法排液")
async def drop_tips96(self, drop: DropTipRack, **backend_kwargs):
print(f"Dropping tips to {drop.resource.name}.")
flow_rate = backend_kwargs.get("flow_rate", 500)
blow_out_air_volume = backend_kwargs.get("blow_out_air_volume", 0)
async def aspirate96(
self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer]
):
if isinstance(aspiration, MultiHeadAspirationPlate):
resource = aspiration.wells[0].parent
else:
resource = aspiration.container
print(f"Aspirating {aspiration.volume} from {resource}.")
if (
self._pipette_ctrl.current_volume - ops[0].volume - blow_out_air_volume < 0
):
raise RuntimeError(
f"排液量超过当前体积: "
f"{self._pipette_ctrl.current_volume - ops[0].volume - blow_out_air_volume} < 0"
)
async def dispense96(self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]):
if isinstance(dispense, MultiHeadDispensePlate):
resource = dispense.wells[0].parent
else:
resource = dispense.container
print(f"Dispensing {dispense.volume} to {resource}.")
self._xyz.move_to_work_coord_safe(x=x, y=y, z=z, speed=200)
self._pipette_dispense(volume=ops[0].volume, flow_rate=flow_rate)
async def pick_up_resource(self, pickup: ResourcePickup):
print(f"Picking up resource: {pickup}")
self._xyz.move_to_work_coord_safe(
z=self._xyz.machine_config.safe_z_height
)
if blow_out_air_volume > 0:
self._pipette_dispense(volume=blow_out_air_volume, flow_rate=flow_rate)
async def move_picked_up_resource(self, move: ResourceMove):
print(f"Moving picked up resource: {move}")
# ------------------------------------------------------------------ 96-channel stubs
async def drop_resource(self, drop: ResourceDrop):
print(f"Dropping resource: {drop}")
async def pick_up_tips96(self, pickup: PickupTipRack, **backend_kwargs):
logger.info(f"Picking up tips from {pickup.resource.name}.")
async def drop_tips96(self, drop: DropTipRack, **backend_kwargs):
logger.info(f"Dropping tips to {drop.resource.name}.")
async def aspirate96(
self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer]
):
if isinstance(aspiration, MultiHeadAspirationPlate):
resource = aspiration.wells[0].parent
else:
resource = aspiration.container
logger.info(f"Aspirating {aspiration.volume} from {resource}.")
async def dispense96(
self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]
):
if isinstance(dispense, MultiHeadDispensePlate):
resource = dispense.wells[0].parent
else:
resource = dispense.container
logger.info(f"Dispensing {dispense.volume} to {resource}.")
async def pick_up_resource(self, pickup: ResourcePickup):
logger.info(f"Picking up resource: {pickup}")
async def move_picked_up_resource(self, move: ResourceMove):
logger.info(f"Moving picked up resource: {move}")
async def drop_resource(self, drop: ResourceDrop):
logger.info(f"Dropping resource: {drop}")
def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool:
return True
def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool:
return True

View File

@@ -5,16 +5,21 @@
封装SOPA移液器的高级控制功能
"""
# 添加项目根目录到Python路径以解决模块导入问题
import sys
import os
_current_file = os.path.abspath(__file__)
_project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(_current_file)))))
if _project_root not in sys.path:
sys.path.insert(0, _project_root)
from tkinter import N
from unilabos.devices.liquid_handling.laiyu.drivers.xyz_stepper_driver import ModbusException
# 无论如何都添加项目根目录到路径
current_file = os.path.abspath(__file__)
# 从 .../Uni-Lab-OS/unilabos/devices/LaiYu_Liquid/controllers/pipette_controller.py
# 向上5级到 .../Uni-Lab-OS
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(current_file)))))
# 强制添加项目根目录到sys.path的开头
sys.path.insert(0, project_root)
import time
import logging
from typing import Optional, List, Dict, Tuple
@@ -148,7 +153,7 @@ class PipetteController:
logger.error("移液器连接失败")
return False
logger.info("移液器连接成功")
# 连接XYZ步进电机控制器如果提供了端口
if self.xyz_port != self.pipette_port:
try:
@@ -167,62 +172,24 @@ class PipetteController:
try:
self.xyz_controller = XYZController(self.xyz_port, auto_connect=False)
self.xyz_controller.serial_conn = self.pipette.serial_port
self.xyz_controller.serial_lock = self.pipette.lock
self.xyz_controller.is_connected = True
logger.info("XYZ控制器与移液器共享串口和互斥锁")
except Exception as e:
logger.warning(f"共享端口 XYZ 控制器创建失败: {e}")
self.xyz_controller = None
self.xyz_connected = False
logger.info("未配置XYZ步进电机端口跳过运动控制器连接")
return True
except Exception as e:
logger.error(f"设备连接失败: {e}")
return False
def connect_shared(self, serial_conn, serial_lock, xyz_controller: XYZController) -> bool:
"""使用已连接的串口和XYZ控制器路径 B 模式XYZ 先开串口,移液器共享)
Args:
serial_conn: 已打开的串口连接(来自 XYZController
serial_lock: 串口互斥锁(来自 XYZController
xyz_controller: 已连接的 XYZController 实例
"""
try:
self.pipette.serial_port = serial_conn
self.pipette.lock = serial_lock
self.pipette.is_connected = True
self.xyz_controller = xyz_controller
self.xyz_connected = True
logger.info("移液控制器已通过 connect_shared 共享 XYZ 串口")
return True
except Exception as e:
logger.error(f"connect_shared 失败: {e}")
return False
def disconnect_shared(self) -> None:
"""释放共享串口引用(与 connect_shared 对称)。
注意:不关闭串口本身,串口由 XYZController 负责关闭。
"""
try:
self.pipette.serial_port = None
self.pipette.lock = None
self.pipette.is_connected = False
self.xyz_controller = None
self.xyz_connected = False
logger.info("移液控制器已释放共享串口引用")
except Exception as e:
logger.error(f"disconnect_shared 失败: {e}")
def initialize(self) -> bool:
"""初始化移液器"""
try:
if self.pipette.initialize():
logger.info("移液器初始化成功")
# 检查枪头状态
self._update_tip_status()
self.xyz_controller.home_all_axes()
self.xyz_controller.move_to_work_coord_safe(x=0, y=-150, z=0)
return True
return False
except Exception as e:
@@ -231,58 +198,56 @@ class PipetteController:
def disconnect(self):
"""断开连接"""
if self.xyz_controller and self.xyz_connected:
if self.xyz_port != self.pipette_port:
try:
self.xyz_controller.disconnect()
logger.info("XYZ 步进电机已断开")
except Exception as e:
logger.error(f"断开 XYZ 步进电机失败: {e}")
else:
self.xyz_controller.serial_conn = None
self.xyz_connected = False
self.xyz_controller = None
# 断开移液器连接
self.pipette.disconnect()
logger.info("移液器已断开")
# 断开 XYZ 步进电机连接
if self.xyz_controller and self.xyz_connected:
try:
self.xyz_controller.disconnect()
self.xyz_connected = False
logger.info("XYZ 步进电机已断开")
except Exception as e:
logger.error(f"断开 XYZ 步进电机失败: {e}")
def _check_xyz_safety(self, axis: MotorAxis, target_position: int) -> bool:
"""
检查 XYZ 轴移动的安全性
Args:
axis: 电机轴
target_position: 目标位置(步数)
Returns:
是否安全
"""
try:
# 获取当前电机状态
motor_position = self.xyz_controller.get_motor_status(axis)
# 检查电机状态是否正常 (不是碰撞停止或限位停止)
if motor_position.status in [MotorStatus.COLLISION_STOP,
MotorStatus.FORWARD_LIMIT_STOP,
if motor_position.status in [MotorStatus.COLLISION_STOP,
MotorStatus.FORWARD_LIMIT_STOP,
MotorStatus.REVERSE_LIMIT_STOP]:
logger.error(f"{axis.name} 轴电机处于错误状态: {motor_position.status.name}")
return False
# 检查位置限制 (扩大安全范围以适应实际硬件)
# 步进电机的位置范围通常很大,这里设置更合理的范围
if target_position < -500000 or target_position > 500000:
logger.error(f"{axis.name} 轴目标位置超出安全范围: {target_position}")
return False
# 检查移动距离是否过大 (单次移动不超过 20000 步约12mm)
current_position = motor_position.steps
move_distance = abs(target_position - current_position)
if move_distance > 20000:
logger.error(f"{axis.name} 轴单次移动距离过大: {move_distance}")
return False
return True
except Exception as e:
logger.error(f"安全检查失败: {e}")
return False
@@ -290,48 +255,48 @@ class PipetteController:
def move_z_relative(self, distance_mm: float, speed: int = 2000, acceleration: int = 500) -> bool:
"""
Z轴相对移动
Args:
distance_mm: 移动距离(mm),正值向下,负值向上
speed: 移动速度(rpm)
acceleration: 加速度(rpm/s)
Returns:
移动是否成功
"""
if not self.xyz_controller or not self.xyz_connected:
logger.error("XYZ 步进电机未连接,无法执行移动")
return False
try:
# 参数验证
if abs(distance_mm) > 15.0:
logger.error(f"移动距离过大: {distance_mm}mm最大允许15mm")
return False
if speed < 100 or speed > 5000:
logger.error(f"速度参数无效: {speed}rpm范围应为100-5000")
return False
# 获取当前 Z 轴位置
current_status = self.xyz_controller.get_motor_status(MotorAxis.Z)
current_z_position = current_status.steps
# 计算移动距离对应的步数 (1mm = 1638.4步)
mm_to_steps = 1638.4
move_distance_steps = int(distance_mm * mm_to_steps)
# 计算目标位置
target_z_position = current_z_position + move_distance_steps
# 安全检查
if not self._check_xyz_safety(MotorAxis.Z, target_z_position):
logger.error("Z轴移动安全检查失败")
return False
logger.info(f"Z轴相对移动: {distance_mm}mm ({move_distance_steps}步)")
logger.info(f"当前位置: {current_z_position}步 -> 目标位置: {target_z_position}")
# 执行移动
success = self.xyz_controller.move_to_position(
axis=MotorAxis.Z,
@@ -340,28 +305,28 @@ class PipetteController:
acceleration=acceleration,
precision=50
)
if not success:
logger.error("Z轴移动命令发送失败")
return False
# 等待移动完成
if not self.xyz_controller.wait_for_completion(MotorAxis.Z, timeout=10.0):
logger.error("Z轴移动超时")
return False
# 验证移动结果
final_status = self.xyz_controller.get_motor_status(MotorAxis.Z)
final_position = final_status.steps
position_error = abs(final_position - target_z_position)
logger.info(f"Z轴移动完成最终位置: {final_position}步,误差: {position_error}")
if position_error > 100:
logger.warning(f"Z轴位置误差较大: {position_error}")
return True
except ModbusException as e:
logger.error(f"Modbus通信错误: {e}")
return False
@@ -372,20 +337,21 @@ class PipetteController:
def emergency_stop(self) -> bool:
"""
紧急停止所有运动
Returns:
停止是否成功
"""
success = True
# 停止移液器操作
try:
if self.pipette and self.pipette.is_connected:
self.pipette.emergency_stop()
if self.pipette and self.connected:
# 这里可以添加移液器的紧急停止逻辑
logger.info("移液器紧急停止")
except Exception as e:
logger.error(f"移液器紧急停止失败: {e}")
success = False
# 停止 XYZ 轴运动
try:
if self.xyz_controller and self.xyz_connected:
@@ -394,7 +360,7 @@ class PipetteController:
except Exception as e:
logger.error(f"XYZ 轴紧急停止失败: {e}")
success = False
return success
def pickup_tip(self) -> bool:
@@ -410,7 +376,7 @@ class PipetteController:
return True
logger.info("开始装载枪头 - Z轴向下移动10mm")
# 使用相对移动方法向下移动10mm
if self.move_z_relative(distance_mm=10.0, speed=2000, acceleration=500):
# 更新枪头状态
@@ -722,31 +688,31 @@ class PipetteController:
if __name__ == "__main__":
# 配置日志
import logging
# 设置日志级别
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
def interactive_test():
"""交互式测试模式 - 适用于已连接的设备"""
print("\n" + "=" * 60)
print("🧪 移液器交互式测试模式")
print("=" * 60)
# 获取用户输入的连接参数
print("\n📡 设备连接配置:")
port = input("请输入移液器串口端口 (默认: /dev/ttyUSB_CH340): ").strip() or "/dev/ttyUSB_CH340"
address_input = input("请输入移液器设备地址 (默认: 4): ").strip()
address = int(address_input) if address_input else 4
# 询问是否连接 XYZ 步进电机控制器
xyz_enable = input("是否连接 XYZ 步进电机控制器? (y/N): ").strip().lower()
xyz_port = None
if xyz_enable not in ['n', 'no']:
xyz_port = input("请输入 XYZ 控制器串口端口 (默认: /dev/ttyUSB_CH340): ").strip() or "/dev/ttyUSB_CH340"
try:
# 创建移液控制器实例
if xyz_port:
@@ -755,21 +721,21 @@ if __name__ == "__main__":
else:
print(f"\n🔧 创建移液控制器实例 (端口: {port}, 地址: {address})...")
pipette = PipetteController(port=port, address=address)
# 连接设备
print("\n📞 连接移液器设备...")
if not pipette.connect():
print("❌ 设备连接失败,请检查连接")
return
print("✅ 设备连接成功")
# 初始化设备
print("\n🚀 初始化设备...")
if not pipette.initialize():
print("❌ 设备初始化失败")
return
print("✅ 设备初始化成功")
# 交互式菜单
while True:
print("\n" + "=" * 50)
@@ -789,9 +755,9 @@ if __name__ == "__main__":
print("99. 🚨 紧急停止")
print("0. 🚪 退出程序")
print("=" * 50)
choice = input("\n请选择操作 (0-12, 99): ").strip()
if choice == "0":
print("\n👋 退出程序...")
break
@@ -807,7 +773,7 @@ if __name__ == "__main__":
# print(f" 🔧 枪头使用次数: {status['statistics']['tip_count']}")
print(f" ⬆️ 吸液次数: {status['statistics']['aspirate_count']}")
print(f" ⬇️ 排液次数: {status['statistics']['dispense_count']}")
elif choice == "2":
# 装载枪头
print("\n🔧 装载枪头...")
@@ -815,14 +781,14 @@ if __name__ == "__main__":
print("📍 使用 XYZ 控制器进行 Z 轴定位 (下移 10mm)")
else:
print("⚠️ 未连接 XYZ 控制器,仅执行移液器枪头装载")
if pipette.pickup_tip():
print("✅ 枪头装载成功")
if pipette.xyz_connected:
print("📍 Z 轴已移动到装载位置")
else:
print("❌ 枪头装载失败")
elif choice == "3":
# 弹出枪头
print("\n🗑️ 弹出枪头...")
@@ -830,7 +796,7 @@ if __name__ == "__main__":
print("✅ 枪头弹出成功")
else:
print("❌ 枪头弹出失败")
elif choice == "4":
# 吸液操作
try:
@@ -844,7 +810,7 @@ if __name__ == "__main__":
print("❌ 吸液失败")
except ValueError:
print("❌ 请输入有效的数字")
elif choice == "5":
# 排液操作
try:
@@ -858,7 +824,7 @@ if __name__ == "__main__":
print("❌ 排液失败")
except ValueError:
print("❌ 请输入有效的数字")
elif choice == "6":
# 混合操作
try:
@@ -872,7 +838,7 @@ if __name__ == "__main__":
print("❌ 混合失败")
except ValueError:
print("❌ 请输入有效的数字")
elif choice == "7":
# 液体转移
try:
@@ -880,7 +846,7 @@ if __name__ == "__main__":
source = input("源孔位 (可选, 如A1): ").strip() or None
dest = input("目标孔位 (可选, 如B1): ").strip() or None
new_tip = input("是否使用新枪头? (y/n, 默认y): ").strip().lower() != 'n'
print(f"\n🔄 执行液体转移 ({volume}ul)...")
if pipette.transfer(volume=volume, source_well=source, dest_well=dest, new_tip=new_tip):
print("✅ 液体转移完成")
@@ -888,7 +854,7 @@ if __name__ == "__main__":
print("❌ 液体转移失败")
except ValueError:
print("❌ 请输入有效的数字")
elif choice == "8":
# 设置液体类型
print("\n🧪 可用液体类型:")
@@ -898,16 +864,16 @@ if __name__ == "__main__":
"3": (LiquidClass.VISCOUS, "粘稠液体"),
"4": (LiquidClass.VOLATILE, "挥发性液体")
}
for key, (liquid_class, description) in liquid_options.items():
print(f" {key}. {description}")
liquid_choice = input("请选择液体类型 (1-4): ").strip()
if liquid_choice in liquid_options:
liquid_class, description = liquid_options[liquid_choice]
pipette.set_liquid_class(liquid_class)
print(f"✅ 液体类型设置为: {description}")
# 显示参数
params = pipette.liquid_params
print(f"📋 参数设置:")
@@ -917,7 +883,7 @@ if __name__ == "__main__":
print(f" 💧 预润湿: {'' if params.pre_wet else ''}")
else:
print("❌ 无效选择")
elif choice == "9":
# 自定义参数
try:
@@ -926,19 +892,19 @@ if __name__ == "__main__":
dispense_speed = input("排液速度 (默认800): ").strip()
air_gap = input("空气间隙 (ul, 默认10.0): ").strip()
pre_wet = input("预润湿 (y/n, 默认n): ").strip().lower() == 'y'
custom_params = LiquidParameters(
aspirate_speed=int(aspirate_speed) if aspirate_speed else 500,
dispense_speed=int(dispense_speed) if dispense_speed else 800,
air_gap=float(air_gap) if air_gap else 10.0,
pre_wet=pre_wet
)
pipette.set_custom_parameters(custom_params)
print("✅ 自定义参数设置完成")
except ValueError:
print("❌ 请输入有效的数字")
elif choice == "10":
# 校准体积
try:
@@ -948,12 +914,12 @@ if __name__ == "__main__":
print(f"✅ 校准完成,校准系数: {actual/expected:.3f}")
except ValueError:
print("❌ 请输入有效的数字")
elif choice == "11":
# 重置统计
pipette.reset_statistics()
print("✅ 统计信息已重置")
elif choice == "12":
# 液体类型测试
print("\n🧪 液体类型参数对比:")
@@ -963,7 +929,7 @@ if __name__ == "__main__":
(LiquidClass.VISCOUS, "粘稠液体"),
(LiquidClass.VOLATILE, "挥发性液体")
]
for liquid_class, description in liquid_tests:
params = pipette.LIQUID_PARAMS[liquid_class]
print(f"\n📋 {description} ({liquid_class.value}):")
@@ -972,7 +938,7 @@ if __name__ == "__main__":
print(f" 💨 空气间隙: {params.air_gap}ul")
print(f" 💧 预润湿: {'' if params.pre_wet else ''}")
print(f" ⏱️ 吸液后延时: {params.delay_after_aspirate}s")
elif choice == "99":
# 紧急停止
print("\n🚨 执行紧急停止...")
@@ -983,19 +949,19 @@ if __name__ == "__main__":
else:
print("❌ 紧急停止执行失败")
print("⚠️ 请手动检查设备状态并采取必要措施")
# 紧急停止后询问是否继续
continue_choice = input("\n是否继续操作?(y/n): ").strip().lower()
if continue_choice != 'y':
print("🚪 退出程序")
break
else:
print("❌ 无效选择,请重新输入")
# 等待用户确认继续
input("\n按回车键继续...")
except KeyboardInterrupt:
print("\n\n⚠️ 用户中断操作")
except Exception as e:
@@ -1008,19 +974,19 @@ if __name__ == "__main__":
print("✅ 连接已断开")
except:
print("⚠️ 断开连接时出现问题")
def demo_test():
"""演示测试模式 - 完整功能演示"""
print("\n" + "=" * 60)
print("🎬 移液控制器演示测试")
print("=" * 60)
try:
# 创建移液控制器实例
print("1. 🔧 创建移液控制器实例...")
pipette = PipetteController(port="/dev/ttyUSB0", address=4)
print("✅ 移液控制器实例创建成功")
# 连接设备
print("\n2. 📞 连接移液器设备...")
if pipette.connect():
@@ -1028,7 +994,7 @@ if __name__ == "__main__":
else:
print("❌ 设备连接失败")
return False
# 初始化设备
print("\n3. 🚀 初始化设备...")
if pipette.initialize():
@@ -1036,19 +1002,19 @@ if __name__ == "__main__":
else:
print("❌ 设备初始化失败")
return False
# 装载枪头
print("\n4. 🔧 装载枪头...")
if pipette.pickup_tip():
print("✅ 枪头装载成功")
else:
print("❌ 枪头装载失败")
# 设置液体类型
print("\n5. 🧪 设置液体类型为血清...")
pipette.set_liquid_class(LiquidClass.SERUM)
print("✅ 液体类型设置完成")
# 吸液操作
print("\n6. 💧 执行吸液操作...")
volume_to_aspirate = 100.0
@@ -1057,7 +1023,7 @@ if __name__ == "__main__":
print(f"📊 当前体积: {pipette.current_volume}ul")
else:
print("❌ 吸液失败")
# 排液操作
print("\n7. 💦 执行排液操作...")
volume_to_dispense = 50.0
@@ -1066,14 +1032,14 @@ if __name__ == "__main__":
print(f"📊 剩余体积: {pipette.current_volume}ul")
else:
print("❌ 排液失败")
# 混合操作
print("\n8. 🌀 执行混合操作...")
if pipette.mix(cycles=3, volume=30.0):
print("✅ 混合完成")
else:
print("❌ 混合失败")
# 获取状态信息
print("\n9. 📊 获取设备状态...")
status = pipette.get_status()
@@ -1086,30 +1052,30 @@ if __name__ == "__main__":
# print(f" 🔧 枪头使用次数: {status['statistics']['tip_count']}")
print(f" ⬆️ 吸液次数: {status['statistics']['aspirate_count']}")
print(f" ⬇️ 排液次数: {status['statistics']['dispense_count']}")
# 弹出枪头
print("\n10. 🗑️ 弹出枪头...")
if pipette.eject_tip():
print("✅ 枪头弹出成功")
else:
print("❌ 枪头弹出失败")
print("\n" + "=" * 60)
print("✅ 移液控制器演示测试完成")
print("=" * 60)
return True
except Exception as e:
print(f"\n❌ 测试过程中发生异常: {e}")
return False
finally:
# 断开连接
print("\n📞 断开连接...")
pipette.disconnect()
print("✅ 连接已断开")
# 主程序入口
print("🧪 移液器控制器测试程序")
print("=" * 40)
@@ -1117,9 +1083,9 @@ if __name__ == "__main__":
print("2. 🎬 演示测试")
print("0. 🚪 退出")
print("=" * 40)
mode = input("请选择测试模式 (0-2): ").strip()
if mode == "1":
interactive_test()
elif mode == "2":
@@ -1128,7 +1094,7 @@ if __name__ == "__main__":
print("👋 再见!")
else:
print("❌ 无效选择")
print("\n🎉 程序结束!")
print("\n💡 使用说明:")
print("1. 确保移液器硬件已正确连接")

View File

@@ -13,7 +13,7 @@ from pylabrobot.liquid_handling import (
SingleChannelDispense,
PickupTipRack,
DropTipRack,
MultiHeadAspirationPlate,
MultiHeadAspirationPlate, ChatterBoxBackend, LiquidHandlerChatterboxBackend,
)
from pylabrobot.liquid_handling.standard import (
MultiHeadAspirationContainer,
@@ -41,6 +41,12 @@ class TransformXYZDeck(Deck):
super().__init__(name, size_x, size_y, size_z)
self.name = name
class TransformXYZBackend(LiquidHandlerBackend):
def __init__(self, name: str, host: str, port: int, timeout: float):
super().__init__()
self.host = host
self.port = port
self.timeout = timeout
class TransformXYZRvizBackend(UniLiquidHandlerRvizBackend):
def __init__(self, name: str, channel_num: int):
@@ -80,9 +86,7 @@ class TransformXYZContainer(Plate, TipRack):
class TransformXYZHandler(LiquidHandlerAbstract):
support_touch_tip = False
def __init__(self, deck: Deck, host: str = "127.0.0.1", port: int = 9999, timeout: float = 10.0, channel_num=1, simulator=True,
serial_port: str = "/dev/ttyUSB0", baudrate: int = 115200, pipette_address: int = 4,
total_height: float = 310, **backend_kwargs):
def __init__(self, deck: Deck, host: str = "127.0.0.1", port: int = 9999, timeout: float = 10.0, channel_num=1, simulator=True, **backend_kwargs):
# Handle case where deck is passed as a dict (from serialization)
if isinstance(deck, dict):
# Try to create a TransformXYZDeck from the dict
@@ -98,22 +102,11 @@ class TransformXYZHandler(LiquidHandlerAbstract):
deck = TransformXYZDeck(name='deck', size_x=100, size_y=100, size_z=100)
if simulator:
self._unilabos_backend = TransformXYZRvizBackend(name="laiyu", channel_num=channel_num)
self._unilabos_backend = TransformXYZRvizBackend(name="laiyu",channel_num=channel_num)
else:
self._unilabos_backend = UniLiquidHandlerLaiyuBackend(
num_channels=channel_num,
total_height=total_height,
port=serial_port,
baudrate=baudrate,
pipette_address=pipette_address,
)
self._unilabos_backend = TransformXYZBackend(name="laiyu",host=host, port=port, timeout=timeout)
super().__init__(backend=self._unilabos_backend, deck=deck, simulator=simulator, channel_num=channel_num)
def post_init(self, ros_node):
super().post_init(ros_node)
if hasattr(self._unilabos_backend, 'post_init'):
self._unilabos_backend.post_init(ros_node)
async def add_liquid(
self,
asp_vols: Union[List[float], float],
@@ -135,25 +128,7 @@ class TransformXYZHandler(LiquidHandlerAbstract):
mix_liquid_height: Optional[float] = None,
none_keys: List[str] = [],
):
return await super().add_liquid(
asp_vols=asp_vols,
dis_vols=dis_vols,
reagent_sources=reagent_sources,
targets=targets,
use_channels=use_channels,
flow_rates=flow_rates,
offsets=offsets,
liquid_height=liquid_height,
blow_out_air_volume=blow_out_air_volume,
spread=spread,
is_96_well=is_96_well,
delays=delays,
mix_time=mix_time,
mix_vol=mix_vol,
mix_rate=mix_rate,
mix_liquid_height=mix_liquid_height,
none_keys=none_keys,
)
pass
async def aspirate(
self,
@@ -167,17 +142,7 @@ class TransformXYZHandler(LiquidHandlerAbstract):
spread: Literal["wide", "tight", "custom"] = "wide",
**backend_kwargs,
):
return await super().aspirate(
resources=resources,
vols=vols,
use_channels=use_channels,
flow_rates=flow_rates,
offsets=offsets,
liquid_height=liquid_height,
blow_out_air_volume=blow_out_air_volume,
spread=spread,
**backend_kwargs,
)
pass
async def dispense(
self,
@@ -191,17 +156,7 @@ class TransformXYZHandler(LiquidHandlerAbstract):
spread: Literal["wide", "tight", "custom"] = "wide",
**backend_kwargs,
):
return await super().dispense(
resources=resources,
vols=vols,
use_channels=use_channels,
flow_rates=flow_rates,
offsets=offsets,
liquid_height=liquid_height,
blow_out_air_volume=blow_out_air_volume,
spread=spread,
**backend_kwargs,
)
pass
async def drop_tips(
self,
@@ -211,13 +166,7 @@ class TransformXYZHandler(LiquidHandlerAbstract):
allow_nonzero_volume: bool = False,
**backend_kwargs,
):
return await super().drop_tips(
tip_spots=tip_spots,
use_channels=use_channels,
offsets=offsets,
allow_nonzero_volume=allow_nonzero_volume,
**backend_kwargs,
)
pass
async def mix(
self,
@@ -229,15 +178,7 @@ class TransformXYZHandler(LiquidHandlerAbstract):
mix_rate: Optional[float] = None,
none_keys: List[str] = [],
):
return await super().mix(
targets=targets,
mix_time=mix_time,
mix_vol=mix_vol,
height_to_bottom=height_to_bottom,
offsets=offsets,
mix_rate=mix_rate,
none_keys=none_keys,
)
pass
async def pick_up_tips(
self,
@@ -246,12 +187,7 @@ class TransformXYZHandler(LiquidHandlerAbstract):
offsets: Optional[List[Coordinate]] = None,
**backend_kwargs,
):
return await super().pick_up_tips(
tip_spots=tip_spots,
use_channels=use_channels,
offsets=offsets,
**backend_kwargs,
)
pass
async def transfer_liquid(
self,
@@ -278,26 +214,5 @@ class TransformXYZHandler(LiquidHandlerAbstract):
delays: Optional[List[int]] = None,
none_keys: List[str] = [],
):
return await super().transfer_liquid(
sources=sources,
targets=targets,
tip_racks=tip_racks,
use_channels=use_channels,
asp_vols=asp_vols,
dis_vols=dis_vols,
asp_flow_rates=asp_flow_rates,
dis_flow_rates=dis_flow_rates,
offsets=offsets,
touch_tip=touch_tip,
liquid_height=liquid_height,
blow_out_air_volume=blow_out_air_volume,
spread=spread,
is_96_well=is_96_well,
mix_stage=mix_stage,
mix_times=mix_times,
mix_vol=mix_vol,
mix_rate=mix_rate,
mix_liquid_height=mix_liquid_height,
delays=delays,
none_keys=none_keys,
)
pass

View File

@@ -57,18 +57,6 @@ class TransferLiquidReturn(TypedDict):
targets: List[List[ResourceDict]]
class SetLiquidReturn(TypedDict):
wells: list
volumes: list
class SetLiquidFromPlateReturn(TypedDict):
plate: list
wells: list
volumes: list
class LiquidHandlerMiddleware(LiquidHandler):
def __init__(
self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool = False, channel_num: int = 8, **kwargs

View File

@@ -1,376 +0,0 @@
# -*- coding: utf-8 -*-
"""
ZDT X42 Closed-Loop Stepper Motor Driver
RS485 Serial Communication via USB-Serial Converter
- Baudrate: 115200
"""
import serial
import time
import threading
import struct
import logging
from typing import Optional, Any
try:
from unilabos.device_comms.universal_driver import UniversalDriver
except ImportError:
class UniversalDriver:
def __init__(self, *args, **kwargs):
self.logger = logging.getLogger(self.__class__.__name__)
def execute_command_from_outer(self, command: Any): pass
from serial.rs485 import RS485Settings
class ZDTX42Driver(UniversalDriver):
"""
ZDT X42 闭环步进电机驱动器
支持功能:
- 速度模式运行
- 位置模式运行 (相对/绝对)
- 位置读取和清零
- 使能/禁用控制
通信协议:
- 帧格式: [设备ID] [功能码] [数据...] [校验位=0x6B]
- 响应长度根据功能码决定
"""
def __init__(
self,
port: str,
baudrate: int = 115200,
device_id: int = 1,
timeout: float = 0.5,
debug: bool = False
):
"""
初始化 ZDT X42 电机驱动
Args:
port: 串口设备路径
baudrate: 波特率 (默认 115200)
device_id: 设备地址 (1-255)
timeout: 通信超时时间(秒)
debug: 是否启用调试输出
"""
super().__init__()
self.id = device_id
self.debug = debug
self.lock = threading.RLock()
self.status = "idle" # 对应注册表中的 status (str)
self.position = 0 # 对应注册表中的 position (int)
try:
self.ser = serial.Serial(
port=port,
baudrate=baudrate,
timeout=timeout,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE
)
# 启用 RS485 模式
try:
self.ser.rs485_mode = RS485Settings(
rts_level_for_tx=True,
rts_level_for_rx=False
)
except Exception:
pass # RS485 模式是可选的
self.logger.info(
f"ZDT X42 Motor connected: {port} "
f"(Baud: {baudrate}, ID: {device_id})"
)
# 自动使能电机,确保初始状态可运动
self.enable(True)
# 启动背景轮询线程,确保 position 实时刷新
self._stop_event = threading.Event()
self._polling_thread = threading.Thread(
target=self._update_loop,
name=f"ZDTPolling_{port}",
daemon=True
)
self._polling_thread.start()
except Exception as e:
self.logger.error(f"Failed to open serial port {port}: {e}")
self.ser = None
def _update_loop(self):
"""背景循环读取电机位置"""
while not self._stop_event.is_set():
try:
self.get_position()
except Exception as e:
if self.debug:
self.logger.error(f"Polling error: {e}")
time.sleep(1.0) # 每1秒刷新一次位置数据
def _send(self, func_code: int, payload: list) -> bytes:
"""
发送指令并接收响应
Args:
func_code: 功能码
payload: 数据负载 (list of bytes)
Returns:
响应数据 (bytes)
"""
if not self.ser:
self.logger.error("Serial port not available")
return b""
with self.lock:
# 清空输入缓冲区
self.ser.reset_input_buffer()
# 构建消息: [ID] [功能码] [数据...] [校验位=0x6B]
message = bytes([self.id, func_code] + payload + [0x6B])
# 发送
self.ser.write(message)
# 根据功能码决定响应长度
# 查询类指令返回 10 字节,控制类指令返回 4 字节
read_len = 10 if func_code in [0x31, 0x32, 0x35, 0x24, 0x27] else 4
response = self.ser.read(read_len)
# 调试输出
if self.debug:
sent_hex = message.hex().upper()
recv_hex = response.hex().upper() if response else 'TIMEOUT'
print(f"[ID {self.id}] TX: {sent_hex} → RX: {recv_hex}")
return response
def enable(self, on: bool = True) -> bool:
"""
使能/禁用电机
Args:
on: True=使能(锁轴), False=禁用(松轴)
Returns:
是否成功
"""
state = 1 if on else 0
resp = self._send(0xF3, [0xAB, state, 0])
return len(resp) >= 4
def move_speed(
self,
speed_rpm: int,
direction: str = "CW",
acceleration: int = 10
) -> bool:
"""
速度模式运行
Args:
speed_rpm: 转速 (RPM)
direction: 方向 ("CW"=顺时针, "CCW"=逆时针)
acceleration: 加速度 (0-255)
Returns:
是否成功
"""
dir_val = 0 if direction.upper() in ["CW", "顺时针"] else 1
speed_bytes = struct.pack('>H', int(speed_rpm))
self.status = f"moving@{speed_rpm}rpm"
resp = self._send(0xF6, [dir_val, speed_bytes[0], speed_bytes[1], acceleration, 0])
return len(resp) >= 4
def move_position(
self,
pulses: int,
speed_rpm: int,
direction: str = "CW",
acceleration: int = 10,
absolute: bool = False
) -> bool:
"""
位置模式运行
Args:
pulses: 脉冲数
speed_rpm: 转速 (RPM)
direction: 方向 ("CW"=顺时针, "CCW"=逆时针)
acceleration: 加速度 (0-255)
absolute: True=绝对位置, False=相对位置
Returns:
是否成功
"""
dir_val = 0 if direction.upper() in ["CW", "顺时针"] else 1
speed_bytes = struct.pack('>H', int(speed_rpm))
self.status = f"moving_to_{pulses}"
pulse_bytes = struct.pack('>I', int(pulses))
abs_flag = 1 if absolute else 0
payload = [
dir_val,
speed_bytes[0], speed_bytes[1],
acceleration,
pulse_bytes[0], pulse_bytes[1], pulse_bytes[2], pulse_bytes[3],
abs_flag,
0
]
resp = self._send(0xFD, payload)
return len(resp) >= 4
def stop(self) -> bool:
"""
停止电机
Returns:
是否成功
"""
self.status = "idle"
resp = self._send(0xFE, [0x98, 0])
return len(resp) >= 4
def rotate_quarter(self, speed_rpm: int = 60, direction: str = "CW") -> bool:
"""
电机旋转 1/4 圈 (阻塞式)
假设电机细分为 3200 脉冲/圈1/4 圈 = 800 脉冲
"""
pulses = 800
success = self.move_position(pulses=pulses, speed_rpm=speed_rpm, direction=direction, absolute=False)
if success:
# 计算预估旋转时间并进行阻塞等待 (Time = revolutions / (RPM/60))
# 1/4 rev / (RPM/60) = 15.0 / RPM
estimated_time = 15.0 / max(1, speed_rpm)
time.sleep(estimated_time + 0.5) # 额外给 0.5 秒缓冲
self.status = "idle"
return success
def wait_time(self, duration_s: float) -> bool:
"""
等待指定时间 (秒)
"""
self.logger.info(f"Waiting for {duration_s} seconds...")
time.sleep(duration_s)
return True
def set_zero(self) -> bool:
"""
清零当前位置
Returns:
是否成功
"""
resp = self._send(0x0A, [])
return len(resp) >= 4
def get_position(self) -> Optional[int]:
"""
读取当前位置 (脉冲数)
Returns:
当前位置脉冲数,失败返回 None
"""
resp = self._send(0x32, [])
if len(resp) >= 8:
# 响应格式: [ID] [Func] [符号位] [数值4字节] [校验]
sign = resp[2] # 0=正, 1=负
value = struct.unpack('>I', resp[3:7])[0]
self.position = -value if sign == 1 else value
if self.debug:
print(f"[Position] Raw: {resp.hex().upper()}, Parsed: {self.position}")
return self.position
self.logger.warning("Failed to read position")
return None
def close(self):
"""关闭串口连接并停止线程"""
if hasattr(self, '_stop_event'):
self._stop_event.set()
if self.ser and self.ser.is_open:
self.ser.close()
self.logger.info("Serial port closed")
# ============================================================
# 测试和调试代码
# ============================================================
def test_motor():
"""基础功能测试"""
logging.basicConfig(level=logging.INFO)
print("="*60)
print("ZDT X42 电机驱动测试")
print("="*60)
driver = ZDTX42Driver(
port="/dev/tty.usbserial-3110",
baudrate=115200,
device_id=2,
debug=True
)
if not driver.ser:
print("❌ 串口打开失败")
return
try:
# 测试 1: 读取位置
print("\n[1] 读取当前位置")
pos = driver.get_position()
print(f"✓ 当前位置: {pos} 脉冲")
# 测试 2: 使能
print("\n[2] 使能电机")
driver.enable(True)
time.sleep(0.3)
print("✓ 电机已锁定")
# 测试 3: 相对位置运动
print("\n[3] 相对位置运动 (1000脉冲)")
driver.move_position(pulses=1000, speed_rpm=60, direction="CW")
time.sleep(2)
pos = driver.get_position()
print(f"✓ 新位置: {pos}")
# 测试 4: 速度运动
print("\n[4] 速度模式 (30RPM, 3秒)")
driver.move_speed(speed_rpm=30, direction="CW")
time.sleep(3)
driver.stop()
pos = driver.get_position()
print(f"✓ 停止后位置: {pos}")
# 测试 5: 禁用
print("\n[5] 禁用电机")
driver.enable(False)
print("✓ 电机已松开")
print("\n" + "="*60)
print("✅ 测试完成")
print("="*60)
except Exception as e:
print(f"\n❌ 测试失败: {e}")
import traceback
traceback.print_exc()
finally:
driver.close()
if __name__ == "__main__":
test_motor()

View File

@@ -623,119 +623,6 @@ class ChinweDevice(UniversalDriver):
time.sleep(duration)
return True
def separation_step(self, motor_id: int = 5, speed: int = 60, pulses: int = 700,
max_cycles: int = 0, timeout: int = 300) -> bool:
"""
分液步骤 - 液位传感器与电机联动
当液位传感器检测到"有液"时,电机顺时针旋转指定脉冲数
当液位传感器检测到"无液"时,电机逆时针旋转指定脉冲数
:param motor_id: 电机ID (必须在初始化时配置的motor_ids中)
:param speed: 电机转速 (RPM)
:param pulses: 每次旋转的脉冲数 (默认700约为1/4圈,假设3200脉冲/圈)
:param max_cycles: 最大执行循环次数 (0=无限制,默认0)
:param timeout: 整体超时时间 (秒)
:return: 成功返回True,超时或失败返回False
"""
motor_id = int(motor_id)
speed = int(speed)
pulses = int(pulses)
max_cycles = int(max_cycles)
timeout = int(timeout)
# 检查电机是否存在
if motor_id not in self.motors:
self.logger.error(f"Motor {motor_id} not found in configured motors: {list(self.motors.keys())}")
return False
# 检查传感器是否可用
if not self.sensor:
self.logger.error("Sensor not initialized")
return False
motor = self.motors[motor_id]
# 停止轮询线程,避免与 separation_step 同时读取传感器造成串口冲突
self.logger.info("Stopping polling thread for separation_step...")
self._stop_event.set()
if self._poll_thread and self._poll_thread.is_alive():
self._poll_thread.join(timeout=2.0)
# 使能电机
self.logger.info(f"Enabling motor {motor_id}...")
motor.enable(True)
time.sleep(0.2)
self.logger.info(f"Starting separation step: motor_id={motor_id}, speed={speed} RPM, "
f"pulses={pulses}, max_cycles={max_cycles}, timeout={timeout}s")
# 记录上一次的液位状态
last_level = None
cycle_count = 0
start_time = time.time()
error_count = 0
try:
while True:
# 检查超时
if time.time() - start_time > timeout:
self.logger.warning(f"Separation step timeout after {timeout} seconds")
return False
# 检查循环次数限制
if max_cycles > 0 and cycle_count >= max_cycles:
self.logger.info(f"Separation step completed: reached max_cycles={max_cycles}")
return True
# 读取传感器数据
data = self.sensor.read_level()
if data is None:
error_count += 1
if error_count > 5:
self.logger.warning("Sensor read failed multiple times, retrying...")
error_count = 0
time.sleep(0.5)
continue
error_count = 0
current_level = data['level']
rssi = data['rssi']
# 检测状态变化 (包括首次检测)
if current_level != last_level:
cycle_count += 1
if current_level:
# 有液 -> 电机顺时针旋转
self.logger.info(f"[Cycle {cycle_count}] Liquid detected (RSSI={rssi}), "
f"rotating motor {motor_id} clockwise {pulses} pulses")
motor.run_position(pulses=pulses, speed_rpm=speed, direction=0, absolute=False)
# 等待电机完成 (预估时间)
estimated_time = 15.0 / max(1, speed)
time.sleep(estimated_time + 0.5)
else:
# 无液 -> 电机逆时针旋转
self.logger.info(f"[Cycle {cycle_count}] No liquid detected (RSSI={rssi}), "
f"rotating motor {motor_id} counter-clockwise {pulses} pulses")
motor.run_position(pulses=pulses, speed_rpm=speed, direction=1, absolute=False)
# 等待电机完成 (预估时间)
estimated_time = 15.0 / max(1, speed)
time.sleep(estimated_time + 0.5)
# 更新状态
last_level = current_level
# 轮询间隔
time.sleep(0.1)
finally:
# 恢复轮询线程
self.logger.info("Restarting polling thread...")
self._start_polling()
def execute_command_from_outer(self, command_dict: Dict[str, Any]) -> bool:
"""支持标准 JSON 指令调用"""
return super().execute_command_from_outer(command_dict)

View File

@@ -1,379 +0,0 @@
# -*- coding: utf-8 -*-
"""
XKC RS485 液位传感器 (Modbus RTU)
说明:
1. 遵循 Modbus-RTU 协议。
2. 数据寄存器: 0x0001 (液位状态, 1=有液, 0=无液), 0x0002 (RSSI 信号强度)。
3. 地址寄存器: 0x0004 (可读写, 范围 1-254)。
4. 波特率寄存器: 0x0005 (可写, 代码表见 change_baudrate 方法)。
"""
import struct
import threading
import time
import logging
import serial
from typing import Optional, Dict, Any, List
from unilabos.device_comms.universal_driver import UniversalDriver
class TransportManager:
"""
统一通信管理类。
仅支持 串口 (Serial/有线) 连接。
"""
def __init__(self, port: str, baudrate: int = 9600, timeout: float = 3.0, logger=None):
self.port = port
self.baudrate = baudrate
self.timeout = timeout
self.logger = logger
self.lock = threading.RLock() # 线程锁,确保多设备共用一个连接时不冲突
self.serial = None
self._connect_serial()
def _connect_serial(self):
try:
self.serial = serial.Serial(
port=self.port,
baudrate=self.baudrate,
timeout=self.timeout
)
except Exception as e:
raise ConnectionError(f"Serial open failed: {e}")
def close(self):
"""关闭连接"""
if self.serial and self.serial.is_open:
self.serial.close()
def clear_buffer(self):
"""清空缓冲区 (Thread-safe)"""
with self.lock:
if self.serial:
self.serial.reset_input_buffer()
def write(self, data: bytes):
"""发送原始字节"""
with self.lock:
if self.serial:
self.serial.write(data)
def read(self, size: int) -> bytes:
"""读取指定长度字节"""
if self.serial:
return self.serial.read(size)
return b''
class XKCSensorDriver(UniversalDriver):
"""XKC RS485 液位传感器 (Modbus RTU)"""
def __init__(self, port: str, baudrate: int = 9600, device_id: int = 6,
threshold: int = 300, timeout: float = 3.0, debug: bool = False):
super().__init__()
self.port = port
self.baudrate = baudrate
self.device_id = device_id
self.threshold = threshold
self.timeout = timeout
self.debug = debug
self.level = False
self.rssi = 0
self.status = {"level": self.level, "rssi": self.rssi}
try:
self.transport = TransportManager(port, baudrate, timeout, logger=self.logger)
self.logger.info(f"XKCSensorDriver connected to {port} (ID: {device_id})")
except Exception as e:
self.logger.error(f"Failed to connect XKCSensorDriver: {e}")
self.transport = None
# 启动背景轮询线程,确保 status 实时刷新
self._stop_event = threading.Event()
self._polling_thread = threading.Thread(
target=self._update_loop,
name=f"XKCPolling_{port}",
daemon=True
)
if self.transport:
self._polling_thread.start()
def _update_loop(self):
"""背景循环读取传感器数据"""
while not self._stop_event.is_set():
try:
self.read_level()
except Exception as e:
if self.debug:
self.logger.error(f"Polling error: {e}")
time.sleep(2.0) # 每2秒刷新一次数据
def _crc(self, data: bytes) -> bytes:
crc = 0xFFFF
for byte in data:
crc ^= byte
for _ in range(8):
if crc & 0x0001: crc = (crc >> 1) ^ 0xA001
else: crc >>= 1
return struct.pack('<H', crc)
def read_level(self) -> Optional[Dict[str, Any]]:
"""
读取液位。
返回: {'level': bool, 'rssi': int}
"""
if not self.transport:
return None
with self.transport.lock:
self.transport.clear_buffer()
# Modbus Read Registers: 01 03 00 01 00 02 CRC
payload = struct.pack('>HH', 0x0001, 0x0002)
msg = struct.pack('BB', self.device_id, 0x03) + payload
msg += self._crc(msg)
if self.debug:
self.logger.info(f"TX (ID {self.device_id}): {msg.hex().upper()}")
self.transport.write(msg)
# Read header
h = self.transport.read(3) # Addr, Func, Len
if self.debug:
self.logger.info(f"RX Header: {h.hex().upper()}")
if len(h) < 3: return None
length = h[2]
# Read body + CRC
body = self.transport.read(length + 2)
if self.debug:
self.logger.info(f"RX Body+CRC: {body.hex().upper()}")
if len(body) < length + 2:
# Firmware bug fix specific to some modules
if len(body) == 4 and length == 4:
pass
else:
return None
data = body[:-2]
# 根据手册说明:
# 寄存器 0x0001 (data[0:2]): 液位状态 (00 01 为有液, 00 00 为无液)
# 寄存器 0x0002 (data[2:4]): 信号强度 RSSI
hw_level = False
rssi = 0
if len(data) >= 4:
hw_level = ((data[0] << 8) | data[1]) == 1
rssi = (data[2] << 8) | data[3]
elif len(data) == 2:
# 兼容模式: 某些老固件可能只返回 1 个寄存器
rssi = (data[0] << 8) | data[1]
hw_level = rssi > self.threshold
else:
return None
# 最终判定: 优先使用硬件层级的 level 判定,但 RSSI 阈值逻辑作为补充/校验
# 注意: 如果用户显式设置了 THRESHOLD我们可以在逻辑中做权衡
self.level = hw_level or (rssi > self.threshold)
self.rssi = rssi
result = {
'level': self.level,
'rssi': self.rssi
}
self.status = result
return result
def wait_level(self, target_state: bool, timeout: float = 60.0) -> bool:
"""
等待液位达到目标状态 (阻塞式)
"""
self.logger.info(f"Waiting for level: {target_state}")
start_time = time.time()
while (time.time() - start_time) < timeout:
res = self.read_level()
if res and res.get('level') == target_state:
return True
time.sleep(0.5)
self.logger.warning(f"Wait level timeout ({timeout}s)")
return False
def wait_for_liquid(self, target_state: bool, timeout: float = 120.0) -> bool:
"""
实时检测电导率(RSSI)并等待用户指定的“有液”或“无液”状态。
一旦检测到符合目标状态,立即返回。
Args:
target_state: True 为“有液”, False 为“无液”
timeout: 最大等待时间(秒)
"""
state_str = "有液" if target_state else "无液"
self.logger.info(f"开始实时检测电导率,等待状态: {state_str} (超时: {timeout}s)")
start_time = time.time()
while (time.time() - start_time) < timeout:
res = self.read_level() # 内部已更新 self.level 和 self.rssi
if res:
current_level = res.get('level')
current_rssi = res.get('rssi')
if current_level == target_state:
self.logger.info(f"✅ 检测到目标状态: {state_str} (当前电导率/RSSI: {current_rssi})")
return True
if self.debug:
self.logger.debug(f"当前状态: {'有液' if current_level else '无液'}, RSSI: {current_rssi}")
time.sleep(0.2) # 高频采样
self.logger.warning(f"❌ 等待 {state_str} 状态超时 ({timeout}s)")
return False
def set_threshold(self, threshold: int):
"""设置液位判定阈值"""
self.threshold = int(threshold)
self.logger.info(f"Threshold updated to: {self.threshold}")
def change_device_id(self, new_id: int) -> bool:
"""
修改设备的 Modbus 从站地址。
寄存器: 0x0004, 功能码: 0x06
"""
if not (1 <= new_id <= 254):
self.logger.error(f"Invalid device ID: {new_id}. Must be 1-254.")
return False
self.logger.info(f"Changing device ID from {self.device_id} to {new_id}")
success = self._write_single_register(0x0004, new_id)
if success:
self.device_id = new_id # 更新内存中的地址
self.logger.info(f"Device ID update command sent successfully (target {new_id}).")
return success
def change_baudrate(self, baud_code: int) -> bool:
"""
更改通讯波特率 (寄存器: 0x0005)。
设置成功后传感器 LED 会闪烁,通常无数据返回。
波特率代码对照表 (16进制):
05: 2400
06: 4800
07: 9600 (默认)
08: 14400
09: 19200
0A: 28800
0C: 57600
0D: 115200
0E: 128000
0F: 256000
"""
self.logger.info(f"Sending baudrate change command (Code: {baud_code:02X})")
# 写入寄存器 0x0005
self._write_single_register(0x0005, baud_code)
self.logger.info("Baudrate change command executed. Device LED should flash. Please update connection settings.")
return True
def factory_reset(self) -> bool:
"""
恢复出厂设置 (通过广播地址 FF)。
设置地址为 01逻辑为向 0x0004 写入 0x0002
"""
self.logger.info("Sending factory reset command via broadcast address FF...")
# 广播指令通常无回显
self._write_single_register(0x0004, 0x0002, slave_id=0xFF)
self.logger.info("Factory reset command sent. Device address should be 01 now.")
return True
def _write_single_register(self, reg_addr: int, value: int, slave_id: Optional[int] = None) -> bool:
"""内部辅助函数: Modbus 功能码 06 写单个寄存器"""
if not self.transport: return False
target_id = slave_id if slave_id is not None else self.device_id
msg = struct.pack('BBHH', target_id, 0x06, reg_addr, value)
msg += self._crc(msg)
with self.transport.lock:
self.transport.clear_buffer()
if self.debug:
self.logger.info(f"TX Write (Reg {reg_addr:#06x}): {msg.hex().upper()}")
self.transport.write(msg)
# 广播地址、波特率修改或厂家特定指令可能无回显
if target_id == 0xFF or reg_addr == 0x0005:
time.sleep(0.5)
return True
# 等待返回 (正常应返回相同报文)
resp = self.transport.read(len(msg))
if self.debug:
self.logger.info(f"RX Write Response: {resp.hex().upper()}")
return resp == msg
def close(self):
if self.transport:
self.transport.close()
if __name__ == "__main__":
# 快速实例化测试
import logging
# 减少冗余日志,仅显示重要信息
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
# 硬件配置 (根据实际情况修改)
TEST_PORT = "/dev/tty.usbserial-3110"
SLAVE_ID = 1
THRESHOLD = 300
print("\n" + "="*50)
print(f" XKC RS485 传感器独立测试程序")
print(f" 端口: {TEST_PORT} | 地址: {SLAVE_ID} | 阈值: {THRESHOLD}")
print("="*50)
sensor = XKCSensorDriver(port=TEST_PORT, device_id=SLAVE_ID, threshold=THRESHOLD, debug=False)
try:
if sensor.transport:
print(f"\n开始实时连续采样测试 (持续 15 秒)...")
print(f"按 Ctrl+C 可提前停止\n")
start_time = time.time()
duration = 15
count = 0
while time.time() - start_time < duration:
count += 1
res = sensor.read_level()
if res:
rssi = res['rssi']
level = res['level']
status_str = "【有液】" if level else "【无液】"
# 使用 \r 实现单行刷新显示 (或者不刷,直接打印历史)
# 为了方便查看变化,我们直接打印
elapsed = time.time() - start_time
print(f" [{elapsed:4.1f}s] 采样 {count:<3}: 电导率/RSSI = {rssi:<5} | 判定结果: {status_str}")
else:
print(f" [{time.time()-start_time:4.1f}s] 采样 {count:<3}: 通信失败 (无响应)")
time.sleep(0.5) # 每秒采样 2 次
print(f"\n--- 15 秒采样测试完成 (总计 {count} 次) ---")
# [3] 测试动态修改阈值
print(f"\n[3] 动态修改阈值演示...")
new_threshold = 400
sensor.set_threshold(new_threshold)
res = sensor.read_level()
if res:
print(f" 采样 (当前阈值={new_threshold}): 电导率/RSSI = {res['rssi']:<5} | 判定结果: {'【有液】' if res['level'] else '【无液】'}")
sensor.set_threshold(THRESHOLD) # 还原
except KeyboardInterrupt:
print("\n[!] 用户中断测试")
except Exception as e:
print(f"\n[!] 测试运行出错: {e}")
finally:
sensor.close()
print("\n--- 测试程序已退出 ---\n")

View File

@@ -2,8 +2,6 @@ import time
import logging
from typing import Union, Dict, Optional
from unilabos.registry.decorators import topic_config
class VirtualMultiwayValve:
"""
@@ -43,11 +41,13 @@ class VirtualMultiwayValve:
def target_position(self) -> int:
return self._target_position
@property
@topic_config()
def current_port(self) -> str:
"""当前连接的端口名称 🔌"""
return self.port
def get_current_position(self) -> int:
"""获取当前阀门位置 📍"""
return self._current_position
def get_current_port(self) -> str:
"""获取当前连接的端口名称 🔌"""
return self._current_position
def set_position(self, command: Union[int, str]):
"""
@@ -169,14 +169,12 @@ class VirtualMultiwayValve:
self._status = "Idle"
self._valve_state = "Closed"
close_msg = f"🔒 阀门已关闭,保持在位置 {self._current_position} ({self.port})"
close_msg = f"🔒 阀门已关闭,保持在位置 {self._current_position} ({self.get_current_port()})"
self.logger.info(close_msg)
return close_msg
@property
@topic_config()
def valve_position(self) -> int:
"""阀门位置 📍"""
def get_valve_position(self) -> int:
"""获取阀门位置 - 兼容性方法 📍"""
return self._current_position
def set_valve_position(self, command: Union[int, str]):
@@ -231,16 +229,19 @@ class VirtualMultiwayValve:
self.logger.info(f"🔄 从端口 {self._current_position} 切换到泵位置...")
return self.set_to_pump_position()
@property
@topic_config()
def flow_path(self) -> str:
"""当前流路路径描述 🌊"""
def get_flow_path(self) -> str:
"""获取当前流路路径描述 🌊"""
current_port = self.get_current_port()
if self._current_position == 0:
return f"🚰 转移泵已连接 (位置 {self._current_position})"
return f"🔌 端口 {self._current_position} 已连接 ({self.current_port})"
flow_path = f"🚰 转移泵已连接 (位置 {self._current_position})"
else:
flow_path = f"🔌 端口 {self._current_position} 已连接 ({current_port})"
# 删除debug日志self.logger.debug(f"🌊 当前流路: {flow_path}")
return flow_path
def __str__(self):
current_port = self.current_port
current_port = self.get_current_port()
status_emoji = "" if self._status == "Idle" else "🔄" if self._status == "Busy" else ""
return f"🔄 VirtualMultiwayValve({status_emoji} 位置: {self._current_position}/{self.max_positions}, 端口: {current_port}, 状态: {self._status})"
@@ -252,7 +253,7 @@ if __name__ == "__main__":
print("🔄 === 虚拟九通阀门测试 === ✨")
print(f"🏠 初始状态: {valve}")
print(f"🌊 当前流路: {valve.flow_path}")
print(f"🌊 当前流路: {valve.get_flow_path()}")
# 切换到试剂瓶11号位
print(f"\n🔌 切换到1号位: {valve.set_position(1)}")

View File

@@ -3,7 +3,6 @@ import logging
import time as time_module
from typing import Dict, Any
from unilabos.registry.decorators import topic_config
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
class VirtualStirrer:
@@ -315,11 +314,9 @@ class VirtualStirrer:
def min_speed(self) -> float:
return self._min_speed
@property
@topic_config()
def device_info(self) -> Dict[str, Any]:
"""设备状态快照信息 📊"""
return {
def get_device_info(self) -> Dict[str, Any]:
"""获取设备状态信息 📊"""
info = {
"device_id": self.device_id,
"status": self.status,
"operation_mode": self.operation_mode,
@@ -328,9 +325,12 @@ class VirtualStirrer:
"is_stirring": self.is_stirring,
"remaining_time": self.remaining_time,
"max_speed": self._max_speed,
"min_speed": self._min_speed,
"min_speed": self._min_speed
}
# self.logger.debug(f"📊 设备信息: 模式={self.operation_mode}, 速度={self.current_speed} RPM, 搅拌={self.is_stirring}")
return info
def __str__(self):
status_emoji = "" if self.operation_mode == "Idle" else "🌪️" if self.operation_mode == "Stirring" else "🛑" if self.operation_mode == "Settling" else ""
return f"🌪️ VirtualStirrer({status_emoji} {self.device_id}: {self.operation_mode}, {self.current_speed} RPM)"

View File

@@ -4,7 +4,6 @@ from enum import Enum
from typing import Union, Optional
import logging
from unilabos.registry.decorators import topic_config
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
@@ -386,10 +385,8 @@ class VirtualTransferPump:
"""获取当前体积"""
return self._current_volume
@property
@topic_config()
def remaining_capacity(self) -> float:
"""剩余容量 (ml)"""
def get_remaining_capacity(self) -> float:
"""获取剩余容量"""
return self.max_volume - self._current_volume
def is_empty(self) -> bool:

View File

@@ -22,11 +22,10 @@ from threading import Lock, RLock
from typing_extensions import TypedDict
from unilabos.registry.decorators import (
device, action, ActionInputHandle, ActionOutputHandle, DataSource, topic_config, not_action, NodeType
device, action, ActionInputHandle, ActionOutputHandle, DataSource, topic_config, not_action
)
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode
from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample, ResourceTreeSet
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample
# ============ TypedDict 返回类型定义 ============
@@ -291,126 +290,6 @@ class VirtualWorkbench:
self._update_data_status(f"机械臂已释放 (完成: {task})")
self.logger.info(f"机械臂已释放 (完成: {task})")
@action(
always_free=True, node_type=NodeType.MANUAL_CONFIRM, placeholder_keys={
"assignee_user_ids": "unilabos_manual_confirm"
}, goal_default={
"timeout_seconds": 3600,
"assignee_user_ids": []
}, feedback_interval=300,
handles=[
ActionInputHandle(key="target_device", data_type="device_id",
label="目标设备", data_key="target_device", data_source=DataSource.HANDLE),
ActionInputHandle(key="resource", data_type="resource",
label="待转移资源", data_key="resource", data_source=DataSource.HANDLE),
ActionInputHandle(key="mount_resource", data_type="resource",
label="目标孔位", data_key="mount_resource", data_source=DataSource.HANDLE),
ActionInputHandle(key="collector_mass", data_type="collector_mass",
label="极流体质量", data_key="collector_mass", data_source=DataSource.HANDLE),
ActionInputHandle(key="active_material", data_type="active_material",
label="活性物质含量", data_key="active_material", data_source=DataSource.HANDLE),
ActionInputHandle(key="capacity", data_type="capacity",
label="克容量", data_key="capacity", data_source=DataSource.HANDLE),
ActionInputHandle(key="battery_system", data_type="battery_system",
label="电池体系", data_key="battery_system", data_source=DataSource.HANDLE),
# transfer使用
ActionOutputHandle(key="target_device", data_type="device_id",
label="目标设备", data_key="target_device", data_source=DataSource.EXECUTOR),
ActionOutputHandle(key="resource", data_type="resource",
label="待转移资源", data_key="resource.@flatten", data_source=DataSource.EXECUTOR),
ActionOutputHandle(key="mount_resource", data_type="resource",
label="目标孔位", data_key="mount_resource.@flatten", data_source=DataSource.EXECUTOR),
# test使用
ActionOutputHandle(key="collector_mass", data_type="collector_mass",
label="极流体质量", data_key="collector_mass", data_source=DataSource.EXECUTOR),
ActionOutputHandle(key="active_material", data_type="active_material",
label="活性物质含量", data_key="active_material", data_source=DataSource.EXECUTOR),
ActionOutputHandle(key="capacity", data_type="capacity",
label="克容量", data_key="capacity", data_source=DataSource.EXECUTOR),
ActionOutputHandle(key="battery_system", data_type="battery_system",
label="电池体系", data_key="battery_system", data_source=DataSource.EXECUTOR),
]
)
def manual_confirm(
self,
resource: List[ResourceSlot],
target_device: DeviceSlot,
mount_resource: List[ResourceSlot],
collector_mass: List[float],
active_material: List[float],
capacity: List[float],
battery_system: List[str],
timeout_seconds: int,
assignee_user_ids: list[str],
**kwargs
) -> dict:
"""
timeout_seconds: 超时时间默认3600秒
collector_mass: 极流体质量
active_material: 活性物质含量
capacity: 克容量mAh/g
battery_system: 电池体系
修改的结果无效,是只读的
"""
resource = ResourceTreeSet.from_plr_resources(resource).dump()
mount_resource = ResourceTreeSet.from_plr_resources(mount_resource).dump()
kwargs.update(locals())
kwargs.pop("kwargs")
kwargs.pop("self")
return kwargs
@action(
description="转移物料",
handles=[
ActionInputHandle(key="target_device", data_type="device_id",
label="目标设备", data_key="target_device", data_source=DataSource.HANDLE),
ActionInputHandle(key="resource", data_type="resource",
label="待转移资源", data_key="resource", data_source=DataSource.HANDLE),
ActionInputHandle(key="mount_resource", data_type="resource",
label="目标孔位", data_key="mount_resource", data_source=DataSource.HANDLE),
]
)
async def transfer(self, resource: List[ResourceSlot], target_device: DeviceSlot, mount_resource: List[ResourceSlot]):
future = ROS2DeviceNode.run_async_func(self._ros_node.transfer_resource_to_another, True,
**{
"plr_resources": resource,
"target_device_id": target_device,
"target_resources": mount_resource,
"sites": [None] * len(mount_resource),
})
result = await future
return result
@action(
description="扣电测试启动",
handles=[
ActionInputHandle(key="resource", data_type="resource",
label="待转移资源", data_key="resource", data_source=DataSource.HANDLE),
ActionInputHandle(key="mount_resource", data_type="resource",
label="目标孔位", data_key="mount_resource", data_source=DataSource.HANDLE),
ActionInputHandle(key="collector_mass", data_type="collector_mass",
label="极流体质量", data_key="collector_mass", data_source=DataSource.HANDLE),
ActionInputHandle(key="active_material", data_type="active_material",
label="活性物质含量", data_key="active_material", data_source=DataSource.HANDLE),
ActionInputHandle(key="capacity", data_type="capacity",
label="克容量", data_key="capacity", data_source=DataSource.HANDLE),
ActionInputHandle(key="battery_system", data_type="battery_system",
label="电池体系", data_key="battery_system", data_source=DataSource.HANDLE),
]
)
async def test(
self, resource: List[ResourceSlot], mount_resource: List[ResourceSlot], collector_mass: List[float], active_material: List[float], capacity: List[float], battery_system: list[str]
):
print(resource)
print(mount_resource)
print(collector_mass)
print(active_material)
print(capacity)
print(battery_system)
@action(
auto_prefix=True,
description="批量准备物料 - 虚拟起始节点, 生成A1-A5物料, 输出5个handle供后续节点使用",

View File

@@ -258,7 +258,7 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
logger.info(f"[同步→Bioyond] 物料不存在于 Bioyond将创建新物料并入库")
# 第1步从配置中获取仓库配置
warehouse_mapping = self.workstation.bioyond_config.get("warehouse_mapping", {})
warehouse_mapping = self.bioyond_config.get("warehouse_mapping", {})
# 确定目标仓库名称
parent_name = None

View File

@@ -0,0 +1,634 @@
# 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

View File

@@ -0,0 +1,9 @@
"""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"]

View File

@@ -0,0 +1,66 @@
"""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]

View File

@@ -0,0 +1,626 @@
"""约束体系:硬约束 / 软约束定义与统一评估。
硬约束违反 → 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

View File

@@ -0,0 +1,302 @@
"""双源设备目录:从 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

View File

@@ -0,0 +1,559 @@
"""从 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

View File

@@ -0,0 +1,187 @@
"""
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()

View File

@@ -0,0 +1,373 @@
"""意图解释器:将语义化意图翻译为 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

View File

@@ -0,0 +1,41 @@
"""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 如果可达
"""
...

View File

@@ -0,0 +1,49 @@
"""解析实验室平面图 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)

View File

@@ -0,0 +1,187 @@
# 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 35 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
```

View File

@@ -0,0 +1,312 @@
# 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.

View File

@@ -0,0 +1,110 @@
"""Mock 检测器:无 ROS 依赖的简化碰撞与可达性检测。
碰撞检测基于 OBB SATO(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.35mreach ≈ 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

View File

@@ -0,0 +1,96 @@
"""数据模型定义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 = "" # 可选的自然语言描述(用于审计/调试)

View File

@@ -0,0 +1,257 @@
"""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

View File

@@ -0,0 +1,144 @@
"""初始布局生成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

View File

@@ -0,0 +1,29 @@
[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"]

View File

@@ -0,0 +1,433 @@
"""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()

View File

@@ -0,0 +1,331 @@
"""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)
]

View File

@@ -0,0 +1,742 @@
"""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-processingopt-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

View File

@@ -0,0 +1,7 @@
[
{"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"}
]

View File

@@ -0,0 +1,7 @@
{
"width": 5.0,
"depth": 4.0,
"obstacles": [
{"x": 2.5, "y": 0.0, "width": 0.1, "depth": 0.5}
]
}

View File

@@ -0,0 +1,241 @@
"""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

View File

@@ -0,0 +1,435 @@
"""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 默认为 0snap_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

View File

@@ -0,0 +1,505 @@
"""约束体系测试。"""
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.5mtheta=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

View File

@@ -0,0 +1,234 @@
"""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"

View File

@@ -0,0 +1,263 @@
"""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

View File

@@ -0,0 +1,210 @@
"""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

View File

@@ -0,0 +1,134 @@
"""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

View File

@@ -0,0 +1,277 @@
"""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

View File

@@ -0,0 +1,138 @@
"""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)

View File

@@ -0,0 +1,156 @@
"""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

View File

@@ -0,0 +1,430 @@
"""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)

Some files were not shown because too many files have changed in this diff Show More