Compare commits

...

101 Commits

Author SHA1 Message Date
q434343
acb2dc9359 初始化完成后上传更新deck 2026-04-14 16:57:33 +08:00
q434343
f22c3f4c42 修改移液部分代码,如果没有10ul枪头,则不使用10ul移液枪 2026-04-14 15:35:04 +08:00
q434343
7df67ea9f3 Merge branch 'dev' into prcix9320 2026-04-09 18:12:25 +08:00
q434343
4d3a41ed0d 修改部分移液逻辑 2026-04-09 18:06:12 +08:00
Xuwznln
58997f0654 fix create_resource_with_slot 2026-04-09 17:34:25 +08:00
Xuwznln
fbfc3e30fb update unilabos_formulation & batch-submit-exp 2026-04-09 16:40:31 +08:00
Xuwznln
1d1c1367df scale multi exec thread up to 48 2026-04-09 14:15:38 +08:00
q434343
56d25b88bd 使用16个孔与固定tip头类型,来定位slot位置 2026-04-08 02:51:28 +08:00
q434343
95f3e0b291 修改物料位置与初始化位置计算方法 2026-04-03 17:10:14 +08:00
Xuwznln
c91b600e90 update handle creation api 2026-04-02 22:53:31 +08:00
Xuwznln
49b3c850f9 fit cocurrent gap 2026-04-02 16:01:23 +08:00
q434343
9b706236f6 完成物料位置标定 2026-04-02 12:44:33 +08:00
q434343
9f60e65b6d Merge pull request #255 from ALITTLELZ/labwaresize
新增 tip_above_rack_length 参数并更新枪头尺寸
2026-04-01 18:33:59 +08:00
ALITTLELZ
59aa991988 新增 tip_above_rack_length 参数并更新 PRCXI 枪头尺寸
- TipInfo 新增 tip_above_rack_length 可选字段
- 编辑器支持 tip_above 与 dz 互算,更新中文标签
- 侧视图绘制枪头露出部分并标注,俯视图/侧视图增加 dx/dy/dz 标注
- 预览增加回中按钮,详情页展示新字段
- 导入时自动计算 tip_above_rack_length
- 批量更新 PRCXI 枪头物理尺寸及 registry YAML

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 18:22:23 +08:00
q434343
aff340de84 Merge pull request #254 from ALITTLELZ/labwaresize
添加 PRCXI 耗材管理 Web 应用 (labware_manager)
2026-04-01 17:03:20 +08:00
Xuwznln
25c94af755 add running status debounce 2026-04-01 16:01:22 +08:00
ALITTLELZ
2fd4270831 添加 PRCXI 耗材管理 Web 应用 (labware_manager)
新增 labware_manager 模块:
- Web UI 支持耗材 CRUD、SVG 俯视图/侧面图实时预览
- SVG 支持触控板双指缩放(pinch-to-zoom)和平移
- 网格排列自动居中按钮(autoCenter)
- 表单参数标签中英文双语显示
- 从已有代码/YAML 导入、Python/YAML 代码生成

更新 CLAUDE.md:补充 labware manager、decorator 注册模式、CI 说明

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 15:19:52 +08:00
q434343
0d41d83ce5 Merge branch 'feat/lab_resource' into prcix9320 2026-04-01 11:55:01 +08:00
q434343
9a6f744afd Merge branch 'sjs_middle_school' into feat/lab_resource 2026-04-01 11:51:54 +08:00
q434343
8164d990cc 适配前吸空气部分 2026-04-01 11:50:42 +08:00
q434343
5c9c8a4ee9 Merge branch 'prcix9320' into sjs_middle_school 2026-03-31 18:48:20 +08:00
q434343
a48985720c 添加run_protocol参数 2026-03-31 16:11:11 +08:00
q434343
ad66fc1841 其他修改, 2026-03-31 14:57:51 +08:00
q434343
6b3f9756a0 修改真机运动方式, 2026-03-31 14:33:50 +08:00
q434343
afddc6e40c 修改上传工作流部分代码 2026-03-31 14:32:48 +08:00
Xuwznln
861a012747 allow non @topic_config support 2026-03-31 13:15:06 +08:00
q434343
edd67e4880 Merge branch 'dev' into feat/lab_resource 2026-03-27 19:50:51 +08:00
Xuwznln
ee63e95f50 update skill 2026-03-25 23:20:13 +08:00
q434343
1ab1ed69d4 Merge branch 'dev' into feat/lab_resource 2026-03-25 19:48:39 +08:00
q434343
ad2e5a1c04 更新prcxi的版面更新与工作流上传方法 2026-03-25 19:42:25 +08:00
Xuwznln
dbf5df6e4d add placeholder keys 2026-03-25 12:11:10 +08:00
Xuwznln
f10c0343ce add placeholder keys 2026-03-25 11:56:40 +08:00
Xuwznln
8b6553bdd9 always free 2026-03-25 11:24:19 +08:00
q434343
04c0564366 Update .gitignore 2026-03-25 06:04:36 +08:00
Xuwznln
e7a4afd6b5 提交实验技能 2026-03-25 00:42:28 +08:00
Xuwznln
f18f6d82fc disable samples 2026-03-24 23:45:50 +08:00
Xuwznln
b7c726635c correct sample demo ret value 2026-03-24 23:24:12 +08:00
Xuwznln
c809912fd3 新增试剂reagent 2026-03-24 23:22:45 +08:00
Xuwznln
d956b27e9f update registry 2026-03-24 23:10:57 +08:00
Xuwznln
ff1e21fcd8 新增manual_confirm 2026-03-24 23:04:00 +08:00
Xuwznln
b9d9666003 add workstation creation skill 2026-03-24 23:03:49 +08:00
Junhan Chang
d776550a4b add virtual_sample_demo 样品追踪测试设备 2026-03-23 16:43:20 +08:00
Xuwznln
3d8123849a add external devices param
fix registry upload missing type
2026-03-23 15:01:16 +08:00
q434343
9d65718f37 Merge branch 'prcix9320' into feat/lab_resource 2026-03-23 00:38:40 +08:00
q434343
35bcf6765d 修改rviz显示逻辑与joint_publisher,添加moveit2相关节点描述 2026-03-23 00:00:57 +08:00
Xuwznln
d2f204c5b0 bump to 0.10.19 2026-03-22 04:17:21 +08:00
Xuwznln
d8922884b1 fast registry load 2026-03-22 04:14:47 +08:00
Xuwznln
427afe83d4 minor fix on skill & registry 2026-03-22 03:36:28 +08:00
Xuwznln
23c2e3b2f7 stripe ros2 schema desc
add create-device-skill
2026-03-22 03:21:13 +08:00
Xuwznln
59c26265e9 new registry system backwards to yaml 2026-03-22 02:19:54 +08:00
Xuwznln
4c2adea55a remove not exist resource 2026-03-21 23:35:51 +08:00
Xuwznln
0f6264503a new registry sys
exp. support with add device
2026-03-21 19:26:24 +08:00
Junhan Chang
2c554182d3 add ai conventions 2026-03-19 14:14:40 +08:00
q434343
cdbca70222 修改workflow上传逻辑,在trash初始化后再开始移液,修改枪头pick和drop的判断 2026-03-19 02:35:25 +08:00
q434343
1a267729e4 修改tracker的问题,在volumetracker报错时,禁用并进行下一步 2026-03-17 20:29:15 +08:00
q434343
b11f6eac55 修改pylabrobot更新后的影响 2026-03-16 20:42:13 +08:00
q434343
d85ff540c4 完成mix,liquid_hight,touch_tip,delay等参数的传递 2026-03-12 13:58:06 +08:00
Xuwznln
6d319d91ff correct raise create resource error 2026-03-10 16:26:37 +08:00
Xuwznln
3155b2f97e ret info fix revert 2026-03-10 16:04:27 +08:00
Xuwznln
e5e30a1c7d ret info fix 2026-03-10 16:00:24 +08:00
Xuwznln
4e82f62327 fix prcxi check 2026-03-10 15:57:27 +08:00
Xuwznln
95d3456214 add create_resource schema 2026-03-10 15:27:39 +08:00
Xuwznln
38bf95b13c re signal host ready event 2026-03-10 14:13:06 +08:00
q434343
5f45a0b81b 修改transfer liquid方法 2026-03-09 19:48:57 +08:00
Xuwznln
f2c0bec02c add websocket connection timeout and improve reconnection logic
add open_timeout parameter to websocket connection
add TimeoutError and InvalidStatus exception handling
implement exponential backoff for reconnection attempts
simplify reconnection logic flow
2026-03-07 04:40:56 +08:00
Xuwznln
e0394bf414 Merge remote-tracking branch 'origin/dev' into dev 2026-03-04 19:18:55 +08:00
Xuwznln
975a56415a import gzip 2026-03-04 19:18:36 +08:00
Xuwznln
cadbe87e3f add gzip 2026-03-04 19:18:19 +08:00
Xuwznln
b993c1f590 add gzip 2026-03-04 19:18:09 +08:00
Xuwznln
e0fae94c10 change pose extra to any 2026-03-04 19:06:58 +08:00
Xuwznln
b5cd181ac1 add isFlapY 2026-03-04 18:59:45 +08:00
Xuwznln
6bf9a319c7 Merge branch 'dev' into feat/lab_resource 2026-03-03 18:05:43 +08:00
Xuwznln
5c047beb83 support container as example
add z index

