Compare commits

...

71 Commits

Author SHA1 Message Date
q434343
ad05e8c73e 修改Z轴最大值无法赋予的bug, 2026-05-18 02:34:29 +08:00
q434343
940abc3664 Merge branch 'dev' into prcix9320 2026-05-14 17:54:24 +08:00
q434343
6288e37464 演示时修改的部分代码 2026-05-14 17:49:48 +08:00
q434343
3aed75bc8b protocol转换方法修改 2026-05-07 20:15:52 +08:00
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
68ef739f4a Merge pull request #253 from ALITTLELZ/9300
Add PRCXI 9300 (3x2) deck layout support
2026-03-31 17:28:52 +08:00
ALITTLELZ
29a484f16f Add "trash" to site content_type in Deck and experiment JSONs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 17:08:20 +08:00
ALITTLELZ
14cf4ddc0d Add PRCXI 9300 (3x2) deck layout support via model parameter
PRCXI9300Deck now accepts model="9300"|"9320" to auto-select 6-slot or
16-slot layout. DefaultLayout gains default_layout for 9300 with T6 as
trash. PRCXI9300Handler auto-derives is_9320 from deck.model when not
explicitly passed. Includes 9300 slim experiment JSON and test fixes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 16:00:26 +08:00
Xuwznln
861a012747 allow non @topic_config support 2026-03-31 13:15:06 +08:00
q434343
d13d3f7dfe Merge pull request #250 from ALITTLELZ/adaptors
Add PRCXI functional modules and fix Deck layout
2026-03-26 12:27:06 +08:00
Xuwznln
ee63e95f50 update skill 2026-03-25 23:20:13 +08:00
ALITTLELZ
71d35d31af Register PRCXI9300ModuleSite/FunctionalModule for PLR deserialization
Added PRCXI9300ModuleSite and PRCXI9300FunctionalModule to the PLR
class registration in plr_additional_res_reg.py so find_subclass can
locate them during deserialization of cached cloud data. Also added
"module" and "carrier" to replace_plr_type and TYPE_MAP in
resource_tracker.py to suppress unknown type warnings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 18:32:01 +08:00
ALITTLELZ
7f4b57f589 Fix Deck slot Y-axis inversion: T1 should be top-left, not bottom-left
Upstream rewrite of PRCXI9300Deck lost the Y-axis flip logic from the
original `(3-row)*96+13` formula. T1-T4 were rendered at the bottom
instead of the top. Reversed _DEFAULT_SITE_POSITIONS Y coordinates and
updated prcxi_9320_slim.json accordingly. Also added "plateadapter" and
"module" to slim JSON content_type entries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 17:16:04 +08:00
ALITTLELZ
0c667e68e6 Remove deprecated PRCXI9300PlateAdapterSite, replaced by PRCXI9300ModuleSite
PRCXI9300PlateAdapterSite was already removed by upstream/prcix9320.
Its functionality is now provided by PRCXI9300ModuleSite which serves
as the base class for functional modules (heating/cooling/shaking/magnetic).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 16:22:39 +08:00
ALITTLELZ
9430be51a4 Merge remote-tracking branch 'upstream/prcix9320' into adaptors
# Conflicts:
#	unilabos/devices/liquid_handling/prcxi/prcxi.py
2026-03-25 16:04:17 +08:00
ALITTLELZ
a187a57430 Add PRCXI functional modules (heating/cooling/shaking/magnetic) and registry config
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 15:19:48 +08:00
q434343
68029217de Merge branch 'dev' into prcix9320 2026-03-25 14:44:52 +08:00
q434343
792504e08c Update .gitignore 2026-03-25 14:39:02 +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
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
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
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
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
ALITTLELZ
ca985f92ab Add 'plateadapter' to device and test configurations 2026-03-02 14:35:12 +08:00
108 changed files with 13747 additions and 3883 deletions

View File

@@ -3,7 +3,7 @@
package: package:
name: unilabos name: unilabos
version: 0.10.19 version: 0.11.1
source: source:
path: ../../unilabos path: ../../unilabos
@@ -54,7 +54,7 @@ requirements:
- pymodbus - pymodbus
- matplotlib - matplotlib
- pylibftdi - pylibftdi
- uni-lab::unilabos-env ==0.10.19 - uni-lab::unilabos-env ==0.11.1
about: about:
repository: https://github.com/deepmodeling/Uni-Lab-OS repository: https://github.com/deepmodeling/Uni-Lab-OS

View File

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

View File

@@ -3,7 +3,7 @@
package: package:
name: unilabos-full name: unilabos-full
version: 0.10.19 version: 0.11.1
build: build:
noarch: generic noarch: generic
@@ -11,7 +11,7 @@ build:
requirements: requirements:
run: run:
# Base unilabos package (includes unilabos-env) # Base unilabos package (includes unilabos-env)
- uni-lab::unilabos ==0.10.19 - uni-lab::unilabos ==0.11.1
# Documentation tools # Documentation tools
- sphinx - sphinx
- sphinx_rtd_theme - sphinx_rtd_theme

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -38,7 +38,7 @@ jobs:
- name: Install ROS dependencies, uv and unilabos-msgs - name: Install ROS dependencies, uv and unilabos-msgs
run: | run: |
echo Installing ROS dependencies... echo Installing ROS dependencies...
mamba install -n check-env conda-forge::uv conda-forge::opencv robostack-staging::ros-humble-ros-core robostack-staging::ros-humble-action-msgs robostack-staging::ros-humble-std-msgs robostack-staging::ros-humble-geometry-msgs robostack-staging::ros-humble-control-msgs robostack-staging::ros-humble-nav2-msgs uni-lab::ros-humble-unilabos-msgs robostack-staging::ros-humble-cv-bridge robostack-staging::ros-humble-vision-opencv robostack-staging::ros-humble-tf-transformations robostack-staging::ros-humble-moveit-msgs robostack-staging::ros-humble-tf2-ros robostack-staging::ros-humble-tf2-ros-py conda-forge::transforms3d -c robostack-staging -c conda-forge -c uni-lab -y mamba install -n check-env --override-channels -c robostack-staging -c conda-forge -c uni-lab conda-forge::uv conda-forge::opencv robostack-staging::ros-humble-ros-core robostack-staging::ros-humble-action-msgs robostack-staging::ros-humble-std-msgs robostack-staging::ros-humble-geometry-msgs robostack-staging::ros-humble-control-msgs robostack-staging::ros-humble-nav2-msgs uni-lab::ros-humble-unilabos-msgs robostack-staging::ros-humble-cv-bridge robostack-staging::ros-humble-vision-opencv robostack-staging::ros-humble-tf-transformations robostack-staging::ros-humble-moveit-msgs robostack-staging::ros-humble-tf2-ros robostack-staging::ros-humble-tf2-ros-py conda-forge::transforms3d -y
- name: Install pip dependencies and unilabos - name: Install pip dependencies and unilabos
run: | run: |

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,95 +1,4 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. Please follow the rules defined in:
## Build & Development @AGENTS.md
```bash
# Install (requires mamba env with python 3.11)
mamba create -n unilab python=3.11.14
mamba activate unilab
mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge
pip install -e .
uv pip install -r unilabos/utils/requirements.txt
# Run with a device graph
unilab --graph <graph.json> --config <config.py> --backend ros
unilab --graph <graph.json> --config <config.py> --backend simple # no ROS2 needed
# Common CLI flags
unilab --app_bridges websocket fastapi # communication bridges
unilab --test_mode # simulate hardware, no real execution
unilab --check_mode # CI validation of registry imports (AST-based)
unilab --skip_env_check # skip auto-install of dependencies
unilab --visual rviz|web|disable # visualization mode
unilab --is_slave # run as slave node
unilab --restart_mode # auto-restart on config changes (supervisor/child process)
# Workflow upload subcommand
unilab workflow_upload -f <workflow.json> -n <name> --tags tag1 tag2
# Tests
pytest tests/ # all tests
pytest tests/resources/test_resourcetreeset.py # single test file
pytest tests/resources/test_resourcetreeset.py::TestClassName::test_method # single test
# CI check (matches .github/workflows/ci-check.yml)
python -m unilabos --check_mode --skip_env_check
```
## Architecture
### Startup Flow
`unilab` CLI (entry point in `setup.py`) → `unilabos/app/main.py:main()` → loads config → builds registry → reads device graph (JSON/GraphML) → starts backend thread (ROS2/simple) → starts FastAPI web server + WebSocket client.
### Core Layers
**Registry** (`unilabos/registry/`): Singleton `Registry` class discovers and catalogs all device types, resource types, and communication devices. Two registration mechanisms: YAML definitions in `registry/devices/*.yaml` and Python decorators (`@device`, `@action`, `@resource` in `registry/decorators.py`). AST scanning discovers decorated classes without importing them. Class paths resolved to Python classes via `utils/import_manager.py`.
**Resource Tracking** (`unilabos/resources/resource_tracker.py`): Pydantic-based `ResourceDict``ResourceDictInstance``ResourceTreeSet` hierarchy. `ResourceTreeSet` is the canonical in-memory representation of all devices and resources. Graph I/O in `resources/graphio.py` reads JSON/GraphML device topology files into `nx.Graph` + `ResourceTreeSet`.
**Device Drivers** (`unilabos/devices/`): 30+ hardware drivers organized by category (liquid_handling, hplc, balance, arm, etc.). Each driver class gets wrapped by `ros/device_node_wrapper.py:ros2_device_node()` into a `ROS2DeviceNode` (defined in `ros/nodes/base_device_node.py`) with publishers, subscribers, and action servers.
**ROS2 Layer** (`unilabos/ros/`): Preset node types in `ros/nodes/presets/``host_node` (main orchestrator, ~90KB), `controller_node`, `workstation`, `serial_node`, `camera`, `resource_mesh_manager`. Custom messages in `unilabos_msgs/` (80+ action types, pre-built via conda `ros-humble-unilabos-msgs`).
**Protocol Compilation** (`unilabos/compile/`): 20+ protocol compilers (add, centrifuge, dissolve, filter, heatchill, stir, pump, etc.) registered in `__init__.py:action_protocol_generators` dict. Utility parsers in `compile/utils/` (vessel, unit, logger).
**Workflow** (`unilabos/workflow/`): Converts workflow definitions from multiple formats — JSON (`convert_from_json.py`, `common.py`), Python scripts (`from_python_script.py`), XDL (`from_xdl.py`) — into executable `WorkflowGraph`. Legacy converters in `workflow/legacy/`.
**Communication** (`unilabos/device_comms/`): Hardware adapters — OPC-UA, Modbus PLC, RPC, universal driver. `app/communication.py` provides factory pattern for WebSocket connections.
**Web/API** (`unilabos/app/web/`): FastAPI server with REST API (`api.py`), Jinja2 templates (`pages.py`), HTTP client (`client.py`). Default port 8002.
### Configuration System
- **Config classes** in `unilabos/config/config.py`: `BasicConfig`, `WSConfig`, `HTTPConfig`, `ROSConfig` — class-level attributes, loaded from Python `.py` config files (see `config/example_config.py`)
- Environment variable overrides with prefix `UNILABOS_` (e.g., `UNILABOS_BASICCONFIG_PORT=9000`)
- Device topology defined in graph files (JSON node-link format or GraphML)
### Key Data Flow
1. Graph file → `graphio.read_node_link_json()``(nx.Graph, ResourceTreeSet, resource_links)`
2. `ResourceTreeSet` + `Registry``initialize_device.initialize_device_from_dict()``ROS2DeviceNode` instances
3. Device nodes communicate via ROS2 topics/actions or direct Python calls (simple backend)
4. Cloud sync via WebSocket (`app/ws_client.py`) and HTTP (`app/web/client.py`)
### Test Data
Example device graphs and experiment configs are in `unilabos/test/experiments/` (not `tests/`). Registry test fixtures in `unilabos/test/registry/`.
## Code Conventions
- Code comments and log messages in **simplified Chinese**
- Python 3.11+, type hints expected
- Pydantic models for data validation (`resource_tracker.py`)
- Singleton pattern via `@singleton` decorator (`utils/decorator.py`)
- Dynamic class loading via `utils/import_manager.py` — device classes resolved at runtime from registry YAML paths
- CLI argument dashes auto-converted to underscores for consistency
- No linter/formatter configuration enforced (no ruff, black, flake8, mypy configs)
- Documentation built with Sphinx (Chinese language, `sphinx_rtd_theme`, `myst_parser`)
## Licensing
- Framework code: GPL-3.0
- Device drivers (`unilabos/devices/`): DP Technology Proprietary License — do not redistribute

View File

