mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-04-25 13:19:56 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06b6f0d804 | ||
|
|
b551e69f64 | ||
|
|
5179a7e48e | ||
|
|
3a2d9e9603 | ||
|
|
a277bd2bed | ||
|
|
176de521b4 | ||
|
|
38c5c267af | ||
|
|
2a5ddd611d | ||
|
|
8580b84167 | ||
|
|
3f80349d7d | ||
|
|
024156848e | ||
|
|
8066c200b9 | ||
|
|
266366cc25 | ||
|
|
121c3985cc | ||
|
|
6ca5c72fc6 | ||
|
|
bc8c49ddda | ||
|
|
28f93737ac | ||
|
|
5dc81ec9be | ||
|
|
13a6795657 | ||
|
|
53219d8b04 |
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
package:
|
package:
|
||||||
name: unilabos
|
name: unilabos
|
||||||
version: 0.10.19
|
version: 0.10.18
|
||||||
|
|
||||||
source:
|
source:
|
||||||
path: ../../unilabos
|
path: ../../unilabos
|
||||||
@@ -54,7 +54,7 @@ requirements:
|
|||||||
- pymodbus
|
- pymodbus
|
||||||
- matplotlib
|
- matplotlib
|
||||||
- pylibftdi
|
- pylibftdi
|
||||||
- uni-lab::unilabos-env ==0.10.19
|
- uni-lab::unilabos-env ==0.10.18
|
||||||
|
|
||||||
about:
|
about:
|
||||||
repository: https://github.com/deepmodeling/Uni-Lab-OS
|
repository: https://github.com/deepmodeling/Uni-Lab-OS
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
package:
|
package:
|
||||||
name: unilabos-env
|
name: unilabos-env
|
||||||
version: 0.10.19
|
version: 0.10.18
|
||||||
|
|
||||||
build:
|
build:
|
||||||
noarch: generic
|
noarch: generic
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
package:
|
package:
|
||||||
name: unilabos-full
|
name: unilabos-full
|
||||||
version: 0.10.19
|
version: 0.10.18
|
||||||
|
|
||||||
build:
|
build:
|
||||||
noarch: generic
|
noarch: generic
|
||||||
@@ -11,7 +11,7 @@ build:
|
|||||||
requirements:
|
requirements:
|
||||||
run:
|
run:
|
||||||
# Base unilabos package (includes unilabos-env)
|
# Base unilabos package (includes unilabos-env)
|
||||||
- uni-lab::unilabos ==0.10.19
|
- uni-lab::unilabos ==0.10.18
|
||||||
# Documentation tools
|
# Documentation tools
|
||||||
- sphinx
|
- sphinx
|
||||||
- sphinx_rtd_theme
|
- sphinx_rtd_theme
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
@echo off
|
|
||||||
setlocal enabledelayedexpansion
|
|
||||||
|
|
||||||
REM upgrade pip
|
|
||||||
"%PREFIX%\python.exe" -m pip install --upgrade pip
|
|
||||||
|
|
||||||
REM install extra deps
|
|
||||||
"%PREFIX%\python.exe" -m pip install paho-mqtt opentrons_shared_data
|
|
||||||
"%PREFIX%\python.exe" -m pip install git+https://github.com/Xuwznln/pylabrobot.git
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euxo pipefail
|
|
||||||
|
|
||||||
# make sure pip is available
|
|
||||||
"$PREFIX/bin/python" -m pip install --upgrade pip
|
|
||||||
|
|
||||||
# install extra deps
|
|
||||||
"$PREFIX/bin/python" -m pip install paho-mqtt opentrons_shared_data
|
|
||||||
"$PREFIX/bin/python" -m pip install git+https://github.com/Xuwznln/pylabrobot.git
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
---
|
|
||||||
name: add-device
|
|
||||||
description: Guide for adding new devices to Uni-Lab-OS (接入新设备). Uses @device decorator + AST auto-scanning instead of manual YAML. Walks through device category, communication protocol, driver creation with decorators, and graph file setup. Use when the user wants to add/integrate a new device, create a device driver, write a device class, or mentions 接入设备/添加设备/设备驱动/物模型.
|
|
||||||
---
|
|
||||||
|
|
||||||
# 添加新设备到 Uni-Lab-OS
|
|
||||||
|
|
||||||
**第一步:** 使用 Read 工具读取 `docs/ai_guides/add_device.md`,获取完整的设备接入指南。
|
|
||||||
|
|
||||||
该指南包含设备类别(物模型)列表、通信协议模板、常见错误检查清单等。搜索 `unilabos/devices/` 获取已有设备的实现参考。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 装饰器参考
|
|
||||||
|
|
||||||
### @device — 设备类装饰器
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.registry.decorators import device
|
|
||||||
|
|
||||||
# 单设备
|
|
||||||
@device(
|
|
||||||
id="my_device.vendor", # 注册表唯一标识(必填)
|
|
||||||
category=["temperature"], # 分类标签列表(必填)
|
|
||||||
description="设备描述", # 设备描述
|
|
||||||
display_name="显示名称", # UI 显示名称(默认用 id)
|
|
||||||
icon="DeviceIcon.webp", # 图标文件名
|
|
||||||
version="1.0.0", # 版本号
|
|
||||||
device_type="python", # "python" 或 "ros2"
|
|
||||||
handles=[...], # 端口列表(InputHandle / OutputHandle)
|
|
||||||
model={...}, # 3D 模型配置
|
|
||||||
hardware_interface=HardwareInterface(...), # 硬件通信接口
|
|
||||||
)
|
|
||||||
|
|
||||||
# 多设备(同一个类注册多个设备 ID,各自有不同的 handles 等配置)
|
|
||||||
@device(
|
|
||||||
ids=["pump.vendor.model_A", "pump.vendor.model_B"],
|
|
||||||
id_meta={
|
|
||||||
"pump.vendor.model_A": {"handles": [...], "description": "型号 A"},
|
|
||||||
"pump.vendor.model_B": {"handles": [...], "description": "型号 B"},
|
|
||||||
},
|
|
||||||
category=["pump_and_valve"],
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### @action — 动作方法装饰器
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.registry.decorators import action
|
|
||||||
|
|
||||||
@action # 无参:注册为 UniLabJsonCommand 动作
|
|
||||||
@action() # 同上
|
|
||||||
@action(description="执行操作") # 带描述
|
|
||||||
@action(
|
|
||||||
action_type=HeatChill, # 指定 ROS Action 消息类型
|
|
||||||
goal={"temperature": "temp"}, # Goal 字段映射
|
|
||||||
feedback={}, # Feedback 字段映射
|
|
||||||
result={}, # Result 字段映射
|
|
||||||
handles=[...], # 动作级别端口
|
|
||||||
goal_default={"temp": 25.0}, # Goal 默认值
|
|
||||||
placeholder_keys={...}, # 参数占位符
|
|
||||||
always_free=True, # 不受排队限制
|
|
||||||
auto_prefix=True, # 强制使用 auto- 前缀
|
|
||||||
parent=True, # 从父类 MRO 获取参数签名
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**自动识别规则:**
|
|
||||||
- 带 `@action` 的公开方法 → 注册为动作(方法名即动作名)
|
|
||||||
- **不带 `@action` 的公开方法** → 自动注册为 `auto-{方法名}` 动作
|
|
||||||
- `_` 开头的方法 → 不扫描
|
|
||||||
- `@not_action` 标记的方法 → 排除
|
|
||||||
|
|
||||||
### @topic_config — 状态属性配置
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.registry.decorators import topic_config
|
|
||||||
|
|
||||||
@property
|
|
||||||
@topic_config(
|
|
||||||
period=5.0, # 发布周期(秒),默认 5.0
|
|
||||||
print_publish=False, # 是否打印发布日志
|
|
||||||
qos=10, # QoS 深度,默认 10
|
|
||||||
name="custom_name", # 自定义发布名称(默认用属性名)
|
|
||||||
)
|
|
||||||
def temperature(self) -> float:
|
|
||||||
return self.data.get("temperature", 0.0)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 辅助装饰器
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.registry.decorators import not_action, always_free
|
|
||||||
|
|
||||||
@not_action # 标记为非动作(post_init、辅助方法等)
|
|
||||||
@always_free # 标记为不受排队限制(查询类操作)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 设备模板
|
|
||||||
|
|
||||||
```python
|
|
||||||
import logging
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
|
||||||
from unilabos.registry.decorators import device, action, topic_config, not_action
|
|
||||||
|
|
||||||
@device(id="my_device", category=["my_category"], description="设备描述")
|
|
||||||
class MyDevice:
|
|
||||||
_ros_node: BaseROS2DeviceNode
|
|
||||||
|
|
||||||
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
|
|
||||||
self.device_id = device_id or "my_device"
|
|
||||||
self.config = config or {}
|
|
||||||
self.logger = logging.getLogger(f"MyDevice.{self.device_id}")
|
|
||||||
self.data: Dict[str, Any] = {"status": "Idle"}
|
|
||||||
|
|
||||||
@not_action
|
|
||||||
def post_init(self, ros_node: BaseROS2DeviceNode) -> None:
|
|
||||||
self._ros_node = ros_node
|
|
||||||
|
|
||||||
@action
|
|
||||||
async def initialize(self) -> bool:
|
|
||||||
self.data["status"] = "Ready"
|
|
||||||
return True
|
|
||||||
|
|
||||||
@action
|
|
||||||
async def cleanup(self) -> bool:
|
|
||||||
self.data["status"] = "Offline"
|
|
||||||
return True
|
|
||||||
|
|
||||||
@action(description="执行操作")
|
|
||||||
def my_action(self, param: float = 0.0, name: str = "") -> Dict[str, Any]:
|
|
||||||
"""带 @action 装饰器 → 注册为 'my_action' 动作"""
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
def get_info(self) -> Dict[str, Any]:
|
|
||||||
"""无 @action → 自动注册为 'auto-get_info' 动作"""
|
|
||||||
return {"device_id": self.device_id}
|
|
||||||
|
|
||||||
@property
|
|
||||||
@topic_config()
|
|
||||||
def status(self) -> str:
|
|
||||||
return self.data.get("status", "Idle")
|
|
||||||
|
|
||||||
@property
|
|
||||||
@topic_config(period=2.0)
|
|
||||||
def temperature(self) -> float:
|
|
||||||
return self.data.get("temperature", 0.0)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 要点
|
|
||||||
|
|
||||||
- `_ros_node: BaseROS2DeviceNode` 类型标注放在类体顶部
|
|
||||||
- `__init__` 签名固定为 `(self, device_id=None, config=None, **kwargs)`
|
|
||||||
- `post_init` 用 `@not_action` 标记,参数类型标注为 `BaseROS2DeviceNode`
|
|
||||||
- 运行时状态存储在 `self.data` 字典中
|
|
||||||
- 设备文件放在 `unilabos/devices/<category>/` 目录下
|
|
||||||
@@ -1,351 +0,0 @@
|
|||||||
---
|
|
||||||
name: add-resource
|
|
||||||
description: Guide for adding new resources (materials, bottles, carriers, decks, warehouses) to Uni-Lab-OS (添加新物料/资源). Uses @resource decorator for AST auto-scanning. Covers Bottle, Carrier, Deck, WareHouse definitions. Use when the user wants to add resources, define materials, create a deck layout, add bottles/carriers/plates, or mentions 物料/资源/resource/bottle/carrier/deck/plate/warehouse.
|
|
||||||
---
|
|
||||||
|
|
||||||
# 添加新物料资源
|
|
||||||
|
|
||||||
Uni-Lab-OS 的资源体系基于 PyLabRobot,通过扩展实现 Bottle、Carrier、WareHouse、Deck 等实验室物料管理。使用 `@resource` 装饰器注册,AST 自动扫描生成注册表条目。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 资源类型
|
|
||||||
|
|
||||||
| 类型 | 基类 | 用途 | 示例 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| **Bottle** | `Well` (PyLabRobot) | 单个容器(瓶、小瓶、烧杯、反应器) | 试剂瓶、粉末瓶 |
|
|
||||||
| **BottleCarrier** | `ItemizedCarrier` | 多槽位载架(放多个 Bottle) | 6 位试剂架、枪头盒 |
|
|
||||||
| **WareHouse** | `ItemizedCarrier` | 堆栈/仓库(放多个 Carrier) | 4x4 堆栈 |
|
|
||||||
| **Deck** | `Deck` (PyLabRobot) | 工作站台面(放多个 WareHouse) | 反应站 Deck |
|
|
||||||
|
|
||||||
**层级关系:** `Deck` → `WareHouse` → `BottleCarrier` → `Bottle`
|
|
||||||
|
|
||||||
WareHouse 本质上和 Site 是同一概念 — 都是定义一组固定的放置位(slot),只不过 WareHouse 多嵌套了一层 Deck。两者都需要开发者根据实际物理尺寸自行计算各 slot 的偏移坐标。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## @resource 装饰器
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.registry.decorators import resource
|
|
||||||
|
|
||||||
@resource(
|
|
||||||
id="my_resource_id", # 注册表唯一标识(必填)
|
|
||||||
category=["bottles"], # 分类标签列表(必填)
|
|
||||||
description="资源描述",
|
|
||||||
icon="", # 图标
|
|
||||||
version="1.0.0",
|
|
||||||
handles=[...], # 端口列表(InputHandle / OutputHandle)
|
|
||||||
model={...}, # 3D 模型配置
|
|
||||||
class_type="pylabrobot", # "python" / "pylabrobot" / "unilabos"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 创建规范
|
|
||||||
|
|
||||||
### 命名规则
|
|
||||||
|
|
||||||
1. **`name` 参数作为前缀**:所有工厂函数必须接受 `name: str` 参数,创建子物料时以 `name` 作为前缀,确保实例名在运行时全局唯一
|
|
||||||
2. **Bottle 命名约定**:试剂瓶-Bottle,烧杯-Beaker,烧瓶-Flask,小瓶-Vial
|
|
||||||
3. **函数名 = `@resource(id=...)`**:工厂函数名与注册表 id 保持一致
|
|
||||||
|
|
||||||
### 子物料命名示例
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Carrier 内部的 sites 用 name 前缀
|
|
||||||
for k, v in sites.items():
|
|
||||||
v.name = f"{name}_{v.name}" # "堆栈1左_A01", "堆栈1左_B02" ...
|
|
||||||
|
|
||||||
# Carrier 中放置 Bottle 时用 name 前缀
|
|
||||||
carrier[0] = My_Reagent_Bottle(f"{name}_flask_1") # "堆栈1左_flask_1"
|
|
||||||
carrier[i] = My_Solid_Vial(f"{name}_vial_{ordering[i]}") # "堆栈1左_vial_A1"
|
|
||||||
|
|
||||||
# create_homogeneous_resources 使用 name_prefix
|
|
||||||
sites=create_homogeneous_resources(
|
|
||||||
klass=ResourceHolder,
|
|
||||||
locations=[...],
|
|
||||||
name_prefix=name, # 自动生成 "{name}_0", "{name}_1" ...
|
|
||||||
)
|
|
||||||
|
|
||||||
# Deck setup 中用仓库名称作为 name 传入
|
|
||||||
self.warehouses = {
|
|
||||||
"堆栈1左": my_warehouse_4x4("堆栈1左"), # WareHouse.name = "堆栈1左"
|
|
||||||
"试剂堆栈": my_reagent_stack("试剂堆栈"), # WareHouse.name = "试剂堆栈"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 其他规范
|
|
||||||
|
|
||||||
- **max_volume 单位为 μL**:500mL = 500000
|
|
||||||
- **尺寸单位为 mm**:`diameter`, `height`, `size_x/y/z`, `dx/dy/dz`
|
|
||||||
- **BottleCarrier 必须设置 `num_items_x/y/z`**:用于前端渲染布局
|
|
||||||
- **Deck 的 `__init__` 必须接受 `setup=False`**:图文件中 `config.setup=true` 触发 `setup()`
|
|
||||||
- **按项目分组文件**:同一工作站的资源放在 `unilabos/resources/<project>/` 下
|
|
||||||
- **`__init__` 必须接受 `serialize()` 输出的所有字段**:`serialize()` 输出会作为 `config` 回传到 `__init__`,因此必须通过显式参数或 `**kwargs` 接受,否则反序列化会报错
|
|
||||||
- **持久化运行时状态用 `serialize_state()`**:通过 `_unilabos_state` 字典存储可变信息(如物料内容、液体量),只存 JSON 可序列化的基本类型
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 资源模板
|
|
||||||
|
|
||||||
### Bottle
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.registry.decorators import resource
|
|
||||||
from unilabos.resources.itemized_carrier import Bottle
|
|
||||||
|
|
||||||
|
|
||||||
@resource(id="My_Reagent_Bottle", category=["bottles"], description="我的试剂瓶")
|
|
||||||
def My_Reagent_Bottle(
|
|
||||||
name: str,
|
|
||||||
diameter: float = 70.0,
|
|
||||||
height: float = 120.0,
|
|
||||||
max_volume: float = 500000.0,
|
|
||||||
barcode: str = None,
|
|
||||||
) -> Bottle:
|
|
||||||
return Bottle(
|
|
||||||
name=name,
|
|
||||||
diameter=diameter,
|
|
||||||
height=height,
|
|
||||||
max_volume=max_volume,
|
|
||||||
barcode=barcode,
|
|
||||||
model="My_Reagent_Bottle",
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Bottle 参数:**
|
|
||||||
- `name`: 实例名称(运行时唯一,由上层 Carrier 以前缀方式传入)
|
|
||||||
- `diameter`: 瓶体直径 (mm)
|
|
||||||
- `height`: 瓶体高度 (mm)
|
|
||||||
- `max_volume`: 最大容积(**μL**,500mL = 500000)
|
|
||||||
- `barcode`: 条形码(可选)
|
|
||||||
|
|
||||||
### BottleCarrier
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pylabrobot.resources import ResourceHolder
|
|
||||||
from pylabrobot.resources.carrier import create_ordered_items_2d
|
|
||||||
from unilabos.resources.itemized_carrier import BottleCarrier
|
|
||||||
from unilabos.registry.decorators import resource
|
|
||||||
|
|
||||||
|
|
||||||
@resource(id="My_6SlotCarrier", category=["bottle_carriers"], description="六槽位载架")
|
|
||||||
def My_6SlotCarrier(name: str) -> BottleCarrier:
|
|
||||||
sites = create_ordered_items_2d(
|
|
||||||
klass=ResourceHolder,
|
|
||||||
num_items_x=3, num_items_y=2,
|
|
||||||
dx=10.0, dy=10.0, dz=5.0,
|
|
||||||
item_dx=42.0, item_dy=35.0,
|
|
||||||
size_x=20.0, size_y=20.0, size_z=50.0,
|
|
||||||
)
|
|
||||||
# 子 site 用 name 作为前缀
|
|
||||||
for k, v in sites.items():
|
|
||||||
v.name = f"{name}_{v.name}"
|
|
||||||
|
|
||||||
carrier = BottleCarrier(
|
|
||||||
name=name, size_x=146.0, size_y=80.0, size_z=55.0,
|
|
||||||
sites=sites, model="My_6SlotCarrier",
|
|
||||||
)
|
|
||||||
carrier.num_items_x = 3
|
|
||||||
carrier.num_items_y = 2
|
|
||||||
carrier.num_items_z = 1
|
|
||||||
|
|
||||||
# 放置 Bottle 时用 name 作为前缀
|
|
||||||
ordering = ["A1", "B1", "A2", "B2", "A3", "B3"]
|
|
||||||
for i in range(6):
|
|
||||||
carrier[i] = My_Reagent_Bottle(f"{name}_vial_{ordering[i]}")
|
|
||||||
return carrier
|
|
||||||
```
|
|
||||||
|
|
||||||
### WareHouse / Deck 放置位
|
|
||||||
|
|
||||||
WareHouse 和 Site 本质上是同一概念:都是定义一组固定放置位(slot),根据物理尺寸自行批量计算偏移坐标。WareHouse 只是多嵌套了一层 Deck 而已。推荐开发者直接根据实物测量数据计算各 slot 偏移量。
|
|
||||||
|
|
||||||
#### WareHouse(使用 warehouse_factory)
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.resources.warehouse import warehouse_factory
|
|
||||||
from unilabos.registry.decorators import resource
|
|
||||||
|
|
||||||
|
|
||||||
@resource(id="my_warehouse_4x4", category=["warehouse"], description="4x4 堆栈仓库")
|
|
||||||
def my_warehouse_4x4(name: str) -> "WareHouse":
|
|
||||||
return warehouse_factory(
|
|
||||||
name=name,
|
|
||||||
num_items_x=4, num_items_y=4, num_items_z=1,
|
|
||||||
dx=10.0, dy=10.0, dz=10.0, # 第一个 slot 的起始偏移
|
|
||||||
item_dx=147.0, item_dy=106.0, item_dz=130.0, # slot 间距
|
|
||||||
resource_size_x=127.0, resource_size_y=85.0, resource_size_z=100.0, # slot 尺寸
|
|
||||||
model="my_warehouse_4x4",
|
|
||||||
col_offset=0, # 列标签起始偏移(0 → A01, 4 → A05)
|
|
||||||
layout="row-major", # "row-major" 行优先 / "col-major" 列优先 / "vertical-col-major" 竖向
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
`warehouse_factory` 参数说明:
|
|
||||||
- `dx/dy/dz`:第一个 slot 相对 WareHouse 原点的偏移(mm)
|
|
||||||
- `item_dx/item_dy/item_dz`:相邻 slot 间距(mm),需根据实际物理间距测量
|
|
||||||
- `resource_size_x/y/z`:每个 slot 的可放置区域尺寸
|
|
||||||
- `layout`:影响 slot 标签和坐标映射
|
|
||||||
- `"row-major"`:A01,A02,...,B01,B02,...(行优先,适合横向排列)
|
|
||||||
- `"col-major"`:A01,B01,...,A02,B02,...(列优先)
|
|
||||||
- `"vertical-col-major"`:竖向排列,y 坐标反向
|
|
||||||
|
|
||||||
#### Deck 组装 WareHouse
|
|
||||||
|
|
||||||
Deck 通过 `setup()` 将多个 WareHouse 放置到指定坐标:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pylabrobot.resources import Deck, Coordinate
|
|
||||||
from unilabos.registry.decorators import resource
|
|
||||||
|
|
||||||
|
|
||||||
@resource(id="MyStation_Deck", category=["deck"], description="我的工作站 Deck")
|
|
||||||
class MyStation_Deck(Deck):
|
|
||||||
def __init__(self, name="MyStation_Deck", size_x=2700.0, size_y=1080.0, size_z=1500.0,
|
|
||||||
category="deck", setup=False, **kwargs) -> None:
|
|
||||||
super().__init__(name=name, size_x=size_x, size_y=size_y, size_z=size_z)
|
|
||||||
if setup:
|
|
||||||
self.setup()
|
|
||||||
|
|
||||||
def setup(self) -> None:
|
|
||||||
self.warehouses = {
|
|
||||||
"堆栈1左": my_warehouse_4x4("堆栈1左"),
|
|
||||||
"堆栈1右": my_warehouse_4x4("堆栈1右"),
|
|
||||||
}
|
|
||||||
self.warehouse_locations = {
|
|
||||||
"堆栈1左": Coordinate(-200.0, 400.0, 0.0), # 自行测量计算
|
|
||||||
"堆栈1右": Coordinate(2350.0, 400.0, 0.0),
|
|
||||||
}
|
|
||||||
for wh_name, wh in self.warehouses.items():
|
|
||||||
self.assign_child_resource(wh, location=self.warehouse_locations[wh_name])
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Site 模式(前端定向放置)
|
|
||||||
|
|
||||||
适用于有固定孔位/槽位的设备(如移液站 PRCXI 9300),Deck 通过 `sites` 列表定义前端展示的放置位,前端据此渲染可拖拽的孔位布局:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import collections
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
from pylabrobot.resources import Deck, Resource, Coordinate
|
|
||||||
from unilabos.registry.decorators import resource
|
|
||||||
|
|
||||||
|
|
||||||
@resource(id="MyLabDeck", category=["deck"], description="带 Site 定向放置的 Deck")
|
|
||||||
class MyLabDeck(Deck):
|
|
||||||
# 根据设备台面实测批量计算各 slot 坐标偏移
|
|
||||||
_DEFAULT_SITE_POSITIONS = [
|
|
||||||
(0, 0, 0), (138, 0, 0), (276, 0, 0), (414, 0, 0), # T1-T4
|
|
||||||
(0, 96, 0), (138, 96, 0), (276, 96, 0), (414, 96, 0), # T5-T8
|
|
||||||
]
|
|
||||||
_DEFAULT_SITE_SIZE = {"width": 128.0, "height": 86.0, "depth": 0}
|
|
||||||
_DEFAULT_CONTENT_TYPE = ["plate", "tip_rack", "tube_rack", "adaptor"]
|
|
||||||
|
|
||||||
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
|
|
||||||
sites: Optional[List[Dict[str, Any]]] = None, **kwargs):
|
|
||||||
super().__init__(size_x, size_y, size_z, name)
|
|
||||||
if sites is not None:
|
|
||||||
self.sites = [dict(s) for s in sites]
|
|
||||||
else:
|
|
||||||
self.sites = []
|
|
||||||
for i, (x, y, z) in enumerate(self._DEFAULT_SITE_POSITIONS):
|
|
||||||
self.sites.append({
|
|
||||||
"label": f"T{i + 1}", # 前端显示的槽位标签
|
|
||||||
"visible": True, # 是否在前端可见
|
|
||||||
"position": {"x": x, "y": y, "z": z}, # 槽位物理坐标
|
|
||||||
"size": dict(self._DEFAULT_SITE_SIZE), # 槽位尺寸
|
|
||||||
"content_type": list(self._DEFAULT_CONTENT_TYPE), # 允许放入的物料类型
|
|
||||||
})
|
|
||||||
self._ordering = collections.OrderedDict(
|
|
||||||
(site["label"], None) for site in self.sites
|
|
||||||
)
|
|
||||||
|
|
||||||
def assign_child_resource(self, resource: Resource,
|
|
||||||
location: Optional[Coordinate] = None,
|
|
||||||
reassign: bool = True,
|
|
||||||
spot: Optional[int] = None):
|
|
||||||
idx = spot
|
|
||||||
if spot is None:
|
|
||||||
for i, site in enumerate(self.sites):
|
|
||||||
if site.get("label") == resource.name:
|
|
||||||
idx = i
|
|
||||||
break
|
|
||||||
if idx is None:
|
|
||||||
for i in range(len(self.sites)):
|
|
||||||
if self._get_site_resource(i) is None:
|
|
||||||
idx = i
|
|
||||||
break
|
|
||||||
if idx is None:
|
|
||||||
raise ValueError(f"No available site for '{resource.name}'")
|
|
||||||
loc = Coordinate(**self.sites[idx]["position"])
|
|
||||||
super().assign_child_resource(resource, location=loc, reassign=reassign)
|
|
||||||
|
|
||||||
def serialize(self) -> dict:
|
|
||||||
data = super().serialize()
|
|
||||||
sites_out = []
|
|
||||||
for i, site in enumerate(self.sites):
|
|
||||||
occupied = self._get_site_resource(i)
|
|
||||||
sites_out.append({
|
|
||||||
"label": site["label"],
|
|
||||||
"visible": site.get("visible", True),
|
|
||||||
"occupied_by": occupied.name if occupied else None,
|
|
||||||
"position": site["position"],
|
|
||||||
"size": site["size"],
|
|
||||||
"content_type": site["content_type"],
|
|
||||||
})
|
|
||||||
data["sites"] = sites_out
|
|
||||||
return data
|
|
||||||
```
|
|
||||||
|
|
||||||
**Site 字段说明:**
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| `label` | str | 槽位标签(如 `"T1"`),前端显示名称,也用于匹配 resource.name |
|
|
||||||
| `visible` | bool | 是否在前端可见 |
|
|
||||||
| `position` | dict | 物理坐标 `{x, y, z}`(mm),需自行测量计算偏移 |
|
|
||||||
| `size` | dict | 槽位尺寸 `{width, height, depth}`(mm) |
|
|
||||||
| `content_type` | list | 允许放入的物料类型,如 `["plate", "tip_rack", "tube_rack", "adaptor"]` |
|
|
||||||
|
|
||||||
**参考实现:** `unilabos/devices/liquid_handling/prcxi/prcxi.py` 中的 `PRCXI9300Deck`(4x4 共 16 个 site)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 文件位置
|
|
||||||
|
|
||||||
```
|
|
||||||
unilabos/resources/
|
|
||||||
├── <project>/ # 按项目分组
|
|
||||||
│ ├── bottles.py # Bottle 工厂函数
|
|
||||||
│ ├── bottle_carriers.py # Carrier 工厂函数
|
|
||||||
│ ├── warehouses.py # WareHouse 工厂函数
|
|
||||||
│ └── decks.py # Deck 类定义
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 验证
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 资源可导入
|
|
||||||
python -c "from unilabos.resources.my_project.bottles import My_Reagent_Bottle; print(My_Reagent_Bottle('test'))"
|
|
||||||
|
|
||||||
# 启动测试(AST 自动扫描)
|
|
||||||
unilab -g <graph>.json
|
|
||||||
```
|
|
||||||
|
|
||||||
仅在以下情况仍需 YAML:第三方库资源(如 pylabrobot 内置资源,无 `@resource` 装饰器)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 关键路径
|
|
||||||
|
|
||||||
| 内容 | 路径 |
|
|
||||||
|------|------|
|
|
||||||
| Bottle/Carrier 基类 | `unilabos/resources/itemized_carrier.py` |
|
|
||||||
| WareHouse 基类 + 工厂 | `unilabos/resources/warehouse.py` |
|
|
||||||
| PLR 注册 | `unilabos/resources/plr_additional_res_reg.py` |
|
|
||||||
| 装饰器定义 | `unilabos/registry/decorators.py` |
|
|
||||||
@@ -1,292 +0,0 @@
|
|||||||
# 资源高级参考
|
|
||||||
|
|
||||||
本文件是 SKILL.md 的补充,包含类继承体系、序列化/反序列化、Bioyond 物料同步、非瓶类资源和仓库工厂模式。Agent 在需要实现这些功能时按需阅读。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 类继承体系
|
|
||||||
|
|
||||||
```
|
|
||||||
PyLabRobot
|
|
||||||
├── Resource (PLR 基类)
|
|
||||||
│ ├── Well
|
|
||||||
│ │ └── Bottle (unilabos) → 瓶/小瓶/烧杯/反应器
|
|
||||||
│ ├── Deck
|
|
||||||
│ │ └── 自定义 Deck 类 (unilabos) → 工作站台面
|
|
||||||
│ ├── ResourceHolder → 槽位占位符
|
|
||||||
│ └── Container
|
|
||||||
│ └── Battery (unilabos) → 组装好的电池
|
|
||||||
│
|
|
||||||
├── ItemizedCarrier (unilabos, 继承 Resource)
|
|
||||||
│ ├── BottleCarrier (unilabos) → 瓶载架
|
|
||||||
│ └── WareHouse (unilabos) → 堆栈仓库
|
|
||||||
│
|
|
||||||
├── ItemizedResource (PLR)
|
|
||||||
│ └── MagazineHolder (unilabos) → 子弹夹载架
|
|
||||||
│
|
|
||||||
└── ResourceStack (PLR)
|
|
||||||
└── Magazine (unilabos) → 子弹夹洞位
|
|
||||||
```
|
|
||||||
|
|
||||||
### Bottle 类细节
|
|
||||||
|
|
||||||
```python
|
|
||||||
class Bottle(Well):
|
|
||||||
def __init__(self, name, diameter, height, max_volume,
|
|
||||||
size_x=0.0, size_y=0.0, size_z=0.0,
|
|
||||||
barcode=None, category="container", model=None, **kwargs):
|
|
||||||
super().__init__(
|
|
||||||
name=name,
|
|
||||||
size_x=diameter, # PLR 用 diameter 作为 size_x/size_y
|
|
||||||
size_y=diameter,
|
|
||||||
size_z=height, # PLR 用 height 作为 size_z
|
|
||||||
max_volume=max_volume,
|
|
||||||
category=category,
|
|
||||||
model=model,
|
|
||||||
bottom_type="flat",
|
|
||||||
cross_section_type="circle"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
注意 `size_x = size_y = diameter`,`size_z = height`。
|
|
||||||
|
|
||||||
### ItemizedCarrier 核心方法
|
|
||||||
|
|
||||||
| 方法 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `__getitem__(identifier)` | 通过索引或 Excel 标识(如 `"A01"`)访问槽位 |
|
|
||||||
| `__setitem__(identifier, resource)` | 向槽位放入资源 |
|
|
||||||
| `get_child_identifier(child)` | 获取子资源的标识符 |
|
|
||||||
| `capacity` | 总槽位数 |
|
|
||||||
| `sites` | 所有槽位字典 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 序列化与反序列化
|
|
||||||
|
|
||||||
### PLR ↔ UniLab 转换
|
|
||||||
|
|
||||||
| 函数 | 位置 | 方向 |
|
|
||||||
|------|------|------|
|
|
||||||
| `ResourceTreeSet.from_plr_resources(resources)` | `resource_tracker.py` | PLR → UniLab |
|
|
||||||
| `ResourceTreeSet.to_plr_resources()` | `resource_tracker.py` | UniLab → PLR |
|
|
||||||
|
|
||||||
### `from_plr_resources` 流程
|
|
||||||
|
|
||||||
```
|
|
||||||
PLR Resource
|
|
||||||
↓ build_uuid_mapping (递归生成 UUID)
|
|
||||||
↓ resource.serialize() → dict
|
|
||||||
↓ resource.serialize_all_state() → states
|
|
||||||
↓ resource_plr_inner (递归构建 ResourceDictInstance)
|
|
||||||
ResourceTreeSet
|
|
||||||
```
|
|
||||||
|
|
||||||
关键:每个 PLR 资源通过 `unilabos_uuid` 属性携带 UUID,`unilabos_extra` 携带扩展数据(如 `class` 名)。
|
|
||||||
|
|
||||||
### `to_plr_resources` 流程
|
|
||||||
|
|
||||||
```
|
|
||||||
ResourceTreeSet
|
|
||||||
↓ collect_node_data (收集 UUID、状态、扩展数据)
|
|
||||||
↓ node_to_plr_dict (转为 PLR 字典格式)
|
|
||||||
↓ find_subclass(type_name, PLRResource) (查找 PLR 子类)
|
|
||||||
↓ sub_cls.deserialize(plr_dict) (反序列化)
|
|
||||||
↓ loop_set_uuid, loop_set_extra (递归设置 UUID 和扩展)
|
|
||||||
PLR Resource
|
|
||||||
```
|
|
||||||
|
|
||||||
### Bottle 序列化
|
|
||||||
|
|
||||||
```python
|
|
||||||
class Bottle(Well):
|
|
||||||
def serialize(self) -> dict:
|
|
||||||
data = super().serialize()
|
|
||||||
return {**data, "diameter": self.diameter, "height": self.height}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def deserialize(cls, data: dict, allow_marshal=False):
|
|
||||||
barcode_data = data.pop("barcode", None)
|
|
||||||
instance = super().deserialize(data, allow_marshal=allow_marshal)
|
|
||||||
if barcode_data and isinstance(barcode_data, str):
|
|
||||||
instance.barcode = barcode_data
|
|
||||||
return instance
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Bioyond 物料同步
|
|
||||||
|
|
||||||
### 双向转换函数
|
|
||||||
|
|
||||||
| 函数 | 位置 | 方向 |
|
|
||||||
|------|------|------|
|
|
||||||
| `resource_bioyond_to_plr(materials, type_mapping, deck)` | `graphio.py` | Bioyond → PLR |
|
|
||||||
| `resource_plr_to_bioyond(resources, type_mapping, warehouse_mapping)` | `graphio.py` | PLR → Bioyond |
|
|
||||||
|
|
||||||
### `resource_bioyond_to_plr` 流程
|
|
||||||
|
|
||||||
```
|
|
||||||
Bioyond 物料列表
|
|
||||||
↓ reverse_type_mapping: {typeName → (model, UUID)}
|
|
||||||
↓ 对每个物料:
|
|
||||||
typeName → 查映射 → model (如 "BIOYOND_PolymerStation_Reactor")
|
|
||||||
initialize_resource({"name": unique_name, "class": model})
|
|
||||||
↓ 设置 unilabos_extra (material_bioyond_id, material_bioyond_name 等)
|
|
||||||
↓ 处理 detail (子物料/坐标)
|
|
||||||
↓ 按 locationName 放入 deck.warehouses 对应槽位
|
|
||||||
PLR 资源列表
|
|
||||||
```
|
|
||||||
|
|
||||||
### `resource_plr_to_bioyond` 流程
|
|
||||||
|
|
||||||
```
|
|
||||||
PLR 资源列表
|
|
||||||
↓ 遍历每个资源:
|
|
||||||
载架(capacity > 1): 生成 details 子物料 + 坐标
|
|
||||||
单瓶: 直接映射
|
|
||||||
↓ type_mapping 查找 typeId
|
|
||||||
↓ warehouse_mapping 查找位置 UUID
|
|
||||||
↓ 组装 Bioyond 格式 (name, typeName, typeId, quantity, Parameters, locations)
|
|
||||||
Bioyond 物料列表
|
|
||||||
```
|
|
||||||
|
|
||||||
### BioyondResourceSynchronizer
|
|
||||||
|
|
||||||
工作站通过 `ResourceSynchronizer` 自动同步物料:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class BioyondResourceSynchronizer(ResourceSynchronizer):
|
|
||||||
def sync_from_external(self) -> bool:
|
|
||||||
all_data = []
|
|
||||||
all_data.extend(api_client.stock_material('{"typeMode": 0}')) # 耗材
|
|
||||||
all_data.extend(api_client.stock_material('{"typeMode": 1}')) # 样品
|
|
||||||
all_data.extend(api_client.stock_material('{"typeMode": 2}')) # 试剂
|
|
||||||
unilab_resources = resource_bioyond_to_plr(
|
|
||||||
all_data,
|
|
||||||
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
|
|
||||||
deck=self.workstation.deck
|
|
||||||
)
|
|
||||||
# 更新 deck 上的资源
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 非瓶类资源
|
|
||||||
|
|
||||||
### ElectrodeSheet(极片)
|
|
||||||
|
|
||||||
路径:`unilabos/resources/battery/electrode_sheet.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
class ElectrodeSheet(ResourcePLR):
|
|
||||||
"""片状材料(极片、隔膜、弹片、垫片等)"""
|
|
||||||
_unilabos_state = {
|
|
||||||
"diameter": 0.0,
|
|
||||||
"thickness": 0.0,
|
|
||||||
"mass": 0.0,
|
|
||||||
"material_type": "",
|
|
||||||
"color": "",
|
|
||||||
"info": "",
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
工厂函数:`PositiveCan`, `PositiveElectrode`, `NegativeCan`, `NegativeElectrode`, `SpringWasher`, `FlatWasher`, `AluminumFoil`
|
|
||||||
|
|
||||||
### Battery(电池)
|
|
||||||
|
|
||||||
```python
|
|
||||||
class Battery(Container):
|
|
||||||
"""组装好的电池"""
|
|
||||||
_unilabos_state = {
|
|
||||||
"color": "",
|
|
||||||
"electrolyte_name": "",
|
|
||||||
"open_circuit_voltage": 0.0,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Magazine / MagazineHolder(子弹夹)
|
|
||||||
|
|
||||||
```python
|
|
||||||
class Magazine(ResourceStack):
|
|
||||||
"""子弹夹洞位,可堆叠 ElectrodeSheet"""
|
|
||||||
# direction, max_sheets
|
|
||||||
|
|
||||||
class MagazineHolder(ItemizedResource):
|
|
||||||
"""多洞位子弹夹"""
|
|
||||||
# hole_diameter, hole_depth, max_sheets_per_hole
|
|
||||||
```
|
|
||||||
|
|
||||||
工厂函数 `magazine_factory()` 用 `create_homogeneous_resources` 生成洞位,可选预填 `ElectrodeSheet` 或 `Battery`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 仓库工厂模式参考
|
|
||||||
|
|
||||||
### 实际 warehouse 工厂函数示例
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 行优先 4x4 仓库
|
|
||||||
def bioyond_warehouse_1x4x4(name: str) -> WareHouse:
|
|
||||||
return warehouse_factory(
|
|
||||||
name=name,
|
|
||||||
num_items_x=4, num_items_y=4, num_items_z=1,
|
|
||||||
dx=10.0, dy=10.0, dz=10.0,
|
|
||||||
item_dx=147.0, item_dy=106.0, item_dz=130.0,
|
|
||||||
layout="row-major", # A01,A02,A03,A04, B01,...
|
|
||||||
)
|
|
||||||
|
|
||||||
# 右侧 4x4 仓库(列名偏移)
|
|
||||||
def bioyond_warehouse_1x4x4_right(name: str) -> WareHouse:
|
|
||||||
return warehouse_factory(
|
|
||||||
name=name,
|
|
||||||
num_items_x=4, num_items_y=4, num_items_z=1,
|
|
||||||
dx=10.0, dy=10.0, dz=10.0,
|
|
||||||
item_dx=147.0, item_dy=106.0, item_dz=130.0,
|
|
||||||
col_offset=4, # A05,A06,A07,A08
|
|
||||||
layout="row-major",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 竖向仓库(站内试剂存放)
|
|
||||||
def bioyond_warehouse_reagent_storage(name: str) -> WareHouse:
|
|
||||||
return warehouse_factory(
|
|
||||||
name=name,
|
|
||||||
num_items_x=1, num_items_y=2, num_items_z=1,
|
|
||||||
dx=10.0, dy=10.0, dz=10.0,
|
|
||||||
item_dx=147.0, item_dy=106.0, item_dz=130.0,
|
|
||||||
layout="vertical-col-major",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 行偏移(F 行开始)
|
|
||||||
def bioyond_warehouse_5x3x1(name: str, row_offset: int = 0) -> WareHouse:
|
|
||||||
return warehouse_factory(
|
|
||||||
name=name,
|
|
||||||
num_items_x=3, num_items_y=5, num_items_z=1,
|
|
||||||
dx=10.0, dy=10.0, dz=10.0,
|
|
||||||
item_dx=159.0, item_dy=183.0, item_dz=130.0,
|
|
||||||
row_offset=row_offset, # 0→A行起,5→F行起
|
|
||||||
layout="row-major",
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### layout 类型说明
|
|
||||||
|
|
||||||
| layout | 命名顺序 | 适用场景 |
|
|
||||||
|--------|---------|---------|
|
|
||||||
| `col-major` (默认) | A01,B01,C01,D01, A02,B02,... | 列优先,标准堆栈 |
|
|
||||||
| `row-major` | A01,A02,A03,A04, B01,B02,... | 行优先,Bioyond 前端展示 |
|
|
||||||
| `vertical-col-major` | 竖向排列,标签从底部开始 | 竖向仓库(试剂存放、测密度) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 关键路径
|
|
||||||
|
|
||||||
| 内容 | 路径 |
|
|
||||||
|------|------|
|
|
||||||
| Bottle/Carrier 基类 | `unilabos/resources/itemized_carrier.py` |
|
|
||||||
| WareHouse 类 + 工厂 | `unilabos/resources/warehouse.py` |
|
|
||||||
| ResourceTreeSet 转换 | `unilabos/resources/resource_tracker.py` |
|
|
||||||
| Bioyond 物料转换 | `unilabos/resources/graphio.py` |
|
|
||||||
| Bioyond 仓库定义 | `unilabos/resources/bioyond/warehouses.py` |
|
|
||||||
| 电池资源 | `unilabos/resources/battery/` |
|
|
||||||
| PLR 注册 | `unilabos/resources/plr_additional_res_reg.py` |
|
|
||||||
@@ -1,626 +0,0 @@
|
|||||||
---
|
|
||||||
name: add-workstation
|
|
||||||
description: Guide for adding new workstations to Uni-Lab-OS (接入新工作站). Uses @device decorator + AST auto-scanning. Walks through workstation type, sub-device composition, driver creation, deck setup, and graph file. Use when the user wants to add a workstation, create a workstation driver, configure a station with sub-devices, or mentions 工作站/工站/station/workstation.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Uni-Lab-OS 工作站接入指南
|
|
||||||
|
|
||||||
工作站(workstation)是组合多个子设备的大型设备,拥有独立的物料管理系统和工作流引擎。使用 `@device` 装饰器注册,AST 自动扫描生成注册表。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 工作站类型
|
|
||||||
|
|
||||||
| 类型 | 基类 | 适用场景 |
|
|
||||||
| ------------------- | ----------------- | ---------------------------------- |
|
|
||||||
| **Protocol 工作站** | `ProtocolNode` | 标准化学操作协议(泵转移、过滤等) |
|
|
||||||
| **外部系统工作站** | `WorkstationBase` | 与外部 LIMS/MES 对接 |
|
|
||||||
| **硬件控制工作站** | `WorkstationBase` | 直接控制 PLC/硬件 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## @device 装饰器(工作站)
|
|
||||||
|
|
||||||
工作站也使用 `@device` 装饰器注册,参数与普通设备一致:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@device(
|
|
||||||
id="my_workstation", # 注册表唯一标识(必填)
|
|
||||||
category=["workstation"], # 分类标签
|
|
||||||
description="我的工作站",
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
如果一个工作站类支持多个具体变体,可使用 `ids` / `id_meta`,与设备的用法相同(参见 add-device SKILL)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 工作站驱动模板
|
|
||||||
|
|
||||||
### 模板 A:基于外部系统的工作站
|
|
||||||
|
|
||||||
```python
|
|
||||||
import logging
|
|
||||||
from typing import Dict, Any, Optional
|
|
||||||
from pylabrobot.resources import Deck
|
|
||||||
|
|
||||||
from unilabos.registry.decorators import device, topic_config, not_action
|
|
||||||
from unilabos.devices.workstation.workstation_base import WorkstationBase
|
|
||||||
|
|
||||||
try:
|
|
||||||
from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
|
|
||||||
except ImportError:
|
|
||||||
ROS2WorkstationNode = None
|
|
||||||
|
|
||||||
|
|
||||||
@device(id="my_workstation", category=["workstation"], description="我的工作站")
|
|
||||||
class MyWorkstation(WorkstationBase):
|
|
||||||
_ros_node: "ROS2WorkstationNode"
|
|
||||||
|
|
||||||
def __init__(self, config=None, deck=None, protocol_type=None, **kwargs):
|
|
||||||
super().__init__(deck=deck, **kwargs)
|
|
||||||
self.config = config or {}
|
|
||||||
self.logger = logging.getLogger("MyWorkstation")
|
|
||||||
self.api_host = self.config.get("api_host", "")
|
|
||||||
self._status = "Idle"
|
|
||||||
|
|
||||||
@not_action
|
|
||||||
def post_init(self, ros_node: "ROS2WorkstationNode"):
|
|
||||||
super().post_init(ros_node)
|
|
||||||
self._ros_node = ros_node
|
|
||||||
|
|
||||||
async def scheduler_start(self, **kwargs) -> Dict[str, Any]:
|
|
||||||
"""注册为工作站动作"""
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
async def create_order(self, json_str: str, **kwargs) -> Dict[str, Any]:
|
|
||||||
"""注册为工作站动作"""
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
@property
|
|
||||||
@topic_config()
|
|
||||||
def workflow_sequence(self) -> str:
|
|
||||||
return "[]"
|
|
||||||
|
|
||||||
@property
|
|
||||||
@topic_config()
|
|
||||||
def material_info(self) -> str:
|
|
||||||
return "{}"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 模板 B:Protocol 工作站
|
|
||||||
|
|
||||||
直接使用 `ProtocolNode`,通常不需要自定义驱动类:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.devices.workstation.workstation_base import ProtocolNode
|
|
||||||
```
|
|
||||||
|
|
||||||
在图文件中配置 `protocol_type` 即可。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 子设备访问(sub_devices)
|
|
||||||
|
|
||||||
工站初始化子设备后,所有子设备实例存储在 `self._ros_node.sub_devices` 字典中(key 为设备 id,value 为 `ROS2DeviceNode` 实例)。工站的驱动类可以直接获取子设备实例来调用其方法:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 在工站驱动类的方法中访问子设备
|
|
||||||
sub = self._ros_node.sub_devices["pump_1"]
|
|
||||||
|
|
||||||
# .driver_instance — 子设备的驱动实例(即设备 Python 类的实例)
|
|
||||||
sub.driver_instance.some_method(arg1, arg2)
|
|
||||||
|
|
||||||
# .ros_node_instance — 子设备的 ROS2 节点实例
|
|
||||||
sub.ros_node_instance._action_value_mappings # 查看子设备支持的 action
|
|
||||||
```
|
|
||||||
|
|
||||||
**常见用法**:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class MyWorkstation(WorkstationBase):
|
|
||||||
def my_protocol(self, **kwargs):
|
|
||||||
# 获取子设备驱动实例
|
|
||||||
pump = self._ros_node.sub_devices["pump_1"].driver_instance
|
|
||||||
heater = self._ros_node.sub_devices["heater_1"].driver_instance
|
|
||||||
|
|
||||||
# 直接调用子设备方法
|
|
||||||
pump.aspirate(volume=100)
|
|
||||||
heater.set_temperature(80)
|
|
||||||
```
|
|
||||||
|
|
||||||
> 参考实现:`unilabos/devices/workstation/bioyond_studio/reaction_station/reaction_station.py` 中通过 `self._ros_node.sub_devices.get(reactor_id)` 获取子反应器实例并更新数据。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 硬件通信接口(hardware_interface)
|
|
||||||
|
|
||||||
硬件控制型工作站通常需要通过串口(Serial)、Modbus 等通信协议控制多个子设备。Uni-Lab-OS 通过 **通信设备代理** 机制实现端口共享:一个串口只创建一个 `serial` 节点,多个子设备共享这个通信实例。
|
|
||||||
|
|
||||||
### 工作原理
|
|
||||||
|
|
||||||
`ROS2WorkstationNode` 初始化时分两轮遍历子设备(`workstation.py`):
|
|
||||||
|
|
||||||
**第一轮 — 初始化所有子设备**:按 `children` 顺序调用 `initialize_device()`,通信设备(`serial_` / `io_` 开头的 id)优先完成初始化,创建 `serial.Serial()` 实例。其他子设备此时 `self.hardware_interface = "serial_pump"`(字符串)。
|
|
||||||
|
|
||||||
**第二轮 — 代理替换**:遍历所有已初始化的子设备,读取子设备的 `_hardware_interface` 配置:
|
|
||||||
|
|
||||||
```
|
|
||||||
hardware_interface = d.ros_node_instance._hardware_interface
|
|
||||||
# → {"name": "hardware_interface", "read": "send_command", "write": "send_command"}
|
|
||||||
```
|
|
||||||
|
|
||||||
1. 取 `name` 字段对应的属性值:`name_value = getattr(driver, hardware_interface["name"])`
|
|
||||||
- 如果 `name_value` 是字符串且该字符串是某个子设备的 id → 触发代理替换
|
|
||||||
2. 从通信设备获取真正的 `read`/`write` 方法
|
|
||||||
3. 用 `setattr(driver, read_method, _read)` 将通信设备的方法绑定到子设备上
|
|
||||||
|
|
||||||
因此:
|
|
||||||
|
|
||||||
- **通信设备 id 必须与子设备 config 中填的字符串完全一致**(如 `"serial_pump"`)
|
|
||||||
- **通信设备 id 必须以 `serial_` 或 `io_` 开头**(否则第一轮不会被识别为通信设备)
|
|
||||||
- **通信设备必须在 `children` 列表中排在最前面**,确保先初始化
|
|
||||||
|
|
||||||
### HardwareInterface 参数说明
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.registry.decorators import HardwareInterface
|
|
||||||
|
|
||||||
HardwareInterface(
|
|
||||||
name="hardware_interface", # __init__ 中接收通信实例的属性名
|
|
||||||
read="send_command", # 通信设备上暴露的读方法名
|
|
||||||
write="send_command", # 通信设备上暴露的写方法名
|
|
||||||
extra_info=["list_ports"], # 可选:额外暴露的方法
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**`name` 字段的含义**:对应设备类 `__init__` 中,用于保存通信实例的**属性名**。系统据此知道要替换哪个属性。大部分设备直接用 `"hardware_interface"`,也可以自定义(如 `"io_device_port"`)。
|
|
||||||
|
|
||||||
### 示例 1:泵(name="hardware_interface")
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.registry.decorators import device, HardwareInterface
|
|
||||||
|
|
||||||
@device(
|
|
||||||
id="my_pump",
|
|
||||||
category=["pump_and_valve"],
|
|
||||||
hardware_interface=HardwareInterface(
|
|
||||||
name="hardware_interface",
|
|
||||||
read="send_command",
|
|
||||||
write="send_command",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
class MyPump:
|
|
||||||
def __init__(self, port=None, address="1", **kwargs):
|
|
||||||
# name="hardware_interface" → 系统替换 self.hardware_interface
|
|
||||||
self.hardware_interface = port # 初始为字符串 "serial_pump",启动后被替换为 Serial 实例
|
|
||||||
self.address = address
|
|
||||||
|
|
||||||
def send_command(self, command: str):
|
|
||||||
full_command = f"/{self.address}{command}\r\n"
|
|
||||||
self.hardware_interface.write(bytearray(full_command, "ascii"))
|
|
||||||
return self.hardware_interface.read_until(b"\n")
|
|
||||||
```
|
|
||||||
|
|
||||||
### 示例 2:电磁阀(name="io_device_port",自定义属性名)
|
|
||||||
|
|
||||||
```python
|
|
||||||
@device(
|
|
||||||
id="solenoid_valve",
|
|
||||||
category=["pump_and_valve"],
|
|
||||||
hardware_interface=HardwareInterface(
|
|
||||||
name="io_device_port", # 自定义属性名 → 系统替换 self.io_device_port
|
|
||||||
read="read_io_coil",
|
|
||||||
write="write_io_coil",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
class SolenoidValve:
|
|
||||||
def __init__(self, io_device_port: str = None, **kwargs):
|
|
||||||
# name="io_device_port" → 图文件 config 中用 "io_device_port": "io_board_1"
|
|
||||||
self.io_device_port = io_device_port # 初始为字符串,系统替换为 Modbus 实例
|
|
||||||
```
|
|
||||||
|
|
||||||
### Serial 通信设备(class="serial")
|
|
||||||
|
|
||||||
`serial` 是 Uni-Lab-OS 内置的通信代理设备,代码位于 `unilabos/ros/nodes/presets/serial_node.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from serial import Serial, SerialException
|
|
||||||
from threading import Lock
|
|
||||||
|
|
||||||
class ROS2SerialNode(BaseROS2DeviceNode):
|
|
||||||
def __init__(self, device_id, registry_name, port: str, baudrate: int = 9600, **kwargs):
|
|
||||||
self.port = port
|
|
||||||
self.baudrate = baudrate
|
|
||||||
self._hardware_interface = {
|
|
||||||
"name": "hardware_interface",
|
|
||||||
"write": "send_command",
|
|
||||||
"read": "read_data",
|
|
||||||
}
|
|
||||||
self._query_lock = Lock()
|
|
||||||
|
|
||||||
self.hardware_interface = Serial(baudrate=baudrate, port=port)
|
|
||||||
|
|
||||||
BaseROS2DeviceNode.__init__(
|
|
||||||
self, driver_instance=self, registry_name=registry_name,
|
|
||||||
device_id=device_id, status_types={}, action_value_mappings={},
|
|
||||||
hardware_interface=self._hardware_interface, print_publish=False,
|
|
||||||
)
|
|
||||||
self.create_service(SerialCommand, "serialwrite", self.handle_serial_request)
|
|
||||||
|
|
||||||
def send_command(self, command: str):
|
|
||||||
with self._query_lock:
|
|
||||||
self.hardware_interface.write(bytearray(f"{command}\n", "ascii"))
|
|
||||||
return self.hardware_interface.read_until(b"\n").decode()
|
|
||||||
|
|
||||||
def read_data(self):
|
|
||||||
with self._query_lock:
|
|
||||||
return self.hardware_interface.read_until(b"\n").decode()
|
|
||||||
```
|
|
||||||
|
|
||||||
在图文件中使用 `"class": "serial"` 即可创建串口代理:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "serial_pump",
|
|
||||||
"class": "serial",
|
|
||||||
"parent": "my_station",
|
|
||||||
"config": { "port": "COM7", "baudrate": 9600 }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 图文件配置
|
|
||||||
|
|
||||||
**通信设备必须在 `children` 列表中排在最前面**,确保先于其他子设备初始化:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"nodes": [
|
|
||||||
{
|
|
||||||
"id": "my_station",
|
|
||||||
"class": "workstation",
|
|
||||||
"children": ["serial_pump", "pump_1", "pump_2"],
|
|
||||||
"config": { "protocol_type": ["PumpTransferProtocol"] }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "serial_pump",
|
|
||||||
"class": "serial",
|
|
||||||
"parent": "my_station",
|
|
||||||
"config": { "port": "COM7", "baudrate": 9600 }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "pump_1",
|
|
||||||
"class": "syringe_pump_with_valve.runze.SY03B-T08",
|
|
||||||
"parent": "my_station",
|
|
||||||
"config": { "port": "serial_pump", "address": "1", "max_volume": 25.0 }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "pump_2",
|
|
||||||
"class": "syringe_pump_with_valve.runze.SY03B-T08",
|
|
||||||
"parent": "my_station",
|
|
||||||
"config": { "port": "serial_pump", "address": "2", "max_volume": 25.0 }
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"links": [
|
|
||||||
{
|
|
||||||
"source": "pump_1",
|
|
||||||
"target": "serial_pump",
|
|
||||||
"type": "communication",
|
|
||||||
"port": { "pump_1": "port", "serial_pump": "port" }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "pump_2",
|
|
||||||
"target": "serial_pump",
|
|
||||||
"type": "communication",
|
|
||||||
"port": { "pump_2": "port", "serial_pump": "port" }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 通信协议速查
|
|
||||||
|
|
||||||
| 协议 | config 参数 | 依赖包 | 通信设备 class |
|
|
||||||
| -------------------- | ------------------------------ | ---------- | -------------------------- |
|
|
||||||
| Serial (RS232/RS485) | `port`, `baudrate` | `pyserial` | `serial` |
|
|
||||||
| Modbus RTU | `port`, `baudrate`, `slave_id` | `pymodbus` | `device_comms/modbus_plc/` |
|
|
||||||
| Modbus TCP | `host`, `port`, `slave_id` | `pymodbus` | `device_comms/modbus_plc/` |
|
|
||||||
| TCP Socket | `host`, `port` | stdlib | 自定义 |
|
|
||||||
| HTTP API | `url`, `token` | `requests` | `device_comms/rpc.py` |
|
|
||||||
|
|
||||||
参考实现:`unilabos/test/experiments/Grignard_flow_batchreact_single_pumpvalve.json`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deck 与物料生命周期
|
|
||||||
|
|
||||||
### 1. Deck 入参与两种初始化模式
|
|
||||||
|
|
||||||
系统根据设备节点 `config.deck` 的写法,自动反序列化 Deck 实例后传入 `__init__` 的 `deck` 参数。目前 `deck` 是固定字段名,只支持一个主 Deck。建议一个设备拥有一个台面,台面上抽象二级、三级子物料。
|
|
||||||
|
|
||||||
有两种初始化模式:
|
|
||||||
|
|
||||||
#### init 初始化(推荐)
|
|
||||||
|
|
||||||
`config.deck` 直接包含 `_resource_type` + `_resource_child_name`,系统先用 Deck 节点的 `config` 调用 Deck 类的 `__init__` 反序列化,再将实例传入设备的 `deck` 参数。子物料随 Deck 的 `children` 一起反序列化。
|
|
||||||
|
|
||||||
```json
|
|
||||||
"config": {
|
|
||||||
"deck": {
|
|
||||||
"_resource_type": "unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Deck",
|
|
||||||
"_resource_child_name": "PRCXI_Deck"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### deserialize 初始化
|
|
||||||
|
|
||||||
`config.deck` 用 `data` 包裹一层,系统走 `deserialize` 路径,可传入更多参数(如 `allow_marshal` 等):
|
|
||||||
|
|
||||||
```json
|
|
||||||
"config": {
|
|
||||||
"deck": {
|
|
||||||
"data": {
|
|
||||||
"_resource_child_name": "YB_Bioyond_Deck",
|
|
||||||
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_YB_Deck"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
没有特殊需求时推荐 init 初始化。
|
|
||||||
|
|
||||||
#### config.deck 字段说明
|
|
||||||
|
|
||||||
| 字段 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `_resource_type` | Deck 类的完整模块路径(`module:ClassName`) |
|
|
||||||
| `_resource_child_name` | 对应图文件中 Deck 节点的 `id`,建立父子关联 |
|
|
||||||
|
|
||||||
#### 设备 __init__ 接收
|
|
||||||
|
|
||||||
```python
|
|
||||||
def __init__(self, config=None, deck=None, protocol_type=None, **kwargs):
|
|
||||||
super().__init__(deck=deck, **kwargs)
|
|
||||||
# deck 已经是反序列化后的 Deck 实例
|
|
||||||
# → PRCXI9300Deck / BIOYOND_YB_Deck 等
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Deck 节点(图文件中)
|
|
||||||
|
|
||||||
Deck 节点作为设备的 `children` 之一,`parent` 指向设备 id:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "PRCXI_Deck",
|
|
||||||
"parent": "PRCXI",
|
|
||||||
"type": "deck",
|
|
||||||
"class": "",
|
|
||||||
"children": [],
|
|
||||||
"config": {
|
|
||||||
"type": "PRCXI9300Deck",
|
|
||||||
"size_x": 542, "size_y": 374, "size_z": 0,
|
|
||||||
"category": "deck",
|
|
||||||
"sites": [...]
|
|
||||||
},
|
|
||||||
"data": {}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `config` 中的字段会传入 Deck 类的 `__init__`(因此 `__init__` 必须能接受所有 `serialize()` 输出的字段)
|
|
||||||
- `children` 初始为空时,由同步器或手动初始化填充
|
|
||||||
- `config.type` 填 Deck 类名
|
|
||||||
|
|
||||||
### 2. Deck 为空时自行初始化
|
|
||||||
|
|
||||||
如果 Deck 节点的 `children` 为空,工作站需在 `post_init` 或首次同步时自行初始化内容:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@not_action
|
|
||||||
def post_init(self, ros_node):
|
|
||||||
super().post_init(ros_node)
|
|
||||||
if self.deck and not self.deck.children:
|
|
||||||
self._initialize_default_deck()
|
|
||||||
|
|
||||||
def _initialize_default_deck(self):
|
|
||||||
from my_labware import My_TipRack, My_Plate
|
|
||||||
self.deck.assign_child_resource(My_TipRack("T1"), spot=0)
|
|
||||||
self.deck.assign_child_resource(My_Plate("T2"), spot=1)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 物料双向同步
|
|
||||||
|
|
||||||
当工作站对接外部系统(LIMS/MES)时,需要实现 `ResourceSynchronizer` 处理双向物料同步:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.devices.workstation.workstation_base import ResourceSynchronizer
|
|
||||||
|
|
||||||
class MyResourceSynchronizer(ResourceSynchronizer):
|
|
||||||
def sync_from_external(self) -> bool:
|
|
||||||
"""从外部系统同步到 self.workstation.deck"""
|
|
||||||
external_data = self._query_external_materials()
|
|
||||||
# 以外部工站为准:根据外部数据反向创建 PLR 资源实例
|
|
||||||
for item in external_data:
|
|
||||||
cls = self._resolve_resource_class(item["type"])
|
|
||||||
resource = cls(name=item["name"], **item["params"])
|
|
||||||
self.workstation.deck.assign_child_resource(resource, spot=item["slot"])
|
|
||||||
return True
|
|
||||||
|
|
||||||
def sync_to_external(self, resource) -> bool:
|
|
||||||
"""将 UniLab 侧物料变更同步到外部系统"""
|
|
||||||
# 以 UniLab 为准:将 PLR 资源转为外部格式并推送
|
|
||||||
external_format = self._convert_to_external(resource)
|
|
||||||
return self._push_to_external(external_format)
|
|
||||||
|
|
||||||
def handle_external_change(self, change_info) -> bool:
|
|
||||||
"""处理外部系统主动推送的变更"""
|
|
||||||
return True
|
|
||||||
```
|
|
||||||
|
|
||||||
同步策略取决于业务场景:
|
|
||||||
|
|
||||||
- **以外部工站为准**:从外部 API 查询物料数据,反向创建对应的 PLR 资源实例放到 Deck 上
|
|
||||||
- **以 UniLab 为准**:UniLab 侧的物料变更通过 `sync_to_external` 推送到外部系统
|
|
||||||
|
|
||||||
在工作站 `post_init` 中初始化同步器:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@not_action
|
|
||||||
def post_init(self, ros_node):
|
|
||||||
super().post_init(ros_node)
|
|
||||||
self.resource_synchronizer = MyResourceSynchronizer(self)
|
|
||||||
self.resource_synchronizer.sync_from_external()
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 序列化与持久化(serialize / serialize_state)
|
|
||||||
|
|
||||||
资源类需正确实现序列化,系统据此完成持久化和前端同步。
|
|
||||||
|
|
||||||
**`serialize()`** — 输出资源的结构信息(`config` 层),反序列化时作为 `__init__` 的入参回传。因此 **`__init__` 必须通过 `**kwargs`接受`serialize()` 输出的所有字段\*\*,即使当前不使用:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class MyDeck(Deck):
|
|
||||||
def __init__(self, name, size_x, size_y, size_z,
|
|
||||||
sites=None, # serialize() 输出的字段
|
|
||||||
rotation=None, # serialize() 输出的字段
|
|
||||||
barcode=None, # serialize() 输出的字段
|
|
||||||
**kwargs): # 兜底:接受所有未知的 serialize 字段
|
|
||||||
super().__init__(size_x, size_y, size_z, name)
|
|
||||||
# ...
|
|
||||||
|
|
||||||
def serialize(self) -> dict:
|
|
||||||
data = super().serialize()
|
|
||||||
data["sites"] = [...] # 自定义字段
|
|
||||||
return data
|
|
||||||
```
|
|
||||||
|
|
||||||
**`serialize_state()`** — 输出资源的运行时状态(`data` 层),用于持久化可变信息。`data` 中的内容会被正确保存和恢复:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class MyPlate(Plate):
|
|
||||||
def __init__(self, name, size_x, size_y, size_z,
|
|
||||||
material_info=None, **kwargs):
|
|
||||||
super().__init__(name, size_x, size_y, size_z, **kwargs)
|
|
||||||
self._unilabos_state = {}
|
|
||||||
if material_info:
|
|
||||||
self._unilabos_state["Material"] = material_info
|
|
||||||
|
|
||||||
def serialize_state(self) -> Dict[str, Any]:
|
|
||||||
data = super().serialize_state()
|
|
||||||
data.update(self._unilabos_state)
|
|
||||||
return data
|
|
||||||
```
|
|
||||||
|
|
||||||
关键要点:
|
|
||||||
|
|
||||||
- `serialize()` 输出的所有字段都会作为 `config` 回传到 `__init__`,所以 `__init__` 必须能接受它们(显式声明或 `**kwargs`)
|
|
||||||
- `serialize_state()` 输出的 `data` 用于持久化运行时状态(如物料信息、液体量等)
|
|
||||||
- `_unilabos_state` 中只存可 JSON 序列化的基本类型(str, int, float, bool, list, dict, None)
|
|
||||||
|
|
||||||
### 5. 子物料自动同步
|
|
||||||
|
|
||||||
子物料(Bottle、Plate、TipRack 等)放到 Deck 上后,系统会自动将其同步到前端的 Deck 视图。只需保证资源类正确实现了 `serialize()` / `serialize_state()` 和反序列化即可。
|
|
||||||
|
|
||||||
### 6. 图文件配置(参考 prcxi_9320_slim.json)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"nodes": [
|
|
||||||
{
|
|
||||||
"id": "my_station",
|
|
||||||
"type": "device",
|
|
||||||
"class": "my_workstation",
|
|
||||||
"config": {
|
|
||||||
"deck": {
|
|
||||||
"_resource_type": "unilabos.resources.my_module:MyDeck",
|
|
||||||
"_resource_child_name": "my_deck"
|
|
||||||
},
|
|
||||||
"host": "10.20.30.1",
|
|
||||||
"port": 9999
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "my_deck",
|
|
||||||
"parent": "my_station",
|
|
||||||
"type": "deck",
|
|
||||||
"class": "",
|
|
||||||
"children": [],
|
|
||||||
"config": {
|
|
||||||
"type": "MyLabDeck",
|
|
||||||
"size_x": 542,
|
|
||||||
"size_y": 374,
|
|
||||||
"size_z": 0,
|
|
||||||
"category": "deck",
|
|
||||||
"sites": [
|
|
||||||
{
|
|
||||||
"label": "T1",
|
|
||||||
"visible": true,
|
|
||||||
"occupied_by": null,
|
|
||||||
"position": { "x": 0, "y": 0, "z": 0 },
|
|
||||||
"size": { "width": 128.0, "height": 86, "depth": 0 },
|
|
||||||
"content_type": ["plate", "tip_rack", "tube_rack", "adaptor"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"data": {}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"edges": []
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Deck 节点要点:
|
|
||||||
|
|
||||||
- `config.type` 填 Deck 类名(如 `"PRCXI9300Deck"`)
|
|
||||||
- `config.sites` 完整列出所有 site(从 Deck 类的 `serialize()` 输出获取)
|
|
||||||
- `children` 初始为空(由同步器或手动初始化填充)
|
|
||||||
- 设备节点 `config.deck._resource_type` 指向 Deck 类的完整模块路径
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 子设备
|
|
||||||
|
|
||||||
子设备按标准设备接入流程创建(参见 add-device SKILL),使用 `@device` 装饰器。
|
|
||||||
|
|
||||||
子设备约束:
|
|
||||||
|
|
||||||
- 图文件中 `parent` 指向工作站 ID
|
|
||||||
- 在工作站 `children` 数组中列出
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 关键规则
|
|
||||||
|
|
||||||
1. **`__init__` 必须接受 `deck` 和 `**kwargs`** — `WorkstationBase.**init**`需要`deck` 参数
|
|
||||||
2. **Deck 通过 `config.deck._resource_type` 反序列化传入** — 不要在 `__init__` 中手动创建 Deck
|
|
||||||
3. **Deck 为空时自行初始化内容** — 在 `post_init` 中检查并填充默认物料
|
|
||||||
4. **外部同步实现 `ResourceSynchronizer`** — `sync_from_external` / `sync_to_external`
|
|
||||||
5. **通过 `self._children` 访问子设备** — 不要自行维护子设备引用
|
|
||||||
6. **`post_init` 中启动后台服务** — 不要在 `__init__` 中启动网络连接
|
|
||||||
7. **异步方法使用 `await self._ros_node.sleep()`** — 禁止 `time.sleep()` 和 `asyncio.sleep()`
|
|
||||||
8. **使用 `@not_action` 标记非动作方法** — `post_init`, `initialize`, `cleanup`
|
|
||||||
9. **子物料保证正确 serialize/deserialize** — 系统自动同步到前端 Deck 视图
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 验证
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 模块可导入
|
|
||||||
python -c "from unilabos.devices.workstation.<name>.<name> import <ClassName>"
|
|
||||||
|
|
||||||
# 启动测试(AST 自动扫描)
|
|
||||||
unilab -g <graph>.json
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 现有工作站参考
|
|
||||||
|
|
||||||
| 工作站 | 驱动类 | 类型 |
|
|
||||||
| -------------- | ----------------------------- | -------- |
|
|
||||||
| Protocol 通用 | `ProtocolNode` | Protocol |
|
|
||||||
| Bioyond 反应站 | `BioyondReactionStation` | 外部系统 |
|
|
||||||
| 纽扣电池组装 | `CoinCellAssemblyWorkstation` | 硬件控制 |
|
|
||||||
|
|
||||||
参考路径:`unilabos/devices/workstation/` 目录下各工作站实现。
|
|
||||||
@@ -1,371 +0,0 @@
|
|||||||
# 工作站高级模式参考
|
|
||||||
|
|
||||||
本文件是 SKILL.md 的补充,包含外部系统集成、物料同步、配置结构等高级模式。
|
|
||||||
Agent 在需要实现这些功能时按需阅读。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 外部系统集成模式
|
|
||||||
|
|
||||||
### 1.1 RPC 客户端
|
|
||||||
|
|
||||||
与外部 LIMS/MES 系统通信的标准模式。继承 `BaseRequest`,所有接口统一用 POST。
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.device_comms.rpc import BaseRequest
|
|
||||||
|
|
||||||
|
|
||||||
class MySystemRPC(BaseRequest):
|
|
||||||
"""外部系统 RPC 客户端"""
|
|
||||||
|
|
||||||
def __init__(self, host: str, api_key: str):
|
|
||||||
super().__init__(host)
|
|
||||||
self.api_key = api_key
|
|
||||||
|
|
||||||
def _request(self, endpoint: str, data: dict = None) -> dict:
|
|
||||||
return self.post(
|
|
||||||
url=f"{self.host}/api/{endpoint}",
|
|
||||||
params={
|
|
||||||
"apiKey": self.api_key,
|
|
||||||
"requestTime": self.get_current_time_iso8601(),
|
|
||||||
"data": data or {},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
def query_status(self) -> dict:
|
|
||||||
return self._request("status/query")
|
|
||||||
|
|
||||||
def create_order(self, order_data: dict) -> dict:
|
|
||||||
return self._request("order/create", order_data)
|
|
||||||
```
|
|
||||||
|
|
||||||
参考:`unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py`(`BioyondV1RPC`)
|
|
||||||
|
|
||||||
### 1.2 HTTP 回调服务
|
|
||||||
|
|
||||||
接收外部系统报送的标准模式。使用 `WorkstationHTTPService`,在 `post_init` 中启动。
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
|
|
||||||
|
|
||||||
|
|
||||||
class MyWorkstation(WorkstationBase):
|
|
||||||
def __init__(self, config=None, deck=None, **kwargs):
|
|
||||||
super().__init__(deck=deck, **kwargs)
|
|
||||||
self.config = config or {}
|
|
||||||
http_cfg = self.config.get("http_service_config", {})
|
|
||||||
self._http_service_config = {
|
|
||||||
"host": http_cfg.get("http_service_host", "127.0.0.1"),
|
|
||||||
"port": http_cfg.get("http_service_port", 8080),
|
|
||||||
}
|
|
||||||
self.http_service = None
|
|
||||||
|
|
||||||
def post_init(self, ros_node):
|
|
||||||
super().post_init(ros_node)
|
|
||||||
self.http_service = WorkstationHTTPService(
|
|
||||||
workstation_instance=self,
|
|
||||||
host=self._http_service_config["host"],
|
|
||||||
port=self._http_service_config["port"],
|
|
||||||
)
|
|
||||||
self.http_service.start()
|
|
||||||
```
|
|
||||||
|
|
||||||
**HTTP 服务路由**(固定端点,由 `WorkstationHTTPHandler` 自动分发):
|
|
||||||
|
|
||||||
| 端点 | 调用的工作站方法 |
|
|
||||||
|------|-----------------|
|
|
||||||
| `/report/step_finish` | `process_step_finish_report(report_request)` |
|
|
||||||
| `/report/sample_finish` | `process_sample_finish_report(report_request)` |
|
|
||||||
| `/report/order_finish` | `process_order_finish_report(report_request, used_materials)` |
|
|
||||||
| `/report/material_change` | `process_material_change_report(report_data)` |
|
|
||||||
| `/report/error_handling` | `handle_external_error(error_data)` |
|
|
||||||
|
|
||||||
实现对应方法即可接收回调:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def process_step_finish_report(self, report_request) -> Dict[str, Any]:
|
|
||||||
"""处理步骤完成报告"""
|
|
||||||
step_name = report_request.data.get("stepName")
|
|
||||||
return {"success": True, "message": f"步骤 {step_name} 已处理"}
|
|
||||||
|
|
||||||
def process_order_finish_report(self, report_request, used_materials) -> Dict[str, Any]:
|
|
||||||
"""处理订单完成报告"""
|
|
||||||
order_code = report_request.data.get("orderCode")
|
|
||||||
return {"success": True}
|
|
||||||
```
|
|
||||||
|
|
||||||
参考:`unilabos/devices/workstation/workstation_http_service.py`
|
|
||||||
|
|
||||||
### 1.3 连接监控
|
|
||||||
|
|
||||||
独立线程周期性检测外部系统连接状态,状态变化时发布 ROS 事件。
|
|
||||||
|
|
||||||
```python
|
|
||||||
class ConnectionMonitor:
|
|
||||||
def __init__(self, workstation, check_interval=30):
|
|
||||||
self.workstation = workstation
|
|
||||||
self.check_interval = check_interval
|
|
||||||
self._running = False
|
|
||||||
self._thread = None
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
self._running = True
|
|
||||||
self._thread = threading.Thread(target=self._monitor_loop, daemon=True)
|
|
||||||
self._thread.start()
|
|
||||||
|
|
||||||
def _monitor_loop(self):
|
|
||||||
while self._running:
|
|
||||||
try:
|
|
||||||
# 调用外部系统接口检测连接
|
|
||||||
self.workstation.hardware_interface.ping()
|
|
||||||
status = "online"
|
|
||||||
except Exception:
|
|
||||||
status = "offline"
|
|
||||||
time.sleep(self.check_interval)
|
|
||||||
```
|
|
||||||
|
|
||||||
参考:`unilabos/devices/workstation/bioyond_studio/station.py`(`ConnectionMonitor`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Config 结构模式
|
|
||||||
|
|
||||||
工作站的 `config` 在图文件中定义,传入 `__init__`。以下是常见字段模式:
|
|
||||||
|
|
||||||
### 2.1 外部系统连接
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"api_host": "http://192.168.1.100:8080",
|
|
||||||
"api_key": "YOUR_API_KEY"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 HTTP 回调服务
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"http_service_config": {
|
|
||||||
"http_service_host": "127.0.0.1",
|
|
||||||
"http_service_port": 8080
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.3 物料类型映射
|
|
||||||
|
|
||||||
将 PLR 资源类名映射到外部系统的物料类型(名称 + UUID)。用于双向物料转换。
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"material_type_mappings": {
|
|
||||||
"PLR_ResourceClassName": ["外部系统显示名", "external-type-uuid"],
|
|
||||||
"BIOYOND_PolymerStation_Reactor": ["反应器", "3a14233b-902d-0d7b-..."]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.4 仓库映射
|
|
||||||
|
|
||||||
将仓库名映射到外部系统的仓库 UUID 和库位 UUID。用于入库/出库操作。
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"warehouse_mapping": {
|
|
||||||
"仓库名": {
|
|
||||||
"uuid": "warehouse-uuid",
|
|
||||||
"site_uuids": {
|
|
||||||
"A01": "site-uuid-A01",
|
|
||||||
"A02": "site-uuid-A02"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.5 工作流映射
|
|
||||||
|
|
||||||
将内部工作流名映射到外部系统的工作流 ID。
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"workflow_mappings": {
|
|
||||||
"internal_workflow_name": "external-workflow-uuid"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.6 物料默认参数
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"material_default_parameters": {
|
|
||||||
"NMP": {
|
|
||||||
"unit": "毫升",
|
|
||||||
"density": "1.03",
|
|
||||||
"densityUnit": "g/mL",
|
|
||||||
"description": "N-甲基吡咯烷酮"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 资源同步机制
|
|
||||||
|
|
||||||
### 3.1 ResourceSynchronizer
|
|
||||||
|
|
||||||
抽象基类,用于与外部物料系统双向同步。定义在 `workstation_base.py`。
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.devices.workstation.workstation_base import ResourceSynchronizer
|
|
||||||
|
|
||||||
|
|
||||||
class MyResourceSynchronizer(ResourceSynchronizer):
|
|
||||||
def __init__(self, workstation, api_client):
|
|
||||||
super().__init__(workstation)
|
|
||||||
self.api_client = api_client
|
|
||||||
|
|
||||||
def sync_from_external(self) -> bool:
|
|
||||||
"""从外部系统拉取物料到 deck"""
|
|
||||||
external_materials = self.api_client.list_materials()
|
|
||||||
for material in external_materials:
|
|
||||||
plr_resource = self._convert_to_plr(material)
|
|
||||||
self.workstation.deck.assign_child_resource(plr_resource, coordinate)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def sync_to_external(self, plr_resource) -> bool:
|
|
||||||
"""将 deck 中的物料变更推送到外部系统"""
|
|
||||||
external_data = self._convert_from_plr(plr_resource)
|
|
||||||
self.api_client.update_material(external_data)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def handle_external_change(self, change_info) -> bool:
|
|
||||||
"""处理外部系统推送的物料变更"""
|
|
||||||
return True
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 update_resource — 上传资源树到云端
|
|
||||||
|
|
||||||
将 PLR Deck 序列化后通过 ROS 服务上传。典型使用场景:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 在 post_init 中上传初始 deck
|
|
||||||
from unilabos.ros.nodes.base_device_node import ROS2DeviceNode
|
|
||||||
|
|
||||||
ROS2DeviceNode.run_async_func(
|
|
||||||
self._ros_node.update_resource, True,
|
|
||||||
**{"resources": [self.deck]}
|
|
||||||
)
|
|
||||||
|
|
||||||
# 在动作方法中更新特定资源
|
|
||||||
ROS2DeviceNode.run_async_func(
|
|
||||||
self._ros_node.update_resource, True,
|
|
||||||
**{"resources": [updated_plate]}
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 工作流序列管理
|
|
||||||
|
|
||||||
工作站通过 `workflow_sequence` 属性管理任务队列(JSON 字符串形式)。
|
|
||||||
|
|
||||||
```python
|
|
||||||
class MyWorkstation(WorkstationBase):
|
|
||||||
def __init__(self, **kwargs):
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
self._workflow_sequence = []
|
|
||||||
|
|
||||||
@property
|
|
||||||
def workflow_sequence(self) -> str:
|
|
||||||
"""返回 JSON 字符串,ROS 自动发布"""
|
|
||||||
import json
|
|
||||||
return json.dumps(self._workflow_sequence)
|
|
||||||
|
|
||||||
async def append_to_workflow_sequence(self, workflow_name: str) -> Dict[str, Any]:
|
|
||||||
"""添加工作流到队列"""
|
|
||||||
self._workflow_sequence.append({
|
|
||||||
"name": workflow_name,
|
|
||||||
"status": "pending",
|
|
||||||
"created_at": time.time(),
|
|
||||||
})
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
async def clear_workflows(self) -> Dict[str, Any]:
|
|
||||||
"""清空工作流队列"""
|
|
||||||
self._workflow_sequence = []
|
|
||||||
return {"success": True}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 站间物料转移
|
|
||||||
|
|
||||||
工作站之间转移物料的模式。通过 ROS ActionClient 调用目标站的动作。
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def transfer_materials_to_another_station(
|
|
||||||
self,
|
|
||||||
target_device_id: str,
|
|
||||||
transfer_groups: list,
|
|
||||||
**kwargs,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""将物料转移到另一个工作站"""
|
|
||||||
target_node = self._children.get(target_device_id)
|
|
||||||
if not target_node:
|
|
||||||
# 通过 ROS 节点查找非子设备的目标站
|
|
||||||
pass
|
|
||||||
|
|
||||||
for group in transfer_groups:
|
|
||||||
resource = self.find_resource_by_name(group["resource_name"])
|
|
||||||
# 从本站 deck 移除
|
|
||||||
resource.unassign()
|
|
||||||
# 调用目标站的接收方法
|
|
||||||
# ...
|
|
||||||
|
|
||||||
return {"success": True, "transferred": len(transfer_groups)}
|
|
||||||
```
|
|
||||||
|
|
||||||
参考:`BioyondDispensingStation.transfer_materials_to_reaction_station`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. post_init 完整模式
|
|
||||||
|
|
||||||
`post_init` 是工作站初始化的关键阶段,此时 ROS 节点和子设备已就绪。
|
|
||||||
|
|
||||||
```python
|
|
||||||
def post_init(self, ros_node):
|
|
||||||
super().post_init(ros_node)
|
|
||||||
|
|
||||||
# 1. 初始化外部系统客户端(此时 config 已可用)
|
|
||||||
self.rpc_client = MySystemRPC(
|
|
||||||
host=self.config.get("api_host"),
|
|
||||||
api_key=self.config.get("api_key"),
|
|
||||||
)
|
|
||||||
self.hardware_interface = self.rpc_client
|
|
||||||
|
|
||||||
# 2. 启动连接监控
|
|
||||||
self.connection_monitor = ConnectionMonitor(self)
|
|
||||||
self.connection_monitor.start()
|
|
||||||
|
|
||||||
# 3. 启动 HTTP 回调服务
|
|
||||||
if hasattr(self, '_http_service_config'):
|
|
||||||
self.http_service = WorkstationHTTPService(
|
|
||||||
workstation_instance=self,
|
|
||||||
host=self._http_service_config["host"],
|
|
||||||
port=self._http_service_config["port"],
|
|
||||||
)
|
|
||||||
self.http_service.start()
|
|
||||||
|
|
||||||
# 4. 上传 deck 到云端
|
|
||||||
ROS2DeviceNode.run_async_func(
|
|
||||||
self._ros_node.update_resource, True,
|
|
||||||
**{"resources": [self.deck]}
|
|
||||||
)
|
|
||||||
|
|
||||||
# 5. 初始化资源同步器(可选)
|
|
||||||
self.resource_synchronizer = MyResourceSynchronizer(self, self.rpc_client)
|
|
||||||
```
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
---
|
|
||||||
name: batch-insert-reagent
|
|
||||||
description: Batch insert reagents into Uni-Lab platform — add chemicals with CAS, SMILES, supplier info. Use when the user wants to add reagents, insert chemicals, batch register reagents, or mentions 录入试剂/添加试剂/试剂入库/reagent.
|
|
||||||
---
|
|
||||||
|
|
||||||
# 批量录入试剂 Skill
|
|
||||||
|
|
||||||
通过云端 API 批量录入试剂信息,支持逐条或批量操作。
|
|
||||||
|
|
||||||
## 前置条件(缺一不可)
|
|
||||||
|
|
||||||
使用本 skill 前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
|
|
||||||
|
|
||||||
### 1. ak / sk → AUTH
|
|
||||||
|
|
||||||
询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。
|
|
||||||
|
|
||||||
生成 AUTH token(任选一种方式):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 方式一:Python 一行生成
|
|
||||||
python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
|
|
||||||
|
|
||||||
# 方式二:手动计算
|
|
||||||
# base64(ak:sk) → Authorization: Lab <token>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. --addr → BASE URL
|
|
||||||
|
|
||||||
| `--addr` 值 | BASE |
|
|
||||||
|-------------|------|
|
|
||||||
| `test` | `https://uni-lab.test.bohrium.com` |
|
|
||||||
| `uat` | `https://uni-lab.uat.bohrium.com` |
|
|
||||||
| `local` | `http://127.0.0.1:48197` |
|
|
||||||
| 不传(默认) | `https://uni-lab.bohrium.com` |
|
|
||||||
|
|
||||||
确认后设置:
|
|
||||||
```bash
|
|
||||||
BASE="<根据 addr 确定的 URL>"
|
|
||||||
AUTH="Authorization: Lab <gen_auth.py 输出的 token>"
|
|
||||||
```
|
|
||||||
|
|
||||||
**两项全部就绪后才可发起 API 请求。**
|
|
||||||
|
|
||||||
## Session State
|
|
||||||
|
|
||||||
- `lab_uuid` — 实验室 UUID(首次通过 API #1 自动获取,**不需要问用户**)
|
|
||||||
|
|
||||||
## 请求约定
|
|
||||||
|
|
||||||
所有请求使用 `curl -s`,POST 需加 `Content-Type: application/json`。
|
|
||||||
|
|
||||||
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名),示例中的 `curl` 均指 `curl.exe`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
### 1. 获取实验室信息(自动获取 lab_uuid)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
|
||||||
```
|
|
||||||
|
|
||||||
返回:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}}
|
|
||||||
```
|
|
||||||
|
|
||||||
记住 `data.uuid` 为 `lab_uuid`。
|
|
||||||
|
|
||||||
### 2. 录入试剂
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -X POST "$BASE/api/v1/lab/reagent" \
|
|
||||||
-H "$AUTH" -H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"lab_uuid": "<lab_uuid>",
|
|
||||||
"cas": "<CAS号>",
|
|
||||||
"name": "<试剂名称>",
|
|
||||||
"molecular_formula": "<分子式>",
|
|
||||||
"smiles": "<SMILES>",
|
|
||||||
"stock_in_quantity": <入库数量>,
|
|
||||||
"unit": "<单位字符串>",
|
|
||||||
"supplier": "<供应商>",
|
|
||||||
"production_date": "<生产日期 ISO 8601>",
|
|
||||||
"expiry_date": "<过期日期 ISO 8601>"
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
返回成功时包含试剂 UUID:
|
|
||||||
```json
|
|
||||||
{"code": 0, "data": {"uuid": "xxx", ...}}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 试剂字段说明
|
|
||||||
|
|
||||||
| 字段 | 类型 | 必填 | 说明 | 示例 |
|
|
||||||
|------|------|------|------|------|
|
|
||||||
| `lab_uuid` | string | 是 | 实验室 UUID(从 API #1 获取) | `"8511c672-..."` |
|
|
||||||
| `cas` | string | 是 | CAS 注册号 | `"7732-18-3"` |
|
|
||||||
| `name` | string | 是 | 试剂中文/英文名称 | `"水"` |
|
|
||||||
| `molecular_formula` | string | 是 | 分子式 | `"H2O"` |
|
|
||||||
| `smiles` | string | 是 | SMILES 表示 | `"O"` |
|
|
||||||
| `stock_in_quantity` | number | 是 | 入库数量 | `10` |
|
|
||||||
| `unit` | string | 是 | 单位(字符串,见下表) | `"mL"` |
|
|
||||||
| `supplier` | string | 否 | 供应商名称 | `"国药集团"` |
|
|
||||||
| `production_date` | string | 否 | 生产日期(ISO 8601) | `"2025-11-18T00:00:00Z"` |
|
|
||||||
| `expiry_date` | string | 否 | 过期日期(ISO 8601) | `"2026-11-18T00:00:00Z"` |
|
|
||||||
|
|
||||||
### unit 单位值
|
|
||||||
|
|
||||||
| 值 | 单位 |
|
|
||||||
|------|------|
|
|
||||||
| `"mL"` | 毫升 |
|
|
||||||
| `"L"` | 升 |
|
|
||||||
| `"g"` | 克 |
|
|
||||||
| `"kg"` | 千克 |
|
|
||||||
| `"瓶"` | 瓶 |
|
|
||||||
|
|
||||||
> 根据试剂状态选择:液体用 `"mL"` / `"L"`,固体用 `"g"` / `"kg"`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 批量录入策略
|
|
||||||
|
|
||||||
### 方式一:用户提供 JSON 数组
|
|
||||||
|
|
||||||
用户一次性给出多条试剂数据:
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{"cas": "7732-18-3", "name": "水", "molecular_formula": "H2O", "smiles": "O", "stock_in_quantity": 10, "unit": "mL"},
|
|
||||||
{"cas": "64-17-5", "name": "乙醇", "molecular_formula": "C2H6O", "smiles": "CCO", "stock_in_quantity": 5, "unit": "L"}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
Agent 自动为每条补充 `lab_uuid`、`production_date`、`expiry_date` 等字段后逐条提交。
|
|
||||||
|
|
||||||
Agent 循环调用 API #2 逐条录入,每条记录一次 API 调用。
|
|
||||||
|
|
||||||
### 方式二:用户逐个描述
|
|
||||||
|
|
||||||
用户口头描述试剂(如「帮我录入 500mL 的无水乙醇,Sigma 的」),agent 自行补全字段:
|
|
||||||
|
|
||||||
1. 根据名称查找 CAS 号、分子式、SMILES(参考下方速查表或自行推断)
|
|
||||||
2. 构建完整的请求体
|
|
||||||
3. 向用户确认后提交
|
|
||||||
|
|
||||||
### 方式三:从 CSV/表格批量导入
|
|
||||||
|
|
||||||
用户提供 CSV 或表格文件路径,agent 读取并解析:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 期望的 CSV 格式(首行为表头)
|
|
||||||
cas,name,molecular_formula,smiles,stock_in_quantity,unit,supplier,production_date,expiry_date
|
|
||||||
7732-18-3,水,H2O,O,10,mL,农夫山泉,2025-11-18T00:00:00Z,2026-11-18T00:00:00Z
|
|
||||||
```
|
|
||||||
|
|
||||||
### 执行与汇报
|
|
||||||
|
|
||||||
每次 API 调用后:
|
|
||||||
1. 检查返回 `code`(0 = 成功)
|
|
||||||
2. 记录成功/失败数量
|
|
||||||
3. 全部完成后汇总:「共录入 N 条试剂,成功 X 条,失败 Y 条」
|
|
||||||
4. 如有失败,列出失败的试剂名称和错误信息
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 常见试剂速查表
|
|
||||||
|
|
||||||
| 名称 | CAS | 分子式 | SMILES |
|
|
||||||
|------|-----|--------|--------|
|
|
||||||
| 水 | 7732-18-3 | H2O | O |
|
|
||||||
| 乙醇 | 64-17-5 | C2H6O | CCO |
|
|
||||||
| 甲醇 | 67-56-1 | CH4O | CO |
|
|
||||||
| 丙酮 | 67-64-1 | C3H6O | CC(C)=O |
|
|
||||||
| 二甲基亚砜(DMSO) | 67-68-5 | C2H6OS | CS(C)=O |
|
|
||||||
| 乙酸乙酯 | 141-78-6 | C4H8O2 | CCOC(C)=O |
|
|
||||||
| 二氯甲烷 | 75-09-2 | CH2Cl2 | ClCCl |
|
|
||||||
| 四氢呋喃(THF) | 109-99-9 | C4H8O | C1CCOC1 |
|
|
||||||
| N,N-二甲基甲酰胺(DMF) | 68-12-2 | C3H7NO | CN(C)C=O |
|
|
||||||
| 氯仿 | 67-66-3 | CHCl3 | ClC(Cl)Cl |
|
|
||||||
| 乙腈 | 75-05-8 | C2H3N | CC#N |
|
|
||||||
| 甲苯 | 108-88-3 | C7H8 | Cc1ccccc1 |
|
|
||||||
| 正己烷 | 110-54-3 | C6H14 | CCCCCC |
|
|
||||||
| 异丙醇 | 67-63-0 | C3H8O | CC(C)O |
|
|
||||||
| 盐酸 | 7647-01-0 | HCl | Cl |
|
|
||||||
| 硫酸 | 7664-93-9 | H2SO4 | OS(O)(=O)=O |
|
|
||||||
| 氢氧化钠 | 1310-73-2 | NaOH | [Na]O |
|
|
||||||
| 碳酸钠 | 497-19-8 | Na2CO3 | [Na]OC([O-])=O.[Na+] |
|
|
||||||
| 氯化钠 | 7647-14-5 | NaCl | [Na]Cl |
|
|
||||||
| 乙二胺四乙酸(EDTA) | 60-00-4 | C10H16N2O8 | OC(=O)CN(CCN(CC(O)=O)CC(O)=O)CC(O)=O |
|
|
||||||
|
|
||||||
> 此表仅供快速参考。对于不在表中的试剂,agent 应根据化学知识推断或提示用户补充。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 完整工作流 Checklist
|
|
||||||
|
|
||||||
```
|
|
||||||
Task Progress:
|
|
||||||
- [ ] Step 1: 确认 ak/sk → 生成 AUTH token
|
|
||||||
- [ ] Step 2: 确认 --addr → 设置 BASE URL
|
|
||||||
- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid
|
|
||||||
- [ ] Step 4: 收集试剂信息(用户提供列表/逐个描述/CSV文件)
|
|
||||||
- [ ] Step 5: 补全缺失字段(CAS、分子式、SMILES 等)
|
|
||||||
- [ ] Step 6: 向用户确认待录入的试剂列表
|
|
||||||
- [ ] Step 7: 循环调用 POST /lab/reagent 逐条录入(每条需含 lab_uuid)
|
|
||||||
- [ ] Step 8: 汇总结果(成功/失败数量及详情)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 完整示例
|
|
||||||
|
|
||||||
用户说:「帮我录入 3 种试剂:500mL 无水乙醇、1kg 氯化钠、2L 去离子水」
|
|
||||||
|
|
||||||
Agent 构建的请求序列:
|
|
||||||
|
|
||||||
```json
|
|
||||||
// 第 1 条
|
|
||||||
{"lab_uuid": "8511c672-...", "cas": "64-17-5", "name": "无水乙醇", "molecular_formula": "C2H6O", "smiles": "CCO", "stock_in_quantity": 500, "unit": "mL", "supplier": "国药集团", "production_date": "2025-01-01T00:00:00Z", "expiry_date": "2026-01-01T00:00:00Z"}
|
|
||||||
|
|
||||||
// 第 2 条
|
|
||||||
{"lab_uuid": "8511c672-...", "cas": "7647-14-5", "name": "氯化钠", "molecular_formula": "NaCl", "smiles": "[Na]Cl", "stock_in_quantity": 1, "unit": "kg", "supplier": "", "production_date": "2025-01-01T00:00:00Z", "expiry_date": "2026-01-01T00:00:00Z"}
|
|
||||||
|
|
||||||
// 第 3 条
|
|
||||||
{"lab_uuid": "8511c672-...", "cas": "7732-18-3", "name": "去离子水", "molecular_formula": "H2O", "smiles": "O", "stock_in_quantity": 2, "unit": "L", "supplier": "", "production_date": "2025-01-01T00:00:00Z", "expiry_date": "2026-01-01T00:00:00Z"}
|
|
||||||
```
|
|
||||||
@@ -1,325 +0,0 @@
|
|||||||
---
|
|
||||||
name: batch-submit-experiment
|
|
||||||
description: Batch submit experiments (notebooks) to Uni-Lab platform — list workflows, generate node_params from registry schemas, submit multiple rounds, check notebook status. Use when the user wants to submit experiments, create notebooks, batch run workflows, check experiment status, or mentions 提交实验/批量实验/notebook/实验轮次/实验状态.
|
|
||||||
---
|
|
||||||
|
|
||||||
# 批量提交实验指南
|
|
||||||
|
|
||||||
通过云端 API 批量提交实验(notebook),支持多轮实验参数配置。根据 workflow 模板详情和本地设备注册表自动生成 `node_params` 模板。
|
|
||||||
|
|
||||||
## 前置条件(缺一不可)
|
|
||||||
|
|
||||||
使用本指南前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
|
|
||||||
|
|
||||||
### 1. ak / sk → AUTH
|
|
||||||
|
|
||||||
询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。
|
|
||||||
|
|
||||||
生成 AUTH token(任选一种方式):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 方式一:Python 一行生成
|
|
||||||
python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
|
|
||||||
|
|
||||||
# 方式二:手动计算
|
|
||||||
# base64(ak:sk) → Authorization: Lab <token>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. --addr → BASE URL
|
|
||||||
|
|
||||||
| `--addr` 值 | BASE |
|
|
||||||
|-------------|------|
|
|
||||||
| `test` | `https://uni-lab.test.bohrium.com` |
|
|
||||||
| `uat` | `https://uni-lab.uat.bohrium.com` |
|
|
||||||
| `local` | `http://127.0.0.1:48197` |
|
|
||||||
| 不传(默认) | `https://uni-lab.bohrium.com` |
|
|
||||||
|
|
||||||
确认后设置:
|
|
||||||
```bash
|
|
||||||
BASE="<根据 addr 确定的 URL>"
|
|
||||||
AUTH="Authorization: Lab <上面命令输出的 token>"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. req_device_registry_upload.json(设备注册表)
|
|
||||||
|
|
||||||
**批量提交实验时需要本地注册表来解析 workflow 节点的参数 schema。**
|
|
||||||
|
|
||||||
按优先级搜索:
|
|
||||||
|
|
||||||
```
|
|
||||||
<workspace 根目录>/unilabos_data/req_device_registry_upload.json
|
|
||||||
<workspace 根目录>/req_device_registry_upload.json
|
|
||||||
```
|
|
||||||
|
|
||||||
也可直接 Glob 搜索:`**/req_device_registry_upload.json`
|
|
||||||
|
|
||||||
找到后**检查文件修改时间**并告知用户。超过 1 天提醒用户是否需要重新启动 `unilab`。
|
|
||||||
|
|
||||||
**如果文件不存在** → 告知用户先运行 `unilab` 启动命令,等注册表生成后再执行。可跳过此步,但将无法自动生成参数模板,需要用户手动填写 `param`。
|
|
||||||
|
|
||||||
### 4. workflow_uuid(目标工作流)
|
|
||||||
|
|
||||||
用户需要提供要提交的 workflow UUID。如果用户不确定,通过 API #3 列出可用 workflow 供选择。
|
|
||||||
|
|
||||||
**四项全部就绪后才可开始。**
|
|
||||||
|
|
||||||
## Session State
|
|
||||||
|
|
||||||
在整个对话过程中,agent 需要记住以下状态,避免重复询问用户:
|
|
||||||
|
|
||||||
- `lab_uuid` — 实验室 UUID(首次通过 API #1 自动获取,**不需要问用户**)
|
|
||||||
- `project_uuid` — 项目 UUID(通过 API #2 列出项目列表,**让用户选择**)
|
|
||||||
- `workflow_uuid` — 工作流 UUID(用户提供或从列表选择)
|
|
||||||
- `workflow_nodes` — workflow 中各 action 节点的 uuid、设备 ID、动作名(从 API #4 获取)
|
|
||||||
|
|
||||||
## 请求约定
|
|
||||||
|
|
||||||
所有请求使用 `curl -s`,POST 需加 `Content-Type: application/json`。
|
|
||||||
|
|
||||||
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名),示例中的 `curl` 均指 `curl.exe`。
|
|
||||||
>
|
|
||||||
> **PowerShell JSON 传参**:PowerShell 中 `-d '{"key":"value"}'` 会因引号转义失败。请将 JSON 写入临时文件,用 `-d '@tmp_body.json'`(单引号包裹 `@`,否则会被解析为 splatting 运算符)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
### 1. 获取实验室信息(自动获取 lab_uuid)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
|
||||||
```
|
|
||||||
|
|
||||||
返回:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}}
|
|
||||||
```
|
|
||||||
|
|
||||||
记住 `data.uuid` 为 `lab_uuid`。
|
|
||||||
|
|
||||||
### 2. 列出实验室项目(让用户选择项目)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -X GET "$BASE/api/v1/lab/project/list?lab_uuid=$lab_uuid" -H "$AUTH"
|
|
||||||
```
|
|
||||||
|
|
||||||
返回项目列表,展示给用户选择。列出每个项目的 `uuid` 和 `name`。
|
|
||||||
|
|
||||||
用户**必须**选择一个项目,记住 `project_uuid`,后续创建 notebook 时需要提供。
|
|
||||||
|
|
||||||
### 3. 列出可用 workflow
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -X GET "$BASE/api/v1/lab/workflow/workflows?page=1&page_size=20&lab_uuid=$lab_uuid" -H "$AUTH"
|
|
||||||
```
|
|
||||||
|
|
||||||
返回 workflow 列表,展示给用户选择。列出每个 workflow 的 `uuid` 和 `name`。
|
|
||||||
|
|
||||||
### 4. 获取 workflow 模板详情
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH"
|
|
||||||
```
|
|
||||||
|
|
||||||
返回 workflow 的完整结构,包含所有 action 节点信息。需要从响应中提取:
|
|
||||||
- 每个 action 节点的 `node_uuid`
|
|
||||||
- 每个节点对应的设备 ID(`resource_template_name`)
|
|
||||||
- 每个节点的动作名(`node_template_name`)
|
|
||||||
- 每个节点的现有参数(`param`)
|
|
||||||
|
|
||||||
> **注意**:此 API 返回格式可能因版本不同而有差异。首次调用时,先打印完整响应分析结构,再提取节点信息。常见的节点字段路径为 `data.nodes[]` 或 `data.workflow_nodes[]`。
|
|
||||||
|
|
||||||
### 5. 提交实验(创建 notebook)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -X POST "$BASE/api/v1/lab/notebook" \
|
|
||||||
-H "$AUTH" -H "Content-Type: application/json" \
|
|
||||||
-d '<request_body>'
|
|
||||||
```
|
|
||||||
|
|
||||||
请求体结构:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"lab_uuid": "<lab_uuid>",
|
|
||||||
"project_uuid": "<project_uuid>",
|
|
||||||
"workflow_uuid": "<workflow_uuid>",
|
|
||||||
"name": "<实验名称>",
|
|
||||||
"node_params": [
|
|
||||||
{
|
|
||||||
"sample_uuids": ["<样品UUID1>", "<样品UUID2>"],
|
|
||||||
"datas": [
|
|
||||||
{
|
|
||||||
"node_uuid": "<workflow中的节点UUID>",
|
|
||||||
"param": {},
|
|
||||||
"sample_params": [
|
|
||||||
{
|
|
||||||
"container_uuid": "<容器UUID>",
|
|
||||||
"sample_value": {
|
|
||||||
"liquid_names": "<液体名称>",
|
|
||||||
"volumes": 1000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> **注意**:`sample_uuids` 必须是 **UUID 数组**(`[]uuid.UUID`),不是字符串。无样品时传空数组 `[]`。
|
|
||||||
|
|
||||||
### 6. 查询 notebook 状态
|
|
||||||
|
|
||||||
提交成功后,使用返回的 notebook UUID 查询执行状态:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -X GET "$BASE/api/v1/lab/notebook/status?uuid=$notebook_uuid" -H "$AUTH"
|
|
||||||
```
|
|
||||||
|
|
||||||
提交后应**立即查询一次**状态,确认 notebook 已被正确接收并开始调度。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notebook 请求体详解
|
|
||||||
|
|
||||||
### node_params 结构
|
|
||||||
|
|
||||||
`node_params` 是一个数组,**每个元素代表一轮实验**:
|
|
||||||
|
|
||||||
- 要跑 2 轮 → `node_params` 有 2 个元素
|
|
||||||
- 要跑 N 轮 → `node_params` 有 N 个元素
|
|
||||||
|
|
||||||
### 每轮的字段
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| `sample_uuids` | array\<uuid\> | 该轮实验的样品 UUID 数组,无样品时传 `[]` |
|
|
||||||
| `datas` | array | 该轮中每个 workflow 节点的参数配置 |
|
|
||||||
|
|
||||||
### datas 中每个节点
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| `node_uuid` | string | workflow 模板中的节点 UUID(从 API #4 获取) |
|
|
||||||
| `param` | object | 动作参数(根据本地注册表 schema 填写) |
|
|
||||||
| `sample_params` | array | 样品相关参数(液体名、体积等) |
|
|
||||||
|
|
||||||
### sample_params 中每条
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| `container_uuid` | string | 容器 UUID |
|
|
||||||
| `sample_value` | object | 样品值,如 `{"liquid_names": "水", "volumes": 1000}` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 从本地注册表生成 param 模板
|
|
||||||
|
|
||||||
### 自动方式 — 运行脚本
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python scripts/gen_notebook_params.py \
|
|
||||||
--auth <token> \
|
|
||||||
--base <BASE_URL> \
|
|
||||||
--workflow-uuid <workflow_uuid> \
|
|
||||||
[--registry <path/to/req_device_registry_upload.json>] \
|
|
||||||
[--rounds <轮次数>] \
|
|
||||||
[--output <输出文件路径>]
|
|
||||||
```
|
|
||||||
|
|
||||||
> 脚本位于本文档同级目录下的 `scripts/gen_notebook_params.py`。
|
|
||||||
|
|
||||||
脚本会:
|
|
||||||
1. 调用 workflow detail API 获取所有 action 节点
|
|
||||||
2. 读取本地注册表,为每个节点查找对应的 action schema
|
|
||||||
3. 生成 `notebook_template.json`,包含:
|
|
||||||
- 完整 `node_params` 骨架
|
|
||||||
- 每个节点的 param 字段及类型说明
|
|
||||||
- `_schema_info` 辅助信息(不提交,仅供参考)
|
|
||||||
|
|
||||||
### 手动方式
|
|
||||||
|
|
||||||
如果脚本不可用或注册表不存在:
|
|
||||||
|
|
||||||
1. 调用 API #4 获取 workflow 详情
|
|
||||||
2. 找到每个 action 节点的 `node_uuid`
|
|
||||||
3. 在本地注册表中查找对应设备的 `action_value_mappings`:
|
|
||||||
```
|
|
||||||
resources[].id == <device_id>
|
|
||||||
→ resources[].class.action_value_mappings.<action_name>.schema.properties.goal.properties
|
|
||||||
```
|
|
||||||
4. 将 schema 中的 properties 作为 `param` 的字段模板
|
|
||||||
5. 按轮次复制 `node_params` 元素,让用户填写每轮的具体值
|
|
||||||
|
|
||||||
### 注册表结构参考
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"resources": [
|
|
||||||
{
|
|
||||||
"id": "liquid_handler.prcxi",
|
|
||||||
"class": {
|
|
||||||
"module": "unilabos.devices.xxx:ClassName",
|
|
||||||
"action_value_mappings": {
|
|
||||||
"transfer_liquid": {
|
|
||||||
"type": "LiquidHandlerTransfer",
|
|
||||||
"schema": {
|
|
||||||
"properties": {
|
|
||||||
"goal": {
|
|
||||||
"properties": {
|
|
||||||
"asp_vols": {"type": "array", "items": {"type": "number"}},
|
|
||||||
"sources": {"type": "array"}
|
|
||||||
},
|
|
||||||
"required": ["asp_vols", "sources"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"goal_default": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`param` 填写时,使用 `goal.properties` 中的字段名和类型。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 完整工作流 Checklist
|
|
||||||
|
|
||||||
```
|
|
||||||
Task Progress:
|
|
||||||
- [ ] Step 1: 确认 ak/sk → 生成 AUTH token
|
|
||||||
- [ ] Step 2: 确认 --addr → 设置 BASE URL
|
|
||||||
- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid
|
|
||||||
- [ ] Step 4: GET /lab/project/list → 列出项目,让用户选择 → 获取 project_uuid
|
|
||||||
- [ ] Step 5: 确认 workflow_uuid(用户提供或从 GET #3 列表选择)
|
|
||||||
- [ ] Step 6: GET workflow detail (#4) → 提取各节点 uuid、设备ID、动作名
|
|
||||||
- [ ] Step 7: 定位本地注册表 req_device_registry_upload.json
|
|
||||||
- [ ] Step 8: 运行 gen_notebook_params.py 或手动匹配 → 生成 node_params 模板
|
|
||||||
- [ ] Step 9: 引导用户填写每轮的参数(sample_uuids、param、sample_params)
|
|
||||||
- [ ] Step 10: 构建完整请求体(含 project_uuid)→ POST /lab/notebook 提交
|
|
||||||
- [ ] Step 11: 检查返回结果,记录 notebook UUID
|
|
||||||
- [ ] Step 12: GET /lab/notebook/status → 查询 notebook 状态,确认已调度
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
### Q: workflow 中有多个节点,每轮都要填所有节点的参数吗?
|
|
||||||
|
|
||||||
是的。`datas` 数组中需要包含该轮实验涉及的每个 workflow 节点的参数。通常每个 action 节点都需要一条 `datas` 记录。
|
|
||||||
|
|
||||||
### Q: 多轮实验的参数完全不同吗?
|
|
||||||
|
|
||||||
通常每轮的 `param`(设备动作参数)可能相同或相似,但 `sample_uuids` 和 `sample_params`(样品信息)每轮不同。脚本生成模板时会按轮次复制骨架,用户只需修改差异部分。
|
|
||||||
|
|
||||||
### Q: 如何获取 sample_uuids 和 container_uuid?
|
|
||||||
|
|
||||||
这些 UUID 通常来自实验室的样品管理系统。向用户询问,或从资源树(API `GET /lab/material/download/$lab_uuid`)中查找。
|
|
||||||
@@ -1,395 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
从 workflow 模板详情 + 本地设备注册表生成 notebook 提交用的 node_params 模板。
|
|
||||||
|
|
||||||
用法:
|
|
||||||
python gen_notebook_params.py --auth <token> --base <url> --workflow-uuid <uuid> [选项]
|
|
||||||
|
|
||||||
选项:
|
|
||||||
--auth <token> Lab token(base64(ak:sk) 的结果,不含 "Lab " 前缀)
|
|
||||||
--base <url> API 基础 URL(如 https://uni-lab.test.bohrium.com)
|
|
||||||
--workflow-uuid <uuid> 目标 workflow 的 UUID
|
|
||||||
--registry <path> 本地注册表文件路径(默认自动搜索)
|
|
||||||
--rounds <n> 实验轮次数(默认 1)
|
|
||||||
--output <path> 输出模板文件路径(默认 notebook_template.json)
|
|
||||||
--dump-response 打印 workflow detail API 的原始响应(调试用)
|
|
||||||
|
|
||||||
示例:
|
|
||||||
python gen_notebook_params.py \\
|
|
||||||
--auth YTFmZDlkNGUtxxxx \\
|
|
||||||
--base https://uni-lab.test.bohrium.com \\
|
|
||||||
--workflow-uuid abc-123-def \\
|
|
||||||
--rounds 2
|
|
||||||
"""
|
|
||||||
import copy
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from datetime import datetime
|
|
||||||
from urllib.request import Request, urlopen
|
|
||||||
from urllib.error import HTTPError, URLError
|
|
||||||
|
|
||||||
REGISTRY_FILENAME = "req_device_registry_upload.json"
|
|
||||||
|
|
||||||
|
|
||||||
def find_registry(explicit_path=None):
|
|
||||||
"""查找本地注册表文件,逻辑同 extract_device_actions.py"""
|
|
||||||
if explicit_path:
|
|
||||||
if os.path.isfile(explicit_path):
|
|
||||||
return explicit_path
|
|
||||||
if os.path.isdir(explicit_path):
|
|
||||||
fp = os.path.join(explicit_path, REGISTRY_FILENAME)
|
|
||||||
if os.path.isfile(fp):
|
|
||||||
return fp
|
|
||||||
print(f"警告: 指定的注册表路径不存在: {explicit_path}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
candidates = [
|
|
||||||
os.path.join("unilabos_data", REGISTRY_FILENAME),
|
|
||||||
REGISTRY_FILENAME,
|
|
||||||
]
|
|
||||||
for c in candidates:
|
|
||||||
if os.path.isfile(c):
|
|
||||||
return c
|
|
||||||
|
|
||||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
workspace_root = os.path.normpath(os.path.join(script_dir, "..", "..", ".."))
|
|
||||||
for c in candidates:
|
|
||||||
path = os.path.join(workspace_root, c)
|
|
||||||
if os.path.isfile(path):
|
|
||||||
return path
|
|
||||||
|
|
||||||
cwd = os.getcwd()
|
|
||||||
for _ in range(5):
|
|
||||||
parent = os.path.dirname(cwd)
|
|
||||||
if parent == cwd:
|
|
||||||
break
|
|
||||||
cwd = parent
|
|
||||||
for c in candidates:
|
|
||||||
path = os.path.join(cwd, c)
|
|
||||||
if os.path.isfile(path):
|
|
||||||
return path
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def load_registry(path):
|
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
|
||||||
return json.load(f)
|
|
||||||
|
|
||||||
|
|
||||||
def build_registry_index(registry_data):
|
|
||||||
"""构建 device_id → action_value_mappings 的索引"""
|
|
||||||
index = {}
|
|
||||||
for res in registry_data.get("resources", []):
|
|
||||||
rid = res.get("id", "")
|
|
||||||
avm = res.get("class", {}).get("action_value_mappings", {})
|
|
||||||
if rid and avm:
|
|
||||||
index[rid] = avm
|
|
||||||
return index
|
|
||||||
|
|
||||||
|
|
||||||
def flatten_goal_schema(action_data):
|
|
||||||
"""从 action_value_mappings 条目中提取 goal 层的 schema"""
|
|
||||||
schema = action_data.get("schema", {})
|
|
||||||
goal_schema = schema.get("properties", {}).get("goal", {})
|
|
||||||
return goal_schema if goal_schema else schema
|
|
||||||
|
|
||||||
|
|
||||||
def build_param_template(goal_schema):
|
|
||||||
"""根据 goal schema 生成 param 模板,含类型标注"""
|
|
||||||
properties = goal_schema.get("properties", {})
|
|
||||||
required = set(goal_schema.get("required", []))
|
|
||||||
template = {}
|
|
||||||
for field_name, field_def in properties.items():
|
|
||||||
if field_name == "unilabos_device_id":
|
|
||||||
continue
|
|
||||||
ftype = field_def.get("type", "any")
|
|
||||||
default = field_def.get("default")
|
|
||||||
if default is not None:
|
|
||||||
template[field_name] = default
|
|
||||||
elif ftype == "string":
|
|
||||||
template[field_name] = f"$TODO ({ftype}, {'required' if field_name in required else 'optional'})"
|
|
||||||
elif ftype == "number" or ftype == "integer":
|
|
||||||
template[field_name] = 0
|
|
||||||
elif ftype == "boolean":
|
|
||||||
template[field_name] = False
|
|
||||||
elif ftype == "array":
|
|
||||||
template[field_name] = []
|
|
||||||
elif ftype == "object":
|
|
||||||
template[field_name] = {}
|
|
||||||
else:
|
|
||||||
template[field_name] = f"$TODO ({ftype})"
|
|
||||||
return template
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_workflow_detail(base_url, auth_token, workflow_uuid):
|
|
||||||
"""调用 workflow detail API"""
|
|
||||||
url = f"{base_url}/api/v1/lab/workflow/template/detail/{workflow_uuid}"
|
|
||||||
req = Request(url, method="GET")
|
|
||||||
req.add_header("Authorization", f"Lab {auth_token}")
|
|
||||||
try:
|
|
||||||
with urlopen(req, timeout=30) as resp:
|
|
||||||
return json.loads(resp.read().decode("utf-8"))
|
|
||||||
except HTTPError as e:
|
|
||||||
body = e.read().decode("utf-8", errors="replace")
|
|
||||||
print(f"API 错误 {e.code}: {body}")
|
|
||||||
return None
|
|
||||||
except URLError as e:
|
|
||||||
print(f"网络错误: {e.reason}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def extract_nodes_from_response(response):
|
|
||||||
"""
|
|
||||||
从 workflow detail 响应中提取 action 节点列表。
|
|
||||||
适配多种可能的响应格式。
|
|
||||||
|
|
||||||
返回: [(node_uuid, resource_template_name, node_template_name, existing_param), ...]
|
|
||||||
"""
|
|
||||||
data = response.get("data", response)
|
|
||||||
|
|
||||||
search_keys = ["nodes", "workflow_nodes", "node_list", "steps"]
|
|
||||||
nodes_raw = None
|
|
||||||
for key in search_keys:
|
|
||||||
if key in data and isinstance(data[key], list):
|
|
||||||
nodes_raw = data[key]
|
|
||||||
break
|
|
||||||
|
|
||||||
if nodes_raw is None:
|
|
||||||
if isinstance(data, list):
|
|
||||||
nodes_raw = data
|
|
||||||
else:
|
|
||||||
for v in data.values():
|
|
||||||
if isinstance(v, list) and len(v) > 0 and isinstance(v[0], dict):
|
|
||||||
nodes_raw = v
|
|
||||||
break
|
|
||||||
|
|
||||||
if not nodes_raw:
|
|
||||||
print("警告: 未能从响应中提取节点列表")
|
|
||||||
print("响应顶层 keys:", list(data.keys()) if isinstance(data, dict) else type(data).__name__)
|
|
||||||
return []
|
|
||||||
|
|
||||||
result = []
|
|
||||||
for node in nodes_raw:
|
|
||||||
if not isinstance(node, dict):
|
|
||||||
continue
|
|
||||||
|
|
||||||
node_uuid = (
|
|
||||||
node.get("uuid")
|
|
||||||
or node.get("node_uuid")
|
|
||||||
or node.get("id")
|
|
||||||
or ""
|
|
||||||
)
|
|
||||||
resource_name = (
|
|
||||||
node.get("resource_template_name")
|
|
||||||
or node.get("device_id")
|
|
||||||
or node.get("resource_name")
|
|
||||||
or node.get("device_name")
|
|
||||||
or ""
|
|
||||||
)
|
|
||||||
template_name = (
|
|
||||||
node.get("node_template_name")
|
|
||||||
or node.get("action_name")
|
|
||||||
or node.get("template_name")
|
|
||||||
or node.get("action")
|
|
||||||
or node.get("name")
|
|
||||||
or ""
|
|
||||||
)
|
|
||||||
existing_param = node.get("param", {}) or {}
|
|
||||||
|
|
||||||
if node_uuid:
|
|
||||||
result.append((node_uuid, resource_name, template_name, existing_param))
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def generate_template(nodes, registry_index, rounds):
|
|
||||||
"""生成 notebook 提交模板"""
|
|
||||||
node_params = []
|
|
||||||
schema_info = {}
|
|
||||||
|
|
||||||
datas_template = []
|
|
||||||
for node_uuid, resource_name, template_name, existing_param in nodes:
|
|
||||||
param_template = {}
|
|
||||||
matched = False
|
|
||||||
|
|
||||||
if resource_name and template_name and resource_name in registry_index:
|
|
||||||
avm = registry_index[resource_name]
|
|
||||||
if template_name in avm:
|
|
||||||
goal_schema = flatten_goal_schema(avm[template_name])
|
|
||||||
param_template = build_param_template(goal_schema)
|
|
||||||
goal_default = avm[template_name].get("goal_default", {})
|
|
||||||
if goal_default:
|
|
||||||
for k, v in goal_default.items():
|
|
||||||
if k in param_template and v is not None:
|
|
||||||
param_template[k] = v
|
|
||||||
matched = True
|
|
||||||
|
|
||||||
schema_info[node_uuid] = {
|
|
||||||
"device_id": resource_name,
|
|
||||||
"action_name": template_name,
|
|
||||||
"action_type": avm[template_name].get("type", ""),
|
|
||||||
"schema_properties": list(goal_schema.get("properties", {}).keys()),
|
|
||||||
"required": goal_schema.get("required", []),
|
|
||||||
}
|
|
||||||
|
|
||||||
if not matched and existing_param:
|
|
||||||
param_template = existing_param
|
|
||||||
|
|
||||||
if not matched and not existing_param:
|
|
||||||
schema_info[node_uuid] = {
|
|
||||||
"device_id": resource_name,
|
|
||||||
"action_name": template_name,
|
|
||||||
"warning": "未在本地注册表中找到匹配的 action schema",
|
|
||||||
}
|
|
||||||
|
|
||||||
datas_template.append({
|
|
||||||
"node_uuid": node_uuid,
|
|
||||||
"param": param_template,
|
|
||||||
"sample_params": [
|
|
||||||
{
|
|
||||||
"container_uuid": "$TODO_CONTAINER_UUID",
|
|
||||||
"sample_value": {
|
|
||||||
"liquid_names": "$TODO_LIQUID_NAME",
|
|
||||||
"volumes": 0,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
for i in range(rounds):
|
|
||||||
node_params.append({
|
|
||||||
"sample_uuids": f"$TODO_SAMPLE_UUID_ROUND_{i + 1}",
|
|
||||||
"datas": copy.deepcopy(datas_template),
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
"lab_uuid": "$TODO_LAB_UUID",
|
|
||||||
"project_uuid": "$TODO_PROJECT_UUID",
|
|
||||||
"workflow_uuid": "$TODO_WORKFLOW_UUID",
|
|
||||||
"name": "$TODO_EXPERIMENT_NAME",
|
|
||||||
"node_params": node_params,
|
|
||||||
"_schema_info(仅参考,提交时删除)": schema_info,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def parse_args(argv):
|
|
||||||
"""简单的参数解析"""
|
|
||||||
opts = {
|
|
||||||
"auth": None,
|
|
||||||
"base": None,
|
|
||||||
"workflow_uuid": None,
|
|
||||||
"registry": None,
|
|
||||||
"rounds": 1,
|
|
||||||
"output": "notebook_template.json",
|
|
||||||
"dump_response": False,
|
|
||||||
}
|
|
||||||
i = 0
|
|
||||||
while i < len(argv):
|
|
||||||
arg = argv[i]
|
|
||||||
if arg == "--auth" and i + 1 < len(argv):
|
|
||||||
opts["auth"] = argv[i + 1]
|
|
||||||
i += 2
|
|
||||||
elif arg == "--base" and i + 1 < len(argv):
|
|
||||||
opts["base"] = argv[i + 1].rstrip("/")
|
|
||||||
i += 2
|
|
||||||
elif arg == "--workflow-uuid" and i + 1 < len(argv):
|
|
||||||
opts["workflow_uuid"] = argv[i + 1]
|
|
||||||
i += 2
|
|
||||||
elif arg == "--registry" and i + 1 < len(argv):
|
|
||||||
opts["registry"] = argv[i + 1]
|
|
||||||
i += 2
|
|
||||||
elif arg == "--rounds" and i + 1 < len(argv):
|
|
||||||
opts["rounds"] = int(argv[i + 1])
|
|
||||||
i += 2
|
|
||||||
elif arg == "--output" and i + 1 < len(argv):
|
|
||||||
opts["output"] = argv[i + 1]
|
|
||||||
i += 2
|
|
||||||
elif arg == "--dump-response":
|
|
||||||
opts["dump_response"] = True
|
|
||||||
i += 1
|
|
||||||
else:
|
|
||||||
print(f"未知参数: {arg}")
|
|
||||||
i += 1
|
|
||||||
return opts
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
opts = parse_args(sys.argv[1:])
|
|
||||||
|
|
||||||
if not opts["auth"] or not opts["base"] or not opts["workflow_uuid"]:
|
|
||||||
print("用法:")
|
|
||||||
print(" python gen_notebook_params.py --auth <token> --base <url> --workflow-uuid <uuid> [选项]")
|
|
||||||
print()
|
|
||||||
print("必需参数:")
|
|
||||||
print(" --auth <token> Lab token(base64(ak:sk))")
|
|
||||||
print(" --base <url> API 基础 URL")
|
|
||||||
print(" --workflow-uuid <uuid> 目标 workflow UUID")
|
|
||||||
print()
|
|
||||||
print("可选参数:")
|
|
||||||
print(" --registry <path> 注册表文件路径(默认自动搜索)")
|
|
||||||
print(" --rounds <n> 实验轮次数(默认 1)")
|
|
||||||
print(" --output <path> 输出文件路径(默认 notebook_template.json)")
|
|
||||||
print(" --dump-response 打印 API 原始响应")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# 1. 查找并加载本地注册表
|
|
||||||
registry_path = find_registry(opts["registry"])
|
|
||||||
registry_index = {}
|
|
||||||
if registry_path:
|
|
||||||
mtime = os.path.getmtime(registry_path)
|
|
||||||
gen_time = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
print(f"注册表: {registry_path} (生成时间: {gen_time})")
|
|
||||||
registry_data = load_registry(registry_path)
|
|
||||||
registry_index = build_registry_index(registry_data)
|
|
||||||
print(f"已索引 {len(registry_index)} 个设备的 action schemas")
|
|
||||||
else:
|
|
||||||
print("警告: 未找到本地注册表,将跳过 param 模板生成")
|
|
||||||
print(" 提交时需要手动填写各节点的 param 字段")
|
|
||||||
|
|
||||||
# 2. 获取 workflow 详情
|
|
||||||
print(f"\n正在获取 workflow 详情: {opts['workflow_uuid']}")
|
|
||||||
response = fetch_workflow_detail(opts["base"], opts["auth"], opts["workflow_uuid"])
|
|
||||||
if not response:
|
|
||||||
print("错误: 无法获取 workflow 详情")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if opts["dump_response"]:
|
|
||||||
print("\n=== API 原始响应 ===")
|
|
||||||
print(json.dumps(response, indent=2, ensure_ascii=False)[:5000])
|
|
||||||
print("=== 响应结束(截断至 5000 字符) ===\n")
|
|
||||||
|
|
||||||
# 3. 提取节点
|
|
||||||
nodes = extract_nodes_from_response(response)
|
|
||||||
if not nodes:
|
|
||||||
print("错误: 未能从 workflow 中提取任何 action 节点")
|
|
||||||
print("请使用 --dump-response 查看原始响应结构")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print(f"\n找到 {len(nodes)} 个 action 节点:")
|
|
||||||
print(f" {'节点 UUID':<40} {'设备 ID':<30} {'动作名':<25} {'Schema'}")
|
|
||||||
print(" " + "-" * 110)
|
|
||||||
for node_uuid, resource_name, template_name, _ in nodes:
|
|
||||||
matched = "✓" if (resource_name in registry_index and
|
|
||||||
template_name in registry_index.get(resource_name, {})) else "✗"
|
|
||||||
print(f" {node_uuid:<40} {resource_name:<30} {template_name:<25} {matched}")
|
|
||||||
|
|
||||||
# 4. 生成模板
|
|
||||||
template = generate_template(nodes, registry_index, opts["rounds"])
|
|
||||||
template["workflow_uuid"] = opts["workflow_uuid"]
|
|
||||||
|
|
||||||
output_path = opts["output"]
|
|
||||||
with open(output_path, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(template, f, indent=2, ensure_ascii=False)
|
|
||||||
print(f"\n模板已写入: {output_path}")
|
|
||||||
print(f" 轮次数: {opts['rounds']}")
|
|
||||||
print(f" 节点数/轮: {len(nodes)}")
|
|
||||||
print()
|
|
||||||
print("下一步:")
|
|
||||||
print(" 1. 打开模板文件,将 $TODO 占位符替换为实际值")
|
|
||||||
print(" 2. 删除 _schema_info 字段(仅供参考)")
|
|
||||||
print(" 3. 使用 POST /api/v1/lab/notebook 提交")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,380 +0,0 @@
|
|||||||
---
|
|
||||||
name: create-device-skill
|
|
||||||
description: Create a skill for any Uni-Lab device by extracting action schemas from the device registry. Use when the user wants to create a new device skill, add device API documentation, or set up action schemas for a device.
|
|
||||||
---
|
|
||||||
|
|
||||||
# 创建设备 Skill 指南
|
|
||||||
|
|
||||||
本 meta-skill 教你如何为任意 Uni-Lab-OS 设备创建完整的 API 操作技能(参考 `unilab-device-api` 的成功案例)。
|
|
||||||
|
|
||||||
## 数据源
|
|
||||||
|
|
||||||
- **设备注册表**: `unilabos_data/req_device_registry_upload.json`
|
|
||||||
- **结构**: `{ "resources": [{ "id": "<device_id>", "class": { "module": "<python_module:ClassName>", "action_value_mappings": { ... } } }] }`
|
|
||||||
- **生成时机**: `unilab` 启动并完成注册表上传后自动生成
|
|
||||||
- **module 字段**: 格式 `unilabos.devices.xxx.yyy:ClassName`,可转为源码路径 `unilabos/devices/xxx/yyy.py`,阅读源码可了解参数含义和设备行为
|
|
||||||
|
|
||||||
## 创建流程
|
|
||||||
|
|
||||||
### Step 0 — 收集必备信息(缺一不可,否则询问后终止)
|
|
||||||
|
|
||||||
开始前**必须**确认以下 4 项信息全部就绪。如果用户未提供任何一项,**立即询问并终止当前流程**,等用户补齐后再继续。
|
|
||||||
|
|
||||||
向用户提问:「请提供你的 unilab 启动参数,我需要以下信息:」
|
|
||||||
|
|
||||||
#### 必备项 ①:ak / sk(认证凭据)
|
|
||||||
|
|
||||||
来源:启动命令的 `--ak` `--sk` 参数,或 config.py 中的 `ak = "..."` `sk = "..."`。
|
|
||||||
|
|
||||||
获取后立即生成 AUTH token:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python ./scripts/gen_auth.py <ak> <sk>
|
|
||||||
# 或从 config.py 提取
|
|
||||||
python ./scripts/gen_auth.py --config <config.py>
|
|
||||||
```
|
|
||||||
|
|
||||||
认证算法:`base64(ak:sk)` → `Authorization: Lab <token>`
|
|
||||||
|
|
||||||
#### 必备项 ②:--addr(目标环境)
|
|
||||||
|
|
||||||
决定 API 请求发往哪个服务器。从启动命令的 `--addr` 参数获取:
|
|
||||||
|
|
||||||
| `--addr` 值 | BASE URL |
|
|
||||||
|-------------|----------|
|
|
||||||
| `test` | `https://uni-lab.test.bohrium.com` |
|
|
||||||
| `uat` | `https://uni-lab.uat.bohrium.com` |
|
|
||||||
| `local` | `http://127.0.0.1:48197` |
|
|
||||||
| 不传(默认) | `https://uni-lab.bohrium.com` |
|
|
||||||
| 其他自定义 URL | 直接使用该 URL |
|
|
||||||
|
|
||||||
#### 必备项 ③:req_device_registry_upload.json(设备注册表)
|
|
||||||
|
|
||||||
数据文件由 `unilab` 启动时自动生成,需要定位它:
|
|
||||||
|
|
||||||
**推断 working_dir**(即 `unilabos_data` 所在目录):
|
|
||||||
|
|
||||||
| 条件 | working_dir 取值 |
|
|
||||||
|------|------------------|
|
|
||||||
| 传了 `--working_dir` | `<working_dir>/unilabos_data/`(若子目录已存在则直接用) |
|
|
||||||
| 仅传了 `--config` | `<config 文件所在目录>/unilabos_data/` |
|
|
||||||
| 都没传 | `<当前工作目录>/unilabos_data/` |
|
|
||||||
|
|
||||||
**按优先级搜索文件**:
|
|
||||||
|
|
||||||
```
|
|
||||||
<推断的 working_dir>/unilabos_data/req_device_registry_upload.json
|
|
||||||
<推断的 working_dir>/req_device_registry_upload.json
|
|
||||||
<workspace 根目录>/unilabos_data/req_device_registry_upload.json
|
|
||||||
```
|
|
||||||
|
|
||||||
也可以直接 Glob 搜索:`**/req_device_registry_upload.json`
|
|
||||||
|
|
||||||
找到后**必须检查文件修改时间**并告知用户:「找到注册表文件 `<路径>`,生成于 `<时间>`。请确认这是最近一次启动生成的。」超过 1 天提醒用户是否需要重新启动 `unilab`。
|
|
||||||
|
|
||||||
**如果文件不存在** → 告知用户先运行 `unilab` 启动命令,等日志出现 `注册表响应数据已保存` 后再执行本流程。**终止。**
|
|
||||||
|
|
||||||
#### 必备项 ④:目标设备
|
|
||||||
|
|
||||||
用户需要明确要为哪个设备创建 skill。可以是设备名称(如「PRCXI 移液站」)或 device_id(如 `liquid_handler.prcxi`)。
|
|
||||||
|
|
||||||
如果用户不确定,运行提取脚本列出所有设备供选择:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python ./scripts/extract_device_actions.py --registry <找到的文件路径>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 完整示例
|
|
||||||
|
|
||||||
用户提供:
|
|
||||||
|
|
||||||
```
|
|
||||||
--ak a1fd9d4e-xxxx-xxxx-xxxx-d9a69c09f0fd
|
|
||||||
--sk 136ff5c6-xxxx-xxxx-xxxx-a03e301f827b
|
|
||||||
--addr test
|
|
||||||
--port 8003
|
|
||||||
--disable_browser
|
|
||||||
```
|
|
||||||
|
|
||||||
从中提取:
|
|
||||||
- ✅ ak/sk → 运行 `gen_auth.py` 得到 `AUTH="Authorization: Lab YTFmZDlk..."`
|
|
||||||
- ✅ addr=test → `BASE=https://uni-lab.test.bohrium.com`
|
|
||||||
- ✅ 搜索 `unilabos_data/req_device_registry_upload.json` → 找到并确认时间
|
|
||||||
- ✅ 用户指明目标设备 → 如 `liquid_handler.prcxi`
|
|
||||||
|
|
||||||
**四项全部就绪后才进入 Step 1。**
|
|
||||||
|
|
||||||
### Step 1 — 列出可用设备
|
|
||||||
|
|
||||||
运行提取脚本,列出所有设备及 action 数量和 Python 源码路径,让用户选择:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 自动搜索(默认在 unilabos_data/ 和当前目录查找)
|
|
||||||
python ./scripts/extract_device_actions.py
|
|
||||||
|
|
||||||
# 指定注册表文件路径
|
|
||||||
python ./scripts/extract_device_actions.py --registry <path/to/req_device_registry_upload.json>
|
|
||||||
```
|
|
||||||
|
|
||||||
脚本输出包含每个设备的 **Python 源码路径**(从 `class.module` 转换),可用于后续阅读源码理解参数含义。
|
|
||||||
|
|
||||||
### Step 2 — 提取 Action Schema
|
|
||||||
|
|
||||||
用户选择设备后,运行提取脚本:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./skills/<skill-name>/actions/
|
|
||||||
```
|
|
||||||
|
|
||||||
脚本会显示设备的 Python 源码路径和类名,方便阅读源码了解参数含义。
|
|
||||||
|
|
||||||
每个 action 生成一个 JSON 文件,包含:
|
|
||||||
- `type` — 作为 API 调用的 `action_type`
|
|
||||||
- `schema` — 完整 JSON Schema(含 `properties.goal.properties` 参数定义)
|
|
||||||
- `goal` — goal 字段映射(含占位符 `$placeholder`)
|
|
||||||
- `goal_default` — 默认值
|
|
||||||
|
|
||||||
### Step 3 — 写 action-index.md
|
|
||||||
|
|
||||||
按模板为每个 action 写条目:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
### `<action_name>`
|
|
||||||
|
|
||||||
<用途描述(一句话)>
|
|
||||||
|
|
||||||
- **Schema**: [`actions/<filename>.json`](actions/<filename>.json)
|
|
||||||
- **核心参数**: `param1`, `param2`(从 schema.required 获取)
|
|
||||||
- **可选参数**: `param3`, `param4`
|
|
||||||
- **占位符字段**: `field`(需填入物料信息,值以 `$` 开头)
|
|
||||||
```
|
|
||||||
|
|
||||||
描述规则:
|
|
||||||
- 从 `schema.properties` 读参数列表(schema 已提升为 goal 内容)
|
|
||||||
- 从 `schema.required` 区分核心/可选参数
|
|
||||||
- 按功能分类(移液、枪头、外设等)
|
|
||||||
- 标注 `placeholder_keys` 中的字段类型:
|
|
||||||
- `unilabos_resources` → **ResourceSlot**,填入 `{id, name, uuid}`(id 是路径格式,从资源树取物料节点)
|
|
||||||
- `unilabos_devices` → **DeviceSlot**,填入路径字符串如 `"/host_node"`(从资源树筛选 type=device)
|
|
||||||
- `unilabos_nodes` → **NodeSlot**,填入路径字符串如 `"/PRCXI/PRCXI_Deck"`(资源树中任意节点)
|
|
||||||
- `unilabos_class` → **ClassSlot**,填入类名字符串如 `"container"`(从注册表查找)
|
|
||||||
- `unilabos_formulation` → **FormulationSlot**,填入配方数组 `[{well_name, liquids: [{name, volume}]}]`(well_name 为目标物料的 name)
|
|
||||||
- array 类型字段 → `[{id, name, uuid}, ...]`
|
|
||||||
- 特殊:`create_resource` 的 `res_id`(ResourceSlot)可填不存在的路径
|
|
||||||
|
|
||||||
### Step 4 — 写 SKILL.md
|
|
||||||
|
|
||||||
直接复用 `unilab-device-api` 的 API 模板,修改:
|
|
||||||
- 设备名称
|
|
||||||
- Action 数量
|
|
||||||
- 目录列表
|
|
||||||
- Session state 中的 `device_name`
|
|
||||||
- **AUTH 头** — 使用 Step 0 中 `gen_auth.py` 生成的 `Authorization: Lab <token>`(不要硬编码 `Api` 类型的 key)
|
|
||||||
- **Python 源码路径** — 在 SKILL.md 开头注明设备对应的源码文件,方便参考参数含义
|
|
||||||
- **Slot 字段表** — 列出本设备哪些 action 的哪些字段需要填入 Slot(物料/设备/节点/类名)
|
|
||||||
|
|
||||||
API 模板结构:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## 设备信息
|
|
||||||
- device_id, Python 源码路径, 设备类名
|
|
||||||
|
|
||||||
## 前置条件(缺一不可)
|
|
||||||
- ak/sk → AUTH, --addr → BASE URL
|
|
||||||
|
|
||||||
## 请求约定
|
|
||||||
- Windows 平台必须用 curl.exe(非 PowerShell 的 curl 别名)
|
|
||||||
|
|
||||||
## Session State
|
|
||||||
- lab_uuid(通过 GET /edge/lab/info 直接获取,不要问用户), device_name
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
# - #1 GET /edge/lab/info → 直接拿到 lab_uuid
|
|
||||||
# - #2 创建工作流 POST /lab/workflow/owner → 拼 URL 告知用户
|
|
||||||
# - #3 创建节点 POST /edge/workflow/node
|
|
||||||
# body: {workflow_uuid, resource_template_name: "<device_id>", node_template_name: "<action_name>"}
|
|
||||||
# - #4 删除节点 DELETE /lab/workflow/nodes
|
|
||||||
# - #5 更新节点参数 PATCH /lab/workflow/node
|
|
||||||
# - #6 查询节点 handles POST /lab/workflow/node-handles
|
|
||||||
# body: {node_uuids: ["uuid1","uuid2"]} → 返回各节点的 handle_uuid
|
|
||||||
# - #7 批量创建边 POST /lab/workflow/edges
|
|
||||||
# body: {edges: [{source_node_uuid, target_node_uuid, source_handle_uuid, target_handle_uuid}]}
|
|
||||||
# - #8 启动工作流 POST /lab/workflow/{uuid}/run
|
|
||||||
# - #9 运行设备单动作 POST /lab/mcp/run/action
|
|
||||||
# - #10 查询任务状态 GET /lab/mcp/task/{task_uuid}
|
|
||||||
# - #11 运行工作流单节点 POST /lab/mcp/run/workflow/action
|
|
||||||
# - #12 获取资源树 GET /lab/material/download/{lab_uuid}
|
|
||||||
# - #13 获取工作流模板详情 GET /lab/workflow/template/detail/{workflow_uuid}
|
|
||||||
# 返回 workflow 完整结构:data.nodes[] 含每个节点的 uuid、name、param、device_name、handles
|
|
||||||
|
|
||||||
## Placeholder Slot 填写规则
|
|
||||||
- unilabos_resources → ResourceSlot → {"id":"/path/name","name":"name","uuid":"xxx"}
|
|
||||||
- unilabos_devices → DeviceSlot → "/parent/device" 路径字符串
|
|
||||||
- unilabos_nodes → NodeSlot → "/parent/node" 路径字符串
|
|
||||||
- unilabos_class → ClassSlot → "class_name" 字符串
|
|
||||||
- unilabos_formulation → FormulationSlot → [{well_name, liquids: [{name, volume}]}] 配方数组
|
|
||||||
- 特例:create_resource 的 res_id 允许填不存在的路径
|
|
||||||
- 列出本设备所有 Slot 字段、类型及含义
|
|
||||||
|
|
||||||
## 渐进加载策略
|
|
||||||
## 完整工作流 Checklist
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 5 — 验证
|
|
||||||
|
|
||||||
检查文件完整性:
|
|
||||||
- [ ] `SKILL.md` 包含 API endpoint(#1 获取 lab_uuid、#2-#7 工作流/节点/边、#8-#11 运行/查询、#12 资源树、#13 工作流模板详情)
|
|
||||||
- [ ] `SKILL.md` 包含 Placeholder Slot 填写规则(ResourceSlot / DeviceSlot / NodeSlot / ClassSlot / FormulationSlot + create_resource 特例)和本设备的 Slot 字段表
|
|
||||||
- [ ] `action-index.md` 列出所有 action 并有描述
|
|
||||||
- [ ] `actions/` 目录中每个 action 有对应 JSON 文件
|
|
||||||
- [ ] JSON 文件包含 `type`, `schema`(已提升为 goal 内容), `goal`, `goal_default`, `placeholder_keys` 字段
|
|
||||||
- [ ] 描述能让 agent 判断该用哪个 action
|
|
||||||
|
|
||||||
## Action JSON 文件结构
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "LiquidHandlerTransfer", // → API 的 action_type
|
|
||||||
"goal": { // goal 字段映射
|
|
||||||
"sources": "sources",
|
|
||||||
"targets": "targets",
|
|
||||||
"tip_racks": "tip_racks",
|
|
||||||
"asp_vols": "asp_vols"
|
|
||||||
},
|
|
||||||
"schema": { // ← 直接是 goal 的 schema(已提升)
|
|
||||||
"type": "object",
|
|
||||||
"properties": { // 参数定义(即请求中 goal 的字段)
|
|
||||||
"sources": { "type": "array", "items": { "type": "object" } },
|
|
||||||
"targets": { "type": "array", "items": { "type": "object" } },
|
|
||||||
"asp_vols": { "type": "array", "items": { "type": "number" } }
|
|
||||||
},
|
|
||||||
"required": [...],
|
|
||||||
"_unilabos_placeholder_info": { // ← Slot 类型标记
|
|
||||||
"sources": "unilabos_resources",
|
|
||||||
"targets": "unilabos_resources",
|
|
||||||
"tip_racks": "unilabos_resources"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"goal_default": { ... }, // 默认值
|
|
||||||
"placeholder_keys": { // ← 汇总所有 Slot 字段
|
|
||||||
"sources": "unilabos_resources", // ResourceSlot
|
|
||||||
"targets": "unilabos_resources",
|
|
||||||
"tip_racks": "unilabos_resources",
|
|
||||||
"target_device_id": "unilabos_devices" // DeviceSlot
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> **注意**:`schema` 已由脚本从原始 `schema.properties.goal` 提升为顶层,直接包含参数定义。
|
|
||||||
> `schema.properties` 中的字段即为 API 创建节点返回的 `data.param` 中的字段,PATCH 更新时直接修改 `param` 即可。
|
|
||||||
|
|
||||||
## Placeholder Slot 类型体系
|
|
||||||
|
|
||||||
`placeholder_keys` / `_unilabos_placeholder_info` 中有 5 种值,对应不同的填写方式:
|
|
||||||
|
|
||||||
| placeholder 值 | Slot 类型 | 填写格式 | 选取范围 |
|
|
||||||
|---------------|-----------|---------|---------|
|
|
||||||
| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅**物料**节点(不含设备) |
|
|
||||||
| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅**设备**节点(type=device),路径字符串 |
|
|
||||||
| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | **设备 + 物料**,即所有节点,路径字符串 |
|
|
||||||
| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已上报的资源类 name |
|
|
||||||
| `unilabos_formulation` | FormulationSlot | `[{well_name, liquids: [{name, volume}]}]` | 资源树中物料节点的 **name**,配合液体配方 |
|
|
||||||
|
|
||||||
### ResourceSlot(`unilabos_resources`)
|
|
||||||
|
|
||||||
最常见的类型。从资源树中选取**物料**节点(孔板、枪头盒、试剂槽等):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"id": "/workstation/container1", "name": "container1", "uuid": "ff149a9a-2cb8-419d-8db5-d3ba056fb3c2"}
|
|
||||||
```
|
|
||||||
|
|
||||||
- 单个(schema type=object):`{"id": "/path/name", "name": "name", "uuid": "xxx"}`
|
|
||||||
- 数组(schema type=array):`[{"id": "/path/a", "name": "a", "uuid": "xxx"}, ...]`
|
|
||||||
- `id` 本身是从 parent 计算的路径格式
|
|
||||||
- 根据 action 语义选择正确的物料(如 `sources` = 液体来源,`targets` = 目标位置)
|
|
||||||
|
|
||||||
> **特例**:`create_resource` 的 `res_id` 字段,目标物料可能**尚不存在**,此时直接填写期望的路径(如 `"/workstation/container1"`),不需要 uuid。
|
|
||||||
|
|
||||||
### DeviceSlot(`unilabos_devices`)
|
|
||||||
|
|
||||||
填写**设备路径字符串**。从资源树中筛选 type=device 的节点,从 parent 计算路径:
|
|
||||||
|
|
||||||
```
|
|
||||||
"/host_node"
|
|
||||||
"/bioyond_cell/reaction_station"
|
|
||||||
```
|
|
||||||
|
|
||||||
- 只填路径字符串,不需要 `{id, uuid}` 对象
|
|
||||||
- 根据 action 语义选择正确的设备(如 `target_device_id` = 目标设备)
|
|
||||||
|
|
||||||
### NodeSlot(`unilabos_nodes`)
|
|
||||||
|
|
||||||
范围 = 设备 + 物料。即资源树中**所有节点**都可以选,填写**路径字符串**:
|
|
||||||
|
|
||||||
```
|
|
||||||
"/PRCXI/PRCXI_Deck"
|
|
||||||
```
|
|
||||||
|
|
||||||
- 使用场景:当参数既可能指向物料也可能指向设备时(如 `PumpTransferProtocol` 的 `from_vessel`/`to_vessel`,`create_resource` 的 `parent`)
|
|
||||||
|
|
||||||
### ClassSlot(`unilabos_class`)
|
|
||||||
|
|
||||||
填写注册表中已上报的**资源类 name**。从本地 `req_resource_registry_upload.json` 中查找:
|
|
||||||
|
|
||||||
```
|
|
||||||
"container"
|
|
||||||
```
|
|
||||||
|
|
||||||
### FormulationSlot(`unilabos_formulation`)
|
|
||||||
|
|
||||||
描述**液体配方**:向哪些物料容器中加入哪些液体及体积。填写为**对象数组**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"sample_uuid": "",
|
|
||||||
"well_name": "YB_PrepBottle_15mL_Carrier_bottle_A1",
|
|
||||||
"liquids": [
|
|
||||||
{ "name": "LiPF6", "volume": 0.6 },
|
|
||||||
{ "name": "DMC", "volume": 1.2 }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 字段说明
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| `sample_uuid` | string | 样品 UUID,无样品时传空字符串 `""` |
|
|
||||||
| `well_name` | string | 目标物料容器的 **name**(从资源树中取物料节点的 `name` 字段,如瓶子、孔位名称) |
|
|
||||||
| `liquids` | array | 要加入的液体列表 |
|
|
||||||
| `liquids[].name` | string | 液体名称(如试剂名、溶剂名) |
|
|
||||||
| `liquids[].volume` | number | 液体体积(单位由设备决定,通常为 mL) |
|
|
||||||
|
|
||||||
#### 填写规则
|
|
||||||
|
|
||||||
- `well_name` 必须是资源树中已存在的物料节点 `name`(不是 `id` 路径),通过 API #12 获取资源树后筛选
|
|
||||||
- 每个数组元素代表一个目标容器的配方
|
|
||||||
- 一个容器可以加入多种液体(`liquids` 数组多条记录)
|
|
||||||
- 与 ResourceSlot 的区别:ResourceSlot 填 `{id, name, uuid}` 指向物料本身;FormulationSlot 用 `well_name` 引用物料,并附带液体配方信息
|
|
||||||
|
|
||||||
### 通过 API #12 获取资源树
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
|
|
||||||
```
|
|
||||||
|
|
||||||
注意 `lab_uuid` 在路径中(不是查询参数)。资源树返回所有节点,每个节点包含 `id`(路径格式)、`name`、`uuid`、`type`、`parent` 等字段。填写 Slot 时需根据 placeholder 类型筛选正确的节点。
|
|
||||||
|
|
||||||
## 最终目录结构
|
|
||||||
|
|
||||||
```
|
|
||||||
./<skill-name>/
|
|
||||||
├── SKILL.md # API 端点 + 渐进加载指引
|
|
||||||
├── action-index.md # 动作索引:描述/用途/核心参数
|
|
||||||
└── actions/ # 每个 action 的完整 JSON Schema
|
|
||||||
├── action1.json
|
|
||||||
├── action2.json
|
|
||||||
└── ...
|
|
||||||
```
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
从 req_device_registry_upload.json 中提取指定设备的 action schema。
|
|
||||||
|
|
||||||
用法:
|
|
||||||
# 列出所有设备及 action 数量(自动搜索注册表文件)
|
|
||||||
python extract_device_actions.py
|
|
||||||
|
|
||||||
# 指定注册表文件路径
|
|
||||||
python extract_device_actions.py --registry <path/to/req_device_registry_upload.json>
|
|
||||||
|
|
||||||
# 提取指定设备的 action 到目录
|
|
||||||
python extract_device_actions.py <device_id> <output_dir>
|
|
||||||
python extract_device_actions.py --registry <path> <device_id> <output_dir>
|
|
||||||
|
|
||||||
示例:
|
|
||||||
python extract_device_actions.py --registry unilabos_data/req_device_registry_upload.json
|
|
||||||
python extract_device_actions.py liquid_handler.prcxi .cursor/skills/unilab-device-api/actions/
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
REGISTRY_FILENAME = "req_device_registry_upload.json"
|
|
||||||
|
|
||||||
def find_registry(explicit_path=None):
|
|
||||||
"""
|
|
||||||
查找 req_device_registry_upload.json 文件。
|
|
||||||
|
|
||||||
搜索优先级:
|
|
||||||
1. 用户通过 --registry 显式指定的路径
|
|
||||||
2. <cwd>/unilabos_data/req_device_registry_upload.json
|
|
||||||
3. <cwd>/req_device_registry_upload.json
|
|
||||||
4. <script所在目录>/../../.. (workspace根) 下的 unilabos_data/
|
|
||||||
5. 向上逐级搜索父目录(最多 5 层)
|
|
||||||
"""
|
|
||||||
if explicit_path:
|
|
||||||
if os.path.isfile(explicit_path):
|
|
||||||
return explicit_path
|
|
||||||
if os.path.isdir(explicit_path):
|
|
||||||
fp = os.path.join(explicit_path, REGISTRY_FILENAME)
|
|
||||||
if os.path.isfile(fp):
|
|
||||||
return fp
|
|
||||||
print(f"警告: 指定的路径不存在: {explicit_path}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
candidates = [
|
|
||||||
os.path.join("unilabos_data", REGISTRY_FILENAME),
|
|
||||||
REGISTRY_FILENAME,
|
|
||||||
]
|
|
||||||
|
|
||||||
for c in candidates:
|
|
||||||
if os.path.isfile(c):
|
|
||||||
return c
|
|
||||||
|
|
||||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
workspace_root = os.path.normpath(os.path.join(script_dir, "..", "..", ".."))
|
|
||||||
for c in candidates:
|
|
||||||
path = os.path.join(workspace_root, c)
|
|
||||||
if os.path.isfile(path):
|
|
||||||
return path
|
|
||||||
|
|
||||||
cwd = os.getcwd()
|
|
||||||
for _ in range(5):
|
|
||||||
parent = os.path.dirname(cwd)
|
|
||||||
if parent == cwd:
|
|
||||||
break
|
|
||||||
cwd = parent
|
|
||||||
for c in candidates:
|
|
||||||
path = os.path.join(cwd, c)
|
|
||||||
if os.path.isfile(path):
|
|
||||||
return path
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def load_registry(path):
|
|
||||||
with open(path, 'r', encoding='utf-8') as f:
|
|
||||||
return json.load(f)
|
|
||||||
|
|
||||||
def list_devices(data):
|
|
||||||
"""列出所有包含 action_value_mappings 的设备,同时返回 module 路径"""
|
|
||||||
resources = data.get('resources', [])
|
|
||||||
devices = []
|
|
||||||
for res in resources:
|
|
||||||
rid = res.get('id', '')
|
|
||||||
cls = res.get('class', {})
|
|
||||||
avm = cls.get('action_value_mappings', {})
|
|
||||||
module = cls.get('module', '')
|
|
||||||
if avm:
|
|
||||||
devices.append((rid, len(avm), module))
|
|
||||||
return devices
|
|
||||||
|
|
||||||
def flatten_schema_to_goal(action_data):
|
|
||||||
"""将 schema 中嵌套的 goal 内容提升为顶层 schema,去掉 feedback/result 包装"""
|
|
||||||
schema = action_data.get('schema', {})
|
|
||||||
goal_schema = schema.get('properties', {}).get('goal', {})
|
|
||||||
if goal_schema:
|
|
||||||
action_data = dict(action_data)
|
|
||||||
action_data['schema'] = goal_schema
|
|
||||||
return action_data
|
|
||||||
|
|
||||||
|
|
||||||
def extract_actions(data, device_id, output_dir):
|
|
||||||
"""提取指定设备的 action schema 到独立 JSON 文件"""
|
|
||||||
resources = data.get('resources', [])
|
|
||||||
for res in resources:
|
|
||||||
if res.get('id') == device_id:
|
|
||||||
cls = res.get('class', {})
|
|
||||||
module = cls.get('module', '')
|
|
||||||
avm = cls.get('action_value_mappings', {})
|
|
||||||
if not avm:
|
|
||||||
print(f"设备 {device_id} 没有 action_value_mappings")
|
|
||||||
return []
|
|
||||||
|
|
||||||
if module:
|
|
||||||
py_path = module.split(":")[0].replace(".", "/") + ".py"
|
|
||||||
class_name = module.split(":")[-1] if ":" in module else ""
|
|
||||||
print(f"Python 源码: {py_path}")
|
|
||||||
if class_name:
|
|
||||||
print(f"设备类: {class_name}")
|
|
||||||
|
|
||||||
os.makedirs(output_dir, exist_ok=True)
|
|
||||||
written = []
|
|
||||||
for action_name in sorted(avm.keys()):
|
|
||||||
action_data = flatten_schema_to_goal(avm[action_name])
|
|
||||||
filename = action_name.replace('-', '_') + '.json'
|
|
||||||
filepath = os.path.join(output_dir, filename)
|
|
||||||
with open(filepath, 'w', encoding='utf-8') as f:
|
|
||||||
json.dump(action_data, f, indent=2, ensure_ascii=False)
|
|
||||||
written.append(filename)
|
|
||||||
print(f" {filepath}")
|
|
||||||
return written
|
|
||||||
|
|
||||||
print(f"设备 {device_id} 未找到")
|
|
||||||
return []
|
|
||||||
|
|
||||||
def main():
|
|
||||||
args = sys.argv[1:]
|
|
||||||
explicit_registry = None
|
|
||||||
|
|
||||||
if "--registry" in args:
|
|
||||||
idx = args.index("--registry")
|
|
||||||
if idx + 1 < len(args):
|
|
||||||
explicit_registry = args[idx + 1]
|
|
||||||
args = args[:idx] + args[idx + 2:]
|
|
||||||
else:
|
|
||||||
print("错误: --registry 需要指定路径")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
registry_path = find_registry(explicit_registry)
|
|
||||||
if not registry_path:
|
|
||||||
print(f"错误: 找不到 {REGISTRY_FILENAME}")
|
|
||||||
print()
|
|
||||||
print("解决方法:")
|
|
||||||
print(" 1. 先运行 unilab 启动命令,等待注册表生成")
|
|
||||||
print(" 2. 用 --registry 指定文件路径:")
|
|
||||||
print(f" python {sys.argv[0]} --registry <path/to/{REGISTRY_FILENAME}>")
|
|
||||||
print()
|
|
||||||
print("搜索过的路径:")
|
|
||||||
for p in [
|
|
||||||
os.path.join("unilabos_data", REGISTRY_FILENAME),
|
|
||||||
REGISTRY_FILENAME,
|
|
||||||
os.path.join("<workspace_root>", "unilabos_data", REGISTRY_FILENAME),
|
|
||||||
]:
|
|
||||||
print(f" - {p}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print(f"注册表: {registry_path}")
|
|
||||||
mtime = os.path.getmtime(registry_path)
|
|
||||||
gen_time = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
size_mb = os.path.getsize(registry_path) / (1024 * 1024)
|
|
||||||
print(f"生成时间: {gen_time} (文件大小: {size_mb:.1f} MB)")
|
|
||||||
data = load_registry(registry_path)
|
|
||||||
|
|
||||||
if len(args) == 0:
|
|
||||||
devices = list_devices(data)
|
|
||||||
print(f"\n找到 {len(devices)} 个设备:")
|
|
||||||
print(f"{'设备 ID':<50} {'Actions':>7} {'Python 模块'}")
|
|
||||||
print("-" * 120)
|
|
||||||
for did, count, module in sorted(devices, key=lambda x: x[0]):
|
|
||||||
py_path = module.split(":")[0].replace(".", "/") + ".py" if module else ""
|
|
||||||
print(f"{did:<50} {count:>7} {py_path}")
|
|
||||||
|
|
||||||
elif len(args) == 2:
|
|
||||||
device_id = args[0]
|
|
||||||
output_dir = args[1]
|
|
||||||
print(f"\n提取 {device_id} 的 actions 到 {output_dir}/")
|
|
||||||
written = extract_actions(data, device_id, output_dir)
|
|
||||||
if written:
|
|
||||||
print(f"\n共写入 {len(written)} 个 action 文件")
|
|
||||||
|
|
||||||
else:
|
|
||||||
print("用法:")
|
|
||||||
print(" python extract_device_actions.py [--registry <path>] # 列出设备")
|
|
||||||
print(" python extract_device_actions.py [--registry <path>] <device_id> <dir> # 提取 actions")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
从 ak/sk 生成 UniLab API Authorization header。
|
|
||||||
|
|
||||||
算法: base64(ak:sk) → "Authorization: Lab <token>"
|
|
||||||
|
|
||||||
用法:
|
|
||||||
python gen_auth.py <ak> <sk>
|
|
||||||
python gen_auth.py --config <config.py>
|
|
||||||
|
|
||||||
示例:
|
|
||||||
python gen_auth.py myak mysk
|
|
||||||
python gen_auth.py --config experiments/config.py
|
|
||||||
"""
|
|
||||||
import base64
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
|
|
||||||
|
|
||||||
def gen_auth(ak: str, sk: str) -> str:
|
|
||||||
token = base64.b64encode(f"{ak}:{sk}".encode("utf-8")).decode("utf-8")
|
|
||||||
return token
|
|
||||||
|
|
||||||
|
|
||||||
def extract_from_config(config_path: str) -> tuple:
|
|
||||||
"""从 config.py 中提取 ak 和 sk"""
|
|
||||||
with open(config_path, "r", encoding="utf-8") as f:
|
|
||||||
content = f.read()
|
|
||||||
ak_match = re.search(r'''ak\s*=\s*["']([^"']+)["']''', content)
|
|
||||||
sk_match = re.search(r'''sk\s*=\s*["']([^"']+)["']''', content)
|
|
||||||
if not ak_match or not sk_match:
|
|
||||||
return None, None
|
|
||||||
return ak_match.group(1), sk_match.group(1)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
args = sys.argv[1:]
|
|
||||||
|
|
||||||
if len(args) == 2 and args[0] == "--config":
|
|
||||||
ak, sk = extract_from_config(args[1])
|
|
||||||
if not ak or not sk:
|
|
||||||
print(f"错误: 在 {args[1]} 中未找到 ak/sk 配置")
|
|
||||||
print("期望格式: ak = \"xxx\" sk = \"xxx\"")
|
|
||||||
sys.exit(1)
|
|
||||||
print(f"配置文件: {args[1]}")
|
|
||||||
elif len(args) == 2:
|
|
||||||
ak, sk = args
|
|
||||||
else:
|
|
||||||
print("用法:")
|
|
||||||
print(" python gen_auth.py <ak> <sk>")
|
|
||||||
print(" python gen_auth.py --config <config.py>")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
token = gen_auth(ak, sk)
|
|
||||||
print(f"ak: {ak}")
|
|
||||||
print(f"sk: {sk}")
|
|
||||||
print()
|
|
||||||
print(f"Authorization header:")
|
|
||||||
print(f" Authorization: Lab {token}")
|
|
||||||
print()
|
|
||||||
print(f"curl 用法:")
|
|
||||||
print(f' curl -H "Authorization: Lab {token}" ...')
|
|
||||||
print()
|
|
||||||
print(f"Shell 变量:")
|
|
||||||
print(f' AUTH="Authorization: Lab {token}"')
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,275 +0,0 @@
|
|||||||
---
|
|
||||||
name: submit-agent-result
|
|
||||||
description: Submit historical experiment results (agent_result) to Uni-Lab notebook — read data files, assemble JSON payload, PUT to cloud API. Use when the user wants to submit experiment results, upload agent results, report experiment data, or mentions agent_result/实验结果/历史记录/notebook结果.
|
|
||||||
---
|
|
||||||
|
|
||||||
# 提交历史实验记录指南
|
|
||||||
|
|
||||||
通过云端 API 向已创建的 notebook 提交实验结果数据(agent_result)。支持从 JSON / CSV 文件读取数据,整合后提交。
|
|
||||||
|
|
||||||
## 前置条件(缺一不可)
|
|
||||||
|
|
||||||
使用本指南前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
|
|
||||||
|
|
||||||
### 1. ak / sk → AUTH
|
|
||||||
|
|
||||||
询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。
|
|
||||||
|
|
||||||
生成 AUTH token:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -c "import base64,sys; print(base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
|
|
||||||
```
|
|
||||||
|
|
||||||
输出即为 token 值,拼接为 `Authorization: Lab <token>`。
|
|
||||||
|
|
||||||
### 2. --addr → BASE URL
|
|
||||||
|
|
||||||
| `--addr` 值 | BASE |
|
|
||||||
|-------------|------|
|
|
||||||
| `test` | `https://uni-lab.test.bohrium.com` |
|
|
||||||
| `uat` | `https://uni-lab.uat.bohrium.com` |
|
|
||||||
| `local` | `http://127.0.0.1:48197` |
|
|
||||||
| 不传(默认) | `https://uni-lab.bohrium.com` |
|
|
||||||
|
|
||||||
确认后设置:
|
|
||||||
```bash
|
|
||||||
BASE="<根据 addr 确定的 URL>"
|
|
||||||
AUTH="Authorization: Lab <上面命令输出的 token>"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. notebook_uuid(**必须询问用户**)
|
|
||||||
|
|
||||||
**必须主动询问用户**:「请提供要提交结果的 notebook UUID。」
|
|
||||||
|
|
||||||
notebook_uuid 来自之前通过「批量提交实验」创建的实验批次,即 `POST /api/v1/lab/notebook` 返回的 `data.uuid`。
|
|
||||||
|
|
||||||
如果用户不记得,可提示:
|
|
||||||
- 查看之前的对话记录中创建 notebook 时返回的 UUID
|
|
||||||
- 或通过平台页面查找对应的 notebook
|
|
||||||
|
|
||||||
**绝不能跳过此步骤,没有 notebook_uuid 无法提交。**
|
|
||||||
|
|
||||||
### 4. 实验结果数据
|
|
||||||
|
|
||||||
用户需要提供实验结果数据,支持以下方式:
|
|
||||||
|
|
||||||
| 方式 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| JSON 文件 | 直接作为 `agent_result` 的内容合并 |
|
|
||||||
| CSV 文件 | 转为 `{"文件名": [行数据...]}` 格式 |
|
|
||||||
| 手动指定 | 用户直接告知 key-value 数据,由 agent 构建 JSON |
|
|
||||||
|
|
||||||
**四项全部就绪后才可开始。**
|
|
||||||
|
|
||||||
## Session State
|
|
||||||
|
|
||||||
在整个对话过程中,agent 需要记住以下状态:
|
|
||||||
|
|
||||||
- `lab_uuid` — 实验室 UUID(通过 API #1 自动获取,**不需要问用户**)
|
|
||||||
- `notebook_uuid` — 目标 notebook UUID(**必须询问用户**)
|
|
||||||
|
|
||||||
## 请求约定
|
|
||||||
|
|
||||||
所有请求使用 `curl -s`,PUT 需加 `Content-Type: application/json`。
|
|
||||||
|
|
||||||
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名),示例中的 `curl` 均指 `curl.exe`。
|
|
||||||
>
|
|
||||||
> **PowerShell JSON 传参**:PowerShell 中 `-d '{"key":"value"}'` 会因引号转义失败。请将 JSON 写入临时文件,用 `-d '@tmp_body.json'`(单引号包裹 `@`,否则 `@` 会被 PowerShell 解析为 splatting 运算符导致报错)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
### 1. 获取实验室信息(自动获取 lab_uuid)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
|
||||||
```
|
|
||||||
|
|
||||||
返回:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}}
|
|
||||||
```
|
|
||||||
|
|
||||||
记住 `data.uuid` 为 `lab_uuid`。
|
|
||||||
|
|
||||||
### 2. 提交实验结果(agent_result)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -X PUT "$BASE/api/v1/lab/notebook/agent-result" \
|
|
||||||
-H "$AUTH" -H "Content-Type: application/json" \
|
|
||||||
-d '<request_body>'
|
|
||||||
```
|
|
||||||
|
|
||||||
请求体结构:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"notebook_uuid": "<notebook_uuid>",
|
|
||||||
"agent_result": {
|
|
||||||
"<key1>": "<value1>",
|
|
||||||
"<key2>": 123,
|
|
||||||
"<nested_key>": {"a": 1, "b": 2},
|
|
||||||
"<array_key>": [{"col1": "v1", "col2": "v2"}, ...]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> **注意**:HTTP 方法是 **PUT**(不是 POST)。
|
|
||||||
|
|
||||||
#### 必要字段
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| `notebook_uuid` | string (UUID) | 目标 notebook 的 UUID,从批量提交实验时获取 |
|
|
||||||
| `agent_result` | object | 实验结果数据,任意 JSON 对象 |
|
|
||||||
|
|
||||||
#### agent_result 内容格式
|
|
||||||
|
|
||||||
`agent_result` 接受**任意 JSON 对象**,常见格式:
|
|
||||||
|
|
||||||
**简单键值对**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"avg_rtt_ms": 12.5,
|
|
||||||
"status": "success",
|
|
||||||
"test_count": 5
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**包含嵌套结构**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"summary": {"total": 100, "passed": 98, "failed": 2},
|
|
||||||
"measurements": [
|
|
||||||
{"sample_id": "S001", "value": 3.14, "unit": "mg/mL"},
|
|
||||||
{"sample_id": "S002", "value": 2.71, "unit": "mg/mL"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**从 CSV 文件导入**(脚本自动转换):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"experiment_data": [
|
|
||||||
{"温度": 25, "压力": 101.3, "产率": 0.85},
|
|
||||||
{"温度": 30, "压力": 101.3, "产率": 0.91}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 整合脚本
|
|
||||||
|
|
||||||
本文档同级目录下的 `scripts/prepare_agent_result.py` 可自动读取文件并构建请求体。
|
|
||||||
|
|
||||||
### 用法
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python scripts/prepare_agent_result.py \
|
|
||||||
--notebook-uuid <uuid> \
|
|
||||||
--files data1.json data2.csv \
|
|
||||||
[--auth <token>] \
|
|
||||||
[--base <BASE_URL>] \
|
|
||||||
[--submit] \
|
|
||||||
[--output <output.json>]
|
|
||||||
```
|
|
||||||
|
|
||||||
| 参数 | 必选 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| `--notebook-uuid` | 是 | 目标 notebook UUID |
|
|
||||||
| `--files` | 是 | 输入文件路径(支持多个,JSON / CSV) |
|
|
||||||
| `--auth` | 提交时必选 | Lab token(base64(ak:sk)) |
|
|
||||||
| `--base` | 提交时必选 | API base URL |
|
|
||||||
| `--submit` | 否 | 加上此标志则直接提交到云端 |
|
|
||||||
| `--output` | 否 | 输出 JSON 路径(默认 `agent_result_body.json`) |
|
|
||||||
|
|
||||||
### 文件合并规则
|
|
||||||
|
|
||||||
| 文件类型 | 合并方式 |
|
|
||||||
|----------|----------|
|
|
||||||
| `.json`(dict) | 字段直接合并到 `agent_result` 顶层 |
|
|
||||||
| `.json`(list/other) | 以文件名为 key 放入 `agent_result` |
|
|
||||||
| `.csv` | 以文件名(不含扩展名)为 key,值为行对象数组 |
|
|
||||||
|
|
||||||
多个文件的字段会合并。JSON dict 中的重复 key 后者覆盖前者。
|
|
||||||
|
|
||||||
### 示例
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 仅生成请求体文件(不提交)
|
|
||||||
python scripts/prepare_agent_result.py \
|
|
||||||
--notebook-uuid 73c67dca-c8cc-4936-85a0-329106aa7cca \
|
|
||||||
--files results.json measurements.csv
|
|
||||||
|
|
||||||
# 生成并直接提交
|
|
||||||
python scripts/prepare_agent_result.py \
|
|
||||||
--notebook-uuid 73c67dca-c8cc-4936-85a0-329106aa7cca \
|
|
||||||
--files results.json \
|
|
||||||
--auth YTFmZDlkNGUt... \
|
|
||||||
--base https://uni-lab.test.bohrium.com \
|
|
||||||
--submit
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 手动构建方式
|
|
||||||
|
|
||||||
如果不使用脚本,也可手动构建请求体:
|
|
||||||
|
|
||||||
1. 将实验结果数据组装为 JSON 对象
|
|
||||||
2. 写入临时文件:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"notebook_uuid": "<uuid>",
|
|
||||||
"agent_result": { ... }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. 用 curl 提交:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -X PUT "$BASE/api/v1/lab/notebook/agent-result" \
|
|
||||||
-H "$AUTH" -H "Content-Type: application/json" \
|
|
||||||
-d '@tmp_body.json'
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 完整工作流 Checklist
|
|
||||||
|
|
||||||
```
|
|
||||||
Task Progress:
|
|
||||||
- [ ] Step 1: 确认 ak/sk → 生成 AUTH token
|
|
||||||
- [ ] Step 2: 确认 --addr → 设置 BASE URL
|
|
||||||
- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid
|
|
||||||
- [ ] Step 4: **询问用户** notebook_uuid(必须,不可跳过)
|
|
||||||
- [ ] Step 5: 确认实验结果数据来源(文件路径或手动数据)
|
|
||||||
- [ ] Step 6: 运行 prepare_agent_result.py 或手动构建请求体
|
|
||||||
- [ ] Step 7: PUT /lab/notebook/agent-result 提交
|
|
||||||
- [ ] Step 8: 检查返回结果,确认提交成功
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
### Q: notebook_uuid 从哪里获取?
|
|
||||||
|
|
||||||
从之前「批量提交实验」时 `POST /api/v1/lab/notebook` 的返回值 `data.uuid` 获取。也可以在平台 UI 中查找对应的 notebook。
|
|
||||||
|
|
||||||
### Q: agent_result 有固定的 schema 吗?
|
|
||||||
|
|
||||||
没有严格 schema,接受任意 JSON 对象。但建议包含有意义的字段名和结构化数据,方便后续分析。
|
|
||||||
|
|
||||||
### Q: 可以多次提交同一个 notebook 的结果吗?
|
|
||||||
|
|
||||||
可以,后续提交会覆盖之前的 agent_result。
|
|
||||||
|
|
||||||
### Q: 认证方式是 Lab 还是 Api?
|
|
||||||
|
|
||||||
本指南统一使用 `Authorization: Lab <base64(ak:sk)>` 方式。如果用户有独立的 API Key,也可用 `Authorization: Api <key>` 替代。
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
"""
|
|
||||||
读取实验结果文件(JSON / CSV),整合为 agent_result 请求体并可选提交。
|
|
||||||
|
|
||||||
用法:
|
|
||||||
python prepare_agent_result.py \
|
|
||||||
--notebook-uuid <uuid> \
|
|
||||||
--files data1.json data2.csv \
|
|
||||||
[--auth <Lab token>] \
|
|
||||||
[--base <BASE_URL>] \
|
|
||||||
[--submit] \
|
|
||||||
[--output <output.json>]
|
|
||||||
|
|
||||||
支持的输入文件格式:
|
|
||||||
- .json → 直接作为 dict 合并
|
|
||||||
- .csv → 转为 {"filename": [row_dict, ...]} 格式
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import base64
|
|
||||||
import csv
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict, List
|
|
||||||
|
|
||||||
|
|
||||||
def read_json_file(filepath: str) -> Dict[str, Any]:
|
|
||||||
with open(filepath, "r", encoding="utf-8") as f:
|
|
||||||
return json.load(f)
|
|
||||||
|
|
||||||
|
|
||||||
def read_csv_file(filepath: str) -> List[Dict[str, Any]]:
|
|
||||||
rows = []
|
|
||||||
with open(filepath, "r", encoding="utf-8-sig") as f:
|
|
||||||
reader = csv.DictReader(f)
|
|
||||||
for row in reader:
|
|
||||||
converted = {}
|
|
||||||
for k, v in row.items():
|
|
||||||
try:
|
|
||||||
converted[k] = int(v)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
try:
|
|
||||||
converted[k] = float(v)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
converted[k] = v
|
|
||||||
rows.append(converted)
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def merge_files(filepaths: List[str]) -> Dict[str, Any]:
|
|
||||||
"""将多个文件合并为一个 agent_result dict"""
|
|
||||||
merged: Dict[str, Any] = {}
|
|
||||||
for fp in filepaths:
|
|
||||||
path = Path(fp)
|
|
||||||
ext = path.suffix.lower()
|
|
||||||
key = path.stem
|
|
||||||
|
|
||||||
if ext == ".json":
|
|
||||||
data = read_json_file(fp)
|
|
||||||
if isinstance(data, dict):
|
|
||||||
merged.update(data)
|
|
||||||
else:
|
|
||||||
merged[key] = data
|
|
||||||
elif ext == ".csv":
|
|
||||||
merged[key] = read_csv_file(fp)
|
|
||||||
else:
|
|
||||||
print(f"[警告] 不支持的文件格式: {fp},跳过", file=sys.stderr)
|
|
||||||
|
|
||||||
return merged
|
|
||||||
|
|
||||||
|
|
||||||
def build_request_body(notebook_uuid: str, agent_result: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"notebook_uuid": notebook_uuid,
|
|
||||||
"agent_result": agent_result,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def submit(base: str, auth: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
try:
|
|
||||||
import requests
|
|
||||||
except ImportError:
|
|
||||||
print("[错误] 提交需要 requests 库: pip install requests", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
url = f"{base}/api/v1/lab/notebook/agent-result"
|
|
||||||
headers = {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": f"Lab {auth}",
|
|
||||||
}
|
|
||||||
resp = requests.put(url, json=body, headers=headers, timeout=30)
|
|
||||||
return {"status_code": resp.status_code, "body": resp.json() if resp.headers.get("content-type", "").startswith("application/json") else resp.text}
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(description="整合实验结果文件并构建 agent_result 请求体")
|
|
||||||
parser.add_argument("--notebook-uuid", required=True, help="目标 notebook UUID")
|
|
||||||
parser.add_argument("--files", nargs="+", required=True, help="输入文件路径(JSON / CSV)")
|
|
||||||
parser.add_argument("--auth", help="Lab token(base64(ak:sk))")
|
|
||||||
parser.add_argument("--base", help="API base URL")
|
|
||||||
parser.add_argument("--submit", action="store_true", help="直接提交到云端")
|
|
||||||
parser.add_argument("--output", default="agent_result_body.json", help="输出 JSON 文件路径")
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
for fp in args.files:
|
|
||||||
if not os.path.exists(fp):
|
|
||||||
print(f"[错误] 文件不存在: {fp}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
agent_result = merge_files(args.files)
|
|
||||||
body = build_request_body(args.notebook_uuid, agent_result)
|
|
||||||
|
|
||||||
with open(args.output, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(body, f, ensure_ascii=False, indent=2)
|
|
||||||
print(f"[完成] 请求体已保存: {args.output}")
|
|
||||||
print(f" notebook_uuid: {args.notebook_uuid}")
|
|
||||||
print(f" agent_result 字段数: {len(agent_result)}")
|
|
||||||
print(f" 合并文件数: {len(args.files)}")
|
|
||||||
|
|
||||||
if args.submit:
|
|
||||||
if not args.auth or not args.base:
|
|
||||||
print("[错误] 提交需要 --auth 和 --base 参数", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
print(f"\n[提交] PUT {args.base}/api/v1/lab/notebook/agent-result ...")
|
|
||||||
result = submit(args.base, args.auth, body)
|
|
||||||
print(f" HTTP {result['status_code']}")
|
|
||||||
print(f" 响应: {json.dumps(result['body'], ensure_ascii=False)}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
.conda
|
|
||||||
# .github
|
|
||||||
.idea
|
|
||||||
# .vscode
|
|
||||||
output
|
|
||||||
pylabrobot_repo
|
|
||||||
recipes
|
|
||||||
scripts
|
|
||||||
service
|
|
||||||
temp
|
|
||||||
# unilabos/test
|
|
||||||
# unilabos/app/web
|
|
||||||
unilabos/device_mesh
|
|
||||||
unilabos_data
|
|
||||||
unilabos_msgs
|
|
||||||
unilabos.egg-info
|
|
||||||
CONTRIBUTORS
|
|
||||||
# LICENSE
|
|
||||||
MANIFEST.in
|
|
||||||
pyrightconfig.json
|
|
||||||
# README.md
|
|
||||||
# README_zh.md
|
|
||||||
setup.py
|
|
||||||
setup.cfg
|
|
||||||
.gitattrubutes
|
|
||||||
**/__pycache__
|
|
||||||
19
.github/dependabot.yml
vendored
Normal file
19
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
# GitHub Actions
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
target-branch: "dev"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
day: "monday"
|
||||||
|
time: "06:00"
|
||||||
|
open-pull-requests-limit: 5
|
||||||
|
reviewers:
|
||||||
|
- "msgcenterpy-team"
|
||||||
|
labels:
|
||||||
|
- "dependencies"
|
||||||
|
- "github-actions"
|
||||||
|
commit-message:
|
||||||
|
prefix: "ci"
|
||||||
|
include: "scope"
|
||||||
2
.github/workflows/ci-check.yml
vendored
2
.github/workflows/ci-check.yml
vendored
@@ -49,7 +49,7 @@ jobs:
|
|||||||
uv pip uninstall enum34 || echo enum34 not installed, skipping
|
uv pip uninstall enum34 || echo enum34 not installed, skipping
|
||||||
uv pip install .
|
uv pip install .
|
||||||
|
|
||||||
- name: Run check mode (AST registry validation)
|
- name: Run check mode (complete_registry)
|
||||||
run: |
|
run: |
|
||||||
call conda activate check-env
|
call conda activate check-env
|
||||||
echo Running check mode...
|
echo Running check mode...
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -5,7 +5,6 @@ output/
|
|||||||
unilabos_data/
|
unilabos_data/
|
||||||
pyrightconfig.json
|
pyrightconfig.json
|
||||||
.cursorignore
|
.cursorignore
|
||||||
device_package*/
|
|
||||||
## Python
|
## Python
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
@@ -252,5 +251,3 @@ ros-humble-unilabos-msgs-0.9.13-h6403a04_5.tar.bz2
|
|||||||
test_config.py
|
test_config.py
|
||||||
|
|
||||||
|
|
||||||
/.claude
|
|
||||||
/.cursor
|
|
||||||
|
|||||||
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
|
```python
|
||||||
from unilabos.registry.decorators import device, topic_config
|
|
||||||
|
|
||||||
@device(id="mock_gripper", category=["gripper"], description="Mock Gripper")
|
|
||||||
class MockGripper:
|
class MockGripper:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._position: float = 0.0
|
self._position: float = 0.0
|
||||||
@@ -26,23 +23,19 @@ class MockGripper:
|
|||||||
self._status = "Idle"
|
self._status = "Idle"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config() # 添加 @topic_config 才会定时广播
|
|
||||||
def position(self) -> float:
|
def position(self) -> float:
|
||||||
return self._position
|
return self._position
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config()
|
|
||||||
def velocity(self) -> float:
|
def velocity(self) -> float:
|
||||||
return self._velocity
|
return self._velocity
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config()
|
|
||||||
def torque(self) -> float:
|
def torque(self) -> float:
|
||||||
return self._torque
|
return self._torque
|
||||||
|
|
||||||
# 使用 @topic_config 装饰的属性,接入 Uni-Lab 时会定时对外广播
|
# 会被自动识别的设备属性,接入 Uni-Lab 时会定时对外广播
|
||||||
@property
|
@property
|
||||||
@topic_config(period=2.0) # 可自定义发布周期
|
|
||||||
def status(self) -> str:
|
def status(self) -> str:
|
||||||
return self._status
|
return self._status
|
||||||
|
|
||||||
@@ -156,7 +149,7 @@ my_device: # 设备唯一标识符
|
|||||||
|
|
||||||
系统会自动分析您的 Python 驱动类并生成:
|
系统会自动分析您的 Python 驱动类并生成:
|
||||||
|
|
||||||
- `status_types`:从 `@topic_config` 装饰的 `@property` 或方法自动识别状态属性
|
- `status_types`:从 `@property` 装饰的方法自动识别状态属性
|
||||||
- `action_value_mappings`:从类方法自动生成动作映射
|
- `action_value_mappings`:从类方法自动生成动作映射
|
||||||
- `init_param_schema`:从 `__init__` 方法分析初始化参数
|
- `init_param_schema`:从 `__init__` 方法分析初始化参数
|
||||||
- `schema`:前端显示用的属性类型定义
|
- `schema`:前端显示用的属性类型定义
|
||||||
@@ -186,9 +179,7 @@ Uni-Lab 设备驱动是一个 Python 类,需要遵循以下结构:
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
from unilabos.registry.decorators import device, topic_config
|
|
||||||
|
|
||||||
@device(id="my_device", category=["general"], description="My Device")
|
|
||||||
class MyDevice:
|
class MyDevice:
|
||||||
"""设备类文档字符串
|
"""设备类文档字符串
|
||||||
|
|
||||||
@@ -207,9 +198,8 @@ class MyDevice:
|
|||||||
# 初始化硬件连接
|
# 初始化硬件连接
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config() # 必须添加 @topic_config 才会广播
|
|
||||||
def status(self) -> str:
|
def status(self) -> str:
|
||||||
"""设备状态(通过 @topic_config 广播)"""
|
"""设备状态(会自动广播)"""
|
||||||
return self._status
|
return self._status
|
||||||
|
|
||||||
def my_action(self, param: float) -> Dict[str, Any]:
|
def my_action(self, param: float) -> Dict[str, Any]:
|
||||||
@@ -227,61 +217,34 @@ class MyDevice:
|
|||||||
|
|
||||||
## 状态属性 vs 动作方法
|
## 状态属性 vs 动作方法
|
||||||
|
|
||||||
### 状态属性(@property + @topic_config)
|
### 状态属性(@property)
|
||||||
|
|
||||||
状态属性需要同时使用 `@property` 和 `@topic_config` 装饰器才会被识别并定期广播:
|
状态属性会被自动识别并定期广播:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from unilabos.registry.decorators import topic_config
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config() # 必须添加,否则不会广播
|
|
||||||
def temperature(self) -> float:
|
def temperature(self) -> float:
|
||||||
"""当前温度"""
|
"""当前温度"""
|
||||||
return self._read_temperature()
|
return self._read_temperature()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config(period=2.0) # 可自定义发布周期(秒)
|
|
||||||
def status(self) -> str:
|
def status(self) -> str:
|
||||||
"""设备状态: idle, running, error"""
|
"""设备状态: idle, running, error"""
|
||||||
return self._status
|
return self._status
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config(name="ready") # 可自定义发布名称
|
|
||||||
def is_ready(self) -> bool:
|
def is_ready(self) -> bool:
|
||||||
"""设备是否就绪"""
|
"""设备是否就绪"""
|
||||||
return self._status == "idle"
|
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`装饰器
|
||||||
- 支持 `@property` 和普通方法
|
- 只读,不能有参数
|
||||||
- 添加到注册表的 `status_types`
|
- 自动添加到注册表的`status_types`
|
||||||
- 定期发布到 ROS2 topic
|
- 定期发布到 ROS2 topic
|
||||||
|
|
||||||
> **⚠️ 重要:** 仅有 `@property` 装饰器而没有 `@topic_config` 的属性**不会**被广播。这是一个 Breaking Change。
|
|
||||||
|
|
||||||
### 动作方法
|
### 动作方法
|
||||||
|
|
||||||
动作方法是设备可以执行的操作:
|
动作方法是设备可以执行的操作:
|
||||||
@@ -534,7 +497,6 @@ class LiquidHandler:
|
|||||||
self._status = "idle"
|
self._status = "idle"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config()
|
|
||||||
def status(self) -> str:
|
def status(self) -> str:
|
||||||
return self._status
|
return self._status
|
||||||
|
|
||||||
@@ -924,52 +886,7 @@ class MyDevice:
|
|||||||
|
|
||||||
## 最佳实践
|
## 最佳实践
|
||||||
|
|
||||||
### 1. 使用 `@device` 装饰器标识设备类
|
### 1. 类型注解
|
||||||
|
|
||||||
```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. 类型注解
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from typing import Dict, Any, Optional, List
|
from typing import Dict, Any, Optional, List
|
||||||
@@ -984,7 +901,7 @@ def method(
|
|||||||
pass
|
pass
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. 文档字符串
|
### 2. 文档字符串
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def method(self, param: float) -> Dict[str, Any]:
|
def method(self, param: float) -> Dict[str, Any]:
|
||||||
@@ -1006,7 +923,7 @@ def method(self, param: float) -> Dict[str, Any]:
|
|||||||
pass
|
pass
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. 配置验证
|
### 3. 配置验证
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def __init__(self, config: Dict[str, Any]):
|
def __init__(self, config: Dict[str, Any]):
|
||||||
@@ -1020,7 +937,7 @@ def __init__(self, config: Dict[str, Any]):
|
|||||||
self.baudrate = config['baudrate']
|
self.baudrate = config['baudrate']
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. 资源清理
|
### 4. 资源清理
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def __del__(self):
|
def __del__(self):
|
||||||
@@ -1029,7 +946,7 @@ def __del__(self):
|
|||||||
self.connection.close()
|
self.connection.close()
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7. 设计前端友好的返回值
|
### 5. 设计前端友好的返回值
|
||||||
|
|
||||||
**记住:返回值会直接显示在 Web 界面**
|
**记住:返回值会直接显示在 Web 界面**
|
||||||
|
|
||||||
|
|||||||
@@ -422,20 +422,18 @@ placeholder_keys:
|
|||||||
|
|
||||||
### status_types
|
### status_types
|
||||||
|
|
||||||
系统会扫描你的 Python 类,从带有 `@topic_config` 装饰器的 `@property` 或方法自动生成这部分:
|
系统会扫描你的 Python 类,从状态方法(property 或 get\_方法)自动生成这部分:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
status_types:
|
status_types:
|
||||||
current_temperature: float # 从 @topic_config 装饰的 @property 或方法
|
current_temperature: float # 从 get_current_temperature() 或 @property current_temperature
|
||||||
is_heating: bool
|
is_heating: bool # 从 get_is_heating() 或 @property is_heating
|
||||||
status: str
|
status: str # 从 get_status() 或 @property status
|
||||||
```
|
```
|
||||||
|
|
||||||
**注意事项**:
|
**注意事项**:
|
||||||
|
|
||||||
- 仅有带 `@topic_config` 装饰器的 `@property` 或方法才会被识别为状态属性
|
- 系统会查找所有 `get_` 开头的方法和 `@property` 装饰的属性
|
||||||
- 没有 `@topic_config` 的 `@property` 不会生成 status_types,也不会广播
|
|
||||||
- `get_` 前缀的方法名会自动去除前缀(如 `get_temperature` → `temperature`)
|
|
||||||
- 类型会自动转成相应的类型(如 `str`、`float`、`bool`)
|
- 类型会自动转成相应的类型(如 `str`、`float`、`bool`)
|
||||||
- 如果类型是 `Any`、`None` 或未知的,默认使用 `String`
|
- 如果类型是 `Any`、`None` 或未知的,默认使用 `String`
|
||||||
|
|
||||||
@@ -539,13 +537,11 @@ class AdvancedLiquidHandler:
|
|||||||
self._temperature = 25.0
|
self._temperature = 25.0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config()
|
|
||||||
def status(self) -> str:
|
def status(self) -> str:
|
||||||
"""设备状态"""
|
"""设备状态"""
|
||||||
return self._status
|
return self._status
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config()
|
|
||||||
def temperature(self) -> float:
|
def temperature(self) -> float:
|
||||||
"""当前温度"""
|
"""当前温度"""
|
||||||
return self._temperature
|
return self._temperature
|
||||||
@@ -813,23 +809,21 @@ my_temperature_controller:
|
|||||||
你的设备类需要符合以下要求:
|
你的设备类需要符合以下要求:
|
||||||
|
|
||||||
```python
|
```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(DeviceBase):
|
||||||
class MyDevice:
|
|
||||||
def __init__(self, config):
|
def __init__(self, config):
|
||||||
"""初始化,参数会自动分析到 init_param_schema.config"""
|
"""初始化,参数会自动分析到 init_param_schema.config"""
|
||||||
|
super().__init__(config)
|
||||||
self.port = config.get('port', '/dev/ttyUSB0')
|
self.port = config.get('port', '/dev/ttyUSB0')
|
||||||
|
|
||||||
# 状态方法(必须添加 @topic_config 才会生成到 status_types 并广播)
|
# 状态方法(会自动生成到 status_types)
|
||||||
@property
|
@property
|
||||||
@topic_config()
|
|
||||||
def status(self):
|
def status(self):
|
||||||
"""返回设备状态"""
|
"""返回设备状态"""
|
||||||
return "idle"
|
return "idle"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config()
|
|
||||||
def temperature(self):
|
def temperature(self):
|
||||||
"""返回当前温度"""
|
"""返回当前温度"""
|
||||||
return 25.0
|
return 25.0
|
||||||
@@ -1045,34 +1039,7 @@ resource.type # "resource"
|
|||||||
|
|
||||||
### 代码规范
|
### 代码规范
|
||||||
|
|
||||||
1. **使用 `@device` 装饰器标识设备类**
|
1. **始终使用类型注解**
|
||||||
|
|
||||||
```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. **始终使用类型注解**
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# ✓ 好
|
# ✓ 好
|
||||||
@@ -1084,7 +1051,7 @@ def method(self, resource, device):
|
|||||||
pass
|
pass
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **提供有意义的参数名**
|
2. **提供有意义的参数名**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# ✓ 好 - 清晰的参数名
|
# ✓ 好 - 清晰的参数名
|
||||||
@@ -1096,7 +1063,7 @@ def transfer(self, r1: ResourceSlot, r2: ResourceSlot):
|
|||||||
pass
|
pass
|
||||||
```
|
```
|
||||||
|
|
||||||
5. **使用 Optional 表示可选参数**
|
3. **使用 Optional 表示可选参数**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -1109,7 +1076,7 @@ def method(
|
|||||||
pass
|
pass
|
||||||
```
|
```
|
||||||
|
|
||||||
6. **添加详细的文档字符串**
|
4. **添加详细的文档字符串**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def method(
|
def method(
|
||||||
@@ -1129,13 +1096,13 @@ def method(
|
|||||||
pass
|
pass
|
||||||
```
|
```
|
||||||
|
|
||||||
7. **方法命名规范**
|
5. **方法命名规范**
|
||||||
|
|
||||||
- 状态方法使用 `@property` + `@topic_config` 装饰器,或普通方法 + `@topic_config`
|
- 状态方法使用 `@property` 装饰器或 `get_` 前缀
|
||||||
- 动作方法使用动词开头
|
- 动作方法使用动词开头
|
||||||
- 保持命名清晰、一致
|
- 保持命名清晰、一致
|
||||||
|
|
||||||
8. **完善的错误处理**
|
6. **完善的错误处理**
|
||||||
- 实现完善的错误处理
|
- 实现完善的错误处理
|
||||||
- 添加日志记录
|
- 添加日志记录
|
||||||
- 提供有意义的错误信息
|
- 提供有意义的错误信息
|
||||||
|
|||||||
@@ -221,10 +221,10 @@ Laboratory A Laboratory B
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 实验室A
|
# 实验室A
|
||||||
unilab --ak your_ak --sk your_sk --upload_registry
|
unilab --ak your_ak --sk your_sk --upload_registry --use_remote_resource
|
||||||
|
|
||||||
# 实验室B
|
# 实验室B
|
||||||
unilab --ak your_ak --sk your_sk --upload_registry
|
unilab --ak your_ak --sk your_sk --upload_registry --use_remote_resource
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ options:
|
|||||||
--is_slave Run the backend as slave node (without host privileges).
|
--is_slave Run the backend as slave node (without host privileges).
|
||||||
--slave_no_host Skip waiting for host service in slave mode
|
--slave_no_host Skip waiting for host service in slave mode
|
||||||
--upload_registry Upload registry information when starting unilab
|
--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
|
--config CONFIG Configuration file path, supports .py format Python config files
|
||||||
--port PORT Port for web service information page
|
--port PORT Port for web service information page
|
||||||
--disable_browser Disable opening information page on startup
|
--disable_browser Disable opening information page on startup
|
||||||
@@ -84,7 +85,7 @@ Uni-Lab 的启动过程分为以下几个阶段:
|
|||||||
支持两种方式:
|
支持两种方式:
|
||||||
|
|
||||||
- **本地文件**:使用 `-g` 指定图谱文件(支持 JSON 和 GraphML 格式)
|
- **本地文件**:使用 `-g` 指定图谱文件(支持 JSON 和 GraphML 格式)
|
||||||
- **远程资源**:不指定本地文件即可
|
- **远程资源**:使用 `--use_remote_resource` 从云端获取
|
||||||
|
|
||||||
### 7. 注册表构建
|
### 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 -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
|
unilab --ak your_ak --sk your_sk --complete_registry
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package:
|
package:
|
||||||
name: ros-humble-unilabos-msgs
|
name: ros-humble-unilabos-msgs
|
||||||
version: 0.10.19
|
version: 0.10.18
|
||||||
source:
|
source:
|
||||||
path: ../../unilabos_msgs
|
path: ../../unilabos_msgs
|
||||||
target_directory: src
|
target_directory: src
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package:
|
package:
|
||||||
name: unilabos
|
name: unilabos
|
||||||
version: "0.10.19"
|
version: "0.10.18"
|
||||||
|
|
||||||
source:
|
source:
|
||||||
path: ../..
|
path: ../..
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import traceback
|
import traceback
|
||||||
import uuid
|
import uuid
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
import networkx as nx
|
import networkx as nx
|
||||||
@@ -24,15 +25,7 @@ class SimpleGraph:
|
|||||||
|
|
||||||
def add_edge(self, source, target, **attrs):
|
def add_edge(self, source, target, **attrs):
|
||||||
"""添加边"""
|
"""添加边"""
|
||||||
# edge = {"source": source, "target": target, **attrs}
|
edge = {"source": source, "target": target, **attrs}
|
||||||
edge = {
|
|
||||||
"source": source, "target": target,
|
|
||||||
"source_node_uuid": source,
|
|
||||||
"target_node_uuid": target,
|
|
||||||
"source_handle_io": "source",
|
|
||||||
"target_handle_io": "target",
|
|
||||||
**attrs
|
|
||||||
}
|
|
||||||
self.edges.append(edge)
|
self.edges.append(edge)
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
@@ -49,7 +42,6 @@ class SimpleGraph:
|
|||||||
"multigraph": False,
|
"multigraph": False,
|
||||||
"graph": {},
|
"graph": {},
|
||||||
"nodes": nodes_list,
|
"nodes": nodes_list,
|
||||||
"edges": self.edges,
|
|
||||||
"links": self.edges,
|
"links": self.edges,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,8 +58,495 @@ def extract_json_from_markdown(text: str) -> str:
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def convert_to_type(val: str) -> Any:
|
||||||
|
"""将字符串值转换为适当的数据类型"""
|
||||||
|
if val == "True":
|
||||||
|
return True
|
||||||
|
if val == "False":
|
||||||
|
return False
|
||||||
|
if val == "?":
|
||||||
|
return None
|
||||||
|
if val.endswith(" g"):
|
||||||
|
return float(val.split(" ")[0])
|
||||||
|
if val.endswith("mg"):
|
||||||
|
return float(val.split("mg")[0])
|
||||||
|
elif val.endswith("mmol"):
|
||||||
|
return float(val.split("mmol")[0]) / 1000
|
||||||
|
elif val.endswith("mol"):
|
||||||
|
return float(val.split("mol")[0])
|
||||||
|
elif val.endswith("ml"):
|
||||||
|
return float(val.split("ml")[0])
|
||||||
|
elif val.endswith("RPM"):
|
||||||
|
return float(val.split("RPM")[0])
|
||||||
|
elif val.endswith(" °C"):
|
||||||
|
return float(val.split(" ")[0])
|
||||||
|
elif val.endswith(" %"):
|
||||||
|
return float(val.split(" ")[0])
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
def refactor_data(data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||||
|
"""统一的数据重构函数,根据操作类型自动选择模板"""
|
||||||
|
refactored_data = []
|
||||||
|
|
||||||
|
# 定义操作映射,包含生物实验和有机化学的所有操作
|
||||||
|
OPERATION_MAPPING = {
|
||||||
|
# 生物实验操作
|
||||||
|
"transfer_liquid": "SynBioFactory-liquid_handler.prcxi-transfer_liquid",
|
||||||
|
"transfer": "SynBioFactory-liquid_handler.biomek-transfer",
|
||||||
|
"incubation": "SynBioFactory-liquid_handler.biomek-incubation",
|
||||||
|
"move_labware": "SynBioFactory-liquid_handler.biomek-move_labware",
|
||||||
|
"oscillation": "SynBioFactory-liquid_handler.biomek-oscillation",
|
||||||
|
# 有机化学操作
|
||||||
|
"HeatChillToTemp": "SynBioFactory-workstation-HeatChillProtocol",
|
||||||
|
"StopHeatChill": "SynBioFactory-workstation-HeatChillStopProtocol",
|
||||||
|
"StartHeatChill": "SynBioFactory-workstation-HeatChillStartProtocol",
|
||||||
|
"HeatChill": "SynBioFactory-workstation-HeatChillProtocol",
|
||||||
|
"Dissolve": "SynBioFactory-workstation-DissolveProtocol",
|
||||||
|
"Transfer": "SynBioFactory-workstation-TransferProtocol",
|
||||||
|
"Evaporate": "SynBioFactory-workstation-EvaporateProtocol",
|
||||||
|
"Recrystallize": "SynBioFactory-workstation-RecrystallizeProtocol",
|
||||||
|
"Filter": "SynBioFactory-workstation-FilterProtocol",
|
||||||
|
"Dry": "SynBioFactory-workstation-DryProtocol",
|
||||||
|
"Add": "SynBioFactory-workstation-AddProtocol",
|
||||||
|
}
|
||||||
|
|
||||||
|
UNSUPPORTED_OPERATIONS = ["Purge", "Wait", "Stir", "ResetHandling"]
|
||||||
|
|
||||||
|
for step in data:
|
||||||
|
operation = step.get("action")
|
||||||
|
if not operation or operation in UNSUPPORTED_OPERATIONS:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 处理重复操作
|
||||||
|
if operation == "Repeat":
|
||||||
|
times = step.get("times", step.get("parameters", {}).get("times", 1))
|
||||||
|
sub_steps = step.get("steps", step.get("parameters", {}).get("steps", []))
|
||||||
|
for i in range(int(times)):
|
||||||
|
sub_data = refactor_data(sub_steps)
|
||||||
|
refactored_data.extend(sub_data)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 获取模板名称
|
||||||
|
template = OPERATION_MAPPING.get(operation)
|
||||||
|
if not template:
|
||||||
|
# 自动推断模板类型
|
||||||
|
if operation.lower() in ["transfer", "incubation", "move_labware", "oscillation"]:
|
||||||
|
template = f"SynBioFactory-liquid_handler.biomek-{operation}"
|
||||||
|
else:
|
||||||
|
template = f"SynBioFactory-workstation-{operation}Protocol"
|
||||||
|
|
||||||
|
# 创建步骤数据
|
||||||
|
step_data = {
|
||||||
|
"template": template,
|
||||||
|
"description": step.get("description", step.get("purpose", f"{operation} operation")),
|
||||||
|
"lab_node_type": "Device",
|
||||||
|
"parameters": step.get("parameters", step.get("action_args", {})),
|
||||||
|
}
|
||||||
|
refactored_data.append(step_data)
|
||||||
|
|
||||||
|
return refactored_data
|
||||||
|
|
||||||
|
|
||||||
|
def build_protocol_graph(
|
||||||
|
labware_info: List[Dict[str, Any]], protocol_steps: List[Dict[str, Any]], workstation_name: str
|
||||||
|
) -> SimpleGraph:
|
||||||
|
"""统一的协议图构建函数,根据设备类型自动选择构建逻辑"""
|
||||||
|
G = SimpleGraph()
|
||||||
|
resource_last_writer = {}
|
||||||
|
LAB_NAME = "SynBioFactory"
|
||||||
|
|
||||||
|
protocol_steps = refactor_data(protocol_steps)
|
||||||
|
|
||||||
|
# 检查协议步骤中的模板来判断协议类型
|
||||||
|
has_biomek_template = any(
|
||||||
|
("biomek" in step.get("template", "")) or ("prcxi" in step.get("template", ""))
|
||||||
|
for step in protocol_steps
|
||||||
|
)
|
||||||
|
|
||||||
|
if has_biomek_template:
|
||||||
|
# 生物实验协议图构建
|
||||||
|
for labware_id, labware in labware_info.items():
|
||||||
|
node_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
labware_attrs = labware.copy()
|
||||||
|
labware_id = labware_attrs.pop("id", labware_attrs.get("name", f"labware_{uuid.uuid4()}"))
|
||||||
|
labware_attrs["description"] = labware_id
|
||||||
|
labware_attrs["lab_node_type"] = (
|
||||||
|
"Reagent" if "Plate" in str(labware_id) else "Labware" if "Rack" in str(labware_id) else "Sample"
|
||||||
|
)
|
||||||
|
labware_attrs["device_id"] = workstation_name
|
||||||
|
|
||||||
|
G.add_node(node_id, template=f"{LAB_NAME}-host_node-create_resource", **labware_attrs)
|
||||||
|
resource_last_writer[labware_id] = f"{node_id}:labware"
|
||||||
|
|
||||||
|
# 处理协议步骤
|
||||||
|
prev_node = None
|
||||||
|
for i, step in enumerate(protocol_steps):
|
||||||
|
node_id = str(uuid.uuid4())
|
||||||
|
G.add_node(node_id, **step)
|
||||||
|
|
||||||
|
# 添加控制流边
|
||||||
|
if prev_node is not None:
|
||||||
|
G.add_edge(prev_node, node_id, source_port="ready", target_port="ready")
|
||||||
|
prev_node = node_id
|
||||||
|
|
||||||
|
# 处理物料流
|
||||||
|
params = step.get("parameters", {})
|
||||||
|
if "sources" in params and params["sources"] in resource_last_writer:
|
||||||
|
source_node, source_port = resource_last_writer[params["sources"]].split(":")
|
||||||
|
G.add_edge(source_node, node_id, source_port=source_port, target_port="labware")
|
||||||
|
|
||||||
|
if "targets" in params:
|
||||||
|
resource_last_writer[params["targets"]] = f"{node_id}:labware"
|
||||||
|
|
||||||
|
# 添加协议结束节点
|
||||||
|
end_id = str(uuid.uuid4())
|
||||||
|
G.add_node(end_id, template=f"{LAB_NAME}-liquid_handler.biomek-run_protocol")
|
||||||
|
if prev_node is not None:
|
||||||
|
G.add_edge(prev_node, end_id, source_port="ready", target_port="ready")
|
||||||
|
|
||||||
|
else:
|
||||||
|
# 有机化学协议图构建
|
||||||
|
WORKSTATION_ID = workstation_name
|
||||||
|
|
||||||
|
# 为所有labware创建资源节点
|
||||||
|
for item_id, item in labware_info.items():
|
||||||
|
# item_id = item.get("id") or item.get("name", f"item_{uuid.uuid4()}")
|
||||||
|
node_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# 判断节点类型
|
||||||
|
if item.get("type") == "hardware" or "reactor" in str(item_id).lower():
|
||||||
|
if "reactor" not in str(item_id).lower():
|
||||||
|
continue
|
||||||
|
lab_node_type = "Sample"
|
||||||
|
description = f"Prepare Reactor: {item_id}"
|
||||||
|
liquid_type = []
|
||||||
|
liquid_volume = []
|
||||||
|
else:
|
||||||
|
lab_node_type = "Reagent"
|
||||||
|
description = f"Add Reagent to Flask: {item_id}"
|
||||||
|
liquid_type = [item_id]
|
||||||
|
liquid_volume = [1e5]
|
||||||
|
|
||||||
|
G.add_node(
|
||||||
|
node_id,
|
||||||
|
template=f"{LAB_NAME}-host_node-create_resource",
|
||||||
|
description=description,
|
||||||
|
lab_node_type=lab_node_type,
|
||||||
|
res_id=item_id,
|
||||||
|
device_id=WORKSTATION_ID,
|
||||||
|
class_name="container",
|
||||||
|
parent=WORKSTATION_ID,
|
||||||
|
bind_locations={"x": 0.0, "y": 0.0, "z": 0.0},
|
||||||
|
liquid_input_slot=[-1],
|
||||||
|
liquid_type=liquid_type,
|
||||||
|
liquid_volume=liquid_volume,
|
||||||
|
slot_on_deck="",
|
||||||
|
role=item.get("role", ""),
|
||||||
|
)
|
||||||
|
resource_last_writer[item_id] = f"{node_id}:labware"
|
||||||
|
|
||||||
|
last_control_node_id = None
|
||||||
|
|
||||||
|
# 处理协议步骤
|
||||||
|
for step in protocol_steps:
|
||||||
|
node_id = str(uuid.uuid4())
|
||||||
|
G.add_node(node_id, **step)
|
||||||
|
|
||||||
|
# 控制流
|
||||||
|
if last_control_node_id is not None:
|
||||||
|
G.add_edge(last_control_node_id, node_id, source_port="ready", target_port="ready")
|
||||||
|
last_control_node_id = node_id
|
||||||
|
|
||||||
|
# 物料流
|
||||||
|
params = step.get("parameters", {})
|
||||||
|
input_resources = {
|
||||||
|
"Vessel": params.get("vessel"),
|
||||||
|
"ToVessel": params.get("to_vessel"),
|
||||||
|
"FromVessel": params.get("from_vessel"),
|
||||||
|
"reagent": params.get("reagent"),
|
||||||
|
"solvent": params.get("solvent"),
|
||||||
|
"compound": params.get("compound"),
|
||||||
|
"sources": params.get("sources"),
|
||||||
|
"targets": params.get("targets"),
|
||||||
|
}
|
||||||
|
|
||||||
|
for target_port, resource_name in input_resources.items():
|
||||||
|
if resource_name and resource_name in resource_last_writer:
|
||||||
|
source_node, source_port = resource_last_writer[resource_name].split(":")
|
||||||
|
G.add_edge(source_node, node_id, source_port=source_port, target_port=target_port)
|
||||||
|
|
||||||
|
output_resources = {
|
||||||
|
"VesselOut": params.get("vessel"),
|
||||||
|
"FromVesselOut": params.get("from_vessel"),
|
||||||
|
"ToVesselOut": params.get("to_vessel"),
|
||||||
|
"FiltrateOut": params.get("filtrate_vessel"),
|
||||||
|
"reagent": params.get("reagent"),
|
||||||
|
"solvent": params.get("solvent"),
|
||||||
|
"compound": params.get("compound"),
|
||||||
|
"sources_out": params.get("sources"),
|
||||||
|
"targets_out": params.get("targets"),
|
||||||
|
}
|
||||||
|
|
||||||
|
for source_port, resource_name in output_resources.items():
|
||||||
|
if resource_name:
|
||||||
|
resource_last_writer[resource_name] = f"{node_id}:{source_port}"
|
||||||
|
|
||||||
|
return G
|
||||||
|
|
||||||
|
|
||||||
|
def draw_protocol_graph(protocol_graph: SimpleGraph, output_path: str):
|
||||||
|
"""
|
||||||
|
(辅助功能) 使用 networkx 和 matplotlib 绘制协议工作流图,用于可视化。
|
||||||
|
"""
|
||||||
|
if not protocol_graph:
|
||||||
|
print("Cannot draw graph: Graph object is empty.")
|
||||||
|
return
|
||||||
|
|
||||||
|
G = nx.DiGraph()
|
||||||
|
|
||||||
|
for node_id, attrs in protocol_graph.nodes.items():
|
||||||
|
label = attrs.get("description", attrs.get("template", node_id[:8]))
|
||||||
|
G.add_node(node_id, label=label, **attrs)
|
||||||
|
|
||||||
|
for edge in protocol_graph.edges:
|
||||||
|
G.add_edge(edge["source"], edge["target"])
|
||||||
|
|
||||||
|
plt.figure(figsize=(20, 15))
|
||||||
|
try:
|
||||||
|
pos = nx.nx_agraph.graphviz_layout(G, prog="dot")
|
||||||
|
except Exception:
|
||||||
|
pos = nx.shell_layout(G) # Fallback layout
|
||||||
|
|
||||||
|
node_labels = {node: data["label"] for node, data in G.nodes(data=True)}
|
||||||
|
nx.draw(
|
||||||
|
G,
|
||||||
|
pos,
|
||||||
|
with_labels=False,
|
||||||
|
node_size=2500,
|
||||||
|
node_color="skyblue",
|
||||||
|
node_shape="o",
|
||||||
|
edge_color="gray",
|
||||||
|
width=1.5,
|
||||||
|
arrowsize=15,
|
||||||
|
)
|
||||||
|
nx.draw_networkx_labels(G, pos, labels=node_labels, font_size=8, font_weight="bold")
|
||||||
|
|
||||||
|
plt.title("Chemical Protocol Workflow Graph", size=15)
|
||||||
|
plt.savefig(output_path, dpi=300, bbox_inches="tight")
|
||||||
|
plt.close()
|
||||||
|
print(f" - Visualization saved to '{output_path}'")
|
||||||
|
|
||||||
|
|
||||||
|
from networkx.drawing.nx_agraph import to_agraph
|
||||||
|
import re
|
||||||
|
|
||||||
|
COMPASS = {"n","e","s","w","ne","nw","se","sw","c"}
|
||||||
|
|
||||||
|
def _is_compass(port: str) -> bool:
|
||||||
|
return isinstance(port, str) and port.lower() in COMPASS
|
||||||
|
|
||||||
|
def draw_protocol_graph_with_ports(protocol_graph, output_path: str, rankdir: str = "LR"):
|
||||||
|
"""
|
||||||
|
使用 Graphviz 端口语法绘制协议工作流图。
|
||||||
|
- 若边上的 source_port/target_port 是 compass(n/e/s/w/...),直接用 compass。
|
||||||
|
- 否则自动为节点创建 record 形状并定义命名端口 <portname>。
|
||||||
|
最终由 PyGraphviz 渲染并输出到 output_path(后缀决定格式,如 .png/.svg/.pdf)。
|
||||||
|
"""
|
||||||
|
if not protocol_graph:
|
||||||
|
print("Cannot draw graph: Graph object is empty.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1) 先用 networkx 搭建有向图,保留端口属性
|
||||||
|
G = nx.DiGraph()
|
||||||
|
for node_id, attrs in protocol_graph.nodes.items():
|
||||||
|
label = attrs.get("description", attrs.get("template", node_id[:8]))
|
||||||
|
# 保留一个干净的“中心标签”,用于放在 record 的中间槽
|
||||||
|
G.add_node(node_id, _core_label=str(label), **{k:v for k,v in attrs.items() if k not in ("label",)})
|
||||||
|
|
||||||
|
edges_data = []
|
||||||
|
in_ports_by_node = {} # 收集命名输入端口
|
||||||
|
out_ports_by_node = {} # 收集命名输出端口
|
||||||
|
|
||||||
|
for edge in protocol_graph.edges:
|
||||||
|
u = edge["source"]
|
||||||
|
v = edge["target"]
|
||||||
|
sp = edge.get("source_port")
|
||||||
|
tp = edge.get("target_port")
|
||||||
|
|
||||||
|
# 记录到图里(保留原始端口信息)
|
||||||
|
G.add_edge(u, v, source_port=sp, target_port=tp)
|
||||||
|
edges_data.append((u, v, sp, tp))
|
||||||
|
|
||||||
|
# 如果不是 compass,就按“命名端口”先归类,等会儿给节点造 record
|
||||||
|
if sp and not _is_compass(sp):
|
||||||
|
out_ports_by_node.setdefault(u, set()).add(str(sp))
|
||||||
|
if tp and not _is_compass(tp):
|
||||||
|
in_ports_by_node.setdefault(v, set()).add(str(tp))
|
||||||
|
|
||||||
|
# 2) 转为 AGraph,使用 Graphviz 渲染
|
||||||
|
A = to_agraph(G)
|
||||||
|
A.graph_attr.update(rankdir=rankdir, splines="true", concentrate="false", fontsize="10")
|
||||||
|
A.node_attr.update(shape="box", style="rounded,filled", fillcolor="lightyellow", color="#999999", fontname="Helvetica")
|
||||||
|
A.edge_attr.update(arrowsize="0.8", color="#666666")
|
||||||
|
|
||||||
|
# 3) 为需要命名端口的节点设置 record 形状与 label
|
||||||
|
# 左列 = 输入端口;中间 = 核心标签;右列 = 输出端口
|
||||||
|
for n in A.nodes():
|
||||||
|
node = A.get_node(n)
|
||||||
|
core = G.nodes[n].get("_core_label", n)
|
||||||
|
|
||||||
|
in_ports = sorted(in_ports_by_node.get(n, []))
|
||||||
|
out_ports = sorted(out_ports_by_node.get(n, []))
|
||||||
|
|
||||||
|
# 如果该节点涉及命名端口,则用 record;否则保留原 box
|
||||||
|
if in_ports or out_ports:
|
||||||
|
def port_fields(ports):
|
||||||
|
if not ports:
|
||||||
|
return " " # 必须留一个空槽占位
|
||||||
|
# 每个端口一个小格子,<p> name
|
||||||
|
return "|".join(f"<{re.sub(r'[^A-Za-z0-9_:.|-]', '_', p)}> {p}" for p in ports)
|
||||||
|
|
||||||
|
left = port_fields(in_ports)
|
||||||
|
right = port_fields(out_ports)
|
||||||
|
|
||||||
|
# 三栏:左(入) | 中(节点名) | 右(出)
|
||||||
|
record_label = f"{{ {left} | {core} | {right} }}"
|
||||||
|
node.attr.update(shape="record", label=record_label)
|
||||||
|
else:
|
||||||
|
# 没有命名端口:普通盒子,显示核心标签
|
||||||
|
node.attr.update(label=str(core))
|
||||||
|
|
||||||
|
# 4) 给边设置 headport / tailport
|
||||||
|
# - 若端口为 compass:直接用 compass(e.g., headport="e")
|
||||||
|
# - 若端口为命名端口:使用在 record 中定义的 <port> 名(同名即可)
|
||||||
|
for (u, v, sp, tp) in edges_data:
|
||||||
|
e = A.get_edge(u, v)
|
||||||
|
|
||||||
|
# Graphviz 属性:tail 是源,head 是目标
|
||||||
|
if sp:
|
||||||
|
if _is_compass(sp):
|
||||||
|
e.attr["tailport"] = sp.lower()
|
||||||
|
else:
|
||||||
|
# 与 record label 中 <port> 名一致;特殊字符已在 label 中做了清洗
|
||||||
|
e.attr["tailport"] = re.sub(r'[^A-Za-z0-9_:.|-]', '_', str(sp))
|
||||||
|
|
||||||
|
if tp:
|
||||||
|
if _is_compass(tp):
|
||||||
|
e.attr["headport"] = tp.lower()
|
||||||
|
else:
|
||||||
|
e.attr["headport"] = re.sub(r'[^A-Za-z0-9_:.|-]', '_', str(tp))
|
||||||
|
|
||||||
|
# 可选:若想让边更贴边缘,可设置 constraint/spline 等
|
||||||
|
# e.attr["arrowhead"] = "vee"
|
||||||
|
|
||||||
|
# 5) 输出
|
||||||
|
A.draw(output_path, prog="dot")
|
||||||
|
print(f" - Port-aware workflow rendered to '{output_path}'")
|
||||||
|
|
||||||
|
|
||||||
|
def flatten_xdl_procedure(procedure_elem: ET.Element) -> List[ET.Element]:
|
||||||
|
"""展平嵌套的XDL程序结构"""
|
||||||
|
flattened_operations = []
|
||||||
|
TEMP_UNSUPPORTED_PROTOCOL = ["Purge", "Wait", "Stir", "ResetHandling"]
|
||||||
|
|
||||||
|
def extract_operations(element: ET.Element):
|
||||||
|
if element.tag not in ["Prep", "Reaction", "Workup", "Purification", "Procedure"]:
|
||||||
|
if element.tag not in TEMP_UNSUPPORTED_PROTOCOL:
|
||||||
|
flattened_operations.append(element)
|
||||||
|
|
||||||
|
for child in element:
|
||||||
|
extract_operations(child)
|
||||||
|
|
||||||
|
for child in procedure_elem:
|
||||||
|
extract_operations(child)
|
||||||
|
|
||||||
|
return flattened_operations
|
||||||
|
|
||||||
|
|
||||||
|
def parse_xdl_content(xdl_content: str) -> tuple:
|
||||||
|
"""解析XDL内容"""
|
||||||
|
try:
|
||||||
|
xdl_content_cleaned = "".join(c for c in xdl_content if c.isprintable())
|
||||||
|
root = ET.fromstring(xdl_content_cleaned)
|
||||||
|
|
||||||
|
synthesis_elem = root.find("Synthesis")
|
||||||
|
if synthesis_elem is None:
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
# 解析硬件组件
|
||||||
|
hardware_elem = synthesis_elem.find("Hardware")
|
||||||
|
hardware = []
|
||||||
|
if hardware_elem is not None:
|
||||||
|
hardware = [{"id": c.get("id"), "type": c.get("type")} for c in hardware_elem.findall("Component")]
|
||||||
|
|
||||||
|
# 解析试剂
|
||||||
|
reagents_elem = synthesis_elem.find("Reagents")
|
||||||
|
reagents = []
|
||||||
|
if reagents_elem is not None:
|
||||||
|
reagents = [{"name": r.get("name"), "role": r.get("role", "")} for r in reagents_elem.findall("Reagent")]
|
||||||
|
|
||||||
|
# 解析程序
|
||||||
|
procedure_elem = synthesis_elem.find("Procedure")
|
||||||
|
if procedure_elem is None:
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
flattened_operations = flatten_xdl_procedure(procedure_elem)
|
||||||
|
return hardware, reagents, flattened_operations
|
||||||
|
|
||||||
|
except ET.ParseError as e:
|
||||||
|
raise ValueError(f"Invalid XDL format: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def convert_xdl_to_dict(xdl_content: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
将XDL XML格式转换为标准的字典格式
|
||||||
|
|
||||||
|
Args:
|
||||||
|
xdl_content: XDL XML内容
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
转换结果,包含步骤和器材信息
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
hardware, reagents, flattened_operations = parse_xdl_content(xdl_content)
|
||||||
|
if hardware is None:
|
||||||
|
return {"error": "Failed to parse XDL content", "success": False}
|
||||||
|
|
||||||
|
# 将XDL元素转换为字典格式
|
||||||
|
steps_data = []
|
||||||
|
for elem in flattened_operations:
|
||||||
|
# 转换参数类型
|
||||||
|
parameters = {}
|
||||||
|
for key, val in elem.attrib.items():
|
||||||
|
converted_val = convert_to_type(val)
|
||||||
|
if converted_val is not None:
|
||||||
|
parameters[key] = converted_val
|
||||||
|
|
||||||
|
step_dict = {
|
||||||
|
"operation": elem.tag,
|
||||||
|
"parameters": parameters,
|
||||||
|
"description": elem.get("purpose", f"Operation: {elem.tag}"),
|
||||||
|
}
|
||||||
|
steps_data.append(step_dict)
|
||||||
|
|
||||||
|
# 合并硬件和试剂为统一的labware_info格式
|
||||||
|
labware_data = []
|
||||||
|
labware_data.extend({"id": hw["id"], "type": "hardware", **hw} for hw in hardware)
|
||||||
|
labware_data.extend({"name": reagent["name"], "type": "reagent", **reagent} for reagent in reagents)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"steps": steps_data,
|
||||||
|
"labware": labware_data,
|
||||||
|
"message": f"Successfully converted XDL to dict format. Found {len(steps_data)} steps and {len(labware_data)} labware items.",
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"XDL conversion failed: {str(e)}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return {"error": error_msg, "success": False}
|
||||||
|
|
||||||
|
|
||||||
def create_workflow(
|
def create_workflow(
|
||||||
|
|||||||
2
setup.py
2
setup.py
@@ -4,7 +4,7 @@ package_name = 'unilabos'
|
|||||||
|
|
||||||
setup(
|
setup(
|
||||||
name=package_name,
|
name=package_name,
|
||||||
version='0.10.19',
|
version='0.10.18',
|
||||||
packages=find_packages(),
|
packages=find_packages(),
|
||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
install_requires=['setuptools'],
|
install_requires=['setuptools'],
|
||||||
|
|||||||
@@ -1,539 +0,0 @@
|
|||||||
import pytest
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import asyncio
|
|
||||||
import collections
|
|
||||||
from typing import List, Dict, Any
|
|
||||||
|
|
||||||
from pylabrobot.resources import Coordinate
|
|
||||||
from pylabrobot.resources.opentrons.tip_racks import opentrons_96_tiprack_300ul, opentrons_96_tiprack_10ul
|
|
||||||
from pylabrobot.resources.opentrons.plates import corning_96_wellplate_360ul_flat, nest_96_wellplate_2ml_deep
|
|
||||||
|
|
||||||
from unilabos.devices.liquid_handling.prcxi.prcxi import (
|
|
||||||
PRCXI9300Deck,
|
|
||||||
PRCXI9300Container,
|
|
||||||
PRCXI9300Trash,
|
|
||||||
PRCXI9300Handler,
|
|
||||||
PRCXI9300Backend,
|
|
||||||
DefaultLayout,
|
|
||||||
Material,
|
|
||||||
WorkTablets,
|
|
||||||
MatrixInfo
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def prcxi_materials() -> Dict[str, Any]:
|
|
||||||
"""加载 PRCXI 物料数据"""
|
|
||||||
print("加载 PRCXI 物料数据...")
|
|
||||||
material_path = os.path.join(os.path.dirname(__file__), "..", "..", "unilabos", "devices", "liquid_handling", "prcxi", "prcxi_material.json")
|
|
||||||
with open(material_path, "r", encoding="utf-8") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
print(f"加载了 {len(data)} 条物料数据")
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def prcxi_9300_deck() -> PRCXI9300Deck:
|
|
||||||
"""创建 PRCXI 9300 工作台"""
|
|
||||||
return PRCXI9300Deck(name="PRCXI_Deck_9300", size_x=100, size_y=100, size_z=100, model="9300")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def prcxi_9320_deck() -> PRCXI9300Deck:
|
|
||||||
"""创建 PRCXI 9320 工作台"""
|
|
||||||
return PRCXI9300Deck(name="PRCXI_Deck_9320", size_x=100, size_y=100, size_z=100, model="9320")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def prcxi_9300_handler(prcxi_9300_deck) -> PRCXI9300Handler:
|
|
||||||
"""创建 PRCXI 9300 处理器(模拟模式)"""
|
|
||||||
return PRCXI9300Handler(
|
|
||||||
deck=prcxi_9300_deck,
|
|
||||||
host="192.168.1.201",
|
|
||||||
port=9999,
|
|
||||||
timeout=10.0,
|
|
||||||
channel_num=8,
|
|
||||||
axis="Left",
|
|
||||||
setup=False,
|
|
||||||
debug=True,
|
|
||||||
simulator=True,
|
|
||||||
matrix_id="test-matrix-9300"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def prcxi_9320_handler(prcxi_9320_deck) -> PRCXI9300Handler:
|
|
||||||
"""创建 PRCXI 9320 处理器(模拟模式)"""
|
|
||||||
return PRCXI9300Handler(
|
|
||||||
deck=prcxi_9320_deck,
|
|
||||||
host="192.168.1.201",
|
|
||||||
port=9999,
|
|
||||||
timeout=10.0,
|
|
||||||
channel_num=1,
|
|
||||||
axis="Right",
|
|
||||||
setup=False,
|
|
||||||
debug=True,
|
|
||||||
simulator=True,
|
|
||||||
matrix_id="test-matrix-9320",
|
|
||||||
is_9320=True
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def tip_rack_300ul(prcxi_materials) -> PRCXI9300Container:
|
|
||||||
"""创建 300μL 枪头盒"""
|
|
||||||
tip_rack = PRCXI9300Container(
|
|
||||||
name="tip_rack_300ul",
|
|
||||||
size_x=50,
|
|
||||||
size_y=50,
|
|
||||||
size_z=10,
|
|
||||||
category="tip_rack",
|
|
||||||
ordering=collections.OrderedDict()
|
|
||||||
)
|
|
||||||
tip_rack.load_state({
|
|
||||||
"Material": {
|
|
||||||
"uuid": prcxi_materials["300μL Tip头"]["uuid"],
|
|
||||||
"Code": "ZX-001-300",
|
|
||||||
"Name": "300μL Tip头"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return tip_rack
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def tip_rack_10ul(prcxi_materials) -> PRCXI9300Container:
|
|
||||||
"""创建 10μL 枪头盒"""
|
|
||||||
tip_rack = PRCXI9300Container(
|
|
||||||
name="tip_rack_10ul",
|
|
||||||
size_x=50,
|
|
||||||
size_y=50,
|
|
||||||
size_z=10,
|
|
||||||
category="tip_rack",
|
|
||||||
ordering=collections.OrderedDict()
|
|
||||||
)
|
|
||||||
tip_rack.load_state({
|
|
||||||
"Material": {
|
|
||||||
"uuid": prcxi_materials["10μL加长 Tip头"]["uuid"],
|
|
||||||
"Code": "ZX-001-10+",
|
|
||||||
"Name": "10μL加长 Tip头"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return tip_rack
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def well_plate_96(prcxi_materials) -> PRCXI9300Container:
|
|
||||||
"""创建 96 孔板"""
|
|
||||||
plate = PRCXI9300Container(
|
|
||||||
name="well_plate_96",
|
|
||||||
size_x=50,
|
|
||||||
size_y=50,
|
|
||||||
size_z=10,
|
|
||||||
category="plate",
|
|
||||||
ordering=collections.OrderedDict()
|
|
||||||
)
|
|
||||||
plate.load_state({
|
|
||||||
"Material": {
|
|
||||||
"uuid": prcxi_materials["96深孔板"]["uuid"],
|
|
||||||
"Code": "ZX-019-2.2",
|
|
||||||
"Name": "96深孔板"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return plate
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def deep_well_plate(prcxi_materials) -> PRCXI9300Container:
|
|
||||||
"""创建深孔板"""
|
|
||||||
plate = PRCXI9300Container(
|
|
||||||
name="deep_well_plate",
|
|
||||||
size_x=50,
|
|
||||||
size_y=50,
|
|
||||||
size_z=10,
|
|
||||||
category="plate",
|
|
||||||
ordering=collections.OrderedDict()
|
|
||||||
)
|
|
||||||
plate.load_state({
|
|
||||||
"Material": {
|
|
||||||
"uuid": prcxi_materials["96深孔板"]["uuid"],
|
|
||||||
"Code": "ZX-019-2.2",
|
|
||||||
"Name": "96深孔板"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return plate
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def trash_container(prcxi_materials) -> PRCXI9300Trash:
|
|
||||||
"""创建垃圾桶"""
|
|
||||||
trash = PRCXI9300Trash(name="trash", size_x=50, size_y=50, size_z=10, category="trash")
|
|
||||||
trash.load_state({
|
|
||||||
"Material": {
|
|
||||||
"uuid": prcxi_materials["废弃槽"]["uuid"]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return trash
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def default_layout_9300() -> DefaultLayout:
|
|
||||||
"""创建 PRCXI 9300 默认布局"""
|
|
||||||
return DefaultLayout("PRCXI9300")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def default_layout_9320() -> DefaultLayout:
|
|
||||||
"""创建 PRCXI 9320 默认布局"""
|
|
||||||
return DefaultLayout("PRCXI9320")
|
|
||||||
|
|
||||||
|
|
||||||
class TestPRCXIDeckSetup:
|
|
||||||
"""测试 PRCXI 工作台设置功能"""
|
|
||||||
|
|
||||||
def test_prcxi_9300_deck_creation(self, prcxi_9300_deck):
|
|
||||||
"""测试 PRCXI 9300 工作台创建"""
|
|
||||||
assert prcxi_9300_deck.name == "PRCXI_Deck_9300"
|
|
||||||
assert len(prcxi_9300_deck.sites) == 6
|
|
||||||
assert prcxi_9300_deck._size_x == 100
|
|
||||||
assert prcxi_9300_deck._size_y == 100
|
|
||||||
assert prcxi_9300_deck._size_z == 100
|
|
||||||
|
|
||||||
def test_prcxi_9320_deck_creation(self, prcxi_9320_deck):
|
|
||||||
"""测试 PRCXI 9320 工作台创建"""
|
|
||||||
assert prcxi_9320_deck.name == "PRCXI_Deck_9320"
|
|
||||||
assert len(prcxi_9320_deck.sites) == 16
|
|
||||||
assert prcxi_9320_deck._size_x == 100
|
|
||||||
assert prcxi_9320_deck._size_y == 100
|
|
||||||
assert prcxi_9320_deck._size_z == 100
|
|
||||||
|
|
||||||
def test_container_assignment(self, prcxi_9300_deck, tip_rack_300ul, well_plate_96, trash_container):
|
|
||||||
"""测试容器分配到工作台"""
|
|
||||||
# 分配枪头盒
|
|
||||||
prcxi_9300_deck.assign_child_resource(tip_rack_300ul, location=Coordinate(0, 0, 0))
|
|
||||||
assert tip_rack_300ul in prcxi_9300_deck.children
|
|
||||||
|
|
||||||
# 分配孔板
|
|
||||||
prcxi_9300_deck.assign_child_resource(well_plate_96, location=Coordinate(0, 0, 0))
|
|
||||||
assert well_plate_96 in prcxi_9300_deck.children
|
|
||||||
|
|
||||||
# 分配垃圾桶
|
|
||||||
prcxi_9300_deck.assign_child_resource(trash_container, location=Coordinate(0, 0, 0))
|
|
||||||
assert trash_container in prcxi_9300_deck.children
|
|
||||||
|
|
||||||
def test_container_material_loading(self, tip_rack_300ul, well_plate_96, prcxi_materials):
|
|
||||||
"""测试容器物料信息加载"""
|
|
||||||
# 测试枪头盒物料信息
|
|
||||||
tip_material = tip_rack_300ul._unilabos_state["Material"]
|
|
||||||
assert tip_material["uuid"] == prcxi_materials["300μL Tip头"]["uuid"]
|
|
||||||
assert tip_material["Name"] == "300μL Tip头"
|
|
||||||
|
|
||||||
# 测试孔板物料信息
|
|
||||||
plate_material = well_plate_96._unilabos_state["Material"]
|
|
||||||
assert plate_material["uuid"] == prcxi_materials["96深孔板"]["uuid"]
|
|
||||||
assert plate_material["Name"] == "96深孔板"
|
|
||||||
|
|
||||||
|
|
||||||
class TestPRCXISingleStepOperations:
|
|
||||||
"""测试 PRCXI 单步操作功能"""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_pick_up_tips_single_channel(self, prcxi_9320_handler, prcxi_9320_deck, tip_rack_10ul):
|
|
||||||
"""测试单通道拾取枪头"""
|
|
||||||
# 将枪头盒添加到工作台
|
|
||||||
prcxi_9320_deck.assign_child_resource(tip_rack_10ul, location=Coordinate(0, 0, 0))
|
|
||||||
|
|
||||||
# 初始化处理器
|
|
||||||
await prcxi_9320_handler.setup()
|
|
||||||
|
|
||||||
# 设置枪头盒
|
|
||||||
prcxi_9320_handler.set_tiprack([tip_rack_10ul])
|
|
||||||
|
|
||||||
# 创建模拟的枪头位置
|
|
||||||
from pylabrobot.resources import TipSpot, Tip
|
|
||||||
tip = Tip(has_filter=False, total_tip_length=10, maximal_volume=10, fitting_depth=5)
|
|
||||||
tip_spot = TipSpot("A1", size_x=1, size_y=1, size_z=1, make_tip=lambda: tip)
|
|
||||||
tip_rack_10ul.assign_child_resource(tip_spot, location=Coordinate(0, 0, 0))
|
|
||||||
|
|
||||||
# 直接测试后端方法
|
|
||||||
from pylabrobot.liquid_handling import Pickup
|
|
||||||
pickup = Pickup(resource=tip_spot, offset=None, tip=tip)
|
|
||||||
await prcxi_9320_handler._unilabos_backend.pick_up_tips([pickup], [0])
|
|
||||||
|
|
||||||
# 验证步骤已添加到待办列表
|
|
||||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
|
||||||
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
|
||||||
assert step["Function"] == "Load"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_pick_up_tips_multi_channel(self, prcxi_9300_handler, tip_rack_300ul):
|
|
||||||
"""测试多通道拾取枪头"""
|
|
||||||
# 设置枪头盒
|
|
||||||
prcxi_9300_handler.set_tiprack([tip_rack_300ul])
|
|
||||||
|
|
||||||
# 拾取8个枪头
|
|
||||||
tip_spots = tip_rack_300ul.children[:8]
|
|
||||||
await prcxi_9300_handler.pick_up_tips(tip_spots, [0, 1, 2, 3, 4, 5, 6, 7])
|
|
||||||
|
|
||||||
# 验证步骤已添加到待办列表
|
|
||||||
assert len(prcxi_9300_handler._unilabos_backend.steps_todo_list) == 1
|
|
||||||
step = prcxi_9300_handler._unilabos_backend.steps_todo_list[0]
|
|
||||||
assert step["Function"] == "Load"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_aspirate_single_channel(self, prcxi_9320_handler, well_plate_96):
|
|
||||||
"""测试单通道吸取液体"""
|
|
||||||
# 设置液体
|
|
||||||
well = well_plate_96.get_item("A1")
|
|
||||||
prcxi_9320_handler.set_liquid([well], ["water"], [50])
|
|
||||||
|
|
||||||
# 吸取液体
|
|
||||||
await prcxi_9320_handler.aspirate([well], [50], [0])
|
|
||||||
|
|
||||||
# 验证步骤已添加到待办列表
|
|
||||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
|
||||||
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
|
||||||
assert step["Function"] == "Imbibing"
|
|
||||||
assert step["DosageNum"] == 50
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_dispense_single_channel(self, prcxi_9320_handler, well_plate_96):
|
|
||||||
"""测试单通道分配液体"""
|
|
||||||
# 分配液体
|
|
||||||
well = well_plate_96.get_item("A1")
|
|
||||||
await prcxi_9320_handler.dispense([well], [25], [0])
|
|
||||||
|
|
||||||
# 验证步骤已添加到待办列表
|
|
||||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
|
||||||
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
|
||||||
assert step["Function"] == "Tapping"
|
|
||||||
assert step["DosageNum"] == 25
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_mix_single_channel(self, prcxi_9320_handler, well_plate_96):
|
|
||||||
"""测试单通道混合液体"""
|
|
||||||
# 混合液体
|
|
||||||
well = well_plate_96.get_item("A1")
|
|
||||||
await prcxi_9320_handler.mix([well], mix_time=3, mix_vol=50)
|
|
||||||
|
|
||||||
# 验证步骤已添加到待办列表
|
|
||||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
|
||||||
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
|
||||||
assert step["Function"] == "Blending"
|
|
||||||
assert step["BlendingTimes"] == 3
|
|
||||||
assert step["DosageNum"] == 50
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_drop_tips_to_trash(self, prcxi_9320_handler, trash_container):
|
|
||||||
"""测试丢弃枪头到垃圾桶"""
|
|
||||||
# 丢弃枪头
|
|
||||||
await prcxi_9320_handler.drop_tips([trash_container], [0])
|
|
||||||
|
|
||||||
# 验证步骤已添加到待办列表
|
|
||||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
|
||||||
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
|
||||||
assert step["Function"] == "UnLoad"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_discard_tips(self, prcxi_9320_handler):
|
|
||||||
"""测试丢弃枪头"""
|
|
||||||
# 丢弃枪头
|
|
||||||
await prcxi_9320_handler.discard_tips([0])
|
|
||||||
|
|
||||||
# 验证步骤已添加到待办列表
|
|
||||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
|
||||||
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
|
||||||
assert step["Function"] == "UnLoad"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_liquid_transfer_workflow(self, prcxi_9320_handler, tip_rack_10ul, well_plate_96):
|
|
||||||
"""测试完整的液体转移工作流程"""
|
|
||||||
# 设置枪头盒和液体
|
|
||||||
prcxi_9320_handler.set_tiprack([tip_rack_10ul])
|
|
||||||
source_well = well_plate_96.get_item("A1")
|
|
||||||
target_well = well_plate_96.get_item("B1")
|
|
||||||
prcxi_9320_handler.set_liquid([source_well], ["water"], [100])
|
|
||||||
|
|
||||||
# 创建协议
|
|
||||||
await prcxi_9320_handler.create_protocol(protocol_name="Test Transfer Protocol")
|
|
||||||
|
|
||||||
# 执行转移流程
|
|
||||||
tip_spot = tip_rack_10ul.get_item("A1")
|
|
||||||
await prcxi_9320_handler.pick_up_tips([tip_spot], [0])
|
|
||||||
await prcxi_9320_handler.aspirate([source_well], [50], [0])
|
|
||||||
await prcxi_9320_handler.dispense([target_well], [50], [0])
|
|
||||||
await prcxi_9320_handler.discard_tips([0])
|
|
||||||
|
|
||||||
# 验证所有步骤都已添加
|
|
||||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 4
|
|
||||||
functions = [step["Function"] for step in prcxi_9320_handler._unilabos_backend.steps_todo_list]
|
|
||||||
assert functions == ["Load", "Imbibing", "Tapping", "UnLoad"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestPRCXILayoutRecommendation:
|
|
||||||
"""测试 PRCXI 板位推荐功能"""
|
|
||||||
|
|
||||||
def test_9300_layout_creation(self, default_layout_9300):
|
|
||||||
"""测试 PRCXI 9300 布局创建"""
|
|
||||||
layout_info = default_layout_9300.get_layout()
|
|
||||||
assert layout_info["rows"] == 2
|
|
||||||
assert layout_info["columns"] == 3
|
|
||||||
assert len(layout_info["layout"]) == 6
|
|
||||||
assert layout_info["trash_slot"] == 6
|
|
||||||
assert "waste_liquid_slot" not in layout_info
|
|
||||||
|
|
||||||
def test_9320_layout_creation(self, default_layout_9320):
|
|
||||||
"""测试 PRCXI 9320 布局创建"""
|
|
||||||
layout_info = default_layout_9320.get_layout()
|
|
||||||
assert layout_info["rows"] == 4
|
|
||||||
assert layout_info["columns"] == 4
|
|
||||||
assert len(layout_info["layout"]) == 16
|
|
||||||
assert layout_info["trash_slot"] == 16
|
|
||||||
assert layout_info["waste_liquid_slot"] == 12
|
|
||||||
|
|
||||||
def test_layout_recommendation_9320(self, default_layout_9320, prcxi_materials):
|
|
||||||
"""测试 PRCXI 9320 板位推荐功能"""
|
|
||||||
# 添加物料信息
|
|
||||||
default_layout_9320.add_lab_resource(prcxi_materials)
|
|
||||||
|
|
||||||
# 推荐布局
|
|
||||||
needs = [
|
|
||||||
("reagent_1", "96 细胞培养皿", 3),
|
|
||||||
("reagent_2", "12道储液槽", 1),
|
|
||||||
("reagent_3", "200μL Tip头", 7),
|
|
||||||
("reagent_4", "10μL加长 Tip头", 1),
|
|
||||||
]
|
|
||||||
|
|
||||||
matrix_layout, layout_list = default_layout_9320.recommend_layout(needs)
|
|
||||||
|
|
||||||
# 验证返回结果
|
|
||||||
assert "MatrixId" in matrix_layout
|
|
||||||
assert "MatrixName" in matrix_layout
|
|
||||||
assert "MatrixCount" in matrix_layout
|
|
||||||
assert "WorkTablets" in matrix_layout
|
|
||||||
assert len(layout_list) == 12 # 3+1+7+1 = 12个位置
|
|
||||||
|
|
||||||
# 验证推荐的位置不包含预留位置
|
|
||||||
reserved_positions = {12, 16}
|
|
||||||
recommended_positions = [item["positions"] for item in layout_list]
|
|
||||||
for pos in recommended_positions:
|
|
||||||
assert pos not in reserved_positions
|
|
||||||
|
|
||||||
def test_layout_recommendation_insufficient_space(self, default_layout_9320, prcxi_materials):
|
|
||||||
"""测试板位推荐空间不足的情况"""
|
|
||||||
# 添加物料信息
|
|
||||||
default_layout_9320.add_lab_resource(prcxi_materials)
|
|
||||||
|
|
||||||
# 尝试推荐超过可用空间的布局
|
|
||||||
needs = [
|
|
||||||
("reagent_1", "96 细胞培养皿", 15), # 需要15个位置,但只有14个可用
|
|
||||||
]
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="需要 .* 个位置,但只有 .* 个可用位置"):
|
|
||||||
default_layout_9320.recommend_layout(needs)
|
|
||||||
|
|
||||||
def test_layout_recommendation_material_not_found(self, default_layout_9320, prcxi_materials):
|
|
||||||
"""测试板位推荐物料不存在的情况"""
|
|
||||||
# 添加物料信息
|
|
||||||
default_layout_9320.add_lab_resource(prcxi_materials)
|
|
||||||
|
|
||||||
# 尝试推荐不存在的物料
|
|
||||||
needs = [
|
|
||||||
("reagent_1", "不存在的物料", 1),
|
|
||||||
]
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Material .* not found in lab resources"):
|
|
||||||
default_layout_9320.recommend_layout(needs)
|
|
||||||
|
|
||||||
|
|
||||||
class TestPRCXIBackendOperations:
|
|
||||||
"""测试 PRCXI 后端操作功能"""
|
|
||||||
|
|
||||||
def test_backend_initialization(self, prcxi_9300_handler):
|
|
||||||
"""测试后端初始化"""
|
|
||||||
backend = prcxi_9300_handler._unilabos_backend
|
|
||||||
assert isinstance(backend, PRCXI9300Backend)
|
|
||||||
assert backend._num_channels == 8
|
|
||||||
assert backend.debug is True
|
|
||||||
|
|
||||||
def test_protocol_creation(self, prcxi_9300_handler):
|
|
||||||
"""测试协议创建"""
|
|
||||||
backend = prcxi_9300_handler._unilabos_backend
|
|
||||||
backend.create_protocol("Test Protocol")
|
|
||||||
assert backend.protocol_name == "Test Protocol"
|
|
||||||
assert len(backend.steps_todo_list) == 0
|
|
||||||
|
|
||||||
def test_channel_validation(self):
|
|
||||||
"""测试通道验证"""
|
|
||||||
# 测试正确的8通道配置
|
|
||||||
valid_channels = [0, 1, 2, 3, 4, 5, 6, 7]
|
|
||||||
result = PRCXI9300Backend.check_channels(valid_channels)
|
|
||||||
assert result == valid_channels
|
|
||||||
|
|
||||||
# 测试错误的通道配置
|
|
||||||
invalid_channels = [0, 1, 2, 3]
|
|
||||||
result = PRCXI9300Backend.check_channels(invalid_channels)
|
|
||||||
assert result == [0, 1, 2, 3, 4, 5, 6, 7]
|
|
||||||
|
|
||||||
def test_matrix_info_creation(self, prcxi_9300_handler):
|
|
||||||
"""测试矩阵信息创建"""
|
|
||||||
backend = prcxi_9300_handler._unilabos_backend
|
|
||||||
backend.create_protocol("Test Protocol")
|
|
||||||
|
|
||||||
# 模拟运行协议时的矩阵信息创建
|
|
||||||
run_time = 1234567890
|
|
||||||
matrix_info = MatrixInfo(
|
|
||||||
MatrixId=f"{int(run_time)}",
|
|
||||||
MatrixName=f"protocol_{run_time}",
|
|
||||||
MatrixCount=len(backend.tablets_info),
|
|
||||||
WorkTablets=backend.tablets_info,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert matrix_info["MatrixId"] == str(int(run_time))
|
|
||||||
assert matrix_info["MatrixName"] == f"protocol_{run_time}"
|
|
||||||
assert "WorkTablets" in matrix_info
|
|
||||||
|
|
||||||
|
|
||||||
class TestPRCXIContainerOperations:
|
|
||||||
"""测试 PRCXI 容器操作功能"""
|
|
||||||
|
|
||||||
def test_container_serialization(self, tip_rack_300ul):
|
|
||||||
"""测试容器序列化"""
|
|
||||||
serialized = tip_rack_300ul.serialize_state()
|
|
||||||
assert "Material" in serialized
|
|
||||||
assert serialized["Material"]["Name"] == "300μL Tip头"
|
|
||||||
|
|
||||||
def test_container_deserialization(self, tip_rack_300ul):
|
|
||||||
"""测试容器反序列化"""
|
|
||||||
# 序列化
|
|
||||||
serialized = tip_rack_300ul.serialize_state()
|
|
||||||
|
|
||||||
# 创建新容器并反序列化
|
|
||||||
new_tip_rack = PRCXI9300Container(
|
|
||||||
name="new_tip_rack",
|
|
||||||
size_x=50,
|
|
||||||
size_y=50,
|
|
||||||
size_z=10,
|
|
||||||
category="tip_rack",
|
|
||||||
ordering=collections.OrderedDict()
|
|
||||||
)
|
|
||||||
new_tip_rack.load_state(serialized)
|
|
||||||
|
|
||||||
assert new_tip_rack._unilabos_state["Material"]["Name"] == "300μL Tip头"
|
|
||||||
|
|
||||||
def test_trash_container_creation(self, prcxi_materials):
|
|
||||||
"""测试垃圾桶容器创建"""
|
|
||||||
trash = PRCXI9300Trash(name="trash", size_x=50, size_y=50, size_z=10, category="trash")
|
|
||||||
trash.load_state({
|
|
||||||
"Material": {
|
|
||||||
"uuid": prcxi_materials["废弃槽"]["uuid"]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
assert trash.name == "trash"
|
|
||||||
assert trash._unilabos_state["Material"]["uuid"] == prcxi_materials["废弃槽"]["uuid"]
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# 运行测试
|
|
||||||
pytest.main([__file__, "-v"])
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# Liquid handling 集成测试
|
|
||||||
|
|
||||||
`test_transfer_liquid.py` 现在会调用 PRCXI 的 RViz 仿真 backend,运行前请确保:
|
|
||||||
|
|
||||||
1. 已安装包含 `pylabrobot`、`rclpy` 的运行环境;
|
|
||||||
2. 启动 ROS 依赖(`rviz` 可选,但是 `rviz_backend` 会创建 ROS 节点);
|
|
||||||
3. 在 shell 中设置 `UNILAB_SIM_TEST=1`,否则 pytest 会自动跳过这些慢速用例:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export UNILAB_SIM_TEST=1
|
|
||||||
pytest tests/devices/liquid_handling/test_transfer_liquid.py -m slow
|
|
||||||
```
|
|
||||||
|
|
||||||
如果只需验证逻辑层(不依赖仿真),可以直接运行 `tests/devices/liquid_handling/unit_test.py`,该文件使用 Fake backend,适合作为 CI 的快速测试。***
|
|
||||||
|
|
||||||
@@ -39,11 +39,6 @@ class FakeLiquidHandler(LiquidHandlerAbstract):
|
|||||||
self.current_tip = iter(make_tip_iter())
|
self.current_tip = iter(make_tip_iter())
|
||||||
self.calls: List[Tuple[str, Any]] = []
|
self.calls: List[Tuple[str, Any]] = []
|
||||||
|
|
||||||
def set_tiprack(self, tip_racks):
|
|
||||||
if not tip_racks:
|
|
||||||
return
|
|
||||||
super().set_tiprack(tip_racks)
|
|
||||||
|
|
||||||
async def pick_up_tips(self, tip_spots, use_channels=None, offsets=None, **backend_kwargs):
|
async def pick_up_tips(self, tip_spots, use_channels=None, offsets=None, **backend_kwargs):
|
||||||
self.calls.append(("pick_up_tips", {"tips": list(tip_spots), "use_channels": use_channels}))
|
self.calls.append(("pick_up_tips", {"tips": list(tip_spots), "use_channels": use_channels}))
|
||||||
|
|
||||||
|
|||||||
@@ -1,608 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any, Iterable, List, Optional, Sequence, Tuple
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class DummyContainer:
|
|
||||||
name: str
|
|
||||||
|
|
||||||
def __repr__(self) -> str: # pragma: no cover
|
|
||||||
return f"DummyContainer({self.name})"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class DummyTipSpot:
|
|
||||||
name: str
|
|
||||||
|
|
||||||
def __repr__(self) -> str: # pragma: no cover
|
|
||||||
return f"DummyTipSpot({self.name})"
|
|
||||||
|
|
||||||
|
|
||||||
def make_tip_iter(n: int = 256) -> Iterable[List[DummyTipSpot]]:
|
|
||||||
"""Yield lists so code can safely call `tip.extend(next(self.current_tip))`."""
|
|
||||||
for i in range(n):
|
|
||||||
yield [DummyTipSpot(f"tip_{i}")]
|
|
||||||
|
|
||||||
|
|
||||||
class FakeLiquidHandler(LiquidHandlerAbstract):
|
|
||||||
"""不初始化真实 backend/deck;仅用来记录 transfer_liquid 内部调用序列。"""
|
|
||||||
|
|
||||||
def __init__(self, channel_num: int = 8):
|
|
||||||
# 不调用 super().__init__,避免真实硬件/后端依赖
|
|
||||||
self.channel_num = channel_num
|
|
||||||
self.support_touch_tip = True
|
|
||||||
self.current_tip = iter(make_tip_iter())
|
|
||||||
self.calls: List[Tuple[str, Any]] = []
|
|
||||||
|
|
||||||
def set_tiprack(self, tip_racks):
|
|
||||||
# transfer_liquid 总会调用 set_tiprack;测试用 Dummy 枪头时 tip_racks 为空,需保留自种子的 current_tip
|
|
||||||
if not tip_racks:
|
|
||||||
return
|
|
||||||
super().set_tiprack(tip_racks)
|
|
||||||
|
|
||||||
async def pick_up_tips(self, tip_spots, use_channels=None, offsets=None, **backend_kwargs):
|
|
||||||
self.calls.append(("pick_up_tips", {"tips": list(tip_spots), "use_channels": use_channels}))
|
|
||||||
|
|
||||||
async def aspirate(
|
|
||||||
self,
|
|
||||||
resources: Sequence[Any],
|
|
||||||
vols: List[float],
|
|
||||||
use_channels: Optional[List[int]] = None,
|
|
||||||
flow_rates: Optional[List[Optional[float]]] = None,
|
|
||||||
offsets: Any = None,
|
|
||||||
liquid_height: Any = None,
|
|
||||||
blow_out_air_volume: Any = None,
|
|
||||||
spread: str = "wide",
|
|
||||||
**backend_kwargs,
|
|
||||||
):
|
|
||||||
self.calls.append(
|
|
||||||
(
|
|
||||||
"aspirate",
|
|
||||||
{
|
|
||||||
"resources": list(resources),
|
|
||||||
"vols": list(vols),
|
|
||||||
"use_channels": list(use_channels) if use_channels is not None else None,
|
|
||||||
"flow_rates": list(flow_rates) if flow_rates is not None else None,
|
|
||||||
"offsets": list(offsets) if offsets is not None else None,
|
|
||||||
"liquid_height": list(liquid_height) if liquid_height is not None else None,
|
|
||||||
"blow_out_air_volume": list(blow_out_air_volume) if blow_out_air_volume is not None else None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def dispense(
|
|
||||||
self,
|
|
||||||
resources: Sequence[Any],
|
|
||||||
vols: List[float],
|
|
||||||
use_channels: Optional[List[int]] = None,
|
|
||||||
flow_rates: Optional[List[Optional[float]]] = None,
|
|
||||||
offsets: Any = None,
|
|
||||||
liquid_height: Any = None,
|
|
||||||
blow_out_air_volume: Any = None,
|
|
||||||
spread: str = "wide",
|
|
||||||
**backend_kwargs,
|
|
||||||
):
|
|
||||||
self.calls.append(
|
|
||||||
(
|
|
||||||
"dispense",
|
|
||||||
{
|
|
||||||
"resources": list(resources),
|
|
||||||
"vols": list(vols),
|
|
||||||
"use_channels": list(use_channels) if use_channels is not None else None,
|
|
||||||
"flow_rates": list(flow_rates) if flow_rates is not None else None,
|
|
||||||
"offsets": list(offsets) if offsets is not None else None,
|
|
||||||
"liquid_height": list(liquid_height) if liquid_height is not None else None,
|
|
||||||
"blow_out_air_volume": list(blow_out_air_volume) if blow_out_air_volume is not None else None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def discard_tips(self, use_channels=None, *args, **kwargs):
|
|
||||||
# 有的分支是 discard_tips(use_channels=[0]),有的分支是 discard_tips([0..7])(位置参数)
|
|
||||||
self.calls.append(("discard_tips", {"use_channels": list(use_channels) if use_channels is not None else None}))
|
|
||||||
|
|
||||||
async def custom_delay(self, seconds=0, msg=None):
|
|
||||||
self.calls.append(("custom_delay", {"seconds": seconds, "msg": msg}))
|
|
||||||
|
|
||||||
async def touch_tip(self, targets):
|
|
||||||
# 原实现会访问 targets.get_size_x() 等;测试里只记录调用
|
|
||||||
self.calls.append(("touch_tip", {"targets": targets}))
|
|
||||||
|
|
||||||
def run(coro):
|
|
||||||
return asyncio.run(coro)
|
|
||||||
|
|
||||||
|
|
||||||
def test_one_to_one_single_channel_basic_calls():
|
|
||||||
lh = FakeLiquidHandler(channel_num=1)
|
|
||||||
lh.current_tip = iter(make_tip_iter(64))
|
|
||||||
|
|
||||||
sources = [DummyContainer(f"S{i}") for i in range(3)]
|
|
||||||
targets = [DummyContainer(f"T{i}") for i in range(3)]
|
|
||||||
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=targets,
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=[0],
|
|
||||||
asp_vols=[1, 2, 3],
|
|
||||||
dis_vols=[4, 5, 6],
|
|
||||||
mix_times=None, # 应该仍能执行(不 mix)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert [c[0] for c in lh.calls].count("pick_up_tips") == 3
|
|
||||||
assert [c[0] for c in lh.calls].count("aspirate") == 3
|
|
||||||
assert [c[0] for c in lh.calls].count("dispense") == 3
|
|
||||||
assert [c[0] for c in lh.calls].count("discard_tips") == 3
|
|
||||||
|
|
||||||
# 每次 aspirate/dispense 都是单孔列表
|
|
||||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
|
||||||
assert aspirates[0]["resources"] == [sources[0]]
|
|
||||||
assert aspirates[0]["vols"] == [1.0]
|
|
||||||
|
|
||||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
|
||||||
assert dispenses[2]["resources"] == [targets[2]]
|
|
||||||
assert dispenses[2]["vols"] == [6.0]
|
|
||||||
|
|
||||||
|
|
||||||
def test_one_to_one_single_channel_before_stage_mixes_prior_to_aspirate():
|
|
||||||
lh = FakeLiquidHandler(channel_num=1)
|
|
||||||
lh.current_tip = iter(make_tip_iter(16))
|
|
||||||
|
|
||||||
source = DummyContainer("S0")
|
|
||||||
target = DummyContainer("T0")
|
|
||||||
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=[source],
|
|
||||||
targets=[target],
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=[0],
|
|
||||||
asp_vols=[5],
|
|
||||||
dis_vols=[5],
|
|
||||||
mix_stage="before",
|
|
||||||
mix_times=1,
|
|
||||||
mix_vol=3,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
aspirate_calls = [(idx, payload) for idx, (name, payload) in enumerate(lh.calls) if name == "aspirate"]
|
|
||||||
assert len(aspirate_calls) >= 2
|
|
||||||
mix_idx, mix_payload = aspirate_calls[0]
|
|
||||||
assert mix_payload["resources"] == [target]
|
|
||||||
assert mix_payload["vols"] == [3]
|
|
||||||
transfer_idx, transfer_payload = aspirate_calls[1]
|
|
||||||
assert transfer_payload["resources"] == [source]
|
|
||||||
assert mix_idx < transfer_idx
|
|
||||||
|
|
||||||
|
|
||||||
def test_one_to_one_eight_channel_groups_by_8():
|
|
||||||
lh = FakeLiquidHandler(channel_num=8)
|
|
||||||
lh.current_tip = iter(make_tip_iter(256))
|
|
||||||
|
|
||||||
sources = [DummyContainer(f"S{i}") for i in range(16)]
|
|
||||||
targets = [DummyContainer(f"T{i}") for i in range(16)]
|
|
||||||
asp_vols = list(range(1, 17))
|
|
||||||
dis_vols = list(range(101, 117))
|
|
||||||
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=targets,
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=list(range(8)),
|
|
||||||
asp_vols=asp_vols,
|
|
||||||
dis_vols=dis_vols,
|
|
||||||
mix_times=0, # 触发逻辑但不 mix
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# 16 个任务 -> 2 组,每组 8 通道一起做
|
|
||||||
assert [c[0] for c in lh.calls].count("pick_up_tips") == 2
|
|
||||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
|
||||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
|
||||||
assert len(aspirates) == 2
|
|
||||||
assert len(dispenses) == 2
|
|
||||||
|
|
||||||
assert aspirates[0]["resources"] == sources[0:8]
|
|
||||||
assert aspirates[0]["vols"] == [float(v) for v in asp_vols[0:8]]
|
|
||||||
assert dispenses[1]["resources"] == targets[8:16]
|
|
||||||
assert dispenses[1]["vols"] == [float(v) for v in dis_vols[8:16]]
|
|
||||||
|
|
||||||
|
|
||||||
def test_one_to_one_eight_channel_requires_multiple_of_8_targets():
|
|
||||||
lh = FakeLiquidHandler(channel_num=8)
|
|
||||||
lh.current_tip = iter(make_tip_iter(64))
|
|
||||||
|
|
||||||
sources = [DummyContainer(f"S{i}") for i in range(9)]
|
|
||||||
targets = [DummyContainer(f"T{i}") for i in range(9)]
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="multiple of 8"):
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=targets,
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=list(range(8)),
|
|
||||||
asp_vols=[1] * 9,
|
|
||||||
dis_vols=[1] * 9,
|
|
||||||
mix_times=0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_one_to_one_eight_channel_parameter_lists_are_chunked_per_8():
|
|
||||||
lh = FakeLiquidHandler(channel_num=8)
|
|
||||||
lh.current_tip = iter(make_tip_iter(512))
|
|
||||||
|
|
||||||
sources = [DummyContainer(f"S{i}") for i in range(16)]
|
|
||||||
targets = [DummyContainer(f"T{i}") for i in range(16)]
|
|
||||||
asp_vols = [i + 1 for i in range(16)]
|
|
||||||
dis_vols = [200 + i for i in range(16)]
|
|
||||||
asp_flow_rates = [0.1 * (i + 1) for i in range(16)]
|
|
||||||
dis_flow_rates = [0.2 * (i + 1) for i in range(16)]
|
|
||||||
offsets = [f"offset_{i}" for i in range(16)]
|
|
||||||
liquid_heights = [i * 0.5 for i in range(16)]
|
|
||||||
blow_out_air_volume = [i + 0.05 for i in range(16)]
|
|
||||||
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=targets,
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=list(range(8)),
|
|
||||||
asp_vols=asp_vols,
|
|
||||||
dis_vols=dis_vols,
|
|
||||||
asp_flow_rates=asp_flow_rates,
|
|
||||||
dis_flow_rates=dis_flow_rates,
|
|
||||||
offsets=offsets,
|
|
||||||
liquid_height=liquid_heights,
|
|
||||||
blow_out_air_volume=blow_out_air_volume,
|
|
||||||
mix_times=0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
|
||||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
|
||||||
assert len(aspirates) == len(dispenses) == 2
|
|
||||||
|
|
||||||
for batch_idx in range(2):
|
|
||||||
start = batch_idx * 8
|
|
||||||
end = start + 8
|
|
||||||
asp_call = aspirates[batch_idx]
|
|
||||||
dis_call = dispenses[batch_idx]
|
|
||||||
assert asp_call["resources"] == sources[start:end]
|
|
||||||
assert asp_call["flow_rates"] == asp_flow_rates[start:end]
|
|
||||||
assert asp_call["offsets"] == offsets[start:end]
|
|
||||||
assert asp_call["liquid_height"] == liquid_heights[start:end]
|
|
||||||
assert asp_call["blow_out_air_volume"] == blow_out_air_volume[start:end]
|
|
||||||
assert dis_call["flow_rates"] == dis_flow_rates[start:end]
|
|
||||||
assert dis_call["offsets"] == offsets[start:end]
|
|
||||||
assert dis_call["liquid_height"] == liquid_heights[start:end]
|
|
||||||
assert dis_call["blow_out_air_volume"] == blow_out_air_volume[start:end]
|
|
||||||
|
|
||||||
|
|
||||||
def test_one_to_one_eight_channel_handles_32_tasks_four_batches():
|
|
||||||
lh = FakeLiquidHandler(channel_num=8)
|
|
||||||
lh.current_tip = iter(make_tip_iter(1024))
|
|
||||||
|
|
||||||
sources = [DummyContainer(f"S{i}") for i in range(32)]
|
|
||||||
targets = [DummyContainer(f"T{i}") for i in range(32)]
|
|
||||||
asp_vols = [i + 1 for i in range(32)]
|
|
||||||
dis_vols = [300 + i for i in range(32)]
|
|
||||||
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=targets,
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=list(range(8)),
|
|
||||||
asp_vols=asp_vols,
|
|
||||||
dis_vols=dis_vols,
|
|
||||||
mix_times=0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
pick_calls = [name for name, _ in lh.calls if name == "pick_up_tips"]
|
|
||||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
|
||||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
|
||||||
assert len(pick_calls) == 4
|
|
||||||
assert len(aspirates) == len(dispenses) == 4
|
|
||||||
assert aspirates[0]["resources"] == sources[0:8]
|
|
||||||
assert aspirates[-1]["resources"] == sources[24:32]
|
|
||||||
assert dispenses[0]["resources"] == targets[0:8]
|
|
||||||
assert dispenses[-1]["resources"] == targets[24:32]
|
|
||||||
|
|
||||||
|
|
||||||
def test_one_to_many_single_channel_aspirates_total_when_asp_vol_too_small():
|
|
||||||
lh = FakeLiquidHandler(channel_num=1)
|
|
||||||
lh.current_tip = iter(make_tip_iter(64))
|
|
||||||
|
|
||||||
source = DummyContainer("SRC")
|
|
||||||
targets = [DummyContainer(f"T{i}") for i in range(3)]
|
|
||||||
dis_vols = [10, 20, 30] # sum=60
|
|
||||||
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=[source],
|
|
||||||
targets=targets,
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=[0],
|
|
||||||
asp_vols=10, # 小于 sum(dis_vols) -> 应吸 60
|
|
||||||
dis_vols=dis_vols,
|
|
||||||
mix_times=0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
|
||||||
assert len(aspirates) == 1
|
|
||||||
assert aspirates[0]["resources"] == [source]
|
|
||||||
assert aspirates[0]["vols"] == [60.0]
|
|
||||||
assert aspirates[0]["use_channels"] == [0]
|
|
||||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
|
||||||
assert [d["vols"][0] for d in dispenses] == [10.0, 20.0, 30.0]
|
|
||||||
|
|
||||||
|
|
||||||
def test_one_to_many_eight_channel_basic():
|
|
||||||
lh = FakeLiquidHandler(channel_num=8)
|
|
||||||
lh.current_tip = iter(make_tip_iter(128))
|
|
||||||
|
|
||||||
source = DummyContainer("SRC")
|
|
||||||
targets = [DummyContainer(f"T{i}") for i in range(8)]
|
|
||||||
dis_vols = [i + 1 for i in range(8)]
|
|
||||||
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=[source],
|
|
||||||
targets=targets,
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=list(range(8)),
|
|
||||||
asp_vols=999, # one-to-many 8ch 会按 dis_vols 吸(每通道各自)
|
|
||||||
dis_vols=dis_vols,
|
|
||||||
mix_times=0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
|
||||||
assert aspirates[0]["resources"] == [source] * 8
|
|
||||||
assert aspirates[0]["vols"] == [float(v) for v in dis_vols]
|
|
||||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
|
||||||
assert dispenses[0]["resources"] == targets
|
|
||||||
assert dispenses[0]["vols"] == [float(v) for v in dis_vols]
|
|
||||||
|
|
||||||
|
|
||||||
def test_many_to_one_single_channel_standard_dispense_equals_asp_by_default():
|
|
||||||
lh = FakeLiquidHandler(channel_num=1)
|
|
||||||
lh.current_tip = iter(make_tip_iter(128))
|
|
||||||
|
|
||||||
sources = [DummyContainer(f"S{i}") for i in range(3)]
|
|
||||||
target = DummyContainer("T")
|
|
||||||
asp_vols = [5, 6, 7]
|
|
||||||
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=[target],
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=[0],
|
|
||||||
asp_vols=asp_vols,
|
|
||||||
dis_vols=1, # many-to-one 允许标量;非比例模式下实际每次分液=对应 asp_vol
|
|
||||||
mix_times=0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
|
||||||
assert [d["vols"][0] for d in dispenses] == [float(v) for v in asp_vols]
|
|
||||||
assert all(d["resources"] == [target] for d in dispenses)
|
|
||||||
|
|
||||||
|
|
||||||
def test_many_to_one_single_channel_before_stage_mixes_target_once():
|
|
||||||
lh = FakeLiquidHandler(channel_num=1)
|
|
||||||
lh.current_tip = iter(make_tip_iter(128))
|
|
||||||
|
|
||||||
sources = [DummyContainer("S0"), DummyContainer("S1")]
|
|
||||||
target = DummyContainer("T")
|
|
||||||
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=[target],
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=[0],
|
|
||||||
asp_vols=[5, 6],
|
|
||||||
dis_vols=1,
|
|
||||||
mix_stage="before",
|
|
||||||
mix_times=2,
|
|
||||||
mix_vol=4,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
aspirate_calls = [(idx, payload) for idx, (name, payload) in enumerate(lh.calls) if name == "aspirate"]
|
|
||||||
assert len(aspirate_calls) >= 1
|
|
||||||
mix_idx, mix_payload = aspirate_calls[0]
|
|
||||||
assert mix_payload["resources"] == [target]
|
|
||||||
assert mix_payload["vols"] == [4]
|
|
||||||
# 第一個 mix 之後會真正開始吸 source
|
|
||||||
assert any(call["resources"] == [sources[0]] for _, call in aspirate_calls[1:])
|
|
||||||
|
|
||||||
|
|
||||||
def test_many_to_one_single_channel_proportional_mixing_uses_dis_vols_per_source():
|
|
||||||
lh = FakeLiquidHandler(channel_num=1)
|
|
||||||
lh.current_tip = iter(make_tip_iter(128))
|
|
||||||
|
|
||||||
sources = [DummyContainer(f"S{i}") for i in range(3)]
|
|
||||||
target = DummyContainer("T")
|
|
||||||
asp_vols = [5, 6, 7]
|
|
||||||
dis_vols = [1, 2, 3]
|
|
||||||
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=[target],
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=[0],
|
|
||||||
asp_vols=asp_vols,
|
|
||||||
dis_vols=dis_vols, # 比例模式
|
|
||||||
mix_times=0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
|
||||||
assert [d["vols"][0] for d in dispenses] == [float(v) for v in dis_vols]
|
|
||||||
|
|
||||||
|
|
||||||
def test_many_to_one_eight_channel_basic():
|
|
||||||
lh = FakeLiquidHandler(channel_num=8)
|
|
||||||
lh.current_tip = iter(make_tip_iter(256))
|
|
||||||
|
|
||||||
sources = [DummyContainer(f"S{i}") for i in range(8)]
|
|
||||||
target = DummyContainer("T")
|
|
||||||
asp_vols = [10 + i for i in range(8)]
|
|
||||||
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=[target],
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=list(range(8)),
|
|
||||||
asp_vols=asp_vols,
|
|
||||||
dis_vols=999, # 非比例模式下每通道分液=对应 asp_vol
|
|
||||||
mix_times=0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
|
||||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
|
||||||
assert aspirates[0]["resources"] == sources
|
|
||||||
assert aspirates[0]["vols"] == [float(v) for v in asp_vols]
|
|
||||||
assert dispenses[0]["resources"] == [target] * 8
|
|
||||||
assert dispenses[0]["vols"] == [float(v) for v in asp_vols]
|
|
||||||
|
|
||||||
|
|
||||||
def test_transfer_liquid_mode_detection_unsupported_shape_raises():
|
|
||||||
lh = FakeLiquidHandler(channel_num=8)
|
|
||||||
lh.current_tip = iter(make_tip_iter(64))
|
|
||||||
|
|
||||||
sources = [DummyContainer("S0"), DummyContainer("S1")]
|
|
||||||
targets = [DummyContainer("T0"), DummyContainer("T1"), DummyContainer("T2")]
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Unsupported transfer mode"):
|
|
||||||
run(
|
|
||||||
lh.transfer_liquid(
|
|
||||||
sources=sources,
|
|
||||||
targets=targets,
|
|
||||||
tip_racks=[],
|
|
||||||
use_channels=[0],
|
|
||||||
asp_vols=[1, 1],
|
|
||||||
dis_vols=[1, 1, 1],
|
|
||||||
mix_times=0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_mix_single_target_produces_matching_cycles():
|
|
||||||
lh = FakeLiquidHandler(channel_num=1)
|
|
||||||
target = DummyContainer("T_mix")
|
|
||||||
|
|
||||||
run(lh.mix(targets=[target], mix_time=2, mix_vol=5))
|
|
||||||
|
|
||||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
|
||||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
|
||||||
assert len(aspirates) == len(dispenses) == 2
|
|
||||||
assert all(call["resources"] == [target] for call in aspirates)
|
|
||||||
assert all(call["vols"] == [5] for call in aspirates)
|
|
||||||
assert all(call["resources"] == [target] for call in dispenses)
|
|
||||||
assert all(call["vols"] == [5] for call in dispenses)
|
|
||||||
|
|
||||||
|
|
||||||
def test_mix_multiple_targets_supports_per_target_offsets():
|
|
||||||
lh = FakeLiquidHandler(channel_num=1)
|
|
||||||
targets = [DummyContainer("T0"), DummyContainer("T1")]
|
|
||||||
offsets = ["left", "right"]
|
|
||||||
heights = [0.1, 0.2]
|
|
||||||
rates = [0.5, 1.0]
|
|
||||||
|
|
||||||
run(
|
|
||||||
lh.mix(
|
|
||||||
targets=targets,
|
|
||||||
mix_time=1,
|
|
||||||
mix_vol=3,
|
|
||||||
offsets=offsets,
|
|
||||||
height_to_bottom=heights,
|
|
||||||
mix_rate=rates,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
|
||||||
assert len(aspirates) == 2
|
|
||||||
assert aspirates[0]["resources"] == [targets[0]]
|
|
||||||
assert aspirates[0]["offsets"] == [offsets[0]]
|
|
||||||
assert aspirates[0]["liquid_height"] == [heights[0]]
|
|
||||||
assert aspirates[0]["flow_rates"] == [rates[0]]
|
|
||||||
assert aspirates[1]["resources"] == [targets[1]]
|
|
||||||
assert aspirates[1]["offsets"] == [offsets[1]]
|
|
||||||
assert aspirates[1]["liquid_height"] == [heights[1]]
|
|
||||||
assert aspirates[1]["flow_rates"] == [rates[1]]
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_tiprack_per_type_resumes_first_physical_rack():
|
|
||||||
"""同型号多次 set_tiprack 时接续第一盒剩余孔位,而非从新盒 A1 开始。"""
|
|
||||||
from pylabrobot.liquid_handling import LiquidHandlerChatterboxBackend
|
|
||||||
from pylabrobot.resources import Deck, Tip, TipRack, TipSpot, create_equally_spaced
|
|
||||||
|
|
||||||
mk = lambda: Tip(
|
|
||||||
has_filter=False, total_tip_length=10.0, maximal_volume=300.0, fitting_depth=2.0
|
|
||||||
)
|
|
||||||
|
|
||||||
class TipTypeAlpha(TipRack):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class TipTypeBeta(TipRack):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def make_rack(cls: type, name: str) -> TipRack:
|
|
||||||
items = create_equally_spaced(
|
|
||||||
TipSpot,
|
|
||||||
num_items_x=12,
|
|
||||||
num_items_y=2,
|
|
||||||
dx=0,
|
|
||||||
dy=0,
|
|
||||||
dz=0,
|
|
||||||
item_dx=9,
|
|
||||||
item_dy=9,
|
|
||||||
size_x=1,
|
|
||||||
size_y=1,
|
|
||||||
make_tip=mk,
|
|
||||||
)
|
|
||||||
return cls(name, 120, 40, 10, items=items)
|
|
||||||
|
|
||||||
rack1 = make_rack(TipTypeAlpha, "rack_phys_1")
|
|
||||||
rack2 = make_rack(TipTypeBeta, "rack_phys_2")
|
|
||||||
rack3 = make_rack(TipTypeAlpha, "rack_phys_3")
|
|
||||||
|
|
||||||
lh = LiquidHandlerAbstract(
|
|
||||||
LiquidHandlerChatterboxBackend(1), Deck(), channel_num=1, simulator=False
|
|
||||||
)
|
|
||||||
flat1 = lh._flatten_tips_from_one(rack1)
|
|
||||||
assert len(flat1) == 24
|
|
||||||
|
|
||||||
lh.set_tiprack([rack1])
|
|
||||||
for i in range(12):
|
|
||||||
assert lh._get_next_tip() is flat1[i]
|
|
||||||
|
|
||||||
lh.set_tiprack([rack2])
|
|
||||||
spot_b = lh._get_next_tip()
|
|
||||||
assert "rack_phys_2" in spot_b.name
|
|
||||||
|
|
||||||
lh.set_tiprack([rack3])
|
|
||||||
spot_resume = lh._get_next_tip()
|
|
||||||
assert spot_resume is flat1[12], "第三次同型号应接续 rack1 第二行首孔,而非 rack3 首孔"
|
|
||||||
assert spot_resume is not lh._flatten_tips_from_one(rack3)[0]
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1 +1 @@
|
|||||||
__version__ = "0.10.19"
|
__version__ = "0.10.18"
|
||||||
|
|||||||
6
unilabos/__main__.py
Normal file
6
unilabos/__main__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""Entry point for `python -m unilabos`."""
|
||||||
|
|
||||||
|
from unilabos.app.main import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import platform
|
|
||||||
import shutil
|
import shutil
|
||||||
import signal
|
import signal
|
||||||
import subprocess
|
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -26,84 +24,6 @@ from unilabos.config.config import load_config, BasicConfig, HTTPConfig
|
|||||||
_restart_requested: bool = False
|
_restart_requested: bool = False
|
||||||
_restart_reason: str = ""
|
_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):
|
def load_config_from_file(config_path):
|
||||||
if config_path is None:
|
if config_path is None:
|
||||||
@@ -145,13 +65,6 @@ def parse_args():
|
|||||||
action="append",
|
action="append",
|
||||||
help="Path to the registry directory",
|
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(
|
parser.add_argument(
|
||||||
"--working_dir",
|
"--working_dir",
|
||||||
type=str,
|
type=str,
|
||||||
@@ -241,18 +154,18 @@ def parse_args():
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Skip environment dependency check on startup",
|
help="Skip environment dependency check on startup",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--complete_registry",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help="Complete registry information",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--check_mode",
|
"--check_mode",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
default=False,
|
default=False,
|
||||||
help="Run in check mode for CI: validates registry imports and ensures no file changes",
|
help="Run in check mode for CI: validates registry imports and ensures no file changes",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
|
||||||
"--complete_registry",
|
|
||||||
action="store_true",
|
|
||||||
default=False,
|
|
||||||
help="Complete and rewrite YAML registry files using AST analysis results",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--no_update_feedback",
|
"--no_update_feedback",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
@@ -264,30 +177,6 @@ def parse_args():
|
|||||||
default=False,
|
default=False,
|
||||||
help="Test mode: all actions simulate execution and return mock results without running real hardware",
|
help="Test mode: all actions simulate execution and return mock results without running real hardware",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
|
||||||
"--external_devices_only",
|
|
||||||
action="store_true",
|
|
||||||
default=False,
|
|
||||||
help="Only load external device packages (--devices), skip built-in unilabos/devices/ scanning and YAML device registry",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--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 upload subcommand
|
||||||
workflow_parser = subparsers.add_parser(
|
workflow_parser = subparsers.add_parser(
|
||||||
"workflow_upload",
|
"workflow_upload",
|
||||||
@@ -338,28 +227,16 @@ def main():
|
|||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
args_dict = vars(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)
|
skip_env_check = args_dict.get("skip_env_check", False)
|
||||||
check_mode = args_dict.get("check_mode", False)
|
check_mode = args_dict.get("check_mode", False)
|
||||||
|
|
||||||
if not skip_env_check:
|
if not skip_env_check:
|
||||||
from unilabos.utils.environment_check import check_environment, check_device_package_requirements
|
from unilabos.utils.environment_check import check_environment
|
||||||
|
|
||||||
if not check_environment(auto_install=True):
|
if not check_environment(auto_install=True):
|
||||||
print_status("环境检查失败,程序退出", "error")
|
print_status("环境检查失败,程序退出", "error")
|
||||||
os._exit(1)
|
os._exit(1)
|
||||||
|
|
||||||
# 第一次设备包依赖检查:build_registry 之前,确保 import map 可用
|
|
||||||
devices_dirs_for_req = args_dict.get("devices", None)
|
|
||||||
if devices_dirs_for_req:
|
|
||||||
if not check_device_package_requirements(devices_dirs_for_req):
|
|
||||||
print_status("设备包依赖检查失败,程序退出", "error")
|
|
||||||
os._exit(1)
|
|
||||||
else:
|
else:
|
||||||
print_status("跳过环境依赖检查", "warning")
|
print_status("跳过环境依赖检查", "warning")
|
||||||
|
|
||||||
@@ -480,63 +357,46 @@ def main():
|
|||||||
BasicConfig.test_mode = args_dict.get("test_mode", False)
|
BasicConfig.test_mode = args_dict.get("test_mode", False)
|
||||||
if BasicConfig.test_mode:
|
if BasicConfig.test_mode:
|
||||||
print_status("启用测试模式:所有动作将模拟执行,不调用真实硬件", "warning")
|
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"
|
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])
|
machine_name = "".join([c if c.isalnum() or c == "_" else "_" for c in machine_name])
|
||||||
BasicConfig.machine_name = machine_name
|
BasicConfig.machine_name = machine_name
|
||||||
BasicConfig.vis_2d_enable = args_dict["2d_vis"]
|
BasicConfig.vis_2d_enable = args_dict["2d_vis"]
|
||||||
BasicConfig.check_mode = check_mode
|
BasicConfig.check_mode = check_mode
|
||||||
|
|
||||||
from unilabos.registry.registry import build_registry
|
|
||||||
|
|
||||||
# 显示启动横幅
|
|
||||||
print_unilab_banner(args_dict)
|
|
||||||
|
|
||||||
# Step 0: AST 分析优先 + YAML 注册表加载
|
|
||||||
# check_mode 和 upload_registry 都会执行实际 import 验证
|
|
||||||
devices_dirs = args_dict.get("devices", None)
|
|
||||||
complete_registry = args_dict.get("complete_registry", False) or check_mode
|
|
||||||
external_only = args_dict.get("external_devices_only", False)
|
|
||||||
lab_registry = build_registry(
|
|
||||||
registry_paths=args_dict["registry_path"],
|
|
||||||
devices_dirs=devices_dirs,
|
|
||||||
upload_registry=BasicConfig.upload_registry,
|
|
||||||
check_mode=check_mode,
|
|
||||||
complete_registry=complete_registry,
|
|
||||||
external_only=external_only,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check mode: 注册表验证完成后直接退出
|
|
||||||
if check_mode:
|
|
||||||
device_count = len(lab_registry.device_type_registry)
|
|
||||||
resource_count = len(lab_registry.resource_type_registry)
|
|
||||||
print_status(f"Check mode: 注册表验证完成 ({device_count} 设备, {resource_count} 资源),退出", "info")
|
|
||||||
os._exit(0)
|
|
||||||
|
|
||||||
# 以下导入依赖 ROS2 环境,check_mode 已退出不需要
|
|
||||||
from unilabos.resources.graphio import (
|
from unilabos.resources.graphio import (
|
||||||
read_node_link_json,
|
read_node_link_json,
|
||||||
read_graphml,
|
read_graphml,
|
||||||
dict_from_graph,
|
dict_from_graph,
|
||||||
modify_to_backend_format,
|
|
||||||
)
|
)
|
||||||
from unilabos.app.communication import get_communication_client
|
from unilabos.app.communication import get_communication_client
|
||||||
|
from unilabos.registry.registry import build_registry
|
||||||
from unilabos.app.backend import start_backend
|
from unilabos.app.backend import start_backend
|
||||||
from unilabos.app.web import http_client
|
from unilabos.app.web import http_client
|
||||||
from unilabos.app.web import start_server
|
from unilabos.app.web import start_server
|
||||||
from unilabos.app.register import register_devices_and_resources
|
from unilabos.app.register import register_devices_and_resources
|
||||||
|
from unilabos.resources.graphio import modify_to_backend_format
|
||||||
from unilabos.resources.resource_tracker import ResourceTreeSet, ResourceDict
|
from unilabos.resources.resource_tracker import ResourceTreeSet, ResourceDict
|
||||||
|
|
||||||
# Step 1: 上传全部注册表到服务端,同步保存到 unilabos_data
|
# 显示启动横幅
|
||||||
|
print_unilab_banner(args_dict)
|
||||||
|
|
||||||
|
# 注册表 - check_mode 时强制启用 complete_registry
|
||||||
|
complete_registry = args_dict.get("complete_registry", False) or check_mode
|
||||||
|
lab_registry = build_registry(args_dict["registry_path"], complete_registry, BasicConfig.upload_registry)
|
||||||
|
|
||||||
|
# Check mode: complete_registry 完成后直接退出,git diff 检测由 CI workflow 执行
|
||||||
|
if check_mode:
|
||||||
|
print_status("Check mode: complete_registry 完成,退出", "info")
|
||||||
|
os._exit(0)
|
||||||
|
|
||||||
if BasicConfig.upload_registry:
|
if BasicConfig.upload_registry:
|
||||||
|
# 设备注册到服务端 - 需要 ak 和 sk
|
||||||
if BasicConfig.ak and BasicConfig.sk:
|
if BasicConfig.ak and BasicConfig.sk:
|
||||||
# print_status("开始注册设备到服务端...", "info")
|
print_status("开始注册设备到服务端...", "info")
|
||||||
try:
|
try:
|
||||||
register_devices_and_resources(lab_registry)
|
register_devices_and_resources(lab_registry)
|
||||||
# print_status("设备注册完成", "info")
|
print_status("设备注册完成", "info")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print_status(f"设备注册失败: {e}", "error")
|
print_status(f"设备注册失败: {e}", "error")
|
||||||
else:
|
else:
|
||||||
@@ -621,16 +481,12 @@ def main():
|
|||||||
continue
|
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")
|
print_status("开始同步远端物料到本地...", "info")
|
||||||
remote_tree_set = ResourceTreeSet.from_raw_dict_list(request_startup_json["nodes"])
|
remote_tree_set = ResourceTreeSet.from_raw_dict_list(request_startup_json["nodes"])
|
||||||
resource_tree_set.merge_remote_resources(remote_tree_set)
|
resource_tree_set.merge_remote_resources(remote_tree_set)
|
||||||
print_status("远端物料同步完成", "info")
|
print_status("远端物料同步完成", "info")
|
||||||
|
|
||||||
# 第二次设备包依赖检查:云端物料同步后,community 包可能引入新的 requirements
|
|
||||||
# TODO: 当 community device package 功能上线后,在这里调用
|
|
||||||
# install_requirements_txt(community_pkg_path / "requirements.txt", label="community.xxx")
|
|
||||||
|
|
||||||
# 使用 ResourceTreeSet 代替 list
|
# 使用 ResourceTreeSet 代替 list
|
||||||
args_dict["resources_config"] = resource_tree_set
|
args_dict["resources_config"] = resource_tree_set
|
||||||
args_dict["devices_config"] = resource_tree_set
|
args_dict["devices_config"] = resource_tree_set
|
||||||
@@ -722,10 +578,6 @@ def main():
|
|||||||
open_browser=not args_dict["disable_browser"],
|
open_browser=not args_dict["disable_browser"],
|
||||||
port=BasicConfig.port,
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import json
|
||||||
import time
|
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.log import logger
|
||||||
from unilabos.utils.tools import normalize_json as _normalize_device
|
from unilabos.utils.type_check import TypeEncoder
|
||||||
|
|
||||||
|
|
||||||
def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[Tuple[Dict[str, Any], Dict[str, Any]]]:
|
def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[Tuple[Dict[str, Any], Dict[str, Any]]]:
|
||||||
@@ -10,63 +11,50 @@ def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[
|
|||||||
注册设备和资源到服务器(仅支持HTTP)
|
注册设备和资源到服务器(仅支持HTTP)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# 注册资源信息 - 使用HTTP方式
|
||||||
from unilabos.app.web.client import http_client
|
from unilabos.app.web.client import http_client
|
||||||
|
|
||||||
logger.info("[UniLab Register] 开始注册设备和资源...")
|
logger.info("[UniLab Register] 开始注册设备和资源...")
|
||||||
|
|
||||||
|
# 注册设备信息
|
||||||
devices_to_register = {}
|
devices_to_register = {}
|
||||||
for device_info in lab_registry.obtain_registry_device_info():
|
for device_info in lab_registry.obtain_registry_device_info():
|
||||||
devices_to_register[device_info["id"]] = _normalize_device(device_info)
|
devices_to_register[device_info["id"]] = json.loads(
|
||||||
logger.trace(f"[UniLab Register] 收集设备: {device_info['id']}")
|
json.dumps(device_info, ensure_ascii=False, cls=TypeEncoder)
|
||||||
|
)
|
||||||
|
logger.debug(f"[UniLab Register] 收集设备: {device_info['id']}")
|
||||||
|
|
||||||
resources_to_register = {}
|
resources_to_register = {}
|
||||||
for resource_info in lab_registry.obtain_registry_resource_info():
|
for resource_info in lab_registry.obtain_registry_resource_info():
|
||||||
resources_to_register[resource_info["id"]] = 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:
|
if gather_only:
|
||||||
return devices_to_register, resources_to_register
|
return devices_to_register, resources_to_register
|
||||||
|
# 注册设备
|
||||||
if devices_to_register:
|
if devices_to_register:
|
||||||
try:
|
try:
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
response = http_client.resource_registry(
|
response = http_client.resource_registry({"resources": list(devices_to_register.values())})
|
||||||
{"resources": list(devices_to_register.values())},
|
|
||||||
tag="device_registry",
|
|
||||||
)
|
|
||||||
cost_time = time.time() - start_time
|
cost_time = time.time() - start_time
|
||||||
res_data = response.json() if response.status_code == 200 else {}
|
if response.status_code in [200, 201]:
|
||||||
skipped = res_data.get("data", {}).get("skipped", False)
|
logger.info(f"[UniLab Register] 成功注册 {len(devices_to_register)} 个设备 {cost_time}s")
|
||||||
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")
|
|
||||||
else:
|
else:
|
||||||
logger.error(f"[UniLab Register] 设备注册失败: {response.status_code}, {response.text} {cost_time:.3f}s")
|
logger.error(f"[UniLab Register] 设备注册失败: {response.status_code}, {response.text} {cost_time}s")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[UniLab Register] 设备注册异常: {e}")
|
logger.error(f"[UniLab Register] 设备注册异常: {e}")
|
||||||
|
|
||||||
|
# 注册资源
|
||||||
if resources_to_register:
|
if resources_to_register:
|
||||||
try:
|
try:
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
response = http_client.resource_registry(
|
response = http_client.resource_registry({"resources": list(resources_to_register.values())})
|
||||||
{"resources": list(resources_to_register.values())},
|
|
||||||
tag="resource_registry",
|
|
||||||
)
|
|
||||||
cost_time = time.time() - start_time
|
cost_time = time.time() - start_time
|
||||||
res_data = response.json() if response.status_code == 200 else {}
|
if response.status_code in [200, 201]:
|
||||||
skipped = res_data.get("data", {}).get("skipped", False)
|
logger.info(f"[UniLab Register] 成功注册 {len(resources_to_register)} 个资源 {cost_time}s")
|
||||||
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")
|
|
||||||
else:
|
else:
|
||||||
logger.error(f"[UniLab Register] 资源注册失败: {response.status_code}, {response.text} {cost_time:.3f}s")
|
logger.error(f"[UniLab Register] 资源注册失败: {response.status_code}, {response.text} {cost_time}s")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[UniLab Register] 资源注册异常: {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": {},
|
"result": {},
|
||||||
"schema": lab_registry._generate_unilab_json_command_schema(v["args"], k),
|
"schema": lab_registry._generate_unilab_json_command_schema(v["args"], k),
|
||||||
"goal_default": {i["name"]: i["default"] for i in v["args"]},
|
"goal_default": {i["name"]: i["default"] for i in v["args"]},
|
||||||
"handles": {},
|
"handles": [],
|
||||||
}
|
}
|
||||||
# 不生成已配置action的动作
|
# 不生成已配置action的动作
|
||||||
for k, v in enhanced_info["action_methods"].items()
|
for k, v in enhanced_info["action_methods"].items()
|
||||||
@@ -1340,5 +1340,5 @@ def setup_api_routes(app):
|
|||||||
# 启动广播任务
|
# 启动广播任务
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup_event():
|
async def startup_event():
|
||||||
asyncio.create_task(broadcast_device_status(), name="web-api-startup-device")
|
asyncio.create_task(broadcast_device_status())
|
||||||
asyncio.create_task(broadcast_status_page_data(), name="web-api-startup-status")
|
asyncio.create_task(broadcast_status_page_data())
|
||||||
|
|||||||
@@ -3,13 +3,11 @@ HTTP客户端模块
|
|||||||
|
|
||||||
提供与远程服务器通信的客户端功能,只有host需要用
|
提供与远程服务器通信的客户端功能,只有host需要用
|
||||||
"""
|
"""
|
||||||
import gzip
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
|
|
||||||
from unilabos.utils.tools import fast_dumps as _fast_dumps, fast_dumps_pretty as _fast_dumps_pretty
|
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from unilabos.resources.resource_tracker import ResourceTreeSet
|
from unilabos.resources.resource_tracker import ResourceTreeSet
|
||||||
from unilabos.utils.log import info
|
from unilabos.utils.log import info
|
||||||
@@ -80,20 +78,19 @@ class HTTPClient:
|
|||||||
f.write(json.dumps(payload, indent=4))
|
f.write(json.dumps(payload, indent=4))
|
||||||
# 从序列化数据中提取所有节点的UUID(保存旧UUID)
|
# 从序列化数据中提取所有节点的UUID(保存旧UUID)
|
||||||
old_uuids = {n.res_content.uuid: n for n in resources.all_nodes}
|
old_uuids = {n.res_content.uuid: n for n in resources.all_nodes}
|
||||||
nodes_info = [x for xs in resources.dump() for x in xs]
|
|
||||||
if not self.initialized or first_add:
|
if not self.initialized or first_add:
|
||||||
self.initialized = True
|
self.initialized = True
|
||||||
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
|
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"{self.remote_addr}/edge/material",
|
f"{self.remote_addr}/edge/material",
|
||||||
json={"nodes": nodes_info, "mount_uuid": mount_uuid},
|
json={"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid},
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
headers={"Authorization": f"Lab {self.auth}"},
|
||||||
timeout=60,
|
timeout=60,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
response = requests.put(
|
response = requests.put(
|
||||||
f"{self.remote_addr}/edge/material",
|
f"{self.remote_addr}/edge/material",
|
||||||
json={"nodes": nodes_info, "mount_uuid": mount_uuid},
|
json={"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid},
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
headers={"Authorization": f"Lab {self.auth}"},
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
@@ -112,7 +109,6 @@ class HTTPClient:
|
|||||||
uuid_mapping[i["uuid"]] = i["cloud_uuid"]
|
uuid_mapping[i["uuid"]] = i["cloud_uuid"]
|
||||||
else:
|
else:
|
||||||
logger.error(f"添加物料失败: {response.text}")
|
logger.error(f"添加物料失败: {response.text}")
|
||||||
logger.trace(f"添加物料失败: {nodes_info}")
|
|
||||||
for u, n in old_uuids.items():
|
for u, n in old_uuids.items():
|
||||||
if u in uuid_mapping:
|
if u in uuid_mapping:
|
||||||
n.res_content.uuid = uuid_mapping[u]
|
n.res_content.uuid = uuid_mapping[u]
|
||||||
@@ -284,54 +280,22 @@ class HTTPClient:
|
|||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def resource_registry(
|
def resource_registry(self, registry_data: Dict[str, Any] | List[Dict[str, Any]]) -> requests.Response:
|
||||||
self, registry_data: Dict[str, Any] | List[Dict[str, Any]], tag: str = "registry",
|
|
||||||
) -> requests.Response:
|
|
||||||
"""
|
"""
|
||||||
注册资源到服务器,同步保存请求/响应到 unilabos_data
|
注册资源到服务器
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
registry_data: 注册表数据,格式为 {resource_id: resource_info} / [{resource_info}]
|
registry_data: 注册表数据,格式为 {resource_id: resource_info} / [{resource_info}]
|
||||||
tag: 保存文件的标签后缀 (如 "device_registry" / "resource_registry")
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Response: API响应对象
|
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(
|
response = requests.post(
|
||||||
f"{self.remote_addr}/lab/resource",
|
f"{self.remote_addr}/lab/resource",
|
||||||
data=compressed_body,
|
json=registry_data,
|
||||||
headers=headers,
|
headers={"Authorization": f"Lab {self.auth}"},
|
||||||
timeout=30,
|
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]:
|
if response.status_code not in [200, 201]:
|
||||||
logger.error(f"注册资源失败: {response.status_code}, {response.text}")
|
logger.error(f"注册资源失败: {response.status_code}, {response.text}")
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
|
|||||||
@@ -58,14 +58,14 @@ class JobResultStore:
|
|||||||
feedback=feedback or {},
|
feedback=feedback or {},
|
||||||
timestamp=time.time(),
|
timestamp=time.time(),
|
||||||
)
|
)
|
||||||
logger.debug(f"[JobResultStore] Stored result for job {job_id[:8]}, status={status}")
|
logger.trace(f"[JobResultStore] Stored result for job {job_id[:8]}, status={status}")
|
||||||
|
|
||||||
def get_and_remove(self, job_id: str) -> Optional[JobResult]:
|
def get_and_remove(self, job_id: str) -> Optional[JobResult]:
|
||||||
"""获取并删除任务结果"""
|
"""获取并删除任务结果"""
|
||||||
with self._results_lock:
|
with self._results_lock:
|
||||||
result = self._results.pop(job_id, None)
|
result = self._results.pop(job_id, None)
|
||||||
if result:
|
if result:
|
||||||
logger.debug(f"[JobResultStore] Retrieved and removed result for job {job_id[:8]}")
|
logger.trace(f"[JobResultStore] Retrieved and removed result for job {job_id[:8]}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def get_result(self, job_id: str) -> Optional[JobResult]:
|
def get_result(self, job_id: str) -> Optional[JobResult]:
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ def setup_server() -> FastAPI:
|
|||||||
# 设置页面路由
|
# 设置页面路由
|
||||||
try:
|
try:
|
||||||
setup_web_pages(pages)
|
setup_web_pages(pages)
|
||||||
# info("[Web] 已加载Web UI模块")
|
info("[Web] 已加载Web UI模块")
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
info(f"[Web] 未找到Web页面模块: {str(e)}")
|
info(f"[Web] 未找到Web页面模块: {str(e)}")
|
||||||
except Exception as 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 = threading.Thread(target=server.run, daemon=True, name="uvicorn_server")
|
||||||
server_thread.start()
|
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
|
import unilabos.app.main as main_module
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ from enum import Enum
|
|||||||
from typing_extensions import TypedDict
|
from typing_extensions import TypedDict
|
||||||
|
|
||||||
from unilabos.app.model import JobAddReq
|
from unilabos.app.model import JobAddReq
|
||||||
from unilabos.resources.resource_tracker import ResourceDictType
|
|
||||||
from unilabos.ros.nodes.presets.host_node import HostNode
|
from unilabos.ros.nodes.presets.host_node import HostNode
|
||||||
from unilabos.utils.type_check import serialize_result_info
|
from unilabos.utils.type_check import serialize_result_info
|
||||||
from unilabos.app.communication import BaseCommunicationClient
|
from unilabos.app.communication import BaseCommunicationClient
|
||||||
@@ -409,7 +408,6 @@ class MessageProcessor:
|
|||||||
# 线程控制
|
# 线程控制
|
||||||
self.is_running = False
|
self.is_running = False
|
||||||
self.thread = None
|
self.thread = None
|
||||||
self._loop = None # asyncio event loop引用,用于外部关闭websocket
|
|
||||||
self.reconnect_count = 0
|
self.reconnect_count = 0
|
||||||
|
|
||||||
logger.info(f"[MessageProcessor] Initialized for URL: {websocket_url}")
|
logger.info(f"[MessageProcessor] Initialized for URL: {websocket_url}")
|
||||||
@@ -436,31 +434,22 @@ class MessageProcessor:
|
|||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
"""停止消息处理线程"""
|
"""停止消息处理线程"""
|
||||||
self.is_running = False
|
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():
|
if self.thread and self.thread.is_alive():
|
||||||
self.thread.join(timeout=2)
|
self.thread.join(timeout=2)
|
||||||
logger.info("[MessageProcessor] Stopped")
|
logger.info("[MessageProcessor] Stopped")
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
"""运行消息处理主循环"""
|
"""运行消息处理主循环"""
|
||||||
self._loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
try:
|
try:
|
||||||
asyncio.set_event_loop(self._loop)
|
asyncio.set_event_loop(loop)
|
||||||
self._loop.run_until_complete(self._connection_handler())
|
loop.run_until_complete(self._connection_handler())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[MessageProcessor] Thread error: {str(e)}")
|
logger.error(f"[MessageProcessor] Thread error: {str(e)}")
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
finally:
|
finally:
|
||||||
if self._loop:
|
if loop:
|
||||||
self._loop.close()
|
loop.close()
|
||||||
self._loop = None
|
|
||||||
|
|
||||||
async def _connection_handler(self):
|
async def _connection_handler(self):
|
||||||
"""处理WebSocket连接和重连逻辑"""
|
"""处理WebSocket连接和重连逻辑"""
|
||||||
@@ -477,10 +466,8 @@ class MessageProcessor:
|
|||||||
async with websockets.connect(
|
async with websockets.connect(
|
||||||
self.websocket_url,
|
self.websocket_url,
|
||||||
ssl=ssl_context,
|
ssl=ssl_context,
|
||||||
open_timeout=20,
|
|
||||||
ping_interval=WSConfig.ping_interval,
|
ping_interval=WSConfig.ping_interval,
|
||||||
ping_timeout=10,
|
ping_timeout=10,
|
||||||
close_timeout=5,
|
|
||||||
additional_headers={
|
additional_headers={
|
||||||
"Authorization": f"Lab {BasicConfig.auth_secret()}",
|
"Authorization": f"Lab {BasicConfig.auth_secret()}",
|
||||||
"EdgeSession": f"{self.session_id}",
|
"EdgeSession": f"{self.session_id}",
|
||||||
@@ -491,72 +478,53 @@ class MessageProcessor:
|
|||||||
self.connected = True
|
self.connected = True
|
||||||
self.reconnect_count = 0
|
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")
|
send_task = asyncio.create_task(self._send_handler())
|
||||||
|
|
||||||
# 每次连接(含重连)后重新向服务端注册,
|
|
||||||
# 否则服务端不知道客户端已上线,不会推送消息。
|
|
||||||
if self.websocket_client:
|
|
||||||
self.websocket_client.publish_host_ready()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 接收消息循环
|
# 接收消息循环
|
||||||
await self._message_handler()
|
await self._message_handler()
|
||||||
finally:
|
finally:
|
||||||
# 必须在 async with __aexit__ 之前停止 send_task,
|
|
||||||
# 否则 send_task 会在关闭握手期间继续发送数据,
|
|
||||||
# 干扰 websockets 库的内部清理,导致 task 泄漏。
|
|
||||||
self.connected = False
|
|
||||||
send_task.cancel()
|
send_task.cancel()
|
||||||
try:
|
try:
|
||||||
await send_task
|
await send_task
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
|
self.connected = False
|
||||||
|
|
||||||
except websockets.exceptions.ConnectionClosed:
|
except websockets.exceptions.ConnectionClosed:
|
||||||
logger.warning("[MessageProcessor] 与服务端连接中断")
|
logger.warning("[MessageProcessor] Connection closed")
|
||||||
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:
|
|
||||||
self.connected = False
|
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
|
self.websocket = None
|
||||||
|
|
||||||
# 重连逻辑
|
# 重连逻辑
|
||||||
if not self.is_running:
|
if self.is_running and self.reconnect_count < WSConfig.max_reconnect_attempts:
|
||||||
break
|
|
||||||
if self.reconnect_count < WSConfig.max_reconnect_attempts:
|
|
||||||
self.reconnect_count += 1
|
self.reconnect_count += 1
|
||||||
backoff = WSConfig.reconnect_interval
|
|
||||||
logger.info(
|
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)
|
await asyncio.sleep(WSConfig.reconnect_interval)
|
||||||
else:
|
elif self.reconnect_count >= WSConfig.max_reconnect_attempts:
|
||||||
logger.error("[MessageProcessor] Max reconnection attempts reached")
|
logger.error("[MessageProcessor] Max reconnection attempts reached")
|
||||||
break
|
break
|
||||||
|
else:
|
||||||
|
self.reconnect_count -= 1
|
||||||
|
|
||||||
async def _message_handler(self):
|
async def _message_handler(self):
|
||||||
"""处理接收到的消息。
|
"""处理接收到的消息"""
|
||||||
|
|
||||||
ConnectionClosed 不在此处捕获,让其向上传播到 _connection_handler,
|
|
||||||
以便 async with websockets.connect() 的 __aexit__ 能感知连接已断,
|
|
||||||
正确清理内部 task,避免 task 泄漏。
|
|
||||||
"""
|
|
||||||
if not self.websocket:
|
if not self.websocket:
|
||||||
logger.error("[MessageProcessor] WebSocket connection is None")
|
logger.error("[MessageProcessor] WebSocket connection is None")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
async for message in self.websocket:
|
async for message in self.websocket:
|
||||||
try:
|
try:
|
||||||
data = json.loads(message)
|
data = json.loads(message)
|
||||||
@@ -580,9 +548,15 @@ class MessageProcessor:
|
|||||||
logger.error(f"[MessageProcessor] Error processing message: {str(e)}")
|
logger.error(f"[MessageProcessor] Error processing message: {str(e)}")
|
||||||
logger.error(traceback.format_exc())
|
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):
|
async def _send_handler(self):
|
||||||
"""处理发送队列中的消息"""
|
"""处理发送队列中的消息"""
|
||||||
logger.trace("[MessageProcessor] Send handler started")
|
logger.debug("[MessageProcessor] Send handler started")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while self.connected and self.websocket:
|
while self.connected and self.websocket:
|
||||||
@@ -627,7 +601,6 @@ class MessageProcessor:
|
|||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
logger.debug("[MessageProcessor] Send handler cancelled")
|
logger.debug("[MessageProcessor] Send handler cancelled")
|
||||||
raise
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[MessageProcessor] Fatal error in send handler: {str(e)}")
|
logger.error(f"[MessageProcessor] Fatal error in send handler: {str(e)}")
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
@@ -659,10 +632,6 @@ class MessageProcessor:
|
|||||||
# elif message_type == "session_id":
|
# elif message_type == "session_id":
|
||||||
# self.session_id = message_data.get("session_id")
|
# self.session_id = message_data.get("session_id")
|
||||||
# logger.info(f"[MessageProcessor] Session ID: {self.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":
|
elif message_type == "request_restart":
|
||||||
await self._handle_request_restart(message_data)
|
await self._handle_request_restart(message_data)
|
||||||
else:
|
else:
|
||||||
@@ -754,32 +723,6 @@ class MessageProcessor:
|
|||||||
req = JobAddReq(**data)
|
req = JobAddReq(**data)
|
||||||
|
|
||||||
job_log = format_job_log(req.job_id, req.task_id, req.device_id, req.action)
|
job_log = format_job_log(req.job_id, req.task_id, req.device_id, req.action)
|
||||||
|
|
||||||
# 服务端对always_free动作可能跳过query_action_state直接发job_start,
|
|
||||||
# 此时job尚未注册,需要自动补注册
|
|
||||||
existing_job = self.device_manager.get_job_info(req.job_id)
|
|
||||||
if not existing_job:
|
|
||||||
action_name = req.action
|
|
||||||
device_action_key = f"/devices/{req.device_id}/{action_name}"
|
|
||||||
action_always_free = self._check_action_always_free(req.device_id, action_name)
|
|
||||||
|
|
||||||
if action_always_free:
|
|
||||||
job_info = JobInfo(
|
|
||||||
job_id=req.job_id,
|
|
||||||
task_id=req.task_id,
|
|
||||||
device_id=req.device_id,
|
|
||||||
action_name=action_name,
|
|
||||||
device_action_key=device_action_key,
|
|
||||||
status=JobStatus.QUEUE,
|
|
||||||
start_time=time.time(),
|
|
||||||
always_free=True,
|
|
||||||
)
|
|
||||||
self.device_manager.add_queue_request(job_info)
|
|
||||||
logger.info(f"[MessageProcessor] Job {job_log} always_free, auto-registered from direct job_start")
|
|
||||||
else:
|
|
||||||
logger.error(f"[MessageProcessor] Job {job_log} not registered (missing query_action_state)")
|
|
||||||
return
|
|
||||||
|
|
||||||
success = self.device_manager.start_job(req.job_id)
|
success = self.device_manager.start_job(req.job_id)
|
||||||
if not success:
|
if not success:
|
||||||
logger.error(f"[MessageProcessor] Failed to start job {job_log}")
|
logger.error(f"[MessageProcessor] Failed to start job {job_log}")
|
||||||
@@ -1025,37 +968,6 @@ class MessageProcessor:
|
|||||||
)
|
)
|
||||||
thread.start()
|
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]):
|
async def _handle_request_restart(self, data: Dict[str, Any]):
|
||||||
"""
|
"""
|
||||||
处理重启请求
|
处理重启请求
|
||||||
@@ -1067,7 +979,8 @@ class MessageProcessor:
|
|||||||
logger.info(f"[MessageProcessor] Received restart request, reason: {reason}, delay: {delay}s")
|
logger.info(f"[MessageProcessor] Received restart request, reason: {reason}, delay: {delay}s")
|
||||||
|
|
||||||
# 发送确认消息
|
# 发送确认消息
|
||||||
self.send_message(
|
if self.websocket_client:
|
||||||
|
await self.websocket_client.send_message(
|
||||||
{"action": "restart_acknowledged", "data": {"reason": reason, "delay": delay}}
|
{"action": "restart_acknowledged", "data": {"reason": reason, "delay": delay}}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1113,7 +1026,7 @@ class MessageProcessor:
|
|||||||
"task_id": task_id,
|
"task_id": task_id,
|
||||||
"job_id": job_id,
|
"job_id": job_id,
|
||||||
"free": free,
|
"free": free,
|
||||||
"need_more": need_more + 1,
|
"need_more": need_more,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1171,14 +1084,13 @@ class QueueProcessor:
|
|||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
"""停止队列处理线程"""
|
"""停止队列处理线程"""
|
||||||
self.is_running = False
|
self.is_running = False
|
||||||
self.queue_update_event.set() # 立即唤醒等待中的线程
|
|
||||||
if self.thread and self.thread.is_alive():
|
if self.thread and self.thread.is_alive():
|
||||||
self.thread.join(timeout=2)
|
self.thread.join(timeout=2)
|
||||||
logger.info("[QueueProcessor] Stopped")
|
logger.info("[QueueProcessor] Stopped")
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
"""运行队列处理主循环"""
|
"""运行队列处理主循环"""
|
||||||
logger.trace("[QueueProcessor] Queue processor started")
|
logger.debug("[QueueProcessor] Queue processor started")
|
||||||
|
|
||||||
while self.is_running:
|
while self.is_running:
|
||||||
try:
|
try:
|
||||||
@@ -1253,7 +1165,7 @@ class QueueProcessor:
|
|||||||
"task_id": job_info.task_id,
|
"task_id": job_info.task_id,
|
||||||
"job_id": job_info.job_id,
|
"job_id": job_info.job_id,
|
||||||
"free": False,
|
"free": False,
|
||||||
"need_more": 10 + 1,
|
"need_more": 10,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
self.message_processor.send_message(message)
|
self.message_processor.send_message(message)
|
||||||
@@ -1286,7 +1198,7 @@ class QueueProcessor:
|
|||||||
"task_id": job_info.task_id,
|
"task_id": job_info.task_id,
|
||||||
"job_id": job_info.job_id,
|
"job_id": job_info.job_id,
|
||||||
"free": False,
|
"free": False,
|
||||||
"need_more": 10 + 1,
|
"need_more": 10,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
success = self.message_processor.send_message(message)
|
success = self.message_processor.send_message(message)
|
||||||
@@ -1369,10 +1281,6 @@ class WebSocketClient(BaseCommunicationClient):
|
|||||||
self.message_processor = MessageProcessor(self.websocket_url, self.send_queue, self.device_manager)
|
self.message_processor = MessageProcessor(self.websocket_url, self.send_queue, self.device_manager)
|
||||||
self.queue_processor = QueueProcessor(self.device_manager, self.message_processor)
|
self.queue_processor = QueueProcessor(self.device_manager, self.message_processor)
|
||||||
|
|
||||||
# running状态debounce缓存: {job_id: (last_send_timestamp, last_feedback_data)}
|
|
||||||
self._job_running_last_sent: Dict[str, tuple] = {}
|
|
||||||
self._job_running_debounce_interval: float = 10.0 # 秒
|
|
||||||
|
|
||||||
# 设置相互引用
|
# 设置相互引用
|
||||||
self.message_processor.set_queue_processor(self.queue_processor)
|
self.message_processor.set_queue_processor(self.queue_processor)
|
||||||
self.message_processor.set_websocket_client(self)
|
self.message_processor.set_websocket_client(self)
|
||||||
@@ -1397,6 +1305,7 @@ class WebSocketClient(BaseCommunicationClient):
|
|||||||
else:
|
else:
|
||||||
url = f"{scheme}://{parsed.netloc}/api/v1/ws/schedule"
|
url = f"{scheme}://{parsed.netloc}/api/v1/ws/schedule"
|
||||||
|
|
||||||
|
logger.debug(f"[WebSocketClient] URL: {url}")
|
||||||
return url
|
return url
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
@@ -1409,11 +1318,13 @@ class WebSocketClient(BaseCommunicationClient):
|
|||||||
logger.error("[WebSocketClient] WebSocket URL not configured")
|
logger.error("[WebSocketClient] WebSocket URL not configured")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
logger.info(f"[WebSocketClient] Starting connection to {self.websocket_url}")
|
||||||
|
|
||||||
# 启动两个核心线程
|
# 启动两个核心线程
|
||||||
self.message_processor.start()
|
self.message_processor.start()
|
||||||
self.queue_processor.start()
|
self.queue_processor.start()
|
||||||
|
|
||||||
logger.trace("[WebSocketClient] All threads started")
|
logger.info("[WebSocketClient] All threads started")
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
"""停止WebSocket客户端"""
|
"""停止WebSocket客户端"""
|
||||||
@@ -1429,8 +1340,8 @@ class WebSocketClient(BaseCommunicationClient):
|
|||||||
message = {"action": "normal_exit", "data": {"session_id": session_id}}
|
message = {"action": "normal_exit", "data": {"session_id": session_id}}
|
||||||
self.message_processor.send_message(message)
|
self.message_processor.send_message(message)
|
||||||
logger.info(f"[WebSocketClient] Sent normal_exit message with session_id: {session_id}")
|
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:
|
except Exception as e:
|
||||||
logger.warning(f"[WebSocketClient] Failed to send normal_exit message: {str(e)}")
|
logger.warning(f"[WebSocketClient] Failed to send normal_exit message: {str(e)}")
|
||||||
|
|
||||||
@@ -1472,32 +1383,22 @@ class WebSocketClient(BaseCommunicationClient):
|
|||||||
logger.debug(f"[WebSocketClient] Not connected, cannot publish job status for job_id: {item.job_id}")
|
logger.debug(f"[WebSocketClient] Not connected, cannot publish job status for job_id: {item.job_id}")
|
||||||
return
|
return
|
||||||
|
|
||||||
job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name)
|
|
||||||
|
|
||||||
# 拦截最终结果状态,与原版本逻辑一致
|
# 拦截最终结果状态,与原版本逻辑一致
|
||||||
if status in ["success", "failed"]:
|
if status in ["success", "failed"]:
|
||||||
self._job_running_last_sent.pop(item.job_id, None)
|
|
||||||
|
|
||||||
host_node = HostNode.get_instance(0)
|
host_node = HostNode.get_instance(0)
|
||||||
if host_node:
|
if host_node:
|
||||||
|
# 从HostNode的device_action_status中移除job_id
|
||||||
try:
|
try:
|
||||||
host_node._device_action_status[item.device_action_key].job_ids.pop(item.job_id, None)
|
host_node._device_action_status[item.device_action_key].job_ids.pop(item.job_id, None)
|
||||||
except (KeyError, AttributeError):
|
except (KeyError, AttributeError):
|
||||||
logger.warning(f"[WebSocketClient] Failed to remove job {item.job_id} from HostNode status")
|
logger.warning(f"[WebSocketClient] Failed to remove job {item.job_id} from HostNode status")
|
||||||
|
|
||||||
|
# logger.debug(f"[WebSocketClient] Intercepting final status for job_id: {item.job_id} - {status}")
|
||||||
|
|
||||||
|
# 通知队列处理器job完成(包括timeout的job)
|
||||||
self.queue_processor.handle_job_completed(item.job_id, status)
|
self.queue_processor.handle_job_completed(item.job_id, status)
|
||||||
|
|
||||||
# running状态按job_id做debounce,内容变化时仍然上报
|
# 发送job状态消息
|
||||||
if status == "running":
|
|
||||||
now = time.time()
|
|
||||||
cached = self._job_running_last_sent.get(item.job_id)
|
|
||||||
if cached is not None:
|
|
||||||
last_ts, last_data = cached
|
|
||||||
if now - last_ts < self._job_running_debounce_interval and last_data == feedback_data:
|
|
||||||
logger.trace(f"[WebSocketClient] Job status debounced (skip): {job_log} - {status}")
|
|
||||||
return
|
|
||||||
self._job_running_last_sent[item.job_id] = (now, feedback_data)
|
|
||||||
|
|
||||||
message = {
|
message = {
|
||||||
"action": "job_status",
|
"action": "job_status",
|
||||||
"data": {
|
"data": {
|
||||||
@@ -1513,6 +1414,7 @@ class WebSocketClient(BaseCommunicationClient):
|
|||||||
}
|
}
|
||||||
self.message_processor.send_message(message)
|
self.message_processor.send_message(message)
|
||||||
|
|
||||||
|
job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name)
|
||||||
logger.trace(f"[WebSocketClient] Job status published: {job_log} - {status}")
|
logger.trace(f"[WebSocketClient] Job status published: {job_log} - {status}")
|
||||||
|
|
||||||
def send_ping(self, ping_id: str, timestamp: float) -> None:
|
def send_ping(self, ping_id: str, timestamp: float) -> None:
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ class BasicConfig:
|
|||||||
port = 8002 # 本地HTTP服务
|
port = 8002 # 本地HTTP服务
|
||||||
check_mode = False # CI 检查模式,用于验证 registry 导入和文件一致性
|
check_mode = False # CI 检查模式,用于验证 registry 导入和文件一致性
|
||||||
test_mode = False # 测试模式,所有动作不实际执行,返回模拟结果
|
test_mode = False # 测试模式,所有动作不实际执行,返回模拟结果
|
||||||
extra_resource = False # 是否加载lab_开头的额外资源
|
|
||||||
# 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'
|
# 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'
|
||||||
log_level: Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "DEBUG"
|
log_level: Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "DEBUG"
|
||||||
|
|
||||||
@@ -41,7 +40,7 @@ class BasicConfig:
|
|||||||
class WSConfig:
|
class WSConfig:
|
||||||
reconnect_interval = 5 # 重连间隔(秒)
|
reconnect_interval = 5 # 重连间隔(秒)
|
||||||
max_reconnect_attempts = 999 # 最大重连次数
|
max_reconnect_attempts = 999 # 最大重连次数
|
||||||
ping_interval = 20 # ping间隔(秒)
|
ping_interval = 30 # ping间隔(秒)
|
||||||
|
|
||||||
|
|
||||||
# HTTP配置
|
# HTTP配置
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
from abc import abstractmethod
|
from abc import abstractmethod
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
import inspect
|
import inspect
|
||||||
|
|||||||
@@ -201,42 +201,17 @@ class ResourceVisualization:
|
|||||||
self.moveit_controllers_yaml['moveit_simple_controller_manager'][f"{name}_{controller_name}"] = moveit_dict['moveit_simple_controller_manager'][controller_name]
|
self.moveit_controllers_yaml['moveit_simple_controller_manager'][f"{name}_{controller_name}"] = moveit_dict['moveit_simple_controller_manager'][controller_name]
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _ensure_ros2_env() -> dict:
|
|
||||||
"""确保 ROS2 环境变量正确设置,返回可用于子进程的 env dict"""
|
|
||||||
import sys
|
|
||||||
env = dict(os.environ)
|
|
||||||
conda_prefix = os.path.dirname(os.path.dirname(sys.executable))
|
|
||||||
|
|
||||||
if "AMENT_PREFIX_PATH" not in env or not env["AMENT_PREFIX_PATH"].strip():
|
|
||||||
candidate = os.pathsep.join([conda_prefix, os.path.join(conda_prefix, "Library")])
|
|
||||||
env["AMENT_PREFIX_PATH"] = candidate
|
|
||||||
os.environ["AMENT_PREFIX_PATH"] = candidate
|
|
||||||
|
|
||||||
extra_bin_dirs = [
|
|
||||||
os.path.join(conda_prefix, "Library", "bin"),
|
|
||||||
os.path.join(conda_prefix, "Library", "lib"),
|
|
||||||
os.path.join(conda_prefix, "Scripts"),
|
|
||||||
conda_prefix,
|
|
||||||
]
|
|
||||||
current_path = env.get("PATH", "")
|
|
||||||
for d in extra_bin_dirs:
|
|
||||||
if d not in current_path:
|
|
||||||
current_path = d + os.pathsep + current_path
|
|
||||||
env["PATH"] = current_path
|
|
||||||
os.environ["PATH"] = current_path
|
|
||||||
|
|
||||||
return env
|
|
||||||
|
|
||||||
def create_launch_description(self) -> LaunchDescription:
|
def create_launch_description(self) -> LaunchDescription:
|
||||||
"""
|
"""
|
||||||
创建launch描述,包含robot_state_publisher和move_group节点
|
创建launch描述,包含robot_state_publisher和move_group节点
|
||||||
|
|
||||||
|
Args:
|
||||||
|
urdf_str: URDF文本
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
LaunchDescription: launch描述对象
|
LaunchDescription: launch描述对象
|
||||||
"""
|
"""
|
||||||
launch_env = self._ensure_ros2_env()
|
# 检查ROS 2环境变量
|
||||||
|
|
||||||
if "AMENT_PREFIX_PATH" not in os.environ:
|
if "AMENT_PREFIX_PATH" not in os.environ:
|
||||||
raise OSError(
|
raise OSError(
|
||||||
"ROS 2环境未正确设置。需要设置 AMENT_PREFIX_PATH 环境变量。\n"
|
"ROS 2环境未正确设置。需要设置 AMENT_PREFIX_PATH 环境变量。\n"
|
||||||
@@ -315,7 +290,7 @@ class ResourceVisualization:
|
|||||||
{"robot_description": robot_description},
|
{"robot_description": robot_description},
|
||||||
ros2_controllers,
|
ros2_controllers,
|
||||||
],
|
],
|
||||||
env=launch_env,
|
env=dict(os.environ)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
for controller in self.moveit_controllers_yaml['moveit_simple_controller_manager']['controller_names']:
|
for controller in self.moveit_controllers_yaml['moveit_simple_controller_manager']['controller_names']:
|
||||||
@@ -325,7 +300,7 @@ class ResourceVisualization:
|
|||||||
executable="spawner",
|
executable="spawner",
|
||||||
arguments=[f"{controller}", "--controller-manager", f"controller_manager"],
|
arguments=[f"{controller}", "--controller-manager", f"controller_manager"],
|
||||||
output="screen",
|
output="screen",
|
||||||
env=launch_env,
|
env=dict(os.environ)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
controllers.append(
|
controllers.append(
|
||||||
@@ -334,7 +309,7 @@ class ResourceVisualization:
|
|||||||
executable="spawner",
|
executable="spawner",
|
||||||
arguments=["joint_state_broadcaster", "--controller-manager", f"controller_manager"],
|
arguments=["joint_state_broadcaster", "--controller-manager", f"controller_manager"],
|
||||||
output="screen",
|
output="screen",
|
||||||
env=launch_env,
|
env=dict(os.environ)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
for i in controllers:
|
for i in controllers:
|
||||||
@@ -342,6 +317,7 @@ class ResourceVisualization:
|
|||||||
else:
|
else:
|
||||||
ros2_controllers = None
|
ros2_controllers = None
|
||||||
|
|
||||||
|
# 创建robot_state_publisher节点
|
||||||
robot_state_publisher = nd(
|
robot_state_publisher = nd(
|
||||||
package='robot_state_publisher',
|
package='robot_state_publisher',
|
||||||
executable='robot_state_publisher',
|
executable='robot_state_publisher',
|
||||||
@@ -351,8 +327,9 @@ class ResourceVisualization:
|
|||||||
'robot_description': robot_description,
|
'robot_description': robot_description,
|
||||||
'use_sim_time': False
|
'use_sim_time': False
|
||||||
},
|
},
|
||||||
|
# kinematics_dict
|
||||||
],
|
],
|
||||||
env=launch_env,
|
env=dict(os.environ)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -384,7 +361,7 @@ class ResourceVisualization:
|
|||||||
executable='move_group',
|
executable='move_group',
|
||||||
output='screen',
|
output='screen',
|
||||||
parameters=moveit_params,
|
parameters=moveit_params,
|
||||||
env=launch_env,
|
env=dict(os.environ)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -402,11 +379,13 @@ class ResourceVisualization:
|
|||||||
arguments=['-d', f"{str(self.mesh_path)}/view_robot.rviz"],
|
arguments=['-d', f"{str(self.mesh_path)}/view_robot.rviz"],
|
||||||
output='screen',
|
output='screen',
|
||||||
parameters=[
|
parameters=[
|
||||||
{'robot_description_kinematics': kinematics_dict},
|
{'robot_description_kinematics': kinematics_dict,
|
||||||
|
},
|
||||||
robot_description_planning,
|
robot_description_planning,
|
||||||
planning_pipelines,
|
planning_pipelines,
|
||||||
|
|
||||||
],
|
],
|
||||||
env=launch_env,
|
env=dict(os.environ)
|
||||||
)
|
)
|
||||||
self.launch_description.add_action(rviz_node)
|
self.launch_description.add_action(rviz_node)
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,150 +0,0 @@
|
|||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from .prcxi import PRCXI9300ModuleSite
|
|
||||||
|
|
||||||
|
|
||||||
class PRCXI9300FunctionalModule(PRCXI9300ModuleSite):
|
|
||||||
"""
|
|
||||||
PRCXI 9300 功能模块基类(加热/冷却/震荡/加热震荡/磁吸等)。
|
|
||||||
|
|
||||||
设计目标:
|
|
||||||
- 作为一个可以在工作台上拖拽摆放的实体资源(继承自 PRCXI9300ModuleSite -> ItemizedCarrier)。
|
|
||||||
- 顶面存在一个站点(site),可吸附标准板类资源(plate / tip_rack / tube_rack 等)。
|
|
||||||
- 支持注入 `material_info` (UUID 等),并且在 serialize_state 时做安全过滤。
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
size_x: float,
|
|
||||||
size_y: float,
|
|
||||||
size_z: float,
|
|
||||||
module_type: Optional[str] = None,
|
|
||||||
category: str = "module",
|
|
||||||
model: Optional[str] = None,
|
|
||||||
material_info: Optional[Dict[str, Any]] = None,
|
|
||||||
**kwargs: Any,
|
|
||||||
):
|
|
||||||
super().__init__(
|
|
||||||
name=name,
|
|
||||||
size_x=size_x,
|
|
||||||
size_y=size_y,
|
|
||||||
size_z=size_z,
|
|
||||||
material_info=material_info,
|
|
||||||
model=model,
|
|
||||||
category=category,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 记录模块类型(加热 / 冷却 / 震荡 / 加热震荡 / 磁吸)
|
|
||||||
self.module_type = module_type or "generic"
|
|
||||||
|
|
||||||
# 与 PRCXI9300PlateAdapter 一致,使用 _unilabos_state 保存扩展信息
|
|
||||||
if not hasattr(self, "_unilabos_state") or self._unilabos_state is None:
|
|
||||||
self._unilabos_state = {}
|
|
||||||
|
|
||||||
# super().__init__ 已经在有 material_info 时写入 "Material",这里仅确保存在
|
|
||||||
if material_info is not None and "Material" not in self._unilabos_state:
|
|
||||||
self._unilabos_state["Material"] = material_info
|
|
||||||
|
|
||||||
# 额外标记 category 和模块类型,便于前端或上层逻辑区分
|
|
||||||
self._unilabos_state.setdefault("category", category)
|
|
||||||
self._unilabos_state["module_type"] = module_type
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# 具体功能模块定义
|
|
||||||
# 这里的尺寸和 material_info 目前为占位参数,后续可根据实际测量/JSON 配置进行更新。
|
|
||||||
# 顶面站点尺寸与模块外形一致,保证可以吸附标准 96 板/储液槽等。
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
def PRCXI_Heating_Module(name: str) -> PRCXI9300FunctionalModule:
|
|
||||||
"""加热模块(顶面可吸附标准板)。"""
|
|
||||||
return PRCXI9300FunctionalModule(
|
|
||||||
name=name,
|
|
||||||
size_x=127.76,
|
|
||||||
size_y=85.48,
|
|
||||||
size_z=40.0,
|
|
||||||
module_type="heating",
|
|
||||||
model="PRCXI_Heating_Module",
|
|
||||||
material_info={
|
|
||||||
"uuid": "TODO-HEATING-MODULE-UUID",
|
|
||||||
"Code": "HEAT-MOD",
|
|
||||||
"Name": "PRCXI 加热模块",
|
|
||||||
"SupplyType": 3,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def PRCXI_MetalCooling_Module(name: str) -> PRCXI9300FunctionalModule:
|
|
||||||
"""金属冷却模块(顶面可吸附标准板)。"""
|
|
||||||
return PRCXI9300FunctionalModule(
|
|
||||||
name=name,
|
|
||||||
size_x=127.76,
|
|
||||||
size_y=85.48,
|
|
||||||
size_z=40.0,
|
|
||||||
module_type="metal_cooling",
|
|
||||||
model="PRCXI_MetalCooling_Module",
|
|
||||||
material_info={
|
|
||||||
"uuid": "TODO-METAL-COOLING-MODULE-UUID",
|
|
||||||
"Code": "METAL-COOL-MOD",
|
|
||||||
"Name": "PRCXI 金属冷却模块",
|
|
||||||
"SupplyType": 3,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def PRCXI_Shaking_Module(name: str) -> PRCXI9300FunctionalModule:
|
|
||||||
"""震荡模块(顶面可吸附标准板)。"""
|
|
||||||
return PRCXI9300FunctionalModule(
|
|
||||||
name=name,
|
|
||||||
size_x=127.76,
|
|
||||||
size_y=85.48,
|
|
||||||
size_z=50.0,
|
|
||||||
module_type="shaking",
|
|
||||||
model="PRCXI_Shaking_Module",
|
|
||||||
material_info={
|
|
||||||
"uuid": "TODO-SHAKING-MODULE-UUID",
|
|
||||||
"Code": "SHAKE-MOD",
|
|
||||||
"Name": "PRCXI 震荡模块",
|
|
||||||
"SupplyType": 3,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def PRCXI_Heating_Shaking_Module(name: str) -> PRCXI9300FunctionalModule:
|
|
||||||
"""加热震荡模块(顶面可吸附标准板)。"""
|
|
||||||
return PRCXI9300FunctionalModule(
|
|
||||||
name=name,
|
|
||||||
size_x=127.76,
|
|
||||||
size_y=85.48,
|
|
||||||
size_z=55.0,
|
|
||||||
module_type="heating_shaking",
|
|
||||||
model="PRCXI_Heating_Shaking_Module",
|
|
||||||
material_info={
|
|
||||||
"uuid": "TODO-HEATING-SHAKING-MODULE-UUID",
|
|
||||||
"Code": "HEAT-SHAKE-MOD",
|
|
||||||
"Name": "PRCXI 加热震荡模块",
|
|
||||||
"SupplyType": 3,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def PRCXI_Magnetic_Module(name: str) -> PRCXI9300FunctionalModule:
|
|
||||||
"""磁吸模块(顶面可吸附标准板)。"""
|
|
||||||
return PRCXI9300FunctionalModule(
|
|
||||||
name=name,
|
|
||||||
size_x=127.76,
|
|
||||||
size_y=85.48,
|
|
||||||
size_z=30.0,
|
|
||||||
module_type="magnetic",
|
|
||||||
model="PRCXI_Magnetic_Module",
|
|
||||||
material_info={
|
|
||||||
"uuid": "TODO-MAGNETIC-MODULE-UUID",
|
|
||||||
"Code": "MAG-MOD",
|
|
||||||
"Name": "PRCXI 磁吸模块",
|
|
||||||
"SupplyType": 3,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
@@ -59,7 +59,6 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend):
|
|||||||
self.total_height = total_height
|
self.total_height = total_height
|
||||||
self.joint_config = kwargs.get("joint_config", None)
|
self.joint_config = kwargs.get("joint_config", None)
|
||||||
self.lh_device_id = kwargs.get("lh_device_id", "lh_joint_publisher")
|
self.lh_device_id = kwargs.get("lh_device_id", "lh_joint_publisher")
|
||||||
self.simulate_rviz = kwargs.get("simulate_rviz", False)
|
|
||||||
if not rclpy.ok():
|
if not rclpy.ok():
|
||||||
rclpy.init()
|
rclpy.init()
|
||||||
self.joint_state_publisher = None
|
self.joint_state_publisher = None
|
||||||
@@ -70,7 +69,7 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend):
|
|||||||
self.joint_state_publisher = LiquidHandlerJointPublisher(
|
self.joint_state_publisher = LiquidHandlerJointPublisher(
|
||||||
joint_config=self.joint_config,
|
joint_config=self.joint_config,
|
||||||
lh_device_id=self.lh_device_id,
|
lh_device_id=self.lh_device_id,
|
||||||
simulate_rviz=self.simulate_rviz)
|
simulate_rviz=True)
|
||||||
|
|
||||||
# 启动ROS executor
|
# 启动ROS executor
|
||||||
self.executor = rclpy.executors.MultiThreadedExecutor()
|
self.executor = rclpy.executors.MultiThreadedExecutor()
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ class LiquidHandlerJointPublisher(Node):
|
|||||||
while self.resource_action is None:
|
while self.resource_action is None:
|
||||||
self.resource_action = self.check_tf_update_actions()
|
self.resource_action = self.check_tf_update_actions()
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
self.get_logger().info(f'Waiting for TfUpdate server: {self.resource_action}')
|
|
||||||
|
|
||||||
self.resource_action_client = ActionClient(self, SendCmd, self.resource_action)
|
self.resource_action_client = ActionClient(self, SendCmd, self.resource_action)
|
||||||
while not self.resource_action_client.wait_for_server(timeout_sec=1.0):
|
while not self.resource_action_client.wait_for_server(timeout_sec=1.0):
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import json
|
|||||||
import time
|
import time
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Sequence
|
|
||||||
|
|
||||||
from moveit_msgs.msg import JointConstraint, Constraints
|
from moveit_msgs.msg import JointConstraint, Constraints
|
||||||
from rclpy.action import ActionClient
|
from rclpy.action import ActionClient
|
||||||
@@ -172,61 +171,65 @@ class MoveitInterface:
|
|||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def pick_and_place(
|
def pick_and_place(self, command: str):
|
||||||
self,
|
|
||||||
option: str,
|
|
||||||
move_group: str,
|
|
||||||
status: str,
|
|
||||||
resource: Optional[str] = None,
|
|
||||||
x_distance: Optional[float] = None,
|
|
||||||
y_distance: Optional[float] = None,
|
|
||||||
lift_height: Optional[float] = None,
|
|
||||||
retry: Optional[int] = None,
|
|
||||||
speed: Optional[float] = None,
|
|
||||||
target: Optional[str] = None,
|
|
||||||
constraints: Optional[Sequence[float]] = None,
|
|
||||||
) -> None:
|
|
||||||
"""
|
"""
|
||||||
使用 MoveIt 完成抓取/放置等序列(pick/place/side_pick/side_place)。
|
Using MoveIt to make the robotic arm pick or place materials to a target point.
|
||||||
|
|
||||||
必选:option, move_group, status。
|
Args:
|
||||||
可选:resource, x_distance, y_distance, lift_height, retry, speed, target, constraints。
|
command: A JSON-formatted string that includes option, target, speed, lift_height, mt_height
|
||||||
无返回值;失败时提前 return 或打印异常。
|
|
||||||
|
*option (string) : Action type: pick/place/side_pick/side_place
|
||||||
|
*move_group (string): The move group moveit will plan
|
||||||
|
*status(string) : Target pose
|
||||||
|
resource(string) : The target resource
|
||||||
|
x_distance (float) : The distance to the target in x direction(meters)
|
||||||
|
y_distance (float) : The distance to the target in y direction(meters)
|
||||||
|
lift_height (float) : The height at which the material should be lifted(meters)
|
||||||
|
retry (float) : Retry times when moveit plan fails
|
||||||
|
speed (float) : The speed of the movement, speed > 0
|
||||||
|
Returns:
|
||||||
|
None
|
||||||
"""
|
"""
|
||||||
|
result = SendCmd.Result()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if option not in self.move_option:
|
cmd_str = str(command).replace("'", '"')
|
||||||
raise ValueError(f"Invalid option: {option}")
|
cmd_dict = json.loads(cmd_str)
|
||||||
|
|
||||||
option_index = self.move_option.index(option)
|
if cmd_dict["option"] in self.move_option:
|
||||||
|
option_index = self.move_option.index(cmd_dict["option"])
|
||||||
place_flag = option_index % 2
|
place_flag = option_index % 2
|
||||||
|
|
||||||
config: dict = {"move_group": move_group}
|
config = {}
|
||||||
if speed is not None:
|
|
||||||
config["speed"] = speed
|
|
||||||
if retry is not None:
|
|
||||||
config["retry"] = retry
|
|
||||||
|
|
||||||
function_list = []
|
function_list = []
|
||||||
joint_positions_ = self.joint_poses[move_group][status]
|
|
||||||
|
|
||||||
# 夹取 / 放置:绑定 resource 与 parent
|
status = cmd_dict["status"]
|
||||||
|
joint_positions_ = self.joint_poses[cmd_dict["move_group"]][status]
|
||||||
|
|
||||||
|
config.update({k: cmd_dict[k] for k in ["speed", "retry", "move_group"] if k in cmd_dict})
|
||||||
|
|
||||||
|
# 夹取
|
||||||
if not place_flag:
|
if not place_flag:
|
||||||
if target is not None:
|
if "target" in cmd_dict.keys():
|
||||||
function_list.append(lambda r=resource, t=target: self.resource_manager(r, t))
|
function_list.append(lambda: self.resource_manager(cmd_dict["resource"], cmd_dict["target"]))
|
||||||
else:
|
else:
|
||||||
ee = self.moveit2[move_group].end_effector_name
|
function_list.append(
|
||||||
function_list.append(lambda r=resource: self.resource_manager(r, ee))
|
lambda: self.resource_manager(
|
||||||
|
cmd_dict["resource"], self.moveit2[cmd_dict["move_group"]].end_effector_name
|
||||||
|
)
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
function_list.append(lambda r=resource: self.resource_manager(r, "world"))
|
function_list.append(lambda: self.resource_manager(cmd_dict["resource"], "world"))
|
||||||
|
|
||||||
joint_constraint_msgs: list = []
|
constraints = []
|
||||||
if constraints is not None:
|
if "constraints" in cmd_dict.keys():
|
||||||
for i, c in enumerate(constraints):
|
|
||||||
v = float(c)
|
for i in range(len(cmd_dict["constraints"])):
|
||||||
|
v = float(cmd_dict["constraints"][i])
|
||||||
if v > 0:
|
if v > 0:
|
||||||
joint_constraint_msgs.append(
|
constraints.append(
|
||||||
JointConstraint(
|
JointConstraint(
|
||||||
joint_name=self.moveit2[move_group].joint_names[i],
|
joint_name=self.moveit2[cmd_dict["move_group"]].joint_names[i],
|
||||||
position=joint_positions_[i],
|
position=joint_positions_[i],
|
||||||
tolerance_above=v,
|
tolerance_above=v,
|
||||||
tolerance_below=v,
|
tolerance_below=v,
|
||||||
@@ -234,15 +237,16 @@ class MoveitInterface:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if lift_height is not None:
|
if "lift_height" in cmd_dict.keys():
|
||||||
retval = None
|
retval = None
|
||||||
attempts = config.get("retry", 10)
|
retry = config.get("retry", 10)
|
||||||
while retval is None and attempts > 0:
|
while retval is None and retry > 0:
|
||||||
retval = self.moveit2[move_group].compute_fk(joint_positions_)
|
retval = self.moveit2[cmd_dict["move_group"]].compute_fk(joint_positions_)
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
attempts -= 1
|
retry -= 1
|
||||||
if retval is None:
|
if retval is None:
|
||||||
raise ValueError("Failed to compute forward kinematics")
|
result.success = False
|
||||||
|
return result
|
||||||
pose = [retval.pose.position.x, retval.pose.position.y, retval.pose.position.z]
|
pose = [retval.pose.position.x, retval.pose.position.y, retval.pose.position.z]
|
||||||
quaternion = [
|
quaternion = [
|
||||||
retval.pose.orientation.x,
|
retval.pose.orientation.x,
|
||||||
@@ -260,57 +264,60 @@ class MoveitInterface:
|
|||||||
)
|
)
|
||||||
] + function_list
|
] + function_list
|
||||||
|
|
||||||
pose[2] += float(lift_height)
|
pose[2] += float(cmd_dict["lift_height"])
|
||||||
function_list.append(
|
function_list.append(
|
||||||
lambda p=pose.copy(), q=quaternion, cfg=config: self.moveit_task(
|
lambda: self.moveit_task(
|
||||||
position=p, quaternion=q, **cfg, cartesian=self.cartesian_flag
|
position=pose, quaternion=quaternion, **config, cartesian=self.cartesian_flag
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
end_pose = list(pose)
|
end_pose = pose
|
||||||
|
|
||||||
if x_distance is not None or y_distance is not None:
|
if "x_distance" in cmd_dict.keys() or "y_distance" in cmd_dict.keys():
|
||||||
if x_distance is not None:
|
if "x_distance" in cmd_dict.keys():
|
||||||
deep_pose = deepcopy(pose)
|
deep_pose = deepcopy(pose)
|
||||||
deep_pose[0] += float(x_distance)
|
deep_pose[0] += float(cmd_dict["x_distance"])
|
||||||
elif y_distance is not None:
|
elif "y_distance" in cmd_dict.keys():
|
||||||
deep_pose = deepcopy(pose)
|
deep_pose = deepcopy(pose)
|
||||||
deep_pose[1] += float(y_distance)
|
deep_pose[1] += float(cmd_dict["y_distance"])
|
||||||
|
|
||||||
function_list = [
|
function_list = [
|
||||||
lambda p=pose.copy(), q=quaternion, cfg=config: self.moveit_task(
|
lambda: self.moveit_task(
|
||||||
position=p, quaternion=q, **cfg, cartesian=self.cartesian_flag
|
position=pose, quaternion=quaternion, **config, cartesian=self.cartesian_flag
|
||||||
)
|
)
|
||||||
] + function_list
|
] + function_list
|
||||||
function_list.append(
|
function_list.append(
|
||||||
lambda dp=deep_pose.copy(), q=quaternion, cfg=config: self.moveit_task(
|
lambda: self.moveit_task(
|
||||||
position=dp, quaternion=q, **cfg, cartesian=self.cartesian_flag
|
position=deep_pose, quaternion=quaternion, **config, cartesian=self.cartesian_flag
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
end_pose = list(deep_pose)
|
end_pose = deep_pose
|
||||||
|
|
||||||
retval_ik = None
|
retval_ik = None
|
||||||
attempts_ik = config.get("retry", 10)
|
retry = config.get("retry", 10)
|
||||||
while retval_ik is None and attempts_ik > 0:
|
while retval_ik is None and retry > 0:
|
||||||
retval_ik = self.moveit2[move_group].compute_ik(
|
retval_ik = self.moveit2[cmd_dict["move_group"]].compute_ik(
|
||||||
position=end_pose,
|
position=end_pose, quat_xyzw=quaternion, constraints=Constraints(joint_constraints=constraints)
|
||||||
quat_xyzw=quaternion,
|
|
||||||
constraints=Constraints(joint_constraints=joint_constraint_msgs),
|
|
||||||
)
|
)
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
attempts_ik -= 1
|
retry -= 1
|
||||||
if retval_ik is None:
|
if retval_ik is None:
|
||||||
raise ValueError("Failed to compute inverse kinematics")
|
result.success = False
|
||||||
|
return result
|
||||||
position_ = [
|
position_ = [
|
||||||
retval_ik.position[retval_ik.name.index(i)] for i in self.moveit2[move_group].joint_names
|
retval_ik.position[retval_ik.name.index(i)]
|
||||||
|
for i in self.moveit2[cmd_dict["move_group"]].joint_names
|
||||||
]
|
]
|
||||||
jn = self.moveit2[move_group].joint_names
|
|
||||||
function_list = [
|
function_list = [
|
||||||
lambda pos=position_, names=jn, cfg=config: self.moveit_joint_task(
|
lambda: self.moveit_joint_task(
|
||||||
joint_positions=pos, joint_names=names, **cfg
|
joint_positions=position_,
|
||||||
|
joint_names=self.moveit2[cmd_dict["move_group"]].joint_names,
|
||||||
|
**config,
|
||||||
)
|
)
|
||||||
] + function_list
|
] + function_list
|
||||||
else:
|
else:
|
||||||
function_list = [lambda cfg=config, jp=joint_positions_: self.moveit_joint_task(**cfg, joint_positions=jp)] + function_list
|
function_list = [
|
||||||
|
lambda: self.moveit_joint_task(**config, joint_positions=joint_positions_)
|
||||||
|
] + function_list
|
||||||
|
|
||||||
for i in range(len(function_list)):
|
for i in range(len(function_list)):
|
||||||
if i == 0:
|
if i == 0:
|
||||||
@@ -321,11 +328,16 @@ class MoveitInterface:
|
|||||||
re = function_list[i]()
|
re = function_list[i]()
|
||||||
if not re:
|
if not re:
|
||||||
print(i, re)
|
print(i, re)
|
||||||
raise ValueError(f"Failed to execute moveit task: {i}")
|
result.success = False
|
||||||
|
return result
|
||||||
|
result.success = True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
self.cartesian_flag = False
|
self.cartesian_flag = False
|
||||||
raise e
|
result.success = False
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
def set_status(self, command: str):
|
def set_status(self, command: str):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
"""虚拟样品演示设备 — 用于前端 sample tracking 功能的极简 demo"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import random
|
|
||||||
import time
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
|
|
||||||
class VirtualSampleDemo:
|
|
||||||
"""虚拟样品追踪演示设备,提供两种典型返回模式:
|
|
||||||
- measure_samples: 等长输入输出 (前端按 index 自动对齐)
|
|
||||||
- split_and_measure: 输出比输入长,附带 samples 列标注归属
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
|
|
||||||
if device_id is None and "id" in kwargs:
|
|
||||||
device_id = kwargs.pop("id")
|
|
||||||
if config is None and "config" in kwargs:
|
|
||||||
config = kwargs.pop("config")
|
|
||||||
|
|
||||||
self.device_id = device_id or "unknown_sample_demo"
|
|
||||||
self.config = config or {}
|
|
||||||
self.logger = logging.getLogger(f"VirtualSampleDemo.{self.device_id}")
|
|
||||||
self.data: Dict[str, Any] = {"status": "Idle"}
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Action 1: 等长输入输出,无 samples 列
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
async def measure_samples(self, concentrations: List[float]) -> Dict[str, Any]:
|
|
||||||
"""模拟光度测量。absorbance = concentration * 0.05 + noise
|
|
||||||
|
|
||||||
入参和出参 list 长度相等,前端按 index 自动对齐。
|
|
||||||
"""
|
|
||||||
self.logger.info(f"measure_samples: concentrations={concentrations}")
|
|
||||||
absorbance = [round(c * 0.05 + random.gauss(0, 0.005), 4) for c in concentrations]
|
|
||||||
return {"concentrations": concentrations, "absorbance": absorbance}
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Action 2: 输出比输入长,带 samples 列
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
async def split_and_measure(self, volumes: List[float], split_count: int = 3) -> Dict[str, Any]:
|
|
||||||
"""将每个样品均分为 split_count 份后逐份测量。
|
|
||||||
|
|
||||||
返回的 list 长度 = len(volumes) * split_count,
|
|
||||||
附带 samples 列标注每行属于第几个输入样品 (0-based index)。
|
|
||||||
"""
|
|
||||||
self.logger.info(f"split_and_measure: volumes={volumes}, split_count={split_count}")
|
|
||||||
out_volumes: List[float] = []
|
|
||||||
readings: List[float] = []
|
|
||||||
samples: List[int] = []
|
|
||||||
|
|
||||||
for idx, vol in enumerate(volumes):
|
|
||||||
split_vol = round(vol / split_count, 2)
|
|
||||||
for _ in range(split_count):
|
|
||||||
out_volumes.append(split_vol)
|
|
||||||
readings.append(round(random.uniform(0.1, 1.0), 4))
|
|
||||||
samples.append(idx)
|
|
||||||
|
|
||||||
return {"volumes": out_volumes, "readings": readings, "unilabos_samples": samples}
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Action 3: 入参和出参都带 samples 列(不等长)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
async def analyze_readings(self, readings: List[float], samples: List[int]) -> Dict[str, Any]:
|
|
||||||
"""对 split_and_measure 的输出做二次分析。
|
|
||||||
|
|
||||||
入参 readings/samples 长度相同但 > 原始样品数,
|
|
||||||
出参同样带 samples 列,长度与入参一致。
|
|
||||||
"""
|
|
||||||
self.logger.info(f"analyze_readings: readings={readings}, samples={samples}")
|
|
||||||
scores: List[float] = []
|
|
||||||
passed: List[bool] = []
|
|
||||||
threshold = 0.4
|
|
||||||
|
|
||||||
for r in readings:
|
|
||||||
score = round(r * 100 + random.gauss(0, 2), 2)
|
|
||||||
scores.append(score)
|
|
||||||
passed.append(r >= threshold)
|
|
||||||
|
|
||||||
return {"scores": scores, "passed": passed, "unilabos_samples": samples}
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# 状态属性
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@property
|
|
||||||
def status(self) -> str:
|
|
||||||
return self.data.get("status", "Idle")
|
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
"""
|
"""
|
||||||
Virtual Workbench Device - 模拟工作台设备
|
Virtual Workbench Device - 模拟工作台设备
|
||||||
包含:
|
包含:
|
||||||
- 1个机械臂 (每次操作3s, 独占锁)
|
- 1个机械臂 (每次操作3s, 独占锁)
|
||||||
- 3个加热台 (每次加热10s, 可并行)
|
- 3个加热台 (每次加热10s, 可并行)
|
||||||
|
|
||||||
工作流程:
|
工作流程:
|
||||||
1. A1-A5 物料同时启动, 竞争机械臂
|
1. A1-A5 物料同时启动,竞争机械臂
|
||||||
2. 机械臂将物料移动到空闲加热台
|
2. 机械臂将物料移动到空闲加热台
|
||||||
3. 加热完成后, 机械臂将物料移动到C1-C5
|
3. 加热完成后,机械臂将物料移动到C1-C5
|
||||||
|
|
||||||
注意: 调用来自线程池, 使用 threading.Lock 进行同步
|
注意:调用来自线程池,使用 threading.Lock 进行同步
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -21,11 +21,9 @@ from threading import Lock, RLock
|
|||||||
|
|
||||||
from typing_extensions import TypedDict
|
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.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||||
from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample
|
from unilabos.utils.decorator import not_action, always_free
|
||||||
|
from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample, RETURN_UNILABOS_SAMPLES
|
||||||
|
|
||||||
|
|
||||||
# ============ TypedDict 返回类型定义 ============
|
# ============ TypedDict 返回类型定义 ============
|
||||||
@@ -59,8 +57,6 @@ class MoveToOutputResult(TypedDict):
|
|||||||
success: bool
|
success: bool
|
||||||
station_id: int
|
station_id: int
|
||||||
material_id: str
|
material_id: str
|
||||||
output_position: str
|
|
||||||
message: str
|
|
||||||
unilabos_samples: List[LabSample]
|
unilabos_samples: List[LabSample]
|
||||||
|
|
||||||
|
|
||||||
@@ -85,9 +81,9 @@ class HeatingStationState(Enum):
|
|||||||
"""加热台状态枚举"""
|
"""加热台状态枚举"""
|
||||||
|
|
||||||
IDLE = "idle" # 空闲
|
IDLE = "idle" # 空闲
|
||||||
OCCUPIED = "occupied" # 已放置物料, 等待加热
|
OCCUPIED = "occupied" # 已放置物料,等待加热
|
||||||
HEATING = "heating" # 加热中
|
HEATING = "heating" # 加热中
|
||||||
COMPLETED = "completed" # 加热完成, 等待取走
|
COMPLETED = "completed" # 加热完成,等待取走
|
||||||
|
|
||||||
|
|
||||||
class ArmState(Enum):
|
class ArmState(Enum):
|
||||||
@@ -109,24 +105,19 @@ class HeatingStation:
|
|||||||
heating_progress: float = 0.0
|
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:
|
class VirtualWorkbench:
|
||||||
"""
|
"""
|
||||||
Virtual Workbench Device - 虚拟工作台设备
|
Virtual Workbench Device - 虚拟工作台设备
|
||||||
|
|
||||||
模拟一个包含1个机械臂和3个加热台的工作站
|
模拟一个包含1个机械臂和3个加热台的工作站
|
||||||
- 机械臂操作耗时3秒, 同一时间只能执行一个操作
|
- 机械臂操作耗时3秒,同一时间只能执行一个操作
|
||||||
- 加热台加热耗时10秒, 3个加热台可并行工作
|
- 加热台加热耗时10秒,3个加热台可并行工作
|
||||||
|
|
||||||
工作流:
|
工作流:
|
||||||
1. 物料A1-A5并发启动(线程池), 竞争机械臂使用权
|
1. 物料A1-A5并发启动(线程池),竞争机械臂使用权
|
||||||
2. 获取机械臂后, 查找空闲加热台
|
2. 获取机械臂后,查找空闲加热台
|
||||||
3. 机械臂将物料放入加热台, 开始加热
|
3. 机械臂将物料放入加热台,开始加热
|
||||||
4. 加热完成后, 机械臂将物料移动到目标位置Cn
|
4. 加热完成后,机械臂将物料移动到目标位置Cn
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_ros_node: BaseROS2DeviceNode
|
_ros_node: BaseROS2DeviceNode
|
||||||
@@ -154,19 +145,19 @@ class VirtualWorkbench:
|
|||||||
self.HEATING_TIME = float(self.config.get("heating_time", self.HEATING_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.NUM_HEATING_STATIONS = int(self.config.get("num_heating_stations", self.NUM_HEATING_STATIONS))
|
||||||
|
|
||||||
# 机械臂状态和锁
|
# 机械臂状态和锁 (使用threading.Lock)
|
||||||
self._arm_lock = Lock()
|
self._arm_lock = Lock()
|
||||||
self._arm_state = ArmState.IDLE
|
self._arm_state = ArmState.IDLE
|
||||||
self._arm_current_task: Optional[str] = None
|
self._arm_current_task: Optional[str] = None
|
||||||
|
|
||||||
# 加热台状态
|
# 加热台状态 (station_id -> HeatingStation) - 立即初始化,不依赖initialize()
|
||||||
self._heating_stations: Dict[int, HeatingStation] = {
|
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()
|
self._tasks_lock = Lock()
|
||||||
|
|
||||||
# 处理其他kwargs参数
|
# 处理其他kwargs参数
|
||||||
@@ -192,6 +183,7 @@ class VirtualWorkbench:
|
|||||||
"""初始化虚拟工作台"""
|
"""初始化虚拟工作台"""
|
||||||
self.logger.info(f"初始化虚拟工作台 {self.device_id}")
|
self.logger.info(f"初始化虚拟工作台 {self.device_id}")
|
||||||
|
|
||||||
|
# 重置加热台状态 (已在__init__中创建,这里重置为初始状态)
|
||||||
with self._stations_lock:
|
with self._stations_lock:
|
||||||
for station in self._heating_stations.values():
|
for station in self._heating_stations.values():
|
||||||
station.state = HeatingStationState.IDLE
|
station.state = HeatingStationState.IDLE
|
||||||
@@ -199,6 +191,7 @@ class VirtualWorkbench:
|
|||||||
station.material_number = None
|
station.material_number = None
|
||||||
station.heating_progress = 0.0
|
station.heating_progress = 0.0
|
||||||
|
|
||||||
|
# 初始化状态
|
||||||
self.data.update(
|
self.data.update(
|
||||||
{
|
{
|
||||||
"status": "Ready",
|
"status": "Ready",
|
||||||
@@ -264,7 +257,11 @@ class VirtualWorkbench:
|
|||||||
self.data["message"] = message
|
self.data["message"] = message
|
||||||
|
|
||||||
def _find_available_heating_station(self) -> Optional[int]:
|
def _find_available_heating_station(self) -> Optional[int]:
|
||||||
"""查找空闲的加热台"""
|
"""查找空闲的加热台
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
空闲加热台ID,如果没有则返回None
|
||||||
|
"""
|
||||||
with self._stations_lock:
|
with self._stations_lock:
|
||||||
for station_id, station in self._heating_stations.items():
|
for station_id, station in self._heating_stations.items():
|
||||||
if station.state == HeatingStationState.IDLE:
|
if station.state == HeatingStationState.IDLE:
|
||||||
@@ -272,12 +269,23 @@ class VirtualWorkbench:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _acquire_arm(self, task_description: str) -> bool:
|
def _acquire_arm(self, task_description: str) -> bool:
|
||||||
"""获取机械臂使用权(阻塞直到获取)"""
|
"""获取机械臂使用权(阻塞直到获取)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_description: 任务描述,用于日志
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否成功获取
|
||||||
|
"""
|
||||||
self.logger.info(f"[{task_description}] 等待获取机械臂...")
|
self.logger.info(f"[{task_description}] 等待获取机械臂...")
|
||||||
|
|
||||||
|
# 阻塞等待获取锁
|
||||||
self._arm_lock.acquire()
|
self._arm_lock.acquire()
|
||||||
|
|
||||||
self._arm_state = ArmState.BUSY
|
self._arm_state = ArmState.BUSY
|
||||||
self._arm_current_task = task_description
|
self._arm_current_task = task_description
|
||||||
self._update_data_status(f"机械臂执行: {task_description}")
|
self._update_data_status(f"机械臂执行: {task_description}")
|
||||||
|
|
||||||
self.logger.info(f"[{task_description}] 成功获取机械臂使用权")
|
self.logger.info(f"[{task_description}] 成功获取机械臂使用权")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -290,22 +298,6 @@ class VirtualWorkbench:
|
|||||||
self._update_data_status(f"机械臂已释放 (完成: {task})")
|
self._update_data_status(f"机械臂已释放 (完成: {task})")
|
||||||
self.logger.info(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(
|
def prepare_materials(
|
||||||
self,
|
self,
|
||||||
sample_uuids: SampleUUIDsType,
|
sample_uuids: SampleUUIDsType,
|
||||||
@@ -314,14 +306,19 @@ class VirtualWorkbench:
|
|||||||
"""
|
"""
|
||||||
批量准备物料 - 虚拟起始节点
|
批量准备物料 - 虚拟起始节点
|
||||||
|
|
||||||
作为工作流的起始节点, 生成指定数量的物料编号供后续节点使用。
|
作为工作流的起始节点,生成指定数量的物料编号供后续节点使用。
|
||||||
输出5个handle (material_1 ~ material_5), 分别对应实验1~5。
|
输出5个handle (material_1 ~ material_5),分别对应实验1~5。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
count: 待生成的物料数量,默认5 (生成 A1-A5)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PrepareMaterialsResult: 包含 material_1 ~ material_5 用于传递给 move_to_heating_station
|
||||||
"""
|
"""
|
||||||
|
# 生成物料列表 A1 - A{count}
|
||||||
materials = [i for i in range(1, count + 1)]
|
materials = [i for i in range(1, count + 1)]
|
||||||
|
|
||||||
self.logger.info(
|
self.logger.info(f"[准备物料] 生成 {count} 个物料: " f"A1-A{count} -> material_1~material_{count}")
|
||||||
f"[准备物料] 生成 {count} 个物料: A1-A{count} -> material_1~material_{count}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -332,28 +329,9 @@ class VirtualWorkbench:
|
|||||||
"material_4": materials[3] if len(materials) > 3 else 0,
|
"material_4": materials[3] if len(materials) > 3 else 0,
|
||||||
"material_5": materials[4] if len(materials) > 4 else 0,
|
"material_5": materials[4] if len(materials) > 4 else 0,
|
||||||
"message": f"已准备 {count} 个物料: A1-A{count}",
|
"message": f"已准备 {count} 个物料: A1-A{count}",
|
||||||
"unilabos_samples": [
|
"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()]
|
||||||
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(
|
def move_to_heating_station(
|
||||||
self,
|
self,
|
||||||
sample_uuids: SampleUUIDsType,
|
sample_uuids: SampleUUIDsType,
|
||||||
@@ -362,12 +340,20 @@ class VirtualWorkbench:
|
|||||||
"""
|
"""
|
||||||
将物料从An位置移动到加热台
|
将物料从An位置移动到加热台
|
||||||
|
|
||||||
多线程并发调用时, 会竞争机械臂使用权, 并自动查找空闲加热台
|
多线程并发调用时,会竞争机械臂使用权,并自动查找空闲加热台
|
||||||
|
|
||||||
|
Args:
|
||||||
|
material_number: 物料编号 (1-5)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
MoveToHeatingStationResult: 包含 station_id, material_number 等用于传递给下一个节点
|
||||||
"""
|
"""
|
||||||
|
# 根据物料编号生成物料ID
|
||||||
material_id = f"A{material_number}"
|
material_id = f"A{material_number}"
|
||||||
task_desc = f"移动{material_id}到加热台"
|
task_desc = f"移动{material_id}到加热台"
|
||||||
self.logger.info(f"[任务] {task_desc} - 开始执行")
|
self.logger.info(f"[任务] {task_desc} - 开始执行")
|
||||||
|
|
||||||
|
# 记录任务
|
||||||
with self._tasks_lock:
|
with self._tasks_lock:
|
||||||
self._active_tasks[material_id] = {
|
self._active_tasks[material_id] = {
|
||||||
"status": "waiting_for_arm",
|
"status": "waiting_for_arm",
|
||||||
@@ -375,27 +361,33 @@ class VirtualWorkbench:
|
|||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# 步骤1: 等待获取机械臂使用权(竞争)
|
||||||
with self._tasks_lock:
|
with self._tasks_lock:
|
||||||
self._active_tasks[material_id]["status"] = "waiting_for_arm"
|
self._active_tasks[material_id]["status"] = "waiting_for_arm"
|
||||||
self._acquire_arm(task_desc)
|
self._acquire_arm(task_desc)
|
||||||
|
|
||||||
|
# 步骤2: 查找空闲加热台
|
||||||
with self._tasks_lock:
|
with self._tasks_lock:
|
||||||
self._active_tasks[material_id]["status"] = "finding_station"
|
self._active_tasks[material_id]["status"] = "finding_station"
|
||||||
station_id = None
|
station_id = None
|
||||||
|
|
||||||
|
# 循环等待直到找到空闲加热台
|
||||||
while station_id is None:
|
while station_id is None:
|
||||||
station_id = self._find_available_heating_station()
|
station_id = self._find_available_heating_station()
|
||||||
if station_id is None:
|
if station_id is None:
|
||||||
self.logger.info(f"[{material_id}] 没有空闲加热台, 等待中...")
|
self.logger.info(f"[{material_id}] 没有空闲加热台,等待中...")
|
||||||
|
# 释放机械臂,等待后重试
|
||||||
self._release_arm()
|
self._release_arm()
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
self._acquire_arm(task_desc)
|
self._acquire_arm(task_desc)
|
||||||
|
|
||||||
|
# 步骤3: 占用加热台 - 立即标记为OCCUPIED,防止其他任务选择同一加热台
|
||||||
with self._stations_lock:
|
with self._stations_lock:
|
||||||
self._heating_stations[station_id].state = HeatingStationState.OCCUPIED
|
self._heating_stations[station_id].state = HeatingStationState.OCCUPIED
|
||||||
self._heating_stations[station_id].current_material = material_id
|
self._heating_stations[station_id].current_material = material_id
|
||||||
self._heating_stations[station_id].material_number = material_number
|
self._heating_stations[station_id].material_number = material_number
|
||||||
|
|
||||||
|
# 步骤4: 模拟机械臂移动操作 (3秒)
|
||||||
with self._tasks_lock:
|
with self._tasks_lock:
|
||||||
self._active_tasks[material_id]["status"] = "arm_moving"
|
self._active_tasks[material_id]["status"] = "arm_moving"
|
||||||
self._active_tasks[material_id]["assigned_station"] = station_id
|
self._active_tasks[material_id]["assigned_station"] = station_id
|
||||||
@@ -403,11 +395,11 @@ class VirtualWorkbench:
|
|||||||
|
|
||||||
time.sleep(self.ARM_OPERATION_TIME)
|
time.sleep(self.ARM_OPERATION_TIME)
|
||||||
|
|
||||||
|
# 步骤5: 放入加热台完成
|
||||||
self._update_data_status(f"{material_id}已放入加热台{station_id}")
|
self._update_data_status(f"{material_id}已放入加热台{station_id}")
|
||||||
self.logger.info(
|
self.logger.info(f"[{material_id}] 已放入加热台{station_id} (用时{self.ARM_OPERATION_TIME}s)")
|
||||||
f"[{material_id}] 已放入加热台{station_id} (用时{self.ARM_OPERATION_TIME}s)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
# 释放机械臂
|
||||||
self._release_arm()
|
self._release_arm()
|
||||||
|
|
||||||
with self._tasks_lock:
|
with self._tasks_lock:
|
||||||
@@ -420,16 +412,8 @@ class VirtualWorkbench:
|
|||||||
"material_number": material_number,
|
"material_number": material_number,
|
||||||
"message": f"{material_id}已成功移动到加热台{station_id}",
|
"message": f"{material_id}已成功移动到加热台{station_id}",
|
||||||
"unilabos_samples": [
|
"unilabos_samples": [
|
||||||
LabSample(
|
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||||
sample_uuid=sample_uuid,
|
sample_uuid, content in sample_uuids.items()]
|
||||||
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:
|
except Exception as e:
|
||||||
@@ -443,33 +427,11 @@ class VirtualWorkbench:
|
|||||||
"material_number": material_number,
|
"material_number": material_number,
|
||||||
"message": f"移动失败: {str(e)}",
|
"message": f"移动失败: {str(e)}",
|
||||||
"unilabos_samples": [
|
"unilabos_samples": [
|
||||||
LabSample(
|
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||||
sample_uuid=sample_uuid,
|
sample_uuid, content in sample_uuids.items()]
|
||||||
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(
|
@always_free
|
||||||
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(
|
def start_heating(
|
||||||
self,
|
self,
|
||||||
sample_uuids: SampleUUIDsType,
|
sample_uuids: SampleUUIDsType,
|
||||||
@@ -478,6 +440,13 @@ class VirtualWorkbench:
|
|||||||
) -> StartHeatingResult:
|
) -> 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}] 开始加热")
|
self.logger.info(f"[加热台{station_id}] 开始加热")
|
||||||
|
|
||||||
@@ -489,16 +458,8 @@ class VirtualWorkbench:
|
|||||||
"material_number": material_number,
|
"material_number": material_number,
|
||||||
"message": f"无效的加热台ID: {station_id}",
|
"message": f"无效的加热台ID: {station_id}",
|
||||||
"unilabos_samples": [
|
"unilabos_samples": [
|
||||||
LabSample(
|
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||||
sample_uuid=sample_uuid,
|
sample_uuid, content in sample_uuids.items()]
|
||||||
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:
|
with self._stations_lock:
|
||||||
@@ -512,16 +473,8 @@ class VirtualWorkbench:
|
|||||||
"material_number": material_number,
|
"material_number": material_number,
|
||||||
"message": f"加热台{station_id}上没有物料",
|
"message": f"加热台{station_id}上没有物料",
|
||||||
"unilabos_samples": [
|
"unilabos_samples": [
|
||||||
LabSample(
|
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||||
sample_uuid=sample_uuid,
|
sample_uuid, content in sample_uuids.items()]
|
||||||
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:
|
if station.state == HeatingStationState.HEATING:
|
||||||
@@ -532,20 +485,13 @@ class VirtualWorkbench:
|
|||||||
"material_number": material_number,
|
"material_number": material_number,
|
||||||
"message": f"加热台{station_id}已经在加热中",
|
"message": f"加热台{station_id}已经在加热中",
|
||||||
"unilabos_samples": [
|
"unilabos_samples": [
|
||||||
LabSample(
|
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||||
sample_uuid=sample_uuid,
|
sample_uuid, content in sample_uuids.items()]
|
||||||
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
|
material_id = station.current_material
|
||||||
|
|
||||||
|
# 开始加热
|
||||||
station.state = HeatingStationState.HEATING
|
station.state = HeatingStationState.HEATING
|
||||||
station.heating_start_time = time.time()
|
station.heating_start_time = time.time()
|
||||||
station.heating_progress = 0.0
|
station.heating_progress = 0.0
|
||||||
@@ -556,6 +502,7 @@ class VirtualWorkbench:
|
|||||||
|
|
||||||
self._update_data_status(f"加热台{station_id}开始加热{material_id}")
|
self._update_data_status(f"加热台{station_id}开始加热{material_id}")
|
||||||
|
|
||||||
|
# 打印当前所有正在加热的台位
|
||||||
with self._stations_lock:
|
with self._stations_lock:
|
||||||
heating_list = [
|
heating_list = [
|
||||||
f"加热台{sid}:{s.current_material}"
|
f"加热台{sid}:{s.current_material}"
|
||||||
@@ -564,6 +511,7 @@ class VirtualWorkbench:
|
|||||||
]
|
]
|
||||||
self.logger.info(f"[并行加热] 当前同时加热中: {', '.join(heating_list)}")
|
self.logger.info(f"[并行加热] 当前同时加热中: {', '.join(heating_list)}")
|
||||||
|
|
||||||
|
# 模拟加热过程
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
last_countdown_log = start_time
|
last_countdown_log = start_time
|
||||||
while True:
|
while True:
|
||||||
@@ -576,6 +524,7 @@ class VirtualWorkbench:
|
|||||||
|
|
||||||
self._update_data_status(f"加热台{station_id}加热中: {progress:.1f}%")
|
self._update_data_status(f"加热台{station_id}加热中: {progress:.1f}%")
|
||||||
|
|
||||||
|
# 每5秒打印一次倒计时
|
||||||
if time.time() - last_countdown_log >= 5.0:
|
if time.time() - last_countdown_log >= 5.0:
|
||||||
self.logger.info(f"[加热台{station_id}] {material_id} 剩余 {remaining:.1f}s")
|
self.logger.info(f"[加热台{station_id}] {material_id} 剩余 {remaining:.1f}s")
|
||||||
last_countdown_log = time.time()
|
last_countdown_log = time.time()
|
||||||
@@ -585,6 +534,7 @@ class VirtualWorkbench:
|
|||||||
|
|
||||||
time.sleep(1.0)
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
# 加热完成
|
||||||
with self._stations_lock:
|
with self._stations_lock:
|
||||||
self._heating_stations[station_id].state = HeatingStationState.COMPLETED
|
self._heating_stations[station_id].state = HeatingStationState.COMPLETED
|
||||||
self._heating_stations[station_id].heating_progress = 100.0
|
self._heating_stations[station_id].heating_progress = 100.0
|
||||||
@@ -603,28 +553,10 @@ class VirtualWorkbench:
|
|||||||
"material_number": material_number,
|
"material_number": material_number,
|
||||||
"message": f"加热台{station_id}加热完成",
|
"message": f"加热台{station_id}加热完成",
|
||||||
"unilabos_samples": [
|
"unilabos_samples": [
|
||||||
LabSample(
|
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||||
sample_uuid=sample_uuid,
|
sample_uuid, content in sample_uuids.items()]
|
||||||
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(
|
def move_to_output(
|
||||||
self,
|
self,
|
||||||
sample_uuids: SampleUUIDsType,
|
sample_uuids: SampleUUIDsType,
|
||||||
@@ -633,8 +565,15 @@ class VirtualWorkbench:
|
|||||||
) -> MoveToOutputResult:
|
) -> MoveToOutputResult:
|
||||||
"""
|
"""
|
||||||
将物料从加热台移动到输出位置Cn
|
将物料从加热台移动到输出位置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:
|
if station_id not in self._heating_stations:
|
||||||
return {
|
return {
|
||||||
@@ -644,16 +583,8 @@ class VirtualWorkbench:
|
|||||||
"output_position": f"C{output_number}",
|
"output_position": f"C{output_number}",
|
||||||
"message": f"无效的加热台ID: {station_id}",
|
"message": f"无效的加热台ID: {station_id}",
|
||||||
"unilabos_samples": [
|
"unilabos_samples": [
|
||||||
LabSample(
|
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||||
sample_uuid=sample_uuid,
|
sample_uuid, content in sample_uuids.items()]
|
||||||
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:
|
with self._stations_lock:
|
||||||
@@ -668,16 +599,8 @@ class VirtualWorkbench:
|
|||||||
"output_position": f"C{output_number}",
|
"output_position": f"C{output_number}",
|
||||||
"message": f"加热台{station_id}上没有物料",
|
"message": f"加热台{station_id}上没有物料",
|
||||||
"unilabos_samples": [
|
"unilabos_samples": [
|
||||||
LabSample(
|
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||||
sample_uuid=sample_uuid,
|
sample_uuid, content in sample_uuids.items()]
|
||||||
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:
|
if station.state != HeatingStationState.COMPLETED:
|
||||||
@@ -688,16 +611,8 @@ class VirtualWorkbench:
|
|||||||
"output_position": f"C{output_number}",
|
"output_position": f"C{output_number}",
|
||||||
"message": f"加热台{station_id}尚未完成加热 (当前状态: {station.state.value})",
|
"message": f"加热台{station_id}尚未完成加热 (当前状态: {station.state.value})",
|
||||||
"unilabos_samples": [
|
"unilabos_samples": [
|
||||||
LabSample(
|
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||||
sample_uuid=sample_uuid,
|
sample_uuid, content in sample_uuids.items()]
|
||||||
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}"
|
output_position = f"C{output_number}"
|
||||||
@@ -709,17 +624,18 @@ class VirtualWorkbench:
|
|||||||
if material_id in self._active_tasks:
|
if material_id in self._active_tasks:
|
||||||
self._active_tasks[material_id]["status"] = "waiting_for_arm_output"
|
self._active_tasks[material_id]["status"] = "waiting_for_arm_output"
|
||||||
|
|
||||||
|
# 获取机械臂
|
||||||
self._acquire_arm(task_desc)
|
self._acquire_arm(task_desc)
|
||||||
|
|
||||||
with self._tasks_lock:
|
with self._tasks_lock:
|
||||||
if material_id in self._active_tasks:
|
if material_id in self._active_tasks:
|
||||||
self._active_tasks[material_id]["status"] = "arm_moving_to_output"
|
self._active_tasks[material_id]["status"] = "arm_moving_to_output"
|
||||||
|
|
||||||
self.logger.info(
|
# 模拟机械臂操作 (3秒)
|
||||||
f"[{material_id}] 机械臂正在从加热台{station_id}取出并移动到{output_position}..."
|
self.logger.info(f"[{material_id}] 机械臂正在从加热台{station_id}取出并移动到{output_position}...")
|
||||||
)
|
|
||||||
time.sleep(self.ARM_OPERATION_TIME)
|
time.sleep(self.ARM_OPERATION_TIME)
|
||||||
|
|
||||||
|
# 清空加热台
|
||||||
with self._stations_lock:
|
with self._stations_lock:
|
||||||
self._heating_stations[station_id].state = HeatingStationState.IDLE
|
self._heating_stations[station_id].state = HeatingStationState.IDLE
|
||||||
self._heating_stations[station_id].current_material = None
|
self._heating_stations[station_id].current_material = None
|
||||||
@@ -727,17 +643,17 @@ class VirtualWorkbench:
|
|||||||
self._heating_stations[station_id].heating_progress = 0.0
|
self._heating_stations[station_id].heating_progress = 0.0
|
||||||
self._heating_stations[station_id].heating_start_time = None
|
self._heating_stations[station_id].heating_start_time = None
|
||||||
|
|
||||||
|
# 释放机械臂
|
||||||
self._release_arm()
|
self._release_arm()
|
||||||
|
|
||||||
|
# 任务完成
|
||||||
with self._tasks_lock:
|
with self._tasks_lock:
|
||||||
if material_id in self._active_tasks:
|
if material_id in self._active_tasks:
|
||||||
self._active_tasks[material_id]["status"] = "completed"
|
self._active_tasks[material_id]["status"] = "completed"
|
||||||
self._active_tasks[material_id]["end_time"] = time.time()
|
self._active_tasks[material_id]["end_time"] = time.time()
|
||||||
|
|
||||||
self._update_data_status(f"{material_id}已移动到{output_position}")
|
self._update_data_status(f"{material_id}已移动到{output_position}")
|
||||||
self.logger.info(
|
self.logger.info(f"[{material_id}] 已成功移动到{output_position} (用时{self.ARM_OPERATION_TIME}s)")
|
||||||
f"[{material_id}] 已成功移动到{output_position} (用时{self.ARM_OPERATION_TIME}s)"
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -746,17 +662,8 @@ class VirtualWorkbench:
|
|||||||
"output_position": output_position,
|
"output_position": output_position,
|
||||||
"message": f"{material_id}已成功移动到{output_position}",
|
"message": f"{material_id}已成功移动到{output_position}",
|
||||||
"unilabos_samples": [
|
"unilabos_samples": [
|
||||||
LabSample(
|
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||||
sample_uuid=sample_uuid,
|
sample_uuid, content in sample_uuids.items()]
|
||||||
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:
|
except Exception as e:
|
||||||
@@ -770,105 +677,83 @@ class VirtualWorkbench:
|
|||||||
"output_position": output_position,
|
"output_position": output_position,
|
||||||
"message": f"移动失败: {str(e)}",
|
"message": f"移动失败: {str(e)}",
|
||||||
"unilabos_samples": [
|
"unilabos_samples": [
|
||||||
LabSample(
|
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||||
sample_uuid=sample_uuid,
|
sample_uuid, content in sample_uuids.items()]
|
||||||
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
|
@property
|
||||||
@topic_config()
|
|
||||||
def status(self) -> str:
|
def status(self) -> str:
|
||||||
return self.data.get("status", "Unknown")
|
return self.data.get("status", "Unknown")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config()
|
|
||||||
def arm_state(self) -> str:
|
def arm_state(self) -> str:
|
||||||
return self._arm_state.value
|
return self._arm_state.value
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config()
|
|
||||||
def arm_current_task(self) -> str:
|
def arm_current_task(self) -> str:
|
||||||
return self._arm_current_task or ""
|
return self._arm_current_task or ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config()
|
|
||||||
def heating_station_1_state(self) -> str:
|
def heating_station_1_state(self) -> str:
|
||||||
with self._stations_lock:
|
with self._stations_lock:
|
||||||
station = self._heating_stations.get(1)
|
station = self._heating_stations.get(1)
|
||||||
return station.state.value if station else "unknown"
|
return station.state.value if station else "unknown"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config()
|
|
||||||
def heating_station_1_material(self) -> str:
|
def heating_station_1_material(self) -> str:
|
||||||
with self._stations_lock:
|
with self._stations_lock:
|
||||||
station = self._heating_stations.get(1)
|
station = self._heating_stations.get(1)
|
||||||
return station.current_material or "" if station else ""
|
return station.current_material or "" if station else ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config()
|
|
||||||
def heating_station_1_progress(self) -> float:
|
def heating_station_1_progress(self) -> float:
|
||||||
with self._stations_lock:
|
with self._stations_lock:
|
||||||
station = self._heating_stations.get(1)
|
station = self._heating_stations.get(1)
|
||||||
return station.heating_progress if station else 0.0
|
return station.heating_progress if station else 0.0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config()
|
|
||||||
def heating_station_2_state(self) -> str:
|
def heating_station_2_state(self) -> str:
|
||||||
with self._stations_lock:
|
with self._stations_lock:
|
||||||
station = self._heating_stations.get(2)
|
station = self._heating_stations.get(2)
|
||||||
return station.state.value if station else "unknown"
|
return station.state.value if station else "unknown"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config()
|
|
||||||
def heating_station_2_material(self) -> str:
|
def heating_station_2_material(self) -> str:
|
||||||
with self._stations_lock:
|
with self._stations_lock:
|
||||||
station = self._heating_stations.get(2)
|
station = self._heating_stations.get(2)
|
||||||
return station.current_material or "" if station else ""
|
return station.current_material or "" if station else ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config()
|
|
||||||
def heating_station_2_progress(self) -> float:
|
def heating_station_2_progress(self) -> float:
|
||||||
with self._stations_lock:
|
with self._stations_lock:
|
||||||
station = self._heating_stations.get(2)
|
station = self._heating_stations.get(2)
|
||||||
return station.heating_progress if station else 0.0
|
return station.heating_progress if station else 0.0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config()
|
|
||||||
def heating_station_3_state(self) -> str:
|
def heating_station_3_state(self) -> str:
|
||||||
with self._stations_lock:
|
with self._stations_lock:
|
||||||
station = self._heating_stations.get(3)
|
station = self._heating_stations.get(3)
|
||||||
return station.state.value if station else "unknown"
|
return station.state.value if station else "unknown"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config()
|
|
||||||
def heating_station_3_material(self) -> str:
|
def heating_station_3_material(self) -> str:
|
||||||
with self._stations_lock:
|
with self._stations_lock:
|
||||||
station = self._heating_stations.get(3)
|
station = self._heating_stations.get(3)
|
||||||
return station.current_material or "" if station else ""
|
return station.current_material or "" if station else ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config()
|
|
||||||
def heating_station_3_progress(self) -> float:
|
def heating_station_3_progress(self) -> float:
|
||||||
with self._stations_lock:
|
with self._stations_lock:
|
||||||
station = self._heating_stations.get(3)
|
station = self._heating_stations.get(3)
|
||||||
return station.heating_progress if station else 0.0
|
return station.heating_progress if station else 0.0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config()
|
|
||||||
def active_tasks_count(self) -> int:
|
def active_tasks_count(self) -> int:
|
||||||
with self._tasks_lock:
|
with self._tasks_lock:
|
||||||
return len(self._active_tasks)
|
return len(self._active_tasks)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config()
|
|
||||||
def message(self) -> str:
|
def message(self) -> str:
|
||||||
return self.data.get("message", "")
|
return self.data.get("message", "")
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
# 工作站抽象基类物料系统架构说明
|
# 工作站抽象基类物料系统架构说明
|
||||||
|
|
||||||
## 设计理念
|
|
||||||
|
|
||||||
基于用户需求"请你帮我系统思考一下,工作站抽象基类的物料系统基类该如何构建",我们最终确定了一个**PyLabRobot Deck为中心**的简化架构。
|
|
||||||
|
|
||||||
### 核心原则
|
### 核心原则
|
||||||
|
|
||||||
1. **PyLabRobot为物料管理核心**:使用PyLabRobot的Deck系统作为物料管理的基础,利用其成熟的Resource体系
|
1. **PyLabRobot为物料管理核心**:使用PyLabRobot的Deck系统作为物料管理的基础,利用其成熟的Resource体系
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# Bioyond Cell 工作站 - 多订单返回示例
|
||||||
|
|
||||||
|
本文档说明了 `create_orders` 函数如何收集并返回所有订单的完成报文。
|
||||||
|
|
||||||
|
## 问题描述
|
||||||
|
|
||||||
|
之前的实现只会等待并返回第一个订单的完成报文,如果有多个订单(例如从 Excel 解析出 3 个订单),只能得到第一个订单的推送信息。
|
||||||
|
|
||||||
|
## 解决方案
|
||||||
|
|
||||||
|
修改后的 `create_orders` 函数现在会:
|
||||||
|
|
||||||
|
1. **提取所有 orderCode**:从 LIMS 接口返回的 `data` 列表中提取所有订单编号
|
||||||
|
2. **逐个等待完成**:遍历所有 orderCode,调用 `wait_for_order_finish` 等待每个订单完成
|
||||||
|
3. **收集所有报文**:将每个订单的完成报文存入 `all_reports` 列表
|
||||||
|
4. **统一返回**:返回包含所有订单报文的 JSON 格式数据
|
||||||
|
|
||||||
|
## 返回格式
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "all_completed",
|
||||||
|
"total_orders": 3,
|
||||||
|
"reports": [
|
||||||
|
{
|
||||||
|
"token": "",
|
||||||
|
"request_time": "2025-12-24T15:32:09.2148671+08:00",
|
||||||
|
"data": {
|
||||||
|
"orderId": "3a1e614d-a082-c44a-60be-68647a35e6f1",
|
||||||
|
"orderCode": "BSO2025122400024",
|
||||||
|
"orderName": "DP20251224001",
|
||||||
|
"status": "30",
|
||||||
|
"workflowStatus": "completed",
|
||||||
|
"usedMaterials": [...]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"token": "",
|
||||||
|
"request_time": "2025-12-24T15:32:09.9999039+08:00",
|
||||||
|
"data": {
|
||||||
|
"orderId": "3a1e614d-a0a2-f7a9-9360-610021c9479d",
|
||||||
|
"orderCode": "BSO2025122400025",
|
||||||
|
"orderName": "DP20251224002",
|
||||||
|
"status": "30",
|
||||||
|
"workflowStatus": "completed",
|
||||||
|
"usedMaterials": [...]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"token": "",
|
||||||
|
"request_time": "2025-12-24T15:34:00.4139986+08:00",
|
||||||
|
"data": {
|
||||||
|
"orderId": "3a1e614d-a0cd-81ca-9f7f-2f4e93af01cd",
|
||||||
|
"orderCode": "BSO2025122400026",
|
||||||
|
"orderName": "DP20251224003",
|
||||||
|
"status": "30",
|
||||||
|
"workflowStatus": "completed",
|
||||||
|
"usedMaterials": [...]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"original_response": {...}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 调用 create_orders
|
||||||
|
result = workstation.create_orders("20251224.xlsx")
|
||||||
|
|
||||||
|
# 访问返回数据
|
||||||
|
print(f"总订单数: {result['total_orders']}")
|
||||||
|
print(f"状态: {result['status']}")
|
||||||
|
|
||||||
|
# 遍历所有订单的报文
|
||||||
|
for i, report in enumerate(result['reports'], 1):
|
||||||
|
order_data = report.get('data', {})
|
||||||
|
print(f"\n订单 {i}:")
|
||||||
|
print(f" orderCode: {order_data.get('orderCode')}")
|
||||||
|
print(f" orderName: {order_data.get('orderName')}")
|
||||||
|
print(f" status: {order_data.get('status')}")
|
||||||
|
print(f" 使用物料数: {len(order_data.get('usedMaterials', []))}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 控制台输出示例
|
||||||
|
|
||||||
|
```
|
||||||
|
[create_orders] 即将提交订单数量: 3
|
||||||
|
[create_orders] 接口返回: {...}
|
||||||
|
[create_orders] 等待 3 个订单完成: ['BSO2025122400024', 'BSO2025122400025', 'BSO2025122400026']
|
||||||
|
[create_orders] 正在等待第 1/3 个订单: BSO2025122400024
|
||||||
|
[create_orders] ✓ 订单 BSO2025122400024 完成
|
||||||
|
[create_orders] 正在等待第 2/3 个订单: BSO2025122400025
|
||||||
|
[create_orders] ✓ 订单 BSO2025122400025 完成
|
||||||
|
[create_orders] 正在等待第 3/3 个订单: BSO2025122400026
|
||||||
|
[create_orders] ✓ 订单 BSO2025122400026 完成
|
||||||
|
[create_orders] 所有订单已完成,共收集 3 个报文
|
||||||
|
实验记录本========================create_orders========================
|
||||||
|
返回报文数量: 3
|
||||||
|
报文 1: orderCode=BSO2025122400024, status=30
|
||||||
|
报文 2: orderCode=BSO2025122400025, status=30
|
||||||
|
报文 3: orderCode=BSO2025122400026, status=30
|
||||||
|
========================
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键改进
|
||||||
|
|
||||||
|
1. ✅ **等待所有订单**:不再只等待第一个订单,而是遍历所有 orderCode
|
||||||
|
2. ✅ **收集完整报文**:每个订单的完整推送报文都被保存在 `reports` 数组中
|
||||||
|
3. ✅ **详细日志**:清晰显示正在等待哪个订单,以及完成情况
|
||||||
|
4. ✅ **错误处理**:即使某个订单失败,也会记录其状态信息
|
||||||
|
5. ✅ **统一格式**:返回的 JSON 格式便于后续处理和分析
|
||||||
Binary file not shown.
@@ -0,0 +1,204 @@
|
|||||||
|
# BioyondCellWorkstation JSON 配置迁移经验总结
|
||||||
|
|
||||||
|
**日期**: 2026-01-13
|
||||||
|
**目的**: 从 `config.py` 迁移到 JSON 配置文件
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题背景
|
||||||
|
|
||||||
|
原系统通过 `config.py` 管理配置,导致:
|
||||||
|
1. HTTP 服务重复启动(父类 `BioyondWorkstation` 和子类都启动)
|
||||||
|
2. 配置分散在代码中,不便于管理
|
||||||
|
3. 无法通过 JSON 统一配置所有参数
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 解决方案:嵌套配置结构
|
||||||
|
|
||||||
|
### JSON 结构设计
|
||||||
|
|
||||||
|
**正确示例** (嵌套在 `config` 中):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nodes": [{
|
||||||
|
"id": "bioyond_cell_workstation",
|
||||||
|
"config": {
|
||||||
|
"deck": {...},
|
||||||
|
"protocol_type": [],
|
||||||
|
"bioyond_config": {
|
||||||
|
"api_host": "http://172.16.11.219:44388",
|
||||||
|
"api_key": "8A819E5C",
|
||||||
|
"timeout": 30,
|
||||||
|
"HTTP_host": "172.16.11.206",
|
||||||
|
"HTTP_port": 8080,
|
||||||
|
"debug_mode": false,
|
||||||
|
"material_type_mappings": {...},
|
||||||
|
"warehouse_mapping": {...},
|
||||||
|
"solid_liquid_mappings": {...}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"data": {}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键点**:
|
||||||
|
- ✅ `bioyond_config` 放在 `config` 中(会传递到 `__init__`)
|
||||||
|
- ❌ **不要**放在 `data` 中(`data` 是运行时状态,不会传递)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Python 代码适配
|
||||||
|
|
||||||
|
### 1. 修改 `BioyondCellWorkstation.__init__` 签名
|
||||||
|
|
||||||
|
**文件**: `bioyond_cell_workstation.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
def __init__(self, bioyond_config: dict = None, deck=None, protocol_type=None, **kwargs):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
bioyond_config: 从 JSON 加载的配置字典
|
||||||
|
deck: Deck 配置
|
||||||
|
protocol_type: 协议类型
|
||||||
|
"""
|
||||||
|
# 验证配置
|
||||||
|
if bioyond_config is None:
|
||||||
|
raise ValueError("需要 bioyond_config 参数")
|
||||||
|
|
||||||
|
# 保存配置
|
||||||
|
self.bioyond_config = bioyond_config
|
||||||
|
|
||||||
|
# 设置 HTTP 服务去重标志
|
||||||
|
self.bioyond_config["_disable_auto_http_service"] = True
|
||||||
|
|
||||||
|
# 调用父类
|
||||||
|
super().__init__(bioyond_config=self.bioyond_config, deck=deck, **kwargs)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 替换全局变量引用
|
||||||
|
|
||||||
|
**修改前**(使用全局变量):
|
||||||
|
```python
|
||||||
|
from config import MATERIAL_TYPE_MAPPINGS, WAREHOUSE_MAPPING
|
||||||
|
|
||||||
|
def create_sample(self, board_type, ...):
|
||||||
|
carrier_type_id = MATERIAL_TYPE_MAPPINGS[board_type][1]
|
||||||
|
location_id = WAREHOUSE_MAPPING[warehouse_name]["site_uuids"][location_code]
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改后**(从配置读取):
|
||||||
|
```python
|
||||||
|
def create_sample(self, board_type, ...):
|
||||||
|
carrier_type_id = self.bioyond_config['material_type_mappings'][board_type][1]
|
||||||
|
location_id = self.bioyond_config['warehouse_mapping'][warehouse_name]["site_uuids"][location_code]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 修复父类配置访问
|
||||||
|
|
||||||
|
在 `station.py` 中安全访问配置默认值:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 修改前(会 KeyError)
|
||||||
|
self._http_service_config = {
|
||||||
|
"host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG["http_service_host"])
|
||||||
|
}
|
||||||
|
|
||||||
|
# 修改后(安全访问)
|
||||||
|
self._http_service_config = {
|
||||||
|
"host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG.get("http_service_host", ""))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见陷阱
|
||||||
|
|
||||||
|
### ❌ 错误1:将配置放在 `data` 字段
|
||||||
|
```json
|
||||||
|
"config": {"deck": {...}},
|
||||||
|
"data": {"bioyond_config": {...}} // ❌ 不会传递到 __init__
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ 错误2:扁平化配置(已废弃方案)
|
||||||
|
虽然扁平化也能工作,但不推荐:
|
||||||
|
```json
|
||||||
|
"config": {
|
||||||
|
"deck": {...},
|
||||||
|
"api_host": "...", // ❌ 不够清晰
|
||||||
|
"api_key": "...",
|
||||||
|
"HTTP_host": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ 错误3:忘记替换全局变量引用
|
||||||
|
代码中直接使用 `MATERIAL_TYPE_MAPPINGS` 等全局变量会导致 `NameError`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 云端同步注意事项
|
||||||
|
|
||||||
|
使用 `--upload_registry` 时,云端配置可能覆盖本地配置:
|
||||||
|
- 首次上传时确保 JSON 完整
|
||||||
|
- 或使用新的 `ak/sk` 避免旧配置干扰
|
||||||
|
- 调试时可暂时移除 `--upload_registry` 参数
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验证清单
|
||||||
|
|
||||||
|
启动成功后应看到:
|
||||||
|
```
|
||||||
|
✅ 从 JSON 配置加载 bioyond_config 成功
|
||||||
|
API Host: http://...
|
||||||
|
HTTP Service: ...
|
||||||
|
✅ BioyondCellWorkstation 初始化完成
|
||||||
|
Loaded ResourceTreeSet with ... nodes
|
||||||
|
```
|
||||||
|
|
||||||
|
运行时不应出现:
|
||||||
|
- ❌ `NameError: name 'MATERIAL_TYPE_MAPPINGS' is not defined`
|
||||||
|
- ❌ `KeyError: 'http_service_host'`
|
||||||
|
- ❌ `bioyond_config 缺少必需参数`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 调试经验
|
||||||
|
|
||||||
|
1. **添加调试日志**查看参数传递链路:
|
||||||
|
- `graphio.py`: JSON 加载后的 config 内容
|
||||||
|
- `initialize_device.py`: `device_config.res_content.config` 的键
|
||||||
|
- `bioyond_cell_workstation.py`: `__init__` 接收到的参数
|
||||||
|
|
||||||
|
2. **config vs data 区别**:
|
||||||
|
- `config`: 初始化参数,传递给 `__init__`
|
||||||
|
- `data`: 运行时状态,不传递给 `__init__`
|
||||||
|
|
||||||
|
3. **参数名必须匹配**:
|
||||||
|
- JSON 中的键名必须与 `__init__` 参数名完全一致
|
||||||
|
|
||||||
|
4. **调试代码清理**:完成后记得删除调试日志(🔍 DEBUG 标记)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 修改文件清单
|
||||||
|
|
||||||
|
| 文件 | 修改内容 |
|
||||||
|
|------|----------|
|
||||||
|
| `yibin_electrolyte_config.json` | 创建嵌套 `config.bioyond_config` 结构 |
|
||||||
|
| `bioyond_cell_workstation.py` | 修改 `__init__` 接收 `bioyond_config`,替换所有全局变量引用 |
|
||||||
|
| `station.py` | 安全访问 `HTTP_SERVICE_CONFIG` 默认值 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 参考代码位置
|
||||||
|
|
||||||
|
- JSON 配置示例: `yibin_electrolyte_config.json` L12-L353
|
||||||
|
- `__init__` 实现: `bioyond_cell_workstation.py` L39-L94
|
||||||
|
- 全局变量替换示例: `bioyond_cell_workstation.py` L2005, L1863, L1966
|
||||||
|
- HTTP 服务配置: `station.py` L629-L634
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**总结**: 使用嵌套结构将所有配置放在 `config.bioyond_config` 中,修改 `__init__` 直接接收该参数,并替换所有全局变量引用为 `self.bioyond_config` 访问。
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
# BioyondCell 配置迁移修改总结
|
||||||
|
|
||||||
|
**日期**: 2026-01-13
|
||||||
|
**目标**: 从 `config.py` 完全迁移到 JSON 配置,消除所有全局变量依赖
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 修改概览
|
||||||
|
|
||||||
|
本次修改完成了 BioyondCell 模块从 Python 配置文件到 JSON 配置的完整迁移,并清理了所有对 `config.py` 全局变量的依赖。
|
||||||
|
|
||||||
|
### 核心成果
|
||||||
|
|
||||||
|
- ✅ 完全移除对 `config.py` 的导入依赖
|
||||||
|
- ✅ 使用嵌套 JSON 结构 `config.bioyond_config`
|
||||||
|
- ✅ 修复 7 处 `bioyond_cell_workstation.py` 中的全局变量引用
|
||||||
|
- ✅ 修复 3 处其他文件中的全局变量引用
|
||||||
|
- ✅ HTTP 服务去重机制完善
|
||||||
|
- ✅ 系统成功启动并正常运行
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 修改文件清单
|
||||||
|
|
||||||
|
### 1. JSON 配置文件
|
||||||
|
|
||||||
|
**文件**: `yibin_electrolyte_config.json`
|
||||||
|
|
||||||
|
**修改**:
|
||||||
|
- 采用嵌套结构将所有配置放在 `config.bioyond_config` 中
|
||||||
|
- 包含:`api_host`, `api_key`, `HTTP_host`, `HTTP_port`, `material_type_mappings`, `warehouse_mapping`, `solid_liquid_mappings` 等
|
||||||
|
|
||||||
|
**示例结构**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nodes": [{
|
||||||
|
"id": "bioyond_cell_workstation",
|
||||||
|
"config": {
|
||||||
|
"deck": {...},
|
||||||
|
"protocol_type": [],
|
||||||
|
"bioyond_config": {
|
||||||
|
"api_host": "http://172.16.11.219:44388",
|
||||||
|
"api_key": "8A819E5C",
|
||||||
|
"HTTP_host": "172.16.11.206",
|
||||||
|
"HTTP_port": 8080,
|
||||||
|
"material_type_mappings": {...},
|
||||||
|
"warehouse_mapping": {...},
|
||||||
|
"solid_liquid_mappings": {...}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. bioyond_cell_workstation.py
|
||||||
|
|
||||||
|
**位置**: `unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py`
|
||||||
|
|
||||||
|
#### 修改 A: `__init__` 方法签名 (L39-99)
|
||||||
|
|
||||||
|
**修改前**:
|
||||||
|
```python
|
||||||
|
def __init__(self, deck=None, protocol_type=None, **kwargs):
|
||||||
|
# 从 kwargs 收集配置字段
|
||||||
|
self.bioyond_config = {}
|
||||||
|
for field in bioyond_field_names:
|
||||||
|
if field in kwargs:
|
||||||
|
self.bioyond_config[field] = kwargs.pop(field)
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改后**:
|
||||||
|
```python
|
||||||
|
def __init__(self, bioyond_config: dict = None, deck=None, protocol_type=None, **kwargs):
|
||||||
|
"""直接接收 bioyond_config 参数"""
|
||||||
|
if bioyond_config is None:
|
||||||
|
raise ValueError("需要 bioyond_config 参数")
|
||||||
|
|
||||||
|
self.bioyond_config = bioyond_config
|
||||||
|
|
||||||
|
# 设置 HTTP 服务去重标志
|
||||||
|
self.bioyond_config["_disable_auto_http_service"] = True
|
||||||
|
|
||||||
|
super().__init__(bioyond_config=self.bioyond_config, deck=deck, **kwargs)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 修改 B: 替换全局变量引用 (7 处)
|
||||||
|
|
||||||
|
| 位置 | 原代码 | 修改后 |
|
||||||
|
|------|--------|--------|
|
||||||
|
| L2005 | `MATERIAL_TYPE_MAPPINGS[board_type][1]` | `self.bioyond_config['material_type_mappings'][board_type][1]` |
|
||||||
|
| L2006 | `MATERIAL_TYPE_MAPPINGS[bottle_type][1]` | `self.bioyond_config['material_type_mappings'][bottle_type][1]` |
|
||||||
|
| L2009 | `WAREHOUSE_MAPPING` | `self.bioyond_config['warehouse_mapping']` |
|
||||||
|
| L2013 | `WAREHOUSE_MAPPING[warehouse_name]` | `self.bioyond_config['warehouse_mapping'][warehouse_name]` |
|
||||||
|
| L2017 | `WAREHOUSE_MAPPING[warehouse_name]["site_uuids"]` | `self.bioyond_config['warehouse_mapping'][warehouse_name]["site_uuids"]` |
|
||||||
|
| L1863 | `SOLID_LIQUID_MAPPINGS.get(material_name)` | `self.bioyond_config.get('solid_liquid_mappings', {}).get(material_name)` |
|
||||||
|
| L1966, L1976 | `MATERIAL_TYPE_MAPPINGS.items()` | `self.bioyond_config['material_type_mappings'].items()` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. station.py
|
||||||
|
|
||||||
|
**位置**: `unilabos/devices/workstation/bioyond_studio/station.py`
|
||||||
|
|
||||||
|
#### 修改 A: 删除 config 导入 (L26-28)
|
||||||
|
|
||||||
|
**修改前**:
|
||||||
|
```python
|
||||||
|
from unilabos.devices.workstation.bioyond_studio.config import (
|
||||||
|
API_CONFIG, WORKFLOW_MAPPINGS, MATERIAL_TYPE_MAPPINGS, WAREHOUSE_MAPPING, HTTP_SERVICE_CONFIG
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改后**:
|
||||||
|
```python
|
||||||
|
# 已删除此导入
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 修改 B: `_create_communication_module` 方法 (L691-702)
|
||||||
|
|
||||||
|
**修改前**:
|
||||||
|
```python
|
||||||
|
def _create_communication_module(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||||
|
default_config = {
|
||||||
|
**API_CONFIG,
|
||||||
|
"workflow_mappings": WORKFLOW_MAPPINGS,
|
||||||
|
"material_type_mappings": MATERIAL_TYPE_MAPPINGS,
|
||||||
|
"warehouse_mapping": WAREHOUSE_MAPPING
|
||||||
|
}
|
||||||
|
if config:
|
||||||
|
self.bioyond_config = {**default_config, **config}
|
||||||
|
else:
|
||||||
|
self.bioyond_config = default_config
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改后**:
|
||||||
|
```python
|
||||||
|
def _create_communication_module(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||||
|
"""创建Bioyond通信模块"""
|
||||||
|
# 使用传入的 config 参数(来自 bioyond_config)
|
||||||
|
# 不再依赖全局变量 API_CONFIG 等
|
||||||
|
if config:
|
||||||
|
self.bioyond_config = config
|
||||||
|
else:
|
||||||
|
# 如果没有传入配置,创建空配置(用于测试或兼容性)
|
||||||
|
self.bioyond_config = {}
|
||||||
|
|
||||||
|
self.hardware_interface = BioyondV1RPC(self.bioyond_config)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 修改 C: HTTP 服务配置 (L627-632)
|
||||||
|
|
||||||
|
**修改前**:
|
||||||
|
```python
|
||||||
|
self._http_service_config = {
|
||||||
|
"host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG.get("http_service_host", "")),
|
||||||
|
"port": bioyond_config.get("http_service_port", HTTP_SERVICE_CONFIG.get("http_service_port", 0))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改后**:
|
||||||
|
```python
|
||||||
|
self._http_service_config = {
|
||||||
|
"host": bioyond_config.get("http_service_host", bioyond_config.get("HTTP_host", "")),
|
||||||
|
"port": bioyond_config.get("http_service_port", bioyond_config.get("HTTP_port", 0))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. bioyond_rpc.py
|
||||||
|
|
||||||
|
**位置**: `unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py`
|
||||||
|
|
||||||
|
#### 修改 A: 删除 config 导入 (L12)
|
||||||
|
|
||||||
|
**修改前**:
|
||||||
|
```python
|
||||||
|
from unilabos.devices.workstation.bioyond_studio.config import LOCATION_MAPPING
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改后**:
|
||||||
|
```python
|
||||||
|
# 已删除此导入
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 修改 B: `material_outbound` 方法 (L278-280)
|
||||||
|
|
||||||
|
**修改前**:
|
||||||
|
```python
|
||||||
|
def material_outbound(self, material_id: str, location_name: str, quantity: int) -> dict:
|
||||||
|
"""指定库位出库物料(通过库位名称)"""
|
||||||
|
location_id = LOCATION_MAPPING.get(location_name, location_name)
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改后**:
|
||||||
|
```python
|
||||||
|
def material_outbound(self, material_id: str, location_name: str, quantity: int) -> dict:
|
||||||
|
"""指定库位出库物料(通过库位名称)"""
|
||||||
|
# location_name 参数实际上应该直接是 location_id (UUID)
|
||||||
|
location_id = location_name
|
||||||
|
```
|
||||||
|
|
||||||
|
**说明**: `LOCATION_MAPPING` 在 `config-0113.py` 中本来就是空字典 `{}`,所以直接使用 `location_name` 逻辑等价。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 关键设计决策
|
||||||
|
|
||||||
|
### 1. 嵌套 vs 扁平配置
|
||||||
|
|
||||||
|
**选择**: 嵌套结构 `config.bioyond_config`
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- ✅ 语义清晰,配置分组明确
|
||||||
|
- ✅ 参数传递直观,直接对应 `__init__` 参数
|
||||||
|
- ✅ 易于维护,不需要硬编码字段列表
|
||||||
|
- ✅ 符合 UniLab 设计模式
|
||||||
|
|
||||||
|
### 2. HTTP 服务去重
|
||||||
|
|
||||||
|
**实现**: 子类设置 `_disable_auto_http_service` 标志
|
||||||
|
|
||||||
|
```python
|
||||||
|
# bioyond_cell_workstation.py
|
||||||
|
self.bioyond_config["_disable_auto_http_service"] = True
|
||||||
|
|
||||||
|
# station.py (post_init)
|
||||||
|
if self.bioyond_config.get("_disable_auto_http_service"):
|
||||||
|
logger.info("子类已自行管理HTTP服务,跳过自动启动")
|
||||||
|
return
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 全局变量替换策略
|
||||||
|
|
||||||
|
**原则**: 所有配置从 `self.bioyond_config` 获取
|
||||||
|
|
||||||
|
**模式**:
|
||||||
|
```python
|
||||||
|
# 修改前
|
||||||
|
from config import MATERIAL_TYPE_MAPPINGS
|
||||||
|
carrier_type_id = MATERIAL_TYPE_MAPPINGS[board_type][1]
|
||||||
|
|
||||||
|
# 修改后
|
||||||
|
carrier_type_id = self.bioyond_config['material_type_mappings'][board_type][1]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 验证结果
|
||||||
|
|
||||||
|
### 启动成功日志
|
||||||
|
```
|
||||||
|
✅ 从 JSON 配置加载 bioyond_config 成功
|
||||||
|
API Host: http://172.16.11.219:44388
|
||||||
|
HTTP Service: 172.16.11.206:8080
|
||||||
|
🔧 已设置 _disable_auto_http_service 标志,防止 HTTP 服务重复启动
|
||||||
|
✅ BioyondCellWorkstation 初始化完成
|
||||||
|
Loaded ResourceTreeSet with 1 trees, 1785 total nodes
|
||||||
|
```
|
||||||
|
|
||||||
|
### 功能验证
|
||||||
|
- ✅ 订单创建 (`create_orders_v2`)
|
||||||
|
- ✅ 质量比计算
|
||||||
|
- ✅ 物料转移 (`transfer_3_to_2_to_1`)
|
||||||
|
- ✅ HTTP 报送接收 (step_finish, sample_finish, order_finish)
|
||||||
|
- ✅ 等待机制 (`wait_for_order_finish`)
|
||||||
|
- ✅ 仓库 UUID 映射
|
||||||
|
- ✅ 物料类型映射
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 相关文档
|
||||||
|
|
||||||
|
- **配置迁移经验**: `2026-01-13_JSON配置迁移经验.md`
|
||||||
|
- **任务清单**: `C:\Users\AndyXie\.gemini\antigravity\brain\...\task.md`
|
||||||
|
- **实施计划**: `C:\Users\AndyXie\.gemini\antigravity\brain\...\implementation_plan.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 注意事项
|
||||||
|
|
||||||
|
### 其他工作站模块
|
||||||
|
|
||||||
|
以下文件仍在使用 `config.py` 全局变量(未包含在本次修改中):
|
||||||
|
- `reaction_station.py` - 使用 `API_CONFIG`
|
||||||
|
- `experiment.py` - 使用 `API_CONFIG`, `WORKFLOW_MAPPINGS`, `MATERIAL_TYPE_MAPPINGS`
|
||||||
|
- `dispensing_station.py` - 使用 `API_CONFIG`, `WAREHOUSE_MAPPING`
|
||||||
|
- `station.py` L176, L177, L529, L530 - 动态导入 `WAREHOUSE_MAPPING`
|
||||||
|
|
||||||
|
**建议**: 后续可以统一迁移这些模块到 JSON 配置。
|
||||||
|
|
||||||
|
### config.py 文件
|
||||||
|
|
||||||
|
`config.py` 文件已恢复但**不再被 bioyond_cell 使用**。可以:
|
||||||
|
- 保留作为其他模块的参考
|
||||||
|
- 或者完全删除(如果其他模块也迁移完成)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 下一步建议
|
||||||
|
|
||||||
|
1. **清理调试代码** ✅ (已完成)
|
||||||
|
2. **提交代码到 Git**
|
||||||
|
3. **迁移其他工作站模块** (可选)
|
||||||
|
4. **更新文档和启动脚本**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**修改完成日期**: 2026-01-13
|
||||||
|
**系统状态**: ✅ 稳定运行
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,157 @@
|
|||||||
|
# 批量出库 Excel 模板使用说明
|
||||||
|
|
||||||
|
**文件**: `outbound_template.xlsx`
|
||||||
|
**用途**: 配合 `auto_batch_outbound_from_xlsx()` 方法进行批量出库操作
|
||||||
|
**API 端点**: `/api/lims/storage/auto-batch-out-bound`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Excel 列说明
|
||||||
|
|
||||||
|
| 列名 | 说明 | 示例 | 必填 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `locationId` | **库位 ID(UUID)** | `3a19da43-57b5-294f-d663-154a1cc32270` | ✅ 是 |
|
||||||
|
| `warehouseId` | **仓库 ID 或名称** | `配液站内试剂仓库` | ✅ 是 |
|
||||||
|
| `quantity` | **出库数量** | `1.0`, `2.0` | ✅ 是 |
|
||||||
|
| `x` | **X 坐标(库位横向位置)** | `1`, `2`, `3` | ✅ 是 |
|
||||||
|
| `y` | **Y 坐标(库位纵向位置)** | `1`, `2`, `3` | ✅ 是 |
|
||||||
|
| `z` | **Z 坐标(库位层数/高度)** | `1`, `2`, `3` | ✅ 是 |
|
||||||
|
| `备注说明` | 可选备注信息 | `配液站内试剂仓库-A01` | ❌ 否 |
|
||||||
|
|
||||||
|
### 📐 坐标说明
|
||||||
|
|
||||||
|
**x, y, z** 是库位在仓库内的**三维坐标**:
|
||||||
|
|
||||||
|
```
|
||||||
|
仓库(例如 WH4)
|
||||||
|
├── Z=1(第1层/加样头面)
|
||||||
|
│ ├── X=1, Y=1(位置 A)
|
||||||
|
│ ├── X=2, Y=1(位置 B)
|
||||||
|
│ ├── X=3, Y=1(位置 C)
|
||||||
|
│ └── ...
|
||||||
|
│
|
||||||
|
└── Z=2(第2层/原液瓶面)
|
||||||
|
├── X=1, Y=1(位置 A)
|
||||||
|
├── X=2, Y=1(位置 B)
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
- **warehouseId**: 指定哪个仓库(WH3, WH4, 配液站等)
|
||||||
|
- **x, y, z**: 在该仓库内的三维坐标
|
||||||
|
- **locationId**: 该坐标位置的唯一 UUID
|
||||||
|
|
||||||
|
### 🎯 起点与终点
|
||||||
|
|
||||||
|
**重要说明**:批量出库模板**只规定了出库的"起点"**(从哪里取物料),**没有指定"终点"**(放到哪里)。
|
||||||
|
|
||||||
|
```
|
||||||
|
出库流程:
|
||||||
|
起点(Excel 指定) → ?终点(LIMS/工作流决定)
|
||||||
|
↓
|
||||||
|
locationId, x, y, z → 由 LIMS 系统或当前工作流自动分配
|
||||||
|
```
|
||||||
|
|
||||||
|
**终点由以下方式确定:**
|
||||||
|
- **LIMS 系统自动分配**:根据当前任务自动规划目标位置
|
||||||
|
- **工作流预定义**:在创建出库任务时已绑定目标位置
|
||||||
|
- **暂存区**:默认放到出库暂存区,等待下一步操作
|
||||||
|
|
||||||
|
💡 **对比**:上料操作(`auto_feeding4to3`)则有 `targetWH` 参数可以指定目标仓库
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 如何获取 UUID?
|
||||||
|
|
||||||
|
### 方法 1:从配置文件获取
|
||||||
|
|
||||||
|
参考 `yibin_electrolyte_config.json` 中的 `warehouse_mapping`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"warehouse_mapping": {
|
||||||
|
"配液站内试剂仓库": {
|
||||||
|
"site_uuids": {
|
||||||
|
"A01": "3a19da43-57b5-294f-d663-154a1cc32270",
|
||||||
|
"B01": "3a19da43-57b5-7394-5f49-54efe2c9bef2",
|
||||||
|
"C01": "3a19da43-57b5-5e75-552f-8dbd0ad1075f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"手动堆栈": {
|
||||||
|
"site_uuids": {
|
||||||
|
"A01": "3a19deae-2c7a-36f5-5e41-02c5b66feaea",
|
||||||
|
"A02": "3a19deae-2c7a-dc6d-c41e-ef285d946cfe"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方法 2:通过 API 查询
|
||||||
|
|
||||||
|
```python
|
||||||
|
material_info = hardware_interface.material_id_query(workflow_id)
|
||||||
|
locations = material_info.get("locations", [])
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 填写示例
|
||||||
|
|
||||||
|
### 示例 1:从配液站内试剂仓库出库
|
||||||
|
|
||||||
|
| locationId | warehouseId | quantity | x | y | z | 备注说明 |
|
||||||
|
|------------|-------------|----------|---|---|---|----------|
|
||||||
|
| `3a19da43-57b5-294f-d663-154a1cc32270` | 配液站内试剂仓库 | 1 | 1 | 1 | 1 | A01 位置 |
|
||||||
|
| `3a19da43-57b5-7394-5f49-54efe2c9bef2` | 配液站内试剂仓库 | 2 | 2 | 1 | 1 | B01 位置 |
|
||||||
|
|
||||||
|
### 示例 2:从手动堆栈出库
|
||||||
|
|
||||||
|
| locationId | warehouseId | quantity | x | y | z | 备注说明 |
|
||||||
|
|------------|-------------|----------|---|---|---|----------|
|
||||||
|
| `3a19deae-2c7a-36f5-5e41-02c5b66feaea` | 手动堆栈 | 1 | 1 | 1 | 1 | A01 |
|
||||||
|
| `3a19deae-2c7a-dc6d-c41e-ef285d946cfe` | 手动堆栈 | 1 | 1 | 2 | 1 | A02 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💻 使用方法
|
||||||
|
|
||||||
|
```python
|
||||||
|
from bioyond_cell_workstation import BioyondCellWorkstation
|
||||||
|
|
||||||
|
# 初始化工作站
|
||||||
|
workstation = BioyondCellWorkstation(config=config, deck=deck)
|
||||||
|
|
||||||
|
# 调用批量出库方法
|
||||||
|
result = workstation.auto_batch_outbound_from_xlsx(
|
||||||
|
xlsx_path="outbound_template.xlsx"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 注意事项
|
||||||
|
|
||||||
|
1. **locationId 必须是有效的 UUID**,不能使用库位名称
|
||||||
|
2. **x, y, z 坐标必须与 locationId 对应**,表示该库位在仓库内的位置
|
||||||
|
3. **quantity 必须是数字**,可以是整数或浮点数
|
||||||
|
4. Excel 文件必须包含表头行
|
||||||
|
5. 空行会被自动跳过
|
||||||
|
6. 确保 UUID 与实际库位对应,否则 API 会报错
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 相关文件
|
||||||
|
|
||||||
|
- **配置文件**: `yibin_electrolyte_config.json`
|
||||||
|
- **Python 代码**: `bioyond_cell_workstation.py` (L630-695)
|
||||||
|
- **生成脚本**: `create_outbound_template.py`
|
||||||
|
- **上料模板**: `material_template.xlsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 重新生成模板
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda activate newunilab
|
||||||
|
python create_outbound_template.py
|
||||||
|
```
|
||||||
@@ -9,7 +9,7 @@ from datetime import datetime, timezone
|
|||||||
from unilabos.device_comms.rpc import BaseRequest
|
from unilabos.device_comms.rpc import BaseRequest
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any
|
||||||
import json
|
import json
|
||||||
from unilabos.devices.workstation.bioyond_studio.config import LOCATION_MAPPING
|
|
||||||
|
|
||||||
|
|
||||||
class SimpleLogger:
|
class SimpleLogger:
|
||||||
@@ -49,6 +49,14 @@ class BioyondV1RPC(BaseRequest):
|
|||||||
self.config = config
|
self.config = config
|
||||||
self.api_key = config["api_key"]
|
self.api_key = config["api_key"]
|
||||||
self.host = config["api_host"]
|
self.host = config["api_host"]
|
||||||
|
|
||||||
|
# 初始化 location_mapping
|
||||||
|
# 直接从 warehouse_mapping 构建,确保数据源所谓的单一和结构化
|
||||||
|
self.location_mapping = {}
|
||||||
|
warehouse_mapping = self.config.get("warehouse_mapping", {})
|
||||||
|
for warehouse_name, warehouse_config in warehouse_mapping.items():
|
||||||
|
if "site_uuids" in warehouse_config:
|
||||||
|
self.location_mapping.update(warehouse_config["site_uuids"])
|
||||||
self._logger = SimpleLogger()
|
self._logger = SimpleLogger()
|
||||||
self.material_cache = {}
|
self.material_cache = {}
|
||||||
self._load_material_cache()
|
self._load_material_cache()
|
||||||
@@ -176,7 +184,40 @@ class BioyondV1RPC(BaseRequest):
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
print(f"add material data: {response['data']}")
|
print(f"add material data: {response['data']}")
|
||||||
return response.get("data", {})
|
|
||||||
|
# 自动更新缓存
|
||||||
|
data = response.get("data", {})
|
||||||
|
if data:
|
||||||
|
if isinstance(data, str):
|
||||||
|
# 如果返回的是字符串,通常是ID
|
||||||
|
mat_id = data
|
||||||
|
name = params.get("name")
|
||||||
|
else:
|
||||||
|
# 如果返回的是字典,尝试获取name和id
|
||||||
|
name = data.get("name") or params.get("name")
|
||||||
|
mat_id = data.get("id")
|
||||||
|
|
||||||
|
if name and mat_id:
|
||||||
|
self.material_cache[name] = mat_id
|
||||||
|
print(f"已自动更新缓存: {name} -> {mat_id}")
|
||||||
|
|
||||||
|
# 处理返回数据中的 details (如果有)
|
||||||
|
# 有些 API 返回结构可能直接包含 details,或者在 data 字段中
|
||||||
|
details = data.get("details", []) if isinstance(data, dict) else []
|
||||||
|
if not details and isinstance(data, dict):
|
||||||
|
details = data.get("detail", [])
|
||||||
|
|
||||||
|
if details:
|
||||||
|
for detail in details:
|
||||||
|
d_name = detail.get("name")
|
||||||
|
# 尝试从不同字段获取 ID
|
||||||
|
d_id = detail.get("id") or detail.get("detailMaterialId")
|
||||||
|
|
||||||
|
if d_name and d_id:
|
||||||
|
self.material_cache[d_name] = d_id
|
||||||
|
print(f"已自动更新 detail 缓存: {d_name} -> {d_id}")
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
def query_matial_type_id(self, data) -> list:
|
def query_matial_type_id(self, data) -> list:
|
||||||
"""查找物料typeid"""
|
"""查找物料typeid"""
|
||||||
@@ -203,7 +244,7 @@ class BioyondV1RPC(BaseRequest):
|
|||||||
params={
|
params={
|
||||||
"apiKey": self.api_key,
|
"apiKey": self.api_key,
|
||||||
"requestTime": self.get_current_time_iso8601(),
|
"requestTime": self.get_current_time_iso8601(),
|
||||||
"data": {},
|
"data": 0,
|
||||||
})
|
})
|
||||||
if not response or response['code'] != 1:
|
if not response or response['code'] != 1:
|
||||||
return []
|
return []
|
||||||
@@ -273,11 +314,19 @@ class BioyondV1RPC(BaseRequest):
|
|||||||
|
|
||||||
if not response or response['code'] != 1:
|
if not response or response['code'] != 1:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
# 自动更新缓存 - 移除被删除的物料
|
||||||
|
for name, mid in list(self.material_cache.items()):
|
||||||
|
if mid == material_id:
|
||||||
|
del self.material_cache[name]
|
||||||
|
print(f"已从缓存移除物料: {name}")
|
||||||
|
break
|
||||||
|
|
||||||
return response.get("data", {})
|
return response.get("data", {})
|
||||||
|
|
||||||
def material_outbound(self, material_id: str, location_name: str, quantity: int) -> dict:
|
def material_outbound(self, material_id: str, location_name: str, quantity: int) -> dict:
|
||||||
"""指定库位出库物料(通过库位名称)"""
|
"""指定库位出库物料(通过库位名称)"""
|
||||||
location_id = LOCATION_MAPPING.get(location_name, location_name)
|
location_id = self.location_mapping.get(location_name, location_name)
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
"materialId": material_id,
|
"materialId": material_id,
|
||||||
@@ -1103,6 +1152,10 @@ class BioyondV1RPC(BaseRequest):
|
|||||||
for detail_material in detail_materials:
|
for detail_material in detail_materials:
|
||||||
detail_name = detail_material.get("name")
|
detail_name = detail_material.get("name")
|
||||||
detail_id = detail_material.get("detailMaterialId")
|
detail_id = detail_material.get("detailMaterialId")
|
||||||
|
if not detail_id:
|
||||||
|
# 尝试其他可能的字段
|
||||||
|
detail_id = detail_material.get("id")
|
||||||
|
|
||||||
if detail_name and detail_id:
|
if detail_name and detail_id:
|
||||||
self.material_cache[detail_name] = detail_id
|
self.material_cache[detail_name] = detail_id
|
||||||
print(f"加载detail材料: {detail_name} -> ID: {detail_id}")
|
print(f"加载detail材料: {detail_name} -> ID: {detail_id}")
|
||||||
@@ -1123,6 +1176,14 @@ class BioyondV1RPC(BaseRequest):
|
|||||||
print(f"从缓存找到材料: {material_name_or_id} -> ID: {material_id}")
|
print(f"从缓存找到材料: {material_name_or_id} -> ID: {material_id}")
|
||||||
return material_id
|
return material_id
|
||||||
|
|
||||||
|
# 如果缓存中没有,尝试刷新缓存
|
||||||
|
print(f"缓存中未找到材料 '{material_name_or_id}',尝试刷新缓存...")
|
||||||
|
self.refresh_material_cache()
|
||||||
|
if material_name_or_id in self.material_cache:
|
||||||
|
material_id = self.material_cache[material_name_or_id]
|
||||||
|
print(f"刷新缓存后找到材料: {material_name_or_id} -> ID: {material_id}")
|
||||||
|
return material_id
|
||||||
|
|
||||||
print(f"警告: 未在缓存中找到材料名称 '{material_name_or_id}',将使用原值")
|
print(f"警告: 未在缓存中找到材料名称 '{material_name_or_id}',将使用原值")
|
||||||
return material_name_or_id
|
return material_name_or_id
|
||||||
|
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
# config.py
|
|
||||||
"""
|
|
||||||
配置文件 - 包含所有配置信息和映射关系
|
|
||||||
"""
|
|
||||||
|
|
||||||
# API配置
|
|
||||||
API_CONFIG = {
|
|
||||||
"api_key": "",
|
|
||||||
"api_host": ""
|
|
||||||
}
|
|
||||||
|
|
||||||
# 工作流映射配置
|
|
||||||
WORKFLOW_MAPPINGS = {
|
|
||||||
"reactor_taken_out": "",
|
|
||||||
"reactor_taken_in": "",
|
|
||||||
"Solid_feeding_vials": "",
|
|
||||||
"Liquid_feeding_vials(non-titration)": "",
|
|
||||||
"Liquid_feeding_solvents": "",
|
|
||||||
"Liquid_feeding(titration)": "",
|
|
||||||
"liquid_feeding_beaker": "",
|
|
||||||
"Drip_back": "",
|
|
||||||
}
|
|
||||||
|
|
||||||
# 工作流名称到DisplaySectionName的映射
|
|
||||||
WORKFLOW_TO_SECTION_MAP = {
|
|
||||||
'reactor_taken_in': '反应器放入',
|
|
||||||
'liquid_feeding_beaker': '液体投料-烧杯',
|
|
||||||
'Liquid_feeding_vials(non-titration)': '液体投料-小瓶(非滴定)',
|
|
||||||
'Liquid_feeding_solvents': '液体投料-溶剂',
|
|
||||||
'Solid_feeding_vials': '固体投料-小瓶',
|
|
||||||
'Liquid_feeding(titration)': '液体投料-滴定',
|
|
||||||
'reactor_taken_out': '反应器取出'
|
|
||||||
}
|
|
||||||
|
|
||||||
# 库位映射配置
|
|
||||||
WAREHOUSE_MAPPING = {
|
|
||||||
"粉末堆栈": {
|
|
||||||
"uuid": "",
|
|
||||||
"site_uuids": {
|
|
||||||
# 样品板
|
|
||||||
"A1": "3a14198e-6929-31f0-8a22-0f98f72260df",
|
|
||||||
"A2": "3a14198e-6929-4379-affa-9a2935c17f99",
|
|
||||||
"A3": "3a14198e-6929-56da-9a1c-7f5fbd4ae8af",
|
|
||||||
"A4": "3a14198e-6929-5e99-2b79-80720f7cfb54",
|
|
||||||
"B1": "3a14198e-6929-f525-9a1b-1857552b28ee",
|
|
||||||
"B2": "3a14198e-6929-bf98-0fd5-26e1d68bf62d",
|
|
||||||
"B3": "3a14198e-6929-2d86-a468-602175a2b5aa",
|
|
||||||
"B4": "3a14198e-6929-1a98-ae57-e97660c489ad",
|
|
||||||
# 分装板
|
|
||||||
"C1": "3a14198e-6929-46fe-841e-03dd753f1e4a",
|
|
||||||
"C2": "3a14198e-6929-1bc9-a9bd-3b7ca66e7f95",
|
|
||||||
"C3": "3a14198e-6929-72ac-32ce-9b50245682b8",
|
|
||||||
"C4": "3a14198e-6929-3bd8-e6c7-4a9fd93be118",
|
|
||||||
"D1": "3a14198e-6929-8a0b-b686-6f4a2955c4e2",
|
|
||||||
"D2": "3a14198e-6929-dde1-fc78-34a84b71afdf",
|
|
||||||
"D3": "3a14198e-6929-a0ec-5f15-c0f9f339f963",
|
|
||||||
"D4": "3a14198e-6929-7ac8-915a-fea51cb2e884"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"溶液堆栈": {
|
|
||||||
"uuid": "",
|
|
||||||
"site_uuids": {
|
|
||||||
"A1": "3a14198e-d724-e036-afdc-2ae39a7f3383",
|
|
||||||
"A2": "3a14198e-d724-afa4-fc82-0ac8a9016791",
|
|
||||||
"A3": "3a14198e-d724-ca48-bb9e-7e85751e55b6",
|
|
||||||
"A4": "3a14198e-d724-df6d-5e32-5483b3cab583",
|
|
||||||
"B1": "3a14198e-d724-d818-6d4f-5725191a24b5",
|
|
||||||
"B2": "3a14198e-d724-be8a-5e0b-012675e195c6",
|
|
||||||
"B3": "3a14198e-d724-cc1e-5c2c-228a130f40a8",
|
|
||||||
"B4": "3a14198e-d724-1e28-c885-574c3df468d0",
|
|
||||||
"C1": "3a14198e-d724-b5bb-adf3-4c5a0da6fb31",
|
|
||||||
"C2": "3a14198e-d724-ab4e-48cb-817c3c146707",
|
|
||||||
"C3": "3a14198e-d724-7f18-1853-39d0c62e1d33",
|
|
||||||
"C4": "3a14198e-d724-28a2-a760-baa896f46b66",
|
|
||||||
"D1": "3a14198e-d724-d378-d266-2508a224a19f",
|
|
||||||
"D2": "3a14198e-d724-f56e-468b-0110a8feb36a",
|
|
||||||
"D3": "3a14198e-d724-0cf1-dea9-a1f40fe7e13c",
|
|
||||||
"D4": "3a14198e-d724-0ddd-9654-f9352a421de9"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"试剂堆栈": {
|
|
||||||
"uuid": "",
|
|
||||||
"site_uuids": {
|
|
||||||
"A1": "3a14198c-c2cf-8b40-af28-b467808f1c36",
|
|
||||||
"A2": "3a14198c-c2d0-f3e7-871a-e470d144296f",
|
|
||||||
"A3": "3a14198c-c2d0-dc7d-b8d0-e1d88cee3094",
|
|
||||||
"A4": "3a14198c-c2d0-2070-efc8-44e245f10c6f",
|
|
||||||
"B1": "3a14198c-c2d0-354f-39ad-642e1a72fcb8",
|
|
||||||
"B2": "3a14198c-c2d0-1559-105d-0ea30682cab4",
|
|
||||||
"B3": "3a14198c-c2d0-725e-523d-34c037ac2440",
|
|
||||||
"B4": "3a14198c-c2d0-efce-0939-69ca5a7dfd39"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# 物料类型配置
|
|
||||||
MATERIAL_TYPE_MAPPINGS = {
|
|
||||||
"烧杯": ("BIOYOND_PolymerStation_1FlaskCarrier", "3a14196b-24f2-ca49-9081-0cab8021bf1a"),
|
|
||||||
"试剂瓶": ("BIOYOND_PolymerStation_1BottleCarrier", ""),
|
|
||||||
"样品板": ("BIOYOND_PolymerStation_6StockCarrier", "3a14196e-b7a0-a5da-1931-35f3000281e9"),
|
|
||||||
"分装板": ("BIOYOND_PolymerStation_6VialCarrier", "3a14196e-5dfe-6e21-0c79-fe2036d052c4"),
|
|
||||||
"样品瓶": ("BIOYOND_PolymerStation_Solid_Stock", "3a14196a-cf7d-8aea-48d8-b9662c7dba94"),
|
|
||||||
"90%分装小瓶": ("BIOYOND_PolymerStation_Solid_Vial", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea"),
|
|
||||||
"10%分装小瓶": ("BIOYOND_PolymerStation_Liquid_Vial", "3a14196c-76be-2279-4e22-7310d69aed68"),
|
|
||||||
}
|
|
||||||
|
|
||||||
# 步骤参数配置(各工作流的步骤UUID)
|
|
||||||
WORKFLOW_STEP_IDS = {
|
|
||||||
"reactor_taken_in": {
|
|
||||||
"config": ""
|
|
||||||
},
|
|
||||||
"liquid_feeding_beaker": {
|
|
||||||
"liquid": "",
|
|
||||||
"observe": ""
|
|
||||||
},
|
|
||||||
"liquid_feeding_vials_non_titration": {
|
|
||||||
"liquid": "",
|
|
||||||
"observe": ""
|
|
||||||
},
|
|
||||||
"liquid_feeding_solvents": {
|
|
||||||
"liquid": "",
|
|
||||||
"observe": ""
|
|
||||||
},
|
|
||||||
"solid_feeding_vials": {
|
|
||||||
"feeding": "",
|
|
||||||
"observe": ""
|
|
||||||
},
|
|
||||||
"liquid_feeding_titration": {
|
|
||||||
"liquid": "",
|
|
||||||
"observe": ""
|
|
||||||
},
|
|
||||||
"drip_back": {
|
|
||||||
"liquid": "",
|
|
||||||
"observe": ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LOCATION_MAPPING = {}
|
|
||||||
|
|
||||||
ACTION_NAMES = {}
|
|
||||||
|
|
||||||
HTTP_SERVICE_CONFIG = {}
|
|
||||||
329
unilabos/devices/workstation/bioyond_studio/config.py.deprecated
Normal file
329
unilabos/devices/workstation/bioyond_studio/config.py.deprecated
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
# config.py
|
||||||
|
"""
|
||||||
|
Bioyond工作站配置文件
|
||||||
|
包含API配置、工作流映射、物料类型映射、仓库库位映射等所有配置信息
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unilabos.resources.bioyond.decks import BIOYOND_PolymerReactionStation_Deck
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 基础配置
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# API配置
|
||||||
|
API_CONFIG = {
|
||||||
|
"api_key": "DE9BDDA0",
|
||||||
|
"api_host": "http://192.168.1.200:44402"
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTP 报送服务配置
|
||||||
|
HTTP_SERVICE_CONFIG = {
|
||||||
|
"http_service_host": "127.0.0.1", # 监听地址
|
||||||
|
"http_service_port": 8080, # 监听端口
|
||||||
|
}
|
||||||
|
|
||||||
|
# Deck配置 - 反应站工作台配置
|
||||||
|
DECK_CONFIG = BIOYOND_PolymerReactionStation_Deck(setup=True)
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 工作流配置
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 工作流ID映射
|
||||||
|
WORKFLOW_MAPPINGS = {
|
||||||
|
"reactor_taken_out": "3a16081e-4788-ca37-eff4-ceed8d7019d1",
|
||||||
|
"reactor_taken_in": "3a160df6-76b3-0957-9eb0-cb496d5721c6",
|
||||||
|
"Solid_feeding_vials": "3a160877-87e7-7699-7bc6-ec72b05eb5e6",
|
||||||
|
"Liquid_feeding_vials(non-titration)": "3a167d99-6158-c6f0-15b5-eb030f7d8e47",
|
||||||
|
"Liquid_feeding_solvents": "3a160824-0665-01ed-285a-51ef817a9046",
|
||||||
|
"Liquid_feeding(titration)": "3a16082a-96ac-0449-446a-4ed39f3365b6",
|
||||||
|
"liquid_feeding_beaker": "3a16087e-124f-8ddb-8ec1-c2dff09ca784",
|
||||||
|
"Drip_back": "3a162cf9-6aac-565a-ddd7-682ba1796a4a",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 工作流名称到显示名称的映射
|
||||||
|
WORKFLOW_TO_SECTION_MAP = {
|
||||||
|
'reactor_taken_in': '反应器放入',
|
||||||
|
'reactor_taken_out': '反应器取出',
|
||||||
|
'Solid_feeding_vials': '固体投料-小瓶',
|
||||||
|
'Liquid_feeding_vials(non-titration)': '液体投料-小瓶(非滴定)',
|
||||||
|
'Liquid_feeding_solvents': '液体投料-溶剂',
|
||||||
|
'Liquid_feeding(titration)': '液体投料-滴定',
|
||||||
|
'liquid_feeding_beaker': '液体投料-烧杯',
|
||||||
|
'Drip_back': '液体回滴'
|
||||||
|
}
|
||||||
|
|
||||||
|
# 工作流步骤ID配置
|
||||||
|
WORKFLOW_STEP_IDS = {
|
||||||
|
"reactor_taken_in": {
|
||||||
|
"config": "60a06f85-c5b3-29eb-180f-4f62dd7e2154"
|
||||||
|
},
|
||||||
|
"liquid_feeding_beaker": {
|
||||||
|
"liquid": "6808cda7-fee7-4092-97f0-5f9c2ffa60e3",
|
||||||
|
"observe": "1753c0de-dffc-4ee6-8458-805a2e227362"
|
||||||
|
},
|
||||||
|
"liquid_feeding_vials_non_titration": {
|
||||||
|
"liquid": "62ea6e95-3d5d-43db-bc1e-9a1802673861",
|
||||||
|
"observe": "3a167d99-6172-b67b-5f22-a7892197142e"
|
||||||
|
},
|
||||||
|
"liquid_feeding_solvents": {
|
||||||
|
"liquid": "1fcea355-2545-462b-b727-350b69a313bf",
|
||||||
|
"observe": "0553dfb3-9ac5-4ace-8e00-2f11029919a8"
|
||||||
|
},
|
||||||
|
"solid_feeding_vials": {
|
||||||
|
"feeding": "f7ae7448-4f20-4c1d-8096-df6fbadd787a",
|
||||||
|
"observe": "263c7ed5-7277-426b-bdff-d6fbf77bcc05"
|
||||||
|
},
|
||||||
|
"liquid_feeding_titration": {
|
||||||
|
"liquid": "a00ec41b-e666-4422-9c20-bfcd3cd15c54",
|
||||||
|
"observe": "ac738ff6-4c58-4155-87b1-d6f65a2c9ab5"
|
||||||
|
},
|
||||||
|
"drip_back": {
|
||||||
|
"liquid": "371be86a-ab77-4769-83e5-54580547c48a",
|
||||||
|
"observe": "ce024b9d-bd20-47b8-9f78-ca5ce7f44cf1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 工作流动作名称配置
|
||||||
|
ACTION_NAMES = {
|
||||||
|
"reactor_taken_in": {
|
||||||
|
"config": "通量-配置",
|
||||||
|
"stirring": "反应模块-开始搅拌"
|
||||||
|
},
|
||||||
|
"solid_feeding_vials": {
|
||||||
|
"feeding": "粉末加样模块-投料",
|
||||||
|
"observe": "反应模块-观察搅拌结果"
|
||||||
|
},
|
||||||
|
"liquid_feeding_vials_non_titration": {
|
||||||
|
"liquid": "稀释液瓶加液位-液体投料",
|
||||||
|
"observe": "反应模块-滴定结果观察"
|
||||||
|
},
|
||||||
|
"liquid_feeding_solvents": {
|
||||||
|
"liquid": "试剂AB放置位-试剂吸液分液",
|
||||||
|
"observe": "反应模块-观察搅拌结果"
|
||||||
|
},
|
||||||
|
"liquid_feeding_titration": {
|
||||||
|
"liquid": "稀释液瓶加液位-稀释液吸液分液",
|
||||||
|
"observe": "反应模块-滴定结果观察"
|
||||||
|
},
|
||||||
|
"liquid_feeding_beaker": {
|
||||||
|
"liquid": "烧杯溶液放置位-烧杯吸液分液",
|
||||||
|
"observe": "反应模块-观察搅拌结果"
|
||||||
|
},
|
||||||
|
"drip_back": {
|
||||||
|
"liquid": "试剂AB放置位-试剂吸液分液",
|
||||||
|
"observe": "反应模块-向下滴定结果观察"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 仓库配置
|
||||||
|
# ============================================================================
|
||||||
|
# 说明:
|
||||||
|
# - 出库和入库操作都需要UUID
|
||||||
|
WAREHOUSE_MAPPING = {
|
||||||
|
# ========== 反应站仓库 ==========
|
||||||
|
|
||||||
|
# 堆栈1左 - 反应站左侧堆栈 (4行×4列=16个库位, A01~D04)
|
||||||
|
"堆栈1左": {
|
||||||
|
"uuid": "3a14aa17-0d49-dce4-486e-4b5c85c8b366",
|
||||||
|
"site_uuids": {
|
||||||
|
"A01": "3a14aa17-0d49-11d7-a6e1-f236b3e5e5a3",
|
||||||
|
"A02": "3a14aa17-0d49-4bc5-8836-517b75473f5f",
|
||||||
|
"A03": "3a14aa17-0d49-c2bc-6222-5cee8d2d94f8",
|
||||||
|
"A04": "3a14aa17-0d49-3ce2-8e9a-008c38d116fb",
|
||||||
|
"B01": "3a14aa17-0d49-f49c-6b66-b27f185a3b32",
|
||||||
|
"B02": "3a14aa17-0d49-cf46-df85-a979c9c9920c",
|
||||||
|
"B03": "3a14aa17-0d49-7698-4a23-f7ffb7d48ba3",
|
||||||
|
"B04": "3a14aa17-0d49-1231-99be-d5870e6478e9",
|
||||||
|
"C01": "3a14aa17-0d49-be34-6fae-4aed9d48b70b",
|
||||||
|
"C02": "3a14aa17-0d49-11d7-0897-34921dcf6b7c",
|
||||||
|
"C03": "3a14aa17-0d49-9840-0bd5-9c63c1bb2c29",
|
||||||
|
"C04": "3a14aa17-0d49-8335-3bff-01da69ea4911",
|
||||||
|
"D01": "3a14aa17-0d49-2bea-c8e5-2b32094935d5",
|
||||||
|
"D02": "3a14aa17-0d49-cff4-e9e8-5f5f0bc1ef32",
|
||||||
|
"D03": "3a14aa17-0d49-4948-cb0a-78f30d1ca9b8",
|
||||||
|
"D04": "3a14aa17-0d49-fd2f-9dfb-a29b11e84099",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
# 堆栈1右 - 反应站右侧堆栈 (4行×4列=16个库位, A05~D08)
|
||||||
|
"堆栈1右": {
|
||||||
|
"uuid": "3a14aa17-0d49-dce4-486e-4b5c85c8b366",
|
||||||
|
"site_uuids": {
|
||||||
|
"A05": "3a14aa17-0d49-2c61-edc8-72a8ca7192dd",
|
||||||
|
"A06": "3a14aa17-0d49-60c8-2b00-40b17198f397",
|
||||||
|
"A07": "3a14aa17-0d49-ec5b-0b75-634dce8eed25",
|
||||||
|
"A08": "3a14aa17-0d49-3ec9-55b3-f3189c4ec53d",
|
||||||
|
"B05": "3a14aa17-0d49-6a4e-abcf-4c113eaaeaad",
|
||||||
|
"B06": "3a14aa17-0d49-e3f6-2dd6-28c2e8194fbe",
|
||||||
|
"B07": "3a14aa17-0d49-11a6-b861-ee895121bf52",
|
||||||
|
"B08": "3a14aa17-0d49-9c7d-1145-d554a6e482f0",
|
||||||
|
"C05": "3a14aa17-0d49-45c4-7a34-5105bc3e2368",
|
||||||
|
"C06": "3a14aa17-0d49-867e-39ab-31b3fe9014be",
|
||||||
|
"C07": "3a14aa17-0d49-ec56-c4b4-39fd9b2131e7",
|
||||||
|
"C08": "3a14aa17-0d49-1128-d7d9-ffb1231c98c0",
|
||||||
|
"D05": "3a14aa17-0d49-e843-f961-ea173326a14b",
|
||||||
|
"D06": "3a14aa17-0d49-4d26-a985-f188359c4f8b",
|
||||||
|
"D07": "3a14aa17-0d49-223a-b520-bc092bb42fe0",
|
||||||
|
"D08": "3a14aa17-0d49-4fa3-401a-6a444e1cca22",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
# 站内试剂存放堆栈
|
||||||
|
"站内试剂存放堆栈": {
|
||||||
|
"uuid": "3a14aa3b-9fab-9d8e-d1a7-828f01f51f0c",
|
||||||
|
"site_uuids": {
|
||||||
|
"A01": "3a14aa3b-9fab-adac-7b9c-e1ee446b51d5",
|
||||||
|
"A02": "3a14aa3b-9fab-ca72-febc-b7c304476c78"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
# 测量小瓶仓库(测密度)
|
||||||
|
"测量小瓶仓库": {
|
||||||
|
"uuid": "3a15012f-705b-c0de-3f9e-950c205f9921",
|
||||||
|
"site_uuids": {
|
||||||
|
"A01": "3a15012f-705e-0524-3161-c523b5aebc97",
|
||||||
|
"A02": "3a15012f-705e-7cd1-32ab-ad4fd1ab75c8",
|
||||||
|
"A03": "3a15012f-705e-a5d6-edac-bdbfec236260",
|
||||||
|
"B01": "3a15012f-705e-e0ee-80e0-10a6b3fc500d",
|
||||||
|
"B02": "3a15012f-705e-e499-180d-de06d60d0b21",
|
||||||
|
"B03": "3a15012f-705e-eff6-63f1-09f742096b26"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
# 站内Tip盒堆栈 - 用于存放枪头盒 (耗材)
|
||||||
|
"站内Tip盒堆栈": {
|
||||||
|
"uuid": "3a14aa3a-2d3c-b5c1-9ddf-7c4a957d459a",
|
||||||
|
"site_uuids": {
|
||||||
|
"A01": "3a14aa3a-2d3d-e700-411a-0ddf85e1f18a",
|
||||||
|
"A02": "3a14aa3a-2d3d-a7ce-099a-d5632fdafa24",
|
||||||
|
"A03": "3a14aa3a-2d3d-bdf6-a702-c60b38b08501",
|
||||||
|
"B01": "3a14aa3a-2d3d-d704-f076-2a8d5bc72cb8",
|
||||||
|
"B02": "3a14aa3a-2d3d-c350-2526-0778d173a5ac",
|
||||||
|
"B03": "3a14aa3a-2d3d-bc38-b356-f0de2e44e0c7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
# ========== 配液站仓库 ==========
|
||||||
|
"粉末堆栈": {
|
||||||
|
"uuid": "3a14198e-6928-121f-7ca6-88ad3ae7e6a0",
|
||||||
|
"site_uuids": {
|
||||||
|
"A01": "3a14198e-6929-31f0-8a22-0f98f72260df",
|
||||||
|
"A02": "3a14198e-6929-4379-affa-9a2935c17f99",
|
||||||
|
"A03": "3a14198e-6929-56da-9a1c-7f5fbd4ae8af",
|
||||||
|
"A04": "3a14198e-6929-5e99-2b79-80720f7cfb54",
|
||||||
|
"B01": "3a14198e-6929-f525-9a1b-1857552b28ee",
|
||||||
|
"B02": "3a14198e-6929-bf98-0fd5-26e1d68bf62d",
|
||||||
|
"B03": "3a14198e-6929-2d86-a468-602175a2b5aa",
|
||||||
|
"B04": "3a14198e-6929-1a98-ae57-e97660c489ad",
|
||||||
|
"C01": "3a14198e-6929-46fe-841e-03dd753f1e4a",
|
||||||
|
"C02": "3a14198e-6929-72ac-32ce-9b50245682b8",
|
||||||
|
"C03": "3a14198e-6929-8a0b-b686-6f4a2955c4e2",
|
||||||
|
"C04": "3a14198e-6929-a0ec-5f15-c0f9f339f963",
|
||||||
|
"D01": "3a14198e-6929-1bc9-a9bd-3b7ca66e7f95",
|
||||||
|
"D02": "3a14198e-6929-3bd8-e6c7-4a9fd93be118",
|
||||||
|
"D03": "3a14198e-6929-dde1-fc78-34a84b71afdf",
|
||||||
|
"D04": "3a14198e-6929-7ac8-915a-fea51cb2e884"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"溶液堆栈": {
|
||||||
|
"uuid": "3a14198e-d723-2c13-7d12-50143e190a23",
|
||||||
|
"site_uuids": {
|
||||||
|
"A01": "3a14198e-d724-e036-afdc-2ae39a7f3383",
|
||||||
|
"A02": "3a14198e-d724-d818-6d4f-5725191a24b5",
|
||||||
|
"A03": "3a14198e-d724-b5bb-adf3-4c5a0da6fb31",
|
||||||
|
"A04": "3a14198e-d724-d378-d266-2508a224a19f",
|
||||||
|
"B01": "3a14198e-d724-afa4-fc82-0ac8a9016791",
|
||||||
|
"B02": "3a14198e-d724-be8a-5e0b-012675e195c6",
|
||||||
|
"B03": "3a14198e-d724-ab4e-48cb-817c3c146707",
|
||||||
|
"B04": "3a14198e-d724-f56e-468b-0110a8feb36a",
|
||||||
|
"C01": "3a14198e-d724-ca48-bb9e-7e85751e55b6",
|
||||||
|
"C02": "3a14198e-d724-cc1e-5c2c-228a130f40a8",
|
||||||
|
"C03": "3a14198e-d724-7f18-1853-39d0c62e1d33",
|
||||||
|
"C04": "3a14198e-d724-0cf1-dea9-a1f40fe7e13c",
|
||||||
|
"D01": "3a14198e-d724-df6d-5e32-5483b3cab583",
|
||||||
|
"D02": "3a14198e-d724-1e28-c885-574c3df468d0",
|
||||||
|
"D03": "3a14198e-d724-28a2-a760-baa896f46b66",
|
||||||
|
"D04": "3a14198e-d724-0ddd-9654-f9352a421de9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"试剂堆栈": {
|
||||||
|
"uuid": "3a14198c-c2cc-0290-e086-44a428fba248",
|
||||||
|
"site_uuids": {
|
||||||
|
"A01": "3a14198c-c2cf-8b40-af28-b467808f1c36", # x=1, y=1, code=0001-0001
|
||||||
|
"A02": "3a14198c-c2d0-dc7d-b8d0-e1d88cee3094", # x=1, y=2, code=0001-0002
|
||||||
|
"A03": "3a14198c-c2d0-354f-39ad-642e1a72fcb8", # x=1, y=3, code=0001-0003
|
||||||
|
"A04": "3a14198c-c2d0-725e-523d-34c037ac2440", # x=1, y=4, code=0001-0004
|
||||||
|
"B01": "3a14198c-c2d0-f3e7-871a-e470d144296f", # x=2, y=1, code=0001-0005
|
||||||
|
"B02": "3a14198c-c2d0-2070-efc8-44e245f10c6f", # x=2, y=2, code=0001-0006
|
||||||
|
"B03": "3a14198c-c2d0-1559-105d-0ea30682cab4", # x=2, y=3, code=0001-0007
|
||||||
|
"B04": "3a14198c-c2d0-efce-0939-69ca5a7dfd39" # x=2, y=4, code=0001-0008
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 物料类型配置
|
||||||
|
# ============================================================================
|
||||||
|
# 说明:
|
||||||
|
# - 格式: PyLabRobot资源类型名称 → Bioyond系统typeId的UUID
|
||||||
|
# - 这个映射基于 resource.model 属性 (不是显示名称!)
|
||||||
|
# - UUID为空表示该类型暂未在Bioyond系统中定义
|
||||||
|
MATERIAL_TYPE_MAPPINGS = {
|
||||||
|
# ================================================配液站资源============================================================
|
||||||
|
# ==================================================样品===============================================================
|
||||||
|
"BIOYOND_PolymerStation_1FlaskCarrier": ("烧杯", "3a14196b-24f2-ca49-9081-0cab8021bf1a"), # 配液站-样品-烧杯
|
||||||
|
"BIOYOND_PolymerStation_1BottleCarrier": ("试剂瓶", "3a14196b-8bcf-a460-4f74-23f21ca79e72"), # 配液站-样品-试剂瓶
|
||||||
|
"BIOYOND_PolymerStation_6StockCarrier": ("分装板", "3a14196e-5dfe-6e21-0c79-fe2036d052c4"), # 配液站-样品-分装板
|
||||||
|
"BIOYOND_PolymerStation_Liquid_Vial": ("10%分装小瓶", "3a14196c-76be-2279-4e22-7310d69aed68"), # 配液站-样品-分装板-第一排小瓶
|
||||||
|
"BIOYOND_PolymerStation_Solid_Vial": ("90%分装小瓶", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea"), # 配液站-样品-分装板-第二排小瓶
|
||||||
|
# ==================================================试剂===============================================================
|
||||||
|
"BIOYOND_PolymerStation_8StockCarrier": ("样品板", "3a14196e-b7a0-a5da-1931-35f3000281e9"), # 配液站-试剂-样品板(8孔)
|
||||||
|
"BIOYOND_PolymerStation_Solid_Stock": ("样品瓶", "3a14196a-cf7d-8aea-48d8-b9662c7dba94"), # 配液站-试剂-样品板-样品瓶
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 动态生成的库位UUID映射(从WAREHOUSE_MAPPING中提取)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
LOCATION_MAPPING = {}
|
||||||
|
for warehouse_name, warehouse_config in WAREHOUSE_MAPPING.items():
|
||||||
|
if "site_uuids" in warehouse_config:
|
||||||
|
LOCATION_MAPPING.update(warehouse_config["site_uuids"])
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 物料默认参数配置
|
||||||
|
# ============================================================================
|
||||||
|
# 说明:
|
||||||
|
# - 为特定物料名称自动添加默认参数(如密度、分子量、单位等)
|
||||||
|
# - 格式: 物料名称 → {参数字典}
|
||||||
|
# - 在创建或更新物料时,会自动合并这些参数到 Parameters 字段
|
||||||
|
# - unit: 物料的计量单位(会用于 unit 字段)
|
||||||
|
# - density/densityUnit: 密度信息(会添加到 Parameters 中)
|
||||||
|
|
||||||
|
MATERIAL_DEFAULT_PARAMETERS = {
|
||||||
|
# 溶剂类
|
||||||
|
"NMP": {
|
||||||
|
"unit": "毫升",
|
||||||
|
"density": "1.03",
|
||||||
|
"densityUnit": "g/mL",
|
||||||
|
"description": "N-甲基吡咯烷酮 (N-Methyl-2-pyrrolidone)"
|
||||||
|
},
|
||||||
|
# 可以继续添加其他物料...
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 物料类型默认参数配置
|
||||||
|
# ============================================================================
|
||||||
|
# 说明:
|
||||||
|
# - 为特定物料类型(UUID)自动添加默认参数
|
||||||
|
# - 格式: Bioyond类型UUID → {参数字典}
|
||||||
|
# - 优先级低于按名称匹配的配置
|
||||||
|
MATERIAL_TYPE_PARAMETERS = {
|
||||||
|
# 示例:
|
||||||
|
# "3a14196b-24f2-ca49-9081-0cab8021bf1a": { # 烧杯
|
||||||
|
# "unit": "个"
|
||||||
|
# }
|
||||||
|
}
|
||||||
@@ -4,7 +4,8 @@ import time
|
|||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List
|
||||||
from typing_extensions import TypedDict
|
from typing_extensions import TypedDict
|
||||||
import requests
|
import requests
|
||||||
from unilabos.devices.workstation.bioyond_studio.config import API_CONFIG
|
import pint
|
||||||
|
|
||||||
|
|
||||||
from unilabos.devices.workstation.bioyond_studio.bioyond_rpc import BioyondException
|
from unilabos.devices.workstation.bioyond_studio.bioyond_rpc import BioyondException
|
||||||
from unilabos.devices.workstation.bioyond_studio.station import BioyondWorkstation
|
from unilabos.devices.workstation.bioyond_studio.station import BioyondWorkstation
|
||||||
@@ -25,13 +26,89 @@ class ComputeExperimentDesignReturn(TypedDict):
|
|||||||
class BioyondDispensingStation(BioyondWorkstation):
|
class BioyondDispensingStation(BioyondWorkstation):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
config,
|
config: dict = None,
|
||||||
# 桌子
|
deck=None,
|
||||||
deck,
|
protocol_type=None,
|
||||||
*args,
|
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
super().__init__(config, deck, *args, **kwargs)
|
"""初始化配液站
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: 配置字典,应包含material_type_mappings等配置
|
||||||
|
deck: Deck对象
|
||||||
|
protocol_type: 协议类型(由ROS系统传递,此处忽略)
|
||||||
|
**kwargs: 其他可能的参数
|
||||||
|
"""
|
||||||
|
if config is None:
|
||||||
|
config = {}
|
||||||
|
|
||||||
|
# 将 kwargs 合并到 config 中 (处理扁平化配置如 api_key)
|
||||||
|
config.update(kwargs)
|
||||||
|
|
||||||
|
if deck is None and config:
|
||||||
|
deck = config.get('deck')
|
||||||
|
|
||||||
|
# 🔧 修复: 确保 Deck 上的 warehouses 具有正确的 UUID (必须在 super().__init__ 之前执行,因为父类会触发同步)
|
||||||
|
# 从配置中读取 warehouse_mapping,并应用到实际的 deck 资源上
|
||||||
|
if config and "warehouse_mapping" in config and deck:
|
||||||
|
warehouse_mapping = config["warehouse_mapping"]
|
||||||
|
print(f"正在根据配置更新 Deck warehouse UUIDs... (共有 {len(warehouse_mapping)} 个配置)")
|
||||||
|
|
||||||
|
user_deck = deck
|
||||||
|
# 初始化 warehouses 字典
|
||||||
|
if not hasattr(user_deck, "warehouses") or user_deck.warehouses is None:
|
||||||
|
user_deck.warehouses = {}
|
||||||
|
|
||||||
|
# 1. 尝试从 children 中查找匹配的资源
|
||||||
|
for child in user_deck.children:
|
||||||
|
# 简单判断: 如果名字在 mapping 中,就认为是 warehouse
|
||||||
|
if child.name in warehouse_mapping:
|
||||||
|
user_deck.warehouses[child.name] = child
|
||||||
|
print(f" - 从子资源中找到 warehouse: {child.name}")
|
||||||
|
|
||||||
|
# 2. 如果还是没找到,且 Deck 类有 setup 方法,尝试调用 setup (针对 Deck 对象正确但未初始化的情况)
|
||||||
|
if not user_deck.warehouses and hasattr(user_deck, "setup"):
|
||||||
|
print(" - 尝试调用 deck.setup() 初始化仓库...")
|
||||||
|
try:
|
||||||
|
user_deck.setup()
|
||||||
|
# setup 后重新检查
|
||||||
|
if hasattr(user_deck, "warehouses") and user_deck.warehouses:
|
||||||
|
print(f" - setup() 成功,找到 {len(user_deck.warehouses)} 个仓库")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" - 调用 setup() 失败: {e}")
|
||||||
|
|
||||||
|
# 3. 如果仍然为空,可能需要手动创建 (仅针对特定已知的 Deck 类型进行补救,这里暂时只打印警告)
|
||||||
|
if not user_deck.warehouses:
|
||||||
|
print(" - ⚠️ 仍然无法找到任何 warehouse 资源!")
|
||||||
|
|
||||||
|
for wh_name, wh_config in warehouse_mapping.items():
|
||||||
|
target_uuid = wh_config.get("uuid")
|
||||||
|
|
||||||
|
# 尝试在 deck.warehouses 中查找
|
||||||
|
wh_resource = None
|
||||||
|
if hasattr(user_deck, "warehouses") and wh_name in user_deck.warehouses:
|
||||||
|
wh_resource = user_deck.warehouses[wh_name]
|
||||||
|
|
||||||
|
# 如果没找到,尝试在所有子资源中查找
|
||||||
|
if not wh_resource:
|
||||||
|
wh_resource = user_deck.get_resource(wh_name)
|
||||||
|
|
||||||
|
if wh_resource:
|
||||||
|
if target_uuid:
|
||||||
|
current_uuid = getattr(wh_resource, "uuid", None)
|
||||||
|
print(f"✅ 更新仓库 '{wh_name}' UUID: {current_uuid} -> {target_uuid}")
|
||||||
|
|
||||||
|
# 动态添加 uuid 属性
|
||||||
|
wh_resource.uuid = target_uuid
|
||||||
|
# 同时也确保 category 正确,避免 graphio 识别错误
|
||||||
|
# wh_resource.category = "warehouse"
|
||||||
|
else:
|
||||||
|
print(f"⚠️ 仓库 '{wh_name}' 在配置中没有 UUID")
|
||||||
|
else:
|
||||||
|
print(f"❌ 在 Deck 中未找到配置的仓库: '{wh_name}'")
|
||||||
|
|
||||||
|
super().__init__(bioyond_config=config, deck=deck)
|
||||||
|
|
||||||
# self.config = config
|
# self.config = config
|
||||||
# self.api_key = config["api_key"]
|
# self.api_key = config["api_key"]
|
||||||
# self.host = config["api_host"]
|
# self.host = config["api_host"]
|
||||||
@@ -43,6 +120,41 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
# 用于跟踪任务完成状态的字典: {orderCode: {status, order_id, timestamp}}
|
# 用于跟踪任务完成状态的字典: {orderCode: {status, order_id, timestamp}}
|
||||||
self.order_completion_status = {}
|
self.order_completion_status = {}
|
||||||
|
|
||||||
|
# 初始化 pint 单位注册表
|
||||||
|
self.ureg = pint.UnitRegistry()
|
||||||
|
|
||||||
|
# 化合物信息
|
||||||
|
self.compound_info = {
|
||||||
|
"MolWt": {
|
||||||
|
"MDA": 108.14 * self.ureg.g / self.ureg.mol,
|
||||||
|
"TDA": 122.16 * self.ureg.g / self.ureg.mol,
|
||||||
|
"PAPP": 521.62 * self.ureg.g / self.ureg.mol,
|
||||||
|
"BTDA": 322.23 * self.ureg.g / self.ureg.mol,
|
||||||
|
"BPDA": 294.22 * self.ureg.g / self.ureg.mol,
|
||||||
|
"6FAP": 366.26 * self.ureg.g / self.ureg.mol,
|
||||||
|
"PMDA": 218.12 * self.ureg.g / self.ureg.mol,
|
||||||
|
"MPDA": 108.14 * self.ureg.g / self.ureg.mol,
|
||||||
|
"SIDA": 248.51 * self.ureg.g / self.ureg.mol,
|
||||||
|
"ODA": 200.236 * self.ureg.g / self.ureg.mol,
|
||||||
|
"4,4'-ODA": 200.236 * self.ureg.g / self.ureg.mol,
|
||||||
|
"134": 292.34 * self.ureg.g / self.ureg.mol,
|
||||||
|
},
|
||||||
|
"FuncGroup": {
|
||||||
|
"MDA": "Amine",
|
||||||
|
"TDA": "Amine",
|
||||||
|
"PAPP": "Amine",
|
||||||
|
"BTDA": "Anhydride",
|
||||||
|
"BPDA": "Anhydride",
|
||||||
|
"6FAP": "Amine",
|
||||||
|
"MPDA": "Amine",
|
||||||
|
"SIDA": "Amine",
|
||||||
|
"PMDA": "Anhydride",
|
||||||
|
"ODA": "Amine",
|
||||||
|
"4,4'-ODA": "Amine",
|
||||||
|
"134": "Amine",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
def _post_project_api(self, endpoint: str, data: Any) -> Dict[str, Any]:
|
def _post_project_api(self, endpoint: str, data: Any) -> Dict[str, Any]:
|
||||||
"""项目接口通用POST调用
|
"""项目接口通用POST调用
|
||||||
|
|
||||||
@@ -54,7 +166,7 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
dict: 服务端响应,失败时返回 {code:0,message,...}
|
dict: 服务端响应,失败时返回 {code:0,message,...}
|
||||||
"""
|
"""
|
||||||
request_data = {
|
request_data = {
|
||||||
"apiKey": API_CONFIG["api_key"],
|
"apiKey": self.bioyond_config["api_key"],
|
||||||
"requestTime": self.hardware_interface.get_current_time_iso8601(),
|
"requestTime": self.hardware_interface.get_current_time_iso8601(),
|
||||||
"data": data
|
"data": data
|
||||||
}
|
}
|
||||||
@@ -85,7 +197,7 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
dict: 服务端响应,失败时返回 {code:0,message,...}
|
dict: 服务端响应,失败时返回 {code:0,message,...}
|
||||||
"""
|
"""
|
||||||
request_data = {
|
request_data = {
|
||||||
"apiKey": API_CONFIG["api_key"],
|
"apiKey": self.bioyond_config["api_key"],
|
||||||
"requestTime": self.hardware_interface.get_current_time_iso8601(),
|
"requestTime": self.hardware_interface.get_current_time_iso8601(),
|
||||||
"data": data
|
"data": data
|
||||||
}
|
}
|
||||||
@@ -118,20 +230,22 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
ratio = json.loads(ratio)
|
ratio = json.loads(ratio)
|
||||||
except Exception:
|
except Exception:
|
||||||
ratio = {}
|
ratio = {}
|
||||||
root = str(Path(__file__).resolve().parents[3])
|
|
||||||
if root not in sys.path:
|
|
||||||
sys.path.append(root)
|
|
||||||
try:
|
|
||||||
mod = importlib.import_module("tem.compute")
|
|
||||||
except Exception as e:
|
|
||||||
raise BioyondException(f"无法导入计算模块: {e}")
|
|
||||||
try:
|
try:
|
||||||
wp = float(wt_percent) if isinstance(wt_percent, str) else wt_percent
|
wp = float(wt_percent) if isinstance(wt_percent, str) else wt_percent
|
||||||
mt = float(m_tot) if isinstance(m_tot, str) else m_tot
|
mt = float(m_tot) if isinstance(m_tot, str) else m_tot
|
||||||
tp = float(titration_percent) if isinstance(titration_percent, str) else titration_percent
|
tp = float(titration_percent) if isinstance(titration_percent, str) else titration_percent
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise BioyondException(f"参数解析失败: {e}")
|
raise BioyondException(f"参数解析失败: {e}")
|
||||||
res = mod.generate_experiment_design(ratio=ratio, wt_percent=wp, m_tot=mt, titration_percent=tp)
|
|
||||||
|
# 2. 调用内部计算方法
|
||||||
|
res = self._generate_experiment_design(
|
||||||
|
ratio=ratio,
|
||||||
|
wt_percent=wp,
|
||||||
|
m_tot=mt,
|
||||||
|
titration_percent=tp
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. 构造返回结果
|
||||||
out = {
|
out = {
|
||||||
"solutions": res.get("solutions", []),
|
"solutions": res.get("solutions", []),
|
||||||
"titration": res.get("titration", {}),
|
"titration": res.get("titration", {}),
|
||||||
@@ -140,11 +254,248 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
"return_info": json.dumps(res, ensure_ascii=False)
|
"return_info": json.dumps(res, ensure_ascii=False)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|
||||||
except BioyondException:
|
except BioyondException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise BioyondException(str(e))
|
raise BioyondException(str(e))
|
||||||
|
|
||||||
|
def _generate_experiment_design(
|
||||||
|
self,
|
||||||
|
ratio: dict,
|
||||||
|
wt_percent: float = 0.25,
|
||||||
|
m_tot: float = 70,
|
||||||
|
titration_percent: float = 0.03,
|
||||||
|
) -> dict:
|
||||||
|
"""内部方法:生成实验设计
|
||||||
|
|
||||||
|
根据FuncGroup自动区分二胺和二酐,每种二胺单独配溶液,严格按照ratio顺序投料。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
ratio: 化合物配比字典,格式: {"compound_name": ratio_value}
|
||||||
|
wt_percent: 固体重量百分比
|
||||||
|
m_tot: 反应混合物总质量(g)
|
||||||
|
titration_percent: 滴定溶液百分比
|
||||||
|
|
||||||
|
返回:
|
||||||
|
包含实验设计详细参数的字典
|
||||||
|
"""
|
||||||
|
# 溶剂密度
|
||||||
|
ρ_solvent = 1.03 * self.ureg.g / self.ureg.ml
|
||||||
|
# 二酐溶解度
|
||||||
|
solubility = 0.02 * self.ureg.g / self.ureg.ml
|
||||||
|
# 投入固体时最小溶剂体积
|
||||||
|
V_min = 30 * self.ureg.ml
|
||||||
|
m_tot = m_tot * self.ureg.g
|
||||||
|
|
||||||
|
# 保持ratio中的顺序
|
||||||
|
compound_names = list(ratio.keys())
|
||||||
|
compound_ratios = list(ratio.values())
|
||||||
|
|
||||||
|
# 验证所有化合物是否在 compound_info 中定义
|
||||||
|
undefined_compounds = [name for name in compound_names if name not in self.compound_info["MolWt"]]
|
||||||
|
if undefined_compounds:
|
||||||
|
available = list(self.compound_info["MolWt"].keys())
|
||||||
|
raise ValueError(
|
||||||
|
f"以下化合物未在 compound_info 中定义: {undefined_compounds}。"
|
||||||
|
f"可用的化合物: {available}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取各化合物的分子量和官能团类型
|
||||||
|
molecular_weights = [self.compound_info["MolWt"][name] for name in compound_names]
|
||||||
|
func_groups = [self.compound_info["FuncGroup"][name] for name in compound_names]
|
||||||
|
|
||||||
|
# 记录化合物信息用于调试
|
||||||
|
self.hardware_interface._logger.info(f"化合物名称: {compound_names}")
|
||||||
|
self.hardware_interface._logger.info(f"官能团类型: {func_groups}")
|
||||||
|
|
||||||
|
# 按原始顺序分离二胺和二酐
|
||||||
|
ordered_compounds = list(zip(compound_names, compound_ratios, molecular_weights, func_groups))
|
||||||
|
diamine_compounds = [(name, ratio_val, mw, i) for i, (name, ratio_val, mw, fg) in enumerate(ordered_compounds) if fg == "Amine"]
|
||||||
|
anhydride_compounds = [(name, ratio_val, mw, i) for i, (name, ratio_val, mw, fg) in enumerate(ordered_compounds) if fg == "Anhydride"]
|
||||||
|
|
||||||
|
if not diamine_compounds or not anhydride_compounds:
|
||||||
|
raise ValueError(
|
||||||
|
f"需要同时包含二胺(Amine)和二酐(Anhydride)化合物。"
|
||||||
|
f"当前二胺: {[c[0] for c in diamine_compounds]}, "
|
||||||
|
f"当前二酐: {[c[0] for c in anhydride_compounds]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 计算加权平均分子量 (基于摩尔比)
|
||||||
|
total_molar_ratio = sum(compound_ratios)
|
||||||
|
weighted_molecular_weight = sum(ratio_val * mw for ratio_val, mw in zip(compound_ratios, molecular_weights))
|
||||||
|
|
||||||
|
# 取最后一个二酐用于滴定
|
||||||
|
titration_anhydride = anhydride_compounds[-1]
|
||||||
|
solid_anhydrides = anhydride_compounds[:-1] if len(anhydride_compounds) > 1 else []
|
||||||
|
|
||||||
|
# 二胺溶液配制参数 - 每种二胺单独配制
|
||||||
|
diamine_solutions = []
|
||||||
|
total_diamine_volume = 0 * self.ureg.ml
|
||||||
|
|
||||||
|
# 计算反应物的总摩尔量
|
||||||
|
n_reactant = m_tot * wt_percent / weighted_molecular_weight
|
||||||
|
|
||||||
|
for name, ratio_val, mw, order_index in diamine_compounds:
|
||||||
|
# 跳过 SIDA
|
||||||
|
if name == "SIDA":
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 计算该二胺需要的摩尔数
|
||||||
|
n_diamine_needed = n_reactant * ratio_val
|
||||||
|
|
||||||
|
# 二胺溶液配制参数 (每种二胺固定配制参数)
|
||||||
|
m_diamine_solid = 5.0 * self.ureg.g # 每种二胺固体质量
|
||||||
|
V_solvent_for_this = 20 * self.ureg.ml # 每种二胺溶剂体积
|
||||||
|
m_solvent_for_this = ρ_solvent * V_solvent_for_this
|
||||||
|
|
||||||
|
# 计算该二胺溶液的浓度
|
||||||
|
c_diamine = (m_diamine_solid / mw) / V_solvent_for_this
|
||||||
|
|
||||||
|
# 计算需要移取的溶液体积
|
||||||
|
V_diamine_needed = n_diamine_needed / c_diamine
|
||||||
|
|
||||||
|
diamine_solutions.append({
|
||||||
|
"name": name,
|
||||||
|
"order": order_index,
|
||||||
|
"solid_mass": m_diamine_solid.magnitude,
|
||||||
|
"solvent_volume": V_solvent_for_this.magnitude,
|
||||||
|
"concentration": c_diamine.magnitude,
|
||||||
|
"volume_needed": V_diamine_needed.magnitude,
|
||||||
|
"molar_ratio": ratio_val
|
||||||
|
})
|
||||||
|
|
||||||
|
total_diamine_volume += V_diamine_needed
|
||||||
|
|
||||||
|
# 按原始顺序排序
|
||||||
|
diamine_solutions.sort(key=lambda x: x["order"])
|
||||||
|
|
||||||
|
# 计算滴定二酐的质量
|
||||||
|
titration_name, titration_ratio, titration_mw, _ = titration_anhydride
|
||||||
|
m_titration_anhydride = n_reactant * titration_ratio * titration_mw
|
||||||
|
m_titration_90 = m_titration_anhydride * (1 - titration_percent)
|
||||||
|
m_titration_10 = m_titration_anhydride * titration_percent
|
||||||
|
|
||||||
|
# 计算其他固体二酐的质量 (按顺序)
|
||||||
|
solid_anhydride_masses = []
|
||||||
|
for name, ratio_val, mw, order_index in solid_anhydrides:
|
||||||
|
mass = n_reactant * ratio_val * mw
|
||||||
|
solid_anhydride_masses.append({
|
||||||
|
"name": name,
|
||||||
|
"order": order_index,
|
||||||
|
"mass": mass.magnitude,
|
||||||
|
"molar_ratio": ratio_val
|
||||||
|
})
|
||||||
|
|
||||||
|
# 按原始顺序排序
|
||||||
|
solid_anhydride_masses.sort(key=lambda x: x["order"])
|
||||||
|
|
||||||
|
# 计算溶剂用量
|
||||||
|
total_diamine_solution_mass = sum(
|
||||||
|
sol["volume_needed"] * ρ_solvent for sol in diamine_solutions
|
||||||
|
) * self.ureg.ml
|
||||||
|
|
||||||
|
# 预估滴定溶剂量、计算补加溶剂量
|
||||||
|
m_solvent_titration = m_titration_10 / solubility * ρ_solvent
|
||||||
|
m_solvent_add = m_tot * (1 - wt_percent) - total_diamine_solution_mass - m_solvent_titration
|
||||||
|
|
||||||
|
# 检查最小溶剂体积要求
|
||||||
|
total_liquid_volume = (total_diamine_solution_mass + m_solvent_add) / ρ_solvent
|
||||||
|
m_tot_min = V_min / total_liquid_volume * m_tot
|
||||||
|
|
||||||
|
# 如果需要,按比例放大
|
||||||
|
scale_factor = 1.0
|
||||||
|
if m_tot_min > m_tot:
|
||||||
|
scale_factor = (m_tot_min / m_tot).magnitude
|
||||||
|
m_titration_90 *= scale_factor
|
||||||
|
m_titration_10 *= scale_factor
|
||||||
|
m_solvent_add *= scale_factor
|
||||||
|
m_solvent_titration *= scale_factor
|
||||||
|
|
||||||
|
# 更新二胺溶液用量
|
||||||
|
for sol in diamine_solutions:
|
||||||
|
sol["volume_needed"] *= scale_factor
|
||||||
|
|
||||||
|
# 更新固体二酐用量
|
||||||
|
for anhydride in solid_anhydride_masses:
|
||||||
|
anhydride["mass"] *= scale_factor
|
||||||
|
|
||||||
|
m_tot = m_tot_min
|
||||||
|
|
||||||
|
# 生成投料顺序
|
||||||
|
feeding_order = []
|
||||||
|
|
||||||
|
# 1. 固体二酐 (按顺序)
|
||||||
|
for anhydride in solid_anhydride_masses:
|
||||||
|
feeding_order.append({
|
||||||
|
"step": len(feeding_order) + 1,
|
||||||
|
"type": "solid_anhydride",
|
||||||
|
"name": anhydride["name"],
|
||||||
|
"amount": anhydride["mass"],
|
||||||
|
"order": anhydride["order"]
|
||||||
|
})
|
||||||
|
|
||||||
|
# 2. 二胺溶液 (按顺序)
|
||||||
|
for sol in diamine_solutions:
|
||||||
|
feeding_order.append({
|
||||||
|
"step": len(feeding_order) + 1,
|
||||||
|
"type": "diamine_solution",
|
||||||
|
"name": sol["name"],
|
||||||
|
"amount": sol["volume_needed"],
|
||||||
|
"order": sol["order"]
|
||||||
|
})
|
||||||
|
|
||||||
|
# 3. 主要二酐粉末
|
||||||
|
feeding_order.append({
|
||||||
|
"step": len(feeding_order) + 1,
|
||||||
|
"type": "main_anhydride",
|
||||||
|
"name": titration_name,
|
||||||
|
"amount": m_titration_90.magnitude,
|
||||||
|
"order": titration_anhydride[3]
|
||||||
|
})
|
||||||
|
|
||||||
|
# 4. 补加溶剂
|
||||||
|
if m_solvent_add > 0:
|
||||||
|
feeding_order.append({
|
||||||
|
"step": len(feeding_order) + 1,
|
||||||
|
"type": "additional_solvent",
|
||||||
|
"name": "溶剂",
|
||||||
|
"amount": m_solvent_add.magnitude,
|
||||||
|
"order": 999
|
||||||
|
})
|
||||||
|
|
||||||
|
# 5. 滴定二酐溶液
|
||||||
|
feeding_order.append({
|
||||||
|
"step": len(feeding_order) + 1,
|
||||||
|
"type": "titration_anhydride",
|
||||||
|
"name": f"{titration_name} 滴定液",
|
||||||
|
"amount": m_titration_10.magnitude,
|
||||||
|
"titration_solvent": m_solvent_titration.magnitude,
|
||||||
|
"order": titration_anhydride[3]
|
||||||
|
})
|
||||||
|
|
||||||
|
# 返回实验设计结果
|
||||||
|
results = {
|
||||||
|
"total_mass": m_tot.magnitude,
|
||||||
|
"scale_factor": scale_factor,
|
||||||
|
"solutions": diamine_solutions,
|
||||||
|
"solids": solid_anhydride_masses,
|
||||||
|
"titration": {
|
||||||
|
"name": titration_name,
|
||||||
|
"main_portion": m_titration_90.magnitude,
|
||||||
|
"titration_portion": m_titration_10.magnitude,
|
||||||
|
"titration_solvent": m_solvent_titration.magnitude,
|
||||||
|
},
|
||||||
|
"solvents": {
|
||||||
|
"additional_solvent": m_solvent_add.magnitude,
|
||||||
|
"total_liquid_volume": total_liquid_volume.magnitude
|
||||||
|
},
|
||||||
|
"feeding_order": feeding_order,
|
||||||
|
"minimum_required_mass": m_tot_min.magnitude
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
# 90%10%小瓶投料任务创建方法
|
# 90%10%小瓶投料任务创建方法
|
||||||
def create_90_10_vial_feeding_task(self,
|
def create_90_10_vial_feeding_task(self,
|
||||||
order_name: str = None,
|
order_name: str = None,
|
||||||
@@ -961,6 +1312,108 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
'actualVolume': actual_volume
|
'actualVolume': actual_volume
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _simplify_report(self, report) -> Dict[str, Any]:
|
||||||
|
"""简化实验报告,只保留关键信息,去除冗余的工作流参数"""
|
||||||
|
if not isinstance(report, dict):
|
||||||
|
return report
|
||||||
|
|
||||||
|
data = report.get('data', {})
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return report
|
||||||
|
|
||||||
|
# 提取关键信息
|
||||||
|
simplified = {
|
||||||
|
'name': data.get('name'),
|
||||||
|
'code': data.get('code'),
|
||||||
|
'requester': data.get('requester'),
|
||||||
|
'workflowName': data.get('workflowName'),
|
||||||
|
'workflowStep': data.get('workflowStep'),
|
||||||
|
'requestTime': data.get('requestTime'),
|
||||||
|
'startPreparationTime': data.get('startPreparationTime'),
|
||||||
|
'completeTime': data.get('completeTime'),
|
||||||
|
'useTime': data.get('useTime'),
|
||||||
|
'status': data.get('status'),
|
||||||
|
'statusName': data.get('statusName'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 提取物料信息(简化版)
|
||||||
|
pre_intakes = data.get('preIntakes', [])
|
||||||
|
if pre_intakes and isinstance(pre_intakes, list):
|
||||||
|
first_intake = pre_intakes[0]
|
||||||
|
sample_materials = first_intake.get('sampleMaterials', [])
|
||||||
|
|
||||||
|
# 简化物料信息
|
||||||
|
simplified_materials = []
|
||||||
|
for material in sample_materials:
|
||||||
|
if isinstance(material, dict):
|
||||||
|
mat_info = {
|
||||||
|
'materialName': material.get('materialName'),
|
||||||
|
'materialTypeName': material.get('materialTypeName'),
|
||||||
|
'materialCode': material.get('materialCode'),
|
||||||
|
'materialLocation': material.get('materialLocation'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 解析parameters中的关键信息(如密度、加料历史等)
|
||||||
|
params_str = material.get('parameters', '{}')
|
||||||
|
try:
|
||||||
|
params = json.loads(params_str) if isinstance(params_str, str) else params_str
|
||||||
|
if isinstance(params, dict):
|
||||||
|
# 只保留关键参数
|
||||||
|
if 'density' in params:
|
||||||
|
mat_info['density'] = params['density']
|
||||||
|
if 'feedingHistory' in params:
|
||||||
|
mat_info['feedingHistory'] = params['feedingHistory']
|
||||||
|
if 'liquidVolume' in params:
|
||||||
|
mat_info['liquidVolume'] = params['liquidVolume']
|
||||||
|
if 'm_diamine_tot' in params:
|
||||||
|
mat_info['m_diamine_tot'] = params['m_diamine_tot']
|
||||||
|
if 'wt_diamine' in params:
|
||||||
|
mat_info['wt_diamine'] = params['wt_diamine']
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
simplified_materials.append(mat_info)
|
||||||
|
|
||||||
|
simplified['sampleMaterials'] = simplified_materials
|
||||||
|
|
||||||
|
# 提取extraProperties中的实际值
|
||||||
|
extra_props = first_intake.get('extraProperties', {})
|
||||||
|
if isinstance(extra_props, dict):
|
||||||
|
simplified_extra = {}
|
||||||
|
for key, value in extra_props.items():
|
||||||
|
try:
|
||||||
|
parsed_value = json.loads(value) if isinstance(value, str) else value
|
||||||
|
simplified_extra[key] = parsed_value
|
||||||
|
except:
|
||||||
|
simplified_extra[key] = value
|
||||||
|
simplified['extraProperties'] = simplified_extra
|
||||||
|
|
||||||
|
return {
|
||||||
|
'data': simplified,
|
||||||
|
'code': report.get('code'),
|
||||||
|
'message': report.get('message'),
|
||||||
|
'timestamp': report.get('timestamp')
|
||||||
|
}
|
||||||
|
|
||||||
|
def scheduler_start(self) -> dict:
|
||||||
|
"""启动调度器 - 启动Bioyond工作站的任务调度器,开始执行队列中的任务
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: 包含return_info的字典,return_info为整型(1=成功)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
BioyondException: 调度器启动失败时抛出异常
|
||||||
|
"""
|
||||||
|
result = self.hardware_interface.scheduler_start()
|
||||||
|
self.hardware_interface._logger.info(f"调度器启动结果: {result}")
|
||||||
|
|
||||||
|
if result != 1:
|
||||||
|
error_msg = "启动调度器失败: 有未处理错误,调度无法启动。请检查Bioyond系统状态。"
|
||||||
|
self.hardware_interface._logger.error(error_msg)
|
||||||
|
raise BioyondException(error_msg)
|
||||||
|
|
||||||
|
return {"return_info": result}
|
||||||
|
|
||||||
# 等待多个任务完成并获取实验报告
|
# 等待多个任务完成并获取实验报告
|
||||||
def wait_for_multiple_orders_and_get_reports(self,
|
def wait_for_multiple_orders_and_get_reports(self,
|
||||||
batch_create_result: str = None,
|
batch_create_result: str = None,
|
||||||
@@ -1002,7 +1455,12 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
|
|
||||||
# 验证batch_create_result参数
|
# 验证batch_create_result参数
|
||||||
if not batch_create_result or batch_create_result == "":
|
if not batch_create_result or batch_create_result == "":
|
||||||
raise BioyondException("batch_create_result参数为空,请确保从batch_create节点正确连接handle")
|
raise BioyondException(
|
||||||
|
"batch_create_result参数为空,请确保:\n"
|
||||||
|
"1. batch_create节点与wait节点之间正确连接了handle\n"
|
||||||
|
"2. batch_create节点成功执行并返回了结果\n"
|
||||||
|
"3. 检查上游batch_create任务是否成功创建了订单"
|
||||||
|
)
|
||||||
|
|
||||||
# 解析batch_create_result JSON对象
|
# 解析batch_create_result JSON对象
|
||||||
try:
|
try:
|
||||||
@@ -1031,7 +1489,17 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
|
|
||||||
# 验证提取的数据
|
# 验证提取的数据
|
||||||
if not order_codes:
|
if not order_codes:
|
||||||
raise BioyondException("batch_create_result中未找到order_codes字段或为空")
|
self.hardware_interface._logger.error(
|
||||||
|
f"batch_create任务未生成任何订单。batch_create_result内容: {batch_create_result}"
|
||||||
|
)
|
||||||
|
raise BioyondException(
|
||||||
|
"batch_create_result中未找到order_codes或为空。\n"
|
||||||
|
"可能的原因:\n"
|
||||||
|
"1. batch_create任务执行失败(检查任务是否报错)\n"
|
||||||
|
"2. 物料配置问题(如'物料样品板分配失败')\n"
|
||||||
|
"3. Bioyond系统状态异常\n"
|
||||||
|
f"请检查batch_create任务的执行结果"
|
||||||
|
)
|
||||||
if not order_ids:
|
if not order_ids:
|
||||||
raise BioyondException("batch_create_result中未找到order_ids字段或为空")
|
raise BioyondException("batch_create_result中未找到order_ids字段或为空")
|
||||||
|
|
||||||
@@ -1114,6 +1582,8 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
self.hardware_interface._logger.info(
|
self.hardware_interface._logger.info(
|
||||||
f"成功获取任务 {order_code} 的实验报告"
|
f"成功获取任务 {order_code} 的实验报告"
|
||||||
)
|
)
|
||||||
|
# 简化报告,去除冗余信息
|
||||||
|
report = self._simplify_report(report)
|
||||||
|
|
||||||
reports.append({
|
reports.append({
|
||||||
"order_code": order_code,
|
"order_code": order_code,
|
||||||
@@ -1288,7 +1758,7 @@ class BioyondDispensingStation(BioyondWorkstation):
|
|||||||
f"开始执行批量物料转移: {len(transfer_groups)}组任务 -> {target_device_id}"
|
f"开始执行批量物料转移: {len(transfer_groups)}组任务 -> {target_device_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
from .config import WAREHOUSE_MAPPING
|
warehouse_mapping = self.bioyond_config.get("warehouse_mapping", {})
|
||||||
results = []
|
results = []
|
||||||
successful_count = 0
|
successful_count = 0
|
||||||
failed_count = 0
|
failed_count = 0
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ Bioyond Workstation Implementation
|
|||||||
"""
|
"""
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
|
import threading
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, Any, List, Optional, Union
|
from typing import Dict, Any, List, Optional, Union
|
||||||
import json
|
import json
|
||||||
@@ -23,12 +24,94 @@ from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
|
|||||||
from unilabos.ros.msgs.message_converter import convert_to_ros_msg, Float64, String
|
from unilabos.ros.msgs.message_converter import convert_to_ros_msg, Float64, String
|
||||||
from pylabrobot.resources.resource import Resource as ResourcePLR
|
from pylabrobot.resources.resource import Resource as ResourcePLR
|
||||||
|
|
||||||
from unilabos.devices.workstation.bioyond_studio.config import (
|
|
||||||
API_CONFIG, WORKFLOW_MAPPINGS, MATERIAL_TYPE_MAPPINGS, WAREHOUSE_MAPPING, HTTP_SERVICE_CONFIG
|
|
||||||
)
|
|
||||||
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
|
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionMonitor:
|
||||||
|
"""Bioyond连接监控器"""
|
||||||
|
def __init__(self, workstation, check_interval=30):
|
||||||
|
self.workstation = workstation
|
||||||
|
self.check_interval = check_interval
|
||||||
|
self._running = False
|
||||||
|
self._thread = None
|
||||||
|
self._last_status = "unknown"
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
self._running = True
|
||||||
|
self._thread = threading.Thread(target=self._monitor_loop, daemon=True, name="BioyondConnectionMonitor")
|
||||||
|
self._thread.start()
|
||||||
|
logger.info("Bioyond连接监控器已启动")
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._running = False
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=2)
|
||||||
|
logger.info("Bioyond连接监控器已停止")
|
||||||
|
|
||||||
|
def _monitor_loop(self):
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
# 使用 lightweight API 检查连接
|
||||||
|
# query_matial_type_list 是比较快的查询
|
||||||
|
start_time = time.time()
|
||||||
|
result = self.workstation.hardware_interface.material_type_list()
|
||||||
|
|
||||||
|
status = "online" if result else "offline"
|
||||||
|
msg = "Connection established" if status == "online" else "Failed to get material type list"
|
||||||
|
|
||||||
|
if status != self._last_status:
|
||||||
|
logger.info(f"Bioyond连接状态变更: {self._last_status} -> {status}")
|
||||||
|
self._publish_event(status, msg)
|
||||||
|
self._last_status = status
|
||||||
|
|
||||||
|
# 发布心跳 (可选,或者只在状态变更时发布)
|
||||||
|
# self._publish_event(status, msg)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Bioyond连接检查异常: {e}")
|
||||||
|
if self._last_status != "error":
|
||||||
|
self._publish_event("error", str(e))
|
||||||
|
self._last_status = "error"
|
||||||
|
|
||||||
|
time.sleep(self.check_interval)
|
||||||
|
|
||||||
|
def _publish_event(self, status, message):
|
||||||
|
try:
|
||||||
|
if hasattr(self.workstation, "_ros_node") and self.workstation._ros_node:
|
||||||
|
event_data = {
|
||||||
|
"status": status,
|
||||||
|
"message": message,
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
# 动态发布消息,需要在 ROS2DeviceNode 中有对应支持
|
||||||
|
# 这里假设通用事件发布机制,使用 String 类型的 topic
|
||||||
|
# 话题: /<namespace>/events/device_status
|
||||||
|
ns = self.workstation._ros_node.namespace
|
||||||
|
topic = f"{ns}/events/device_status"
|
||||||
|
|
||||||
|
# 使用 ROS2DeviceNode 的发布功能
|
||||||
|
# 如果没有预定义的 publisher,需要动态创建
|
||||||
|
# 注意:workstation base node 可能没有自动创建 arbitrary publishers 的机制
|
||||||
|
# 这里我们先尝试用 String json 发布
|
||||||
|
|
||||||
|
# 在 ROS2DeviceNode 中通常需要先 create_publisher
|
||||||
|
# 为了简单起见,我们检查是否已有 publisher,没有则创建
|
||||||
|
if not hasattr(self.workstation, "_device_status_pub"):
|
||||||
|
self.workstation._device_status_pub = self.workstation._ros_node.create_publisher(
|
||||||
|
String, topic, 10
|
||||||
|
)
|
||||||
|
|
||||||
|
self.workstation._device_status_pub.publish(
|
||||||
|
convert_to_ros_msg(String, json.dumps(event_data, ensure_ascii=False))
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"发布设备状态事件失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
class BioyondResourceSynchronizer(ResourceSynchronizer):
|
class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||||
"""Bioyond资源同步器
|
"""Bioyond资源同步器
|
||||||
|
|
||||||
@@ -174,9 +257,8 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
|||||||
else:
|
else:
|
||||||
logger.info(f"[同步→Bioyond] ➕ 物料不存在于 Bioyond,将创建新物料并入库")
|
logger.info(f"[同步→Bioyond] ➕ 物料不存在于 Bioyond,将创建新物料并入库")
|
||||||
|
|
||||||
# 第1步:获取仓库配置
|
# 第1步:从配置中获取仓库配置
|
||||||
from .config import WAREHOUSE_MAPPING
|
warehouse_mapping = self.bioyond_config.get("warehouse_mapping", {})
|
||||||
warehouse_mapping = WAREHOUSE_MAPPING
|
|
||||||
|
|
||||||
# 确定目标仓库名称
|
# 确定目标仓库名称
|
||||||
parent_name = None
|
parent_name = None
|
||||||
@@ -238,14 +320,20 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
|||||||
# 第2步:转换为 Bioyond 格式
|
# 第2步:转换为 Bioyond 格式
|
||||||
logger.info(f"[同步→Bioyond] 🔄 转换物料为 Bioyond 格式...")
|
logger.info(f"[同步→Bioyond] 🔄 转换物料为 Bioyond 格式...")
|
||||||
|
|
||||||
# 导入物料默认参数配置
|
# 从配置中获取物料默认参数
|
||||||
from .config import MATERIAL_DEFAULT_PARAMETERS
|
material_default_params = self.workstation.bioyond_config.get("material_default_parameters", {})
|
||||||
|
material_type_params = self.workstation.bioyond_config.get("material_type_parameters", {})
|
||||||
|
|
||||||
|
# 合并参数配置:物料名称参数 + typeId参数(转换为 type:<uuid> 格式)
|
||||||
|
merged_params = material_default_params.copy()
|
||||||
|
for type_id, params in material_type_params.items():
|
||||||
|
merged_params[f"type:{type_id}"] = params
|
||||||
|
|
||||||
bioyond_material = resource_plr_to_bioyond(
|
bioyond_material = resource_plr_to_bioyond(
|
||||||
[resource],
|
[resource],
|
||||||
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
|
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
|
||||||
warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"],
|
warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"],
|
||||||
material_params=MATERIAL_DEFAULT_PARAMETERS
|
material_params=merged_params
|
||||||
)[0]
|
)[0]
|
||||||
|
|
||||||
logger.info(f"[同步→Bioyond] 🔧 准备覆盖locations字段,目标仓库: {parent_name}, 库位: {update_site}, UUID: {target_location_uuid[:8]}...")
|
logger.info(f"[同步→Bioyond] 🔧 准备覆盖locations字段,目标仓库: {parent_name}, 库位: {update_site}, UUID: {target_location_uuid[:8]}...")
|
||||||
@@ -468,13 +556,20 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
|||||||
return material_bioyond_id
|
return material_bioyond_id
|
||||||
|
|
||||||
# 转换为 Bioyond 格式
|
# 转换为 Bioyond 格式
|
||||||
from .config import MATERIAL_DEFAULT_PARAMETERS
|
# 从配置中获取物料默认参数
|
||||||
|
material_default_params = self.workstation.bioyond_config.get("material_default_parameters", {})
|
||||||
|
material_type_params = self.workstation.bioyond_config.get("material_type_parameters", {})
|
||||||
|
|
||||||
|
# 合并参数配置:物料名称参数 + typeId参数(转换为 type:<uuid> 格式)
|
||||||
|
merged_params = material_default_params.copy()
|
||||||
|
for type_id, params in material_type_params.items():
|
||||||
|
merged_params[f"type:{type_id}"] = params
|
||||||
|
|
||||||
bioyond_material = resource_plr_to_bioyond(
|
bioyond_material = resource_plr_to_bioyond(
|
||||||
[resource],
|
[resource],
|
||||||
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
|
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
|
||||||
warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"],
|
warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"],
|
||||||
material_params=MATERIAL_DEFAULT_PARAMETERS
|
material_params=merged_params
|
||||||
)[0]
|
)[0]
|
||||||
|
|
||||||
# ⚠️ 关键:创建物料时不设置 locations,让 Bioyond 系统暂不分配库位
|
# ⚠️ 关键:创建物料时不设置 locations,让 Bioyond 系统暂不分配库位
|
||||||
@@ -528,8 +623,7 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
|||||||
logger.info(f"[物料入库] 目标库位: {update_site}")
|
logger.info(f"[物料入库] 目标库位: {update_site}")
|
||||||
|
|
||||||
# 获取仓库配置和目标库位 UUID
|
# 获取仓库配置和目标库位 UUID
|
||||||
from .config import WAREHOUSE_MAPPING
|
warehouse_mapping = self.workstation.bioyond_config.get("warehouse_mapping", {})
|
||||||
warehouse_mapping = WAREHOUSE_MAPPING
|
|
||||||
|
|
||||||
parent_name = None
|
parent_name = None
|
||||||
target_location_uuid = None
|
target_location_uuid = None
|
||||||
@@ -584,6 +678,44 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
集成Bioyond物料管理的工作站实现
|
集成Bioyond物料管理的工作站实现
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def _publish_task_status(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
task_type: str,
|
||||||
|
status: str,
|
||||||
|
result: dict = None,
|
||||||
|
progress: float = 0.0,
|
||||||
|
task_code: str = None
|
||||||
|
):
|
||||||
|
"""发布任务状态事件"""
|
||||||
|
try:
|
||||||
|
if not getattr(self, "_ros_node", None):
|
||||||
|
return
|
||||||
|
|
||||||
|
event_data = {
|
||||||
|
"task_id": task_id,
|
||||||
|
"task_code": task_code,
|
||||||
|
"task_type": task_type,
|
||||||
|
"status": status,
|
||||||
|
"progress": progress,
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
if result:
|
||||||
|
event_data["result"] = result
|
||||||
|
|
||||||
|
topic = f"{self._ros_node.namespace}/events/task_status"
|
||||||
|
|
||||||
|
if not hasattr(self, "_task_status_pub"):
|
||||||
|
self._task_status_pub = self._ros_node.create_publisher(
|
||||||
|
String, topic, 10
|
||||||
|
)
|
||||||
|
|
||||||
|
self._task_status_pub.publish(
|
||||||
|
convert_to_ros_msg(String, json.dumps(event_data, ensure_ascii=False))
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"发布任务状态事件失败: {e}")
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
bioyond_config: Optional[Dict[str, Any]] = None,
|
bioyond_config: Optional[Dict[str, Any]] = None,
|
||||||
@@ -605,10 +737,28 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
raise ValueError("Deck 配置不能为空,请在配置文件中添加正确的 deck 配置")
|
raise ValueError("Deck 配置不能为空,请在配置文件中添加正确的 deck 配置")
|
||||||
|
|
||||||
# 初始化 warehouses 属性
|
# 初始化 warehouses 属性
|
||||||
|
if not hasattr(self.deck, "warehouses") or self.deck.warehouses is None:
|
||||||
self.deck.warehouses = {}
|
self.deck.warehouses = {}
|
||||||
|
|
||||||
|
# 仅当 warehouses 为空时尝试重新扫描(避免覆盖子类的修复)
|
||||||
|
if not self.deck.warehouses:
|
||||||
for resource in self.deck.children:
|
for resource in self.deck.children:
|
||||||
if isinstance(resource, WareHouse):
|
# 兼容性增强: 只要是仓库类别或者是 WareHouse 实例均可
|
||||||
|
is_warehouse = isinstance(resource, WareHouse) or getattr(resource, "category", "") == "warehouse"
|
||||||
|
|
||||||
|
# 如果配置中有定义,也可以认定为 warehouse
|
||||||
|
if not is_warehouse and "warehouse_mapping" in bioyond_config:
|
||||||
|
if resource.name in bioyond_config["warehouse_mapping"]:
|
||||||
|
is_warehouse = True
|
||||||
|
|
||||||
|
if is_warehouse:
|
||||||
self.deck.warehouses[resource.name] = resource
|
self.deck.warehouses[resource.name] = resource
|
||||||
|
# 确保 category 被正确设置,方便后续使用
|
||||||
|
if getattr(resource, "category", "") != "warehouse":
|
||||||
|
try:
|
||||||
|
resource.category = "warehouse"
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
# 创建通信模块
|
# 创建通信模块
|
||||||
self._create_communication_module(bioyond_config)
|
self._create_communication_module(bioyond_config)
|
||||||
@@ -627,18 +777,22 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
self._set_workflow_mappings(bioyond_config["workflow_mappings"])
|
self._set_workflow_mappings(bioyond_config["workflow_mappings"])
|
||||||
|
|
||||||
# 准备 HTTP 报送接收服务配置(延迟到 post_init 启动)
|
# 准备 HTTP 报送接收服务配置(延迟到 post_init 启动)
|
||||||
# 从 bioyond_config 中获取,如果没有则使用 HTTP_SERVICE_CONFIG 的默认值
|
# 从 bioyond_config 中的 http_service_config 获取
|
||||||
|
http_service_cfg = bioyond_config.get("http_service_config", {})
|
||||||
self._http_service_config = {
|
self._http_service_config = {
|
||||||
"host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG["http_service_host"]),
|
"host": http_service_cfg.get("http_service_host", "127.0.0.1"),
|
||||||
"port": bioyond_config.get("http_service_port", HTTP_SERVICE_CONFIG["http_service_port"])
|
"port": http_service_cfg.get("http_service_port", 8080)
|
||||||
}
|
}
|
||||||
self.http_service = None # 将在 post_init 中启动
|
self.http_service = None # 将在 post_init 启动
|
||||||
|
self.connection_monitor = None # 将在 post_init 启动
|
||||||
|
|
||||||
logger.info(f"Bioyond工作站初始化完成")
|
logger.info(f"Bioyond工作站初始化完成")
|
||||||
|
|
||||||
def __del__(self):
|
def __del__(self):
|
||||||
"""析构函数:清理资源,停止 HTTP 服务"""
|
"""析构函数:清理资源,停止 HTTP 服务"""
|
||||||
try:
|
try:
|
||||||
|
if hasattr(self, 'connection_monitor') and self.connection_monitor:
|
||||||
|
self.connection_monitor.stop()
|
||||||
if hasattr(self, 'http_service') and self.http_service is not None:
|
if hasattr(self, 'http_service') and self.http_service is not None:
|
||||||
logger.info("正在停止 HTTP 报送服务...")
|
logger.info("正在停止 HTTP 报送服务...")
|
||||||
self.http_service.stop()
|
self.http_service.stop()
|
||||||
@@ -648,8 +802,19 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
def post_init(self, ros_node: ROS2WorkstationNode):
|
def post_init(self, ros_node: ROS2WorkstationNode):
|
||||||
self._ros_node = ros_node
|
self._ros_node = ros_node
|
||||||
|
|
||||||
|
# 启动连接监控
|
||||||
|
try:
|
||||||
|
self.connection_monitor = ConnectionMonitor(self)
|
||||||
|
self.connection_monitor.start()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"启动连接监控失败: {e}")
|
||||||
|
|
||||||
# 启动 HTTP 报送接收服务(现在 device_id 已可用)
|
# 启动 HTTP 报送接收服务(现在 device_id 已可用)
|
||||||
if hasattr(self, '_http_service_config'):
|
# ⚠️ 检查子类是否已经自己管理 HTTP 服务
|
||||||
|
if self.bioyond_config.get("_disable_auto_http_service"):
|
||||||
|
logger.info("🔧 检测到 _disable_auto_http_service 标志,跳过自动启动 HTTP 服务")
|
||||||
|
logger.info(" 子类(BioyondCellWorkstation)已自行管理 HTTP 服务")
|
||||||
|
elif hasattr(self, '_http_service_config'):
|
||||||
try:
|
try:
|
||||||
self.http_service = WorkstationHTTPService(
|
self.http_service = WorkstationHTTPService(
|
||||||
workstation_instance=self,
|
workstation_instance=self,
|
||||||
@@ -688,19 +853,14 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
|
|
||||||
def _create_communication_module(self, config: Optional[Dict[str, Any]] = None) -> None:
|
def _create_communication_module(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||||
"""创建Bioyond通信模块"""
|
"""创建Bioyond通信模块"""
|
||||||
# 创建默认配置
|
# 直接使用传入的配置,不再使用默认值
|
||||||
default_config = {
|
# 所有配置必须从 JSON 文件中提供
|
||||||
**API_CONFIG,
|
|
||||||
"workflow_mappings": WORKFLOW_MAPPINGS,
|
|
||||||
"material_type_mappings": MATERIAL_TYPE_MAPPINGS,
|
|
||||||
"warehouse_mapping": WAREHOUSE_MAPPING
|
|
||||||
}
|
|
||||||
|
|
||||||
# 如果传入了 config,合并配置(config 中的值会覆盖默认值)
|
|
||||||
if config:
|
if config:
|
||||||
self.bioyond_config = {**default_config, **config}
|
self.bioyond_config = config
|
||||||
else:
|
else:
|
||||||
self.bioyond_config = default_config
|
# 如果没有配置,使用空字典(会导致后续错误,但这是预期的)
|
||||||
|
self.bioyond_config = {}
|
||||||
|
print("警告: 未提供 bioyond_config,请确保在 JSON 配置文件中提供完整配置")
|
||||||
|
|
||||||
self.hardware_interface = BioyondV1RPC(self.bioyond_config)
|
self.hardware_interface = BioyondV1RPC(self.bioyond_config)
|
||||||
|
|
||||||
@@ -1014,7 +1174,15 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
|
|
||||||
workflow_id = self._get_workflow(actual_workflow_name)
|
workflow_id = self._get_workflow(actual_workflow_name)
|
||||||
if workflow_id:
|
if workflow_id:
|
||||||
|
# 兼容 BioyondReactionStation 中 workflow_sequence 被重写为 property 的情况
|
||||||
|
if isinstance(self.workflow_sequence, list):
|
||||||
self.workflow_sequence.append(workflow_id)
|
self.workflow_sequence.append(workflow_id)
|
||||||
|
elif hasattr(self, "_cached_workflow_sequence") and isinstance(self._cached_workflow_sequence, list):
|
||||||
|
self._cached_workflow_sequence.append(workflow_id)
|
||||||
|
else:
|
||||||
|
print(f"❌ 无法添加工作流: workflow_sequence 类型错误 {type(self.workflow_sequence)}")
|
||||||
|
return False
|
||||||
|
|
||||||
print(f"添加工作流到执行顺序: {actual_workflow_name} -> {workflow_id}")
|
print(f"添加工作流到执行顺序: {actual_workflow_name} -> {workflow_id}")
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
@@ -1215,6 +1383,22 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
# TODO: 根据实际业务需求处理步骤完成逻辑
|
# TODO: 根据实际业务需求处理步骤完成逻辑
|
||||||
# 例如:更新数据库、触发后续流程等
|
# 例如:更新数据库、触发后续流程等
|
||||||
|
|
||||||
|
# 发布任务状态事件 (running/progress update)
|
||||||
|
self._publish_task_status(
|
||||||
|
task_id=data.get('orderCode'), # 使用 OrderCode 作为关联 ID
|
||||||
|
task_code=data.get('orderCode'),
|
||||||
|
task_type="bioyond_step",
|
||||||
|
status="running",
|
||||||
|
progress=0.5, # 步骤完成视为任务进行中
|
||||||
|
result={"step_name": data.get('stepName'), "step_id": data.get('stepId')}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 更新物料信息
|
||||||
|
# 步骤完成后,物料状态可能发生变化(如位置、用量等),触发同步
|
||||||
|
logger.info(f"[步骤完成报送] 触发物料同步...")
|
||||||
|
self.resource_synchronizer.sync_from_external()
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"processed": True,
|
"processed": True,
|
||||||
"step_id": data.get('stepId'),
|
"step_id": data.get('stepId'),
|
||||||
@@ -1249,6 +1433,17 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
|
|
||||||
# TODO: 根据实际业务需求处理通量完成逻辑
|
# TODO: 根据实际业务需求处理通量完成逻辑
|
||||||
|
|
||||||
|
# 发布任务状态事件
|
||||||
|
self._publish_task_status(
|
||||||
|
task_id=data.get('orderCode'),
|
||||||
|
task_code=data.get('orderCode'),
|
||||||
|
task_type="bioyond_sample",
|
||||||
|
status="running",
|
||||||
|
progress=0.7,
|
||||||
|
result={"sample_id": data.get('sampleId'), "status": status_desc}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"processed": True,
|
"processed": True,
|
||||||
"sample_id": data.get('sampleId'),
|
"sample_id": data.get('sampleId'),
|
||||||
@@ -1288,6 +1483,32 @@ class BioyondWorkstation(WorkstationBase):
|
|||||||
# TODO: 根据实际业务需求处理任务完成逻辑
|
# TODO: 根据实际业务需求处理任务完成逻辑
|
||||||
# 例如:更新物料库存、生成报表等
|
# 例如:更新物料库存、生成报表等
|
||||||
|
|
||||||
|
# 映射状态到事件状态
|
||||||
|
event_status = "completed"
|
||||||
|
if str(data.get('status')) in ["-11", "-12"]:
|
||||||
|
event_status = "error"
|
||||||
|
elif str(data.get('status')) == "30":
|
||||||
|
event_status = "completed"
|
||||||
|
else:
|
||||||
|
event_status = "running" # 其他状态视为运行中(或根据实际定义)
|
||||||
|
|
||||||
|
# 发布任务状态事件
|
||||||
|
self._publish_task_status(
|
||||||
|
task_id=data.get('orderCode'),
|
||||||
|
task_code=data.get('orderCode'),
|
||||||
|
task_type="bioyond_order",
|
||||||
|
status=event_status,
|
||||||
|
progress=1.0 if event_status in ["completed", "error"] else 0.9,
|
||||||
|
result={"order_name": data.get('orderName'), "status": status_desc, "materials_count": len(used_materials)}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 更新物料信息
|
||||||
|
# 任务完成后,且状态为完成时,触发同步以更新最终物料状态
|
||||||
|
if event_status == "completed":
|
||||||
|
logger.info(f"[任务完成报送] 触发物料同步...")
|
||||||
|
self.resource_synchronizer.sync_from_external()
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"processed": True,
|
"processed": True,
|
||||||
"order_code": data.get('orderCode'),
|
"order_code": data.get('orderCode'),
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Modbus CSV 地址映射说明
|
||||||
|
|
||||||
|
本文档说明 `coin_cell_assembly_a.csv` 文件如何将命名节点映射到实际的 Modbus 地址,以及如何在代码中使用它们。
|
||||||
|
|
||||||
|
## 1. CSV 文件结构
|
||||||
|
|
||||||
|
地址表文件位于同级目录下:`coin_cell_assembly_a.csv`
|
||||||
|
|
||||||
|
每一行定义了一个 Modbus 节点,包含以下关键列:
|
||||||
|
|
||||||
|
| 列名 | 说明 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| **Name** | **节点名称** (代码中引用的 Key) | `COIL_ALUMINUM_FOIL` |
|
||||||
|
| **DataType** | 数据类型 (BOOL, INT16, FLOAT32, STRING) | `BOOL` |
|
||||||
|
| **Comment** | 注释说明 | `使用铝箔垫` |
|
||||||
|
| **Attribute** | 属性 (通常留空或用于额外标记) | |
|
||||||
|
| **DeviceType** | Modbus 寄存器类型 (`coil`, `hold_register`) | `coil` |
|
||||||
|
| **Address** | **Modbus 地址** (十进制) | `8340` |
|
||||||
|
|
||||||
|
### 示例行 (铝箔垫片)
|
||||||
|
|
||||||
|
```csv
|
||||||
|
COIL_ALUMINUM_FOIL,BOOL,,使用铝箔垫,,coil,8340,
|
||||||
|
```
|
||||||
|
|
||||||
|
- **名称**: `COIL_ALUMINUM_FOIL`
|
||||||
|
- **类型**: `coil` (线圈,读写单个位)
|
||||||
|
- **地址**: `8340`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 加载与注册流程
|
||||||
|
|
||||||
|
在 `coin_cell_assembly.py` 的初始化代码中:
|
||||||
|
|
||||||
|
1. **加载 CSV**: `BaseClient.load_csv()` 读取 CSV 并解析每行定义。
|
||||||
|
2. **注册节点**: `modbus_client.register_node_list()` 将解析后的节点注册到 Modbus 客户端实例中。
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 代码位置: coin_cell_assembly.py (L174-175)
|
||||||
|
self.nodes = BaseClient.load_csv(os.path.join(os.path.dirname(__file__), 'coin_cell_assembly_a.csv'))
|
||||||
|
self.client = modbus_client.register_node_list(self.nodes)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 代码中的使用方式
|
||||||
|
|
||||||
|
注册后,通过 `self.client.use_node('节点名称')` 即可获取该节点对象并进行读写操作,无需关心具体地址。
|
||||||
|
|
||||||
|
### 控制铝箔垫片 (COIL_ALUMINUM_FOIL)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 代码位置: qiming_coin_cell_code 函数 (L1048)
|
||||||
|
self.client.use_node('COIL_ALUMINUM_FOIL').write(not lvbodian)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **写入 True**: 对应 Modbus 功能码 05 (Write Single Coil),向地址 `8340` 写入 `1` (ON)。
|
||||||
|
- **写入 False**: 向地址 `8340` 写入 `0` (OFF)。
|
||||||
|
|
||||||
|
> **注意**: 代码中使用了 `not lvbodian`,这意味着逻辑是反转的。如果 `lvbodian` 参数为 `True` (默认),写入的是 `False` (不使用铝箔垫)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 地址转换注意事项 (Modbus vs PLC)
|
||||||
|
|
||||||
|
CSV 中的 `Address` 列(如 `8340`)是 **Modbus 协议地址**。
|
||||||
|
|
||||||
|
如果使用 InoProShop (汇川 PLC 编程软件),看到的可能是 **PLC 内部地址** (如 `%QX...` 或 `%MW...`)。这两者之间通常需要转换。
|
||||||
|
|
||||||
|
### 常见的转换规则 (示例)
|
||||||
|
|
||||||
|
- **Coil (线圈) %QX**:
|
||||||
|
- `Modbus地址 = 字节地址 * 8 + 位偏移`
|
||||||
|
- *例子*: `%QX834.0` -> `834 * 8 + 0` = `6672`
|
||||||
|
- *注意*: 如果 CSV 中配置的是 `8340`,这可能是一个自定义映射,或者是基于不同规则(如直接对应 Word 地址的某种映射,或者可能就是地址写错了/使用了非标准映射)。
|
||||||
|
|
||||||
|
- **Register (寄存器) %MW**:
|
||||||
|
- 通常直接对应,或者有偏移量 (如 Modbus 40001 = PLC MW0)。
|
||||||
|
|
||||||
|
### 验证方法
|
||||||
|
由于 `test_unilab_interact.py` 中发现 `8450` (CSV风格) 不工作,而 `6760` (%QX845.0 计算值) 工作正常,**建议对 CSV 中的其他地址也进行核实**,特别是像 `8340` 这样以 0 结尾看起来像是 "字节地址+0" 的数值,可能实际上应该是 `%QX834.0` 对应的 `6672`。
|
||||||
|
|
||||||
|
如果发现设备控制无反应,请尝试按照标准的 Modbus 计算方式转换 PLC 地址。
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
# 2026-01-13 物料搜寻确认弹窗自动处理功能
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本次更新为设备初始化流程添加了**物料搜寻确认弹窗自动检测与处理功能**。在设备初始化过程中,PLC 会弹出物料搜寻确认对话框,现在系统可以根据用户参数自动点击"是"或"否"按钮,无需手动干预。
|
||||||
|
|
||||||
|
## 背景问题
|
||||||
|
|
||||||
|
### 原有流程
|
||||||
|
1. 调用 `func_pack_device_init_auto_start_combined()` 初始化设备
|
||||||
|
2. PLC 在初始化过程中弹出物料搜寻确认对话框
|
||||||
|
3. **需要人工手动点击**"是"或"否"按钮
|
||||||
|
4. PLC 继续完成初始化并启动
|
||||||
|
|
||||||
|
### 存在的问题
|
||||||
|
- 需要人工干预,无法实现全自动化
|
||||||
|
- 影响批量生产效率
|
||||||
|
- 容易遗忘点击导致流程卡住
|
||||||
|
|
||||||
|
## 解决方案
|
||||||
|
|
||||||
|
### 新增 Modbus 地址配置
|
||||||
|
|
||||||
|
在 `coin_cell_assembly_b.csv` 第 69-71 行添加三个 coil:
|
||||||
|
|
||||||
|
| Name | DeviceType | Address | 说明 |
|
||||||
|
|------|-----------|---------|------|
|
||||||
|
| COIL_MATERIAL_SEARCH_DIALOG_APPEAR | coil | 6470 | 物料搜寻确认弹窗画面是否出现 |
|
||||||
|
| COIL_MATERIAL_SEARCH_CONFIRM_YES | coil | 6480 | 初始化物料搜寻确认按钮"是" |
|
||||||
|
| COIL_MATERIAL_SEARCH_CONFIRM_NO | coil | 6490 | 初始化物料搜寻确认按钮"否" |
|
||||||
|
|
||||||
|
**Modbus 地址转换:**
|
||||||
|
- CSV 6470 → Modbus 5176 (弹窗出现)
|
||||||
|
- CSV 6480 → Modbus 5184 (按钮"是")
|
||||||
|
- CSV 6490 → Modbus 5192 (按钮"否")
|
||||||
|
|
||||||
|
## 代码修改详情
|
||||||
|
|
||||||
|
### 1. coin_cell_assembly.py
|
||||||
|
|
||||||
|
#### 1.1 新增辅助方法 `_handle_material_search_dialog()`
|
||||||
|
|
||||||
|
**位置:** 第 799-901 行
|
||||||
|
|
||||||
|
**功能:**
|
||||||
|
- 监测物料搜寻确认弹窗是否出现(Coil 5176)
|
||||||
|
- 根据 `enable_search` 参数自动点击对应按钮
|
||||||
|
- 使用**脉冲模式**模拟真实按钮操作:`True` → 保持 0.5 秒 → `False`
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `enable_search: bool` - True=点击"是"(启用物料搜寻), False=点击"否"(不启用)
|
||||||
|
- `timeout: int = 30` - 等待弹窗出现的最大时间(秒)
|
||||||
|
|
||||||
|
**逻辑流程:**
|
||||||
|
```python
|
||||||
|
1. 监测 COIL_MATERIAL_SEARCH_DIALOG_APPEAR (每 0.5 秒检查一次)
|
||||||
|
2. 检测到弹窗出现 (Coil = True)
|
||||||
|
3. 选择按钮:
|
||||||
|
- enable_search=True → COIL_MATERIAL_SEARCH_CONFIRM_YES
|
||||||
|
- enable_search=False → COIL_MATERIAL_SEARCH_CONFIRM_NO
|
||||||
|
4. 执行脉冲操作:
|
||||||
|
- 写入 True (按下按钮)
|
||||||
|
- 等待 0.5 秒
|
||||||
|
- 写入 False (释放按钮)
|
||||||
|
- 验证状态
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.2 修改 `func_pack_device_init_auto_start_combined()`
|
||||||
|
|
||||||
|
**位置:** 第 904-1115 行
|
||||||
|
|
||||||
|
**主要改动:**
|
||||||
|
|
||||||
|
1. **添加新参数**
|
||||||
|
```python
|
||||||
|
def func_pack_device_init_auto_start_combined(
|
||||||
|
self,
|
||||||
|
material_search_enable: bool = False # 新增参数
|
||||||
|
) -> bool:
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **内联初始化逻辑并集成弹窗检测**
|
||||||
|
- 不再调用 `self.func_pack_device_init()`
|
||||||
|
- 将初始化逻辑直接实现在函数内
|
||||||
|
- **在等待初始化完成的循环中实时检测弹窗**
|
||||||
|
- 避免死锁:PLC 等待弹窗确认 ↔ 代码等待初始化完成
|
||||||
|
|
||||||
|
3. **关键代码片段**
|
||||||
|
```python
|
||||||
|
# 等待初始化完成,同时检测物料搜寻弹窗
|
||||||
|
while (self._sys_init_status()) == False:
|
||||||
|
# 检查超时
|
||||||
|
if time.time() - start_wait > max_wait_time:
|
||||||
|
raise RuntimeError(f"初始化超时")
|
||||||
|
|
||||||
|
# 如果还没处理弹窗,检测弹窗是否出现
|
||||||
|
if not dialog_handled:
|
||||||
|
dialog_state = self.client.use_node('COIL_MATERIAL_SEARCH_DIALOG_APPEAR').read(1)
|
||||||
|
if dialog_actual: # 弹窗出现
|
||||||
|
# 执行脉冲按钮点击
|
||||||
|
button_node.write(True) # 按下
|
||||||
|
time.sleep(0.5) # 保持
|
||||||
|
button_node.write(False) # 释放
|
||||||
|
dialog_handled = True
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **步骤调整**
|
||||||
|
- 步骤 0: 前置条件检查
|
||||||
|
- 步骤 1: 设备初始化(**包含弹窗检测**)
|
||||||
|
- 步骤 1.5: 已在步骤 1 中完成
|
||||||
|
- 步骤 2: 切换自动模式
|
||||||
|
- 步骤 3: 启动设备
|
||||||
|
|
||||||
|
### 2. coin_cell_workstation.yaml
|
||||||
|
|
||||||
|
**位置:** 第 292-312 行
|
||||||
|
|
||||||
|
**修改内容:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
auto-func_pack_device_init_auto_start_combined:
|
||||||
|
goal_default:
|
||||||
|
material_search_enable: false # 新增默认值
|
||||||
|
|
||||||
|
schema:
|
||||||
|
description: 组合函数:设备初始化 + 物料搜寻确认 + 切换自动模式 + 启动。初始化过程中会自动检测物料搜寻确认弹窗,并根据参数自动点击"是"或"否"按钮
|
||||||
|
|
||||||
|
goal:
|
||||||
|
properties:
|
||||||
|
material_search_enable: # 新增参数配置
|
||||||
|
default: false
|
||||||
|
description: 是否启用物料搜寻功能。设备初始化后会弹出物料搜寻确认弹窗,此参数控制自动点击"是"(启用)或"否"(不启用)。默认为false(不启用物料搜寻)
|
||||||
|
type: boolean
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 测试脚本(已创建,用户已删除)
|
||||||
|
|
||||||
|
#### 3.1 test_material_search_dialog.py
|
||||||
|
- 从 CSV 动态加载 Modbus 地址
|
||||||
|
- 支持 4 种测试模式:
|
||||||
|
- `query` - 查询所有状态
|
||||||
|
- `dialog <0|1>` - 设置弹窗出现/消失
|
||||||
|
- `yes` - 脉冲点击"是"按钮
|
||||||
|
- `no` - 脉冲点击"否"按钮
|
||||||
|
- 兼容 pymodbus 3.x API
|
||||||
|
|
||||||
|
#### 3.2 更新其他测试脚本
|
||||||
|
- `test_coin_cell_reset.py` - 更新为 pymodbus 3.x API
|
||||||
|
- `test_unilab_interact.py` - 更新为 pymodbus 3.x API
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
### 参数说明
|
||||||
|
|
||||||
|
| 参数 | 类型 | 默认值 | 说明 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| `material_search_enable` | boolean | `false` | 是否启用物料搜寻功能 |
|
||||||
|
|
||||||
|
### 调用示例
|
||||||
|
|
||||||
|
#### 1. 不启用物料搜寻(默认)
|
||||||
|
```python
|
||||||
|
# 默认参数,点击"否"按钮
|
||||||
|
await device.func_pack_device_init_auto_start_combined()
|
||||||
|
```
|
||||||
|
|
||||||
|
或在 YAML workflow 中:
|
||||||
|
```yaml
|
||||||
|
# 使用默认值 false,不启用物料搜寻
|
||||||
|
- BatteryStation/auto-func_pack_device_init_auto_start_combined: {}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 启用物料搜寻
|
||||||
|
```python
|
||||||
|
# 显式设置为 True,点击"是"按钮
|
||||||
|
await device.func_pack_device_init_auto_start_combined(
|
||||||
|
material_search_enable=True
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
或在 YAML workflow 中:
|
||||||
|
```yaml
|
||||||
|
- BatteryStation/auto-func_pack_device_init_auto_start_combined:
|
||||||
|
goal:
|
||||||
|
material_search_enable: true # 启用物料搜寻
|
||||||
|
```
|
||||||
|
|
||||||
|
## 执行日志示例
|
||||||
|
|
||||||
|
```
|
||||||
|
26-01-13 [21:32:44] [INFO] 开始组合操作:设备初始化 → 物料搜寻确认 → 自动模式 → 启动
|
||||||
|
26-01-13 [21:32:44] [INFO] 【步骤 0/4】前置条件检查...
|
||||||
|
26-01-13 [21:32:44] [INFO] ✓ REG_UNILAB_INTERACT 检查通过
|
||||||
|
26-01-13 [21:32:44] [INFO] ✓ COIL_GB_L_IGNORE_CMD 检查通过
|
||||||
|
26-01-13 [21:32:44] [INFO] 【步骤 1/4】设备初始化...
|
||||||
|
26-01-13 [21:32:44] [INFO] 切换手动模式...
|
||||||
|
26-01-13 [21:32:46] [INFO] 发送初始化命令...
|
||||||
|
26-01-13 [21:32:47] [INFO] 等待初始化完成(同时监测物料搜寻弹窗)...
|
||||||
|
26-01-13 [21:33:05] [INFO] ✓ 在初始化过程中检测到物料搜寻确认弹窗!
|
||||||
|
26-01-13 [21:33:05] [INFO] 用户选择: 不启用物料搜寻(点击否)
|
||||||
|
26-01-13 [21:33:05] [INFO] → 按下按钮 '否'
|
||||||
|
26-01-13 [21:33:06] [INFO] → 释放按钮 '否'
|
||||||
|
26-01-13 [21:33:07] [INFO] ✓ 成功处理物料搜寻确认弹窗(选择: 否)
|
||||||
|
26-01-13 [21:33:08] [INFO] ✓ 初始化状态完成
|
||||||
|
26-01-13 [21:33:12] [INFO] ✓ 设备初始化完成
|
||||||
|
26-01-13 [21:33:12] [INFO] 【步骤 1.5/4】物料搜寻确认已在初始化过程中完成
|
||||||
|
26-01-13 [21:33:12] [INFO] 【步骤 2/4】切换自动模式...
|
||||||
|
26-01-13 [21:33:15] [INFO] ✓ 切换自动模式完成
|
||||||
|
26-01-13 [21:33:15] [INFO] 【步骤 3/4】启动设备...
|
||||||
|
26-01-13 [21:33:18] [INFO] ✓ 启动设备完成
|
||||||
|
26-01-13 [21:33:18] [INFO] 组合操作完成:设备已成功初始化、确认物料搜寻、切换自动模式并启动
|
||||||
|
```
|
||||||
|
|
||||||
|
## 技术要点
|
||||||
|
|
||||||
|
### 1. 脉冲模式按钮操作
|
||||||
|
模拟真实按钮按压过程:
|
||||||
|
1. 写入 `True` (按下)
|
||||||
|
2. 保持 0.5 秒
|
||||||
|
3. 写入 `False` (释放)
|
||||||
|
4. 验证状态
|
||||||
|
|
||||||
|
### 2. 避免死锁
|
||||||
|
**问题:** PLC 在初始化过程中等待弹窗确认,而代码等待初始化完成
|
||||||
|
**解决:** 在初始化等待循环中实时检测弹窗,一旦出现立即处理
|
||||||
|
|
||||||
|
### 3. 超时保护
|
||||||
|
- 弹窗检测超时:30 秒(在 `_handle_material_search_dialog` 中)
|
||||||
|
- 初始化超时:120 秒(在 `func_pack_device_init_auto_start_combined` 中)
|
||||||
|
|
||||||
|
### 4. PyModbus 3.x API 兼容
|
||||||
|
所有 Modbus 操作使用 keyword arguments:
|
||||||
|
```python
|
||||||
|
# 读取
|
||||||
|
client.read_coils(address=5176, count=1)
|
||||||
|
|
||||||
|
# 写入
|
||||||
|
client.write_coil(address=5184, value=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 向后兼容性
|
||||||
|
|
||||||
|
### 保留的原有函数
|
||||||
|
- `func_pack_device_init()` - 单独的初始化函数,不包含弹窗处理
|
||||||
|
- 仍可在 YAML 中通过 `auto-func_pack_device_init` 调用
|
||||||
|
- 用于不需要自动处理弹窗的场景
|
||||||
|
|
||||||
|
### 新增的功能
|
||||||
|
- 在 `func_pack_device_init_auto_start_combined()` 中集成弹窗处理
|
||||||
|
- 通过参数控制,默认行为与之前兼容(点击"否")
|
||||||
|
|
||||||
|
## 验证测试
|
||||||
|
|
||||||
|
### 测试场景
|
||||||
|
|
||||||
|
#### 场景 1:默认参数(不启用物料搜寻)
|
||||||
|
```bash
|
||||||
|
# 调用时不传参数
|
||||||
|
BatteryStation/auto-func_pack_device_init_auto_start_combined: {}
|
||||||
|
```
|
||||||
|
**预期结果:**
|
||||||
|
- ✅ 检测到弹窗
|
||||||
|
- ✅ 自动点击"否"按钮
|
||||||
|
- ✅ 初始化完成并启动成功
|
||||||
|
|
||||||
|
#### 场景 2:启用物料搜寻
|
||||||
|
```bash
|
||||||
|
# 设置 material_search_enable=true
|
||||||
|
BatteryStation/auto-func_pack_device_init_auto_start_combined:
|
||||||
|
goal:
|
||||||
|
material_search_enable: true
|
||||||
|
```
|
||||||
|
**预期结果:**
|
||||||
|
- ✅ 检测到弹窗
|
||||||
|
- ✅ 自动点击"是"按钮
|
||||||
|
- ✅ 初始化完成并启动成功
|
||||||
|
|
||||||
|
### 实际测试结果
|
||||||
|
|
||||||
|
**测试时间:** 2026-01-13 21:32:43
|
||||||
|
**测试参数:** `material_search_enable: false`
|
||||||
|
**测试结果:** ✅ 成功
|
||||||
|
|
||||||
|
**关键时间节点:**
|
||||||
|
- 21:33:05 - 检测到弹窗
|
||||||
|
- 21:33:05 - 按下"否"按钮
|
||||||
|
- 21:33:06 - 释放"否"按钮
|
||||||
|
- 21:33:07 - 弹窗处理完成
|
||||||
|
- 21:33:08 - 初始化状态完成
|
||||||
|
- 21:33:18 - 整个流程完成
|
||||||
|
|
||||||
|
**总耗时:** 约 35 秒(包含初始化全过程)
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **CSV 配置依赖**
|
||||||
|
- 确保 `coin_cell_assembly_b.csv` 包含 69-71 行的 coil 配置
|
||||||
|
- 地址转换逻辑:`modbus_addr = (csv_addr // 10) * 8 + (csv_addr % 10)`
|
||||||
|
|
||||||
|
2. **默认行为**
|
||||||
|
- 默认 `material_search_enable=false`,即不启用物料搜寻
|
||||||
|
- 如需启用,必须显式设置为 `true`
|
||||||
|
|
||||||
|
3. **日志级别**
|
||||||
|
- 弹窗检测过程中的 `waiting for init_cmd` 使用 DEBUG 级别
|
||||||
|
- 关键操作(检测到弹窗、按钮操作)使用 INFO 级别
|
||||||
|
|
||||||
|
4. **原有函数保留**
|
||||||
|
- `func_pack_device_init()` 仍然可用,但不包含弹窗处理
|
||||||
|
- 如果单独调用此函数,仍需手动处理弹窗
|
||||||
|
|
||||||
|
## 文件清单
|
||||||
|
|
||||||
|
### 修改的文件
|
||||||
|
1. `d:\UniLabdev\Uni-Lab-OS\unilabos\devices\workstation\coin_cell_assembly\coin_cell_assembly.py`
|
||||||
|
- 新增 `_handle_material_search_dialog()` 方法
|
||||||
|
- 修改 `func_pack_device_init_auto_start_combined()` 函数
|
||||||
|
|
||||||
|
2. `d:\UniLabdev\Uni-Lab-OS\unilabos\registry\devices\coin_cell_workstation.yaml`
|
||||||
|
- 更新 `auto-func_pack_device_init_auto_start_combined` 配置
|
||||||
|
- 添加 `material_search_enable` 参数说明
|
||||||
|
|
||||||
|
3. `d:\UniLabdev\Uni-Lab-OS\unilabos\devices\workstation\coin_cell_assembly\coin_cell_assembly_b.csv`
|
||||||
|
- 第 69-71 行添加三个 coil 配置
|
||||||
|
|
||||||
|
### 创建的测试文件(已删除)
|
||||||
|
1. `test_material_search_dialog.py` - 物料搜寻弹窗测试脚本
|
||||||
|
2. `test_coin_cell_reset.py` - 复位功能测试(更新为 pymodbus 3.x)
|
||||||
|
3. `test_unilab_interact.py` - Unilab 交互测试(更新为 pymodbus 3.x)
|
||||||
|
|
||||||
|
## 总结
|
||||||
|
|
||||||
|
本次更新成功实现了设备初始化过程中物料搜寻确认弹窗的自动化处理,主要优势:
|
||||||
|
|
||||||
|
✅ **全自动化** - 无需人工干预
|
||||||
|
✅ **参数可配** - 灵活控制是否启用物料搜寻
|
||||||
|
✅ **实时检测** - 在初始化等待循环中检测,避免死锁
|
||||||
|
✅ **脉冲模式** - 模拟真实按钮操作
|
||||||
|
✅ **向后兼容** - 保留原有函数,不影响现有流程
|
||||||
|
✅ **完整日志** - 详细记录每一步操作
|
||||||
|
✅ **超时保护** - 防止无限等待
|
||||||
|
|
||||||
|
该功能已通过实际测试验证,可投入生产使用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**文档版本:** 1.0
|
||||||
|
**创建日期:** 2026-01-13
|
||||||
|
**作者:** Antigravity AI Assistant
|
||||||
|
**最后更新:** 2026-01-13 21:36
|
||||||
@@ -0,0 +1,645 @@
|
|||||||
|
"""
|
||||||
|
纽扣电池组装工作站物料类定义
|
||||||
|
Button Battery Assembly Station Resource Classes
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import OrderedDict
|
||||||
|
from typing import Any, Dict, List, Optional, TypedDict, Union, cast
|
||||||
|
|
||||||
|
from pylabrobot.resources.coordinate import Coordinate
|
||||||
|
from pylabrobot.resources.container import Container
|
||||||
|
from pylabrobot.resources.deck import Deck
|
||||||
|
from pylabrobot.resources.itemized_resource import ItemizedResource
|
||||||
|
from pylabrobot.resources.resource import Resource
|
||||||
|
from pylabrobot.resources.resource_stack import ResourceStack
|
||||||
|
from pylabrobot.resources.tip_rack import TipRack, TipSpot
|
||||||
|
from pylabrobot.resources.trash import Trash
|
||||||
|
from pylabrobot.resources.utils import create_ordered_items_2d
|
||||||
|
|
||||||
|
from unilabos.resources.battery.magazine import MagazineHolder_4_Cathode, MagazineHolder_6_Cathode, MagazineHolder_6_Anode, MagazineHolder_6_Battery
|
||||||
|
from unilabos.resources.battery.bottle_carriers import YIHUA_Electrolyte_12VialCarrier
|
||||||
|
from unilabos.resources.battery.electrode_sheet import ElectrodeSheet
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: 这个应该只能放一个极片
|
||||||
|
class MaterialHoleState(TypedDict):
|
||||||
|
diameter: int
|
||||||
|
depth: int
|
||||||
|
max_sheets: int
|
||||||
|
info: Optional[str] # 附加信息
|
||||||
|
|
||||||
|
class MaterialHole(Resource):
|
||||||
|
"""料板洞位类"""
|
||||||
|
children: List[ElectrodeSheet] = []
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
size_x: float,
|
||||||
|
size_y: float,
|
||||||
|
size_z: float,
|
||||||
|
category: str = "material_hole",
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
size_x=size_x,
|
||||||
|
size_y=size_y,
|
||||||
|
size_z=size_z,
|
||||||
|
category=category,
|
||||||
|
)
|
||||||
|
self._unilabos_state: MaterialHoleState = MaterialHoleState(
|
||||||
|
diameter=20,
|
||||||
|
depth=10,
|
||||||
|
max_sheets=1,
|
||||||
|
info=None
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_all_sheet_info(self):
|
||||||
|
info_list = []
|
||||||
|
for sheet in self.children:
|
||||||
|
info_list.append(sheet._unilabos_state["info"])
|
||||||
|
return info_list
|
||||||
|
|
||||||
|
#这个函数函数好像没用,一般不会集中赋值质量
|
||||||
|
def set_all_sheet_mass(self):
|
||||||
|
for sheet in self.children:
|
||||||
|
sheet._unilabos_state["mass"] = 0.5 # 示例:设置质量为0.5g
|
||||||
|
|
||||||
|
def load_state(self, state: Dict[str, Any]) -> None:
|
||||||
|
"""格式不变"""
|
||||||
|
super().load_state(state)
|
||||||
|
self._unilabos_state = state
|
||||||
|
|
||||||
|
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""格式不变"""
|
||||||
|
data = super().serialize_state()
|
||||||
|
data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等)
|
||||||
|
return data
|
||||||
|
#移动极片前先取出对象
|
||||||
|
def get_sheet_with_name(self, name: str) -> Optional[ElectrodeSheet]:
|
||||||
|
for sheet in self.children:
|
||||||
|
if sheet.name == name:
|
||||||
|
return sheet
|
||||||
|
return None
|
||||||
|
|
||||||
|
def has_electrode_sheet(self) -> bool:
|
||||||
|
"""检查洞位是否有极片"""
|
||||||
|
return len(self.children) > 0
|
||||||
|
|
||||||
|
def assign_child_resource(
|
||||||
|
self,
|
||||||
|
resource: ElectrodeSheet,
|
||||||
|
location: Optional[Coordinate],
|
||||||
|
reassign: bool = True,
|
||||||
|
):
|
||||||
|
"""放置极片"""
|
||||||
|
# TODO: 这里要改,diameter找不到,加入._unilabos_state后应该没问题
|
||||||
|
#if resource._unilabos_state["diameter"] > self._unilabos_state["diameter"]:
|
||||||
|
# raise ValueError(f"极片直径 {resource._unilabos_state['diameter']} 超过洞位直径 {self._unilabos_state['diameter']}")
|
||||||
|
#if len(self.children) >= self._unilabos_state["max_sheets"]:
|
||||||
|
# raise ValueError(f"洞位已满,无法放置更多极片")
|
||||||
|
super().assign_child_resource(resource, location, reassign)
|
||||||
|
|
||||||
|
# 根据children的编号取物料对象。
|
||||||
|
def get_electrode_sheet_info(self, index: int) -> ElectrodeSheet:
|
||||||
|
return self.children[index]
|
||||||
|
|
||||||
|
|
||||||
|
class MaterialPlateState(TypedDict):
|
||||||
|
hole_spacing_x: float
|
||||||
|
hole_spacing_y: float
|
||||||
|
hole_diameter: float
|
||||||
|
info: Optional[str] # 附加信息
|
||||||
|
|
||||||
|
class MaterialPlate(ItemizedResource[MaterialHole]):
|
||||||
|
"""料板类 - 4x4个洞位,每个洞位放1个极片"""
|
||||||
|
|
||||||
|
children: List[MaterialHole]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
size_x: float,
|
||||||
|
size_y: float,
|
||||||
|
size_z: float,
|
||||||
|
ordered_items: Optional[Dict[str, MaterialHole]] = None,
|
||||||
|
ordering: Optional[OrderedDict[str, str]] = None,
|
||||||
|
category: str = "material_plate",
|
||||||
|
model: Optional[str] = None,
|
||||||
|
fill: bool = False
|
||||||
|
):
|
||||||
|
"""初始化料板
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 料板名称
|
||||||
|
size_x: 长度 (mm)
|
||||||
|
size_y: 宽度 (mm)
|
||||||
|
size_z: 高度 (mm)
|
||||||
|
hole_diameter: 洞直径 (mm)
|
||||||
|
hole_depth: 洞深度 (mm)
|
||||||
|
hole_spacing_x: X方向洞位间距 (mm)
|
||||||
|
hole_spacing_y: Y方向洞位间距 (mm)
|
||||||
|
number: 编号
|
||||||
|
category: 类别
|
||||||
|
model: 型号
|
||||||
|
"""
|
||||||
|
self._unilabos_state: MaterialPlateState = MaterialPlateState(
|
||||||
|
hole_spacing_x=24.0,
|
||||||
|
hole_spacing_y=24.0,
|
||||||
|
hole_diameter=20.0,
|
||||||
|
info="",
|
||||||
|
)
|
||||||
|
# 创建4x4的洞位
|
||||||
|
# TODO: 这里要改,对应不同形状
|
||||||
|
holes = create_ordered_items_2d(
|
||||||
|
klass=MaterialHole,
|
||||||
|
num_items_x=4,
|
||||||
|
num_items_y=4,
|
||||||
|
dx=(size_x - 4 * self._unilabos_state["hole_spacing_x"]) / 2, # 居中
|
||||||
|
dy=(size_y - 4 * self._unilabos_state["hole_spacing_y"]) / 2, # 居中
|
||||||
|
dz=size_z,
|
||||||
|
item_dx=self._unilabos_state["hole_spacing_x"],
|
||||||
|
item_dy=self._unilabos_state["hole_spacing_y"],
|
||||||
|
size_x = 16,
|
||||||
|
size_y = 16,
|
||||||
|
size_z = 16,
|
||||||
|
)
|
||||||
|
if fill:
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
size_x=size_x,
|
||||||
|
size_y=size_y,
|
||||||
|
size_z=size_z,
|
||||||
|
ordered_items=holes,
|
||||||
|
category=category,
|
||||||
|
model=model,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
size_x=size_x,
|
||||||
|
size_y=size_y,
|
||||||
|
size_z=size_z,
|
||||||
|
ordered_items=ordered_items,
|
||||||
|
ordering=ordering,
|
||||||
|
category=category,
|
||||||
|
model=model,
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_locations(self):
|
||||||
|
# TODO:调多次相加
|
||||||
|
holes = create_ordered_items_2d(
|
||||||
|
klass=MaterialHole,
|
||||||
|
num_items_x=4,
|
||||||
|
num_items_y=4,
|
||||||
|
dx=(self._size_x - 3 * self._unilabos_state["hole_spacing_x"]) / 2, # 居中
|
||||||
|
dy=(self._size_y - 3 * self._unilabos_state["hole_spacing_y"]) / 2, # 居中
|
||||||
|
dz=self._size_z,
|
||||||
|
item_dx=self._unilabos_state["hole_spacing_x"],
|
||||||
|
item_dy=self._unilabos_state["hole_spacing_y"],
|
||||||
|
size_x = 1,
|
||||||
|
size_y = 1,
|
||||||
|
size_z = 1,
|
||||||
|
)
|
||||||
|
for item, original_item in zip(holes.items(), self.children):
|
||||||
|
original_item.location = item[1].location
|
||||||
|
|
||||||
|
|
||||||
|
class PlateSlot(ResourceStack):
|
||||||
|
"""板槽位类 - 1个槽上能堆放8个板,移板只能操作最上方的板"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
size_x: float,
|
||||||
|
size_y: float,
|
||||||
|
size_z: float,
|
||||||
|
max_plates: int = 8,
|
||||||
|
category: str = "plate_slot",
|
||||||
|
model: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""初始化板槽位
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 槽位名称
|
||||||
|
max_plates: 最大板数量
|
||||||
|
category: 类别
|
||||||
|
"""
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
direction="z", # Z方向堆叠
|
||||||
|
resources=[],
|
||||||
|
)
|
||||||
|
self.max_plates = max_plates
|
||||||
|
self.category = category
|
||||||
|
|
||||||
|
def can_add_plate(self) -> bool:
|
||||||
|
"""检查是否可以添加板"""
|
||||||
|
return len(self.children) < self.max_plates
|
||||||
|
|
||||||
|
def add_plate(self, plate: MaterialPlate) -> None:
|
||||||
|
"""添加料板"""
|
||||||
|
if not self.can_add_plate():
|
||||||
|
raise ValueError(f"槽位 {self.name} 已满,无法添加更多板")
|
||||||
|
self.assign_child_resource(plate)
|
||||||
|
|
||||||
|
def get_top_plate(self) -> MaterialPlate:
|
||||||
|
"""获取最上方的板"""
|
||||||
|
if len(self.children) == 0:
|
||||||
|
raise ValueError(f"槽位 {self.name} 为空")
|
||||||
|
return cast(MaterialPlate, self.get_top_item())
|
||||||
|
|
||||||
|
def take_top_plate(self) -> MaterialPlate:
|
||||||
|
"""取出最上方的板"""
|
||||||
|
top_plate = self.get_top_plate()
|
||||||
|
self.unassign_child_resource(top_plate)
|
||||||
|
return top_plate
|
||||||
|
|
||||||
|
def can_access_for_picking(self) -> bool:
|
||||||
|
"""检查是否可以进行取料操作(只有最上方的板能进行取料操作)"""
|
||||||
|
return len(self.children) > 0
|
||||||
|
|
||||||
|
def serialize(self) -> dict:
|
||||||
|
return {
|
||||||
|
**super().serialize(),
|
||||||
|
"max_plates": self.max_plates,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#是一种类型注解,不用self
|
||||||
|
class BatteryState(TypedDict):
|
||||||
|
"""电池状态字典"""
|
||||||
|
diameter: float
|
||||||
|
height: float
|
||||||
|
assembly_pressure: float
|
||||||
|
electrolyte_volume: float
|
||||||
|
electrolyte_name: str
|
||||||
|
|
||||||
|
class Battery(Resource):
|
||||||
|
"""电池类 - 可容纳极片"""
|
||||||
|
children: List[ElectrodeSheet] = []
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
size_x=1,
|
||||||
|
size_y=1,
|
||||||
|
size_z=1,
|
||||||
|
category: str = "battery",
|
||||||
|
):
|
||||||
|
"""初始化电池
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 电池名称
|
||||||
|
diameter: 直径 (mm)
|
||||||
|
height: 高度 (mm)
|
||||||
|
max_volume: 最大容量 (μL)
|
||||||
|
barcode: 二维码编号
|
||||||
|
category: 类别
|
||||||
|
model: 型号
|
||||||
|
"""
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
size_x=1,
|
||||||
|
size_y=1,
|
||||||
|
size_z=1,
|
||||||
|
category=category,
|
||||||
|
)
|
||||||
|
self._unilabos_state: BatteryState = BatteryState(
|
||||||
|
diameter = 1.0,
|
||||||
|
height = 1.0,
|
||||||
|
assembly_pressure = 1.0,
|
||||||
|
electrolyte_volume = 1.0,
|
||||||
|
electrolyte_name = "DP001"
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_electrolyte_with_bottle(self, bottle: Bottle) -> bool:
|
||||||
|
to_add_name = bottle._unilabos_state["electrolyte_name"]
|
||||||
|
if bottle.aspirate_electrolyte(10):
|
||||||
|
if self.add_electrolyte(to_add_name, 10):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
bottle._unilabos_state["electrolyte_volume"] += 10
|
||||||
|
|
||||||
|
def set_electrolyte(self, name: str, volume: float) -> None:
|
||||||
|
"""设置电解液信息"""
|
||||||
|
self._unilabos_state["electrolyte_name"] = name
|
||||||
|
self._unilabos_state["electrolyte_volume"] = volume
|
||||||
|
#这个应该没用,不会有加了后再加的事情
|
||||||
|
def add_electrolyte(self, name: str, volume: float) -> bool:
|
||||||
|
"""添加电解液信息"""
|
||||||
|
if name != self._unilabos_state["electrolyte_name"]:
|
||||||
|
return False
|
||||||
|
self._unilabos_state["electrolyte_volume"] += volume
|
||||||
|
|
||||||
|
def load_state(self, state: Dict[str, Any]) -> None:
|
||||||
|
"""格式不变"""
|
||||||
|
super().load_state(state)
|
||||||
|
self._unilabos_state = state
|
||||||
|
|
||||||
|
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""格式不变"""
|
||||||
|
data = super().serialize_state()
|
||||||
|
data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等)
|
||||||
|
return data
|
||||||
|
|
||||||
|
# 电解液作为属性放进去
|
||||||
|
|
||||||
|
class BatteryPressSlotState(TypedDict):
|
||||||
|
"""电池状态字典"""
|
||||||
|
diameter: float =20.0
|
||||||
|
depth: float = 4.0
|
||||||
|
|
||||||
|
class BatteryPressSlot(Resource):
|
||||||
|
"""电池压制槽类 - 设备,可容纳一个电池"""
|
||||||
|
children: List[Battery] = []
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str = "BatteryPressSlot",
|
||||||
|
category: str = "battery_press_slot",
|
||||||
|
):
|
||||||
|
"""初始化电池压制槽
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 压制槽名称
|
||||||
|
diameter: 直径 (mm)
|
||||||
|
depth: 深度 (mm)
|
||||||
|
category: 类别
|
||||||
|
model: 型号
|
||||||
|
"""
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
size_x=10,
|
||||||
|
size_y=12,
|
||||||
|
size_z=13,
|
||||||
|
category=category,
|
||||||
|
)
|
||||||
|
self._unilabos_state: BatteryPressSlotState = BatteryPressSlotState()
|
||||||
|
|
||||||
|
def has_battery(self) -> bool:
|
||||||
|
"""检查是否有电池"""
|
||||||
|
return len(self.children) > 0
|
||||||
|
|
||||||
|
def load_state(self, state: Dict[str, Any]) -> None:
|
||||||
|
"""格式不变"""
|
||||||
|
super().load_state(state)
|
||||||
|
self._unilabos_state = state
|
||||||
|
|
||||||
|
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""格式不变"""
|
||||||
|
data = super().serialize_state()
|
||||||
|
data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def assign_child_resource(
|
||||||
|
self,
|
||||||
|
resource: Battery,
|
||||||
|
location: Optional[Coordinate],
|
||||||
|
reassign: bool = True,
|
||||||
|
):
|
||||||
|
"""放置极片"""
|
||||||
|
# TODO: 让高京看下槽位只有一个电池时是否这么写。
|
||||||
|
if self.has_battery():
|
||||||
|
raise ValueError(f"槽位已含有一个电池,无法再放置其他电池")
|
||||||
|
super().assign_child_resource(resource, location, reassign)
|
||||||
|
|
||||||
|
# 根据children的编号取物料对象。
|
||||||
|
def get_battery_info(self, index: int) -> Battery:
|
||||||
|
return self.children[0]
|
||||||
|
|
||||||
|
|
||||||
|
def TipBox64(
|
||||||
|
name: str,
|
||||||
|
size_x: float = 127.8,
|
||||||
|
size_y: float = 85.5,
|
||||||
|
size_z: float = 60.0,
|
||||||
|
category: str = "tip_rack",
|
||||||
|
model: Optional[str] = None,
|
||||||
|
):
|
||||||
|
"""64孔枪头盒类"""
|
||||||
|
from pylabrobot.resources.tip import Tip
|
||||||
|
|
||||||
|
# 创建12x8=96个枪头位
|
||||||
|
def make_tip():
|
||||||
|
return Tip(
|
||||||
|
has_filter=False,
|
||||||
|
total_tip_length=20.0,
|
||||||
|
maximal_volume=1000, # 1mL
|
||||||
|
fitting_depth=8.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
tip_spots = create_ordered_items_2d(
|
||||||
|
klass=TipSpot,
|
||||||
|
num_items_x=12,
|
||||||
|
num_items_y=8,
|
||||||
|
dx=8.0,
|
||||||
|
dy=8.0,
|
||||||
|
dz=0.0,
|
||||||
|
item_dx=9.0,
|
||||||
|
item_dy=9.0,
|
||||||
|
size_x=10,
|
||||||
|
size_y=10,
|
||||||
|
size_z=0.0,
|
||||||
|
make_tip=make_tip,
|
||||||
|
)
|
||||||
|
idx_available = list(range(0, 32)) + list(range(64, 96))
|
||||||
|
tip_spots_available = {k: v for i, (k, v) in enumerate(tip_spots.items()) if i in idx_available}
|
||||||
|
tip_rack = TipRack(
|
||||||
|
name=name,
|
||||||
|
size_x=size_x,
|
||||||
|
size_y=size_y,
|
||||||
|
size_z=size_z,
|
||||||
|
# ordered_items=tip_spots_available,
|
||||||
|
ordered_items=tip_spots,
|
||||||
|
category=category,
|
||||||
|
model=model,
|
||||||
|
with_tips=False,
|
||||||
|
)
|
||||||
|
tip_rack.set_tip_state([True]*32 + [False]*32 + [True]*32) # 前32和后32个有枪头,中间32个无枪头
|
||||||
|
return tip_rack
|
||||||
|
|
||||||
|
|
||||||
|
class WasteTipBoxstate(TypedDict):
|
||||||
|
""""废枪头盒状态字典"""
|
||||||
|
max_tips: int = 100
|
||||||
|
tip_count: int = 0
|
||||||
|
|
||||||
|
#枪头不是一次性的(同一溶液则反复使用),根据寄存器判断
|
||||||
|
class WasteTipBox(Trash):
|
||||||
|
"""废枪头盒类 - 100个枪头容量"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
size_x: float = 127.8,
|
||||||
|
size_y: float = 85.5,
|
||||||
|
size_z: float = 60.0,
|
||||||
|
material_z_thickness=0,
|
||||||
|
max_volume=float("inf"),
|
||||||
|
category="trash",
|
||||||
|
model=None,
|
||||||
|
compute_volume_from_height=None,
|
||||||
|
compute_height_from_volume=None,
|
||||||
|
):
|
||||||
|
"""初始化废枪头盒
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 废枪头盒名称
|
||||||
|
size_x: 长度 (mm)
|
||||||
|
size_y: 宽度 (mm)
|
||||||
|
size_z: 高度 (mm)
|
||||||
|
max_tips: 最大枪头容量
|
||||||
|
category: 类别
|
||||||
|
model: 型号
|
||||||
|
"""
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
size_x=size_x,
|
||||||
|
size_y=size_y,
|
||||||
|
size_z=size_z,
|
||||||
|
category=category,
|
||||||
|
model=model,
|
||||||
|
)
|
||||||
|
self._unilabos_state: WasteTipBoxstate = WasteTipBoxstate()
|
||||||
|
|
||||||
|
def add_tip(self) -> None:
|
||||||
|
"""添加废枪头"""
|
||||||
|
if self._unilabos_state["tip_count"] >= self._unilabos_state["max_tips"]:
|
||||||
|
raise ValueError(f"废枪头盒 {self.name} 已满")
|
||||||
|
self._unilabos_state["tip_count"] += 1
|
||||||
|
|
||||||
|
def get_tip_count(self) -> int:
|
||||||
|
"""获取枪头数量"""
|
||||||
|
return self._unilabos_state["tip_count"]
|
||||||
|
|
||||||
|
def empty(self) -> None:
|
||||||
|
"""清空废枪头盒"""
|
||||||
|
self._unilabos_state["tip_count"] = 0
|
||||||
|
|
||||||
|
|
||||||
|
def load_state(self, state: Dict[str, Any]) -> None:
|
||||||
|
"""格式不变"""
|
||||||
|
super().load_state(state)
|
||||||
|
self._unilabos_state = state
|
||||||
|
|
||||||
|
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""格式不变"""
|
||||||
|
data = super().serialize_state()
|
||||||
|
data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class CoincellDeck(Deck):
|
||||||
|
"""纽扣电池组装工作站台面类"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str = "coin_cell_deck",
|
||||||
|
size_x: float = 1450.0, # 1m
|
||||||
|
size_y: float = 1450.0, # 1m
|
||||||
|
size_z: float = 100.0, # 0.9m
|
||||||
|
origin: Coordinate = Coordinate(-2200, 0, 0),
|
||||||
|
category: str = "coin_cell_deck",
|
||||||
|
setup: bool = False, # 是否自动执行 setup
|
||||||
|
):
|
||||||
|
"""初始化纽扣电池组装工作站台面
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 台面名称
|
||||||
|
size_x: 长度 (mm) - 1m
|
||||||
|
size_y: 宽度 (mm) - 1m
|
||||||
|
size_z: 高度 (mm) - 0.9m
|
||||||
|
origin: 原点坐标
|
||||||
|
category: 类别
|
||||||
|
setup: 是否自动执行 setup 配置标准布局
|
||||||
|
"""
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
size_x=1450.0,
|
||||||
|
size_y=1450.0,
|
||||||
|
size_z=100.0,
|
||||||
|
origin=origin,
|
||||||
|
)
|
||||||
|
if setup:
|
||||||
|
self.setup()
|
||||||
|
|
||||||
|
def setup(self) -> None:
|
||||||
|
"""设置工作站的标准布局 - 包含子弹夹、料盘、瓶架等完整配置"""
|
||||||
|
# ====================================== 子弹夹 ============================================
|
||||||
|
|
||||||
|
# 正极片(4个洞位,2x2布局)
|
||||||
|
zhengji_zip = MagazineHolder_4_Cathode("正极&铝箔弹夹")
|
||||||
|
self.assign_child_resource(zhengji_zip, Coordinate(x=402.0, y=830.0, z=0))
|
||||||
|
|
||||||
|
# 正极壳、平垫片(6个洞位,2x2+2布局)
|
||||||
|
zhengjike_zip = MagazineHolder_6_Cathode("正极壳&平垫片弹夹")
|
||||||
|
self.assign_child_resource(zhengjike_zip, Coordinate(x=566.0, y=272.0, z=0))
|
||||||
|
|
||||||
|
# 负极壳、弹垫片(6个洞位,2x2+2布局)
|
||||||
|
fujike_zip = MagazineHolder_6_Anode("负极壳&弹垫片弹夹")
|
||||||
|
self.assign_child_resource(fujike_zip, Coordinate(x=474.0, y=276.0, z=0))
|
||||||
|
|
||||||
|
# 成品弹夹(6个洞位,3x2布局)
|
||||||
|
chengpindanjia_zip = MagazineHolder_6_Battery("成品弹夹")
|
||||||
|
self.assign_child_resource(chengpindanjia_zip, Coordinate(x=260.0, y=156.0, z=0))
|
||||||
|
|
||||||
|
# ====================================== 物料板 ============================================
|
||||||
|
# 创建物料板(料盘carrier)- 4x4布局
|
||||||
|
# 负极料盘
|
||||||
|
fujiliaopan = MaterialPlate(name="负极料盘", size_x=120, size_y=100, size_z=10.0, fill=True)
|
||||||
|
self.assign_child_resource(fujiliaopan, Coordinate(x=708.0, y=794.0, z=0))
|
||||||
|
# for i in range(16):
|
||||||
|
# fujipian = ElectrodeSheet(name=f"{fujiliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
|
||||||
|
# fujiliaopan.children[i].assign_child_resource(fujipian, location=None)
|
||||||
|
|
||||||
|
# 隔膜料盘
|
||||||
|
gemoliaopan = MaterialPlate(name="隔膜料盘", size_x=120, size_y=100, size_z=10.0, fill=True)
|
||||||
|
self.assign_child_resource(gemoliaopan, Coordinate(x=718.0, y=918.0, z=0))
|
||||||
|
# for i in range(16):
|
||||||
|
# gemopian = ElectrodeSheet(name=f"{gemoliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
|
||||||
|
# gemoliaopan.children[i].assign_child_resource(gemopian, location=None)
|
||||||
|
|
||||||
|
# ====================================== 瓶架、移液枪 ============================================
|
||||||
|
# 在台面上放置 3x4 瓶架、6x2 瓶架 与 64孔移液枪头盒
|
||||||
|
# 奔耀上料5ml分液瓶小板 - 由奔曜跨站转运而来,不单独写,但是这里应该有一个堆栈用于摆放分液瓶小板
|
||||||
|
|
||||||
|
# bottle_rack_3x4 = BottleRack(
|
||||||
|
# name="bottle_rack_3x4",
|
||||||
|
# size_x=210.0,
|
||||||
|
# size_y=140.0,
|
||||||
|
# size_z=100.0,
|
||||||
|
# num_items_x=2,
|
||||||
|
# num_items_y=4,
|
||||||
|
# position_spacing=35.0,
|
||||||
|
# orientation="vertical",
|
||||||
|
# )
|
||||||
|
# self.assign_child_resource(bottle_rack_3x4, Coordinate(x=1542.0, y=717.0, z=0))
|
||||||
|
|
||||||
|
# 电解液缓存位 - 6x2布局
|
||||||
|
bottle_rack_6x2 = YIHUA_Electrolyte_12VialCarrier(name="bottle_rack_6x2")
|
||||||
|
self.assign_child_resource(bottle_rack_6x2, Coordinate(x=1050.0, y=358.0, z=0))
|
||||||
|
# 电解液回收位6x2
|
||||||
|
bottle_rack_6x2_2 = YIHUA_Electrolyte_12VialCarrier(name="bottle_rack_6x2_2")
|
||||||
|
self.assign_child_resource(bottle_rack_6x2_2, Coordinate(x=914.0, y=358.0, z=0))
|
||||||
|
|
||||||
|
tip_box = TipBox64(name="tip_box_64")
|
||||||
|
self.assign_child_resource(tip_box, Coordinate(x=782.0, y=514.0, z=0))
|
||||||
|
|
||||||
|
waste_tip_box = WasteTipBox(name="waste_tip_box")
|
||||||
|
self.assign_child_resource(waste_tip_box, Coordinate(x=778.0, y=622.0, z=0))
|
||||||
|
|
||||||
|
|
||||||
|
def YH_Deck(name=""):
|
||||||
|
cd = CoincellDeck(name=name)
|
||||||
|
cd.setup()
|
||||||
|
return cd
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
deck = create_coin_cell_deck()
|
||||||
|
print(deck)
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,133 @@
|
|||||||
|
Name,DataType,InitValue,Comment,Attribute,DeviceType,Address,
|
||||||
|
COIL_SYS_START_CMD,BOOL,,,,coil,8010,
|
||||||
|
COIL_SYS_STOP_CMD,BOOL,,,,coil,8020,
|
||||||
|
COIL_SYS_RESET_CMD,BOOL,,,,coil,8030,
|
||||||
|
COIL_SYS_HAND_CMD,BOOL,,,,coil,8040,
|
||||||
|
COIL_SYS_AUTO_CMD,BOOL,,,,coil,8050,
|
||||||
|
COIL_SYS_INIT_CMD,BOOL,,,,coil,8060,
|
||||||
|
COIL_UNILAB_SEND_MSG_SUCC_CMD,BOOL,,,,coil,8700,
|
||||||
|
COIL_UNILAB_REC_MSG_SUCC_CMD,BOOL,,,,coil,8710,unilab_rec_msg_succ_cmd
|
||||||
|
COIL_SYS_START_STATUS,BOOL,,,,coil,8210,
|
||||||
|
COIL_SYS_STOP_STATUS,BOOL,,,,coil,8220,
|
||||||
|
COIL_SYS_RESET_STATUS,BOOL,,,,coil,8230,
|
||||||
|
COIL_SYS_HAND_STATUS,BOOL,,,,coil,8240,
|
||||||
|
COIL_SYS_AUTO_STATUS,BOOL,,,,coil,8250,
|
||||||
|
COIL_SYS_INIT_STATUS,BOOL,,,,coil,8260,
|
||||||
|
COIL_REQUEST_REC_MSG_STATUS,BOOL,,,,coil,8500,
|
||||||
|
COIL_REQUEST_SEND_MSG_STATUS,BOOL,,,,coil,8510,request_send_msg_status
|
||||||
|
REG_MSG_ELECTROLYTE_USE_NUM,INT16,,,,hold_register,11000,
|
||||||
|
REG_MSG_ELECTROLYTE_NUM,INT16,,,,hold_register,11002,unilab_send_msg_electrolyte_num
|
||||||
|
REG_MSG_ELECTROLYTE_VOLUME,INT16,,,,hold_register,11004,unilab_send_msg_electrolyte_vol
|
||||||
|
REG_MSG_ASSEMBLY_TYPE,INT16,,,,hold_register,11006,unilab_send_msg_assembly_type
|
||||||
|
REG_MSG_ASSEMBLY_PRESSURE,INT16,,,,hold_register,11008,unilab_send_msg_assembly_pressure
|
||||||
|
REG_DATA_ASSEMBLY_COIN_CELL_NUM,INT16,,,,hold_register,10000,data_assembly_coin_cell_num
|
||||||
|
REG_DATA_OPEN_CIRCUIT_VOLTAGE,FLOAT32,,,,hold_register,10002,data_open_circuit_voltage
|
||||||
|
REG_DATA_AXIS_X_POS,FLOAT32,,,,hold_register,10004,
|
||||||
|
REG_DATA_AXIS_Y_POS,FLOAT32,,,,hold_register,10006,
|
||||||
|
REG_DATA_AXIS_Z_POS,FLOAT32,,,,hold_register,10008,
|
||||||
|
REG_DATA_POLE_WEIGHT,FLOAT32,,,,hold_register,10010,data_pole_weight
|
||||||
|
REG_DATA_ASSEMBLY_PER_TIME,FLOAT32,,,,hold_register,10012,data_assembly_time
|
||||||
|
REG_DATA_ASSEMBLY_PRESSURE,INT16,,,,hold_register,10014,data_assembly_pressure
|
||||||
|
REG_DATA_ELECTROLYTE_VOLUME,INT16,,,,hold_register,10016,data_electrolyte_volume
|
||||||
|
REG_DATA_COIN_NUM,INT16,,,,hold_register,10018,data_coin_num
|
||||||
|
REG_DATA_ELECTROLYTE_CODE,STRING,,,,hold_register,10020,data_electrolyte_code()
|
||||||
|
REG_DATA_COIN_CELL_CODE,STRING,,,,hold_register,10030,data_coin_cell_code()
|
||||||
|
REG_DATA_STACK_VISON_CODE,STRING,,,,hold_register,12004,data_stack_vision_code()
|
||||||
|
REG_DATA_GLOVE_BOX_PRESSURE,FLOAT32,,,,hold_register,10050,data_glove_box_pressure
|
||||||
|
REG_DATA_GLOVE_BOX_WATER_CONTENT,FLOAT32,,,,hold_register,10052,data_glove_box_water_content
|
||||||
|
REG_DATA_GLOVE_BOX_O2_CONTENT,FLOAT32,,,,hold_register,10054,data_glove_box_o2_content
|
||||||
|
UNILAB_SEND_ELECTROLYTE_BOTTLE_NUM,BOOL,,,,coil,8720,
|
||||||
|
UNILAB_RECE_ELECTROLYTE_BOTTLE_NUM,BOOL,,,,coil,8520,
|
||||||
|
REG_MSG_ELECTROLYTE_NUM_USED,INT16,,,,hold_register,496,
|
||||||
|
REG_DATA_ELECTROLYTE_USE_NUM,INT16,,,,hold_register,10000,
|
||||||
|
UNILAB_SEND_FINISHED_CMD,BOOL,,,,coil,8730,
|
||||||
|
UNILAB_RECE_FINISHED_CMD,BOOL,,,,coil,8530,
|
||||||
|
REG_DATA_ASSEMBLY_TYPE,INT16,,,,hold_register,10018,ASSEMBLY_TYPE7or8
|
||||||
|
REG_UNILAB_INTERACT,BOOL,,,,coil,8450,
|
||||||
|
,,,,,coil,8320,
|
||||||
|
COIL_ALUMINUM_FOIL,BOOL,,,,coil,8340,
|
||||||
|
REG_MSG_NE_PLATE_MATRIX,INT16,,,,hold_register,440,
|
||||||
|
REG_MSG_SEPARATOR_PLATE_MATRIX,INT16,,,,hold_register,450,
|
||||||
|
REG_MSG_TIP_BOX_MATRIX,INT16,,,,hold_register,480,
|
||||||
|
REG_MSG_NE_PLATE_NUM,INT16,,,,hold_register,443,
|
||||||
|
REG_MSG_SEPARATOR_PLATE_NUM,INT16,,,,hold_register,453,
|
||||||
|
REG_MSG_PRESS_MODE,BOOL,,,,coil,8360,
|
||||||
|
,BOOL,,,,coil,8300,
|
||||||
|
,BOOL,,,,coil,8310,
|
||||||
|
COIL_GB_L_IGNORE_CMD,BOOL,,,,coil,8320,
|
||||||
|
COIL_GB_R_IGNORE_CMD,BOOL,,,,coil,8420,
|
||||||
|
,BOOL,,,,coil,8350,
|
||||||
|
COIL_ELECTROLYTE_DUAL_DROP_MODE,BOOL,,,,coil,8370,
|
||||||
|
,BOOL,,,,coil,8380,
|
||||||
|
,BOOL,,,,coil,8390,
|
||||||
|
,BOOL,,,,coil,8400,
|
||||||
|
,BOOL,,,,coil,8410,
|
||||||
|
REG_MSG_DUAL_DROP_FIRST_VOLUME,INT16,,,,hold_register,4001,
|
||||||
|
COIL_DUAL_DROP_SUCTION_TIMING,BOOL,,,,coil,8430,
|
||||||
|
COIL_DUAL_DROP_START_TIMING,BOOL,,,,coil,8470,
|
||||||
|
REG_MSG_BATTERY_CLEAN_IGNORE,BOOL,,,,coil,8460,
|
||||||
|
COIL_MATERIAL_SEARCH_DIALOG_APPEAR,BOOL,,,,coil,6470,
|
||||||
|
COIL_MATERIAL_SEARCH_CONFIRM_YES,BOOL,,,,coil,6480,
|
||||||
|
COIL_MATERIAL_SEARCH_CONFIRM_NO,BOOL,,,,coil,6490,
|
||||||
|
COIL_ALARM_100_SYSTEM_ERROR,BOOL,,,,coil,1000,异常100-系统异常
|
||||||
|
COIL_ALARM_101_EMERGENCY_STOP,BOOL,,,,coil,1010,异常101-急停
|
||||||
|
COIL_ALARM_111_GLOVEBOX_EMERGENCY_STOP,BOOL,,,,coil,1110,异常111-手套箱急停
|
||||||
|
COIL_ALARM_112_GLOVEBOX_GRATING_BLOCKED,BOOL,,,,coil,1120,异常112-手套箱内光栅遮挡
|
||||||
|
COIL_ALARM_160_PIPETTE_TIP_SHORTAGE,BOOL,,,,coil,1600,异常160-移液枪头缺料
|
||||||
|
COIL_ALARM_161_POSITIVE_SHELL_SHORTAGE,BOOL,,,,coil,1610,异常161-正极壳缺料
|
||||||
|
COIL_ALARM_162_ALUMINUM_FOIL_SHORTAGE,BOOL,,,,coil,1620,异常162-铝箔垫缺料
|
||||||
|
COIL_ALARM_163_POSITIVE_PLATE_SHORTAGE,BOOL,,,,coil,1630,异常163-正极片缺料
|
||||||
|
COIL_ALARM_164_SEPARATOR_SHORTAGE,BOOL,,,,coil,1640,异常164-隔膜缺料
|
||||||
|
COIL_ALARM_165_NEGATIVE_PLATE_SHORTAGE,BOOL,,,,coil,1650,异常165-负极片缺料
|
||||||
|
COIL_ALARM_166_FLAT_WASHER_SHORTAGE,BOOL,,,,coil,1660,异常166-平垫缺料
|
||||||
|
COIL_ALARM_167_SPRING_WASHER_SHORTAGE,BOOL,,,,coil,1670,异常167-弹垫缺料
|
||||||
|
COIL_ALARM_168_NEGATIVE_SHELL_SHORTAGE,BOOL,,,,coil,1680,异常168-负极壳缺料
|
||||||
|
COIL_ALARM_169_FINISHED_BATTERY_FULL,BOOL,,,,coil,1690,异常169-成品电池满料
|
||||||
|
COIL_ALARM_201_SERVO_AXIS_01_ERROR,BOOL,,,,coil,2010,异常201-伺服轴01异常
|
||||||
|
COIL_ALARM_202_SERVO_AXIS_02_ERROR,BOOL,,,,coil,2020,异常202-伺服轴02异常
|
||||||
|
COIL_ALARM_203_SERVO_AXIS_03_ERROR,BOOL,,,,coil,2030,异常203-伺服轴03异常
|
||||||
|
COIL_ALARM_204_SERVO_AXIS_04_ERROR,BOOL,,,,coil,2040,异常204-伺服轴04异常
|
||||||
|
COIL_ALARM_205_SERVO_AXIS_05_ERROR,BOOL,,,,coil,2050,异常205-伺服轴05异常
|
||||||
|
COIL_ALARM_206_SERVO_AXIS_06_ERROR,BOOL,,,,coil,2060,异常206-伺服轴06异常
|
||||||
|
COIL_ALARM_207_SERVO_AXIS_07_ERROR,BOOL,,,,coil,2070,异常207-伺服轴07异常
|
||||||
|
COIL_ALARM_208_SERVO_AXIS_08_ERROR,BOOL,,,,coil,2080,异常208-伺服轴08异常
|
||||||
|
COIL_ALARM_209_SERVO_AXIS_09_ERROR,BOOL,,,,coil,2090,异常209-伺服轴09异常
|
||||||
|
COIL_ALARM_210_SERVO_AXIS_10_ERROR,BOOL,,,,coil,2100,异常210-伺服轴10异常
|
||||||
|
COIL_ALARM_211_SERVO_AXIS_11_ERROR,BOOL,,,,coil,2110,异常211-伺服轴11异常
|
||||||
|
COIL_ALARM_212_SERVO_AXIS_12_ERROR,BOOL,,,,coil,2120,异常212-伺服轴12异常
|
||||||
|
COIL_ALARM_213_SERVO_AXIS_13_ERROR,BOOL,,,,coil,2130,异常213-伺服轴13异常
|
||||||
|
COIL_ALARM_214_SERVO_AXIS_14_ERROR,BOOL,,,,coil,2140,异常214-伺服轴14异常
|
||||||
|
COIL_ALARM_250_OTHER_COMPONENT_ERROR,BOOL,,,,coil,2500,异常250-其他元件异常
|
||||||
|
COIL_ALARM_251_PIPETTE_COMM_ERROR,BOOL,,,,coil,2510,异常251-移液枪通讯异常
|
||||||
|
COIL_ALARM_252_PIPETTE_ALARM,BOOL,,,,coil,2520,异常252-移液枪报警
|
||||||
|
COIL_ALARM_256_ELECTRIC_GRIPPER_ERROR,BOOL,,,,coil,2560,异常256-电爪异常
|
||||||
|
COIL_ALARM_262_RB_UNKNOWN_POSITION_ERROR,BOOL,,,,coil,2620,异常262-RB报警:未知点位错误
|
||||||
|
COIL_ALARM_263_RB_XYZ_PARAM_LIMIT_ERROR,BOOL,,,,coil,2630,异常263-RB报警:X、Y、Z参数超限制
|
||||||
|
COIL_ALARM_264_RB_VISION_PARAM_ERROR,BOOL,,,,coil,2640,异常264-RB报警:视觉参数误差过大
|
||||||
|
COIL_ALARM_265_RB_NOZZLE_1_PICK_FAIL,BOOL,,,,coil,2650,异常265-RB报警:1#吸嘴取料失败
|
||||||
|
COIL_ALARM_266_RB_NOZZLE_2_PICK_FAIL,BOOL,,,,coil,2660,异常266-RB报警:2#吸嘴取料失败
|
||||||
|
COIL_ALARM_267_RB_NOZZLE_3_PICK_FAIL,BOOL,,,,coil,2670,异常267-RB报警:3#吸嘴取料失败
|
||||||
|
COIL_ALARM_268_RB_NOZZLE_4_PICK_FAIL,BOOL,,,,coil,2680,异常268-RB报警:4#吸嘴取料失败
|
||||||
|
COIL_ALARM_269_RB_TRAY_PICK_FAIL,BOOL,,,,coil,2690,异常269-RB报警:取物料盘失败
|
||||||
|
COIL_ALARM_280_RB_COLLISION_ERROR,BOOL,,,,coil,2800,异常280-RB碰撞异常
|
||||||
|
COIL_ALARM_290_VISION_SYSTEM_COMM_ERROR,BOOL,,,,coil,2900,异常290-视觉系统通讯异常
|
||||||
|
COIL_ALARM_291_VISION_ALIGNMENT_NG,BOOL,,,,coil,2910,异常291-视觉对位NG异常
|
||||||
|
COIL_ALARM_292_BARCODE_SCANNER_COMM_ERROR,BOOL,,,,coil,2920,异常292-扫码枪通讯异常
|
||||||
|
COIL_ALARM_310_OCV_TRANSFER_NOZZLE_SUCTION_ERROR,BOOL,,,,coil,3100,异常310-开电移载吸嘴吸真空异常
|
||||||
|
COIL_ALARM_311_OCV_TRANSFER_NOZZLE_BREAK_ERROR,BOOL,,,,coil,3110,异常311-开电移载吸嘴破真空异常
|
||||||
|
COIL_ALARM_312_WEIGHT_TRANSFER_NOZZLE_SUCTION_ERROR,BOOL,,,,coil,3120,异常312-称重移载吸嘴吸真空异常
|
||||||
|
COIL_ALARM_313_WEIGHT_TRANSFER_NOZZLE_BREAK_ERROR,BOOL,,,,coil,3130,异常313-称重移载吸嘴破真空异常
|
||||||
|
COIL_ALARM_340_OCV_NOZZLE_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3400,异常340-开路电压吸嘴移载气缸异常
|
||||||
|
COIL_ALARM_342_OCV_NOZZLE_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3420,异常342-开路电压吸嘴升降气缸异常
|
||||||
|
COIL_ALARM_344_OCV_CRIMPING_CYLINDER_ERROR,BOOL,,,,coil,3440,异常344-开路电压旋压气缸异常
|
||||||
|
COIL_ALARM_350_WEIGHT_NOZZLE_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3500,异常350-称重吸嘴移载气缸异常
|
||||||
|
COIL_ALARM_352_WEIGHT_NOZZLE_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3520,异常352-称重吸嘴升降气缸异常
|
||||||
|
COIL_ALARM_354_CLEANING_CLOTH_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3540,异常354-清洗无尘布移载气缸异常
|
||||||
|
COIL_ALARM_356_CLEANING_CLOTH_PRESS_CYLINDER_ERROR,BOOL,,,,coil,3560,异常356-清洗无尘布压紧气缸异常
|
||||||
|
COIL_ALARM_360_ELECTROLYTE_BOTTLE_POSITION_CYLINDER_ERROR,BOOL,,,,coil,3600,异常360-电解液瓶定位气缸异常
|
||||||
|
COIL_ALARM_362_PIPETTE_TIP_BOX_POSITION_CYLINDER_ERROR,BOOL,,,,coil,3620,异常362-移液枪头盒定位气缸异常
|
||||||
|
COIL_ALARM_364_REAGENT_BOTTLE_GRIPPER_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3640,异常364-试剂瓶夹爪升降气缸异常
|
||||||
|
COIL_ALARM_366_REAGENT_BOTTLE_GRIPPER_CYLINDER_ERROR,BOOL,,,,coil,3660,异常366-试剂瓶夹爪气缸异常
|
||||||
|
COIL_ALARM_370_PRESS_MODULE_BLOW_CYLINDER_ERROR,BOOL,,,,coil,3700,异常370-压制模块吹气气缸异常
|
||||||
|
COIL_ALARM_151_ELECTROLYTE_BOTTLE_POSITION_ERROR,BOOL,,,,coil,1510,异常151-电解液瓶定位在籍异常
|
||||||
|
COIL_ALARM_152_ELECTROLYTE_BOTTLE_CAP_ERROR,BOOL,,,,coil,1520,异常152-电解液瓶盖在籍异常
|
||||||
|
File diff suppressed because it is too large
Load Diff
@@ -459,12 +459,12 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
|
|||||||
# 验证必需字段
|
# 验证必需字段
|
||||||
if 'brand' in request_data:
|
if 'brand' in request_data:
|
||||||
if request_data['brand'] == "bioyond": # 奔曜
|
if request_data['brand'] == "bioyond": # 奔曜
|
||||||
error_msg = request_data["text"]
|
material_data = request_data["text"]
|
||||||
logger.info(f"收到奔曜错误处理报送: {error_msg}")
|
logger.info(f"收到奔曜物料变更报送: {material_data}")
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
success=True,
|
success=True,
|
||||||
message=f"错误处理报送已收到: {error_msg}",
|
message=f"物料变更报送已收到: {material_data}",
|
||||||
acknowledgment_id=f"ERROR_{int(time.time() * 1000)}_{error_msg.get('action_id', 'unknown')}",
|
acknowledgment_id=f"MATERIAL_{int(time.time() * 1000)}_{material_data.get('id', 'unknown')}",
|
||||||
data=None
|
data=None
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
# PRCXI 耗材管理 Web 应用
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
"""启动入口: python -m unilabos.labware_manager"""
|
|
||||||
from unilabos.labware_manager.app import main
|
|
||||||
|
|
||||||
main()
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
"""FastAPI 应用 + CRUD API + 启动入口。
|
|
||||||
|
|
||||||
用法: python -m unilabos.labware_manager.app
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Query, Request
|
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
|
|
||||||
from unilabos.labware_manager.models import LabwareDB, LabwareItem
|
|
||||||
|
|
||||||
_HERE = Path(__file__).resolve().parent
|
|
||||||
_DB_PATH = _HERE / "labware_db.json"
|
|
||||||
|
|
||||||
app = FastAPI(title="PRCXI 耗材管理", version="1.0")
|
|
||||||
|
|
||||||
# 静态文件 + 模板
|
|
||||||
app.mount("/static", StaticFiles(directory=str(_HERE / "static")), name="static")
|
|
||||||
templates = Jinja2Templates(directory=str(_HERE / "templates"))
|
|
||||||
|
|
||||||
|
|
||||||
# ---------- DB 读写 ----------
|
|
||||||
|
|
||||||
def _load_db() -> LabwareDB:
|
|
||||||
if not _DB_PATH.exists():
|
|
||||||
return LabwareDB()
|
|
||||||
with open(_DB_PATH, "r", encoding="utf-8") as f:
|
|
||||||
return LabwareDB(**json.load(f))
|
|
||||||
|
|
||||||
|
|
||||||
def _save_db(db: LabwareDB) -> None:
|
|
||||||
with open(_DB_PATH, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(db.model_dump(), f, ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------- 页面路由 ----------
|
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
|
||||||
async def index_page(request: Request):
|
|
||||||
db = _load_db()
|
|
||||||
# 按 type 分组
|
|
||||||
groups = {}
|
|
||||||
for item in db.items:
|
|
||||||
groups.setdefault(item.type, []).append(item)
|
|
||||||
return templates.TemplateResponse("index.html", {
|
|
||||||
"request": request,
|
|
||||||
"groups": groups,
|
|
||||||
"total": len(db.items),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/labware/new", response_class=HTMLResponse)
|
|
||||||
async def new_page(request: Request, type: str = "plate"):
|
|
||||||
return templates.TemplateResponse("edit.html", {
|
|
||||||
"request": request,
|
|
||||||
"item": None,
|
|
||||||
"labware_type": type,
|
|
||||||
"is_new": True,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/labware/{item_id}", response_class=HTMLResponse)
|
|
||||||
async def detail_page(request: Request, item_id: str):
|
|
||||||
db = _load_db()
|
|
||||||
item = _find_item(db, item_id)
|
|
||||||
if not item:
|
|
||||||
raise HTTPException(404, "耗材不存在")
|
|
||||||
return templates.TemplateResponse("detail.html", {
|
|
||||||
"request": request,
|
|
||||||
"item": item,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/labware/{item_id}/edit", response_class=HTMLResponse)
|
|
||||||
async def edit_page(request: Request, item_id: str):
|
|
||||||
db = _load_db()
|
|
||||||
item = _find_item(db, item_id)
|
|
||||||
if not item:
|
|
||||||
raise HTTPException(404, "耗材不存在")
|
|
||||||
return templates.TemplateResponse("edit.html", {
|
|
||||||
"request": request,
|
|
||||||
"item": item,
|
|
||||||
"labware_type": item.type,
|
|
||||||
"is_new": False,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
# ---------- API 端点 ----------
|
|
||||||
|
|
||||||
@app.get("/api/labware")
|
|
||||||
async def api_list_labware():
|
|
||||||
db = _load_db()
|
|
||||||
return {"items": [item.model_dump() for item in db.items]}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/labware")
|
|
||||||
async def api_create_labware(request: Request):
|
|
||||||
data = await request.json()
|
|
||||||
db = _load_db()
|
|
||||||
item = LabwareItem(**data)
|
|
||||||
# 确保 id 唯一
|
|
||||||
existing_ids = {it.id for it in db.items}
|
|
||||||
while item.id in existing_ids:
|
|
||||||
import uuid
|
|
||||||
item.id = uuid.uuid4().hex[:8]
|
|
||||||
db.items.append(item)
|
|
||||||
_save_db(db)
|
|
||||||
return {"status": "ok", "id": item.id}
|
|
||||||
|
|
||||||
|
|
||||||
@app.put("/api/labware/{item_id}")
|
|
||||||
async def api_update_labware(item_id: str, request: Request):
|
|
||||||
data = await request.json()
|
|
||||||
db = _load_db()
|
|
||||||
for i, it in enumerate(db.items):
|
|
||||||
if it.id == item_id or it.function_name == item_id:
|
|
||||||
updated = LabwareItem(**{**it.model_dump(), **data, "id": it.id})
|
|
||||||
db.items[i] = updated
|
|
||||||
_save_db(db)
|
|
||||||
return {"status": "ok", "id": it.id}
|
|
||||||
raise HTTPException(404, "耗材不存在")
|
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/labware/{item_id}")
|
|
||||||
async def api_delete_labware(item_id: str):
|
|
||||||
db = _load_db()
|
|
||||||
original_len = len(db.items)
|
|
||||||
db.items = [it for it in db.items if it.id != item_id and it.function_name != item_id]
|
|
||||||
if len(db.items) == original_len:
|
|
||||||
raise HTTPException(404, "耗材不存在")
|
|
||||||
_save_db(db)
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/generate-code")
|
|
||||||
async def api_generate_code(request: Request):
|
|
||||||
body = await request.json() if await request.body() else {}
|
|
||||||
test_mode = body.get("test_mode", True)
|
|
||||||
db = _load_db()
|
|
||||||
if not db.items:
|
|
||||||
raise HTTPException(400, "数据库为空,请先导入")
|
|
||||||
|
|
||||||
from unilabos.labware_manager.codegen import generate_code
|
|
||||||
from unilabos.labware_manager.yaml_gen import generate_yaml
|
|
||||||
|
|
||||||
py_path = generate_code(db, test_mode=test_mode)
|
|
||||||
yaml_paths = generate_yaml(db, test_mode=test_mode)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "ok",
|
|
||||||
"python_file": str(py_path),
|
|
||||||
"yaml_files": [str(p) for p in yaml_paths],
|
|
||||||
"test_mode": test_mode,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/import-from-code")
|
|
||||||
async def api_import_from_code():
|
|
||||||
from unilabos.labware_manager.importer import import_from_code, save_db
|
|
||||||
db = import_from_code()
|
|
||||||
save_db(db)
|
|
||||||
return {
|
|
||||||
"status": "ok",
|
|
||||||
"count": len(db.items),
|
|
||||||
"items": [{"function_name": it.function_name, "type": it.type} for it in db.items],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------- 辅助函数 ----------
|
|
||||||
|
|
||||||
def _find_item(db: LabwareDB, item_id: str) -> Optional[LabwareItem]:
|
|
||||||
for item in db.items:
|
|
||||||
if item.id == item_id or item.function_name == item_id:
|
|
||||||
return item
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------- 启动入口 ----------
|
|
||||||
|
|
||||||
def main():
|
|
||||||
import uvicorn
|
|
||||||
port = int(os.environ.get("LABWARE_PORT", "8010"))
|
|
||||||
print(f"PRCXI 耗材管理 → http://localhost:{port}")
|
|
||||||
uvicorn.run(app, host="0.0.0.0", port=port)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,451 +0,0 @@
|
|||||||
"""JSON → prcxi_labware.py 代码生成。
|
|
||||||
|
|
||||||
读取 labware_db.json,输出完整的 prcxi_labware.py(或 prcxi_labware_test.py)。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import shutil
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from unilabos.labware_manager.models import LabwareDB, LabwareItem
|
|
||||||
|
|
||||||
_TARGET_DIR = Path(__file__).resolve().parents[1] / "devices" / "liquid_handling" / "prcxi"
|
|
||||||
|
|
||||||
# ---------- 固定头部 ----------
|
|
||||||
_HEADER = '''\
|
|
||||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
||||||
from pylabrobot.resources import Tube, Coordinate
|
|
||||||
from pylabrobot.resources.well import Well, WellBottomType, CrossSectionType
|
|
||||||
from pylabrobot.resources.tip import Tip, TipCreator
|
|
||||||
from pylabrobot.resources.tip_rack import TipRack, TipSpot
|
|
||||||
from pylabrobot.resources.utils import create_ordered_items_2d
|
|
||||||
from pylabrobot.resources.height_volume_functions import (
|
|
||||||
compute_height_from_volume_rectangle,
|
|
||||||
compute_volume_from_height_rectangle,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .prcxi import PRCXI9300Plate, PRCXI9300TipRack, PRCXI9300Trash, PRCXI9300TubeRack, PRCXI9300PlateAdapter
|
|
||||||
|
|
||||||
def _make_tip_helper(volume: float, length: float, depth: float) -> Tip:
|
|
||||||
"""
|
|
||||||
PLR 的 Tip 类参数名为: maximal_volume, total_tip_length, fitting_depth
|
|
||||||
"""
|
|
||||||
return Tip(
|
|
||||||
has_filter=False, # 默认无滤芯
|
|
||||||
maximal_volume=volume,
|
|
||||||
total_tip_length=length,
|
|
||||||
fitting_depth=depth
|
|
||||||
)
|
|
||||||
|
|
||||||
'''
|
|
||||||
|
|
||||||
|
|
||||||
def _gen_plate(item: LabwareItem) -> str:
|
|
||||||
"""生成 Plate 类型的工厂函数代码。"""
|
|
||||||
lines = []
|
|
||||||
fn = item.function_name
|
|
||||||
doc = item.docstring or f"Code: {item.material_info.Code}"
|
|
||||||
|
|
||||||
has_vf = item.volume_functions is not None
|
|
||||||
|
|
||||||
if has_vf:
|
|
||||||
# 有 volume_functions 时需要 well_kwargs 方式
|
|
||||||
vf = item.volume_functions
|
|
||||||
well = item.well
|
|
||||||
grid = item.grid
|
|
||||||
|
|
||||||
lines.append(f'def {fn}(name: str) -> PRCXI9300Plate:')
|
|
||||||
lines.append(f' """')
|
|
||||||
for dl in doc.split('\n'):
|
|
||||||
lines.append(f' {dl}')
|
|
||||||
lines.append(f' """')
|
|
||||||
|
|
||||||
# 计算 well_size 变量
|
|
||||||
lines.append(f' well_size_x = {well.size_x}')
|
|
||||||
lines.append(f' well_size_y = {well.size_y}')
|
|
||||||
|
|
||||||
lines.append(f' well_kwargs = {{')
|
|
||||||
lines.append(f' "size_x": well_size_x,')
|
|
||||||
lines.append(f' "size_y": well_size_y,')
|
|
||||||
lines.append(f' "size_z": {well.size_z},')
|
|
||||||
lines.append(f' "bottom_type": WellBottomType.{well.bottom_type},')
|
|
||||||
if well.cross_section_type and well.cross_section_type != "CIRCLE":
|
|
||||||
lines.append(f' "cross_section_type": CrossSectionType.{well.cross_section_type},')
|
|
||||||
lines.append(f' "compute_height_from_volume": lambda liquid_volume: compute_height_from_volume_rectangle(')
|
|
||||||
lines.append(f' liquid_volume=liquid_volume, well_length=well_size_x, well_width=well_size_y')
|
|
||||||
lines.append(f' ),')
|
|
||||||
lines.append(f' "compute_volume_from_height": lambda liquid_height: compute_volume_from_height_rectangle(')
|
|
||||||
lines.append(f' liquid_height=liquid_height, well_length=well_size_x, well_width=well_size_y')
|
|
||||||
lines.append(f' ),')
|
|
||||||
if well.material_z_thickness is not None:
|
|
||||||
lines.append(f' "material_z_thickness": {well.material_z_thickness},')
|
|
||||||
lines.append(f' }}')
|
|
||||||
lines.append(f'')
|
|
||||||
lines.append(f' return PRCXI9300Plate(')
|
|
||||||
lines.append(f' name=name,')
|
|
||||||
lines.append(f' size_x={item.size_x},')
|
|
||||||
lines.append(f' size_y={item.size_y},')
|
|
||||||
lines.append(f' size_z={item.size_z},')
|
|
||||||
lines.append(f' lid=None,')
|
|
||||||
lines.append(f' model="{item.model}",')
|
|
||||||
lines.append(f' category="plate",')
|
|
||||||
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))},')
|
|
||||||
lines.append(f' ordered_items=create_ordered_items_2d(')
|
|
||||||
lines.append(f' Well,')
|
|
||||||
lines.append(f' num_items_x={grid.num_items_x},')
|
|
||||||
lines.append(f' num_items_y={grid.num_items_y},')
|
|
||||||
lines.append(f' dx={grid.dx},')
|
|
||||||
lines.append(f' dy={grid.dy},')
|
|
||||||
lines.append(f' dz={grid.dz},')
|
|
||||||
lines.append(f' item_dx={grid.item_dx},')
|
|
||||||
lines.append(f' item_dy={grid.item_dy},')
|
|
||||||
lines.append(f' **well_kwargs,')
|
|
||||||
lines.append(f' ),')
|
|
||||||
lines.append(f' )')
|
|
||||||
else:
|
|
||||||
# 普通 plate
|
|
||||||
well = item.well
|
|
||||||
grid = item.grid
|
|
||||||
|
|
||||||
lines.append(f'def {fn}(name: str) -> PRCXI9300Plate:')
|
|
||||||
lines.append(f' """')
|
|
||||||
for dl in doc.split('\n'):
|
|
||||||
lines.append(f' {dl}')
|
|
||||||
lines.append(f' """')
|
|
||||||
lines.append(f' return PRCXI9300Plate(')
|
|
||||||
lines.append(f' name=name,')
|
|
||||||
lines.append(f' size_x={item.size_x},')
|
|
||||||
lines.append(f' size_y={item.size_y},')
|
|
||||||
lines.append(f' size_z={item.size_z},')
|
|
||||||
if item.plate_type:
|
|
||||||
lines.append(f' plate_type="{item.plate_type}",')
|
|
||||||
lines.append(f' model="{item.model}",')
|
|
||||||
lines.append(f' category="plate",')
|
|
||||||
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))},')
|
|
||||||
if grid and well:
|
|
||||||
lines.append(f' ordered_items=create_ordered_items_2d(')
|
|
||||||
lines.append(f' Well,')
|
|
||||||
lines.append(f' num_items_x={grid.num_items_x},')
|
|
||||||
lines.append(f' num_items_y={grid.num_items_y},')
|
|
||||||
lines.append(f' dx={grid.dx},')
|
|
||||||
lines.append(f' dy={grid.dy},')
|
|
||||||
lines.append(f' dz={grid.dz},')
|
|
||||||
lines.append(f' item_dx={grid.item_dx},')
|
|
||||||
lines.append(f' item_dy={grid.item_dy},')
|
|
||||||
lines.append(f' size_x={well.size_x},')
|
|
||||||
lines.append(f' size_y={well.size_y},')
|
|
||||||
lines.append(f' size_z={well.size_z},')
|
|
||||||
if well.max_volume is not None:
|
|
||||||
lines.append(f' max_volume={well.max_volume},')
|
|
||||||
if well.material_z_thickness is not None:
|
|
||||||
lines.append(f' material_z_thickness={well.material_z_thickness},')
|
|
||||||
if well.bottom_type and well.bottom_type != "FLAT":
|
|
||||||
lines.append(f' bottom_type=WellBottomType.{well.bottom_type},')
|
|
||||||
if well.cross_section_type:
|
|
||||||
lines.append(f' cross_section_type=CrossSectionType.{well.cross_section_type},')
|
|
||||||
lines.append(f' ),')
|
|
||||||
lines.append(f' )')
|
|
||||||
|
|
||||||
return '\n'.join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def _gen_tip_rack(item: LabwareItem) -> str:
|
|
||||||
"""生成 TipRack 工厂函数代码。"""
|
|
||||||
lines = []
|
|
||||||
fn = item.function_name
|
|
||||||
doc = item.docstring or f"Code: {item.material_info.Code}"
|
|
||||||
grid = item.grid
|
|
||||||
tip = item.tip
|
|
||||||
|
|
||||||
lines.append(f'def {fn}(name: str) -> PRCXI9300TipRack:')
|
|
||||||
lines.append(f' """')
|
|
||||||
for dl in doc.split('\n'):
|
|
||||||
lines.append(f' {dl}')
|
|
||||||
lines.append(f' """')
|
|
||||||
lines.append(f' return PRCXI9300TipRack(')
|
|
||||||
lines.append(f' name=name,')
|
|
||||||
lines.append(f' size_x={item.size_x},')
|
|
||||||
lines.append(f' size_y={item.size_y},')
|
|
||||||
lines.append(f' size_z={item.size_z},')
|
|
||||||
lines.append(f' model="{item.model}",')
|
|
||||||
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))},')
|
|
||||||
if grid and tip:
|
|
||||||
lines.append(f' ordered_items=create_ordered_items_2d(')
|
|
||||||
lines.append(f' TipSpot,')
|
|
||||||
lines.append(f' num_items_x={grid.num_items_x},')
|
|
||||||
lines.append(f' num_items_y={grid.num_items_y},')
|
|
||||||
lines.append(f' dx={grid.dx},')
|
|
||||||
lines.append(f' dy={grid.dy},')
|
|
||||||
lines.append(f' dz={grid.dz},')
|
|
||||||
lines.append(f' item_dx={grid.item_dx},')
|
|
||||||
lines.append(f' item_dy={grid.item_dy},')
|
|
||||||
lines.append(f' size_x={tip.spot_size_x},')
|
|
||||||
lines.append(f' size_y={tip.spot_size_y},')
|
|
||||||
lines.append(f' size_z={tip.spot_size_z},')
|
|
||||||
lines.append(f' make_tip=lambda: _make_tip_helper(volume={tip.tip_volume}, length={tip.tip_length}, depth={tip.tip_fitting_depth})')
|
|
||||||
lines.append(f' )')
|
|
||||||
lines.append(f' )')
|
|
||||||
|
|
||||||
return '\n'.join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def _gen_trash(item: LabwareItem) -> str:
|
|
||||||
"""生成 Trash 工厂函数代码。"""
|
|
||||||
lines = []
|
|
||||||
fn = item.function_name
|
|
||||||
doc = item.docstring or f"Code: {item.material_info.Code}"
|
|
||||||
|
|
||||||
lines.append(f'def {fn}(name: str = "trash") -> PRCXI9300Trash:')
|
|
||||||
lines.append(f' """')
|
|
||||||
for dl in doc.split('\n'):
|
|
||||||
lines.append(f' {dl}')
|
|
||||||
lines.append(f' """')
|
|
||||||
lines.append(f' return PRCXI9300Trash(')
|
|
||||||
lines.append(f' name="trash",')
|
|
||||||
lines.append(f' size_x={item.size_x},')
|
|
||||||
lines.append(f' size_y={item.size_y},')
|
|
||||||
lines.append(f' size_z={item.size_z},')
|
|
||||||
lines.append(f' category="trash",')
|
|
||||||
lines.append(f' model="{item.model}",')
|
|
||||||
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))}')
|
|
||||||
lines.append(f' )')
|
|
||||||
|
|
||||||
return '\n'.join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def _gen_tube_rack(item: LabwareItem) -> str:
|
|
||||||
"""生成 TubeRack 工厂函数代码。"""
|
|
||||||
lines = []
|
|
||||||
fn = item.function_name
|
|
||||||
doc = item.docstring or f"Code: {item.material_info.Code}"
|
|
||||||
grid = item.grid
|
|
||||||
tube = item.tube
|
|
||||||
|
|
||||||
lines.append(f'def {fn}(name: str) -> PRCXI9300TubeRack:')
|
|
||||||
lines.append(f' """')
|
|
||||||
for dl in doc.split('\n'):
|
|
||||||
lines.append(f' {dl}')
|
|
||||||
lines.append(f' """')
|
|
||||||
lines.append(f' return PRCXI9300TubeRack(')
|
|
||||||
lines.append(f' name=name,')
|
|
||||||
lines.append(f' size_x={item.size_x},')
|
|
||||||
lines.append(f' size_y={item.size_y},')
|
|
||||||
lines.append(f' size_z={item.size_z},')
|
|
||||||
lines.append(f' model="{item.model}",')
|
|
||||||
lines.append(f' category="tube_rack",')
|
|
||||||
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))},')
|
|
||||||
if grid and tube:
|
|
||||||
lines.append(f' ordered_items=create_ordered_items_2d(')
|
|
||||||
lines.append(f' Tube,')
|
|
||||||
lines.append(f' num_items_x={grid.num_items_x},')
|
|
||||||
lines.append(f' num_items_y={grid.num_items_y},')
|
|
||||||
lines.append(f' dx={grid.dx},')
|
|
||||||
lines.append(f' dy={grid.dy},')
|
|
||||||
lines.append(f' dz={grid.dz},')
|
|
||||||
lines.append(f' item_dx={grid.item_dx},')
|
|
||||||
lines.append(f' item_dy={grid.item_dy},')
|
|
||||||
lines.append(f' size_x={tube.size_x},')
|
|
||||||
lines.append(f' size_y={tube.size_y},')
|
|
||||||
lines.append(f' size_z={tube.size_z},')
|
|
||||||
lines.append(f' max_volume={tube.max_volume}')
|
|
||||||
lines.append(f' )')
|
|
||||||
lines.append(f' )')
|
|
||||||
|
|
||||||
return '\n'.join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def _gen_plate_adapter(item: LabwareItem) -> str:
|
|
||||||
"""生成 PlateAdapter 工厂函数代码。"""
|
|
||||||
lines = []
|
|
||||||
fn = item.function_name
|
|
||||||
doc = item.docstring or f"Code: {item.material_info.Code}"
|
|
||||||
|
|
||||||
lines.append(f'def {fn}(name: str) -> PRCXI9300PlateAdapter:')
|
|
||||||
lines.append(f' """ {doc} """')
|
|
||||||
lines.append(f' return PRCXI9300PlateAdapter(')
|
|
||||||
lines.append(f' name=name,')
|
|
||||||
lines.append(f' size_x={item.size_x},')
|
|
||||||
lines.append(f' size_y={item.size_y},')
|
|
||||||
lines.append(f' size_z={item.size_z},')
|
|
||||||
if item.model:
|
|
||||||
lines.append(f' model="{item.model}",')
|
|
||||||
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))}')
|
|
||||||
lines.append(f' )')
|
|
||||||
|
|
||||||
return '\n'.join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def _fmt_dict(d: dict) -> str:
|
|
||||||
"""格式化字典为 Python 代码片段。"""
|
|
||||||
parts = []
|
|
||||||
for k, v in d.items():
|
|
||||||
if isinstance(v, str):
|
|
||||||
parts.append(f'"{k}": "{v}"')
|
|
||||||
elif v is None:
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
parts.append(f'"{k}": {v}')
|
|
||||||
return '{' + ', '.join(parts) + '}'
|
|
||||||
|
|
||||||
|
|
||||||
def _gen_template_factory_kinds(items: List[LabwareItem]) -> str:
|
|
||||||
"""生成 PRCXI_TEMPLATE_FACTORY_KINDS 列表。"""
|
|
||||||
lines = ['PRCXI_TEMPLATE_FACTORY_KINDS: List[Tuple[Callable[..., Any], str]] = [']
|
|
||||||
for item in items:
|
|
||||||
if item.include_in_template_matching and item.template_kind:
|
|
||||||
lines.append(f' ({item.function_name}, "{item.template_kind}"),')
|
|
||||||
lines.append(']')
|
|
||||||
return '\n'.join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def _gen_footer() -> str:
|
|
||||||
"""生成文件尾部的模板相关代码。"""
|
|
||||||
return '''
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 协议上传 / workflow 用:与设备端耗材字典字段对齐的模板描述(供 common 自动匹配)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_PRCXI_TEMPLATE_SPECS_CACHE: Optional[List[Dict[str, Any]]] = None
|
|
||||||
|
|
||||||
|
|
||||||
def _probe_prcxi_resource(factory: Callable[..., Any]) -> Any:
|
|
||||||
probe = "__unilab_template_probe__"
|
|
||||||
if factory.__name__ == "PRCXI_trash":
|
|
||||||
return factory()
|
|
||||||
return factory(probe)
|
|
||||||
|
|
||||||
|
|
||||||
def _first_child_capacity_for_match(resource: Any) -> float:
|
|
||||||
"""Well max_volume 或 Tip 的 maximal_volume,用于与设备端 Volume 类似的打分。"""
|
|
||||||
ch = getattr(resource, "children", None) or []
|
|
||||||
if not ch:
|
|
||||||
return 0.0
|
|
||||||
c0 = ch[0]
|
|
||||||
mv = getattr(c0, "max_volume", None)
|
|
||||||
if mv is not None:
|
|
||||||
return float(mv)
|
|
||||||
tip = getattr(c0, "tip", None)
|
|
||||||
if tip is not None:
|
|
||||||
mv2 = getattr(tip, "maximal_volume", None)
|
|
||||||
if mv2 is not None:
|
|
||||||
return float(mv2)
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def get_prcxi_labware_template_specs() -> List[Dict[str, Any]]:
|
|
||||||
"""返回与 ``prcxi._match_and_create_matrix`` 中耗材字段兼容的模板列表,用于按孔数+容量打分。"""
|
|
||||||
global _PRCXI_TEMPLATE_SPECS_CACHE
|
|
||||||
if _PRCXI_TEMPLATE_SPECS_CACHE is not None:
|
|
||||||
return _PRCXI_TEMPLATE_SPECS_CACHE
|
|
||||||
|
|
||||||
out: List[Dict[str, Any]] = []
|
|
||||||
for factory, kind in PRCXI_TEMPLATE_FACTORY_KINDS:
|
|
||||||
try:
|
|
||||||
r = _probe_prcxi_resource(factory)
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
nx = int(getattr(r, "num_items_x", None) or 0)
|
|
||||||
ny = int(getattr(r, "num_items_y", None) or 0)
|
|
||||||
nchild = len(getattr(r, "children", []) or [])
|
|
||||||
hole_count = nx * ny if nx > 0 and ny > 0 else nchild
|
|
||||||
hole_row = ny if nx > 0 and ny > 0 else 0
|
|
||||||
hole_col = nx if nx > 0 and ny > 0 else 0
|
|
||||||
mi = getattr(r, "material_info", None) or {}
|
|
||||||
vol = _first_child_capacity_for_match(r)
|
|
||||||
menum = mi.get("materialEnum")
|
|
||||||
if menum is None and kind == "tip_rack":
|
|
||||||
menum = 1
|
|
||||||
elif menum is None and kind == "trash":
|
|
||||||
menum = 6
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"class_name": factory.__name__,
|
|
||||||
"kind": kind,
|
|
||||||
"materialEnum": menum,
|
|
||||||
"HoleRow": hole_row,
|
|
||||||
"HoleColum": hole_col,
|
|
||||||
"Volume": vol,
|
|
||||||
"hole_count": hole_count,
|
|
||||||
"material_uuid": mi.get("uuid"),
|
|
||||||
"material_code": mi.get("Code"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
_PRCXI_TEMPLATE_SPECS_CACHE = out
|
|
||||||
return out
|
|
||||||
'''
|
|
||||||
|
|
||||||
|
|
||||||
def generate_code(db: LabwareDB, test_mode: bool = True) -> Path:
|
|
||||||
"""生成 prcxi_labware.py (或 _test.py),返回输出文件路径。"""
|
|
||||||
suffix = "_test" if test_mode else ""
|
|
||||||
out_path = _TARGET_DIR / f"prcxi_labware{suffix}.py"
|
|
||||||
|
|
||||||
# 备份
|
|
||||||
if out_path.exists():
|
|
||||||
bak = out_path.with_suffix(".py.bak")
|
|
||||||
shutil.copy2(out_path, bak)
|
|
||||||
|
|
||||||
# 按类型分组的生成器
|
|
||||||
generators = {
|
|
||||||
"plate": _gen_plate,
|
|
||||||
"tip_rack": _gen_tip_rack,
|
|
||||||
"trash": _gen_trash,
|
|
||||||
"tube_rack": _gen_tube_rack,
|
|
||||||
"plate_adapter": _gen_plate_adapter,
|
|
||||||
}
|
|
||||||
|
|
||||||
# 按 type 分段
|
|
||||||
sections = {
|
|
||||||
"plate": [],
|
|
||||||
"tip_rack": [],
|
|
||||||
"trash": [],
|
|
||||||
"tube_rack": [],
|
|
||||||
"plate_adapter": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
for item in db.items:
|
|
||||||
gen = generators.get(item.type)
|
|
||||||
if gen:
|
|
||||||
sections[item.type].append(gen(item))
|
|
||||||
|
|
||||||
# 组装完整文件
|
|
||||||
parts = [_HEADER]
|
|
||||||
|
|
||||||
section_titles = {
|
|
||||||
"plate": "# =========================================================================\n# Plates\n# =========================================================================",
|
|
||||||
"tip_rack": "# =========================================================================\n# Tip Racks\n# =========================================================================",
|
|
||||||
"trash": "# =========================================================================\n# Trash\n# =========================================================================",
|
|
||||||
"tube_rack": "# =========================================================================\n# Tube Racks\n# =========================================================================",
|
|
||||||
"plate_adapter": "# =========================================================================\n# Plate Adapters\n# =========================================================================",
|
|
||||||
}
|
|
||||||
|
|
||||||
for type_key in ["plate", "tip_rack", "trash", "tube_rack", "plate_adapter"]:
|
|
||||||
if sections[type_key]:
|
|
||||||
parts.append(section_titles[type_key])
|
|
||||||
for code in sections[type_key]:
|
|
||||||
parts.append(code)
|
|
||||||
|
|
||||||
# Template factory kinds
|
|
||||||
parts.append("")
|
|
||||||
parts.append(_gen_template_factory_kinds(db.items))
|
|
||||||
|
|
||||||
# Footer
|
|
||||||
parts.append(_gen_footer())
|
|
||||||
|
|
||||||
content = '\n'.join(parts)
|
|
||||||
out_path.write_text(content, encoding="utf-8")
|
|
||||||
return out_path
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
from unilabos.labware_manager.importer import load_db
|
|
||||||
db = load_db()
|
|
||||||
if not db.items:
|
|
||||||
print("labware_db.json 为空,请先运行 importer.py")
|
|
||||||
else:
|
|
||||||
out = generate_code(db, test_mode=True)
|
|
||||||
print(f"已生成 {out} ({len(db.items)} 个工厂函数)")
|
|
||||||
@@ -1,474 +0,0 @@
|
|||||||
"""从现有 prcxi_labware.py + registry YAML 导入耗材数据到 labware_db.json。
|
|
||||||
|
|
||||||
策略:
|
|
||||||
1. 实例化每个工厂函数 → 提取物理尺寸、material_info、children
|
|
||||||
2. AST 解析源码 → 提取 docstring、volume_function 参数、plate_type
|
|
||||||
3. 从 children[0].location 反推 dx/dy/dz,相邻位置差推 item_dx/item_dy
|
|
||||||
4. 同时读取现有 YAML → 提取 registry_category / description
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import ast
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
# 将项目根目录加入 sys.path 以便 import
|
|
||||||
_PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
if str(_PROJECT_ROOT) not in sys.path:
|
|
||||||
sys.path.insert(0, str(_PROJECT_ROOT))
|
|
||||||
|
|
||||||
from unilabos.labware_manager.models import (
|
|
||||||
AdapterInfo,
|
|
||||||
GridInfo,
|
|
||||||
LabwareDB,
|
|
||||||
LabwareItem,
|
|
||||||
MaterialInfo,
|
|
||||||
TipInfo,
|
|
||||||
TubeInfo,
|
|
||||||
VolumeFunctions,
|
|
||||||
WellInfo,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---------- 路径常量 ----------
|
|
||||||
_LABWARE_PY = Path(__file__).resolve().parents[1] / "devices" / "liquid_handling" / "prcxi" / "prcxi_labware.py"
|
|
||||||
_REGISTRY_DIR = Path(__file__).resolve().parents[1] / "registry" / "resources" / "prcxi"
|
|
||||||
_DB_PATH = Path(__file__).resolve().parent / "labware_db.json"
|
|
||||||
|
|
||||||
# YAML 文件名 → type 映射
|
|
||||||
_YAML_MAP: Dict[str, str] = {
|
|
||||||
"plates.yaml": "plate",
|
|
||||||
"tip_racks.yaml": "tip_rack",
|
|
||||||
"trash.yaml": "trash",
|
|
||||||
"tube_racks.yaml": "tube_rack",
|
|
||||||
"plate_adapters.yaml": "plate_adapter",
|
|
||||||
}
|
|
||||||
|
|
||||||
# PRCXI_TEMPLATE_FACTORY_KINDS 中列出的函数名(include_in_template_matching=True)
|
|
||||||
_TEMPLATE_FACTORY_NAMES = {
|
|
||||||
"PRCXI_BioER_96_wellplate", "PRCXI_nest_1_troughplate",
|
|
||||||
"PRCXI_BioRad_384_wellplate", "PRCXI_AGenBio_4_troughplate",
|
|
||||||
"PRCXI_nest_12_troughplate", "PRCXI_CellTreat_96_wellplate",
|
|
||||||
"PRCXI_10ul_eTips", "PRCXI_300ul_Tips",
|
|
||||||
"PRCXI_PCR_Plate_200uL_nonskirted", "PRCXI_PCR_Plate_200uL_semiskirted",
|
|
||||||
"PRCXI_PCR_Plate_200uL_skirted", "PRCXI_trash",
|
|
||||||
"PRCXI_96_DeepWell", "PRCXI_EP_Adapter",
|
|
||||||
"PRCXI_1250uL_Tips", "PRCXI_10uL_Tips",
|
|
||||||
"PRCXI_1000uL_Tips", "PRCXI_200uL_Tips",
|
|
||||||
"PRCXI_48_DeepWell",
|
|
||||||
}
|
|
||||||
|
|
||||||
# template_kind 对应
|
|
||||||
_TEMPLATE_KINDS: Dict[str, str] = {
|
|
||||||
"PRCXI_BioER_96_wellplate": "plate",
|
|
||||||
"PRCXI_nest_1_troughplate": "plate",
|
|
||||||
"PRCXI_BioRad_384_wellplate": "plate",
|
|
||||||
"PRCXI_AGenBio_4_troughplate": "plate",
|
|
||||||
"PRCXI_nest_12_troughplate": "plate",
|
|
||||||
"PRCXI_CellTreat_96_wellplate": "plate",
|
|
||||||
"PRCXI_10ul_eTips": "tip_rack",
|
|
||||||
"PRCXI_300ul_Tips": "tip_rack",
|
|
||||||
"PRCXI_PCR_Plate_200uL_nonskirted": "plate",
|
|
||||||
"PRCXI_PCR_Plate_200uL_semiskirted": "plate",
|
|
||||||
"PRCXI_PCR_Plate_200uL_skirted": "plate",
|
|
||||||
"PRCXI_trash": "trash",
|
|
||||||
"PRCXI_96_DeepWell": "plate",
|
|
||||||
"PRCXI_EP_Adapter": "tube_rack",
|
|
||||||
"PRCXI_1250uL_Tips": "tip_rack",
|
|
||||||
"PRCXI_10uL_Tips": "tip_rack",
|
|
||||||
"PRCXI_1000uL_Tips": "tip_rack",
|
|
||||||
"PRCXI_200uL_Tips": "tip_rack",
|
|
||||||
"PRCXI_48_DeepWell": "plate",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _load_registry_info() -> Dict[str, Dict[str, Any]]:
|
|
||||||
"""读取所有 registry YAML 文件,返回 {function_name: {category, description}} 映射。"""
|
|
||||||
info: Dict[str, Dict[str, Any]] = {}
|
|
||||||
for fname, ltype in _YAML_MAP.items():
|
|
||||||
fpath = _REGISTRY_DIR / fname
|
|
||||||
if not fpath.exists():
|
|
||||||
continue
|
|
||||||
with open(fpath, "r", encoding="utf-8") as f:
|
|
||||||
data = yaml.safe_load(f) or {}
|
|
||||||
for func_name, entry in data.items():
|
|
||||||
info[func_name] = {
|
|
||||||
"registry_category": entry.get("category", ["prcxi", ltype.replace("plate_adapter", "plate_adapters")]),
|
|
||||||
"registry_description": entry.get("description", ""),
|
|
||||||
}
|
|
||||||
return info
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_ast_info() -> Dict[str, Dict[str, Any]]:
|
|
||||||
"""AST 解析 prcxi_labware.py,提取每个工厂函数的 docstring 和 volume_function 参数。"""
|
|
||||||
source = _LABWARE_PY.read_text(encoding="utf-8")
|
|
||||||
tree = ast.parse(source)
|
|
||||||
result: Dict[str, Dict[str, Any]] = {}
|
|
||||||
|
|
||||||
for node in ast.walk(tree):
|
|
||||||
if not isinstance(node, ast.FunctionDef):
|
|
||||||
continue
|
|
||||||
fname = node.name
|
|
||||||
if not fname.startswith("PRCXI_"):
|
|
||||||
continue
|
|
||||||
if fname.startswith("_"):
|
|
||||||
continue
|
|
||||||
|
|
||||||
info: Dict[str, Any] = {"docstring": "", "volume_functions": None, "plate_type": None}
|
|
||||||
|
|
||||||
# docstring
|
|
||||||
doc = ast.get_docstring(node)
|
|
||||||
if doc:
|
|
||||||
info["docstring"] = doc.strip()
|
|
||||||
|
|
||||||
# 搜索函数体中的 plate_type 赋值和 volume_function 参数
|
|
||||||
func_source = ast.get_source_segment(source, node) or ""
|
|
||||||
|
|
||||||
# plate_type
|
|
||||||
m = re.search(r'plate_type\s*=\s*["\']([^"\']+)["\']', func_source)
|
|
||||||
if m:
|
|
||||||
info["plate_type"] = m.group(1)
|
|
||||||
|
|
||||||
# volume_functions: 检查 compute_height_from_volume_rectangle
|
|
||||||
if "compute_height_from_volume_rectangle" in func_source:
|
|
||||||
# 提取 well_length 和 well_width
|
|
||||||
vf: Dict[str, Any] = {"type": "rectangle"}
|
|
||||||
# 尝试从 lambda 中提取
|
|
||||||
wl_match = re.search(r'well_length\s*=\s*([\w_.]+)', func_source)
|
|
||||||
ww_match = re.search(r'well_width\s*=\s*([\w_.]+)', func_source)
|
|
||||||
if wl_match:
|
|
||||||
vf["well_length_var"] = wl_match.group(1)
|
|
||||||
if ww_match:
|
|
||||||
vf["well_width_var"] = ww_match.group(1)
|
|
||||||
info["volume_functions"] = vf
|
|
||||||
|
|
||||||
result[fname] = info
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _probe_factory(factory_func) -> Any:
|
|
||||||
"""实例化工厂函数获取 resource 对象。"""
|
|
||||||
if factory_func.__name__ == "PRCXI_trash":
|
|
||||||
return factory_func()
|
|
||||||
return factory_func("__probe__")
|
|
||||||
|
|
||||||
|
|
||||||
def _get_size(resource, attr: str) -> float:
|
|
||||||
"""获取 PLR Resource 的尺寸(兼容 size_x 和 _size_x)。"""
|
|
||||||
val = getattr(resource, attr, None)
|
|
||||||
if val is None:
|
|
||||||
val = getattr(resource, f"_{attr}", None)
|
|
||||||
if val is None:
|
|
||||||
val = getattr(resource, f"get_{attr}", lambda: 0)()
|
|
||||||
return float(val or 0)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_grid_from_children(resource) -> Optional[Dict[str, Any]]:
|
|
||||||
"""从 resource.children 提取网格信息。"""
|
|
||||||
children = getattr(resource, "children", None) or []
|
|
||||||
if not children:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 获取 num_items_x, num_items_y
|
|
||||||
num_x = getattr(resource, "num_items_x", None)
|
|
||||||
num_y = getattr(resource, "num_items_y", None)
|
|
||||||
if num_x is None or num_y is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
c0 = children[0]
|
|
||||||
loc0 = getattr(c0, "location", None)
|
|
||||||
dx = loc0.x if loc0 else 0.0
|
|
||||||
dy_raw = loc0.y if loc0 else 0.0 # 这是 PLR 布局后的位置,不是输入参数
|
|
||||||
dz = loc0.z if loc0 else 0.0
|
|
||||||
|
|
||||||
# 推算 item_dx, item_dy
|
|
||||||
item_dx = 9.0
|
|
||||||
item_dy = 9.0
|
|
||||||
if len(children) > 1:
|
|
||||||
c1 = children[1]
|
|
||||||
loc1 = getattr(c1, "location", None)
|
|
||||||
if loc1 and loc0:
|
|
||||||
diff_x = abs(loc1.x - loc0.x)
|
|
||||||
diff_y = abs(loc1.y - loc0.y)
|
|
||||||
if diff_x > 0.1:
|
|
||||||
item_dx = diff_x
|
|
||||||
if diff_y > 0.1:
|
|
||||||
item_dy = diff_y
|
|
||||||
|
|
||||||
# 如果 num_items_y > 1 且 num_items_x > 1, 找列间距
|
|
||||||
if int(num_y) > 1 and int(num_x) > 1 and len(children) >= int(num_y) + 1:
|
|
||||||
cn = children[int(num_y)]
|
|
||||||
locn = getattr(cn, "location", None)
|
|
||||||
if locn and loc0:
|
|
||||||
col_diff = abs(locn.x - loc0.x)
|
|
||||||
row_diff = abs(children[1].location.y - loc0.y) if len(children) > 1 else item_dy
|
|
||||||
if col_diff > 0.1:
|
|
||||||
item_dx = col_diff
|
|
||||||
if row_diff > 0.1:
|
|
||||||
item_dy = row_diff
|
|
||||||
|
|
||||||
# PLR create_ordered_items_2d 的 Y 轴排列是倒序的:
|
|
||||||
# child[0].y = dy_param + (num_y - 1) * item_dy (最上面一行)
|
|
||||||
# 因此反推原始 dy 参数:
|
|
||||||
dy = dy_raw - (int(num_y) - 1) * item_dy
|
|
||||||
|
|
||||||
return {
|
|
||||||
"num_items_x": int(num_x),
|
|
||||||
"num_items_y": int(num_y),
|
|
||||||
"dx": round(dx, 4),
|
|
||||||
"dy": round(dy, 4),
|
|
||||||
"dz": round(dz, 4),
|
|
||||||
"item_dx": round(item_dx, 4),
|
|
||||||
"item_dy": round(item_dy, 4),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_well_info(child) -> Dict[str, Any]:
|
|
||||||
"""从 Well/TipSpot/Tube 子对象提取信息。"""
|
|
||||||
# material_z_thickness 在 PLR 中如果未设置会抛 NotImplementedError
|
|
||||||
mzt = None
|
|
||||||
try:
|
|
||||||
mzt = child.material_z_thickness
|
|
||||||
except (NotImplementedError, AttributeError):
|
|
||||||
mzt = getattr(child, "_material_z_thickness", None)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"size_x": round(_get_size(child, "size_x"), 4),
|
|
||||||
"size_y": round(_get_size(child, "size_y"), 4),
|
|
||||||
"size_z": round(_get_size(child, "size_z"), 4),
|
|
||||||
"max_volume": getattr(child, "max_volume", None),
|
|
||||||
"bottom_type": getattr(child, "bottom_type", None),
|
|
||||||
"cross_section_type": getattr(child, "cross_section_type", None),
|
|
||||||
"material_z_thickness": mzt,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def import_from_code() -> LabwareDB:
|
|
||||||
"""执行完整的导入流程,返回 LabwareDB 对象。"""
|
|
||||||
# 1. 加载 registry 信息
|
|
||||||
reg_info = _load_registry_info()
|
|
||||||
|
|
||||||
# 2. AST 解析源码
|
|
||||||
ast_info = _parse_ast_info()
|
|
||||||
|
|
||||||
# 3. 导入工厂模块(通过包路径避免 relative import 问题)
|
|
||||||
import importlib
|
|
||||||
mod = importlib.import_module("unilabos.devices.liquid_handling.prcxi.prcxi_labware")
|
|
||||||
|
|
||||||
# 4. 获取 PRCXI_TEMPLATE_FACTORY_KINDS 列出的函数
|
|
||||||
factory_kinds = getattr(mod, "PRCXI_TEMPLATE_FACTORY_KINDS", [])
|
|
||||||
template_func_names = {f.__name__ for f, _k in factory_kinds}
|
|
||||||
|
|
||||||
# 5. 收集所有 PRCXI_ 开头的工厂函数
|
|
||||||
all_factories: List[Tuple[str, Any]] = []
|
|
||||||
for attr_name in dir(mod):
|
|
||||||
if attr_name.startswith("PRCXI_") and not attr_name.startswith("_"):
|
|
||||||
obj = getattr(mod, attr_name)
|
|
||||||
if callable(obj) and not isinstance(obj, type):
|
|
||||||
all_factories.append((attr_name, obj))
|
|
||||||
|
|
||||||
# 按源码行号排序
|
|
||||||
all_factories.sort(key=lambda x: getattr(x[1], "__code__", None) and x[1].__code__.co_firstlineno or 0)
|
|
||||||
|
|
||||||
items: List[LabwareItem] = []
|
|
||||||
|
|
||||||
for func_name, factory in all_factories:
|
|
||||||
try:
|
|
||||||
resource = _probe_factory(factory)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"跳过 {func_name}: {e}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 确定类型
|
|
||||||
type_name = "plate"
|
|
||||||
class_name = type(resource).__name__
|
|
||||||
if "TipRack" in class_name:
|
|
||||||
type_name = "tip_rack"
|
|
||||||
elif "Trash" in class_name:
|
|
||||||
type_name = "trash"
|
|
||||||
elif "TubeRack" in class_name:
|
|
||||||
type_name = "tube_rack"
|
|
||||||
elif "PlateAdapter" in class_name:
|
|
||||||
type_name = "plate_adapter"
|
|
||||||
|
|
||||||
# material_info
|
|
||||||
state = getattr(resource, "_unilabos_state", {}) or {}
|
|
||||||
mat = state.get("Material", {})
|
|
||||||
mat_info = MaterialInfo(
|
|
||||||
uuid=mat.get("uuid", ""),
|
|
||||||
Code=mat.get("Code", ""),
|
|
||||||
Name=mat.get("Name", ""),
|
|
||||||
materialEnum=mat.get("materialEnum"),
|
|
||||||
SupplyType=mat.get("SupplyType"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# AST 信息
|
|
||||||
ast_data = ast_info.get(func_name, {})
|
|
||||||
docstring = ast_data.get("docstring", "")
|
|
||||||
plate_type = ast_data.get("plate_type")
|
|
||||||
|
|
||||||
# Registry 信息
|
|
||||||
reg = reg_info.get(func_name, {})
|
|
||||||
registry_category = reg.get("registry_category", ["prcxi", _type_to_yaml_subcategory(type_name)])
|
|
||||||
registry_description = reg.get("registry_description", f'{mat_info.Name} (Code: {mat_info.Code})')
|
|
||||||
|
|
||||||
# 构建 item
|
|
||||||
item = LabwareItem(
|
|
||||||
id=func_name.lower().replace("prcxi_", "")[:8] or func_name[:8],
|
|
||||||
type=type_name,
|
|
||||||
function_name=func_name,
|
|
||||||
docstring=docstring,
|
|
||||||
size_x=round(_get_size(resource, "size_x"), 4),
|
|
||||||
size_y=round(_get_size(resource, "size_y"), 4),
|
|
||||||
size_z=round(_get_size(resource, "size_z"), 4),
|
|
||||||
model=getattr(resource, "model", None),
|
|
||||||
category=getattr(resource, "category", type_name),
|
|
||||||
plate_type=plate_type,
|
|
||||||
material_info=mat_info,
|
|
||||||
registry_category=registry_category,
|
|
||||||
registry_description=registry_description,
|
|
||||||
include_in_template_matching=func_name in template_func_names,
|
|
||||||
template_kind=_TEMPLATE_KINDS.get(func_name),
|
|
||||||
)
|
|
||||||
|
|
||||||
# 提取子项信息
|
|
||||||
children = getattr(resource, "children", None) or []
|
|
||||||
grid_data = _extract_grid_from_children(resource)
|
|
||||||
|
|
||||||
if type_name == "plate" and children:
|
|
||||||
if grid_data:
|
|
||||||
item.grid = GridInfo(**grid_data)
|
|
||||||
c0 = children[0]
|
|
||||||
well_data = _extract_well_info(c0)
|
|
||||||
bt = well_data.get("bottom_type")
|
|
||||||
if bt is not None:
|
|
||||||
bt = bt.name if hasattr(bt, "name") else str(bt)
|
|
||||||
else:
|
|
||||||
bt = "FLAT"
|
|
||||||
cst = well_data.get("cross_section_type")
|
|
||||||
if cst is not None:
|
|
||||||
cst = cst.name if hasattr(cst, "name") else str(cst)
|
|
||||||
else:
|
|
||||||
cst = "CIRCLE"
|
|
||||||
item.well = WellInfo(
|
|
||||||
size_x=well_data["size_x"],
|
|
||||||
size_y=well_data["size_y"],
|
|
||||||
size_z=well_data["size_z"],
|
|
||||||
max_volume=well_data.get("max_volume"),
|
|
||||||
bottom_type=bt,
|
|
||||||
cross_section_type=cst,
|
|
||||||
material_z_thickness=well_data.get("material_z_thickness"),
|
|
||||||
)
|
|
||||||
# volume_functions
|
|
||||||
vf = ast_data.get("volume_functions")
|
|
||||||
if vf:
|
|
||||||
# 需要实际获取 well 尺寸作为 volume_function 参数
|
|
||||||
item.volume_functions = VolumeFunctions(
|
|
||||||
type="rectangle",
|
|
||||||
well_length=well_data["size_x"],
|
|
||||||
well_width=well_data["size_y"],
|
|
||||||
)
|
|
||||||
|
|
||||||
elif type_name == "tip_rack" and children:
|
|
||||||
if grid_data:
|
|
||||||
item.grid = GridInfo(**grid_data)
|
|
||||||
c0 = children[0]
|
|
||||||
tip_obj = getattr(c0, "tip", None)
|
|
||||||
tip_volume = 300.0
|
|
||||||
tip_length = 60.0
|
|
||||||
tip_depth = 51.0
|
|
||||||
tip_filter = False
|
|
||||||
if tip_obj:
|
|
||||||
tip_volume = getattr(tip_obj, "maximal_volume", 300.0)
|
|
||||||
tip_length = getattr(tip_obj, "total_tip_length", 60.0)
|
|
||||||
tip_depth = getattr(tip_obj, "fitting_depth", 51.0)
|
|
||||||
tip_filter = getattr(tip_obj, "has_filter", False)
|
|
||||||
item.tip = TipInfo(
|
|
||||||
spot_size_x=round(_get_size(c0, "size_x"), 4),
|
|
||||||
spot_size_y=round(_get_size(c0, "size_y"), 4),
|
|
||||||
spot_size_z=round(_get_size(c0, "size_z"), 4),
|
|
||||||
tip_volume=tip_volume,
|
|
||||||
tip_length=tip_length,
|
|
||||||
tip_fitting_depth=tip_depth,
|
|
||||||
has_filter=tip_filter,
|
|
||||||
)
|
|
||||||
# 计算 tip_above_rack_length = tip_length - (size_z - dz)
|
|
||||||
if grid_data:
|
|
||||||
_dz = grid_data.get("dz", 0.0)
|
|
||||||
_above = tip_length - (item.size_z - _dz)
|
|
||||||
item.tip.tip_above_rack_length = round(_above, 4) if _above > 0 else None
|
|
||||||
|
|
||||||
elif type_name == "tube_rack" and children:
|
|
||||||
if grid_data:
|
|
||||||
item.grid = GridInfo(**grid_data)
|
|
||||||
c0 = children[0]
|
|
||||||
item.tube = TubeInfo(
|
|
||||||
size_x=round(_get_size(c0, "size_x"), 4),
|
|
||||||
size_y=round(_get_size(c0, "size_y"), 4),
|
|
||||||
size_z=round(_get_size(c0, "size_z"), 4),
|
|
||||||
max_volume=getattr(c0, "max_volume", 1500.0) or 1500.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
elif type_name == "plate_adapter":
|
|
||||||
# 提取 adapter 参数
|
|
||||||
ahx = getattr(resource, "adapter_hole_size_x", 127.76)
|
|
||||||
ahy = getattr(resource, "adapter_hole_size_y", 85.48)
|
|
||||||
ahz = getattr(resource, "adapter_hole_size_z", 10.0)
|
|
||||||
adx = getattr(resource, "dx", None)
|
|
||||||
ady = getattr(resource, "dy", None)
|
|
||||||
adz = getattr(resource, "dz", 0.0)
|
|
||||||
item.adapter = AdapterInfo(
|
|
||||||
adapter_hole_size_x=ahx,
|
|
||||||
adapter_hole_size_y=ahy,
|
|
||||||
adapter_hole_size_z=ahz,
|
|
||||||
dx=adx,
|
|
||||||
dy=ady,
|
|
||||||
dz=adz,
|
|
||||||
)
|
|
||||||
|
|
||||||
items.append(item)
|
|
||||||
|
|
||||||
return LabwareDB(items=items)
|
|
||||||
|
|
||||||
|
|
||||||
def _type_to_yaml_subcategory(type_name: str) -> str:
|
|
||||||
mapping = {
|
|
||||||
"plate": "plates",
|
|
||||||
"tip_rack": "tip_racks",
|
|
||||||
"trash": "trash",
|
|
||||||
"tube_rack": "tube_racks",
|
|
||||||
"plate_adapter": "plate_adapters",
|
|
||||||
}
|
|
||||||
return mapping.get(type_name, type_name)
|
|
||||||
|
|
||||||
|
|
||||||
def save_db(db: LabwareDB, path: Optional[Path] = None) -> Path:
|
|
||||||
"""保存 LabwareDB 到 JSON 文件。"""
|
|
||||||
out = path or _DB_PATH
|
|
||||||
with open(out, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(db.model_dump(), f, ensure_ascii=False, indent=2)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def load_db(path: Optional[Path] = None) -> LabwareDB:
|
|
||||||
"""从 JSON 文件加载 LabwareDB。"""
|
|
||||||
src = path or _DB_PATH
|
|
||||||
if not src.exists():
|
|
||||||
return LabwareDB()
|
|
||||||
with open(src, "r", encoding="utf-8") as f:
|
|
||||||
return LabwareDB(**json.load(f))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
db = import_from_code()
|
|
||||||
out = save_db(db)
|
|
||||||
print(f"已导入 {len(db.items)} 个耗材 → {out}")
|
|
||||||
for item in db.items:
|
|
||||||
print(f" [{item.type:14s}] {item.function_name}")
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,126 +0,0 @@
|
|||||||
"""Pydantic 数据模型,描述所有 PRCXI 耗材类型的 JSON 结构。"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import uuid as _uuid
|
|
||||||
from typing import Any, Dict, List, Literal, Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class MaterialInfo(BaseModel):
|
|
||||||
uuid: str = ""
|
|
||||||
Code: str = ""
|
|
||||||
Name: str = ""
|
|
||||||
materialEnum: Optional[int] = None
|
|
||||||
SupplyType: Optional[int] = None
|
|
||||||
|
|
||||||
|
|
||||||
class GridInfo(BaseModel):
|
|
||||||
"""孔位网格排列参数"""
|
|
||||||
num_items_x: int = 12
|
|
||||||
num_items_y: int = 8
|
|
||||||
dx: float = 0.0
|
|
||||||
dy: float = 0.0
|
|
||||||
dz: float = 0.0
|
|
||||||
item_dx: float = 9.0
|
|
||||||
item_dy: float = 9.0
|
|
||||||
|
|
||||||
|
|
||||||
class WellInfo(BaseModel):
|
|
||||||
"""孔参数 (Plate)"""
|
|
||||||
size_x: float = 8.0
|
|
||||||
size_y: float = 8.0
|
|
||||||
size_z: float = 10.0
|
|
||||||
max_volume: Optional[float] = None
|
|
||||||
bottom_type: str = "FLAT" # V / U / FLAT
|
|
||||||
cross_section_type: str = "CIRCLE" # CIRCLE / RECTANGLE
|
|
||||||
material_z_thickness: Optional[float] = None
|
|
||||||
|
|
||||||
|
|
||||||
class VolumeFunctions(BaseModel):
|
|
||||||
"""体积-高度计算函数参数 (矩形 well)"""
|
|
||||||
type: str = "rectangle"
|
|
||||||
well_length: float = 0.0
|
|
||||||
well_width: float = 0.0
|
|
||||||
|
|
||||||
|
|
||||||
class TipInfo(BaseModel):
|
|
||||||
"""枪头参数 (TipRack)"""
|
|
||||||
spot_size_x: float = 7.0
|
|
||||||
spot_size_y: float = 7.0
|
|
||||||
spot_size_z: float = 0.0
|
|
||||||
tip_volume: float = 300.0
|
|
||||||
tip_length: float = 60.0
|
|
||||||
tip_fitting_depth: float = 51.0
|
|
||||||
tip_above_rack_length: Optional[float] = None
|
|
||||||
has_filter: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class TubeInfo(BaseModel):
|
|
||||||
"""管参数 (TubeRack)"""
|
|
||||||
size_x: float = 10.6
|
|
||||||
size_y: float = 10.6
|
|
||||||
size_z: float = 40.0
|
|
||||||
max_volume: float = 1500.0
|
|
||||||
|
|
||||||
|
|
||||||
class AdapterInfo(BaseModel):
|
|
||||||
"""适配器参数 (PlateAdapter)"""
|
|
||||||
adapter_hole_size_x: float = 127.76
|
|
||||||
adapter_hole_size_y: float = 85.48
|
|
||||||
adapter_hole_size_z: float = 10.0
|
|
||||||
dx: Optional[float] = None
|
|
||||||
dy: Optional[float] = None
|
|
||||||
dz: float = 0.0
|
|
||||||
|
|
||||||
|
|
||||||
LabwareType = Literal["plate", "tip_rack", "trash", "tube_rack", "plate_adapter"]
|
|
||||||
|
|
||||||
|
|
||||||
class LabwareItem(BaseModel):
|
|
||||||
"""一个耗材条目的完整 JSON 表示"""
|
|
||||||
|
|
||||||
id: str = Field(default_factory=lambda: _uuid.uuid4().hex[:8])
|
|
||||||
type: LabwareType = "plate"
|
|
||||||
function_name: str = ""
|
|
||||||
docstring: str = ""
|
|
||||||
|
|
||||||
# 物理尺寸
|
|
||||||
size_x: float = 127.0
|
|
||||||
size_y: float = 85.0
|
|
||||||
size_z: float = 20.0
|
|
||||||
model: Optional[str] = None
|
|
||||||
category: Optional[str] = None
|
|
||||||
plate_type: Optional[str] = None # non-skirted / semi-skirted / skirted
|
|
||||||
|
|
||||||
# 材料信息
|
|
||||||
material_info: MaterialInfo = Field(default_factory=MaterialInfo)
|
|
||||||
|
|
||||||
# Registry 字段
|
|
||||||
registry_category: List[str] = Field(default_factory=lambda: ["prcxi", "plates"])
|
|
||||||
registry_description: str = ""
|
|
||||||
|
|
||||||
# Plate 特有
|
|
||||||
grid: Optional[GridInfo] = None
|
|
||||||
well: Optional[WellInfo] = None
|
|
||||||
volume_functions: Optional[VolumeFunctions] = None
|
|
||||||
|
|
||||||
# TipRack 特有
|
|
||||||
tip: Optional[TipInfo] = None
|
|
||||||
|
|
||||||
# TubeRack 特有
|
|
||||||
tube: Optional[TubeInfo] = None
|
|
||||||
|
|
||||||
# PlateAdapter 特有
|
|
||||||
adapter: Optional[AdapterInfo] = None
|
|
||||||
|
|
||||||
# 模板匹配
|
|
||||||
include_in_template_matching: bool = False
|
|
||||||
template_kind: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class LabwareDB(BaseModel):
|
|
||||||
"""整个 labware_db.json 的结构"""
|
|
||||||
version: str = "1.0"
|
|
||||||
items: List[LabwareItem] = Field(default_factory=list)
|
|
||||||
@@ -1,292 +0,0 @@
|
|||||||
/**
|
|
||||||
* form_handler.js — 动态表单逻辑 + 实时预览
|
|
||||||
*/
|
|
||||||
|
|
||||||
// 根据类型显示/隐藏对应的表单段
|
|
||||||
function onTypeChange() {
|
|
||||||
const type = document.getElementById('f-type').value;
|
|
||||||
const sections = {
|
|
||||||
grid: ['plate', 'tip_rack', 'tube_rack'],
|
|
||||||
well: ['plate'],
|
|
||||||
tip: ['tip_rack'],
|
|
||||||
tube: ['tube_rack'],
|
|
||||||
adapter: ['plate_adapter'],
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const [sec, types] of Object.entries(sections)) {
|
|
||||||
const el = document.getElementById('section-' + sec);
|
|
||||||
if (el) el.style.display = types.includes(type) ? 'block' : 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// plate_type 行只对 plate 显示
|
|
||||||
const ptRow = document.getElementById('row-plate_type');
|
|
||||||
if (ptRow) ptRow.style.display = type === 'plate' ? 'block' : 'none';
|
|
||||||
|
|
||||||
updatePreview();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从表单收集数据
|
|
||||||
function collectFormData() {
|
|
||||||
const g = id => {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (!el) return null;
|
|
||||||
if (el.type === 'checkbox') return el.checked;
|
|
||||||
if (el.type === 'number') return el.value === '' ? null : parseFloat(el.value);
|
|
||||||
return el.value || null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const type = g('f-type');
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
type: type,
|
|
||||||
function_name: g('f-function_name') || 'PRCXI_new',
|
|
||||||
model: g('f-model'),
|
|
||||||
docstring: g('f-docstring') || '',
|
|
||||||
plate_type: type === 'plate' ? g('f-plate_type') : null,
|
|
||||||
size_x: g('f-size_x') || 127,
|
|
||||||
size_y: g('f-size_y') || 85,
|
|
||||||
size_z: g('f-size_z') || 20,
|
|
||||||
material_info: {
|
|
||||||
uuid: g('f-mi_uuid') || '',
|
|
||||||
Code: g('f-mi_code') || '',
|
|
||||||
Name: g('f-mi_name') || '',
|
|
||||||
materialEnum: g('f-mi_menum'),
|
|
||||||
SupplyType: g('f-mi_stype'),
|
|
||||||
},
|
|
||||||
registry_category: (g('f-reg_cat') || 'prcxi,plates').split(',').map(s => s.trim()),
|
|
||||||
registry_description: g('f-reg_desc') || '',
|
|
||||||
include_in_template_matching: g('f-in_tpl') || false,
|
|
||||||
template_kind: g('f-tpl_kind') || null,
|
|
||||||
grid: null,
|
|
||||||
well: null,
|
|
||||||
tip: null,
|
|
||||||
tube: null,
|
|
||||||
adapter: null,
|
|
||||||
volume_functions: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Grid
|
|
||||||
if (['plate', 'tip_rack', 'tube_rack'].includes(type)) {
|
|
||||||
data.grid = {
|
|
||||||
num_items_x: g('f-grid_nx') || 12,
|
|
||||||
num_items_y: g('f-grid_ny') || 8,
|
|
||||||
dx: g('f-grid_dx') || 0,
|
|
||||||
dy: g('f-grid_dy') || 0,
|
|
||||||
dz: g('f-grid_dz') || 0,
|
|
||||||
item_dx: g('f-grid_idx') || 9,
|
|
||||||
item_dy: g('f-grid_idy') || 9,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Well
|
|
||||||
if (type === 'plate') {
|
|
||||||
data.well = {
|
|
||||||
size_x: g('f-well_sx') || 8,
|
|
||||||
size_y: g('f-well_sy') || 8,
|
|
||||||
size_z: g('f-well_sz') || 10,
|
|
||||||
max_volume: g('f-well_vol'),
|
|
||||||
material_z_thickness: g('f-well_mzt'),
|
|
||||||
bottom_type: g('f-well_bt') || 'FLAT',
|
|
||||||
cross_section_type: g('f-well_cs') || 'CIRCLE',
|
|
||||||
};
|
|
||||||
if (g('f-has_vf')) {
|
|
||||||
data.volume_functions = {
|
|
||||||
type: 'rectangle',
|
|
||||||
well_length: data.well.size_x,
|
|
||||||
well_width: data.well.size_y,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tip
|
|
||||||
if (type === 'tip_rack') {
|
|
||||||
data.tip = {
|
|
||||||
spot_size_x: g('f-tip_sx') || 7,
|
|
||||||
spot_size_y: g('f-tip_sy') || 7,
|
|
||||||
spot_size_z: g('f-tip_sz') || 0,
|
|
||||||
tip_volume: g('f-tip_vol') || 300,
|
|
||||||
tip_length: g('f-tip_len') || 60,
|
|
||||||
tip_fitting_depth: g('f-tip_dep') || 51,
|
|
||||||
tip_above_rack_length: g('f-tip_above'),
|
|
||||||
has_filter: g('f-tip_filter') || false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tube
|
|
||||||
if (type === 'tube_rack') {
|
|
||||||
data.tube = {
|
|
||||||
size_x: g('f-tube_sx') || 10.6,
|
|
||||||
size_y: g('f-tube_sy') || 10.6,
|
|
||||||
size_z: g('f-tube_sz') || 40,
|
|
||||||
max_volume: g('f-tube_vol') || 1500,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adapter
|
|
||||||
if (type === 'plate_adapter') {
|
|
||||||
data.adapter = {
|
|
||||||
adapter_hole_size_x: g('f-adp_hsx') || 127.76,
|
|
||||||
adapter_hole_size_y: g('f-adp_hsy') || 85.48,
|
|
||||||
adapter_hole_size_z: g('f-adp_hsz') || 10,
|
|
||||||
dx: g('f-adp_dx'),
|
|
||||||
dy: g('f-adp_dy'),
|
|
||||||
dz: g('f-adp_dz') || 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 实时预览 (debounce)
|
|
||||||
let _previewTimer = null;
|
|
||||||
function updatePreview() {
|
|
||||||
if (_previewTimer) clearTimeout(_previewTimer);
|
|
||||||
_previewTimer = setTimeout(() => {
|
|
||||||
const data = collectFormData();
|
|
||||||
const topEl = document.getElementById('svg-topdown');
|
|
||||||
const sideEl = document.getElementById('svg-side');
|
|
||||||
if (topEl) renderTopDown(topEl, data);
|
|
||||||
if (sideEl) renderSideProfile(sideEl, data);
|
|
||||||
}, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 给所有表单元素绑定 input 事件
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const form = document.getElementById('labware-form');
|
|
||||||
if (!form) return;
|
|
||||||
form.addEventListener('input', updatePreview);
|
|
||||||
form.addEventListener('change', updatePreview);
|
|
||||||
|
|
||||||
// tip_above_rack_length 与 dz 互算
|
|
||||||
// 公式: tip_length = tip_above_rack_length + size_z - dz
|
|
||||||
// 规则: 填 tip_above → 自动算 dz;填 dz → 自动算 tip_above
|
|
||||||
// 改 size_z / tip_length → 优先重算 tip_above(若有值),否则算 dz
|
|
||||||
|
|
||||||
function _getVal(id) {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
return (el && el.value !== '') ? parseFloat(el.value) : null;
|
|
||||||
}
|
|
||||||
function _setVal(id, v) {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (el) el.value = Math.round(v * 1000) / 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
function autoCalcTipAbove(changedId) {
|
|
||||||
const typeEl = document.getElementById('f-type');
|
|
||||||
if (!typeEl || typeEl.value !== 'tip_rack') return;
|
|
||||||
|
|
||||||
const tipLen = _getVal('f-tip_len');
|
|
||||||
const sizeZ = _getVal('f-size_z');
|
|
||||||
const dz = _getVal('f-grid_dz');
|
|
||||||
const above = _getVal('f-tip_above');
|
|
||||||
|
|
||||||
// 需要 tip_length 和 size_z 才能计算
|
|
||||||
if (tipLen == null || sizeZ == null) return;
|
|
||||||
|
|
||||||
if (changedId === 'f-tip_above') {
|
|
||||||
// 用户填了 tip_above → 算 dz
|
|
||||||
if (above != null) _setVal('f-grid_dz', above + sizeZ - tipLen);
|
|
||||||
} else if (changedId === 'f-grid_dz') {
|
|
||||||
// 用户填了 dz → 算 tip_above
|
|
||||||
if (dz != null) _setVal('f-tip_above', tipLen - sizeZ + dz);
|
|
||||||
} else {
|
|
||||||
// size_z 或 tip_length 变了 → 优先重算 tip_above(若已有值或 dz 已有值)
|
|
||||||
if (dz != null) {
|
|
||||||
_setVal('f-tip_above', tipLen - sizeZ + dz);
|
|
||||||
} else if (above != null) {
|
|
||||||
_setVal('f-grid_dz', above + sizeZ - tipLen);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 绑定 input 事件
|
|
||||||
for (const id of ['f-tip_len', 'f-size_z', 'f-grid_dz', 'f-tip_above']) {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (el) el.addEventListener('input', () => autoCalcTipAbove(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 编辑已有 tip_rack 条目时自动补算 tip_above_rack_length
|
|
||||||
const typeEl = document.getElementById('f-type');
|
|
||||||
if (typeEl && typeEl.value === 'tip_rack') {
|
|
||||||
autoCalcTipAbove('f-grid_dz');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 自动居中:根据板尺寸和孔阵列参数计算 dx/dy
|
|
||||||
function autoCenter() {
|
|
||||||
const g = id => { const el = document.getElementById(id); return el && el.value !== '' ? parseFloat(el.value) : 0; };
|
|
||||||
|
|
||||||
const sizeX = g('f-size_x') || 127;
|
|
||||||
const sizeY = g('f-size_y') || 85;
|
|
||||||
const nx = g('f-grid_nx') || 1;
|
|
||||||
const ny = g('f-grid_ny') || 1;
|
|
||||||
const itemDx = g('f-grid_idx') || 9;
|
|
||||||
const itemDy = g('f-grid_idy') || 9;
|
|
||||||
|
|
||||||
// 根据当前耗材类型确定子元素尺寸
|
|
||||||
const type = document.getElementById('f-type').value;
|
|
||||||
let childSx = 0, childSy = 0;
|
|
||||||
if (type === 'plate') {
|
|
||||||
childSx = g('f-well_sx') || 8;
|
|
||||||
childSy = g('f-well_sy') || 8;
|
|
||||||
} else if (type === 'tip_rack') {
|
|
||||||
childSx = g('f-tip_sx') || 7;
|
|
||||||
childSy = g('f-tip_sy') || 7;
|
|
||||||
} else if (type === 'tube_rack') {
|
|
||||||
childSx = g('f-tube_sx') || 10.6;
|
|
||||||
childSy = g('f-tube_sy') || 10.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
// dx = (板宽 - 孔阵列总占宽) / 2
|
|
||||||
const dx = (sizeX - (nx - 1) * itemDx - childSx) / 2;
|
|
||||||
const dy = (sizeY - (ny - 1) * itemDy - childSy) / 2;
|
|
||||||
|
|
||||||
const elDx = document.getElementById('f-grid_dx');
|
|
||||||
const elDy = document.getElementById('f-grid_dy');
|
|
||||||
if (elDx) elDx.value = Math.round(dx * 100) / 100;
|
|
||||||
if (elDy) elDy.value = Math.round(dy * 100) / 100;
|
|
||||||
|
|
||||||
updatePreview();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存
|
|
||||||
function showMsg(text, ok) {
|
|
||||||
const el = document.getElementById('status-msg');
|
|
||||||
if (!el) return;
|
|
||||||
el.textContent = text;
|
|
||||||
el.className = 'status-msg ' + (ok ? 'msg-ok' : 'msg-err');
|
|
||||||
el.style.display = 'block';
|
|
||||||
setTimeout(() => el.style.display = 'none', 4000);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveForm() {
|
|
||||||
const data = collectFormData();
|
|
||||||
|
|
||||||
let url, method;
|
|
||||||
if (typeof IS_NEW !== 'undefined' && IS_NEW) {
|
|
||||||
url = '/api/labware';
|
|
||||||
method = 'POST';
|
|
||||||
} else {
|
|
||||||
url = '/api/labware/' + (typeof ITEM_ID !== 'undefined' ? ITEM_ID : '');
|
|
||||||
method = 'PUT';
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const r = await fetch(url, {
|
|
||||||
method: method,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
const d = await r.json();
|
|
||||||
if (d.status === 'ok') {
|
|
||||||
showMsg('保存成功', true);
|
|
||||||
if (IS_NEW) {
|
|
||||||
setTimeout(() => location.href = '/labware/' + data.function_name, 500);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
showMsg('保存失败: ' + JSON.stringify(d), false);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
showMsg('请求错误: ' + e.message, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,450 +0,0 @@
|
|||||||
/**
|
|
||||||
* labware_viz.js — PRCXI 耗材 SVG 2D 可视化渲染引擎
|
|
||||||
*
|
|
||||||
* renderTopDown(container, itemData) — 俯视图
|
|
||||||
* renderSideProfile(container, itemData) — 侧面截面图
|
|
||||||
*/
|
|
||||||
|
|
||||||
const TYPE_COLORS = {
|
|
||||||
plate: '#3b82f6',
|
|
||||||
tip_rack: '#10b981',
|
|
||||||
tube_rack: '#f59e0b',
|
|
||||||
trash: '#ef4444',
|
|
||||||
plate_adapter: '#8b5cf6',
|
|
||||||
};
|
|
||||||
|
|
||||||
function _svgNS() { return 'http://www.w3.org/2000/svg'; }
|
|
||||||
|
|
||||||
function _makeSVG(w, h) {
|
|
||||||
const svg = document.createElementNS(_svgNS(), 'svg');
|
|
||||||
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
|
|
||||||
svg.setAttribute('width', '100%');
|
|
||||||
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
|
||||||
svg.style.background = '#fff';
|
|
||||||
return svg;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _rect(svg, x, y, w, h, fill, stroke, rx) {
|
|
||||||
const r = document.createElementNS(_svgNS(), 'rect');
|
|
||||||
r.setAttribute('x', x); r.setAttribute('y', y);
|
|
||||||
r.setAttribute('width', w); r.setAttribute('height', h);
|
|
||||||
r.setAttribute('fill', fill || 'none');
|
|
||||||
r.setAttribute('stroke', stroke || '#333');
|
|
||||||
r.setAttribute('stroke-width', '0.5');
|
|
||||||
if (rx) r.setAttribute('rx', rx);
|
|
||||||
svg.appendChild(r);
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _circle(svg, cx, cy, r, fill, stroke) {
|
|
||||||
const c = document.createElementNS(_svgNS(), 'circle');
|
|
||||||
c.setAttribute('cx', cx); c.setAttribute('cy', cy);
|
|
||||||
c.setAttribute('r', r);
|
|
||||||
c.setAttribute('fill', fill || 'none');
|
|
||||||
c.setAttribute('stroke', stroke || '#333');
|
|
||||||
c.setAttribute('stroke-width', '0.4');
|
|
||||||
svg.appendChild(c);
|
|
||||||
return c;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _text(svg, x, y, txt, size, anchor, fill) {
|
|
||||||
const t = document.createElementNS(_svgNS(), 'text');
|
|
||||||
t.setAttribute('x', x); t.setAttribute('y', y);
|
|
||||||
t.setAttribute('font-size', size || '3');
|
|
||||||
t.setAttribute('text-anchor', anchor || 'middle');
|
|
||||||
t.setAttribute('fill', fill || '#666');
|
|
||||||
t.setAttribute('font-family', 'sans-serif');
|
|
||||||
t.textContent = txt;
|
|
||||||
svg.appendChild(t);
|
|
||||||
return t;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _line(svg, x1, y1, x2, y2, stroke, dash) {
|
|
||||||
const l = document.createElementNS(_svgNS(), 'line');
|
|
||||||
l.setAttribute('x1', x1); l.setAttribute('y1', y1);
|
|
||||||
l.setAttribute('x2', x2); l.setAttribute('y2', y2);
|
|
||||||
l.setAttribute('stroke', stroke || '#999');
|
|
||||||
l.setAttribute('stroke-width', '0.3');
|
|
||||||
if (dash) l.setAttribute('stroke-dasharray', dash);
|
|
||||||
svg.appendChild(l);
|
|
||||||
return l;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _title(el, txt) {
|
|
||||||
const t = document.createElementNS(_svgNS(), 'title');
|
|
||||||
t.textContent = txt;
|
|
||||||
el.appendChild(t);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 俯视图 ====================
|
|
||||||
function renderTopDown(container, data) {
|
|
||||||
container.innerHTML = '';
|
|
||||||
if (!data) return;
|
|
||||||
|
|
||||||
const pad = 18;
|
|
||||||
const sx = data.size_x || 127;
|
|
||||||
const sy = data.size_y || 85;
|
|
||||||
const w = sx + pad * 2;
|
|
||||||
const h = sy + pad * 2;
|
|
||||||
const svg = _makeSVG(w, h);
|
|
||||||
|
|
||||||
const color = TYPE_COLORS[data.type] || '#3b82f6';
|
|
||||||
const lightColor = color + '22';
|
|
||||||
|
|
||||||
// 板子外轮廓
|
|
||||||
_rect(svg, pad, pad, sx, sy, lightColor, color, 3);
|
|
||||||
|
|
||||||
// 尺寸标注
|
|
||||||
_text(svg, pad + sx / 2, pad - 4, `${sx} mm`, '3.5', 'middle', '#333');
|
|
||||||
// Y 尺寸 (竖直)
|
|
||||||
const yt = document.createElementNS(_svgNS(), 'text');
|
|
||||||
yt.setAttribute('x', pad - 5);
|
|
||||||
yt.setAttribute('y', pad + sy / 2);
|
|
||||||
yt.setAttribute('font-size', '3.5');
|
|
||||||
yt.setAttribute('text-anchor', 'middle');
|
|
||||||
yt.setAttribute('fill', '#333');
|
|
||||||
yt.setAttribute('font-family', 'sans-serif');
|
|
||||||
yt.setAttribute('transform', `rotate(-90, ${pad - 5}, ${pad + sy / 2})`);
|
|
||||||
yt.textContent = `${sy} mm`;
|
|
||||||
svg.appendChild(yt);
|
|
||||||
|
|
||||||
const grid = data.grid;
|
|
||||||
const well = data.well;
|
|
||||||
const tip = data.tip;
|
|
||||||
const tube = data.tube;
|
|
||||||
|
|
||||||
if (grid && (well || tip || tube)) {
|
|
||||||
const nx = grid.num_items_x || 1;
|
|
||||||
const ny = grid.num_items_y || 1;
|
|
||||||
const dx = grid.dx || 0;
|
|
||||||
const dy = grid.dy || 0;
|
|
||||||
const idx = grid.item_dx || 9;
|
|
||||||
const idy = grid.item_dy || 9;
|
|
||||||
|
|
||||||
const child = well || tip || tube;
|
|
||||||
const csx = child.size_x || child.spot_size_x || 8;
|
|
||||||
const csy = child.size_y || child.spot_size_y || 8;
|
|
||||||
|
|
||||||
const isCircle = well ? (well.cross_section_type === 'CIRCLE') : (!!tip);
|
|
||||||
|
|
||||||
// 行列标签
|
|
||||||
for (let col = 0; col < nx; col++) {
|
|
||||||
const cx = pad + dx + csx / 2 + col * idx;
|
|
||||||
_text(svg, cx, pad + sy + 5, String(col + 1), '2.5', 'middle', '#999');
|
|
||||||
}
|
|
||||||
const rowLabels = 'ABCDEFGHIJKLMNOP';
|
|
||||||
for (let row = 0; row < ny; row++) {
|
|
||||||
const cy = pad + dy + csy / 2 + row * idy;
|
|
||||||
_text(svg, pad - 4, cy + 1, rowLabels[row] || String(row), '2.5', 'middle', '#999');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 绘制孔位
|
|
||||||
for (let col = 0; col < nx; col++) {
|
|
||||||
for (let row = 0; row < ny; row++) {
|
|
||||||
const cx = pad + dx + csx / 2 + col * idx;
|
|
||||||
const cy = pad + dy + csy / 2 + row * idy;
|
|
||||||
|
|
||||||
let el;
|
|
||||||
if (isCircle) {
|
|
||||||
const r = Math.min(csx, csy) / 2;
|
|
||||||
el = _circle(svg, cx, cy, r, '#fff', color);
|
|
||||||
} else {
|
|
||||||
el = _rect(svg, cx - csx / 2, cy - csy / 2, csx, csy, '#fff', color);
|
|
||||||
}
|
|
||||||
|
|
||||||
const label = (rowLabels[row] || '') + String(col + 1);
|
|
||||||
_title(el, `${label}: ${csx}x${csy} mm`);
|
|
||||||
|
|
||||||
// hover 效果
|
|
||||||
el.style.cursor = 'pointer';
|
|
||||||
el.addEventListener('mouseenter', () => el.setAttribute('fill', color + '44'));
|
|
||||||
el.addEventListener('mouseleave', () => el.setAttribute('fill', '#fff'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// dx / dy 标注(板边到首个子元素左上角)
|
|
||||||
const dimColor = '#e67e22';
|
|
||||||
const firstLeft = pad + dx; // 首列子元素左边 X
|
|
||||||
const firstTop = pad + dy; // 首行子元素上边 Y
|
|
||||||
if (dx > 0.1) {
|
|
||||||
// dx: 板左边 → 首列子元素左边,画在第一行子元素中心高度
|
|
||||||
const annY = firstTop + csy / 2;
|
|
||||||
_line(svg, pad, annY, firstLeft, annY, dimColor, '1,1');
|
|
||||||
_line(svg, pad, annY - 2, pad, annY + 2, dimColor);
|
|
||||||
_line(svg, firstLeft, annY - 2, firstLeft, annY + 2, dimColor);
|
|
||||||
_text(svg, pad + dx / 2, annY - 2, `dx=${dx}`, '2.5', 'middle', dimColor);
|
|
||||||
}
|
|
||||||
if (dy > 0.1) {
|
|
||||||
// dy: 板上边 → 首行子元素上边,画在第一列子元素中心宽度
|
|
||||||
const annX = firstLeft + csx / 2;
|
|
||||||
_line(svg, annX, pad, annX, firstTop, dimColor, '1,1');
|
|
||||||
_line(svg, annX - 2, pad, annX + 2, pad, dimColor);
|
|
||||||
_line(svg, annX - 2, firstTop, annX + 2, firstTop, dimColor);
|
|
||||||
_text(svg, annX + 4, pad + dy / 2 + 1, `dy=${dy}`, '2.5', 'start', dimColor);
|
|
||||||
}
|
|
||||||
} else if (data.type === 'plate_adapter' && data.adapter) {
|
|
||||||
// 绘制适配器凹槽
|
|
||||||
const adp = data.adapter;
|
|
||||||
const ahx = adp.adapter_hole_size_x || 127;
|
|
||||||
const ahy = adp.adapter_hole_size_y || 85;
|
|
||||||
const adx = adp.dx != null ? adp.dx : (sx - ahx) / 2;
|
|
||||||
const ady = adp.dy != null ? adp.dy : (sy - ahy) / 2;
|
|
||||||
_rect(svg, pad + adx, pad + ady, ahx, ahy, '#f0f0ff', '#8b5cf6', 2);
|
|
||||||
_text(svg, pad + adx + ahx / 2, pad + ady + ahy / 2, `${ahx}x${ahy}`, '4', 'middle', '#8b5cf6');
|
|
||||||
} else if (data.type === 'trash') {
|
|
||||||
// 简单标记
|
|
||||||
_text(svg, pad + sx / 2, pad + sy / 2, 'TRASH', '8', 'middle', '#ef4444');
|
|
||||||
}
|
|
||||||
|
|
||||||
container.appendChild(svg);
|
|
||||||
_enableZoomPan(svg, `0 0 ${w} ${h}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 侧面截面图 ====================
|
|
||||||
function renderSideProfile(container, data) {
|
|
||||||
container.innerHTML = '';
|
|
||||||
if (!data) return;
|
|
||||||
|
|
||||||
const pad = 20;
|
|
||||||
const sx = data.size_x || 127;
|
|
||||||
const sz = data.size_z || 20;
|
|
||||||
|
|
||||||
// 按比例缩放,侧面以 X-Z 面
|
|
||||||
const scaleH = Math.max(1, sz / 60); // 让较矮的板子不会太小
|
|
||||||
|
|
||||||
// 计算枪头露出高度(仅 tip_rack)
|
|
||||||
const tip = data.tip;
|
|
||||||
const grid = data.grid;
|
|
||||||
let tipAbove = 0;
|
|
||||||
if (data.type === 'tip_rack' && tip) {
|
|
||||||
if (tip.tip_above_rack_length != null && tip.tip_above_rack_length > 0) {
|
|
||||||
tipAbove = tip.tip_above_rack_length;
|
|
||||||
} else if (tip.tip_length && grid) {
|
|
||||||
const dz = grid.dz || 0;
|
|
||||||
const calc = tip.tip_length - (sz - dz);
|
|
||||||
if (calc > 0) tipAbove = calc;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const drawW = sx;
|
|
||||||
const drawH = sz;
|
|
||||||
const w = drawW + pad * 2 + 30; // 额外空间给标注
|
|
||||||
const h = drawH + tipAbove + pad * 2 + 10;
|
|
||||||
const svg = _makeSVG(w, h);
|
|
||||||
|
|
||||||
const color = TYPE_COLORS[data.type] || '#3b82f6';
|
|
||||||
const baseY = pad + tipAbove + drawH; // 底部 Y
|
|
||||||
const rackTopY = pad + tipAbove; // rack 顶部 Y
|
|
||||||
|
|
||||||
// 板壳矩形
|
|
||||||
_rect(svg, pad, rackTopY, drawW, drawH, color + '15', color);
|
|
||||||
|
|
||||||
// 尺寸标注
|
|
||||||
// X 方向
|
|
||||||
_line(svg, pad, baseY + 5, pad + drawW, baseY + 5, '#333');
|
|
||||||
_text(svg, pad + drawW / 2, baseY + 12, `${sx} mm`, '3.5', 'middle', '#333');
|
|
||||||
|
|
||||||
// Z 方向
|
|
||||||
_line(svg, pad + drawW + 5, rackTopY, pad + drawW + 5, baseY, '#333');
|
|
||||||
const zt = document.createElementNS(_svgNS(), 'text');
|
|
||||||
zt.setAttribute('x', pad + drawW + 12);
|
|
||||||
zt.setAttribute('y', rackTopY + drawH / 2);
|
|
||||||
zt.setAttribute('font-size', '3.5');
|
|
||||||
zt.setAttribute('text-anchor', 'middle');
|
|
||||||
zt.setAttribute('fill', '#333');
|
|
||||||
zt.setAttribute('font-family', 'sans-serif');
|
|
||||||
zt.setAttribute('transform', `rotate(-90, ${pad + drawW + 12}, ${rackTopY + drawH / 2})`);
|
|
||||||
zt.textContent = `${sz} mm`;
|
|
||||||
svg.appendChild(zt);
|
|
||||||
|
|
||||||
const well = data.well;
|
|
||||||
const tube = data.tube;
|
|
||||||
|
|
||||||
if (grid && (well || tip || tube)) {
|
|
||||||
const dx = grid.dx || 0;
|
|
||||||
const dz = grid.dz || 0;
|
|
||||||
const idx = grid.item_dx || 9;
|
|
||||||
const nx = Math.min(grid.num_items_x || 1, 24); // 最多画24列
|
|
||||||
const dimColor = '#e67e22';
|
|
||||||
|
|
||||||
const child = well || tube;
|
|
||||||
const childTip = tip;
|
|
||||||
|
|
||||||
if (child) {
|
|
||||||
const csx = child.size_x || 8;
|
|
||||||
const csz = child.size_z || 10;
|
|
||||||
const bt = well ? (well.bottom_type || 'FLAT') : 'FLAT';
|
|
||||||
|
|
||||||
// 画几个代表性的孔截面
|
|
||||||
const nDraw = Math.min(nx, 12);
|
|
||||||
for (let i = 0; i < nDraw; i++) {
|
|
||||||
const cx = pad + dx + csx / 2 + i * idx;
|
|
||||||
const topZ = baseY - dz - csz;
|
|
||||||
const botZ = baseY - dz;
|
|
||||||
|
|
||||||
// 孔壁
|
|
||||||
_rect(svg, cx - csx / 2, topZ, csx, csz, '#e0e8ff', color, 0.5);
|
|
||||||
|
|
||||||
// 底部形状
|
|
||||||
if (bt === 'V') {
|
|
||||||
// V 底 三角
|
|
||||||
const triH = Math.min(csx / 2, csz * 0.3);
|
|
||||||
const p = document.createElementNS(_svgNS(), 'polygon');
|
|
||||||
p.setAttribute('points',
|
|
||||||
`${cx - csx / 2},${botZ - triH} ${cx},${botZ} ${cx + csx / 2},${botZ - triH}`);
|
|
||||||
p.setAttribute('fill', color + '33');
|
|
||||||
p.setAttribute('stroke', color);
|
|
||||||
p.setAttribute('stroke-width', '0.3');
|
|
||||||
svg.appendChild(p);
|
|
||||||
} else if (bt === 'U') {
|
|
||||||
// U 底 圆弧
|
|
||||||
const arcR = csx / 2;
|
|
||||||
const p = document.createElementNS(_svgNS(), 'path');
|
|
||||||
p.setAttribute('d', `M ${cx - csx / 2} ${botZ - arcR} A ${arcR} ${arcR} 0 0 0 ${cx + csx / 2} ${botZ - arcR}`);
|
|
||||||
p.setAttribute('fill', color + '33');
|
|
||||||
p.setAttribute('stroke', color);
|
|
||||||
p.setAttribute('stroke-width', '0.3');
|
|
||||||
svg.appendChild(p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// dz 标注
|
|
||||||
if (dz > 0) {
|
|
||||||
const lx = pad + dx + 0.5 * idx * nDraw + csx / 2 + 5;
|
|
||||||
_line(svg, lx, baseY, lx, baseY - dz, dimColor, '1,1');
|
|
||||||
_line(svg, lx - 2, baseY, lx + 2, baseY, dimColor);
|
|
||||||
_line(svg, lx - 2, baseY - dz, lx + 2, baseY - dz, dimColor);
|
|
||||||
_text(svg, lx + 4, baseY - dz / 2 + 1, `dz=${dz}`, '2.5', 'start', dimColor);
|
|
||||||
}
|
|
||||||
// dx 标注
|
|
||||||
if (dx > 0.1) {
|
|
||||||
const annY = rackTopY + 4;
|
|
||||||
_line(svg, pad, annY, pad + dx, annY, dimColor, '1,1');
|
|
||||||
_line(svg, pad, annY - 2, pad, annY + 2, dimColor);
|
|
||||||
_line(svg, pad + dx, annY - 2, pad + dx, annY + 2, dimColor);
|
|
||||||
_text(svg, pad + dx / 2, annY - 2, `dx=${dx}`, '2.5', 'middle', dimColor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (childTip) {
|
|
||||||
// 枪头截面
|
|
||||||
const tipLen = childTip.tip_length || 50;
|
|
||||||
const nDraw = Math.min(nx, 12);
|
|
||||||
for (let i = 0; i < nDraw; i++) {
|
|
||||||
const cx = pad + dx + 3.5 + i * idx;
|
|
||||||
// 枪头顶部 = rack顶部 - 露出长度
|
|
||||||
const tipTopZ = rackTopY - tipAbove;
|
|
||||||
const drawLen = Math.min(tipLen, sz - dz + tipAbove);
|
|
||||||
|
|
||||||
// 枪头轮廓 (梯形)
|
|
||||||
const topW = 4;
|
|
||||||
const botW = 1.5;
|
|
||||||
const p = document.createElementNS(_svgNS(), 'polygon');
|
|
||||||
p.setAttribute('points',
|
|
||||||
`${cx - topW / 2},${tipTopZ} ${cx + topW / 2},${tipTopZ} ${cx + botW / 2},${tipTopZ + drawLen} ${cx - botW / 2},${tipTopZ + drawLen}`);
|
|
||||||
p.setAttribute('fill', '#10b98133');
|
|
||||||
p.setAttribute('stroke', '#10b981');
|
|
||||||
p.setAttribute('stroke-width', '0.3');
|
|
||||||
svg.appendChild(p);
|
|
||||||
}
|
|
||||||
|
|
||||||
// dz 标注
|
|
||||||
if (dz > 0) {
|
|
||||||
const lx = pad + dx + nDraw * idx + 5;
|
|
||||||
_line(svg, lx, baseY, lx, baseY - dz, dimColor, '1,1');
|
|
||||||
_line(svg, lx - 2, baseY, lx + 2, baseY, dimColor);
|
|
||||||
_line(svg, lx - 2, baseY - dz, lx + 2, baseY - dz, dimColor);
|
|
||||||
_text(svg, lx + 4, baseY - dz / 2 + 1, `dz=${dz}`, '2.5', 'start', dimColor);
|
|
||||||
}
|
|
||||||
// dx 标注
|
|
||||||
if (dx > 0.1) {
|
|
||||||
const annY = rackTopY + 4;
|
|
||||||
_line(svg, pad, annY, pad + dx, annY, dimColor, '1,1');
|
|
||||||
_line(svg, pad, annY - 2, pad, annY + 2, dimColor);
|
|
||||||
_line(svg, pad + dx, annY - 2, pad + dx, annY + 2, dimColor);
|
|
||||||
_text(svg, pad + dx / 2, annY - 2, `dx=${dx}`, '2.5', 'middle', dimColor);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 露出长度标注线
|
|
||||||
if (tipAbove > 0) {
|
|
||||||
const annotX = pad + dx + nDraw * idx + 8;
|
|
||||||
// rack 顶部水平参考线
|
|
||||||
_line(svg, annotX - 3, rackTopY, annotX + 3, rackTopY, '#10b981');
|
|
||||||
// 枪头顶部水平参考线
|
|
||||||
_line(svg, annotX - 3, rackTopY - tipAbove, annotX + 3, rackTopY - tipAbove, '#10b981');
|
|
||||||
// 竖直标注线
|
|
||||||
_line(svg, annotX, rackTopY - tipAbove, annotX, rackTopY, '#10b981', '1,1');
|
|
||||||
_text(svg, annotX + 2, rackTopY - tipAbove / 2 + 1, `露出=${Math.round(tipAbove * 100) / 100}mm`, '2.5', 'start', '#10b981');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (data.type === 'plate_adapter' && data.adapter) {
|
|
||||||
const adp = data.adapter;
|
|
||||||
const ahz = adp.adapter_hole_size_z || 10;
|
|
||||||
const adz = adp.dz || 0;
|
|
||||||
const adx_val = adp.dx != null ? adp.dx : (sx - (adp.adapter_hole_size_x || 127)) / 2;
|
|
||||||
const ahx = adp.adapter_hole_size_x || 127;
|
|
||||||
|
|
||||||
// 凹槽截面
|
|
||||||
_rect(svg, pad + adx_val, rackTopY + adz, ahx, ahz, '#ede9fe', '#8b5cf6');
|
|
||||||
_text(svg, pad + adx_val + ahx / 2, rackTopY + adz + ahz / 2 + 1, `hole: ${ahz}mm deep`, '3', 'middle', '#8b5cf6');
|
|
||||||
} else if (data.type === 'trash') {
|
|
||||||
_text(svg, pad + drawW / 2, rackTopY + drawH / 2, 'TRASH', '8', 'middle', '#ef4444');
|
|
||||||
}
|
|
||||||
|
|
||||||
container.appendChild(svg);
|
|
||||||
_enableZoomPan(svg, `0 0 ${w} ${h}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 缩放 & 平移 ====================
|
|
||||||
function _enableZoomPan(svgEl, origViewBox) {
|
|
||||||
const parts = origViewBox.split(' ').map(Number);
|
|
||||||
let vx = parts[0], vy = parts[1], vw = parts[2], vh = parts[3];
|
|
||||||
const origVx = vx, origVy = vy, origW = vw, origH = vh;
|
|
||||||
const MIN_SCALE = 0.5, MAX_SCALE = 5;
|
|
||||||
|
|
||||||
function applyViewBox() {
|
|
||||||
svgEl.setAttribute('viewBox', `${vx} ${vy} ${vw} ${vh}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetView() {
|
|
||||||
vx = origVx; vy = origVy; vw = origW; vh = origH;
|
|
||||||
applyViewBox();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将 resetView 挂到 svg 元素上,方便外部调用
|
|
||||||
svgEl._resetView = resetView;
|
|
||||||
|
|
||||||
svgEl.addEventListener('wheel', function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (e.ctrlKey) {
|
|
||||||
// pinch / ctrl+scroll → 缩放
|
|
||||||
const factor = e.deltaY > 0 ? 1.08 : 1 / 1.08;
|
|
||||||
const newW = vw * factor;
|
|
||||||
const newH = vh * factor;
|
|
||||||
// 限制缩放范围
|
|
||||||
if (newW < origW / MAX_SCALE || newW > origW * (1 / MIN_SCALE)) return;
|
|
||||||
// 以鼠标位置为缩放中心
|
|
||||||
const rect = svgEl.getBoundingClientRect();
|
|
||||||
const mx = (e.clientX - rect.left) / rect.width;
|
|
||||||
const my = (e.clientY - rect.top) / rect.height;
|
|
||||||
vx += (vw - newW) * mx;
|
|
||||||
vy += (vh - newH) * my;
|
|
||||||
vw = newW;
|
|
||||||
vh = newH;
|
|
||||||
} else {
|
|
||||||
// 普通滚轮 → 平移
|
|
||||||
const panSpeed = vw * 0.002;
|
|
||||||
vx += e.deltaX * panSpeed;
|
|
||||||
vy += e.deltaY * panSpeed;
|
|
||||||
}
|
|
||||||
applyViewBox();
|
|
||||||
}, { passive: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 回中按钮:重置指定容器内 SVG 的 viewBox
|
|
||||||
function resetSvgView(containerId) {
|
|
||||||
const container = document.getElementById(containerId);
|
|
||||||
if (!container) return;
|
|
||||||
const svg = container.querySelector('svg');
|
|
||||||
if (svg && svg._resetView) svg._resetView();
|
|
||||||
}
|
|
||||||
@@ -1,295 +0,0 @@
|
|||||||
/* PRCXI 耗材管理 - 全局样式 */
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--c-primary: #3b82f6;
|
|
||||||
--c-primary-dark: #2563eb;
|
|
||||||
--c-danger: #ef4444;
|
|
||||||
--c-warning: #f59e0b;
|
|
||||||
--c-success: #10b981;
|
|
||||||
--c-gray-50: #f9fafb;
|
|
||||||
--c-gray-100: #f3f4f6;
|
|
||||||
--c-gray-200: #e5e7eb;
|
|
||||||
--c-gray-300: #d1d5db;
|
|
||||||
--c-gray-500: #6b7280;
|
|
||||||
--c-gray-700: #374151;
|
|
||||||
--c-gray-900: #111827;
|
|
||||||
--radius: 8px;
|
|
||||||
--shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
background: var(--c-gray-50);
|
|
||||||
color: var(--c-gray-900);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 顶部导航 */
|
|
||||||
.topbar {
|
|
||||||
background: #fff;
|
|
||||||
border-bottom: 1px solid var(--c-gray-200);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0 24px;
|
|
||||||
height: 56px;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
.topbar .logo {
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
color: var(--c-gray-900);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 容器 */
|
|
||||||
.container {
|
|
||||||
max-width: 1400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 页头 */
|
|
||||||
.page-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.page-header h1 { font-size: 1.5rem; }
|
|
||||||
.header-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
||||||
|
|
||||||
/* 按钮 */
|
|
||||||
.btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: all 0.15s;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.btn-sm { padding: 4px 10px; font-size: 0.8rem; }
|
|
||||||
.btn-primary { background: var(--c-primary); color: #fff; }
|
|
||||||
.btn-primary:hover { background: var(--c-primary-dark); }
|
|
||||||
.btn-outline { background: #fff; color: var(--c-gray-700); border-color: var(--c-gray-300); }
|
|
||||||
.btn-outline:hover { background: var(--c-gray-100); }
|
|
||||||
.btn-danger { background: var(--c-danger); color: #fff; }
|
|
||||||
.btn-danger:hover { background: #dc2626; }
|
|
||||||
.btn-warning { background: var(--c-warning); color: #fff; }
|
|
||||||
.btn-warning:hover { background: #d97706; }
|
|
||||||
|
|
||||||
/* 徽章 */
|
|
||||||
.badge {
|
|
||||||
background: var(--c-gray-200);
|
|
||||||
color: var(--c-gray-700);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-left: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 状态消息 */
|
|
||||||
.status-msg {
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
margin-bottom: 16px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
.msg-ok { background: #d1fae5; color: #065f46; }
|
|
||||||
.msg-err { background: #fee2e2; color: #991b1b; }
|
|
||||||
|
|
||||||
/* 类型分段 */
|
|
||||||
.type-section { margin-bottom: 32px; }
|
|
||||||
.type-section h2 {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.type-dot {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 卡片网格 */
|
|
||||||
.card-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 耗材卡片 */
|
|
||||||
.labware-card {
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid var(--c-gray-200);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: box-shadow 0.2s, border-color 0.2s;
|
|
||||||
}
|
|
||||||
.labware-card:hover {
|
|
||||||
border-color: var(--c-primary);
|
|
||||||
box-shadow: 0 4px 12px rgba(59,130,246,0.15);
|
|
||||||
}
|
|
||||||
.card-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
.card-title {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--c-gray-900);
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
.card-body { font-size: 0.85rem; color: var(--c-gray-500); }
|
|
||||||
.card-info { margin-bottom: 2px; }
|
|
||||||
.card-info .label { color: var(--c-gray-700); font-weight: 500; }
|
|
||||||
.card-footer {
|
|
||||||
margin-top: 12px;
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
border-top: 1px solid var(--c-gray-100);
|
|
||||||
padding-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 标签 */
|
|
||||||
.tag {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.tag-tpl { background: #dbeafe; color: #1e40af; }
|
|
||||||
.tag-plate { background: #dbeafe; color: #1e40af; }
|
|
||||||
.tag-tip_rack { background: #d1fae5; color: #065f46; }
|
|
||||||
.tag-trash { background: #fee2e2; color: #991b1b; }
|
|
||||||
.tag-tube_rack { background: #fef3c7; color: #92400e; }
|
|
||||||
.tag-plate_adapter { background: #ede9fe; color: #5b21b6; }
|
|
||||||
|
|
||||||
/* 详情页布局 */
|
|
||||||
.detail-layout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 24px;
|
|
||||||
}
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.detail-layout { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
.detail-info, .detail-viz { display: flex; flex-direction: column; gap: 16px; }
|
|
||||||
|
|
||||||
.info-card, .viz-card {
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid var(--c-gray-200);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
.info-card h3, .viz-card h3 {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
color: var(--c-gray-700);
|
|
||||||
border-bottom: 1px solid var(--c-gray-100);
|
|
||||||
padding-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-table { width: 100%; font-size: 0.85rem; }
|
|
||||||
.info-table td { padding: 4px 8px; border-bottom: 1px solid var(--c-gray-100); }
|
|
||||||
.info-table .label { color: var(--c-gray-500); font-weight: 500; width: 140px; }
|
|
||||||
.info-table code { background: var(--c-gray-100); padding: 1px 4px; border-radius: 3px; font-size: 0.8rem; }
|
|
||||||
.info-table code.small { font-size: 0.7rem; }
|
|
||||||
|
|
||||||
/* SVG 容器 */
|
|
||||||
#svg-topdown, #svg-side {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 200px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
#svg-topdown svg, #svg-side svg {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
touch-action: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 编辑页布局 */
|
|
||||||
.edit-layout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 24px;
|
|
||||||
}
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.edit-layout { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
.edit-form { display: flex; flex-direction: column; gap: 16px; }
|
|
||||||
.edit-preview { display: flex; flex-direction: column; gap: 16px; position: sticky; top: 72px; align-self: start; }
|
|
||||||
|
|
||||||
/* 表单 */
|
|
||||||
.form-section {
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid var(--c-gray-200);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
.form-section h3 {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
color: var(--c-gray-700);
|
|
||||||
}
|
|
||||||
.form-row { margin-bottom: 10px; }
|
|
||||||
.form-row label { display: block; font-size: 0.8rem; color: var(--c-gray-500); margin-bottom: 4px; font-weight: 500; }
|
|
||||||
.form-row input, .form-row select, .form-row textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border: 1px solid var(--c-gray-300);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
.form-row input:focus, .form-row select:focus, .form-row textarea:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--c-primary);
|
|
||||||
box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
|
|
||||||
}
|
|
||||||
.form-row-2, .form-row-3 { display: grid; gap: 12px; margin-bottom: 10px; }
|
|
||||||
.form-row-2 { grid-template-columns: 1fr 1fr; }
|
|
||||||
.form-row-3 { grid-template-columns: 1fr 1fr 1fr; }
|
|
||||||
.form-row-2 label, .form-row-3 label { display: block; font-size: 0.8rem; color: var(--c-gray-500); margin-bottom: 4px; font-weight: 500; }
|
|
||||||
.form-row-2 input, .form-row-2 select,
|
|
||||||
.form-row-3 input, .form-row-3 select {
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border: 1px solid var(--c-gray-300);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
.form-row-2 input:focus, .form-row-3 input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--c-primary);
|
|
||||||
box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 双语标签中文部分 */
|
|
||||||
.label-cn { color: var(--c-gray-400, #9ca3af); font-weight: 400; margin-left: 4px; }
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>{% block title %}PRCXI 耗材管理{% endblock %}</title>
|
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
|
||||||
{% block head_extra %}{% endblock %}
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<nav class="topbar">
|
|
||||||
<a href="/" class="logo">PRCXI 耗材管理</a>
|
|
||||||
<div class="nav-actions">
|
|
||||||
<a href="/labware/new" class="btn btn-primary btn-sm">+ 新建耗材</a>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main class="container">
|
|
||||||
{% block content %}{% endblock %}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{% block scripts %}{% endblock %}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}{{ item.function_name }} - PRCXI{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="page-header">
|
|
||||||
<h1>{{ item.function_name }}</h1>
|
|
||||||
<div class="header-actions">
|
|
||||||
<a href="/labware/{{ item.function_name }}/edit" class="btn btn-primary">编辑</a>
|
|
||||||
<a href="/" class="btn btn-outline">返回列表</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="detail-layout">
|
|
||||||
<!-- 左侧: 信息 -->
|
|
||||||
<div class="detail-info">
|
|
||||||
<div class="info-card">
|
|
||||||
<h3>基本信息</h3>
|
|
||||||
<table class="info-table">
|
|
||||||
<tr><td class="label">类型</td><td><span class="tag tag-{{ item.type }}">{{ item.type }}</span></td></tr>
|
|
||||||
<tr><td class="label">函数名</td><td><code>{{ item.function_name }}</code></td></tr>
|
|
||||||
<tr><td class="label">Model</td><td>{{ item.model or '-' }}</td></tr>
|
|
||||||
{% if item.plate_type %}
|
|
||||||
<tr><td class="label">Plate Type</td><td>{{ item.plate_type }}</td></tr>
|
|
||||||
{% endif %}
|
|
||||||
<tr><td class="label">Docstring</td><td>{{ item.docstring or '-' }}</td></tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="info-card">
|
|
||||||
<h3>物理尺寸 (mm)</h3>
|
|
||||||
<table class="info-table">
|
|
||||||
<tr><td class="label">X</td><td>{{ item.size_x }}</td></tr>
|
|
||||||
<tr><td class="label">Y</td><td>{{ item.size_y }}</td></tr>
|
|
||||||
<tr><td class="label">Z</td><td>{{ item.size_z }}</td></tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="info-card">
|
|
||||||
<h3>材料信息</h3>
|
|
||||||
<table class="info-table">
|
|
||||||
<tr><td class="label">UUID</td><td><code class="small">{{ item.material_info.uuid }}</code></td></tr>
|
|
||||||
<tr><td class="label">Code</td><td>{{ item.material_info.Code }}</td></tr>
|
|
||||||
<tr><td class="label">Name</td><td>{{ item.material_info.Name }}</td></tr>
|
|
||||||
{% if item.material_info.materialEnum is not none %}
|
|
||||||
<tr><td class="label">materialEnum</td><td>{{ item.material_info.materialEnum }}</td></tr>
|
|
||||||
{% endif %}
|
|
||||||
{% if item.material_info.SupplyType is not none %}
|
|
||||||
<tr><td class="label">SupplyType</td><td>{{ item.material_info.SupplyType }}</td></tr>
|
|
||||||
{% endif %}
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if item.grid %}
|
|
||||||
<div class="info-card">
|
|
||||||
<h3>网格排列</h3>
|
|
||||||
<table class="info-table">
|
|
||||||
<tr><td class="label">列 x 行</td><td>{{ item.grid.num_items_x }} x {{ item.grid.num_items_y }}</td></tr>
|
|
||||||
<tr><td class="label">dx, dy, dz</td><td>{{ item.grid.dx }}, {{ item.grid.dy }}, {{ item.grid.dz }}</td></tr>
|
|
||||||
<tr><td class="label">item_dx, item_dy</td><td>{{ item.grid.item_dx }}, {{ item.grid.item_dy }}</td></tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if item.well %}
|
|
||||||
<div class="info-card">
|
|
||||||
<h3>孔参数 (Well)</h3>
|
|
||||||
<table class="info-table">
|
|
||||||
<tr><td class="label">尺寸</td><td>{{ item.well.size_x }} x {{ item.well.size_y }} x {{ item.well.size_z }}</td></tr>
|
|
||||||
{% if item.well.max_volume is not none %}
|
|
||||||
<tr><td class="label">最大体积</td><td>{{ item.well.max_volume }} uL</td></tr>
|
|
||||||
{% endif %}
|
|
||||||
<tr><td class="label">底部类型</td><td>{{ item.well.bottom_type }}</td></tr>
|
|
||||||
<tr><td class="label">截面类型</td><td>{{ item.well.cross_section_type }}</td></tr>
|
|
||||||
{% if item.well.material_z_thickness is not none %}
|
|
||||||
<tr><td class="label">材料Z厚度</td><td>{{ item.well.material_z_thickness }}</td></tr>
|
|
||||||
{% endif %}
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if item.tip %}
|
|
||||||
<div class="info-card">
|
|
||||||
<h3>枪头参数 (Tip)</h3>
|
|
||||||
<table class="info-table">
|
|
||||||
<tr><td class="label">Spot 尺寸</td><td>{{ item.tip.spot_size_x }} x {{ item.tip.spot_size_y }} x {{ item.tip.spot_size_z }}</td></tr>
|
|
||||||
<tr><td class="label">容量</td><td>{{ item.tip.tip_volume }} uL</td></tr>
|
|
||||||
<tr><td class="label">长度</td><td>{{ item.tip.tip_length }} mm</td></tr>
|
|
||||||
<tr><td class="label">取枪头插入深度</td><td>{{ item.tip.tip_fitting_depth }} mm</td></tr>
|
|
||||||
{% if item.tip.tip_above_rack_length is not none %}
|
|
||||||
<tr><td class="label">枪头露出枪头盒长度</td><td>{{ item.tip.tip_above_rack_length }} mm</td></tr>
|
|
||||||
{% endif %}
|
|
||||||
<tr><td class="label">有滤芯</td><td>{{ item.tip.has_filter }}</td></tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if item.tube %}
|
|
||||||
<div class="info-card">
|
|
||||||
<h3>管参数 (Tube)</h3>
|
|
||||||
<table class="info-table">
|
|
||||||
<tr><td class="label">尺寸</td><td>{{ item.tube.size_x }} x {{ item.tube.size_y }} x {{ item.tube.size_z }}</td></tr>
|
|
||||||
<tr><td class="label">最大体积</td><td>{{ item.tube.max_volume }} uL</td></tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if item.adapter %}
|
|
||||||
<div class="info-card">
|
|
||||||
<h3>适配器参数</h3>
|
|
||||||
<table class="info-table">
|
|
||||||
<tr><td class="label">Hole 尺寸</td><td>{{ item.adapter.adapter_hole_size_x }} x {{ item.adapter.adapter_hole_size_y }} x {{ item.adapter.adapter_hole_size_z }}</td></tr>
|
|
||||||
<tr><td class="label">dx, dy, dz</td><td>{{ item.adapter.dx }}, {{ item.adapter.dy }}, {{ item.adapter.dz }}</td></tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="info-card">
|
|
||||||
<h3>Registry</h3>
|
|
||||||
<table class="info-table">
|
|
||||||
<tr><td class="label">分类</td><td>{{ item.registry_category | join(' / ') }}</td></tr>
|
|
||||||
<tr><td class="label">描述</td><td>{{ item.registry_description }}</td></tr>
|
|
||||||
<tr><td class="label">模板匹配</td><td>{{ item.include_in_template_matching }}</td></tr>
|
|
||||||
{% if item.template_kind %}
|
|
||||||
<tr><td class="label">模板类型</td><td>{{ item.template_kind }}</td></tr>
|
|
||||||
{% endif %}
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 右侧: 可视化 -->
|
|
||||||
<div class="detail-viz">
|
|
||||||
<div class="viz-card">
|
|
||||||
<h3 style="display:flex;align-items:center;justify-content:space-between;">
|
|
||||||
俯视图 (Top-Down)
|
|
||||||
<button type="button" class="btn btn-sm btn-outline" onclick="resetSvgView('svg-topdown')">回中</button>
|
|
||||||
</h3>
|
|
||||||
<div id="svg-topdown"></div>
|
|
||||||
</div>
|
|
||||||
<div class="viz-card">
|
|
||||||
<h3 style="display:flex;align-items:center;justify-content:space-between;">
|
|
||||||
侧面截面图 (Side Profile)
|
|
||||||
<button type="button" class="btn btn-sm btn-outline" onclick="resetSvgView('svg-side')">回中</button>
|
|
||||||
</h3>
|
|
||||||
<div id="svg-side"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block scripts %}
|
|
||||||
<script src="/static/labware_viz.js"></script>
|
|
||||||
<script>
|
|
||||||
const itemData = {{ item.model_dump() | tojson }};
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
renderTopDown(document.getElementById('svg-topdown'), itemData);
|
|
||||||
renderSideProfile(document.getElementById('svg-side'), itemData);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}{% if is_new %}新建耗材{% else %}编辑 {{ item.function_name }}{% endif %} - PRCXI{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="page-header">
|
|
||||||
<h1>{% if is_new %}新建耗材{% else %}编辑 {{ item.function_name }}{% endif %}</h1>
|
|
||||||
<div class="header-actions">
|
|
||||||
<a href="/" class="btn btn-outline">返回列表</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="status-msg" class="status-msg" style="display:none;"></div>
|
|
||||||
|
|
||||||
<div class="edit-layout">
|
|
||||||
<!-- 左侧: 表单 -->
|
|
||||||
<div class="edit-form">
|
|
||||||
<form id="labware-form" onsubmit="return false;">
|
|
||||||
<!-- 基本信息 -->
|
|
||||||
<div class="form-section">
|
|
||||||
<h3>基本信息</h3>
|
|
||||||
<div class="form-row">
|
|
||||||
<label>类型</label>
|
|
||||||
<select name="type" id="f-type" onchange="onTypeChange()">
|
|
||||||
<option value="plate" {% if labware_type == 'plate' %}selected{% endif %}>Plate (孔板)</option>
|
|
||||||
<option value="tip_rack" {% if labware_type == 'tip_rack' %}selected{% endif %}>TipRack (吸头盒)</option>
|
|
||||||
<option value="trash" {% if labware_type == 'trash' %}selected{% endif %}>Trash (废弃槽)</option>
|
|
||||||
<option value="tube_rack" {% if labware_type == 'tube_rack' %}selected{% endif %}>TubeRack (管架)</option>
|
|
||||||
<option value="plate_adapter" {% if labware_type == 'plate_adapter' %}selected{% endif %}>PlateAdapter (适配器)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<label>函数名</label>
|
|
||||||
<input type="text" name="function_name" id="f-function_name"
|
|
||||||
value="{{ item.function_name if item else 'PRCXI_new_labware' }}"
|
|
||||||
placeholder="PRCXI_xxx">
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<label>Model</label>
|
|
||||||
<input type="text" name="model" id="f-model"
|
|
||||||
value="{{ item.model if item and item.model else '' }}">
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<label>Docstring</label>
|
|
||||||
<textarea name="docstring" id="f-docstring" rows="2">{{ item.docstring if item else '' }}</textarea>
|
|
||||||
</div>
|
|
||||||
<div class="form-row" id="row-plate_type" style="display:none;">
|
|
||||||
<label>Plate Type</label>
|
|
||||||
<select name="plate_type" id="f-plate_type">
|
|
||||||
<option value="">-</option>
|
|
||||||
<option value="skirted" {% if item and item.plate_type == 'skirted' %}selected{% endif %}>skirted</option>
|
|
||||||
<option value="semi-skirted" {% if item and item.plate_type == 'semi-skirted' %}selected{% endif %}>semi-skirted</option>
|
|
||||||
<option value="non-skirted" {% if item and item.plate_type == 'non-skirted' %}selected{% endif %}>non-skirted</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 物理尺寸 -->
|
|
||||||
<div class="form-section">
|
|
||||||
<h3>物理尺寸 Physical Dimensions (mm)</h3>
|
|
||||||
<div class="form-row-3">
|
|
||||||
<div><label>size_x <span class="label-cn">板长</span></label><input type="number" step="any" name="size_x" id="f-size_x" value="{{ item.size_x if item else 127 }}"></div>
|
|
||||||
<div><label>size_y <span class="label-cn">板宽</span></label><input type="number" step="any" name="size_y" id="f-size_y" value="{{ item.size_y if item else 85 }}"></div>
|
|
||||||
<div><label>size_z <span class="label-cn">板高</span></label><input type="number" step="any" name="size_z" id="f-size_z" value="{{ item.size_z if item else 20 }}"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 材料信息 -->
|
|
||||||
<div class="form-section">
|
|
||||||
<h3>材料信息</h3>
|
|
||||||
<div class="form-row">
|
|
||||||
<label>UUID</label>
|
|
||||||
<input type="text" name="mi_uuid" id="f-mi_uuid"
|
|
||||||
value="{{ item.material_info.uuid if item else '' }}">
|
|
||||||
</div>
|
|
||||||
<div class="form-row-2">
|
|
||||||
<div><label>Code</label><input type="text" name="mi_code" id="f-mi_code" value="{{ item.material_info.Code if item else '' }}"></div>
|
|
||||||
<div><label>Name</label><input type="text" name="mi_name" id="f-mi_name" value="{{ item.material_info.Name if item else '' }}"></div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row-2">
|
|
||||||
<div><label>materialEnum</label><input type="number" name="mi_menum" id="f-mi_menum" value="{{ item.material_info.materialEnum if item and item.material_info.materialEnum is not none else '' }}"></div>
|
|
||||||
<div><label>SupplyType</label><input type="number" name="mi_stype" id="f-mi_stype" value="{{ item.material_info.SupplyType if item and item.material_info.SupplyType is not none else '' }}"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 网格排列 (plate/tip_rack/tube_rack) -->
|
|
||||||
<div class="form-section" id="section-grid" style="display:none;">
|
|
||||||
<h3 style="display:flex;align-items:center;justify-content:space-between;">
|
|
||||||
网格排列 Grid Layout
|
|
||||||
<button type="button" class="btn btn-sm btn-outline" onclick="autoCenter()">自动居中 Auto-Center</button>
|
|
||||||
</h3>
|
|
||||||
<div class="form-row-2">
|
|
||||||
<div><label>num_items_x <span class="label-cn">列数</span></label><input type="number" name="grid_nx" id="f-grid_nx" value="{{ item.grid.num_items_x if item and item.grid else 12 }}"></div>
|
|
||||||
<div><label>num_items_y <span class="label-cn">行数</span></label><input type="number" name="grid_ny" id="f-grid_ny" value="{{ item.grid.num_items_y if item and item.grid else 8 }}"></div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row-3">
|
|
||||||
<div><label>dx <span class="label-cn">首孔X偏移</span></label><input type="number" step="any" name="grid_dx" id="f-grid_dx" value="{{ item.grid.dx if item and item.grid else 0 }}"></div>
|
|
||||||
<div><label>dy <span class="label-cn">首孔Y偏移</span></label><input type="number" step="any" name="grid_dy" id="f-grid_dy" value="{{ item.grid.dy if item and item.grid else 0 }}"></div>
|
|
||||||
<div><label>dz <span class="label-cn">孔底Z偏移</span></label><input type="number" step="any" name="grid_dz" id="f-grid_dz" value="{{ item.grid.dz if item and item.grid else 0 }}"></div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row-2">
|
|
||||||
<div><label>item_dx <span class="label-cn">列间距</span></label><input type="number" step="any" name="grid_idx" id="f-grid_idx" value="{{ item.grid.item_dx if item and item.grid else 9 }}"></div>
|
|
||||||
<div><label>item_dy <span class="label-cn">行间距</span></label><input type="number" step="any" name="grid_idy" id="f-grid_idy" value="{{ item.grid.item_dy if item and item.grid else 9 }}"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Well 参数 (plate) -->
|
|
||||||
<div class="form-section" id="section-well" style="display:none;">
|
|
||||||
<h3>孔参数 Well</h3>
|
|
||||||
<div class="form-row-3">
|
|
||||||
<div><label>size_x <span class="label-cn">孔长</span></label><input type="number" step="any" name="well_sx" id="f-well_sx" value="{{ item.well.size_x if item and item.well else 8 }}"></div>
|
|
||||||
<div><label>size_y <span class="label-cn">孔宽</span></label><input type="number" step="any" name="well_sy" id="f-well_sy" value="{{ item.well.size_y if item and item.well else 8 }}"></div>
|
|
||||||
<div><label>size_z <span class="label-cn">孔深</span></label><input type="number" step="any" name="well_sz" id="f-well_sz" value="{{ item.well.size_z if item and item.well else 10 }}"></div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row-2">
|
|
||||||
<div><label>max_volume <span class="label-cn">最大容量 (uL)</span></label><input type="number" step="any" name="well_vol" id="f-well_vol" value="{{ item.well.max_volume if item and item.well and item.well.max_volume is not none else '' }}"></div>
|
|
||||||
<div><label>material_z_thickness <span class="label-cn">底壁厚度</span></label><input type="number" step="any" name="well_mzt" id="f-well_mzt" value="{{ item.well.material_z_thickness if item and item.well and item.well.material_z_thickness is not none else '' }}"></div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row-2">
|
|
||||||
<div>
|
|
||||||
<label>bottom_type <span class="label-cn">底部形状</span></label>
|
|
||||||
<select name="well_bt" id="f-well_bt">
|
|
||||||
<option value="FLAT" {% if item and item.well and item.well.bottom_type == 'FLAT' %}selected{% endif %}>FLAT</option>
|
|
||||||
<option value="V" {% if item and item.well and item.well.bottom_type == 'V' %}selected{% endif %}>V</option>
|
|
||||||
<option value="U" {% if item and item.well and item.well.bottom_type == 'U' %}selected{% endif %}>U</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>cross_section_type <span class="label-cn">截面形状</span></label>
|
|
||||||
<select name="well_cs" id="f-well_cs">
|
|
||||||
<option value="CIRCLE" {% if item and item.well and item.well.cross_section_type == 'CIRCLE' %}selected{% endif %}>CIRCLE</option>
|
|
||||||
<option value="RECTANGLE" {% if item and item.well and item.well.cross_section_type == 'RECTANGLE' %}selected{% endif %}>RECTANGLE</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<label><input type="checkbox" name="has_vf" id="f-has_vf" {% if item and item.volume_functions %}checked{% endif %}> 使用 volume_functions (rectangle)</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tip 参数 (tip_rack) -->
|
|
||||||
<div class="form-section" id="section-tip" style="display:none;">
|
|
||||||
<h3>枪头参数 Tip</h3>
|
|
||||||
<div class="form-row-3">
|
|
||||||
<div><label>spot_size_x <span class="label-cn">卡槽长</span></label><input type="number" step="any" name="tip_sx" id="f-tip_sx" value="{{ item.tip.spot_size_x if item and item.tip else 7 }}"></div>
|
|
||||||
<div><label>spot_size_y <span class="label-cn">卡槽宽</span></label><input type="number" step="any" name="tip_sy" id="f-tip_sy" value="{{ item.tip.spot_size_y if item and item.tip else 7 }}"></div>
|
|
||||||
<div><label>spot_size_z <span class="label-cn">卡槽高</span></label><input type="number" step="any" name="tip_sz" id="f-tip_sz" value="{{ item.tip.spot_size_z if item and item.tip else 0 }}"></div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row-3">
|
|
||||||
<div><label>tip_volume <span class="label-cn">枪头容量 (uL)</span></label><input type="number" step="any" name="tip_vol" id="f-tip_vol" value="{{ item.tip.tip_volume if item and item.tip else 300 }}"></div>
|
|
||||||
<div><label>tip_length <span class="label-cn">枪头总长度 (mm)</span></label><input type="number" step="any" name="tip_len" id="f-tip_len" value="{{ item.tip.tip_length if item and item.tip else 60 }}"></div>
|
|
||||||
<div><label>fitting_depth <span class="label-cn">取枪头时插入的长度 (mm)</span></label><input type="number" step="any" name="tip_dep" id="f-tip_dep" value="{{ item.tip.tip_fitting_depth if item and item.tip else 51 }}"></div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<label>tip_above_rack_length <span class="label-cn">枪头在枪头盒上方的部分的长度 (mm)</span></label>
|
|
||||||
<input type="number" step="any" name="tip_above" id="f-tip_above"
|
|
||||||
value="{{ item.tip.tip_above_rack_length if item and item.tip and item.tip.tip_above_rack_length is not none else '' }}"
|
|
||||||
placeholder="tip_length - (size_z - dz)">
|
|
||||||
<small style="color:#888;margin-top:2px;">公式: tip_length = tip_above + size_z - dz;填 tip_above 自动算 dz,填 dz 自动算 tip_above</small>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<label><input type="checkbox" name="tip_filter" id="f-tip_filter" {% if item and item.tip and item.tip.has_filter %}checked{% endif %}> has_filter</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tube 参数 (tube_rack) -->
|
|
||||||
<div class="form-section" id="section-tube" style="display:none;">
|
|
||||||
<h3>管参数 Tube</h3>
|
|
||||||
<div class="form-row-3">
|
|
||||||
<div><label>size_x <span class="label-cn">管径X</span></label><input type="number" step="any" name="tube_sx" id="f-tube_sx" value="{{ item.tube.size_x if item and item.tube else 10.6 }}"></div>
|
|
||||||
<div><label>size_y <span class="label-cn">管径Y</span></label><input type="number" step="any" name="tube_sy" id="f-tube_sy" value="{{ item.tube.size_y if item and item.tube else 10.6 }}"></div>
|
|
||||||
<div><label>size_z <span class="label-cn">管高</span></label><input type="number" step="any" name="tube_sz" id="f-tube_sz" value="{{ item.tube.size_z if item and item.tube else 40 }}"></div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<label>max_volume <span class="label-cn">最大容量 (uL)</span></label>
|
|
||||||
<input type="number" step="any" name="tube_vol" id="f-tube_vol" value="{{ item.tube.max_volume if item and item.tube else 1500 }}">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Adapter 参数 (plate_adapter) -->
|
|
||||||
<div class="form-section" id="section-adapter" style="display:none;">
|
|
||||||
<h3>适配器参数 Adapter</h3>
|
|
||||||
<div class="form-row-3">
|
|
||||||
<div><label>hole_size_x <span class="label-cn">凹槽长</span></label><input type="number" step="any" name="adp_hsx" id="f-adp_hsx" value="{{ item.adapter.adapter_hole_size_x if item and item.adapter else 127.76 }}"></div>
|
|
||||||
<div><label>hole_size_y <span class="label-cn">凹槽宽</span></label><input type="number" step="any" name="adp_hsy" id="f-adp_hsy" value="{{ item.adapter.adapter_hole_size_y if item and item.adapter else 85.48 }}"></div>
|
|
||||||
<div><label>hole_size_z <span class="label-cn">凹槽深</span></label><input type="number" step="any" name="adp_hsz" id="f-adp_hsz" value="{{ item.adapter.adapter_hole_size_z if item and item.adapter else 10 }}"></div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row-3">
|
|
||||||
<div><label>dx <span class="label-cn">X偏移</span></label><input type="number" step="any" name="adp_dx" id="f-adp_dx" value="{{ item.adapter.dx if item and item.adapter and item.adapter.dx is not none else '' }}"></div>
|
|
||||||
<div><label>dy <span class="label-cn">Y偏移</span></label><input type="number" step="any" name="adp_dy" id="f-adp_dy" value="{{ item.adapter.dy if item and item.adapter and item.adapter.dy is not none else '' }}"></div>
|
|
||||||
<div><label>dz <span class="label-cn">Z偏移</span></label><input type="number" step="any" name="adp_dz" id="f-adp_dz" value="{{ item.adapter.dz if item and item.adapter else 0 }}"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Registry -->
|
|
||||||
<div class="form-section">
|
|
||||||
<h3>Registry / Template</h3>
|
|
||||||
<div class="form-row">
|
|
||||||
<label>registry_category (逗号分隔)</label>
|
|
||||||
<input type="text" name="reg_cat" id="f-reg_cat"
|
|
||||||
value="{{ item.registry_category | join(',') if item else 'prcxi,plates' }}">
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<label>registry_description</label>
|
|
||||||
<input type="text" name="reg_desc" id="f-reg_desc"
|
|
||||||
value="{{ item.registry_description if item else '' }}">
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<label><input type="checkbox" name="in_tpl" id="f-in_tpl" {% if item and item.include_in_template_matching %}checked{% endif %}> include_in_template_matching</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-row" id="row-tpl_kind">
|
|
||||||
<label>template_kind</label>
|
|
||||||
<input type="text" name="tpl_kind" id="f-tpl_kind"
|
|
||||||
value="{{ item.template_kind if item and item.template_kind else '' }}">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-actions">
|
|
||||||
<button type="button" class="btn btn-primary" onclick="saveForm()">
|
|
||||||
{% if is_new %}创建{% else %}保存{% endif %}
|
|
||||||
</button>
|
|
||||||
<a href="/" class="btn btn-outline">取消</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 右侧: 实时预览 -->
|
|
||||||
<div class="edit-preview">
|
|
||||||
<div class="viz-card">
|
|
||||||
<h3 style="display:flex;align-items:center;justify-content:space-between;">
|
|
||||||
预览: 俯视图
|
|
||||||
<button type="button" class="btn btn-sm btn-outline" onclick="resetSvgView('svg-topdown')">回中</button>
|
|
||||||
</h3>
|
|
||||||
<div id="svg-topdown"></div>
|
|
||||||
</div>
|
|
||||||
<div class="viz-card">
|
|
||||||
<h3 style="display:flex;align-items:center;justify-content:space-between;">
|
|
||||||
预览: 侧面截面图
|
|
||||||
<button type="button" class="btn btn-sm btn-outline" onclick="resetSvgView('svg-side')">回中</button>
|
|
||||||
</h3>
|
|
||||||
<div id="svg-side"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block scripts %}
|
|
||||||
<script src="/static/labware_viz.js"></script>
|
|
||||||
<script src="/static/form_handler.js"></script>
|
|
||||||
<script>
|
|
||||||
const IS_NEW = {{ 'true' if is_new else 'false' }};
|
|
||||||
const ITEM_ID = "{{ item.function_name if item else '' }}";
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
onTypeChange();
|
|
||||||
updatePreview();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}耗材列表 - PRCXI{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="page-header">
|
|
||||||
<h1>耗材列表 <span class="badge">{{ total }}</span></h1>
|
|
||||||
<div class="header-actions">
|
|
||||||
<button class="btn btn-outline" onclick="importFromCode()">从代码导入</button>
|
|
||||||
<button class="btn btn-outline" onclick="generateCode(true)">生成代码 (测试)</button>
|
|
||||||
<button class="btn btn-warning" onclick="generateCode(false)">生成代码 (正式)</button>
|
|
||||||
<a href="/labware/new" class="btn btn-primary">+ 新建耗材</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="status-msg" class="status-msg" style="display:none;"></div>
|
|
||||||
|
|
||||||
{% set type_labels = {
|
|
||||||
"plate": "孔板 (Plate)",
|
|
||||||
"tip_rack": "吸头盒 (TipRack)",
|
|
||||||
"trash": "废弃槽 (Trash)",
|
|
||||||
"tube_rack": "管架 (TubeRack)",
|
|
||||||
"plate_adapter": "适配器 (PlateAdapter)"
|
|
||||||
} %}
|
|
||||||
{% set type_colors = {
|
|
||||||
"plate": "#3b82f6",
|
|
||||||
"tip_rack": "#10b981",
|
|
||||||
"trash": "#ef4444",
|
|
||||||
"tube_rack": "#f59e0b",
|
|
||||||
"plate_adapter": "#8b5cf6"
|
|
||||||
} %}
|
|
||||||
|
|
||||||
{% for type_key in ["plate", "tip_rack", "trash", "tube_rack", "plate_adapter"] %}
|
|
||||||
{% if type_key in groups %}
|
|
||||||
<section class="type-section">
|
|
||||||
<h2>
|
|
||||||
<span class="type-dot" style="background:{{ type_colors[type_key] }}"></span>
|
|
||||||
{{ type_labels[type_key] }}
|
|
||||||
<span class="badge">{{ groups[type_key]|length }}</span>
|
|
||||||
</h2>
|
|
||||||
<div class="card-grid">
|
|
||||||
{% for item in groups[type_key] %}
|
|
||||||
<div class="labware-card" onclick="location.href='/labware/{{ item.function_name }}'">
|
|
||||||
<div class="card-header">
|
|
||||||
<span class="card-title">{{ item.function_name }}</span>
|
|
||||||
{% if item.include_in_template_matching %}
|
|
||||||
<span class="tag tag-tpl">TPL</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="card-info">
|
|
||||||
<span class="label">Code:</span> {{ item.material_info.Code or '-' }}
|
|
||||||
</div>
|
|
||||||
<div class="card-info">
|
|
||||||
<span class="label">名称:</span> {{ item.material_info.Name or '-' }}
|
|
||||||
</div>
|
|
||||||
<div class="card-info">
|
|
||||||
<span class="label">尺寸:</span>
|
|
||||||
{{ item.size_x }} x {{ item.size_y }} x {{ item.size_z }} mm
|
|
||||||
</div>
|
|
||||||
{% if item.grid %}
|
|
||||||
<div class="card-info">
|
|
||||||
<span class="label">网格:</span>
|
|
||||||
{{ item.grid.num_items_x }} x {{ item.grid.num_items_y }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="card-footer">
|
|
||||||
<a href="/labware/{{ item.function_name }}/edit" class="btn btn-sm btn-outline"
|
|
||||||
onclick="event.stopPropagation()">编辑</a>
|
|
||||||
<button class="btn btn-sm btn-danger"
|
|
||||||
onclick="event.stopPropagation(); deleteItem('{{ item.function_name }}')">删除</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block scripts %}
|
|
||||||
<script>
|
|
||||||
function showMsg(text, ok) {
|
|
||||||
const el = document.getElementById('status-msg');
|
|
||||||
el.textContent = text;
|
|
||||||
el.className = 'status-msg ' + (ok ? 'msg-ok' : 'msg-err');
|
|
||||||
el.style.display = 'block';
|
|
||||||
setTimeout(() => el.style.display = 'none', 4000);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function importFromCode() {
|
|
||||||
if (!confirm('将从现有 prcxi_labware.py + YAML 重新导入,覆盖当前 JSON 数据?')) return;
|
|
||||||
const r = await fetch('/api/import-from-code', {method:'POST'});
|
|
||||||
const d = await r.json();
|
|
||||||
if (d.status === 'ok') {
|
|
||||||
showMsg('导入成功: ' + d.count + ' 个耗材', true);
|
|
||||||
setTimeout(() => location.reload(), 1000);
|
|
||||||
} else {
|
|
||||||
showMsg('导入失败: ' + JSON.stringify(d), false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateCode(testMode) {
|
|
||||||
const label = testMode ? '测试' : '正式';
|
|
||||||
if (!testMode && !confirm('正式模式将覆盖原有 prcxi_labware.py 和 YAML 文件,确定?')) return;
|
|
||||||
const r = await fetch('/api/generate-code', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type':'application/json'},
|
|
||||||
body: JSON.stringify({test_mode: testMode})
|
|
||||||
});
|
|
||||||
const d = await r.json();
|
|
||||||
if (d.status === 'ok') {
|
|
||||||
showMsg(`[${label}] 生成成功: ${d.python_file}`, true);
|
|
||||||
} else {
|
|
||||||
showMsg('生成失败: ' + JSON.stringify(d), false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteItem(id) {
|
|
||||||
if (!confirm('确定删除 ' + id + '?')) return;
|
|
||||||
const r = await fetch('/api/labware/' + id, {method:'DELETE'});
|
|
||||||
const d = await r.json();
|
|
||||||
if (d.status === 'ok') {
|
|
||||||
showMsg('已删除', true);
|
|
||||||
setTimeout(() => location.reload(), 500);
|
|
||||||
} else {
|
|
||||||
showMsg('删除失败', false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
"""JSON → Registry YAML 文件生成。
|
|
||||||
|
|
||||||
按 type 分组输出到对应 YAML 文件(与现有格式完全一致)。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import shutil
|
|
||||||
from collections import defaultdict
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Dict, List
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
from unilabos.labware_manager.models import LabwareDB, LabwareItem
|
|
||||||
|
|
||||||
_REGISTRY_DIR = Path(__file__).resolve().parents[1] / "registry" / "resources" / "prcxi"
|
|
||||||
|
|
||||||
# type → yaml 文件名
|
|
||||||
_TYPE_TO_YAML = {
|
|
||||||
"plate": "plates",
|
|
||||||
"tip_rack": "tip_racks",
|
|
||||||
"trash": "trash",
|
|
||||||
"tube_rack": "tube_racks",
|
|
||||||
"plate_adapter": "plate_adapters",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _build_entry(item: LabwareItem) -> dict:
|
|
||||||
"""构建单个 YAML 条目(与现有格式完全一致)。"""
|
|
||||||
mi = item.material_info
|
|
||||||
desc = item.registry_description
|
|
||||||
if not desc:
|
|
||||||
desc = f'{mi.Name} (Code: {mi.Code})' if mi.Name and mi.Code else item.function_name
|
|
||||||
|
|
||||||
return {
|
|
||||||
"category": list(item.registry_category),
|
|
||||||
"class": {
|
|
||||||
"module": f"unilabos.devices.liquid_handling.prcxi.prcxi_labware:{item.function_name}",
|
|
||||||
"type": "pylabrobot",
|
|
||||||
},
|
|
||||||
"description": desc,
|
|
||||||
"handles": [],
|
|
||||||
"icon": "",
|
|
||||||
"init_param_schema": {},
|
|
||||||
"version": "1.0.0",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class _YAMLDumper(yaml.SafeDumper):
|
|
||||||
"""自定义 Dumper: 空列表输出为 [],空字典输出为 {}。"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _represent_list(dumper, data):
|
|
||||||
if not data:
|
|
||||||
return dumper.represent_sequence("tag:yaml.org,2002:seq", data, flow_style=True)
|
|
||||||
return dumper.represent_sequence("tag:yaml.org,2002:seq", data)
|
|
||||||
|
|
||||||
|
|
||||||
def _represent_dict(dumper, data):
|
|
||||||
if not data:
|
|
||||||
return dumper.represent_mapping("tag:yaml.org,2002:map", data, flow_style=True)
|
|
||||||
return dumper.represent_mapping("tag:yaml.org,2002:map", data)
|
|
||||||
|
|
||||||
|
|
||||||
def _represent_str(dumper, data):
|
|
||||||
if '\n' in data or ':' in data or "'" in data:
|
|
||||||
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="'")
|
|
||||||
return dumper.represent_scalar("tag:yaml.org,2002:str", data)
|
|
||||||
|
|
||||||
|
|
||||||
_YAMLDumper.add_representer(list, _represent_list)
|
|
||||||
_YAMLDumper.add_representer(dict, _represent_dict)
|
|
||||||
_YAMLDumper.add_representer(str, _represent_str)
|
|
||||||
|
|
||||||
|
|
||||||
def generate_yaml(db: LabwareDB, test_mode: bool = True) -> List[Path]:
|
|
||||||
"""生成所有 registry YAML 文件,返回输出文件路径列表。"""
|
|
||||||
suffix = "_test" if test_mode else ""
|
|
||||||
|
|
||||||
# 按 type 分组
|
|
||||||
groups: Dict[str, Dict[str, dict]] = defaultdict(dict)
|
|
||||||
for item in db.items:
|
|
||||||
yaml_key = _TYPE_TO_YAML.get(item.type)
|
|
||||||
if yaml_key is None:
|
|
||||||
continue
|
|
||||||
groups[yaml_key][item.function_name] = _build_entry(item)
|
|
||||||
|
|
||||||
out_paths: List[Path] = []
|
|
||||||
for yaml_key, entries in groups.items():
|
|
||||||
out_path = _REGISTRY_DIR / f"{yaml_key}{suffix}.yaml"
|
|
||||||
|
|
||||||
# 备份
|
|
||||||
if out_path.exists():
|
|
||||||
bak = out_path.with_suffix(".yaml.bak")
|
|
||||||
shutil.copy2(out_path, bak)
|
|
||||||
|
|
||||||
# 按函数名排序
|
|
||||||
sorted_entries = dict(sorted(entries.items()))
|
|
||||||
|
|
||||||
content = yaml.dump(sorted_entries, Dumper=_YAMLDumper, allow_unicode=True,
|
|
||||||
default_flow_style=False, sort_keys=False)
|
|
||||||
out_path.write_text(content, encoding="utf-8")
|
|
||||||
out_paths.append(out_path)
|
|
||||||
|
|
||||||
return out_paths
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
from unilabos.labware_manager.importer import load_db
|
|
||||||
db = load_db()
|
|
||||||
if not db.items:
|
|
||||||
print("labware_db.json 为空,请先运行 importer.py")
|
|
||||||
else:
|
|
||||||
paths = generate_yaml(db, test_mode=True)
|
|
||||||
print(f"已生成 {len(paths)} 个 YAML 文件:")
|
|
||||||
for p in paths:
|
|
||||||
print(f" {p}")
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,658 +0,0 @@
|
|||||||
"""
|
|
||||||
装饰器注册表系统
|
|
||||||
|
|
||||||
通过 @device, @action, @resource 装饰器替代 YAML 配置文件来定义设备/动作/资源注册表信息。
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
from unilabos.registry.decorators import (
|
|
||||||
device, action, resource,
|
|
||||||
InputHandle, OutputHandle,
|
|
||||||
ActionInputHandle, ActionOutputHandle,
|
|
||||||
HardwareInterface, Side, DataSource, NodeType,
|
|
||||||
)
|
|
||||||
|
|
||||||
@device(
|
|
||||||
id="solenoid_valve.mock",
|
|
||||||
category=["pump_and_valve"],
|
|
||||||
description="模拟电磁阀设备",
|
|
||||||
handles=[
|
|
||||||
InputHandle(key="in", data_type="fluid", label="in", side=Side.NORTH),
|
|
||||||
OutputHandle(key="out", data_type="fluid", label="out", side=Side.SOUTH),
|
|
||||||
],
|
|
||||||
hardware_interface=HardwareInterface(
|
|
||||||
name="hardware_interface",
|
|
||||||
read="send_command",
|
|
||||||
write="send_command",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
class SolenoidValveMock:
|
|
||||||
@action(action_type=EmptyIn)
|
|
||||||
def close(self):
|
|
||||||
...
|
|
||||||
|
|
||||||
@action(
|
|
||||||
handles=[
|
|
||||||
ActionInputHandle(key="in", data_type="fluid", label="in"),
|
|
||||||
ActionOutputHandle(key="out", data_type="fluid", label="out"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def set_valve_position(self, position):
|
|
||||||
...
|
|
||||||
|
|
||||||
# 无 @action 装饰器 => auto- 前缀动作
|
|
||||||
def is_open(self):
|
|
||||||
...
|
|
||||||
"""
|
|
||||||
|
|
||||||
from enum import Enum
|
|
||||||
from functools import wraps
|
|
||||||
from typing import Any, Callable, Dict, List, Optional, TypeVar
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
F = TypeVar("F", bound=Callable[..., Any])
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 枚举
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class Side(str, Enum):
|
|
||||||
"""UI 上 Handle 的显示位置"""
|
|
||||||
|
|
||||||
NORTH = "NORTH"
|
|
||||||
SOUTH = "SOUTH"
|
|
||||||
EAST = "EAST"
|
|
||||||
WEST = "WEST"
|
|
||||||
|
|
||||||
|
|
||||||
class DataSource(str, Enum):
|
|
||||||
"""Handle 的数据来源"""
|
|
||||||
|
|
||||||
HANDLE = "handle" # 从上游 handle 获取数据 (用于 InputHandle)
|
|
||||||
EXECUTOR = "executor" # 从执行器输出数据 (用于 OutputHandle)
|
|
||||||
|
|
||||||
|
|
||||||
class NodeType(str, Enum):
|
|
||||||
"""动作的节点类型(用于区分 ILab 节点和人工确认节点等)"""
|
|
||||||
|
|
||||||
ILAB = "ILab"
|
|
||||||
MANUAL_CONFIRM = "manual_confirm"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Device / Resource Handle (设备/资源级别端口, 序列化时包含 io_type)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class _DeviceHandleBase(BaseModel):
|
|
||||||
"""设备/资源端口基类 (内部使用)"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(populate_by_name=True)
|
|
||||||
|
|
||||||
key: str = Field(serialization_alias="handler_key")
|
|
||||||
data_type: str
|
|
||||||
label: str
|
|
||||||
side: Optional[Side] = None
|
|
||||||
data_key: Optional[str] = None
|
|
||||||
data_source: Optional[str] = None
|
|
||||||
description: Optional[str] = None
|
|
||||||
|
|
||||||
# 子类覆盖
|
|
||||||
io_type: str = ""
|
|
||||||
|
|
||||||
def to_registry_dict(self) -> Dict[str, Any]:
|
|
||||||
return self.model_dump(by_alias=True, exclude_none=True)
|
|
||||||
|
|
||||||
|
|
||||||
class InputHandle(_DeviceHandleBase):
|
|
||||||
"""
|
|
||||||
输入端口 (io_type="target"), 用于 @device / @resource handles
|
|
||||||
|
|
||||||
Example:
|
|
||||||
InputHandle(key="in", data_type="fluid", label="in", side=Side.NORTH)
|
|
||||||
"""
|
|
||||||
|
|
||||||
io_type: str = "target"
|
|
||||||
|
|
||||||
|
|
||||||
class OutputHandle(_DeviceHandleBase):
|
|
||||||
"""
|
|
||||||
输出端口 (io_type="source"), 用于 @device / @resource handles
|
|
||||||
|
|
||||||
Example:
|
|
||||||
OutputHandle(key="out", data_type="fluid", label="out", side=Side.SOUTH)
|
|
||||||
"""
|
|
||||||
|
|
||||||
io_type: str = "source"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Action Handle (动作级别端口, 序列化时不含 io_type, 按类型自动分组)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class _ActionHandleBase(BaseModel):
|
|
||||||
"""动作端口基类 (内部使用)"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(populate_by_name=True)
|
|
||||||
|
|
||||||
key: str = Field(serialization_alias="handler_key")
|
|
||||||
data_type: str
|
|
||||||
label: str
|
|
||||||
side: Optional[Side] = None
|
|
||||||
data_key: Optional[str] = None
|
|
||||||
data_source: Optional[str] = None
|
|
||||||
description: Optional[str] = None
|
|
||||||
io_type: Optional[str] = None # source/sink (dataflow) or target/source (device-style)
|
|
||||||
|
|
||||||
def to_registry_dict(self) -> Dict[str, Any]:
|
|
||||||
return self.model_dump(by_alias=True, exclude_none=True)
|
|
||||||
|
|
||||||
|
|
||||||
class ActionInputHandle(_ActionHandleBase):
|
|
||||||
"""
|
|
||||||
动作输入端口, 用于 @action handles, 序列化后归入 "input" 组
|
|
||||||
|
|
||||||
Example:
|
|
||||||
ActionInputHandle(
|
|
||||||
key="material_input", data_type="workbench_material",
|
|
||||||
label="物料编号", data_key="material_number", data_source="handle",
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ActionOutputHandle(_ActionHandleBase):
|
|
||||||
"""
|
|
||||||
动作输出端口, 用于 @action handles, 序列化后归入 "output" 组
|
|
||||||
|
|
||||||
Example:
|
|
||||||
ActionOutputHandle(
|
|
||||||
key="station_output", data_type="workbench_station",
|
|
||||||
label="加热台ID", data_key="station_id", data_source="executor",
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# HardwareInterface
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class HardwareInterface(BaseModel):
|
|
||||||
"""
|
|
||||||
硬件通信接口定义
|
|
||||||
|
|
||||||
描述设备与底层硬件通信的方式 (串口、Modbus 等)。
|
|
||||||
|
|
||||||
Example:
|
|
||||||
HardwareInterface(name="hardware_interface", read="send_command", write="send_command")
|
|
||||||
"""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
read: Optional[str] = None
|
|
||||||
write: Optional[str] = None
|
|
||||||
extra_info: Optional[List[str]] = None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 全局注册表 -- 记录所有被装饰器标记的类/函数
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
_registered_devices: Dict[str, type] = {} # device_id -> class
|
|
||||||
_registered_resources: Dict[str, Any] = {} # resource_id -> class or function
|
|
||||||
|
|
||||||
|
|
||||||
def _device_handles_to_list(
|
|
||||||
handles: Optional[List[_DeviceHandleBase]],
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""将设备/资源 Handle 列表序列化为字典列表 (含 io_type)"""
|
|
||||||
if handles is None:
|
|
||||||
return []
|
|
||||||
return [h.to_registry_dict() for h in handles]
|
|
||||||
|
|
||||||
|
|
||||||
def _action_handles_to_dict(
|
|
||||||
handles: Optional[List[_ActionHandleBase]],
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
将动作 Handle 列表序列化为 {"input": [...], "output": [...]} 格式。
|
|
||||||
|
|
||||||
ActionInputHandle => "input", ActionOutputHandle => "output"
|
|
||||||
"""
|
|
||||||
if handles is None:
|
|
||||||
return {}
|
|
||||||
input_list = [h.to_registry_dict() for h in handles if isinstance(h, ActionInputHandle)]
|
|
||||||
output_list = [h.to_registry_dict() for h in handles if isinstance(h, ActionOutputHandle)]
|
|
||||||
result: Dict[str, Any] = {}
|
|
||||||
if input_list:
|
|
||||||
result["input"] = input_list
|
|
||||||
if output_list:
|
|
||||||
result["output"] = output_list
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# @device 类装饰器
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
# noinspection PyShadowingBuiltins
|
|
||||||
def device(
|
|
||||||
id: Optional[str] = None,
|
|
||||||
ids: Optional[List[str]] = None,
|
|
||||||
id_meta: Optional[Dict[str, Dict[str, Any]]] = None,
|
|
||||||
category: Optional[List[str]] = None,
|
|
||||||
description: str = "",
|
|
||||||
display_name: str = "",
|
|
||||||
icon: str = "",
|
|
||||||
version: str = "1.0.0",
|
|
||||||
handles: Optional[List[_DeviceHandleBase]] = None,
|
|
||||||
model: Optional[Dict[str, Any]] = None,
|
|
||||||
device_type: str = "python",
|
|
||||||
hardware_interface: Optional[HardwareInterface] = None,
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
设备类装饰器
|
|
||||||
|
|
||||||
将类标记为一个 UniLab-OS 设备,并附加注册表元数据。
|
|
||||||
|
|
||||||
支持两种模式:
|
|
||||||
1. 单设备: id="xxx", category=[...]
|
|
||||||
2. 多设备: ids=["id1","id2"], id_meta={"id1":{handles:[...]}, "id2":{...}}
|
|
||||||
|
|
||||||
Args:
|
|
||||||
id: 单设备时的注册表唯一标识
|
|
||||||
ids: 多设备时的 id 列表,与 id_meta 配合使用
|
|
||||||
id_meta: 每个 device_id 的覆盖元数据 (handles/description/icon/model)
|
|
||||||
category: 设备分类标签列表 (必填)
|
|
||||||
description: 设备描述
|
|
||||||
display_name: 人类可读的设备显示名称,缺失时默认使用 id
|
|
||||||
icon: 图标路径
|
|
||||||
version: 版本号
|
|
||||||
handles: 设备端口列表 (单设备或 id_meta 未覆盖时使用)
|
|
||||||
model: 可选的 3D 模型配置
|
|
||||||
device_type: 设备实现类型 ("python" / "ros2")
|
|
||||||
hardware_interface: 硬件通信接口 (HardwareInterface)
|
|
||||||
"""
|
|
||||||
# Resolve device ids
|
|
||||||
if ids is not None:
|
|
||||||
device_ids = list(ids)
|
|
||||||
if not device_ids:
|
|
||||||
raise ValueError("@device ids 不能为空")
|
|
||||||
id_meta = id_meta or {}
|
|
||||||
elif id is not None:
|
|
||||||
device_ids = [id]
|
|
||||||
id_meta = {}
|
|
||||||
else:
|
|
||||||
raise ValueError("@device 必须提供 id 或 ids")
|
|
||||||
|
|
||||||
if category is None:
|
|
||||||
raise ValueError("@device category 必填")
|
|
||||||
|
|
||||||
base_meta = {
|
|
||||||
"category": category,
|
|
||||||
"description": description,
|
|
||||||
"display_name": display_name,
|
|
||||||
"icon": icon,
|
|
||||||
"version": version,
|
|
||||||
"handles": _device_handles_to_list(handles),
|
|
||||||
"model": model,
|
|
||||||
"device_type": device_type,
|
|
||||||
"hardware_interface": (hardware_interface.model_dump(exclude_none=True) if hardware_interface else None),
|
|
||||||
}
|
|
||||||
|
|
||||||
def decorator(cls):
|
|
||||||
cls._device_registry_meta = base_meta
|
|
||||||
cls._device_registry_id_meta = id_meta
|
|
||||||
cls._device_registry_ids = device_ids
|
|
||||||
|
|
||||||
for did in device_ids:
|
|
||||||
if did in _registered_devices:
|
|
||||||
raise ValueError(f"@device id 重复: '{did}' 已被 {_registered_devices[did]} 注册")
|
|
||||||
_registered_devices[did] = cls
|
|
||||||
|
|
||||||
return cls
|
|
||||||
|
|
||||||
return decorator
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# @action 方法装饰器
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# 区分 "用户没传 action_type" 和 "用户传了 None"
|
|
||||||
_ACTION_TYPE_UNSET = object()
|
|
||||||
|
|
||||||
|
|
||||||
# noinspection PyShadowingNames
|
|
||||||
def action(
|
|
||||||
action_type: Any = _ACTION_TYPE_UNSET,
|
|
||||||
goal: Optional[Dict[str, str]] = None,
|
|
||||||
feedback: Optional[Dict[str, str]] = None,
|
|
||||||
result: Optional[Dict[str, str]] = None,
|
|
||||||
handles: Optional[List[_ActionHandleBase]] = None,
|
|
||||||
goal_default: Optional[Dict[str, Any]] = None,
|
|
||||||
placeholder_keys: Optional[Dict[str, str]] = None,
|
|
||||||
always_free: bool = False,
|
|
||||||
is_protocol: bool = False,
|
|
||||||
description: str = "",
|
|
||||||
auto_prefix: bool = False,
|
|
||||||
parent: bool = False,
|
|
||||||
node_type: Optional["NodeType"] = None,
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
动作方法装饰器
|
|
||||||
|
|
||||||
标记方法为注册表动作。有三种用法:
|
|
||||||
1. @action(action_type=EmptyIn, ...) -- 非 auto, 使用指定 ROS Action 类型
|
|
||||||
2. @action() -- 非 auto, UniLabJsonCommand (从方法签名生成 schema)
|
|
||||||
3. 不加 @action -- auto- 前缀, UniLabJsonCommand
|
|
||||||
|
|
||||||
Protocol 用法:
|
|
||||||
@action(action_type=Add, is_protocol=True)
|
|
||||||
def AddProtocol(self): ...
|
|
||||||
标记该动作为高级协议 (protocol),运行时通过 ROS Action 路由到
|
|
||||||
protocol generator 执行。action_type 指向 unilabos_msgs 的 Action 类型。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
action_type: ROS Action 消息类型 (如 EmptyIn, SendCmd, HeatChill).
|
|
||||||
不传/默认 = UniLabJsonCommand (非 auto).
|
|
||||||
goal: Goal 字段映射 (ROS字段名 -> 设备参数名).
|
|
||||||
protocol 模式下可留空,系统自动生成 identity 映射.
|
|
||||||
feedback: Feedback 字段映射
|
|
||||||
result: Result 字段映射
|
|
||||||
handles: 动作端口列表 (ActionInputHandle / ActionOutputHandle)
|
|
||||||
goal_default: Goal 字段默认值映射 (字段名 -> 默认值), 与自动生成的 goal_default 合并
|
|
||||||
placeholder_keys: 参数占位符配置
|
|
||||||
always_free: 是否为永久闲置动作 (不受排队限制)
|
|
||||||
is_protocol: 是否为工作站协议 (protocol)。True 时运行时走 protocol generator 路径。
|
|
||||||
description: 动作描述
|
|
||||||
auto_prefix: 若为 True,动作名使用 auto-{method_name} 形式(与无 @action 时一致)
|
|
||||||
parent: 若为 True,当方法参数为空 (*args, **kwargs) 时,通过 MRO 从父类获取真实方法参数
|
|
||||||
node_type: 动作的节点类型 (NodeType.ILAB / NodeType.MANUAL_CONFIRM)。
|
|
||||||
不填写时不写入注册表。
|
|
||||||
"""
|
|
||||||
|
|
||||||
def decorator(func: F) -> F:
|
|
||||||
@wraps(func)
|
|
||||||
def wrapper(*args, **kwargs):
|
|
||||||
return func(*args, **kwargs)
|
|
||||||
|
|
||||||
# action_type 为哨兵值 => 用户没传, 视为 None (UniLabJsonCommand)
|
|
||||||
resolved_type = None if action_type is _ACTION_TYPE_UNSET else action_type
|
|
||||||
|
|
||||||
meta = {
|
|
||||||
"action_type": resolved_type,
|
|
||||||
"goal": goal or {},
|
|
||||||
"feedback": feedback or {},
|
|
||||||
"result": result or {},
|
|
||||||
"handles": _action_handles_to_dict(handles),
|
|
||||||
"goal_default": goal_default or {},
|
|
||||||
"placeholder_keys": placeholder_keys or {},
|
|
||||||
"always_free": always_free,
|
|
||||||
"is_protocol": is_protocol,
|
|
||||||
"description": description,
|
|
||||||
"auto_prefix": auto_prefix,
|
|
||||||
"parent": parent,
|
|
||||||
}
|
|
||||||
if node_type is not None:
|
|
||||||
meta["node_type"] = node_type.value if isinstance(node_type, NodeType) else str(node_type)
|
|
||||||
wrapper._action_registry_meta = meta # type: ignore[attr-defined]
|
|
||||||
|
|
||||||
# 设置 _is_always_free 保持与旧 @always_free 装饰器兼容
|
|
||||||
if always_free:
|
|
||||||
wrapper._is_always_free = True # type: ignore[attr-defined]
|
|
||||||
|
|
||||||
return wrapper # type: ignore[return-value]
|
|
||||||
|
|
||||||
return decorator
|
|
||||||
|
|
||||||
|
|
||||||
def get_action_meta(func) -> Optional[Dict[str, Any]]:
|
|
||||||
"""获取方法上的 @action 装饰器元数据"""
|
|
||||||
return getattr(func, "_action_registry_meta", None)
|
|
||||||
|
|
||||||
|
|
||||||
def has_action_decorator(func) -> bool:
|
|
||||||
"""检查函数是否带有 @action 装饰器"""
|
|
||||||
return hasattr(func, "_action_registry_meta")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# @resource 类/函数装饰器
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def resource(
|
|
||||||
id: str,
|
|
||||||
category: List[str],
|
|
||||||
description: str = "",
|
|
||||||
icon: str = "",
|
|
||||||
version: str = "1.0.0",
|
|
||||||
handles: Optional[List[_DeviceHandleBase]] = None,
|
|
||||||
model: Optional[Dict[str, Any]] = None,
|
|
||||||
class_type: str = "pylabrobot",
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
资源类/函数装饰器
|
|
||||||
|
|
||||||
将类或工厂函数标记为一个 UniLab-OS 资源,附加注册表元数据。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
id: 注册表唯一标识 (必填, 不可重复)
|
|
||||||
category: 资源分类标签列表 (必填)
|
|
||||||
description: 资源描述
|
|
||||||
icon: 图标路径
|
|
||||||
version: 版本号
|
|
||||||
handles: 端口列表 (InputHandle / OutputHandle)
|
|
||||||
model: 可选的 3D 模型配置
|
|
||||||
class_type: 资源实现类型 ("python" / "pylabrobot" / "unilabos")
|
|
||||||
"""
|
|
||||||
|
|
||||||
def decorator(obj):
|
|
||||||
meta = {
|
|
||||||
"resource_id": id,
|
|
||||||
"category": category,
|
|
||||||
"description": description,
|
|
||||||
"icon": icon,
|
|
||||||
"version": version,
|
|
||||||
"handles": _device_handles_to_list(handles),
|
|
||||||
"model": model,
|
|
||||||
"class_type": class_type,
|
|
||||||
}
|
|
||||||
obj._resource_registry_meta = meta
|
|
||||||
|
|
||||||
if id in _registered_resources:
|
|
||||||
raise ValueError(f"@resource id 重复: '{id}' 已被 {_registered_resources[id]} 注册")
|
|
||||||
_registered_resources[id] = obj
|
|
||||||
|
|
||||||
return obj
|
|
||||||
|
|
||||||
return decorator
|
|
||||||
|
|
||||||
|
|
||||||
def get_device_meta(cls, device_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
获取类上的 @device 装饰器元数据。
|
|
||||||
|
|
||||||
当 device_id 存在且类使用 ids+id_meta 时,返回合并后的 meta
|
|
||||||
(base_meta 与 id_meta[device_id] 深度合并)。
|
|
||||||
"""
|
|
||||||
base = getattr(cls, "_device_registry_meta", None)
|
|
||||||
if base is None:
|
|
||||||
return None
|
|
||||||
id_meta = getattr(cls, "_device_registry_id_meta", None) or {}
|
|
||||||
if device_id is None or device_id not in id_meta:
|
|
||||||
result = dict(base)
|
|
||||||
ids = getattr(cls, "_device_registry_ids", None)
|
|
||||||
result["device_id"] = device_id if device_id is not None else (ids[0] if ids else None)
|
|
||||||
return result
|
|
||||||
|
|
||||||
overrides = id_meta[device_id]
|
|
||||||
result = dict(base)
|
|
||||||
result["device_id"] = device_id
|
|
||||||
for key in ["handles", "description", "icon", "model"]:
|
|
||||||
if key in overrides:
|
|
||||||
val = overrides[key]
|
|
||||||
if key == "handles" and isinstance(val, list):
|
|
||||||
# handles 必须是 Handle 对象列表
|
|
||||||
result[key] = [h.to_registry_dict() for h in val]
|
|
||||||
else:
|
|
||||||
result[key] = val
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def get_resource_meta(obj) -> Optional[Dict[str, Any]]:
|
|
||||||
"""获取对象上的 @resource 装饰器元数据"""
|
|
||||||
return getattr(obj, "_resource_registry_meta", None)
|
|
||||||
|
|
||||||
|
|
||||||
def get_all_registered_devices() -> Dict[str, type]:
|
|
||||||
"""获取所有已注册的设备类"""
|
|
||||||
return _registered_devices.copy()
|
|
||||||
|
|
||||||
|
|
||||||
def get_all_registered_resources() -> Dict[str, Any]:
|
|
||||||
"""获取所有已注册的资源"""
|
|
||||||
return _registered_resources.copy()
|
|
||||||
|
|
||||||
|
|
||||||
def clear_registry():
|
|
||||||
"""清空全局注册表 (用于测试)"""
|
|
||||||
_registered_devices.clear()
|
|
||||||
_registered_resources.clear()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 枚举值归一化
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_enum_value(raw: Any, enum_cls) -> Optional[str]:
|
|
||||||
"""将 AST 提取的枚举成员名 / YAML 值字符串 / 旧格式长路径统一归一化为枚举值。
|
|
||||||
|
|
||||||
适用于 Side、DataSource、NodeType 等继承自 ``str, Enum`` 的装饰器枚举。
|
|
||||||
|
|
||||||
处理以下格式:
|
|
||||||
- "MANUAL_CONFIRM" → NodeType["MANUAL_CONFIRM"].value = "manual_confirm"
|
|
||||||
- "manual_confirm" → NodeType("manual_confirm").value = "manual_confirm"
|
|
||||||
- "HANDLE" → DataSource["HANDLE"].value = "handle"
|
|
||||||
- "NORTH" → Side["NORTH"].value = "NORTH"
|
|
||||||
- 旧缓存长路径 "unilabos...NodeType.MANUAL_CONFIRM" → 先 rsplit 再查找
|
|
||||||
"""
|
|
||||||
if not raw:
|
|
||||||
return None
|
|
||||||
raw_str = str(raw)
|
|
||||||
if "." in raw_str:
|
|
||||||
raw_str = raw_str.rsplit(".", 1)[-1]
|
|
||||||
try:
|
|
||||||
return enum_cls[raw_str].value
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
return enum_cls(raw_str).value
|
|
||||||
except ValueError:
|
|
||||||
return raw_str
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# topic_config / not_action / always_free 装饰器
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def topic_config(
|
|
||||||
period: Optional[float] = None,
|
|
||||||
print_publish: Optional[bool] = None,
|
|
||||||
qos: Optional[int] = None,
|
|
||||||
name: Optional[str] = None,
|
|
||||||
) -> Callable[[F], F]:
|
|
||||||
"""
|
|
||||||
Topic发布配置装饰器
|
|
||||||
|
|
||||||
用于装饰 get_{attr_name} 方法或 @property,控制对应属性的ROS topic发布行为。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
period: 发布周期(秒)。None 表示使用默认值 5.0
|
|
||||||
print_publish: 是否打印发布日志。None 表示使用节点默认配置
|
|
||||||
qos: QoS深度配置。None 表示使用默认值 10
|
|
||||||
name: 自定义发布名称。None 表示使用方法名(去掉 get_ 前缀)
|
|
||||||
|
|
||||||
Note:
|
|
||||||
与 @property 连用时,@topic_config 必须放在 @property 下面,
|
|
||||||
这样装饰器执行顺序为:先 topic_config 添加配置,再 property 包装。
|
|
||||||
"""
|
|
||||||
|
|
||||||
def decorator(func: F) -> F:
|
|
||||||
@wraps(func)
|
|
||||||
def wrapper(*args, **kwargs):
|
|
||||||
return func(*args, **kwargs)
|
|
||||||
|
|
||||||
wrapper._topic_period = period # type: ignore[attr-defined]
|
|
||||||
wrapper._topic_print_publish = print_publish # type: ignore[attr-defined]
|
|
||||||
wrapper._topic_qos = qos # type: ignore[attr-defined]
|
|
||||||
wrapper._topic_name = name # type: ignore[attr-defined]
|
|
||||||
wrapper._has_topic_config = True # type: ignore[attr-defined]
|
|
||||||
|
|
||||||
return wrapper # type: ignore[return-value]
|
|
||||||
|
|
||||||
return decorator
|
|
||||||
|
|
||||||
|
|
||||||
def get_topic_config(func) -> dict:
|
|
||||||
"""获取函数上的 topic 配置 (period, print_publish, qos, name)"""
|
|
||||||
if hasattr(func, "_has_topic_config") and getattr(func, "_has_topic_config", False):
|
|
||||||
return {
|
|
||||||
"period": getattr(func, "_topic_period", None),
|
|
||||||
"print_publish": getattr(func, "_topic_print_publish", None),
|
|
||||||
"qos": getattr(func, "_topic_qos", None),
|
|
||||||
"name": getattr(func, "_topic_name", None),
|
|
||||||
}
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def always_free(func: F) -> F:
|
|
||||||
"""
|
|
||||||
标记动作为永久闲置(不受busy队列限制)的装饰器
|
|
||||||
|
|
||||||
被此装饰器标记的 action 方法,在执行时不会受到设备级别的排队限制,
|
|
||||||
任何时候请求都可以立即执行。适用于查询类、状态读取类等轻量级操作。
|
|
||||||
"""
|
|
||||||
|
|
||||||
@wraps(func)
|
|
||||||
def wrapper(*args, **kwargs):
|
|
||||||
return func(*args, **kwargs)
|
|
||||||
|
|
||||||
wrapper._is_always_free = True # type: ignore[attr-defined]
|
|
||||||
|
|
||||||
return wrapper # type: ignore[return-value]
|
|
||||||
|
|
||||||
|
|
||||||
def is_always_free(func) -> bool:
|
|
||||||
"""检查函数是否被标记为永久闲置"""
|
|
||||||
return getattr(func, "_is_always_free", False)
|
|
||||||
|
|
||||||
|
|
||||||
def not_action(func: F) -> F:
|
|
||||||
"""
|
|
||||||
标记方法为非动作的装饰器
|
|
||||||
|
|
||||||
用于装饰 driver 类中的方法,使其在注册表扫描时不被识别为动作。
|
|
||||||
适用于辅助方法、内部工具方法等不应暴露为设备动作的公共方法。
|
|
||||||
"""
|
|
||||||
|
|
||||||
@wraps(func)
|
|
||||||
def wrapper(*args, **kwargs):
|
|
||||||
return func(*args, **kwargs)
|
|
||||||
|
|
||||||
wrapper._is_not_action = True # type: ignore[attr-defined]
|
|
||||||
|
|
||||||
return wrapper # type: ignore[return-value]
|
|
||||||
|
|
||||||
|
|
||||||
def is_not_action(func) -> bool:
|
|
||||||
"""检查函数是否被标记为非动作"""
|
|
||||||
return getattr(func, "_is_not_action", False)
|
|
||||||
@@ -13,18 +13,21 @@ Qone_nmr:
|
|||||||
description: ''
|
description: ''
|
||||||
properties:
|
properties:
|
||||||
feedback:
|
feedback:
|
||||||
additionalProperties: true
|
properties: {}
|
||||||
|
required: []
|
||||||
title: EmptyIn_Feedback
|
title: EmptyIn_Feedback
|
||||||
type: object
|
type: object
|
||||||
goal:
|
goal:
|
||||||
additionalProperties: true
|
properties: {}
|
||||||
|
required: []
|
||||||
title: EmptyIn_Goal
|
title: EmptyIn_Goal
|
||||||
type: object
|
type: object
|
||||||
result:
|
result:
|
||||||
additionalProperties: false
|
|
||||||
properties:
|
properties:
|
||||||
return_info:
|
return_info:
|
||||||
type: string
|
type: string
|
||||||
|
required:
|
||||||
|
- return_info
|
||||||
title: EmptyIn_Result
|
title: EmptyIn_Result
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
@@ -68,6 +71,31 @@ Qone_nmr:
|
|||||||
title: monitor_folder_for_new_content参数
|
title: monitor_folder_for_new_content参数
|
||||||
type: object
|
type: object
|
||||||
type: UniLabJsonCommand
|
type: UniLabJsonCommand
|
||||||
|
auto-post_init:
|
||||||
|
feedback: {}
|
||||||
|
goal: {}
|
||||||
|
goal_default:
|
||||||
|
ros_node: null
|
||||||
|
handles: {}
|
||||||
|
placeholder_keys: {}
|
||||||
|
result: {}
|
||||||
|
schema:
|
||||||
|
description: ''
|
||||||
|
properties:
|
||||||
|
feedback: {}
|
||||||
|
goal:
|
||||||
|
properties:
|
||||||
|
ros_node:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- ros_node
|
||||||
|
type: object
|
||||||
|
result: {}
|
||||||
|
required:
|
||||||
|
- goal
|
||||||
|
title: post_init参数
|
||||||
|
type: object
|
||||||
|
type: UniLabJsonCommand
|
||||||
auto-strings_to_txt:
|
auto-strings_to_txt:
|
||||||
feedback: {}
|
feedback: {}
|
||||||
goal: {}
|
goal: {}
|
||||||
@@ -110,18 +138,21 @@ Qone_nmr:
|
|||||||
description: ''
|
description: ''
|
||||||
properties:
|
properties:
|
||||||
feedback:
|
feedback:
|
||||||
additionalProperties: true
|
properties: {}
|
||||||
|
required: []
|
||||||
title: EmptyIn_Feedback
|
title: EmptyIn_Feedback
|
||||||
type: object
|
type: object
|
||||||
goal:
|
goal:
|
||||||
additionalProperties: true
|
properties: {}
|
||||||
|
required: []
|
||||||
title: EmptyIn_Goal
|
title: EmptyIn_Goal
|
||||||
type: object
|
type: object
|
||||||
result:
|
result:
|
||||||
additionalProperties: false
|
|
||||||
properties:
|
properties:
|
||||||
return_info:
|
return_info:
|
||||||
type: string
|
type: string
|
||||||
|
required:
|
||||||
|
- return_info
|
||||||
title: EmptyIn_Result
|
title: EmptyIn_Result
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
@@ -136,31 +167,32 @@ Qone_nmr:
|
|||||||
goal_default:
|
goal_default:
|
||||||
string: ''
|
string: ''
|
||||||
handles: {}
|
handles: {}
|
||||||
placeholder_keys: {}
|
result: {}
|
||||||
result:
|
|
||||||
return_info: return_info
|
|
||||||
success: success
|
|
||||||
schema:
|
schema:
|
||||||
description: ''
|
description: ''
|
||||||
properties:
|
properties:
|
||||||
feedback:
|
feedback:
|
||||||
additionalProperties: true
|
properties: {}
|
||||||
|
required: []
|
||||||
title: StrSingleInput_Feedback
|
title: StrSingleInput_Feedback
|
||||||
type: object
|
type: object
|
||||||
goal:
|
goal:
|
||||||
additionalProperties: false
|
|
||||||
properties:
|
properties:
|
||||||
string:
|
string:
|
||||||
type: string
|
type: string
|
||||||
|
required:
|
||||||
|
- string
|
||||||
title: StrSingleInput_Goal
|
title: StrSingleInput_Goal
|
||||||
type: object
|
type: object
|
||||||
result:
|
result:
|
||||||
additionalProperties: false
|
|
||||||
properties:
|
properties:
|
||||||
return_info:
|
return_info:
|
||||||
type: string
|
type: string
|
||||||
success:
|
success:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
required:
|
||||||
|
- return_info
|
||||||
|
- success
|
||||||
title: StrSingleInput_Result
|
title: StrSingleInput_Result
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user