Compare commits

...

40 Commits

Author SHA1 Message Date
yxz321
45b9fd5262 plan: add resource system guidance 2026-05-20 19:17:48 +08:00
yxz321
a1c0b83490 fix: change reset and submit_experiment* from manual confirm to normal node 2026-05-15 15:25:54 +08:00
yxz321
570d6763c0 Fix: update sirna_station to support new flexible resultTable 2026-05-15 14:01:34 +08:00
yxz321
633c8b3d2c feat: RNA add Sirna experiment controls and workflow bindings
- Add bound Sirna workflow names for experiment 1 and experiment 2 submissions.
- Route named workflow submission through a shared create-order core with minimal param payloads.
- Add direct scheduler controls and cancel/take-out manual confirmation handling.
2026-05-12 19:35:39 +08:00
yxz321
e6ee6fc964 fix: sync Bioyond materials over stale slot placeholders 2026-05-12 19:35:39 +08:00
yxz321
765342c4ff feat: RNA aggregate Bioyond LIMS reports
- Add get_order_report and frontend-like get_aggregated_order_report actions for Sirna LIMS debugging.
- Preserve raw checked-section LIMS responses, including collapsed error envelopes, while allowing partial aggregate success.
- Add order-id matching safeguards, aggregate report tests, and scaffold assertions for the new manual-confirm actions.
- Keep Sirna graph debug logging enabled for report/live-probe troubleshooting.
2026-05-12 19:35:39 +08:00
yxz321
3fc94c6720 feat: RNA refine Sirna material sync and placement
- Publish resource tree updates after shared Bioyond external material sync succeeds.
- Keep start_experiment scheduler startup non-blocking while reporting unchecked manual load gates.
- Prefer Bioyond barCode as PLR material code and add safer occupied-slot diagnostics for warehouse placement.
2026-05-12 19:35:39 +08:00
yxz321
d5f0bca643 fix: RNA use preexisting sync_from_external() from base class.
Leave _register_materials_to_tree() commented
2026-05-12 19:35:39 +08:00
yxz321
de51b19e88 fix: RNA restrore manual-confirm table 2026-05-12 19:35:39 +08:00
yxz321
6b94bdd2da fix: align Bioyond deck warehouse axes
- Preserve Sirna col-row labels while flipping visual stack dimensions.
- Rebuild Peptide deck warehouses from live API slot geometry and avoid initial graph overlap.
- Add Peptide deck layout tests and keep Sirna resource tests passing.
2026-05-12 19:35:39 +08:00
yxz321
d009863c8c feat: RNA add guided manual unload end_experiment action
- Add end_experiment manual_confirm action mirroring start_experiment, with three boolean operator gates and twelve EXECUTOR sibling-array output handles for unloaded material manifests.
- Add helpers _build_unload_materials_by_type, _classify_labware_mode, _iter_reagent_liquids, and _clear_unloaded_materials.
- Clear unloaded slots and zero reagent liquid contents on confirmation while preserving trough labware; publish single resource tree update after mutations.
- Wrap action body in _debug_call_session("end_experiment") for opt-in raw call capture.
2026-05-12 19:35:39 +08:00
yxz321
cae828ce74 feat: RNA add per-action raw HTTP call log for Bioyond station
- Add debug_call_log module: contextvar-scoped session(), idempotent wrap_rpc_http, lazy markdown writer with apiKey redaction, source attribution via stack walk, raw_text fallback.
- Centralize Bioyond RPC creation in BioyondWorkstation via _create_bioyond_rpc / _set_hardware_interface / _debug_call_session helpers and _DEBUG_LOG_DEFAULT_DIR.
- Wrap reset, submit_experiment_1, start_experiment, get_order_list action bodies in _debug_call_session for opt-in per-action capture.
- ConnectionMonitor polling stays outside debug sessions and is not logged.
2026-05-12 19:35:39 +08:00
yxz321
5b9f77e81f feat: RNA simplify station action signatures and add order query
- Add explicit reset_operations default; keep hidden plumbing kwargs-only.
- Add read-only get_order_list manual_confirm action that calls order_query with latest_only and exposes order_id/order_ids handles.
- Collapse start_experiment visible signature to (order_id, materials_loaded, timeout_seconds, assignee_user_ids); legacy params consumed from kwargs to preserve runtime contract.
- Reduce submit_experiment_1 graph handles to order_id, resource, coin_cell_code, mount_resource; result dict gains order_id while keeping existing keys.
2026-05-12 19:35:39 +08:00
yxz321
7c83e1bd51 feat: RNA land resource-system mega plan Phases 1-4 plus Phase 5 stack orientation
- Phase 1: fix _publish_resource_tree_update to call update_resource via run_async_func with deck resource list.
- Phase 2: add ID-first material placement resolver chain with material_info and warehouse_inventory caches.
- Phase 3: classify stock-material rows as slot_labware vs liquid_content; idempotent reagent attachment by Bioyond materialId.
- Phase 4: introduce SirnaResourceSynchronizer over BioyondResourceSynchronizer; install once in post_init without double-sync.
- Phase 5: numeric stack orientation and bioyond_axis support in bioyond warehouses/__init__/decks (carries forward prior in-progress edits).
- _resolve_location_to_warehouse now raises on ambiguity; deck constructor accepts warehouse_bioyond_ids kwarg.
2026-05-12 19:35:39 +08:00
yxz321
1f93740580 fix: add assert for convenient API call only when DEBUG_CLI_ENABLED 2026-05-12 19:35:39 +08:00
yxz321
98c27cde40 feat: RNA add guided siRNA manual load gate
- Expose siRNA order and material handles for manual-confirm load workflows.
- Gate scheduler start on explicit material-load confirmation before calling Bioyond RPC.
- Improve lazy API config diagnostics and Sirna warehouse/material resource handling.
2026-05-12 19:35:39 +08:00
yxz321
18c3263e92 feat: RNA refine Bioyond siRNA Experiment 1 submission 2026-05-12 19:35:39 +08:00
yxz321
1519a7d985 feat: RNA add Bioyond siRNA station resources and Experiment 1 submission
- Add siRNA station runtime, decorator metadata, and lazy init
- Implement Experiment 1 submit, start, and reset flows
- Add siRNA deck and numeric warehouse stack resources
- Move siRNA example config to temp_benyao
2026-05-12 19:35:39 +08:00
yxz321
96c3f5a3e5 feat: RNA. Initial sirna workstation implementation. 2026-05-12 19:35:39 +08:00
Xuwznln
927c7e95f5 fix pack install 2 2026-05-09 01:22:42 +08:00
Xuwznln
16910fe25c fix pip install & git install failed 2026-05-08 23:50:00 +08:00
Xuwznln
c38987d94d fix pack build 1 2026-05-08 23:49:32 +08:00
Junhan Chang
e4132111bc Update SKILL.md 2026-05-08 00:08:04 +08:00
Junhan Chang
211ee3027d Update Skills 2026-05-07 23:01:37 +08:00
Xuwznln
32c195d875 Update registry for all param desc 2026-04-27 20:47:52 +08:00
Xuwznln
f145dc04bb Support display_name & desc in new registry system
(cherry picked from commit f71ea2a258)
2026-04-27 20:28:54 +08:00
Xuwznln
195fad9398 upgrade to 0.11.1 2026-04-22 19:54:16 +08:00
Xuwznln
898ed5d34b use gitee to install pylabrobot
fix virtual import
2026-04-22 19:51:10 +08:00
Xuwznln
60cbedc4b2 v0.11.0
(cherry picked from commit 67a74172dc)
2026-04-22 19:50:42 +08:00
Xuwznln
2d6a9f7db9 fix possible conversion error 2026-04-22 00:09:06 +08:00
Xuwznln
5dca3d8c3d update workbench example 2026-04-15 16:33:43 +08:00
Xuwznln
37cbed722a update aksk desc 2026-04-13 23:17:43 +08:00
Xuwznln
132cffbe7c print res query logs 2026-04-13 20:24:48 +08:00
Xuwznln
36e5ff804c Fix skills exec error with action type 2026-04-13 20:16:00 +08:00
Xuwznln
eaf8ad5609 Fix skills exec error with action type 2026-04-13 17:02:38 +08:00
Xuwznln
16122ad2fa Update Skills 2026-04-13 15:57:50 +08:00
Xuwznln
d3fef85dd8 Update Skills addr 2026-04-13 11:15:35 +08:00
Xuwznln
f77ac2a5e8 Change uni-lab. to leap-lab.
Support unit in pylabrobot
2026-04-12 15:32:27 +08:00
Xuwznln
93ac55a65b Support async func. 2026-04-11 18:13:08 +08:00
Xuwznln
af35debe38 change to leap-lab backend. Support feedback interval. Reduce cocurrent lags. 2026-04-11 06:22:53 +08:00
91 changed files with 11289 additions and 748 deletions

View File

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

View File

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

View File

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

View File

@@ -71,6 +71,22 @@ from unilabos.registry.decorators import action
- `_` 开头的方法 → 不扫描
- `@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
@@ -105,13 +121,27 @@ import logging
from typing import Any, Dict, Optional
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
from unilabos.registry.decorators import device, action, topic_config, not_action
from unilabos.registry.decorators import action, device, not_action, topic_config
@device(id="my_device", category=["my_category"], description="设备描述")
@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}")
@@ -133,7 +163,13 @@ class MyDevice:
@action(description="执行操作")
def my_action(self, param: float = 0.0, name: str = "") -> Dict[str, Any]:
"""带 @action 装饰器 → 注册为 'my_action' 动作"""
"""
带 @action 装饰器 → 注册为 'my_action' 动作。
Args:
param[操作数值]: 操作使用的数值参数。
name[操作名称]: 操作名称或备注。
"""
return {"success": True}
def get_info(self) -> Dict[str, Any]:

View File

@@ -27,14 +27,15 @@ python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{
### 2. --addr → BASE URL
| `--addr` 值 | BASE |
|-------------|------|
| `test` | `https://uni-lab.test.bohrium.com` |
| `uat` | `https://uni-lab.uat.bohrium.com` |
| `local` | `http://127.0.0.1:48197` |
| 不传(默认) | `https://uni-lab.bohrium.com` |
| `--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>"
@@ -65,7 +66,7 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
返回:
```json
{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}}
{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } }
```
记住 `data.uuid``lab_uuid`
@@ -90,6 +91,7 @@ curl -s -X POST "$BASE/api/v1/lab/reagent" \
```
返回成功时包含试剂 UUID
```json
{"code": 0, "data": {"uuid": "xxx", ...}}
```
@@ -98,28 +100,28 @@ curl -s -X POST "$BASE/api/v1/lab/reagent" \
## 试剂字段说明
| 字段 | 类型 | 必填 | 说明 | 示例 |
|------|------|------|------|------|
| `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"` |
| 字段 | 类型 | 必填 | 说明 | 示例 |
| ------------------- | ------ | ---- | ----------------------------- | ------------------------ |
| `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"` | 克 |
| `"L"` | 升 |
| `"g"` | 克 |
| `"kg"` | 千克 |
| `"瓶"` | 瓶 |
| `"瓶"` | 瓶 |
> 根据试剂状态选择:液体用 `"mL"` / `"L"`,固体用 `"g"` / `"kg"`。
@@ -133,8 +135,22 @@ curl -s -X POST "$BASE/api/v1/lab/reagent" \
```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"}
{
"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"
}
]
```
@@ -160,9 +176,20 @@ cas,name,molecular_formula,smiles,stock_in_quantity,unit,supplier,production_dat
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 条」
@@ -172,28 +199,29 @@ cas,name,molecular_formula,smiles,stock_in_quantity,unit,supplier,production_dat
## 常见试剂速查表
| 名称 | CAS | 分子式 | SMILES |
|------|-----|--------|--------|
| 水 | 7732-18-3 | H2O | O |
| 乙醇 | 64-17-5 | C2H6O | CCO |
| 甲醇 | 67-56-1 | CH4O | CO |
| 丙酮 | 67-64-1 | C3H6O | CC(C)=O |
| 二甲基亚砜(DMSO) | 67-68-5 | C2H6OS | CS(C)=O |
| 乙酸乙酯 | 141-78-6 | C4H8O2 | CCOC(C)=O |
| 二氯甲烷 | 75-09-2 | CH2Cl2 | ClCCl |
| 四氢呋喃(THF) | 109-99-9 | C4H8O | C1CCOC1 |
| N,N-二甲基甲酰胺(DMF) | 68-12-2 | C3H7NO | CN(C)C=O |
| 氯仿 | 67-66-3 | CHCl3 | ClC(Cl)Cl |
| 乙腈 | 75-05-8 | C2H3N | CC#N |
| 甲苯 | 108-88-3 | C7H8 | Cc1ccccc1 |
| 正己烷 | 110-54-3 | C6H14 | CCCCCC |
| 异丙醇 | 67-63-0 | C3H8O | CC(C)O |
| 盐酸 | 7647-01-0 | HCl | Cl |
| 酸 | 7664-93-9 | H2SO4 | OS(O)(=O)=O |
| 氢氧化钠 | 1310-73-2 | NaOH | [Na]O |
| 碳酸钠 | 497-19-8 | Na2CO3 | [Na]OC([O-])=O.[Na+] |
| 氯化钠 | 7647-14-5 | NaCl | [Na]Cl |
| 乙二胺四乙酸(EDTA) | 60-00-4 | C10H16N2O8 | OC(=O)CN(CCN(CC(O)=O)CC(O)=O)CC(O)=O |
| 名称 | 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 应根据化学知识推断或提示用户补充。

View File