@@ -12,7 +12,7 @@ Uni-Lab 使用 Python 格式的配置文件(`.py`),默认为 `unilabos_dat
**获取方式:** **获取方式:**
进入 [Uni-Lab 实验室](https://uni-lab.bohrium.com),点击左下角的头像,在实验室详情中获取所在实验室的 ak 和 sk 进入 [Uni-Lab 实验室](https://leap-lab.bohrium.com),点击左下角的头像,在实验室详情中获取所在实验室的 ak 和 sk
![copy_aksk.gif](image/copy_aksk.gif) ![copy_aksk.gif](image/copy_aksk.gif)
@@ -69,7 +69,7 @@ class WSConfig:
# HTTP配置 # HTTP配置
class HTTPConfig: class HTTPConfig:
remote_addr = "https://uni-lab.bohrium.com/api/v1" # 远程服务器地址 remote_addr = "https://leap-lab.bohrium.com/api/v1" # 远程服务器地址
# ROS配置 # ROS配置
class ROSConfig: class ROSConfig:
@@ -209,8 +209,8 @@ unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis -g grap
`--addr` 参数支持以下预设值,会自动转换为对应的完整 URL `--addr` 参数支持以下预设值,会自动转换为对应的完整 URL
- `test``https://uni-lab.test.bohrium.com/api/v1` - `test``https://leap-lab.test.bohrium.com/api/v1`
- `uat``https://uni-lab.uat.bohrium.com/api/v1` - `uat``https://leap-lab.uat.bohrium.com/api/v1`
- `local``http://127.0.0.1:48197/api/v1` - `local``http://127.0.0.1:48197/api/v1`
- 其他值 → 直接使用作为完整 URL - 其他值 → 直接使用作为完整 URL
@@ -248,7 +248,7 @@ unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis -g grap
`ak``sk` 是必需的认证参数: `ak``sk` 是必需的认证参数:
1. **获取方式**:在 [Uni-Lab 官网](https://uni-lab.bohrium.com) 注册实验室后获得 1. **获取方式**:在 [Uni-Lab 官网](https://leap-lab.bohrium.com) 注册实验室后获得
2. **配置方式** 2. **配置方式**
- **命令行参数**`--ak "your_key" --sk "your_secret"`(最高优先级,推荐) - **命令行参数**`--ak "your_key" --sk "your_secret"`(最高优先级,推荐)
- **环境变量**`UNILABOS_BASICCONFIG_AK``UNILABOS_BASICCONFIG_SK` - **环境变量**`UNILABOS_BASICCONFIG_AK``UNILABOS_BASICCONFIG_SK`
@@ -275,15 +275,15 @@ WebSocket 是 Uni-Lab 的主要通信方式:
HTTP 客户端配置用于与云端服务通信: HTTP 客户端配置用于与云端服务通信:
| 参数 | 类型 | 默认值 | 说明 | | 参数 | 类型 | 默认值 | 说明 |
| ------------- | ---- | -------------------------------------- | ------------ | | ------------- | ---- | --------------------------------------- | ------------ |
| `remote_addr` | str | `"https://uni-lab.bohrium.com/api/v1"` | 远程服务地址 | | `remote_addr` | str | `"https://leap-lab.bohrium.com/api/v1"` | 远程服务地址 |
**预设环境地址** **预设环境地址**
- 生产环境:`https://uni-lab.bohrium.com/api/v1`(默认) - 生产环境:`https://leap-lab.bohrium.com/api/v1`(默认)
- 测试环境:`https://uni-lab.test.bohrium.com/api/v1` - 测试环境:`https://leap-lab.test.bohrium.com/api/v1`
- UAT 环境:`https://uni-lab.uat.bohrium.com/api/v1` - UAT 环境:`https://leap-lab.uat.bohrium.com/api/v1`
- 本地环境:`http://127.0.0.1:48197/api/v1` - 本地环境:`http://127.0.0.1:48197/api/v1`
### 4. ROSConfig - ROS 配置 ### 4. ROSConfig - ROS 配置
@@ -401,7 +401,7 @@ export UNILABOS_WSCONFIG_RECONNECT_INTERVAL="10"
export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS="500" export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS="500"
# 设置HTTP配置 # 设置HTTP配置
export UNILABOS_HTTPCONFIG_REMOTE_ADDR="https://uni-lab.test.bohrium.com/api/v1" export UNILABOS_HTTPCONFIG_REMOTE_ADDR="https://leap-lab.test.bohrium.com/api/v1"
``` ```
## 配置文件使用方法 ## 配置文件使用方法
@@ -484,13 +484,13 @@ export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS=100
```python ```python
class HTTPConfig: class HTTPConfig:
remote_addr = "https://uni-lab.test.bohrium.com/api/v1" remote_addr = "https://leap-lab.test.bohrium.com/api/v1"
``` ```
**环境变量方式:** **环境变量方式:**
```bash ```bash
export UNILABOS_HTTPCONFIG_REMOTE_ADDR=https://uni-lab.test.bohrium.com/api/v1 export UNILABOS_HTTPCONFIG_REMOTE_ADDR=https://leap-lab.test.bohrium.com/api/v1
``` ```
**命令行方式(推荐):** **命令行方式(推荐):**

View File

@@ -23,7 +23,7 @@ Uni-Lab-OS 支持多种部署模式:
``` ```
┌──────────────────────────────────────────────┐ ┌──────────────────────────────────────────────┐
│ Cloud Platform/Self-hosted Platform │ │ Cloud Platform/Self-hosted Platform │
uni-lab.bohrium.com │ leap-lab.bohrium.com │
│ (Resource Management, Task Scheduling, │ │ (Resource Management, Task Scheduling, │
│ Monitoring) │ │ Monitoring) │
└────────────────────┬─────────────────────────┘ └────────────────────┬─────────────────────────┘
@@ -444,7 +444,7 @@ ros2 daemon stop && ros2 daemon start
```bash ```bash
# 测试云端连接 # 测试云端连接
curl https://uni-lab.bohrium.com/api/v1/health curl https://leap-lab.bohrium.com/api/v1/health
# 测试WebSocket # 测试WebSocket
# 启动Uni-Lab后查看日志 # 启动Uni-Lab后查看日志

View File

@@ -467,43 +467,58 @@ set_status(command_json)
#### 2.1.9 核心方法详解:`pick_and_place` #### 2.1.9 核心方法详解:`pick_and_place`
```python ```python
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:
``` ```
这是 `MoveitInterface` 最复杂的方法,实现了完整的抓取-放置工作流。它动态构建一个**有序函数列表** (`function_list`),然后顺序执行。 这是 `MoveitInterface` 最复杂的方法,实现了完整的抓取-放置工作流。它动态构建一个**有序函数列表** (`function_list`),然后顺序执行。
**JSON 指令格式(完整参数):** **破坏性变更(注册表 / 客户端)**`pick_and_place` 在注册表中由 **SendCmd**(单字段 `command` JSON 字符串)改为 **UniLabJsonCommand**goal 为与上表同名的**结构化字段**。云端与其它调用方需按 schema 提交 goal而不再把整段 JSON 塞进 `command`
**动作 goal 示例(字段与旧版 JSON 一致,现为结构化 goal**
```json ```json
{ {
"option": "pick", // *必须: pick/place/side_pick/side_place "option": "pick",
"move_group": "arm", // *必须: MoveIt2 规划组名 "move_group": "arm",
"status": "pick_station_A", // *必须: 在 joint_poses 中的目标状态名 "status": "pick_station_A",
"resource": "beaker_1", // 要操作的资源名称 "resource": "beaker_1",
"target": "custom_link", // pick 时资源附着的目标 link (默认末端执行器) "target": "custom_link",
"lift_height": 0.05, // 抬升高度 (米) "lift_height": 0.05,
"x_distance": 0.1, // X 方向水平移动距离 (米) "x_distance": 0.1,
"y_distance": 0.0, // Y 方向水平移动距离 (米) "y_distance": 0.0,
"speed": 0.5, // 运动速度因子 (0.1~1.0) "speed": 0.5,
"retry": 10, // 规划失败重试次数 "retry": 10,
"constraints": [0, 0, 0, 0.5, 0, 0] // 各关节约束容差 (>0 时生效) "constraints": [0, 0, 0, 0.5, 0, 0]
} }
``` ```
##### 阶段 1指令解析与动作类型判定 ##### 阶段 1参数与动作类型判定
``` ```
pick_and_place(command_json) pick_and_place(option, move_group, status, ...)
├── JSON 解析 ├── 校验 option ∈ move_option否则直接 return
├── 动作类型判定: ├── 动作类型判定:
│ move_option = ["pick", "place", "side_pick", "side_place"] │ move_option = ["pick", "place", "side_pick", "side_place"]
│ 0 1 2 3 │ 0 1 2 3
│ option_index = move_option.index(cmd["option"]) │ option_index = move_option.index(option)
│ place_flag = option_index % 2 ← 0=pick类, 1=place类 │ place_flag = option_index % 2 ← 0=pick类, 1=place类
├── 提取运动参数: ├── 提取运动参数:
│ config = {speed, retry, move_group} ← 从 cmd_dict 中按需提取 │ config = { move_group };若 speed/retry 非 None 则写入 config
└── 获取目标关节位姿: └── 获取目标关节位姿:
joint_positions_ = joint_poses[move_group][status] joint_positions_ = joint_poses[move_group][status]
@@ -515,7 +530,7 @@ pick_and_place(command_json)
根据 place_flag 决定资源 TF 操作: 根据 place_flag 决定资源 TF 操作:
if pick 类 (place_flag == 0): if pick 类 (place_flag == 0):
if "target" 已指定: if target is not None:
function_list += [resource_manager(resource, target)] ← 挂到自定义 link function_list += [resource_manager(resource, target)] ← 挂到自定义 link
else: else:
function_list += [resource_manager(resource, end_effector)] ← 挂到末端执行器 function_list += [resource_manager(resource, end_effector)] ← 挂到末端执行器
@@ -527,7 +542,7 @@ pick_and_place(command_json)
##### 阶段 3构建关节约束 ##### 阶段 3构建关节约束
``` ```
if "constraints" 存在于指令中: if constraints is not None:
for i, tolerance in enumerate(constraints): for i, tolerance in enumerate(constraints):
if tolerance > 0: if tolerance > 0:
JointConstraint( JointConstraint(
@@ -546,7 +561,7 @@ if "constraints" 存在于指令中:
这是最复杂的场景,涉及 FK/IK 计算和多段运动拼接: 这是最复杂的场景,涉及 FK/IK 计算和多段运动拼接:
``` ```
if "lift_height" 存在: if lift_height is not None:
├── Step 1: FK 计算 → 获取目标关节配置对应的末端位姿 ├── Step 1: FK 计算 → 获取目标关节配置对应的末端位姿
│ retval = compute_fk(joint_positions_) ← 可能需要重试 │ retval = compute_fk(joint_positions_) ← 可能需要重试
@@ -562,12 +577,12 @@ if "lift_height" 存在:
│ function_list += [moveit_task(position=pose_lifted, ...)] │ function_list += [moveit_task(position=pose_lifted, ...)]
├── Step 4 (可选): 水平移动 ├── Step 4 (可选): 水平移动
│ if "x_distance": │ if x_distance is not None:
│ deep_pose = copy(pose_lifted) │ deep_pose = copy(pose_lifted)
│ deep_pose[0] += x_distance │ deep_pose[0] += x_distance
│ function_list = [moveit_task(pose_lifted)] + function_list │ function_list = [moveit_task(pose_lifted)] + function_list
│ function_list += [moveit_task(deep_pose)] │ function_list += [moveit_task(deep_pose)]
│ elif "y_distance": │ elif y_distance is not None:
│ 类似处理 Y 方向 │ 类似处理 Y 方向
├── Step 5: IK 预计算 → 将末端位姿转换为安全的关节配置 ├── Step 5: IK 预计算 → 将末端位姿转换为安全的关节配置
@@ -585,7 +600,7 @@ if "lift_height" 存在:
##### 阶段 4B无 `lift_height` 的简单流程 ##### 阶段 4B无 `lift_height` 的简单流程
``` ```
else (lift_height): else (lift_height is None):
└── 直接关节运动到目标位姿 └── 直接关节运动到目标位姿
function_list = [moveit_joint_task(joint_positions_)] + function_list function_list = [moveit_joint_task(joint_positions_)] + function_list
@@ -600,10 +615,10 @@ for i, func in enumerate(function_list):
│ i == 0: cartesian_flag = False ← 第一步用自由空间规划(大范围移动) │ i == 0: cartesian_flag = False ← 第一步用自由空间规划(大范围移动)
│ i > 0: cartesian_flag = True ← 后续用笛卡尔直线规划(精确控制) │ i > 0: cartesian_flag = True ← 后续用笛卡尔直线规划(精确控制)
├── result = func() ← 执行动作 ├── re = func() ← 执行动作
└── if not result: └── if not re:
return failure ← 任一步骤失败即中止 return(无返回值,不构造 SendCmd.Result← 任一步骤失败即中止
``` ```
##### 完整 pick 流程示例(含 lift_height + x_distance ##### 完整 pick 流程示例(含 lift_height + x_distance
@@ -657,10 +672,10 @@ for i, func in enumerate(function_list):
| `.move_to_pose(...)` | `moveit_task` L129-137 | 笛卡尔空间运动规划与执行 | | `.move_to_pose(...)` | `moveit_task` L129-137 | 笛卡尔空间运动规划与执行 |
| `.wait_until_executed()` | `moveit_task` L138, `moveit_joint_task` L157 | 阻塞等待运动完成 | | `.wait_until_executed()` | `moveit_task` L138, `moveit_joint_task` L157 | 阻塞等待运动完成 |
| `.move_to_configuration(...)` | `moveit_joint_task` L156 | 关节空间运动规划与执行 | | `.move_to_configuration(...)` | `moveit_joint_task` L156 | 关节空间运动规划与执行 |
| `.compute_fk(...)` | `pick_and_place` L244, `moveit_joint_task` L160 | 正运动学:关节角 → 末端位姿 | | `.compute_fk(...)` | `pick_and_place`, `moveit_joint_task` | 正运动学:关节角 → 末端位姿 |
| `.compute_ik(...)` | `pick_and_place` L298-300 | 逆运动学:末端位姿 → 关节角(含约束) | | `.compute_ik(...)` | `pick_and_place` | 逆运动学:末端位姿 → 关节角(含约束) |
| `.end_effector_name` | `pick_and_place` L218 | 获取末端执行器 link 名 | | `.end_effector_name` | `pick_and_place` | 获取末端执行器 link 名 |
| `.joint_names` | `pick_and_place` L232, L308, L313 | 获取关节名列表 | | `.joint_names` | `pick_and_place` | 获取关节名列表 |
--- ---
@@ -668,11 +683,11 @@ for i, func in enumerate(function_list):
| 场景 | 处理方式 | | 场景 | 处理方式 |
|------|---------| |------|---------|
| FK 计算失败 | 最多重试 `retry` 次(每次间隔 0.1s),超时返回 `result.success = False` | | FK 计算失败 | 最多重试 `retry` 次(每次间隔 0.1s),超时则提前 `return`(无返回值) |
| IK 计算失败 | 同上 | | IK 计算失败 | 同上 |
| 运动规划失败 | 在 `moveit_task` / `moveit_joint_task` 中最多重试 `retry+1` 次 | | 运动规划失败 | 在 `moveit_task` / `moveit_joint_task` 中最多重试 `retry+1` 次 |
| 动作序列中任一步失败 | `pick_and_place` 立即中止并返回 `result.success = False` | | 动作序列中任一步失败 | `pick_and_place` 立即中止并 `return`(不返回 `SendCmd.Result` |
| 未知异常 | `pick_and_place``set_status` 捕获 Exception重置 `cartesian_flag`,返回失败 | | 未知异常 | `pick_and_place` 捕获 Exception打印并重置 `cartesian_flag``set_status` 仍返回 SendCmd.Result |
--- ---
@@ -702,7 +717,8 @@ for i, func in enumerate(function_list):
``` ```
外部系统 (base_device_node) 外部系统 (base_device_node)
JSON 指令字符串 set_position/set_status: JSON 指令字符串SendCmd.command
│ pick_and_place: UniLabJsonCommand 结构化 goal → Python 关键字参数
┌── MoveitInterface ──────────────────────────────────────────────────┐ ┌── MoveitInterface ──────────────────────────────────────────────────┐
│ │ │ │
@@ -710,7 +726,7 @@ for i, func in enumerate(function_list):
│ │ │ │
│ set_status(cmd) ──→ moveit_joint_task() ──→ MoveIt2.move_to_config│ │ set_status(cmd) ──→ moveit_joint_task() ──→ MoveIt2.move_to_config│
│ │ │ │
│ pick_and_place(cmd) │ pick_and_place(option, move_group, status, ...)
│ │ │ │ │ │
│ ├─ MoveIt2.compute_fk() ─── /compute_fk service ──→ move_group │ │ ├─ MoveIt2.compute_fk() ─── /compute_fk service ──→ move_group │
│ ├─ MoveIt2.compute_ik() ─── /compute_ik service ──→ move_group │ │ ├─ MoveIt2.compute_ik() ─── /compute_ik service ──→ move_group │
@@ -963,7 +979,7 @@ robotic_arm.SCARA_with_slider.moveit.virtual:
module: unilabos.devices.ros_dev.moveit_interface:MoveitInterface module: unilabos.devices.ros_dev.moveit_interface:MoveitInterface
type: python type: python
action_value_mappings: action_value_mappings:
pick_and_place: ... # SendCmd ActionJSON 指令 pick_and_place: ... # UniLabJsonCommand结构化 goal与 Python 签名一致
set_position: ... # SendCmd Action set_position: ... # SendCmd Action
set_status: ... # SendCmd Action set_status: ... # SendCmd Action
auto-moveit_task: ... # 自动发现的方法UniLabJsonCommand auto-moveit_task: ... # 自动发现的方法UniLabJsonCommand

View File

@@ -33,11 +33,11 @@
**选择合适的安装包:** **选择合适的安装包:**
| 安装包 | 适用场景 | 包含组件 | | 安装包 | 适用场景 | 包含组件 |
|--------|----------|----------| | --------------- | ---------------------------- | --------------------------------------------- |
| `unilabos` | **推荐大多数用户**,生产部署 | 完整安装包,开箱即用 | | `unilabos` | **推荐大多数用户**,生产部署 | 完整安装包,开箱即用 |
| `unilabos-env` | 开发者(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos | | `unilabos-env` | 开发者(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos |
| `unilabos-full` | 仿真/可视化 | unilabos + 完整 ROS2 桌面版 + Gazebo + MoveIt | | `unilabos-full` | 仿真/可视化 | unilabos + 完整 ROS2 桌面版 + Gazebo + MoveIt |
**关键步骤:** **关键步骤:**
@@ -66,6 +66,7 @@ mamba install uni-lab::unilabos-full -c robostack-staging -c conda-forge
``` ```
**选择建议:** **选择建议:**
- **日常使用/生产部署**:使用 `unilabos`(推荐),完整功能,开箱即用 - **日常使用/生产部署**:使用 `unilabos`(推荐),完整功能,开箱即用
- **开发者**:使用 `unilabos-env` + `pip install -e .` + `uv pip install -r unilabos/utils/requirements.txt`,代码修改立即生效 - **开发者**:使用 `unilabos-env` + `pip install -e .` + `uv pip install -r unilabos/utils/requirements.txt`,代码修改立即生效
- **仿真/可视化**:使用 `unilabos-full`,含 Gazebo、rviz2、MoveIt - **仿真/可视化**:使用 `unilabos-full`,含 Gazebo、rviz2、MoveIt
@@ -88,7 +89,7 @@ python -c "from unilabos_msgs.msg import Resource; print('ROS msgs OK')"
#### 2.1 注册实验室账号 #### 2.1 注册实验室账号
1. 访问 [https://uni-lab.bohrium.com](https://uni-lab.bohrium.com) 1. 访问 [https://leap-lab.bohrium.com](https://leap-lab.bohrium.com)
2. 注册账号并登录 2. 注册账号并登录
3. 创建新实验室 3. 创建新实验室
@@ -297,7 +298,7 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
#### 5.2 访问 Web 界面 #### 5.2 访问 Web 界面
启动系统后,访问[https://uni-lab.bohrium.com](https://uni-lab.bohrium.com) 启动系统后,访问[https://leap-lab.bohrium.com](https://leap-lab.bohrium.com)
#### 5.3 添加设备和物料 #### 5.3 添加设备和物料
@@ -306,12 +307,10 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
**示例场景:** 创建一个简单的液体转移实验 **示例场景:** 创建一个简单的液体转移实验
1. **添加工作站(必需):** 1. **添加工作站(必需):**
- 在"仪器设备"中找到 `work_station` - 在"仪器设备"中找到 `work_station`
- 添加 `workstation` x1 - 添加 `workstation` x1
2. **添加虚拟转移泵:** 2. **添加虚拟转移泵:**
- 在"仪器设备"中找到 `virtual_device` - 在"仪器设备"中找到 `virtual_device`
- 添加 `virtual_transfer_pump` x1 - 添加 `virtual_transfer_pump` x1
@@ -818,6 +817,7 @@ uv pip install -r unilabos/utils/requirements.txt
``` ```
**为什么使用这种方式?** **为什么使用这种方式?**
- `unilabos-env` 提供 ROS2 核心组件和 uv通过 conda 安装,避免编译) - `unilabos-env` 提供 ROS2 核心组件和 uv通过 conda 安装,避免编译)
- `unilabos/utils/requirements.txt` 包含所有运行时需要的 pip 依赖 - `unilabos/utils/requirements.txt` 包含所有运行时需要的 pip 依赖
- `dev_install.py` 自动检测中文环境,中文系统自动使用清华镜像 - `dev_install.py` 自动检测中文环境,中文系统自动使用清华镜像
@@ -1796,32 +1796,27 @@ unilab --ak your_ak --sk your_sk -g graph.json \
**详细步骤:** **详细步骤:**
1. **需求分析** 1. **需求分析**
- 明确实验流程 - 明确实验流程
- 列出所需设备和物料 - 列出所需设备和物料
- 设计工作流程图 - 设计工作流程图
2. **环境搭建** 2. **环境搭建**
- 安装 Uni-Lab-OS - 安装 Uni-Lab-OS
- 创建实验室账号 - 创建实验室账号
- 准备开发工具IDE、Git - 准备开发工具IDE、Git
3. **原型验证** 3. **原型验证**
- 使用虚拟设备测试流程 - 使用虚拟设备测试流程
- 验证工作流逻辑 - 验证工作流逻辑
- 调整参数 - 调整参数
4. **迭代开发** 4. **迭代开发**
- 实现自定义设备驱动(同时撰写单点函数测试) - 实现自定义设备驱动(同时撰写单点函数测试)
- 编写注册表 - 编写注册表
- 单元测试 - 单元测试
- 集成测试 - 集成测试
5. **测试部署** 5. **测试部署**
- 连接真实硬件 - 连接真实硬件
- 空跑测试 - 空跑测试
- 小规模试验 - 小规模试验
@@ -1871,7 +1866,7 @@ unilab --ak your_ak --sk your_sk -g graph.json \
#### 14.5 社区支持 #### 14.5 社区支持
- **GitHub Issues**[https://github.com/deepmodeling/Uni-Lab-OS/issues](https://github.com/deepmodeling/Uni-Lab-OS/issues) - **GitHub Issues**[https://github.com/deepmodeling/Uni-Lab-OS/issues](https://github.com/deepmodeling/Uni-Lab-OS/issues)
- **官方网站**[https://uni-lab.bohrium.com](https://uni-lab.bohrium.com) - **官方网站**[https://leap-lab.bohrium.com](https://leap-lab.bohrium.com)
--- ---

View File

@@ -626,7 +626,7 @@ unilab
**云端图文件管理**: **云端图文件管理**:
1. 登录 https://uni-lab.bohrium.com 1. 登录 https://leap-lab.bohrium.com
2. 进入"设备配置" 2. 进入"设备配置"
3. 创建或编辑配置 3. 创建或编辑配置
4. 保存到云端 4. 保存到云端

View File

@@ -54,7 +54,6 @@ Uni-Lab 的启动过程分为以下几个阶段:
您可以直接跟随 unilabos 的提示进行,无需查阅本节 您可以直接跟随 unilabos 的提示进行,无需查阅本节
- **工作目录设置** - **工作目录设置**
- 如果当前目录以 `unilabos_data` 结尾,则使用当前目录 - 如果当前目录以 `unilabos_data` 结尾,则使用当前目录
- 否则使用 `当前目录/unilabos_data` 作为工作目录 - 否则使用 `当前目录/unilabos_data` 作为工作目录
- 可通过 `--working_dir` 指定自定义工作目录 - 可通过 `--working_dir` 指定自定义工作目录
@@ -68,8 +67,8 @@ Uni-Lab 的启动过程分为以下几个阶段:
支持多种后端环境: 支持多种后端环境:
- `--addr test`:测试环境 (`https://uni-lab.test.bohrium.com/api/v1`) - `--addr test`:测试环境 (`https://leap-lab.test.bohrium.com/api/v1`)
- `--addr uat`UAT 环境 (`https://uni-lab.uat.bohrium.com/api/v1`) - `--addr uat`UAT 环境 (`https://leap-lab.uat.bohrium.com/api/v1`)
- `--addr local`:本地环境 (`http://127.0.0.1:48197/api/v1`) - `--addr local`:本地环境 (`http://127.0.0.1:48197/api/v1`)
- 自定义地址:直接指定完整 URL - 自定义地址:直接指定完整 URL
@@ -176,7 +175,7 @@ unilab --config path/to/your/config.py
如果是首次使用,系统会: 如果是首次使用,系统会:
1. 提示前往 https://uni-lab.bohrium.com 注册实验室 1. 提示前往 https://leap-lab.bohrium.com 注册实验室
2. 引导创建配置文件 2. 引导创建配置文件
3. 设置工作目录 3. 设置工作目录
@@ -216,7 +215,7 @@ unilab --ak your_ak --sk your_sk --port 8080 --disable_browser
如果提示 "后续运行必须拥有一个实验室",请确保: 如果提示 "后续运行必须拥有一个实验室",请确保:
- 已在 https://uni-lab.bohrium.com 注册实验室 - 已在 https://leap-lab.bohrium.com 注册实验室
- 正确设置了 `--ak``--sk` 参数 - 正确设置了 `--ak``--sk` 参数
- 配置文件中包含正确的认证信息 - 配置文件中包含正确的认证信息

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -39,6 +39,12 @@ class FakeLiquidHandler(LiquidHandlerAbstract):
self.current_tip = iter(make_tip_iter()) self.current_tip = iter(make_tip_iter())
self.calls: List[Tuple[str, Any]] = [] self.calls: List[Tuple[str, Any]] = []
def set_tiprack(self, tip_racks):
# 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): async def pick_up_tips(self, tip_spots, use_channels=None, offsets=None, **backend_kwargs):
self.calls.append(("pick_up_tips", {"tips": list(tip_spots), "use_channels": use_channels})) self.calls.append(("pick_up_tips", {"tips": list(tip_spots), "use_channels": use_channels}))
@@ -545,3 +551,58 @@ def test_mix_multiple_targets_supports_per_target_offsets():
assert aspirates[1]["flow_rates"] == [rates[1]] assert aspirates[1]["flow_rates"] == [rates[1]]
def test_set_tiprack_per_type_resumes_first_physical_rack():
"""同型号多次 set_tiprack 时接续第一盒剩余孔位,而非从新盒 A1 开始。"""
from pylabrobot.liquid_handling import LiquidHandlerChatterboxBackend
from pylabrobot.resources import Deck, Tip, TipRack, TipSpot, create_equally_spaced
mk = lambda: Tip(
has_filter=False, total_tip_length=10.0, maximal_volume=300.0, fitting_depth=2.0
)
class TipTypeAlpha(TipRack):
pass
class TipTypeBeta(TipRack):
pass
def make_rack(cls: type, name: str) -> TipRack:
items = create_equally_spaced(
TipSpot,
num_items_x=12,
num_items_y=2,
dx=0,
dy=0,
dz=0,
item_dx=9,
item_dy=9,
size_x=1,
size_y=1,
make_tip=mk,
)
return cls(name, 120, 40, 10, items=items)
rack1 = make_rack(TipTypeAlpha, "rack_phys_1")
rack2 = make_rack(TipTypeBeta, "rack_phys_2")
rack3 = make_rack(TipTypeAlpha, "rack_phys_3")
lh = LiquidHandlerAbstract(
LiquidHandlerChatterboxBackend(1), Deck(), channel_num=1, simulator=False
)
flat1 = lh._flatten_tips_from_one(rack1)
assert len(flat1) == 24
lh.set_tiprack([rack1])
for i in range(12):
assert lh._get_next_tip() is flat1[i]
lh.set_tiprack([rack2])
spot_b = lh._get_next_tip()
assert "rack_phys_2" in spot_b.name
lh.set_tiprack([rack3])
spot_resume = lh._get_next_tip()
assert spot_resume is flat1[12], "第三次同型号应接续 rack1 第二行首孔,而非 rack3 首孔"
assert spot_resume is not lh._flatten_tips_from_one(rack3)[0]

View File

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

View File

@@ -12,6 +12,15 @@ from typing import Dict, Any, List
import networkx as nx import networkx as nx
import yaml import yaml
# Windows 中文系统 stdout 默认 GBK无法编码 banner / emoji 日志中的 Unicode 字符
# 强制 stdout/stderr 用 UTF-8避免 print 触发 UnicodeEncodeError 导致进程崩溃
if sys.platform == "win32":
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
except (AttributeError, OSError):
pass
# 首先添加项目根目录到路径 # 首先添加项目根目录到路径
current_dir = os.path.dirname(os.path.abspath(__file__)) current_dir = os.path.dirname(os.path.abspath(__file__))
unilabos_dir = os.path.dirname(os.path.dirname(current_dir)) unilabos_dir = os.path.dirname(os.path.dirname(current_dir))
@@ -233,7 +242,7 @@ def parse_args():
parser.add_argument( parser.add_argument(
"--addr", "--addr",
type=str, type=str,
default="https://uni-lab.bohrium.com/api/v1", default="https://leap-lab.bohrium.com/api/v1",
help="Laboratory backend address", help="Laboratory backend address",
) )
parser.add_argument( parser.add_argument(
@@ -438,10 +447,10 @@ def main():
if args.addr != parser.get_default("addr"): if args.addr != parser.get_default("addr"):
if args.addr == "test": if args.addr == "test":
print_status("使用测试环境地址", "info") print_status("使用测试环境地址", "info")
HTTPConfig.remote_addr = "https://uni-lab.test.bohrium.com/api/v1" HTTPConfig.remote_addr = "https://leap-lab.test.bohrium.com/api/v1"
elif args.addr == "uat": elif args.addr == "uat":
print_status("使用uat环境地址", "info") print_status("使用uat环境地址", "info")
HTTPConfig.remote_addr = "https://uni-lab.uat.bohrium.com/api/v1" HTTPConfig.remote_addr = "https://leap-lab.uat.bohrium.com/api/v1"
elif args.addr == "local": elif args.addr == "local":
print_status("使用本地环境地址", "info") print_status("使用本地环境地址", "info")
HTTPConfig.remote_addr = "http://127.0.0.1:48197/api/v1" HTTPConfig.remote_addr = "http://127.0.0.1:48197/api/v1"
@@ -553,7 +562,7 @@ def main():
os._exit(0) os._exit(0)
if not BasicConfig.ak or not BasicConfig.sk: if not BasicConfig.ak or not BasicConfig.sk:
print_status("后续运行必须拥有一个实验室,请前往 https://uni-lab.bohrium.com 注册实验室!", "warning") print_status("后续运行必须拥有一个实验室,请前往 https://leap-lab.bohrium.com 注册实验室!", "warning")
os._exit(1) os._exit(1)
graph: nx.Graph graph: nx.Graph
resource_tree_set: ResourceTreeSet resource_tree_set: ResourceTreeSet

View File

@@ -36,6 +36,9 @@ class HTTPClient:
auth_secret = BasicConfig.auth_secret() auth_secret = BasicConfig.auth_secret()
self.auth = auth_secret self.auth = auth_secret
info(f"正在使用ak sk作为授权信息[{auth_secret}]") info(f"正在使用ak sk作为授权信息[{auth_secret}]")
# 复用 TCP/TLS 连接,避免每次请求重新握手
self._session = requests.Session()
self._session.headers.update({"Authorization": f"Lab {self.auth}"})
info(f"HTTPClient 初始化完成: remote_addr={self.remote_addr}") info(f"HTTPClient 初始化完成: remote_addr={self.remote_addr}")
def resource_edge_add(self, resources: List[Dict[str, Any]]) -> requests.Response: def resource_edge_add(self, resources: List[Dict[str, Any]]) -> requests.Response:
@@ -48,7 +51,7 @@ class HTTPClient:
Returns: Returns:
Response: API响应对象 Response: API响应对象
""" """
response = requests.post( response = self._session.post(
f"{self.remote_addr}/edge/material/edge", f"{self.remote_addr}/edge/material/edge",
json={ json={
"edges": resources, "edges": resources,
@@ -75,25 +78,28 @@ class HTTPClient:
Returns: Returns:
Dict[str, str]: 旧UUID到新UUID的映射关系 {old_uuid: new_uuid} Dict[str, str]: 旧UUID到新UUID的映射关系 {old_uuid: new_uuid}
""" """
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_add.json"), "w", encoding="utf-8") as f: # dump() 只调用一次,复用给文件保存和 HTTP 请求
payload = {"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid} nodes_info = [x for xs in resources.dump() for x in xs]
f.write(json.dumps(payload, indent=4))
# 从序列化数据中提取所有节点的UUID保存旧UUID
old_uuids = {n.res_content.uuid: n for n in resources.all_nodes} old_uuids = {n.res_content.uuid: n for n in resources.all_nodes}
payload = {"nodes": nodes_info, "mount_uuid": mount_uuid}
body_bytes = _fast_dumps(payload)
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_add.json"), "wb") as f:
f.write(_fast_dumps_pretty(payload))
http_headers = {"Content-Type": "application/json"}
if not self.initialized or first_add: if not self.initialized or first_add:
self.initialized = True self.initialized = True
info(f"首次添加资源,当前远程地址: {self.remote_addr}") info(f"首次添加资源,当前远程地址: {self.remote_addr}")
response = requests.post( response = self._session.post(
f"{self.remote_addr}/edge/material", f"{self.remote_addr}/edge/material",
json={"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid}, data=body_bytes,
headers={"Authorization": f"Lab {self.auth}"}, headers=http_headers,
timeout=60, timeout=60,
) )
else: else:
response = requests.put( response = self._session.put(
f"{self.remote_addr}/edge/material", f"{self.remote_addr}/edge/material",
json={"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid}, data=body_bytes,
headers={"Authorization": f"Lab {self.auth}"}, headers=http_headers,
timeout=10, timeout=10,
) )
@@ -111,6 +117,7 @@ class HTTPClient:
uuid_mapping[i["uuid"]] = i["cloud_uuid"] uuid_mapping[i["uuid"]] = i["cloud_uuid"]
else: else:
logger.error(f"添加物料失败: {response.text}") logger.error(f"添加物料失败: {response.text}")
logger.trace(f"添加物料失败: {nodes_info}")
for u, n in old_uuids.items(): for u, n in old_uuids.items():
if u in uuid_mapping: if u in uuid_mapping:
n.res_content.uuid = uuid_mapping[u] n.res_content.uuid = uuid_mapping[u]
@@ -131,7 +138,7 @@ class HTTPClient:
""" """
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_get.json"), "w", encoding="utf-8") as f: with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_get.json"), "w", encoding="utf-8") as f:
f.write(json.dumps({"uuids": uuid_list, "with_children": with_children}, indent=4)) f.write(json.dumps({"uuids": uuid_list, "with_children": with_children}, indent=4))
response = requests.post( response = self._session.post(
f"{self.remote_addr}/edge/material/query", f"{self.remote_addr}/edge/material/query",
json={"uuids": uuid_list, "with_children": with_children}, json={"uuids": uuid_list, "with_children": with_children},
headers={"Authorization": f"Lab {self.auth}"}, headers={"Authorization": f"Lab {self.auth}"},
@@ -145,6 +152,7 @@ class HTTPClient:
logger.error(f"查询物料失败: {response.text}") logger.error(f"查询物料失败: {response.text}")
else: else:
data = res["data"]["nodes"] data = res["data"]["nodes"]
logger.trace(f"resource_tree_get查询到物料: {data}")
return data return data
else: else:
logger.error(f"查询物料失败: {response.text}") logger.error(f"查询物料失败: {response.text}")
@@ -162,14 +170,14 @@ class HTTPClient:
if not self.initialized: if not self.initialized:
self.initialized = True self.initialized = True
info(f"首次添加资源,当前远程地址: {self.remote_addr}") info(f"首次添加资源,当前远程地址: {self.remote_addr}")
response = requests.post( response = self._session.post(
f"{self.remote_addr}/lab/material", f"{self.remote_addr}/lab/material",
json={"nodes": resources}, json={"nodes": resources},
headers={"Authorization": f"Lab {self.auth}"}, headers={"Authorization": f"Lab {self.auth}"},
timeout=100, timeout=100,
) )
else: else:
response = requests.put( response = self._session.put(
f"{self.remote_addr}/lab/material", f"{self.remote_addr}/lab/material",
json={"nodes": resources}, json={"nodes": resources},
headers={"Authorization": f"Lab {self.auth}"}, headers={"Authorization": f"Lab {self.auth}"},
@@ -196,7 +204,7 @@ class HTTPClient:
""" """
with open(os.path.join(BasicConfig.working_dir, "req_resource_get.json"), "w", encoding="utf-8") as f: with open(os.path.join(BasicConfig.working_dir, "req_resource_get.json"), "w", encoding="utf-8") as f:
f.write(json.dumps({"id": id, "with_children": with_children}, indent=4)) f.write(json.dumps({"id": id, "with_children": with_children}, indent=4))
response = requests.get( response = self._session.get(
f"{self.remote_addr}/lab/material", f"{self.remote_addr}/lab/material",
params={"id": id, "with_children": with_children}, params={"id": id, "with_children": with_children},
headers={"Authorization": f"Lab {self.auth}"}, headers={"Authorization": f"Lab {self.auth}"},
@@ -237,14 +245,14 @@ class HTTPClient:
if not self.initialized: if not self.initialized:
self.initialized = True self.initialized = True
info(f"首次添加资源,当前远程地址: {self.remote_addr}") info(f"首次添加资源,当前远程地址: {self.remote_addr}")
response = requests.post( response = self._session.post(
f"{self.remote_addr}/lab/material", f"{self.remote_addr}/lab/material",
json={"nodes": resources}, json={"nodes": resources},
headers={"Authorization": f"Lab {self.auth}"}, headers={"Authorization": f"Lab {self.auth}"},
timeout=100, timeout=100,
) )
else: else:
response = requests.put( response = self._session.put(
f"{self.remote_addr}/lab/material", f"{self.remote_addr}/lab/material",
json={"nodes": resources}, json={"nodes": resources},
headers={"Authorization": f"Lab {self.auth}"}, headers={"Authorization": f"Lab {self.auth}"},
@@ -274,7 +282,7 @@ class HTTPClient:
with open(file_path, "rb") as file: with open(file_path, "rb") as file:
files = {"files": file} files = {"files": file}
logger.info(f"上传文件: {file_path}{scene}") logger.info(f"上传文件: {file_path}{scene}")
response = requests.post( response = self._session.post(
f"{self.remote_addr}/api/account/file_upload/{scene}", f"{self.remote_addr}/api/account/file_upload/{scene}",
files=files, files=files,
headers={"Authorization": f"Lab {self.auth}"}, headers={"Authorization": f"Lab {self.auth}"},
@@ -314,7 +322,7 @@ class HTTPClient:
"Content-Type": "application/json", "Content-Type": "application/json",
"Content-Encoding": "gzip", "Content-Encoding": "gzip",
} }
response = requests.post( response = self._session.post(
f"{self.remote_addr}/lab/resource", f"{self.remote_addr}/lab/resource",
data=compressed_body, data=compressed_body,
headers=headers, headers=headers,
@@ -348,7 +356,7 @@ class HTTPClient:
Returns: Returns:
Response: API响应对象 Response: API响应对象
""" """
response = requests.get( response = self._session.get(
f"{self.remote_addr}/edge/material/download", f"{self.remote_addr}/edge/material/download",
headers={"Authorization": f"Lab {self.auth}"}, headers={"Authorization": f"Lab {self.auth}"},
timeout=(3, 30), timeout=(3, 30),
@@ -409,7 +417,7 @@ class HTTPClient:
with open(os.path.join(BasicConfig.working_dir, "req_workflow_upload.json"), "w", encoding="utf-8") as f: with open(os.path.join(BasicConfig.working_dir, "req_workflow_upload.json"), "w", encoding="utf-8") as f:
f.write(json.dumps(payload, indent=4, ensure_ascii=False)) f.write(json.dumps(payload, indent=4, ensure_ascii=False))
response = requests.post( response = self._session.post(
f"{self.remote_addr}/lab/workflow/owner/import", f"{self.remote_addr}/lab/workflow/owner/import",
json=payload, json=payload,
headers={"Authorization": f"Lab {self.auth}"}, headers={"Authorization": f"Lab {self.auth}"},

View File

@@ -1113,7 +1113,7 @@ class MessageProcessor:
"task_id": task_id, "task_id": task_id,
"job_id": job_id, "job_id": job_id,
"free": free, "free": free,
"need_more": need_more, "need_more": need_more + 1,
}, },
} }
@@ -1253,7 +1253,7 @@ class QueueProcessor:
"task_id": job_info.task_id, "task_id": job_info.task_id,
"job_id": job_info.job_id, "job_id": job_info.job_id,
"free": False, "free": False,
"need_more": 10, "need_more": 10 + 1,
}, },
} }
self.message_processor.send_message(message) self.message_processor.send_message(message)
@@ -1269,7 +1269,13 @@ class QueueProcessor:
if not queued_jobs: if not queued_jobs:
return return
logger.debug(f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs") queue_summary = {}
for j in queued_jobs:
key = f"{j.device_id}/{j.action_name}"
queue_summary[key] = queue_summary.get(key, 0) + 1
logger.debug(
f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs: {queue_summary}"
)
for job_info in queued_jobs: for job_info in queued_jobs:
# 快照可能已过期:在遍历过程中 end_job() 可能已将此 job 移至 READY # 快照可能已过期:在遍历过程中 end_job() 可能已将此 job 移至 READY
@@ -1286,7 +1292,7 @@ class QueueProcessor:
"task_id": job_info.task_id, "task_id": job_info.task_id,
"job_id": job_info.job_id, "job_id": job_info.job_id,
"free": False, "free": False,
"need_more": 10, "need_more": 10 + 1,
}, },
} }
success = self.message_processor.send_message(message) success = self.message_processor.send_message(message)
@@ -1369,6 +1375,10 @@ class WebSocketClient(BaseCommunicationClient):
self.message_processor = MessageProcessor(self.websocket_url, self.send_queue, self.device_manager) self.message_processor = MessageProcessor(self.websocket_url, self.send_queue, self.device_manager)
self.queue_processor = QueueProcessor(self.device_manager, self.message_processor) self.queue_processor = QueueProcessor(self.device_manager, self.message_processor)
# running状态debounce缓存: {job_id: (last_send_timestamp, last_feedback_data)}
self._job_running_last_sent: Dict[str, tuple] = {}
self._job_running_debounce_interval: float = 10.0 # 秒
# 设置相互引用 # 设置相互引用
self.message_processor.set_queue_processor(self.queue_processor) self.message_processor.set_queue_processor(self.queue_processor)
self.message_processor.set_websocket_client(self) self.message_processor.set_websocket_client(self)
@@ -1468,22 +1478,32 @@ class WebSocketClient(BaseCommunicationClient):
logger.debug(f"[WebSocketClient] Not connected, cannot publish job status for job_id: {item.job_id}") logger.debug(f"[WebSocketClient] Not connected, cannot publish job status for job_id: {item.job_id}")
return return
job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name)
# 拦截最终结果状态,与原版本逻辑一致 # 拦截最终结果状态,与原版本逻辑一致
if status in ["success", "failed"]: if status in ["success", "failed"]:
self._job_running_last_sent.pop(item.job_id, None)
host_node = HostNode.get_instance(0) host_node = HostNode.get_instance(0)
if host_node: if host_node:
# 从HostNode的device_action_status中移除job_id
try: try:
host_node._device_action_status[item.device_action_key].job_ids.pop(item.job_id, None) host_node._device_action_status[item.device_action_key].job_ids.pop(item.job_id, None)
except (KeyError, AttributeError): except (KeyError, AttributeError):
logger.warning(f"[WebSocketClient] Failed to remove job {item.job_id} from HostNode status") logger.warning(f"[WebSocketClient] Failed to remove job {item.job_id} from HostNode status")
# logger.debug(f"[WebSocketClient] Intercepting final status for job_id: {item.job_id} - {status}")
# 通知队列处理器job完成包括timeout的job
self.queue_processor.handle_job_completed(item.job_id, status) self.queue_processor.handle_job_completed(item.job_id, status)
# 发送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 = { message = {
"action": "job_status", "action": "job_status",
"data": { "data": {
@@ -1499,7 +1519,6 @@ class WebSocketClient(BaseCommunicationClient):
} }
self.message_processor.send_message(message) self.message_processor.send_message(message)
job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name)
logger.trace(f"[WebSocketClient] Job status published: {job_log} - {status}") logger.trace(f"[WebSocketClient] Job status published: {job_log} - {status}")
def send_ping(self, ping_id: str, timestamp: float) -> None: def send_ping(self, ping_id: str, timestamp: float) -> None:

View File

@@ -46,7 +46,7 @@ class WSConfig:
# HTTP配置 # HTTP配置
class HTTPConfig: class HTTPConfig:
remote_addr = "https://uni-lab.bohrium.com/api/v1" remote_addr = "https://leap-lab.bohrium.com/api/v1"
# ROS配置 # ROS配置

View File

@@ -61,6 +61,7 @@ class TransferLiquidReturn(TypedDict):
class LiquidHandlerMiddleware(LiquidHandler): class LiquidHandlerMiddleware(LiquidHandler):
_ros_node: ROS2DeviceNode
def __init__( def __init__(
self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool = False, channel_num: int = 8, **kwargs self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool = False, channel_num: int = 8, **kwargs
): ):
@@ -79,6 +80,11 @@ class LiquidHandlerMiddleware(LiquidHandler):
self._simulate_handler = LiquidHandlerAbstract(self._simulate_backend, deck, False) self._simulate_handler = LiquidHandlerAbstract(self._simulate_backend, deck, False)
super().__init__(backend, deck) super().__init__(backend, deck)
def post_init(self, ros_node: BaseROS2DeviceNode):
self._ros_node = ros_node
if getattr(self, "_simulator", False) and getattr(self, "_simulate_handler", None) is not None:
self._simulate_handler._ros_node = ros_node
async def setup(self, **backend_kwargs): async def setup(self, **backend_kwargs):
if self._simulator: if self._simulator:
return await self._simulate_handler.setup(**backend_kwargs) return await self._simulate_handler.setup(**backend_kwargs)
@@ -152,7 +158,20 @@ class LiquidHandlerMiddleware(LiquidHandler):
if self._simulator: if self._simulator:
return await self._simulate_handler.pick_up_tips(tip_spots, use_channels, offsets, **backend_kwargs) return await self._simulate_handler.pick_up_tips(tip_spots, use_channels, offsets, **backend_kwargs)
return await super().pick_up_tips(tip_spots, use_channels, offsets, **backend_kwargs) # 让 PLR 走标准链路tracker.remove_tip -> 成功 commit / 失败 rollback
# 由此 TipSpot.has_tip() 自动反映为 False符合 LiquidHandler 规范。
result = await super().pick_up_tips(tip_spots, use_channels, offsets, **backend_kwargs)
for tip_spot in tip_spots:
tip_spot.empty()
if hasattr(self, "_ros_node") and self._ros_node is not None:
task = ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{"resources": tip_spots})
submit_time = time.time()
while not task.done():
if time.time() - submit_time > 10:
self._ros_node.lab_logger().info(f"pick_up_tips {tip_spots} 超时")
break
time.sleep(0.01)
return result
async def drop_tips( async def drop_tips(
self, self,
@@ -360,6 +379,16 @@ class LiquidHandlerMiddleware(LiquidHandler):
EXTRA_SAMPLE_UUID: sample_uuid_value, EXTRA_SAMPLE_UUID: sample_uuid_value,
"volume": volume, "volume": volume,
} }
if hasattr(self, "_ros_node") and self._ros_node is not None:
task = ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{"resources": resources})
submit_time = time.time()
while not task.done():
if time.time() - submit_time > 10:
self._ros_node.lab_logger().info(f"aspirate {resources} 超时")
break
time.sleep(0.01)
return SimpleReturn(samples=res_samples, volumes=res_volumes) return SimpleReturn(samples=res_samples, volumes=res_volumes)
async def dispense( async def dispense(
@@ -391,6 +420,7 @@ class LiquidHandlerMiddleware(LiquidHandler):
except Exception: except Exception:
free_volume = None free_volume = None
if isinstance(free_volume, (int, float)): if isinstance(free_volume, (int, float)):
req = min(req, max(float(free_volume), 0.0)) req = min(req, max(float(free_volume), 0.0))
safe.append(req) safe.append(req)
@@ -494,6 +524,15 @@ class LiquidHandlerMiddleware(LiquidHandler):
res_samples.append({"name": resource.name, EXTRA_SAMPLE_UUID: res_uuid}) res_samples.append({"name": resource.name, EXTRA_SAMPLE_UUID: res_uuid})
res_volumes.append(volume) res_volumes.append(volume)
if hasattr(self, "_ros_node") and self._ros_node is not None:
task = ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{"resources": resources})
submit_time = time.time()
while not task.done():
if time.time() - submit_time > 10:
self._ros_node.lab_logger().info(f"dispense {resources} 超时")
break
time.sleep(0.01)
return SimpleReturn(samples=res_samples, volumes=res_volumes) return SimpleReturn(samples=res_samples, volumes=res_volumes)
async def transfer( async def transfer(
@@ -879,7 +918,10 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
super().__init__(backend_type, deck, simulator, channel_num, total_height=total_height, **kwargs) super().__init__(backend_type, deck, simulator, channel_num, total_height=total_height, **kwargs)
def post_init(self, ros_node: BaseROS2DeviceNode): def post_init(self, ros_node: BaseROS2DeviceNode):
self._ros_node = ros_node super().post_init(ros_node)
ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
"resources": [self.deck]
})
async def _resolve_to_plr_resources( async def _resolve_to_plr_resources(
self, self,
@@ -934,6 +976,10 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
if (isinstance(local_history, list) and len(local_history) == 0 if (isinstance(local_history, list) and len(local_history) == 0
and isinstance(plr_history, list) and len(plr_history) > 0): and isinstance(plr_history, list) and len(plr_history) > 0):
local_tracker.liquid_history = list(plr_history) local_tracker.liquid_history = list(plr_history)
elif (isinstance(local_history, list) and len(local_history) > 0
and isinstance(plr_history, list) and len(plr_history) == 0):
# 远端认为容器为空,重置本地 tracker 以保持同步
local_tracker.liquid_history = []
resolved.append(local) resolved.append(local)
if len(resolved) != len(uuids): if len(resolved) != len(uuids):
raise ValueError( raise ValueError(
@@ -947,13 +993,17 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
if isinstance(orig_dict, dict) and hasattr(res, "tracker"): if isinstance(orig_dict, dict) and hasattr(res, "tracker"):
tracker = res.tracker tracker = res.tracker
local_history = getattr(tracker, "liquid_history", None) local_history = getattr(tracker, "liquid_history", None)
data = orig_dict.get("data") or {}
dict_history = data.get("liquid_history")
if isinstance(local_history, list) and len(local_history) == 0: if isinstance(local_history, list) and len(local_history) == 0:
data = orig_dict.get("data") or {}
dict_history = data.get("liquid_history")
if isinstance(dict_history, list) and len(dict_history) > 0: if isinstance(dict_history, list) and len(dict_history) > 0:
tracker.liquid_history = [ tracker.liquid_history = [
(name, float(vol)) for name, vol in dict_history (name, float(vol)) for name, vol in dict_history
] ]
elif isinstance(local_history, list) and len(local_history) > 0:
if isinstance(dict_history, list) and len(dict_history) == 0:
# 调用方认为容器为空,重置本地 tracker
tracker.liquid_history = []
result[idx] = res result[idx] = res
return result return result
@@ -1024,13 +1074,14 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
well.set_liquids([(liquid_name, safe_volume)]) # type: ignore well.set_liquids([(liquid_name, safe_volume)]) # type: ignore
res_volumes.append(safe_volume) res_volumes.append(safe_volume)
task = ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{"resources": wells}) if hasattr(self, "_ros_node") and self._ros_node is not None:
submit_time = time.time() task = ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{"resources": wells})
while not task.done(): submit_time = time.time()
if time.time() - submit_time > 10: while not task.done():
self._ros_node.lab_logger().info(f"set_liquid_from_plate {plate} 超时") if time.time() - submit_time > 10:
break self._ros_node.lab_logger().info(f"set_liquid_from_plate {plate} 超时")
time.sleep(0.01) break
time.sleep(0.01)
return SetLiquidFromPlateReturn( return SetLiquidFromPlateReturn(
plate=ResourceTreeSet.from_plr_resources([plate], known_newly_created=False).dump(), # type: ignore plate=ResourceTreeSet.from_plr_resources([plate], known_newly_created=False).dump(), # type: ignore
@@ -1437,6 +1488,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
mix_rate: Optional[int] = None, mix_rate: Optional[int] = None,
mix_liquid_height: Optional[float] = None, mix_liquid_height: Optional[float] = None,
delays: Optional[List[int]] = None, delays: Optional[List[int]] = None,
pre_aspirate_from_target: Optional[float] = None,
none_keys: List[str] = [], none_keys: List[str] = [],
) -> TransferLiquidReturn: ) -> TransferLiquidReturn:
"""Transfer liquid with automatic mode detection. """Transfer liquid with automatic mode detection.
@@ -1528,7 +1580,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
# raise ValueError(f"dis_vols length must be equal to sources or targets length, but got {len_dis_vols} and {num_sources} and {num_targets}") # raise ValueError(f"dis_vols length must be equal to sources or targets length, but got {len_dis_vols} and {num_sources} and {num_targets}")
if len(use_channels) != 8: if len(use_channels) != 8:
max_len = max(num_sources, num_targets) max_len = max(num_sources, num_targets, len_asp_vols, len_dis_vols)
prev_dropped = True # 循环开始前通道上无 tip prev_dropped = True # 循环开始前通道上无 tip
for i in range(max_len): for i in range(max_len):
@@ -1588,6 +1640,8 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
kwargs['mix_liquid_height'] = safe_get(mix_liquid_height, i, wrap=False) kwargs['mix_liquid_height'] = safe_get(mix_liquid_height, i, wrap=False)
if delays is not None: if delays is not None:
kwargs['delays'] = safe_get(delays, i) kwargs['delays'] = safe_get(delays, i)
if pre_aspirate_from_target is not None:
kwargs['pre_aspirate_from_target'] = safe_get(pre_aspirate_from_target, i)
cur_source = sources[i % num_sources] cur_source = sources[i % num_sources]
cur_target = targets[i % num_targets] cur_target = targets[i % num_targets]
@@ -1647,11 +1701,12 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
mix_rate = kwargs.get('mix_rate') mix_rate = kwargs.get('mix_rate')
mix_liquid_height = kwargs.get('mix_liquid_height') mix_liquid_height = kwargs.get('mix_liquid_height')
delays = kwargs.get('delays') delays = kwargs.get('delays')
pre_aspirate_from_target = kwargs.get('pre_aspirate_from_target')
tip = [] tip = []
if pick_up: if pick_up:
tip.append(self._get_next_tip()) tip.append(self._get_next_tip())
await self.pick_up_tips(tip) await self.pick_up_tips(tip,use_channels=use_channels)
blow_out_air_volume_before_vol = 0.0 blow_out_air_volume_before_vol = 0.0
if blow_out_air_volume_before is not None and len(blow_out_air_volume_before) > 0: if blow_out_air_volume_before is not None and len(blow_out_air_volume_before) > 0:
blow_out_air_volume_before_vol = float(blow_out_air_volume_before[0] or 0.0) blow_out_air_volume_before_vol = float(blow_out_air_volume_before[0] or 0.0)
@@ -1663,7 +1718,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0: if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
await self.mix( await self.mix(
targets=[targets[0]], targets=[sources[0]],
mix_time=mix_times, mix_time=mix_times,
mix_vol=mix_vol, mix_vol=mix_vol,
offsets=offsets if offsets else None, offsets=offsets if offsets else None,
@@ -1672,25 +1727,25 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
use_channels=use_channels, use_channels=use_channels,
) )
# if blow_out_air_volume_before_vol > 0: if blow_out_air_volume_before_vol > 0:
# source_tracker = getattr(sources[0], "tracker", None) source_tracker = getattr(sources[0], "tracker", None)
# source_tracker_was_disabled = bool(getattr(source_tracker, "is_disabled", False)) source_tracker_was_disabled = bool(getattr(source_tracker, "is_disabled", False))
# try: try:
# if source_tracker is not None and hasattr(source_tracker, "disable"): if source_tracker is not None and hasattr(source_tracker, "disable"):
# source_tracker.disable() source_tracker.disable()
# await self.aspirate( await self.aspirate(
# resources=[sources[0]], resources=[sources[0]],
# vols=[blow_out_air_volume_before_vol], vols=[0],
# use_channels=use_channels, use_channels=use_channels,
# flow_rates=None, flow_rates=None,
# offsets=[Coordinate(x=0, y=0, z=sources[0].get_size_z())], offsets=[Coordinate(x=0, y=0, z=sources[0].get_size_z())],
# liquid_height=None, liquid_height=None,
# blow_out_air_volume=None, blow_out_air_volume=[blow_out_air_volume_before_vol],
# spread="custom", spread="custom",
# ) )
# finally: finally:
# if source_tracker is not None: if source_tracker is not None:
# source_tracker.enable() source_tracker.enable()
await self.aspirate( await self.aspirate(
resources=[sources[0]], resources=[sources[0]],
@@ -1712,9 +1767,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
use_channels=use_channels, use_channels=use_channels,
flow_rates=[dis_flow_rates[0]] if dis_flow_rates and len(dis_flow_rates) > 0 else None, flow_rates=[dis_flow_rates[0]] if dis_flow_rates and len(dis_flow_rates) > 0 else None,
offsets=[offsets[0]] if offsets and len(offsets) > 0 else None, offsets=[offsets[0]] if offsets and len(offsets) > 0 else None,
blow_out_air_volume=( blow_out_air_volume=[blow_out_air_volume_vol+blow_out_air_volume_before_vol],
[blow_out_air_volume_vol] if blow_out_air_volume_vol > 0 else None
),
liquid_height=[liquid_height[0]] if liquid_height and len(liquid_height) > 0 else None, liquid_height=[liquid_height[0]] if liquid_height and len(liquid_height) > 0 else None,
spread=spread, spread=spread,
) )
@@ -1854,76 +1907,141 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
def iter_tips(self, tip_racks: Sequence[TipRack]) -> Iterator[Resource]: def iter_tips(self, tip_racks: Sequence[TipRack]) -> Iterator[Resource]:
"""Yield tips from a list of TipRacks one-by-one until depleted.""" """Yield tips from a list of TipRacks one-by-one until depleted."""
for rack in tip_racks: for rack in tip_racks:
if isinstance(rack, TipSpot): yield from self._iter_tips_single_rack_or_spot(rack)
yield rack
elif isinstance(rack, TipRack): def _iter_tips_single_rack_or_spot(self, rack: Resource) -> Iterator[Resource]:
for item in rack: """单盒或单孔:与 ``iter_tips`` 中单项逻辑一致,供扁平池构建复用。"""
if isinstance(item, list): if isinstance(rack, TipSpot):
yield from item yield rack
else: elif isinstance(rack, TipRack):
yield item for item in rack:
if isinstance(item, list):
yield from item
else:
yield item
def _flatten_tips_from_one(self, rack: Resource) -> List[Resource]:
"""将单个 TipRack/TipSpot 展开为孔位列表(顺序与 ``iter_tips`` 一致)。"""
return list(self._iter_tips_single_rack_or_spot(rack))
def _get_next_tip(self): def _get_next_tip(self):
""" current_tip 迭代器获取下一个 tip耗尽时抛出明确错误而非 StopIteration""" """按型号分组的扁平枪头池取下一孔;耗尽时抛出明确错误而非 StopIteration"""
key = getattr(self, "_active_tip_type_key", None)
flat_map = getattr(self, "_tip_flat_spots", None)
if key is not None and flat_map is not None:
flat = flat_map.get(key)
if flat is not None and len(flat) > 0:
idx = self._tip_next_index.get(key, 0)
if idx < len(flat):
self._tip_next_index[key] = idx + 1
return flat[idx]
diag = (
f"active_type_key={key}, next_index={idx}, pool_len={len(flat)}; "
f"_tip_racks_by_type[{key}] count={len(self._tip_racks_by_type.get(key, []))}"
)
raise RuntimeError(
"Tip rack exhausted: no more tips available for this tip type. "
f"Diagnostics: {diag}"
)
if not hasattr(self, "current_tip"):
raise RuntimeError(
"No tip source: call set_tiprack with TipRack/TipSpot before picking tips."
)
try: try:
return next(self.current_tip) return next(self.current_tip)
except StopIteration as e: except StopIteration as e:
diag_parts = [] diag_parts = []
tip_racks = getattr(self, 'tip_racks', None) tip_racks = getattr(self, "tip_racks", None)
if tip_racks is not None: if tip_racks is not None:
for idx, rack in enumerate(tip_racks): for idx, rack in enumerate(tip_racks):
r_name = getattr(rack, 'name', '?') r_name = getattr(rack, "name", "?")
r_type = type(rack).__name__ r_type = type(rack).__name__
is_tr = isinstance(rack, TipRack) is_tr = isinstance(rack, TipRack)
is_ts = isinstance(rack, TipSpot) is_ts = isinstance(rack, TipSpot)
n_children = len(getattr(rack, 'children', [])) n_children = len(getattr(rack, "children", []))
diag_parts.append( diag_parts.append(
f"rack[{idx}] name={r_name}, type={r_type}, " f"rack[{idx}] name={r_name}, type={r_type}, "
f"is_TipRack={is_tr}, is_TipSpot={is_ts}, children={n_children}" f"is_TipRack={is_tr}, is_TipSpot={is_ts}, children={n_children}"
) )
else: else:
diag_parts.append("tip_racks=None") diag_parts.append("tip_racks=None")
by_type = getattr(self, '_tip_racks_by_type', {}) by_type = getattr(self, "_tip_racks_by_type", {})
diag_parts.append(f"_tip_racks_by_type keys={list(by_type.keys())}") diag_parts.append(f"_tip_racks_by_type keys={list(by_type.keys())}")
active = getattr(self, "_active_tip_type_key", None)
diag_parts.append(f"_active_tip_type_key={active}")
raise RuntimeError( raise RuntimeError(
f"Tip rack exhausted: no more tips available for transfer. " "Tip rack exhausted: no more tips available for transfer. "
f"Diagnostics: {'; '.join(diag_parts)}" f"Diagnostics: {'; '.join(diag_parts)}"
) from e ) from e
@staticmethod
def _tip_type_key(rack: Resource) -> str:
"""生成枪头盒的分组键:优先用 model区分 10uL/300uL 等),否则回退到类名。"""
model = getattr(rack, "model", None)
if model and str(model).strip():
return str(model).strip()
return type(rack).__name__
def _register_rack(self, rack: Resource) -> None:
"""将单个 TipRack/TipSpot 注册到按型号分组的扁平池(去重、不重置已消耗下标)。"""
if not isinstance(rack, (TipRack, TipSpot)):
return
rack_name = rack.name if hasattr(rack, "name") else str(id(rack))
if rack_name in self._seen_rack_names:
return
self._seen_rack_names.add(rack_name)
type_key = self._tip_type_key(rack)
self._tip_racks_by_type.setdefault(type_key, []).append(rack)
self._tip_flat_spots.setdefault(type_key, []).extend(self._flatten_tips_from_one(rack))
self._tip_next_index.setdefault(type_key, 0)
def _init_all_tip_pools(self) -> None:
"""首次调用:从 deck 上一次性扫描所有 TipRack/TipSpot构建完整的按型号扁平池。"""
self._tip_racks_by_type: Dict[str, List[TipRack]] = {}
self._seen_rack_names: Set[str] = set()
self._tip_flat_spots: Dict[str, List[Resource]] = {}
self._tip_next_index: Dict[str, int] = {}
self._tip_pools_initialized = True
# 遍历 deck 直接子资源,收集所有 TipRack
deck = getattr(self, "deck", None)
if deck is not None:
for child in deck.children:
self._register_rack(child)
def set_tiprack(self, tip_racks: Sequence[TipRack]): def set_tiprack(self, tip_racks: Sequence[TipRack]):
"""Set the tip racks for the liquid handler. """设置当前 transfer 使用的枪头类型。
Groups tip racks by type name (``type(rack).__name__``). 首次调用时从 ``self.deck`` 一次性扫描所有 TipRack/TipSpot
- Only actual TipRack / TipSpot instances are registered. ``model``(或 ``type(rack).__name__``)分组构建扁平枪头池与消费下标。
- If a rack has already been registered (by ``name``), it is skipped. 后续调用仅切换 ``_active_tip_type_key``,不重建池。
- If a rack is new and its type already exists, it is appended to that type's list.
- If the type is new, a new key-value pair is created.
If the current ``tip_racks`` contain no valid TipRack/TipSpot (e.g. a 同型号多次 transfer 时,游标接续(如 A1-A12 用完后继续 B1-B12
Plate was passed by mistake), the iterator falls back to all previously 而非从新盒 A1 重新开始。
registered racks.
""" """
if not hasattr(self, '_tip_racks_by_type'): # —— 首次:全量初始化 ——
self._tip_racks_by_type: Dict[str, List[TipRack]] = {} if not getattr(self, "_tip_pools_initialized", False):
self._seen_rack_names: Set[str] = set() self._init_all_tip_pools()
# 将本次传入但 deck 上不存在的新盒也注册进去(兜底)
for rack in tip_racks: for rack in tip_racks:
if not isinstance(rack, (TipRack, TipSpot)): self._register_rack(rack)
continue
rack_name = rack.name if hasattr(rack, 'name') else str(id(rack))
if rack_name in self._seen_rack_names:
continue
self._seen_rack_names.add(rack_name)
type_key = type(rack).__name__
if type_key not in self._tip_racks_by_type:
self._tip_racks_by_type[type_key] = []
self._tip_racks_by_type[type_key].append(rack)
# —— 切换当前激活的枪头类型(按 model 区分 10uL/300uL 等)——
first_valid = next(
(r for r in tip_racks if isinstance(r, (TipRack, TipSpot))),
None,
)
self._active_tip_type_key = (
self._tip_type_key(first_valid) if first_valid is not None else None
)
# 兼容旧路径add_liquid / remove_liquid 等可能直接用 current_tip
self.tip_racks = tip_racks
valid_racks = [r for r in tip_racks if isinstance(r, (TipRack, TipSpot))] valid_racks = [r for r in tip_racks if isinstance(r, (TipRack, TipSpot))]
if not valid_racks: if not valid_racks:
valid_racks = [r for racks in self._tip_racks_by_type.values() for r in racks] valid_racks = [r for racks in self._tip_racks_by_type.values() for r in racks]
self.tip_racks = tip_racks
self.current_tip = self.iter_tips(valid_racks) self.current_tip = self.iter_tips(valid_racks)
async def move_to(self, well: Well, dis_to_top: float = 0, channel: int = 0): async def move_to(self, well: Well, dis_to_top: float = 0, channel: int = 0):

View File

@@ -149,6 +149,40 @@ class PRCXI9300Deck(Deck):
pos = self.sites[idx]["position"] pos = self.sites[idx]["position"]
return Coordinate(pos["x"], pos["y"], pos["z"]) return Coordinate(pos["x"], pos["y"], pos["z"])
def get_slot_location(self, slot: Union[int, str]) -> Coordinate:
"""根据 slot 标识返回该 slot 的坐标。
支持的输入:
- int: 1-based slot 序号(与 ``assign_child_at_slot`` 一致1 → sites[0]
- str: 纯数字字符串 ``"3"``,或带前缀的 label ``"T3"``(不区分大小写)
Raises:
ValueError: slot 解析失败或越界
"""
idx: Optional[int] = None
if isinstance(slot, int):
idx = slot - 1
elif isinstance(slot, str):
s = slot.strip()
if not s:
raise ValueError(f"空 slot 标识")
digits = s[1:] if s[0].isalpha() else s
try:
idx = int(digits) - 1
except ValueError:
# 退而求其次:直接按 label 全等匹配
for i, site in enumerate(self.sites):
if site.get("label") == s:
idx = i
break
if idx is None:
raise ValueError(f"无法解析 slot 标识: {slot!r}")
if idx < 0 or idx >= len(self.sites):
raise ValueError(
f"slot {slot!r} 超出范围 [1, {len(self.sites)}] (解析为 idx={idx})"
)
return self._get_site_location(idx)
def _get_site_resource(self, idx: int) -> Optional[Resource]: def _get_site_resource(self, idx: int) -> Optional[Resource]:
site_loc = self._get_site_location(idx) site_loc = self._get_site_location(idx)
for child in self.children: for child in self.children:
@@ -444,7 +478,7 @@ class PRCXI9300Trash(Trash):
if name != "trash": if name != "trash":
print(f"Warning: PRCXI9300Trash usually expects name='trash' for backend logic, but got '{name}'.") print(f"Warning: PRCXI9300Trash usually expects name='trash' for backend logic, but got '{name}'.")
super().__init__(name, size_x, size_y, size_z, **kwargs) super().__init__(name, size_x, size_y, size_z, category=category, **kwargs)
self._unilabos_state = {} self._unilabos_state = {}
# 初始化时注入 UUID # 初始化时注入 UUID
if material_info: if material_info:
@@ -533,12 +567,16 @@ class PRCXI9300TubeRack(TubeRack):
# 根据情况传递不同的参数 # 根据情况传递不同的参数
if items_to_pass is not None: if items_to_pass is not None:
super().__init__(name, size_x, size_y, size_z, ordered_items=items_to_pass, model=model, **kwargs) super().__init__(
name, size_x, size_y, size_z, ordered_items=items_to_pass, category=category, model=model, **kwargs
)
elif ordering_param is not None: elif ordering_param is not None:
# 传递 ordering 参数,让 TubeRack 自己创建 Tube 对象 # 传递 ordering 参数,让 TubeRack 自己创建 Tube 对象
super().__init__(name, size_x, size_y, size_z, ordering=ordering_param, model=model, **kwargs) super().__init__(
name, size_x, size_y, size_z, ordering=ordering_param, category=category, model=model, **kwargs
)
else: else:
super().__init__(name, size_x, size_y, size_z, model=model, **kwargs) super().__init__(name, size_x, size_y, size_z, category=category, model=model, **kwargs)
self._unilabos_state = {} self._unilabos_state = {}
if material_info: if material_info:
@@ -716,6 +754,7 @@ class PRCXI9300PlateAdapter(PlateAdapter):
adapter_hole_size_x=adapter_hole_size_x, adapter_hole_size_x=adapter_hole_size_x,
adapter_hole_size_y=adapter_hole_size_y, adapter_hole_size_y=adapter_hole_size_y,
adapter_hole_size_z=adapter_hole_size_z, adapter_hole_size_z=adapter_hole_size_z,
category=category,
model=model, model=model,
**kwargs, **kwargs,
) )
@@ -781,14 +820,18 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
rail_interval=0, rail_interval=0,
x_increase = -0.003636, x_increase = -0.003636,
y_increase = -0.003636, y_increase = -0.003636,
x_offset = 9.2, x_offset = -1.8,
y_offset = -27.98, y_offset = -37.48,
deck_z = 300, deck_z = 235.5,
deck_y = 400, deck_y = 400,
rail_width=27.5, rail_width=27.5,
xy_coupling = -0.0045, 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_x = (start_rail + rail_nums*5 + (rail_nums-1)*rail_interval) * rail_width
self.deck_y = deck_y self.deck_y = deck_y
self.deck_z = deck_z self.deck_z = deck_z
@@ -797,10 +840,17 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
self.x_offset = x_offset self.x_offset = x_offset
self.y_offset = y_offset self.y_offset = y_offset
self.xy_coupling = xy_coupling self.xy_coupling = xy_coupling
self.left_2_claw = Coordinate(-130.2, 34, -134) self._slot_prcxi_positions: Dict[int, Tuple[float, float]] = {}
self.right_2_left = Coordinate(22,-1, 8) self.calibration_labware_type = calibration_labware_type
plate_positions = [] self.max_z_pipetting = 185
self.max_z_claw = 170
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 = [] tablets_info = []
if is_9320 is None: if is_9320 is None:
@@ -830,12 +880,65 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
) )
super().__init__(backend=self._unilabos_backend, deck=deck, simulator=simulator, channel_num=channel_num) super().__init__(backend=self._unilabos_backend, deck=deck, simulator=simulator, channel_num=channel_num)
self._first_transfer_done = False self._first_transfer_done = False
# backend 在做槽位反查时若拿不到 deck需要回退到 handler.deck这里建立反向引用
self._unilabos_backend._handler = self
@staticmethod @staticmethod
def _get_slot_number(resource) -> Optional[int]: def _get_slot_number(resource) -> Optional[int]:
"""从 resource 的 unilabos_extra["update_resource_site"](如 "T13")或位置反算槽位号。""" """从 resource 的 unilabos_extra["update_resource_site"](如 "T13")或位置反算槽位号。"""
return _get_slot_number(resource) return _get_slot_number(resource)
def _top_level_consumable(self, resource):
"""从任意 PLR 资源沿 parent 向上找"放在 deck 上的那一层耗材""""
if resource is None:
return None
cur = resource
while cur is not None:
parent = getattr(cur, "parent", None)
if isinstance(parent, PRCXI9300Deck):
return cur
if parent is None:
# 已到顶;若 cur 本身就是 deck没有"耗材"层
if isinstance(cur, PRCXI9300Deck):
return None
return cur
cur = parent
return None
def _attach_resources_to_deck_if_needed(self, items: Sequence[Resource]) -> None:
"""把通过 _resolve_to_plr_resources 拿回的"游离"耗材自动挂到 self.deck。
- 已经在 PRCXI9300Deck 上(含 name 同名)的跳过;
- 优先按 ``unilabos_extra.update_resource_site`` 的 Tn 解析槽位;
- 否则交给 ``Deck.assign_child_resource`` 找空槽。
- 任意失败仅打印告警不中断主流程backend 仍可走名字兜底)。
"""
deck = getattr(self, "deck", None)
if not isinstance(deck, PRCXI9300Deck):
return
existing_names = {getattr(c, "name", None) for c in deck.children}
for item in items:
top = self._top_level_consumable(item)
if top is None or not isinstance(top, Resource):
continue
if isinstance(getattr(top, "parent", None), PRCXI9300Deck):
continue
top_name = getattr(top, "name", None)
if top_name in existing_names:
continue
spot_idx: Optional[int] = None
extra = getattr(top, "unilabos_extra", {}) or {}
site = str(extra.get("update_resource_site", ""))
if site:
digits = "".join(c for c in site if c.isdigit())
if digits:
spot_idx = int(digits) - 1
try:
deck.assign_child_resource(top, spot=spot_idx, reassign=False)
existing_names.add(top_name)
except Exception as e:
print(f"[PRCXI] 自动挂载到 deck 失败: name={top_name}, site={site or '?'}, err={e}")
def _match_and_create_matrix(self): def _match_and_create_matrix(self):
"""首次 transfer_liquid 时,根据 deck 上的 resource 自动匹配耗材并创建 WorkTabletMatrix。""" """首次 transfer_liquid 时,根据 deck 上的 resource 自动匹配耗材并创建 WorkTabletMatrix。"""
backend = self._unilabos_backend backend = self._unilabos_backend
@@ -872,14 +975,15 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
mat_uuid = resource._unilabos_state["Material"].get("uuid") mat_uuid = resource._unilabos_state["Material"].get("uuid")
if mat_uuid and mat_uuid in material_uuid_map: if mat_uuid and mat_uuid in material_uuid_map:
work_tablets.append({"Number": number, "Material": material_uuid_map[mat_uuid]}) work_tablets.append({"Number": number, "Material": material_uuid_map[mat_uuid]})
slot_none.remove(number)
continue continue
# 根据 resource 类型推断 materialEnum # 根据 resource 类型推断 materialEnum
# MaterialEnum: Other=0, Tips=1, DeepWellPlate=2, PCRPlate=3, ELISAPlate=4, Reservoir=5, WasteBox=6 # MaterialEnum: Other=0, Tips=1, DeepWellPlate=2, PCRPlate=3, ELISAPlate=4, Reservoir=5, WasteBox=6
expected_enum = None expected_enum = None
if isinstance(resource, PRCXI9300TipRack) or isinstance(resource, TipRack): if isinstance(resource, TipRack):
expected_enum = 1 # Tips expected_enum = 1 # Tips
elif isinstance(resource, PRCXI9300Trash) or isinstance(resource, Trash): elif isinstance(resource, Trash):
expected_enum = 6 # WasteBox expected_enum = 6 # WasteBox
elif isinstance(resource, (PRCXI9300Plate, Plate)): elif isinstance(resource, (PRCXI9300Plate, Plate)):
expected_enum = None # Plate 可能是 DeepWellPlate/PCRPlate/ELISAPlate不限定 expected_enum = None # Plate 可能是 DeepWellPlate/PCRPlate/ELISAPlate不限定
@@ -930,7 +1034,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
matrix_id = str(uuid.uuid4()) matrix_id = str(uuid.uuid4())
matrix_info = { matrix_info = {
"MatrixId": matrix_id, "MatrixId": matrix_id,
"MatrixName": matrix_id, "MatrixName": "matrix_" + str(time.time()),
"WorkTablets": work_tablets + "WorkTablets": work_tablets +
[{"Number": number, "Material": {"uuid": "730067cf07ae43849ddf4034299030e9"}} for number in slot_none], [{"Number": number, "Material": {"uuid": "730067cf07ae43849ddf4034299030e9"}} for number in slot_none],
} }
@@ -941,34 +1045,40 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
# 重新计算所有槽位的位置(初始化时 deck 可能为空,此时才有资源) # 重新计算所有槽位的位置(初始化时 deck 可能为空,此时才有资源)
pipetting_positions = [] pipetting_positions = []
plate_positions = [] claw_positions = []
for child in self.deck.children: for child in self.deck.children:
number = self._get_slot_number(child) number = self._get_slot_number(child)
if number is None: if number is None:
continue continue
pos = self.plr_pos_to_prcxi(child) pos = self.plr_pos_to_prcxi(child, self.left_2_claw)
plate_positions.append({"Number": number, "XPos": pos.x, "YPos": pos.y, "ZPos": pos.z}) 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": max(min(pos.z, self.max_z_claw),0)})
if child.children: if child.children:
pip_pos = self.plr_pos_to_prcxi(child.children[0], self.left_2_claw) pip_pos = self.plr_pos_to_prcxi(child.children[0])
else: else:
pip_pos = self.plr_pos_to_prcxi(child, Coordinate(-100, self.left_2_claw.y, self.left_2_claw.z)) pip_pos = self.plr_pos_to_prcxi(child)
half_x = child.get_size_x() / 2 * abs(1 + self.x_increase) 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() z_wall = child.get_size_z()
pipetting_positions.append({ pipetting_positions.append({
"Number": number, "Number": number,
"XPos": pip_pos.x, "XPos": pip_pos.x,
"YPos": pip_pos.y, "YPos": pip_pos.y,
"ZPos": pip_pos.z, "ZPos": max(min(pip_pos.z, self.max_z_pipetting),0),
"X_Left": half_x, "X_Left": half_x,
"X_Right": half_x, "X_Right": half_x,
"ZAgainstTheWall": pip_pos.z - z_wall, "ZAgainstTheWall": pip_pos.z - z_wall,
"X2Pos": pip_pos.x + self.right_2_left.x, "X2Pos": pip_pos.x + self.right_2_left.x,
"Y2Pos": pip_pos.y + self.right_2_left.y, "Y2Pos": pip_pos.y + self.right_2_left.y,
"Z2Pos": pip_pos.z + self.right_2_left.z, "Z2Pos": max(min((pip_pos.z + self.right_2_left.z), self.max_z_pipetting),0),
"X2_Left": half_x, "X2_Left": half_x,
"X2_Right": half_x, "X2_Right": half_x,
"ZAgainstTheWall2": pip_pos.z - z_wall, "ZAgainstTheWall2": pip_pos.z - z_wall,
@@ -976,26 +1086,78 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
if pipetting_positions: if pipetting_positions:
api.update_pipetting_position(matrix_id, pipetting_positions) api.update_pipetting_position(matrix_id, pipetting_positions)
# 更新 backend 中的 plate_positions # 更新 backend 中的 claw_positions
backend.plate_positions = plate_positions backend.claw_positions = claw_positions
if plate_positions: if claw_positions:
api.update_clamp_jaw_position(matrix_id, plate_positions) api.update_clamp_jaw_position(matrix_id, claw_positions)
print(f"Auto-matched materials and created matrix: {matrix_id}") print(f"Auto-matched materials and created matrix: {matrix_id}")
else: else:
raise PRCXIError(f"Failed to create auto-matched matrix: {res.get('Message', 'Unknown error')}") raise PRCXIError(f"Failed to create auto-matched matrix: {res.get('Message', 'Unknown error')}")
def plr_pos_to_prcxi(self, resource: Resource, offset: Coordinate = Coordinate(0, 0, 0)): 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' z_pos = 'c'
if isinstance(resource, Tip): tip_height = self.tip_height
z_pos = 'b' if isinstance(resource, TipSpot):
z_pos = 't'
tip_height = 0
resource_pos = resource.get_absolute_location(x="c",y="c",z=z_pos) resource_pos = resource.get_absolute_location(x="c",y="c",z=z_pos)
x = resource_pos.x x = resource_pos.x
y = resource_pos.y y = resource_pos.y
z = resource_pos.z z = resource_pos.z + tip_height
# 如果z等于0则递归resource.parent的高度并向z加使用get_size_z方法
parent = resource.parent parent = resource.parent
res_z = resource.location.z res_z = resource.location.z
@@ -1003,14 +1165,21 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
z += parent.get_size_z() z += parent.get_size_z()
res_z = parent.location.z res_z = parent.location.z
parent = getattr(parent, "parent", None) parent = getattr(parent, "parent", None)
prcxi_x = (self.deck_x - x)*(1+self.x_increase) + self.x_offset + self.xy_coupling * (self.deck_y - y) slot_number = self._find_slot_for_resource(resource) if self._slot_prcxi_positions else None
prcxi_y = (self.deck_y - y)*(1+self.y_increase) + self.y_offset 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_z = self.deck_z - z
prcxi_x = min(max(0, prcxi_x+offset.x),self.deck_x) prcxi_x = min(max(0, prcxi_x+resource_offset.x),self.deck_x)
prcxi_y = min(max(0, prcxi_y+offset.y),self.deck_y) prcxi_y = min(max(0, prcxi_y+resource_offset.y),self.deck_y)
prcxi_z = min(max(0, prcxi_z+offset.z),self.deck_z) prcxi_z = min(max(0, prcxi_z+resource_offset.z),self.deck_z)
return Coordinate(prcxi_x, prcxi_y, prcxi_z) return Coordinate(prcxi_x, prcxi_y, prcxi_z)
@@ -1121,6 +1290,27 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
none_keys=none_keys, 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( async def transfer_liquid(
self, self,
sources: Sequence[Container], sources: Sequence[Container],
@@ -1145,6 +1335,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
mix_rate: Optional[int] = None, mix_rate: Optional[int] = None,
mix_liquid_height: Optional[float] = None, mix_liquid_height: Optional[float] = None,
delays: Optional[List[int]] = None, delays: Optional[List[int]] = None,
pre_aspirate_from_target: Optional[float] = None,
none_keys: List[str] = [], none_keys: List[str] = [],
) -> TransferLiquidReturn: ) -> TransferLiquidReturn:
if not self._first_transfer_done: if not self._first_transfer_done:
@@ -1152,6 +1343,58 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
self._first_transfer_done = True self._first_transfer_done = True
if self.step_mode: if self.step_mode:
await self.create_protocol(f"transfer_liquid{time.time()}") 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))
# 远端解析回来的 PLR 实例可能未挂到 self.deck主动绑定一次避免 backend 取 plate.parent==None
self._attach_resources_to_deck_if_needed(list(sources) + list(targets) + list(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( res = await super().transfer_liquid(
sources, sources,
targets, targets,
@@ -1165,7 +1408,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
touch_tip=touch_tip, touch_tip=touch_tip,
liquid_height=liquid_height, liquid_height=liquid_height,
blow_out_air_volume=blow_out_air_volume, blow_out_air_volume=blow_out_air_volume,
blow_out_air_volume_before=blow_out_air_volume_before, blow_out_air_volume_before=None,
spread=spread, spread=spread,
is_96_well=is_96_well, is_96_well=is_96_well,
mix_stage=mix_stage, mix_stage=mix_stage,
@@ -1174,6 +1417,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
mix_rate=mix_rate, mix_rate=mix_rate,
mix_liquid_height=mix_liquid_height, mix_liquid_height=mix_liquid_height,
delays=delays, delays=delays,
pre_aspirate_from_target=pre_aspirate_from_target,
none_keys=none_keys, none_keys=none_keys,
) )
if self.step_mode: if self.step_mode:
@@ -1329,6 +1573,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
_num_channels = 8 # 默认通道数为 8 _num_channels = 8 # 默认通道数为 8
_is_reset_ok = False _is_reset_ok = False
_ros_node: BaseROS2DeviceNode _ros_node: BaseROS2DeviceNode
_handler: Optional["PRCXI9300Handler"] = None # 由 PRCXI9300Handler.__init__ 注入
@property @property
def is_reset_ok(self) -> bool: def is_reset_ok(self) -> bool:
@@ -1362,13 +1607,52 @@ class PRCXI9300Backend(LiquidHandlerBackend):
self.debug = debug self.debug = debug
self.axis = "Left" self.axis = "Left"
@staticmethod def _resolve_deck(self, plate, deck=None) -> Optional["PRCXI9300Deck"]:
def _deck_plate_slot_no(plate, deck) -> int: """定位 plate 所属的 PRCXI9300Deck按 deck 入参 → plate 的祖先链 → handler.deck 顺序回退。"""
"""台面板位槽号116与 PRCXI9300Handler._get_slot_number 一致;无法解析时退回 deck 子项顺序 +1。""" if isinstance(deck, PRCXI9300Deck):
return deck
cur = plate
while cur is not None:
if isinstance(cur, PRCXI9300Deck):
return cur
cur = getattr(cur, "parent", None)
if self._handler is not None:
handler_deck = getattr(self._handler, "deck", None)
if isinstance(handler_deck, PRCXI9300Deck):
return handler_deck
return None
def _deck_plate_slot_no(self, plate, deck=None) -> int:
"""台面板位槽号116优先 _get_slot_number否则沿父链/handler.deck 找到 deck 后取序号+1。"""
sn = PRCXI9300Handler._get_slot_number(plate) sn = PRCXI9300Handler._get_slot_number(plate)
if sn is not None: if sn is not None:
return sn return sn
return deck.children.index(plate) + 1 actual_deck = self._resolve_deck(plate, deck)
if actual_deck is None:
raise RuntimeError(
f"无法定位 {getattr(plate, 'name', '?')} 所在的 PRCXI9300Deck"
"请确认 tip_rack/plate 已挂到 self.deck或在 unilabos_extra 中提供 update_resource_site=Tn。"
)
if plate in actual_deck.children:
index = actual_deck.children.index(plate)
plate_new = actual_deck.children[index]
sn = PRCXI9300Handler._get_slot_number(plate_new)
if sn is not None:
return sn
else:
raise RuntimeError(
f"无法定位 {getattr(plate_new, 'name', '?')} 所在的 PRCXI9300Deck"
f"x: {plate_new.location}"
)
# 名字兜底(远端解析回来的实例与 deck 上的不是同一对象)
plate_name = getattr(plate, "name", None)
if plate_name is not None:
for i, c in enumerate(actual_deck.children):
if getattr(c, "name", None) == plate_name:
return i + 1
raise RuntimeError(
f"{getattr(plate, 'name', '?')} 不在 deck.children 中且无可解析的槽位号"
)
@staticmethod @staticmethod
def _resource_num_items_y(resource) -> int: def _resource_num_items_y(resource) -> int:
@@ -1396,8 +1680,9 @@ class PRCXI9300Backend(LiquidHandlerBackend):
offset = pickup.offset offset = pickup.offset
pickup_distance_from_top = pickup.pickup_distance_from_top pickup_distance_from_top = pickup.pickup_distance_from_top
direction = pickup.direction direction = pickup.direction
plate = resource.parent
plate_number = int(resource.parent.name.replace("T", "")) deck = plate.parent
plate_number = self._deck_plate_slot_no(plate, deck)
is_whole_plate = True is_whole_plate = True
balance_height = 0 balance_height = 0
step = self.api_client.clamp_jaw_pick_up(plate_number, is_whole_plate, balance_height) step = self.api_client.clamp_jaw_pick_up(plate_number, is_whole_plate, balance_height)
@@ -1410,7 +1695,9 @@ class PRCXI9300Backend(LiquidHandlerBackend):
plate_number = None plate_number = None
target_plate_number = backend_kwargs.get("target_plate_number", None) target_plate_number = backend_kwargs.get("target_plate_number", None)
if target_plate_number is not 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 is_whole_plate = True
balance_height = 0 balance_height = 0
@@ -1508,7 +1795,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
await asyncio.sleep(1) await asyncio.sleep(1)
print("PRCXI9300 reset successfully.") print("PRCXI9300 reset successfully.")
# self.api_client.update_clamp_jaw_position(self.matrix_id, self.plate_positions) # self.api_client.update_clamp_jaw_position(self.matrix_id, self.claw_positions)
except ConnectionRefusedError as e: except ConnectionRefusedError as e:
raise RuntimeError( raise RuntimeError(
@@ -1702,6 +1989,7 @@ class PRCXI9300Backend(LiquidHandlerBackend):
assert mix_time > 0 assert mix_time > 0
step = self.api_client.Blending( step = self.api_client.Blending(
axis=axis,
dosage=mix_vol, dosage=mix_vol,
plate_no=PlateNo, plate_no=PlateNo,
is_whole_plate=False, is_whole_plate=False,
@@ -1757,20 +2045,24 @@ class PRCXI9300Backend(LiquidHandlerBackend):
PlateNo = plate_slots[0] PlateNo = plate_slots[0]
hole_col = tip_columns[0] + 1 hole_col = tip_columns[0] + 1
hole_row = 1 hole_row = 1
assist_fun1 = ""
if self.num_channels != 8: if self.num_channels != 8:
hole_row = tipspot_index % ny + 1 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( step = self.api_client.Imbibing(
axis=axis, axis=axis,
dosage=int(volumes[0]), dosage=float(volumes[0]),
plate_no=PlateNo, plate_no=PlateNo,
is_whole_plate=False, is_whole_plate=False,
hole_row=hole_row, hole_row=hole_row,
hole_col=hole_col, hole_col=hole_col,
blending_times=0, blending_times=0,
balance_height=0, balance_height=int(min(max(ops[0].liquid_height,0),10)),
plate_or_hole=f"H{hole_col}-{ny},T{PlateNo}", plate_or_hole=f"H{hole_col}-{ny},T{PlateNo}",
hole_numbers="1,2,3,4,5,6,7,8", hole_numbers="1,2,3,4,5,6,7,8",
assist_fun1=assist_fun1,
) )
self.steps_todo_list.append(step) self.steps_todo_list.append(step)
@@ -1821,6 +2113,12 @@ class PRCXI9300Backend(LiquidHandlerBackend):
if self.num_channels != 8: if self.num_channels != 8:
hole_row = tipspot_index % ny + 1 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,5),10))}ul)"
else :
assist_fun1 = f"吹样({5.0}ul)"
step = self.api_client.Tapping( step = self.api_client.Tapping(
axis=axis, axis=axis,
dosage=int(volumes[0]), dosage=int(volumes[0]),
@@ -1829,9 +2127,10 @@ class PRCXI9300Backend(LiquidHandlerBackend):
hole_row=hole_row, hole_row=hole_row,
hole_col=hole_col, hole_col=hole_col,
blending_times=0, blending_times=0,
balance_height=0, balance_height=int(min(max(ops[0].liquid_height,0),10)),
plate_or_hole=f"H{hole_col}-{ny},T{PlateNo}", plate_or_hole=f"H{hole_col}-{ny},T{PlateNo}",
hole_numbers="1,2,3,4,5,6,7,8", hole_numbers="1,2,3,4,5,6,7,8",
assist_fun1=assist_fun1,
) )
self.steps_todo_list.append(step) self.steps_todo_list.append(step)
@@ -2026,10 +2325,10 @@ class PRCXI9300Api:
"""GetWorkTabletMatrixById""" """GetWorkTabletMatrixById"""
return self.call("IMatrix", "GetWorkTabletMatrixById", [matrix_id]) return self.call("IMatrix", "GetWorkTabletMatrixById", [matrix_id])
def update_clamp_jaw_position(self, target_matrix_id: str, plate_positions: List[Dict[str, Any]]): def update_clamp_jaw_position(self, target_matrix_id: str, claw_positions: List[Dict[str, Any]]):
position_params = { position_params = {
"MatrixId": target_matrix_id, "MatrixId": target_matrix_id,
"WorkTablets": plate_positions "WorkTablets": claw_positions
} }
return self.call("IMatrix", "UpdateClampJawPosition", [position_params]) return self.call("IMatrix", "UpdateClampJawPosition", [position_params])

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -2,6 +2,8 @@ import time
import logging import logging
from typing import Union, Dict, Optional from typing import Union, Dict, Optional
from unilabos.registry.decorators import topic_config
class VirtualMultiwayValve: class VirtualMultiwayValve:
""" """
@@ -41,13 +43,11 @@ class VirtualMultiwayValve:
def target_position(self) -> int: def target_position(self) -> int:
return self._target_position return self._target_position
def get_current_position(self) -> int: @property
"""获取当前阀门位置 📍""" @topic_config()
return self._current_position def current_port(self) -> str:
"""当前连接的端口名称 🔌"""
def get_current_port(self) -> str: return self.port
"""获取当前连接的端口名称 🔌"""
return self._current_position
def set_position(self, command: Union[int, str]): def set_position(self, command: Union[int, str]):
""" """
@@ -169,12 +169,14 @@ class VirtualMultiwayValve:
self._status = "Idle" self._status = "Idle"
self._valve_state = "Closed" self._valve_state = "Closed"
close_msg = f"🔒 阀门已关闭,保持在位置 {self._current_position} ({self.get_current_port()})" close_msg = f"🔒 阀门已关闭,保持在位置 {self._current_position} ({self.port})"
self.logger.info(close_msg) self.logger.info(close_msg)
return close_msg return close_msg
def get_valve_position(self) -> int: @property
"""获取阀门位置 - 兼容性方法 📍""" @topic_config()
def valve_position(self) -> int:
"""阀门位置 📍"""
return self._current_position return self._current_position
def set_valve_position(self, command: Union[int, str]): def set_valve_position(self, command: Union[int, str]):
@@ -229,19 +231,16 @@ class VirtualMultiwayValve:
self.logger.info(f"🔄 从端口 {self._current_position} 切换到泵位置...") self.logger.info(f"🔄 从端口 {self._current_position} 切换到泵位置...")
return self.set_to_pump_position() return self.set_to_pump_position()
def get_flow_path(self) -> str: @property
"""获取当前流路路径描述 🌊""" @topic_config()
current_port = self.get_current_port() def flow_path(self) -> str:
"""当前流路路径描述 🌊"""
if self._current_position == 0: if self._current_position == 0:
flow_path = f"🚰 转移泵已连接 (位置 {self._current_position})" return f"🚰 转移泵已连接 (位置 {self._current_position})"
else: return f"🔌 端口 {self._current_position} 已连接 ({self.current_port})"
flow_path = f"🔌 端口 {self._current_position} 已连接 ({current_port})"
# 删除debug日志self.logger.debug(f"🌊 当前流路: {flow_path}")
return flow_path
def __str__(self): def __str__(self):
current_port = self.get_current_port() current_port = self.current_port
status_emoji = "" if self._status == "Idle" else "🔄" if self._status == "Busy" else "" status_emoji = "" if self._status == "Idle" else "🔄" if self._status == "Busy" else ""
return f"🔄 VirtualMultiwayValve({status_emoji} 位置: {self._current_position}/{self.max_positions}, 端口: {current_port}, 状态: {self._status})" return f"🔄 VirtualMultiwayValve({status_emoji} 位置: {self._current_position}/{self.max_positions}, 端口: {current_port}, 状态: {self._status})"
@@ -253,7 +252,7 @@ if __name__ == "__main__":
print("🔄 === 虚拟九通阀门测试 === ✨") print("🔄 === 虚拟九通阀门测试 === ✨")
print(f"🏠 初始状态: {valve}") print(f"🏠 初始状态: {valve}")
print(f"🌊 当前流路: {valve.get_flow_path()}") print(f"🌊 当前流路: {valve.flow_path}")
# 切换到试剂瓶11号位 # 切换到试剂瓶11号位
print(f"\n🔌 切换到1号位: {valve.set_position(1)}") print(f"\n🔌 切换到1号位: {valve.set_position(1)}")

View File

@@ -3,6 +3,7 @@ import logging
import time as time_module import time as time_module
from typing import Dict, Any from typing import Dict, Any
from unilabos.registry.decorators import topic_config
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
class VirtualStirrer: class VirtualStirrer:
@@ -314,9 +315,11 @@ class VirtualStirrer:
def min_speed(self) -> float: def min_speed(self) -> float:
return self._min_speed return self._min_speed
def get_device_info(self) -> Dict[str, Any]: @property
"""获取设备状态信息 📊""" @topic_config()
info = { def device_info(self) -> Dict[str, Any]:
"""设备状态快照信息 📊"""
return {
"device_id": self.device_id, "device_id": self.device_id,
"status": self.status, "status": self.status,
"operation_mode": self.operation_mode, "operation_mode": self.operation_mode,
@@ -325,12 +328,9 @@ class VirtualStirrer:
"is_stirring": self.is_stirring, "is_stirring": self.is_stirring,
"remaining_time": self.remaining_time, "remaining_time": self.remaining_time,
"max_speed": self._max_speed, "max_speed": self._max_speed,
"min_speed": self._min_speed "min_speed": self._min_speed,
} }
# self.logger.debug(f"📊 设备信息: 模式={self.operation_mode}, 速度={self.current_speed} RPM, 搅拌={self.is_stirring}")
return info
def __str__(self): def __str__(self):
status_emoji = "" if self.operation_mode == "Idle" else "🌪️" if self.operation_mode == "Stirring" else "🛑" if self.operation_mode == "Settling" else "" status_emoji = "" if self.operation_mode == "Idle" else "🌪️" if self.operation_mode == "Stirring" else "🛑" if self.operation_mode == "Settling" else ""
return f"🌪️ VirtualStirrer({status_emoji} {self.device_id}: {self.operation_mode}, {self.current_speed} RPM)" return f"🌪️ VirtualStirrer({status_emoji} {self.device_id}: {self.operation_mode}, {self.current_speed} RPM)"

View File

@@ -4,6 +4,7 @@ from enum import Enum
from typing import Union, Optional from typing import Union, Optional
import logging import logging
from unilabos.registry.decorators import topic_config
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
@@ -385,8 +386,10 @@ class VirtualTransferPump:
"""获取当前体积""" """获取当前体积"""
return self._current_volume return self._current_volume
def get_remaining_capacity(self) -> float: @property
"""获取剩余容量""" @topic_config()
def remaining_capacity(self) -> float:
"""剩余容量 (ml)"""
return self.max_volume - self._current_volume return self.max_volume - self._current_volume
def is_empty(self) -> bool: def is_empty(self) -> bool:

View File

@@ -14,19 +14,30 @@ Virtual Workbench Device - 模拟工作台设备
import logging import logging
import time import time
from typing import Dict, Any, Optional, List
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
from threading import Lock, RLock from threading import Lock, RLock
from typing import Any, Dict, List, Optional, cast
from typing_extensions import TypedDict from typing_extensions import TypedDict
from unilabos.registry.decorators import ( from unilabos.registry.decorators import (
device, action, ActionInputHandle, ActionOutputHandle, DataSource, topic_config, not_action ActionInputHandle,
ActionOutputHandle,
DataSource,
NodeType,
action,
device,
not_action,
topic_config,
)
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode
from unilabos.resources.resource_tracker import (
SampleUUIDsType,
LabSample,
ResourceTreeSet,
) )
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample
# ============ TypedDict 返回类型定义 ============ # ============ TypedDict 返回类型定义 ============
@@ -111,6 +122,7 @@ class HeatingStation:
@device( @device(
id="virtual_workbench", id="virtual_workbench",
display_name="虚拟工作台",
category=["virtual_device"], category=["virtual_device"],
description="Virtual Workbench with 1 robotic arm and 3 heating stations for concurrent material processing", description="Virtual Workbench with 1 robotic arm and 3 heating stations for concurrent material processing",
) )
@@ -136,7 +148,19 @@ class VirtualWorkbench:
HEATING_TIME: float = 60.0 # 加热时间(秒) HEATING_TIME: float = 60.0 # 加热时间(秒)
NUM_HEATING_STATIONS: int = 3 # 加热台数量 NUM_HEATING_STATIONS: int = 3 # 加热台数量
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs): def __init__(
self,
device_id: Optional[str] = None,
config: Optional[Dict[str, Any]] = None,
**kwargs,
):
"""
初始化虚拟工作台。
Args:
device_id[设备ID]: 工作台设备实例 ID默认使用 virtual_workbench。
config[设备配置]: 可包含 arm_operation_time、heating_time、num_heating_stations。
"""
# 处理可能的不同调用方式 # 处理可能的不同调用方式
if device_id is None and "id" in kwargs: if device_id is None and "id" in kwargs:
device_id = kwargs.pop("id") device_id = kwargs.pop("id")
@@ -150,9 +174,13 @@ class VirtualWorkbench:
self.data: Dict[str, Any] = {} self.data: Dict[str, Any] = {}
# 从config中获取可配置参数 # 从config中获取可配置参数
self.ARM_OPERATION_TIME = float(self.config.get("arm_operation_time", self.ARM_OPERATION_TIME)) self.ARM_OPERATION_TIME = float(
self.config.get("arm_operation_time", self.ARM_OPERATION_TIME)
)
self.HEATING_TIME = float(self.config.get("heating_time", self.HEATING_TIME)) self.HEATING_TIME = float(self.config.get("heating_time", self.HEATING_TIME))
self.NUM_HEATING_STATIONS = int(self.config.get("num_heating_stations", self.NUM_HEATING_STATIONS)) self.NUM_HEATING_STATIONS = int(
self.config.get("num_heating_stations", self.NUM_HEATING_STATIONS)
)
# 机械臂状态和锁 # 机械臂状态和锁
self._arm_lock = Lock() self._arm_lock = Lock()
@@ -161,7 +189,8 @@ class VirtualWorkbench:
# 加热台状态 # 加热台状态
self._heating_stations: Dict[int, HeatingStation] = { self._heating_stations: Dict[int, HeatingStation] = {
i: HeatingStation(station_id=i) for i in range(1, self.NUM_HEATING_STATIONS + 1) i: HeatingStation(station_id=i)
for i in range(1, self.NUM_HEATING_STATIONS + 1)
} }
self._stations_lock = RLock() self._stations_lock = RLock()
@@ -290,20 +319,292 @@ class VirtualWorkbench:
self._update_data_status(f"机械臂已释放 (完成: {task})") self._update_data_status(f"机械臂已释放 (完成: {task})")
self.logger.info(f"机械臂已释放 (完成: {task})") self.logger.info(f"机械臂已释放 (完成: {task})")
@action(
always_free=True,
node_type=NodeType.MANUAL_CONFIRM,
placeholder_keys={"assignee_user_ids": "unilabos_manual_confirm"},
goal_default={"timeout_seconds": 3600, "assignee_user_ids": []},
feedback_interval=300,
handles=[
ActionInputHandle(
key="target_device",
data_type="device_id",
label="目标设备",
data_key="target_device",
data_source=DataSource.HANDLE,
),
ActionInputHandle(
key="resource",
data_type="resource",
label="待转移资源",
data_key="resource",
data_source=DataSource.HANDLE,
),
ActionInputHandle(
key="mount_resource",
data_type="resource",
label="目标孔位",
data_key="mount_resource",
data_source=DataSource.HANDLE,
),
ActionInputHandle(
key="collector_mass",
data_type="collector_mass",
label="极流体质量",
data_key="collector_mass",
data_source=DataSource.HANDLE,
),
ActionInputHandle(
key="active_material",
data_type="active_material",
label="活性物质含量",
data_key="active_material",
data_source=DataSource.HANDLE,
),
ActionInputHandle(
key="capacity",
data_type="capacity",
label="克容量",
data_key="capacity",
data_source=DataSource.HANDLE,
),
ActionInputHandle(
key="battery_system",
data_type="battery_system",
label="电池体系",
data_key="battery_system",
data_source=DataSource.HANDLE,
),
# transfer使用
ActionOutputHandle(
key="target_device",
data_type="device_id",
label="目标设备",
data_key="target_device",
data_source=DataSource.EXECUTOR,
),
ActionOutputHandle(
key="resource",
data_type="resource",
label="待转移资源",
data_key="resource.@flatten",
data_source=DataSource.EXECUTOR,
),
ActionOutputHandle(
key="mount_resource",
data_type="resource",
label="目标孔位",
data_key="mount_resource.@flatten",
data_source=DataSource.EXECUTOR,
),
# test使用
ActionOutputHandle(
key="collector_mass",
data_type="collector_mass",
label="极流体质量",
data_key="collector_mass",
data_source=DataSource.EXECUTOR,
),
ActionOutputHandle(
key="active_material",
data_type="active_material",
label="活性物质含量",
data_key="active_material",
data_source=DataSource.EXECUTOR,
),
ActionOutputHandle(
key="capacity",
data_type="capacity",
label="克容量",
data_key="capacity",
data_source=DataSource.EXECUTOR,
),
ActionOutputHandle(
key="battery_system",
data_type="battery_system",
label="电池体系",
data_key="battery_system",
data_source=DataSource.EXECUTOR,
),
],
)
def manual_confirm(
self,
resource: List[ResourceSlot],
target_device: DeviceSlot,
mount_resource: List[ResourceSlot],
collector_mass: List[float],
active_material: List[float],
capacity: List[float],
battery_system: List[str],
timeout_seconds: int,
assignee_user_ids: list[str],
**kwargs,
) -> dict:
"""
人工确认资源转移和扣电测试参数。
Args:
resource[待转移资源]: 需要人工确认的资源列表。
target_device[目标设备]: 资源要转移到的目标设备 ID。
mount_resource[目标孔位]: 资源要挂载到的目标孔位列表。
collector_mass[极流体质量]: 每个样品对应的极流体质量。
active_material[活性物质含量]: 每个样品对应的活性物质含量。
capacity[克容量]: 每个样品对应的克容量,单位 mAh/g。
battery_system[电池体系]: 每个样品对应的电池体系名称。
timeout_seconds[超时时间]: 人工确认超时时间,单位秒。
assignee_user_ids[确认人]: 指定处理人工确认任务的用户 ID 列表。
Note:
修改的结果无效,是只读的。
"""
resource_tree = ResourceTreeSet.from_plr_resources(cast(Any, resource)).dump()
mount_resource_tree = ResourceTreeSet.from_plr_resources(cast(Any, mount_resource)).dump()
kwargs.update(locals())
kwargs.pop("kwargs")
kwargs.pop("self")
kwargs["resource"] = resource_tree
kwargs["mount_resource"] = mount_resource_tree
kwargs.pop("resource_tree")
kwargs.pop("mount_resource_tree")
return kwargs
@action(
description="转移物料",
handles=[
ActionInputHandle(
key="target_device",
data_type="device_id",
label="目标设备",
data_key="target_device",
data_source=DataSource.HANDLE,
),
ActionInputHandle(
key="resource",
data_type="resource",
label="待转移资源",
data_key="resource",
data_source=DataSource.HANDLE,
),
ActionInputHandle(
key="mount_resource",
data_type="resource",
label="目标孔位",
data_key="mount_resource",
data_source=DataSource.HANDLE,
),
],
)
async def transfer(
self,
resource: List[ResourceSlot],
target_device: DeviceSlot,
mount_resource: List[ResourceSlot],
):
"""
转移资源到目标设备。
Args:
resource[待转移资源]: 待转移的资源列表。
target_device[目标设备]: 接收资源的目标设备 ID。
mount_resource[目标孔位]: 目标设备上的挂载孔位列表。
"""
future = ROS2DeviceNode.run_async_func(
self._ros_node.transfer_resource_to_another,
True,
**{
"plr_resources": resource,
"target_device_id": target_device,
"target_resources": mount_resource,
"sites": [None] * len(mount_resource),
},
)
result = await future
return result
@action(
description="扣电测试启动",
handles=[
ActionInputHandle(
key="resource",
data_type="resource",
label="待转移资源",
data_key="resource",
data_source=DataSource.HANDLE,
),
ActionInputHandle(
key="mount_resource",
data_type="resource",
label="目标孔位",
data_key="mount_resource",
data_source=DataSource.HANDLE,
),
ActionInputHandle(
key="collector_mass",
data_type="collector_mass",
label="极流体质量",
data_key="collector_mass",
data_source=DataSource.HANDLE,
),
ActionInputHandle(
key="active_material",
data_type="active_material",
label="活性物质含量",
data_key="active_material",
data_source=DataSource.HANDLE,
),
ActionInputHandle(
key="capacity",
data_type="capacity",
label="克容量",
data_key="capacity",
data_source=DataSource.HANDLE,
),
ActionInputHandle(
key="battery_system",
data_type="battery_system",
label="电池体系",
data_key="battery_system",
data_source=DataSource.HANDLE,
),
],
)
async def test(
self,
resource: List[ResourceSlot],
mount_resource: List[ResourceSlot],
collector_mass: List[float],
active_material: List[float],
capacity: List[float],
battery_system: list[str],
):
"""
启动扣电测试。
Args:
resource[待测试资源]: 需要进行扣电测试的资源列表。
mount_resource[测试孔位]: 扣电测试使用的目标孔位列表。
collector_mass[极流体质量]: 每个样品对应的极流体质量。
active_material[活性物质含量]: 每个样品对应的活性物质含量。
capacity[克容量]: 每个样品对应的克容量,单位 mAh/g。
battery_system[电池体系]: 每个样品对应的电池体系名称。
"""
print(resource)
print(mount_resource)
print(collector_mass)
print(active_material)
print(capacity)
print(battery_system)
@action( @action(
auto_prefix=True, auto_prefix=True,
description="批量准备物料 - 虚拟起始节点, 生成A1-A5物料, 输出5个handle供后续节点使用", description="批量准备物料 - 虚拟起始节点, 生成A1-A5物料, 输出5个handle供后续节点使用",
handles=[ handles=[
ActionOutputHandle(key="channel_1", data_type="workbench_material", ActionOutputHandle(key="channel_1", data_type="workbench_material", label="实验1", data_key="material_1", data_source=DataSource.EXECUTOR), # noqa: E501
label="实验1", data_key="material_1", data_source=DataSource.EXECUTOR), ActionOutputHandle(key="channel_2", data_type="workbench_material", label="实验2", data_key="material_2", data_source=DataSource.EXECUTOR), # noqa: E501
ActionOutputHandle(key="channel_2", data_type="workbench_material", ActionOutputHandle(key="channel_3", data_type="workbench_material", label="实验3", data_key="material_3", data_source=DataSource.EXECUTOR), # noqa: E501
label="实验2", data_key="material_2", data_source=DataSource.EXECUTOR), ActionOutputHandle(key="channel_4", data_type="workbench_material", label="实验4", data_key="material_4", data_source=DataSource.EXECUTOR), # noqa: E501
ActionOutputHandle(key="channel_3", data_type="workbench_material", ActionOutputHandle(key="channel_5", data_type="workbench_material", label="实验5", data_key="material_5", data_source=DataSource.EXECUTOR), # noqa: E501
label="实验3", data_key="material_3", data_source=DataSource.EXECUTOR),
ActionOutputHandle(key="channel_4", data_type="workbench_material",
label="实验4", data_key="material_4", data_source=DataSource.EXECUTOR),
ActionOutputHandle(key="channel_5", data_type="workbench_material",
label="实验5", data_key="material_5", data_source=DataSource.EXECUTOR),
], ],
) )
def prepare_materials( def prepare_materials(
@@ -316,6 +617,9 @@ class VirtualWorkbench:
作为工作流的起始节点, 生成指定数量的物料编号供后续节点使用。 作为工作流的起始节点, 生成指定数量的物料编号供后续节点使用。
输出5个handle (material_1 ~ material_5), 分别对应实验1~5。 输出5个handle (material_1 ~ material_5), 分别对应实验1~5。
Args:
count[物料数量]: 要生成的物料数量,默认生成 5 个。
""" """
materials = [i for i in range(1, count + 1)] materials = [i for i in range(1, count + 1)]
@@ -336,7 +640,11 @@ class VirtualWorkbench:
LabSample( LabSample(
sample_uuid=sample_uuid, sample_uuid=sample_uuid,
oss_path="", oss_path="",
extra={"material_uuid": content} if isinstance(content, str) else (content.serialize() if content else {}), extra=(
{"material_uuid": content}
if isinstance(content, str)
else (content.serialize() if content else {})
),
) )
for sample_uuid, content in sample_uuids.items() for sample_uuid, content in sample_uuids.items()
], ],
@@ -346,12 +654,27 @@ class VirtualWorkbench:
auto_prefix=True, auto_prefix=True,
description="将物料从An位置移动到空闲加热台, 返回分配的加热台ID", description="将物料从An位置移动到空闲加热台, 返回分配的加热台ID",
handles=[ handles=[
ActionInputHandle(key="material_input", data_type="workbench_material", ActionInputHandle(
label="物料编号", data_key="material_number", data_source=DataSource.HANDLE), key="material_input",
ActionOutputHandle(key="heating_station_output", data_type="workbench_station", data_type="workbench_material",
label="加热台ID", data_key="station_id", data_source=DataSource.EXECUTOR), label="物料编号",
ActionOutputHandle(key="material_number_output", data_type="workbench_material", data_key="material_number",
label="物料编号", data_key="material_number", data_source=DataSource.EXECUTOR), data_source=DataSource.HANDLE,
),
ActionOutputHandle(
key="heating_station_output",
data_type="workbench_station",
label="加热台ID",
data_key="station_id",
data_source=DataSource.EXECUTOR,
),
ActionOutputHandle(
key="material_number_output",
data_type="workbench_material",
label="物料编号",
data_key="material_number",
data_source=DataSource.EXECUTOR,
),
], ],
) )
def move_to_heating_station( def move_to_heating_station(
@@ -363,6 +686,9 @@ class VirtualWorkbench:
将物料从An位置移动到加热台 将物料从An位置移动到加热台
多线程并发调用时, 会竞争机械臂使用权, 并自动查找空闲加热台 多线程并发调用时, 会竞争机械臂使用权, 并自动查找空闲加热台
Args:
material_number[物料编号]: 要移动的物料编号,对应 A1、A2 等起始位置。
""" """
material_id = f"A{material_number}" material_id = f"A{material_number}"
task_desc = f"移动{material_id}到加热台" task_desc = f"移动{material_id}到加热台"
@@ -425,7 +751,8 @@ class VirtualWorkbench:
oss_path="", oss_path="",
extra=( extra=(
{"material_uuid": content} {"material_uuid": content}
if isinstance(content, str) else (content.serialize() if content else {}) if isinstance(content, str)
else (content.serialize() if content else {})
), ),
) )
for sample_uuid, content in sample_uuids.items() for sample_uuid, content in sample_uuids.items()
@@ -448,7 +775,8 @@ class VirtualWorkbench:
oss_path="", oss_path="",
extra=( extra=(
{"material_uuid": content} {"material_uuid": content}
if isinstance(content, str) else (content.serialize() if content else {}) if isinstance(content, str)
else (content.serialize() if content else {})
), ),
) )
for sample_uuid, content in sample_uuids.items() for sample_uuid, content in sample_uuids.items()
@@ -460,14 +788,34 @@ class VirtualWorkbench:
always_free=True, always_free=True,
description="启动指定加热台的加热程序", description="启动指定加热台的加热程序",
handles=[ handles=[
ActionInputHandle(key="station_id_input", data_type="workbench_station", ActionInputHandle(
label="加热台ID", data_key="station_id", data_source=DataSource.HANDLE), key="station_id_input",
ActionInputHandle(key="material_number_input", data_type="workbench_material", data_type="workbench_station",
label="物料编号", data_key="material_number", data_source=DataSource.HANDLE), label="加热台ID",
ActionOutputHandle(key="heating_done_station", data_type="workbench_station", data_key="station_id",
label="加热完成-加热台ID", data_key="station_id", data_source=DataSource.EXECUTOR), data_source=DataSource.HANDLE,
ActionOutputHandle(key="heating_done_material", data_type="workbench_material", ),
label="加热完成-物料编号", data_key="material_number", data_source=DataSource.EXECUTOR), ActionInputHandle(
key="material_number_input",
data_type="workbench_material",
label="物料编号",
data_key="material_number",
data_source=DataSource.HANDLE,
),
ActionOutputHandle(
key="heating_done_station",
data_type="workbench_station",
label="加热完成-加热台ID",
data_key="station_id",
data_source=DataSource.EXECUTOR,
),
ActionOutputHandle(
key="heating_done_material",
data_type="workbench_material",
label="加热完成-物料编号",
data_key="material_number",
data_source=DataSource.EXECUTOR,
),
], ],
) )
def start_heating( def start_heating(
@@ -478,6 +826,10 @@ class VirtualWorkbench:
) -> StartHeatingResult: ) -> StartHeatingResult:
""" """
启动指定加热台的加热程序 启动指定加热台的加热程序
Args:
station_id[加热台ID]: 要启动加热的加热台编号。
material_number[物料编号]: 当前加热台上的物料编号。
""" """
self.logger.info(f"[加热台{station_id}] 开始加热") self.logger.info(f"[加热台{station_id}] 开始加热")
@@ -494,7 +846,8 @@ class VirtualWorkbench:
oss_path="", oss_path="",
extra=( extra=(
{"material_uuid": content} {"material_uuid": content}
if isinstance(content, str) else (content.serialize() if content else {}) if isinstance(content, str)
else (content.serialize() if content else {})
), ),
) )
for sample_uuid, content in sample_uuids.items() for sample_uuid, content in sample_uuids.items()
@@ -517,7 +870,8 @@ class VirtualWorkbench:
oss_path="", oss_path="",
extra=( extra=(
{"material_uuid": content} {"material_uuid": content}
if isinstance(content, str) else (content.serialize() if content else {}) if isinstance(content, str)
else (content.serialize() if content else {})
), ),
) )
for sample_uuid, content in sample_uuids.items() for sample_uuid, content in sample_uuids.items()
@@ -537,7 +891,8 @@ class VirtualWorkbench:
oss_path="", oss_path="",
extra=( extra=(
{"material_uuid": content} {"material_uuid": content}
if isinstance(content, str) else (content.serialize() if content else {}) if isinstance(content, str)
else (content.serialize() if content else {})
), ),
) )
for sample_uuid, content in sample_uuids.items() for sample_uuid, content in sample_uuids.items()
@@ -577,7 +932,9 @@ class VirtualWorkbench:
self._update_data_status(f"加热台{station_id}加热中: {progress:.1f}%") self._update_data_status(f"加热台{station_id}加热中: {progress:.1f}%")
if time.time() - last_countdown_log >= 5.0: if time.time() - last_countdown_log >= 5.0:
self.logger.info(f"[加热台{station_id}] {material_id} 剩余 {remaining:.1f}s") self.logger.info(
f"[加热台{station_id}] {material_id} 剩余 {remaining:.1f}s"
)
last_countdown_log = time.time() last_countdown_log = time.time()
if elapsed >= self.HEATING_TIME: if elapsed >= self.HEATING_TIME:
@@ -594,7 +951,9 @@ class VirtualWorkbench:
self._active_tasks[material_id]["status"] = "heating_completed" self._active_tasks[material_id]["status"] = "heating_completed"
self._update_data_status(f"加热台{station_id}加热完成") self._update_data_status(f"加热台{station_id}加热完成")
self.logger.info(f"[加热台{station_id}] {material_id}加热完成 (用时{self.HEATING_TIME}s)") self.logger.info(
f"[加热台{station_id}] {material_id}加热完成 (用时{self.HEATING_TIME}s)"
)
return { return {
"success": True, "success": True,
@@ -608,7 +967,8 @@ class VirtualWorkbench:
oss_path="", oss_path="",
extra=( extra=(
{"material_uuid": content} {"material_uuid": content}
if isinstance(content, str) else (content.serialize() if content else {}) if isinstance(content, str)
else (content.serialize() if content else {})
), ),
) )
for sample_uuid, content in sample_uuids.items() for sample_uuid, content in sample_uuids.items()
@@ -619,10 +979,20 @@ class VirtualWorkbench:
auto_prefix=True, auto_prefix=True,
description="将物料从加热台移动到输出位置Cn", description="将物料从加热台移动到输出位置Cn",
handles=[ handles=[
ActionInputHandle(key="output_station_input", data_type="workbench_station", ActionInputHandle(
label="加热台ID", data_key="station_id", data_source=DataSource.HANDLE), key="output_station_input",
ActionInputHandle(key="output_material_input", data_type="workbench_material", data_type="workbench_station",
label="物料编号", data_key="material_number", data_source=DataSource.HANDLE), label="加热台ID",
data_key="station_id",
data_source=DataSource.HANDLE,
),
ActionInputHandle(
key="output_material_input",
data_type="workbench_material",
label="物料编号",
data_key="material_number",
data_source=DataSource.HANDLE,
),
], ],
) )
def move_to_output( def move_to_output(
@@ -633,6 +1003,10 @@ class VirtualWorkbench:
) -> MoveToOutputResult: ) -> MoveToOutputResult:
""" """
将物料从加热台移动到输出位置Cn 将物料从加热台移动到输出位置Cn
Args:
station_id[加热台ID]: 已完成加热的加热台编号。
material_number[物料编号]: 要移动到输出位置的物料编号,对应 Cn。
""" """
output_number = material_number output_number = material_number
@@ -649,7 +1023,8 @@ class VirtualWorkbench:
oss_path="", oss_path="",
extra=( extra=(
{"material_uuid": content} {"material_uuid": content}
if isinstance(content, str) else (content.serialize() if content else {}) if isinstance(content, str)
else (content.serialize() if content else {})
), ),
) )
for sample_uuid, content in sample_uuids.items() for sample_uuid, content in sample_uuids.items()
@@ -673,7 +1048,8 @@ class VirtualWorkbench:
oss_path="", oss_path="",
extra=( extra=(
{"material_uuid": content} {"material_uuid": content}
if isinstance(content, str) else (content.serialize() if content else {}) if isinstance(content, str)
else (content.serialize() if content else {})
), ),
) )
for sample_uuid, content in sample_uuids.items() for sample_uuid, content in sample_uuids.items()
@@ -693,7 +1069,8 @@ class VirtualWorkbench:
oss_path="", oss_path="",
extra=( extra=(
{"material_uuid": content} {"material_uuid": content}
if isinstance(content, str) else (content.serialize() if content else {}) if isinstance(content, str)
else (content.serialize() if content else {})
), ),
) )
for sample_uuid, content in sample_uuids.items() for sample_uuid, content in sample_uuids.items()
@@ -775,7 +1152,8 @@ class VirtualWorkbench:
oss_path="", oss_path="",
extra=( extra=(
{"material_uuid": content} {"material_uuid": content}
if isinstance(content, str) else (content.serialize() if content else {}) if isinstance(content, str)
else (content.serialize() if content else {})
), ),
) )
for sample_uuid, content in sample_uuids.items() for sample_uuid, content in sample_uuids.items()

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