(cherry picked from commit 145fcaae65)
2026-03-03 18:04:13 +08:00
Xuwznln
b40c087143 fix container volume 2026-03-03 17:13:32 +08:00
q434343
74f0d5ee65 Merge branch 'feat/lab_resource' of https://github.com/deepmodeling/Uni-Lab-OS into feat/lab_resource 2026-03-03 14:17:36 +08:00
Xuwznln
7f1cc3b2a5 update materials 2026-03-03 11:43:52 +08:00
Xuwznln
2596d48a2f update materials 2026-03-03 11:43:41 +08:00
Xuwznln
3f160c2049 更新prcxi deck & 新增 unilabos_resource_slot 2026-03-03 11:40:23 +08:00
Xuwznln
2ac1a3242a 更新prcxi deck & 新增 unilabos_resource_slot 2026-03-03 11:40:02 +08:00
q434343
5d208c832b 修改工作流上传以及lh的物料初步判定 2026-03-02 18:32:44 +08:00
q434343
786498904d 修改上传方式,添加tip_rack的连线 2026-03-02 18:32:18 +08:00
q434343
a9ea9f425d 添加单枪头的多对多移液判定 2026-03-02 18:31:28 +08:00
Xuwznln
b3bc951cae registry update & workflow update 2026-03-02 18:31:26 +08:00
Xuwznln
01df4f1115 add resource 2026-03-02 18:30:07 +08:00
Xuwznln
a54e7c0f23 new workflow & prcxi slot removal 2026-03-02 18:29:25 +08:00
Xuwznln
e5015cd5e0 fix size change 2026-03-02 15:52:44 +08:00
Xuwznln
514373c164 v0.10.18
(cherry picked from commit 06b6f0d804)
2026-03-02 02:30:10 +08:00
Xuwznln
fcea02585a no opcua installation on macos 2026-02-28 09:41:37 +08:00
q434343
e1074f06d2 修改工作流上传以及lh的物料初步判定 2026-02-26 10:52:41 +08:00
q434343
0dc273f366 修改上传方式,添加tip_rack的连线 2026-02-24 19:37:11 +08:00
q434343
2e5fac26b3 添加单枪头的多对多移液判定 2026-02-13 13:46:27 +08:00
Xuwznln
07cf690897 fix possible crash 2026-02-12 01:46:26 +08:00
Xuwznln
cfea27460a fix deck & host_node 2026-02-12 01:46:24 +08:00
Xuwznln
b7d3e980a9 set liquid with tube 2026-02-12 01:46:23 +08:00
Xuwznln
5c2da9b793 fix possible crash 2026-02-11 23:44:53 +08:00
Xuwznln
45efbfcd12 fix deck & host_node 2026-02-11 17:33:26 +08:00
Xuwznln
8da6fdfd0b set liquid with tube 2026-02-11 16:20:07 +08:00
Xuwznln
29ea9909a5 Merge branch 'dev' into feat/lab_resource 2026-02-11 14:04:49 +08:00
Xuwznln
f9ed6cb3fb add test_resource_schema 2026-02-11 14:02:21 +08:00
Xuwznln
ee6307a568 registry update & workflow update 2026-02-10 22:45:51 +08:00
Xuwznln
8a0116c852 add resource 2026-02-10 22:44:45 +08:00
60 changed files with 12771 additions and 2386 deletions

View File

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

View 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>/` 目录下

View 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 9300Deck 通过 `sites` 列表定义前端展示的放置位,前端据此渲染可拖拽的孔位布局:
```python
import collections
from typing import Any, Dict, List, Optional
from pylabrobot.resources import Deck, Resource, Coordinate
from unilabos.registry.decorators import resource
@resource(id="MyLabDeck", category=["deck"], description="带 Site 定向放置的 Deck")
class MyLabDeck(Deck):
# 根据设备台面实测批量计算各 slot 坐标偏移
_DEFAULT_SITE_POSITIONS = [
(0, 0, 0), (138, 0, 0), (276, 0, 0), (414, 0, 0), # T1-T4
(0, 96, 0), (138, 96, 0), (276, 96, 0), (414, 96, 0), # T5-T8
]
_DEFAULT_SITE_SIZE = {"width": 128.0, "height": 86.0, "depth": 0}
_DEFAULT_CONTENT_TYPE = ["plate", "tip_rack", "tube_rack", "adaptor"]
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
sites: Optional[List[Dict[str, Any]]] = None, **kwargs):
super().__init__(size_x, size_y, size_z, name)
if sites is not None:
self.sites = [dict(s) for s in sites]
else:
self.sites = []
for i, (x, y, z) in enumerate(self._DEFAULT_SITE_POSITIONS):
self.sites.append({
"label": f"T{i + 1}", # 前端显示的槽位标签
"visible": True, # 是否在前端可见
"position": {"x": x, "y": y, "z": z}, # 槽位物理坐标
"size": dict(self._DEFAULT_SITE_SIZE), # 槽位尺寸
"content_type": list(self._DEFAULT_CONTENT_TYPE), # 允许放入的物料类型
})
self._ordering = collections.OrderedDict(
(site["label"], None) for site in self.sites
)
def assign_child_resource(self, resource: Resource,
location: Optional[Coordinate] = None,
reassign: bool = True,
spot: Optional[int] = None):
idx = spot
if spot is None:
for i, site in enumerate(self.sites):
if site.get("label") == resource.name:
idx = i
break
if idx is None:
for i in range(len(self.sites)):
if self._get_site_resource(i) is None:
idx = i
break
if idx is None:
raise ValueError(f"No available site for '{resource.name}'")
loc = Coordinate(**self.sites[idx]["position"])
super().assign_child_resource(resource, location=loc, reassign=reassign)
def serialize(self) -> dict:
data = super().serialize()
sites_out = []
for i, site in enumerate(self.sites):
occupied = self._get_site_resource(i)
sites_out.append({
"label": site["label"],
"visible": site.get("visible", True),
"occupied_by": occupied.name if occupied else None,
"position": site["position"],
"size": site["size"],
"content_type": site["content_type"],
})
data["sites"] = sites_out
return data
```
**Site 字段说明:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `label` | str | 槽位标签(如 `"T1"`),前端显示名称,也用于匹配 resource.name |
| `visible` | bool | 是否在前端可见 |
| `position` | dict | 物理坐标 `{x, y, z}`mm需自行测量计算偏移 |
| `size` | dict | 槽位尺寸 `{width, height, depth}`mm |
| `content_type` | list | 允许放入的物料类型,如 `["plate", "tip_rack", "tube_rack", "adaptor"]` |
**参考实现:** `unilabos/devices/liquid_handling/prcxi/prcxi.py` 中的 `PRCXI9300Deck`4x4 共 16 个 site
---
## 文件位置
```
unilabos/resources/
├── <project>/ # 按项目分组
│ ├── bottles.py # Bottle 工厂函数
│ ├── bottle_carriers.py # Carrier 工厂函数
│ ├── warehouses.py # WareHouse 工厂函数
│ └── decks.py # Deck 类定义
```
---
## 验证
```bash
# 资源可导入
python -c "from unilabos.resources.my_project.bottles import My_Reagent_Bottle; print(My_Reagent_Bottle('test'))"
# 启动测试AST 自动扫描)
unilab -g <graph>.json
```
仅在以下情况仍需 YAML第三方库资源如 pylabrobot 内置资源,无 `@resource` 装饰器)。
---
## 关键路径
| 内容 | 路径 |
|------|------|
| Bottle/Carrier 基类 | `unilabos/resources/itemized_carrier.py` |
| WareHouse 基类 + 工厂 | `unilabos/resources/warehouse.py` |
| PLR 注册 | `unilabos/resources/plr_additional_res_reg.py` |
| 装饰器定义 | `unilabos/registry/decorators.py` |

View File

@@ -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` |

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