@@ -1,11 +1,13 @@
---
name: batch-submit-experiment
description: Batch submit experiments (notebooks) to Uni-Lab platform — list workflows, generate node_params from registry schemas, submit multiple rounds, check notebook status. Use when the user wants to submit experiments, create notebooks, batch run workflows, check experiment status, or mentions 提交实验/批量实验/notebook/实验轮次/实验状态.
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 批量提交实验指南
通过云端 API 批量提交实验notebook支持多轮实验参数配置。根据 workflow 模板详情和本地设备注册表自动生成 `node_params` 模板。
通过 Uni-Lab 云端 API 批量提交实验notebook支持多轮实验参数配置。根据 workflow 模板详情和本地设备注册表自动生成 `node_params` 模板。
> **重要**:本指南中的 `Authorization: Lab <token>` 是 **Uni-Lab 平台专用的认证方式**`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。
## 前置条件(缺一不可)
@@ -18,25 +20,28 @@ description: Batch submit experiments (notebooks) to Uni-Lab platform — list w
生成 AUTH token任选一种方式
```bash
# 方式一Python 一行生成
# 方式一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://uni-lab.test.bohrium.com` |
| `uat` | `https://uni-lab.uat.bohrium.com` |
| `local` | `http://127.0.0.1:48197` |
| 不传(默认) | `https://uni-lab.bohrium.com` |
| `--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>"
```
@@ -44,18 +49,19 @@ AUTH="Authorization: Lab <上面命令输出的 token>"
**批量提交实验时需要本地注册表来解析 workflow 节点的参数 schema。**
按优先级搜索
**必须先用 Glob 工具搜索文件**,不要直接猜测路径
```
<workspace 根目录>/unilabos_data/req_device_registry_upload.json
<workspace 根目录>/req_device_registry_upload.json
Glob: **/req_device_registry_upload.json
```
也可直接 Glob 搜索:`**/req_device_registry_upload.json`
常见位置(仅供参考,以 Glob 实际结果为准):
- `<workspace>/unilabos_data/req_device_registry_upload.json`
- `<workspace>/req_device_registry_upload.json`
找到后**检查文件修改时间**并告知用户。超过 1 天提醒用户是否需要重新启动 `unilab`
**如果文件不存在** → 告知用户先运行 `unilab` 启动命令,等注册表生成后再执行。可跳过此步,但将无法自动生成参数模板,需要用户手动填写 `param`
**如果 Glob 搜索无结果** → 告知用户先运行 `unilab` 启动命令,等注册表生成后再执行。可跳过此步,但将无法自动生成参数模板,需要用户手动填写 `param`
### 4. workflow_uuid目标工作流
@@ -93,7 +99,7 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
返回:
```json
{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}}
{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } }
```
记住 `data.uuid``lab_uuid`
@@ -104,9 +110,33 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
curl -s -X GET "$BASE/api/v1/lab/project/list?lab_uuid=$lab_uuid" -H "$AUTH"
```
返回项目列表,展示给用户选择。列出每个项目的 `uuid``name`
返回
用户**必须**选择一个项目,记住 `project_uuid`,后续创建 notebook 时需要提供。
```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
@@ -123,6 +153,7 @@ curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$A
```
返回 workflow 的完整结构,包含所有 action 节点信息。需要从响应中提取:
- 每个 action 节点的 `node_uuid`
- 每个节点对应的设备 ID`resource_template_name`
- 每个节点的动作名(`node_template_name`
@@ -142,30 +173,30 @@ curl -s -X POST "$BASE/api/v1/lab/notebook" \
```json
{
"lab_uuid": "<lab_uuid>",
"project_uuid": "<project_uuid>",
"workflow_uuid": "<workflow_uuid>",
"name": "<实验名称>",
"node_params": [
"lab_uuid": "<lab_uuid>",
"project_uuid": "<project_uuid>",
"workflow_uuid": "<workflow_uuid>",
"name": "<实验名称>",
"node_params": [
{
"sample_uuids": ["<样品UUID1>", "<样品UUID2>"],
"datas": [
{
"sample_uuids": ["<样品UUID1>", "<样品UUID2>"],
"datas": [
{
"node_uuid": "<workflow中的节点UUID>",
"param": {},
"sample_params": [
{
"container_uuid": "<容器UUID>",
"sample_value": {
"liquid_names": "<液体名称>",
"volumes": 1000
}
}
]
}
]
"node_uuid": "<workflow中的节点UUID>",
"param": {},
"sample_params": [
{
"container_uuid": "<容器UUID>",
"sample_value": {
"liquid_names": "<液体名称>",
"volumes": 1000
}
}
]
}
]
]
}
]
}
```
@@ -194,25 +225,25 @@ curl -s -X GET "$BASE/api/v1/lab/notebook/status?uuid=$notebook_uuid" -H "$AUTH"
### 每轮的字段
| 字段 | 类型 | 说明 |
|------|------|------|
| 字段 | 类型 | 说明 |
| -------------- | ------------- | ----------------------------------------- |
| `sample_uuids` | array\<uuid\> | 该轮实验的样品 UUID 数组,无样品时传 `[]` |
| `datas` | array | 该轮中每个 workflow 节点的参数配置 |
| `datas` | array | 该轮中每个 workflow 节点的参数配置 |
### datas 中每个节点
| 字段 | 类型 | 说明 |
|------|------|------|
| `node_uuid` | string | workflow 模板中的节点 UUID从 API #4 获取) |
| `param` | object | 动作参数(根据本地注册表 schema 填写) |
| `sample_params` | array | 样品相关参数(液体名、体积等) |
| 字段 | 类型 | 说明 |
| --------------- | ------ | -------------------------------------------- |
| `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}` |
| 字段 | 类型 | 说明 |
| ---------------- | ------ | ---------------------------------------------------- |
| `container_uuid` | string | 容器 UUID |
| `sample_value` | object | 样品值,如 `{"liquid_names": "水", "volumes": 1000}` |
---
@@ -233,6 +264,7 @@ python scripts/gen_notebook_params.py \
> 脚本位于本文档同级目录下的 `scripts/gen_notebook_params.py`。
脚本会:
1. 调用 workflow detail API 获取所有 action 节点
2. 读取本地注册表,为每个节点查找对应的 action schema
3. 生成 `notebook_template.json`,包含:
@@ -270,8 +302,11 @@ python scripts/gen_notebook_params.py \
"properties": {
"goal": {
"properties": {
"asp_vols": {"type": "array", "items": {"type": "number"}},
"sources": {"type": "array"}
"asp_vols": {
"type": "array",
"items": { "type": "number" }
},
"sources": { "type": "array" }
},
"required": ["asp_vols", "sources"]
}

View File

@@ -7,7 +7,7 @@
选项:
--auth <token> Lab tokenbase64(ak:sk) 的结果,不含 "Lab " 前缀)
--base <url> API 基础 URL如 https://uni-lab.test.bohrium.com
--base <url> API 基础 URL如 https://leap-lab.test.bohrium.com
--workflow-uuid <uuid> 目标 workflow 的 UUID
--registry <path> 本地注册表文件路径(默认自动搜索)
--rounds <n> 实验轮次数(默认 1
@@ -17,7 +17,7 @@
示例:
python gen_notebook_params.py \\
--auth YTFmZDlkNGUtxxxx \\
--base https://uni-lab.test.bohrium.com \\
--base https://leap-lab.test.bohrium.com \\
--workflow-uuid abc-123-def \\
--rounds 2
"""

View File

@@ -40,13 +40,13 @@ python ./scripts/gen_auth.py --config <config.py>
决定 API 请求发往哪个服务器。从启动命令的 `--addr` 参数获取:
| `--addr` 值 | BASE URL |
|-------------|----------|
| `test` | `https://uni-lab.test.bohrium.com` |
| `uat` | `https://uni-lab.uat.bohrium.com` |
| `local` | `http://127.0.0.1:48197` |
| 不传(默认) | `https://uni-lab.bohrium.com` |
| 其他自定义 URL | 直接使用该 URL |
| `--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设备注册表
@@ -54,11 +54,11 @@ python ./scripts/gen_auth.py --config <config.py>
**推断 working_dir**(即 `unilabos_data` 所在目录):
| 条件 | working_dir 取值 |
|------|------------------|
| 条件 | working_dir 取值 |
| -------------------- | -------------------------------------------------------- |
| 传了 `--working_dir` | `<working_dir>/unilabos_data/`(若子目录已存在则直接用) |
| 仅传了 `--config` | `<config 文件所在目录>/unilabos_data/` |
| 都没传 | `<当前工作目录>/unilabos_data/` |
| 仅传了 `--config` | `<config 文件所在目录>/unilabos_data/` |
| 都没传 | `<当前工作目录>/unilabos_data/` |
**按优先级搜索文件**
@@ -84,24 +84,6 @@ python ./scripts/gen_auth.py --config <config.py>
python ./scripts/extract_device_actions.py --registry <找到的文件路径>
```
#### 完整示例
用户提供:
```
--ak a1fd9d4e-xxxx-xxxx-xxxx-d9a69c09f0fd
--sk 136ff5c6-xxxx-xxxx-xxxx-a03e301f827b
--addr test
--port 8003
--disable_browser
```
从中提取:
- ✅ ak/sk → 运行 `gen_auth.py` 得到 `AUTH="Authorization: Lab YTFmZDlk..."`
- ✅ addr=test → `BASE=https://uni-lab.test.bohrium.com`
- ✅ 搜索 `unilabos_data/req_device_registry_upload.json` → 找到并确认时间
- ✅ 用户指明目标设备 → 如 `liquid_handler.prcxi`
**四项全部就绪后才进入 Step 1。**
### Step 1 — 列出可用设备
@@ -129,6 +111,7 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
脚本会显示设备的 Python 源码路径和类名,方便阅读源码了解参数含义。
每个 action 生成一个 JSON 文件,包含:
- `type` — 作为 API 调用的 `action_type`
- `schema` — 完整 JSON Schema`properties.goal.properties` 参数定义)
- `goal` — goal 字段映射(含占位符 `$placeholder`
@@ -136,13 +119,14 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
### Step 3 — 写 action-index.md
按模板为每个 action 写条目:
按模板为每个 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`
@@ -150,6 +134,8 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
```
描述规则:
- **每个 action 必须标注 `action_type`**(从 JSON 的 `type` 字段读取),这是 API #9 调用时的必填参数,传错会导致任务永远卡住
-`schema.properties` 读参数列表schema 已提升为 goal 内容)
-`schema.required` 区分核心/可选参数
- 按功能分类(移液、枪头、外设等)
@@ -165,6 +151,7 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
### Step 4 — 写 SKILL.md
直接复用 `unilab-device-api` 的 API 模板,修改:
- 设备名称
- Action 数量
- 目录列表
@@ -172,42 +159,77 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
- **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
# - #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
# 返回 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" 路径字符串
@@ -217,13 +239,15 @@ API 模板结构:
- 列出本设备所有 Slot 字段、类型及含义
## 渐进加载策略
## 完整工作流 Checklist
```
### Step 5 — 验证
检查文件完整性:
- [ ] `SKILL.md` 包含 API endpoint#1 获取 lab_uuid、#2-#7 工作流/节点/边、#8-#11 运行/查询、#12 资源树、#13 工作流模板详情)
- [ ] `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 文件
@@ -272,92 +296,48 @@ API 模板结构:
`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**,配合液体配方 |
| placeholder 值 | Slot 类型 | 填写格式 | 选取范围 |
| ---------------------- | --------------- | ----------------------------------------------------- | ----------------------------------------- |
| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅**物料**节点(不含设备) |
| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅**设备**节点type=device路径字符串 |
| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | **设备 + 物料**,即所有节点,路径字符串 |
| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已上报的资源类 name |
| `unilabos_formulation` | FormulationSlot | `[{well_name, liquids: [{name, volume}]}]` | 资源树中物料节点的 **name**,配合液体配方 |
### ResourceSlot`unilabos_resources`
最常见的类型。从资源树中选取**物料**节点(孔板、枪头盒、试剂槽等):
```json
{"id": "/workstation/container1", "name": "container1", "uuid": "ff149a9a-2cb8-419d-8db5-d3ba056fb3c2"}
```
- 单个:`{"id": "/workstation/container1", "name": "container1", "uuid": "ff149a9a-..."}`
- 数组:`[{"id": "/path/a", "name": "a", "uuid": "xxx"}, ...]`
- `id` 从 parent 计算的路径格式,根据 action 语义选择正确的物料
- 单个schema type=object`{"id": "/path/name", "name": "name", "uuid": "xxx"}`
- 数组schema type=array`[{"id": "/path/a", "name": "a", "uuid": "xxx"}, ...]`
- `id` 本身是从 parent 计算的路径格式
- 根据 action 语义选择正确的物料(如 `sources` = 液体来源,`targets` = 目标位置)
> **特例**`create_resource` 的 `res_id`,目标物料可能尚不存在,直接填期望路径,不需要 uuid。
> **特例**`create_resource` 的 `res_id` 字段,目标物料可能**尚不存在**,此时直接填写期望的路径(如 `"/workstation/container1"`),不需要 uuid。
### DeviceSlot / NodeSlot / ClassSlot
### DeviceSlot`unilabos_devices`
填写**设备路径字符串**。从资源树中筛选 type=device 的节点,从 parent 计算路径:
```
"/host_node"
"/bioyond_cell/reaction_station"
```
- 只填路径字符串,不需要 `{id, uuid}` 对象
- 根据 action 语义选择正确的设备(如 `target_device_id` = 目标设备)
### NodeSlot`unilabos_nodes`
范围 = 设备 + 物料。即资源树中**所有节点**都可以选,填写**路径字符串**
```
"/PRCXI/PRCXI_Deck"
```
- 使用场景:当参数既可能指向物料也可能指向设备时(如 `PumpTransferProtocol``from_vessel`/`to_vessel``create_resource``parent`
### ClassSlot`unilabos_class`
填写注册表中已上报的**资源类 name**。从本地 `req_resource_registry_upload.json` 中查找:
```
"container"
```
- **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": "YB_PrepBottle_15mL_Carrier_bottle_A1",
"liquids": [
{ "name": "LiPF6", "volume": 0.6 },
{ "name": "DMC", "volume": 1.2 }
]
"well_name": "bottle_A1",
"liquids": [{ "name": "LiPF6", "volume": 0.6 }]
}
]
```
#### 字段说明
| 字段 | 类型 | 说明 |
|------|------|------|
| `sample_uuid` | string | 样品 UUID无样品时传空字符串 `""` |
| `well_name` | string | 目标物料容器的 **name**(从资源树中取物料节点的 `name` 字段,如瓶子、孔位名称) |
| `liquids` | array | 要加入的液体列表 |
| `liquids[].name` | string | 液体名称(如试剂名、溶剂名) |
| `liquids[].volume` | number | 液体体积(单位由设备决定,通常为 mL |
#### 填写规则
- `well_name` 必须是资源树中已存在的物料节点 `name`(不是 `id` 路径),通过 API #12 获取资源树后筛选
- 每个数组元素代表一个目标容器的配方
- 一个容器可以加入多种液体(`liquids` 数组多条记录)
- 与 ResourceSlot 的区别ResourceSlot 填 `{id, name, uuid}` 指向物料本身FormulationSlot 用 `well_name` 引用物料,并附带液体配方信息
- `well_name` — 目标物料的 **name**(从资源树取,不是 `id` 路径)
- `liquids[]` — 液体列表,每条含 `name`(试剂名)和 `volume`体积单位由上下文决定pylabrobot 内部统一 uL
- `sample_uuid` — 样品 UUID无样品传 `""`
- 与 ResourceSlot 的区别ResourceSlot 指向物料本身FormulationSlot 引用物料名并附带配方信息
### 通过 API #12 获取资源树
@@ -365,7 +345,147 @@ API 模板结构:
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
```
注意 `lab_uuid` 在路径中(不是查询参数)。资源树返回所有节点,每个节点包含 `id`(路径格式)、`name``uuid``type``parent` 等字段。填写 Slot 时需根据 placeholder 类型筛选正确的节点。
注意 `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 | 用户指定 | 更新扩展数据 |
> 只传需要更新的字段,未传的字段保持不变。
## 最终目录结构

View File

@@ -0,0 +1,450 @@
---
name: filter-workflow-by-tags
description: Query backend workflow list, aggregate all tags, and filter workflows by domain/scenario requirements using tags. Use when the user wants to search workflows, find workflows by tags, list available workflow tags, filter workflows by category/domain/scenario, or mentions 工作流筛选/标签查询/workflow tags/按领域查找工作流.
---
# Uni-Lab 工作流标签筛选指南
通过 Uni-Lab 云端 API 查询工作流列表汇总所有可用标签tags并根据领域和场景要求筛选工作流。
> **重要**:本指南中的 `Authorization: Lab <token>` 是 **Uni-Lab 平台专用的认证方式**`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。
## 使用模式识别
**用户可能一开始就给出场景目标**(如"我要做有机合成实验"、"找柱层析相关的 protocol")。此时:
1. **识别场景关键词** → 映射到可能的 tags如 synthesis、organic、chromatography、purification
2. **直接执行完整流程**(获取 ak/sk/addr → 拉取所有工作流 → 汇总 tags → 按场景筛选)
3. **展示筛选结果** → 引导用户从候选 workflow 中**选择明确的实验 protocol**
4. **如果用户确认某个 workflow** → 记录 `workflow_uuid`,准备对接“与其他 Skill 的协作”
**如果用户未给场景目标**,则按标准 checklist 询问筛选条件。
---
## 前置条件
使用本指南前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
### 1. ak / sk → AUTH
询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。
生成 AUTH token
```bash
python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
```
### 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 <上面命令输出的 token>"
```
### 3. lab_uuid实验室 UUID
如果用户未提供 `lab_uuid`,通过以下 API 自动获取:
```bash
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
```
返回 `data.uuid` 即为 `lab_uuid`
**三项全部就绪后才可开始。**
## Session State
在整个对话过程中agent 需要记住以下状态:
- `lab_uuid` — 实验室 UUID
- `all_workflows` — 完整工作流列表(分页获取后缓存到内存或临时文件)
- `all_tags` — 所有工作流的标签汇总
---
## API 端点
### 查询工作流列表(支持分页)
```
GET $BASE/api/v1/lab/workflow/owner/list?page=<page>&page_size=<page_size>&lab_uuid=$lab_uuid
```
**参数:**
- `page` — 页码,从 1 开始
- `page_size` — 每页数量,建议 1000
- `lab_uuid` — 实验室 UUID
**返回结构:**
```json
{
"code": 0,
"data": {
"has_more": true,
"data": [
{
"uuid": "9661bba2-1b9f-4687-a63d-910245df174b",
"name": "Untitled",
"description": "",
"user_id": "114211",
"published": false,
"tags": null
},
{
"uuid": "e0436638-190b-46bc-b1a1-2711d9602f6a",
"name": "Synthesis v2",
"user_id": "114211",
"published": true,
"tags": ["synthesis", "organic"]
}
]
}
}
```
**字段说明:**
- `has_more` — 若为 `true`,需要继续请求 `page+1`
- `tags` — 可能为 `null`、空数组或字符串数组;聚合时必须容忍 `null`
### 启动工作流(直接运行)
```
POST $BASE/api/v1/lab/workflow/<workflow_uuid>/run
```
**用途:** 直接启动一个 workflow 的默认执行(使用模板中预设的参数),无需创建 notebook。适用于快速测试或无参数变化的重复执行。
**请求体:** 空 JSON `{}` 或省略
**返回:**
```json
{
"code": 0,
"data": "<run_uuid>"
}
```
- `run_uuid` — 本次执行的唯一标识(不是 notebook UUID
**注意:**
- 该接口会使用 workflow 模板中保存的默认参数直接执行
- 如果 workflow 需要动态参数(如 CSV 路径、样品 UUID应使用 `POST /lab/notebook` 创建 notebook 并传入 `node_params`
- 返回的 `run_uuid` 可直接传入下方「查询任务状态」接口查询实时进度
### 查询任务状态
```
GET $BASE/api/v1/lab/mcp/task/<task_uuid>
```
**用途:** 查询由 `POST /lab/workflow/<uuid>/run` 返回的 `run_uuid`(即 task_uuid的实时执行状态包括整体状态和每个节点JOSJob On Station的执行详情。
**路径参数:**
- `task_uuid` — 等同于启动工作流接口返回的 `run_uuid`
**返回:**
```json
{
"code": 0,
"data": {
"status": "running",
"jos_status": [
{
"uuid": "d0e24bfe-8d99-450e-b19d-f25849dfbaad",
"node_name": "PRCXI_BioER_96_wellplate_slot_1",
"action_name": "create_resource",
"status": "success",
"return_info": {
"suc": true,
"error": "",
"return_value": { ... }
}
},
{
"uuid": "...",
"node_name": "...",
"action_name": "transfer_liquid",
"status": "pending",
"return_info": null
}
]
}
}
```
**字段说明:**
- `data.status` — 整体任务状态
- `running` — 正在执行(至少一个节点 pending 或 running
- `success` — 全部节点成功
- `failed` — 有节点失败
- `data.jos_status[]` — 节点级执行列表(按执行顺序)
- `uuid` — 节点执行实例 UUID
- `node_name` — 节点名称(资源/设备名或工位名)
- `action_name` — 动作类型(`create_resource``transfer_liquid``centrifuge`、等)
- `status` — 节点状态:`success``pending``running``failed`
- `return_info` — 执行返回,失败时 `suc=false``error` 有错误信息
**注意:**
- 此接口的 `task_uuid` **是** `POST /lab/workflow/<uuid>/run` 返回的 `run_uuid`,二者为同一个 ID 的不同称呼
- **不要**把 notebook UUID`POST /lab/notebook` 返回)传进来——那条路径用 `GET /lab/notebook/status` 查询
- `jos_status` 数组按节点执行顺序给出;从 pending 数量可以估算剩余进度
- 返回体可能较大(`return_info.return_value` 中可能包含完整 resource tree可在脚本中只提取 `status` + `node_name` + `action_name` 做摘要
**状态轮询示例:**
```bash
# 每 5 秒轮询一次直至完成
TASK="b183d97e-d2b5-4b24-b14b-820df04d87c0"
while :; do
st=$(curl -s -X GET "$BASE/api/v1/lab/mcp/task/$TASK" -H "$AUTH" \
| python3 -c "import json,sys; d=json.load(sys.stdin)['data']; \
print(d['status'], '|', sum(1 for j in d['jos_status'] if j['status']=='success'), '/', len(d['jos_status']))")
echo "$(date +%H:%M:%S) $st"
[[ "$st" == success* || "$st" == failed* ]] && break
sleep 5
done
```
---
## 完整工作流 Checklist
```
Task Progress:
- [ ] Step 0: 识别用户是否已给出场景目标(如"有机合成"、"柱层析"
- 若已给出 → 记录场景关键词,自动进入后续步骤
- 若未给出 → 在 Step 6 询问用户
- [ ] Step 1: 确认 ak/sk → 生成 AUTH token
- [ ] Step 2: 确认 --addr → 设置 BASE URL
- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid如用户未提供
- [ ] Step 4: 分页获取所有工作流(从 page=1 开始直到 has_more=false
- [ ] Step 5: 汇总所有非空 tags → 生成 all_tags去重、排序、附出现次数
- [ ] Step 6: 根据场景关键词Step 0 或新询问)在 all_tags 中做语义映射 → 确定候选 tags
- 若语义映射不唯一,列出候选 tags 让用户确认
- [ ] Step 7: 按候选 tags 筛选工作流(默认 any 模式,召回优先)
- [ ] Step 8: 展示筛选结果uuid、name、description、tags、published
- [ ] Step 9: 引导用户从结果中选择**明确的实验 protocol**
- 若结果只有 1 条 → 直接确认该 workflow_uuid
- 若结果 210 条 → 让用户按编号选择
- 若结果过多 → 提示收紧条件(加 tag、切换 all 模式、仅 published
- 若结果为空 → 放宽条件(去掉最稀有 tag或提示用户换关键词
- [ ] Step 10: 记录用户选中的 workflow_uuid并提示提交实验或查看详情
```
---
## 推荐路径:使用脚本
同目录下提供 `scripts/filter_workflows.py`,一次完成分页抓取、标签聚合与筛选:
```bash
# 1. 仅汇总标签(不筛选)
python scripts/filter_workflows.py \
--auth "<Lab base64token>" \
--base "$BASE" \
--lab-uuid "$lab_uuid" \
--summary-only
# 2. 按标签筛选ANY 模式:包含任一)
python scripts/filter_workflows.py \
--auth "<Lab base64token>" \
--base "$BASE" \
--lab-uuid "$lab_uuid" \
--tags synthesis organic \
--mode any
# 3. 按标签筛选ALL 模式:必须同时包含)
python scripts/filter_workflows.py \
--auth "<Lab base64token>" \
--base "$BASE" \
--lab-uuid "$lab_uuid" \
--tags synthesis organic \
--mode all \
--output filtered.json
# 4. 仅筛选已发布
python scripts/filter_workflows.py \
--auth "<Lab base64token>" \
--base "$BASE" \
--lab-uuid "$lab_uuid" \
--tags synthesis \
--published-only
```
**`--auth` 参数说明**:传入 `Authorization` 头中 `Lab` 之后的 base64 token不带 `Lab ` 前缀),脚本内部会自动补上 scheme。
**输出结构:**
```json
{
"total_workflows": 150,
"tag_counts": {"synthesis": 12, "organic": 8, "analysis": 5},
"all_tags": ["analysis", "organic", "synthesis"],
"filter": {"tags": ["synthesis", "organic"], "mode": "any"},
"filtered_workflows": [
{"uuid": "...", "name": "...", "description": "...", "tags": [...], "published": true}
]
}
```
---
## 手动路径curl + jq
如果脚本不可用或环境缺少 Python可用 shell 实现。
### 1. 分页抓取(写入 `all_workflows.json`
```bash
page=1
echo "[]" > all_workflows.json
while :; do
resp=$(curl -s -X GET \
"$BASE/api/v1/lab/workflow/owner/list?page=$page&page_size=1000&lab_uuid=$lab_uuid" \
-H "$AUTH")
page_data=$(echo "$resp" | jq -c '.data.data // []')
jq -c --argjson p "$page_data" '. + $p' all_workflows.json > _tmp.json && mv _tmp.json all_workflows.json
has_more=$(echo "$resp" | jq -r '.data.has_more')
[ "$has_more" != "true" ] && break
page=$((page + 1))
done
echo "Total: $(jq 'length' all_workflows.json)"
```
### 2. 汇总所有标签(含出现次数)
```bash
jq '[.[].tags // [] | .[]] | group_by(.) | map({tag: .[0], count: length}) | sort_by(-.count)' \
all_workflows.json
```
### 3. 按标签筛选
```bash
# ANY包含任一指定标签
jq --argjson want '["synthesis","organic"]' \
'[.[] | select((.tags // []) | any(. as $t | $want | index($t)))]' \
all_workflows.json
# ALL同时包含所有指定标签
jq --argjson want '["synthesis","organic"]' \
'[.[] | select(($want | all(. as $w | (.tags // []) | index($w))))]' \
all_workflows.json
```
---
## 筛选策略
agent 拿到用户的「领域 + 场景」自然语言描述时,按如下顺序选择 tag
1. **优先用户显式指定的 tags**:若用户明确给出标签词,直接精确匹配。
2. **从 all_tags 中做语义映射**:若用户描述是自然语言(如"有机合成、柱层析"),在 all_tags 中找语义相关项(如 `synthesis``organic``chromatography`)。必要时展示候选 tag 让用户确认。
3. **模式选择**
- 默认 `any`(更多召回),给出 tag 集合的并集匹配
- 用户强调"必须同时满足"时用 `all`
4. **空结果兜底**:如果筛选为空,放宽条件(去掉最稀有 tag、切换 any 模式),并提醒用户。
---
## 引导到明确的 Protocol
筛选完成后,**最终目标是让用户确认一个具体的 workflow_uuid**,而不是停留在"一堆候选"上。按结果数量采取不同策略:
| 结果数量 | 策略 |
| --------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| 0 条 | 放宽筛选(去掉最稀有 tag → 切换 any 模式 → 去掉 `--published-only`)。仍为空则提示换关键词,或列出 `all_tags` 让用户重新选。 |
| 1 条 | 直接确认:"找到唯一匹配:`<name>` (uuid `<uuid>`),是否用它?"用户确认后记录 `workflow_uuid`。 |
| 210 条 | 编号列表展示,让用户选编号。每项给出 name、tags、description 摘要、published 状态。 |
| 1030 条 | 先展示 tag 分布帮助用户进一步收紧:列出匹配结果中最常见的子标签,提示"加一个 tag 可将结果缩小到 N 条"。 |
| >30 条 | 强制要求用户补充条件:仅 published、指定具体 tag 组合、或按名称关键词过滤。 |
**确认 workflow 后**
1.`workflow_uuid` 写入 session state
2. 提示用户下一步可用的 skill
- 提交实验 → 引导到“与其他 Skill 的协作”
- 查看 workflow 详细节点 → `GET /api/v1/lab/workflow/template/detail/<workflow_uuid>`
3. 若用户想换一个,回到筛选步骤。
---
## 展示结果
推荐格式(表格 + 汇总统计):
```
共 150 个工作流,其中 32 个匹配筛选条件 [tags: synthesis OR organic]
| UUID (短) | 名称 | Tags | 已发布 |
|-----------|--------------------------|------------------------------|--------|
| e0436638 | Synthesis v2 | synthesis, organic | ✓ |
| 5b60dbb8 | Grignard Protocol | synthesis, organometallic | ✓ |
| ... | ... | ... | ... |
所有可用标签(按频次):
synthesis (12), organic (8), analysis (5), purification (4), ...
```
如果用户下一步想执行某工作流 → 引导到“与其他 Skill 的协作”。
---
## 常见问题
### Q: tags 为 null 的工作流要不要展示?
默认**不展示**在筛选结果中(因为无法按 tag 匹配)。但在 `--summary-only` 或无筛选条件时,这些工作流仍会计入总数,并在输出中单独列出"未打标签"计数。
### Q: 如何按名称/描述做模糊匹配?
脚本未内置,但可在 jq 中组合:
```bash
jq '[.[] | select((.name + " " + (.description // "")) | test("organic"; "i"))]' all_workflows.json
```
### Q: `page_size=1000` 是否会被服务端限制?
接口通常允许最大 1000如果返回量少于 1000 且 `has_more=false`,说明已到末页。极端情况下若服务端返回错误,可降到 200 或 500 再试。
### Q: 工作流数量极大(>10k怎么办
1. 先跑 `--summary-only` 了解 tag 分布
2. 提示用户先限定 `--published-only` 或指定 tag
3. 考虑将 `all_workflows.json` 缓存到本地,下次直接复用
---
## 与其他 Skill 的协作
- 正常情况下,找到 workflow 之后可以直接用它提交实验(启动工作流的 api 端点 POST $BASE/api/v1/lab/workflow/<workflow_uuid>/run不用别的 skill
- **仅当需要进行多次实验时,使用 batch-submit-experiment** — 筛选到目标工作流后,`workflow_uuid` 直接用于实验提交
## 脚本依赖
`scripts/filter_workflows.py` 仅使用 Python 标准库(`urllib``json``argparse`),无需额外安装。

View File

@@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""分页拉取 Uni-Lab 工作流列表,汇总 tags 并按 tag 筛选。
使用示例:
python filter_workflows.py \
--auth <base64token> \
--base https://leap-lab.test.bohrium.com \
--lab-uuid a9059772-... \
--tags synthesis organic --mode any
仅依赖 Python 标准库。
"""
from __future__ import annotations
import argparse
import json
import sys
import urllib.error
import urllib.parse
import urllib.request
from collections import Counter
def fetch_all_workflows(base: str, auth_token: str, lab_uuid: str, page_size: int = 1000) -> list[dict]:
"""分页拉取所有 owner 工作流,直到 has_more=false。"""
workflows: list[dict] = []
page = 1
while True:
query = urllib.parse.urlencode(
{"page": page, "page_size": page_size, "lab_uuid": lab_uuid}
)
url = f"{base.rstrip('/')}/api/v1/lab/workflow/owner/list?{query}"
req = urllib.request.Request(
url,
headers={
"Authorization": f"Lab {auth_token}",
"Accept": "application/json",
},
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
payload = json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
sys.exit(f"[ERROR] HTTP {e.code} on page {page}: {e.read().decode('utf-8', 'ignore')}")
except urllib.error.URLError as e:
sys.exit(f"[ERROR] URL error on page {page}: {e.reason}")
if payload.get("code") != 0:
sys.exit(f"[ERROR] API returned non-zero code: {payload}")
data = payload.get("data") or {}
page_items = data.get("data") or []
workflows.extend(page_items)
if not data.get("has_more"):
break
page += 1
# 防御性兜底,避免接口异常导致无限循环
if page > 1000:
print(f"[WARN] page count exceeded 1000, stopping early", file=sys.stderr)
break
return workflows
def aggregate_tags(workflows: list[dict]) -> tuple[list[str], dict[str, int], int]:
"""返回 (sorted_tags, tag_counts, untagged_count)。"""
counter: Counter[str] = Counter()
untagged = 0
for wf in workflows:
tags = wf.get("tags")
if not tags:
untagged += 1
continue
for t in tags:
if isinstance(t, str) and t.strip():
counter[t.strip()] += 1
return sorted(counter.keys()), dict(counter), untagged
def filter_workflows(
workflows: list[dict],
want_tags: list[str],
mode: str,
published_only: bool,
) -> list[dict]:
"""按 tag 筛选。mode 取值 any / all。"""
want_set = {t.strip() for t in want_tags if t.strip()}
out: list[dict] = []
for wf in workflows:
if published_only and not wf.get("published"):
continue
if not want_set:
out.append(wf)
continue
tags = wf.get("tags") or []
tag_set = {t for t in tags if isinstance(t, str)}
if mode == "all":
if want_set.issubset(tag_set):
out.append(wf)
else: # any
if want_set & tag_set:
out.append(wf)
return out
def project_workflow(wf: dict) -> dict:
"""精简输出字段。"""
return {
"uuid": wf.get("uuid"),
"name": wf.get("name"),
"description": wf.get("description", ""),
"tags": wf.get("tags") or [],
"published": bool(wf.get("published")),
"user_id": wf.get("user_id"),
}
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Fetch & filter Uni-Lab workflows by tags.")
p.add_argument("--auth", required=True, help="Base64 token (the part after `Lab `).")
p.add_argument("--base", required=True, help="Base URL, e.g. https://leap-lab.test.bohrium.com")
p.add_argument("--lab-uuid", required=True, help="Lab UUID.")
p.add_argument("--tags", nargs="*", default=[], help="Tags to filter by (space separated).")
p.add_argument(
"--mode",
choices=["any", "all"],
default="any",
help="any: workflow contains at least one tag; all: workflow contains every tag.",
)
p.add_argument("--published-only", action="store_true", help="Only include published workflows.")
p.add_argument("--page-size", type=int, default=1000, help="Page size, default 1000.")
p.add_argument(
"--summary-only",
action="store_true",
help="Print tag summary without applying filter (still fetches everything).",
)
p.add_argument("--output", help="Write JSON result to this path. If omitted, print to stdout.")
return p.parse_args()
def main() -> None:
args = parse_args()
workflows = fetch_all_workflows(
base=args.base,
auth_token=args.auth,
lab_uuid=args.lab_uuid,
page_size=args.page_size,
)
sorted_tags, tag_counts, untagged = aggregate_tags(workflows)
if args.summary_only:
result = {
"total_workflows": len(workflows),
"untagged_count": untagged,
"tag_counts": tag_counts,
"all_tags": sorted_tags,
}
else:
filtered = filter_workflows(
workflows,
want_tags=args.tags,
mode=args.mode,
published_only=args.published_only,
)
result = {
"total_workflows": len(workflows),
"untagged_count": untagged,
"tag_counts": tag_counts,
"all_tags": sorted_tags,
"filter": {
"tags": args.tags,
"mode": args.mode,
"published_only": args.published_only,
},
"matched_count": len(filtered),
"filtered_workflows": [project_workflow(wf) for wf in filtered],
}
payload = json.dumps(result, ensure_ascii=False, indent=2)
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(payload)
print(f"Wrote {len(workflows)} workflows summary → {args.output}", file=sys.stderr)
else:
print(payload)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,251 @@
---
name: host-node
description: Operate Uni-Lab host node via REST API — create resources, test latency, test resource tree, manual confirm. Use when the user mentions host_node, creating resources, resource management, testing latency, or any host node operation.
---
# Host Node API Skill
## 设备信息
- **device_id**: `host_node`
- **Python 源码**: `unilabos/ros/nodes/presets/host_node.py`
- **设备类**: `HostNode`
- **动作数**: 4`create_resource`, `test_latency`, `auto-test_resource`, `manual_confirm`
## 前置条件(缺一不可)
使用本 skill 前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
### 1. ak / sk → AUTH
从启动参数 `--ak` `--sk` 或 config.py 中获取,生成 token`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 <token>"
```
**两项全部就绪后才可发起 API 请求。**
## Session State
在整个对话过程中agent 需要记住以下状态,避免重复询问用户:
- `lab_uuid` — 实验室 UUID首次通过 API #1 自动获取,**不需要问用户**
- `device_name``host_node`
## 请求约定
所有请求使用 `curl -s`POST/PATCH/DELETE 需加 `Content-Type: application/json`
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名)。
---
## API Endpoints
### 1. 获取实验室信息(自动获取 lab_uuid
```bash
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
```
返回 `data.uuid``lab_uuid``data.name``lab_name`
### 2. 创建工作流
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/owner" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"name":"<名称>","lab_uuid":"<lab_uuid>","description":"<描述>"}'
```
返回 `data.uuid``workflow_uuid`。创建成功后告知用户链接:`$BASE/laboratory/$lab_uuid/workflow/$workflow_uuid`
### 3. 创建节点
```bash
curl -s -X POST "$BASE/api/v1/edge/workflow/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"workflow_uuid":"<workflow_uuid>","resource_template_name":"host_node","node_template_name":"<action_name>"}'
```
- `resource_template_name` 固定为 `host_node`
- `node_template_name` — action 名称(如 `create_resource`, `test_latency`
### 4. 删除节点
```bash
curl -s -X DELETE "$BASE/api/v1/lab/workflow/nodes" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"node_uuids":["<uuid1>"],"workflow_uuid":"<workflow_uuid>"}'
```
### 5. 更新节点参数
```bash
curl -s -X PATCH "$BASE/api/v1/lab/workflow/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"workflow_uuid":"<wf_uuid>","uuid":"<node_uuid>","param":{...}}'
```
`param` 直接使用创建节点返回的 `data.param` 结构,修改需要填入的字段值。参考 [action-index.md](action-index.md) 确定哪些字段是 Slot。
### 6. 查询节点 handles
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/node-handles" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"node_uuids":["<node_uuid_1>","<node_uuid_2>"]}'
```
### 7. 批量创建边
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/edges" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"edges":[{"source_node_uuid":"<uuid>","target_node_uuid":"<uuid>","source_handle_uuid":"<uuid>","target_handle_uuid":"<uuid>"}]}'
```
### 8. 启动工作流
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/<workflow_uuid>/run" -H "$AUTH"
```
### 9. 运行设备单动作
```bash
curl -s -X POST "$BASE/api/v1/lab/mcp/run/action" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"lab_uuid":"<lab_uuid>","device_id":"host_node","action":"<action_name>","action_type":"<type>","param":{...}}'
```
`param` 直接放 goal 里的属性,**不要**再包一层 `{"goal": {...}}`
> **WARNING: `action_type` 必须正确,传错会导致任务永远卡住无法完成。** 从下表或 `actions/<name>.json` 的 `type` 字段获取。
#### action_type 速查表
| action | action_type |
|--------|-------------|
| `test_latency` | `UniLabJsonCommand` |
| `create_resource` | `ResourceCreateFromOuterEasy` |
| `auto-test_resource` | `UniLabJsonCommand` |
| `manual_confirm` | `UniLabJsonCommand` |
### 10. 查询任务状态
```bash
curl -s -X GET "$BASE/api/v1/lab/mcp/task/<task_uuid>" -H "$AUTH"
```
### 11. 运行工作流单节点
```bash
curl -s -X POST "$BASE/api/v1/lab/mcp/run/workflow/action" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"node_uuid":"<node_uuid>"}'
```
### 12. 获取资源树(物料信息)
```bash
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
```
注意 `lab_uuid` 在路径中。返回 `data.nodes[]` 含所有节点(设备 + 物料),每个节点含 `name``uuid``type``parent`
### 13. 获取工作流模板详情
```bash
curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH"
```
> 必须使用 `/lab/workflow/template/detail/{uuid}`,其他路径会返回 404。
### 14. 按名称查询物料模板
```bash
curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=<template_name>" -H "$AUTH"
```
返回 `data.uuid``res_template_uuid`,用于 API #15
### 15. 创建物料节点
```bash
curl -s -X POST "$BASE/api/v1/edge/material/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"res_template_uuid":"<uuid>","name":"<名称>","display_name":"<显示名>","parent_uuid":"<父节点uuid>","data":{...}}'
```
### 16. 更新物料节点
```bash
curl -s -X PUT "$BASE/api/v1/edge/material/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"uuid":"<节点uuid>","display_name":"<新名称>","data":{...}}'
```
---
## Placeholder Slot 填写规则
| `placeholder_keys` 值 | 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"` | 注册表中已注册的资源类 |
### host_node 设备的 Slot 字段表
| Action | 字段 | Slot 类型 | 说明 |
| ----------------- | ----------- | ------------ | ------------------------------ |
| `create_resource` | `res_id` | ResourceSlot | 新资源路径(可填不存在的路径) |
| `create_resource` | `device_id` | DeviceSlot | 归属设备 |
| `create_resource` | `parent` | NodeSlot | 父节点路径 |
| `create_resource` | `class_name`| ClassSlot | 资源类名如 `"container"` |
| `auto-test_resource` | `resource` | ResourceSlot | 单个测试物料 |
| `auto-test_resource` | `resources` | ResourceSlot | 测试物料数组 |
| `auto-test_resource` | `device` | DeviceSlot | 测试设备 |
| `auto-test_resource` | `devices` | DeviceSlot | 测试设备 |
---
## 渐进加载策略
1. **SKILL.md**(本文件)— API 端点 + session state 管理
2. **[action-index.md](action-index.md)** — 按分类浏览 4 个动作的描述和核心参数
3. **[actions/\<name\>.json](actions/)** — 仅在需要构建具体请求时,加载对应 action 的完整 JSON Schema
---
## 完整工作流 Checklist
```
Task Progress:
- [ ] Step 1: GET /edge/lab/info 获取 lab_uuid
- [ ] Step 2: 获取资源树 (GET #12) → 记住可用物料
- [ ] Step 3: 读 action-index.md 确定要用的 action 名
- [ ] Step 4: 创建工作流 (POST #2) → 记住 workflow_uuid告知用户链接
- [ ] Step 5: 创建节点 (POST #3, resource_template_name=host_node) → 记住 node_uuid + data.param
- [ ] Step 6: 根据 _unilabos_placeholder_info 和资源树,填写 data.param 中的 Slot 字段
- [ ] Step 7: 更新节点参数 (PATCH #5)
- [ ] Step 8: 查询节点 handles (POST #6) → 获取各节点的 handle_uuid
- [ ] Step 9: 批量创建边 (POST #7) → 用 handle_uuid 连接节点
- [ ] Step 10: 启动工作流 (POST #8) 或运行单节点 (POST #11)
- [ ] Step 11: 查询任务状态 (GET #10) 确认完成
```

View File

@@ -0,0 +1,58 @@
# Action Index — host_node
4 个动作,按功能分类。每个动作的完整 JSON Schema 在 `actions/<name>.json`
---
## 资源管理
### `create_resource`
在资源树中创建新资源(容器、物料等),支持指定位置、类型和初始液体
- **action_type**: `ResourceCreateFromOuterEasy`
- **Schema**: [`actions/create_resource.json`](actions/create_resource.json)
- **可选参数**: `res_id`, `device_id`, `class_name`, `parent`, `bind_locations`, `liquid_input_slot`, `liquid_type`, `liquid_volume`, `slot_on_deck`
- **占位符字段**:
- `res_id`**ResourceSlot**(特例:目标物料可能尚不存在,直接填期望路径)
- `device_id`**DeviceSlot**,填路径字符串如 `"/host_node"`
- `parent`**NodeSlot**,填路径字符串如 `"/workstation/deck"`
- `class_name`**ClassSlot**,填类名如 `"container"`
### `auto-test_resource`
测试资源系统,返回当前资源树和设备列表
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/test_resource.json`](actions/test_resource.json)
- **可选参数**: `resource`, `resources`, `device`, `devices`
- **占位符字段**:
- `resource`**ResourceSlot**,单个物料节点 `{id, name, uuid}`
- `resources`**ResourceSlot**,物料节点数组 `[{id, name, uuid}, ...]`
- `device`**DeviceSlot**,设备路径字符串
- `devices`**DeviceSlot**,设备路径字符串
---
## 系统工具
### `test_latency`
测试设备通信延迟,返回 RTT、时间差、任务延迟等指标
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/test_latency.json`](actions/test_latency.json)
- **参数**: 无(零参数调用)
---
## 人工确认
### `manual_confirm`
创建人工确认节点,等待用户手动确认后继续
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/manual_confirm.json`](actions/manual_confirm.json)
- **核心参数**: `timeout_seconds`(超时时间,秒), `assignee_user_ids`(指派用户 ID 列表)
- **占位符字段**: `assignee_user_ids``unilabos_manual_confirm` 类型

View File

@@ -0,0 +1,93 @@
{
"type": "ResourceCreateFromOuterEasy",
"goal": {
"res_id": "res_id",
"class_name": "class_name",
"parent": "parent",
"device_id": "device_id",
"bind_locations": "bind_locations",
"liquid_input_slot": "liquid_input_slot[]",
"liquid_type": "liquid_type[]",
"liquid_volume": "liquid_volume[]",
"slot_on_deck": "slot_on_deck"
},
"schema": {
"type": "object",
"properties": {
"res_id": {
"type": "string"
},
"device_id": {
"type": "string"
},
"class_name": {
"type": "string"
},
"parent": {
"type": "string"
},
"bind_locations": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z"
],
"title": "bind_locations",
"additionalProperties": false
},
"liquid_input_slot": {
"type": "array",
"items": {
"type": "integer"
}
},
"liquid_type": {
"type": "array",
"items": {
"type": "string"
}
},
"liquid_volume": {
"type": "array",
"items": {
"type": "number"
}
},
"slot_on_deck": {
"type": "string"
}
},
"required": [],
"_unilabos_placeholder_info": {
"res_id": "unilabos_resources",
"device_id": "unilabos_devices",
"parent": "unilabos_nodes",
"class_name": "unilabos_class"
}
},
"goal_default": {},
"placeholder_keys": {
"res_id": "unilabos_resources",
"device_id": "unilabos_devices",
"parent": "unilabos_nodes",
"class_name": "unilabos_class"
}
}

View File

@@ -0,0 +1,32 @@
{
"type": "UniLabJsonCommand",
"goal": {
"timeout_seconds": "timeout_seconds",
"assignee_user_ids": "assignee_user_ids"
},
"schema": {
"type": "object",
"properties": {
"timeout_seconds": {
"type": "integer"
},
"assignee_user_ids": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"timeout_seconds",
"assignee_user_ids"
],
"_unilabos_placeholder_info": {
"assignee_user_ids": "unilabos_manual_confirm"
}
},
"goal_default": {},
"placeholder_keys": {
"assignee_user_ids": "unilabos_manual_confirm"
}
}

View File

@@ -0,0 +1,11 @@
{
"type": "UniLabJsonCommand",
"goal": {},
"schema": {
"type": "object",
"properties": {},
"required": []
},
"goal_default": {},
"placeholder_keys": {}
}

View File

@@ -0,0 +1,255 @@
{
"type": "UniLabJsonCommand",
"goal": {
"resource": "resource",
"resources": "resources",
"device": "device",
"devices": "devices"
},
"schema": {
"type": "object",
"properties": {
"resource": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"sample_id": {
"type": "string"
},
"children": {
"type": "array",
"items": {
"type": "string"
}
},
"parent": {
"type": "string"
},
"type": {
"type": "string"
},
"category": {
"type": "string"
},
"pose": {
"type": "object",
"properties": {
"position": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z"
],
"title": "position",
"additionalProperties": false
},
"orientation": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"w": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z",
"w"
],
"title": "orientation",
"additionalProperties": false
}
},
"required": [
"position",
"orientation"
],
"title": "pose",
"additionalProperties": false
},
"config": {
"type": "string"
},
"data": {
"type": "string"
}
},
"title": "resource"
},
"resources": {
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"sample_id": {
"type": "string"
},
"children": {
"type": "array",
"items": {
"type": "string"
}
},
"parent": {
"type": "string"
},
"type": {
"type": "string"
},
"category": {
"type": "string"
},
"pose": {
"type": "object",
"properties": {
"position": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z"
],
"title": "position",
"additionalProperties": false
},
"orientation": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"w": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z",
"w"
],
"title": "orientation",
"additionalProperties": false
}
},
"required": [
"position",
"orientation"
],
"title": "pose",
"additionalProperties": false
},
"config": {
"type": "string"
},
"data": {
"type": "string"
}
},
"title": "resources"
},
"type": "array"
},
"device": {
"type": "string",
"description": "device reference"
},
"devices": {
"type": "string",
"description": "device reference"
}
},
"required": [],
"_unilabos_placeholder_info": {
"resource": "unilabos_resources",
"resources": "unilabos_resources",
"device": "unilabos_devices",
"devices": "unilabos_devices"
}
},
"goal_default": {},
"placeholder_keys": {
"resource": "unilabos_resources",
"resources": "unilabos_resources",
"device": "unilabos_devices",
"devices": "unilabos_devices"
}
}

View File

@@ -1,11 +1,13 @@
---
name: submit-agent-result
description: Submit historical experiment results (agent_result) to Uni-Lab notebook — read data files, assemble JSON payload, PUT to cloud API. Use when the user wants to submit experiment results, upload agent results, report experiment data, or mentions agent_result/实验结果/历史记录/notebook结果.
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 提交历史实验记录指南
通过云端 API 向已创建的 notebook 提交实验结果数据agent_result。支持从 JSON / CSV 文件读取数据,整合后提交。
通过 Uni-Lab 云端 API 向已创建的 notebook 提交实验结果数据agent_result。支持从 JSON / CSV 文件读取数据,整合后提交。
> **重要**:本指南中的 `Authorization: Lab <token>` 是 **Uni-Lab 平台专用的认证方式**`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。
## 前置条件(缺一不可)
@@ -18,23 +20,26 @@ description: Submit historical experiment results (agent_result) to Uni-Lab note
生成 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>`
输出即为 token 值,拼接为 `Authorization: Lab <token>``Lab` 是 Uni-Lab 平台 auth scheme不可替换为 `Basic`
### 2. --addr → BASE URL
| `--addr` 值 | BASE |
|-------------|------|
| `test` | `https://uni-lab.test.bohrium.com` |
| `uat` | `https://uni-lab.uat.bohrium.com` |
| `local` | `http://127.0.0.1:48197` |
| 不传(默认) | `https://uni-lab.bohrium.com` |
| `--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>"
```
@@ -45,6 +50,7 @@ AUTH="Authorization: Lab <上面命令输出的 token>"
notebook_uuid 来自之前通过「批量提交实验」创建的实验批次,即 `POST /api/v1/lab/notebook` 返回的 `data.uuid`
如果用户不记得,可提示:
- 查看之前的对话记录中创建 notebook 时返回的 UUID
- 或通过平台页面查找对应的 notebook
@@ -54,11 +60,11 @@ notebook_uuid 来自之前通过「批量提交实验」创建的实验批次,
用户需要提供实验结果数据,支持以下方式:
| 方式 | 说明 |
|------|------|
| JSON 文件 | 直接作为 `agent_result` 的内容合并 |
| CSV 文件 | 转为 `{"文件名": [行数据...]}` 格式 |
| 手动指定 | 用户直接告知 key-value 数据,由 agent 构建 JSON |
| 方式 | 说明 |
| --------- | ----------------------------------------------- |
| JSON 文件 | 直接作为 `agent_result` 的内容合并 |
| CSV 文件 | 转为 `{"文件名": [行数据...]}` 格式 |
| 手动指定 | 用户直接告知 key-value 数据,由 agent 构建 JSON |
**四项全部就绪后才可开始。**
@@ -90,7 +96,7 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
返回:
```json
{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}}
{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } }
```
记住 `data.uuid``lab_uuid`
@@ -121,42 +127,45 @@ curl -s -X PUT "$BASE/api/v1/lab/notebook/agent-result" \
#### 必要字段
| 字段 | 类型 | 说明 |
|------|------|------|
| 字段 | 类型 | 说明 |
| --------------- | ------------- | ------------------------------------------- |
| `notebook_uuid` | string (UUID) | 目标 notebook 的 UUID从批量提交实验时获取 |
| `agent_result` | object | 实验结果数据,任意 JSON 对象 |
| `agent_result` | object | 实验结果数据,任意 JSON 对象 |
#### agent_result 内容格式
`agent_result` 接受**任意 JSON 对象**,常见格式:
**简单键值对**
```json
{
"avg_rtt_ms": 12.5,
"status": "success",
"test_count": 5
"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"}
]
"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}
]
"experiment_data": [
{ "温度": 25, "压力": 101.3, "产率": 0.85 },
{ "温度": 30, "压力": 101.3, "产率": 0.91 }
]
}
```
@@ -178,22 +187,22 @@ python scripts/prepare_agent_result.py \
[--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` |
| 参数 | 必选 | 说明 |
| ----------------- | ---------- | ----------------------------------------------- |
| `--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 | 字段直接合并到 `agent_result` 顶层 |
| `.json`list/other | 以文件名为 key 放入 `agent_result` |
| `.csv` | 以文件名(不含扩展名)为 key值为行对象数组 |
多个文件的字段会合并。JSON dict 中的重复 key 后者覆盖前者。
@@ -210,7 +219,7 @@ python scripts/prepare_agent_result.py \
--notebook-uuid 73c67dca-c8cc-4936-85a0-329106aa7cca \
--files results.json \
--auth YTFmZDlkNGUt... \
--base https://uni-lab.test.bohrium.com \
--base https://leap-lab.test.bohrium.com \
--submit
```
@@ -272,4 +281,4 @@ Task Progress:
### Q: 认证方式是 Lab 还是 Api
本指南统一使用 `Authorization: Lab <base64(ak:sk)>` 方式。如果用户有独立的 API Key也可用 `Authorization: Api <key>` 替代。
本指南统一使用 `Authorization: Lab <base64(ak:sk)>` 方式`Lab` 是 Uni-Lab 平台的 auth scheme**绝不能用 `Basic` 替代**。如果用户有独立的 API Key也可用 `Authorization: Api <key>` 替代。

View File

@@ -0,0 +1,272 @@
---
name: virtual-workbench
description: Operate Virtual Workbench via REST API — prepare materials, move to heating stations, start heating, move to output, transfer resources. Use when the user mentions virtual workbench, virtual_workbench, 虚拟工作台, heating stations, material processing, or workbench operations.
---
# Virtual Workbench API Skill
## 设备信息
- **device_id**: `virtual_workbench`
- **Python 源码**: `unilabos/devices/virtual/workbench.py`
- **设备类**: `VirtualWorkbench`
- **动作数**: 6`auto-prepare_materials`, `auto-move_to_heating_station`, `auto-start_heating`, `auto-move_to_output`, `transfer`, `manual_confirm`
- **设备描述**: 模拟工作台,包含 1 个机械臂(每次操作 2s独占锁和 3 个加热台(每次加热 60s可并行
### 典型工作流程
1. `prepare_materials` — 生成 A1-A5 物料5 个 output handle
2. `move_to_heating_station` — 物料并发竞争机械臂,移动到空闲加热台
3. `start_heating` — 启动加热3 个加热台可并行)
4. `move_to_output` — 加热完成后移到输出位置 Cn
## 前置条件(缺一不可)
使用本 skill 前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
### 1. ak / sk → AUTH
从启动参数 `--ak` `--sk` 或 config.py 中获取,生成 token`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 <token>"
```
**两项全部就绪后才可发起 API 请求。**
## Session State
- `lab_uuid` — 实验室 UUID首次通过 API #1 自动获取,**不需要问用户**
- `device_name``virtual_workbench`
## 请求约定
所有请求使用 `curl -s`POST/PATCH/DELETE 需加 `Content-Type: application/json`
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名)。
---
## API Endpoints
### 1. 获取实验室信息(自动获取 lab_uuid
```bash
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
```
返回 `data.uuid``lab_uuid``data.name``lab_name`
### 2. 创建工作流
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/owner" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"name":"<名称>","lab_uuid":"<lab_uuid>","description":"<描述>"}'
```
返回 `data.uuid``workflow_uuid`。创建成功后告知用户链接:`$BASE/laboratory/$lab_uuid/workflow/$workflow_uuid`
### 3. 创建节点
```bash
curl -s -X POST "$BASE/api/v1/edge/workflow/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"workflow_uuid":"<workflow_uuid>","resource_template_name":"virtual_workbench","node_template_name":"<action_name>"}'
```
- `resource_template_name` 固定为 `virtual_workbench`
- `node_template_name` — action 名称(如 `auto-prepare_materials`, `auto-move_to_heating_station`
### 4. 删除节点
```bash
curl -s -X DELETE "$BASE/api/v1/lab/workflow/nodes" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"node_uuids":["<uuid1>"],"workflow_uuid":"<workflow_uuid>"}'
```
### 5. 更新节点参数
```bash
curl -s -X PATCH "$BASE/api/v1/lab/workflow/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"workflow_uuid":"<wf_uuid>","uuid":"<node_uuid>","param":{...}}'
```
参考 [action-index.md](action-index.md) 确定哪些字段是 Slot。
### 6. 查询节点 handles
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/node-handles" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"node_uuids":["<node_uuid_1>","<node_uuid_2>"]}'
```
### 7. 批量创建边
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/edges" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"edges":[{"source_node_uuid":"<uuid>","target_node_uuid":"<uuid>","source_handle_uuid":"<uuid>","target_handle_uuid":"<uuid>"}]}'
```
### 8. 启动工作流
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/<workflow_uuid>/run" -H "$AUTH"
```
### 9. 运行设备单动作
```bash
curl -s -X POST "$BASE/api/v1/lab/mcp/run/action" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"lab_uuid":"<lab_uuid>","device_id":"virtual_workbench","action":"<action_name>","action_type":"<type>","param":{...}}'
```
`param` 直接放 goal 里的属性,**不要**再包一层 `{"goal": {...}}`
> **WARNING: `action_type` 必须正确,传错会导致任务永远卡住无法完成。** 从下表或 `actions/<name>.json` 的 `type` 字段获取。
#### action_type 速查表
| action | action_type |
|--------|-------------|
| `auto-prepare_materials` | `UniLabJsonCommand` |
| `auto-move_to_heating_station` | `UniLabJsonCommand` |
| `auto-start_heating` | `UniLabJsonCommand` |
| `auto-move_to_output` | `UniLabJsonCommand` |
| `transfer` | `UniLabJsonCommandAsync` |
| `manual_confirm` | `UniLabJsonCommand` |
### 10. 查询任务状态
```bash
curl -s -X GET "$BASE/api/v1/lab/mcp/task/<task_uuid>" -H "$AUTH"
```
### 11. 运行工作流单节点
```bash
curl -s -X POST "$BASE/api/v1/lab/mcp/run/workflow/action" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"node_uuid":"<node_uuid>"}'
```
### 12. 获取资源树(物料信息)
```bash
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
```
注意 `lab_uuid` 在路径中。返回 `data.nodes[]` 含所有节点(设备 + 物料),每个节点含 `name``uuid``type``parent`
### 13. 获取工作流模板详情
```bash
curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH"
```
> 必须使用 `/lab/workflow/template/detail/{uuid}`,其他路径会返回 404。
### 14. 按名称查询物料模板
```bash
curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=<template_name>" -H "$AUTH"
```
返回 `data.uuid``res_template_uuid`,用于 API #15
### 15. 创建物料节点
```bash
curl -s -X POST "$BASE/api/v1/edge/material/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"res_template_uuid":"<uuid>","name":"<名称>","display_name":"<显示名>","parent_uuid":"<父节点uuid>","data":{...}}'
```
### 16. 更新物料节点
```bash
curl -s -X PUT "$BASE/api/v1/edge/material/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"uuid":"<节点uuid>","display_name":"<新名称>","data":{...}}'
```
---
## Placeholder Slot 填写规则
| `placeholder_keys` 值 | 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"` | 注册表中已注册的资源类 |
### virtual_workbench 设备的 Slot 字段表
| Action | 字段 | Slot 类型 | 说明 |
| ----------------- | ---------------- | ------------ | -------------------- |
| `transfer` | `resource` | ResourceSlot | 待转移物料数组 |
| `transfer` | `target_device` | DeviceSlot | 目标设备路径 |
| `transfer` | `mount_resource` | ResourceSlot | 目标孔位数组 |
| `manual_confirm` | `resource` | ResourceSlot | 确认用物料数组 |
| `manual_confirm` | `target_device` | DeviceSlot | 确认用目标设备 |
| `manual_confirm` | `mount_resource` | ResourceSlot | 确认用目标孔位数组 |
> `prepare_materials`、`move_to_heating_station`、`start_heating`、`move_to_output` 这 4 个动作**无 Slot 字段**,参数为纯数值/整数。
---
## 渐进加载策略
1. **SKILL.md**(本文件)— API 端点 + session state 管理 + 设备工作流概览
2. **[action-index.md](action-index.md)** — 按分类浏览 6 个动作的描述和核心参数
3. **[actions/\<name\>.json](actions/)** — 仅在需要构建具体请求时,加载对应 action 的完整 JSON Schema
---
## 完整工作流 Checklist
```
Task Progress:
- [ ] Step 1: GET /edge/lab/info 获取 lab_uuid
- [ ] Step 2: 获取资源树 (GET #12) → 记住可用物料
- [ ] Step 3: 读 action-index.md 确定要用的 action 名
- [ ] Step 4: 创建工作流 (POST #2) → 记住 workflow_uuid告知用户链接
- [ ] Step 5: 创建节点 (POST #3, resource_template_name=virtual_workbench) → 记住 node_uuid + data.param
- [ ] Step 6: 根据 _unilabos_placeholder_info 和资源树,填写 data.param 中的 Slot 字段
- [ ] Step 7: 更新节点参数 (PATCH #5)
- [ ] Step 8: 查询节点 handles (POST #6) → 获取各节点的 handle_uuid
- [ ] Step 9: 批量创建边 (POST #7) → 用 handle_uuid 连接节点
- [ ] Step 10: 启动工作流 (POST #8) 或运行单节点 (POST #11)
- [ ] Step 11: 查询任务状态 (GET #10) 确认完成
```
### 典型 5 物料并发加热工作流示例
```
prepare_materials (count=5)
├─ channel_1 → move_to_heating_station (material_number=1) → start_heating → move_to_output
├─ channel_2 → move_to_heating_station (material_number=2) → start_heating → move_to_output
├─ channel_3 → move_to_heating_station (material_number=3) → start_heating → move_to_output
├─ channel_4 → move_to_heating_station (material_number=4) → start_heating → move_to_output
└─ channel_5 → move_to_heating_station (material_number=5) → start_heating → move_to_output
```
创建节点时,`prepare_materials` 的 5 个 output handle`channel_1` ~ `channel_5`)分别连接到 5 个 `move_to_heating_station` 节点的 `material_input` handle。每个 `move_to_heating_station``heating_station_output``material_number_output` 连接到对应 `start_heating``station_id_input``material_number_input`

View File

@@ -0,0 +1,76 @@
# Action Index — virtual_workbench
6 个动作,按功能分类。每个动作的完整 JSON Schema 在 `actions/<name>.json`
---
## 物料准备
### `auto-prepare_materials`
批量准备物料(虚拟起始节点),生成 A1-A5 物料编号,输出 5 个 handle 供后续节点使用
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/prepare_materials.json`](actions/prepare_materials.json)
- **可选参数**: `count`(物料数量,默认 5
---
## 机械臂 & 加热台操作
### `auto-move_to_heating_station`
将物料从 An 位置移动到空闲加热台(竞争机械臂,自动查找空闲加热台)
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/move_to_heating_station.json`](actions/move_to_heating_station.json)
- **核心参数**: `material_number`物料编号integer
### `auto-start_heating`
启动指定加热台的加热程序可并行3 个加热台同时工作)
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/start_heating.json`](actions/start_heating.json)
- **核心参数**: `station_id`(加热台 ID`material_number`(物料编号)
### `auto-move_to_output`
将加热完成的物料从加热台移动到输出位置 Cn
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/move_to_output.json`](actions/move_to_output.json)
- **核心参数**: `station_id`(加热台 ID`material_number`(物料编号)
---
## 物料转移
### `transfer`
异步转移物料到目标设备(通过 ROS 资源转移)
- **action_type**: `UniLabJsonCommandAsync`
- **Schema**: [`actions/transfer.json`](actions/transfer.json)
- **核心参数**: `resource`, `target_device`, `mount_resource`
- **占位符字段**:
- `resource`**ResourceSlot**,待转移的物料数组 `[{id, name, uuid}, ...]`
- `target_device`**DeviceSlot**,目标设备路径字符串
- `mount_resource`**ResourceSlot**,目标孔位数组 `[{id, name, uuid}, ...]`
---
## 人工确认
### `manual_confirm`
创建人工确认节点,等待用户手动确认后继续(含物料转移上下文)
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/manual_confirm.json`](actions/manual_confirm.json)
- **核心参数**: `resource`, `target_device`, `mount_resource`, `timeout_seconds`, `assignee_user_ids`
- **占位符字段**:
- `resource`**ResourceSlot**,物料数组
- `target_device`**DeviceSlot**,目标设备路径
- `mount_resource`**ResourceSlot**,目标孔位数组
- `assignee_user_ids``unilabos_manual_confirm` 类型

View File

@@ -0,0 +1,270 @@
{
"type": "UniLabJsonCommand",
"goal": {
"resource": "resource",
"target_device": "target_device",
"mount_resource": "mount_resource",
"timeout_seconds": "timeout_seconds",
"assignee_user_ids": "assignee_user_ids"
},
"schema": {
"type": "object",
"properties": {
"resource": {
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"sample_id": {
"type": "string"
},
"children": {
"type": "array",
"items": {
"type": "string"
}
},
"parent": {
"type": "string"
},
"type": {
"type": "string"
},
"category": {
"type": "string"
},
"pose": {
"type": "object",
"properties": {
"position": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z"
],
"title": "position",
"additionalProperties": false
},
"orientation": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"w": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z",
"w"
],
"title": "orientation",
"additionalProperties": false
}
},
"required": [
"position",
"orientation"
],
"title": "pose",
"additionalProperties": false
},
"config": {
"type": "string"
},
"data": {
"type": "string"
}
},
"title": "resource"
},
"type": "array"
},
"target_device": {
"type": "string",
"description": "device reference"
},
"mount_resource": {
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"sample_id": {
"type": "string"
},
"children": {
"type": "array",
"items": {
"type": "string"
}
},
"parent": {
"type": "string"
},
"type": {
"type": "string"
},
"category": {
"type": "string"
},
"pose": {
"type": "object",
"properties": {
"position": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z"
],
"title": "position",
"additionalProperties": false
},
"orientation": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"w": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z",
"w"
],
"title": "orientation",
"additionalProperties": false
}
},
"required": [
"position",
"orientation"
],
"title": "pose",
"additionalProperties": false
},
"config": {
"type": "string"
},
"data": {
"type": "string"
}
},
"title": "mount_resource"
},
"type": "array"
},
"timeout_seconds": {
"type": "integer"
},
"assignee_user_ids": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"resource",
"target_device",
"mount_resource",
"timeout_seconds",
"assignee_user_ids"
],
"_unilabos_placeholder_info": {
"resource": "unilabos_resources",
"target_device": "unilabos_devices",
"mount_resource": "unilabos_resources",
"assignee_user_ids": "unilabos_manual_confirm"
}
},
"goal_default": {},
"placeholder_keys": {
"resource": "unilabos_resources",
"target_device": "unilabos_devices",
"mount_resource": "unilabos_resources",
"assignee_user_ids": "unilabos_manual_confirm"
}
}

View File

@@ -0,0 +1,19 @@
{
"type": "UniLabJsonCommand",
"goal": {
"material_number": "material_number"
},
"schema": {
"type": "object",
"properties": {
"material_number": {
"type": "integer"
}
},
"required": [
"material_number"
]
},
"goal_default": {},
"placeholder_keys": {}
}

View File

@@ -0,0 +1,24 @@
{
"type": "UniLabJsonCommand",
"goal": {
"station_id": "station_id",
"material_number": "material_number"
},
"schema": {
"type": "object",
"properties": {
"station_id": {
"type": "integer"
},
"material_number": {
"type": "integer"
}
},
"required": [
"station_id",
"material_number"
]
},
"goal_default": {},
"placeholder_keys": {}
}

View File

@@ -0,0 +1,20 @@
{
"type": "UniLabJsonCommand",
"goal": {
"count": "count"
},
"schema": {
"type": "object",
"properties": {
"count": {
"type": "integer",
"default": 5
}
},
"required": []
},
"goal_default": {
"count": 5
},
"placeholder_keys": {}
}

View File

@@ -0,0 +1,24 @@
{
"type": "UniLabJsonCommand",
"goal": {
"station_id": "station_id",
"material_number": "material_number"
},
"schema": {
"type": "object",
"properties": {
"station_id": {
"type": "integer"
},
"material_number": {
"type": "integer"
}
},
"required": [
"station_id",
"material_number"
]
},
"goal_default": {},
"placeholder_keys": {}
}

View File

@@ -0,0 +1,255 @@
{
"type": "UniLabJsonCommandAsync",
"goal": {
"resource": "resource",
"target_device": "target_device",
"mount_resource": "mount_resource"
},
"schema": {
"type": "object",
"properties": {
"resource": {
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"sample_id": {
"type": "string"
},
"children": {
"type": "array",
"items": {
"type": "string"
}
},
"parent": {
"type": "string"
},
"type": {
"type": "string"
},
"category": {
"type": "string"
},
"pose": {
"type": "object",
"properties": {
"position": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z"
],
"title": "position",
"additionalProperties": false
},
"orientation": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"w": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z",
"w"
],
"title": "orientation",
"additionalProperties": false
}
},
"required": [
"position",
"orientation"
],
"title": "pose",
"additionalProperties": false
},
"config": {
"type": "string"
},
"data": {
"type": "string"
}
},
"title": "resource"
},
"type": "array"
},
"target_device": {
"type": "string",
"description": "device reference"
},
"mount_resource": {
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"sample_id": {
"type": "string"
},
"children": {
"type": "array",
"items": {
"type": "string"
}
},
"parent": {
"type": "string"
},
"type": {
"type": "string"
},
"category": {
"type": "string"
},
"pose": {
"type": "object",
"properties": {
"position": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z"
],
"title": "position",
"additionalProperties": false
},
"orientation": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"w": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z",
"w"
],
"title": "orientation",
"additionalProperties": false
}
},
"required": [
"position",
"orientation"
],
"title": "pose",
"additionalProperties": false
},
"config": {
"type": "string"
},
"data": {
"type": "string"
}
},
"title": "mount_resource"
},
"type": "array"
}
},
"required": [
"resource",
"target_device",
"mount_resource"
],
"_unilabos_placeholder_info": {
"resource": "unilabos_resources",
"target_device": "unilabos_devices",
"mount_resource": "unilabos_resources"
}
},
"goal_default": {},
"placeholder_keys": {
"resource": "unilabos_resources",
"target_device": "unilabos_devices",
"mount_resource": "unilabos_resources"
}
}

View File

@@ -38,7 +38,7 @@ jobs:
- name: Install ROS dependencies, uv and unilabos-msgs
run: |
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
run: |

View File

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

View File

@@ -56,7 +56,7 @@ jobs:
miniforge-version: latest
use-mamba: true
python-version: '3.11.14'
channels: conda-forge,robostack-staging,uni-lab,defaults
channels: conda-forge,robostack-staging,uni-lab
channel-priority: flexible
activate-environment: unilab
auto-update-conda: false
@@ -66,7 +66,7 @@ jobs:
run: |
echo "Installing unilabos and dependencies to unilab environment..."
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
run: |

View File

@@ -10,6 +10,9 @@ on:
# 支持 tag 推送(不依赖 CI Check
push:
tags: ['v*']
# GitHub Release 发布时自动构建并上传
release:
types: [published]
# 手动触发
workflow_dispatch:
inputs:
@@ -80,7 +83,7 @@ jobs:
- uses: actions/checkout@v6
with:
# 如果是 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
- name: Check if platform should be built
@@ -96,12 +99,13 @@ jobs:
echo "should_build=false" >> $GITHUB_OUTPUT
fi
- name: Setup Miniconda
- name: Setup Miniforge
if: steps.should_build.outputs.should_build == 'true'
uses: conda-incubator/setup-miniconda@v3
with:
miniconda-version: 'latest'
channels: conda-forge,robostack-staging,defaults
miniforge-version: latest
use-mamba: true
channels: conda-forge,robostack-staging
channel-priority: strict
activate-environment: build-env
auto-update-conda: false
@@ -110,7 +114,7 @@ jobs:
- name: Install rattler-build and anaconda-client
if: steps.should_build.outputs.should_build == 'true'
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
if: steps.should_build.outputs.should_build == 'true'
@@ -157,7 +161,13 @@ jobs:
retention-days: 30
- 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: |
for package in $(find ./output -name "*.conda"); do
echo "Uploading $package to unilab organization..."

View File

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

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)
@@ -69,7 +69,7 @@ class WSConfig:
# HTTP配置
class HTTPConfig:
remote_addr = "https://uni-lab.bohrium.com/api/v1" # 远程服务器地址
remote_addr = "https://leap-lab.bohrium.com/api/v1" # 远程服务器地址
# ROS配置
class ROSConfig:
@@ -209,8 +209,8 @@ unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis -g grap
`--addr` 参数支持以下预设值,会自动转换为对应的完整 URL
- `test``https://uni-lab.test.bohrium.com/api/v1`
- `uat``https://uni-lab.uat.bohrium.com/api/v1`
- `test``https://leap-lab.test.bohrium.com/api/v1`
- `uat``https://leap-lab.uat.bohrium.com/api/v1`
- `local``http://127.0.0.1:48197/api/v1`
- 其他值 → 直接使用作为完整 URL
@@ -248,7 +248,7 @@ unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis -g grap
`ak``sk` 是必需的认证参数:
1. **获取方式**:在 [Uni-Lab 官网](https://uni-lab.bohrium.com) 注册实验室后获得
1. **获取方式**:在 [Uni-Lab 官网](https://leap-lab.bohrium.com) 注册实验室后获得
2. **配置方式**
- **命令行参数**`--ak "your_key" --sk "your_secret"`(最高优先级,推荐)
- **环境变量**`UNILABOS_BASICCONFIG_AK``UNILABOS_BASICCONFIG_SK`
@@ -275,15 +275,15 @@ WebSocket 是 Uni-Lab 的主要通信方式:
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://uni-lab.test.bohrium.com/api/v1`
- UAT 环境:`https://uni-lab.uat.bohrium.com/api/v1`
- 生产环境:`https://leap-lab.bohrium.com/api/v1`(默认)
- 测试环境:`https://leap-lab.test.bohrium.com/api/v1`
- UAT 环境:`https://leap-lab.uat.bohrium.com/api/v1`
- 本地环境:`http://127.0.0.1:48197/api/v1`
### 4. ROSConfig - ROS 配置
@@ -401,7 +401,7 @@ export UNILABOS_WSCONFIG_RECONNECT_INTERVAL="10"
export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS="500"
# 设置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
class HTTPConfig:
remote_addr = "https://uni-lab.test.bohrium.com/api/v1"
remote_addr = "https://leap-lab.test.bohrium.com/api/v1"
```
**环境变量方式:**
```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 │
uni-lab.bohrium.com │
leap-lab.bohrium.com │
│ (Resource Management, Task Scheduling, │
│ Monitoring) │
└────────────────────┬─────────────────────────┘
@@ -444,7 +444,7 @@ ros2 daemon stop && ros2 daemon start
```bash
# 测试云端连接
curl https://uni-lab.bohrium.com/api/v1/health
curl https://leap-lab.bohrium.com/api/v1/health
# 测试WebSocket
# 启动Uni-Lab后查看日志

View File

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

View File

@@ -54,7 +54,6 @@ Uni-Lab 的启动过程分为以下几个阶段:
您可以直接跟随 unilabos 的提示进行,无需查阅本节
- **工作目录设置**
- 如果当前目录以 `unilabos_data` 结尾,则使用当前目录
- 否则使用 `当前目录/unilabos_data` 作为工作目录
- 可通过 `--working_dir` 指定自定义工作目录
@@ -68,8 +67,8 @@ Uni-Lab 的启动过程分为以下几个阶段:
支持多种后端环境:
- `--addr test`:测试环境 (`https://uni-lab.test.bohrium.com/api/v1`)
- `--addr uat`UAT 环境 (`https://uni-lab.uat.bohrium.com/api/v1`)
- `--addr test`:测试环境 (`https://leap-lab.test.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`)
- 自定义地址:直接指定完整 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. 引导创建配置文件
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` 参数
- 配置文件中包含正确的认证信息

View File

@@ -0,0 +1,914 @@
# Draft: Resource And Material Sync Guidance For Sirna And Similar Bioyond Systems
Status: draft for discussion, not an implementation mandate.
This note triangulates across five source categories with different authority:
1. `docs/developer_guide/examples/workstation_architecture.md`: desired shape
and vocabulary. It is a design target, not proof that the current code has
every behavior.
2. `BioyondWorkstation`, `BioyondResourceSynchronizer`, and shared graphio code:
the current compatibility anchor. New guidance should mostly preserve this
lifecycle and extend it deliberately.
3. Existing non-Sirna Bioyond stations: practical examples of how the shared
base is used, including shortcuts that should not become policy.
4. Sirna implementation, plans, findings, and guide notes: stress-test evidence.
They expose real missing cases, but the current Sirna code is not the
architecture authority because parts were added without fully aligning to the
shared base.
5. UniLabOS resource framework behavior around PLR resources,
`ResourceTreeSet`, serialize/deserialize, and `update_resource(resources=...)`.
The short recommendation is:
Keep the shared Bioyond workstation lifecycle as the center of gravity. Evolve
the shared synchronizer with small project hooks for classification, ID-based
resolution, and non-slot material handling, rather than letting Sirna become a
parallel synchronization model. For Sirna, those hooks should resolve placement
by Bioyond IDs, distinguish physical slot labware from reagent liquid contents,
preserve Bioyond IDs in `unilabos_extra`, mutate the local PLR deck, and publish
the full deck through `update_resource(resources=[deck])`.
Do not treat every Bioyond stock row as a deck resource.
## Evidence Weighting
This task is not a Sirna implementation retrospective. It is a best-practice
alignment pass across the architecture target, the shared base class, observed
station behavior, and Sirna's newly exposed edge cases.
Use the sources this way:
- Architecture doc: ask "what shape should this system eventually have?"
- Shared Bioyond base: ask "what behavior must remain compatible today?"
- Other Bioyond stations: ask "what patterns are already working in practice?"
- Sirna current code and notes: ask "what did the base model fail to handle, and
which Sirna fixes conflict with the shared lifecycle?"
- Live API/schema evidence: ask "what is true for this deployment's material,
warehouse, and coordinate data?"
The Sirna AGENT_GUIDE is useful for finding caveats and prior investigations,
but it should not be cited as an independent source of truth when source code,
framework behavior, live API evidence, or architecture docs can answer the same
question.
## Mental Model
There are three resource worlds. Confusing them is the main source of bugs.
| World | Owner | Purpose | Recommended truth |
| --- | --- | --- | --- |
| Bioyond/LIMS | Bioyond APIs through `BioyondV1RPC` | External stock, material IDs, warehouse IDs, location IDs, inbound/outbound side effects | External material truth |
| PLR deck | Workstation driver | Runtime workstation material layout, warehouse occupancy, liquid contents | Local mutation surface |
| UniLabOS resource tree | `ResourceTreeSet` / ROS node / host resource APIs | Canonical UniLabOS/cloud representation | Framework/cloud truth |
The architecture guide describes `Deck` as the local material system and
`ResourceSynchronizer` as the optional external-system bridge
(`docs/developer_guide/examples/workstation_architecture.md:221`). The broader
framework serializes PLR objects into `ResourceTreeSet`, whose resource dicts
carry `id`, `uuid`, `parent_uuid`, `type`, `class`, `pose`, `config`, `data`,
and `extra` (`unilabos/resources/resource_tracker.py:107`).
Therefore the correct path is:
```text
Bioyond rows
-> normalized material records
-> preserve Bioyond materialTypeMode: Sample / Consumables / Reagent
-> resolve material/location/warehouse IDs
-> choose UniLabOS handling for that mode
-> mutate PLR deck
-> ResourceTreeSet.from_plr_resources([deck])
-> ROS update_resource(resources=[deck])
-> host/cloud resource-tree update
```
Avoid direct cloud JSON patches and avoid treating Bioyond records as already
being UniLabOS resource nodes.
## Target Architecture
The ideal architecture in `workstation_architecture.md` is still the right
direction:
1. `WorkstationBase` owns the local `deck`, workflow state, and hardware
interface.
2. `ResourceSynchronizer` owns external material synchronization.
3. `BioyondResourceSynchronizer` owns the Bioyond-specific use of
`BioyondV1RPC`.
4. `ROS2WorkstationNode.update_resource(resources)` owns the UniLabOS/cloud
resource-tree update.
5. Optional HTTP report handlers may trigger local deck mutation, external sync,
and cloud publication.
The documented startup sequence is:
1. Create workstation.
2. Initialize PLR deck and warehouses.
3. Create Bioyond RPC hardware interface.
4. Create resource synchronizer.
5. `sync_from_external()`.
6. Initialize ROS node and children.
7. `post_init(ros_node)`.
8. Upload `resources=[deck]`.
See `docs/developer_guide/examples/workstation_architecture.md:308`,
`docs/developer_guide/examples/workstation_architecture.md:497`, and
`docs/developer_guide/examples/workstation_architecture.md:737`.
Important caveat: that document is the hope. It is still valuable because it
names the desired responsibilities, but current Bioyond stations implement a
more limited, best-effort side-effect sync. When the doc and current code
diverge, use the doc to choose direction and the shared base code to choose the
next compatible step.
## Current Practical Behavior
The shared `BioyondWorkstation` implementation is the current behavioral
baseline. It does this today:
1. Requires a deck.
2. Reconstructs `deck.warehouses` from deck children/config when needed.
3. Creates `BioyondV1RPC`.
4. Installs `BioyondResourceSynchronizer`.
5. Immediately calls `sync_from_external()`.
6. Later, in `post_init`, publishes the whole deck with
`ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True,
resources=[self.deck])`.
Relevant code:
- `BioyondResourceSynchronizer` is defined in
`unilabos/devices/workstation/bioyond_studio/station.py:117`.
- `sync_from_external()` fetches stock `typeMode` 0, 1, and 2, then passes all
rows to `resource_bioyond_to_plr(...)`
(`unilabos/devices/workstation/bioyond_studio/station.py:147`).
- `BioyondWorkstation.__init__` installs the synchronizer and syncs immediately
(`unilabos/devices/workstation/bioyond_studio/station.py:856`).
- `post_init()` uploads the deck (`unilabos/devices/workstation/bioyond_studio/station.py:893`).
- `resource_tree_add()` performs Bioyond create/inbound side effects
(`unilabos/devices/workstation/bioyond_studio/station.py:958`).
This practical behavior works for simple physical-resource stock import, but it
does not provide full continuous two-way sync, conflict resolution, or reliable
stale-state cleanup. In particular:
- Re-fetching stock does not clearly clear stale local deck state first.
- Local-to-external update no-ops unless `unilabos_extra["update_resource_site"]`
is present.
- `process_material_change_report()` is mostly TODO-level in the base station.
- Some station-specific code bypasses the shared synchronizer with direct LIMS
calls.
- Some existing stations contain shortcuts that should not be copied, such as
duplicate initialization, stale globals, or hardcoded warehouse/axis cases.
Treat current Bioyond behavior as operationally useful and compatibility
important, not as proof that the ideal architecture is already achieved. The
goal is to tighten this base path, not replace it with a Sirna-only path.
## Recommended Shared Pipeline
For Sirna and similar Bioyond systems, the base synchronizer should evolve from
the current shared path into one shared pipeline with small project hooks:
```text
sync_from_external()
fetch stock rows from Bioyond
update RPC material cache
normalize rows into a common internal shape
preserve materialTypeMode: Sample / Consumables / Reagent
resolve warehouse/location by Bioyond IDs when available
choose handling for the row's mode
apply Sample / Consumables rows as mapped slot labware by default
apply Reagent rows as physical reagent labware or liquid content by evidence
report unknown modes or unmapped handling loudly
publish deck if deck changed and ROS node is available
```
Suggested extension points:
```python
class BioyondResourceSynchronizer(ResourceSynchronizer):
def external_material_mode(self, row: dict) -> str:
return row["materialTypeMode"] # Sample | Consumables | Reagent
def resolve_external_row(self, row: dict) -> dict:
...
def apply_external_row(self, row: dict, mode: str, resolved: dict) -> None:
...
```
The base class should continue to own:
- stock fetches for `typeMode` 0/1/2;
- material-cache refresh;
- common normalization;
- mode validation for `Sample` / `Consumables` / `Reagent`;
- publication orchestration;
- error aggregation;
- stale-state policy once agreed.
Project code should own only the parts that are truly deployment-specific:
- material type/mode to PLR class mapping;
- project-local handling inside `Sample` / `Consumables` / `Reagent`;
- project-local warehouse ID/name mapping;
- project-local coordinate conventions;
- special row handling such as Sirna reagent-as-liquid.
This keeps existing Bioyond stations close to the same lifecycle while still
absorbing the Sirna lessons that the earlier base did not model. The default
hook behavior can remain "Sample/Consumables/Reagent rows become mapped slot
labware" for simpler stations; Sirna should override Reagent handling where
evidence requires liquid-content behavior.
## Sirna Findings To Feed Back Into Shared Design
Treat Sirna as a stress test for the shared base, not as a replacement design.
It raises real questions that earlier stations did not need to answer:
- within the `Reagent` mode, some external rows may be liquid contents rather
than slot-occupying reagent labware;
- placement should be ID-first where Bioyond supplies material/location IDs;
- warehouse and axis metadata must survive serialize/deserialize;
project-specific mode handling is available.
For Sirna-like deployments, the old implicit rule "stock row equals PLR
resource" is too coarse. For simpler deployments, it can remain the default
mode-handling behavior.
### IDs Win
Placement identity should prefer:
```text
materialId
locationId
materialTypeId
warehouseId / whid
```
Display/debug fields are not identity:
```text
materialName
materialCode
locationCode
locationShowName
warehouseName / whName
```
`locationCode` such as `1-1` is not globally unique. It can exist in multiple
warehouses. Code-only resolution may be kept as a diagnostic fallback, but it
must raise on ambiguity.
The current Sirna ID-first resolver is useful source evidence for this direction
(`unilabos/devices/workstation/bioyond_studio/sirna_station/sirna_station.py:3659`).
The Sirna mega plan captures the same concern, but the implementation and live
Bioyond IDs are the stronger evidence.
### Bioyond Modes And UniLabOS Handling Are Different
Bioyond has three primary material modes in this context:
```text
Sample
Consumables
Reagent
```
Those modes should be preserved as the external taxonomy. UniLabOS still needs a
handling decision inside the mode: should the row create/place a physical PLR
resource, or update contents on an already-placed parent resource?
Physical slot resources include things like:
- tip racks;
- plates;
- cell culture plates;
- empty trough/bottle labware;
- other objects that occupy a warehouse slot.
Reagents are contents of a parent labware when the row describes a liquid rather
than a physical holder. For Sirna, Bioyond `stock_material(typeMode=2)` returns
`materialTypeMode="Reagent"` rows; those rows still need evidence-based handling
as either physical reagent labware or reagent liquid content. The finding in
`temp_benyao/sirna/_findings/2026-05-07_reagents_vs_resources.md:6` records the
bug: the generic path can fall back to `RegularContainer`, fail registry lookup,
and drop reagent rows such as `试剂槽裂解液/Betame`.
Recommended behavior:
1. Validate `materialTypeMode` as `Sample`, `Consumables`, or `Reagent`.
2. For `Sample` and `Consumables`, default to mapped slot labware: instantiate
the mapped PLR class and place it into the resolved warehouse slot.
3. For `Reagent`, decide whether the row represents physical reagent labware or
reagent liquid content from material type evidence, row shape, and live data.
4. For physical reagent labware, place the mapped PLR resource in the resolved
slot.
5. For reagent liquid content, find the parent trough/bottle and attach liquid
metadata to its tracker.
6. Preserve Bioyond IDs in `parent.unilabos_extra["reagent_bioyond_ids"]`.
7. Make liquid attachment idempotent by Bioyond material ID.
8. If the parent labware is missing or the mode/handling cannot be resolved,
defer or log with IDs; do not guess.
The existing Sirna helper `_attach_liquid_to_parent()` already follows the
idempotent metadata direction
(`unilabos/devices/workstation/bioyond_studio/sirna_station/sirna_station.py:4074`).
### The `0003` Question Must Stay Evidence-Based
Do not blindly map `materialTypeCode 0003` to `BioyondSirna_ReagentTrough`.
If live/schema evidence says a `0003` row is physical trough labware, map it to
a PLR resource class. If evidence says it is liquid content, attach it to the
parent trough. If evidence is contradictory, mark it unsupported and log the
Bioyond IDs.
Treat this as an open design decision until source/schema/live evidence settles
the row shape. The Sirna plan records the question, but it should not decide the
mapping by itself.
### Sirna Warehouse And Axis Rules Are Project-Local
Sirna warehouse layout and axis conventions must be verified from Sirna source,
Sirna schema, current deck behavior, and live/read-only Sirna APIs when
available. Do not import Peptide, reaction, dispensing, or cell station layout
truth.
For Sirna specifically, the current integrated deck display should be treated as
the good baseline. The prior col-row slot-key fix is already present in the
deck/graphio path, and any remaining x/y or y-reverse correction should be
handled through shared warehouse metadata and shared graphio/display mapping
rather than by reshaping the Sirna station display ad hoc.
### Display Geometry: Evidence Before Axis Values
Peptide resource sync should not be treated as a validated model, but its
UniLabOS display work is useful and should influence this guidance.
Confirmed Peptide behavior:
- Peptide live warehouse evidence found `自动化堆栈` with 170 locations where
`code="10-17"` appears at `x=17, y=10`
(`../Uni-Lab-OS-Peptide/temp_benyao/peptide/_findings/2026-05-13_1404_peptide_col_row_deck.md:6`).
- Peptide's current display convention is Peptide-specific evidence:
`bioyond_axis="xy_col_row"` and `bioyond_key_axis="col_row"` in the current
Peptide station implementation. Do not copy that pair into Sirna or any other
project without live warehouse evidence for that target system. With this
Peptide combination, graphio does not apply the legacy x/y swap
(`../Uni-Lab-OS-Peptide/unilabos/resources/graphio.py:868`).
- Peptide models the main automation stack as 17 visual rows by 10 visual
columns while preserving labels such as `10-17`
(`../Uni-Lab-OS-Peptide/unilabos/resources/bioyond/decks.py:308`).
- Peptide warehouses and deck child positions use `frontend_y_flip=True` /
`_frontend_y_flipped_coordinate(...)` so stored PLR coordinates compensate for
the frontend y-axis inversion
(`../Uni-Lab-OS-Peptide/unilabos/resources/bioyond/decks.py:299`;
`temp_benyao/peptide/_findings/2026-05-13_1514_frontend_y_flip_layout.md:6`).
- The older Peptide graph-layout note is stale for the Sirna discussion. The
current Sirna integrated station/deck display is a good baseline; the reusable
Peptide lesson is axis metadata and frontend y-reverse compatibility.
- Peptide tests encode the intended behavior: `10-17` lands at row 17 /
column 10, and after frontend y-flip the displayed site positions match the
expected top-to-bottom layout
(`../Uni-Lab-OS-Peptide/temp_benyao/peptide/tests/test_peptide_deck_layout.py:60`).
Guidance for Sirna and similar systems:
1. Treat `bioyond_axis` and `bioyond_key_axis` as two separate concepts:
- `bioyond_axis` describes how Bioyond numeric `x/y` map to visual axes.
- `bioyond_key_axis` describes how slot labels such as `10-17` are generated
or preserved.
2. Do not infer display orientation from label strings alone. Both `row_col`
and `col_row` can preserve the same final label text while producing
different visual layouts and graphio swap behavior.
3. Use live/read-only `code/x/y` evidence for each project and each warehouse
family before choosing axis metadata.
4. Keep visual orientation fixes separate from material identity. IDs and slot
labels determine registration; display dimensions and frontend y-flip only
determine how the deck appears.
5. Author intended display coordinates first, then convert stored y coordinates
for the active frontend convention:
- deck child stored y = `deck_height - display_y - child_height`;
- warehouse site stored y = `warehouse_height - display_y - site_height`;
- graph-level y values should be transformed only when the active frontend
convention requires it.
6. Preserve the current Sirna display as the baseline unless concrete frontend
evidence shows a y-reverse problem. Do not change station/deck graph
semantics just to match stale Peptide layout notes.
7. Add layout tests that assert:
- warehouse `num_items_x`, `num_items_y`, capacity, first key, and last key;
- representative `code/x/y` examples land on the intended site key;
- frontend y-flip produces expected displayed positions;
- generated deck children do not overlap in displayed coordinates.
8. When borrowing from Peptide, borrow the evidence pattern: live discovery,
axis/key-axis metadata, y-flip tests, and display fixtures. Do not borrow a
literal axis pair or Peptide resource-sync behavior as a validated path.
Concrete live discovery workflow:
1. Prefer the reusable read-only probe pattern before reading old findings:
```bash
python3 temp_benyao/sirna/tests/probe_readonly_storage_inventory.py \
--base-url <api_host> \
--api-key <api_key> \
--output temp_benyao/<project>/_logs/<timestamp>_readonly_storage_inventory_probe.json
```
This probe checks swagger candidates, project storage/location endpoints,
material type endpoints, `warehouse-info-by-mat-type-id`, and `stock-material`,
then writes a redacted evidence file and a merged warehouse summary.
2. For Sirna quick checks, `temp_benyao/sirna/tests/discover_sirna_warehouses.py`
is the narrower historical helper. Treat it as a template unless it has been
parameterized for the target config.
3. Query `/api/storage/location/locations-by-type?type=0&typeMode=0&materialType=0`
first for stack names, warehouse IDs, full slot lists, `code`, `x`, `y`, `z`,
and display mode. This endpoint is the topology source when available.
4. Cross-reference with `/api/lims/storage/material-types` and
`/api/lims/storage/warehouse-info-by-mat-type-id` to learn material-type
placement constraints. Use `stock-material` only for occupied-slot evidence;
it cannot reveal empty topology.
5. Infer `bioyond_key_axis` from how slot labels must be generated or preserved,
and infer `bioyond_axis` from how raw Bioyond `x/y` must map to PLR holder
indices. A label such as `10-17` alone is ambiguous; compare it to the same
record's `x/y`, and use boundary examples from non-square stacks.
6. Encode the discovered convention on the warehouse resource, not in a one-off
station branch. The metadata must serialize/deserialize with the warehouse
because graph load and cloud sync rebuild resources.
7. Add or update layout tests before changing Sirna display behavior. For Sirna,
test against the current good display first, then only change shared x/y or
y-reverse mapping if the fixture demonstrates a real mismatch.
### Deserialize Must Be Idempotent
This is confirmed framework behavior, not a possible risk. Resource publication
builds `ResourceTreeSet` from live PLR resources by calling
`resource.serialize()` and `resource.serialize_all_state()`
(`unilabos/resources/resource_tracker.py:547`). Readback/reconstruction goes
through `ResourceTreeSet.to_plr_resources()`, finds the PLR subclass, calls
`sub_cls.deserialize(...)`, then restores PLR state, UUIDs, and `unilabos_extra`
(`unilabos/resources/resource_tracker.py:637`). `ResourceTreeSet.dump()` also
serializes resource nodes while excluding `children` from each individual node
record (`unilabos/resources/resource_tracker.py:914`), so parent/child
relationships and object state must survive the framework tree shape rather than
only an in-memory PLR object graph.
Sirna resource/deck classes therefore must survive:
1. registry-time construction;
2. `Resource.deserialize()` round trips;
3. cloud-synced deck state with serialized children.
If a synced deck already has serialized `children`, default setup must not
create duplicate/stale child resources. Sirna noticed this problem, but the
reason is framework-level: cloud/resource-tree sync reconstructs PLR objects
from serialized state, so constructors and setup logic must be idempotent.
Existing Sirna deck code already acknowledges the issue: `BIOYOND_Sirna_Deck`
turns `setup=False` during deserialize when serialized `children` are present
(`unilabos/resources/bioyond/decks.py:153`). Sirna material classes also use
registry-visible `@resource(...)` decorators and tolerant constructors with
`*args, **kwargs`, defaults, and `ordering` fallbacks
(`unilabos/resources/bioyond/sirna_materials.py:14`). Treat those as required
patterns for any new synced resource class.
Guidance:
- Do not add a deck/resource class until it round-trips through
`Resource.serialize()` / `Resource.deserialize()` and
`ResourceTreeSet.from_plr_resources(...).to_plr_resources()`.
- Preserve `unilabos_uuid`, parentage, `unilabos_extra`, and liquid state across
the round trip.
- Make default setup conditional: if serialized children exist, do not recreate
default warehouses or default child resources on top of them.
- Itemized PLR subclasses must provide `ordered_items` or `ordering`; otherwise
deserialization can fail or rebuild a different structure.
## Startup And Resync Recommendation
The current Sirna code installs `SirnaResourceSynchronizer` after
`super().post_init(ros_node)` (`sirna_station.py:363`). That is useful as a
phase patch and as evidence that classification hooks are needed, but it should
not become the long-term lifecycle. Base initialization may already have run
generic stock sync and base post-init may already have published the deck before
the Sirna synchronizer is installed.
Recommended pathway:
1. Keep the `BioyondWorkstation` lifecycle as the base path.
2. Add a synchronizer factory or hook registration point before eager sync, for
example `create_resource_synchronizer()`.
3. Let Sirna provide classification/resolution hooks through that shared
synchronizer shape.
4. Make startup sync use the installed hook-aware synchronizer before the first
deck publication.
5. Make manual resync, reset resync, report-triggered resync, and future
periodic sync all call `self.resource_synchronizer.sync_from_external()`.
6. Do not instantiate fresh base synchronizers inside Sirna actions, because
that bypasses the installed project logic.
This directly addresses the overlap finding in
`temp_benyao/sirna/_findings/2026-05-12_sirna_synchronizer_overlap.md:6`.
## Material Registration From Create-Order Results
Create-order allocation registration should remain separate from stock sync, but
it should use the same classification and apply logic.
Recommended create-order pipeline:
1. Normalize allocation records into:
```python
{
"materialId": "...",
"materialCode": "...",
"materialName": "...",
"materialTypeId": "...",
"materialTypeCode": "...",
"materialTypeMode": "Sample|Consumables|Reagent",
"materialTypeName": "...",
"locationId": "...",
"locationCode": "...",
"locationShowName": "...",
}
```
2. Resolve warehouse and slot by:
```text
material-info(materialId)
-> warehouse-info-by-mat-type-id(materialTypeId) matched by locationId
-> code-only diagnostic fallback only if unambiguous
```
3. Classify as `slot_labware`, `liquid_content`, or `unsupported`.
4. Apply mutation to the PLR deck.
5. Publish the full deck once after the batch.
The current Sirna `_register_materials_to_tree()` already documents this flow
(`sirna_station.py:3659`) and should be the local reference implementation until
the shared hook is designed.
## Cloud / UniLabOS Publication Rules
Always mutate real tracked PLR resources first. Then publish through the
framework.
Correct call shape:
```python
ROS2DeviceNode.run_async_func(
self._ros_node.update_resource,
True,
**{"resources": [self.deck]},
)
```
The real method is:
```python
async def update_resource(self, resources: List["ResourcePLR"])
```
See `unilabos/ros/nodes/base_device_node.py:727`.
Do not call:
```python
update_resource(resource_name=..., resource_data=...)
```
Do not manually serialize the deck for this path. `update_resource()` creates a
`ResourceTreeSet`, sends it to `/c2s_update_resource_tree`, and applies returned
UUID mappings. Missing root parent UUIDs are auto-mounted to the current device,
so parentage should be preserved on PLR objects before publication.
## Metadata Contract
Every Bioyond-originated slot resource should carry enough metadata for unload,
audit, and later sync:
```python
plr_resource.unilabos_extra = {
"material_bioyond_id": mat["materialId"],
"material_bioyond_code": mat["materialCode"],
"material_bioyond_name": mat["materialName"],
"material_bioyond_type_id": mat["materialTypeId"],
"material_bioyond_type_code": mat["materialTypeCode"],
"material_bioyond_type_mode": mat["materialTypeMode"],
"location_bioyond_id": mat["locationId"],
"location_code": resolved["location_code"],
"warehouse_bioyond_id": resolved["warehouse_id"],
"warehouse_bioyond_name": resolved["warehouse_name"],
"location_resolution_source": resolved["source"],
}
```
Every Bioyond-originated liquid content should preserve equivalent metadata on
the parent labware:
```python
parent.unilabos_extra.setdefault("reagent_bioyond_ids", []).append({
"material_bioyond_id": mat["materialId"],
"material_bioyond_code": mat["materialCode"],
"material_bioyond_name": mat["materialName"],
"material_bioyond_type_id": mat["materialTypeId"],
"material_bioyond_type_code": mat["materialTypeCode"],
"location_bioyond_id": mat["locationId"],
"quantity": mat.get("quantity"),
"location_resolution_source": resolved["source"],
})
```
This metadata is not decorative. It is required for correct unload, audit,
duplicate prevention, and future round-trip sync.
## Stale State And Conflict Policy
This is confirmed current behavior, not just a theoretical risk.
Current stock import places resources into empty warehouse slots and skips
occupied slots. That prevents overwrites, but it can leave stale local resources
after external moves/deletes.
Evidence:
- `BioyondResourceSynchronizer.sync_from_external()` fetches Bioyond stock and
delegates to `resource_bioyond_to_plr(...)`, but it does not compute a
before/after diff or remove local resources absent from the snapshot
(`unilabos/devices/workstation/bioyond_studio/station.py:147`).
- `resource_bioyond_to_plr(...)` only assigns when the target warehouse position
is empty or a placeholder; when a real resource already occupies the slot, it
logs "跳过放置" and leaves the existing object in place
(`unilabos/resources/graphio.py:936`).
- Bioyond outbound/removal logic exists in separate local-to-external hooks such
as `resource_tree_remove(...)`, but that is not invoked by stock refresh for
Bioyond rows that disappeared externally
(`unilabos/devices/workstation/bioyond_studio/station.py:987`).
Recommended direction:
1. Define Bioyond as the source of truth for external stock snapshots unless a
local operation is in progress.
2. Before applying a stock snapshot, compute a diff by Bioyond material ID.
3. Remove or mark local resources whose Bioyond IDs disappeared from the
snapshot, subject to workflow safety checks.
4. Move local resources whose Bioyond location ID changed.
5. Attach/detach liquid contents idempotently by Bioyond material ID.
6. Publish once after applying the batch.
7. In ambiguous or active-operation cases, log and require manual confirmation.
Until this policy exists, call the current implementation "refresh/import" rather
than "authoritative synchronization."
## External-To-Local And Local-To-External Boundaries
External-to-local:
- Bioyond stock and create-order allocation rows mutate the local PLR deck.
- Then UniLabOS publication derives resource trees from the PLR deck.
Local-to-external:
- `resource_tree_add` / update / remove may call Bioyond add, inbound, outbound,
or move APIs.
- These paths should require Bioyond IDs or explicit creation parameters.
- Movement should use `unilabos_extra["update_resource_site"]` only as an
explicit request, not as hidden ambient state.
- After successful Bioyond side effects, refresh or update the PLR deck and
publish the deck.
Avoid station-level direct LIMS calls that bypass the synchronizer unless the
action explicitly reconciles the deck afterward.
## Error Handling Rules
Fail loudly on:
- unknown material type code/name;
- unresolved warehouse ID/name;
- location ID not found in `warehouse-info-by-mat-type-id`;
- code-only location ambiguity;
- missing parent labware for liquid content;
- invalid PLR resource class or `Resource.deserialize()` failure;
- Bioyond RPC returning empty/fallback values where an ID is required.
Do not silently create `RegularContainer` to hide mapping failures. The Sirna
finding at `temp_benyao/sirna/_findings/2026-05-07_reagents_vs_resources.md:52`
calls this out as a reusable trap.
## Tests And Validation
Minimum offline tests:
1. `update_resource` is called only with `resources=[deck]`.
2. `ResourceTreeSet.from_plr_resources([deck])` preserves UUIDs, parentage,
`data`, and `unilabos_extra`.
3. Sirna allocation records resolve by `material-info` first.
4. `warehouse-info-by-mat-type-id` resolves by `locationId`.
5. Code-only fallback raises on ambiguous slots.
6. `Sample` and `Consumables` rows become slot labware.
7. Reagent content rows become parent liquid metadata.
8. Re-running registration does not duplicate liquid entries.
9. Missing parent labware is deferred/logged, not guessed.
10. Unknown material type emits Bioyond IDs.
11. Deck deserialize does not recreate duplicate default children.
12. Warehouse coordinate mapping is tested per warehouse, not globally.
13. A synced Sirna deck round-trips through PLR deserialize and
`ResourceTreeSet` conversion without duplicate default children.
14. Stock refresh with a missing Bioyond material ID proves current add/skip
behavior first, then verifies the chosen diff/delete policy once implemented.
15. Display geometry tests cover project-local live discovery fixtures,
`bioyond_axis`, `bioyond_key_axis`, representative `code/x/y` mappings,
frontend y-flip, and no-overlap deck layout.
Focused Sirna command:
```bash
pytest temp_benyao/sirna/tests/test_sirna_resource_system.py -q
```
Shared conversion command, when changing graphio or Bioyond converters:
```bash
pytest tests/resources/test_converter_bioyond.py -q
```
Live/read-only validation should capture fixtures for:
- `stock_material(typeMode=0/1/2, includeDetail=true)`;
- `material-info(materialId)`;
- `warehouse-info-by-mat-type-id(materialTypeId)`;
- create-order allocation records;
- frontend/cloud resource-tree readback after publication.
## Discrepancies To Discuss
### 1. Architecture Is Direction; Base Code Is The Compatibility Anchor
The architecture guide presents `ResourceSynchronizer` as bidirectional. Current
Bioyond code is mostly eager external import plus selected add/remove/update
side effects. It has no complete continuous conflict-resolution loop.
Sirna current code should be read in this context: it exposes missing
capabilities, but parts of it conflict with the existing base lifecycle because
it was layered on after the base sync behavior already existed.
Recommendation: keep the bidirectional architecture as the target, preserve
`BioyondWorkstation` as the implementation anchor, and introduce shared hooks
for the missing Sirna-class problems. Describe the current implementation as
best-effort refresh plus Bioyond side effects until a diff/conflict policy
exists.
### 2. The Doc Centers `Deck`; The Framework Centers `ResourceTreeSet`
The architecture guide uses PLR `Deck` as the material system. UniLabOS resource
tracking uses `ResourceTreeSet` as the canonical serialized representation.
Recommendation: phrase the model as "Deck is the local PLR mutation surface;
ResourceTreeSet is the UniLabOS/cloud representation derived from it."
### 3. Base Bioyond Sync Treats Every Stock Row As PLR Resource
`BioyondResourceSynchronizer.sync_from_external()` currently feeds typeMode
0/1/2 rows into `resource_bioyond_to_plr(...)`.
Sirna evidence shows this is wrong for reagent/content-like rows.
Recommendation: add classification hooks before conversion. Rows classified as
liquid content must not go through standalone PLR resource conversion.
### 4. Sirna Fix Exists As A Fork, Not A Shared Hook
Current `SirnaResourceSynchronizer` captures the important reagent-as-liquid
question, but it duplicates or bypasses shared sync mechanics. Startup can run
base sync before the Sirna synchronizer is installed, and manual/reset paths
have used fresh base synchronizers.
Recommendation: keep the discovered behavior where evidence supports it, but
refactor the shape. A factory/hook in the shared synchronizer is preferable to a
long-lived parallel sync class.
### 5. Sirna Create-Order Registration Is Stronger Than Stock Sync
Create-order registration now has ID-first resolution and a clearer classifier.
That is valuable evidence, not automatically the canonical sync design. The
stock sync path still resolves external liquid rows by warehouse name/code and
stores weaker metadata.
Recommendation: extract the stronger resolver/classifier ideas into shared
hooks, then converge stock sync and create-order registration on the same
functions.
### 6. Graphio Has Shared Fragility
`resource_bioyond_to_plr()` contains hardcoded warehouse/coordinate cases and a
fallback to `RegularContainer`. Hardening it affects multiple Bioyond projects.
Recommendation: after Sirna is green, discuss shared graphio hardening in a
separate change: no silent `RegularContainer`, explicit unknown-type diagnostics,
and project-specific axis metadata instead of hardcoded names.
### 7. Serialize/Deserialize Is A Gating Contract
This is not optional documentation neatness. Any new resource/deck class or sync
metadata must pass the framework serialize/deserialize path before it can be
trusted in cloud-synced operation.
Recommendation: make round-trip tests part of acceptance for resource-system
changes, especially for Sirna deck setup, warehouse metadata, `reagent_bioyond_ids`,
liquid tracker state, and itemized labware ordering.
### 8. Existing Stock Sync Is Add/Skip-Biased, Not Delete-Aware
Current `sync_from_external()` imports and places observed Bioyond resources,
but it does not delete local resources missing from the latest stock snapshot.
When a target slot is occupied by a real resource, graphio skips placement rather
than replacing it.
Recommendation: describe the current behavior as "stock refresh/import" until a
Bioyond-ID diff policy is implemented. Deletion/removal must be a deliberate
phase, with workflow-safety checks and tests, not implied by the current stock
sync name.
### 9. Peptide Display Is Useful; Peptide Sync Is Not Yet Authority
Peptide's latest display work is useful evidence for Peptide's own warehouse
axis/key-axis metadata and for the shared need to account for frontend y-axis
inversion. It does not define Sirna's axis values. Its resource sync remains
untested/problematic and should not be treated as the model for Sirna sync
behavior.
Recommendation: fold the Peptide display lesson into shared guidance as a
discovery-and-test pattern, not as a literal axis pair. Keep Sirna display
behavior grounded in Sirna live evidence and the current good deck baseline; keep
resource synchronization recommendations grounded in the Sirna ID-first
registration path and the shared Bioyond synchronizer refactor.
### 10. Some Existing Stations Should Be Examples Only, Not Templates
Non-Sirna stations show useful patterns, but also shortcuts:
- direct LIMS calls bypassing the synchronizer;
- duplicate `super().__init__()` in reaction station;
- stale/undefined `WAREHOUSE_MAPPING` references;
- RPC methods collapsing failures into empty values;
- no robust stale deck cleanup on stock refresh.
Recommendation: borrow the shared deck publication and Bioyond ID metadata
patterns, not the incidental shortcuts.
## Recommended Discussion Path
For the next implementation discussion, decide these in order:
1. What is the smallest shared-base extension that lets project hooks exist
before eager sync without breaking existing Bioyond stations?
2. Should `BioyondWorkstation` gain a synchronizer factory, a hook registry, or
a delayed eager-sync point?
3. Is `materialTypeCode 0003` in current Sirna live data physical labware,
liquid content, or both depending on row shape?
4. Where should Sirna warehouse Bioyond IDs live: station config, deck metadata,
or both?
5. Should stock refresh clear/diff deck state now, or remain append/skip until
after create-order registration is stable?
6. Should `update_resource` remain async/non-blocking for material registration,
or should selected user-facing actions fail if publication fails?
7. Which parts of the Sirna reagent-as-liquid rule are project-local, and which
should become shared Bioyond behavior after Peptide/cell validation?
8. Should graphio fallback hardening land before or after Sirna stock sync is
fully validated?
9. Should any remaining Sirna x/y or y-reverse issue be fixed in shared
graphio/display mapping after a live fixture proves it, or should the current
Sirna deck remain untouched?
## Recommended Pathway
The pragmatic path is:
1. Preserve and test the current `BioyondWorkstation` lifecycle as the shared
compatibility baseline.
2. Treat Sirna create-order registration as a local proof of the missing
classification/ID-resolution behavior, not as the architecture shape to copy.
3. Validate live/read-only Sirna fixtures for `0003`, warehouse IDs, and stock
reagent rows.
4. Refactor the shared Bioyond synchronizer to expose classification,
resolution, and non-slot row hooks with default behavior matching existing
stations.
5. Make Sirna install hooks before the first external sync and first deck
publication through the shared lifecycle.
6. Route startup, manual resync, reset resync, and report-triggered resync
through the installed synchronizer.
7. Converge stock sync and create-order registration on one
resolver/classifier/apply implementation.
8. Add stale-state diffing only after the basic ID-first path is stable.
9. Harden graphio fallback as a shared follow-up.
This gives Sirna the necessary behavior without locking in a second Bioyond sync
system that future projects will have to debug around.

View File

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

View File

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

View File

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

View File

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

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 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__))
unilabos_dir = os.path.dirname(os.path.dirname(current_dir))
@@ -233,7 +242,7 @@ def parse_args():
parser.add_argument(
"--addr",
type=str,
default="https://uni-lab.bohrium.com/api/v1",
default="https://leap-lab.bohrium.com/api/v1",
help="Laboratory backend address",
)
parser.add_argument(
@@ -438,10 +447,10 @@ def main():
if args.addr != parser.get_default("addr"):
if args.addr == "test":
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":
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":
print_status("使用本地环境地址", "info")
HTTPConfig.remote_addr = "http://127.0.0.1:48197/api/v1"
@@ -553,7 +562,7 @@ def main():
os._exit(0)
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)
graph: nx.Graph
resource_tree_set: ResourceTreeSet

View File

@@ -36,6 +36,9 @@ class HTTPClient:
auth_secret = BasicConfig.auth_secret()
self.auth = 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}")
def resource_edge_add(self, resources: List[Dict[str, Any]]) -> requests.Response:
@@ -48,7 +51,7 @@ class HTTPClient:
Returns:
Response: API响应对象
"""
response = requests.post(
response = self._session.post(
f"{self.remote_addr}/edge/material/edge",
json={
"edges": resources,
@@ -75,26 +78,28 @@ class HTTPClient:
Returns:
Dict[str, str]: 旧UUID到新UUID的映射关系 {old_uuid: new_uuid}
"""
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_add.json"), "w", encoding="utf-8") as f:
payload = {"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid}
f.write(json.dumps(payload, indent=4))
# 从序列化数据中提取所有节点的UUID保存旧UUID
old_uuids = {n.res_content.uuid: n for n in resources.all_nodes}
# dump() 只调用一次,复用给文件保存和 HTTP 请求
nodes_info = [x for xs in resources.dump() for x in xs]
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:
self.initialized = True
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
response = requests.post(
response = self._session.post(
f"{self.remote_addr}/edge/material",
json={"nodes": nodes_info, "mount_uuid": mount_uuid},
headers={"Authorization": f"Lab {self.auth}"},
data=body_bytes,
headers=http_headers,
timeout=60,
)
else:
response = requests.put(
response = self._session.put(
f"{self.remote_addr}/edge/material",
json={"nodes": nodes_info, "mount_uuid": mount_uuid},
headers={"Authorization": f"Lab {self.auth}"},
data=body_bytes,
headers=http_headers,
timeout=10,
)
@@ -133,7 +138,7 @@ class HTTPClient:
"""
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))
response = requests.post(
response = self._session.post(
f"{self.remote_addr}/edge/material/query",
json={"uuids": uuid_list, "with_children": with_children},
headers={"Authorization": f"Lab {self.auth}"},
@@ -147,6 +152,7 @@ class HTTPClient:
logger.error(f"查询物料失败: {response.text}")
else:
data = res["data"]["nodes"]
logger.trace(f"resource_tree_get查询到物料: {data}")
return data
else:
logger.error(f"查询物料失败: {response.text}")
@@ -164,14 +170,14 @@ class HTTPClient:
if not self.initialized:
self.initialized = True
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
response = requests.post(
response = self._session.post(
f"{self.remote_addr}/lab/material",
json={"nodes": resources},
headers={"Authorization": f"Lab {self.auth}"},
timeout=100,
)
else:
response = requests.put(
response = self._session.put(
f"{self.remote_addr}/lab/material",
json={"nodes": resources},
headers={"Authorization": f"Lab {self.auth}"},
@@ -198,7 +204,7 @@ class HTTPClient:
"""
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))
response = requests.get(
response = self._session.get(
f"{self.remote_addr}/lab/material",
params={"id": id, "with_children": with_children},
headers={"Authorization": f"Lab {self.auth}"},
@@ -239,14 +245,14 @@ class HTTPClient:
if not self.initialized:
self.initialized = True
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
response = requests.post(
response = self._session.post(
f"{self.remote_addr}/lab/material",
json={"nodes": resources},
headers={"Authorization": f"Lab {self.auth}"},
timeout=100,
)
else:
response = requests.put(
response = self._session.put(
f"{self.remote_addr}/lab/material",
json={"nodes": resources},
headers={"Authorization": f"Lab {self.auth}"},
@@ -276,7 +282,7 @@ class HTTPClient:
with open(file_path, "rb") as file:
files = {"files": file}
logger.info(f"上传文件: {file_path}{scene}")
response = requests.post(
response = self._session.post(
f"{self.remote_addr}/api/account/file_upload/{scene}",
files=files,
headers={"Authorization": f"Lab {self.auth}"},
@@ -316,7 +322,7 @@ class HTTPClient:
"Content-Type": "application/json",
"Content-Encoding": "gzip",
}
response = requests.post(
response = self._session.post(
f"{self.remote_addr}/lab/resource",
data=compressed_body,
headers=headers,
@@ -350,7 +356,7 @@ class HTTPClient:
Returns:
Response: API响应对象
"""
response = requests.get(
response = self._session.get(
f"{self.remote_addr}/edge/material/download",
headers={"Authorization": f"Lab {self.auth}"},
timeout=(3, 30),
@@ -411,7 +417,7 @@ class HTTPClient:
with open(os.path.join(BasicConfig.working_dir, "req_workflow_upload.json"), "w", encoding="utf-8") as f:
f.write(json.dumps(payload, indent=4, ensure_ascii=False))
response = requests.post(
response = self._session.post(
f"{self.remote_addr}/lab/workflow/owner/import",
json=payload,
headers={"Authorization": f"Lab {self.auth}"},

View File

@@ -1269,7 +1269,13 @@ class QueueProcessor:
if not queued_jobs:
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:
# 快照可能已过期:在遍历过程中 end_job() 可能已将此 job 移至 READY

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import logging
import time as time_module
from typing import Dict, Any
from unilabos.registry.decorators import topic_config
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
class VirtualStirrer:
@@ -314,9 +315,11 @@ class VirtualStirrer:
def min_speed(self) -> float:
return self._min_speed
def get_device_info(self) -> Dict[str, Any]:
"""获取设备状态信息 📊"""
info = {
@property
@topic_config()
def device_info(self) -> Dict[str, Any]:
"""设备状态快照信息 📊"""
return {
"device_id": self.device_id,
"status": self.status,
"operation_mode": self.operation_mode,
@@ -325,12 +328,9 @@ class VirtualStirrer:
"is_stirring": self.is_stirring,
"remaining_time": self.remaining_time,
"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):
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)"

View File

@@ -4,6 +4,7 @@ from enum import Enum
from typing import Union, Optional
import logging
from unilabos.registry.decorators import topic_config
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
@@ -385,8 +386,10 @@ class VirtualTransferPump:
"""获取当前体积"""
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
def is_empty(self) -> bool:

View File

@@ -14,19 +14,30 @@ Virtual Workbench Device - 模拟工作台设备
import logging
import time
from typing import Dict, Any, Optional, List
from dataclasses import dataclass
from enum import Enum
from threading import Lock, RLock
from typing import Any, Dict, List, Optional, cast
from typing_extensions import TypedDict
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 返回类型定义 ============
@@ -111,6 +122,7 @@ class HeatingStation:
@device(
id="virtual_workbench",
display_name="虚拟工作台",
category=["virtual_device"],
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 # 加热时间(秒)
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:
device_id = kwargs.pop("id")
@@ -150,9 +174,13 @@ class VirtualWorkbench:
self.data: Dict[str, Any] = {}
# 从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.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()
@@ -161,7 +189,8 @@ class VirtualWorkbench:
# 加热台状态
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()
@@ -290,20 +319,292 @@ class VirtualWorkbench:
self._update_data_status(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(
auto_prefix=True,
description="批量准备物料 - 虚拟起始节点, 生成A1-A5物料, 输出5个handle供后续节点使用",
handles=[
ActionOutputHandle(key="channel_1", data_type="workbench_material",
label="实验1", data_key="material_1", data_source=DataSource.EXECUTOR),
ActionOutputHandle(key="channel_2", data_type="workbench_material",
label="实验2", data_key="material_2", data_source=DataSource.EXECUTOR),
ActionOutputHandle(key="channel_3", data_type="workbench_material",
label="实验3", data_key="material_3", data_source=DataSource.EXECUTOR),
ActionOutputHandle(key="channel_4", data_type="workbench_material",
label="实验4", data_key="material_4", data_source=DataSource.EXECUTOR),
ActionOutputHandle(key="channel_5", data_type="workbench_material",
label="实验5", data_key="material_5", data_source=DataSource.EXECUTOR),
ActionOutputHandle(key="channel_1", data_type="workbench_material", label="实验1", data_key="material_1", data_source=DataSource.EXECUTOR), # noqa: E501
ActionOutputHandle(key="channel_2", data_type="workbench_material", label="实验2", data_key="material_2", data_source=DataSource.EXECUTOR), # noqa: E501
ActionOutputHandle(key="channel_3", data_type="workbench_material", label="实验3", data_key="material_3", data_source=DataSource.EXECUTOR), # noqa: E501
ActionOutputHandle(key="channel_4", data_type="workbench_material", label="实验4", data_key="material_4", data_source=DataSource.EXECUTOR), # noqa: E501
ActionOutputHandle(key="channel_5", data_type="workbench_material", label="实验5", data_key="material_5", data_source=DataSource.EXECUTOR), # noqa: E501
],
)
def prepare_materials(
@@ -316,6 +617,9 @@ class VirtualWorkbench:
作为工作流的起始节点, 生成指定数量的物料编号供后续节点使用。
输出5个handle (material_1 ~ material_5), 分别对应实验1~5。
Args:
count[物料数量]: 要生成的物料数量,默认生成 5 个。
"""
materials = [i for i in range(1, count + 1)]
@@ -336,7 +640,11 @@ class VirtualWorkbench:
LabSample(
sample_uuid=sample_uuid,
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()
],
@@ -346,12 +654,27 @@ class VirtualWorkbench:
auto_prefix=True,
description="将物料从An位置移动到空闲加热台, 返回分配的加热台ID",
handles=[
ActionInputHandle(key="material_input", data_type="workbench_material",
label="物料编号", data_key="material_number", data_source=DataSource.HANDLE),
ActionOutputHandle(key="heating_station_output", data_type="workbench_station",
label="加热台ID", data_key="station_id", data_source=DataSource.EXECUTOR),
ActionOutputHandle(key="material_number_output", data_type="workbench_material",
label="物料编号", data_key="material_number", data_source=DataSource.EXECUTOR),
ActionInputHandle(
key="material_input",
data_type="workbench_material",
label="物料编号",
data_key="material_number",
data_source=DataSource.HANDLE,
),
ActionOutputHandle(
key="heating_station_output",
data_type="workbench_station",
label="加热台ID",
data_key="station_id",
data_source=DataSource.EXECUTOR,
),
ActionOutputHandle(
key="material_number_output",
data_type="workbench_material",
label="物料编号",
data_key="material_number",
data_source=DataSource.EXECUTOR,
),
],
)
def move_to_heating_station(
@@ -363,6 +686,9 @@ class VirtualWorkbench:
将物料从An位置移动到加热台
多线程并发调用时, 会竞争机械臂使用权, 并自动查找空闲加热台
Args:
material_number[物料编号]: 要移动的物料编号,对应 A1、A2 等起始位置。
"""
material_id = f"A{material_number}"
task_desc = f"移动{material_id}到加热台"
@@ -425,7 +751,8 @@ class VirtualWorkbench:
oss_path="",
extra=(
{"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()
@@ -448,7 +775,8 @@ class VirtualWorkbench:
oss_path="",
extra=(
{"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()
@@ -460,14 +788,34 @@ class VirtualWorkbench:
always_free=True,
description="启动指定加热台的加热程序",
handles=[
ActionInputHandle(key="station_id_input", data_type="workbench_station",
label="加热台ID", data_key="station_id", data_source=DataSource.HANDLE),
ActionInputHandle(key="material_number_input", data_type="workbench_material",
label="物料编号", data_key="material_number", data_source=DataSource.HANDLE),
ActionOutputHandle(key="heating_done_station", data_type="workbench_station",
label="加热完成-加热台ID", data_key="station_id", data_source=DataSource.EXECUTOR),
ActionOutputHandle(key="heating_done_material", data_type="workbench_material",
label="加热完成-物料编号", data_key="material_number", data_source=DataSource.EXECUTOR),
ActionInputHandle(
key="station_id_input",
data_type="workbench_station",
label="加热台ID",
data_key="station_id",
data_source=DataSource.HANDLE,
),
ActionInputHandle(
key="material_number_input",
data_type="workbench_material",
label="物料编号",
data_key="material_number",
data_source=DataSource.HANDLE,
),
ActionOutputHandle(
key="heating_done_station",
data_type="workbench_station",
label="加热完成-加热台ID",
data_key="station_id",
data_source=DataSource.EXECUTOR,
),
ActionOutputHandle(
key="heating_done_material",
data_type="workbench_material",
label="加热完成-物料编号",
data_key="material_number",
data_source=DataSource.EXECUTOR,
),
],
)
def start_heating(
@@ -478,6 +826,10 @@ class VirtualWorkbench:
) -> StartHeatingResult:
"""
启动指定加热台的加热程序
Args:
station_id[加热台ID]: 要启动加热的加热台编号。
material_number[物料编号]: 当前加热台上的物料编号。
"""
self.logger.info(f"[加热台{station_id}] 开始加热")
@@ -494,7 +846,8 @@ class VirtualWorkbench:
oss_path="",
extra=(
{"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()
@@ -517,7 +870,8 @@ class VirtualWorkbench:
oss_path="",
extra=(
{"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()
@@ -537,7 +891,8 @@ class VirtualWorkbench:
oss_path="",
extra=(
{"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()
@@ -577,7 +932,9 @@ class VirtualWorkbench:
self._update_data_status(f"加热台{station_id}加热中: {progress:.1f}%")
if time.time() - last_countdown_log >= 5.0:
self.logger.info(f"[加热台{station_id}] {material_id} 剩余 {remaining:.1f}s")
self.logger.info(
f"[加热台{station_id}] {material_id} 剩余 {remaining:.1f}s"
)
last_countdown_log = time.time()
if elapsed >= self.HEATING_TIME:
@@ -594,7 +951,9 @@ class VirtualWorkbench:
self._active_tasks[material_id]["status"] = "heating_completed"
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 {
"success": True,
@@ -608,7 +967,8 @@ class VirtualWorkbench:
oss_path="",
extra=(
{"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()
@@ -619,10 +979,20 @@ class VirtualWorkbench:
auto_prefix=True,
description="将物料从加热台移动到输出位置Cn",
handles=[
ActionInputHandle(key="output_station_input", data_type="workbench_station",
label="加热台ID", data_key="station_id", data_source=DataSource.HANDLE),
ActionInputHandle(key="output_material_input", data_type="workbench_material",
label="物料编号", data_key="material_number", data_source=DataSource.HANDLE),
ActionInputHandle(
key="output_station_input",
data_type="workbench_station",
label="加热台ID",
data_key="station_id",
data_source=DataSource.HANDLE,
),
ActionInputHandle(
key="output_material_input",
data_type="workbench_material",
label="物料编号",
data_key="material_number",
data_source=DataSource.HANDLE,
),
],
)
def move_to_output(
@@ -633,6 +1003,10 @@ class VirtualWorkbench:
) -> MoveToOutputResult:
"""
将物料从加热台移动到输出位置Cn
Args:
station_id[加热台ID]: 已完成加热的加热台编号。
material_number[物料编号]: 要移动到输出位置的物料编号,对应 Cn。
"""
output_number = material_number
@@ -649,7 +1023,8 @@ class VirtualWorkbench:
oss_path="",
extra=(
{"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()
@@ -673,7 +1048,8 @@ class VirtualWorkbench:
oss_path="",
extra=(
{"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()
@@ -693,7 +1069,8 @@ class VirtualWorkbench:
oss_path="",
extra=(
{"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()
@@ -775,7 +1152,8 @@ class VirtualWorkbench:
oss_path="",
extra=(
{"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()

View File

@@ -0,0 +1,459 @@
"""Per-action raw call/response log for Bioyond stations.
When a debug session is active, ``wrap_rpc_http`` replaces a ``BioyondV1RPC``
instance's ``post`` / ``get`` methods with closures that perform the HTTP
transport themselves, capture the request/response details, and append a record
to the active session before returning exactly what ``BaseRequest`` would have
returned. Outside of an active session the wrapped method delegates to the
original (unwrapped) implementation, leaving non-debug behavior intact.
The session writes a Markdown file under ``out_dir`` mirroring the format of
``temp_benyao/peptide/_logs/2026-04-30_160316_day3_samplefile_only_raw_calls.md``
minus the "Raw Payload Argument" section.
This module has no dependency on ``BioyondV1RPC`` itself; the only contract is
that the wrapped instance descends from ``BaseRequest`` (i.e. has a logger
returned by ``self.get_logger()``).
"""
from __future__ import annotations
import contextvars
import copy
import inspect
import json
import re
from contextlib import contextmanager
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, Iterator, List, Optional
import requests
__all__ = [
"CallRecord",
"CallLogContext",
"session",
"wrap_rpc_http",
"active_session",
]
_DEFAULT_TIMEOUT_GET = 30
_DEFAULT_TIMEOUT_POST = 120
@dataclass
class CallRecord:
"""One captured HTTP call inside a debug session."""
index: int
method: str
url: str
path: str
source: str
transport: str
http_status: Optional[int]
request_body: Any
response_body: Any
error: Optional[str] = None
@dataclass
class CallLogContext:
"""State for a single ``session()`` block.
A session lazily creates its file on the first appended record. Actions
that abort before any RPC produce no file.
"""
action: str
out_dir: Path
started_at: datetime
calls: List[CallRecord] = field(default_factory=list)
file_path: Optional[Path] = None
def append(self, record: CallRecord) -> None:
record.index = len(self.calls) + 1
self.calls.append(record)
self._write_file()
# -- file I/O -------------------------------------------------------------
def _resolve_file_path(self) -> Path:
if self.file_path is not None:
return self.file_path
timestamp = self.started_at.strftime("%Y-%m-%d_%H%M%S")
slug = _slugify_action(self.action)
candidate = self.out_dir / f"{timestamp}_{slug}_raw_calls.md"
suffix = 2
while candidate.exists():
candidate = (
self.out_dir
/ f"{timestamp}_{slug}_raw_calls_{suffix:02d}.md"
)
suffix += 1
self.file_path = candidate
return self.file_path
def _write_file(self) -> None:
path = self._resolve_file_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(_render_markdown(self), encoding="utf-8")
_active_session: contextvars.ContextVar[Optional[CallLogContext]] = (
contextvars.ContextVar("_active_session", default=None)
)
def active_session() -> Optional[CallLogContext]:
"""Return the currently active :class:`CallLogContext`, if any."""
return _active_session.get()
@contextmanager
def session(action: str, out_dir: Path) -> Iterator[CallLogContext]:
"""Open a per-action debug session.
On entry, sets the module-level ``_active_session`` ContextVar so any
``wrap_rpc_http``'d clients on the same thread/task record their calls.
On exit, the previous active session (if any) is restored.
"""
ctx = CallLogContext(
action=str(action),
out_dir=Path(out_dir),
started_at=datetime.now(),
)
token = _active_session.set(ctx)
try:
yield ctx
finally:
_active_session.reset(token)
def wrap_rpc_http(rpc: Any) -> None:
"""Idempotently wrap ``rpc.post`` / ``rpc.get``.
When a session is active (``_active_session.get() is not None``), the
wrapped methods perform the HTTP call themselves with ``requests`` and
record the call before returning the same value ``BaseRequest`` would have
returned. When no session is active, the wrapped methods delegate to the
original implementation, preserving stock ``BaseRequest`` behavior.
Calling this twice on the same instance is a no-op. The wrapper does not
alter ``rpc.form_post`` (no Sirna action calls it as of plan 3).
"""
if rpc is None:
return
if getattr(rpc, "_debug_call_log_wrapped", False):
return
rpc._orig_post = rpc.post
rpc._orig_get = rpc.get
def _wrapped_post(
url: str,
params: Any = None,
files: Any = None,
headers: Optional[dict] = None,
) -> Any:
ctx = _active_session.get()
if ctx is None:
kwargs = {}
if params is not None:
kwargs["params"] = params
if files is not None:
kwargs["files"] = files
if headers is not None:
kwargs["headers"] = headers
return rpc._orig_post(url, **kwargs)
effective_params = params if params is not None else {}
effective_headers = (
headers
if headers is not None
else {"Content-Type": "application/json"}
)
source = _detect_source(rpc)
request_body = _redact(effective_params)
record = CallRecord(
index=0,
method="POST",
url=str(url),
path=_url_path(url),
source=source,
transport=_pick_transport(effective_params),
http_status=None,
request_body=request_body,
response_body=None,
error=None,
)
return_value: Any = None
try:
response = requests.post(
url,
data=json.dumps(effective_params) if effective_params else None,
headers=effective_headers,
timeout=_DEFAULT_TIMEOUT_POST,
files=files,
)
except Exception as exc: # pragma: no cover - delegated to logger
record.error = f"transport error: {exc}"
try:
rpc.get_logger().error(f"Request ERROR: {exc}")
except Exception:
pass
ctx.append(record)
return None
record.http_status = response.status_code
record.response_body, parse_error = _decode_response_body(response)
try:
rpc.get_logger().debug(
f"Request >>> : {response.request.body} "
f"{response.status_code} {response.text}"
)
except Exception:
pass
if response.status_code == 200:
if parse_error is not None:
record.error = f"json parse error: {parse_error}"
return_value = None
else:
return_value = record.response_body
else:
record.error = f"HTTP {response.status_code}: {response.text}"
try:
rpc.get_logger().error(
f"Request ERROR: ('Request ERROR:', {response.text!r})"
)
except Exception:
pass
return_value = None
ctx.append(record)
return return_value
def _wrapped_get(
url: str,
params: Any = None,
headers: Optional[dict] = None,
) -> Any:
ctx = _active_session.get()
if ctx is None:
kwargs = {}
if params is not None:
kwargs["params"] = params
if headers is not None:
kwargs["headers"] = headers
return rpc._orig_get(url, **kwargs)
effective_params = params if params is not None else {}
effective_headers = (
headers
if headers is not None
else {"Content-Type": "application/json"}
)
source = _detect_source(rpc)
request_body = _redact(effective_params)
record = CallRecord(
index=0,
method="GET",
url=str(url),
path=_url_path(url),
source=source,
transport="params",
http_status=None,
request_body=request_body,
response_body=None,
error=None,
)
return_value: Any = None
try:
response = requests.get(
url,
params=effective_params,
headers=effective_headers,
timeout=_DEFAULT_TIMEOUT_GET,
)
except Exception as exc: # pragma: no cover - delegated to logger
record.error = f"transport error: {exc}"
try:
rpc.get_logger().error(f"Request ERROR: {exc}")
except Exception:
pass
ctx.append(record)
return None
record.http_status = response.status_code
record.response_body, parse_error = _decode_response_body(response)
try:
rpc.get_logger().debug(
f"Request >>> : {effective_params} "
f"{response.status_code} {response.text}"
)
except Exception:
pass
if response.status_code == 200:
if parse_error is not None:
record.error = f"json parse error: {parse_error}"
return_value = None
else:
return_value = record.response_body
ctx.append(record)
return return_value
rpc.post = _wrapped_post
rpc.get = _wrapped_get
rpc._debug_call_log_wrapped = True
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_URL_PATH_RE = re.compile(r"https?://[^/]+(/.*)?$")
_SLUG_RE = re.compile(r"[^A-Za-z0-9._-]+")
def _slugify_action(action: str) -> str:
slug = _SLUG_RE.sub("_", str(action)).strip("_")
return slug or "action"
def _url_path(url: Any) -> str:
text = str(url or "")
match = _URL_PATH_RE.match(text)
if match and match.group(1):
return match.group(1)
if text.startswith("/"):
return text
return text
def _pick_transport(params: Any) -> str:
if isinstance(params, dict) and "data" in params:
return "data"
return "params"
def _detect_source(rpc: Any) -> str:
"""Walk the call stack to find the outermost frame whose ``self`` is rpc."""
try:
stack = inspect.stack()
except Exception:
return ""
candidate = ""
try:
for frame_info in stack:
frame = frame_info.frame
if frame.f_locals.get("self", None) is rpc:
candidate = frame_info.function
return candidate
finally:
del stack
def _redact(params: Any) -> Any:
"""Return a copy of ``params`` with ``apiKey`` redacted."""
try:
cloned = copy.deepcopy(params)
except Exception:
return params
_redact_in_place(cloned)
return cloned
def _redact_in_place(value: Any) -> None:
if isinstance(value, dict):
for key in list(value.keys()):
if isinstance(key, str) and key.lower() == "apikey":
value[key] = "<redacted>"
else:
_redact_in_place(value[key])
elif isinstance(value, list):
for item in value:
_redact_in_place(item)
def _decode_response_body(response: Any) -> tuple[Any, Optional[str]]:
"""Best-effort response decoding used for both record + return value."""
text = getattr(response, "text", "")
try:
return response.json(), None
except Exception as exc:
if text:
return {"raw_text": text}, str(exc)
return None, str(exc)
# ---------------------------------------------------------------------------
# Markdown rendering
# ---------------------------------------------------------------------------
def _render_markdown(ctx: CallLogContext) -> str:
title = f"# {ctx.action} Raw Call/Response Log"
parts: List[str] = [title, ""]
parts.append("## LIMS Calls")
parts.append("")
parts.append("| # | Method | Path | Source | HTTP |")
parts.append("|---|---|---|---|---|")
for record in ctx.calls:
anchor = _row_anchor(record)
http = (
f"`{record.http_status}`"
if record.http_status is not None
else "`-`"
)
parts.append(
f"| [{record.index}](#{anchor}) | `{record.method}` | "
f"`{record.path}` | `{record.source}` | {http} |"
)
parts.append("")
for record in ctx.calls:
parts.append(f"## {record.index} {record.method} {record.path}")
parts.append("")
parts.append(f"- Source: `{record.source}`")
parts.append(f"- Transport: `{record.transport}`")
if record.http_status is not None:
parts.append(f"- HTTP status: `{record.http_status}`")
else:
parts.append("- HTTP status: `-`")
if record.error:
parts.append(f"- Error: {record.error}")
parts.append("")
parts.append("### Request Body")
parts.append("")
parts.append("```json")
parts.append(_to_json_block(record.request_body))
parts.append("```")
parts.append("")
parts.append("### Response Body")
parts.append("")
parts.append("```json")
parts.append(_to_json_block(record.response_body))
parts.append("```")
parts.append("")
return "\n".join(parts).rstrip() + "\n"
def _row_anchor(record: CallRecord) -> str:
"""Build a GitHub-style anchor matching ``## N METHOD /path``."""
raw = f"{record.index}-{record.method}-{record.path}"
raw = raw.lower()
raw = re.sub(r"[^a-z0-9]+", "-", raw)
return raw.strip("-")
def _to_json_block(value: Any) -> str:
try:
return json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True)
except TypeError:
return json.dumps(str(value), ensure_ascii=False, indent=2)

View File

@@ -0,0 +1,3 @@
from .sirna_station import BioyondSirnaStation, fetch_workflow_list, load_sirna_config
__all__ = ["BioyondSirnaStation", "fetch_workflow_list", "load_sirna_config"]

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@ Bioyond Workstation Implementation
import time
import traceback
import threading
from contextlib import contextmanager
from datetime import datetime
from typing import Dict, Any, List, Optional, Union
import json
@@ -14,6 +15,7 @@ from pathlib import Path
from unilabos.devices.workstation.workstation_base import WorkstationBase, ResourceSynchronizer
from unilabos.devices.workstation.bioyond_studio.bioyond_rpc import BioyondV1RPC
from unilabos.devices.workstation.bioyond_studio import debug_call_log
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
from unilabos.resources.warehouse import WareHouse
from unilabos.utils.log import logger
@@ -174,6 +176,8 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
logger.warning("从Bioyond获取的物料数据为空")
return False
self._update_material_cache_from_stock(all_bioyond_data)
# 转换为UniLab格式
unilab_resources = resource_bioyond_to_plr(
all_bioyond_data,
@@ -187,6 +191,29 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
logger.error(f"从Bioyond同步物料数据失败: {e}")
return False
def _update_material_cache_from_stock(self, materials: List[Dict[str, Any]]) -> None:
"""用本次库存查询结果同步 RPC 的 name -> material id 缓存。"""
material_cache = getattr(self.bioyond_api_client, "material_cache", None)
if not isinstance(material_cache, dict):
return
before_count = len(material_cache)
for material in materials:
material_name = material.get("name")
material_id = material.get("id")
if material_name and material_id:
material_cache[material_name] = material_id
for detail_material in material.get("detail", []) or []:
detail_name = detail_material.get("name")
detail_id = detail_material.get("detailMaterialId") or detail_material.get("id")
if detail_name and detail_id:
material_cache[detail_name] = detail_id
logger.debug(
f"已用Bioyond库存同步物料缓存: {before_count} -> {len(material_cache)}"
)
def sync_to_external(self, resource: Any) -> bool:
"""将本地物料数据变更同步到Bioyond系统"""
try:
@@ -678,6 +705,70 @@ class BioyondWorkstation(WorkstationBase):
集成Bioyond物料管理的工作站实现
"""
# 子类(如 sirna / peptide覆写以指定默认 raw-call 日志目录。
# 路径相对仓库根;为 None 时若 debug_log=True 仍会写入临时位置。
_DEBUG_LOG_DEFAULT_DIR: Optional[str] = None
def _create_bioyond_rpc(self, config: Dict[str, Any]) -> BioyondV1RPC:
"""创建 Bioyond RPC 客户端并应用调试包装。
所有创建 ``BioyondV1RPC`` 的路径饿汉初始化、Sirna 延迟初始化、
以及未来的前端重新配置路径)都应通过该 helper
以确保 debug_log 包装与命名/日志策略保持一致。
"""
rpc = BioyondV1RPC(config)
debug_call_log.wrap_rpc_http(rpc)
return rpc
def _set_hardware_interface(self, rpc: BioyondV1RPC) -> BioyondV1RPC:
"""将已构造的 RPC 客户端设置到 ``self.hardware_interface``,并应用调试包装。"""
debug_call_log.wrap_rpc_http(rpc)
self.hardware_interface = rpc
return rpc
def _debug_log_resolved_dir(self) -> Path:
"""解析 ``debug_log_dir`` 为绝对路径。"""
configured = (getattr(self, "bioyond_config", {}) or {}).get("debug_log_dir")
default_dir = getattr(self, "_DEBUG_LOG_DEFAULT_DIR", None)
candidate = configured or default_dir or "temp_benyao/_logs/bioyond_debug"
path = Path(candidate)
if not path.is_absolute():
repo_root = Path(__file__).resolve().parents[4]
path = repo_root / path
return path
def _ensure_debug_log_state(self) -> None:
"""从 ``self.bioyond_config`` 派生 ``_debug_log_enabled`` / ``_debug_log_dir``。
每次进入 ``_debug_call_session`` 时都重新解析,以兼容前端在运行时
修改 ``bioyond_config['debug_log']`` 或目录的场景;同时也容忍
子类(如 Sirna 延迟初始化)在 ``__init__`` 早期未触发本方法。
"""
cfg = getattr(self, "bioyond_config", {}) or {}
self._debug_log_enabled = bool(cfg.get("debug_log"))
self._debug_log_dir = self._debug_log_resolved_dir()
@contextmanager
def _debug_call_session(self, action_name: str):
"""在 action 体外加一层 debug 会话上下文。
- ``debug_log`` 关闭时是空上下文,开销为 0。
- ``debug_log`` 开启时进入 :func:`debug_call_log.session`,所有
已被 ``wrap_rpc_http`` 包装过的 RPC 客户端都会捕获本次 action
产生的 HTTP 调用并写入 Markdown 文件。
子类(如 ``end_experiment``、``manual_unload`` 等)可以直接在
action 体里以 ``with self._debug_call_session("action_name"):`` 包裹。
"""
cfg = getattr(self, "bioyond_config", {}) or {}
enabled = bool(cfg.get("debug_log"))
if not enabled:
yield None
return
out_dir = BioyondWorkstation._debug_log_resolved_dir(self)
with debug_call_log.session(action_name, out_dir) as ctx:
yield ctx
def _publish_task_status(
self,
task_id: str,
@@ -862,7 +953,7 @@ class BioyondWorkstation(WorkstationBase):
self.bioyond_config = {}
print("警告: 未提供 bioyond_config请确保在 JSON 配置文件中提供完整配置")
self.hardware_interface = BioyondV1RPC(self.bioyond_config)
self.hardware_interface = self._create_bioyond_rpc(self.bioyond_config)
def resource_tree_add(self, resources: List[ResourcePLR]) -> None:
"""添加资源到资源树并更新ROS节点
@@ -1338,11 +1429,7 @@ class BioyondWorkstation(WorkstationBase):
if self.hardware_interface:
self.hardware_interface.scheduler_reset()
# 新物料缓存
if self.hardware_interface:
self.hardware_interface.refresh_material_cache()
# 重新同步资源
# 重新同步资源,并用同一次库存查询结果更新物料缓存
if self.resource_synchronizer:
self.resource_synchronizer.sync_from_external()

View File

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

View File

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

View File

@@ -51,14 +51,18 @@ Qone_nmr:
properties:
check_interval:
default: 60
description: 检查间隔时间默认60秒
type: string
expected_count:
default: 1
description: 期望生成的.nmr文件数量默认1个
type: string
monitor_dir:
description: 要监督的目录路径如果未指定则使用self.monitor_directory
type: string
stability_checks:
default: 3
description: 文件大小稳定性检查次数默认3次
type: string
required: []
type: object
@@ -85,11 +89,14 @@ Qone_nmr:
goal:
properties:
output_dir:
description: 输出目录如果未指定使用self.output_directory
type: string
string_list:
description: 字符串列表
type: string
txt_encoding:
default: utf-8
description: 文件编码
type: string
required:
- string_list
@@ -151,6 +158,13 @@ Qone_nmr:
additionalProperties: false
properties:
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
title: StrSingleInput_Goal
type: object

View File

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

View File

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

View File

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

View File

@@ -31,6 +31,6 @@ hotel.thermo_orbitor_rs2_hotel:
type: object
model:
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
version: 1.0.0

View File

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

View File

@@ -510,9 +510,11 @@ liquid_handler:
goal:
properties:
msg:
description: information to be printed
type: string
seconds:
default: 0
description: seconds to wait
type: string
required: []
type: object
@@ -2963,15 +2965,22 @@ liquid_handler:
additionalProperties: false
properties:
channel:
description: int
maximum: 2147483647
minimum: -2147483648
type: integer
dis_to_top:
description: 'float
Height in mm to move to relative to the well top.'
maximum: 1.7976931348623157e+308
minimum: -1.7976931348623157e+308
type: number
well:
additionalProperties: false
description: 'Well
The target well.'
properties:
category:
type: string
@@ -4829,11 +4838,13 @@ liquid_handler:
config:
properties:
backend:
description: Backend to use.
type: object
channel_num:
default: 8
type: integer
deck:
description: Deck to use.
type: object
simulator:
default: false
@@ -4883,14 +4894,17 @@ liquid_handler.biomek:
bind_parent_id:
type: string
liquid_input_slot:
description: 液体输入槽列表
items:
type: integer
type: array
liquid_type:
description: 液体类型列表
items:
type: string
type: array
liquid_volume:
description: 液体体积列表
items:
type: integer
type: array
@@ -4901,6 +4915,7 @@ liquid_handler.biomek:
type: object
type: array
slot_on_deck:
description: 甲板上的槽位
type: integer
required:
- resource_tracker
@@ -5036,20 +5051,27 @@ liquid_handler.biomek:
additionalProperties: false
properties:
none_keys:
description: 需要设置为None的键列表
items:
type: string
type: array
protocol_author:
description: 协议作者
type: string
protocol_date:
description: 协议日期
type: string
protocol_description:
description: 协议描述
type: string
protocol_name:
description: 协议名称
type: string
protocol_type:
description: 协议类型
type: string
protocol_version:
description: 协议版本
type: string
title: LiquidHandlerProtocolCreation_Goal
type: object

View File

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

View File

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

View File

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

View File

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

View File

@@ -198,6 +198,8 @@ robotic_arm.SCARA_with_slider.moveit.virtual:
additionalProperties: false
properties:
command:
description: A JSON-formatted string that includes option, target,
speed, lift_height, mt_height
type: string
title: SendCmd_Goal
type: object
@@ -241,6 +243,8 @@ robotic_arm.SCARA_with_slider.moveit.virtual:
additionalProperties: false
properties:
command:
description: A JSON-formatted string that includes quaternion, speed,
position
type: string
title: SendCmd_Goal
type: object
@@ -284,6 +288,7 @@ robotic_arm.SCARA_with_slider.moveit.virtual:
additionalProperties: false
properties:
command:
description: A JSON-formatted string that includes speed
type: string
title: SendCmd_Goal
type: object
@@ -329,7 +334,7 @@ robotic_arm.SCARA_with_slider.moveit.virtual:
type: object
model:
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
version: 1.0.0
robotic_arm.UR:

View File

@@ -709,6 +709,8 @@ linear_motion.toyo_xyz.sim:
additionalProperties: false
properties:
command:
description: A JSON-formatted string that includes option, target,
speed, lift_height, mt_height
type: string
title: SendCmd_Goal
type: object
@@ -752,6 +754,8 @@ linear_motion.toyo_xyz.sim:
additionalProperties: false
properties:
command:
description: A JSON-formatted string that includes quaternion, speed,
position
type: string
title: SendCmd_Goal
type: object
@@ -795,6 +799,7 @@ linear_motion.toyo_xyz.sim:
additionalProperties: false
properties:
command:
description: A JSON-formatted string that includes speed
type: string
title: SendCmd_Goal
type: object

View File

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

View File

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

View File

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

View File

@@ -238,6 +238,7 @@ class Registry:
"class_name": "unilabos_class",
},
"always_free": True,
"feedback_interval": 300.0,
},
"test_latency": test_latency_action,
"auto-test_resource": test_resource_action,
@@ -270,6 +271,7 @@ class Registry:
registry_cache.pkl 一个文件中,删除即可完全重置。
"""
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
scan_t0 = _time.perf_counter()
@@ -285,6 +287,10 @@ class Registry:
# ---- 统一缓存:一个 pkl 包含所有数据 ----
unified_cache = self._load_config_cache()
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 包所在的父目录
pkg_root = Path(__file__).resolve().parent.parent # .../unilabos
@@ -560,13 +566,47 @@ class Registry:
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(
self, method_args: list, docstring: Optional[str] = None,
import_map: Optional[Dict[str, str]] = None,
apply_doc_defaults: bool = False,
) -> Dict[str, Any]:
"""根据方法参数和 docstring 生成 UniLabJsonCommand schema"""
doc_info = parse_docstring(docstring)
param_descs = doc_info.get("params", {})
schema = {
"type": "object",
@@ -597,12 +637,10 @@ class Registry:
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:
schema["required"].append(param_name)
self._apply_docstring_param_metadata(schema, doc_info, apply_defaults=apply_doc_defaults)
return schema
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"
params = method_info.get("params", [])
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)
if action_args is not None:
@@ -827,10 +866,15 @@ class Registry:
# action handles: 从 @action(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: 优先用装饰器显式配置,否则从参数类型检测
pk = (action_args or {}).get("placeholder_keys") or detect_placeholder_keys(params)
# placeholder_keys: 先从参数类型自动检测,再用装饰器显式配置覆盖/补充
pk = detect_placeholder_keys(params)
pk.update((action_args or {}).get("placeholder_keys") or {})
# 从方法返回值类型生成 result schema
result_schema = None
@@ -845,13 +889,20 @@ class Registry:
"goal": goal,
"feedback": (action_args or {}).get("feedback") 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,
"handles": handles,
"placeholder_keys": pk,
}
if (action_args or {}).get("always_free") or method_info.get("always_free"):
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)
if nt:
entry["node_type"] = nt
@@ -882,7 +933,11 @@ class Registry:
action_name = f"auto-{action_name}"
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", [])
@@ -975,20 +1030,34 @@ class Registry:
"schema": schema,
"goal_default": goal_default,
"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"):
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)
if 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 = dict(sorted(action_value_mappings.items()))
# --- init_param_schema = { config: <init_params>, data: <status_types> } ---
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(
ast_meta.get("status_properties", {}), imap
)
@@ -1036,7 +1105,6 @@ class Registry:
) -> Dict[str, Any]:
"""Generate JSON Schema from AST-extracted parameter list."""
doc_info = parse_docstring(docstring)
param_descs = doc_info.get("params", {})
schema: Dict[str, Any] = {
"type": "object",
@@ -1066,12 +1134,10 @@ class Registry:
pname, ptype, pdefault, import_map
)
if pname in param_descs:
schema["properties"][pname]["description"] = param_descs[pname]
if prequired:
schema["required"].append(pname)
self._apply_docstring_param_metadata(schema, doc_info, apply_defaults=True)
return schema
def _generate_status_schema_from_ast(
@@ -1801,7 +1867,7 @@ class Registry:
else:
action_key = f"auto-{k}"
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", "")
result_schema = None
@@ -1810,7 +1876,13 @@ class Registry:
"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}", {})
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", {})
if 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.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 = {
"type": entry_type,
@@ -1896,7 +1974,8 @@ class Registry:
device_config["init_param_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,
)
device_config["init_param_schema"]["config"] = init_schema
@@ -1943,7 +2022,9 @@ class Registry:
action_str_type_mapping[action_type_str] = target_type
if target_type is not None:
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:
action_config["goal_default"] = {}
prev_schema = action_config.get("schema", {})
@@ -2135,6 +2216,7 @@ class Registry:
"unilabos_device_id": {
"type": "string",
"default": "",
"title": "设备ID",
"description": "UniLabOS设备ID用于指定执行动作的具体设备实例",
},
**schema["properties"]["goal"]["properties"],
@@ -2206,7 +2288,14 @@ class 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单例实例
"""
@@ -2220,7 +2309,12 @@ def build_registry(registry_paths=None, devices_dirs=None, upload_registry=False
if path not in current_paths:
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 设备模块)
lab_registry.resolve_all_types()

View File

@@ -17,7 +17,7 @@ hplc_plate:
- 0
- 0
- 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
version: 1.0.0
plate_96:
@@ -39,7 +39,7 @@ plate_96:
- 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
version: 1.0.0
plate_96_high:
@@ -61,7 +61,7 @@ plate_96_high:
- 1.5708
- 0
- 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
version: 1.0.0
tiprack_96_high:
@@ -76,7 +76,7 @@ tiprack_96_high:
init_param_schema: {}
model:
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:
- 0.0018
- 0.0018
@@ -92,7 +92,7 @@ tiprack_96_high:
- 1.5708
- 0
- 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
version: 1.0.0
tiprack_box:
@@ -107,7 +107,7 @@ tiprack_box:
init_param_schema: {}
model:
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:
- 0.0045
- 0.0045
@@ -123,6 +123,6 @@ tiprack_box:
- 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
version: 1.0.0

View File

@@ -11,7 +11,7 @@ bottle_container:
init_param_schema: {}
model:
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:
- 0.04
- 0.04
@@ -27,7 +27,7 @@ bottle_container:
- 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
version: 1.0.0
tube_container:
@@ -43,7 +43,7 @@ tube_container:
init_param_schema: {}
model:
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:
- 0.017
- 0.017
@@ -59,6 +59,6 @@ tube_container:
- 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
version: 1.0.0

View File

@@ -10,6 +10,6 @@ TransformXYZDeck:
init_param_schema: {}
model:
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
version: 1.0.0

View File

@@ -10,7 +10,7 @@ OTDeck:
init_param_schema: {}
model:
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
version: 1.0.0
hplc_station:
@@ -25,6 +25,6 @@ hplc_station:
init_param_schema: {}
model:
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
version: 1.0.0

View File

@@ -109,7 +109,7 @@ nest_96_wellplate_100ul_pcr_full_skirt:
init_param_schema: {}
model:
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:
- 0.0018
- 0.0018
@@ -125,7 +125,7 @@ nest_96_wellplate_100ul_pcr_full_skirt:
- -1.5708
- 0
- 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
version: 1.0.0
nest_96_wellplate_200ul_flat:
@@ -158,7 +158,7 @@ nest_96_wellplate_2ml_deep:
- -1.5708
- 0
- 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
version: 1.0.0
thermoscientificnunc_96_wellplate_1300ul:

View File

@@ -69,7 +69,7 @@ opentrons_96_filtertiprack_1000ul:
- -1.5708
- 0
- 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
version: 1.0.0
opentrons_96_filtertiprack_10ul:

View File

@@ -36,16 +36,40 @@ class ROSMsgNotFound(Exception):
# ---------------------------------------------------------------------------
_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]:
"""
解析 Google-style docstring提取描述和参数说明。
解析 docstring提取描述和参数说明。
支持:
- Google-style ``Args:`` / ``Parameters:`` 小节
- 直接参数行 ``field: desc``
- 带显示名参数行 ``field[Display Name]: desc``
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:
return result
@@ -53,33 +77,53 @@ def parse_docstring(docstring: Optional[str]) -> Dict[str, Any]:
if not lines:
return result
result["description"] = lines[0].strip()
in_args = False
current_section: Optional[str] = None
current_param: Optional[str] = None
current_display_name: Optional[str] = None
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()
if not stripped:
if current_param is not None:
current_desc_parts.append("")
continue
section_match = _SECTION_RE.match(stripped)
if section_match:
if current_param is not None:
result["params"][current_param] = "\n".join(current_desc_parts).strip()
current_param = None
current_desc_parts = []
section_name = section_match.group(1).lower()
in_args = section_name in ("args", "arguments", "parameters", "params")
flush_current_param()
current_section = section_match.group(1).lower()
in_args = current_section in ("args", "arguments", "parameters", "params")
continue
if not in_args:
parse_as_param = in_args or current_section is None
if not parse_as_param:
continue
if ":" in stripped and not stripped.startswith(" "):
if current_param is not None:
result["params"][current_param] = "\n".join(current_desc_parts).strip()
if ":" in stripped:
flush_current_param()
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_display_name = display_name
current_desc_parts = [desc_part.strip()]
elif current_param is not None:
aline = line
@@ -89,8 +133,7 @@ def parse_docstring(docstring: Optional[str]) -> Dict[str, Any]:
aline = aline[1:]
current_desc_parts.append(aline.strip())
if current_param is not None:
result["params"][current_param] = "\n".join(current_desc_parts).strip()
flush_current_param()
return result

View File

@@ -0,0 +1 @@
from . import sirna_materials # noqa: F401 ensure @resource classes are importable for PLR deserialize

View File

@@ -1,6 +1,8 @@
from os import name
from pylabrobot.resources import Deck, Coordinate, Rotation
from unilabos.registry.decorators import resource
from unilabos.resources.bioyond.YB_warehouses import (
bioyond_warehouse_1x4x4,
bioyond_warehouse_1x4x4_right, # 新增:右侧仓库 (A05D08)
@@ -23,6 +25,11 @@ from unilabos.resources.bioyond.YB_warehouses import (
from unilabos.resources.bioyond.warehouses import (
bioyond_warehouse_tipbox_storage_left, # 新增Tip盒堆栈(左)
bioyond_warehouse_tipbox_storage_right, # 新增Tip盒堆栈(右)
bioyond_warehouse_sirna_automation_stack,
bioyond_warehouse_sirna_centrifuge_balance_plate_stack,
bioyond_warehouse_sirna_g3_liquid_handler,
bioyond_warehouse_numeric_stack, # 新增:数字编码堆栈 (用于多肽站)
bioyond_warehouse_live_grid,
)
@@ -101,6 +108,83 @@ class BIOYOND_PolymerPreparationStation_Deck(Deck):
for warehouse_name, warehouse in self.warehouses.items():
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
@resource(
id="BIOYOND_SirnaStation_Deck",
category=["deck"],
description="BIOYOND 小核酸工作站 Deck",
icon="配液站.webp",
)
class BIOYOND_SirnaStation_Deck(Deck):
WAREHOUSE_BIOYOND_AXIS = {
"G3移液站": "xy_col_row",
"自动化堆栈": "xy_col_row",
"离心机配平板堆栈": "xy_col_row",
}
WAREHOUSE_BIOYOND_KEY_AXIS = {
"G3移液站": "col_row",
"自动化堆栈": "col_row",
"离心机配平板堆栈": "col_row",
}
# Bioyond warehouse UUID -> 本地仓库名称 映射。
# 留空时由配置station config 的 ``warehouse_bioyond_ids``)注入。
# graph 节点也可在 deck.config.warehouse_bioyond_ids 覆盖。
WAREHOUSE_BIOYOND_IDS: dict = {}
def __init__(
self,
name: str = "SirnaStation_Deck",
size_x: float = 2700.0,
size_y: float = 1080.0,
size_z: float = 1500.0,
category: str = "deck",
setup: bool = False,
warehouse_bioyond_ids: dict | None = None,
**kwargs,
) -> None:
super().__init__(name=name, size_x=size_x, size_y=size_y, size_z=size_z)
# 按需写入实例级覆盖;保留默认空 mapping避免改动模型常量。
self.warehouse_bioyond_ids: dict = dict(self.WAREHOUSE_BIOYOND_IDS)
if warehouse_bioyond_ids:
self.warehouse_bioyond_ids.update(warehouse_bioyond_ids)
if setup:
self.setup()
@classmethod
def deserialize(cls, data: dict, allow_marshal: bool = False):
if data.get("children") and data.get("setup") is True:
data = data.copy()
data["setup"] = False
result = super().deserialize(data, allow_marshal=allow_marshal)
result._ensure_sirna_warehouse_metadata()
return result
def _ensure_sirna_warehouse_metadata(self) -> None:
for child in getattr(self, "children", []):
name = getattr(child, "name", "")
axis = self.WAREHOUSE_BIOYOND_AXIS.get(name)
if axis and not hasattr(child, "bioyond_axis"):
child.bioyond_axis = axis
key_axis = self.WAREHOUSE_BIOYOND_KEY_AXIS.get(name)
if key_axis and not hasattr(child, "bioyond_key_axis"):
child.bioyond_key_axis = key_axis
def setup(self) -> None:
# Sirna 读接口 /api/storage/location/locations-by-type 返回完整固定堆栈清单。
# LIMS 在库物料接口仍使用相同的 自动化堆栈 名称和数字库位编码。
self.warehouses = {
"G3移液站": bioyond_warehouse_sirna_g3_liquid_handler(),
"自动化堆栈": bioyond_warehouse_sirna_automation_stack(),
"离心机配平板堆栈": bioyond_warehouse_sirna_centrifuge_balance_plate_stack(),
}
self.warehouse_locations = {
"G3移液站": Coordinate(0.0, 0.0, 0.0),
"自动化堆栈": Coordinate(220.0, 0.0, 0.0),
"离心机配平板堆栈": Coordinate(1740.0, 0.0, 0.0),
}
for warehouse_name, warehouse in self.warehouses.items():
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
class BIOYOND_YB_Deck(Deck):
def __init__(
self,
@@ -150,12 +234,146 @@ class BIOYOND_YB_Deck(Deck):
for warehouse_name, warehouse in self.warehouses.items():
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
@resource(
id="BIOYOND_PeptideStation_Deck",
category=["deck"],
description="BIOYOND 多肽工作站 Deck",
icon="preparation_station.webp",
)
class BIOYOND_PeptideStation_Deck(Deck):
WAREHOUSE_BIOYOND_AXIS = dict.fromkeys(
[
"自动化堆栈",
"低温冰箱仓库",
"Tecan移液站库",
"G3移液站库",
"IDOT移液站库",
"G3缓冲库",
"盖板缓冲库",
"配平板缓冲库",
"IDOT缓冲库",
"固相合成板底座缓冲位",
"离心机库位",
"热封膜机位",
],
"xy_col_row",
)
WAREHOUSE_BIOYOND_KEY_AXIS = dict.fromkeys(WAREHOUSE_BIOYOND_AXIS, "row_col")
def __init__(
self,
name: str = "PeptideStation_Deck",
size_x: float = 3500.0,
size_y: float = 1800.0,
size_z: float = 1500.0,
category: str = "deck",
setup: bool = False
) -> None:
super().__init__(name=name, size_x=size_x, size_y=size_y, size_z=size_z)
if setup:
self.setup()
@classmethod
def deserialize(cls, data: dict, allow_marshal: bool = False):
if data.get("children") and data.get("setup") is True:
# 已有序列化子资源,跳过 setup 避免重复创建
result = super(BIOYOND_PeptideStation_Deck, cls).deserialize(data, allow_marshal=allow_marshal)
else:
result = super(BIOYOND_PeptideStation_Deck, cls).deserialize(data, allow_marshal=allow_marshal)
result._ensure_peptide_warehouse_metadata()
return result
def _ensure_peptide_warehouse_metadata(self) -> None:
for child in getattr(self, "children", []):
name = getattr(child, "name", "")
axis = self.WAREHOUSE_BIOYOND_AXIS.get(name)
if axis and not hasattr(child, "bioyond_axis"):
child.bioyond_axis = axis
key_axis = self.WAREHOUSE_BIOYOND_KEY_AXIS.get(name)
if key_axis and not hasattr(child, "bioyond_key_axis"):
child.bioyond_key_axis = key_axis
def setup(self) -> None:
# 多肽工作站仓库配置
# 基于 2026-05-09 live API probe 发现的实际仓库拓扑 (12个仓库)
# 数据来源: temp_benyao/peptide/_logs/warehouse_discovery_raw_live_2026-05-09.json
self.warehouses = {
# 主自动化堆栈 - live API: code 10-17 -> x=17, y=10显示为 10 行×17 列
"自动化堆栈": bioyond_warehouse_numeric_stack(
"自动化堆栈", rows=10, columns=17, bioyond_axis="xy_col_row", bioyond_key_axis="row_col"
),
# 低温存储
"低温冰箱仓库": bioyond_warehouse_live_grid(
"低温冰箱仓库", rows=2, columns=3, slot_keys=["1", "2", "3", "4", "5", "6"]
),
# 移液站库位
"Tecan移液站库": bioyond_warehouse_live_grid(
"Tecan移液站库", rows=1, columns=18, slot_keys=[str(index) for index in range(1, 19)]
),
"G3移液站库": bioyond_warehouse_live_grid(
"G3移液站库",
rows=1,
columns=18,
slot_keys=["1", "2", "3", "4", "垃圾桶", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18"],
),
"IDOT移液站库": bioyond_warehouse_live_grid(
"IDOT移液站库",
rows=1,
columns=12,
slot_keys=[f"0009-{index:04d}" for index in range(1, 13)],
),
# 缓冲库位
"G3缓冲库": bioyond_warehouse_live_grid(
"G3缓冲库", rows=1, columns=5, slot_keys=[str(index) for index in range(1, 6)]
),
"盖板缓冲库": bioyond_warehouse_live_grid(
"盖板缓冲库", rows=1, columns=7, slot_keys=[str(index) for index in range(1, 8)]
),
"配平板缓冲库": bioyond_warehouse_live_grid(
"配平板缓冲库", rows=1, columns=3, slot_keys=[str(index) for index in range(1, 4)]
),
"IDOT缓冲库": bioyond_warehouse_live_grid(
"IDOT缓冲库", rows=1, columns=2, slot_keys=["1", "1"]
),
"固相合成板底座缓冲位": bioyond_warehouse_live_grid(
"固相合成板底座缓冲位",
rows=1,
columns=4,
slot_keys=[f"0015-{index:04d}" for index in range(1, 5)],
),
# 设备库位
"离心机库位": bioyond_warehouse_live_grid(
"离心机库位", rows=1, columns=4, slot_keys=[f"0017-{index:04d}" for index in range(1, 5)]
),
"热封膜机位": bioyond_warehouse_live_grid(
"热封膜机位", rows=1, columns=2, slot_keys=[f"0016-{index:04d}" for index in range(1, 3)]
),
}
# 仓库位置布局 (需根据实际硬件布局调整)
self.warehouse_locations = {
"自动化堆栈": Coordinate(0.0, 0.0, 0.0),
"Tecan移液站库": Coordinate(0.0, 1150.0, 0.0),
"G3移液站库": Coordinate(0.0, 1300.0, 0.0),
"IDOT移液站库": Coordinate(0.0, 1450.0, 0.0),
"G3缓冲库": Coordinate(0.0, 1600.0, 0.0),
"盖板缓冲库": Coordinate(850.0, 1600.0, 0.0),
"低温冰箱仓库": Coordinate(2700.0, 0.0, 0.0),
"配平板缓冲库": Coordinate(2700.0, 300.0, 0.0),
"IDOT缓冲库": Coordinate(2700.0, 450.0, 0.0),
"固相合成板底座缓冲位": Coordinate(2700.0, 600.0, 0.0),
"离心机库位": Coordinate(2700.0, 750.0, 0.0),
"热封膜机位": Coordinate(2700.0, 900.0, 0.0),
}
for warehouse_name, warehouse in self.warehouses.items():
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
def YB_Deck(name: str) -> Deck:
by=BIOYOND_YB_Deck(name=name)
by.setup()
return by

View File

@@ -0,0 +1,126 @@
"""Sirna Station Material Resource Definitions
Defines PyLabRobot resource classes for Bioyond Sirna station materials.
Each class is decorated with @resource for AST-based registry discovery.
"""
from collections import OrderedDict
from pylabrobot.resources import Plate, TipRack, Container
from unilabos.registry.decorators import resource
@resource(
id="bioyond_sirna_g3_200ul_tip_rack",
category=["labware", "tip_rack"],
description="G3-200ul枪头盒 for Sirna station",
)
class BioyondSirna_G3_200ul_TipRack(TipRack):
"""G3-200ul tip rack for Sirna liquid handling."""
def __init__(self, *args, **kwargs):
kwargs.setdefault("size_x", 127.76)
kwargs.setdefault("size_y", 85.48)
kwargs.setdefault("size_z", 64.0)
kwargs.setdefault("model", "bioyond_sirna_g3_200ul_tip_rack")
kwargs.setdefault("with_tips", True)
if kwargs.get("ordering") is None and kwargs.get("ordered_items") is None:
kwargs["ordering"] = OrderedDict()
super().__init__(*args, **kwargs)
@resource(
id="bioyond_sirna_g3_50ul_tip_rack",
category=["labware", "tip_rack"],
description="G3-50ul枪头盒 for Sirna station",
)
class BioyondSirna_G3_50ul_TipRack(TipRack):
"""G3-50ul tip rack for Sirna liquid handling."""
def __init__(self, *args, **kwargs):
kwargs.setdefault("size_x", 127.76)
kwargs.setdefault("size_y", 85.48)
kwargs.setdefault("size_z", 64.0)
kwargs.setdefault("model", "bioyond_sirna_g3_50ul_tip_rack")
kwargs.setdefault("with_tips", True)
if kwargs.get("ordering") is None and kwargs.get("ordered_items") is None:
kwargs["ordering"] = OrderedDict()
super().__init__(*args, **kwargs)
@resource(
id="bioyond_sirna_384_well_plate",
category=["labware", "plate"],
description="384孔板 for Sirna assays",
)
class BioyondSirna_384WellPlate(Plate):
"""384-well plate for Sirna reporter gene detection."""
def __init__(self, *args, **kwargs):
kwargs.setdefault("size_x", 127.76)
kwargs.setdefault("size_y", 85.48)
kwargs.setdefault("size_z", 14.35)
kwargs.setdefault("model", "bioyond_sirna_384_well_plate")
kwargs.setdefault("plate_type", "skirted")
if kwargs.get("ordering") is None and kwargs.get("ordered_items") is None:
kwargs["ordering"] = OrderedDict()
super().__init__(*args, **kwargs)
@resource(
id="bioyond_sirna_cell_culture_plate",
category=["labware", "plate"],
description="细胞培养板 for Sirna cell culture",
)
class BioyondSirna_CellCulturePlate(Plate):
"""Cell culture plate for Sirna experiments."""
def __init__(self, *args, **kwargs):
kwargs.setdefault("size_x", 127.76)
kwargs.setdefault("size_y", 85.48)
kwargs.setdefault("size_z", 14.35)
kwargs.setdefault("model", "bioyond_sirna_cell_culture_plate")
kwargs.setdefault("plate_type", "skirted")
if kwargs.get("ordering") is None and kwargs.get("ordered_items") is None:
kwargs["ordering"] = OrderedDict()
super().__init__(*args, **kwargs)
@resource(
id="bioyond_sirna_reagent_trough",
category=["labware", "trough"],
description="试剂槽 for Sirna reagents",
)
class BioyondSirna_ReagentTrough(Container):
"""Reagent trough for Sirna station reagents (RiboGreen, etc.)."""
def __init__(self, *args, **kwargs):
kwargs.setdefault("size_x", 127.76)
kwargs.setdefault("size_y", 85.48)
kwargs.setdefault("size_z", 44.0)
kwargs.setdefault("max_volume", 300000.0)
kwargs.setdefault("model", "bioyond_sirna_reagent_trough")
super().__init__(*args, **kwargs)
# Material type code mapping for dynamic instantiation
MATERIAL_TYPE_CODE_TO_CLASS = {
"0016": BioyondSirna_G3_200ul_TipRack,
"0017": BioyondSirna_G3_50ul_TipRack,
"0015": BioyondSirna_384WellPlate,
"0001": BioyondSirna_CellCulturePlate,
"0006": BioyondSirna_ReagentTrough,
}
def get_material_class_by_type_code(type_code: str):
"""Get resource class by Bioyond material type code.
Args:
type_code: Bioyond materialTypeCode (e.g., "0016", "0017")
Returns:
Resource class or None if not found
"""
return MATERIAL_TYPE_CODE_TO_CLASS.get(type_code)

View File

@@ -1,5 +1,180 @@
from pylabrobot.resources import Coordinate
from pylabrobot.resources.carrier import ResourceHolder, create_homogeneous_resources
from unilabos.resources.warehouse import WareHouse, warehouse_factory
class BioyondWareHouse(WareHouse):
"""Bioyond 仓库,额外保存服务端 x/y 坐标和库位标签语义。"""
def __init__(self, *args, bioyond_axis: str = "xy_row_col", bioyond_key_axis: str = "row_col", **kwargs):
super().__init__(*args, **kwargs)
self.bioyond_axis = bioyond_axis
self.bioyond_key_axis = bioyond_key_axis
def serialize(self) -> dict:
data = super().serialize()
data["bioyond_axis"] = self.bioyond_axis
data["bioyond_key_axis"] = self.bioyond_key_axis
return data
def bioyond_warehouse_numeric_stack(
name: str,
rows: int = 10,
columns: int = 17,
bioyond_axis: str = "xy_row_col",
bioyond_key_axis: str = "row_col",
) -> WareHouse:
"""创建 Bioyond 数字库位堆栈,库位名使用服务端返回的 行-列 格式。
bioyond_axis: 仓库级别的 Bioyond 坐标轴约定,供 graphio 的坐标映射使用。
- "xy_row_col" (default): Bioyond x→row, y→col (reaction/peptide 历史约定).
- "xy_col_row": Bioyond x→col, y→row (Sirna live API 实测约定).
bioyond_key_axis: 库位标签生成约定。
- "row_col" (default): 视觉行列和标签行列一致,例如 10 行 x 17 列 → 1-1..10-17。
- "col_row": 视觉行列转置,但标签仍保持 Bioyond row-col例如
17 行 x 10 列 → 1-1..10-17。
未设置时 graphio 回退到默认 "xy_row_col",其他调用方保持原行为。
"""
num_items_x = columns
num_items_y = rows
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
locations = [
Coordinate(dx + col * item_dx, dy + row * item_dy, dz)
for row in range(num_items_y)
for col in range(num_items_x)
]
holders = create_homogeneous_resources(
klass=ResourceHolder,
locations=locations,
resource_size_x=127.0,
resource_size_y=86.0,
resource_size_z=25.0,
name_prefix=name,
)
if bioyond_key_axis == "row_col":
keys = [
f"{row + 1}-{col + 1}"
for row in range(num_items_y)
for col in range(num_items_x)
]
elif bioyond_key_axis == "col_row":
keys = [
f"{col + 1}-{row + 1}"
for row in range(num_items_y)
for col in range(num_items_x)
]
else:
raise ValueError(f"未知 Bioyond 库位标签约定: {bioyond_key_axis!r}")
warehouse = BioyondWareHouse(
name=name,
size_x=dx + item_dx * num_items_x,
size_y=dy + item_dy * num_items_y,
size_z=dz + item_dz * num_items_z,
num_items_x=num_items_x,
num_items_y=num_items_y,
num_items_z=num_items_z,
ordering_layout="row-major",
sites={key: holder for key, holder in zip(keys, holders.values())},
category="warehouse",
bioyond_axis=bioyond_axis,
bioyond_key_axis=bioyond_key_axis,
)
return warehouse
def bioyond_warehouse_live_grid(
name: str,
rows: int,
columns: int,
slot_keys: list[str] | None = None,
bioyond_axis: str = "xy_col_row",
bioyond_key_axis: str = "row_col",
) -> WareHouse:
"""创建 Bioyond 实测库位网格,按服务端 code 保存位点标签。
默认用于 Peptide live API 返回的坐标x 是视觉列y 是视觉行。
当服务端 code 重复时,为保持 PLR ordering 唯一性,会给后续重复项追加 ``#N``。
"""
num_items_x = columns
num_items_y = rows
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
locations = [
Coordinate(dx + col * item_dx, dy + row * item_dy, dz)
for row in range(num_items_y)
for col in range(num_items_x)
]
holders = create_homogeneous_resources(
klass=ResourceHolder,
locations=locations,
resource_size_x=127.0,
resource_size_y=86.0,
resource_size_z=25.0,
name_prefix=name,
)
keys = slot_keys or [str(index + 1) for index in range(num_items_x * num_items_y)]
if len(keys) != len(holders):
raise ValueError(f"{name} 库位数量不匹配: keys={len(keys)}, holders={len(holders)}")
seen: dict[str, int] = {}
unique_keys: list[str] = []
for key in keys:
count = seen.get(key, 0) + 1
seen[key] = count
unique_keys.append(key if count == 1 else f"{key}#{count}")
return BioyondWareHouse(
name=name,
size_x=dx + item_dx * num_items_x,
size_y=dy + item_dy * num_items_y,
size_z=dz + item_dz * num_items_z,
num_items_x=num_items_x,
num_items_y=num_items_y,
num_items_z=num_items_z,
ordering_layout="row-major",
sites={key: holder for key, holder in zip(unique_keys, holders.values())},
category="warehouse",
bioyond_axis=bioyond_axis,
bioyond_key_axis=bioyond_key_axis,
)
# ================ 小核酸工作站相关堆栈 ================
def bioyond_warehouse_sirna_g3_liquid_handler(name: str = "G3移液站") -> WareHouse:
"""创建小核酸 G3 移液站库位堆栈:显示为 14 行 x 1 列,标签保持 1-1..1-14。"""
return bioyond_warehouse_numeric_stack(
name, rows=14, columns=1, bioyond_axis="xy_col_row", bioyond_key_axis="col_row"
)
def bioyond_warehouse_sirna_automation_stack(name: str = "自动化堆栈") -> WareHouse:
"""创建小核酸自动化堆栈:显示为 17 行 x 10 列,标签保持 1-1..10-17。"""
return bioyond_warehouse_numeric_stack(
name, rows=17, columns=10, bioyond_axis="xy_col_row", bioyond_key_axis="col_row"
)
def bioyond_warehouse_sirna_centrifuge_balance_plate_stack(name: str = "离心机配平板堆栈") -> WareHouse:
"""创建小核酸离心机配平板堆栈:显示为 1 行 x 2 列,标签保持 1-1、2-1。"""
return bioyond_warehouse_numeric_stack(
name, rows=1, columns=2, bioyond_axis="xy_col_row", bioyond_key_axis="col_row"
)
# ================ 反应站相关堆栈 ================
def bioyond_warehouse_1x4x4(name: str) -> WareHouse:

View File

@@ -736,7 +736,7 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
logger.warning(f"物料 {unique_name} 不是有效的 ResourcePLR 实例,类型: {type(plr_material)}")
continue
plr_material.code = material.get("code", "") and material.get("barCode", "") or ""
plr_material.code = material.get("barCode") or material.get("code") or ""
plr_material.unilabos_uuid = str(uuid.uuid4())
# ⭐ 保存 Bioyond 原始信息到 unilabos_extra用于出库时查询
@@ -864,11 +864,22 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
warehouse = deck.warehouses[wh_name]
logger.debug(f"[Warehouse匹配] 找到warehouse: {wh_name} (容量: {warehouse.capacity}, 行×列: {warehouse.num_items_x}×{warehouse.num_items_y})")
# Bioyond坐标映射 (重要!): x→行(1=A,2=B...), y→列(1=01,2=02...), z→层(通常=1)
x = loc.get("x", 1) # 行号 (1-based: 1=A, 2=B, 3=C, 4=D)
y = loc.get("y", 1) # 列号 (1-based: 1=01, 2=02, 3=03...)
# Bioyond坐标映射:
# - 历史 row_col 仓库中 x/y 直接按行/列参与索引。
# - Sirna 的库位标签为 col-rowstock-material 返回 x=标签第二段、y=标签第一段。
# 因此 x=13,y=4 应落到 key=4-13而不是交换后落到 3-5。
x = loc.get("x", 1)
y = loc.get("y", 1)
z = loc.get("z", 1) # 层号 (1-based, 通常为1)
# 仓库级别的轴约定覆盖。
# 对旧的 row-col 视觉标签bioyond_axis="xy_col_row" 需要交换 x/y。
# 对 Sirna 的 col-row 库位标签,原始 x/y 已能直接索引到 code 对应位置,不再交换。
bioyond_axis = getattr(warehouse, "bioyond_axis", "xy_row_col")
bioyond_key_axis = getattr(warehouse, "bioyond_key_axis", "row_col")
if bioyond_axis == "xy_col_row" and bioyond_key_axis != "col_row":
x, y = y, x
# 如果是右侧堆栈,需要调整列号 (5→1, 6→2, 7→3, 8→4)
if wh_name == "堆栈1右":
y = y - 4 # 将5-8映射到1-4
@@ -912,10 +923,43 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
logger.debug(f"列优先warehouse {wh_name}: x={x}(行),y={y}(列) → row={row_idx},col={col_idx} → idx={idx}")
if 0 <= idx < warehouse.capacity:
if warehouse[idx] is None or isinstance(warehouse[idx], ResourceHolder):
slot_key = None
ordering = getattr(warehouse, "_ordering", {})
sites = getattr(warehouse, "sites", [])
if isinstance(ordering, dict) and idx < len(sites):
site_at_idx = sites[idx]
slot_key = next(
(key for key, site in ordering.items() if site is site_at_idx),
None,
)
current_resource = warehouse[idx]
if current_resource is None or isinstance(current_resource, (ResourceHolder, str)):
if isinstance(current_resource, str):
logger.warning(
f"⚠️ 物料 {unique_name} 覆盖 {wh_name}[{idx}]"
f"{f'({slot_key})' if slot_key else ''} 的旧占位 occupied_by={current_resource!r}"
)
# 物料尺寸已在放入warehouse前根据需要进行了交换
warehouse[idx] = plr_material
logger.debug(f"✅ 物料 {unique_name} 放置到 {wh_name}[{idx}] (Bioyond坐标: x={loc.get('x')}, y={loc.get('y')})")
logger.debug(
f"✅ 物料 {unique_name} 放置到 {wh_name}[{idx}]"
f"{f'({slot_key})' if slot_key else ''} "
f"(Bioyond坐标: x={loc.get('x')}, y={loc.get('y')})"
)
else:
parent = getattr(current_resource, "parent", None)
current_repr = repr(current_resource)
current_len = len(current_resource) if isinstance(current_resource, str) else None
logger.warning(
f"⚠️ 物料 {unique_name} 跳过放置到 {wh_name}[{idx}]"
f"{f'({slot_key})' if slot_key else ''}:目标库位已有 "
f"{type(current_resource).__name__}"
f"(value={current_repr}, len={current_len})"
f"(name={getattr(current_resource, 'name', None)}, "
f"parent={getattr(parent, 'name', None)}, "
f"uuid={getattr(current_resource, 'unilabos_uuid', None)})"
)
else:
logger.warning(f"❌ 物料 {unique_name} 的索引 {idx} 超出仓库 {wh_name} 容量 {warehouse.capacity}")
else:
@@ -1033,7 +1077,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})")
# 🔥 提取物料名称:从 tracker.liquids 中获取第一个液体的名称去除PLR系统添加的后缀
# tracker.liquids 格式: [(物料名称, 数量), ...]
# tracker.liquids 格式: [(物料名称, 数量, 单位), ...]
material_name = bottle_type_info[0] # 默认使用类型名称(如"样品瓶"
if hasattr(bottle, "tracker") and bottle.tracker.liquids:
# 如果有液体,使用液体的名称
@@ -1051,7 +1095,7 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict
"typeId": bottle_type_info[1],
"code": bottle.code if hasattr(bottle, "code") else "",
"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,
"y": bioyond_y,
"z": 1,
@@ -1124,7 +1168,7 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict
"barCode": "",
"name": material_name, # 使用物料名称而不是资源名称
"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 实际要求的字段(必需)
}

View File

@@ -4,6 +4,8 @@ import json
import threading
import time
import traceback
from unilabos.utils.tools import fast_dumps_str as _fast_dumps_str, fast_loads as _fast_loads
from typing import (
get_type_hints,
TypeVar,
@@ -78,6 +80,67 @@ if TYPE_CHECKING:
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"] = {}
@@ -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}"
)
self._append_resource_lock = RclpyAsyncMutex(name=f"AR:{device_id}")
# 创建资源管理客户端
self._resource_clients: Dict[str, Client] = {
"resource_add": self.create_client(ResourceAdd, "/resources/add", callback_group=self.callback_group),
@@ -378,15 +443,40 @@ class BaseROS2DeviceNode(Node, Generic[T]):
return res
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 import Coordinate
from pylabrobot.resources import Plate
# 物料传输到对应的node节点
# _t0 = time.time()
client = self._resource_clients["c2s_update_resource_tree"]
request = SerialCommand.Request()
request2 = SerialCommand.Request()
command_json = json.loads(req.command)
command_json = _fast_loads(req.command)
namespace = command_json["namespace"]
bind_parent_id = command_json["bind_parent_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}"
)
# 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",
"data": {
@@ -450,7 +544,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
}
)
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()
for plr_instance in plr_instances:
self.resource_tracker.loop_update_uuid(plr_instance, uuid_maps)
@@ -508,7 +606,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
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")])
input_well.set_liquids([(liquid_type, liquid_volume, "ul")])
final_response["liquid_input_resource_tree"] = ResourceTreeSet.from_plr_resources(
input_wells
).dump()
@@ -527,12 +625,13 @@ class BaseROS2DeviceNode(Node, Generic[T]):
Coordinate(location["x"], location["y"], location["z"]),
**other_calling_param,
)
# 调整了液体以及Deck之后要重新Assign
# noinspection PyUnresolvedReferences
# _t3 = time.time()
rts_with_parent = ResourceTreeSet.from_plr_resources([parent_resource])
# _n_parent = len(rts_with_parent.all_nodes)
if rts_with_parent.root_nodes[0].res_content.uuid_parent is None:
rts_with_parent.root_nodes[0].res_content.parent_uuid = self.uuid
request.command = json.dumps(
request.command = _fast_dumps_str(
{
"action": "add",
"data": {
@@ -542,11 +641,18 @@ 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)
uuid_maps = json.loads(tree_response.response)
# _t5 = time.time()
uuid_maps = _fast_loads(tree_response.response)
self.resource_tracker.loop_update_uuid(input_resources, uuid_maps)
self._lab_logger.info(f"Resource tree added. UUID mapping: {len(uuid_maps)} nodes")
# 这里created_resources不包含parent_resource
# self._lab_logger.info(
# f"[AR:{_ar_tag}] 二次上传完成 HTTP={(_t5 - _t4) * 1000:.0f}ms "
# f"UUID映射={len(uuid_maps)}节点 总执行={(_t5 - _t0) * 1000:.0f}ms"
# )
# 发送给ResourceMeshManager
action_client = ActionClient(
self,
@@ -683,7 +789,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
)
# 发送请求并等待响应
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)
if not raw_data:
raise ValueError(f"查询资源 {resource_id} 失败:返回数据为空")
# 转换为 PLR 资源
tree_set = ResourceTreeSet.from_raw_dict_list(raw_data)
@@ -1132,7 +1242,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
if uid is None:
raise ValueError(f"目标物料{target_resource}没有unilabos_uuid属性无法转运")
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)
# 等待服务可用(设置超时)
if not sclient.wait_for_service(timeout_sec=5.0):
@@ -1182,7 +1293,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
return False
time.sleep(0.05)
self.lab_logger().info(f"资源本地增加到{target_device_id}结果: {response.response}")
return None
return "转运完成"
def register_device(self):
"""向注册表中注册设备信息"""
@@ -1567,37 +1678,75 @@ class BaseROS2DeviceNode(Node, Generic[T]):
feedback_msg_types = action_type.Feedback.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():
if goal_handle.is_cancel_requested:
self.lab_logger().info(f"取消动作: {action_name}")
future.cancel() # 尝试取消线程池中的任务
goal_handle.canceled()
return action_type.Result()
# 低频 feedback timer10s不阻塞完成检测
_feedback_timer = None
self._time_spent = time.time() - time_start
self._time_remaining = time_overall - self._time_spent
def _publish_feedback():
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)
# 发布反馈
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"],
if action_value_mapping.get("feedback"):
_fb_interval = action_value_mapping.get("feedback_interval", 0.5)
_feedback_timer = self.create_timer(
_fb_interval, _publish_feedback, callback_group=self.callback_group
)
goal_handle.publish_feedback(feedback_msg)
time.sleep(0.5)
# 等待 action 完成
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():
self.lab_logger().info(f"动作 {action_name} 已取消")
@@ -1606,8 +1755,12 @@ class BaseROS2DeviceNode(Node, Generic[T]):
# self.lab_logger().info(f"动作执行完成: {action_name}")
del future
# 执行失败时跳过物料状态更新
if execution_error:
execution_success = False
# 向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():
if v not in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]:
continue
@@ -1663,7 +1816,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
for attr_name in result_msg_types.keys():
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":
setattr(
result_msg,
@@ -1769,7 +1922,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
raise ValueError("至少需要提供一个 UUID")
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(
command=json.dumps(
{
@@ -1795,6 +1948,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
raise Exception(f"资源查询返回空结果: {uuids_list}")
raw_data = json.loads(response.response)
if not raw_data:
raise Exception(f"资源原始查询返回空结果: {raw_data}")
# 转换为 PLR 资源
tree_set = ResourceTreeSet.from_raw_dict_list(raw_data)
@@ -1816,10 +1971,15 @@ class BaseROS2DeviceNode(Node, Generic[T]):
mapped_plr_resources = []
for uuid in uuids_list:
found = None
for plr_resource in figured_resources:
r = self.resource_tracker.loop_find_with_uuid(plr_resource, uuid)
mapped_plr_resources.append(r)
break
if r is not None:
found = r
break
if found is None:
raise Exception(f"未能在已解析的资源树中找到 uuid={uuid} 对应的资源")
mapped_plr_resources.append(found)
return mapped_plr_resources
@@ -1912,16 +2072,27 @@ class BaseROS2DeviceNode(Node, Generic[T]):
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]):
"""异步转换资源数据为实例"""
# 使用封装的get_resource_with_dir方法获取PLR资源
plr_resource = await self.get_resource_with_dir(resource_ids=resource_data["id"], with_children=True)
async def _convert_resource_async(self, resource_data: "ResourceDictType"):
"""异步转换 ResourceDictType 为 PLR 实例,优先用 uuid 查询"""
unilabos_uuid = resource_data.get("uuid")
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)
if len(res) == 0:
# todo: 后续通过decoration来区分减少warning
self.lab_logger().warning(f"资源转换未能索引到实例: {resource_data},返回新建实例")
self.lab_logger().warning(f"资源转换未能索引到实例: {resource_data.get('id', '?')},返回新建实例")
return plr_resource
elif len(res) == 1:
return res[0]

View File

@@ -4,6 +4,8 @@ import threading
import time
import traceback
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 typing import TYPE_CHECKING, Optional, Dict, Any, List, ClassVar, Set, Union
@@ -618,22 +620,17 @@ class HostNode(BaseROS2DeviceNode):
}
)
]
response: List[str] = await self.create_resource_detailed(
resources, device_ids, bind_parent_id, bind_location, other_calling_param
)
try:
assert len(response) == 1, "Create Resource应当只返回一个结果"
for i in response:
res = json.loads(i)
if "suc" in res:
raise ValueError(res.get("error"))
return res
except Exception as ex:
pass
_n = "\n"
raise ValueError(f"创建资源时失败!\n{_n.join(response)}")
assert len(response) == 1, "Create Resource应当只返回一个结果"
for i in response:
res = json.loads(i)
if "suc" in res and not res["suc"]:
raise ValueError(res.get("error", "未知错误"))
return res
raise ValueError(f"创建资源时失败!响应为空")
def initialize_device(self, device_id: str, device_config: ResourceDictInstance) -> None:
"""
@@ -1168,7 +1165,7 @@ class HostNode(BaseROS2DeviceNode):
else:
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}")
async def _resource_tree_action_get_callback(self, data: dict, response: SerialCommand_Response): # OK
@@ -1178,6 +1175,7 @@ class HostNode(BaseROS2DeviceNode):
resource_response = http_client.resource_tree_get(uuid_list, with_children)
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):
"""
@@ -1230,9 +1228,26 @@ class HostNode(BaseROS2DeviceNode):
"""
try:
# 解析请求数据
data = json.loads(request.command)
data = _fast_loads(request.command)
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"]
if action == "add":
await self._resource_tree_action_add_callback(data, response)

View File

@@ -22,6 +22,447 @@
"arm_state": "idle",
"message": "工作台就绪"
}
},
{
"id": "PRCXI",
"name": "PRCXI",
"type": "device",
"class": "liquid_handler.prcxi",
"parent": "",
"pose": {
"size": {
"width": 562,
"height": 394,
"depth": 0
}
},
"config": {
"axis": "Left",
"deck": {
"_resource_type": "unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Deck",
"_resource_child_name": "PRCXI_Deck"
},
"host": "10.20.30.184",
"port": 9999,
"debug": true,
"setup": true,
"is_9320": true,
"timeout": 10,
"matrix_id": "5de524d0-3f95-406c-86dd-f83626ebc7cb",
"simulator": true,
"channel_num": 2
},
"data": {
"reset_ok": true
},
"schema": {},
"description": "",
"model": null,
"position": {
"x": 0,
"y": 240,
"z": 0
}
},
{
"id": "PRCXI_Deck",
"name": "PRCXI_Deck",
"children": [],
"parent": "PRCXI",
"type": "deck",
"class": "",
"position": {
"x": 10,
"y": 10,
"z": 0
},
"config": {
"type": "PRCXI9300Deck",
"size_x": 542,
"size_y": 374,
"size_z": 0,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"category": "deck",
"barcode": null,
"preferred_pickup_location": null,
"sites": [
{
"label": "T1",
"visible": true,
"occupied_by": null,
"position": {
"x": 0,
"y": 0,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"container",
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T2",
"visible": true,
"occupied_by": null,
"position": {
"x": 138,
"y": 0,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T3",
"visible": true,
"occupied_by": null,
"position": {
"x": 276,
"y": 0,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T4",
"visible": true,
"occupied_by": null,
"position": {
"x": 414,
"y": 0,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T5",
"visible": true,
"occupied_by": null,
"position": {
"x": 0,
"y": 96,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T6",
"visible": true,
"occupied_by": null,
"position": {
"x": 138,
"y": 96,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T7",
"visible": true,
"occupied_by": null,
"position": {
"x": 276,
"y": 96,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T8",
"visible": true,
"occupied_by": null,
"position": {
"x": 414,
"y": 96,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T9",
"visible": true,
"occupied_by": null,
"position": {
"x": 0,
"y": 192,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T10",
"visible": true,
"occupied_by": null,
"position": {
"x": 138,
"y": 192,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T11",
"visible": true,
"occupied_by": null,
"position": {
"x": 276,
"y": 192,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T12",
"visible": true,
"occupied_by": null,
"position": {
"x": 414,
"y": 192,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T13",
"visible": true,
"occupied_by": null,
"position": {
"x": 0,
"y": 288,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T14",
"visible": true,
"occupied_by": null,
"position": {
"x": 138,
"y": 288,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T15",
"visible": true,
"occupied_by": null,
"position": {
"x": 276,
"y": 288,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T16",
"visible": true,
"occupied_by": null,
"position": {
"x": 414,
"y": 288,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
}
]
},
"data": {}
}
],
"links": []

View File

@@ -33,10 +33,76 @@ _USE_UV: Optional[bool] = None
def _has_uv() -> bool:
global _USE_UV
if _USE_UV is None:
_USE_UV = shutil.which("uv") is not None
uv_path = shutil.which("uv")
if not uv_path:
_USE_UV = False
else:
try:
result = subprocess.run([uv_path, "--version"], capture_output=True, text=True, timeout=10)
_USE_UV = result.returncode == 0
except Exception:
_USE_UV = False
return _USE_UV
def _install_command(installer: str, package: str, upgrade: bool, is_chinese: bool) -> List[str]:
if installer == "uv":
cmd = ["uv", "pip", "install"]
if upgrade:
cmd.append("--upgrade")
cmd.append(package)
if is_chinese:
cmd.extend(["--index-url", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"])
return cmd
cmd = [sys.executable, "-m", "pip", "install", "--disable-pip-version-check"]
if upgrade:
cmd.append("--upgrade")
cmd.append(package)
if is_chinese:
cmd.extend(["-i", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"])
return cmd
def _installer_candidates() -> List[str]:
installers: List[str] = []
if _has_uv():
installers.append("uv")
installers.append("pip")
return installers
def _git_url_from_requirement(requirement: str) -> Optional[str]:
if not requirement.startswith("git+"):
return None
return requirement[4:].split("#", 1)[0]
def _repo_dir_name(git_url: str) -> str:
repo_name = git_url.rstrip("/").rsplit("/", 1)[-1]
return repo_name[:-4] if repo_name.endswith(".git") else repo_name
def _print_manual_git_install_hint(requirement: str) -> None:
git_url = _git_url_from_requirement(requirement)
if not git_url:
return
repo_dir = _repo_dir_name(git_url)
install_cmd = "uv pip install -e ." if _has_uv() else f"{sys.executable} -m pip install -e ."
if _is_chinese_locale() and not _has_uv():
install_cmd += " -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"
print_status("Git 依赖自动安装失败,通常是网络连接被重置或代码托管站点暂时不可达。", "warning")
print_status("可以手动拉取代码后在本地安装:", "warning")
print_status(f" git clone {git_url}", "warning")
print_status(f" cd {repo_dir}", "warning")
print_status(" git pull", "warning")
print_status(f" {install_cmd}", "warning")
print_status(f"如果目录 {repo_dir} 已存在,直接进入该目录执行 git pull 后再安装。", "warning")
print_status("如果 git clone 仍失败,请切换网络/代理,或从浏览器下载源码后进入源码目录执行本地安装命令。", "warning")
def _install_packages(
packages: List[str],
upgrade: bool = False,
@@ -53,7 +119,7 @@ def _install_packages(
return True
is_chinese = _is_chinese_locale()
use_uv = _has_uv()
installers = _installer_candidates()
failed: List[str] = []
for pkg in packages:
@@ -63,35 +129,30 @@ def _install_packages(
else:
print_status(f"正在{action_word} {pkg}...", "info")
if use_uv:
cmd = ["uv", "pip", "install"]
if upgrade:
cmd.append("--upgrade")
cmd.append(pkg)
if is_chinese:
cmd.extend(["--index-url", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"])
else:
cmd = [sys.executable, "-m", "pip", "install"]
if upgrade:
cmd.append("--upgrade")
cmd.append(pkg)
if is_chinese:
cmd.extend(["-i", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"])
pkg_installed = False
last_error = "unknown error"
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
if result.returncode == 0:
installer = "uv" if use_uv else "pip"
print_status(f"{pkg} {action_word}成功 (via {installer})", "success")
else:
stderr_short = result.stderr.strip().split("\n")[-1] if result.stderr else "unknown error"
print_status(f"× {pkg} {action_word}失败: {stderr_short}", "error")
failed.append(pkg)
except subprocess.TimeoutExpired:
print_status(f"× {pkg} {action_word}超时 (300s)", "error")
failed.append(pkg)
except Exception as e:
print_status(f"× {pkg} {action_word}异常: {e}", "error")
for installer in installers:
cmd = _install_command(installer, pkg, upgrade, is_chinese)
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
if result.returncode == 0:
print_status(f"{pkg} {action_word}成功 (via {installer})", "success")
pkg_installed = True
break
last_error = result.stderr.strip().split("\n")[-1] if result.stderr else "unknown error"
print_status(f"× {pkg} {action_word}失败 (via {installer}): {last_error}", "warning")
except subprocess.TimeoutExpired:
last_error = "timeout after 300s"
print_status(f"× {pkg} {action_word}超时 (via {installer}, 300s)", "warning")
except Exception as e:
last_error = str(e)
print_status(f"× {pkg} {action_word}异常 (via {installer}): {e}", "warning")
if not pkg_installed:
print_status(f"× {pkg} {action_word}失败: {last_error}", "error")
_print_manual_git_install_hint(pkg)
failed.append(pkg)
if failed:
@@ -188,7 +249,13 @@ class EnvironmentChecker:
"crcmod": "crcmod-plus",
}
self.special_packages = {"pylabrobot": "git+https://github.com/Xuwznln/pylabrobot.git"}
# 中文 locale 下走 Gitee 镜像,规避 GitHub 拉取失败
pylabrobot_url = (
"git+https://gitee.com/xuwznln/pylabrobot.git"
if _is_chinese_locale()
else "git+https://github.com/Xuwznln/pylabrobot.git"
)
self.special_packages = {"pylabrobot": pylabrobot_url}
self.version_requirements = {
"msgcenterpy": "0.1.8",

View File

@@ -206,6 +206,7 @@ class ImportManager:
"ast_analysis_success": False,
"import_map": {},
"init_params": [],
"init_docstring": None,
"status_methods": {},
"action_methods": {},
}
@@ -251,6 +252,7 @@ class ImportManager:
# 映射到统一字段名(与 registry.py complete_registry 消费端一致)
result["init_params"] = body.get("init_params", [])
result["init_docstring"] = body.get("init_docstring")
result["status_methods"] = body.get("status_properties", {})
result["action_methods"] = {
k: {

View File

@@ -17,6 +17,14 @@ try:
default=json_default,
)
def fast_loads(data) -> dict:
"""JSON 反序列化,优先使用 orjson。接受 str / bytes。"""
return orjson.loads(data)
def fast_dumps_str(obj, **kwargs) -> str:
"""JSON 序列化为 str优先使用 orjson。用于需要 str 而非 bytes 的场景(如 ROS msg"""
return orjson.dumps(obj, option=orjson.OPT_NON_STR_KEYS, default=json_default).decode("utf-8")
def normalize_json(info: dict) -> dict:
"""经 JSON 序列化/反序列化一轮来清理非标准类型。"""
return orjson.loads(orjson.dumps(info, default=json_default))
@@ -29,6 +37,14 @@ except ImportError:
def fast_dumps_pretty(obj, **kwargs) -> bytes: # type: ignore[misc]
return json.dumps(obj, indent=2, ensure_ascii=False, cls=TypeEncoder).encode("utf-8")
def fast_loads(data) -> dict: # type: ignore[misc]
if isinstance(data, bytes):
data = data.decode("utf-8")
return json.loads(data)
def fast_dumps_str(obj, **kwargs) -> str: # type: ignore[misc]
return json.dumps(obj, ensure_ascii=False, cls=TypeEncoder)
def normalize_json(info: dict) -> dict: # type: ignore[misc]
return json.loads(json.dumps(info, ensure_ascii=False, cls=TypeEncoder))

View File

@@ -2,7 +2,7 @@
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
<package format="3">
<name>unilabos_msgs</name>
<version>0.10.19</version>
<version>0.11.1</version>
<description>ROS2 Messages package for unilabos devices</description>
<maintainer email="changjh@pku.edu.cn">Junhan Chang</maintainer>
<maintainer email="18435084+Xuwznln@users.noreply.github.com">Xuwznln</maintainer>