@@ -32,7 +32,7 @@ from typing import Any, Dict, List, Optional, Tuple, Union
MAX_SCAN_DEPTH = 10 # 最大目录递归深度 MAX_SCAN_DEPTH = 10 # 最大目录递归深度
MAX_SCAN_FILES = 1000 # 最大扫描文件数量 MAX_SCAN_FILES = 1000 # 最大扫描文件数量
_CACHE_VERSION = 1 # 缓存格式版本号,格式变更时递增 _CACHE_VERSION = 2 # 缓存格式版本号,格式变更时递增
# 合法的装饰器来源模块 # 合法的装饰器来源模块
_REGISTRY_DECORATOR_MODULE = "unilabos.registry.decorators" _REGISTRY_DECORATOR_MODULE = "unilabos.registry.decorators"
@@ -258,8 +258,6 @@ def scan_directory(
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# File-level parsing # File-level parsing
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -361,6 +359,7 @@ def _parse_file(
"actions": class_body.get("actions", {}), "actions": class_body.get("actions", {}),
"status_properties": class_body.get("status_properties", {}), "status_properties": class_body.get("status_properties", {}),
"init_params": class_body.get("init_params", []), "init_params": class_body.get("init_params", []),
"init_docstring": class_body.get("init_docstring"),
"auto_methods": class_body.get("auto_methods", {}), "auto_methods": class_body.get("auto_methods", {}),
"import_map": import_map, "import_map": import_map,
} }
@@ -497,7 +496,6 @@ def _collect_imports(tree: ast.Module, module_path: str = "") -> Dict[str, str]:
return import_map return import_map
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Decorator finding & argument extraction # Decorator finding & argument extraction
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -768,6 +766,7 @@ def _extract_class_body(
"actions": {}, # method_name -> action_info "actions": {}, # method_name -> action_info
"status_properties": {}, # prop_name -> status_info "status_properties": {}, # prop_name -> status_info
"init_params": [], # [{"name": ..., "type": ..., "default": ...}, ...] "init_params": [], # [{"name": ..., "type": ..., "default": ...}, ...]
"init_docstring": None,
"auto_methods": {}, # method_name -> method_info (no @action decorator) "auto_methods": {}, # method_name -> method_info (no @action decorator)
} }
@@ -780,6 +779,7 @@ def _extract_class_body(
# --- __init__ --- # --- __init__ ---
if method_name == "__init__": if method_name == "__init__":
result["init_params"] = _extract_method_params(item, import_map) result["init_params"] = _extract_method_params(item, import_map)
result["init_docstring"] = ast.get_docstring(item)
continue continue
# --- Skip private/dunder --- # --- Skip private/dunder ---
@@ -825,6 +825,7 @@ def _extract_class_body(
action_args.setdefault("placeholder_keys", {}) action_args.setdefault("placeholder_keys", {})
action_args.setdefault("always_free", False) action_args.setdefault("always_free", False)
action_args.setdefault("is_protocol", False) action_args.setdefault("is_protocol", False)
action_args.setdefault("feedback_interval", 1.0)
action_args.setdefault("description", "") action_args.setdefault("description", "")
action_args.setdefault("auto_prefix", False) action_args.setdefault("auto_prefix", False)
action_args.setdefault("parent", False) action_args.setdefault("parent", False)

View File

@@ -343,6 +343,7 @@ def action(
auto_prefix: bool = False, auto_prefix: bool = False,
parent: bool = False, parent: bool = False,
node_type: Optional["NodeType"] = None, node_type: Optional["NodeType"] = None,
feedback_interval: Optional[float] = None,
): ):
""" """
动作方法装饰器 动作方法装饰器
@@ -378,9 +379,16 @@ def action(
""" """
def decorator(func: F) -> F: def decorator(func: F) -> F:
@wraps(func) import asyncio as _asyncio
def wrapper(*args, **kwargs):
return func(*args, **kwargs) if _asyncio.iscoroutinefunction(func):
@wraps(func)
async def wrapper(*args, **kwargs):
return await func(*args, **kwargs)
else:
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
# action_type 为哨兵值 => 用户没传, 视为 None (UniLabJsonCommand) # action_type 为哨兵值 => 用户没传, 视为 None (UniLabJsonCommand)
resolved_type = None if action_type is _ACTION_TYPE_UNSET else action_type resolved_type = None if action_type is _ACTION_TYPE_UNSET else action_type
@@ -399,6 +407,8 @@ def action(
"auto_prefix": auto_prefix, "auto_prefix": auto_prefix,
"parent": parent, "parent": parent,
} }
if feedback_interval is not None:
meta["feedback_interval"] = feedback_interval
if node_type is not None: if node_type is not None:
meta["node_type"] = node_type.value if isinstance(node_type, NodeType) else str(node_type) meta["node_type"] = node_type.value if isinstance(node_type, NodeType) else str(node_type)
wrapper._action_registry_meta = meta # type: ignore[attr-defined] wrapper._action_registry_meta = meta # type: ignore[attr-defined]

View File

@@ -51,14 +51,18 @@ Qone_nmr:
properties: properties:
check_interval: check_interval:
default: 60 default: 60
description: 检查间隔时间默认60秒
type: string type: string
expected_count: expected_count:
default: 1 default: 1
description: 期望生成的.nmr文件数量默认1个
type: string type: string
monitor_dir: monitor_dir:
description: 要监督的目录路径如果未指定则使用self.monitor_directory
type: string type: string
stability_checks: stability_checks:
default: 3 default: 3
description: 文件大小稳定性检查次数默认3次
type: string type: string
required: [] required: []
type: object type: object
@@ -85,11 +89,14 @@ Qone_nmr:
goal: goal:
properties: properties:
output_dir: output_dir:
description: 输出目录如果未指定使用self.output_directory
type: string type: string
string_list: string_list:
description: 字符串列表
type: string type: string
txt_encoding: txt_encoding:
default: utf-8 default: utf-8
description: 文件编码
type: string type: string
required: required:
- string_list - string_list
@@ -151,6 +158,13 @@ Qone_nmr:
additionalProperties: false additionalProperties: false
properties: properties:
string: string:
description: '包含多个字符串的输入数据,支持两种格式:
1. 逗号分隔:如 "A 1 B 2 C 3, X 10 Y 20 Z 30"
2. 换行分隔:如 "A 1 B 2 C 3
X 10 Y 20 Z 30"'
type: string type: string
title: StrSingleInput_Goal title: StrSingleInput_Goal
type: object type: object

View File

@@ -491,14 +491,17 @@ bioyond_cell:
goal: goal:
properties: properties:
material_names: material_names:
description: 物料名称列表;默认使用 [LiPF6, LiDFOB, DTD, LiFSI, LiPO2F2]
items: items:
type: string type: string
type: array type: array
type_id: type_id:
default: 3a190ca0-b2f6-9aeb-8067-547e72c11469 default: 3a190ca0-b2f6-9aeb-8067-547e72c11469
description: 物料类型ID
type: string type: string
warehouse_name: warehouse_name:
default: 粉末加样头堆栈 default: 粉末加样头堆栈
description: 目标仓库名(用于取位置信息)
type: string type: string
required: [] required: []
type: object type: object
@@ -527,12 +530,16 @@ bioyond_cell:
goal: goal:
properties: properties:
location_name_or_id: location_name_or_id:
description: 具体库位名称(如 A01或库位 UUID由用户指定。
type: string type: string
material_name: material_name:
description: 物料名称(会优先匹配配置模板)。
type: string type: string
type_id: type_id:
description: 物料类型 ID若为空则尝试从配置推断
type: string type: string
warehouse_name: warehouse_name:
description: 需要入库的仓库名称;若为空则仅创建不入库。
type: string type: string
required: required:
- material_name - material_name
@@ -661,15 +668,20 @@ bioyond_cell:
goal: goal:
properties: properties:
board_type: board_type:
description: 板类型,如 "5ml分液瓶板"、"配液瓶(小)板"
type: string type: string
bottle_type: bottle_type:
description: 瓶类型,如 "5ml分液瓶"、"配液瓶(小)"
type: string type: string
location_code: location_code:
description: 库位编号,例如 "A01"
type: string type: string
name: name:
description: 物料名称
type: string type: string
warehouse_name: warehouse_name:
default: 手动堆栈 default: 手动堆栈
description: 仓库名称,默认为 "手动堆栈",支持 "自动堆栈-左"、"自动堆栈-右" 等
type: string type: string
required: required:
- name - name
@@ -1956,19 +1968,19 @@ bioyond_cell:
properties: properties:
source_wh_id: source_wh_id:
default: 3a19debc-84b4-0359-e2d4-b3beea49348b default: 3a19debc-84b4-0359-e2d4-b3beea49348b
description: 来源仓库ID description: 来源仓库 Id (默认为3号仓库)
type: string type: string
source_x: source_x:
default: 1 default: 1
description: 来源位置X坐标 description: 来源位置 X 坐标
type: integer type: integer
source_y: source_y:
default: 1 default: 1
description: 来源位置Y坐标 description: 来源位置 Y 坐标
type: integer type: integer
source_z: source_z:
default: 1 default: 1
description: 来源位置Z坐标 description: 来源位置 Z 坐标
type: integer type: integer
required: [] required: []
type: object type: object
@@ -2061,9 +2073,11 @@ bioyond_cell:
goal: goal:
properties: properties:
order_code: order_code:
description: 任务编号
type: string type: string
timeout: timeout:
default: 36000 default: 36000
description: 超时时间(秒)
type: integer type: integer
required: required:
- order_code - order_code
@@ -2092,12 +2106,15 @@ bioyond_cell:
goal: goal:
properties: properties:
order_code: order_code:
description: 任务编号
type: string type: string
poll_interval: poll_interval:
default: 0.5 default: 0.5
description: 轮询间隔(秒),默认 0.5 秒
type: number type: number
timeout: timeout:
default: 36000 default: 36000
description: 超时时间(秒)
type: integer type: integer
required: required:
- order_code - order_code
@@ -2154,10 +2171,15 @@ bioyond_cell:
config: config:
properties: properties:
bioyond_config: bioyond_config:
description: '从 JSON 文件加载的 bioyond 配置字典
包含 api_host, api_key, HTTP_host, HTTP_port 等配置'
type: object type: object
deck: deck:
description: Deck 配置(可选,会从 JSON 中自动处理)
type: string type: string
protocol_type: protocol_type:
description: 协议类型(可选)
type: string type: string
required: [] required: []
type: object type: object

View File

@@ -47,8 +47,10 @@ bioyond_dispensing_station:
goal: goal:
properties: properties:
report_request: report_request:
description: WorkstationReportRequest 对象,包含任务完成信息
type: string type: string
used_materials: used_materials:
description: 物料使用记录列表
type: string type: string
required: required:
- report_request - report_request
@@ -102,6 +104,7 @@ bioyond_dispensing_station:
goal: goal:
properties: properties:
material_name: material_name:
description: 物料名称
type: string type: string
required: required:
- material_name - material_name
@@ -611,10 +614,10 @@ bioyond_dispensing_station:
goal: goal:
properties: properties:
target_device_id: target_device_id:
description: 目标反应站设备ID(从设备列表中选择,所有转移组使用同一个目标设备 description: 目标反应站设备ID(所有转移组使用同一个设备)
type: string type: string
transfer_groups: transfer_groups:
description: 转移任务组列表每组包含物料名称、目标堆栈和目标库位,可以添加多组 description: '转移任务组列表,每组包含:'
type: array type: array
required: required:
- target_device_id - target_device_id
@@ -694,10 +697,13 @@ bioyond_dispensing_station:
config: config:
properties: properties:
config: config:
description: 配置字典,应包含material_type_mappings等配置
type: object type: object
deck: deck:
description: Deck对象
type: string type: string
protocol_type: protocol_type:
description: 协议类型(由ROS系统传递,此处忽略)
type: string type: string
required: [] required: []
type: object type: object

View File

@@ -150,15 +150,15 @@ coincellassemblyworkstation_device:
properties: properties:
assembly_pressure: assembly_pressure:
default: 4200 default: 4200
description: 电池压制力(N) description: 电池压制力 (N)
type: integer type: integer
assembly_type: assembly_type:
default: 7 default: 7
description: 组装类型(7=不用铝箔垫, 8=使用铝箔垫) description: 组装类型 (7=不用铝箔垫, 8=使用铝箔垫)
type: integer type: integer
battery_clean_ignore: battery_clean_ignore:
default: false default: false
description: 是否忽略电池清洁步骤 description: 是否忽略电池清洁
type: boolean type: boolean
battery_pressure_mode: battery_pressure_mode:
default: true default: true
@@ -166,29 +166,29 @@ coincellassemblyworkstation_device:
type: boolean type: boolean
dual_drop_first_volume: dual_drop_first_volume:
default: 25 default: 25
description: 二次滴液第一次排液体积(μL) description: 二次滴液第一次排液体积 (μL)
type: integer type: integer
dual_drop_mode: dual_drop_mode:
default: false default: false
description: 电解液添加模式(false=单次滴液, true=二次滴液) description: 电解液添加模式 (False=单次滴液, True=二次滴液)
type: boolean type: boolean
dual_drop_start_timing: dual_drop_start_timing:
default: false default: false
description: 二次滴液开始滴液时机(false=正极片前, true=正极片后) description: 二次滴液开始滴液时机 (False=正极片前, True=正极片后)
type: boolean type: boolean
dual_drop_suction_timing: dual_drop_suction_timing:
default: false default: false
description: 二次滴液吸液时机(false=正常吸液, true=先吸液) description: 二次滴液吸液时机 (False=正常吸液, True=先吸液)
type: boolean type: boolean
elec_num: elec_num:
description: 电解液瓶数 description: 电解液瓶数
type: string type: string
elec_use_num: elec_use_num:
description: 每瓶电解液组装电池数 description: 每瓶电解液组装电池数
type: string type: string
elec_vol: elec_vol:
default: 50 default: 50
description: 电解液吸液量(μL) description: 电解液吸液量 (μL)
type: integer type: integer
file_path: file_path:
default: /Users/sml/work default: /Users/sml/work
@@ -196,7 +196,7 @@ coincellassemblyworkstation_device:
type: string type: string
fujipian_juzhendianwei: fujipian_juzhendianwei:
default: 0 default: 0
description: 负极片矩阵点位。盘位置从1开始计数有效范围1-8, 13-20 (写入值比实际位置少1例如写0取盘位1写1取盘位2) description: 负极片矩阵点位
type: integer type: integer
fujipian_panshu: fujipian_panshu:
default: 0 default: 0
@@ -204,7 +204,7 @@ coincellassemblyworkstation_device:
type: integer type: integer
gemo_juzhendianwei: gemo_juzhendianwei:
default: 0 default: 0
description: 隔膜矩阵点位。盘位置从1开始计数有效范围1-8, 13-20 (写入值比实际位置少1例如写0取盘位1写1取盘位2) description: 隔膜矩阵点位
type: integer type: integer
gemopanshu: gemopanshu:
default: 0 default: 0
@@ -216,7 +216,7 @@ coincellassemblyworkstation_device:
type: boolean type: boolean
qiangtou_juzhendianwei: qiangtou_juzhendianwei:
default: 0 default: 0
description: 枪头盒矩阵点位。盘位置从1开始计数有效范围1-32, 64-96 (写入值比实际位置少1例如写0取盘位1写1取盘位2) description: 枪头盒矩阵点位
type: integer type: integer
required: required:
- elec_num - elec_num
@@ -308,7 +308,13 @@ coincellassemblyworkstation_device:
properties: properties:
material_search_enable: material_search_enable:
default: false default: false
description: 是否启用物料搜寻功能。设备初始化后会弹出物料搜寻确认弹窗,此参数控制自动点击"是"(启用)或"否"(不启用)。默认为false(不启用物料搜寻) description: '是否启用物料搜寻功能。
设备初始化后会弹出物料搜寻确认弹窗,
此参数控制自动点击''是''(启用)或''否''(不启用)。
默认为False不启用物料搜寻。'
type: boolean type: boolean
required: [] required: []
type: object type: object
@@ -547,15 +553,15 @@ coincellassemblyworkstation_device:
properties: properties:
assembly_pressure: assembly_pressure:
default: 4200 default: 4200
description: 电池压制力(N) description: 电池压制力 (N)
type: integer type: integer
assembly_type: assembly_type:
default: 7 default: 7
description: 组装类型(7=不用铝箔垫, 8=使用铝箔垫) description: 组装类型 (7=不用铝箔垫, 8=使用铝箔垫)
type: integer type: integer
battery_clean_ignore: battery_clean_ignore:
default: false default: false
description: 是否忽略电池清洁步骤 description: 是否忽略电池清洁
type: boolean type: boolean
battery_pressure_mode: battery_pressure_mode:
default: true default: true
@@ -563,29 +569,29 @@ coincellassemblyworkstation_device:
type: boolean type: boolean
dual_drop_first_volume: dual_drop_first_volume:
default: 25 default: 25
description: 二次滴液第一次排液体积(μL) description: 二次滴液第一次排液体积 (μL)
type: integer type: integer
dual_drop_mode: dual_drop_mode:
default: false default: false
description: 电解液添加模式(false=单次滴液, true=二次滴液) description: 电解液添加模式 (False=单次滴液, True=二次滴液)
type: boolean type: boolean
dual_drop_start_timing: dual_drop_start_timing:
default: false default: false
description: 二次滴液开始滴液时机(false=正极片前, true=正极片后) description: 二次滴液开始滴液时机 (False=正极片前, True=正极片后)
type: boolean type: boolean
dual_drop_suction_timing: dual_drop_suction_timing:
default: false default: false
description: 二次滴液吸液时机(false=正常吸液, true=先吸液) description: 二次滴液吸液时机 (False=正常吸液, True=先吸液)
type: boolean type: boolean
elec_num: elec_num:
description: 电解液瓶数如果在workflow中已通过handles连接上游(create_orders的bottle_count输出),则此参数会自动从上游获取,无需手动填写;如果单独使用此函数(没有上游连接),则必须手动填写电解液瓶数 description: 电解液瓶数
type: string type: string
elec_use_num: elec_use_num:
description: 每瓶电解液组装电池数 description: 每瓶电解液组装电池数
type: string type: string
elec_vol: elec_vol:
default: 50 default: 50
description: 电解液吸液量(μL) description: 电解液吸液量 (μL)
type: integer type: integer
file_path: file_path:
default: /Users/sml/work default: /Users/sml/work
@@ -593,7 +599,7 @@ coincellassemblyworkstation_device:
type: string type: string
fujipian_juzhendianwei: fujipian_juzhendianwei:
default: 0 default: 0
description: 负极片矩阵点位。盘位置从1开始计数有效范围1-8, 13-20 (写入值比实际位置少1例如写0取盘位1写1取盘位2) description: 负极片矩阵点位
type: integer type: integer
fujipian_panshu: fujipian_panshu:
default: 0 default: 0
@@ -601,7 +607,7 @@ coincellassemblyworkstation_device:
type: integer type: integer
gemo_juzhendianwei: gemo_juzhendianwei:
default: 0 default: 0
description: 隔膜矩阵点位。盘位置从1开始计数有效范围1-8, 13-20 (写入值比实际位置少1例如写0取盘位1写1取盘位2) description: 隔膜矩阵点位
type: integer type: integer
gemopanshu: gemopanshu:
default: 0 default: 0
@@ -613,7 +619,7 @@ coincellassemblyworkstation_device:
type: boolean type: boolean
qiangtou_juzhendianwei: qiangtou_juzhendianwei:
default: 0 default: 0
description: 枪头盒矩阵点位。盘位置从1开始计数有效范围1-32, 64-96 (写入值比实际位置少1例如写0取盘位1写1取盘位2) description: 枪头盒矩阵点位
type: integer type: integer
required: required:
- elec_num - elec_num

View File

@@ -31,6 +31,6 @@ hotel.thermo_orbitor_rs2_hotel:
type: object type: object
model: model:
mesh: thermo_orbitor_rs2_hotel mesh: thermo_orbitor_rs2_hotel
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/thermo_orbitor_rs2_hotel/macro_device.xacro path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/thermo_orbitor_rs2_hotel/macro_device.xacro
type: device type: device
version: 1.0.0 version: 1.0.0

View File

@@ -18,6 +18,7 @@ xyz_stepper_controller:
goal: goal:
properties: properties:
degrees: degrees:
description: 角度值
type: number type: number
required: required:
- degrees - degrees
@@ -44,6 +45,7 @@ xyz_stepper_controller:
goal: goal:
properties: properties:
axis: axis:
description: 电机轴
type: object type: object
required: required:
- axis - axis
@@ -71,6 +73,7 @@ xyz_stepper_controller:
properties: properties:
enable: enable:
default: true default: true
description: True为使能False为失能
type: boolean type: boolean
required: [] required: []
type: object type: object
@@ -99,9 +102,11 @@ xyz_stepper_controller:
goal: goal:
properties: properties:
axis: axis:
description: 电机轴
type: object type: object
enable: enable:
default: true default: true
description: True为使能False为失能
type: boolean type: boolean
required: required:
- axis - axis
@@ -152,6 +157,7 @@ xyz_stepper_controller:
goal: goal:
properties: properties:
axis: axis:
description: 电机轴
type: object type: object
required: required:
- axis - axis
@@ -183,16 +189,21 @@ xyz_stepper_controller:
properties: properties:
acceleration: acceleration:
default: 1000 default: 1000
description: 加速度(rpm/s)
type: integer type: integer
axis: axis:
description: 电机轴
type: object type: object
position: position:
description: 目标位置(步数)
type: integer type: integer
precision: precision:
default: 100 default: 100
description: 到位精度
type: integer type: integer
speed: speed:
default: 5000 default: 5000
description: 运行速度(rpm)
type: integer type: integer
required: required:
- axis - axis
@@ -225,16 +236,21 @@ xyz_stepper_controller:
properties: properties:
acceleration: acceleration:
default: 1000 default: 1000
description: 加速度
type: integer type: integer
axis: axis:
description: 电机轴
type: object type: object
degrees: degrees:
description: 目标角度(度)
type: number type: number
precision: precision:
default: 100 default: 100
description: 精度
type: integer type: integer
speed: speed:
default: 5000 default: 5000
description: 移动速度
type: integer type: integer
required: required:
- axis - axis
@@ -267,16 +283,21 @@ xyz_stepper_controller:
properties: properties:
acceleration: acceleration:
default: 1000 default: 1000
description: 加速度
type: integer type: integer
axis: axis:
description: 电机轴
type: object type: object
precision: precision:
default: 100 default: 100
description: 精度
type: integer type: integer
revolutions: revolutions:
description: 目标圈数
type: number type: number
speed: speed:
default: 5000 default: 5000
description: 移动速度
type: integer type: integer
required: required:
- axis - axis
@@ -309,15 +330,20 @@ xyz_stepper_controller:
properties: properties:
acceleration: acceleration:
default: 1000 default: 1000
description: 加速度
type: integer type: integer
speed: speed:
default: 5000 default: 5000
description: 运行速度
type: integer type: integer
x: x:
description: X轴目标位置
type: integer type: integer
y: y:
description: Y轴目标位置
type: integer type: integer
z: z:
description: Z轴目标位置
type: integer type: integer
required: [] required: []
type: object type: object
@@ -350,15 +376,20 @@ xyz_stepper_controller:
properties: properties:
acceleration: acceleration:
default: 1000 default: 1000
description: 加速度
type: integer type: integer
speed: speed:
default: 5000 default: 5000
description: 移动速度
type: integer type: integer
x_deg: x_deg:
description: X轴目标角度
type: number type: number
y_deg: y_deg:
description: Y轴目标角度
type: number type: number
z_deg: z_deg:
description: Z轴目标角度
type: number type: number
required: [] required: []
type: object type: object
@@ -391,15 +422,20 @@ xyz_stepper_controller:
properties: properties:
acceleration: acceleration:
default: 1000 default: 1000
description: 加速度
type: integer type: integer
speed: speed:
default: 5000 default: 5000
description: 移动速度
type: integer type: integer
x_rev: x_rev:
description: X轴目标圈数
type: number type: number
y_rev: y_rev:
description: Y轴目标圈数
type: number type: number
z_rev: z_rev:
description: Z轴目标圈数
type: number type: number
required: [] required: []
type: object type: object
@@ -427,6 +463,7 @@ xyz_stepper_controller:
goal: goal:
properties: properties:
revolutions: revolutions:
description: 圈数
type: number type: number
required: required:
- revolutions - revolutions
@@ -456,10 +493,13 @@ xyz_stepper_controller:
properties: properties:
acceleration: acceleration:
default: 1000 default: 1000
description: 加速度(rpm/s)
type: integer type: integer
axis: axis:
description: 电机轴
type: object type: object
speed: speed:
description: 运行速度(rpm),正值正转,负值反转
type: integer type: integer
required: required:
- axis - axis
@@ -487,6 +527,7 @@ xyz_stepper_controller:
goal: goal:
properties: properties:
steps: steps:
description: 步数
type: integer type: integer
required: required:
- steps - steps
@@ -513,6 +554,7 @@ xyz_stepper_controller:
goal: goal:
properties: properties:
steps: steps:
description: 步数
type: integer type: integer
required: required:
- steps - steps
@@ -564,9 +606,11 @@ xyz_stepper_controller:
goal: goal:
properties: properties:
axis: axis:
description: 电机轴
type: object type: object
timeout: timeout:
default: 30.0 default: 30.0
description: 超时时间(秒)
type: number type: number
required: required:
- axis - axis
@@ -591,11 +635,14 @@ xyz_stepper_controller:
properties: properties:
baudrate: baudrate:
default: 115200 default: 115200
description: 波特率
type: integer type: integer
port: port:
description: 串口端口名
type: string type: string
timeout: timeout:
default: 1.0 default: 1.0
description: 通信超时时间
type: number type: number
required: required:
- port - port

File diff suppressed because it is too large Load Diff

View File

@@ -87,7 +87,7 @@ neware_battery_test_system:
properties: properties:
filepath: filepath:
default: bts_status.json default: bts_status.json
description: 输出JSON文件路径 description: 输出文件路径
type: string type: string
required: [] required: []
type: object type: object
@@ -146,7 +146,7 @@ neware_battery_test_system:
goal: goal:
properties: properties:
plate_num: plate_num:
description: 盘号 (1 或 2),如果为null则返回所有盘的状态 description: 盘号 (1 或 2),如果为None则返回所有盘的状态
type: integer type: integer
required: [] required: []
type: object type: object
@@ -237,11 +237,11 @@ neware_battery_test_system:
goal: goal:
properties: properties:
csv_path: csv_path:
description: 输入CSV文件的绝对路径 description: 输入CSV文件路径
type: string type: string
output_dir: output_dir:
default: . default: .
description: 输出目录用于存储XML和备份文件),默认当前目录 description: 输出目录用于存储XML文件和备份,默认当前目录
type: string type: string
required: required:
- csv_path - csv_path
@@ -302,14 +302,14 @@ neware_battery_test_system:
goal: goal:
properties: properties:
backup_dir: backup_dir:
description: 备份目录路径默认使用最近一次submit_from_csvbackup_dir description: 备份目录路径默认使用最近一次 submit_from_csvbackup_dir
type: string type: string
file_pattern: file_pattern:
default: '*' default: '*'
description: 文件通配符模式,例如 *.csv 或 Battery_*.nda description: 文件通配符模式,默认 "*" 上传所有文件(例如 "*.csv" 仅上传 CSV 文件)
type: string type: string
oss_prefix: oss_prefix:
description: OSS对象路径前缀默认使用self.oss_prefix description: OSS 对象前缀默认使用类初始化时的配置
type: string type: string
required: [] required: []
type: object type: object
@@ -336,19 +336,25 @@ neware_battery_test_system:
config: config:
properties: properties:
devtype: devtype:
description: 设备类型标识
type: string type: string
ip: ip:
description: TCP服务器IP地址
type: string type: string
machine_id: machine_id:
default: 1 default: 1
description: 机器ID
type: integer type: integer
oss_prefix: oss_prefix:
default: neware_backup default: neware_backup
description: OSS对象路径前缀默认"neware_backup"
type: string type: string
oss_upload_enabled: oss_upload_enabled:
default: false default: false
description: 是否启用OSS上传功能默认False
type: boolean type: boolean
port: port:
description: TCP端口
type: integer type: integer
size_x: size_x:
default: 50 default: 50
@@ -360,6 +366,7 @@ neware_battery_test_system:
default: 20 default: 20
type: number type: number
timeout: timeout:
description: 通信超时时间(秒)
type: integer type: integer
required: [] required: []
type: object type: object

View File

@@ -207,8 +207,12 @@ separator.homemade:
goal: goal:
properties: properties:
condition: condition:
description: The condition to be monitored, either 'delta' or 'time'.
type: string type: string
value: value:
description: 'The threshold value for the condition.
`delta > 0.05`, `time > 60`'
type: string type: string
required: required:
- condition - condition
@@ -305,12 +309,17 @@ separator.homemade:
event: event:
type: string type: string
settling_time: settling_time:
description: The duration for which to settle after stirring, in
seconds. Defaults to 10.
type: string type: string
stir_speed: stir_speed:
description: The speed of stirring, in RPM. Defaults to 300.
maximum: 1.7976931348623157e+308 maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308 minimum: -1.7976931348623157e+308
type: number type: number
stir_time: stir_time:
description: The duration for which to stir, in seconds. Defaults
to 10.
maximum: 1.7976931348623157e+308 maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308 minimum: -1.7976931348623157e+308
type: number type: number

View File

@@ -456,6 +456,7 @@ syringe_pump_with_valve.runze.SY03B-T06:
goal: goal:
properties: properties:
volume: volume:
description: 'absolute position of the plunger, unit: mL'
type: number type: number
required: required:
- volume - volume
@@ -481,6 +482,7 @@ syringe_pump_with_valve.runze.SY03B-T06:
goal: goal:
properties: properties:
volume: volume:
description: 'absolute position of the plunger, unit: mL'
type: number type: number
required: required:
- volume - volume
@@ -687,8 +689,10 @@ syringe_pump_with_valve.runze.SY03B-T06:
goal: goal:
properties: properties:
max_velocity: max_velocity:
description: 'maximum velocity of the plunger, unit: ml/s'
type: number type: number
position: position:
description: 'absolute position of the plunger, unit: ml'
type: number type: number
required: required:
- position - position
@@ -1003,6 +1007,7 @@ syringe_pump_with_valve.runze.SY03B-T08:
goal: goal:
properties: properties:
volume: volume:
description: 'absolute position of the plunger, unit: mL'
type: number type: number
required: required:
- volume - volume
@@ -1028,6 +1033,7 @@ syringe_pump_with_valve.runze.SY03B-T08:
goal: goal:
properties: properties:
volume: volume:
description: 'absolute position of the plunger, unit: mL'
type: number type: number
required: required:
- volume - volume
@@ -1234,8 +1240,10 @@ syringe_pump_with_valve.runze.SY03B-T08:
goal: goal:
properties: properties:
max_velocity: max_velocity:
description: 'maximum velocity of the plunger, unit: ml/s'
type: number type: number
position: position:
description: 'absolute position of the plunger, unit: ml'
type: number type: number
required: required:
- position - position

View File

@@ -32,7 +32,7 @@ reaction_station.bioyond:
type: integer type: integer
end_point: end_point:
default: 0 default: 0
description: 终点计时点 (Start=开始前, End=结束后) description: 终点计时点 (Start=0, End=1)
type: integer type: integer
end_step_key: end_step_key:
default: '' default: ''
@@ -40,11 +40,11 @@ reaction_station.bioyond:
type: string type: string
start_point: start_point:
default: 0 default: 0
description: 起点计时点 (Start=开始前, End=结束后) description: 起点计时点 (Start=0, End=1)
type: integer type: integer
start_step_key: start_step_key:
default: '' default: ''
description: 起点步骤Key (例如 "feeding", "liquid", 可选, 默认为空则自动选择) description: 起点步骤Key (可选, 默认为空则自动选择)
type: string type: string
required: required:
- duration - duration
@@ -91,6 +91,7 @@ reaction_station.bioyond:
goal: goal:
properties: properties:
json_str: json_str:
description: 订单参数的JSON字符串
type: string type: string
required: required:
- json_str - json_str
@@ -117,6 +118,7 @@ reaction_station.bioyond:
goal: goal:
properties: properties:
workflow_ids: workflow_ids:
description: 要删除的工作流ID数组
items: items:
type: string type: string
type: array type: array
@@ -145,6 +147,7 @@ reaction_station.bioyond:
goal: goal:
properties: properties:
json_str: json_str:
description: 'JSON格式的字符串,包含:'
type: string type: string
required: required:
- json_str - json_str
@@ -197,6 +200,7 @@ reaction_station.bioyond:
goal: goal:
properties: properties:
web_workflow_json: web_workflow_json:
description: JSON 格式的网页工作流列表
type: string type: string
required: required:
- web_workflow_json - web_workflow_json
@@ -228,8 +232,10 @@ reaction_station.bioyond:
goal: goal:
properties: properties:
reactor_id: reactor_id:
description: 反应器编号 (1-5)
type: integer type: integer
temperature: temperature:
description: 目标温度 (°C)
type: number type: number
required: required:
- reactor_id - reactor_id
@@ -257,6 +263,7 @@ reaction_station.bioyond:
goal: goal:
properties: properties:
preintake_id: preintake_id:
description: 通量ID
type: string type: string
required: required:
- preintake_id - preintake_id
@@ -338,6 +345,7 @@ reaction_station.bioyond:
goal: goal:
properties: properties:
value: value:
description: 工作流 ID 列表
items: items:
type: string type: string
type: array type: array
@@ -365,6 +373,7 @@ reaction_station.bioyond:
goal: goal:
properties: properties:
workflow_id: workflow_id:
description: 工作流ID
type: string type: string
required: required:
- workflow_id - workflow_id
@@ -424,11 +433,11 @@ reaction_station.bioyond:
goal: goal:
properties: properties:
assign_material_name: assign_material_name:
description: 物料名称(不能为空) description: 物料名称(液体种类)
type: string type: string
temperature: temperature:
default: 25.0 default: 25.0
description: 温度设定(°C) description: 温度(C)
type: number type: number
time: time:
default: '90' default: '90'
@@ -436,14 +445,14 @@ reaction_station.bioyond:
type: string type: string
titration_type: titration_type:
default: '1' default: '1'
description: 是否滴定(NO=, YES=) description: 是否滴定(NO=1, YES=2)
type: string type: string
torque_variation: torque_variation:
default: 2 default: 2
description: 是否观察 (NO=, YES=) description: 是否观察(NO=1, YES=2)
type: integer type: integer
volume: volume:
description: 分液公式(mL) description: 分液量(μL)
type: string type: string
required: required:
- assign_material_name - assign_material_name
@@ -525,11 +534,11 @@ reaction_station.bioyond:
properties: properties:
assign_material_name: assign_material_name:
default: BAPP default: BAPP
description: 物料名称 description: 物料名称(试剂瓶位)
type: string type: string
temperature: temperature:
default: 25.0 default: 25.0
description: 温度设定(°C) description: 温度设定(C)
type: number type: number
time: time:
default: '0' default: '0'
@@ -537,15 +546,15 @@ reaction_station.bioyond:
type: string type: string
titration_type: titration_type:
default: '1' default: '1'
description: 是否滴定(NO=, YES=) description: 是否滴定(NO=1, YES=2)
type: string type: string
torque_variation: torque_variation:
default: 1 default: 1
description: 是否观察 (NO=否, YES=是) description: 是否观察(int类型, 1=否, 2=是)
type: integer type: integer
volume: volume:
default: '350' default: '350'
description: 分液公式(mL) description: 分液质量(g)
type: string type: string
required: [] required: []
type: object type: object
@@ -593,26 +602,28 @@ reaction_station.bioyond:
description: 物料名称 description: 物料名称
type: string type: string
solvents: solvents:
description: '溶剂信息对象(可选),包含: additional_solvent(溶剂体积mL), total_liquid_volume(总液体体积mL)。如果提供,将自动计算volume' description: '溶剂信息的字典或JSON字符串(可选),格式如下:
{'
type: string type: string
temperature: temperature:
default: 25.0 default: 25.0
description: 温度设定(°C),默认25.00 description: 温度设定(C)
type: number type: number
time: time:
default: '360' default: '360'
description: 观察时间(分钟),默认360 description: 观察时间(分钟)
type: string type: string
titration_type: titration_type:
default: '1' default: '1'
description: 是否滴定(NO=, YES=是),默认NO description: 是否滴定(NO=1, YES=2)
type: string type: string
torque_variation: torque_variation:
default: 2 default: 2
description: 是否观察 (NO=, YES=是),默认YES description: 是否观察(NO=1, YES=2)
type: integer type: integer
volume: volume:
description: 分液量(mL)。可直接提供,或通过solvents参数自动计算 description: 分液量(μL),直接指定体积(可选,如果提供solvents自动计算)
type: string type: string
required: required:
- assign_material_name - assign_material_name
@@ -671,33 +682,32 @@ reaction_station.bioyond:
description: 物料名称 description: 物料名称
type: string type: string
extracted_actuals: extracted_actuals:
description: 从报告提取的实际加料量JSON字符串,包含actualTargetWeigh(m二酐滴定)和actualVolume(V二酐滴定) description: 从报告提取的实际加料量JSON字符串,包含actualTargetWeigh和actualVolume
type: string type: string
feeding_order_data: feeding_order_data:
description: 'feeding_order JSON对象,用于获取m二酐值(type为main_anhydride的amount)。示例: description: feeding_order JSON字符串或对象,用于获取m二酐值
{"feeding_order": [{"type": "main_anhydride", "amount": 1.915}]}'
type: string type: string
temperature: temperature:
default: 25.0 default: 25.0
description: 温度设定(°C),默认25.00 description: 温度(C)
type: number type: number
time: time:
default: '90' default: '90'
description: 观察时间(分钟),默认90 description: 观察时间(分钟)
type: string type: string
titration_type: titration_type:
default: '2' default: '2'
description: 是否滴定(NO=, YES=),默认YES description: 是否滴定(NO=1, YES=2),默认2
type: string type: string
torque_variation: torque_variation:
default: 2 default: 2
description: 是否观察 (NO=, YES=是),默认YES description: 是否观察(NO=1, YES=2)
type: integer type: integer
volume_formula: volume_formula:
description: 分液公式(mL)。可直接提供固定公式,或留空由系统根据x_value、feeding_order_data、extracted_actuals自动生成 description: 分液公式(μL),如果提供则直接使用,否则自动计算
type: string type: string
x_value: x_value:
description: 公式中的x值,手工输入,格式为"{{1-2-3}}"(包含双花括号)。用于自动公式计算 description: 手工输入的x值,格式如 "1-2-3"
type: string type: string
required: required:
- assign_material_name - assign_material_name
@@ -738,7 +748,7 @@ reaction_station.bioyond:
type: string type: string
temperature: temperature:
default: 25.0 default: 25.0
description: 温度设定(°C) description: 温度(C)
type: number type: number
time: time:
default: '0' default: '0'
@@ -746,14 +756,14 @@ reaction_station.bioyond:
type: string type: string
titration_type: titration_type:
default: '1' default: '1'
description: 是否滴定(NO=, YES=) description: 是否滴定(NO=1, YES=2)
type: string type: string
torque_variation: torque_variation:
default: 1 default: 1
description: 是否观察 (NO=, YES=) description: 是否观察(NO=1, YES=2)
type: integer type: integer
volume_formula: volume_formula:
description: 分液公式(mL) description: 分液公式(μL)
type: string type: string
required: required:
- volume_formula - volume_formula
@@ -786,7 +796,7 @@ reaction_station.bioyond:
description: 任务名称 description: 任务名称
type: string type: string
workflow_name: workflow_name:
description: 工作流名称 description: 合并后的工作流名称
type: string type: string
required: required:
- workflow_name - workflow_name
@@ -819,15 +829,15 @@ reaction_station.bioyond:
goal: goal:
properties: properties:
assign_material_name: assign_material_name:
description: 物料名称 description: 物料名称(不能为空)
type: string type: string
cutoff: cutoff:
default: '900000' default: '900000'
description: 粘度上限 description: 粘度上限(需为有效数字字符串,默认 "900000")
type: string type: string
temperature: temperature:
default: -10.0 default: -10.0
description: 温度设定(°C) description: 温度设定(C,范围:-50.00 至 100.00)
type: number type: number
required: required:
- assign_material_name - assign_material_name
@@ -909,11 +919,11 @@ reaction_station.bioyond:
description: 物料名称(用于获取试剂瓶位ID) description: 物料名称(用于获取试剂瓶位ID)
type: string type: string
material_id: material_id:
description: 粉末类型IDSalt=21分钟Flour=面粉27分钟BTDA=BTDA38分钟 description: 粉末类型ID, Salt=1, Flour=2, BTDA=3
type: string type: string
temperature: temperature:
default: 25.0 default: 25.0
description: 温度设定(°C) description: 温度设定(C)
type: number type: number
time: time:
default: '0' default: '0'
@@ -921,7 +931,7 @@ reaction_station.bioyond:
type: string type: string
torque_variation: torque_variation:
default: 1 default: 1
description: 是否观察 (NO=, YES=) description: 是否观察(NO=1, YES=2)
type: integer type: integer
required: required:
- material_id - material_id
@@ -945,10 +955,13 @@ reaction_station.bioyond:
config: config:
properties: properties:
config: config:
description: 配置字典,应包含workflow_mappings等配置
type: object type: object
deck: deck:
description: Deck对象
type: string type: string
protocol_type: protocol_type:
description: 协议类型(由ROS系统传递,此处忽略)
type: string type: string
required: [] required: []
type: object type: object

View File

@@ -173,48 +173,64 @@ robotic_arm.SCARA_with_slider.moveit.virtual:
type: object type: object
type: UniLabJsonCommand type: UniLabJsonCommand
pick_and_place: pick_and_place:
feedback: feedback: {}
status: status goal: {}
goal:
command: command
goal_default: 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: {} handles: {}
placeholder_keys: {} placeholder_keys: {}
result: result: {}
return_info: return_info
success: success
schema: schema:
description: '' description: pick_and_place 显式参数UniLabJsonCommand
properties: properties:
feedback: feedback: {}
additionalProperties: false goal:
properties: 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: status:
type: string type: string
title: SendCmd_Feedback target:
type: object
goal:
additionalProperties: false
properties:
command:
type: string type: string
title: SendCmd_Goal x_distance:
type: object
result:
additionalProperties: false
properties:
return_info:
type: string type: string
success: y_distance:
type: boolean type: string
title: SendCmd_Result required:
- option
- move_group
- status
type: object type: object
result: {}
required: required:
- goal - goal
title: SendCmd title: pick_and_place参数
type: object type: object
type: SendCmd type: UniLabJsonCommand
set_position: set_position:
feedback: feedback:
status: status status: status
@@ -241,6 +257,8 @@ robotic_arm.SCARA_with_slider.moveit.virtual:
additionalProperties: false additionalProperties: false
properties: properties:
command: command:
description: A JSON-formatted string that includes quaternion, speed,
position
type: string type: string
title: SendCmd_Goal title: SendCmd_Goal
type: object type: object
@@ -284,6 +302,7 @@ robotic_arm.SCARA_with_slider.moveit.virtual:
additionalProperties: false additionalProperties: false
properties: properties:
command: command:
description: A JSON-formatted string that includes speed
type: string type: string
title: SendCmd_Goal title: SendCmd_Goal
type: object type: object
@@ -329,7 +348,7 @@ robotic_arm.SCARA_with_slider.moveit.virtual:
type: object type: object
model: model:
mesh: arm_slider mesh: arm_slider
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/arm_slider/macro_device.xacro path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/arm_slider/macro_device.xacro
type: device type: device
version: 1.0.0 version: 1.0.0
robotic_arm.UR: robotic_arm.UR:

View File

@@ -684,48 +684,64 @@ linear_motion.toyo_xyz.sim:
type: object type: object
type: UniLabJsonCommand type: UniLabJsonCommand
pick_and_place: pick_and_place:
feedback: feedback: {}
status: status goal: {}
goal:
command: command
goal_default: 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: {} handles: {}
placeholder_keys: {} placeholder_keys: {}
result: result: {}
return_info: return_info
success: success
schema: schema:
description: '' description: pick_and_place 显式参数UniLabJsonCommand
properties: properties:
feedback: feedback: {}
additionalProperties: false goal:
properties: 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: status:
type: string type: string
title: SendCmd_Feedback target:
type: object
goal:
additionalProperties: false
properties:
command:
type: string type: string
title: SendCmd_Goal x_distance:
type: object
result:
additionalProperties: false
properties:
return_info:
type: string type: string
success: y_distance:
type: boolean type: string
title: SendCmd_Result required:
- option
- move_group
- status
type: object type: object
result: {}
required: required:
- goal - goal
title: SendCmd title: pick_and_place参数
type: object type: object
type: SendCmd type: UniLabJsonCommand
set_position: set_position:
feedback: feedback:
status: status status: status
@@ -752,6 +768,8 @@ linear_motion.toyo_xyz.sim:
additionalProperties: false additionalProperties: false
properties: properties:
command: command:
description: A JSON-formatted string that includes quaternion, speed,
position
type: string type: string
title: SendCmd_Goal title: SendCmd_Goal
type: object type: object
@@ -795,6 +813,7 @@ linear_motion.toyo_xyz.sim:
additionalProperties: false additionalProperties: false
properties: properties:
command: command:
description: A JSON-formatted string that includes speed
type: string type: string
title: SendCmd_Goal title: SendCmd_Goal
type: object type: object

View File

@@ -2179,6 +2179,7 @@ virtual_multiway_valve:
goal: goal:
properties: properties:
port_number: port_number:
description: 端口号 (1-8)
type: integer type: integer
required: required:
- port_number - port_number
@@ -2225,6 +2226,7 @@ virtual_multiway_valve:
goal: goal:
properties: properties:
port_number: port_number:
description: 目标端口号 (1-8)
type: integer type: integer
required: required:
- port_number - port_number
@@ -2261,6 +2263,7 @@ virtual_multiway_valve:
additionalProperties: false additionalProperties: false
properties: properties:
command: command:
description: 目标位置 (0-8) 或位置字符串
type: string type: string
title: SendCmd_Goal title: SendCmd_Goal
type: object type: object
@@ -2304,6 +2307,7 @@ virtual_multiway_valve:
additionalProperties: false additionalProperties: false
properties: properties:
command: command:
description: 目标位置 (0-8) 或位置字符串
type: string type: string
title: SendCmd_Goal title: SendCmd_Goal
type: object type: object
@@ -3960,6 +3964,14 @@ virtual_separator:
io_type: source io_type: source
label: bottom_phase_out label: bottom_phase_out
side: SOUTH side: SOUTH
- data_key: top_outlet
data_source: executor
data_type: fluid
description: 上相(轻相)液体输出口
handler_key: topphaseout
io_type: source
label: top_phase_out
side: NORTH
- data_key: mechanical_port - data_key: mechanical_port
data_source: handle data_source: handle
data_type: mechanical data_type: mechanical
@@ -4207,6 +4219,7 @@ virtual_solenoid_valve:
additionalProperties: false additionalProperties: false
properties: properties:
string: string:
description: '"ON"/"OFF" 或 "OPEN"/"CLOSED"'
type: string type: string
title: StrSingleInput_Goal title: StrSingleInput_Goal
type: object type: object
@@ -4250,6 +4263,7 @@ virtual_solenoid_valve:
additionalProperties: false additionalProperties: false
properties: properties:
command: command:
description: '"OPEN"/"CLOSED" 或其他控制命令'
type: string type: string
title: SendCmd_Goal title: SendCmd_Goal
type: object type: object
@@ -4410,16 +4424,20 @@ virtual_solid_dispenser:
event: event:
type: string type: string
mass: mass:
description: 质量字符串 (如 "2.9 g")
type: string type: string
mol: mol:
description: 摩尔数字符串 (如 "0.12 mol")
type: string type: string
purpose: purpose:
description: 添加目的
type: string type: string
rate_spec: rate_spec:
type: string type: string
ratio: ratio:
type: string type: string
reagent: reagent:
description: 试剂名称
type: string type: string
stir: stir:
type: boolean type: boolean
@@ -4431,6 +4449,7 @@ virtual_solid_dispenser:
type: string type: string
vessel: vessel:
additionalProperties: false additionalProperties: false
description: 目标容器
properties: properties:
category: category:
type: string type: string
@@ -5560,8 +5579,10 @@ virtual_transfer_pump:
goal: goal:
properties: properties:
velocity: velocity:
description: 拉取速度 (ml/s)
type: number type: number
volume: volume:
description: 要拉取的体积 (ml)
type: number type: number
required: required:
- volume - volume
@@ -5588,8 +5609,10 @@ virtual_transfer_pump:
goal: goal:
properties: properties:
velocity: velocity:
description: 推出速度 (ml/s)
type: number type: number
volume: volume:
description: 要推出的体积 (ml)
type: number type: number
required: required:
- volume - volume
@@ -5685,10 +5708,12 @@ virtual_transfer_pump:
additionalProperties: false additionalProperties: false
properties: properties:
max_velocity: max_velocity:
description: 移动速度 (ml/s)
maximum: 1.7976931348623157e+308 maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308 minimum: -1.7976931348623157e+308
type: number type: number
position: position:
description: 目标位置 (ml)
maximum: 1.7976931348623157e+308 maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308 minimum: -1.7976931348623157e+308
type: number type: number
@@ -5837,8 +5862,10 @@ virtual_transfer_pump:
config: config:
properties: properties:
config: config:
description: 配置字典包含max_volume, port等参数
type: object type: object
device_id: device_id:
description: 设备ID
type: string type: string
required: [] required: []
type: object type: object

View File

@@ -409,11 +409,11 @@ xrd_d7mate:
properties: properties:
end_theta: end_theta:
default: 80.0 default: 80.0
description: 结束角度≥5.5°且必须大于start_theta description: 结束角度≥5.5°,且必须大于 start_theta
type: number type: number
exp_time: exp_time:
default: 0.1 default: 0.1
description: 曝光时间0.1-5.0秒) description: 曝光时间0.1-5.0 秒)
type: number type: number
increment: increment:
default: 0.05 default: 0.05
@@ -421,7 +421,7 @@ xrd_d7mate:
type: number type: number
sample_id: sample_id:
default: '' default: ''
description: 样品标识符 description: 样品名称
type: string type: string
start_theta: start_theta:
default: 10.0 default: 10.0
@@ -433,7 +433,7 @@ xrd_d7mate:
type: string type: string
wait_minutes: wait_minutes:
default: 3.0 default: 3.0
description: 允许上样后等待分钟数 description: 允许上样后、发送样品准备完成前的等待分钟数(默认 3 分钟)
type: number type: number
required: [] required: []
title: StartWorkflow_Goal title: StartWorkflow_Goal
@@ -492,12 +492,15 @@ xrd_d7mate:
properties: properties:
host: host:
default: 127.0.0.1 default: 127.0.0.1
description: 设备IP地址
type: string type: string
port: port:
default: 6001 default: 6001
description: 通信端口默认6001
type: string type: string
timeout: timeout:
default: 10.0 default: 10.0
description: 超时时间,单位秒
type: string type: string
required: [] required: []
type: object type: object

View File

@@ -217,6 +217,7 @@ zhida_gcms:
additionalProperties: false additionalProperties: false
properties: properties:
string: string:
description: Base64编码的CSV数据ROS2参数名
type: string type: string
title: StrSingleInput_Goal title: StrSingleInput_Goal
type: object type: object
@@ -257,6 +258,7 @@ zhida_gcms:
additionalProperties: false additionalProperties: false
properties: properties:
string: string:
description: CSV文件路径ROS2参数名
type: string type: string
title: StrSingleInput_Goal title: StrSingleInput_Goal
type: object type: object
@@ -289,12 +291,15 @@ zhida_gcms:
properties: properties:
host: host:
default: 192.168.3.184 default: 192.168.3.184
description: 设备IP地址本地部署时可使用'127.0.0.1'
type: string type: string
port: port:
default: 5792 default: 5792
description: 通信端口默认5792
type: string type: string
timeout: timeout:
default: 10.0 default: 10.0
description: 超时时间,单位秒
type: string type: string
required: [] required: []
type: object type: object

View File

@@ -238,6 +238,7 @@ class Registry:
"class_name": "unilabos_class", "class_name": "unilabos_class",
}, },
"always_free": True, "always_free": True,
"feedback_interval": 300.0,
}, },
"test_latency": test_latency_action, "test_latency": test_latency_action,
"auto-test_resource": test_resource_action, "auto-test_resource": test_resource_action,
@@ -270,6 +271,7 @@ class Registry:
registry_cache.pkl 一个文件中,删除即可完全重置。 registry_cache.pkl 一个文件中,删除即可完全重置。
""" """
import time as _time import time as _time
from unilabos.registry.ast_registry_scanner import _CACHE_VERSION as AST_SCAN_CACHE_VERSION
from unilabos.registry.ast_registry_scanner import scan_directory from unilabos.registry.ast_registry_scanner import scan_directory
scan_t0 = _time.perf_counter() scan_t0 = _time.perf_counter()
@@ -285,6 +287,10 @@ class Registry:
# ---- 统一缓存:一个 pkl 包含所有数据 ---- # ---- 统一缓存:一个 pkl 包含所有数据 ----
unified_cache = self._load_config_cache() unified_cache = self._load_config_cache()
ast_cache = unified_cache.setdefault("_ast_scan", {"files": {}}) ast_cache = unified_cache.setdefault("_ast_scan", {"files": {}})
if ast_cache.get("version") != AST_SCAN_CACHE_VERSION:
ast_cache = {"version": AST_SCAN_CACHE_VERSION, "files": {}}
unified_cache["_ast_scan"] = ast_cache
unified_cache.pop("_build_results", None)
# 默认:扫描 unilabos 包所在的父目录 # 默认:扫描 unilabos 包所在的父目录
pkg_root = Path(__file__).resolve().parent.parent # .../unilabos pkg_root = Path(__file__).resolve().parent.parent # .../unilabos
@@ -560,13 +566,47 @@ class Registry:
return prop_schema return prop_schema
@staticmethod
def _apply_docstring_param_metadata(
schema: Dict[str, Any],
doc_info: Dict[str, Any],
field_to_param: Optional[Dict[str, str]] = None,
apply_defaults: bool = False,
) -> None:
"""Apply parsed docstring display names and descriptions to schema properties."""
if not schema or not doc_info:
return
props = schema.get("properties", {})
if not isinstance(props, dict):
return
param_descs = doc_info.get("params", {}) or {}
param_display_names = doc_info.get("param_display_names", {}) or {}
for field_name, prop_schema in props.items():
if not isinstance(prop_schema, dict):
continue
param_name = field_to_param.get(field_name, field_name) if field_to_param else field_name
if not isinstance(param_name, str):
continue
param_name = param_name.removesuffix("[]")
if param_name in param_display_names:
prop_schema["title"] = param_display_names[param_name]
elif apply_defaults and not prop_schema.get("title"):
prop_schema["title"] = field_name
if param_name in param_descs:
prop_schema["description"] = param_descs[param_name]
elif apply_defaults and "description" not in prop_schema:
prop_schema["description"] = ""
def _generate_unilab_json_command_schema( def _generate_unilab_json_command_schema(
self, method_args: list, docstring: Optional[str] = None, self, method_args: list, docstring: Optional[str] = None,
import_map: Optional[Dict[str, str]] = None, import_map: Optional[Dict[str, str]] = None,
apply_doc_defaults: bool = False,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""根据方法参数和 docstring 生成 UniLabJsonCommand schema""" """根据方法参数和 docstring 生成 UniLabJsonCommand schema"""
doc_info = parse_docstring(docstring) doc_info = parse_docstring(docstring)
param_descs = doc_info.get("params", {})
schema = { schema = {
"type": "object", "type": "object",
@@ -597,12 +637,10 @@ class Registry:
param_name, param_type, param_default, import_map=import_map param_name, param_type, param_default, import_map=import_map
) )
if param_name in param_descs:
schema["properties"][param_name]["description"] = param_descs[param_name]
if param_required: if param_required:
schema["required"].append(param_name) schema["required"].append(param_name)
self._apply_docstring_param_metadata(schema, doc_info, apply_defaults=apply_doc_defaults)
return schema return schema
def _generate_status_types_schema(self, status_methods: Dict[str, Any]) -> Dict[str, Any]: def _generate_status_types_schema(self, status_methods: Dict[str, Any]) -> Dict[str, Any]:
@@ -798,6 +836,7 @@ class Registry:
type_str = "UniLabJsonCommandAsync" if is_async else "UniLabJsonCommand" type_str = "UniLabJsonCommandAsync" if is_async else "UniLabJsonCommand"
params = method_info.get("params", []) params = method_info.get("params", [])
method_doc = method_info.get("docstring") method_doc = method_info.get("docstring")
method_doc_info = parse_docstring(method_doc)
goal_schema = self._generate_schema_from_ast_params(params, method_name, method_doc, imap) goal_schema = self._generate_schema_from_ast_params(params, method_name, method_doc, imap)
if action_args is not None: if action_args is not None:
@@ -827,10 +866,15 @@ class Registry:
# action handles: 从 @action(handles=[...]) 提取并转换为标准格式 # action handles: 从 @action(handles=[...]) 提取并转换为标准格式
raw_handles = (action_args or {}).get("handles") raw_handles = (action_args or {}).get("handles")
handles = normalize_ast_action_handles(raw_handles) if isinstance(raw_handles, list) else (raw_handles or {}) handles = (
normalize_ast_action_handles(raw_handles)
if isinstance(raw_handles, list)
else (raw_handles or {})
)
# placeholder_keys: 优先用装饰器显式配置,否则从参数类型检测 # placeholder_keys: 先从参数类型自动检测,再用装饰器显式配置覆盖/补充
pk = (action_args or {}).get("placeholder_keys") or detect_placeholder_keys(params) pk = detect_placeholder_keys(params)
pk.update((action_args or {}).get("placeholder_keys") or {})
# 从方法返回值类型生成 result schema # 从方法返回值类型生成 result schema
result_schema = None result_schema = None
@@ -845,13 +889,20 @@ class Registry:
"goal": goal, "goal": goal,
"feedback": (action_args or {}).get("feedback") or {}, "feedback": (action_args or {}).get("feedback") or {},
"result": (action_args or {}).get("result") or {}, "result": (action_args or {}).get("result") or {},
"schema": wrap_action_schema(goal_schema, action_name, result_schema=result_schema), "schema": wrap_action_schema(
goal_schema,
action_name,
description=(action_args or {}).get("description") or method_doc_info.get("description", ""),
result_schema=result_schema,
),
"goal_default": goal_default, "goal_default": goal_default,
"handles": handles, "handles": handles,
"placeholder_keys": pk, "placeholder_keys": pk,
} }
if (action_args or {}).get("always_free") or method_info.get("always_free"): if (action_args or {}).get("always_free") or method_info.get("always_free"):
entry["always_free"] = True entry["always_free"] = True
_fb_iv = (action_args or {}).get("feedback_interval", method_info.get("feedback_interval", 1.0))
entry["feedback_interval"] = _fb_iv
nt = normalize_enum_value((action_args or {}).get("node_type"), NodeType) nt = normalize_enum_value((action_args or {}).get("node_type"), NodeType)
if nt: if nt:
entry["node_type"] = nt entry["node_type"] = nt
@@ -882,7 +933,11 @@ class Registry:
action_name = f"auto-{action_name}" action_name = f"auto-{action_name}"
raw_handles = action_args.get("handles") raw_handles = action_args.get("handles")
handles = normalize_ast_action_handles(raw_handles) if isinstance(raw_handles, list) else (raw_handles or {}) handles = (
normalize_ast_action_handles(raw_handles)
if isinstance(raw_handles, list)
else (raw_handles or {})
)
method_params = method_info.get("params", []) method_params = method_info.get("params", [])
@@ -975,20 +1030,34 @@ class Registry:
"schema": schema, "schema": schema,
"goal_default": goal_default, "goal_default": goal_default,
"handles": handles, "handles": handles,
"placeholder_keys": action_args.get("placeholder_keys") or detect_placeholder_keys(method_params), "placeholder_keys": {
**detect_placeholder_keys(method_params),
**(action_args.get("placeholder_keys") or {}),
},
} }
if action_args.get("always_free") or method_info.get("always_free"): if action_args.get("always_free") or method_info.get("always_free"):
action_entry["always_free"] = True action_entry["always_free"] = True
_fb_iv = action_args.get("feedback_interval", method_info.get("feedback_interval", 1.0))
action_entry["feedback_interval"] = _fb_iv
nt = normalize_enum_value(action_args.get("node_type"), NodeType) nt = normalize_enum_value(action_args.get("node_type"), NodeType)
if nt: if nt:
action_entry["node_type"] = nt action_entry["node_type"] = nt
goal_schema_for_docs = action_entry.get("schema", {}).get("properties", {}).get("goal", {})
self._apply_docstring_param_metadata(
goal_schema_for_docs,
parse_docstring(method_info.get("docstring")),
goal,
apply_defaults=True,
)
action_value_mappings[action_name] = action_entry action_value_mappings[action_name] = action_entry
action_value_mappings = dict(sorted(action_value_mappings.items())) action_value_mappings = dict(sorted(action_value_mappings.items()))
# --- init_param_schema = { config: <init_params>, data: <status_types> } --- # --- init_param_schema = { config: <init_params>, data: <status_types> } ---
init_params = ast_meta.get("init_params", []) init_params = ast_meta.get("init_params", [])
config_schema = self._generate_schema_from_ast_params(init_params, "__init__", import_map=imap) config_schema = self._generate_schema_from_ast_params(
init_params, "__init__", ast_meta.get("init_docstring"), import_map=imap
)
data_schema = self._generate_status_schema_from_ast( data_schema = self._generate_status_schema_from_ast(
ast_meta.get("status_properties", {}), imap ast_meta.get("status_properties", {}), imap
) )
@@ -1036,7 +1105,6 @@ class Registry:
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Generate JSON Schema from AST-extracted parameter list.""" """Generate JSON Schema from AST-extracted parameter list."""
doc_info = parse_docstring(docstring) doc_info = parse_docstring(docstring)
param_descs = doc_info.get("params", {})
schema: Dict[str, Any] = { schema: Dict[str, Any] = {
"type": "object", "type": "object",
@@ -1066,12 +1134,10 @@ class Registry:
pname, ptype, pdefault, import_map pname, ptype, pdefault, import_map
) )
if pname in param_descs:
schema["properties"][pname]["description"] = param_descs[pname]
if prequired: if prequired:
schema["required"].append(pname) schema["required"].append(pname)
self._apply_docstring_param_metadata(schema, doc_info, apply_defaults=True)
return schema return schema
def _generate_status_schema_from_ast( def _generate_status_schema_from_ast(
@@ -1801,7 +1867,7 @@ class Registry:
else: else:
action_key = f"auto-{k}" action_key = f"auto-{k}"
goal_schema = self._generate_unilab_json_command_schema( goal_schema = self._generate_unilab_json_command_schema(
v["args"], import_map=enhanced_import_map v["args"], docstring=v.get("docstring"), import_map=enhanced_import_map
) )
ret_type = v.get("return_type", "") ret_type = v.get("return_type", "")
result_schema = None result_schema = None
@@ -1810,7 +1876,13 @@ class Registry:
"result", ret_type, None, import_map=enhanced_import_map "result", ret_type, None, import_map=enhanced_import_map
) )
old_cfg = old_action_configs.get(action_key) or old_action_configs.get(f"auto-{k}", {}) old_cfg = old_action_configs.get(action_key) or old_action_configs.get(f"auto-{k}", {})
new_schema = wrap_action_schema(goal_schema, action_key, result_schema=result_schema) doc_info = parse_docstring(v.get("docstring"))
new_schema = wrap_action_schema(
goal_schema,
action_key,
description=doc_info.get("description", ""),
result_schema=result_schema,
)
old_schema = old_cfg.get("schema", {}) old_schema = old_cfg.get("schema", {})
if old_schema: if old_schema:
preserve_field_descriptions(new_schema, old_schema) preserve_field_descriptions(new_schema, old_schema)
@@ -1876,6 +1948,12 @@ class Registry:
merged_pk = dict(old_cfg.get("placeholder_keys", {})) merged_pk = dict(old_cfg.get("placeholder_keys", {}))
merged_pk.update(detect_placeholder_keys(v["args"])) merged_pk.update(detect_placeholder_keys(v["args"]))
goal_schema_for_docs = (
entry_schema.get("properties", {}).get("goal", {})
if isinstance(entry_schema, dict)
else {}
)
self._apply_docstring_param_metadata(goal_schema_for_docs, doc_info, entry_goal)
entry = { entry = {
"type": entry_type, "type": entry_type,
@@ -1896,7 +1974,8 @@ class Registry:
device_config["init_param_schema"] = {} device_config["init_param_schema"] = {}
init_schema = self._generate_unilab_json_command_schema( init_schema = self._generate_unilab_json_command_schema(
enhanced_info["init_params"], "__init__", enhanced_info["init_params"],
docstring=enhanced_info.get("init_docstring"),
import_map=enhanced_import_map, import_map=enhanced_import_map,
) )
device_config["init_param_schema"]["config"] = init_schema device_config["init_param_schema"]["config"] = init_schema
@@ -1943,7 +2022,9 @@ class Registry:
action_str_type_mapping[action_type_str] = target_type action_str_type_mapping[action_type_str] = target_type
if target_type is not None: if target_type is not None:
try: try:
action_config["goal_default"] = ROS2MessageInstance(target_type.Goal()).get_python_dict() action_config["goal_default"] = ROS2MessageInstance(
target_type.Goal()
).get_python_dict()
except Exception: except Exception:
action_config["goal_default"] = {} action_config["goal_default"] = {}
prev_schema = action_config.get("schema", {}) prev_schema = action_config.get("schema", {})
@@ -2135,6 +2216,7 @@ class Registry:
"unilabos_device_id": { "unilabos_device_id": {
"type": "string", "type": "string",
"default": "", "default": "",
"title": "设备ID",
"description": "UniLabOS设备ID用于指定执行动作的具体设备实例", "description": "UniLabOS设备ID用于指定执行动作的具体设备实例",
}, },
**schema["properties"]["goal"]["properties"], **schema["properties"]["goal"]["properties"],
@@ -2206,7 +2288,14 @@ class Registry:
lab_registry = Registry() lab_registry = Registry()
def build_registry(registry_paths=None, devices_dirs=None, upload_registry=False, check_mode=False, complete_registry=False, external_only=False): def build_registry(
registry_paths=None,
devices_dirs=None,
upload_registry=False,
check_mode=False,
complete_registry=False,
external_only=False,
):
""" """
构建或获取Registry单例实例 构建或获取Registry单例实例
""" """
@@ -2220,7 +2309,12 @@ def build_registry(registry_paths=None, devices_dirs=None, upload_registry=False
if path not in current_paths: if path not in current_paths:
lab_registry.registry_paths.append(path) lab_registry.registry_paths.append(path)
lab_registry.setup(devices_dirs=devices_dirs, upload_registry=upload_registry, complete_registry=complete_registry, external_only=external_only) lab_registry.setup(
devices_dirs=devices_dirs,
upload_registry=upload_registry,
complete_registry=complete_registry,
external_only=external_only,
)
# 将 AST 扫描的字符串类型替换为实际 ROS2 消息类(仅查找 ROS2 类型,不 import 设备模块) # 将 AST 扫描的字符串类型替换为实际 ROS2 消息类(仅查找 ROS2 类型,不 import 设备模块)
lab_registry.resolve_all_types() lab_registry.resolve_all_types()

View File

@@ -17,7 +17,7 @@ hplc_plate:
- 0 - 0
- 0 - 0
- 3.1416 - 3.1416
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/hplc_plate/modal.xacro path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/hplc_plate/modal.xacro
type: resource type: resource
version: 1.0.0 version: 1.0.0
plate_96: plate_96:
@@ -39,7 +39,7 @@ plate_96:
- 0 - 0
- 0 - 0
- 0 - 0
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/plate_96/modal.xacro path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/plate_96/modal.xacro
type: resource type: resource
version: 1.0.0 version: 1.0.0
plate_96_high: plate_96_high:
@@ -61,7 +61,7 @@ plate_96_high:
- 1.5708 - 1.5708
- 0 - 0
- 1.5708 - 1.5708
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/plate_96_high/modal.xacro path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/plate_96_high/modal.xacro
type: resource type: resource
version: 1.0.0 version: 1.0.0
tiprack_96_high: tiprack_96_high:
@@ -76,7 +76,7 @@ tiprack_96_high:
init_param_schema: {} init_param_schema: {}
model: model:
children_mesh: generic_labware_tube_10_75/meshes/0_base.stl children_mesh: generic_labware_tube_10_75/meshes/0_base.stl
children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/generic_labware_tube_10_75/modal.xacro children_mesh_path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/generic_labware_tube_10_75/modal.xacro
children_mesh_tf: children_mesh_tf:
- 0.0018 - 0.0018
- 0.0018 - 0.0018
@@ -92,7 +92,7 @@ tiprack_96_high:
- 1.5708 - 1.5708
- 0 - 0
- 1.5708 - 1.5708
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tiprack_96_high/modal.xacro path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tiprack_96_high/modal.xacro
type: resource type: resource
version: 1.0.0 version: 1.0.0
tiprack_box: tiprack_box:
@@ -107,7 +107,7 @@ tiprack_box:
init_param_schema: {} init_param_schema: {}
model: model:
children_mesh: tip/meshes/tip.stl children_mesh: tip/meshes/tip.stl
children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tip/modal.xacro children_mesh_path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tip/modal.xacro
children_mesh_tf: children_mesh_tf:
- 0.0045 - 0.0045
- 0.0045 - 0.0045
@@ -123,6 +123,6 @@ tiprack_box:
- 0 - 0
- 0 - 0
- 0 - 0
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tiprack_box/modal.xacro path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tiprack_box/modal.xacro
type: resource type: resource
version: 1.0.0 version: 1.0.0

View File

@@ -11,7 +11,7 @@ bottle_container:
init_param_schema: {} init_param_schema: {}
model: model:
children_mesh: bottle/meshes/bottle.stl children_mesh: bottle/meshes/bottle.stl
children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/bottle/modal.xacro children_mesh_path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/bottle/modal.xacro
children_mesh_tf: children_mesh_tf:
- 0.04 - 0.04
- 0.04 - 0.04
@@ -27,7 +27,7 @@ bottle_container:
- 0 - 0
- 0 - 0
- 0 - 0
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/bottle_container/modal.xacro path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/bottle_container/modal.xacro
type: resource type: resource
version: 1.0.0 version: 1.0.0
tube_container: tube_container:
@@ -43,7 +43,7 @@ tube_container:
init_param_schema: {} init_param_schema: {}
model: model:
children_mesh: tube/meshes/tube.stl children_mesh: tube/meshes/tube.stl
children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tube/modal.xacro children_mesh_path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tube/modal.xacro
children_mesh_tf: children_mesh_tf:
- 0.017 - 0.017
- 0.017 - 0.017
@@ -59,6 +59,6 @@ tube_container:
- 0 - 0
- 0 - 0
- 0 - 0
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tube_container/modal.xacro path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tube_container/modal.xacro
type: resource type: resource
version: 1.0.0 version: 1.0.0

View File

@@ -10,6 +10,6 @@ TransformXYZDeck:
init_param_schema: {} init_param_schema: {}
model: model:
mesh: liquid_transform_xyz mesh: liquid_transform_xyz
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/liquid_transform_xyz/macro_device.xacro path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/liquid_transform_xyz/macro_device.xacro
type: device type: device
version: 1.0.0 version: 1.0.0

View File

@@ -10,7 +10,7 @@ OTDeck:
init_param_schema: {} init_param_schema: {}
model: model:
mesh: opentrons_liquid_handler mesh: opentrons_liquid_handler
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/opentrons_liquid_handler/macro_device.xacro path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/opentrons_liquid_handler/macro_device.xacro
type: device type: device
version: 1.0.0 version: 1.0.0
hplc_station: hplc_station:
@@ -25,6 +25,6 @@ hplc_station:
init_param_schema: {} init_param_schema: {}
model: model:
mesh: hplc_station mesh: hplc_station
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/hplc_station/macro_device.xacro path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/hplc_station/macro_device.xacro
type: device type: device
version: 1.0.0 version: 1.0.0

View File

@@ -109,7 +109,7 @@ nest_96_wellplate_100ul_pcr_full_skirt:
init_param_schema: {} init_param_schema: {}
model: model:
children_mesh: generic_labware_tube_10_75/meshes/0_base.stl children_mesh: generic_labware_tube_10_75/meshes/0_base.stl
children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/generic_labware_tube_10_75/modal.xacro children_mesh_path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/generic_labware_tube_10_75/modal.xacro
children_mesh_tf: children_mesh_tf:
- 0.0018 - 0.0018
- 0.0018 - 0.0018
@@ -125,7 +125,7 @@ nest_96_wellplate_100ul_pcr_full_skirt:
- -1.5708 - -1.5708
- 0 - 0
- 1.5708 - 1.5708
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro
type: resource type: resource
version: 1.0.0 version: 1.0.0
nest_96_wellplate_200ul_flat: nest_96_wellplate_200ul_flat:
@@ -158,7 +158,7 @@ nest_96_wellplate_2ml_deep:
- -1.5708 - -1.5708
- 0 - 0
- 1.5708 - 1.5708
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro
type: resource type: resource
version: 1.0.0 version: 1.0.0
thermoscientificnunc_96_wellplate_1300ul: thermoscientificnunc_96_wellplate_1300ul:

View File

@@ -69,7 +69,7 @@ opentrons_96_filtertiprack_1000ul:
- -1.5708 - -1.5708
- 0 - 0
- 1.5708 - 1.5708
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro
type: resource type: resource
version: 1.0.0 version: 1.0.0
opentrons_96_filtertiprack_10ul: opentrons_96_filtertiprack_10ul:

View File

@@ -3,7 +3,7 @@ PRCXI_30mm_Adapter:
- prcxi - prcxi
- plate_adapters - plate_adapters
class: 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 type: pylabrobot
description: '30mm适配器 (Code: ZX-58-30)' description: '30mm适配器 (Code: ZX-58-30)'
handles: [] handles: []
@@ -15,7 +15,7 @@ PRCXI_Adapter:
- prcxi - prcxi
- plate_adapters - plate_adapters
class: class:
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Adapter module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_Adapter'
type: pylabrobot type: pylabrobot
description: '适配器 (Code: Fhh478)' description: '适配器 (Code: Fhh478)'
handles: [] handles: []
@@ -27,7 +27,7 @@ PRCXI_Deep10_Adapter:
- prcxi - prcxi
- plate_adapters - plate_adapters
class: 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 type: pylabrobot
description: '10ul专用深孔板适配器 (Code: ZX-002-10)' description: '10ul专用深孔板适配器 (Code: ZX-002-10)'
handles: [] handles: []
@@ -39,7 +39,7 @@ PRCXI_Deep300_Adapter:
- prcxi - prcxi
- plate_adapters - plate_adapters
class: 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 type: pylabrobot
description: '300ul深孔板适配器 (Code: ZX-002-300)' description: '300ul深孔板适配器 (Code: ZX-002-300)'
handles: [] handles: []
@@ -51,7 +51,7 @@ PRCXI_PCR_Adapter:
- prcxi - prcxi
- plate_adapters - plate_adapters
class: 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 type: pylabrobot
description: '全裙边 PCR适配器 (Code: ZX-58-0001)' description: '全裙边 PCR适配器 (Code: ZX-58-0001)'
handles: [] handles: []
@@ -63,7 +63,7 @@ PRCXI_Reservoir_Adapter:
- prcxi - prcxi
- plate_adapters - plate_adapters
class: 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 type: pylabrobot
description: '储液槽 适配器 (Code: ZX-ADP-001)' description: '储液槽 适配器 (Code: ZX-ADP-001)'
handles: [] handles: []
@@ -75,7 +75,7 @@ PRCXI_Tip10_Adapter:
- prcxi - prcxi
- plate_adapters - plate_adapters
class: 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 type: pylabrobot
description: '吸头10ul 适配器 (Code: ZX-58-10)' description: '吸头10ul 适配器 (Code: ZX-58-10)'
handles: [] handles: []
@@ -87,7 +87,7 @@ PRCXI_Tip1250_Adapter:
- prcxi - prcxi
- plate_adapters - plate_adapters
class: 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 type: pylabrobot
description: 'Tip头适配器 1250uL (Code: ZX-58-1250)' description: 'Tip头适配器 1250uL (Code: ZX-58-1250)'
handles: [] handles: []
@@ -99,7 +99,7 @@ PRCXI_Tip300_Adapter:
- prcxi - prcxi
- plate_adapters - plate_adapters
class: 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 type: pylabrobot
description: 'ZHONGXI 适配器 300uL (Code: ZX-58-300)' description: 'ZHONGXI 适配器 300uL (Code: ZX-58-300)'
handles: [] handles: []

View File

@@ -3,7 +3,7 @@ PRCXI_48_DeepWell:
- prcxi - prcxi
- plates - plates
class: 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 type: pylabrobot
description: '48孔深孔板 (Code: 22)' description: '48孔深孔板 (Code: 22)'
handles: [] handles: []
@@ -15,7 +15,7 @@ PRCXI_96_DeepWell:
- prcxi - prcxi
- plates - plates
class: 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 type: pylabrobot
description: '96深孔板 (Code: q2)' description: '96深孔板 (Code: q2)'
handles: [] handles: []
@@ -27,7 +27,7 @@ PRCXI_AGenBio_4_troughplate:
- prcxi - prcxi
- plates - plates
class: 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 type: pylabrobot
description: '4道储液槽 (Code: sdfrth654)' description: '4道储液槽 (Code: sdfrth654)'
handles: [] handles: []
@@ -39,7 +39,7 @@ PRCXI_BioER_96_wellplate:
- prcxi - prcxi
- plates - plates
class: 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 type: pylabrobot
description: '2.2ml 深孔板 (Code: ZX-019-2.2)' description: '2.2ml 深孔板 (Code: ZX-019-2.2)'
handles: [] handles: []
@@ -51,7 +51,7 @@ PRCXI_BioRad_384_wellplate:
- prcxi - prcxi
- plates - plates
class: 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 type: pylabrobot
description: '384板 (Code: q3)' description: '384板 (Code: q3)'
handles: [] handles: []
@@ -63,7 +63,7 @@ PRCXI_CellTreat_96_wellplate:
- prcxi - prcxi
- plates - plates
class: 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 type: pylabrobot
description: '细菌培养皿 (Code: ZX-78-096)' description: '细菌培养皿 (Code: ZX-78-096)'
handles: [] handles: []
@@ -75,7 +75,7 @@ PRCXI_PCR_Plate_200uL_nonskirted:
- prcxi - prcxi
- plates - plates
class: 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 type: pylabrobot
description: '0.2ml PCR 板 (Code: ZX-023-0.2)' description: '0.2ml PCR 板 (Code: ZX-023-0.2)'
handles: [] handles: []
@@ -87,7 +87,7 @@ PRCXI_PCR_Plate_200uL_semiskirted:
- prcxi - prcxi
- plates - plates
class: 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 type: pylabrobot
description: '0.2ml PCR 板 (Code: ZX-023-0.2)' description: '0.2ml PCR 板 (Code: ZX-023-0.2)'
handles: [] handles: []
@@ -99,7 +99,7 @@ PRCXI_PCR_Plate_200uL_skirted:
- prcxi - prcxi
- plates - plates
class: 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 type: pylabrobot
description: '0.2ml PCR 板 (Code: ZX-023-0.2)' description: '0.2ml PCR 板 (Code: ZX-023-0.2)'
handles: [] handles: []
@@ -111,7 +111,7 @@ PRCXI_nest_12_troughplate:
- prcxi - prcxi
- plates - plates
class: 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 type: pylabrobot
description: '12道储液槽 (Code: 12道储液槽)' description: '12道储液槽 (Code: 12道储液槽)'
handles: [] handles: []
@@ -123,7 +123,7 @@ PRCXI_nest_1_troughplate:
- prcxi - prcxi
- plates - plates
class: 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 type: pylabrobot
description: '储液槽 (Code: ZX-58-10000)' description: '储液槽 (Code: ZX-58-10000)'
handles: [] handles: []

View File

@@ -3,7 +3,7 @@ PRCXI_1000uL_Tips:
- prcxi - prcxi
- tip_racks - tip_racks
class: 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 type: pylabrobot
description: '1000μL Tip头 (Code: ZX-001-1000)' description: '1000μL Tip头 (Code: ZX-001-1000)'
handles: [] handles: []
@@ -15,7 +15,7 @@ PRCXI_10uL_Tips:
- prcxi - prcxi
- tip_racks - tip_racks
class: 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 type: pylabrobot
description: '10μL Tip头 (Code: ZX-001-10)' description: '10μL Tip头 (Code: ZX-001-10)'
handles: [] handles: []
@@ -27,7 +27,7 @@ PRCXI_10ul_eTips:
- prcxi - prcxi
- tip_racks - tip_racks
class: 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 type: pylabrobot
description: '10μL加长 Tip头 (Code: ZX-001-10+)' description: '10μL加长 Tip头 (Code: ZX-001-10+)'
handles: [] handles: []
@@ -39,7 +39,7 @@ PRCXI_1250uL_Tips:
- prcxi - prcxi
- tip_racks - tip_racks
class: 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 type: pylabrobot
description: '1250μL Tip头 (Code: ZX-001-1250)' description: '1250μL Tip头 (Code: ZX-001-1250)'
handles: [] handles: []
@@ -51,7 +51,7 @@ PRCXI_200uL_Tips:
- prcxi - prcxi
- tip_racks - tip_racks
class: 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 type: pylabrobot
description: '200μL Tip头 (Code: ZX-001-200)' description: '200μL Tip头 (Code: ZX-001-200)'
handles: [] handles: []
@@ -63,10 +63,22 @@ PRCXI_300ul_Tips:
- prcxi - prcxi
- tip_racks - tip_racks
class: 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 type: pylabrobot
description: '300μL Tip头 (Code: ZX-001-300)' description: '300μL Tip头 (Code: ZX-001-300)'
handles: [] handles: []
icon: '' icon: ''
init_param_schema: {} init_param_schema: {}
version: 1.0.0 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 - prcxi
- trash - trash
class: class:
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_trash module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_trash'
type: pylabrobot type: pylabrobot
description: '废弃槽 (Code: q1)' description: '废弃槽 (Code: q1)'
handles: [] handles: []

View File

@@ -3,7 +3,7 @@ PRCXI_EP_Adapter:
- prcxi - prcxi
- tube_racks - tube_racks
class: 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 type: pylabrobot
description: 'ep适配器 (Code: 1)' description: 'ep适配器 (Code: 1)'
handles: [] handles: []

View File

@@ -36,16 +36,40 @@ class ROSMsgNotFound(Exception):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_SECTION_RE = re.compile(r"^(\w[\w\s]*):\s*$") _SECTION_RE = re.compile(r"^(\w[\w\s]*):\s*$")
_PARAM_HEADER_RE = re.compile(
r"^\s*(?P<name>\w[\w]*)\s*(?:\[(?P<display_name>[^\]]+)\])?(?:\s*\([^)]*\))?\s*$"
)
def _parse_docstring_param_header(param_part: str) -> Tuple[str, Optional[str]]:
"""Parse ``name[display_name]`` or Google-style ``name (type)``."""
match = _PARAM_HEADER_RE.match(param_part.strip())
if not match:
return param_part.strip().split("(")[0].strip(), None
display_name = match.group("display_name")
if display_name is not None:
display_name = display_name.strip() or None
return match.group("name").strip(), display_name
def parse_docstring(docstring: Optional[str]) -> Dict[str, Any]: def parse_docstring(docstring: Optional[str]) -> Dict[str, Any]:
""" """
解析 Google-style docstring提取描述和参数说明。 解析 docstring提取描述和参数说明。
支持:
- Google-style ``Args:`` / ``Parameters:`` 小节
- 直接参数行 ``field: desc``
- 带显示名参数行 ``field[Display Name]: desc``
Returns: Returns:
{"description": "短描述", "params": {"param1": "参数1描述", ...}} {
"description": "短描述",
"params": {"param1": "参数1描述", ...},
"param_display_names": {"param1": "显示名", ...},
}
""" """
result: Dict[str, Any] = {"description": "", "params": {}} result: Dict[str, Any] = {"description": "", "params": {}, "param_display_names": {}}
if not docstring: if not docstring:
return result return result
@@ -53,33 +77,53 @@ def parse_docstring(docstring: Optional[str]) -> Dict[str, Any]:
if not lines: if not lines:
return result return result
result["description"] = lines[0].strip()
in_args = False in_args = False
current_section: Optional[str] = None
current_param: Optional[str] = None current_param: Optional[str] = None
current_display_name: Optional[str] = None
current_desc_parts: list = [] current_desc_parts: list = []
for line in lines[1:]: def flush_current_param() -> None:
nonlocal current_param, current_display_name, current_desc_parts
if current_param is None:
return
result["params"][current_param] = "\n".join(current_desc_parts).strip()
if current_display_name:
result["param_display_names"][current_param] = current_display_name
current_param = None
current_display_name = None
current_desc_parts = []
first_line = lines[0].strip()
start_index = 0
if not _SECTION_RE.match(first_line) and ":" not in first_line:
result["description"] = first_line
start_index = 1
for line in lines[start_index:]:
stripped = line.strip() stripped = line.strip()
if not stripped:
if current_param is not None:
current_desc_parts.append("")
continue
section_match = _SECTION_RE.match(stripped) section_match = _SECTION_RE.match(stripped)
if section_match: if section_match:
if current_param is not None: flush_current_param()
result["params"][current_param] = "\n".join(current_desc_parts).strip() current_section = section_match.group(1).lower()
current_param = None in_args = current_section in ("args", "arguments", "parameters", "params")
current_desc_parts = []
section_name = section_match.group(1).lower()
in_args = section_name in ("args", "arguments", "parameters", "params")
continue continue
if not in_args: parse_as_param = in_args or current_section is None
if not parse_as_param:
continue continue
if ":" in stripped and not stripped.startswith(" "): if ":" in stripped:
if current_param is not None: flush_current_param()
result["params"][current_param] = "\n".join(current_desc_parts).strip()
param_part, _, desc_part = stripped.partition(":") param_part, _, desc_part = stripped.partition(":")
param_name = param_part.strip().split("(")[0].strip() param_name, display_name = _parse_docstring_param_header(param_part)
current_param = param_name current_param = param_name
current_display_name = display_name
current_desc_parts = [desc_part.strip()] current_desc_parts = [desc_part.strip()]
elif current_param is not None: elif current_param is not None:
aline = line aline = line
@@ -89,8 +133,7 @@ def parse_docstring(docstring: Optional[str]) -> Dict[str, Any]:
aline = aline[1:] aline = aline[1:]
current_desc_parts.append(aline.strip()) current_desc_parts.append(aline.strip())
if current_param is not None: flush_current_param()
result["params"][current_param] = "\n".join(current_desc_parts).strip()
return result return result

View File

@@ -997,7 +997,7 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict
logger.debug(f"🔍 [PLR→Bioyond] detail转换: {bottle.name} → PLR(x={site['x']},y={site['y']},id={site.get('identifier','?')}) → Bioyond(x={bioyond_x},y={bioyond_y})") logger.debug(f"🔍 [PLR→Bioyond] detail转换: {bottle.name} → PLR(x={site['x']},y={site['y']},id={site.get('identifier','?')}) → Bioyond(x={bioyond_x},y={bioyond_y})")
# 🔥 提取物料名称:从 tracker.liquids 中获取第一个液体的名称去除PLR系统添加的后缀 # 🔥 提取物料名称:从 tracker.liquids 中获取第一个液体的名称去除PLR系统添加的后缀
# tracker.liquids 格式: [(物料名称, 数量), ...] # tracker.liquids 格式: [(物料名称, 数量, 单位), ...]
material_name = bottle_type_info[0] # 默认使用类型名称(如"样品瓶" material_name = bottle_type_info[0] # 默认使用类型名称(如"样品瓶"
if hasattr(bottle, "tracker") and bottle.tracker.liquids: if hasattr(bottle, "tracker") and bottle.tracker.liquids:
# 如果有液体,使用液体的名称 # 如果有液体,使用液体的名称
@@ -1015,7 +1015,7 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict
"typeId": bottle_type_info[1], "typeId": bottle_type_info[1],
"code": bottle.code if hasattr(bottle, "code") else "", "code": bottle.code if hasattr(bottle, "code") else "",
"name": material_name, # 使用物料名称(如"9090"),而不是类型名称("样品瓶" "name": material_name, # 使用物料名称(如"9090"),而不是类型名称("样品瓶"
"quantity": sum(qty for _, qty in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0, "quantity": sum(qty for _, qty, *_ in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0,
"x": bioyond_x, "x": bioyond_x,
"y": bioyond_y, "y": bioyond_y,
"z": 1, "z": 1,
@@ -1075,7 +1075,7 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict
"barCode": "", "barCode": "",
"name": material_name, # 使用物料名称而不是资源名称 "name": material_name, # 使用物料名称而不是资源名称
"unit": default_unit, # 使用配置的单位或默认单位 "unit": default_unit, # 使用配置的单位或默认单位
"quantity": sum(qty for _, qty in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0, "quantity": sum(qty for _, qty, *_ in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0,
"Parameters": parameters_json # API 实际要求的字段(必需) "Parameters": parameters_json # API 实际要求的字段(必需)
} }

View File

@@ -489,7 +489,18 @@ class ResourceTreeSet(object):
def resource_plr_inner( def resource_plr_inner(
d: dict, parent_resource: Optional[ResourceDict], states: dict, uuids: list d: dict, parent_resource: Optional[ResourceDict], states: dict, uuids: list
) -> ResourceDictInstance: ) -> 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 = ( raw_pos = (
{"x": d["location"]["x"], "y": d["location"]["y"], "z": d["location"]["z"]} {"x": d["location"]["x"], "y": d["location"]["y"], "z": d["location"]["z"]}

View File

@@ -1,4 +1,5 @@
import json import json
import os
# from nt import device_encoding # from nt import device_encoding
import threading import threading
@@ -62,7 +63,7 @@ def main(
rclpy.init(args=rclpy_init_args) rclpy.init(args=rclpy_init_args)
else: else:
logger.info("[ROS] rclpy already initialized, reusing context") 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 = HostNode(
"host_node", "host_node",
@@ -124,7 +125,7 @@ def slave(
rclpy.init(args=rclpy_init_args) rclpy.init(args=rclpy_init_args)
executor = rclpy.__executor executor = rclpy.__executor
if not executor: if not executor:
executor = rclpy.__executor = MultiThreadedExecutor() executor = rclpy.__executor = MultiThreadedExecutor(num_threads=max(os.cpu_count() * 4, 48))
# 1.5 启动 executor 线程 # 1.5 启动 executor 线程
thread = threading.Thread(target=executor.spin, daemon=True, name="slave_executor_thread") thread = threading.Thread(target=executor.spin, daemon=True, name="slave_executor_thread")

View File

@@ -4,6 +4,8 @@ import json
import threading import threading
import time import time
import traceback import traceback
from unilabos.utils.tools import fast_dumps_str as _fast_dumps_str, fast_loads as _fast_loads
from typing import ( from typing import (
get_type_hints, get_type_hints,
TypeVar, TypeVar,
@@ -78,6 +80,67 @@ if TYPE_CHECKING:
T = TypeVar("T") T = TypeVar("T")
class RclpyAsyncMutex:
"""rclpy executor 兼容的异步互斥锁
通过 executor.create_task 唤醒等待者,避免 timer 的 InvalidHandle 问题。
"""
def __init__(self, name: str = ""):
self._lock = threading.Lock()
self._acquired = False
self._queue: List[Future] = []
self._name = name
self._holder: Optional[str] = None
async def acquire(self, node: "BaseROS2DeviceNode", tag: str = ""):
"""获取锁。如果已被占用,则异步等待直到锁释放。"""
# t0 = time.time()
with self._lock:
# qlen = len(self._queue)
if not self._acquired:
self._acquired = True
self._holder = tag
# node.lab_logger().debug(
# f"[Mutex:{self._name}] 获取锁 tag={tag} (无等待, queue=0)"
# )
return
waiter = Future()
self._queue.append(waiter)
# node.lab_logger().info(
# f"[Mutex:{self._name}] 等待锁 tag={tag} "
# f"(holder={self._holder}, queue={qlen + 1})"
# )
await waiter
# wait_ms = (time.time() - t0) * 1000
self._holder = tag
# node.lab_logger().info(
# f"[Mutex:{self._name}] 获取锁 tag={tag} (等了 {wait_ms:.0f}ms)"
# )
def release(self, node: "BaseROS2DeviceNode"):
"""释放锁,通过 executor task 唤醒下一个等待者。"""
with self._lock:
# old_holder = self._holder
if self._queue:
next_waiter = self._queue.pop(0)
# node.lab_logger().debug(
# f"[Mutex:{self._name}] 释放锁 holder={old_holder} → 唤醒下一个 (剩余 queue={len(self._queue)})"
# )
async def _wake():
if not next_waiter.done():
next_waiter.set_result(None)
rclpy.get_global_executor().create_task(_wake())
else:
self._acquired = False
self._holder = None
# node.lab_logger().debug(
# f"[Mutex:{self._name}] 释放锁 holder={old_holder} → 空闲"
# )
# 在线设备注册表 # 在线设备注册表
registered_devices: Dict[str, "DeviceInfoType"] = {} registered_devices: Dict[str, "DeviceInfoType"] = {}
@@ -355,6 +418,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
max_workers=max(len(action_value_mappings), 1), thread_name_prefix=f"ROSDevice{self.device_id}" max_workers=max(len(action_value_mappings), 1), thread_name_prefix=f"ROSDevice{self.device_id}"
) )
self._append_resource_lock = RclpyAsyncMutex(name=f"AR:{device_id}")
# 创建资源管理客户端 # 创建资源管理客户端
self._resource_clients: Dict[str, Client] = { self._resource_clients: Dict[str, Client] = {
"resource_add": self.create_client(ResourceAdd, "/resources/add", callback_group=self.callback_group), "resource_add": self.create_client(ResourceAdd, "/resources/add", callback_group=self.callback_group),
@@ -378,15 +443,40 @@ class BaseROS2DeviceNode(Node, Generic[T]):
return res return res
async def append_resource(req: SerialCommand_Request, res: SerialCommand_Response): async def append_resource(req: SerialCommand_Request, res: SerialCommand_Response):
_cmd = _fast_loads(req.command)
_res_name = _cmd.get("resource", [{}])
_res_name = (_res_name[0].get("id", "?") if isinstance(_res_name, list) and _res_name
else _res_name.get("id", "?") if isinstance(_res_name, dict) else "?")
_ar_tag = f"{_res_name}"
# _t_enter = time.time()
# self.lab_logger().info(f"[AR:{_ar_tag}] 进入 append_resource")
await self._append_resource_lock.acquire(self, tag=_ar_tag)
# _t_locked = time.time()
try:
return await _append_resource_inner(req, res, _ar_tag)
# _t_done = time.time()
# self.lab_logger().info(
# f"[AR:{_ar_tag}] 完成 "
# f"等锁={(_t_locked - _t_enter) * 1000:.0f}ms "
# f"执行={(_t_done - _t_locked) * 1000:.0f}ms "
# f"总计={(_t_done - _t_enter) * 1000:.0f}ms"
# )
except Exception as _ex:
self.lab_logger().error(f"[AR:{_ar_tag}] 异常: {_ex}")
raise
finally:
self._append_resource_lock.release(self)
async def _append_resource_inner(req: SerialCommand_Request, res: SerialCommand_Response, _ar_tag: str = ""):
from pylabrobot.resources.deck import Deck from pylabrobot.resources.deck import Deck
from pylabrobot.resources import Coordinate from pylabrobot.resources import Coordinate
from pylabrobot.resources import Plate from pylabrobot.resources import Plate
# 物料传输到对应的node节点 # _t0 = time.time()
client = self._resource_clients["c2s_update_resource_tree"] client = self._resource_clients["c2s_update_resource_tree"]
request = SerialCommand.Request() request = SerialCommand.Request()
request2 = SerialCommand.Request() request2 = SerialCommand.Request()
command_json = json.loads(req.command) command_json = _fast_loads(req.command)
namespace = command_json["namespace"] namespace = command_json["namespace"]
bind_parent_id = command_json["bind_parent_id"] bind_parent_id = command_json["bind_parent_id"]
edge_device_id = command_json["edge_device_id"] edge_device_id = command_json["edge_device_id"]
@@ -439,7 +529,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
f"更新物料{container_instance.name}出现不支持的数据类型{type(found_resource)} {found_resource}" f"更新物料{container_instance.name}出现不支持的数据类型{type(found_resource)} {found_resource}"
) )
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
request.command = json.dumps( # _t1 = time.time()
# self.lab_logger().debug(
# f"[AR:{_ar_tag}] 准备完成 PLR转换+序列化 {((_t1 - _t0) * 1000):.0f}ms, 发送首次上传..."
# )
request.command = _fast_dumps_str(
{ {
"action": "add", "action": "add",
"data": { "data": {
@@ -450,7 +544,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
} }
) )
tree_response: SerialCommand.Response = await client.call_async(request) tree_response: SerialCommand.Response = await client.call_async(request)
uuid_maps = json.loads(tree_response.response) # _t2 = time.time()
# self.lab_logger().debug(
# f"[AR:{_ar_tag}] 首次上传完成 {((_t2 - _t1) * 1000):.0f}ms"
# )
uuid_maps = _fast_loads(tree_response.response)
plr_instances = rts.to_plr_resources() plr_instances = rts.to_plr_resources()
for plr_instance in plr_instances: for plr_instance in plr_instances:
self.resource_tracker.loop_update_uuid(plr_instance, uuid_maps) self.resource_tracker.loop_update_uuid(plr_instance, uuid_maps)
@@ -486,18 +584,12 @@ class BaseROS2DeviceNode(Node, Generic[T]):
if len(rts.root_nodes) == 1 and parent_resource is not None: if len(rts.root_nodes) == 1 and parent_resource is not None:
plr_instance = plr_instances[0] plr_instance = plr_instances[0]
if isinstance(plr_instance, Plate): 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: 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) ADD_LIQUID_TYPE = ADD_LIQUID_TYPE * len(LIQUID_INPUT_SLOT)
LIQUID_VOLUME = LIQUID_VOLUME * len(LIQUID_INPUT_SLOT) LIQUID_VOLUME = LIQUID_VOLUME * len(LIQUID_INPUT_SLOT)
self.lab_logger().warning( self.lab_logger().warning(
f"增加液体资源时数量为1自动补全为 {len(LIQUID_INPUT_SLOT)}" 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: try:
# noinspection PyProtectedMember # noinspection PyProtectedMember
keys = list(plr_instance._ordering.keys()) keys = list(plr_instance._ordering.keys())
@@ -511,6 +603,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
input_wells = [] input_wells = []
for r in LIQUID_INPUT_SLOT: for r in LIQUID_INPUT_SLOT:
input_wells.append(plr_instance.children[r]) 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( final_response["liquid_input_resource_tree"] = ResourceTreeSet.from_plr_resources(
input_wells input_wells
).dump() ).dump()
@@ -529,12 +625,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
Coordinate(location["x"], location["y"], location["z"]), Coordinate(location["x"], location["y"], location["z"]),
**other_calling_param, **other_calling_param,
) )
# 调整了液体以及Deck之后要重新Assign
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
rts_with_parent = ResourceTreeSet.from_plr_resources([parent_resource]) rts_with_parent = ResourceTreeSet.from_plr_resources([plr_instance])
if rts_with_parent.root_nodes[0].res_content.uuid_parent is None: if rts_with_parent.root_nodes[0].res_content.uuid_parent is None:
rts_with_parent.root_nodes[0].res_content.parent_uuid = self.uuid rts_with_parent.root_nodes[0].res_content.parent_uuid = self.uuid
request.command = json.dumps( request.command = _fast_dumps_str(
{ {
"action": "add", "action": "add",
"data": { "data": {
@@ -544,11 +639,22 @@ class BaseROS2DeviceNode(Node, Generic[T]):
}, },
} }
) )
# _t4 = time.time()
# self.lab_logger().debug(
# f"[AR:{_ar_tag}] 二次上传序列化 {_n_parent}节点 {((_t4 - _t3) * 1000):.0f}ms, 发送中..."
# )
tree_response: SerialCommand.Response = await client.call_async(request) 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.resource_tracker.loop_update_uuid(input_resources, uuid_maps)
self._lab_logger.info(f"Resource tree added. UUID mapping: {len(uuid_maps)} nodes") # self._lab_logger.info(
# 这里created_resources不包含parent_resource # f"[AR:{_ar_tag}] 二次上传完成 HTTP={(_t5 - _t4) * 1000:.0f}ms "
# f"UUID映射={len(uuid_maps)}节点 总执行={(_t5 - _t0) * 1000:.0f}ms"
# )
# 发送给ResourceMeshManager # 发送给ResourceMeshManager
action_client = ActionClient( action_client = ActionClient(
self, self,
@@ -685,7 +791,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
) )
# 发送请求并等待响应 # 发送请求并等待响应
response: SerialCommand_Response = await self._resource_clients["resource_get"].call_async(r) response: SerialCommand_Response = await self._resource_clients["resource_get"].call_async(r)
if not response.response:
raise ValueError(f"查询资源 {resource_id} 失败:服务端返回空响应")
raw_data = json.loads(response.response) raw_data = json.loads(response.response)
if not raw_data:
raise ValueError(f"查询资源 {resource_id} 失败:返回数据为空")
# 转换为 PLR 资源 # 转换为 PLR 资源
tree_set = ResourceTreeSet.from_raw_dict_list(raw_data) tree_set = ResourceTreeSet.from_raw_dict_list(raw_data)
@@ -1134,7 +1244,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
if uid is None: if uid is None:
raise ValueError(f"目标物料{target_resource}没有unilabos_uuid属性无法转运") raise ValueError(f"目标物料{target_resource}没有unilabos_uuid属性无法转运")
target_uids.append(uid) target_uids.append(uid)
srv_address = f"/srv{target_device_id}/s2c_resource_tree" _ns = target_device_id if target_device_id.startswith("/devices/") else f"/devices/{target_device_id.lstrip('/')}"
srv_address = f"/srv{_ns}/s2c_resource_tree"
sclient = self.create_client(SerialCommand, srv_address) sclient = self.create_client(SerialCommand, srv_address)
# 等待服务可用(设置超时) # 等待服务可用(设置超时)
if not sclient.wait_for_service(timeout_sec=5.0): if not sclient.wait_for_service(timeout_sec=5.0):
@@ -1184,7 +1295,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
return False return False
time.sleep(0.05) time.sleep(0.05)
self.lab_logger().info(f"资源本地增加到{target_device_id}结果: {response.response}") self.lab_logger().info(f"资源本地增加到{target_device_id}结果: {response.response}")
return None return "转运完成"
def register_device(self): def register_device(self):
"""向注册表中注册设备信息""" """向注册表中注册设备信息"""
@@ -1256,9 +1367,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
return self._lab_logger return self._lab_logger
def create_ros_publisher(self, attr_name, msg_type, initial_period=5.0): def create_ros_publisher(self, attr_name, msg_type, initial_period=5.0):
"""创建ROS发布者,仅当方法/属性有 @topic_config 装饰器时才创建""" """创建ROS发布者。已在 status_types 中声明的属性直接创建;@topic_config 用于覆盖默认参数"""
# 检测 @topic_config 装饰器配置 topic_cfg = {}
topic_config = {}
driver_class = type(self.driver_instance) driver_class = type(self.driver_instance)
# 区分 @property 和普通方法两种情况 # 区分 @property 和普通方法两种情况
@@ -1267,23 +1377,17 @@ class BaseROS2DeviceNode(Node, Generic[T]):
) )
if is_prop: if is_prop:
# @property: 检测 fget 上的 @topic_config
class_attr = getattr(driver_class, attr_name) class_attr = getattr(driver_class, attr_name)
if class_attr.fget is not None: if class_attr.fget is not None:
topic_config = get_topic_config(class_attr.fget) topic_cfg = get_topic_config(class_attr.fget)
else: else:
# 普通方法: 直接检测 attr_name 方法上的 @topic_config
if hasattr(self.driver_instance, attr_name): if hasattr(self.driver_instance, attr_name):
method = getattr(self.driver_instance, attr_name) method = getattr(self.driver_instance, attr_name)
if callable(method): if callable(method):
topic_config = get_topic_config(method) topic_cfg = get_topic_config(method)
# 没有 @topic_config 装饰器则跳过发布
if not topic_config:
return
# 发布名称优先级: @topic_config(name=...) > get_ 前缀去除 > attr_name # 发布名称优先级: @topic_config(name=...) > get_ 前缀去除 > attr_name
cfg_name = topic_config.get("name") cfg_name = topic_cfg.get("name")
if cfg_name: if cfg_name:
publish_name = cfg_name publish_name = cfg_name
elif attr_name.startswith("get_"): elif attr_name.startswith("get_"):
@@ -1291,10 +1395,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
else: else:
publish_name = attr_name publish_name = attr_name
# 使用装饰器配置或默认值 # @topic_config 参数覆盖默认值
cfg_period = topic_config.get("period") cfg_period = topic_cfg.get("period")
cfg_print = topic_config.get("print_publish") cfg_print = topic_cfg.get("print_publish")
cfg_qos = topic_config.get("qos") cfg_qos = topic_cfg.get("qos")
period: float = cfg_period if cfg_period is not None else initial_period 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 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 qos: int = cfg_qos if cfg_qos is not None else 10
@@ -1486,13 +1590,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
if uuid_indices: if uuid_indices:
uuids = [item[1] for item in uuid_indices] uuids = [item[1] for item in uuid_indices]
resource_tree = await self.get_resource(uuids) 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): for i, (idx, _, resource_data) in enumerate(uuid_indices):
try: plr_resource = plr_resources[i]
plr_resource = plr_resources[i]
except Exception as e:
self.lab_logger().error(f"资源查询结果: 共 {len(queried_resources)} 个资源,但查询结果只有 {len(plr_resources)} 个资源,索引为 {i} 的资源不存在")
raise e
if "sample_id" in resource_data: if "sample_id" in resource_data:
plr_resource.unilabos_extra[EXTRA_SAMPLE_UUID] = resource_data["sample_id"] plr_resource.unilabos_extra[EXTRA_SAMPLE_UUID] = resource_data["sample_id"]
queried_resources[idx] = plr_resource queried_resources[idx] = plr_resource
@@ -1580,37 +1680,75 @@ class BaseROS2DeviceNode(Node, Generic[T]):
feedback_msg_types = action_type.Feedback.get_fields_and_field_types() feedback_msg_types = action_type.Feedback.get_fields_and_field_types()
result_msg_types = action_type.Result.get_fields_and_field_types() result_msg_types = action_type.Result.get_fields_and_field_types()
while future is not None and not future.done(): # 低频 feedback timer10s不阻塞完成检测
if goal_handle.is_cancel_requested: _feedback_timer = None
self.lab_logger().info(f"取消动作: {action_name}")
future.cancel() # 尝试取消线程池中的任务
goal_handle.canceled()
return action_type.Result()
self._time_spent = time.time() - time_start def _publish_feedback():
self._time_remaining = time_overall - self._time_spent if future is not None and not future.done():
self._time_spent = time.time() - time_start
self._time_remaining = time_overall - self._time_spent
feedback_values = {}
for msg_name, attr_name in action_value_mapping["feedback"].items():
if hasattr(self.driver_instance, f"get_{attr_name}"):
method = getattr(self.driver_instance, f"get_{attr_name}")
if not asyncio.iscoroutinefunction(method):
feedback_values[msg_name] = method()
elif hasattr(self.driver_instance, attr_name):
feedback_values[msg_name] = getattr(self.driver_instance, attr_name)
if self._print_publish:
self.lab_logger().info(f"反馈: {feedback_values}")
feedback_msg = convert_to_ros_msg_with_mapping(
ros_msg_type=action_type.Feedback(),
obj=feedback_values,
value_mapping=action_value_mapping["feedback"],
)
goal_handle.publish_feedback(feedback_msg)
# 发布反馈 if action_value_mapping.get("feedback"):
feedback_values = {} _fb_interval = action_value_mapping.get("feedback_interval", 0.5)
for msg_name, attr_name in action_value_mapping["feedback"].items(): _feedback_timer = self.create_timer(
if hasattr(self.driver_instance, f"get_{attr_name}"): _fb_interval, _publish_feedback, callback_group=self.callback_group
method = getattr(self.driver_instance, f"get_{attr_name}")
if not asyncio.iscoroutinefunction(method):
feedback_values[msg_name] = method()
elif hasattr(self.driver_instance, attr_name):
feedback_values[msg_name] = getattr(self.driver_instance, attr_name)
if self._print_publish:
self.lab_logger().info(f"反馈: {feedback_values}")
feedback_msg = convert_to_ros_msg_with_mapping(
ros_msg_type=action_type.Feedback(),
obj=feedback_values,
value_mapping=action_value_mapping["feedback"],
) )
goal_handle.publish_feedback(feedback_msg) # 等待 action 完成
time.sleep(0.5) if future is not None:
if isinstance(future, Task):
# rclpy Task直接 await完成瞬间唤醒
try:
_raw_result = await future
except Exception as e:
_raw_result = e
else:
# concurrent.futures.Future同步 action用 rclpy 兼容的轮询
_poll_future = Future()
def _on_sync_done(fut):
if not _poll_future.done():
_poll_future.set_result(None)
future.add_done_callback(_on_sync_done)
await _poll_future
try:
_raw_result = future.result()
except Exception as e:
_raw_result = e
# 确保 execution_error/success 被正确设置(不依赖 done callback 时序)
if isinstance(_raw_result, BaseException):
if not execution_error:
execution_error = traceback.format_exception(
type(_raw_result), _raw_result, _raw_result.__traceback__
)
execution_error = "".join(execution_error)
execution_success = False
action_return_value = _raw_result
elif not execution_error:
execution_success = True
action_return_value = _raw_result
# 清理 feedback timer
if _feedback_timer is not None:
_feedback_timer.cancel()
if future is not None and future.cancelled(): if future is not None and future.cancelled():
self.lab_logger().info(f"动作 {action_name} 已取消") self.lab_logger().info(f"动作 {action_name} 已取消")
@@ -1619,8 +1757,12 @@ class BaseROS2DeviceNode(Node, Generic[T]):
# self.lab_logger().info(f"动作执行完成: {action_name}") # self.lab_logger().info(f"动作执行完成: {action_name}")
del future del future
# 执行失败时跳过物料状态更新
if execution_error:
execution_success = False
# 向Host更新物料当前状态 # 向Host更新物料当前状态
if action_name not in ["create_resource_detailed", "create_resource"]: if not execution_error and action_name not in ["create_resource_detailed", "create_resource"]:
for k, v in goal.get_fields_and_field_types().items(): for k, v in goal.get_fields_and_field_types().items():
if v not in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]: if v not in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]:
continue continue
@@ -1676,7 +1818,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
for attr_name in result_msg_types.keys(): for attr_name in result_msg_types.keys():
if attr_name in ["success", "reached_goal"]: if attr_name in ["success", "reached_goal"]:
setattr(result_msg, attr_name, True) setattr(result_msg, attr_name, execution_success)
elif attr_name == "return_info": elif attr_name == "return_info":
setattr( setattr(
result_msg, result_msg,
@@ -1742,10 +1884,25 @@ class BaseROS2DeviceNode(Node, Generic[T]):
try: try:
function_args[arg_name] = self._convert_resources_sync(resource_data["uuid"])[0] function_args[arg_name] = self._convert_resources_sync(resource_data["uuid"])[0]
except Exception as e: except Exception as e:
self.lab_logger().error( # UUID 在资源树中不存在,尝试从传入的完整 dict 直接构建 PLR 资源
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:
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 列表 # 处理 ResourceSlot 列表
elif isinstance(arg_type, tuple) and len(arg_type) == 2: elif isinstance(arg_type, tuple) and len(arg_type) == 2:
@@ -1757,10 +1914,25 @@ class BaseROS2DeviceNode(Node, Generic[T]):
uuids = [r["uuid"] for r in resource_list if isinstance(r, dict) and "id" in r] 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 [] function_args[arg_name] = self._convert_resources_sync(*uuids) if uuids else []
except Exception as e: except Exception as e:
self.lab_logger().error( self.lab_logger().warning(
f"转换ResourceSlot列表参数 {arg_name} 失败: {e}\n{traceback.format_exc()}" 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: 默认反报送 # todo: 默认反报送
return function(**function_args) return function(**function_args)
@@ -1782,7 +1954,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
raise ValueError("至少需要提供一个 UUID") raise ValueError("至少需要提供一个 UUID")
uuids_list = list(uuids) uuids_list = list(uuids)
future = self._resource_clients["c2s_update_resource_tree"].call_async( future: Future = self._resource_clients["c2s_update_resource_tree"].call_async(
SerialCommand.Request( SerialCommand.Request(
command=json.dumps( command=json.dumps(
{ {
@@ -1808,6 +1980,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
raise Exception(f"资源查询返回空结果: {uuids_list}") raise Exception(f"资源查询返回空结果: {uuids_list}")
raw_data = json.loads(response.response) raw_data = json.loads(response.response)
if not raw_data:
raise Exception(f"资源原始查询返回空结果: {raw_data}")
# 转换为 PLR 资源 # 转换为 PLR 资源
tree_set = ResourceTreeSet.from_raw_dict_list(raw_data) tree_set = ResourceTreeSet.from_raw_dict_list(raw_data)
@@ -1829,10 +2003,15 @@ class BaseROS2DeviceNode(Node, Generic[T]):
mapped_plr_resources = [] mapped_plr_resources = []
for uuid in uuids_list: for uuid in uuids_list:
found = None
for plr_resource in figured_resources: for plr_resource in figured_resources:
r = self.resource_tracker.loop_find_with_uuid(plr_resource, uuid) r = self.resource_tracker.loop_find_with_uuid(plr_resource, uuid)
mapped_plr_resources.append(r) if r is not None:
break found = r
break
if found is None:
raise Exception(f"未能在已解析的资源树中找到 uuid={uuid} 对应的资源")
mapped_plr_resources.append(found)
return mapped_plr_resources return mapped_plr_resources
@@ -1925,16 +2104,27 @@ class BaseROS2DeviceNode(Node, Generic[T]):
f"执行动作时JSON缺少function_name或function_args: {ex}\n原JSON: {string}\n{traceback.format_exc()}" f"执行动作时JSON缺少function_name或function_args: {ex}\n原JSON: {string}\n{traceback.format_exc()}"
) )
async def _convert_resource_async(self, resource_data: Dict[str, Any]): async def _convert_resource_async(self, resource_data: "ResourceDictType"):
"""异步转换资源数据为实例""" """异步转换 ResourceDictType 为 PLR 实例,优先用 uuid 查询"""
# 使用封装的get_resource_with_dir方法获取PLR资源 unilabos_uuid = resource_data.get("uuid")
plr_resource = await self.get_resource_with_dir(resource_ids=resource_data["id"], with_children=True)
if unilabos_uuid:
resource_tree = await self.get_resource([unilabos_uuid], with_children=True)
plr_resources = resource_tree.to_plr_resources()
if plr_resources:
plr_resource = plr_resources[0]
else:
raise ValueError(f"通过 uuid={unilabos_uuid} 查询资源为空")
else:
res_id = resource_data.get("id") or resource_data.get("name", "")
if not res_id:
raise ValueError(f"资源数据缺少 uuid 和 id: {list(resource_data.keys())}")
plr_resource = await self.get_resource_with_dir(resource_id=res_id, with_children=True)
# 通过资源跟踪器获取本地实例 # 通过资源跟踪器获取本地实例
res = self.resource_tracker.figure_resource(plr_resource, try_mode=True) res = self.resource_tracker.figure_resource(plr_resource, try_mode=True)
if len(res) == 0: if len(res) == 0:
# todo: 后续通过decoration来区分减少warning self.lab_logger().warning(f"资源转换未能索引到实例: {resource_data.get('id', '?')},返回新建实例")
self.lab_logger().warning(f"资源转换未能索引到实例: {resource_data},返回新建实例")
return plr_resource return plr_resource
elif len(res) == 1: elif len(res) == 1:
return res[0] return res[0]

View File

@@ -4,6 +4,8 @@ import threading
import time import time
import traceback import traceback
import uuid import uuid
from unilabos.utils.tools import fast_dumps_str as _fast_dumps_str, fast_loads as _fast_loads
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Optional, Dict, Any, List, ClassVar, Set, Union from typing import TYPE_CHECKING, Optional, Dict, Any, List, ClassVar, Set, Union
@@ -570,6 +572,102 @@ class HostNode(BaseROS2DeviceNode):
responses.append(response.response) responses.append(response.response)
return responses return responses
def _lookup_deck_for_slot(self, device_id: str, deck_id: str):
"""根据 device_id / deck_id 查找 deck PLR 实例,找不到返回 None。
优先级:
1. ``devices_instances[device_id]`` 上对应 driver 的 ``deck`` 属性PLR LiquidHandler 的标准属性)
2. driver / wrapper / _ros_node 各级 resource_tracker.figure_resource({"id": deck_id})
3. host 自己的 ``_resource_tracker``
"""
log = self.lab_logger()
def _try_tracker(tracker, src_desc: str):
if tracker is None:
log.debug(f"[Host Node] _lookup_deck: {src_desc} tracker 为 None跳过")
return None
try:
matches = tracker.figure_resource({"id": deck_id}, try_mode=True)
except Exception as e:
log.warning(f"[Host Node] _lookup_deck: {src_desc}.figure_resource({deck_id}) 失败: {e}")
return None
if isinstance(matches, list) and matches:
obj = next((m for m in matches if not isinstance(m, dict)), matches[0])
if obj is not None and not isinstance(obj, dict):
log.debug(f"[Host Node] _lookup_deck: 命中 via {src_desc} -> {type(obj).__name__}")
return obj
log.debug(f"[Host Node] _lookup_deck: {src_desc} figure_resource 未命中 (matches={matches!r})")
return None
# 1) 先按 device_id 拿 driver 上的 deck
candidate_ids = []
if device_id:
candidate_ids.append(device_id)
stripped = device_id.lstrip("/")
if stripped and stripped != device_id:
candidate_ids.append(stripped)
tail = device_id.split("/")[-1]
if tail and tail not in candidate_ids:
candidate_ids.append(tail)
d = None
for did in candidate_ids:
d = self.devices_instances.get(did)
if d is not None:
break
if d is None:
log.warning(
f"[Host Node] _lookup_deck: devices_instances 找不到 device_id={device_id!r} "
f"(尝试过 {candidate_ids}); 当前已知: {list(self.devices_instances.keys())}"
)
else:
# 真正的 driver 在 wrapper 的 _driver_instance / _ros_node.driver_instance 上
driver_candidates = []
for attr_path in ("_driver_instance", "_ros_node.driver_instance", "driver_instance"):
obj = d
for part in attr_path.split("."):
obj = getattr(obj, part, None)
if obj is None:
break
if obj is not None and obj not in driver_candidates:
driver_candidates.append(obj)
for drv in driver_candidates:
deck = getattr(drv, "deck", None)
if deck is not None:
deck_name = getattr(deck, "name", None)
if deck_name == deck_id:
log.debug(
f"[Host Node] _lookup_deck: 命中 via {type(drv).__name__}.deck (name={deck_name})"
)
return deck
log.debug(
f"[Host Node] _lookup_deck: {type(drv).__name__}.deck.name={deck_name!r}{deck_id!r} 不一致"
)
# 退化:从 wrapper / _ros_node 的 resource_tracker 找
tracker_paths = (
"resource_tracker",
"_ros_node.resource_tracker",
)
for attr_path in tracker_paths:
tracker = d
for part in attr_path.split("."):
tracker = getattr(tracker, part, None)
if tracker is None:
break
obj = _try_tracker(tracker, f"device({device_id}).{attr_path}")
if obj is not None:
return obj
# 2) host 自己的 tracker一般为空因为 init 时 device 树被 continue 了)
host_tracker = getattr(self, "resource_tracker", None) or getattr(self, "_resource_tracker", None)
obj = _try_tracker(host_tracker, "host._resource_tracker")
if obj is not None:
return obj
return None
async def create_resource( async def create_resource(
self, self,
device_id: DeviceSlot, device_id: DeviceSlot,
@@ -583,6 +681,30 @@ class HostNode(BaseROS2DeviceNode):
slot_on_deck: str = "", slot_on_deck: str = "",
) -> CreateResourceReturn: ) -> CreateResourceReturn:
# 暂不支持多对同名父子同时存在 # 暂不支持多对同名父子同时存在
# 如果 slot_on_deck 不是空,并且 bind_locations 全为 0则尝试通过 deck 的 slot 信息推算真实坐标
if slot_on_deck and (
(not hasattr(bind_locations, "x") or bind_locations.x == 0)
and (not hasattr(bind_locations, "y") or bind_locations.y == 0)
and (not hasattr(bind_locations, "z") or bind_locations.z == 0)
):
# 尝试通过 parent (deck) 查找 slot 坐标parent 应是deck的id
deck_id = parent.split("/")[-1]
deck_obj = self._lookup_deck_for_slot(device_id, deck_id)
if deck_obj is not None and hasattr(deck_obj, "get_slot_location"):
try:
slot_location = deck_obj.get_slot_location(slot_on_deck)
bind_locations.x = slot_location.x
bind_locations.y = slot_location.y
bind_locations.z = slot_location.z
except Exception as e:
self.lab_logger().warning(
f"[Host Node] 无法通过deck({deck_id})获取slot({slot_on_deck})位置: {e}"
)
else:
self.lab_logger().warning(
f"[Host Node] 找不到deck对象({deck_id})或其不支持get_slot_location, 无法修正bind_locations"
)
res_creation_input = { res_creation_input = {
"id": res_id.split("/")[-1], "id": res_id.split("/")[-1],
"name": res_id.split("/")[-1], "name": res_id.split("/")[-1],
@@ -608,6 +730,45 @@ class HostNode(BaseROS2DeviceNode):
} }
) )
init_new_res = initialize_resource(res_creation_input) # flatten的格式 init_new_res = initialize_resource(res_creation_input) # flatten的格式
# 若 init_new_res 中节点的 pose.position 与 pose.position3d 同时全为 0
# 用上面通过 deck slot 反查得到的 bind_locations 覆盖(位置仍可能是默认 0
bind_xyz = {
"x": float(getattr(bind_locations, "x", 0) or 0),
"y": float(getattr(bind_locations, "y", 0) or 0),
"z": float(getattr(bind_locations, "z", 0) or 0),
}
if any(v != 0.0 for v in bind_xyz.values()):
def _is_zero_xyz(p):
if not isinstance(p, dict):
return False
return (
float(p.get("x", 0) or 0) == 0.0
and float(p.get("y", 0) or 0) == 0.0
and float(p.get("z", 0) or 0) == 0.0
)
def _patch_node(node):
if not isinstance(node, dict):
return
pose = node.get("pose")
if not isinstance(pose, dict):
return
pos = pose.get("position")
pos3d = pose.get("position3d")
if _is_zero_xyz(pos) and _is_zero_xyz(pos3d):
pose["position"] = dict(bind_xyz)
pose["position3d"] = dict(bind_xyz)
def _walk(obj):
if isinstance(obj, dict):
_patch_node(obj)
elif isinstance(obj, list):
for item in obj:
_walk(item)
_walk(init_new_res)
if len(init_new_res) > 1: # 一个物料,多个子节点 if len(init_new_res) > 1: # 一个物料,多个子节点
init_new_res = [init_new_res] init_new_res = [init_new_res]
resources: List[Resource] | List[List[Resource]] = init_new_res # initialize_resource已经返回list[dict] resources: List[Resource] | List[List[Resource]] = init_new_res # initialize_resource已经返回list[dict]
@@ -625,22 +786,17 @@ class HostNode(BaseROS2DeviceNode):
} }
) )
] ]
response: List[str] = await self.create_resource_detailed( response: List[str] = await self.create_resource_detailed(
resources, device_ids, bind_parent_id, bind_location, other_calling_param resources, device_ids, bind_parent_id, bind_location, other_calling_param
) )
try: assert len(response) == 1, "Create Resource应当只返回一个结果"
assert len(response) == 1, "Create Resource应当只返回一个结果" for i in response:
for i in response: res = json.loads(i)
res = json.loads(i) if "suc" in res and not res["suc"]:
if "suc" in res: raise ValueError(res.get("error", "未知错误"))
raise ValueError(res.get("error")) return res
return res raise ValueError(f"创建资源时失败!响应为空")
except Exception as ex:
pass
_n = "\n"
raise ValueError(f"创建资源时失败!\n{_n.join(response)}")
def initialize_device(self, device_id: str, device_config: ResourceDictInstance) -> None: def initialize_device(self, device_id: str, device_config: ResourceDictInstance) -> None:
""" """
@@ -1196,7 +1352,7 @@ class HostNode(BaseROS2DeviceNode):
else: else:
physical_setup_graph.nodes[resource_dict["id"]]["data"].update(resource_dict.get("data", {})) physical_setup_graph.nodes[resource_dict["id"]]["data"].update(resource_dict.get("data", {}))
response.response = json.dumps(uuid_mapping) if success else "FAILED" response.response = _fast_dumps_str(uuid_mapping) if success else "FAILED"
self.lab_logger().info(f"[Host Node-Resource] Resource tree add completed, success: {success}") self.lab_logger().info(f"[Host Node-Resource] Resource tree add completed, success: {success}")
if success: if success:
@@ -1212,6 +1368,7 @@ class HostNode(BaseROS2DeviceNode):
resource_response = http_client.resource_tree_get(uuid_list, with_children) resource_response = http_client.resource_tree_get(uuid_list, with_children)
response.response = json.dumps(resource_response) response.response = json.dumps(resource_response)
self.lab_logger().trace(f"[Host Node-Resource] Resource tree get request callback {response.response}")
async def _resource_tree_action_remove_callback(self, data: dict, response: SerialCommand_Response): async def _resource_tree_action_remove_callback(self, data: dict, response: SerialCommand_Response):
""" """
@@ -1270,9 +1427,26 @@ class HostNode(BaseROS2DeviceNode):
""" """
try: try:
# 解析请求数据 # 解析请求数据
data = json.loads(request.command) data = _fast_loads(request.command)
action = data["action"] action = data["action"]
self.lab_logger().info(f"[Host Node-Resource] Resource tree {action} request received") inner = data.get("data", {})
if action == "add":
mount_uuid = inner.get("mount_uuid", "?")[:8] if isinstance(inner, dict) else "?"
tree_data = inner.get("data", []) if isinstance(inner, dict) else inner
node_count = len(tree_data) if isinstance(tree_data, list) else "?"
source = f"mount={mount_uuid}.. nodes≈{node_count}"
elif action in ("get", "remove"):
uid_list = inner.get("data", inner) if isinstance(inner, dict) else inner
source = f"uuids={len(uid_list) if isinstance(uid_list, list) else '?'}"
elif action == "update":
tree_data = inner.get("data", []) if isinstance(inner, dict) else inner
node_count = len(tree_data) if isinstance(tree_data, list) else "?"
source = f"nodes≈{node_count}"
else:
source = ""
self.lab_logger().info(
f"[Host Node-Resource] Resource tree {action} request received ({source})"
)
data = data["data"] data = data["data"]
if action == "add": if action == "add":
await self._resource_tree_action_add_callback(data, response) await self._resource_tree_action_add_callback(data, response)

View File

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

View File

@@ -21,13 +21,20 @@
}, },
"host": "10.20.30.184", "host": "10.20.30.184",
"port": 9999, "port": 9999,
"debug": true, "debug": false,
"setup": true, "setup": false,
"is_9320": true, "is_9320": true,
"timeout": 10, "timeout": 10,
"matrix_id": "5de524d0-3f95-406c-86dd-f83626ebc7cb", "matrix_id": "",
"simulator": true, "simulator": false,
"channel_num": 2 "channel_num": 2,
"step_mode": false,
"calibration_points": {
"line_1": [[452.07,21.19], [313.88,21.19], [177.17,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": { "data": {
"reset_ok": true "reset_ok": true
@@ -49,8 +56,8 @@
"type": "deck", "type": "deck",
"class": "", "class": "",
"position": { "position": {
"x": 10, "x": 0,
"y": 10, "y": 0,
"z": 0 "z": 0
}, },
"config": { "config": {
@@ -66,426 +73,7 @@
}, },
"category": "deck", "category": "deck",
"barcode": null, "barcode": null,
"preferred_pickup_location": 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"
]
}
]
}, },
"data": {} "data": {}
} }

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