View File

@@ -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)
```

View 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"}
```

View 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`)中查找。

View File

@@ -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 tokenbase64(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 tokenbase64(ak:sk)")
print(" --base <url> API 基础 URL")
print(" --workflow-uuid <uuid> 目标 workflow UUID")
print()
print("可选参数:")
print(" --registry <path> 注册表文件路径(默认自动搜索)")
print(" --rounds <n> 实验轮次数(默认 1")
print(" --output <path> 输出文件路径(默认 notebook_template.json")
print(" --dump-response 打印 API 原始响应")
sys.exit(1)
# 1. 查找并加载本地注册表
registry_path = find_registry(opts["registry"])
registry_index = {}
if registry_path:
mtime = os.path.getmtime(registry_path)
gen_time = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
print(f"注册表: {registry_path} (生成时间: {gen_time})")
registry_data = load_registry(registry_path)
registry_index = build_registry_index(registry_data)
print(f"已索引 {len(registry_index)} 个设备的 action schemas")
else:
print("警告: 未找到本地注册表,将跳过 param 模板生成")
print(" 提交时需要手动填写各节点的 param 字段")
# 2. 获取 workflow 详情
print(f"\n正在获取 workflow 详情: {opts['workflow_uuid']}")
response = fetch_workflow_detail(opts["base"], opts["auth"], opts["workflow_uuid"])
if not response:
print("错误: 无法获取 workflow 详情")
sys.exit(1)
if opts["dump_response"]:
print("\n=== API 原始响应 ===")
print(json.dumps(response, indent=2, ensure_ascii=False)[:5000])
print("=== 响应结束(截断至 5000 字符) ===\n")
# 3. 提取节点
nodes = extract_nodes_from_response(response)
if not nodes:
print("错误: 未能从 workflow 中提取任何 action 节点")
print("请使用 --dump-response 查看原始响应结构")
sys.exit(1)
print(f"\n找到 {len(nodes)} 个 action 节点:")
print(f" {'节点 UUID':<40} {'设备 ID':<30} {'动作名':<25} {'Schema'}")
print(" " + "-" * 110)
for node_uuid, resource_name, template_name, _ in nodes:
matched = "" if (resource_name in registry_index and
template_name in registry_index.get(resource_name, {})) else ""
print(f" {node_uuid:<40} {resource_name:<30} {template_name:<25} {matched}")
# 4. 生成模板
template = generate_template(nodes, registry_index, opts["rounds"])
template["workflow_uuid"] = opts["workflow_uuid"]
output_path = opts["output"]
with open(output_path, "w", encoding="utf-8") as f:
json.dump(template, f, indent=2, ensure_ascii=False)
print(f"\n模板已写入: {output_path}")
print(f" 轮次数: {opts['rounds']}")
print(f" 节点数/轮: {len(nodes)}")
print()
print("下一步:")
print(" 1. 打开模板文件,将 $TODO 占位符替换为实际值")
print(" 2. 删除 _schema_info 字段(仅供参考)")
print(" 3. 使用 POST /api/v1/lab/notebook 提交")
if __name__ == "__main__":
main()

View File

@@ -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
└── ...
```

View File

@@ -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()

View 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()

View 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 tokenbase64(ak:sk) |
| `--base` | 提交时必选 | API base URL |
| `--submit` | 否 | 加上此标志则直接提交到云端 |
| `--output` | 否 | 输出 JSON 路径(默认 `agent_result_body.json` |
### 文件合并规则
| 文件类型 | 合并方式 |
|----------|----------|
| `.json`dict | 字段直接合并到 `agent_result` 顶层 |
| `.json`list/other | 以文件名为 key 放入 `agent_result` |
| `.csv` | 以文件名(不含扩展名)为 key值为行对象数组 |
多个文件的字段会合并。JSON dict 中的重复 key 后者覆盖前者。
### 示例
```bash
# 仅生成请求体文件(不提交)
python scripts/prepare_agent_result.py \
--notebook-uuid 73c67dca-c8cc-4936-85a0-329106aa7cca \
--files results.json measurements.csv
# 生成并直接提交
python scripts/prepare_agent_result.py \
--notebook-uuid 73c67dca-c8cc-4936-85a0-329106aa7cca \
--files results.json \
--auth YTFmZDlkNGUt... \
--base https://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>` 替代。

View File

@@ -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 tokenbase64(ak:sk)")
parser.add_argument("--base", help="API base URL")
parser.add_argument("--submit", action="store_true", help="直接提交到云端")
parser.add_argument("--output", default="agent_result_body.json", help="输出 JSON 文件路径")
args = parser.parse_args()
for fp in args.files:
if not os.path.exists(fp):
print(f"[错误] 文件不存在: {fp}", file=sys.stderr)
sys.exit(1)
agent_result = merge_files(args.files)
body = build_request_body(args.notebook_uuid, agent_result)
with open(args.output, "w", encoding="utf-8") as f:
json.dump(body, f, ensure_ascii=False, indent=2)
print(f"[完成] 请求体已保存: {args.output}")
print(f" notebook_uuid: {args.notebook_uuid}")
print(f" agent_result 字段数: {len(agent_result)}")
print(f" 合并文件数: {len(args.files)}")
if args.submit:
if not args.auth or not args.base:
print("[错误] 提交需要 --auth 和 --base 参数", file=sys.stderr)
sys.exit(1)
print(f"\n[提交] PUT {args.base}/api/v1/lab/notebook/agent-result ...")
result = submit(args.base, args.auth, body)
print(f" HTTP {result['status_code']}")
print(f" 响应: {json.dumps(result['body'], ensure_ascii=False)}")
if __name__ == "__main__":
main()

4
.gitignore vendored
View File

@@ -253,8 +253,4 @@ test_config.py
/.claude
/.conda
/.cursor
/.github
/.conda/base
.conda/base/recipe.yaml

File diff suppressed because it is too large Load Diff

View File

@@ -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}))

View File

@@ -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]

View File

@@ -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]

View File

@@ -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:

View File

@@ -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

View File

