mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-04-26 19:39:54 +00:00
Compare commits
111 Commits
68029217de
...
prcix9320
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acb2dc9359 | ||
|
|
f22c3f4c42 | ||
|
|
7df67ea9f3 | ||
|
|
4d3a41ed0d | ||
|
|
58997f0654 | ||
|
|
fbfc3e30fb | ||
|
|
1d1c1367df | ||
|
|
56d25b88bd | ||
|
|
95f3e0b291 | ||
|
|
c91b600e90 | ||
|
|
49b3c850f9 | ||
|
|
9b706236f6 | ||
|
|
9f60e65b6d | ||
|
|
59aa991988 | ||
|
|
aff340de84 | ||
|
|
25c94af755 | ||
|
|
2fd4270831 | ||
|
|
0d41d83ce5 | ||
|
|
9a6f744afd | ||
|
|
8164d990cc | ||
|
|
5c9c8a4ee9 | ||
|
|
68ef739f4a | ||
|
|
29a484f16f | ||
|
|
a48985720c | ||
|
|
14cf4ddc0d | ||
|
|
ad66fc1841 | ||
|
|
6b3f9756a0 | ||
|
|
afddc6e40c | ||
|
|
861a012747 | ||
|
|
edd67e4880 | ||
|
|
d13d3f7dfe | ||
|
|
ee63e95f50 | ||
|
|
1ab1ed69d4 | ||
|
|
ad2e5a1c04 | ||
|
|
71d35d31af | ||
|
|
7f4b57f589 | ||
|
|
0c667e68e6 | ||
|
|
9430be51a4 | ||
|
|
a187a57430 | ||
|
|
dbf5df6e4d | ||
|
|
f10c0343ce | ||
|
|
8b6553bdd9 | ||
|
|
04c0564366 | ||
|
|
e7a4afd6b5 | ||
|
|
f18f6d82fc | ||
|
|
b7c726635c | ||
|
|
c809912fd3 | ||
|
|
d956b27e9f | ||
|
|
ff1e21fcd8 | ||
|
|
b9d9666003 | ||
|
|
d776550a4b | ||
|
|
3d8123849a | ||
|
|
9d65718f37 | ||
|
|
35bcf6765d | ||
|
|
d2f204c5b0 | ||
|
|
d8922884b1 | ||
|
|
427afe83d4 | ||
|
|
23c2e3b2f7 | ||
|
|
59c26265e9 | ||
|
|
4c2adea55a | ||
|
|
0f6264503a | ||
|
|
2c554182d3 | ||
|
|
cdbca70222 | ||
|
|
1a267729e4 | ||
|
|
b11f6eac55 | ||
|
|
d85ff540c4 | ||
|
|
6d319d91ff | ||
|
|
3155b2f97e | ||
|
|
e5e30a1c7d | ||
|
|
4e82f62327 | ||
|
|
95d3456214 | ||
|
|
38bf95b13c | ||
|
|
5f45a0b81b | ||
|
|
f2c0bec02c | ||
|
|
e0394bf414 | ||
|
|
975a56415a | ||
|
|
cadbe87e3f | ||
|
|
b993c1f590 | ||
|
|
e0fae94c10 | ||
|
|
b5cd181ac1 | ||
|
|
6bf9a319c7 | ||
|
|
5c047beb83 | ||
|
|
b40c087143 | ||
|
|
74f0d5ee65 | ||
|
|
7f1cc3b2a5 | ||
|
|
2596d48a2f | ||
|
|
3f160c2049 | ||
|
|
2ac1a3242a | ||
|
|
5d208c832b | ||
|
|
786498904d | ||
|
|
a9ea9f425d | ||
|
|
b3bc951cae | ||
|
|
01df4f1115 | ||
|
|
a54e7c0f23 | ||
|
|
e5015cd5e0 | ||
|
|
ca985f92ab | ||
|
|
514373c164 | ||
|
|
fcea02585a | ||
|
|
e1074f06d2 | ||
|
|
0dc273f366 | ||
|
|
2e5fac26b3 | ||
|
|
07cf690897 | ||
|
|
cfea27460a | ||
|
|
b7d3e980a9 | ||
|
|
5c2da9b793 | ||
|
|
45efbfcd12 | ||
|
|
8da6fdfd0b | ||
|
|
29ea9909a5 | ||
|
|
f9ed6cb3fb | ||
|
|
ee6307a568 | ||
|
|
8a0116c852 |
@@ -2,7 +2,7 @@
|
||||
|
||||
package:
|
||||
name: unilabos-env
|
||||
version: 0.10.17
|
||||
version: 0.10.19
|
||||
|
||||
build:
|
||||
noarch: generic
|
||||
|
||||
160
.cursor/skills/add-device/SKILL.md
Normal file
160
.cursor/skills/add-device/SKILL.md
Normal file
@@ -0,0 +1,160 @@
|
||||
---
|
||||
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>/` 目录下
|
||||
351
.cursor/skills/add-resource/SKILL.md
Normal file
351
.cursor/skills/add-resource/SKILL.md
Normal file
@@ -0,0 +1,351 @@
|
||||
---
|
||||
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` |
|
||||
292
.cursor/skills/add-resource/reference.md
Normal file
292
.cursor/skills/add-resource/reference.md
Normal file
@@ -0,0 +1,292 @@
|
||||
# 资源高级参考
|
||||
|
||||
本文件是 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` |
|
||||
626
.cursor/skills/add-workstation/SKILL.md
Normal file
626
.cursor/skills/add-workstation/SKILL.md
Normal file
@@ -0,0 +1,626 @@
|
||||
---
|
||||
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/` 目录下各工作站实现。
|
||||
371
.cursor/skills/add-workstation/reference.md
Normal file
371
.cursor/skills/add-workstation/reference.md
Normal file
@@ -0,0 +1,371 @@
|
||||
# 工作站高级模式参考
|
||||
|
||||
本文件是 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)
|
||||
```
|
||||
233
.cursor/skills/batch-insert-reagent/SKILL.md
Normal file
233
.cursor/skills/batch-insert-reagent/SKILL.md
Normal file
@@ -0,0 +1,233 @@
|
||||
---
|
||||
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"}
|
||||
```
|
||||
325
.cursor/skills/batch-submit-experiment/SKILL.md
Normal file
325
.cursor/skills/batch-submit-experiment/SKILL.md
Normal file
@@ -0,0 +1,325 @@
|
||||
---
|
||||
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`)中查找。
|
||||
@@ -0,0 +1,395 @@
|
||||
#!/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()
|
||||
380
.cursor/skills/create-device-skill/SKILL.md
Normal file
380
.cursor/skills/create-device-skill/SKILL.md
Normal file
@@ -0,0 +1,380 @@
|
||||
---
|
||||
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
|
||||
└── ...
|
||||
```
|
||||
@@ -0,0 +1,200 @@
|
||||
#!/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()
|
||||
69
.cursor/skills/create-device-skill/scripts/gen_auth.py
Normal file
69
.cursor/skills/create-device-skill/scripts/gen_auth.py
Normal file
@@ -0,0 +1,69 @@
|
||||
#!/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()
|
||||
275
.cursor/skills/submit-agent-result/SKILL.md
Normal file
275
.cursor/skills/submit-agent-result/SKILL.md
Normal file
@@ -0,0 +1,275 @@
|
||||
---
|
||||
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>` 替代。
|
||||
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
读取实验结果文件(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()
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -253,8 +253,4 @@ test_config.py
|
||||
|
||||
|
||||
/.claude
|
||||
/.conda
|
||||
/.cursor
|
||||
/.github
|
||||
/.conda/base
|
||||
.conda/base/recipe.yaml
|
||||
|
||||
1331
docs/moveit2_integration_summary.md
Normal file
1331
docs/moveit2_integration_summary.md
Normal file
File diff suppressed because it is too large
Load Diff
539
test/devices/test_prcxi.py
Normal file
539
test/devices/test_prcxi.py
Normal file
@@ -0,0 +1,539 @@
|
||||
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"])
|
||||
@@ -39,6 +39,11 @@ class FakeLiquidHandler(LiquidHandlerAbstract):
|
||||
self.current_tip = iter(make_tip_iter())
|
||||
self.calls: List[Tuple[str, Any]] = []
|
||||
|
||||
def set_tiprack(self, tip_racks):
|
||||
if not tip_racks:
|
||||
return
|
||||
super().set_tiprack(tip_racks)
|
||||
|
||||
async def pick_up_tips(self, tip_spots, use_channels=None, offsets=None, **backend_kwargs):
|
||||
self.calls.append(("pick_up_tips", {"tips": list(tip_spots), "use_channels": use_channels}))
|
||||
|
||||
|
||||
@@ -39,6 +39,12 @@ class FakeLiquidHandler(LiquidHandlerAbstract):
|
||||
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}))
|
||||
|
||||
@@ -545,3 +551,58 @@ def test_mix_multiple_targets_supports_per_target_offsets():
|
||||
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]
|
||||
|
||||
|
||||
|
||||
@@ -80,19 +80,20 @@ class HTTPClient:
|
||||
f.write(json.dumps(payload, indent=4))
|
||||
# 从序列化数据中提取所有节点的UUID(保存旧UUID)
|
||||
old_uuids = {n.res_content.uuid: n for n in resources.all_nodes}
|
||||
nodes_info = [x for xs in resources.dump() for x in xs]
|
||||
if not self.initialized or first_add:
|
||||
self.initialized = True
|
||||
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
|
||||
response = requests.post(
|
||||
f"{self.remote_addr}/edge/material",
|
||||
json={"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid},
|
||||
json={"nodes": nodes_info, "mount_uuid": mount_uuid},
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
timeout=60,
|
||||
)
|
||||
else:
|
||||
response = requests.put(
|
||||
f"{self.remote_addr}/edge/material",
|
||||
json={"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid},
|
||||
json={"nodes": nodes_info, "mount_uuid": mount_uuid},
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
timeout=10,
|
||||
)
|
||||
@@ -111,6 +112,7 @@ class HTTPClient:
|
||||
uuid_mapping[i["uuid"]] = i["cloud_uuid"]
|
||||
else:
|
||||
logger.error(f"添加物料失败: {response.text}")
|
||||
logger.trace(f"添加物料失败: {nodes_info}")
|
||||
for u, n in old_uuids.items():
|
||||
if u in uuid_mapping:
|
||||
n.res_content.uuid = uuid_mapping[u]
|
||||
|
||||
@@ -1113,7 +1113,7 @@ class MessageProcessor:
|
||||
"task_id": task_id,
|
||||
"job_id": job_id,
|
||||
"free": free,
|
||||
"need_more": need_more,
|
||||
"need_more": need_more + 1,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1253,7 +1253,7 @@ class QueueProcessor:
|
||||
"task_id": job_info.task_id,
|
||||
"job_id": job_info.job_id,
|
||||
"free": False,
|
||||
"need_more": 10,
|
||||
"need_more": 10 + 1,
|
||||
},
|
||||
}
|
||||
self.message_processor.send_message(message)
|
||||
@@ -1286,7 +1286,7 @@ class QueueProcessor:
|
||||
"task_id": job_info.task_id,
|
||||
"job_id": job_info.job_id,
|
||||
"free": False,
|
||||
"need_more": 10,
|
||||
"need_more": 10 + 1,
|
||||
},
|
||||
}
|
||||
success = self.message_processor.send_message(message)
|
||||
@@ -1369,6 +1369,10 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
self.message_processor = MessageProcessor(self.websocket_url, self.send_queue, self.device_manager)
|
||||
self.queue_processor = QueueProcessor(self.device_manager, self.message_processor)
|
||||
|
||||
# running状态debounce缓存: {job_id: (last_send_timestamp, last_feedback_data)}
|
||||
self._job_running_last_sent: Dict[str, tuple] = {}
|
||||
self._job_running_debounce_interval: float = 10.0 # 秒
|
||||
|
||||
# 设置相互引用
|
||||
self.message_processor.set_queue_processor(self.queue_processor)
|
||||
self.message_processor.set_websocket_client(self)
|
||||
@@ -1468,22 +1472,32 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
logger.debug(f"[WebSocketClient] Not connected, cannot publish job status for job_id: {item.job_id}")
|
||||
return
|
||||
|
||||
job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name)
|
||||
|
||||
# 拦截最终结果状态,与原版本逻辑一致
|
||||
if status in ["success", "failed"]:
|
||||
self._job_running_last_sent.pop(item.job_id, None)
|
||||
|
||||
host_node = HostNode.get_instance(0)
|
||||
if host_node:
|
||||
# 从HostNode的device_action_status中移除job_id
|
||||
try:
|
||||
host_node._device_action_status[item.device_action_key].job_ids.pop(item.job_id, None)
|
||||
except (KeyError, AttributeError):
|
||||
logger.warning(f"[WebSocketClient] Failed to remove job {item.job_id} from HostNode status")
|
||||
|
||||
# logger.debug(f"[WebSocketClient] Intercepting final status for job_id: {item.job_id} - {status}")
|
||||
|
||||
# 通知队列处理器job完成(包括timeout的job)
|
||||
self.queue_processor.handle_job_completed(item.job_id, status)
|
||||
|
||||
# 发送job状态消息
|
||||
# running状态按job_id做debounce,内容变化时仍然上报
|
||||
if status == "running":
|
||||
now = time.time()
|
||||
cached = self._job_running_last_sent.get(item.job_id)
|
||||
if cached is not None:
|
||||
last_ts, last_data = cached
|
||||
if now - last_ts < self._job_running_debounce_interval and last_data == feedback_data:
|
||||
logger.trace(f"[WebSocketClient] Job status debounced (skip): {job_log} - {status}")
|
||||
return
|
||||
self._job_running_last_sent[item.job_id] = (now, feedback_data)
|
||||
|
||||
message = {
|
||||
"action": "job_status",
|
||||
"data": {
|
||||
@@ -1499,7 +1513,6 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
}
|
||||
self.message_processor.send_message(message)
|
||||
|
||||
job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name)
|
||||
logger.trace(f"[WebSocketClient] Job status published: {job_log} - {status}")
|
||||
|
||||
def send_ping(self, ping_id: str, timestamp: float) -> None:
|
||||
|
||||
@@ -201,17 +201,42 @@ class ResourceVisualization:
|
||||
self.moveit_controllers_yaml['moveit_simple_controller_manager'][f"{name}_{controller_name}"] = moveit_dict['moveit_simple_controller_manager'][controller_name]
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _ensure_ros2_env() -> dict:
|
||||
"""确保 ROS2 环境变量正确设置,返回可用于子进程的 env dict"""
|
||||
import sys
|
||||
env = dict(os.environ)
|
||||
conda_prefix = os.path.dirname(os.path.dirname(sys.executable))
|
||||
|
||||
if "AMENT_PREFIX_PATH" not in env or not env["AMENT_PREFIX_PATH"].strip():
|
||||
candidate = os.pathsep.join([conda_prefix, os.path.join(conda_prefix, "Library")])
|
||||
env["AMENT_PREFIX_PATH"] = candidate
|
||||
os.environ["AMENT_PREFIX_PATH"] = candidate
|
||||
|
||||
extra_bin_dirs = [
|
||||
os.path.join(conda_prefix, "Library", "bin"),
|
||||
os.path.join(conda_prefix, "Library", "lib"),
|
||||
os.path.join(conda_prefix, "Scripts"),
|
||||
conda_prefix,
|
||||
]
|
||||
current_path = env.get("PATH", "")
|
||||
for d in extra_bin_dirs:
|
||||
if d not in current_path:
|
||||
current_path = d + os.pathsep + current_path
|
||||
env["PATH"] = current_path
|
||||
os.environ["PATH"] = current_path
|
||||
|
||||
return env
|
||||
|
||||
def create_launch_description(self) -> LaunchDescription:
|
||||
"""
|
||||
创建launch描述,包含robot_state_publisher和move_group节点
|
||||
|
||||
Args:
|
||||
urdf_str: URDF文本
|
||||
|
||||
Returns:
|
||||
LaunchDescription: launch描述对象
|
||||
"""
|
||||
# 检查ROS 2环境变量
|
||||
launch_env = self._ensure_ros2_env()
|
||||
|
||||
if "AMENT_PREFIX_PATH" not in os.environ:
|
||||
raise OSError(
|
||||
"ROS 2环境未正确设置。需要设置 AMENT_PREFIX_PATH 环境变量。\n"
|
||||
@@ -290,7 +315,7 @@ class ResourceVisualization:
|
||||
{"robot_description": robot_description},
|
||||
ros2_controllers,
|
||||
],
|
||||
env=dict(os.environ)
|
||||
env=launch_env,
|
||||
)
|
||||
)
|
||||
for controller in self.moveit_controllers_yaml['moveit_simple_controller_manager']['controller_names']:
|
||||
@@ -300,7 +325,7 @@ class ResourceVisualization:
|
||||
executable="spawner",
|
||||
arguments=[f"{controller}", "--controller-manager", f"controller_manager"],
|
||||
output="screen",
|
||||
env=dict(os.environ)
|
||||
env=launch_env,
|
||||
)
|
||||
)
|
||||
controllers.append(
|
||||
@@ -309,7 +334,7 @@ class ResourceVisualization:
|
||||
executable="spawner",
|
||||
arguments=["joint_state_broadcaster", "--controller-manager", f"controller_manager"],
|
||||
output="screen",
|
||||
env=dict(os.environ)
|
||||
env=launch_env,
|
||||
)
|
||||
)
|
||||
for i in controllers:
|
||||
@@ -317,7 +342,6 @@ class ResourceVisualization:
|
||||
else:
|
||||
ros2_controllers = None
|
||||
|
||||
# 创建robot_state_publisher节点
|
||||
robot_state_publisher = nd(
|
||||
package='robot_state_publisher',
|
||||
executable='robot_state_publisher',
|
||||
@@ -327,9 +351,8 @@ class ResourceVisualization:
|
||||
'robot_description': robot_description,
|
||||
'use_sim_time': False
|
||||
},
|
||||
# kinematics_dict
|
||||
],
|
||||
env=dict(os.environ)
|
||||
env=launch_env,
|
||||
)
|
||||
|
||||
|
||||
@@ -361,7 +384,7 @@ class ResourceVisualization:
|
||||
executable='move_group',
|
||||
output='screen',
|
||||
parameters=moveit_params,
|
||||
env=dict(os.environ)
|
||||
env=launch_env,
|
||||
)
|
||||
|
||||
|
||||
@@ -379,13 +402,11 @@ class ResourceVisualization:
|
||||
arguments=['-d', f"{str(self.mesh_path)}/view_robot.rviz"],
|
||||
output='screen',
|
||||
parameters=[
|
||||
{'robot_description_kinematics': kinematics_dict,
|
||||
},
|
||||
{'robot_description_kinematics': kinematics_dict},
|
||||
robot_description_planning,
|
||||
planning_pipelines,
|
||||
|
||||
],
|
||||
env=dict(os.environ)
|
||||
env=launch_env,
|
||||
)
|
||||
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
150
unilabos/devices/liquid_handling/prcxi/prcxi_modules.py
Normal file
150
unilabos/devices/liquid_handling/prcxi/prcxi_modules.py
Normal file
@@ -0,0 +1,150 @@
|
||||
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,6 +59,7 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend):
|
||||
self.total_height = total_height
|
||||
self.joint_config = kwargs.get("joint_config", None)
|
||||
self.lh_device_id = kwargs.get("lh_device_id", "lh_joint_publisher")
|
||||
self.simulate_rviz = kwargs.get("simulate_rviz", False)
|
||||
if not rclpy.ok():
|
||||
rclpy.init()
|
||||
self.joint_state_publisher = None
|
||||
@@ -69,7 +70,7 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend):
|
||||
self.joint_state_publisher = LiquidHandlerJointPublisher(
|
||||
joint_config=self.joint_config,
|
||||
lh_device_id=self.lh_device_id,
|
||||
simulate_rviz=True)
|
||||
simulate_rviz=self.simulate_rviz)
|
||||
|
||||
# 启动ROS executor
|
||||
self.executor = rclpy.executors.MultiThreadedExecutor()
|
||||
|
||||
@@ -42,6 +42,7 @@ class LiquidHandlerJointPublisher(Node):
|
||||
while self.resource_action is None:
|
||||
self.resource_action = self.check_tf_update_actions()
|
||||
time.sleep(1)
|
||||
self.get_logger().info(f'Waiting for TfUpdate server: {self.resource_action}')
|
||||
|
||||
self.resource_action_client = ActionClient(self, SendCmd, self.resource_action)
|
||||
while not self.resource_action_client.wait_for_server(timeout_sec=1.0):
|
||||
|
||||
@@ -2,6 +2,7 @@ import json
|
||||
import time
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Optional, Sequence
|
||||
|
||||
from moveit_msgs.msg import JointConstraint, Constraints
|
||||
from rclpy.action import ActionClient
|
||||
@@ -171,173 +172,160 @@ class MoveitInterface:
|
||||
|
||||
return True
|
||||
|
||||
def pick_and_place(self, command: str):
|
||||
def pick_and_place(
|
||||
self,
|
||||
option: str,
|
||||
move_group: str,
|
||||
status: str,
|
||||
resource: Optional[str] = None,
|
||||
x_distance: Optional[float] = None,
|
||||
y_distance: Optional[float] = None,
|
||||
lift_height: Optional[float] = None,
|
||||
retry: Optional[int] = None,
|
||||
speed: Optional[float] = None,
|
||||
target: Optional[str] = None,
|
||||
constraints: Optional[Sequence[float]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Using MoveIt to make the robotic arm pick or place materials to a target point.
|
||||
使用 MoveIt 完成抓取/放置等序列(pick/place/side_pick/side_place)。
|
||||
|
||||
Args:
|
||||
command: A JSON-formatted string that includes option, target, speed, lift_height, mt_height
|
||||
|
||||
*option (string) : Action type: pick/place/side_pick/side_place
|
||||
*move_group (string): The move group moveit will plan
|
||||
*status(string) : Target pose
|
||||
resource(string) : The target resource
|
||||
x_distance (float) : The distance to the target in x direction(meters)
|
||||
y_distance (float) : The distance to the target in y direction(meters)
|
||||
lift_height (float) : The height at which the material should be lifted(meters)
|
||||
retry (float) : Retry times when moveit plan fails
|
||||
speed (float) : The speed of the movement, speed > 0
|
||||
Returns:
|
||||
None
|
||||
必选:option, move_group, status。
|
||||
可选:resource, x_distance, y_distance, lift_height, retry, speed, target, constraints。
|
||||
无返回值;失败时提前 return 或打印异常。
|
||||
"""
|
||||
result = SendCmd.Result()
|
||||
|
||||
try:
|
||||
cmd_str = str(command).replace("'", '"')
|
||||
cmd_dict = json.loads(cmd_str)
|
||||
if option not in self.move_option:
|
||||
raise ValueError(f"Invalid option: {option}")
|
||||
|
||||
if cmd_dict["option"] in self.move_option:
|
||||
option_index = self.move_option.index(cmd_dict["option"])
|
||||
place_flag = option_index % 2
|
||||
option_index = self.move_option.index(option)
|
||||
place_flag = option_index % 2
|
||||
|
||||
config = {}
|
||||
function_list = []
|
||||
config: dict = {"move_group": move_group}
|
||||
if speed is not None:
|
||||
config["speed"] = speed
|
||||
if retry is not None:
|
||||
config["retry"] = retry
|
||||
|
||||
status = cmd_dict["status"]
|
||||
joint_positions_ = self.joint_poses[cmd_dict["move_group"]][status]
|
||||
function_list = []
|
||||
joint_positions_ = self.joint_poses[move_group][status]
|
||||
|
||||
config.update({k: cmd_dict[k] for k in ["speed", "retry", "move_group"] if k in cmd_dict})
|
||||
# 夹取 / 放置:绑定 resource 与 parent
|
||||
if not place_flag:
|
||||
if target is not None:
|
||||
function_list.append(lambda r=resource, t=target: self.resource_manager(r, t))
|
||||
else:
|
||||
ee = self.moveit2[move_group].end_effector_name
|
||||
function_list.append(lambda r=resource: self.resource_manager(r, ee))
|
||||
else:
|
||||
function_list.append(lambda r=resource: self.resource_manager(r, "world"))
|
||||
|
||||
# 夹取
|
||||
if not place_flag:
|
||||
if "target" in cmd_dict.keys():
|
||||
function_list.append(lambda: self.resource_manager(cmd_dict["resource"], cmd_dict["target"]))
|
||||
else:
|
||||
function_list.append(
|
||||
lambda: self.resource_manager(
|
||||
cmd_dict["resource"], self.moveit2[cmd_dict["move_group"]].end_effector_name
|
||||
joint_constraint_msgs: list = []
|
||||
if constraints is not None:
|
||||
for i, c in enumerate(constraints):
|
||||
v = float(c)
|
||||
if v > 0:
|
||||
joint_constraint_msgs.append(
|
||||
JointConstraint(
|
||||
joint_name=self.moveit2[move_group].joint_names[i],
|
||||
position=joint_positions_[i],
|
||||
tolerance_above=v,
|
||||
tolerance_below=v,
|
||||
weight=1.0,
|
||||
)
|
||||
)
|
||||
else:
|
||||
function_list.append(lambda: self.resource_manager(cmd_dict["resource"], "world"))
|
||||
|
||||
constraints = []
|
||||
if "constraints" in cmd_dict.keys():
|
||||
if lift_height is not None:
|
||||
retval = None
|
||||
attempts = config.get("retry", 10)
|
||||
while retval is None and attempts > 0:
|
||||
retval = self.moveit2[move_group].compute_fk(joint_positions_)
|
||||
time.sleep(0.1)
|
||||
attempts -= 1
|
||||
if retval is None:
|
||||
raise ValueError("Failed to compute forward kinematics")
|
||||
pose = [retval.pose.position.x, retval.pose.position.y, retval.pose.position.z]
|
||||
quaternion = [
|
||||
retval.pose.orientation.x,
|
||||
retval.pose.orientation.y,
|
||||
retval.pose.orientation.z,
|
||||
retval.pose.orientation.w,
|
||||
]
|
||||
|
||||
for i in range(len(cmd_dict["constraints"])):
|
||||
v = float(cmd_dict["constraints"][i])
|
||||
if v > 0:
|
||||
constraints.append(
|
||||
JointConstraint(
|
||||
joint_name=self.moveit2[cmd_dict["move_group"]].joint_names[i],
|
||||
position=joint_positions_[i],
|
||||
tolerance_above=v,
|
||||
tolerance_below=v,
|
||||
weight=1.0,
|
||||
)
|
||||
)
|
||||
function_list = [
|
||||
lambda: self.moveit_task(
|
||||
position=[retval.pose.position.x, retval.pose.position.y, retval.pose.position.z],
|
||||
quaternion=quaternion,
|
||||
**config,
|
||||
cartesian=self.cartesian_flag,
|
||||
)
|
||||
] + function_list
|
||||
|
||||
if "lift_height" in cmd_dict.keys():
|
||||
retval = None
|
||||
retry = config.get("retry", 10)
|
||||
while retval is None and retry > 0:
|
||||
retval = self.moveit2[cmd_dict["move_group"]].compute_fk(joint_positions_)
|
||||
time.sleep(0.1)
|
||||
retry -= 1
|
||||
if retval is None:
|
||||
result.success = False
|
||||
return result
|
||||
pose = [retval.pose.position.x, retval.pose.position.y, retval.pose.position.z]
|
||||
quaternion = [
|
||||
retval.pose.orientation.x,
|
||||
retval.pose.orientation.y,
|
||||
retval.pose.orientation.z,
|
||||
retval.pose.orientation.w,
|
||||
]
|
||||
pose[2] += float(lift_height)
|
||||
function_list.append(
|
||||
lambda p=pose.copy(), q=quaternion, cfg=config: self.moveit_task(
|
||||
position=p, quaternion=q, **cfg, cartesian=self.cartesian_flag
|
||||
)
|
||||
)
|
||||
end_pose = list(pose)
|
||||
|
||||
if x_distance is not None or y_distance is not None:
|
||||
if x_distance is not None:
|
||||
deep_pose = deepcopy(pose)
|
||||
deep_pose[0] += float(x_distance)
|
||||
elif y_distance is not None:
|
||||
deep_pose = deepcopy(pose)
|
||||
deep_pose[1] += float(y_distance)
|
||||
|
||||
function_list = [
|
||||
lambda: self.moveit_task(
|
||||
position=[retval.pose.position.x, retval.pose.position.y, retval.pose.position.z],
|
||||
quaternion=quaternion,
|
||||
**config,
|
||||
cartesian=self.cartesian_flag,
|
||||
lambda p=pose.copy(), q=quaternion, cfg=config: self.moveit_task(
|
||||
position=p, quaternion=q, **cfg, cartesian=self.cartesian_flag
|
||||
)
|
||||
] + function_list
|
||||
|
||||
pose[2] += float(cmd_dict["lift_height"])
|
||||
function_list.append(
|
||||
lambda: self.moveit_task(
|
||||
position=pose, quaternion=quaternion, **config, cartesian=self.cartesian_flag
|
||||
lambda dp=deep_pose.copy(), q=quaternion, cfg=config: self.moveit_task(
|
||||
position=dp, quaternion=q, **cfg, cartesian=self.cartesian_flag
|
||||
)
|
||||
)
|
||||
end_pose = pose
|
||||
end_pose = list(deep_pose)
|
||||
|
||||
if "x_distance" in cmd_dict.keys() or "y_distance" in cmd_dict.keys():
|
||||
if "x_distance" in cmd_dict.keys():
|
||||
deep_pose = deepcopy(pose)
|
||||
deep_pose[0] += float(cmd_dict["x_distance"])
|
||||
elif "y_distance" in cmd_dict.keys():
|
||||
deep_pose = deepcopy(pose)
|
||||
deep_pose[1] += float(cmd_dict["y_distance"])
|
||||
retval_ik = None
|
||||
attempts_ik = config.get("retry", 10)
|
||||
while retval_ik is None and attempts_ik > 0:
|
||||
retval_ik = self.moveit2[move_group].compute_ik(
|
||||
position=end_pose,
|
||||
quat_xyzw=quaternion,
|
||||
constraints=Constraints(joint_constraints=joint_constraint_msgs),
|
||||
)
|
||||
time.sleep(0.1)
|
||||
attempts_ik -= 1
|
||||
if retval_ik is None:
|
||||
raise ValueError("Failed to compute inverse kinematics")
|
||||
position_ = [
|
||||
retval_ik.position[retval_ik.name.index(i)] for i in self.moveit2[move_group].joint_names
|
||||
]
|
||||
jn = self.moveit2[move_group].joint_names
|
||||
function_list = [
|
||||
lambda pos=position_, names=jn, cfg=config: self.moveit_joint_task(
|
||||
joint_positions=pos, joint_names=names, **cfg
|
||||
)
|
||||
] + function_list
|
||||
else:
|
||||
function_list = [lambda cfg=config, jp=joint_positions_: self.moveit_joint_task(**cfg, joint_positions=jp)] + function_list
|
||||
|
||||
function_list = [
|
||||
lambda: self.moveit_task(
|
||||
position=pose, quaternion=quaternion, **config, cartesian=self.cartesian_flag
|
||||
)
|
||||
] + function_list
|
||||
function_list.append(
|
||||
lambda: self.moveit_task(
|
||||
position=deep_pose, quaternion=quaternion, **config, cartesian=self.cartesian_flag
|
||||
)
|
||||
)
|
||||
end_pose = deep_pose
|
||||
|
||||
retval_ik = None
|
||||
retry = config.get("retry", 10)
|
||||
while retval_ik is None and retry > 0:
|
||||
retval_ik = self.moveit2[cmd_dict["move_group"]].compute_ik(
|
||||
position=end_pose, quat_xyzw=quaternion, constraints=Constraints(joint_constraints=constraints)
|
||||
)
|
||||
time.sleep(0.1)
|
||||
retry -= 1
|
||||
if retval_ik is None:
|
||||
result.success = False
|
||||
return result
|
||||
position_ = [
|
||||
retval_ik.position[retval_ik.name.index(i)]
|
||||
for i in self.moveit2[cmd_dict["move_group"]].joint_names
|
||||
]
|
||||
function_list = [
|
||||
lambda: self.moveit_joint_task(
|
||||
joint_positions=position_,
|
||||
joint_names=self.moveit2[cmd_dict["move_group"]].joint_names,
|
||||
**config,
|
||||
)
|
||||
] + function_list
|
||||
for i in range(len(function_list)):
|
||||
if i == 0:
|
||||
self.cartesian_flag = False
|
||||
else:
|
||||
function_list = [
|
||||
lambda: self.moveit_joint_task(**config, joint_positions=joint_positions_)
|
||||
] + function_list
|
||||
self.cartesian_flag = True
|
||||
|
||||
for i in range(len(function_list)):
|
||||
if i == 0:
|
||||
self.cartesian_flag = False
|
||||
else:
|
||||
self.cartesian_flag = True
|
||||
|
||||
re = function_list[i]()
|
||||
if not re:
|
||||
print(i, re)
|
||||
result.success = False
|
||||
return result
|
||||
result.success = True
|
||||
re = function_list[i]()
|
||||
if not re:
|
||||
print(i, re)
|
||||
raise ValueError(f"Failed to execute moveit task: {i}")
|
||||
|
||||
except Exception as e:
|
||||
print(e)
|
||||
self.cartesian_flag = False
|
||||
result.success = False
|
||||
|
||||
return result
|
||||
raise e
|
||||
|
||||
def set_status(self, command: str):
|
||||
"""
|
||||
|
||||
1
unilabos/labware_manager/__init__.py
Normal file
1
unilabos/labware_manager/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# PRCXI 耗材管理 Web 应用
|
||||
4
unilabos/labware_manager/__main__.py
Normal file
4
unilabos/labware_manager/__main__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""启动入口: python -m unilabos.labware_manager"""
|
||||
from unilabos.labware_manager.app import main
|
||||
|
||||
main()
|
||||
196
unilabos/labware_manager/app.py
Normal file
196
unilabos/labware_manager/app.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""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()
|
||||
451
unilabos/labware_manager/codegen.py
Normal file
451
unilabos/labware_manager/codegen.py
Normal file
@@ -0,0 +1,451 @@
|
||||
"""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)} 个工厂函数)")
|
||||
474
unilabos/labware_manager/importer.py
Normal file
474
unilabos/labware_manager/importer.py
Normal file
@@ -0,0 +1,474 @@
|
||||
"""从现有 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}")
|
||||
1316
unilabos/labware_manager/labware_db.json
Normal file
1316
unilabos/labware_manager/labware_db.json
Normal file
File diff suppressed because it is too large
Load Diff
126
unilabos/labware_manager/models.py
Normal file
126
unilabos/labware_manager/models.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""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)
|
||||
292
unilabos/labware_manager/static/form_handler.js
Normal file
292
unilabos/labware_manager/static/form_handler.js
Normal file
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
450
unilabos/labware_manager/static/labware_viz.js
Normal file
450
unilabos/labware_manager/static/labware_viz.js
Normal file
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
295
unilabos/labware_manager/static/style.css
Normal file
295
unilabos/labware_manager/static/style.css
Normal file
@@ -0,0 +1,295 @@
|
||||
/* 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; }
|
||||
24
unilabos/labware_manager/templates/base.html
Normal file
24
unilabos/labware_manager/templates/base.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<!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>
|
||||
159
unilabos/labware_manager/templates/detail.html
Normal file
159
unilabos/labware_manager/templates/detail.html
Normal file
@@ -0,0 +1,159 @@
|
||||
{% 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 %}
|
||||
257
unilabos/labware_manager/templates/edit.html
Normal file
257
unilabos/labware_manager/templates/edit.html
Normal file
@@ -0,0 +1,257 @@
|
||||
{% 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 %}
|
||||
131
unilabos/labware_manager/templates/index.html
Normal file
131
unilabos/labware_manager/templates/index.html
Normal file
@@ -0,0 +1,131 @@
|
||||
{% 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 %}
|
||||
119
unilabos/labware_manager/yaml_gen.py
Normal file
119
unilabos/labware_manager/yaml_gen.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""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}")
|
||||
@@ -7651,10 +7651,61 @@ liquid_handler.prcxi:
|
||||
title: move_to参数
|
||||
type: object
|
||||
type: UniLabJsonCommandAsync
|
||||
auto-plr_pos_to_prcxi:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
resource: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
resource:
|
||||
type: object
|
||||
required:
|
||||
- resource
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: plr_pos_to_prcxi参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-post_init:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
ros_node: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
ros_node:
|
||||
type: object
|
||||
required:
|
||||
- ros_node
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: post_init参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-run_protocol:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
goal_default:
|
||||
protocol_id: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
@@ -7663,7 +7714,9 @@ liquid_handler.prcxi:
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
properties:
|
||||
protocol_id:
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
@@ -9858,48 +9911,28 @@ liquid_handler.prcxi:
|
||||
type: Transfer
|
||||
transfer_liquid:
|
||||
feedback: {}
|
||||
goal:
|
||||
asp_flow_rates: asp_flow_rates
|
||||
asp_vols: asp_vols
|
||||
blow_out_air_volume: blow_out_air_volume
|
||||
delays: delays
|
||||
dis_flow_rates: dis_flow_rates
|
||||
dis_vols: dis_vols
|
||||
is_96_well: is_96_well
|
||||
liquid_height: liquid_height
|
||||
mix_liquid_height: mix_liquid_height
|
||||
mix_rate: mix_rate
|
||||
mix_stage: mix_stage
|
||||
mix_times: mix_times
|
||||
mix_vol: mix_vol
|
||||
none_keys: none_keys
|
||||
offsets: offsets
|
||||
sources: sources
|
||||
spread: spread
|
||||
targets: targets
|
||||
tip_racks: tip_racks
|
||||
touch_tip: touch_tip
|
||||
use_channels: use_channels
|
||||
goal: {}
|
||||
goal_default:
|
||||
asp_flow_rates: []
|
||||
asp_vols: []
|
||||
blow_out_air_volume: []
|
||||
delays: []
|
||||
dis_flow_rates: []
|
||||
dis_vols: []
|
||||
asp_flow_rates: null
|
||||
asp_vols: null
|
||||
blow_out_air_volume: null
|
||||
blow_out_air_volume_before: null
|
||||
delays: null
|
||||
dis_flow_rates: null
|
||||
dis_vols: null
|
||||
is_96_well: false
|
||||
liquid_height: []
|
||||
mix_liquid_height: 0.0
|
||||
mix_rate: 0
|
||||
mix_stage: ''
|
||||
mix_times: 0
|
||||
mix_vol: 0
|
||||
liquid_height: null
|
||||
mix_liquid_height: null
|
||||
mix_rate: null
|
||||
mix_stage: none
|
||||
mix_times: null
|
||||
mix_vol: null
|
||||
none_keys: []
|
||||
offsets: []
|
||||
sources: []
|
||||
spread: ''
|
||||
targets: []
|
||||
tip_racks: []
|
||||
offsets: null
|
||||
sources: null
|
||||
spread: wide
|
||||
targets: null
|
||||
tip_racks: null
|
||||
touch_tip: false
|
||||
use_channels: []
|
||||
handles:
|
||||
@@ -9940,10 +9973,7 @@ liquid_handler.prcxi:
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: true
|
||||
title: LiquidHandlerTransfer_Feedback
|
||||
type: object
|
||||
feedback: {}
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
@@ -9959,6 +9989,10 @@ liquid_handler.prcxi:
|
||||
items:
|
||||
type: number
|
||||
type: array
|
||||
blow_out_air_volume_before:
|
||||
items:
|
||||
type: number
|
||||
type: array
|
||||
delays:
|
||||
items:
|
||||
type: integer
|
||||
@@ -9972,6 +10006,7 @@ liquid_handler.prcxi:
|
||||
type: number
|
||||
type: array
|
||||
is_96_well:
|
||||
default: false
|
||||
type: boolean
|
||||
liquid_height:
|
||||
items:
|
||||
@@ -9986,6 +10021,7 @@ liquid_handler.prcxi:
|
||||
minimum: -2147483648
|
||||
type: integer
|
||||
mix_stage:
|
||||
default: none
|
||||
type: string
|
||||
mix_times:
|
||||
maximum: 2147483647
|
||||
@@ -9996,6 +10032,7 @@ liquid_handler.prcxi:
|
||||
minimum: -2147483648
|
||||
type: integer
|
||||
none_keys:
|
||||
default: []
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
@@ -10089,6 +10126,7 @@ liquid_handler.prcxi:
|
||||
type: object
|
||||
type: array
|
||||
spread:
|
||||
default: wide
|
||||
type: string
|
||||
targets:
|
||||
items:
|
||||
@@ -10239,27 +10277,228 @@ liquid_handler.prcxi:
|
||||
type: object
|
||||
type: array
|
||||
touch_tip:
|
||||
default: false
|
||||
type: boolean
|
||||
use_channels:
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
title: LiquidHandlerTransfer_Goal
|
||||
required:
|
||||
- sources
|
||||
- targets
|
||||
- tip_racks
|
||||
- asp_vols
|
||||
- dis_vols
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
$defs:
|
||||
ResourceDict:
|
||||
properties:
|
||||
class:
|
||||
description: Resource class name
|
||||
title: Class
|
||||
type: string
|
||||
config:
|
||||
additionalProperties: true
|
||||
description: Resource configuration
|
||||
title: Config
|
||||
type: object
|
||||
data:
|
||||
additionalProperties: true
|
||||
description: 'Resource data, eg: container liquid data'
|
||||
title: Data
|
||||
type: object
|
||||
description:
|
||||
default: ''
|
||||
description: Resource description
|
||||
title: Description
|
||||
type: string
|
||||
extra:
|
||||
additionalProperties: true
|
||||
description: 'Extra data, eg: slot index'
|
||||
title: Extra
|
||||
type: object
|
||||
icon:
|
||||
default: ''
|
||||
description: Resource icon
|
||||
title: Icon
|
||||
type: string
|
||||
id:
|
||||
description: Resource ID
|
||||
title: Id
|
||||
type: string
|
||||
model:
|
||||
additionalProperties: true
|
||||
description: Resource model
|
||||
title: Model
|
||||
type: object
|
||||
name:
|
||||
description: Resource name
|
||||
title: Name
|
||||
type: string
|
||||
parent:
|
||||
anyOf:
|
||||
- $ref: '#/$defs/ResourceDict'
|
||||
- type: 'null'
|
||||
default: null
|
||||
description: Parent resource object
|
||||
parent_uuid:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
default: null
|
||||
description: Parent resource uuid
|
||||
title: Parent Uuid
|
||||
pose:
|
||||
$ref: '#/$defs/ResourceDictPosition'
|
||||
description: Resource position
|
||||
schema:
|
||||
additionalProperties: true
|
||||
description: Resource schema
|
||||
title: Schema
|
||||
type: object
|
||||
type:
|
||||
anyOf:
|
||||
- const: device
|
||||
type: string
|
||||
- type: string
|
||||
description: Resource type
|
||||
title: Type
|
||||
uuid:
|
||||
description: Resource UUID
|
||||
title: Uuid
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- uuid
|
||||
- name
|
||||
- type
|
||||
- class
|
||||
- config
|
||||
- data
|
||||
- extra
|
||||
title: ResourceDict
|
||||
type: object
|
||||
ResourceDictPosition:
|
||||
properties:
|
||||
cross_section_type:
|
||||
default: rectangle
|
||||
description: Cross section type
|
||||
enum:
|
||||
- rectangle
|
||||
- circle
|
||||
- rounded_rectangle
|
||||
title: Cross Section Type
|
||||
type: string
|
||||
layout:
|
||||
default: x-y
|
||||
description: Resource layout
|
||||
enum:
|
||||
- 2d
|
||||
- x-y
|
||||
- z-y
|
||||
- x-z
|
||||
title: Layout
|
||||
type: string
|
||||
position:
|
||||
$ref: '#/$defs/ResourceDictPositionObject'
|
||||
description: Resource position
|
||||
position3d:
|
||||
$ref: '#/$defs/ResourceDictPositionObject'
|
||||
description: Resource position in 3D space
|
||||
rotation:
|
||||
$ref: '#/$defs/ResourceDictPositionObject'
|
||||
description: Resource rotation
|
||||
scale:
|
||||
$ref: '#/$defs/ResourceDictPositionScale'
|
||||
description: Resource scale
|
||||
size:
|
||||
$ref: '#/$defs/ResourceDictPositionSize'
|
||||
description: Resource size
|
||||
title: ResourceDictPosition
|
||||
type: object
|
||||
ResourceDictPositionObject:
|
||||
properties:
|
||||
x:
|
||||
default: 0.0
|
||||
description: X coordinate
|
||||
title: X
|
||||
type: number
|
||||
y:
|
||||
default: 0.0
|
||||
description: Y coordinate
|
||||
title: Y
|
||||
type: number
|
||||
z:
|
||||
default: 0.0
|
||||
description: Z coordinate
|
||||
title: Z
|
||||
type: number
|
||||
title: ResourceDictPositionObject
|
||||
type: object
|
||||
ResourceDictPositionScale:
|
||||
properties:
|
||||
x:
|
||||
default: 0.0
|
||||
description: x scale
|
||||
title: X
|
||||
type: number
|
||||
y:
|
||||
default: 0.0
|
||||
description: y scale
|
||||
title: Y
|
||||
type: number
|
||||
z:
|
||||
default: 0.0
|
||||
description: z scale
|
||||
title: Z
|
||||
type: number
|
||||
title: ResourceDictPositionScale
|
||||
type: object
|
||||
ResourceDictPositionSize:
|
||||
properties:
|
||||
depth:
|
||||
default: 0.0
|
||||
description: Depth
|
||||
title: Depth
|
||||
type: number
|
||||
height:
|
||||
default: 0.0
|
||||
description: Height
|
||||
title: Height
|
||||
type: number
|
||||
width:
|
||||
default: 0.0
|
||||
description: Width
|
||||
title: Width
|
||||
type: number
|
||||
title: ResourceDictPositionSize
|
||||
type: object
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
success:
|
||||
type: boolean
|
||||
title: LiquidHandlerTransfer_Result
|
||||
sources:
|
||||
items:
|
||||
items:
|
||||
$ref: '#/$defs/ResourceDict'
|
||||
type: array
|
||||
title: Sources
|
||||
type: array
|
||||
targets:
|
||||
items:
|
||||
items:
|
||||
$ref: '#/$defs/ResourceDict'
|
||||
type: array
|
||||
title: Targets
|
||||
type: array
|
||||
required:
|
||||
- sources
|
||||
- targets
|
||||
title: TransferLiquidReturn
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: LiquidHandlerTransfer
|
||||
title: transfer_liquid参数
|
||||
type: object
|
||||
type: LiquidHandlerTransfer
|
||||
type: UniLabJsonCommandAsync
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Handler
|
||||
status_types:
|
||||
reset_ok: bool
|
||||
@@ -10282,6 +10521,12 @@ liquid_handler.prcxi:
|
||||
type: string
|
||||
deck:
|
||||
type: object
|
||||
deck_y:
|
||||
default: 400
|
||||
type: string
|
||||
deck_z:
|
||||
default: 300
|
||||
type: string
|
||||
host:
|
||||
type: string
|
||||
is_9320:
|
||||
@@ -10292,17 +10537,44 @@ liquid_handler.prcxi:
|
||||
type: string
|
||||
port:
|
||||
type: integer
|
||||
rail_interval:
|
||||
default: 0
|
||||
type: string
|
||||
rail_nums:
|
||||
default: 4
|
||||
type: string
|
||||
rail_width:
|
||||
default: 27.5
|
||||
type: string
|
||||
setup:
|
||||
default: true
|
||||
type: string
|
||||
simulator:
|
||||
default: false
|
||||
type: string
|
||||
start_rail:
|
||||
default: 2
|
||||
type: string
|
||||
step_mode:
|
||||
default: false
|
||||
type: string
|
||||
timeout:
|
||||
type: number
|
||||
x_increase:
|
||||
default: -0.003636
|
||||
type: string
|
||||
x_offset:
|
||||
default: -0.8
|
||||
type: string
|
||||
xy_coupling:
|
||||
default: -0.0045
|
||||
type: string
|
||||
y_increase:
|
||||
default: -0.003636
|
||||
type: string
|
||||
y_offset:
|
||||
default: -37.98
|
||||
type: string
|
||||
required:
|
||||
- deck
|
||||
- host
|
||||
|
||||
@@ -173,48 +173,64 @@ robotic_arm.SCARA_with_slider.moveit.virtual:
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
pick_and_place:
|
||||
feedback:
|
||||
status: status
|
||||
goal:
|
||||
command: command
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
command: ''
|
||||
constraints: null
|
||||
lift_height: null
|
||||
move_group: null
|
||||
option: null
|
||||
resource: null
|
||||
retry: null
|
||||
speed: null
|
||||
status: null
|
||||
target: null
|
||||
x_distance: null
|
||||
y_distance: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
success: success
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
description: pick_and_place 显式参数(UniLabJsonCommand)
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: false
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
constraints:
|
||||
items:
|
||||
type: number
|
||||
type: array
|
||||
lift_height:
|
||||
type: string
|
||||
move_group:
|
||||
type: string
|
||||
option:
|
||||
type: string
|
||||
resource:
|
||||
type: string
|
||||
retry:
|
||||
type: string
|
||||
speed:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
title: SendCmd_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
command:
|
||||
target:
|
||||
type: string
|
||||
title: SendCmd_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
x_distance:
|
||||
type: string
|
||||
success:
|
||||
type: boolean
|
||||
title: SendCmd_Result
|
||||
y_distance:
|
||||
type: string
|
||||
required:
|
||||
- option
|
||||
- move_group
|
||||
- status
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: SendCmd
|
||||
title: pick_and_place参数
|
||||
type: object
|
||||
type: SendCmd
|
||||
type: UniLabJsonCommand
|
||||
set_position:
|
||||
feedback:
|
||||
status: status
|
||||
|
||||
@@ -684,48 +684,64 @@ linear_motion.toyo_xyz.sim:
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
pick_and_place:
|
||||
feedback:
|
||||
status: status
|
||||
goal:
|
||||
command: command
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
command: ''
|
||||
constraints: null
|
||||
lift_height: null
|
||||
move_group: null
|
||||
option: null
|
||||
resource: null
|
||||
retry: null
|
||||
speed: null
|
||||
status: null
|
||||
target: null
|
||||
x_distance: null
|
||||
y_distance: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
success: success
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
description: pick_and_place 显式参数(UniLabJsonCommand)
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: false
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
constraints:
|
||||
items:
|
||||
type: number
|
||||
type: array
|
||||
lift_height:
|
||||
type: string
|
||||
move_group:
|
||||
type: string
|
||||
option:
|
||||
type: string
|
||||
resource:
|
||||
type: string
|
||||
retry:
|
||||
type: string
|
||||
speed:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
title: SendCmd_Feedback
|
||||
type: object
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
command:
|
||||
target:
|
||||
type: string
|
||||
title: SendCmd_Goal
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
return_info:
|
||||
x_distance:
|
||||
type: string
|
||||
success:
|
||||
type: boolean
|
||||
title: SendCmd_Result
|
||||
y_distance:
|
||||
type: string
|
||||
required:
|
||||
- option
|
||||
- move_group
|
||||
- status
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: SendCmd
|
||||
title: pick_and_place参数
|
||||
type: object
|
||||
type: SendCmd
|
||||
type: UniLabJsonCommand
|
||||
set_position:
|
||||
feedback:
|
||||
status: status
|
||||
|
||||
70
unilabos/registry/resources/prcxi/modules.yaml
Normal file
70
unilabos/registry/resources/prcxi/modules.yaml
Normal file
@@ -0,0 +1,70 @@
|
||||
PRCXI_Heating_Module:
|
||||
category:
|
||||
- prcxi
|
||||
- modules
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_modules:PRCXI_Heating_Module
|
||||
type: pylabrobot
|
||||
description: '加热模块 (Code: HEAT-MOD)'
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
|
||||
PRCXI_MetalCooling_Module:
|
||||
category:
|
||||
- prcxi
|
||||
- modules
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_modules:PRCXI_MetalCooling_Module
|
||||
type: pylabrobot
|
||||
description: '金属冷却模块 (Code: METAL-COOL-MOD)'
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
|
||||
PRCXI_Shaking_Module:
|
||||
category:
|
||||
- prcxi
|
||||
- modules
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_modules:PRCXI_Shaking_Module
|
||||
type: pylabrobot
|
||||
description: '震荡模块 (Code: SHAKE-MOD)'
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
|
||||
PRCXI_Heating_Shaking_Module:
|
||||
category:
|
||||
- prcxi
|
||||
- modules
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_modules:PRCXI_Heating_Shaking_Module
|
||||
type: pylabrobot
|
||||
description: '加热震荡模块 (Code: HEAT-SHAKE-MOD)'
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
|
||||
PRCXI_Magnetic_Module:
|
||||
category:
|
||||
- prcxi
|
||||
- modules
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_modules:PRCXI_Magnetic_Module
|
||||
type: pylabrobot
|
||||
description: '磁吸模块 (Code: MAG-MOD)'
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
|
||||
@@ -3,7 +3,7 @@ PRCXI_30mm_Adapter:
|
||||
- prcxi
|
||||
- plate_adapters
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_30mm_Adapter
|
||||
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_30mm_Adapter'
|
||||
type: pylabrobot
|
||||
description: '30mm适配器 (Code: ZX-58-30)'
|
||||
handles: []
|
||||
@@ -15,7 +15,7 @@ PRCXI_Adapter:
|
||||
- prcxi
|
||||
- plate_adapters
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Adapter
|
||||
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Adapter'
|
||||
type: pylabrobot
|
||||
description: '适配器 (Code: Fhh478)'
|
||||
handles: []
|
||||
@@ -27,7 +27,7 @@ PRCXI_Deep10_Adapter:
|
||||
- prcxi
|
||||
- plate_adapters
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Deep10_Adapter
|
||||
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Deep10_Adapter'
|
||||
type: pylabrobot
|
||||
description: '10ul专用深孔板适配器 (Code: ZX-002-10)'
|
||||
handles: []
|
||||
@@ -39,7 +39,7 @@ PRCXI_Deep300_Adapter:
|
||||
- prcxi
|
||||
- plate_adapters
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Deep300_Adapter
|
||||
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Deep300_Adapter'
|
||||
type: pylabrobot
|
||||
description: '300ul深孔板适配器 (Code: ZX-002-300)'
|
||||
handles: []
|
||||
@@ -51,7 +51,7 @@ PRCXI_PCR_Adapter:
|
||||
- prcxi
|
||||
- plate_adapters
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_PCR_Adapter
|
||||
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_PCR_Adapter'
|
||||
type: pylabrobot
|
||||
description: '全裙边 PCR适配器 (Code: ZX-58-0001)'
|
||||
handles: []
|
||||
@@ -63,7 +63,7 @@ PRCXI_Reservoir_Adapter:
|
||||
- prcxi
|
||||
- plate_adapters
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Reservoir_Adapter
|
||||
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Reservoir_Adapter'
|
||||
type: pylabrobot
|
||||
description: '储液槽 适配器 (Code: ZX-ADP-001)'
|
||||
handles: []
|
||||
@@ -75,7 +75,7 @@ PRCXI_Tip10_Adapter:
|
||||
- prcxi
|
||||
- plate_adapters
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Tip10_Adapter
|
||||
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Tip10_Adapter'
|
||||
type: pylabrobot
|
||||
description: '吸头10ul 适配器 (Code: ZX-58-10)'
|
||||
handles: []
|
||||
@@ -87,7 +87,7 @@ PRCXI_Tip1250_Adapter:
|
||||
- prcxi
|
||||
- plate_adapters
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Tip1250_Adapter
|
||||
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Tip1250_Adapter'
|
||||
type: pylabrobot
|
||||
description: 'Tip头适配器 1250uL (Code: ZX-58-1250)'
|
||||
handles: []
|
||||
@@ -99,7 +99,7 @@ PRCXI_Tip300_Adapter:
|
||||
- prcxi
|
||||
- plate_adapters
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Tip300_Adapter
|
||||
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Tip300_Adapter'
|
||||
type: pylabrobot
|
||||
description: 'ZHONGXI 适配器 300uL (Code: ZX-58-300)'
|
||||
handles: []
|
||||
|
||||
@@ -3,7 +3,7 @@ PRCXI_48_DeepWell:
|
||||
- prcxi
|
||||
- plates
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_48_DeepWell
|
||||
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_48_DeepWell'
|
||||
type: pylabrobot
|
||||
description: '48孔深孔板 (Code: 22)'
|
||||
handles: []
|
||||
@@ -15,7 +15,7 @@ PRCXI_96_DeepWell:
|
||||
- prcxi
|
||||
- plates
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_96_DeepWell
|
||||
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_96_DeepWell'
|
||||
type: pylabrobot
|
||||
description: '96深孔板 (Code: q2)'
|
||||
handles: []
|
||||
@@ -27,7 +27,7 @@ PRCXI_AGenBio_4_troughplate:
|
||||
- prcxi
|
||||
- plates
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_AGenBio_4_troughplate
|
||||
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_AGenBio_4_troughplate'
|
||||
type: pylabrobot
|
||||
description: '4道储液槽 (Code: sdfrth654)'
|
||||
handles: []
|
||||
@@ -39,7 +39,7 @@ PRCXI_BioER_96_wellplate:
|
||||
- prcxi
|
||||
- plates
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_BioER_96_wellplate
|
||||
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_BioER_96_wellplate'
|
||||
type: pylabrobot
|
||||
description: '2.2ml 深孔板 (Code: ZX-019-2.2)'
|
||||
handles: []
|
||||
@@ -51,7 +51,7 @@ PRCXI_BioRad_384_wellplate:
|
||||
- prcxi
|
||||
- plates
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_BioRad_384_wellplate
|
||||
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_BioRad_384_wellplate'
|
||||
type: pylabrobot
|
||||
description: '384板 (Code: q3)'
|
||||
handles: []
|
||||
@@ -63,7 +63,7 @@ PRCXI_CellTreat_96_wellplate:
|
||||
- prcxi
|
||||
- plates
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_CellTreat_96_wellplate
|
||||
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_CellTreat_96_wellplate'
|
||||
type: pylabrobot
|
||||
description: '细菌培养皿 (Code: ZX-78-096)'
|
||||
handles: []
|
||||
@@ -75,7 +75,7 @@ PRCXI_PCR_Plate_200uL_nonskirted:
|
||||
- prcxi
|
||||
- plates
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_PCR_Plate_200uL_nonskirted
|
||||
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_PCR_Plate_200uL_nonskirted'
|
||||
type: pylabrobot
|
||||
description: '0.2ml PCR 板 (Code: ZX-023-0.2)'
|
||||
handles: []
|
||||
@@ -87,7 +87,7 @@ PRCXI_PCR_Plate_200uL_semiskirted:
|
||||
- prcxi
|
||||
- plates
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_PCR_Plate_200uL_semiskirted
|
||||
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_PCR_Plate_200uL_semiskirted'
|
||||
type: pylabrobot
|
||||
description: '0.2ml PCR 板 (Code: ZX-023-0.2)'
|
||||
handles: []
|
||||
@@ -99,7 +99,7 @@ PRCXI_PCR_Plate_200uL_skirted:
|
||||
- prcxi
|
||||
- plates
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_PCR_Plate_200uL_skirted
|
||||
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_PCR_Plate_200uL_skirted'
|
||||
type: pylabrobot
|
||||
description: '0.2ml PCR 板 (Code: ZX-023-0.2)'
|
||||
handles: []
|
||||
@@ -111,7 +111,7 @@ PRCXI_nest_12_troughplate:
|
||||
- prcxi
|
||||
- plates
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_nest_12_troughplate
|
||||
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_nest_12_troughplate'
|
||||
type: pylabrobot
|
||||
description: '12道储液槽 (Code: 12道储液槽)'
|
||||
handles: []
|
||||
@@ -123,7 +123,7 @@ PRCXI_nest_1_troughplate:
|
||||
- prcxi
|
||||
- plates
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_nest_1_troughplate
|
||||
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_nest_1_troughplate'
|
||||
type: pylabrobot
|
||||
description: '储液槽 (Code: ZX-58-10000)'
|
||||
handles: []
|
||||
|
||||
@@ -3,7 +3,7 @@ PRCXI_1000uL_Tips:
|
||||
- prcxi
|
||||
- tip_racks
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_1000uL_Tips
|
||||
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_1000uL_Tips'
|
||||
type: pylabrobot
|
||||
description: '1000μL Tip头 (Code: ZX-001-1000)'
|
||||
handles: []
|
||||
@@ -15,7 +15,7 @@ PRCXI_10uL_Tips:
|
||||
- prcxi
|
||||
- tip_racks
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_10uL_Tips
|
||||
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_10uL_Tips'
|
||||
type: pylabrobot
|
||||
description: '10μL Tip头 (Code: ZX-001-10)'
|
||||
handles: []
|
||||
@@ -27,7 +27,7 @@ PRCXI_10ul_eTips:
|
||||
- prcxi
|
||||
- tip_racks
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_10ul_eTips
|
||||
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_10ul_eTips'
|
||||
type: pylabrobot
|
||||
description: '10μL加长 Tip头 (Code: ZX-001-10+)'
|
||||
handles: []
|
||||
@@ -39,7 +39,7 @@ PRCXI_1250uL_Tips:
|
||||
- prcxi
|
||||
- tip_racks
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_1250uL_Tips
|
||||
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_1250uL_Tips'
|
||||
type: pylabrobot
|
||||
description: '1250μL Tip头 (Code: ZX-001-1250)'
|
||||
handles: []
|
||||
@@ -51,7 +51,7 @@ PRCXI_200uL_Tips:
|
||||
- prcxi
|
||||
- tip_racks
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_200uL_Tips
|
||||
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_200uL_Tips'
|
||||
type: pylabrobot
|
||||
description: '200μL Tip头 (Code: ZX-001-200)'
|
||||
handles: []
|
||||
@@ -63,10 +63,22 @@ PRCXI_300ul_Tips:
|
||||
- prcxi
|
||||
- tip_racks
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_300ul_Tips
|
||||
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_300ul_Tips'
|
||||
type: pylabrobot
|
||||
description: '300μL Tip头 (Code: ZX-001-300)'
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
version: 1.0.0
|
||||
PRCXI_50uL_tips:
|
||||
category:
|
||||
- prcxi
|
||||
- tip_racks
|
||||
class:
|
||||
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_50uL_tips'
|
||||
type: pylabrobot
|
||||
description: PRCXI_50uL_tips
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
version: 1.0.0
|
||||
|
||||
@@ -3,7 +3,7 @@ PRCXI_trash:
|
||||
- prcxi
|
||||
- trash
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_trash
|
||||
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_trash'
|
||||
type: pylabrobot
|
||||
description: '废弃槽 (Code: q1)'
|
||||
handles: []
|
||||
|
||||
@@ -3,7 +3,7 @@ PRCXI_EP_Adapter:
|
||||
- prcxi
|
||||
- tube_racks
|
||||
class:
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_EP_Adapter
|
||||
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_EP_Adapter'
|
||||
type: pylabrobot
|
||||
description: 'ep适配器 (Code: 1)'
|
||||
handles: []
|
||||
|
||||
@@ -9,6 +9,9 @@ def register():
|
||||
from unilabos.devices.liquid_handling.prcxi.prcxi import PRCXI9300TipRack
|
||||
from unilabos.devices.liquid_handling.prcxi.prcxi import PRCXI9300Trash
|
||||
from unilabos.devices.liquid_handling.prcxi.prcxi import PRCXI9300TubeRack
|
||||
from unilabos.devices.liquid_handling.prcxi.prcxi import PRCXI9300ModuleSite
|
||||
# noinspection PyUnresolvedReferences
|
||||
from unilabos.devices.liquid_handling.prcxi.prcxi_modules import PRCXI9300FunctionalModule
|
||||
# noinspection PyUnresolvedReferences
|
||||
from unilabos.devices.workstation.workstation_base import WorkStationContainer
|
||||
|
||||
|
||||
@@ -459,6 +459,8 @@ class ResourceTreeSet(object):
|
||||
"reagent_bottle": "reagent_bottle",
|
||||
"flask": "flask",
|
||||
"beaker": "beaker",
|
||||
"module": "module",
|
||||
"carrier": "carrier",
|
||||
}
|
||||
if source in replace_info:
|
||||
return replace_info[source]
|
||||
@@ -487,7 +489,18 @@ class ResourceTreeSet(object):
|
||||
def resource_plr_inner(
|
||||
d: dict, parent_resource: Optional[ResourceDict], states: dict, uuids: list
|
||||
) -> ResourceDictInstance:
|
||||
current_uuid, parent_uuid, extra = uuids.pop(0)
|
||||
if uuids:
|
||||
current_uuid, parent_uuid, extra = uuids.pop(0)
|
||||
else:
|
||||
# serialize() 树比 res.children 树多出了节点(虚拟子节点等),兜底生成 UUID
|
||||
current_uuid = str(uuid.uuid4())
|
||||
parent_uuid = parent_resource.get("uuid") if isinstance(parent_resource, dict) else (
|
||||
getattr(parent_resource, "uuid", None) if parent_resource is not None else None
|
||||
)
|
||||
extra = {}
|
||||
logger.warning(
|
||||
f"from_plr_resources: UUID 列表耗尽,为节点 '{d.get('name', '?')}' 生成临时 UUID {current_uuid}"
|
||||
)
|
||||
|
||||
raw_pos = (
|
||||
{"x": d["location"]["x"], "y": d["location"]["y"], "z": d["location"]["z"]}
|
||||
@@ -596,6 +609,8 @@ class ResourceTreeSet(object):
|
||||
"deck": "Deck",
|
||||
"container": "RegularContainer",
|
||||
"tip_spot": "TipSpot",
|
||||
"module": "PRCXI9300ModuleSite",
|
||||
"carrier": "ItemizedCarrier",
|
||||
}
|
||||
|
||||
def collect_node_data(node: ResourceDictInstance, name_to_uuid: dict, all_states: dict, name_to_extra: dict):
|
||||
@@ -958,6 +973,17 @@ class ResourceTreeSet(object):
|
||||
f"从远端同步了 {added_count} 个物料子树"
|
||||
)
|
||||
else:
|
||||
# 二级是物料
|
||||
if remote_child_name not in local_children_map:
|
||||
# 本地不存在该物料,直接引入
|
||||
remote_child.res_content.parent = local_device.res_content
|
||||
local_device.children.append(remote_child)
|
||||
local_children_map[remote_child_name] = remote_child
|
||||
logger.info(
|
||||
f"物料 '{remote_root_id}/{remote_child_name}': "
|
||||
f"从远端同步了整个子树"
|
||||
)
|
||||
continue
|
||||
# 二级物料已存在,比较三级子节点是否缺失
|
||||
local_material = local_children_map[remote_child_name]
|
||||
local_material_children_map = {child.res_content.name: child for child in
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
# from nt import device_encoding
|
||||
import threading
|
||||
@@ -51,6 +52,7 @@ def main(
|
||||
bridges: List[Any] = [],
|
||||
visual: str = "disable",
|
||||
resources_mesh_config: dict = {},
|
||||
resources_mesh_resource_list: list = [],
|
||||
rclpy_init_args: List[str] = ["--log-level", "debug"],
|
||||
discovery_interval: float = 15.0,
|
||||
) -> None:
|
||||
@@ -61,7 +63,7 @@ def main(
|
||||
rclpy.init(args=rclpy_init_args)
|
||||
else:
|
||||
logger.info("[ROS] rclpy already initialized, reusing context")
|
||||
executor = rclpy.__executor = MultiThreadedExecutor()
|
||||
executor = rclpy.__executor = MultiThreadedExecutor(num_threads=max(os.cpu_count() * 4, 48))
|
||||
# 创建主机节点
|
||||
host_node = HostNode(
|
||||
"host_node",
|
||||
@@ -77,12 +79,12 @@ def main(
|
||||
if visual != "disable":
|
||||
from unilabos.ros.nodes.presets.joint_republisher import JointRepublisher
|
||||
|
||||
# 将 ResourceTreeSet 转换为 list 用于 visual 组件
|
||||
resources_list = (
|
||||
[node.res_content.model_dump(by_alias=True) for node in resources_config.all_nodes]
|
||||
if resources_config
|
||||
else []
|
||||
)
|
||||
# 优先使用从 main.py 传入的完整资源列表(包含所有子资源)
|
||||
if resources_mesh_resource_list:
|
||||
resources_list = resources_mesh_resource_list
|
||||
else:
|
||||
# fallback: 从 ResourceTreeSet 获取
|
||||
resources_list = [node.res_content.model_dump(by_alias=True) for node in resources_config.all_nodes]
|
||||
resource_mesh_manager = ResourceMeshManager(
|
||||
resources_mesh_config,
|
||||
resources_list,
|
||||
@@ -90,7 +92,7 @@ def main(
|
||||
device_id="resource_mesh_manager",
|
||||
device_uuid=str(uuid.uuid4()),
|
||||
)
|
||||
joint_republisher = JointRepublisher("joint_republisher", host_node.resource_tracker)
|
||||
joint_republisher = JointRepublisher("joint_republisher","joint_republisher", host_node.resource_tracker)
|
||||
# lh_joint_pub = LiquidHandlerJointPublisher(
|
||||
# resources_config=resources_list, resource_tracker=host_node.resource_tracker
|
||||
# )
|
||||
@@ -114,6 +116,7 @@ def slave(
|
||||
bridges: List[Any] = [],
|
||||
visual: str = "disable",
|
||||
resources_mesh_config: dict = {},
|
||||
resources_mesh_resource_list: list = [],
|
||||
rclpy_init_args: List[str] = ["--log-level", "debug"],
|
||||
) -> None:
|
||||
"""从节点函数"""
|
||||
@@ -122,7 +125,7 @@ def slave(
|
||||
rclpy.init(args=rclpy_init_args)
|
||||
executor = rclpy.__executor
|
||||
if not executor:
|
||||
executor = rclpy.__executor = MultiThreadedExecutor()
|
||||
executor = rclpy.__executor = MultiThreadedExecutor(num_threads=max(os.cpu_count() * 4, 48))
|
||||
|
||||
# 1.5 启动 executor 线程
|
||||
thread = threading.Thread(target=executor.spin, daemon=True, name="slave_executor_thread")
|
||||
@@ -208,12 +211,12 @@ def slave(
|
||||
if visual != "disable":
|
||||
from unilabos.ros.nodes.presets.joint_republisher import JointRepublisher
|
||||
|
||||
# 将 ResourceTreeSet 转换为 list 用于 visual 组件
|
||||
resources_list = (
|
||||
[node.res_content.model_dump(by_alias=True) for node in resources_config.all_nodes]
|
||||
if resources_config
|
||||
else []
|
||||
)
|
||||
# 优先使用从 main.py 传入的完整资源列表(包含所有子资源)
|
||||
if resources_mesh_resource_list:
|
||||
resources_list = resources_mesh_resource_list
|
||||
else:
|
||||
# fallback: 从 ResourceTreeSet 获取
|
||||
resources_list = [node.res_content.model_dump(by_alias=True) for node in resources_config.all_nodes]
|
||||
resource_mesh_manager = ResourceMeshManager(
|
||||
resources_mesh_config,
|
||||
resources_list,
|
||||
|
||||
@@ -486,18 +486,12 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
if len(rts.root_nodes) == 1 and parent_resource is not None:
|
||||
plr_instance = plr_instances[0]
|
||||
if isinstance(plr_instance, Plate):
|
||||
empty_liquid_info_in: List[Tuple[Optional[str], float]] = [(None, 0)] * plr_instance.num_items
|
||||
if len(ADD_LIQUID_TYPE) == 1 and len(LIQUID_VOLUME) == 1 and len(LIQUID_INPUT_SLOT) > 1:
|
||||
ADD_LIQUID_TYPE = ADD_LIQUID_TYPE * len(LIQUID_INPUT_SLOT)
|
||||
LIQUID_VOLUME = LIQUID_VOLUME * len(LIQUID_INPUT_SLOT)
|
||||
self.lab_logger().warning(
|
||||
f"增加液体资源时,数量为1,自动补全为 {len(LIQUID_INPUT_SLOT)} 个"
|
||||
)
|
||||
for liquid_type, liquid_volume, liquid_input_slot in zip(
|
||||
ADD_LIQUID_TYPE, LIQUID_VOLUME, LIQUID_INPUT_SLOT
|
||||
):
|
||||
empty_liquid_info_in[liquid_input_slot] = (liquid_type, liquid_volume)
|
||||
plr_instance.set_well_liquids(empty_liquid_info_in)
|
||||
try:
|
||||
# noinspection PyProtectedMember
|
||||
keys = list(plr_instance._ordering.keys())
|
||||
@@ -511,6 +505,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
input_wells = []
|
||||
for r in LIQUID_INPUT_SLOT:
|
||||
input_wells.append(plr_instance.children[r])
|
||||
for input_well, liquid_type, liquid_volume, liquid_input_slot in zip(
|
||||
input_wells, ADD_LIQUID_TYPE, LIQUID_VOLUME, LIQUID_INPUT_SLOT
|
||||
):
|
||||
input_well.set_liquids([(liquid_type, liquid_volume, "uL")])
|
||||
final_response["liquid_input_resource_tree"] = ResourceTreeSet.from_plr_resources(
|
||||
input_wells
|
||||
).dump()
|
||||
@@ -545,7 +543,12 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
}
|
||||
)
|
||||
tree_response: SerialCommand.Response = await client.call_async(request)
|
||||
uuid_maps = json.loads(tree_response.response)
|
||||
_raw_resp = tree_response.response if tree_response else ""
|
||||
if _raw_resp:
|
||||
uuid_maps = json.loads(_raw_resp)
|
||||
else:
|
||||
uuid_maps = {}
|
||||
self._lab_logger.warning("Resource tree add 返回空响应,跳过 UUID 映射")
|
||||
self.resource_tracker.loop_update_uuid(input_resources, uuid_maps)
|
||||
self._lab_logger.info(f"Resource tree added. UUID mapping: {len(uuid_maps)} nodes")
|
||||
# 这里created_resources不包含parent_resource
|
||||
@@ -1256,9 +1259,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
return self._lab_logger
|
||||
|
||||
def create_ros_publisher(self, attr_name, msg_type, initial_period=5.0):
|
||||
"""创建ROS发布者,仅当方法/属性有 @topic_config 装饰器时才创建。"""
|
||||
# 检测 @topic_config 装饰器配置
|
||||
topic_config = {}
|
||||
"""创建ROS发布者。已在 status_types 中声明的属性直接创建;@topic_config 用于覆盖默认参数。"""
|
||||
topic_cfg = {}
|
||||
driver_class = type(self.driver_instance)
|
||||
|
||||
# 区分 @property 和普通方法两种情况
|
||||
@@ -1267,23 +1269,17 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
)
|
||||
|
||||
if is_prop:
|
||||
# @property: 检测 fget 上的 @topic_config
|
||||
class_attr = getattr(driver_class, attr_name)
|
||||
if class_attr.fget is not None:
|
||||
topic_config = get_topic_config(class_attr.fget)
|
||||
topic_cfg = get_topic_config(class_attr.fget)
|
||||
else:
|
||||
# 普通方法: 直接检测 attr_name 方法上的 @topic_config
|
||||
if hasattr(self.driver_instance, attr_name):
|
||||
method = getattr(self.driver_instance, attr_name)
|
||||
if callable(method):
|
||||
topic_config = get_topic_config(method)
|
||||
|
||||
# 没有 @topic_config 装饰器则跳过发布
|
||||
if not topic_config:
|
||||
return
|
||||
topic_cfg = get_topic_config(method)
|
||||
|
||||
# 发布名称优先级: @topic_config(name=...) > get_ 前缀去除 > attr_name
|
||||
cfg_name = topic_config.get("name")
|
||||
cfg_name = topic_cfg.get("name")
|
||||
if cfg_name:
|
||||
publish_name = cfg_name
|
||||
elif attr_name.startswith("get_"):
|
||||
@@ -1291,10 +1287,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
else:
|
||||
publish_name = attr_name
|
||||
|
||||
# 使用装饰器配置或默认值
|
||||
cfg_period = topic_config.get("period")
|
||||
cfg_print = topic_config.get("print_publish")
|
||||
cfg_qos = topic_config.get("qos")
|
||||
# @topic_config 参数覆盖默认值
|
||||
cfg_period = topic_cfg.get("period")
|
||||
cfg_print = topic_cfg.get("print_publish")
|
||||
cfg_qos = topic_cfg.get("qos")
|
||||
period: float = cfg_period if cfg_period is not None else initial_period
|
||||
print_publish: bool = cfg_print if cfg_print is not None else self._print_publish
|
||||
qos: int = cfg_qos if cfg_qos is not None else 10
|
||||
@@ -1486,13 +1482,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
if uuid_indices:
|
||||
uuids = [item[1] for item in uuid_indices]
|
||||
resource_tree = await self.get_resource(uuids)
|
||||
plr_resources = resource_tree.to_plr_resources(requested_uuids=uuids)
|
||||
plr_resources = resource_tree.to_plr_resources()
|
||||
for i, (idx, _, resource_data) in enumerate(uuid_indices):
|
||||
try:
|
||||
plr_resource = plr_resources[i]
|
||||
except Exception as e:
|
||||
self.lab_logger().error(f"资源查询结果: 共 {len(queried_resources)} 个资源,但查询结果只有 {len(plr_resources)} 个资源,索引为 {i} 的资源不存在")
|
||||
raise e
|
||||
plr_resource = plr_resources[i]
|
||||
if "sample_id" in resource_data:
|
||||
plr_resource.unilabos_extra[EXTRA_SAMPLE_UUID] = resource_data["sample_id"]
|
||||
queried_resources[idx] = plr_resource
|
||||
@@ -1742,10 +1734,25 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
try:
|
||||
function_args[arg_name] = self._convert_resources_sync(resource_data["uuid"])[0]
|
||||
except Exception as e:
|
||||
self.lab_logger().error(
|
||||
f"转换ResourceSlot参数 {arg_name} 失败: {e}\n{traceback.format_exc()}"
|
||||
# UUID 在资源树中不存在,尝试从传入的完整 dict 直接构建 PLR 资源
|
||||
self.lab_logger().warning(
|
||||
f"UUID查询 {arg_name} 失败,尝试从传入数据直接构建: {e}"
|
||||
)
|
||||
raise JsonCommandInitError(f"ResourceSlot参数转换失败: {arg_name}")
|
||||
try:
|
||||
fallback_tree = ResourceTreeSet.from_raw_dict_list([resource_data])
|
||||
if len(fallback_tree.trees) == 0:
|
||||
raise
|
||||
plr_list = fallback_tree.to_plr_resources()
|
||||
if not plr_list:
|
||||
raise
|
||||
plr_res = plr_list[0]
|
||||
figured = self.resource_tracker.figure_resource(plr_res, try_mode=True)
|
||||
function_args[arg_name] = figured[0] if figured else plr_res
|
||||
except Exception:
|
||||
self.lab_logger().error(
|
||||
f"转换ResourceSlot参数 {arg_name} 失败(含回退): {e}\n{traceback.format_exc()}"
|
||||
)
|
||||
raise JsonCommandInitError(f"ResourceSlot参数转换失败: {arg_name}")
|
||||
|
||||
# 处理 ResourceSlot 列表
|
||||
elif isinstance(arg_type, tuple) and len(arg_type) == 2:
|
||||
@@ -1757,10 +1764,25 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
uuids = [r["uuid"] for r in resource_list if isinstance(r, dict) and "id" in r]
|
||||
function_args[arg_name] = self._convert_resources_sync(*uuids) if uuids else []
|
||||
except Exception as e:
|
||||
self.lab_logger().error(
|
||||
f"转换ResourceSlot列表参数 {arg_name} 失败: {e}\n{traceback.format_exc()}"
|
||||
self.lab_logger().warning(
|
||||
f"UUID查询列表 {arg_name} 失败,尝试从传入数据直接构建: {e}"
|
||||
)
|
||||
raise JsonCommandInitError(f"ResourceSlot列表参数转换失败: {arg_name}")
|
||||
try:
|
||||
dict_items = [r for r in resource_list if isinstance(r, dict) and "id" in r]
|
||||
fallback_tree = ResourceTreeSet.from_raw_dict_list(dict_items)
|
||||
if len(fallback_tree.trees) == 0:
|
||||
raise
|
||||
plr_list = fallback_tree.to_plr_resources()
|
||||
resolved = []
|
||||
for plr_res in plr_list:
|
||||
figured = self.resource_tracker.figure_resource(plr_res, try_mode=True)
|
||||
resolved.append(figured[0] if figured else plr_res)
|
||||
function_args[arg_name] = resolved
|
||||
except Exception:
|
||||
self.lab_logger().error(
|
||||
f"转换ResourceSlot列表参数 {arg_name} 失败(含回退): {e}\n{traceback.format_exc()}"
|
||||
)
|
||||
raise JsonCommandInitError(f"ResourceSlot列表参数转换失败: {arg_name}")
|
||||
|
||||
# todo: 默认反报送
|
||||
return function(**function_args)
|
||||
|
||||
@@ -22,6 +22,7 @@ from unilabos_msgs.srv import (
|
||||
SerialCommand,
|
||||
) # type: ignore
|
||||
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response
|
||||
from unilabos_msgs.action import SendCmd
|
||||
from unique_identifier_msgs.msg import UUID
|
||||
|
||||
from unilabos.registry.decorators import device, action, NodeType
|
||||
@@ -313,9 +314,15 @@ class HostNode(BaseROS2DeviceNode):
|
||||
callback_group=self.callback_group,
|
||||
),
|
||||
} # 用来存储多个ActionClient实例
|
||||
self._add_resource_mesh_client = ActionClient(
|
||||
self,
|
||||
SendCmd,
|
||||
"/devices/resource_mesh_manager/add_resource_mesh",
|
||||
callback_group=self.callback_group,
|
||||
)
|
||||
self._action_value_mappings: Dict[str, Dict] = {
|
||||
device_id: self._action_value_mappings
|
||||
} # device_id -> action_value_mappings(本地+远程设备统一存储)
|
||||
} # device_id -> action_value_mappings(本地+远程设备统一存储)
|
||||
self._slave_registry_configs: Dict[str, Dict] = {} # registry_name -> registry_config(含action_value_mappings)
|
||||
self._goals: Dict[str, Any] = {} # 用来存储多个目标的状态
|
||||
self._online_devices: Set[str] = {f"{self.namespace}/{device_id}"} # 用于跟踪在线设备
|
||||
@@ -1131,6 +1138,27 @@ class HostNode(BaseROS2DeviceNode):
|
||||
),
|
||||
}
|
||||
|
||||
def _notify_resource_mesh_add(self, resource_tree_set: ResourceTreeSet):
|
||||
"""通知 ResourceMeshManager 添加资源的 mesh 可视化"""
|
||||
if not self._add_resource_mesh_client.server_is_ready():
|
||||
self.lab_logger().debug("[Host Node] ResourceMeshManager 未就绪,跳过 mesh 添加通知")
|
||||
return
|
||||
|
||||
resource_configs = []
|
||||
for node in resource_tree_set.all_nodes:
|
||||
res_dict = node.res_content.model_dump(by_alias=True)
|
||||
if res_dict.get("type") == "device":
|
||||
continue
|
||||
resource_configs.append(res_dict)
|
||||
|
||||
if not resource_configs:
|
||||
return
|
||||
|
||||
goal_msg = SendCmd.Goal()
|
||||
goal_msg.command = json.dumps({"resources": resource_configs})
|
||||
self._add_resource_mesh_client.send_goal_async(goal_msg)
|
||||
self.lab_logger().info(f"[Host Node] 已发送 {len(resource_configs)} 个资源 mesh 添加请求")
|
||||
|
||||
async def _resource_tree_action_add_callback(self, data: dict, response: SerialCommand_Response): # OK
|
||||
resource_tree_set = ResourceTreeSet.load(data["data"])
|
||||
mount_uuid = data["mount_uuid"]
|
||||
@@ -1171,6 +1199,12 @@ class HostNode(BaseROS2DeviceNode):
|
||||
response.response = json.dumps(uuid_mapping) if success else "FAILED"
|
||||
self.lab_logger().info(f"[Host Node-Resource] Resource tree add completed, success: {success}")
|
||||
|
||||
if success:
|
||||
try:
|
||||
self._notify_resource_mesh_add(resource_tree_set)
|
||||
except Exception as e:
|
||||
self.lab_logger().error(f"[Host Node] 通知 ResourceMeshManager 添加 mesh 失败: {e}")
|
||||
|
||||
async def _resource_tree_action_get_callback(self, data: dict, response: SerialCommand_Response): # OK
|
||||
uuid_list: List[str] = data["data"]
|
||||
with_children: bool = data["with_children"]
|
||||
@@ -1222,6 +1256,12 @@ class HostNode(BaseROS2DeviceNode):
|
||||
response.response = json.dumps(uuid_mapping)
|
||||
self.lab_logger().info(f"[Host Node-Resource] Resource tree update completed, success: {success}")
|
||||
|
||||
if success:
|
||||
try:
|
||||
self._notify_resource_mesh_add(new_tree_set)
|
||||
except Exception as e:
|
||||
self.lab_logger().error(f"[Host Node] 通知 ResourceMeshManager 更新 mesh 失败: {e}")
|
||||
|
||||
async def _resource_tree_update_callback(self, request: SerialCommand_Request, response: SerialCommand_Response):
|
||||
"""
|
||||
子节点通知Host物料树更新
|
||||
|
||||
@@ -23,17 +23,32 @@ from unilabos_msgs.action import SendCmd
|
||||
from rclpy.action.server import ServerGoalHandle
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode,DeviceNodeResourceTracker
|
||||
from unilabos.resources.graphio import initialize_resources
|
||||
from unilabos.resources.resource_tracker import EXTRA_CLASS
|
||||
from unilabos.registry.registry import lab_registry
|
||||
|
||||
class ResourceMeshManager(BaseROS2DeviceNode):
|
||||
def __init__(self, resource_model: dict, resource_config: list,resource_tracker, device_id: str = "resource_mesh_manager", registry_name: str = "", rate=50, **kwargs):
|
||||
def __init__(
|
||||
self,
|
||||
resource_model: Optional[dict] = None,
|
||||
resource_config: Optional[list] = None,
|
||||
resource_tracker=None,
|
||||
device_id: str = "resource_mesh_manager",
|
||||
registry_name: str = "",
|
||||
rate=50,
|
||||
**kwargs,
|
||||
):
|
||||
"""初始化资源网格管理器节点
|
||||
|
||||
|
||||
Args:
|
||||
resource_model (dict): 资源模型字典,包含资源的3D模型信息
|
||||
resource_config (dict): 资源配置字典,包含资源的配置信息
|
||||
device_id (str): 节点名称
|
||||
resource_model: 资源模型字典(可选,为 None 时自动从 registry 构建)
|
||||
resource_config: 资源配置列表(可选,为 None 时启动后通过 ActionServer 或 load_from_resource_tree 加载)
|
||||
resource_tracker: 资源追踪器
|
||||
device_id: 节点名称
|
||||
rate: TF 发布频率
|
||||
"""
|
||||
if resource_tracker is None:
|
||||
resource_tracker = DeviceNodeResourceTracker()
|
||||
|
||||
super().__init__(
|
||||
driver_instance=self,
|
||||
device_id=device_id,
|
||||
@@ -42,12 +57,14 @@ class ResourceMeshManager(BaseROS2DeviceNode):
|
||||
action_value_mappings={},
|
||||
hardware_interface={},
|
||||
print_publish=False,
|
||||
resource_tracker=resource_tracker,
|
||||
resource_tracker=resource_tracker,
|
||||
device_uuid=kwargs.get("uuid", str(uuid.uuid4())),
|
||||
)
|
||||
)
|
||||
|
||||
self.resource_model = resource_model
|
||||
self.resource_config_dict = {item['uuid']: item for item in resource_config}
|
||||
self.resource_model = resource_model if resource_model is not None else {}
|
||||
self.resource_config_dict = (
|
||||
{item['uuid']: item for item in resource_config} if resource_config else {}
|
||||
)
|
||||
self.move_group_ready = False
|
||||
self.resource_tf_dict = {}
|
||||
self.tf_broadcaster = TransformBroadcaster(self)
|
||||
@@ -63,7 +80,7 @@ class ResourceMeshManager(BaseROS2DeviceNode):
|
||||
self.mesh_path = Path(__file__).parent.parent.parent.parent.absolute()
|
||||
self.msg_type = 'resource_status'
|
||||
self.resource_status_dict = {}
|
||||
|
||||
|
||||
callback_group = ReentrantCallbackGroup()
|
||||
self._get_planning_scene_service = self.create_client(
|
||||
srv_type=GetPlanningScene,
|
||||
@@ -76,8 +93,7 @@ class ResourceMeshManager(BaseROS2DeviceNode):
|
||||
),
|
||||
callback_group=callback_group,
|
||||
)
|
||||
|
||||
# Create a service for applying the planning scene
|
||||
|
||||
self._apply_planning_scene_service = self.create_client(
|
||||
srv_type=ApplyPlanningScene,
|
||||
srv_name="/apply_planning_scene",
|
||||
@@ -103,27 +119,36 @@ class ResourceMeshManager(BaseROS2DeviceNode):
|
||||
AttachedCollisionObject, "/attached_collision_object", 0
|
||||
)
|
||||
|
||||
# 创建一个Action Server用于修改resource_tf_dict
|
||||
self._action_server = ActionServer(
|
||||
self,
|
||||
SendCmd,
|
||||
f"tf_update",
|
||||
self.tf_update,
|
||||
callback_group=callback_group
|
||||
callback_group=callback_group,
|
||||
)
|
||||
|
||||
# 创建一个Action Server用于添加新的资源模型与resource_tf_dict
|
||||
self._add_resource_mesh_action_server = ActionServer(
|
||||
self,
|
||||
SendCmd,
|
||||
f"add_resource_mesh",
|
||||
self.add_resource_mesh_callback,
|
||||
callback_group=callback_group
|
||||
callback_group=callback_group,
|
||||
)
|
||||
|
||||
self.resource_tf_dict = self.resource_mesh_setup(self.resource_config_dict)
|
||||
self.create_timer(1/self.rate, self.publish_resource_tf)
|
||||
self.create_timer(1/self.rate, self.check_resource_pose_changes)
|
||||
self._reload_resource_mesh_action_server = ActionServer(
|
||||
self,
|
||||
SendCmd,
|
||||
f"reload_resource_mesh",
|
||||
self._reload_resource_mesh_callback,
|
||||
callback_group=callback_group,
|
||||
)
|
||||
|
||||
if self.resource_config_dict:
|
||||
self.resource_tf_dict = self.resource_mesh_setup(self.resource_config_dict)
|
||||
else:
|
||||
self.get_logger().info("未提供 resource_config,将通过 ActionServer 或 load_from_resource_tree 加载")
|
||||
self.create_timer(1 / self.rate, self.publish_resource_tf)
|
||||
self.create_timer(1 / self.rate, self.check_resource_pose_changes)
|
||||
|
||||
def check_move_group_ready(self):
|
||||
"""检查move_group节点是否已初始化完成"""
|
||||
@@ -140,56 +165,156 @@ class ResourceMeshManager(BaseROS2DeviceNode):
|
||||
self.add_resource_collision_meshes(self.resource_tf_dict)
|
||||
|
||||
|
||||
def add_resource_mesh_callback(self, goal_handle : ServerGoalHandle):
|
||||
def _build_resource_model_for_config(self, resource_config_dict: dict):
|
||||
"""从 registry 中为给定的资源配置自动构建 resource_model(mesh 信息)"""
|
||||
registry = lab_registry
|
||||
for _uuid, res_cfg in resource_config_dict.items():
|
||||
resource_id = res_cfg.get('id', '')
|
||||
resource_class = res_cfg.get('class', '')
|
||||
if not resource_class:
|
||||
continue
|
||||
if resource_class not in registry.resource_type_registry:
|
||||
continue
|
||||
reg_entry = registry.resource_type_registry[resource_class]
|
||||
if 'model' not in reg_entry:
|
||||
continue
|
||||
model_config = reg_entry['model']
|
||||
if model_config.get('type') != 'resource':
|
||||
continue
|
||||
if resource_id in self.resource_model:
|
||||
continue
|
||||
self.resource_model[resource_id] = {
|
||||
'mesh': f"{str(self.mesh_path)}/device_mesh/resources/{model_config['mesh']}",
|
||||
'mesh_tf': model_config['mesh_tf'],
|
||||
}
|
||||
if model_config.get('children_mesh') is not None:
|
||||
self.resource_model[f"{resource_id}_"] = {
|
||||
'mesh': f"{str(self.mesh_path)}/device_mesh/resources/{model_config['children_mesh']}",
|
||||
'mesh_tf': model_config['children_mesh_tf'],
|
||||
}
|
||||
|
||||
def load_from_resource_tree(self):
|
||||
"""从 resource_tracker 中读取资源树,自动构建 resource_config_dict / resource_model 并刷新 TF"""
|
||||
new_config_dict: dict = {}
|
||||
|
||||
def _collect_plr_resource(res, parent_uuid: Optional[str] = None):
|
||||
res_uuid = getattr(res, 'unilabos_uuid', None)
|
||||
if not res_uuid:
|
||||
res_uuid = str(uuid.uuid4())
|
||||
extra = getattr(res, 'unilabos_extra', {}) or {}
|
||||
resource_class = extra.get(EXTRA_CLASS, '')
|
||||
|
||||
location = getattr(res, 'location', None)
|
||||
pos_x = float(location.x) if location else 0.0
|
||||
pos_y = float(location.y) if location else 0.0
|
||||
pos_z = float(location.z) if location else 0.0
|
||||
|
||||
rotation = extra.get('rotation', {'x': 0, 'y': 0, 'z': 0})
|
||||
|
||||
new_config_dict[res_uuid] = {
|
||||
'id': res.name,
|
||||
'uuid': res_uuid,
|
||||
'class': resource_class,
|
||||
'parent_uuid': parent_uuid,
|
||||
'pose': {
|
||||
'position': {'x': pos_x, 'y': pos_y, 'z': pos_z},
|
||||
'rotation': rotation,
|
||||
},
|
||||
}
|
||||
|
||||
for child in getattr(res, 'children', []) or []:
|
||||
_collect_plr_resource(child, res_uuid)
|
||||
|
||||
for resource in self.resource_tracker.resources:
|
||||
root_parent_uuid = None
|
||||
plr_parent = getattr(resource, 'parent', None)
|
||||
if plr_parent is not None:
|
||||
root_parent_uuid = getattr(plr_parent, 'unilabos_uuid', None)
|
||||
_collect_plr_resource(resource, root_parent_uuid)
|
||||
|
||||
if not new_config_dict:
|
||||
self.get_logger().warning("resource_tracker 中没有找到任何资源")
|
||||
return
|
||||
|
||||
self.resource_config_dict = {**self.resource_config_dict, **new_config_dict}
|
||||
self._build_resource_model_for_config(new_config_dict)
|
||||
|
||||
tf_dict = self.resource_mesh_setup(new_config_dict)
|
||||
self.resource_tf_dict = {**self.resource_tf_dict, **tf_dict}
|
||||
self.publish_resource_tf()
|
||||
if self.move_group_ready:
|
||||
self.add_resource_collision_meshes(tf_dict)
|
||||
self.get_logger().info(f"从资源树加载了 {len(new_config_dict)} 个资源")
|
||||
|
||||
def _reload_resource_mesh_callback(self, goal_handle: ServerGoalHandle):
|
||||
"""ActionServer 回调:重新从资源树加载所有 mesh"""
|
||||
try:
|
||||
self.load_from_resource_tree()
|
||||
except Exception as e:
|
||||
self.get_logger().error(f"重新加载资源失败: {e}")
|
||||
goal_handle.abort()
|
||||
return SendCmd.Result(success=False)
|
||||
goal_handle.succeed()
|
||||
return SendCmd.Result(success=True)
|
||||
|
||||
def add_resource_mesh_callback(self, goal_handle: ServerGoalHandle):
|
||||
tf_update_msg = goal_handle.request
|
||||
try:
|
||||
self.add_resource_mesh(tf_update_msg.command)
|
||||
try:
|
||||
parsed = json.loads(tf_update_msg.command.replace("'", '"'))
|
||||
if 'resources' in parsed:
|
||||
for res_config in parsed['resources']:
|
||||
self.add_resource_mesh(json.dumps(res_config))
|
||||
else:
|
||||
self.add_resource_mesh(tf_update_msg.command)
|
||||
except Exception as e:
|
||||
self.get_logger().error(f"添加资源失败: {e}")
|
||||
goal_handle.abort()
|
||||
return SendCmd.Result(success=False)
|
||||
goal_handle.succeed()
|
||||
return SendCmd.Result(success=True)
|
||||
|
||||
def add_resource_mesh(self,resource_config_str:str):
|
||||
"""刷新资源配置"""
|
||||
|
||||
def add_resource_mesh(self, resource_config_str: str):
|
||||
"""添加单个资源的 mesh 配置"""
|
||||
registry = lab_registry
|
||||
resource_config = json.loads(resource_config_str.replace("'",'"'))
|
||||
|
||||
resource_config = json.loads(resource_config_str.replace("'", '"'))
|
||||
|
||||
if resource_config['id'] in self.resource_config_dict:
|
||||
self.get_logger().info(f'资源 {resource_config["id"]} 已存在')
|
||||
return
|
||||
if resource_config['class'] in registry.resource_type_registry.keys():
|
||||
model_config = registry.resource_type_registry[resource_config['class']]['model']
|
||||
if model_config['type'] == 'resource':
|
||||
self.resource_model[resource_config['id']] = {
|
||||
'mesh': f"{str(self.mesh_path)}/device_mesh/resources/{model_config['mesh']}",
|
||||
'mesh_tf': model_config['mesh_tf']}
|
||||
if 'children_mesh' in model_config.keys():
|
||||
self.resource_model[f"{resource_config['id']}_"] = {
|
||||
'mesh': f"{str(self.mesh_path)}/device_mesh/resources/{model_config['children_mesh']}",
|
||||
'mesh_tf': model_config['children_mesh_tf']
|
||||
resource_class = resource_config.get('class', '')
|
||||
if resource_class and resource_class in registry.resource_type_registry:
|
||||
reg_entry = registry.resource_type_registry[resource_class]
|
||||
if 'model' in reg_entry:
|
||||
model_config = reg_entry['model']
|
||||
if model_config.get('type') == 'resource':
|
||||
self.resource_model[resource_config['id']] = {
|
||||
'mesh': f"{str(self.mesh_path)}/device_mesh/resources/{model_config['mesh']}",
|
||||
'mesh_tf': model_config['mesh_tf'],
|
||||
}
|
||||
if model_config.get('children_mesh') is not None:
|
||||
self.resource_model[f"{resource_config['id']}_"] = {
|
||||
'mesh': f"{str(self.mesh_path)}/device_mesh/resources/{model_config['children_mesh']}",
|
||||
'mesh_tf': model_config['children_mesh_tf'],
|
||||
}
|
||||
resources = initialize_resources([resource_config])
|
||||
resource_dict = {item['id']: item for item in resources}
|
||||
self.resource_config_dict = {**self.resource_config_dict,**resource_dict}
|
||||
self.resource_config_dict = {**self.resource_config_dict, **resource_dict}
|
||||
tf_dict = self.resource_mesh_setup(resource_dict)
|
||||
self.resource_tf_dict = {**self.resource_tf_dict,**tf_dict}
|
||||
self.resource_tf_dict = {**self.resource_tf_dict, **tf_dict}
|
||||
self.publish_resource_tf()
|
||||
self.add_resource_collision_meshes(tf_dict)
|
||||
|
||||
|
||||
def resource_mesh_setup(self, resource_config_dict:dict):
|
||||
"""move_group初始化完成后的设置"""
|
||||
def resource_mesh_setup(self, resource_config_dict: dict):
|
||||
"""根据资源配置字典设置 TF 关系"""
|
||||
self.get_logger().info('开始设置资源网格管理器')
|
||||
#遍历resource_config中的资源配置,判断panent是否在resource_model中,
|
||||
resource_tf_dict = {}
|
||||
for resource_uuid, resource_config in resource_config_dict.items():
|
||||
parent = None
|
||||
resource_id = resource_config['id']
|
||||
if resource_config['parent_uuid'] is not None and resource_config['parent_uuid'] != "":
|
||||
parent = resource_config_dict[resource_config['parent_uuid']]['id']
|
||||
parent_uuid = resource_config.get('parent_uuid')
|
||||
if parent_uuid is not None and parent_uuid != "":
|
||||
parent_entry = resource_config_dict.get(parent_uuid) or self.resource_config_dict.get(parent_uuid)
|
||||
parent = parent_entry['id'] if parent_entry else None
|
||||
|
||||
parent_link = 'world'
|
||||
if parent in self.resource_model:
|
||||
|
||||
233
unilabos/test/experiments/prcxi_9300_slim.json
Normal file
233
unilabos/test/experiments/prcxi_9300_slim.json
Normal file
@@ -0,0 +1,233 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "PRCXI",
|
||||
"name": "PRCXI",
|
||||
"type": "device",
|
||||
"class": "liquid_handler.prcxi",
|
||||
"parent": "",
|
||||
"pose": {
|
||||
"size": {
|
||||
"width": 424,
|
||||
"height": 202,
|
||||
"depth": 0
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"axis": "Left",
|
||||
"deck": {
|
||||
"_resource_type": "unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Deck",
|
||||
"_resource_child_name": "PRCXI_Deck"
|
||||
},
|
||||
"host": "10.20.31.167",
|
||||
"port": 9999,
|
||||
"debug": false,
|
||||
"setup": true,
|
||||
"matrix_id": "1ecb1b45-6aef-456b-bd68-8f538c4e5826",
|
||||
"timeout": 10,
|
||||
"simulator": false,
|
||||
"channel_num": 8
|
||||
},
|
||||
"data": {
|
||||
"reset_ok": true
|
||||
},
|
||||
"schema": {},
|
||||
"description": "",
|
||||
"model": null,
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 240,
|
||||
"z": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "PRCXI_Deck",
|
||||
"name": "PRCXI_Deck",
|
||||
"children": [],
|
||||
"parent": "PRCXI",
|
||||
"type": "deck",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Deck",
|
||||
"size_x": 404,
|
||||
"size_y": 182,
|
||||
"size_z": 0,
|
||||
"model": "9300",
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0,
|
||||
"type": "Rotation"
|
||||
},
|
||||
"category": "deck",
|
||||
"barcode": null,
|
||||
"preferred_pickup_location": null,
|
||||
"sites": [
|
||||
{
|
||||
"label": "T1",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 96,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor",
|
||||
"plateadapter",
|
||||
"module",
|
||||
"trash"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T2",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 138,
|
||||
"y": 96,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor",
|
||||
"plateadapter",
|
||||
"module",
|
||||
"trash"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T3",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 276,
|
||||
"y": 96,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor",
|
||||
"plateadapter",
|
||||
"module",
|
||||
"trash"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T4",
|
||||
"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",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor",
|
||||
"plateadapter",
|
||||
"module",
|
||||
"trash"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T5",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 138,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor",
|
||||
"plateadapter",
|
||||
"module",
|
||||
"trash"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T6",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 276,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor",
|
||||
"plateadapter",
|
||||
"module",
|
||||
"trash"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
],
|
||||
"edges": []
|
||||
}
|
||||
@@ -8,8 +8,8 @@
|
||||
"parent": "",
|
||||
"pose": {
|
||||
"size": {
|
||||
"width": 562,
|
||||
"height": 394,
|
||||
"width": 550,
|
||||
"height": 400,
|
||||
"depth": 0
|
||||
}
|
||||
},
|
||||
@@ -21,13 +21,20 @@
|
||||
},
|
||||
"host": "10.20.30.184",
|
||||
"port": 9999,
|
||||
"debug": true,
|
||||
"setup": true,
|
||||
"debug": false,
|
||||
"setup": false,
|
||||
"is_9320": true,
|
||||
"timeout": 10,
|
||||
"matrix_id": "5de524d0-3f95-406c-86dd-f83626ebc7cb",
|
||||
"simulator": true,
|
||||
"channel_num": 2
|
||||
"matrix_id": "",
|
||||
"simulator": false,
|
||||
"channel_num": 2,
|
||||
"step_mode": false,
|
||||
"calibration_points": {
|
||||
"line_1": [[452.07,21.19], [313.88,21.19], [176.87,21.19], [39.08,21.19]],
|
||||
"line_2": [[451.37,116.68], [313.28,116.88], [176.58,116.69], [38.58,117.18]],
|
||||
"line_3": [[450.87,212.18], [312.98,212.38], [176.08,212.68], [38.08,213.18]],
|
||||
"line_4": [[450.08,307.68], [312.18,307.89], [175.18,308.18], [37.58,309.18]]
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
"reset_ok": true
|
||||
@@ -49,15 +56,15 @@
|
||||
"type": "deck",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"type": "PRCXI9300Deck",
|
||||
"size_x": 542,
|
||||
"size_y": 374,
|
||||
"size_z": 0,
|
||||
"size_x": 550,
|
||||
"size_y": 400,
|
||||
"size_z": 17,
|
||||
"rotation": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
@@ -66,378 +73,7 @@
|
||||
},
|
||||
"category": "deck",
|
||||
"barcode": null,
|
||||
"preferred_pickup_location": null,
|
||||
"sites": [
|
||||
{
|
||||
"label": "T1",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"container",
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T2",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 138,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T3",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 276,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T4",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 414,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T5",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 96,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T6",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 138,
|
||||
"y": 96,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T7",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 276,
|
||||
"y": 96,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T8",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 414,
|
||||
"y": 96,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T9",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 192,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T10",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 138,
|
||||
"y": 192,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T11",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 276,
|
||||
"y": 192,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T12",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 414,
|
||||
"y": 192,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T13",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 288,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T14",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 138,
|
||||
"y": 288,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T15",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 276,
|
||||
"y": 288,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "T16",
|
||||
"visible": true,
|
||||
"occupied_by": null,
|
||||
"position": {
|
||||
"x": 414,
|
||||
"y": 288,
|
||||
"z": 0
|
||||
},
|
||||
"size": {
|
||||
"width": 128.0,
|
||||
"height": 86,
|
||||
"depth": 0
|
||||
},
|
||||
"content_type": [
|
||||
"plate",
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack",
|
||||
"adaptor"
|
||||
]
|
||||
}
|
||||
]
|
||||
"preferred_pickup_location": null
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
|
||||
@@ -108,7 +108,8 @@
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack"
|
||||
"tube_rack",
|
||||
"plateadapter"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -153,7 +154,8 @@
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack"
|
||||
"tube_rack",
|
||||
"plateadapter"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -198,7 +200,8 @@
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack"
|
||||
"tube_rack",
|
||||
"plateadapter"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -243,7 +246,8 @@
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack"
|
||||
"tube_rack",
|
||||
"plateadapter"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -288,7 +292,8 @@
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack"
|
||||
"tube_rack",
|
||||
"plateadapter"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -333,7 +338,8 @@
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack"
|
||||
"tube_rack",
|
||||
"plateadapter"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -378,7 +384,8 @@
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack"
|
||||
"tube_rack",
|
||||
"plateadapter"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -423,7 +430,8 @@
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack"
|
||||
"tube_rack",
|
||||
"plateadapter"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -468,7 +476,8 @@
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack"
|
||||
"tube_rack",
|
||||
"plateadapter"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -513,7 +522,8 @@
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack"
|
||||
"tube_rack",
|
||||
"plateadapter"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -558,7 +568,8 @@
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack"
|
||||
"tube_rack",
|
||||
"plateadapter"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -603,7 +614,8 @@
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack"
|
||||
"tube_rack",
|
||||
"plateadapter"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -648,7 +660,8 @@
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack"
|
||||
"tube_rack",
|
||||
"plateadapter"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -693,7 +706,8 @@
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack"
|
||||
"tube_rack",
|
||||
"plateadapter"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -738,7 +752,8 @@
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack"
|
||||
"tube_rack",
|
||||
"plateadapter"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -783,7 +798,8 @@
|
||||
"tip_rack",
|
||||
"plates",
|
||||
"tip_racks",
|
||||
"tube_rack"
|
||||
"tube_rack",
|
||||
"plateadapter"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -23,9 +23,9 @@
|
||||
- 遍历所有 reagent,按 slot 去重,为每个唯一的 slot 创建一个板子
|
||||
- 所有 create_resource 节点的 parent_uuid 指向 Group 节点,minimized=true
|
||||
- 生成参数:
|
||||
res_id: plate_slot_{slot}
|
||||
res_id / 节点 name / display_name: {匹配后的 prcxi 类名}_slot_{槽位}
|
||||
device_id: /PRCXI
|
||||
class_name: PRCXI_BioER_96_wellplate
|
||||
class_name: 与 res_id 中类型一致(如 PRCXI 384/96 孔板注册类)
|
||||
parent: /PRCXI/PRCXI_Deck
|
||||
slot_on_deck: "{slot}"
|
||||
- 输出端口: labware(用于连接 set_liquid_from_plate)
|
||||
@@ -51,6 +51,7 @@
|
||||
--------------------------------------------------------------------------------
|
||||
- 遍历 workflow 数组,为每个动作创建步骤节点
|
||||
- 参数重命名: asp_vol -> asp_vols, dis_vol -> dis_vols, asp_flow_rate -> asp_flow_rates, dis_flow_rate -> dis_flow_rates
|
||||
- 参数输入转换: liquid_height(按 wells 扩展);mix_stage/mix_times/mix_vol/mix_rate/mix_liquid_height 保持标量
|
||||
- 参数扩展: 根据 targets 的 wells 数量,将单值扩展为数组
|
||||
例: asp_vol=100.0, targets 有 3 个 wells -> asp_vols=[100.0, 100.0, 100.0]
|
||||
- 连接处理: 如果 sources/targets 已通过 set_liquid_from_plate 连接,参数值改为 []
|
||||
@@ -119,11 +120,14 @@ DEVICE_NAME_DEFAULT = "PRCXI" # transfer_liquid, set_liquid_from_plate 等动
|
||||
# 节点类型
|
||||
NODE_TYPE_DEFAULT = "ILab" # 所有节点的默认类型
|
||||
|
||||
CLASS_NAMES_MAPPING = {
|
||||
"plate": "PRCXI_BioER_96_wellplate",
|
||||
"tip_rack": "PRCXI_300ul_Tips",
|
||||
}
|
||||
# create_resource 节点默认参数
|
||||
CREATE_RESOURCE_DEFAULTS = {
|
||||
"device_id": "/PRCXI",
|
||||
"parent_template": "/PRCXI/PRCXI_Deck",
|
||||
"class_name": "PRCXI_BioER_96_wellplate",
|
||||
}
|
||||
|
||||
# 默认液体体积 (uL)
|
||||
@@ -138,6 +142,382 @@ PARAM_RENAME_MAPPING = {
|
||||
}
|
||||
|
||||
|
||||
def _map_deck_slot(raw_slot: str, object_type: str = "") -> str:
|
||||
"""协议槽位 -> 实际 deck:4→13,8→14,12+trash→16,其余不变。"""
|
||||
s = "" if raw_slot is None else str(raw_slot).strip()
|
||||
if not s:
|
||||
return ""
|
||||
if s == "12" and (object_type or "").strip().lower() == "trash":
|
||||
return "16"
|
||||
return {"4": "13", "8": "14"}.get(s, s)
|
||||
|
||||
|
||||
def _labware_def_index(labware_defs: Optional[List[Dict[str, Any]]]) -> Dict[str, Dict[str, Any]]:
|
||||
m: Dict[str, Dict[str, Any]] = {}
|
||||
for d in labware_defs or []:
|
||||
for k in ("id", "name", "reagent_id", "reagent"):
|
||||
key = d.get(k)
|
||||
if key is not None and str(key):
|
||||
m[str(key)] = d
|
||||
return m
|
||||
|
||||
|
||||
def _labware_hint_text(labware_id: str, item: Dict[str, Any]) -> str:
|
||||
"""合并 id 与协议里的 labware 描述(OpenTrons 全名常在 labware 字段)。"""
|
||||
parts = [str(labware_id), str(item.get("labware", "") or "")]
|
||||
return " ".join(parts).lower()
|
||||
|
||||
|
||||
def _infer_reagent_kind(labware_id: str, item: Dict[str, Any]) -> str:
|
||||
ot = (item.get("object") or "").strip().lower()
|
||||
if ot == "trash":
|
||||
return "trash"
|
||||
if ot == "tiprack":
|
||||
return "tip_rack"
|
||||
lid = _labware_hint_text(labware_id, item)
|
||||
if "trash" in lid:
|
||||
return "trash"
|
||||
# tiprack / tip + rack(顺序在 tuberack 之前)
|
||||
if "tiprack" in lid or ("tip" in lid and "rack" in lid):
|
||||
return "tip_rack"
|
||||
# 离心管架 / OpenTrons tuberack(勿与 96 tiprack 混淆)
|
||||
if "tuberack" in lid or "tube_rack" in lid:
|
||||
return "tube_rack"
|
||||
if "eppendorf" in lid and "rack" in lid:
|
||||
return "tube_rack"
|
||||
if "safelock" in lid and "rack" in lid:
|
||||
return "tube_rack"
|
||||
if "rack" in lid and "tip" not in lid:
|
||||
return "tube_rack"
|
||||
return "plate"
|
||||
|
||||
|
||||
def _infer_tube_rack_num_positions(labware_id: str, item: Dict[str, Any]) -> int:
|
||||
"""从 ``24_tuberack`` 等命名中解析孔位数;解析不到则默认 24(与 PRCXI_EP_Adapter 4×6 一致)。"""
|
||||
hint = _labware_hint_text(labware_id, item)
|
||||
m = re.search(r"(\d+)_tuberack", hint)
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
m = re.search(r"tuberack[_\s]*(\d+)", hint)
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
m = re.search(r"(\d+)\s*[-_]?\s*pos(?:ition)?s?", hint)
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
return 96
|
||||
|
||||
|
||||
def _infer_plate_num_children_from_wells(wells: Any) -> Optional[int]:
|
||||
"""根据 well 名推断孔板总孔数档位:列>12 或 行>H(8) 视为 384,否则 96。"""
|
||||
if not isinstance(wells, list) or not wells:
|
||||
return None
|
||||
max_row = 0
|
||||
max_col = 0
|
||||
for w in wells:
|
||||
m = re.match(r"^([A-Za-z]+)(\d+)$", str(w).strip())
|
||||
if not m:
|
||||
continue
|
||||
row_s, col_s = m.group(1).upper(), m.group(2)
|
||||
ri = 0
|
||||
for ch in row_s:
|
||||
ri = ri * 26 + (ord(ch) - ord("A") + 1)
|
||||
max_row = max(max_row, ri)
|
||||
max_col = max(max_col, int(col_s))
|
||||
if max_col <= 0:
|
||||
return None
|
||||
if max_col > 12 or max_row > 8:
|
||||
return 384
|
||||
return 96
|
||||
|
||||
|
||||
def _infer_plate_num_children_from_labware_hint(labware_id: str, item: Dict[str, Any]) -> Optional[int]:
|
||||
"""从 labware 命名(如 custom_384_wellplate、nest_96_wellplate)解析孔数,供模板匹配。"""
|
||||
hint = _labware_hint_text(labware_id, item)
|
||||
m = re.search(
|
||||
r"\b(1536|384|96|48|24|12|6)(\s*[-_]?\s*well|wellplate|_well_)",
|
||||
hint,
|
||||
)
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
m = re.search(r"[_\s](1536|384|96|48|24|12|6)[_\s]", hint)
|
||||
if m and ("well" in hint or "plate" in hint):
|
||||
return int(m.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def _infer_plate_num_children(
|
||||
labware_id: str,
|
||||
item: Dict[str, Any],
|
||||
wells: Any,
|
||||
num_from_def: int,
|
||||
) -> int:
|
||||
"""孔板用于 PRCXI 匹配的孔数:优先定义表,其次命名,再 well 地址,最后默认 96。"""
|
||||
if num_from_def > 0:
|
||||
return num_from_def
|
||||
hinted = _infer_plate_num_children_from_labware_hint(labware_id, item)
|
||||
if hinted is not None:
|
||||
return hinted
|
||||
from_wells = _infer_plate_num_children_from_wells(wells)
|
||||
if from_wells is not None:
|
||||
return from_wells
|
||||
return 96
|
||||
|
||||
|
||||
def _tip_volume_hint(item: Dict[str, Any], labware_id: str) -> Optional[float]:
|
||||
s = _labware_hint_text(labware_id, item)
|
||||
for v in (1250, 1000, 300, 200, 10):
|
||||
if f"{v}ul" in s or f"{v}μl" in s or f"{v}u" in s:
|
||||
return float(v)
|
||||
if f" {v} " in f" {s} ":
|
||||
return float(v)
|
||||
return None
|
||||
|
||||
|
||||
def _flatten_transfer_vols(value: Any) -> List[float]:
|
||||
"""将 asp_vols/dis_vols 标量或列表展平为 float 列表,无法转换的项跳过。"""
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, (list, tuple)):
|
||||
out: List[float] = []
|
||||
for v in value:
|
||||
try:
|
||||
out.append(float(v))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return out
|
||||
try:
|
||||
return [float(value)]
|
||||
except (TypeError, ValueError):
|
||||
return []
|
||||
|
||||
|
||||
def _tip_prcxi_class_for_max_ul(max_ul: float) -> str:
|
||||
"""按移液最大体积分档推介 PRCXI tip 类名:≤10 µL → 10µL;<300 → 300µL;否则 1000µL。"""
|
||||
if max_ul <= 10:
|
||||
return "PRCXI_10uL_Tips"
|
||||
if max_ul < 300:
|
||||
return "PRCXI_300ul_Tips"
|
||||
return "PRCXI_1000uL_Tips"
|
||||
|
||||
|
||||
def _apply_tip_rack_class_from_transfer_volumes(
|
||||
labware_info: Dict[str, Dict[str, Any]],
|
||||
protocol_steps_refactored: List[Dict[str, Any]],
|
||||
) -> None:
|
||||
"""根据各 ``transfer_liquid`` 的 asp_vols/dis_vols 为对应 ``tip_racks`` 写入 ``prcxi_class_name``。"""
|
||||
tip_to_max_ul: Dict[str, float] = {}
|
||||
|
||||
for step in protocol_steps_refactored:
|
||||
if step.get("template_name") != "transfer_liquid":
|
||||
continue
|
||||
p = step.get("param") or {}
|
||||
tip_key_raw = p.get("tip_racks")
|
||||
if tip_key_raw is None or str(tip_key_raw).strip() == "":
|
||||
continue
|
||||
tip_key = str(tip_key_raw).strip()
|
||||
if tip_key not in labware_info:
|
||||
continue
|
||||
|
||||
nums = _flatten_transfer_vols(p.get("asp_vols", p.get("asp_vol"))) + _flatten_transfer_vols(
|
||||
p.get("dis_vols", p.get("dis_vol"))
|
||||
)
|
||||
if not nums:
|
||||
continue
|
||||
step_max = max(nums)
|
||||
tip_to_max_ul[tip_key] = max(tip_to_max_ul.get(tip_key, 0.0), step_max)
|
||||
|
||||
for tip_key, max_ul in tip_to_max_ul.items():
|
||||
item = labware_info.get(tip_key)
|
||||
if item is None:
|
||||
continue
|
||||
if _infer_reagent_kind(tip_key, item) != "tip_rack":
|
||||
continue
|
||||
item["prcxi_class_name"] = _tip_prcxi_class_for_max_ul(max_ul)
|
||||
|
||||
|
||||
def _volume_template_covers_requirement(template: Dict[str, Any], req: Optional[float], kind: str) -> bool:
|
||||
"""有明确需求体积时,模板标称 Volume 必须 >= 需求;无 Volume 的模板不参与(trash 除外)。"""
|
||||
if kind == "trash":
|
||||
return True
|
||||
if req is None or req <= 0:
|
||||
return True
|
||||
mv = float(template.get("Volume") or 0)
|
||||
if mv <= 0:
|
||||
return False
|
||||
return mv >= req
|
||||
|
||||
|
||||
def _direct_labware_class_name(item: Dict[str, Any]) -> str:
|
||||
"""仅用于 tip_rack 且 ``preserve_tip_rack_incoming_class=True``:``class_name``/``class`` 原样;否则 ``labware`` → ``lab_*``。"""
|
||||
explicit = item.get("class_name") or item.get("class")
|
||||
if explicit is not None and str(explicit).strip() != "":
|
||||
return str(explicit).strip()
|
||||
lw = str(item.get("labware", "") or "").strip()
|
||||
if lw:
|
||||
return f"lab_{lw.lower().replace('.', 'point').replace(' ', '_')}"
|
||||
return ""
|
||||
|
||||
|
||||
def _match_score_prcxi_template(
|
||||
template: Dict[str, Any],
|
||||
num_children: int,
|
||||
child_max_volume: Optional[float],
|
||||
) -> float:
|
||||
"""孔数差主导;有需求体积且模板已满足 >= 时,余量比例 (模板-需求)/需求 越小越好(优先选刚好够的)。"""
|
||||
hole_count = int(template.get("hole_count") or 0)
|
||||
hole_diff = abs(num_children - hole_count)
|
||||
material_volume = float(template.get("Volume") or 0)
|
||||
req = child_max_volume
|
||||
if req is not None and req > 0 and material_volume > 0:
|
||||
vol_diff = (material_volume - req) / max(req, 1e-9)
|
||||
elif material_volume > 0 and req is not None:
|
||||
vol_diff = abs(float(req) - material_volume) / material_volume
|
||||
else:
|
||||
vol_diff = 0.0
|
||||
return hole_diff * 1000 + vol_diff
|
||||
|
||||
|
||||
def _apply_prcxi_labware_auto_match(
|
||||
labware_info: Dict[str, Dict[str, Any]],
|
||||
labware_defs: Optional[List[Dict[str, Any]]] = None,
|
||||
*,
|
||||
preserve_tip_rack_incoming_class: bool = True,
|
||||
) -> None:
|
||||
"""上传构建图前:按孔数+容量将 reagent 条目匹配到 ``prcxi_labware`` 注册类名,写入 ``prcxi_class_name``。
|
||||
若给出需求体积,仅选用模板标称 Volume >= 该值的物料,并在满足条件的模板中选余量最小者。
|
||||
|
||||
``preserve_tip_rack_incoming_class=True``(默认)时:**仅 tip_rack** 不做模板匹配,类名由 ``class_name``/``class`` 或
|
||||
``labware``(``lab_*``)直接给出;**plate / tube_rack / trash 等**仍按注册模板匹配。
|
||||
``False`` 时 **全部**(含 tip_rack)走模板匹配。"""
|
||||
if not labware_info:
|
||||
return
|
||||
|
||||
default_prcxi_tip_class = CLASS_NAMES_MAPPING.get("tip_rack", "PRCXI_300ul_Tips")
|
||||
|
||||
try:
|
||||
from unilabos.devices.liquid_handling.prcxi.prcxi_labware import get_prcxi_labware_template_specs
|
||||
except Exception:
|
||||
return
|
||||
|
||||
templates = get_prcxi_labware_template_specs()
|
||||
if not templates:
|
||||
return
|
||||
|
||||
def_map = _labware_def_index(labware_defs)
|
||||
|
||||
for labware_id, item in labware_info.items():
|
||||
if item.get("prcxi_class_name"):
|
||||
continue
|
||||
|
||||
kind = _infer_reagent_kind(labware_id, item)
|
||||
|
||||
if preserve_tip_rack_incoming_class and kind == "tip_rack":
|
||||
inc_s = _direct_labware_class_name(item)
|
||||
if inc_s == default_prcxi_tip_class:
|
||||
inc_s = ""
|
||||
if inc_s:
|
||||
item["prcxi_class_name"] = inc_s
|
||||
continue
|
||||
|
||||
explicit = item.get("class_name") or item.get("class")
|
||||
if explicit and str(explicit).startswith("PRCXI_"):
|
||||
item["prcxi_class_name"] = str(explicit)
|
||||
continue
|
||||
|
||||
extra = def_map.get(str(labware_id), {})
|
||||
|
||||
wells = item.get("well") or []
|
||||
well_n = len(wells) if isinstance(wells, list) else 0
|
||||
num_from_def = int(extra.get("num_wells") or extra.get("well_count") or item.get("num_wells") or 0)
|
||||
|
||||
if kind == "trash":
|
||||
num_children = 0
|
||||
elif kind == "tip_rack":
|
||||
num_children = num_from_def if num_from_def > 0 else 96
|
||||
elif kind == "tube_rack":
|
||||
if num_from_def > 0:
|
||||
num_children = num_from_def
|
||||
elif well_n > 0:
|
||||
num_children = well_n
|
||||
else:
|
||||
num_children = _infer_tube_rack_num_positions(labware_id, item)
|
||||
else:
|
||||
# plate:勿在无 labware_defs 时默认 96,否则 384 板会被错配成 96 模板
|
||||
num_children = _infer_plate_num_children(labware_id, item, wells, num_from_def)
|
||||
|
||||
child_max_volume = item.get("max_volume")
|
||||
if child_max_volume is None:
|
||||
child_max_volume = extra.get("max_volume")
|
||||
try:
|
||||
child_max_volume_f = float(child_max_volume) if child_max_volume is not None else None
|
||||
except (TypeError, ValueError):
|
||||
child_max_volume_f = None
|
||||
|
||||
if kind == "tip_rack" and child_max_volume_f is None:
|
||||
child_max_volume_f = _tip_volume_hint(item, labware_id) or 300.0
|
||||
|
||||
candidates = [t for t in templates if t["kind"] == kind]
|
||||
if not candidates:
|
||||
continue
|
||||
|
||||
best = None
|
||||
best_score = float("inf")
|
||||
for t in candidates:
|
||||
if kind != "trash" and int(t.get("hole_count") or 0) <= 0:
|
||||
continue
|
||||
if not _volume_template_covers_requirement(t, child_max_volume_f, kind):
|
||||
continue
|
||||
sc = _match_score_prcxi_template(t, num_children, child_max_volume_f)
|
||||
if sc < best_score:
|
||||
best_score = sc
|
||||
best = t
|
||||
|
||||
if best:
|
||||
item["prcxi_class_name"] = best["class_name"]
|
||||
|
||||
|
||||
def _reconcile_slot_carrier_prcxi_class(
|
||||
labware_info: Dict[str, Dict[str, Any]],
|
||||
*,
|
||||
preserve_tip_rack_incoming_class: bool = False,
|
||||
) -> None:
|
||||
"""同一 deck 槽位上多条 reagent 时,按载体类型优先级统一 ``prcxi_class_name``,避免先遍历到 96 板后槽位被错误绑定。
|
||||
|
||||
``preserve_tip_rack_incoming_class=True`` 时:tip_rack 条目不参与同槽类名合并(不被覆盖、也不把 tip 类名扩散到同槽其它条目)。"""
|
||||
by_slot: Dict[str, List[Tuple[str, Dict[str, Any]]]] = {}
|
||||
for lid, item in labware_info.items():
|
||||
ot = item.get("object", "") or ""
|
||||
slot = _map_deck_slot(str(item.get("slot", "")), ot)
|
||||
if not slot:
|
||||
continue
|
||||
by_slot.setdefault(str(slot), []).append((lid, item))
|
||||
|
||||
priority = {"trash": 0, "tube_rack": 1, "tip_rack": 2, "plate": 3}
|
||||
|
||||
for _slot, pairs in by_slot.items():
|
||||
if len(pairs) < 2:
|
||||
continue
|
||||
|
||||
def _rank(p: Tuple[str, Dict[str, Any]]) -> int:
|
||||
return priority.get(_infer_reagent_kind(p[0], p[1]), 9)
|
||||
|
||||
pairs_sorted = sorted(pairs, key=_rank)
|
||||
best_cls = None
|
||||
for lid, it in pairs_sorted:
|
||||
if preserve_tip_rack_incoming_class and _infer_reagent_kind(lid, it) == "tip_rack":
|
||||
continue
|
||||
c = it.get("prcxi_class_name")
|
||||
if c:
|
||||
best_cls = c
|
||||
break
|
||||
if not best_cls:
|
||||
continue
|
||||
for lid, it in pairs:
|
||||
if preserve_tip_rack_incoming_class and _infer_reagent_kind(lid, it) == "tip_rack":
|
||||
continue
|
||||
it["prcxi_class_name"] = best_cls
|
||||
|
||||
|
||||
# ---------------- Graph ----------------
|
||||
|
||||
|
||||
@@ -346,7 +726,7 @@ def refactor_data(
|
||||
"template_name": template_name,
|
||||
"resource_name": resource_name,
|
||||
"description": step.get("description", step.get("purpose", f"{operation} operation")),
|
||||
"lab_node_type": "Device",
|
||||
"lab_node_type": "ILab",
|
||||
"param": step.get("parameters", step.get("action_args", {})),
|
||||
"footer": f"{template_name}-{resource_name}",
|
||||
}
|
||||
@@ -363,23 +743,79 @@ def build_protocol_graph(
|
||||
workstation_name: str,
|
||||
action_resource_mapping: Optional[Dict[str, str]] = None,
|
||||
labware_defs: Optional[List[Dict[str, Any]]] = None,
|
||||
preserve_tip_rack_incoming_class: bool = False,
|
||||
) -> WorkflowGraph:
|
||||
"""统一的协议图构建函数,根据设备类型自动选择构建逻辑
|
||||
|
||||
Args:
|
||||
labware_info: reagent 信息字典,格式为 {name: {slot, well}, ...},用于 set_liquid 和 well 查找
|
||||
labware_info: labware 信息字典,格式为 {name: {slot, well, labware, ...}, ...}
|
||||
protocol_steps: 协议步骤列表
|
||||
workstation_name: 工作站名称
|
||||
action_resource_mapping: action 到 resource_name 的映射字典,可选
|
||||
labware_defs: labware 定义列表,格式为 [{"name": "...", "slot": "1", "type": "lab_xxx"}, ...]
|
||||
labware_defs: 可选,``[{"id": "...", "num_wells": 96, "max_volume": 2200}, ...]`` 等,辅助 PRCXI 模板匹配
|
||||
preserve_tip_rack_incoming_class: 默认 True 时**仅 tip_rack** 不跑模板匹配(类名由传入的 class/labware 决定);
|
||||
**其它载体**仍按 PRCXI 模板匹配。False 时 **全部**(含 tip_rack)都走模板匹配。
|
||||
|
||||
会先 ``refactor_data`` 规范化步骤,再根据 ``transfer_liquid`` 的 ``asp_vols``/``dis_vols`` 为对应
|
||||
``tip_racks`` 写入 ``prcxi_class_name``(最大体积 ``≤10`` → ``PRCXI_10uL_Tips``,``<300`` → ``PRCXI_300ul_Tips``,
|
||||
否则 ``PRCXI_1000uL_Tips``);无有效体积的步骤不覆盖。
|
||||
"""
|
||||
G = WorkflowGraph()
|
||||
resource_last_writer = {} # reagent_name -> "node_id:port"
|
||||
slot_to_create_resource = {} # slot -> create_resource node_id
|
||||
|
||||
protocol_steps = refactor_data(protocol_steps, action_resource_mapping)
|
||||
_apply_tip_rack_class_from_transfer_volumes(labware_info, protocol_steps)
|
||||
|
||||
_apply_prcxi_labware_auto_match(
|
||||
labware_info,
|
||||
labware_defs,
|
||||
preserve_tip_rack_incoming_class=preserve_tip_rack_incoming_class,
|
||||
)
|
||||
_reconcile_slot_carrier_prcxi_class(
|
||||
labware_info,
|
||||
preserve_tip_rack_incoming_class=preserve_tip_rack_incoming_class,
|
||||
)
|
||||
|
||||
# ==================== 第一步:按 slot 去重创建 create_resource 节点 ====================
|
||||
# 按槽聚合:同一 slot 多条 reagent 时不能只取遍历顺序第一条,否则 tip 的 prcxi_class_name / object 会被其它条目盖住
|
||||
by_slot: Dict[str, List[Tuple[str, Dict[str, Any]]]] = {}
|
||||
for labware_id, item in labware_info.items():
|
||||
object_type = item.get("object", "") or ""
|
||||
slot = _map_deck_slot(str(item.get("slot", "")), object_type)
|
||||
if not slot:
|
||||
continue
|
||||
by_slot.setdefault(slot, []).append((labware_id, item))
|
||||
|
||||
slots_info: Dict[str, Dict[str, Any]] = {}
|
||||
for slot, pairs in by_slot.items():
|
||||
def _ot_tip(it: Dict[str, Any]) -> bool:
|
||||
return str(it.get("object", "") or "").strip().lower() == "tiprack"
|
||||
|
||||
tip_pairs = [(lid, it) for lid, it in pairs if _ot_tip(it)]
|
||||
chosen_lid = ""
|
||||
chosen_item: Dict[str, Any] = {}
|
||||
prcxi_val: Optional[str] = None
|
||||
|
||||
scan = tip_pairs if tip_pairs else pairs
|
||||
for lid, it in scan:
|
||||
c = it.get("prcxi_class_name")
|
||||
if c:
|
||||
chosen_lid, chosen_item, prcxi_val = lid, it, str(c)
|
||||
break
|
||||
if not chosen_lid and scan:
|
||||
chosen_lid, chosen_item = scan[0]
|
||||
pv = chosen_item.get("prcxi_class_name")
|
||||
prcxi_val = str(pv) if pv else None
|
||||
|
||||
labware = str(chosen_item.get("labware", "") or "")
|
||||
slots_info[slot] = {
|
||||
"labware": labware,
|
||||
"labware_id": chosen_lid,
|
||||
"object": chosen_item.get("object", "") or "",
|
||||
"prcxi_class_name": prcxi_val,
|
||||
}
|
||||
|
||||
# ==================== 第一步:按 slot 创建 create_resource 节点 ====================
|
||||
# 创建 Group 节点,包含所有 create_resource 节点
|
||||
group_node_id = str(uuid.uuid4())
|
||||
G.add_node(
|
||||
@@ -395,41 +831,56 @@ def build_protocol_graph(
|
||||
param=None,
|
||||
)
|
||||
|
||||
# 直接使用 JSON 中的 labware 定义,每个 slot 一条记录,type 即 class_name
|
||||
res_index = 0
|
||||
for lw in (labware_defs or []):
|
||||
slot = str(lw.get("slot", ""))
|
||||
if not slot or slot in slot_to_create_resource:
|
||||
continue # 跳过空 slot 或已处理的 slot
|
||||
trash_create_node_id = None # 记录 trash 的 create_resource 节点
|
||||
|
||||
lw_name = lw.get("name", f"slot {slot}")
|
||||
lw_type = lw.get("type", CREATE_RESOURCE_DEFAULTS["class_name"])
|
||||
res_id = f"plate_slot_{slot}"
|
||||
|
||||
res_index += 1
|
||||
# 为每个唯一的 slot 创建 create_resource 节点
|
||||
for slot, info in slots_info.items():
|
||||
node_id = str(uuid.uuid4())
|
||||
object_type = info.get("object", "") or ""
|
||||
ot_lo = str(object_type).strip().lower()
|
||||
matched = info.get("prcxi_class_name")
|
||||
if ot_lo == "trash":
|
||||
res_type_name = "PRCXI_trash"
|
||||
elif matched:
|
||||
res_type_name = matched
|
||||
elif ot_lo == "tiprack":
|
||||
if preserve_tip_rack_incoming_class:
|
||||
lid = str(info.get("labware_id") or "").strip() or "tip_rack"
|
||||
res_type_name = f"lab_{lid.lower().replace('.', 'point').replace(' ', '_')}"
|
||||
else:
|
||||
res_type_name = CLASS_NAMES_MAPPING.get("tip_rack", "PRCXI_300ul_Tips")
|
||||
else:
|
||||
res_type_name = f"lab_{info['labware'].lower().replace('.', 'point')}"
|
||||
# 上传物料:匹配后的类型名 + _slot_ + 槽位(name / display_name / res_id 一致)
|
||||
res_id = f"{res_type_name}_slot_{slot}".replace(" ", "_")
|
||||
G.add_node(
|
||||
node_id,
|
||||
template_name="create_resource",
|
||||
resource_name="host_node",
|
||||
name=lw_name,
|
||||
description=f"Create {lw_name}",
|
||||
name=res_id,
|
||||
display_name=res_id,
|
||||
description=f"Create plate on slot {slot}",
|
||||
lab_node_type="Labware",
|
||||
footer="create_resource-host_node",
|
||||
device_name=DEVICE_NAME_HOST,
|
||||
type=NODE_TYPE_DEFAULT,
|
||||
parent_uuid=group_node_id,
|
||||
minimized=True,
|
||||
parent_uuid=group_node_id, # 指向 Group 节点
|
||||
minimized=True, # 折叠显示
|
||||
param={
|
||||
"res_id": res_id,
|
||||
"device_id": CREATE_RESOURCE_DEFAULTS["device_id"],
|
||||
"class_name": lw_type,
|
||||
"parent": CREATE_RESOURCE_DEFAULTS["parent_template"],
|
||||
"class_name": res_type_name,
|
||||
"parent": CREATE_RESOURCE_DEFAULTS["parent_template"].format(slot=slot),
|
||||
"bind_locations": {"x": 0.0, "y": 0.0, "z": 0.0},
|
||||
"slot_on_deck": slot,
|
||||
},
|
||||
)
|
||||
slot_to_create_resource[slot] = node_id
|
||||
if ot_lo == "tiprack":
|
||||
resource_last_writer[info["labware_id"]] = f"{node_id}:labware"
|
||||
if ot_lo == "trash":
|
||||
trash_create_node_id = node_id
|
||||
# create_resource 之间不需要 ready 连接
|
||||
|
||||
# ==================== 第二步:为每个 reagent 创建 set_liquid_from_plate 节点 ====================
|
||||
# 创建 Group 节点,包含所有 set_liquid_from_plate 节点
|
||||
@@ -456,23 +907,31 @@ def build_protocol_graph(
|
||||
if item.get("type") == "hardware":
|
||||
continue
|
||||
|
||||
slot = str(item.get("slot", ""))
|
||||
object_type = item.get("object", "") or ""
|
||||
slot = _map_deck_slot(str(item.get("slot", "")), object_type)
|
||||
wells = item.get("well", [])
|
||||
if not wells or not slot:
|
||||
continue
|
||||
|
||||
# res_id 不能有空格
|
||||
# res_id 不能有空格(液体名仍用协议中的 reagent key)
|
||||
res_id = str(labware_id).replace(" ", "_")
|
||||
well_count = len(wells)
|
||||
liquid_volume = DEFAULT_LIQUID_VOLUME if object_type == "source" else 0
|
||||
|
||||
node_id = str(uuid.uuid4())
|
||||
set_liquid_index += 1
|
||||
prcxi_mat = item.get("prcxi_class_name")
|
||||
if prcxi_mat:
|
||||
sl_node_title = f"{prcxi_mat}_slot_{slot}_{res_id}"
|
||||
else:
|
||||
sl_node_title = f"lab_{res_id.lower()}_slot_{slot}_{set_liquid_index}"
|
||||
|
||||
G.add_node(
|
||||
node_id,
|
||||
template_name="set_liquid_from_plate",
|
||||
resource_name="liquid_handler.prcxi",
|
||||
name=f"SetLiquid {set_liquid_index}",
|
||||
name=sl_node_title,
|
||||
display_name=sl_node_title,
|
||||
description=f"Set liquid: {labware_id}",
|
||||
lab_node_type="Reagent",
|
||||
footer="set_liquid_from_plate-liquid_handler.prcxi",
|
||||
@@ -484,7 +943,7 @@ def build_protocol_graph(
|
||||
"plate": [], # 通过连接传递
|
||||
"well_names": wells, # 孔位名数组,如 ["A1", "A3", "A5"]
|
||||
"liquid_names": [res_id] * well_count,
|
||||
"volumes": [DEFAULT_LIQUID_VOLUME] * well_count,
|
||||
"volumes": [liquid_volume] * well_count,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -498,8 +957,12 @@ def build_protocol_graph(
|
||||
# set_liquid_from_plate 的输出 output_wells 用于连接 transfer_liquid
|
||||
resource_last_writer[labware_id] = f"{node_id}:output_wells"
|
||||
|
||||
# transfer_liquid 之间通过 ready 串联,从 None 开始
|
||||
last_control_node_id = None
|
||||
# 收集所有 create_resource 节点 ID,用于让第一个 transfer_liquid 等待所有资源创建完成
|
||||
all_create_resource_node_ids = list(slot_to_create_resource.values())
|
||||
|
||||
# transfer_liquid 之间通过 ready 串联;第一个 transfer_liquid 需要等待所有 create_resource 完成
|
||||
last_control_node_id = trash_create_node_id
|
||||
is_first_action_node = True
|
||||
|
||||
# 端口名称映射:JSON 字段名 -> 实际 handle key
|
||||
INPUT_PORT_MAPPING = {
|
||||
@@ -511,6 +974,7 @@ def build_protocol_graph(
|
||||
"reagent": "reagent",
|
||||
"solvent": "solvent",
|
||||
"compound": "compound",
|
||||
"tip_racks": "tip_rack_identifier",
|
||||
}
|
||||
|
||||
OUTPUT_PORT_MAPPING = {
|
||||
@@ -525,8 +989,17 @@ def build_protocol_graph(
|
||||
"compound": "compound",
|
||||
}
|
||||
|
||||
# 需要根据 wells 数量扩展的参数列表(复数形式)
|
||||
EXPAND_BY_WELLS_PARAMS = ["asp_vols", "dis_vols", "asp_flow_rates", "dis_flow_rates"]
|
||||
# 需要根据 wells 数量扩展的参数列表:
|
||||
# - 复数参数(asp_vols 等)支持单值自动扩展
|
||||
# - liquid_height 按 wells 扩展为数组
|
||||
# - mix_* 参数保持标量,避免被转换为 list
|
||||
EXPAND_BY_WELLS_PARAMS = [
|
||||
"asp_vols",
|
||||
"dis_vols",
|
||||
"asp_flow_rates",
|
||||
"dis_flow_rates",
|
||||
"liquid_height",
|
||||
]
|
||||
|
||||
# 处理协议步骤
|
||||
for step in protocol_steps:
|
||||
@@ -540,6 +1013,57 @@ def build_protocol_graph(
|
||||
if old_name in params:
|
||||
params[new_name] = params.pop(old_name)
|
||||
|
||||
# touch_tip 输入归一化:
|
||||
# - 支持 bool / 0/1 / "true"/"false" / 单元素 list
|
||||
# - 最终统一为 bool 标量,避免被下游误当作序列处理
|
||||
if "touch_tip" in params:
|
||||
touch_tip_value = params.get("touch_tip")
|
||||
if isinstance(touch_tip_value, list):
|
||||
if len(touch_tip_value) == 1:
|
||||
touch_tip_value = touch_tip_value[0]
|
||||
elif len(touch_tip_value) == 0:
|
||||
touch_tip_value = False
|
||||
else:
|
||||
warnings.append(f"touch_tip 期望标量,但收到长度为 {len(touch_tip_value)} 的列表,使用首个值")
|
||||
touch_tip_value = touch_tip_value[0]
|
||||
if isinstance(touch_tip_value, str):
|
||||
norm = touch_tip_value.strip().lower()
|
||||
if norm in {"true", "1", "yes", "y", "on"}:
|
||||
touch_tip_value = True
|
||||
elif norm in {"false", "0", "no", "n", "off", ""}:
|
||||
touch_tip_value = False
|
||||
else:
|
||||
warnings.append(f"touch_tip 字符串值无法识别: {touch_tip_value},按 True 处理")
|
||||
touch_tip_value = True
|
||||
elif isinstance(touch_tip_value, (int, float)):
|
||||
touch_tip_value = bool(touch_tip_value)
|
||||
elif touch_tip_value is None:
|
||||
touch_tip_value = False
|
||||
else:
|
||||
touch_tip_value = bool(touch_tip_value)
|
||||
params["touch_tip"] = touch_tip_value
|
||||
|
||||
# delays 输入归一化:
|
||||
# - 支持标量(int/float/字符串数字)与 list
|
||||
# - 最终统一为数字列表,供下游按 delays[0]/delays[1] 使用
|
||||
if "delays" in params:
|
||||
delays_value = params.get("delays")
|
||||
if delays_value is None or delays_value == "":
|
||||
params["delays"] = []
|
||||
else:
|
||||
raw_list = delays_value if isinstance(delays_value, list) else [delays_value]
|
||||
normalized_delays = []
|
||||
for delay_item in raw_list:
|
||||
if isinstance(delay_item, str):
|
||||
delay_item = delay_item.strip()
|
||||
if delay_item == "":
|
||||
continue
|
||||
try:
|
||||
normalized_delays.append(float(delay_item))
|
||||
except (TypeError, ValueError):
|
||||
warnings.append(f"delays 包含无法转换为数字的值: {delay_item},已忽略")
|
||||
params["delays"] = normalized_delays
|
||||
|
||||
# 处理输入连接
|
||||
for param_key, target_port in INPUT_PORT_MAPPING.items():
|
||||
resource_name = params.get(param_key)
|
||||
@@ -606,7 +1130,12 @@ def build_protocol_graph(
|
||||
G.add_node(node_id, **step_copy)
|
||||
|
||||
# 控制流
|
||||
if last_control_node_id is not None:
|
||||
if is_first_action_node:
|
||||
# 第一个 transfer_liquid 需要等待所有 create_resource 完成
|
||||
for cr_node_id in all_create_resource_node_ids:
|
||||
G.add_edge(cr_node_id, node_id, source_port="ready", target_port="ready")
|
||||
is_first_action_node = False
|
||||
elif 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
|
||||
|
||||
|
||||
@@ -210,6 +210,7 @@ def convert_from_json(
|
||||
data: Union[str, PathLike, Dict[str, Any]],
|
||||
workstation_name: str = DEFAULT_WORKSTATION,
|
||||
validate: bool = True,
|
||||
preserve_tip_rack_incoming_class: bool = False,
|
||||
) -> WorkflowGraph:
|
||||
"""
|
||||
从 JSON 数据或文件转换为 WorkflowGraph
|
||||
@@ -221,6 +222,8 @@ def convert_from_json(
|
||||
data: JSON 文件路径、字典数据、或 JSON 字符串
|
||||
workstation_name: 工作站名称,默认 "PRCXi"
|
||||
validate: 是否校验句柄配置,默认 True
|
||||
preserve_tip_rack_incoming_class: True(默认)时仅 tip_rack 不跑模板、按传入类名/labware;其它载体仍自动匹配。
|
||||
False 时全部走模板。JSON 根 ``preserve_tip_rack_incoming_class`` 可覆盖此参数。
|
||||
|
||||
Returns:
|
||||
WorkflowGraph: 构建好的工作流图
|
||||
@@ -263,6 +266,10 @@ def convert_from_json(
|
||||
# reagent 已经是字典格式,用于 set_liquid 和 well 数量查找
|
||||
labware_info = reagent
|
||||
|
||||
preserve = preserve_tip_rack_incoming_class
|
||||
if "preserve_tip_rack_incoming_class" in json_data:
|
||||
preserve = bool(json_data["preserve_tip_rack_incoming_class"])
|
||||
|
||||
# 构建工作流图
|
||||
graph = build_protocol_graph(
|
||||
labware_info=labware_info,
|
||||
@@ -270,6 +277,7 @@ def convert_from_json(
|
||||
workstation_name=workstation_name,
|
||||
action_resource_mapping=ACTION_RESOURCE_MAPPING,
|
||||
labware_defs=labware_defs,
|
||||
preserve_tip_rack_incoming_class=preserve,
|
||||
)
|
||||
|
||||
# 校验句柄配置
|
||||
@@ -287,6 +295,7 @@ def convert_from_json(
|
||||
def convert_json_to_node_link(
|
||||
data: Union[str, PathLike, Dict[str, Any]],
|
||||
workstation_name: str = DEFAULT_WORKSTATION,
|
||||
preserve_tip_rack_incoming_class: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
将 JSON 数据转换为 node-link 格式的字典
|
||||
@@ -298,13 +307,18 @@ def convert_json_to_node_link(
|
||||
Returns:
|
||||
Dict: node-link 格式的工作流数据
|
||||
"""
|
||||
graph = convert_from_json(data, workstation_name)
|
||||
graph = convert_from_json(
|
||||
data,
|
||||
workstation_name,
|
||||
preserve_tip_rack_incoming_class=preserve_tip_rack_incoming_class,
|
||||
)
|
||||
return graph.to_node_link_dict()
|
||||
|
||||
|
||||
def convert_json_to_workflow_list(
|
||||
data: Union[str, PathLike, Dict[str, Any]],
|
||||
workstation_name: str = DEFAULT_WORKSTATION,
|
||||
preserve_tip_rack_incoming_class: bool = True,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
将 JSON 数据转换为工作流列表格式
|
||||
@@ -316,5 +330,9 @@ def convert_json_to_workflow_list(
|
||||
Returns:
|
||||
List: 工作流节点列表
|
||||
"""
|
||||
graph = convert_from_json(data, workstation_name)
|
||||
graph = convert_from_json(
|
||||
data,
|
||||
workstation_name,
|
||||
preserve_tip_rack_incoming_class=preserve_tip_rack_incoming_class,
|
||||
)
|
||||
return graph.to_dict()
|
||||
|
||||
@@ -234,6 +234,7 @@ def convert_from_json(
|
||||
data: Union[str, PathLike, Dict[str, Any]],
|
||||
workstation_name: str = "PRCXi",
|
||||
validate: bool = True,
|
||||
preserve_tip_rack_incoming_class: bool = False,
|
||||
) -> WorkflowGraph:
|
||||
"""
|
||||
从 JSON 数据或文件转换为 WorkflowGraph
|
||||
@@ -246,6 +247,7 @@ def convert_from_json(
|
||||
data: JSON 文件路径、字典数据、或 JSON 字符串
|
||||
workstation_name: 工作站名称,默认 "PRCXi"
|
||||
validate: 是否校验句柄配置,默认 True
|
||||
preserve_tip_rack_incoming_class: True 时仅 tip 不跑模板;False 时全部匹配;JSON 根字段同名可覆盖
|
||||
|
||||
Returns:
|
||||
WorkflowGraph: 构建好的工作流图
|
||||
@@ -295,12 +297,17 @@ def convert_from_json(
|
||||
"3. {'steps': [...], 'labware': [...]}"
|
||||
)
|
||||
|
||||
preserve = preserve_tip_rack_incoming_class
|
||||
if "preserve_tip_rack_incoming_class" in json_data:
|
||||
preserve = bool(json_data["preserve_tip_rack_incoming_class"])
|
||||
|
||||
# 构建工作流图
|
||||
graph = build_protocol_graph(
|
||||
labware_info=labware_info,
|
||||
protocol_steps=protocol_steps,
|
||||
workstation_name=workstation_name,
|
||||
action_resource_mapping=ACTION_RESOURCE_MAPPING,
|
||||
preserve_tip_rack_incoming_class=preserve,
|
||||
)
|
||||
|
||||
# 校验句柄配置
|
||||
|
||||
Reference in New Issue
Block a user