mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-03-24 11:24:19 +00:00
Compare commits
73 Commits
0f6264503a
...
v0.10.17
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
176de521b4 | ||
|
|
38c5c267af | ||
|
|
2a5ddd611d | ||
|
|
8580b84167 | ||
|
|
3f80349d7d | ||
|
|
024156848e | ||
|
|
8066c200b9 | ||
|
|
266366cc25 | ||
|
|
121c3985cc | ||
|
|
6ca5c72fc6 | ||
|
|
bc8c49ddda | ||
|
|
28f93737ac | ||
|
|
5dc81ec9be | ||
|
|
13a6795657 | ||
|
|
53219d8b04 | ||
|
|
b1cdef9185 | ||
|
|
9854ed8c9c | ||
|
|
52544a2c69 | ||
|
|
5ce433e235 | ||
|
|
c7c14d2332 | ||
|
|
6fdd482649 | ||
|
|
d390236318 | ||
|
|
ed8ee29732 | ||
|
|
ffc583e9d5 | ||
|
|
f1ad0c9c96 | ||
|
|
8fa3407649 | ||
|
|
d3282822fc | ||
|
|
554bcade24 | ||
|
|
a662c75de1 | ||
|
|
931614fe64 | ||
|
|
d39662f65f | ||
|
|
acf5fdebf8 | ||
|
|
7f7b1c13c0 | ||
|
|
75f09034ff | ||
|
|
549a50220b | ||
|
|
4189a2cfbe | ||
|
|
48895a9bb1 | ||
|
|
891f126ed6 | ||
|
|
4d3475a849 | ||
|
|
b475db66df | ||
|
|
a625a86e3e | ||
|
|
37e0f1037c | ||
|
|
a242253145 | ||
|
|
448e0074b7 | ||
|
|
304827fc8d | ||
|
|
872b3d781f | ||
|
|
813400f2b4 | ||
|
|
b6dfe2b944 | ||
|
|
8807865649 | ||
|
|
5fc7eb7586 | ||
|
|
9bd72b48e1 | ||
|
|
42b78ab4c1 | ||
|
|
9645609a05 | ||
|
|
a2a827d7ac | ||
|
|
bb3ca645a4 | ||
|
|
37ee43d19a | ||
|
|
bc30f23e34 | ||
|
|
166d84afe1 | ||
|
|
1b43c53015 | ||
|
|
d4415f5a35 | ||
|
|
0260cbbedb | ||
|
|
7c440d10ab | ||
|
|
c85c49817d | ||
|
|
c70eafa5f0 | ||
|
|
b64466d443 | ||
|
|
ef3f24ed48 | ||
|
|
2a8e8d014b | ||
|
|
e0da1c7217 | ||
|
|
51d3e61723 | ||
|
|
6b5765bbf3 | ||
|
|
eb1f3fbe1c | ||
|
|
fb93b1cd94 | ||
|
|
9aeffebde1 |
@@ -3,7 +3,7 @@
|
||||
|
||||
package:
|
||||
name: unilabos
|
||||
version: 0.10.18
|
||||
version: 0.10.17
|
||||
|
||||
source:
|
||||
path: ../../unilabos
|
||||
@@ -41,20 +41,19 @@ requirements:
|
||||
- networkx
|
||||
- typing_extensions
|
||||
- websockets
|
||||
- opentrons_shared_data
|
||||
- pint
|
||||
- fastapi
|
||||
- jinja2
|
||||
- requests
|
||||
- uvicorn
|
||||
- if: not osx
|
||||
then:
|
||||
- opcua
|
||||
- opcua
|
||||
- pyserial
|
||||
- pandas
|
||||
- pymodbus
|
||||
- matplotlib
|
||||
- pylibftdi
|
||||
- uni-lab::unilabos-env ==0.10.18
|
||||
- uni-lab::unilabos-env ==0.10.17
|
||||
|
||||
about:
|
||||
repository: https://github.com/deepmodeling/Uni-Lab-OS
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
package:
|
||||
name: unilabos-env
|
||||
version: 0.10.18
|
||||
version: 0.10.17
|
||||
|
||||
build:
|
||||
noarch: generic
|
||||
@@ -36,4 +36,4 @@ requirements:
|
||||
about:
|
||||
repository: https://github.com/deepmodeling/Uni-Lab-OS
|
||||
license: GPL-3.0-only
|
||||
description: "UniLabOS Environment - ROS2 and conda dependencies"
|
||||
description: "UniLabOS Environment - ROS2 and conda dependencies (for developers: pip install -e .)"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
package:
|
||||
name: unilabos-full
|
||||
version: 0.10.18
|
||||
version: 0.10.17
|
||||
|
||||
build:
|
||||
noarch: generic
|
||||
@@ -11,7 +11,7 @@ build:
|
||||
requirements:
|
||||
run:
|
||||
# Base unilabos package (includes unilabos-env)
|
||||
- uni-lab::unilabos ==0.10.18
|
||||
- uni-lab::unilabos ==0.10.17
|
||||
# Documentation tools
|
||||
- sphinx
|
||||
- sphinx_rtd_theme
|
||||
|
||||
4
.github/workflows/ci-check.yml
vendored
4
.github/workflows/ci-check.yml
vendored
@@ -47,9 +47,9 @@ jobs:
|
||||
uv pip install -r unilabos/utils/requirements.txt
|
||||
uv pip install pywinauto git+https://github.com/Xuwznln/pylabrobot.git
|
||||
uv pip uninstall enum34 || echo enum34 not installed, skipping
|
||||
uv pip install .
|
||||
uv pip install -e .
|
||||
|
||||
- name: Run check mode (AST registry validation)
|
||||
- name: Run check mode (complete_registry)
|
||||
run: |
|
||||
call conda activate check-env
|
||||
echo Running check mode...
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,7 +5,6 @@ output/
|
||||
unilabos_data/
|
||||
pyrightconfig.json
|
||||
.cursorignore
|
||||
device_package*/
|
||||
## Python
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
|
||||
87
AGENTS.md
87
AGENTS.md
@@ -1,87 +0,0 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
Also follow the monorepo-level rules in `../AGENTS.md`.
|
||||
|
||||
## Build & Development
|
||||
|
||||
```bash
|
||||
# Install in editable mode (requires mamba env with python 3.11)
|
||||
pip install -e .
|
||||
uv pip install -r unilabos/utils/requirements.txt
|
||||
|
||||
# Run with a device graph
|
||||
unilab --graph <graph.json> --config <config.py> --backend ros
|
||||
unilab --graph <graph.json> --config <config.py> --backend simple # no ROS2 needed
|
||||
|
||||
# Common CLI flags
|
||||
unilab --app_bridges websocket fastapi # communication bridges
|
||||
unilab --test_mode # simulate hardware, no real execution
|
||||
unilab --check_mode # CI validation of registry imports
|
||||
unilab --skip_env_check # skip auto-install of dependencies
|
||||
unilab --visual rviz|web|disable # visualization mode
|
||||
unilab --is_slave # run as slave node
|
||||
|
||||
# Workflow upload subcommand
|
||||
unilab workflow_upload -f <workflow.json> -n <name> --tags tag1 tag2
|
||||
|
||||
# Tests
|
||||
pytest tests/ # all tests
|
||||
pytest tests/resources/test_resourcetreeset.py # single test file
|
||||
pytest tests/resources/test_resourcetreeset.py::TestClassName::test_method # single test
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Startup Flow
|
||||
|
||||
`unilab` CLI → `unilabos/app/main.py:main()` → loads config → builds registry → reads device graph (JSON/GraphML) → starts backend thread (ROS2/simple) → starts FastAPI web server + WebSocket client.
|
||||
|
||||
### Core Layers
|
||||
|
||||
**Registry** (`unilabos/registry/`): Singleton `Registry` class discovers and catalogs all device types, resource types, and communication devices from YAML definitions. Device types live in `registry/devices/*.yaml`, resources in `registry/resources/`, comms in `registry/device_comms/`. The registry resolves class paths to actual Python classes via `utils/import_manager.py`.
|
||||
|
||||
**Resource Tracking** (`unilabos/resources/resource_tracker.py`): Pydantic-based `ResourceDict` → `ResourceDictInstance` → `ResourceTreeSet` hierarchy. `ResourceTreeSet` is the canonical in-memory representation of all devices and resources, used throughout the system. Graph I/O is in `resources/graphio.py` (reads JSON/GraphML device topology files into `nx.Graph` + `ResourceTreeSet`).
|
||||
|
||||
**Device Drivers** (`unilabos/devices/`): 30+ hardware drivers organized by device type (liquid_handling, hplc, balance, arm, etc.). Each driver is a Python class that gets wrapped by `ros/device_node_wrapper.py:ros2_device_node()` to become a ROS2 node with publishers, subscribers, and action servers.
|
||||
|
||||
**ROS2 Layer** (`unilabos/ros/`): `device_node_wrapper.py` dynamically wraps any device class into `ROS2DeviceNode` (defined in `ros/nodes/base_device_node.py`). Preset node types in `ros/nodes/presets/` include `host_node`, `controller_node`, `workstation`, `serial_node`, `camera`. Messages use custom `unilabos_msgs` (pre-built, distributed via releases).
|
||||
|
||||
**Protocol Compilation** (`unilabos/compile/`): 20+ protocol compilers (add, centrifuge, dissolve, filter, heatchill, stir, pump, etc.) that transform YAML protocol definitions into executable sequences.
|
||||
|
||||
**Communication** (`unilabos/device_comms/`): Hardware communication adapters — OPC-UA client, Modbus PLC, RPC, and a universal driver. `app/communication.py` provides a factory pattern for WebSocket client connections to the cloud.
|
||||
|
||||
**Web/API** (`unilabos/app/web/`): FastAPI server with REST API (`api.py`), Jinja2 template pages (`pages.py`), and HTTP client for cloud communication (`client.py`). Runs on port 8002 by default.
|
||||
|
||||
### Configuration System
|
||||
|
||||
- **Config classes** in `unilabos/config/config.py`: `BasicConfig`, `WSConfig`, `HTTPConfig`, `ROSConfig` — all class-level attributes, loaded from Python config files
|
||||
- Config files are `.py` files with matching class names (see `config/example_config.py`)
|
||||
- Environment variables override with prefix `UNILABOS_` (e.g., `UNILABOS_BASICCONFIG_PORT=9000`)
|
||||
- Device topology defined in graph files (JSON with node-link format, or GraphML)
|
||||
|
||||
### Key Data Flow
|
||||
|
||||
1. Graph file → `graphio.read_node_link_json()` → `(nx.Graph, ResourceTreeSet, resource_links)`
|
||||
2. `ResourceTreeSet` + `Registry` → `initialize_device.initialize_device_from_dict()` → `ROS2DeviceNode` instances
|
||||
3. Device nodes communicate via ROS2 topics/actions or direct Python calls (simple backend)
|
||||
4. Cloud sync via WebSocket (`app/ws_client.py`) and HTTP (`app/web/client.py`)
|
||||
|
||||
### Test Data
|
||||
|
||||
Example device graphs and experiment configs are in `unilabos/test/experiments/` (not `tests/`). Registry test fixtures in `unilabos/test/registry/`.
|
||||
|
||||
## Code Conventions
|
||||
|
||||
- Code comments and log messages in simplified Chinese
|
||||
- Python 3.11+, type hints expected
|
||||
- Pydantic models for data validation (`resource_tracker.py`)
|
||||
- Singleton pattern via `@singleton` decorator (`utils/decorator.py`)
|
||||
- Dynamic class loading via `utils/import_manager.py` — device classes resolved at runtime from registry YAML paths
|
||||
- CLI argument dashes auto-converted to underscores for consistency
|
||||
|
||||
## Licensing
|
||||
|
||||
- Framework code: GPL-3.0
|
||||
- Device drivers (`unilabos/devices/`): DP Technology Proprietary License — do not redistribute
|
||||
@@ -15,9 +15,6 @@ Python 类设备驱动在完成注册表后可以直接在 Uni-Lab 中使用,
|
||||
**示例:**
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import device, topic_config
|
||||
|
||||
@device(id="mock_gripper", category=["gripper"], description="Mock Gripper")
|
||||
class MockGripper:
|
||||
def __init__(self):
|
||||
self._position: float = 0.0
|
||||
@@ -26,23 +23,19 @@ class MockGripper:
|
||||
self._status = "Idle"
|
||||
|
||||
@property
|
||||
@topic_config() # 添加 @topic_config 才会定时广播
|
||||
def position(self) -> float:
|
||||
return self._position
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def velocity(self) -> float:
|
||||
return self._velocity
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def torque(self) -> float:
|
||||
return self._torque
|
||||
|
||||
# 使用 @topic_config 装饰的属性,接入 Uni-Lab 时会定时对外广播
|
||||
# 会被自动识别的设备属性,接入 Uni-Lab 时会定时对外广播
|
||||
@property
|
||||
@topic_config(period=2.0) # 可自定义发布周期
|
||||
def status(self) -> str:
|
||||
return self._status
|
||||
|
||||
@@ -156,7 +149,7 @@ my_device: # 设备唯一标识符
|
||||
|
||||
系统会自动分析您的 Python 驱动类并生成:
|
||||
|
||||
- `status_types`:从 `@topic_config` 装饰的 `@property` 或方法自动识别状态属性
|
||||
- `status_types`:从 `@property` 装饰的方法自动识别状态属性
|
||||
- `action_value_mappings`:从类方法自动生成动作映射
|
||||
- `init_param_schema`:从 `__init__` 方法分析初始化参数
|
||||
- `schema`:前端显示用的属性类型定义
|
||||
@@ -186,9 +179,7 @@ Uni-Lab 设备驱动是一个 Python 类,需要遵循以下结构:
|
||||
|
||||
```python
|
||||
from typing import Dict, Any
|
||||
from unilabos.registry.decorators import device, topic_config
|
||||
|
||||
@device(id="my_device", category=["general"], description="My Device")
|
||||
class MyDevice:
|
||||
"""设备类文档字符串
|
||||
|
||||
@@ -207,9 +198,8 @@ class MyDevice:
|
||||
# 初始化硬件连接
|
||||
|
||||
@property
|
||||
@topic_config() # 必须添加 @topic_config 才会广播
|
||||
def status(self) -> str:
|
||||
"""设备状态(通过 @topic_config 广播)"""
|
||||
"""设备状态(会自动广播)"""
|
||||
return self._status
|
||||
|
||||
def my_action(self, param: float) -> Dict[str, Any]:
|
||||
@@ -227,61 +217,34 @@ class MyDevice:
|
||||
|
||||
## 状态属性 vs 动作方法
|
||||
|
||||
### 状态属性(@property + @topic_config)
|
||||
### 状态属性(@property)
|
||||
|
||||
状态属性需要同时使用 `@property` 和 `@topic_config` 装饰器才会被识别并定期广播:
|
||||
状态属性会被自动识别并定期广播:
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import topic_config
|
||||
|
||||
@property
|
||||
@topic_config() # 必须添加,否则不会广播
|
||||
def temperature(self) -> float:
|
||||
"""当前温度"""
|
||||
return self._read_temperature()
|
||||
|
||||
@property
|
||||
@topic_config(period=2.0) # 可自定义发布周期(秒)
|
||||
def status(self) -> str:
|
||||
"""设备状态: idle, running, error"""
|
||||
return self._status
|
||||
|
||||
@property
|
||||
@topic_config(name="ready") # 可自定义发布名称
|
||||
def is_ready(self) -> bool:
|
||||
"""设备是否就绪"""
|
||||
return self._status == "idle"
|
||||
```
|
||||
|
||||
也可以使用普通方法(非 @property)配合 `@topic_config`:
|
||||
|
||||
```python
|
||||
@topic_config(period=10.0)
|
||||
def get_sensor_data(self) -> Dict[str, float]:
|
||||
"""获取传感器数据(get_ 前缀会自动去除,发布名为 sensor_data)"""
|
||||
return {"temp": self._temp, "humidity": self._humidity}
|
||||
```
|
||||
|
||||
**`@topic_config` 参数**:
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `period` | float | 5.0 | 发布周期(秒) |
|
||||
| `print_publish` | bool | 节点默认 | 是否打印发布日志 |
|
||||
| `qos` | int | 10 | QoS 深度 |
|
||||
| `name` | str | None | 自定义发布名称 |
|
||||
|
||||
**发布名称优先级**:`@topic_config(name=...)` > `get_` 前缀去除 > 方法名
|
||||
|
||||
**特点**:
|
||||
|
||||
- 必须使用 `@topic_config` 装饰器
|
||||
- 支持 `@property` 和普通方法
|
||||
- 添加到注册表的 `status_types`
|
||||
- 使用`@property`装饰器
|
||||
- 只读,不能有参数
|
||||
- 自动添加到注册表的`status_types`
|
||||
- 定期发布到 ROS2 topic
|
||||
|
||||
> **⚠️ 重要:** 仅有 `@property` 装饰器而没有 `@topic_config` 的属性**不会**被广播。这是一个 Breaking Change。
|
||||
|
||||
### 动作方法
|
||||
|
||||
动作方法是设备可以执行的操作:
|
||||
@@ -534,7 +497,6 @@ class LiquidHandler:
|
||||
self._status = "idle"
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def status(self) -> str:
|
||||
return self._status
|
||||
|
||||
@@ -924,52 +886,7 @@ class MyDevice:
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 使用 `@device` 装饰器标识设备类
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import device
|
||||
|
||||
@device(id="my_device", category=["heating"], description="My Heating Device", icon="heater.webp")
|
||||
class MyDevice:
|
||||
...
|
||||
```
|
||||
|
||||
- `id`:设备唯一标识符,用于注册表匹配
|
||||
- `category`:分类列表,前端用于分组显示
|
||||
- `description`:设备描述
|
||||
- `icon`:图标文件名(可选)
|
||||
|
||||
### 2. 使用 `@topic_config` 声明需要广播的状态
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import topic_config
|
||||
|
||||
# ✓ @property + @topic_config → 会广播
|
||||
@property
|
||||
@topic_config(period=2.0)
|
||||
def temperature(self) -> float:
|
||||
return self._temp
|
||||
|
||||
# ✓ 普通方法 + @topic_config → 会广播(get_ 前缀自动去除)
|
||||
@topic_config(period=10.0)
|
||||
def get_sensor_data(self) -> Dict[str, float]:
|
||||
return {"temp": self._temp}
|
||||
|
||||
# ✓ 使用 name 参数自定义发布名称
|
||||
@property
|
||||
@topic_config(name="ready")
|
||||
def is_ready(self) -> bool:
|
||||
return self._status == "idle"
|
||||
|
||||
# ✗ 仅有 @property,没有 @topic_config → 不会广播
|
||||
@property
|
||||
def internal_state(self) -> str:
|
||||
return self._state
|
||||
```
|
||||
|
||||
> **注意:** 与 `@property` 连用时,`@topic_config` 必须放在 `@property` 下面。
|
||||
|
||||
### 3. 类型注解
|
||||
### 1. 类型注解
|
||||
|
||||
```python
|
||||
from typing import Dict, Any, Optional, List
|
||||
@@ -984,7 +901,7 @@ def method(
|
||||
pass
|
||||
```
|
||||
|
||||
### 4. 文档字符串
|
||||
### 2. 文档字符串
|
||||
|
||||
```python
|
||||
def method(self, param: float) -> Dict[str, Any]:
|
||||
@@ -1006,7 +923,7 @@ def method(self, param: float) -> Dict[str, Any]:
|
||||
pass
|
||||
```
|
||||
|
||||
### 5. 配置验证
|
||||
### 3. 配置验证
|
||||
|
||||
```python
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
@@ -1020,7 +937,7 @@ def __init__(self, config: Dict[str, Any]):
|
||||
self.baudrate = config['baudrate']
|
||||
```
|
||||
|
||||
### 6. 资源清理
|
||||
### 4. 资源清理
|
||||
|
||||
```python
|
||||
def __del__(self):
|
||||
@@ -1029,7 +946,7 @@ def __del__(self):
|
||||
self.connection.close()
|
||||
```
|
||||
|
||||
### 7. 设计前端友好的返回值
|
||||
### 5. 设计前端友好的返回值
|
||||
|
||||
**记住:返回值会直接显示在 Web 界面**
|
||||
|
||||
|
||||
@@ -422,20 +422,18 @@ placeholder_keys:
|
||||
|
||||
### status_types
|
||||
|
||||
系统会扫描你的 Python 类,从带有 `@topic_config` 装饰器的 `@property` 或方法自动生成这部分:
|
||||
系统会扫描你的 Python 类,从状态方法(property 或 get\_方法)自动生成这部分:
|
||||
|
||||
```yaml
|
||||
status_types:
|
||||
current_temperature: float # 从 @topic_config 装饰的 @property 或方法
|
||||
is_heating: bool
|
||||
status: str
|
||||
current_temperature: float # 从 get_current_temperature() 或 @property current_temperature
|
||||
is_heating: bool # 从 get_is_heating() 或 @property is_heating
|
||||
status: str # 从 get_status() 或 @property status
|
||||
```
|
||||
|
||||
**注意事项**:
|
||||
|
||||
- 仅有带 `@topic_config` 装饰器的 `@property` 或方法才会被识别为状态属性
|
||||
- 没有 `@topic_config` 的 `@property` 不会生成 status_types,也不会广播
|
||||
- `get_` 前缀的方法名会自动去除前缀(如 `get_temperature` → `temperature`)
|
||||
- 系统会查找所有 `get_` 开头的方法和 `@property` 装饰的属性
|
||||
- 类型会自动转成相应的类型(如 `str`、`float`、`bool`)
|
||||
- 如果类型是 `Any`、`None` 或未知的,默认使用 `String`
|
||||
|
||||
@@ -539,13 +537,11 @@ class AdvancedLiquidHandler:
|
||||
self._temperature = 25.0
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def status(self) -> str:
|
||||
"""设备状态"""
|
||||
return self._status
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def temperature(self) -> float:
|
||||
"""当前温度"""
|
||||
return self._temperature
|
||||
@@ -813,23 +809,21 @@ my_temperature_controller:
|
||||
你的设备类需要符合以下要求:
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import device, topic_config
|
||||
from unilabos.common.device_base import DeviceBase
|
||||
|
||||
@device(id="my_device", category=["temperature"], description="My Device")
|
||||
class MyDevice:
|
||||
class MyDevice(DeviceBase):
|
||||
def __init__(self, config):
|
||||
"""初始化,参数会自动分析到 init_param_schema.config"""
|
||||
super().__init__(config)
|
||||
self.port = config.get('port', '/dev/ttyUSB0')
|
||||
|
||||
# 状态方法(必须添加 @topic_config 才会生成到 status_types 并广播)
|
||||
# 状态方法(会自动生成到 status_types)
|
||||
@property
|
||||
@topic_config()
|
||||
def status(self):
|
||||
"""返回设备状态"""
|
||||
return "idle"
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def temperature(self):
|
||||
"""返回当前温度"""
|
||||
return 25.0
|
||||
@@ -1045,34 +1039,7 @@ resource.type # "resource"
|
||||
|
||||
### 代码规范
|
||||
|
||||
1. **使用 `@device` 装饰器标识设备类**
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import device
|
||||
|
||||
@device(id="my_device", category=["heating"], description="My Device")
|
||||
class MyDevice:
|
||||
...
|
||||
```
|
||||
|
||||
2. **使用 `@topic_config` 声明广播属性**
|
||||
|
||||
```python
|
||||
from unilabos.registry.decorators import topic_config
|
||||
|
||||
# ✓ 需要广播的状态属性
|
||||
@property
|
||||
@topic_config(period=2.0)
|
||||
def temperature(self) -> float:
|
||||
return self._temp
|
||||
|
||||
# ✗ 仅有 @property 不会广播
|
||||
@property
|
||||
def internal_counter(self) -> int:
|
||||
return self._counter
|
||||
```
|
||||
|
||||
3. **始终使用类型注解**
|
||||
1. **始终使用类型注解**
|
||||
|
||||
```python
|
||||
# ✓ 好
|
||||
@@ -1084,7 +1051,7 @@ def method(self, resource, device):
|
||||
pass
|
||||
```
|
||||
|
||||
4. **提供有意义的参数名**
|
||||
2. **提供有意义的参数名**
|
||||
|
||||
```python
|
||||
# ✓ 好 - 清晰的参数名
|
||||
@@ -1096,7 +1063,7 @@ def transfer(self, r1: ResourceSlot, r2: ResourceSlot):
|
||||
pass
|
||||
```
|
||||
|
||||
5. **使用 Optional 表示可选参数**
|
||||
3. **使用 Optional 表示可选参数**
|
||||
|
||||
```python
|
||||
from typing import Optional
|
||||
@@ -1109,7 +1076,7 @@ def method(
|
||||
pass
|
||||
```
|
||||
|
||||
6. **添加详细的文档字符串**
|
||||
4. **添加详细的文档字符串**
|
||||
|
||||
```python
|
||||
def method(
|
||||
@@ -1129,13 +1096,13 @@ def method(
|
||||
pass
|
||||
```
|
||||
|
||||
7. **方法命名规范**
|
||||
5. **方法命名规范**
|
||||
|
||||
- 状态方法使用 `@property` + `@topic_config` 装饰器,或普通方法 + `@topic_config`
|
||||
- 状态方法使用 `@property` 装饰器或 `get_` 前缀
|
||||
- 动作方法使用动词开头
|
||||
- 保持命名清晰、一致
|
||||
|
||||
8. **完善的错误处理**
|
||||
6. **完善的错误处理**
|
||||
- 实现完善的错误处理
|
||||
- 添加日志记录
|
||||
- 提供有意义的错误信息
|
||||
|
||||
@@ -221,10 +221,10 @@ Laboratory A Laboratory B
|
||||
|
||||
```bash
|
||||
# 实验室A
|
||||
unilab --ak your_ak --sk your_sk --upload_registry
|
||||
unilab --ak your_ak --sk your_sk --upload_registry --use_remote_resource
|
||||
|
||||
# 实验室B
|
||||
unilab --ak your_ak --sk your_sk --upload_registry
|
||||
unilab --ak your_ak --sk your_sk --upload_registry --use_remote_resource
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -439,9 +439,6 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
|
||||
1. 访问 Web 界面,进入"仪器耗材"模块
|
||||
2. 在"仪器设备"区域找到并添加上述设备
|
||||
3. 在"物料耗材"区域找到并添加容器
|
||||
4. 在workstation中配置protocol_type包含PumpTransferProtocol
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
@@ -452,9 +449,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. 在画布上连接它们(建立父子关系)
|
||||
|
||||

|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 81 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 415 KiB After Width: | Height: | Size: 275 KiB |
@@ -22,6 +22,7 @@ options:
|
||||
--is_slave Run the backend as slave node (without host privileges).
|
||||
--slave_no_host Skip waiting for host service in slave mode
|
||||
--upload_registry Upload registry information when starting unilab
|
||||
--use_remote_resource Use remote resources when starting unilab
|
||||
--config CONFIG Configuration file path, supports .py format Python config files
|
||||
--port PORT Port for web service information page
|
||||
--disable_browser Disable opening information page on startup
|
||||
@@ -84,7 +85,7 @@ Uni-Lab 的启动过程分为以下几个阶段:
|
||||
支持两种方式:
|
||||
|
||||
- **本地文件**:使用 `-g` 指定图谱文件(支持 JSON 和 GraphML 格式)
|
||||
- **远程资源**:不指定本地文件即可
|
||||
- **远程资源**:使用 `--use_remote_resource` 从云端获取
|
||||
|
||||
### 7. 注册表构建
|
||||
|
||||
@@ -195,7 +196,7 @@ unilab --config path/to/your/config.py
|
||||
unilab --ak your_ak --sk your_sk -g path/to/graph.json --upload_registry
|
||||
|
||||
# 使用远程资源启动
|
||||
unilab --ak your_ak --sk your_sk
|
||||
unilab --ak your_ak --sk your_sk --use_remote_resource
|
||||
|
||||
# 更新注册表
|
||||
unilab --ak your_ak --sk your_sk --complete_registry
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: ros-humble-unilabos-msgs
|
||||
version: 0.10.18
|
||||
version: 0.10.17
|
||||
source:
|
||||
path: ../../unilabos_msgs
|
||||
target_directory: src
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: unilabos
|
||||
version: "0.10.18"
|
||||
version: "0.10.17"
|
||||
|
||||
source:
|
||||
path: ../..
|
||||
|
||||
@@ -2,6 +2,7 @@ import json
|
||||
import logging
|
||||
import traceback
|
||||
import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import networkx as nx
|
||||
@@ -24,15 +25,7 @@ class SimpleGraph:
|
||||
|
||||
def add_edge(self, source, target, **attrs):
|
||||
"""添加边"""
|
||||
# edge = {"source": source, "target": target, **attrs}
|
||||
edge = {
|
||||
"source": source, "target": target,
|
||||
"source_node_uuid": source,
|
||||
"target_node_uuid": target,
|
||||
"source_handle_io": "source",
|
||||
"target_handle_io": "target",
|
||||
**attrs
|
||||
}
|
||||
edge = {"source": source, "target": target, **attrs}
|
||||
self.edges.append(edge)
|
||||
|
||||
def to_dict(self):
|
||||
@@ -49,7 +42,6 @@ class SimpleGraph:
|
||||
"multigraph": False,
|
||||
"graph": {},
|
||||
"nodes": nodes_list,
|
||||
"edges": self.edges,
|
||||
"links": self.edges,
|
||||
}
|
||||
|
||||
@@ -66,8 +58,495 @@ def extract_json_from_markdown(text: str) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def convert_to_type(val: str) -> Any:
|
||||
"""将字符串值转换为适当的数据类型"""
|
||||
if val == "True":
|
||||
return True
|
||||
if val == "False":
|
||||
return False
|
||||
if val == "?":
|
||||
return None
|
||||
if val.endswith(" g"):
|
||||
return float(val.split(" ")[0])
|
||||
if val.endswith("mg"):
|
||||
return float(val.split("mg")[0])
|
||||
elif val.endswith("mmol"):
|
||||
return float(val.split("mmol")[0]) / 1000
|
||||
elif val.endswith("mol"):
|
||||
return float(val.split("mol")[0])
|
||||
elif val.endswith("ml"):
|
||||
return float(val.split("ml")[0])
|
||||
elif val.endswith("RPM"):
|
||||
return float(val.split("RPM")[0])
|
||||
elif val.endswith(" °C"):
|
||||
return float(val.split(" ")[0])
|
||||
elif val.endswith(" %"):
|
||||
return float(val.split(" ")[0])
|
||||
return val
|
||||
|
||||
|
||||
def refactor_data(data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""统一的数据重构函数,根据操作类型自动选择模板"""
|
||||
refactored_data = []
|
||||
|
||||
# 定义操作映射,包含生物实验和有机化学的所有操作
|
||||
OPERATION_MAPPING = {
|
||||
# 生物实验操作
|
||||
"transfer_liquid": "SynBioFactory-liquid_handler.prcxi-transfer_liquid",
|
||||
"transfer": "SynBioFactory-liquid_handler.biomek-transfer",
|
||||
"incubation": "SynBioFactory-liquid_handler.biomek-incubation",
|
||||
"move_labware": "SynBioFactory-liquid_handler.biomek-move_labware",
|
||||
"oscillation": "SynBioFactory-liquid_handler.biomek-oscillation",
|
||||
# 有机化学操作
|
||||
"HeatChillToTemp": "SynBioFactory-workstation-HeatChillProtocol",
|
||||
"StopHeatChill": "SynBioFactory-workstation-HeatChillStopProtocol",
|
||||
"StartHeatChill": "SynBioFactory-workstation-HeatChillStartProtocol",
|
||||
"HeatChill": "SynBioFactory-workstation-HeatChillProtocol",
|
||||
"Dissolve": "SynBioFactory-workstation-DissolveProtocol",
|
||||
"Transfer": "SynBioFactory-workstation-TransferProtocol",
|
||||
"Evaporate": "SynBioFactory-workstation-EvaporateProtocol",
|
||||
"Recrystallize": "SynBioFactory-workstation-RecrystallizeProtocol",
|
||||
"Filter": "SynBioFactory-workstation-FilterProtocol",
|
||||
"Dry": "SynBioFactory-workstation-DryProtocol",
|
||||
"Add": "SynBioFactory-workstation-AddProtocol",
|
||||
}
|
||||
|
||||
UNSUPPORTED_OPERATIONS = ["Purge", "Wait", "Stir", "ResetHandling"]
|
||||
|
||||
for step in data:
|
||||
operation = step.get("action")
|
||||
if not operation or operation in UNSUPPORTED_OPERATIONS:
|
||||
continue
|
||||
|
||||
# 处理重复操作
|
||||
if operation == "Repeat":
|
||||
times = step.get("times", step.get("parameters", {}).get("times", 1))
|
||||
sub_steps = step.get("steps", step.get("parameters", {}).get("steps", []))
|
||||
for i in range(int(times)):
|
||||
sub_data = refactor_data(sub_steps)
|
||||
refactored_data.extend(sub_data)
|
||||
continue
|
||||
|
||||
# 获取模板名称
|
||||
template = OPERATION_MAPPING.get(operation)
|
||||
if not template:
|
||||
# 自动推断模板类型
|
||||
if operation.lower() in ["transfer", "incubation", "move_labware", "oscillation"]:
|
||||
template = f"SynBioFactory-liquid_handler.biomek-{operation}"
|
||||
else:
|
||||
template = f"SynBioFactory-workstation-{operation}Protocol"
|
||||
|
||||
# 创建步骤数据
|
||||
step_data = {
|
||||
"template": template,
|
||||
"description": step.get("description", step.get("purpose", f"{operation} operation")),
|
||||
"lab_node_type": "Device",
|
||||
"parameters": step.get("parameters", step.get("action_args", {})),
|
||||
}
|
||||
refactored_data.append(step_data)
|
||||
|
||||
return refactored_data
|
||||
|
||||
|
||||
def build_protocol_graph(
|
||||
labware_info: List[Dict[str, Any]], protocol_steps: List[Dict[str, Any]], workstation_name: str
|
||||
) -> SimpleGraph:
|
||||
"""统一的协议图构建函数,根据设备类型自动选择构建逻辑"""
|
||||
G = SimpleGraph()
|
||||
resource_last_writer = {}
|
||||
LAB_NAME = "SynBioFactory"
|
||||
|
||||
protocol_steps = refactor_data(protocol_steps)
|
||||
|
||||
# 检查协议步骤中的模板来判断协议类型
|
||||
has_biomek_template = any(
|
||||
("biomek" in step.get("template", "")) or ("prcxi" in step.get("template", ""))
|
||||
for step in protocol_steps
|
||||
)
|
||||
|
||||
if has_biomek_template:
|
||||
# 生物实验协议图构建
|
||||
for labware_id, labware in labware_info.items():
|
||||
node_id = str(uuid.uuid4())
|
||||
|
||||
labware_attrs = labware.copy()
|
||||
labware_id = labware_attrs.pop("id", labware_attrs.get("name", f"labware_{uuid.uuid4()}"))
|
||||
labware_attrs["description"] = labware_id
|
||||
labware_attrs["lab_node_type"] = (
|
||||
"Reagent" if "Plate" in str(labware_id) else "Labware" if "Rack" in str(labware_id) else "Sample"
|
||||
)
|
||||
labware_attrs["device_id"] = workstation_name
|
||||
|
||||
G.add_node(node_id, template=f"{LAB_NAME}-host_node-create_resource", **labware_attrs)
|
||||
resource_last_writer[labware_id] = f"{node_id}:labware"
|
||||
|
||||
# 处理协议步骤
|
||||
prev_node = None
|
||||
for i, step in enumerate(protocol_steps):
|
||||
node_id = str(uuid.uuid4())
|
||||
G.add_node(node_id, **step)
|
||||
|
||||
# 添加控制流边
|
||||
if prev_node is not None:
|
||||
G.add_edge(prev_node, node_id, source_port="ready", target_port="ready")
|
||||
prev_node = node_id
|
||||
|
||||
# 处理物料流
|
||||
params = step.get("parameters", {})
|
||||
if "sources" in params and params["sources"] in resource_last_writer:
|
||||
source_node, source_port = resource_last_writer[params["sources"]].split(":")
|
||||
G.add_edge(source_node, node_id, source_port=source_port, target_port="labware")
|
||||
|
||||
if "targets" in params:
|
||||
resource_last_writer[params["targets"]] = f"{node_id}:labware"
|
||||
|
||||
# 添加协议结束节点
|
||||
end_id = str(uuid.uuid4())
|
||||
G.add_node(end_id, template=f"{LAB_NAME}-liquid_handler.biomek-run_protocol")
|
||||
if prev_node is not None:
|
||||
G.add_edge(prev_node, end_id, source_port="ready", target_port="ready")
|
||||
|
||||
else:
|
||||
# 有机化学协议图构建
|
||||
WORKSTATION_ID = workstation_name
|
||||
|
||||
# 为所有labware创建资源节点
|
||||
for item_id, item in labware_info.items():
|
||||
# item_id = item.get("id") or item.get("name", f"item_{uuid.uuid4()}")
|
||||
node_id = str(uuid.uuid4())
|
||||
|
||||
# 判断节点类型
|
||||
if item.get("type") == "hardware" or "reactor" in str(item_id).lower():
|
||||
if "reactor" not in str(item_id).lower():
|
||||
continue
|
||||
lab_node_type = "Sample"
|
||||
description = f"Prepare Reactor: {item_id}"
|
||||
liquid_type = []
|
||||
liquid_volume = []
|
||||
else:
|
||||
lab_node_type = "Reagent"
|
||||
description = f"Add Reagent to Flask: {item_id}"
|
||||
liquid_type = [item_id]
|
||||
liquid_volume = [1e5]
|
||||
|
||||
G.add_node(
|
||||
node_id,
|
||||
template=f"{LAB_NAME}-host_node-create_resource",
|
||||
description=description,
|
||||
lab_node_type=lab_node_type,
|
||||
res_id=item_id,
|
||||
device_id=WORKSTATION_ID,
|
||||
class_name="container",
|
||||
parent=WORKSTATION_ID,
|
||||
bind_locations={"x": 0.0, "y": 0.0, "z": 0.0},
|
||||
liquid_input_slot=[-1],
|
||||
liquid_type=liquid_type,
|
||||
liquid_volume=liquid_volume,
|
||||
slot_on_deck="",
|
||||
role=item.get("role", ""),
|
||||
)
|
||||
resource_last_writer[item_id] = f"{node_id}:labware"
|
||||
|
||||
last_control_node_id = None
|
||||
|
||||
# 处理协议步骤
|
||||
for step in protocol_steps:
|
||||
node_id = str(uuid.uuid4())
|
||||
G.add_node(node_id, **step)
|
||||
|
||||
# 控制流
|
||||
if last_control_node_id is not None:
|
||||
G.add_edge(last_control_node_id, node_id, source_port="ready", target_port="ready")
|
||||
last_control_node_id = node_id
|
||||
|
||||
# 物料流
|
||||
params = step.get("parameters", {})
|
||||
input_resources = {
|
||||
"Vessel": params.get("vessel"),
|
||||
"ToVessel": params.get("to_vessel"),
|
||||
"FromVessel": params.get("from_vessel"),
|
||||
"reagent": params.get("reagent"),
|
||||
"solvent": params.get("solvent"),
|
||||
"compound": params.get("compound"),
|
||||
"sources": params.get("sources"),
|
||||
"targets": params.get("targets"),
|
||||
}
|
||||
|
||||
for target_port, resource_name in input_resources.items():
|
||||
if resource_name and resource_name in resource_last_writer:
|
||||
source_node, source_port = resource_last_writer[resource_name].split(":")
|
||||
G.add_edge(source_node, node_id, source_port=source_port, target_port=target_port)
|
||||
|
||||
output_resources = {
|
||||
"VesselOut": params.get("vessel"),
|
||||
"FromVesselOut": params.get("from_vessel"),
|
||||
"ToVesselOut": params.get("to_vessel"),
|
||||
"FiltrateOut": params.get("filtrate_vessel"),
|
||||
"reagent": params.get("reagent"),
|
||||
"solvent": params.get("solvent"),
|
||||
"compound": params.get("compound"),
|
||||
"sources_out": params.get("sources"),
|
||||
"targets_out": params.get("targets"),
|
||||
}
|
||||
|
||||
for source_port, resource_name in output_resources.items():
|
||||
if resource_name:
|
||||
resource_last_writer[resource_name] = f"{node_id}:{source_port}"
|
||||
|
||||
return G
|
||||
|
||||
|
||||
def draw_protocol_graph(protocol_graph: SimpleGraph, output_path: str):
|
||||
"""
|
||||
(辅助功能) 使用 networkx 和 matplotlib 绘制协议工作流图,用于可视化。
|
||||
"""
|
||||
if not protocol_graph:
|
||||
print("Cannot draw graph: Graph object is empty.")
|
||||
return
|
||||
|
||||
G = nx.DiGraph()
|
||||
|
||||
for node_id, attrs in protocol_graph.nodes.items():
|
||||
label = attrs.get("description", attrs.get("template", node_id[:8]))
|
||||
G.add_node(node_id, label=label, **attrs)
|
||||
|
||||
for edge in protocol_graph.edges:
|
||||
G.add_edge(edge["source"], edge["target"])
|
||||
|
||||
plt.figure(figsize=(20, 15))
|
||||
try:
|
||||
pos = nx.nx_agraph.graphviz_layout(G, prog="dot")
|
||||
except Exception:
|
||||
pos = nx.shell_layout(G) # Fallback layout
|
||||
|
||||
node_labels = {node: data["label"] for node, data in G.nodes(data=True)}
|
||||
nx.draw(
|
||||
G,
|
||||
pos,
|
||||
with_labels=False,
|
||||
node_size=2500,
|
||||
node_color="skyblue",
|
||||
node_shape="o",
|
||||
edge_color="gray",
|
||||
width=1.5,
|
||||
arrowsize=15,
|
||||
)
|
||||
nx.draw_networkx_labels(G, pos, labels=node_labels, font_size=8, font_weight="bold")
|
||||
|
||||
plt.title("Chemical Protocol Workflow Graph", size=15)
|
||||
plt.savefig(output_path, dpi=300, bbox_inches="tight")
|
||||
plt.close()
|
||||
print(f" - Visualization saved to '{output_path}'")
|
||||
|
||||
|
||||
from networkx.drawing.nx_agraph import to_agraph
|
||||
import re
|
||||
|
||||
COMPASS = {"n","e","s","w","ne","nw","se","sw","c"}
|
||||
|
||||
def _is_compass(port: str) -> bool:
|
||||
return isinstance(port, str) and port.lower() in COMPASS
|
||||
|
||||
def draw_protocol_graph_with_ports(protocol_graph, output_path: str, rankdir: str = "LR"):
|
||||
"""
|
||||
使用 Graphviz 端口语法绘制协议工作流图。
|
||||
- 若边上的 source_port/target_port 是 compass(n/e/s/w/...),直接用 compass。
|
||||
- 否则自动为节点创建 record 形状并定义命名端口 <portname>。
|
||||
最终由 PyGraphviz 渲染并输出到 output_path(后缀决定格式,如 .png/.svg/.pdf)。
|
||||
"""
|
||||
if not protocol_graph:
|
||||
print("Cannot draw graph: Graph object is empty.")
|
||||
return
|
||||
|
||||
# 1) 先用 networkx 搭建有向图,保留端口属性
|
||||
G = nx.DiGraph()
|
||||
for node_id, attrs in protocol_graph.nodes.items():
|
||||
label = attrs.get("description", attrs.get("template", node_id[:8]))
|
||||
# 保留一个干净的“中心标签”,用于放在 record 的中间槽
|
||||
G.add_node(node_id, _core_label=str(label), **{k:v for k,v in attrs.items() if k not in ("label",)})
|
||||
|
||||
edges_data = []
|
||||
in_ports_by_node = {} # 收集命名输入端口
|
||||
out_ports_by_node = {} # 收集命名输出端口
|
||||
|
||||
for edge in protocol_graph.edges:
|
||||
u = edge["source"]
|
||||
v = edge["target"]
|
||||
sp = edge.get("source_port")
|
||||
tp = edge.get("target_port")
|
||||
|
||||
# 记录到图里(保留原始端口信息)
|
||||
G.add_edge(u, v, source_port=sp, target_port=tp)
|
||||
edges_data.append((u, v, sp, tp))
|
||||
|
||||
# 如果不是 compass,就按“命名端口”先归类,等会儿给节点造 record
|
||||
if sp and not _is_compass(sp):
|
||||
out_ports_by_node.setdefault(u, set()).add(str(sp))
|
||||
if tp and not _is_compass(tp):
|
||||
in_ports_by_node.setdefault(v, set()).add(str(tp))
|
||||
|
||||
# 2) 转为 AGraph,使用 Graphviz 渲染
|
||||
A = to_agraph(G)
|
||||
A.graph_attr.update(rankdir=rankdir, splines="true", concentrate="false", fontsize="10")
|
||||
A.node_attr.update(shape="box", style="rounded,filled", fillcolor="lightyellow", color="#999999", fontname="Helvetica")
|
||||
A.edge_attr.update(arrowsize="0.8", color="#666666")
|
||||
|
||||
# 3) 为需要命名端口的节点设置 record 形状与 label
|
||||
# 左列 = 输入端口;中间 = 核心标签;右列 = 输出端口
|
||||
for n in A.nodes():
|
||||
node = A.get_node(n)
|
||||
core = G.nodes[n].get("_core_label", n)
|
||||
|
||||
in_ports = sorted(in_ports_by_node.get(n, []))
|
||||
out_ports = sorted(out_ports_by_node.get(n, []))
|
||||
|
||||
# 如果该节点涉及命名端口,则用 record;否则保留原 box
|
||||
if in_ports or out_ports:
|
||||
def port_fields(ports):
|
||||
if not ports:
|
||||
return " " # 必须留一个空槽占位
|
||||
# 每个端口一个小格子,<p> name
|
||||
return "|".join(f"<{re.sub(r'[^A-Za-z0-9_:.|-]', '_', p)}> {p}" for p in ports)
|
||||
|
||||
left = port_fields(in_ports)
|
||||
right = port_fields(out_ports)
|
||||
|
||||
# 三栏:左(入) | 中(节点名) | 右(出)
|
||||
record_label = f"{{ {left} | {core} | {right} }}"
|
||||
node.attr.update(shape="record", label=record_label)
|
||||
else:
|
||||
# 没有命名端口:普通盒子,显示核心标签
|
||||
node.attr.update(label=str(core))
|
||||
|
||||
# 4) 给边设置 headport / tailport
|
||||
# - 若端口为 compass:直接用 compass(e.g., headport="e")
|
||||
# - 若端口为命名端口:使用在 record 中定义的 <port> 名(同名即可)
|
||||
for (u, v, sp, tp) in edges_data:
|
||||
e = A.get_edge(u, v)
|
||||
|
||||
# Graphviz 属性:tail 是源,head 是目标
|
||||
if sp:
|
||||
if _is_compass(sp):
|
||||
e.attr["tailport"] = sp.lower()
|
||||
else:
|
||||
# 与 record label 中 <port> 名一致;特殊字符已在 label 中做了清洗
|
||||
e.attr["tailport"] = re.sub(r'[^A-Za-z0-9_:.|-]', '_', str(sp))
|
||||
|
||||
if tp:
|
||||
if _is_compass(tp):
|
||||
e.attr["headport"] = tp.lower()
|
||||
else:
|
||||
e.attr["headport"] = re.sub(r'[^A-Za-z0-9_:.|-]', '_', str(tp))
|
||||
|
||||
# 可选:若想让边更贴边缘,可设置 constraint/spline 等
|
||||
# e.attr["arrowhead"] = "vee"
|
||||
|
||||
# 5) 输出
|
||||
A.draw(output_path, prog="dot")
|
||||
print(f" - Port-aware workflow rendered to '{output_path}'")
|
||||
|
||||
|
||||
def flatten_xdl_procedure(procedure_elem: ET.Element) -> List[ET.Element]:
|
||||
"""展平嵌套的XDL程序结构"""
|
||||
flattened_operations = []
|
||||
TEMP_UNSUPPORTED_PROTOCOL = ["Purge", "Wait", "Stir", "ResetHandling"]
|
||||
|
||||
def extract_operations(element: ET.Element):
|
||||
if element.tag not in ["Prep", "Reaction", "Workup", "Purification", "Procedure"]:
|
||||
if element.tag not in TEMP_UNSUPPORTED_PROTOCOL:
|
||||
flattened_operations.append(element)
|
||||
|
||||
for child in element:
|
||||
extract_operations(child)
|
||||
|
||||
for child in procedure_elem:
|
||||
extract_operations(child)
|
||||
|
||||
return flattened_operations
|
||||
|
||||
|
||||
def parse_xdl_content(xdl_content: str) -> tuple:
|
||||
"""解析XDL内容"""
|
||||
try:
|
||||
xdl_content_cleaned = "".join(c for c in xdl_content if c.isprintable())
|
||||
root = ET.fromstring(xdl_content_cleaned)
|
||||
|
||||
synthesis_elem = root.find("Synthesis")
|
||||
if synthesis_elem is None:
|
||||
return None, None, None
|
||||
|
||||
# 解析硬件组件
|
||||
hardware_elem = synthesis_elem.find("Hardware")
|
||||
hardware = []
|
||||
if hardware_elem is not None:
|
||||
hardware = [{"id": c.get("id"), "type": c.get("type")} for c in hardware_elem.findall("Component")]
|
||||
|
||||
# 解析试剂
|
||||
reagents_elem = synthesis_elem.find("Reagents")
|
||||
reagents = []
|
||||
if reagents_elem is not None:
|
||||
reagents = [{"name": r.get("name"), "role": r.get("role", "")} for r in reagents_elem.findall("Reagent")]
|
||||
|
||||
# 解析程序
|
||||
procedure_elem = synthesis_elem.find("Procedure")
|
||||
if procedure_elem is None:
|
||||
return None, None, None
|
||||
|
||||
flattened_operations = flatten_xdl_procedure(procedure_elem)
|
||||
return hardware, reagents, flattened_operations
|
||||
|
||||
except ET.ParseError as e:
|
||||
raise ValueError(f"Invalid XDL format: {e}")
|
||||
|
||||
|
||||
def convert_xdl_to_dict(xdl_content: str) -> Dict[str, Any]:
|
||||
"""
|
||||
将XDL XML格式转换为标准的字典格式
|
||||
|
||||
Args:
|
||||
xdl_content: XDL XML内容
|
||||
|
||||
Returns:
|
||||
转换结果,包含步骤和器材信息
|
||||
"""
|
||||
try:
|
||||
hardware, reagents, flattened_operations = parse_xdl_content(xdl_content)
|
||||
if hardware is None:
|
||||
return {"error": "Failed to parse XDL content", "success": False}
|
||||
|
||||
# 将XDL元素转换为字典格式
|
||||
steps_data = []
|
||||
for elem in flattened_operations:
|
||||
# 转换参数类型
|
||||
parameters = {}
|
||||
for key, val in elem.attrib.items():
|
||||
converted_val = convert_to_type(val)
|
||||
if converted_val is not None:
|
||||
parameters[key] = converted_val
|
||||
|
||||
step_dict = {
|
||||
"operation": elem.tag,
|
||||
"parameters": parameters,
|
||||
"description": elem.get("purpose", f"Operation: {elem.tag}"),
|
||||
}
|
||||
steps_data.append(step_dict)
|
||||
|
||||
# 合并硬件和试剂为统一的labware_info格式
|
||||
labware_data = []
|
||||
labware_data.extend({"id": hw["id"], "type": "hardware", **hw} for hw in hardware)
|
||||
labware_data.extend({"name": reagent["name"], "type": "reagent", **reagent} for reagent in reagents)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"steps": steps_data,
|
||||
"labware": labware_data,
|
||||
"message": f"Successfully converted XDL to dict format. Found {len(steps_data)} steps and {len(labware_data)} labware items.",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"XDL conversion failed: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return {"error": error_msg, "success": False}
|
||||
|
||||
|
||||
def create_workflow(
|
||||
|
||||
2
setup.py
2
setup.py
@@ -4,7 +4,7 @@ package_name = 'unilabos'
|
||||
|
||||
setup(
|
||||
name=package_name,
|
||||
version='0.10.18',
|
||||
version='0.10.17',
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
install_requires=['setuptools'],
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
{
|
||||
"workflow": [
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"action_args": {
|
||||
"sources": "cell_lines",
|
||||
"targets": "Liquid_1",
|
||||
"asp_vol": 100.0,
|
||||
"dis_vol": 74.75,
|
||||
"asp_flow_rate": 94.0,
|
||||
"dis_flow_rate": 95.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"action_args": {
|
||||
"sources": "cell_lines",
|
||||
"targets": "Liquid_2",
|
||||
"asp_vol": 100.0,
|
||||
"dis_vol": 74.75,
|
||||
"asp_flow_rate": 94.0,
|
||||
"dis_flow_rate": 95.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"action_args": {
|
||||
"sources": "cell_lines",
|
||||
"targets": "Liquid_3",
|
||||
"asp_vol": 100.0,
|
||||
"dis_vol": 74.75,
|
||||
"asp_flow_rate": 94.0,
|
||||
"dis_flow_rate": 95.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"action_args": {
|
||||
"sources": "cell_lines_2",
|
||||
"targets": "Liquid_4",
|
||||
"asp_vol": 100.0,
|
||||
"dis_vol": 74.75,
|
||||
"asp_flow_rate": 94.0,
|
||||
"dis_flow_rate": 95.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"action_args": {
|
||||
"sources": "cell_lines_2",
|
||||
"targets": "Liquid_5",
|
||||
"asp_vol": 100.0,
|
||||
"dis_vol": 74.75,
|
||||
"asp_flow_rate": 94.0,
|
||||
"dis_flow_rate": 95.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"action_args": {
|
||||
"sources": "cell_lines_2",
|
||||
"targets": "Liquid_6",
|
||||
"asp_vol": 100.0,
|
||||
"dis_vol": 74.75,
|
||||
"asp_flow_rate": 94.0,
|
||||
"dis_flow_rate": 95.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"action_args": {
|
||||
"sources": "cell_lines_3",
|
||||
"targets": "dest_set",
|
||||
"asp_vol": 100.0,
|
||||
"dis_vol": 74.75,
|
||||
"asp_flow_rate": 94.0,
|
||||
"dis_flow_rate": 95.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"action_args": {
|
||||
"sources": "cell_lines_3",
|
||||
"targets": "dest_set_2",
|
||||
"asp_vol": 100.0,
|
||||
"dis_vol": 74.75,
|
||||
"asp_flow_rate": 94.0,
|
||||
"dis_flow_rate": 95.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "transfer_liquid",
|
||||
"action_args": {
|
||||
"sources": "cell_lines_3",
|
||||
"targets": "dest_set_3",
|
||||
"asp_vol": 100.0,
|
||||
"dis_vol": 74.75,
|
||||
"asp_flow_rate": 94.0,
|
||||
"dis_flow_rate": 95.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"reagent": {
|
||||
"Liquid_1": {
|
||||
"slot": 1,
|
||||
"well": [
|
||||
"A4",
|
||||
"A7",
|
||||
"A10"
|
||||
],
|
||||
"labware": "rep 1"
|
||||
},
|
||||
"Liquid_4": {
|
||||
"slot": 1,
|
||||
"well": [
|
||||
"A4",
|
||||
"A7",
|
||||
"A10"
|
||||
],
|
||||
"labware": "rep 1"
|
||||
},
|
||||
"dest_set": {
|
||||
"slot": 1,
|
||||
"well": [
|
||||
"A4",
|
||||
"A7",
|
||||
"A10"
|
||||
],
|
||||
"labware": "rep 1"
|
||||
},
|
||||
"Liquid_2": {
|
||||
"slot": 2,
|
||||
"well": [
|
||||
"A3",
|
||||
"A5",
|
||||
"A8"
|
||||
],
|
||||
"labware": "rep 2"
|
||||
},
|
||||
"Liquid_5": {
|
||||
"slot": 2,
|
||||
"well": [
|
||||
"A3",
|
||||
"A5",
|
||||
"A8"
|
||||
],
|
||||
"labware": "rep 2"
|
||||
},
|
||||
"dest_set_2": {
|
||||
"slot": 2,
|
||||
"well": [
|
||||
"A3",
|
||||
"A5",
|
||||
"A8"
|
||||
],
|
||||
"labware": "rep 2"
|
||||
},
|
||||
"Liquid_3": {
|
||||
"slot": 3,
|
||||
"well": [
|
||||
"A4",
|
||||
"A6",
|
||||
"A10"
|
||||
],
|
||||
"labware": "rep 3"
|
||||
},
|
||||
"Liquid_6": {
|
||||
"slot": 3,
|
||||
"well": [
|
||||
"A4",
|
||||
"A6",
|
||||
"A10"
|
||||
],
|
||||
"labware": "rep 3"
|
||||
},
|
||||
"dest_set_3": {
|
||||
"slot": 3,
|
||||
"well": [
|
||||
"A4",
|
||||
"A6",
|
||||
"A10"
|
||||
],
|
||||
"labware": "rep 3"
|
||||
},
|
||||
"cell_lines": {
|
||||
"slot": 4,
|
||||
"well": [
|
||||
"A1",
|
||||
"A3",
|
||||
"A5"
|
||||
],
|
||||
"labware": "DRUG + YOYO-MEDIA"
|
||||
},
|
||||
"cell_lines_2": {
|
||||
"slot": 4,
|
||||
"well": [
|
||||
"A1",
|
||||
"A3",
|
||||
"A5"
|
||||
],
|
||||
"labware": "DRUG + YOYO-MEDIA"
|
||||
},
|
||||
"cell_lines_3": {
|
||||
"slot": 4,
|
||||
"well": [
|
||||
"A1",
|
||||
"A3",
|
||||
"A5"
|
||||
],
|
||||
"labware": "DRUG + YOYO-MEDIA"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.10.18"
|
||||
__version__ = "0.10.17"
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
@@ -26,84 +24,6 @@ from unilabos.config.config import load_config, BasicConfig, HTTPConfig
|
||||
_restart_requested: bool = False
|
||||
_restart_reason: str = ""
|
||||
|
||||
RESTART_EXIT_CODE = 42
|
||||
|
||||
|
||||
def _build_child_argv():
|
||||
"""Build sys.argv for child process, stripping supervisor-only arguments."""
|
||||
result = []
|
||||
skip_next = False
|
||||
for arg in sys.argv:
|
||||
if skip_next:
|
||||
skip_next = False
|
||||
continue
|
||||
if arg in ("--restart_mode", "--restart-mode"):
|
||||
continue
|
||||
if arg in ("--auto_restart_count", "--auto-restart-count"):
|
||||
skip_next = True
|
||||
continue
|
||||
if arg.startswith("--auto_restart_count=") or arg.startswith("--auto-restart-count="):
|
||||
continue
|
||||
result.append(arg)
|
||||
return result
|
||||
|
||||
|
||||
def _run_as_supervisor(max_restarts: int):
|
||||
"""
|
||||
Supervisor process that spawns and monitors child processes.
|
||||
|
||||
Similar to Uvicorn's --reload: the supervisor itself does no heavy work,
|
||||
it only launches the real process as a child and restarts it when the child
|
||||
exits with RESTART_EXIT_CODE.
|
||||
"""
|
||||
child_argv = [sys.executable] + _build_child_argv()
|
||||
restart_count = 0
|
||||
|
||||
print_status(
|
||||
f"[Supervisor] Restart mode enabled (max restarts: {max_restarts}), "
|
||||
f"child command: {' '.join(child_argv)}",
|
||||
"info",
|
||||
)
|
||||
|
||||
while True:
|
||||
print_status(
|
||||
f"[Supervisor] Launching process (restart {restart_count}/{max_restarts})...",
|
||||
"info",
|
||||
)
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(child_argv)
|
||||
exit_code = process.wait()
|
||||
except KeyboardInterrupt:
|
||||
print_status("[Supervisor] Interrupted, terminating child process...", "info")
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
sys.exit(1)
|
||||
|
||||
if exit_code == RESTART_EXIT_CODE:
|
||||
restart_count += 1
|
||||
if restart_count > max_restarts:
|
||||
print_status(
|
||||
f"[Supervisor] Maximum restart count ({max_restarts}) reached, exiting",
|
||||
"warning",
|
||||
)
|
||||
sys.exit(1)
|
||||
print_status(
|
||||
f"[Supervisor] Child requested restart ({restart_count}/{max_restarts}), restarting in 2s...",
|
||||
"info",
|
||||
)
|
||||
time.sleep(2)
|
||||
else:
|
||||
if exit_code != 0:
|
||||
print_status(f"[Supervisor] Child exited with code {exit_code}", "warning")
|
||||
else:
|
||||
print_status("[Supervisor] Child exited normally", "info")
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
def load_config_from_file(config_path):
|
||||
if config_path is None:
|
||||
@@ -145,13 +65,6 @@ def parse_args():
|
||||
action="append",
|
||||
help="Path to the registry directory",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--devices",
|
||||
type=str,
|
||||
default=None,
|
||||
action="append",
|
||||
help="Path to Python code directory for AST-based device/resource scanning",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--working_dir",
|
||||
type=str,
|
||||
@@ -241,6 +154,12 @@ 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",
|
||||
@@ -252,30 +171,6 @@ def parse_args():
|
||||
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(
|
||||
"--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",
|
||||
@@ -309,12 +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",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
@@ -326,11 +215,6 @@ 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)
|
||||
@@ -347,60 +231,52 @@ def main():
|
||||
# 加载配置文件,优先加载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")
|
||||
@@ -412,9 +288,7 @@ 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":
|
||||
@@ -458,14 +332,8 @@ 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"]
|
||||
@@ -488,30 +356,22 @@ def main():
|
||||
# 显示启动横幅
|
||||
print_unilab_banner(args_dict)
|
||||
|
||||
# Step 0: AST 分析优先 + YAML 注册表加载
|
||||
# check_mode 和 upload_registry 都会执行实际 import 验证
|
||||
devices_dirs = args_dict.get("devices", None)
|
||||
lab_registry = build_registry(
|
||||
registry_paths=args_dict["registry_path"],
|
||||
devices_dirs=devices_dirs,
|
||||
upload_registry=BasicConfig.upload_registry,
|
||||
check_mode=check_mode,
|
||||
)
|
||||
# 注册表 - 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: 注册表验证完成后直接退出
|
||||
# Check mode: complete_registry 完成后直接退出,git diff 检测由 CI workflow 执行
|
||||
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")
|
||||
print_status("Check mode: complete_registry 完成,退出", "info")
|
||||
os._exit(0)
|
||||
|
||||
# Step 1: 上传全部注册表到服务端,同步保存到 unilabos_data
|
||||
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:
|
||||
@@ -596,7 +456,7 @@ 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)
|
||||
@@ -693,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__":
|
||||
|
||||
@@ -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="")
|
||||
|
||||
@@ -1,83 +1,60 @@
|
||||
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.type_check import TypeEncoder
|
||||
|
||||
try:
|
||||
import orjson
|
||||
|
||||
def _normalize_device(info: dict) -> dict:
|
||||
"""Serialize via orjson to strip non-JSON types (type objects etc.)."""
|
||||
return orjson.loads(orjson.dumps(info, default=str))
|
||||
except ImportError:
|
||||
def _normalize_device(info: dict) -> dict:
|
||||
return json.loads(json.dumps(info, ensure_ascii=False, cls=TypeEncoder))
|
||||
|
||||
|
||||
def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[Tuple[Dict[str, Any], Dict[str, Any]]]:
|
||||
"""
|
||||
注册设备和资源到服务器(仅支持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] 设备和资源注册完成.")
|
||||
|
||||
@@ -1052,7 +1052,7 @@ async def handle_file_import(websocket: WebSocket, request_data: dict):
|
||||
"result": {},
|
||||
"schema": lab_registry._generate_unilab_json_command_schema(v["args"], k),
|
||||
"goal_default": {i["name"]: i["default"] for i in v["args"]},
|
||||
"handles": {},
|
||||
"handles": [],
|
||||
}
|
||||
# 不生成已配置action的动作
|
||||
for k, v in enhanced_info["action_methods"].items()
|
||||
@@ -1340,5 +1340,5 @@ def setup_api_routes(app):
|
||||
# 启动广播任务
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
asyncio.create_task(broadcast_device_status(), name="web-api-startup-device")
|
||||
asyncio.create_task(broadcast_status_page_data(), name="web-api-startup-status")
|
||||
asyncio.create_task(broadcast_device_status())
|
||||
asyncio.create_task(broadcast_status_page_data())
|
||||
|
||||
@@ -3,30 +3,11 @@ HTTP客户端模块
|
||||
|
||||
提供与远程服务器通信的客户端功能,只有host需要用
|
||||
"""
|
||||
import gzip
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
try:
|
||||
import orjson as _json_fast
|
||||
|
||||
def _fast_dumps(obj, **kwargs) -> bytes:
|
||||
return _json_fast.dumps(obj, option=_json_fast.OPT_NON_STR_KEYS, default=str)
|
||||
|
||||
def _fast_dumps_pretty(obj, **kwargs) -> bytes:
|
||||
return _json_fast.dumps(
|
||||
obj, option=_json_fast.OPT_NON_STR_KEYS | _json_fast.OPT_INDENT_2, default=str,
|
||||
)
|
||||
except ImportError:
|
||||
_json_fast = None # type: ignore[assignment]
|
||||
|
||||
def _fast_dumps(obj, **kwargs) -> bytes:
|
||||
return json.dumps(obj, ensure_ascii=False, default=str).encode("utf-8")
|
||||
|
||||
def _fast_dumps_pretty(obj, **kwargs) -> bytes:
|
||||
return json.dumps(obj, indent=2, ensure_ascii=False, default=str).encode("utf-8")
|
||||
|
||||
import requests
|
||||
from unilabos.resources.resource_tracker import ResourceTreeSet
|
||||
from unilabos.utils.log import info
|
||||
@@ -93,8 +74,7 @@ class HTTPClient:
|
||||
Dict[str, str]: 旧UUID到新UUID的映射关系 {old_uuid: new_uuid}
|
||||
"""
|
||||
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_add.json"), "w", encoding="utf-8") as f:
|
||||
payload = {"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid}
|
||||
f.write(json.dumps(payload, indent=4))
|
||||
f.write(json.dumps({"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid}, indent=4))
|
||||
# 从序列化数据中提取所有节点的UUID(保存旧UUID)
|
||||
old_uuids = {n.res_content.uuid: n for n in resources.all_nodes}
|
||||
if not self.initialized or first_add:
|
||||
@@ -299,54 +279,22 @@ class HTTPClient:
|
||||
)
|
||||
return response
|
||||
|
||||
def resource_registry(
|
||||
self, registry_data: Dict[str, Any] | List[Dict[str, Any]], tag: str = "registry",
|
||||
) -> requests.Response:
|
||||
def resource_registry(self, registry_data: Dict[str, Any] | List[Dict[str, Any]]) -> requests.Response:
|
||||
"""
|
||||
注册资源到服务器,同步保存请求/响应到 unilabos_data
|
||||
注册资源到服务器
|
||||
|
||||
Args:
|
||||
registry_data: 注册表数据,格式为 {resource_id: resource_info} / [{resource_info}]
|
||||
tag: 保存文件的标签后缀 (如 "device_registry" / "resource_registry")
|
||||
|
||||
Returns:
|
||||
Response: API响应对象
|
||||
"""
|
||||
# 序列化一次,同时用于保存和发送
|
||||
json_bytes = _fast_dumps(registry_data)
|
||||
|
||||
# 保存请求数据到 unilabos_data
|
||||
req_path = os.path.join(BasicConfig.working_dir, f"req_{tag}_upload.json")
|
||||
try:
|
||||
os.makedirs(BasicConfig.working_dir, exist_ok=True)
|
||||
with open(req_path, "wb") as f:
|
||||
f.write(_fast_dumps_pretty(registry_data))
|
||||
logger.trace(f"注册表请求数据已保存: {req_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"保存注册表请求数据失败: {e}")
|
||||
|
||||
compressed_body = gzip.compress(json_bytes)
|
||||
headers = {
|
||||
"Authorization": f"Lab {self.auth}",
|
||||
"Content-Type": "application/json",
|
||||
"Content-Encoding": "gzip",
|
||||
}
|
||||
response = requests.post(
|
||||
f"{self.remote_addr}/lab/resource",
|
||||
data=compressed_body,
|
||||
headers=headers,
|
||||
json=registry_data,
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
# 保存响应数据到 unilabos_data
|
||||
res_path = os.path.join(BasicConfig.working_dir, f"res_{tag}_upload.json")
|
||||
try:
|
||||
with open(res_path, "w", encoding="utf-8") as f:
|
||||
f.write(f"{response.status_code}\n{response.text}")
|
||||
logger.trace(f"注册表响应数据已保存: {res_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"保存注册表响应数据失败: {e}")
|
||||
|
||||
if response.status_code not in [200, 201]:
|
||||
logger.error(f"注册资源失败: {response.status_code}, {response.text}")
|
||||
if response.status_code == 200:
|
||||
@@ -385,106 +333,6 @@ class HTTPClient:
|
||||
logger.error(f"响应内容: {response.text}")
|
||||
return None
|
||||
|
||||
def workflow_import(
|
||||
self,
|
||||
name: str,
|
||||
workflow_uuid: str,
|
||||
workflow_name: str,
|
||||
nodes: List[Dict[str, Any]],
|
||||
edges: List[Dict[str, Any]],
|
||||
tags: Optional[List[str]] = None,
|
||||
published: bool = False,
|
||||
description: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
导入工作流到服务器,如果 published 为 True,则额外发起发布请求
|
||||
|
||||
Args:
|
||||
name: 工作流名称(顶层)
|
||||
workflow_uuid: 工作流UUID
|
||||
workflow_name: 工作流名称(data内部)
|
||||
nodes: 工作流节点列表
|
||||
edges: 工作流边列表
|
||||
tags: 工作流标签列表,默认为空列表
|
||||
published: 是否发布工作流,默认为False
|
||||
description: 工作流描述,发布时使用
|
||||
|
||||
Returns:
|
||||
Dict: API响应数据,包含 code 和 data (uuid, name)
|
||||
"""
|
||||
payload = {
|
||||
"name": name,
|
||||
"data": {
|
||||
"workflow_uuid": workflow_uuid,
|
||||
"workflow_name": workflow_name,
|
||||
"nodes": nodes,
|
||||
"edges": edges,
|
||||
"tags": tags if tags is not None else [],
|
||||
},
|
||||
}
|
||||
# 保存请求到文件
|
||||
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 = requests.post(
|
||||
f"{self.remote_addr}/lab/workflow/owner/import",
|
||||
json=payload,
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
timeout=60,
|
||||
)
|
||||
# 保存响应到文件
|
||||
with open(os.path.join(BasicConfig.working_dir, "res_workflow_upload.json"), "w", encoding="utf-8") as f:
|
||||
f.write(f"{response.status_code}" + "\n" + response.text)
|
||||
|
||||
if response.status_code == 200:
|
||||
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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ def setup_server() -> FastAPI:
|
||||
# 设置页面路由
|
||||
try:
|
||||
setup_web_pages(pages)
|
||||
# info("[Web] 已加载Web UI模块")
|
||||
info("[Web] 已加载Web UI模块")
|
||||
except ImportError as e:
|
||||
info(f"[Web] 未找到Web页面模块: {str(e)}")
|
||||
except Exception as e:
|
||||
@@ -138,7 +138,7 @@ def start_server(host: str = "0.0.0.0", port: int = 8002, open_browser: bool = T
|
||||
server_thread = threading.Thread(target=server.run, daemon=True, name="uvicorn_server")
|
||||
server_thread.start()
|
||||
|
||||
# info("[Web] Server started, monitoring for restart requests...")
|
||||
info("[Web] Server started, monitoring for restart requests...")
|
||||
|
||||
# 监控重启标志
|
||||
import unilabos.app.main as main_module
|
||||
|
||||
@@ -26,7 +26,6 @@ from enum import Enum
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from unilabos.app.model import JobAddReq
|
||||
from unilabos.resources.resource_tracker import ResourceDictType
|
||||
from unilabos.ros.nodes.presets.host_node import HostNode
|
||||
from unilabos.utils.type_check import serialize_result_info
|
||||
from unilabos.app.communication import BaseCommunicationClient
|
||||
@@ -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,98 +439,85 @@ class MessageProcessor:
|
||||
self.connected = True
|
||||
self.reconnect_count = 0
|
||||
|
||||
logger.info(f"[MessageProcessor] 已连接到 {self.websocket_url}")
|
||||
logger.info(f"[MessageProcessor] Connected to {self.websocket_url}")
|
||||
|
||||
# 启动发送协程
|
||||
send_task = asyncio.create_task(self._send_handler(), name="websocket-send_task")
|
||||
|
||||
# 每次连接(含重连)后重新向服务端注册,
|
||||
# 否则服务端不知道客户端已上线,不会推送消息。
|
||||
if self.websocket_client:
|
||||
self.websocket_client.publish_host_ready()
|
||||
send_task = asyncio.create_task(self._send_handler())
|
||||
|
||||
try:
|
||||
# 接收消息循环
|
||||
await self._message_handler()
|
||||
finally:
|
||||
# 必须在 async with __aexit__ 之前停止 send_task,
|
||||
# 否则 send_task 会在关闭握手期间继续发送数据,
|
||||
# 干扰 websockets 库的内部清理,导致 task 泄漏。
|
||||
self.connected = False
|
||||
send_task.cancel()
|
||||
try:
|
||||
await send_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self.connected = False
|
||||
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
logger.warning("[MessageProcessor] 与服务端连接中断")
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
f"[MessageProcessor] 与服务端连接通信超时 (已尝试 {self.reconnect_count + 1} 次),请检查您的网络状况"
|
||||
)
|
||||
except websockets.exceptions.InvalidStatus as e:
|
||||
logger.warning(
|
||||
f"[MessageProcessor] 收到服务端注册码 {e.response.status_code}, 上一进程可能还未退出"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
logger.error(f"[MessageProcessor] 尝试重连时出错 {str(e)}")
|
||||
finally:
|
||||
logger.warning("[MessageProcessor] Connection closed")
|
||||
self.connected = False
|
||||
except Exception as e:
|
||||
logger.error(f"[MessageProcessor] Connection error: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
self.connected = False
|
||||
finally:
|
||||
self.websocket = None
|
||||
|
||||
# 重连逻辑
|
||||
if not self.is_running:
|
||||
break
|
||||
if self.reconnect_count < WSConfig.max_reconnect_attempts:
|
||||
if self.is_running and self.reconnect_count < WSConfig.max_reconnect_attempts:
|
||||
self.reconnect_count += 1
|
||||
backoff = WSConfig.reconnect_interval
|
||||
logger.info(
|
||||
f"[MessageProcessor] 即将在 {backoff} 秒后重连 (已尝试 {self.reconnect_count}/{WSConfig.max_reconnect_attempts})"
|
||||
f"[MessageProcessor] Reconnecting in {WSConfig.reconnect_interval}s "
|
||||
f"(attempt {self.reconnect_count}/{WSConfig.max_reconnect_attempts})"
|
||||
)
|
||||
await asyncio.sleep(backoff)
|
||||
else:
|
||||
await asyncio.sleep(WSConfig.reconnect_interval)
|
||||
elif self.reconnect_count >= WSConfig.max_reconnect_attempts:
|
||||
logger.error("[MessageProcessor] Max reconnection attempts reached")
|
||||
break
|
||||
else:
|
||||
self.reconnect_count -= 1
|
||||
|
||||
async def _message_handler(self):
|
||||
"""处理接收到的消息。
|
||||
|
||||
ConnectionClosed 不在此处捕获,让其向上传播到 _connection_handler,
|
||||
以便 async with websockets.connect() 的 __aexit__ 能感知连接已断,
|
||||
正确清理内部 task,避免 task 泄漏。
|
||||
"""
|
||||
"""处理接收到的消息"""
|
||||
if not self.websocket:
|
||||
logger.error("[MessageProcessor] WebSocket connection is None")
|
||||
return
|
||||
|
||||
async for message in self.websocket:
|
||||
try:
|
||||
data = json.loads(message)
|
||||
message_type = data.get("action", "")
|
||||
message_data = data.get("data")
|
||||
if self.session_id and self.session_id == data.get("edge_session"):
|
||||
await self._process_message(message_type, message_data)
|
||||
else:
|
||||
if message_type.endswith("_material"):
|
||||
logger.trace(
|
||||
f"[MessageProcessor] 收到一条归属 {data.get('edge_session')} 的旧消息:{data}"
|
||||
)
|
||||
logger.debug(
|
||||
f"[MessageProcessor] 跳过了一条归属 {data.get('edge_session')} 的旧消息: {data.get('action')}"
|
||||
)
|
||||
else:
|
||||
try:
|
||||
async for message in self.websocket:
|
||||
try:
|
||||
data = json.loads(message)
|
||||
message_type = data.get("action", "")
|
||||
message_data = data.get("data")
|
||||
if self.session_id and self.session_id == data.get("edge_session"):
|
||||
await self._process_message(message_type, message_data)
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"[MessageProcessor] Invalid JSON received: {message}")
|
||||
except Exception as e:
|
||||
logger.error(f"[MessageProcessor] Error processing message: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
else:
|
||||
if message_type.endswith("_material"):
|
||||
logger.trace(
|
||||
f"[MessageProcessor] 收到一条归属 {data.get('edge_session')} 的旧消息:{data}"
|
||||
)
|
||||
logger.debug(
|
||||
f"[MessageProcessor] 跳过了一条归属 {data.get('edge_session')} 的旧消息: {data.get('action')}"
|
||||
)
|
||||
else:
|
||||
await self._process_message(message_type, message_data)
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"[MessageProcessor] Invalid JSON received: {message}")
|
||||
except Exception as e:
|
||||
logger.error(f"[MessageProcessor] Error processing message: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
logger.info("[MessageProcessor] Message handler stopped - connection closed")
|
||||
except Exception as e:
|
||||
logger.error(f"[MessageProcessor] Message handler error: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
async def _send_handler(self):
|
||||
"""处理发送队列中的消息"""
|
||||
logger.trace("[MessageProcessor] Send handler started")
|
||||
logger.debug("[MessageProcessor] Send handler started")
|
||||
|
||||
try:
|
||||
while self.connected and self.websocket:
|
||||
@@ -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,8 +657,6 @@ 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)
|
||||
@@ -782,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,
|
||||
)
|
||||
|
||||
@@ -999,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]):
|
||||
"""
|
||||
处理重启请求
|
||||
@@ -1041,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
|
||||
@@ -1145,14 +1020,13 @@ class QueueProcessor:
|
||||
def stop(self) -> None:
|
||||
"""停止队列处理线程"""
|
||||
self.is_running = False
|
||||
self.queue_update_event.set() # 立即唤醒等待中的线程
|
||||
if self.thread and self.thread.is_alive():
|
||||
self.thread.join(timeout=2)
|
||||
logger.info("[QueueProcessor] Stopped")
|
||||
|
||||
def _run(self):
|
||||
"""运行队列处理主循环"""
|
||||
logger.trace("[QueueProcessor] Queue processor started")
|
||||
logger.debug("[QueueProcessor] Queue processor started")
|
||||
|
||||
while self.is_running:
|
||||
try:
|
||||
@@ -1246,11 +1120,6 @@ class QueueProcessor:
|
||||
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": {
|
||||
@@ -1367,6 +1236,7 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
else:
|
||||
url = f"{scheme}://{parsed.netloc}/api/v1/ws/schedule"
|
||||
|
||||
logger.debug(f"[WebSocketClient] URL: {url}")
|
||||
return url
|
||||
|
||||
def start(self) -> None:
|
||||
@@ -1379,11 +1249,13 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
logger.error("[WebSocketClient] WebSocket URL not configured")
|
||||
return
|
||||
|
||||
logger.info(f"[WebSocketClient] Starting connection to {self.websocket_url}")
|
||||
|
||||
# 启动两个核心线程
|
||||
self.message_processor.start()
|
||||
self.queue_processor.start()
|
||||
|
||||
logger.trace("[WebSocketClient] All threads started")
|
||||
logger.info("[WebSocketClient] All threads started")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止WebSocket客户端"""
|
||||
@@ -1399,8 +1271,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)}")
|
||||
|
||||
@@ -1432,7 +1304,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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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,7 +39,7 @@ class BasicConfig:
|
||||
class WSConfig:
|
||||
reconnect_interval = 5 # 重连间隔(秒)
|
||||
max_reconnect_attempts = 999 # 最大重连次数
|
||||
ping_interval = 20 # ping间隔(秒)
|
||||
ping_interval = 30 # ping间隔(秒)
|
||||
|
||||
|
||||
# HTTP配置
|
||||
@@ -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)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
from abc import abstractmethod
|
||||
from functools import wraps
|
||||
import inspect
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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={},
|
||||
|
||||
@@ -15,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
|
||||
|
||||
# 🚀 新增:快速模式设置 - 大幅缩短执行时间
|
||||
@@ -52,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} ✨")
|
||||
@@ -70,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
|
||||
@@ -105,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:
|
||||
"""
|
||||
计算操作持续时间 ⏰
|
||||
@@ -123,10 +121,10 @@ class VirtualTransferPump:
|
||||
"""
|
||||
if velocity is None:
|
||||
velocity = self._max_velocity
|
||||
|
||||
|
||||
# 📊 计算理论时间(用于日志显示)
|
||||
theoretical_duration = abs(volume) / velocity
|
||||
|
||||
|
||||
# 🚀 如果启用快速模式,使用固定的快速时间
|
||||
if self._fast_mode:
|
||||
# 根据操作类型选择快速时间
|
||||
@@ -134,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:
|
||||
"""
|
||||
计算显示用的持续时间(用于日志) 📊
|
||||
@@ -149,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定义的结果
|
||||
"""
|
||||
@@ -166,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 = "吸液"
|
||||
@@ -189,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}中"
|
||||
@@ -224,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}%")
|
||||
@@ -235,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)
|
||||
@@ -244,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)
|
||||
@@ -272,136 +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
|
||||
|
||||
|
||||
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__()
|
||||
|
||||
@@ -410,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}")
|
||||
|
||||
@@ -1,72 +1,58 @@
|
||||
"""
|
||||
Virtual Workbench Device - 模拟工作台设备
|
||||
包含:
|
||||
包含:
|
||||
- 1个机械臂 (每次操作3s, 独占锁)
|
||||
- 3个加热台 (每次加热10s, 可并行)
|
||||
|
||||
工作流程:
|
||||
1. A1-A5 物料同时启动, 竞争机械臂
|
||||
工作流程:
|
||||
1. A1-A5 物料同时启动,竞争机械臂
|
||||
2. 机械臂将物料移动到空闲加热台
|
||||
3. 加热完成后, 机械臂将物料移动到C1-C5
|
||||
3. 加热完成后,机械臂将物料移动到C1-C5
|
||||
|
||||
注意: 调用来自线程池, 使用 threading.Lock 进行同步
|
||||
注意:调用来自线程池,使用 threading.Lock 进行同步
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Dict, Any, Optional, List
|
||||
from typing import Dict, Any, Optional
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from threading import Lock, RLock
|
||||
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from unilabos.registry.decorators import (
|
||||
device, action, ActionInputHandle, ActionOutputHandle, DataSource, topic_config, not_action
|
||||
)
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample
|
||||
from unilabos.utils.decorator import not_action
|
||||
|
||||
|
||||
# ============ TypedDict 返回类型定义 ============
|
||||
|
||||
|
||||
class MoveToHeatingStationResult(TypedDict):
|
||||
"""move_to_heating_station 返回类型"""
|
||||
|
||||
success: bool
|
||||
station_id: int
|
||||
material_id: str
|
||||
material_number: int
|
||||
message: str
|
||||
unilabos_samples: List[LabSample]
|
||||
|
||||
|
||||
class StartHeatingResult(TypedDict):
|
||||
"""start_heating 返回类型"""
|
||||
|
||||
success: bool
|
||||
station_id: int
|
||||
material_id: str
|
||||
material_number: int
|
||||
message: str
|
||||
unilabos_samples: List[LabSample]
|
||||
|
||||
|
||||
class MoveToOutputResult(TypedDict):
|
||||
"""move_to_output 返回类型"""
|
||||
|
||||
success: bool
|
||||
station_id: int
|
||||
material_id: str
|
||||
output_position: str
|
||||
message: str
|
||||
unilabos_samples: List[LabSample]
|
||||
|
||||
|
||||
class PrepareMaterialsResult(TypedDict):
|
||||
"""prepare_materials 返回类型 - 批量准备物料"""
|
||||
|
||||
success: bool
|
||||
count: int
|
||||
material_1: int # 物料编号1
|
||||
@@ -75,24 +61,20 @@ class PrepareMaterialsResult(TypedDict):
|
||||
material_4: int # 物料编号4
|
||||
material_5: int # 物料编号5
|
||||
message: str
|
||||
unilabos_samples: List[LabSample]
|
||||
|
||||
|
||||
# ============ 状态枚举 ============
|
||||
|
||||
|
||||
class HeatingStationState(Enum):
|
||||
"""加热台状态枚举"""
|
||||
|
||||
IDLE = "idle" # 空闲
|
||||
OCCUPIED = "occupied" # 已放置物料, 等待加热
|
||||
OCCUPIED = "occupied" # 已放置物料,等待加热
|
||||
HEATING = "heating" # 加热中
|
||||
COMPLETED = "completed" # 加热完成, 等待取走
|
||||
COMPLETED = "completed" # 加热完成,等待取走
|
||||
|
||||
|
||||
class ArmState(Enum):
|
||||
"""机械臂状态枚举"""
|
||||
|
||||
IDLE = "idle" # 空闲
|
||||
BUSY = "busy" # 工作中
|
||||
|
||||
@@ -100,7 +82,6 @@ class ArmState(Enum):
|
||||
@dataclass
|
||||
class HeatingStation:
|
||||
"""加热台数据结构"""
|
||||
|
||||
station_id: int
|
||||
state: HeatingStationState = HeatingStationState.IDLE
|
||||
current_material: Optional[str] = None # 当前物料 (如 "A1", "A2")
|
||||
@@ -109,31 +90,26 @@ class HeatingStation:
|
||||
heating_progress: float = 0.0
|
||||
|
||||
|
||||
@device(
|
||||
id="virtual_workbench",
|
||||
category=["virtual_device"],
|
||||
description="Virtual Workbench with 1 robotic arm and 3 heating stations for concurrent material processing",
|
||||
)
|
||||
class VirtualWorkbench:
|
||||
"""
|
||||
Virtual Workbench Device - 虚拟工作台设备
|
||||
|
||||
模拟一个包含1个机械臂和3个加热台的工作站
|
||||
- 机械臂操作耗时3秒, 同一时间只能执行一个操作
|
||||
- 加热台加热耗时10秒, 3个加热台可并行工作
|
||||
- 机械臂操作耗时3秒,同一时间只能执行一个操作
|
||||
- 加热台加热耗时10秒,3个加热台可并行工作
|
||||
|
||||
工作流:
|
||||
1. 物料A1-A5并发启动(线程池), 竞争机械臂使用权
|
||||
2. 获取机械臂后, 查找空闲加热台
|
||||
3. 机械臂将物料放入加热台, 开始加热
|
||||
4. 加热完成后, 机械臂将物料移动到目标位置Cn
|
||||
1. 物料A1-A5并发启动(线程池),竞争机械臂使用权
|
||||
2. 获取机械臂后,查找空闲加热台
|
||||
3. 机械臂将物料放入加热台,开始加热
|
||||
4. 加热完成后,机械臂将物料移动到目标位置Cn
|
||||
"""
|
||||
|
||||
_ros_node: BaseROS2DeviceNode
|
||||
|
||||
# 配置常量
|
||||
ARM_OPERATION_TIME: float = 2 # 机械臂操作时间(秒)
|
||||
HEATING_TIME: float = 60.0 # 加热时间(秒)
|
||||
ARM_OPERATION_TIME: float = 3.0 # 机械臂操作时间(秒)
|
||||
HEATING_TIME: float = 10.0 # 加热时间(秒)
|
||||
NUM_HEATING_STATIONS: int = 3 # 加热台数量
|
||||
|
||||
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
|
||||
@@ -150,23 +126,24 @@ class VirtualWorkbench:
|
||||
self.data: Dict[str, Any] = {}
|
||||
|
||||
# 从config中获取可配置参数
|
||||
self.ARM_OPERATION_TIME = float(self.config.get("arm_operation_time", self.ARM_OPERATION_TIME))
|
||||
self.HEATING_TIME = float(self.config.get("heating_time", self.HEATING_TIME))
|
||||
self.NUM_HEATING_STATIONS = int(self.config.get("num_heating_stations", self.NUM_HEATING_STATIONS))
|
||||
self.ARM_OPERATION_TIME = float(self.config.get("arm_operation_time", 3.0))
|
||||
self.HEATING_TIME = float(self.config.get("heating_time", 10.0))
|
||||
self.NUM_HEATING_STATIONS = int(self.config.get("num_heating_stations", 3))
|
||||
|
||||
# 机械臂状态和锁
|
||||
# 机械臂状态和锁 (使用threading.Lock)
|
||||
self._arm_lock = Lock()
|
||||
self._arm_state = ArmState.IDLE
|
||||
self._arm_current_task: Optional[str] = None
|
||||
|
||||
# 加热台状态
|
||||
# 加热台状态 (station_id -> HeatingStation) - 立即初始化,不依赖initialize()
|
||||
self._heating_stations: Dict[int, HeatingStation] = {
|
||||
i: HeatingStation(station_id=i) for i in range(1, self.NUM_HEATING_STATIONS + 1)
|
||||
i: HeatingStation(station_id=i)
|
||||
for i in range(1, self.NUM_HEATING_STATIONS + 1)
|
||||
}
|
||||
self._stations_lock = RLock()
|
||||
self._stations_lock = RLock() # 可重入锁,保护加热台状态
|
||||
|
||||
# 任务追踪
|
||||
self._active_tasks: Dict[str, Dict[str, Any]] = {}
|
||||
self._active_tasks: Dict[str, Dict[str, Any]] = {} # material_id -> task_info
|
||||
self._tasks_lock = Lock()
|
||||
|
||||
# 处理其他kwargs参数
|
||||
@@ -192,6 +169,7 @@ class VirtualWorkbench:
|
||||
"""初始化虚拟工作台"""
|
||||
self.logger.info(f"初始化虚拟工作台 {self.device_id}")
|
||||
|
||||
# 重置加热台状态 (已在__init__中创建,这里重置为初始状态)
|
||||
with self._stations_lock:
|
||||
for station in self._heating_stations.values():
|
||||
station.state = HeatingStationState.IDLE
|
||||
@@ -199,16 +177,15 @@ class VirtualWorkbench:
|
||||
station.material_number = None
|
||||
station.heating_progress = 0.0
|
||||
|
||||
self.data.update(
|
||||
{
|
||||
"status": "Ready",
|
||||
"arm_state": ArmState.IDLE.value,
|
||||
"arm_current_task": None,
|
||||
"heating_stations": self._get_stations_status(),
|
||||
"active_tasks_count": 0,
|
||||
"message": "工作台就绪",
|
||||
}
|
||||
)
|
||||
# 初始化状态
|
||||
self.data.update({
|
||||
"status": "Ready",
|
||||
"arm_state": ArmState.IDLE.value,
|
||||
"arm_current_task": None,
|
||||
"heating_stations": self._get_stations_status(),
|
||||
"active_tasks_count": 0,
|
||||
"message": "工作台就绪",
|
||||
})
|
||||
|
||||
self.logger.info(f"工作台初始化完成: {self.NUM_HEATING_STATIONS}个加热台就绪")
|
||||
return True
|
||||
@@ -227,14 +204,12 @@ class VirtualWorkbench:
|
||||
with self._tasks_lock:
|
||||
self._active_tasks.clear()
|
||||
|
||||
self.data.update(
|
||||
{
|
||||
"status": "Offline",
|
||||
"arm_state": ArmState.IDLE.value,
|
||||
"heating_stations": {},
|
||||
"message": "工作台已关闭",
|
||||
}
|
||||
)
|
||||
self.data.update({
|
||||
"status": "Offline",
|
||||
"arm_state": ArmState.IDLE.value,
|
||||
"heating_stations": {},
|
||||
"message": "工作台已关闭",
|
||||
})
|
||||
return True
|
||||
|
||||
def _get_stations_status(self) -> Dict[int, Dict[str, Any]]:
|
||||
@@ -252,19 +227,21 @@ class VirtualWorkbench:
|
||||
|
||||
def _update_data_status(self, message: Optional[str] = None):
|
||||
"""更新状态数据"""
|
||||
self.data.update(
|
||||
{
|
||||
"arm_state": self._arm_state.value,
|
||||
"arm_current_task": self._arm_current_task,
|
||||
"heating_stations": self._get_stations_status(),
|
||||
"active_tasks_count": len(self._active_tasks),
|
||||
}
|
||||
)
|
||||
self.data.update({
|
||||
"arm_state": self._arm_state.value,
|
||||
"arm_current_task": self._arm_current_task,
|
||||
"heating_stations": self._get_stations_status(),
|
||||
"active_tasks_count": len(self._active_tasks),
|
||||
})
|
||||
if message:
|
||||
self.data["message"] = message
|
||||
|
||||
def _find_available_heating_station(self) -> Optional[int]:
|
||||
"""查找空闲的加热台"""
|
||||
"""查找空闲的加热台
|
||||
|
||||
Returns:
|
||||
空闲加热台ID,如果没有则返回None
|
||||
"""
|
||||
with self._stations_lock:
|
||||
for station_id, station in self._heating_stations.items():
|
||||
if station.state == HeatingStationState.IDLE:
|
||||
@@ -272,12 +249,23 @@ class VirtualWorkbench:
|
||||
return None
|
||||
|
||||
def _acquire_arm(self, task_description: str) -> bool:
|
||||
"""获取机械臂使用权(阻塞直到获取)"""
|
||||
"""获取机械臂使用权(阻塞直到获取)
|
||||
|
||||
Args:
|
||||
task_description: 任务描述,用于日志
|
||||
|
||||
Returns:
|
||||
是否成功获取
|
||||
"""
|
||||
self.logger.info(f"[{task_description}] 等待获取机械臂...")
|
||||
|
||||
# 阻塞等待获取锁
|
||||
self._arm_lock.acquire()
|
||||
|
||||
self._arm_state = ArmState.BUSY
|
||||
self._arm_current_task = task_description
|
||||
self._update_data_status(f"机械臂执行: {task_description}")
|
||||
|
||||
self.logger.info(f"[{task_description}] 成功获取机械臂使用权")
|
||||
return True
|
||||
|
||||
@@ -290,37 +278,28 @@ class VirtualWorkbench:
|
||||
self._update_data_status(f"机械臂已释放 (完成: {task})")
|
||||
self.logger.info(f"机械臂已释放 (完成: {task})")
|
||||
|
||||
@action(
|
||||
auto_prefix=True,
|
||||
description="批量准备物料 - 虚拟起始节点, 生成A1-A5物料, 输出5个handle供后续节点使用",
|
||||
handles=[
|
||||
ActionOutputHandle(key="channel_1", data_type="workbench_material",
|
||||
label="实验1", data_key="material_1", data_source=DataSource.EXECUTOR),
|
||||
ActionOutputHandle(key="channel_2", data_type="workbench_material",
|
||||
label="实验2", data_key="material_2", data_source=DataSource.EXECUTOR),
|
||||
ActionOutputHandle(key="channel_3", data_type="workbench_material",
|
||||
label="实验3", data_key="material_3", data_source=DataSource.EXECUTOR),
|
||||
ActionOutputHandle(key="channel_4", data_type="workbench_material",
|
||||
label="实验4", data_key="material_4", data_source=DataSource.EXECUTOR),
|
||||
ActionOutputHandle(key="channel_5", data_type="workbench_material",
|
||||
label="实验5", data_key="material_5", data_source=DataSource.EXECUTOR),
|
||||
],
|
||||
)
|
||||
def prepare_materials(
|
||||
self,
|
||||
sample_uuids: SampleUUIDsType,
|
||||
count: int = 5,
|
||||
) -> PrepareMaterialsResult:
|
||||
"""
|
||||
批量准备物料 - 虚拟起始节点
|
||||
|
||||
作为工作流的起始节点, 生成指定数量的物料编号供后续节点使用。
|
||||
输出5个handle (material_1 ~ material_5), 分别对应实验1~5。
|
||||
作为工作流的起始节点,生成指定数量的物料编号供后续节点使用。
|
||||
输出5个handle (material_1 ~ material_5),分别对应实验1~5。
|
||||
|
||||
Args:
|
||||
count: 待生成的物料数量,默认5 (生成 A1-A5)
|
||||
|
||||
Returns:
|
||||
PrepareMaterialsResult: 包含 material_1 ~ material_5 用于传递给 move_to_heating_station
|
||||
"""
|
||||
# 生成物料列表 A1 - A{count}
|
||||
materials = [i for i in range(1, count + 1)]
|
||||
|
||||
self.logger.info(
|
||||
f"[准备物料] 生成 {count} 个物料: A1-A{count} -> material_1~material_{count}"
|
||||
f"[准备物料] 生成 {count} 个物料: "
|
||||
f"A1-A{count} -> material_1~material_{count}"
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -332,42 +311,29 @@ class VirtualWorkbench:
|
||||
"material_4": materials[3] if len(materials) > 3 else 0,
|
||||
"material_5": materials[4] if len(materials) > 4 else 0,
|
||||
"message": f"已准备 {count} 个物料: A1-A{count}",
|
||||
"unilabos_samples": [
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra={"material_uuid": content} if isinstance(content, str) else (content.serialize() if content else {}),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
}
|
||||
|
||||
@action(
|
||||
auto_prefix=True,
|
||||
description="将物料从An位置移动到空闲加热台, 返回分配的加热台ID",
|
||||
handles=[
|
||||
ActionInputHandle(key="material_input", data_type="workbench_material",
|
||||
label="物料编号", data_key="material_number", data_source=DataSource.HANDLE),
|
||||
ActionOutputHandle(key="heating_station_output", data_type="workbench_station",
|
||||
label="加热台ID", data_key="station_id", data_source=DataSource.EXECUTOR),
|
||||
ActionOutputHandle(key="material_number_output", data_type="workbench_material",
|
||||
label="物料编号", data_key="material_number", data_source=DataSource.EXECUTOR),
|
||||
],
|
||||
)
|
||||
def move_to_heating_station(
|
||||
self,
|
||||
sample_uuids: SampleUUIDsType,
|
||||
material_number: int,
|
||||
) -> MoveToHeatingStationResult:
|
||||
"""
|
||||
将物料从An位置移动到加热台
|
||||
|
||||
多线程并发调用时, 会竞争机械臂使用权, 并自动查找空闲加热台
|
||||
多线程并发调用时,会竞争机械臂使用权,并自动查找空闲加热台
|
||||
|
||||
Args:
|
||||
material_number: 物料编号 (1-5)
|
||||
|
||||
Returns:
|
||||
MoveToHeatingStationResult: 包含 station_id, material_number 等用于传递给下一个节点
|
||||
"""
|
||||
# 根据物料编号生成物料ID
|
||||
material_id = f"A{material_number}"
|
||||
task_desc = f"移动{material_id}到加热台"
|
||||
self.logger.info(f"[任务] {task_desc} - 开始执行")
|
||||
|
||||
# 记录任务
|
||||
with self._tasks_lock:
|
||||
self._active_tasks[material_id] = {
|
||||
"status": "waiting_for_arm",
|
||||
@@ -375,27 +341,33 @@ class VirtualWorkbench:
|
||||
}
|
||||
|
||||
try:
|
||||
# 步骤1: 等待获取机械臂使用权(竞争)
|
||||
with self._tasks_lock:
|
||||
self._active_tasks[material_id]["status"] = "waiting_for_arm"
|
||||
self._acquire_arm(task_desc)
|
||||
|
||||
# 步骤2: 查找空闲加热台
|
||||
with self._tasks_lock:
|
||||
self._active_tasks[material_id]["status"] = "finding_station"
|
||||
station_id = None
|
||||
|
||||
# 循环等待直到找到空闲加热台
|
||||
while station_id is None:
|
||||
station_id = self._find_available_heating_station()
|
||||
if station_id is None:
|
||||
self.logger.info(f"[{material_id}] 没有空闲加热台, 等待中...")
|
||||
self.logger.info(f"[{material_id}] 没有空闲加热台,等待中...")
|
||||
# 释放机械臂,等待后重试
|
||||
self._release_arm()
|
||||
time.sleep(0.5)
|
||||
self._acquire_arm(task_desc)
|
||||
|
||||
# 步骤3: 占用加热台 - 立即标记为OCCUPIED,防止其他任务选择同一加热台
|
||||
with self._stations_lock:
|
||||
self._heating_stations[station_id].state = HeatingStationState.OCCUPIED
|
||||
self._heating_stations[station_id].current_material = material_id
|
||||
self._heating_stations[station_id].material_number = material_number
|
||||
|
||||
# 步骤4: 模拟机械臂移动操作 (3秒)
|
||||
with self._tasks_lock:
|
||||
self._active_tasks[material_id]["status"] = "arm_moving"
|
||||
self._active_tasks[material_id]["assigned_station"] = station_id
|
||||
@@ -403,11 +375,11 @@ class VirtualWorkbench:
|
||||
|
||||
time.sleep(self.ARM_OPERATION_TIME)
|
||||
|
||||
# 步骤5: 放入加热台完成
|
||||
self._update_data_status(f"{material_id}已放入加热台{station_id}")
|
||||
self.logger.info(
|
||||
f"[{material_id}] 已放入加热台{station_id} (用时{self.ARM_OPERATION_TIME}s)"
|
||||
)
|
||||
self.logger.info(f"[{material_id}] 已放入加热台{station_id} (用时{self.ARM_OPERATION_TIME}s)")
|
||||
|
||||
# 释放机械臂
|
||||
self._release_arm()
|
||||
|
||||
with self._tasks_lock:
|
||||
@@ -419,17 +391,6 @@ class VirtualWorkbench:
|
||||
"material_id": material_id,
|
||||
"material_number": material_number,
|
||||
"message": f"{material_id}已成功移动到加热台{station_id}",
|
||||
"unilabos_samples": [
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
@@ -442,42 +403,22 @@ class VirtualWorkbench:
|
||||
"material_id": material_id,
|
||||
"material_number": material_number,
|
||||
"message": f"移动失败: {str(e)}",
|
||||
"unilabos_samples": [
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
}
|
||||
|
||||
@action(
|
||||
auto_prefix=True,
|
||||
always_free=True,
|
||||
description="启动指定加热台的加热程序",
|
||||
handles=[
|
||||
ActionInputHandle(key="station_id_input", data_type="workbench_station",
|
||||
label="加热台ID", data_key="station_id", data_source=DataSource.HANDLE),
|
||||
ActionInputHandle(key="material_number_input", data_type="workbench_material",
|
||||
label="物料编号", data_key="material_number", data_source=DataSource.HANDLE),
|
||||
ActionOutputHandle(key="heating_done_station", data_type="workbench_station",
|
||||
label="加热完成-加热台ID", data_key="station_id", data_source=DataSource.EXECUTOR),
|
||||
ActionOutputHandle(key="heating_done_material", data_type="workbench_material",
|
||||
label="加热完成-物料编号", data_key="material_number", data_source=DataSource.EXECUTOR),
|
||||
],
|
||||
)
|
||||
def start_heating(
|
||||
self,
|
||||
sample_uuids: SampleUUIDsType,
|
||||
station_id: int,
|
||||
material_number: int,
|
||||
) -> StartHeatingResult:
|
||||
"""
|
||||
启动指定加热台的加热程序
|
||||
|
||||
Args:
|
||||
station_id: 加热台ID (1-3),从 move_to_heating_station 的 handle 传入
|
||||
material_number: 物料编号,从 move_to_heating_station 的 handle 传入
|
||||
|
||||
Returns:
|
||||
StartHeatingResult: 包含 station_id, material_number 等用于传递给下一个节点
|
||||
"""
|
||||
self.logger.info(f"[加热台{station_id}] 开始加热")
|
||||
|
||||
@@ -488,17 +429,6 @@ class VirtualWorkbench:
|
||||
"material_id": "",
|
||||
"material_number": material_number,
|
||||
"message": f"无效的加热台ID: {station_id}",
|
||||
"unilabos_samples": [
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
}
|
||||
|
||||
with self._stations_lock:
|
||||
@@ -511,17 +441,6 @@ class VirtualWorkbench:
|
||||
"material_id": "",
|
||||
"material_number": material_number,
|
||||
"message": f"加热台{station_id}上没有物料",
|
||||
"unilabos_samples": [
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
}
|
||||
|
||||
if station.state == HeatingStationState.HEATING:
|
||||
@@ -531,21 +450,11 @@ class VirtualWorkbench:
|
||||
"material_id": station.current_material,
|
||||
"material_number": material_number,
|
||||
"message": f"加热台{station_id}已经在加热中",
|
||||
"unilabos_samples": [
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
}
|
||||
|
||||
material_id = station.current_material
|
||||
|
||||
# 开始加热
|
||||
station.state = HeatingStationState.HEATING
|
||||
station.heating_start_time = time.time()
|
||||
station.heating_progress = 0.0
|
||||
@@ -556,19 +465,10 @@ class VirtualWorkbench:
|
||||
|
||||
self._update_data_status(f"加热台{station_id}开始加热{material_id}")
|
||||
|
||||
with self._stations_lock:
|
||||
heating_list = [
|
||||
f"加热台{sid}:{s.current_material}"
|
||||
for sid, s in self._heating_stations.items()
|
||||
if s.state == HeatingStationState.HEATING and s.current_material
|
||||
]
|
||||
self.logger.info(f"[并行加热] 当前同时加热中: {', '.join(heating_list)}")
|
||||
|
||||
# 模拟加热过程 (10秒)
|
||||
start_time = time.time()
|
||||
last_countdown_log = start_time
|
||||
while True:
|
||||
elapsed = time.time() - start_time
|
||||
remaining = max(0.0, self.HEATING_TIME - elapsed)
|
||||
progress = min(100.0, (elapsed / self.HEATING_TIME) * 100)
|
||||
|
||||
with self._stations_lock:
|
||||
@@ -576,15 +476,12 @@ class VirtualWorkbench:
|
||||
|
||||
self._update_data_status(f"加热台{station_id}加热中: {progress:.1f}%")
|
||||
|
||||
if time.time() - last_countdown_log >= 5.0:
|
||||
self.logger.info(f"[加热台{station_id}] {material_id} 剩余 {remaining:.1f}s")
|
||||
last_countdown_log = time.time()
|
||||
|
||||
if elapsed >= self.HEATING_TIME:
|
||||
break
|
||||
|
||||
time.sleep(1.0)
|
||||
|
||||
# 加热完成
|
||||
with self._stations_lock:
|
||||
self._heating_stations[station_id].state = HeatingStationState.COMPLETED
|
||||
self._heating_stations[station_id].heating_progress = 100.0
|
||||
@@ -602,39 +499,24 @@ class VirtualWorkbench:
|
||||
"material_id": material_id,
|
||||
"material_number": material_number,
|
||||
"message": f"加热台{station_id}加热完成",
|
||||
"unilabos_samples": [
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
}
|
||||
|
||||
@action(
|
||||
auto_prefix=True,
|
||||
description="将物料从加热台移动到输出位置Cn",
|
||||
handles=[
|
||||
ActionInputHandle(key="output_station_input", data_type="workbench_station",
|
||||
label="加热台ID", data_key="station_id", data_source=DataSource.HANDLE),
|
||||
ActionInputHandle(key="output_material_input", data_type="workbench_material",
|
||||
label="物料编号", data_key="material_number", data_source=DataSource.HANDLE),
|
||||
],
|
||||
)
|
||||
def move_to_output(
|
||||
self,
|
||||
sample_uuids: SampleUUIDsType,
|
||||
station_id: int,
|
||||
material_number: int,
|
||||
) -> MoveToOutputResult:
|
||||
"""
|
||||
将物料从加热台移动到输出位置Cn
|
||||
|
||||
Args:
|
||||
station_id: 加热台ID (1-3),从 start_heating 的 handle 传入
|
||||
material_number: 物料编号,从 start_heating 的 handle 传入,用于确定输出位置 Cn
|
||||
|
||||
Returns:
|
||||
MoveToOutputResult: 包含执行结果
|
||||
"""
|
||||
output_number = material_number
|
||||
output_number = material_number # 物料编号决定输出位置
|
||||
|
||||
if station_id not in self._heating_stations:
|
||||
return {
|
||||
@@ -643,17 +525,6 @@ class VirtualWorkbench:
|
||||
"material_id": "",
|
||||
"output_position": f"C{output_number}",
|
||||
"message": f"无效的加热台ID: {station_id}",
|
||||
"unilabos_samples": [
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
}
|
||||
|
||||
with self._stations_lock:
|
||||
@@ -667,17 +538,6 @@ class VirtualWorkbench:
|
||||
"material_id": "",
|
||||
"output_position": f"C{output_number}",
|
||||
"message": f"加热台{station_id}上没有物料",
|
||||
"unilabos_samples": [
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
}
|
||||
|
||||
if station.state != HeatingStationState.COMPLETED:
|
||||
@@ -687,17 +547,6 @@ class VirtualWorkbench:
|
||||
"material_id": material_id,
|
||||
"output_position": f"C{output_number}",
|
||||
"message": f"加热台{station_id}尚未完成加热 (当前状态: {station.state.value})",
|
||||
"unilabos_samples": [
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
}
|
||||
|
||||
output_position = f"C{output_number}"
|
||||
@@ -709,17 +558,18 @@ class VirtualWorkbench:
|
||||
if material_id in self._active_tasks:
|
||||
self._active_tasks[material_id]["status"] = "waiting_for_arm_output"
|
||||
|
||||
# 获取机械臂
|
||||
self._acquire_arm(task_desc)
|
||||
|
||||
with self._tasks_lock:
|
||||
if material_id in self._active_tasks:
|
||||
self._active_tasks[material_id]["status"] = "arm_moving_to_output"
|
||||
|
||||
self.logger.info(
|
||||
f"[{material_id}] 机械臂正在从加热台{station_id}取出并移动到{output_position}..."
|
||||
)
|
||||
# 模拟机械臂操作 (3秒)
|
||||
self.logger.info(f"[{material_id}] 机械臂正在从加热台{station_id}取出并移动到{output_position}...")
|
||||
time.sleep(self.ARM_OPERATION_TIME)
|
||||
|
||||
# 清空加热台
|
||||
with self._stations_lock:
|
||||
self._heating_stations[station_id].state = HeatingStationState.IDLE
|
||||
self._heating_stations[station_id].current_material = None
|
||||
@@ -727,17 +577,17 @@ class VirtualWorkbench:
|
||||
self._heating_stations[station_id].heating_progress = 0.0
|
||||
self._heating_stations[station_id].heating_start_time = None
|
||||
|
||||
# 释放机械臂
|
||||
self._release_arm()
|
||||
|
||||
# 任务完成
|
||||
with self._tasks_lock:
|
||||
if material_id in self._active_tasks:
|
||||
self._active_tasks[material_id]["status"] = "completed"
|
||||
self._active_tasks[material_id]["end_time"] = time.time()
|
||||
|
||||
self._update_data_status(f"{material_id}已移动到{output_position}")
|
||||
self.logger.info(
|
||||
f"[{material_id}] 已成功移动到{output_position} (用时{self.ARM_OPERATION_TIME}s)"
|
||||
)
|
||||
self.logger.info(f"[{material_id}] 已成功移动到{output_position} (用时{self.ARM_OPERATION_TIME}s)")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -745,18 +595,6 @@ class VirtualWorkbench:
|
||||
"material_id": material_id,
|
||||
"output_position": output_position,
|
||||
"message": f"{material_id}已成功移动到{output_position}",
|
||||
"unilabos_samples": [
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str)
|
||||
else (content.serialize() if content is not None else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
@@ -769,106 +607,81 @@ class VirtualWorkbench:
|
||||
"material_id": "",
|
||||
"output_position": output_position,
|
||||
"message": f"移动失败: {str(e)}",
|
||||
"unilabos_samples": [
|
||||
LabSample(
|
||||
sample_uuid=sample_uuid,
|
||||
oss_path="",
|
||||
extra=(
|
||||
{"material_uuid": content}
|
||||
if isinstance(content, str) else (content.serialize() if content else {})
|
||||
),
|
||||
)
|
||||
for sample_uuid, content in sample_uuids.items()
|
||||
],
|
||||
}
|
||||
|
||||
# ============ 状态属性 ============
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def status(self) -> str:
|
||||
return self.data.get("status", "Unknown")
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def arm_state(self) -> str:
|
||||
return self._arm_state.value
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def arm_current_task(self) -> str:
|
||||
return self._arm_current_task or ""
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def heating_station_1_state(self) -> str:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(1)
|
||||
return station.state.value if station else "unknown"
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def heating_station_1_material(self) -> str:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(1)
|
||||
return station.current_material or "" if station else ""
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def heating_station_1_progress(self) -> float:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(1)
|
||||
return station.heating_progress if station else 0.0
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def heating_station_2_state(self) -> str:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(2)
|
||||
return station.state.value if station else "unknown"
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def heating_station_2_material(self) -> str:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(2)
|
||||
return station.current_material or "" if station else ""
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def heating_station_2_progress(self) -> float:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(2)
|
||||
return station.heating_progress if station else 0.0
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def heating_station_3_state(self) -> str:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(3)
|
||||
return station.state.value if station else "unknown"
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def heating_station_3_material(self) -> str:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(3)
|
||||
return station.current_material or "" if station else ""
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def heating_station_3_progress(self) -> float:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(3)
|
||||
return station.heating_progress if station else 0.0
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def active_tasks_count(self) -> int:
|
||||
with self._tasks_lock:
|
||||
return len(self._active_tasks)
|
||||
|
||||
@property
|
||||
@topic_config()
|
||||
def message(self) -> str:
|
||||
return self.data.get("message", "")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,614 +0,0 @@
|
||||
"""
|
||||
装饰器注册表系统
|
||||
|
||||
通过 @device, @action, @resource 装饰器替代 YAML 配置文件来定义设备/动作/资源注册表信息。
|
||||
|
||||
Usage:
|
||||
from unilabos.registry.decorators import (
|
||||
device, action, resource,
|
||||
InputHandle, OutputHandle,
|
||||
ActionInputHandle, ActionOutputHandle,
|
||||
HardwareInterface, Side, DataSource,
|
||||
)
|
||||
|
||||
@device(
|
||||
id="solenoid_valve.mock",
|
||||
category=["pump_and_valve"],
|
||||
description="模拟电磁阀设备",
|
||||
handles=[
|
||||
InputHandle(key="in", data_type="fluid", label="in", side=Side.NORTH),
|
||||
OutputHandle(key="out", data_type="fluid", label="out", side=Side.SOUTH),
|
||||
],
|
||||
hardware_interface=HardwareInterface(
|
||||
name="hardware_interface",
|
||||
read="send_command",
|
||||
write="send_command",
|
||||
),
|
||||
)
|
||||
class SolenoidValveMock:
|
||||
@action(action_type=EmptyIn)
|
||||
def close(self):
|
||||
...
|
||||
|
||||
@action(
|
||||
handles=[
|
||||
ActionInputHandle(key="in", data_type="fluid", label="in"),
|
||||
ActionOutputHandle(key="out", data_type="fluid", label="out"),
|
||||
],
|
||||
)
|
||||
def set_valve_position(self, position):
|
||||
...
|
||||
|
||||
# 无 @action 装饰器 => auto- 前缀动作
|
||||
def is_open(self):
|
||||
...
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, Dict, List, Optional, TypeVar
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 枚举
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Side(str, Enum):
|
||||
"""UI 上 Handle 的显示位置"""
|
||||
|
||||
NORTH = "NORTH"
|
||||
SOUTH = "SOUTH"
|
||||
EAST = "EAST"
|
||||
WEST = "WEST"
|
||||
|
||||
|
||||
class DataSource(str, Enum):
|
||||
"""Handle 的数据来源"""
|
||||
|
||||
HANDLE = "handle" # 从上游 handle 获取数据 (用于 InputHandle)
|
||||
EXECUTOR = "executor" # 从执行器输出数据 (用于 OutputHandle)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device / Resource Handle (设备/资源级别端口, 序列化时包含 io_type)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _DeviceHandleBase(BaseModel):
|
||||
"""设备/资源端口基类 (内部使用)"""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
key: str = Field(serialization_alias="handler_key")
|
||||
data_type: str
|
||||
label: str
|
||||
side: Optional[Side] = None
|
||||
data_key: Optional[str] = None
|
||||
data_source: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
# 子类覆盖
|
||||
io_type: str = ""
|
||||
|
||||
def to_registry_dict(self) -> Dict[str, Any]:
|
||||
return self.model_dump(by_alias=True, exclude_none=True)
|
||||
|
||||
|
||||
class InputHandle(_DeviceHandleBase):
|
||||
"""
|
||||
输入端口 (io_type="target"), 用于 @device / @resource handles
|
||||
|
||||
Example:
|
||||
InputHandle(key="in", data_type="fluid", label="in", side=Side.NORTH)
|
||||
"""
|
||||
|
||||
io_type: str = "target"
|
||||
|
||||
|
||||
class OutputHandle(_DeviceHandleBase):
|
||||
"""
|
||||
输出端口 (io_type="source"), 用于 @device / @resource handles
|
||||
|
||||
Example:
|
||||
OutputHandle(key="out", data_type="fluid", label="out", side=Side.SOUTH)
|
||||
"""
|
||||
|
||||
io_type: str = "source"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Action Handle (动作级别端口, 序列化时不含 io_type, 按类型自动分组)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _ActionHandleBase(BaseModel):
|
||||
"""动作端口基类 (内部使用)"""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
key: str = Field(serialization_alias="handler_key")
|
||||
data_type: str
|
||||
label: str
|
||||
side: Optional[Side] = None
|
||||
data_key: Optional[str] = None
|
||||
data_source: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
io_type: Optional[str] = None # source/sink (dataflow) or target/source (device-style)
|
||||
|
||||
def to_registry_dict(self) -> Dict[str, Any]:
|
||||
return self.model_dump(by_alias=True, exclude_none=True)
|
||||
|
||||
|
||||
class ActionInputHandle(_ActionHandleBase):
|
||||
"""
|
||||
动作输入端口, 用于 @action handles, 序列化后归入 "input" 组
|
||||
|
||||
Example:
|
||||
ActionInputHandle(
|
||||
key="material_input", data_type="workbench_material",
|
||||
label="物料编号", data_key="material_number", data_source="handle",
|
||||
)
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ActionOutputHandle(_ActionHandleBase):
|
||||
"""
|
||||
动作输出端口, 用于 @action handles, 序列化后归入 "output" 组
|
||||
|
||||
Example:
|
||||
ActionOutputHandle(
|
||||
key="station_output", data_type="workbench_station",
|
||||
label="加热台ID", data_key="station_id", data_source="executor",
|
||||
)
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HardwareInterface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class HardwareInterface(BaseModel):
|
||||
"""
|
||||
硬件通信接口定义
|
||||
|
||||
描述设备与底层硬件通信的方式 (串口、Modbus 等)。
|
||||
|
||||
Example:
|
||||
HardwareInterface(name="hardware_interface", read="send_command", write="send_command")
|
||||
"""
|
||||
|
||||
name: str
|
||||
read: Optional[str] = None
|
||||
write: Optional[str] = None
|
||||
extra_info: Optional[List[str]] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 全局注册表 -- 记录所有被装饰器标记的类/函数
|
||||
# ---------------------------------------------------------------------------
|
||||
_registered_devices: Dict[str, type] = {} # device_id -> class
|
||||
_registered_resources: Dict[str, Any] = {} # resource_id -> class or function
|
||||
|
||||
|
||||
def _device_handles_to_list(
|
||||
handles: Optional[List[_DeviceHandleBase]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""将设备/资源 Handle 列表序列化为字典列表 (含 io_type)"""
|
||||
if handles is None:
|
||||
return []
|
||||
return [h.to_registry_dict() for h in handles]
|
||||
|
||||
|
||||
def _action_handles_to_dict(
|
||||
handles: Optional[List[_ActionHandleBase]],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
将动作 Handle 列表序列化为 {"input": [...], "output": [...]} 格式。
|
||||
|
||||
ActionInputHandle => "input", ActionOutputHandle => "output"
|
||||
"""
|
||||
if handles is None:
|
||||
return {}
|
||||
input_list = [h.to_registry_dict() for h in handles if isinstance(h, ActionInputHandle)]
|
||||
output_list = [h.to_registry_dict() for h in handles if isinstance(h, ActionOutputHandle)]
|
||||
result: Dict[str, Any] = {}
|
||||
if input_list:
|
||||
result["input"] = input_list
|
||||
if output_list:
|
||||
result["output"] = output_list
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# @device 类装饰器
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# noinspection PyShadowingBuiltins
|
||||
def device(
|
||||
id: Optional[str] = None,
|
||||
ids: Optional[List[str]] = None,
|
||||
id_meta: Optional[Dict[str, Dict[str, Any]]] = None,
|
||||
category: Optional[List[str]] = None,
|
||||
description: str = "",
|
||||
display_name: str = "",
|
||||
icon: str = "",
|
||||
version: str = "1.0.0",
|
||||
handles: Optional[List[_DeviceHandleBase]] = None,
|
||||
model: Optional[Dict[str, Any]] = None,
|
||||
device_type: str = "python",
|
||||
hardware_interface: Optional[HardwareInterface] = None,
|
||||
):
|
||||
"""
|
||||
设备类装饰器
|
||||
|
||||
将类标记为一个 UniLab-OS 设备,并附加注册表元数据。
|
||||
|
||||
支持两种模式:
|
||||
1. 单设备: id="xxx", category=[...]
|
||||
2. 多设备: ids=["id1","id2"], id_meta={"id1":{handles:[...]}, "id2":{...}}
|
||||
|
||||
Args:
|
||||
id: 单设备时的注册表唯一标识
|
||||
ids: 多设备时的 id 列表,与 id_meta 配合使用
|
||||
id_meta: 每个 device_id 的覆盖元数据 (handles/description/icon/model)
|
||||
category: 设备分类标签列表 (必填)
|
||||
description: 设备描述
|
||||
display_name: 人类可读的设备显示名称,缺失时默认使用 id
|
||||
icon: 图标路径
|
||||
version: 版本号
|
||||
handles: 设备端口列表 (单设备或 id_meta 未覆盖时使用)
|
||||
model: 可选的 3D 模型配置
|
||||
device_type: 设备实现类型 ("python" / "ros2")
|
||||
hardware_interface: 硬件通信接口 (HardwareInterface)
|
||||
"""
|
||||
# Resolve device ids
|
||||
if ids is not None:
|
||||
device_ids = list(ids)
|
||||
if not device_ids:
|
||||
raise ValueError("@device ids 不能为空")
|
||||
id_meta = id_meta or {}
|
||||
elif id is not None:
|
||||
device_ids = [id]
|
||||
id_meta = {}
|
||||
else:
|
||||
raise ValueError("@device 必须提供 id 或 ids")
|
||||
|
||||
if category is None:
|
||||
raise ValueError("@device category 必填")
|
||||
|
||||
base_meta = {
|
||||
"category": category,
|
||||
"description": description,
|
||||
"display_name": display_name,
|
||||
"icon": icon,
|
||||
"version": version,
|
||||
"handles": _device_handles_to_list(handles),
|
||||
"model": model,
|
||||
"device_type": device_type,
|
||||
"hardware_interface": (hardware_interface.model_dump(exclude_none=True) if hardware_interface else None),
|
||||
}
|
||||
|
||||
def decorator(cls):
|
||||
cls._device_registry_meta = base_meta
|
||||
cls._device_registry_id_meta = id_meta
|
||||
cls._device_registry_ids = device_ids
|
||||
|
||||
for did in device_ids:
|
||||
if did in _registered_devices:
|
||||
raise ValueError(f"@device id 重复: '{did}' 已被 {_registered_devices[did]} 注册")
|
||||
_registered_devices[did] = cls
|
||||
|
||||
return cls
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# @action 方法装饰器
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 区分 "用户没传 action_type" 和 "用户传了 None"
|
||||
_ACTION_TYPE_UNSET = object()
|
||||
|
||||
|
||||
# noinspection PyShadowingNames
|
||||
def action(
|
||||
action_type: Any = _ACTION_TYPE_UNSET,
|
||||
goal: Optional[Dict[str, str]] = None,
|
||||
feedback: Optional[Dict[str, str]] = None,
|
||||
result: Optional[Dict[str, str]] = None,
|
||||
handles: Optional[List[_ActionHandleBase]] = None,
|
||||
goal_default: Optional[Dict[str, Any]] = None,
|
||||
placeholder_keys: Optional[Dict[str, str]] = None,
|
||||
always_free: bool = False,
|
||||
is_protocol: bool = False,
|
||||
description: str = "",
|
||||
auto_prefix: bool = False,
|
||||
parent: bool = False,
|
||||
):
|
||||
"""
|
||||
动作方法装饰器
|
||||
|
||||
标记方法为注册表动作。有三种用法:
|
||||
1. @action(action_type=EmptyIn, ...) -- 非 auto, 使用指定 ROS Action 类型
|
||||
2. @action() -- 非 auto, UniLabJsonCommand (从方法签名生成 schema)
|
||||
3. 不加 @action -- auto- 前缀, UniLabJsonCommand
|
||||
|
||||
Protocol 用法:
|
||||
@action(action_type=Add, is_protocol=True)
|
||||
def AddProtocol(self): ...
|
||||
标记该动作为高级协议 (protocol),运行时通过 ROS Action 路由到
|
||||
protocol generator 执行。action_type 指向 unilabos_msgs 的 Action 类型。
|
||||
|
||||
Args:
|
||||
action_type: ROS Action 消息类型 (如 EmptyIn, SendCmd, HeatChill).
|
||||
不传/默认 = UniLabJsonCommand (非 auto).
|
||||
goal: Goal 字段映射 (ROS字段名 -> 设备参数名).
|
||||
protocol 模式下可留空,系统自动生成 identity 映射.
|
||||
feedback: Feedback 字段映射
|
||||
result: Result 字段映射
|
||||
handles: 动作端口列表 (ActionInputHandle / ActionOutputHandle)
|
||||
goal_default: Goal 字段默认值映射 (字段名 -> 默认值), 与自动生成的 goal_default 合并
|
||||
placeholder_keys: 参数占位符配置
|
||||
always_free: 是否为永久闲置动作 (不受排队限制)
|
||||
is_protocol: 是否为工作站协议 (protocol)。True 时运行时走 protocol generator 路径。
|
||||
description: 动作描述
|
||||
auto_prefix: 若为 True,动作名使用 auto-{method_name} 形式(与无 @action 时一致)
|
||||
parent: 若为 True,当方法参数为空 (*args, **kwargs) 时,通过 MRO 从父类获取真实方法参数
|
||||
"""
|
||||
|
||||
def decorator(func: F) -> F:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
# action_type 为哨兵值 => 用户没传, 视为 None (UniLabJsonCommand)
|
||||
resolved_type = None if action_type is _ACTION_TYPE_UNSET else action_type
|
||||
|
||||
meta = {
|
||||
"action_type": resolved_type,
|
||||
"goal": goal or {},
|
||||
"feedback": feedback or {},
|
||||
"result": result or {},
|
||||
"handles": _action_handles_to_dict(handles),
|
||||
"goal_default": goal_default or {},
|
||||
"placeholder_keys": placeholder_keys or {},
|
||||
"always_free": always_free,
|
||||
"is_protocol": is_protocol,
|
||||
"description": description,
|
||||
"auto_prefix": auto_prefix,
|
||||
"parent": parent,
|
||||
}
|
||||
wrapper._action_registry_meta = meta # type: ignore[attr-defined]
|
||||
|
||||
# 设置 _is_always_free 保持与旧 @always_free 装饰器兼容
|
||||
if always_free:
|
||||
wrapper._is_always_free = True # type: ignore[attr-defined]
|
||||
|
||||
return wrapper # type: ignore[return-value]
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def get_action_meta(func) -> Optional[Dict[str, Any]]:
|
||||
"""获取方法上的 @action 装饰器元数据"""
|
||||
return getattr(func, "_action_registry_meta", None)
|
||||
|
||||
|
||||
def has_action_decorator(func) -> bool:
|
||||
"""检查函数是否带有 @action 装饰器"""
|
||||
return hasattr(func, "_action_registry_meta")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# @resource 类/函数装饰器
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def resource(
|
||||
id: str,
|
||||
category: List[str],
|
||||
description: str = "",
|
||||
icon: str = "",
|
||||
version: str = "1.0.0",
|
||||
handles: Optional[List[_DeviceHandleBase]] = None,
|
||||
model: Optional[Dict[str, Any]] = None,
|
||||
class_type: str = "pylabrobot",
|
||||
):
|
||||
"""
|
||||
资源类/函数装饰器
|
||||
|
||||
将类或工厂函数标记为一个 UniLab-OS 资源,附加注册表元数据。
|
||||
|
||||
Args:
|
||||
id: 注册表唯一标识 (必填, 不可重复)
|
||||
category: 资源分类标签列表 (必填)
|
||||
description: 资源描述
|
||||
icon: 图标路径
|
||||
version: 版本号
|
||||
handles: 端口列表 (InputHandle / OutputHandle)
|
||||
model: 可选的 3D 模型配置
|
||||
class_type: 资源实现类型 ("python" / "pylabrobot" / "unilabos")
|
||||
"""
|
||||
|
||||
def decorator(obj):
|
||||
meta = {
|
||||
"resource_id": id,
|
||||
"category": category,
|
||||
"description": description,
|
||||
"icon": icon,
|
||||
"version": version,
|
||||
"handles": _device_handles_to_list(handles),
|
||||
"model": model,
|
||||
"class_type": class_type,
|
||||
}
|
||||
obj._resource_registry_meta = meta
|
||||
|
||||
if id in _registered_resources:
|
||||
raise ValueError(f"@resource id 重复: '{id}' 已被 {_registered_resources[id]} 注册")
|
||||
_registered_resources[id] = obj
|
||||
|
||||
return obj
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def get_device_meta(cls, device_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
获取类上的 @device 装饰器元数据。
|
||||
|
||||
当 device_id 存在且类使用 ids+id_meta 时,返回合并后的 meta
|
||||
(base_meta 与 id_meta[device_id] 深度合并)。
|
||||
"""
|
||||
base = getattr(cls, "_device_registry_meta", None)
|
||||
if base is None:
|
||||
return None
|
||||
id_meta = getattr(cls, "_device_registry_id_meta", None) or {}
|
||||
if device_id is None or device_id not in id_meta:
|
||||
result = dict(base)
|
||||
ids = getattr(cls, "_device_registry_ids", None)
|
||||
result["device_id"] = device_id if device_id is not None else (ids[0] if ids else None)
|
||||
return result
|
||||
|
||||
overrides = id_meta[device_id]
|
||||
result = dict(base)
|
||||
result["device_id"] = device_id
|
||||
for key in ["handles", "description", "icon", "model"]:
|
||||
if key in overrides:
|
||||
val = overrides[key]
|
||||
if key == "handles" and isinstance(val, list):
|
||||
# handles 必须是 Handle 对象列表
|
||||
result[key] = [h.to_registry_dict() for h in val]
|
||||
else:
|
||||
result[key] = val
|
||||
return result
|
||||
|
||||
|
||||
def get_resource_meta(obj) -> Optional[Dict[str, Any]]:
|
||||
"""获取对象上的 @resource 装饰器元数据"""
|
||||
return getattr(obj, "_resource_registry_meta", None)
|
||||
|
||||
|
||||
def get_all_registered_devices() -> Dict[str, type]:
|
||||
"""获取所有已注册的设备类"""
|
||||
return _registered_devices.copy()
|
||||
|
||||
|
||||
def get_all_registered_resources() -> Dict[str, Any]:
|
||||
"""获取所有已注册的资源"""
|
||||
return _registered_resources.copy()
|
||||
|
||||
|
||||
def clear_registry():
|
||||
"""清空全局注册表 (用于测试)"""
|
||||
_registered_devices.clear()
|
||||
_registered_resources.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# topic_config / not_action / always_free 装饰器
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def topic_config(
|
||||
period: Optional[float] = None,
|
||||
print_publish: Optional[bool] = None,
|
||||
qos: Optional[int] = None,
|
||||
name: Optional[str] = None,
|
||||
) -> Callable[[F], F]:
|
||||
"""
|
||||
Topic发布配置装饰器
|
||||
|
||||
用于装饰 get_{attr_name} 方法或 @property,控制对应属性的ROS topic发布行为。
|
||||
|
||||
Args:
|
||||
period: 发布周期(秒)。None 表示使用默认值 5.0
|
||||
print_publish: 是否打印发布日志。None 表示使用节点默认配置
|
||||
qos: QoS深度配置。None 表示使用默认值 10
|
||||
name: 自定义发布名称。None 表示使用方法名(去掉 get_ 前缀)
|
||||
|
||||
Note:
|
||||
与 @property 连用时,@topic_config 必须放在 @property 下面,
|
||||
这样装饰器执行顺序为:先 topic_config 添加配置,再 property 包装。
|
||||
"""
|
||||
|
||||
def decorator(func: F) -> F:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
wrapper._topic_period = period # type: ignore[attr-defined]
|
||||
wrapper._topic_print_publish = print_publish # type: ignore[attr-defined]
|
||||
wrapper._topic_qos = qos # type: ignore[attr-defined]
|
||||
wrapper._topic_name = name # type: ignore[attr-defined]
|
||||
wrapper._has_topic_config = True # type: ignore[attr-defined]
|
||||
|
||||
return wrapper # type: ignore[return-value]
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def get_topic_config(func) -> dict:
|
||||
"""获取函数上的 topic 配置 (period, print_publish, qos, name)"""
|
||||
if hasattr(func, "_has_topic_config") and getattr(func, "_has_topic_config", False):
|
||||
return {
|
||||
"period": getattr(func, "_topic_period", None),
|
||||
"print_publish": getattr(func, "_topic_print_publish", None),
|
||||
"qos": getattr(func, "_topic_qos", None),
|
||||
"name": getattr(func, "_topic_name", None),
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
def always_free(func: F) -> F:
|
||||
"""
|
||||
标记动作为永久闲置(不受busy队列限制)的装饰器
|
||||
|
||||
被此装饰器标记的 action 方法,在执行时不会受到设备级别的排队限制,
|
||||
任何时候请求都可以立即执行。适用于查询类、状态读取类等轻量级操作。
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
wrapper._is_always_free = True # type: ignore[attr-defined]
|
||||
|
||||
return wrapper # type: ignore[return-value]
|
||||
|
||||
|
||||
def is_always_free(func) -> bool:
|
||||
"""检查函数是否被标记为永久闲置"""
|
||||
return getattr(func, "_is_always_free", False)
|
||||
|
||||
|
||||
def not_action(func: F) -> F:
|
||||
"""
|
||||
标记方法为非动作的装饰器
|
||||
|
||||
用于装饰 driver 类中的方法,使其在注册表扫描时不被识别为动作。
|
||||
适用于辅助方法、内部工具方法等不应暴露为设备动作的公共方法。
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
wrapper._is_not_action = True # type: ignore[attr-defined]
|
||||
|
||||
return wrapper # type: ignore[return-value]
|
||||
|
||||
|
||||
def is_not_action(func) -> bool:
|
||||
"""检查函数是否被标记为非动作"""
|
||||
return getattr(func, "_is_not_action", False)
|
||||
@@ -96,13 +96,10 @@ serial:
|
||||
type: string
|
||||
port:
|
||||
type: string
|
||||
registry_name:
|
||||
type: string
|
||||
resource_tracker:
|
||||
type: object
|
||||
required:
|
||||
- device_id
|
||||
- registry_name
|
||||
- port
|
||||
type: object
|
||||
data:
|
||||
|
||||
@@ -67,9 +67,6 @@ camera:
|
||||
period:
|
||||
default: 0.1
|
||||
type: number
|
||||
registry_name:
|
||||
default: ''
|
||||
type: string
|
||||
resource_tracker:
|
||||
type: object
|
||||
required: []
|
||||
|
||||
@@ -638,7 +638,7 @@ liquid_handler:
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 吸头迭代函数。用于自动管理和切换枪头盒中的吸头,实现批量实验中的吸头自动分配和追踪。该函数监控吸头使用状态,自动切换到下一个可用吸头位置,确保实验流程的连续性。适用于高通量实验、批量处理、自动化流水线等需要大量吸头管理的应用场景。
|
||||
description: 吸头迭代函数。用于自动管理和切换吸头架中的吸头,实现批量实验中的吸头自动分配和追踪。该函数监控吸头使用状态,自动切换到下一个可用吸头位置,确保实验流程的连续性。适用于高通量实验、批量处理、自动化流水线等需要大量吸头管理的应用场景。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
@@ -712,43 +712,6 @@ liquid_handler:
|
||||
title: set_group参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-set_liquid_from_plate:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
liquid_names: null
|
||||
plate: null
|
||||
volumes: null
|
||||
well_names: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
liquid_names:
|
||||
type: string
|
||||
plate:
|
||||
type: string
|
||||
volumes:
|
||||
type: string
|
||||
well_names:
|
||||
type: string
|
||||
required:
|
||||
- plate
|
||||
- well_names
|
||||
- liquid_names
|
||||
- volumes
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: set_liquid_from_plate参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-set_tiprack:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
@@ -758,7 +721,7 @@ liquid_handler:
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: 枪头盒设置函数。用于配置和初始化液体处理系统的枪头盒信息,包括枪头盒位置、类型、容量等参数。该函数建立吸头资源管理系统,为后续的吸头选择和使用提供基础配置。适用于系统初始化、枪头盒更换、实验配置等需要吸头资源管理的操作场景。
|
||||
description: 吸头架设置函数。用于配置和初始化液体处理系统的吸头架信息,包括吸头架位置、类型、容量等参数。该函数建立吸头资源管理系统,为后续的吸头选择和使用提供基础配置。适用于系统初始化、吸头架更换、实验配置等需要吸头资源管理的操作场景。
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
@@ -4130,32 +4093,32 @@ liquid_handler:
|
||||
- 0
|
||||
handles:
|
||||
input:
|
||||
- data_key: sources
|
||||
- data_key: liquid
|
||||
data_source: handle
|
||||
data_type: resource
|
||||
handler_key: sources
|
||||
label: 待移动液体
|
||||
- data_key: targets
|
||||
data_source: handle
|
||||
data_type: resource
|
||||
handler_key: targets
|
||||
label: 转移目标
|
||||
- data_key: tip_racks
|
||||
data_source: handle
|
||||
data_type: resource
|
||||
handler_key: tip_rack
|
||||
label: 枪头盒
|
||||
output:
|
||||
- data_key: sources.@flatten
|
||||
label: sources
|
||||
- data_key: liquid
|
||||
data_source: executor
|
||||
data_type: resource
|
||||
handler_key: targets
|
||||
label: targets
|
||||
- data_key: liquid
|
||||
data_source: executor
|
||||
data_type: resource
|
||||
handler_key: tip_rack
|
||||
label: tip_rack
|
||||
output:
|
||||
- data_key: liquid
|
||||
data_source: handle
|
||||
data_type: resource
|
||||
handler_key: sources_out
|
||||
label: 移液后源孔
|
||||
- data_key: targets.@flatten
|
||||
label: sources
|
||||
- data_key: liquid
|
||||
data_source: executor
|
||||
data_type: resource
|
||||
handler_key: targets_out
|
||||
label: 移液后目标孔
|
||||
label: targets
|
||||
placeholder_keys:
|
||||
sources: unilabos_resources
|
||||
targets: unilabos_resources
|
||||
@@ -5151,34 +5114,19 @@ liquid_handler.biomek:
|
||||
- 0
|
||||
handles:
|
||||
input:
|
||||
- data_key: sources
|
||||
- data_key: liquid
|
||||
data_source: handle
|
||||
data_type: resource
|
||||
handler_key: sources
|
||||
handler_key: liquid-input
|
||||
io_type: target
|
||||
label: 待移动液体
|
||||
- data_key: targets
|
||||
data_source: handle
|
||||
data_type: resource
|
||||
handler_key: targets
|
||||
label: 转移目标
|
||||
- data_key: tip_racks
|
||||
data_source: handle
|
||||
data_type: resource
|
||||
handler_key: tip_rack
|
||||
label: 枪头盒
|
||||
label: Liquid Input
|
||||
output:
|
||||
- data_key: sources.@flatten
|
||||
- data_key: liquid
|
||||
data_source: executor
|
||||
data_type: resource
|
||||
handler_key: sources_out
|
||||
handler_key: liquid-output
|
||||
io_type: source
|
||||
label: 移液后源孔
|
||||
- data_key: targets.@flatten
|
||||
data_source: executor
|
||||
data_type: resource
|
||||
handler_key: targets_out
|
||||
label: 移液后目标孔
|
||||
label: Liquid Output
|
||||
placeholder_keys:
|
||||
sources: unilabos_resources
|
||||
targets: unilabos_resources
|
||||
@@ -9336,13 +9284,7 @@ liquid_handler.prcxi:
|
||||
data_source: handle
|
||||
data_type: resource
|
||||
handler_key: input_wells
|
||||
label: 待设定液体孔
|
||||
output:
|
||||
- data_key: wells.@flatten
|
||||
data_source: executor
|
||||
data_type: resource
|
||||
handler_key: output_wells
|
||||
label: 已设定液体孔
|
||||
label: InputWells
|
||||
placeholder_keys:
|
||||
wells: unilabos_resources
|
||||
result: {}
|
||||
@@ -9458,352 +9400,6 @@ liquid_handler.prcxi:
|
||||
title: LiquidHandlerSetLiquid
|
||||
type: object
|
||||
type: LiquidHandlerSetLiquid
|
||||
set_liquid_from_plate:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
liquid_names: null
|
||||
plate: null
|
||||
volumes: null
|
||||
well_names: null
|
||||
handles:
|
||||
input:
|
||||
- data_key: '@this.0@@@plate'
|
||||
data_source: handle
|
||||
data_type: resource
|
||||
handler_key: input_plate
|
||||
label: 待设定液体板
|
||||
output:
|
||||
- data_key: plate.@flatten
|
||||
data_source: executor
|
||||
data_type: resource
|
||||
handler_key: output_plate
|
||||
label: 已设定液体板
|
||||
- data_key: wells.@flatten
|
||||
data_source: executor
|
||||
data_type: resource
|
||||
handler_key: output_wells
|
||||
label: 已设定液体孔
|
||||
- data_key: volumes
|
||||
data_source: executor
|
||||
data_type: number_array
|
||||
handler_key: output_volumes
|
||||
label: 各孔设定体积
|
||||
placeholder_keys:
|
||||
plate: unilabos_resources
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
liquid_names:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
plate:
|
||||
properties:
|
||||
category:
|
||||
type: string
|
||||
children:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
config:
|
||||
type: string
|
||||
data:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
parent:
|
||||
type: string
|
||||
pose:
|
||||
properties:
|
||||
orientation:
|
||||
properties:
|
||||
w:
|
||||
type: number
|
||||
x:
|
||||
type: number
|
||||
y:
|
||||
type: number
|
||||
z:
|
||||
type: number
|
||||
required:
|
||||
- x
|
||||
- y
|
||||
- z
|
||||
- w
|
||||
title: orientation
|
||||
type: object
|
||||
position:
|
||||
properties:
|
||||
x:
|
||||
type: number
|
||||
y:
|
||||
type: number
|
||||
z:
|
||||
type: number
|
||||
required:
|
||||
- x
|
||||
- y
|
||||
- z
|
||||
title: position
|
||||
type: object
|
||||
required:
|
||||
- position
|
||||
- orientation
|
||||
title: pose
|
||||
type: object
|
||||
sample_id:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- sample_id
|
||||
- children
|
||||
- parent
|
||||
- type
|
||||
- category
|
||||
- pose
|
||||
- config
|
||||
- data
|
||||
title: plate
|
||||
type: object
|
||||
volumes:
|
||||
items:
|
||||
type: number
|
||||
type: array
|
||||
well_names:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- plate
|
||||
- well_names
|
||||
- liquid_names
|
||||
- volumes
|
||||
type: object
|
||||
result:
|
||||
$defs:
|
||||
ResourceDict:
|
||||
properties:
|
||||
class:
|
||||
description: Resource class name
|
||||
title: Class
|
||||
type: string
|
||||
config:
|
||||
additionalProperties: true
|
||||
description: Resource configuration
|
||||
title: Config
|
||||
type: object
|
||||
data:
|
||||
additionalProperties: true
|
||||
description: 'Resource data, eg: container liquid data'
|
||||
title: Data
|
||||
type: object
|
||||
description:
|
||||
default: ''
|
||||
description: Resource description
|
||||
title: Description
|
||||
type: string
|
||||
extra:
|
||||
additionalProperties: true
|
||||
description: 'Extra data, eg: slot index'
|
||||
title: Extra
|
||||
type: object
|
||||
icon:
|
||||
default: ''
|
||||
description: Resource icon
|
||||
title: Icon
|
||||
type: string
|
||||
id:
|
||||
description: Resource ID
|
||||
title: Id
|
||||
type: string
|
||||
model:
|
||||
additionalProperties: true
|
||||
description: Resource model
|
||||
title: Model
|
||||
type: object
|
||||
name:
|
||||
description: Resource name
|
||||
title: Name
|
||||
type: string
|
||||
parent:
|
||||
anyOf:
|
||||
- $ref: '#/$defs/ResourceDict'
|
||||
- type: 'null'
|
||||
default: null
|
||||
description: Parent resource object
|
||||
parent_uuid:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
default: null
|
||||
description: Parent resource uuid
|
||||
title: Parent Uuid
|
||||
pose:
|
||||
$ref: '#/$defs/ResourceDictPosition'
|
||||
description: Resource position
|
||||
schema:
|
||||
additionalProperties: true
|
||||
description: Resource schema
|
||||
title: Schema
|
||||
type: object
|
||||
type:
|
||||
anyOf:
|
||||
- const: device
|
||||
type: string
|
||||
- type: string
|
||||
description: Resource type
|
||||
title: Type
|
||||
uuid:
|
||||
description: Resource UUID
|
||||
title: Uuid
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- uuid
|
||||
- name
|
||||
- type
|
||||
- class
|
||||
- config
|
||||
- data
|
||||
- extra
|
||||
title: ResourceDict
|
||||
type: object
|
||||
ResourceDictPosition:
|
||||
properties:
|
||||
cross_section_type:
|
||||
default: rectangle
|
||||
description: Cross section type
|
||||
enum:
|
||||
- rectangle
|
||||
- circle
|
||||
- rounded_rectangle
|
||||
title: Cross Section Type
|
||||
type: string
|
||||
layout:
|
||||
default: x-y
|
||||
description: Resource layout
|
||||
enum:
|
||||
- 2d
|
||||
- x-y
|
||||
- z-y
|
||||
- x-z
|
||||
title: Layout
|
||||
type: string
|
||||
position:
|
||||
$ref: '#/$defs/ResourceDictPositionObject'
|
||||
description: Resource position
|
||||
position3d:
|
||||
$ref: '#/$defs/ResourceDictPositionObject'
|
||||
description: Resource position in 3D space
|
||||
rotation:
|
||||
$ref: '#/$defs/ResourceDictPositionObject'
|
||||
description: Resource rotation
|
||||
scale:
|
||||
$ref: '#/$defs/ResourceDictPositionScale'
|
||||
description: Resource scale
|
||||
size:
|
||||
$ref: '#/$defs/ResourceDictPositionSize'
|
||||
description: Resource size
|
||||
title: ResourceDictPosition
|
||||
type: object
|
||||
ResourceDictPositionObject:
|
||||
properties:
|
||||
x:
|
||||
default: 0.0
|
||||
description: X coordinate
|
||||
title: X
|
||||
type: number
|
||||
y:
|
||||
default: 0.0
|
||||
description: Y coordinate
|
||||
title: Y
|
||||
type: number
|
||||
z:
|
||||
default: 0.0
|
||||
description: Z coordinate
|
||||
title: Z
|
||||
type: number
|
||||
title: ResourceDictPositionObject
|
||||
type: object
|
||||
ResourceDictPositionScale:
|
||||
properties:
|
||||
x:
|
||||
default: 0.0
|
||||
description: x scale
|
||||
title: X
|
||||
type: number
|
||||
y:
|
||||
default: 0.0
|
||||
description: y scale
|
||||
title: Y
|
||||
type: number
|
||||
z:
|
||||
default: 0.0
|
||||
description: z scale
|
||||
title: Z
|
||||
type: number
|
||||
title: ResourceDictPositionScale
|
||||
type: object
|
||||
ResourceDictPositionSize:
|
||||
properties:
|
||||
depth:
|
||||
default: 0.0
|
||||
description: Depth
|
||||
title: Depth
|
||||
type: number
|
||||
height:
|
||||
default: 0.0
|
||||
description: Height
|
||||
title: Height
|
||||
type: number
|
||||
width:
|
||||
default: 0.0
|
||||
description: Width
|
||||
title: Width
|
||||
type: number
|
||||
title: ResourceDictPositionSize
|
||||
type: object
|
||||
properties:
|
||||
plate:
|
||||
items:
|
||||
items:
|
||||
$ref: '#/$defs/ResourceDict'
|
||||
type: array
|
||||
title: Plate
|
||||
type: array
|
||||
volumes:
|
||||
items:
|
||||
type: number
|
||||
title: Volumes
|
||||
type: array
|
||||
wells:
|
||||
items:
|
||||
items:
|
||||
$ref: '#/$defs/ResourceDict'
|
||||
type: array
|
||||
title: Wells
|
||||
type: array
|
||||
required:
|
||||
- plate
|
||||
- wells
|
||||
- volumes
|
||||
title: SetLiquidFromPlateReturn
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: set_liquid_from_plate参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
set_tiprack:
|
||||
feedback: {}
|
||||
goal:
|
||||
@@ -10147,34 +9743,7 @@ liquid_handler.prcxi:
|
||||
touch_tip: false
|
||||
use_channels:
|
||||
- 0
|
||||
handles:
|
||||
input:
|
||||
- data_key: sources
|
||||
data_source: handle
|
||||
data_type: resource
|
||||
handler_key: sources_identifier
|
||||
label: 待移动液体
|
||||
- data_key: targets
|
||||
data_source: handle
|
||||
data_type: resource
|
||||
handler_key: targets_identifier
|
||||
label: 转移目标
|
||||
- data_key: tip_rack
|
||||
data_source: handle
|
||||
data_type: resource
|
||||
handler_key: tip_rack_identifier
|
||||
label: 枪头盒
|
||||
output:
|
||||
- data_key: sources.@flatten
|
||||
data_source: executor
|
||||
data_type: resource
|
||||
handler_key: sources_out
|
||||
label: 移液后源孔
|
||||
- data_key: targets.@flatten
|
||||
data_source: executor
|
||||
data_type: resource
|
||||
handler_key: targets_out
|
||||
label: 移液后目标孔
|
||||
handles: {}
|
||||
placeholder_keys:
|
||||
sources: unilabos_resources
|
||||
targets: unilabos_resources
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,699 +0,0 @@
|
||||
"""
|
||||
注册表工具函数
|
||||
|
||||
从 registry.py 中提取的纯工具函数,包括:
|
||||
- docstring 解析
|
||||
- 类型字符串 → JSON Schema 转换
|
||||
- AST 类型节点解析
|
||||
- TypedDict / Slot / Handle 等辅助检测
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
import re
|
||||
import typing
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from msgcenterpy.instances.typed_dict_instance import TypedDictMessageInstance
|
||||
|
||||
from unilabos.utils.cls_creator import import_class
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 异常
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ROSMsgNotFound(Exception):
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Docstring 解析 (Google-style)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SECTION_RE = re.compile(r"^(\w[\w\s]*):\s*$")
|
||||
|
||||
|
||||
def parse_docstring(docstring: Optional[str]) -> Dict[str, Any]:
|
||||
"""
|
||||
解析 Google-style docstring,提取描述和参数说明。
|
||||
|
||||
Returns:
|
||||
{"description": "短描述", "params": {"param1": "参数1描述", ...}}
|
||||
"""
|
||||
result: Dict[str, Any] = {"description": "", "params": {}}
|
||||
if not docstring:
|
||||
return result
|
||||
|
||||
lines = docstring.strip().splitlines()
|
||||
if not lines:
|
||||
return result
|
||||
|
||||
result["description"] = lines[0].strip()
|
||||
|
||||
in_args = False
|
||||
current_param: Optional[str] = None
|
||||
current_desc_parts: list = []
|
||||
|
||||
for line in lines[1:]:
|
||||
stripped = line.strip()
|
||||
section_match = _SECTION_RE.match(stripped)
|
||||
if section_match:
|
||||
if current_param is not None:
|
||||
result["params"][current_param] = "\n".join(current_desc_parts).strip()
|
||||
current_param = None
|
||||
current_desc_parts = []
|
||||
section_name = section_match.group(1).lower()
|
||||
in_args = section_name in ("args", "arguments", "parameters", "params")
|
||||
continue
|
||||
|
||||
if not in_args:
|
||||
continue
|
||||
|
||||
if ":" in stripped and not stripped.startswith(" "):
|
||||
if current_param is not None:
|
||||
result["params"][current_param] = "\n".join(current_desc_parts).strip()
|
||||
param_part, _, desc_part = stripped.partition(":")
|
||||
param_name = param_part.strip().split("(")[0].strip()
|
||||
current_param = param_name
|
||||
current_desc_parts = [desc_part.strip()]
|
||||
elif current_param is not None:
|
||||
aline = line
|
||||
if aline.startswith(" "):
|
||||
aline = aline[4:]
|
||||
elif aline.startswith("\t"):
|
||||
aline = aline[1:]
|
||||
current_desc_parts.append(aline.strip())
|
||||
|
||||
if current_param is not None:
|
||||
result["params"][current_param] = "\n".join(current_desc_parts).strip()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 类型常量
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SIMPLE_TYPE_MAP = {
|
||||
"str": "string",
|
||||
"string": "string",
|
||||
"int": "integer",
|
||||
"integer": "integer",
|
||||
"float": "number",
|
||||
"number": "number",
|
||||
"bool": "boolean",
|
||||
"boolean": "boolean",
|
||||
"list": "array",
|
||||
"array": "array",
|
||||
"dict": "object",
|
||||
"object": "object",
|
||||
}
|
||||
|
||||
ARRAY_TYPES = {"list", "List", "tuple", "Tuple", "set", "Set", "Sequence", "Iterable"}
|
||||
OBJECT_TYPES = {"dict", "Dict", "Mapping"}
|
||||
WRAPPER_TYPES = {"Optional"}
|
||||
SLOT_TYPES = {"ResourceSlot", "DeviceSlot"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 简单类型映射
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_json_schema_type(type_str: str) -> str:
|
||||
"""简单类型名 -> JSON Schema type"""
|
||||
return SIMPLE_TYPE_MAP.get(type_str.lower(), "string")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AST 类型解析
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def parse_type_node(type_str: str):
|
||||
"""将类型注解字符串解析为 AST 节点,失败返回 None。"""
|
||||
import ast as _ast
|
||||
|
||||
try:
|
||||
return _ast.parse(type_str.strip(), mode="eval").body
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _collect_bitor(node, out: list):
|
||||
"""递归收集 X | Y | Z 的所有分支。"""
|
||||
import ast as _ast
|
||||
|
||||
if isinstance(node, _ast.BinOp) and isinstance(node.op, _ast.BitOr):
|
||||
_collect_bitor(node.left, out)
|
||||
_collect_bitor(node.right, out)
|
||||
else:
|
||||
out.append(node)
|
||||
|
||||
|
||||
def type_node_to_schema(
|
||||
node,
|
||||
import_map: Optional[Dict[str, str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""将 AST 类型注解节点递归转换为 JSON Schema dict。
|
||||
|
||||
当提供 import_map 时,对于未知类名会尝试通过 import_map 解析模块路径,
|
||||
然后 import 真实类型对象来生成 schema (支持 TypedDict 等)。
|
||||
|
||||
映射规则:
|
||||
- Optional[X] → X 的 schema (剥掉 Optional)
|
||||
- Union[X, Y] → {"anyOf": [X_schema, Y_schema]}
|
||||
- List[X] / Tuple[X] / Set[X] → {"type": "array", "items": X_schema}
|
||||
- Dict[K, V] → {"type": "object", "additionalProperties": V_schema}
|
||||
- Literal["a", "b"] → {"type": "string", "enum": ["a", "b"]}
|
||||
- TypedDict (via import_map) → {"type": "object", "properties": {...}}
|
||||
- 基本类型 str/int/... → {"type": "string"/"integer"/...}
|
||||
"""
|
||||
import ast as _ast
|
||||
|
||||
# --- Name 节点: str / int / dict / ResourceSlot / 自定义类 ---
|
||||
if isinstance(node, _ast.Name):
|
||||
name = node.id
|
||||
if name in SLOT_TYPES:
|
||||
return {"$slot": name}
|
||||
json_type = SIMPLE_TYPE_MAP.get(name.lower())
|
||||
if json_type:
|
||||
return {"type": json_type}
|
||||
# 尝试通过 import_map 解析并 import 真实类型
|
||||
if import_map and name in import_map:
|
||||
type_obj = resolve_type_object(import_map[name])
|
||||
if type_obj is not None:
|
||||
return type_to_schema(type_obj)
|
||||
# 未知类名 → 无法转 schema 的自定义类型默认当 object
|
||||
return {"type": "object"}
|
||||
|
||||
if isinstance(node, _ast.Constant):
|
||||
if isinstance(node.value, str):
|
||||
return {"type": SIMPLE_TYPE_MAP.get(node.value.lower(), "string")}
|
||||
return {"type": "string"}
|
||||
|
||||
# --- Subscript 节点: List[X], Dict[K,V], Optional[X], Literal[...] 等 ---
|
||||
if isinstance(node, _ast.Subscript):
|
||||
base_name = node.value.id if isinstance(node.value, _ast.Name) else ""
|
||||
|
||||
# Optional[X] → 剥掉
|
||||
if base_name in WRAPPER_TYPES:
|
||||
return type_node_to_schema(node.slice, import_map)
|
||||
|
||||
# Union[X, None] → 剥掉 None; Union[X, Y] → anyOf
|
||||
if base_name == "Union":
|
||||
elts = node.slice.elts if isinstance(node.slice, _ast.Tuple) else [node.slice]
|
||||
non_none = [
|
||||
e
|
||||
for e in elts
|
||||
if not (isinstance(e, _ast.Constant) and e.value is None)
|
||||
and not (isinstance(e, _ast.Name) and e.id == "None")
|
||||
]
|
||||
if len(non_none) == 1:
|
||||
return type_node_to_schema(non_none[0], import_map)
|
||||
if len(non_none) > 1:
|
||||
return {"anyOf": [type_node_to_schema(e, import_map) for e in non_none]}
|
||||
return {"type": "string"}
|
||||
|
||||
# Literal["a", "b", 1] → enum
|
||||
if base_name == "Literal":
|
||||
elts = node.slice.elts if isinstance(node.slice, _ast.Tuple) else [node.slice]
|
||||
values = []
|
||||
for e in elts:
|
||||
if isinstance(e, _ast.Constant):
|
||||
values.append(e.value)
|
||||
elif isinstance(e, _ast.Name):
|
||||
values.append(e.id)
|
||||
if values:
|
||||
return {"type": "string", "enum": values}
|
||||
return {"type": "string"}
|
||||
|
||||
# List / Tuple / Set → array
|
||||
if base_name in ARRAY_TYPES:
|
||||
if isinstance(node.slice, _ast.Tuple) and node.slice.elts:
|
||||
inner_node = node.slice.elts[0]
|
||||
else:
|
||||
inner_node = node.slice
|
||||
return {"type": "array", "items": type_node_to_schema(inner_node, import_map)}
|
||||
|
||||
# Dict → object
|
||||
if base_name in OBJECT_TYPES:
|
||||
schema: Dict[str, Any] = {"type": "object"}
|
||||
if isinstance(node.slice, _ast.Tuple) and len(node.slice.elts) >= 2:
|
||||
val_node = node.slice.elts[1]
|
||||
# Dict[str, Any] → 不加 additionalProperties (Any 等同于无约束)
|
||||
is_any = (isinstance(val_node, _ast.Name) and val_node.id == "Any") or (
|
||||
isinstance(val_node, _ast.Constant) and val_node.value is None
|
||||
)
|
||||
if not is_any:
|
||||
val_schema = type_node_to_schema(val_node, import_map)
|
||||
schema["additionalProperties"] = val_schema
|
||||
return schema
|
||||
|
||||
# --- BinOp: X | Y (Python 3.10+) → 当 Union 处理 ---
|
||||
if isinstance(node, _ast.BinOp) and isinstance(node.op, _ast.BitOr):
|
||||
parts: list = []
|
||||
_collect_bitor(node, parts)
|
||||
non_none = [
|
||||
p
|
||||
for p in parts
|
||||
if not (isinstance(p, _ast.Constant) and p.value is None)
|
||||
and not (isinstance(p, _ast.Name) and p.id == "None")
|
||||
]
|
||||
if len(non_none) == 1:
|
||||
return type_node_to_schema(non_none[0], import_map)
|
||||
if len(non_none) > 1:
|
||||
return {"anyOf": [type_node_to_schema(p, import_map) for p in non_none]}
|
||||
return {"type": "string"}
|
||||
|
||||
return {"type": "string"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 真实类型对象解析 (import-based)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def resolve_type_object(type_ref: str) -> Optional[Any]:
|
||||
"""通过 'module.path:ClassName' 格式的引用 import 并返回真实类型对象。
|
||||
|
||||
对于 typing 内置名 (str, int, List 等) 直接返回 None (由 AST 路径处理)。
|
||||
import 失败时静默返回 None。
|
||||
"""
|
||||
if ":" not in type_ref:
|
||||
return None
|
||||
try:
|
||||
return import_class(type_ref)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def is_typed_dict_class(obj: Any) -> bool:
|
||||
"""检查对象是否是 TypedDict 类。"""
|
||||
if obj is None:
|
||||
return False
|
||||
try:
|
||||
from typing_extensions import is_typeddict
|
||||
|
||||
return is_typeddict(obj)
|
||||
except ImportError:
|
||||
if isinstance(obj, type):
|
||||
return hasattr(obj, "__required_keys__") and hasattr(obj, "__optional_keys__")
|
||||
return False
|
||||
|
||||
|
||||
def type_to_schema(tp: Any) -> Dict[str, Any]:
|
||||
"""将真实 typing 对象递归转换为 JSON Schema dict。
|
||||
|
||||
支持:
|
||||
- 基本类型: str, int, float, bool → {"type": "string"/"integer"/...}
|
||||
- typing 泛型: List[X], Dict[K,V], Optional[X], Union[X,Y], Literal[...]
|
||||
- TypedDict → {"type": "object", "properties": {...}, "required": [...]}
|
||||
- 自定义类 (ResourceSlot 等) → {"$slot": "..."} 或 {"type": "string"}
|
||||
"""
|
||||
origin = getattr(tp, "__origin__", None)
|
||||
args = getattr(tp, "__args__", None)
|
||||
|
||||
# --- None / NoneType ---
|
||||
if tp is type(None):
|
||||
return {"type": "null"}
|
||||
|
||||
# --- 基本类型 ---
|
||||
if tp is str:
|
||||
return {"type": "string"}
|
||||
if tp is int:
|
||||
return {"type": "integer"}
|
||||
if tp is float:
|
||||
return {"type": "number"}
|
||||
if tp is bool:
|
||||
return {"type": "boolean"}
|
||||
|
||||
# --- TypedDict ---
|
||||
if is_typed_dict_class(tp):
|
||||
try:
|
||||
return TypedDictMessageInstance.get_json_schema_from_typed_dict(tp)
|
||||
except Exception:
|
||||
return {"type": "object"}
|
||||
|
||||
# --- Literal ---
|
||||
if origin is typing.Literal:
|
||||
values = list(args) if args else []
|
||||
return {"type": "string", "enum": values}
|
||||
|
||||
# --- Optional / Union ---
|
||||
if origin is typing.Union:
|
||||
non_none = [a for a in (args or ()) if a is not type(None)]
|
||||
if len(non_none) == 1:
|
||||
return type_to_schema(non_none[0])
|
||||
if len(non_none) > 1:
|
||||
return {"anyOf": [type_to_schema(a) for a in non_none]}
|
||||
return {"type": "string"}
|
||||
|
||||
# --- List / Sequence / Set / Tuple / Iterable ---
|
||||
if origin in (list, tuple, set, frozenset) or (
|
||||
origin is not None
|
||||
and getattr(origin, "__name__", "") in ("Sequence", "Iterable", "Iterator", "MutableSequence")
|
||||
):
|
||||
if args:
|
||||
return {"type": "array", "items": type_to_schema(args[0])}
|
||||
return {"type": "array"}
|
||||
|
||||
# --- Dict / Mapping ---
|
||||
if origin in (dict,) or (origin is not None and getattr(origin, "__name__", "") in ("Mapping", "MutableMapping")):
|
||||
schema: Dict[str, Any] = {"type": "object"}
|
||||
if args and len(args) >= 2:
|
||||
schema["additionalProperties"] = type_to_schema(args[1])
|
||||
return schema
|
||||
|
||||
# --- Slot 类型 ---
|
||||
if isinstance(tp, type):
|
||||
name = tp.__name__
|
||||
if name in SLOT_TYPES:
|
||||
return {"$slot": name}
|
||||
|
||||
# --- 其他未知类型 fallback ---
|
||||
if isinstance(tp, type):
|
||||
return {"type": "object"}
|
||||
return {"type": "string"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Slot / Placeholder 检测
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def detect_slot_type(ptype) -> Tuple[Optional[str], bool]:
|
||||
"""检测参数类型是否为 ResourceSlot / DeviceSlot。
|
||||
|
||||
兼容多种格式:
|
||||
- runtime: "unilabos.registry.placeholder_type:ResourceSlot"
|
||||
- runtime tuple: ("list", "unilabos.registry.placeholder_type:ResourceSlot")
|
||||
- AST 裸名: "ResourceSlot", "List[ResourceSlot]", "Optional[ResourceSlot]"
|
||||
|
||||
Returns: (slot_name | None, is_list)
|
||||
"""
|
||||
ptype_str = str(ptype)
|
||||
|
||||
# 快速路径: 字符串里根本没有 Slot
|
||||
if "ResourceSlot" not in ptype_str and "DeviceSlot" not in ptype_str:
|
||||
return (None, False)
|
||||
|
||||
# runtime 格式: 完整模块路径
|
||||
if isinstance(ptype, str):
|
||||
if ptype.endswith(":ResourceSlot") or ptype == "ResourceSlot":
|
||||
return ("ResourceSlot", False)
|
||||
if ptype.endswith(":DeviceSlot") or ptype == "DeviceSlot":
|
||||
return ("DeviceSlot", False)
|
||||
# AST 复杂格式: List[ResourceSlot], Optional[ResourceSlot] 等
|
||||
if "[" in ptype:
|
||||
node = parse_type_node(ptype)
|
||||
if node is not None:
|
||||
schema = type_node_to_schema(node)
|
||||
# 直接是 slot
|
||||
if "$slot" in schema:
|
||||
return (schema["$slot"], False)
|
||||
# array 包裹 slot: {"type": "array", "items": {"$slot": "..."}}
|
||||
items = schema.get("items", {})
|
||||
if isinstance(items, dict) and "$slot" in items:
|
||||
return (items["$slot"], True)
|
||||
return (None, False)
|
||||
|
||||
# runtime tuple 格式
|
||||
if isinstance(ptype, tuple) and len(ptype) == 2:
|
||||
inner_str = str(ptype[1])
|
||||
if "ResourceSlot" in inner_str:
|
||||
return ("ResourceSlot", True)
|
||||
if "DeviceSlot" in inner_str:
|
||||
return ("DeviceSlot", True)
|
||||
|
||||
return (None, False)
|
||||
|
||||
|
||||
def detect_placeholder_keys(params: list) -> Dict[str, str]:
|
||||
"""Detect parameters that reference ResourceSlot or DeviceSlot."""
|
||||
result: Dict[str, str] = {}
|
||||
for p in params:
|
||||
ptype = p.get("type", "")
|
||||
if "ResourceSlot" in str(ptype):
|
||||
result[p["name"]] = "unilabos_resources"
|
||||
elif "DeviceSlot" in str(ptype):
|
||||
result[p["name"]] = "unilabos_devices"
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handle 规范化
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def normalize_ast_handles(handles_raw: Any) -> List[Dict[str, Any]]:
|
||||
"""Convert AST-parsed handle structures to the standard registry format."""
|
||||
if not handles_raw:
|
||||
return []
|
||||
|
||||
# handle_type → io_type 映射 (AST 内部类名 → YAML 标准字段值)
|
||||
_HANDLE_TYPE_TO_IO_TYPE = {
|
||||
"input": "target",
|
||||
"output": "source",
|
||||
"action_input": "action_target",
|
||||
"action_output": "action_source",
|
||||
}
|
||||
|
||||
result: List[Dict[str, Any]] = []
|
||||
for h in handles_raw:
|
||||
if isinstance(h, dict):
|
||||
call = h.get("_call", "")
|
||||
if "InputHandle" in call:
|
||||
handle_type = "input"
|
||||
elif "OutputHandle" in call:
|
||||
handle_type = "output"
|
||||
elif "ActionInputHandle" in call:
|
||||
handle_type = "action_input"
|
||||
elif "ActionOutputHandle" in call:
|
||||
handle_type = "action_output"
|
||||
else:
|
||||
handle_type = h.get("handle_type", "unknown")
|
||||
|
||||
io_type = _HANDLE_TYPE_TO_IO_TYPE.get(handle_type, handle_type)
|
||||
|
||||
entry: Dict[str, Any] = {
|
||||
"handler_key": h.get("key", ""),
|
||||
"data_type": h.get("data_type", ""),
|
||||
"io_type": io_type,
|
||||
}
|
||||
side = h.get("side")
|
||||
if side:
|
||||
if isinstance(side, str) and "." in side:
|
||||
val = side.rsplit(".", 1)[-1]
|
||||
side = val.lower() if val in ("LEFT", "RIGHT", "TOP", "BOTTOM") else val
|
||||
entry["side"] = side
|
||||
label = h.get("label")
|
||||
if label:
|
||||
entry["label"] = label
|
||||
data_key = h.get("data_key")
|
||||
if data_key:
|
||||
entry["data_key"] = data_key
|
||||
data_source = h.get("data_source")
|
||||
if data_source:
|
||||
if isinstance(data_source, str) and "." in data_source:
|
||||
val = data_source.rsplit(".", 1)[-1]
|
||||
data_source = val.lower() if val in ("HANDLE", "EXECUTOR") else val
|
||||
entry["data_source"] = data_source
|
||||
description = h.get("description")
|
||||
if description:
|
||||
entry["description"] = description
|
||||
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
|
||||
def normalize_ast_action_handles(handles_raw: Any) -> Dict[str, Any]:
|
||||
"""Convert AST-parsed action handle list to {"input": [...], "output": [...]}.
|
||||
|
||||
Mirrors the runtime behavior of decorators._action_handles_to_dict:
|
||||
- ActionInputHandle => grouped under "input"
|
||||
- ActionOutputHandle => grouped under "output"
|
||||
Field mapping: key -> handler_key (matches Pydantic serialization_alias).
|
||||
"""
|
||||
if not handles_raw or not isinstance(handles_raw, list):
|
||||
return {}
|
||||
|
||||
input_list: List[Dict[str, Any]] = []
|
||||
output_list: List[Dict[str, Any]] = []
|
||||
|
||||
for h in handles_raw:
|
||||
if not isinstance(h, dict):
|
||||
continue
|
||||
call = h.get("_call", "")
|
||||
is_input = "ActionInputHandle" in call or "InputHandle" in call
|
||||
is_output = "ActionOutputHandle" in call or "OutputHandle" in call
|
||||
|
||||
entry: Dict[str, Any] = {
|
||||
"handler_key": h.get("key", ""),
|
||||
"data_type": h.get("data_type", ""),
|
||||
"label": h.get("label", ""),
|
||||
}
|
||||
for opt_key in ("side", "data_key", "data_source", "description", "io_type"):
|
||||
val = h.get(opt_key)
|
||||
if val is not None:
|
||||
# Only resolve enum-style refs (e.g. DataSource.HANDLE -> handle) for data_source/side
|
||||
# data_key values like "wells.@flatten", "@this.0@@@plate" must be preserved as-is
|
||||
if (
|
||||
isinstance(val, str)
|
||||
and "." in val
|
||||
and opt_key not in ("io_type", "data_key")
|
||||
):
|
||||
val = val.rsplit(".", 1)[-1].lower()
|
||||
entry[opt_key] = val
|
||||
|
||||
# io_type: only add when explicitly set; do not default output to "sink" (YAML convention omits it)
|
||||
if "io_type" not in entry and is_input:
|
||||
entry["io_type"] = "source"
|
||||
|
||||
if is_input:
|
||||
input_list.append(entry)
|
||||
elif is_output:
|
||||
output_list.append(entry)
|
||||
|
||||
result: Dict[str, Any] = {}
|
||||
if input_list:
|
||||
result["input"] = input_list
|
||||
# Always include output (empty list when no outputs) to match YAML
|
||||
result["output"] = output_list
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema 辅助
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def wrap_action_schema(
|
||||
goal_schema: Dict[str, Any],
|
||||
action_name: str,
|
||||
description: str = "",
|
||||
result_schema: Optional[Dict[str, Any]] = None,
|
||||
feedback_schema: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
将 goal 参数 schema 包装为标准的 action schema 格式:
|
||||
{ "properties": { "goal": ..., "feedback": ..., "result": ... }, ... }
|
||||
"""
|
||||
# 去掉 auto- 前缀用于 title/description,与 YAML 路径保持一致
|
||||
display_name = action_name.removeprefix("auto-")
|
||||
return {
|
||||
"title": f"{display_name}参数",
|
||||
"description": description or f"{display_name}的参数schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"goal": goal_schema,
|
||||
"feedback": feedback_schema or {},
|
||||
"result": result_schema or {},
|
||||
},
|
||||
"required": ["goal"],
|
||||
}
|
||||
|
||||
|
||||
def preserve_field_descriptions(new_schema: Dict[str, Any], prev_schema: Dict[str, Any]):
|
||||
"""保留之前 schema 中的 field descriptions"""
|
||||
if not prev_schema or not new_schema:
|
||||
return
|
||||
prev_props = prev_schema.get("properties", {})
|
||||
new_props = new_schema.get("properties", {})
|
||||
for field_name, prev_field in prev_props.items():
|
||||
if field_name in new_props and "title" in prev_field:
|
||||
new_props[field_name].setdefault("title", prev_field["title"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 深度对比
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _short(val, limit=120):
|
||||
"""截断过长的值用于日志显示。"""
|
||||
s = repr(val)
|
||||
return s if len(s) <= limit else s[:limit] + "..."
|
||||
|
||||
|
||||
def deep_diff(old, new, path="", max_depth=10) -> list:
|
||||
"""递归对比两个对象,返回所有差异的描述列表。"""
|
||||
diffs = []
|
||||
if max_depth <= 0:
|
||||
if old != new:
|
||||
diffs.append(f"{path}: (达到最大深度) OLD≠NEW")
|
||||
return diffs
|
||||
|
||||
if type(old) != type(new):
|
||||
diffs.append(f"{path}: 类型不同 OLD={type(old).__name__}({_short(old)}) NEW={type(new).__name__}({_short(new)})")
|
||||
return diffs
|
||||
|
||||
if isinstance(old, dict):
|
||||
old_keys = set(old.keys())
|
||||
new_keys = set(new.keys())
|
||||
for k in sorted(new_keys - old_keys):
|
||||
diffs.append(f"{path}.{k}: 新增字段 (AST有, YAML无) = {_short(new[k])}")
|
||||
for k in sorted(old_keys - new_keys):
|
||||
diffs.append(f"{path}.{k}: 缺失字段 (YAML有, AST无) = {_short(old[k])}")
|
||||
for k in sorted(old_keys & new_keys):
|
||||
diffs.extend(deep_diff(old[k], new[k], f"{path}.{k}", max_depth - 1))
|
||||
elif isinstance(old, (list, tuple)):
|
||||
if len(old) != len(new):
|
||||
diffs.append(f"{path}: 列表长度不同 OLD={len(old)} NEW={len(new)}")
|
||||
for i in range(min(len(old), len(new))):
|
||||
diffs.extend(deep_diff(old[i], new[i], f"{path}[{i}]", max_depth - 1))
|
||||
if len(new) > len(old):
|
||||
for i in range(len(old), len(new)):
|
||||
diffs.append(f"{path}[{i}]: 新增元素 = {_short(new[i])}")
|
||||
elif len(old) > len(new):
|
||||
for i in range(len(new), len(old)):
|
||||
diffs.append(f"{path}[{i}]: 缺失元素 = {_short(old[i])}")
|
||||
else:
|
||||
if old != new:
|
||||
diffs.append(f"{path}: OLD={_short(old)} NEW={_short(new)}")
|
||||
return diffs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MRO 方法参数解析
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def resolve_method_params_via_import(module_str: str, method_name: str) -> Dict[str, str]:
|
||||
"""当 AST 方法参数为空 (如 *args, **kwargs) 时, import class 并通过 MRO 获取真实方法参数.
|
||||
|
||||
返回 identity mapping {param_name: param_name}.
|
||||
"""
|
||||
if not module_str or ":" not in module_str:
|
||||
return {}
|
||||
try:
|
||||
cls = import_class(module_str)
|
||||
except Exception as e:
|
||||
_logger.debug(f"[AST] resolve_method_params_via_import: import_class('{module_str}') failed: {e}")
|
||||
return {}
|
||||
|
||||
try:
|
||||
for base_cls in cls.__mro__:
|
||||
if method_name not in base_cls.__dict__:
|
||||
continue
|
||||
method = base_cls.__dict__[method_name]
|
||||
actual = getattr(method, "__wrapped__", method)
|
||||
if isinstance(actual, (staticmethod, classmethod)):
|
||||
actual = actual.__func__
|
||||
if not callable(actual):
|
||||
continue
|
||||
sig = inspect.signature(actual, follow_wrapped=True)
|
||||
params = [
|
||||
p.name for p in sig.parameters.values()
|
||||
if p.name not in ("self", "cls")
|
||||
and p.kind not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
|
||||
]
|
||||
if params:
|
||||
return {p: p for p in params}
|
||||
except Exception as e:
|
||||
_logger.debug(f"[AST] resolve_method_params_via_import: MRO walk for '{method_name}' failed: {e}")
|
||||
return {}
|
||||
@@ -1,6 +1,10 @@
|
||||
import json
|
||||
from typing import Dict, Any
|
||||
|
||||
from pylabrobot.resources import Container
|
||||
from unilabos_msgs.msg import Resource
|
||||
|
||||
from unilabos.ros.msgs.message_converter import convert_from_ros_msg
|
||||
|
||||
|
||||
class RegularContainer(Container):
|
||||
@@ -12,14 +16,12 @@ class RegularContainer(Container):
|
||||
kwargs["size_y"] = 0
|
||||
if "size_z" not in kwargs:
|
||||
kwargs["size_z"] = 0
|
||||
if "category" not in kwargs:
|
||||
kwargs["category"] = "container"
|
||||
|
||||
self.kwargs = kwargs
|
||||
super().__init__(*args, **kwargs)
|
||||
self.state = {}
|
||||
super().__init__(*args, category="container", **kwargs)
|
||||
|
||||
def load_state(self, state: Dict[str, Any]):
|
||||
super().load_state(state)
|
||||
self.state = state
|
||||
|
||||
|
||||
def get_regular_container(name="container"):
|
||||
@@ -27,6 +29,7 @@ def get_regular_container(name="container"):
|
||||
r.category = "container"
|
||||
return r
|
||||
|
||||
#
|
||||
# class RegularContainer(object):
|
||||
# # 第一个参数必须是id传入
|
||||
# # noinspection PyShadowingBuiltins
|
||||
@@ -86,4 +89,4 @@ def get_regular_container(name="container"):
|
||||
# return to_dict
|
||||
#
|
||||
# def __str__(self):
|
||||
# return f"{self.id}"
|
||||
# return f"{self.id}"
|
||||
@@ -42,7 +42,7 @@ def canonicalize_nodes_data(
|
||||
Returns:
|
||||
ResourceTreeSet: 标准化后的资源树集合
|
||||
"""
|
||||
print_status(f"{len(nodes)} Resources loaded", "info")
|
||||
print_status(f"{len(nodes)} Resources loaded:", "info")
|
||||
|
||||
# 第一步:基本预处理(处理graphml的label字段)
|
||||
outer_host_node_id = None
|
||||
@@ -76,7 +76,7 @@ def canonicalize_nodes_data(
|
||||
if sample_id:
|
||||
logger.error(f"{node}的sample_id参数已弃用,sample_id: {sample_id}")
|
||||
for k in list(node.keys()):
|
||||
if k not in ["id", "uuid", "name", "description", "schema", "model", "icon", "parent_uuid", "parent", "type", "class", "position", "config", "data", "children", "pose", "extra", "machine_name"]:
|
||||
if k not in ["id", "uuid", "name", "description", "schema", "model", "icon", "parent_uuid", "parent", "type", "class", "position", "config", "data", "children", "pose"]:
|
||||
v = node.pop(k)
|
||||
node["config"][k] = v
|
||||
if outer_host_node_id is not None:
|
||||
@@ -151,40 +151,12 @@ def canonicalize_links_ports(links: List[Dict[str, Any]], resource_tree_set: Res
|
||||
"""
|
||||
# 构建 id 到 uuid 的映射
|
||||
id_to_uuid: Dict[str, str] = {}
|
||||
uuid_to_id: Dict[str, str] = {}
|
||||
for node in resource_tree_set.all_nodes:
|
||||
id_to_uuid[node.res_content.id] = node.res_content.uuid
|
||||
uuid_to_id[node.res_content.uuid] = node.res_content.id
|
||||
|
||||
# 第三遍处理:为每个 link 添加 source_uuid 和 target_uuid
|
||||
for link in links:
|
||||
source_id = link.get("source")
|
||||
target_id = link.get("target")
|
||||
|
||||
# 添加 source_uuid
|
||||
if source_id and source_id in id_to_uuid:
|
||||
link["source_uuid"] = id_to_uuid[source_id]
|
||||
|
||||
# 添加 target_uuid
|
||||
if target_id and target_id in id_to_uuid:
|
||||
link["target_uuid"] = id_to_uuid[target_id]
|
||||
|
||||
source_uuid = link.get("source_uuid")
|
||||
target_uuid = link.get("target_uuid")
|
||||
|
||||
# 添加 source_uuid
|
||||
if source_uuid and source_uuid in uuid_to_id:
|
||||
link["source"] = uuid_to_id[source_uuid]
|
||||
|
||||
# 添加 target_uuid
|
||||
if target_uuid and target_uuid in uuid_to_id:
|
||||
link["target"] = uuid_to_id[target_uuid]
|
||||
|
||||
# 第一遍处理:将字符串类型的port转换为字典格式
|
||||
for link in links:
|
||||
port = link.get("port")
|
||||
if port is None:
|
||||
continue
|
||||
if link.get("type", "physical") == "physical":
|
||||
link["type"] = "fluid"
|
||||
if isinstance(port, int):
|
||||
@@ -207,15 +179,13 @@ def canonicalize_links_ports(links: List[Dict[str, Any]], resource_tree_set: Res
|
||||
link["port"] = {link["source"]: None, link["target"]: None}
|
||||
|
||||
# 构建边字典,键为(source节点, target节点),值为对应的port信息
|
||||
edges = {(link["source"], link["target"]): link["port"] for link in links if link.get("port")}
|
||||
edges = {(link["source"], link["target"]): link["port"] for link in links}
|
||||
|
||||
# 第二遍处理:填充反向边的dest信息
|
||||
delete_reverses = []
|
||||
for i, link in enumerate(links):
|
||||
s, t = link["source"], link["target"]
|
||||
current_port = link.get("port")
|
||||
if current_port is None:
|
||||
continue
|
||||
current_port = link["port"]
|
||||
if current_port.get(t) is None:
|
||||
reverse_key = (t, s)
|
||||
reverse_port = edges.get(reverse_key)
|
||||
@@ -230,6 +200,20 @@ def canonicalize_links_ports(links: List[Dict[str, Any]], resource_tree_set: Res
|
||||
current_port[t] = current_port[s]
|
||||
# 删除已被使用反向端口信息的反向边
|
||||
standardized_links = [link for i, link in enumerate(links) if i not in delete_reverses]
|
||||
|
||||
# 第三遍处理:为每个 link 添加 source_uuid 和 target_uuid
|
||||
for link in standardized_links:
|
||||
source_id = link.get("source")
|
||||
target_id = link.get("target")
|
||||
|
||||
# 添加 source_uuid
|
||||
if source_id and source_id in id_to_uuid:
|
||||
link["source_uuid"] = id_to_uuid[source_id]
|
||||
|
||||
# 添加 target_uuid
|
||||
if target_id and target_id in id_to_uuid:
|
||||
link["target_uuid"] = id_to_uuid[target_id]
|
||||
|
||||
return standardized_links
|
||||
|
||||
|
||||
@@ -276,7 +260,7 @@ def read_node_link_json(
|
||||
resource_tree_set = canonicalize_nodes_data(nodes)
|
||||
|
||||
# 标准化边数据
|
||||
links = data.get("links", data.get("edges", []))
|
||||
links = data.get("links", [])
|
||||
standardized_links = canonicalize_links_ports(links, resource_tree_set)
|
||||
|
||||
# 构建 NetworkX 图(需要转换回 dict 格式)
|
||||
@@ -288,15 +272,6 @@ def read_node_link_json(
|
||||
physical_setup_graph = nx.node_link_graph(graph_data, edges="links", multigraph=False)
|
||||
handle_communications(physical_setup_graph)
|
||||
|
||||
# Stamp machine_name on device trees only (resources are cloud-managed)
|
||||
local_machine = BasicConfig.machine_name or "本地"
|
||||
for tree in resource_tree_set.trees:
|
||||
if tree.root_node.res_content.type != "device":
|
||||
continue
|
||||
for node in tree.get_all_nodes():
|
||||
if not node.res_content.machine_name:
|
||||
node.res_content.machine_name = local_machine
|
||||
|
||||
return physical_setup_graph, resource_tree_set, standardized_links
|
||||
|
||||
|
||||
@@ -309,8 +284,6 @@ def modify_to_backend_format(data: list[dict[str, Any]]) -> list[dict[str, Any]]
|
||||
edge["sourceHandle"] = port[source]
|
||||
elif "source_port" in edge:
|
||||
edge["sourceHandle"] = edge.pop("source_port")
|
||||
elif "source_handle" in edge:
|
||||
edge["sourceHandle"] = edge.pop("source_handle")
|
||||
else:
|
||||
typ = edge.get("type")
|
||||
if typ == "communication":
|
||||
@@ -319,8 +292,6 @@ def modify_to_backend_format(data: list[dict[str, Any]]) -> list[dict[str, Any]]
|
||||
edge["targetHandle"] = port[target]
|
||||
elif "target_port" in edge:
|
||||
edge["targetHandle"] = edge.pop("target_port")
|
||||
elif "target_handle" in edge:
|
||||
edge["targetHandle"] = edge.pop("target_handle")
|
||||
else:
|
||||
typ = edge.get("type")
|
||||
if typ == "communication":
|
||||
@@ -381,15 +352,6 @@ def read_graphml(graphml_file: str) -> tuple[nx.Graph, ResourceTreeSet, List[Dic
|
||||
physical_setup_graph = nx.node_link_graph(graph_data, link="links", multigraph=False)
|
||||
handle_communications(physical_setup_graph)
|
||||
|
||||
# Stamp machine_name on device trees only (resources are cloud-managed)
|
||||
local_machine = BasicConfig.machine_name or "本地"
|
||||
for tree in resource_tree_set.trees:
|
||||
if tree.root_node.res_content.type != "device":
|
||||
continue
|
||||
for node in tree.get_all_nodes():
|
||||
if not node.res_content.machine_name:
|
||||
node.res_content.machine_name = local_machine
|
||||
|
||||
return physical_setup_graph, resource_tree_set, standardized_links
|
||||
|
||||
|
||||
@@ -635,8 +597,6 @@ def resource_plr_to_ulab(resource_plr: "ResourcePLR", parent_name: str = None, w
|
||||
"tube": "tube",
|
||||
"bottle_carrier": "bottle_carrier",
|
||||
"plate_adapter": "plate_adapter",
|
||||
"electrode_sheet": "electrode_sheet",
|
||||
"material_hole": "material_hole",
|
||||
}
|
||||
if source in replace_info:
|
||||
return replace_info[source]
|
||||
|
||||
@@ -5,8 +5,6 @@ from pydantic import BaseModel, field_serializer, field_validator, ValidationErr
|
||||
from pydantic import Field
|
||||
from typing import List, Tuple, Any, Dict, Literal, Optional, cast, TYPE_CHECKING, Union
|
||||
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from unilabos.resources.plr_additional_res_reg import register
|
||||
from unilabos.utils.log import logger
|
||||
|
||||
@@ -15,76 +13,24 @@ if TYPE_CHECKING:
|
||||
from pylabrobot.resources import Resource as PLRResource
|
||||
|
||||
|
||||
EXTRA_CLASS = "unilabos_resource_class"
|
||||
FRONTEND_POSE_EXTRA = "unilabos_frontend_pose_extra"
|
||||
EXTRA_SAMPLE_UUID = "sample_uuid"
|
||||
EXTRA_UNILABOS_SAMPLE_UUID = "unilabos_sample_uuid"
|
||||
|
||||
# 函数参数名常量 - 用于自动注入 sample_uuids 列表
|
||||
PARAM_SAMPLE_UUIDS = "sample_uuids"
|
||||
|
||||
# JSON Command 中的系统参数字段名
|
||||
JSON_UNILABOS_PARAM = "unilabos_param"
|
||||
|
||||
# 返回值中的 samples 字段名
|
||||
RETURN_UNILABOS_SAMPLES = "unilabos_samples"
|
||||
|
||||
# sample_uuids 参数类型 (用于 virtual bench 等设备添加 sample_uuids 参数)
|
||||
SampleUUIDsType = Dict[str, Optional["PLRResource"]]
|
||||
|
||||
|
||||
class LabSample(TypedDict):
|
||||
sample_uuid: str
|
||||
oss_path: str
|
||||
extra: Dict[str, Any]
|
||||
|
||||
|
||||
class ResourceDictPositionSizeType(TypedDict):
|
||||
depth: float
|
||||
width: float
|
||||
height: float
|
||||
|
||||
|
||||
class ResourceDictPositionSize(BaseModel):
|
||||
depth: float = Field(description="Depth", default=0.0) # z
|
||||
width: float = Field(description="Width", default=0.0) # x
|
||||
height: float = Field(description="Height", default=0.0) # y
|
||||
|
||||
|
||||
class ResourceDictPositionScaleType(TypedDict):
|
||||
x: float
|
||||
y: float
|
||||
z: float
|
||||
|
||||
|
||||
class ResourceDictPositionScale(BaseModel):
|
||||
x: float = Field(description="x scale", default=0.0)
|
||||
y: float = Field(description="y scale", default=0.0)
|
||||
z: float = Field(description="z scale", default=0.0)
|
||||
|
||||
|
||||
class ResourceDictPositionObjectType(TypedDict):
|
||||
x: float
|
||||
y: float
|
||||
z: float
|
||||
|
||||
|
||||
class ResourceDictPositionObject(BaseModel):
|
||||
x: float = Field(description="X coordinate", default=0.0)
|
||||
y: float = Field(description="Y coordinate", default=0.0)
|
||||
z: float = Field(description="Z coordinate", default=0.0)
|
||||
|
||||
|
||||
class ResourceDictPositionType(TypedDict):
|
||||
size: ResourceDictPositionSizeType
|
||||
scale: ResourceDictPositionScaleType
|
||||
layout: Literal["2d", "x-y", "z-y", "x-z"]
|
||||
position: ResourceDictPositionObjectType
|
||||
position3d: ResourceDictPositionObjectType
|
||||
rotation: ResourceDictPositionObjectType
|
||||
cross_section_type: Literal["rectangle", "circle", "rounded_rectangle"]
|
||||
|
||||
|
||||
class ResourceDictPosition(BaseModel):
|
||||
size: ResourceDictPositionSize = Field(description="Resource size", default_factory=ResourceDictPositionSize)
|
||||
scale: ResourceDictPositionScale = Field(description="Resource scale", default_factory=ResourceDictPositionScale)
|
||||
@@ -101,26 +47,6 @@ class ResourceDictPosition(BaseModel):
|
||||
cross_section_type: Literal["rectangle", "circle", "rounded_rectangle"] = Field(
|
||||
description="Cross section type", default="rectangle"
|
||||
)
|
||||
extra: Optional[Dict[str, Any]] = Field(description="Extra data", default=None)
|
||||
|
||||
|
||||
class ResourceDictType(TypedDict):
|
||||
id: str
|
||||
uuid: str
|
||||
name: str
|
||||
description: str
|
||||
resource_schema: Dict[str, Any]
|
||||
model: Dict[str, Any]
|
||||
icon: str
|
||||
parent_uuid: Optional[str]
|
||||
parent: Optional["ResourceDictType"]
|
||||
type: Union[Literal["device"], str]
|
||||
klass: str
|
||||
pose: ResourceDictPositionType
|
||||
config: Dict[str, Any]
|
||||
data: Dict[str, Any]
|
||||
extra: Dict[str, Any]
|
||||
machine_name: str
|
||||
|
||||
|
||||
# 统一的资源字典模型,parent 自动序列化为 parent_uuid,children 不序列化
|
||||
@@ -140,9 +66,8 @@ class ResourceDict(BaseModel):
|
||||
klass: str = Field(alias="class", description="Resource class name")
|
||||
pose: ResourceDictPosition = Field(description="Resource position", default_factory=ResourceDictPosition)
|
||||
config: Dict[str, Any] = Field(description="Resource configuration")
|
||||
data: Dict[str, Any] = Field(description="Resource data, eg: container liquid data")
|
||||
extra: Dict[str, Any] = Field(description="Extra data, eg: slot index")
|
||||
machine_name: str = Field(description="Machine this resource belongs to", default="")
|
||||
data: Dict[str, Any] = Field(description="Resource data")
|
||||
extra: Dict[str, Any] = Field(description="Extra data")
|
||||
|
||||
@field_serializer("parent_uuid")
|
||||
def _serialize_parent(self, parent_uuid: Optional["ResourceDict"]):
|
||||
@@ -198,30 +123,22 @@ class ResourceDictInstance(object):
|
||||
self.typ = "dict"
|
||||
|
||||
@classmethod
|
||||
def get_resource_instance_from_dict(cls, content: ResourceDictType) -> "ResourceDictInstance":
|
||||
def get_resource_instance_from_dict(cls, content: Dict[str, Any]) -> "ResourceDictInstance":
|
||||
"""从字典创建资源实例"""
|
||||
if "id" not in content:
|
||||
content["id"] = content["name"]
|
||||
if "uuid" not in content:
|
||||
content["uuid"] = str(uuid.uuid4())
|
||||
if "description" in content and content["description"] is None:
|
||||
# noinspection PyTypedDict
|
||||
del content["description"]
|
||||
if "model" in content and content["model"] is None:
|
||||
# noinspection PyTypedDict
|
||||
del content["model"]
|
||||
# noinspection PyTypedDict
|
||||
if "schema" in content and content["schema"] is None:
|
||||
# noinspection PyTypedDict
|
||||
del content["schema"]
|
||||
# noinspection PyTypedDict
|
||||
if "x" in content.get("position", {}):
|
||||
# 说明是老版本的position格式,转换成新的
|
||||
# noinspection PyTypedDict
|
||||
content["position"] = {"position": content["position"]}
|
||||
# noinspection PyTypedDict
|
||||
if not content.get("class"):
|
||||
# noinspection PyTypedDict
|
||||
content["class"] = ""
|
||||
if not content.get("config"): # todo: 后续从后端保证字段非空
|
||||
content["config"] = {}
|
||||
@@ -232,18 +149,16 @@ class ResourceDictInstance(object):
|
||||
if "position" in content:
|
||||
pose = content.get("pose", {})
|
||||
if "position" not in pose:
|
||||
# noinspection PyTypedDict
|
||||
if "position" in content["position"]:
|
||||
# noinspection PyTypedDict
|
||||
pose["position"] = content["position"]["position"]
|
||||
else:
|
||||
pose["position"] = ResourceDictPositionObjectType(x=0, y=0, z=0)
|
||||
pose["position"] = {"x": 0, "y": 0, "z": 0}
|
||||
if "size" not in pose:
|
||||
pose["size"] = ResourceDictPositionSizeType(
|
||||
width= content["config"].get("size_x", 0),
|
||||
height= content["config"].get("size_y", 0),
|
||||
depth= content["config"].get("size_z", 0),
|
||||
)
|
||||
pose["size"] = {
|
||||
"width": content["config"].get("size_x", 0),
|
||||
"height": content["config"].get("size_y", 0),
|
||||
"depth": content["config"].get("size_z", 0),
|
||||
}
|
||||
content["pose"] = pose
|
||||
try:
|
||||
res_dict = ResourceDict.model_validate(content)
|
||||
@@ -411,7 +326,7 @@ class ResourceTreeSet(object):
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_plr_resources(cls, resources: List["PLRResource"], known_newly_created=False, old_size=False) -> "ResourceTreeSet":
|
||||
def from_plr_resources(cls, resources: List["PLRResource"], known_newly_created=False) -> "ResourceTreeSet":
|
||||
"""
|
||||
从plr资源创建ResourceTreeSet
|
||||
"""
|
||||
@@ -425,29 +340,13 @@ class ResourceTreeSet(object):
|
||||
"tip_spot": "tip_spot",
|
||||
"tube": "tube",
|
||||
"bottle_carrier": "bottle_carrier",
|
||||
"material_hole": "material_hole",
|
||||
"container": "container",
|
||||
"material_plate": "material_plate",
|
||||
"electrode_sheet": "electrode_sheet",
|
||||
"warehouse": "warehouse",
|
||||
"magazine_holder": "magazine_holder",
|
||||
"resource_group": "resource_group",
|
||||
"trash": "trash",
|
||||
"plate_adapter": "plate_adapter",
|
||||
"consumable": "consumable",
|
||||
"tool": "tool",
|
||||
"condenser": "condenser",
|
||||
"crucible": "crucible",
|
||||
"reagent_bottle": "reagent_bottle",
|
||||
"flask": "flask",
|
||||
"beaker": "beaker",
|
||||
}
|
||||
if source in replace_info:
|
||||
return replace_info[source]
|
||||
elif source is None:
|
||||
return ""
|
||||
else:
|
||||
logger.trace(f"转换pylabrobot的时候,出现未知类型 {source}")
|
||||
print("转换pylabrobot的时候,出现未知类型", source)
|
||||
return source
|
||||
|
||||
def build_uuid_mapping(res: "PLRResource", uuid_list: list, parent_uuid: Optional[str] = None):
|
||||
@@ -484,7 +383,6 @@ class ResourceTreeSet(object):
|
||||
"position3d": raw_pos,
|
||||
"rotation": d["rotation"],
|
||||
"cross_section_type": d.get("cross_section_type", "rectangle"),
|
||||
"extra": extra.get(FRONTEND_POSE_EXTRA)
|
||||
}
|
||||
|
||||
# 先构建当前节点的字典(不包含children)
|
||||
@@ -495,14 +393,14 @@ class ResourceTreeSet(object):
|
||||
"parent": parent_resource, # 直接传入 ResourceDict 对象
|
||||
"parent_uuid": parent_uuid, # 使用 parent_uuid 而不是 parent 对象
|
||||
"type": replace_plr_type(d.get("category", "")),
|
||||
"class": extra.get(EXTRA_CLASS, ""),
|
||||
"class": d.get("class", ""),
|
||||
"position": pos,
|
||||
"pose": pos,
|
||||
"config": {
|
||||
k: v
|
||||
for k, v in d.items()
|
||||
if k
|
||||
not in ([
|
||||
not in [
|
||||
"name",
|
||||
"children",
|
||||
"parent_name",
|
||||
@@ -513,15 +411,7 @@ class ResourceTreeSet(object):
|
||||
"size_z",
|
||||
"cross_section_type",
|
||||
"bottom_type",
|
||||
] if not old_size else [
|
||||
"name",
|
||||
"children",
|
||||
"parent_name",
|
||||
"location",
|
||||
"rotation",
|
||||
"cross_section_type",
|
||||
"bottom_type",
|
||||
])
|
||||
]
|
||||
},
|
||||
"data": states[d["name"]],
|
||||
"extra": extra,
|
||||
@@ -553,7 +443,7 @@ class ResourceTreeSet(object):
|
||||
trees.append(tree_instance)
|
||||
return cls(trees)
|
||||
|
||||
def to_plr_resources(self, skip_devices=True) -> List["PLRResource"]:
|
||||
def to_plr_resources(self) -> List["PLRResource"]:
|
||||
"""
|
||||
将 ResourceTreeSet 转换为 PLR 资源列表
|
||||
|
||||
@@ -578,8 +468,6 @@ class ResourceTreeSet(object):
|
||||
name_to_uuid[node.res_content.name] = node.res_content.uuid
|
||||
all_states[node.res_content.name] = node.res_content.data
|
||||
name_to_extra[node.res_content.name] = node.res_content.extra
|
||||
name_to_extra[node.res_content.name][FRONTEND_POSE_EXTRA] = node.res_content.pose.extra
|
||||
name_to_extra[node.res_content.name][EXTRA_CLASS] = node.res_content.klass
|
||||
for child in node.children:
|
||||
collect_node_data(child, name_to_uuid, all_states, name_to_extra)
|
||||
|
||||
@@ -624,10 +512,7 @@ class ResourceTreeSet(object):
|
||||
plr_dict = node_to_plr_dict(tree.root_node, has_model)
|
||||
try:
|
||||
sub_cls = find_subclass(plr_dict["type"], PLRResource)
|
||||
if skip_devices and plr_dict["type"] == "device":
|
||||
logger.info(f"跳过更新 {plr_dict['name']} 设备是class")
|
||||
continue
|
||||
elif sub_cls is None:
|
||||
if sub_cls is None:
|
||||
raise ValueError(
|
||||
f"无法找到类型 {plr_dict['type']} 对应的 PLR 资源类。原始信息:{tree.root_node.res_content}"
|
||||
)
|
||||
@@ -635,11 +520,6 @@ class ResourceTreeSet(object):
|
||||
if "category" not in spec.parameters:
|
||||
plr_dict.pop("category", None)
|
||||
plr_resource = sub_cls.deserialize(plr_dict, allow_marshal=True)
|
||||
from pylabrobot.resources import Coordinate
|
||||
from pylabrobot.serializer import deserialize
|
||||
|
||||
location = cast(Coordinate, deserialize(plr_dict["location"]))
|
||||
plr_resource.location = location
|
||||
plr_resource.load_all_state(all_states)
|
||||
# 使用 DeviceNodeResourceTracker 设置 UUID 和 Extra
|
||||
tracker.loop_set_uuid(plr_resource, name_to_uuid)
|
||||
@@ -647,7 +527,7 @@ class ResourceTreeSet(object):
|
||||
plr_resources.append(plr_resource)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"转换 PLR 资源失败: {e} {str(plr_dict)[:1000]}")
|
||||
logger.error(f"转换 PLR 资源失败: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(f"堆栈: {traceback.format_exc()}")
|
||||
@@ -820,8 +700,7 @@ class ResourceTreeSet(object):
|
||||
if remote_root_type == "device":
|
||||
# 情况1: 一级是 device
|
||||
if remote_root_id not in local_device_map:
|
||||
if remote_root_id != "host_node":
|
||||
logger.warning(f"Device '{remote_root_id}' 在本地不存在,跳过该 device 下的物料同步")
|
||||
logger.warning(f"Device '{remote_root_id}' 在本地不存在,跳过该 device 下的物料同步")
|
||||
continue
|
||||
|
||||
local_device = local_device_map[remote_root_id]
|
||||
@@ -868,27 +747,14 @@ class ResourceTreeSet(object):
|
||||
f"从远端同步了 {added_count} 个物料子树"
|
||||
)
|
||||
else:
|
||||
# 二级物料已存在,比较三级子节点是否缺失
|
||||
local_material = local_children_map[remote_child_name]
|
||||
local_material_children_map = {child.res_content.name: child for child in
|
||||
local_material.children}
|
||||
added_count = 0
|
||||
for remote_sub in remote_child.children:
|
||||
remote_sub_name = remote_sub.res_content.name
|
||||
if remote_sub_name not in local_material_children_map:
|
||||
remote_sub.res_content.parent = local_material.res_content
|
||||
local_material.children.append(remote_sub)
|
||||
added_count += 1
|
||||
else:
|
||||
logger.info(
|
||||
f"物料 '{remote_root_id}/{remote_child_name}/{remote_sub_name}' "
|
||||
f"已存在,跳过"
|
||||
)
|
||||
if added_count > 0:
|
||||
logger.info(
|
||||
f"物料 '{remote_root_id}/{remote_child_name}': "
|
||||
f"从远端同步了 {added_count} 个子物料"
|
||||
)
|
||||
# 情况2: 二级是物料(不是 device)
|
||||
if remote_child_name not in local_children_map:
|
||||
# 引入整个子树
|
||||
remote_child.res_content.parent = local_device.res_content
|
||||
local_device.children.append(remote_child)
|
||||
logger.info(f"Device '{remote_root_id}': 从远端同步物料子树 '{remote_child_name}'")
|
||||
else:
|
||||
logger.info(f"物料 '{remote_root_id}/{remote_child_name}' 已存在,跳过")
|
||||
else:
|
||||
# 情况1: 一级节点是物料(不是 device)
|
||||
# 检查是否已存在
|
||||
@@ -911,7 +777,7 @@ class ResourceTreeSet(object):
|
||||
|
||||
return self
|
||||
|
||||
def dump(self, old_position=False) -> List[List[Dict[str, Any]]]:
|
||||
def dump(self) -> List[List[Dict[str, Any]]]:
|
||||
"""
|
||||
将 ResourceTreeSet 序列化为嵌套列表格式
|
||||
|
||||
@@ -927,10 +793,6 @@ class ResourceTreeSet(object):
|
||||
# 获取树的所有节点并序列化
|
||||
tree_nodes = [node.res_content.model_dump(by_alias=True) for node in tree.get_all_nodes()]
|
||||
result.append(tree_nodes)
|
||||
if old_position:
|
||||
for r in result:
|
||||
for rr in r:
|
||||
rr["position"] = rr["pose"]["position"]
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
@@ -1124,7 +986,7 @@ class DeviceNodeResourceTracker(object):
|
||||
extra = name_to_extra_map[resource_name]
|
||||
self.set_resource_extra(res, extra)
|
||||
if len(extra):
|
||||
logger.trace(f"设置资源Extra: {resource_name} -> {extra}")
|
||||
logger.debug(f"设置资源Extra: {resource_name} -> {extra}")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
@@ -44,7 +44,8 @@ def ros2_device_node(
|
||||
# 从属性中自动发现可发布状态
|
||||
if status_types is None:
|
||||
status_types = {}
|
||||
assert device_config is not None, "device_config cannot be None"
|
||||
if device_config is None:
|
||||
raise ValueError("device_config cannot be None")
|
||||
if action_value_mappings is None:
|
||||
action_value_mappings = {}
|
||||
if hardware_interface is None:
|
||||
|
||||
@@ -11,7 +11,6 @@ from io import StringIO
|
||||
from typing import Iterable, Any, Dict, Type, TypeVar, Union
|
||||
|
||||
import yaml
|
||||
from msgcenterpy.instances.ros2_instance import ROS2MessageInstance
|
||||
from pydantic import BaseModel
|
||||
from dataclasses import asdict, is_dataclass
|
||||
|
||||
@@ -728,9 +727,46 @@ def ros_message_to_json_schema(msg_class: Any, field_name: str) -> Dict[str, Any
|
||||
Returns:
|
||||
对应的 JSON Schema 定义
|
||||
"""
|
||||
schema = ROS2MessageInstance(msg_class()).get_json_schema()
|
||||
schema = {"type": "object", "properties": {}, "required": []}
|
||||
|
||||
# 优先使用字段名作为标题,否则使用类名
|
||||
schema["title"] = field_name
|
||||
schema.pop("description")
|
||||
|
||||
# 获取消息的字段和字段类型
|
||||
try:
|
||||
for ind, slot_info in enumerate(msg_class._fields_and_field_types.items()):
|
||||
slot_name, slot_type = slot_info
|
||||
type_info = msg_class.SLOT_TYPES[ind]
|
||||
field_schema = ros_field_type_to_json_schema(type_info, slot_name)
|
||||
schema["properties"][slot_name] = field_schema
|
||||
schema["required"].append(slot_name)
|
||||
# if hasattr(msg_class, 'get_fields_and_field_types'):
|
||||
# fields_and_types = msg_class.get_fields_and_field_types()
|
||||
#
|
||||
# for field_name, field_type in fields_and_types.items():
|
||||
# # 将 ROS 字段类型转换为 JSON Schema
|
||||
# field_schema = ros_field_type_to_json_schema(field_type)
|
||||
#
|
||||
# schema['properties'][field_name] = field_schema
|
||||
# schema['required'].append(field_name)
|
||||
# elif hasattr(msg_class, '__slots__') and hasattr(msg_class, '_fields_and_field_types'):
|
||||
# # 直接从实例属性获取
|
||||
# for field_name in msg_class.__slots__:
|
||||
# # 移除前导下划线(如果有)
|
||||
# clean_name = field_name[1:] if field_name.startswith('_') else field_name
|
||||
#
|
||||
# # 从 _fields_and_field_types 获取类型
|
||||
# if clean_name in msg_class._fields_and_field_types:
|
||||
# field_type = msg_class._fields_and_field_types[clean_name]
|
||||
# field_schema = ros_field_type_to_json_schema(field_type)
|
||||
#
|
||||
# schema['properties'][clean_name] = field_schema
|
||||
# schema['required'].append(clean_name)
|
||||
except Exception as e:
|
||||
# 如果获取字段类型失败,添加错误信息
|
||||
schema["description"] = f"解析消息字段时出错: {str(e)}"
|
||||
logger.error(f"解析 {msg_class.__name__} 消息字段失败: {str(e)}")
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
|
||||
@@ -4,20 +4,8 @@ import json
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from typing import (
|
||||
get_type_hints,
|
||||
TypeVar,
|
||||
Generic,
|
||||
Dict,
|
||||
Any,
|
||||
Type,
|
||||
TypedDict,
|
||||
Optional,
|
||||
List,
|
||||
TYPE_CHECKING,
|
||||
Union,
|
||||
Tuple,
|
||||
)
|
||||
from typing import get_type_hints, TypeVar, Generic, Dict, Any, Type, TypedDict, Optional, List, TYPE_CHECKING, Union, \
|
||||
Tuple
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import asyncio
|
||||
@@ -34,8 +22,7 @@ from unilabos_msgs.action import SendCmd
|
||||
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response
|
||||
|
||||
from unilabos.config.config import BasicConfig
|
||||
from unilabos.registry.decorators import get_topic_config
|
||||
from unilabos.utils.decorator import get_all_subscriptions
|
||||
from unilabos.utils.decorator import get_topic_config, get_all_subscriptions
|
||||
|
||||
from unilabos.resources.container import RegularContainer
|
||||
from unilabos.resources.graphio import (
|
||||
@@ -58,13 +45,9 @@ from unilabos_msgs.msg import Resource # type: ignore
|
||||
|
||||
from unilabos.resources.resource_tracker import (
|
||||
DeviceNodeResourceTracker,
|
||||
ResourceDictType,
|
||||
ResourceTreeSet,
|
||||
ResourceTreeInstance,
|
||||
ResourceDictInstance,
|
||||
EXTRA_SAMPLE_UUID,
|
||||
PARAM_SAMPLE_UUIDS,
|
||||
JSON_UNILABOS_PARAM,
|
||||
)
|
||||
from unilabos.ros.utils.driver_creator import WorkstationNodeCreator, PyLabRobotCreator, DeviceClassCreator
|
||||
from rclpy.task import Task, Future
|
||||
@@ -148,7 +131,7 @@ def init_wrapper(
|
||||
device_id: str,
|
||||
device_uuid: str,
|
||||
driver_class: type[T],
|
||||
device_config: ResourceDictInstance,
|
||||
device_config: ResourceTreeInstance,
|
||||
status_types: Dict[str, Any],
|
||||
action_value_mappings: Dict[str, Any],
|
||||
hardware_interface: Dict[str, Any],
|
||||
@@ -196,9 +179,9 @@ class PropertyPublisher:
|
||||
self._value = None
|
||||
try:
|
||||
self.publisher_ = node.create_publisher(msg_type, f"{name}", qos)
|
||||
except Exception as e:
|
||||
except AttributeError as ex:
|
||||
self.node.lab_logger().error(
|
||||
f"StatusError, DeviceId: {self.node.device_id} 创建发布者 {name} 失败,可能由于注册表有误,类型: {msg_type},错误: {e}"
|
||||
f"创建发布者 {name} 失败,可能由于注册表有误,类型: {msg_type},错误: {ex}\n{traceback.format_exc()}"
|
||||
)
|
||||
self.timer = node.create_timer(self.timer_period, self.publish_property)
|
||||
self.__loop = ROS2DeviceNode.get_asyncio_loop()
|
||||
@@ -233,15 +216,14 @@ class PropertyPublisher:
|
||||
|
||||
def publish_property(self):
|
||||
try:
|
||||
# self.node.lab_logger().trace(f"【.publish_property】开始发布属性: {self.name}")
|
||||
self.node.lab_logger().trace(f"【.publish_property】开始发布属性: {self.name}")
|
||||
value = self.get_property()
|
||||
if self.print_publish:
|
||||
pass
|
||||
# self.node.lab_logger().trace(f"【.publish_property】发布 {self.msg_type}: {value}")
|
||||
self.node.lab_logger().trace(f"【.publish_property】发布 {self.msg_type}: {value}")
|
||||
if value is not None:
|
||||
msg = convert_to_ros_msg(self.msg_type, value)
|
||||
self.publisher_.publish(msg)
|
||||
# self.node.lab_logger().trace(f"【.publish_property】属性 {self.name} 发布成功")
|
||||
self.node.lab_logger().trace(f"【.publish_property】属性 {self.name} 发布成功")
|
||||
except Exception as e:
|
||||
self.node.lab_logger().error(
|
||||
f"【.publish_property】发布属性 {self.publisher_.topic} 出错: {str(e)}\n{traceback.format_exc()}"
|
||||
@@ -281,7 +263,6 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
self,
|
||||
driver_instance: T,
|
||||
device_id: str,
|
||||
registry_name: str,
|
||||
device_uuid: str,
|
||||
status_types: Dict[str, Any],
|
||||
action_value_mappings: Dict[str, Any],
|
||||
@@ -303,7 +284,6 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
"""
|
||||
self.driver_instance = driver_instance
|
||||
self.device_id = device_id
|
||||
self.registry_name = registry_name
|
||||
self.uuid = device_uuid
|
||||
self.publish_high_frequency = False
|
||||
self.callback_group = ReentrantCallbackGroup()
|
||||
@@ -381,7 +361,6 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
from pylabrobot.resources.deck import Deck
|
||||
from pylabrobot.resources import Coordinate
|
||||
from pylabrobot.resources import Plate
|
||||
|
||||
# 物料传输到对应的node节点
|
||||
client = self._resource_clients["c2s_update_resource_tree"]
|
||||
request = SerialCommand.Request()
|
||||
@@ -409,29 +388,33 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
rts: ResourceTreeSet = ResourceTreeSet.from_raw_dict_list(input_resources)
|
||||
parent_resource = None
|
||||
if bind_parent_id != self.node_name:
|
||||
parent_resource = self.resource_tracker.figure_resource({"name": bind_parent_id})
|
||||
parent_resource = self.resource_tracker.figure_resource(
|
||||
{"name": bind_parent_id}
|
||||
)
|
||||
for r in rts.root_nodes:
|
||||
# noinspection PyUnresolvedReferences
|
||||
r.res_content.parent_uuid = parent_resource.unilabos_uuid
|
||||
else:
|
||||
for r in rts.root_nodes:
|
||||
r.res_content.parent_uuid = self.uuid
|
||||
rts_plr_instances = rts.to_plr_resources()
|
||||
if len(rts.root_nodes) == 1 and isinstance(rts_plr_instances[0], RegularContainer):
|
||||
|
||||
if len(LIQUID_INPUT_SLOT) and LIQUID_INPUT_SLOT[0] == -1 and len(rts.root_nodes) == 1 and isinstance(rts.root_nodes[0], RegularContainer):
|
||||
# noinspection PyTypeChecker
|
||||
container_instance: RegularContainer = rts_plr_instances[0]
|
||||
container_instance: RegularContainer = rts.root_nodes[0]
|
||||
found_resources = self.resource_tracker.figure_resource(
|
||||
{"name": container_instance.name}, try_mode=True
|
||||
{"id": container_instance.name}, try_mode=True
|
||||
)
|
||||
if not len(found_resources):
|
||||
self.resource_tracker.add_resource(container_instance)
|
||||
logger.info(f"添加物料{container_instance.name}到资源跟踪器")
|
||||
else:
|
||||
assert len(found_resources) == 1, f"找到多个同名物料: {container_instance.name}, 请检查物料系统"
|
||||
assert (
|
||||
len(found_resources) == 1
|
||||
), f"找到多个同名物料: {container_instance.name}, 请检查物料系统"
|
||||
found_resource = found_resources[0]
|
||||
if isinstance(found_resource, RegularContainer):
|
||||
logger.info(f"更新物料{container_instance.name}的数据{found_resource.state}")
|
||||
found_resource.state.update(container_instance.state)
|
||||
found_resource.state.update(json.loads(container_instance.state))
|
||||
elif isinstance(found_resource, dict):
|
||||
raise ValueError("已不支持 字典 版本的RegularContainer")
|
||||
else:
|
||||
@@ -439,16 +422,14 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
f"更新物料{container_instance.name}出现不支持的数据类型{type(found_resource)} {found_resource}"
|
||||
)
|
||||
# noinspection PyUnresolvedReferences
|
||||
request.command = json.dumps(
|
||||
{
|
||||
"action": "add",
|
||||
"data": {
|
||||
"data": rts.dump(),
|
||||
"mount_uuid": parent_resource.unilabos_uuid if parent_resource is not None else self.uuid,
|
||||
"first_add": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
request.command = json.dumps({
|
||||
"action": "add",
|
||||
"data": {
|
||||
"data": rts.dump(),
|
||||
"mount_uuid": parent_resource.unilabos_uuid if parent_resource is not None else "",
|
||||
"first_add": False,
|
||||
},
|
||||
})
|
||||
tree_response: SerialCommand.Response = await client.call_async(request)
|
||||
uuid_maps = json.loads(tree_response.response)
|
||||
plr_instances = rts.to_plr_resources()
|
||||
@@ -462,7 +443,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
}
|
||||
res.response = json.dumps(final_response)
|
||||
# 如果driver自己就有assign的方法,那就使用driver自己的assign方法
|
||||
if hasattr(self.driver_instance, "create_resource") and self.node_name != "host_node":
|
||||
if hasattr(self.driver_instance, "create_resource"):
|
||||
create_resource_func = getattr(self.driver_instance, "create_resource")
|
||||
try:
|
||||
ret = create_resource_func(
|
||||
@@ -490,9 +471,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
if len(ADD_LIQUID_TYPE) == 1 and len(LIQUID_VOLUME) == 1 and len(LIQUID_INPUT_SLOT) > 1:
|
||||
ADD_LIQUID_TYPE = ADD_LIQUID_TYPE * len(LIQUID_INPUT_SLOT)
|
||||
LIQUID_VOLUME = LIQUID_VOLUME * len(LIQUID_INPUT_SLOT)
|
||||
self.lab_logger().warning(
|
||||
f"增加液体资源时,数量为1,自动补全为 {len(LIQUID_INPUT_SLOT)} 个"
|
||||
)
|
||||
self.lab_logger().warning(f"增加液体资源时,数量为1,自动补全为 {len(LIQUID_INPUT_SLOT)} 个")
|
||||
for liquid_type, liquid_volume, liquid_input_slot in zip(
|
||||
ADD_LIQUID_TYPE, LIQUID_VOLUME, LIQUID_INPUT_SLOT
|
||||
):
|
||||
@@ -511,15 +490,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
input_wells = []
|
||||
for r in LIQUID_INPUT_SLOT:
|
||||
input_wells.append(plr_instance.children[r])
|
||||
final_response["liquid_input_resource_tree"] = ResourceTreeSet.from_plr_resources(
|
||||
input_wells
|
||||
).dump()
|
||||
final_response["liquid_input_resource_tree"] = ResourceTreeSet.from_plr_resources(input_wells).dump()
|
||||
res.response = json.dumps(final_response)
|
||||
if (
|
||||
issubclass(parent_resource.__class__, Deck)
|
||||
and hasattr(parent_resource, "assign_child_at_slot")
|
||||
and "slot" in other_calling_param
|
||||
):
|
||||
if issubclass(parent_resource.__class__, Deck) and hasattr(parent_resource, "assign_child_at_slot") and "slot" in other_calling_param:
|
||||
other_calling_param["slot"] = int(other_calling_param["slot"])
|
||||
parent_resource.assign_child_at_slot(plr_instance, **other_calling_param)
|
||||
else:
|
||||
@@ -534,16 +507,14 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
rts_with_parent = ResourceTreeSet.from_plr_resources([parent_resource])
|
||||
if rts_with_parent.root_nodes[0].res_content.uuid_parent is None:
|
||||
rts_with_parent.root_nodes[0].res_content.parent_uuid = self.uuid
|
||||
request.command = json.dumps(
|
||||
{
|
||||
"action": "add",
|
||||
"data": {
|
||||
"data": rts_with_parent.dump(),
|
||||
"mount_uuid": rts_with_parent.root_nodes[0].res_content.uuid_parent,
|
||||
"first_add": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
request.command = json.dumps({
|
||||
"action": "add",
|
||||
"data": {
|
||||
"data": rts_with_parent.dump(),
|
||||
"mount_uuid": rts_with_parent.root_nodes[0].res_content.uuid_parent,
|
||||
"first_add": False,
|
||||
},
|
||||
})
|
||||
tree_response: SerialCommand.Response = await client.call_async(request)
|
||||
uuid_maps = json.loads(tree_response.response)
|
||||
self.resource_tracker.loop_update_uuid(input_resources, uuid_maps)
|
||||
@@ -571,11 +542,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
future.add_done_callback(done_cb)
|
||||
except ImportError:
|
||||
self.lab_logger().error("Host请求添加物料时,本环境并不存在pylabrobot")
|
||||
res.response = get_result_info_str(traceback.format_exc(), False, {})
|
||||
except Exception as e:
|
||||
self.lab_logger().error("Host请求添加物料时出错")
|
||||
self.lab_logger().error(traceback.format_exc())
|
||||
res.response = get_result_info_str(traceback.format_exc(), False, {})
|
||||
return res
|
||||
|
||||
# noinspection PyTypeChecker
|
||||
@@ -598,12 +567,6 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
self.s2c_resource_tree, # type: ignore
|
||||
callback_group=self.callback_group,
|
||||
),
|
||||
"s2c_device_manage": self.create_service(
|
||||
SerialCommand,
|
||||
f"/srv{self.namespace}/s2c_device_manage",
|
||||
self.s2c_device_manage, # type: ignore
|
||||
callback_group=self.callback_group,
|
||||
),
|
||||
}
|
||||
|
||||
# 向全局在线设备注册表添加设备信息
|
||||
@@ -848,9 +811,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
}
|
||||
|
||||
def _handle_update(
|
||||
plr_resources: List[Union[ResourcePLR, ResourceDictInstance]],
|
||||
tree_set: ResourceTreeSet,
|
||||
additional_add_params: Dict[str, Any],
|
||||
plr_resources: List[Union[ResourcePLR, ResourceDictInstance]], tree_set: ResourceTreeSet, additional_add_params: Dict[str, Any]
|
||||
) -> Tuple[Dict[str, Any], List[ResourcePLR]]:
|
||||
"""
|
||||
处理资源更新操作的内部函数
|
||||
@@ -875,10 +836,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
original_parent_resource = original_instance.parent
|
||||
original_parent_resource_uuid = getattr(original_parent_resource, "unilabos_uuid", None)
|
||||
target_parent_resource_uuid = tree.root_node.res_content.uuid_parent
|
||||
not_same_parent = (
|
||||
original_parent_resource_uuid != target_parent_resource_uuid
|
||||
and original_parent_resource is not None
|
||||
)
|
||||
not_same_parent = original_parent_resource_uuid != target_parent_resource_uuid and original_parent_resource is not None
|
||||
old_name = original_instance.name
|
||||
new_name = plr_resource.name
|
||||
parent_appended = False
|
||||
@@ -914,35 +872,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
else:
|
||||
# 判断是否变更了resource_site,重新登记
|
||||
target_site = original_instance.unilabos_extra.get("update_resource_site")
|
||||
sites = (
|
||||
original_instance.parent.sites
|
||||
if original_instance.parent is not None and hasattr(original_instance.parent, "sites")
|
||||
else None
|
||||
)
|
||||
site_names = (
|
||||
list(original_instance.parent._ordering.keys())
|
||||
if original_instance.parent is not None and hasattr(original_instance.parent, "sites")
|
||||
else []
|
||||
)
|
||||
sites = original_instance.parent.sites if original_instance.parent is not None and hasattr(original_instance.parent, "sites") else None
|
||||
site_names = list(original_instance.parent._ordering.keys()) if original_instance.parent is not None and hasattr(original_instance.parent, "sites") else []
|
||||
if target_site is not None and sites is not None and site_names is not None:
|
||||
site_index = None
|
||||
try:
|
||||
# sites 可能是 Resource 列表或 dict 列表 (如 PRCXI9300Deck)
|
||||
# 只有itemized_carrier在使用,准备弃用
|
||||
site_index = sites.index(original_instance)
|
||||
except ValueError:
|
||||
# dict 类型的 sites: 通过name匹配
|
||||
for idx, site in enumerate(sites):
|
||||
if original_instance.name == site["occupied_by"]:
|
||||
site_index = idx
|
||||
break
|
||||
elif (original_instance.location.x == site["position"]["x"] and original_instance.location.y == site["position"]["y"] and original_instance.location.z == site["position"]["z"]):
|
||||
site_index = idx
|
||||
break
|
||||
if site_index is None:
|
||||
site_name = None
|
||||
else:
|
||||
site_name = site_names[site_index]
|
||||
site_index = sites.index(original_instance)
|
||||
site_name = site_names[site_index]
|
||||
if site_name != target_site:
|
||||
parent = self.transfer_to_new_resource(original_instance, tree, additional_add_params)
|
||||
if parent is not None:
|
||||
@@ -950,17 +884,6 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
parent_appended = True
|
||||
|
||||
# 加载状态
|
||||
# noinspection PyProtectedMember
|
||||
original_instance._size_x = plr_resource._size_x
|
||||
# noinspection PyProtectedMember
|
||||
original_instance._size_y = plr_resource._size_y
|
||||
# noinspection PyProtectedMember
|
||||
original_instance._size_z = plr_resource._size_z
|
||||
# noinspection PyProtectedMember
|
||||
original_instance._local_size_z = plr_resource._local_size_z
|
||||
original_instance.location = plr_resource.location
|
||||
original_instance.rotation = plr_resource.rotation
|
||||
original_instance.barcode = plr_resource.barcode
|
||||
original_instance.load_all_state(states)
|
||||
child_count = len(original_instance.get_all_children())
|
||||
self.lab_logger().info(
|
||||
@@ -984,7 +907,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
action = i.get("action") # remove, add, update
|
||||
resources_uuid: List[str] = i.get("data") # 资源数据
|
||||
additional_add_params = i.get("additional_add_params", {}) # 额外参数
|
||||
self.lab_logger().trace(f"[资源同步] 处理 {action}, " f"resources count: {len(resources_uuid)}")
|
||||
self.lab_logger().trace(
|
||||
f"[资源同步] 处理 {action}, " f"resources count: {len(resources_uuid)}"
|
||||
)
|
||||
tree_set = None
|
||||
if action in ["add", "update"]:
|
||||
tree_set = await self.get_resource(
|
||||
@@ -1011,14 +936,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
tree.root_node.res_content.parent_uuid = self.uuid
|
||||
r = SerialCommand.Request()
|
||||
r.command = json.dumps(
|
||||
{"data": {"data": new_tree_set.dump()}, "action": "update"}
|
||||
) # 和Update Resource一致
|
||||
{"data": {"data": new_tree_set.dump()}, "action": "update"}) # 和Update Resource一致
|
||||
response: SerialCommand_Response = await self._resource_clients[
|
||||
"c2s_update_resource_tree"
|
||||
].call_async(
|
||||
r
|
||||
) # type: ignore
|
||||
self.lab_logger().trace(f"确认资源云端 Add 结果: {response.response}")
|
||||
"c2s_update_resource_tree"].call_async(r) # type: ignore
|
||||
self.lab_logger().info(f"确认资源云端 Add 结果: {response.response}")
|
||||
results.append(result)
|
||||
elif action == "update":
|
||||
if tree_set is None:
|
||||
@@ -1037,14 +958,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
tree.root_node.res_content.parent_uuid = self.uuid
|
||||
r = SerialCommand.Request()
|
||||
r.command = json.dumps(
|
||||
{"data": {"data": new_tree_set.dump()}, "action": "update"}
|
||||
) # 和Update Resource一致
|
||||
{"data": {"data": new_tree_set.dump()}, "action": "update"}) # 和Update Resource一致
|
||||
response: SerialCommand_Response = await self._resource_clients[
|
||||
"c2s_update_resource_tree"
|
||||
].call_async(
|
||||
r
|
||||
) # type: ignore
|
||||
self.lab_logger().trace(f"确认资源云端 Update 结果: {response.response}")
|
||||
"c2s_update_resource_tree"].call_async(r) # type: ignore
|
||||
self.lab_logger().info(f"确认资源云端 Update 结果: {response.response}")
|
||||
results.append(result)
|
||||
elif action == "remove":
|
||||
result = _handle_remove(resources_uuid)
|
||||
@@ -1072,48 +989,6 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
|
||||
return res
|
||||
|
||||
async def s2c_device_manage(self, req: SerialCommand_Request, res: SerialCommand_Response):
|
||||
"""Handle add/remove device requests from HostNode via SerialCommand."""
|
||||
try:
|
||||
cmd = json.loads(req.command)
|
||||
action = cmd.get("action", "")
|
||||
data = cmd.get("data", {})
|
||||
device_id = data.get("device_id", "")
|
||||
|
||||
if not device_id:
|
||||
res.response = json.dumps({"success": False, "error": "device_id required"})
|
||||
return res
|
||||
|
||||
if action == "add":
|
||||
result = self.create_device(device_id, data)
|
||||
elif action == "remove":
|
||||
result = self.destroy_device(device_id)
|
||||
else:
|
||||
result = {"success": False, "error": f"Unknown action: {action}"}
|
||||
|
||||
res.response = json.dumps(result, ensure_ascii=False)
|
||||
|
||||
except NotImplementedError as e:
|
||||
self.lab_logger().warning(f"[DeviceManage] {e}")
|
||||
res.response = json.dumps({"success": False, "error": str(e)})
|
||||
except Exception as e:
|
||||
self.lab_logger().error(f"[DeviceManage] Error: {e}")
|
||||
res.response = json.dumps({"success": False, "error": str(e)})
|
||||
|
||||
return res
|
||||
|
||||
def create_device(self, device_id: str, config: "ResourceDictType") -> dict:
|
||||
"""Create a sub-device dynamically. Override in HostNode / WorkstationNode."""
|
||||
raise NotImplementedError(
|
||||
f"{self.__class__.__name__} does not support dynamic device creation"
|
||||
)
|
||||
|
||||
def destroy_device(self, device_id: str) -> dict:
|
||||
"""Destroy a sub-device dynamically. Override in HostNode / WorkstationNode."""
|
||||
raise NotImplementedError(
|
||||
f"{self.__class__.__name__} does not support dynamic device removal"
|
||||
)
|
||||
|
||||
async def transfer_resource_to_another(
|
||||
self,
|
||||
plr_resources: List["ResourcePLR"],
|
||||
@@ -1232,7 +1107,6 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
"machine_name": BasicConfig.machine_name,
|
||||
"type": "slave",
|
||||
"edge_device_id": self.device_id,
|
||||
"registry_name": self.registry_name,
|
||||
}
|
||||
},
|
||||
ensure_ascii=False,
|
||||
@@ -1256,40 +1130,22 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
return self._lab_logger
|
||||
|
||||
def create_ros_publisher(self, attr_name, msg_type, initial_period=5.0):
|
||||
"""创建ROS发布者,仅当方法/属性有 @topic_config 装饰器时才创建。"""
|
||||
# 检测 @topic_config 装饰器配置
|
||||
"""创建ROS发布者"""
|
||||
# 检测装饰器配置(支持 get_{attr_name} 方法和 @property)
|
||||
topic_config = {}
|
||||
driver_class = type(self.driver_instance)
|
||||
|
||||
# 区分 @property 和普通方法两种情况
|
||||
is_prop = hasattr(driver_class, attr_name) and isinstance(
|
||||
getattr(driver_class, attr_name), property
|
||||
)
|
||||
# 优先检测 get_{attr_name} 方法
|
||||
if hasattr(self.driver_instance, f"get_{attr_name}"):
|
||||
getter_method = getattr(self.driver_instance, f"get_{attr_name}")
|
||||
topic_config = get_topic_config(getter_method)
|
||||
|
||||
if is_prop:
|
||||
# @property: 检测 fget 上的 @topic_config
|
||||
class_attr = getattr(driver_class, attr_name)
|
||||
if class_attr.fget is not None:
|
||||
topic_config = get_topic_config(class_attr.fget)
|
||||
else:
|
||||
# 普通方法: 直接检测 attr_name 方法上的 @topic_config
|
||||
if hasattr(self.driver_instance, attr_name):
|
||||
method = getattr(self.driver_instance, attr_name)
|
||||
if callable(method):
|
||||
topic_config = get_topic_config(method)
|
||||
|
||||
# 没有 @topic_config 装饰器则跳过发布
|
||||
# 如果没有配置,检测 @property 装饰的属性
|
||||
if not topic_config:
|
||||
return
|
||||
|
||||
# 发布名称优先级: @topic_config(name=...) > get_ 前缀去除 > attr_name
|
||||
cfg_name = topic_config.get("name")
|
||||
if cfg_name:
|
||||
publish_name = cfg_name
|
||||
elif attr_name.startswith("get_"):
|
||||
publish_name = attr_name[4:]
|
||||
else:
|
||||
publish_name = attr_name
|
||||
driver_class = type(self.driver_instance)
|
||||
if hasattr(driver_class, attr_name):
|
||||
class_attr = getattr(driver_class, attr_name)
|
||||
if isinstance(class_attr, property) and class_attr.fget is not None:
|
||||
topic_config = get_topic_config(class_attr.fget)
|
||||
|
||||
# 使用装饰器配置或默认值
|
||||
cfg_period = topic_config.get("period")
|
||||
@@ -1302,10 +1158,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
# 获取属性值的方法
|
||||
def get_device_attr():
|
||||
try:
|
||||
if is_prop:
|
||||
return getattr(self.driver_instance, attr_name)
|
||||
if hasattr(self.driver_instance, f"get_{attr_name}"):
|
||||
return getattr(self.driver_instance, f"get_{attr_name}")()
|
||||
else:
|
||||
return getattr(self.driver_instance, attr_name)()
|
||||
return getattr(self.driver_instance, attr_name)
|
||||
except AttributeError as ex:
|
||||
if ex.args[0].startswith(f"AttributeError: '{self.driver_instance.__class__.__name__}' object"):
|
||||
self.lab_logger().error(
|
||||
@@ -1317,8 +1173,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
)
|
||||
self.lab_logger().error(traceback.format_exc())
|
||||
|
||||
self._property_publishers[publish_name] = PropertyPublisher(
|
||||
self, publish_name, get_device_attr, msg_type, period, print_publish, qos
|
||||
self._property_publishers[attr_name] = PropertyPublisher(
|
||||
self, attr_name, get_device_attr, msg_type, period, print_publish, qos
|
||||
)
|
||||
|
||||
def create_ros_action_server(self, action_name, action_value_mapping):
|
||||
@@ -1326,17 +1182,14 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
action_type = action_value_mapping["type"]
|
||||
str_action_type = str(action_type)[8:-2]
|
||||
|
||||
try:
|
||||
self._action_servers[action_name] = ActionServer(
|
||||
self,
|
||||
action_type,
|
||||
action_name,
|
||||
execute_callback=self._create_execute_callback(action_name, action_value_mapping),
|
||||
callback_group=self.callback_group,
|
||||
)
|
||||
except Exception as e:
|
||||
self.lab_logger().error(f"创建ActionServer失败,Device: {self.device_id}, Action Name: {action_name}, Action Type: {action_type}, Error: {e}")
|
||||
return
|
||||
self._action_servers[action_name] = ActionServer(
|
||||
self,
|
||||
action_type,
|
||||
action_name,
|
||||
execute_callback=self._create_execute_callback(action_name, action_value_mapping),
|
||||
callback_group=self.callback_group,
|
||||
)
|
||||
|
||||
self.lab_logger().trace(f"发布动作: {action_name}, 类型: {str_action_type}")
|
||||
|
||||
def _setup_decorated_subscribers(self):
|
||||
@@ -1466,41 +1319,26 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
resource_inputs = action_kwargs[k] if is_sequence else [action_kwargs[k]]
|
||||
|
||||
# 批量查询资源
|
||||
queried_resources: list = [None] * len(resource_inputs)
|
||||
uuid_indices: list[tuple[int, str, dict]] = [] # (index, uuid, resource_data)
|
||||
|
||||
# 第一遍:处理没有uuid的资源,收集有uuid的资源信息
|
||||
for idx, resource_data in enumerate(resource_inputs):
|
||||
queried_resources = []
|
||||
for resource_data in resource_inputs:
|
||||
unilabos_uuid = resource_data.get("data", {}).get("unilabos_uuid")
|
||||
if unilabos_uuid is None:
|
||||
plr_resource = await self.get_resource_with_dir(
|
||||
resource_id=resource_data["id"], with_children=True
|
||||
)
|
||||
if "sample_id" in resource_data:
|
||||
plr_resource.unilabos_extra[EXTRA_SAMPLE_UUID] = resource_data["sample_id"]
|
||||
queried_resources[idx] = plr_resource
|
||||
else:
|
||||
uuid_indices.append((idx, unilabos_uuid, resource_data))
|
||||
|
||||
# 第二遍:批量查询有uuid的资源
|
||||
if uuid_indices:
|
||||
uuids = [item[1] for item in uuid_indices]
|
||||
resource_tree = await self.get_resource(uuids)
|
||||
plr_resources = resource_tree.to_plr_resources()
|
||||
for i, (idx, _, resource_data) in enumerate(uuid_indices):
|
||||
plr_resource = plr_resources[i]
|
||||
if "sample_id" in resource_data:
|
||||
plr_resource.unilabos_extra[EXTRA_SAMPLE_UUID] = resource_data["sample_id"]
|
||||
queried_resources[idx] = plr_resource
|
||||
resource_tree = await self.get_resource([unilabos_uuid])
|
||||
plr_resource = resource_tree.to_plr_resources()[0]
|
||||
if "sample_id" in resource_data:
|
||||
plr_resource.unilabos_extra["sample_uuid"] = resource_data["sample_id"]
|
||||
queried_resources.append(plr_resource)
|
||||
|
||||
self.lab_logger().debug(f"资源查询结果: 共 {len(queried_resources)} 个资源")
|
||||
|
||||
# 通过资源跟踪器获取本地实例
|
||||
final_resources = queried_resources if is_sequence else queried_resources[0]
|
||||
if not is_sequence:
|
||||
plr = self.resource_tracker.figure_resource(
|
||||
{"name": final_resources.name}, try_mode=False
|
||||
)
|
||||
plr = self.resource_tracker.figure_resource({"name": final_resources.name}, try_mode=False)
|
||||
# 保留unilabos_extra
|
||||
if hasattr(final_resources, "unilabos_extra") and hasattr(plr, "unilabos_extra"):
|
||||
plr.unilabos_extra = getattr(final_resources, "unilabos_extra", {}).copy()
|
||||
@@ -1539,12 +1377,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
execution_success = True
|
||||
except Exception as _:
|
||||
execution_error = traceback.format_exc()
|
||||
error(
|
||||
f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{str(action_kwargs)[:1000]}"
|
||||
)
|
||||
trace(
|
||||
f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}"
|
||||
)
|
||||
error(f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{str(action_kwargs)[:1000]}")
|
||||
trace(f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}")
|
||||
|
||||
future = ROS2DeviceNode.run_async_func(ACTION, trace_error=False, **action_kwargs)
|
||||
future.add_done_callback(_handle_future_exception)
|
||||
@@ -1564,11 +1398,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
except Exception as _:
|
||||
execution_error = traceback.format_exc()
|
||||
error(
|
||||
f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{str(action_kwargs)[:1000]}"
|
||||
)
|
||||
f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{str(action_kwargs)[:1000]}")
|
||||
trace(
|
||||
f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}"
|
||||
)
|
||||
f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}")
|
||||
|
||||
future.add_done_callback(_handle_future_exception)
|
||||
|
||||
@@ -1635,18 +1467,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
if isinstance(rs, list):
|
||||
for r in rs:
|
||||
res = self.resource_tracker.parent_resource(r) # 获取 resource 对象
|
||||
if res is None:
|
||||
res = rs
|
||||
if id(res) not in seen:
|
||||
seen.add(id(res))
|
||||
unique_resources.append(res)
|
||||
else:
|
||||
res = self.resource_tracker.parent_resource(rs)
|
||||
if res is None:
|
||||
res = rs
|
||||
if id(res) not in seen:
|
||||
seen.add(id(res))
|
||||
unique_resources.append(res)
|
||||
if id(res) not in seen:
|
||||
seen.add(id(res))
|
||||
unique_resources.append(res)
|
||||
|
||||
# 使用新的资源树接口
|
||||
if unique_resources:
|
||||
@@ -1698,37 +1523,20 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
try:
|
||||
function_name = target["function_name"]
|
||||
function_args = target["function_args"]
|
||||
# 获取 unilabos 系统参数
|
||||
unilabos_param: Dict[str, Any] = target[JSON_UNILABOS_PARAM]
|
||||
|
||||
assert isinstance(function_args, dict), "执行动作时JSON必须为dict类型\n原JSON: {string}"
|
||||
function = getattr(self.driver_instance, function_name)
|
||||
assert callable(
|
||||
function
|
||||
), f"执行动作时JSON中的function_name对应的函数不可调用: {function_name}\n原JSON: {string}"
|
||||
|
||||
# 处理参数(包含 unilabos 系统参数如 sample_uuids)
|
||||
args_list = default_manager._analyze_method_signature(function, skip_unilabos_params=False)["args"]
|
||||
# 处理 ResourceSlot 类型参数
|
||||
args_list = default_manager._analyze_method_signature(function)["args"]
|
||||
for arg in args_list:
|
||||
arg_name = arg["name"]
|
||||
arg_type = arg["type"]
|
||||
|
||||
# 跳过不在 function_args 中的参数
|
||||
if arg_name not in function_args:
|
||||
# 处理 sample_uuids 参数注入
|
||||
if arg_name == PARAM_SAMPLE_UUIDS:
|
||||
raw_sample_uuids = unilabos_param.get(PARAM_SAMPLE_UUIDS, {})
|
||||
# 将 material uuid 转换为 resource 实例
|
||||
# key: sample_uuid, value: material_uuid -> resource 实例
|
||||
resolved_sample_uuids: Dict[str, Any] = {}
|
||||
for sample_uuid, material_uuid in raw_sample_uuids.items():
|
||||
if material_uuid and self.resource_tracker:
|
||||
resource = self.resource_tracker.uuid_to_resources.get(material_uuid)
|
||||
resolved_sample_uuids[sample_uuid] = resource if resource else material_uuid
|
||||
else:
|
||||
resolved_sample_uuids[sample_uuid] = material_uuid
|
||||
function_args[PARAM_SAMPLE_UUIDS] = resolved_sample_uuids
|
||||
self.lab_logger().debug(f"[JsonCommand] 注入 {PARAM_SAMPLE_UUIDS}: {resolved_sample_uuids}")
|
||||
continue
|
||||
|
||||
# 处理单个 ResourceSlot
|
||||
@@ -1758,7 +1566,6 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
)
|
||||
raise JsonCommandInitError(f"ResourceSlot列表参数转换失败: {arg_name}")
|
||||
|
||||
# todo: 默认反报送
|
||||
return function(**function_args)
|
||||
except KeyError as ex:
|
||||
raise JsonCommandInitError(
|
||||
@@ -1778,23 +1585,21 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
raise ValueError("至少需要提供一个 UUID")
|
||||
|
||||
uuids_list = list(uuids)
|
||||
future = self._resource_clients["c2s_update_resource_tree"].call_async(
|
||||
SerialCommand.Request(
|
||||
command=json.dumps(
|
||||
{
|
||||
"data": {"data": uuids_list, "with_children": True},
|
||||
"action": "get",
|
||||
}
|
||||
)
|
||||
future = self._resource_clients["c2s_update_resource_tree"].call_async(SerialCommand.Request(
|
||||
command=json.dumps(
|
||||
{
|
||||
"data": {"data": uuids_list, "with_children": True},
|
||||
"action": "get",
|
||||
}
|
||||
)
|
||||
)
|
||||
))
|
||||
|
||||
# 等待结果(使用while循环,每次sleep 0.05秒,最多等待30秒)
|
||||
timeout = 30.0
|
||||
elapsed = 0.0
|
||||
while not future.done() and elapsed < timeout:
|
||||
time.sleep(0.02)
|
||||
elapsed += 0.02
|
||||
time.sleep(0.05)
|
||||
elapsed += 0.05
|
||||
|
||||
if not future.done():
|
||||
raise Exception(f"资源查询超时: {uuids_list}")
|
||||
@@ -1845,9 +1650,6 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
try:
|
||||
function_name = target["function_name"]
|
||||
function_args = target["function_args"]
|
||||
# 获取 unilabos 系统参数
|
||||
unilabos_param: Dict[str, Any] = target.get(JSON_UNILABOS_PARAM, {})
|
||||
|
||||
assert isinstance(function_args, dict), "执行动作时JSON必须为dict类型\n原JSON: {string}"
|
||||
function = getattr(self.driver_instance, function_name)
|
||||
assert callable(
|
||||
@@ -1857,30 +1659,14 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
function
|
||||
), f"执行动作时JSON中的function并非异步: {function_name}\n原JSON: {string}"
|
||||
|
||||
# 处理参数(包含 unilabos 系统参数如 sample_uuids)
|
||||
args_list = default_manager._analyze_method_signature(function, skip_unilabos_params=False)["args"]
|
||||
# 处理 ResourceSlot 类型参数
|
||||
args_list = default_manager._analyze_method_signature(function)["args"]
|
||||
for arg in args_list:
|
||||
arg_name = arg["name"]
|
||||
arg_type = arg["type"]
|
||||
|
||||
# 跳过不在 function_args 中的参数
|
||||
if arg_name not in function_args:
|
||||
# 处理 sample_uuids 参数注入
|
||||
if arg_name == PARAM_SAMPLE_UUIDS:
|
||||
raw_sample_uuids = unilabos_param.get(PARAM_SAMPLE_UUIDS, {})
|
||||
# 将 material uuid 转换为 resource 实例
|
||||
# key: sample_uuid, value: material_uuid -> resource 实例
|
||||
resolved_sample_uuids: Dict[str, Any] = {}
|
||||
for sample_uuid, material_uuid in raw_sample_uuids.items():
|
||||
if material_uuid and self.resource_tracker:
|
||||
resource = self.resource_tracker.uuid_to_resources.get(material_uuid)
|
||||
resolved_sample_uuids[sample_uuid] = resource if resource else material_uuid
|
||||
else:
|
||||
resolved_sample_uuids[sample_uuid] = material_uuid
|
||||
function_args[PARAM_SAMPLE_UUIDS] = resolved_sample_uuids
|
||||
self.lab_logger().debug(
|
||||
f"[JsonCommandAsync] 注入 {PARAM_SAMPLE_UUIDS}: {resolved_sample_uuids}"
|
||||
)
|
||||
continue
|
||||
|
||||
# 处理单个 ResourceSlot
|
||||
@@ -2105,7 +1891,6 @@ class ROS2DeviceNode:
|
||||
|
||||
if driver_is_ros:
|
||||
driver_params["device_id"] = device_id
|
||||
driver_params["registry_name"] = device_config.res_content.klass
|
||||
driver_params["resource_tracker"] = self.resource_tracker
|
||||
self._driver_instance = self._driver_creator.create_instance(driver_params)
|
||||
if self._driver_instance is None:
|
||||
@@ -2123,7 +1908,6 @@ class ROS2DeviceNode:
|
||||
children=children,
|
||||
driver_instance=self._driver_instance, # type: ignore
|
||||
device_id=device_id,
|
||||
registry_name=device_config.res_content.klass,
|
||||
device_uuid=device_uuid,
|
||||
status_types=status_types,
|
||||
action_value_mappings=action_value_mappings,
|
||||
@@ -2135,7 +1919,6 @@ class ROS2DeviceNode:
|
||||
self._ros_node = BaseROS2DeviceNode(
|
||||
driver_instance=self._driver_instance,
|
||||
device_id=device_id,
|
||||
registry_name=device_config.res_content.klass,
|
||||
device_uuid=device_uuid,
|
||||
status_types=status_types,
|
||||
action_value_mappings=action_value_mappings,
|
||||
@@ -2144,7 +1927,6 @@ class ROS2DeviceNode:
|
||||
resource_tracker=self.resource_tracker,
|
||||
)
|
||||
self._ros_node: BaseROS2DeviceNode
|
||||
# 将注册表类型名传递给BaseROS2DeviceNode,用于slave上报
|
||||
self._ros_node.lab_logger().info(f"初始化完成 {self._ros_node.uuid} {self.driver_is_ros}")
|
||||
self.driver_instance._ros_node = self._ros_node # type: ignore
|
||||
self.driver_instance._execute_driver_command = self._ros_node._execute_driver_command # type: ignore
|
||||
@@ -2162,9 +1944,7 @@ class ROS2DeviceNode:
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_forever()
|
||||
|
||||
ROS2DeviceNode._asyncio_loop_thread = threading.Thread(
|
||||
target=run_event_loop, daemon=True, name="ROS2DeviceNode"
|
||||
)
|
||||
ROS2DeviceNode._asyncio_loop_thread = threading.Thread(target=run_event_loop, daemon=True, name="ROS2DeviceNode")
|
||||
ROS2DeviceNode._asyncio_loop_thread.start()
|
||||
logger.info(f"循环线程已启动")
|
||||
|
||||
|
||||
@@ -4,22 +4,14 @@ import cv2
|
||||
from sensor_msgs.msg import Image
|
||||
from cv_bridge import CvBridge
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, DeviceNodeResourceTracker
|
||||
from unilabos.registry.decorators import device
|
||||
|
||||
|
||||
@device(
|
||||
id="camera",
|
||||
category=["camera"],
|
||||
description="""VideoPublisher摄像头设备节点,用于实时视频采集和流媒体发布。该设备通过OpenCV连接本地摄像头(如USB摄像头、内置摄像头等),定时采集视频帧并将其转换为ROS2的sensor_msgs/Image消息格式发布到视频话题。主要用于实验室自动化系统中的视觉监控、图像分析、实时观察等应用场景。支持可配置的摄像头索引、发布频率等参数。""",
|
||||
)
|
||||
class VideoPublisher(BaseROS2DeviceNode):
|
||||
def __init__(self, device_id='video_publisher', registry_name="", device_uuid='', camera_index=0, period: float = 0.1, resource_tracker: DeviceNodeResourceTracker = None):
|
||||
def __init__(self, device_id='video_publisher', device_uuid='', camera_index=0, period: float = 0.1, resource_tracker: DeviceNodeResourceTracker = None):
|
||||
# 初始化BaseROS2DeviceNode,使用自身作为driver_instance
|
||||
BaseROS2DeviceNode.__init__(
|
||||
self,
|
||||
driver_instance=self,
|
||||
device_id=device_id,
|
||||
registry_name=registry_name,
|
||||
device_uuid=device_uuid,
|
||||
status_types={},
|
||||
action_value_mappings={},
|
||||
|
||||
@@ -10,7 +10,6 @@ class ControllerNode(BaseROS2DeviceNode):
|
||||
def __init__(
|
||||
self,
|
||||
device_id: str,
|
||||
registry_name: str,
|
||||
controller_func: Callable,
|
||||
update_rate: float,
|
||||
inputs: Dict[str, Dict[str, type | str]],
|
||||
@@ -52,7 +51,6 @@ class ControllerNode(BaseROS2DeviceNode):
|
||||
self,
|
||||
driver_instance=self,
|
||||
device_id=device_id,
|
||||
registry_name=registry_name,
|
||||
status_types=status_types,
|
||||
action_value_mappings=action_value_mappings,
|
||||
hardware_interface=hardware_interface,
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import collections
|
||||
from dataclasses import dataclass, field
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Optional, Dict, Any, List, ClassVar, Set, Union
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from action_msgs.msg import GoalStatus
|
||||
from geometry_msgs.msg import Point
|
||||
from rclpy.action import ActionClient, get_action_server_names_and_types_by_node
|
||||
from rclpy.service import Service
|
||||
from typing_extensions import TypedDict
|
||||
from unilabos_msgs.action import EmptyIn, StrSingleInput, ResourceCreateFromOuterEasy, ResourceCreateFromOuter
|
||||
from unilabos_msgs.msg import Resource # type: ignore
|
||||
from unilabos_msgs.srv import (
|
||||
ResourceAdd,
|
||||
@@ -24,22 +23,10 @@ from unilabos_msgs.srv import (
|
||||
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response
|
||||
from unique_identifier_msgs.msg import UUID
|
||||
|
||||
from unilabos.registry.decorators import device
|
||||
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
|
||||
from unilabos.registry.registry import lab_registry
|
||||
from unilabos.resources.container import RegularContainer
|
||||
from unilabos.resources.graphio import initialize_resource
|
||||
from unilabos.resources.registry import add_schema
|
||||
from unilabos.resources.resource_tracker import (
|
||||
ResourceDict,
|
||||
ResourceDictType,
|
||||
ResourceDictInstance,
|
||||
ResourceTreeSet,
|
||||
ResourceTreeInstance,
|
||||
RETURN_UNILABOS_SAMPLES,
|
||||
JSON_UNILABOS_PARAM,
|
||||
PARAM_SAMPLE_UUIDS, SampleUUIDsType, LabSample,
|
||||
)
|
||||
from unilabos.ros.initialize_device import initialize_device_from_dict
|
||||
from unilabos.ros.msgs.message_converter import (
|
||||
get_msg_type,
|
||||
@@ -50,11 +37,17 @@ from unilabos.ros.msgs.message_converter import (
|
||||
)
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode, DeviceNodeResourceTracker
|
||||
from unilabos.ros.nodes.presets.controller_node import ControllerNode
|
||||
from unilabos.resources.resource_tracker import (
|
||||
ResourceDict,
|
||||
ResourceDictInstance,
|
||||
ResourceTreeSet,
|
||||
ResourceTreeInstance,
|
||||
)
|
||||
from unilabos.utils import logger
|
||||
from unilabos.utils.exception import DeviceClassInvalid
|
||||
from unilabos.utils.log import warning
|
||||
from unilabos.utils.type_check import serialize_result_info
|
||||
from unilabos.config.config import BasicConfig
|
||||
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from unilabos.app.ws_client import QueueItem
|
||||
@@ -67,14 +60,7 @@ class DeviceActionStatus:
|
||||
|
||||
class TestResourceReturn(TypedDict):
|
||||
resources: List[List[ResourceDict]]
|
||||
devices: List[Dict[str, Any]]
|
||||
# unilabos_samples: List[LabSample]
|
||||
|
||||
|
||||
class CreateResourceReturn(TypedDict):
|
||||
created_resource_tree: List[List[ResourceDict]]
|
||||
liquid_input_resource_tree: List[Dict[str, Any]]
|
||||
# unilabos_samples: List[LabSample]
|
||||
devices: List[DeviceSlot]
|
||||
|
||||
|
||||
class TestLatencyReturn(TypedDict):
|
||||
@@ -89,7 +75,6 @@ class TestLatencyReturn(TypedDict):
|
||||
status: str
|
||||
|
||||
|
||||
@device(id="host_node", category=[], description="Host Node", icon="icon_device.webp")
|
||||
class HostNode(BaseROS2DeviceNode):
|
||||
"""
|
||||
主机节点类,负责管理设备、资源和控制器
|
||||
@@ -260,7 +245,6 @@ class HostNode(BaseROS2DeviceNode):
|
||||
self,
|
||||
driver_instance=self,
|
||||
device_id=device_id,
|
||||
registry_name="host_node",
|
||||
device_uuid=host_node_dict["uuid"],
|
||||
status_types={},
|
||||
action_value_mappings=lab_registry.device_type_registry["host_node"]["class"]["action_value_mappings"],
|
||||
@@ -278,43 +262,44 @@ class HostNode(BaseROS2DeviceNode):
|
||||
self._action_clients: Dict[str, ActionClient] = { # 为了方便了解实际的数据类型,host的默认写好
|
||||
"/devices/host_node/create_resource": ActionClient(
|
||||
self,
|
||||
ResourceCreateFromOuterEasy,
|
||||
lab_registry.ResourceCreateFromOuterEasy,
|
||||
"/devices/host_node/create_resource",
|
||||
callback_group=self.callback_group,
|
||||
),
|
||||
"/devices/host_node/create_resource_detailed": ActionClient(
|
||||
self,
|
||||
ResourceCreateFromOuter,
|
||||
lab_registry.ResourceCreateFromOuter,
|
||||
"/devices/host_node/create_resource_detailed",
|
||||
callback_group=self.callback_group,
|
||||
),
|
||||
"/devices/host_node/test_latency": ActionClient(
|
||||
self,
|
||||
EmptyIn,
|
||||
lab_registry.EmptyIn,
|
||||
"/devices/host_node/test_latency",
|
||||
callback_group=self.callback_group,
|
||||
),
|
||||
"/devices/host_node/test_resource": ActionClient(
|
||||
self,
|
||||
EmptyIn,
|
||||
lab_registry.EmptyIn,
|
||||
"/devices/host_node/test_resource",
|
||||
callback_group=self.callback_group,
|
||||
),
|
||||
"/devices/host_node/_execute_driver_command": ActionClient(
|
||||
self,
|
||||
StrSingleInput,
|
||||
lab_registry.StrSingleInput,
|
||||
"/devices/host_node/_execute_driver_command",
|
||||
callback_group=self.callback_group,
|
||||
),
|
||||
"/devices/host_node/_execute_driver_command_async": ActionClient(
|
||||
self,
|
||||
StrSingleInput,
|
||||
lab_registry.StrSingleInput,
|
||||
"/devices/host_node/_execute_driver_command_async",
|
||||
callback_group=self.callback_group,
|
||||
),
|
||||
} # 用来存储多个ActionClient实例
|
||||
self._action_value_mappings: Dict[str, Dict] = {} # device_id -> action_value_mappings(本地+远程设备统一存储)
|
||||
self._slave_registry_configs: Dict[str, Dict] = {} # registry_name -> registry_config(含action_value_mappings)
|
||||
self._action_value_mappings: Dict[str, Dict] = (
|
||||
{}
|
||||
) # 用来存储多个ActionClient的type, goal, feedback, result的变量名映射关系
|
||||
self._goals: Dict[str, Any] = {} # 用来存储多个目标的状态
|
||||
self._online_devices: Set[str] = {f"{self.namespace}/{device_id}"} # 用于跟踪在线设备
|
||||
self._last_discovery_time = 0.0 # 上次设备发现的时间
|
||||
@@ -331,18 +316,10 @@ class HostNode(BaseROS2DeviceNode):
|
||||
self._discover_devices()
|
||||
|
||||
# 初始化所有本机设备节点,多一次过滤,防止重复初始化
|
||||
local_machine = BasicConfig.machine_name
|
||||
for device_config in devices_config.root_nodes:
|
||||
device_id = device_config.res_content.id
|
||||
if device_config.res_content.type != "device":
|
||||
continue
|
||||
dev_machine = device_config.res_content.machine_name
|
||||
if dev_machine and local_machine and dev_machine != local_machine:
|
||||
self.lab_logger().info(
|
||||
f"[Host Node] Device {device_id} belongs to machine '{dev_machine}', "
|
||||
f"local is '{local_machine}', skipping initialization."
|
||||
)
|
||||
continue
|
||||
if device_id not in self.devices_names:
|
||||
self.initialize_device(device_id, device_config)
|
||||
else:
|
||||
@@ -572,7 +549,7 @@ class HostNode(BaseROS2DeviceNode):
|
||||
liquid_type: list[str] = [],
|
||||
liquid_volume: list[int] = [],
|
||||
slot_on_deck: str = "",
|
||||
) -> CreateResourceReturn:
|
||||
):
|
||||
# 暂不支持多对同名父子同时存在
|
||||
res_creation_input = {
|
||||
"id": res_id.split("/")[-1],
|
||||
@@ -625,8 +602,6 @@ class HostNode(BaseROS2DeviceNode):
|
||||
assert len(response) == 1, "Create Resource应当只返回一个结果"
|
||||
for i in response:
|
||||
res = json.loads(i)
|
||||
if "suc" in res:
|
||||
raise ValueError(res.get("error"))
|
||||
return res
|
||||
except Exception as ex:
|
||||
pass
|
||||
@@ -658,8 +633,6 @@ class HostNode(BaseROS2DeviceNode):
|
||||
self.device_machine_names[device_id] = "本地"
|
||||
self.devices_instances[device_id] = d
|
||||
# noinspection PyProtectedMember
|
||||
self._action_value_mappings[device_id] = d._ros_node._action_value_mappings
|
||||
# noinspection PyProtectedMember
|
||||
for action_name, action_value_mapping in d._ros_node._action_value_mappings.items():
|
||||
if action_name.startswith("auto-") or str(action_value_mapping.get("type", "")).startswith(
|
||||
"UniLabJsonCommand"
|
||||
@@ -668,12 +641,7 @@ class HostNode(BaseROS2DeviceNode):
|
||||
action_id = f"/devices/{device_id}/{action_name}"
|
||||
if action_id not in self._action_clients:
|
||||
action_type = action_value_mapping["type"]
|
||||
try:
|
||||
self._action_clients[action_id] = ActionClient(self, action_type, action_id)
|
||||
except Exception as e:
|
||||
self.lab_logger().error(
|
||||
f"创建ActionClient失败,Device: {device_id}, Action Name: {action_name}, Action Type: {action_type}, Error: {e}")
|
||||
continue
|
||||
self._action_clients[action_id] = ActionClient(self, action_type, action_id)
|
||||
self.lab_logger().trace(
|
||||
f"[Host Node] Created ActionClient (Local): {action_id}"
|
||||
) # 子设备再创建用的是Discover发现的
|
||||
@@ -787,7 +755,6 @@ class HostNode(BaseROS2DeviceNode):
|
||||
item: "QueueItem",
|
||||
action_type: str,
|
||||
action_kwargs: Dict[str, Any],
|
||||
sample_material: Dict[str, str],
|
||||
server_info: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
@@ -801,29 +768,18 @@ class HostNode(BaseROS2DeviceNode):
|
||||
u = uuid.UUID(item.job_id)
|
||||
device_id = item.device_id
|
||||
action_name = item.action_name
|
||||
|
||||
if BasicConfig.test_mode:
|
||||
action_id = f"/devices/{device_id}/{action_name}"
|
||||
self.lab_logger().info(
|
||||
f"[TEST MODE] 模拟执行: {action_id} (job={item.job_id[:8]}), 参数: {str(action_kwargs)[:500]}"
|
||||
)
|
||||
# 根据注册表 handles 构建模拟返回值
|
||||
mock_return = self._build_test_mode_return(device_id, action_name, action_kwargs)
|
||||
self._handle_test_mode_result(item, action_id, mock_return)
|
||||
return
|
||||
|
||||
if action_type.startswith("UniLabJsonCommand"):
|
||||
if action_name.startswith("auto-"):
|
||||
action_name = action_name[5:]
|
||||
action_id = f"/devices/{device_id}/_execute_driver_command"
|
||||
json_command: Dict[str, Any] = {
|
||||
"function_name": action_name,
|
||||
"function_args": action_kwargs,
|
||||
JSON_UNILABOS_PARAM: {
|
||||
PARAM_SAMPLE_UUIDS: sample_material,
|
||||
},
|
||||
action_kwargs = {
|
||||
"string": json.dumps(
|
||||
{
|
||||
"function_name": action_name,
|
||||
"function_args": action_kwargs,
|
||||
}
|
||||
)
|
||||
}
|
||||
action_kwargs = {"string": json.dumps(json_command)}
|
||||
if action_type.startswith("UniLabJsonCommandAsync"):
|
||||
action_id = f"/devices/{device_id}/_execute_driver_command_async"
|
||||
else:
|
||||
@@ -834,10 +790,24 @@ class HostNode(BaseROS2DeviceNode):
|
||||
raise ValueError(f"ActionClient {action_id} not found.")
|
||||
|
||||
action_client: ActionClient = self._action_clients[action_id]
|
||||
|
||||
# 遍历action_kwargs下的所有子dict,将"sample_uuid"的值赋给"sample_id"
|
||||
def assign_sample_id(obj):
|
||||
if isinstance(obj, dict):
|
||||
if "sample_uuid" in obj:
|
||||
obj["sample_id"] = obj["sample_uuid"]
|
||||
obj.pop("sample_uuid")
|
||||
for k, v in obj.items():
|
||||
if k != "unilabos_extra":
|
||||
assign_sample_id(v)
|
||||
elif isinstance(obj, list):
|
||||
for item in obj:
|
||||
assign_sample_id(item)
|
||||
|
||||
assign_sample_id(action_kwargs)
|
||||
goal_msg = convert_to_ros_msg(action_client._action_type.Goal(), action_kwargs)
|
||||
|
||||
# self.lab_logger().trace(f"[Host Node] Sending goal for {action_id}: {str(goal_msg)[:1000]}")
|
||||
self.lab_logger().trace(f"[Host Node] Sending goal for {action_id}: {action_kwargs}")
|
||||
self.lab_logger().info(f"[Host Node] Sending goal for {action_id}: {str(goal_msg)[:1000]}")
|
||||
self.lab_logger().trace(f"[Host Node] Sending goal for {action_id}: {goal_msg}")
|
||||
action_client.wait_for_server()
|
||||
goal_uuid_obj = UUID(uuid=list(u.bytes))
|
||||
@@ -849,51 +819,6 @@ class HostNode(BaseROS2DeviceNode):
|
||||
)
|
||||
future.add_done_callback(lambda f: self.goal_response_callback(item, action_id, f))
|
||||
|
||||
def _build_test_mode_return(
|
||||
self, device_id: str, action_name: str, action_kwargs: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
根据注册表 handles 的 output 定义构建测试模式的模拟返回值
|
||||
|
||||
根据 data_key 中 @flatten 的层数决定嵌套数组层数,叶子值为空字典。
|
||||
例如: "vessel" → {}, "plate.@flatten" → [{}], "a.@flatten.@flatten" → [[{}]]
|
||||
"""
|
||||
mock_return: Dict[str, Any] = {"test_mode": True, "action_name": action_name}
|
||||
action_mappings = self._action_value_mappings.get(device_id, {})
|
||||
action_mapping = action_mappings.get(action_name, {})
|
||||
handles = action_mapping.get("handles", {})
|
||||
if isinstance(handles, dict):
|
||||
for output_handle in handles.get("output", []):
|
||||
data_key = output_handle.get("data_key", "")
|
||||
handler_key = output_handle.get("handler_key", "")
|
||||
# 根据 @flatten 层数构建嵌套数组,叶子为空字典
|
||||
flatten_count = data_key.count("@flatten")
|
||||
value: Any = {}
|
||||
for _ in range(flatten_count):
|
||||
value = [value]
|
||||
mock_return[handler_key] = value
|
||||
return mock_return
|
||||
|
||||
def _handle_test_mode_result(
|
||||
self, item: "QueueItem", action_id: str, mock_return: Dict[str, Any]
|
||||
) -> None:
|
||||
"""
|
||||
测试模式下直接构建结果并走正常的结果回调流程(跳过 ROS)
|
||||
"""
|
||||
job_id = item.job_id
|
||||
status = "success"
|
||||
return_info = serialize_result_info("", True, mock_return)
|
||||
|
||||
self.lab_logger().info(f"[TEST MODE] Result for {action_id} ({job_id[:8]}): {status}")
|
||||
|
||||
from unilabos.app.web.controller import store_job_result
|
||||
store_job_result(job_id, status, return_info, mock_return)
|
||||
|
||||
# 发布状态到桥接器
|
||||
for bridge in self.bridges:
|
||||
if hasattr(bridge, "publish_job_status"):
|
||||
bridge.publish_job_status(mock_return, item, status, return_info)
|
||||
|
||||
def goal_response_callback(self, item: "QueueItem", action_id: str, future) -> None:
|
||||
"""目标响应回调"""
|
||||
goal_handle = future.result()
|
||||
@@ -941,14 +866,14 @@ class HostNode(BaseROS2DeviceNode):
|
||||
# 适配后端的一些额外处理
|
||||
return_value = return_info.get("return_value")
|
||||
if isinstance(return_value, dict):
|
||||
unilabos_samples = return_value.pop(RETURN_UNILABOS_SAMPLES, None)
|
||||
unilabos_samples = return_value.pop("unilabos_samples", None)
|
||||
if isinstance(unilabos_samples, list) and unilabos_samples:
|
||||
self.lab_logger().info(
|
||||
f"[Host Node] Job {job_id[:8]} returned {len(unilabos_samples)} sample(s): "
|
||||
f"{[s.get('name', s.get('id', 'unknown')) if isinstance(s, dict) else str(s)[:20] for s in unilabos_samples[:5]]}"
|
||||
f"{'...' if len(unilabos_samples) > 5 else ''}"
|
||||
)
|
||||
return_info["samples"] = unilabos_samples
|
||||
return_info["unilabos_samples"] = unilabos_samples
|
||||
suc = return_info.get("suc", False)
|
||||
if not suc:
|
||||
status = "failed"
|
||||
@@ -1218,7 +1143,7 @@ class HostNode(BaseROS2DeviceNode):
|
||||
self.lab_logger().info(f"[Host Node-Resource] UUID映射: {len(uuid_mapping)} 个节点")
|
||||
# 还需要加入到资源图中,暂不实现,考虑资源图新的获取方式
|
||||
response.response = json.dumps(uuid_mapping)
|
||||
self.lab_logger().info(f"[Host Node-Resource] Resource tree update completed, success: {success}")
|
||||
self.lab_logger().info(f"[Host Node-Resource] Resource tree add completed, success: {success}")
|
||||
|
||||
async def _resource_tree_update_callback(self, request: SerialCommand_Request, response: SerialCommand_Response):
|
||||
"""
|
||||
@@ -1253,12 +1178,8 @@ class HostNode(BaseROS2DeviceNode):
|
||||
def _node_info_update_callback(self, request, response):
|
||||
"""
|
||||
更新节点信息回调
|
||||
|
||||
处理两种消息:
|
||||
1. 首次上报(main_slave_run): 带 devices_config + registry_config,存储 action_value_mappings
|
||||
2. 设备重注册(SYNC_SLAVE_NODE_INFO): 带 edge_device_id + registry_name,用 registry_name 索引已存储的 mappings
|
||||
"""
|
||||
self.lab_logger().trace(f"[Host Node] Node info update request received: {request}")
|
||||
# self.lab_logger().info(f"[Host Node] Node info update request received: {request}")
|
||||
try:
|
||||
from unilabos.app.communication import get_communication_client
|
||||
from unilabos.app.web.client import HTTPClient, http_client
|
||||
@@ -1268,65 +1189,12 @@ class HostNode(BaseROS2DeviceNode):
|
||||
info = info["SYNC_SLAVE_NODE_INFO"]
|
||||
machine_name = info["machine_name"]
|
||||
edge_device_id = info["edge_device_id"]
|
||||
registry_name = info.get("registry_name", "")
|
||||
self.device_machine_names[edge_device_id] = machine_name
|
||||
|
||||
# 用 registry_name 索引已存储的 registry_config,获取 action_value_mappings
|
||||
if registry_name and registry_name in self._slave_registry_configs:
|
||||
action_mappings = (
|
||||
self._slave_registry_configs[registry_name].get("class", {}).get("action_value_mappings", {})
|
||||
)
|
||||
if action_mappings:
|
||||
self._action_value_mappings[edge_device_id] = action_mappings
|
||||
self.lab_logger().info(
|
||||
f"[Host Node] Loaded {len(action_mappings)} action mappings "
|
||||
f"for remote device {edge_device_id} (registry: {registry_name})"
|
||||
)
|
||||
else:
|
||||
devices_config = info.pop("devices_config")
|
||||
registry_config = info.pop("registry_config")
|
||||
if registry_config:
|
||||
http_client.resource_registry({"resources": registry_config})
|
||||
|
||||
# 存储 slave 的 registry_config,用于后续 SYNC_SLAVE_NODE_INFO 索引
|
||||
for reg_name, reg_data in registry_config.items():
|
||||
if isinstance(reg_data, dict) and "class" in reg_data:
|
||||
self._slave_registry_configs[reg_name] = reg_data
|
||||
|
||||
# 解析 devices_config,建立 device_id -> action_value_mappings 映射
|
||||
if devices_config:
|
||||
machine_name = info["machine_name"]
|
||||
# Stamp machine_name on each device dict before parsing
|
||||
for device_tree in devices_config:
|
||||
for device_dict in device_tree:
|
||||
device_dict["machine_name"] = machine_name
|
||||
device_id = device_dict.get("id", "")
|
||||
class_name = device_dict.get("class", "")
|
||||
if device_id and class_name and class_name in self._slave_registry_configs:
|
||||
action_mappings = (
|
||||
self._slave_registry_configs[class_name]
|
||||
.get("class", {})
|
||||
.get("action_value_mappings", {})
|
||||
)
|
||||
if action_mappings:
|
||||
self._action_value_mappings[device_id] = action_mappings
|
||||
self.lab_logger().info(
|
||||
f"[Host Node] Stored {len(action_mappings)} action mappings "
|
||||
f"for remote device {device_id} (class: {class_name})"
|
||||
)
|
||||
|
||||
# Merge slave devices_config into self.devices_config tree
|
||||
try:
|
||||
slave_tree_set = ResourceTreeSet.load(devices_config) # slave一定是根节点的tree
|
||||
for tree in slave_tree_set.trees:
|
||||
self.devices_config.trees.append(tree)
|
||||
self.lab_logger().info(
|
||||
f"[Host Node] Merged {len(slave_tree_set.trees)} slave device trees "
|
||||
f"(machine: {machine_name}) into devices_config"
|
||||
)
|
||||
except Exception as e:
|
||||
self.lab_logger().error(f"[Host Node] Failed to merge slave devices_config: {e}")
|
||||
|
||||
self.lab_logger().debug(f"[Host Node] Node info update: {info}")
|
||||
response.response = "OK"
|
||||
except Exception as e:
|
||||
@@ -1623,7 +1491,6 @@ class HostNode(BaseROS2DeviceNode):
|
||||
|
||||
def test_resource(
|
||||
self,
|
||||
sample_uuids: SampleUUIDsType,
|
||||
resource: ResourceSlot = None,
|
||||
resources: List[ResourceSlot] = None,
|
||||
device: DeviceSlot = None,
|
||||
@@ -1638,7 +1505,6 @@ class HostNode(BaseROS2DeviceNode):
|
||||
return {
|
||||
"resources": ResourceTreeSet.from_plr_resources([resource, *resources], known_newly_created=True).dump(),
|
||||
"devices": [device, *devices],
|
||||
"unilabos_samples": [LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for sample_uuid, content in sample_uuids.items()]
|
||||
}
|
||||
|
||||
def handle_pong_response(self, pong_data: dict):
|
||||
@@ -1735,177 +1601,3 @@ class HostNode(BaseROS2DeviceNode):
|
||||
self.lab_logger().error(f"[Host Node-Resource] Error notifying resource tree update: {str(e)}")
|
||||
self.lab_logger().error(traceback.format_exc())
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Device lifecycle (add / remove) — pure forwarder
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def notify_device_manage(self, target_node_id: str, action: str, config: ResourceDictType) -> bool:
|
||||
"""Forward an add/remove device command to the target node via ROS2 SerialCommand.
|
||||
|
||||
The HostNode does NOT interpret the command; it simply resolves the
|
||||
target namespace and forwards the request to ``s2c_device_manage``.
|
||||
|
||||
If *target_node_id* equals the HostNode's own device_id (i.e. the
|
||||
command targets the host itself), we call our local ``create_device``
|
||||
/ ``destroy_device`` directly instead of going through ROS2.
|
||||
"""
|
||||
try:
|
||||
# If the target is the host itself, handle locally
|
||||
device_id = config["id"]
|
||||
if target_node_id == self.device_id:
|
||||
if action == "add":
|
||||
return self.create_device(device_id, config).get("success", False)
|
||||
elif action == "remove":
|
||||
return self.destroy_device(device_id).get("success", False)
|
||||
|
||||
if target_node_id not in self.devices_names:
|
||||
self.lab_logger().error(
|
||||
f"[Host Node-DeviceMgr] Target {target_node_id} not found in devices_names"
|
||||
)
|
||||
return False
|
||||
|
||||
namespace = self.devices_names[target_node_id]
|
||||
device_key = f"{namespace}/{target_node_id}"
|
||||
if device_key not in self._online_devices:
|
||||
self.lab_logger().error(f"[Host Node-DeviceMgr] Target {device_key} is offline")
|
||||
return False
|
||||
|
||||
srv_address = f"/srv{namespace}/s2c_device_manage"
|
||||
self.lab_logger().info(
|
||||
f"[Host Node-DeviceMgr] Forwarding {action}_device to {target_node_id} ({srv_address})"
|
||||
)
|
||||
|
||||
sclient = self.create_client(SerialCommand, srv_address)
|
||||
if not sclient.wait_for_service(timeout_sec=5.0):
|
||||
self.lab_logger().error(f"[Host Node-DeviceMgr] Service {srv_address} not available")
|
||||
return False
|
||||
|
||||
request = SerialCommand.Request()
|
||||
request.command = json.dumps({"action": action, "data": config}, ensure_ascii=False)
|
||||
|
||||
future = sclient.call_async(request)
|
||||
timeout = 30.0
|
||||
start_time = time.time()
|
||||
while not future.done():
|
||||
if time.time() - start_time > timeout:
|
||||
self.lab_logger().error(
|
||||
f"[Host Node-DeviceMgr] Timeout waiting for {action}_device on {target_node_id}"
|
||||
)
|
||||
return False
|
||||
time.sleep(0.05)
|
||||
|
||||
response = future.result()
|
||||
self.lab_logger().info(
|
||||
f"[Host Node-DeviceMgr] {action}_device on {target_node_id} completed"
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.lab_logger().error(f"[Host Node-DeviceMgr] Error: {e}")
|
||||
self.lab_logger().error(traceback.format_exc())
|
||||
return False
|
||||
|
||||
def create_device(self, device_id: str, config: ResourceDictType) -> dict:
|
||||
"""Dynamically create a root-level device on the host."""
|
||||
if not device_id:
|
||||
return {"success": False, "error": "device_id required"}
|
||||
|
||||
if device_id in self.devices_names:
|
||||
return {"success": False, "error": f"Device {device_id} already exists"}
|
||||
|
||||
try:
|
||||
config.setdefault("id", device_id)
|
||||
config.setdefault("type", "device")
|
||||
config.setdefault("machine_name", BasicConfig.machine_name or "本地")
|
||||
res_dict = ResourceDictInstance.get_resource_instance_from_dict(config)
|
||||
|
||||
self.initialize_device(device_id, res_dict)
|
||||
|
||||
if device_id not in self.devices_names:
|
||||
return {"success": False, "error": f"initialize_device failed for {device_id}"}
|
||||
|
||||
# Add to config tree (devices_config)
|
||||
tree = ResourceTreeInstance(res_dict)
|
||||
self.devices_config.trees.append(tree)
|
||||
|
||||
# Add to resource tracker so s2c_resource_tree can find it
|
||||
try:
|
||||
for plr_resource in ResourceTreeSet([tree]).to_plr_resources():
|
||||
self._resource_tracker.add_resource(plr_resource)
|
||||
except Exception as ex:
|
||||
self.lab_logger().warning(f"[Host Node-DeviceMgr] PLR resource registration skipped: {ex}")
|
||||
|
||||
self.lab_logger().info(f"[Host Node-DeviceMgr] Device {device_id} created successfully")
|
||||
return {"success": True, "device_id": device_id}
|
||||
|
||||
except Exception as e:
|
||||
self.lab_logger().error(f"[Host Node-DeviceMgr] Failed to create {device_id}: {e}")
|
||||
self.lab_logger().error(traceback.format_exc())
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def destroy_device(self, device_id: str) -> dict:
|
||||
"""Remove a root-level device from the host."""
|
||||
if not device_id:
|
||||
return {"success": False, "error": "device_id required"}
|
||||
|
||||
if device_id not in self.devices_names:
|
||||
return {"success": False, "error": f"Device {device_id} not found"}
|
||||
|
||||
if device_id == self.device_id:
|
||||
return {"success": False, "error": "Cannot destroy host_node itself"}
|
||||
|
||||
try:
|
||||
namespace = self.devices_names[device_id]
|
||||
device_key = f"{namespace}/{device_id}"
|
||||
|
||||
# Remove action clients
|
||||
action_prefix = f"/devices/{device_id}/"
|
||||
to_remove = [k for k in self._action_clients if k.startswith(action_prefix)]
|
||||
for k in to_remove:
|
||||
try:
|
||||
self._action_clients[k].destroy()
|
||||
except Exception:
|
||||
pass
|
||||
del self._action_clients[k]
|
||||
|
||||
# Remove from config tree (devices_config)
|
||||
self.devices_config.trees = [
|
||||
t for t in self.devices_config.trees
|
||||
if t.root_node.res_content.id != device_id
|
||||
]
|
||||
|
||||
# Remove from resource tracker
|
||||
try:
|
||||
tracked = self._resource_tracker.uuid_to_resources.copy()
|
||||
for uid, res in tracked.items():
|
||||
res_id = res.get("id") if isinstance(res, dict) else getattr(res, "name", None)
|
||||
if res_id == device_id:
|
||||
self._resource_tracker.remove_resource(res)
|
||||
except Exception as ex:
|
||||
self.lab_logger().warning(f"[Host Node-DeviceMgr] Resource tracker cleanup: {ex}")
|
||||
|
||||
# Clean internal state
|
||||
self._online_devices.discard(device_key)
|
||||
self.devices_names.pop(device_id, None)
|
||||
self.device_machine_names.pop(device_id, None)
|
||||
self._action_value_mappings.pop(device_id, None)
|
||||
|
||||
# Destroy the ROS2 node of the device
|
||||
instance = self.devices_instances.pop(device_id, None)
|
||||
if instance is not None:
|
||||
try:
|
||||
# noinspection PyProtectedMember
|
||||
ros_node = getattr(instance, "_ros_node", None)
|
||||
if ros_node is not None:
|
||||
ros_node.destroy_node()
|
||||
except Exception as e:
|
||||
self.lab_logger().warning(f"[Host Node-DeviceMgr] Error destroying ROS node for {device_id}: {e}")
|
||||
|
||||
self.lab_logger().info(f"[Host Node-DeviceMgr] Device {device_id} destroyed")
|
||||
return {"success": True, "device_id": device_id}
|
||||
|
||||
except Exception as e:
|
||||
self.lab_logger().error(f"[Host Node-DeviceMgr] Failed to destroy {device_id}: {e}")
|
||||
self.lab_logger().error(traceback.format_exc())
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
@@ -7,11 +7,10 @@ from rclpy.callback_groups import ReentrantCallbackGroup
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
|
||||
class JointRepublisher(BaseROS2DeviceNode):
|
||||
def __init__(self,device_id, registry_name, resource_tracker, **kwargs):
|
||||
def __init__(self,device_id,resource_tracker, **kwargs):
|
||||
super().__init__(
|
||||
driver_instance=self,
|
||||
device_id=device_id,
|
||||
registry_name=registry_name,
|
||||
status_types={},
|
||||
action_value_mappings={},
|
||||
hardware_interface={},
|
||||
|
||||
@@ -26,7 +26,7 @@ from unilabos.resources.graphio import initialize_resources
|
||||
from unilabos.registry.registry import lab_registry
|
||||
|
||||
class ResourceMeshManager(BaseROS2DeviceNode):
|
||||
def __init__(self, resource_model: dict, resource_config: list,resource_tracker, device_id: str = "resource_mesh_manager", registry_name: str = "", rate=50, **kwargs):
|
||||
def __init__(self, resource_model: dict, resource_config: list,resource_tracker, device_id: str = "resource_mesh_manager", rate=50, **kwargs):
|
||||
"""初始化资源网格管理器节点
|
||||
|
||||
Args:
|
||||
@@ -37,7 +37,6 @@ class ResourceMeshManager(BaseROS2DeviceNode):
|
||||
super().__init__(
|
||||
driver_instance=self,
|
||||
device_id=device_id,
|
||||
registry_name=registry_name,
|
||||
status_types={},
|
||||
action_value_mappings={},
|
||||
hardware_interface={},
|
||||
|
||||
@@ -7,7 +7,7 @@ from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, DeviceNodeRe
|
||||
|
||||
|
||||
class ROS2SerialNode(BaseROS2DeviceNode):
|
||||
def __init__(self, device_id, registry_name, port: str, baudrate: int = 9600, resource_tracker: DeviceNodeResourceTracker=None):
|
||||
def __init__(self, device_id, port: str, baudrate: int = 9600, resource_tracker: DeviceNodeResourceTracker=None):
|
||||
# 保存属性,以便在调用父类初始化前使用
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
@@ -28,7 +28,6 @@ class ROS2SerialNode(BaseROS2DeviceNode):
|
||||
BaseROS2DeviceNode.__init__(
|
||||
self,
|
||||
driver_instance=self,
|
||||
registry_name=registry_name,
|
||||
device_id=device_id,
|
||||
status_types={},
|
||||
action_value_mappings={},
|
||||
|
||||
@@ -6,6 +6,8 @@ from typing import List, Dict, Any, Optional, TYPE_CHECKING
|
||||
|
||||
import rclpy
|
||||
from rosidl_runtime_py import message_to_ordereddict
|
||||
from unilabos_msgs.msg import Resource
|
||||
from unilabos_msgs.srv import ResourceUpdate
|
||||
|
||||
from unilabos.messages import * # type: ignore # protocol names
|
||||
from rclpy.action import ActionServer, ActionClient
|
||||
@@ -13,6 +15,7 @@ from rclpy.action.server import ServerGoalHandle
|
||||
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response
|
||||
|
||||
from unilabos.compile import action_protocol_generators
|
||||
from unilabos.resources.graphio import nested_dict_to_list
|
||||
from unilabos.ros.initialize_device import initialize_device_from_dict
|
||||
from unilabos.ros.msgs.message_converter import (
|
||||
get_action_type,
|
||||
@@ -20,7 +23,7 @@ from unilabos.ros.msgs.message_converter import (
|
||||
convert_from_ros_msg_with_mapping,
|
||||
)
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, DeviceNodeResourceTracker, ROS2DeviceNode
|
||||
from unilabos.resources.resource_tracker import ResourceDictType, ResourceTreeSet, ResourceDictInstance
|
||||
from unilabos.resources.resource_tracker import ResourceTreeSet, ResourceDictInstance
|
||||
from unilabos.utils.type_check import get_result_info_str
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -47,7 +50,6 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
|
||||
*,
|
||||
driver_instance: "WorkstationBase",
|
||||
device_id: str,
|
||||
registry_name: str,
|
||||
device_uuid: str,
|
||||
status_types: Dict[str, Any],
|
||||
action_value_mappings: Dict[str, Any],
|
||||
@@ -63,7 +65,6 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
|
||||
super().__init__(
|
||||
driver_instance=driver_instance,
|
||||
device_id=device_id,
|
||||
registry_name=registry_name,
|
||||
device_uuid=device_uuid,
|
||||
status_types=status_types,
|
||||
action_value_mappings={**action_value_mappings, **self.protocol_action_mappings},
|
||||
@@ -177,103 +178,6 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
|
||||
self.lab_logger().trace(f"为子设备 {device_id} 创建动作客户端: {action_name}")
|
||||
return d
|
||||
|
||||
def create_device(self, device_id: str, config: ResourceDictType) -> dict:
|
||||
"""Dynamically add a sub-device to this workstation."""
|
||||
if not device_id:
|
||||
return {"success": False, "error": "device_id required"}
|
||||
|
||||
if device_id in self.sub_devices:
|
||||
return {"success": False, "error": f"Sub-device {device_id} already exists"}
|
||||
|
||||
try:
|
||||
from unilabos.config.config import BasicConfig
|
||||
config.setdefault("id", device_id)
|
||||
config.setdefault("type", "device")
|
||||
config.setdefault("machine_name", BasicConfig.machine_name or "本地")
|
||||
res_dict = ResourceDictInstance.get_resource_instance_from_dict(config)
|
||||
|
||||
d = self.initialize_device(device_id, res_dict)
|
||||
if d is None:
|
||||
return {"success": False, "error": f"initialize_device returned None for {device_id}"}
|
||||
|
||||
# Add to children config list
|
||||
self.children.append(res_dict)
|
||||
|
||||
# Add to resource tracker
|
||||
try:
|
||||
from unilabos.resources.resource_tracker import ResourceTreeInstance
|
||||
tree = ResourceTreeInstance(res_dict)
|
||||
for plr_resource in ResourceTreeSet([tree]).to_plr_resources():
|
||||
self.resource_tracker.add_resource(plr_resource)
|
||||
except Exception as ex:
|
||||
self.lab_logger().warning(f"[Workstation-DeviceMgr] PLR resource registration skipped: {ex}")
|
||||
|
||||
self.lab_logger().info(f"[Workstation-DeviceMgr] Sub-device {device_id} created")
|
||||
return {"success": True, "device_id": device_id}
|
||||
|
||||
except Exception as e:
|
||||
self.lab_logger().error(f"[Workstation-DeviceMgr] Failed to create {device_id}: {e}")
|
||||
self.lab_logger().error(traceback.format_exc())
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def destroy_device(self, device_id: str) -> dict:
|
||||
"""Dynamically remove a sub-device from this workstation."""
|
||||
if not device_id:
|
||||
return {"success": False, "error": "device_id required"}
|
||||
|
||||
if device_id not in self.sub_devices:
|
||||
return {"success": False, "error": f"Sub-device {device_id} not found"}
|
||||
|
||||
try:
|
||||
# Remove from children config list
|
||||
self.children = [
|
||||
c for c in self.children
|
||||
if c.res_content.id != device_id
|
||||
]
|
||||
|
||||
# Remove from resource tracker
|
||||
try:
|
||||
tracked = self.resource_tracker.uuid_to_resources.copy()
|
||||
for uid, res in tracked.items():
|
||||
res_id = res.get("id") if isinstance(res, dict) else getattr(res, "name", None)
|
||||
if res_id == device_id:
|
||||
self.resource_tracker.remove_resource(res)
|
||||
except Exception as ex:
|
||||
self.lab_logger().warning(f"[Workstation-DeviceMgr] Resource tracker cleanup: {ex}")
|
||||
|
||||
# Remove action clients for this sub-device
|
||||
action_prefix = f"/devices/{device_id}/"
|
||||
to_remove = [k for k in self._action_clients if k.startswith(action_prefix)]
|
||||
for k in to_remove:
|
||||
try:
|
||||
self._action_clients[k].destroy()
|
||||
except Exception:
|
||||
pass
|
||||
del self._action_clients[k]
|
||||
|
||||
# Destroy the ROS2 node
|
||||
instance = self.sub_devices.pop(device_id, None)
|
||||
if instance is not None:
|
||||
ros_node = getattr(instance, "ros_node_instance", None)
|
||||
if ros_node is not None:
|
||||
try:
|
||||
ros_node.destroy_node()
|
||||
except Exception as e:
|
||||
self.lab_logger().warning(
|
||||
f"[Workstation-DeviceMgr] Error destroying ROS node for {device_id}: {e}"
|
||||
)
|
||||
|
||||
# Remove from communication map if present
|
||||
self.communication_node_id_to_instance.pop(device_id, None)
|
||||
|
||||
self.lab_logger().info(f"[Workstation-DeviceMgr] Sub-device {device_id} destroyed")
|
||||
return {"success": True, "device_id": device_id}
|
||||
|
||||
except Exception as e:
|
||||
self.lab_logger().error(f"[Workstation-DeviceMgr] Failed to destroy {device_id}: {e}")
|
||||
self.lab_logger().error(traceback.format_exc())
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def create_ros_action_server(self, action_name, action_value_mapping):
|
||||
"""创建ROS动作服务器"""
|
||||
if action_name not in self.protocol_names:
|
||||
@@ -327,15 +231,15 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
|
||||
try:
|
||||
# 统一处理单个或多个资源
|
||||
resource_id = (
|
||||
protocol_kwargs[k]["id"]
|
||||
if v == "unilabos_msgs/Resource"
|
||||
else protocol_kwargs[k][0]["id"]
|
||||
protocol_kwargs[k]["id"] if v == "unilabos_msgs/Resource" else protocol_kwargs[k][0]["id"]
|
||||
)
|
||||
resource_uuid = protocol_kwargs[k].get("uuid", None)
|
||||
r = SerialCommand_Request()
|
||||
r.command = json.dumps({"id": resource_id, "uuid": resource_uuid, "with_children": True})
|
||||
# 发送请求并等待响应
|
||||
response: SerialCommand_Response = await self._resource_clients["resource_get"].call_async(
|
||||
response: SerialCommand_Response = await self._resource_clients[
|
||||
"resource_get"
|
||||
].call_async(
|
||||
r
|
||||
) # type: ignore
|
||||
raw_data = json.loads(response.response)
|
||||
@@ -403,54 +307,12 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
|
||||
|
||||
# 向Host更新物料当前状态
|
||||
for k, v in goal.get_fields_and_field_types().items():
|
||||
if v not in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]:
|
||||
continue
|
||||
self.lab_logger().info(f"更新资源状态: {k}")
|
||||
try:
|
||||
# 去重:使用 seen 集合获取唯一的资源对象
|
||||
seen = set()
|
||||
unique_resources = []
|
||||
|
||||
# 获取资源数据,统一转换为列表
|
||||
resource_data = protocol_kwargs[k]
|
||||
is_sequence = v != "unilabos_msgs/Resource"
|
||||
if not is_sequence:
|
||||
resource_list = [resource_data] if isinstance(resource_data, dict) else resource_data
|
||||
else:
|
||||
# 处理序列类型,可能是嵌套列表
|
||||
resource_list = []
|
||||
if isinstance(resource_data, list):
|
||||
for item in resource_data:
|
||||
if isinstance(item, list):
|
||||
resource_list.extend(item)
|
||||
else:
|
||||
resource_list.append(item)
|
||||
else:
|
||||
resource_list = [resource_data]
|
||||
|
||||
for res_data in resource_list:
|
||||
if not isinstance(res_data, dict):
|
||||
continue
|
||||
res_name = res_data.get("id") or res_data.get("name")
|
||||
if not res_name:
|
||||
continue
|
||||
|
||||
# 使用 resource_tracker 获取本地 PLR 实例
|
||||
plr = self.resource_tracker.figure_resource({"name": res_name}, try_mode=False)
|
||||
# 获取父资源
|
||||
res = self.resource_tracker.parent_resource(plr)
|
||||
if res is None:
|
||||
res = plr
|
||||
if id(res) not in seen:
|
||||
seen.add(id(res))
|
||||
unique_resources.append(res)
|
||||
|
||||
# 使用新的资源树接口更新
|
||||
if unique_resources:
|
||||
await self.update_resource(unique_resources)
|
||||
except Exception as e:
|
||||
self.lab_logger().error(f"资源更新失败: {e}")
|
||||
self.lab_logger().error(traceback.format_exc())
|
||||
if v in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]:
|
||||
r = ResourceUpdate.Request()
|
||||
r.resources = [
|
||||
convert_to_ros_msg(Resource, rs) for rs in nested_dict_to_list(protocol_kwargs[k])
|
||||
]
|
||||
response = await self._resource_clients["resource_update"].call_async(r)
|
||||
|
||||
# 设置成功状态和返回值
|
||||
execution_success = True
|
||||
|
||||
@@ -52,8 +52,7 @@ class DeviceClassCreator(Generic[T]):
|
||||
if self.device_instance is not None:
|
||||
for c in self.children:
|
||||
if c.res_content.type != "device":
|
||||
res = ResourceTreeSet([ResourceTreeInstance(c)]).to_plr_resources()[0]
|
||||
self.resource_tracker.add_resource(res)
|
||||
self.resource_tracker.add_resource(c.get_plr_nested_dict())
|
||||
|
||||
def create_instance(self, data: Dict[str, Any]) -> T:
|
||||
"""
|
||||
@@ -120,7 +119,7 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
|
||||
# return resource, source_type
|
||||
|
||||
def _process_resource_references(
|
||||
self, data: Any, processed_child_names: Optional[Dict[str, Any]], to_dict=False, states=None, prefix_path="", name_to_uuid=None
|
||||
self, data: Any, to_dict=False, states=None, prefix_path="", name_to_uuid=None
|
||||
) -> Any:
|
||||
"""
|
||||
递归处理资源引用,替换_resource_child_name对应的资源
|
||||
@@ -165,7 +164,6 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
|
||||
states[prefix_path] = resource_instance.serialize_all_state()
|
||||
return serialized
|
||||
else:
|
||||
processed_child_names[child_name] = resource_instance
|
||||
self.resource_tracker.add_resource(resource_instance)
|
||||
# 立即设置UUID,state已经在resource_ulab_to_plr中处理过了
|
||||
if name_to_uuid:
|
||||
@@ -184,12 +182,12 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
|
||||
result = {}
|
||||
for key, value in data.items():
|
||||
new_prefix = f"{prefix_path}.{key}" if prefix_path else key
|
||||
result[key] = self._process_resource_references(value, processed_child_names, to_dict, states, new_prefix, name_to_uuid)
|
||||
result[key] = self._process_resource_references(value, to_dict, states, new_prefix, name_to_uuid)
|
||||
return result
|
||||
|
||||
elif isinstance(data, list):
|
||||
return [
|
||||
self._process_resource_references(item, processed_child_names, to_dict, states, f"{prefix_path}[{i}]", name_to_uuid)
|
||||
self._process_resource_references(item, to_dict, states, f"{prefix_path}[{i}]", name_to_uuid)
|
||||
for i, item in enumerate(data)
|
||||
]
|
||||
|
||||
@@ -236,7 +234,7 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
|
||||
# 首先处理资源引用
|
||||
states = {}
|
||||
processed_data = self._process_resource_references(
|
||||
data, {}, to_dict=True, states=states, name_to_uuid=name_to_uuid
|
||||
data, to_dict=True, states=states, name_to_uuid=name_to_uuid
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -272,12 +270,7 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
|
||||
arg_value = spec_args[param_name].annotation
|
||||
data[param_name]["_resource_type"] = self.device_cls.__module__ + ":" + arg_value
|
||||
logger.debug(f"自动补充 _resource_type: {data[param_name]['_resource_type']}")
|
||||
processed_child_names = {}
|
||||
processed_data = self._process_resource_references(data, processed_child_names, to_dict=False, name_to_uuid=name_to_uuid)
|
||||
for child_name, resource_instance in processed_data.items():
|
||||
for ind, name in enumerate([child.res_content.name for child in self.children]):
|
||||
if name == child_name:
|
||||
self.children.pop(ind)
|
||||
processed_data = self._process_resource_references(data, to_dict=False, name_to_uuid=name_to_uuid)
|
||||
self.device_instance = super(PyLabRobotCreator, self).create_instance(processed_data) # 补全变量后直接调用,调用的自身的attach_resource
|
||||
except Exception as e:
|
||||
logger.error(f"PyLabRobot创建实例失败: {e}")
|
||||
@@ -349,10 +342,9 @@ class WorkstationNodeCreator(DeviceClassCreator[T]):
|
||||
try:
|
||||
# 创建实例,额外补充一个给protocol node的字段,后面考虑取消
|
||||
data["children"] = self.children
|
||||
# super(WorkstationNodeCreator, self).create_instance(data)的时候会attach
|
||||
# for child in self.children:
|
||||
# if child.res_content.type != "device":
|
||||
# self.resource_tracker.add_resource(child.get_plr_nested_dict())
|
||||
for child in self.children:
|
||||
if child.res_content.type != "device":
|
||||
self.resource_tracker.add_resource(child.get_plr_nested_dict())
|
||||
deck_dict = data.get("deck")
|
||||
if deck_dict:
|
||||
from pylabrobot.resources import Deck, Resource
|
||||
|
||||
@@ -339,8 +339,13 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"max_volume": 500.0,
|
||||
"type": "RegularContainer",
|
||||
"category": "container"
|
||||
"category": "container",
|
||||
"max_temp": 200.0,
|
||||
"min_temp": -20.0,
|
||||
"has_stirrer": true,
|
||||
"has_heater": true
|
||||
},
|
||||
"data": {
|
||||
"liquids": [],
|
||||
@@ -764,7 +769,9 @@
|
||||
"size_y": 250,
|
||||
"size_z": 0,
|
||||
"type": "RegularContainer",
|
||||
"category": "container"
|
||||
"category": "container",
|
||||
"reagent": "sodium_chloride",
|
||||
"physical_state": "solid"
|
||||
},
|
||||
"data": {
|
||||
"current_mass": 500.0,
|
||||
@@ -785,11 +792,14 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"volume": 500.0,
|
||||
"size_x": 600,
|
||||
"size_y": 250,
|
||||
"size_z": 0,
|
||||
"type": "RegularContainer",
|
||||
"category": "container"
|
||||
"category": "container",
|
||||
"reagent": "sodium_carbonate",
|
||||
"physical_state": "solid"
|
||||
},
|
||||
"data": {
|
||||
"current_mass": 500.0,
|
||||
@@ -810,11 +820,14 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"volume": 500.0,
|
||||
"size_x": 650,
|
||||
"size_y": 250,
|
||||
"size_z": 0,
|
||||
"type": "RegularContainer",
|
||||
"category": "container"
|
||||
"category": "container",
|
||||
"reagent": "magnesium_chloride",
|
||||
"physical_state": "solid"
|
||||
},
|
||||
"data": {
|
||||
"current_mass": 500.0,
|
||||
|
||||
@@ -1,446 +0,0 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "PRCXI",
|
||||
"name": "PRCXI",
|
||||
"type": "device",
|
||||
"class": "liquid_handler.prcxi",
|
||||
"parent": "",
|
||||
"pose": {
|
||||
"size": {
|
||||
"width": 562,
|
||||
"height": 394,
|
||||
"depth": 0
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"axis": "Left",
|
||||
"deck": {
|
||||
"_resource_type": "unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Deck",
|
||||
"_resource_child_name": "PRCXI_Deck"
|
||||
},
|
||||
"host": "10.20.30.184",
|
||||
"port": 9999,
|
||||
"debug": true,
|
||||
"setup": true,
|
||||
"is_9320": true,
|
||||
"timeout": 10,
|
||||
"matrix_id": "5de524d0-3f95-406c-86dd-f83626ebc7cb",
|
||||
"simulator": true,
|
||||
"channel_num": 2
|
||||
},
|
||||
"data": {
|
||||
"reset_ok": true
|
||||
},
|
||||
"schema": {},
|
||||
"description": "",
|
||||
"model": null,
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 240,
|
||||
"z": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "PRCXI_Deck",
|
||||
"name": "PRCXI_Deck",
|
||||
"children": [],
|
||||
"parent": "PRCXI",
|
||||
"type": "deck",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Deck",
|
||||
"size_x": 542,
|
||||
"size_y": 374,
|
||||
"size_z": 0,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "deck",
|
||||
"barcode": null,
|
||||
"preferred_pickup_location": null,
|
||||
"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": [
|
||||
"container",
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T2",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 138,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T3",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 276,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T4",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 414,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T5",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 96,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T6",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 138,
|
||||
"y": 96,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T7",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 276,
|
||||
"y": 96,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T8",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 414,
|
||||
"y": 96,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T9",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 192,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T10",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 138,
|
||||
"y": 192,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T11",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 276,
|
||||
"y": 192,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T12",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 414,
|
||||
"y": 192,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T13",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 288,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T14",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 138,
|
||||
"y": 288,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T15",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 276,
|
||||
"y": 288,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T16",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 414,
|
||||
"y": 288,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
],
|
||||
"edges": []
|
||||
}
|
||||
@@ -19,6 +19,74 @@ def singleton(cls):
|
||||
return get_instance
|
||||
|
||||
|
||||
def topic_config(
|
||||
period: Optional[float] = None,
|
||||
print_publish: Optional[bool] = None,
|
||||
qos: Optional[int] = None,
|
||||
) -> Callable[[F], F]:
|
||||
"""
|
||||
Topic发布配置装饰器
|
||||
|
||||
用于装饰 get_{attr_name} 方法或 @property,控制对应属性的ROS topic发布行为。
|
||||
|
||||
Args:
|
||||
period: 发布周期(秒)。None 表示使用默认值 5.0
|
||||
print_publish: 是否打印发布日志。None 表示使用节点默认配置
|
||||
qos: QoS深度配置。None 表示使用默认值 10
|
||||
|
||||
Example:
|
||||
class MyDriver:
|
||||
# 方式1: 装饰 get_{attr_name} 方法
|
||||
@topic_config(period=1.0, print_publish=False, qos=5)
|
||||
def get_temperature(self):
|
||||
return self._temperature
|
||||
|
||||
# 方式2: 与 @property 连用(topic_config 放在下面)
|
||||
@property
|
||||
@topic_config(period=0.1)
|
||||
def position(self):
|
||||
return self._position
|
||||
|
||||
Note:
|
||||
与 @property 连用时,@topic_config 必须放在 @property 下面,
|
||||
这样装饰器执行顺序为:先 topic_config 添加配置,再 property 包装。
|
||||
"""
|
||||
|
||||
def decorator(func: F) -> F:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
# 在函数上附加配置属性 (type: ignore 用于动态属性)
|
||||
wrapper._topic_period = period # type: ignore[attr-defined]
|
||||
wrapper._topic_print_publish = print_publish # type: ignore[attr-defined]
|
||||
wrapper._topic_qos = qos # type: ignore[attr-defined]
|
||||
wrapper._has_topic_config = True # type: ignore[attr-defined]
|
||||
|
||||
return wrapper # type: ignore[return-value]
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def get_topic_config(func) -> dict:
|
||||
"""
|
||||
获取函数上的topic配置
|
||||
|
||||
Args:
|
||||
func: 被装饰的函数
|
||||
|
||||
Returns:
|
||||
包含 period, print_publish, qos 的配置字典
|
||||
"""
|
||||
if hasattr(func, "_has_topic_config") and getattr(func, "_has_topic_config", False):
|
||||
return {
|
||||
"period": getattr(func, "_topic_period", None),
|
||||
"print_publish": getattr(func, "_topic_print_publish", None),
|
||||
"qos": getattr(func, "_topic_qos", None),
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
def subscribe(
|
||||
topic: str,
|
||||
msg_type: Optional[type] = None,
|
||||
@@ -36,6 +104,24 @@ def subscribe(
|
||||
- {namespace}: 完整命名空间 (如 "/devices/pump_1")
|
||||
msg_type: ROS 消息类型。如果为 None,需要在回调函数的类型注解中指定
|
||||
qos: QoS 深度配置,默认为 10
|
||||
|
||||
Example:
|
||||
from std_msgs.msg import String, Float64
|
||||
|
||||
class MyDriver:
|
||||
@subscribe(topic="/devices/{device_id}/set_speed", msg_type=Float64)
|
||||
def on_speed_update(self, msg: Float64):
|
||||
self._speed = msg.data
|
||||
print(f"Speed updated to: {self._speed}")
|
||||
|
||||
@subscribe(topic="{namespace}/command")
|
||||
def on_command(self, msg: String):
|
||||
# msg_type 可从类型注解推断
|
||||
self.execute_command(msg.data)
|
||||
|
||||
Note:
|
||||
- 回调方法的第一个参数是 self,第二个参数是收到的 ROS 消息
|
||||
- topic 中的占位符会在创建订阅时被实际值替换
|
||||
"""
|
||||
|
||||
def decorator(func: F) -> F:
|
||||
@@ -43,6 +129,7 @@ def subscribe(
|
||||
def wrapper(*args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
# 在函数上附加订阅配置
|
||||
wrapper._subscribe_topic = topic # type: ignore[attr-defined]
|
||||
wrapper._subscribe_msg_type = msg_type # type: ignore[attr-defined]
|
||||
wrapper._subscribe_qos = qos # type: ignore[attr-defined]
|
||||
@@ -54,7 +141,15 @@ def subscribe(
|
||||
|
||||
|
||||
def get_subscribe_config(func) -> dict:
|
||||
"""获取函数上的订阅配置 (topic, msg_type, qos)"""
|
||||
"""
|
||||
获取函数上的订阅配置
|
||||
|
||||
Args:
|
||||
func: 被装饰的函数
|
||||
|
||||
Returns:
|
||||
包含 topic, msg_type, qos 的配置字典
|
||||
"""
|
||||
if hasattr(func, "_has_subscribe") and getattr(func, "_has_subscribe", False):
|
||||
return {
|
||||
"topic": getattr(func, "_subscribe_topic", None),
|
||||
@@ -68,6 +163,9 @@ def get_all_subscriptions(instance) -> list:
|
||||
"""
|
||||
扫描实例的所有方法,获取带有 @subscribe 装饰器的方法及其配置
|
||||
|
||||
Args:
|
||||
instance: 要扫描的实例
|
||||
|
||||
Returns:
|
||||
包含 (method_name, method, config) 元组的列表
|
||||
"""
|
||||
@@ -86,14 +184,47 @@ def get_all_subscriptions(instance) -> list:
|
||||
return subscriptions
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 向后兼容重导出 -- 已迁移到 unilabos.registry.decorators
|
||||
# ---------------------------------------------------------------------------
|
||||
from unilabos.registry.decorators import ( # noqa: E402, F401
|
||||
topic_config,
|
||||
get_topic_config,
|
||||
always_free,
|
||||
is_always_free,
|
||||
not_action,
|
||||
is_not_action,
|
||||
)
|
||||
def not_action(func: F) -> F:
|
||||
"""
|
||||
标记方法为非动作的装饰器
|
||||
|
||||
用于装饰 driver 类中的方法,使其在 complete_registry 时不被识别为动作。
|
||||
适用于辅助方法、内部工具方法等不应暴露为设备动作的公共方法。
|
||||
|
||||
Example:
|
||||
class MyDriver:
|
||||
@not_action
|
||||
def helper_method(self):
|
||||
# 这个方法不会被注册为动作
|
||||
pass
|
||||
|
||||
def actual_action(self, param: str):
|
||||
# 这个方法会被注册为动作
|
||||
self.helper_method()
|
||||
|
||||
Note:
|
||||
- 可以与其他装饰器组合使用,@not_action 应放在最外层
|
||||
- 仅影响 complete_registry 的动作识别,不影响方法的正常调用
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
# 在函数上附加标记
|
||||
wrapper._is_not_action = True # type: ignore[attr-defined]
|
||||
|
||||
return wrapper # type: ignore[return-value]
|
||||
|
||||
|
||||
def is_not_action(func) -> bool:
|
||||
"""
|
||||
检查函数是否被标记为非动作
|
||||
|
||||
Args:
|
||||
func: 被检查的函数
|
||||
|
||||
Returns:
|
||||
如果函数被 @not_action 装饰则返回 True,否则返回 False
|
||||
"""
|
||||
return getattr(func, "_is_not_action", False)
|
||||
|
||||
@@ -22,7 +22,6 @@ class EnvironmentChecker:
|
||||
# "pymodbus.framer.FramerType": "pymodbus==3.9.2",
|
||||
"websockets": "websockets",
|
||||
"msgcenterpy": "msgcenterpy",
|
||||
"orjson": "orjson",
|
||||
"opentrons_shared_data": "opentrons_shared_data",
|
||||
"typing_extensions": "typing_extensions",
|
||||
"crcmod": "crcmod-plus",
|
||||
@@ -33,7 +32,7 @@ class EnvironmentChecker:
|
||||
|
||||
# 包版本要求(包名: 最低版本)
|
||||
self.version_requirements = {
|
||||
"msgcenterpy": "0.1.7", # msgcenterpy 最低版本要求
|
||||
"msgcenterpy": "0.1.5", # msgcenterpy 最低版本要求
|
||||
}
|
||||
|
||||
self.missing_packages = []
|
||||
|
||||
@@ -27,9 +27,8 @@ __all__ = [
|
||||
|
||||
from ast import Constant
|
||||
|
||||
from unilabos.resources.resource_tracker import PARAM_SAMPLE_UUIDS
|
||||
from unilabos.utils import logger
|
||||
from unilabos.registry.decorators import is_not_action, is_always_free
|
||||
from unilabos.utils.decorator import is_not_action
|
||||
|
||||
|
||||
class ImportManager:
|
||||
@@ -282,9 +281,6 @@ class ImportManager:
|
||||
continue
|
||||
# 其他非_开头的方法归类为action
|
||||
method_info = self._analyze_method_signature(method)
|
||||
# 检查是否被 @always_free 装饰器标记
|
||||
if is_always_free(method):
|
||||
method_info["always_free"] = True
|
||||
result["action_methods"][name] = method_info
|
||||
|
||||
return result
|
||||
@@ -342,24 +338,16 @@ class ImportManager:
|
||||
if self._is_not_action_method(node):
|
||||
continue
|
||||
# 其他非_开头的方法归类为action
|
||||
# 检查是否被 @always_free 装饰器标记
|
||||
if self._is_always_free_method(node):
|
||||
method_info["always_free"] = True
|
||||
result["action_methods"][method_name] = method_info
|
||||
return result
|
||||
|
||||
def _analyze_method_signature(self, method, skip_unilabos_params: bool = True) -> Dict[str, Any]:
|
||||
def _analyze_method_signature(self, method) -> Dict[str, Any]:
|
||||
"""
|
||||
分析方法签名,提取具体的命名参数信息
|
||||
|
||||
注意:此方法会跳过*args和**kwargs,只提取具体的命名参数
|
||||
这样可以确保通过**dict方式传参时的准确性
|
||||
|
||||
Args:
|
||||
method: 要分析的方法
|
||||
skip_unilabos_params: 是否跳过 unilabos 系统参数(如 sample_uuids),
|
||||
registry 补全时为 True,JsonCommand 执行时为 False
|
||||
|
||||
示例用法:
|
||||
method_info = self._analyze_method_signature(some_method)
|
||||
params = {"param1": "value1", "param2": "value2"}
|
||||
@@ -380,10 +368,6 @@ class ImportManager:
|
||||
if param.kind == param.VAR_KEYWORD: # **kwargs
|
||||
continue
|
||||
|
||||
# 跳过 sample_uuids 参数(由系统自动注入,registry 补全时跳过)
|
||||
if skip_unilabos_params and param_name == PARAM_SAMPLE_UUIDS:
|
||||
continue
|
||||
|
||||
is_required = param.default == inspect.Parameter.empty
|
||||
if is_required:
|
||||
num_required += 1
|
||||
@@ -480,19 +464,6 @@ class ImportManager:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _is_always_free_method(self, node: ast.FunctionDef) -> bool:
|
||||
"""检查是否是@always_free装饰的方法,或 @action(always_free=True) 装饰的方法"""
|
||||
for decorator in node.decorator_list:
|
||||
# 检查 @action(always_free=True)
|
||||
if isinstance(decorator, ast.Call):
|
||||
func = decorator.func
|
||||
if isinstance(func, ast.Name) and func.id == "action":
|
||||
for keyword in decorator.keywords:
|
||||
if keyword.arg == "always_free":
|
||||
if isinstance(keyword.value, Constant) and keyword.value.value is True:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _get_property_name_from_setter(self, node: ast.FunctionDef) -> str:
|
||||
"""从setter装饰器中获取属性名"""
|
||||
for decorator in node.decorator_list:
|
||||
@@ -592,9 +563,6 @@ class ImportManager:
|
||||
for i, arg in enumerate(node.args.args):
|
||||
if arg.arg == "self":
|
||||
continue
|
||||
# 跳过 sample_uuids 参数(由系统自动注入)
|
||||
if arg.arg == PARAM_SAMPLE_UUIDS:
|
||||
continue
|
||||
arg_info = {
|
||||
"name": arg.arg,
|
||||
"type": None,
|
||||
|
||||
@@ -193,7 +193,6 @@ def configure_logger(loglevel=None, working_dir=None):
|
||||
root_logger.addHandler(console_handler)
|
||||
|
||||
# 如果指定了工作目录,添加文件处理器
|
||||
log_filepath = None
|
||||
if working_dir is not None:
|
||||
logs_dir = os.path.join(working_dir, "logs")
|
||||
os.makedirs(logs_dir, exist_ok=True)
|
||||
@@ -214,7 +213,7 @@ def configure_logger(loglevel=None, working_dir=None):
|
||||
|
||||
logging.getLogger("asyncio").setLevel(logging.INFO)
|
||||
logging.getLogger("urllib3").setLevel(logging.INFO)
|
||||
return log_filepath
|
||||
|
||||
|
||||
|
||||
# 配置日志系统
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
networkx
|
||||
typing_extensions
|
||||
websockets
|
||||
msgcenterpy>=0.1.7
|
||||
orjson>=3.11
|
||||
msgcenterpy>=0.1.5
|
||||
opentrons_shared_data
|
||||
pint
|
||||
fastapi
|
||||
|
||||
@@ -1,820 +0,0 @@
|
||||
"""
|
||||
工作流转换模块 - JSON 到 WorkflowGraph 的转换流程
|
||||
|
||||
==================== 输入格式 (JSON) ====================
|
||||
|
||||
{
|
||||
"workflow": [
|
||||
{"action": "transfer_liquid", "action_args": {"sources": "cell_lines", "targets": "Liquid_1", "asp_vol": 100.0, "dis_vol": 74.75, ...}},
|
||||
...
|
||||
],
|
||||
"reagent": {
|
||||
"cell_lines": {"slot": 4, "well": ["A1", "A3", "A5"], "labware": "DRUG + YOYO-MEDIA"},
|
||||
"Liquid_1": {"slot": 1, "well": ["A4", "A7", "A10"], "labware": "rep 1"},
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
==================== 转换步骤 ====================
|
||||
|
||||
第一步: 按 slot 去重创建 create_resource 节点(创建板子)
|
||||
--------------------------------------------------------------------------------
|
||||
- 首先创建一个 Group 节点(type="Group", minimized=true),用于包含所有 create_resource 节点
|
||||
- 遍历所有 reagent,按 slot 去重,为每个唯一的 slot 创建一个板子
|
||||
- 所有 create_resource 节点的 parent_uuid 指向 Group 节点,minimized=true
|
||||
- 生成参数:
|
||||
res_id: plate_slot_{slot}
|
||||
device_id: /PRCXI
|
||||
class_name: PRCXI_BioER_96_wellplate
|
||||
parent: /PRCXI/PRCXI_Deck
|
||||
slot_on_deck: "{slot}"
|
||||
- 输出端口: labware(用于连接 set_liquid_from_plate)
|
||||
- 控制流: create_resource 之间通过 ready 端口串联
|
||||
|
||||
示例: slot=1, slot=4 -> 创建 1 个 Group + 2 个 create_resource 节点
|
||||
|
||||
第二步: 为每个 reagent 创建 set_liquid_from_plate 节点(设置液体)
|
||||
--------------------------------------------------------------------------------
|
||||
- 首先创建一个 Group 节点(type="Group", minimized=true),用于包含所有 set_liquid_from_plate 节点
|
||||
- 遍历所有 reagent,为每个试剂创建 set_liquid_from_plate 节点
|
||||
- 所有 set_liquid_from_plate 节点的 parent_uuid 指向 Group 节点,minimized=true
|
||||
- 生成参数:
|
||||
plate: [](通过连接传递,来自 create_resource 的 labware)
|
||||
well_names: ["A1", "A3", "A5"](来自 reagent 的 well 数组)
|
||||
liquid_names: ["cell_lines", "cell_lines", "cell_lines"](与 well 数量一致)
|
||||
volumes: [1e5, 1e5, 1e5](与 well 数量一致,默认体积)
|
||||
- 输入连接: create_resource (labware) -> set_liquid_from_plate (input_plate)
|
||||
- 输出端口: output_wells(用于连接 transfer_liquid)
|
||||
- 控制流: set_liquid_from_plate 连接在所有 create_resource 之后,通过 ready 端口串联
|
||||
|
||||
第三步: 解析 workflow,创建 transfer_liquid 等动作节点
|
||||
--------------------------------------------------------------------------------
|
||||
- 遍历 workflow 数组,为每个动作创建步骤节点
|
||||
- 参数重命名: asp_vol -> asp_vols, dis_vol -> dis_vols, asp_flow_rate -> asp_flow_rates, dis_flow_rate -> dis_flow_rates
|
||||
- 参数扩展: 根据 targets 的 wells 数量,将单值扩展为数组
|
||||
例: asp_vol=100.0, targets 有 3 个 wells -> asp_vols=[100.0, 100.0, 100.0]
|
||||
- 连接处理: 如果 sources/targets 已通过 set_liquid_from_plate 连接,参数值改为 []
|
||||
- 输入连接: set_liquid_from_plate (output_wells) -> transfer_liquid (sources_identifier / targets_identifier)
|
||||
- 输出端口: sources_out, targets_out(用于连接下一个 transfer_liquid)
|
||||
|
||||
==================== 连接关系图 ====================
|
||||
|
||||
控制流 (ready 端口串联):
|
||||
- create_resource 之间: 无 ready 连接
|
||||
- set_liquid_from_plate 之间: 无 ready 连接
|
||||
- create_resource 与 set_liquid_from_plate 之间: 无 ready 连接
|
||||
- transfer_liquid 之间: 通过 ready 端口串联
|
||||
transfer_liquid_1 -> transfer_liquid_2 -> transfer_liquid_3 -> ...
|
||||
|
||||
物料流:
|
||||
[create_resource] --labware--> [set_liquid_from_plate] --output_wells--> [transfer_liquid] --sources_out/targets_out--> [下一个 transfer_liquid]
|
||||
(slot=1) (cell_lines) (input_plate) (sources_identifier) (sources_identifier)
|
||||
(slot=4) (Liquid_1) (targets_identifier) (targets_identifier)
|
||||
|
||||
==================== 端口映射 ====================
|
||||
|
||||
create_resource:
|
||||
输出: labware
|
||||
|
||||
set_liquid_from_plate:
|
||||
输入: input_plate
|
||||
输出: output_plate, output_wells
|
||||
|
||||
transfer_liquid:
|
||||
输入: sources -> sources_identifier, targets -> targets_identifier
|
||||
输出: sources -> sources_out, targets -> targets_out
|
||||
|
||||
==================== 设备名配置 (device_name) ====================
|
||||
|
||||
每个节点都有 device_name 字段,指定在哪个设备上执行:
|
||||
- create_resource: device_name = "host_node"(固定)
|
||||
- set_liquid_from_plate: device_name = "PRCXI"(可配置,见 DEVICE_NAME_DEFAULT)
|
||||
- transfer_liquid 等动作: device_name = "PRCXI"(可配置,见 DEVICE_NAME_DEFAULT)
|
||||
|
||||
==================== 校验规则 ====================
|
||||
|
||||
- 检查 sources/targets 是否在 reagent 中定义
|
||||
- 检查 sources 和 targets 的 wells 数量是否匹配
|
||||
- 检查参数数组长度是否与 wells 数量一致
|
||||
- 如有问题,在 footer 中添加 [WARN: ...] 标记
|
||||
"""
|
||||
|
||||
import re
|
||||
import uuid
|
||||
|
||||
import networkx as nx
|
||||
from networkx.drawing.nx_agraph import to_agraph
|
||||
import matplotlib.pyplot as plt
|
||||
from typing import Dict, List, Any, Tuple, Optional
|
||||
|
||||
Json = Dict[str, Any]
|
||||
|
||||
|
||||
# ==================== 默认配置 ====================
|
||||
|
||||
# 设备名配置
|
||||
DEVICE_NAME_HOST = "host_node" # create_resource 固定在 host_node 上执行
|
||||
DEVICE_NAME_DEFAULT = "PRCXI" # transfer_liquid, set_liquid_from_plate 等动作的默认设备名
|
||||
|
||||
# 节点类型
|
||||
NODE_TYPE_DEFAULT = "ILab" # 所有节点的默认类型
|
||||
|
||||
# create_resource 节点默认参数
|
||||
CREATE_RESOURCE_DEFAULTS = {
|
||||
"device_id": "/PRCXI",
|
||||
"parent_template": "/PRCXI/PRCXI_Deck",
|
||||
"class_name": "PRCXI_BioER_96_wellplate",
|
||||
}
|
||||
|
||||
# 默认液体体积 (uL)
|
||||
DEFAULT_LIQUID_VOLUME = 1e5
|
||||
|
||||
# 参数重命名映射:单数 -> 复数(用于 transfer_liquid 等动作)
|
||||
PARAM_RENAME_MAPPING = {
|
||||
"asp_vol": "asp_vols",
|
||||
"dis_vol": "dis_vols",
|
||||
"asp_flow_rate": "asp_flow_rates",
|
||||
"dis_flow_rate": "dis_flow_rates",
|
||||
}
|
||||
|
||||
|
||||
# ---------------- Graph ----------------
|
||||
|
||||
|
||||
class WorkflowGraph:
|
||||
"""简单的有向图实现:使用 params 单层参数;inputs 内含连线;支持 node-link 导出"""
|
||||
|
||||
def __init__(self):
|
||||
self.nodes: Dict[str, Dict[str, Any]] = {}
|
||||
self.edges: List[Dict[str, Any]] = []
|
||||
|
||||
def add_node(self, node_id: str, **attrs):
|
||||
self.nodes[node_id] = attrs
|
||||
|
||||
def add_edge(self, source: str, target: str, **attrs):
|
||||
# 将 source_port/target_port 映射为服务端期望的 source_handle_key/target_handle_key
|
||||
source_handle_key = attrs.pop("source_port", "") or attrs.pop("source_handle_key", "")
|
||||
target_handle_key = attrs.pop("target_port", "") or attrs.pop("target_handle_key", "")
|
||||
|
||||
edge = {
|
||||
"source": source,
|
||||
"target": target,
|
||||
"source_node_uuid": source,
|
||||
"target_node_uuid": target,
|
||||
"source_handle_key": source_handle_key,
|
||||
"source_handle_io": attrs.pop("source_handle_io", "source"),
|
||||
"target_handle_key": target_handle_key,
|
||||
"target_handle_io": attrs.pop("target_handle_io", "target"),
|
||||
**attrs,
|
||||
}
|
||||
self.edges.append(edge)
|
||||
|
||||
def _materialize_wiring_into_inputs(
|
||||
self,
|
||||
obj: Any,
|
||||
inputs: Dict[str, Any],
|
||||
variable_sources: Dict[str, Dict[str, Any]],
|
||||
target_node_id: str,
|
||||
base_path: List[str],
|
||||
):
|
||||
has_var = False
|
||||
|
||||
def walk(node: Any, path: List[str]):
|
||||
nonlocal has_var
|
||||
if isinstance(node, dict):
|
||||
if "__var__" in node:
|
||||
has_var = True
|
||||
varname = node["__var__"]
|
||||
placeholder = f"${{{varname}}}"
|
||||
src = variable_sources.get(varname)
|
||||
if src:
|
||||
key = ".".join(path) # e.g. "params.foo.bar.0"
|
||||
inputs[key] = {"node": src["node_id"], "output": src.get("output_name", "result")}
|
||||
self.add_edge(
|
||||
str(src["node_id"]),
|
||||
target_node_id,
|
||||
source_handle_io=src.get("output_name", "result"),
|
||||
target_handle_io=key,
|
||||
)
|
||||
return placeholder
|
||||
return {k: walk(v, path + [k]) for k, v in node.items()}
|
||||
if isinstance(node, list):
|
||||
return [walk(v, path + [str(i)]) for i, v in enumerate(node)]
|
||||
return node
|
||||
|
||||
replaced = walk(obj, base_path[:])
|
||||
return replaced, has_var
|
||||
|
||||
def add_workflow_node(
|
||||
self,
|
||||
node_id: int,
|
||||
*,
|
||||
device_key: Optional[str] = None, # 实例名,如 "ser"
|
||||
resource_name: Optional[str] = None, # registry key(原 device_class)
|
||||
module: Optional[str] = None,
|
||||
template_name: Optional[str] = None, # 动作/模板名(原 action_key)
|
||||
params: Dict[str, Any],
|
||||
variable_sources: Dict[str, Dict[str, Any]],
|
||||
add_ready_if_no_vars: bool = True,
|
||||
prev_node_id: Optional[int] = None,
|
||||
**extra_attrs,
|
||||
) -> None:
|
||||
"""添加工作流节点:params 单层;自动变量连线与 ready 串联;支持附加属性"""
|
||||
node_id_str = str(node_id)
|
||||
inputs: Dict[str, Any] = {}
|
||||
|
||||
params, has_var = self._materialize_wiring_into_inputs(
|
||||
params, inputs, variable_sources, node_id_str, base_path=["params"]
|
||||
)
|
||||
|
||||
if add_ready_if_no_vars and not has_var:
|
||||
last_id = str(prev_node_id) if prev_node_id is not None else "-1"
|
||||
inputs["ready"] = {"node": int(last_id), "output": "ready"}
|
||||
self.add_edge(last_id, node_id_str, source_handle_io="ready", target_handle_io="ready")
|
||||
|
||||
node_obj = {
|
||||
"device_key": device_key,
|
||||
"resource_name": resource_name, # ✅ 新名字
|
||||
"module": module,
|
||||
"template_name": template_name, # ✅ 新名字
|
||||
"params": params,
|
||||
"inputs": inputs,
|
||||
}
|
||||
node_obj.update(extra_attrs or {})
|
||||
self.add_node(node_id_str, parameters=node_obj)
|
||||
|
||||
# 顺序工作流导出(连线在 inputs,不返回 edges)
|
||||
def to_dict(self) -> List[Dict[str, Any]]:
|
||||
result = []
|
||||
for node_id, attrs in self.nodes.items():
|
||||
node = {"uuid": node_id}
|
||||
params = dict(attrs.get("parameters", {}) or {})
|
||||
flat = {k: v for k, v in attrs.items() if k != "parameters"}
|
||||
flat.update(params)
|
||||
node.update(flat)
|
||||
result.append(node)
|
||||
return sorted(result, key=lambda n: int(n["uuid"]) if str(n["uuid"]).isdigit() else n["uuid"])
|
||||
|
||||
# node-link 导出(含 edges)
|
||||
def to_node_link_dict(self) -> Dict[str, Any]:
|
||||
nodes_list = []
|
||||
for node_id, attrs in self.nodes.items():
|
||||
node_attrs = attrs.copy()
|
||||
params = node_attrs.pop("parameters", {}) or {}
|
||||
node_attrs.update(params)
|
||||
nodes_list.append({"uuid": node_id, **node_attrs})
|
||||
return {
|
||||
"directed": True,
|
||||
"multigraph": False,
|
||||
"graph": {},
|
||||
"nodes": nodes_list,
|
||||
"edges": self.edges,
|
||||
"links": self.edges,
|
||||
}
|
||||
|
||||
|
||||
def refactor_data(
|
||||
data: List[Dict[str, Any]],
|
||||
action_resource_mapping: Optional[Dict[str, str]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""统一的数据重构函数,根据操作类型自动选择模板
|
||||
|
||||
Args:
|
||||
data: 原始步骤数据列表
|
||||
action_resource_mapping: action 到 resource_name 的映射字典,可选
|
||||
"""
|
||||
refactored_data = []
|
||||
|
||||
# 定义操作映射,包含生物实验和有机化学的所有操作
|
||||
OPERATION_MAPPING = {
|
||||
# 生物实验操作
|
||||
"transfer_liquid": "transfer_liquid",
|
||||
"transfer": "transfer",
|
||||
"incubation": "incubation",
|
||||
"move_labware": "move_labware",
|
||||
"oscillation": "oscillation",
|
||||
# 有机化学操作
|
||||
"HeatChillToTemp": "HeatChillProtocol",
|
||||
"StopHeatChill": "HeatChillStopProtocol",
|
||||
"StartHeatChill": "HeatChillStartProtocol",
|
||||
"HeatChill": "HeatChillProtocol",
|
||||
"Dissolve": "DissolveProtocol",
|
||||
"Transfer": "TransferProtocol",
|
||||
"Evaporate": "EvaporateProtocol",
|
||||
"Recrystallize": "RecrystallizeProtocol",
|
||||
"Filter": "FilterProtocol",
|
||||
"Dry": "DryProtocol",
|
||||
"Add": "AddProtocol",
|
||||
}
|
||||
|
||||
UNSUPPORTED_OPERATIONS = ["Purge", "Wait", "Stir", "ResetHandling"]
|
||||
|
||||
for step in data:
|
||||
operation = step.get("action")
|
||||
if not operation or operation in UNSUPPORTED_OPERATIONS:
|
||||
continue
|
||||
|
||||
# 处理重复操作
|
||||
if operation == "Repeat":
|
||||
times = step.get("times", step.get("parameters", {}).get("times", 1))
|
||||
sub_steps = step.get("steps", step.get("parameters", {}).get("steps", []))
|
||||
for i in range(int(times)):
|
||||
sub_data = refactor_data(sub_steps, action_resource_mapping)
|
||||
refactored_data.extend(sub_data)
|
||||
continue
|
||||
|
||||
# 获取模板名称
|
||||
template_name = OPERATION_MAPPING.get(operation)
|
||||
if not template_name:
|
||||
# 自动推断模板类型
|
||||
if operation.lower() in ["transfer", "incubation", "move_labware", "oscillation"]:
|
||||
template_name = f"biomek-{operation}"
|
||||
else:
|
||||
template_name = f"{operation}Protocol"
|
||||
|
||||
# 获取 resource_name
|
||||
resource_name = f"device.{operation.lower()}"
|
||||
if action_resource_mapping:
|
||||
resource_name = action_resource_mapping.get(operation, resource_name)
|
||||
|
||||
# 获取步骤编号,生成 name 字段
|
||||
step_number = step.get("step_number")
|
||||
name = f"Step {step_number}" if step_number is not None else None
|
||||
|
||||
# 创建步骤数据
|
||||
step_data = {
|
||||
"template_name": template_name,
|
||||
"resource_name": resource_name,
|
||||
"description": step.get("description", step.get("purpose", f"{operation} operation")),
|
||||
"lab_node_type": "Device",
|
||||
"param": step.get("parameters", step.get("action_args", {})),
|
||||
"footer": f"{template_name}-{resource_name}",
|
||||
}
|
||||
if name:
|
||||
step_data["name"] = name
|
||||
refactored_data.append(step_data)
|
||||
|
||||
return refactored_data
|
||||
|
||||
|
||||
def build_protocol_graph(
|
||||
labware_info: Dict[str, Dict[str, Any]],
|
||||
protocol_steps: List[Dict[str, Any]],
|
||||
workstation_name: str,
|
||||
action_resource_mapping: Optional[Dict[str, str]] = None,
|
||||
labware_defs: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> WorkflowGraph:
|
||||
"""统一的协议图构建函数,根据设备类型自动选择构建逻辑
|
||||
|
||||
Args:
|
||||
labware_info: reagent 信息字典,格式为 {name: {slot, well}, ...},用于 set_liquid 和 well 查找
|
||||
protocol_steps: 协议步骤列表
|
||||
workstation_name: 工作站名称
|
||||
action_resource_mapping: action 到 resource_name 的映射字典,可选
|
||||
labware_defs: labware 定义列表,格式为 [{"name": "...", "slot": "1", "type": "lab_xxx"}, ...]
|
||||
"""
|
||||
G = WorkflowGraph()
|
||||
resource_last_writer = {} # reagent_name -> "node_id:port"
|
||||
slot_to_create_resource = {} # slot -> create_resource node_id
|
||||
|
||||
protocol_steps = refactor_data(protocol_steps, action_resource_mapping)
|
||||
|
||||
# ==================== 第一步:按 slot 创建 create_resource 节点 ====================
|
||||
# 创建 Group 节点,包含所有 create_resource 节点
|
||||
group_node_id = str(uuid.uuid4())
|
||||
G.add_node(
|
||||
group_node_id,
|
||||
name="Resources Group",
|
||||
type="Group",
|
||||
parent_uuid="",
|
||||
lab_node_type="Device",
|
||||
template_name="",
|
||||
resource_name="",
|
||||
footer="",
|
||||
minimized=True,
|
||||
param=None,
|
||||
)
|
||||
|
||||
# 直接使用 JSON 中的 labware 定义,每个 slot 一条记录,type 即 class_name
|
||||
res_index = 0
|
||||
for lw in (labware_defs or []):
|
||||
slot = str(lw.get("slot", ""))
|
||||
if not slot or slot in slot_to_create_resource:
|
||||
continue # 跳过空 slot 或已处理的 slot
|
||||
|
||||
lw_name = lw.get("name", f"slot {slot}")
|
||||
lw_type = lw.get("type", CREATE_RESOURCE_DEFAULTS["class_name"])
|
||||
res_id = f"plate_slot_{slot}"
|
||||
|
||||
res_index += 1
|
||||
node_id = str(uuid.uuid4())
|
||||
G.add_node(
|
||||
node_id,
|
||||
template_name="create_resource",
|
||||
resource_name="host_node",
|
||||
name=lw_name,
|
||||
description=f"Create {lw_name}",
|
||||
lab_node_type="Labware",
|
||||
footer="create_resource-host_node",
|
||||
device_name=DEVICE_NAME_HOST,
|
||||
type=NODE_TYPE_DEFAULT,
|
||||
parent_uuid=group_node_id,
|
||||
minimized=True,
|
||||
param={
|
||||
"res_id": res_id,
|
||||
"device_id": CREATE_RESOURCE_DEFAULTS["device_id"],
|
||||
"class_name": lw_type,
|
||||
"parent": CREATE_RESOURCE_DEFAULTS["parent_template"],
|
||||
"bind_locations": {"x": 0.0, "y": 0.0, "z": 0.0},
|
||||
"slot_on_deck": slot,
|
||||
},
|
||||
)
|
||||
slot_to_create_resource[slot] = node_id
|
||||
|
||||
# ==================== 第二步:为每个 reagent 创建 set_liquid_from_plate 节点 ====================
|
||||
# 创建 Group 节点,包含所有 set_liquid_from_plate 节点
|
||||
set_liquid_group_id = str(uuid.uuid4())
|
||||
G.add_node(
|
||||
set_liquid_group_id,
|
||||
name="SetLiquid Group",
|
||||
type="Group",
|
||||
parent_uuid="",
|
||||
lab_node_type="Device",
|
||||
template_name="",
|
||||
resource_name="",
|
||||
footer="",
|
||||
minimized=True,
|
||||
param=None,
|
||||
)
|
||||
|
||||
set_liquid_index = 0
|
||||
|
||||
for labware_id, item in labware_info.items():
|
||||
# 跳过 Tip/Rack 类型
|
||||
if "Rack" in str(labware_id) or "Tip" in str(labware_id):
|
||||
continue
|
||||
if item.get("type") == "hardware":
|
||||
continue
|
||||
|
||||
slot = str(item.get("slot", ""))
|
||||
wells = item.get("well", [])
|
||||
if not wells or not slot:
|
||||
continue
|
||||
|
||||
# res_id 不能有空格
|
||||
res_id = str(labware_id).replace(" ", "_")
|
||||
well_count = len(wells)
|
||||
|
||||
node_id = str(uuid.uuid4())
|
||||
set_liquid_index += 1
|
||||
|
||||
G.add_node(
|
||||
node_id,
|
||||
template_name="set_liquid_from_plate",
|
||||
resource_name="liquid_handler.prcxi",
|
||||
name=f"SetLiquid {set_liquid_index}",
|
||||
description=f"Set liquid: {labware_id}",
|
||||
lab_node_type="Reagent",
|
||||
footer="set_liquid_from_plate-liquid_handler.prcxi",
|
||||
device_name=DEVICE_NAME_DEFAULT,
|
||||
type=NODE_TYPE_DEFAULT,
|
||||
parent_uuid=set_liquid_group_id, # 指向 Group 节点
|
||||
minimized=True, # 折叠显示
|
||||
param={
|
||||
"plate": [], # 通过连接传递
|
||||
"well_names": wells, # 孔位名数组,如 ["A1", "A3", "A5"]
|
||||
"liquid_names": [res_id] * well_count,
|
||||
"volumes": [DEFAULT_LIQUID_VOLUME] * well_count,
|
||||
},
|
||||
)
|
||||
|
||||
# set_liquid_from_plate 之间不需要 ready 连接
|
||||
|
||||
# 物料流:create_resource 的 labware -> set_liquid_from_plate 的 input_plate
|
||||
create_res_node_id = slot_to_create_resource.get(slot)
|
||||
if create_res_node_id:
|
||||
G.add_edge(create_res_node_id, node_id, source_port="labware", target_port="input_plate")
|
||||
|
||||
# set_liquid_from_plate 的输出 output_wells 用于连接 transfer_liquid
|
||||
resource_last_writer[labware_id] = f"{node_id}:output_wells"
|
||||
|
||||
# transfer_liquid 之间通过 ready 串联,从 None 开始
|
||||
last_control_node_id = None
|
||||
|
||||
# 端口名称映射:JSON 字段名 -> 实际 handle key
|
||||
INPUT_PORT_MAPPING = {
|
||||
"sources": "sources_identifier",
|
||||
"targets": "targets_identifier",
|
||||
"vessel": "vessel",
|
||||
"to_vessel": "to_vessel",
|
||||
"from_vessel": "from_vessel",
|
||||
"reagent": "reagent",
|
||||
"solvent": "solvent",
|
||||
"compound": "compound",
|
||||
}
|
||||
|
||||
OUTPUT_PORT_MAPPING = {
|
||||
"sources": "sources_out", # 输出端口是 xxx_out
|
||||
"targets": "targets_out", # 输出端口是 xxx_out
|
||||
"vessel": "vessel_out",
|
||||
"to_vessel": "to_vessel_out",
|
||||
"from_vessel": "from_vessel_out",
|
||||
"filtrate_vessel": "filtrate_out",
|
||||
"reagent": "reagent",
|
||||
"solvent": "solvent",
|
||||
"compound": "compound",
|
||||
}
|
||||
|
||||
# 需要根据 wells 数量扩展的参数列表(复数形式)
|
||||
EXPAND_BY_WELLS_PARAMS = ["asp_vols", "dis_vols", "asp_flow_rates", "dis_flow_rates"]
|
||||
|
||||
# 处理协议步骤
|
||||
for step in protocol_steps:
|
||||
node_id = str(uuid.uuid4())
|
||||
params = step.get("param", {}).copy() # 复制一份,避免修改原数据
|
||||
connected_params = set() # 记录被连接的参数
|
||||
warnings = [] # 收集警告信息
|
||||
|
||||
# 参数重命名:单数 -> 复数
|
||||
for old_name, new_name in PARAM_RENAME_MAPPING.items():
|
||||
if old_name in params:
|
||||
params[new_name] = params.pop(old_name)
|
||||
|
||||
# 处理输入连接
|
||||
for param_key, target_port in INPUT_PORT_MAPPING.items():
|
||||
resource_name = params.get(param_key)
|
||||
if resource_name and resource_name in resource_last_writer:
|
||||
source_node, source_port = resource_last_writer[resource_name].split(":")
|
||||
G.add_edge(source_node, node_id, source_port=source_port, target_port=target_port)
|
||||
connected_params.add(param_key)
|
||||
elif resource_name and resource_name not in resource_last_writer:
|
||||
# 资源名在 labware_info 中不存在
|
||||
warnings.append(f"{param_key}={resource_name} 未找到")
|
||||
|
||||
# 获取 targets 对应的 wells 数量,用于扩展参数
|
||||
targets_name = params.get("targets")
|
||||
sources_name = params.get("sources")
|
||||
targets_wells_count = 1
|
||||
sources_wells_count = 1
|
||||
|
||||
if targets_name and targets_name in labware_info:
|
||||
target_wells = labware_info[targets_name].get("well", [])
|
||||
targets_wells_count = len(target_wells) if target_wells else 1
|
||||
elif targets_name:
|
||||
warnings.append(f"targets={targets_name} 未在 reagent 中定义")
|
||||
|
||||
if sources_name and sources_name in labware_info:
|
||||
source_wells = labware_info[sources_name].get("well", [])
|
||||
sources_wells_count = len(source_wells) if source_wells else 1
|
||||
elif sources_name:
|
||||
warnings.append(f"sources={sources_name} 未在 reagent 中定义")
|
||||
|
||||
# 检查 sources 和 targets 的 wells 数量是否匹配
|
||||
if targets_wells_count != sources_wells_count and targets_name and sources_name:
|
||||
warnings.append(f"wells 数量不匹配: sources={sources_wells_count}, targets={targets_wells_count}")
|
||||
|
||||
# 使用 targets 的 wells 数量来扩展参数
|
||||
wells_count = targets_wells_count
|
||||
|
||||
# 扩展单值参数为数组(根据 targets 的 wells 数量)
|
||||
for expand_param in EXPAND_BY_WELLS_PARAMS:
|
||||
if expand_param in params:
|
||||
value = params[expand_param]
|
||||
# 如果是单个值,扩展为数组
|
||||
if not isinstance(value, list):
|
||||
params[expand_param] = [value] * wells_count
|
||||
# 如果已经是数组但长度不对,记录警告
|
||||
elif len(value) != wells_count:
|
||||
warnings.append(f"{expand_param} 数量({len(value)})与 wells({wells_count})不匹配")
|
||||
|
||||
# 如果 sources/targets 已通过连接传递,将参数值改为空数组
|
||||
for param_key in connected_params:
|
||||
if param_key in params:
|
||||
params[param_key] = []
|
||||
|
||||
# 更新 step 的 param、footer、device_name 和 type
|
||||
step_copy = step.copy()
|
||||
step_copy["param"] = params
|
||||
step_copy["device_name"] = DEVICE_NAME_DEFAULT # 动作节点使用默认设备名
|
||||
step_copy["type"] = NODE_TYPE_DEFAULT # 节点类型
|
||||
|
||||
# 如果有警告,修改 footer 添加警告标记(警告放前面)
|
||||
if warnings:
|
||||
original_footer = step.get("footer", "")
|
||||
step_copy["footer"] = f"[WARN: {'; '.join(warnings)}] {original_footer}"
|
||||
|
||||
G.add_node(node_id, **step_copy)
|
||||
|
||||
# 控制流
|
||||
if last_control_node_id is not None:
|
||||
G.add_edge(last_control_node_id, node_id, source_port="ready", target_port="ready")
|
||||
last_control_node_id = node_id
|
||||
|
||||
# 处理输出:更新 resource_last_writer
|
||||
for param_key, output_port in OUTPUT_PORT_MAPPING.items():
|
||||
resource_name = step.get("param", {}).get(param_key) # 使用原始参数值
|
||||
if resource_name:
|
||||
resource_last_writer[resource_name] = f"{node_id}:{output_port}"
|
||||
|
||||
return G
|
||||
|
||||
|
||||
def draw_protocol_graph(protocol_graph: WorkflowGraph, output_path: str):
|
||||
"""
|
||||
(辅助功能) 使用 networkx 和 matplotlib 绘制协议工作流图,用于可视化。
|
||||
"""
|
||||
if not protocol_graph:
|
||||
print("Cannot draw graph: Graph object is empty.")
|
||||
return
|
||||
|
||||
G = nx.DiGraph()
|
||||
|
||||
for node_id, attrs in protocol_graph.nodes.items():
|
||||
label = attrs.get("description", attrs.get("template_name", node_id[:8]))
|
||||
G.add_node(node_id, label=label, **attrs)
|
||||
|
||||
for edge in protocol_graph.edges:
|
||||
G.add_edge(edge["source"], edge["target"])
|
||||
|
||||
plt.figure(figsize=(20, 15))
|
||||
try:
|
||||
pos = nx.nx_agraph.graphviz_layout(G, prog="dot")
|
||||
except Exception:
|
||||
pos = nx.shell_layout(G) # Fallback layout
|
||||
|
||||
node_labels = {node: data["label"] for node, data in G.nodes(data=True)}
|
||||
nx.draw(
|
||||
G,
|
||||
pos,
|
||||
with_labels=False,
|
||||
node_size=2500,
|
||||
node_color="skyblue",
|
||||
node_shape="o",
|
||||
edge_color="gray",
|
||||
width=1.5,
|
||||
arrowsize=15,
|
||||
)
|
||||
nx.draw_networkx_labels(G, pos, labels=node_labels, font_size=8, font_weight="bold")
|
||||
|
||||
plt.title("Chemical Protocol Workflow Graph", size=15)
|
||||
plt.savefig(output_path, dpi=300, bbox_inches="tight")
|
||||
plt.close()
|
||||
print(f" - Visualization saved to '{output_path}'")
|
||||
|
||||
|
||||
COMPASS = {"n", "e", "s", "w", "ne", "nw", "se", "sw", "c"}
|
||||
|
||||
|
||||
def _is_compass(port: str) -> bool:
|
||||
return isinstance(port, str) and port.lower() in COMPASS
|
||||
|
||||
|
||||
def draw_protocol_graph_with_ports(protocol_graph, output_path: str, rankdir: str = "LR"):
|
||||
"""
|
||||
使用 Graphviz 端口语法绘制协议工作流图。
|
||||
- 若边上的 source_port/target_port 是 compass(n/e/s/w/...),直接用 compass。
|
||||
- 否则自动为节点创建 record 形状并定义命名端口 <portname>。
|
||||
最终由 PyGraphviz 渲染并输出到 output_path(后缀决定格式,如 .png/.svg/.pdf)。
|
||||
"""
|
||||
if not protocol_graph:
|
||||
print("Cannot draw graph: Graph object is empty.")
|
||||
return
|
||||
|
||||
# 1) 先用 networkx 搭建有向图,保留端口属性
|
||||
G = nx.DiGraph()
|
||||
for node_id, attrs in protocol_graph.nodes.items():
|
||||
label = attrs.get("description", attrs.get("template_name", node_id[:8]))
|
||||
# 保留一个干净的“中心标签”,用于放在 record 的中间槽
|
||||
G.add_node(node_id, _core_label=str(label), **{k: v for k, v in attrs.items() if k not in ("label",)})
|
||||
|
||||
edges_data = []
|
||||
in_ports_by_node = {} # 收集命名输入端口
|
||||
out_ports_by_node = {} # 收集命名输出端口
|
||||
|
||||
for edge in protocol_graph.edges:
|
||||
u = edge["source"]
|
||||
v = edge["target"]
|
||||
sp = edge.get("source_handle_key") or edge.get("source_port")
|
||||
tp = edge.get("target_handle_key") or edge.get("target_port")
|
||||
|
||||
# 记录到图里(保留原始端口信息)
|
||||
G.add_edge(u, v, source_handle_key=sp, target_handle_key=tp)
|
||||
edges_data.append((u, v, sp, tp))
|
||||
|
||||
# 如果不是 compass,就按“命名端口”先归类,等会儿给节点造 record
|
||||
if sp and not _is_compass(sp):
|
||||
out_ports_by_node.setdefault(u, set()).add(str(sp))
|
||||
if tp and not _is_compass(tp):
|
||||
in_ports_by_node.setdefault(v, set()).add(str(tp))
|
||||
|
||||
# 2) 转为 AGraph,使用 Graphviz 渲染
|
||||
A = to_agraph(G)
|
||||
A.graph_attr.update(rankdir=rankdir, splines="true", concentrate="false", fontsize="10")
|
||||
A.node_attr.update(
|
||||
shape="box", style="rounded,filled", fillcolor="lightyellow", color="#999999", fontname="Helvetica"
|
||||
)
|
||||
A.edge_attr.update(arrowsize="0.8", color="#666666")
|
||||
|
||||
# 3) 为需要命名端口的节点设置 record 形状与 label
|
||||
# 左列 = 输入端口;中间 = 核心标签;右列 = 输出端口
|
||||
for n in A.nodes():
|
||||
node = A.get_node(n)
|
||||
core = G.nodes[n].get("_core_label", n)
|
||||
|
||||
in_ports = sorted(in_ports_by_node.get(n, []))
|
||||
out_ports = sorted(out_ports_by_node.get(n, []))
|
||||
|
||||
# 如果该节点涉及命名端口,则用 record;否则保留原 box
|
||||
if in_ports or out_ports:
|
||||
|
||||
def port_fields(ports):
|
||||
if not ports:
|
||||
return " " # 必须留一个空槽占位
|
||||
# 每个端口一个小格子,<p> name
|
||||
return "|".join(f"<{re.sub(r'[^A-Za-z0-9_:.|-]', '_', p)}> {p}" for p in ports)
|
||||
|
||||
left = port_fields(in_ports)
|
||||
right = port_fields(out_ports)
|
||||
|
||||
# 三栏:左(入) | 中(节点名) | 右(出)
|
||||
record_label = f"{{ {left} | {core} | {right} }}"
|
||||
node.attr.update(shape="record", label=record_label)
|
||||
else:
|
||||
# 没有命名端口:普通盒子,显示核心标签
|
||||
node.attr.update(label=str(core))
|
||||
|
||||
# 4) 给边设置 headport / tailport
|
||||
# - 若端口为 compass:直接用 compass(e.g., headport="e")
|
||||
# - 若端口为命名端口:使用在 record 中定义的 <port> 名(同名即可)
|
||||
for u, v, sp, tp in edges_data:
|
||||
e = A.get_edge(u, v)
|
||||
|
||||
# Graphviz 属性:tail 是源,head 是目标
|
||||
if sp:
|
||||
if _is_compass(sp):
|
||||
e.attr["tailport"] = sp.lower()
|
||||
else:
|
||||
# 与 record label 中 <port> 名一致;特殊字符已在 label 中做了清洗
|
||||
e.attr["tailport"] = re.sub(r"[^A-Za-z0-9_:.|-]", "_", str(sp))
|
||||
|
||||
if tp:
|
||||
if _is_compass(tp):
|
||||
e.attr["headport"] = tp.lower()
|
||||
else:
|
||||
e.attr["headport"] = re.sub(r"[^A-Za-z0-9_:.|-]", "_", str(tp))
|
||||
|
||||
# 可选:若想让边更贴边缘,可设置 constraint/spline 等
|
||||
# e.attr["arrowhead"] = "vee"
|
||||
|
||||
# 5) 输出
|
||||
A.draw(output_path, prog="dot")
|
||||
print(f" - Port-aware workflow rendered to '{output_path}'")
|
||||
|
||||
|
||||
# ---------------- Registry Adapter ----------------
|
||||
|
||||
|
||||
class RegistryAdapter:
|
||||
"""根据 module 的类名(冒号右侧)反查 registry 的 resource_name(原 device_class),并抽取参数顺序"""
|
||||
|
||||
def __init__(self, device_registry: Dict[str, Any]):
|
||||
self.device_registry = device_registry or {}
|
||||
self.module_class_to_resource = self._build_module_class_index()
|
||||
|
||||
def _build_module_class_index(self) -> Dict[str, str]:
|
||||
idx = {}
|
||||
for resource_name, info in self.device_registry.items():
|
||||
module = info.get("module")
|
||||
if isinstance(module, str) and ":" in module:
|
||||
cls = module.split(":")[-1]
|
||||
idx[cls] = resource_name
|
||||
idx[cls.lower()] = resource_name
|
||||
return idx
|
||||
|
||||
def resolve_resource_by_classname(self, class_name: str) -> Optional[str]:
|
||||
if not class_name:
|
||||
return None
|
||||
return self.module_class_to_resource.get(class_name) or self.module_class_to_resource.get(class_name.lower())
|
||||
|
||||
def get_device_module(self, resource_name: Optional[str]) -> Optional[str]:
|
||||
if not resource_name:
|
||||
return None
|
||||
return self.device_registry.get(resource_name, {}).get("module")
|
||||
|
||||
def get_actions(self, resource_name: Optional[str]) -> Dict[str, Any]:
|
||||
if not resource_name:
|
||||
return {}
|
||||
return (self.device_registry.get(resource_name, {}).get("class", {}).get("action_value_mappings", {})) or {}
|
||||
|
||||
def get_action_schema(self, resource_name: Optional[str], template_name: str) -> Optional[Json]:
|
||||
return (self.get_actions(resource_name).get(template_name) or {}).get("schema")
|
||||
|
||||
def get_action_goal_default(self, resource_name: Optional[str], template_name: str) -> Json:
|
||||
return (self.get_actions(resource_name).get(template_name) or {}).get("goal_default", {}) or {}
|
||||
|
||||
def get_action_input_keys(self, resource_name: Optional[str], template_name: str) -> List[str]:
|
||||
schema = self.get_action_schema(resource_name, template_name) or {}
|
||||
goal = (schema.get("properties") or {}).get("goal") or {}
|
||||
props = goal.get("properties") or {}
|
||||
required = goal.get("required") or []
|
||||
return list(dict.fromkeys(required + list(props.keys())))
|
||||
@@ -1,320 +0,0 @@
|
||||
"""
|
||||
JSON 工作流转换模块
|
||||
|
||||
将 workflow/reagent/labware 格式的 JSON 转换为统一工作流格式。
|
||||
|
||||
输入格式:
|
||||
{
|
||||
"labware": [
|
||||
{"name": "...", "slot": "1", "type": "lab_xxx"},
|
||||
...
|
||||
],
|
||||
"workflow": [
|
||||
{"action": "...", "action_args": {...}},
|
||||
...
|
||||
],
|
||||
"reagent": {
|
||||
"reagent_name": {"slot": int, "well": [...]},
|
||||
...
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
import json
|
||||
from os import PathLike
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from unilabos.workflow.common import WorkflowGraph, build_protocol_graph
|
||||
from unilabos.registry.registry import lab_registry
|
||||
|
||||
|
||||
# ==================== 字段映射配置 ====================
|
||||
|
||||
# action 到 resource_name 的映射
|
||||
ACTION_RESOURCE_MAPPING: Dict[str, str] = {
|
||||
# 生物实验操作
|
||||
"transfer_liquid": "liquid_handler.prcxi",
|
||||
"transfer": "liquid_handler.prcxi",
|
||||
"incubation": "incubator.prcxi",
|
||||
"move_labware": "labware_mover.prcxi",
|
||||
"oscillation": "shaker.prcxi",
|
||||
# 有机化学操作
|
||||
"HeatChillToTemp": "heatchill.chemputer",
|
||||
"StopHeatChill": "heatchill.chemputer",
|
||||
"StartHeatChill": "heatchill.chemputer",
|
||||
"HeatChill": "heatchill.chemputer",
|
||||
"Dissolve": "stirrer.chemputer",
|
||||
"Transfer": "liquid_handler.chemputer",
|
||||
"Evaporate": "rotavap.chemputer",
|
||||
"Recrystallize": "reactor.chemputer",
|
||||
"Filter": "filter.chemputer",
|
||||
"Dry": "dryer.chemputer",
|
||||
"Add": "liquid_handler.chemputer",
|
||||
}
|
||||
|
||||
# action_args 字段到 parameters 字段的映射
|
||||
# 格式: {"old_key": "new_key"}, 仅映射需要重命名的字段
|
||||
ARGS_FIELD_MAPPING: Dict[str, str] = {
|
||||
# 如果需要字段重命名,在这里配置
|
||||
# "old_field_name": "new_field_name",
|
||||
}
|
||||
|
||||
# 默认工作站名称
|
||||
DEFAULT_WORKSTATION = "PRCXI"
|
||||
|
||||
|
||||
# ==================== 核心转换函数 ====================
|
||||
|
||||
|
||||
def get_action_handles(resource_name: str, template_name: str) -> Dict[str, List[str]]:
|
||||
"""
|
||||
从 registry 获取指定设备和动作的 handles 配置
|
||||
|
||||
Args:
|
||||
resource_name: 设备资源名称,如 "liquid_handler.prcxi"
|
||||
template_name: 动作模板名称,如 "transfer_liquid"
|
||||
|
||||
Returns:
|
||||
包含 source 和 target handler_keys 的字典:
|
||||
{"source": ["sources_out", "targets_out", ...], "target": ["sources", "targets", ...]}
|
||||
"""
|
||||
result = {"source": [], "target": []}
|
||||
|
||||
device_info = lab_registry.device_type_registry.get(resource_name, {})
|
||||
if not device_info:
|
||||
return result
|
||||
|
||||
action_mappings = device_info.get("class", {}).get("action_value_mappings", {})
|
||||
action_config = action_mappings.get(template_name, {})
|
||||
handles = action_config.get("handles", {})
|
||||
|
||||
if isinstance(handles, dict):
|
||||
for handle in handles.get("input", []):
|
||||
handler_key = handle.get("handler_key", "")
|
||||
if handler_key:
|
||||
result["source"].append(handler_key)
|
||||
for handle in handles.get("output", []):
|
||||
handler_key = handle.get("handler_key", "")
|
||||
if handler_key:
|
||||
result["target"].append(handler_key)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def validate_workflow_handles(graph: WorkflowGraph) -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
校验工作流图中所有边的句柄配置是否正确
|
||||
|
||||
Args:
|
||||
graph: 工作流图对象
|
||||
|
||||
Returns:
|
||||
(is_valid, errors): 是否有效,错误信息列表
|
||||
"""
|
||||
errors = []
|
||||
nodes = graph.nodes
|
||||
|
||||
for edge in graph.edges:
|
||||
left_uuid = edge.get("source")
|
||||
right_uuid = edge.get("target")
|
||||
right_source_conn_key = edge.get("target_handle_key", "")
|
||||
left_target_conn_key = edge.get("source_handle_key", "")
|
||||
|
||||
left_node = nodes.get(left_uuid, {})
|
||||
right_node = nodes.get(right_uuid, {})
|
||||
|
||||
left_res_name = left_node.get("resource_name", "")
|
||||
left_template_name = left_node.get("template_name", "")
|
||||
right_res_name = right_node.get("resource_name", "")
|
||||
right_template_name = right_node.get("template_name", "")
|
||||
|
||||
left_node_handles = get_action_handles(left_res_name, left_template_name)
|
||||
target_valid_keys = left_node_handles.get("target", [])
|
||||
target_valid_keys.append("ready")
|
||||
|
||||
right_node_handles = get_action_handles(right_res_name, right_template_name)
|
||||
source_valid_keys = right_node_handles.get("source", [])
|
||||
source_valid_keys.append("ready")
|
||||
|
||||
# 验证目标节点(right)的输入端口
|
||||
if not right_source_conn_key:
|
||||
node_name = right_node.get("name", right_uuid[:8])
|
||||
errors.append(f"目标节点 '{node_name}' 的输入端口 (target_handle_key) 为空,应设置为: {source_valid_keys}")
|
||||
elif right_source_conn_key not in source_valid_keys:
|
||||
node_name = right_node.get("name", right_uuid[:8])
|
||||
errors.append(
|
||||
f"目标节点 '{node_name}' 的输入端口 '{right_source_conn_key}' 不存在,支持的输入端口: {source_valid_keys}"
|
||||
)
|
||||
|
||||
# 验证源节点(left)的输出端口
|
||||
if not left_target_conn_key:
|
||||
node_name = left_node.get("name", left_uuid[:8])
|
||||
errors.append(f"源节点 '{node_name}' 的输出端口 (source_handle_key) 为空,应设置为: {target_valid_keys}")
|
||||
elif left_target_conn_key not in target_valid_keys:
|
||||
node_name = left_node.get("name", left_uuid[:8])
|
||||
errors.append(
|
||||
f"源节点 '{node_name}' 的输出端口 '{left_target_conn_key}' 不存在,支持的输出端口: {target_valid_keys}"
|
||||
)
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
|
||||
def normalize_workflow_steps(workflow: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
将 workflow 格式的步骤数据规范化
|
||||
|
||||
输入格式:
|
||||
[{"action": "...", "action_args": {...}}, ...]
|
||||
|
||||
输出格式:
|
||||
[{"action": "...", "parameters": {...}, "step_number": int}, ...]
|
||||
|
||||
Args:
|
||||
workflow: workflow 数组
|
||||
|
||||
Returns:
|
||||
规范化后的步骤列表
|
||||
"""
|
||||
normalized = []
|
||||
for idx, step in enumerate(workflow):
|
||||
action = step.get("action")
|
||||
if not action:
|
||||
continue
|
||||
|
||||
# 获取参数: action_args
|
||||
raw_params = step.get("action_args", {})
|
||||
params = {}
|
||||
|
||||
# 应用字段映射
|
||||
for key, value in raw_params.items():
|
||||
mapped_key = ARGS_FIELD_MAPPING.get(key, key)
|
||||
params[mapped_key] = value
|
||||
|
||||
step_dict = {
|
||||
"action": action,
|
||||
"parameters": params,
|
||||
"step_number": idx + 1,
|
||||
}
|
||||
|
||||
# 保留描述字段
|
||||
if "description" in step:
|
||||
step_dict["description"] = step["description"]
|
||||
|
||||
normalized.append(step_dict)
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def convert_from_json(
|
||||
data: Union[str, PathLike, Dict[str, Any]],
|
||||
workstation_name: str = DEFAULT_WORKSTATION,
|
||||
validate: bool = True,
|
||||
) -> WorkflowGraph:
|
||||
"""
|
||||
从 JSON 数据或文件转换为 WorkflowGraph
|
||||
|
||||
JSON 格式:
|
||||
{"workflow": [...], "reagent": {...}}
|
||||
|
||||
Args:
|
||||
data: JSON 文件路径、字典数据、或 JSON 字符串
|
||||
workstation_name: 工作站名称,默认 "PRCXi"
|
||||
validate: 是否校验句柄配置,默认 True
|
||||
|
||||
Returns:
|
||||
WorkflowGraph: 构建好的工作流图
|
||||
|
||||
Raises:
|
||||
ValueError: 不支持的 JSON 格式
|
||||
FileNotFoundError: 文件不存在
|
||||
json.JSONDecodeError: JSON 解析失败
|
||||
"""
|
||||
# 处理输入数据
|
||||
if isinstance(data, (str, PathLike)):
|
||||
path = Path(data)
|
||||
if path.exists():
|
||||
with path.open("r", encoding="utf-8") as fp:
|
||||
json_data = json.load(fp)
|
||||
elif isinstance(data, str):
|
||||
json_data = json.loads(data)
|
||||
else:
|
||||
raise FileNotFoundError(f"文件不存在: {data}")
|
||||
elif isinstance(data, dict):
|
||||
json_data = data
|
||||
else:
|
||||
raise TypeError(f"不支持的数据类型: {type(data)}")
|
||||
|
||||
# 校验格式
|
||||
if "workflow" not in json_data or "reagent" not in json_data:
|
||||
raise ValueError(
|
||||
"不支持的 JSON 格式。请使用标准格式:\n"
|
||||
'{"labware": [...], "workflow": [...], "reagent": {...}}'
|
||||
)
|
||||
|
||||
# 提取数据
|
||||
workflow = json_data["workflow"]
|
||||
reagent = json_data["reagent"]
|
||||
labware_defs = json_data.get("labware", []) # 新的 labware 定义列表
|
||||
|
||||
# 规范化步骤数据
|
||||
protocol_steps = normalize_workflow_steps(workflow)
|
||||
|
||||
# reagent 已经是字典格式,用于 set_liquid 和 well 数量查找
|
||||
labware_info = reagent
|
||||
|
||||
# 构建工作流图
|
||||
graph = build_protocol_graph(
|
||||
labware_info=labware_info,
|
||||
protocol_steps=protocol_steps,
|
||||
workstation_name=workstation_name,
|
||||
action_resource_mapping=ACTION_RESOURCE_MAPPING,
|
||||
labware_defs=labware_defs,
|
||||
)
|
||||
|
||||
# 校验句柄配置
|
||||
if validate:
|
||||
is_valid, errors = validate_workflow_handles(graph)
|
||||
if not is_valid:
|
||||
import warnings
|
||||
|
||||
for error in errors:
|
||||
warnings.warn(f"句柄校验警告: {error}")
|
||||
|
||||
return graph
|
||||
|
||||
|
||||
def convert_json_to_node_link(
|
||||
data: Union[str, PathLike, Dict[str, Any]],
|
||||
workstation_name: str = DEFAULT_WORKSTATION,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
将 JSON 数据转换为 node-link 格式的字典
|
||||
|
||||
Args:
|
||||
data: JSON 文件路径、字典数据、或 JSON 字符串
|
||||
workstation_name: 工作站名称,默认 "PRCXi"
|
||||
|
||||
Returns:
|
||||
Dict: node-link 格式的工作流数据
|
||||
"""
|
||||
graph = convert_from_json(data, workstation_name)
|
||||
return graph.to_node_link_dict()
|
||||
|
||||
|
||||
def convert_json_to_workflow_list(
|
||||
data: Union[str, PathLike, Dict[str, Any]],
|
||||
workstation_name: str = DEFAULT_WORKSTATION,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
将 JSON 数据转换为工作流列表格式
|
||||
|
||||
Args:
|
||||
data: JSON 文件路径、字典数据、或 JSON 字符串
|
||||
workstation_name: 工作站名称,默认 "PRCXi"
|
||||
|
||||
Returns:
|
||||
List: 工作流节点列表
|
||||
"""
|
||||
graph = convert_from_json(data, workstation_name)
|
||||
return graph.to_dict()
|
||||
@@ -1,241 +0,0 @@
|
||||
import ast
|
||||
import json
|
||||
from typing import Dict, List, Any, Tuple, Optional
|
||||
|
||||
from .common import WorkflowGraph, RegistryAdapter
|
||||
|
||||
Json = Dict[str, Any]
|
||||
|
||||
# ---------------- Converter ----------------
|
||||
|
||||
class DeviceMethodConverter:
|
||||
"""
|
||||
- 字段统一:resource_name(原 device_class)、template_name(原 action_key)
|
||||
- params 单层;inputs 使用 'params.' 前缀
|
||||
- SimpleGraph.add_workflow_node 负责变量连线与边
|
||||
"""
|
||||
def __init__(self, device_registry: Optional[Dict[str, Any]] = None):
|
||||
self.graph = WorkflowGraph()
|
||||
self.variable_sources: Dict[str, Dict[str, Any]] = {} # var -> {node_id, output_name}
|
||||
self.instance_to_resource: Dict[str, Optional[str]] = {} # 实例名 -> resource_name
|
||||
self.node_id_counter: int = 0
|
||||
self.registry = RegistryAdapter(device_registry or {})
|
||||
|
||||
# ---- helpers ----
|
||||
def _new_node_id(self) -> int:
|
||||
nid = self.node_id_counter
|
||||
self.node_id_counter += 1
|
||||
return nid
|
||||
|
||||
def _assign_targets(self, targets) -> List[str]:
|
||||
names: List[str] = []
|
||||
import ast
|
||||
if isinstance(targets, ast.Tuple):
|
||||
for elt in targets.elts:
|
||||
if isinstance(elt, ast.Name):
|
||||
names.append(elt.id)
|
||||
elif isinstance(targets, ast.Name):
|
||||
names.append(targets.id)
|
||||
return names
|
||||
|
||||
def _extract_device_instantiation(self, node) -> Optional[Tuple[str, str]]:
|
||||
import ast
|
||||
if not isinstance(node.value, ast.Call):
|
||||
return None
|
||||
callee = node.value.func
|
||||
if isinstance(callee, ast.Name):
|
||||
class_name = callee.id
|
||||
elif isinstance(callee, ast.Attribute) and isinstance(callee.value, ast.Name):
|
||||
class_name = callee.attr
|
||||
else:
|
||||
return None
|
||||
if isinstance(node.targets[0], ast.Name):
|
||||
instance = node.targets[0].id
|
||||
return instance, class_name
|
||||
return None
|
||||
|
||||
def _extract_call(self, call) -> Tuple[str, str, Dict[str, Any], str]:
|
||||
import ast
|
||||
owner_name, method_name, call_kind = "", "", "func"
|
||||
if isinstance(call.func, ast.Attribute):
|
||||
method_name = call.func.attr
|
||||
if isinstance(call.func.value, ast.Name):
|
||||
owner_name = call.func.value.id
|
||||
call_kind = "instance" if owner_name in self.instance_to_resource else "class_or_module"
|
||||
elif isinstance(call.func.value, ast.Attribute) and isinstance(call.func.value.value, ast.Name):
|
||||
owner_name = call.func.value.attr
|
||||
call_kind = "class_or_module"
|
||||
elif isinstance(call.func, ast.Name):
|
||||
method_name = call.func.id
|
||||
call_kind = "func"
|
||||
|
||||
def pack(node):
|
||||
if isinstance(node, ast.Name):
|
||||
return {"type": "variable", "value": node.id}
|
||||
if isinstance(node, ast.Constant):
|
||||
return {"type": "constant", "value": node.value}
|
||||
if isinstance(node, ast.Dict):
|
||||
return {"type": "dict", "value": self._parse_dict(node)}
|
||||
if isinstance(node, ast.List):
|
||||
return {"type": "list", "value": self._parse_list(node)}
|
||||
return {"type": "raw", "value": ast.unparse(node) if hasattr(ast, "unparse") else str(node)}
|
||||
|
||||
args: Dict[str, Any] = {}
|
||||
pos: List[Any] = []
|
||||
for a in call.args:
|
||||
pos.append(pack(a))
|
||||
for kw in call.keywords:
|
||||
args[kw.arg] = pack(kw.value)
|
||||
if pos:
|
||||
args["_positional"] = pos
|
||||
return owner_name, method_name, args, call_kind
|
||||
|
||||
def _parse_dict(self, node) -> Dict[str, Any]:
|
||||
import ast
|
||||
out: Dict[str, Any] = {}
|
||||
for k, v in zip(node.keys, node.values):
|
||||
if isinstance(k, ast.Constant):
|
||||
key = str(k.value)
|
||||
if isinstance(v, ast.Name):
|
||||
out[key] = f"var:{v.id}"
|
||||
elif isinstance(v, ast.Constant):
|
||||
out[key] = v.value
|
||||
elif isinstance(v, ast.Dict):
|
||||
out[key] = self._parse_dict(v)
|
||||
elif isinstance(v, ast.List):
|
||||
out[key] = self._parse_list(v)
|
||||
return out
|
||||
|
||||
def _parse_list(self, node) -> List[Any]:
|
||||
import ast
|
||||
out: List[Any] = []
|
||||
for elt in node.elts:
|
||||
if isinstance(elt, ast.Name):
|
||||
out.append(f"var:{elt.id}")
|
||||
elif isinstance(elt, ast.Constant):
|
||||
out.append(elt.value)
|
||||
elif isinstance(elt, ast.Dict):
|
||||
out.append(self._parse_dict(elt))
|
||||
elif isinstance(elt, ast.List):
|
||||
out.append(self._parse_list(elt))
|
||||
return out
|
||||
|
||||
def _normalize_var_tokens(self, x: Any) -> Any:
|
||||
if isinstance(x, str) and x.startswith("var:"):
|
||||
return {"__var__": x[4:]}
|
||||
if isinstance(x, list):
|
||||
return [self._normalize_var_tokens(i) for i in x]
|
||||
if isinstance(x, dict):
|
||||
return {k: self._normalize_var_tokens(v) for k, v in x.items()}
|
||||
return x
|
||||
|
||||
def _make_params_payload(self, resource_name: Optional[str], template_name: str, call_args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
input_keys = self.registry.get_action_input_keys(resource_name, template_name) if resource_name else []
|
||||
defaults = self.registry.get_action_goal_default(resource_name, template_name) if resource_name else {}
|
||||
params: Dict[str, Any] = dict(defaults)
|
||||
|
||||
def unpack(p):
|
||||
t, v = p.get("type"), p.get("value")
|
||||
if t == "variable":
|
||||
return {"__var__": v}
|
||||
if t == "dict":
|
||||
return self._normalize_var_tokens(v)
|
||||
if t == "list":
|
||||
return self._normalize_var_tokens(v)
|
||||
return v
|
||||
|
||||
for k, p in call_args.items():
|
||||
if k == "_positional":
|
||||
continue
|
||||
params[k] = unpack(p)
|
||||
|
||||
pos = call_args.get("_positional", [])
|
||||
if pos:
|
||||
if input_keys:
|
||||
for i, p in enumerate(pos):
|
||||
if i >= len(input_keys):
|
||||
break
|
||||
name = input_keys[i]
|
||||
if name in params:
|
||||
continue
|
||||
params[name] = unpack(p)
|
||||
else:
|
||||
for i, p in enumerate(pos):
|
||||
params[f"arg_{i}"] = unpack(p)
|
||||
return params
|
||||
|
||||
# ---- handlers ----
|
||||
def _on_assign(self, stmt):
|
||||
import ast
|
||||
inst = self._extract_device_instantiation(stmt)
|
||||
if inst:
|
||||
instance, code_class = inst
|
||||
resource_name = self.registry.resolve_resource_by_classname(code_class)
|
||||
self.instance_to_resource[instance] = resource_name
|
||||
return
|
||||
|
||||
if isinstance(stmt.value, ast.Call):
|
||||
owner, method, call_args, kind = self._extract_call(stmt.value)
|
||||
if kind == "instance":
|
||||
device_key = owner
|
||||
resource_name = self.instance_to_resource.get(owner)
|
||||
else:
|
||||
device_key = owner
|
||||
resource_name = self.registry.resolve_resource_by_classname(owner)
|
||||
|
||||
module = self.registry.get_device_module(resource_name)
|
||||
params = self._make_params_payload(resource_name, method, call_args)
|
||||
|
||||
nid = self._new_node_id()
|
||||
self.graph.add_workflow_node(
|
||||
nid,
|
||||
device_key=device_key,
|
||||
resource_name=resource_name, # ✅
|
||||
module=module,
|
||||
template_name=method, # ✅
|
||||
params=params,
|
||||
variable_sources=self.variable_sources,
|
||||
add_ready_if_no_vars=True,
|
||||
prev_node_id=(nid - 1) if nid > 0 else None,
|
||||
)
|
||||
|
||||
out_vars = self._assign_targets(stmt.targets[0])
|
||||
for var in out_vars:
|
||||
self.variable_sources[var] = {"node_id": nid, "output_name": "result"}
|
||||
|
||||
def _on_expr(self, stmt):
|
||||
import ast
|
||||
if not isinstance(stmt.value, ast.Call):
|
||||
return
|
||||
owner, method, call_args, kind = self._extract_call(stmt.value)
|
||||
if kind == "instance":
|
||||
device_key = owner
|
||||
resource_name = self.instance_to_resource.get(owner)
|
||||
else:
|
||||
device_key = owner
|
||||
resource_name = self.registry.resolve_resource_by_classname(owner)
|
||||
|
||||
module = self.registry.get_device_module(resource_name)
|
||||
params = self._make_params_payload(resource_name, method, call_args)
|
||||
|
||||
nid = self._new_node_id()
|
||||
self.graph.add_workflow_node(
|
||||
nid,
|
||||
device_key=device_key,
|
||||
resource_name=resource_name, # ✅
|
||||
module=module,
|
||||
template_name=method, # ✅
|
||||
params=params,
|
||||
variable_sources=self.variable_sources,
|
||||
add_ready_if_no_vars=True,
|
||||
prev_node_id=(nid - 1) if nid > 0 else None,
|
||||
)
|
||||
|
||||
def convert(self, python_code: str):
|
||||
tree = ast.parse(python_code)
|
||||
for stmt in tree.body:
|
||||
if isinstance(stmt, ast.Assign):
|
||||
self._on_assign(stmt)
|
||||
elif isinstance(stmt, ast.Expr):
|
||||
self._on_expr(stmt)
|
||||
return self
|
||||
@@ -1,131 +0,0 @@
|
||||
from typing import List, Any, Dict
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
|
||||
def convert_to_type(val: str) -> Any:
|
||||
"""将字符串值转换为适当的数据类型"""
|
||||
if val == "True":
|
||||
return True
|
||||
if val == "False":
|
||||
return False
|
||||
if val == "?":
|
||||
return None
|
||||
if val.endswith(" g"):
|
||||
return float(val.split(" ")[0])
|
||||
if val.endswith("mg"):
|
||||
return float(val.split("mg")[0])
|
||||
elif val.endswith("mmol"):
|
||||
return float(val.split("mmol")[0]) / 1000
|
||||
elif val.endswith("mol"):
|
||||
return float(val.split("mol")[0])
|
||||
elif val.endswith("ml"):
|
||||
return float(val.split("ml")[0])
|
||||
elif val.endswith("RPM"):
|
||||
return float(val.split("RPM")[0])
|
||||
elif val.endswith(" °C"):
|
||||
return float(val.split(" ")[0])
|
||||
elif val.endswith(" %"):
|
||||
return float(val.split(" ")[0])
|
||||
return val
|
||||
|
||||
|
||||
def flatten_xdl_procedure(procedure_elem: ET.Element) -> List[ET.Element]:
|
||||
"""展平嵌套的XDL程序结构"""
|
||||
flattened_operations = []
|
||||
TEMP_UNSUPPORTED_PROTOCOL = ["Purge", "Wait", "Stir", "ResetHandling"]
|
||||
|
||||
def extract_operations(element: ET.Element):
|
||||
if element.tag not in ["Prep", "Reaction", "Workup", "Purification", "Procedure"]:
|
||||
if element.tag not in TEMP_UNSUPPORTED_PROTOCOL:
|
||||
flattened_operations.append(element)
|
||||
|
||||
for child in element:
|
||||
extract_operations(child)
|
||||
|
||||
for child in procedure_elem:
|
||||
extract_operations(child)
|
||||
|
||||
return flattened_operations
|
||||
|
||||
|
||||
def parse_xdl_content(xdl_content: str) -> tuple:
|
||||
"""解析XDL内容"""
|
||||
try:
|
||||
xdl_content_cleaned = "".join(c for c in xdl_content if c.isprintable())
|
||||
root = ET.fromstring(xdl_content_cleaned)
|
||||
|
||||
synthesis_elem = root.find("Synthesis")
|
||||
if synthesis_elem is None:
|
||||
return None, None, None
|
||||
|
||||
# 解析硬件组件
|
||||
hardware_elem = synthesis_elem.find("Hardware")
|
||||
hardware = []
|
||||
if hardware_elem is not None:
|
||||
hardware = [{"id": c.get("id"), "type": c.get("type")} for c in hardware_elem.findall("Component")]
|
||||
|
||||
# 解析试剂
|
||||
reagents_elem = synthesis_elem.find("Reagents")
|
||||
reagents = []
|
||||
if reagents_elem is not None:
|
||||
reagents = [{"name": r.get("name"), "role": r.get("role", "")} for r in reagents_elem.findall("Reagent")]
|
||||
|
||||
# 解析程序
|
||||
procedure_elem = synthesis_elem.find("Procedure")
|
||||
if procedure_elem is None:
|
||||
return None, None, None
|
||||
|
||||
flattened_operations = flatten_xdl_procedure(procedure_elem)
|
||||
return hardware, reagents, flattened_operations
|
||||
|
||||
except ET.ParseError as e:
|
||||
raise ValueError(f"Invalid XDL format: {e}")
|
||||
|
||||
|
||||
def convert_xdl_to_dict(xdl_content: str) -> Dict[str, Any]:
|
||||
"""
|
||||
将XDL XML格式转换为标准的字典格式
|
||||
|
||||
Args:
|
||||
xdl_content: XDL XML内容
|
||||
|
||||
Returns:
|
||||
转换结果,包含步骤和器材信息
|
||||
"""
|
||||
try:
|
||||
hardware, reagents, flattened_operations = parse_xdl_content(xdl_content)
|
||||
if hardware is None:
|
||||
return {"error": "Failed to parse XDL content", "success": False}
|
||||
|
||||
# 将XDL元素转换为字典格式
|
||||
steps_data = []
|
||||
for elem in flattened_operations:
|
||||
# 转换参数类型
|
||||
parameters = {}
|
||||
for key, val in elem.attrib.items():
|
||||
converted_val = convert_to_type(val)
|
||||
if converted_val is not None:
|
||||
parameters[key] = converted_val
|
||||
|
||||
step_dict = {
|
||||
"operation": elem.tag,
|
||||
"parameters": parameters,
|
||||
"description": elem.get("purpose", f"Operation: {elem.tag}"),
|
||||
}
|
||||
steps_data.append(step_dict)
|
||||
|
||||
# 合并硬件和试剂为统一的labware_info格式
|
||||
labware_data = []
|
||||
labware_data.extend({"id": hw["id"], "type": "hardware", **hw} for hw in hardware)
|
||||
labware_data.extend({"name": reagent["name"], "type": "reagent", **reagent} for reagent in reagents)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"steps": steps_data,
|
||||
"labware": labware_data,
|
||||
"message": f"Successfully converted XDL to dict format. Found {len(steps_data)} steps and {len(labware_data)} labware items.",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"XDL conversion failed: {str(e)}"
|
||||
return {"error": error_msg, "success": False}
|
||||
@@ -1,356 +0,0 @@
|
||||
"""
|
||||
JSON 工作流转换模块
|
||||
|
||||
提供从多种 JSON 格式转换为统一工作流格式的功能。
|
||||
支持的格式:
|
||||
1. workflow/reagent 格式
|
||||
2. steps_info/labware_info 格式
|
||||
"""
|
||||
|
||||
import json
|
||||
from os import PathLike
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple, Union
|
||||
|
||||
from unilabos.workflow.common import WorkflowGraph, build_protocol_graph
|
||||
from unilabos.registry.registry import lab_registry
|
||||
|
||||
|
||||
def get_action_handles(resource_name: str, template_name: str) -> Dict[str, List[str]]:
|
||||
"""
|
||||
从 registry 获取指定设备和动作的 handles 配置
|
||||
|
||||
Args:
|
||||
resource_name: 设备资源名称,如 "liquid_handler.prcxi"
|
||||
template_name: 动作模板名称,如 "transfer_liquid"
|
||||
|
||||
Returns:
|
||||
包含 source 和 target handler_keys 的字典:
|
||||
{"source": ["sources_out", "targets_out", ...], "target": ["sources", "targets", ...]}
|
||||
"""
|
||||
result = {"source": [], "target": []}
|
||||
|
||||
device_info = lab_registry.device_type_registry.get(resource_name, {})
|
||||
if not device_info:
|
||||
return result
|
||||
|
||||
action_mappings = device_info.get("class", {}).get("action_value_mappings", {})
|
||||
action_config = action_mappings.get(template_name, {})
|
||||
handles = action_config.get("handles", {})
|
||||
|
||||
if isinstance(handles, dict):
|
||||
# 处理 input handles (作为 target)
|
||||
for handle in handles.get("input", []):
|
||||
handler_key = handle.get("handler_key", "")
|
||||
if handler_key:
|
||||
result["source"].append(handler_key)
|
||||
# 处理 output handles (作为 source)
|
||||
for handle in handles.get("output", []):
|
||||
handler_key = handle.get("handler_key", "")
|
||||
if handler_key:
|
||||
result["target"].append(handler_key)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def validate_workflow_handles(graph: WorkflowGraph) -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
校验工作流图中所有边的句柄配置是否正确
|
||||
|
||||
Args:
|
||||
graph: 工作流图对象
|
||||
|
||||
Returns:
|
||||
(is_valid, errors): 是否有效,错误信息列表
|
||||
"""
|
||||
errors = []
|
||||
nodes = graph.nodes
|
||||
|
||||
for edge in graph.edges:
|
||||
left_uuid = edge.get("source")
|
||||
right_uuid = edge.get("target")
|
||||
# target_handle_key是target, right的输入节点(入节点)
|
||||
# source_handle_key是source, left的输出节点(出节点)
|
||||
right_source_conn_key = edge.get("target_handle_key", "")
|
||||
left_target_conn_key = edge.get("source_handle_key", "")
|
||||
|
||||
# 获取源节点和目标节点信息
|
||||
left_node = nodes.get(left_uuid, {})
|
||||
right_node = nodes.get(right_uuid, {})
|
||||
|
||||
left_res_name = left_node.get("resource_name", "")
|
||||
left_template_name = left_node.get("template_name", "")
|
||||
right_res_name = right_node.get("resource_name", "")
|
||||
right_template_name = right_node.get("template_name", "")
|
||||
|
||||
# 获取源节点的 output handles
|
||||
left_node_handles = get_action_handles(left_res_name, left_template_name)
|
||||
target_valid_keys = left_node_handles.get("target", [])
|
||||
target_valid_keys.append("ready")
|
||||
|
||||
# 获取目标节点的 input handles
|
||||
right_node_handles = get_action_handles(right_res_name, right_template_name)
|
||||
source_valid_keys = right_node_handles.get("source", [])
|
||||
source_valid_keys.append("ready")
|
||||
|
||||
# 如果节点配置了 output handles,则 source_port 必须有效
|
||||
if not right_source_conn_key:
|
||||
node_name = left_node.get("name", left_uuid[:8])
|
||||
errors.append(f"源节点 '{node_name}' 的 source_handle_key 为空," f"应设置为: {source_valid_keys}")
|
||||
elif right_source_conn_key not in source_valid_keys:
|
||||
node_name = left_node.get("name", left_uuid[:8])
|
||||
errors.append(
|
||||
f"源节点 '{node_name}' 的 source 端点 '{right_source_conn_key}' 不存在," f"支持的端点: {source_valid_keys}"
|
||||
)
|
||||
|
||||
# 如果节点配置了 input handles,则 target_port 必须有效
|
||||
if not left_target_conn_key:
|
||||
node_name = right_node.get("name", right_uuid[:8])
|
||||
errors.append(f"目标节点 '{node_name}' 的 target_handle_key 为空," f"应设置为: {target_valid_keys}")
|
||||
elif left_target_conn_key not in target_valid_keys:
|
||||
node_name = right_node.get("name", right_uuid[:8])
|
||||
errors.append(
|
||||
f"目标节点 '{node_name}' 的 target 端点 '{left_target_conn_key}' 不存在,"
|
||||
f"支持的端点: {target_valid_keys}"
|
||||
)
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
|
||||
# action 到 resource_name 的映射
|
||||
ACTION_RESOURCE_MAPPING: Dict[str, str] = {
|
||||
# 生物实验操作
|
||||
"transfer_liquid": "liquid_handler.prcxi",
|
||||
"transfer": "liquid_handler.prcxi",
|
||||
"incubation": "incubator.prcxi",
|
||||
"move_labware": "labware_mover.prcxi",
|
||||
"oscillation": "shaker.prcxi",
|
||||
# 有机化学操作
|
||||
"HeatChillToTemp": "heatchill.chemputer",
|
||||
"StopHeatChill": "heatchill.chemputer",
|
||||
"StartHeatChill": "heatchill.chemputer",
|
||||
"HeatChill": "heatchill.chemputer",
|
||||
"Dissolve": "stirrer.chemputer",
|
||||
"Transfer": "liquid_handler.chemputer",
|
||||
"Evaporate": "rotavap.chemputer",
|
||||
"Recrystallize": "reactor.chemputer",
|
||||
"Filter": "filter.chemputer",
|
||||
"Dry": "dryer.chemputer",
|
||||
"Add": "liquid_handler.chemputer",
|
||||
}
|
||||
|
||||
|
||||
def normalize_steps(data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
将不同格式的步骤数据规范化为统一格式
|
||||
|
||||
支持的输入格式:
|
||||
- action + parameters
|
||||
- action + action_args
|
||||
- operation + parameters
|
||||
|
||||
Args:
|
||||
data: 原始步骤数据列表
|
||||
|
||||
Returns:
|
||||
规范化后的步骤列表,格式为 [{"action": str, "parameters": dict, "description": str?, "step_number": int?}, ...]
|
||||
"""
|
||||
normalized = []
|
||||
for idx, step in enumerate(data):
|
||||
# 获取动作名称(支持 action 或 operation 字段)
|
||||
action = step.get("action") or step.get("operation")
|
||||
if not action:
|
||||
continue
|
||||
|
||||
# 获取参数(支持 parameters 或 action_args 字段)
|
||||
raw_params = step.get("parameters") or step.get("action_args") or {}
|
||||
params = dict(raw_params)
|
||||
|
||||
# 规范化 source/target -> sources/targets
|
||||
if "source" in raw_params and "sources" not in raw_params:
|
||||
params["sources"] = raw_params["source"]
|
||||
if "target" in raw_params and "targets" not in raw_params:
|
||||
params["targets"] = raw_params["target"]
|
||||
|
||||
# 获取描述(支持 description 或 purpose 字段)
|
||||
description = step.get("description") or step.get("purpose")
|
||||
|
||||
# 获取步骤编号(优先使用原始数据中的 step_number,否则使用索引+1)
|
||||
step_number = step.get("step_number", idx + 1)
|
||||
|
||||
step_dict = {"action": action, "parameters": params, "step_number": step_number}
|
||||
if description:
|
||||
step_dict["description"] = description
|
||||
|
||||
normalized.append(step_dict)
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_labware(data: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
将不同格式的 labware 数据规范化为统一的字典格式
|
||||
|
||||
支持的输入格式:
|
||||
- reagent_name + material_name + positions
|
||||
- name + labware + slot
|
||||
|
||||
Args:
|
||||
data: 原始 labware 数据列表
|
||||
|
||||
Returns:
|
||||
规范化后的 labware 字典,格式为 {name: {"slot": int, "labware": str, "well": list, "type": str, "role": str, "name": str}, ...}
|
||||
"""
|
||||
labware = {}
|
||||
for item in data:
|
||||
# 获取 key 名称(优先使用 reagent_name,其次是 material_name 或 name)
|
||||
reagent_name = item.get("reagent_name")
|
||||
key = reagent_name or item.get("material_name") or item.get("name")
|
||||
if not key:
|
||||
continue
|
||||
|
||||
key = str(key)
|
||||
|
||||
# 处理重复 key,自动添加后缀
|
||||
idx = 1
|
||||
original_key = key
|
||||
while key in labware:
|
||||
idx += 1
|
||||
key = f"{original_key}_{idx}"
|
||||
|
||||
labware[key] = {
|
||||
"slot": item.get("positions") or item.get("slot"),
|
||||
"labware": item.get("material_name") or item.get("labware"),
|
||||
"well": item.get("well", []),
|
||||
"type": item.get("type", "reagent"),
|
||||
"role": item.get("role", ""),
|
||||
"name": key,
|
||||
}
|
||||
|
||||
return labware
|
||||
|
||||
|
||||
def convert_from_json(
|
||||
data: Union[str, PathLike, Dict[str, Any]],
|
||||
workstation_name: str = "PRCXi",
|
||||
validate: bool = True,
|
||||
) -> WorkflowGraph:
|
||||
"""
|
||||
从 JSON 数据或文件转换为 WorkflowGraph
|
||||
|
||||
支持的 JSON 格式:
|
||||
1. {"workflow": [...], "reagent": {...}} - 直接格式
|
||||
2. {"steps_info": [...], "labware_info": [...]} - 需要规范化的格式
|
||||
|
||||
Args:
|
||||
data: JSON 文件路径、字典数据、或 JSON 字符串
|
||||
workstation_name: 工作站名称,默认 "PRCXi"
|
||||
validate: 是否校验句柄配置,默认 True
|
||||
|
||||
Returns:
|
||||
WorkflowGraph: 构建好的工作流图
|
||||
|
||||
Raises:
|
||||
ValueError: 不支持的 JSON 格式 或 句柄校验失败
|
||||
FileNotFoundError: 文件不存在
|
||||
json.JSONDecodeError: JSON 解析失败
|
||||
"""
|
||||
# 处理输入数据
|
||||
if isinstance(data, (str, PathLike)):
|
||||
path = Path(data)
|
||||
if path.exists():
|
||||
with path.open("r", encoding="utf-8") as fp:
|
||||
json_data = json.load(fp)
|
||||
elif isinstance(data, str):
|
||||
# 尝试作为 JSON 字符串解析
|
||||
json_data = json.loads(data)
|
||||
else:
|
||||
raise FileNotFoundError(f"文件不存在: {data}")
|
||||
elif isinstance(data, dict):
|
||||
json_data = data
|
||||
else:
|
||||
raise TypeError(f"不支持的数据类型: {type(data)}")
|
||||
|
||||
# 根据格式解析数据
|
||||
if "workflow" in json_data and "reagent" in json_data:
|
||||
# 格式1: workflow/reagent(已经是规范格式)
|
||||
protocol_steps = json_data["workflow"]
|
||||
labware_info = json_data["reagent"]
|
||||
elif "steps_info" in json_data and "labware_info" in json_data:
|
||||
# 格式2: steps_info/labware_info(需要规范化)
|
||||
protocol_steps = normalize_steps(json_data["steps_info"])
|
||||
labware_info = normalize_labware(json_data["labware_info"])
|
||||
elif "steps" in json_data and "labware" in json_data:
|
||||
# 格式3: steps/labware(另一种常见格式)
|
||||
protocol_steps = normalize_steps(json_data["steps"])
|
||||
if isinstance(json_data["labware"], list):
|
||||
labware_info = normalize_labware(json_data["labware"])
|
||||
else:
|
||||
labware_info = json_data["labware"]
|
||||
else:
|
||||
raise ValueError(
|
||||
"不支持的 JSON 格式。支持的格式:\n"
|
||||
"1. {'workflow': [...], 'reagent': {...}}\n"
|
||||
"2. {'steps_info': [...], 'labware_info': [...]}\n"
|
||||
"3. {'steps': [...], 'labware': [...]}"
|
||||
)
|
||||
|
||||
# 构建工作流图
|
||||
graph = build_protocol_graph(
|
||||
labware_info=labware_info,
|
||||
protocol_steps=protocol_steps,
|
||||
workstation_name=workstation_name,
|
||||
action_resource_mapping=ACTION_RESOURCE_MAPPING,
|
||||
)
|
||||
|
||||
# 校验句柄配置
|
||||
if validate:
|
||||
is_valid, errors = validate_workflow_handles(graph)
|
||||
if not is_valid:
|
||||
import warnings
|
||||
|
||||
for error in errors:
|
||||
warnings.warn(f"句柄校验警告: {error}")
|
||||
|
||||
return graph
|
||||
|
||||
|
||||
def convert_json_to_node_link(
|
||||
data: Union[str, PathLike, Dict[str, Any]],
|
||||
workstation_name: str = "PRCXi",
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
将 JSON 数据转换为 node-link 格式的字典
|
||||
|
||||
Args:
|
||||
data: JSON 文件路径、字典数据、或 JSON 字符串
|
||||
workstation_name: 工作站名称,默认 "PRCXi"
|
||||
|
||||
Returns:
|
||||
Dict: node-link 格式的工作流数据
|
||||
"""
|
||||
graph = convert_from_json(data, workstation_name)
|
||||
return graph.to_node_link_dict()
|
||||
|
||||
|
||||
def convert_json_to_workflow_list(
|
||||
data: Union[str, PathLike, Dict[str, Any]],
|
||||
workstation_name: str = "PRCXi",
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
将 JSON 数据转换为工作流列表格式
|
||||
|
||||
Args:
|
||||
data: JSON 文件路径、字典数据、或 JSON 字符串
|
||||
workstation_name: 工作站名称,默认 "PRCXi"
|
||||
|
||||
Returns:
|
||||
List: 工作流节点列表
|
||||
"""
|
||||
graph = convert_from_json(data, workstation_name)
|
||||
return graph.to_dict()
|
||||
|
||||
|
||||
# 为了向后兼容,保留下划线前缀的别名
|
||||
_normalize_steps = normalize_steps
|
||||
_normalize_labware = normalize_labware
|
||||
@@ -41,7 +41,6 @@ def upload_workflow(
|
||||
workflow_name: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
published: bool = False,
|
||||
description: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
上传工作流到服务器
|
||||
@@ -57,7 +56,6 @@ def upload_workflow(
|
||||
workflow_name: 工作流名称,如果不提供则从文件中读取或使用文件名
|
||||
tags: 工作流标签列表,默认为空列表
|
||||
published: 是否发布工作流,默认为False
|
||||
description: 工作流描述,发布时使用
|
||||
|
||||
Returns:
|
||||
Dict: API响应数据
|
||||
@@ -77,14 +75,6 @@ def upload_workflow(
|
||||
print_status(f"工作流文件JSON解析失败: {e}", "error")
|
||||
return {"code": -1, "message": f"JSON解析失败: {e}"}
|
||||
|
||||
# 从 JSON 文件中提取 description 和 tags(作为 fallback)
|
||||
if not description and "description" in workflow_data:
|
||||
description = workflow_data["description"]
|
||||
print_status(f"从文件中读取 description", "info")
|
||||
if not tags and "tags" in workflow_data:
|
||||
tags = workflow_data["tags"]
|
||||
print_status(f"从文件中读取 tags: {tags}", "info")
|
||||
|
||||
# 自动检测并转换格式
|
||||
if not _is_node_link_format(workflow_data):
|
||||
try:
|
||||
@@ -106,7 +96,6 @@ def upload_workflow(
|
||||
print_status(f" - 节点数量: {len(nodes)}", "info")
|
||||
print_status(f" - 边数量: {len(edges)}", "info")
|
||||
print_status(f" - 标签: {tags or []}", "info")
|
||||
print_status(f" - 描述: {description[:50]}{'...' if len(description) > 50 else ''}", "info")
|
||||
print_status(f" - 发布状态: {published}", "info")
|
||||
|
||||
# 调用 http_client 上传
|
||||
@@ -118,7 +107,6 @@ def upload_workflow(
|
||||
edges=edges,
|
||||
tags=tags,
|
||||
published=published,
|
||||
description=description,
|
||||
)
|
||||
|
||||
if result.get("code") == 0:
|
||||
@@ -143,9 +131,8 @@ def handle_workflow_upload_command(args_dict: Dict[str, Any]) -> None:
|
||||
workflow_name = args_dict.get("workflow_name")
|
||||
tags = args_dict.get("tags", [])
|
||||
published = args_dict.get("published", False)
|
||||
description = args_dict.get("description", "")
|
||||
|
||||
if workflow_file:
|
||||
upload_workflow(workflow_file, workflow_name, tags, published, description)
|
||||
upload_workflow(workflow_file, workflow_name, tags, published)
|
||||
else:
|
||||
print_status("未指定工作流文件路径,请使用 -f/--workflow_file 参数", "error")
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
|
||||
<package format="3">
|
||||
<name>unilabos_msgs</name>
|
||||
<version>0.10.18</version>
|
||||
<version>0.10.17</version>
|
||||
<description>ROS2 Messages package for unilabos devices</description>
|
||||
<maintainer email="changjh@pku.edu.cn">Junhan Chang</maintainer>
|
||||
<maintainer email="18435084+Xuwznln@users.noreply.github.com">Xuwznln</maintainer>
|
||||
|
||||
Reference in New Issue
Block a user