Compare commits

..

10 Commits

Author SHA1 Message Date
ZiWei
e4e4bfbe20 Merge branch 'dev' into feature/organic-extraction 2026-02-04 15:47:47 +08:00
ZiWei
64c748d921 Merge branch 'vibe/dev' into feature/organic-extraction 2026-02-03 10:39:44 +08:00
ZiWei
15ff0e9d30 feat: add Bioyond deck imports to resource registration 2026-02-03 10:28:51 +08:00
ZiWei
f8a52860ad Add BIOYOND deck imports and update JSON configurations with new UUIDs for various components 2026-02-03 10:25:47 +08:00
Xuwznln
e30c01d54e Dev backward (#228)
* Workbench example, adjust log level, and ci check (#220)

* TestLatency Return Value Example & gitignore update

* Adjust log level & Add workbench virtual example & Add not action decorator & Add check_mode &

* Add CI Check

* CI Check Fix 1

* CI Check Fix 2

* CI Check Fix 3

* CI Check Fix 4

* CI Check Fix 5

* Upgrade to py 3.11.14; ros 0.7; unilabos 0.10.16

* Update to ROS2 Humble 0.7

* Fix Build 1

* Fix Build 2

* Fix Build 3

* Fix Build 4

* Fix Build 5

* Fix Build 6

* Fix Build 7

* ci(deps): bump actions/configure-pages from 4 to 5 (#222)

Bumps [actions/configure-pages](https://github.com/actions/configure-pages) from 4 to 5.
- [Release notes](https://github.com/actions/configure-pages/releases)
- [Commits](https://github.com/actions/configure-pages/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/configure-pages
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* ci(deps): bump actions/upload-artifact from 4 to 6 (#224)

Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* ci(deps): bump actions/upload-pages-artifact from 3 to 4 (#225)

Bumps [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) from 3 to 4.
- [Release notes](https://github.com/actions/upload-pages-artifact/releases)
- [Commits](https://github.com/actions/upload-pages-artifact/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/upload-pages-artifact
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* ci(deps): bump actions/checkout from 4 to 6 (#223)

Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Fix Build 8

* Fix Build 9

* Fix Build 10

* Fix Build 11

* Fix Build 12

* Fix Build 13

* v0.10.17

(cherry picked from commit 176de521b4)

* CI Check use production mode

* Fix OT2 & ReAdd Virtual Devices

* add msg goal

* transfer liquid handles

* gather query

* add unilabos_class

* Support root node change pos

* save class name when deserialize & protocol execute test

* fix upload workflow json

* workflow upload & set liquid fix & add set liquid with plate

* speed up registry load

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: hanhua@dp.tech <2509856570@qq.com>
2026-02-02 23:57:13 +08:00
ZiWei
37ec49f318 Refactor Bioyond resource handling: update warehouse mapping retrieval, add TipBox support, and improve liquid tracking logic. Migrate TipBox creation to bottle_carriers.py for better structure. 2026-01-29 16:31:14 +08:00
ZiWei
6bf57f18c1 Collaboration With Cursor 2026-01-29 11:29:38 +08:00
ZiWei
c4a3be1498 feat: enhance separation_step logic with polling thread management and error handling 2026-01-27 12:37:09 +08:00
ZiWei
e11070315d feat: add separation_step with sensor-motor linkage 2026-01-26 23:34:47 +08:00
ZiWei
50ebcad9d7 feat: add ZDT_X42 motor and XKC sensor drivers 2026-01-22 15:07:32 +08:00
255 changed files with 29184 additions and 66571 deletions

View File

@@ -3,7 +3,7 @@
package:
name: unilabos
version: 0.11.1
version: 0.10.17
source:
path: ../../unilabos
@@ -46,15 +46,13 @@ requirements:
- jinja2
- requests
- uvicorn
- if: not osx
then:
- opcua
- opcua # [not osx]
- pyserial
- pandas
- pymodbus
- matplotlib
- pylibftdi
- uni-lab::unilabos-env ==0.11.1
- uni-lab::unilabos-env ==0.10.17
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.17
build:
noarch: generic

View File

@@ -3,7 +3,7 @@
package:
name: unilabos-full
version: 0.11.1
version: 0.10.17
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.17
# Documentation tools
- sphinx
- sphinx_rtd_theme

View File

@@ -1,9 +0,0 @@
@echo off
setlocal enabledelayedexpansion
REM upgrade pip
"%PREFIX%\python.exe" -m pip install --upgrade pip
REM install extra deps
"%PREFIX%\python.exe" -m pip install paho-mqtt opentrons_shared_data
"%PREFIX%\python.exe" -m pip install git+https://github.com/Xuwznln/pylabrobot.git

View File

@@ -1,9 +0,0 @@
#!/usr/bin/env bash
set -euxo pipefail
# make sure pip is available
"$PREFIX/bin/python" -m pip install --upgrade pip
# install extra deps
"$PREFIX/bin/python" -m pip install paho-mqtt opentrons_shared_data
"$PREFIX/bin/python" -m pip install git+https://github.com/Xuwznln/pylabrobot.git

View File

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

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

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

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

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

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

@@ -1,196 +0,0 @@
---
name: add-device
description: Guide for adding new devices to Uni-Lab-OS (接入新设备). Uses @device decorator + AST auto-scanning instead of manual YAML. Walks through device category, communication protocol, driver creation with decorators, and graph file setup. Use when the user wants to add/integrate a new device, create a device driver, write a device class, or mentions 接入设备/添加设备/设备驱动/物模型.
---
# 添加新设备到 Uni-Lab-OS
**第一步:** 使用 Read 工具读取 `docs/ai_guides/add_device.md`,获取完整的设备接入指南。
该指南包含设备类别(物模型)列表、通信协议模板、常见错误检查清单等。搜索 `unilabos/devices/` 获取已有设备的实现参考。
---
## 装饰器参考
### @device — 设备类装饰器
```python
from unilabos.registry.decorators import device
# 单设备
@device(
id="my_device.vendor", # 注册表唯一标识(必填)
category=["temperature"], # 分类标签列表(必填)
description="设备描述", # 设备描述
display_name="显示名称", # UI 显示名称(默认用 id
icon="DeviceIcon.webp", # 图标文件名
version="1.0.0", # 版本号
device_type="python", # "python" 或 "ros2"
handles=[...], # 端口列表InputHandle / OutputHandle
model={...}, # 3D 模型配置
hardware_interface=HardwareInterface(...), # 硬件通信接口
)
# 多设备(同一个类注册多个设备 ID各自有不同的 handles 等配置)
@device(
ids=["pump.vendor.model_A", "pump.vendor.model_B"],
id_meta={
"pump.vendor.model_A": {"handles": [...], "description": "型号 A"},
"pump.vendor.model_B": {"handles": [...], "description": "型号 B"},
},
category=["pump_and_valve"],
)
```
### @action — 动作方法装饰器
```python
from unilabos.registry.decorators import action
@action # 无参:注册为 UniLabJsonCommand 动作
@action() # 同上
@action(description="执行操作") # 带描述
@action(
action_type=HeatChill, # 指定 ROS Action 消息类型
goal={"temperature": "temp"}, # Goal 字段映射
feedback={}, # Feedback 字段映射
result={}, # Result 字段映射
handles=[...], # 动作级别端口
goal_default={"temp": 25.0}, # Goal 默认值
placeholder_keys={...}, # 参数占位符
always_free=True, # 不受排队限制
auto_prefix=True, # 强制使用 auto- 前缀
parent=True, # 从父类 MRO 获取参数签名
)
```
**自动识别规则:**
-`@action` 的公开方法 → 注册为动作(方法名即动作名)
- **不带 `@action` 的公开方法** → 自动注册为 `auto-{方法名}` 动作
- `_` 开头的方法 → 不扫描
- `@not_action` 标记的方法 → 排除
### 参数文档 → JSON Schema 元数据
`__init__` 和 action 方法 docstring 的 `Args:` 小节里,使用以下格式生成入参 schema 的显示信息:
```python
"""
Args:
param[显示名称]: 参数说明,会写入 JSON Schema 的 description。
"""
```
- `param[显示名称]` 的显示名称会写入 goal property 的 `title`
- `:` 后面的说明会写入 goal property 的 `description`
- 如果只写 `param: 参数说明``title` 会兜底为字段名,`description` 使用参数说明。
- 如果没有写参数文档,生成器也会兜底补齐 `title=<字段名>``description=""`,但新设备应优先写清楚显示名和说明。
### @topic_config — 状态属性配置
```python
from unilabos.registry.decorators import topic_config
@property
@topic_config(
period=5.0, # 发布周期(秒),默认 5.0
print_publish=False, # 是否打印发布日志
qos=10, # QoS 深度,默认 10
name="custom_name", # 自定义发布名称(默认用属性名)
)
def temperature(self) -> float:
return self.data.get("temperature", 0.0)
```
### 辅助装饰器
```python
from unilabos.registry.decorators import not_action, always_free
@not_action # 标记为非动作post_init、辅助方法等
@always_free # 标记为不受排队限制(查询类操作)
```
---
## 设备模板
```python
import logging
from typing import Any, Dict, Optional
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
from unilabos.registry.decorators import action, device, not_action, topic_config
@device(
id="my_device",
category=["my_category"],
description="设备描述",
display_name="设备显示名",
)
class MyDevice:
"""设备类说明。"""
_ros_node: BaseROS2DeviceNode
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
"""
初始化设备。
Args:
device_id[设备ID]: 设备实例 ID默认使用 my_device。
config[设备配置]: 设备启动配置。
"""
self.device_id = device_id or "my_device"
self.config = config or {}
self.logger = logging.getLogger(f"MyDevice.{self.device_id}")
self.data: Dict[str, Any] = {"status": "Idle"}
@not_action
def post_init(self, ros_node: BaseROS2DeviceNode) -> None:
self._ros_node = ros_node
@action
async def initialize(self) -> bool:
self.data["status"] = "Ready"
return True
@action
async def cleanup(self) -> bool:
self.data["status"] = "Offline"
return True
@action(description="执行操作")
def my_action(self, param: float = 0.0, name: str = "") -> Dict[str, Any]:
"""
带 @action 装饰器 → 注册为 'my_action' 动作。
Args:
param[操作数值]: 操作使用的数值参数。
name[操作名称]: 操作名称或备注。
"""
return {"success": True}
def get_info(self) -> Dict[str, Any]:
"""无 @action → 自动注册为 'auto-get_info' 动作"""
return {"device_id": self.device_id}
@property
@topic_config()
def status(self) -> str:
return self.data.get("status", "Idle")
@property
@topic_config(period=2.0)
def temperature(self) -> float:
return self.data.get("temperature", 0.0)
```
### 要点
- `_ros_node: BaseROS2DeviceNode` 类型标注放在类体顶部
- `__init__` 签名固定为 `(self, device_id=None, config=None, **kwargs)`
- `post_init``@not_action` 标记,参数类型标注为 `BaseROS2DeviceNode`
- 运行时状态存储在 `self.data` 字典中
- 设备文件放在 `unilabos/devices/<category>/` 目录下

View File

@@ -1,351 +0,0 @@
---
name: add-resource
description: Guide for adding new resources (materials, bottles, carriers, decks, warehouses) to Uni-Lab-OS (添加新物料/资源). Uses @resource decorator for AST auto-scanning. Covers Bottle, Carrier, Deck, WareHouse definitions. Use when the user wants to add resources, define materials, create a deck layout, add bottles/carriers/plates, or mentions 物料/资源/resource/bottle/carrier/deck/plate/warehouse.
---
# 添加新物料资源
Uni-Lab-OS 的资源体系基于 PyLabRobot通过扩展实现 Bottle、Carrier、WareHouse、Deck 等实验室物料管理。使用 `@resource` 装饰器注册AST 自动扫描生成注册表条目。
---
## 资源类型
| 类型 | 基类 | 用途 | 示例 |
|------|------|------|------|
| **Bottle** | `Well` (PyLabRobot) | 单个容器(瓶、小瓶、烧杯、反应器) | 试剂瓶、粉末瓶 |
| **BottleCarrier** | `ItemizedCarrier` | 多槽位载架(放多个 Bottle | 6 位试剂架、枪头盒 |
| **WareHouse** | `ItemizedCarrier` | 堆栈/仓库(放多个 Carrier | 4x4 堆栈 |
| **Deck** | `Deck` (PyLabRobot) | 工作站台面(放多个 WareHouse | 反应站 Deck |
**层级关系:** `Deck``WareHouse``BottleCarrier``Bottle`
WareHouse 本质上和 Site 是同一概念 — 都是定义一组固定的放置位slot只不过 WareHouse 多嵌套了一层 Deck。两者都需要开发者根据实际物理尺寸自行计算各 slot 的偏移坐标。
---
## @resource 装饰器
```python
from unilabos.registry.decorators import resource
@resource(
id="my_resource_id", # 注册表唯一标识(必填)
category=["bottles"], # 分类标签列表(必填)
description="资源描述",
icon="", # 图标
version="1.0.0",
handles=[...], # 端口列表InputHandle / OutputHandle
model={...}, # 3D 模型配置
class_type="pylabrobot", # "python" / "pylabrobot" / "unilabos"
)
```
---
## 创建规范
### 命名规则
1. **`name` 参数作为前缀**:所有工厂函数必须接受 `name: str` 参数,创建子物料时以 `name` 作为前缀,确保实例名在运行时全局唯一
2. **Bottle 命名约定**:试剂瓶-Bottle烧杯-Beaker烧瓶-Flask小瓶-Vial
3. **函数名 = `@resource(id=...)`**:工厂函数名与注册表 id 保持一致
### 子物料命名示例
```python
# Carrier 内部的 sites 用 name 前缀
for k, v in sites.items():
v.name = f"{name}_{v.name}" # "堆栈1左_A01", "堆栈1左_B02" ...
# Carrier 中放置 Bottle 时用 name 前缀
carrier[0] = My_Reagent_Bottle(f"{name}_flask_1") # "堆栈1左_flask_1"
carrier[i] = My_Solid_Vial(f"{name}_vial_{ordering[i]}") # "堆栈1左_vial_A1"
# create_homogeneous_resources 使用 name_prefix
sites=create_homogeneous_resources(
klass=ResourceHolder,
locations=[...],
name_prefix=name, # 自动生成 "{name}_0", "{name}_1" ...
)
# Deck setup 中用仓库名称作为 name 传入
self.warehouses = {
"堆栈1左": my_warehouse_4x4("堆栈1左"), # WareHouse.name = "堆栈1左"
"试剂堆栈": my_reagent_stack("试剂堆栈"), # WareHouse.name = "试剂堆栈"
}
```
### 其他规范
- **max_volume 单位为 μL**500mL = 500000
- **尺寸单位为 mm**`diameter`, `height`, `size_x/y/z`, `dx/dy/dz`
- **BottleCarrier 必须设置 `num_items_x/y/z`**:用于前端渲染布局
- **Deck 的 `__init__` 必须接受 `setup=False`**:图文件中 `config.setup=true` 触发 `setup()`
- **按项目分组文件**:同一工作站的资源放在 `unilabos/resources/<project>/`
- **`__init__` 必须接受 `serialize()` 输出的所有字段**`serialize()` 输出会作为 `config` 回传到 `__init__`,因此必须通过显式参数或 `**kwargs` 接受,否则反序列化会报错
- **持久化运行时状态用 `serialize_state()`**:通过 `_unilabos_state` 字典存储可变信息(如物料内容、液体量),只存 JSON 可序列化的基本类型
---
## 资源模板
### Bottle
```python
from unilabos.registry.decorators import resource
from unilabos.resources.itemized_carrier import Bottle
@resource(id="My_Reagent_Bottle", category=["bottles"], description="我的试剂瓶")
def My_Reagent_Bottle(
name: str,
diameter: float = 70.0,
height: float = 120.0,
max_volume: float = 500000.0,
barcode: str = None,
) -> Bottle:
return Bottle(
name=name,
diameter=diameter,
height=height,
max_volume=max_volume,
barcode=barcode,
model="My_Reagent_Bottle",
)
```
**Bottle 参数:**
- `name`: 实例名称(运行时唯一,由上层 Carrier 以前缀方式传入)
- `diameter`: 瓶体直径 (mm)
- `height`: 瓶体高度 (mm)
- `max_volume`: 最大容积(**μL**500mL = 500000
- `barcode`: 条形码(可选)
### BottleCarrier
```python
from pylabrobot.resources import ResourceHolder
from pylabrobot.resources.carrier import create_ordered_items_2d
from unilabos.resources.itemized_carrier import BottleCarrier
from unilabos.registry.decorators import resource
@resource(id="My_6SlotCarrier", category=["bottle_carriers"], description="六槽位载架")
def My_6SlotCarrier(name: str) -> BottleCarrier:
sites = create_ordered_items_2d(
klass=ResourceHolder,
num_items_x=3, num_items_y=2,
dx=10.0, dy=10.0, dz=5.0,
item_dx=42.0, item_dy=35.0,
size_x=20.0, size_y=20.0, size_z=50.0,
)
# 子 site 用 name 作为前缀
for k, v in sites.items():
v.name = f"{name}_{v.name}"
carrier = BottleCarrier(
name=name, size_x=146.0, size_y=80.0, size_z=55.0,
sites=sites, model="My_6SlotCarrier",
)
carrier.num_items_x = 3
carrier.num_items_y = 2
carrier.num_items_z = 1
# 放置 Bottle 时用 name 作为前缀
ordering = ["A1", "B1", "A2", "B2", "A3", "B3"]
for i in range(6):
carrier[i] = My_Reagent_Bottle(f"{name}_vial_{ordering[i]}")
return carrier
```
### WareHouse / Deck 放置位
WareHouse 和 Site 本质上是同一概念都是定义一组固定放置位slot根据物理尺寸自行批量计算偏移坐标。WareHouse 只是多嵌套了一层 Deck 而已。推荐开发者直接根据实物测量数据计算各 slot 偏移量。
#### WareHouse使用 warehouse_factory
```python
from unilabos.resources.warehouse import warehouse_factory
from unilabos.registry.decorators import resource
@resource(id="my_warehouse_4x4", category=["warehouse"], description="4x4 堆栈仓库")
def my_warehouse_4x4(name: str) -> "WareHouse":
return warehouse_factory(
name=name,
num_items_x=4, num_items_y=4, num_items_z=1,
dx=10.0, dy=10.0, dz=10.0, # 第一个 slot 的起始偏移
item_dx=147.0, item_dy=106.0, item_dz=130.0, # slot 间距
resource_size_x=127.0, resource_size_y=85.0, resource_size_z=100.0, # slot 尺寸
model="my_warehouse_4x4",
col_offset=0, # 列标签起始偏移0 → A01, 4 → A05
layout="row-major", # "row-major" 行优先 / "col-major" 列优先 / "vertical-col-major" 竖向
)
```
`warehouse_factory` 参数说明:
- `dx/dy/dz`:第一个 slot 相对 WareHouse 原点的偏移mm
- `item_dx/item_dy/item_dz`:相邻 slot 间距mm需根据实际物理间距测量
- `resource_size_x/y/z`:每个 slot 的可放置区域尺寸
- `layout`:影响 slot 标签和坐标映射
- `"row-major"`A01,A02,...,B01,B02,...(行优先,适合横向排列)
- `"col-major"`A01,B01,...,A02,B02,...(列优先)
- `"vertical-col-major"`竖向排列y 坐标反向
#### Deck 组装 WareHouse
Deck 通过 `setup()` 将多个 WareHouse 放置到指定坐标:
```python
from pylabrobot.resources import Deck, Coordinate
from unilabos.registry.decorators import resource
@resource(id="MyStation_Deck", category=["deck"], description="我的工作站 Deck")
class MyStation_Deck(Deck):
def __init__(self, name="MyStation_Deck", size_x=2700.0, size_y=1080.0, size_z=1500.0,
category="deck", setup=False, **kwargs) -> None:
super().__init__(name=name, size_x=size_x, size_y=size_y, size_z=size_z)
if setup:
self.setup()
def setup(self) -> None:
self.warehouses = {
"堆栈1左": my_warehouse_4x4("堆栈1左"),
"堆栈1右": my_warehouse_4x4("堆栈1右"),
}
self.warehouse_locations = {
"堆栈1左": Coordinate(-200.0, 400.0, 0.0), # 自行测量计算
"堆栈1右": Coordinate(2350.0, 400.0, 0.0),
}
for wh_name, wh in self.warehouses.items():
self.assign_child_resource(wh, location=self.warehouse_locations[wh_name])
```
#### Site 模式(前端定向放置)
适用于有固定孔位/槽位的设备(如移液站 PRCXI 9300Deck 通过 `sites` 列表定义前端展示的放置位,前端据此渲染可拖拽的孔位布局:
```python
import collections
from typing import Any, Dict, List, Optional
from pylabrobot.resources import Deck, Resource, Coordinate
from unilabos.registry.decorators import resource
@resource(id="MyLabDeck", category=["deck"], description="带 Site 定向放置的 Deck")
class MyLabDeck(Deck):
# 根据设备台面实测批量计算各 slot 坐标偏移
_DEFAULT_SITE_POSITIONS = [
(0, 0, 0), (138, 0, 0), (276, 0, 0), (414, 0, 0), # T1-T4
(0, 96, 0), (138, 96, 0), (276, 96, 0), (414, 96, 0), # T5-T8
]
_DEFAULT_SITE_SIZE = {"width": 128.0, "height": 86.0, "depth": 0}
_DEFAULT_CONTENT_TYPE = ["plate", "tip_rack", "tube_rack", "adaptor"]
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
sites: Optional[List[Dict[str, Any]]] = None, **kwargs):
super().__init__(size_x, size_y, size_z, name)
if sites is not None:
self.sites = [dict(s) for s in sites]
else:
self.sites = []
for i, (x, y, z) in enumerate(self._DEFAULT_SITE_POSITIONS):
self.sites.append({
"label": f"T{i + 1}", # 前端显示的槽位标签
"visible": True, # 是否在前端可见
"position": {"x": x, "y": y, "z": z}, # 槽位物理坐标
"size": dict(self._DEFAULT_SITE_SIZE), # 槽位尺寸
"content_type": list(self._DEFAULT_CONTENT_TYPE), # 允许放入的物料类型
})
self._ordering = collections.OrderedDict(
(site["label"], None) for site in self.sites
)
def assign_child_resource(self, resource: Resource,
location: Optional[Coordinate] = None,
reassign: bool = True,
spot: Optional[int] = None):
idx = spot
if spot is None:
for i, site in enumerate(self.sites):
if site.get("label") == resource.name:
idx = i
break
if idx is None:
for i in range(len(self.sites)):
if self._get_site_resource(i) is None:
idx = i
break
if idx is None:
raise ValueError(f"No available site for '{resource.name}'")
loc = Coordinate(**self.sites[idx]["position"])
super().assign_child_resource(resource, location=loc, reassign=reassign)
def serialize(self) -> dict:
data = super().serialize()
sites_out = []
for i, site in enumerate(self.sites):
occupied = self._get_site_resource(i)
sites_out.append({
"label": site["label"],
"visible": site.get("visible", True),
"occupied_by": occupied.name if occupied else None,
"position": site["position"],
"size": site["size"],
"content_type": site["content_type"],
})
data["sites"] = sites_out
return data
```
**Site 字段说明:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `label` | str | 槽位标签(如 `"T1"`),前端显示名称,也用于匹配 resource.name |
| `visible` | bool | 是否在前端可见 |
| `position` | dict | 物理坐标 `{x, y, z}`mm需自行测量计算偏移 |
| `size` | dict | 槽位尺寸 `{width, height, depth}`mm |
| `content_type` | list | 允许放入的物料类型,如 `["plate", "tip_rack", "tube_rack", "adaptor"]` |
**参考实现:** `unilabos/devices/liquid_handling/prcxi/prcxi.py` 中的 `PRCXI9300Deck`4x4 共 16 个 site
---
## 文件位置
```
unilabos/resources/
├── <project>/ # 按项目分组
│ ├── bottles.py # Bottle 工厂函数
│ ├── bottle_carriers.py # Carrier 工厂函数
│ ├── warehouses.py # WareHouse 工厂函数
│ └── decks.py # Deck 类定义
```
---
## 验证
```bash
# 资源可导入
python -c "from unilabos.resources.my_project.bottles import My_Reagent_Bottle; print(My_Reagent_Bottle('test'))"
# 启动测试AST 自动扫描)
unilab -g <graph>.json
```
仅在以下情况仍需 YAML第三方库资源如 pylabrobot 内置资源,无 `@resource` 装饰器)。
---
## 关键路径
| 内容 | 路径 |
|------|------|
| Bottle/Carrier 基类 | `unilabos/resources/itemized_carrier.py` |
| WareHouse 基类 + 工厂 | `unilabos/resources/warehouse.py` |
| PLR 注册 | `unilabos/resources/plr_additional_res_reg.py` |
| 装饰器定义 | `unilabos/registry/decorators.py` |

View File

@@ -1,292 +0,0 @@
# 资源高级参考
本文件是 SKILL.md 的补充,包含类继承体系、序列化/反序列化、Bioyond 物料同步、非瓶类资源和仓库工厂模式。Agent 在需要实现这些功能时按需阅读。
---
## 1. 类继承体系
```
PyLabRobot
├── Resource (PLR 基类)
│ ├── Well
│ │ └── Bottle (unilabos) → 瓶/小瓶/烧杯/反应器
│ ├── Deck
│ │ └── 自定义 Deck 类 (unilabos) → 工作站台面
│ ├── ResourceHolder → 槽位占位符
│ └── Container
│ └── Battery (unilabos) → 组装好的电池
├── ItemizedCarrier (unilabos, 继承 Resource)
│ ├── BottleCarrier (unilabos) → 瓶载架
│ └── WareHouse (unilabos) → 堆栈仓库
├── ItemizedResource (PLR)
│ └── MagazineHolder (unilabos) → 子弹夹载架
└── ResourceStack (PLR)
└── Magazine (unilabos) → 子弹夹洞位
```
### Bottle 类细节
```python
class Bottle(Well):
def __init__(self, name, diameter, height, max_volume,
size_x=0.0, size_y=0.0, size_z=0.0,
barcode=None, category="container", model=None, **kwargs):
super().__init__(
name=name,
size_x=diameter, # PLR 用 diameter 作为 size_x/size_y
size_y=diameter,
size_z=height, # PLR 用 height 作为 size_z
max_volume=max_volume,
category=category,
model=model,
bottom_type="flat",
cross_section_type="circle"
)
```
注意 `size_x = size_y = diameter``size_z = height`
### ItemizedCarrier 核心方法
| 方法 | 说明 |
|------|------|
| `__getitem__(identifier)` | 通过索引或 Excel 标识(如 `"A01"`)访问槽位 |
| `__setitem__(identifier, resource)` | 向槽位放入资源 |
| `get_child_identifier(child)` | 获取子资源的标识符 |
| `capacity` | 总槽位数 |
| `sites` | 所有槽位字典 |
---
## 2. 序列化与反序列化
### PLR ↔ UniLab 转换
| 函数 | 位置 | 方向 |
|------|------|------|
| `ResourceTreeSet.from_plr_resources(resources)` | `resource_tracker.py` | PLR → UniLab |
| `ResourceTreeSet.to_plr_resources()` | `resource_tracker.py` | UniLab → PLR |
### `from_plr_resources` 流程
```
PLR Resource
↓ build_uuid_mapping (递归生成 UUID)
↓ resource.serialize() → dict
↓ resource.serialize_all_state() → states
↓ resource_plr_inner (递归构建 ResourceDictInstance)
ResourceTreeSet
```
关键:每个 PLR 资源通过 `unilabos_uuid` 属性携带 UUID`unilabos_extra` 携带扩展数据(如 `class` 名)。
### `to_plr_resources` 流程
```
ResourceTreeSet
↓ collect_node_data (收集 UUID、状态、扩展数据)
↓ node_to_plr_dict (转为 PLR 字典格式)
↓ find_subclass(type_name, PLRResource) (查找 PLR 子类)
↓ sub_cls.deserialize(plr_dict) (反序列化)
↓ loop_set_uuid, loop_set_extra (递归设置 UUID 和扩展)
PLR Resource
```
### Bottle 序列化
```python
class Bottle(Well):
def serialize(self) -> dict:
data = super().serialize()
return {**data, "diameter": self.diameter, "height": self.height}
@classmethod
def deserialize(cls, data: dict, allow_marshal=False):
barcode_data = data.pop("barcode", None)
instance = super().deserialize(data, allow_marshal=allow_marshal)
if barcode_data and isinstance(barcode_data, str):
instance.barcode = barcode_data
return instance
```
---
## 3. Bioyond 物料同步
### 双向转换函数
| 函数 | 位置 | 方向 |
|------|------|------|
| `resource_bioyond_to_plr(materials, type_mapping, deck)` | `graphio.py` | Bioyond → PLR |
| `resource_plr_to_bioyond(resources, type_mapping, warehouse_mapping)` | `graphio.py` | PLR → Bioyond |
### `resource_bioyond_to_plr` 流程
```
Bioyond 物料列表
↓ reverse_type_mapping: {typeName → (model, UUID)}
↓ 对每个物料:
typeName → 查映射 → model (如 "BIOYOND_PolymerStation_Reactor")
initialize_resource({"name": unique_name, "class": model})
↓ 设置 unilabos_extra (material_bioyond_id, material_bioyond_name 等)
↓ 处理 detail (子物料/坐标)
↓ 按 locationName 放入 deck.warehouses 对应槽位
PLR 资源列表
```
### `resource_plr_to_bioyond` 流程
```
PLR 资源列表
↓ 遍历每个资源:
载架(capacity > 1): 生成 details 子物料 + 坐标
单瓶: 直接映射
↓ type_mapping 查找 typeId
↓ warehouse_mapping 查找位置 UUID
↓ 组装 Bioyond 格式 (name, typeName, typeId, quantity, Parameters, locations)
Bioyond 物料列表
```
### BioyondResourceSynchronizer
工作站通过 `ResourceSynchronizer` 自动同步物料:
```python
class BioyondResourceSynchronizer(ResourceSynchronizer):
def sync_from_external(self) -> bool:
all_data = []
all_data.extend(api_client.stock_material('{"typeMode": 0}')) # 耗材
all_data.extend(api_client.stock_material('{"typeMode": 1}')) # 样品
all_data.extend(api_client.stock_material('{"typeMode": 2}')) # 试剂
unilab_resources = resource_bioyond_to_plr(
all_data,
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
deck=self.workstation.deck
)
# 更新 deck 上的资源
```
---
## 4. 非瓶类资源
### ElectrodeSheet极片
路径:`unilabos/resources/battery/electrode_sheet.py`
```python
class ElectrodeSheet(ResourcePLR):
"""片状材料(极片、隔膜、弹片、垫片等)"""
_unilabos_state = {
"diameter": 0.0,
"thickness": 0.0,
"mass": 0.0,
"material_type": "",
"color": "",
"info": "",
}
```
工厂函数:`PositiveCan`, `PositiveElectrode`, `NegativeCan`, `NegativeElectrode`, `SpringWasher`, `FlatWasher`, `AluminumFoil`
### Battery电池
```python
class Battery(Container):
"""组装好的电池"""
_unilabos_state = {
"color": "",
"electrolyte_name": "",
"open_circuit_voltage": 0.0,
}
```
### Magazine / MagazineHolder子弹夹
```python
class Magazine(ResourceStack):
"""子弹夹洞位,可堆叠 ElectrodeSheet"""
# direction, max_sheets
class MagazineHolder(ItemizedResource):
"""多洞位子弹夹"""
# hole_diameter, hole_depth, max_sheets_per_hole
```
工厂函数 `magazine_factory()``create_homogeneous_resources` 生成洞位,可选预填 `ElectrodeSheet``Battery`
---
## 5. 仓库工厂模式参考
### 实际 warehouse 工厂函数示例
```python
# 行优先 4x4 仓库
def bioyond_warehouse_1x4x4(name: str) -> WareHouse:
return warehouse_factory(
name=name,
num_items_x=4, num_items_y=4, num_items_z=1,
dx=10.0, dy=10.0, dz=10.0,
item_dx=147.0, item_dy=106.0, item_dz=130.0,
layout="row-major", # A01,A02,A03,A04, B01,...
)
# 右侧 4x4 仓库(列名偏移)
def bioyond_warehouse_1x4x4_right(name: str) -> WareHouse:
return warehouse_factory(
name=name,
num_items_x=4, num_items_y=4, num_items_z=1,
dx=10.0, dy=10.0, dz=10.0,
item_dx=147.0, item_dy=106.0, item_dz=130.0,
col_offset=4, # A05,A06,A07,A08
layout="row-major",
)
# 竖向仓库(站内试剂存放)
def bioyond_warehouse_reagent_storage(name: str) -> WareHouse:
return warehouse_factory(
name=name,
num_items_x=1, num_items_y=2, num_items_z=1,
dx=10.0, dy=10.0, dz=10.0,
item_dx=147.0, item_dy=106.0, item_dz=130.0,
layout="vertical-col-major",
)
# 行偏移F 行开始)
def bioyond_warehouse_5x3x1(name: str, row_offset: int = 0) -> WareHouse:
return warehouse_factory(
name=name,
num_items_x=3, num_items_y=5, num_items_z=1,
dx=10.0, dy=10.0, dz=10.0,
item_dx=159.0, item_dy=183.0, item_dz=130.0,
row_offset=row_offset, # 0→A行起5→F行起
layout="row-major",
)
```
### layout 类型说明
| layout | 命名顺序 | 适用场景 |
|--------|---------|---------|
| `col-major` (默认) | A01,B01,C01,D01, A02,B02,... | 列优先,标准堆栈 |
| `row-major` | A01,A02,A03,A04, B01,B02,... | 行优先Bioyond 前端展示 |
| `vertical-col-major` | 竖向排列,标签从底部开始 | 竖向仓库(试剂存放、测密度) |
---
## 6. 关键路径
| 内容 | 路径 |
|------|------|
| Bottle/Carrier 基类 | `unilabos/resources/itemized_carrier.py` |
| WareHouse 类 + 工厂 | `unilabos/resources/warehouse.py` |
| ResourceTreeSet 转换 | `unilabos/resources/resource_tracker.py` |
| Bioyond 物料转换 | `unilabos/resources/graphio.py` |
| Bioyond 仓库定义 | `unilabos/resources/bioyond/warehouses.py` |
| 电池资源 | `unilabos/resources/battery/` |
| PLR 注册 | `unilabos/resources/plr_additional_res_reg.py` |

View File

@@ -1,626 +0,0 @@
---
name: add-workstation
description: Guide for adding new workstations to Uni-Lab-OS (接入新工作站). Uses @device decorator + AST auto-scanning. Walks through workstation type, sub-device composition, driver creation, deck setup, and graph file. Use when the user wants to add a workstation, create a workstation driver, configure a station with sub-devices, or mentions 工作站/工站/station/workstation.
---
# Uni-Lab-OS 工作站接入指南
工作站workstation是组合多个子设备的大型设备拥有独立的物料管理系统和工作流引擎。使用 `@device` 装饰器注册AST 自动扫描生成注册表。
---
## 工作站类型
| 类型 | 基类 | 适用场景 |
| ------------------- | ----------------- | ---------------------------------- |
| **Protocol 工作站** | `ProtocolNode` | 标准化学操作协议(泵转移、过滤等) |
| **外部系统工作站** | `WorkstationBase` | 与外部 LIMS/MES 对接 |
| **硬件控制工作站** | `WorkstationBase` | 直接控制 PLC/硬件 |
---
## @device 装饰器(工作站)
工作站也使用 `@device` 装饰器注册,参数与普通设备一致:
```python
@device(
id="my_workstation", # 注册表唯一标识(必填)
category=["workstation"], # 分类标签
description="我的工作站",
)
```
如果一个工作站类支持多个具体变体,可使用 `ids` / `id_meta`,与设备的用法相同(参见 add-device SKILL
---
## 工作站驱动模板
### 模板 A基于外部系统的工作站
```python
import logging
from typing import Dict, Any, Optional
from pylabrobot.resources import Deck
from unilabos.registry.decorators import device, topic_config, not_action
from unilabos.devices.workstation.workstation_base import WorkstationBase
try:
from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
except ImportError:
ROS2WorkstationNode = None
@device(id="my_workstation", category=["workstation"], description="我的工作站")
class MyWorkstation(WorkstationBase):
_ros_node: "ROS2WorkstationNode"
def __init__(self, config=None, deck=None, protocol_type=None, **kwargs):
super().__init__(deck=deck, **kwargs)
self.config = config or {}
self.logger = logging.getLogger("MyWorkstation")
self.api_host = self.config.get("api_host", "")
self._status = "Idle"
@not_action
def post_init(self, ros_node: "ROS2WorkstationNode"):
super().post_init(ros_node)
self._ros_node = ros_node
async def scheduler_start(self, **kwargs) -> Dict[str, Any]:
"""注册为工作站动作"""
return {"success": True}
async def create_order(self, json_str: str, **kwargs) -> Dict[str, Any]:
"""注册为工作站动作"""
return {"success": True}
@property
@topic_config()
def workflow_sequence(self) -> str:
return "[]"
@property
@topic_config()
def material_info(self) -> str:
return "{}"
```
### 模板 BProtocol 工作站
直接使用 `ProtocolNode`,通常不需要自定义驱动类:
```python
from unilabos.devices.workstation.workstation_base import ProtocolNode
```
在图文件中配置 `protocol_type` 即可。
---
## 子设备访问sub_devices
工站初始化子设备后,所有子设备实例存储在 `self._ros_node.sub_devices` 字典中key 为设备 idvalue 为 `ROS2DeviceNode` 实例)。工站的驱动类可以直接获取子设备实例来调用其方法:
```python
# 在工站驱动类的方法中访问子设备
sub = self._ros_node.sub_devices["pump_1"]
# .driver_instance — 子设备的驱动实例(即设备 Python 类的实例)
sub.driver_instance.some_method(arg1, arg2)
# .ros_node_instance — 子设备的 ROS2 节点实例
sub.ros_node_instance._action_value_mappings # 查看子设备支持的 action
```
**常见用法**
```python
class MyWorkstation(WorkstationBase):
def my_protocol(self, **kwargs):
# 获取子设备驱动实例
pump = self._ros_node.sub_devices["pump_1"].driver_instance
heater = self._ros_node.sub_devices["heater_1"].driver_instance
# 直接调用子设备方法
pump.aspirate(volume=100)
heater.set_temperature(80)
```
> 参考实现:`unilabos/devices/workstation/bioyond_studio/reaction_station/reaction_station.py` 中通过 `self._ros_node.sub_devices.get(reactor_id)` 获取子反应器实例并更新数据。
---
## 硬件通信接口hardware_interface
硬件控制型工作站通常需要通过串口Serial、Modbus 等通信协议控制多个子设备。Uni-Lab-OS 通过 **通信设备代理** 机制实现端口共享:一个串口只创建一个 `serial` 节点,多个子设备共享这个通信实例。
### 工作原理
`ROS2WorkstationNode` 初始化时分两轮遍历子设备(`workstation.py`
**第一轮 — 初始化所有子设备**:按 `children` 顺序调用 `initialize_device()`,通信设备(`serial_` / `io_` 开头的 id优先完成初始化创建 `serial.Serial()` 实例。其他子设备此时 `self.hardware_interface = "serial_pump"`(字符串)。
**第二轮 — 代理替换**:遍历所有已初始化的子设备,读取子设备的 `_hardware_interface` 配置:
```
hardware_interface = d.ros_node_instance._hardware_interface
# → {"name": "hardware_interface", "read": "send_command", "write": "send_command"}
```
1.`name` 字段对应的属性值:`name_value = getattr(driver, hardware_interface["name"])`
- 如果 `name_value` 是字符串且该字符串是某个子设备的 id → 触发代理替换
2. 从通信设备获取真正的 `read`/`write` 方法
3.`setattr(driver, read_method, _read)` 将通信设备的方法绑定到子设备上
因此:
- **通信设备 id 必须与子设备 config 中填的字符串完全一致**(如 `"serial_pump"`
- **通信设备 id 必须以 `serial_``io_` 开头**(否则第一轮不会被识别为通信设备)
- **通信设备必须在 `children` 列表中排在最前面**,确保先初始化
### HardwareInterface 参数说明
```python
from unilabos.registry.decorators import HardwareInterface
HardwareInterface(
name="hardware_interface", # __init__ 中接收通信实例的属性名
read="send_command", # 通信设备上暴露的读方法名
write="send_command", # 通信设备上暴露的写方法名
extra_info=["list_ports"], # 可选:额外暴露的方法
)
```
**`name` 字段的含义**:对应设备类 `__init__` 中,用于保存通信实例的**属性名**。系统据此知道要替换哪个属性。大部分设备直接用 `"hardware_interface"`,也可以自定义(如 `"io_device_port"`)。
### 示例 1name="hardware_interface"
```python
from unilabos.registry.decorators import device, HardwareInterface
@device(
id="my_pump",
category=["pump_and_valve"],
hardware_interface=HardwareInterface(
name="hardware_interface",
read="send_command",
write="send_command",
),
)
class MyPump:
def __init__(self, port=None, address="1", **kwargs):
# name="hardware_interface" → 系统替换 self.hardware_interface
self.hardware_interface = port # 初始为字符串 "serial_pump",启动后被替换为 Serial 实例
self.address = address
def send_command(self, command: str):
full_command = f"/{self.address}{command}\r\n"
self.hardware_interface.write(bytearray(full_command, "ascii"))
return self.hardware_interface.read_until(b"\n")
```
### 示例 2电磁阀name="io_device_port",自定义属性名)
```python
@device(
id="solenoid_valve",
category=["pump_and_valve"],
hardware_interface=HardwareInterface(
name="io_device_port", # 自定义属性名 → 系统替换 self.io_device_port
read="read_io_coil",
write="write_io_coil",
),
)
class SolenoidValve:
def __init__(self, io_device_port: str = None, **kwargs):
# name="io_device_port" → 图文件 config 中用 "io_device_port": "io_board_1"
self.io_device_port = io_device_port # 初始为字符串,系统替换为 Modbus 实例
```
### Serial 通信设备class="serial"
`serial` 是 Uni-Lab-OS 内置的通信代理设备,代码位于 `unilabos/ros/nodes/presets/serial_node.py`
```python
from serial import Serial, SerialException
from threading import Lock
class ROS2SerialNode(BaseROS2DeviceNode):
def __init__(self, device_id, registry_name, port: str, baudrate: int = 9600, **kwargs):
self.port = port
self.baudrate = baudrate
self._hardware_interface = {
"name": "hardware_interface",
"write": "send_command",
"read": "read_data",
}
self._query_lock = Lock()
self.hardware_interface = Serial(baudrate=baudrate, port=port)
BaseROS2DeviceNode.__init__(
self, driver_instance=self, registry_name=registry_name,
device_id=device_id, status_types={}, action_value_mappings={},
hardware_interface=self._hardware_interface, print_publish=False,
)
self.create_service(SerialCommand, "serialwrite", self.handle_serial_request)
def send_command(self, command: str):
with self._query_lock:
self.hardware_interface.write(bytearray(f"{command}\n", "ascii"))
return self.hardware_interface.read_until(b"\n").decode()
def read_data(self):
with self._query_lock:
return self.hardware_interface.read_until(b"\n").decode()
```
在图文件中使用 `"class": "serial"` 即可创建串口代理:
```json
{
"id": "serial_pump",
"class": "serial",
"parent": "my_station",
"config": { "port": "COM7", "baudrate": 9600 }
}
```
### 图文件配置
**通信设备必须在 `children` 列表中排在最前面**,确保先于其他子设备初始化:
```json
{
"nodes": [
{
"id": "my_station",
"class": "workstation",
"children": ["serial_pump", "pump_1", "pump_2"],
"config": { "protocol_type": ["PumpTransferProtocol"] }
},
{
"id": "serial_pump",
"class": "serial",
"parent": "my_station",
"config": { "port": "COM7", "baudrate": 9600 }
},
{
"id": "pump_1",
"class": "syringe_pump_with_valve.runze.SY03B-T08",
"parent": "my_station",
"config": { "port": "serial_pump", "address": "1", "max_volume": 25.0 }
},
{
"id": "pump_2",
"class": "syringe_pump_with_valve.runze.SY03B-T08",
"parent": "my_station",
"config": { "port": "serial_pump", "address": "2", "max_volume": 25.0 }
}
],
"links": [
{
"source": "pump_1",
"target": "serial_pump",
"type": "communication",
"port": { "pump_1": "port", "serial_pump": "port" }
},
{
"source": "pump_2",
"target": "serial_pump",
"type": "communication",
"port": { "pump_2": "port", "serial_pump": "port" }
}
]
}
```
### 通信协议速查
| 协议 | config 参数 | 依赖包 | 通信设备 class |
| -------------------- | ------------------------------ | ---------- | -------------------------- |
| Serial (RS232/RS485) | `port`, `baudrate` | `pyserial` | `serial` |
| Modbus RTU | `port`, `baudrate`, `slave_id` | `pymodbus` | `device_comms/modbus_plc/` |
| Modbus TCP | `host`, `port`, `slave_id` | `pymodbus` | `device_comms/modbus_plc/` |
| TCP Socket | `host`, `port` | stdlib | 自定义 |
| HTTP API | `url`, `token` | `requests` | `device_comms/rpc.py` |
参考实现:`unilabos/test/experiments/Grignard_flow_batchreact_single_pumpvalve.json`
---
## Deck 与物料生命周期
### 1. Deck 入参与两种初始化模式
系统根据设备节点 `config.deck` 的写法,自动反序列化 Deck 实例后传入 `__init__``deck` 参数。目前 `deck` 是固定字段名,只支持一个主 Deck。建议一个设备拥有一个台面台面上抽象二级、三级子物料。
有两种初始化模式:
#### init 初始化(推荐)
`config.deck` 直接包含 `_resource_type` + `_resource_child_name`,系统先用 Deck 节点的 `config` 调用 Deck 类的 `__init__` 反序列化,再将实例传入设备的 `deck` 参数。子物料随 Deck 的 `children` 一起反序列化。
```json
"config": {
"deck": {
"_resource_type": "unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Deck",
"_resource_child_name": "PRCXI_Deck"
}
}
```
#### deserialize 初始化
`config.deck``data` 包裹一层,系统走 `deserialize` 路径,可传入更多参数(如 `allow_marshal` 等):
```json
"config": {
"deck": {
"data": {
"_resource_child_name": "YB_Bioyond_Deck",
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_YB_Deck"
}
}
}
```
没有特殊需求时推荐 init 初始化。
#### config.deck 字段说明
| 字段 | 说明 |
|------|------|
| `_resource_type` | Deck 类的完整模块路径(`module:ClassName` |
| `_resource_child_name` | 对应图文件中 Deck 节点的 `id`,建立父子关联 |
#### 设备 __init__ 接收
```python
def __init__(self, config=None, deck=None, protocol_type=None, **kwargs):
super().__init__(deck=deck, **kwargs)
# deck 已经是反序列化后的 Deck 实例
# → PRCXI9300Deck / BIOYOND_YB_Deck 等
```
#### Deck 节点(图文件中)
Deck 节点作为设备的 `children` 之一,`parent` 指向设备 id
```json
{
"id": "PRCXI_Deck",
"parent": "PRCXI",
"type": "deck",
"class": "",
"children": [],
"config": {
"type": "PRCXI9300Deck",
"size_x": 542, "size_y": 374, "size_z": 0,
"category": "deck",
"sites": [...]
},
"data": {}
}
```
- `config` 中的字段会传入 Deck 类的 `__init__`(因此 `__init__` 必须能接受所有 `serialize()` 输出的字段)
- `children` 初始为空时,由同步器或手动初始化填充
- `config.type` 填 Deck 类名
### 2. Deck 为空时自行初始化
如果 Deck 节点的 `children` 为空,工作站需在 `post_init` 或首次同步时自行初始化内容:
```python
@not_action
def post_init(self, ros_node):
super().post_init(ros_node)
if self.deck and not self.deck.children:
self._initialize_default_deck()
def _initialize_default_deck(self):
from my_labware import My_TipRack, My_Plate
self.deck.assign_child_resource(My_TipRack("T1"), spot=0)
self.deck.assign_child_resource(My_Plate("T2"), spot=1)
```
### 3. 物料双向同步
当工作站对接外部系统LIMS/MES需要实现 `ResourceSynchronizer` 处理双向物料同步:
```python
from unilabos.devices.workstation.workstation_base import ResourceSynchronizer
class MyResourceSynchronizer(ResourceSynchronizer):
def sync_from_external(self) -> bool:
"""从外部系统同步到 self.workstation.deck"""
external_data = self._query_external_materials()
# 以外部工站为准:根据外部数据反向创建 PLR 资源实例
for item in external_data:
cls = self._resolve_resource_class(item["type"])
resource = cls(name=item["name"], **item["params"])
self.workstation.deck.assign_child_resource(resource, spot=item["slot"])
return True
def sync_to_external(self, resource) -> bool:
"""将 UniLab 侧物料变更同步到外部系统"""
# 以 UniLab 为准:将 PLR 资源转为外部格式并推送
external_format = self._convert_to_external(resource)
return self._push_to_external(external_format)
def handle_external_change(self, change_info) -> bool:
"""处理外部系统主动推送的变更"""
return True
```
同步策略取决于业务场景:
- **以外部工站为准**:从外部 API 查询物料数据,反向创建对应的 PLR 资源实例放到 Deck 上
- **以 UniLab 为准**UniLab 侧的物料变更通过 `sync_to_external` 推送到外部系统
在工作站 `post_init` 中初始化同步器:
```python
@not_action
def post_init(self, ros_node):
super().post_init(ros_node)
self.resource_synchronizer = MyResourceSynchronizer(self)
self.resource_synchronizer.sync_from_external()
```
### 4. 序列化与持久化serialize / serialize_state
资源类需正确实现序列化,系统据此完成持久化和前端同步。
**`serialize()`** — 输出资源的结构信息(`config` 层),反序列化时作为 `__init__` 的入参回传。因此 **`__init__` 必须通过 `**kwargs`接受`serialize()` 输出的所有字段\*\*,即使当前不使用:
```python
class MyDeck(Deck):
def __init__(self, name, size_x, size_y, size_z,
sites=None, # serialize() 输出的字段
rotation=None, # serialize() 输出的字段
barcode=None, # serialize() 输出的字段
**kwargs): # 兜底:接受所有未知的 serialize 字段
super().__init__(size_x, size_y, size_z, name)
# ...
def serialize(self) -> dict:
data = super().serialize()
data["sites"] = [...] # 自定义字段
return data
```
**`serialize_state()`** — 输出资源的运行时状态(`data` 层),用于持久化可变信息。`data` 中的内容会被正确保存和恢复:
```python
class MyPlate(Plate):
def __init__(self, name, size_x, size_y, size_z,
material_info=None, **kwargs):
super().__init__(name, size_x, size_y, size_z, **kwargs)
self._unilabos_state = {}
if material_info:
self._unilabos_state["Material"] = material_info
def serialize_state(self) -> Dict[str, Any]:
data = super().serialize_state()
data.update(self._unilabos_state)
return data
```
关键要点:
- `serialize()` 输出的所有字段都会作为 `config` 回传到 `__init__`,所以 `__init__` 必须能接受它们(显式声明或 `**kwargs`
- `serialize_state()` 输出的 `data` 用于持久化运行时状态(如物料信息、液体量等)
- `_unilabos_state` 中只存可 JSON 序列化的基本类型str, int, float, bool, list, dict, None
### 5. 子物料自动同步
子物料Bottle、Plate、TipRack 等)放到 Deck 上后,系统会自动将其同步到前端的 Deck 视图。只需保证资源类正确实现了 `serialize()` / `serialize_state()` 和反序列化即可。
### 6. 图文件配置(参考 prcxi_9320_slim.json
```json
{
"nodes": [
{
"id": "my_station",
"type": "device",
"class": "my_workstation",
"config": {
"deck": {
"_resource_type": "unilabos.resources.my_module:MyDeck",
"_resource_child_name": "my_deck"
},
"host": "10.20.30.1",
"port": 9999
}
},
{
"id": "my_deck",
"parent": "my_station",
"type": "deck",
"class": "",
"children": [],
"config": {
"type": "MyLabDeck",
"size_x": 542,
"size_y": 374,
"size_z": 0,
"category": "deck",
"sites": [
{
"label": "T1",
"visible": true,
"occupied_by": null,
"position": { "x": 0, "y": 0, "z": 0 },
"size": { "width": 128.0, "height": 86, "depth": 0 },
"content_type": ["plate", "tip_rack", "tube_rack", "adaptor"]
}
]
},
"data": {}
}
],
"edges": []
}
```
Deck 节点要点:
- `config.type` 填 Deck 类名(如 `"PRCXI9300Deck"`
- `config.sites` 完整列出所有 site从 Deck 类的 `serialize()` 输出获取)
- `children` 初始为空(由同步器或手动初始化填充)
- 设备节点 `config.deck._resource_type` 指向 Deck 类的完整模块路径
---
## 子设备
子设备按标准设备接入流程创建(参见 add-device SKILL使用 `@device` 装饰器。
子设备约束:
- 图文件中 `parent` 指向工作站 ID
- 在工作站 `children` 数组中列出
---
## 关键规则
1. **`__init__` 必须接受 `deck``**kwargs`** — `WorkstationBase.**init**`需要`deck` 参数
2. **Deck 通过 `config.deck._resource_type` 反序列化传入** — 不要在 `__init__` 中手动创建 Deck
3. **Deck 为空时自行初始化内容** — 在 `post_init` 中检查并填充默认物料
4. **外部同步实现 `ResourceSynchronizer`**`sync_from_external` / `sync_to_external`
5. **通过 `self._children` 访问子设备** — 不要自行维护子设备引用
6. **`post_init` 中启动后台服务** — 不要在 `__init__` 中启动网络连接
7. **异步方法使用 `await self._ros_node.sleep()`** — 禁止 `time.sleep()``asyncio.sleep()`
8. **使用 `@not_action` 标记非动作方法**`post_init`, `initialize`, `cleanup`
9. **子物料保证正确 serialize/deserialize** — 系统自动同步到前端 Deck 视图
---
## 验证
```bash
# 模块可导入
python -c "from unilabos.devices.workstation.<name>.<name> import <ClassName>"
# 启动测试AST 自动扫描)
unilab -g <graph>.json
```
---
## 现有工作站参考
| 工作站 | 驱动类 | 类型 |
| -------------- | ----------------------------- | -------- |
| Protocol 通用 | `ProtocolNode` | Protocol |
| Bioyond 反应站 | `BioyondReactionStation` | 外部系统 |
| 纽扣电池组装 | `CoinCellAssemblyWorkstation` | 硬件控制 |
参考路径:`unilabos/devices/workstation/` 目录下各工作站实现。

View File

@@ -1,371 +0,0 @@
# 工作站高级模式参考
本文件是 SKILL.md 的补充,包含外部系统集成、物料同步、配置结构等高级模式。
Agent 在需要实现这些功能时按需阅读。
---
## 1. 外部系统集成模式
### 1.1 RPC 客户端
与外部 LIMS/MES 系统通信的标准模式。继承 `BaseRequest`,所有接口统一用 POST。
```python
from unilabos.device_comms.rpc import BaseRequest
class MySystemRPC(BaseRequest):
"""外部系统 RPC 客户端"""
def __init__(self, host: str, api_key: str):
super().__init__(host)
self.api_key = api_key
def _request(self, endpoint: str, data: dict = None) -> dict:
return self.post(
url=f"{self.host}/api/{endpoint}",
params={
"apiKey": self.api_key,
"requestTime": self.get_current_time_iso8601(),
"data": data or {},
},
)
def query_status(self) -> dict:
return self._request("status/query")
def create_order(self, order_data: dict) -> dict:
return self._request("order/create", order_data)
```
参考:`unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py``BioyondV1RPC`
### 1.2 HTTP 回调服务
接收外部系统报送的标准模式。使用 `WorkstationHTTPService`,在 `post_init` 中启动。
```python
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
class MyWorkstation(WorkstationBase):
def __init__(self, config=None, deck=None, **kwargs):
super().__init__(deck=deck, **kwargs)
self.config = config or {}
http_cfg = self.config.get("http_service_config", {})
self._http_service_config = {
"host": http_cfg.get("http_service_host", "127.0.0.1"),
"port": http_cfg.get("http_service_port", 8080),
}
self.http_service = None
def post_init(self, ros_node):
super().post_init(ros_node)
self.http_service = WorkstationHTTPService(
workstation_instance=self,
host=self._http_service_config["host"],
port=self._http_service_config["port"],
)
self.http_service.start()
```
**HTTP 服务路由**(固定端点,由 `WorkstationHTTPHandler` 自动分发):
| 端点 | 调用的工作站方法 |
|------|-----------------|
| `/report/step_finish` | `process_step_finish_report(report_request)` |
| `/report/sample_finish` | `process_sample_finish_report(report_request)` |
| `/report/order_finish` | `process_order_finish_report(report_request, used_materials)` |
| `/report/material_change` | `process_material_change_report(report_data)` |
| `/report/error_handling` | `handle_external_error(error_data)` |
实现对应方法即可接收回调:
```python
def process_step_finish_report(self, report_request) -> Dict[str, Any]:
"""处理步骤完成报告"""
step_name = report_request.data.get("stepName")
return {"success": True, "message": f"步骤 {step_name} 已处理"}
def process_order_finish_report(self, report_request, used_materials) -> Dict[str, Any]:
"""处理订单完成报告"""
order_code = report_request.data.get("orderCode")
return {"success": True}
```
参考:`unilabos/devices/workstation/workstation_http_service.py`
### 1.3 连接监控
独立线程周期性检测外部系统连接状态,状态变化时发布 ROS 事件。
```python
class ConnectionMonitor:
def __init__(self, workstation, check_interval=30):
self.workstation = workstation
self.check_interval = check_interval
self._running = False
self._thread = None
def start(self):
self._running = True
self._thread = threading.Thread(target=self._monitor_loop, daemon=True)
self._thread.start()
def _monitor_loop(self):
while self._running:
try:
# 调用外部系统接口检测连接
self.workstation.hardware_interface.ping()
status = "online"
except Exception:
status = "offline"
time.sleep(self.check_interval)
```
参考:`unilabos/devices/workstation/bioyond_studio/station.py``ConnectionMonitor`
---
## 2. Config 结构模式
工作站的 `config` 在图文件中定义,传入 `__init__`。以下是常见字段模式:
### 2.1 外部系统连接
```json
{
"api_host": "http://192.168.1.100:8080",
"api_key": "YOUR_API_KEY"
}
```
### 2.2 HTTP 回调服务
```json
{
"http_service_config": {
"http_service_host": "127.0.0.1",
"http_service_port": 8080
}
}
```
### 2.3 物料类型映射
将 PLR 资源类名映射到外部系统的物料类型(名称 + UUID。用于双向物料转换。
```json
{
"material_type_mappings": {
"PLR_ResourceClassName": ["外部系统显示名", "external-type-uuid"],
"BIOYOND_PolymerStation_Reactor": ["反应器", "3a14233b-902d-0d7b-..."]
}
}
```
### 2.4 仓库映射
将仓库名映射到外部系统的仓库 UUID 和库位 UUID。用于入库/出库操作。
```json
{
"warehouse_mapping": {
"仓库名": {
"uuid": "warehouse-uuid",
"site_uuids": {
"A01": "site-uuid-A01",
"A02": "site-uuid-A02"
}
}
}
}
```
### 2.5 工作流映射
将内部工作流名映射到外部系统的工作流 ID。
```json
{
"workflow_mappings": {
"internal_workflow_name": "external-workflow-uuid"
}
}
```
### 2.6 物料默认参数
```json
{
"material_default_parameters": {
"NMP": {
"unit": "毫升",
"density": "1.03",
"densityUnit": "g/mL",
"description": "N-甲基吡咯烷酮"
}
}
}
```
---
## 3. 资源同步机制
### 3.1 ResourceSynchronizer
抽象基类,用于与外部物料系统双向同步。定义在 `workstation_base.py`
```python
from unilabos.devices.workstation.workstation_base import ResourceSynchronizer
class MyResourceSynchronizer(ResourceSynchronizer):
def __init__(self, workstation, api_client):
super().__init__(workstation)
self.api_client = api_client
def sync_from_external(self) -> bool:
"""从外部系统拉取物料到 deck"""
external_materials = self.api_client.list_materials()
for material in external_materials:
plr_resource = self._convert_to_plr(material)
self.workstation.deck.assign_child_resource(plr_resource, coordinate)
return True
def sync_to_external(self, plr_resource) -> bool:
"""将 deck 中的物料变更推送到外部系统"""
external_data = self._convert_from_plr(plr_resource)
self.api_client.update_material(external_data)
return True
def handle_external_change(self, change_info) -> bool:
"""处理外部系统推送的物料变更"""
return True
```
### 3.2 update_resource — 上传资源树到云端
将 PLR Deck 序列化后通过 ROS 服务上传。典型使用场景:
```python
# 在 post_init 中上传初始 deck
from unilabos.ros.nodes.base_device_node import ROS2DeviceNode
ROS2DeviceNode.run_async_func(
self._ros_node.update_resource, True,
**{"resources": [self.deck]}
)
# 在动作方法中更新特定资源
ROS2DeviceNode.run_async_func(
self._ros_node.update_resource, True,
**{"resources": [updated_plate]}
)
```
---
## 4. 工作流序列管理
工作站通过 `workflow_sequence` 属性管理任务队列JSON 字符串形式)。
```python
class MyWorkstation(WorkstationBase):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._workflow_sequence = []
@property
def workflow_sequence(self) -> str:
"""返回 JSON 字符串ROS 自动发布"""
import json
return json.dumps(self._workflow_sequence)
async def append_to_workflow_sequence(self, workflow_name: str) -> Dict[str, Any]:
"""添加工作流到队列"""
self._workflow_sequence.append({
"name": workflow_name,
"status": "pending",
"created_at": time.time(),
})
return {"success": True}
async def clear_workflows(self) -> Dict[str, Any]:
"""清空工作流队列"""
self._workflow_sequence = []
return {"success": True}
```
---
## 5. 站间物料转移
工作站之间转移物料的模式。通过 ROS ActionClient 调用目标站的动作。
```python
async def transfer_materials_to_another_station(
self,
target_device_id: str,
transfer_groups: list,
**kwargs,
) -> Dict[str, Any]:
"""将物料转移到另一个工作站"""
target_node = self._children.get(target_device_id)
if not target_node:
# 通过 ROS 节点查找非子设备的目标站
pass
for group in transfer_groups:
resource = self.find_resource_by_name(group["resource_name"])
# 从本站 deck 移除
resource.unassign()
# 调用目标站的接收方法
# ...
return {"success": True, "transferred": len(transfer_groups)}
```
参考:`BioyondDispensingStation.transfer_materials_to_reaction_station`
---
## 6. post_init 完整模式
`post_init` 是工作站初始化的关键阶段,此时 ROS 节点和子设备已就绪。
```python
def post_init(self, ros_node):
super().post_init(ros_node)
# 1. 初始化外部系统客户端(此时 config 已可用)
self.rpc_client = MySystemRPC(
host=self.config.get("api_host"),
api_key=self.config.get("api_key"),
)
self.hardware_interface = self.rpc_client
# 2. 启动连接监控
self.connection_monitor = ConnectionMonitor(self)
self.connection_monitor.start()
# 3. 启动 HTTP 回调服务
if hasattr(self, '_http_service_config'):
self.http_service = WorkstationHTTPService(
workstation_instance=self,
host=self._http_service_config["host"],
port=self._http_service_config["port"],
)
self.http_service.start()
# 4. 上传 deck 到云端
ROS2DeviceNode.run_async_func(
self._ros_node.update_resource, True,
**{"resources": [self.deck]}
)
# 5. 初始化资源同步器(可选)
self.resource_synchronizer = MyResourceSynchronizer(self, self.rpc_client)
```

View File

@@ -1,261 +0,0 @@
---
name: batch-insert-reagent
description: Batch insert reagents into Uni-Lab platform — add chemicals with CAS, SMILES, supplier info. Use when the user wants to add reagents, insert chemicals, batch register reagents, or mentions 录入试剂/添加试剂/试剂入库/reagent.
---
# 批量录入试剂 Skill
通过云端 API 批量录入试剂信息,支持逐条或批量操作。
## 前置条件(缺一不可)
使用本 skill 前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
### 1. ak / sk → AUTH
询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。
生成 AUTH token任选一种方式
```bash
# 方式一Python 一行生成
python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
# 方式二:手动计算
# base64(ak:sk) → Authorization: Lab <token>
```
### 2. --addr → BASE URL
| `--addr` 值 | BASE |
| ------------ | ----------------------------------- |
| `test` | `https://leap-lab.test.bohrium.com` |
| `uat` | `https://leap-lab.uat.bohrium.com` |
| `local` | `http://127.0.0.1:48197` |
| 不传(默认) | `https://leap-lab.bohrium.com` |
确认后设置:
```bash
BASE="<根据 addr 确定的 URL>"
AUTH="Authorization: Lab <gen_auth.py 输出的 token>"
```
**两项全部就绪后才可发起 API 请求。**
## Session State
- `lab_uuid` — 实验室 UUID首次通过 API #1 自动获取,**不需要问用户**
## 请求约定
所有请求使用 `curl -s`POST 需加 `Content-Type: application/json`
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名),示例中的 `curl` 均指 `curl.exe`。
---
## API Endpoints
### 1. 获取实验室信息(自动获取 lab_uuid
```bash
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
```
返回:
```json
{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } }
```
记住 `data.uuid``lab_uuid`
### 2. 录入试剂
```bash
curl -s -X POST "$BASE/api/v1/lab/reagent" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{
"lab_uuid": "<lab_uuid>",
"cas": "<CAS号>",
"name": "<试剂名称>",
"molecular_formula": "<分子式>",
"smiles": "<SMILES>",
"stock_in_quantity": <入库数量>,
"unit": "<单位字符串>",
"supplier": "<供应商>",
"production_date": "<生产日期 ISO 8601>",
"expiry_date": "<过期日期 ISO 8601>"
}'
```
返回成功时包含试剂 UUID
```json
{"code": 0, "data": {"uuid": "xxx", ...}}
```
---
## 试剂字段说明
| 字段 | 类型 | 必填 | 说明 | 示例 |
| ------------------- | ------ | ---- | ----------------------------- | ------------------------ |
| `lab_uuid` | string | 是 | 实验室 UUID从 API #1 获取) | `"8511c672-..."` |
| `cas` | string | 是 | CAS 注册号 | `"7732-18-3"` |
| `name` | string | 是 | 试剂中文/英文名称 | `"水"` |
| `molecular_formula` | string | 是 | 分子式 | `"H2O"` |
| `smiles` | string | 是 | SMILES 表示 | `"O"` |
| `stock_in_quantity` | number | 是 | 入库数量 | `10` |
| `unit` | string | 是 | 单位(字符串,见下表) | `"mL"` |
| `supplier` | string | 否 | 供应商名称 | `"国药集团"` |
| `production_date` | string | 否 | 生产日期ISO 8601 | `"2025-11-18T00:00:00Z"` |
| `expiry_date` | string | 否 | 过期日期ISO 8601 | `"2026-11-18T00:00:00Z"` |
### unit 单位值
| 值 | 单位 |
| ------ | ---- |
| `"mL"` | 毫升 |
| `"L"` | 升 |
| `"g"` | 克 |
| `"kg"` | 千克 |
| `"瓶"` | 瓶 |
> 根据试剂状态选择:液体用 `"mL"` / `"L"`,固体用 `"g"` / `"kg"`。
---
## 批量录入策略
### 方式一:用户提供 JSON 数组
用户一次性给出多条试剂数据:
```json
[
{
"cas": "7732-18-3",
"name": "水",
"molecular_formula": "H2O",
"smiles": "O",
"stock_in_quantity": 10,
"unit": "mL"
},
{
"cas": "64-17-5",
"name": "乙醇",
"molecular_formula": "C2H6O",
"smiles": "CCO",
"stock_in_quantity": 5,
"unit": "L"
}
]
```
Agent 自动为每条补充 `lab_uuid``production_date``expiry_date` 等字段后逐条提交。
Agent 循环调用 API #2 逐条录入,每条记录一次 API 调用。
### 方式二:用户逐个描述
用户口头描述试剂(如「帮我录入 500mL 的无水乙醇Sigma 的」agent 自行补全字段:
1. 根据名称查找 CAS 号、分子式、SMILES参考下方速查表或自行推断
2. 构建完整的请求体
3. 向用户确认后提交
### 方式三:从 CSV/表格批量导入
用户提供 CSV 或表格文件路径agent 读取并解析:
```bash
# 期望的 CSV 格式(首行为表头)
cas,name,molecular_formula,smiles,stock_in_quantity,unit,supplier,production_date,expiry_date
7732-18-3,水,H2O,O,10,mL,农夫山泉,2025-11-18T00:00:00Z,2026-11-18T00:00:00Z
```
### 日期格式规则(重要)
所有日期字段(`production_date``expiry_date`**必须**使用 ISO 8601 完整格式:`YYYY-MM-DDTHH:MM:SSZ`
- 用户输入 `2025-03-01` → 转换为 `"2025-03-01T00:00:00Z"`
- 用户输入 `2025/9/1` → 转换为 `"2025-09-01T00:00:00Z"`
- 用户未提供日期 → 使用当天日期 + `T00:00:00Z`,有效期默认 +1 年
**禁止**发送不带时间部分的日期字符串(如 `"2025-03-01"`API 会拒绝。
### 执行与汇报
每次 API 调用后:
1. 检查返回 `code`0 = 成功)
2. 记录成功/失败数量
3. 全部完成后汇总:「共录入 N 条试剂,成功 X 条,失败 Y 条」
4. 如有失败,列出失败的试剂名称和错误信息
---
## 常见试剂速查表
| 名称 | CAS | 分子式 | SMILES |
| --------------------- | --------- | ---------- | ------------------------------------ |
| 水 | 7732-18-3 | H2O | O |
| 乙醇 | 64-17-5 | C2H6O | CCO |
| 乙酸 | 64-19-7 | C2H4O2 | CC(O)=O |
| 甲醇 | 67-56-1 | CH4O | CO |
| 丙酮 | 67-64-1 | C3H6O | CC(C)=O |
| 二甲基亚砜(DMSO) | 67-68-5 | C2H6OS | CS(C)=O |
| 乙酸乙酯 | 141-78-6 | C4H8O2 | CCOC(C)=O |
| 二氯甲烷 | 75-09-2 | CH2Cl2 | ClCCl |
| 四氢呋喃(THF) | 109-99-9 | C4H8O | C1CCOC1 |
| N,N-二甲基甲酰胺(DMF) | 68-12-2 | C3H7NO | CN(C)C=O |
| 氯仿 | 67-66-3 | CHCl3 | ClC(Cl)Cl |
| 乙腈 | 75-05-8 | C2H3N | CC#N |
| 甲苯 | 108-88-3 | C7H8 | Cc1ccccc1 |
| 正己烷 | 110-54-3 | C6H14 | CCCCCC |
| 异丙醇 | 67-63-0 | C3H8O | CC(C)O |
| 盐酸 | 7647-01-0 | HCl | Cl |
| 硫酸 | 7664-93-9 | H2SO4 | OS(O)(=O)=O |
| 氢氧化钠 | 1310-73-2 | NaOH | [Na]O |
| 碳酸钠 | 497-19-8 | Na2CO3 | [Na]OC([O-])=O.[Na+] |
| 氯化钠 | 7647-14-5 | NaCl | [Na]Cl |
| 乙二胺四乙酸(EDTA) | 60-00-4 | C10H16N2O8 | OC(=O)CN(CCN(CC(O)=O)CC(O)=O)CC(O)=O |
> 此表仅供快速参考。对于不在表中的试剂agent 应根据化学知识推断或提示用户补充。
---
## 完整工作流 Checklist
```
Task Progress:
- [ ] Step 1: 确认 ak/sk → 生成 AUTH token
- [ ] Step 2: 确认 --addr → 设置 BASE URL
- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid
- [ ] Step 4: 收集试剂信息(用户提供列表/逐个描述/CSV文件
- [ ] Step 5: 补全缺失字段CAS、分子式、SMILES 等)
- [ ] Step 6: 向用户确认待录入的试剂列表
- [ ] Step 7: 循环调用 POST /lab/reagent 逐条录入(每条需含 lab_uuid
- [ ] Step 8: 汇总结果(成功/失败数量及详情)
```
---
## 完整示例
用户说:「帮我录入 3 种试剂500mL 无水乙醇、1kg 氯化钠、2L 去离子水」
Agent 构建的请求序列:
```json
// 第 1 条
{"lab_uuid": "8511c672-...", "cas": "64-17-5", "name": "无水乙醇", "molecular_formula": "C2H6O", "smiles": "CCO", "stock_in_quantity": 500, "unit": "mL", "supplier": "国药集团", "production_date": "2025-01-01T00:00:00Z", "expiry_date": "2026-01-01T00:00:00Z"}
// 第 2 条
{"lab_uuid": "8511c672-...", "cas": "7647-14-5", "name": "氯化钠", "molecular_formula": "NaCl", "smiles": "[Na]Cl", "stock_in_quantity": 1, "unit": "kg", "supplier": "", "production_date": "2025-01-01T00:00:00Z", "expiry_date": "2026-01-01T00:00:00Z"}
// 第 3 条
{"lab_uuid": "8511c672-...", "cas": "7732-18-3", "name": "去离子水", "molecular_formula": "H2O", "smiles": "O", "stock_in_quantity": 2, "unit": "L", "supplier": "", "production_date": "2025-01-01T00:00:00Z", "expiry_date": "2026-01-01T00:00:00Z"}
```

View File

@@ -1,360 +0,0 @@
---
name: batch-submit-experiment
description: Batch submit experiments (notebooks) to the Uni-Lab cloud platform (leap-lab) — list workflows, generate node_params from registry schemas, submit multiple rounds, check notebook status. Use when the user wants to submit experiments, create notebooks, batch run workflows, check experiment status, or mentions 提交实验/批量实验/notebook/实验轮次/实验状态.
---
# Uni-Lab 批量提交实验指南
通过 Uni-Lab 云端 API 批量提交实验notebook支持多轮实验参数配置。根据 workflow 模板详情和本地设备注册表自动生成 `node_params` 模板。
> **重要**:本指南中的 `Authorization: Lab <token>` 是 **Uni-Lab 平台专用的认证方式**`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。
## 前置条件(缺一不可)
使用本指南前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
### 1. ak / sk → AUTH
询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。
生成 AUTH token任选一种方式
```bash
# 方式一Python 一行生成注意scheme 是 "Lab" 不是 "Basic"
python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
# 方式二:手动计算
# base64(ak:sk) → Authorization: Lab <token>
# ⚠️ 这里的 "Lab" 是 Uni-Lab 平台的 auth scheme绝对不能用 "Basic" 替代
```
### 2. --addr → BASE URL
| `--addr` 值 | BASE |
| ------------ | ----------------------------------- |
| `test` | `https://leap-lab.test.bohrium.com` |
| `uat` | `https://leap-lab.uat.bohrium.com` |
| `local` | `http://127.0.0.1:48197` |
| 不传(默认) | `https://leap-lab.bohrium.com` |
确认后设置:
```bash
BASE="<根据 addr 确定的 URL>"
# ⚠️ Auth scheme 必须是 "Lab"Uni-Lab 专用),不是 "Basic"
AUTH="Authorization: Lab <上面命令输出的 token>"
```
### 3. req_device_registry_upload.json设备注册表
**批量提交实验时需要本地注册表来解析 workflow 节点的参数 schema。**
**必须先用 Glob 工具搜索文件**,不要直接猜测路径:
```
Glob: **/req_device_registry_upload.json
```
常见位置(仅供参考,以 Glob 实际结果为准):
- `<workspace>/unilabos_data/req_device_registry_upload.json`
- `<workspace>/req_device_registry_upload.json`
找到后**检查文件修改时间**并告知用户。超过 1 天提醒用户是否需要重新启动 `unilab`
**如果 Glob 搜索无结果** → 告知用户先运行 `unilab` 启动命令,等注册表生成后再执行。可跳过此步,但将无法自动生成参数模板,需要用户手动填写 `param`
### 4. workflow_uuid目标工作流
用户需要提供要提交的 workflow UUID。如果用户不确定通过 API #3 列出可用 workflow 供选择。
**四项全部就绪后才可开始。**
## Session State
在整个对话过程中agent 需要记住以下状态,避免重复询问用户:
- `lab_uuid` — 实验室 UUID首次通过 API #1 自动获取,**不需要问用户**
- `project_uuid` — 项目 UUID通过 API #2 列出项目列表,**让用户选择**
- `workflow_uuid` — 工作流 UUID用户提供或从列表选择
- `workflow_nodes` — workflow 中各 action 节点的 uuid、设备 ID、动作名从 API #4 获取)
## 请求约定
所有请求使用 `curl -s`POST 需加 `Content-Type: application/json`
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名),示例中的 `curl` 均指 `curl.exe`。
>
> **PowerShell JSON 传参**PowerShell 中 `-d '{"key":"value"}'` 会因引号转义失败。请将 JSON 写入临时文件,用 `-d '@tmp_body.json'`(单引号包裹 `@`,否则会被解析为 splatting 运算符)。
---
## API Endpoints
### 1. 获取实验室信息(自动获取 lab_uuid
```bash
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
```
返回:
```json
{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } }
```
记住 `data.uuid``lab_uuid`
### 2. 列出实验室项目(让用户选择项目)
```bash
curl -s -X GET "$BASE/api/v1/lab/project/list?lab_uuid=$lab_uuid" -H "$AUTH"
```
返回:
```json
{
"code": 0,
"data": {
"items": [
{
"uuid": "1b3f249a-...",
"name": "bt",
"description": null,
"status": "active",
"created_at": "2026-04-09T14:31:28+08:00"
},
{
"uuid": "b6366243-...",
"name": "default",
"description": "默认项目",
"status": "active",
"created_at": "2026-03-26T11:13:36+08:00"
}
]
}
}
```
展示 `data.items[]` 中每个项目的 `name``uuid`,让用户选择。用户**必须**选择一个项目,记住 `project_uuid`(即选中项目的 `uuid`),后续创建 notebook 时需要提供。
### 3. 列出可用 workflow
```bash
curl -s -X GET "$BASE/api/v1/lab/workflow/workflows?page=1&page_size=20&lab_uuid=$lab_uuid" -H "$AUTH"
```
返回 workflow 列表,展示给用户选择。列出每个 workflow 的 `uuid``name`
### 4. 获取 workflow 模板详情
```bash
curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH"
```
返回 workflow 的完整结构,包含所有 action 节点信息。需要从响应中提取:
- 每个 action 节点的 `node_uuid`
- 每个节点对应的设备 ID`resource_template_name`
- 每个节点的动作名(`node_template_name`
- 每个节点的现有参数(`param`
> **注意**:此 API 返回格式可能因版本不同而有差异。首次调用时,先打印完整响应分析结构,再提取节点信息。常见的节点字段路径为 `data.nodes[]` 或 `data.workflow_nodes[]`。
### 5. 提交实验(创建 notebook
```bash
curl -s -X POST "$BASE/api/v1/lab/notebook" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '<request_body>'
```
请求体结构:
```json
{
"lab_uuid": "<lab_uuid>",
"project_uuid": "<project_uuid>",
"workflow_uuid": "<workflow_uuid>",
"name": "<实验名称>",
"node_params": [
{
"sample_uuids": ["<样品UUID1>", "<样品UUID2>"],
"datas": [
{
"node_uuid": "<workflow中的节点UUID>",
"param": {},
"sample_params": [
{
"container_uuid": "<容器UUID>",
"sample_value": {
"liquid_names": "<液体名称>",
"volumes": 1000
}
}
]
}
]
}
]
}
```
> **注意**`sample_uuids` 必须是 **UUID 数组**`[]uuid.UUID`),不是字符串。无样品时传空数组 `[]`。
### 6. 查询 notebook 状态
提交成功后,使用返回的 notebook UUID 查询执行状态:
```bash
curl -s -X GET "$BASE/api/v1/lab/notebook/status?uuid=$notebook_uuid" -H "$AUTH"
```
提交后应**立即查询一次**状态,确认 notebook 已被正确接收并开始调度。
---
## Notebook 请求体详解
### node_params 结构
`node_params` 是一个数组,**每个元素代表一轮实验**
- 要跑 2 轮 → `node_params` 有 2 个元素
- 要跑 N 轮 → `node_params` 有 N 个元素
### 每轮的字段
| 字段 | 类型 | 说明 |
| -------------- | ------------- | ----------------------------------------- |
| `sample_uuids` | array\<uuid\> | 该轮实验的样品 UUID 数组,无样品时传 `[]` |
| `datas` | array | 该轮中每个 workflow 节点的参数配置 |
### datas 中每个节点
| 字段 | 类型 | 说明 |
| --------------- | ------ | -------------------------------------------- |
| `node_uuid` | string | workflow 模板中的节点 UUID从 API #4 获取) |
| `param` | object | 动作参数(根据本地注册表 schema 填写) |
| `sample_params` | array | 样品相关参数(液体名、体积等) |
### sample_params 中每条
| 字段 | 类型 | 说明 |
| ---------------- | ------ | ---------------------------------------------------- |
| `container_uuid` | string | 容器 UUID |
| `sample_value` | object | 样品值,如 `{"liquid_names": "水", "volumes": 1000}` |
---
## 从本地注册表生成 param 模板
### 自动方式 — 运行脚本
```bash
python scripts/gen_notebook_params.py \
--auth <token> \
--base <BASE_URL> \
--workflow-uuid <workflow_uuid> \
[--registry <path/to/req_device_registry_upload.json>] \
[--rounds <轮次数>] \
[--output <输出文件路径>]
```
> 脚本位于本文档同级目录下的 `scripts/gen_notebook_params.py`。
脚本会:
1. 调用 workflow detail API 获取所有 action 节点
2. 读取本地注册表,为每个节点查找对应的 action schema
3. 生成 `notebook_template.json`,包含:
- 完整 `node_params` 骨架
- 每个节点的 param 字段及类型说明
- `_schema_info` 辅助信息(不提交,仅供参考)
### 手动方式
如果脚本不可用或注册表不存在:
1. 调用 API #4 获取 workflow 详情
2. 找到每个 action 节点的 `node_uuid`
3. 在本地注册表中查找对应设备的 `action_value_mappings`
```
resources[].id == <device_id>
→ resources[].class.action_value_mappings.<action_name>.schema.properties.goal.properties
```
4. 将 schema 中的 properties 作为 `param` 的字段模板
5. 按轮次复制 `node_params` 元素,让用户填写每轮的具体值
### 注册表结构参考
```json
{
"resources": [
{
"id": "liquid_handler.prcxi",
"class": {
"module": "unilabos.devices.xxx:ClassName",
"action_value_mappings": {
"transfer_liquid": {
"type": "LiquidHandlerTransfer",
"schema": {
"properties": {
"goal": {
"properties": {
"asp_vols": {
"type": "array",
"items": { "type": "number" }
},
"sources": { "type": "array" }
},
"required": ["asp_vols", "sources"]
}
}
},
"goal_default": {}
}
}
}
}
]
}
```
`param` 填写时,使用 `goal.properties` 中的字段名和类型。
---
## 完整工作流 Checklist
```
Task Progress:
- [ ] Step 1: 确认 ak/sk → 生成 AUTH token
- [ ] Step 2: 确认 --addr → 设置 BASE URL
- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid
- [ ] Step 4: GET /lab/project/list → 列出项目,让用户选择 → 获取 project_uuid
- [ ] Step 5: 确认 workflow_uuid用户提供或从 GET #3 列表选择)
- [ ] Step 6: GET workflow detail (#4) → 提取各节点 uuid、设备ID、动作名
- [ ] Step 7: 定位本地注册表 req_device_registry_upload.json
- [ ] Step 8: 运行 gen_notebook_params.py 或手动匹配 → 生成 node_params 模板
- [ ] Step 9: 引导用户填写每轮的参数sample_uuids、param、sample_params
- [ ] Step 10: 构建完整请求体(含 project_uuid→ POST /lab/notebook 提交
- [ ] Step 11: 检查返回结果,记录 notebook UUID
- [ ] Step 12: GET /lab/notebook/status → 查询 notebook 状态,确认已调度
```
---
## 常见问题
### Q: workflow 中有多个节点,每轮都要填所有节点的参数吗?
是的。`datas` 数组中需要包含该轮实验涉及的每个 workflow 节点的参数。通常每个 action 节点都需要一条 `datas` 记录。
### Q: 多轮实验的参数完全不同吗?
通常每轮的 `param`(设备动作参数)可能相同或相似,但 `sample_uuids` 和 `sample_params`(样品信息)每轮不同。脚本生成模板时会按轮次复制骨架,用户只需修改差异部分。
### Q: 如何获取 sample_uuids 和 container_uuid
这些 UUID 通常来自实验室的样品管理系统。向用户询问或从资源树API `GET /lab/material/download/$lab_uuid`)中查找。

View File

@@ -1,395 +0,0 @@
#!/usr/bin/env python3
"""
从 workflow 模板详情 + 本地设备注册表生成 notebook 提交用的 node_params 模板。
用法:
python gen_notebook_params.py --auth <token> --base <url> --workflow-uuid <uuid> [选项]
选项:
--auth <token> Lab tokenbase64(ak:sk) 的结果,不含 "Lab " 前缀)
--base <url> API 基础 URL如 https://leap-lab.test.bohrium.com
--workflow-uuid <uuid> 目标 workflow 的 UUID
--registry <path> 本地注册表文件路径(默认自动搜索)
--rounds <n> 实验轮次数(默认 1
--output <path> 输出模板文件路径(默认 notebook_template.json
--dump-response 打印 workflow detail API 的原始响应(调试用)
示例:
python gen_notebook_params.py \\
--auth YTFmZDlkNGUtxxxx \\
--base https://leap-lab.test.bohrium.com \\
--workflow-uuid abc-123-def \\
--rounds 2
"""
import copy
import json
import os
import sys
from datetime import datetime
from urllib.request import Request, urlopen
from urllib.error import HTTPError, URLError
REGISTRY_FILENAME = "req_device_registry_upload.json"
def find_registry(explicit_path=None):
"""查找本地注册表文件,逻辑同 extract_device_actions.py"""
if explicit_path:
if os.path.isfile(explicit_path):
return explicit_path
if os.path.isdir(explicit_path):
fp = os.path.join(explicit_path, REGISTRY_FILENAME)
if os.path.isfile(fp):
return fp
print(f"警告: 指定的注册表路径不存在: {explicit_path}")
return None
candidates = [
os.path.join("unilabos_data", REGISTRY_FILENAME),
REGISTRY_FILENAME,
]
for c in candidates:
if os.path.isfile(c):
return c
script_dir = os.path.dirname(os.path.abspath(__file__))
workspace_root = os.path.normpath(os.path.join(script_dir, "..", "..", ".."))
for c in candidates:
path = os.path.join(workspace_root, c)
if os.path.isfile(path):
return path
cwd = os.getcwd()
for _ in range(5):
parent = os.path.dirname(cwd)
if parent == cwd:
break
cwd = parent
for c in candidates:
path = os.path.join(cwd, c)
if os.path.isfile(path):
return path
return None
def load_registry(path):
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def build_registry_index(registry_data):
"""构建 device_id → action_value_mappings 的索引"""
index = {}
for res in registry_data.get("resources", []):
rid = res.get("id", "")
avm = res.get("class", {}).get("action_value_mappings", {})
if rid and avm:
index[rid] = avm
return index
def flatten_goal_schema(action_data):
"""从 action_value_mappings 条目中提取 goal 层的 schema"""
schema = action_data.get("schema", {})
goal_schema = schema.get("properties", {}).get("goal", {})
return goal_schema if goal_schema else schema
def build_param_template(goal_schema):
"""根据 goal schema 生成 param 模板,含类型标注"""
properties = goal_schema.get("properties", {})
required = set(goal_schema.get("required", []))
template = {}
for field_name, field_def in properties.items():
if field_name == "unilabos_device_id":
continue
ftype = field_def.get("type", "any")
default = field_def.get("default")
if default is not None:
template[field_name] = default
elif ftype == "string":
template[field_name] = f"$TODO ({ftype}, {'required' if field_name in required else 'optional'})"
elif ftype == "number" or ftype == "integer":
template[field_name] = 0
elif ftype == "boolean":
template[field_name] = False
elif ftype == "array":
template[field_name] = []
elif ftype == "object":
template[field_name] = {}
else:
template[field_name] = f"$TODO ({ftype})"
return template
def fetch_workflow_detail(base_url, auth_token, workflow_uuid):
"""调用 workflow detail API"""
url = f"{base_url}/api/v1/lab/workflow/template/detail/{workflow_uuid}"
req = Request(url, method="GET")
req.add_header("Authorization", f"Lab {auth_token}")
try:
with urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode("utf-8"))
except HTTPError as e:
body = e.read().decode("utf-8", errors="replace")
print(f"API 错误 {e.code}: {body}")
return None
except URLError as e:
print(f"网络错误: {e.reason}")
return None
def extract_nodes_from_response(response):
"""
从 workflow detail 响应中提取 action 节点列表。
适配多种可能的响应格式。
返回: [(node_uuid, resource_template_name, node_template_name, existing_param), ...]
"""
data = response.get("data", response)
search_keys = ["nodes", "workflow_nodes", "node_list", "steps"]
nodes_raw = None
for key in search_keys:
if key in data and isinstance(data[key], list):
nodes_raw = data[key]
break
if nodes_raw is None:
if isinstance(data, list):
nodes_raw = data
else:
for v in data.values():
if isinstance(v, list) and len(v) > 0 and isinstance(v[0], dict):
nodes_raw = v
break
if not nodes_raw:
print("警告: 未能从响应中提取节点列表")
print("响应顶层 keys:", list(data.keys()) if isinstance(data, dict) else type(data).__name__)
return []
result = []
for node in nodes_raw:
if not isinstance(node, dict):
continue
node_uuid = (
node.get("uuid")
or node.get("node_uuid")
or node.get("id")
or ""
)
resource_name = (
node.get("resource_template_name")
or node.get("device_id")
or node.get("resource_name")
or node.get("device_name")
or ""
)
template_name = (
node.get("node_template_name")
or node.get("action_name")
or node.get("template_name")
or node.get("action")
or node.get("name")
or ""
)
existing_param = node.get("param", {}) or {}
if node_uuid:
result.append((node_uuid, resource_name, template_name, existing_param))
return result
def generate_template(nodes, registry_index, rounds):
"""生成 notebook 提交模板"""
node_params = []
schema_info = {}
datas_template = []
for node_uuid, resource_name, template_name, existing_param in nodes:
param_template = {}
matched = False
if resource_name and template_name and resource_name in registry_index:
avm = registry_index[resource_name]
if template_name in avm:
goal_schema = flatten_goal_schema(avm[template_name])
param_template = build_param_template(goal_schema)
goal_default = avm[template_name].get("goal_default", {})
if goal_default:
for k, v in goal_default.items():
if k in param_template and v is not None:
param_template[k] = v
matched = True
schema_info[node_uuid] = {
"device_id": resource_name,
"action_name": template_name,
"action_type": avm[template_name].get("type", ""),
"schema_properties": list(goal_schema.get("properties", {}).keys()),
"required": goal_schema.get("required", []),
}
if not matched and existing_param:
param_template = existing_param
if not matched and not existing_param:
schema_info[node_uuid] = {
"device_id": resource_name,
"action_name": template_name,
"warning": "未在本地注册表中找到匹配的 action schema",
}
datas_template.append({
"node_uuid": node_uuid,
"param": param_template,
"sample_params": [
{
"container_uuid": "$TODO_CONTAINER_UUID",
"sample_value": {
"liquid_names": "$TODO_LIQUID_NAME",
"volumes": 0,
},
}
],
})
for i in range(rounds):
node_params.append({
"sample_uuids": f"$TODO_SAMPLE_UUID_ROUND_{i + 1}",
"datas": copy.deepcopy(datas_template),
})
return {
"lab_uuid": "$TODO_LAB_UUID",
"project_uuid": "$TODO_PROJECT_UUID",
"workflow_uuid": "$TODO_WORKFLOW_UUID",
"name": "$TODO_EXPERIMENT_NAME",
"node_params": node_params,
"_schema_info仅参考提交时删除": schema_info,
}
def parse_args(argv):
"""简单的参数解析"""
opts = {
"auth": None,
"base": None,
"workflow_uuid": None,
"registry": None,
"rounds": 1,
"output": "notebook_template.json",
"dump_response": False,
}
i = 0
while i < len(argv):
arg = argv[i]
if arg == "--auth" and i + 1 < len(argv):
opts["auth"] = argv[i + 1]
i += 2
elif arg == "--base" and i + 1 < len(argv):
opts["base"] = argv[i + 1].rstrip("/")
i += 2
elif arg == "--workflow-uuid" and i + 1 < len(argv):
opts["workflow_uuid"] = argv[i + 1]
i += 2
elif arg == "--registry" and i + 1 < len(argv):
opts["registry"] = argv[i + 1]
i += 2
elif arg == "--rounds" and i + 1 < len(argv):
opts["rounds"] = int(argv[i + 1])
i += 2
elif arg == "--output" and i + 1 < len(argv):
opts["output"] = argv[i + 1]
i += 2
elif arg == "--dump-response":
opts["dump_response"] = True
i += 1
else:
print(f"未知参数: {arg}")
i += 1
return opts
def main():
opts = parse_args(sys.argv[1:])
if not opts["auth"] or not opts["base"] or not opts["workflow_uuid"]:
print("用法:")
print(" python gen_notebook_params.py --auth <token> --base <url> --workflow-uuid <uuid> [选项]")
print()
print("必需参数:")
print(" --auth <token> Lab tokenbase64(ak:sk)")
print(" --base <url> API 基础 URL")
print(" --workflow-uuid <uuid> 目标 workflow UUID")
print()
print("可选参数:")
print(" --registry <path> 注册表文件路径(默认自动搜索)")
print(" --rounds <n> 实验轮次数(默认 1")
print(" --output <path> 输出文件路径(默认 notebook_template.json")
print(" --dump-response 打印 API 原始响应")
sys.exit(1)
# 1. 查找并加载本地注册表
registry_path = find_registry(opts["registry"])
registry_index = {}
if registry_path:
mtime = os.path.getmtime(registry_path)
gen_time = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
print(f"注册表: {registry_path} (生成时间: {gen_time})")
registry_data = load_registry(registry_path)
registry_index = build_registry_index(registry_data)
print(f"已索引 {len(registry_index)} 个设备的 action schemas")
else:
print("警告: 未找到本地注册表,将跳过 param 模板生成")
print(" 提交时需要手动填写各节点的 param 字段")
# 2. 获取 workflow 详情
print(f"\n正在获取 workflow 详情: {opts['workflow_uuid']}")
response = fetch_workflow_detail(opts["base"], opts["auth"], opts["workflow_uuid"])
if not response:
print("错误: 无法获取 workflow 详情")
sys.exit(1)
if opts["dump_response"]:
print("\n=== API 原始响应 ===")
print(json.dumps(response, indent=2, ensure_ascii=False)[:5000])
print("=== 响应结束(截断至 5000 字符) ===\n")
# 3. 提取节点
nodes = extract_nodes_from_response(response)
if not nodes:
print("错误: 未能从 workflow 中提取任何 action 节点")
print("请使用 --dump-response 查看原始响应结构")
sys.exit(1)
print(f"\n找到 {len(nodes)} 个 action 节点:")
print(f" {'节点 UUID':<40} {'设备 ID':<30} {'动作名':<25} {'Schema'}")
print(" " + "-" * 110)
for node_uuid, resource_name, template_name, _ in nodes:
matched = "" if (resource_name in registry_index and
template_name in registry_index.get(resource_name, {})) else ""
print(f" {node_uuid:<40} {resource_name:<30} {template_name:<25} {matched}")
# 4. 生成模板
template = generate_template(nodes, registry_index, opts["rounds"])
template["workflow_uuid"] = opts["workflow_uuid"]
output_path = opts["output"]
with open(output_path, "w", encoding="utf-8") as f:
json.dump(template, f, indent=2, ensure_ascii=False)
print(f"\n模板已写入: {output_path}")
print(f" 轮次数: {opts['rounds']}")
print(f" 节点数/轮: {len(nodes)}")
print()
print("下一步:")
print(" 1. 打开模板文件,将 $TODO 占位符替换为实际值")
print(" 2. 删除 _schema_info 字段(仅供参考)")
print(" 3. 使用 POST /api/v1/lab/notebook 提交")
if __name__ == "__main__":
main()

View File

@@ -1,500 +0,0 @@
---
name: create-device-skill
description: Create a skill for any Uni-Lab device by extracting action schemas from the device registry. Use when the user wants to create a new device skill, add device API documentation, or set up action schemas for a device.
---
# 创建设备 Skill 指南
本 meta-skill 教你如何为任意 Uni-Lab-OS 设备创建完整的 API 操作技能(参考 `unilab-device-api` 的成功案例)。
## 数据源
- **设备注册表**: `unilabos_data/req_device_registry_upload.json`
- **结构**: `{ "resources": [{ "id": "<device_id>", "class": { "module": "<python_module:ClassName>", "action_value_mappings": { ... } } }] }`
- **生成时机**: `unilab` 启动并完成注册表上传后自动生成
- **module 字段**: 格式 `unilabos.devices.xxx.yyy:ClassName`,可转为源码路径 `unilabos/devices/xxx/yyy.py`,阅读源码可了解参数含义和设备行为
## 创建流程
### Step 0 — 收集必备信息(缺一不可,否则询问后终止)
开始前**必须**确认以下 4 项信息全部就绪。如果用户未提供任何一项,**立即询问并终止当前流程**,等用户补齐后再继续。
向用户提问:「请提供你的 unilab 启动参数,我需要以下信息:」
#### 必备项 ①ak / sk认证凭据
来源:启动命令的 `--ak` `--sk` 参数,或 config.py 中的 `ak = "..."` `sk = "..."`
获取后立即生成 AUTH token
```bash
python ./scripts/gen_auth.py <ak> <sk>
# 或从 config.py 提取
python ./scripts/gen_auth.py --config <config.py>
```
认证算法:`base64(ak:sk)``Authorization: Lab <token>`
#### 必备项 ②:--addr目标环境
决定 API 请求发往哪个服务器。从启动命令的 `--addr` 参数获取:
| `--addr` 值 | BASE URL |
| -------------- | ----------------------------------- |
| `test` | `https://leap-lab.test.bohrium.com` |
| `uat` | `https://leap-lab.uat.bohrium.com` |
| `local` | `http://127.0.0.1:48197` |
| 不传(默认) | `https://leap-lab.bohrium.com` |
| 其他自定义 URL | 直接使用该 URL |
#### 必备项 ③req_device_registry_upload.json设备注册表
数据文件由 `unilab` 启动时自动生成,需要定位它:
**推断 working_dir**(即 `unilabos_data` 所在目录):
| 条件 | working_dir 取值 |
| -------------------- | -------------------------------------------------------- |
| 传了 `--working_dir` | `<working_dir>/unilabos_data/`(若子目录已存在则直接用) |
| 仅传了 `--config` | `<config 文件所在目录>/unilabos_data/` |
| 都没传 | `<当前工作目录>/unilabos_data/` |
**按优先级搜索文件**
```
<推断的 working_dir>/unilabos_data/req_device_registry_upload.json
<推断的 working_dir>/req_device_registry_upload.json
<workspace 根目录>/unilabos_data/req_device_registry_upload.json
```
也可以直接 Glob 搜索:`**/req_device_registry_upload.json`
找到后**必须检查文件修改时间**并告知用户:「找到注册表文件 `<路径>`,生成于 `<时间>`。请确认这是最近一次启动生成的。」超过 1 天提醒用户是否需要重新启动 `unilab`
**如果文件不存在** → 告知用户先运行 `unilab` 启动命令,等日志出现 `注册表响应数据已保存` 后再执行本流程。**终止。**
#### 必备项 ④:目标设备
用户需要明确要为哪个设备创建 skill。可以是设备名称如「PRCXI 移液站」)或 device_id`liquid_handler.prcxi`)。
如果用户不确定,运行提取脚本列出所有设备供选择:
```bash
python ./scripts/extract_device_actions.py --registry <找到的文件路径>
```
**四项全部就绪后才进入 Step 1。**
### Step 1 — 列出可用设备
运行提取脚本,列出所有设备及 action 数量和 Python 源码路径,让用户选择:
```bash
# 自动搜索(默认在 unilabos_data/ 和当前目录查找)
python ./scripts/extract_device_actions.py
# 指定注册表文件路径
python ./scripts/extract_device_actions.py --registry <path/to/req_device_registry_upload.json>
```
脚本输出包含每个设备的 **Python 源码路径**(从 `class.module` 转换),可用于后续阅读源码理解参数含义。
### Step 2 — 提取 Action Schema
用户选择设备后,运行提取脚本:
```bash
python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./skills/<skill-name>/actions/
```
脚本会显示设备的 Python 源码路径和类名,方便阅读源码了解参数含义。
每个 action 生成一个 JSON 文件,包含:
- `type` — 作为 API 调用的 `action_type`
- `schema` — 完整 JSON Schema`properties.goal.properties` 参数定义)
- `goal` — goal 字段映射(含占位符 `$placeholder`
- `goal_default` — 默认值
### Step 3 — 写 action-index.md
按模板为每个 action 写条目(**必须包含 `action_type`**
```markdown
### `<action_name>`
<用途描述(一句话)>
- **action_type**: `<从 actions/<name>.json 的 type 字段获取>`
- **Schema**: [`actions/<filename>.json`](actions/<filename>.json)
- **核心参数**: `param1`, `param2`(从 schema.required 获取)
- **可选参数**: `param3`, `param4`
- **占位符字段**: `field`(需填入物料信息,值以 `$` 开头)
```
描述规则:
- **每个 action 必须标注 `action_type`**(从 JSON 的 `type` 字段读取),这是 API #9 调用时的必填参数,传错会导致任务永远卡住
-`schema.properties` 读参数列表schema 已提升为 goal 内容)
-`schema.required` 区分核心/可选参数
- 按功能分类(移液、枪头、外设等)
- 标注 `placeholder_keys` 中的字段类型:
- `unilabos_resources`**ResourceSlot**,填入 `{id, name, uuid}`id 是路径格式,从资源树取物料节点)
- `unilabos_devices`**DeviceSlot**,填入路径字符串如 `"/host_node"`(从资源树筛选 type=device
- `unilabos_nodes`**NodeSlot**,填入路径字符串如 `"/PRCXI/PRCXI_Deck"`(资源树中任意节点)
- `unilabos_class`**ClassSlot**,填入类名字符串如 `"container"`(从注册表查找)
- `unilabos_formulation`**FormulationSlot**,填入配方数组 `[{well_name, liquids: [{name, volume}]}]`well_name 为目标物料的 name
- array 类型字段 → `[{id, name, uuid}, ...]`
- 特殊:`create_resource``res_id`ResourceSlot可填不存在的路径
### Step 4 — 写 SKILL.md
直接复用 `unilab-device-api` 的 API 模板,修改:
- 设备名称
- Action 数量
- 目录列表
- Session state 中的 `device_name`
- **AUTH 头** — 使用 Step 0 中 `gen_auth.py` 生成的 `Authorization: Lab <token>`(不要硬编码 `Api` 类型的 key
- **Python 源码路径** — 在 SKILL.md 开头注明设备对应的源码文件,方便参考参数含义
- **Slot 字段表** — 列出本设备哪些 action 的哪些字段需要填入 Slot物料/设备/节点/类名)
- **action_type 速查表** — 在 API #9 说明后面紧跟一个表格,列出每个 action 对应的 `action_type` 值(从 JSON `type` 字段提取),方便 agent 快速查找而无需打开 JSON 文件
API 模板结构:
```markdown
## 设备信息
- device_id, Python 源码路径, 设备类名
## 前置条件(缺一不可)
- ak/sk → AUTH, --addr → BASE URL
## 请求约定
- Windows 平台必须用 curl.exe非 PowerShell 的 curl 别名)
## Session State
- lab_uuid通过 GET /edge/lab/info 直接获取,不要问用户), device_name
## API Endpoints
# - #1 GET /edge/lab/info → 直接拿到 lab_uuid
# - #2 创建工作流 POST /lab/workflow/owner → 拼 URL 告知用户
# - #3 创建节点 POST /edge/workflow/node
# body: {workflow_uuid, resource_template_name: "<device_id>", node_template_name: "<action_name>"}
# - #4 删除节点 DELETE /lab/workflow/nodes
# - #5 更新节点参数 PATCH /lab/workflow/node
# - #6 查询节点 handles POST /lab/workflow/node-handles
# body: {node_uuids: ["uuid1","uuid2"]} → 返回各节点的 handle_uuid
# - #7 批量创建边 POST /lab/workflow/edges
# body: {edges: [{source_node_uuid, target_node_uuid, source_handle_uuid, target_handle_uuid}]}
# - #8 启动工作流 POST /lab/workflow/{uuid}/run
# - #9 运行设备单动作 POST /lab/mcp/run/action action_type 必须从 action-index.md 或 actions/<name>.json 的 type 字段获取,传错会导致任务永远卡住)
# - #10 查询任务状态 GET /lab/mcp/task/{task_uuid}
# - #11 运行工作流单节点 POST /lab/mcp/run/workflow/action
# - #12 获取资源树 GET /lab/material/download/{lab_uuid}
# - #13 获取工作流模板详情 GET /lab/workflow/template/detail/{workflow_uuid}
# 返回 workflow 完整结构data.nodes[] 含每个节点的 uuid、name、param、device_name、handles
# - #14 按名称查询物料模板 GET /lab/material/template/by-name?lab_uuid=&name=
# 返回 res_template_uuid用于 #15 创建物料时的必填字段
# - #15 创建物料节点 POST /edge/material/node
# body: {res_template_uuid(从#14获取), name(自定义), display_name, parent_uuid?(从#12获取), ...}
# - #16 更新物料节点 PUT /edge/material/node
# body: {uuid(从#12获取), display_name?, description?, init_param_data?, data?, ...}
## Placeholder Slot 填写规则
- unilabos_resources → ResourceSlot → {"id":"/path/name","name":"name","uuid":"xxx"}
- unilabos_devices → DeviceSlot → "/parent/device" 路径字符串
- unilabos_nodes → NodeSlot → "/parent/node" 路径字符串
- unilabos_class → ClassSlot → "class_name" 字符串
- unilabos_formulation → FormulationSlot → [{well_name, liquids: [{name, volume}]}] 配方数组
- 特例create_resource 的 res_id 允许填不存在的路径
- 列出本设备所有 Slot 字段、类型及含义
## 渐进加载策略
## 完整工作流 Checklist
```
### Step 5 — 验证
检查文件完整性:
- [ ] `SKILL.md` 包含 API endpoint#1 获取 lab_uuid、#2-#7 工作流/节点/边、#8-#11 运行/查询、#12 资源树、#13 工作流模板详情、#14-#16 物料管理)
- [ ] `SKILL.md` 包含 Placeholder Slot 填写规则ResourceSlot / DeviceSlot / NodeSlot / ClassSlot / FormulationSlot + create_resource 特例)和本设备的 Slot 字段表
- [ ] `action-index.md` 列出所有 action 并有描述
- [ ] `actions/` 目录中每个 action 有对应 JSON 文件
- [ ] JSON 文件包含 `type`, `schema`(已提升为 goal 内容), `goal`, `goal_default`, `placeholder_keys` 字段
- [ ] 描述能让 agent 判断该用哪个 action
## Action JSON 文件结构
```json
{
"type": "LiquidHandlerTransfer", // → API 的 action_type
"goal": { // goal 字段映射
"sources": "sources",
"targets": "targets",
"tip_racks": "tip_racks",
"asp_vols": "asp_vols"
},
"schema": { // ← 直接是 goal 的 schema已提升
"type": "object",
"properties": { // 参数定义(即请求中 goal 的字段)
"sources": { "type": "array", "items": { "type": "object" } },
"targets": { "type": "array", "items": { "type": "object" } },
"asp_vols": { "type": "array", "items": { "type": "number" } }
},
"required": [...],
"_unilabos_placeholder_info": { // ← Slot 类型标记
"sources": "unilabos_resources",
"targets": "unilabos_resources",
"tip_racks": "unilabos_resources"
}
},
"goal_default": { ... }, // 默认值
"placeholder_keys": { // ← 汇总所有 Slot 字段
"sources": "unilabos_resources", // ResourceSlot
"targets": "unilabos_resources",
"tip_racks": "unilabos_resources",
"target_device_id": "unilabos_devices" // DeviceSlot
}
}
```
> **注意**`schema` 已由脚本从原始 `schema.properties.goal` 提升为顶层,直接包含参数定义。
> `schema.properties` 中的字段即为 API 创建节点返回的 `data.param` 中的字段PATCH 更新时直接修改 `param` 即可。
## Placeholder Slot 类型体系
`placeholder_keys` / `_unilabos_placeholder_info` 中有 5 种值,对应不同的填写方式:
| placeholder 值 | Slot 类型 | 填写格式 | 选取范围 |
| ---------------------- | --------------- | ----------------------------------------------------- | ----------------------------------------- |
| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅**物料**节点(不含设备) |
| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅**设备**节点type=device路径字符串 |
| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | **设备 + 物料**,即所有节点,路径字符串 |
| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已上报的资源类 name |
| `unilabos_formulation` | FormulationSlot | `[{well_name, liquids: [{name, volume}]}]` | 资源树中物料节点的 **name**,配合液体配方 |
### ResourceSlot`unilabos_resources`
最常见的类型。从资源树中选取**物料**节点(孔板、枪头盒、试剂槽等):
- 单个:`{"id": "/workstation/container1", "name": "container1", "uuid": "ff149a9a-..."}`
- 数组:`[{"id": "/path/a", "name": "a", "uuid": "xxx"}, ...]`
- `id` 从 parent 计算的路径格式,根据 action 语义选择正确的物料
> **特例**`create_resource` 的 `res_id`,目标物料可能尚不存在,直接填期望路径,不需要 uuid。
### DeviceSlot / NodeSlot / ClassSlot
- **DeviceSlot**`unilabos_devices`):路径字符串如 `"/host_node"`,仅 type=device 的节点
- **NodeSlot**`unilabos_nodes`):路径字符串如 `"/PRCXI/PRCXI_Deck"`,设备 + 物料均可选
- **ClassSlot**`unilabos_class`):类名字符串如 `"container"`,从 `req_resource_registry_upload.json` 查找
### FormulationSlot`unilabos_formulation`
描述**液体配方**:向哪些容器中加入哪些液体及体积。
```json
[
{
"sample_uuid": "",
"well_name": "bottle_A1",
"liquids": [{ "name": "LiPF6", "volume": 0.6 }]
}
]
```
- `well_name` — 目标物料的 **name**(从资源树取,不是 `id` 路径)
- `liquids[]` — 液体列表,每条含 `name`(试剂名)和 `volume`体积单位由上下文决定pylabrobot 内部统一 uL
- `sample_uuid` — 样品 UUID无样品传 `""`
- 与 ResourceSlot 的区别ResourceSlot 指向物料本身FormulationSlot 引用物料名并附带配方信息
### 通过 API #12 获取资源树
```bash
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
```
注意 `lab_uuid` 在路径中(不是查询参数)。返回结构:
```json
{
"code": 0,
"data": {
"nodes": [
{"name": "host_node", "uuid": "c3ec1e68-...", "type": "device", "parent": ""},
{"name": "PRCXI", "uuid": "e249c9a6-...", "type": "device", "parent": ""},
{"name": "PRCXI_Deck", "uuid": "fb6a8b71-...", "type": "deck", "parent": "PRCXI"}
],
"edges": [...]
}
}
```
- `data.nodes[]` — 所有节点(设备 + 物料),每个节点含 `name``uuid``type``parent`
- `type` 区分设备(`device`)和物料(`deck``container``resource` 等)
- `parent` 为父节点名称(空字符串表示顶级)
- 填写 Slot 时根据 placeholder 类型筛选ResourceSlot 取非 device 节点DeviceSlot 取 device 节点
- 创建/更新物料时:`parent_uuid` 取父节点的 `uuid`,更新目标的 `uuid` 取节点自身的 `uuid`
## 物料管理 API
设备 Skill 除了设备动作外,还需支持物料节点的创建和参数设定,用于在资源树中动态管理物料。
典型流程:先通过 **#14 按名称查询模板** 获取 `res_template_uuid` → 再通过 **#15 创建物料** → 之后可通过 **#16 更新物料** 修改属性。更新时需要的 `uuid``parent_uuid` 均从 **#12 资源树下载** 获取。
### API #14 — 按名称查询物料模板
创建物料前,需要先获取物料模板的 UUID。通过模板名称查询
```bash
curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=<template_name>" -H "$AUTH"
```
| 参数 | 必填 | 说明 |
| ---------- | ------ | -------------------------------- |
| `lab_uuid` | **是** | 实验室 UUID从 API #1 获取) |
| `name` | **是** | 物料模板名称(如 `"container"` |
返回 `code: 0` 时,**`data.uuid`** 即为 `res_template_uuid`,用于 API #15 创建物料。返回还包含 `name``resource_type``handles``config_infos` 等模板元信息。
模板不存在时返回 `code: 10002``data` 为空对象。模板名称来自资源注册表中已注册的资源类型。
### API #15 — 创建物料节点
```bash
curl -s -X POST "$BASE/api/v1/edge/material/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '<request_body>'
```
请求体:
```json
{
"res_template_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"name": "my_custom_bottle",
"display_name": "自定义瓶子",
"parent_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"type": "",
"init_param_data": {},
"schema": {},
"data": {
"liquids": [["water", 1000, "uL"]],
"max_volume": 50000
},
"plate_well_datas": {},
"plate_reagent_datas": {},
"pose": {},
"model": {}
}
```
| 字段 | 必填 | 类型 | 数据来源 | 说明 |
| --------------------- | ------ | ------------- | ----------------------------------- | -------------------------------------- |
| `res_template_uuid` | **是** | string (UUID) | **API #14** 按名称查询获取 | 物料模板 UUID |
| `name` | 否 | string | **用户自定义** | 节点名称(标识符),可自由命名 |
| `display_name` | 否 | string | 用户自定义 | 显示名称UI 展示用) |
| `parent_uuid` | 否 | string (UUID) | **API #12** 资源树中父节点的 `uuid` | 父节点,为空则创建顶级节点 |
| `type` | 否 | string | 从模板继承 | 节点类型 |
| `init_param_data` | 否 | object | 用户指定 | 初始化参数,覆盖模板默认值 |
| `data` | 否 | object | 用户指定 | 节点数据container 见下方 data 格式 |
| `plate_well_datas` | 否 | object | 用户指定 | 孔板子节点数据(创建带孔位的板时使用) |
| `plate_reagent_datas` | 否 | object | 用户指定 | 试剂关联数据 |
| `schema` | 否 | object | 从模板继承 | 自定义 schema不传则从模板继承 |
| `pose` | 否 | object | 用户指定 | 位姿信息 |
| `model` | 否 | object | 用户指定 | 3D 模型信息 |
#### container 的 `data` 格式
> **体积单位统一为 uL微升**。pylabrobot 体系中所有体积值(`max_volume`、`liquids` 中的 volume均为 uL。外部如果是 mL 需乘 1000 转换。
```json
{
"liquids": [["water", 1000, "uL"], ["ethanol", 500, "uL"]],
"max_volume": 50000
}
```
- `liquids` — 液体列表,每条为 `[液体名称, 体积(uL), 单位字符串]`
- `max_volume` — 容器最大容量uL如 50 mL = 50000 uL
### API #16 — 更新物料节点
```bash
curl -s -X PUT "$BASE/api/v1/edge/material/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '<request_body>'
```
请求体:
```json
{
"uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"parent_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"display_name": "新显示名称",
"description": "新描述",
"init_param_data": {},
"data": {},
"pose": {},
"schema": {},
"extra": {}
}
```
| 字段 | 必填 | 类型 | 数据来源 | 说明 |
| ----------------- | ------ | ------------- | ------------------------------------- | ---------------- |
| `uuid` | **是** | string (UUID) | **API #12** 资源树中目标节点的 `uuid` | 要更新的物料节点 |
| `parent_uuid` | 否 | string (UUID) | API #12 资源树 | 移动到新父节点 |
| `display_name` | 否 | string | 用户指定 | 更新显示名称 |
| `description` | 否 | string | 用户指定 | 更新描述 |
| `init_param_data` | 否 | object | 用户指定 | 更新初始化参数 |
| `data` | 否 | object | 用户指定 | 更新节点数据 |
| `pose` | 否 | object | 用户指定 | 更新位姿 |
| `schema` | 否 | object | 用户指定 | 更新 schema |
| `extra` | 否 | object | 用户指定 | 更新扩展数据 |
> 只传需要更新的字段,未传的字段保持不变。
## 最终目录结构
```
./<skill-name>/
├── SKILL.md # API 端点 + 渐进加载指引
├── action-index.md # 动作索引:描述/用途/核心参数
└── actions/ # 每个 action 的完整 JSON Schema
├── action1.json
├── action2.json
└── ...
```

View File

@@ -1,200 +0,0 @@
#!/usr/bin/env python3
"""
从 req_device_registry_upload.json 中提取指定设备的 action schema。
用法:
# 列出所有设备及 action 数量(自动搜索注册表文件)
python extract_device_actions.py
# 指定注册表文件路径
python extract_device_actions.py --registry <path/to/req_device_registry_upload.json>
# 提取指定设备的 action 到目录
python extract_device_actions.py <device_id> <output_dir>
python extract_device_actions.py --registry <path> <device_id> <output_dir>
示例:
python extract_device_actions.py --registry unilabos_data/req_device_registry_upload.json
python extract_device_actions.py liquid_handler.prcxi .cursor/skills/unilab-device-api/actions/
"""
import json
import os
import sys
from datetime import datetime
REGISTRY_FILENAME = "req_device_registry_upload.json"
def find_registry(explicit_path=None):
"""
查找 req_device_registry_upload.json 文件。
搜索优先级:
1. 用户通过 --registry 显式指定的路径
2. <cwd>/unilabos_data/req_device_registry_upload.json
3. <cwd>/req_device_registry_upload.json
4. <script所在目录>/../../.. (workspace根) 下的 unilabos_data/
5. 向上逐级搜索父目录(最多 5 层)
"""
if explicit_path:
if os.path.isfile(explicit_path):
return explicit_path
if os.path.isdir(explicit_path):
fp = os.path.join(explicit_path, REGISTRY_FILENAME)
if os.path.isfile(fp):
return fp
print(f"警告: 指定的路径不存在: {explicit_path}")
return None
candidates = [
os.path.join("unilabos_data", REGISTRY_FILENAME),
REGISTRY_FILENAME,
]
for c in candidates:
if os.path.isfile(c):
return c
script_dir = os.path.dirname(os.path.abspath(__file__))
workspace_root = os.path.normpath(os.path.join(script_dir, "..", "..", ".."))
for c in candidates:
path = os.path.join(workspace_root, c)
if os.path.isfile(path):
return path
cwd = os.getcwd()
for _ in range(5):
parent = os.path.dirname(cwd)
if parent == cwd:
break
cwd = parent
for c in candidates:
path = os.path.join(cwd, c)
if os.path.isfile(path):
return path
return None
def load_registry(path):
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
def list_devices(data):
"""列出所有包含 action_value_mappings 的设备,同时返回 module 路径"""
resources = data.get('resources', [])
devices = []
for res in resources:
rid = res.get('id', '')
cls = res.get('class', {})
avm = cls.get('action_value_mappings', {})
module = cls.get('module', '')
if avm:
devices.append((rid, len(avm), module))
return devices
def flatten_schema_to_goal(action_data):
"""将 schema 中嵌套的 goal 内容提升为顶层 schema去掉 feedback/result 包装"""
schema = action_data.get('schema', {})
goal_schema = schema.get('properties', {}).get('goal', {})
if goal_schema:
action_data = dict(action_data)
action_data['schema'] = goal_schema
return action_data
def extract_actions(data, device_id, output_dir):
"""提取指定设备的 action schema 到独立 JSON 文件"""
resources = data.get('resources', [])
for res in resources:
if res.get('id') == device_id:
cls = res.get('class', {})
module = cls.get('module', '')
avm = cls.get('action_value_mappings', {})
if not avm:
print(f"设备 {device_id} 没有 action_value_mappings")
return []
if module:
py_path = module.split(":")[0].replace(".", "/") + ".py"
class_name = module.split(":")[-1] if ":" in module else ""
print(f"Python 源码: {py_path}")
if class_name:
print(f"设备类: {class_name}")
os.makedirs(output_dir, exist_ok=True)
written = []
for action_name in sorted(avm.keys()):
action_data = flatten_schema_to_goal(avm[action_name])
filename = action_name.replace('-', '_') + '.json'
filepath = os.path.join(output_dir, filename)
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(action_data, f, indent=2, ensure_ascii=False)
written.append(filename)
print(f" {filepath}")
return written
print(f"设备 {device_id} 未找到")
return []
def main():
args = sys.argv[1:]
explicit_registry = None
if "--registry" in args:
idx = args.index("--registry")
if idx + 1 < len(args):
explicit_registry = args[idx + 1]
args = args[:idx] + args[idx + 2:]
else:
print("错误: --registry 需要指定路径")
sys.exit(1)
registry_path = find_registry(explicit_registry)
if not registry_path:
print(f"错误: 找不到 {REGISTRY_FILENAME}")
print()
print("解决方法:")
print(" 1. 先运行 unilab 启动命令,等待注册表生成")
print(" 2. 用 --registry 指定文件路径:")
print(f" python {sys.argv[0]} --registry <path/to/{REGISTRY_FILENAME}>")
print()
print("搜索过的路径:")
for p in [
os.path.join("unilabos_data", REGISTRY_FILENAME),
REGISTRY_FILENAME,
os.path.join("<workspace_root>", "unilabos_data", REGISTRY_FILENAME),
]:
print(f" - {p}")
sys.exit(1)
print(f"注册表: {registry_path}")
mtime = os.path.getmtime(registry_path)
gen_time = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
size_mb = os.path.getsize(registry_path) / (1024 * 1024)
print(f"生成时间: {gen_time} (文件大小: {size_mb:.1f} MB)")
data = load_registry(registry_path)
if len(args) == 0:
devices = list_devices(data)
print(f"\n找到 {len(devices)} 个设备:")
print(f"{'设备 ID':<50} {'Actions':>7} {'Python 模块'}")
print("-" * 120)
for did, count, module in sorted(devices, key=lambda x: x[0]):
py_path = module.split(":")[0].replace(".", "/") + ".py" if module else ""
print(f"{did:<50} {count:>7} {py_path}")
elif len(args) == 2:
device_id = args[0]
output_dir = args[1]
print(f"\n提取 {device_id} 的 actions 到 {output_dir}/")
written = extract_actions(data, device_id, output_dir)
if written:
print(f"\n共写入 {len(written)} 个 action 文件")
else:
print("用法:")
print(" python extract_device_actions.py [--registry <path>] # 列出设备")
print(" python extract_device_actions.py [--registry <path>] <device_id> <dir> # 提取 actions")
sys.exit(1)
if __name__ == '__main__':
main()

View File

@@ -1,69 +0,0 @@
#!/usr/bin/env python3
"""
从 ak/sk 生成 UniLab API Authorization header。
算法: base64(ak:sk) → "Authorization: Lab <token>"
用法:
python gen_auth.py <ak> <sk>
python gen_auth.py --config <config.py>
示例:
python gen_auth.py myak mysk
python gen_auth.py --config experiments/config.py
"""
import base64
import re
import sys
def gen_auth(ak: str, sk: str) -> str:
token = base64.b64encode(f"{ak}:{sk}".encode("utf-8")).decode("utf-8")
return token
def extract_from_config(config_path: str) -> tuple:
"""从 config.py 中提取 ak 和 sk"""
with open(config_path, "r", encoding="utf-8") as f:
content = f.read()
ak_match = re.search(r'''ak\s*=\s*["']([^"']+)["']''', content)
sk_match = re.search(r'''sk\s*=\s*["']([^"']+)["']''', content)
if not ak_match or not sk_match:
return None, None
return ak_match.group(1), sk_match.group(1)
def main():
args = sys.argv[1:]
if len(args) == 2 and args[0] == "--config":
ak, sk = extract_from_config(args[1])
if not ak or not sk:
print(f"错误: 在 {args[1]} 中未找到 ak/sk 配置")
print("期望格式: ak = \"xxx\" sk = \"xxx\"")
sys.exit(1)
print(f"配置文件: {args[1]}")
elif len(args) == 2:
ak, sk = args
else:
print("用法:")
print(" python gen_auth.py <ak> <sk>")
print(" python gen_auth.py --config <config.py>")
sys.exit(1)
token = gen_auth(ak, sk)
print(f"ak: {ak}")
print(f"sk: {sk}")
print()
print(f"Authorization header:")
print(f" Authorization: Lab {token}")
print()
print(f"curl 用法:")
print(f' curl -H "Authorization: Lab {token}" ...')
print()
print(f"Shell 变量:")
print(f' AUTH="Authorization: Lab {token}"')
if __name__ == "__main__":
main()

View File

@@ -1,284 +0,0 @@
---
name: submit-agent-result
description: Submit historical experiment results (agent_result) to Uni-Lab cloud platform (leap-lab) notebook — read data files, assemble JSON payload, PUT to cloud API. Use when the user wants to submit experiment results, upload agent results, report experiment data, or mentions agent_result/实验结果/历史记录/notebook结果.
---
# Uni-Lab 提交历史实验记录指南
通过 Uni-Lab 云端 API 向已创建的 notebook 提交实验结果数据agent_result。支持从 JSON / CSV 文件读取数据,整合后提交。
> **重要**:本指南中的 `Authorization: Lab <token>` 是 **Uni-Lab 平台专用的认证方式**`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。
## 前置条件(缺一不可)
使用本指南前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
### 1. ak / sk → AUTH
询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。
生成 AUTH token
```bash
# ⚠️ 注意scheme 是 "Lab"Uni-Lab 专用),不是 "Basic"
python -c "import base64,sys; print(base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
```
输出即为 token 值,拼接为 `Authorization: Lab <token>``Lab` 是 Uni-Lab 平台 auth scheme不可替换为 `Basic`)。
### 2. --addr → BASE URL
| `--addr` 值 | BASE |
| ------------ | ----------------------------------- |
| `test` | `https://leap-lab.test.bohrium.com` |
| `uat` | `https://leap-lab.uat.bohrium.com` |
| `local` | `http://127.0.0.1:48197` |
| 不传(默认) | `https://leap-lab.bohrium.com` |
确认后设置:
```bash
BASE="<根据 addr 确定的 URL>"
# ⚠️ Auth scheme 必须是 "Lab"Uni-Lab 专用),不是 "Basic"
AUTH="Authorization: Lab <上面命令输出的 token>"
```
### 3. notebook_uuid**必须询问用户**
**必须主动询问用户**:「请提供要提交结果的 notebook UUID。」
notebook_uuid 来自之前通过「批量提交实验」创建的实验批次,即 `POST /api/v1/lab/notebook` 返回的 `data.uuid`
如果用户不记得,可提示:
- 查看之前的对话记录中创建 notebook 时返回的 UUID
- 或通过平台页面查找对应的 notebook
**绝不能跳过此步骤,没有 notebook_uuid 无法提交。**
### 4. 实验结果数据
用户需要提供实验结果数据,支持以下方式:
| 方式 | 说明 |
| --------- | ----------------------------------------------- |
| JSON 文件 | 直接作为 `agent_result` 的内容合并 |
| CSV 文件 | 转为 `{"文件名": [行数据...]}` 格式 |
| 手动指定 | 用户直接告知 key-value 数据,由 agent 构建 JSON |
**四项全部就绪后才可开始。**
## Session State
在整个对话过程中agent 需要记住以下状态:
- `lab_uuid` — 实验室 UUID通过 API #1 自动获取,**不需要问用户**
- `notebook_uuid` — 目标 notebook UUID**必须询问用户**
## 请求约定
所有请求使用 `curl -s`PUT 需加 `Content-Type: application/json`
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名),示例中的 `curl` 均指 `curl.exe`。
>
> **PowerShell JSON 传参**PowerShell 中 `-d '{"key":"value"}'` 会因引号转义失败。请将 JSON 写入临时文件,用 `-d '@tmp_body.json'`(单引号包裹 `@`,否则 `@` 会被 PowerShell 解析为 splatting 运算符导致报错)。
---
## API Endpoints
### 1. 获取实验室信息(自动获取 lab_uuid
```bash
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
```
返回:
```json
{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } }
```
记住 `data.uuid``lab_uuid`
### 2. 提交实验结果agent_result
```bash
curl -s -X PUT "$BASE/api/v1/lab/notebook/agent-result" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '<request_body>'
```
请求体结构:
```json
{
"notebook_uuid": "<notebook_uuid>",
"agent_result": {
"<key1>": "<value1>",
"<key2>": 123,
"<nested_key>": {"a": 1, "b": 2},
"<array_key>": [{"col1": "v1", "col2": "v2"}, ...]
}
}
```
> **注意**HTTP 方法是 **PUT**(不是 POST
#### 必要字段
| 字段 | 类型 | 说明 |
| --------------- | ------------- | ------------------------------------------- |
| `notebook_uuid` | string (UUID) | 目标 notebook 的 UUID从批量提交实验时获取 |
| `agent_result` | object | 实验结果数据,任意 JSON 对象 |
#### agent_result 内容格式
`agent_result` 接受**任意 JSON 对象**,常见格式:
**简单键值对**
```json
{
"avg_rtt_ms": 12.5,
"status": "success",
"test_count": 5
}
```
**包含嵌套结构**
```json
{
"summary": { "total": 100, "passed": 98, "failed": 2 },
"measurements": [
{ "sample_id": "S001", "value": 3.14, "unit": "mg/mL" },
{ "sample_id": "S002", "value": 2.71, "unit": "mg/mL" }
]
}
```
**从 CSV 文件导入**(脚本自动转换):
```json
{
"experiment_data": [
{ "温度": 25, "压力": 101.3, "产率": 0.85 },
{ "温度": 30, "压力": 101.3, "产率": 0.91 }
]
}
```
---
## 整合脚本
本文档同级目录下的 `scripts/prepare_agent_result.py` 可自动读取文件并构建请求体。
### 用法
```bash
python scripts/prepare_agent_result.py \
--notebook-uuid <uuid> \
--files data1.json data2.csv \
[--auth <token>] \
[--base <BASE_URL>] \
[--submit] \
[--output <output.json>]
```
| 参数 | 必选 | 说明 |
| ----------------- | ---------- | ----------------------------------------------- |
| `--notebook-uuid` | 是 | 目标 notebook UUID |
| `--files` | 是 | 输入文件路径支持多个JSON / CSV |
| `--auth` | 提交时必选 | Lab 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 中的重复 key 后者覆盖前者。
### 示例
```bash
# 仅生成请求体文件(不提交)
python scripts/prepare_agent_result.py \
--notebook-uuid 73c67dca-c8cc-4936-85a0-329106aa7cca \
--files results.json measurements.csv
# 生成并直接提交
python scripts/prepare_agent_result.py \
--notebook-uuid 73c67dca-c8cc-4936-85a0-329106aa7cca \
--files results.json \
--auth YTFmZDlkNGUt... \
--base https://leap-lab.test.bohrium.com \
--submit
```
---
## 手动构建方式
如果不使用脚本,也可手动构建请求体:
1. 将实验结果数据组装为 JSON 对象
2. 写入临时文件:
```json
{
"notebook_uuid": "<uuid>",
"agent_result": { ... }
}
```
3. 用 curl 提交:
```bash
curl -s -X PUT "$BASE/api/v1/lab/notebook/agent-result" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '@tmp_body.json'
```
---
## 完整工作流 Checklist
```
Task Progress:
- [ ] Step 1: 确认 ak/sk → 生成 AUTH token
- [ ] Step 2: 确认 --addr → 设置 BASE URL
- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid
- [ ] Step 4: **询问用户** notebook_uuid必须不可跳过
- [ ] Step 5: 确认实验结果数据来源(文件路径或手动数据)
- [ ] Step 6: 运行 prepare_agent_result.py 或手动构建请求体
- [ ] Step 7: PUT /lab/notebook/agent-result 提交
- [ ] Step 8: 检查返回结果,确认提交成功
```
---
## 常见问题
### Q: notebook_uuid 从哪里获取?
从之前「批量提交实验」时 `POST /api/v1/lab/notebook` 的返回值 `data.uuid` 获取。也可以在平台 UI 中查找对应的 notebook。
### Q: agent_result 有固定的 schema 吗?
没有严格 schema接受任意 JSON 对象。但建议包含有意义的字段名和结构化数据,方便后续分析。
### Q: 可以多次提交同一个 notebook 的结果吗?
可以,后续提交会覆盖之前的 agent_result。
### Q: 认证方式是 Lab 还是 Api
本指南统一使用 `Authorization: Lab <base64(ak:sk)>` 方式(`Lab` 是 Uni-Lab 平台的 auth scheme**绝不能用 `Basic` 替代**)。如果用户有独立的 API Key也可用 `Authorization: Api <key>` 替代。

View File

@@ -1,133 +0,0 @@
"""
读取实验结果文件JSON / CSV整合为 agent_result 请求体并可选提交。
用法:
python prepare_agent_result.py \
--notebook-uuid <uuid> \
--files data1.json data2.csv \
[--auth <Lab token>] \
[--base <BASE_URL>] \
[--submit] \
[--output <output.json>]
支持的输入文件格式:
- .json → 直接作为 dict 合并
- .csv → 转为 {"filename": [row_dict, ...]} 格式
"""
import argparse
import base64
import csv
import json
import os
import sys
from pathlib import Path
from typing import Any, Dict, List
def read_json_file(filepath: str) -> Dict[str, Any]:
with open(filepath, "r", encoding="utf-8") as f:
return json.load(f)
def read_csv_file(filepath: str) -> List[Dict[str, Any]]:
rows = []
with open(filepath, "r", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
for row in reader:
converted = {}
for k, v in row.items():
try:
converted[k] = int(v)
except (ValueError, TypeError):
try:
converted[k] = float(v)
except (ValueError, TypeError):
converted[k] = v
rows.append(converted)
return rows
def merge_files(filepaths: List[str]) -> Dict[str, Any]:
"""将多个文件合并为一个 agent_result dict"""
merged: Dict[str, Any] = {}
for fp in filepaths:
path = Path(fp)
ext = path.suffix.lower()
key = path.stem
if ext == ".json":
data = read_json_file(fp)
if isinstance(data, dict):
merged.update(data)
else:
merged[key] = data
elif ext == ".csv":
merged[key] = read_csv_file(fp)
else:
print(f"[警告] 不支持的文件格式: {fp},跳过", file=sys.stderr)
return merged
def build_request_body(notebook_uuid: str, agent_result: Dict[str, Any]) -> Dict[str, Any]:
return {
"notebook_uuid": notebook_uuid,
"agent_result": agent_result,
}
def submit(base: str, auth: str, body: Dict[str, Any]) -> Dict[str, Any]:
try:
import requests
except ImportError:
print("[错误] 提交需要 requests 库: pip install requests", file=sys.stderr)
sys.exit(1)
url = f"{base}/api/v1/lab/notebook/agent-result"
headers = {
"Content-Type": "application/json",
"Authorization": f"Lab {auth}",
}
resp = requests.put(url, json=body, headers=headers, timeout=30)
return {"status_code": resp.status_code, "body": resp.json() if resp.headers.get("content-type", "").startswith("application/json") else resp.text}
def main():
parser = argparse.ArgumentParser(description="整合实验结果文件并构建 agent_result 请求体")
parser.add_argument("--notebook-uuid", required=True, help="目标 notebook UUID")
parser.add_argument("--files", nargs="+", required=True, help="输入文件路径JSON / CSV")
parser.add_argument("--auth", help="Lab tokenbase64(ak:sk)")
parser.add_argument("--base", help="API base URL")
parser.add_argument("--submit", action="store_true", help="直接提交到云端")
parser.add_argument("--output", default="agent_result_body.json", help="输出 JSON 文件路径")
args = parser.parse_args()
for fp in args.files:
if not os.path.exists(fp):
print(f"[错误] 文件不存在: {fp}", file=sys.stderr)
sys.exit(1)
agent_result = merge_files(args.files)
body = build_request_body(args.notebook_uuid, agent_result)
with open(args.output, "w", encoding="utf-8") as f:
json.dump(body, f, ensure_ascii=False, indent=2)
print(f"[完成] 请求体已保存: {args.output}")
print(f" notebook_uuid: {args.notebook_uuid}")
print(f" agent_result 字段数: {len(agent_result)}")
print(f" 合并文件数: {len(args.files)}")
if args.submit:
if not args.auth or not args.base:
print("[错误] 提交需要 --auth 和 --base 参数", file=sys.stderr)
sys.exit(1)
print(f"\n[提交] PUT {args.base}/api/v1/lab/notebook/agent-result ...")
result = submit(args.base, args.auth, body)
print(f" HTTP {result['status_code']}")
print(f" 响应: {json.dumps(result['body'], ensure_ascii=False)}")
if __name__ == "__main__":
main()

View File

@@ -1,26 +1,188 @@
.conda
# .github
.idea
# .vscode
output
pylabrobot_repo
recipes
scripts
service
temp
# unilabos/test
# unilabos/app/web
unilabos/device_mesh
unilabos_data
unilabos_msgs
unilabos.egg-info
CONTRIBUTORS
# LICENSE
MANIFEST.in
# ============================================================
# 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
# README.md
# README_zh.md
setup.py
setup.cfg
.gitattrubutes
**/__pycache__
# ==================== 其他 ====================
# Catkin
CATKIN_IGNORE
# Eclipse/Qt
.project
.cproject
CMakeLists.txt.user
*.user
qtcreator-*

19
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,19 @@
version: 2
updates:
# GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
target-branch: "dev"
schedule:
interval: "weekly"
day: "monday"
time: "06:00"
open-pull-requests-limit: 5
reviewers:
- "msgcenterpy-team"
labels:
- "dependencies"
- "github-actions"
commit-message:
prefix: "ci"
include: "scope"

View File

@@ -38,7 +38,7 @@ jobs:
- name: Install ROS dependencies, uv and unilabos-msgs
run: |
echo Installing ROS dependencies...
mamba install -n check-env --override-channels -c robostack-staging -c conda-forge -c uni-lab conda-forge::uv conda-forge::opencv robostack-staging::ros-humble-ros-core robostack-staging::ros-humble-action-msgs robostack-staging::ros-humble-std-msgs robostack-staging::ros-humble-geometry-msgs robostack-staging::ros-humble-control-msgs robostack-staging::ros-humble-nav2-msgs uni-lab::ros-humble-unilabos-msgs robostack-staging::ros-humble-cv-bridge robostack-staging::ros-humble-vision-opencv robostack-staging::ros-humble-tf-transformations robostack-staging::ros-humble-moveit-msgs robostack-staging::ros-humble-tf2-ros robostack-staging::ros-humble-tf2-ros-py conda-forge::transforms3d -y
mamba install -n check-env conda-forge::uv conda-forge::opencv robostack-staging::ros-humble-ros-core robostack-staging::ros-humble-action-msgs robostack-staging::ros-humble-std-msgs robostack-staging::ros-humble-geometry-msgs robostack-staging::ros-humble-control-msgs robostack-staging::ros-humble-nav2-msgs uni-lab::ros-humble-unilabos-msgs robostack-staging::ros-humble-cv-bridge robostack-staging::ros-humble-vision-opencv robostack-staging::ros-humble-tf-transformations robostack-staging::ros-humble-moveit-msgs robostack-staging::ros-humble-tf2-ros robostack-staging::ros-humble-tf2-ros-py conda-forge::transforms3d -c robostack-staging -c conda-forge -c uni-lab -y
- name: Install pip dependencies and unilabos
run: |
@@ -49,7 +49,7 @@ jobs:
uv pip uninstall enum34 || echo enum34 not installed, skipping
uv pip install .
- name: Run check mode (AST registry validation)
- name: Run check mode (complete_registry)
run: |
call conda activate check-env
echo Running check mode...

View File

@@ -1,10 +1,6 @@
name: Build Conda-Pack Environment
on:
# 在 UniLabOS Conda Build 成功上传后自动构建非全量 conda-pack
workflow_run:
workflows: ["UniLabOS Conda Build"]
types: [completed]
workflow_dispatch:
inputs:
branch:
@@ -25,16 +21,6 @@ on:
jobs:
build-conda-pack:
if: |
github.event_name == 'workflow_dispatch' ||
(
github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'workflow_run'
)
env:
BUILD_FULL: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' }}
PACKAGE_REF: ${{ github.event.inputs.branch || github.event.workflow_run.head_sha || github.ref_name }}
strategy:
fail-fast: false
matrix:
@@ -68,9 +54,7 @@ jobs:
id: should_build
shell: bash
run: |
if [[ "${{ github.event_name }}" != "workflow_dispatch" ]]; then
echo "should_build=true" >> $GITHUB_OUTPUT
elif [[ -z "${{ github.event.inputs.platforms }}" ]]; then
if [[ -z "${{ github.event.inputs.platforms }}" ]]; then
echo "should_build=true" >> $GITHUB_OUTPUT
elif [[ "${{ github.event.inputs.platforms }}" == *"${{ matrix.platform }}"* ]]; then
echo "should_build=true" >> $GITHUB_OUTPUT
@@ -81,7 +65,7 @@ jobs:
- uses: actions/checkout@v6
if: steps.should_build.outputs.should_build == 'true'
with:
ref: ${{ github.event.inputs.branch || github.event.workflow_run.head_sha || github.ref }}
ref: ${{ github.event.inputs.branch }}
fetch-depth: 0
- name: Setup Miniforge (with mamba)
@@ -91,7 +75,7 @@ jobs:
miniforge-version: latest
use-mamba: true
python-version: '3.11.14'
channels: conda-forge,robostack-staging,uni-lab
channels: conda-forge,robostack-staging,uni-lab,defaults
channel-priority: flexible
activate-environment: unilab
auto-update-conda: false
@@ -102,13 +86,13 @@ jobs:
run: |
echo Installing unilabos and dependencies to unilab environment...
echo Using mamba for faster and more reliable dependency resolution...
echo Build full: ${{ env.BUILD_FULL }}
if "${{ env.BUILD_FULL }}"=="true" (
echo Build full: ${{ github.event.inputs.build_full }}
if "${{ github.event.inputs.build_full }}"=="true" (
echo Installing unilabos-full ^(complete package^)...
mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos-full conda-pack zstandard -y
mamba install -n unilab uni-lab::unilabos-full conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
) else (
echo Installing unilabos ^(minimal package^)...
mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos conda-pack zstandard -y
mamba install -n unilab uni-lab::unilabos conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
)
- name: Install conda-pack, unilabos and dependencies (Unix)
@@ -117,13 +101,13 @@ jobs:
run: |
echo "Installing unilabos and dependencies to unilab environment..."
echo "Using mamba for faster and more reliable dependency resolution..."
echo "Build full: ${{ env.BUILD_FULL }}"
if [[ "${{ env.BUILD_FULL }}" == "true" ]]; then
echo "Build full: ${{ github.event.inputs.build_full }}"
if [[ "${{ github.event.inputs.build_full }}" == "true" ]]; then
echo "Installing unilabos-full (complete package)..."
mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos-full conda-pack zstandard -y
mamba install -n unilab uni-lab::unilabos-full conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
else
echo "Installing unilabos (minimal package)..."
mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos conda-pack zstandard -y
mamba install -n unilab uni-lab::unilabos conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
fi
- name: Get latest ros-humble-unilabos-msgs version (Windows)
@@ -150,27 +134,27 @@ jobs:
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
run: |
echo Checking for available ros-humble-unilabos-msgs versions...
mamba search --override-channels -c uni-lab -c robostack-staging -c conda-forge ros-humble-unilabos-msgs || echo Search completed
mamba search ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge || echo Search completed
echo.
echo Updating ros-humble-unilabos-msgs to latest version...
mamba update -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge ros-humble-unilabos-msgs -y || echo Already at latest version
mamba update -n unilab ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge -y || echo Already at latest version
- name: Check for newer ros-humble-unilabos-msgs (Unix)
if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64'
shell: bash
run: |
echo "Checking for available ros-humble-unilabos-msgs versions..."
mamba search --override-channels -c uni-lab -c robostack-staging -c conda-forge ros-humble-unilabos-msgs || echo "Search completed"
mamba search ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge || echo "Search completed"
echo ""
echo "Updating ros-humble-unilabos-msgs to latest version..."
mamba update -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge ros-humble-unilabos-msgs -y || echo "Already at latest version"
mamba update -n unilab ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge -y || echo "Already at latest version"
- name: Install latest unilabos from source (Windows)
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
run: |
echo Uninstalling existing unilabos...
mamba run -n unilab pip uninstall unilabos -y || echo unilabos not installed via pip
echo Installing unilabos from source (ref: ${{ env.PACKAGE_REF }})...
echo Installing unilabos from source (branch: ${{ github.event.inputs.branch }})...
mamba run -n unilab pip install .
echo Verifying installation...
mamba run -n unilab pip show unilabos
@@ -181,7 +165,7 @@ jobs:
run: |
echo "Uninstalling existing unilabos..."
mamba run -n unilab pip uninstall unilabos -y || echo "unilabos not installed via pip"
echo "Installing unilabos from source (ref: ${{ env.PACKAGE_REF }})..."
echo "Installing unilabos from source (branch: ${{ github.event.inputs.branch }})..."
mamba run -n unilab pip install .
echo "Verifying installation..."
mamba run -n unilab pip show unilabos
@@ -242,9 +226,7 @@ jobs:
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
run: |
echo Packing unilab environment with conda-pack...
for /f "delims=" %%i in ('mamba run -n unilab python -c "import os; print(os.environ['CONDA_PREFIX'])"') do set "UNILAB_PREFIX=%%i"
echo Packing environment at: %UNILAB_PREFIX%
mamba run -n unilab conda-pack -p "%UNILAB_PREFIX%" -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files
mamba activate unilab && conda pack -n unilab -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files
echo Pack file created:
dir unilab-env-${{ matrix.platform }}.tar.gz
@@ -253,9 +235,8 @@ jobs:
shell: bash
run: |
echo "Packing unilab environment with conda-pack..."
UNILAB_PREFIX="$(mamba run -n unilab python -c 'import os; print(os.environ["CONDA_PREFIX"])')"
echo "Packing environment at: $UNILAB_PREFIX"
mamba run -n unilab conda-pack -p "$UNILAB_PREFIX" -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files
mamba install conda-pack -c conda-forge -y
conda pack -n unilab -o unilab-env-${{ matrix.platform }}.tar.gz --ignore-missing-files
echo "Pack file created:"
ls -lh unilab-env-${{ matrix.platform }}.tar.gz
@@ -286,7 +267,7 @@ jobs:
rem Create README using Python script
echo Creating: README.txt
python scripts\create_readme.py ${{ matrix.platform }} ${{ env.PACKAGE_REF }} dist-package\README.txt
python scripts\create_readme.py ${{ matrix.platform }} ${{ github.event.inputs.branch }} dist-package\README.txt
echo.
echo Distribution package contents:
@@ -322,7 +303,7 @@ jobs:
# Create README using Python script
echo "Creating: README.txt"
python scripts/create_readme.py ${{ matrix.platform }} ${{ env.PACKAGE_REF }} dist-package/README.txt
python scripts/create_readme.py ${{ matrix.platform }} ${{ github.event.inputs.branch }} dist-package/README.txt
echo ""
echo "Distribution package contents:"
@@ -333,7 +314,7 @@ jobs:
if: steps.should_build.outputs.should_build == 'true'
uses: actions/upload-artifact@v6
with:
name: unilab-pack-${{ matrix.platform }}-${{ env.PACKAGE_REF }}
name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }}
path: dist-package/
retention-days: 90
if-no-files-found: error
@@ -345,9 +326,9 @@ jobs:
echo Build Summary
echo ==========================================
echo Platform: ${{ matrix.platform }}
echo Branch: ${{ env.PACKAGE_REF }}
echo Branch: ${{ github.event.inputs.branch }}
echo Python version: 3.11.14
if "${{ env.BUILD_FULL }}"=="true" (
if "${{ github.event.inputs.build_full }}"=="true" (
echo Package: unilabos-full ^(complete^)
) else (
echo Package: unilabos ^(minimal^)
@@ -356,7 +337,7 @@ jobs:
echo Distribution package contents:
dir dist-package
echo.
echo Artifact name: unilab-pack-${{ matrix.platform }}-${{ env.PACKAGE_REF }}
echo Artifact name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }}
echo.
echo After download, extract the ZIP and run:
echo install_unilab.bat
@@ -370,9 +351,9 @@ jobs:
echo "Build Summary"
echo "=========================================="
echo "Platform: ${{ matrix.platform }}"
echo "Branch: ${{ env.PACKAGE_REF }}"
echo "Branch: ${{ github.event.inputs.branch }}"
echo "Python version: 3.11.14"
if [[ "${{ env.BUILD_FULL }}" == "true" ]]; then
if [[ "${{ github.event.inputs.build_full }}" == "true" ]]; then
echo "Package: unilabos-full (complete)"
else
echo "Package: unilabos (minimal)"
@@ -381,7 +362,7 @@ jobs:
echo "Distribution package contents:"
ls -lh dist-package/
echo ""
echo "Artifact name: unilab-pack-${{ matrix.platform }}-${{ env.PACKAGE_REF }}"
echo "Artifact name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }}"
echo ""
echo "After download:"
echo " install_unilab.sh"

View File

@@ -56,7 +56,7 @@ jobs:
miniforge-version: latest
use-mamba: true
python-version: '3.11.14'
channels: conda-forge,robostack-staging,uni-lab
channels: conda-forge,robostack-staging,uni-lab,defaults
channel-priority: flexible
activate-environment: unilab
auto-update-conda: false
@@ -66,7 +66,7 @@ jobs:
run: |
echo "Installing unilabos and dependencies to unilab environment..."
echo "Using mamba for faster and more reliable dependency resolution..."
mamba install -n unilab --override-channels -c uni-lab -c robostack-staging -c conda-forge uni-lab::unilabos -y
mamba install -n unilab uni-lab::unilabos -c uni-lab -c robostack-staging -c conda-forge -y
- name: Install latest unilabos from source
run: |

View File

@@ -10,9 +10,6 @@ on:
# 支持 tag 推送(不依赖 CI Check
push:
tags: ['v*']
# GitHub Release 发布时自动构建并上传
release:
types: [published]
# 手动触发
workflow_dispatch:
inputs:
@@ -83,7 +80,7 @@ jobs:
- uses: actions/checkout@v6
with:
# 如果是 workflow_run 触发,使用触发 CI Check 的 commit
ref: ${{ github.event.workflow_run.head_sha || github.event.release.tag_name || github.ref }}
ref: ${{ github.event.workflow_run.head_sha || github.ref }}
fetch-depth: 0
- name: Check if platform should be built
@@ -99,13 +96,12 @@ jobs:
echo "should_build=false" >> $GITHUB_OUTPUT
fi
- name: Setup Miniforge
- name: Setup Miniconda
if: steps.should_build.outputs.should_build == 'true'
uses: conda-incubator/setup-miniconda@v3
with:
miniforge-version: latest
use-mamba: true
channels: conda-forge,robostack-staging
miniconda-version: 'latest'
channels: conda-forge,robostack-staging,defaults
channel-priority: strict
activate-environment: build-env
auto-update-conda: false
@@ -114,7 +110,7 @@ jobs:
- name: Install rattler-build and anaconda-client
if: steps.should_build.outputs.should_build == 'true'
run: |
mamba install --override-channels -c conda-forge rattler-build anaconda-client -y
conda install -c conda-forge rattler-build anaconda-client
- name: Show environment info
if: steps.should_build.outputs.should_build == 'true'
@@ -161,13 +157,7 @@ jobs:
retention-days: 30
- name: Upload to Anaconda.org (unilab organization)
if: |
steps.should_build.outputs.should_build == 'true' &&
(
github.event_name == 'release' ||
startsWith(github.ref, 'refs/tags/') ||
github.event.inputs.upload_to_anaconda == 'true'
)
if: steps.should_build.outputs.should_build == 'true' && github.event.inputs.upload_to_anaconda == 'true'
run: |
for package in $(find ./output -name "*.conda"); do
echo "Uploading $package to unilab organization..."

View File

@@ -1,10 +1,14 @@
name: UniLabOS Conda Build
on:
# 在 Multi-Platform Conda Build 成功上传 msgs 后自动触发
# 在 CI Check 成功后自动触发
workflow_run:
workflows: ["Multi-Platform Conda Build"]
workflows: ["CI Check"]
types: [completed]
branches: [main, dev]
# 标签推送时直接触发(发布版本)
push:
tags: ['v*']
# 手动触发
workflow_dispatch:
inputs:
@@ -29,30 +33,30 @@ on:
type: boolean
jobs:
# 等待上游 msgs 构建完成的 job (仅用于 workflow_run 触发)
wait-for-upstream:
# 等待 CI Check 完成的 job (仅用于 workflow_run 触发)
wait-for-ci:
runs-on: ubuntu-latest
if: github.event_name == 'workflow_run'
outputs:
should_continue: ${{ steps.check.outputs.should_continue }}
steps:
- name: Check upstream workflow status
- name: Check CI status
id: check
run: |
if [[ "${{ github.event.workflow_run.conclusion }}" == "success" && ( "${{ github.event.workflow_run.event }}" == "release" || "${{ github.event.workflow_run.event }}" == "push" ) ]]; then
if [[ "${{ github.event.workflow_run.conclusion }}" == "success" ]]; then
echo "should_continue=true" >> $GITHUB_OUTPUT
echo "Multi-Platform Conda Build passed for release/tag, proceeding with UniLabOS build"
echo "CI Check passed, proceeding with build"
else
echo "should_continue=false" >> $GITHUB_OUTPUT
echo "Upstream workflow is not a successful release/tag build (status: ${{ github.event.workflow_run.conclusion }}, event: ${{ github.event.workflow_run.event }}), skipping build"
echo "CI Check did not succeed (status: ${{ github.event.workflow_run.conclusion }}), skipping build"
fi
build:
needs: [wait-for-upstream]
# 运行条件workflow_run 触发且上游成功,或者手动触发
needs: [wait-for-ci]
# 运行条件workflow_run 触发且 CI 成功,或者其他触发方式
if: |
always() &&
(needs.wait-for-upstream.result == 'skipped' || needs.wait-for-upstream.outputs.should_continue == 'true')
(needs.wait-for-ci.result == 'skipped' || needs.wait-for-ci.outputs.should_continue == 'true')
strategy:
fail-fast: false
matrix:
@@ -75,7 +79,7 @@ jobs:
steps:
- uses: actions/checkout@v6
with:
# 如果是 workflow_run 触发,使用上游 conda 包构建的 commit
# 如果是 workflow_run 触发,使用触发 CI Check 的 commit
ref: ${{ github.event.workflow_run.head_sha || github.ref }}
fetch-depth: 0
@@ -92,13 +96,12 @@ jobs:
echo "should_build=false" >> $GITHUB_OUTPUT
fi
- name: Setup Miniforge
- name: Setup Miniconda
if: steps.should_build.outputs.should_build == 'true'
uses: conda-incubator/setup-miniconda@v3
with:
miniforge-version: latest
use-mamba: true
channels: conda-forge,robostack-staging,uni-lab
miniconda-version: 'latest'
channels: conda-forge,robostack-staging,uni-lab,defaults
channel-priority: strict
activate-environment: build-env
auto-update-conda: false
@@ -107,7 +110,7 @@ jobs:
- name: Install rattler-build and anaconda-client
if: steps.should_build.outputs.should_build == 'true'
run: |
mamba install --override-channels -c conda-forge rattler-build anaconda-client -y
conda install -c conda-forge rattler-build anaconda-client
- name: Show environment info
if: steps.should_build.outputs.should_build == 'true'
@@ -116,11 +119,11 @@ jobs:
conda list | grep -E "(rattler-build|anaconda-client)"
echo "Platform: ${{ matrix.platform }}"
echo "OS: ${{ matrix.os }}"
echo "Build full package: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' }}"
echo "Build full package: ${{ github.event.inputs.build_full || 'false' }}"
echo "Building packages:"
echo " - unilabos-env (environment dependencies)"
echo " - unilabos (with pip package)"
if [[ "${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build_full == 'true' }}" == "true" ]]; then
if [[ "${{ github.event.inputs.build_full }}" == "true" ]]; then
echo " - unilabos-full (complete package)"
fi
@@ -131,12 +134,7 @@ jobs:
rattler-build build -r .conda/environment/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge
- name: Upload unilabos-env to Anaconda.org (if enabled)
if: |
steps.should_build.outputs.should_build == 'true' &&
(
github.event_name == 'workflow_run' ||
github.event.inputs.upload_to_anaconda == 'true'
)
if: steps.should_build.outputs.should_build == 'true' && github.event.inputs.upload_to_anaconda == 'true'
run: |
echo "Uploading unilabos-env to uni-lab organization..."
for package in $(find ./output -name "unilabos-env*.conda"); do
@@ -151,12 +149,7 @@ jobs:
rattler-build build -r .conda/base/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge --channel ./output
- name: Upload unilabos to Anaconda.org (if enabled)
if: |
steps.should_build.outputs.should_build == 'true' &&
(
github.event_name == 'workflow_run' ||
github.event.inputs.upload_to_anaconda == 'true'
)
if: steps.should_build.outputs.should_build == 'true' && github.event.inputs.upload_to_anaconda == 'true'
run: |
echo "Uploading unilabos to uni-lab organization..."
for package in $(find ./output -name "unilabos-0*.conda" -o -name "unilabos-[0-9]*.conda"); do
@@ -166,7 +159,6 @@ jobs:
- name: Build unilabos-full - Only when explicitly requested
if: |
steps.should_build.outputs.should_build == 'true' &&
github.event_name == 'workflow_dispatch' &&
github.event.inputs.build_full == 'true'
run: |
echo "Building unilabos-full package on ${{ matrix.platform }}..."
@@ -175,7 +167,6 @@ jobs:
- name: Upload unilabos-full to Anaconda.org (if enabled)
if: |
steps.should_build.outputs.should_build == 'true' &&
github.event_name == 'workflow_dispatch' &&
github.event.inputs.build_full == 'true' &&
github.event.inputs.upload_to_anaconda == 'true'
run: |

3
.gitignore vendored
View File

@@ -5,7 +5,6 @@ output/
unilabos_data/
pyrightconfig.json
.cursorignore
device_package*/
## Python
# Byte-compiled / optimized / DLL files
@@ -252,5 +251,3 @@ ros-humble-unilabos-msgs-0.9.13-h6403a04_5.tar.bz2
test_config.py
/.claude
/.cursor

170
AGENTS.md
View File

@@ -1,170 +0,0 @@
# AGENTS.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Also follow the monorepo-level rules in `../AGENTS.md`.
## Build & Development
```bash
# Install in editable mode (requires mamba env with python 3.11)
pip install -e .
uv pip install -r unilabos/utils/requirements.txt
# Run with a device graph
unilab --graph <graph.json> --config <config.py> --backend ros
unilab --graph <graph.json> --config <config.py> --backend simple # no ROS2 needed
# Common CLI flags
unilab --app_bridges websocket fastapi # communication bridges
unilab --test_mode # simulate hardware, no real execution
unilab --check_mode # CI validation of registry imports
unilab --skip_env_check # skip auto-install of dependencies
unilab --visual rviz|web|disable # visualization mode
unilab --is_slave # run as slave node
# Workflow upload subcommandP6.1 新增 --target_deviceP6.1.1 新增 --target_model
unilab workflow_upload -f <workflow.json> -n <name> --tags tag1 tag2
unilab workflow_upload -f <workflow.json> --target_device prcxi # P6.1 默认;同上 P6 行为
unilab workflow_upload -f <workflow.json> --target_device prcxi --target_model 9320 # P6.1.1:型号粒度
unilab workflow_upload -f <workflow.json> --target_device beckman # 未来支持,需在 YAML 中声明 target_devices.beckman
# Tests
pytest tests/ # all tests
pytest tests/resources/test_resourcetreeset.py # single test file
pytest tests/resources/test_resourcetreeset.py::TestClassName::test_method # single test
```
## Architecture
### Startup Flow
`unilab` CLI → `unilabos/app/main.py:main()` → loads config → builds registry → reads device graph (JSON/GraphML) → starts backend thread (ROS2/simple) → starts FastAPI web server + WebSocket client.
### Core Layers
**Registry** (`unilabos/registry/`): Singleton `Registry` class discovers and catalogs all device types, resource types, and communication devices from YAML definitions. Device types live in `registry/devices/*.yaml`, resources in `registry/resources/`, comms in `registry/device_comms/`. The registry resolves class paths to actual Python classes via `utils/import_manager.py`.
**Resource Tracking** (`unilabos/resources/resource_tracker.py`): Pydantic-based `ResourceDict``ResourceDictInstance``ResourceTreeSet` hierarchy. `ResourceTreeSet` is the canonical in-memory representation of all devices and resources, used throughout the system. Graph I/O is in `resources/graphio.py` (reads JSON/GraphML device topology files into `nx.Graph` + `ResourceTreeSet`).
**Device Drivers** (`unilabos/devices/`): 30+ hardware drivers organized by device type (liquid_handling, hplc, balance, arm, etc.). Each driver is a Python class that gets wrapped by `ros/device_node_wrapper.py:ros2_device_node()` to become a ROS2 node with publishers, subscribers, and action servers.
**ROS2 Layer** (`unilabos/ros/`): `device_node_wrapper.py` dynamically wraps any device class into `ROS2DeviceNode` (defined in `ros/nodes/base_device_node.py`). Preset node types in `ros/nodes/presets/` include `host_node`, `controller_node`, `workstation`, `serial_node`, `camera`. Messages use custom `unilabos_msgs` (pre-built, distributed via releases).
**Protocol Compilation** (`unilabos/compile/`): 20+ protocol compilers (add, centrifuge, dissolve, filter, heatchill, stir, pump, etc.) that transform YAML protocol definitions into executable sequences.
**Communication** (`unilabos/device_comms/`): Hardware communication adapters — OPC-UA client, Modbus PLC, RPC, and a universal driver. `app/communication.py` provides a factory pattern for WebSocket client connections to the cloud.
**Web/API** (`unilabos/app/web/`): FastAPI server with REST API (`api.py`), Jinja2 template pages (`pages.py`), and HTTP client for cloud communication (`client.py`). Runs on port 8002 by default.
### Configuration System
- **Config classes** in `unilabos/config/config.py`: `BasicConfig`, `WSConfig`, `HTTPConfig`, `ROSConfig` — all class-level attributes, loaded from Python config files
- Config files are `.py` files with matching class names (see `config/example_config.py`)
- Environment variables override with prefix `UNILABOS_` (e.g., `UNILABOS_BASICCONFIG_PORT=9000`)
- Device topology defined in graph files (JSON with node-link format, or GraphML)
### Key Data Flow
1. Graph file → `graphio.read_node_link_json()``(nx.Graph, ResourceTreeSet, resource_links)`
2. `ResourceTreeSet` + `Registry``initialize_device.initialize_device_from_dict()``ROS2DeviceNode` instances
3. Device nodes communicate via ROS2 topics/actions or direct Python calls (simple backend)
4. Cloud sync via WebSocket (`app/ws_client.py`) and HTTP (`app/web/client.py`)
### Test Data
Example device graphs and experiment configs are in `unilabos/test/experiments/` (not `tests/`). Registry test fixtures in `unilabos/test/registry/`.
### Labware Mapping Table (`labware_mapping.yaml`) — P6 + P6.1 + P6.1.1
Opentrons → 目标仪器PRCXI / Beckman / Tecan ...)的「槽位重映射 + labware 归类 +
class_name 选择」全部外化到项目根的
[`labware_mapping.yaml`](./labware_mapping.yaml)(与 `pyproject.toml` 同级,最显眼的位置)。
要新增 SKU、新厂商、新型号、或调整 tip 量程档时,**只改 YAML不改 Python**。
- **YAML 两段顶层语义**P6.1.1 起 `slot_remap` 已下沉到 `target_devices` 内):
- `kinds` — 顺序敏感的 regex把 labware 字符串归到 `trash / tip_rack / tube_rack / plate`。**全局段**,与目标仪器无关。
- `target_devices.<name>` — 按目标仪器组织的规则段,内含三个字段:
- `slot_remap` — 替代历史 `_map_deck_slot`(例:`4 → 13``8 → 14``12+trash → 16`)。
- `rules` — 顺序敏感的「`kind + hole_count + volume_min/volume_max``class_name`」规则,首个命中胜出。
- `models.<model_name>` — 可选的型号粒度覆盖slot_remap / rules缺失字段自动继承厂商级。
- **`target_devices` 内段名约定**
- `default`**固定段名**,兜底物料集 + 兜底 `slot_remap`。caller 传入的 `target_device``target_devices`
下未声明时,自动 fallback 到此段loader 单次 warning下游消费方零感知)。
**第一版按 prcxi 内容拷贝填充**(值仍是 `PRCXI_*`),但与 prcxi 段在 YAML 中
各自独立,可独立演进。**`default` 不支持 `models` 子段**——型号粒度差异必须落到具体仪器段。
- `prcxi` / `beckman` / `tecan` / ... — 具体仪器段厂商粒度caller 显式
`--target_device <name>` 时命中。可在 `models.<model>` 下声明同厂商不同型号的差异。
- **4 段 fallback 链**`slot_remap` / `rules` 共用):
1. `target_devices.<device>.models.<model>.<field>`caller 同时传 device + model
2. `target_devices.<device>.<field>`(厂商级;步骤 1 缺字段时静默 fallback
3. `target_devices.default.<field>`caller 传未声明 device或步骤 2 缺字段;打 warning
4. `_BUILTIN_DEFAULT.target_devices.default.<field>`YAML 误删 default 段时的最后兜底)
- **CLI 用法**
- P6.1`unilab workflow_upload -f <workflow.json> --target_device prcxi`
`--target_device` snake-case默认 `prcxi`;未声明的名字自动 fallback 到 `default` 段)。
- P6.1.1:可加 `--target_model <name>`snake可省略默认 `None`)。
例:`unilab workflow_upload -f <workflow.json> --target_device prcxi --target_model 9320`
- **入口代码**`unilabos/workflow/labware_mapping.py` 暴露 `remap_slot` / `infer_kind` /
`resolve_target_class` / `reload_mapping`
API 签名P6.1.1
- `remap_slot(raw_slot, object_type="", *, target_device="prcxi", target_model=None)`
- `resolve_target_class(target_device, kind, hole_count=None, volume=None, *, target_model=None)`
`workflow/common.py``_map_deck_slot` / `_infer_reagent_kind` /
`_apply_tip_rack_class_from_transfer_volumes` / `_apply_target_labware_class_auto_match` /
`_reconcile_slot_carrier_target_class` 都已转调 YAML 并透传 `target_device` / `target_model`
YAML 未命中(孔数 / 体积超出 default 段覆盖范围)时 fallback 到
`prcxi_labware.get_prcxi_labware_template_specs` 的模板打分匹配,并打 warning 提示「请补到映射表」。
- **`labware_info` 字段重命名**P6 的 `prcxi_class_name` → P6.1 的 `target_class_name`
13 处全部同步刷新;旧 schema顶层 `vendors` / `slot_remap` 或任一 rule 内 `prcxi_class`
会触发 loader warning 并整段 fallback 到 builtin 默认表。
- **测试**
- `pytest tests/workflow/test_labware_mapping.py` —— 45 项单元测试(含 P6.1 + P6.1.1 用例:
`test_remap_slot_model_level_overrides_device_level`
`test_remap_slot_model_inherits_device_when_field_missing`
`test_legacy_top_level_slot_remap_rejected`
`test_default_section_models_subsection_warns` 等)。
- `pytest tests/workflow/test_build_protocol_graph_target_device.py` —— 6 项集成
测试(默认 / 显式 prcxi / unknown 段 fallback / per-device tip class / 字段重命名 /
P6.1.1 model-level slot_remap
- **设计文档**[`product_designs/protocol_convert/06-labware-mapping-table.md`](../product_designs/protocol_convert/06-labware-mapping-table.md)
§11.7 = P6.1 多目标仪器选择§11.8 = P6.1.1 槽位映射按厂商+型号分叉)。
### P2 跨 slot transfer_liquid 合并v2已落地
当一次 phase 中存在「单源吸取 → 跨多个 plate 分发」(典型 `steps/51b9a5.json` 9 plate × 12 well = 108 条 1:1 dispenseStage 2 + Stage 3 现在能把它折叠成 **1 个 merged set_liquid_from_plate + 1 个 transfer_liquid** 节点。
- **Stage 2**[`Protocols/protocol_converter/change_to_transfer_group.py`](../Protocols/protocol_converter/change_to_transfer_group.py)
- `_pair_mergeable` 只要求源 slot / tip 量程档 / use_channels 一致;不再要求 `_target_slot` 相同。
- `_merge_two_transfer_actions` 维护 `_target_slots: list[int]`(与 `_target_wells` 平行,每次 dispense 一条)。
- `export_transfer_actions` 通过 `_register_target_reagent_key` 统一注册 reagent_key跨 slot 时按 `_target_slots` 顺序拼出 `action_args.targets: list[str]`(同板退化为 `str`)。
- 末尾 `pop` 全部 `_` 前缀字段(包括新增的 `_target_slots`)。
- **Stage 3**[`Uni-Lab-OS/unilabos/workflow/common.py`](unilabos/workflow/common.py)
- 新增 `_emit_merged_set_liquid(...)`:对 `params.targets: list[str]` 的 transfer_liquid 节点,在其上游插入一个 **merged `set_liquid_from_plate`** 跨板聚合器;其 `param.wells` 是按 dispense 顺序通过 cursor 走 `reagent[key].well` 得出的有序跨板 well refs多入边每 plate 一条 `create_resource.labware → wells_identifier`),单出边(`output_wells → transfer_liquid.targets_identifier`)。
-`params["targets"]` 改写为 synthetic str `_merged_targets_<idx>` 并注册 `resource_last_writer`,保证 INPUT_PORT_MAPPING 走 P3 既有的单边路径。
- `OUTPUT_PORT_MAPPING` 在原始 `step.param.targets``list[str]` 时为每个 reagent_key 分别注册 transfer_liquid 的下游 writer。
- **PRCXI runtime**[`prcxi/prcxi.py`](unilabos/devices/liquid_handling/prcxi/prcxi.py)`change_slots` 改为遍历所有 source / target 的 parent plate 并按 plate name 去重(跨板 4 个 plate 都能 `update_pipetting_position`)。
- **`liquid_handler_abstract.transfer_liquid`****完全不改动**,主循环 `i % num_targets` 与单边 + 单 list 完全兼容。
CLI 行为不变:现有 `unilab workflow_upload -f <workflow.json> ...` 一切照旧;跨 slot 协议自动走 v2 路径。
测试:
- `pytest Protocols/protocol_converter/tests/test_cross_slot_merge.py` — Stage 2 单测 10 项。
- `pytest tests/workflow/test_common_cross_slot_v2.py` — Stage 3 集成测试 6 项。
- `pytest tests/devices/liquid_handling/test_set_liquid_from_plate_cross_plate.py` — device 跨板单测 6 项pylabrobot 不全时优雅 skip
设计文档:[`product_designs/protocol_convert/02-cross-slot-merge.md`](../product_designs/protocol_convert/02-cross-slot-merge.md)§9 v2 设计 + §11 落地记录)。
## Code Conventions
- Code comments and log messages in simplified Chinese
- Python 3.11+, type hints expected
- Pydantic models for data validation (`resource_tracker.py`)
- Singleton pattern via `@singleton` decorator (`utils/decorator.py`)
- Dynamic class loading via `utils/import_manager.py` — device classes resolved at runtime from registry YAML paths
- CLI argument dashes auto-converted to underscores for consistency
## Licensing
- Framework code: GPL-3.0
- Device drivers (`unilabos/devices/`): DP Technology Proprietary License — do not redistribute

View File

@@ -1,4 +0,0 @@
Please follow the rules defined in:
@AGENTS.md

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
```
**命令行方式(推荐):**

View File

@@ -15,9 +15,6 @@ Python 类设备驱动在完成注册表后可以直接在 Uni-Lab 中使用,
**示例:**
```python
from unilabos.registry.decorators import device, topic_config
@device(id="mock_gripper", category=["gripper"], description="Mock Gripper")
class MockGripper:
def __init__(self):
self._position: float = 0.0
@@ -26,23 +23,19 @@ class MockGripper:
self._status = "Idle"
@property
@topic_config() # 添加 @topic_config 才会定时广播
def position(self) -> float:
return self._position
@property
@topic_config()
def velocity(self) -> float:
return self._velocity
@property
@topic_config()
def torque(self) -> float:
return self._torque
# 使用 @topic_config 装饰的属性,接入 Uni-Lab 时会定时对外广播
# 会被自动识别的设备属性,接入 Uni-Lab 时会定时对外广播
@property
@topic_config(period=2.0) # 可自定义发布周期
def status(self) -> str:
return self._status
@@ -156,7 +149,7 @@ my_device: # 设备唯一标识符
系统会自动分析您的 Python 驱动类并生成:
- `status_types`:从 `@topic_config` 装饰的 `@property` 方法自动识别状态属性
- `status_types`:从 `@property` 装饰的方法自动识别状态属性
- `action_value_mappings`:从类方法自动生成动作映射
- `init_param_schema`:从 `__init__` 方法分析初始化参数
- `schema`:前端显示用的属性类型定义
@@ -186,9 +179,7 @@ Uni-Lab 设备驱动是一个 Python 类,需要遵循以下结构:
```python
from typing import Dict, Any
from unilabos.registry.decorators import device, topic_config
@device(id="my_device", category=["general"], description="My Device")
class MyDevice:
"""设备类文档字符串
@@ -207,9 +198,8 @@ class MyDevice:
# 初始化硬件连接
@property
@topic_config() # 必须添加 @topic_config 才会广播
def status(self) -> str:
"""设备状态(通过 @topic_config 广播)"""
"""设备状态(会自动广播)"""
return self._status
def my_action(self, param: float) -> Dict[str, Any]:
@@ -227,61 +217,34 @@ class MyDevice:
## 状态属性 vs 动作方法
### 状态属性(@property + @topic_config
### 状态属性(@property
状态属性需要同时使用 `@property``@topic_config` 装饰器才会被识别并定期广播:
状态属性会被自动识别并定期广播:
```python
from unilabos.registry.decorators import topic_config
@property
@topic_config() # 必须添加,否则不会广播
def temperature(self) -> float:
"""当前温度"""
return self._read_temperature()
@property
@topic_config(period=2.0) # 可自定义发布周期(秒)
def status(self) -> str:
"""设备状态: idle, running, error"""
return self._status
@property
@topic_config(name="ready") # 可自定义发布名称
def is_ready(self) -> bool:
"""设备是否就绪"""
return self._status == "idle"
```
也可以使用普通方法(非 @property)配合 `@topic_config`
```python
@topic_config(period=10.0)
def get_sensor_data(self) -> Dict[str, float]:
"""获取传感器数据get_ 前缀会自动去除,发布名为 sensor_data"""
return {"temp": self._temp, "humidity": self._humidity}
```
**`@topic_config` 参数**:
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `period` | float | 5.0 | 发布周期(秒) |
| `print_publish` | bool | 节点默认 | 是否打印发布日志 |
| `qos` | int | 10 | QoS 深度 |
| `name` | str | None | 自定义发布名称 |
**发布名称优先级**`@topic_config(name=...)` > `get_` 前缀去除 > 方法名
**特点**:
- 必须使用 `@topic_config` 装饰器
- 支持 `@property` 和普通方法
- 添加到注册表的 `status_types`
- 使用`@property`装饰器
- 只读,不能有参数
- 自动添加到注册表的`status_types`
- 定期发布到 ROS2 topic
> **⚠️ 重要:** 仅有 `@property` 装饰器而没有 `@topic_config` 的属性**不会**被广播。这是一个 Breaking Change。
### 动作方法
动作方法是设备可以执行的操作:
@@ -534,7 +497,6 @@ class LiquidHandler:
self._status = "idle"
@property
@topic_config()
def status(self) -> str:
return self._status
@@ -924,52 +886,7 @@ class MyDevice:
## 最佳实践
### 1. 使用 `@device` 装饰器标识设备
```python
from unilabos.registry.decorators import device
@device(id="my_device", category=["heating"], description="My Heating Device", icon="heater.webp")
class MyDevice:
...
```
- `id`:设备唯一标识符,用于注册表匹配
- `category`:分类列表,前端用于分组显示
- `description`:设备描述
- `icon`:图标文件名(可选)
### 2. 使用 `@topic_config` 声明需要广播的状态
```python
from unilabos.registry.decorators import topic_config
# ✓ @property + @topic_config → 会广播
@property
@topic_config(period=2.0)
def temperature(self) -> float:
return self._temp
# ✓ 普通方法 + @topic_config → 会广播get_ 前缀自动去除)
@topic_config(period=10.0)
def get_sensor_data(self) -> Dict[str, float]:
return {"temp": self._temp}
# ✓ 使用 name 参数自定义发布名称
@property
@topic_config(name="ready")
def is_ready(self) -> bool:
return self._status == "idle"
# ✗ 仅有 @property没有 @topic_config → 不会广播
@property
def internal_state(self) -> str:
return self._state
```
> **注意:** 与 `@property` 连用时,`@topic_config` 必须放在 `@property` 下面。
### 3. 类型注解
### 1. 类型注解
```python
from typing import Dict, Any, Optional, List
@@ -984,7 +901,7 @@ def method(
pass
```
### 4. 文档字符串
### 2. 文档字符串
```python
def method(self, param: float) -> Dict[str, Any]:
@@ -1006,7 +923,7 @@ def method(self, param: float) -> Dict[str, Any]:
pass
```
### 5. 配置验证
### 3. 配置验证
```python
def __init__(self, config: Dict[str, Any]):
@@ -1020,7 +937,7 @@ def __init__(self, config: Dict[str, Any]):
self.baudrate = config['baudrate']
```
### 6. 资源清理
### 4. 资源清理
```python
def __del__(self):
@@ -1029,7 +946,7 @@ def __del__(self):
self.connection.close()
```
### 7. 设计前端友好的返回值
### 5. 设计前端友好的返回值
**记住:返回值会直接显示在 Web 界面**

View File

@@ -422,20 +422,18 @@ placeholder_keys:
### status_types
系统会扫描你的 Python 类,从带有 `@topic_config` 装饰器的 `@property`方法自动生成这部分:
系统会扫描你的 Python 类,从状态方法property 或 get\_方法自动生成这部分:
```yaml
status_types:
current_temperature: float # 从 @topic_config 装饰的 @property 或方法
is_heating: bool
status: str
current_temperature: float # 从 get_current_temperature() 或 @property current_temperature
is_heating: bool # 从 get_is_heating() 或 @property is_heating
status: str # 从 get_status() 或 @property status
```
**注意事项**
- 仅有带 `@topic_config` 装饰器的 `@property` 或方法才会被识别为状态属性
- 没有 `@topic_config``@property` 不会生成 status_types也不会广播
- `get_` 前缀的方法名会自动去除前缀(如 `get_temperature``temperature`
- 系统会查找所有 `get_` 开头的方法和 `@property` 装饰的属性
- 类型会自动转成相应的类型(如 `str``float``bool`
- 如果类型是 `Any``None` 或未知的,默认使用 `String`
@@ -539,13 +537,11 @@ class AdvancedLiquidHandler:
self._temperature = 25.0
@property
@topic_config()
def status(self) -> str:
"""设备状态"""
return self._status
@property
@topic_config()
def temperature(self) -> float:
"""当前温度"""
return self._temperature
@@ -813,23 +809,21 @@ my_temperature_controller:
你的设备类需要符合以下要求:
```python
from unilabos.registry.decorators import device, topic_config
from unilabos.common.device_base import DeviceBase
@device(id="my_device", category=["temperature"], description="My Device")
class MyDevice:
class MyDevice(DeviceBase):
def __init__(self, config):
"""初始化,参数会自动分析到 init_param_schema.config"""
super().__init__(config)
self.port = config.get('port', '/dev/ttyUSB0')
# 状态方法(必须添加 @topic_config 才会生成到 status_types 并广播
# 状态方法(会自动生成到 status_types
@property
@topic_config()
def status(self):
"""返回设备状态"""
return "idle"
@property
@topic_config()
def temperature(self):
"""返回当前温度"""
return 25.0
@@ -1045,34 +1039,7 @@ resource.type # "resource"
### 代码规范
1. **使用 `@device` 装饰器标识设备类**
```python
from unilabos.registry.decorators import device
@device(id="my_device", category=["heating"], description="My Device")
class MyDevice:
...
```
2. **使用 `@topic_config` 声明广播属性**
```python
from unilabos.registry.decorators import topic_config
# ✓ 需要广播的状态属性
@property
@topic_config(period=2.0)
def temperature(self) -> float:
return self._temp
# ✗ 仅有 @property 不会广播
@property
def internal_counter(self) -> int:
return self._counter
```
3. **始终使用类型注解**
1. **始终使用类型注解**
```python
# ✓ 好
@@ -1084,7 +1051,7 @@ def method(self, resource, device):
pass
```
4. **提供有意义的参数名**
2. **提供有意义的参数名**
```python
# ✓ 好 - 清晰的参数名
@@ -1096,7 +1063,7 @@ def transfer(self, r1: ResourceSlot, r2: ResourceSlot):
pass
```
5. **使用 Optional 表示可选参数**
3. **使用 Optional 表示可选参数**
```python
from typing import Optional
@@ -1109,7 +1076,7 @@ def method(
pass
```
6. **添加详细的文档字符串**
4. **添加详细的文档字符串**
```python
def method(
@@ -1129,13 +1096,13 @@ def method(
pass
```
7. **方法命名规范**
5. **方法命名规范**
- 状态方法使用 `@property` + `@topic_config` 装饰器,或普通方法 + `@topic_config`
- 状态方法使用 `@property` 装饰器或 `get_` 前缀
- 动作方法使用动词开头
- 保持命名清晰、一致
8. **完善的错误处理**
6. **完善的错误处理**
- 实现完善的错误处理
- 添加日志记录
- 提供有意义的错误信息

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) │
└────────────────────┬─────────────────────────┘
@@ -221,10 +221,10 @@ Laboratory A Laboratory B
```bash
# 实验室A
unilab --ak your_ak --sk your_sk --upload_registry
unilab --ak your_ak --sk your_sk --upload_registry --use_remote_resource
# 实验室B
unilab --ak your_ak --sk your_sk --upload_registry
unilab --ak your_ak --sk your_sk --upload_registry --use_remote_resource
```
---
@@ -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后查看日志

File diff suppressed because it is too large Load Diff

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
@@ -451,9 +452,8 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
**操作步骤:**
1. 将两个 `container` 拖拽到 `workstation`
2.`virtual_multiway_valve` 拖拽到 `workstation`
3. `virtual_transfer_pump` 拖拽到 `workstation`
4. 在画布上连接它们(建立父子关系)
2.`virtual_transfer_pump` 拖拽到 `workstation`
3. 在画布上连接它们(建立父子关系)
![设备连接](image/links.png)
@@ -817,7 +817,6 @@ uv pip install -r unilabos/utils/requirements.txt
```
**为什么使用这种方式?**
- `unilabos-env` 提供 ROS2 核心组件和 uv通过 conda 安装,避免编译)
- `unilabos/utils/requirements.txt` 包含所有运行时需要的 pip 依赖
- `dev_install.py` 自动检测中文环境,中文系统自动使用清华镜像
@@ -1796,27 +1795,32 @@ unilab --ak your_ak --sk your_sk -g graph.json \
**详细步骤:**
1. **需求分析**
- 明确实验流程
- 列出所需设备和物料
- 设计工作流程图
2. **环境搭建**
- 安装 Uni-Lab-OS
- 创建实验室账号
- 准备开发工具IDE、Git
3. **原型验证**
- 使用虚拟设备测试流程
- 验证工作流逻辑
- 调整参数
4. **迭代开发**
- 实现自定义设备驱动(同时撰写单点函数测试)
- 编写注册表
- 单元测试
- 集成测试
5. **测试部署**
- 连接真实硬件
- 空跑测试
- 小规模试验
@@ -1866,7 +1870,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. 保存到云端

Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 KiB

After

Width:  |  Height:  |  Size: 275 KiB

View File

@@ -22,6 +22,7 @@ options:
--is_slave Run the backend as slave node (without host privileges).
--slave_no_host Skip waiting for host service in slave mode
--upload_registry Upload registry information when starting unilab
--use_remote_resource Use remote resources when starting unilab
--config CONFIG Configuration file path, supports .py format Python config files
--port PORT Port for web service information page
--disable_browser Disable opening information page on startup
@@ -54,6 +55,7 @@ Uni-Lab 的启动过程分为以下几个阶段:
您可以直接跟随 unilabos 的提示进行,无需查阅本节
- **工作目录设置**
- 如果当前目录以 `unilabos_data` 结尾,则使用当前目录
- 否则使用 `当前目录/unilabos_data` 作为工作目录
- 可通过 `--working_dir` 指定自定义工作目录
@@ -67,8 +69,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
@@ -83,7 +85,7 @@ Uni-Lab 的启动过程分为以下几个阶段:
支持两种方式:
- **本地文件**:使用 `-g` 指定图谱文件(支持 JSON 和 GraphML 格式)
- **远程资源**不指定本地文件即可
- **远程资源**使用 `--use_remote_resource` 从云端获取
### 7. 注册表构建
@@ -175,7 +177,7 @@ unilab --config path/to/your/config.py
如果是首次使用,系统会:
1. 提示前往 https://leap-lab.bohrium.com 注册实验室
1. 提示前往 https://uni-lab.bohrium.com 注册实验室
2. 引导创建配置文件
3. 设置工作目录
@@ -194,7 +196,7 @@ unilab --config path/to/your/config.py
unilab --ak your_ak --sk your_sk -g path/to/graph.json --upload_registry
# 使用远程资源启动
unilab --ak your_ak --sk your_sk
unilab --ak your_ak --sk your_sk --use_remote_resource
# 更新注册表
unilab --ak your_ak --sk your_sk --complete_registry
@@ -215,7 +217,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,140 +0,0 @@
# Opentrons → 目标仪器 物料映射表P6.1.1
#
# 两段顶层 keyP6.1.1 起 slot_remap 从顶层下沉到 target_devices 内):
# kinds : labware 字符串 → kind 归类(与目标仪器无关,**保留全局**
# target_devices : 按目标仪器 + 型号组织rule = kind + hole_count + volume_min/max → class_name
# slot_remap 也内嵌在 target_devices 下(按 deck 物理布局变化)
#
# target_devices 段内结构:
# target_devices.<device>: # 厂商段(必填)
# slot_remap: {...} # 厂商级默认 slot 映射(缺失 → 继承 default 段)
# rules: [...] # 厂商级规则(缺失 → 继承 default 段)
# models: # 同厂商多型号(可选;缺失 = 仅厂商级,不区分型号)
# <model_name>: # 型号子段
# slot_remap: {...} # 型号级覆盖(缺失 → 继承厂商级)
# rules: [...] # 型号级覆盖(缺失 → 继承厂商级)
#
# 段名约定:
# target_devices.default : 兜底物料集 + 兜底 slot_remap。caller 传未声明的 target_device 时使用此段。
# **不支持 models 子段**(型号粒度差异必须落到具体仪器段,否则歧义)。
# target_devices.<name> : 具体仪器段prcxi / beckman / tecan ...)。
#
# 解析链remap_slot / resolve_target_class 共用,字段级 fallback
# 1. target_devices.<device>.models.<model>.<field> (caller 同时传 device + model)
# 2. target_devices.<device>.<field> (caller 传 device或步骤 1 缺字段)
# 3. target_devices.default.<field> (caller 传未声明 device或步骤 2 缺字段)
# 4. _BUILTIN_DEFAULT.target_devices.default.<field> (YAML 误删 default 段时的最后兜底)
#
# 编辑建议:
# 1. 顺序敏感kinds 与 rules 内首个命中胜出;窄规则在前、宽规则在后。
# 2. volume_min / volume_max 是闭区间µL。任一字段可省略都省略 = 不限制体积。
# 3. notes 仅作注释,不参与匹配。
# 4. 新增目标仪器:复制 target_devices.prcxi 段、改 device 名、改 slot_remap + rules。
# 5. 同厂商不同型号:在 target_devices.<device>.models.<model> 下显式覆盖差异字段;
# 没声明的字段自动继承厂商级。
# 6. P6.1.1 不再支持顶层 slot_remap检出顶层 slot_remap → warning + fallback 到 builtin。
#
# 设计文档product_designs/protocol_convert/06-labware-mapping-table.md§11.8
kinds:
# 顺序敏感的 regex第一个命中胜出
# 注意trash 必须在 tip_rack 之前tip_rack 必须在 tube_rack 之前("tuberack" 含 "rack"
- { pattern: "trash", kind: trash }
- { pattern: "tiprack|tip[_ ]?rack|opentrons_\\d+_tiprack", kind: tip_rack }
- { pattern: "tuberack|tube[_ ]rack|eppendorf.*rack|safelock.*rack", kind: tube_rack }
# 「<labware> 含 'rack' 但不含 'tip'」也归到 tube_rack与历史 _infer_reagent_kind 行为一致)
- { pattern: "(?:^|[^a-z])rack(?:[^a-z]|$)", kind: tube_rack }
- { pattern: ".*", kind: plate }
target_devices:
# ─────────────────────────────────────────────────────────────────────────
# default兜底物料集 + 兜底 slot_remap。
# caller 传未声明的 target_device 时使用本段;**不支持 models 子段**。
# 第一版内容按 prcxi 拷贝填充(值仍是 PRCXI_*),但语义独立,可独立演进。
# ─────────────────────────────────────────────────────────────────────────
default:
notes: "默认兜底物料集caller 传未声明 target_device 时使用此段。第一版按 prcxi 拷贝填充。"
slot_remap:
# raw slot → deck slot与对象类型无关
default:
"4": "13"
"8": "14"
# 按 object 字段覆盖 default
by_object:
trash:
"12": "16"
rules:
# ─ tip rack默认量程档≤10 / <300 / 否则 1000
- { kind: tip_rack, hole_count: 96, volume_max: 10, class_name: PRCXI_10uL_Tips }
- { kind: tip_rack, hole_count: 96, volume_max: 299.9, class_name: PRCXI_300ul_Tips }
- { kind: tip_rack, hole_count: 96, class_name: PRCXI_1000uL_Tips }
# ─ tube rack ─
- { kind: tube_rack, hole_count: 24, class_name: PRCXI_EP_Adapter, notes: "Eppendorf 1.5/2 mL 24 位 4×6" }
- { kind: tube_rack, hole_count: 10, class_name: PRCXI_EP_Adapter, notes: "Falcon 4x50 + 6x15 mL10 位兼容 4×6 适配器)" }
# ─ plate ─
- { kind: plate, hole_count: 96, class_name: PRCXI_BioER_96_wellplate }
- { kind: plate, hole_count: 384, class_name: PRCXI_BioER_384_wellplate }
# ─ trash ─
- { kind: trash, class_name: PRCXI_trash }
# ─────────────────────────────────────────────────────────────────────────
# prcxiPRCXI 仪器专用段。caller 显式传 --target_device prcxi 时命中此段。
# 厂商级 slot_remap + rules 适用于"未声明 model"的调用;
# models 子段下声明同厂商不同型号的 deck 物理布局差异。
# ─────────────────────────────────────────────────────────────────────────
prcxi:
slot_remap:
# PRCXI 多数型号通用的 deck 物理布局映射
default:
"4": "13"
"8": "14"
by_object:
trash:
"12": "16"
rules:
# ─ tip rackPRCXI 量程档≤10 / <300 / 否则 1000
- { kind: tip_rack, hole_count: 96, volume_max: 10, class_name: PRCXI_10uL_Tips }
- { kind: tip_rack, hole_count: 96, volume_max: 299.9, class_name: PRCXI_300ul_Tips }
- { kind: tip_rack, hole_count: 96, class_name: PRCXI_1000uL_Tips }
# ─ tube rack ─
- { kind: tube_rack, hole_count: 24, class_name: PRCXI_EP_Adapter, notes: "Eppendorf 1.5/2 mL 24 位 4×6" }
- { kind: tube_rack, hole_count: 10, class_name: PRCXI_EP_Adapter, notes: "Falcon 4x50 + 6x15 mL10 位兼容 4×6 适配器)" }
# ─ plate ─
- { kind: plate, hole_count: 96, class_name: PRCXI_BioER_96_wellplate }
- { kind: plate, hole_count: 384, class_name: PRCXI_BioER_384_wellplate }
# ─ trash ─
- { kind: trash, class_name: PRCXI_trash }
models:
# PRCXI 9320 —— 与厂商级完全一致(空 dict 仅作为合法 model 名占位)。
# caller `--target_model 9320` 时所有字段继承厂商级 prcxi 段。
"9320": {}
# 演示:假想 PRCXI 4040 把 slot 4 物理位换到 16、trash 槽换到 20。
# 仅 slot_remap 不同rules 与厂商级一致 → 不重复声明(自动继承)。
"4040":
slot_remap:
default:
"4": "16"
"8": "14"
by_object:
trash:
"12": "20"
# ─────────────────────────────────────────────────────────────────────────
# 演示:未来加新仪器只复制 prcxi 段、改 device 名 + slot_remap + rules。
# 特别注意 tip 量程档可与 PRCXI 不同。
# ─────────────────────────────────────────────────────────────────────────
# beckman:
# slot_remap:
# default: {"4": "13"}
# by_object: {trash: {"12": "16"}}
# rules:
# - { kind: tip_rack, hole_count: 96, volume_max: 20, class_name: Beckman_20uL_Tips }
# - { kind: tip_rack, hole_count: 96, volume_max: 199.9, class_name: Beckman_200uL_Tips }
# - { kind: tip_rack, hole_count: 96, class_name: Beckman_1000uL_Tips }
# - { kind: tube_rack, hole_count: 24, class_name: Beckman_24_TubeRack }
# - { kind: plate, hole_count: 96, class_name: Beckman_BioMek_96_wellplate }
# - { kind: trash, class_name: Beckman_Trash }
# models:
# "i7":
# slot_remap:
# default: {"4": "13", "5": "14"} # 假想 i7 多一个 slot 重映射

View File

@@ -1,5 +1,5 @@
channel_sources:
- robostack,robostack-staging,conda-forge
- robostack,robostack-staging,conda-forge,defaults
gazebo:
- '11'

View File

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

View File

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

View File

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

View File

@@ -1,539 +0,0 @@
import pytest
import json
import os
import asyncio
import collections
from typing import List, Dict, Any
from pylabrobot.resources import Coordinate
from pylabrobot.resources.opentrons.tip_racks import opentrons_96_tiprack_300ul, opentrons_96_tiprack_10ul
from pylabrobot.resources.opentrons.plates import corning_96_wellplate_360ul_flat, nest_96_wellplate_2ml_deep
from unilabos.devices.liquid_handling.prcxi.prcxi import (
PRCXI9300Deck,
PRCXI9300Container,
PRCXI9300Trash,
PRCXI9300Handler,
PRCXI9300Backend,
DefaultLayout,
Material,
WorkTablets,
MatrixInfo
)
@pytest.fixture
def prcxi_materials() -> Dict[str, Any]:
"""加载 PRCXI 物料数据"""
print("加载 PRCXI 物料数据...")
material_path = os.path.join(os.path.dirname(__file__), "..", "..", "unilabos", "devices", "liquid_handling", "prcxi", "prcxi_material.json")
with open(material_path, "r", encoding="utf-8") as f:
data = json.load(f)
print(f"加载了 {len(data)} 条物料数据")
return data
@pytest.fixture
def prcxi_9300_deck() -> PRCXI9300Deck:
"""创建 PRCXI 9300 工作台"""
return PRCXI9300Deck(name="PRCXI_Deck_9300", size_x=100, size_y=100, size_z=100, model="9300")
@pytest.fixture
def prcxi_9320_deck() -> PRCXI9300Deck:
"""创建 PRCXI 9320 工作台"""
return PRCXI9300Deck(name="PRCXI_Deck_9320", size_x=100, size_y=100, size_z=100, model="9320")
@pytest.fixture
def prcxi_9300_handler(prcxi_9300_deck) -> PRCXI9300Handler:
"""创建 PRCXI 9300 处理器(模拟模式)"""
return PRCXI9300Handler(
deck=prcxi_9300_deck,
host="192.168.1.201",
port=9999,
timeout=10.0,
channel_num=8,
axis="Left",
setup=False,
debug=True,
simulator=True,
matrix_id="test-matrix-9300"
)
@pytest.fixture
def prcxi_9320_handler(prcxi_9320_deck) -> PRCXI9300Handler:
"""创建 PRCXI 9320 处理器(模拟模式)"""
return PRCXI9300Handler(
deck=prcxi_9320_deck,
host="192.168.1.201",
port=9999,
timeout=10.0,
channel_num=1,
axis="Right",
setup=False,
debug=True,
simulator=True,
matrix_id="test-matrix-9320",
is_9320=True
)
@pytest.fixture
def tip_rack_300ul(prcxi_materials) -> PRCXI9300Container:
"""创建 300μL 枪头盒"""
tip_rack = PRCXI9300Container(
name="tip_rack_300ul",
size_x=50,
size_y=50,
size_z=10,
category="tip_rack",
ordering=collections.OrderedDict()
)
tip_rack.load_state({
"Material": {
"uuid": prcxi_materials["300μL Tip头"]["uuid"],
"Code": "ZX-001-300",
"Name": "300μL Tip头"
}
})
return tip_rack
@pytest.fixture
def tip_rack_10ul(prcxi_materials) -> PRCXI9300Container:
"""创建 10μL 枪头盒"""
tip_rack = PRCXI9300Container(
name="tip_rack_10ul",
size_x=50,
size_y=50,
size_z=10,
category="tip_rack",
ordering=collections.OrderedDict()
)
tip_rack.load_state({
"Material": {
"uuid": prcxi_materials["10μL加长 Tip头"]["uuid"],
"Code": "ZX-001-10+",
"Name": "10μL加长 Tip头"
}
})
return tip_rack
@pytest.fixture
def well_plate_96(prcxi_materials) -> PRCXI9300Container:
"""创建 96 孔板"""
plate = PRCXI9300Container(
name="well_plate_96",
size_x=50,
size_y=50,
size_z=10,
category="plate",
ordering=collections.OrderedDict()
)
plate.load_state({
"Material": {
"uuid": prcxi_materials["96深孔板"]["uuid"],
"Code": "ZX-019-2.2",
"Name": "96深孔板"
}
})
return plate
@pytest.fixture
def deep_well_plate(prcxi_materials) -> PRCXI9300Container:
"""创建深孔板"""
plate = PRCXI9300Container(
name="deep_well_plate",
size_x=50,
size_y=50,
size_z=10,
category="plate",
ordering=collections.OrderedDict()
)
plate.load_state({
"Material": {
"uuid": prcxi_materials["96深孔板"]["uuid"],
"Code": "ZX-019-2.2",
"Name": "96深孔板"
}
})
return plate
@pytest.fixture
def trash_container(prcxi_materials) -> PRCXI9300Trash:
"""创建垃圾桶"""
trash = PRCXI9300Trash(name="trash", size_x=50, size_y=50, size_z=10, category="trash")
trash.load_state({
"Material": {
"uuid": prcxi_materials["废弃槽"]["uuid"]
}
})
return trash
@pytest.fixture
def default_layout_9300() -> DefaultLayout:
"""创建 PRCXI 9300 默认布局"""
return DefaultLayout("PRCXI9300")
@pytest.fixture
def default_layout_9320() -> DefaultLayout:
"""创建 PRCXI 9320 默认布局"""
return DefaultLayout("PRCXI9320")
class TestPRCXIDeckSetup:
"""测试 PRCXI 工作台设置功能"""
def test_prcxi_9300_deck_creation(self, prcxi_9300_deck):
"""测试 PRCXI 9300 工作台创建"""
assert prcxi_9300_deck.name == "PRCXI_Deck_9300"
assert len(prcxi_9300_deck.sites) == 6
assert prcxi_9300_deck._size_x == 100
assert prcxi_9300_deck._size_y == 100
assert prcxi_9300_deck._size_z == 100
def test_prcxi_9320_deck_creation(self, prcxi_9320_deck):
"""测试 PRCXI 9320 工作台创建"""
assert prcxi_9320_deck.name == "PRCXI_Deck_9320"
assert len(prcxi_9320_deck.sites) == 16
assert prcxi_9320_deck._size_x == 100
assert prcxi_9320_deck._size_y == 100
assert prcxi_9320_deck._size_z == 100
def test_container_assignment(self, prcxi_9300_deck, tip_rack_300ul, well_plate_96, trash_container):
"""测试容器分配到工作台"""
# 分配枪头盒
prcxi_9300_deck.assign_child_resource(tip_rack_300ul, location=Coordinate(0, 0, 0))
assert tip_rack_300ul in prcxi_9300_deck.children
# 分配孔板
prcxi_9300_deck.assign_child_resource(well_plate_96, location=Coordinate(0, 0, 0))
assert well_plate_96 in prcxi_9300_deck.children
# 分配垃圾桶
prcxi_9300_deck.assign_child_resource(trash_container, location=Coordinate(0, 0, 0))
assert trash_container in prcxi_9300_deck.children
def test_container_material_loading(self, tip_rack_300ul, well_plate_96, prcxi_materials):
"""测试容器物料信息加载"""
# 测试枪头盒物料信息
tip_material = tip_rack_300ul._unilabos_state["Material"]
assert tip_material["uuid"] == prcxi_materials["300μL Tip头"]["uuid"]
assert tip_material["Name"] == "300μL Tip头"
# 测试孔板物料信息
plate_material = well_plate_96._unilabos_state["Material"]
assert plate_material["uuid"] == prcxi_materials["96深孔板"]["uuid"]
assert plate_material["Name"] == "96深孔板"
class TestPRCXISingleStepOperations:
"""测试 PRCXI 单步操作功能"""
@pytest.mark.asyncio
async def test_pick_up_tips_single_channel(self, prcxi_9320_handler, prcxi_9320_deck, tip_rack_10ul):
"""测试单通道拾取枪头"""
# 将枪头盒添加到工作台
prcxi_9320_deck.assign_child_resource(tip_rack_10ul, location=Coordinate(0, 0, 0))
# 初始化处理器
await prcxi_9320_handler.setup()
# 设置枪头盒
prcxi_9320_handler.set_tiprack([tip_rack_10ul])
# 创建模拟的枪头位置
from pylabrobot.resources import TipSpot, Tip
tip = Tip(has_filter=False, total_tip_length=10, maximal_volume=10, fitting_depth=5)
tip_spot = TipSpot("A1", size_x=1, size_y=1, size_z=1, make_tip=lambda: tip)
tip_rack_10ul.assign_child_resource(tip_spot, location=Coordinate(0, 0, 0))
# 直接测试后端方法
from pylabrobot.liquid_handling import Pickup
pickup = Pickup(resource=tip_spot, offset=None, tip=tip)
await prcxi_9320_handler._unilabos_backend.pick_up_tips([pickup], [0])
# 验证步骤已添加到待办列表
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
assert step["Function"] == "Load"
@pytest.mark.asyncio
async def test_pick_up_tips_multi_channel(self, prcxi_9300_handler, tip_rack_300ul):
"""测试多通道拾取枪头"""
# 设置枪头盒
prcxi_9300_handler.set_tiprack([tip_rack_300ul])
# 拾取8个枪头
tip_spots = tip_rack_300ul.children[:8]
await prcxi_9300_handler.pick_up_tips(tip_spots, [0, 1, 2, 3, 4, 5, 6, 7])
# 验证步骤已添加到待办列表
assert len(prcxi_9300_handler._unilabos_backend.steps_todo_list) == 1
step = prcxi_9300_handler._unilabos_backend.steps_todo_list[0]
assert step["Function"] == "Load"
@pytest.mark.asyncio
async def test_aspirate_single_channel(self, prcxi_9320_handler, well_plate_96):
"""测试单通道吸取液体"""
# 设置液体
well = well_plate_96.get_item("A1")
prcxi_9320_handler.set_liquid([well], ["water"], [50])
# 吸取液体
await prcxi_9320_handler.aspirate([well], [50], [0])
# 验证步骤已添加到待办列表
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
assert step["Function"] == "Imbibing"
assert step["DosageNum"] == 50
@pytest.mark.asyncio
async def test_dispense_single_channel(self, prcxi_9320_handler, well_plate_96):
"""测试单通道分配液体"""
# 分配液体
well = well_plate_96.get_item("A1")
await prcxi_9320_handler.dispense([well], [25], [0])
# 验证步骤已添加到待办列表
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
assert step["Function"] == "Tapping"
assert step["DosageNum"] == 25
@pytest.mark.asyncio
async def test_mix_single_channel(self, prcxi_9320_handler, well_plate_96):
"""测试单通道混合液体"""
# 混合液体
well = well_plate_96.get_item("A1")
await prcxi_9320_handler.mix([well], mix_time=3, mix_vol=50)
# 验证步骤已添加到待办列表
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
assert step["Function"] == "Blending"
assert step["BlendingTimes"] == 3
assert step["DosageNum"] == 50
@pytest.mark.asyncio
async def test_drop_tips_to_trash(self, prcxi_9320_handler, trash_container):
"""测试丢弃枪头到垃圾桶"""
# 丢弃枪头
await prcxi_9320_handler.drop_tips([trash_container], [0])
# 验证步骤已添加到待办列表
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
assert step["Function"] == "UnLoad"
@pytest.mark.asyncio
async def test_discard_tips(self, prcxi_9320_handler):
"""测试丢弃枪头"""
# 丢弃枪头
await prcxi_9320_handler.discard_tips([0])
# 验证步骤已添加到待办列表
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
assert step["Function"] == "UnLoad"
@pytest.mark.asyncio
async def test_liquid_transfer_workflow(self, prcxi_9320_handler, tip_rack_10ul, well_plate_96):
"""测试完整的液体转移工作流程"""
# 设置枪头盒和液体
prcxi_9320_handler.set_tiprack([tip_rack_10ul])
source_well = well_plate_96.get_item("A1")
target_well = well_plate_96.get_item("B1")
prcxi_9320_handler.set_liquid([source_well], ["water"], [100])
# 创建协议
await prcxi_9320_handler.create_protocol(protocol_name="Test Transfer Protocol")
# 执行转移流程
tip_spot = tip_rack_10ul.get_item("A1")
await prcxi_9320_handler.pick_up_tips([tip_spot], [0])
await prcxi_9320_handler.aspirate([source_well], [50], [0])
await prcxi_9320_handler.dispense([target_well], [50], [0])
await prcxi_9320_handler.discard_tips([0])
# 验证所有步骤都已添加
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 4
functions = [step["Function"] for step in prcxi_9320_handler._unilabos_backend.steps_todo_list]
assert functions == ["Load", "Imbibing", "Tapping", "UnLoad"]
class TestPRCXILayoutRecommendation:
"""测试 PRCXI 板位推荐功能"""
def test_9300_layout_creation(self, default_layout_9300):
"""测试 PRCXI 9300 布局创建"""
layout_info = default_layout_9300.get_layout()
assert layout_info["rows"] == 2
assert layout_info["columns"] == 3
assert len(layout_info["layout"]) == 6
assert layout_info["trash_slot"] == 6
assert "waste_liquid_slot" not in layout_info
def test_9320_layout_creation(self, default_layout_9320):
"""测试 PRCXI 9320 布局创建"""
layout_info = default_layout_9320.get_layout()
assert layout_info["rows"] == 4
assert layout_info["columns"] == 4
assert len(layout_info["layout"]) == 16
assert layout_info["trash_slot"] == 16
assert layout_info["waste_liquid_slot"] == 12
def test_layout_recommendation_9320(self, default_layout_9320, prcxi_materials):
"""测试 PRCXI 9320 板位推荐功能"""
# 添加物料信息
default_layout_9320.add_lab_resource(prcxi_materials)
# 推荐布局
needs = [
("reagent_1", "96 细胞培养皿", 3),
("reagent_2", "12道储液槽", 1),
("reagent_3", "200μL Tip头", 7),
("reagent_4", "10μL加长 Tip头", 1),
]
matrix_layout, layout_list = default_layout_9320.recommend_layout(needs)
# 验证返回结果
assert "MatrixId" in matrix_layout
assert "MatrixName" in matrix_layout
assert "MatrixCount" in matrix_layout
assert "WorkTablets" in matrix_layout
assert len(layout_list) == 12 # 3+1+7+1 = 12个位置
# 验证推荐的位置不包含预留位置
reserved_positions = {12, 16}
recommended_positions = [item["positions"] for item in layout_list]
for pos in recommended_positions:
assert pos not in reserved_positions
def test_layout_recommendation_insufficient_space(self, default_layout_9320, prcxi_materials):
"""测试板位推荐空间不足的情况"""
# 添加物料信息
default_layout_9320.add_lab_resource(prcxi_materials)
# 尝试推荐超过可用空间的布局
needs = [
("reagent_1", "96 细胞培养皿", 15), # 需要15个位置但只有14个可用
]
with pytest.raises(ValueError, match="需要 .* 个位置,但只有 .* 个可用位置"):
default_layout_9320.recommend_layout(needs)
def test_layout_recommendation_material_not_found(self, default_layout_9320, prcxi_materials):
"""测试板位推荐物料不存在的情况"""
# 添加物料信息
default_layout_9320.add_lab_resource(prcxi_materials)
# 尝试推荐不存在的物料
needs = [
("reagent_1", "不存在的物料", 1),
]
with pytest.raises(ValueError, match="Material .* not found in lab resources"):
default_layout_9320.recommend_layout(needs)
class TestPRCXIBackendOperations:
"""测试 PRCXI 后端操作功能"""
def test_backend_initialization(self, prcxi_9300_handler):
"""测试后端初始化"""
backend = prcxi_9300_handler._unilabos_backend
assert isinstance(backend, PRCXI9300Backend)
assert backend._num_channels == 8
assert backend.debug is True
def test_protocol_creation(self, prcxi_9300_handler):
"""测试协议创建"""
backend = prcxi_9300_handler._unilabos_backend
backend.create_protocol("Test Protocol")
assert backend.protocol_name == "Test Protocol"
assert len(backend.steps_todo_list) == 0
def test_channel_validation(self):
"""测试通道验证"""
# 测试正确的8通道配置
valid_channels = [0, 1, 2, 3, 4, 5, 6, 7]
result = PRCXI9300Backend.check_channels(valid_channels)
assert result == valid_channels
# 测试错误的通道配置
invalid_channels = [0, 1, 2, 3]
result = PRCXI9300Backend.check_channels(invalid_channels)
assert result == [0, 1, 2, 3, 4, 5, 6, 7]
def test_matrix_info_creation(self, prcxi_9300_handler):
"""测试矩阵信息创建"""
backend = prcxi_9300_handler._unilabos_backend
backend.create_protocol("Test Protocol")
# 模拟运行协议时的矩阵信息创建
run_time = 1234567890
matrix_info = MatrixInfo(
MatrixId=f"{int(run_time)}",
MatrixName=f"protocol_{run_time}",
MatrixCount=len(backend.tablets_info),
WorkTablets=backend.tablets_info,
)
assert matrix_info["MatrixId"] == str(int(run_time))
assert matrix_info["MatrixName"] == f"protocol_{run_time}"
assert "WorkTablets" in matrix_info
class TestPRCXIContainerOperations:
"""测试 PRCXI 容器操作功能"""
def test_container_serialization(self, tip_rack_300ul):
"""测试容器序列化"""
serialized = tip_rack_300ul.serialize_state()
assert "Material" in serialized
assert serialized["Material"]["Name"] == "300μL Tip头"
def test_container_deserialization(self, tip_rack_300ul):
"""测试容器反序列化"""
# 序列化
serialized = tip_rack_300ul.serialize_state()
# 创建新容器并反序列化
new_tip_rack = PRCXI9300Container(
name="new_tip_rack",
size_x=50,
size_y=50,
size_z=10,
category="tip_rack",
ordering=collections.OrderedDict()
)
new_tip_rack.load_state(serialized)
assert new_tip_rack._unilabos_state["Material"]["Name"] == "300μL Tip头"
def test_trash_container_creation(self, prcxi_materials):
"""测试垃圾桶容器创建"""
trash = PRCXI9300Trash(name="trash", size_x=50, size_y=50, size_z=10, category="trash")
trash.load_state({
"Material": {
"uuid": prcxi_materials["废弃槽"]["uuid"]
}
})
assert trash.name == "trash"
assert trash._unilabos_state["Material"]["uuid"] == prcxi_materials["废弃槽"]["uuid"]
if __name__ == "__main__":
# 运行测试
pytest.main([__file__, "-v"])

View File

@@ -1,15 +0,0 @@
# Liquid handling 集成测试
`test_transfer_liquid.py` 现在会调用 PRCXI 的 RViz 仿真 backend运行前请确保
1. 已安装包含 `pylabrobot``rclpy` 的运行环境;
2. 启动 ROS 依赖(`rviz` 可选,但是 `rviz_backend` 会创建 ROS 节点);
3. 在 shell 中设置 `UNILAB_SIM_TEST=1`,否则 pytest 会自动跳过这些慢速用例:
```bash
export UNILAB_SIM_TEST=1
pytest tests/devices/liquid_handling/test_transfer_liquid.py -m slow
```
如果只需验证逻辑层(不依赖仿真),可以直接运行 `tests/devices/liquid_handling/unit_test.py`,该文件使用 Fake backend适合作为 CI 的快速测试。***

View File

@@ -1,244 +0,0 @@
"""P9 — ``liquid_history`` schema v3 + helper 单元测试。
测试覆盖:
- :func:`append_liquid_history`:写 v3 entry / tracker 缺失 graceful / 滚动上限
- :func:`normalize_liquid_history`v3 dict / v2 tuple / list[str] / 混合 / 非法
- :func:`well_current_liquid_name`tracker.liquids 末项 / get_liquids fallback / 缺失
注:``LiquidHandlerAbstract.set_liquid`` 写 history 的集成("set" action覆盖
逻辑相同(直接调用 :func:`append_liquid_history`),由本测试间接验证;端到端走 PLR
真实 ``Well.set_liquids`` 的集成测试在 ``tests/devices/liquid_handling/unit_test.py``
范围内随 PLR 环境就绪后增补,本 P9 提交保持解耦。
详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md`` §8。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, List, Tuple
import pytest
# liquid_history 模块**不依赖** pylabrobot可在 PLR 环境缺失时独立 import / 单测。
from unilabos.devices.liquid_handling.liquid_history import (
LIQUID_HISTORY_MAX_ENTRIES,
LiquidHistoryEntry,
append_liquid_history,
normalize_liquid_history,
well_current_liquid_name,
)
# ---------------------------------------------------------------------------
# FixturesDummyTracker / DummyWell避免引入真实 PLR Well/VolumeTracker 依赖)
# ---------------------------------------------------------------------------
@dataclass
class DummyTracker:
"""模拟 PLR VolumeTracker仅暴露 P9 hook 关心的字段。"""
liquid_history: List[Any] = field(default_factory=list)
liquids: List[Tuple[Any, float]] = field(default_factory=list)
max_volume: float = 200.0
is_disabled: bool = False
@dataclass
class DummyWell:
"""模拟 PLR Well仅暴露 ``tracker``。"""
name: str = "well_A1"
max_volume: float = 200.0
tracker: DummyTracker = field(default_factory=DummyTracker)
# ---------------------------------------------------------------------------
# append_liquid_history
# ---------------------------------------------------------------------------
class TestAppendLiquidHistory:
def test_append_creates_v3_entry(self) -> None:
well = DummyWell()
append_liquid_history(well, "Plasma", 100.0, "set")
assert len(well.tracker.liquid_history) == 1
entry = well.tracker.liquid_history[0]
assert entry["name"] == "Plasma"
assert entry["volume"] == 100.0
assert entry["action"] == "set"
assert "timestamp" in entry and isinstance(entry["timestamp"], str)
def test_append_aspirate_negative_volume(self) -> None:
well = DummyWell()
append_liquid_history(well, "Water", -50.0, "aspirate")
assert well.tracker.liquid_history[0]["volume"] == -50.0
assert well.tracker.liquid_history[0]["action"] == "aspirate"
def test_append_with_empty_name_keeps_empty_string(self) -> None:
"""name 为空时应写入 ``""`` 而非字面 "unknown"(避免视觉混淆 bottom_type"""
well = DummyWell()
append_liquid_history(well, "", 50.0, "dispense")
assert well.tracker.liquid_history[0]["name"] == ""
def test_append_with_none_name_normalized_to_empty_string(self) -> None:
well = DummyWell()
append_liquid_history(well, None, 50.0, "dispense") # type: ignore[arg-type]
assert well.tracker.liquid_history[0]["name"] == ""
def test_append_initializes_history_if_missing(self) -> None:
"""tracker 没有 liquid_history 属性时 helper 自动创建空 list 并写入。"""
well = DummyWell()
del well.tracker.liquid_history # 模拟全新 PLR tracker
append_liquid_history(well, "X", 10.0, "set")
assert hasattr(well.tracker, "liquid_history")
assert len(well.tracker.liquid_history) == 1
def test_append_no_tracker_is_graceful(self) -> None:
"""well 无 tracker 时静默不抛(保护主流程)。"""
class NoTrackerWell:
name = "no_tracker"
well = NoTrackerWell()
append_liquid_history(well, "X", 10.0, "set") # 不应抛
assert not hasattr(well, "tracker")
def test_append_action_defaults_to_legacy_when_empty(self) -> None:
well = DummyWell()
append_liquid_history(well, "X", 1.0, "")
assert well.tracker.liquid_history[0]["action"] == "legacy"
def test_append_respects_max_entries_rolling(self) -> None:
"""超过 ``LIQUID_HISTORY_MAX_ENTRIES`` 时丢弃头部,保留最近 entries。"""
well = DummyWell()
well.tracker.liquid_history = [
{"name": f"old_{i}"} for i in range(LIQUID_HISTORY_MAX_ENTRIES + 5)
]
append_liquid_history(well, "newest", 1.0, "set")
assert len(well.tracker.liquid_history) == LIQUID_HISTORY_MAX_ENTRIES
assert well.tracker.liquid_history[-1]["name"] == "newest"
assert well.tracker.liquid_history[0]["name"] != "old_0"
# ---------------------------------------------------------------------------
# normalize_liquid_history
# ---------------------------------------------------------------------------
class TestNormalizeLiquidHistory:
def test_v3_dict_passthrough_with_field_defaults(self) -> None:
raw = [{"name": "A", "volume": 100, "action": "set", "timestamp": "2026-05-22T00:00:00Z"}]
result = normalize_liquid_history(raw)
assert result == [{
"name": "A",
"volume": 100.0,
"action": "set",
"timestamp": "2026-05-22T00:00:00Z",
}]
def test_v3_dict_missing_optional_fields_filled_with_defaults(self) -> None:
raw = [{"name": "A"}]
result = normalize_liquid_history(raw)
assert result == [{"name": "A", "volume": 0.0, "action": "legacy"}]
assert "timestamp" not in result[0]
def test_v2_tuple_upgraded_to_v3_legacy(self) -> None:
raw = [("A", 100), ("B", 50.5)]
result = normalize_liquid_history(raw)
assert result == [
{"name": "A", "volume": 100.0, "action": "legacy"},
{"name": "B", "volume": 50.5, "action": "legacy"},
]
def test_list_of_strings_upgraded(self) -> None:
raw = ["A", "B"]
result = normalize_liquid_history(raw)
assert result == [
{"name": "A", "volume": 0.0, "action": "legacy"},
{"name": "B", "volume": 0.0, "action": "legacy"},
]
def test_mixed_input_normalized(self) -> None:
raw = [
{"name": "A", "volume": 1, "action": "set"},
("B", 2),
"C",
]
result = normalize_liquid_history(raw)
assert [e["name"] for e in result] == ["A", "B", "C"]
assert [e["action"] for e in result] == ["set", "legacy", "legacy"]
def test_invalid_entries_dropped(self) -> None:
raw = [42, None, {"name": "A"}, ("only_one",)]
result = normalize_liquid_history(raw)
# 只保留 {"name": "A"} 这一条;其它都被丢弃
assert len(result) == 1
assert result[0]["name"] == "A"
assert result[0]["volume"] == 0.0 # 缺省补 0
def test_non_list_input_returns_empty(self) -> None:
assert normalize_liquid_history(None) == []
assert normalize_liquid_history("not_a_list") == []
assert normalize_liquid_history({"name": "X"}) == []
def test_tuple_with_unconvertible_volume_falls_back_to_zero(self) -> None:
raw = [("A", "not_a_number")]
result = normalize_liquid_history(raw)
assert result[0]["volume"] == 0.0
# ---------------------------------------------------------------------------
# well_current_liquid_name
# ---------------------------------------------------------------------------
class TestWellCurrentLiquidName:
def test_returns_last_liquid_name_from_tuple(self) -> None:
well = DummyWell()
well.tracker.liquids = [("Water", 50.0), ("Plasma", 100.0)]
assert well_current_liquid_name(well) == "Plasma"
def test_returns_enum_like_name_attr(self) -> None:
class FakeLiquid:
name = "ETHANOL"
well = DummyWell()
well.tracker.liquids = [(FakeLiquid(), 100.0)]
assert well_current_liquid_name(well) == "ETHANOL"
def test_empty_liquids_returns_empty_string(self) -> None:
well = DummyWell()
well.tracker.liquids = []
assert well_current_liquid_name(well) == ""
def test_no_tracker_returns_empty_string(self) -> None:
class NoTrackerWell:
name = "x"
assert well_current_liquid_name(NoTrackerWell()) == ""
def test_none_liquid_returns_empty_string(self) -> None:
well = DummyWell()
well.tracker.liquids = [(None, 100.0)]
assert well_current_liquid_name(well) == ""
def test_string_liquid_returned_as_is(self) -> None:
well = DummyWell()
well.tracker.liquids = ["Saline"]
assert well_current_liquid_name(well) == "Saline"

View File

@@ -1,239 +0,0 @@
"""P2 v2 跨板能力验证 —— device 层 ``set_liquid_from_plate`` 单测。
对应 ``product_designs/protocol_convert/02-cross-slot-merge.md`` §9.1 / §9.5 step 6.3。
本测试聚焦于 **`_set_liquid_grouped_by_plate`** 已天然支持跨板 wells 的能力v2 设计
的核心依据):
- 输入 ``wells`` 列表来自多个 plate每板各一/多个 well``set_liquid`` 应按 plate
分桶串行调用每板一次plate-bucket 顺序按 first-occurrence
- 同板内多孔归到同一桶。
- 返回 ``volumes`` 按 **输入 index 顺序**回拼,与 wells 一致 —— 这是 v2 Stage 3
merged ``set_liquid_from_plate.output_wells`` 的顺序权威来源。
- ``Well.set_liquids`` 在 ``set_liquid`` 链内被逐孔调用,与 PLR 实现的预期接口一致。
为了避免引入完整 PLR 资源树,测试用 duck-typed ``DummyWell`` / ``DummyPlate`` +
``ResourceTreeSet`` 的 monkeypatchdump 直接返回输入列表)。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import List, Tuple
import pytest
# ----------------------------------------------------------------------
# 跨环境兼容:与现有 ``tests/devices/liquid_handling/test_transfer_liquid.py`` 一致,
# 本测试通过 import ``unilabos.devices.liquid_handling.liquid_handler_abstract``
# 拉起 pylabrobot 链;某些本地开发机的 pylabrobot 版本与代码库要求不一致,
# 会在 import 阶段抛 ``ImportError``。这里用 ``importorskip`` 优雅跳过,让
# CI统一 pylabrobot 版本跑全纯逻辑测试Stage 2 / Stage 3不受影响。
# ----------------------------------------------------------------------
LiquidHandlerAbstract = pytest.importorskip(
"unilabos.devices.liquid_handling.liquid_handler_abstract",
reason="pylabrobot 链未完整可用,跳过 device 单测CI 上请保证 pylabrobot ≥ 项目要求版本",
exc_type=ImportError,
).LiquidHandlerAbstract
# ==================== Duck-typed PLR-like 资源 ====================
@dataclass
class DummyPlate:
name: str
def __repr__(self) -> str: # pragma: no cover
return f"DummyPlate({self.name})"
@dataclass
class DummyWell:
name: str
parent: DummyPlate
max_volume: float = 1000.0
liquid_history: List[Tuple[str, float]] = field(default_factory=list)
def set_liquids(self, items):
"""模拟 PLR ``Well.set_liquids([(name, vol), ...])`` 接口。"""
for name, vol in items:
self.liquid_history.append((str(name), float(vol)))
def __repr__(self) -> str: # pragma: no cover
return f"DummyWell({self.parent.name}/{self.name})"
# ==================== fixture装一台 FakeLiquidHandler ====================
@pytest.fixture
def patched_resource_tree(monkeypatch):
"""patch ``ResourceTreeSet.from_plr_resources`` 使其接受 duck-typed wells/plates。
返回的对象只要带 ``.dump()`` 即可(``_set_liquid_grouped_by_plate`` 仅消费该方法)。
"""
from unilabos.devices.liquid_handling import liquid_handler_abstract as lha
class _FakeTree:
def __init__(self, items):
self._items = items
def dump(self):
return [
{"name": getattr(x, "name", None), "type": type(x).__name__}
for x in self._items
]
def _fake_from_plr_resources(items, known_newly_created=False): # noqa: ARG001
return _FakeTree(list(items))
monkeypatch.setattr(
lha.ResourceTreeSet,
"from_plr_resources",
staticmethod(_fake_from_plr_resources),
)
return lha
@pytest.fixture
def handler(patched_resource_tree):
"""构造一台最小 LiquidHandlerAbstract 实例,绕过真实 backend / deck。"""
class _FakeHandler(LiquidHandlerAbstract):
def __init__(self):
# 不调用 super().__init__避免真实硬件/后端依赖
self.channel_num = 8
self.support_touch_tip = True
return _FakeHandler()
def _wells_grid(plate_name: str, well_names: List[str]) -> List[DummyWell]:
plate = DummyPlate(name=plate_name)
return [DummyWell(name=w, parent=plate) for w in well_names]
# ==================== 用例 ====================
def test_grouped_by_plate_single_plate_set_liquid_inline(handler):
"""单 plate 多孔set_liquids 按 wells 顺序逐项调用volumes 回拼一致。"""
wells = _wells_grid("plate_slot2", ["A1", "A2", "A3"])
ret = handler._set_liquid_grouped_by_plate(
wells=wells,
liquid_names=["reagent_X"] * 3,
volumes=[10.0, 20.0, 30.0],
)
# 每个 well 的 liquid_history 各 1 条
for w, expected_vol in zip(wells, [10.0, 20.0, 30.0]):
assert w.liquid_history == [("reagent_X", expected_vol)]
# 返回 volumes 顺序与输入一致
assert ret.volumes == [10.0, 20.0, 30.0]
def test_grouped_by_plate_cross_plate_buckets_by_parent(handler):
"""跨板 wells 列表 → 按 first-occurrence plate 顺序分桶,每板单独 set_liquid。
51b9a5 简化(每板 1 孔4 plate × 1 well = 4 set_liquids 调用。
"""
p2 = _wells_grid("plate_slot2", ["A1"])
p3 = _wells_grid("plate_slot3", ["A1"])
p5 = _wells_grid("plate_slot5", ["A1"])
p6 = _wells_grid("plate_slot6", ["A1"])
wells = p2 + p3 + p5 + p6
ret = handler._set_liquid_grouped_by_plate(
wells=wells,
liquid_names=["l1"] * 4,
volumes=[8.3] * 4,
)
# 每个 well 都被 set_liquids 设过
for w in wells:
assert w.liquid_history == [("l1", 8.3)], f"well {w.parent.name}/{w.name} 未正确设液"
# volumes 顺序与输入对齐
assert ret.volumes == [8.3, 8.3, 8.3, 8.3]
# plate dump 应含 4 个 plate按 first-occurrence
plate_dump = ret.plate
plate_names = [p["name"] for p in plate_dump]
assert plate_names == ["plate_slot2", "plate_slot3", "plate_slot5", "plate_slot6"]
def test_grouped_by_plate_interleaved_cross_plate_preserves_input_order(handler):
"""交错跨板wells=[p2.A1, p3.A1, p2.A2, p5.A1] → volumes 顺序按输入回拼。
内部仍按 plate 分桶执行 set_liquidper-plate 串行),但返回顺序遵循输入 index。
"""
p2 = DummyPlate(name="plate_slot2")
p3 = DummyPlate(name="plate_slot3")
p5 = DummyPlate(name="plate_slot5")
w_p2_a1 = DummyWell(name="A1", parent=p2)
w_p2_a2 = DummyWell(name="A2", parent=p2)
w_p3_a1 = DummyWell(name="A1", parent=p3)
w_p5_a1 = DummyWell(name="A1", parent=p5)
wells = [w_p2_a1, w_p3_a1, w_p2_a2, w_p5_a1]
ret = handler._set_liquid_grouped_by_plate(
wells=wells,
liquid_names=["l1"] * 4,
volumes=[10.0, 20.0, 30.0, 40.0],
)
# 每个 well 都被设液
assert w_p2_a1.liquid_history == [("l1", 10.0)]
assert w_p3_a1.liquid_history == [("l1", 20.0)]
assert w_p2_a2.liquid_history == [("l1", 30.0)]
assert w_p5_a1.liquid_history == [("l1", 40.0)]
# 返回 volumes 严格按输入 index 顺序回拼
assert ret.volumes == [10.0, 20.0, 30.0, 40.0]
# plate dump按 first-occurrenceplate_slot2 第 1 次出现于 idx=0plate_slot3 idx=1plate_slot5 idx=3
plate_names = [p["name"] for p in ret.plate]
assert plate_names == ["plate_slot2", "plate_slot3", "plate_slot5"]
def test_grouped_by_plate_volumes_clamped_to_max_volume(handler):
"""``set_liquid`` 会按 ``max_volume`` 做 clamp防止初始化液量超容器容量。"""
plate = DummyPlate(name="plate_slot2")
well = DummyWell(name="A1", parent=plate, max_volume=200.0)
ret = handler._set_liquid_grouped_by_plate(
wells=[well],
liquid_names=["overflow"],
volumes=[500.0], # 超过 max_volume=200
)
assert well.liquid_history == [("overflow", 200.0)]
assert ret.volumes == [200.0]
def test_grouped_by_plate_empty_names_short_circuit(handler):
"""``liquid_names`` 与 ``volumes`` 均为空早返回wells 列表回显但不设液。"""
wells = _wells_grid("plate_slot2", ["A1", "A2"])
ret = handler._set_liquid_grouped_by_plate(
wells=wells,
liquid_names=[],
volumes=[],
)
# 不调用 set_liquids
assert all(w.liquid_history == [] for w in wells)
assert ret.volumes == []
# wells dump 仍返回输入列表
assert [w["name"] for w in ret.wells] == ["A1", "A2"]
def test_grouped_by_plate_length_mismatch_raises(handler):
"""wells / liquid_names / volumes 长度不一致应直接 raise防御性校验"""
wells = _wells_grid("plate_slot2", ["A1", "A2"])
with pytest.raises(ValueError, match=r"必须等长"):
handler._set_liquid_grouped_by_plate(
wells=wells,
liquid_names=["r"] * 2,
volumes=[10.0], # 长度 1不匹配
)

View File

@@ -1,566 +0,0 @@
"""P10 v2 — Tip 复用 ``tracker.liquids`` 等价规则单元测试。
测试覆盖(详见 ``product_designs/protocol_convert/10-tip-reuse-by-liquid-history.md`` §5
- Helper``is_known_liquid_name`` / ``same_liquid_via_liquids`` /
``same_liquid_via_liquids_pair`` / ``capture_tip_liquid_name``4 helper
位于 ``liquid_history.py``PLR-free 模块)。
- 单通道 transfer_liquid 主循环identity-keep / liquids-keep / 配置开关 /
未知 name 保守换 tip / aspirate 顶层归零时序。
- 8 通道分支:段锚孔 liquids-keep。
- 跨节点边界:两个独立 transfer_liquid 调用状态隔离。
helper 测试独立于 PLR可在 ``pylabrobot`` 缺失环境下单独运行;端到端
``transfer_liquid`` 主循环测试需要 PLR 环境(沿用 ``test_transfer_liquid.py`` 的
``FakeLiquidHandler`` 模式:跳过 ``super().__init__``,仅 stub 4 类方法记录调用)。
若 PLR import 失败则自动 skip 端到端测试,保留 helper 测试结果。
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from typing import Any, Iterable, List, Optional, Sequence, Tuple
import pytest
# P10 v2 helper 位于 PLR-free 模块,无论 pylabrobot 是否安装都能 import。
from unilabos.devices.liquid_handling.liquid_history import (
capture_tip_liquid_name,
is_known_liquid_name,
same_liquid_via_liquids,
same_liquid_via_liquids_pair,
)
# 端到端测试依赖 PLR 完整环境;若 import 失败(例如本地 PLR 版本不匹配),
# 整段端到端测试自动 skip但 helper 测试照常执行。
try:
from unilabos.devices.liquid_handling.liquid_handler_abstract import (
LiquidHandlerAbstract,
)
_PLR_AVAILABLE = True
_PLR_IMPORT_ERROR: Optional[Exception] = None
except Exception as exc: # pragma: no cover - 环境相关
LiquidHandlerAbstract = None # type: ignore[assignment, misc]
_PLR_AVAILABLE = False
_PLR_IMPORT_ERROR = exc
# ---------------------------------------------------------------------------
# FixturesDummyTracker / DummyWell / DummyTipSpot / FakeLiquidHandler
# ---------------------------------------------------------------------------
@dataclass
class DummyTracker:
"""模拟 PLR ``VolumeTracker``:仅暴露 P10 v2 关心的 ``liquids`` 字段。"""
liquids: List[Tuple[Any, float]] = field(default_factory=list)
max_volume: float = 200.0
is_disabled: bool = False
@dataclass
class DummyWell:
"""模拟 PLR ``Well``:仅暴露 ``tracker``。"""
name: str = "well"
tracker: DummyTracker = field(default_factory=DummyTracker)
def __repr__(self) -> str: # pragma: no cover
return f"DummyWell({self.name})"
def make_well(name: str, liquid_name: Optional[str] = None, vol: float = 100.0) -> DummyWell:
"""构造一个 well若指定 ``liquid_name`` 则写入 ``tracker.liquids`` 顶层。"""
well = DummyWell(name=name, tracker=DummyTracker())
if liquid_name is not None:
well.tracker.liquids = [(liquid_name, vol)]
return well
@dataclass(frozen=True)
class DummyTipSpot:
name: str
def make_tip_iter(n: int = 256) -> Iterable[List[DummyTipSpot]]:
for i in range(n):
yield [DummyTipSpot(f"tip_{i}")]
# E2E 测试用的 basePLR 可用时是 ``LiquidHandlerAbstract``,否则 fallback 到
# ``object`` 让模块仍能 import带 ``LiquidHandlerAbstract`` 的 e2e 测试用
# ``skipif`` 跳过。
_FakeBase = LiquidHandlerAbstract if _PLR_AVAILABLE else object
class FakeLiquidHandler(_FakeBase): # type: ignore[misc, valid-type]
"""不初始化真实 backend/deck仅记录 transfer_liquid 内部 4 类调用序列。
P10 v2 测试关心 ``pick_up_tips`` / ``discard_tips`` 的触发次数 + 顺序,
以推断 tip 是否被复用(一次 pick_up_tips 多次 aspirate/dispense → 复用)。
"""
def __init__(self, channel_num: int = 1, tip_reuse_by_liquid_name: bool = True):
# 不调用 super().__init__避免硬件 / ROS / PLR Deck 初始化。
self.channel_num = channel_num
self.support_touch_tip = True
self.current_tip = iter(make_tip_iter(2048))
self.calls: List[Tuple[str, Any]] = []
self._tip_reuse_by_liquid_name: bool = tip_reuse_by_liquid_name
def set_tiprack(self, tip_racks):
if not tip_racks:
return
# 跳过真实 set_tiprack依赖 PLR Deck
return
async def pick_up_tips(self, tip_spots, use_channels=None, offsets=None, **kw):
self.calls.append(("pick_up_tips", {"tips": list(tip_spots), "use_channels": use_channels}))
async def aspirate(
self,
resources: Sequence[Any],
vols: List[float],
use_channels: Optional[List[int]] = None,
flow_rates: Optional[List[Optional[float]]] = None,
offsets: Any = None,
liquid_height: Any = None,
blow_out_air_volume: Any = None,
spread: str = "wide",
**backend_kwargs,
):
self.calls.append(
("aspirate", {"resources": list(resources), "vols": list(vols)})
)
async def dispense(
self,
resources: Sequence[Any],
vols: List[float],
use_channels: Optional[List[int]] = None,
flow_rates: Optional[List[Optional[float]]] = None,
offsets: Any = None,
liquid_height: Any = None,
blow_out_air_volume: Any = None,
spread: str = "wide",
**backend_kwargs,
):
self.calls.append(
("dispense", {"resources": list(resources), "vols": list(vols)})
)
async def discard_tips(self, use_channels=None, *args, **kwargs):
self.calls.append(("discard_tips", {"use_channels": use_channels}))
class AspiratePopFakeLiquidHandler(FakeLiquidHandler):
"""T11 专用aspirate 时模拟 PLR "顶层归零时 pop ``tracker.liquids`` 顶层" 的行为。
用于验证 P10 v2 的关键时序约束tip name 必须在 aspirate **之前**预读,
否则 aspirate 后再读 ``tracker.liquids[-1]`` 会拿不到液体身份。
"""
async def aspirate(self, resources, vols, **kwargs):
await super().aspirate(resources, vols, **kwargs)
# 模拟 PLR 顶层归零时 pop对每个 source well若 liquids 非空则 pop 顶层
for r in resources:
tracker = getattr(r, "tracker", None)
if tracker is not None and tracker.liquids:
tracker.liquids.pop()
def run(coro):
return asyncio.run(coro)
def call_names(lh: FakeLiquidHandler) -> List[str]:
return [c[0] for c in lh.calls]
# ---------------------------------------------------------------------------
# Helper 单元测试
# ---------------------------------------------------------------------------
class TestIsKnownLiquidName:
def test_empty_string_is_unknown(self) -> None:
assert is_known_liquid_name("") is False
def test_none_is_unknown(self) -> None:
assert is_known_liquid_name(None) is False
def test_literal_unknown_is_unknown(self) -> None:
assert is_known_liquid_name("unknown") is False
assert is_known_liquid_name("UNKNOWN") is False
assert is_known_liquid_name(" Unknown ") is False
def test_literal_none_string_is_unknown(self) -> None:
assert is_known_liquid_name("none") is False
assert is_known_liquid_name("None") is False
def test_real_liquid_name_is_known(self) -> None:
assert is_known_liquid_name("PBS") is True
assert is_known_liquid_name("Tris HCl") is True
assert is_known_liquid_name("Liquid_3") is True
class TestSameLiquidViaLiquids:
def test_well_and_tip_same_name_match(self) -> None:
well = make_well("A1", "PBS")
assert same_liquid_via_liquids(well, "PBS") is True
def test_well_and_tip_different_names_no_match(self) -> None:
well = make_well("A1", "PBS")
assert same_liquid_via_liquids(well, "Tris HCl") is False
def test_tip_unknown_returns_false(self) -> None:
well = make_well("A1", "PBS")
assert same_liquid_via_liquids(well, None) is False
assert same_liquid_via_liquids(well, "") is False
assert same_liquid_via_liquids(well, "unknown") is False
def test_well_empty_liquids_returns_false(self) -> None:
well = make_well("A1", liquid_name=None) # 不写 liquids
assert same_liquid_via_liquids(well, "PBS") is False
def test_well_unknown_literal_returns_false(self) -> None:
well = make_well("A1", "unknown")
assert same_liquid_via_liquids(well, "unknown") is False
class TestSameLiquidViaLiquidsPair:
def test_two_wells_same_name_match(self) -> None:
a = make_well("A1", "PBS")
b = make_well("B1", "PBS")
assert same_liquid_via_liquids_pair(a, b) is True
def test_two_wells_different_names_no_match(self) -> None:
a = make_well("A1", "PBS")
b = make_well("B1", "Tris HCl")
assert same_liquid_via_liquids_pair(a, b) is False
def test_either_well_empty_returns_false(self) -> None:
a = make_well("A1", "PBS")
b = make_well("B1", liquid_name=None)
assert same_liquid_via_liquids_pair(a, b) is False
assert same_liquid_via_liquids_pair(b, a) is False
class TestCaptureTipLiquidName:
def test_known_name_returned(self) -> None:
well = make_well("A1", "PBS")
assert capture_tip_liquid_name(well) == "PBS"
def test_empty_well_returns_none(self) -> None:
well = make_well("A1", liquid_name=None)
assert capture_tip_liquid_name(well) is None
def test_unknown_literal_returns_none(self) -> None:
well = make_well("A1", "unknown")
assert capture_tip_liquid_name(well) is None
# ---------------------------------------------------------------------------
# T1T12 端到端测试(单通道 transfer_liquid 主循环)
#
# 需要 PLR 完整环境(``pylabrobot.liquid_handling.LiquidHandlerBackend`` 等)。
# 若 PLR import 失败则整段 skiphelper 测试照常运行。
# ---------------------------------------------------------------------------
_skip_if_no_plr = pytest.mark.skipif(
not _PLR_AVAILABLE,
reason=f"pylabrobot import failed: {_PLR_IMPORT_ERROR}",
)
@_skip_if_no_plr
class TestSingleChannelTipReuse:
"""覆盖 §5 矩阵 T1 / T2 / T3 / T4 / T5 / T6 / T8 / T10 / T11。"""
def test_T1_identity_hit_reuses_tip(self) -> None:
"""T1连续 2 轮同 source/target → identity-keep 命中,复用 tip。"""
lh = FakeLiquidHandler(channel_num=1)
src = make_well("S0", "PBS")
tgt = make_well("T0")
run(
lh.transfer_liquid(
sources=[src, src],
targets=[tgt, tgt],
tip_racks=[],
use_channels=[0],
asp_vols=[1, 1],
dis_vols=[1, 1],
)
)
# 2 次 transfer但 identity-keep → 仅 1 次 pick_up_tips / 1 次 discard_tips
assert call_names(lh).count("pick_up_tips") == 1
assert call_names(lh).count("discard_tips") == 1
assert call_names(lh).count("aspirate") == 2
assert call_names(lh).count("dispense") == 2
def test_T2_liquids_hit_across_plates(self) -> None:
"""T29 个独立 source well不同 PLR Well 对象)都装 PBS → identity 全 failliquids-keep 全命中。"""
lh = FakeLiquidHandler(channel_num=1)
sources = [make_well(f"S{i}", "PBS") for i in range(9)]
targets = [make_well(f"T{i}") for i in range(9)]
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=[0],
asp_vols=[1] * 9,
dis_vols=[1] * 9,
)
)
# 9 个 source 物理上同液 → 整段共用 1 个 tip
assert call_names(lh).count("pick_up_tips") == 1
assert call_names(lh).count("discard_tips") == 1
assert call_names(lh).count("aspirate") == 9
assert call_names(lh).count("dispense") == 9
def test_T3_liquids_hit_same_plate_different_wells(self) -> None:
"""T3同 plate 上 A1-H1 都装 PBS8 个不同 Well 对象)→ identity 全 failliquids-keep 命中。"""
lh = FakeLiquidHandler(channel_num=1)
sources = [make_well(f"A{i}", "PBS") for i in range(1, 9)]
targets = [make_well(f"T{i}") for i in range(8)]
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=[0],
asp_vols=[1] * 8,
dis_vols=[1] * 8,
)
)
assert call_names(lh).count("pick_up_tips") == 1
assert call_names(lh).count("discard_tips") == 1
def test_T4_liquids_not_match_forces_tip_change(self) -> None:
"""T4A1=PBSB1=Tris HCl → liquids 名不等,强制换 tip。"""
lh = FakeLiquidHandler(channel_num=1)
sources = [make_well("A1", "PBS"), make_well("B1", "Tris HCl")]
targets = [make_well("T0"), make_well("T1")]
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=[0],
asp_vols=[1, 1],
dis_vols=[1, 1],
)
)
# 2 次完全独立的 transfer2 次 pick_up / 2 次 discard
assert call_names(lh).count("pick_up_tips") == 2
assert call_names(lh).count("discard_tips") == 2
def test_T5_empty_liquids_forces_tip_change(self) -> None:
"""T5source 从未调过 set_liquidsliquids 空)→ 视为未知,强制换 tip。"""
lh = FakeLiquidHandler(channel_num=1)
sources = [make_well("A1"), make_well("B1")] # 没装液体名
targets = [make_well("T0"), make_well("T1")]
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=[0],
asp_vols=[1, 1],
dis_vols=[1, 1],
)
)
assert call_names(lh).count("pick_up_tips") == 2
assert call_names(lh).count("discard_tips") == 2
def test_T6_switch_off_disables_liquids_keep(self) -> None:
"""T6tip_reuse_by_liquid_name=FalseT2 场景退化为 identity-only强制换 tip。"""
lh = FakeLiquidHandler(channel_num=1, tip_reuse_by_liquid_name=False)
sources = [make_well(f"S{i}", "PBS") for i in range(9)]
targets = [make_well(f"T{i}") for i in range(9)]
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=[0],
asp_vols=[1] * 9,
dis_vols=[1] * 9,
)
)
# 关闭开关后 → 退化为 identity-only9 次独立换 tip
assert call_names(lh).count("pick_up_tips") == 9
assert call_names(lh).count("discard_tips") == 9
def test_T8_mix_style_same_source_reuses_via_identity(self) -> None:
"""T8单 source 反复 aspirate/dispense → identity-keep 命中mix-style"""
lh = FakeLiquidHandler(channel_num=1)
src = make_well("S0", "Methanol")
tgt = make_well("T0")
run(
lh.transfer_liquid(
sources=[src, src, src],
targets=[tgt, tgt, tgt],
tip_racks=[],
use_channels=[0],
asp_vols=[1, 1, 1],
dis_vols=[1, 1, 1],
)
)
assert call_names(lh).count("pick_up_tips") == 1
assert call_names(lh).count("discard_tips") == 1
def test_T10_unknown_literal_treated_as_unknown(self) -> None:
"""T10``tracker.liquids = [("unknown", v)]``(兼容旧数据)→ 视为未知,强制换 tip。"""
lh = FakeLiquidHandler(channel_num=1)
sources = [make_well("A1", "unknown"), make_well("B1", "unknown")]
targets = [make_well("T0"), make_well("T1")]
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=[0],
asp_vols=[1, 1],
dis_vols=[1, 1],
)
)
assert call_names(lh).count("pick_up_tips") == 2
assert call_names(lh).count("discard_tips") == 2
def test_T11_aspirate_pop_timing_pre_read(self) -> None:
"""T11aspirate 顶层归零 → PLR pop ``tracker.liquids`` 顶层;
验证 P10 v2 ``pending_tip_name`` 必须在 aspirate **之前**预读才能命中下一轮。
"""
lh = AspiratePopFakeLiquidHandler(channel_num=1)
sources = [make_well(f"S{i}", "PBS") for i in range(3)]
targets = [make_well(f"T{i}") for i in range(3)]
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=[0],
asp_vols=[1] * 3,
dis_vols=[1] * 3,
)
)
# 即使 aspirate 后 source.tracker.liquids 被 poppending_tip_name 已捕获 "PBS"
# → 下一轮 source 仍是 PBSaspirate 还没发生liquids-keep 命中
# → 整段 1 次 pick_up_tips
assert call_names(lh).count("pick_up_tips") == 1
assert call_names(lh).count("discard_tips") == 1
# ---------------------------------------------------------------------------
# T7跨节点边界两个独立 transfer_liquid 调用,状态隔离)
# ---------------------------------------------------------------------------
@_skip_if_no_plr
class TestCrossNodeBoundary:
"""T7两个 transfer_liquid 节点之间不复用 tip每次调用初始化 current_tip_liquid_name=None"""
def test_T7_two_calls_dont_share_tip_state(self) -> None:
lh = FakeLiquidHandler(channel_num=1)
src_a = make_well("A_src", "PBS")
tgt_a = make_well("A_tgt")
src_b = make_well("B_src", "PBS") # 同名液,但不同 well
tgt_b = make_well("B_tgt")
run(
lh.transfer_liquid(
sources=[src_a],
targets=[tgt_a],
tip_racks=[],
use_channels=[0],
asp_vols=[1],
dis_vols=[1],
)
)
run(
lh.transfer_liquid(
sources=[src_b],
targets=[tgt_b],
tip_racks=[],
use_channels=[0],
asp_vols=[1],
dis_vols=[1],
)
)
# 两次调用各自独立换 tip → 2 次 pick_up_tips / 2 次 discard_tips
assert call_names(lh).count("pick_up_tips") == 2
assert call_names(lh).count("discard_tips") == 2
# ---------------------------------------------------------------------------
# T98 通道段锚孔 liquids-keep
# ---------------------------------------------------------------------------
@_skip_if_no_plr
class TestEightChannelSegmentTipReuse:
"""T98 通道分段,连续两段 src_slice[0] 同名 → 段间不换 tip。"""
def test_T9_two_segments_same_anchor_liquid(self) -> None:
lh = FakeLiquidHandler(channel_num=8)
# 16 个 source wells分 2 段;段 1 锚孔 = sources[0],段 2 锚孔 = sources[8]
sources = [make_well(f"S{i}", "PBS") for i in range(16)]
targets = [make_well(f"T{i}") for i in range(16)]
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=list(range(8)),
asp_vols=[1] * 16,
dis_vols=[1] * 16,
mix_times=0,
)
)
# 2 段都同液 → liquids-keep 命中 → 仅 1 次 pick_up_tips
assert call_names(lh).count("pick_up_tips") == 1
assert call_names(lh).count("discard_tips") == 1
def test_T9b_two_segments_different_anchor_liquid_forces_tip_change(self) -> None:
"""T9b段 1 锚孔 = PBS段 2 锚孔 = Tris → 段间强制换 tip。"""
lh = FakeLiquidHandler(channel_num=8)
seg1 = [make_well(f"S{i}", "PBS") for i in range(8)]
seg2 = [make_well(f"S{i + 8}", "Tris HCl") for i in range(8)]
sources = seg1 + seg2
targets = [make_well(f"T{i}") for i in range(16)]
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=list(range(8)),
asp_vols=[1] * 16,
dis_vols=[1] * 16,
mix_times=0,
)
)
# 2 段不同液 → 2 次独立换 tip
assert call_names(lh).count("pick_up_tips") == 2
assert call_names(lh).count("discard_tips") == 2
# ---------------------------------------------------------------------------
# 配置开关默认值 / 实例字段读取
# ---------------------------------------------------------------------------
@_skip_if_no_plr
class TestConfigDefault:
def test_default_switch_is_on(self) -> None:
"""默认 ``_tip_reuse_by_liquid_name`` 应为 True测试 fixture 显式 default 一致)。"""
lh = FakeLiquidHandler()
assert lh._tip_reuse_by_liquid_name is True
def test_switch_off_takes_effect(self) -> None:
lh = FakeLiquidHandler(tip_reuse_by_liquid_name=False)
assert lh._tip_reuse_by_liquid_name is False

View File

@@ -39,11 +39,6 @@ class FakeLiquidHandler(LiquidHandlerAbstract):
self.current_tip = iter(make_tip_iter())
self.calls: List[Tuple[str, Any]] = []
def set_tiprack(self, tip_racks):
if not tip_racks:
return
super().set_tiprack(tip_racks)
async def pick_up_tips(self, tip_spots, use_channels=None, offsets=None, **backend_kwargs):
self.calls.append(("pick_up_tips", {"tips": list(tip_spots), "use_channels": use_channels}))

View File

@@ -1,608 +0,0 @@
import asyncio
from dataclasses import dataclass
from typing import Any, Iterable, List, Optional, Sequence, Tuple
import pytest
from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract
@dataclass(frozen=True)
class DummyContainer:
name: str
def __repr__(self) -> str: # pragma: no cover
return f"DummyContainer({self.name})"
@dataclass(frozen=True)
class DummyTipSpot:
name: str
def __repr__(self) -> str: # pragma: no cover
return f"DummyTipSpot({self.name})"
def make_tip_iter(n: int = 256) -> Iterable[List[DummyTipSpot]]:
"""Yield lists so code can safely call `tip.extend(next(self.current_tip))`."""
for i in range(n):
yield [DummyTipSpot(f"tip_{i}")]
class FakeLiquidHandler(LiquidHandlerAbstract):
"""不初始化真实 backend/deck仅用来记录 transfer_liquid 内部调用序列。"""
def __init__(self, channel_num: int = 8):
# 不调用 super().__init__避免真实硬件/后端依赖
self.channel_num = channel_num
self.support_touch_tip = True
self.current_tip = iter(make_tip_iter())
self.calls: List[Tuple[str, Any]] = []
def set_tiprack(self, tip_racks):
# transfer_liquid 总会调用 set_tiprack测试用 Dummy 枪头时 tip_racks 为空,需保留自种子的 current_tip
if not tip_racks:
return
super().set_tiprack(tip_racks)
async def pick_up_tips(self, tip_spots, use_channels=None, offsets=None, **backend_kwargs):
self.calls.append(("pick_up_tips", {"tips": list(tip_spots), "use_channels": use_channels}))
async def aspirate(
self,
resources: Sequence[Any],
vols: List[float],
use_channels: Optional[List[int]] = None,
flow_rates: Optional[List[Optional[float]]] = None,
offsets: Any = None,
liquid_height: Any = None,
blow_out_air_volume: Any = None,
spread: str = "wide",
**backend_kwargs,
):
self.calls.append(
(
"aspirate",
{
"resources": list(resources),
"vols": list(vols),
"use_channels": list(use_channels) if use_channels is not None else None,
"flow_rates": list(flow_rates) if flow_rates is not None else None,
"offsets": list(offsets) if offsets is not None else None,
"liquid_height": list(liquid_height) if liquid_height is not None else None,
"blow_out_air_volume": list(blow_out_air_volume) if blow_out_air_volume is not None else None,
},
)
)
async def dispense(
self,
resources: Sequence[Any],
vols: List[float],
use_channels: Optional[List[int]] = None,
flow_rates: Optional[List[Optional[float]]] = None,
offsets: Any = None,
liquid_height: Any = None,
blow_out_air_volume: Any = None,
spread: str = "wide",
**backend_kwargs,
):
self.calls.append(
(
"dispense",
{
"resources": list(resources),
"vols": list(vols),
"use_channels": list(use_channels) if use_channels is not None else None,
"flow_rates": list(flow_rates) if flow_rates is not None else None,
"offsets": list(offsets) if offsets is not None else None,
"liquid_height": list(liquid_height) if liquid_height is not None else None,
"blow_out_air_volume": list(blow_out_air_volume) if blow_out_air_volume is not None else None,
},
)
)
async def discard_tips(self, use_channels=None, *args, **kwargs):
# 有的分支是 discard_tips(use_channels=[0]),有的分支是 discard_tips([0..7])(位置参数)
self.calls.append(("discard_tips", {"use_channels": list(use_channels) if use_channels is not None else None}))
async def custom_delay(self, seconds=0, msg=None):
self.calls.append(("custom_delay", {"seconds": seconds, "msg": msg}))
async def touch_tip(self, targets):
# 原实现会访问 targets.get_size_x() 等;测试里只记录调用
self.calls.append(("touch_tip", {"targets": targets}))
def run(coro):
return asyncio.run(coro)
def test_one_to_one_single_channel_basic_calls():
lh = FakeLiquidHandler(channel_num=1)
lh.current_tip = iter(make_tip_iter(64))
sources = [DummyContainer(f"S{i}") for i in range(3)]
targets = [DummyContainer(f"T{i}") for i in range(3)]
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=[0],
asp_vols=[1, 2, 3],
dis_vols=[4, 5, 6],
mix_times=None, # 应该仍能执行(不 mix
)
)
assert [c[0] for c in lh.calls].count("pick_up_tips") == 3
assert [c[0] for c in lh.calls].count("aspirate") == 3
assert [c[0] for c in lh.calls].count("dispense") == 3
assert [c[0] for c in lh.calls].count("discard_tips") == 3
# 每次 aspirate/dispense 都是单孔列表
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
assert aspirates[0]["resources"] == [sources[0]]
assert aspirates[0]["vols"] == [1.0]
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert dispenses[2]["resources"] == [targets[2]]
assert dispenses[2]["vols"] == [6.0]
def test_one_to_one_single_channel_before_stage_mixes_prior_to_aspirate():
lh = FakeLiquidHandler(channel_num=1)
lh.current_tip = iter(make_tip_iter(16))
source = DummyContainer("S0")
target = DummyContainer("T0")
run(
lh.transfer_liquid(
sources=[source],
targets=[target],
tip_racks=[],
use_channels=[0],
asp_vols=[5],
dis_vols=[5],
mix_stage="before",
mix_times=1,
mix_vol=3,
)
)
aspirate_calls = [(idx, payload) for idx, (name, payload) in enumerate(lh.calls) if name == "aspirate"]
assert len(aspirate_calls) >= 2
mix_idx, mix_payload = aspirate_calls[0]
assert mix_payload["resources"] == [target]
assert mix_payload["vols"] == [3]
transfer_idx, transfer_payload = aspirate_calls[1]
assert transfer_payload["resources"] == [source]
assert mix_idx < transfer_idx
def test_one_to_one_eight_channel_groups_by_8():
lh = FakeLiquidHandler(channel_num=8)
lh.current_tip = iter(make_tip_iter(256))
sources = [DummyContainer(f"S{i}") for i in range(16)]
targets = [DummyContainer(f"T{i}") for i in range(16)]
asp_vols = list(range(1, 17))
dis_vols = list(range(101, 117))
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=list(range(8)),
asp_vols=asp_vols,
dis_vols=dis_vols,
mix_times=0, # 触发逻辑但不 mix
)
)
# 16 个任务 -> 2 组,每组 8 通道一起做
assert [c[0] for c in lh.calls].count("pick_up_tips") == 2
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert len(aspirates) == 2
assert len(dispenses) == 2
assert aspirates[0]["resources"] == sources[0:8]
assert aspirates[0]["vols"] == [float(v) for v in asp_vols[0:8]]
assert dispenses[1]["resources"] == targets[8:16]
assert dispenses[1]["vols"] == [float(v) for v in dis_vols[8:16]]
def test_one_to_one_eight_channel_requires_multiple_of_8_targets():
lh = FakeLiquidHandler(channel_num=8)
lh.current_tip = iter(make_tip_iter(64))
sources = [DummyContainer(f"S{i}") for i in range(9)]
targets = [DummyContainer(f"T{i}") for i in range(9)]
with pytest.raises(ValueError, match="multiple of 8"):
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=list(range(8)),
asp_vols=[1] * 9,
dis_vols=[1] * 9,
mix_times=0,
)
)
def test_one_to_one_eight_channel_parameter_lists_are_chunked_per_8():
lh = FakeLiquidHandler(channel_num=8)
lh.current_tip = iter(make_tip_iter(512))
sources = [DummyContainer(f"S{i}") for i in range(16)]
targets = [DummyContainer(f"T{i}") for i in range(16)]
asp_vols = [i + 1 for i in range(16)]
dis_vols = [200 + i for i in range(16)]
asp_flow_rates = [0.1 * (i + 1) for i in range(16)]
dis_flow_rates = [0.2 * (i + 1) for i in range(16)]
offsets = [f"offset_{i}" for i in range(16)]
liquid_heights = [i * 0.5 for i in range(16)]
blow_out_air_volume = [i + 0.05 for i in range(16)]
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=list(range(8)),
asp_vols=asp_vols,
dis_vols=dis_vols,
asp_flow_rates=asp_flow_rates,
dis_flow_rates=dis_flow_rates,
offsets=offsets,
liquid_height=liquid_heights,
blow_out_air_volume=blow_out_air_volume,
mix_times=0,
)
)
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert len(aspirates) == len(dispenses) == 2
for batch_idx in range(2):
start = batch_idx * 8
end = start + 8
asp_call = aspirates[batch_idx]
dis_call = dispenses[batch_idx]
assert asp_call["resources"] == sources[start:end]
assert asp_call["flow_rates"] == asp_flow_rates[start:end]
assert asp_call["offsets"] == offsets[start:end]
assert asp_call["liquid_height"] == liquid_heights[start:end]
assert asp_call["blow_out_air_volume"] == blow_out_air_volume[start:end]
assert dis_call["flow_rates"] == dis_flow_rates[start:end]
assert dis_call["offsets"] == offsets[start:end]
assert dis_call["liquid_height"] == liquid_heights[start:end]
assert dis_call["blow_out_air_volume"] == blow_out_air_volume[start:end]
def test_one_to_one_eight_channel_handles_32_tasks_four_batches():
lh = FakeLiquidHandler(channel_num=8)
lh.current_tip = iter(make_tip_iter(1024))
sources = [DummyContainer(f"S{i}") for i in range(32)]
targets = [DummyContainer(f"T{i}") for i in range(32)]
asp_vols = [i + 1 for i in range(32)]
dis_vols = [300 + i for i in range(32)]
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=list(range(8)),
asp_vols=asp_vols,
dis_vols=dis_vols,
mix_times=0,
)
)
pick_calls = [name for name, _ in lh.calls if name == "pick_up_tips"]
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert len(pick_calls) == 4
assert len(aspirates) == len(dispenses) == 4
assert aspirates[0]["resources"] == sources[0:8]
assert aspirates[-1]["resources"] == sources[24:32]
assert dispenses[0]["resources"] == targets[0:8]
assert dispenses[-1]["resources"] == targets[24:32]
def test_one_to_many_single_channel_aspirates_total_when_asp_vol_too_small():
lh = FakeLiquidHandler(channel_num=1)
lh.current_tip = iter(make_tip_iter(64))
source = DummyContainer("SRC")
targets = [DummyContainer(f"T{i}") for i in range(3)]
dis_vols = [10, 20, 30] # sum=60
run(
lh.transfer_liquid(
sources=[source],
targets=targets,
tip_racks=[],
use_channels=[0],
asp_vols=10, # 小于 sum(dis_vols) -> 应吸 60
dis_vols=dis_vols,
mix_times=0,
)
)
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
assert len(aspirates) == 1
assert aspirates[0]["resources"] == [source]
assert aspirates[0]["vols"] == [60.0]
assert aspirates[0]["use_channels"] == [0]
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert [d["vols"][0] for d in dispenses] == [10.0, 20.0, 30.0]
def test_one_to_many_eight_channel_basic():
lh = FakeLiquidHandler(channel_num=8)
lh.current_tip = iter(make_tip_iter(128))
source = DummyContainer("SRC")
targets = [DummyContainer(f"T{i}") for i in range(8)]
dis_vols = [i + 1 for i in range(8)]
run(
lh.transfer_liquid(
sources=[source],
targets=targets,
tip_racks=[],
use_channels=list(range(8)),
asp_vols=999, # one-to-many 8ch 会按 dis_vols 吸(每通道各自)
dis_vols=dis_vols,
mix_times=0,
)
)
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
assert aspirates[0]["resources"] == [source] * 8
assert aspirates[0]["vols"] == [float(v) for v in dis_vols]
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert dispenses[0]["resources"] == targets
assert dispenses[0]["vols"] == [float(v) for v in dis_vols]
def test_many_to_one_single_channel_standard_dispense_equals_asp_by_default():
lh = FakeLiquidHandler(channel_num=1)
lh.current_tip = iter(make_tip_iter(128))
sources = [DummyContainer(f"S{i}") for i in range(3)]
target = DummyContainer("T")
asp_vols = [5, 6, 7]
run(
lh.transfer_liquid(
sources=sources,
targets=[target],
tip_racks=[],
use_channels=[0],
asp_vols=asp_vols,
dis_vols=1, # many-to-one 允许标量;非比例模式下实际每次分液=对应 asp_vol
mix_times=0,
)
)
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert [d["vols"][0] for d in dispenses] == [float(v) for v in asp_vols]
assert all(d["resources"] == [target] for d in dispenses)
def test_many_to_one_single_channel_before_stage_mixes_target_once():
lh = FakeLiquidHandler(channel_num=1)
lh.current_tip = iter(make_tip_iter(128))
sources = [DummyContainer("S0"), DummyContainer("S1")]
target = DummyContainer("T")
run(
lh.transfer_liquid(
sources=sources,
targets=[target],
tip_racks=[],
use_channels=[0],
asp_vols=[5, 6],
dis_vols=1,
mix_stage="before",
mix_times=2,
mix_vol=4,
)
)
aspirate_calls = [(idx, payload) for idx, (name, payload) in enumerate(lh.calls) if name == "aspirate"]
assert len(aspirate_calls) >= 1
mix_idx, mix_payload = aspirate_calls[0]
assert mix_payload["resources"] == [target]
assert mix_payload["vols"] == [4]
# 第一個 mix 之後會真正開始吸 source
assert any(call["resources"] == [sources[0]] for _, call in aspirate_calls[1:])
def test_many_to_one_single_channel_proportional_mixing_uses_dis_vols_per_source():
lh = FakeLiquidHandler(channel_num=1)
lh.current_tip = iter(make_tip_iter(128))
sources = [DummyContainer(f"S{i}") for i in range(3)]
target = DummyContainer("T")
asp_vols = [5, 6, 7]
dis_vols = [1, 2, 3]
run(
lh.transfer_liquid(
sources=sources,
targets=[target],
tip_racks=[],
use_channels=[0],
asp_vols=asp_vols,
dis_vols=dis_vols, # 比例模式
mix_times=0,
)
)
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert [d["vols"][0] for d in dispenses] == [float(v) for v in dis_vols]
def test_many_to_one_eight_channel_basic():
lh = FakeLiquidHandler(channel_num=8)
lh.current_tip = iter(make_tip_iter(256))
sources = [DummyContainer(f"S{i}") for i in range(8)]
target = DummyContainer("T")
asp_vols = [10 + i for i in range(8)]
run(
lh.transfer_liquid(
sources=sources,
targets=[target],
tip_racks=[],
use_channels=list(range(8)),
asp_vols=asp_vols,
dis_vols=999, # 非比例模式下每通道分液=对应 asp_vol
mix_times=0,
)
)
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert aspirates[0]["resources"] == sources
assert aspirates[0]["vols"] == [float(v) for v in asp_vols]
assert dispenses[0]["resources"] == [target] * 8
assert dispenses[0]["vols"] == [float(v) for v in asp_vols]
def test_transfer_liquid_mode_detection_unsupported_shape_raises():
lh = FakeLiquidHandler(channel_num=8)
lh.current_tip = iter(make_tip_iter(64))
sources = [DummyContainer("S0"), DummyContainer("S1")]
targets = [DummyContainer("T0"), DummyContainer("T1"), DummyContainer("T2")]
with pytest.raises(ValueError, match="Unsupported transfer mode"):
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=[0],
asp_vols=[1, 1],
dis_vols=[1, 1, 1],
mix_times=0,
)
)
def test_mix_single_target_produces_matching_cycles():
lh = FakeLiquidHandler(channel_num=1)
target = DummyContainer("T_mix")
run(lh.mix(targets=[target], mix_time=2, mix_vol=5))
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert len(aspirates) == len(dispenses) == 2
assert all(call["resources"] == [target] for call in aspirates)
assert all(call["vols"] == [5] for call in aspirates)
assert all(call["resources"] == [target] for call in dispenses)
assert all(call["vols"] == [5] for call in dispenses)
def test_mix_multiple_targets_supports_per_target_offsets():
lh = FakeLiquidHandler(channel_num=1)
targets = [DummyContainer("T0"), DummyContainer("T1")]
offsets = ["left", "right"]
heights = [0.1, 0.2]
rates = [0.5, 1.0]
run(
lh.mix(
targets=targets,
mix_time=1,
mix_vol=3,
offsets=offsets,
height_to_bottom=heights,
mix_rate=rates,
)
)
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
assert len(aspirates) == 2
assert aspirates[0]["resources"] == [targets[0]]
assert aspirates[0]["offsets"] == [offsets[0]]
assert aspirates[0]["liquid_height"] == [heights[0]]
assert aspirates[0]["flow_rates"] == [rates[0]]
assert aspirates[1]["resources"] == [targets[1]]
assert aspirates[1]["offsets"] == [offsets[1]]
assert aspirates[1]["liquid_height"] == [heights[1]]
assert aspirates[1]["flow_rates"] == [rates[1]]
def test_set_tiprack_per_type_resumes_first_physical_rack():
"""同型号多次 set_tiprack 时接续第一盒剩余孔位,而非从新盒 A1 开始。"""
from pylabrobot.liquid_handling import LiquidHandlerChatterboxBackend
from pylabrobot.resources import Deck, Tip, TipRack, TipSpot, create_equally_spaced
mk = lambda: Tip(
has_filter=False, total_tip_length=10.0, maximal_volume=300.0, fitting_depth=2.0
)
class TipTypeAlpha(TipRack):
pass
class TipTypeBeta(TipRack):
pass
def make_rack(cls: type, name: str) -> TipRack:
items = create_equally_spaced(
TipSpot,
num_items_x=12,
num_items_y=2,
dx=0,
dy=0,
dz=0,
item_dx=9,
item_dy=9,
size_x=1,
size_y=1,
make_tip=mk,
)
return cls(name, 120, 40, 10, items=items)
rack1 = make_rack(TipTypeAlpha, "rack_phys_1")
rack2 = make_rack(TipTypeBeta, "rack_phys_2")
rack3 = make_rack(TipTypeAlpha, "rack_phys_3")
lh = LiquidHandlerAbstract(
LiquidHandlerChatterboxBackend(1), Deck(), channel_num=1, simulator=False
)
flat1 = lh._flatten_tips_from_one(rack1)
assert len(flat1) == 24
lh.set_tiprack([rack1])
for i in range(12):
assert lh._get_next_tip() is flat1[i]
lh.set_tiprack([rack2])
spot_b = lh._get_next_tip()
assert "rack_phys_2" in spot_b.name
lh.set_tiprack([rack3])
spot_resume = lh._get_next_tip()
assert spot_resume is flat1[12], "第三次同型号应接续 rack1 第二行首孔,而非 rack3 首孔"
assert spot_resume is not lh._flatten_tips_from_one(rack3)[0]

View File

@@ -1,137 +0,0 @@
"""P9 — ``_augment_states_with_liquid_history`` 单元测试OS→Cloud sync 链路 Phase C
详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md`` §6.3 / §8 T4。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List
import pytest
from unilabos.resources.resource_tracker import _augment_states_with_liquid_history
# ---------------------------------------------------------------------------
# Fixtures纯 dataclass 模拟 PLR 资源树(避免引入 PLR 真实实例化)
# ---------------------------------------------------------------------------
@dataclass
class FakeTracker:
liquid_history: Any = field(default_factory=list)
@dataclass
class FakeResource:
name: str
tracker: Any = None
children: List["FakeResource"] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestAugmentStatesWithLiquidHistory:
def test_single_well_history_attached(self) -> None:
well = FakeResource("well_A1", tracker=FakeTracker(liquid_history=[
{"name": "Plasma", "volume": 100, "action": "set"}
]))
states: Dict[str, Any] = {"well_A1": {"liquids": [], "pending_liquids": []}}
_augment_states_with_liquid_history(well, states)
assert "liquid_history" in states["well_A1"]
assert states["well_A1"]["liquid_history"] == [
{"name": "Plasma", "volume": 100, "action": "set"}
]
def test_recursive_walk_attaches_to_all_wells(self) -> None:
"""resource 树有多层时,每个有 tracker 的节点都会被并入 states。"""
wells = [
FakeResource(f"well_{i}", tracker=FakeTracker(liquid_history=[
{"name": f"L_{i}", "volume": i * 10, "action": "set"}
]))
for i in range(3)
]
plate = FakeResource("plate", children=wells)
deck = FakeResource("deck", children=[plate])
states: Dict[str, Any] = {
"deck": {"liquids": []},
"plate": {"liquids": []},
"well_0": {"liquids": []},
"well_1": {"liquids": []},
"well_2": {"liquids": []},
}
_augment_states_with_liquid_history(deck, states)
assert states["well_0"]["liquid_history"] == [{"name": "L_0", "volume": 0, "action": "set"}]
assert states["well_1"]["liquid_history"] == [{"name": "L_1", "volume": 10, "action": "set"}]
assert states["well_2"]["liquid_history"] == [{"name": "L_2", "volume": 20, "action": "set"}]
def test_no_tracker_node_skipped(self) -> None:
"""没有 tracker 的节点(如 deck 自身跳过state dict 不被污染。"""
deck = FakeResource("deck") # tracker=None
states: Dict[str, Any] = {"deck": {"some_field": 1}}
_augment_states_with_liquid_history(deck, states)
assert "liquid_history" not in states["deck"]
def test_existing_liquid_history_in_state_not_overwritten(self) -> None:
"""state 已经有 liquid_history 字段(例如 PLR 升级未来支持了)→ 不覆盖。"""
well = FakeResource("well_A1", tracker=FakeTracker(liquid_history=[
{"name": "Plasma", "volume": 100, "action": "set"}
]))
states: Dict[str, Any] = {"well_A1": {"liquid_history": ["preexisting"]}}
_augment_states_with_liquid_history(well, states)
assert states["well_A1"]["liquid_history"] == ["preexisting"]
def test_history_is_shallow_copied(self) -> None:
"""augment 后的 history 应是独立 list避免运行时 mutate 污染 dump 结果)。"""
original_history = [{"name": "X", "volume": 1, "action": "set"}]
well = FakeResource("well_A1", tracker=FakeTracker(liquid_history=original_history))
states: Dict[str, Any] = {"well_A1": {}}
_augment_states_with_liquid_history(well, states)
# mutate runtime history 不应反映到 augmented state
original_history.append({"name": "Y", "volume": 2, "action": "set"})
assert len(states["well_A1"]["liquid_history"]) == 1
def test_node_not_in_states_silently_skipped(self) -> None:
"""resource 树中的节点 name 不在 ``states`` 字典里 → 静默跳过。"""
well = FakeResource("well_orphan", tracker=FakeTracker(liquid_history=[
{"name": "X", "volume": 1, "action": "set"}
]))
states: Dict[str, Any] = {"well_A1": {}}
_augment_states_with_liquid_history(well, states)
# 不应该新增 well_orphan 键,也不应污染 well_A1
assert "well_orphan" not in states
assert "liquid_history" not in states["well_A1"]
def test_non_list_liquid_history_skipped(self) -> None:
"""tracker.liquid_history 非 list 时(异常情况)→ 跳过,不写入 state。"""
well = FakeResource("well_A1", tracker=FakeTracker(liquid_history="broken"))
states: Dict[str, Any] = {"well_A1": {}}
_augment_states_with_liquid_history(well, states)
assert "liquid_history" not in states["well_A1"]
def test_empty_history_still_written(self) -> None:
"""tracker.liquid_history = [] 是合法状态 → 应写入空 list表示"未有任何液体操作")。"""
well = FakeResource("well_A1", tracker=FakeTracker(liquid_history=[]))
states: Dict[str, Any] = {"well_A1": {}}
_augment_states_with_liquid_history(well, states)
assert states["well_A1"]["liquid_history"] == []

View File

@@ -1,351 +0,0 @@
"""P6.1 / P6.1.1 `build_protocol_graph` 集成测试 —— 对应 06-labware-mapping-table.md §11.7.7 C / §11.8.7 C。
6 条用例:
- `test_build_graph_default_target_device_prcxi` —— 不传 target_device 时默认 "prcxi"
与 P6 等价PRCXI_* class_name
- `test_build_graph_explicit_target_device_prcxi` —— 显式 "prcxi" 与默认完全等价。
- `test_build_graph_target_device_unknown_falls_back_to_default_section` —— 未声明的
target_device 由 loader 自动 fallback 到 ``target_devices.default``;第一版 default
段按 prcxi 拷贝,所以结果应与 "prcxi" 完全一致。
- `test_build_graph_per_device_tip_class` —— 临时 YAML 同时声明 prcxi 与 beckman tip
量程档;同一 transfer_liquid 在 target_device="prcxi" / "beckman" 下命中不同 class。
- `test_field_renamed_target_class_name` —— `labware_info` 写入的字段是
`target_class_name`**旧字段 `prcxi_class_name` 不存在**。
- `test_build_graph_model_level_slot_remap` —— P6.1.1``target_model`` 透传到
``_map_deck_slot`` 后改变 create_resource 的 slot同厂商不同型号 deck 物理布局不同)。
本测试在导入 common.py 之前 mock 掉 matplotlib / networkx.drawing.nx_agraph避免在
没有图形依赖的最小 Python 环境下也能跑(与 P6 批量回归脚本同样的策略)。
"""
from __future__ import annotations
import sys
import types
import warnings
from pathlib import Path
import pytest
ROOT_DIR = Path(__file__).resolve().parents[2]
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
def _install_fake_optional_deps() -> None:
"""安装 matplotlib / networkx.drawing.nx_agraph 的 fake 实现,避免本地环境硬依赖。
common.py 在模块级 import 这些库做可视化辅助build_protocol_graph 主路径不会真用到。
fake 模块只需要满足 ``from X import Y`` 的查找即可。
"""
if "matplotlib" not in sys.modules:
fake_matplotlib = types.ModuleType("matplotlib")
sys.modules["matplotlib"] = fake_matplotlib
if "matplotlib.pyplot" not in sys.modules:
fake_plt = types.ModuleType("matplotlib.pyplot")
sys.modules["matplotlib.pyplot"] = fake_plt
# networkx.drawing.nx_agraph.to_agraph 依赖 pygraphviz不可用时给个空 stub
try:
from networkx.drawing import nx_agraph # noqa: F401
except Exception:
nx_drawing = types.ModuleType("networkx.drawing")
nx_agraph_mod = types.ModuleType("networkx.drawing.nx_agraph")
def _to_agraph(_g): # type: ignore[no-untyped-def]
raise RuntimeError("nx_agraph fake — not used in build_protocol_graph main path")
nx_agraph_mod.to_agraph = _to_agraph # type: ignore[attr-defined]
nx_drawing.nx_agraph = nx_agraph_mod # type: ignore[attr-defined]
sys.modules["networkx.drawing"] = nx_drawing
sys.modules["networkx.drawing.nx_agraph"] = nx_agraph_mod
_install_fake_optional_deps()
from unilabos.workflow import labware_mapping as lm # noqa: E402
from unilabos.workflow.common import build_protocol_graph # noqa: E402
@pytest.fixture(autouse=True)
def _reset_mapping_cache():
"""每个用例后清 lru_cache避免跨用例污染。"""
yield
lm.reload_mapping()
# ==================== 公共 fixture最小 transfer_liquid 协议 ====================
def _minimal_labware_info() -> dict:
"""返回最小可用的 labware_infomutable每个 case 独立 build 一份)。
包含 tip rack + 24-tube rack + 96 wellplateslot 1/2/3覆盖 P6.1 主要 kind。
tube rack / plate 显式声明 ``num_wells``,避免在无 labware_defs / 无 prcxi_labware 模板
时通过 well-count 启发式well_n=3误判孔数与真实协议中 labware_defs 提供 num_wells
的行为对齐。
"""
return {
"tips": {
"slot": 1,
"well": [],
"labware": "opentrons_96_tiprack_300ul",
"object": "tiprack",
},
"samples": {
"slot": 2,
"well": ["A1", "A2", "A3"],
"labware": "opentrons_24_tuberack_eppendorf_2ml_safelock_snapcap",
"object": "source",
"num_wells": 24,
},
"plate_target": {
"slot": 3,
"well": ["A1", "A2", "A3"],
"labware": "opentrons_96_wellplate_300ul_pcr",
"object": "target",
"num_wells": 96,
},
}
def _minimal_protocol_steps() -> list:
"""最小 transfer_liquid 协议步骤asp_vols/dis_vols 最大 200 µL → PRCXI 300ul 档。"""
return [
{
"action": "transfer_liquid",
"parameters": {
"sources": "samples",
"targets": "plate_target",
"tip_racks": "tips",
"asp_vols": [200.0, 200.0, 200.0],
"dis_vols": [200.0, 200.0, 200.0],
},
"step_number": 1,
}
]
def _collect_create_resource_classes(graph) -> dict:
"""从工作流图中提取每个 create_resource 节点的 ``slot_on_deck → class_name``。"""
out: dict = {}
for _nid, node in graph.nodes.items():
if node.get("template_name") != "create_resource":
continue
param = node.get("param") or {}
slot = str(param.get("slot_on_deck") or "")
cls = str(param.get("class_name") or "")
if slot:
out[slot] = cls
return out
# ==================== 5 条核心用例 ====================
def test_build_graph_default_target_device_prcxi():
"""不传 target_device → 默认 "prcxi" → 与 P6 等价PRCXI_* class_name"""
labware_info = _minimal_labware_info()
g = build_protocol_graph(
labware_info=labware_info,
protocol_steps=_minimal_protocol_steps(),
workstation_name="PRCXI",
)
classes = _collect_create_resource_classes(g)
assert classes["1"] == "PRCXI_300ul_Tips" # 200 µL → 300 档
assert classes["2"] == "PRCXI_EP_Adapter" # 24-tube rack
assert classes["3"] == "PRCXI_BioER_96_wellplate" # 96 wellplate
def test_build_graph_explicit_target_device_prcxi():
"""显式传 target_device="prcxi" 应与默认完全等价。"""
labware_info_a = _minimal_labware_info()
labware_info_b = _minimal_labware_info()
g_default = build_protocol_graph(
labware_info=labware_info_a,
protocol_steps=_minimal_protocol_steps(),
workstation_name="PRCXI",
)
g_prcxi = build_protocol_graph(
labware_info=labware_info_b,
protocol_steps=_minimal_protocol_steps(),
workstation_name="PRCXI",
target_device="prcxi",
)
assert _collect_create_resource_classes(g_default) == _collect_create_resource_classes(g_prcxi)
def test_build_graph_target_device_unknown_falls_back_to_default_section():
"""未声明的 target_device → loader 自动 fallback 到固定段 target_devices.default + warning。
第一版 default 段按 prcxi 拷贝填充 → 结果应与 target_device="prcxi" 完全等价PRCXI_*)。
"""
labware_info_a = _minimal_labware_info()
labware_info_b = _minimal_labware_info()
g_prcxi = build_protocol_graph(
labware_info=labware_info_a,
protocol_steps=_minimal_protocol_steps(),
workstation_name="PRCXI",
target_device="prcxi",
)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
g_unknown = build_protocol_graph(
labware_info=labware_info_b,
protocol_steps=_minimal_protocol_steps(),
workstation_name="PRCXI",
target_device="unknown_xxx",
)
assert _collect_create_resource_classes(g_unknown) == _collect_create_resource_classes(g_prcxi)
# loader 至少打 1 次 warning 提示「未声明、已回退到 default」
assert any(
("未在 labware_mapping.yaml" in str(w.message))
or ("target_devices.default" in str(w.message))
for w in caught
)
def test_build_graph_per_device_tip_class(tmp_path, monkeypatch):
"""同一 protocoltarget_device="prcxi" / "beckman" 在 200µL 下命中不同 tip 档P6.1.1 schema"""
yaml_path = tmp_path / "labware_mapping.yaml"
yaml_path.write_text(
'kinds:\n'
' - {pattern: "trash", kind: trash}\n'
' - {pattern: "tiprack|tip[_ ]?rack|opentrons_\\\\d+_tiprack", kind: tip_rack}\n'
' - {pattern: "tuberack|tube[_ ]rack|eppendorf.*rack|safelock.*rack", kind: tube_rack}\n'
' - {pattern: ".*", kind: plate}\n'
'target_devices:\n'
' default:\n'
' slot_remap: {default: {"4": "13", "8": "14"}, by_object: {trash: {"12": "16"}}}\n'
' rules:\n'
' - {kind: tip_rack, hole_count: 96, volume_max: 10, class_name: PRCXI_10uL_Tips}\n'
' - {kind: tip_rack, hole_count: 96, volume_max: 299.9, class_name: PRCXI_300ul_Tips}\n'
' - {kind: tip_rack, hole_count: 96, class_name: PRCXI_1000uL_Tips}\n'
' - {kind: tube_rack, hole_count: 24, class_name: PRCXI_EP_Adapter}\n'
' - {kind: plate, hole_count: 96, class_name: PRCXI_BioER_96_wellplate}\n'
' prcxi:\n'
' slot_remap: {default: {"4": "13", "8": "14"}, by_object: {trash: {"12": "16"}}}\n'
' rules:\n'
' - {kind: tip_rack, hole_count: 96, volume_max: 10, class_name: PRCXI_10uL_Tips}\n'
' - {kind: tip_rack, hole_count: 96, volume_max: 299.9, class_name: PRCXI_300ul_Tips}\n'
' - {kind: tip_rack, hole_count: 96, class_name: PRCXI_1000uL_Tips}\n'
' - {kind: tube_rack, hole_count: 24, class_name: PRCXI_EP_Adapter}\n'
' - {kind: plate, hole_count: 96, class_name: PRCXI_BioER_96_wellplate}\n'
' beckman:\n'
' slot_remap: {default: {"4": "13"}, by_object: {trash: {"12": "16"}}}\n'
' rules:\n'
' - {kind: tip_rack, hole_count: 96, volume_max: 20, class_name: Beckman_20uL_Tips}\n'
' - {kind: tip_rack, hole_count: 96, volume_max: 199.9, class_name: Beckman_200uL_Tips}\n'
' - {kind: tip_rack, hole_count: 96, class_name: Beckman_1000uL_Tips}\n'
' - {kind: tube_rack, hole_count: 24, class_name: Beckman_24_TubeRack}\n'
' - {kind: plate, hole_count: 96, class_name: Beckman_BioMek_96_wellplate}\n',
encoding="utf-8",
)
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
lm.reload_mapping()
g_prcxi = build_protocol_graph(
labware_info=_minimal_labware_info(),
protocol_steps=_minimal_protocol_steps(),
workstation_name="PRCXI",
target_device="prcxi",
)
g_beckman = build_protocol_graph(
labware_info=_minimal_labware_info(),
protocol_steps=_minimal_protocol_steps(),
workstation_name="PRCXI",
target_device="beckman",
)
classes_prcxi = _collect_create_resource_classes(g_prcxi)
classes_beckman = _collect_create_resource_classes(g_beckman)
# 200 µLprcxi 走 300 档beckman 200 档已超 → 1000 档
assert classes_prcxi["1"] == "PRCXI_300ul_Tips"
assert classes_beckman["1"] == "Beckman_1000uL_Tips"
# plate / tube rack 也按 target_device 输出对应厂商类
assert classes_prcxi["2"] == "PRCXI_EP_Adapter"
assert classes_beckman["2"] == "Beckman_24_TubeRack"
assert classes_prcxi["3"] == "PRCXI_BioER_96_wellplate"
assert classes_beckman["3"] == "Beckman_BioMek_96_wellplate"
def test_field_renamed_target_class_name():
"""`labware_info` 写入的字段是 `target_class_name`;旧字段 `prcxi_class_name` 不存在。"""
labware_info = _minimal_labware_info()
build_protocol_graph(
labware_info=labware_info,
protocol_steps=_minimal_protocol_steps(),
workstation_name="PRCXI",
)
for lid, item in labware_info.items():
assert "target_class_name" in item, f"{lid!r} 缺少 target_class_name 字段"
assert "prcxi_class_name" not in item, f"{lid!r} 残留了旧字段 prcxi_class_name"
assert item["target_class_name"], f"{lid!r} target_class_name 为空"
# ==================== P6.1.1 新增集成测试 ====================
def _labware_info_slot4_plate() -> dict:
"""slot=4 的 96 板:用来验证 target_model 透传后 slot_remap 改变 create_resource 的槽位。"""
return {
"plate_slot4": {
"slot": 4,
"well": ["A1"],
"labware": "opentrons_96_wellplate_300ul_pcr",
"object": "target",
"num_wells": 96,
},
}
def test_build_graph_model_level_slot_remap(tmp_path, monkeypatch):
"""P6.1.1target_model 透传到 _map_deck_slot 后改变 create_resource 的 slot_on_deck。
YAML 中 prcxi 厂商级 slot_remap 4→13模型 "4040" 显式覆盖 4→16。
同一份 labware_infoslot=4build 出的两份图slot_on_deck 应分别为 "13""16"
"""
yaml_path = tmp_path / "labware_mapping.yaml"
yaml_path.write_text(
'kinds: [{pattern: ".*", kind: plate}]\n'
'target_devices:\n'
' default:\n'
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
' rules: [{kind: plate, hole_count: 96, class_name: PRCXI_BioER_96_wellplate}]\n'
' prcxi:\n'
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
' rules: [{kind: plate, hole_count: 96, class_name: PRCXI_BioER_96_wellplate}]\n'
' models:\n'
' "4040":\n'
' slot_remap: {default: {"4": "16"}, by_object: {}}\n',
encoding="utf-8",
)
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
lm.reload_mapping()
g_default = build_protocol_graph(
labware_info=_labware_info_slot4_plate(),
protocol_steps=[],
workstation_name="PRCXI",
target_device="prcxi",
)
g_model_4040 = build_protocol_graph(
labware_info=_labware_info_slot4_plate(),
protocol_steps=[],
workstation_name="PRCXI",
target_device="prcxi",
target_model="4040",
)
classes_default = _collect_create_resource_classes(g_default)
classes_4040 = _collect_create_resource_classes(g_model_4040)
# 厂商级(无 model→ slot 4 → "13"
assert "13" in classes_default, f"未找到 slot 13实际生成的 slots: {list(classes_default)}"
assert "16" not in classes_default
# 模型 4040 → slot 4 → "16"
assert "16" in classes_4040, f"未找到 slot 16实际生成的 slots: {list(classes_4040)}"
assert "13" not in classes_4040
# class_name 不变rules 继承厂商级)
assert classes_default["13"] == "PRCXI_BioER_96_wellplate"
assert classes_4040["16"] == "PRCXI_BioER_96_wellplate"

View File

@@ -1,369 +0,0 @@
"""P2 v2 跨 slot transfer_liquid 合并 —— Stage 3 (`workflow/common.py`) 集成测试。
对应 ``product_designs/protocol_convert/02-cross-slot-merge.md`` §9.5 step 6.2。
v2 设计要点(与本测试用例的映射)
-----------------------------------
当 transfer_liquid 节点 ``params.targets`` 是 ``list[str]`` 时,``build_protocol_graph``
在该 transfer_liquid 之前**插入一个 merged ``set_liquid_from_plate`` 节点**
- merged 节点的 ``param.wells`` 是按 ``params.targets`` 顺序通过 cursor 拼出来的有序跨板
well refs每个元素是 ``{id, name, parent: reagent_key, type: "well"}``)。
- merged 节点接收来自每个涉及 plate 的 ``create_resource`` 节点的多入边
(``labware`` → ``wells_identifier``)。
- merged 节点的 ``output_wells`` 通过**单条边**连到 transfer_liquid 的 ``targets_identifier``。
- transfer_liquid 节点的 ``params.targets`` 被改写为 synthetic key
``_merged_targets_<idx>``runtime 不消费 list 形态),保证 INPUT_PORT_MAPPING 走单边路径。
用例
----
- ``test_emit_merged_set_liquid_basic`` — 4 个 distinct reagent_key51b9a5 主场景)。
- ``test_emit_merged_set_liquid_repeat_key`` — 同 reagent_key 重复(同板多孔)。
- ``test_emit_merged_set_liquid_mixed`` — 跨板混合 + 同板重复cursor 推进)。
- ``test_emit_merged_set_liquid_8ch`` — 与 P1 multi-channel 复合8 通道 cross-slot
- ``test_transfer_liquid_targets_rewrite`` — transfer_liquid 节点改写后只剩 1 条
``targets_identifier`` 入边params.targets 不再是 list。
"""
from __future__ import annotations
import sys
import types
from pathlib import Path
from typing import Any, Dict, List
ROOT_DIR = Path(__file__).resolve().parents[2]
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
def _install_fake_optional_deps() -> None:
"""与 test_build_protocol_graph_target_device.py 一致的可选依赖 stub。"""
if "matplotlib" not in sys.modules:
sys.modules["matplotlib"] = types.ModuleType("matplotlib")
if "matplotlib.pyplot" not in sys.modules:
sys.modules["matplotlib.pyplot"] = types.ModuleType("matplotlib.pyplot")
try:
from networkx.drawing import nx_agraph # noqa: F401
except Exception:
nx_drawing = types.ModuleType("networkx.drawing")
nx_agraph_mod = types.ModuleType("networkx.drawing.nx_agraph")
nx_agraph_mod.to_agraph = lambda _g: None # type: ignore[attr-defined]
nx_drawing.nx_agraph = nx_agraph_mod # type: ignore[attr-defined]
sys.modules["networkx.drawing"] = nx_drawing
sys.modules["networkx.drawing.nx_agraph"] = nx_agraph_mod
_install_fake_optional_deps()
import pytest # noqa: E402
from unilabos.workflow.common import build_protocol_graph # noqa: E402
# ==================== 测试辅助:从工作流图中提取节点/边 ====================
def _nodes_by_template(graph, template_name: str) -> List[Dict[str, Any]]:
return [
{"id": nid, **node}
for nid, node in graph.nodes.items()
if node.get("template_name") == template_name
]
def _create_resource_by_slot(graph) -> Dict[str, str]:
"""slot_on_deck (str) -> create_resource 节点 ID。"""
out: Dict[str, str] = {}
for nid, node in graph.nodes.items():
if node.get("template_name") == "create_resource":
slot = str(node.get("param", {}).get("slot_on_deck") or "")
if slot:
out[slot] = nid
return out
def _edges_to(graph, target_id: str) -> List[Dict[str, Any]]:
return [e for e in graph.edges if e["target"] == target_id]
def _edges_from(graph, source_id: str) -> List[Dict[str, Any]]:
return [e for e in graph.edges if e["source"] == source_id]
# ==================== fixture构造跨板 labware + steps ====================
def _cross_slot_labware_info() -> Dict[str, Dict[str, Any]]:
"""51b9a5 简化slot1 source + slot2/3/5/6 target plates + slot12 tip。"""
return {
"l1": {
"slot": 1,
"well": ["A1"],
"labware": "nest_12_reservoir_15ml",
"object": "source",
},
"plate_slot2": {
"slot": 2,
"well": ["A1"],
"labware": "nest_96_wellplate_2ml_deep",
"object": "target",
},
"plate_slot3": {
"slot": 3,
"well": ["A1"],
"labware": "nest_96_wellplate_2ml_deep",
"object": "target",
},
"plate_slot5": {
"slot": 5,
"well": ["A1"],
"labware": "nest_96_wellplate_2ml_deep",
"object": "target",
},
"plate_slot6": {
"slot": 6,
"well": ["A1"],
"labware": "nest_96_wellplate_2ml_deep",
"object": "target",
},
"tiprack_12": {
"slot": 12,
"well": [],
"labware": "opentrons_96_tiprack_300ul",
"object": "tiprack",
},
}
def _cross_slot_protocol_steps(targets: List[str], dis_vols: List[float]) -> List[Dict[str, Any]]:
return [
{
"action": "transfer_liquid",
"parameters": {
"sources": "l1",
"targets": targets,
"tip_racks": "tiprack_12",
"asp_vols": dis_vols.copy(),
"dis_vols": dis_vols.copy(),
},
"step_number": 1,
}
]
# ==================== 用例 ====================
def test_emit_merged_set_liquid_basic():
"""51b9a5 主场景targets=[A,B,C,D] → 1 merged set_liquid 节点
+ 4 条入边(来自 4 个 distinct create_resource+ 1 条出边(去 transfer_liquid
"""
targets = ["plate_slot2", "plate_slot3", "plate_slot5", "plate_slot6"]
dis_vols = [8.3, 8.3, 8.3, 8.3]
g = build_protocol_graph(
labware_info=_cross_slot_labware_info(),
protocol_steps=_cross_slot_protocol_steps(targets, dis_vols),
workstation_name="PRCXI",
)
set_liquid_nodes = _nodes_by_template(g, "set_liquid_from_plate")
merged_nodes = [n for n in set_liquid_nodes if str(n.get("name", "")).startswith("_merged_targets_")]
assert len(merged_nodes) == 1, (
f"应有且仅有 1 个 merged set_liquid_from_plate 节点v2 跨板聚合器);"
f" 实际找到 {len(merged_nodes)}: {[n.get('name') for n in merged_nodes]}"
)
merged = merged_nodes[0]
merged_id = merged["id"]
# param.wells长度 4每元素的 parent 是对应 reagent_key
wells = merged.get("param", {}).get("wells") or []
assert len(wells) == 4
assert [w["parent"] for w in wells] == targets, "merged.wells 顺序必须严格按 targets 列表"
# well 字段映射到 reagent.well[0](都是 "A1"
for w, key in zip(wells, targets):
assert w["id"].endswith("/A1"), f"well id 应包含 well 名: {w}"
assert w["parent"] == key
# 入边4 条来自 distinct create_resource 节点slot 2/3/5/6target_port=wells_identifier
cr_by_slot = _create_resource_by_slot(g)
in_edges = _edges_to(g, merged_id)
in_sources = {e["source"] for e in in_edges if e.get("target_handle_key") == "wells_identifier"}
expected_sources = {cr_by_slot[s] for s in ("2", "3", "5", "6")}
assert in_sources == expected_sources, (
f"merged 节点应接收 4 个 distinct create_resource 的 wells_identifier 边;"
f" 实际 {in_sources} vs 期望 {expected_sources}"
)
# 出边1 条到 transfer_liquidtargets_identifier
transfer_nodes = _nodes_by_template(g, "transfer_liquid")
assert len(transfer_nodes) == 1
transfer_id = transfer_nodes[0]["id"]
out_to_transfer = [
e for e in _edges_from(g, merged_id)
if e["target"] == transfer_id and e.get("target_handle_key") == "targets_identifier"
]
assert len(out_to_transfer) == 1, (
f"merged 节点应向 transfer_liquid.targets_identifier 发出唯一 1 条边;"
f" 实际 {len(out_to_transfer)}"
)
def test_emit_merged_set_liquid_repeat_key():
"""同 reagent_key 重复同板多孔targets=[A,A,A] + reagent.A.well=[A1,A2,A3]
→ merged.wells 顺序 = [A/A1, A/A2, A/A3]cursor 推进取每个 well
"""
labware = _cross_slot_labware_info()
labware["plate_slot2"]["well"] = ["A1", "A2", "A3"]
targets = ["plate_slot2", "plate_slot2", "plate_slot2"]
dis_vols = [10.0, 20.0, 30.0]
g = build_protocol_graph(
labware_info=labware,
protocol_steps=_cross_slot_protocol_steps(targets, dis_vols),
workstation_name="PRCXI",
)
merged_nodes = [
n for n in _nodes_by_template(g, "set_liquid_from_plate")
if str(n.get("name", "")).startswith("_merged_targets_")
]
assert len(merged_nodes) == 1
wells = merged_nodes[0]["param"]["wells"]
assert [w["id"].rsplit("/", 1)[-1] for w in wells] == ["A1", "A2", "A3"], (
"cursor 应依次取 reagent.A.well[0/1/2]"
)
assert all(w["parent"] == "plate_slot2" for w in wells)
def test_emit_merged_set_liquid_mixed():
"""跨板 + 同板重复targets=[A,B,A,C] + reagent.A.well=[A1,A2]
→ merged.wells = [A/A1, B/A1, A/A2, C/A1]。
"""
labware = _cross_slot_labware_info()
labware["plate_slot2"]["well"] = ["A1", "A2"]
targets = ["plate_slot2", "plate_slot3", "plate_slot2", "plate_slot5"]
dis_vols = [10.0, 20.0, 30.0, 40.0]
g = build_protocol_graph(
labware_info=labware,
protocol_steps=_cross_slot_protocol_steps(targets, dis_vols),
workstation_name="PRCXI",
)
merged_nodes = [
n for n in _nodes_by_template(g, "set_liquid_from_plate")
if str(n.get("name", "")).startswith("_merged_targets_")
]
assert len(merged_nodes) == 1
wells = merged_nodes[0]["param"]["wells"]
ids = [(w["parent"], w["id"].rsplit("/", 1)[-1]) for w in wells]
assert ids == [
("plate_slot2", "A1"),
("plate_slot3", "A1"),
("plate_slot2", "A2"),
("plate_slot5", "A1"),
]
def test_emit_merged_set_liquid_8ch():
"""与 P1 multi-channel 复合targets=[A]*8+[B]*8每列 8 通道)。
merged.wells 长度 16前 8 全 plate_slot2 的 8 个 well后 8 全 plate_slot3 的 8 个 well。
"""
labware = _cross_slot_labware_info()
# 8 通道场景 reagent.well 已被 P1 multi 展开为长度 8
labware["plate_slot2"]["well"] = [f"{r}1" for r in "ABCDEFGH"]
labware["plate_slot3"]["well"] = [f"{r}1" for r in "ABCDEFGH"]
targets = ["plate_slot2"] * 8 + ["plate_slot3"] * 8
dis_vols = [5.0] * 16
g = build_protocol_graph(
labware_info=labware,
protocol_steps=_cross_slot_protocol_steps(targets, dis_vols),
workstation_name="PRCXI",
)
merged_nodes = [
n for n in _nodes_by_template(g, "set_liquid_from_plate")
if str(n.get("name", "")).startswith("_merged_targets_")
]
assert len(merged_nodes) == 1
wells = merged_nodes[0]["param"]["wells"]
assert len(wells) == 16
# 前 8 全 plate_slot2后 8 全 plate_slot3满足 cross-slot × 8ch 列对齐约束)
assert all(w["parent"] == "plate_slot2" for w in wells[:8])
assert all(w["parent"] == "plate_slot3" for w in wells[8:])
# well 名顺序A1..H1 重复两遍
assert [w["id"].rsplit("/", 1)[-1] for w in wells[:8]] == [f"{r}1" for r in "ABCDEFGH"]
assert [w["id"].rsplit("/", 1)[-1] for w in wells[8:]] == [f"{r}1" for r in "ABCDEFGH"]
def test_transfer_liquid_targets_rewrite():
"""transfer_liquid 节点改写后只剩 1 条 targets_identifier 入边params.targets 不再是 list。"""
targets = ["plate_slot2", "plate_slot3", "plate_slot5", "plate_slot6"]
dis_vols = [8.3, 8.3, 8.3, 8.3]
g = build_protocol_graph(
labware_info=_cross_slot_labware_info(),
protocol_steps=_cross_slot_protocol_steps(targets, dis_vols),
workstation_name="PRCXI",
)
transfer_nodes = _nodes_by_template(g, "transfer_liquid")
assert len(transfer_nodes) == 1
tnode = transfer_nodes[0]
transfer_id = tnode["id"]
# params.targetsv2 中 list 形态在 INPUT_PORT_MAPPING 处理后被清空([])或为单字符串
# (不再是原始 list[str]——避免下游 runtime 对其再做无序聚合)
tparams = tnode.get("param", {}) or {}
assert not isinstance(tparams.get("targets"), list) or tparams.get("targets") == [], (
f"v2params.targets 不再是非空 list实际 {tparams.get('targets')!r}"
)
# targets_identifier 端口:只有 1 条入边
in_targets_edges = [
e for e in _edges_to(g, transfer_id)
if e.get("target_handle_key") == "targets_identifier"
]
assert len(in_targets_edges) == 1, (
f"v2transfer_liquid.targets_identifier 必须是单入边(来自 merged set_liquid;"
f" 实际 {len(in_targets_edges)}"
)
# 这条入边的源端口必须是 output_wells
edge = in_targets_edges[0]
assert edge.get("source_handle_key") == "output_wells"
def test_str_targets_no_merged_node_emitted():
"""对照组targets 为 str单 reagent → 不插入 merged set_liquid_from_plate 节点。
保证 v2 改造**只**对 list 形态触发,单 reagent 走 P3 原有 per-plate set_liquid 路径。
"""
labware = _cross_slot_labware_info()
labware["plate_slot2"]["well"] = ["A1", "A2", "A3"]
steps = [
{
"action": "transfer_liquid",
"parameters": {
"sources": "l1",
"targets": "plate_slot2", # ← 单 str非 list
"tip_racks": "tiprack_12",
"asp_vols": [8.3, 8.3, 8.3],
"dis_vols": [8.3, 8.3, 8.3],
},
"step_number": 1,
}
]
g = build_protocol_graph(
labware_info=labware,
protocol_steps=steps,
workstation_name="PRCXI",
)
merged_nodes = [
n for n in _nodes_by_template(g, "set_liquid_from_plate")
if str(n.get("name", "")).startswith("_merged_targets_")
]
assert merged_nodes == [], "str 形态 targets 不应触发 v2 merged 聚合节点"

View File

@@ -1,452 +0,0 @@
"""P8 — Stage 3 (``workflow/common.py``) 写入 ``set_liquid_from_plate.param.liquid_names`` 时
优先取 ``reagent[key].liquid_name``,缺省时 fallback 到 reagent_key。
对应 ``product_designs/protocol_convert/08-liquid-name-from-reagent-block.md`` §3.4 + §5。
设计要点
--------
- ``reagent[key].liquid_name`` 是 P8 新增的**可选**字段,承载真实化学名(与 reagent_key
解耦reagent_key 仍是数据流引用名 / 业务别名,``liquid_name`` 是写入 PLR tracker /
前端的 human-readable 名称)。
- ``liquid_name`` 来源优先级Stage 0 mock ``Well.load_liquid(liquid=...)`` 实参 >
README 语义词 > 不写Stage 3 fallback 到 reagent_key
- ``liquid_name`` 保留空格 / 中文 / 括号等原字符,**不**做 snake_case / underscore 替换。
- 旧 JSON无 ``liquid_name`` 字段)行为完全不变(设计点 §7.A
测试用例
--------
- ``test_per_plate_fallback_when_no_liquid_name`` —— 缺省 fallback
reagent 块无 ``liquid_name`` → liquid_names[i] == reagent_key与 P8 前一致)。
- ``test_per_plate_uses_explicit_liquid_name`` —— 显式 liquid_name
liquid_names[i] == "EDTA Plasma"
- ``test_per_plate_preserves_spaces_and_special_chars`` —— 含空格 / 括号:
liquid_names[i] 不被 ``replace(" ", "_")`` 处理(不同于 reagent_key 用的 res_id
- ``test_merged_node_uses_explicit_liquid_name_per_dispense`` —— merged 节点
每个 dispense 独立取 ``liquid_name or key``,部分有部分无能共存。
- ``test_liquid_name_independent_of_reagent_key_normalization`` —— 与 P4 共存:
reagent_key 仍是 ``samples_2`` 等去重后缀,但 liquid_names 写的是真实化学名。
"""
from __future__ import annotations
import sys
import types
from pathlib import Path
from typing import Any, Dict, List
ROOT_DIR = Path(__file__).resolve().parents[2]
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
def _install_fake_optional_deps() -> None:
"""与 test_common_set_liquid_dedup.py 一致的可选依赖 stub。"""
if "matplotlib" not in sys.modules:
sys.modules["matplotlib"] = types.ModuleType("matplotlib")
if "matplotlib.pyplot" not in sys.modules:
sys.modules["matplotlib.pyplot"] = types.ModuleType("matplotlib.pyplot")
try:
from networkx.drawing import nx_agraph # noqa: F401
except Exception:
nx_drawing = types.ModuleType("networkx.drawing")
nx_agraph_mod = types.ModuleType("networkx.drawing.nx_agraph")
nx_agraph_mod.to_agraph = lambda _g: None # type: ignore[attr-defined]
nx_drawing.nx_agraph = nx_agraph_mod # type: ignore[attr-defined]
sys.modules["networkx.drawing"] = nx_drawing
sys.modules["networkx.drawing.nx_agraph"] = nx_agraph_mod
_install_fake_optional_deps()
import pytest # noqa: E402
from unilabos.workflow.common import build_protocol_graph # noqa: E402
# ==================== 辅助 ====================
def _set_liquid_nodes(graph) -> List[Dict[str, Any]]:
return [
{"id": nid, **node}
for nid, node in graph.nodes.items()
if node.get("template_name") == "set_liquid_from_plate"
]
def _per_plate_for(graph, reagent_key: str) -> Dict[str, Any]:
"""根据 ``description = "Set liquid: <reagent_key>"`` 反查 per-plate 节点。"""
for n in _set_liquid_nodes(graph):
if n.get("description") == f"Set liquid: {reagent_key}":
return n
raise AssertionError(f"未找到 per-plate set_liquid_from_plate(reagent_key={reagent_key!r})")
def _merged_nodes(graph) -> List[Dict[str, Any]]:
return [
n for n in _set_liquid_nodes(graph)
if str(n.get("name", "")).startswith("_merged_targets_")
]
def _make_source_target_labware(
*,
source_key: str = "src_1",
source_liquid_name: str | None = None,
target_keys: List[str] | None = None,
target_liquid_names: Dict[str, str] | None = None,
) -> Dict[str, Dict[str, Any]]:
"""构造 1 个 source + N 个 target reagent + 1 个 tip rack。
``*_liquid_name`` 为 None / 缺省时**不**写入 ``liquid_name`` 字段,
模拟旧 schema / mock 未给 liquid_name 的真实回归场景。
"""
info: Dict[str, Dict[str, Any]] = {}
source_entry: Dict[str, Any] = {
"slot": 1,
"well": ["A1"],
"labware": "nest_12_reservoir_15ml",
"object": "source",
}
if source_liquid_name is not None:
source_entry["liquid_name"] = source_liquid_name
info[source_key] = source_entry
target_keys = target_keys or ["t_A"]
target_liquid_names = target_liquid_names or {}
for i, tk in enumerate(target_keys, start=1):
entry: Dict[str, Any] = {
"slot": 2 + i,
"well": ["A1"],
"labware": "nest_96_wellplate_2ml_deep",
"object": "target",
}
if tk in target_liquid_names:
entry["liquid_name"] = target_liquid_names[tk]
info[tk] = entry
info["tiprack_12"] = {
"slot": 12,
"well": [],
"labware": "opentrons_96_tiprack_300ul",
"object": "tiprack",
}
return info
# ==================== T1 缺省 fallback ====================
def test_per_plate_fallback_when_no_liquid_name():
"""reagent block 无 ``liquid_name`` 字段 → liquid_names[i] == reagent_keyP8 前行为)。"""
labware = _make_source_target_labware(
source_key="src_1",
target_keys=["t_A"],
# 都不给 liquid_name
)
steps = [
{
"action": "transfer_liquid",
"parameters": {
"sources": "src_1",
"targets": "t_A",
"tip_racks": "tiprack_12",
"asp_vols": [10.0],
"dis_vols": [10.0],
},
"step_number": 1,
}
]
g = build_protocol_graph(
labware_info=labware,
protocol_steps=steps,
workstation_name="PRCXI",
)
src_node = _per_plate_for(g, "src_1")
tgt_node = _per_plate_for(g, "t_A")
assert src_node["param"]["liquid_names"] == ["src_1"], (
f"无 liquid_name 时 source per-plate 应 fallback 到 reagent_key"
f" 实际 {src_node['param']['liquid_names']}"
)
assert tgt_node["param"]["liquid_names"] == ["t_A"], (
f"无 liquid_name 时 target per-plate 应 fallback 到 reagent_key"
f" 实际 {tgt_node['param']['liquid_names']}"
)
# ==================== T2 显式 liquid_name ====================
def test_per_plate_uses_explicit_liquid_name():
"""reagent block 含 ``liquid_name`` → liquid_names[i] 用该值(不是 reagent_key"""
labware = _make_source_target_labware(
source_key="src_1",
source_liquid_name="EDTA Plasma",
target_keys=["t_A"],
target_liquid_names={"t_A": "PBS Diluent"},
)
steps = [
{
"action": "transfer_liquid",
"parameters": {
"sources": "src_1",
"targets": "t_A",
"tip_racks": "tiprack_12",
"asp_vols": [10.0],
"dis_vols": [10.0],
},
"step_number": 1,
}
]
g = build_protocol_graph(
labware_info=labware,
protocol_steps=steps,
workstation_name="PRCXI",
)
src_node = _per_plate_for(g, "src_1")
tgt_node = _per_plate_for(g, "t_A")
assert src_node["param"]["liquid_names"] == ["EDTA Plasma"], (
f"source per-plate 应使用 reagent.liquid_name实际 {src_node['param']['liquid_names']}"
)
assert tgt_node["param"]["liquid_names"] == ["PBS Diluent"], (
f"target per-plate 应使用 reagent.liquid_name实际 {tgt_node['param']['liquid_names']}"
)
# ==================== T3 空格 / 括号 ====================
def test_per_plate_preserves_spaces_and_special_chars():
"""``liquid_name`` 保留空格 / 括号 / 中文等原字符,不被 replace(' ', '_') 处理。
这条与 reagent_key 走 ``res_id = str(labware_id).replace(' ', '_')`` 的语义不同。
"""
labware = _make_source_target_labware(
source_key="src_1",
source_liquid_name="Tris HCl pH 8.0 (1×)",
target_keys=["t_A"],
target_liquid_names={"t_A": "稀释液 A"},
)
steps = [
{
"action": "transfer_liquid",
"parameters": {
"sources": "src_1",
"targets": "t_A",
"tip_racks": "tiprack_12",
"asp_vols": [10.0],
"dis_vols": [10.0],
},
"step_number": 1,
}
]
g = build_protocol_graph(
labware_info=labware,
protocol_steps=steps,
workstation_name="PRCXI",
)
src_node = _per_plate_for(g, "src_1")
tgt_node = _per_plate_for(g, "t_A")
assert src_node["param"]["liquid_names"] == ["Tris HCl pH 8.0 (1×)"], (
f"空格 / 括号应原样保留;实际 {src_node['param']['liquid_names']}"
)
assert tgt_node["param"]["liquid_names"] == ["稀释液 A"], (
f"中文应原样保留;实际 {tgt_node['param']['liquid_names']}"
)
# reagent_key 自身仍受 ``res_id = replace(' ', '_')`` 影响,
# 但本测试 reagent_key 不含空格,故 sl_node_title 仍以 reagent_key 为根。
# 这里仅断言 liquid_names 字段独立于 reagent_key normalize。
# ==================== T4 merged 节点跨板部分有部分无 ====================
def test_merged_node_uses_explicit_liquid_name_per_dispense():
"""merged 节点 ``liquid_names`` 与 list-targets 同长,每个元素独立取
``reagent[key].liquid_name or key``:本例 3 个 target2 个有显式名、1 个无。
"""
labware = _make_source_target_labware(
source_key="src_1",
target_keys=["t_A", "t_B", "t_C"],
target_liquid_names={
"t_A": "Plasma",
# t_B 无 liquid_name
"t_C": "Buffer X",
},
)
steps = [
{
"action": "transfer_liquid",
"parameters": {
"sources": "src_1",
"targets": ["t_A", "t_B", "t_C"],
"tip_racks": "tiprack_12",
"asp_vols": [5.0] * 3,
"dis_vols": [5.0] * 3,
},
"step_number": 1,
}
]
g = build_protocol_graph(
labware_info=labware,
protocol_steps=steps,
workstation_name="PRCXI",
)
merged = _merged_nodes(g)
assert len(merged) == 1, f"应有 1 个 merged 节点,实际 {len(merged)}"
liquid_names = merged[0]["param"]["liquid_names"]
assert liquid_names == ["Plasma", "t_B", "Buffer X"], (
f"merged 每 dispense 独立取 liquid_name or key实际 {liquid_names}"
)
# ==================== T5 与 P4 reagent_key 后缀共存 ====================
def test_liquid_name_independent_of_reagent_key_normalization():
"""P4 命名链产生 ``samples_2`` 这种带后缀的 reagent_key跨板去重
P8 ``liquid_name`` 应保持原始化学名,**不**带 P4 的去重后缀。
构造2 个 target reagent_keys ``samples`` / ``samples_2``(不同 slot
模拟跨板同液体被 Stage 2 去重),都标 liquid_name="Bacterial Culture"
"""
labware = _make_source_target_labware(
source_key="src_1",
target_keys=["samples", "samples_2"],
target_liquid_names={
"samples": "Bacterial Culture",
"samples_2": "Bacterial Culture",
},
)
steps = [
{
"action": "transfer_liquid",
"parameters": {
"sources": "src_1",
"targets": ["samples", "samples_2"],
"tip_racks": "tiprack_12",
"asp_vols": [5.0, 5.0],
"dis_vols": [5.0, 5.0],
},
"step_number": 1,
}
]
g = build_protocol_graph(
labware_info=labware,
protocol_steps=steps,
workstation_name="PRCXI",
)
merged = _merged_nodes(g)
assert len(merged) == 1
liquid_names = merged[0]["param"]["liquid_names"]
assert liquid_names == ["Bacterial Culture", "Bacterial Culture"], (
f"P8 liquid_name 应与 P4 reagent_key 后缀解耦:同液体的两个 reagent_key 应得相同"
f" liquid_name实际 {liquid_names}"
)
# 同时 reagent_key 仍是 samples / samples_2不变
wells = merged[0]["param"]["wells"]
parents = [w["parent"] for w in wells]
assert parents == ["samples", "samples_2"], (
f"merged wells.parent 应等于 list-targets reagent_keys实际 {parents}"
)
# ==================== T6 source per-plate / target per-plate 同步生效 ====================
def test_both_source_and_target_per_plate_use_liquid_name():
"""str-targets 路径(无 mergedsource 和 target 都走 per-plate emit
各自独立取 ``liquid_name``。"""
labware = _make_source_target_labware(
source_key="src_1",
source_liquid_name="Reagent A",
target_keys=["t_A"],
target_liquid_names={"t_A": "Reagent B"},
)
steps = [
{
"action": "transfer_liquid",
"parameters": {
"sources": "src_1",
"targets": "t_A", # str-targets不触发 merged
"tip_racks": "tiprack_12",
"asp_vols": [10.0],
"dis_vols": [10.0],
},
"step_number": 1,
}
]
g = build_protocol_graph(
labware_info=labware,
protocol_steps=steps,
workstation_name="PRCXI",
)
assert _merged_nodes(g) == [], "str-targets 不应产生 merged 节点"
src_node = _per_plate_for(g, "src_1")
tgt_node = _per_plate_for(g, "t_A")
assert src_node["param"]["liquid_names"] == ["Reagent A"]
assert tgt_node["param"]["liquid_names"] == ["Reagent B"]
# ==================== T7 多孔同 reagent → 整列 liquid_names 一致 ====================
def test_multi_well_reagent_replicates_liquid_name():
"""1 个 reagent 含 8 wellsmulti-channel 扩展场景)→ liquid_names 应是
``[liquid_name] * 8``,与 wells 长度一致。"""
labware: Dict[str, Dict[str, Any]] = {
"src_1": {
"slot": 1,
"well": ["A1", "B1", "C1", "D1", "E1", "F1", "G1", "H1"],
"labware": "nest_96_wellplate_100ul_pcr_full_skirt",
"object": "source",
"liquid_name": "Mastermix",
},
"t_A": {
"slot": 3,
"well": ["A1"],
"labware": "nest_96_wellplate_2ml_deep",
"object": "target",
},
"tiprack_12": {
"slot": 12,
"well": [],
"labware": "opentrons_96_tiprack_300ul",
"object": "tiprack",
},
}
steps = [
{
"action": "transfer_liquid",
"parameters": {
"sources": "src_1",
"targets": "t_A",
"tip_racks": "tiprack_12",
"asp_vols": [10.0],
"dis_vols": [10.0],
},
"step_number": 1,
}
]
g = build_protocol_graph(
labware_info=labware,
protocol_steps=steps,
workstation_name="PRCXI",
)
src_node = _per_plate_for(g, "src_1")
liquid_names = src_node["param"]["liquid_names"]
assert liquid_names == ["Mastermix"] * 8, (
f"per-plate 应把 liquid_name 复制 well_count 份;实际 {liquid_names}"
)
# 同时 wells / volumes 长度一致
assert len(src_node["param"]["wells"]) == 8
assert len(src_node["param"]["volumes"]) == 8

View File

@@ -1,174 +0,0 @@
"""P6 §17 hint bug —— `_infer_plate_num_children_from_labware_hint` 误把
reagent_id 末尾数字(如 ``samples_6`` 的 ``_6``)当作孔板规格,导致
``_apply_target_labware_class_auto_match`` fallback 到 PRCXI 4-孔 trough 模板。
跨板 fixP2 v2 §14把 plate name 作为 prefix 编码进 ``well_names`` 之后,
runtime 调用 ``plate.get_well("A5")`` 严格定位 welltrough plate 上不存在
``A5`` 会直接 IndexError使得这个隐藏多年的孔数推断 bug 浮出。
修复策略(方案 A
-----
hint 只用 ``item.get("labware", "")``**不再**拼上 ``labware_id``reagent_key
是业务名,不应参与孔板规格推断)。
测试矩阵
----
- ``test_reagent_key_numeric_suffix_must_not_match_hint`` —— samples_6 / samples_24 /
samples_96 + nunc_rectangular_agar_plate → hint 返回 Nonelabware string 不带孔数信息)。
- ``test_labware_string_X_well_correctly_inferred`` —— labware="nest_96_wellplate..." → 96
"custom_384_wellplate" → 384"nest_24_wellplate_2ml_pcr" → 24。
- ``test_apply_does_not_classify_samples_6_as_trough`` —— 集成:构造 Agar Plating-like
reagent blockslot 8 上 12 个 samples_XX 末尾含 6/24/96
``_apply_target_labware_class_auto_match`` 后samples_6/24 不再得到 trough class。
- ``test_real_labware_96_wellplate_still_inferred_via_labware_str`` —— 即便 labware_id
与孔数无关,``nest_96_wellplate_100ul_pcr_full_skirt`` 这种 labware 命名仍应被识别为 96。
"""
from __future__ import annotations
import sys
import types
from pathlib import Path
ROOT_DIR = Path(__file__).resolve().parents[2]
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
def _install_fake_optional_deps() -> None:
if "matplotlib" not in sys.modules:
sys.modules["matplotlib"] = types.ModuleType("matplotlib")
if "matplotlib.pyplot" not in sys.modules:
sys.modules["matplotlib.pyplot"] = types.ModuleType("matplotlib.pyplot")
_install_fake_optional_deps()
import pytest # noqa: E402
from unilabos.workflow.common import ( # noqa: E402
_apply_target_labware_class_auto_match,
_infer_plate_num_children_from_labware_hint,
_reconcile_slot_carrier_target_class,
)
# ==================== unithint 函数本身 ====================
@pytest.mark.parametrize(
"labware_id",
["samples_6", "samples_24", "samples_96", "samples_12", "samples_48"],
)
def test_reagent_key_numeric_suffix_must_not_match_hint(labware_id):
"""reagent_id 末尾的孔数关键字数字不应被识别为孔板规格。"""
item = {
"slot": 8,
"well": ["A5"],
"labware": "nunc_rectangular_agar_plate",
"object": "target",
}
assert _infer_plate_num_children_from_labware_hint(labware_id, item) is None, (
f"reagent_id {labware_id!r} 不应被识别为孔板规格 "
f"其末尾数字应当被忽略labware string 不含 96/384/etc 关键字)"
)
@pytest.mark.parametrize(
"labware_str,expected",
[
("nest_96_wellplate_100ul_pcr_full_skirt", 96),
("custom_384_wellplate", 384),
("nest_24_wellplate_2ml_pcr", 24),
("custom_48_wellplate", 48),
("opentrons_12_wellplate_15ml", 12),
("nest_6_wellplate_5ml", 6),
("nunc_rectangular_agar_plate", None),
("", None),
],
)
def test_labware_string_well_count_inferred(labware_str, expected):
item = {"labware": labware_str}
assert (
_infer_plate_num_children_from_labware_hint("samples", item) == expected
), f"labware {labware_str!r} 应推断为 {expected!r}"
# ==================== integration模拟 Agar Plating ====================
def _agar_plating_reagent_block():
"""反推自 unilabos_data/req_workflow_upload.json12 列 × 9 reagent per step。
slot 8 (mapped 14) 上 12 个 reagent_keys: samples_6, samples_15, samples_24,
samples_33, samples_42, samples_51, samples_60, samples_69, samples_78,
samples_87, samples_96, samples_105.
"""
info = {}
slot_for_idx = {0: 3, 1: 4, 2: 5, 3: 6, 4: 7, 5: 8, 6: 9, 7: 10, 8: 11}
cols = [f"A{i + 1}" for i in range(12)]
for col_i, col in enumerate(cols):
for di in range(9):
n = col_i * 9 + di + 1
key = "samples" if n == 1 else f"samples_{n}"
info[key] = {
"slot": slot_for_idx[di],
"well": [col],
"labware": "nunc_rectangular_agar_plate",
"object": "target",
}
for i in range(12):
key = "sources" if i == 0 else f"sources_{i + 1}"
info[key] = {
"slot": 2,
"well": [cols[i]],
"labware": "nest_96_wellplate_100ul_pcr_full_skirt",
"object": "source",
}
info["tiprack_1"] = {
"slot": 1,
"well": None,
"labware": "opentrons_96_tiprack_10ul",
"object": "tiprack",
}
info["trash"] = {
"slot": 12,
"well": None,
"labware": "opentrons_1_trash_1100ml_fixed",
"object": "trash",
}
return info
def test_apply_does_not_classify_samples_6_as_trough():
"""集成回归Agar Plating-like reagent block 跑完类匹配 + slot 统一后,
slot 8 上 12 个 reagent 不应得到 4-孔 trough class。"""
info = _agar_plating_reagent_block()
_apply_target_labware_class_auto_match(
info, preserve_tip_rack_incoming_class=True, target_device="prcxi"
)
_reconcile_slot_carrier_target_class(
info, preserve_tip_rack_incoming_class=True, target_device="prcxi"
)
slot8_keys = [
"samples_6", "samples_15", "samples_24", "samples_33",
"samples_42", "samples_51", "samples_60", "samples_69",
"samples_78", "samples_87", "samples_96", "samples_105",
]
for k in slot8_keys:
cls = info[k].get("target_class_name") or ""
assert "trough" not in cls.lower(), (
f"reagent {k} 被误识别为 trough class: {cls!r}"
"这通常是 hint 误把 reagent_id 末尾数字当孔板规格"
)
def test_real_labware_96_wellplate_still_inferred_via_labware_str():
"""labware string 含 96_wellplate 时应该正常识别为 96不被 fix 破坏。"""
item = {
"slot": 2,
"well": ["A1"],
"labware": "nest_96_wellplate_100ul_pcr_full_skirt",
"object": "source",
}
assert _infer_plate_num_children_from_labware_hint("sources", item) == 96

View File

@@ -1,379 +0,0 @@
"""P2 v2 §14 set_liquid_from_plate 去重 —— Stage 3 (`workflow/common.py`) 集成测试。
对应 ``product_designs/protocol_convert/02-cross-slot-merge.md`` §142026-05-22 plan
§14 设计要点
-----------------
当 ``transfer_liquid.params.targets`` 是 ``list[str]`` 时,``_emit_merged_set_liquid``
已经为该 transfer 插入一个 merged ``set_liquid_from_plate`` 节点,
其 ``param.wells`` 聚合了 list 中所有 reagent_keys 的跨板 wells。
§14 之前:第二步循环(``for labware_id, item in labware_info.items()``)仍然为
list-targets 中出现的每个 reagent_key 创建一个 per-plate ``set_liquid_from_plate`` 节点,
导致**节点冗余**per-plate 节点的 ``output_wells`` 对 transfer_liquid 的
``targets_identifier`` 边毫无贡献 —— transfer_liquid 单边只接 merged 节点)。
§14 改造:在第二步循环**之前**预扫描 protocol_steps收集
``set_liquid_covered_by_merged: Set[str]``(出现在某个 list[str] targets 中的所有 keys
与 ``set_liquid_referenced_by_str: Set[str]``(出现在 str targets 中的所有 keys
循环内对 ``object="target"`` 且 ``key ∈ covered ∧ key ∉ referenced_by_str`` 的 reagent_key
**跳过** per-plate 节点创建。
测试用例
----
- ``test_per_plate_skipped_when_covered_by_merged`` —— list-targets 覆盖的
target reagent_keys 不再产生 per-plate set_liquid_from_plate。
- ``test_per_plate_kept_when_also_referenced_by_str_targets`` —— R1 缓解:
同时被 list-targets 和 str-targets 引用的 reagent_key 仍保留 per-plate。
- ``test_str_targets_protocol_unaffected`` —— 单 slot 协议(仅 str-targets
节点数完全不变(回归防护)。
- ``test_51b9a5_style_node_count`` —— 12 list-targets × len=9 大规模场景:
set_liquid_from_plate 总节点数 = source per-plate + merged + 0 target per-plate。
- ``test_source_per_plate_always_kept`` —— source 端不受 §14 影响source
reagent_keys 不出现在 targets 字段中per-plate 节点恒在。
"""
from __future__ import annotations
import sys
import types
from pathlib import Path
from typing import Any, Dict, List
ROOT_DIR = Path(__file__).resolve().parents[2]
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
def _install_fake_optional_deps() -> None:
"""与 test_common_cross_slot_v2.py 一致的可选依赖 stub。"""
if "matplotlib" not in sys.modules:
sys.modules["matplotlib"] = types.ModuleType("matplotlib")
if "matplotlib.pyplot" not in sys.modules:
sys.modules["matplotlib.pyplot"] = types.ModuleType("matplotlib.pyplot")
try:
from networkx.drawing import nx_agraph # noqa: F401
except Exception:
nx_drawing = types.ModuleType("networkx.drawing")
nx_agraph_mod = types.ModuleType("networkx.drawing.nx_agraph")
nx_agraph_mod.to_agraph = lambda _g: None # type: ignore[attr-defined]
nx_drawing.nx_agraph = nx_agraph_mod # type: ignore[attr-defined]
sys.modules["networkx.drawing"] = nx_drawing
sys.modules["networkx.drawing.nx_agraph"] = nx_agraph_mod
_install_fake_optional_deps()
import pytest # noqa: E402
from unilabos.workflow.common import build_protocol_graph # noqa: E402
# ==================== 辅助 ====================
def _nodes_by_template(graph, template_name: str) -> List[Dict[str, Any]]:
return [
{"id": nid, **node}
for nid, node in graph.nodes.items()
if node.get("template_name") == template_name
]
def _set_liquid_nodes_split(graph):
"""返回 (per_plate_nodes, merged_nodes)。merged 节点 name 以 `_merged_targets_` 开头。"""
all_sl = _nodes_by_template(graph, "set_liquid_from_plate")
merged = [n for n in all_sl if str(n.get("name", "")).startswith("_merged_targets_")]
per_plate = [n for n in all_sl if not str(n.get("name", "")).startswith("_merged_targets_")]
return per_plate, merged
def _labware_with_targets(target_keys: List[str], source_keys: List[str] | None = None) -> Dict[str, Dict[str, Any]]:
"""构造 labware_infosource 端 1 个 + 任意数量 target plates + tip rack。"""
info: Dict[str, Dict[str, Any]] = {}
source_keys = source_keys or ["src_1"]
for i, sk in enumerate(source_keys, start=1):
info[sk] = {
"slot": 1 + i - 1, # slot 1 占位(实际可能映射)
"well": ["A1"],
"labware": "nest_12_reservoir_15ml",
"object": "source",
}
for i, tk in enumerate(target_keys, start=1):
info[tk] = {
"slot": 2 + i, # 错开 source 使用的 slot
"well": ["A1"],
"labware": "nest_96_wellplate_2ml_deep",
"object": "target",
}
info["tiprack_12"] = {
"slot": 12,
"well": [],
"labware": "opentrons_96_tiprack_300ul",
"object": "tiprack",
}
return info
# ==================== 用例 ====================
def test_per_plate_skipped_when_covered_by_merged():
"""单 list-targets transfer 覆盖 4 个 target reagent_keys → per-plate 不再出现。"""
targets = ["t_A", "t_B", "t_C", "t_D"]
labware = _labware_with_targets(targets, source_keys=["src_1"])
steps = [
{
"action": "transfer_liquid",
"parameters": {
"sources": "src_1",
"targets": targets,
"tip_racks": "tiprack_12",
"asp_vols": [8.0] * 4,
"dis_vols": [8.0] * 4,
},
"step_number": 1,
}
]
g = build_protocol_graph(
labware_info=labware,
protocol_steps=steps,
workstation_name="PRCXI",
)
per_plate, merged = _set_liquid_nodes_split(g)
# merged 节点1 个
assert len(merged) == 1, f"应有 1 个 merged 节点;实际 {len(merged)}"
# per-plate 节点:仅 source 1 个src_1target 端被全部跳过
per_plate_names = {n.get("description", "") for n in per_plate}
per_plate_keys = {
n.get("description", "").replace("Set liquid: ", "")
for n in per_plate
}
assert "src_1" in per_plate_keys, "source 端 per-plate 必须保留"
for tk in targets:
assert tk not in per_plate_keys, (
f"§14target reagent_key '{tk}' 已被 merged 覆盖,不应再有 per-plate 节点;"
f" 实际 per_plate_keys={per_plate_keys}"
)
def test_per_plate_kept_when_also_referenced_by_str_targets():
"""R1 缓解t_A 既被 list-targets 引用,又被 str-targets 引用 → per-plate 必须保留。"""
targets_list = ["t_A", "t_B", "t_C"]
labware = _labware_with_targets(targets_list, source_keys=["src_1"])
steps = [
{
"action": "transfer_liquid",
"parameters": {
"sources": "src_1",
"targets": targets_list,
"tip_racks": "tiprack_12",
"asp_vols": [5.0] * 3,
"dis_vols": [5.0] * 3,
},
"step_number": 1,
},
{
"action": "transfer_liquid",
"parameters": {
"sources": "src_1",
"targets": "t_A",
"tip_racks": "tiprack_12",
"asp_vols": [10.0],
"dis_vols": [10.0],
},
"step_number": 2,
},
]
g = build_protocol_graph(
labware_info=labware,
protocol_steps=steps,
workstation_name="PRCXI",
)
per_plate, merged = _set_liquid_nodes_split(g)
per_plate_keys = {
n.get("description", "").replace("Set liquid: ", "")
for n in per_plate
}
assert "t_A" in per_plate_keys, (
f"R1t_A 被 str transfer #2 引用,必须保留 per-plate 节点;"
f" 实际 per_plate_keys={per_plate_keys}"
)
assert "t_B" not in per_plate_keys, "t_B 仅出现在 list-targets应跳过"
assert "t_C" not in per_plate_keys, "t_C 仅出现在 list-targets应跳过"
# merged 节点数1仅 list-targets transfer #1 生成)
assert len(merged) == 1
def test_str_targets_protocol_unaffected():
"""单 slot 协议(全 str-targets→ 每个 target reagent_key 仍有 per-plate零回归"""
labware = _labware_with_targets(["t_A", "t_B"], source_keys=["src_1"])
steps = [
{
"action": "transfer_liquid",
"parameters": {
"sources": "src_1",
"targets": "t_A",
"tip_racks": "tiprack_12",
"asp_vols": [10.0],
"dis_vols": [10.0],
},
"step_number": 1,
},
{
"action": "transfer_liquid",
"parameters": {
"sources": "src_1",
"targets": "t_B",
"tip_racks": "tiprack_12",
"asp_vols": [20.0],
"dis_vols": [20.0],
},
"step_number": 2,
},
]
g = build_protocol_graph(
labware_info=labware,
protocol_steps=steps,
workstation_name="PRCXI",
)
per_plate, merged = _set_liquid_nodes_split(g)
per_plate_keys = {
n.get("description", "").replace("Set liquid: ", "")
for n in per_plate
}
assert merged == [], "全 str-targets 协议不应触发 merged 节点"
assert {"src_1", "t_A", "t_B"}.issubset(per_plate_keys), (
f"单 slot 协议每个 reagent_key含 source/target都应保留 per-plate"
f" 实际 {per_plate_keys}"
)
def test_51b9a5_style_node_count():
"""大规模场景N 个 list-targets transfers每个长度 M同 source 不同跨板)。
构造2 个 sourcesrc_A1、src_A2+ 9 个 target plates × 2 个 well = 18 target reagent_keys。
2 个 transfer
- transfer #1: targets = [t_A1_1, t_A1_2, ..., t_A1_9](同 source src_A1跨 9 plate
- transfer #2: targets = [t_A2_1, t_A2_2, ..., t_A2_9](同 source src_A2跨 9 plate
期望 set_liquid_from_plate 总节点数 = 2 source per-plate + 2 merged + 0 target per-plate = 4。
"""
target_keys_a1 = [f"t_A1_{i}" for i in range(1, 10)]
target_keys_a2 = [f"t_A2_{i}" for i in range(1, 10)]
all_target_keys = target_keys_a1 + target_keys_a2
labware = _labware_with_targets(
all_target_keys,
source_keys=["src_A1", "src_A2"],
)
steps = [
{
"action": "transfer_liquid",
"parameters": {
"sources": "src_A1",
"targets": target_keys_a1,
"tip_racks": "tiprack_12",
"asp_vols": [8.3] * 9,
"dis_vols": [8.3] * 9,
},
"step_number": 1,
},
{
"action": "transfer_liquid",
"parameters": {
"sources": "src_A2",
"targets": target_keys_a2,
"tip_racks": "tiprack_12",
"asp_vols": [8.3] * 9,
"dis_vols": [8.3] * 9,
},
"step_number": 2,
},
]
g = build_protocol_graph(
labware_info=labware,
protocol_steps=steps,
workstation_name="PRCXI",
)
per_plate, merged = _set_liquid_nodes_split(g)
assert len(merged) == 2, f"应有 2 个 merged 节点;实际 {len(merged)}"
per_plate_keys = {
n.get("description", "").replace("Set liquid: ", "")
for n in per_plate
}
# source 端2 个 per-plate
assert "src_A1" in per_plate_keys and "src_A2" in per_plate_keys, (
f"source 端必须有 src_A1 + src_A2 per-plate实际 {per_plate_keys}"
)
# target 端18 个全部被跳过
for tk in all_target_keys:
assert tk not in per_plate_keys, (
f"§14target reagent_key '{tk}' 应被 merged 覆盖并跳过;"
f" 实际 per_plate_keys 包含 {tk}"
)
# 总节点数 == 2 + 2
assert len(per_plate) + len(merged) == 4, (
f"set_liquid_from_plate 总节点数应为 4 (2 source + 2 merged + 0 target per-plate);"
f" 实际 per_plate={len(per_plate)} merged={len(merged)}"
)
def test_source_per_plate_always_kept():
"""source reagent_keys 不出现在任何 targets 字段中 → per-plate 节点恒保留(与 §14 无关)。"""
target_keys = ["t_A", "t_B", "t_C"]
labware = _labware_with_targets(target_keys, source_keys=["src_X", "src_Y"])
steps = [
{
"action": "transfer_liquid",
"parameters": {
"sources": "src_X",
"targets": target_keys,
"tip_racks": "tiprack_12",
"asp_vols": [5.0] * 3,
"dis_vols": [5.0] * 3,
},
"step_number": 1,
},
{
"action": "transfer_liquid",
"parameters": {
"sources": "src_Y",
"targets": "t_A",
"tip_racks": "tiprack_12",
"asp_vols": [10.0],
"dis_vols": [10.0],
},
"step_number": 2,
},
]
g = build_protocol_graph(
labware_info=labware,
protocol_steps=steps,
workstation_name="PRCXI",
)
per_plate, _ = _set_liquid_nodes_split(g)
per_plate_keys = {
n.get("description", "").replace("Set liquid: ", "")
for n in per_plate
}
assert "src_X" in per_plate_keys, "source src_X 必须有 per-platesource 不会被 §14 跳过)"
assert "src_Y" in per_plate_keys, "source src_Y 必须有 per-plate"

View File

@@ -1,534 +0,0 @@
"""P6 / P6.1 / P6.1.1 `labware_mapping.py` 单元测试 —— 对应 06-labware-mapping-table.md §11.7.7 / §11.8.7。
这些用例只依赖 `unilabos.workflow.labware_mapping` 自身与 PyYAML
不需要 ROS2 / matplotlib / networkx 等环境,可直接 `pytest tests/workflow/test_labware_mapping.py`。
P6.1.1 schemav1.9
- 顶层 key 两段:``kinds`` / ``target_devices``**P6.1.1 起顶层 `slot_remap` 已不支持**,下沉到 ``target_devices.<device>`` 内)
- ``target_devices.default`` 是固定段名,作为兜底物料集,第一版按 prcxi 拷贝填充,**不支持 `models` 子段**
- ``target_devices.<device>.models.<model>`` 是可选的型号粒度覆盖slot_remap / rules
- 旧 schema顶层 ``vendors`` / ``slot_remap`` 或 rule 含 ``prcxi_class``)会触发 warning + fallback 到 builtin
"""
from __future__ import annotations
import sys
import warnings
from pathlib import Path
import pytest
ROOT_DIR = Path(__file__).resolve().parents[2]
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
from unilabos.workflow import labware_mapping as lm
@pytest.fixture(autouse=True)
def _reset_lru_cache():
"""每个用例后清缓存,避免 monkeypatch 跨用例污染。"""
yield
lm.reload_mapping()
# ==================== slot_remap ====================
@pytest.mark.parametrize(
"raw,object_type,want",
[
("4", "", "13"),
("8", "", "14"),
("12", "trash", "16"),
("12", "source", "12"),
("1", "", "1"),
("", "", ""),
(4, "", "13"), # 非字符串入参也应规整
],
)
def test_remap_slot_basic(raw, object_type, want):
assert lm.remap_slot(raw, object_type) == want
def test_remap_slot_none_returns_empty():
assert lm.remap_slot(None) == ""
def test_remap_slot_passthrough_unknown():
assert lm.remap_slot("99") == "99"
# ==================== infer_kind ====================
def test_infer_kind_trash_priority():
"""`trash` 在 kinds 列表第 1 条 → 优先于含 'rack' 的字符串。"""
assert lm.infer_kind("foo_trash_bar") == "trash"
assert lm.infer_kind("opentrons_fixed_trash") == "trash"
def test_infer_kind_tiprack_before_tuberack():
"""`tiprack` 子串包含 'rack',但应被 tip_rack 规则先抓到(顺序敏感)。"""
assert lm.infer_kind("opentrons_96_tiprack_300ul") == "tip_rack"
assert lm.infer_kind("opentrons_96_tiprack_20ul") == "tip_rack"
def test_infer_kind_tube_rack_variants():
assert (
lm.infer_kind("opentrons_24_tuberack_eppendorf_2ml_safelock_snapcap")
== "tube_rack"
)
assert lm.infer_kind("opentrons_10_tuberack_falcon_4x50ml_6x15ml_conical") == "tube_rack"
def test_infer_kind_object_overrides_string():
"""object 字段优先:即使字符串看起来像 platetrash / tiprack 也能强制归类。"""
assert lm.infer_kind("anything_at_all", "tiprack") == "tip_rack"
assert lm.infer_kind("opentrons_96_wellplate", "trash") == "trash"
def test_infer_kind_default_plate():
assert lm.infer_kind("opentrons_96_wellplate_300ul_pcr") == "plate"
assert lm.infer_kind("custom_384_wellplate_2200ul") == "plate"
def test_infer_kind_rack_without_tip_is_tube_rack():
"""复现历史 `_infer_reagent_kind` 中「含 rack 不含 tip → tube_rack」的语义。"""
assert lm.infer_kind("nest_4x6_rack") == "tube_rack"
def test_infer_kind_empty_hint_returns_plate():
assert lm.infer_kind("") == "plate"
assert lm.infer_kind(None) == "plate" # type: ignore[arg-type]
# ==================== resolve_target_classtarget_device="prcxi" ====================
@pytest.mark.parametrize(
"vol,want",
[
(1, "PRCXI_10uL_Tips"),
(9, "PRCXI_10uL_Tips"),
(10, "PRCXI_10uL_Tips"), # 闭区间 ≤10
(11, "PRCXI_300ul_Tips"),
(200, "PRCXI_300ul_Tips"),
(299.9, "PRCXI_300ul_Tips"),
(300, "PRCXI_1000uL_Tips"), # 300 上一档(与 <300 半开等价)
(500, "PRCXI_1000uL_Tips"),
(1000, "PRCXI_1000uL_Tips"),
],
)
def test_resolve_tip_volume_buckets(vol, want):
assert lm.resolve_target_class("prcxi", "tip_rack", 96, vol) == want
def test_resolve_tube_rack_holes():
assert lm.resolve_target_class("prcxi", "tube_rack", 24, None) == "PRCXI_EP_Adapter"
assert lm.resolve_target_class("prcxi", "tube_rack", 10, None) == "PRCXI_EP_Adapter"
def test_resolve_plate_holes():
assert lm.resolve_target_class("prcxi", "plate", 96, None) == "PRCXI_BioER_96_wellplate"
assert (
lm.resolve_target_class("prcxi", "plate", 384, None) == "PRCXI_BioER_384_wellplate"
)
def test_resolve_plate_unknown_holes_returns_none():
"""48 孔板未在 YAML 列出 → None交给 PRCXI 模板打分匹配 fallback。"""
assert lm.resolve_target_class("prcxi", "plate", 48, 2200) is None
def test_resolve_trash_any():
assert lm.resolve_target_class("prcxi", "trash", None, None) == "PRCXI_trash"
# trash 规则未约束 hole_count / volume所以任意值都命中
assert lm.resolve_target_class("prcxi", "trash", 0, 0) == "PRCXI_trash"
# ==================== YAML 缺失 / 热加载 ====================
def test_missing_yaml_uses_builtin(monkeypatch, tmp_path):
"""YAML 文件不存在时,应自动落到 `_BUILTIN_DEFAULT`,且打 warning。"""
bogus = tmp_path / "no_such_labware_mapping.yaml"
monkeypatch.setattr(lm, "_DEFAULT_PATH", bogus)
lm._load_mapping.cache_clear()
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
assert lm.remap_slot("4") == "13"
assert (
lm.resolve_target_class("prcxi", "plate", 96, None)
== "PRCXI_BioER_96_wellplate"
)
assert any("labware_mapping.yaml 未找到" in str(w.message) for w in caught)
def test_invalid_yaml_uses_builtin(monkeypatch, tmp_path):
"""YAML 解析失败也应回退到 builtin且打 warning。"""
bad = tmp_path / "labware_mapping.yaml"
bad.write_text("this is :: not valid: yaml: [unclosed", encoding="utf-8")
monkeypatch.setattr(lm, "_DEFAULT_PATH", bad)
lm._load_mapping.cache_clear()
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
assert lm.remap_slot("4") == "13"
assert any(
"labware_mapping.yaml 解析失败" in str(w.message)
or "labware_mapping.yaml 根不是 dict" in str(w.message)
for w in caught
)
def test_yaml_reload_after_edit(monkeypatch, tmp_path):
"""临时 YAML 覆盖 + reload_mapping → 新规则生效且原规则失效P6.1.1 schema"""
tmp_yaml = tmp_path / "labware_mapping.yaml"
tmp_yaml.write_text(
'kinds:\n'
" - { pattern: 'trash', kind: trash }\n"
" - { pattern: '.*', kind: plate }\n"
'target_devices:\n'
' default:\n'
' slot_remap:\n'
' default: {"4": "99"}\n'
' by_object: {}\n'
' rules:\n'
" - { kind: plate, hole_count: 96, class_name: PRCXI_FooPlate }\n"
' prcxi:\n'
' slot_remap:\n'
' default: {"4": "99"}\n'
' by_object: {}\n'
' rules:\n'
" - { kind: plate, hole_count: 96, class_name: PRCXI_FooPlate }\n",
encoding="utf-8",
)
monkeypatch.setattr(lm, "_DEFAULT_PATH", tmp_yaml)
lm.reload_mapping()
assert lm.remap_slot("4") == "99"
assert lm.resolve_target_class("prcxi", "plate", 96, None) == "PRCXI_FooPlate"
# 新表里只有 96没有 384 → None
assert lm.resolve_target_class("prcxi", "plate", 384, None) is None
# tube_rack / tip_rack 在新表里没规则 → None
assert lm.resolve_target_class("prcxi", "tip_rack", 96, 200) is None
def test_missing_section_uses_builtin(monkeypatch, tmp_path):
"""YAML 缺 `kinds` 段 → 该段使用 builtin其它段保留用户值P6.1.1 schema"""
partial = tmp_path / "labware_mapping.yaml"
partial.write_text(
'target_devices:\n'
' default:\n'
' slot_remap:\n'
' default: {"4": "88"}\n'
' by_object: {}\n'
' rules: []\n'
' prcxi:\n'
' slot_remap:\n'
' default: {"4": "88"}\n'
' by_object: {}\n'
' rules: []\n', # 故意没有 kinds 段
encoding="utf-8",
)
monkeypatch.setattr(lm, "_DEFAULT_PATH", partial)
lm._load_mapping.cache_clear()
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
# slot_remap 用 YAML 中的覆盖值
assert lm.remap_slot("4") == "88"
# kinds 段缺失 → 使用 builtin 的 tiprack 规则
assert lm.infer_kind("opentrons_96_tiprack_300ul") == "tip_rack"
assert any("缺少 `kinds` 段" in str(w.message) for w in caught)
# ==================== P6.1 新增用例 ====================
def test_resolve_target_class_prcxi_tip_buckets():
"""PRCXI tip 量程档≤10 / <300 / 否则 1000与历史 _tip_prcxi_class_for_max_ul 等价)。"""
assert lm.resolve_target_class("prcxi", "tip_rack", 96, 10) == "PRCXI_10uL_Tips"
assert lm.resolve_target_class("prcxi", "tip_rack", 96, 200) == "PRCXI_300ul_Tips"
assert lm.resolve_target_class("prcxi", "tip_rack", 96, 1000) == "PRCXI_1000uL_Tips"
def test_resolve_target_class_unknown_device_falls_back_to_default_section():
"""未声明的 target_device 自动回退到固定段 target_devices.default打 warning。
第一版 default 段内容按 prcxi 拷贝 → 断言caller 传 'tecan' 时,结果应等于查 default 段。"""
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
# tecan / beckman / 任意未声明名字 → 全部回退到固定段 "default"
assert (
lm.resolve_target_class("tecan", "tip_rack", 96, 200)
== lm.resolve_target_class("default", "tip_rack", 96, 200)
== "PRCXI_300ul_Tips" # 第一版 default 段按 prcxi 填,所以值仍是 PRCXI_*
)
assert (
lm.resolve_target_class("unknown_xxx", "plate", 96, None)
== lm.resolve_target_class("default", "plate", 96, None)
)
# 至少打 1 次 warning提示「未声明、已回退到 default 段」
assert any(
("未在 labware_mapping.yaml" in str(w.message))
or ("target_devices.default" in str(w.message))
for w in caught
)
def test_resolve_target_class_per_device_tip_buckets(tmp_path, monkeypatch):
"""**P6.1 核心断言**:不同 target_device 在同一体积下命中不同 tip 量程档P6.1.1 schema"""
yaml_path = tmp_path / "labware_mapping.yaml"
yaml_path.write_text(
'kinds: [{pattern: ".*", kind: plate}]\n'
'target_devices:\n'
' default:\n'
' slot_remap: {default: {}, by_object: {}}\n'
' rules:\n'
' - {kind: tip_rack, hole_count: 96, volume_max: 10, class_name: PRCXI_10uL_Tips}\n'
' - {kind: tip_rack, hole_count: 96, volume_max: 299.9, class_name: PRCXI_300ul_Tips}\n'
' - {kind: tip_rack, hole_count: 96, class_name: PRCXI_1000uL_Tips}\n'
' prcxi:\n'
' slot_remap: {default: {}, by_object: {}}\n'
' rules:\n'
' - {kind: tip_rack, hole_count: 96, volume_max: 10, class_name: PRCXI_10uL_Tips}\n'
' - {kind: tip_rack, hole_count: 96, volume_max: 299.9, class_name: PRCXI_300ul_Tips}\n'
' - {kind: tip_rack, hole_count: 96, class_name: PRCXI_1000uL_Tips}\n'
' beckman:\n'
' slot_remap: {default: {}, by_object: {}}\n'
' rules:\n'
' - {kind: tip_rack, hole_count: 96, volume_max: 20, class_name: Beckman_20uL_Tips}\n'
' - {kind: tip_rack, hole_count: 96, volume_max: 199.9, class_name: Beckman_200uL_Tips}\n'
' - {kind: tip_rack, hole_count: 96, class_name: Beckman_1000uL_Tips}\n',
encoding="utf-8",
)
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
lm.reload_mapping()
# 同样的体积 200prcxi 走 300 档、beckman 已超出 200 档 → 1000 档
assert lm.resolve_target_class("prcxi", "tip_rack", 96, 200) == "PRCXI_300ul_Tips"
assert lm.resolve_target_class("beckman", "tip_rack", 96, 200) == "Beckman_1000uL_Tips"
# 同样的体积 15prcxi 已超出 10 档 → 300 档beckman 仍在 20 档
assert lm.resolve_target_class("prcxi", "tip_rack", 96, 15) == "PRCXI_300ul_Tips"
assert lm.resolve_target_class("beckman", "tip_rack", 96, 15) == "Beckman_20uL_Tips"
def test_default_section_independent_from_prcxi(tmp_path, monkeypatch):
"""default 与 prcxi 是两段独立物料集:改 default 不影响 prcxi、改 prcxi 不影响 default。
断言:把 default 段改成 Generic_Plate96prcxi 段保持 PRCXI_Plate96 时,
caller 传未声明的名字回退到 default 拿 Generic_Plate96传 prcxi 仍拿 PRCXI_Plate96。
"""
yaml_path = tmp_path / "labware_mapping.yaml"
yaml_path.write_text(
'kinds: [{pattern: ".*", kind: plate}]\n'
'target_devices:\n'
' default:\n' # ← 独立改 default 段
' slot_remap: {default: {}, by_object: {}}\n'
' rules:\n'
' - {kind: plate, hole_count: 96, class_name: Generic_Plate96}\n'
' prcxi:\n' # ← prcxi 段保持 PRCXI_*
' slot_remap: {default: {}, by_object: {}}\n'
' rules:\n'
' - {kind: plate, hole_count: 96, class_name: PRCXI_Plate96}\n',
encoding="utf-8",
)
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
lm.reload_mapping()
# caller 传未声明的 tecan → 走 default 段 → Generic_*
assert lm.resolve_target_class("tecan", "plate", 96, None) == "Generic_Plate96"
# caller 显式传 prcxi → 走 prcxi 段 → PRCXI_***不**受 default 影响)
assert lm.resolve_target_class("prcxi", "plate", 96, None) == "PRCXI_Plate96"
# 显式传 "default" 也合法caller 可主动选择走 default 段)
assert lm.resolve_target_class("default", "plate", 96, None) == "Generic_Plate96"
def test_legacy_yaml_schema_rejected_with_warning(tmp_path, monkeypatch):
"""旧 schemavendors / prcxi_class应被拒绝 + warning + 整段 fallback 到 builtinP6.1.1 schema"""
legacy = tmp_path / "labware_mapping.yaml"
legacy.write_text(
'kinds: [{pattern: ".*", kind: plate}]\n'
'vendors:\n' # ← 旧顶层 key
' opentrons:\n'
' rules:\n'
" - {kind: plate, hole_count: 96, prcxi_class: PRCXI_FooPlate}\n", # ← 旧字段
encoding="utf-8",
)
monkeypatch.setattr(lm, "_DEFAULT_PATH", legacy)
lm._load_mapping.cache_clear()
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
# 整段走 builtin → 96 板还是 PRCXI_BioER_96_wellplate**不是**用户旧 YAML 中的 PRCXI_FooPlate
assert lm.resolve_target_class("prcxi", "plate", 96, None) == "PRCXI_BioER_96_wellplate"
assert any(
("旧 schema" in str(w.message))
or ("vendors" in str(w.message))
or ("prcxi_class" in str(w.message))
for w in caught
)
def test_resolve_target_class_unknown_kind_returns_none():
"""target_device 存在、kind 不存在 → None。"""
assert lm.resolve_target_class("prcxi", "reservoir", 12, None) is None
# ==================== P6.1.1 新增用例slot_remap 按 device + model 分叉) ====================
def test_remap_slot_model_level_overrides_device_level(tmp_path, monkeypatch):
"""型号级 slot_remap 优先级 > 厂商级。"""
yaml_path = tmp_path / "labware_mapping.yaml"
yaml_path.write_text(
'kinds: [{pattern: ".*", kind: plate}]\n'
'target_devices:\n'
' default:\n'
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
' rules: []\n'
' prcxi:\n'
' slot_remap: {default: {"4": "13"}, by_object: {trash: {"12": "16"}}}\n'
' rules: []\n'
' models:\n'
' "4040":\n'
' slot_remap: {default: {"4": "16"}, by_object: {}}\n',
encoding="utf-8",
)
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
lm.reload_mapping()
# device 级(不传 model→ "13"
assert lm.remap_slot("4", target_device="prcxi") == "13"
# model "4040" 覆盖 → "16"
assert lm.remap_slot("4", target_device="prcxi", target_model="4040") == "16"
# model "9320" 未声明 → 静默 fallback 到 device 级 → "13"
assert lm.remap_slot("4", target_device="prcxi", target_model="9320") == "13"
def test_remap_slot_model_inherits_device_when_field_missing(tmp_path, monkeypatch):
"""model 子段声明但 slot_remap 字段缺失 → 静默继承厂商级rules 同理。"""
yaml_path = tmp_path / "labware_mapping.yaml"
yaml_path.write_text(
'kinds: [{pattern: ".*", kind: plate}]\n'
'target_devices:\n'
' default:\n'
' slot_remap: {default: {}, by_object: {}}\n'
' rules: []\n'
' prcxi:\n'
' slot_remap: {default: {"4": "13", "8": "14"}, by_object: {}}\n'
' rules: [{kind: plate, hole_count: 96, class_name: PRCXI_PlateA}]\n'
' models:\n'
' "9320":\n'
' rules: [{kind: plate, hole_count: 96, class_name: PRCXI_PlateB}]\n', # 仅覆盖 rules未声明 slot_remap
encoding="utf-8",
)
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
lm.reload_mapping()
# model 9320 的 slot_remap 缺字段 → 继承 prcxi.slot_remap → "4" → "13"
assert lm.remap_slot("4", target_device="prcxi", target_model="9320") == "13"
# model 9320 的 rules 覆盖 → PRCXI_PlateB
assert (
lm.resolve_target_class("prcxi", "plate", 96, None, target_model="9320")
== "PRCXI_PlateB"
)
# 不传 model → 用厂商级 rules → PRCXI_PlateA
assert lm.resolve_target_class("prcxi", "plate", 96, None) == "PRCXI_PlateA"
def test_legacy_top_level_slot_remap_rejected(tmp_path, monkeypatch):
"""P6.1.1:顶层 slot_remap 段被视为旧 schema → warning + 整段 fallback 到 builtin。"""
legacy = tmp_path / "labware_mapping.yaml"
legacy.write_text(
'slot_remap:\n' # ← P6.1.1 已不支持的顶层段
' default: {"4": "99"}\n'
' by_object: {}\n'
'kinds: [{pattern: ".*", kind: plate}]\n'
'target_devices:\n'
' default:\n'
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
' rules: []\n'
' prcxi:\n'
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
' rules: []\n',
encoding="utf-8",
)
monkeypatch.setattr(lm, "_DEFAULT_PATH", legacy)
lm._load_mapping.cache_clear()
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
# 整段走 builtin → "4" 仍然 → "13"builtin 值),**不是** YAML 顶层的 "99"
assert lm.remap_slot("4", target_device="prcxi") == "13"
assert any(
("顶层" in str(w.message) and "slot_remap" in str(w.message))
or ("旧 schema" in str(w.message))
for w in caught
)
def test_remap_slot_unknown_device_falls_back_with_warning(tmp_path, monkeypatch):
"""未声明的 target_device → fallback 到 default.slot_remap + warning与 resolve_target_class 同语义)。"""
yaml_path = tmp_path / "labware_mapping.yaml"
yaml_path.write_text(
'kinds: [{pattern: ".*", kind: plate}]\n'
'target_devices:\n'
' default:\n'
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
' rules: []\n'
' prcxi:\n'
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
' rules: []\n',
encoding="utf-8",
)
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
lm.reload_mapping()
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
assert lm.remap_slot("4", target_device="tecan") == "13" # fallback 到 default
assert any(
("tecan" in str(w.message)) or ("target_devices.default" in str(w.message))
for w in caught
)
def test_remap_slot_model_only_no_device_passthrough(tmp_path, monkeypatch):
"""caller 传 target_model 但 target_device 段不存在 → 直接走 default.slot_remapmodel 名忽略)。"""
yaml_path = tmp_path / "labware_mapping.yaml"
yaml_path.write_text(
'kinds: [{pattern: ".*", kind: plate}]\n'
'target_devices:\n'
' default:\n'
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
' rules: []\n', # 没有 prcxi 段
encoding="utf-8",
)
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
lm.reload_mapping()
with warnings.catch_warnings(record=True):
warnings.simplefilter("always")
# target_device "prcxi" 不存在、target_model 即使传也忽略 → 走 default
assert lm.remap_slot("4", target_device="prcxi", target_model="9320") == "13"
def test_default_section_models_subsection_warns(tmp_path, monkeypatch):
"""target_devices.default.models 不被支持 → warning但 default.slot_remap 仍生效。"""
yaml_path = tmp_path / "labware_mapping.yaml"
yaml_path.write_text(
'kinds: [{pattern: ".*", kind: plate}]\n'
'target_devices:\n'
' default:\n'
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
' rules: []\n'
' models:\n' # ← default 段不支持 models
' "ghost":\n'
' slot_remap: {default: {"4": "99"}, by_object: {}}\n'
' prcxi:\n'
' slot_remap: {default: {"4": "13"}, by_object: {}}\n'
' rules: []\n',
encoding="utf-8",
)
monkeypatch.setattr(lm, "_DEFAULT_PATH", yaml_path)
lm._load_mapping.cache_clear()
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
# default 段的 models 被忽略 → 走 default.slot_remap → "13"(不是 "99"
assert lm.remap_slot("4", target_device="tecan", target_model="ghost") == "13"
assert any(
("default" in str(w.message) and "models" in str(w.message))
for w in caught
)

View File

@@ -1,178 +0,0 @@
"""``unilabos.workflow.wf_utils.upload_workflow`` 工作流名称 fallback 链单元测试。
对应需求:上传工作流时,**优先取 metadata.workflow_name**;缺失时再回退到顶层
``workflow_name``(旧 node-link 形态遗留字段);最后才回退到文件名(去 ``.json`` 后缀)。
CLI 显式 ``-n/--workflow_name`` 永远最优先。
本测试只校验「**名称 fallback 链 + tags fallback 链**」的纯逻辑路径,
不实际访问 HTTP / 后端;通过 monkeypatch 把 ``http_client.workflow_import``
桩成可观察的捕获函数。
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
import pytest
# 让 import 走 Uni-Lab-OS 包根
ROOT = Path(__file__).resolve().parents[2]
SRC = ROOT / "unilabos"
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
@pytest.fixture
def stub_upload(monkeypatch, tmp_path):
"""Monkeypatch ``http_client.workflow_import`` + ``_convert_to_node_link``
返回 (helper, captured) 二元组:
- ``helper(workflow_data, **upload_kwargs)`` 写入 tmp_path/wf.json
并调用 ``upload_workflow``
- ``captured`` 是 dict记录 ``workflow_import`` 实际收到的 kwargs
以及 ``_convert_to_node_link`` 是否被调过。
本测试不依赖真实 ``unilabos.app.web``(其级联依赖含 ``fastapi`` 等重型
package本地 dev venv 不必装)。通过在 sys.modules 注入空壳 module 拦截
delayed import。
"""
import types
captured: Dict[str, Any] = {"workflow_import_kwargs": None, "converted": False}
def fake_workflow_import(**kwargs): # noqa: ANN003
captured["workflow_import_kwargs"] = kwargs
return {"code": 0, "data": {"uuid": "fake-uuid", "name": kwargs.get("name")}}
# 关键:在 wf_utils 触发 `from unilabos.app.web import http_client` 之前
# 用空壳 module 占位(避免触发真实 web 包的 fastapi 依赖链)。
fake_http_client = types.ModuleType("unilabos.app.web.http_client")
fake_http_client.workflow_import = fake_workflow_import # type: ignore[attr-defined]
fake_web_pkg = types.ModuleType("unilabos.app.web")
fake_web_pkg.http_client = fake_http_client # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "unilabos.app.web", fake_web_pkg)
monkeypatch.setitem(sys.modules, "unilabos.app.web.http_client", fake_http_client)
from unilabos.workflow import wf_utils
# _convert_to_node_link 走真实路径会拉重型依赖,这里桩为 node-link 直返回
def fake_convert_to_node_link(workflow_file, workflow_data, *, target_device="prcxi", target_model=None):
captured["converted"] = True
# 返回最小合法 node-link 形态(不带 metadata模拟当前行为
return {"nodes": [], "edges": [], "workflow_uuid": ""}
monkeypatch.setattr(wf_utils, "_convert_to_node_link", fake_convert_to_node_link)
def helper(workflow_data: Dict[str, Any], **upload_kwargs: Any) -> Dict[str, Any]:
wf_path = tmp_path / "transfer_actions_sample.json"
wf_path.write_text(json.dumps(workflow_data, ensure_ascii=False), encoding="utf-8")
return wf_utils.upload_workflow(str(wf_path), **upload_kwargs)
return helper, captured
# ==================== workflow_name fallback 链 ====================
def test_metadata_workflow_name_wins_over_filename(stub_upload):
"""P5 主路径transfer_actions JSON 含 metadata.workflow_name → 优先于文件名。"""
helper, captured = stub_upload
data = {
"metadata": {"workflow_name": "PCR Prep with Categories", "tags": []},
"workflow": [],
"reagent": {},
}
helper(data)
kwargs = captured["workflow_import_kwargs"]
assert kwargs is not None and captured["converted"] is True
assert kwargs["name"] == "PCR Prep with Categories"
assert kwargs["workflow_name"] == "PCR Prep with Categories"
def test_cli_workflow_name_overrides_metadata(stub_upload):
"""CLI 显式 -n/--workflow_name 永远最优先。"""
helper, captured = stub_upload
data = {
"metadata": {"workflow_name": "Metadata Wins By Default"},
"workflow": [],
"reagent": {},
}
helper(data, workflow_name="CLI Override Name")
kwargs = captured["workflow_import_kwargs"]
assert kwargs["name"] == "CLI Override Name"
assert kwargs["workflow_name"] == "CLI Override Name"
def test_filename_used_when_no_metadata_and_no_legacy(stub_upload):
"""P5 之前的旧文件、且无顶层 workflow_name → 回退到去 .json 后缀的文件名。"""
helper, captured = stub_upload
data = {"workflow": [], "reagent": {}} # 既无 metadata也无 workflow_name
helper(data)
kwargs = captured["workflow_import_kwargs"]
# 文件名由 fixture 固定为 transfer_actions_sample.json
assert kwargs["name"] == "transfer_actions_sample"
assert kwargs["workflow_name"] == "transfer_actions_sample"
def test_metadata_empty_string_falls_back_to_filename(stub_upload):
"""metadata.workflow_name 为空字符串(而非缺失)也应回退到文件名。"""
helper, captured = stub_upload
data = {
"metadata": {"workflow_name": " "}, # whitespace-only
"workflow": [],
"reagent": {},
}
helper(data)
kwargs = captured["workflow_import_kwargs"]
assert kwargs["name"] == "transfer_actions_sample"
def test_legacy_top_level_workflow_name_used_when_metadata_missing(stub_upload, monkeypatch):
"""旧 node-link 文件(已是 nodes/edges 形态)顶层 workflow_name → 应被使用。
覆盖路径:``_is_node_link_format`` 直接命中 → 不走转换 → workflow_data 保留顶层
workflow_name``orig_metadata`` 为空时 fallback 到该字段。
"""
helper, captured = stub_upload
data = {
"nodes": [],
"edges": [],
"workflow_name": "Legacy Top Name",
}
helper(data)
kwargs = captured["workflow_import_kwargs"]
assert captured["converted"] is False, "node-link 输入不应触发转换"
assert kwargs["name"] == "Legacy Top Name"
assert kwargs["workflow_name"] == "Legacy Top Name"
# ==================== tags fallback 链 ====================
def test_metadata_tags_used_when_cli_tags_missing(stub_upload):
"""P5 主路径metadata.tags 在 CLI 未传 tags 时被使用。"""
helper, captured = stub_upload
data = {
"metadata": {"workflow_name": "X", "tags": ["Opentrons", "PCR"]},
"workflow": [],
"reagent": {},
}
helper(data)
kwargs = captured["workflow_import_kwargs"]
assert kwargs["tags"] == ["Opentrons", "PCR"]
def test_cli_tags_override_metadata_tags(stub_upload):
"""CLI 显式 --tags 优先于 metadata.tags。"""
helper, captured = stub_upload
data = {
"metadata": {"workflow_name": "X", "tags": ["Opentrons", "PCR"]},
"workflow": [],
"reagent": {},
}
helper(data, tags=["CLI", "Wins"])
kwargs = captured["workflow_import_kwargs"]
assert kwargs["tags"] == ["CLI", "Wins"]

View File

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

6
unilabos/__main__.py Normal file
View File

@@ -0,0 +1,6 @@
"""Entry point for `python -m unilabos`."""
from unilabos.app.main import main
if __name__ == "__main__":
main()

View File

@@ -1,10 +1,8 @@
import argparse
import asyncio
import os
import platform
import shutil
import signal
import subprocess
import sys
import threading
import time
@@ -12,15 +10,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))
@@ -35,84 +24,6 @@ from unilabos.config.config import load_config, BasicConfig, HTTPConfig
_restart_requested: bool = False
_restart_reason: str = ""
RESTART_EXIT_CODE = 42
def _build_child_argv():
"""Build sys.argv for child process, stripping supervisor-only arguments."""
result = []
skip_next = False
for arg in sys.argv:
if skip_next:
skip_next = False
continue
if arg in ("--restart_mode", "--restart-mode"):
continue
if arg in ("--auto_restart_count", "--auto-restart-count"):
skip_next = True
continue
if arg.startswith("--auto_restart_count=") or arg.startswith("--auto-restart-count="):
continue
result.append(arg)
return result
def _run_as_supervisor(max_restarts: int):
"""
Supervisor process that spawns and monitors child processes.
Similar to Uvicorn's --reload: the supervisor itself does no heavy work,
it only launches the real process as a child and restarts it when the child
exits with RESTART_EXIT_CODE.
"""
child_argv = [sys.executable] + _build_child_argv()
restart_count = 0
print_status(
f"[Supervisor] Restart mode enabled (max restarts: {max_restarts}), "
f"child command: {' '.join(child_argv)}",
"info",
)
while True:
print_status(
f"[Supervisor] Launching process (restart {restart_count}/{max_restarts})...",
"info",
)
try:
process = subprocess.Popen(child_argv)
exit_code = process.wait()
except KeyboardInterrupt:
print_status("[Supervisor] Interrupted, terminating child process...", "info")
process.terminate()
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
sys.exit(1)
if exit_code == RESTART_EXIT_CODE:
restart_count += 1
if restart_count > max_restarts:
print_status(
f"[Supervisor] Maximum restart count ({max_restarts}) reached, exiting",
"warning",
)
sys.exit(1)
print_status(
f"[Supervisor] Child requested restart ({restart_count}/{max_restarts}), restarting in 2s...",
"info",
)
time.sleep(2)
else:
if exit_code != 0:
print_status(f"[Supervisor] Child exited with code {exit_code}", "warning")
else:
print_status("[Supervisor] Child exited normally", "info")
sys.exit(exit_code)
def load_config_from_file(config_path):
if config_path is None:
@@ -154,13 +65,6 @@ def parse_args():
action="append",
help="Path to the registry directory",
)
parser.add_argument(
"--devices",
type=str,
default=None,
action="append",
help="Path to Python code directory for AST-based device/resource scanning",
)
parser.add_argument(
"--working_dir",
type=str,
@@ -242,7 +146,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(
@@ -250,53 +154,23 @@ def parse_args():
action="store_true",
help="Skip environment dependency check on startup",
)
parser.add_argument(
"--complete_registry",
action="store_true",
default=False,
help="Complete registry information",
)
parser.add_argument(
"--check_mode",
action="store_true",
default=False,
help="Run in check mode for CI: validates registry imports and ensures no file changes",
)
parser.add_argument(
"--complete_registry",
action="store_true",
default=False,
help="Complete and rewrite YAML registry files using AST analysis results",
)
parser.add_argument(
"--no_update_feedback",
action="store_true",
help="Disable sending update feedback to server",
)
parser.add_argument(
"--test_mode",
action="store_true",
default=False,
help="Test mode: all actions simulate execution and return mock results without running real hardware",
)
parser.add_argument(
"--external_devices_only",
action="store_true",
default=False,
help="Only load external device packages (--devices), skip built-in unilabos/devices/ scanning and YAML device registry",
)
parser.add_argument(
"--extra_resource",
action="store_true",
default=False,
help="Load extra lab_ prefixed labware resources (529 auto-generated definitions from lab_resources.py)",
)
parser.add_argument(
"--restart_mode",
action="store_true",
default=False,
help="Enable supervisor mode: automatically restart the process when triggered via WebSocket",
)
parser.add_argument(
"--auto_restart_count",
type=int,
default=500,
help="Maximum number of automatic restarts in restart mode (default: 500)",
)
# workflow upload subcommand
workflow_parser = subparsers.add_parser(
"workflow_upload",
@@ -330,33 +204,6 @@ def parse_args():
default=False,
help="Whether to publish the workflow (default: False)",
)
workflow_parser.add_argument(
"--description",
type=str,
default="",
help="Workflow description, used when publishing the workflow",
)
workflow_parser.add_argument(
"--target_device",
type=str,
default="prcxi",
help=(
"Target instrument name at vendor granularity (e.g. 'prcxi', 'beckman', 'tecan'). "
"Decides which target_devices.<name>.rules section in labware_mapping.yaml is used. "
"Unknown names fall back to target_devices.default. Default: 'prcxi'."
),
)
workflow_parser.add_argument(
"--target_model",
type=str,
default=None,
help=(
"Optional target instrument model name within the same vendor (e.g. '9320', '4040'). "
"Used to look up target_devices.<target_device>.models.<target_model>.slot_remap / "
".rules for model-specific deck layout or rule overrides. Falls back to the vendor-level "
"configuration when omitted or the model is not declared. Default: None."
),
)
return parser
@@ -368,88 +215,68 @@ def main():
args = parser.parse_args()
args_dict = vars(args)
# Supervisor mode: spawn child processes and monitor for restart
if args_dict.get("restart_mode", False):
_run_as_supervisor(args_dict.get("auto_restart_count", 5))
return
# 环境检查 - 检查并自动安装必需的包 (可选)
skip_env_check = args_dict.get("skip_env_check", False)
check_mode = args_dict.get("check_mode", False)
if not skip_env_check:
from unilabos.utils.environment_check import check_environment, check_device_package_requirements
from unilabos.utils.environment_check import check_environment
if not check_environment(auto_install=True):
print_status("环境检查失败,程序退出", "error")
os._exit(1)
# 第一次设备包依赖检查build_registry 之前,确保 import map 可用
devices_dirs_for_req = args_dict.get("devices", None)
if devices_dirs_for_req:
if not check_device_package_requirements(devices_dirs_for_req):
print_status("设备包依赖检查失败,程序退出", "error")
os._exit(1)
else:
print_status("跳过环境依赖检查", "warning")
# 加载配置文件优先加载config然后从env读取
config_path = args_dict.get("config")
# === 解析 working_dir ===
# 规则1: working_dir 传入 → 检测 unilabos_data 子目录,已是则不修改
# 规则2: 仅 config_path 传入 → 用其父目录作为 working_dir
# 规则4: 两者都传入 → 各用各的,但 working_dir 仍做 unilabos_data 子目录检测
raw_working_dir = args_dict.get("working_dir")
if raw_working_dir:
working_dir = os.path.abspath(raw_working_dir)
elif config_path and os.path.exists(config_path):
working_dir = os.path.dirname(os.path.abspath(config_path))
else:
if check_mode:
args_dict["working_dir"] = os.path.abspath(os.getcwd())
# 当 skip_env_check 时,默认使用当前目录作为 working_dir
if skip_env_check and not args_dict.get("working_dir") and not config_path:
working_dir = os.path.abspath(os.getcwd())
# unilabos_data 子目录自动检测
if os.path.basename(working_dir) != "unilabos_data":
unilabos_data_sub = os.path.join(working_dir, "unilabos_data")
if os.path.isdir(unilabos_data_sub):
working_dir = unilabos_data_sub
elif not raw_working_dir and not (config_path and os.path.exists(config_path)):
# 未显式指定路径,默认使用 cwd/unilabos_data
working_dir = os.path.abspath(os.path.join(os.getcwd(), "unilabos_data"))
# === 解析 config_path ===
if config_path and not os.path.exists(config_path):
# config_path 传入但不存在,尝试在 working_dir 中查找
candidate = os.path.join(working_dir, "local_config.py")
if os.path.exists(candidate):
config_path = candidate
print_status(f"在工作目录中发现配置文件: {config_path}", "info")
else:
print_status(
f"配置文件 {config_path} 不存在,工作目录 {working_dir} 中也未找到 local_config.py"
f"请通过 --config 传入 local_config.py 文件路径",
"error",
)
os._exit(1)
elif not config_path:
# 规则3: 未传入 config_path尝试 working_dir/local_config.py
candidate = os.path.join(working_dir, "local_config.py")
if os.path.exists(candidate):
config_path = candidate
print_status(f"跳过环境检查模式:使用当前目录作为工作目录 {working_dir}", "info")
# 检查当前目录是否有 local_config.py
local_config_in_cwd = os.path.join(working_dir, "local_config.py")
if os.path.exists(local_config_in_cwd):
config_path = local_config_in_cwd
print_status(f"发现本地配置文件: {config_path}", "info")
else:
print_status(f"未指定config路径可通过 --config 传入 local_config.py 文件路径", "info")
print_status(f"您是否为第一次使用?并将当前路径 {working_dir} 作为工作目录? (Y/n)", "info")
if check_mode or input() != "n":
os.makedirs(working_dir, exist_ok=True)
config_path = os.path.join(working_dir, "local_config.py")
shutil.copy(
os.path.join(os.path.dirname(os.path.dirname(__file__)), "config", "example_config.py"),
config_path,
elif os.getcwd().endswith("unilabos_data"):
working_dir = os.path.abspath(os.getcwd())
else:
working_dir = os.path.abspath(os.path.join(os.getcwd(), "unilabos_data"))
if args_dict.get("working_dir"):
working_dir = args_dict.get("working_dir", "")
if config_path and not os.path.exists(config_path):
config_path = os.path.join(working_dir, "local_config.py")
if not os.path.exists(config_path):
print_status(
f"当前工作目录 {working_dir} 未找到local_config.py请通过 --config 传入 local_config.py 文件路径",
"error",
)
print_status(f"已创建 local_config.py 路径: {config_path}", "info")
else:
os._exit(1)
elif config_path and os.path.exists(config_path):
working_dir = os.path.dirname(config_path)
elif os.path.exists(working_dir) and os.path.exists(os.path.join(working_dir, "local_config.py")):
config_path = os.path.join(working_dir, "local_config.py")
elif not skip_env_check and not config_path and (
not os.path.exists(working_dir) or not os.path.exists(os.path.join(working_dir, "local_config.py"))
):
print_status(f"未指定config路径可通过 --config 传入 local_config.py 文件路径", "info")
print_status(f"您是否为第一次使用?并将当前路径 {working_dir} 作为工作目录? (Y/n)", "info")
if input() != "n":
os.makedirs(working_dir, exist_ok=True)
config_path = os.path.join(working_dir, "local_config.py")
shutil.copy(
os.path.join(os.path.dirname(os.path.dirname(__file__)), "config", "example_config.py"), config_path
)
print_status(f"已创建 local_config.py 路径: {config_path}", "info")
else:
os._exit(1)
# 加载配置文件 (check_mode 跳过)
print_status(f"当前工作目录为 {working_dir}", "info")
@@ -461,17 +288,15 @@ def main():
if hasattr(BasicConfig, "log_level"):
logger.info(f"Log level set to '{BasicConfig.log_level}' from config file.")
file_path = configure_logger(loglevel=BasicConfig.log_level, working_dir=working_dir)
if file_path is not None:
logger.info(f"[LOG_FILE] {file_path}")
configure_logger(loglevel=BasicConfig.log_level, working_dir=working_dir)
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"
@@ -507,66 +332,46 @@ def main():
BasicConfig.slave_no_host = args_dict.get("slave_no_host", False)
BasicConfig.upload_registry = args_dict.get("upload_registry", False)
BasicConfig.no_update_feedback = args_dict.get("no_update_feedback", False)
BasicConfig.test_mode = args_dict.get("test_mode", False)
if BasicConfig.test_mode:
print_status("启用测试模式:所有动作将模拟执行,不调用真实硬件", "warning")
BasicConfig.extra_resource = args_dict.get("extra_resource", False)
if BasicConfig.extra_resource:
print_status("启用额外资源加载将加载lab_开头的labware资源定义", "info")
BasicConfig.communication_protocol = "websocket"
machine_name = platform.node()
machine_name = os.popen("hostname").read().strip()
machine_name = "".join([c if c.isalnum() or c == "_" else "_" for c in machine_name])
BasicConfig.machine_name = machine_name
BasicConfig.vis_2d_enable = args_dict["2d_vis"]
BasicConfig.check_mode = check_mode
from unilabos.registry.registry import build_registry
# 显示启动横幅
print_unilab_banner(args_dict)
# Step 0: AST 分析优先 + YAML 注册表加载
# check_mode 和 upload_registry 都会执行实际 import 验证
devices_dirs = args_dict.get("devices", None)
complete_registry = args_dict.get("complete_registry", False) or check_mode
external_only = args_dict.get("external_devices_only", False)
lab_registry = build_registry(
registry_paths=args_dict["registry_path"],
devices_dirs=devices_dirs,
upload_registry=BasicConfig.upload_registry,
check_mode=check_mode,
complete_registry=complete_registry,
external_only=external_only,
)
# Check mode: 注册表验证完成后直接退出
if check_mode:
device_count = len(lab_registry.device_type_registry)
resource_count = len(lab_registry.resource_type_registry)
print_status(f"Check mode: 注册表验证完成 ({device_count} 设备, {resource_count} 资源),退出", "info")
os._exit(0)
# 以下导入依赖 ROS2 环境check_mode 已退出不需要
from unilabos.resources.graphio import (
read_node_link_json,
read_graphml,
dict_from_graph,
modify_to_backend_format,
)
from unilabos.app.communication import get_communication_client
from unilabos.registry.registry import build_registry
from unilabos.app.backend import start_backend
from unilabos.app.web import http_client
from unilabos.app.web import start_server
from unilabos.app.register import register_devices_and_resources
from unilabos.resources.graphio import modify_to_backend_format
from unilabos.resources.resource_tracker import ResourceTreeSet, ResourceDict
# Step 1: 上传全部注册表到服务端,同步保存到 unilabos_data
# 显示启动横幅
print_unilab_banner(args_dict)
# 注册表 - check_mode 时强制启用 complete_registry
complete_registry = args_dict.get("complete_registry", False) or check_mode
lab_registry = build_registry(args_dict["registry_path"], complete_registry, BasicConfig.upload_registry)
# Check mode: complete_registry 完成后直接退出git diff 检测由 CI workflow 执行
if check_mode:
print_status("Check mode: complete_registry 完成,退出", "info")
os._exit(0)
if BasicConfig.upload_registry:
# 设备注册到服务端 - 需要 ak 和 sk
if BasicConfig.ak and BasicConfig.sk:
# print_status("开始注册设备到服务端...", "info")
print_status("开始注册设备到服务端...", "info")
try:
register_devices_and_resources(lab_registry)
# print_status("设备注册完成", "info")
print_status("设备注册完成", "info")
except Exception as e:
print_status(f"设备注册失败: {e}", "error")
else:
@@ -583,7 +388,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
@@ -651,16 +456,12 @@ def main():
continue
# 如果从远端获取了物料信息,则与本地物料进行同步
if file_path is not None and request_startup_json and "nodes" in request_startup_json:
if request_startup_json and "nodes" in request_startup_json:
print_status("开始同步远端物料到本地...", "info")
remote_tree_set = ResourceTreeSet.from_raw_dict_list(request_startup_json["nodes"])
resource_tree_set.merge_remote_resources(remote_tree_set)
print_status("远端物料同步完成", "info")
# 第二次设备包依赖检查云端物料同步后community 包可能引入新的 requirements
# TODO: 当 community device package 功能上线后,在这里调用
# install_requirements_txt(community_pkg_path / "requirements.txt", label="community.xxx")
# 使用 ResourceTreeSet 代替 list
args_dict["resources_config"] = resource_tree_set
args_dict["devices_config"] = resource_tree_set
@@ -752,10 +553,6 @@ def main():
open_browser=not args_dict["disable_browser"],
port=BasicConfig.port,
)
if restart_requested:
print_status("[Main] Restart requested, cleaning up...", "info")
cleanup_for_restart()
os._exit(RESTART_EXIT_CODE)
if __name__ == "__main__":

View File

@@ -54,7 +54,6 @@ class JobAddReq(BaseModel):
action_type: str = Field(
examples=["unilabos_msgs.action._str_single_input.StrSingleInput"], description="action type", default=""
)
sample_material: dict = Field(examples=[{"string": "string"}], description="sample uuid to material uuid")
action_args: dict = Field(examples=[{"string": "string"}], description="action arguments", default_factory=dict)
task_id: str = Field(examples=["task_id"], description="task uuid (auto-generated if empty)", default="")
job_id: str = Field(examples=["job_id"], description="goal uuid (auto-generated if empty)", default="")

View File

@@ -1,8 +1,9 @@
import json
import time
from typing import Any, Dict, Optional, Tuple
from typing import Optional, Tuple, Dict, Any
from unilabos.utils.log import logger
from unilabos.utils.tools import normalize_json as _normalize_device
from unilabos.utils.type_check import TypeEncoder
def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[Tuple[Dict[str, Any], Dict[str, Any]]]:
@@ -10,63 +11,50 @@ def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[
注册设备和资源到服务器仅支持HTTP
"""
# 注册资源信息 - 使用HTTP方式
from unilabos.app.web.client import http_client
logger.info("[UniLab Register] 开始注册设备和资源...")
# 注册设备信息
devices_to_register = {}
for device_info in lab_registry.obtain_registry_device_info():
devices_to_register[device_info["id"]] = _normalize_device(device_info)
logger.trace(f"[UniLab Register] 收集设备: {device_info['id']}")
devices_to_register[device_info["id"]] = json.loads(
json.dumps(device_info, ensure_ascii=False, cls=TypeEncoder)
)
logger.debug(f"[UniLab Register] 收集设备: {device_info['id']}")
resources_to_register = {}
for resource_info in lab_registry.obtain_registry_resource_info():
resources_to_register[resource_info["id"]] = resource_info
logger.trace(f"[UniLab Register] 收集资源: {resource_info['id']}")
logger.debug(f"[UniLab Register] 收集资源: {resource_info['id']}")
if gather_only:
return devices_to_register, resources_to_register
# 注册设备
if devices_to_register:
try:
start_time = time.time()
response = http_client.resource_registry(
{"resources": list(devices_to_register.values())},
tag="device_registry",
)
response = http_client.resource_registry({"resources": list(devices_to_register.values())})
cost_time = time.time() - start_time
res_data = response.json() if response.status_code == 200 else {}
skipped = res_data.get("data", {}).get("skipped", False)
if skipped:
logger.info(
f"[UniLab Register] 设备注册跳过(内容未变化)"
f" {len(devices_to_register)}{cost_time:.3f}s"
)
elif response.status_code in [200, 201]:
logger.info(f"[UniLab Register] 成功注册 {len(devices_to_register)} 个设备 {cost_time:.3f}s")
if response.status_code in [200, 201]:
logger.info(f"[UniLab Register] 成功注册 {len(devices_to_register)} 个设备 {cost_time}ms")
else:
logger.error(f"[UniLab Register] 设备注册失败: {response.status_code}, {response.text} {cost_time:.3f}s")
logger.error(f"[UniLab Register] 设备注册失败: {response.status_code}, {response.text} {cost_time}ms")
except Exception as e:
logger.error(f"[UniLab Register] 设备注册异常: {e}")
# 注册资源
if resources_to_register:
try:
start_time = time.time()
response = http_client.resource_registry(
{"resources": list(resources_to_register.values())},
tag="resource_registry",
)
response = http_client.resource_registry({"resources": list(resources_to_register.values())})
cost_time = time.time() - start_time
res_data = response.json() if response.status_code == 200 else {}
skipped = res_data.get("data", {}).get("skipped", False)
if skipped:
logger.info(
f"[UniLab Register] 资源注册跳过(内容未变化)"
f" {len(resources_to_register)}{cost_time:.3f}s"
)
elif response.status_code in [200, 201]:
logger.info(f"[UniLab Register] 成功注册 {len(resources_to_register)} 个资源 {cost_time:.3f}s")
if response.status_code in [200, 201]:
logger.info(f"[UniLab Register] 成功注册 {len(resources_to_register)} 个资源 {cost_time}ms")
else:
logger.error(f"[UniLab Register] 资源注册失败: {response.status_code}, {response.text} {cost_time:.3f}s")
logger.error(f"[UniLab Register] 资源注册失败: {response.status_code}, {response.text} {cost_time}ms")
except Exception as e:
logger.error(f"[UniLab Register] 资源注册异常: {e}")
logger.info("[UniLab Register] 设备和资源注册完成.")

View File

@@ -1052,7 +1052,7 @@ async def handle_file_import(websocket: WebSocket, request_data: dict):
"result": {},
"schema": lab_registry._generate_unilab_json_command_schema(v["args"], k),
"goal_default": {i["name"]: i["default"] for i in v["args"]},
"handles": {},
"handles": [],
}
# 不生成已配置action的动作
for k, v in enhanced_info["action_methods"].items()
@@ -1340,5 +1340,5 @@ def setup_api_routes(app):
# 启动广播任务
@app.on_event("startup")
async def startup_event():
asyncio.create_task(broadcast_device_status(), name="web-api-startup-device")
asyncio.create_task(broadcast_status_page_data(), name="web-api-startup-status")
asyncio.create_task(broadcast_device_status())
asyncio.create_task(broadcast_status_page_data())

View File

@@ -3,13 +3,11 @@ HTTP客户端模块
提供与远程服务器通信的客户端功能只有host需要用
"""
import gzip
import json
import os
from typing import List, Dict, Any, Optional
from unilabos.utils.tools import fast_dumps as _fast_dumps, fast_dumps_pretty as _fast_dumps_pretty
import requests
from unilabos.resources.resource_tracker import ResourceTreeSet
from unilabos.utils.log import info
@@ -36,9 +34,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 +46,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 +73,25 @@ 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"}
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": [x for xs in resources.dump() for x in xs], "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": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid},
headers={"Authorization": f"Lab {self.auth}"},
timeout=10,
)
@@ -117,7 +109,6 @@ class HTTPClient:
uuid_mapping[i["uuid"]] = i["cloud_uuid"]
else:
logger.error(f"添加物料失败: {response.text}")
logger.trace(f"添加物料失败: {nodes_info}")
for u, n in old_uuids.items():
if u in uuid_mapping:
n.res_content.uuid = uuid_mapping[u]
@@ -138,7 +129,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 +143,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 +160,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 +194,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 +235,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 +272,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}"},
@@ -290,54 +280,22 @@ class HTTPClient:
)
return response
def resource_registry(
self, registry_data: Dict[str, Any] | List[Dict[str, Any]], tag: str = "registry",
) -> requests.Response:
def resource_registry(self, registry_data: Dict[str, Any] | List[Dict[str, Any]]) -> requests.Response:
"""
注册资源到服务器,同步保存请求/响应到 unilabos_data
注册资源到服务器
Args:
registry_data: 注册表数据,格式为 {resource_id: resource_info} / [{resource_info}]
tag: 保存文件的标签后缀 (如 "device_registry" / "resource_registry")
Returns:
Response: API响应对象
"""
# 序列化一次,同时用于保存和发送
json_bytes = _fast_dumps(registry_data)
# 保存请求数据到 unilabos_data
req_path = os.path.join(BasicConfig.working_dir, f"req_{tag}_upload.json")
try:
os.makedirs(BasicConfig.working_dir, exist_ok=True)
with open(req_path, "wb") as f:
f.write(_fast_dumps_pretty(registry_data))
logger.trace(f"注册表请求数据已保存: {req_path}")
except Exception as e:
logger.warning(f"保存注册表请求数据失败: {e}")
compressed_body = gzip.compress(json_bytes)
headers = {
"Authorization": f"Lab {self.auth}",
"Content-Type": "application/json",
"Content-Encoding": "gzip",
}
response = self._session.post(
response = requests.post(
f"{self.remote_addr}/lab/resource",
data=compressed_body,
headers=headers,
json=registry_data,
headers={"Authorization": f"Lab {self.auth}"},
timeout=30,
)
# 保存响应数据到 unilabos_data
res_path = os.path.join(BasicConfig.working_dir, f"res_{tag}_upload.json")
try:
with open(res_path, "w", encoding="utf-8") as f:
f.write(f"{response.status_code}\n{response.text}")
logger.trace(f"注册表响应数据已保存: {res_path}")
except Exception as e:
logger.warning(f"保存注册表响应数据失败: {e}")
if response.status_code not in [200, 201]:
logger.error(f"注册资源失败: {response.status_code}, {response.text}")
if response.status_code == 200:
@@ -356,7 +314,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),
@@ -385,10 +343,9 @@ class HTTPClient:
edges: List[Dict[str, Any]],
tags: Optional[List[str]] = None,
published: bool = False,
description: str = "",
) -> Dict[str, Any]:
"""
导入工作流到服务器,如果 published 为 True则额外发起发布请求
导入工作流到服务器
Args:
name: 工作流名称(顶层)
@@ -398,7 +355,6 @@ class HTTPClient:
edges: 工作流边列表
tags: 工作流标签列表,默认为空列表
published: 是否发布工作流默认为False
description: 工作流描述,发布时使用
Returns:
Dict: API响应数据包含 code 和 data (uuid, name)
@@ -411,13 +367,14 @@ class HTTPClient:
"nodes": nodes,
"edges": edges,
"tags": tags if tags is not None else [],
"published": published,
},
}
# 保存请求到文件
with open(os.path.join(BasicConfig.working_dir, "req_workflow_upload.json"), "w", encoding="utf-8") as f:
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}"},
@@ -431,51 +388,11 @@ class HTTPClient:
res = response.json()
if "code" in res and res["code"] != 0:
logger.error(f"导入工作流失败: {response.text}")
return res
# 导入成功后,如果需要发布则额外发起发布请求
if published:
imported_uuid = res.get("data", {}).get("uuid", workflow_uuid)
publish_res = self.workflow_publish(imported_uuid, description)
res["publish_result"] = publish_res
return res
else:
logger.error(f"导入工作流失败: {response.status_code}, {response.text}")
return {"code": response.status_code, "message": response.text}
def workflow_publish(self, workflow_uuid: str, description: str = "") -> Dict[str, Any]:
"""
发布工作流
Args:
workflow_uuid: 工作流UUID
description: 工作流描述
Returns:
Dict: API响应数据
"""
payload = {
"uuid": workflow_uuid,
"description": description,
"published": True,
}
logger.info(f"正在发布工作流: {workflow_uuid}")
response = requests.patch(
f"{self.remote_addr}/lab/workflow/owner",
json=payload,
headers={"Authorization": f"Lab {self.auth}"},
timeout=60,
)
if response.status_code == 200:
res = response.json()
if "code" in res and res["code"] != 0:
logger.error(f"发布工作流失败: {response.text}")
else:
logger.info(f"工作流发布成功: {workflow_uuid}")
return res
else:
logger.error(f"发布工作流失败: {response.status_code}, {response.text}")
return {"code": response.status_code, "message": response.text}
# 创建默认客户端实例
http_client = HTTPClient()

View File

@@ -58,14 +58,14 @@ class JobResultStore:
feedback=feedback or {},
timestamp=time.time(),
)
logger.debug(f"[JobResultStore] Stored result for job {job_id[:8]}, status={status}")
logger.trace(f"[JobResultStore] Stored result for job {job_id[:8]}, status={status}")
def get_and_remove(self, job_id: str) -> Optional[JobResult]:
"""获取并删除任务结果"""
with self._results_lock:
result = self._results.pop(job_id, None)
if result:
logger.debug(f"[JobResultStore] Retrieved and removed result for job {job_id[:8]}")
logger.trace(f"[JobResultStore] Retrieved and removed result for job {job_id[:8]}")
return result
def get_result(self, job_id: str) -> Optional[JobResult]:
@@ -327,7 +327,6 @@ def job_add(req: JobAddReq) -> JobData:
queue_item,
action_type=action_type,
action_kwargs=action_args,
sample_material=req.sample_material,
server_info=server_info,
)

View File

@@ -86,7 +86,7 @@ def setup_server() -> FastAPI:
# 设置页面路由
try:
setup_web_pages(pages)
# info("[Web] 已加载Web UI模块")
info("[Web] 已加载Web UI模块")
except ImportError as e:
info(f"[Web] 未找到Web页面模块: {str(e)}")
except Exception as e:
@@ -138,7 +138,7 @@ def start_server(host: str = "0.0.0.0", port: int = 8002, open_browser: bool = T
server_thread = threading.Thread(target=server.run, daemon=True, name="uvicorn_server")
server_thread.start()
# info("[Web] Server started, monitoring for restart requests...")
info("[Web] Server started, monitoring for restart requests...")
# 监控重启标志
import unilabos.app.main as main_module

View File

@@ -26,7 +26,6 @@ from enum import Enum
from typing_extensions import TypedDict
from unilabos.app.model import JobAddReq
from unilabos.resources.resource_tracker import ResourceDictType
from unilabos.ros.nodes.presets.host_node import HostNode
from unilabos.utils.type_check import serialize_result_info
from unilabos.app.communication import BaseCommunicationClient
@@ -77,7 +76,6 @@ class JobInfo:
start_time: float
last_update_time: float = field(default_factory=time.time)
ready_timeout: Optional[float] = None # READY状态的超时时间
always_free: bool = False # 是否为永久闲置动作(不受排队限制)
def update_timestamp(self):
"""更新最后更新时间"""
@@ -129,15 +127,6 @@ class DeviceActionManager:
# 总是将job添加到all_jobs中
self.all_jobs[job_info.job_id] = job_info
# always_free的动作不受排队限制直接设为READY
if job_info.always_free:
job_info.status = JobStatus.READY
job_info.update_timestamp()
job_info.set_ready_timeout(10)
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
logger.trace(f"[DeviceActionManager] Job {job_log} always_free, start immediately")
return True
# 检查是否有正在执行或准备执行的任务
if device_key in self.active_jobs:
# 有正在执行或准备执行的任务,加入队列
@@ -187,15 +176,11 @@ class DeviceActionManager:
logger.error(f"[DeviceActionManager] Job {job_log} is not in READY status, current: {job_info.status}")
return False
# always_free的job不需要检查active_jobs
if not job_info.always_free:
# 检查设备上是否是这个job
if device_key not in self.active_jobs or self.active_jobs[device_key].job_id != job_id:
job_log = format_job_log(
job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name
)
logger.error(f"[DeviceActionManager] Job {job_log} is not the active job for {device_key}")
return False
# 检查设备上是否是这个job
if device_key not in self.active_jobs or self.active_jobs[device_key].job_id != job_id:
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
logger.error(f"[DeviceActionManager] Job {job_log} is not the active job for {device_key}")
return False
# 开始执行任务将状态从READY转换为STARTED
job_info.status = JobStatus.STARTED
@@ -218,13 +203,6 @@ class DeviceActionManager:
job_info = self.all_jobs[job_id]
device_key = job_info.device_action_key
# always_free的job直接清理不影响队列
if job_info.always_free:
job_info.status = JobStatus.ENDED
job_info.update_timestamp()
del self.all_jobs[job_id]
return None
# 移除活跃任务
if device_key in self.active_jobs and self.active_jobs[device_key].job_id == job_id:
del self.active_jobs[device_key]
@@ -256,14 +234,9 @@ class DeviceActionManager:
return None
def get_active_jobs(self) -> List[JobInfo]:
"""获取所有正在执行的任务(含active_jobs和always_free的STARTED job)"""
"""获取所有正在执行的任务"""
with self.lock:
jobs = list(self.active_jobs.values())
# 补充 always_free 的 STARTED job(它们不在 active_jobs 中)
for job in self.all_jobs.values():
if job.always_free and job.status == JobStatus.STARTED and job not in jobs:
jobs.append(job)
return jobs
return list(self.active_jobs.values())
def get_queued_jobs(self) -> List[JobInfo]:
"""获取所有排队中的任务"""
@@ -288,14 +261,6 @@ class DeviceActionManager:
job_info = self.all_jobs[job_id]
device_key = job_info.device_action_key
# always_free的job直接清理
if job_info.always_free:
job_info.status = JobStatus.ENDED
del self.all_jobs[job_id]
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
logger.trace(f"[DeviceActionManager] Always-free job {job_log} cancelled")
return True
# 如果是正在执行的任务
if device_key in self.active_jobs and self.active_jobs[device_key].job_id == job_id:
# 清理active job状态
@@ -369,18 +334,13 @@ class DeviceActionManager:
timeout_jobs = []
with self.lock:
# 收集所有需要检查的 READY 任务(active_jobs + always_free READY jobs)
ready_candidates = list(self.active_jobs.values())
for job in self.all_jobs.values():
if job.always_free and job.status == JobStatus.READY and job not in ready_candidates:
ready_candidates.append(job)
ready_jobs_count = sum(1 for job in ready_candidates if job.status == JobStatus.READY)
# 统计READY状态的任务数量
ready_jobs_count = sum(1 for job in self.active_jobs.values() if job.status == JobStatus.READY)
if ready_jobs_count > 0:
logger.trace(f"[DeviceActionManager] Checking {ready_jobs_count} READY jobs for timeout") # type: ignore # noqa: E501
# 找到所有超时的READY任务只检测不处理
for job_info in ready_candidates:
for job_info in self.active_jobs.values():
if job_info.is_ready_timeout():
timeout_jobs.append(job_info)
job_log = format_job_log(
@@ -409,7 +369,6 @@ class MessageProcessor:
# 线程控制
self.is_running = False
self.thread = None
self._loop = None # asyncio event loop引用用于外部关闭websocket
self.reconnect_count = 0
logger.info(f"[MessageProcessor] Initialized for URL: {websocket_url}")
@@ -436,31 +395,22 @@ class MessageProcessor:
def stop(self) -> None:
"""停止消息处理线程"""
self.is_running = False
# 主动关闭websocket以快速中断消息接收循环
ws = self.websocket
loop = self._loop
if ws and loop and loop.is_running():
try:
asyncio.run_coroutine_threadsafe(ws.close(), loop)
except Exception:
pass
if self.thread and self.thread.is_alive():
self.thread.join(timeout=2)
logger.info("[MessageProcessor] Stopped")
def _run(self):
"""运行消息处理主循环"""
self._loop = asyncio.new_event_loop()
loop = asyncio.new_event_loop()
try:
asyncio.set_event_loop(self._loop)
self._loop.run_until_complete(self._connection_handler())
asyncio.set_event_loop(loop)
loop.run_until_complete(self._connection_handler())
except Exception as e:
logger.error(f"[MessageProcessor] Thread error: {str(e)}")
logger.error(traceback.format_exc())
finally:
if self._loop:
self._loop.close()
self._loop = None
if loop:
loop.close()
async def _connection_handler(self):
"""处理WebSocket连接和重连逻辑"""
@@ -477,10 +427,8 @@ class MessageProcessor:
async with websockets.connect(
self.websocket_url,
ssl=ssl_context,
open_timeout=20,
ping_interval=WSConfig.ping_interval,
ping_timeout=10,
close_timeout=5,
additional_headers={
"Authorization": f"Lab {BasicConfig.auth_secret()}",
"EdgeSession": f"{self.session_id}",
@@ -491,94 +439,81 @@ class MessageProcessor:
self.connected = True
self.reconnect_count = 0
logger.info(f"[MessageProcessor] 已连接到 {self.websocket_url}")
logger.trace(f"[MessageProcessor] Connected to {self.websocket_url}")
# 启动发送协程
send_task = asyncio.create_task(self._send_handler(), name="websocket-send_task")
# 每次连接(含重连)后重新向服务端注册,
# 否则服务端不知道客户端已上线,不会推送消息。
if self.websocket_client:
self.websocket_client.publish_host_ready()
send_task = asyncio.create_task(self._send_handler())
try:
# 接收消息循环
await self._message_handler()
finally:
# 必须在 async with __aexit__ 之前停止 send_task
# 否则 send_task 会在关闭握手期间继续发送数据,
# 干扰 websockets 库的内部清理,导致 task 泄漏。
self.connected = False
send_task.cancel()
try:
await send_task
except asyncio.CancelledError:
pass
self.connected = False
except websockets.exceptions.ConnectionClosed:
logger.warning("[MessageProcessor] 与服务端连接中断")
except TimeoutError:
logger.warning(
f"[MessageProcessor] 与服务端连接通信超时 (已尝试 {self.reconnect_count + 1} 次),请检查您的网络状况"
)
except websockets.exceptions.InvalidStatus as e:
logger.warning(
f"[MessageProcessor] 收到服务端注册码 {e.response.status_code}, 上一进程可能还未退出"
)
except Exception as e:
logger.error(traceback.format_exc())
logger.error(f"[MessageProcessor] 尝试重连时出错 {str(e)}")
finally:
logger.warning("[MessageProcessor] Connection closed")
self.connected = False
except Exception as e:
logger.error(f"[MessageProcessor] Connection error: {str(e)}")
logger.error(traceback.format_exc())
self.connected = False
finally:
self.websocket = None
# 重连逻辑
if not self.is_running:
break
if self.reconnect_count < WSConfig.max_reconnect_attempts:
if self.is_running and self.reconnect_count < WSConfig.max_reconnect_attempts:
self.reconnect_count += 1
backoff = WSConfig.reconnect_interval
logger.info(
f"[MessageProcessor] 即将在 {backoff} 秒后重连 (已尝试 {self.reconnect_count}/{WSConfig.max_reconnect_attempts})"
f"[MessageProcessor] Reconnecting in {WSConfig.reconnect_interval}s "
f"(attempt {self.reconnect_count}/{WSConfig.max_reconnect_attempts})"
)
await asyncio.sleep(backoff)
else:
await asyncio.sleep(WSConfig.reconnect_interval)
elif self.reconnect_count >= WSConfig.max_reconnect_attempts:
logger.error("[MessageProcessor] Max reconnection attempts reached")
break
else:
self.reconnect_count -= 1
async def _message_handler(self):
"""处理接收到的消息
ConnectionClosed 不在此处捕获,让其向上传播到 _connection_handler
以便 async with websockets.connect() 的 __aexit__ 能感知连接已断,
正确清理内部 task避免 task 泄漏。
"""
"""处理接收到的消息"""
if not self.websocket:
logger.error("[MessageProcessor] WebSocket connection is None")
return
async for message in self.websocket:
try:
data = json.loads(message)
message_type = data.get("action", "")
message_data = data.get("data")
if self.session_id and self.session_id == data.get("edge_session"):
await self._process_message(message_type, message_data)
else:
if message_type.endswith("_material"):
logger.trace(
f"[MessageProcessor] 收到一条归属 {data.get('edge_session')} 的旧消息:{data}"
)
logger.debug(
f"[MessageProcessor] 跳过了一条归属 {data.get('edge_session')} 的旧消息: {data.get('action')}"
)
else:
try:
async for message in self.websocket:
try:
data = json.loads(message)
message_type = data.get("action", "")
message_data = data.get("data")
if self.session_id and self.session_id == data.get("edge_session"):
await self._process_message(message_type, message_data)
except json.JSONDecodeError:
logger.error(f"[MessageProcessor] Invalid JSON received: {message}")
except Exception as e:
logger.error(f"[MessageProcessor] Error processing message: {str(e)}")
logger.error(traceback.format_exc())
else:
if message_type.endswith("_material"):
logger.trace(
f"[MessageProcessor] 收到一条归属 {data.get('edge_session')} 的旧消息:{data}"
)
logger.debug(
f"[MessageProcessor] 跳过了一条归属 {data.get('edge_session')} 的旧消息: {data.get('action')}"
)
else:
await self._process_message(message_type, message_data)
except json.JSONDecodeError:
logger.error(f"[MessageProcessor] Invalid JSON received: {message}")
except Exception as e:
logger.error(f"[MessageProcessor] Error processing message: {str(e)}")
logger.error(traceback.format_exc())
except websockets.exceptions.ConnectionClosed:
logger.info("[MessageProcessor] Message handler stopped - connection closed")
except Exception as e:
logger.error(f"[MessageProcessor] Message handler error: {str(e)}")
logger.error(traceback.format_exc())
async def _send_handler(self):
"""处理发送队列中的消息"""
@@ -610,7 +545,7 @@ class MessageProcessor:
try:
message_str = json.dumps(msg, ensure_ascii=False)
await self.websocket.send(message_str)
# logger.trace(f"[MessageProcessor] Message sent: {msg.get('action', 'unknown')}") # type: ignore # noqa: E501
logger.trace(f"[MessageProcessor] Message sent: {msg.get('action', 'unknown')}") # type: ignore # noqa: E501
except Exception as e:
logger.error(f"[MessageProcessor] Failed to send message: {str(e)}")
logger.error(traceback.format_exc())
@@ -627,7 +562,6 @@ class MessageProcessor:
except asyncio.CancelledError:
logger.debug("[MessageProcessor] Send handler cancelled")
raise
except Exception as e:
logger.error(f"[MessageProcessor] Fatal error in send handler: {str(e)}")
logger.error(traceback.format_exc())
@@ -659,10 +593,6 @@ class MessageProcessor:
# elif message_type == "session_id":
# self.session_id = message_data.get("session_id")
# logger.info(f"[MessageProcessor] Session ID: {self.session_id}")
elif message_type == "add_device":
await self._handle_device_manage(message_data, "add")
elif message_type == "remove_device":
await self._handle_device_manage(message_data, "remove")
elif message_type == "request_restart":
await self._handle_request_restart(message_data)
else:
@@ -678,24 +608,6 @@ class MessageProcessor:
if host_node:
host_node.handle_pong_response(pong_data)
def _check_action_always_free(self, device_id: str, action_name: str) -> bool:
"""检查该action是否标记为always_free通过HostNode统一的_action_value_mappings查找"""
try:
host_node = HostNode.get_instance(0)
if not host_node:
return False
# noinspection PyProtectedMember
action_mappings = host_node._action_value_mappings.get(device_id)
if not action_mappings:
return False
# 尝试直接匹配或 auto- 前缀匹配
for key in [action_name, f"auto-{action_name}"]:
if key in action_mappings:
return action_mappings[key].get("always_free", False)
return False
except Exception:
return False
async def _handle_query_action_state(self, data: Dict[str, Any]):
"""处理query_action_state消息"""
device_id = data.get("device_id", "")
@@ -710,9 +622,6 @@ class MessageProcessor:
device_action_key = f"/devices/{device_id}/{action_name}"
# 检查action是否为always_free
action_always_free = self._check_action_always_free(device_id, action_name)
# 创建任务信息
job_info = JobInfo(
job_id=job_id,
@@ -722,7 +631,6 @@ class MessageProcessor:
device_action_key=device_action_key,
status=JobStatus.QUEUE,
start_time=time.time(),
always_free=action_always_free,
)
# 添加到设备管理器
@@ -749,37 +657,9 @@ class MessageProcessor:
async def _handle_job_start(self, data: Dict[str, Any]):
"""处理job_start消息"""
try:
if not data.get("sample_material"):
data["sample_material"] = {}
req = JobAddReq(**data)
job_log = format_job_log(req.job_id, req.task_id, req.device_id, req.action)
# 服务端对always_free动作可能跳过query_action_state直接发job_start
# 此时job尚未注册需要自动补注册
existing_job = self.device_manager.get_job_info(req.job_id)
if not existing_job:
action_name = req.action
device_action_key = f"/devices/{req.device_id}/{action_name}"
action_always_free = self._check_action_always_free(req.device_id, action_name)
if action_always_free:
job_info = JobInfo(
job_id=req.job_id,
task_id=req.task_id,
device_id=req.device_id,
action_name=action_name,
device_action_key=device_action_key,
status=JobStatus.QUEUE,
start_time=time.time(),
always_free=True,
)
self.device_manager.add_queue_request(job_info)
logger.info(f"[MessageProcessor] Job {job_log} always_free, auto-registered from direct job_start")
else:
logger.error(f"[MessageProcessor] Job {job_log} not registered (missing query_action_state)")
return
success = self.device_manager.start_job(req.job_id)
if not success:
logger.error(f"[MessageProcessor] Failed to start job {job_log}")
@@ -808,7 +688,6 @@ class MessageProcessor:
queue_item,
action_type=req.action_type,
action_kwargs=req.action_args,
sample_material=req.sample_material,
server_info=req.server_info,
)
@@ -1025,37 +904,6 @@ class MessageProcessor:
)
thread.start()
async def _handle_device_manage(self, device_list: list[ResourceDictType], action: str):
"""Handle add_device / remove_device from LabGo server."""
if not device_list:
return
for item in device_list:
target_node_id = item.get("target_node_id", "host_node")
def _notify(target_id: str, act: str, cfg: ResourceDictType):
try:
host_node = HostNode.get_instance(timeout=5)
if not host_node:
logger.error(f"[DeviceManage] HostNode not available for {act}_device")
return
success = host_node.notify_device_manage(target_id, act, cfg)
if success:
logger.info(f"[DeviceManage] {act}_device completed on {target_id}")
else:
logger.warning(f"[DeviceManage] {act}_device failed on {target_id}")
except Exception as e:
logger.error(f"[DeviceManage] Error in {act}_device: {e}")
logger.error(traceback.format_exc())
thread = threading.Thread(
target=_notify,
args=(target_node_id, action, item),
daemon=True,
name=f"DeviceManage-{action}-{item.get('id', '')}",
)
thread.start()
async def _handle_request_restart(self, data: Dict[str, Any]):
"""
处理重启请求
@@ -1067,9 +915,10 @@ class MessageProcessor:
logger.info(f"[MessageProcessor] Received restart request, reason: {reason}, delay: {delay}s")
# 发送确认消息
self.send_message(
{"action": "restart_acknowledged", "data": {"reason": reason, "delay": delay}}
)
if self.websocket_client:
await self.websocket_client.send_message(
{"action": "restart_acknowledged", "data": {"reason": reason, "delay": delay}}
)
# 设置全局重启标志
import unilabos.app.main as main_module
@@ -1113,7 +962,7 @@ class MessageProcessor:
"task_id": task_id,
"job_id": job_id,
"free": free,
"need_more": need_more + 1,
"need_more": need_more,
},
}
@@ -1171,7 +1020,6 @@ class QueueProcessor:
def stop(self) -> None:
"""停止队列处理线程"""
self.is_running = False
self.queue_update_event.set() # 立即唤醒等待中的线程
if self.thread and self.thread.is_alive():
self.thread.join(timeout=2)
logger.info("[QueueProcessor] Stopped")
@@ -1253,7 +1101,7 @@ class QueueProcessor:
"task_id": job_info.task_id,
"job_id": job_info.job_id,
"free": False,
"need_more": 10 + 1,
"need_more": 10,
},
}
self.message_processor.send_message(message)
@@ -1269,20 +1117,9 @@ 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
# 此时不应再发送 busy/need_more否则会覆盖已发出的 free=True 通知
if job_info.status != JobStatus.QUEUE:
continue
message = {
"action": "report_action_state",
"data": {
@@ -1292,7 +1129,7 @@ class QueueProcessor:
"task_id": job_info.task_id,
"job_id": job_info.job_id,
"free": False,
"need_more": 10 + 1,
"need_more": 10,
},
}
success = self.message_processor.send_message(message)
@@ -1375,10 +1212,6 @@ class WebSocketClient(BaseCommunicationClient):
self.message_processor = MessageProcessor(self.websocket_url, self.send_queue, self.device_manager)
self.queue_processor = QueueProcessor(self.device_manager, self.message_processor)
# running状态debounce缓存: {job_id: (last_send_timestamp, last_feedback_data)}
self._job_running_last_sent: Dict[str, tuple] = {}
self._job_running_debounce_interval: float = 10.0 # 秒
# 设置相互引用
self.message_processor.set_queue_processor(self.queue_processor)
self.message_processor.set_websocket_client(self)
@@ -1435,8 +1268,8 @@ class WebSocketClient(BaseCommunicationClient):
message = {"action": "normal_exit", "data": {"session_id": session_id}}
self.message_processor.send_message(message)
logger.info(f"[WebSocketClient] Sent normal_exit message with session_id: {session_id}")
# send_handler 每100ms检查一次队列等300ms足以让消息发
time.sleep(0.3)
# 给一点时间让消息发送出去
time.sleep(1)
except Exception as e:
logger.warning(f"[WebSocketClient] Failed to send normal_exit message: {str(e)}")
@@ -1468,7 +1301,7 @@ class WebSocketClient(BaseCommunicationClient):
},
}
self.message_processor.send_message(message)
# logger.trace(f"[WebSocketClient] Device status published: {device_id}.{property_name}")
logger.trace(f"[WebSocketClient] Device status published: {device_id}.{property_name}")
def publish_job_status(
self, feedback_data: dict, item: QueueItem, status: str, return_info: Optional[dict] = None
@@ -1478,32 +1311,22 @@ class WebSocketClient(BaseCommunicationClient):
logger.debug(f"[WebSocketClient] Not connected, cannot publish job status for job_id: {item.job_id}")
return
job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name)
# 拦截最终结果状态,与原版本逻辑一致
if status in ["success", "failed"]:
self._job_running_last_sent.pop(item.job_id, None)
host_node = HostNode.get_instance(0)
if host_node:
# 从HostNode的device_action_status中移除job_id
try:
host_node._device_action_status[item.device_action_key].job_ids.pop(item.job_id, None)
except (KeyError, AttributeError):
logger.warning(f"[WebSocketClient] Failed to remove job {item.job_id} from HostNode status")
# logger.debug(f"[WebSocketClient] Intercepting final status for job_id: {item.job_id} - {status}")
# 通知队列处理器job完成包括timeout的job
self.queue_processor.handle_job_completed(item.job_id, status)
# running状态按job_id做debounce内容变化时仍然上报
if status == "running":
now = time.time()
cached = self._job_running_last_sent.get(item.job_id)
if cached is not None:
last_ts, last_data = cached
if now - last_ts < self._job_running_debounce_interval and last_data == feedback_data:
logger.trace(f"[WebSocketClient] Job status debounced (skip): {job_log} - {status}")
return
self._job_running_last_sent[item.job_id] = (now, feedback_data)
# 发送job状态消息
message = {
"action": "job_status",
"data": {
@@ -1519,6 +1342,7 @@ class WebSocketClient(BaseCommunicationClient):
}
self.message_processor.send_message(message)
job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name)
logger.trace(f"[WebSocketClient] Job status published: {job_log} - {status}")
def send_ping(self, ping_id: str, timestamp: float) -> None:

View File

@@ -95,29 +95,8 @@ def get_vessel_liquid_volume(G: nx.DiGraph, vessel: str) -> float:
return total_volume
def is_integrated_pump(node_class: str, node_name: str = "") -> bool:
"""
判断是否为泵阀一体设备
"""
class_lower = (node_class or "").lower()
name_lower = (node_name or "").lower()
if "pump" not in class_lower and "pump" not in name_lower:
return False
integrated_markers = [
"valve",
"pump_valve",
"pumpvalve",
"integrated",
"transfer_pump",
]
for marker in integrated_markers:
if marker in class_lower or marker in name_lower:
return True
return False
def is_integrated_pump(node_name):
return "pump" in node_name and "valve" in node_name
def find_connected_pump(G, valve_node):
@@ -207,9 +186,7 @@ def build_pump_valve_maps(G, pump_backbone):
debug_print(f"🔧 过滤后的骨架: {filtered_backbone}")
for node in filtered_backbone:
node_data = G.nodes.get(node, {})
node_class = node_data.get("class", "") or ""
if is_integrated_pump(node_class, node):
if is_integrated_pump(G.nodes[node]["class"]):
pumps_from_node[node] = node
valve_from_node[node] = node
debug_print(f" - 集成泵-阀: {node}")

View File

@@ -23,8 +23,6 @@ class BasicConfig:
disable_browser = False # 禁止浏览器自动打开
port = 8002 # 本地HTTP服务
check_mode = False # CI 检查模式,用于验证 registry 导入和文件一致性
test_mode = False # 测试模式,所有动作不实际执行,返回模拟结果
extra_resource = False # 是否加载lab_开头的额外资源
# 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'
log_level: Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "DEBUG"
@@ -41,12 +39,12 @@ class BasicConfig:
class WSConfig:
reconnect_interval = 5 # 重连间隔(秒)
max_reconnect_attempts = 999 # 最大重连次数
ping_interval = 20 # ping间隔
ping_interval = 30 # ping间隔
# HTTP配置
class HTTPConfig:
remote_addr = "https://leap-lab.bohrium.com/api/v1"
remote_addr = "https://uni-lab.bohrium.com/api/v1"
# ROS配置
@@ -147,5 +145,5 @@ def load_config(config_path=None):
traceback.print_exc()
exit(1)
else:
config_path = os.path.join(os.path.dirname(__file__), "example_config.py")
config_path = os.path.join(os.path.dirname(__file__), "local_config.py")
load_config(config_path)

File diff suppressed because it is too large Load Diff

View File

@@ -43,7 +43,7 @@ class Base(ABC):
self._type = typ
self._data_type = data_type
self._node: Optional[Node] = None
def _get_node(self) -> Node:
if self._node is None:
try:
@@ -66,7 +66,7 @@ class Base(ABC):
# 直接以字符串形式处理
if isinstance(nid, str):
nid = nid.strip()
# 处理包含类名的格式,如 'StringNodeId(ns=4;s=...)' 或 'NumericNodeId(ns=2;i=...)'
# 提取括号内的内容
match_wrapped = re.match(r'(String|Numeric|Byte|Guid|TwoByteNode|FourByteNode)NodeId\((.*)\)', nid)
@@ -116,16 +116,16 @@ class Base(ABC):
def read(self) -> Tuple[Any, bool]:
"""读取节点值,返回(值, 是否出错)"""
pass
@abstractmethod
def write(self, value: Any) -> bool:
"""写入节点值,返回是否出错"""
pass
@property
def type(self) -> NodeType:
return self._type
@property
def node_id(self) -> str:
return self._node_id
@@ -210,15 +210,15 @@ class Method(Base):
super().__init__(client, name, node_id, NodeType.METHOD, data_type)
self._parent_node_id = parent_node_id
self._parent_node = None
def _get_parent_node(self) -> Node:
if self._parent_node is None:
try:
# 处理父节点ID使用与_get_node相同的解析逻辑
import re
nid = self._parent_node_id
# 如果已经是 NodeId 对象,直接使用
try:
from opcua.ua import NodeId as UaNodeId
@@ -227,16 +227,16 @@ class Method(Base):
return self._parent_node
except Exception:
pass
# 字符串处理
if isinstance(nid, str):
nid = nid.strip()
# 处理包含类名的格式
match_wrapped = re.match(r'(String|Numeric|Byte|Guid|TwoByteNode|FourByteNode)NodeId\((.*)\)', nid)
if match_wrapped:
nid = match_wrapped.group(2).strip()
# 常见短格式
if re.match(r'^ns=\d+;[is]=', nid):
self._parent_node = self._client.get_node(nid)
@@ -271,7 +271,7 @@ class Method(Base):
def write(self, value: Any) -> bool:
"""方法节点不支持写入操作"""
return True
def call(self, *args) -> Tuple[Any, bool]:
"""调用方法,返回(返回值, 是否出错)"""
try:
@@ -285,7 +285,7 @@ class Method(Base):
class Object(Base):
def __init__(self, client: Client, name: str, node_id: str):
super().__init__(client, name, node_id, NodeType.OBJECT, None)
def read(self) -> Tuple[Any, bool]:
"""对象节点不支持直接读取操作"""
return None, True
@@ -293,7 +293,7 @@ class Object(Base):
def write(self, value: Any) -> bool:
"""对象节点不支持直接写入操作"""
return True
def get_children(self) -> Tuple[List[Node], bool]:
"""获取子节点列表,返回(子节点列表, 是否出错)"""
try:
@@ -301,4 +301,4 @@ class Object(Base):
return children, False
except Exception as e:
print(f"获取对象 {self._name} 的子节点失败: {e}")
return [], True
return [], True

View File

@@ -1,3 +1,4 @@
from abc import abstractmethod
from functools import wraps
import inspect

View File

@@ -201,42 +201,17 @@ class ResourceVisualization:
self.moveit_controllers_yaml['moveit_simple_controller_manager'][f"{name}_{controller_name}"] = moveit_dict['moveit_simple_controller_manager'][controller_name]
@staticmethod
def _ensure_ros2_env() -> dict:
"""确保 ROS2 环境变量正确设置,返回可用于子进程的 env dict"""
import sys
env = dict(os.environ)
conda_prefix = os.path.dirname(os.path.dirname(sys.executable))
if "AMENT_PREFIX_PATH" not in env or not env["AMENT_PREFIX_PATH"].strip():
candidate = os.pathsep.join([conda_prefix, os.path.join(conda_prefix, "Library")])
env["AMENT_PREFIX_PATH"] = candidate
os.environ["AMENT_PREFIX_PATH"] = candidate
extra_bin_dirs = [
os.path.join(conda_prefix, "Library", "bin"),
os.path.join(conda_prefix, "Library", "lib"),
os.path.join(conda_prefix, "Scripts"),
conda_prefix,
]
current_path = env.get("PATH", "")
for d in extra_bin_dirs:
if d not in current_path:
current_path = d + os.pathsep + current_path
env["PATH"] = current_path
os.environ["PATH"] = current_path
return env
def create_launch_description(self) -> LaunchDescription:
"""
创建launch描述包含robot_state_publisher和move_group节点
Args:
urdf_str: URDF文本
Returns:
LaunchDescription: launch描述对象
"""
launch_env = self._ensure_ros2_env()
# 检查ROS 2环境变量
if "AMENT_PREFIX_PATH" not in os.environ:
raise OSError(
"ROS 2环境未正确设置。需要设置 AMENT_PREFIX_PATH 环境变量。\n"
@@ -315,7 +290,7 @@ class ResourceVisualization:
{"robot_description": robot_description},
ros2_controllers,
],
env=launch_env,
env=dict(os.environ)
)
)
for controller in self.moveit_controllers_yaml['moveit_simple_controller_manager']['controller_names']:
@@ -325,7 +300,7 @@ class ResourceVisualization:
executable="spawner",
arguments=[f"{controller}", "--controller-manager", f"controller_manager"],
output="screen",
env=launch_env,
env=dict(os.environ)
)
)
controllers.append(
@@ -334,7 +309,7 @@ class ResourceVisualization:
executable="spawner",
arguments=["joint_state_broadcaster", "--controller-manager", f"controller_manager"],
output="screen",
env=launch_env,
env=dict(os.environ)
)
)
for i in controllers:
@@ -342,6 +317,7 @@ class ResourceVisualization:
else:
ros2_controllers = None
# 创建robot_state_publisher节点
robot_state_publisher = nd(
package='robot_state_publisher',
executable='robot_state_publisher',
@@ -351,8 +327,9 @@ class ResourceVisualization:
'robot_description': robot_description,
'use_sim_time': False
},
# kinematics_dict
],
env=launch_env,
env=dict(os.environ)
)
@@ -384,7 +361,7 @@ class ResourceVisualization:
executable='move_group',
output='screen',
parameters=moveit_params,
env=launch_env,
env=dict(os.environ)
)
@@ -402,11 +379,13 @@ class ResourceVisualization:
arguments=['-d', f"{str(self.mesh_path)}/view_robot.rviz"],
output='screen',
parameters=[
{'robot_description_kinematics': kinematics_dict},
{'robot_description_kinematics': kinematics_dict,
},
robot_description_planning,
planning_pipelines,
],
env=launch_env,
env=dict(os.environ)
)
self.launch_description.add_action(rviz_node)

File diff suppressed because it is too large Load Diff

View File

@@ -1,221 +0,0 @@
"""P9 — liquid_history schema v3 与 helper 函数。
独立模块,**不依赖 pylabrobot**,可在 PLR 环境缺失时单独单测。
模块由 ``liquid_handler_abstract.py`` 在 runtime 挂载点set_liquid / aspirate /
dispense调用且由 ``resource_tracker._augment_states_with_liquid_history`` 在
serialize 链路使用。
详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md``。
"""
from __future__ import annotations
from typing import Any, List, Tuple
from typing_extensions import TypedDict
# liquid_history 元素 schema v3
# 详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md`` §6.1。
# 旧格式v2 ``(name, vol)`` 元组、list[str])由 ``normalize_liquid_history`` 升级。
class LiquidHistoryEntry(TypedDict, total=False):
name: str # 液体名(如 "Plasma";与 P8 reagent.liquid_name 联动;缺省 ""
volume: float # 操作体积µLaspirate 为负dispense / set 为正)
action: str # "set" / "aspirate" / "dispense" / "legacy" / "auto_init"
timestamp: str # ISO8601 UTCOS runtime 写入时填,前端写入时可省略)
# liquid_history 单 well 上限:超过则滚动丢弃头部
# 既限制内存(典型 8 通道 transfer 一次产生 ≤16 条),也防止极端 batch 拖慢前端渲染
LIQUID_HISTORY_MAX_ENTRIES = 1000
def well_current_liquid_name(well: Any) -> str:
"""从 ``well.tracker.liquids`` 末项读取当前液体名PLR ``Liquid`` enum / str / None 兼容)。
P9作为 ``aspirate`` 写入 history 时 ``name`` 字段的来源。
返回 ``""`` 表示未知(不写字面 "unknown",避免被前端误展示)。
"""
tracker = getattr(well, "tracker", None)
if tracker is None:
return ""
liquids = getattr(tracker, "liquids", None)
if not liquids:
# PLR 提供 get_liquids() 时优先用之(返回 list[(Liquid|None, vol)]
try:
liquids = tracker.get_liquids() # type: ignore[attr-defined]
except Exception:
liquids = None
if not liquids:
return ""
last = liquids[-1]
if isinstance(last, (list, tuple)) and last:
candidate = last[0]
else:
candidate = last
if candidate is None:
return ""
name = getattr(candidate, "name", None)
if isinstance(name, str) and name:
return name
if isinstance(candidate, str):
return candidate
return ""
def append_liquid_history(
well: Any,
liquid_name: str,
volume: float,
action: str,
) -> None:
"""P9 — 统一写入 ``well.tracker.liquid_history``PLR 扩展属性)。
设计要点:
- 元素为 v3 dict 形态 ``{name, volume, action, timestamp}``,与
:class:`LiquidHistoryEntry` schema 一致。
- ``aspirate`` 的 ``volume`` 应为**负数**(与 dispense/set 正数对称,
``sum(history.volume)`` ≈ 当前残量)。
- ``well`` 无 tracker 或 tracker 不可写时 graceful 静默(避免污染主流程)。
- 滚动上限 ``LIQUID_HISTORY_MAX_ENTRIES``:超出时丢弃**头部**(保留最近)。
详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md`` §6.2。
"""
tracker = getattr(well, "tracker", None)
if tracker is None:
return
history = getattr(tracker, "liquid_history", None)
if not isinstance(history, list):
history = []
try:
tracker.liquid_history = history # type: ignore[attr-defined]
except Exception:
return # tracker 拒绝写扩展属性(极少见);静默放弃
# 兼容修复PLR VolumeTracker.current_liquids 依赖 tracker.liquid_history 为
# list[(name, vol)];若写入 dict 会在 `for name, vol in liquid_history` 时崩溃。
# 这里把历史就地归一为 tuple 形态,再 append tuple避免 unpack ValueError。
normalized_pairs: List[Tuple[str, float]] = []
for item in history:
if isinstance(item, (list, tuple)) and len(item) >= 2:
name_val = str(item[0] or "")
try:
vol_val = float(item[1])
except (TypeError, ValueError):
vol_val = 0.0
normalized_pairs.append((name_val, vol_val))
elif isinstance(item, dict):
name_val = str(item.get("name", ""))
try:
vol_val = float(item.get("volume", 0.0) or 0.0)
except (TypeError, ValueError):
vol_val = 0.0
normalized_pairs.append((name_val, vol_val))
elif isinstance(item, str):
normalized_pairs.append((item, 0.0))
history[:] = normalized_pairs
entry = (str(liquid_name or ""), float(volume))
history.append(entry)
overflow = len(history) - LIQUID_HISTORY_MAX_ENTRIES
if overflow > 0:
del history[:overflow]
# ---------------------------------------------------------------------------
# P10 v2 — Tip 复用 ``tracker.liquids`` 等价 helper详见
# ``product_designs/protocol_convert/10-tip-reuse-by-liquid-history.md`` §3.2
#
# 设计原则:
# - 信号源使用 PLR 原生 ``well.tracker.liquids`` 末项("well 此刻顶层液体"
# 而非 P9 扩展属性 ``liquid_history``P10 v2 因此不依赖 P9 是否落地。
# - 名称比较使用严格字符串相等;空 / "unknown" / "none" 一律保守视为未知 →
# 不触发 liquids 复用,落回 identity-only 现状(零回归)。
# - 与 P9 现有 ``liquid_names_before_aspirate`` 同模式aspirate 之前预读
# source 当前液体名,避免 PLR 顶层归零时 pop ``liquids`` 拿不到身份。
# - 4 个 helper 共同居于本 PLR-free 模块,方便单元测试在不安装 pylabrobot
# 的环境下独立运行。
# ---------------------------------------------------------------------------
def is_known_liquid_name(name: Any) -> bool:
"""空字符串 / "unknown" / "none" / None 一律视为未知,不触发 liquids 复用。"""
if not name:
return False
if not isinstance(name, str):
return False
return name.strip().lower() not in {"unknown", "none"}
def same_liquid_via_liquids(well: Any, tip_liquid_name: Any) -> bool:
"""tip 残留液体名 vs ``well.tracker.liquids`` 末项 name 严格相等。
用于 pick_up 决策:判断下一轮要 aspirate 的 well 当前液体是否与 tip 残液同名。
任一侧未知(空 / "unknown")→ 返回 ``False``(保守换 tip
"""
if not is_known_liquid_name(tip_liquid_name):
return False
well_name = well_current_liquid_name(well)
if not is_known_liquid_name(well_name):
return False
return well_name == tip_liquid_name
def same_liquid_via_liquids_pair(cur_well: Any, next_well: Any) -> bool:
"""两个 source well 当前 ``tracker.liquids`` 末项是否同名(用于决定 drop 时机)。
注:必须在 cur_well 的 aspirate **之前**调用aspirate 不改
``liquids[-1].name`` 只改顶层 vol或顶层归零时 pop故 cur/next 的判等
"将要被抽的那一层" 为准。
"""
cur_name = well_current_liquid_name(cur_well)
next_name = well_current_liquid_name(next_well)
if not is_known_liquid_name(cur_name) or not is_known_liquid_name(next_name):
return False
return cur_name == next_name
def capture_tip_liquid_name(source_well: Any) -> "str | None":
"""**aspirate 之前** 把 source well 的当前液体名捕获下来,作为本轮 aspirate
完成后 tip 上残留液体的身份。
必须在 ``super().aspirate`` / ``_transfer_base_method`` 调用前读取PLR
aspirate 会从顶层扣减体积,体积归零时 PLR ``VolumeTracker`` 会 pop 顶层
``(Liquid, vol)``,事后再读 ``liquids[-1]`` 可能拿到 prev layer 或空 list。
详见 ``liquid_handler_abstract.aspirate`` 中 ``liquid_names_before_aspirate``
同样的 "预读" 模式。
"""
name = well_current_liquid_name(source_well)
return name if is_known_liquid_name(name) else None
def normalize_liquid_history(raw: Any) -> List[Tuple[str, float]]:
"""P9 — 把任意旧形态的 liquid_history 升级为 v3 dict 列表。
兼容输入:
- v3 dict ``[{name, volume, action, timestamp?}, ...]`` 原样返回(字段补全)
- v2 tuple ``[(name, vol), ...]`` → ``action="legacy"``
- list[str] ``["A", "B"]`` → ``volume=0, action="legacy"``
- 其它:丢弃该 entry
详见 ``product_designs/protocol_convert/09-liquid-history-unknown-debug.md`` §6.4。
"""
if not isinstance(raw, list):
return []
result: List[Tuple[str, float]] = []
for entry in raw:
if isinstance(entry, dict):
try:
vol_val = float(entry.get("volume", 0.0) or 0.0)
except (TypeError, ValueError):
vol_val = 0.0
result.append((str(entry.get("name", "")), vol_val))
elif isinstance(entry, (list, tuple)) and len(entry) >= 2:
try:
vol_val = float(entry[1])
except (TypeError, ValueError):
vol_val = 0.0
result.append((str(entry[0] or ""), vol_val))
elif isinstance(entry, str):
result.append((entry, 0.0))
# 其它类型静默丢弃
return result

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,150 +0,0 @@
from typing import Any, Dict, Optional
from .prcxi import PRCXI9300ModuleSite
class PRCXI9300FunctionalModule(PRCXI9300ModuleSite):
"""
PRCXI 9300 功能模块基类(加热/冷却/震荡/加热震荡/磁吸等)。
设计目标:
- 作为一个可以在工作台上拖拽摆放的实体资源(继承自 PRCXI9300ModuleSite -> ItemizedCarrier
- 顶面存在一个站点site可吸附标准板类资源plate / tip_rack / tube_rack 等)。
- 支持注入 `material_info` (UUID 等),并且在 serialize_state 时做安全过滤。
"""
def __init__(
self,
name: str,
size_x: float,
size_y: float,
size_z: float,
module_type: Optional[str] = None,
category: str = "module",
model: Optional[str] = None,
material_info: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
super().__init__(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
material_info=material_info,
model=model,
category=category,
**kwargs,
)
# 记录模块类型(加热 / 冷却 / 震荡 / 加热震荡 / 磁吸)
self.module_type = module_type or "generic"
# 与 PRCXI9300PlateAdapter 一致,使用 _unilabos_state 保存扩展信息
if not hasattr(self, "_unilabos_state") or self._unilabos_state is None:
self._unilabos_state = {}
# super().__init__ 已经在有 material_info 时写入 "Material",这里仅确保存在
if material_info is not None and "Material" not in self._unilabos_state:
self._unilabos_state["Material"] = material_info
# 额外标记 category 和模块类型,便于前端或上层逻辑区分
self._unilabos_state.setdefault("category", category)
self._unilabos_state["module_type"] = module_type
# ============================================================================
# 具体功能模块定义
# 这里的尺寸和 material_info 目前为占位参数,后续可根据实际测量/JSON 配置进行更新。
# 顶面站点尺寸与模块外形一致,保证可以吸附标准 96 板/储液槽等。
# ============================================================================
def PRCXI_Heating_Module(name: str) -> PRCXI9300FunctionalModule:
"""加热模块(顶面可吸附标准板)。"""
return PRCXI9300FunctionalModule(
name=name,
size_x=127.76,
size_y=85.48,
size_z=40.0,
module_type="heating",
model="PRCXI_Heating_Module",
material_info={
"uuid": "TODO-HEATING-MODULE-UUID",
"Code": "HEAT-MOD",
"Name": "PRCXI 加热模块",
"SupplyType": 3,
},
)
def PRCXI_MetalCooling_Module(name: str) -> PRCXI9300FunctionalModule:
"""金属冷却模块(顶面可吸附标准板)。"""
return PRCXI9300FunctionalModule(
name=name,
size_x=127.76,
size_y=85.48,
size_z=40.0,
module_type="metal_cooling",
model="PRCXI_MetalCooling_Module",
material_info={
"uuid": "TODO-METAL-COOLING-MODULE-UUID",
"Code": "METAL-COOL-MOD",
"Name": "PRCXI 金属冷却模块",
"SupplyType": 3,
},
)
def PRCXI_Shaking_Module(name: str) -> PRCXI9300FunctionalModule:
"""震荡模块(顶面可吸附标准板)。"""
return PRCXI9300FunctionalModule(
name=name,
size_x=127.76,
size_y=85.48,
size_z=50.0,
module_type="shaking",
model="PRCXI_Shaking_Module",
material_info={
"uuid": "TODO-SHAKING-MODULE-UUID",
"Code": "SHAKE-MOD",
"Name": "PRCXI 震荡模块",
"SupplyType": 3,
},
)
def PRCXI_Heating_Shaking_Module(name: str) -> PRCXI9300FunctionalModule:
"""加热震荡模块(顶面可吸附标准板)。"""
return PRCXI9300FunctionalModule(
name=name,
size_x=127.76,
size_y=85.48,
size_z=55.0,
module_type="heating_shaking",
model="PRCXI_Heating_Shaking_Module",
material_info={
"uuid": "TODO-HEATING-SHAKING-MODULE-UUID",
"Code": "HEAT-SHAKE-MOD",
"Name": "PRCXI 加热震荡模块",
"SupplyType": 3,
},
)
def PRCXI_Magnetic_Module(name: str) -> PRCXI9300FunctionalModule:
"""磁吸模块(顶面可吸附标准板)。"""
return PRCXI9300FunctionalModule(
name=name,
size_x=127.76,
size_y=85.48,
size_z=30.0,
module_type="magnetic",
model="PRCXI_Magnetic_Module",
material_info={
"uuid": "TODO-MAGNETIC-MODULE-UUID",
"Code": "MAG-MOD",
"Name": "PRCXI 磁吸模块",
"SupplyType": 3,
},
)

View File

@@ -59,7 +59,6 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend):
self.total_height = total_height
self.joint_config = kwargs.get("joint_config", None)
self.lh_device_id = kwargs.get("lh_device_id", "lh_joint_publisher")
self.simulate_rviz = kwargs.get("simulate_rviz", False)
if not rclpy.ok():
rclpy.init()
self.joint_state_publisher = None
@@ -70,7 +69,7 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend):
self.joint_state_publisher = LiquidHandlerJointPublisher(
joint_config=self.joint_config,
lh_device_id=self.lh_device_id,
simulate_rviz=self.simulate_rviz)
simulate_rviz=True)
# 启动ROS executor
self.executor = rclpy.executors.MultiThreadedExecutor()

View File

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

@@ -19,11 +19,10 @@ from rclpy.node import Node
import re
class LiquidHandlerJointPublisher(BaseROS2DeviceNode):
def __init__(self,resources_config:list, resource_tracker, rate=50, device_id:str = "lh_joint_publisher", registry_name: str = "lh_joint_publisher", **kwargs):
def __init__(self,resources_config:list, resource_tracker, rate=50, device_id:str = "lh_joint_publisher", **kwargs):
super().__init__(
driver_instance=self,
device_id=device_id,
registry_name=registry_name,
status_types={},
action_value_mappings={},
hardware_interface={},

View File

@@ -42,7 +42,6 @@ class LiquidHandlerJointPublisher(Node):
while self.resource_action is None:
self.resource_action = self.check_tf_update_actions()
time.sleep(1)
self.get_logger().info(f'Waiting for TfUpdate server: {self.resource_action}')
self.resource_action_client = ActionClient(self, SendCmd, self.resource_action)
while not self.resource_action_client.wait_for_server(timeout_sec=1.0):

View File

@@ -2,7 +2,6 @@ import json
import time
from copy import deepcopy
from pathlib import Path
from typing import Optional, Sequence
from moveit_msgs.msg import JointConstraint, Constraints
from rclpy.action import ActionClient
@@ -172,160 +171,173 @@ class MoveitInterface:
return True
def pick_and_place(
self,
option: str,
move_group: str,
status: str,
resource: Optional[str] = None,
x_distance: Optional[float] = None,
y_distance: Optional[float] = None,
lift_height: Optional[float] = None,
retry: Optional[int] = None,
speed: Optional[float] = None,
target: Optional[str] = None,
constraints: Optional[Sequence[float]] = None,
) -> None:
def pick_and_place(self, command: str):
"""
使用 MoveIt 完成抓取/放置等序列pick/place/side_pick/side_place
Using MoveIt to make the robotic arm pick or place materials to a target point.
必选option, move_group, status。
可选resource, x_distance, y_distance, lift_height, retry, speed, target, constraints。
无返回值;失败时提前 return 或打印异常。
Args:
command: A JSON-formatted string that includes option, target, speed, lift_height, mt_height
*option (string) : Action type: pick/place/side_pick/side_place
*move_group (string): The move group moveit will plan
*status(string) : Target pose
resource(string) : The target resource
x_distance (float) : The distance to the target in x direction(meters)
y_distance (float) : The distance to the target in y direction(meters)
lift_height (float) : The height at which the material should be lifted(meters)
retry (float) : Retry times when moveit plan fails
speed (float) : The speed of the movement, speed > 0
Returns:
None
"""
result = SendCmd.Result()
try:
if option not in self.move_option:
raise ValueError(f"Invalid option: {option}")
cmd_str = str(command).replace("'", '"')
cmd_dict = json.loads(cmd_str)
option_index = self.move_option.index(option)
place_flag = option_index % 2
if cmd_dict["option"] in self.move_option:
option_index = self.move_option.index(cmd_dict["option"])
place_flag = option_index % 2
config: dict = {"move_group": move_group}
if speed is not None:
config["speed"] = speed
if retry is not None:
config["retry"] = retry
config = {}
function_list = []
function_list = []
joint_positions_ = self.joint_poses[move_group][status]
status = cmd_dict["status"]
joint_positions_ = self.joint_poses[cmd_dict["move_group"]][status]
# 夹取 / 放置:绑定 resource 与 parent
if not place_flag:
if target is not None:
function_list.append(lambda r=resource, t=target: self.resource_manager(r, t))
else:
ee = self.moveit2[move_group].end_effector_name
function_list.append(lambda r=resource: self.resource_manager(r, ee))
else:
function_list.append(lambda r=resource: self.resource_manager(r, "world"))
config.update({k: cmd_dict[k] for k in ["speed", "retry", "move_group"] if k in cmd_dict})
joint_constraint_msgs: list = []
if constraints is not None:
for i, c in enumerate(constraints):
v = float(c)
if v > 0:
joint_constraint_msgs.append(
JointConstraint(
joint_name=self.moveit2[move_group].joint_names[i],
position=joint_positions_[i],
tolerance_above=v,
tolerance_below=v,
weight=1.0,
# 夹取
if not place_flag:
if "target" in cmd_dict.keys():
function_list.append(lambda: self.resource_manager(cmd_dict["resource"], cmd_dict["target"]))
else:
function_list.append(
lambda: self.resource_manager(
cmd_dict["resource"], self.moveit2[cmd_dict["move_group"]].end_effector_name
)
)
else:
function_list.append(lambda: self.resource_manager(cmd_dict["resource"], "world"))
if lift_height is not None:
retval = None
attempts = config.get("retry", 10)
while retval is None and attempts > 0:
retval = self.moveit2[move_group].compute_fk(joint_positions_)
time.sleep(0.1)
attempts -= 1
if retval is None:
raise ValueError("Failed to compute forward kinematics")
pose = [retval.pose.position.x, retval.pose.position.y, retval.pose.position.z]
quaternion = [
retval.pose.orientation.x,
retval.pose.orientation.y,
retval.pose.orientation.z,
retval.pose.orientation.w,
]
constraints = []
if "constraints" in cmd_dict.keys():
function_list = [
lambda: self.moveit_task(
position=[retval.pose.position.x, retval.pose.position.y, retval.pose.position.z],
quaternion=quaternion,
**config,
cartesian=self.cartesian_flag,
)
] + function_list
for i in range(len(cmd_dict["constraints"])):
v = float(cmd_dict["constraints"][i])
if v > 0:
constraints.append(
JointConstraint(
joint_name=self.moveit2[cmd_dict["move_group"]].joint_names[i],
position=joint_positions_[i],
tolerance_above=v,
tolerance_below=v,
weight=1.0,
)
)
pose[2] += float(lift_height)
function_list.append(
lambda p=pose.copy(), q=quaternion, cfg=config: self.moveit_task(
position=p, quaternion=q, **cfg, cartesian=self.cartesian_flag
)
)
end_pose = list(pose)
if x_distance is not None or y_distance is not None:
if x_distance is not None:
deep_pose = deepcopy(pose)
deep_pose[0] += float(x_distance)
elif y_distance is not None:
deep_pose = deepcopy(pose)
deep_pose[1] += float(y_distance)
if "lift_height" in cmd_dict.keys():
retval = None
retry = config.get("retry", 10)
while retval is None and retry > 0:
retval = self.moveit2[cmd_dict["move_group"]].compute_fk(joint_positions_)
time.sleep(0.1)
retry -= 1
if retval is None:
result.success = False
return result
pose = [retval.pose.position.x, retval.pose.position.y, retval.pose.position.z]
quaternion = [
retval.pose.orientation.x,
retval.pose.orientation.y,
retval.pose.orientation.z,
retval.pose.orientation.w,
]
function_list = [
lambda p=pose.copy(), q=quaternion, cfg=config: self.moveit_task(
position=p, quaternion=q, **cfg, cartesian=self.cartesian_flag
lambda: self.moveit_task(
position=[retval.pose.position.x, retval.pose.position.y, retval.pose.position.z],
quaternion=quaternion,
**config,
cartesian=self.cartesian_flag,
)
] + function_list
pose[2] += float(cmd_dict["lift_height"])
function_list.append(
lambda dp=deep_pose.copy(), q=quaternion, cfg=config: self.moveit_task(
position=dp, quaternion=q, **cfg, cartesian=self.cartesian_flag
lambda: self.moveit_task(
position=pose, quaternion=quaternion, **config, cartesian=self.cartesian_flag
)
)
end_pose = list(deep_pose)
end_pose = pose
retval_ik = None
attempts_ik = config.get("retry", 10)
while retval_ik is None and attempts_ik > 0:
retval_ik = self.moveit2[move_group].compute_ik(
position=end_pose,
quat_xyzw=quaternion,
constraints=Constraints(joint_constraints=joint_constraint_msgs),
)
time.sleep(0.1)
attempts_ik -= 1
if retval_ik is None:
raise ValueError("Failed to compute inverse kinematics")
position_ = [
retval_ik.position[retval_ik.name.index(i)] for i in self.moveit2[move_group].joint_names
]
jn = self.moveit2[move_group].joint_names
function_list = [
lambda pos=position_, names=jn, cfg=config: self.moveit_joint_task(
joint_positions=pos, joint_names=names, **cfg
)
] + function_list
else:
function_list = [lambda cfg=config, jp=joint_positions_: self.moveit_joint_task(**cfg, joint_positions=jp)] + function_list
if "x_distance" in cmd_dict.keys() or "y_distance" in cmd_dict.keys():
if "x_distance" in cmd_dict.keys():
deep_pose = deepcopy(pose)
deep_pose[0] += float(cmd_dict["x_distance"])
elif "y_distance" in cmd_dict.keys():
deep_pose = deepcopy(pose)
deep_pose[1] += float(cmd_dict["y_distance"])
for i in range(len(function_list)):
if i == 0:
self.cartesian_flag = False
function_list = [
lambda: self.moveit_task(
position=pose, quaternion=quaternion, **config, cartesian=self.cartesian_flag
)
] + function_list
function_list.append(
lambda: self.moveit_task(
position=deep_pose, quaternion=quaternion, **config, cartesian=self.cartesian_flag
)
)
end_pose = deep_pose
retval_ik = None
retry = config.get("retry", 10)
while retval_ik is None and retry > 0:
retval_ik = self.moveit2[cmd_dict["move_group"]].compute_ik(
position=end_pose, quat_xyzw=quaternion, constraints=Constraints(joint_constraints=constraints)
)
time.sleep(0.1)
retry -= 1
if retval_ik is None:
result.success = False
return result
position_ = [
retval_ik.position[retval_ik.name.index(i)]
for i in self.moveit2[cmd_dict["move_group"]].joint_names
]
function_list = [
lambda: self.moveit_joint_task(
joint_positions=position_,
joint_names=self.moveit2[cmd_dict["move_group"]].joint_names,
**config,
)
] + function_list
else:
self.cartesian_flag = True
function_list = [
lambda: self.moveit_joint_task(**config, joint_positions=joint_positions_)
] + function_list
re = function_list[i]()
if not re:
print(i, re)
raise ValueError(f"Failed to execute moveit task: {i}")
for i in range(len(function_list)):
if i == 0:
self.cartesian_flag = False
else:
self.cartesian_flag = True
re = function_list[i]()
if not re:
print(i, re)
result.success = False
return result
result.success = True
except Exception as e:
print(e)
self.cartesian_flag = False
raise e
result.success = False
return result
def set_status(self, command: str):
"""

View File

@@ -623,6 +623,119 @@ 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

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

@@ -1,88 +0,0 @@
"""虚拟样品演示设备 — 用于前端 sample tracking 功能的极简 demo"""
import asyncio
import logging
import random
import time
from typing import Any, Dict, List, Optional
class VirtualSampleDemo:
"""虚拟样品追踪演示设备,提供两种典型返回模式:
- measure_samples: 等长输入输出 (前端按 index 自动对齐)
- split_and_measure: 输出比输入长,附带 samples 列标注归属
"""
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
if device_id is None and "id" in kwargs:
device_id = kwargs.pop("id")
if config is None and "config" in kwargs:
config = kwargs.pop("config")
self.device_id = device_id or "unknown_sample_demo"
self.config = config or {}
self.logger = logging.getLogger(f"VirtualSampleDemo.{self.device_id}")
self.data: Dict[str, Any] = {"status": "Idle"}
# ------------------------------------------------------------------
# Action 1: 等长输入输出,无 samples 列
# ------------------------------------------------------------------
async def measure_samples(self, concentrations: List[float]) -> Dict[str, Any]:
"""模拟光度测量。absorbance = concentration * 0.05 + noise
入参和出参 list 长度相等,前端按 index 自动对齐。
"""
self.logger.info(f"measure_samples: concentrations={concentrations}")
absorbance = [round(c * 0.05 + random.gauss(0, 0.005), 4) for c in concentrations]
return {"concentrations": concentrations, "absorbance": absorbance}
# ------------------------------------------------------------------
# Action 2: 输出比输入长,带 samples 列
# ------------------------------------------------------------------
async def split_and_measure(self, volumes: List[float], split_count: int = 3) -> Dict[str, Any]:
"""将每个样品均分为 split_count 份后逐份测量。
返回的 list 长度 = len(volumes) * split_count
附带 samples 列标注每行属于第几个输入样品 (0-based index)。
"""
self.logger.info(f"split_and_measure: volumes={volumes}, split_count={split_count}")
out_volumes: List[float] = []
readings: List[float] = []
samples: List[int] = []
for idx, vol in enumerate(volumes):
split_vol = round(vol / split_count, 2)
for _ in range(split_count):
out_volumes.append(split_vol)
readings.append(round(random.uniform(0.1, 1.0), 4))
samples.append(idx)
return {"volumes": out_volumes, "readings": readings, "unilabos_samples": samples}
# ------------------------------------------------------------------
# Action 3: 入参和出参都带 samples 列(不等长)
# ------------------------------------------------------------------
async def analyze_readings(self, readings: List[float], samples: List[int]) -> Dict[str, Any]:
"""对 split_and_measure 的输出做二次分析。
入参 readings/samples 长度相同但 > 原始样品数,
出参同样带 samples 列,长度与入参一致。
"""
self.logger.info(f"analyze_readings: readings={readings}, samples={samples}")
scores: List[float] = []
passed: List[bool] = []
threshold = 0.4
for r in readings:
score = round(r * 100 + random.gauss(0, 2), 2)
scores.append(score)
passed.append(r >= threshold)
return {"scores": scores, "passed": passed, "unilabos_samples": samples}
# ------------------------------------------------------------------
# 状态属性
# ------------------------------------------------------------------
@property
def status(self) -> str:
return self.data.get("status", "Idle")

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
@@ -16,35 +15,35 @@ class VirtualPumpMode(Enum):
class VirtualTransferPump:
"""虚拟转移泵类 - 模拟泵的基本功能,无需实际硬件 🚰"""
_ros_node: BaseROS2DeviceNode
def __init__(self, device_id: str = None, config: dict = None, **kwargs):
"""
初始化虚拟转移泵
Args:
device_id: 设备ID
config: 配置字典包含max_volume, port等参数
**kwargs: 其他参数,确保兼容性
"""
self.device_id = device_id or "virtual_transfer_pump"
# 从config或kwargs中获取参数确保类型正确
if config:
self.max_volume = float(config.get("max_volume", 25.0))
self.port = config.get("port", "VIRTUAL")
self.max_volume = float(config.get('max_volume', 25.0))
self.port = config.get('port', 'VIRTUAL')
else:
self.max_volume = float(kwargs.get("max_volume", 25.0))
self.port = kwargs.get("port", "VIRTUAL")
self._transfer_rate = float(kwargs.get("transfer_rate", 0))
self.mode = kwargs.get("mode", VirtualPumpMode.Normal)
self.max_volume = float(kwargs.get('max_volume', 25.0))
self.port = kwargs.get('port', 'VIRTUAL')
self._transfer_rate = float(kwargs.get('transfer_rate', 0))
self.mode = kwargs.get('mode', VirtualPumpMode.Normal)
# 状态变量 - 确保都是正确类型
self._status = "Idle"
self._position = 0.0 # float
self._max_velocity = 5.0 # float
self._max_velocity = 5.0 # float
self._current_volume = 0.0 # float
# 🚀 新增:快速模式设置 - 大幅缩短执行时间
@@ -53,16 +52,14 @@ class VirtualTransferPump:
self._fast_dispense_time = 1.0 # 快速喷射时间(秒)
self.logger = logging.getLogger(f"VirtualTransferPump.{self.device_id}")
print(f"🚰 === 虚拟转移泵 {self.device_id} 已创建 === ✨")
print(
f"💨 快速模式: {'启用' if self._fast_mode else '禁用'} | 移动时间: {self._fast_move_time}s | 喷射时间: {self._fast_dispense_time}s"
)
print(f"💨 快速模式: {'启用' if self._fast_mode else '禁用'} | 移动时间: {self._fast_move_time}s | 喷射时间: {self._fast_dispense_time}s")
print(f"📊 最大容量: {self.max_volume}mL | 端口: {self.port}")
def post_init(self, ros_node: BaseROS2DeviceNode):
self._ros_node = ros_node
async def initialize(self) -> bool:
"""初始化虚拟泵 🚀"""
self.logger.info(f"🔧 初始化虚拟转移泵 {self.device_id}")
@@ -71,33 +68,33 @@ class VirtualTransferPump:
self._current_volume = 0.0
self.logger.info(f"✅ 转移泵 {self.device_id} 初始化完成 🚰")
return True
async def cleanup(self) -> bool:
"""清理虚拟泵 🧹"""
self.logger.info(f"🧹 清理虚拟转移泵 {self.device_id} 🔚")
self._status = "Idle"
self.logger.info(f"✅ 转移泵 {self.device_id} 清理完成 💤")
return True
# 基本属性
@property
def status(self) -> str:
return self._status
@property
def position(self) -> float:
"""当前柱塞位置 (ml) 📍"""
return self._position
@property
def current_volume(self) -> float:
"""当前注射器中的体积 (ml) 💧"""
return self._current_volume
@property
def max_velocity(self) -> float:
return self._max_velocity
@property
def transfer_rate(self) -> float:
return self._transfer_rate
@@ -106,17 +103,17 @@ class VirtualTransferPump:
"""设置最大速度 (ml/s) 🌊"""
self._max_velocity = max(0.1, min(50.0, velocity)) # 限制在合理范围内
self.logger.info(f"🌊 设置最大速度为 {self._max_velocity} mL/s")
def get_status(self) -> str:
"""获取泵状态 📋"""
return self._status
async def _simulate_operation(self, duration: float):
"""模拟操作延时 ⏱️"""
self._status = "Busy"
await self._ros_node.sleep(duration)
self._status = "Idle"
def _calculate_duration(self, volume: float, velocity: float = None) -> float:
"""
计算操作持续时间 ⏰
@@ -124,10 +121,10 @@ class VirtualTransferPump:
"""
if velocity is None:
velocity = self._max_velocity
# 📊 计算理论时间(用于日志显示)
theoretical_duration = abs(volume) / velocity
# 🚀 如果启用快速模式,使用固定的快速时间
if self._fast_mode:
# 根据操作类型选择快速时间
@@ -135,13 +132,13 @@ class VirtualTransferPump:
actual_duration = self._fast_move_time
else: # 很小的操作
actual_duration = 0.5
self.logger.debug(f"⚡ 快速模式: 理论时间 {theoretical_duration:.2f}s → 实际时间 {actual_duration:.2f}s")
return actual_duration
else:
# 正常模式使用理论时间
return theoretical_duration
def _calculate_display_duration(self, volume: float, velocity: float = None) -> float:
"""
计算显示用的持续时间(用于日志) 📊
@@ -150,16 +147,16 @@ class VirtualTransferPump:
if velocity is None:
velocity = self._max_velocity
return abs(volume) / velocity
# 新的set_position方法 - 专门用于SetPumpPosition动作
async def set_position(self, position: float, max_velocity: float = None):
"""
移动到绝对位置 - 专门用于SetPumpPosition动作 🎯
Args:
position (float): 目标位置 (ml)
max_velocity (float): 移动速度 (ml/s)
Returns:
dict: 符合SetPumpPosition.action定义的结果
"""
@@ -167,19 +164,19 @@ class VirtualTransferPump:
# 验证并转换参数
target_position = float(position)
velocity = float(max_velocity) if max_velocity is not None else self._max_velocity
# 限制位置在有效范围内
target_position = max(0.0, min(float(self.max_volume), target_position))
# 计算移动距离
volume_to_move = abs(target_position - self._position)
# 📊 计算显示用的时间(用于日志)
display_duration = self._calculate_display_duration(volume_to_move, velocity)
# ⚡ 计算实际执行时间(快速模式)
actual_duration = self._calculate_duration(volume_to_move, velocity)
# 🎯 确定操作类型和emoji
if target_position > self._position:
operation_type = "吸液"
@@ -190,34 +187,28 @@ class VirtualTransferPump:
else:
operation_type = "保持"
operation_emoji = "📍"
self.logger.info(f"🎯 SET_POSITION: {operation_type} {operation_emoji}")
self.logger.info(
f" 📍 位置: {self._position:.2f}mL → {target_position:.2f}mL (移动 {volume_to_move:.2f}mL)"
)
self.logger.info(f" 📍 位置: {self._position:.2f}mL → {target_position:.2f}mL (移动 {volume_to_move:.2f}mL)")
self.logger.info(f" 🌊 速度: {velocity:.2f} mL/s")
self.logger.info(f" ⏰ 预计时间: {display_duration:.2f}s")
if self._fast_mode:
self.logger.info(f" ⚡ 快速模式: 实际用时 {actual_duration:.2f}s")
# 🚀 模拟移动过程
if volume_to_move > 0.01: # 只有当移动距离足够大时才显示进度
start_position = self._position
steps = 5 if actual_duration > 0.5 else 2 # 根据实际时间调整步数
step_duration = actual_duration / steps
self.logger.info(f"🚀 开始{operation_type}... {operation_emoji}")
for i in range(steps + 1):
# 计算当前位置和进度
progress = (i / steps) * 100 if steps > 0 else 100
current_pos = (
start_position + (target_position - start_position) * (i / steps)
if steps > 0
else target_position
)
current_pos = start_position + (target_position - start_position) * (i / steps) if steps > 0 else target_position
# 更新状态
if i < steps:
self._status = f"{operation_type}"
@@ -225,10 +216,10 @@ class VirtualTransferPump:
else:
self._status = "Idle"
status_emoji = ""
self._position = current_pos
self._current_volume = current_pos
# 显示进度每25%或最后一步)
if i == 0:
self.logger.debug(f" 🔄 {operation_type}开始: {progress:.0f}%")
@@ -236,7 +227,7 @@ class VirtualTransferPump:
self.logger.debug(f" 🔄 {operation_type}进度: {progress:.0f}%")
elif i == steps:
self.logger.info(f"{operation_type}完成: {progress:.0f}% | 当前位置: {current_pos:.2f}mL")
# 等待一小步时间
if i < steps and step_duration > 0:
await self._ros_node.sleep(step_duration)
@@ -245,27 +236,25 @@ class VirtualTransferPump:
self._position = target_position
self._current_volume = target_position
self.logger.info(f" 📍 微调完成: {target_position:.2f}mL")
# 确保最终位置准确
self._position = target_position
self._current_volume = target_position
self._status = "Idle"
# 📊 最终状态日志
if volume_to_move > 0.01:
self.logger.info(
f"🎉 SET_POSITION 完成! 📍 最终位置: {self._position:.2f}mL | 💧 当前体积: {self._current_volume:.2f}mL"
)
self.logger.info(f"🎉 SET_POSITION 完成! 📍 最终位置: {self._position:.2f}mL | 💧 当前体积: {self._current_volume:.2f}mL")
# 返回符合action定义的结果
return {
"success": True,
"message": f"✅ 成功移动到位置 {self._position:.2f}mL ({operation_type})",
"final_position": self._position,
"final_volume": self._current_volume,
"operation_type": operation_type,
"operation_type": operation_type
}
except Exception as e:
error_msg = f"❌ 设置位置失败: {str(e)}"
self.logger.error(error_msg)
@@ -273,138 +262,134 @@ class VirtualTransferPump:
"success": False,
"message": error_msg,
"final_position": self._position,
"final_volume": self._current_volume,
"final_volume": self._current_volume
}
# 其他泵操作方法
async def pull_plunger(self, volume: float, velocity: float = None):
"""
拉取柱塞(吸液) 📥
Args:
volume (float): 要拉取的体积 (ml)
velocity (float): 拉取速度 (ml/s)
"""
new_position = min(self.max_volume, self._position + volume)
actual_volume = new_position - self._position
if actual_volume <= 0:
self.logger.warning("⚠️ 无法吸液 - 已达到最大容量")
return
display_duration = self._calculate_display_duration(actual_volume, velocity)
actual_duration = self._calculate_duration(actual_volume, velocity)
self.logger.info(f"📥 开始吸液: {actual_volume:.2f}mL")
self.logger.info(f" 📍 位置: {self._position:.2f}mL → {new_position:.2f}mL")
self.logger.info(f" ⏰ 预计时间: {display_duration:.2f}s")
if self._fast_mode:
self.logger.info(f" ⚡ 快速模式: 实际用时 {actual_duration:.2f}s")
await self._simulate_operation(actual_duration)
self._position = new_position
self._current_volume = new_position
self.logger.info(f"✅ 吸液完成: {actual_volume:.2f}mL | 💧 当前体积: {self._current_volume:.2f}mL")
async def push_plunger(self, volume: float, velocity: float = None):
"""
推出柱塞(排液) 📤
Args:
volume (float): 要推出的体积 (ml)
velocity (float): 推出速度 (ml/s)
"""
new_position = max(0, self._position - volume)
actual_volume = self._position - new_position
if actual_volume <= 0:
self.logger.warning("⚠️ 无法排液 - 已达到最小容量")
return
display_duration = self._calculate_display_duration(actual_volume, velocity)
actual_duration = self._calculate_duration(actual_volume, velocity)
self.logger.info(f"📤 开始排液: {actual_volume:.2f}mL")
self.logger.info(f" 📍 位置: {self._position:.2f}mL → {new_position:.2f}mL")
self.logger.info(f" ⏰ 预计时间: {display_duration:.2f}s")
if self._fast_mode:
self.logger.info(f" ⚡ 快速模式: 实际用时 {actual_duration:.2f}s")
await self._simulate_operation(actual_duration)
self._position = new_position
self._current_volume = new_position
self.logger.info(f"✅ 排液完成: {actual_volume:.2f}mL | 💧 当前体积: {self._current_volume:.2f}mL")
# 便捷操作方法
async def aspirate(self, volume: float, velocity: float = None):
"""吸液操作 📥"""
await self.pull_plunger(volume, velocity)
async def dispense(self, volume: float, velocity: float = None):
"""排液操作 📤"""
await self.push_plunger(volume, velocity)
async def transfer(self, volume: float, aspirate_velocity: float = None, dispense_velocity: float = None):
"""转移操作(先吸后排) 🔄"""
self.logger.info(f"🔄 开始转移操作: {volume:.2f}mL")
# 吸液
await self.aspirate(volume, aspirate_velocity)
# 短暂停顿
self.logger.debug("⏸️ 短暂停顿...")
await self._ros_node.sleep(0.1)
# 排液
await self.dispense(volume, dispense_velocity)
async def empty_syringe(self, velocity: float = None):
"""清空注射器"""
await self.set_position(0, velocity)
async def fill_syringe(self, velocity: float = None):
"""充满注射器"""
await self.set_position(self.max_volume, velocity)
async def stop_operation(self):
"""停止当前操作"""
self._status = "Idle"
self.logger.info("Operation stopped")
# 状态查询方法
def get_position(self) -> float:
"""获取当前位置"""
return self._position
def get_current_volume(self) -> float:
"""获取当前体积"""
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:
"""检查是否为空"""
return self._current_volume <= 0.01 # 允许小量误差
def is_full(self) -> bool:
"""检查是否已满"""
return self._current_volume >= (self.max_volume - 0.01) # 允许小量误差
def __str__(self):
return (
f"VirtualTransferPump({self.device_id}: {self._current_volume:.2f}/{self.max_volume} ml, {self._status})"
)
return f"VirtualTransferPump({self.device_id}: {self._current_volume:.2f}/{self.max_volume} ml, {self._status})"
def __repr__(self):
return self.__str__()
@@ -413,20 +398,20 @@ class VirtualTransferPump:
async def demo():
"""虚拟泵使用示例"""
pump = VirtualTransferPump("demo_pump", {"max_volume": 50.0})
await pump.initialize()
print(f"Initial state: {pump}")
# 测试set_position方法
result = await pump.set_position(10.0, max_velocity=2.0)
print(f"Set position result: {result}")
print(f"After setting position to 10ml: {pump}")
# 吸液测试
await pump.aspirate(5.0, velocity=2.0)
print(f"After aspirating 5ml: {pump}")
# 清空测试
result = await pump.set_position(0.0)
print(f"Empty result: {result}")

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,5 @@
# 工作站抽象基类物料系统架构说明
## 设计理念
基于用户需求"请你帮我系统思考一下,工作站抽象基类的物料系统基类该如何构建",我们最终确定了一个**PyLabRobot Deck为中心**的简化架构。
### 核心原则
1. **PyLabRobot为物料管理核心**使用PyLabRobot的Deck系统作为物料管理的基础利用其成熟的Resource体系

View File

@@ -0,0 +1,113 @@
# Bioyond Cell 工作站 - 多订单返回示例
本文档说明了 `create_orders` 函数如何收集并返回所有订单的完成报文。
## 问题描述
之前的实现只会等待并返回第一个订单的完成报文,如果有多个订单(例如从 Excel 解析出 3 个订单),只能得到第一个订单的推送信息。
## 解决方案
修改后的 `create_orders` 函数现在会:
1. **提取所有 orderCode**:从 LIMS 接口返回的 `data` 列表中提取所有订单编号
2. **逐个等待完成**:遍历所有 orderCode调用 `wait_for_order_finish` 等待每个订单完成
3. **收集所有报文**:将每个订单的完成报文存入 `all_reports` 列表
4. **统一返回**:返回包含所有订单报文的 JSON 格式数据
## 返回格式
```json
{
"status": "all_completed",
"total_orders": 3,
"reports": [
{
"token": "",
"request_time": "2025-12-24T15:32:09.2148671+08:00",
"data": {
"orderId": "3a1e614d-a082-c44a-60be-68647a35e6f1",
"orderCode": "BSO2025122400024",
"orderName": "DP20251224001",
"status": "30",
"workflowStatus": "completed",
"usedMaterials": [...]
}
},
{
"token": "",
"request_time": "2025-12-24T15:32:09.9999039+08:00",
"data": {
"orderId": "3a1e614d-a0a2-f7a9-9360-610021c9479d",
"orderCode": "BSO2025122400025",
"orderName": "DP20251224002",
"status": "30",
"workflowStatus": "completed",
"usedMaterials": [...]
}
},
{
"token": "",
"request_time": "2025-12-24T15:34:00.4139986+08:00",
"data": {
"orderId": "3a1e614d-a0cd-81ca-9f7f-2f4e93af01cd",
"orderCode": "BSO2025122400026",
"orderName": "DP20251224003",
"status": "30",
"workflowStatus": "completed",
"usedMaterials": [...]
}
}
],
"original_response": {...}
}
```
## 使用示例
```python
# 调用 create_orders
result = workstation.create_orders("20251224.xlsx")
# 访问返回数据
print(f"总订单数: {result['total_orders']}")
print(f"状态: {result['status']}")
# 遍历所有订单的报文
for i, report in enumerate(result['reports'], 1):
order_data = report.get('data', {})
print(f"\n订单 {i}:")
print(f" orderCode: {order_data.get('orderCode')}")
print(f" orderName: {order_data.get('orderName')}")
print(f" status: {order_data.get('status')}")
print(f" 使用物料数: {len(order_data.get('usedMaterials', []))}")
```
## 控制台输出示例
```
[create_orders] 即将提交订单数量: 3
[create_orders] 接口返回: {...}
[create_orders] 等待 3 个订单完成: ['BSO2025122400024', 'BSO2025122400025', 'BSO2025122400026']
[create_orders] 正在等待第 1/3 个订单: BSO2025122400024
[create_orders] ✓ 订单 BSO2025122400024 完成
[create_orders] 正在等待第 2/3 个订单: BSO2025122400025
[create_orders] ✓ 订单 BSO2025122400025 完成
[create_orders] 正在等待第 3/3 个订单: BSO2025122400026
[create_orders] ✓ 订单 BSO2025122400026 完成
[create_orders] 所有订单已完成,共收集 3 个报文
实验记录本========================create_orders========================
返回报文数量: 3
报文 1: orderCode=BSO2025122400024, status=30
报文 2: orderCode=BSO2025122400025, status=30
报文 3: orderCode=BSO2025122400026, status=30
========================
```
## 关键改进
1.**等待所有订单**:不再只等待第一个订单,而是遍历所有 orderCode
2.**收集完整报文**:每个订单的完整推送报文都被保存在 `reports` 数组中
3.**详细日志**:清晰显示正在等待哪个订单,以及完成情况
4.**错误处理**:即使某个订单失败,也会记录其状态信息
5.**统一格式**:返回的 JSON 格式便于后续处理和分析

View File

@@ -0,0 +1,204 @@
# BioyondCellWorkstation JSON 配置迁移经验总结
**日期**: 2026-01-13
**目的**: 从 `config.py` 迁移到 JSON 配置文件
---
## 问题背景
原系统通过 `config.py` 管理配置,导致:
1. HTTP 服务重复启动(父类 `BioyondWorkstation` 和子类都启动)
2. 配置分散在代码中,不便于管理
3. 无法通过 JSON 统一配置所有参数
---
## 解决方案:嵌套配置结构
### JSON 结构设计
**正确示例** (嵌套在 `config` 中):
```json
{
"nodes": [{
"id": "bioyond_cell_workstation",
"config": {
"deck": {...},
"protocol_type": [],
"bioyond_config": {
"api_host": "http://172.16.11.219:44388",
"api_key": "8A819E5C",
"timeout": 30,
"HTTP_host": "172.16.11.206",
"HTTP_port": 8080,
"debug_mode": false,
"material_type_mappings": {...},
"warehouse_mapping": {...},
"solid_liquid_mappings": {...}
}
},
"data": {}
}]
}
```
**关键点**
-`bioyond_config` 放在 `config` 中(会传递到 `__init__`
- ❌ **不要**放在 `data` 中(`data` 是运行时状态,不会传递)
---
## Python 代码适配
### 1. 修改 `BioyondCellWorkstation.__init__` 签名
**文件**: `bioyond_cell_workstation.py`
```python
def __init__(self, bioyond_config: dict = None, deck=None, protocol_type=None, **kwargs):
"""
Args:
bioyond_config: 从 JSON 加载的配置字典
deck: Deck 配置
protocol_type: 协议类型
"""
# 验证配置
if bioyond_config is None:
raise ValueError("需要 bioyond_config 参数")
# 保存配置
self.bioyond_config = bioyond_config
# 设置 HTTP 服务去重标志
self.bioyond_config["_disable_auto_http_service"] = True
# 调用父类
super().__init__(bioyond_config=self.bioyond_config, deck=deck, **kwargs)
```
### 2. 替换全局变量引用
**修改前**(使用全局变量):
```python
from config import MATERIAL_TYPE_MAPPINGS, WAREHOUSE_MAPPING
def create_sample(self, board_type, ...):
carrier_type_id = MATERIAL_TYPE_MAPPINGS[board_type][1]
location_id = WAREHOUSE_MAPPING[warehouse_name]["site_uuids"][location_code]
```
**修改后**(从配置读取):
```python
def create_sample(self, board_type, ...):
carrier_type_id = self.bioyond_config['material_type_mappings'][board_type][1]
location_id = self.bioyond_config['warehouse_mapping'][warehouse_name]["site_uuids"][location_code]
```
### 3. 修复父类配置访问
`station.py` 中安全访问配置默认值:
```python
# 修改前(会 KeyError
self._http_service_config = {
"host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG["http_service_host"])
}
# 修改后(安全访问)
self._http_service_config = {
"host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG.get("http_service_host", ""))
}
```
---
## 常见陷阱
### ❌ 错误1将配置放在 `data` 字段
```json
"config": {"deck": {...}},
"data": {"bioyond_config": {...}} // ❌ 不会传递到 __init__
```
### ❌ 错误2扁平化配置已废弃方案
虽然扁平化也能工作,但不推荐:
```json
"config": {
"deck": {...},
"api_host": "...", // ❌ 不够清晰
"api_key": "...",
"HTTP_host": "..."
}
```
### ❌ 错误3忘记替换全局变量引用
代码中直接使用 `MATERIAL_TYPE_MAPPINGS` 等全局变量会导致 `NameError`
---
## 云端同步注意事项
使用 `--upload_registry` 时,云端配置可能覆盖本地配置:
- 首次上传时确保 JSON 完整
- 或使用新的 `ak/sk` 避免旧配置干扰
- 调试时可暂时移除 `--upload_registry` 参数
---
## 验证清单
启动成功后应看到:
```
✅ 从 JSON 配置加载 bioyond_config 成功
API Host: http://...
HTTP Service: ...
✅ BioyondCellWorkstation 初始化完成
Loaded ResourceTreeSet with ... nodes
```
运行时不应出现:
-`NameError: name 'MATERIAL_TYPE_MAPPINGS' is not defined`
-`KeyError: 'http_service_host'`
-`bioyond_config 缺少必需参数`
---
## 调试经验
1. **添加调试日志**查看参数传递链路:
- `graphio.py`: JSON 加载后的 config 内容
- `initialize_device.py`: `device_config.res_content.config` 的键
- `bioyond_cell_workstation.py`: `__init__` 接收到的参数
2. **config vs data 区别**
- `config`: 初始化参数,传递给 `__init__`
- `data`: 运行时状态,不传递给 `__init__`
3. **参数名必须匹配**
- JSON 中的键名必须与 `__init__` 参数名完全一致
4. **调试代码清理**:完成后记得删除调试日志(🔍 DEBUG 标记)
---
## 修改文件清单
| 文件 | 修改内容 |
|------|----------|
| `yibin_electrolyte_config.json` | 创建嵌套 `config.bioyond_config` 结构 |
| `bioyond_cell_workstation.py` | 修改 `__init__` 接收 `bioyond_config`,替换所有全局变量引用 |
| `station.py` | 安全访问 `HTTP_SERVICE_CONFIG` 默认值 |
---
## 参考代码位置
- JSON 配置示例: `yibin_electrolyte_config.json` L12-L353
- `__init__` 实现: `bioyond_cell_workstation.py` L39-L94
- 全局变量替换示例: `bioyond_cell_workstation.py` L2005, L1863, L1966
- HTTP 服务配置: `station.py` L629-L634
---
**总结**: 使用嵌套结构将所有配置放在 `config.bioyond_config` 中,修改 `__init__` 直接接收该参数,并替换所有全局变量引用为 `self.bioyond_config` 访问。

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