@@ -87,19 +87,31 @@ class MatrixInfo(TypedDict):
WorkTablets: list[WorkTablets]
def _get_slot_number(resource) -> Optional[int]:
"""从 resource 的 unilabos_extra["update_resource_site"](如 "T13")或位置反算槽位号。"""
extra = getattr(resource, "unilabos_extra", {}) or {}
site = extra.get("update_resource_site", "")
if site:
digits = "".join(c for c in str(site) if c.isdigit())
return int(digits) if digits else None
loc = getattr(resource, "location", None)
if loc is not None and loc.x is not None and loc.y is not None:
col = round((loc.x - 5) / 137.5)
row = round(3 - (loc.y - 13) / 96)
idx = row * 4 + col
if 0 <= idx < 16:
return idx + 1
return None
class PRCXI9300Deck(Deck):
"""PRCXI 9300 的专用 Deck 类,继承自 Deck。
该类定义了 PRCXI 9300 的工作台布局和槽位信息。
"""
# 9320: 4列×4行 = 16 slotsY轴从上往下递减, T1在左上角
_9320_SITE_POSITIONS = [
(0, 288, 0), (138, 288, 0), (276, 288, 0), (414, 288, 0), # T1-T4 (第1行, 最上)
(0, 192, 0), (138, 192, 0), (276, 192, 0), (414, 192, 0), # T5-T8 (第2行)
(0, 96, 0), (138, 96, 0), (276, 96, 0), (414, 96, 0), # T9-T12 (第3行)
(0, 0, 0), (138, 0, 0), (276, 0, 0), (414, 0, 0), # T13-T16 (第4行, 最下)
]
_9320_SITE_POSITIONS = [((i%4)*137.5+5, (3-int(i/4))*96+13, 0) for i in range(0, 16)]
# 9300: 3列×2行 = 6 slots间距与9320相同X: 138mm, Y: 96mm
_9300_SITE_POSITIONS = [
@@ -113,15 +125,13 @@ class PRCXI9300Deck(Deck):
_DEFAULT_CONTENT_TYPE = ["plate", "tip_rack", "plates", "tip_racks", "tube_rack", "adaptor", "plateadapter", "module", "trash"]
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
sites: Optional[List[Dict[str, Any]]] = None, model: str = "9320", **kwargs):
super().__init__(size_x, size_y, size_z, name)
self.model = model
sites: Optional[List[Dict[str, Any]]] = None, **kwargs):
super().__init__( size_x, size_y, size_z, name=name)
if sites is not None:
self.sites: List[Dict[str, Any]] = [dict(s) for s in sites]
else:
positions = self._9300_SITE_POSITIONS if model == "9300" else self._9320_SITE_POSITIONS
self.sites = []
for i, (x, y, z) in enumerate(positions):
for i, (x, y, z) in enumerate(self._DEFAULT_SITE_POSITIONS):
self.sites.append({
"label": f"T{i + 1}",
"visible": True,
@@ -133,6 +143,7 @@ class PRCXI9300Deck(Deck):
self._ordering = collections.OrderedDict(
(site["label"], None) for site in self.sites
)
self.root = self.get_root()
def _get_site_location(self, idx: int) -> Coordinate:
pos = self.sites[idx]["position"]
@@ -175,7 +186,10 @@ class PRCXI9300Deck(Deck):
raise ValueError(f"No available site on deck '{self.name}' for resource '{resource.name}'")
if not reassign and self._get_site_resource(idx) is not None:
raise ValueError(f"Site {idx} ('{self.sites[idx]['label']}') is already occupied")
existing = self.root.get_resource(resource.name)
if existing is not resource and existing.parent is not None:
existing.parent.unassign_child_resource(existing)
loc = self._get_site_location(idx)
super().assign_child_resource(resource, location=loc, reassign=reassign)
@@ -761,23 +775,58 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
simulator=False,
step_mode=False,
matrix_id="",
is_9320=None,
is_9320=False,
start_rail=2,
rail_nums=4,
rail_interval=0,
x_increase = -0.003636,
y_increase = -0.003636,
x_offset = -1.8,
y_offset = -37.48,
deck_z = 235.5,
deck_y = 400,
rail_width=27.5,
xy_coupling = -0.0045,
calibration_points: Optional[Dict[str, List[List[float]]]] = None,
calibration_labware_type: Optional[str] = "PRCXI_300ul_Tips",
):
self._rail_width = rail_width
self._rail_interval = rail_interval
self.deck_x = (start_rail + rail_nums*5 + (rail_nums-1)*rail_interval) * rail_width
self.deck_y = deck_y
self.deck_z = deck_z
self.x_increase = x_increase
self.y_increase = y_increase
self.x_offset = x_offset
self.y_offset = y_offset
self.xy_coupling = xy_coupling
self._slot_prcxi_positions: Dict[int, Tuple[float, float]] = {}
self.calibration_labware_type = calibration_labware_type
if calibration_points is not None:
self.calibrate_from_points(calibration_points, labware_type=self.calibration_labware_type)
self.left_2_claw = Coordinate(130.2, -34, 74)
self.right_2_left = Coordinate(22,-1, 12)
self.tip_height = 0
tablets_info = []
for site_id in range(len(deck.sites)):
child = deck._get_site_resource(site_id)
# 如果放其他类型的物料,是不可以的
if hasattr(child, "_unilabos_state") and "Material" in child._unilabos_state:
number = site_id + 1
tablets_info.append(
WorkTablets(
Number=number, Code=f"T{number}", Material=child._unilabos_state["Material"]
)
)
if is_9320 is None:
is_9320 = getattr(deck, 'model', '9300') == '9320'
if is_9320:
print("当前设备是9320")
else:
for site_id in range(len(deck.sites)):
child = deck._get_site_resource(site_id)
# 如果放其他类型的物料,是不可以的
if hasattr(child, "_unilabos_state") and "Material" in child._unilabos_state:
number = site_id + 1
tablets_info.append(
WorkTablets(
Number=number, Code=f"T{number}", Material=child._unilabos_state["Material"]
)
)
# 始终初始化 step_mode 属性
self.step_mode = False
if step_mode:
@@ -789,6 +838,256 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
tablets_info, host, port, timeout, channel_num, axis, setup, debug, matrix_id, is_9320
)
super().__init__(backend=self._unilabos_backend, deck=deck, simulator=simulator, channel_num=channel_num)
self._first_transfer_done = False
@staticmethod
def _get_slot_number(resource) -> Optional[int]:
"""从 resource 的 unilabos_extra["update_resource_site"](如 "T13")或位置反算槽位号。"""
return _get_slot_number(resource)
def _match_and_create_matrix(self):
"""首次 transfer_liquid 时,根据 deck 上的 resource 自动匹配耗材并创建 WorkTabletMatrix。"""
backend = self._unilabos_backend
api = backend.api_client
if backend.matrix_id:
return
material_list = api.get_all_materials()
if not material_list:
return
# 按 materialEnum 分组: {enum_value: [material, ...]}
material_dict = {}
material_uuid_map = {}
for m in material_list:
enum_key = m.get("materialEnum")
material_dict.setdefault(enum_key, []).append(m)
if "uuid" in m:
material_uuid_map[m["uuid"]] = m
work_tablets = []
slot_none = [i for i in range(1, 17)]
for child in self.deck.children:
resource = child
number = self._get_slot_number(resource)
if number is None:
continue
# 如果 resource 已有 Material UUID直接使用
if hasattr(resource, "_unilabos_state") and "Material" in getattr(resource, "_unilabos_state", {}):
mat_uuid = resource._unilabos_state["Material"].get("uuid")
if mat_uuid and mat_uuid in material_uuid_map:
work_tablets.append({"Number": number, "Material": material_uuid_map[mat_uuid]})
slot_none.remove(number)
continue
# 根据 resource 类型推断 materialEnum
# MaterialEnum: Other=0, Tips=1, DeepWellPlate=2, PCRPlate=3, ELISAPlate=4, Reservoir=5, WasteBox=6
expected_enum = None
if isinstance(resource, TipRack):
expected_enum = 1 # Tips
elif isinstance(resource, Trash):
expected_enum = 6 # WasteBox
elif isinstance(resource, (PRCXI9300Plate, Plate)):
expected_enum = None # Plate 可能是 DeepWellPlate/PCRPlate/ELISAPlate不限定
# 根据 expected_enum 筛选候选耗材列表
if expected_enum is not None:
candidates = material_dict.get(expected_enum, [])
else:
# expected_enum 未确定时,搜索所有耗材
candidates = material_list
# 根据 children 个数和容量匹配最相似的耗材
num_children = len(resource.children)
child_max_volume = None
if resource.children:
first_child = resource.children[0]
if hasattr(first_child, "max_volume") and first_child.max_volume is not None:
child_max_volume = first_child.max_volume
best_material = None
best_score = float("inf")
for material in candidates:
hole_count = (material.get("HoleRow", 0) or 0) * (material.get("HoleColum", 0) or 0)
material_volume = material.get("Volume", 0) or 0
# 孔数差异(高权重优先匹配孔数)
hole_diff = abs(num_children - hole_count)
# 容量差异(归一化)
if child_max_volume is not None and material_volume > 0:
vol_diff = abs(child_max_volume - material_volume) / material_volume
else:
vol_diff = 0
score = hole_diff * 1000 + vol_diff
if score < best_score:
best_score = score
best_material = material
if best_material:
work_tablets.append({"Number": number, "Material": best_material})
slot_none.remove(number)
if not work_tablets:
return
matrix_id = str(uuid.uuid4())
matrix_info = {
"MatrixId": matrix_id,
"MatrixName": "matrix_" + str(time.time()),
"WorkTablets": work_tablets +
[{"Number": number, "Material": {"uuid": "730067cf07ae43849ddf4034299030e9"}} for number in slot_none],
}
res = api.add_WorkTablet_Matrix(matrix_info)
if res.get("Success"):
backend.matrix_id = matrix_id
backend.matrix_info = matrix_info
# 重新计算所有槽位的位置(初始化时 deck 可能为空,此时才有资源)
pipetting_positions = []
claw_positions = []
for child in self.deck.children:
number = self._get_slot_number(child)
if number is None:
continue
pos = self.plr_pos_to_prcxi(child, self.left_2_claw)
slot_pos = self._slot_prcxi_positions[number]
pos.x = slot_pos[0] - child.get_size_x() / 2 + self.left_2_claw.x
pos.y = slot_pos[1] - child.get_size_y() / 2 + self.left_2_claw.y
claw_positions.append({"Number": number, "XPos": pos.x, "YPos": pos.y, "ZPos": pos.z})
if child.children:
pip_pos = self.plr_pos_to_prcxi(child.children[0])
else:
pip_pos = self.plr_pos_to_prcxi(child)
pip_pos.x = slot_pos[0] - 40
pip_pos.y = slot_pos[1] - child.get_size_y() / 2
pip_pos.z = pip_pos.z - 40
half_x = child.get_size_x() / 2
z_wall = child.get_size_z()
pipetting_positions.append({
"Number": number,
"XPos": pip_pos.x,
"YPos": pip_pos.y,
"ZPos": pip_pos.z,
"X_Left": half_x,
"X_Right": half_x,
"ZAgainstTheWall": pip_pos.z - z_wall,
"X2Pos": pip_pos.x + self.right_2_left.x,
"Y2Pos": pip_pos.y + self.right_2_left.y,
"Z2Pos": pip_pos.z + self.right_2_left.z,
"X2_Left": half_x,
"X2_Right": half_x,
"ZAgainstTheWall2": pip_pos.z - z_wall,
})
if pipetting_positions:
api.update_pipetting_position(matrix_id, pipetting_positions)
# 更新 backend 中的 claw_positions
backend.claw_positions = claw_positions
if claw_positions:
api.update_clamp_jaw_position(matrix_id, claw_positions)
print(f"Auto-matched materials and created matrix: {matrix_id}")
else:
raise PRCXIError(f"Failed to create auto-matched matrix: {res.get('Message', 'Unknown error')}")
def calibrate_from_points(
self,
calibration_points: Dict[str, List[List[float]]],
labware_type: Optional[str] = "PRCXI_300ul_Tips",
):
"""从实测 PRCXI 机器坐标直接计算每个 slot 的 PRCXI 原点坐标。
校准点是将参考物料放在各 slot 后,机器移至其 A1 位置所读取的
PRCXI 坐标。通过 ``labware_type`` 创建临时实例,取 ``children[0]``
(即 A1的 location 作为偏移量,逆运算得 slot 原点。
line_1~line_N 依次对应 T1~T4, T5~T8, ...
Args:
calibration_points: ``{"line_1": [[px, py], ...], ...}``。
``[0, 0]`` 表示该点无效,不计入。
labware_type: prcxi_labware 中的工厂函数名(如 ``"PRCXI_300ul_Tips"``)。
为 ``None`` 时 dx=dy=0即校准点直接作为 slot 原点。
"""
dx, dy = 0.0, 0.0
if labware_type is not None:
from . import prcxi_labware
factory = getattr(prcxi_labware, labware_type)
temp = factory("_calibration_ref")
a1 = temp.children[0]
dx, dy = a1.location.x + a1.get_size_x() / 2, a1.location.y + a1.get_size_y() / 2
sorted_keys = sorted(
calibration_points.keys(),
key=lambda k: int("".join(c for c in k if c.isdigit()) or "0"),
)
slot_number = 0
for key in sorted_keys:
for pt in calibration_points[key]:
slot_number += 1
if isinstance(pt, (list, tuple)) and len(pt) >= 2 and not (pt[0] == 0 and pt[1] == 0):
self._slot_prcxi_positions[slot_number] = (
float(pt[0]) + dx,
float(pt[1]) + dy,
)
def _find_slot_for_resource(self, resource: Resource) -> Optional[int]:
"""沿 parent 链向上找到 Deck 的直接子节点,返回其槽位号。"""
current = resource
while current is not None:
if isinstance(current.parent, (PRCXI9300Deck, LiquidHandlerAbstract)):
return self._get_slot_number(current)
current = getattr(current, "parent", None)
return self._get_slot_number(resource)
def plr_pos_to_prcxi(self, resource: Resource, resource_offset: Coordinate = Coordinate(0, 0, 0), offset: Coordinate = Coordinate(0, 0, 0)):
z_pos = 'c'
tip_height = self.tip_height
if isinstance(resource, TipSpot):
z_pos = 't'
tip_height = 0
resource_pos = resource.get_absolute_location(x="c",y="c",z=z_pos)
x = resource_pos.x
y = resource_pos.y
z = resource_pos.z + tip_height
parent = resource.parent
res_z = resource.location.z
while not isinstance(parent, LiquidHandlerAbstract) and (res_z == 0) and parent is not None:
z += parent.get_size_z()
res_z = parent.location.z
parent = getattr(parent, "parent", None)
slot_number = self._find_slot_for_resource(resource) if self._slot_prcxi_positions else None
if slot_number is not None and slot_number in self._slot_prcxi_positions and self.calibration_labware_type is not None:
slot_prcxi_x, slot_prcxi_y = self._slot_prcxi_positions[slot_number]
prcxi_x = slot_prcxi_x - resource.location.x - resource.get_size_x() / 2
prcxi_y = slot_prcxi_y - resource.location.y - resource.get_size_y() / 2
else:
prcxi_x = (self.deck_x - x)*(1+self.x_increase) + self.x_offset + self.xy_coupling * (self.deck_y - y)
prcxi_y = (self.deck_y - y)*(1+self.y_increase) + self.y_offset
prcxi_z = self.deck_z - z
prcxi_x = min(max(0, prcxi_x+resource_offset.x),self.deck_x)
prcxi_y = min(max(0, prcxi_y+resource_offset.y),self.deck_y)
prcxi_z = min(max(0, prcxi_z+resource_offset.z),self.deck_z)
return Coordinate(prcxi_x, prcxi_y, prcxi_z)
def post_init(self, ros_node: BaseROS2DeviceNode):
super().post_init(ros_node)
@@ -820,8 +1119,8 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
):
self._unilabos_backend.create_protocol(protocol_name)
async def run_protocol(self):
return self._unilabos_backend.run_protocol()
async def run_protocol(self, protocol_id: str = None):
return self._unilabos_backend.run_protocol(protocol_id)
async def remove_liquid(
self,
@@ -897,6 +1196,27 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
none_keys=none_keys,
)
@staticmethod
def _tip_rack_is_10ul_range(rack: TipRack) -> bool:
"""判断 tip 盒是否为 10µL 量程(对应右头);优先用孔位上 prototype tip 的 maximal_volume。"""
children = getattr(rack, "children", None) or []
if children:
spot = children[0]
tr = getattr(spot, "tracker", None)
tip = None
if tr is not None:
tip = getattr(tr, "_tip", None) or getattr(tr, "tip", None)
if tip is None:
tip = getattr(spot, "tip", None)
mv = getattr(tip, "maximal_volume", None) if tip is not None else None
if mv is not None:
try:
return float(mv) <= 10.0
except (TypeError, ValueError):
pass
ident = f"{getattr(rack, 'model', '') or ''} {type(rack).__name__}".lower()
return "10ul" in ident
async def transfer_liquid(
self,
sources: Sequence[Container],
@@ -912,6 +1232,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
touch_tip: bool = False,
liquid_height: Optional[List[Optional[float]]] = None,
blow_out_air_volume: Optional[List[Optional[float]]] = None,
blow_out_air_volume_before: Optional[List[Optional[float]]] = None,
spread: Literal["wide", "tight", "custom"] = "wide",
is_96_well: bool = False,
mix_stage: Optional[Literal["none", "before", "after", "both"]] = "none",
@@ -922,7 +1243,62 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
delays: Optional[List[int]] = None,
none_keys: List[str] = [],
) -> TransferLiquidReturn:
return await super().transfer_liquid(
if not self._first_transfer_done:
self._match_and_create_matrix()
self._first_transfer_done = True
if self.step_mode:
await self.create_protocol(f"transfer_liquid{time.time()}")
_asp_list = asp_vols if isinstance(asp_vols, list) else [asp_vols]
_dis_list = dis_vols if isinstance(dis_vols, list) else [dis_vols]
sources = await self._resolve_to_plr_resources(sources)
targets = await self._resolve_to_plr_resources(targets)
tip_racks = list(await self._resolve_to_plr_resources(tip_racks))
if isinstance(tip_racks[0], TipRack):
tip_rack = tip_racks[0]
else:
tip_rack = tip_racks[0].parent
small_vols = all(v <= 10.0 for v in _asp_list) and all(v <= 10.0 for v in _dis_list)
if small_vols and self._tip_rack_is_10ul_range(tip_rack):
use_channels = [1]
mix_vol = max(min(mix_vol, 10), 0) if mix_vol is not None else None
change_slots = []
change_slots.append(sources[0].parent)
change_slots.append(targets[0].parent)
change_slots.append(tip_rack)
self.tip_height = tip_rack.children[0].get_size_z()
change_slots_positions = []
for slot in change_slots:
number = self._get_slot_number(slot)
pip_pos = self.plr_pos_to_prcxi(slot.children[0])
half_x = slot.children[0].get_size_x() / 2 * abs(1 + self.x_increase)
z_wall = slot.children[0].get_size_z()
change_slots_positions.append({
"Number": number,
"XPos": pip_pos.x,
"YPos": pip_pos.y,
"ZPos": pip_pos.z,
"X_Left": half_x,
"X_Right": half_x,
"ZAgainstTheWall": pip_pos.z - z_wall,
"X2Pos": pip_pos.x + self.right_2_left.x,
"Y2Pos": pip_pos.y + self.right_2_left.y,
"Z2Pos": pip_pos.z + self.right_2_left.z,
"X2_Left": half_x,
"X2_Right": half_x,
"ZAgainstTheWall2": pip_pos.z - z_wall,
})
if change_slots_positions:
self._unilabos_backend.api_client.update_pipetting_position(self._unilabos_backend.matrix_id, change_slots_positions)
res = await super().transfer_liquid(
sources,
targets,
tip_racks,
@@ -935,6 +1311,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
touch_tip=touch_tip,
liquid_height=liquid_height,
blow_out_air_volume=blow_out_air_volume,
blow_out_air_volume_before=None,
spread=spread,
is_96_well=is_96_well,
mix_stage=mix_stage,
@@ -945,6 +1322,9 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
delays=delays,
none_keys=none_keys,
)
if self.step_mode:
await self.run_protocol()
return res
async def custom_delay(self, seconds=0, msg=None):
return await super().custom_delay(seconds, msg)
@@ -961,9 +1341,10 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
offsets: Optional[Coordinate] = None,
mix_rate: Optional[float] = None,
none_keys: List[str] = [],
use_channels: Optional[List[int]] = [0],
):
return await self._unilabos_backend.mix(
targets, mix_time, mix_vol, height_to_bottom, offsets, mix_rate, none_keys
targets, mix_time, mix_vol, height_to_bottom, offsets, mix_rate, none_keys, use_channels
)
def iter_tips(self, tip_racks: Sequence[TipRack]) -> Iterator[Resource]:
@@ -976,10 +1357,6 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
offsets: Optional[List[Coordinate]] = None,
**backend_kwargs,
):
if self.step_mode:
await self.create_protocol(f"单点动作{time.time()}")
await super().pick_up_tips(tip_spots, use_channels, offsets, **backend_kwargs)
await self.run_protocol()
return await super().pick_up_tips(tip_spots, use_channels, offsets, **backend_kwargs)
async def aspirate(
@@ -1131,6 +1508,24 @@ class PRCXI9300Backend(LiquidHandlerBackend):
self.debug = debug
self.axis = "Left"
@staticmethod
def _deck_plate_slot_no(plate, deck) -> int:
"""台面板位槽号116与 PRCXI9300Handler._get_slot_number 一致;无法解析时退回 deck 子项顺序 +1。"""
sn = PRCXI9300Handler._get_slot_number(plate)
if sn is not None:
return sn
return deck.children.index(plate) + 1
@staticmethod
def _resource_num_items_y(resource) -> int:
"""板/TipRack 等在 Y 向孔位数;无 ``num_items_y`` 或非正数时返回 1。"""
ny = getattr(resource, "num_items_y", None)
try:
n = int(ny) if ny is not None else 1
except (TypeError, ValueError):
n = 1
return n if n >= 1 else 1
async def shaker_action(self, time: int, module_no: int, amplitude: int, is_wait: bool):
step = self.api_client.shaker_action(
time=time,
@@ -1147,8 +1542,9 @@ class PRCXI9300Backend(LiquidHandlerBackend):
offset = pickup.offset
pickup_distance_from_top = pickup.pickup_distance_from_top
direction = pickup.direction
plate_number = int(resource.parent.name.replace("T", ""))
plate = resource.parent
deck = plate.parent
plate_number = self._deck_plate_slot_no(plate, deck)
is_whole_plate = True
balance_height = 0
step = self.api_client.clamp_jaw_pick_up(plate_number, is_whole_plate, balance_height)
@@ -1161,7 +1557,9 @@ class PRCXI9300Backend(LiquidHandlerBackend):
plate_number = None
target_plate_number = backend_kwargs.get("target_plate_number", None)
if target_plate_number is not None:
plate_number = int(target_plate_number.name.replace("T", ""))
plate = target_plate_number
deck = plate.parent
plate_number = self._deck_plate_slot_no(plate, deck)
is_whole_plate = True
balance_height = 0
@@ -1182,26 +1580,40 @@ class PRCXI9300Backend(LiquidHandlerBackend):
self.protocol_name = protocol_name
self.steps_todo_list = []
def run_protocol(self):
if not len(self.matrix_id):
self.matrix_id = str(uuid.uuid4())
material_list = self.api_client.get_all_materials()
material_dict = {material["uuid"]: material for material in material_list}
work_tablets = []
for num, material_id in self.tablets_info.items():
work_tablets.append({
"Number": num,
"Material": material_dict[material_id]
})
self.matrix_info = {
"MatrixId": self.matrix_id,
"MatrixName": self.matrix_id,
"WorkTablets": work_tablets,
}
# print(json.dumps(self.matrix_info, indent=2))
res = self.api_client.add_WorkTablet_Matrix(self.matrix_info)
if not res["Success"]:
self.matrix_id = ""
raise AssertionError(f"Failed to create matrix: {res.get('Message', 'Unknown error')}")
print(f"PRCXI9300Backend created matrix with ID: {self.matrix_info['MatrixId']}, result: {res}")
def run_protocol(self, protocol_id: str = None):
assert self.is_reset_ok, "PRCXI9300Backend is not reset successfully. Please call setup() first."
run_time = time.time()
self.matrix_info = MatrixInfo(
MatrixId=f"{int(run_time)}",
MatrixName=f"protocol_{run_time}",
MatrixCount=len(self.tablets_info),
WorkTablets=self.tablets_info,
)
# print(json.dumps(self.matrix_info, indent=2))
if not len(self.matrix_id):
res = self.api_client.add_WorkTablet_Matrix(self.matrix_info)
assert res["Success"], f"Failed to create matrix: {res.get('Message', 'Unknown error')}"
print(f"PRCXI9300Backend created matrix with ID: {self.matrix_info['MatrixId']}, result: {res}")
if protocol_id == "" or protocol_id is None:
solution_id = self.api_client.add_solution(
f"protocol_{run_time}", self.matrix_info["MatrixId"], self.steps_todo_list
f"protocol_{run_time}", self.matrix_id, self.steps_todo_list
)
else:
print(f"PRCXI9300Backend using predefined worktable {self.matrix_id}, skipping matrix creation.")
solution_id = self.api_client.add_solution(f"protocol_{run_time}", self.matrix_id, self.steps_todo_list)
solution_id = protocol_id
print(f"PRCXI9300Backend created solution with ID: {solution_id}")
self.api_client.load_solution(solution_id)
print(json.dumps(self.steps_todo_list, indent=2))
@@ -1244,6 +1656,9 @@ class PRCXI9300Backend(LiquidHandlerBackend):
else:
await asyncio.sleep(1)
print("PRCXI9300 reset successfully.")
# self.api_client.update_clamp_jaw_position(self.matrix_id, self.claw_positions)
except ConnectionRefusedError as e:
raise RuntimeError(
f"Failed to connect to PRCXI9300 API at {self.host}:{self.port}. "
@@ -1267,33 +1682,33 @@ class PRCXI9300Backend(LiquidHandlerBackend):
axis = "Right"
else:
raise ValueError("Invalid use channels: " + str(_use_channels))
plate_indexes = []
plate_slots = []
for op in ops:
plate = op.resource.parent
deck = plate.parent.parent
plate_index = deck.children.index(plate.parent)
# print(f"Plate index: {plate_index}, Plate name: {plate.name}")
# print(f"Number of children in deck: {len(deck.children)}")
deck = plate.parent
plate_slots.append(self._deck_plate_slot_no(plate, deck))
plate_indexes.append(plate_index)
if len(set(plate_indexes)) != 1:
raise ValueError("All pickups must be from the same plate. Found different plates: " + str(plate_indexes))
if len(set(plate_slots)) != 1:
raise ValueError("All pickups must be from the same plate (slot). Found different slots: " + str(plate_slots))
_rack = ops[0].resource.parent
ny = self._resource_num_items_y(_rack)
tip_columns = []
for op in ops:
tipspot = op.resource
if self._resource_num_items_y(tipspot.parent) != ny:
raise ValueError("All pickups must use tip racks with the same num_items_y")
tipspot_index = tipspot.parent.children.index(tipspot)
tip_columns.append(tipspot_index // 8)
tip_columns.append(tipspot_index // ny)
if len(set(tip_columns)) != 1:
raise ValueError(
"All pickups must be from the same tip column. Found different columns: " + str(tip_columns)
)
PlateNo = plate_indexes[0] + 1
PlateNo = plate_slots[0]
hole_col = tip_columns[0] + 1
hole_row = 1
if self._num_channels == 1:
hole_row = tipspot_index % 8 + 1
if self.num_channels != 8:
hole_row = tipspot_index % ny + 1
step = self.api_client.Load(
axis=axis,
@@ -1304,8 +1719,8 @@ class PRCXI9300Backend(LiquidHandlerBackend):
hole_col=hole_col,
blending_times=0,
balance_height=0,
plate_or_hole=f"H{hole_col}-8,T{PlateNo}",
hole_numbers=f"{(hole_col - 1) * 8 + hole_row}" if self._num_channels == 1 else "1,2,3,4,5",
plate_or_hole=f"H{hole_col}-{ny},T{PlateNo}",
hole_numbers=f"{(hole_col - 1) * ny + hole_row}" if self._num_channels != 8 else "1,2,3,4,5",
)
self.steps_todo_list.append(step)
@@ -1323,8 +1738,9 @@ class PRCXI9300Backend(LiquidHandlerBackend):
raise ValueError("Invalid use channels: " + str(_use_channels))
# 检查trash #
if ops[0].resource.name == "trash":
PlateNo = ops[0].resource.parent.parent.children.index(ops[0].resource.parent) + 1
_plate = ops[0].resource
_deck = _plate.parent
PlateNo = self._deck_plate_slot_no(_plate, _deck)
step = self.api_client.UnLoad(
axis=axis,
@@ -1342,32 +1758,35 @@ class PRCXI9300Backend(LiquidHandlerBackend):
return
# print(ops[0].resource.parent.children.index(ops[0].resource))
plate_indexes = []
plate_slots = []
for op in ops:
plate = op.resource.parent
deck = plate.parent.parent
plate_index = deck.children.index(plate.parent)
plate_indexes.append(plate_index)
if len(set(plate_indexes)) != 1:
deck = plate.parent
plate_slots.append(self._deck_plate_slot_no(plate, deck))
if len(set(plate_slots)) != 1:
raise ValueError(
"All drop_tips must be from the same plate. Found different plates: " + str(plate_indexes)
"All drop_tips must be from the same plate (slot). Found different slots: " + str(plate_slots)
)
_rack = ops[0].resource.parent
ny = self._resource_num_items_y(_rack)
tip_columns = []
for op in ops:
tipspot = op.resource
if self._resource_num_items_y(tipspot.parent) != ny:
raise ValueError("All drop_tips must use tip racks with the same num_items_y")
tipspot_index = tipspot.parent.children.index(tipspot)
tip_columns.append(tipspot_index // 8)
tip_columns.append(tipspot_index // ny)
if len(set(tip_columns)) != 1:
raise ValueError(
"All drop_tips must be from the same tip column. Found different columns: " + str(tip_columns)
)
PlateNo = plate_indexes[0] + 1
PlateNo = plate_slots[0]
hole_col = tip_columns[0] + 1
if self.channel_num == 1:
hole_row = tipspot_index % 8 + 1
hole_row = 1
if self.num_channels != 8:
hole_row = tipspot_index % ny + 1
step = self.api_client.UnLoad(
axis=axis,
@@ -1378,7 +1797,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
hole_col=hole_col,
blending_times=0,
balance_height=0,
plate_or_hole=f"H{hole_col}-8,T{PlateNo}",
plate_or_hole=f"H{hole_col}-{ny},T{PlateNo}",
hole_numbers="1,2,3,4,5,6,7,8",
)
self.steps_todo_list.append(step)
@@ -1392,37 +1811,47 @@ class PRCXI9300Backend(LiquidHandlerBackend):
offsets: Optional[Coordinate] = None,
mix_rate: Optional[float] = None,
none_keys: List[str] = [],
use_channels: Optional[List[int]] = [0],
):
"""Mix liquid in the specified resources."""
plate_indexes = []
if use_channels == [0]:
axis = "Left"
elif use_channels == [1]:
axis = "Right"
else:
raise ValueError("Invalid use channels: " + str(use_channels))
plate_slots = []
for op in targets:
deck = op.parent.parent.parent
plate = op.parent
plate_index = deck.children.index(plate.parent)
plate_indexes.append(plate_index)
plate_slots.append(self._deck_plate_slot_no(plate, deck))
if len(set(plate_indexes)) != 1:
raise ValueError("All pickups must be from the same plate. Found different plates: " + str(plate_indexes))
if len(set(plate_slots)) != 1:
raise ValueError("All mix targets must be from the same plate (slot). Found different slots: " + str(plate_slots))
_plate0 = targets[0].parent
ny = self._resource_num_items_y(_plate0)
tip_columns = []
for op in targets:
if self._resource_num_items_y(op.parent) != ny:
raise ValueError("All mix targets must be on plates with the same num_items_y")
tipspot_index = op.parent.children.index(op)
tip_columns.append(tipspot_index // 8)
tip_columns.append(tipspot_index // ny)
if len(set(tip_columns)) != 1:
raise ValueError(
"All pickups must be from the same tip column. Found different columns: " + str(tip_columns)
"All mix targets must be in the same column group. Found different columns: " + str(tip_columns)
)
PlateNo = plate_indexes[0] + 1
PlateNo = plate_slots[0]
hole_col = tip_columns[0] + 1
hole_row = 1
if self.num_channels == 1:
hole_row = tipspot_index % 8 + 1
if self.num_channels != 8:
hole_row = tipspot_index % ny + 1
assert mix_time > 0
step = self.api_client.Blending(
axis=axis,
dosage=mix_vol,
plate_no=PlateNo,
is_whole_plate=False,
@@ -1430,7 +1859,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
hole_col=hole_col,
blending_times=mix_time,
balance_height=0,
plate_or_hole=f"H{hole_col}-8,T{PlateNo}",
plate_or_hole=f"H{hole_col}-{ny},T{PlateNo}",
hole_numbers="1,2,3,4,5,6,7,8",
)
self.steps_todo_list.append(step)
@@ -1447,36 +1876,42 @@ class PRCXI9300Backend(LiquidHandlerBackend):
axis = "Right"
else:
raise ValueError("Invalid use channels: " + str(_use_channels))
plate_indexes = []
plate_slots = []
for op in ops:
plate = op.resource.parent
deck = plate.parent.parent
plate_index = deck.children.index(plate.parent)
plate_indexes.append(plate_index)
deck = plate.parent
plate_slots.append(self._deck_plate_slot_no(plate, deck))
if len(set(plate_indexes)) != 1:
raise ValueError("All pickups must be from the same plate. Found different plates: " + str(plate_indexes))
if len(set(plate_slots)) != 1:
raise ValueError("All aspirate must be from the same plate (slot). Found different slots: " + str(plate_slots))
_plate0 = ops[0].resource.parent
ny = self._resource_num_items_y(_plate0)
tip_columns = []
for op in ops:
tipspot = op.resource
if self._resource_num_items_y(tipspot.parent) != ny:
raise ValueError("All aspirate wells must be on plates with the same num_items_y")
tipspot_index = tipspot.parent.children.index(tipspot)
tip_columns.append(tipspot_index // 8)
tip_columns.append(tipspot_index // ny)
if len(set(tip_columns)) != 1:
raise ValueError(
"All pickups must be from the same tip column. Found different columns: " + str(tip_columns)
"All aspirate must be from the same tip column. Found different columns: " + str(tip_columns)
)
volumes = [op.volume for op in ops]
if len(set(volumes)) != 1:
raise ValueError("All aspirate volumes must be the same. Found different volumes: " + str(volumes))
PlateNo = plate_indexes[0] + 1
PlateNo = plate_slots[0]
hole_col = tip_columns[0] + 1
hole_row = 1
if self.num_channels == 1:
hole_row = tipspot_index % 8 + 1
assist_fun1 = ""
if self.num_channels != 8:
hole_row = tipspot_index % ny + 1
if ops[0].blow_out_air_volume is not None:
assist_fun1 = f"反向吸液({float(min(max(ops[0].blow_out_air_volume,0),10))}ul)"
step = self.api_client.Imbibing(
axis=axis,
@@ -1486,9 +1921,10 @@ class PRCXI9300Backend(LiquidHandlerBackend):
hole_row=hole_row,
hole_col=hole_col,
blending_times=0,
balance_height=0,
plate_or_hole=f"H{hole_col}-8,T{PlateNo}",
balance_height=int(min(max(ops[0].liquid_height,0),10)),
plate_or_hole=f"H{hole_col}-{ny},T{PlateNo}",
hole_numbers="1,2,3,4,5,6,7,8",
assist_fun1=assist_fun1,
)
self.steps_todo_list.append(step)
@@ -1504,21 +1940,24 @@ class PRCXI9300Backend(LiquidHandlerBackend):
axis = "Right"
else:
raise ValueError("Invalid use channels: " + str(_use_channels))
plate_indexes = []
plate_slots = []
for op in ops:
plate = op.resource.parent
deck = plate.parent.parent
plate_index = deck.children.index(plate.parent)
plate_indexes.append(plate_index)
deck = plate.parent
plate_slots.append(self._deck_plate_slot_no(plate, deck))
if len(set(plate_indexes)) != 1:
raise ValueError("All dispense must be from the same plate. Found different plates: " + str(plate_indexes))
if len(set(plate_slots)) != 1:
raise ValueError("All dispense must be from the same plate (slot). Found different slots: " + str(plate_slots))
_plate0 = ops[0].resource.parent
ny = self._resource_num_items_y(_plate0)
tip_columns = []
for op in ops:
tipspot = op.resource
if self._resource_num_items_y(tipspot.parent) != ny:
raise ValueError("All dispense wells must be on plates with the same num_items_y")
tipspot_index = tipspot.parent.children.index(tipspot)
tip_columns.append(tipspot_index // 8)
tip_columns.append(tipspot_index // ny)
if len(set(tip_columns)) != 1:
raise ValueError(
@@ -1529,12 +1968,16 @@ class PRCXI9300Backend(LiquidHandlerBackend):
if len(set(volumes)) != 1:
raise ValueError("All dispense volumes must be the same. Found different volumes: " + str(volumes))
PlateNo = plate_indexes[0] + 1
PlateNo = plate_slots[0]
hole_col = tip_columns[0] + 1
hole_row = 1
if self.num_channels == 1:
hole_row = tipspot_index % 8 + 1
if self.num_channels != 8:
hole_row = tipspot_index % ny + 1
assist_fun1 = ""
if ops[0].blow_out_air_volume is not None:
assist_fun1 = f"吹样({float(min(max(ops[0].blow_out_air_volume,0),10))}ul)"
step = self.api_client.Tapping(
axis=axis,
@@ -1544,9 +1987,10 @@ class PRCXI9300Backend(LiquidHandlerBackend):
hole_row=hole_row,
hole_col=hole_col,
blending_times=0,
balance_height=0,
plate_or_hole=f"H{hole_col}-8,T{PlateNo}",
balance_height=int(min(max(ops[0].liquid_height,0),10)),
plate_or_hole=f"H{hole_col}-{ny},T{PlateNo}",
hole_numbers="1,2,3,4,5,6,7,8",
assist_fun1=assist_fun1,
)
self.steps_todo_list.append(step)
@@ -1741,6 +2185,21 @@ class PRCXI9300Api:
"""GetWorkTabletMatrixById"""
return self.call("IMatrix", "GetWorkTabletMatrixById", [matrix_id])
def update_clamp_jaw_position(self, target_matrix_id: str, claw_positions: List[Dict[str, Any]]):
position_params = {
"MatrixId": target_matrix_id,
"WorkTablets": claw_positions
}
return self.call("IMatrix", "UpdateClampJawPosition", [position_params])
def update_pipetting_position(self, target_matrix_id: str, pipetting_positions: List[Dict[str, Any]]):
"""UpdatePipettingPosition - 更新移液位置"""
position_params = {
"MatrixId": target_matrix_id,
"WorkTablets": pipetting_positions
}
return self.call("IMatrix", "UpdatePipettingPosition", [position_params])
def add_WorkTablet_Matrix(self, matrix: MatrixInfo):
return self.call("IMatrix", "AddWorkTabletMatrix2" if self.is_9320 else "AddWorkTabletMatrix", [matrix])

File diff suppressed because it is too large Load Diff

View File

@@ -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()

View File

@@ -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):

View File

@@ -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):
"""

View File

@@ -0,0 +1 @@
# PRCXI 耗材管理 Web 应用

View File

@@ -0,0 +1,4 @@
"""启动入口: python -m unilabos.labware_manager"""
from unilabos.labware_manager.app import main
main()

View 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()

View 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)} 个工厂函数)")

View 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}")

File diff suppressed because it is too large Load Diff

View 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)

View 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);
}
}

View 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();
}

View 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; }

View 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>

View 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 %}

View 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 %}

View 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 %}

View 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}")

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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: []

View File

@@ -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: []

View File

@@ -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

View File

@@ -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: []

View File

@@ -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: []

View File

@@ -489,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"]}

View File

@@ -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,

View File

@@ -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)

View File

@@ -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物料树更新

View File

@@ -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_modelmesh 信息)"""
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:

View File

@@ -23,6 +23,7 @@
"port": 9999,
"debug": false,
"setup": true,
"matrix_id": "1ecb1b45-6aef-456b-bd68-8f538c4e5826",
"timeout": 10,
"simulator": false,
"channel_num": 8

View File

@@ -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,426 +73,7 @@
},
"category": "deck",
"barcode": null,
"preferred_pickup_location": null,
"sites": [
{
"label": "T1",
"visible": true,
"occupied_by": null,
"position": {
"x": 0,
"y": 288,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"container",
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor",
"plateadapter",
"module",
"trash"
]
},
{
"label": "T2",
"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",
"plateadapter",
"module",
"trash"
]
},
{
"label": "T3",
"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",
"plateadapter",
"module",
"trash"
]
},
{
"label": "T4",
"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",
"plateadapter",
"module",
"trash"
]
},
{
"label": "T5",
"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",
"plateadapter",
"module",
"trash"
]
},
{
"label": "T6",
"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",
"plateadapter",
"module",
"trash"
]
},
{
"label": "T7",
"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",
"plateadapter",
"module",
"trash"
]
},
{
"label": "T8",
"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",
"plateadapter",
"module",
"trash"
]
},
{
"label": "T9",
"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": "T10",
"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": "T11",
"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": "T12",
"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",
"plateadapter",
"module",
"trash"
]
},
{
"label": "T13",
"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": "T14",
"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": "T15",
"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"
]
},
{
"label": "T16",
"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",
"plateadapter",
"module",
"trash"
]
}
]
"preferred_pickup_location": null
},
"data": {}
}

View File

@@ -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:
"""协议槽位 -> 实际 deck4→138→1412+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

View File

@@ -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()

View File

@@ -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,
)
# 校验句柄配置