Compare commits

..

39 Commits

Author SHA1 Message Date
Junhan Chang
9862415655 Update test_model_upload.py 2026-03-25 09:24:51 +08:00
Junhan Chang
18296d3cb2 Update model_upload.py 2026-03-25 08:55:21 +08:00
Junhan Chang
090d5c5cb5 feat(app): 模型上传与注册增强 — normalize_model、upload_model_package、backend client
- model_upload.py: normalize_model_package 标准化模型目录 + upload_model_package 上传到后端
- register.py: 设备注册时自动检测并上传本地模型文件
- web/client.py: BackendClient 新增 get_model_upload_urls/publish_model/update_template_model
- tests: test_model_upload.py、test_normalize_model.py 单元测试

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 23:02:18 +08:00
Junhan Chang
48e13a7b4d P1 Edge: 关节桥接重构 — 直接订阅 /joint_states + 资源跟随 + 吞吐优化
- HostNode 直接订阅 /joint_states (JointStateMsg),绕过 JointRepublisher 中间人
- 新增 resource_pose 订阅,实现资源夹取跟随 (gripper attach/detach)
- 吞吐优化:死区过滤 (1e-4 rad)、抑频 (~20Hz)、增量 resource_poses
- JointRepublisher 修复 str→json.dumps (E1)
- communication.py 新增 publish_joint_state 抽象方法
- ws_client.py 实现 push_joint_state action 发送
- 57 项测试覆盖:关节分组、资源跟随、同类型多设备、优化行为

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 06:32:30 +08:00
Junhan Chang
d776550a4b add virtual_sample_demo 样品追踪测试设备 2026-03-23 16:43:20 +08:00
Xuwznln
3d8123849a add external devices param
fix registry upload missing type
2026-03-23 15:01:16 +08:00
Xuwznln
d2f204c5b0 bump to 0.10.19 2026-03-22 04:17:21 +08:00
Xuwznln
d8922884b1 fast registry load 2026-03-22 04:14:47 +08:00
Xuwznln
427afe83d4 minor fix on skill & registry 2026-03-22 03:36:28 +08:00
Xuwznln
23c2e3b2f7 stripe ros2 schema desc
add create-device-skill
2026-03-22 03:21:13 +08:00
Xuwznln
59c26265e9 new registry system backwards to yaml 2026-03-22 02:19:54 +08:00
Xuwznln
4c2adea55a remove not exist resource 2026-03-21 23:35:51 +08:00
Xuwznln
0f6264503a new registry sys
exp. support with add device
2026-03-21 19:26:24 +08:00
Junhan Chang
2c554182d3 add ai conventions 2026-03-19 14:14:40 +08:00
Xuwznln
6d319d91ff correct raise create resource error 2026-03-10 16:26:37 +08:00
Xuwznln
3155b2f97e ret info fix revert 2026-03-10 16:04:27 +08:00
Xuwznln
e5e30a1c7d ret info fix 2026-03-10 16:00:24 +08:00
Xuwznln
4e82f62327 fix prcxi check 2026-03-10 15:57:27 +08:00
Xuwznln
95d3456214 add create_resource schema 2026-03-10 15:27:39 +08:00
Xuwznln
38bf95b13c re signal host ready event 2026-03-10 14:13:06 +08:00
Xuwznln
f2c0bec02c add websocket connection timeout and improve reconnection logic
add open_timeout parameter to websocket connection
add TimeoutError and InvalidStatus exception handling
implement exponential backoff for reconnection attempts
simplify reconnection logic flow
2026-03-07 04:40:56 +08:00
Xuwznln
e0394bf414 Merge remote-tracking branch 'origin/dev' into dev 2026-03-04 19:18:55 +08:00
Xuwznln
975a56415a import gzip 2026-03-04 19:18:36 +08:00
Xuwznln
cadbe87e3f add gzip 2026-03-04 19:18:19 +08:00
Xuwznln
b993c1f590 add gzip 2026-03-04 19:18:09 +08:00
Xuwznln
e0fae94c10 change pose extra to any 2026-03-04 19:06:58 +08:00
Xuwznln
b5cd181ac1 add isFlapY 2026-03-04 18:59:45 +08:00
Xuwznln
5c047beb83 support container as example
add z index

(cherry picked from commit 145fcaae65)
2026-03-03 18:04:13 +08:00
Xuwznln
b40c087143 fix container volume 2026-03-03 17:13:32 +08:00
Xuwznln
7f1cc3b2a5 update materials 2026-03-03 11:43:52 +08:00
Xuwznln
3f160c2049 更新prcxi deck & 新增 unilabos_resource_slot 2026-03-03 11:40:23 +08:00
Xuwznln
a54e7c0f23 new workflow & prcxi slot removal 2026-03-02 18:29:25 +08:00
Xuwznln
e5015cd5e0 fix size change 2026-03-02 15:52:44 +08:00
Xuwznln
514373c164 v0.10.18
(cherry picked from commit 06b6f0d804)
2026-03-02 02:30:10 +08:00
Xuwznln
fcea02585a no opcua installation on macos 2026-02-28 09:41:37 +08:00
Xuwznln
07cf690897 fix possible crash 2026-02-12 01:46:26 +08:00
Xuwznln
cfea27460a fix deck & host_node 2026-02-12 01:46:24 +08:00
Xuwznln
b7d3e980a9 set liquid with tube 2026-02-12 01:46:23 +08:00
Xuwznln
f9ed6cb3fb add test_resource_schema 2026-02-11 14:02:21 +08:00
96 changed files with 19299 additions and 32973 deletions

View File

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

View File

@@ -1,9 +0,0 @@
@echo off
setlocal enabledelayedexpansion
REM upgrade pip
"%PREFIX%\python.exe" -m pip install --upgrade pip
REM install extra deps
"%PREFIX%\python.exe" -m pip install paho-mqtt opentrons_shared_data
"%PREFIX%\python.exe" -m pip install git+https://github.com/Xuwznln/pylabrobot.git

View File

@@ -1,9 +0,0 @@
#!/usr/bin/env bash
set -euxo pipefail
# make sure pip is available
"$PREFIX/bin/python" -m pip install --upgrade pip
# install extra deps
"$PREFIX/bin/python" -m pip install paho-mqtt opentrons_shared_data
"$PREFIX/bin/python" -m pip install git+https://github.com/Xuwznln/pylabrobot.git

View File

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

View File

@@ -0,0 +1,351 @@
---
name: add-resource
description: Guide for adding new resources (materials, bottles, carriers, decks, warehouses) to Uni-Lab-OS (添加新物料/资源). Uses @resource decorator for AST auto-scanning. Covers Bottle, Carrier, Deck, WareHouse definitions. Use when the user wants to add resources, define materials, create a deck layout, add bottles/carriers/plates, or mentions 物料/资源/resource/bottle/carrier/deck/plate/warehouse.
---
# 添加新物料资源
Uni-Lab-OS 的资源体系基于 PyLabRobot通过扩展实现 Bottle、Carrier、WareHouse、Deck 等实验室物料管理。使用 `@resource` 装饰器注册AST 自动扫描生成注册表条目。
---
## 资源类型
| 类型 | 基类 | 用途 | 示例 |
|------|------|------|------|
| **Bottle** | `Well` (PyLabRobot) | 单个容器(瓶、小瓶、烧杯、反应器) | 试剂瓶、粉末瓶 |
| **BottleCarrier** | `ItemizedCarrier` | 多槽位载架(放多个 Bottle | 6 位试剂架、枪头盒 |
| **WareHouse** | `ItemizedCarrier` | 堆栈/仓库(放多个 Carrier | 4x4 堆栈 |
| **Deck** | `Deck` (PyLabRobot) | 工作站台面(放多个 WareHouse | 反应站 Deck |
**层级关系:** `Deck``WareHouse``BottleCarrier``Bottle`
WareHouse 本质上和 Site 是同一概念 — 都是定义一组固定的放置位slot只不过 WareHouse 多嵌套了一层 Deck。两者都需要开发者根据实际物理尺寸自行计算各 slot 的偏移坐标。
---
## @resource 装饰器
```python
from unilabos.registry.decorators import resource
@resource(
id="my_resource_id", # 注册表唯一标识(必填)
category=["bottles"], # 分类标签列表(必填)
description="资源描述",
icon="", # 图标
version="1.0.0",
handles=[...], # 端口列表InputHandle / OutputHandle
model={...}, # 3D 模型配置
class_type="pylabrobot", # "python" / "pylabrobot" / "unilabos"
)
```
---
## 创建规范
### 命名规则
1. **`name` 参数作为前缀**:所有工厂函数必须接受 `name: str` 参数,创建子物料时以 `name` 作为前缀,确保实例名在运行时全局唯一
2. **Bottle 命名约定**:试剂瓶-Bottle烧杯-Beaker烧瓶-Flask小瓶-Vial
3. **函数名 = `@resource(id=...)`**:工厂函数名与注册表 id 保持一致
### 子物料命名示例
```python
# Carrier 内部的 sites 用 name 前缀
for k, v in sites.items():
v.name = f"{name}_{v.name}" # "堆栈1左_A01", "堆栈1左_B02" ...
# Carrier 中放置 Bottle 时用 name 前缀
carrier[0] = My_Reagent_Bottle(f"{name}_flask_1") # "堆栈1左_flask_1"
carrier[i] = My_Solid_Vial(f"{name}_vial_{ordering[i]}") # "堆栈1左_vial_A1"
# create_homogeneous_resources 使用 name_prefix
sites=create_homogeneous_resources(
klass=ResourceHolder,
locations=[...],
name_prefix=name, # 自动生成 "{name}_0", "{name}_1" ...
)
# Deck setup 中用仓库名称作为 name 传入
self.warehouses = {
"堆栈1左": my_warehouse_4x4("堆栈1左"), # WareHouse.name = "堆栈1左"
"试剂堆栈": my_reagent_stack("试剂堆栈"), # WareHouse.name = "试剂堆栈"
}
```
### 其他规范
- **max_volume 单位为 μL**500mL = 500000
- **尺寸单位为 mm**`diameter`, `height`, `size_x/y/z`, `dx/dy/dz`
- **BottleCarrier 必须设置 `num_items_x/y/z`**:用于前端渲染布局
- **Deck 的 `__init__` 必须接受 `setup=False`**:图文件中 `config.setup=true` 触发 `setup()`
- **按项目分组文件**:同一工作站的资源放在 `unilabos/resources/<project>/`
- **`__init__` 必须接受 `serialize()` 输出的所有字段**`serialize()` 输出会作为 `config` 回传到 `__init__`,因此必须通过显式参数或 `**kwargs` 接受,否则反序列化会报错
- **持久化运行时状态用 `serialize_state()`**:通过 `_unilabos_state` 字典存储可变信息(如物料内容、液体量),只存 JSON 可序列化的基本类型
---
## 资源模板
### Bottle
```python
from unilabos.registry.decorators import resource
from unilabos.resources.itemized_carrier import Bottle
@resource(id="My_Reagent_Bottle", category=["bottles"], description="我的试剂瓶")
def My_Reagent_Bottle(
name: str,
diameter: float = 70.0,
height: float = 120.0,
max_volume: float = 500000.0,
barcode: str = None,
) -> Bottle:
return Bottle(
name=name,
diameter=diameter,
height=height,
max_volume=max_volume,
barcode=barcode,
model="My_Reagent_Bottle",
)
```
**Bottle 参数:**
- `name`: 实例名称(运行时唯一,由上层 Carrier 以前缀方式传入)
- `diameter`: 瓶体直径 (mm)
- `height`: 瓶体高度 (mm)
- `max_volume`: 最大容积(**μL**500mL = 500000
- `barcode`: 条形码(可选)
### BottleCarrier
```python
from pylabrobot.resources import ResourceHolder
from pylabrobot.resources.carrier import create_ordered_items_2d
from unilabos.resources.itemized_carrier import BottleCarrier
from unilabos.registry.decorators import resource
@resource(id="My_6SlotCarrier", category=["bottle_carriers"], description="六槽位载架")
def My_6SlotCarrier(name: str) -> BottleCarrier:
sites = create_ordered_items_2d(
klass=ResourceHolder,
num_items_x=3, num_items_y=2,
dx=10.0, dy=10.0, dz=5.0,
item_dx=42.0, item_dy=35.0,
size_x=20.0, size_y=20.0, size_z=50.0,
)
# 子 site 用 name 作为前缀
for k, v in sites.items():
v.name = f"{name}_{v.name}"
carrier = BottleCarrier(
name=name, size_x=146.0, size_y=80.0, size_z=55.0,
sites=sites, model="My_6SlotCarrier",
)
carrier.num_items_x = 3
carrier.num_items_y = 2
carrier.num_items_z = 1
# 放置 Bottle 时用 name 作为前缀
ordering = ["A1", "B1", "A2", "B2", "A3", "B3"]
for i in range(6):
carrier[i] = My_Reagent_Bottle(f"{name}_vial_{ordering[i]}")
return carrier
```
### WareHouse / Deck 放置位
WareHouse 和 Site 本质上是同一概念都是定义一组固定放置位slot根据物理尺寸自行批量计算偏移坐标。WareHouse 只是多嵌套了一层 Deck 而已。推荐开发者直接根据实物测量数据计算各 slot 偏移量。
#### WareHouse使用 warehouse_factory
```python
from unilabos.resources.warehouse import warehouse_factory
from unilabos.registry.decorators import resource
@resource(id="my_warehouse_4x4", category=["warehouse"], description="4x4 堆栈仓库")
def my_warehouse_4x4(name: str) -> "WareHouse":
return warehouse_factory(
name=name,
num_items_x=4, num_items_y=4, num_items_z=1,
dx=10.0, dy=10.0, dz=10.0, # 第一个 slot 的起始偏移
item_dx=147.0, item_dy=106.0, item_dz=130.0, # slot 间距
resource_size_x=127.0, resource_size_y=85.0, resource_size_z=100.0, # slot 尺寸
model="my_warehouse_4x4",
col_offset=0, # 列标签起始偏移0 → A01, 4 → A05
layout="row-major", # "row-major" 行优先 / "col-major" 列优先 / "vertical-col-major" 竖向
)
```
`warehouse_factory` 参数说明:
- `dx/dy/dz`:第一个 slot 相对 WareHouse 原点的偏移mm
- `item_dx/item_dy/item_dz`:相邻 slot 间距mm需根据实际物理间距测量
- `resource_size_x/y/z`:每个 slot 的可放置区域尺寸
- `layout`:影响 slot 标签和坐标映射
- `"row-major"`A01,A02,...,B01,B02,...(行优先,适合横向排列)
- `"col-major"`A01,B01,...,A02,B02,...(列优先)
- `"vertical-col-major"`竖向排列y 坐标反向
#### Deck 组装 WareHouse
Deck 通过 `setup()` 将多个 WareHouse 放置到指定坐标:
```python
from pylabrobot.resources import Deck, Coordinate
from unilabos.registry.decorators import resource
@resource(id="MyStation_Deck", category=["deck"], description="我的工作站 Deck")
class MyStation_Deck(Deck):
def __init__(self, name="MyStation_Deck", size_x=2700.0, size_y=1080.0, size_z=1500.0,
category="deck", setup=False, **kwargs) -> None:
super().__init__(name=name, size_x=size_x, size_y=size_y, size_z=size_z)
if setup:
self.setup()
def setup(self) -> None:
self.warehouses = {
"堆栈1左": my_warehouse_4x4("堆栈1左"),
"堆栈1右": my_warehouse_4x4("堆栈1右"),
}
self.warehouse_locations = {
"堆栈1左": Coordinate(-200.0, 400.0, 0.0), # 自行测量计算
"堆栈1右": Coordinate(2350.0, 400.0, 0.0),
}
for wh_name, wh in self.warehouses.items():
self.assign_child_resource(wh, location=self.warehouse_locations[wh_name])
```
#### Site 模式(前端定向放置)
适用于有固定孔位/槽位的设备(如移液站 PRCXI 9300Deck 通过 `sites` 列表定义前端展示的放置位,前端据此渲染可拖拽的孔位布局:
```python
import collections
from typing import Any, Dict, List, Optional
from pylabrobot.resources import Deck, Resource, Coordinate
from unilabos.registry.decorators import resource
@resource(id="MyLabDeck", category=["deck"], description="带 Site 定向放置的 Deck")
class MyLabDeck(Deck):
# 根据设备台面实测批量计算各 slot 坐标偏移
_DEFAULT_SITE_POSITIONS = [
(0, 0, 0), (138, 0, 0), (276, 0, 0), (414, 0, 0), # T1-T4
(0, 96, 0), (138, 96, 0), (276, 96, 0), (414, 96, 0), # T5-T8
]
_DEFAULT_SITE_SIZE = {"width": 128.0, "height": 86.0, "depth": 0}
_DEFAULT_CONTENT_TYPE = ["plate", "tip_rack", "tube_rack", "adaptor"]
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
sites: Optional[List[Dict[str, Any]]] = None, **kwargs):
super().__init__(size_x, size_y, size_z, name)
if sites is not None:
self.sites = [dict(s) for s in sites]
else:
self.sites = []
for i, (x, y, z) in enumerate(self._DEFAULT_SITE_POSITIONS):
self.sites.append({
"label": f"T{i + 1}", # 前端显示的槽位标签
"visible": True, # 是否在前端可见
"position": {"x": x, "y": y, "z": z}, # 槽位物理坐标
"size": dict(self._DEFAULT_SITE_SIZE), # 槽位尺寸
"content_type": list(self._DEFAULT_CONTENT_TYPE), # 允许放入的物料类型
})
self._ordering = collections.OrderedDict(
(site["label"], None) for site in self.sites
)
def assign_child_resource(self, resource: Resource,
location: Optional[Coordinate] = None,
reassign: bool = True,
spot: Optional[int] = None):
idx = spot
if spot is None:
for i, site in enumerate(self.sites):
if site.get("label") == resource.name:
idx = i
break
if idx is None:
for i in range(len(self.sites)):
if self._get_site_resource(i) is None:
idx = i
break
if idx is None:
raise ValueError(f"No available site for '{resource.name}'")
loc = Coordinate(**self.sites[idx]["position"])
super().assign_child_resource(resource, location=loc, reassign=reassign)
def serialize(self) -> dict:
data = super().serialize()
sites_out = []
for i, site in enumerate(self.sites):
occupied = self._get_site_resource(i)
sites_out.append({
"label": site["label"],
"visible": site.get("visible", True),
"occupied_by": occupied.name if occupied else None,
"position": site["position"],
"size": site["size"],
"content_type": site["content_type"],
})
data["sites"] = sites_out
return data
```
**Site 字段说明:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `label` | str | 槽位标签(如 `"T1"`),前端显示名称,也用于匹配 resource.name |
| `visible` | bool | 是否在前端可见 |
| `position` | dict | 物理坐标 `{x, y, z}`mm需自行测量计算偏移 |
| `size` | dict | 槽位尺寸 `{width, height, depth}`mm |
| `content_type` | list | 允许放入的物料类型,如 `["plate", "tip_rack", "tube_rack", "adaptor"]` |
**参考实现:** `unilabos/devices/liquid_handling/prcxi/prcxi.py` 中的 `PRCXI9300Deck`4x4 共 16 个 site
---
## 文件位置
```
unilabos/resources/
├── <project>/ # 按项目分组
│ ├── bottles.py # Bottle 工厂函数
│ ├── bottle_carriers.py # Carrier 工厂函数
│ ├── warehouses.py # WareHouse 工厂函数
│ └── decks.py # Deck 类定义
```
---
## 验证
```bash
# 资源可导入
python -c "from unilabos.resources.my_project.bottles import My_Reagent_Bottle; print(My_Reagent_Bottle('test'))"
# 启动测试AST 自动扫描)
unilab -g <graph>.json
```
仅在以下情况仍需 YAML第三方库资源如 pylabrobot 内置资源,无 `@resource` 装饰器)。
---
## 关键路径
| 内容 | 路径 |
|------|------|
| Bottle/Carrier 基类 | `unilabos/resources/itemized_carrier.py` |
| WareHouse 基类 + 工厂 | `unilabos/resources/warehouse.py` |
| PLR 注册 | `unilabos/resources/plr_additional_res_reg.py` |
| 装饰器定义 | `unilabos/registry/decorators.py` |

View File

@@ -0,0 +1,292 @@
# 资源高级参考
本文件是 SKILL.md 的补充,包含类继承体系、序列化/反序列化、Bioyond 物料同步、非瓶类资源和仓库工厂模式。Agent 在需要实现这些功能时按需阅读。
---
## 1. 类继承体系
```
PyLabRobot
├── Resource (PLR 基类)
│ ├── Well
│ │ └── Bottle (unilabos) → 瓶/小瓶/烧杯/反应器
│ ├── Deck
│ │ └── 自定义 Deck 类 (unilabos) → 工作站台面
│ ├── ResourceHolder → 槽位占位符
│ └── Container
│ └── Battery (unilabos) → 组装好的电池
├── ItemizedCarrier (unilabos, 继承 Resource)
│ ├── BottleCarrier (unilabos) → 瓶载架
│ └── WareHouse (unilabos) → 堆栈仓库
├── ItemizedResource (PLR)
│ └── MagazineHolder (unilabos) → 子弹夹载架
└── ResourceStack (PLR)
└── Magazine (unilabos) → 子弹夹洞位
```
### Bottle 类细节
```python
class Bottle(Well):
def __init__(self, name, diameter, height, max_volume,
size_x=0.0, size_y=0.0, size_z=0.0,
barcode=None, category="container", model=None, **kwargs):
super().__init__(
name=name,
size_x=diameter, # PLR 用 diameter 作为 size_x/size_y
size_y=diameter,
size_z=height, # PLR 用 height 作为 size_z
max_volume=max_volume,
category=category,
model=model,
bottom_type="flat",
cross_section_type="circle"
)
```
注意 `size_x = size_y = diameter``size_z = height`
### ItemizedCarrier 核心方法
| 方法 | 说明 |
|------|------|
| `__getitem__(identifier)` | 通过索引或 Excel 标识(如 `"A01"`)访问槽位 |
| `__setitem__(identifier, resource)` | 向槽位放入资源 |
| `get_child_identifier(child)` | 获取子资源的标识符 |
| `capacity` | 总槽位数 |
| `sites` | 所有槽位字典 |
---
## 2. 序列化与反序列化
### PLR ↔ UniLab 转换
| 函数 | 位置 | 方向 |
|------|------|------|
| `ResourceTreeSet.from_plr_resources(resources)` | `resource_tracker.py` | PLR → UniLab |
| `ResourceTreeSet.to_plr_resources()` | `resource_tracker.py` | UniLab → PLR |
### `from_plr_resources` 流程
```
PLR Resource
↓ build_uuid_mapping (递归生成 UUID)
↓ resource.serialize() → dict
↓ resource.serialize_all_state() → states
↓ resource_plr_inner (递归构建 ResourceDictInstance)
ResourceTreeSet
```
关键:每个 PLR 资源通过 `unilabos_uuid` 属性携带 UUID`unilabos_extra` 携带扩展数据(如 `class` 名)。
### `to_plr_resources` 流程
```
ResourceTreeSet
↓ collect_node_data (收集 UUID、状态、扩展数据)
↓ node_to_plr_dict (转为 PLR 字典格式)
↓ find_subclass(type_name, PLRResource) (查找 PLR 子类)
↓ sub_cls.deserialize(plr_dict) (反序列化)
↓ loop_set_uuid, loop_set_extra (递归设置 UUID 和扩展)
PLR Resource
```
### Bottle 序列化
```python
class Bottle(Well):
def serialize(self) -> dict:
data = super().serialize()
return {**data, "diameter": self.diameter, "height": self.height}
@classmethod
def deserialize(cls, data: dict, allow_marshal=False):
barcode_data = data.pop("barcode", None)
instance = super().deserialize(data, allow_marshal=allow_marshal)
if barcode_data and isinstance(barcode_data, str):
instance.barcode = barcode_data
return instance
```
---
## 3. Bioyond 物料同步
### 双向转换函数
| 函数 | 位置 | 方向 |
|------|------|------|
| `resource_bioyond_to_plr(materials, type_mapping, deck)` | `graphio.py` | Bioyond → PLR |
| `resource_plr_to_bioyond(resources, type_mapping, warehouse_mapping)` | `graphio.py` | PLR → Bioyond |
### `resource_bioyond_to_plr` 流程
```
Bioyond 物料列表
↓ reverse_type_mapping: {typeName → (model, UUID)}
↓ 对每个物料:
typeName → 查映射 → model (如 "BIOYOND_PolymerStation_Reactor")
initialize_resource({"name": unique_name, "class": model})
↓ 设置 unilabos_extra (material_bioyond_id, material_bioyond_name 等)
↓ 处理 detail (子物料/坐标)
↓ 按 locationName 放入 deck.warehouses 对应槽位
PLR 资源列表
```
### `resource_plr_to_bioyond` 流程
```
PLR 资源列表
↓ 遍历每个资源:
载架(capacity > 1): 生成 details 子物料 + 坐标
单瓶: 直接映射
↓ type_mapping 查找 typeId
↓ warehouse_mapping 查找位置 UUID
↓ 组装 Bioyond 格式 (name, typeName, typeId, quantity, Parameters, locations)
Bioyond 物料列表
```
### BioyondResourceSynchronizer
工作站通过 `ResourceSynchronizer` 自动同步物料:
```python
class BioyondResourceSynchronizer(ResourceSynchronizer):
def sync_from_external(self) -> bool:
all_data = []
all_data.extend(api_client.stock_material('{"typeMode": 0}')) # 耗材
all_data.extend(api_client.stock_material('{"typeMode": 1}')) # 样品
all_data.extend(api_client.stock_material('{"typeMode": 2}')) # 试剂
unilab_resources = resource_bioyond_to_plr(
all_data,
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
deck=self.workstation.deck
)
# 更新 deck 上的资源
```
---
## 4. 非瓶类资源
### ElectrodeSheet极片
路径:`unilabos/resources/battery/electrode_sheet.py`
```python
class ElectrodeSheet(ResourcePLR):
"""片状材料(极片、隔膜、弹片、垫片等)"""
_unilabos_state = {
"diameter": 0.0,
"thickness": 0.0,
"mass": 0.0,
"material_type": "",
"color": "",
"info": "",
}
```
工厂函数:`PositiveCan`, `PositiveElectrode`, `NegativeCan`, `NegativeElectrode`, `SpringWasher`, `FlatWasher`, `AluminumFoil`
### Battery电池
```python
class Battery(Container):
"""组装好的电池"""
_unilabos_state = {
"color": "",
"electrolyte_name": "",
"open_circuit_voltage": 0.0,
}
```
### Magazine / MagazineHolder子弹夹
```python
class Magazine(ResourceStack):
"""子弹夹洞位,可堆叠 ElectrodeSheet"""
# direction, max_sheets
class MagazineHolder(ItemizedResource):
"""多洞位子弹夹"""
# hole_diameter, hole_depth, max_sheets_per_hole
```
工厂函数 `magazine_factory()``create_homogeneous_resources` 生成洞位,可选预填 `ElectrodeSheet``Battery`
---
## 5. 仓库工厂模式参考
### 实际 warehouse 工厂函数示例
```python
# 行优先 4x4 仓库
def bioyond_warehouse_1x4x4(name: str) -> WareHouse:
return warehouse_factory(
name=name,
num_items_x=4, num_items_y=4, num_items_z=1,
dx=10.0, dy=10.0, dz=10.0,
item_dx=147.0, item_dy=106.0, item_dz=130.0,
layout="row-major", # A01,A02,A03,A04, B01,...
)
# 右侧 4x4 仓库(列名偏移)
def bioyond_warehouse_1x4x4_right(name: str) -> WareHouse:
return warehouse_factory(
name=name,
num_items_x=4, num_items_y=4, num_items_z=1,
dx=10.0, dy=10.0, dz=10.0,
item_dx=147.0, item_dy=106.0, item_dz=130.0,
col_offset=4, # A05,A06,A07,A08
layout="row-major",
)
# 竖向仓库(站内试剂存放)
def bioyond_warehouse_reagent_storage(name: str) -> WareHouse:
return warehouse_factory(
name=name,
num_items_x=1, num_items_y=2, num_items_z=1,
dx=10.0, dy=10.0, dz=10.0,
item_dx=147.0, item_dy=106.0, item_dz=130.0,
layout="vertical-col-major",
)
# 行偏移F 行开始)
def bioyond_warehouse_5x3x1(name: str, row_offset: int = 0) -> WareHouse:
return warehouse_factory(
name=name,
num_items_x=3, num_items_y=5, num_items_z=1,
dx=10.0, dy=10.0, dz=10.0,
item_dx=159.0, item_dy=183.0, item_dz=130.0,
row_offset=row_offset, # 0→A行起5→F行起
layout="row-major",
)
```
### layout 类型说明
| layout | 命名顺序 | 适用场景 |
|--------|---------|---------|
| `col-major` (默认) | A01,B01,C01,D01, A02,B02,... | 列优先,标准堆栈 |
| `row-major` | A01,A02,A03,A04, B01,B02,... | 行优先Bioyond 前端展示 |
| `vertical-col-major` | 竖向排列,标签从底部开始 | 竖向仓库(试剂存放、测密度) |
---
## 6. 关键路径
| 内容 | 路径 |
|------|------|
| Bottle/Carrier 基类 | `unilabos/resources/itemized_carrier.py` |
| WareHouse 类 + 工厂 | `unilabos/resources/warehouse.py` |
| ResourceTreeSet 转换 | `unilabos/resources/resource_tracker.py` |
| Bioyond 物料转换 | `unilabos/resources/graphio.py` |
| Bioyond 仓库定义 | `unilabos/resources/bioyond/warehouses.py` |
| 电池资源 | `unilabos/resources/battery/` |
| PLR 注册 | `unilabos/resources/plr_additional_res_reg.py` |

View File

@@ -0,0 +1,328 @@
---
name: create-device-skill
description: Create a skill for any Uni-Lab device by extracting action schemas from the device registry. Use when the user wants to create a new device skill, add device API documentation, or set up action schemas for a device.
---
# 创建设备 Skill 指南
本 meta-skill 教你如何为任意 Uni-Lab-OS 设备创建完整的 API 操作技能(参考 `unilab-device-api` 的成功案例)。
## 数据源
- **设备注册表**: `unilabos_data/req_device_registry_upload.json`
- **结构**: `{ "resources": [{ "id": "<device_id>", "class": { "module": "<python_module:ClassName>", "action_value_mappings": { ... } } }] }`
- **生成时机**: `unilab` 启动并完成注册表上传后自动生成
- **module 字段**: 格式 `unilabos.devices.xxx.yyy:ClassName`,可转为源码路径 `unilabos/devices/xxx/yyy.py`,阅读源码可了解参数含义和设备行为
## 创建流程
### Step 0 — 收集必备信息(缺一不可,否则询问后终止)
开始前**必须**确认以下 4 项信息全部就绪。如果用户未提供任何一项,**立即询问并终止当前流程**,等用户补齐后再继续。
向用户提问:「请提供你的 unilab 启动参数,我需要以下信息:」
#### 必备项 ①ak / sk认证凭据
来源:启动命令的 `--ak` `--sk` 参数,或 config.py 中的 `ak = "..."` `sk = "..."`
获取后立即生成 AUTH token
```bash
python ./scripts/gen_auth.py <ak> <sk>
# 或从 config.py 提取
python ./scripts/gen_auth.py --config <config.py>
```
认证算法:`base64(ak:sk)``Authorization: Lab <token>`
#### 必备项 ②:--addr目标环境
决定 API 请求发往哪个服务器。从启动命令的 `--addr` 参数获取:
| `--addr` 值 | BASE URL |
|-------------|----------|
| `test` | `https://uni-lab.test.bohrium.com` |
| `uat` | `https://uni-lab.uat.bohrium.com` |
| `local` | `http://127.0.0.1:48197` |
| 不传(默认) | `https://uni-lab.bohrium.com` |
| 其他自定义 URL | 直接使用该 URL |
#### 必备项 ③req_device_registry_upload.json设备注册表
数据文件由 `unilab` 启动时自动生成,需要定位它:
**推断 working_dir**(即 `unilabos_data` 所在目录):
| 条件 | working_dir 取值 |
|------|------------------|
| 传了 `--working_dir` | `<working_dir>/unilabos_data/`(若子目录已存在则直接用) |
| 仅传了 `--config` | `<config 文件所在目录>/unilabos_data/` |
| 都没传 | `<当前工作目录>/unilabos_data/` |
**按优先级搜索文件**
```
<推断的 working_dir>/unilabos_data/req_device_registry_upload.json
<推断的 working_dir>/req_device_registry_upload.json
<workspace 根目录>/unilabos_data/req_device_registry_upload.json
```
也可以直接 Glob 搜索:`**/req_device_registry_upload.json`
找到后**必须检查文件修改时间**并告知用户:「找到注册表文件 `<路径>`,生成于 `<时间>`。请确认这是最近一次启动生成的。」超过 1 天提醒用户是否需要重新启动 `unilab`
**如果文件不存在** → 告知用户先运行 `unilab` 启动命令,等日志出现 `注册表响应数据已保存` 后再执行本流程。**终止。**
#### 必备项 ④:目标设备
用户需要明确要为哪个设备创建 skill。可以是设备名称如「PRCXI 移液站」)或 device_id`liquid_handler.prcxi`)。
如果用户不确定,运行提取脚本列出所有设备供选择:
```bash
python ./scripts/extract_device_actions.py --registry <找到的文件路径>
```
#### 完整示例
用户提供:
```
--ak a1fd9d4e-xxxx-xxxx-xxxx-d9a69c09f0fd
--sk 136ff5c6-xxxx-xxxx-xxxx-a03e301f827b
--addr test
--port 8003
--disable_browser
```
从中提取:
- ✅ ak/sk → 运行 `gen_auth.py` 得到 `AUTH="Authorization: Lab YTFmZDlk..."`
- ✅ addr=test → `BASE=https://uni-lab.test.bohrium.com`
- ✅ 搜索 `unilabos_data/req_device_registry_upload.json` → 找到并确认时间
- ✅ 用户指明目标设备 → 如 `liquid_handler.prcxi`
**四项全部就绪后才进入 Step 1。**
### Step 1 — 列出可用设备
运行提取脚本,列出所有设备及 action 数量和 Python 源码路径,让用户选择:
```bash
# 自动搜索(默认在 unilabos_data/ 和当前目录查找)
python ./scripts/extract_device_actions.py
# 指定注册表文件路径
python ./scripts/extract_device_actions.py --registry <path/to/req_device_registry_upload.json>
```
脚本输出包含每个设备的 **Python 源码路径**(从 `class.module` 转换),可用于后续阅读源码理解参数含义。
### Step 2 — 提取 Action Schema
用户选择设备后,运行提取脚本:
```bash
python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./skills/<skill-name>/actions/
```
脚本会显示设备的 Python 源码路径和类名,方便阅读源码了解参数含义。
每个 action 生成一个 JSON 文件,包含:
- `type` — 作为 API 调用的 `action_type`
- `schema` — 完整 JSON Schema`properties.goal.properties` 参数定义)
- `goal` — goal 字段映射(含占位符 `$placeholder`
- `goal_default` — 默认值
### Step 3 — 写 action-index.md
按模板为每个 action 写条目:
```markdown
### `<action_name>`
<用途描述(一句话)>
- **Schema**: [`actions/<filename>.json`](actions/<filename>.json)
- **核心参数**: `param1`, `param2`(从 schema.required 获取)
- **可选参数**: `param3`, `param4`
- **占位符字段**: `field`(需填入物料信息,值以 `$` 开头)
```
描述规则:
-`schema.properties` 读参数列表schema 已提升为 goal 内容)
-`schema.required` 区分核心/可选参数
- 按功能分类(移液、枪头、外设等)
- 标注 `placeholder_keys` 中的字段类型:
- `unilabos_resources`**ResourceSlot**,填入 `{id, name, uuid}`id 是路径格式,从资源树取物料节点)
- `unilabos_devices`**DeviceSlot**,填入路径字符串如 `"/host_node"`(从资源树筛选 type=device
- `unilabos_nodes`**NodeSlot**,填入路径字符串如 `"/PRCXI/PRCXI_Deck"`(资源树中任意节点)
- `unilabos_class`**ClassSlot**,填入类名字符串如 `"container"`(从注册表查找)
- array 类型字段 → `[{id, name, uuid}, ...]`
- 特殊:`create_resource``res_id`ResourceSlot可填不存在的路径
### Step 4 — 写 SKILL.md
直接复用 `unilab-device-api` 的 API 模板10 个 endpoint修改
- 设备名称
- Action 数量
- 目录列表
- Session state 中的 `device_name`
- **AUTH 头** — 使用 Step 0 中 `gen_auth.py` 生成的 `Authorization: Lab <token>`(不要硬编码 `Api` 类型的 key
- **Python 源码路径** — 在 SKILL.md 开头注明设备对应的源码文件,方便参考参数含义
- **Slot 字段表** — 列出本设备哪些 action 的哪些字段需要填入 Slot物料/设备/节点/类名)
API 模板结构:
```markdown
## 设备信息
- device_id, Python 源码路径, 设备类名
## 前置条件(缺一不可)
- ak/sk → AUTH, --addr → BASE URL
## Session State
- lab_uuid通过 API #1 自动匹配,不要问用户), device_name
## API Endpoints (10 个)
# 注意:
# - #1 获取 lab 列表 + 自动匹配 lab_uuid遍历 is_admin 的 lab
# 调用 /lab/info/{uuid} 比对 access_key == ak
# - #2 创建工作流用 POST /lab/workflow
# - #10 获取资源树路径含 lab_uuid: /lab/material/download/{lab_uuid}
## Placeholder Slot 填写规则
- unilabos_resources → ResourceSlot → {"id":"/path/name","name":"name","uuid":"xxx"}
- unilabos_devices → DeviceSlot → "/parent/device" 路径字符串
- unilabos_nodes → NodeSlot → "/parent/node" 路径字符串
- unilabos_class → ClassSlot → "class_name" 字符串
- 特例create_resource 的 res_id 允许填不存在的路径
- 列出本设备所有 Slot 字段、类型及含义
## 渐进加载策略
## 完整工作流 Checklist
```
### Step 5 — 验证
检查文件完整性:
- [ ] `SKILL.md` 包含 10 个 API endpoint
- [ ] `SKILL.md` 包含 Placeholder Slot 填写规则ResourceSlot / DeviceSlot / NodeSlot / ClassSlot + create_resource 特例)和本设备的 Slot 字段表
- [ ] `action-index.md` 列出所有 action 并有描述
- [ ] `actions/` 目录中每个 action 有对应 JSON 文件
- [ ] JSON 文件包含 `type`, `schema`(已提升为 goal 内容), `goal`, `goal_default`, `placeholder_keys` 字段
- [ ] 描述能让 agent 判断该用哪个 action
## Action JSON 文件结构
```json
{
"type": "LiquidHandlerTransfer", // → API 的 action_type
"goal": { // goal 字段映射
"sources": "sources",
"targets": "targets",
"tip_racks": "tip_racks",
"asp_vols": "asp_vols"
},
"schema": { // ← 直接是 goal 的 schema已提升
"type": "object",
"properties": { // 参数定义(即请求中 goal 的字段)
"sources": { "type": "array", "items": { "type": "object" } },
"targets": { "type": "array", "items": { "type": "object" } },
"asp_vols": { "type": "array", "items": { "type": "number" } }
},
"required": [...],
"_unilabos_placeholder_info": { // ← Slot 类型标记
"sources": "unilabos_resources",
"targets": "unilabos_resources",
"tip_racks": "unilabos_resources"
}
},
"goal_default": { ... }, // 默认值
"placeholder_keys": { // ← 汇总所有 Slot 字段
"sources": "unilabos_resources", // ResourceSlot
"targets": "unilabos_resources",
"tip_racks": "unilabos_resources",
"target_device_id": "unilabos_devices" // DeviceSlot
}
}
```
> **注意**`schema` 已由脚本从原始 `schema.properties.goal` 提升为顶层,直接包含参数定义。
> `schema.properties` 中的字段即为 API 请求 `param.goal` 中的字段。
## Placeholder Slot 类型体系
`placeholder_keys` / `_unilabos_placeholder_info` 中有 4 种值,对应不同的填写方式:
| 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 |
### ResourceSlot`unilabos_resources`
最常见的类型。从资源树中选取**物料**节点(孔板、枪头盒、试剂槽等):
```json
{"id": "/workstation/container1", "name": "container1", "uuid": "ff149a9a-2cb8-419d-8db5-d3ba056fb3c2"}
```
- 单个schema type=object`{"id": "/path/name", "name": "name", "uuid": "xxx"}`
- 数组schema type=array`[{"id": "/path/a", "name": "a", "uuid": "xxx"}, ...]`
- `id` 本身是从 parent 计算的路径格式
- 根据 action 语义选择正确的物料(如 `sources` = 液体来源,`targets` = 目标位置)
> **特例**`create_resource` 的 `res_id` 字段,目标物料可能**尚不存在**,此时直接填写期望的路径(如 `"/workstation/container1"`),不需要 uuid。
### DeviceSlot`unilabos_devices`
填写**设备路径字符串**。从资源树中筛选 type=device 的节点,从 parent 计算路径:
```
"/host_node"
"/bioyond_cell/reaction_station"
```
- 只填路径字符串,不需要 `{id, uuid}` 对象
- 根据 action 语义选择正确的设备(如 `target_device_id` = 目标设备)
### NodeSlot`unilabos_nodes`
范围 = 设备 + 物料。即资源树中**所有节点**都可以选,填写**路径字符串**
```
"/PRCXI/PRCXI_Deck"
```
- 使用场景:当参数既可能指向物料也可能指向设备时(如 `PumpTransferProtocol``from_vessel`/`to_vessel``create_resource``parent`
### ClassSlot`unilabos_class`
填写注册表中已上报的**资源类 name**。从本地 `req_resource_registry_upload.json` 中查找:
```
"container"
```
### 通过 API #10 获取资源树
```bash
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
```
注意 `lab_uuid` 在路径中(不是查询参数)。资源树返回所有节点,每个节点包含 `id`(路径格式)、`name``uuid``type``parent` 等字段。填写 Slot 时需根据 placeholder 类型筛选正确的节点。
## 最终目录结构
```
./<skill-name>/
├── SKILL.md # API 端点 + 渐进加载指引
├── action-index.md # 动作索引:描述/用途/核心参数
└── actions/ # 每个 action 的完整 JSON Schema
├── action1.json
├── action2.json
└── ...
```

View File

@@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""
从 req_device_registry_upload.json 中提取指定设备的 action schema。
用法:
# 列出所有设备及 action 数量(自动搜索注册表文件)
python extract_device_actions.py
# 指定注册表文件路径
python extract_device_actions.py --registry <path/to/req_device_registry_upload.json>
# 提取指定设备的 action 到目录
python extract_device_actions.py <device_id> <output_dir>
python extract_device_actions.py --registry <path> <device_id> <output_dir>
示例:
python extract_device_actions.py --registry unilabos_data/req_device_registry_upload.json
python extract_device_actions.py liquid_handler.prcxi .cursor/skills/unilab-device-api/actions/
"""
import json
import os
import sys
from datetime import datetime
REGISTRY_FILENAME = "req_device_registry_upload.json"
def find_registry(explicit_path=None):
"""
查找 req_device_registry_upload.json 文件。
搜索优先级:
1. 用户通过 --registry 显式指定的路径
2. <cwd>/unilabos_data/req_device_registry_upload.json
3. <cwd>/req_device_registry_upload.json
4. <script所在目录>/../../.. (workspace根) 下的 unilabos_data/
5. 向上逐级搜索父目录(最多 5 层)
"""
if explicit_path:
if os.path.isfile(explicit_path):
return explicit_path
if os.path.isdir(explicit_path):
fp = os.path.join(explicit_path, REGISTRY_FILENAME)
if os.path.isfile(fp):
return fp
print(f"警告: 指定的路径不存在: {explicit_path}")
return None
candidates = [
os.path.join("unilabos_data", REGISTRY_FILENAME),
REGISTRY_FILENAME,
]
for c in candidates:
if os.path.isfile(c):
return c
script_dir = os.path.dirname(os.path.abspath(__file__))
workspace_root = os.path.normpath(os.path.join(script_dir, "..", "..", ".."))
for c in candidates:
path = os.path.join(workspace_root, c)
if os.path.isfile(path):
return path
cwd = os.getcwd()
for _ in range(5):
parent = os.path.dirname(cwd)
if parent == cwd:
break
cwd = parent
for c in candidates:
path = os.path.join(cwd, c)
if os.path.isfile(path):
return path
return None
def load_registry(path):
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
def list_devices(data):
"""列出所有包含 action_value_mappings 的设备,同时返回 module 路径"""
resources = data.get('resources', [])
devices = []
for res in resources:
rid = res.get('id', '')
cls = res.get('class', {})
avm = cls.get('action_value_mappings', {})
module = cls.get('module', '')
if avm:
devices.append((rid, len(avm), module))
return devices
def flatten_schema_to_goal(action_data):
"""将 schema 中嵌套的 goal 内容提升为顶层 schema去掉 feedback/result 包装"""
schema = action_data.get('schema', {})
goal_schema = schema.get('properties', {}).get('goal', {})
if goal_schema:
action_data = dict(action_data)
action_data['schema'] = goal_schema
return action_data
def extract_actions(data, device_id, output_dir):
"""提取指定设备的 action schema 到独立 JSON 文件"""
resources = data.get('resources', [])
for res in resources:
if res.get('id') == device_id:
cls = res.get('class', {})
module = cls.get('module', '')
avm = cls.get('action_value_mappings', {})
if not avm:
print(f"设备 {device_id} 没有 action_value_mappings")
return []
if module:
py_path = module.split(":")[0].replace(".", "/") + ".py"
class_name = module.split(":")[-1] if ":" in module else ""
print(f"Python 源码: {py_path}")
if class_name:
print(f"设备类: {class_name}")
os.makedirs(output_dir, exist_ok=True)
written = []
for action_name in sorted(avm.keys()):
action_data = flatten_schema_to_goal(avm[action_name])
filename = action_name.replace('-', '_') + '.json'
filepath = os.path.join(output_dir, filename)
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(action_data, f, indent=2, ensure_ascii=False)
written.append(filename)
print(f" {filepath}")
return written
print(f"设备 {device_id} 未找到")
return []
def main():
args = sys.argv[1:]
explicit_registry = None
if "--registry" in args:
idx = args.index("--registry")
if idx + 1 < len(args):
explicit_registry = args[idx + 1]
args = args[:idx] + args[idx + 2:]
else:
print("错误: --registry 需要指定路径")
sys.exit(1)
registry_path = find_registry(explicit_registry)
if not registry_path:
print(f"错误: 找不到 {REGISTRY_FILENAME}")
print()
print("解决方法:")
print(" 1. 先运行 unilab 启动命令,等待注册表生成")
print(" 2. 用 --registry 指定文件路径:")
print(f" python {sys.argv[0]} --registry <path/to/{REGISTRY_FILENAME}>")
print()
print("搜索过的路径:")
for p in [
os.path.join("unilabos_data", REGISTRY_FILENAME),
REGISTRY_FILENAME,
os.path.join("<workspace_root>", "unilabos_data", REGISTRY_FILENAME),
]:
print(f" - {p}")
sys.exit(1)
print(f"注册表: {registry_path}")
mtime = os.path.getmtime(registry_path)
gen_time = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
size_mb = os.path.getsize(registry_path) / (1024 * 1024)
print(f"生成时间: {gen_time} (文件大小: {size_mb:.1f} MB)")
data = load_registry(registry_path)
if len(args) == 0:
devices = list_devices(data)
print(f"\n找到 {len(devices)} 个设备:")
print(f"{'设备 ID':<50} {'Actions':>7} {'Python 模块'}")
print("-" * 120)
for did, count, module in sorted(devices, key=lambda x: x[0]):
py_path = module.split(":")[0].replace(".", "/") + ".py" if module else ""
print(f"{did:<50} {count:>7} {py_path}")
elif len(args) == 2:
device_id = args[0]
output_dir = args[1]
print(f"\n提取 {device_id} 的 actions 到 {output_dir}/")
written = extract_actions(data, device_id, output_dir)
if written:
print(f"\n共写入 {len(written)} 个 action 文件")
else:
print("用法:")
print(" python extract_device_actions.py [--registry <path>] # 列出设备")
print(" python extract_device_actions.py [--registry <path>] <device_id> <dir> # 提取 actions")
sys.exit(1)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""
从 ak/sk 生成 UniLab API Authorization header。
算法: base64(ak:sk) → "Authorization: Lab <token>"
用法:
python gen_auth.py <ak> <sk>
python gen_auth.py --config <config.py>
示例:
python gen_auth.py myak mysk
python gen_auth.py --config experiments/config.py
"""
import base64
import re
import sys
def gen_auth(ak: str, sk: str) -> str:
token = base64.b64encode(f"{ak}:{sk}".encode("utf-8")).decode("utf-8")
return token
def extract_from_config(config_path: str) -> tuple:
"""从 config.py 中提取 ak 和 sk"""
with open(config_path, "r", encoding="utf-8") as f:
content = f.read()
ak_match = re.search(r'''ak\s*=\s*["']([^"']+)["']''', content)
sk_match = re.search(r'''sk\s*=\s*["']([^"']+)["']''', content)
if not ak_match or not sk_match:
return None, None
return ak_match.group(1), sk_match.group(1)
def main():
args = sys.argv[1:]
if len(args) == 2 and args[0] == "--config":
ak, sk = extract_from_config(args[1])
if not ak or not sk:
print(f"错误: 在 {args[1]} 中未找到 ak/sk 配置")
print("期望格式: ak = \"xxx\" sk = \"xxx\"")
sys.exit(1)
print(f"配置文件: {args[1]}")
elif len(args) == 2:
ak, sk = args
else:
print("用法:")
print(" python gen_auth.py <ak> <sk>")
print(" python gen_auth.py --config <config.py>")
sys.exit(1)
token = gen_auth(ak, sk)
print(f"ak: {ak}")
print(f"sk: {sk}")
print()
print(f"Authorization header:")
print(f" Authorization: Lab {token}")
print()
print(f"curl 用法:")
print(f' curl -H "Authorization: Lab {token}" ...')
print()
print(f"Shell 变量:")
print(f' AUTH="Authorization: Lab {token}"')
if __name__ == "__main__":
main()

View File

@@ -1,26 +0,0 @@
.conda
# .github
.idea
# .vscode
output
pylabrobot_repo
recipes
scripts
service
temp
# unilabos/test
# unilabos/app/web
unilabos/device_mesh
unilabos_data
unilabos_msgs
unilabos.egg-info
CONTRIBUTORS
# LICENSE
MANIFEST.in
pyrightconfig.json
# README.md
# README_zh.md
setup.py
setup.cfg
.gitattrubutes
**/__pycache__

19
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,19 @@
version: 2
updates:
# GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
target-branch: "dev"
schedule:
interval: "weekly"
day: "monday"
time: "06:00"
open-pull-requests-limit: 5
reviewers:
- "msgcenterpy-team"
labels:
- "dependencies"
- "github-actions"
commit-message:
prefix: "ci"
include: "scope"

6
.gitignore vendored
View File

@@ -252,9 +252,3 @@ ros-humble-unilabos-msgs-0.9.13-h6403a04_5.tar.bz2
test_config.py
/.claude
/.conda
/.cursor
/.github
/.conda/base
.conda/base/recipe.yaml

172
tests/app/__init__.py Normal file
View File

@@ -0,0 +1,172 @@
"""normalize_model_for_upload 单元测试"""
import unittest
import sys
import os
# 添加项目根目录到 sys.path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from unilabos.app.register import normalize_model_for_upload
class TestNormalizeModelForUpload(unittest.TestCase):
"""测试 Registry YAML model 字段标准化"""
def test_empty_input(self):
"""空 dict 直接返回"""
self.assertEqual(normalize_model_for_upload({}), {})
self.assertIsNone(normalize_model_for_upload(None))
def test_format_infer_xacro(self):
"""自动从 path 后缀推断 format=xacro"""
model = {
"path": "https://oss.example.com/devices/arm/macro_device.xacro",
"mesh": "arm_slider",
"type": "device",
}
result = normalize_model_for_upload(model)
self.assertEqual(result["format"], "xacro")
def test_format_infer_urdf(self):
"""自动推断 format=urdf"""
model = {"path": "https://example.com/robot.urdf", "type": "device"}
result = normalize_model_for_upload(model)
self.assertEqual(result["format"], "urdf")
def test_format_infer_stl(self):
"""自动推断 format=stl"""
model = {"path": "https://example.com/part.stl"}
result = normalize_model_for_upload(model)
self.assertEqual(result["format"], "stl")
def test_format_infer_gltf(self):
"""自动推断 format=gltf.gltf 和 .glb"""
for ext in [".gltf", ".glb"]:
model = {"path": f"https://example.com/model{ext}"}
result = normalize_model_for_upload(model)
self.assertEqual(result["format"], "gltf", f"failed for {ext}")
def test_format_not_overwritten(self):
"""已有 format 字段时不覆盖"""
model = {
"path": "https://example.com/model.xacro",
"format": "custom",
}
result = normalize_model_for_upload(model)
self.assertEqual(result["format"], "custom")
def test_format_no_path(self):
"""没有 path 时不推断 format"""
model = {"mesh": "arm_slider", "type": "device"}
result = normalize_model_for_upload(model)
self.assertNotIn("format", result)
def test_children_mesh_string_to_struct(self):
"""将 children_mesh 字符串(旧格式)转为结构化对象"""
model = {
"path": "https://example.com/rack.xacro",
"type": "resource",
"children_mesh": "tip/meshes/tip.stl",
"children_mesh_tf": [0.0045, 0.0045, 0, 0, 0, 1.57],
"children_mesh_path": "https://oss.example.com/tip.stl",
}
result = normalize_model_for_upload(model)
# children_mesh 应变为 dict
cm = result["children_mesh"]
self.assertIsInstance(cm, dict)
self.assertEqual(cm["path"], "https://oss.example.com/tip.stl") # 优先使用 OSS URL
self.assertEqual(cm["format"], "stl")
self.assertTrue(cm["default_visible"])
self.assertEqual(cm["local_offset"], [0.0045, 0.0045, 0])
self.assertEqual(cm["local_rotation"], [0, 0, 1.57])
# 旧字段应被移除
self.assertNotIn("children_mesh_tf", result)
self.assertNotIn("children_mesh_path", result)
def test_children_mesh_no_oss_fallback(self):
"""children_mesh 无 OSS URL 时 fallback 到本地路径"""
model = {
"path": "https://example.com/rack.xacro",
"children_mesh": "plate_96/meshes/plate_96.stl",
}
result = normalize_model_for_upload(model)
cm = result["children_mesh"]
self.assertEqual(cm["path"], "plate_96/meshes/plate_96.stl")
self.assertEqual(cm["format"], "stl")
def test_children_mesh_gltf_format(self):
"""children_mesh .glb 文件推断 format=gltf"""
model = {
"path": "https://example.com/rack.xacro",
"children_mesh": "meshes/child.glb",
}
result = normalize_model_for_upload(model)
self.assertEqual(result["children_mesh"]["format"], "gltf")
def test_children_mesh_partial_tf(self):
"""children_mesh_tf 只有 3 个值时只有 offset 无 rotation"""
model = {
"path": "https://example.com/rack.xacro",
"children_mesh": "tip.stl",
"children_mesh_tf": [0.01, 0.02, 0.03],
}
result = normalize_model_for_upload(model)
cm = result["children_mesh"]
self.assertEqual(cm["local_offset"], [0.01, 0.02, 0.03])
self.assertNotIn("local_rotation", cm)
def test_children_mesh_no_tf(self):
"""children_mesh 无 tf 时不加 offset/rotation"""
model = {
"path": "https://example.com/rack.xacro",
"children_mesh": "tip.stl",
}
result = normalize_model_for_upload(model)
cm = result["children_mesh"]
self.assertNotIn("local_offset", cm)
self.assertNotIn("local_rotation", cm)
def test_children_mesh_already_dict(self):
"""children_mesh 已经是 dict 时不重新映射"""
model = {
"path": "https://example.com/rack.xacro",
"children_mesh": {
"path": "https://example.com/tip.stl",
"format": "stl",
"default_visible": False,
},
}
result = normalize_model_for_upload(model)
cm = result["children_mesh"]
self.assertIsInstance(cm, dict)
self.assertFalse(cm["default_visible"])
def test_original_not_mutated(self):
"""原始 dict 不被修改"""
original = {
"path": "https://example.com/model.xacro",
"mesh": "arm",
}
original_copy = {**original}
normalize_model_for_upload(original)
self.assertEqual(original, original_copy)
def test_preserves_existing_fields(self):
"""所有原始字段都被保留"""
model = {
"path": "https://example.com/model.xacro",
"mesh": "arm_slider",
"type": "device",
"mesh_tf": [0, 0, 0, 0, 0, 0],
"custom_field": "should_survive",
}
result = normalize_model_for_upload(model)
self.assertEqual(result["custom_field"], "should_survive")
self.assertEqual(result["mesh_tf"], [0, 0, 0, 0, 0, 0])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,496 @@
"""model_upload.py 单元测试upload_device_model / download_model_from_oss / XOR 加解密)"""
import unittest
import tempfile
import os
import sys
from pathlib import Path
from unittest.mock import patch, MagicMock
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from unilabos.app.model_upload import (
upload_device_model,
download_model_from_oss,
_MODEL_EXTENSIONS,
_MESH_ENCRYPT_EXTENSIONS,
_xor_transform,
)
class TestUploadDeviceModel(unittest.TestCase):
"""测试本地模型文件上传到 OSS"""
def setUp(self):
self.tmp_dir = tempfile.mkdtemp()
self.mock_client = MagicMock()
def _create_model_files(self, subdir: str, filenames: list[str]):
"""在临时目录中创建设备模型文件"""
model_dir = Path(self.tmp_dir) / "devices" / subdir
model_dir.mkdir(parents=True, exist_ok=True)
for name in filenames:
p = model_dir / name
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text("dummy content")
return model_dir
@patch("unilabos.app.model_upload._MESH_BASE_DIR")
def test_upload_success(self, mock_base):
"""正常上传流程"""
mock_base.__truediv__ = lambda self, x: Path(self.tmp_dir) / x
# 直接 patch _MESH_BASE_DIR 为 Path(tmp_dir)
with patch("unilabos.app.model_upload._MESH_BASE_DIR", Path(self.tmp_dir)):
self._create_model_files("arm_slider", ["macro_device.xacro", "meshes/link1.stl"])
self.mock_client.get_model_upload_urls.return_value = {
"files": [
{"name": "macro_device.xacro", "upload_url": "https://oss.example.com/put1"},
{"name": "meshes/link1.stl", "upload_url": "https://oss.example.com/put2"},
]
}
self.mock_client.publish_model.return_value = {
"path": "https://oss.example.com/arm_slider/macro_device.xacro"
}
with patch("unilabos.app.model_upload._put_upload") as mock_put:
result = upload_device_model(
http_client=self.mock_client,
template_uuid="test-uuid",
mesh_name="arm_slider",
model_type="device",
version="1.0.0",
)
self.assertEqual(result, "https://oss.example.com/arm_slider/macro_device.xacro")
self.mock_client.get_model_upload_urls.assert_called_once()
self.mock_client.publish_model.assert_called_once()
@patch("unilabos.app.model_upload._MESH_BASE_DIR")
def test_upload_dir_not_exists(self, mock_base):
"""本地目录不存在时返回 None"""
with patch("unilabos.app.model_upload._MESH_BASE_DIR", Path(self.tmp_dir)):
result = upload_device_model(
http_client=self.mock_client,
template_uuid="test-uuid",
mesh_name="nonexistent",
model_type="device",
)
self.assertIsNone(result)
@patch("unilabos.app.model_upload._MESH_BASE_DIR")
def test_upload_no_valid_files(self, mock_base):
"""目录中无有效模型文件时返回 None"""
with patch("unilabos.app.model_upload._MESH_BASE_DIR", Path(self.tmp_dir)):
model_dir = Path(self.tmp_dir) / "devices" / "empty_model"
model_dir.mkdir(parents=True, exist_ok=True)
(model_dir / "readme.txt").write_text("not a model")
result = upload_device_model(
http_client=self.mock_client,
template_uuid="test-uuid",
mesh_name="empty_model",
model_type="device",
)
self.assertIsNone(result)
@patch("unilabos.app.model_upload._MESH_BASE_DIR")
def test_upload_urls_failure(self, mock_base):
"""获取上传 URL 失败时返回 None"""
with patch("unilabos.app.model_upload._MESH_BASE_DIR", Path(self.tmp_dir)):
self._create_model_files("arm", ["device.xacro"])
self.mock_client.get_model_upload_urls.return_value = None
result = upload_device_model(
http_client=self.mock_client,
template_uuid="test-uuid",
mesh_name="arm",
model_type="device",
)
self.assertIsNone(result)
class TestDownloadModelFromOss(unittest.TestCase):
"""测试从 OSS 下载模型文件到本地"""
def setUp(self):
self.tmp_dir = tempfile.mkdtemp()
def test_skip_no_mesh_name(self):
"""缺少 mesh 名称时跳过"""
result = download_model_from_oss({"type": "device", "path": "https://x.com/a.xacro"})
self.assertFalse(result)
def test_skip_no_oss_path(self):
"""缺少 OSS path 时跳过"""
result = download_model_from_oss({"mesh": "arm", "type": "device"})
self.assertFalse(result)
def test_skip_local_path(self):
"""非 https:// 路径时跳过"""
result = download_model_from_oss({
"mesh": "arm",
"type": "device",
"path": "file:///local/model.xacro",
})
self.assertFalse(result)
def test_already_exists(self):
"""本地已有文件时跳过下载"""
device_dir = Path(self.tmp_dir) / "devices" / "arm"
device_dir.mkdir(parents=True, exist_ok=True)
(device_dir / "model.xacro").write_text("existing")
result = download_model_from_oss(
{"mesh": "arm", "type": "device", "path": "https://oss.example.com/model.xacro"},
mesh_base_dir=Path(self.tmp_dir),
)
self.assertTrue(result)
@patch("unilabos.app.model_upload._download_file")
def test_download_device(self, mock_download):
"""下载 device 模型到 devices/ 目录"""
result = download_model_from_oss(
{"mesh": "new_arm", "type": "device", "path": "https://oss.example.com/new_arm/macro_device.xacro"},
mesh_base_dir=Path(self.tmp_dir),
)
self.assertTrue(result)
mock_download.assert_called_once()
call_args = mock_download.call_args
self.assertIn("macro_device.xacro", str(call_args[0][1]))
@patch("unilabos.app.model_upload._download_file")
def test_download_resource(self, mock_download):
"""下载 resource 模型到 resources/ 目录"""
result = download_model_from_oss(
{
"mesh": "plate_96/meshes/plate_96.stl",
"type": "resource",
"path": "https://oss.example.com/plate_96/modal.xacro",
},
mesh_base_dir=Path(self.tmp_dir),
)
self.assertTrue(result)
target_dir = Path(self.tmp_dir) / "resources" / "plate_96"
self.assertTrue(target_dir.exists())
@patch("unilabos.app.model_upload._download_file")
def test_download_with_children_mesh(self, mock_download):
"""下载包含 children_mesh 的模型"""
result = download_model_from_oss(
{
"mesh": "tip_rack",
"type": "device",
"path": "https://oss.example.com/tip_rack/model.xacro",
"children_mesh": {
"path": "https://oss.example.com/tip_rack/meshes/tip.stl",
"format": "stl",
},
},
mesh_base_dir=Path(self.tmp_dir),
)
self.assertTrue(result)
# 应调用两次:入口文件 + children_mesh
self.assertEqual(mock_download.call_count, 2)
@patch("unilabos.app.model_upload._download_file", side_effect=Exception("network error"))
def test_download_failure_graceful(self, mock_download):
"""下载失败时返回 False不抛异常"""
result = download_model_from_oss(
{"mesh": "broken", "type": "device", "path": "https://oss.example.com/broken.xacro"},
mesh_base_dir=Path(self.tmp_dir),
)
self.assertFalse(result)
class TestModelExtensions(unittest.TestCase):
"""测试支持的模型文件后缀集合"""
def test_standard_extensions(self):
"""确认标准 3D 格式在支持列表中"""
expected = {".stl", ".gltf", ".glb", ".xacro", ".urdf", ".obj", ".dae"}
for ext in expected:
self.assertIn(ext, _MODEL_EXTENSIONS, f"{ext} should be supported")
def test_non_model_excluded(self):
"""非模型文件后缀不在列表中"""
excluded = {".txt", ".json", ".py", ".png", ".jpg"}
for ext in excluded:
self.assertNotIn(ext, _MODEL_EXTENSIONS, f"{ext} should not be supported")
class TestXorTransform(unittest.TestCase):
"""XOR 加密/解密核心函数测试。"""
def test_roundtrip_symmetry(self):
"""XOR 加密后再解密恢复原始数据(对称性)。"""
original = b"Hello, this is a test model file content."
encrypted = _xor_transform(original)
self.assertNotEqual(encrypted, original)
decrypted = _xor_transform(encrypted)
self.assertEqual(decrypted, original)
def test_empty_data(self):
"""空数据加密后仍为空。"""
result = _xor_transform(b"")
self.assertEqual(result, b"")
def test_single_byte(self):
"""单字节数据正确加解密。"""
original = b"\xff"
encrypted = _xor_transform(original)
decrypted = _xor_transform(encrypted)
self.assertEqual(decrypted, original)
def test_data_longer_than_key(self):
"""超过密钥长度32 字节)的数据正确循环 XOR。"""
original = bytes(range(256)) * 2 # 512 字节
encrypted = _xor_transform(original)
self.assertNotEqual(encrypted, original)
decrypted = _xor_transform(encrypted)
self.assertEqual(decrypted, original)
def test_data_exactly_key_length(self):
"""恰好 32 字节(密钥长度)的数据正确处理。"""
original = bytes(range(32))
encrypted = _xor_transform(original)
decrypted = _xor_transform(encrypted)
self.assertEqual(decrypted, original)
def test_all_zeros_produces_key(self):
"""全零数据 XOR 后结果应为密钥本身。"""
zeros = b"\x00" * 32
result = _xor_transform(zeros)
key = os.environ.get(
"UNILAB_MESH_XOR_KEY", "unilab3d-model-protection-key-v1"
).encode()
self.assertEqual(result, key)
def test_custom_key(self):
"""自定义密钥正确加解密。"""
custom_key = b"custom-key-12345"
original = b"test data for custom key"
encrypted = _xor_transform(original, key=custom_key)
decrypted = _xor_transform(encrypted, key=custom_key)
self.assertEqual(decrypted, original)
def test_different_keys_produce_different_results(self):
"""不同密钥产生不同加密结果。"""
data = b"same data"
key1 = b"key-one-is-here!"
key2 = b"key-two-is-here!"
self.assertNotEqual(_xor_transform(data, key1), _xor_transform(data, key2))
def test_binary_stl_header(self):
"""二进制内容(模拟 STL 文件头)正确加解密。"""
stl_header = b"\x00" * 80 + b"\x03\x00\x00\x00"
encrypted = _xor_transform(stl_header)
decrypted = _xor_transform(encrypted)
self.assertEqual(decrypted, stl_header)
def test_large_data_roundtrip(self):
"""大数据1MB加解密正确性。"""
original = os.urandom(1024 * 1024)
encrypted = _xor_transform(original)
decrypted = _xor_transform(encrypted)
self.assertEqual(decrypted, original)
def test_consistency_with_frontend_key(self):
"""验证 Python 端与前端使用相同的默认密钥。"""
frontend_key = b"unilab3d-model-protection-key-v1"
data = b"cross-platform test data"
encrypted = _xor_transform(data, key=frontend_key)
# 用默认密钥解密(应一致)
decrypted = _xor_transform(encrypted)
self.assertEqual(decrypted, data)
class TestEncryptExtensions(unittest.TestCase):
"""加密文件扩展名配置测试。"""
def test_all_mesh_formats_in_encrypt_set(self):
"""所有 mesh 格式都在加密扩展名集合中。"""
expected = {".stl", ".dae", ".obj", ".fbx", ".gltf", ".glb"}
self.assertEqual(_MESH_ENCRYPT_EXTENSIONS, expected)
def test_xml_formats_not_encrypted(self):
"""XACRO/URDF/YAML 文件不加密。"""
for ext in {".xacro", ".urdf", ".yaml", ".yml"}:
self.assertNotIn(ext, _MESH_ENCRYPT_EXTENSIONS)
def test_encrypt_is_subset_of_model_extensions(self):
"""加密扩展名是模型扩展名的子集。"""
self.assertTrue(_MESH_ENCRYPT_EXTENSIONS.issubset(_MODEL_EXTENSIONS))
class TestPutUploadEncryption(unittest.TestCase):
"""_put_upload 中的条件加密测试。"""
@patch("unilabos.app.model_upload.requests.put")
def test_stl_file_encrypted_before_upload(self, mock_put):
"""STL 文件上传前自动 XOR 加密。"""
from unilabos.app.model_upload import _put_upload
original_data = b"solid test\nfacet normal 0 0 1\n"
with tempfile.NamedTemporaryFile(suffix=".stl", delete=False) as f:
f.write(original_data)
f.flush()
tmp_path = Path(f.name)
try:
mock_put.return_value = MagicMock(status_code=200)
mock_put.return_value.raise_for_status = MagicMock()
_put_upload(tmp_path, "https://oss.example.com/upload")
uploaded_data = mock_put.call_args.kwargs.get("data")
self.assertIsNotNone(uploaded_data)
self.assertNotEqual(uploaded_data, original_data)
# 解密后应恢复原始数据
self.assertEqual(_xor_transform(uploaded_data), original_data)
finally:
tmp_path.unlink(missing_ok=True)
@patch("unilabos.app.model_upload.requests.put")
def test_xacro_file_not_encrypted(self, mock_put):
"""XACRO 文件上传时不加密。"""
from unilabos.app.model_upload import _put_upload
original_data = b'<?xml version="1.0"?><robot></robot>'
with tempfile.NamedTemporaryFile(suffix=".xacro", delete=False) as f:
f.write(original_data)
f.flush()
tmp_path = Path(f.name)
try:
mock_put.return_value = MagicMock(status_code=200)
mock_put.return_value.raise_for_status = MagicMock()
_put_upload(tmp_path, "https://oss.example.com/upload")
uploaded_data = mock_put.call_args.kwargs.get("data")
self.assertEqual(uploaded_data, original_data)
finally:
tmp_path.unlink(missing_ok=True)
@patch("unilabos.app.model_upload.requests.put")
def test_all_mesh_formats_encrypted(self, mock_put):
"""所有 mesh 格式上传前都加密。"""
from unilabos.app.model_upload import _put_upload
original_data = b"test mesh binary data content"
for ext in [".stl", ".dae", ".obj", ".fbx", ".gltf", ".glb"]:
with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as f:
f.write(original_data)
f.flush()
tmp_path = Path(f.name)
try:
mock_put.reset_mock()
mock_put.return_value = MagicMock(status_code=200)
mock_put.return_value.raise_for_status = MagicMock()
_put_upload(tmp_path, "https://oss.example.com/upload")
uploaded_data = mock_put.call_args.kwargs.get("data")
self.assertNotEqual(uploaded_data, original_data, f"{ext} 文件应被加密")
finally:
tmp_path.unlink(missing_ok=True)
@patch("unilabos.app.model_upload.requests.put")
def test_uppercase_extension_encrypted(self, mock_put):
"""大写扩展名 .STL 也被加密(大小写不敏感)。"""
from unilabos.app.model_upload import _put_upload
original_data = b"uppercase ext test"
with tempfile.NamedTemporaryFile(suffix=".STL", delete=False) as f:
f.write(original_data)
f.flush()
tmp_path = Path(f.name)
try:
mock_put.return_value = MagicMock(status_code=200)
mock_put.return_value.raise_for_status = MagicMock()
_put_upload(tmp_path, "https://oss.example.com/upload")
uploaded_data = mock_put.call_args.kwargs.get("data")
self.assertNotEqual(uploaded_data, original_data)
finally:
tmp_path.unlink(missing_ok=True)
class TestDownloadFileDecryption(unittest.TestCase):
"""_download_file 中的条件解密测试。"""
@patch("unilabos.app.model_upload.requests.get")
def test_mesh_file_decrypted_on_download(self, mock_get):
"""下载的 mesh 文件自动 XOR 解密后存本地。"""
from unilabos.app.model_upload import _download_file
original_data = b"original stl content here"
encrypted_data = _xor_transform(original_data)
mock_response = MagicMock()
mock_response.content = encrypted_data
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
with tempfile.TemporaryDirectory() as tmpdir:
local_path = Path(tmpdir) / "model.stl"
_download_file("https://oss.example.com/model.stl", local_path)
self.assertEqual(local_path.read_bytes(), original_data)
@patch("unilabos.app.model_upload.requests.get")
def test_xacro_file_not_decrypted(self, mock_get):
"""下载的 XACRO 文件不做解密处理。"""
from unilabos.app.model_upload import _download_file
xml_data = b'<?xml version="1.0"?><robot></robot>'
mock_response = MagicMock()
mock_response.content = xml_data
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
with tempfile.TemporaryDirectory() as tmpdir:
local_path = Path(tmpdir) / "macro.xacro"
_download_file("https://oss.example.com/macro.xacro", local_path)
self.assertEqual(local_path.read_bytes(), xml_data)
@patch("unilabos.app.model_upload.requests.get")
def test_upload_download_roundtrip(self, mock_get):
"""上传加密 → 下载解密的完整 round-trip。"""
from unilabos.app.model_upload import _download_file
original_data = b"binary stl mesh \x00\xff\x80 special bytes"
encrypted_data = _xor_transform(original_data)
mock_response = MagicMock()
mock_response.content = encrypted_data
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
with tempfile.TemporaryDirectory() as tmpdir:
local_path = Path(tmpdir) / "mesh.stl"
_download_file("https://oss.example.com/mesh.stl", local_path)
self.assertEqual(local_path.read_bytes(), original_data)
@patch("unilabos.app.model_upload.requests.get")
def test_all_mesh_formats_decrypted(self, mock_get):
"""所有 mesh 格式下载后都解密。"""
from unilabos.app.model_upload import _download_file
original_data = b"mesh content for roundtrip"
encrypted_data = _xor_transform(original_data)
for ext in [".stl", ".dae", ".obj", ".fbx", ".gltf", ".glb"]:
mock_response = MagicMock()
mock_response.content = encrypted_data
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
with tempfile.TemporaryDirectory() as tmpdir:
local_path = Path(tmpdir) / f"model{ext}"
_download_file(f"https://oss.example.com/model{ext}", local_path)
self.assertEqual(
local_path.read_bytes(), original_data, f"{ext} 文件应被解密"
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,170 @@
"""normalize_model_for_upload 单元测试"""
import unittest
import sys
import os
# 添加项目根目录到 sys.path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from unilabos.app.register import normalize_model_for_upload
class TestNormalizeModelForUpload(unittest.TestCase):
"""测试 Registry YAML model 字段标准化"""
def test_empty_input(self):
"""空 dict 直接返回"""
self.assertEqual(normalize_model_for_upload({}), {})
self.assertIsNone(normalize_model_for_upload(None))
def test_format_infer_xacro(self):
"""自动从 path 后缀推断 format=xacro"""
model = {
"path": "https://oss.example.com/devices/arm/macro_device.xacro",
"mesh": "arm_slider",
"type": "device",
}
result = normalize_model_for_upload(model)
self.assertEqual(result["format"], "xacro")
def test_format_infer_urdf(self):
"""自动推断 format=urdf"""
model = {"path": "https://example.com/robot.urdf", "type": "device"}
result = normalize_model_for_upload(model)
self.assertEqual(result["format"], "urdf")
def test_format_infer_stl(self):
"""自动推断 format=stl"""
model = {"path": "https://example.com/part.stl"}
result = normalize_model_for_upload(model)
self.assertEqual(result["format"], "stl")
def test_format_infer_gltf(self):
"""自动推断 format=gltf.gltf 和 .glb"""
for ext in [".gltf", ".glb"]:
model = {"path": f"https://example.com/model{ext}"}
result = normalize_model_for_upload(model)
self.assertEqual(result["format"], "gltf", f"failed for {ext}")
def test_format_not_overwritten(self):
"""已有 format 字段时不覆盖"""
model = {
"path": "https://example.com/model.xacro",
"format": "custom",
}
result = normalize_model_for_upload(model)
self.assertEqual(result["format"], "custom")
def test_format_no_path(self):
"""没有 path 时不推断 format"""
model = {"mesh": "arm_slider", "type": "device"}
result = normalize_model_for_upload(model)
self.assertNotIn("format", result)
def test_children_mesh_string_to_struct(self):
"""将 children_mesh 字符串(旧格式)转为结构化对象"""
model = {
"path": "https://example.com/rack.xacro",
"type": "resource",
"children_mesh": "tip/meshes/tip.stl",
"children_mesh_tf": [0.0045, 0.0045, 0, 0, 0, 1.57],
"children_mesh_path": "https://oss.example.com/tip.stl",
}
result = normalize_model_for_upload(model)
cm = result["children_mesh"]
self.assertIsInstance(cm, dict)
self.assertEqual(cm["path"], "https://oss.example.com/tip.stl")
self.assertEqual(cm["format"], "stl")
self.assertTrue(cm["default_visible"])
self.assertEqual(cm["local_offset"], [0.0045, 0.0045, 0])
self.assertEqual(cm["local_rotation"], [0, 0, 1.57])
self.assertNotIn("children_mesh_tf", result)
self.assertNotIn("children_mesh_path", result)
def test_children_mesh_no_oss_fallback(self):
"""children_mesh 无 OSS URL 时 fallback 到本地路径"""
model = {
"path": "https://example.com/rack.xacro",
"children_mesh": "plate_96/meshes/plate_96.stl",
}
result = normalize_model_for_upload(model)
cm = result["children_mesh"]
self.assertEqual(cm["path"], "plate_96/meshes/plate_96.stl")
self.assertEqual(cm["format"], "stl")
def test_children_mesh_gltf_format(self):
"""children_mesh .glb 文件推断 format=gltf"""
model = {
"path": "https://example.com/rack.xacro",
"children_mesh": "meshes/child.glb",
}
result = normalize_model_for_upload(model)
self.assertEqual(result["children_mesh"]["format"], "gltf")
def test_children_mesh_partial_tf(self):
"""children_mesh_tf 只有 3 个值时只有 offset 无 rotation"""
model = {
"path": "https://example.com/rack.xacro",
"children_mesh": "tip.stl",
"children_mesh_tf": [0.01, 0.02, 0.03],
}
result = normalize_model_for_upload(model)
cm = result["children_mesh"]
self.assertEqual(cm["local_offset"], [0.01, 0.02, 0.03])
self.assertNotIn("local_rotation", cm)
def test_children_mesh_no_tf(self):
"""children_mesh 无 tf 时不加 offset/rotation"""
model = {
"path": "https://example.com/rack.xacro",
"children_mesh": "tip.stl",
}
result = normalize_model_for_upload(model)
cm = result["children_mesh"]
self.assertNotIn("local_offset", cm)
self.assertNotIn("local_rotation", cm)
def test_children_mesh_already_dict(self):
"""children_mesh 已经是 dict 时不重新映射"""
model = {
"path": "https://example.com/rack.xacro",
"children_mesh": {
"path": "https://example.com/tip.stl",
"format": "stl",
"default_visible": False,
},
}
result = normalize_model_for_upload(model)
cm = result["children_mesh"]
self.assertIsInstance(cm, dict)
self.assertFalse(cm["default_visible"])
def test_original_not_mutated(self):
"""原始 dict 不被修改"""
original = {
"path": "https://example.com/model.xacro",
"mesh": "arm",
}
original_copy = {**original}
normalize_model_for_upload(original)
self.assertEqual(original, original_copy)
def test_preserves_existing_fields(self):
"""所有原始字段都被保留"""
model = {
"path": "https://example.com/model.xacro",
"mesh": "arm_slider",
"type": "device",
"mesh_tf": [0, 0, 0, 0, 0, 0],
"custom_field": "should_survive",
}
result = normalize_model_for_upload(model)
self.assertEqual(result["custom_field"], "should_survive")
self.assertEqual(result["mesh_tf"], [0, 0, 0, 0, 0, 0])
if __name__ == "__main__":
unittest.main()

View File

@@ -1,15 +0,0 @@
# Liquid handling 集成测试
`test_transfer_liquid.py` 现在会调用 PRCXI 的 RViz 仿真 backend运行前请确保
1. 已安装包含 `pylabrobot``rclpy` 的运行环境;
2. 启动 ROS 依赖(`rviz` 可选,但是 `rviz_backend` 会创建 ROS 节点);
3. 在 shell 中设置 `UNILAB_SIM_TEST=1`,否则 pytest 会自动跳过这些慢速用例:
```bash
export UNILAB_SIM_TEST=1
pytest tests/devices/liquid_handling/test_transfer_liquid.py -m slow
```
如果只需验证逻辑层(不依赖仿真),可以直接运行 `tests/devices/liquid_handling/unit_test.py`,该文件使用 Fake backend适合作为 CI 的快速测试。***

View File

@@ -1,547 +0,0 @@
import asyncio
from dataclasses import dataclass
from typing import Any, Iterable, List, Optional, Sequence, Tuple
import pytest
from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract
@dataclass(frozen=True)
class DummyContainer:
name: str
def __repr__(self) -> str: # pragma: no cover
return f"DummyContainer({self.name})"
@dataclass(frozen=True)
class DummyTipSpot:
name: str
def __repr__(self) -> str: # pragma: no cover
return f"DummyTipSpot({self.name})"
def make_tip_iter(n: int = 256) -> Iterable[List[DummyTipSpot]]:
"""Yield lists so code can safely call `tip.extend(next(self.current_tip))`."""
for i in range(n):
yield [DummyTipSpot(f"tip_{i}")]
class FakeLiquidHandler(LiquidHandlerAbstract):
"""不初始化真实 backend/deck仅用来记录 transfer_liquid 内部调用序列。"""
def __init__(self, channel_num: int = 8):
# 不调用 super().__init__避免真实硬件/后端依赖
self.channel_num = channel_num
self.support_touch_tip = True
self.current_tip = iter(make_tip_iter())
self.calls: List[Tuple[str, Any]] = []
async def pick_up_tips(self, tip_spots, use_channels=None, offsets=None, **backend_kwargs):
self.calls.append(("pick_up_tips", {"tips": list(tip_spots), "use_channels": use_channels}))
async def aspirate(
self,
resources: Sequence[Any],
vols: List[float],
use_channels: Optional[List[int]] = None,
flow_rates: Optional[List[Optional[float]]] = None,
offsets: Any = None,
liquid_height: Any = None,
blow_out_air_volume: Any = None,
spread: str = "wide",
**backend_kwargs,
):
self.calls.append(
(
"aspirate",
{
"resources": list(resources),
"vols": list(vols),
"use_channels": list(use_channels) if use_channels is not None else None,
"flow_rates": list(flow_rates) if flow_rates is not None else None,
"offsets": list(offsets) if offsets is not None else None,
"liquid_height": list(liquid_height) if liquid_height is not None else None,
"blow_out_air_volume": list(blow_out_air_volume) if blow_out_air_volume is not None else None,
},
)
)
async def dispense(
self,
resources: Sequence[Any],
vols: List[float],
use_channels: Optional[List[int]] = None,
flow_rates: Optional[List[Optional[float]]] = None,
offsets: Any = None,
liquid_height: Any = None,
blow_out_air_volume: Any = None,
spread: str = "wide",
**backend_kwargs,
):
self.calls.append(
(
"dispense",
{
"resources": list(resources),
"vols": list(vols),
"use_channels": list(use_channels) if use_channels is not None else None,
"flow_rates": list(flow_rates) if flow_rates is not None else None,
"offsets": list(offsets) if offsets is not None else None,
"liquid_height": list(liquid_height) if liquid_height is not None else None,
"blow_out_air_volume": list(blow_out_air_volume) if blow_out_air_volume is not None else None,
},
)
)
async def discard_tips(self, use_channels=None, *args, **kwargs):
# 有的分支是 discard_tips(use_channels=[0]),有的分支是 discard_tips([0..7])(位置参数)
self.calls.append(("discard_tips", {"use_channels": list(use_channels) if use_channels is not None else None}))
async def custom_delay(self, seconds=0, msg=None):
self.calls.append(("custom_delay", {"seconds": seconds, "msg": msg}))
async def touch_tip(self, targets):
# 原实现会访问 targets.get_size_x() 等;测试里只记录调用
self.calls.append(("touch_tip", {"targets": targets}))
def run(coro):
return asyncio.run(coro)
def test_one_to_one_single_channel_basic_calls():
lh = FakeLiquidHandler(channel_num=1)
lh.current_tip = iter(make_tip_iter(64))
sources = [DummyContainer(f"S{i}") for i in range(3)]
targets = [DummyContainer(f"T{i}") for i in range(3)]
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=[0],
asp_vols=[1, 2, 3],
dis_vols=[4, 5, 6],
mix_times=None, # 应该仍能执行(不 mix
)
)
assert [c[0] for c in lh.calls].count("pick_up_tips") == 3
assert [c[0] for c in lh.calls].count("aspirate") == 3
assert [c[0] for c in lh.calls].count("dispense") == 3
assert [c[0] for c in lh.calls].count("discard_tips") == 3
# 每次 aspirate/dispense 都是单孔列表
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
assert aspirates[0]["resources"] == [sources[0]]
assert aspirates[0]["vols"] == [1.0]
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert dispenses[2]["resources"] == [targets[2]]
assert dispenses[2]["vols"] == [6.0]
def test_one_to_one_single_channel_before_stage_mixes_prior_to_aspirate():
lh = FakeLiquidHandler(channel_num=1)
lh.current_tip = iter(make_tip_iter(16))
source = DummyContainer("S0")
target = DummyContainer("T0")
run(
lh.transfer_liquid(
sources=[source],
targets=[target],
tip_racks=[],
use_channels=[0],
asp_vols=[5],
dis_vols=[5],
mix_stage="before",
mix_times=1,
mix_vol=3,
)
)
aspirate_calls = [(idx, payload) for idx, (name, payload) in enumerate(lh.calls) if name == "aspirate"]
assert len(aspirate_calls) >= 2
mix_idx, mix_payload = aspirate_calls[0]
assert mix_payload["resources"] == [target]
assert mix_payload["vols"] == [3]
transfer_idx, transfer_payload = aspirate_calls[1]
assert transfer_payload["resources"] == [source]
assert mix_idx < transfer_idx
def test_one_to_one_eight_channel_groups_by_8():
lh = FakeLiquidHandler(channel_num=8)
lh.current_tip = iter(make_tip_iter(256))
sources = [DummyContainer(f"S{i}") for i in range(16)]
targets = [DummyContainer(f"T{i}") for i in range(16)]
asp_vols = list(range(1, 17))
dis_vols = list(range(101, 117))
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=list(range(8)),
asp_vols=asp_vols,
dis_vols=dis_vols,
mix_times=0, # 触发逻辑但不 mix
)
)
# 16 个任务 -> 2 组,每组 8 通道一起做
assert [c[0] for c in lh.calls].count("pick_up_tips") == 2
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert len(aspirates) == 2
assert len(dispenses) == 2
assert aspirates[0]["resources"] == sources[0:8]
assert aspirates[0]["vols"] == [float(v) for v in asp_vols[0:8]]
assert dispenses[1]["resources"] == targets[8:16]
assert dispenses[1]["vols"] == [float(v) for v in dis_vols[8:16]]
def test_one_to_one_eight_channel_requires_multiple_of_8_targets():
lh = FakeLiquidHandler(channel_num=8)
lh.current_tip = iter(make_tip_iter(64))
sources = [DummyContainer(f"S{i}") for i in range(9)]
targets = [DummyContainer(f"T{i}") for i in range(9)]
with pytest.raises(ValueError, match="multiple of 8"):
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=list(range(8)),
asp_vols=[1] * 9,
dis_vols=[1] * 9,
mix_times=0,
)
)
def test_one_to_one_eight_channel_parameter_lists_are_chunked_per_8():
lh = FakeLiquidHandler(channel_num=8)
lh.current_tip = iter(make_tip_iter(512))
sources = [DummyContainer(f"S{i}") for i in range(16)]
targets = [DummyContainer(f"T{i}") for i in range(16)]
asp_vols = [i + 1 for i in range(16)]
dis_vols = [200 + i for i in range(16)]
asp_flow_rates = [0.1 * (i + 1) for i in range(16)]
dis_flow_rates = [0.2 * (i + 1) for i in range(16)]
offsets = [f"offset_{i}" for i in range(16)]
liquid_heights = [i * 0.5 for i in range(16)]
blow_out_air_volume = [i + 0.05 for i in range(16)]
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=list(range(8)),
asp_vols=asp_vols,
dis_vols=dis_vols,
asp_flow_rates=asp_flow_rates,
dis_flow_rates=dis_flow_rates,
offsets=offsets,
liquid_height=liquid_heights,
blow_out_air_volume=blow_out_air_volume,
mix_times=0,
)
)
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert len(aspirates) == len(dispenses) == 2
for batch_idx in range(2):
start = batch_idx * 8
end = start + 8
asp_call = aspirates[batch_idx]
dis_call = dispenses[batch_idx]
assert asp_call["resources"] == sources[start:end]
assert asp_call["flow_rates"] == asp_flow_rates[start:end]
assert asp_call["offsets"] == offsets[start:end]
assert asp_call["liquid_height"] == liquid_heights[start:end]
assert asp_call["blow_out_air_volume"] == blow_out_air_volume[start:end]
assert dis_call["flow_rates"] == dis_flow_rates[start:end]
assert dis_call["offsets"] == offsets[start:end]
assert dis_call["liquid_height"] == liquid_heights[start:end]
assert dis_call["blow_out_air_volume"] == blow_out_air_volume[start:end]
def test_one_to_one_eight_channel_handles_32_tasks_four_batches():
lh = FakeLiquidHandler(channel_num=8)
lh.current_tip = iter(make_tip_iter(1024))
sources = [DummyContainer(f"S{i}") for i in range(32)]
targets = [DummyContainer(f"T{i}") for i in range(32)]
asp_vols = [i + 1 for i in range(32)]
dis_vols = [300 + i for i in range(32)]
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=list(range(8)),
asp_vols=asp_vols,
dis_vols=dis_vols,
mix_times=0,
)
)
pick_calls = [name for name, _ in lh.calls if name == "pick_up_tips"]
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert len(pick_calls) == 4
assert len(aspirates) == len(dispenses) == 4
assert aspirates[0]["resources"] == sources[0:8]
assert aspirates[-1]["resources"] == sources[24:32]
assert dispenses[0]["resources"] == targets[0:8]
assert dispenses[-1]["resources"] == targets[24:32]
def test_one_to_many_single_channel_aspirates_total_when_asp_vol_too_small():
lh = FakeLiquidHandler(channel_num=1)
lh.current_tip = iter(make_tip_iter(64))
source = DummyContainer("SRC")
targets = [DummyContainer(f"T{i}") for i in range(3)]
dis_vols = [10, 20, 30] # sum=60
run(
lh.transfer_liquid(
sources=[source],
targets=targets,
tip_racks=[],
use_channels=[0],
asp_vols=10, # 小于 sum(dis_vols) -> 应吸 60
dis_vols=dis_vols,
mix_times=0,
)
)
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
assert len(aspirates) == 1
assert aspirates[0]["resources"] == [source]
assert aspirates[0]["vols"] == [60.0]
assert aspirates[0]["use_channels"] == [0]
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert [d["vols"][0] for d in dispenses] == [10.0, 20.0, 30.0]
def test_one_to_many_eight_channel_basic():
lh = FakeLiquidHandler(channel_num=8)
lh.current_tip = iter(make_tip_iter(128))
source = DummyContainer("SRC")
targets = [DummyContainer(f"T{i}") for i in range(8)]
dis_vols = [i + 1 for i in range(8)]
run(
lh.transfer_liquid(
sources=[source],
targets=targets,
tip_racks=[],
use_channels=list(range(8)),
asp_vols=999, # one-to-many 8ch 会按 dis_vols 吸(每通道各自)
dis_vols=dis_vols,
mix_times=0,
)
)
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
assert aspirates[0]["resources"] == [source] * 8
assert aspirates[0]["vols"] == [float(v) for v in dis_vols]
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert dispenses[0]["resources"] == targets
assert dispenses[0]["vols"] == [float(v) for v in dis_vols]
def test_many_to_one_single_channel_standard_dispense_equals_asp_by_default():
lh = FakeLiquidHandler(channel_num=1)
lh.current_tip = iter(make_tip_iter(128))
sources = [DummyContainer(f"S{i}") for i in range(3)]
target = DummyContainer("T")
asp_vols = [5, 6, 7]
run(
lh.transfer_liquid(
sources=sources,
targets=[target],
tip_racks=[],
use_channels=[0],
asp_vols=asp_vols,
dis_vols=1, # many-to-one 允许标量;非比例模式下实际每次分液=对应 asp_vol
mix_times=0,
)
)
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert [d["vols"][0] for d in dispenses] == [float(v) for v in asp_vols]
assert all(d["resources"] == [target] for d in dispenses)
def test_many_to_one_single_channel_before_stage_mixes_target_once():
lh = FakeLiquidHandler(channel_num=1)
lh.current_tip = iter(make_tip_iter(128))
sources = [DummyContainer("S0"), DummyContainer("S1")]
target = DummyContainer("T")
run(
lh.transfer_liquid(
sources=sources,
targets=[target],
tip_racks=[],
use_channels=[0],
asp_vols=[5, 6],
dis_vols=1,
mix_stage="before",
mix_times=2,
mix_vol=4,
)
)
aspirate_calls = [(idx, payload) for idx, (name, payload) in enumerate(lh.calls) if name == "aspirate"]
assert len(aspirate_calls) >= 1
mix_idx, mix_payload = aspirate_calls[0]
assert mix_payload["resources"] == [target]
assert mix_payload["vols"] == [4]
# 第一個 mix 之後會真正開始吸 source
assert any(call["resources"] == [sources[0]] for _, call in aspirate_calls[1:])
def test_many_to_one_single_channel_proportional_mixing_uses_dis_vols_per_source():
lh = FakeLiquidHandler(channel_num=1)
lh.current_tip = iter(make_tip_iter(128))
sources = [DummyContainer(f"S{i}") for i in range(3)]
target = DummyContainer("T")
asp_vols = [5, 6, 7]
dis_vols = [1, 2, 3]
run(
lh.transfer_liquid(
sources=sources,
targets=[target],
tip_racks=[],
use_channels=[0],
asp_vols=asp_vols,
dis_vols=dis_vols, # 比例模式
mix_times=0,
)
)
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert [d["vols"][0] for d in dispenses] == [float(v) for v in dis_vols]
def test_many_to_one_eight_channel_basic():
lh = FakeLiquidHandler(channel_num=8)
lh.current_tip = iter(make_tip_iter(256))
sources = [DummyContainer(f"S{i}") for i in range(8)]
target = DummyContainer("T")
asp_vols = [10 + i for i in range(8)]
run(
lh.transfer_liquid(
sources=sources,
targets=[target],
tip_racks=[],
use_channels=list(range(8)),
asp_vols=asp_vols,
dis_vols=999, # 非比例模式下每通道分液=对应 asp_vol
mix_times=0,
)
)
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert aspirates[0]["resources"] == sources
assert aspirates[0]["vols"] == [float(v) for v in asp_vols]
assert dispenses[0]["resources"] == [target] * 8
assert dispenses[0]["vols"] == [float(v) for v in asp_vols]
def test_transfer_liquid_mode_detection_unsupported_shape_raises():
lh = FakeLiquidHandler(channel_num=8)
lh.current_tip = iter(make_tip_iter(64))
sources = [DummyContainer("S0"), DummyContainer("S1")]
targets = [DummyContainer("T0"), DummyContainer("T1"), DummyContainer("T2")]
with pytest.raises(ValueError, match="Unsupported transfer mode"):
run(
lh.transfer_liquid(
sources=sources,
targets=targets,
tip_racks=[],
use_channels=[0],
asp_vols=[1, 1],
dis_vols=[1, 1, 1],
mix_times=0,
)
)
def test_mix_single_target_produces_matching_cycles():
lh = FakeLiquidHandler(channel_num=1)
target = DummyContainer("T_mix")
run(lh.mix(targets=[target], mix_time=2, mix_vol=5))
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
assert len(aspirates) == len(dispenses) == 2
assert all(call["resources"] == [target] for call in aspirates)
assert all(call["vols"] == [5] for call in aspirates)
assert all(call["resources"] == [target] for call in dispenses)
assert all(call["vols"] == [5] for call in dispenses)
def test_mix_multiple_targets_supports_per_target_offsets():
lh = FakeLiquidHandler(channel_num=1)
targets = [DummyContainer("T0"), DummyContainer("T1")]
offsets = ["left", "right"]
heights = [0.1, 0.2]
rates = [0.5, 1.0]
run(
lh.mix(
targets=targets,
mix_time=1,
mix_vol=3,
offsets=offsets,
height_to_bottom=heights,
mix_rate=rates,
)
)
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
assert len(aspirates) == 2
assert aspirates[0]["resources"] == [targets[0]]
assert aspirates[0]["offsets"] == [offsets[0]]
assert aspirates[0]["liquid_height"] == [heights[0]]
assert aspirates[0]["flow_rates"] == [rates[0]]
assert aspirates[1]["resources"] == [targets[1]]
assert aspirates[1]["offsets"] == [offsets[1]]
assert aspirates[1]["liquid_height"] == [heights[1]]
assert aspirates[1]["flow_rates"] == [rates[1]]

View File

@@ -0,0 +1,939 @@
"""
P1 关节数据 & 资源跟随桥接测试 — 全面覆盖 HostNode 关节回调 + resource_pose 回调的边缘 case。
不依赖 ROS2 运行时,通过 mock 模拟 msg 和 bridge。
测试分组:
E1: JointRepublisher JSON 输出格式 (已修复 str→json.dumps)
E2: 关节状态回调 — 从 /joint_states (JointState msg) 直接读取 name/position
E3: 资源跟随 (resource_pose) — 夹爪抓取/释放/多资源
E4: 联合流程 — 关节 + 资源一并通过 bridge 发送
E5: Bridge 调用验证
E6: 同类型设备多实例 — 重复关节名场景
E7: 吞吐优化 — 死区过滤、抑频、增量 resource_poses
"""
import json
import time
import pytest
from unittest.mock import MagicMock
from types import SimpleNamespace
from typing import Dict, Optional
# ==================== 辅助: 模拟 JointState msg ====================
def _make_joint_state_msg(names: list, positions: list, velocities=None, efforts=None):
"""构造模拟的 sensor_msgs/JointState 消息(不依赖 ROS2"""
msg = SimpleNamespace()
msg.name = names
msg.position = positions
msg.velocity = velocities or [0.0] * len(names)
msg.effort = efforts or [0.0] * len(names)
return msg
def _make_string_msg(data: str):
"""构造模拟的 std_msgs/String 消息"""
msg = SimpleNamespace()
msg.data = data
return msg
# ==================== 辅助: 提取 HostNode 核心逻辑用于隔离测试 ====================
class JointBridgeSimulator:
"""
模拟 HostNode 的关节桥接核心逻辑(提取自 host_node.py
不依赖 ROS2 Node、subscription 等基础设施。
包含吞吐优化逻辑:
- 死区过滤 (dead band): 关节变化 < 阈值时不发送
- 抑频 (throttle): 限制最大发送频率
- 增量 resource_poses: 仅在变化时附带
"""
JOINT_DEAD_BAND: float = 1e-4
JOINT_MIN_INTERVAL: float = 0.05 # 秒
def __init__(self, device_uuid_map: Dict[str, str],
dead_band: Optional[float] = None,
min_interval: Optional[float] = None):
self.device_uuid_map = device_uuid_map
self._device_ids_sorted = sorted(device_uuid_map.keys(), key=len, reverse=True)
self._resource_poses: Dict[str, str] = {}
self._resource_poses_dirty: bool = False
self._last_joint_values: Dict[str, float] = {}
self._last_send_time: float = -float("inf") # 确保首条消息总是通过
# 允许测试覆盖优化参数
if dead_band is not None:
self.JOINT_DEAD_BAND = dead_band
if min_interval is not None:
self.JOINT_MIN_INTERVAL = min_interval
def resource_pose_callback(self, msg) -> None:
"""模拟 HostNode._resource_pose_callback含变化检测"""
try:
data = json.loads(msg.data)
except (json.JSONDecodeError, ValueError):
return
if not isinstance(data, dict) or not data:
return
has_change = False
for k, v in data.items():
if self._resource_poses.get(k) != v:
has_change = True
break
if has_change:
self._resource_poses.update(data)
self._resource_poses_dirty = True
def joint_state_callback(self, msg, now: Optional[float] = None) -> dict:
"""
模拟 HostNode._joint_state_callback 核心逻辑(含优化)。
now 参数允许测试控制时间。
返回 {device_id: {"node_uuid": ..., "joint_states": {...}, "resource_poses": {...}}}。
返回 {} 表示被优化过滤。
"""
names = list(msg.name)
positions = list(msg.position)
if not names or len(names) != len(positions):
return {}
if now is None:
now = time.time()
resource_dirty = self._resource_poses_dirty
# 抑频检查
if not resource_dirty and (now - self._last_send_time) < self.JOINT_MIN_INTERVAL:
return {}
# 死区过滤
has_significant_change = False
for name, pos in zip(names, positions):
last_val = self._last_joint_values.get(name)
if last_val is None or abs(float(pos) - last_val) >= self.JOINT_DEAD_BAND:
has_significant_change = True
break
if not has_significant_change and not resource_dirty:
return {}
# 更新状态
for name, pos in zip(names, positions):
self._last_joint_values[name] = float(pos)
self._last_send_time = now
# 按设备 ID 分组关节数据
device_joints: Dict[str, Dict[str, float]] = {}
for name, pos in zip(names, positions):
matched_device = None
for device_id in self._device_ids_sorted:
if name.startswith(device_id + "_"):
matched_device = device_id
break
if matched_device:
if matched_device not in device_joints:
device_joints[matched_device] = {}
device_joints[matched_device][name] = float(pos)
elif len(self.device_uuid_map) == 1:
fallback_id = self._device_ids_sorted[0]
if fallback_id not in device_joints:
device_joints[fallback_id] = {}
device_joints[fallback_id][name] = float(pos)
# 构建设备级 resource_poses仅 dirty 时附带)
device_resource_poses: Dict[str, Dict[str, str]] = {}
if resource_dirty:
for resource_id, link_name in self._resource_poses.items():
matched_device = None
for device_id in self._device_ids_sorted:
if link_name.startswith(device_id + "_"):
matched_device = device_id
break
if matched_device:
if matched_device not in device_resource_poses:
device_resource_poses[matched_device] = {}
device_resource_poses[matched_device][resource_id] = link_name
elif len(self.device_uuid_map) == 1:
fallback_id = self._device_ids_sorted[0]
if fallback_id not in device_resource_poses:
device_resource_poses[fallback_id] = {}
device_resource_poses[fallback_id][resource_id] = link_name
self._resource_poses_dirty = False
result = {}
for device_id, joint_states in device_joints.items():
node_uuid = self.device_uuid_map.get(device_id)
if not node_uuid:
continue
result[device_id] = {
"node_uuid": node_uuid,
"joint_states": joint_states,
"resource_poses": device_resource_poses.get(device_id, {}),
}
return result
# 功能测试中禁用优化dead_band=0, min_interval=0确保逻辑正确性
def _make_sim(device_uuid_map: Dict[str, str]) -> JointBridgeSimulator:
"""创建禁用吞吐优化的模拟器(用于功能正确性测试)"""
return JointBridgeSimulator(device_uuid_map, dead_band=0.0, min_interval=0.0)
# ==================== E1: JointRepublisher JSON 输出 ====================
class TestJointRepublisherFormat:
"""验证 JointRepublisher 输出标准 JSON双引号而非 Python repr单引号"""
def test_output_is_valid_json(self):
"""str() 产生单引号json.dumps() 产生双引号"""
joint_dict = {
"name": ["joint1", "joint2"],
"position": [0.1, 0.2],
"velocity": [0.0, 0.0],
"effort": [0.0, 0.0],
}
result = json.dumps(joint_dict)
parsed = json.loads(result)
assert parsed["name"] == ["joint1", "joint2"]
assert parsed["position"] == [0.1, 0.2]
assert "'" not in result
def test_str_produces_invalid_json(self):
"""对比: str() 不是合法 JSON"""
joint_dict = {"name": ["joint1"], "position": [0.1]}
result = str(joint_dict)
with pytest.raises(json.JSONDecodeError):
json.loads(result)
# ==================== E2: 关节状态回调JointState msg 直接读取)====================
class TestJointStateCallback:
"""测试从 JointState msg 直接读取 name/position 的分组逻辑"""
def test_single_device_simple(self):
"""单设备,关节名有设备前缀"""
sim = _make_sim({"panda": "uuid-panda"})
msg = _make_joint_state_msg(
["panda_joint1", "panda_joint2"], [0.5, 1.0]
)
result = sim.joint_state_callback(msg)
assert "panda" in result
assert result["panda"]["joint_states"]["panda_joint1"] == 0.5
assert result["panda"]["joint_states"]["panda_joint2"] == 1.0
def test_single_device_no_prefix_fallback(self):
"""单设备,关节名无设备前缀 → 应归入唯一设备"""
sim = _make_sim({"robot1": "uuid-r1"})
msg = _make_joint_state_msg(["joint_a", "joint_b"], [1.0, 2.0])
result = sim.joint_state_callback(msg)
assert "robot1" in result
assert result["robot1"]["joint_states"]["joint_a"] == 1.0
assert result["robot1"]["joint_states"]["joint_b"] == 2.0
def test_multi_device_distinct_prefixes(self):
"""多设备,不同前缀,正确分组"""
sim = _make_sim({"arm1": "uuid-arm1", "arm2": "uuid-arm2"})
msg = _make_joint_state_msg(
["arm1_j1", "arm1_j2", "arm2_j1", "arm2_j2"],
[0.1, 0.2, 0.3, 0.4],
)
result = sim.joint_state_callback(msg)
assert result["arm1"]["joint_states"]["arm1_j1"] == 0.1
assert result["arm1"]["joint_states"]["arm1_j2"] == 0.2
assert result["arm2"]["joint_states"]["arm2_j1"] == 0.3
assert result["arm2"]["joint_states"]["arm2_j2"] == 0.4
def test_ambiguous_prefix_longest_wins(self):
"""前缀歧义: arm 和 arm_left — 最长前缀优先"""
sim = _make_sim({"arm": "uuid-arm", "arm_left": "uuid-arm-left"})
msg = _make_joint_state_msg(
["arm_j1", "arm_left_j1", "arm_left_j2"],
[0.1, 0.2, 0.3],
)
result = sim.joint_state_callback(msg)
assert result["arm"]["joint_states"]["arm_j1"] == 0.1
assert result["arm_left"]["joint_states"]["arm_left_j1"] == 0.2
assert result["arm_left"]["joint_states"]["arm_left_j2"] == 0.3
def test_multi_device_unmatched_joints_dropped(self):
"""多设备时,无法匹配前缀的关节应被丢弃(不 fallback"""
sim = _make_sim({"arm1": "uuid-arm1", "arm2": "uuid-arm2"})
msg = _make_joint_state_msg(
["arm1_j1", "unknown_j1"],
[0.1, 0.9],
)
result = sim.joint_state_callback(msg)
assert result["arm1"]["joint_states"]["arm1_j1"] == 0.1
for device_id, data in result.items():
assert "unknown_j1" not in data["joint_states"]
def test_empty_names(self):
"""空 name 列表"""
sim = _make_sim({"dev": "uuid-dev"})
msg = _make_joint_state_msg([], [])
result = sim.joint_state_callback(msg)
assert result == {}
def test_mismatched_lengths(self):
"""name 和 position 长度不一致"""
sim = _make_sim({"dev": "uuid-dev"})
msg = _make_joint_state_msg(["j1", "j2"], [0.1])
result = sim.joint_state_callback(msg)
assert result == {}
def test_no_devices(self):
"""无设备 UUID 映射"""
sim = _make_sim({})
msg = _make_joint_state_msg(["j1"], [0.1])
result = sim.joint_state_callback(msg)
assert result == {}
def test_numeric_prefix_device_ids(self):
"""数字化设备 ID (如 deck1, deck12) — deck12_slot1 不应匹配 deck1"""
sim = _make_sim({"deck1": "uuid-d1", "deck12": "uuid-d12"})
msg = _make_joint_state_msg(
["deck1_slot1", "deck12_slot1"],
[1.0, 2.0],
)
result = sim.joint_state_callback(msg)
assert result["deck1"]["joint_states"]["deck1_slot1"] == 1.0
assert result["deck12"]["joint_states"]["deck12_slot1"] == 2.0
def test_position_float_conversion(self):
"""position 值应强制转为 float即使输入为 int"""
sim = _make_sim({"arm": "uuid-arm"})
msg = _make_joint_state_msg(["arm_j1"], [1])
result = sim.joint_state_callback(msg)
assert result["arm"]["joint_states"]["arm_j1"] == 1.0
assert isinstance(result["arm"]["joint_states"]["arm_j1"], float)
def test_node_uuid_in_result(self):
"""结果中应携带正确的 node_uuid"""
sim = _make_sim({"panda": "uuid-panda-123"})
msg = _make_joint_state_msg(["panda_j1"], [0.5])
result = sim.joint_state_callback(msg)
assert result["panda"]["node_uuid"] == "uuid-panda-123"
def test_device_with_no_uuid_skipped(self):
"""device_uuid_map 中存在映射但值为空 → 跳过"""
sim = _make_sim({"arm": ""})
msg = _make_joint_state_msg(["arm_j1"], [0.5])
result = sim.joint_state_callback(msg)
assert result == {}
def test_many_joints_single_device(self):
"""单设备大量关节(如 7-DOF arm"""
sim = _make_sim({"panda": "uuid-panda"})
names = [f"panda_joint{i}" for i in range(1, 8)]
positions = [float(i) * 0.1 for i in range(1, 8)]
msg = _make_joint_state_msg(names, positions)
result = sim.joint_state_callback(msg)
assert len(result["panda"]["joint_states"]) == 7
assert result["panda"]["joint_states"]["panda_joint7"] == pytest.approx(0.7)
def test_duplicate_joint_names_last_wins(self):
"""同类型设备多个实例时如果关节名完全重复bug 场景),后出现的值覆盖前者"""
sim = _make_sim({"dev": "uuid-dev"})
msg = _make_joint_state_msg(["dev_j1", "dev_j1"], [1.0, 2.0])
result = sim.joint_state_callback(msg)
assert result["dev"]["joint_states"]["dev_j1"] == 2.0
def test_negative_positions(self):
"""关节角度为负数"""
sim = _make_sim({"arm": "uuid-arm"})
msg = _make_joint_state_msg(["arm_j1", "arm_j2"], [-1.57, -3.14])
result = sim.joint_state_callback(msg)
assert result["arm"]["joint_states"]["arm_j1"] == pytest.approx(-1.57)
assert result["arm"]["joint_states"]["arm_j2"] == pytest.approx(-3.14)
# ==================== E3: 资源跟随 (resource_pose) ====================
class TestResourcePoseCallback:
"""测试 resource_pose 回调 — 夹爪抓取/释放/多资源"""
def test_single_resource_attach(self):
"""单个资源挂载到夹爪 link"""
sim = _make_sim({"panda": "uuid-panda"})
msg = _make_string_msg(json.dumps({"plate_1": "panda_gripper_link"}))
sim.resource_pose_callback(msg)
assert sim._resource_poses == {"plate_1": "panda_gripper_link"}
assert sim._resource_poses_dirty is True
def test_multiple_resource_attach(self):
"""多个资源同时挂载到不同 link"""
sim = _make_sim({"panda": "uuid-panda"})
msg = _make_string_msg(json.dumps({
"plate_1": "panda_gripper_link",
"tip_rack": "panda_deck_link",
}))
sim.resource_pose_callback(msg)
assert sim._resource_poses["plate_1"] == "panda_gripper_link"
assert sim._resource_poses["tip_rack"] == "panda_deck_link"
def test_incremental_update(self):
"""增量更新:新消息合并到已有状态"""
sim = _make_sim({"panda": "uuid-panda"})
sim.resource_pose_callback(_make_string_msg(json.dumps({"plate_1": "panda_deck_link"})))
sim.resource_pose_callback(_make_string_msg(json.dumps({"plate_2": "panda_gripper_link"})))
assert len(sim._resource_poses) == 2
assert sim._resource_poses["plate_1"] == "panda_deck_link"
assert sim._resource_poses["plate_2"] == "panda_gripper_link"
def test_resource_reattach(self):
"""资源从 deck 移动到 gripper抓取操作"""
sim = _make_sim({"panda": "uuid-panda"})
sim.resource_pose_callback(_make_string_msg(json.dumps({"plate_1": "panda_deck_link"})))
assert sim._resource_poses["plate_1"] == "panda_deck_link"
sim.resource_pose_callback(_make_string_msg(json.dumps({"plate_1": "panda_gripper_link"})))
assert sim._resource_poses["plate_1"] == "panda_gripper_link"
def test_resource_release_back_to_world(self):
"""释放资源回到 world"""
sim = _make_sim({"panda": "uuid-panda"})
sim.resource_pose_callback(_make_string_msg(json.dumps({"plate_1": "panda_gripper_link"})))
sim.resource_pose_callback(_make_string_msg(json.dumps({"plate_1": "world"})))
assert sim._resource_poses["plate_1"] == "world"
def test_empty_dict_heartbeat_no_dirty(self):
"""空 dict心跳包不标记 dirty"""
sim = _make_sim({"panda": "uuid-panda"})
sim.resource_pose_callback(_make_string_msg(json.dumps({"plate_1": "panda_link"})))
sim._resource_poses_dirty = False # 重置
sim.resource_pose_callback(_make_string_msg(json.dumps({})))
assert sim._resource_poses_dirty is False # 空 dict 不应标记 dirty
def test_same_value_no_dirty(self):
"""重复发送相同值不应标记 dirty"""
sim = _make_sim({"panda": "uuid-panda"})
sim.resource_pose_callback(_make_string_msg(json.dumps({"plate_1": "panda_link"})))
sim._resource_poses_dirty = False
sim.resource_pose_callback(_make_string_msg(json.dumps({"plate_1": "panda_link"})))
assert sim._resource_poses_dirty is False
def test_invalid_json_ignored(self):
"""非法 JSON 消息不影响状态"""
sim = _make_sim({"panda": "uuid-panda"})
sim.resource_pose_callback(_make_string_msg(json.dumps({"plate_1": "panda_link"})))
sim.resource_pose_callback(_make_string_msg("not valid json {{{"))
assert sim._resource_poses["plate_1"] == "panda_link"
def test_non_dict_json_ignored(self):
"""JSON 但不是 dict如 list应被忽略"""
sim = _make_sim({"panda": "uuid-panda"})
sim.resource_pose_callback(_make_string_msg(json.dumps(["not", "a", "dict"])))
assert sim._resource_poses == {}
def test_python_repr_ignored(self):
"""Python repr 格式(单引号)应被忽略"""
sim = _make_sim({"panda": "uuid-panda"})
sim.resource_pose_callback(_make_string_msg("{'plate_1': 'panda_link'}"))
assert sim._resource_poses == {}
def test_multi_device_resource_attach(self):
"""多设备场景:不同设备的 link 挂载不同资源"""
sim = _make_sim({"arm1": "uuid-arm1", "arm2": "uuid-arm2"})
sim.resource_pose_callback(_make_string_msg(json.dumps({
"plate_A": "arm1_gripper_link",
"plate_B": "arm2_gripper_link",
})))
assert sim._resource_poses["plate_A"] == "arm1_gripper_link"
assert sim._resource_poses["plate_B"] == "arm2_gripper_link"
# ==================== E4: 联合流程 — 关节 + 资源一并通过 bridge ====================
class TestJointWithResourcePoses:
"""测试关节状态回调时resource_poses 被正确按设备分组并包含在结果中"""
def test_single_device_joint_with_resource(self):
"""单设备:关节更新时携带已挂载的资源"""
sim = _make_sim({"panda": "uuid-panda"})
sim.resource_pose_callback(_make_string_msg(json.dumps({
"plate_1": "panda_gripper_link",
})))
msg = _make_joint_state_msg(["panda_j1", "panda_j2"], [0.5, 1.0])
result = sim.joint_state_callback(msg)
assert result["panda"]["resource_poses"] == {"plate_1": "panda_gripper_link"}
def test_single_device_no_resource(self):
"""单设备:无资源挂载时 resource_poses 为空 dict"""
sim = _make_sim({"panda": "uuid-panda"})
msg = _make_joint_state_msg(["panda_j1"], [0.5])
result = sim.joint_state_callback(msg)
assert result["panda"]["resource_poses"] == {}
def test_multi_device_resource_routing(self):
"""多设备:资源按 link 前缀路由到正确设备"""
sim = _make_sim({"arm1": "uuid-arm1", "arm2": "uuid-arm2"})
sim.resource_pose_callback(_make_string_msg(json.dumps({
"plate_A": "arm1_gripper_link",
"plate_B": "arm2_gripper_link",
"tube_1": "arm1_tool_link",
})))
msg = _make_joint_state_msg(
["arm1_j1", "arm2_j1"],
[0.1, 0.2],
)
result = sim.joint_state_callback(msg)
assert result["arm1"]["resource_poses"] == {
"plate_A": "arm1_gripper_link",
"tube_1": "arm1_tool_link",
}
assert result["arm2"]["resource_poses"] == {"plate_B": "arm2_gripper_link"}
def test_resource_on_world_frame_not_routed(self):
"""资源挂在 world frame已释放— 多设备时无法匹配任何设备前缀"""
sim = _make_sim({"arm1": "uuid-arm1", "arm2": "uuid-arm2"})
sim.resource_pose_callback(_make_string_msg(json.dumps({
"plate_A": "world",
})))
msg = _make_joint_state_msg(["arm1_j1"], [0.1])
result = sim.joint_state_callback(msg)
assert result["arm1"]["resource_poses"] == {}
def test_resource_world_frame_single_device_fallback(self):
"""单设备时 world frame 的资源走 fallback"""
sim = _make_sim({"panda": "uuid-panda"})
sim.resource_pose_callback(_make_string_msg(json.dumps({
"plate_A": "world",
})))
msg = _make_joint_state_msg(["panda_j1"], [0.1])
result = sim.joint_state_callback(msg)
assert result["panda"]["resource_poses"] == {"plate_A": "world"}
def test_grab_and_move_sequence(self):
"""完整夹取序列: 资源在 deck → gripper 抓取 → arm 移动 → 放下"""
sim = _make_sim({"panda": "uuid-panda"})
# 初始: plate 在 deck
sim.resource_pose_callback(_make_string_msg(json.dumps({
"plate_1": "panda_deck_third_link",
})))
msg = _make_joint_state_msg(
["panda_j1", "panda_j2", "panda_j3"],
[0.0, -0.5, 1.0],
)
result = sim.joint_state_callback(msg)
assert result["panda"]["resource_poses"]["plate_1"] == "panda_deck_third_link"
# 抓取: plate 从 deck → gripper
sim.resource_pose_callback(_make_string_msg(json.dumps({
"plate_1": "panda_gripper_link",
})))
msg = _make_joint_state_msg(
["panda_j1", "panda_j2", "panda_j3"],
[1.57, 0.0, -0.5],
)
result = sim.joint_state_callback(msg)
assert result["panda"]["resource_poses"]["plate_1"] == "panda_gripper_link"
assert result["panda"]["joint_states"]["panda_j1"] == pytest.approx(1.57)
# 放下: plate 从 gripper → 目标 deck
sim.resource_pose_callback(_make_string_msg(json.dumps({
"plate_1": "panda_deck_first_link",
})))
msg = _make_joint_state_msg(
["panda_j1", "panda_j2", "panda_j3"],
[0.0, 0.0, 0.0],
)
result = sim.joint_state_callback(msg)
assert result["panda"]["resource_poses"]["plate_1"] == "panda_deck_first_link"
def test_simultaneous_grab_multiple_resources(self):
"""同时持有多个资源(如双夹爪)"""
sim = _make_sim({"panda": "uuid-panda"})
sim.resource_pose_callback(_make_string_msg(json.dumps({
"plate_1": "panda_left_gripper",
"plate_2": "panda_right_gripper",
"tip_rack": "panda_deck_link",
})))
msg = _make_joint_state_msg(["panda_j1"], [0.5])
result = sim.joint_state_callback(msg)
assert len(result["panda"]["resource_poses"]) == 3
def test_resource_with_ambiguous_link_prefix(self):
"""link 前缀歧义: arm_left_gripper 应匹配 arm_left 而非 arm"""
sim = _make_sim({"arm": "uuid-arm", "arm_left": "uuid-arm-left"})
sim.resource_pose_callback(_make_string_msg(json.dumps({
"plate_A": "arm_gripper_link",
"plate_B": "arm_left_gripper_link",
})))
msg = _make_joint_state_msg(
["arm_j1", "arm_left_j1"],
[0.1, 0.2],
)
result = sim.joint_state_callback(msg)
assert result["arm"]["resource_poses"] == {"plate_A": "arm_gripper_link"}
assert result["arm_left"]["resource_poses"] == {"plate_B": "arm_left_gripper_link"}
# ==================== E5: Bridge 调用验证 ====================
class TestBridgeCalls:
"""验证完整桥接流: callback → bridge.publish_joint_state 调用"""
def test_bridge_called_per_device(self):
"""每个设备调用一次 publish_joint_state"""
device_uuid_map = {"arm1": "uuid-111", "arm2": "uuid-222"}
sim = _make_sim(device_uuid_map)
bridge = MagicMock()
bridge.publish_joint_state = MagicMock()
msg = _make_joint_state_msg(
["arm1_j1", "arm2_j1"],
[1.0, 2.0],
)
result = sim.joint_state_callback(msg)
for device_id, data in result.items():
bridge.publish_joint_state(
data["node_uuid"], data["joint_states"], data["resource_poses"]
)
assert bridge.publish_joint_state.call_count == 2
call_uuids = {c[0][0] for c in bridge.publish_joint_state.call_args_list}
assert call_uuids == {"uuid-111", "uuid-222"}
def test_bridge_called_with_resource_poses(self):
"""bridge 调用时携带 resource_poses"""
sim = _make_sim({"panda": "uuid-panda"})
sim.resource_pose_callback(_make_string_msg(json.dumps({
"plate_1": "panda_gripper_link",
})))
bridge = MagicMock()
msg = _make_joint_state_msg(["panda_j1"], [0.5])
result = sim.joint_state_callback(msg)
for device_id, data in result.items():
bridge.publish_joint_state(
data["node_uuid"], data["joint_states"], data["resource_poses"]
)
bridge.publish_joint_state.assert_called_once_with(
"uuid-panda",
{"panda_j1": 0.5},
{"plate_1": "panda_gripper_link"},
)
def test_bridge_no_call_for_empty_joints(self):
"""无关节数据时不调用 bridge"""
sim = _make_sim({"panda": "uuid-panda"})
bridge = MagicMock()
msg = _make_joint_state_msg([], [])
result = sim.joint_state_callback(msg)
for device_id, data in result.items():
bridge.publish_joint_state(
data["node_uuid"], data["joint_states"], data["resource_poses"]
)
bridge.publish_joint_state.assert_not_called()
def test_bridge_resource_poses_empty_when_no_resources(self):
"""无资源挂载时resource_poses 参数为空 dict"""
sim = _make_sim({"panda": "uuid-panda"})
bridge = MagicMock()
msg = _make_joint_state_msg(["panda_j1"], [0.5])
result = sim.joint_state_callback(msg)
for device_id, data in result.items():
bridge.publish_joint_state(
data["node_uuid"], data["joint_states"], data["resource_poses"]
)
bridge.publish_joint_state.assert_called_once_with(
"uuid-panda",
{"panda_j1": 0.5},
{},
)
def test_multi_bridge_all_called(self):
"""多个 bridge 都应被调用"""
sim = _make_sim({"arm": "uuid-arm"})
bridges = [MagicMock(), MagicMock()]
msg = _make_joint_state_msg(["arm_j1"], [0.5])
result = sim.joint_state_callback(msg)
for device_id, data in result.items():
for bridge in bridges:
bridge.publish_joint_state(
data["node_uuid"], data["joint_states"], data["resource_poses"]
)
for bridge in bridges:
bridge.publish_joint_state.assert_called_once()
# ==================== E6: 同类型设备多个实例 — 重复关节名场景 ====================
class TestDuplicateDeviceTypes:
"""
多个同类型设备(如 2 个 OT-2 移液器),关节名格式为 {device_id}_{joint_name}
设备 ID 不同(如 ot2_left, ot2_right但底层关节名相同如 pipette_j1
"""
def test_same_type_different_id(self):
"""同类型设备不同 ID"""
sim = _make_sim({
"ot2_left": "uuid-ot2-left",
"ot2_right": "uuid-ot2-right",
})
msg = _make_joint_state_msg(
["ot2_left_pipette_j1", "ot2_left_pipette_j2",
"ot2_right_pipette_j1", "ot2_right_pipette_j2"],
[0.1, 0.2, 0.3, 0.4],
)
result = sim.joint_state_callback(msg)
assert result["ot2_left"]["joint_states"]["ot2_left_pipette_j1"] == 0.1
assert result["ot2_left"]["joint_states"]["ot2_left_pipette_j2"] == 0.2
assert result["ot2_right"]["joint_states"]["ot2_right_pipette_j1"] == 0.3
assert result["ot2_right"]["joint_states"]["ot2_right_pipette_j2"] == 0.4
def test_same_type_with_resources_routed_correctly(self):
"""同类型设备各自抓取资源,按 link 前缀正确路由"""
sim = _make_sim({
"ot2_left": "uuid-ot2-left",
"ot2_right": "uuid-ot2-right",
})
sim.resource_pose_callback(_make_string_msg(json.dumps({
"plate_A": "ot2_left_gripper",
"plate_B": "ot2_right_gripper",
})))
msg = _make_joint_state_msg(
["ot2_left_j1", "ot2_right_j1"],
[0.5, 0.6],
)
result = sim.joint_state_callback(msg)
assert result["ot2_left"]["resource_poses"] == {"plate_A": "ot2_left_gripper"}
assert result["ot2_right"]["resource_poses"] == {"plate_B": "ot2_right_gripper"}
def test_numbered_devices_no_confusion(self):
"""编号设备: robot1 不应匹配 robot10 的关节"""
sim = _make_sim({
"robot1": "uuid-r1",
"robot10": "uuid-r10",
})
msg = _make_joint_state_msg(
["robot1_j1", "robot10_j1"],
[1.0, 10.0],
)
result = sim.joint_state_callback(msg)
assert result["robot1"]["joint_states"]["robot1_j1"] == 1.0
assert result["robot10"]["joint_states"]["robot10_j1"] == 10.0
def test_three_same_type_devices(self):
"""三个同类型设备"""
sim = _make_sim({
"pump_a": "uuid-pa",
"pump_b": "uuid-pb",
"pump_c": "uuid-pc",
})
msg = _make_joint_state_msg(
["pump_a_flow", "pump_b_flow", "pump_c_flow",
"pump_a_pressure", "pump_b_pressure"],
[1.0, 2.0, 3.0, 0.1, 0.2],
)
result = sim.joint_state_callback(msg)
assert len(result["pump_a"]["joint_states"]) == 2
assert len(result["pump_b"]["joint_states"]) == 2
assert len(result["pump_c"]["joint_states"]) == 1
# ==================== E7: 吞吐优化测试 ====================
class TestThroughputOptimizations:
"""测试死区过滤、抑频、增量 resource_poses 等优化行为"""
# --- 死区过滤 (Dead Band) ---
def test_dead_band_filters_tiny_change(self):
"""关节变化小于死区阈值 → 被过滤"""
sim = JointBridgeSimulator({"arm": "uuid-arm"}, dead_band=0.01, min_interval=0.0)
msg1 = _make_joint_state_msg(["arm_j1"], [1.0])
result1 = sim.joint_state_callback(msg1, now=0.0)
assert "arm" in result1
# 微小变化 (0.001 < 0.01 死区)
msg2 = _make_joint_state_msg(["arm_j1"], [1.001])
result2 = sim.joint_state_callback(msg2, now=1.0)
assert result2 == {}
def test_dead_band_passes_significant_change(self):
"""关节变化大于死区阈值 → 通过"""
sim = JointBridgeSimulator({"arm": "uuid-arm"}, dead_band=0.01, min_interval=0.0)
msg1 = _make_joint_state_msg(["arm_j1"], [1.0])
sim.joint_state_callback(msg1, now=0.0)
msg2 = _make_joint_state_msg(["arm_j1"], [1.05])
result2 = sim.joint_state_callback(msg2, now=1.0)
assert "arm" in result2
assert result2["arm"]["joint_states"]["arm_j1"] == pytest.approx(1.05)
def test_dead_band_first_message_always_passes(self):
"""首次消息总是通过(无历史值)"""
sim = JointBridgeSimulator({"arm": "uuid-arm"}, dead_band=1000.0, min_interval=0.0)
msg = _make_joint_state_msg(["arm_j1"], [0.001])
result = sim.joint_state_callback(msg, now=0.0)
assert "arm" in result
def test_dead_band_any_joint_change_triggers(self):
"""多关节中只要有一个超过死区就全部发送"""
sim = JointBridgeSimulator({"arm": "uuid-arm"}, dead_band=0.01, min_interval=0.0)
msg1 = _make_joint_state_msg(["arm_j1", "arm_j2"], [1.0, 2.0])
sim.joint_state_callback(msg1, now=0.0)
# j1 微变化j2 大变化
msg2 = _make_joint_state_msg(["arm_j1", "arm_j2"], [1.001, 2.5])
result2 = sim.joint_state_callback(msg2, now=1.0)
assert "arm" in result2
# 两个关节的值都应包含在结果中
assert result2["arm"]["joint_states"]["arm_j1"] == pytest.approx(1.001)
assert result2["arm"]["joint_states"]["arm_j2"] == pytest.approx(2.5)
# --- 抑频 (Throttle) ---
def test_throttle_filters_rapid_messages(self):
"""发送间隔内的消息被过滤"""
sim = JointBridgeSimulator({"arm": "uuid-arm"}, dead_band=0.0, min_interval=0.1)
msg1 = _make_joint_state_msg(["arm_j1"], [1.0])
result1 = sim.joint_state_callback(msg1, now=0.0)
assert "arm" in result1
# 0.05s < 0.1s 间隔
msg2 = _make_joint_state_msg(["arm_j1"], [2.0])
result2 = sim.joint_state_callback(msg2, now=0.05)
assert result2 == {}
def test_throttle_passes_after_interval(self):
"""超过发送间隔后消息通过"""
sim = JointBridgeSimulator({"arm": "uuid-arm"}, dead_band=0.0, min_interval=0.1)
msg1 = _make_joint_state_msg(["arm_j1"], [1.0])
sim.joint_state_callback(msg1, now=0.0)
msg2 = _make_joint_state_msg(["arm_j1"], [2.0])
result2 = sim.joint_state_callback(msg2, now=0.15)
assert "arm" in result2
def test_throttle_bypassed_by_resource_change(self):
"""resource_pose 变化时忽略抑频限制"""
sim = JointBridgeSimulator({"arm": "uuid-arm"}, dead_band=0.0, min_interval=1.0)
msg1 = _make_joint_state_msg(["arm_j1"], [1.0])
sim.joint_state_callback(msg1, now=0.0)
# 资源变化 → 强制发送
sim.resource_pose_callback(_make_string_msg(json.dumps({"plate": "arm_gripper"})))
msg2 = _make_joint_state_msg(["arm_j1"], [1.0])
result2 = sim.joint_state_callback(msg2, now=0.01) # 远小于 1.0 间隔
assert "arm" in result2
assert result2["arm"]["resource_poses"] == {"plate": "arm_gripper"}
# --- 增量 resource_poses ---
def test_resource_poses_only_sent_when_dirty(self):
"""resource_poses 仅在 dirty 时附带,否则为空"""
sim = _make_sim({"panda": "uuid-panda"})
sim.resource_pose_callback(_make_string_msg(json.dumps({"plate": "panda_gripper"})))
# 第一次发送dirty → 携带 resource_poses
msg1 = _make_joint_state_msg(["panda_j1"], [0.5])
result1 = sim.joint_state_callback(msg1)
assert result1["panda"]["resource_poses"] == {"plate": "panda_gripper"}
# dirty 已清除
assert sim._resource_poses_dirty is False
# 第二次发送not dirty → resource_poses 为空
msg2 = _make_joint_state_msg(["panda_j1"], [1.0])
result2 = sim.joint_state_callback(msg2)
assert result2["panda"]["resource_poses"] == {}
def test_resource_change_resets_dirty_after_send(self):
"""dirty 在发送后被重置,再次 resource_pose 变化后重新标记"""
sim = _make_sim({"panda": "uuid-panda"})
sim.resource_pose_callback(_make_string_msg(json.dumps({"plate": "panda_deck"})))
msg = _make_joint_state_msg(["panda_j1"], [0.5])
sim.joint_state_callback(msg)
assert sim._resource_poses_dirty is False
# 再次资源变化
sim.resource_pose_callback(_make_string_msg(json.dumps({"plate": "panda_gripper"})))
assert sim._resource_poses_dirty is True
msg2 = _make_joint_state_msg(["panda_j1"], [1.0])
result2 = sim.joint_state_callback(msg2)
assert result2["panda"]["resource_poses"] == {"plate": "panda_gripper"}
# --- 组合场景 ---
def test_dead_band_bypassed_by_resource_dirty(self):
"""关节无变化但 resource_pose 有变化 → 仍然发送"""
sim = JointBridgeSimulator({"arm": "uuid-arm"}, dead_band=0.01, min_interval=0.0)
msg1 = _make_joint_state_msg(["arm_j1"], [1.0])
sim.joint_state_callback(msg1, now=0.0)
sim.resource_pose_callback(_make_string_msg(json.dumps({"plate": "arm_gripper"})))
# 关节值完全不变
msg2 = _make_joint_state_msg(["arm_j1"], [1.0])
result2 = sim.joint_state_callback(msg2, now=1.0)
assert "arm" in result2
assert result2["arm"]["resource_poses"] == {"plate": "arm_gripper"}
def test_high_frequency_stream_only_significant_pass(self):
"""模拟高频流: 只有显著变化的消息通过"""
sim = JointBridgeSimulator({"arm": "uuid-arm"}, dead_band=0.01, min_interval=0.0)
t = 0.0
passed_count = 0
# 100 条消息,每条微小递增 0.001
for i in range(100):
t += 0.1
val = 1.0 + i * 0.001
msg = _make_joint_state_msg(["arm_j1"], [val])
result = sim.joint_state_callback(msg, now=t)
if result:
passed_count += 1
# 首次总通过 + 每 10 条左右(累计 0.01 变化)通过一次
assert passed_count < 20 # 远少于 100
assert passed_count >= 5 # 但不应为 0
def test_throttle_and_dead_band_combined(self):
"""同时受抑频和死区影响"""
sim = JointBridgeSimulator({"arm": "uuid-arm"}, dead_band=0.01, min_interval=0.5)
# 首条通过
msg1 = _make_joint_state_msg(["arm_j1"], [1.0])
assert sim.joint_state_callback(msg1, now=0.0) != {}
# 时间不够 + 变化不够 → 过滤
msg2 = _make_joint_state_msg(["arm_j1"], [1.001])
assert sim.joint_state_callback(msg2, now=0.1) == {}
# 时间够但变化不够 → 过滤
msg3 = _make_joint_state_msg(["arm_j1"], [1.002])
assert sim.joint_state_callback(msg3, now=1.0) == {}
# 时间够且变化够 → 通过
msg4 = _make_joint_state_msg(["arm_j1"], [1.05])
assert sim.joint_state_callback(msg4, now=1.5) != {}

6
unilabos/__main__.py Normal file
View File

@@ -0,0 +1,6 @@
"""Entry point for `python -m unilabos`."""
from unilabos.app.main import main
if __name__ == "__main__":
main()

View File

@@ -50,6 +50,17 @@ class BaseCommunicationClient(ABC):
"""
pass
def publish_joint_state(self, node_uuid: str, joint_states: dict, resource_poses: dict = None) -> None:
"""
发布高频关节状态数据push_joint_state action不写 DB
Args:
node_uuid: 设备节点的云端 UUID
joint_states: 关节名 → 角度/位置 的映射
resource_poses: 物料附着映射(可选)
"""
pass
@abstractmethod
def publish_job_status(
self, feedback_data: dict, job_id: str, status: str, return_info: Optional[dict] = None

View File

@@ -0,0 +1,210 @@
"""模型文件上传/下载管理。
提供 Edge 端本地模型文件与 OSS 之间的双向同步:
- upload_device_model: 本地模型 → OSSEdge 首次接入时)
- download_model_from_oss: OSS → 本地(新 Edge 加入已有 Lab 时)
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import TYPE_CHECKING, Optional
import requests
from unilabos.utils.log import logger
if TYPE_CHECKING:
from unilabos.app.web.client import HTTPClient
# 设备 mesh 根目录
_MESH_BASE_DIR = Path(__file__).parent.parent / "device_mesh"
# 支持的模型文件后缀
_MODEL_EXTENSIONS = frozenset({
".xacro", ".urdf", ".stl", ".dae", ".obj",
".gltf", ".glb", ".fbx", ".yaml", ".yml",
})
# 需要 XOR 加密/解密的 mesh 文件后缀(反爬保护 — 方案 C
_MESH_ENCRYPT_EXTENSIONS = frozenset({
".stl", ".dae", ".obj", ".fbx", ".gltf", ".glb",
})
# XOR 密钥 — 从环境变量读取,与前端 mesh-decrypt.ts 一致
_XOR_KEY = os.environ.get("UNILAB_MESH_XOR_KEY", "unilab3d-model-protection-key-v1").encode()
def _xor_transform(data: bytes, key: bytes = _XOR_KEY) -> bytes:
"""XOR 加密/解密(对称操作)。"""
key_len = len(key)
return bytes(b ^ key[i % key_len] for i, b in enumerate(data))
def upload_device_model(
http_client: "HTTPClient",
template_uuid: str,
mesh_name: str,
model_type: str,
version: str = "1.0.0",
) -> Optional[str]:
"""上传本地模型文件到 OSS返回入口文件的 OSS URL。
Args:
http_client: HTTPClient 实例
template_uuid: 设备模板 UUID
mesh_name: mesh 目录名(如 "arm_slider"
model_type: "device""resource"
version: 模型版本
Returns:
入口文件 OSS URL上传失败返回 None
"""
if model_type == "device":
model_dir = _MESH_BASE_DIR / "devices" / mesh_name
else:
model_dir = _MESH_BASE_DIR / "resources" / mesh_name
if not model_dir.exists():
logger.warning(f"[模型上传] 本地目录不存在: {model_dir}")
return None
# 收集所有需要上传的文件
files = []
for f in model_dir.rglob("*"):
if f.is_file() and f.suffix.lower() in _MODEL_EXTENSIONS:
files.append({
"name": str(f.relative_to(model_dir)),
"size_kb": f.stat().st_size // 1024,
})
if not files:
logger.warning(f"[模型上传] 目录中无可上传的模型文件: {model_dir}")
return None
try:
# 1. 获取预签名上传 URL
upload_urls_resp = http_client.get_model_upload_urls(
template_uuid=template_uuid,
files=[{"name": f["name"], "version": version} for f in files],
)
if not upload_urls_resp:
return None
url_items = upload_urls_resp.get("files", [])
# 2. 逐个上传文件
for file_info, url_info in zip(files, url_items):
local_path = model_dir / file_info["name"]
upload_url = url_info.get("upload_url", "")
if not upload_url:
continue
_put_upload(local_path, upload_url)
# 3. 确认发布
entry_file = "macro_device.xacro" if model_type == "device" else "modal.xacro"
# 检查入口文件是否存在,使用实际存在的文件名
for f in files:
if f["name"].endswith(".xacro"):
entry_file = f["name"]
break
publish_resp = http_client.publish_model(
template_uuid=template_uuid,
version=version,
entry_file=entry_file,
)
return publish_resp.get("path") if publish_resp else None
except Exception as e:
logger.error(f"[模型上传] 上传失败 ({mesh_name}): {e}")
return None
def download_model_from_oss(
model_config: dict,
mesh_base_dir: Optional[Path] = None,
) -> bool:
"""检查本地模型文件是否存在,不存在则从 OSS 下载。
Args:
model_config: 节点的 model 配置字典
mesh_base_dir: mesh 根目录,默认使用 device_mesh/
Returns:
True 表示本地文件就绪False 表示下载失败或无需下载
"""
if mesh_base_dir is None:
mesh_base_dir = _MESH_BASE_DIR
mesh_name = model_config.get("mesh", "")
model_type = model_config.get("type", "")
oss_path = model_config.get("path", "")
if not mesh_name or not oss_path or not oss_path.startswith("https://"):
return False
# 确定本地目标目录
if model_type == "device":
local_dir = mesh_base_dir / "devices" / mesh_name
elif model_type == "resource":
resource_name = mesh_name.split("/")[0]
local_dir = mesh_base_dir / "resources" / resource_name
else:
return False
# 已有本地文件 → 跳过
if local_dir.exists() and any(local_dir.iterdir()):
return True
# 从 OSS 下载
local_dir.mkdir(parents=True, exist_ok=True)
try:
# 下载入口文件OSS URL 通常直接可访问)
entry_name = oss_path.rsplit("/", 1)[-1]
_download_file(oss_path, local_dir / entry_name)
# 如果有 children_mesh也下载
children_mesh = model_config.get("children_mesh")
if isinstance(children_mesh, dict) and children_mesh.get("path"):
cm_path = children_mesh["path"]
if cm_path.startswith("https://"):
cm_name = cm_path.rsplit("/", 1)[-1]
meshes_dir = local_dir / "meshes"
meshes_dir.mkdir(parents=True, exist_ok=True)
_download_file(cm_path, meshes_dir / cm_name)
logger.info(f"[模型下载] 成功下载模型到本地: {mesh_name}{local_dir}")
return True
except Exception as e:
logger.warning(f"[模型下载] 下载失败 ({mesh_name}): {e}")
return False
def _put_upload(local_path: Path, upload_url: str) -> None:
"""通过预签名 URL 上传文件到 OSS。对 mesh 文件自动 XOR 加密。"""
with open(local_path, "rb") as f:
data = f.read()
# 对 mesh 文件 XOR 加密后上传(反爬保护 — 方案 C
if local_path.suffix.lower() in _MESH_ENCRYPT_EXTENSIONS:
data = _xor_transform(data)
logger.debug(f"[模型上传] XOR 加密: {local_path.name}")
resp = requests.put(upload_url, data=data, timeout=120)
resp.raise_for_status()
logger.debug(f"[模型上传] 已上传: {local_path.name}")
def _download_file(url: str, local_path: Path) -> None:
"""下载单个文件到本地路径。对 mesh 文件自动 XOR 解密。"""
local_path.parent.mkdir(parents=True, exist_ok=True)
resp = requests.get(url, timeout=60)
resp.raise_for_status()
data = resp.content
# 从 OSS 下载的 mesh 文件是加密的,需要 XOR 解密后再存本地
if local_path.suffix.lower() in _MESH_ENCRYPT_EXTENSIONS:
data = _xor_transform(data)
logger.debug(f"[模型下载] XOR 解密: {local_path.name}")
local_path.write_bytes(data)
logger.debug(f"[模型下载] 已下载: {local_path}")

View File

@@ -5,6 +5,48 @@ from unilabos.utils.log import logger
from unilabos.utils.tools import normalize_json as _normalize_device
def normalize_model_for_upload(model_dict: dict) -> dict:
"""将 Registry YAML 的 model 字段映射为后端 DeviceModel 结构化格式。
保留所有原始字段,额外做以下标准化:
1. 自动推断 format如果 YAML 未指定)
2. 将 children_mesh 扁平字段映射为结构化 children_mesh 对象
"""
if not model_dict:
return model_dict
result = {**model_dict}
# 自动推断 format
if "format" not in result and result.get("path"):
path = result["path"]
if path.endswith(".xacro"):
result["format"] = "xacro"
elif path.endswith(".urdf"):
result["format"] = "urdf"
elif path.endswith(".stl"):
result["format"] = "stl"
elif path.endswith((".gltf", ".glb")):
result["format"] = "gltf"
# 将 children_mesh 扁平字段 → 结构化 children_mesh 对象
if "children_mesh" in result and isinstance(result["children_mesh"], str):
cm_path = result.pop("children_mesh")
cm_tf = result.pop("children_mesh_tf", None)
cm_oss = result.pop("children_mesh_path", None)
result["children_mesh"] = {
"path": cm_oss or cm_path,
"format": "stl" if cm_path.endswith(".stl") else "gltf",
"default_visible": True,
}
if cm_tf and len(cm_tf) >= 3:
result["children_mesh"]["local_offset"] = cm_tf[:3]
if cm_tf and len(cm_tf) >= 6:
result["children_mesh"]["local_rotation"] = cm_tf[3:6]
return result
def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[Tuple[Dict[str, Any], Dict[str, Any]]]:
"""
注册设备和资源到服务器仅支持HTTP
@@ -16,11 +58,18 @@ def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[
devices_to_register = {}
for device_info in lab_registry.obtain_registry_device_info():
devices_to_register[device_info["id"]] = _normalize_device(device_info)
normalized = _normalize_device(device_info)
# 标准化 model 字段
if normalized.get("model"):
normalized["model"] = normalize_model_for_upload(normalized["model"])
devices_to_register[device_info["id"]] = normalized
logger.trace(f"[UniLab Register] 收集设备: {device_info['id']}")
resources_to_register = {}
for resource_info in lab_registry.obtain_registry_resource_info():
# 标准化 model 字段
if resource_info.get("model"):
resource_info["model"] = normalize_model_for_upload(resource_info["model"])
resources_to_register[resource_info["id"]] = resource_info
logger.trace(f"[UniLab Register] 收集资源: {resource_info['id']}")

View File

@@ -468,6 +468,63 @@ class HTTPClient:
logger.error(f"发布工作流失败: {response.status_code}, {response.text}")
return {"code": response.status_code, "message": response.text}
# ──────────────────── 模型资产管理 ────────────────────
def get_model_upload_urls(
self, template_uuid: str, files: list[dict],
) -> dict | None:
"""获取模型文件预签名上传 URL。
Args:
template_uuid: 设备模板 UUID
files: 文件列表 [{"name": "...", "version": "1.0.0"}]
Returns:
{"files": [{"name": "...", "upload_url": "...", "path": "..."}]}
"""
try:
response = requests.post(
f"{self.remote_addr}/lab/square/template/{template_uuid}/model/upload-urls",
json={"files": files},
headers={"Authorization": f"Lab {self.auth}"},
timeout=30,
)
if response.status_code == 200:
data = response.json().get("data")
return data
logger.error(f"获取模型上传 URL 失败: {response.status_code}, {response.text}")
except Exception as e:
logger.error(f"获取模型上传 URL 异常: {e}")
return None
def publish_model(
self, template_uuid: str, version: str, entry_file: str,
) -> dict | None:
"""确认模型上传完成,发布新版本。
Args:
template_uuid: 设备模板 UUID
version: 模型版本
entry_file: 入口文件名
Returns:
{"path": "...", "oss_dir": "...", "version": "..."}
"""
try:
response = requests.post(
f"{self.remote_addr}/lab/square/template/{template_uuid}/model/publish",
json={"version": version, "entry_file": entry_file},
headers={"Authorization": f"Lab {self.auth}"},
timeout=30,
)
if response.status_code == 200:
data = response.json().get("data")
return data
logger.error(f"发布模型失败: {response.status_code}, {response.text}")
except Exception as e:
logger.error(f"发布模型异常: {e}")
return None
# 创建默认客户端实例
http_client = HTTPClient()

View File

@@ -58,14 +58,14 @@ class JobResultStore:
feedback=feedback or {},
timestamp=time.time(),
)
logger.debug(f"[JobResultStore] Stored result for job {job_id[:8]}, status={status}")
logger.trace(f"[JobResultStore] Stored result for job {job_id[:8]}, status={status}")
def get_and_remove(self, job_id: str) -> Optional[JobResult]:
"""获取并删除任务结果"""
with self._results_lock:
result = self._results.pop(job_id, None)
if result:
logger.debug(f"[JobResultStore] Retrieved and removed result for job {job_id[:8]}")
logger.trace(f"[JobResultStore] Retrieved and removed result for job {job_id[:8]}")
return result
def get_result(self, job_id: str) -> Optional[JobResult]:

View File

@@ -754,32 +754,6 @@ class MessageProcessor:
req = JobAddReq(**data)
job_log = format_job_log(req.job_id, req.task_id, req.device_id, req.action)
# 服务端对always_free动作可能跳过query_action_state直接发job_start
# 此时job尚未注册需要自动补注册
existing_job = self.device_manager.get_job_info(req.job_id)
if not existing_job:
action_name = req.action
device_action_key = f"/devices/{req.device_id}/{action_name}"
action_always_free = self._check_action_always_free(req.device_id, action_name)
if action_always_free:
job_info = JobInfo(
job_id=req.job_id,
task_id=req.task_id,
device_id=req.device_id,
action_name=action_name,
device_action_key=device_action_key,
status=JobStatus.QUEUE,
start_time=time.time(),
always_free=True,
)
self.device_manager.add_queue_request(job_info)
logger.info(f"[MessageProcessor] Job {job_log} always_free, auto-registered from direct job_start")
else:
logger.error(f"[MessageProcessor] Job {job_log} not registered (missing query_action_state)")
return
success = self.device_manager.start_job(req.job_id)
if not success:
logger.error(f"[MessageProcessor] Failed to start job {job_log}")
@@ -1460,6 +1434,21 @@ class WebSocketClient(BaseCommunicationClient):
self.message_processor.send_message(message)
# logger.trace(f"[WebSocketClient] Device status published: {device_id}.{property_name}")
def publish_joint_state(self, node_uuid: str, joint_states: dict, resource_poses: dict = None) -> None:
"""发布高频关节状态push_joint_state不写 DB"""
if self.is_disabled or not self.is_connected():
return
message = {
"action": "push_joint_state",
"data": {
"node_uuid": node_uuid,
"joint_states": joint_states or {},
"resource_poses": resource_poses or {},
},
}
self.message_processor.send_message(message)
def publish_job_status(
self, feedback_data: dict, item: QueueItem, status: str, return_info: Optional[dict] = None
) -> None:

File diff suppressed because it is too large Load Diff

View File

@@ -43,7 +43,7 @@ class Base(ABC):
self._type = typ
self._data_type = data_type
self._node: Optional[Node] = None
def _get_node(self) -> Node:
if self._node is None:
try:
@@ -66,7 +66,7 @@ class Base(ABC):
# 直接以字符串形式处理
if isinstance(nid, str):
nid = nid.strip()
# 处理包含类名的格式,如 'StringNodeId(ns=4;s=...)' 或 'NumericNodeId(ns=2;i=...)'
# 提取括号内的内容
match_wrapped = re.match(r'(String|Numeric|Byte|Guid|TwoByteNode|FourByteNode)NodeId\((.*)\)', nid)
@@ -116,16 +116,16 @@ class Base(ABC):
def read(self) -> Tuple[Any, bool]:
"""读取节点值,返回(值, 是否出错)"""
pass
@abstractmethod
def write(self, value: Any) -> bool:
"""写入节点值,返回是否出错"""
pass
@property
def type(self) -> NodeType:
return self._type
@property
def node_id(self) -> str:
return self._node_id
@@ -210,15 +210,15 @@ class Method(Base):
super().__init__(client, name, node_id, NodeType.METHOD, data_type)
self._parent_node_id = parent_node_id
self._parent_node = None
def _get_parent_node(self) -> Node:
if self._parent_node is None:
try:
# 处理父节点ID使用与_get_node相同的解析逻辑
import re
nid = self._parent_node_id
# 如果已经是 NodeId 对象,直接使用
try:
from opcua.ua import NodeId as UaNodeId
@@ -227,16 +227,16 @@ class Method(Base):
return self._parent_node
except Exception:
pass
# 字符串处理
if isinstance(nid, str):
nid = nid.strip()
# 处理包含类名的格式
match_wrapped = re.match(r'(String|Numeric|Byte|Guid|TwoByteNode|FourByteNode)NodeId\((.*)\)', nid)
if match_wrapped:
nid = match_wrapped.group(2).strip()
# 常见短格式
if re.match(r'^ns=\d+;[is]=', nid):
self._parent_node = self._client.get_node(nid)
@@ -271,7 +271,7 @@ class Method(Base):
def write(self, value: Any) -> bool:
"""方法节点不支持写入操作"""
return True
def call(self, *args) -> Tuple[Any, bool]:
"""调用方法,返回(返回值, 是否出错)"""
try:
@@ -285,7 +285,7 @@ class Method(Base):
class Object(Base):
def __init__(self, client: Client, name: str, node_id: str):
super().__init__(client, name, node_id, NodeType.OBJECT, None)
def read(self) -> Tuple[Any, bool]:
"""对象节点不支持直接读取操作"""
return None, True
@@ -293,7 +293,7 @@ class Object(Base):
def write(self, value: Any) -> bool:
"""对象节点不支持直接写入操作"""
return True
def get_children(self) -> Tuple[List[Node], bool]:
"""获取子节点列表,返回(子节点列表, 是否出错)"""
try:
@@ -301,4 +301,4 @@ class Object(Base):
return children, False
except Exception as e:
print(f"获取对象 {self._name} 的子节点失败: {e}")
return [], True
return [], True

View File

@@ -208,8 +208,7 @@ class LiquidHandlerMiddleware(LiquidHandler):
spread: Literal["wide", "tight", "custom"] = "wide",
**backend_kwargs,
):
if spread == "":
spread = "wide"
if self._simulator:
return await self._simulate_handler.aspirate(
resources,
@@ -222,46 +221,23 @@ class LiquidHandlerMiddleware(LiquidHandler):
spread,
**backend_kwargs,
)
try:
await super().aspirate(
resources,
vols,
use_channels,
flow_rates,
offsets,
liquid_height,
blow_out_air_volume,
spread,
**backend_kwargs,
)
except ValueError as e:
if "Resource is too small to space channels" in str(e) and spread != "custom":
await super().aspirate(
resources,
vols,
use_channels,
flow_rates,
offsets,
liquid_height,
blow_out_air_volume,
spread="custom",
**backend_kwargs,
)
else:
raise
await super().aspirate(
resources,
vols,
use_channels,
flow_rates,
offsets,
liquid_height,
blow_out_air_volume,
spread,
**backend_kwargs,
)
res_samples = []
res_volumes = []
# 处理 use_channels 为 None 的情况(通常用于单通道操作)
if use_channels is None:
# 对于单通道操作,推断通道为 [0]
channels_to_use = [0] * len(resources)
else:
channels_to_use = use_channels
for resource, volume, channel in zip(resources, vols, channels_to_use):
sample_uuid_value = getattr(resource, "unilabos_extra", {}).get(EXTRA_SAMPLE_UUID, None)
res_samples.append({"name": resource.name, "sample_uuid": sample_uuid_value})
for resource, volume, channel in zip(resources, vols, use_channels):
sample_uuid_value = resource.unilabos_extra.get(EXTRA_SAMPLE_UUID, None)
res_samples.append({"name": resource.name, EXTRA_SAMPLE_UUID: sample_uuid_value})
res_volumes.append(volume)
self.pending_liquids_dict[channel] = {
EXTRA_SAMPLE_UUID: sample_uuid_value,
@@ -281,8 +257,6 @@ class LiquidHandlerMiddleware(LiquidHandler):
spread: Literal["wide", "tight", "custom"] = "wide",
**backend_kwargs,
) -> SimpleReturn:
if spread == "":
spread = "wide"
if self._simulator:
return await self._simulate_handler.dispense(
resources,
@@ -295,33 +269,16 @@ class LiquidHandlerMiddleware(LiquidHandler):
spread,
**backend_kwargs,
)
try:
await super().dispense(
resources,
vols,
use_channels,
flow_rates,
offsets,
liquid_height,
blow_out_air_volume,
spread,
**backend_kwargs,
)
except ValueError as e:
if "Resource is too small to space channels" in str(e) and spread != "custom":
await super().dispense(
resources,
vols,
use_channels,
flow_rates,
offsets,
liquid_height,
blow_out_air_volume,
"custom",
**backend_kwargs,
)
else:
raise
await super().dispense(
resources,
vols,
use_channels,
flow_rates,
offsets,
liquid_height,
blow_out_air_volume,
**backend_kwargs,
)
res_samples = []
res_volumes = []
for resource, volume, channel in zip(resources, vols, use_channels):
@@ -891,7 +848,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
for _ in range(len(sources)):
tip = []
for __ in range(len(use_channels)):
tip.extend(self._get_next_tip())
tip.extend(next(self.current_tip))
await self.pick_up_tips(tip)
await self.aspirate(
resources=[sources[_]],
@@ -931,7 +888,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
for i in range(0, len(sources), 8):
tip = []
for _ in range(len(use_channels)):
tip.extend(self._get_next_tip())
tip.extend(next(self.current_tip))
await self.pick_up_tips(tip)
current_targets = waste_liquid[i : i + 8]
current_reagent_sources = sources[i : i + 8]
@@ -1025,7 +982,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
for _ in range(len(targets)):
tip = []
for x in range(len(use_channels)):
tip.extend(self._get_next_tip())
tip.extend(next(self.current_tip))
await self.pick_up_tips(tip)
await self.aspirate(
@@ -1077,7 +1034,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
for i in range(0, len(targets), 8):
tip = []
for _ in range(len(use_channels)):
tip.extend(self._get_next_tip())
tip.extend(next(self.current_tip))
await self.pick_up_tips(tip)
current_targets = targets[i : i + 8]
current_reagent_sources = reagent_sources[i : i + 8]
@@ -1202,19 +1159,11 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
Number of mix cycles. If *None* (default) no mixing occurs regardless of
mix_stage.
"""
num_sources = len(sources)
num_targets = len(targets)
len_asp_vols = len(asp_vols)
len_dis_vols = len(dis_vols)
# 确保 use_channels 有默认值
if use_channels is None:
# 默认使用设备所有通道(例如 8 通道移液站默认就是 0-7
use_channels = list(range(self.channel_num)) if self.channel_num == 8 else [0]
elif len(use_channels) == 8:
if self.channel_num != 8:
raise ValueError(f"if channel_num is 8, use_channels length must be 8, but got {len(use_channels)}")
if num_sources%8 != 0 or num_targets%8 != 0 or len_asp_vols%8 != 0 or len_dis_vols%8 != 0:
raise ValueError(f"if channel_num is 8, sources, targets, asp_vols, and dis_vols length must be divisible by 8, but got {num_sources}, {num_targets}, {len_asp_vols}, and {len_dis_vols}")
use_channels = list(range(self.channel_num)) if self.channel_num > 0 else [0]
if is_96_well:
pass # This mode is not verified.
@@ -1242,234 +1191,90 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
if mix_times is not None:
mix_times = int(mix_times)
# 设置tip racks
self.set_tiprack(tip_racks)
# 识别传输模式mix_times 为 None 也应该能正常移液,只是不做 mix
num_sources = len(sources)
num_targets = len(targets)
len_asp_vols = len(asp_vols)
len_dis_vols = len(dis_vols)
# if num_targets != 1 and num_sources != 1:
# if len_asp_vols != num_sources and len_asp_vols != num_targets:
# raise ValueError(f"asp_vols length must be equal to sources or targets length, but got {len_asp_vols} and {num_sources} and {num_targets}")
# if len_dis_vols != num_sources and len_dis_vols != num_targets:
# raise ValueError(f"dis_vols length must be equal to sources or targets length, but got {len_dis_vols} and {num_sources} and {num_targets}")
if len(use_channels) != 8:
max_len = max(num_sources, num_targets)
for i in range(max_len):
# 辅助函数安全地从列表中获取元素如果列表为空则返回None
def safe_get(lst, idx, default=None):
return [lst[idx]] if lst else default
# 动态构建参数字典,只传递实际提供的参数
kwargs = {
'sources': [sources[i%num_sources]],
'targets': [targets[i%num_targets]],
'tip_racks': tip_racks,
'use_channels': use_channels,
'asp_vols': [asp_vols[i%len_asp_vols]],
'dis_vols': [dis_vols[i%len_dis_vols]],
}
# 条件性添加可选参数
if asp_flow_rates is not None:
kwargs['asp_flow_rates'] = [asp_flow_rates[i%len_asp_vols]]
if dis_flow_rates is not None:
kwargs['dis_flow_rates'] = [dis_flow_rates[i%len_dis_vols]]
if offsets is not None:
kwargs['offsets'] = safe_get(offsets, i)
if touch_tip is not None:
kwargs['touch_tip'] = touch_tip if touch_tip else False
if liquid_height is not None:
kwargs['liquid_height'] = safe_get(liquid_height, i)
if blow_out_air_volume is not None:
kwargs['blow_out_air_volume'] = safe_get(blow_out_air_volume, i)
if spread is not None:
kwargs['spread'] = spread
if mix_stage is not None:
kwargs['mix_stage'] = safe_get(mix_stage, i)
if mix_times is not None:
kwargs['mix_times'] = safe_get(mix_times, i)
if mix_vol is not None:
kwargs['mix_vol'] = safe_get(mix_vol, i)
if mix_rate is not None:
kwargs['mix_rate'] = safe_get(mix_rate, i)
if mix_liquid_height is not None:
kwargs['mix_liquid_height'] = safe_get(mix_liquid_height, i)
if delays is not None:
kwargs['delays'] = safe_get(delays, i)
await self._transfer_base_method(**kwargs)
# if num_sources == 1 and num_targets > 1:
# # 模式1: 一对多 (1 source -> N targets)
# await self._transfer_one_to_many(
# sources,
# targets,
# tip_racks,
# use_channels,
# asp_vols,
# dis_vols,
# asp_flow_rates,
# dis_flow_rates,
# offsets,
# touch_tip,
# liquid_height,
# blow_out_air_volume,
# spread,
# mix_stage,
# mix_times,
# mix_vol,
# mix_rate,
# mix_liquid_height,
# delays,
# )
# elif num_sources > 1 and num_targets == 1:
# # 模式2: 多对一 (N sources -> 1 target)
# await self._transfer_many_to_one(
# sources,
# targets[0],
# tip_racks,
# use_channels,
# asp_vols,
# dis_vols,
# asp_flow_rates,
# dis_flow_rates,
# offsets,
# touch_tip,
# liquid_height,
# blow_out_air_volume,
# spread,
# mix_stage,
# mix_times,
# mix_vol,
# mix_rate,
# mix_liquid_height,
# delays,
# )
# elif num_sources == num_targets:
# # 模式3: 一对一 (N sources -> N targets)
# await self._transfer_one_to_one(
# sources,
# targets,
# tip_racks,
# use_channels,
# asp_vols,
# dis_vols,
# asp_flow_rates,
# dis_flow_rates,
# offsets,
# touch_tip,
# liquid_height,
# blow_out_air_volume,
# spread,
# mix_stage,
# mix_times,
# mix_vol,
# mix_rate,
# mix_liquid_height,
# delays,
# )
# else:
# raise ValueError(
# f"Unsupported transfer mode: {num_sources} sources -> {num_targets} targets. "
# "Supported modes: 1->N, N->1, or N->N."
# )
if num_sources == 1 and num_targets > 1:
# 模式1: 一对多 (1 source -> N targets)
await self._transfer_one_to_many(
sources[0],
targets,
tip_racks,
use_channels,
asp_vols,
dis_vols,
asp_flow_rates,
dis_flow_rates,
offsets,
touch_tip,
liquid_height,
blow_out_air_volume,
spread,
mix_stage,
mix_times,
mix_vol,
mix_rate,
mix_liquid_height,
delays,
)
elif num_sources > 1 and num_targets == 1:
# 模式2: 多对一 (N sources -> 1 target)
await self._transfer_many_to_one(
sources,
targets[0],
tip_racks,
use_channels,
asp_vols,
dis_vols,
asp_flow_rates,
dis_flow_rates,
offsets,
touch_tip,
liquid_height,
blow_out_air_volume,
spread,
mix_stage,
mix_times,
mix_vol,
mix_rate,
mix_liquid_height,
delays,
)
elif num_sources == num_targets:
# 模式3: 一对一 (N sources -> N targets)
await self._transfer_one_to_one(
sources,
targets,
tip_racks,
use_channels,
asp_vols,
dis_vols,
asp_flow_rates,
dis_flow_rates,
offsets,
touch_tip,
liquid_height,
blow_out_air_volume,
spread,
mix_stage,
mix_times,
mix_vol,
mix_rate,
mix_liquid_height,
delays,
)
else:
raise ValueError(
f"Unsupported transfer mode: {num_sources} sources -> {num_targets} targets. "
"Supported modes: 1->N, N->1, or N->N."
)
return TransferLiquidReturn(
sources=ResourceTreeSet.from_plr_resources(list(sources), known_newly_created=False).dump(), # type: ignore
targets=ResourceTreeSet.from_plr_resources(list(targets), known_newly_created=False).dump(), # type: ignore
)
async def _transfer_base_method(
self,
sources: Sequence[Container],
targets: Sequence[Container],
tip_racks: Sequence[TipRack],
use_channels: List[int],
asp_vols: List[float],
dis_vols: List[float],
**kwargs
):
# 从kwargs中提取参数提供默认值
asp_flow_rates = kwargs.get('asp_flow_rates')
dis_flow_rates = kwargs.get('dis_flow_rates')
offsets = kwargs.get('offsets')
touch_tip = kwargs.get('touch_tip', False)
liquid_height = kwargs.get('liquid_height')
blow_out_air_volume = kwargs.get('blow_out_air_volume')
spread = kwargs.get('spread', 'wide')
mix_stage = kwargs.get('mix_stage')
mix_times = kwargs.get('mix_times')
mix_vol = kwargs.get('mix_vol')
mix_rate = kwargs.get('mix_rate')
mix_liquid_height = kwargs.get('mix_liquid_height')
delays = kwargs.get('delays')
tip = []
tip.extend(self._get_next_tip())
await self.pick_up_tips(tip)
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
await self.mix(
targets=[targets[0]],
mix_time=mix_times,
mix_vol=mix_vol,
offsets=offsets if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None,
use_channels=use_channels,
)
await self.aspirate(
resources=[sources[0]],
vols=[asp_vols[0]],
use_channels=use_channels,
flow_rates=[asp_flow_rates[0]] if asp_flow_rates and len(asp_flow_rates) > 0 else None,
offsets=[offsets[0]] if offsets and len(offsets) > 0 else None,
liquid_height=[liquid_height[0]] if liquid_height and len(liquid_height) > 0 else None,
blow_out_air_volume=(
[blow_out_air_volume[0]] if blow_out_air_volume and len(blow_out_air_volume) > 0 else None
),
spread=spread,
)
if delays is not None:
await self.custom_delay(seconds=delays[0])
await self.dispense(
resources=[targets[0]],
vols=[dis_vols[0]],
use_channels=use_channels,
flow_rates=[dis_flow_rates[0]] if dis_flow_rates and len(dis_flow_rates) > 0 else None,
offsets=[offsets[0]] if offsets and len(offsets) > 0 else None,
blow_out_air_volume=(
[blow_out_air_volume[0]] if blow_out_air_volume and len(blow_out_air_volume) > 0 else None
),
liquid_height=[liquid_height[0]] if liquid_height and len(liquid_height) > 0 else None,
spread=spread,
)
if delays is not None and len(delays) > 1:
await self.custom_delay(seconds=delays[1])
if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0:
await self.mix(
targets=[targets[0]],
mix_time=mix_times,
mix_vol=mix_vol,
offsets=offsets if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None,
use_channels=use_channels,
)
if delays is not None and len(delays) > 1:
await self.custom_delay(seconds=delays[0])
await self.touch_tip(targets[0])
await self.discard_tips(use_channels=use_channels)
async def _transfer_one_to_one(
self,
sources: Sequence[Container],
@@ -1495,23 +1300,17 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
"""一对一传输模式N sources -> N targets"""
# 验证参数长度
if len(asp_vols) != len(targets):
if len(asp_vols) == 1:
asp_vols = [asp_vols[0]] * len(targets)
else:
raise ValueError(f"Length of `asp_vols` {len(asp_vols)} must match `targets` {len(targets)}.")
raise ValueError(f"Length of `asp_vols` {len(asp_vols)} must match `targets` {len(targets)}.")
if len(dis_vols) != len(targets):
if len(dis_vols) == 1:
dis_vols = [dis_vols[0]] * len(targets)
else:
raise ValueError(f"Length of `dis_vols` {len(dis_vols)} must match `targets` {len(targets)}.")
raise ValueError(f"Length of `dis_vols` {len(dis_vols)} must match `targets` {len(targets)}.")
if len(sources) != len(targets):
raise ValueError(f"Length of `sources` {len(sources)} must match `targets` {len(targets)}.")
if len(use_channels) != 1:
if len(use_channels) == 1:
for _ in range(len(targets)):
tip = []
for ___ in range(len(use_channels)):
tip.extend(self._get_next_tip())
tip.extend(next(self.current_tip))
await self.pick_up_tips(tip)
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
@@ -1522,7 +1321,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
offsets=offsets if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None,
use_channels=use_channels,
)
await self.aspirate(
@@ -1561,7 +1359,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
offsets=offsets if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None,
use_channels=use_channels,
)
if delays is not None and len(delays) > 1:
await self.custom_delay(seconds=delays[1])
@@ -1575,7 +1372,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
for i in range(0, len(targets), 8):
tip = []
for _ in range(len(use_channels)):
tip.extend(self._get_next_tip())
tip.extend(next(self.current_tip))
await self.pick_up_tips(tip)
current_targets = targets[i : i + 8]
current_reagent_sources = sources[i : i + 8]
@@ -1598,7 +1395,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
offsets=offsets if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None,
use_channels=use_channels,
)
await self.aspirate(
@@ -1635,7 +1431,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
offsets=offsets if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None,
use_channels=use_channels,
)
if delays is not None and len(delays) > 1:
await self.custom_delay(seconds=delays[1])
@@ -1680,7 +1475,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
# 单通道模式:一次吸液,多次分液
tip = []
for _ in range(len(use_channels)):
tip.extend(self._get_next_tip())
tip.extend(next(self.current_tip))
await self.pick_up_tips(tip)
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
@@ -1692,7 +1487,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
offsets=offsets[idx : idx + 1] if offsets and len(offsets) > idx else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None,
use_channels=use_channels,
)
# 从源容器吸液(总体积)
@@ -1736,7 +1530,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
offsets=offsets[idx : idx + 1] if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None,
use_channels=use_channels,
)
if touch_tip:
await self.touch_tip([target])
@@ -1752,7 +1545,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
for i in range(0, len(targets), 8):
tip = []
for _ in range(len(use_channels)):
tip.extend(self._get_next_tip())
tip.extend(next(self.current_tip))
await self.pick_up_tips(tip)
current_targets = targets[i : i + 8]
@@ -1780,7 +1573,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
offsets=offsets[i : i + 8] if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None,
use_channels=use_channels,
)
# 从源容器吸液8个通道都从同一个源但每个通道的吸液体积不同
@@ -1826,7 +1618,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
offsets=offsets if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None,
use_channels=use_channels,
)
if touch_tip:
@@ -1859,11 +1650,8 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
"""多对一传输模式N sources -> 1 target汇总/混合)"""
# 验证和扩展体积参数
if len(asp_vols) != len(sources):
if len(asp_vols) == 1:
asp_vols = [asp_vols[0]] * len(sources)
else:
raise ValueError(f"Length of `asp_vols` {len(asp_vols)} must match `sources` {len(sources)}.")
raise ValueError(f"Length of `asp_vols` {len(asp_vols)} must match `sources` {len(sources)}.")
# 支持两种模式:
# 1. dis_vols 为单个值:所有源汇总,使用总吸液体积或指定分液体积
# 2. dis_vols 长度等于 asp_vols每个源按不同比例分液按比例混合
@@ -1881,19 +1669,10 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
f"(matching `asp_vols`). Got length {len(dis_vols)}."
)
need_mix_after = mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0
defer_final_discard = need_mix_after or touch_tip
if len(use_channels) == 1:
# 单通道模式:多次吸液,一次分液
# 如果需要 before mix先 pick up tip 并执行 mix
# 先混合前(如果需要)
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
tip = []
for _ in range(len(use_channels)):
tip.extend(self._get_next_tip())
await self.pick_up_tips(tip)
await self.mix(
targets=[target],
mix_time=mix_times,
@@ -1901,16 +1680,13 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
offsets=offsets[0:1] if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None,
use_channels=use_channels,
)
await self.discard_tips(use_channels=use_channels)
# 从每个源容器吸液并分液到目标容器
for idx, source in enumerate(sources):
tip = []
for _ in range(len(use_channels)):
tip.extend(self._get_next_tip())
tip.extend(next(self.current_tip))
await self.pick_up_tips(tip)
await self.aspirate(
@@ -1963,11 +1739,10 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
if delays is not None and len(delays) > 1:
await self.custom_delay(seconds=delays[1])
if not (defer_final_discard and idx == len(sources) - 1):
await self.discard_tips(use_channels=use_channels)
await self.discard_tips(use_channels=use_channels)
# 最后在目标容器中混合(如果需要)
if need_mix_after:
if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0:
await self.mix(
targets=[target],
mix_time=mix_times,
@@ -1975,15 +1750,11 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
offsets=offsets[0:1] if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None,
use_channels=use_channels,
)
if touch_tip:
await self.touch_tip([target])
if defer_final_discard:
await self.discard_tips(use_channels=use_channels)
elif len(use_channels) == 8:
# 8通道模式需要确保源数量是8的倍数
if len(sources) % 8 != 0:
@@ -1991,11 +1762,6 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
# 每次处理8个源
if mix_stage in ["before", "both"] and mix_times is not None and mix_times > 0:
tip = []
for _ in range(len(use_channels)):
tip.extend(self._get_next_tip())
await self.pick_up_tips(tip)
await self.mix(
targets=[target],
mix_time=mix_times,
@@ -2003,15 +1769,12 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
offsets=offsets[0:1] if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None,
use_channels=use_channels,
)
await self.discard_tips([0,1,2,3,4,5,6,7])
for i in range(0, len(sources), 8):
tip = []
for _ in range(len(use_channels)):
tip.extend(self._get_next_tip())
tip.extend(next(self.current_tip))
await self.pick_up_tips(tip)
current_sources = sources[i : i + 8]
@@ -2070,11 +1833,10 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
if delays is not None and len(delays) > 1:
await self.custom_delay(seconds=delays[1])
if not (defer_final_discard and i + 8 >= len(sources)):
await self.discard_tips([0,1,2,3,4,5,6,7])
await self.discard_tips([0, 1, 2, 3, 4, 5, 6, 7])
# 最后在目标容器中混合(如果需要)
if need_mix_after:
if mix_stage in ["after", "both"] and mix_times is not None and mix_times > 0:
await self.mix(
targets=[target],
mix_time=mix_times,
@@ -2082,15 +1844,11 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
offsets=offsets[0:1] if offsets else None,
height_to_bottom=mix_liquid_height if mix_liquid_height else None,
mix_rate=mix_rate if mix_rate else None,
use_channels=use_channels,
)
if touch_tip:
await self.touch_tip([target])
if defer_final_discard:
await self.discard_tips([0,1,2,3,4,5,6,7])
# except Exception as e:
# traceback.print_exc()
# raise RuntimeError(f"Liquid addition failed: {e}") from e
@@ -2109,12 +1867,7 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
print(f"Waiting time: {msg}")
print(f"Current time: {time.strftime('%H:%M:%S')}")
print(f"Time to finish: {time.strftime('%H:%M:%S', time.localtime(time.time() + seconds))}")
# Use ROS node sleep if available, otherwise use asyncio.sleep
if hasattr(self, '_ros_node') and self._ros_node is not None:
await self._ros_node.sleep(seconds)
else:
import asyncio
await asyncio.sleep(seconds)
await self._ros_node.sleep(seconds)
if msg:
print(f"Done: {msg}")
print(f"Current time: {time.strftime('%H:%M:%S')}")
@@ -2152,73 +1905,34 @@ class LiquidHandlerAbstract(LiquidHandlerMiddleware):
height_to_bottom: Optional[float] = None,
offsets: Optional[Coordinate] = None,
mix_rate: Optional[float] = None,
use_channels: Optional[List[int]] = None,
none_keys: List[str] = [],
):
if mix_time is None or mix_time <= 0: # No mixing required
if mix_time is None: # No mixing required
return
"""Mix the liquid in the target wells."""
if mix_vol is None:
raise ValueError("`mix_vol` must be provided when `mix_time` is set.")
targets_list: List[Container] = list(targets)
if len(targets_list) == 0:
return
def _expand(value, count: int):
if value is None:
return [None] * count
if isinstance(value, (list, tuple)):
if len(value) != count:
raise ValueError("Length of per-target parameters must match targets.")
return list(value)
return [value] * count
offsets_list = _expand(offsets, len(targets_list))
heights_list = _expand(height_to_bottom, len(targets_list))
rates_list = _expand(mix_rate, len(targets_list))
for _ in range(mix_time):
for idx, target in enumerate(targets_list):
offset_arg = (
[offsets_list[idx]] if offsets_list[idx] is not None else None
)
height_arg = (
[heights_list[idx]] if heights_list[idx] is not None else None
)
rate_arg = [rates_list[idx]] if rates_list[idx] is not None else None
await self.aspirate(
resources=[target],
vols=[mix_vol],
use_channels=use_channels,
flow_rates=rate_arg,
offsets=offset_arg,
liquid_height=height_arg,
)
await self.custom_delay(seconds=1)
await self.dispense(
resources=[target],
vols=[mix_vol],
use_channels=use_channels,
flow_rates=rate_arg,
offsets=offset_arg,
liquid_height=height_arg,
)
await self.aspirate(
resources=[targets],
vols=[mix_vol],
flow_rates=[mix_rate] if mix_rate else None,
offsets=[offsets] if offsets else None,
liquid_height=[height_to_bottom] if height_to_bottom else None,
)
await self.custom_delay(seconds=1)
await self.dispense(
resources=[targets],
vols=[mix_vol],
flow_rates=[mix_rate] if mix_rate else None,
offsets=[offsets] if offsets else None,
liquid_height=[height_to_bottom] if height_to_bottom else None,
)
def iter_tips(self, tip_racks: Sequence[TipRack]) -> Iterator[Resource]:
"""Yield tips from a list of TipRacks one-by-one until depleted."""
for rack in tip_racks:
for tip in rack:
yield tip
# raise RuntimeError("Out of tips!")
def _get_next_tip(self):
"""从 current_tip 迭代器获取下一个 tip耗尽时抛出明确错误而非 StopIteration"""
try:
return next(self.current_tip)
except StopIteration as e:
raise RuntimeError("Tip rack exhausted: no more tips available for transfer") from e
raise RuntimeError("Out of tips!")
def set_tiprack(self, tip_racks: Sequence[TipRack]):
"""Set the tip racks for the liquid handler."""

View File

@@ -45,7 +45,6 @@ from pylabrobot.resources import (
Trash,
PlateAdapter,
TubeRack,
create_homogeneous_resources,
)
from unilabos.devices.liquid_handling.liquid_handler_abstract import (
@@ -56,7 +55,6 @@ from unilabos.devices.liquid_handling.liquid_handler_abstract import (
TransferLiquidReturn,
)
from unilabos.registry.placeholder_type import ResourceSlot
from unilabos.resources.itemized_carrier import ItemizedCarrier
from unilabos.resources.resource_tracker import ResourceTreeSet
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
@@ -93,15 +91,15 @@ class PRCXI9300Deck(Deck):
该类定义了 PRCXI 9300 的工作台布局和槽位信息。
"""
# T1-T16 默认位置 (4列×4行, Y轴从上往下递减, T1在左上角)
# T1-T16 默认位置 (4列×4行)
_DEFAULT_SITE_POSITIONS = [
(0, 288, 0), (138, 288, 0), (276, 288, 0), (414, 288, 0), # T1-T4 (第1行, 最上)
(0, 192, 0), (138, 192, 0), (276, 192, 0), (414, 192, 0), # T5-T8 (第2行)
(0, 96, 0), (138, 96, 0), (276, 96, 0), (414, 96, 0), # T9-T12 (第3行)
(0, 0, 0), (138, 0, 0), (276, 0, 0), (414, 0, 0), # T13-T16 (第4行, 最下)
(0, 0, 0), (138, 0, 0), (276, 0, 0), (414, 0, 0), # T1-T4
(0, 96, 0), (138, 96, 0), (276, 96, 0), (414, 96, 0), # T5-T8
(0, 192, 0), (138, 192, 0), (276, 192, 0), (414, 192, 0), # T9-T12
(0, 288, 0), (138, 288, 0), (276, 288, 0), (414, 288, 0), # T13-T16
]
_DEFAULT_SITE_SIZE = {"width": 128.0, "height": 86, "depth": 0}
_DEFAULT_CONTENT_TYPE = ["plate", "tip_rack", "plates", "tip_racks", "tube_rack", "adaptor", "plateadapter", "module"]
_DEFAULT_CONTENT_TYPE = ["plate", "tip_rack", "plates", "tip_racks", "tube_rack", "adaptor"]
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
sites: Optional[List[Dict[str, Any]]] = None, **kwargs):
@@ -546,108 +544,6 @@ class PRCXI9300TubeRack(TubeRack):
return data
class PRCXI9300ModuleSite(ItemizedCarrier):
"""
PRCXI 功能模块的基础站点类(加热/冷却/震荡/磁吸等)。
- 继承 ItemizedCarrier可被拖放到 Deck 槽位上
- 顶面有一个 ResourceHolder 站点,可吸附板类资源(叠放)
- content_type 包含 "plateadapter" 以支持适配器叠放
- 支持 material_info 注入
"""
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
material_info: Optional[Dict[str, Any]] = None, **kwargs):
sites = create_homogeneous_resources(
klass=ResourceHolder,
locations=[Coordinate(0, 0, 0)],
resource_size_x=size_x,
resource_size_y=size_y,
resource_size_z=size_z,
name_prefix=name,
)[0]
kwargs.pop('layout', None)
sites_in = kwargs.pop('sites', None)
sites_dict = {name: sites}
content_type = [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"plateadapter",
]
if sites_in is not None and isinstance(sites_in, dict):
for site_key, site_value in sites_in.items():
if site_key in sites_dict:
sites_dict[site_key] = site_value
super().__init__(
name, size_x, size_y, size_z,
sites=sites_dict,
num_items_x=kwargs.pop('num_items_x', 1),
num_items_y=kwargs.pop('num_items_y', 1),
num_items_z=kwargs.pop('num_items_z', 1),
content_type=content_type,
**kwargs,
)
self._unilabos_state = {}
if material_info:
self._unilabos_state["Material"] = material_info
def assign_child_resource(self, resource, location=Coordinate(0, 0, 0), reassign=True, spot=None):
from pylabrobot.resources.resource import Resource
Resource.assign_child_resource(self, resource, location=location, reassign=reassign)
def unassign_child_resource(self, resource):
from pylabrobot.resources.resource import Resource
Resource.unassign_child_resource(self, resource)
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
try:
data = super().serialize_state()
except AttributeError:
data = {}
if hasattr(self, 'sites') and self.sites:
sites_info = []
for site in self.sites:
if hasattr(site, '__class__') and 'pylabrobot' in str(site.__class__.__module__):
sites_info.append({
"__pylabrobot_object__": True,
"class": site.__class__.__name__,
"module": site.__class__.__module__,
"name": getattr(site, 'name', str(site))
})
else:
sites_info.append(site)
data['sites'] = sites_info
if hasattr(self, "_unilabos_state") and self._unilabos_state:
safe_state: Dict[str, Any] = {}
for k, v in self._unilabos_state.items():
if k == "Material" and isinstance(v, dict):
safe_material: Dict[str, Any] = {}
for mk, mv in v.items():
if isinstance(mv, (str, int, float, bool, list, dict, type(None))):
safe_material[mk] = mv
safe_state[k] = safe_material
elif isinstance(v, (str, int, float, bool, list, dict, type(None))):
safe_state[k] = v
data.update(safe_state)
return data
def load_state(self, state: Dict[str, Any]) -> None:
super().load_state(state)
if 'sites' in state:
self.sites = [state['sites']]
class PRCXI9300PlateAdapter(PlateAdapter):
"""
专用板式适配器类:用于承载 Plate 的底座(如 PCR 适配器、磁吸架等)。

View File

@@ -1,150 +0,0 @@
from typing import Any, Dict, Optional
from .prcxi import PRCXI9300ModuleSite
class PRCXI9300FunctionalModule(PRCXI9300ModuleSite):
"""
PRCXI 9300 功能模块基类(加热/冷却/震荡/加热震荡/磁吸等)。
设计目标:
- 作为一个可以在工作台上拖拽摆放的实体资源(继承自 PRCXI9300ModuleSite -> ItemizedCarrier
- 顶面存在一个站点site可吸附标准板类资源plate / tip_rack / tube_rack 等)。
- 支持注入 `material_info` (UUID 等),并且在 serialize_state 时做安全过滤。
"""
def __init__(
self,
name: str,
size_x: float,
size_y: float,
size_z: float,
module_type: Optional[str] = None,
category: str = "module",
model: Optional[str] = None,
material_info: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
super().__init__(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
material_info=material_info,
model=model,
category=category,
**kwargs,
)
# 记录模块类型(加热 / 冷却 / 震荡 / 加热震荡 / 磁吸)
self.module_type = module_type or "generic"
# 与 PRCXI9300PlateAdapter 一致,使用 _unilabos_state 保存扩展信息
if not hasattr(self, "_unilabos_state") or self._unilabos_state is None:
self._unilabos_state = {}
# super().__init__ 已经在有 material_info 时写入 "Material",这里仅确保存在
if material_info is not None and "Material" not in self._unilabos_state:
self._unilabos_state["Material"] = material_info
# 额外标记 category 和模块类型,便于前端或上层逻辑区分
self._unilabos_state.setdefault("category", category)
self._unilabos_state["module_type"] = module_type
# ============================================================================
# 具体功能模块定义
# 这里的尺寸和 material_info 目前为占位参数,后续可根据实际测量/JSON 配置进行更新。
# 顶面站点尺寸与模块外形一致,保证可以吸附标准 96 板/储液槽等。
# ============================================================================
def PRCXI_Heating_Module(name: str) -> PRCXI9300FunctionalModule:
"""加热模块(顶面可吸附标准板)。"""
return PRCXI9300FunctionalModule(
name=name,
size_x=127.76,
size_y=85.48,
size_z=40.0,
module_type="heating",
model="PRCXI_Heating_Module",
material_info={
"uuid": "TODO-HEATING-MODULE-UUID",
"Code": "HEAT-MOD",
"Name": "PRCXI 加热模块",
"SupplyType": 3,
},
)
def PRCXI_MetalCooling_Module(name: str) -> PRCXI9300FunctionalModule:
"""金属冷却模块(顶面可吸附标准板)。"""
return PRCXI9300FunctionalModule(
name=name,
size_x=127.76,
size_y=85.48,
size_z=40.0,
module_type="metal_cooling",
model="PRCXI_MetalCooling_Module",
material_info={
"uuid": "TODO-METAL-COOLING-MODULE-UUID",
"Code": "METAL-COOL-MOD",
"Name": "PRCXI 金属冷却模块",
"SupplyType": 3,
},
)
def PRCXI_Shaking_Module(name: str) -> PRCXI9300FunctionalModule:
"""震荡模块(顶面可吸附标准板)。"""
return PRCXI9300FunctionalModule(
name=name,
size_x=127.76,
size_y=85.48,
size_z=50.0,
module_type="shaking",
model="PRCXI_Shaking_Module",
material_info={
"uuid": "TODO-SHAKING-MODULE-UUID",
"Code": "SHAKE-MOD",
"Name": "PRCXI 震荡模块",
"SupplyType": 3,
},
)
def PRCXI_Heating_Shaking_Module(name: str) -> PRCXI9300FunctionalModule:
"""加热震荡模块(顶面可吸附标准板)。"""
return PRCXI9300FunctionalModule(
name=name,
size_x=127.76,
size_y=85.48,
size_z=55.0,
module_type="heating_shaking",
model="PRCXI_Heating_Shaking_Module",
material_info={
"uuid": "TODO-HEATING-SHAKING-MODULE-UUID",
"Code": "HEAT-SHAKE-MOD",
"Name": "PRCXI 加热震荡模块",
"SupplyType": 3,
},
)
def PRCXI_Magnetic_Module(name: str) -> PRCXI9300FunctionalModule:
"""磁吸模块(顶面可吸附标准板)。"""
return PRCXI9300FunctionalModule(
name=name,
size_x=127.76,
size_y=85.48,
size_z=30.0,
module_type="magnetic",
model="PRCXI_Magnetic_Module",
material_info={
"uuid": "TODO-MAGNETIC-MODULE-UUID",
"Code": "MAG-MOD",
"Name": "PRCXI 磁吸模块",
"SupplyType": 3,
},
)

View File

@@ -57,7 +57,7 @@ class VirtualSampleDemo:
readings.append(round(random.uniform(0.1, 1.0), 4))
samples.append(idx)
return {"volumes": out_volumes, "readings": readings, "unilabos_samples": samples}
return {"volumes": out_volumes, "readings": readings, "samples": samples}
# ------------------------------------------------------------------
# Action 3: 入参和出参都带 samples 列(不等长)
@@ -78,7 +78,7 @@ class VirtualSampleDemo:
scores.append(score)
passed.append(r >= threshold)
return {"scores": scores, "passed": passed, "unilabos_samples": samples}
return {"scores": scores, "passed": passed, "samples": samples}
# ------------------------------------------------------------------
# 状态属性

View File

@@ -1,9 +1,5 @@
# 工作站抽象基类物料系统架构说明
## 设计理念
基于用户需求"请你帮我系统思考一下,工作站抽象基类的物料系统基类该如何构建",我们最终确定了一个**PyLabRobot Deck为中心**的简化架构。
### 核心原则
1. **PyLabRobot为物料管理核心**使用PyLabRobot的Deck系统作为物料管理的基础利用其成熟的Resource体系

View File

@@ -0,0 +1,113 @@
# Bioyond Cell 工作站 - 多订单返回示例
本文档说明了 `create_orders` 函数如何收集并返回所有订单的完成报文。
## 问题描述
之前的实现只会等待并返回第一个订单的完成报文,如果有多个订单(例如从 Excel 解析出 3 个订单),只能得到第一个订单的推送信息。
## 解决方案
修改后的 `create_orders` 函数现在会:
1. **提取所有 orderCode**:从 LIMS 接口返回的 `data` 列表中提取所有订单编号
2. **逐个等待完成**:遍历所有 orderCode调用 `wait_for_order_finish` 等待每个订单完成
3. **收集所有报文**:将每个订单的完成报文存入 `all_reports` 列表
4. **统一返回**:返回包含所有订单报文的 JSON 格式数据
## 返回格式
```json
{
"status": "all_completed",
"total_orders": 3,
"reports": [
{
"token": "",
"request_time": "2025-12-24T15:32:09.2148671+08:00",
"data": {
"orderId": "3a1e614d-a082-c44a-60be-68647a35e6f1",
"orderCode": "BSO2025122400024",
"orderName": "DP20251224001",
"status": "30",
"workflowStatus": "completed",
"usedMaterials": [...]
}
},
{
"token": "",
"request_time": "2025-12-24T15:32:09.9999039+08:00",
"data": {
"orderId": "3a1e614d-a0a2-f7a9-9360-610021c9479d",
"orderCode": "BSO2025122400025",
"orderName": "DP20251224002",
"status": "30",
"workflowStatus": "completed",
"usedMaterials": [...]
}
},
{
"token": "",
"request_time": "2025-12-24T15:34:00.4139986+08:00",
"data": {
"orderId": "3a1e614d-a0cd-81ca-9f7f-2f4e93af01cd",
"orderCode": "BSO2025122400026",
"orderName": "DP20251224003",
"status": "30",
"workflowStatus": "completed",
"usedMaterials": [...]
}
}
],
"original_response": {...}
}
```
## 使用示例
```python
# 调用 create_orders
result = workstation.create_orders("20251224.xlsx")
# 访问返回数据
print(f"总订单数: {result['total_orders']}")
print(f"状态: {result['status']}")
# 遍历所有订单的报文
for i, report in enumerate(result['reports'], 1):
order_data = report.get('data', {})
print(f"\n订单 {i}:")
print(f" orderCode: {order_data.get('orderCode')}")
print(f" orderName: {order_data.get('orderName')}")
print(f" status: {order_data.get('status')}")
print(f" 使用物料数: {len(order_data.get('usedMaterials', []))}")
```
## 控制台输出示例
```
[create_orders] 即将提交订单数量: 3
[create_orders] 接口返回: {...}
[create_orders] 等待 3 个订单完成: ['BSO2025122400024', 'BSO2025122400025', 'BSO2025122400026']
[create_orders] 正在等待第 1/3 个订单: BSO2025122400024
[create_orders] ✓ 订单 BSO2025122400024 完成
[create_orders] 正在等待第 2/3 个订单: BSO2025122400025
[create_orders] ✓ 订单 BSO2025122400025 完成
[create_orders] 正在等待第 3/3 个订单: BSO2025122400026
[create_orders] ✓ 订单 BSO2025122400026 完成
[create_orders] 所有订单已完成,共收集 3 个报文
实验记录本========================create_orders========================
返回报文数量: 3
报文 1: orderCode=BSO2025122400024, status=30
报文 2: orderCode=BSO2025122400025, status=30
报文 3: orderCode=BSO2025122400026, status=30
========================
```
## 关键改进
1.**等待所有订单**:不再只等待第一个订单,而是遍历所有 orderCode
2.**收集完整报文**:每个订单的完整推送报文都被保存在 `reports` 数组中
3.**详细日志**:清晰显示正在等待哪个订单,以及完成情况
4.**错误处理**:即使某个订单失败,也会记录其状态信息
5.**统一格式**:返回的 JSON 格式便于后续处理和分析

View File

@@ -0,0 +1,204 @@
# BioyondCellWorkstation JSON 配置迁移经验总结
**日期**: 2026-01-13
**目的**: 从 `config.py` 迁移到 JSON 配置文件
---
## 问题背景
原系统通过 `config.py` 管理配置,导致:
1. HTTP 服务重复启动(父类 `BioyondWorkstation` 和子类都启动)
2. 配置分散在代码中,不便于管理
3. 无法通过 JSON 统一配置所有参数
---
## 解决方案:嵌套配置结构
### JSON 结构设计
**正确示例** (嵌套在 `config` 中):
```json
{
"nodes": [{
"id": "bioyond_cell_workstation",
"config": {
"deck": {...},
"protocol_type": [],
"bioyond_config": {
"api_host": "http://172.16.11.219:44388",
"api_key": "8A819E5C",
"timeout": 30,
"HTTP_host": "172.16.11.206",
"HTTP_port": 8080,
"debug_mode": false,
"material_type_mappings": {...},
"warehouse_mapping": {...},
"solid_liquid_mappings": {...}
}
},
"data": {}
}]
}
```
**关键点**
-`bioyond_config` 放在 `config` 中(会传递到 `__init__`
- ❌ **不要**放在 `data` 中(`data` 是运行时状态,不会传递)
---
## Python 代码适配
### 1. 修改 `BioyondCellWorkstation.__init__` 签名
**文件**: `bioyond_cell_workstation.py`
```python
def __init__(self, bioyond_config: dict = None, deck=None, protocol_type=None, **kwargs):
"""
Args:
bioyond_config: 从 JSON 加载的配置字典
deck: Deck 配置
protocol_type: 协议类型
"""
# 验证配置
if bioyond_config is None:
raise ValueError("需要 bioyond_config 参数")
# 保存配置
self.bioyond_config = bioyond_config
# 设置 HTTP 服务去重标志
self.bioyond_config["_disable_auto_http_service"] = True
# 调用父类
super().__init__(bioyond_config=self.bioyond_config, deck=deck, **kwargs)
```
### 2. 替换全局变量引用
**修改前**(使用全局变量):
```python
from config import MATERIAL_TYPE_MAPPINGS, WAREHOUSE_MAPPING
def create_sample(self, board_type, ...):
carrier_type_id = MATERIAL_TYPE_MAPPINGS[board_type][1]
location_id = WAREHOUSE_MAPPING[warehouse_name]["site_uuids"][location_code]
```
**修改后**(从配置读取):
```python
def create_sample(self, board_type, ...):
carrier_type_id = self.bioyond_config['material_type_mappings'][board_type][1]
location_id = self.bioyond_config['warehouse_mapping'][warehouse_name]["site_uuids"][location_code]
```
### 3. 修复父类配置访问
`station.py` 中安全访问配置默认值:
```python
# 修改前(会 KeyError
self._http_service_config = {
"host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG["http_service_host"])
}
# 修改后(安全访问)
self._http_service_config = {
"host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG.get("http_service_host", ""))
}
```
---
## 常见陷阱
### ❌ 错误1将配置放在 `data` 字段
```json
"config": {"deck": {...}},
"data": {"bioyond_config": {...}} // ❌ 不会传递到 __init__
```
### ❌ 错误2扁平化配置已废弃方案
虽然扁平化也能工作,但不推荐:
```json
"config": {
"deck": {...},
"api_host": "...", // ❌ 不够清晰
"api_key": "...",
"HTTP_host": "..."
}
```
### ❌ 错误3忘记替换全局变量引用
代码中直接使用 `MATERIAL_TYPE_MAPPINGS` 等全局变量会导致 `NameError`
---
## 云端同步注意事项
使用 `--upload_registry` 时,云端配置可能覆盖本地配置:
- 首次上传时确保 JSON 完整
- 或使用新的 `ak/sk` 避免旧配置干扰
- 调试时可暂时移除 `--upload_registry` 参数
---
## 验证清单
启动成功后应看到:
```
✅ 从 JSON 配置加载 bioyond_config 成功
API Host: http://...
HTTP Service: ...
✅ BioyondCellWorkstation 初始化完成
Loaded ResourceTreeSet with ... nodes
```
运行时不应出现:
-`NameError: name 'MATERIAL_TYPE_MAPPINGS' is not defined`
-`KeyError: 'http_service_host'`
-`bioyond_config 缺少必需参数`
---
## 调试经验
1. **添加调试日志**查看参数传递链路:
- `graphio.py`: JSON 加载后的 config 内容
- `initialize_device.py`: `device_config.res_content.config` 的键
- `bioyond_cell_workstation.py`: `__init__` 接收到的参数
2. **config vs data 区别**
- `config`: 初始化参数,传递给 `__init__`
- `data`: 运行时状态,不传递给 `__init__`
3. **参数名必须匹配**
- JSON 中的键名必须与 `__init__` 参数名完全一致
4. **调试代码清理**:完成后记得删除调试日志(🔍 DEBUG 标记)
---
## 修改文件清单
| 文件 | 修改内容 |
|------|----------|
| `yibin_electrolyte_config.json` | 创建嵌套 `config.bioyond_config` 结构 |
| `bioyond_cell_workstation.py` | 修改 `__init__` 接收 `bioyond_config`,替换所有全局变量引用 |
| `station.py` | 安全访问 `HTTP_SERVICE_CONFIG` 默认值 |
---
## 参考代码位置
- JSON 配置示例: `yibin_electrolyte_config.json` L12-L353
- `__init__` 实现: `bioyond_cell_workstation.py` L39-L94
- 全局变量替换示例: `bioyond_cell_workstation.py` L2005, L1863, L1966
- HTTP 服务配置: `station.py` L629-L634
---
**总结**: 使用嵌套结构将所有配置放在 `config.bioyond_config` 中,修改 `__init__` 直接接收该参数,并替换所有全局变量引用为 `self.bioyond_config` 访问。

View File

@@ -0,0 +1,312 @@
# BioyondCell 配置迁移修改总结
**日期**: 2026-01-13
**目标**: 从 `config.py` 完全迁移到 JSON 配置,消除所有全局变量依赖
---
## 📋 修改概览
本次修改完成了 BioyondCell 模块从 Python 配置文件到 JSON 配置的完整迁移,并清理了所有对 `config.py` 全局变量的依赖。
### 核心成果
- ✅ 完全移除对 `config.py` 的导入依赖
- ✅ 使用嵌套 JSON 结构 `config.bioyond_config`
- ✅ 修复 7 处 `bioyond_cell_workstation.py` 中的全局变量引用
- ✅ 修复 3 处其他文件中的全局变量引用
- ✅ HTTP 服务去重机制完善
- ✅ 系统成功启动并正常运行
---
## 🔧 修改文件清单
### 1. JSON 配置文件
**文件**: `yibin_electrolyte_config.json`
**修改**:
- 采用嵌套结构将所有配置放在 `config.bioyond_config`
- 包含:`api_host`, `api_key`, `HTTP_host`, `HTTP_port`, `material_type_mappings`, `warehouse_mapping`, `solid_liquid_mappings`
**示例结构**:
```json
{
"nodes": [{
"id": "bioyond_cell_workstation",
"config": {
"deck": {...},
"protocol_type": [],
"bioyond_config": {
"api_host": "http://172.16.11.219:44388",
"api_key": "8A819E5C",
"HTTP_host": "172.16.11.206",
"HTTP_port": 8080,
"material_type_mappings": {...},
"warehouse_mapping": {...},
"solid_liquid_mappings": {...}
}
}
}]
}
```
---
### 2. bioyond_cell_workstation.py
**位置**: `unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py`
#### 修改 A: `__init__` 方法签名 (L39-99)
**修改前**:
```python
def __init__(self, deck=None, protocol_type=None, **kwargs):
# 从 kwargs 收集配置字段
self.bioyond_config = {}
for field in bioyond_field_names:
if field in kwargs:
self.bioyond_config[field] = kwargs.pop(field)
```
**修改后**:
```python
def __init__(self, bioyond_config: dict = None, deck=None, protocol_type=None, **kwargs):
"""直接接收 bioyond_config 参数"""
if bioyond_config is None:
raise ValueError("需要 bioyond_config 参数")
self.bioyond_config = bioyond_config
# 设置 HTTP 服务去重标志
self.bioyond_config["_disable_auto_http_service"] = True
super().__init__(bioyond_config=self.bioyond_config, deck=deck, **kwargs)
```
#### 修改 B: 替换全局变量引用 (7 处)
| 位置 | 原代码 | 修改后 |
|------|--------|--------|
| L2005 | `MATERIAL_TYPE_MAPPINGS[board_type][1]` | `self.bioyond_config['material_type_mappings'][board_type][1]` |
| L2006 | `MATERIAL_TYPE_MAPPINGS[bottle_type][1]` | `self.bioyond_config['material_type_mappings'][bottle_type][1]` |
| L2009 | `WAREHOUSE_MAPPING` | `self.bioyond_config['warehouse_mapping']` |
| L2013 | `WAREHOUSE_MAPPING[warehouse_name]` | `self.bioyond_config['warehouse_mapping'][warehouse_name]` |
| L2017 | `WAREHOUSE_MAPPING[warehouse_name]["site_uuids"]` | `self.bioyond_config['warehouse_mapping'][warehouse_name]["site_uuids"]` |
| L1863 | `SOLID_LIQUID_MAPPINGS.get(material_name)` | `self.bioyond_config.get('solid_liquid_mappings', {}).get(material_name)` |
| L1966, L1976 | `MATERIAL_TYPE_MAPPINGS.items()` | `self.bioyond_config['material_type_mappings'].items()` |
---
### 3. station.py
**位置**: `unilabos/devices/workstation/bioyond_studio/station.py`
#### 修改 A: 删除 config 导入 (L26-28)
**修改前**:
```python
from unilabos.devices.workstation.bioyond_studio.config import (
API_CONFIG, WORKFLOW_MAPPINGS, MATERIAL_TYPE_MAPPINGS, WAREHOUSE_MAPPING, HTTP_SERVICE_CONFIG
)
```
**修改后**:
```python
# 已删除此导入
```
#### 修改 B: `_create_communication_module` 方法 (L691-702)
**修改前**:
```python
def _create_communication_module(self, config: Optional[Dict[str, Any]] = None) -> None:
default_config = {
**API_CONFIG,
"workflow_mappings": WORKFLOW_MAPPINGS,
"material_type_mappings": MATERIAL_TYPE_MAPPINGS,
"warehouse_mapping": WAREHOUSE_MAPPING
}
if config:
self.bioyond_config = {**default_config, **config}
else:
self.bioyond_config = default_config
```
**修改后**:
```python
def _create_communication_module(self, config: Optional[Dict[str, Any]] = None) -> None:
"""创建Bioyond通信模块"""
# 使用传入的 config 参数(来自 bioyond_config
# 不再依赖全局变量 API_CONFIG 等
if config:
self.bioyond_config = config
else:
# 如果没有传入配置,创建空配置(用于测试或兼容性)
self.bioyond_config = {}
self.hardware_interface = BioyondV1RPC(self.bioyond_config)
```
#### 修改 C: HTTP 服务配置 (L627-632)
**修改前**:
```python
self._http_service_config = {
"host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG.get("http_service_host", "")),
"port": bioyond_config.get("http_service_port", HTTP_SERVICE_CONFIG.get("http_service_port", 0))
}
```
**修改后**:
```python
self._http_service_config = {
"host": bioyond_config.get("http_service_host", bioyond_config.get("HTTP_host", "")),
"port": bioyond_config.get("http_service_port", bioyond_config.get("HTTP_port", 0))
}
```
---
### 4. bioyond_rpc.py
**位置**: `unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py`
#### 修改 A: 删除 config 导入 (L12)
**修改前**:
```python
from unilabos.devices.workstation.bioyond_studio.config import LOCATION_MAPPING
```
**修改后**:
```python
# 已删除此导入
```
#### 修改 B: `material_outbound` 方法 (L278-280)
**修改前**:
```python
def material_outbound(self, material_id: str, location_name: str, quantity: int) -> dict:
"""指定库位出库物料(通过库位名称)"""
location_id = LOCATION_MAPPING.get(location_name, location_name)
```
**修改后**:
```python
def material_outbound(self, material_id: str, location_name: str, quantity: int) -> dict:
"""指定库位出库物料(通过库位名称)"""
# location_name 参数实际上应该直接是 location_id (UUID)
location_id = location_name
```
**说明**: `LOCATION_MAPPING``config-0113.py` 中本来就是空字典 `{}`,所以直接使用 `location_name` 逻辑等价。
---
## 🎯 关键设计决策
### 1. 嵌套 vs 扁平配置
**选择**: 嵌套结构 `config.bioyond_config`
**理由**:
- ✅ 语义清晰,配置分组明确
- ✅ 参数传递直观,直接对应 `__init__` 参数
- ✅ 易于维护,不需要硬编码字段列表
- ✅ 符合 UniLab 设计模式
### 2. HTTP 服务去重
**实现**: 子类设置 `_disable_auto_http_service` 标志
```python
# bioyond_cell_workstation.py
self.bioyond_config["_disable_auto_http_service"] = True
# station.py (post_init)
if self.bioyond_config.get("_disable_auto_http_service"):
logger.info("子类已自行管理HTTP服务跳过自动启动")
return
```
### 3. 全局变量替换策略
**原则**: 所有配置从 `self.bioyond_config` 获取
**模式**:
```python
# 修改前
from config import MATERIAL_TYPE_MAPPINGS
carrier_type_id = MATERIAL_TYPE_MAPPINGS[board_type][1]
# 修改后
carrier_type_id = self.bioyond_config['material_type_mappings'][board_type][1]
```
---
## ✅ 验证结果
### 启动成功日志
```
✅ 从 JSON 配置加载 bioyond_config 成功
API Host: http://172.16.11.219:44388
HTTP Service: 172.16.11.206:8080
🔧 已设置 _disable_auto_http_service 标志,防止 HTTP 服务重复启动
✅ BioyondCellWorkstation 初始化完成
Loaded ResourceTreeSet with 1 trees, 1785 total nodes
```
### 功能验证
- ✅ 订单创建 (`create_orders_v2`)
- ✅ 质量比计算
- ✅ 物料转移 (`transfer_3_to_2_to_1`)
- ✅ HTTP 报送接收 (step_finish, sample_finish, order_finish)
- ✅ 等待机制 (`wait_for_order_finish`)
- ✅ 仓库 UUID 映射
- ✅ 物料类型映射
---
## 📚 相关文档
- **配置迁移经验**: `2026-01-13_JSON配置迁移经验.md`
- **任务清单**: `C:\Users\AndyXie\.gemini\antigravity\brain\...\task.md`
- **实施计划**: `C:\Users\AndyXie\.gemini\antigravity\brain\...\implementation_plan.md`
---
## ⚠️ 注意事项
### 其他工作站模块
以下文件仍在使用 `config.py` 全局变量(未包含在本次修改中):
- `reaction_station.py` - 使用 `API_CONFIG`
- `experiment.py` - 使用 `API_CONFIG`, `WORKFLOW_MAPPINGS`, `MATERIAL_TYPE_MAPPINGS`
- `dispensing_station.py` - 使用 `API_CONFIG`, `WAREHOUSE_MAPPING`
- `station.py` L176, L177, L529, L530 - 动态导入 `WAREHOUSE_MAPPING`
**建议**: 后续可以统一迁移这些模块到 JSON 配置。
### config.py 文件
`config.py` 文件已恢复但**不再被 bioyond_cell 使用**。可以:
- 保留作为其他模块的参考
- 或者完全删除(如果其他模块也迁移完成)
---
## 🚀 下一步建议
1. **清理调试代码** ✅ (已完成)
2. **提交代码到 Git**
3. **迁移其他工作站模块** (可选)
4. **更新文档和启动脚本**
---
**修改完成日期**: 2026-01-13
**系统状态**: ✅ 稳定运行

View File

@@ -0,0 +1,157 @@
# 批量出库 Excel 模板使用说明
**文件**: `outbound_template.xlsx`
**用途**: 配合 `auto_batch_outbound_from_xlsx()` 方法进行批量出库操作
**API 端点**: `/api/lims/storage/auto-batch-out-bound`
---
## 📋 Excel 列说明
| 列名 | 说明 | 示例 | 必填 |
|------|------|------|------|
| `locationId` | **库位 IDUUID** | `3a19da43-57b5-294f-d663-154a1cc32270` | ✅ 是 |
| `warehouseId` | **仓库 ID 或名称** | `配液站内试剂仓库` | ✅ 是 |
| `quantity` | **出库数量** | `1.0`, `2.0` | ✅ 是 |
| `x` | **X 坐标(库位横向位置)** | `1`, `2`, `3` | ✅ 是 |
| `y` | **Y 坐标(库位纵向位置)** | `1`, `2`, `3` | ✅ 是 |
| `z` | **Z 坐标(库位层数/高度)** | `1`, `2`, `3` | ✅ 是 |
| `备注说明` | 可选备注信息 | `配液站内试剂仓库-A01` | ❌ 否 |
### 📐 坐标说明
**x, y, z** 是库位在仓库内的**三维坐标**
```
仓库(例如 WH4
├── Z=1第1层/加样头面)
│ ├── X=1, Y=1位置 A
│ ├── X=2, Y=1位置 B
│ ├── X=3, Y=1位置 C
│ └── ...
└── Z=2第2层/原液瓶面)
├── X=1, Y=1位置 A
├── X=2, Y=1位置 B
└── ...
```
- **warehouseId**: 指定哪个仓库WH3, WH4, 配液站等)
- **x, y, z**: 在该仓库内的三维坐标
- **locationId**: 该坐标位置的唯一 UUID
### 🎯 起点与终点
**重要说明**:批量出库模板**只规定了出库的"起点"**(从哪里取物料),**没有指定"终点"**(放到哪里)。
```
出库流程:
起点Excel 指定) → 终点LIMS/工作流决定)
locationId, x, y, z → 由 LIMS 系统或当前工作流自动分配
```
**终点由以下方式确定:**
- **LIMS 系统自动分配**:根据当前任务自动规划目标位置
- **工作流预定义**:在创建出库任务时已绑定目标位置
- **暂存区**:默认放到出库暂存区,等待下一步操作
💡 **对比**:上料操作(`auto_feeding4to3`)则有 `targetWH` 参数可以指定目标仓库
---
## 🔍 如何获取 UUID
### 方法 1从配置文件获取
参考 `yibin_electrolyte_config.json` 中的 `warehouse_mapping`
```json
{
"warehouse_mapping": {
"配液站内试剂仓库": {
"site_uuids": {
"A01": "3a19da43-57b5-294f-d663-154a1cc32270",
"B01": "3a19da43-57b5-7394-5f49-54efe2c9bef2",
"C01": "3a19da43-57b5-5e75-552f-8dbd0ad1075f"
}
},
"手动堆栈": {
"site_uuids": {
"A01": "3a19deae-2c7a-36f5-5e41-02c5b66feaea",
"A02": "3a19deae-2c7a-dc6d-c41e-ef285d946cfe"
}
}
}
}
```
### 方法 2通过 API 查询
```python
material_info = hardware_interface.material_id_query(workflow_id)
locations = material_info.get("locations", [])
```
---
## 📝 填写示例
### 示例 1从配液站内试剂仓库出库
| locationId | warehouseId | quantity | x | y | z | 备注说明 |
|------------|-------------|----------|---|---|---|----------|
| `3a19da43-57b5-294f-d663-154a1cc32270` | 配液站内试剂仓库 | 1 | 1 | 1 | 1 | A01 位置 |
| `3a19da43-57b5-7394-5f49-54efe2c9bef2` | 配液站内试剂仓库 | 2 | 2 | 1 | 1 | B01 位置 |
### 示例 2从手动堆栈出库
| locationId | warehouseId | quantity | x | y | z | 备注说明 |
|------------|-------------|----------|---|---|---|----------|
| `3a19deae-2c7a-36f5-5e41-02c5b66feaea` | 手动堆栈 | 1 | 1 | 1 | 1 | A01 |
| `3a19deae-2c7a-dc6d-c41e-ef285d946cfe` | 手动堆栈 | 1 | 1 | 2 | 1 | A02 |
---
## 💻 使用方法
```python
from bioyond_cell_workstation import BioyondCellWorkstation
# 初始化工作站
workstation = BioyondCellWorkstation(config=config, deck=deck)
# 调用批量出库方法
result = workstation.auto_batch_outbound_from_xlsx(
xlsx_path="outbound_template.xlsx"
)
```
---
## ⚠️ 注意事项
1. **locationId 必须是有效的 UUID**,不能使用库位名称
2. **x, y, z 坐标必须与 locationId 对应**,表示该库位在仓库内的位置
3. **quantity 必须是数字**,可以是整数或浮点数
4. Excel 文件必须包含表头行
5. 空行会被自动跳过
6. 确保 UUID 与实际库位对应,否则 API 会报错
---
## 📚 相关文件
- **配置文件**: `yibin_electrolyte_config.json`
- **Python 代码**: `bioyond_cell_workstation.py` (L630-695)
- **生成脚本**: `create_outbound_template.py`
- **上料模板**: `material_template.xlsx`
---
## 🔄 重新生成模板
```bash
conda activate newunilab
python create_outbound_template.py
```

View File

@@ -9,7 +9,7 @@ from datetime import datetime, timezone
from unilabos.device_comms.rpc import BaseRequest
from typing import Optional, List, Dict, Any
import json
from unilabos.devices.workstation.bioyond_studio.config import LOCATION_MAPPING
class SimpleLogger:
@@ -49,6 +49,14 @@ class BioyondV1RPC(BaseRequest):
self.config = config
self.api_key = config["api_key"]
self.host = config["api_host"]
# 初始化 location_mapping
# 直接从 warehouse_mapping 构建,确保数据源所谓的单一和结构化
self.location_mapping = {}
warehouse_mapping = self.config.get("warehouse_mapping", {})
for warehouse_name, warehouse_config in warehouse_mapping.items():
if "site_uuids" in warehouse_config:
self.location_mapping.update(warehouse_config["site_uuids"])
self._logger = SimpleLogger()
self.material_cache = {}
self._load_material_cache()
@@ -176,7 +184,40 @@ class BioyondV1RPC(BaseRequest):
return {}
print(f"add material data: {response['data']}")
return response.get("data", {})
# 自动更新缓存
data = response.get("data", {})
if data:
if isinstance(data, str):
# 如果返回的是字符串通常是ID
mat_id = data
name = params.get("name")
else:
# 如果返回的是字典尝试获取name和id
name = data.get("name") or params.get("name")
mat_id = data.get("id")
if name and mat_id:
self.material_cache[name] = mat_id
print(f"已自动更新缓存: {name} -> {mat_id}")
# 处理返回数据中的 details (如果有)
# 有些 API 返回结构可能直接包含 details或者在 data 字段中
details = data.get("details", []) if isinstance(data, dict) else []
if not details and isinstance(data, dict):
details = data.get("detail", [])
if details:
for detail in details:
d_name = detail.get("name")
# 尝试从不同字段获取 ID
d_id = detail.get("id") or detail.get("detailMaterialId")
if d_name and d_id:
self.material_cache[d_name] = d_id
print(f"已自动更新 detail 缓存: {d_name} -> {d_id}")
return data
def query_matial_type_id(self, data) -> list:
"""查找物料typeid"""
@@ -203,7 +244,7 @@ class BioyondV1RPC(BaseRequest):
params={
"apiKey": self.api_key,
"requestTime": self.get_current_time_iso8601(),
"data": {},
"data": 0,
})
if not response or response['code'] != 1:
return []
@@ -273,11 +314,19 @@ class BioyondV1RPC(BaseRequest):
if not response or response['code'] != 1:
return {}
# 自动更新缓存 - 移除被删除的物料
for name, mid in list(self.material_cache.items()):
if mid == material_id:
del self.material_cache[name]
print(f"已从缓存移除物料: {name}")
break
return response.get("data", {})
def material_outbound(self, material_id: str, location_name: str, quantity: int) -> dict:
"""指定库位出库物料(通过库位名称)"""
location_id = LOCATION_MAPPING.get(location_name, location_name)
location_id = self.location_mapping.get(location_name, location_name)
params = {
"materialId": material_id,
@@ -1103,6 +1152,10 @@ class BioyondV1RPC(BaseRequest):
for detail_material in detail_materials:
detail_name = detail_material.get("name")
detail_id = detail_material.get("detailMaterialId")
if not detail_id:
# 尝试其他可能的字段
detail_id = detail_material.get("id")
if detail_name and detail_id:
self.material_cache[detail_name] = detail_id
print(f"加载detail材料: {detail_name} -> ID: {detail_id}")
@@ -1123,6 +1176,14 @@ class BioyondV1RPC(BaseRequest):
print(f"从缓存找到材料: {material_name_or_id} -> ID: {material_id}")
return material_id
# 如果缓存中没有,尝试刷新缓存
print(f"缓存中未找到材料 '{material_name_or_id}',尝试刷新缓存...")
self.refresh_material_cache()
if material_name_or_id in self.material_cache:
material_id = self.material_cache[material_name_or_id]
print(f"刷新缓存后找到材料: {material_name_or_id} -> ID: {material_id}")
return material_id
print(f"警告: 未在缓存中找到材料名称 '{material_name_or_id}',将使用原值")
return material_name_or_id

View File

@@ -1,142 +0,0 @@
# config.py
"""
配置文件 - 包含所有配置信息和映射关系
"""
# API配置
API_CONFIG = {
"api_key": "",
"api_host": ""
}
# 工作流映射配置
WORKFLOW_MAPPINGS = {
"reactor_taken_out": "",
"reactor_taken_in": "",
"Solid_feeding_vials": "",
"Liquid_feeding_vials(non-titration)": "",
"Liquid_feeding_solvents": "",
"Liquid_feeding(titration)": "",
"liquid_feeding_beaker": "",
"Drip_back": "",
}
# 工作流名称到DisplaySectionName的映射
WORKFLOW_TO_SECTION_MAP = {
'reactor_taken_in': '反应器放入',
'liquid_feeding_beaker': '液体投料-烧杯',
'Liquid_feeding_vials(non-titration)': '液体投料-小瓶(非滴定)',
'Liquid_feeding_solvents': '液体投料-溶剂',
'Solid_feeding_vials': '固体投料-小瓶',
'Liquid_feeding(titration)': '液体投料-滴定',
'reactor_taken_out': '反应器取出'
}
# 库位映射配置
WAREHOUSE_MAPPING = {
"粉末堆栈": {
"uuid": "",
"site_uuids": {
# 样品板
"A1": "3a14198e-6929-31f0-8a22-0f98f72260df",
"A2": "3a14198e-6929-4379-affa-9a2935c17f99",
"A3": "3a14198e-6929-56da-9a1c-7f5fbd4ae8af",
"A4": "3a14198e-6929-5e99-2b79-80720f7cfb54",
"B1": "3a14198e-6929-f525-9a1b-1857552b28ee",
"B2": "3a14198e-6929-bf98-0fd5-26e1d68bf62d",
"B3": "3a14198e-6929-2d86-a468-602175a2b5aa",
"B4": "3a14198e-6929-1a98-ae57-e97660c489ad",
# 分装板
"C1": "3a14198e-6929-46fe-841e-03dd753f1e4a",
"C2": "3a14198e-6929-1bc9-a9bd-3b7ca66e7f95",
"C3": "3a14198e-6929-72ac-32ce-9b50245682b8",
"C4": "3a14198e-6929-3bd8-e6c7-4a9fd93be118",
"D1": "3a14198e-6929-8a0b-b686-6f4a2955c4e2",
"D2": "3a14198e-6929-dde1-fc78-34a84b71afdf",
"D3": "3a14198e-6929-a0ec-5f15-c0f9f339f963",
"D4": "3a14198e-6929-7ac8-915a-fea51cb2e884"
}
},
"溶液堆栈": {
"uuid": "",
"site_uuids": {
"A1": "3a14198e-d724-e036-afdc-2ae39a7f3383",
"A2": "3a14198e-d724-afa4-fc82-0ac8a9016791",
"A3": "3a14198e-d724-ca48-bb9e-7e85751e55b6",
"A4": "3a14198e-d724-df6d-5e32-5483b3cab583",
"B1": "3a14198e-d724-d818-6d4f-5725191a24b5",
"B2": "3a14198e-d724-be8a-5e0b-012675e195c6",
"B3": "3a14198e-d724-cc1e-5c2c-228a130f40a8",
"B4": "3a14198e-d724-1e28-c885-574c3df468d0",
"C1": "3a14198e-d724-b5bb-adf3-4c5a0da6fb31",
"C2": "3a14198e-d724-ab4e-48cb-817c3c146707",
"C3": "3a14198e-d724-7f18-1853-39d0c62e1d33",
"C4": "3a14198e-d724-28a2-a760-baa896f46b66",
"D1": "3a14198e-d724-d378-d266-2508a224a19f",
"D2": "3a14198e-d724-f56e-468b-0110a8feb36a",
"D3": "3a14198e-d724-0cf1-dea9-a1f40fe7e13c",
"D4": "3a14198e-d724-0ddd-9654-f9352a421de9"
}
},
"试剂堆栈": {
"uuid": "",
"site_uuids": {
"A1": "3a14198c-c2cf-8b40-af28-b467808f1c36",
"A2": "3a14198c-c2d0-f3e7-871a-e470d144296f",
"A3": "3a14198c-c2d0-dc7d-b8d0-e1d88cee3094",
"A4": "3a14198c-c2d0-2070-efc8-44e245f10c6f",
"B1": "3a14198c-c2d0-354f-39ad-642e1a72fcb8",
"B2": "3a14198c-c2d0-1559-105d-0ea30682cab4",
"B3": "3a14198c-c2d0-725e-523d-34c037ac2440",
"B4": "3a14198c-c2d0-efce-0939-69ca5a7dfd39"
}
}
}
# 物料类型配置
MATERIAL_TYPE_MAPPINGS = {
"烧杯": ("BIOYOND_PolymerStation_1FlaskCarrier", "3a14196b-24f2-ca49-9081-0cab8021bf1a"),
"试剂瓶": ("BIOYOND_PolymerStation_1BottleCarrier", ""),
"样品板": ("BIOYOND_PolymerStation_6StockCarrier", "3a14196e-b7a0-a5da-1931-35f3000281e9"),
"分装板": ("BIOYOND_PolymerStation_6VialCarrier", "3a14196e-5dfe-6e21-0c79-fe2036d052c4"),
"样品瓶": ("BIOYOND_PolymerStation_Solid_Stock", "3a14196a-cf7d-8aea-48d8-b9662c7dba94"),
"90%分装小瓶": ("BIOYOND_PolymerStation_Solid_Vial", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea"),
"10%分装小瓶": ("BIOYOND_PolymerStation_Liquid_Vial", "3a14196c-76be-2279-4e22-7310d69aed68"),
}
# 步骤参数配置各工作流的步骤UUID
WORKFLOW_STEP_IDS = {
"reactor_taken_in": {
"config": ""
},
"liquid_feeding_beaker": {
"liquid": "",
"observe": ""
},
"liquid_feeding_vials_non_titration": {
"liquid": "",
"observe": ""
},
"liquid_feeding_solvents": {
"liquid": "",
"observe": ""
},
"solid_feeding_vials": {
"feeding": "",
"observe": ""
},
"liquid_feeding_titration": {
"liquid": "",
"observe": ""
},
"drip_back": {
"liquid": "",
"observe": ""
}
}
LOCATION_MAPPING = {}
ACTION_NAMES = {}
HTTP_SERVICE_CONFIG = {}

View File

@@ -0,0 +1,329 @@
# config.py
"""
Bioyond工作站配置文件
包含API配置、工作流映射、物料类型映射、仓库库位映射等所有配置信息
"""
from unilabos.resources.bioyond.decks import BIOYOND_PolymerReactionStation_Deck
# ============================================================================
# 基础配置
# ============================================================================
# API配置
API_CONFIG = {
"api_key": "DE9BDDA0",
"api_host": "http://192.168.1.200:44402"
}
# HTTP 报送服务配置
HTTP_SERVICE_CONFIG = {
"http_service_host": "127.0.0.1", # 监听地址
"http_service_port": 8080, # 监听端口
}
# Deck配置 - 反应站工作台配置
DECK_CONFIG = BIOYOND_PolymerReactionStation_Deck(setup=True)
# ============================================================================
# 工作流配置
# ============================================================================
# 工作流ID映射
WORKFLOW_MAPPINGS = {
"reactor_taken_out": "3a16081e-4788-ca37-eff4-ceed8d7019d1",
"reactor_taken_in": "3a160df6-76b3-0957-9eb0-cb496d5721c6",
"Solid_feeding_vials": "3a160877-87e7-7699-7bc6-ec72b05eb5e6",
"Liquid_feeding_vials(non-titration)": "3a167d99-6158-c6f0-15b5-eb030f7d8e47",
"Liquid_feeding_solvents": "3a160824-0665-01ed-285a-51ef817a9046",
"Liquid_feeding(titration)": "3a16082a-96ac-0449-446a-4ed39f3365b6",
"liquid_feeding_beaker": "3a16087e-124f-8ddb-8ec1-c2dff09ca784",
"Drip_back": "3a162cf9-6aac-565a-ddd7-682ba1796a4a",
}
# 工作流名称到显示名称的映射
WORKFLOW_TO_SECTION_MAP = {
'reactor_taken_in': '反应器放入',
'reactor_taken_out': '反应器取出',
'Solid_feeding_vials': '固体投料-小瓶',
'Liquid_feeding_vials(non-titration)': '液体投料-小瓶(非滴定)',
'Liquid_feeding_solvents': '液体投料-溶剂',
'Liquid_feeding(titration)': '液体投料-滴定',
'liquid_feeding_beaker': '液体投料-烧杯',
'Drip_back': '液体回滴'
}
# 工作流步骤ID配置
WORKFLOW_STEP_IDS = {
"reactor_taken_in": {
"config": "60a06f85-c5b3-29eb-180f-4f62dd7e2154"
},
"liquid_feeding_beaker": {
"liquid": "6808cda7-fee7-4092-97f0-5f9c2ffa60e3",
"observe": "1753c0de-dffc-4ee6-8458-805a2e227362"
},
"liquid_feeding_vials_non_titration": {
"liquid": "62ea6e95-3d5d-43db-bc1e-9a1802673861",
"observe": "3a167d99-6172-b67b-5f22-a7892197142e"
},
"liquid_feeding_solvents": {
"liquid": "1fcea355-2545-462b-b727-350b69a313bf",
"observe": "0553dfb3-9ac5-4ace-8e00-2f11029919a8"
},
"solid_feeding_vials": {
"feeding": "f7ae7448-4f20-4c1d-8096-df6fbadd787a",
"observe": "263c7ed5-7277-426b-bdff-d6fbf77bcc05"
},
"liquid_feeding_titration": {
"liquid": "a00ec41b-e666-4422-9c20-bfcd3cd15c54",
"observe": "ac738ff6-4c58-4155-87b1-d6f65a2c9ab5"
},
"drip_back": {
"liquid": "371be86a-ab77-4769-83e5-54580547c48a",
"observe": "ce024b9d-bd20-47b8-9f78-ca5ce7f44cf1"
}
}
# 工作流动作名称配置
ACTION_NAMES = {
"reactor_taken_in": {
"config": "通量-配置",
"stirring": "反应模块-开始搅拌"
},
"solid_feeding_vials": {
"feeding": "粉末加样模块-投料",
"observe": "反应模块-观察搅拌结果"
},
"liquid_feeding_vials_non_titration": {
"liquid": "稀释液瓶加液位-液体投料",
"observe": "反应模块-滴定结果观察"
},
"liquid_feeding_solvents": {
"liquid": "试剂AB放置位-试剂吸液分液",
"observe": "反应模块-观察搅拌结果"
},
"liquid_feeding_titration": {
"liquid": "稀释液瓶加液位-稀释液吸液分液",
"observe": "反应模块-滴定结果观察"
},
"liquid_feeding_beaker": {
"liquid": "烧杯溶液放置位-烧杯吸液分液",
"observe": "反应模块-观察搅拌结果"
},
"drip_back": {
"liquid": "试剂AB放置位-试剂吸液分液",
"observe": "反应模块-向下滴定结果观察"
}
}
# ============================================================================
# 仓库配置
# ============================================================================
# 说明:
# - 出库和入库操作都需要UUID
WAREHOUSE_MAPPING = {
# ========== 反应站仓库 ==========
# 堆栈1左 - 反应站左侧堆栈 (4行×4列=16个库位, A01D04)
"堆栈1左": {
"uuid": "3a14aa17-0d49-dce4-486e-4b5c85c8b366",
"site_uuids": {
"A01": "3a14aa17-0d49-11d7-a6e1-f236b3e5e5a3",
"A02": "3a14aa17-0d49-4bc5-8836-517b75473f5f",
"A03": "3a14aa17-0d49-c2bc-6222-5cee8d2d94f8",
"A04": "3a14aa17-0d49-3ce2-8e9a-008c38d116fb",
"B01": "3a14aa17-0d49-f49c-6b66-b27f185a3b32",
"B02": "3a14aa17-0d49-cf46-df85-a979c9c9920c",
"B03": "3a14aa17-0d49-7698-4a23-f7ffb7d48ba3",
"B04": "3a14aa17-0d49-1231-99be-d5870e6478e9",
"C01": "3a14aa17-0d49-be34-6fae-4aed9d48b70b",
"C02": "3a14aa17-0d49-11d7-0897-34921dcf6b7c",
"C03": "3a14aa17-0d49-9840-0bd5-9c63c1bb2c29",
"C04": "3a14aa17-0d49-8335-3bff-01da69ea4911",
"D01": "3a14aa17-0d49-2bea-c8e5-2b32094935d5",
"D02": "3a14aa17-0d49-cff4-e9e8-5f5f0bc1ef32",
"D03": "3a14aa17-0d49-4948-cb0a-78f30d1ca9b8",
"D04": "3a14aa17-0d49-fd2f-9dfb-a29b11e84099",
},
},
# 堆栈1右 - 反应站右侧堆栈 (4行×4列=16个库位, A05D08)
"堆栈1右": {
"uuid": "3a14aa17-0d49-dce4-486e-4b5c85c8b366",
"site_uuids": {
"A05": "3a14aa17-0d49-2c61-edc8-72a8ca7192dd",
"A06": "3a14aa17-0d49-60c8-2b00-40b17198f397",
"A07": "3a14aa17-0d49-ec5b-0b75-634dce8eed25",
"A08": "3a14aa17-0d49-3ec9-55b3-f3189c4ec53d",
"B05": "3a14aa17-0d49-6a4e-abcf-4c113eaaeaad",
"B06": "3a14aa17-0d49-e3f6-2dd6-28c2e8194fbe",
"B07": "3a14aa17-0d49-11a6-b861-ee895121bf52",
"B08": "3a14aa17-0d49-9c7d-1145-d554a6e482f0",
"C05": "3a14aa17-0d49-45c4-7a34-5105bc3e2368",
"C06": "3a14aa17-0d49-867e-39ab-31b3fe9014be",
"C07": "3a14aa17-0d49-ec56-c4b4-39fd9b2131e7",
"C08": "3a14aa17-0d49-1128-d7d9-ffb1231c98c0",
"D05": "3a14aa17-0d49-e843-f961-ea173326a14b",
"D06": "3a14aa17-0d49-4d26-a985-f188359c4f8b",
"D07": "3a14aa17-0d49-223a-b520-bc092bb42fe0",
"D08": "3a14aa17-0d49-4fa3-401a-6a444e1cca22",
},
},
# 站内试剂存放堆栈
"站内试剂存放堆栈": {
"uuid": "3a14aa3b-9fab-9d8e-d1a7-828f01f51f0c",
"site_uuids": {
"A01": "3a14aa3b-9fab-adac-7b9c-e1ee446b51d5",
"A02": "3a14aa3b-9fab-ca72-febc-b7c304476c78"
}
},
# 测量小瓶仓库(测密度)
"测量小瓶仓库": {
"uuid": "3a15012f-705b-c0de-3f9e-950c205f9921",
"site_uuids": {
"A01": "3a15012f-705e-0524-3161-c523b5aebc97",
"A02": "3a15012f-705e-7cd1-32ab-ad4fd1ab75c8",
"A03": "3a15012f-705e-a5d6-edac-bdbfec236260",
"B01": "3a15012f-705e-e0ee-80e0-10a6b3fc500d",
"B02": "3a15012f-705e-e499-180d-de06d60d0b21",
"B03": "3a15012f-705e-eff6-63f1-09f742096b26"
}
},
# 站内Tip盒堆栈 - 用于存放枪头盒 (耗材)
"站内Tip盒堆栈": {
"uuid": "3a14aa3a-2d3c-b5c1-9ddf-7c4a957d459a",
"site_uuids": {
"A01": "3a14aa3a-2d3d-e700-411a-0ddf85e1f18a",
"A02": "3a14aa3a-2d3d-a7ce-099a-d5632fdafa24",
"A03": "3a14aa3a-2d3d-bdf6-a702-c60b38b08501",
"B01": "3a14aa3a-2d3d-d704-f076-2a8d5bc72cb8",
"B02": "3a14aa3a-2d3d-c350-2526-0778d173a5ac",
"B03": "3a14aa3a-2d3d-bc38-b356-f0de2e44e0c7"
}
},
# ========== 配液站仓库 ==========
"粉末堆栈": {
"uuid": "3a14198e-6928-121f-7ca6-88ad3ae7e6a0",
"site_uuids": {
"A01": "3a14198e-6929-31f0-8a22-0f98f72260df",
"A02": "3a14198e-6929-4379-affa-9a2935c17f99",
"A03": "3a14198e-6929-56da-9a1c-7f5fbd4ae8af",
"A04": "3a14198e-6929-5e99-2b79-80720f7cfb54",
"B01": "3a14198e-6929-f525-9a1b-1857552b28ee",
"B02": "3a14198e-6929-bf98-0fd5-26e1d68bf62d",
"B03": "3a14198e-6929-2d86-a468-602175a2b5aa",
"B04": "3a14198e-6929-1a98-ae57-e97660c489ad",
"C01": "3a14198e-6929-46fe-841e-03dd753f1e4a",
"C02": "3a14198e-6929-72ac-32ce-9b50245682b8",
"C03": "3a14198e-6929-8a0b-b686-6f4a2955c4e2",
"C04": "3a14198e-6929-a0ec-5f15-c0f9f339f963",
"D01": "3a14198e-6929-1bc9-a9bd-3b7ca66e7f95",
"D02": "3a14198e-6929-3bd8-e6c7-4a9fd93be118",
"D03": "3a14198e-6929-dde1-fc78-34a84b71afdf",
"D04": "3a14198e-6929-7ac8-915a-fea51cb2e884"
}
},
"溶液堆栈": {
"uuid": "3a14198e-d723-2c13-7d12-50143e190a23",
"site_uuids": {
"A01": "3a14198e-d724-e036-afdc-2ae39a7f3383",
"A02": "3a14198e-d724-d818-6d4f-5725191a24b5",
"A03": "3a14198e-d724-b5bb-adf3-4c5a0da6fb31",
"A04": "3a14198e-d724-d378-d266-2508a224a19f",
"B01": "3a14198e-d724-afa4-fc82-0ac8a9016791",
"B02": "3a14198e-d724-be8a-5e0b-012675e195c6",
"B03": "3a14198e-d724-ab4e-48cb-817c3c146707",
"B04": "3a14198e-d724-f56e-468b-0110a8feb36a",
"C01": "3a14198e-d724-ca48-bb9e-7e85751e55b6",
"C02": "3a14198e-d724-cc1e-5c2c-228a130f40a8",
"C03": "3a14198e-d724-7f18-1853-39d0c62e1d33",
"C04": "3a14198e-d724-0cf1-dea9-a1f40fe7e13c",
"D01": "3a14198e-d724-df6d-5e32-5483b3cab583",
"D02": "3a14198e-d724-1e28-c885-574c3df468d0",
"D03": "3a14198e-d724-28a2-a760-baa896f46b66",
"D04": "3a14198e-d724-0ddd-9654-f9352a421de9"
}
},
"试剂堆栈": {
"uuid": "3a14198c-c2cc-0290-e086-44a428fba248",
"site_uuids": {
"A01": "3a14198c-c2cf-8b40-af28-b467808f1c36", # x=1, y=1, code=0001-0001
"A02": "3a14198c-c2d0-dc7d-b8d0-e1d88cee3094", # x=1, y=2, code=0001-0002
"A03": "3a14198c-c2d0-354f-39ad-642e1a72fcb8", # x=1, y=3, code=0001-0003
"A04": "3a14198c-c2d0-725e-523d-34c037ac2440", # x=1, y=4, code=0001-0004
"B01": "3a14198c-c2d0-f3e7-871a-e470d144296f", # x=2, y=1, code=0001-0005
"B02": "3a14198c-c2d0-2070-efc8-44e245f10c6f", # x=2, y=2, code=0001-0006
"B03": "3a14198c-c2d0-1559-105d-0ea30682cab4", # x=2, y=3, code=0001-0007
"B04": "3a14198c-c2d0-efce-0939-69ca5a7dfd39" # x=2, y=4, code=0001-0008
}
}
}
# ============================================================================
# 物料类型配置
# ============================================================================
# 说明:
# - 格式: PyLabRobot资源类型名称 → Bioyond系统typeId的UUID
# - 这个映射基于 resource.model 属性 (不是显示名称!)
# - UUID为空表示该类型暂未在Bioyond系统中定义
MATERIAL_TYPE_MAPPINGS = {
# ================================================配液站资源============================================================
# ==================================================样品===============================================================
"BIOYOND_PolymerStation_1FlaskCarrier": ("烧杯", "3a14196b-24f2-ca49-9081-0cab8021bf1a"), # 配液站-样品-烧杯
"BIOYOND_PolymerStation_1BottleCarrier": ("试剂瓶", "3a14196b-8bcf-a460-4f74-23f21ca79e72"), # 配液站-样品-试剂瓶
"BIOYOND_PolymerStation_6StockCarrier": ("分装板", "3a14196e-5dfe-6e21-0c79-fe2036d052c4"), # 配液站-样品-分装板
"BIOYOND_PolymerStation_Liquid_Vial": ("10%分装小瓶", "3a14196c-76be-2279-4e22-7310d69aed68"), # 配液站-样品-分装板-第一排小瓶
"BIOYOND_PolymerStation_Solid_Vial": ("90%分装小瓶", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea"), # 配液站-样品-分装板-第二排小瓶
# ==================================================试剂===============================================================
"BIOYOND_PolymerStation_8StockCarrier": ("样品板", "3a14196e-b7a0-a5da-1931-35f3000281e9"), # 配液站-试剂-样品板8孔
"BIOYOND_PolymerStation_Solid_Stock": ("样品瓶", "3a14196a-cf7d-8aea-48d8-b9662c7dba94"), # 配液站-试剂-样品板-样品瓶
}
# ============================================================================
# 动态生成的库位UUID映射从WAREHOUSE_MAPPING中提取
# ============================================================================
LOCATION_MAPPING = {}
for warehouse_name, warehouse_config in WAREHOUSE_MAPPING.items():
if "site_uuids" in warehouse_config:
LOCATION_MAPPING.update(warehouse_config["site_uuids"])
# ============================================================================
# 物料默认参数配置
# ============================================================================
# 说明:
# - 为特定物料名称自动添加默认参数(如密度、分子量、单位等)
# - 格式: 物料名称 → {参数字典}
# - 在创建或更新物料时,会自动合并这些参数到 Parameters 字段
# - unit: 物料的计量单位(会用于 unit 字段)
# - density/densityUnit: 密度信息(会添加到 Parameters 中)
MATERIAL_DEFAULT_PARAMETERS = {
# 溶剂类
"NMP": {
"unit": "毫升",
"density": "1.03",
"densityUnit": "g/mL",
"description": "N-甲基吡咯烷酮 (N-Methyl-2-pyrrolidone)"
},
# 可以继续添加其他物料...
}
# ============================================================================
# 物料类型默认参数配置
# ============================================================================
# 说明:
# - 为特定物料类型UUID自动添加默认参数
# - 格式: Bioyond类型UUID → {参数字典}
# - 优先级低于按名称匹配的配置
MATERIAL_TYPE_PARAMETERS = {
# 示例:
# "3a14196b-24f2-ca49-9081-0cab8021bf1a": { # 烧杯
# "unit": "个"
# }
}

View File

@@ -4,7 +4,8 @@ import time
from typing import Optional, Dict, Any, List
from typing_extensions import TypedDict
import requests
from unilabos.devices.workstation.bioyond_studio.config import API_CONFIG
import pint
from unilabos.devices.workstation.bioyond_studio.bioyond_rpc import BioyondException
from unilabos.devices.workstation.bioyond_studio.station import BioyondWorkstation
@@ -25,13 +26,89 @@ class ComputeExperimentDesignReturn(TypedDict):
class BioyondDispensingStation(BioyondWorkstation):
def __init__(
self,
config,
# 桌子
deck,
*args,
config: dict = None,
deck=None,
protocol_type=None,
**kwargs,
):
super().__init__(config, deck, *args, **kwargs)
):
"""初始化配液站
Args:
config: 配置字典,应包含material_type_mappings等配置
deck: Deck对象
protocol_type: 协议类型(由ROS系统传递,此处忽略)
**kwargs: 其他可能的参数
"""
if config is None:
config = {}
# 将 kwargs 合并到 config 中 (处理扁平化配置如 api_key)
config.update(kwargs)
if deck is None and config:
deck = config.get('deck')
# 🔧 修复: 确保 Deck 上的 warehouses 具有正确的 UUID (必须在 super().__init__ 之前执行,因为父类会触发同步)
# 从配置中读取 warehouse_mapping并应用到实际的 deck 资源上
if config and "warehouse_mapping" in config and deck:
warehouse_mapping = config["warehouse_mapping"]
print(f"正在根据配置更新 Deck warehouse UUIDs... (共有 {len(warehouse_mapping)} 个配置)")
user_deck = deck
# 初始化 warehouses 字典
if not hasattr(user_deck, "warehouses") or user_deck.warehouses is None:
user_deck.warehouses = {}
# 1. 尝试从 children 中查找匹配的资源
for child in user_deck.children:
# 简单判断: 如果名字在 mapping 中,就认为是 warehouse
if child.name in warehouse_mapping:
user_deck.warehouses[child.name] = child
print(f" - 从子资源中找到 warehouse: {child.name}")
# 2. 如果还是没找到,且 Deck 类有 setup 方法,尝试调用 setup (针对 Deck 对象正确但未初始化的情况)
if not user_deck.warehouses and hasattr(user_deck, "setup"):
print(" - 尝试调用 deck.setup() 初始化仓库...")
try:
user_deck.setup()
# setup 后重新检查
if hasattr(user_deck, "warehouses") and user_deck.warehouses:
print(f" - setup() 成功,找到 {len(user_deck.warehouses)} 个仓库")
except Exception as e:
print(f" - 调用 setup() 失败: {e}")
# 3. 如果仍然为空,可能需要手动创建 (仅针对特定已知的 Deck 类型进行补救,这里暂时只打印警告)
if not user_deck.warehouses:
print(" - ⚠️ 仍然无法找到任何 warehouse 资源!")
for wh_name, wh_config in warehouse_mapping.items():
target_uuid = wh_config.get("uuid")
# 尝试在 deck.warehouses 中查找
wh_resource = None
if hasattr(user_deck, "warehouses") and wh_name in user_deck.warehouses:
wh_resource = user_deck.warehouses[wh_name]
# 如果没找到,尝试在所有子资源中查找
if not wh_resource:
wh_resource = user_deck.get_resource(wh_name)
if wh_resource:
if target_uuid:
current_uuid = getattr(wh_resource, "uuid", None)
print(f"✅ 更新仓库 '{wh_name}' UUID: {current_uuid} -> {target_uuid}")
# 动态添加 uuid 属性
wh_resource.uuid = target_uuid
# 同时也确保 category 正确,避免 graphio 识别错误
# wh_resource.category = "warehouse"
else:
print(f"⚠️ 仓库 '{wh_name}' 在配置中没有 UUID")
else:
print(f"❌ 在 Deck 中未找到配置的仓库: '{wh_name}'")
super().__init__(bioyond_config=config, deck=deck)
# self.config = config
# self.api_key = config["api_key"]
# self.host = config["api_host"]
@@ -43,6 +120,41 @@ class BioyondDispensingStation(BioyondWorkstation):
# 用于跟踪任务完成状态的字典: {orderCode: {status, order_id, timestamp}}
self.order_completion_status = {}
# 初始化 pint 单位注册表
self.ureg = pint.UnitRegistry()
# 化合物信息
self.compound_info = {
"MolWt": {
"MDA": 108.14 * self.ureg.g / self.ureg.mol,
"TDA": 122.16 * self.ureg.g / self.ureg.mol,
"PAPP": 521.62 * self.ureg.g / self.ureg.mol,
"BTDA": 322.23 * self.ureg.g / self.ureg.mol,
"BPDA": 294.22 * self.ureg.g / self.ureg.mol,
"6FAP": 366.26 * self.ureg.g / self.ureg.mol,
"PMDA": 218.12 * self.ureg.g / self.ureg.mol,
"MPDA": 108.14 * self.ureg.g / self.ureg.mol,
"SIDA": 248.51 * self.ureg.g / self.ureg.mol,
"ODA": 200.236 * self.ureg.g / self.ureg.mol,
"4,4'-ODA": 200.236 * self.ureg.g / self.ureg.mol,
"134": 292.34 * self.ureg.g / self.ureg.mol,
},
"FuncGroup": {
"MDA": "Amine",
"TDA": "Amine",
"PAPP": "Amine",
"BTDA": "Anhydride",
"BPDA": "Anhydride",
"6FAP": "Amine",
"MPDA": "Amine",
"SIDA": "Amine",
"PMDA": "Anhydride",
"ODA": "Amine",
"4,4'-ODA": "Amine",
"134": "Amine",
}
}
def _post_project_api(self, endpoint: str, data: Any) -> Dict[str, Any]:
"""项目接口通用POST调用
@@ -54,7 +166,7 @@ class BioyondDispensingStation(BioyondWorkstation):
dict: 服务端响应失败时返回 {code:0,message,...}
"""
request_data = {
"apiKey": API_CONFIG["api_key"],
"apiKey": self.bioyond_config["api_key"],
"requestTime": self.hardware_interface.get_current_time_iso8601(),
"data": data
}
@@ -85,7 +197,7 @@ class BioyondDispensingStation(BioyondWorkstation):
dict: 服务端响应失败时返回 {code:0,message,...}
"""
request_data = {
"apiKey": API_CONFIG["api_key"],
"apiKey": self.bioyond_config["api_key"],
"requestTime": self.hardware_interface.get_current_time_iso8601(),
"data": data
}
@@ -118,20 +230,22 @@ class BioyondDispensingStation(BioyondWorkstation):
ratio = json.loads(ratio)
except Exception:
ratio = {}
root = str(Path(__file__).resolve().parents[3])
if root not in sys.path:
sys.path.append(root)
try:
mod = importlib.import_module("tem.compute")
except Exception as e:
raise BioyondException(f"无法导入计算模块: {e}")
try:
wp = float(wt_percent) if isinstance(wt_percent, str) else wt_percent
mt = float(m_tot) if isinstance(m_tot, str) else m_tot
tp = float(titration_percent) if isinstance(titration_percent, str) else titration_percent
except Exception as e:
raise BioyondException(f"参数解析失败: {e}")
res = mod.generate_experiment_design(ratio=ratio, wt_percent=wp, m_tot=mt, titration_percent=tp)
# 2. 调用内部计算方法
res = self._generate_experiment_design(
ratio=ratio,
wt_percent=wp,
m_tot=mt,
titration_percent=tp
)
# 3. 构造返回结果
out = {
"solutions": res.get("solutions", []),
"titration": res.get("titration", {}),
@@ -140,11 +254,248 @@ class BioyondDispensingStation(BioyondWorkstation):
"return_info": json.dumps(res, ensure_ascii=False)
}
return out
except BioyondException:
raise
except Exception as e:
raise BioyondException(str(e))
def _generate_experiment_design(
self,
ratio: dict,
wt_percent: float = 0.25,
m_tot: float = 70,
titration_percent: float = 0.03,
) -> dict:
"""内部方法:生成实验设计
根据FuncGroup自动区分二胺和二酐每种二胺单独配溶液严格按照ratio顺序投料
参数:
ratio: 化合物配比字典格式: {"compound_name": ratio_value}
wt_percent: 固体重量百分比
m_tot: 反应混合物总质量(g)
titration_percent: 滴定溶液百分比
返回:
包含实验设计详细参数的字典
"""
# 溶剂密度
ρ_solvent = 1.03 * self.ureg.g / self.ureg.ml
# 二酐溶解度
solubility = 0.02 * self.ureg.g / self.ureg.ml
# 投入固体时最小溶剂体积
V_min = 30 * self.ureg.ml
m_tot = m_tot * self.ureg.g
# 保持ratio中的顺序
compound_names = list(ratio.keys())
compound_ratios = list(ratio.values())
# 验证所有化合物是否在 compound_info 中定义
undefined_compounds = [name for name in compound_names if name not in self.compound_info["MolWt"]]
if undefined_compounds:
available = list(self.compound_info["MolWt"].keys())
raise ValueError(
f"以下化合物未在 compound_info 中定义: {undefined_compounds}"
f"可用的化合物: {available}"
)
# 获取各化合物的分子量和官能团类型
molecular_weights = [self.compound_info["MolWt"][name] for name in compound_names]
func_groups = [self.compound_info["FuncGroup"][name] for name in compound_names]
# 记录化合物信息用于调试
self.hardware_interface._logger.info(f"化合物名称: {compound_names}")
self.hardware_interface._logger.info(f"官能团类型: {func_groups}")
# 按原始顺序分离二胺和二酐
ordered_compounds = list(zip(compound_names, compound_ratios, molecular_weights, func_groups))
diamine_compounds = [(name, ratio_val, mw, i) for i, (name, ratio_val, mw, fg) in enumerate(ordered_compounds) if fg == "Amine"]
anhydride_compounds = [(name, ratio_val, mw, i) for i, (name, ratio_val, mw, fg) in enumerate(ordered_compounds) if fg == "Anhydride"]
if not diamine_compounds or not anhydride_compounds:
raise ValueError(
f"需要同时包含二胺(Amine)和二酐(Anhydride)化合物。"
f"当前二胺: {[c[0] for c in diamine_compounds]}, "
f"当前二酐: {[c[0] for c in anhydride_compounds]}"
)
# 计算加权平均分子量 (基于摩尔比)
total_molar_ratio = sum(compound_ratios)
weighted_molecular_weight = sum(ratio_val * mw for ratio_val, mw in zip(compound_ratios, molecular_weights))
# 取最后一个二酐用于滴定
titration_anhydride = anhydride_compounds[-1]
solid_anhydrides = anhydride_compounds[:-1] if len(anhydride_compounds) > 1 else []
# 二胺溶液配制参数 - 每种二胺单独配制
diamine_solutions = []
total_diamine_volume = 0 * self.ureg.ml
# 计算反应物的总摩尔量
n_reactant = m_tot * wt_percent / weighted_molecular_weight
for name, ratio_val, mw, order_index in diamine_compounds:
# 跳过 SIDA
if name == "SIDA":
continue
# 计算该二胺需要的摩尔数
n_diamine_needed = n_reactant * ratio_val
# 二胺溶液配制参数 (每种二胺固定配制参数)
m_diamine_solid = 5.0 * self.ureg.g # 每种二胺固体质量
V_solvent_for_this = 20 * self.ureg.ml # 每种二胺溶剂体积
m_solvent_for_this = ρ_solvent * V_solvent_for_this
# 计算该二胺溶液的浓度
c_diamine = (m_diamine_solid / mw) / V_solvent_for_this
# 计算需要移取的溶液体积
V_diamine_needed = n_diamine_needed / c_diamine
diamine_solutions.append({
"name": name,
"order": order_index,
"solid_mass": m_diamine_solid.magnitude,
"solvent_volume": V_solvent_for_this.magnitude,
"concentration": c_diamine.magnitude,
"volume_needed": V_diamine_needed.magnitude,
"molar_ratio": ratio_val
})
total_diamine_volume += V_diamine_needed
# 按原始顺序排序
diamine_solutions.sort(key=lambda x: x["order"])
# 计算滴定二酐的质量
titration_name, titration_ratio, titration_mw, _ = titration_anhydride
m_titration_anhydride = n_reactant * titration_ratio * titration_mw
m_titration_90 = m_titration_anhydride * (1 - titration_percent)
m_titration_10 = m_titration_anhydride * titration_percent
# 计算其他固体二酐的质量 (按顺序)
solid_anhydride_masses = []
for name, ratio_val, mw, order_index in solid_anhydrides:
mass = n_reactant * ratio_val * mw
solid_anhydride_masses.append({
"name": name,
"order": order_index,
"mass": mass.magnitude,
"molar_ratio": ratio_val
})
# 按原始顺序排序
solid_anhydride_masses.sort(key=lambda x: x["order"])
# 计算溶剂用量
total_diamine_solution_mass = sum(
sol["volume_needed"] * ρ_solvent for sol in diamine_solutions
) * self.ureg.ml
# 预估滴定溶剂量、计算补加溶剂量
m_solvent_titration = m_titration_10 / solubility * ρ_solvent
m_solvent_add = m_tot * (1 - wt_percent) - total_diamine_solution_mass - m_solvent_titration
# 检查最小溶剂体积要求
total_liquid_volume = (total_diamine_solution_mass + m_solvent_add) / ρ_solvent
m_tot_min = V_min / total_liquid_volume * m_tot
# 如果需要,按比例放大
scale_factor = 1.0
if m_tot_min > m_tot:
scale_factor = (m_tot_min / m_tot).magnitude
m_titration_90 *= scale_factor
m_titration_10 *= scale_factor
m_solvent_add *= scale_factor
m_solvent_titration *= scale_factor
# 更新二胺溶液用量
for sol in diamine_solutions:
sol["volume_needed"] *= scale_factor
# 更新固体二酐用量
for anhydride in solid_anhydride_masses:
anhydride["mass"] *= scale_factor
m_tot = m_tot_min
# 生成投料顺序
feeding_order = []
# 1. 固体二酐 (按顺序)
for anhydride in solid_anhydride_masses:
feeding_order.append({
"step": len(feeding_order) + 1,
"type": "solid_anhydride",
"name": anhydride["name"],
"amount": anhydride["mass"],
"order": anhydride["order"]
})
# 2. 二胺溶液 (按顺序)
for sol in diamine_solutions:
feeding_order.append({
"step": len(feeding_order) + 1,
"type": "diamine_solution",
"name": sol["name"],
"amount": sol["volume_needed"],
"order": sol["order"]
})
# 3. 主要二酐粉末
feeding_order.append({
"step": len(feeding_order) + 1,
"type": "main_anhydride",
"name": titration_name,
"amount": m_titration_90.magnitude,
"order": titration_anhydride[3]
})
# 4. 补加溶剂
if m_solvent_add > 0:
feeding_order.append({
"step": len(feeding_order) + 1,
"type": "additional_solvent",
"name": "溶剂",
"amount": m_solvent_add.magnitude,
"order": 999
})
# 5. 滴定二酐溶液
feeding_order.append({
"step": len(feeding_order) + 1,
"type": "titration_anhydride",
"name": f"{titration_name} 滴定液",
"amount": m_titration_10.magnitude,
"titration_solvent": m_solvent_titration.magnitude,
"order": titration_anhydride[3]
})
# 返回实验设计结果
results = {
"total_mass": m_tot.magnitude,
"scale_factor": scale_factor,
"solutions": diamine_solutions,
"solids": solid_anhydride_masses,
"titration": {
"name": titration_name,
"main_portion": m_titration_90.magnitude,
"titration_portion": m_titration_10.magnitude,
"titration_solvent": m_solvent_titration.magnitude,
},
"solvents": {
"additional_solvent": m_solvent_add.magnitude,
"total_liquid_volume": total_liquid_volume.magnitude
},
"feeding_order": feeding_order,
"minimum_required_mass": m_tot_min.magnitude
}
return results
# 90%10%小瓶投料任务创建方法
def create_90_10_vial_feeding_task(self,
order_name: str = None,
@@ -961,6 +1312,108 @@ class BioyondDispensingStation(BioyondWorkstation):
'actualVolume': actual_volume
}
def _simplify_report(self, report) -> Dict[str, Any]:
"""简化实验报告,只保留关键信息,去除冗余的工作流参数"""
if not isinstance(report, dict):
return report
data = report.get('data', {})
if not isinstance(data, dict):
return report
# 提取关键信息
simplified = {
'name': data.get('name'),
'code': data.get('code'),
'requester': data.get('requester'),
'workflowName': data.get('workflowName'),
'workflowStep': data.get('workflowStep'),
'requestTime': data.get('requestTime'),
'startPreparationTime': data.get('startPreparationTime'),
'completeTime': data.get('completeTime'),
'useTime': data.get('useTime'),
'status': data.get('status'),
'statusName': data.get('statusName'),
}
# 提取物料信息(简化版)
pre_intakes = data.get('preIntakes', [])
if pre_intakes and isinstance(pre_intakes, list):
first_intake = pre_intakes[0]
sample_materials = first_intake.get('sampleMaterials', [])
# 简化物料信息
simplified_materials = []
for material in sample_materials:
if isinstance(material, dict):
mat_info = {
'materialName': material.get('materialName'),
'materialTypeName': material.get('materialTypeName'),
'materialCode': material.get('materialCode'),
'materialLocation': material.get('materialLocation'),
}
# 解析parameters中的关键信息如密度、加料历史等
params_str = material.get('parameters', '{}')
try:
params = json.loads(params_str) if isinstance(params_str, str) else params_str
if isinstance(params, dict):
# 只保留关键参数
if 'density' in params:
mat_info['density'] = params['density']
if 'feedingHistory' in params:
mat_info['feedingHistory'] = params['feedingHistory']
if 'liquidVolume' in params:
mat_info['liquidVolume'] = params['liquidVolume']
if 'm_diamine_tot' in params:
mat_info['m_diamine_tot'] = params['m_diamine_tot']
if 'wt_diamine' in params:
mat_info['wt_diamine'] = params['wt_diamine']
except:
pass
simplified_materials.append(mat_info)
simplified['sampleMaterials'] = simplified_materials
# 提取extraProperties中的实际值
extra_props = first_intake.get('extraProperties', {})
if isinstance(extra_props, dict):
simplified_extra = {}
for key, value in extra_props.items():
try:
parsed_value = json.loads(value) if isinstance(value, str) else value
simplified_extra[key] = parsed_value
except:
simplified_extra[key] = value
simplified['extraProperties'] = simplified_extra
return {
'data': simplified,
'code': report.get('code'),
'message': report.get('message'),
'timestamp': report.get('timestamp')
}
def scheduler_start(self) -> dict:
"""启动调度器 - 启动Bioyond工作站的任务调度器开始执行队列中的任务
Returns:
dict: 包含return_info的字典return_info为整型(1=成功)
Raises:
BioyondException: 调度器启动失败时抛出异常
"""
result = self.hardware_interface.scheduler_start()
self.hardware_interface._logger.info(f"调度器启动结果: {result}")
if result != 1:
error_msg = "启动调度器失败: 有未处理错误调度无法启动。请检查Bioyond系统状态。"
self.hardware_interface._logger.error(error_msg)
raise BioyondException(error_msg)
return {"return_info": result}
# 等待多个任务完成并获取实验报告
def wait_for_multiple_orders_and_get_reports(self,
batch_create_result: str = None,
@@ -1002,7 +1455,12 @@ class BioyondDispensingStation(BioyondWorkstation):
# 验证batch_create_result参数
if not batch_create_result or batch_create_result == "":
raise BioyondException("batch_create_result参数为空请确保从batch_create节点正确连接handle")
raise BioyondException(
"batch_create_result参数为空请确保:\n"
"1. batch_create节点与wait节点之间正确连接了handle\n"
"2. batch_create节点成功执行并返回了结果\n"
"3. 检查上游batch_create任务是否成功创建了订单"
)
# 解析batch_create_result JSON对象
try:
@@ -1031,7 +1489,17 @@ class BioyondDispensingStation(BioyondWorkstation):
# 验证提取的数据
if not order_codes:
raise BioyondException("batch_create_result中未找到order_codes字段或为空")
self.hardware_interface._logger.error(
f"batch_create任务未生成任何订单。batch_create_result内容: {batch_create_result}"
)
raise BioyondException(
"batch_create_result中未找到order_codes或为空。\n"
"可能的原因:\n"
"1. batch_create任务执行失败检查任务是否报错\n"
"2. 物料配置问题(如'物料样品板分配失败'\n"
"3. Bioyond系统状态异常\n"
f"请检查batch_create任务的执行结果"
)
if not order_ids:
raise BioyondException("batch_create_result中未找到order_ids字段或为空")
@@ -1114,6 +1582,8 @@ class BioyondDispensingStation(BioyondWorkstation):
self.hardware_interface._logger.info(
f"成功获取任务 {order_code} 的实验报告"
)
# 简化报告,去除冗余信息
report = self._simplify_report(report)
reports.append({
"order_code": order_code,
@@ -1288,7 +1758,7 @@ class BioyondDispensingStation(BioyondWorkstation):
f"开始执行批量物料转移: {len(transfer_groups)}组任务 -> {target_device_id}"
)
from .config import WAREHOUSE_MAPPING
warehouse_mapping = self.bioyond_config.get("warehouse_mapping", {})
results = []
successful_count = 0
failed_count = 0

View File

@@ -6,6 +6,7 @@ Bioyond Workstation Implementation
"""
import time
import traceback
import threading
from datetime import datetime
from typing import Dict, Any, List, Optional, Union
import json
@@ -23,12 +24,94 @@ from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
from unilabos.ros.msgs.message_converter import convert_to_ros_msg, Float64, String
from pylabrobot.resources.resource import Resource as ResourcePLR
from unilabos.devices.workstation.bioyond_studio.config import (
API_CONFIG, WORKFLOW_MAPPINGS, MATERIAL_TYPE_MAPPINGS, WAREHOUSE_MAPPING, HTTP_SERVICE_CONFIG
)
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
class ConnectionMonitor:
"""Bioyond连接监控器"""
def __init__(self, workstation, check_interval=30):
self.workstation = workstation
self.check_interval = check_interval
self._running = False
self._thread = None
self._last_status = "unknown"
def start(self):
if self._running:
return
self._running = True
self._thread = threading.Thread(target=self._monitor_loop, daemon=True, name="BioyondConnectionMonitor")
self._thread.start()
logger.info("Bioyond连接监控器已启动")
def stop(self):
self._running = False
if self._thread:
self._thread.join(timeout=2)
logger.info("Bioyond连接监控器已停止")
def _monitor_loop(self):
while self._running:
try:
# 使用 lightweight API 检查连接
# query_matial_type_list 是比较快的查询
start_time = time.time()
result = self.workstation.hardware_interface.material_type_list()
status = "online" if result else "offline"
msg = "Connection established" if status == "online" else "Failed to get material type list"
if status != self._last_status:
logger.info(f"Bioyond连接状态变更: {self._last_status} -> {status}")
self._publish_event(status, msg)
self._last_status = status
# 发布心跳 (可选,或者只在状态变更时发布)
# self._publish_event(status, msg)
except Exception as e:
logger.error(f"Bioyond连接检查异常: {e}")
if self._last_status != "error":
self._publish_event("error", str(e))
self._last_status = "error"
time.sleep(self.check_interval)
def _publish_event(self, status, message):
try:
if hasattr(self.workstation, "_ros_node") and self.workstation._ros_node:
event_data = {
"status": status,
"message": message,
"timestamp": datetime.now().isoformat()
}
# 动态发布消息,需要在 ROS2DeviceNode 中有对应支持
# 这里假设通用事件发布机制,使用 String 类型的 topic
# 话题: /<namespace>/events/device_status
ns = self.workstation._ros_node.namespace
topic = f"{ns}/events/device_status"
# 使用 ROS2DeviceNode 的发布功能
# 如果没有预定义的 publisher需要动态创建
# 注意workstation base node 可能没有自动创建 arbitrary publishers 的机制
# 这里我们先尝试用 String json 发布
# 在 ROS2DeviceNode 中通常需要先 create_publisher
# 为了简单起见,我们检查是否已有 publisher没有则创建
if not hasattr(self.workstation, "_device_status_pub"):
self.workstation._device_status_pub = self.workstation._ros_node.create_publisher(
String, topic, 10
)
self.workstation._device_status_pub.publish(
convert_to_ros_msg(String, json.dumps(event_data, ensure_ascii=False))
)
except Exception as e:
logger.error(f"发布设备状态事件失败: {e}")
class BioyondResourceSynchronizer(ResourceSynchronizer):
"""Bioyond资源同步器
@@ -174,9 +257,8 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
else:
logger.info(f"[同步→Bioyond] 物料不存在于 Bioyond将创建新物料并入库")
# 第1步获取仓库配置
from .config import WAREHOUSE_MAPPING
warehouse_mapping = WAREHOUSE_MAPPING
# 第1步从配置中获取仓库配置
warehouse_mapping = self.bioyond_config.get("warehouse_mapping", {})
# 确定目标仓库名称
parent_name = None
@@ -238,14 +320,20 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
# 第2步转换为 Bioyond 格式
logger.info(f"[同步→Bioyond] 🔄 转换物料为 Bioyond 格式...")
# 导入物料默认参数配置
from .config import MATERIAL_DEFAULT_PARAMETERS
# 从配置中获取物料默认参数
material_default_params = self.workstation.bioyond_config.get("material_default_parameters", {})
material_type_params = self.workstation.bioyond_config.get("material_type_parameters", {})
# 合并参数配置:物料名称参数 + typeId参数转换为 type:<uuid> 格式)
merged_params = material_default_params.copy()
for type_id, params in material_type_params.items():
merged_params[f"type:{type_id}"] = params
bioyond_material = resource_plr_to_bioyond(
[resource],
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"],
material_params=MATERIAL_DEFAULT_PARAMETERS
material_params=merged_params
)[0]
logger.info(f"[同步→Bioyond] 🔧 准备覆盖locations字段目标仓库: {parent_name}, 库位: {update_site}, UUID: {target_location_uuid[:8]}...")
@@ -468,13 +556,20 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
return material_bioyond_id
# 转换为 Bioyond 格式
from .config import MATERIAL_DEFAULT_PARAMETERS
# 从配置中获取物料默认参数
material_default_params = self.workstation.bioyond_config.get("material_default_parameters", {})
material_type_params = self.workstation.bioyond_config.get("material_type_parameters", {})
# 合并参数配置:物料名称参数 + typeId参数转换为 type:<uuid> 格式)
merged_params = material_default_params.copy()
for type_id, params in material_type_params.items():
merged_params[f"type:{type_id}"] = params
bioyond_material = resource_plr_to_bioyond(
[resource],
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"],
material_params=MATERIAL_DEFAULT_PARAMETERS
material_params=merged_params
)[0]
# ⚠️ 关键:创建物料时不设置 locations让 Bioyond 系统暂不分配库位
@@ -528,8 +623,7 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
logger.info(f"[物料入库] 目标库位: {update_site}")
# 获取仓库配置和目标库位 UUID
from .config import WAREHOUSE_MAPPING
warehouse_mapping = WAREHOUSE_MAPPING
warehouse_mapping = self.workstation.bioyond_config.get("warehouse_mapping", {})
parent_name = None
target_location_uuid = None
@@ -584,6 +678,44 @@ class BioyondWorkstation(WorkstationBase):
集成Bioyond物料管理的工作站实现
"""
def _publish_task_status(
self,
task_id: str,
task_type: str,
status: str,
result: dict = None,
progress: float = 0.0,
task_code: str = None
):
"""发布任务状态事件"""
try:
if not getattr(self, "_ros_node", None):
return
event_data = {
"task_id": task_id,
"task_code": task_code,
"task_type": task_type,
"status": status,
"progress": progress,
"timestamp": datetime.now().isoformat()
}
if result:
event_data["result"] = result
topic = f"{self._ros_node.namespace}/events/task_status"
if not hasattr(self, "_task_status_pub"):
self._task_status_pub = self._ros_node.create_publisher(
String, topic, 10
)
self._task_status_pub.publish(
convert_to_ros_msg(String, json.dumps(event_data, ensure_ascii=False))
)
except Exception as e:
logger.error(f"发布任务状态事件失败: {e}")
def __init__(
self,
bioyond_config: Optional[Dict[str, Any]] = None,
@@ -605,10 +737,28 @@ class BioyondWorkstation(WorkstationBase):
raise ValueError("Deck 配置不能为空,请在配置文件中添加正确的 deck 配置")
# 初始化 warehouses 属性
self.deck.warehouses = {}
for resource in self.deck.children:
if isinstance(resource, WareHouse):
self.deck.warehouses[resource.name] = resource
if not hasattr(self.deck, "warehouses") or self.deck.warehouses is None:
self.deck.warehouses = {}
# 仅当 warehouses 为空时尝试重新扫描(避免覆盖子类的修复)
if not self.deck.warehouses:
for resource in self.deck.children:
# 兼容性增强: 只要是仓库类别或者是 WareHouse 实例均可
is_warehouse = isinstance(resource, WareHouse) or getattr(resource, "category", "") == "warehouse"
# 如果配置中有定义,也可以认定为 warehouse
if not is_warehouse and "warehouse_mapping" in bioyond_config:
if resource.name in bioyond_config["warehouse_mapping"]:
is_warehouse = True
if is_warehouse:
self.deck.warehouses[resource.name] = resource
# 确保 category 被正确设置,方便后续使用
if getattr(resource, "category", "") != "warehouse":
try:
resource.category = "warehouse"
except:
pass
# 创建通信模块
self._create_communication_module(bioyond_config)
@@ -627,18 +777,22 @@ class BioyondWorkstation(WorkstationBase):
self._set_workflow_mappings(bioyond_config["workflow_mappings"])
# 准备 HTTP 报送接收服务配置(延迟到 post_init 启动)
# 从 bioyond_config 中获取,如果没有则使用 HTTP_SERVICE_CONFIG 的默认值
# 从 bioyond_config 中的 http_service_config 获取
http_service_cfg = bioyond_config.get("http_service_config", {})
self._http_service_config = {
"host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG["http_service_host"]),
"port": bioyond_config.get("http_service_port", HTTP_SERVICE_CONFIG["http_service_port"])
"host": http_service_cfg.get("http_service_host", "127.0.0.1"),
"port": http_service_cfg.get("http_service_port", 8080)
}
self.http_service = None # 将在 post_init 启动
self.http_service = None # 将在 post_init 启动
self.connection_monitor = None # 将在 post_init 启动
logger.info(f"Bioyond工作站初始化完成")
def __del__(self):
"""析构函数:清理资源,停止 HTTP 服务"""
try:
if hasattr(self, 'connection_monitor') and self.connection_monitor:
self.connection_monitor.stop()
if hasattr(self, 'http_service') and self.http_service is not None:
logger.info("正在停止 HTTP 报送服务...")
self.http_service.stop()
@@ -648,8 +802,19 @@ class BioyondWorkstation(WorkstationBase):
def post_init(self, ros_node: ROS2WorkstationNode):
self._ros_node = ros_node
# 启动连接监控
try:
self.connection_monitor = ConnectionMonitor(self)
self.connection_monitor.start()
except Exception as e:
logger.error(f"启动连接监控失败: {e}")
# 启动 HTTP 报送接收服务(现在 device_id 已可用)
if hasattr(self, '_http_service_config'):
# ⚠️ 检查子类是否已经自己管理 HTTP 服务
if self.bioyond_config.get("_disable_auto_http_service"):
logger.info("🔧 检测到 _disable_auto_http_service 标志,跳过自动启动 HTTP 服务")
logger.info(" 子类BioyondCellWorkstation已自行管理 HTTP 服务")
elif hasattr(self, '_http_service_config'):
try:
self.http_service = WorkstationHTTPService(
workstation_instance=self,
@@ -688,19 +853,14 @@ class BioyondWorkstation(WorkstationBase):
def _create_communication_module(self, config: Optional[Dict[str, Any]] = None) -> None:
"""创建Bioyond通信模块"""
# 创建默认配置
default_config = {
**API_CONFIG,
"workflow_mappings": WORKFLOW_MAPPINGS,
"material_type_mappings": MATERIAL_TYPE_MAPPINGS,
"warehouse_mapping": WAREHOUSE_MAPPING
}
# 如果传入了 config合并配置config 中的值会覆盖默认值)
# 直接使用传入的配置,不再使用默认值
# 所有配置必须从 JSON 文件中提供
if config:
self.bioyond_config = {**default_config, **config}
self.bioyond_config = config
else:
self.bioyond_config = default_config
# 如果没有配置,使用空字典(会导致后续错误,但这是预期的)
self.bioyond_config = {}
print("警告: 未提供 bioyond_config请确保在 JSON 配置文件中提供完整配置")
self.hardware_interface = BioyondV1RPC(self.bioyond_config)
@@ -1014,7 +1174,15 @@ class BioyondWorkstation(WorkstationBase):
workflow_id = self._get_workflow(actual_workflow_name)
if workflow_id:
self.workflow_sequence.append(workflow_id)
# 兼容 BioyondReactionStation 中 workflow_sequence 被重写为 property 的情况
if isinstance(self.workflow_sequence, list):
self.workflow_sequence.append(workflow_id)
elif hasattr(self, "_cached_workflow_sequence") and isinstance(self._cached_workflow_sequence, list):
self._cached_workflow_sequence.append(workflow_id)
else:
print(f"❌ 无法添加工作流: workflow_sequence 类型错误 {type(self.workflow_sequence)}")
return False
print(f"添加工作流到执行顺序: {actual_workflow_name} -> {workflow_id}")
return True
return False
@@ -1215,6 +1383,22 @@ class BioyondWorkstation(WorkstationBase):
# TODO: 根据实际业务需求处理步骤完成逻辑
# 例如:更新数据库、触发后续流程等
# 发布任务状态事件 (running/progress update)
self._publish_task_status(
task_id=data.get('orderCode'), # 使用 OrderCode 作为关联 ID
task_code=data.get('orderCode'),
task_type="bioyond_step",
status="running",
progress=0.5, # 步骤完成视为任务进行中
result={"step_name": data.get('stepName'), "step_id": data.get('stepId')}
)
# 更新物料信息
# 步骤完成后,物料状态可能发生变化(如位置、用量等),触发同步
logger.info(f"[步骤完成报送] 触发物料同步...")
self.resource_synchronizer.sync_from_external()
return {
"processed": True,
"step_id": data.get('stepId'),
@@ -1249,6 +1433,17 @@ class BioyondWorkstation(WorkstationBase):
# TODO: 根据实际业务需求处理通量完成逻辑
# 发布任务状态事件
self._publish_task_status(
task_id=data.get('orderCode'),
task_code=data.get('orderCode'),
task_type="bioyond_sample",
status="running",
progress=0.7,
result={"sample_id": data.get('sampleId'), "status": status_desc}
)
return {
"processed": True,
"sample_id": data.get('sampleId'),
@@ -1288,6 +1483,32 @@ class BioyondWorkstation(WorkstationBase):
# TODO: 根据实际业务需求处理任务完成逻辑
# 例如:更新物料库存、生成报表等
# 映射状态到事件状态
event_status = "completed"
if str(data.get('status')) in ["-11", "-12"]:
event_status = "error"
elif str(data.get('status')) == "30":
event_status = "completed"
else:
event_status = "running" # 其他状态视为运行中(或根据实际定义)
# 发布任务状态事件
self._publish_task_status(
task_id=data.get('orderCode'),
task_code=data.get('orderCode'),
task_type="bioyond_order",
status=event_status,
progress=1.0 if event_status in ["completed", "error"] else 0.9,
result={"order_name": data.get('orderName'), "status": status_desc, "materials_count": len(used_materials)}
)
# 更新物料信息
# 任务完成后,且状态为完成时,触发同步以更新最终物料状态
if event_status == "completed":
logger.info(f"[任务完成报送] 触发物料同步...")
self.resource_synchronizer.sync_from_external()
return {
"processed": True,
"order_code": data.get('orderCode'),

View File

@@ -0,0 +1,84 @@
# Modbus CSV 地址映射说明
本文档说明 `coin_cell_assembly_a.csv` 文件如何将命名节点映射到实际的 Modbus 地址,以及如何在代码中使用它们。
## 1. CSV 文件结构
地址表文件位于同级目录下:`coin_cell_assembly_a.csv`
每一行定义了一个 Modbus 节点,包含以下关键列:
| 列名 | 说明 | 示例 |
|------|------|------|
| **Name** | **节点名称** (代码中引用的 Key) | `COIL_ALUMINUM_FOIL` |
| **DataType** | 数据类型 (BOOL, INT16, FLOAT32, STRING) | `BOOL` |
| **Comment** | 注释说明 | `使用铝箔垫` |
| **Attribute** | 属性 (通常留空或用于额外标记) | |
| **DeviceType** | Modbus 寄存器类型 (`coil`, `hold_register`) | `coil` |
| **Address** | **Modbus 地址** (十进制) | `8340` |
### 示例行 (铝箔垫片)
```csv
COIL_ALUMINUM_FOIL,BOOL,,使用铝箔垫,,coil,8340,
```
- **名称**: `COIL_ALUMINUM_FOIL`
- **类型**: `coil` (线圈,读写单个位)
- **地址**: `8340`
---
## 2. 加载与注册流程
`coin_cell_assembly.py` 的初始化代码中:
1. **加载 CSV**: `BaseClient.load_csv()` 读取 CSV 并解析每行定义。
2. **注册节点**: `modbus_client.register_node_list()` 将解析后的节点注册到 Modbus 客户端实例中。
```python
# 代码位置: coin_cell_assembly.py (L174-175)
self.nodes = BaseClient.load_csv(os.path.join(os.path.dirname(__file__), 'coin_cell_assembly_a.csv'))
self.client = modbus_client.register_node_list(self.nodes)
```
---
## 3. 代码中的使用方式
注册后,通过 `self.client.use_node('节点名称')` 即可获取该节点对象并进行读写操作,无需关心具体地址。
### 控制铝箔垫片 (COIL_ALUMINUM_FOIL)
```python
# 代码位置: qiming_coin_cell_code 函数 (L1048)
self.client.use_node('COIL_ALUMINUM_FOIL').write(not lvbodian)
```
- **写入 True**: 对应 Modbus 功能码 05 (Write Single Coil),向地址 `8340` 写入 `1` (ON)。
- **写入 False**: 向地址 `8340` 写入 `0` (OFF)。
> **注意**: 代码中使用了 `not lvbodian`,这意味着逻辑是反转的。如果 `lvbodian` 参数为 `True` (默认),写入的是 `False` (不使用铝箔垫)。
---
## 4. 地址转换注意事项 (Modbus vs PLC)
CSV 中的 `Address` 列(如 `8340`)是 **Modbus 协议地址**
如果使用 InoProShop (汇川 PLC 编程软件),看到的可能是 **PLC 内部地址** (如 `%QX...``%MW...`)。这两者之间通常需要转换。
### 常见的转换规则 (示例)
- **Coil (线圈) %QX**:
- `Modbus地址 = 字节地址 * 8 + 位偏移`
- *例子*: `%QX834.0` -> `834 * 8 + 0` = `6672`
- *注意*: 如果 CSV 中配置的是 `8340`,这可能是一个自定义映射,或者是基于不同规则(如直接对应 Word 地址的某种映射,或者可能就是地址写错了/使用了非标准映射)。
- **Register (寄存器) %MW**:
- 通常直接对应,或者有偏移量 (如 Modbus 40001 = PLC MW0)。
### 验证方法
由于 `test_unilab_interact.py` 中发现 `8450` (CSV风格) 不工作,而 `6760` (%QX845.0 计算值) 工作正常,**建议对 CSV 中的其他地址也进行核实**,特别是像 `8340` 这样以 0 结尾看起来像是 "字节地址+0" 的数值,可能实际上应该是 `%QX834.0` 对应的 `6672`
如果发现设备控制无反应,请尝试按照标准的 Modbus 计算方式转换 PLC 地址。

View File

@@ -0,0 +1,352 @@
# 2026-01-13 物料搜寻确认弹窗自动处理功能
## 概述
本次更新为设备初始化流程添加了**物料搜寻确认弹窗自动检测与处理功能**。在设备初始化过程中PLC 会弹出物料搜寻确认对话框,现在系统可以根据用户参数自动点击"是"或"否"按钮,无需手动干预。
## 背景问题
### 原有流程
1. 调用 `func_pack_device_init_auto_start_combined()` 初始化设备
2. PLC 在初始化过程中弹出物料搜寻确认对话框
3. **需要人工手动点击**"是"或"否"按钮
4. PLC 继续完成初始化并启动
### 存在的问题
- 需要人工干预,无法实现全自动化
- 影响批量生产效率
- 容易遗忘点击导致流程卡住
## 解决方案
### 新增 Modbus 地址配置
`coin_cell_assembly_b.csv` 第 69-71 行添加三个 coil
| Name | DeviceType | Address | 说明 |
|------|-----------|---------|------|
| COIL_MATERIAL_SEARCH_DIALOG_APPEAR | coil | 6470 | 物料搜寻确认弹窗画面是否出现 |
| COIL_MATERIAL_SEARCH_CONFIRM_YES | coil | 6480 | 初始化物料搜寻确认按钮"是" |
| COIL_MATERIAL_SEARCH_CONFIRM_NO | coil | 6490 | 初始化物料搜寻确认按钮"否" |
**Modbus 地址转换:**
- CSV 6470 → Modbus 5176 (弹窗出现)
- CSV 6480 → Modbus 5184 (按钮"是")
- CSV 6490 → Modbus 5192 (按钮"否")
## 代码修改详情
### 1. coin_cell_assembly.py
#### 1.1 新增辅助方法 `_handle_material_search_dialog()`
**位置:** 第 799-901 行
**功能:**
- 监测物料搜寻确认弹窗是否出现Coil 5176
- 根据 `enable_search` 参数自动点击对应按钮
- 使用**脉冲模式**模拟真实按钮操作:`True` → 保持 0.5 秒 → `False`
**参数:**
- `enable_search: bool` - True=点击"是"(启用物料搜寻), False=点击"否"(不启用)
- `timeout: int = 30` - 等待弹窗出现的最大时间(秒)
**逻辑流程:**
```python
1. 监测 COIL_MATERIAL_SEARCH_DIALOG_APPEAR ( 0.5 秒检查一次)
2. 检测到弹窗出现 (Coil = True)
3. 选择按钮
- enable_search=True COIL_MATERIAL_SEARCH_CONFIRM_YES
- enable_search=False COIL_MATERIAL_SEARCH_CONFIRM_NO
4. 执行脉冲操作
- 写入 True (按下按钮)
- 等待 0.5
- 写入 False (释放按钮)
- 验证状态
```
#### 1.2 修改 `func_pack_device_init_auto_start_combined()`
**位置:** 第 904-1115 行
**主要改动:**
1. **添加新参数**
```python
def func_pack_device_init_auto_start_combined(
self,
material_search_enable: bool = False # 新增参数
) -> bool:
```
2. **内联初始化逻辑并集成弹窗检测**
- 不再调用 `self.func_pack_device_init()`
- 将初始化逻辑直接实现在函数内
- **在等待初始化完成的循环中实时检测弹窗**
- 避免死锁PLC 等待弹窗确认 ↔ 代码等待初始化完成
3. **关键代码片段**
```python
# 等待初始化完成,同时检测物料搜寻弹窗
while (self._sys_init_status()) == False:
# 检查超时
if time.time() - start_wait > max_wait_time:
raise RuntimeError(f"初始化超时")
# 如果还没处理弹窗,检测弹窗是否出现
if not dialog_handled:
dialog_state = self.client.use_node('COIL_MATERIAL_SEARCH_DIALOG_APPEAR').read(1)
if dialog_actual: # 弹窗出现
# 执行脉冲按钮点击
button_node.write(True) # 按下
time.sleep(0.5) # 保持
button_node.write(False) # 释放
dialog_handled = True
time.sleep(1)
```
4. **步骤调整**
- 步骤 0: 前置条件检查
- 步骤 1: 设备初始化(**包含弹窗检测**
- 步骤 1.5: 已在步骤 1 中完成
- 步骤 2: 切换自动模式
- 步骤 3: 启动设备
### 2. coin_cell_workstation.yaml
**位置:** 第 292-312 行
**修改内容:**
```yaml
auto-func_pack_device_init_auto_start_combined:
goal_default:
material_search_enable: false # 新增默认值
schema:
description: 组合函数:设备初始化 + 物料搜寻确认 + 切换自动模式 + 启动。初始化过程中会自动检测物料搜寻确认弹窗,并根据参数自动点击"是"或"否"按钮
goal:
properties:
material_search_enable: # 新增参数配置
default: false
description: 是否启用物料搜寻功能。设备初始化后会弹出物料搜寻确认弹窗,此参数控制自动点击"是"(启用)或"否"(不启用)。默认为false(不启用物料搜寻)
type: boolean
```
### 3. 测试脚本(已创建,用户已删除)
#### 3.1 test_material_search_dialog.py
- 从 CSV 动态加载 Modbus 地址
- 支持 4 种测试模式:
- `query` - 查询所有状态
- `dialog <0|1>` - 设置弹窗出现/消失
- `yes` - 脉冲点击"是"按钮
- `no` - 脉冲点击"否"按钮
- 兼容 pymodbus 3.x API
#### 3.2 更新其他测试脚本
- `test_coin_cell_reset.py` - 更新为 pymodbus 3.x API
- `test_unilab_interact.py` - 更新为 pymodbus 3.x API
## 使用方法
### 参数说明
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `material_search_enable` | boolean | `false` | 是否启用物料搜寻功能 |
### 调用示例
#### 1. 不启用物料搜寻(默认)
```python
# 默认参数,点击"否"按钮
await device.func_pack_device_init_auto_start_combined()
```
或在 YAML workflow 中:
```yaml
# 使用默认值 false不启用物料搜寻
- BatteryStation/auto-func_pack_device_init_auto_start_combined: {}
```
#### 2. 启用物料搜寻
```python
# 显式设置为 True点击"是"按钮
await device.func_pack_device_init_auto_start_combined(
material_search_enable=True
)
```
或在 YAML workflow 中:
```yaml
- BatteryStation/auto-func_pack_device_init_auto_start_combined:
goal:
material_search_enable: true # 启用物料搜寻
```
## 执行日志示例
```
26-01-13 [21:32:44] [INFO] 开始组合操作:设备初始化 → 物料搜寻确认 → 自动模式 → 启动
26-01-13 [21:32:44] [INFO] 【步骤 0/4】前置条件检查...
26-01-13 [21:32:44] [INFO] ✓ REG_UNILAB_INTERACT 检查通过
26-01-13 [21:32:44] [INFO] ✓ COIL_GB_L_IGNORE_CMD 检查通过
26-01-13 [21:32:44] [INFO] 【步骤 1/4】设备初始化...
26-01-13 [21:32:44] [INFO] 切换手动模式...
26-01-13 [21:32:46] [INFO] 发送初始化命令...
26-01-13 [21:32:47] [INFO] 等待初始化完成(同时监测物料搜寻弹窗)...
26-01-13 [21:33:05] [INFO] ✓ 在初始化过程中检测到物料搜寻确认弹窗!
26-01-13 [21:33:05] [INFO] 用户选择: 不启用物料搜寻(点击否)
26-01-13 [21:33:05] [INFO] → 按下按钮 '否'
26-01-13 [21:33:06] [INFO] → 释放按钮 '否'
26-01-13 [21:33:07] [INFO] ✓ 成功处理物料搜寻确认弹窗(选择: 否)
26-01-13 [21:33:08] [INFO] ✓ 初始化状态完成
26-01-13 [21:33:12] [INFO] ✓ 设备初始化完成
26-01-13 [21:33:12] [INFO] 【步骤 1.5/4】物料搜寻确认已在初始化过程中完成
26-01-13 [21:33:12] [INFO] 【步骤 2/4】切换自动模式...
26-01-13 [21:33:15] [INFO] ✓ 切换自动模式完成
26-01-13 [21:33:15] [INFO] 【步骤 3/4】启动设备...
26-01-13 [21:33:18] [INFO] ✓ 启动设备完成
26-01-13 [21:33:18] [INFO] 组合操作完成:设备已成功初始化、确认物料搜寻、切换自动模式并启动
```
## 技术要点
### 1. 脉冲模式按钮操作
模拟真实按钮按压过程:
1. 写入 `True` (按下)
2. 保持 0.5 秒
3. 写入 `False` (释放)
4. 验证状态
### 2. 避免死锁
**问题:** PLC 在初始化过程中等待弹窗确认,而代码等待初始化完成
**解决:** 在初始化等待循环中实时检测弹窗,一旦出现立即处理
### 3. 超时保护
- 弹窗检测超时30 秒(在 `_handle_material_search_dialog` 中)
- 初始化超时120 秒(在 `func_pack_device_init_auto_start_combined` 中)
### 4. PyModbus 3.x API 兼容
所有 Modbus 操作使用 keyword arguments
```python
# 读取
client.read_coils(address=5176, count=1)
# 写入
client.write_coil(address=5184, value=True)
```
## 向后兼容性
### 保留的原有函数
- `func_pack_device_init()` - 单独的初始化函数,不包含弹窗处理
- 仍可在 YAML 中通过 `auto-func_pack_device_init` 调用
- 用于不需要自动处理弹窗的场景
### 新增的功能
- 在 `func_pack_device_init_auto_start_combined()` 中集成弹窗处理
- 通过参数控制,默认行为与之前兼容(点击"否"
## 验证测试
### 测试场景
#### 场景 1默认参数不启用物料搜寻
```bash
# 调用时不传参数
BatteryStation/auto-func_pack_device_init_auto_start_combined: {}
```
**预期结果:**
- ✅ 检测到弹窗
- ✅ 自动点击"否"按钮
- ✅ 初始化完成并启动成功
#### 场景 2启用物料搜寻
```bash
# 设置 material_search_enable=true
BatteryStation/auto-func_pack_device_init_auto_start_combined:
goal:
material_search_enable: true
```
**预期结果:**
- ✅ 检测到弹窗
- ✅ 自动点击"是"按钮
- ✅ 初始化完成并启动成功
### 实际测试结果
**测试时间:** 2026-01-13 21:32:43
**测试参数:** `material_search_enable: false`
**测试结果:** ✅ 成功
**关键时间节点:**
- 21:33:05 - 检测到弹窗
- 21:33:05 - 按下"否"按钮
- 21:33:06 - 释放"否"按钮
- 21:33:07 - 弹窗处理完成
- 21:33:08 - 初始化状态完成
- 21:33:18 - 整个流程完成
**总耗时:** 约 35 秒(包含初始化全过程)
## 注意事项
1. **CSV 配置依赖**
- 确保 `coin_cell_assembly_b.csv` 包含 69-71 行的 coil 配置
- 地址转换逻辑:`modbus_addr = (csv_addr // 10) * 8 + (csv_addr % 10)`
2. **默认行为**
- 默认 `material_search_enable=false`,即不启用物料搜寻
- 如需启用,必须显式设置为 `true`
3. **日志级别**
- 弹窗检测过程中的 `waiting for init_cmd` 使用 DEBUG 级别
- 关键操作(检测到弹窗、按钮操作)使用 INFO 级别
4. **原有函数保留**
- `func_pack_device_init()` 仍然可用,但不包含弹窗处理
- 如果单独调用此函数,仍需手动处理弹窗
## 文件清单
### 修改的文件
1. `d:\UniLabdev\Uni-Lab-OS\unilabos\devices\workstation\coin_cell_assembly\coin_cell_assembly.py`
- 新增 `_handle_material_search_dialog()` 方法
- 修改 `func_pack_device_init_auto_start_combined()` 函数
2. `d:\UniLabdev\Uni-Lab-OS\unilabos\registry\devices\coin_cell_workstation.yaml`
- 更新 `auto-func_pack_device_init_auto_start_combined` 配置
- 添加 `material_search_enable` 参数说明
3. `d:\UniLabdev\Uni-Lab-OS\unilabos\devices\workstation\coin_cell_assembly\coin_cell_assembly_b.csv`
- 第 69-71 行添加三个 coil 配置
### 创建的测试文件(已删除)
1. `test_material_search_dialog.py` - 物料搜寻弹窗测试脚本
2. `test_coin_cell_reset.py` - 复位功能测试(更新为 pymodbus 3.x
3. `test_unilab_interact.py` - Unilab 交互测试(更新为 pymodbus 3.x
## 总结
本次更新成功实现了设备初始化过程中物料搜寻确认弹窗的自动化处理,主要优势:
**全自动化** - 无需人工干预
**参数可配** - 灵活控制是否启用物料搜寻
**实时检测** - 在初始化等待循环中检测,避免死锁
**脉冲模式** - 模拟真实按钮操作
**向后兼容** - 保留原有函数,不影响现有流程
**完整日志** - 详细记录每一步操作
**超时保护** - 防止无限等待
该功能已通过实际测试验证,可投入生产使用。
---
**文档版本:** 1.0
**创建日期:** 2026-01-13
**作者:** Antigravity AI Assistant
**最后更新:** 2026-01-13 21:36

View File

@@ -0,0 +1,645 @@
"""
纽扣电池组装工作站物料类定义
Button Battery Assembly Station Resource Classes
"""
from __future__ import annotations
from collections import OrderedDict
from typing import Any, Dict, List, Optional, TypedDict, Union, cast
from pylabrobot.resources.coordinate import Coordinate
from pylabrobot.resources.container import Container
from pylabrobot.resources.deck import Deck
from pylabrobot.resources.itemized_resource import ItemizedResource
from pylabrobot.resources.resource import Resource
from pylabrobot.resources.resource_stack import ResourceStack
from pylabrobot.resources.tip_rack import TipRack, TipSpot
from pylabrobot.resources.trash import Trash
from pylabrobot.resources.utils import create_ordered_items_2d
from unilabos.resources.battery.magazine import MagazineHolder_4_Cathode, MagazineHolder_6_Cathode, MagazineHolder_6_Anode, MagazineHolder_6_Battery
from unilabos.resources.battery.bottle_carriers import YIHUA_Electrolyte_12VialCarrier
from unilabos.resources.battery.electrode_sheet import ElectrodeSheet
# TODO: 这个应该只能放一个极片
class MaterialHoleState(TypedDict):
diameter: int
depth: int
max_sheets: int
info: Optional[str] # 附加信息
class MaterialHole(Resource):
"""料板洞位类"""
children: List[ElectrodeSheet] = []
def __init__(
self,
name: str,
size_x: float,
size_y: float,
size_z: float,
category: str = "material_hole",
**kwargs
):
super().__init__(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
category=category,
)
self._unilabos_state: MaterialHoleState = MaterialHoleState(
diameter=20,
depth=10,
max_sheets=1,
info=None
)
def get_all_sheet_info(self):
info_list = []
for sheet in self.children:
info_list.append(sheet._unilabos_state["info"])
return info_list
#这个函数函数好像没用,一般不会集中赋值质量
def set_all_sheet_mass(self):
for sheet in self.children:
sheet._unilabos_state["mass"] = 0.5 # 示例设置质量为0.5g
def load_state(self, state: Dict[str, Any]) -> None:
"""格式不变"""
super().load_state(state)
self._unilabos_state = state
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
"""格式不变"""
data = super().serialize_state()
data.update(self._unilabos_state) # Container自身的信息云端物料将保存这一data本地也通过这里的data进行读写当前类用来表示这个物料的长宽高大小的属性而datastate用来表示物料的内容细节等
return data
#移动极片前先取出对象
def get_sheet_with_name(self, name: str) -> Optional[ElectrodeSheet]:
for sheet in self.children:
if sheet.name == name:
return sheet
return None
def has_electrode_sheet(self) -> bool:
"""检查洞位是否有极片"""
return len(self.children) > 0
def assign_child_resource(
self,
resource: ElectrodeSheet,
location: Optional[Coordinate],
reassign: bool = True,
):
"""放置极片"""
# TODO: 这里要改diameter找不到加入._unilabos_state后应该没问题
#if resource._unilabos_state["diameter"] > self._unilabos_state["diameter"]:
# raise ValueError(f"极片直径 {resource._unilabos_state['diameter']} 超过洞位直径 {self._unilabos_state['diameter']}")
#if len(self.children) >= self._unilabos_state["max_sheets"]:
# raise ValueError(f"洞位已满,无法放置更多极片")
super().assign_child_resource(resource, location, reassign)
# 根据children的编号取物料对象。
def get_electrode_sheet_info(self, index: int) -> ElectrodeSheet:
return self.children[index]
class MaterialPlateState(TypedDict):
hole_spacing_x: float
hole_spacing_y: float
hole_diameter: float
info: Optional[str] # 附加信息
class MaterialPlate(ItemizedResource[MaterialHole]):
"""料板类 - 4x4个洞位每个洞位放1个极片"""
children: List[MaterialHole]
def __init__(
self,
name: str,
size_x: float,
size_y: float,
size_z: float,
ordered_items: Optional[Dict[str, MaterialHole]] = None,
ordering: Optional[OrderedDict[str, str]] = None,
category: str = "material_plate",
model: Optional[str] = None,
fill: bool = False
):
"""初始化料板
Args:
name: 料板名称
size_x: 长度 (mm)
size_y: 宽度 (mm)
size_z: 高度 (mm)
hole_diameter: 洞直径 (mm)
hole_depth: 洞深度 (mm)
hole_spacing_x: X方向洞位间距 (mm)
hole_spacing_y: Y方向洞位间距 (mm)
number: 编号
category: 类别
model: 型号
"""
self._unilabos_state: MaterialPlateState = MaterialPlateState(
hole_spacing_x=24.0,
hole_spacing_y=24.0,
hole_diameter=20.0,
info="",
)
# 创建4x4的洞位
# TODO: 这里要改,对应不同形状
holes = create_ordered_items_2d(
klass=MaterialHole,
num_items_x=4,
num_items_y=4,
dx=(size_x - 4 * self._unilabos_state["hole_spacing_x"]) / 2, # 居中
dy=(size_y - 4 * self._unilabos_state["hole_spacing_y"]) / 2, # 居中
dz=size_z,
item_dx=self._unilabos_state["hole_spacing_x"],
item_dy=self._unilabos_state["hole_spacing_y"],
size_x = 16,
size_y = 16,
size_z = 16,
)
if fill:
super().__init__(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
ordered_items=holes,
category=category,
model=model,
)
else:
super().__init__(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
ordered_items=ordered_items,
ordering=ordering,
category=category,
model=model,
)
def update_locations(self):
# TODO:调多次相加
holes = create_ordered_items_2d(
klass=MaterialHole,
num_items_x=4,
num_items_y=4,
dx=(self._size_x - 3 * self._unilabos_state["hole_spacing_x"]) / 2, # 居中
dy=(self._size_y - 3 * self._unilabos_state["hole_spacing_y"]) / 2, # 居中
dz=self._size_z,
item_dx=self._unilabos_state["hole_spacing_x"],
item_dy=self._unilabos_state["hole_spacing_y"],
size_x = 1,
size_y = 1,
size_z = 1,
)
for item, original_item in zip(holes.items(), self.children):
original_item.location = item[1].location
class PlateSlot(ResourceStack):
"""板槽位类 - 1个槽上能堆放8个板移板只能操作最上方的板"""
def __init__(
self,
name: str,
size_x: float,
size_y: float,
size_z: float,
max_plates: int = 8,
category: str = "plate_slot",
model: Optional[str] = None
):
"""初始化板槽位
Args:
name: 槽位名称
max_plates: 最大板数量
category: 类别
"""
super().__init__(
name=name,
direction="z", # Z方向堆叠
resources=[],
)
self.max_plates = max_plates
self.category = category
def can_add_plate(self) -> bool:
"""检查是否可以添加板"""
return len(self.children) < self.max_plates
def add_plate(self, plate: MaterialPlate) -> None:
"""添加料板"""
if not self.can_add_plate():
raise ValueError(f"槽位 {self.name} 已满,无法添加更多板")
self.assign_child_resource(plate)
def get_top_plate(self) -> MaterialPlate:
"""获取最上方的板"""
if len(self.children) == 0:
raise ValueError(f"槽位 {self.name} 为空")
return cast(MaterialPlate, self.get_top_item())
def take_top_plate(self) -> MaterialPlate:
"""取出最上方的板"""
top_plate = self.get_top_plate()
self.unassign_child_resource(top_plate)
return top_plate
def can_access_for_picking(self) -> bool:
"""检查是否可以进行取料操作(只有最上方的板能进行取料操作)"""
return len(self.children) > 0
def serialize(self) -> dict:
return {
**super().serialize(),
"max_plates": self.max_plates,
}
#是一种类型注解不用self
class BatteryState(TypedDict):
"""电池状态字典"""
diameter: float
height: float
assembly_pressure: float
electrolyte_volume: float
electrolyte_name: str
class Battery(Resource):
"""电池类 - 可容纳极片"""
children: List[ElectrodeSheet] = []
def __init__(
self,
name: str,
size_x=1,
size_y=1,
size_z=1,
category: str = "battery",
):
"""初始化电池
Args:
name: 电池名称
diameter: 直径 (mm)
height: 高度 (mm)
max_volume: 最大容量 (μL)
barcode: 二维码编号
category: 类别
model: 型号
"""
super().__init__(
name=name,
size_x=1,
size_y=1,
size_z=1,
category=category,
)
self._unilabos_state: BatteryState = BatteryState(
diameter = 1.0,
height = 1.0,
assembly_pressure = 1.0,
electrolyte_volume = 1.0,
electrolyte_name = "DP001"
)
def add_electrolyte_with_bottle(self, bottle: Bottle) -> bool:
to_add_name = bottle._unilabos_state["electrolyte_name"]
if bottle.aspirate_electrolyte(10):
if self.add_electrolyte(to_add_name, 10):
pass
else:
bottle._unilabos_state["electrolyte_volume"] += 10
def set_electrolyte(self, name: str, volume: float) -> None:
"""设置电解液信息"""
self._unilabos_state["electrolyte_name"] = name
self._unilabos_state["electrolyte_volume"] = volume
#这个应该没用,不会有加了后再加的事情
def add_electrolyte(self, name: str, volume: float) -> bool:
"""添加电解液信息"""
if name != self._unilabos_state["electrolyte_name"]:
return False
self._unilabos_state["electrolyte_volume"] += volume
def load_state(self, state: Dict[str, Any]) -> None:
"""格式不变"""
super().load_state(state)
self._unilabos_state = state
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
"""格式不变"""
data = super().serialize_state()
data.update(self._unilabos_state) # Container自身的信息云端物料将保存这一data本地也通过这里的data进行读写当前类用来表示这个物料的长宽高大小的属性而datastate用来表示物料的内容细节等
return data
# 电解液作为属性放进去
class BatteryPressSlotState(TypedDict):
"""电池状态字典"""
diameter: float =20.0
depth: float = 4.0
class BatteryPressSlot(Resource):
"""电池压制槽类 - 设备,可容纳一个电池"""
children: List[Battery] = []
def __init__(
self,
name: str = "BatteryPressSlot",
category: str = "battery_press_slot",
):
"""初始化电池压制槽
Args:
name: 压制槽名称
diameter: 直径 (mm)
depth: 深度 (mm)
category: 类别
model: 型号
"""
super().__init__(
name=name,
size_x=10,
size_y=12,
size_z=13,
category=category,
)
self._unilabos_state: BatteryPressSlotState = BatteryPressSlotState()
def has_battery(self) -> bool:
"""检查是否有电池"""
return len(self.children) > 0
def load_state(self, state: Dict[str, Any]) -> None:
"""格式不变"""
super().load_state(state)
self._unilabos_state = state
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
"""格式不变"""
data = super().serialize_state()
data.update(self._unilabos_state) # Container自身的信息云端物料将保存这一data本地也通过这里的data进行读写当前类用来表示这个物料的长宽高大小的属性而datastate用来表示物料的内容细节等
return data
def assign_child_resource(
self,
resource: Battery,
location: Optional[Coordinate],
reassign: bool = True,
):
"""放置极片"""
# TODO: 让高京看下槽位只有一个电池时是否这么写。
if self.has_battery():
raise ValueError(f"槽位已含有一个电池,无法再放置其他电池")
super().assign_child_resource(resource, location, reassign)
# 根据children的编号取物料对象。
def get_battery_info(self, index: int) -> Battery:
return self.children[0]
def TipBox64(
name: str,
size_x: float = 127.8,
size_y: float = 85.5,
size_z: float = 60.0,
category: str = "tip_rack",
model: Optional[str] = None,
):
"""64孔枪头盒类"""
from pylabrobot.resources.tip import Tip
# 创建12x8=96个枪头位
def make_tip():
return Tip(
has_filter=False,
total_tip_length=20.0,
maximal_volume=1000, # 1mL
fitting_depth=8.0,
)
tip_spots = create_ordered_items_2d(
klass=TipSpot,
num_items_x=12,
num_items_y=8,
dx=8.0,
dy=8.0,
dz=0.0,
item_dx=9.0,
item_dy=9.0,
size_x=10,
size_y=10,
size_z=0.0,
make_tip=make_tip,
)
idx_available = list(range(0, 32)) + list(range(64, 96))
tip_spots_available = {k: v for i, (k, v) in enumerate(tip_spots.items()) if i in idx_available}
tip_rack = TipRack(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
# ordered_items=tip_spots_available,
ordered_items=tip_spots,
category=category,
model=model,
with_tips=False,
)
tip_rack.set_tip_state([True]*32 + [False]*32 + [True]*32) # 前32和后32个有枪头中间32个无枪头
return tip_rack
class WasteTipBoxstate(TypedDict):
""""废枪头盒状态字典"""
max_tips: int = 100
tip_count: int = 0
#枪头不是一次性的(同一溶液则反复使用),根据寄存器判断
class WasteTipBox(Trash):
"""废枪头盒类 - 100个枪头容量"""
def __init__(
self,
name: str,
size_x: float = 127.8,
size_y: float = 85.5,
size_z: float = 60.0,
material_z_thickness=0,
max_volume=float("inf"),
category="trash",
model=None,
compute_volume_from_height=None,
compute_height_from_volume=None,
):
"""初始化废枪头盒
Args:
name: 废枪头盒名称
size_x: 长度 (mm)
size_y: 宽度 (mm)
size_z: 高度 (mm)
max_tips: 最大枪头容量
category: 类别
model: 型号
"""
super().__init__(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
category=category,
model=model,
)
self._unilabos_state: WasteTipBoxstate = WasteTipBoxstate()
def add_tip(self) -> None:
"""添加废枪头"""
if self._unilabos_state["tip_count"] >= self._unilabos_state["max_tips"]:
raise ValueError(f"废枪头盒 {self.name} 已满")
self._unilabos_state["tip_count"] += 1
def get_tip_count(self) -> int:
"""获取枪头数量"""
return self._unilabos_state["tip_count"]
def empty(self) -> None:
"""清空废枪头盒"""
self._unilabos_state["tip_count"] = 0
def load_state(self, state: Dict[str, Any]) -> None:
"""格式不变"""
super().load_state(state)
self._unilabos_state = state
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
"""格式不变"""
data = super().serialize_state()
data.update(self._unilabos_state) # Container自身的信息云端物料将保存这一data本地也通过这里的data进行读写当前类用来表示这个物料的长宽高大小的属性而datastate用来表示物料的内容细节等
return data
class CoincellDeck(Deck):
"""纽扣电池组装工作站台面类"""
def __init__(
self,
name: str = "coin_cell_deck",
size_x: float = 1450.0, # 1m
size_y: float = 1450.0, # 1m
size_z: float = 100.0, # 0.9m
origin: Coordinate = Coordinate(-2200, 0, 0),
category: str = "coin_cell_deck",
setup: bool = False, # 是否自动执行 setup
):
"""初始化纽扣电池组装工作站台面
Args:
name: 台面名称
size_x: 长度 (mm) - 1m
size_y: 宽度 (mm) - 1m
size_z: 高度 (mm) - 0.9m
origin: 原点坐标
category: 类别
setup: 是否自动执行 setup 配置标准布局
"""
super().__init__(
name=name,
size_x=1450.0,
size_y=1450.0,
size_z=100.0,
origin=origin,
)
if setup:
self.setup()
def setup(self) -> None:
"""设置工作站的标准布局 - 包含子弹夹、料盘、瓶架等完整配置"""
# ====================================== 子弹夹 ============================================
# 正极片4个洞位2x2布局
zhengji_zip = MagazineHolder_4_Cathode("正极&铝箔弹夹")
self.assign_child_resource(zhengji_zip, Coordinate(x=402.0, y=830.0, z=0))
# 正极壳、平垫片6个洞位2x2+2布局
zhengjike_zip = MagazineHolder_6_Cathode("正极壳&平垫片弹夹")
self.assign_child_resource(zhengjike_zip, Coordinate(x=566.0, y=272.0, z=0))
# 负极壳、弹垫片6个洞位2x2+2布局
fujike_zip = MagazineHolder_6_Anode("负极壳&弹垫片弹夹")
self.assign_child_resource(fujike_zip, Coordinate(x=474.0, y=276.0, z=0))
# 成品弹夹6个洞位3x2布局
chengpindanjia_zip = MagazineHolder_6_Battery("成品弹夹")
self.assign_child_resource(chengpindanjia_zip, Coordinate(x=260.0, y=156.0, z=0))
# ====================================== 物料板 ============================================
# 创建物料板料盘carrier- 4x4布局
# 负极料盘
fujiliaopan = MaterialPlate(name="负极料盘", size_x=120, size_y=100, size_z=10.0, fill=True)
self.assign_child_resource(fujiliaopan, Coordinate(x=708.0, y=794.0, z=0))
# for i in range(16):
# fujipian = ElectrodeSheet(name=f"{fujiliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
# fujiliaopan.children[i].assign_child_resource(fujipian, location=None)
# 隔膜料盘
gemoliaopan = MaterialPlate(name="隔膜料盘", size_x=120, size_y=100, size_z=10.0, fill=True)
self.assign_child_resource(gemoliaopan, Coordinate(x=718.0, y=918.0, z=0))
# for i in range(16):
# gemopian = ElectrodeSheet(name=f"{gemoliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
# gemoliaopan.children[i].assign_child_resource(gemopian, location=None)
# ====================================== 瓶架、移液枪 ============================================
# 在台面上放置 3x4 瓶架、6x2 瓶架 与 64孔移液枪头盒
# 奔耀上料5ml分液瓶小板 - 由奔曜跨站转运而来,不单独写,但是这里应该有一个堆栈用于摆放分液瓶小板
# bottle_rack_3x4 = BottleRack(
# name="bottle_rack_3x4",
# size_x=210.0,
# size_y=140.0,
# size_z=100.0,
# num_items_x=2,
# num_items_y=4,
# position_spacing=35.0,
# orientation="vertical",
# )
# self.assign_child_resource(bottle_rack_3x4, Coordinate(x=1542.0, y=717.0, z=0))
# 电解液缓存位 - 6x2布局
bottle_rack_6x2 = YIHUA_Electrolyte_12VialCarrier(name="bottle_rack_6x2")
self.assign_child_resource(bottle_rack_6x2, Coordinate(x=1050.0, y=358.0, z=0))
# 电解液回收位6x2
bottle_rack_6x2_2 = YIHUA_Electrolyte_12VialCarrier(name="bottle_rack_6x2_2")
self.assign_child_resource(bottle_rack_6x2_2, Coordinate(x=914.0, y=358.0, z=0))
tip_box = TipBox64(name="tip_box_64")
self.assign_child_resource(tip_box, Coordinate(x=782.0, y=514.0, z=0))
waste_tip_box = WasteTipBox(name="waste_tip_box")
self.assign_child_resource(waste_tip_box, Coordinate(x=778.0, y=622.0, z=0))
def YH_Deck(name=""):
cd = CoincellDeck(name=name)
cd.setup()
return cd
if __name__ == "__main__":
deck = create_coin_cell_deck()
print(deck)

View File

@@ -0,0 +1,133 @@
Name,DataType,InitValue,Comment,Attribute,DeviceType,Address,
COIL_SYS_START_CMD,BOOL,,,,coil,8010,
COIL_SYS_STOP_CMD,BOOL,,,,coil,8020,
COIL_SYS_RESET_CMD,BOOL,,,,coil,8030,
COIL_SYS_HAND_CMD,BOOL,,,,coil,8040,
COIL_SYS_AUTO_CMD,BOOL,,,,coil,8050,
COIL_SYS_INIT_CMD,BOOL,,,,coil,8060,
COIL_UNILAB_SEND_MSG_SUCC_CMD,BOOL,,,,coil,8700,
COIL_UNILAB_REC_MSG_SUCC_CMD,BOOL,,,,coil,8710,unilab_rec_msg_succ_cmd
COIL_SYS_START_STATUS,BOOL,,,,coil,8210,
COIL_SYS_STOP_STATUS,BOOL,,,,coil,8220,
COIL_SYS_RESET_STATUS,BOOL,,,,coil,8230,
COIL_SYS_HAND_STATUS,BOOL,,,,coil,8240,
COIL_SYS_AUTO_STATUS,BOOL,,,,coil,8250,
COIL_SYS_INIT_STATUS,BOOL,,,,coil,8260,
COIL_REQUEST_REC_MSG_STATUS,BOOL,,,,coil,8500,
COIL_REQUEST_SEND_MSG_STATUS,BOOL,,,,coil,8510,request_send_msg_status
REG_MSG_ELECTROLYTE_USE_NUM,INT16,,,,hold_register,11000,
REG_MSG_ELECTROLYTE_NUM,INT16,,,,hold_register,11002,unilab_send_msg_electrolyte_num
REG_MSG_ELECTROLYTE_VOLUME,INT16,,,,hold_register,11004,unilab_send_msg_electrolyte_vol
REG_MSG_ASSEMBLY_TYPE,INT16,,,,hold_register,11006,unilab_send_msg_assembly_type
REG_MSG_ASSEMBLY_PRESSURE,INT16,,,,hold_register,11008,unilab_send_msg_assembly_pressure
REG_DATA_ASSEMBLY_COIN_CELL_NUM,INT16,,,,hold_register,10000,data_assembly_coin_cell_num
REG_DATA_OPEN_CIRCUIT_VOLTAGE,FLOAT32,,,,hold_register,10002,data_open_circuit_voltage
REG_DATA_AXIS_X_POS,FLOAT32,,,,hold_register,10004,
REG_DATA_AXIS_Y_POS,FLOAT32,,,,hold_register,10006,
REG_DATA_AXIS_Z_POS,FLOAT32,,,,hold_register,10008,
REG_DATA_POLE_WEIGHT,FLOAT32,,,,hold_register,10010,data_pole_weight
REG_DATA_ASSEMBLY_PER_TIME,FLOAT32,,,,hold_register,10012,data_assembly_time
REG_DATA_ASSEMBLY_PRESSURE,INT16,,,,hold_register,10014,data_assembly_pressure
REG_DATA_ELECTROLYTE_VOLUME,INT16,,,,hold_register,10016,data_electrolyte_volume
REG_DATA_COIN_NUM,INT16,,,,hold_register,10018,data_coin_num
REG_DATA_ELECTROLYTE_CODE,STRING,,,,hold_register,10020,data_electrolyte_code()
REG_DATA_COIN_CELL_CODE,STRING,,,,hold_register,10030,data_coin_cell_code()
REG_DATA_STACK_VISON_CODE,STRING,,,,hold_register,12004,data_stack_vision_code()
REG_DATA_GLOVE_BOX_PRESSURE,FLOAT32,,,,hold_register,10050,data_glove_box_pressure
REG_DATA_GLOVE_BOX_WATER_CONTENT,FLOAT32,,,,hold_register,10052,data_glove_box_water_content
REG_DATA_GLOVE_BOX_O2_CONTENT,FLOAT32,,,,hold_register,10054,data_glove_box_o2_content
UNILAB_SEND_ELECTROLYTE_BOTTLE_NUM,BOOL,,,,coil,8720,
UNILAB_RECE_ELECTROLYTE_BOTTLE_NUM,BOOL,,,,coil,8520,
REG_MSG_ELECTROLYTE_NUM_USED,INT16,,,,hold_register,496,
REG_DATA_ELECTROLYTE_USE_NUM,INT16,,,,hold_register,10000,
UNILAB_SEND_FINISHED_CMD,BOOL,,,,coil,8730,
UNILAB_RECE_FINISHED_CMD,BOOL,,,,coil,8530,
REG_DATA_ASSEMBLY_TYPE,INT16,,,,hold_register,10018,ASSEMBLY_TYPE7or8
REG_UNILAB_INTERACT,BOOL,,,,coil,8450,
,,,,,coil,8320,
COIL_ALUMINUM_FOIL,BOOL,,,,coil,8340,
REG_MSG_NE_PLATE_MATRIX,INT16,,,,hold_register,440,
REG_MSG_SEPARATOR_PLATE_MATRIX,INT16,,,,hold_register,450,
REG_MSG_TIP_BOX_MATRIX,INT16,,,,hold_register,480,
REG_MSG_NE_PLATE_NUM,INT16,,,,hold_register,443,
REG_MSG_SEPARATOR_PLATE_NUM,INT16,,,,hold_register,453,
REG_MSG_PRESS_MODE,BOOL,,,,coil,8360,
,BOOL,,,,coil,8300,
,BOOL,,,,coil,8310,
COIL_GB_L_IGNORE_CMD,BOOL,,,,coil,8320,
COIL_GB_R_IGNORE_CMD,BOOL,,,,coil,8420,
,BOOL,,,,coil,8350,
COIL_ELECTROLYTE_DUAL_DROP_MODE,BOOL,,,,coil,8370,
,BOOL,,,,coil,8380,
,BOOL,,,,coil,8390,
,BOOL,,,,coil,8400,
,BOOL,,,,coil,8410,
REG_MSG_DUAL_DROP_FIRST_VOLUME,INT16,,,,hold_register,4001,
COIL_DUAL_DROP_SUCTION_TIMING,BOOL,,,,coil,8430,
COIL_DUAL_DROP_START_TIMING,BOOL,,,,coil,8470,
REG_MSG_BATTERY_CLEAN_IGNORE,BOOL,,,,coil,8460,
COIL_MATERIAL_SEARCH_DIALOG_APPEAR,BOOL,,,,coil,6470,
COIL_MATERIAL_SEARCH_CONFIRM_YES,BOOL,,,,coil,6480,
COIL_MATERIAL_SEARCH_CONFIRM_NO,BOOL,,,,coil,6490,
COIL_ALARM_100_SYSTEM_ERROR,BOOL,,,,coil,1000,异常100-系统异常
COIL_ALARM_101_EMERGENCY_STOP,BOOL,,,,coil,1010,异常101-急停
COIL_ALARM_111_GLOVEBOX_EMERGENCY_STOP,BOOL,,,,coil,1110,异常111-手套箱急停
COIL_ALARM_112_GLOVEBOX_GRATING_BLOCKED,BOOL,,,,coil,1120,异常112-手套箱内光栅遮挡
COIL_ALARM_160_PIPETTE_TIP_SHORTAGE,BOOL,,,,coil,1600,异常160-移液枪头缺料
COIL_ALARM_161_POSITIVE_SHELL_SHORTAGE,BOOL,,,,coil,1610,异常161-正极壳缺料
COIL_ALARM_162_ALUMINUM_FOIL_SHORTAGE,BOOL,,,,coil,1620,异常162-铝箔垫缺料
COIL_ALARM_163_POSITIVE_PLATE_SHORTAGE,BOOL,,,,coil,1630,异常163-正极片缺料
COIL_ALARM_164_SEPARATOR_SHORTAGE,BOOL,,,,coil,1640,异常164-隔膜缺料
COIL_ALARM_165_NEGATIVE_PLATE_SHORTAGE,BOOL,,,,coil,1650,异常165-负极片缺料
COIL_ALARM_166_FLAT_WASHER_SHORTAGE,BOOL,,,,coil,1660,异常166-平垫缺料
COIL_ALARM_167_SPRING_WASHER_SHORTAGE,BOOL,,,,coil,1670,异常167-弹垫缺料
COIL_ALARM_168_NEGATIVE_SHELL_SHORTAGE,BOOL,,,,coil,1680,异常168-负极壳缺料
COIL_ALARM_169_FINISHED_BATTERY_FULL,BOOL,,,,coil,1690,异常169-成品电池满料
COIL_ALARM_201_SERVO_AXIS_01_ERROR,BOOL,,,,coil,2010,异常201-伺服轴01异常
COIL_ALARM_202_SERVO_AXIS_02_ERROR,BOOL,,,,coil,2020,异常202-伺服轴02异常
COIL_ALARM_203_SERVO_AXIS_03_ERROR,BOOL,,,,coil,2030,异常203-伺服轴03异常
COIL_ALARM_204_SERVO_AXIS_04_ERROR,BOOL,,,,coil,2040,异常204-伺服轴04异常
COIL_ALARM_205_SERVO_AXIS_05_ERROR,BOOL,,,,coil,2050,异常205-伺服轴05异常
COIL_ALARM_206_SERVO_AXIS_06_ERROR,BOOL,,,,coil,2060,异常206-伺服轴06异常
COIL_ALARM_207_SERVO_AXIS_07_ERROR,BOOL,,,,coil,2070,异常207-伺服轴07异常
COIL_ALARM_208_SERVO_AXIS_08_ERROR,BOOL,,,,coil,2080,异常208-伺服轴08异常
COIL_ALARM_209_SERVO_AXIS_09_ERROR,BOOL,,,,coil,2090,异常209-伺服轴09异常
COIL_ALARM_210_SERVO_AXIS_10_ERROR,BOOL,,,,coil,2100,异常210-伺服轴10异常
COIL_ALARM_211_SERVO_AXIS_11_ERROR,BOOL,,,,coil,2110,异常211-伺服轴11异常
COIL_ALARM_212_SERVO_AXIS_12_ERROR,BOOL,,,,coil,2120,异常212-伺服轴12异常
COIL_ALARM_213_SERVO_AXIS_13_ERROR,BOOL,,,,coil,2130,异常213-伺服轴13异常
COIL_ALARM_214_SERVO_AXIS_14_ERROR,BOOL,,,,coil,2140,异常214-伺服轴14异常
COIL_ALARM_250_OTHER_COMPONENT_ERROR,BOOL,,,,coil,2500,异常250-其他元件异常
COIL_ALARM_251_PIPETTE_COMM_ERROR,BOOL,,,,coil,2510,异常251-移液枪通讯异常
COIL_ALARM_252_PIPETTE_ALARM,BOOL,,,,coil,2520,异常252-移液枪报警
COIL_ALARM_256_ELECTRIC_GRIPPER_ERROR,BOOL,,,,coil,2560,异常256-电爪异常
COIL_ALARM_262_RB_UNKNOWN_POSITION_ERROR,BOOL,,,,coil,2620,异常262-RB报警未知点位错误
COIL_ALARM_263_RB_XYZ_PARAM_LIMIT_ERROR,BOOL,,,,coil,2630,异常263-RB报警X、Y、Z参数超限制
COIL_ALARM_264_RB_VISION_PARAM_ERROR,BOOL,,,,coil,2640,异常264-RB报警视觉参数误差过大
COIL_ALARM_265_RB_NOZZLE_1_PICK_FAIL,BOOL,,,,coil,2650,异常265-RB报警1#吸嘴取料失败
COIL_ALARM_266_RB_NOZZLE_2_PICK_FAIL,BOOL,,,,coil,2660,异常266-RB报警2#吸嘴取料失败
COIL_ALARM_267_RB_NOZZLE_3_PICK_FAIL,BOOL,,,,coil,2670,异常267-RB报警3#吸嘴取料失败
COIL_ALARM_268_RB_NOZZLE_4_PICK_FAIL,BOOL,,,,coil,2680,异常268-RB报警4#吸嘴取料失败
COIL_ALARM_269_RB_TRAY_PICK_FAIL,BOOL,,,,coil,2690,异常269-RB报警取物料盘失败
COIL_ALARM_280_RB_COLLISION_ERROR,BOOL,,,,coil,2800,异常280-RB碰撞异常
COIL_ALARM_290_VISION_SYSTEM_COMM_ERROR,BOOL,,,,coil,2900,异常290-视觉系统通讯异常
COIL_ALARM_291_VISION_ALIGNMENT_NG,BOOL,,,,coil,2910,异常291-视觉对位NG异常
COIL_ALARM_292_BARCODE_SCANNER_COMM_ERROR,BOOL,,,,coil,2920,异常292-扫码枪通讯异常
COIL_ALARM_310_OCV_TRANSFER_NOZZLE_SUCTION_ERROR,BOOL,,,,coil,3100,异常310-开电移载吸嘴吸真空异常
COIL_ALARM_311_OCV_TRANSFER_NOZZLE_BREAK_ERROR,BOOL,,,,coil,3110,异常311-开电移载吸嘴破真空异常
COIL_ALARM_312_WEIGHT_TRANSFER_NOZZLE_SUCTION_ERROR,BOOL,,,,coil,3120,异常312-称重移载吸嘴吸真空异常
COIL_ALARM_313_WEIGHT_TRANSFER_NOZZLE_BREAK_ERROR,BOOL,,,,coil,3130,异常313-称重移载吸嘴破真空异常
COIL_ALARM_340_OCV_NOZZLE_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3400,异常340-开路电压吸嘴移载气缸异常
COIL_ALARM_342_OCV_NOZZLE_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3420,异常342-开路电压吸嘴升降气缸异常
COIL_ALARM_344_OCV_CRIMPING_CYLINDER_ERROR,BOOL,,,,coil,3440,异常344-开路电压旋压气缸异常
COIL_ALARM_350_WEIGHT_NOZZLE_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3500,异常350-称重吸嘴移载气缸异常
COIL_ALARM_352_WEIGHT_NOZZLE_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3520,异常352-称重吸嘴升降气缸异常
COIL_ALARM_354_CLEANING_CLOTH_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3540,异常354-清洗无尘布移载气缸异常
COIL_ALARM_356_CLEANING_CLOTH_PRESS_CYLINDER_ERROR,BOOL,,,,coil,3560,异常356-清洗无尘布压紧气缸异常
COIL_ALARM_360_ELECTROLYTE_BOTTLE_POSITION_CYLINDER_ERROR,BOOL,,,,coil,3600,异常360-电解液瓶定位气缸异常
COIL_ALARM_362_PIPETTE_TIP_BOX_POSITION_CYLINDER_ERROR,BOOL,,,,coil,3620,异常362-移液枪头盒定位气缸异常
COIL_ALARM_364_REAGENT_BOTTLE_GRIPPER_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3640,异常364-试剂瓶夹爪升降气缸异常
COIL_ALARM_366_REAGENT_BOTTLE_GRIPPER_CYLINDER_ERROR,BOOL,,,,coil,3660,异常366-试剂瓶夹爪气缸异常
COIL_ALARM_370_PRESS_MODULE_BLOW_CYLINDER_ERROR,BOOL,,,,coil,3700,异常370-压制模块吹气气缸异常
COIL_ALARM_151_ELECTROLYTE_BOTTLE_POSITION_ERROR,BOOL,,,,coil,1510,异常151-电解液瓶定位在籍异常
COIL_ALARM_152_ELECTROLYTE_BOTTLE_CAP_ERROR,BOOL,,,,coil,1520,异常152-电解液瓶盖在籍异常
1 Name DataType InitValue Comment Attribute DeviceType Address
2 COIL_SYS_START_CMD BOOL coil 8010
3 COIL_SYS_STOP_CMD BOOL coil 8020
4 COIL_SYS_RESET_CMD BOOL coil 8030
5 COIL_SYS_HAND_CMD BOOL coil 8040
6 COIL_SYS_AUTO_CMD BOOL coil 8050
7 COIL_SYS_INIT_CMD BOOL coil 8060
8 COIL_UNILAB_SEND_MSG_SUCC_CMD BOOL coil 8700
9 COIL_UNILAB_REC_MSG_SUCC_CMD BOOL coil 8710 unilab_rec_msg_succ_cmd
10 COIL_SYS_START_STATUS BOOL coil 8210
11 COIL_SYS_STOP_STATUS BOOL coil 8220
12 COIL_SYS_RESET_STATUS BOOL coil 8230
13 COIL_SYS_HAND_STATUS BOOL coil 8240
14 COIL_SYS_AUTO_STATUS BOOL coil 8250
15 COIL_SYS_INIT_STATUS BOOL coil 8260
16 COIL_REQUEST_REC_MSG_STATUS BOOL coil 8500
17 COIL_REQUEST_SEND_MSG_STATUS BOOL coil 8510 request_send_msg_status
18 REG_MSG_ELECTROLYTE_USE_NUM INT16 hold_register 11000
19 REG_MSG_ELECTROLYTE_NUM INT16 hold_register 11002 unilab_send_msg_electrolyte_num
20 REG_MSG_ELECTROLYTE_VOLUME INT16 hold_register 11004 unilab_send_msg_electrolyte_vol
21 REG_MSG_ASSEMBLY_TYPE INT16 hold_register 11006 unilab_send_msg_assembly_type
22 REG_MSG_ASSEMBLY_PRESSURE INT16 hold_register 11008 unilab_send_msg_assembly_pressure
23 REG_DATA_ASSEMBLY_COIN_CELL_NUM INT16 hold_register 10000 data_assembly_coin_cell_num
24 REG_DATA_OPEN_CIRCUIT_VOLTAGE FLOAT32 hold_register 10002 data_open_circuit_voltage
25 REG_DATA_AXIS_X_POS FLOAT32 hold_register 10004
26 REG_DATA_AXIS_Y_POS FLOAT32 hold_register 10006
27 REG_DATA_AXIS_Z_POS FLOAT32 hold_register 10008
28 REG_DATA_POLE_WEIGHT FLOAT32 hold_register 10010 data_pole_weight
29 REG_DATA_ASSEMBLY_PER_TIME FLOAT32 hold_register 10012 data_assembly_time
30 REG_DATA_ASSEMBLY_PRESSURE INT16 hold_register 10014 data_assembly_pressure
31 REG_DATA_ELECTROLYTE_VOLUME INT16 hold_register 10016 data_electrolyte_volume
32 REG_DATA_COIN_NUM INT16 hold_register 10018 data_coin_num
33 REG_DATA_ELECTROLYTE_CODE STRING hold_register 10020 data_electrolyte_code()
34 REG_DATA_COIN_CELL_CODE STRING hold_register 10030 data_coin_cell_code()
35 REG_DATA_STACK_VISON_CODE STRING hold_register 12004 data_stack_vision_code()
36 REG_DATA_GLOVE_BOX_PRESSURE FLOAT32 hold_register 10050 data_glove_box_pressure
37 REG_DATA_GLOVE_BOX_WATER_CONTENT FLOAT32 hold_register 10052 data_glove_box_water_content
38 REG_DATA_GLOVE_BOX_O2_CONTENT FLOAT32 hold_register 10054 data_glove_box_o2_content
39 UNILAB_SEND_ELECTROLYTE_BOTTLE_NUM BOOL coil 8720
40 UNILAB_RECE_ELECTROLYTE_BOTTLE_NUM BOOL coil 8520
41 REG_MSG_ELECTROLYTE_NUM_USED INT16 hold_register 496
42 REG_DATA_ELECTROLYTE_USE_NUM INT16 hold_register 10000
43 UNILAB_SEND_FINISHED_CMD BOOL coil 8730
44 UNILAB_RECE_FINISHED_CMD BOOL coil 8530
45 REG_DATA_ASSEMBLY_TYPE INT16 hold_register 10018 ASSEMBLY_TYPE7or8
46 REG_UNILAB_INTERACT BOOL coil 8450
47 coil 8320
48 COIL_ALUMINUM_FOIL BOOL coil 8340
49 REG_MSG_NE_PLATE_MATRIX INT16 hold_register 440
50 REG_MSG_SEPARATOR_PLATE_MATRIX INT16 hold_register 450
51 REG_MSG_TIP_BOX_MATRIX INT16 hold_register 480
52 REG_MSG_NE_PLATE_NUM INT16 hold_register 443
53 REG_MSG_SEPARATOR_PLATE_NUM INT16 hold_register 453
54 REG_MSG_PRESS_MODE BOOL coil 8360
55 BOOL coil 8300
56 BOOL coil 8310
57 COIL_GB_L_IGNORE_CMD BOOL coil 8320
58 COIL_GB_R_IGNORE_CMD BOOL coil 8420
59 BOOL coil 8350
60 COIL_ELECTROLYTE_DUAL_DROP_MODE BOOL coil 8370
61 BOOL coil 8380
62 BOOL coil 8390
63 BOOL coil 8400
64 BOOL coil 8410
65 REG_MSG_DUAL_DROP_FIRST_VOLUME INT16 hold_register 4001
66 COIL_DUAL_DROP_SUCTION_TIMING BOOL coil 8430
67 COIL_DUAL_DROP_START_TIMING BOOL coil 8470
68 REG_MSG_BATTERY_CLEAN_IGNORE BOOL coil 8460
69 COIL_MATERIAL_SEARCH_DIALOG_APPEAR BOOL coil 6470
70 COIL_MATERIAL_SEARCH_CONFIRM_YES BOOL coil 6480
71 COIL_MATERIAL_SEARCH_CONFIRM_NO BOOL coil 6490
72 COIL_ALARM_100_SYSTEM_ERROR BOOL coil 1000 异常100-系统异常
73 COIL_ALARM_101_EMERGENCY_STOP BOOL coil 1010 异常101-急停
74 COIL_ALARM_111_GLOVEBOX_EMERGENCY_STOP BOOL coil 1110 异常111-手套箱急停
75 COIL_ALARM_112_GLOVEBOX_GRATING_BLOCKED BOOL coil 1120 异常112-手套箱内光栅遮挡
76 COIL_ALARM_160_PIPETTE_TIP_SHORTAGE BOOL coil 1600 异常160-移液枪头缺料
77 COIL_ALARM_161_POSITIVE_SHELL_SHORTAGE BOOL coil 1610 异常161-正极壳缺料
78 COIL_ALARM_162_ALUMINUM_FOIL_SHORTAGE BOOL coil 1620 异常162-铝箔垫缺料
79 COIL_ALARM_163_POSITIVE_PLATE_SHORTAGE BOOL coil 1630 异常163-正极片缺料
80 COIL_ALARM_164_SEPARATOR_SHORTAGE BOOL coil 1640 异常164-隔膜缺料
81 COIL_ALARM_165_NEGATIVE_PLATE_SHORTAGE BOOL coil 1650 异常165-负极片缺料
82 COIL_ALARM_166_FLAT_WASHER_SHORTAGE BOOL coil 1660 异常166-平垫缺料
83 COIL_ALARM_167_SPRING_WASHER_SHORTAGE BOOL coil 1670 异常167-弹垫缺料
84 COIL_ALARM_168_NEGATIVE_SHELL_SHORTAGE BOOL coil 1680 异常168-负极壳缺料
85 COIL_ALARM_169_FINISHED_BATTERY_FULL BOOL coil 1690 异常169-成品电池满料
86 COIL_ALARM_201_SERVO_AXIS_01_ERROR BOOL coil 2010 异常201-伺服轴01异常
87 COIL_ALARM_202_SERVO_AXIS_02_ERROR BOOL coil 2020 异常202-伺服轴02异常
88 COIL_ALARM_203_SERVO_AXIS_03_ERROR BOOL coil 2030 异常203-伺服轴03异常
89 COIL_ALARM_204_SERVO_AXIS_04_ERROR BOOL coil 2040 异常204-伺服轴04异常
90 COIL_ALARM_205_SERVO_AXIS_05_ERROR BOOL coil 2050 异常205-伺服轴05异常
91 COIL_ALARM_206_SERVO_AXIS_06_ERROR BOOL coil 2060 异常206-伺服轴06异常
92 COIL_ALARM_207_SERVO_AXIS_07_ERROR BOOL coil 2070 异常207-伺服轴07异常
93 COIL_ALARM_208_SERVO_AXIS_08_ERROR BOOL coil 2080 异常208-伺服轴08异常
94 COIL_ALARM_209_SERVO_AXIS_09_ERROR BOOL coil 2090 异常209-伺服轴09异常
95 COIL_ALARM_210_SERVO_AXIS_10_ERROR BOOL coil 2100 异常210-伺服轴10异常
96 COIL_ALARM_211_SERVO_AXIS_11_ERROR BOOL coil 2110 异常211-伺服轴11异常
97 COIL_ALARM_212_SERVO_AXIS_12_ERROR BOOL coil 2120 异常212-伺服轴12异常
98 COIL_ALARM_213_SERVO_AXIS_13_ERROR BOOL coil 2130 异常213-伺服轴13异常
99 COIL_ALARM_214_SERVO_AXIS_14_ERROR BOOL coil 2140 异常214-伺服轴14异常
100 COIL_ALARM_250_OTHER_COMPONENT_ERROR BOOL coil 2500 异常250-其他元件异常
101 COIL_ALARM_251_PIPETTE_COMM_ERROR BOOL coil 2510 异常251-移液枪通讯异常
102 COIL_ALARM_252_PIPETTE_ALARM BOOL coil 2520 异常252-移液枪报警
103 COIL_ALARM_256_ELECTRIC_GRIPPER_ERROR BOOL coil 2560 异常256-电爪异常
104 COIL_ALARM_262_RB_UNKNOWN_POSITION_ERROR BOOL coil 2620 异常262-RB报警:未知点位错误
105 COIL_ALARM_263_RB_XYZ_PARAM_LIMIT_ERROR BOOL coil 2630 异常263-RB报警:X、Y、Z参数超限制
106 COIL_ALARM_264_RB_VISION_PARAM_ERROR BOOL coil 2640 异常264-RB报警:视觉参数误差过大
107 COIL_ALARM_265_RB_NOZZLE_1_PICK_FAIL BOOL coil 2650 异常265-RB报警:1#吸嘴取料失败
108 COIL_ALARM_266_RB_NOZZLE_2_PICK_FAIL BOOL coil 2660 异常266-RB报警:2#吸嘴取料失败
109 COIL_ALARM_267_RB_NOZZLE_3_PICK_FAIL BOOL coil 2670 异常267-RB报警:3#吸嘴取料失败
110 COIL_ALARM_268_RB_NOZZLE_4_PICK_FAIL BOOL coil 2680 异常268-RB报警:4#吸嘴取料失败
111 COIL_ALARM_269_RB_TRAY_PICK_FAIL BOOL coil 2690 异常269-RB报警:取物料盘失败
112 COIL_ALARM_280_RB_COLLISION_ERROR BOOL coil 2800 异常280-RB碰撞异常
113 COIL_ALARM_290_VISION_SYSTEM_COMM_ERROR BOOL coil 2900 异常290-视觉系统通讯异常
114 COIL_ALARM_291_VISION_ALIGNMENT_NG BOOL coil 2910 异常291-视觉对位NG异常
115 COIL_ALARM_292_BARCODE_SCANNER_COMM_ERROR BOOL coil 2920 异常292-扫码枪通讯异常
116 COIL_ALARM_310_OCV_TRANSFER_NOZZLE_SUCTION_ERROR BOOL coil 3100 异常310-开电移载吸嘴吸真空异常
117 COIL_ALARM_311_OCV_TRANSFER_NOZZLE_BREAK_ERROR BOOL coil 3110 异常311-开电移载吸嘴破真空异常
118 COIL_ALARM_312_WEIGHT_TRANSFER_NOZZLE_SUCTION_ERROR BOOL coil 3120 异常312-称重移载吸嘴吸真空异常
119 COIL_ALARM_313_WEIGHT_TRANSFER_NOZZLE_BREAK_ERROR BOOL coil 3130 异常313-称重移载吸嘴破真空异常
120 COIL_ALARM_340_OCV_NOZZLE_TRANSFER_CYLINDER_ERROR BOOL coil 3400 异常340-开路电压吸嘴移载气缸异常
121 COIL_ALARM_342_OCV_NOZZLE_LIFT_CYLINDER_ERROR BOOL coil 3420 异常342-开路电压吸嘴升降气缸异常
122 COIL_ALARM_344_OCV_CRIMPING_CYLINDER_ERROR BOOL coil 3440 异常344-开路电压旋压气缸异常
123 COIL_ALARM_350_WEIGHT_NOZZLE_TRANSFER_CYLINDER_ERROR BOOL coil 3500 异常350-称重吸嘴移载气缸异常
124 COIL_ALARM_352_WEIGHT_NOZZLE_LIFT_CYLINDER_ERROR BOOL coil 3520 异常352-称重吸嘴升降气缸异常
125 COIL_ALARM_354_CLEANING_CLOTH_TRANSFER_CYLINDER_ERROR BOOL coil 3540 异常354-清洗无尘布移载气缸异常
126 COIL_ALARM_356_CLEANING_CLOTH_PRESS_CYLINDER_ERROR BOOL coil 3560 异常356-清洗无尘布压紧气缸异常
127 COIL_ALARM_360_ELECTROLYTE_BOTTLE_POSITION_CYLINDER_ERROR BOOL coil 3600 异常360-电解液瓶定位气缸异常
128 COIL_ALARM_362_PIPETTE_TIP_BOX_POSITION_CYLINDER_ERROR BOOL coil 3620 异常362-移液枪头盒定位气缸异常
129 COIL_ALARM_364_REAGENT_BOTTLE_GRIPPER_LIFT_CYLINDER_ERROR BOOL coil 3640 异常364-试剂瓶夹爪升降气缸异常
130 COIL_ALARM_366_REAGENT_BOTTLE_GRIPPER_CYLINDER_ERROR BOOL coil 3660 异常366-试剂瓶夹爪气缸异常
131 COIL_ALARM_370_PRESS_MODULE_BLOW_CYLINDER_ERROR BOOL coil 3700 异常370-压制模块吹气气缸异常
132 COIL_ALARM_151_ELECTROLYTE_BOTTLE_POSITION_ERROR BOOL coil 1510 异常151-电解液瓶定位在籍异常
133 COIL_ALARM_152_ELECTROLYTE_BOTTLE_CAP_ERROR BOOL coil 1520 异常152-电解液瓶盖在籍异常

View File

@@ -459,12 +459,12 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
# 验证必需字段
if 'brand' in request_data:
if request_data['brand'] == "bioyond": # 奔曜
error_msg = request_data["text"]
logger.info(f"收到奔曜错误处理报送: {error_msg}")
material_data = request_data["text"]
logger.info(f"收到奔曜物料变更报送: {material_data}")
return HttpResponse(
success=True,
message=f"错误处理报送已收到: {error_msg}",
acknowledgment_id=f"ERROR_{int(time.time() * 1000)}_{error_msg.get('action_id', 'unknown')}",
message=f"物料变更报送已收到: {material_data}",
acknowledgment_id=f"MATERIAL_{int(time.time() * 1000)}_{material_data.get('id', 'unknown')}",
data=None
)
else:

View File

@@ -679,17 +679,14 @@ def _resolve_name(name: str, import_map: Dict[str, str]) -> str:
return name
_DECORATOR_ENUM_CLASSES = frozenset({"Side", "DataSource", "NodeType"})
def _resolve_attribute(node: ast.Attribute, import_map: Dict[str, str]) -> str:
"""
Resolve an attribute access like Side.NORTH or DataSource.HANDLE.
对于来自 ``unilabos.registry.decorators`` 的枚举类 (Side / DataSource / NodeType)
直接返回枚举成员名 (如 ``"NORTH"`` / ``"HANDLE"`` / ``"MANUAL_CONFIRM"``)
省去消费端二次 rsplit 解析。其它 import 仍返回完整模块路径。
Returns a string like "NORTH" for enum values, or
"module.path:Class.attr" for imported references.
"""
# Get the full dotted path
parts = []
current = node
while isinstance(current, ast.Attribute):
@@ -699,20 +696,21 @@ def _resolve_attribute(node: ast.Attribute, import_map: Dict[str, str]) -> str:
parts.append(current.id)
parts.reverse()
# parts = ["Side", "NORTH"] or ["DataSource", "HANDLE"] or ["NodeType", "MANUAL_CONFIRM"]
# parts = ["Side", "NORTH"] or ["DataSource", "HANDLE"]
if len(parts) >= 2:
base = parts[0]
attr = ".".join(parts[1:])
if base in _DECORATOR_ENUM_CLASSES:
source = import_map.get(base, "")
if not source or _REGISTRY_DECORATOR_MODULE in source:
return parts[-1]
# If the base is an imported name, resolve it
if base in import_map:
return f"{import_map[base]}.{attr}"
# For known enum-like patterns, return just the value
# e.g. Side.NORTH -> "NORTH"
if base in ("Side", "DataSource"):
return parts[-1]
return ".".join(parts)

View File

@@ -8,7 +8,7 @@ Usage:
device, action, resource,
InputHandle, OutputHandle,
ActionInputHandle, ActionOutputHandle,
HardwareInterface, Side, DataSource, NodeType,
HardwareInterface, Side, DataSource,
)
@device(
@@ -73,13 +73,6 @@ class DataSource(str, Enum):
EXECUTOR = "executor" # 从执行器输出数据 (用于 OutputHandle)
class NodeType(str, Enum):
"""动作的节点类型(用于区分 ILab 节点和人工确认节点等)"""
ILAB = "ILab"
MANUAL_CONFIRM = "manual_confirm"
# ---------------------------------------------------------------------------
# Device / Resource Handle (设备/资源级别端口, 序列化时包含 io_type)
# ---------------------------------------------------------------------------
@@ -342,7 +335,6 @@ def action(
description: str = "",
auto_prefix: bool = False,
parent: bool = False,
node_type: Optional["NodeType"] = None,
):
"""
动作方法装饰器
@@ -373,8 +365,6 @@ def action(
description: 动作描述
auto_prefix: 若为 True动作名使用 auto-{method_name} 形式(与无 @action 时一致)
parent: 若为 True当方法参数为空 (*args, **kwargs) 时,通过 MRO 从父类获取真实方法参数
node_type: 动作的节点类型 (NodeType.ILAB / NodeType.MANUAL_CONFIRM)。
不填写时不写入注册表。
"""
def decorator(func: F) -> F:
@@ -399,8 +389,6 @@ def action(
"auto_prefix": auto_prefix,
"parent": parent,
}
if node_type is not None:
meta["node_type"] = node_type.value if isinstance(node_type, NodeType) else str(node_type)
wrapper._action_registry_meta = meta # type: ignore[attr-defined]
# 设置 _is_always_free 保持与旧 @always_free 装饰器兼容
@@ -527,38 +515,6 @@ def clear_registry():
_registered_resources.clear()
# ---------------------------------------------------------------------------
# 枚举值归一化
# ---------------------------------------------------------------------------
def normalize_enum_value(raw: Any, enum_cls) -> Optional[str]:
"""将 AST 提取的枚举成员名 / YAML 值字符串 / 旧格式长路径统一归一化为枚举值。
适用于 Side、DataSource、NodeType 等继承自 ``str, Enum`` 的装饰器枚举。
处理以下格式:
- "MANUAL_CONFIRM" → NodeType["MANUAL_CONFIRM"].value = "manual_confirm"
- "manual_confirm" → NodeType("manual_confirm").value = "manual_confirm"
- "HANDLE" → DataSource["HANDLE"].value = "handle"
- "NORTH" → Side["NORTH"].value = "NORTH"
- 旧缓存长路径 "unilabos...NodeType.MANUAL_CONFIRM" → 先 rsplit 再查找
"""
if not raw:
return None
raw_str = str(raw)
if "." in raw_str:
raw_str = raw_str.rsplit(".", 1)[-1]
try:
return enum_cls[raw_str].value
except KeyError:
pass
try:
return enum_cls(raw_str).value
except ValueError:
return raw_str
# ---------------------------------------------------------------------------
# topic_config / not_action / always_free 装饰器
# ---------------------------------------------------------------------------

View File

@@ -1,589 +0,0 @@
workstation.bioyond_dispensing_station:
category:
- workstation
- bioyond
class:
action_value_mappings:
auto-batch_create_90_10_vial_feeding_tasks:
feedback: {}
goal: {}
goal_default:
delay_time: null
hold_m_name: null
liquid_material_name: NMP
speed: null
temperature: null
titration: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
delay_time:
type: string
hold_m_name:
type: string
liquid_material_name:
default: NMP
type: string
speed:
type: string
temperature:
type: string
titration:
type: string
required:
- titration
type: object
result: {}
required:
- goal
title: batch_create_90_10_vial_feeding_tasks参数
type: object
type: UniLabJsonCommand
auto-batch_create_diamine_solution_tasks:
feedback: {}
goal: {}
goal_default:
delay_time: null
liquid_material_name: NMP
solutions: null
speed: null
temperature: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
delay_time:
type: string
liquid_material_name:
default: NMP
type: string
solutions:
type: string
speed:
type: string
temperature:
type: string
required:
- solutions
type: object
result: {}
required:
- goal
title: batch_create_diamine_solution_tasks参数
type: object
type: UniLabJsonCommand
auto-brief_step_parameters:
feedback: {}
goal: {}
goal_default:
data: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
data:
type: object
required:
- data
type: object
result: {}
required:
- goal
title: brief_step_parameters参数
type: object
type: UniLabJsonCommand
auto-compute_experiment_design:
feedback: {}
goal: {}
goal_default:
m_tot: '70'
ratio: null
titration_percent: '0.03'
wt_percent: '0.25'
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
m_tot:
default: '70'
type: string
ratio:
type: object
titration_percent:
default: '0.03'
type: string
wt_percent:
default: '0.25'
type: string
required:
- ratio
type: object
result:
properties:
feeding_order:
items: {}
title: Feeding Order
type: array
return_info:
title: Return Info
type: string
solutions:
items: {}
title: Solutions
type: array
solvents:
additionalProperties: true
title: Solvents
type: object
titration:
additionalProperties: true
title: Titration
type: object
required:
- solutions
- titration
- solvents
- feeding_order
- return_info
title: ComputeExperimentDesignReturn
type: object
required:
- goal
title: compute_experiment_design参数
type: object
type: UniLabJsonCommand
auto-process_order_finish_report:
feedback: {}
goal: {}
goal_default:
report_request: null
used_materials: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
report_request:
type: string
used_materials:
type: string
required:
- report_request
- used_materials
type: object
result: {}
required:
- goal
title: process_order_finish_report参数
type: object
type: UniLabJsonCommand
auto-project_order_report:
feedback: {}
goal: {}
goal_default:
order_id: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
order_id:
type: string
required:
- order_id
type: object
result: {}
required:
- goal
title: project_order_report参数
type: object
type: UniLabJsonCommand
auto-query_resource_by_name:
feedback: {}
goal: {}
goal_default:
material_name: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
material_name:
type: string
required:
- material_name
type: object
result: {}
required:
- goal
title: query_resource_by_name参数
type: object
type: UniLabJsonCommand
auto-transfer_materials_to_reaction_station:
feedback: {}
goal: {}
goal_default:
target_device_id: null
transfer_groups: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
target_device_id:
type: string
transfer_groups:
type: array
required:
- target_device_id
- transfer_groups
type: object
result: {}
required:
- goal
title: transfer_materials_to_reaction_station参数
type: object
type: UniLabJsonCommand
auto-wait_for_multiple_orders_and_get_reports:
feedback: {}
goal: {}
goal_default:
batch_create_result: null
check_interval: 10
timeout: 7200
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
batch_create_result:
type: string
check_interval:
default: 10
type: integer
timeout:
default: 7200
type: integer
required: []
type: object
result: {}
required:
- goal
title: wait_for_multiple_orders_and_get_reports参数
type: object
type: UniLabJsonCommand
auto-workflow_sample_locations:
feedback: {}
goal: {}
goal_default:
workflow_id: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
workflow_id:
type: string
required:
- workflow_id
type: object
result: {}
required:
- goal
title: workflow_sample_locations参数
type: object
type: UniLabJsonCommand
create_90_10_vial_feeding_task:
feedback: {}
goal:
delay_time: delay_time
hold_m_name: hold_m_name
order_name: order_name
percent_10_1_assign_material_name: percent_10_1_assign_material_name
percent_10_1_liquid_material_name: percent_10_1_liquid_material_name
percent_10_1_target_weigh: percent_10_1_target_weigh
percent_10_1_volume: percent_10_1_volume
percent_10_2_assign_material_name: percent_10_2_assign_material_name
percent_10_2_liquid_material_name: percent_10_2_liquid_material_name
percent_10_2_target_weigh: percent_10_2_target_weigh
percent_10_2_volume: percent_10_2_volume
percent_10_3_assign_material_name: percent_10_3_assign_material_name
percent_10_3_liquid_material_name: percent_10_3_liquid_material_name
percent_10_3_target_weigh: percent_10_3_target_weigh
percent_10_3_volume: percent_10_3_volume
percent_90_1_assign_material_name: percent_90_1_assign_material_name
percent_90_1_target_weigh: percent_90_1_target_weigh
percent_90_2_assign_material_name: percent_90_2_assign_material_name
percent_90_2_target_weigh: percent_90_2_target_weigh
percent_90_3_assign_material_name: percent_90_3_assign_material_name
percent_90_3_target_weigh: percent_90_3_target_weigh
speed: speed
temperature: temperature
goal_default:
delay_time: ''
hold_m_name: ''
order_name: ''
percent_10_1_assign_material_name: ''
percent_10_1_liquid_material_name: ''
percent_10_1_target_weigh: ''
percent_10_1_volume: ''
percent_10_2_assign_material_name: ''
percent_10_2_liquid_material_name: ''
percent_10_2_target_weigh: ''
percent_10_2_volume: ''
percent_10_3_assign_material_name: ''
percent_10_3_liquid_material_name: ''
percent_10_3_target_weigh: ''
percent_10_3_volume: ''
percent_90_1_assign_material_name: ''
percent_90_1_target_weigh: ''
percent_90_2_assign_material_name: ''
percent_90_2_target_weigh: ''
percent_90_3_assign_material_name: ''
percent_90_3_target_weigh: ''
speed: ''
temperature: ''
handles: {}
result:
return_info: return_info
schema:
description: ''
properties:
feedback:
properties: {}
required: []
title: DispenStationVialFeed_Feedback
type: object
goal:
properties:
delay_time:
type: string
hold_m_name:
type: string
order_name:
type: string
percent_10_1_assign_material_name:
type: string
percent_10_1_liquid_material_name:
type: string
percent_10_1_target_weigh:
type: string
percent_10_1_volume:
type: string
percent_10_2_assign_material_name:
type: string
percent_10_2_liquid_material_name:
type: string
percent_10_2_target_weigh:
type: string
percent_10_2_volume:
type: string
percent_10_3_assign_material_name:
type: string
percent_10_3_liquid_material_name:
type: string
percent_10_3_target_weigh:
type: string
percent_10_3_volume:
type: string
percent_90_1_assign_material_name:
type: string
percent_90_1_target_weigh:
type: string
percent_90_2_assign_material_name:
type: string
percent_90_2_target_weigh:
type: string
percent_90_3_assign_material_name:
type: string
percent_90_3_target_weigh:
type: string
speed:
type: string
temperature:
type: string
required:
- order_name
- percent_90_1_assign_material_name
- percent_90_1_target_weigh
- percent_90_2_assign_material_name
- percent_90_2_target_weigh
- percent_90_3_assign_material_name
- percent_90_3_target_weigh
- percent_10_1_assign_material_name
- percent_10_1_target_weigh
- percent_10_1_volume
- percent_10_1_liquid_material_name
- percent_10_2_assign_material_name
- percent_10_2_target_weigh
- percent_10_2_volume
- percent_10_2_liquid_material_name
- percent_10_3_assign_material_name
- percent_10_3_target_weigh
- percent_10_3_volume
- percent_10_3_liquid_material_name
- speed
- temperature
- delay_time
- hold_m_name
title: DispenStationVialFeed_Goal
type: object
result:
properties:
return_info:
type: string
required:
- return_info
title: DispenStationVialFeed_Result
type: object
required:
- goal
title: DispenStationVialFeed
type: object
type: DispenStationVialFeed
create_diamine_solution_task:
feedback: {}
goal:
delay_time: delay_time
hold_m_name: hold_m_name
liquid_material_name: liquid_material_name
material_name: material_name
order_name: order_name
speed: speed
target_weigh: target_weigh
temperature: temperature
volume: volume
goal_default:
delay_time: ''
hold_m_name: ''
liquid_material_name: ''
material_name: ''
order_name: ''
speed: ''
target_weigh: ''
temperature: ''
volume: ''
handles: {}
result:
return_info: return_info
schema:
description: ''
properties:
feedback:
properties: {}
required: []
title: DispenStationSolnPrep_Feedback
type: object
goal:
properties:
delay_time:
type: string
hold_m_name:
type: string
liquid_material_name:
type: string
material_name:
type: string
order_name:
type: string
speed:
type: string
target_weigh:
type: string
temperature:
type: string
volume:
type: string
required:
- order_name
- material_name
- target_weigh
- volume
- liquid_material_name
- speed
- temperature
- delay_time
- hold_m_name
title: DispenStationSolnPrep_Goal
type: object
result:
properties:
return_info:
type: string
required:
- return_info
title: DispenStationSolnPrep_Result
type: object
required:
- goal
title: DispenStationSolnPrep
type: object
type: DispenStationSolnPrep
module: unilabos.devices.workstation.bioyond_studio.dispensing_station:BioyondDispensingStation
status_types: {}
type: python
config_info: []
description: ''
handles: []
icon: ''
init_param_schema:
config:
properties:
config:
type: string
deck:
type: string
required:
- config
- deck
type: object
data:
properties: {}
required: []
type: object
version: 1.0.0

View File

@@ -5303,13 +5303,13 @@ liquid_handler.biomek:
handler_key: tip_rack
label: tip_rack
output:
- data_key: sources
- data_key: liquid
data_source: handle
data_type: resource
handler_key: sources_out
label: sources
- data_key: targets
data_source: handle
- data_key: liquid
data_source: executor
data_type: resource
handler_key: targets_out
label: targets
@@ -7581,43 +7581,6 @@ liquid_handler.prcxi:
title: iter_tips参数
type: object
type: UniLabJsonCommand
auto-magnetic_action:
feedback: {}
goal: {}
goal_default:
height: null
is_wait: null
module_no: null
time: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
height:
type: integer
is_wait:
type: boolean
module_no:
type: integer
time:
type: integer
required:
- time
- module_no
- height
- is_wait
type: object
result: {}
required:
- goal
title: magnetic_action参数
type: object
type: UniLabJsonCommandAsync
auto-move_to:
feedback: {}
goal: {}
@@ -7746,47 +7709,6 @@ liquid_handler.prcxi:
title: shaker_action参数
type: object
type: UniLabJsonCommandAsync
auto-shaking_incubation_action:
feedback: {}
goal: {}
goal_default:
amplitude: null
is_wait: null
module_no: null
temperature: null
time: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
amplitude:
type: integer
is_wait:
type: boolean
module_no:
type: integer
temperature:
type: integer
time:
type: integer
required:
- time
- module_no
- amplitude
- is_wait
- temperature
type: object
result: {}
required:
- goal
title: shaking_incubation_action参数
type: object
type: UniLabJsonCommandAsync
auto-touch_tip:
feedback: {}
goal: {}
@@ -9914,7 +9836,7 @@ liquid_handler.prcxi:
data_type: resource
handler_key: targets_identifier
label: 转移目标
- data_key: tip_racks
- data_key: tip_rack
data_source: handle
data_type: resource
handler_key: tip_rack_identifier

View File

@@ -2815,8 +2815,8 @@ virtual_sample_demo:
readings: readings
samples: samples
goal_default:
readings: null
samples: null
readings: []
samples: []
handles:
input:
- data_key: readings
@@ -2846,12 +2846,18 @@ virtual_sample_demo:
handler_key: samples_result_out
label: 样品索引
placeholder_keys: {}
result: {}
result:
passed: passed
samples: samples
scores: scores
schema:
description: 对 split_and_measure 输出做二次分析,入参和出参都带 samples 列
properties:
feedback:
properties: {}
required: []
title: AnalyzeReadings_Feedback
type: object
goal:
properties:
readings:
@@ -2870,11 +2876,52 @@ virtual_sample_demo:
title: AnalyzeReadings_Goal
type: object
result:
properties:
passed:
description: 是否通过阈值
items:
type: boolean
type: array
samples:
description: 每行归属的输入样品 index (0-based)
items:
type: integer
type: array
scores:
description: 分析得分
items:
type: number
type: array
required:
- scores
- passed
- samples
title: AnalyzeReadings_Result
type: object
required:
- goal
title: analyze_readings参数
title: AnalyzeReadings
type: object
type: UniLabJsonCommandAsync
auto-cleanup:
feedback: {}
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: cleanup的参数schema
properties:
feedback: {}
goal:
properties: {}
required: []
type: object
result: {}
required:
- goal
title: cleanup参数
type: object
type: UniLabJsonCommandAsync
measure_samples:
@@ -2882,7 +2929,7 @@ virtual_sample_demo:
goal:
concentrations: concentrations
goal_default:
concentrations: null
concentrations: []
handles:
output:
- data_key: concentrations
@@ -2896,12 +2943,17 @@ virtual_sample_demo:
handler_key: absorbance_out
label: 吸光度列表
placeholder_keys: {}
result: {}
result:
absorbance: absorbance
concentrations: concentrations
schema:
description: 模拟光度测量,入参出参等长
properties:
feedback:
properties: {}
required: []
title: MeasureSamples_Feedback
type: object
goal:
properties:
concentrations:
@@ -2914,11 +2966,25 @@ virtual_sample_demo:
title: MeasureSamples_Goal
type: object
result:
properties:
absorbance:
description: 吸光度列表(与浓度等长)
items:
type: number
type: array
concentrations:
description: 原始浓度列表
items:
type: number
type: array
required:
- concentrations
- absorbance
title: MeasureSamples_Result
type: object
required:
- goal
title: measure_samples参数
title: MeasureSamples
type: object
type: UniLabJsonCommandAsync
split_and_measure:
@@ -2928,7 +2994,7 @@ virtual_sample_demo:
volumes: volumes
goal_default:
split_count: 3
volumes: null
volumes: []
handles:
output:
- data_key: readings
@@ -2947,16 +3013,21 @@ virtual_sample_demo:
handler_key: volumes_out
label: 均分体积
placeholder_keys: {}
result: {}
result:
readings: readings
samples: samples
volumes: volumes
schema:
description: 均分样品后逐份测量,输出带 samples 列标注归属
properties:
feedback:
properties: {}
required: []
title: SplitAndMeasure_Feedback
type: object
goal:
properties:
split_count:
default: 3
description: 每个样品均分的份数
type: integer
volumes:
@@ -2969,11 +3040,31 @@ virtual_sample_demo:
title: SplitAndMeasure_Goal
type: object
result:
properties:
readings:
description: 测量读数
items:
type: number
type: array
samples:
description: 每行归属的输入样品 index (0-based)
items:
type: integer
type: array
volumes:
description: 均分后的体积列表
items:
type: number
type: array
required:
- volumes
- readings
- samples
title: SplitAndMeasure_Result
type: object
required:
- goal
title: split_and_measure参数
title: SplitAndMeasure
type: object
type: UniLabJsonCommandAsync
module: unilabos.devices.virtual.virtual_sample_demo:VirtualSampleDemo
@@ -2988,7 +3079,7 @@ virtual_sample_demo:
config:
properties:
config:
type: object
type: string
device_id:
type: string
required: []

View File

@@ -33,8 +33,6 @@ from unilabos.registry.decorators import (
is_not_action,
is_always_free,
get_topic_config,
NodeType,
normalize_enum_value,
)
from unilabos.registry.utils import (
ROSMsgNotFound,
@@ -161,10 +159,9 @@ class Registry:
ast_entry = self.device_type_registry.get("host_node", {})
ast_actions = ast_entry.get("class", {}).get("action_value_mappings", {})
# 取出 AST 生成的 action entries, 补充特定覆写
# 取出 AST 生成的 auto-method entries, 补充特定覆写
test_latency_action = ast_actions.get("auto-test_latency", {})
test_resource_action = ast_actions.get("auto-test_resource", {})
manual_confirm_action = ast_actions.get("manual_confirm", {})
test_resource_action["handles"] = {
"input": [
{
@@ -237,11 +234,9 @@ class Registry:
"parent": "unilabos_nodes",
"class_name": "unilabos_class",
},
"always_free": True,
},
"test_latency": test_latency_action,
"auto-test_resource": test_resource_action,
"manual_confirm": manual_confirm_action,
},
"init_params": {},
},
@@ -852,9 +847,6 @@ class Registry:
}
if (action_args or {}).get("always_free") or method_info.get("always_free"):
entry["always_free"] = True
nt = normalize_enum_value((action_args or {}).get("node_type"), NodeType)
if nt:
entry["node_type"] = nt
return action_name, entry
# 1) auto- actions
@@ -979,9 +971,6 @@ class Registry:
}
if action_args.get("always_free") or method_info.get("always_free"):
action_entry["always_free"] = True
nt = normalize_enum_value(action_args.get("node_type"), NodeType)
if nt:
action_entry["node_type"] = nt
action_value_mappings[action_name] = action_entry
action_value_mappings = dict(sorted(action_value_mappings.items()))
@@ -1164,7 +1153,7 @@ class Registry:
return Path(BasicConfig.working_dir) / "registry_cache.pkl"
return None
_CACHE_VERSION = 4
_CACHE_VERSION = 3
def _load_config_cache(self) -> dict:
import pickle
@@ -1889,9 +1878,6 @@ class Registry:
}
if v.get("always_free"):
entry["always_free"] = True
old_node_type = old_cfg.get("node_type")
if old_node_type in [NodeType.ILAB.value, NodeType.MANUAL_CONFIRM.value]:
entry["node_type"] = old_node_type
device_config["class"]["action_value_mappings"][action_key] = entry
device_config["init_param_schema"] = {}

View File

@@ -0,0 +1,170 @@
# UniLabOS 资源注册架构详解
> **目标受众**: 主要开发 `unilabos/registry/devices` 抽象层的开发者
> **最后更新**: 2026-01-11
> **维护者**: Uni-Lab-OS 开发团队
本文档详细说明 UniLabOS 资源注册系统的架构、资源的完整生命周期,以及如何实现动态物料位置追踪。
---
## 📚 目录
- [核心概念](#核心概念)
- [三层架构详解](#三层架构详解)
- [资源注册机制](#资源注册机制)
- [物料生命周期管理](#物料生命周期管理)
- [动态物料位置追踪](#动态物料位置追踪)
- [实战案例](#实战案例)
- [常见问题排查](#常见问题排查)
---
## 核心概念
### 1. Resources vs Registry
UniLabOS 采用**声明式注册**模式,将资源的**定义**Python与**注册信息**YAML分离
```
┌──────────────────────────────────────────────────────────┐
│ unilabos/resources (Python 实现) │
│ - 定义资源的物理属性、行为和创建逻辑 │
│ - 例如: 瓶子的尺寸、容量、材质 │
├──────────────────────────────────────────────────────────┤
│ unilabos/registry/resources (YAML 注册表) │
│ - 声明哪些资源可以被前端使用 │
│ - 定义资源的分类、图标、初始化参数 │
└──────────────────────────────────────────────────────────┘
```
**为什么要分离?**
1. **解耦**: Python 代码可以定义无限多的资源类,但只有在 YAML 中注册的才能被前端识别
2. **灵活性**: 无需修改 Python 代码,只需修改 YAML 就能添加/移除可用资源
3. **可扩展性**: 第三方开发者可以通过 YAML 注册自己的资源,无需修改核心代码
---
## 三层架构详解
UniLabOS 资源系统采用**三层架构**实现从前端UI到底层硬件的完整映射
### 架构图
```
┌─────────────────────────────────────────────────────┐
│ 第1层: YAML 注册表 (registry/resources) │
│ - 告诉系统"哪些资源可用" │
│ - 前端通过此层获取可用资源列表 │
│ - 文件: YB_bottle.yaml, YB_bottle_carriers.yaml │
├─────────────────────────────────────────────────────┤
│ 第2层: Python 实现 (resources/bioyond) │
│ - 定义资源的具体属性和行为 │
│ - 创建资源实例的工厂函数 │
│ - 文件: YB_bottles.py, YB_bottle_carriers.py │
├─────────────────────────────────────────────────────┤
│ 第3层: Hardware/API 集成 (devices/workstation) │
│ - 连接 Bioyond 系统 API │
│ - 同步物料位置和状态 │
│ - 文件: station.py, bioyond_rpc.py, config.py │
└─────────────────────────────────────────────────────┘
```
### 第1层: YAML 注册表
#### YB_bottle.yaml - 单个瓶子注册
```yaml
YB_5ml_fenyeping:
category:
- yb3 # 系统分类
- YB_bottle # 资源类型
class:
module: unilabos.resources.bioyond.YB_bottles:YB_5ml_fenyeping # Python 函数路径
type: pylabrobot # 框架类型
description: YB_5ml_fenyeping # 前端显示名称
handles: []
icon: '' # 图标路径
init_param_schema: {} # 初始化参数 schema
registry_type: resource
version: 1.0.0
```
**作用:**
- 前端通过读取此文件知道有一个叫 "YB_5ml_fenyeping" 的资源
- 用户拖拽时,系统会调用 `YB_bottles:YB_5ml_fenyeping()` 创建实例
#### YB_bottle_carriers.yaml - 载架(容器)注册
```yaml
YB_5ml_fenyepingban:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_5ml_fenyepingban
type: pylabrobot
description: YB_5ml_fenyepingban # 5ml分液瓶板
```
**作用:**
- 载架是容器,里面可以放多个瓶子
- 例如: `YB_5ml_fenyepingban` 是一个 4x2 布局的板,可以放 8 个 5ml 瓶子
### 第2层: Python 实现
#### YB_bottles.py - 瓶子工厂函数
```python
def YB_5ml_fenyeping(
name: str,
diameter: float = 20.0, # 直径 (mm)
height: float = 50.0, # 高度 (mm)
max_volume: float = 5000.0, # 最大容量 (μL)
barcode: str = None,
) -> Bottle:
\"\"\"创建5ml分液瓶\"\"\"
return Bottle(
name=name,
diameter=diameter,
height=height,
max_volume=max_volume,
barcode=barcode,
model="YB_5ml_fenyeping", # ⭐ 与 YAML 中的名称对应
)
```
**关键点:**
- 函数名 `YB_5ml_fenyeping` 必须与 YAML 中的 `module` 路径末尾一致
- 返回一个 `Bottle` 对象PyLabRobot 资源类型)
- `model` 字段用于在 Bioyond 系统中识别资源类型
**详细文档请参考完整版 README**
---
## 相关文件索引
### 核心文件
| 文件 | 功能 | 路径 |
|------|------|------|
| `YB_bottle.yaml` | 瓶子注册表 | `unilabos/registry/resources/bioyond/` |
| `YB_bottle_carriers.yaml` | 载架注册表 | `unilabos/registry/resources/bioyond/` |
| `deck.yaml` | Deck注册表 | `unilabos/registry/resources/bioyond/` |
| `YB_bottles.py` | 瓶子实现 | `unilabos/resources/bioyond/` |
| `YB_bottle_carriers.py` | 载架实现 | `unilabos/resources/bioyond/` |
| `YB_warehouses.py` | 仓库实现 | `unilabos/resources/bioyond/` |
| `decks.py` | Deck布局 | `unilabos/resources/bioyond/` |
| `station.py` | 物料同步 | `unilabos/devices/workstation/bioyond_studio/` |
| `config.py` | UUID映射 | `unilabos/devices/workstation/bioyond_studio/` |
### 仓库相关文档
- [README_WAREHOUSE.md](../../resources/bioyond/README_WAREHOUSE.md) - 仓库系统开发指南
---
**维护者:** Uni-Lab-OS 开发团队
**最后更新:** 2026-01-11

View File

@@ -20,6 +20,17 @@ BIOYOND_PolymerStation_Liquid_Vial:
icon: ''
init_param_schema: {}
version: 1.0.0
BIOYOND_PolymerStation_Measurement_Vial:
category:
- bottles
class:
module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_Measurement_Vial
type: pylabrobot
description: 聚合站-测量小瓶(测密度)
handles: []
icon: ''
init_param_schema: {}
version: 1.0.0
BIOYOND_PolymerStation_Reactor:
category:
- bottles

File diff suppressed because it is too large Load Diff

View File

@@ -1,70 +0,0 @@
PRCXI_Heating_Module:
category:
- prcxi
- modules
class:
module: unilabos.devices.liquid_handling.prcxi.prcxi_modules:PRCXI_Heating_Module
type: pylabrobot
description: '加热模块 (Code: HEAT-MOD)'
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
PRCXI_MetalCooling_Module:
category:
- prcxi
- modules
class:
module: unilabos.devices.liquid_handling.prcxi.prcxi_modules:PRCXI_MetalCooling_Module
type: pylabrobot
description: '金属冷却模块 (Code: METAL-COOL-MOD)'
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
PRCXI_Shaking_Module:
category:
- prcxi
- modules
class:
module: unilabos.devices.liquid_handling.prcxi.prcxi_modules:PRCXI_Shaking_Module
type: pylabrobot
description: '震荡模块 (Code: SHAKE-MOD)'
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
PRCXI_Heating_Shaking_Module:
category:
- prcxi
- modules
class:
module: unilabos.devices.liquid_handling.prcxi.prcxi_modules:PRCXI_Heating_Shaking_Module
type: pylabrobot
description: '加热震荡模块 (Code: HEAT-SHAKE-MOD)'
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
PRCXI_Magnetic_Module:
category:
- prcxi
- modules
class:
module: unilabos.devices.liquid_handling.prcxi.prcxi_modules:PRCXI_Magnetic_Module
type: pylabrobot
description: '磁吸模块 (Code: MAG-MOD)'
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0

View File

@@ -17,7 +17,6 @@ from typing import Any, Dict, List, Optional, Tuple, Union
from msgcenterpy.instances.typed_dict_instance import TypedDictMessageInstance
from unilabos.utils.cls_creator import import_class
from unilabos.registry.decorators import Side, DataSource, normalize_enum_value
_logger = logging.getLogger(__name__)
@@ -488,7 +487,10 @@ def normalize_ast_handles(handles_raw: Any) -> List[Dict[str, Any]]:
}
side = h.get("side")
if side:
entry["side"] = normalize_enum_value(side, Side) or side
if isinstance(side, str) and "." in side:
val = side.rsplit(".", 1)[-1]
side = val.lower() if val in ("LEFT", "RIGHT", "TOP", "BOTTOM") else val
entry["side"] = side
label = h.get("label")
if label:
entry["label"] = label
@@ -497,7 +499,10 @@ def normalize_ast_handles(handles_raw: Any) -> List[Dict[str, Any]]:
entry["data_key"] = data_key
data_source = h.get("data_source")
if data_source:
entry["data_source"] = normalize_enum_value(data_source, DataSource) or data_source
if isinstance(data_source, str) and "." in data_source:
val = data_source.rsplit(".", 1)[-1]
data_source = val.lower() if val in ("HANDLE", "EXECUTOR") else val
entry["data_source"] = data_source
description = h.get("description")
if description:
entry["description"] = description
@@ -532,12 +537,17 @@ def normalize_ast_action_handles(handles_raw: Any) -> Dict[str, Any]:
"data_type": h.get("data_type", ""),
"label": h.get("label", ""),
}
_FIELD_ENUM_MAP = {"side": Side, "data_source": DataSource}
for opt_key in ("side", "data_key", "data_source", "description", "io_type"):
val = h.get(opt_key)
if val is not None:
if opt_key in _FIELD_ENUM_MAP:
val = normalize_enum_value(val, _FIELD_ENUM_MAP[opt_key]) or val
# Only resolve enum-style refs (e.g. DataSource.HANDLE -> handle) for data_source/side
# data_key values like "wells.@flatten", "@this.0@@@plate" must be preserved as-is
if (
isinstance(val, str)
and "." in val
and opt_key not in ("io_type", "data_key")
):
val = val.rsplit(".", 1)[-1].lower()
entry[opt_key] = val
# io_type: only add when explicitly set; do not default output to "sink" (YAML convention omits it)

View File

@@ -0,0 +1,56 @@
from pylabrobot.resources import create_homogeneous_resources, Coordinate, ResourceHolder, create_ordered_items_2d
from unilabos.resources.itemized_carrier import Bottle, BottleCarrier
from unilabos.resources.bioyond.YB_bottles import (
YB_pei_ye_xiao_Bottle,
)
# 命名约定:试剂瓶-Bottle烧杯-Beaker烧瓶-Flask小瓶-Vial
def YIHUA_Electrolyte_12VialCarrier(name: str) -> BottleCarrier:
"""12瓶载架 - 2x6布局"""
# 载架尺寸 (mm)
carrier_size_x = 120.0
carrier_size_y = 250.0
carrier_size_z = 50.0
# 瓶位尺寸
bottle_diameter = 35.0
bottle_spacing_x = 35.0 # X方向间距
bottle_spacing_y = 35.0 # Y方向间距
# 计算起始位置 (居中排列)
start_x = (carrier_size_x - (2 - 1) * bottle_spacing_x - bottle_diameter) / 2
start_y = (carrier_size_y - (6 - 1) * bottle_spacing_y - bottle_diameter) / 2
sites = create_ordered_items_2d(
klass=ResourceHolder,
num_items_x=2,
num_items_y=6,
dx=start_x,
dy=start_y,
dz=5.0,
item_dx=bottle_spacing_x,
item_dy=bottle_spacing_y,
size_x=bottle_diameter,
size_y=bottle_diameter,
size_z=carrier_size_z,
)
for k, v in sites.items():
v.name = f"{name}_{v.name}"
carrier = BottleCarrier(
name=name,
size_x=carrier_size_x,
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=sites,
model="Electrolyte_12VialCarrier",
)
carrier.num_items_x = 2
carrier.num_items_y = 6
carrier.num_items_z = 1
for i in range(12):
carrier[i] = YB_pei_ye_xiao_Bottle(f"{name}_vial_{i+1}")
return carrier

View File

@@ -0,0 +1,195 @@
from typing import Any, Dict, Optional, TypedDict
from pylabrobot.resources import Resource as ResourcePLR
from pylabrobot.resources import Container
electrode_colors = {
"PositiveCan": "#ff0000",
"PositiveElectrode": "#cc3333",
"NegativeCan": "#000000",
"NegativeElectrode": "#666666",
"SpringWasher": "#8b7355",
"FlatWasher": "a9a9a9",
"AluminumFoil": "#ffcccc",
"Battery": "#00ff00",
}
class ElectrodeSheetState(TypedDict):
diameter: float # 直径 (mm)
thickness: float # 厚度 (mm)
mass: float # 质量 (g)
material_type: str # 材料类型(铜、铝、不锈钢、弹簧钢等)
color: str # 材料类型对应的颜色
info: Optional[str] # 附加信息
class ElectrodeSheet(ResourcePLR):
"""极片类 - 包含正负极片、隔膜、弹片、垫片、铝箔等所有片状材料"""
def __init__(
self,
name: str = "极片",
size_x: float = 10,
size_y: float = 10,
size_z: float = 10,
category: str = "electrode_sheet",
model: Optional[str] = None,
**kwargs
):
"""初始化极片
Args:
name: 极片名称
size_x: 长度 (mm)
size_y: 宽度 (mm)
size_z: 高度 (mm)
category: 类别
model: 型号
**kwargs: 其他参数传递给父类
"""
super().__init__(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
category=category,
model=model,
**kwargs
)
self._unilabos_state: ElectrodeSheetState = ElectrodeSheetState(
diameter=14,
thickness=0.1,
mass=0.5,
material_type="copper",
color="#8b4513",
info=None
)
# TODO: 这个还要不要给self._unilabos_state赋值的
def load_state(self, state: Dict[str, Any]) -> None:
"""格式不变"""
super().load_state(state)
self._unilabos_state = state
#序列化
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
"""格式不变"""
data = super().serialize_state()
data.update(self._unilabos_state) # Container自身的信息云端物料将保存这一data本地也通过这里的data进行读写当前类用来表示这个物料的长宽高大小的属性而datastate用来表示物料的内容细节等
return data
def PositiveCan(name: str) -> ElectrodeSheet:
"""创建正极壳"""
sheet = ElectrodeSheet(name=name, size_x=12, size_y=12, size_z=3.0, model="PositiveCan")
sheet.load_state({"diameter": 20.0, "thickness": 0.5, "mass": 0.5, "material_type": "aluminum", "color": electrode_colors["PositiveCan"], "info": None})
return sheet
def PositiveElectrode(name: str) -> ElectrodeSheet:
"""创建正极片"""
sheet = ElectrodeSheet(name=name, size_x=10, size_y=10, size_z=0.1, model="PositiveElectrode")
sheet.load_state({"material_type": "positive_electrode", "color": electrode_colors["PositiveElectrode"]})
return sheet
def NegativeCan(name: str) -> ElectrodeSheet:
"""创建负极壳"""
sheet = ElectrodeSheet(name=name, size_x=12, size_y=12, size_z=2.0, model="NegativeCan")
sheet.load_state({"material_type": "steel", "color": electrode_colors["NegativeCan"]})
return sheet
def NegativeElectrode(name: str) -> ElectrodeSheet:
"""创建负极片"""
sheet = ElectrodeSheet(name=name, size_x=10, size_y=10, size_z=0.1, model="NegativeElectrode")
sheet.load_state({"material_type": "negative_electrode", "color": electrode_colors["NegativeElectrode"]})
return sheet
def SpringWasher(name: str) -> ElectrodeSheet:
"""创建弹片"""
sheet = ElectrodeSheet(name=name, size_x=10, size_y=10, size_z=0.5, model="SpringWasher")
sheet.load_state({"material_type": "spring_steel", "color": electrode_colors["SpringWasher"]})
return sheet
def FlatWasher(name: str) -> ElectrodeSheet:
"""创建垫片"""
sheet = ElectrodeSheet(name=name, size_x=10, size_y=10, size_z=0.2, model="FlatWasher")
sheet.load_state({"material_type": "steel", "color": electrode_colors["FlatWasher"]})
return sheet
def AluminumFoil(name: str) -> ElectrodeSheet:
"""创建铝箔"""
sheet = ElectrodeSheet(name=name, size_x=10, size_y=10, size_z=0.05, model="AluminumFoil")
sheet.load_state({"material_type": "aluminum", "color": electrode_colors["AluminumFoil"]})
return sheet
class BatteryState(TypedDict):
color: str # 材料类型对应的颜色
electrolyte_name: str
data_electrolyte_code: str
open_circuit_voltage: float
assembly_pressure: float
electrolyte_volume: float
info: Optional[str] # 附加信息
class Battery(Container):
"""电池类 - 包含组装好的电池"""
def __init__(
self,
name: str = "电池",
size_x: float = 12,
size_y: float = 12,
size_z: float = 6,
category: str = "battery",
model: Optional[str] = None,
**kwargs
):
"""初始化电池
Args:
name: 电池名称
size_x: 长度 (mm)
size_y: 宽度 (mm)
size_z: 高度 (mm)
category: 类别
model: 型号
**kwargs: 其他参数传递给父类
"""
super().__init__(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
category=category,
model=model,
**kwargs
)
self._unilabos_state: BatteryState = BatteryState(
color=electrode_colors["Battery"],
electrolyte_name="",
data_electrolyte_code="",
open_circuit_voltage=0.0,
assembly_pressure=0.0,
electrolyte_volume=0.0,
info=None
)
def load_state(self, state: Dict[str, Any]) -> None:
"""格式不变"""
super().load_state(state)
self._unilabos_state = state
#序列化
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
"""格式不变"""
data = super().serialize_state()
data.update(self._unilabos_state) # Container自身的信息云端物料将保存这一data本地也通过这里的data进行读写当前类用来表示这个物料的长宽高大小的属性而datastate用来表示物料的内容细节等
return data

View File

@@ -0,0 +1,344 @@
from typing import Dict, List, Optional, OrderedDict, Union, Callable
import math
from pylabrobot.resources.coordinate import Coordinate
from pylabrobot.resources import Resource, ResourceStack, ItemizedResource
from pylabrobot.resources.carrier import create_homogeneous_resources
from unilabos.resources.battery.electrode_sheet import (
PositiveCan, PositiveElectrode,
NegativeCan, NegativeElectrode,
SpringWasher, FlatWasher,
AluminumFoil,
Battery
)
class Magazine(ResourceStack):
"""子弹夹洞位类"""
def __init__(
self,
name: str,
direction: str = 'z',
resources: Optional[List[Resource]] = None,
max_sheets: int = 100,
**kwargs
):
"""初始化子弹夹洞位
Args:
name: 洞位名称
direction: 堆叠方向
resources: 资源列表
max_sheets: 最大极片数量
"""
super().__init__(
name=name,
direction=direction,
resources=resources,
)
self.max_sheets = max_sheets
@property
def size_x(self) -> float:
return self.get_size_x()
@property
def size_y(self) -> float:
return self.get_size_y()
@property
def size_z(self) -> float:
return self.get_size_z()
def serialize(self) -> dict:
return {
**super().serialize(),
"size_x": self.size_x or 10.0,
"size_y": self.size_y or 10.0,
"size_z": self.size_z or 10.0,
"max_sheets": self.max_sheets,
}
class MagazineHolder(ItemizedResource):
"""子弹夹类 - 有多个洞位,每个洞位放多个极片"""
def __init__(
self,
name: str,
size_x: float,
size_y: float,
size_z: float,
ordered_items: Optional[Dict[str, Magazine]] = None,
ordering: Optional[OrderedDict[str, str]] = None,
hole_diameter: float = 14.0,
hole_depth: float = 10.0,
max_sheets_per_hole: int = 100,
cross_section_type: str = "circle",
category: str = "magazine_holder",
model: Optional[str] = None,
):
"""初始化子弹夹
Args:
name: 子弹夹名称
size_x: 长度 (mm)
size_y: 宽度 (mm)
size_z: 高度 (mm)
hole_diameter: 洞直径 (mm)
hole_depth: 洞深度 (mm)
max_sheets_per_hole: 每个洞位最大极片数量
category: 类别
model: 型号
"""
super().__init__(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
ordered_items=ordered_items,
ordering=ordering,
category=category,
model=model,
)
# 保存洞位的直径和深度
self.hole_diameter = hole_diameter
self.hole_depth = hole_depth
self.max_sheets_per_hole = max_sheets_per_hole
self.cross_section_type = cross_section_type
def serialize(self) -> dict:
return {
**super().serialize(),
"hole_diameter": self.hole_diameter,
"hole_depth": self.hole_depth,
"max_sheets_per_hole": self.max_sheets_per_hole,
"cross_section_type": self.cross_section_type,
}
def magazine_factory(
name: str,
size_x: float,
size_y: float,
size_z: float,
locations: List[Coordinate],
klasses: Optional[List[Callable[[str], str]]] = None,
hole_diameter: float = 14.0,
hole_depth: float = 10.0,
max_sheets_per_hole: int = 100,
category: str = "magazine_holder",
model: Optional[str] = None,
) -> 'MagazineHolder':
"""工厂函数:创建子弹夹
Args:
name: 子弹夹名称
size_x: 长度 (mm)
size_y: 宽度 (mm)
size_z: 高度 (mm)
locations: 洞位坐标列表
klasses: 每个洞位中极片的类列表
hole_diameter: 洞直径 (mm)
hole_depth: 洞深度 (mm)
max_sheets_per_hole: 每个洞位最大极片数量
category: 类别
model: 型号
"""
for loc in locations:
loc.x -= hole_diameter / 2
loc.y -= hole_diameter / 2
# 创建洞位
_sites = create_homogeneous_resources(
klass=Magazine,
locations=locations,
resource_size_x=hole_diameter,
resource_size_y=hole_diameter,
name_prefix=name,
max_sheets=max_sheets_per_hole,
)
# 生成编号键
keys = [f"A{i+1}" for i in range(len(locations))]
sites = dict(zip(keys, _sites.values()))
holder = MagazineHolder(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
ordered_items=sites,
hole_diameter=hole_diameter,
hole_depth=hole_depth,
max_sheets_per_hole=max_sheets_per_hole,
category=category,
model=model,
)
if klasses is not None:
for i, klass in enumerate(klasses):
hole_key = keys[i]
hole = holder.children[i]
for j in reversed(range(max_sheets_per_hole)):
item_name = f"{hole_key}_sheet{j+1}"
item = klass(name=item_name)
hole.assign_child_resource(item)
return holder
def MagazineHolder_6_Cathode(
name: str,
size_x: float = 80.0,
size_y: float = 80.0,
size_z: float = 40.0,
hole_diameter: float = 14.0,
hole_depth: float = 10.0,
hole_spacing: float = 20.0,
max_sheets_per_hole: int = 100,
) -> MagazineHolder:
"""创建6孔子弹夹 - 六边形排布"""
center_x = size_x / 2
center_y = size_y / 2
locations = []
# 周围6个孔按六边形排布
for i in range(6):
angle = i * 60 * math.pi / 180 # 每60度一个孔
x = center_x + hole_spacing * math.cos(angle)
y = center_y + hole_spacing * math.sin(angle)
locations.append(Coordinate(x, y, size_z - hole_depth))
return magazine_factory(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
locations=locations,
klasses=[FlatWasher, PositiveCan, PositiveCan, FlatWasher, PositiveCan, PositiveCan],
hole_diameter=hole_diameter,
hole_depth=hole_depth,
max_sheets_per_hole=max_sheets_per_hole,
category="magazine_holder",
model="MagazineHolder_6_Cathode",
)
def MagazineHolder_6_Anode(
name: str,
size_x: float = 80.0,
size_y: float = 80.0,
size_z: float = 40.0,
hole_diameter: float = 14.0,
hole_depth: float = 10.0,
hole_spacing: float = 20.0,
max_sheets_per_hole: int = 100,
) -> MagazineHolder:
"""创建6孔子弹夹 - 六边形排布"""
center_x = size_x / 2
center_y = size_y / 2
locations = []
# 周围6个孔按六边形排布
for i in range(6):
angle = i * 60 * math.pi / 180 # 每60度一个孔
x = center_x + hole_spacing * math.cos(angle)
y = center_y + hole_spacing * math.sin(angle)
locations.append(Coordinate(x, y, size_z - hole_depth))
return magazine_factory(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
locations=locations,
klasses=[SpringWasher, NegativeCan, NegativeCan, SpringWasher, NegativeCan, NegativeCan],
hole_diameter=hole_diameter,
hole_depth=hole_depth,
max_sheets_per_hole=max_sheets_per_hole,
category="magazine_holder",
model="MagazineHolder_6_Anode",
)
def MagazineHolder_6_Battery(
name: str,
size_x: float = 80.0,
size_y: float = 80.0,
size_z: float = 40.0,
hole_diameter: float = 14.0,
hole_depth: float = 10.0,
hole_spacing: float = 20.0,
max_sheets_per_hole: int = 100,
) -> MagazineHolder:
"""创建6孔子弹夹 - 六边形排布"""
center_x = size_x / 2
center_y = size_y / 2
locations = []
# 周围6个孔按六边形排布
for i in range(6):
angle = i * 60 * math.pi / 180 # 每60度一个孔
x = center_x + hole_spacing * math.cos(angle)
y = center_y + hole_spacing * math.sin(angle)
locations.append(Coordinate(x, y, size_z - hole_depth))
return magazine_factory(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
locations=locations,
klasses=None, # 初始化时,不放入装好的电池
hole_diameter=hole_diameter,
hole_depth=hole_depth,
max_sheets_per_hole=max_sheets_per_hole,
category="magazine_holder",
model="MagazineHolder_6_Battery",
)
def MagazineHolder_4_Cathode(
name: str,
) -> MagazineHolder:
"""创建4孔子弹夹 - 正方形四角排布"""
size_x: float = 80.0
size_y: float = 80.0
size_z: float = 10.0
hole_diameter: float = 14.0
hole_depth: float = 10.0
hole_spacing: float = 25.0
max_sheets_per_hole: int = 100
# 计算4个洞位的坐标正方形四角排布
center_x = size_x / 2
center_y = size_y / 2
offset = hole_spacing / 2
locations = [
Coordinate(center_x - offset, center_y - offset, size_z - hole_depth), # 左下
Coordinate(center_x + offset, center_y - offset, size_z - hole_depth), # 右下
Coordinate(center_x - offset, center_y + offset, size_z - hole_depth), # 左上
Coordinate(center_x + offset, center_y + offset, size_z - hole_depth), # 右上
]
return magazine_factory(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
locations=locations,
klasses=[AluminumFoil, PositiveElectrode, PositiveElectrode, PositiveElectrode],
hole_diameter=hole_diameter,
hole_depth=hole_depth,
max_sheets_per_hole=max_sheets_per_hole,
category="magazine_holder",
model="MagazineHolder_4_Cathode",
)

View File

@@ -0,0 +1,548 @@
# Bioyond 仓库系统开发指南
本文档详细说明 Bioyond 仓库Warehouse系统的架构、配置和使用方法帮助开发者快速理解和维护仓库相关代码。
## 📚 目录
- [系统架构](#系统架构)
- [核心概念](#核心概念)
- [三层映射关系](#三层映射关系)
- [warehouse_factory 详解](#warehouse_factory-详解)
- [创建新仓库](#创建新仓库)
- [常见问题](#常见问题)
- [调试技巧](#调试技巧)
---
## 系统架构
Bioyond 仓库系统采用**三层架构**,实现从前端显示到后端 API 的完整映射:
```
┌─────────────────────────────────────────────────────────┐
│ 前端显示层 (YB_warehouses.py) │
│ - warehouse_factory 自动生成库位网格 │
│ - 生成库位名称A01, B02, C03... │
│ - 存储在 WareHouse.sites 字典中 │
└────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Deck 布局层 (decks.py) │
│ - 定义仓库在 Deck 上的物理位置 │
│ - 组织多个仓库形成完整布局 │
└────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ UUID 映射层 (config.py) │
│ - 将库位名称映射到 Bioyond 系统 UUID │
│ - 用于 API 调用时的物料入库操作 │
└─────────────────────────────────────────────────────────┘
```
---
## 核心概念
### 仓库Warehouse
仓库是一个**三维网格**,用于存放物料。由以下参数定义:
- **num_items_x**: 列数X 轴)
- **num_items_y**: 行数Y 轴)
- **num_items_z**: 层数Z 轴)
例如:`5行×3列×1层` = 5×3×1 = 15个库位
### 库位Site
库位是仓库中的单个存储位置,由**字母行+数字列**命名:
- **字母行**A, B, C, D, E, F...(对应 Y 轴)
- **数字列**01, 02, 03, 04...(对应 X 轴或 Z 轴)
示例:`A01`, `B02`, `C03`
### 布局模式Layout
控制库位的排序和 Y 坐标计算:
| 模式 | 说明 | 生成顺序 | Y 坐标计算 | 显示效果 |
|------|------|----------|-----------|---------|
| `col-major` | 列优先(默认) | A01, B01, C01, A02... | `dy + (num_y - row - 1) * item_dy` | A 可能在下 |
| `row-major` | 行优先 | A01, A02, A03, B01... | `dy + row * item_dy` | **A 在上** ✓ |
**重要:** 使用 `row-major` 可以避免上下颠倒问题!
---
## 三层映射关系
### 示例手动传递窗右A01-E03
#### 1⃣ 前端显示层 - [`YB_warehouses.py`](YB_warehouses.py)
```python
def bioyond_warehouse_5x3x1(name: str, row_offset: int = 0) -> WareHouse:
"""创建 5行×3列×1层 仓库"""
return warehouse_factory(
name=name,
num_items_x=3, # 3列
num_items_y=5, # 5行
num_items_z=1, # 1层
row_offset=row_offset,
layout="row-major",
)
```
**自动生成的库位:** A01, A02, A03, B01, B02, B03, ..., E01, E02, E03
#### 2⃣ Deck 布局层 - [`decks.py`](decks.py)
```python
self.warehouses = {
"手动传递窗右": bioyond_warehouse_5x3x1("手动传递窗右", row_offset=0),
}
self.warehouse_locations = {
"手动传递窗右": Coordinate(4160.0, 877.0, 0.0),
}
```
**作用:**
- 创建仓库实例
- 设置在 Deck 上的物理坐标
#### 3⃣ UUID 映射层 - [`config.py`](../../devices/workstation/bioyond_studio/config.py)
```python
WAREHOUSE_MAPPING = {
"手动传递窗右": {
"uuid": "",
"site_uuids": {
"A01": "3a19deae-2c7a-36f5-5e41-02c5b66feaea",
"A02": "3a19deae-2c7a-dc6d-c41e-ef285d946cfe",
# ... 其他库位
}
}
}
```
**作用:**
- 用户拖拽物料到"手动传递窗右"的"A01"位置时
- 系统查找 `WAREHOUSE_MAPPING["手动传递窗右"]["site_uuids"]["A01"]`
- 获取 UUID `"3a19deae-2c7a-36f5-5e41-02c5b66feaea"`
- 调用 Bioyond API 将物料入库到该 UUID 位置
---
## 实际配置案例
### 案例:手动传递窗左/右的完整配置
本案例展示如何为"手动传递窗右"和"手动传递窗左"建立完整的三层映射。
#### 背景需求
- **手动传递窗右**: 需要 A01-E035行×3列=15个库位
- **手动传递窗左**: 需要 F01-J035行×3列=15个库位
- 这两个仓库共享同一个物理堆栈的 UUID"手动堆栈"
#### 实施步骤
**1⃣ 修复前端布局** - [`YB_warehouses.py`](YB_warehouses.py)
```python
# 创建新的 5×3×1 仓库函数(之前是错误的 1×3×3
def bioyond_warehouse_5x3x1(name: str, row_offset: int = 0) -> WareHouse:
"""创建5行×3列×1层仓库支持行偏移生成不同字母行"""
return warehouse_factory(
name=name,
num_items_x=3, # 3列
num_items_y=5, # 5行 ← 修正
num_items_z=1, # 1层 ← 修正
row_offset=row_offset, # ← 支持 F-J 行
layout="row-major", # ← 避免上下颠倒
)
```
**2⃣ 更新 Deck 配置** - [`decks.py`](decks.py)
```python
from unilabos.resources.bioyond.YB_warehouses import (
bioyond_warehouse_5x3x1, # 新增导入
)
class BIOYOND_YB_Deck(Deck):
def setup(self) -> None:
self.warehouses = {
# 修改前: bioyond_warehouse_1x3x3 (错误尺寸)
# 修改后: bioyond_warehouse_5x3x1 (正确尺寸)
"手动传递窗右": bioyond_warehouse_5x3x1("手动传递窗右", row_offset=0), # A01-E03
"手动传递窗左": bioyond_warehouse_5x3x1("手动传递窗左", row_offset=5), # F01-J03
}
```
**3⃣ 添加 UUID 映射** - [`config.py`](../../devices/workstation/bioyond_studio/config.py)
```python
WAREHOUSE_MAPPING = {
# 保持原有的"手动堆栈"配置不变A01-J03共30个库位
"手动堆栈": {
"uuid": "",
"site_uuids": {
"A01": "3a19deae-2c7a-36f5-5e41-02c5b66feaea",
# ... A02-E03 共15个
"F01": "3a19deae-2c7a-d594-fd6a-0d20de3c7c4a",
# ... F02-J03 共15个
}
},
# [新增] 手动传递窗右 - 复用"手动堆栈"的 A01-E03 UUID
"手动传递窗右": {
"uuid": "",
"site_uuids": {
"A01": "3a19deae-2c7a-36f5-5e41-02c5b66feaea", # ← 与手动堆栈A01相同
"A02": "3a19deae-2c7a-dc6d-c41e-ef285d946cfe",
"A03": "3a19deae-2c7a-5876-c454-6b7e224ca927",
"B01": "3a19deae-2c7a-2426-6d71-e9de3cb158b1",
"B02": "3a19deae-2c7a-79b0-5e44-efaafd1e4cf3",
"B03": "3a19deae-2c7a-b9eb-f4e3-e308e0cf839a",
"C01": "3a19deae-2c7a-32bc-768e-556647e292f3",
"C02": "3a19deae-2c7a-e97a-8484-f5a4599447c4",
"C03": "3a19deae-2c7a-3056-6504-10dc73fbc276",
"D01": "3a19deae-2c7a-ffad-875e-8c4cda61d440",
"D02": "3a19deae-2c7a-61be-601c-b6fb5610499a",
"D03": "3a19deae-2c7a-c0f7-05a7-e3fe2491e560",
"E01": "3a19deae-2c7a-a6f4-edd1-b436a7576363",
"E02": "3a19deae-2c7a-4367-96dd-1ca2186f4910",
"E03": "3a19deae-2c7a-b163-2219-23df15200311",
}
},
# [新增] 手动传递窗左 - 复用"手动堆栈"的 F01-J03 UUID
"手动传递窗左": {
"uuid": "",
"site_uuids": {
"F01": "3a19deae-2c7a-d594-fd6a-0d20de3c7c4a", # ← 与手动堆栈F01相同
"F02": "3a19deae-2c7a-a194-ea63-8b342b8d8679",
"F03": "3a19deae-2c7a-f7c4-12bd-425799425698",
"G01": "3a19deae-2c7a-0b56-72f1-8ab86e53b955",
"G02": "3a19deae-2c7a-204e-95ed-1f1950f28343",
"G03": "3a19deae-2c7a-392b-62f1-4907c66343f8",
"H01": "3a19deae-2c7a-5602-e876-d27aca4e3201",
"H02": "3a19deae-2c7a-f15c-70e0-25b58a8c9702",
"H03": "3a19deae-2c7a-780b-8965-2e1345f7e834",
"I01": "3a19deae-2c7a-8849-e172-07de14ede928",
"I02": "3a19deae-2c7a-4772-a37f-ff99270bafc0",
"I03": "3a19deae-2c7a-cce7-6e4a-25ea4a2068c4",
"J01": "3a19deae-2c7a-1848-de92-b5d5ed054cc6",
"J02": "3a19deae-2c7a-1d45-b4f8-6f866530e205",
"J03": "3a19deae-2c7a-f237-89d9-8fe19025dee9"
}
},
}
```
#### 关键要点
1. **UUID 可以复用**: 三个仓库(手动堆栈、手动传递窗右、手动传递窗左)可以共享相同的物理库位 UUID
2. **库位名称必须匹配**: 前端生成的库位名称(如 F01必须与 config.py 中的键名完全一致
3. **row_offset 的妙用**:
- `row_offset=0` → 生成 A-E 行
- `row_offset=5` → 生成 F-J 行跳过前5个字母
#### 验证结果
配置完成后,拖拽测试:
| 拖拽位置 | 前端库位 | 查找路径 | UUID | 结果 |
|---------|---------|---------|------|------|
| 手动传递窗右/A01 | A01 | `WAREHOUSE_MAPPING["手动传递窗右"]["site_uuids"]["A01"]` | `3a19...eaea` | ✅ 正确入库 |
| 手动传递窗左/F01 | F01 | `WAREHOUSE_MAPPING["手动传递窗左"]["site_uuids"]["F01"]` | `3a19...c4a` | ✅ 正确入库 |
| 手动堆栈/A01 | A01 | `WAREHOUSE_MAPPING["手动堆栈"]["site_uuids"]["A01"]` | `3a19...eaea` | ✅ 仍然正常 |
---
## warehouse_factory 详解
### 函数签名
```python
def warehouse_factory(
name: str,
num_items_x: int = 1, # 列数
num_items_y: int = 4, # 行数
num_items_z: int = 4, # 层数
dx: float = 137.0, # X 起始偏移
dy: float = 96.0, # Y 起始偏移
dz: float = 120.0, # Z 起始偏移
item_dx: float = 10.0, # X 间距
item_dy: float = 10.0, # Y 间距
item_dz: float = 10.0, # Z 间距
col_offset: int = 0, # 列偏移(影响数字)
row_offset: int = 0, # 行偏移(影响字母)
layout: str = "col-major", # 布局模式
) -> WareHouse:
```
### 参数说明
#### 尺寸参数
- **num_items_x, y, z**: 定义仓库的网格尺寸
- **注意**: 当 `num_items_z > 1`Z 轴会被映射为数字列
#### 位置参数
- **dx, dy, dz**: 第一个库位的起始坐标
- **item_dx, dy, dz**: 库位之间的间距
#### 偏移参数
- **col_offset**: 列起始偏移,用于生成 A05-D08 等命名
```python
col_offset=4 # 生成 A05, A06, A07, A08
```
- **row_offset**: 行起始偏移,用于生成 F01-J03 等命名
```python
row_offset=5 # 生成 F01, F02, F03跳过 A-E
```
#### 布局参数
- **layout**:
- `"col-major"`: 列优先(默认),可能导致上下颠倒
- `"row-major"`: 行优先,**推荐使用**A 显示在上
### 库位生成逻辑
```python
# row-major 模式(推荐)
keys = [f"{LETTERS[j + row_offset]}{i + 1 + col_offset:02d}"
for j in range(num_y)
for i in range(num_x)]
# 示例num_y=2, num_x=3, row_offset=0, col_offset=0
# 生成A01, A02, A03, B01, B02, B03
```
### Y 坐标计算
```python
if layout == "row-major":
# A 在上Y 较小)
y = dy + row * item_dy
else:
# A 在下Y 较大)- 不推荐
y = dy + (num_items_y - row - 1) * item_dy
```
---
## 创建新仓库
### 步骤 1: 在 YB_warehouses.py 中创建函数
```python
def bioyond_warehouse_3x4x1(name: str) -> WareHouse:
"""创建 3行×4列×1层 仓库
布局:
A01 | A02 | A03 | A04
B01 | B02 | B03 | B04
C01 | C02 | C03 | C04
"""
return warehouse_factory(
name=name,
num_items_x=4, # 4列
num_items_y=3, # 3行
num_items_z=1, # 1层
dx=10.0,
dy=10.0,
dz=10.0,
item_dx=137.0,
item_dy=120.0,
item_dz=120.0,
category="warehouse",
layout="row-major", # ⭐ 推荐使用
)
```
### 步骤 2: 在 decks.py 中使用
```python
# 1. 导入函数
from unilabos.resources.bioyond.YB_warehouses import (
bioyond_warehouse_3x4x1, # 新增
)
# 2. 在 setup() 中添加
self.warehouses = {
"我的新仓库": bioyond_warehouse_3x4x1("我的新仓库"),
}
self.warehouse_locations = {
"我的新仓库": Coordinate(100.0, 200.0, 0.0),
}
```
### 步骤 3: 在 config.py 中配置 UUID可选
```python
WAREHOUSE_MAPPING = {
"我的新仓库": {
"uuid": "",
"site_uuids": {
"A01": "从 Bioyond 系统获取的 UUID",
"A02": "从 Bioyond 系统获取的 UUID",
# ... 其他 11 个库位
}
}
}
```
**注意:** 如果不需要拖拽入库功能,可跳过此步骤。
---
## 常见问题
### Q1: 为什么库位显示上下颠倒C 在上A 在下)?
**原因:** 使用了默认的 `col-major` 布局。
**解决:** 在 `warehouse_factory` 中添加 `layout="row-major"`
```python
return warehouse_factory(
...
layout="row-major", # ← 添加这行
)
```
### Q2: 我需要 1×3×3 还是 3×3×1
**判断方法:**
- **1×3×3**: 1列×3行×3**层**(垂直堆叠,有高度)
- **3×3×1**: 3行×3列×1**层**(平面网格)
**推荐:** 大多数情况使用 `X×Y×1`(平面网格)更直观。
### Q3: 如何生成 F01-J03 而非 A01-E03
**方法:** 使用 `row_offset` 参数
```python
bioyond_warehouse_5x3x1("仓库名", row_offset=5)
# row_offset=5 跳过 A-E从 F 开始
```
### Q4: 拖拽物料后找不到 UUID 怎么办?
**检查清单:**
1. `config.py` 中是否有该仓库的配置?
2. 仓库名称是否完全匹配?
3. 库位名称(如 A01是否在 `site_uuids` 中?
**示例错误:**
```python
# decks.py
"手动传递窗右": bioyond_warehouse_5x3x1(...)
# config.py - ❌ 名称不匹配
"手动传递窗": { ... } # 缺少"右"字
```
### Q5: 库位重叠怎么办?
**原因:** 间距(`item_dx/dy/dz`)太小。
**解决:** 增大间距参数
```python
item_dx=150.0, # 增大 X 间距
item_dy=130.0, # 增大 Y 间距
```
---
## 调试技巧
### 1. 查看生成的库位
```python
warehouse = bioyond_warehouse_5x3x1("测试仓库")
print(list(warehouse.sites.keys()))
# 输出:['A01', 'A02', 'A03', 'B01', 'B02', ...]
```
### 2. 检查库位坐标
```python
for name, site in warehouse.sites.items():
print(f"{name}: {site.location}")
# 输出:
# A01: Coordinate(x=10.0, y=10.0, z=120.0)
# A02: Coordinate(x=147.0, y=10.0, z=120.0)
# ...
```
### 3. 验证 UUID 映射
```python
from unilabos.devices.workstation.bioyond_studio.config import WAREHOUSE_MAPPING
warehouse_name = "手动传递窗右"
location_code = "A01"
if warehouse_name in WAREHOUSE_MAPPING:
uuid = WAREHOUSE_MAPPING[warehouse_name]["site_uuids"].get(location_code)
print(f"{warehouse_name}/{location_code} → {uuid}")
else:
print(f"❌ 未找到仓库: {warehouse_name}")
```
---
## 文件关系图
```
unilabos/
├── resources/
│ ├── warehouse.py # warehouse_factory 核心实现
│ └── bioyond/
│ ├── YB_warehouses.py # ⭐ 仓库函数定义
│ ├── decks.py # ⭐ Deck 布局配置
│ └── README_WAREHOUSE.md # 📖 本文档
└── devices/
└── workstation/
└── bioyond_studio/
├── config.py # ⭐ UUID 映射配置
└── bioyond_cell/
└── bioyond_cell_workstation.py # 业务逻辑
```
---
## 版本历史
- **v1.1** (2026-01-08): 补充实际配置案例
- 添加"手动传递窗右"和"手动传递窗左"的完整配置示例
- 展示 UUID 复用的实际应用
- 说明三个仓库共享物理堆栈的配置方法
- **v1.0** (2026-01-07): 初始版本
- 新增 `row_offset` 参数支持
- 创建 `bioyond_warehouse_5x3x1` 和 `bioyond_warehouse_2x2x1`
- 修复多个仓库的上下颠倒问题
---
## 相关资源
- [warehouse.py](../warehouse.py) - 核心工厂函数实现
- [YB_warehouses.py](YB_warehouses.py) - 所有仓库定义
- [decks.py](decks.py) - Deck 布局配置
- [config.py](../../devices/workstation/bioyond_studio/config.py) - UUID 映射
---
**维护者:** Uni-Lab-OS 开发团队
**最后更新:** 2026-01-07

View File

@@ -0,0 +1,653 @@
from pylabrobot.resources import create_homogeneous_resources, Coordinate, ResourceHolder, create_ordered_items_2d
from unilabos.resources.itemized_carrier import Bottle, BottleCarrier
from unilabos.resources.bioyond.YB_bottles import (
YB_jia_yang_tou_da,
YB_ye_Bottle,
YB_ye_100ml_Bottle,
YB_gao_nian_ye_Bottle,
YB_5ml_fenyeping,
YB_20ml_fenyeping,
YB_pei_ye_xiao_Bottle,
YB_pei_ye_da_Bottle,
YB_qiang_tou,
)
# 命名约定:试剂瓶-Bottle烧杯-Beaker烧瓶-Flask小瓶-Vial
def BIOYOND_Electrolyte_6VialCarrier(name: str) -> BottleCarrier:
"""6瓶载架 - 2x3布局"""
# 载架尺寸 (mm)
carrier_size_x = 127.8
carrier_size_y = 85.5
carrier_size_z = 50.0
# 瓶位尺寸
bottle_diameter = 30.0
bottle_spacing_x = 42.0 # X方向间距
bottle_spacing_y = 35.0 # Y方向间距
# 计算起始位置 (居中排列)
start_x = (carrier_size_x - (3 - 1) * bottle_spacing_x - bottle_diameter) / 2
start_y = (carrier_size_y - (2 - 1) * bottle_spacing_y - bottle_diameter) / 2
sites = create_ordered_items_2d(
klass=ResourceHolder,
num_items_x=3,
num_items_y=2,
dx=start_x,
dy=start_y,
dz=5.0,
item_dx=bottle_spacing_x,
item_dy=bottle_spacing_y,
size_x=bottle_diameter,
size_y=bottle_diameter,
size_z=carrier_size_z,
)
for k, v in sites.items():
v.name = f"{name}_{v.name}"
carrier = BottleCarrier(
name=name,
size_x=carrier_size_x,
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=sites,
model="Electrolyte_6VialCarrier",
)
carrier.num_items_x = 3
carrier.num_items_y = 2
carrier.num_items_z = 1
# for i in range(6):
# carrier[i] = YB_Solid_Vial(f"{name}_vial_{i+1}")
return carrier
def BIOYOND_Electrolyte_1BottleCarrier(name: str) -> BottleCarrier:
"""1瓶载架 - 单个中央位置"""
# 载架尺寸 (mm)
carrier_size_x = 127.8
carrier_size_y = 85.5
carrier_size_z = 100.0
# 烧杯尺寸
beaker_diameter = 80.0
# 计算中央位置
center_x = (carrier_size_x - beaker_diameter) / 2
center_y = (carrier_size_y - beaker_diameter) / 2
center_z = 5.0
carrier = BottleCarrier(
name=name,
size_x=carrier_size_x,
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=create_homogeneous_resources(
klass=ResourceHolder,
locations=[Coordinate(center_x, center_y, center_z)],
resource_size_x=beaker_diameter,
resource_size_y=beaker_diameter,
name_prefix=name,
),
model="Electrolyte_1BottleCarrier",
)
carrier.num_items_x = 1
carrier.num_items_y = 1
carrier.num_items_z = 1
# carrier[0] = YB_Solution_Beaker(f"{name}_beaker_1")
return carrier
def YB_6StockCarrier(name: str) -> BottleCarrier:
"""6瓶载架 - 2x3布局"""
# 载架尺寸 (mm)
carrier_size_x = 127.8
carrier_size_y = 85.5
carrier_size_z = 50.0
# 瓶位尺寸
bottle_diameter = 20.0
bottle_spacing_x = 42.0 # X方向间距
bottle_spacing_y = 35.0 # Y方向间距
# 计算起始位置 (居中排列)
start_x = (carrier_size_x - (3 - 1) * bottle_spacing_x - bottle_diameter) / 2
start_y = (carrier_size_y - (2 - 1) * bottle_spacing_y - bottle_diameter) / 2
sites = create_ordered_items_2d(
klass=ResourceHolder,
num_items_x=3,
num_items_y=2,
dx=start_x,
dy=start_y,
dz=5.0,
item_dx=bottle_spacing_x,
item_dy=bottle_spacing_y,
size_x=bottle_diameter,
size_y=bottle_diameter,
size_z=carrier_size_z,
)
for k, v in sites.items():
v.name = f"{name}_{v.name}"
carrier = BottleCarrier(
name=name,
size_x=carrier_size_x,
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=sites,
model="6StockCarrier",
)
carrier.num_items_x = 3
carrier.num_items_y = 2
carrier.num_items_z = 1
ordering = ["A1", "A2", "A3", "B1", "B2", "B3"] # 自定义顺序
# for i in range(6):
# carrier[i] = YB_Solid_Stock(f"{name}_vial_{ordering[i]}")
return carrier
def YB_6VialCarrier(name: str) -> BottleCarrier:
"""6瓶载架 - 2x3布局"""
# 载架尺寸 (mm)
carrier_size_x = 127.8
carrier_size_y = 85.5
carrier_size_z = 50.0
# 瓶位尺寸
bottle_diameter = 30.0
bottle_spacing_x = 42.0 # X方向间距
bottle_spacing_y = 35.0 # Y方向间距
# 计算起始位置 (居中排列)
start_x = (carrier_size_x - (3 - 1) * bottle_spacing_x - bottle_diameter) / 2
start_y = (carrier_size_y - (2 - 1) * bottle_spacing_y - bottle_diameter) / 2
sites = create_ordered_items_2d(
klass=ResourceHolder,
num_items_x=3,
num_items_y=2,
dx=start_x,
dy=start_y,
dz=5.0,
item_dx=bottle_spacing_x,
item_dy=bottle_spacing_y,
size_x=bottle_diameter,
size_y=bottle_diameter,
size_z=carrier_size_z,
)
for k, v in sites.items():
v.name = f"{name}_{v.name}"
carrier = BottleCarrier(
name=name,
size_x=carrier_size_x,
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=sites,
model="6VialCarrier",
)
carrier.num_items_x = 3
carrier.num_items_y = 2
carrier.num_items_z = 1
ordering = ["A1", "A2", "A3", "B1", "B2", "B3"] # 自定义顺序
# for i in range(3):
# carrier[i] = YB_Solid_Vial(f"{name}_solidvial_{ordering[i]}")
# for i in range(3, 6):
# carrier[i] = YB_Liquid_Vial(f"{name}_liquidvial_{ordering[i]}")
return carrier
# 1瓶载架 - 单个中央位置
def YB_ye(name: str) -> BottleCarrier:
# 载架尺寸 (mm)
carrier_size_x = 127.8
carrier_size_y = 85.5
carrier_size_z = 20.0
# 烧杯尺寸
beaker_diameter = 60.0
# 计算中央位置
center_x = (carrier_size_x - beaker_diameter) / 2
center_y = (carrier_size_y - beaker_diameter) / 2
center_z = 5.0
carrier = BottleCarrier(
name=name,
size_x=carrier_size_x,
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=create_homogeneous_resources(
klass=ResourceHolder,
locations=[Coordinate(center_x, center_y, center_z)],
resource_size_x=beaker_diameter,
resource_size_y=beaker_diameter,
name_prefix=name,
),
model="YB_ye",
)
carrier.num_items_x = 1
carrier.num_items_y = 1
carrier.num_items_z = 1
carrier[0] = YB_ye_Bottle(f"{name}_flask_1")
return carrier
# 高粘液瓶载架 - 单个中央位置
def YB_gaonianye(name: str) -> BottleCarrier:
# 载架尺寸 (mm)
carrier_size_x = 127.8
carrier_size_y = 85.5
carrier_size_z = 20.0
# 烧杯尺寸
beaker_diameter = 60.0
# 计算中央位置
center_x = (carrier_size_x - beaker_diameter) / 2
center_y = (carrier_size_y - beaker_diameter) / 2
center_z = 5.0
carrier = BottleCarrier(
name=name,
size_x=carrier_size_x,
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=create_homogeneous_resources(
klass=ResourceHolder,
locations=[Coordinate(center_x, center_y, center_z)],
resource_size_x=beaker_diameter,
resource_size_y=beaker_diameter,
name_prefix=name,
),
model="YB_gaonianye",
)
carrier.num_items_x = 1
carrier.num_items_y = 1
carrier.num_items_z = 1
carrier[0] = YB_gao_nian_ye_Bottle(f"{name}_flask_1")
return carrier
# 100ml液体瓶载架 - 单个中央位置
def YB_100ml_yeti(name: str) -> BottleCarrier:
# 载架尺寸 (mm)
carrier_size_x = 127.8
carrier_size_y = 85.5
carrier_size_z = 20.0
# 烧杯尺寸
beaker_diameter = 60.0
# 计算中央位置
center_x = (carrier_size_x - beaker_diameter) / 2
center_y = (carrier_size_y - beaker_diameter) / 2
center_z = 5.0
carrier = BottleCarrier(
name=name,
size_x=carrier_size_x,
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=create_homogeneous_resources(
klass=ResourceHolder,
locations=[Coordinate(center_x, center_y, center_z)],
resource_size_x=beaker_diameter,
resource_size_y=beaker_diameter,
name_prefix=name,
),
model="YB_100ml_yeti",
)
carrier.num_items_x = 1
carrier.num_items_y = 1
carrier.num_items_z = 1
carrier[0] = YB_ye_100ml_Bottle(f"{name}_flask_1")
return carrier
# 5ml分液瓶板 - 4x2布局8个位置
def YB_5ml_fenyepingban(name: str) -> BottleCarrier:
# 载架尺寸 (mm)
carrier_size_x = 127.8
carrier_size_y = 85.5
carrier_size_z = 50.0
# 瓶位尺寸
bottle_diameter = 15.0
bottle_spacing_x = 42.0 # X方向间距
bottle_spacing_y = 35.0 # Y方向间距
# 计算起始位置 (居中排列)
start_x = (carrier_size_x - (4 - 1) * bottle_spacing_x - bottle_diameter) / 2
start_y = (carrier_size_y - (2 - 1) * bottle_spacing_y - bottle_diameter) / 2
sites = create_ordered_items_2d(
klass=ResourceHolder,
num_items_x=4,
num_items_y=2,
dx=start_x,
dy=start_y,
dz=5.0,
item_dx=bottle_spacing_x,
item_dy=bottle_spacing_y,
size_x=bottle_diameter,
size_y=bottle_diameter,
size_z=carrier_size_z,
)
for k, v in sites.items():
v.name = f"{name}_{v.name}"
carrier = BottleCarrier(
name=name,
size_x=carrier_size_x,
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=sites,
model="YB_5ml_fenyepingban",
)
carrier.num_items_x = 4
carrier.num_items_y = 2
carrier.num_items_z = 1
ordering = ["A1", "A2", "A3", "A4", "B1", "B2", "B3", "B4"]
for i in range(8):
carrier[i] = YB_5ml_fenyeping(f"{name}_vial_{ordering[i]}")
return carrier
# 20ml分液瓶板 - 4x2布局8个位置
def YB_20ml_fenyepingban(name: str) -> BottleCarrier:
# 载架尺寸 (mm)
carrier_size_x = 127.8
carrier_size_y = 85.5
carrier_size_z = 70.0
# 瓶位尺寸
bottle_diameter = 20.0
bottle_spacing_x = 42.0 # X方向间距
bottle_spacing_y = 35.0 # Y方向间距
# 计算起始位置 (居中排列)
start_x = (carrier_size_x - (4 - 1) * bottle_spacing_x - bottle_diameter) / 2
start_y = (carrier_size_y - (2 - 1) * bottle_spacing_y - bottle_diameter) / 2
sites = create_ordered_items_2d(
klass=ResourceHolder,
num_items_x=4,
num_items_y=2,
dx=start_x,
dy=start_y,
dz=5.0,
item_dx=bottle_spacing_x,
item_dy=bottle_spacing_y,
size_x=bottle_diameter,
size_y=bottle_diameter,
size_z=carrier_size_z,
)
for k, v in sites.items():
v.name = f"{name}_{v.name}"
carrier = BottleCarrier(
name=name,
size_x=carrier_size_x,
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=sites,
model="YB_20ml_fenyepingban",
)
carrier.num_items_x = 4
carrier.num_items_y = 2
carrier.num_items_z = 1
ordering = ["A1", "A2", "A3", "A4", "B1", "B2", "B3", "B4"]
for i in range(8):
carrier[i] = YB_20ml_fenyeping(f"{name}_vial_{ordering[i]}")
return carrier
# 配液瓶(小)板 - 4x2布局8个位置
def YB_peiyepingxiaoban(name: str) -> BottleCarrier:
# 载架尺寸 (mm)
carrier_size_x = 127.8
carrier_size_y = 85.5
carrier_size_z = 65.0
# 瓶位尺寸
bottle_diameter = 35.0
bottle_spacing_x = 42.0 # X方向间距
bottle_spacing_y = 35.0 # Y方向间距
# 计算起始位置 (居中排列)
start_x = (carrier_size_x - (4 - 1) * bottle_spacing_x - bottle_diameter) / 2
start_y = (carrier_size_y - (2 - 1) * bottle_spacing_y - bottle_diameter) / 2
sites = create_ordered_items_2d(
klass=ResourceHolder,
num_items_x=4,
num_items_y=2,
dx=start_x,
dy=start_y,
dz=5.0,
item_dx=bottle_spacing_x,
item_dy=bottle_spacing_y,
size_x=bottle_diameter,
size_y=bottle_diameter,
size_z=carrier_size_z,
)
for k, v in sites.items():
v.name = f"{name}_{v.name}"
carrier = BottleCarrier(
name=name,
size_x=carrier_size_x,
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=sites,
model="YB_peiyepingxiaoban",
)
carrier.num_items_x = 4
carrier.num_items_y = 2
carrier.num_items_z = 1
ordering = ["A1", "A2", "A3", "A4", "B1", "B2", "B3", "B4"]
for i in range(8):
carrier[i] = YB_pei_ye_xiao_Bottle(f"{name}_bottle_{ordering[i]}")
return carrier
# 配液瓶(大)板 - 2x2布局4个位置
def YB_peiyepingdaban(name: str) -> BottleCarrier:
# 载架尺寸 (mm)
carrier_size_x = 127.8
carrier_size_y = 85.5
carrier_size_z = 95.0
# 瓶位尺寸
bottle_diameter = 55.0
bottle_spacing_x = 60.0 # X方向间距
bottle_spacing_y = 60.0 # Y方向间距
# 计算起始位置 (居中排列)
start_x = (carrier_size_x - (2 - 1) * bottle_spacing_x - bottle_diameter) / 2
start_y = (carrier_size_y - (2 - 1) * bottle_spacing_y - bottle_diameter) / 2
sites = create_ordered_items_2d(
klass=ResourceHolder,
num_items_x=2,
num_items_y=2,
dx=start_x,
dy=start_y,
dz=5.0,
item_dx=bottle_spacing_x,
item_dy=bottle_spacing_y,
size_x=bottle_diameter,
size_y=bottle_diameter,
size_z=carrier_size_z,
)
for k, v in sites.items():
v.name = f"{name}_{v.name}"
carrier = BottleCarrier(
name=name,
size_x=carrier_size_x,
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=sites,
model="YB_peiyepingdaban",
)
carrier.num_items_x = 2
carrier.num_items_y = 2
carrier.num_items_z = 1
ordering = ["A1", "A2", "B1", "B2"]
for i in range(4):
carrier[i] = YB_pei_ye_da_Bottle(f"{name}_bottle_{ordering[i]}")
return carrier
# 加样头(大)板 - 1x1布局1个位置
def YB_jia_yang_tou_da_Carrier(name: str) -> BottleCarrier:
# 载架尺寸 (mm)
carrier_size_x = 127.8
carrier_size_y = 85.5
carrier_size_z = 95.0
# 瓶位尺寸
bottle_diameter = 35.0
bottle_spacing_x = 42.0 # X方向间距
bottle_spacing_y = 35.0 # Y方向间距
# 计算起始位置 (居中排列)
start_x = (carrier_size_x - (1 - 1) * bottle_spacing_x - bottle_diameter) / 2
start_y = (carrier_size_y - (1 - 1) * bottle_spacing_y - bottle_diameter) / 2
sites = create_ordered_items_2d(
klass=ResourceHolder,
num_items_x=1,
num_items_y=1,
dx=start_x,
dy=start_y,
dz=5.0,
item_dx=bottle_spacing_x,
item_dy=bottle_spacing_y,
size_x=bottle_diameter,
size_y=bottle_diameter,
size_z=carrier_size_z,
)
for k, v in sites.items():
v.name = f"{name}_{v.name}"
carrier = BottleCarrier(
name=name,
size_x=carrier_size_x,
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=sites,
model="YB_jia_yang_tou_da_Carrier",
)
carrier.num_items_x = 1
carrier.num_items_y = 1
carrier.num_items_z = 1
carrier[0] = YB_jia_yang_tou_da(f"{name}_head_1")
return carrier
def YB_shi_pei_qi_kuai(name: str) -> BottleCarrier:
"""适配器块 - 单个中央位置"""
# 载架尺寸 (mm)
carrier_size_x = 127.8
carrier_size_y = 85.5
carrier_size_z = 30.0
# 适配器尺寸
adapter_diameter = 80.0
# 计算中央位置
center_x = (carrier_size_x - adapter_diameter) / 2
center_y = (carrier_size_y - adapter_diameter) / 2
center_z = 0.0
carrier = BottleCarrier(
name=name,
size_x=carrier_size_x,
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=create_homogeneous_resources(
klass=ResourceHolder,
locations=[Coordinate(center_x, center_y, center_z)],
resource_size_x=adapter_diameter,
resource_size_y=adapter_diameter,
name_prefix=name,
),
model="YB_shi_pei_qi_kuai",
)
carrier.num_items_x = 1
carrier.num_items_y = 1
carrier.num_items_z = 1
# 适配器块本身不包含瓶子,只是一个支撑结构
return carrier
def YB_qiang_tou_he(name: str) -> BottleCarrier:
"""枪头盒 - 8x12布局96个位置"""
# 载架尺寸 (mm)
carrier_size_x = 127.8
carrier_size_y = 85.5
carrier_size_z = 55.0
# 枪头尺寸
tip_diameter = 10.0
tip_spacing_x = 9.0 # X方向间距
tip_spacing_y = 9.0 # Y方向间距
# 计算起始位置 (居中排列)
start_x = (carrier_size_x - (12 - 1) * tip_spacing_x - tip_diameter) / 2
start_y = (carrier_size_y - (8 - 1) * tip_spacing_y - tip_diameter) / 2
sites = create_ordered_items_2d(
klass=ResourceHolder,
num_items_x=12,
num_items_y=8,
dx=start_x,
dy=start_y,
dz=5.0,
item_dx=tip_spacing_x,
item_dy=tip_spacing_y,
size_x=tip_diameter,
size_y=tip_diameter,
size_z=carrier_size_z,
)
for k, v in sites.items():
v.name = f"{name}_{v.name}"
carrier = BottleCarrier(
name=name,
size_x=carrier_size_x,
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=sites,
model="YB_qiang_tou_he",
)
carrier.num_items_x = 12
carrier.num_items_y = 8
carrier.num_items_z = 1
# 创建96个枪头
for i in range(96):
row = chr(65 + i // 12) # A-H
col = (i % 12) + 1 # 1-12
carrier[i] = YB_qiang_tou(f"{name}_tip_{row}{col}")
return carrier

View File

@@ -0,0 +1,163 @@
from unilabos.resources.itemized_carrier import Bottle, BottleCarrier
# 工厂函数
"""加样头(大)"""
def YB_jia_yang_tou_da(
name: str,
diameter: float = 20.0,
height: float = 100.0,
max_volume: float = 30000.0, # 30mL
barcode: str = None,
) -> Bottle:
"""创建粉末瓶"""
return Bottle(
name=name,
diameter=diameter,# 未知
height=height,
max_volume=max_volume,
barcode=barcode,
model="YB_jia_yang_tou_da",
)
"""液1x1"""
def YB_ye_Bottle(
name: str,
diameter: float = 40.0,
height: float = 70.0,
max_volume: float = 50000.0, # 50mL
barcode: str = None,
) -> Bottle:
"""创建液体瓶"""
return Bottle(
name=name,
diameter=diameter,
height=height,
max_volume=max_volume,
barcode=barcode,
model="YB_ye_Bottle",
)
"""100ml液体"""
def YB_ye_100ml_Bottle(
name: str,
diameter: float = 50.0,
height: float = 90.0,
max_volume: float = 100000.0, # 100mL
barcode: str = None,
) -> Bottle:
"""创建100ml液体瓶"""
return Bottle(
name=name,
diameter=diameter,
height=height,
max_volume=max_volume,
barcode=barcode,
model="YB_100ml_yeti",
)
"""高粘液"""
def YB_gao_nian_ye_Bottle(
name: str,
diameter: float = 40.0,
height: float = 70.0,
max_volume: float = 50000.0, # 50mL
barcode: str = None,
) -> Bottle:
"""创建高粘液瓶"""
return Bottle(
name=name,
diameter=diameter,
height=height,
max_volume=max_volume,
barcode=barcode,
model="High_Viscosity_Liquid",
)
"""5ml分液瓶"""
def YB_5ml_fenyeping(
name: str,
diameter: float = 20.0,
height: float = 50.0,
max_volume: float = 5000.0, # 5mL
barcode: str = None,
) -> Bottle:
"""创建5ml分液瓶"""
return Bottle(
name=name,
diameter=diameter,
height=height,
max_volume=max_volume,
barcode=barcode,
model="YB_5ml_fenyeping",
)
"""20ml分液瓶"""
def YB_20ml_fenyeping(
name: str,
diameter: float = 30.0,
height: float = 65.0,
max_volume: float = 20000.0, # 20mL
barcode: str = None,
) -> Bottle:
"""创建20ml分液瓶"""
return Bottle(
name=name,
diameter=diameter,
height=height,
max_volume=max_volume,
barcode=barcode,
model="YB_20ml_fenyeping",
)
"""配液瓶(小)"""
def YB_pei_ye_xiao_Bottle(
name: str,
diameter: float = 35.0,
height: float = 60.0,
max_volume: float = 30000.0, # 30mL
barcode: str = None,
) -> Bottle:
"""创建配液瓶(小)"""
return Bottle(
name=name,
diameter=diameter,
height=height,
max_volume=max_volume,
barcode=barcode,
model="YB_pei_ye_xiao_Bottle",
)
"""配液瓶(大)"""
def YB_pei_ye_da_Bottle(
name: str,
diameter: float = 55.0,
height: float = 100.0,
max_volume: float = 150000.0, # 150mL
barcode: str = None,
) -> Bottle:
"""创建配液瓶(大)"""
return Bottle(
name=name,
diameter=diameter,
height=height,
max_volume=max_volume,
barcode=barcode,
model="YB_pei_ye_da_Bottle",
)
"""枪头"""
def YB_qiang_tou(
name: str,
diameter: float = 10.0,
height: float = 50.0,
max_volume: float = 1000.0, # 1mL
barcode: str = None,
) -> Bottle:
"""创建枪头"""
return Bottle(
name=name,
diameter=diameter,
height=height,
max_volume=max_volume,
barcode=barcode,
model="YB_qiang_tou",
)

View File

@@ -0,0 +1,384 @@
from unilabos.resources.warehouse import WareHouse, warehouse_factory
# ================ 反应站相关堆栈 ================
def bioyond_warehouse_1x4x4(name: str) -> WareHouse:
"""创建BioYond 4x4x1仓库 (左侧堆栈: A01D04)
使用行优先排序,前端展示为:
A01 | A02 | A03 | A04
B01 | B02 | B03 | B04
C01 | C02 | C03 | C04
D01 | D02 | D03 | D04
"""
return warehouse_factory(
name=name,
num_items_x=4, # 4列
num_items_y=4, # 4行
num_items_z=1,
dx=10.0,
dy=10.0,
dz=10.0,
item_dx=147.0,
item_dy=106.0,
item_dz=130.0,
category="warehouse",
col_offset=0, # 从01开始: A01, A02, A03, A04
layout="row-major", # ⭐ 改为行优先排序
)
def bioyond_warehouse_1x4x4_right(name: str) -> WareHouse:
"""创建BioYond 4x4x1仓库 (右侧堆栈: A05D08)"""
return warehouse_factory(
name=name,
num_items_x=4,
num_items_y=4,
num_items_z=1,
dx=10.0,
dy=10.0,
dz=10.0,
item_dx=147.0,
item_dy=106.0,
item_dz=130.0,
category="warehouse",
col_offset=4, # 从05开始: A05, A06, A07, A08
layout="row-major", # ⭐ 改为行优先排序
)
def bioyond_warehouse_density_vial(name: str) -> WareHouse:
"""创建测量小瓶仓库(测密度) A01B03"""
return warehouse_factory(
name=name,
num_items_x=3, # 3列01-03
num_items_y=2, # 2行A-B
num_items_z=1, # 1层
dx=10.0,
dy=10.0,
dz=10.0,
item_dx=40.0,
item_dy=40.0,
item_dz=50.0,
# 用更小的 resource_size 来表现 "小点的孔位"
resource_size_x=30.0,
resource_size_y=30.0,
resource_size_z=12.0,
category="warehouse",
col_offset=0,
layout="row-major",
)
def bioyond_warehouse_reagent_storage(name: str) -> WareHouse:
"""创建BioYond站内试剂存放堆栈A01A02, 1行×2列"""
return warehouse_factory(
name=name,
num_items_x=2, # 2列01-02
num_items_y=1, # 1行A
num_items_z=1, # 1层
dx=10.0,
dy=10.0,
dz=10.0,
item_dx=137.0,
item_dy=96.0,
item_dz=120.0,
category="warehouse",
)
def bioyond_warehouse_tipbox_storage(name: str) -> WareHouse:
"""创建BioYond站内Tip盒堆栈A01B03, 2行×3列"""
return warehouse_factory(
name=name,
num_items_x=3, # 3列01-03
num_items_y=2, # 2行A-B
num_items_z=1, # 1层
dx=10.0,
dy=10.0,
dz=10.0,
item_dx=137.0,
item_dy=96.0,
item_dz=120.0,
category="warehouse",
col_offset=0,
layout="row-major",
)
def bioyond_warehouse_liquid_preparation(name: str) -> WareHouse:
"""已弃用,创建BioYond移液站内10%分装液体准备仓库A01B04"""
return warehouse_factory(
name=name,
num_items_x=4, # 4列01-04
num_items_y=2, # 2行A-B
num_items_z=1, # 1层
dx=10.0,
dy=10.0,
dz=10.0,
item_dx=137.0,
item_dy=96.0,
item_dz=120.0,
category="warehouse",
col_offset=0,
layout="row-major",
)
# ================ 配液站相关堆栈 ================
def bioyond_warehouse_reagent_stack(name: str) -> WareHouse:
"""创建BioYond 试剂堆栈 2x4x1 (2行×4列: A01-A04, B01-B04)
使用行优先排序,前端展示为:
A01 | A02 | A03 | A04
B01 | B02 | B03 | B04
"""
return warehouse_factory(
name=name,
num_items_x=4, # 4列 (01-04)
num_items_y=2, # 2行 (A-B)
num_items_z=1, # 1层
dx=10.0,
dy=10.0,
dz=10.0,
item_dx=147.0,
item_dy=106.0,
item_dz=130.0,
category="warehouse",
col_offset=0, # 从01开始
layout="row-major", # ⭐ 使用行优先排序: A01,A02,A03,A04, B01,B02,B03,B04
)
# 定义bioyond的堆栈
# =================== Other ===================
def bioyond_warehouse_1x4x2(name: str) -> WareHouse:
"""创建BioYond 4x2x1仓库"""
return warehouse_factory(
name=name,
num_items_x=1,
num_items_y=4,
num_items_z=2,
dx=10.0,
dy=10.0,
dz=10.0,
item_dx=137.0,
item_dy=96.0,
item_dz=120.0,
category="warehouse",
removed_positions=None
)
def bioyond_warehouse_1x2x2(name: str) -> WareHouse:
"""创建BioYond 1x2x2仓库1列×2行×2层- 旧版本,已弃用
布局2层:
层1: A01
B01
层2: A02
B02
"""
return warehouse_factory(
name=name,
num_items_x=1,
num_items_y=2,
num_items_z=2,
dx=10.0,
dy=10.0,
dz=10.0,
item_dx=137.0,
item_dy=96.0,
item_dz=120.0,
category="warehouse",
layout="row-major", # 使用行优先避免上下颠倒
)
def bioyond_warehouse_2x2x1(name: str) -> WareHouse:
"""创建BioYond 2x2x1仓库2行×2列×1层
布局:
A01 | A02
B01 | B02
"""
return warehouse_factory(
name=name,
num_items_x=2, # 2列
num_items_y=2, # 2行
num_items_z=1, # 1层
dx=10.0,
dy=10.0,
dz=10.0,
item_dx=137.0,
item_dy=96.0,
item_dz=120.0,
category="warehouse",
layout="row-major", # 使用行优先避免上下颠倒
)
def bioyond_warehouse_10x1x1(name: str) -> WareHouse:
"""创建BioYond 10x1x1仓库"""
return warehouse_factory(
name=name,
num_items_x=10,
num_items_y=1,
num_items_z=1,
dx=10.0,
dy=10.0,
dz=10.0,
item_dx=137.0,
item_dy=96.0,
item_dz=120.0,
category="warehouse",
)
def bioyond_warehouse_1x3x3(name: str) -> WareHouse:
"""创建BioYond 1x3x3仓库"""
return warehouse_factory(
name=name,
num_items_x=1,
num_items_y=3,
num_items_z=3,
dx=10.0,
dy=10.0,
dz=10.0,
item_dx=137.0,
item_dy=120.0, # 增大Y方向间距以避免重叠
item_dz=120.0,
category="warehouse",
)
def bioyond_warehouse_5x3x1(name: str, row_offset: int = 0) -> WareHouse:
"""创建BioYond 5x3x1仓库5行×3列×1层
标准布局row_offset=0:
A01 | A02 | A03
B01 | B02 | B03
C01 | C02 | C03
D01 | D02 | D03
E01 | E02 | E03
带偏移布局row_offset=5:
F01 | F02 | F03
G01 | G02 | G03
H01 | H02 | H03
I01 | I02 | I03
J01 | J02 | J03
"""
return warehouse_factory(
name=name,
num_items_x=3, # 3列
num_items_y=5, # 5行
num_items_z=1, # 1层
dx=10.0,
dy=10.0,
dz=10.0,
item_dx=137.0,
item_dy=120.0,
item_dz=120.0,
category="warehouse",
col_offset=0,
row_offset=row_offset, # 支持行偏移
layout="row-major", # 使用行优先避免颠倒
)
def bioyond_warehouse_3x3x1(name: str) -> WareHouse:
"""创建BioYond 3x3x1仓库3行×3列×1层
布局:
A01 | A02 | A03
B01 | B02 | B03
C01 | C02 | C03
"""
return warehouse_factory(
name=name,
num_items_x=3,
num_items_y=3,
num_items_z=1,
dx=10.0,
dy=10.0,
dz=10.0,
item_dx=137.0,
item_dy=96.0,
item_dz=120.0,
category="warehouse",
layout="row-major", # ⭐ 使用行优先避免上下颠倒
)
def bioyond_warehouse_2x1x3(name: str) -> WareHouse:
"""创建BioYond 2x1x3仓库"""
return warehouse_factory(
name=name,
num_items_x=2,
num_items_y=1,
num_items_z=3,
dx=10.0,
dy=10.0,
dz=10.0,
item_dx=137.0,
item_dy=96.0,
item_dz=120.0,
category="warehouse",
)
def bioyond_warehouse_5x1x1(name: str) -> WareHouse:
"""已弃用创建BioYond 5x1x1仓库"""
return warehouse_factory(
name=name,
num_items_x=5,
num_items_y=1,
num_items_z=1,
dx=10.0,
dy=10.0,
dz=10.0,
item_dx=137.0,
item_dy=96.0,
item_dz=120.0,
category="warehouse",
)
def bioyond_warehouse_3x3x1_2(name: str) -> WareHouse:
"""已弃用创建BioYond 3x3x1仓库"""
return warehouse_factory(
name=name,
num_items_x=3,
num_items_y=3,
num_items_z=1,
dx=12.0,
dy=12.0,
dz=12.0,
item_dx=137.0,
item_dy=96.0,
item_dz=120.0,
category="warehouse",
)
def bioyond_warehouse_liquid_and_lid_handling(name: str) -> WareHouse:
"""创建BioYond开关盖加液模块台面"""
return warehouse_factory(
name=name,
num_items_x=2,
num_items_y=5,
num_items_z=1,
dx=10.0,
dy=10.0,
dz=10.0,
item_dx=137.0,
item_dy=96.0,
item_dz=120.0,
category="warehouse",
removed_positions=None
)
def bioyond_warehouse_1x8x4(name: str) -> WareHouse:
"""创建BioYond 8x4x1反应站堆栈A01D08"""
return warehouse_factory(
name=name,
num_items_x=8, # 8列01-08
num_items_y=4, # 4行A-D
num_items_z=1, # 1层
dx=10.0,
dy=10.0,
dz=10.0,
item_dx=147.0,
item_dy=106.0,
item_dz=130.0,
category="warehouse",
)

View File

@@ -193,3 +193,20 @@ def BIOYOND_PolymerStation_Flask(
barcode=barcode,
model="BIOYOND_PolymerStation_Flask",
)
def BIOYOND_PolymerStation_Measurement_Vial(
name: str,
diameter: float = 25.0,
height: float = 60.0,
max_volume: float = 20000.0, # 20mL
barcode: str = None,
) -> Bottle:
"""创建测量小瓶"""
return Bottle(
name=name,
diameter=diameter,
height=height,
max_volume=max_volume,
barcode=barcode,
model="BIOYOND_PolymerStation_Measurement_Vial",
)

View File

@@ -1,14 +1,16 @@
from os import name
from pylabrobot.resources import Deck, Coordinate, Rotation
from unilabos.resources.bioyond.warehouses import (
from unilabos.resources.bioyond.YB_warehouses import (
bioyond_warehouse_1x4x4,
bioyond_warehouse_1x4x4_right, # 新增:右侧仓库 (A05D08)
bioyond_warehouse_1x4x2,
bioyond_warehouse_reagent_stack, # 新增:试剂堆栈 (A1-B4)
bioyond_warehouse_liquid_and_lid_handling,
bioyond_warehouse_1x2x2,
bioyond_warehouse_2x2x1, # 新增321和43窗口 (2行×2列)
bioyond_warehouse_1x3x3,
bioyond_warehouse_5x3x1, # 新增:手动传递窗仓库 (5行×3列)
bioyond_warehouse_10x1x1,
bioyond_warehouse_3x3x1,
bioyond_warehouse_3x3x1_2,
@@ -16,9 +18,12 @@ from unilabos.resources.bioyond.warehouses import (
bioyond_warehouse_1x8x4,
bioyond_warehouse_reagent_storage,
# bioyond_warehouse_liquid_preparation,
bioyond_warehouse_tipbox_storage, # 新增Tip盒堆栈
bioyond_warehouse_density_vial,
)
from unilabos.resources.bioyond.warehouses import (
bioyond_warehouse_tipbox_storage_left, # 新增Tip盒堆栈(左)
bioyond_warehouse_tipbox_storage_right, # 新增Tip盒堆栈(右)
)
class BIOYOND_PolymerReactionStation_Deck(Deck):
@@ -45,24 +50,22 @@ class BIOYOND_PolymerReactionStation_Deck(Deck):
"堆栈1右": bioyond_warehouse_1x4x4_right("堆栈1右"), # 右侧堆栈: A05D08
"站内试剂存放堆栈": bioyond_warehouse_reagent_storage("站内试剂存放堆栈"), # A01A02
# "移液站内10%分装液体准备仓库": bioyond_warehouse_liquid_preparation("移液站内10%分装液体准备仓库"), # A01B04
"站内Tip盒堆栈": bioyond_warehouse_tipbox_storage("站内Tip盒堆栈"), # A01B03, 存放枪头盒.
"站内Tip盒堆栈(左)": bioyond_warehouse_tipbox_storage_left("站内Tip盒堆栈(左)"), # A02B03
"站内Tip盒堆栈(右)": bioyond_warehouse_tipbox_storage_right("站内Tip盒堆栈(右)"), # A01B01
"测量小瓶仓库(测密度)": bioyond_warehouse_density_vial("测量小瓶仓库(测密度)"), # A01B03
}
self.warehouse_locations = {
"堆栈1左": Coordinate(0.0, 430.0, 0.0), # 左侧位置
"堆栈1右": Coordinate(2500.0, 430.0, 0.0), # 右侧位置
"站内试剂存放堆栈": Coordinate(640.0, 480.0, 0.0),
# "移液站内10%分装液体准备仓库": Coordinate(1200.0, 600.0, 0.0),
"站内Tip盒堆栈": Coordinate(300.0, 150.0, 0.0),
"测量小瓶仓库(测密度)": Coordinate(922.0, 552.0, 0.0),
"堆栈1左": Coordinate(-200.0, 400.0, 0.0), # 左侧位置
"堆栈1右": Coordinate(2350.0, 400.0, 0.0), # 右侧位置
"站内试剂存放堆栈": Coordinate(640.0, 400.0, 0.0),
"站内Tip盒堆栈(左)": Coordinate(300.0, 100.0, 0.0),
"站内Tip盒堆栈(右)": Coordinate(2250.0, 100.0, 0.0), # 向右偏移 2 * item_dx (137.0)
"测量小瓶仓库(测密度)": Coordinate(1000.0, 530.0, 0.0),
}
self.warehouses["站内试剂存放堆栈"].rotation = Rotation(z=90)
self.warehouses["测量小瓶仓库(测密度)"].rotation = Rotation(z=270)
for warehouse_name, warehouse in self.warehouses.items():
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
class BIOYOND_PolymerPreparationStation_Deck(Deck):
def __init__(
self,
@@ -90,9 +93,9 @@ class BIOYOND_PolymerPreparationStation_Deck(Deck):
"溶液堆栈": bioyond_warehouse_1x4x4("溶液堆栈"), # 4行×4列 (A01-D04)
}
self.warehouse_locations = {
"粉末堆栈": Coordinate(0.0, 450.0, 0.0),
"试剂堆栈": Coordinate(1850.0, 200.0, 0.0),
"溶液堆栈": Coordinate(2500.0, 450.0, 0.0),
"粉末堆栈": Coordinate(-200.0, 400.0, 0.0),
"试剂堆栈": Coordinate(1750.0, 160.0, 0.0),
"溶液堆栈": Coordinate(2350.0, 400.0, 0.0),
}
for warehouse_name, warehouse in self.warehouses.items():
@@ -115,10 +118,10 @@ class BIOYOND_YB_Deck(Deck):
def setup(self) -> None:
# 添加仓库
self.warehouses = {
"321窗口": bioyond_warehouse_1x2x2("321窗口"),
"43窗口": bioyond_warehouse_1x2x2("43窗口"),
"手动传递窗": bioyond_warehouse_1x3x3("手动传递窗"),
"手动传递窗": bioyond_warehouse_1x3x3("手动传递窗"),
"321窗口": bioyond_warehouse_2x2x1("321窗口"), # 2行×2列
"43窗口": bioyond_warehouse_2x2x1("43窗口"), # 2行×2列
"手动传递窗": bioyond_warehouse_5x3x1("手动传递窗", row_offset=0), # A01-E03
"手动传递窗": bioyond_warehouse_5x3x1("手动传递窗", row_offset=5), # F01-J03
"加样头堆栈左": bioyond_warehouse_10x1x1("加样头堆栈左"),
"加样头堆栈右": bioyond_warehouse_10x1x1("加样头堆栈右"),
@@ -126,6 +129,7 @@ class BIOYOND_YB_Deck(Deck):
"母液加样右": bioyond_warehouse_3x3x1_2("母液加样右"),
"大瓶母液堆栈左": bioyond_warehouse_5x1x1("大瓶母液堆栈左"),
"大瓶母液堆栈右": bioyond_warehouse_5x1x1("大瓶母液堆栈右"),
"2号手套箱内部堆栈": bioyond_warehouse_3x3x1("2号手套箱内部堆栈"), # 新增3行×3列 (A01-C03)
}
# warehouse 的位置
self.warehouse_locations = {
@@ -140,10 +144,12 @@ class BIOYOND_YB_Deck(Deck):
"母液加样右": Coordinate(2152.0, 333.0, 0.0),
"大瓶母液堆栈左": Coordinate(1164.0, 676.0, 0.0),
"大瓶母液堆栈右": Coordinate(2717.0, 676.0, 0.0),
"2号手套箱内部堆栈": Coordinate(-800, -500.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()

View File

@@ -46,48 +46,62 @@ def bioyond_warehouse_1x4x4_right(name: str) -> WareHouse:
)
def bioyond_warehouse_density_vial(name: str) -> WareHouse:
"""创建测量小瓶仓库(测密度) A01B03"""
"""创建测量小瓶仓库(测密度) - 竖向排列2列3行
布局(从下到上,从左到右):
| A03 | B03 | ← 顶部
| A02 | B02 | ← 中部
| A01 | B01 | ← 底部
"""
return warehouse_factory(
name=name,
num_items_x=3, # 3列(01-03
num_items_y=2, # 2行(A-B
num_items_x=2, # 2列(A, B
num_items_y=3, # 3行(01-03从下到上
num_items_z=1, # 1层
dx=10.0,
dy=10.0,
dz=10.0,
item_dx=40.0,
item_dy=40.0,
item_dx=40.0, # 列间距A到B的横向距离
item_dy=40.0, # 行间距01到02到03的竖向距离
item_dz=50.0,
# 用更小的 resource_size 来表现 "小点的孔位"
# ⭐ 竖向warehouse槽位尺寸也是竖向的小瓶已经是正方形无需调整
resource_size_x=30.0,
resource_size_y=30.0,
resource_size_z=12.0,
category="warehouse",
col_offset=0,
layout="row-major",
layout="vertical-col-major", # ⭐ 竖向warehouse专用布局
)
def bioyond_warehouse_reagent_storage(name: str) -> WareHouse:
"""创建BioYond站内试剂存放堆栈A01A02, 1行×2列"""
"""创建BioYond站内试剂存放堆栈 - 竖向排列1列2行
布局(竖向,从下到上):
| A02 | ← 顶部
| A01 | ← 底部
"""
return warehouse_factory(
name=name,
num_items_x=2, # 2列01-02
num_items_y=1, # 1行(A
num_items_x=1, # 1列
num_items_y=2, # 2行(01-02从下到上
num_items_z=1, # 1层
dx=10.0,
dy=10.0,
dz=10.0,
item_dx=137.0,
item_dy=96.0,
item_dx=96.0, # 列间距这里只有1列不重要
item_dy=137.0, # 行间距A01到A02的竖向距离
item_dz=120.0,
# ⭐ 竖向warehouse交换槽位尺寸使槽位框也是竖向的
resource_size_x=86.0, # 原来的 resource_size_y
resource_size_y=127.0, # 原来的 resource_size_x
resource_size_z=25.0,
category="warehouse",
layout="vertical-col-major", # ⭐ 竖向warehouse专用布局
)
def bioyond_warehouse_tipbox_storage(name: str) -> WareHouse:
"""创建BioYond站内Tip盒堆栈A01B03用于存放枪头盒"""
def bioyond_warehouse_tipbox_storage_left(name: str) -> WareHouse:
"""创建BioYond站内Tip盒堆栈左侧部分A02B032列2行"""
return warehouse_factory(
name=name,
num_items_x=3, # 3列01-03
num_items_x=2, # 2列
num_items_y=2, # 2行A-B
num_items_z=1, # 1层
dx=10.0,
@@ -97,7 +111,25 @@ def bioyond_warehouse_tipbox_storage(name: str) -> WareHouse:
item_dy=96.0,
item_dz=120.0,
category="warehouse",
col_offset=0,
col_offset=1, # 从02开始: A02, A03
layout="row-major",
)
def bioyond_warehouse_tipbox_storage_right(name: str) -> WareHouse:
"""创建BioYond站内Tip盒堆栈右侧部分A01B011列2行"""
return warehouse_factory(
name=name,
num_items_x=1, # 1列
num_items_y=2, # 2行A-B
num_items_z=1, # 1层
dx=10.0,
dy=10.0,
dz=10.0,
item_dx=137.0,
item_dy=96.0,
item_dz=120.0,
category="warehouse",
col_offset=0, # 从01开始: A01
layout="row-major",
)

View File

@@ -819,6 +819,22 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
if not locations:
logger.debug(f"[物料位置] {unique_name} 没有location信息跳过warehouse放置")
# ⭐ 预先检查如果物料的任何location在竖向warehouse中提前交换尺寸
# 这样可以避免多个location时尺寸不一致的问题
needs_size_swap = False
for loc in locations:
wh_name_check = loc.get("whName")
if wh_name_check in ["站内试剂存放堆栈", "测量小瓶仓库(测密度)"]:
needs_size_swap = True
break
if needs_size_swap and hasattr(plr_material, 'size_x') and hasattr(plr_material, 'size_y'):
original_x = plr_material.size_x
original_y = plr_material.size_y
plr_material.size_x = original_y
plr_material.size_y = original_x
logger.debug(f" 物料 {unique_name} 将放入竖向warehouse预先交换尺寸: {original_x}×{original_y}{plr_material.size_x}×{plr_material.size_y}")
for loc in locations:
wh_name = loc.get("whName")
logger.debug(f"[物料位置] {unique_name} 尝试放置到 warehouse: {wh_name} (Bioyond坐标: x={loc.get('x')}, y={loc.get('y')}, z={loc.get('z')})")
@@ -835,12 +851,20 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
logger.warning(f"物料 {material['name']} 的列号 x={x_val} 超出范围无法映射到堆栈1左或堆栈1右")
continue
# 特殊处理: Bioyond的"站内Tip盒堆栈"也需要进行拆分映射
if wh_name == "站内Tip盒堆栈":
y_val = loc.get("y", 1)
if y_val == 1:
wh_name = "站内Tip盒堆栈(右)"
elif y_val in [2, 3]:
wh_name = "站内Tip盒堆栈(左)"
y = y - 1 # 调整列号,因为左侧仓库对应的 Bioyond y=2 实际上是它的第1列
if hasattr(deck, "warehouses") and wh_name in deck.warehouses:
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)
# PyLabRobot warehouse是列优先存储: A01,B01,C01,D01, A02,B02,C02,D02, ...
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...)
z = loc.get("z", 1) # 层号 (1-based, 通常为1)
@@ -849,12 +873,23 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
if wh_name == "堆栈1右":
y = y - 4 # 将5-8映射到1-4
# 特殊处理对于1行×N列的横向warehouse站内试剂存放堆栈)
# Bioyond的y坐标表示线性位置序号而不是列号
if warehouse.num_items_y == 1:
# 1行warehouse: 直接用y作为线性索引
idx = y - 1
logger.debug(f"1行warehouse {wh_name}: y={y} → idx={idx}")
# 特殊处理向warehouse站内试剂存放堆栈、测量小瓶仓库
# 这些warehouse使用 vertical-col-major 布局
if wh_name in ["站内试剂存放堆栈", "测量小瓶仓库(测密度)"]:
# vertical-col-major 布局的坐标映射:
# - Bioyond的x(1=A,2=B)对应warehouse的列(col, x方向)
# - Bioyond的y(1=01,2=02,3=03)对应warehouse的行(row, y方向),从下到上
# vertical-col-major 中: row=0 对应底部row=n-1 对应顶部
# Bioyond y=1(01) 对应底部 → row=0, y=2(02) 对应中间 → row=1
# 索引计算: idx = row * num_cols + col
col_idx = x - 1 # Bioyond的x(A,B) → col索引(0,1)
row_idx = y - 1 # Bioyond的y(01,02,03) → row索引(0,1,2)
layer_idx = z - 1
idx = layer_idx * (warehouse.num_items_x * warehouse.num_items_y) + row_idx * warehouse.num_items_y + col_idx
logger.debug(f"🔍 竖向warehouse {wh_name}: Bioyond(x={x},y={y},z={z}) → warehouse(col={col_idx},row={row_idx},layer={layer_idx}) → idx={idx}, capacity={warehouse.capacity}")
# 普通横向warehouse的处理
else:
# 多行warehouse: 根据 layout 使用不同的索引计算
row_idx = x - 1 # x表示行: 转为0-based
@@ -878,6 +913,7 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
if 0 <= idx < warehouse.capacity:
if warehouse[idx] is None or isinstance(warehouse[idx], ResourceHolder):
# 物料尺寸已在放入warehouse前根据需要进行了交换
warehouse[idx] = plr_material
logger.debug(f"✅ 物料 {unique_name} 放置到 {wh_name}[{idx}] (Bioyond坐标: x={loc.get('x')}, y={loc.get('y')})")
else:
@@ -1051,11 +1087,24 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict
logger.debug(f" 📭 [单瓶物料] {resource.name} 无液体,使用资源名: {material_name}")
# 🎯 处理物料默认参数和单位
# 检查是否有该物料名称的默认参数配置
# 优先级: typeId参数 > 物料名称参数 > 默认值
default_unit = "" # 默认单位
material_parameters = {}
if material_name in material_params:
# 1⃣ 首先检查是否有 typeId 对应的参数配置(从 material_params 中获取key 格式为 "type:<typeId>"
type_params_key = f"type:{type_id}"
if type_params_key in material_params:
params_config = material_params[type_params_key].copy()
# 提取 unit 字段(如果有)
if "unit" in params_config:
default_unit = params_config.pop("unit") # 从参数中移除,放到外层
# 剩余的字段放入 Parameters
material_parameters = params_config
logger.debug(f" 🔧 [物料参数-按typeId] 为 typeId={type_id[:8]}... 应用配置: unit={default_unit}, parameters={material_parameters}")
# 2⃣ 其次检查是否有该物料名称的默认参数配置
elif material_name in material_params:
params_config = material_params[material_name].copy()
# 提取 unit 字段(如果有)
@@ -1064,7 +1113,7 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict
# 剩余的字段放入 Parameters
material_parameters = params_config
logger.debug(f" 🔧 [物料参数] 为 {material_name} 应用配置: unit={default_unit}, parameters={material_parameters}")
logger.debug(f" 🔧 [物料参数-按名称] 为 {material_name} 应用配置: unit={default_unit}, parameters={material_parameters}")
# 转换为 JSON 字符串
parameters_json = json.dumps(material_parameters) if material_parameters else "{}"

View File

@@ -29,7 +29,7 @@ class Bottle(Well):
size_x: float = 0.0,
size_y: float = 0.0,
size_z: float = 0.0,
barcode: Optional[str] = "",
barcode: Optional[str] = None,
category: str = "container",
model: Optional[str] = None,
**kwargs,
@@ -50,13 +50,45 @@ class Bottle(Well):
self.barcode = barcode
def serialize(self) -> dict:
# Pylabrobot expects barcode to be an object with serialize(), but here it is a str.
# We temporarily unset it to avoid AttributeError in super().serialize().
_barcode = self.barcode
self.barcode = None
try:
data = super().serialize()
finally:
self.barcode = _barcode
return {
**super().serialize(),
**data,
"diameter": self.diameter,
"height": self.height,
"barcode": self.barcode,
}
@classmethod
def deserialize(cls, data: dict, allow_marshal: bool = False):
# Extract barcode before calling parent deserialize to avoid type error
barcode_data = data.pop("barcode", None)
# Call parent deserialize
instance = super(Bottle, cls).deserialize(data, allow_marshal=allow_marshal)
# Set barcode as string (not as Barcode object)
if barcode_data:
if isinstance(barcode_data, str):
instance.barcode = barcode_data
elif isinstance(barcode_data, dict):
# If it's a dict (Barcode serialized format), extract the data field
instance.barcode = barcode_data.get("data", "")
else:
instance.barcode = ""
# Set additional attributes
instance.diameter = data.get("diameter", instance._size_x)
instance.height = data.get("height", instance._size_z)
return instance
T = TypeVar("T", bound=ResourceHolder)
S = TypeVar("S", bound=ResourceHolder)
@@ -79,7 +111,6 @@ class ItemizedCarrier(ResourcePLR):
category: Optional[str] = "carrier",
model: Optional[str] = None,
invisible_slots: Optional[str] = None,
content_type: Optional[List[str]] = ["bottle", "container", "tube", "bottle_carrier", "tip_rack"],
):
super().__init__(
name=name,
@@ -93,7 +124,6 @@ class ItemizedCarrier(ResourcePLR):
self.num_items_x, self.num_items_y, self.num_items_z = num_items_x, num_items_y, num_items_z
self.invisible_slots = [] if invisible_slots is None else invisible_slots
self.layout = "z-y" if self.num_items_z > 1 and self.num_items_x == 1 else "x-z" if self.num_items_z > 1 and self.num_items_y == 1 else "x-y"
self.content_type = content_type
if isinstance(sites, dict):
sites = sites or {}
@@ -421,7 +451,7 @@ class ItemizedCarrier(ResourcePLR):
self[identifier] if isinstance(self[identifier], str) else None,
"position": {"x": location.x, "y": location.y, "z": location.z},
"size": self.child_size[identifier],
"content_type": self.content_type
"content_type": ["bottle", "container", "tube", "bottle_carrier", "tip_rack"]
} for identifier, location in self.child_locations.items()]
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -9,9 +9,6 @@ def register():
from unilabos.devices.liquid_handling.prcxi.prcxi import PRCXI9300TipRack
from unilabos.devices.liquid_handling.prcxi.prcxi import PRCXI9300Trash
from unilabos.devices.liquid_handling.prcxi.prcxi import PRCXI9300TubeRack
from unilabos.devices.liquid_handling.prcxi.prcxi import PRCXI9300ModuleSite
# noinspection PyUnresolvedReferences
from unilabos.devices.liquid_handling.prcxi.prcxi_modules import PRCXI9300FunctionalModule
# noinspection PyUnresolvedReferences
from unilabos.devices.workstation.workstation_base import WorkStationContainer

View File

@@ -123,24 +123,6 @@ class ResourceDictType(TypedDict):
machine_name: str
class ResourceDictType(TypedDict):
id: str
uuid: str
name: str
description: str
resource_schema: Dict[str, Any]
model: Dict[str, Any]
icon: str
parent_uuid: Optional[str]
parent: Optional["ResourceDictType"]
type: Union[Literal["device"], str]
klass: str
pose: ResourceDictPositionType
config: Dict[str, Any]
data: Dict[str, Any]
extra: Dict[str, Any]
# 统一的资源字典模型parent 自动序列化为 parent_uuidchildren 不序列化
class ResourceDict(BaseModel):
id: str = Field(description="Resource ID")
@@ -459,8 +441,6 @@ class ResourceTreeSet(object):
"reagent_bottle": "reagent_bottle",
"flask": "flask",
"beaker": "beaker",
"module": "module",
"carrier": "carrier",
}
if source in replace_info:
return replace_info[source]
@@ -573,17 +553,10 @@ class ResourceTreeSet(object):
trees.append(tree_instance)
return cls(trees)
def to_plr_resources(
self, skip_devices: bool = True, requested_uuids: Optional[List[str]] = None
) -> List["PLRResource"]:
def to_plr_resources(self, skip_devices=True) -> List["PLRResource"]:
"""
将 ResourceTreeSet 转换为 PLR 资源列表
Args:
skip_devices: 是否跳过 device 类型节点
requested_uuids: 若指定,则按此 UUID 顺序返回对应资源(用于批量查询时一一对应),
否则返回各树的根节点列表
Returns:
List[PLRResource]: PLR 资源实例列表
"""
@@ -598,8 +571,6 @@ class ResourceTreeSet(object):
"deck": "Deck",
"container": "RegularContainer",
"tip_spot": "TipSpot",
"module": "PRCXI9300ModuleSite",
"carrier": "ItemizedCarrier",
}
def collect_node_data(node: ResourceDictInstance, name_to_uuid: dict, all_states: dict, name_to_extra: dict):
@@ -641,71 +612,6 @@ class ResourceTreeSet(object):
d["model"] = res.config.get("model", None)
return d
# deserialize 会单独处理的元数据 key不传给构造函数
_META_KEYS = {"type", "parent_name", "location", "children", "rotation", "barcode"}
# deserialize 自定义逻辑使用的 key如 TipSpot 用 prototype_tip 构建 make_tip需保留
_DESERIALIZE_PRESERVED_KEYS = {"prototype_tip"}
def remove_incompatible_params(plr_d: dict) -> None:
"""递归移除 PLR 类不接受的参数,避免 deserialize 报错。
- 移除构造函数不接受的参数(如 compute_height_from_volume、ordering、category
- 对 TubeRack将 ordering 转为 ordered_items
- 保留 deserialize 自定义逻辑需要的 key如 prototype_tip
"""
if "type" in plr_d:
sub_cls = find_subclass(plr_d["type"], PLRResource)
if sub_cls is not None:
spec = inspect.signature(sub_cls)
valid_params = set(spec.parameters.keys())
# TubeRack 特殊处理:先转换 ordering再参与后续过滤
if "ordering" not in valid_params and "ordering" in plr_d:
ordering = plr_d.pop("ordering", None)
if sub_cls.__name__ == "TubeRack":
plr_d["ordered_items"] = (
_ordering_to_ordered_items(plr_d, ordering)
if ordering
else {}
)
# 移除构造函数不接受的参数(保留 META 和 deserialize 自定义逻辑需要的 key
for key in list(plr_d.keys()):
if (
key not in _META_KEYS
and key not in _DESERIALIZE_PRESERVED_KEYS
and key not in valid_params
):
plr_d.pop(key, None)
for child in plr_d.get("children", []):
remove_incompatible_params(child)
def _ordering_to_ordered_items(plr_d: dict, ordering: dict) -> dict:
"""将 ordering 转为 ordered_items从 children 构建 Tube 对象"""
from pylabrobot.resources import Tube, Coordinate
from pylabrobot.serializer import deserialize as plr_deserialize
children = plr_d.get("children", [])
ordered_items = {}
for idx, (ident, child_name) in enumerate(ordering.items()):
child_data = children[idx] if idx < len(children) else None
if child_data is None:
continue
loc_data = child_data.get("location")
loc = (
plr_deserialize(loc_data)
if loc_data
else Coordinate(0, 0, 0)
)
tube = Tube(
name=child_data.get("name", child_name or ident),
size_x=child_data.get("size_x", 10),
size_y=child_data.get("size_y", 10),
size_z=child_data.get("size_z", 50),
max_volume=child_data.get("max_volume", 1000),
)
tube.location = loc
ordered_items[ident] = tube
plr_d["children"] = [] # 已并入 ordered_items避免重复反序列化
return ordered_items
plr_resources = []
tracker = DeviceNodeResourceTracker()
@@ -725,7 +631,9 @@ class ResourceTreeSet(object):
raise ValueError(
f"无法找到类型 {plr_dict['type']} 对应的 PLR 资源类。原始信息:{tree.root_node.res_content}"
)
remove_incompatible_params(plr_dict)
spec = inspect.signature(sub_cls)
if "category" not in spec.parameters:
plr_dict.pop("category", None)
plr_resource = sub_cls.deserialize(plr_dict, allow_marshal=True)
from pylabrobot.resources import Coordinate
from pylabrobot.serializer import deserialize
@@ -745,18 +653,6 @@ class ResourceTreeSet(object):
logger.error(f"堆栈: {traceback.format_exc()}")
raise
if requested_uuids:
# 按请求的 UUID 顺序返回对应资源(从整棵树中按 uuid 提取)
result = []
for uid in requested_uuids:
if uid in tracker.uuid_to_resources:
result.append(tracker.uuid_to_resources[uid])
else:
raise ValueError(
f"请求的 UUID {uid} 在资源树中未找到。"
f"可用 UUID 数量: {len(tracker.uuid_to_resources)}"
)
return result
return plr_resources
@classmethod
@@ -845,6 +741,16 @@ class ResourceTreeSet(object):
"""
return [tree.root_node for tree in self.trees]
@property
def root_nodes_uuid(self) -> List[ResourceDictInstance]:
"""
获取所有树的根节点
Returns:
所有根节点的资源实例列表
"""
return [tree.root_node.res_content.uuid for tree in self.trees]
@property
def all_nodes(self) -> List[ResourceDictInstance]:
"""
@@ -962,17 +868,6 @@ class ResourceTreeSet(object):
f"从远端同步了 {added_count} 个物料子树"
)
else:
# 二级是物料
if remote_child_name not in local_children_map:
# 本地不存在该物料,直接引入
remote_child.res_content.parent = local_device.res_content
local_device.children.append(remote_child)
local_children_map[remote_child_name] = remote_child
logger.info(
f"物料 '{remote_root_id}/{remote_child_name}': "
f"从远端同步了整个子树"
)
continue
# 二级物料已存在,比较三级子节点是否缺失
local_material = local_children_map[remote_child_name]
local_material_children_map = {child.res_content.name: child for child in

View File

@@ -27,6 +27,7 @@ def warehouse_factory(
category: str = "warehouse",
model: Optional[str] = None,
col_offset: int = 0, # 列起始偏移量用于生成A05-D08等命名
row_offset: int = 0, # 行起始偏移量用于生成F01-J03等命名
layout: str = "col-major", # 新增:排序方式,"col-major"=列优先,"row-major"=行优先
):
# 创建位置坐标
@@ -42,6 +43,10 @@ def warehouse_factory(
if layout == "row-major":
# 行优先row=0(A行) 应该显示在上方,需要较小的 y 值
y = dy + row * item_dy
elif layout == "vertical-col-major":
# 竖向warehouse: row=0 对应顶部y小row=n-1 对应底部y大
# 但标签 01 应该在底部,所以使用反向映射
y = dy + (num_items_y - row - 1) * item_dy
else:
# 列优先保持原逻辑row=0 对应较大的 y
y = dy + (num_items_y - row - 1) * item_dy
@@ -65,10 +70,10 @@ def warehouse_factory(
if layout == "row-major":
# 行优先顺序: A01,A02,A03,A04, B01,B02,B03,B04
# locations[0] 对应 row=0, y最大前端顶部→ 应该是 A01
keys = [f"{LETTERS[j]}{i + 1 + col_offset:02d}" for j in range(len_y) for i in range(len_x)]
keys = [f"{LETTERS[j + row_offset]}{i + 1 + col_offset:02d}" for j in range(len_y) for i in range(len_x)]
else:
# 列优先顺序: A01,B01,C01,D01, A02,B02,C02,D02
keys = [f"{LETTERS[j]}{i + 1 + col_offset:02d}" for i in range(len_x) for j in range(len_y)]
keys = [f"{LETTERS[j + row_offset]}{i + 1 + col_offset:02d}" for i in range(len_x) for j in range(len_y)]
sites = {i: site for i, site in zip(keys, _sites.values())}

View File

@@ -1486,13 +1486,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
if uuid_indices:
uuids = [item[1] for item in uuid_indices]
resource_tree = await self.get_resource(uuids)
plr_resources = resource_tree.to_plr_resources(requested_uuids=uuids)
plr_resources = resource_tree.to_plr_resources()
for i, (idx, _, resource_data) in enumerate(uuid_indices):
try:
plr_resource = plr_resources[i]
except Exception as e:
self.lab_logger().error(f"资源查询结果: 共 {len(queried_resources)} 个资源,但查询结果只有 {len(plr_resources)} 个资源,索引为 {i} 的资源不存在")
raise e
plr_resource = plr_resources[i]
if "sample_id" in resource_data:
plr_resource.unilabos_extra[EXTRA_SAMPLE_UUID] = resource_data["sample_id"]
queried_resources[idx] = plr_resource

View File

@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Optional, Dict, Any, List, ClassVar, Set, Unio
from action_msgs.msg import GoalStatus
from geometry_msgs.msg import Point
from sensor_msgs.msg import JointState as JointStateMsg
from rclpy.action import ActionClient, get_action_server_names_and_types_by_node
from rclpy.service import Service
from typing_extensions import TypedDict
@@ -24,7 +25,7 @@ from unilabos_msgs.srv import (
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response
from unique_identifier_msgs.msg import UUID
from unilabos.registry.decorators import device, action, NodeType
from unilabos.registry.decorators import device
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
from unilabos.registry.registry import lab_registry
from unilabos.resources.container import RegularContainer
@@ -313,9 +314,7 @@ class HostNode(BaseROS2DeviceNode):
callback_group=self.callback_group,
),
} # 用来存储多个ActionClient实例
self._action_value_mappings: Dict[str, Dict] = {
device_id: self._action_value_mappings
} # device_id -> action_value_mappings(本地+远程设备统一存储)
self._action_value_mappings: Dict[str, Dict] = {} # device_id -> action_value_mappings(本地+远程设备统一存储)
self._slave_registry_configs: Dict[str, Dict] = {} # registry_name -> registry_config(含action_value_mappings)
self._goals: Dict[str, Any] = {} # 用来存储多个目标的状态
self._online_devices: Set[str] = {f"{self.namespace}/{device_id}"} # 用于跟踪在线设备
@@ -350,6 +349,10 @@ class HostNode(BaseROS2DeviceNode):
else:
self.lab_logger().warning(f"[Host Node] Device {device_id} already existed, skipping.")
self.update_device_status_subscriptions()
# 订阅 joint_state_repub topic桥接关节数据到云端
self._init_joint_state_bridge()
# TODO: 需要验证 初始化所有控制器节点
if controllers_config:
update_rate = controllers_config["controller_manager"]["ros__parameters"]["update_rate"]
@@ -784,6 +787,179 @@ class HostNode(BaseROS2DeviceNode):
else:
self.lab_logger().trace(f"Status updated: {device_id}.{property_name} = {msg.data}")
"""关节数据 & 资源跟随桥接"""
# 吞吐优化参数
_JOINT_DEAD_BAND: float = 1e-4 # 关节角度变化小于此值视为无变化
_JOINT_MIN_INTERVAL: float = 0.05 # 最小发送间隔 (秒),限制到 ~20Hz
def _init_joint_state_bridge(self):
"""
订阅 /joint_states (sensor_msgs/JointState) 和 resource_pose (String)
构建 device_id → uuid 映射,并维护 resource_poses 状态。
吞吐优化:
- 死区过滤 (dead band): 关节角度变化 < 阈值时不发送
- 抑频 (throttle): 限制最大发送频率,避免 ROS2 1kHz 打满 WS
- 增量 resource_poses: 仅在 resource_pose 实际变化时才附带发送
"""
# 构建 device_id → cloud_uuid 映射(从 devices_config 中获取)
self._device_uuid_map: Dict[str, str] = {}
for tree in self.devices_config.trees:
node = tree.root_node
if node.res_content.type == "device" and node.res_content.uuid:
self._device_uuid_map[node.res_content.id] = node.res_content.uuid
# 按 device_id 长度降序排列,最长前缀优先匹配(避免 arm 抢先匹配 arm_left_j1
self._device_ids_sorted = sorted(self._device_uuid_map.keys(), key=len, reverse=True)
# 资源挂载状态:{resource_id: parent_link_name}
self._resource_poses: Dict[str, str] = {}
# resource_pose 变化标志,仅在真正变化时随关节数据发送
self._resource_poses_dirty: bool = False
# 吞吐优化状态
self._last_joint_values: Dict[str, float] = {} # 上次发送的关节值(全局)
self._last_send_time: float = -float("inf") # 上次发送时间戳(初始为-inf确保首条通过
self._last_sent_resource_poses: Dict[str, str] = {} # 上次发送的 resource_poses 快照
if not self._device_uuid_map:
self.lab_logger().debug("[Host Node] 无设备 UUID 映射,跳过关节桥接")
return
# 直接订阅 /joint_statessensor_msgs/JointState无需经过 JointRepublisher
self.create_subscription(
JointStateMsg,
"/joint_states",
self._joint_state_callback,
10,
callback_group=self.callback_group,
)
# 订阅 resource_pose资源挂载变化由 ResourceMeshManager 发布)
from std_msgs.msg import String as StdString
self.create_subscription(
StdString,
"resource_pose",
self._resource_pose_callback,
10,
callback_group=self.callback_group,
)
self.lab_logger().info(
f"[Host Node] 已订阅 /joint_states 和 resource_pose设备映射: {list(self._device_uuid_map.keys())}"
)
def _resource_pose_callback(self, msg):
"""
接收 ResourceMeshManager 发布的资源挂载变更。
msg.data 格式: JSON dict{"tip_rack_A1": "gripper_link", "plate_1": "deck_link"}
空 dict {} 表示无变化(心跳包)。
"""
try:
data = json.loads(msg.data)
except (json.JSONDecodeError, ValueError):
return
if not isinstance(data, dict) or not data:
return
# 检测实际变化
has_change = False
for k, v in data.items():
if self._resource_poses.get(k) != v:
has_change = True
break
if has_change:
self._resource_poses.update(data)
self._resource_poses_dirty = True
def _joint_state_callback(self, msg: JointStateMsg):
"""
直接接收 /joint_states (sensor_msgs/JointState),按设备分组后通过 bridge 发送到云端。
吞吐优化:
1. 抑频: 距上次发送 < _JOINT_MIN_INTERVAL 则跳过(除非有 resource_pose 变化)
2. 死区: 所有关节角度变化 < _JOINT_DEAD_BAND 则跳过(除非有 resource_pose 变化)
3. 增量 resource_poses: 仅在 dirty 时附带,否则发空 dict
"""
names = list(msg.name)
positions = list(msg.position)
if not names or len(names) != len(positions):
return
now = time.time()
resource_dirty = self._resource_poses_dirty
# 抑频检查resource_pose 变化时强制发送
if not resource_dirty and (now - self._last_send_time) < self._JOINT_MIN_INTERVAL:
return
# 死区过滤:检测是否有关节值实质变化
has_significant_change = False
for name, pos in zip(names, positions):
last_val = self._last_joint_values.get(name)
if last_val is None or abs(float(pos) - last_val) >= self._JOINT_DEAD_BAND:
has_significant_change = True
break
# 无关节变化且无资源变化 → 跳过
if not has_significant_change and not resource_dirty:
return
# 更新上次发送的关节值
for name, pos in zip(names, positions):
self._last_joint_values[name] = float(pos)
self._last_send_time = now
# 按设备 ID 分组关节数据(最长前缀优先匹配)
device_joints: Dict[str, Dict[str, float]] = {}
for name, pos in zip(names, positions):
matched_device = None
for device_id in self._device_ids_sorted:
if name.startswith(device_id + "_"):
matched_device = device_id
break
if matched_device:
if matched_device not in device_joints:
device_joints[matched_device] = {}
device_joints[matched_device][name] = float(pos)
elif len(self._device_uuid_map) == 1:
fallback_id = self._device_ids_sorted[0]
if fallback_id not in device_joints:
device_joints[fallback_id] = {}
device_joints[fallback_id][name] = float(pos)
# 构建设备级 resource_poses仅在 dirty 时附带实际数据)
device_resource_poses: Dict[str, Dict[str, str]] = {}
if resource_dirty:
for resource_id, link_name in self._resource_poses.items():
matched_device = None
for device_id in self._device_ids_sorted:
if link_name.startswith(device_id + "_"):
matched_device = device_id
break
if matched_device:
if matched_device not in device_resource_poses:
device_resource_poses[matched_device] = {}
device_resource_poses[matched_device][resource_id] = link_name
elif len(self._device_uuid_map) == 1:
fallback_id = self._device_ids_sorted[0]
if fallback_id not in device_resource_poses:
device_resource_poses[fallback_id] = {}
device_resource_poses[fallback_id][resource_id] = link_name
self._resource_poses_dirty = False
# 通过 bridge 发送 push_joint_state含 resource_poses
for device_id, joint_states in device_joints.items():
node_uuid = self._device_uuid_map.get(device_id)
if not node_uuid:
continue
resource_poses = device_resource_poses.get(device_id, {})
for bridge in self.bridges:
if hasattr(bridge, "publish_joint_state"):
bridge.publish_joint_state(node_uuid, joint_states, resource_poses)
def send_goal(
self,
item: "QueueItem",
@@ -1623,18 +1799,6 @@ class HostNode(BaseROS2DeviceNode):
}
return res
@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": []
})
def manual_confirm(self, timeout_seconds: int, assignee_user_ids: list[str], **kwargs) -> dict:
"""
timeout_seconds: 超时时间默认3600秒
"""
return kwargs
def test_resource(
self,
sample_uuids: SampleUUIDsType,

View File

@@ -41,7 +41,7 @@ class JointRepublisher(BaseROS2DeviceNode):
json_dict["velocity"] = list(msg.velocity)
json_dict["effort"] = list(msg.effort)
self.msg.data = str(json_dict)
self.msg.data = json.dumps(json_dict)
self.joint_repub.publish(self.msg)
# print('-'*20)
# print(self.msg.data)

View File

@@ -1,182 +0,0 @@
import asyncio
from asyncio import events
import threading
import rclpy
from rclpy.impl.implementation_singleton import rclpy_implementation as _rclpy
from rclpy.executors import await_or_execute, Executor
from rclpy.action import ActionClient, ActionServer
from rclpy.action.server import ServerGoalHandle, GoalResponse, GoalInfo, GoalStatus
from std_msgs.msg import String
from action_tutorials_interfaces.action import Fibonacci
loop = None
def get_event_loop():
global loop
return loop
async def default_handle_accepted_callback_async(goal_handle):
"""Execute the goal."""
await goal_handle.execute()
class ServerGoalHandleX(ServerGoalHandle):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
async def execute(self, execute_callback=None):
# It's possible that there has been a request to cancel the goal prior to executing.
# In this case we want to avoid the illegal state transition to EXECUTING
# but still call the users execute callback to let them handle canceling the goal.
if not self.is_cancel_requested:
self._update_state(_rclpy.GoalEvent.EXECUTE)
await self._action_server.notify_execute_async(self, execute_callback)
class ActionServerX(ActionServer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.register_handle_accepted_callback(default_handle_accepted_callback_async)
async def _execute_goal_request(self, request_header_and_message):
request_header, goal_request = request_header_and_message
goal_uuid = goal_request.goal_id
goal_info = GoalInfo()
goal_info.goal_id = goal_uuid
self._node.get_logger().debug('New goal request with ID: {0}'.format(goal_uuid.uuid))
# Check if goal ID is already being tracked by this action server
with self._lock:
goal_id_exists = self._handle.goal_exists(goal_info)
accepted = False
if not goal_id_exists:
# Call user goal callback
response = await await_or_execute(self._goal_callback, goal_request.goal)
if not isinstance(response, GoalResponse):
self._node.get_logger().warning(
'Goal request callback did not return a GoalResponse type. Rejecting goal.')
else:
accepted = GoalResponse.ACCEPT == response
if accepted:
# Stamp time of acceptance
goal_info.stamp = self._node.get_clock().now().to_msg()
# Create a goal handle
try:
with self._lock:
goal_handle = ServerGoalHandleX(self, goal_info, goal_request.goal)
except RuntimeError as e:
self._node.get_logger().error(
'Failed to accept new goal with ID {0}: {1}'.format(goal_uuid.uuid, e))
accepted = False
else:
self._goal_handles[bytes(goal_uuid.uuid)] = goal_handle
# Send response
response_msg = self._action_type.Impl.SendGoalService.Response()
response_msg.accepted = accepted
response_msg.stamp = goal_info.stamp
self._handle.send_goal_response(request_header, response_msg)
if not accepted:
self._node.get_logger().debug('New goal rejected: {0}'.format(goal_uuid.uuid))
return
self._node.get_logger().debug('New goal accepted: {0}'.format(goal_uuid.uuid))
# Provide the user a reference to the goal handle
# await await_or_execute(self._handle_accepted_callback, goal_handle)
asyncio.create_task(self._handle_accepted_callback(goal_handle))
async def notify_execute_async(self, goal_handle, execute_callback):
# Use provided callback, defaulting to a previously registered callback
if execute_callback is None:
if self._execute_callback is None:
return
execute_callback = self._execute_callback
# Schedule user callback for execution
self._node.get_logger().info(f"{events.get_running_loop()}")
asyncio.create_task(self._execute_goal(execute_callback, goal_handle))
# loop = asyncio.new_event_loop()
# asyncio.set_event_loop(loop)
# task = loop.create_task(self._execute_goal(execute_callback, goal_handle))
# await task
class ActionClientX(ActionClient):
feedback_queue = asyncio.Queue()
async def feedback_cb(self, msg):
await self.feedback_queue.put(msg)
async def send_goal_async(self, goal_msg):
goal_future = super().send_goal_async(
goal_msg,
feedback_callback=self.feedback_cb
)
client_goal_handle = await asyncio.ensure_future(goal_future)
if not client_goal_handle.accepted:
raise Exception("Goal rejected.")
result_future = client_goal_handle.get_result_async()
while True:
feedback_future = asyncio.ensure_future(self.feedback_queue.get())
tasks = [result_future, feedback_future]
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
if result_future.done():
result = result_future.result().result
yield (None, result)
break
else:
feedback = feedback_future.result().feedback
yield (feedback, None)
async def main(node):
print('Node started.')
action_client = ActionClientX(node, Fibonacci, 'fibonacci')
goal_msg = Fibonacci.Goal()
goal_msg.order = 10
async for (feedback, result) in action_client.send_goal_async(goal_msg):
if feedback:
print(f'Feedback: {feedback}')
else:
print(f'Result: {result}')
print('Finished.')
async def ros_loop_node(node):
while rclpy.ok():
rclpy.spin_once(node, timeout_sec=0)
await asyncio.sleep(1e-4)
async def ros_loop(executor: Executor):
while rclpy.ok():
executor.spin_once(timeout_sec=0)
await asyncio.sleep(1e-4)
def run_event_loop():
global loop
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_forever()
def run_event_loop_in_thread():
thread = threading.Thread(target=run_event_loop, args=())
thread.start()
if __name__ == "__main__":
rclpy.init()
node = rclpy.create_node('async_subscriber')
future = asyncio.wait([ros_loop(node), main()])
asyncio.get_event_loop().run_until_complete(future)

View File

@@ -9,49 +9,125 @@
"parent": null,
"type": "device",
"class": "bioyond_dispensing_station",
"config": {
"config": {
"api_key": "DE9BDDA0",
"api_host": "http://192.168.1.200:44388",
"material_type_mappings": {
"BIOYOND_PolymerStation_1FlaskCarrier": [
"烧杯",
"3a14196b-24f2-ca49-9081-0cab8021bf1a"
],
"BIOYOND_PolymerStation_1BottleCarrier": [
"试剂瓶",
"3a14196b-8bcf-a460-4f74-23f21ca79e72"
],
"BIOYOND_PolymerStation_6StockCarrier": [
"分装板",
"3a14196e-5dfe-6e21-0c79-fe2036d052c4"
],
"BIOYOND_PolymerStation_Liquid_Vial": [
"10%分装小瓶",
"3a14196c-76be-2279-4e22-7310d69aed68"
],
"BIOYOND_PolymerStation_Solid_Vial": [
"90%分装小瓶",
"3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea"
],
"BIOYOND_PolymerStation_8StockCarrier": [
"样品板",
"3a14196e-b7a0-a5da-1931-35f3000281e9"
],
"BIOYOND_PolymerStation_Solid_Stock": [
"样品瓶",
"3a14196a-cf7d-8aea-48d8-b9662c7dba94"
]
}
},
"deck": {
"data": {
"_resource_child_name": "Bioyond_Dispensing_Deck",
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerPreparationStation_Deck"
}
},
"protocol_type": []
"position": {
"x": 0,
"y": 0,
"z": 0
},
"config": {
"api_key": "YOUR_API_KEY",
"api_host": "http://your-api-host:port",
"material_type_mappings": {
"BIOYOND_PolymerStation_1FlaskCarrier": [
"烧杯",
"uuid-placeholder-flask"
],
"BIOYOND_PolymerStation_1BottleCarrier": [
"试剂瓶",
"uuid-placeholder-bottle"
],
"BIOYOND_PolymerStation_6StockCarrier": [
"分装板",
"uuid-placeholder-stock-6"
],
"BIOYOND_PolymerStation_Liquid_Vial": [
"10%分装小瓶",
"uuid-placeholder-liquid-vial"
],
"BIOYOND_PolymerStation_Solid_Vial": [
"90%分装小瓶",
"uuid-placeholder-solid-vial"
],
"BIOYOND_PolymerStation_8StockCarrier": [
"样品板",
"uuid-placeholder-stock-8"
],
"BIOYOND_PolymerStation_Solid_Stock": [
"样品瓶",
"uuid-placeholder-solid-stock"
]
},
"warehouse_mapping": {
"粉末堆栈": {
"uuid": "uuid-placeholder-powder-stack",
"site_uuids": {
"A01": "uuid-placeholder-powder-A01",
"A02": "uuid-placeholder-powder-A02",
"A03": "uuid-placeholder-powder-A03",
"A04": "uuid-placeholder-powder-A04",
"B01": "uuid-placeholder-powder-B01",
"B02": "uuid-placeholder-powder-B02",
"B03": "uuid-placeholder-powder-B03",
"B04": "uuid-placeholder-powder-B04",
"C01": "uuid-placeholder-powder-C01",
"C02": "uuid-placeholder-powder-C02",
"C03": "uuid-placeholder-powder-C03",
"C04": "uuid-placeholder-powder-C04",
"D01": "uuid-placeholder-powder-D01",
"D02": "uuid-placeholder-powder-D02",
"D03": "uuid-placeholder-powder-D03",
"D04": "uuid-placeholder-powder-D04"
}
},
"溶液堆栈": {
"uuid": "uuid-placeholder-liquid-stack",
"site_uuids": {
"A01": "uuid-placeholder-liquid-A01",
"A02": "uuid-placeholder-liquid-A02",
"A03": "uuid-placeholder-liquid-A03",
"A04": "uuid-placeholder-liquid-A04",
"B01": "uuid-placeholder-liquid-B01",
"B02": "uuid-placeholder-liquid-B02",
"B03": "uuid-placeholder-liquid-B03",
"B04": "uuid-placeholder-liquid-B04",
"C01": "uuid-placeholder-liquid-C01",
"C02": "uuid-placeholder-liquid-C02",
"C03": "uuid-placeholder-liquid-C03",
"C04": "uuid-placeholder-liquid-C04",
"D01": "uuid-placeholder-liquid-D01",
"D02": "uuid-placeholder-liquid-D02",
"D03": "uuid-placeholder-liquid-D03",
"D04": "uuid-placeholder-liquid-D04"
}
},
"试剂堆栈": {
"uuid": "uuid-placeholder-reagent-stack",
"site_uuids": {
"A01": "uuid-placeholder-reagent-A01",
"A02": "uuid-placeholder-reagent-A02",
"A03": "uuid-placeholder-reagent-A03",
"A04": "uuid-placeholder-reagent-A04",
"B01": "uuid-placeholder-reagent-B01",
"B02": "uuid-placeholder-reagent-B02",
"B03": "uuid-placeholder-reagent-B03",
"B04": "uuid-placeholder-reagent-B04"
}
}
},
"http_service_config": {
"http_service_host": "127.0.0.1",
"http_service_port": 8080
},
"material_default_parameters": {
"NMP": {
"unit": "毫升",
"density": "1.03",
"densityUnit": "g/mL",
"description": "N-甲基吡咯烷酮 (N-Methyl-2-pyrrolidone)"
}
},
"material_type_parameters": {}
},
"deck": {
"data": {
"_resource_child_name": "Bioyond_Dispensing_Deck",
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerPreparationStation_Deck"
}
},
"size_x": 2700.0,
"size_y": 1080.0,
"size_z": 1500.0,
"protocol_type": [],
"data": {}
},
{
@@ -80,4 +156,4 @@
"data": {}
}
]
}
}

View File

@@ -1,837 +0,0 @@
{
"nodes": [
{
"id": "PRCXI",
"name": "PRCXI",
"type": "device",
"class": "liquid_handler.prcxi",
"parent": "",
"pose": {
"size": {
"width": 550,
"height": 400,
"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": false,
"setup": true,
"is_9320": true,
"timeout": 10,
"matrix_id": "5de524d0-3f95-406c-86dd-f83626ebc7cb",
"simulator": false,
"step_mode": false,
"channel_num": 2
},
"data": {
"reset_ok": true
},
"schema": {},
"description": "",
"model": null,
"position": {
"x": 0,
"y": 700,
"z": 0
}
},
{
"id": "PRCXI_Deck",
"name": "PRCXI_Deck",
"children": [],
"parent": "PRCXI",
"type": "deck",
"class": "",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"config": {
"type": "PRCXI9300Deck",
"size_x": 550,
"size_y": 400,
"size_z": 17,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"category": "deck",
"barcode": null
},
"data": {}
},
{
"id": "T1",
"name": "T1",
"children": [],
"parent": "PRCXI_Deck",
"type": "plate",
"class": "",
"position": {
"x": 5,
"y": 301,
"z": 0
},
"config": {
"type": "PRCXI9300PlateAdapterSite",
"size_x": 127.5,
"size_y": 86,
"size_z": 28,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"category": "plate",
"model": null,
"barcode": null,
"sites": [
{
"label": "T1",
"visible": true,
"position": { "x": 0, "y": 0, "z": 0 },
"size": { "width": 128.0, "height": 86, "depth": 0 },
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack"
]
}
]
},
"data": {}
},
{
"id": "T2",
"name": "T2",
"children": [],
"parent": "PRCXI_Deck",
"type": "plate",
"class": "",
"position": {
"x": 142.5,
"y": 301,
"z": 0
},
"config": {
"type": "PRCXI9300PlateAdapterSite",
"size_x": 127.5,
"size_y": 86,
"size_z": 28,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"category": "plate",
"model": null,
"barcode": null,
"sites": [
{
"label": "T2",
"visible": true,
"position": { "x": 0, "y": 0, "z": 0 },
"size": { "width": 128.0, "height": 86, "depth": 0 },
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack"
]
}
]
},
"data": {}
},
{
"id": "T3",
"name": "T3",
"children": [],
"parent": "PRCXI_Deck",
"type": "plate",
"class": "",
"position": {
"x": 280,
"y": 301,
"z": 0
},
"config": {
"type": "PRCXI9300PlateAdapterSite",
"size_x": 127.5,
"size_y": 86,
"size_z": 28,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"category": "plate",
"model": null,
"barcode": null,
"sites": [
{
"label": "T3",
"visible": true,
"position": { "x": 0, "y": 0, "z": 0 },
"size": { "width": 128.0, "height": 86, "depth": 0 },
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack"
]
}
]
},
"data": {}
},
{
"id": "T4",
"name": "T4",
"children": [],
"parent": "PRCXI_Deck",
"type": "plate",
"class": "",
"position": {
"x": 417.5,
"y": 301,
"z": 0
},
"config": {
"type": "PRCXI9300PlateAdapterSite",
"size_x": 127.5,
"size_y": 86,
"size_z": 94,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"category": "plate",
"model": null,
"barcode": null,
"sites": [
{
"label": "T4",
"visible": true,
"position": { "x": 0, "y": 0, "z": 0 },
"size": { "width": 128.0, "height": 86, "depth": 0 },
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack"
]
}
]
},
"data": {}
},
{
"id": "T5",
"name": "T5",
"children": [],
"parent": "PRCXI_Deck",
"type": "plate",
"class": "",
"position": {
"x": 5,
"y": 205,
"z": 0
},
"config": {
"type": "PRCXI9300PlateAdapterSite",
"size_x": 127.5,
"size_y": 86,
"size_z": 28,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"category": "plate",
"model": null,
"barcode": null,
"sites": [
{
"label": "T5",
"visible": true,
"position": { "x": 0, "y": 0, "z": 0 },
"size": { "width": 128.0, "height": 86, "depth": 0 },
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack"
]
}
]
},
"data": {}
},
{
"id": "T6",
"name": "T6",
"children": [],
"parent": "PRCXI_Deck",
"type": "plate",
"class": "",
"position": {
"x": 142.5,
"y": 205,
"z": 0
},
"config": {
"type": "PRCXI9300PlateAdapterSite",
"size_x": 127.5,
"size_y": 86,
"size_z": 28,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"category": "plate",
"model": null,
"barcode": null,
"sites": [
{
"label": "T6",
"visible": true,
"position": { "x": 0, "y": 0, "z": 0 },
"size": { "width": 128.0, "height": 86, "depth": 0 },
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack"
]
}
]
},
"data": {}
},
{
"id": "T7",
"name": "T7",
"children": [],
"parent": "PRCXI_Deck",
"type": "plate",
"class": "",
"position": {
"x": 280,
"y": 205,
"z": 0
},
"config": {
"type": "PRCXI9300PlateAdapterSite",
"size_x": 127.5,
"size_y": 86,
"size_z": 28,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"category": "plate",
"model": null,
"barcode": null,
"sites": [
{
"label": "T7",
"visible": true,
"position": { "x": 0, "y": 0, "z": 0 },
"size": { "width": 128.0, "height": 86, "depth": 0 },
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack"
]
}
]
},
"data": {}
},
{
"id": "T8",
"name": "T8",
"children": [],
"parent": "PRCXI_Deck",
"type": "plate",
"class": "",
"position": {
"x": 417.5,
"y": 205,
"z": 0
},
"config": {
"type": "PRCXI9300PlateAdapterSite",
"size_x": 127.5,
"size_y": 86,
"size_z": 28,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"category": "plate",
"model": null,
"barcode": null,
"sites": [
{
"label": "T8",
"visible": true,
"position": { "x": 0, "y": 0, "z": 0 },
"size": { "width": 128.0, "height": 86, "depth": 0 },
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack"
]
}
]
},
"data": {}
},
{
"id": "T9",
"name": "T9",
"children": [],
"parent": "PRCXI_Deck",
"type": "plate",
"class": "",
"position": {
"x": 5,
"y": 109,
"z": 0
},
"config": {
"type": "PRCXI9300PlateAdapterSite",
"size_x": 127.5,
"size_y": 86,
"size_z": 28,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"category": "plate",
"model": null,
"barcode": null,
"sites": [
{
"label": "T9",
"visible": true,
"position": { "x": 0, "y": 0, "z": 0 },
"size": { "width": 128.0, "height": 86, "depth": 0 },
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack"
]
}
]
},
"data": {}
},
{
"id": "T10",
"name": "T10",
"children": [],
"parent": "PRCXI_Deck",
"type": "plate",
"class": "",
"position": {
"x": 142.5,
"y": 109,
"z": 0
},
"config": {
"type": "PRCXI9300PlateAdapterSite",
"size_x": 127.5,
"size_y": 86,
"size_z": 28,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"category": "plate",
"model": null,
"barcode": null,
"sites": [
{
"label": "T10",
"visible": true,
"position": { "x": 0, "y": 0, "z": 0 },
"size": { "width": 128.0, "height": 86, "depth": 0 },
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack"
]
}
]
},
"data": {}
},
{
"id": "T11",
"name": "T11",
"children": [],
"parent": "PRCXI_Deck",
"type": "plate",
"class": "",
"position": {
"x": 280,
"y": 109,
"z": 0
},
"config": {
"type": "PRCXI9300PlateAdapterSite",
"size_x": 127.5,
"size_y": 86,
"size_z": 28,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"category": "plate",
"model": null,
"barcode": null,
"sites": [
{
"label": "T11",
"visible": true,
"position": { "x": 0, "y": 0, "z": 0 },
"size": { "width": 128.0, "height": 86, "depth": 0 },
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack"
]
}
]
},
"data": {}
},
{
"id": "T12",
"name": "T12",
"children": [],
"parent": "PRCXI_Deck",
"type": "plate",
"class": "",
"position": {
"x": 417.5,
"y": 109,
"z": 0
},
"config": {
"type": "PRCXI9300PlateAdapterSite",
"size_x": 127.5,
"size_y": 86,
"size_z": 28,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"category": "plate",
"model": null,
"barcode": null,
"sites": [
{
"label": "T12",
"visible": true,
"position": { "x": 0, "y": 0, "z": 0 },
"size": { "width": 128.0, "height": 86, "depth": 0 },
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack"
]
}
]
},
"data": {}
},
{
"id": "T13",
"name": "T13",
"children": [],
"parent": "PRCXI_Deck",
"type": "plate",
"class": "",
"position": {
"x": 5,
"y": 13,
"z": 0
},
"config": {
"type": "PRCXI9300PlateAdapterSite",
"size_x": 127.5,
"size_y": 86,
"size_z": 28,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"category": "plate",
"model": null,
"barcode": null,
"sites": [
{
"label": "T13",
"visible": true,
"position": { "x": 0, "y": 0, "z": 0 },
"size": { "width": 128.0, "height": 86, "depth": 0 },
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack"
]
}
]
},
"data": {}
},
{
"id": "T14",
"name": "T14",
"children": [],
"parent": "PRCXI_Deck",
"type": "plate",
"class": "",
"position": {
"x": 142.5,
"y": 13,
"z": 0
},
"config": {
"type": "PRCXI9300PlateAdapterSite",
"size_x": 127.5,
"size_y": 86,
"size_z": 28,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"category": "plate",
"model": null,
"barcode": null,
"sites": [
{
"label": "T14",
"visible": true,
"position": { "x": 0, "y": 0, "z": 0 },
"size": { "width": 128.0, "height": 86, "depth": 0 },
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack"
]
}
]
},
"data": {}
},
{
"id": "T15",
"name": "T15",
"children": [],
"parent": "PRCXI_Deck",
"type": "plate",
"class": "",
"position": {
"x": 280,
"y": 13,
"z": 0
},
"config": {
"type": "PRCXI9300PlateAdapterSite",
"size_x": 127.5,
"size_y": 86,
"size_z": 28,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"category": "plate",
"model": null,
"barcode": null,
"sites": [
{
"label": "T15",
"visible": true,
"position": { "x": 0, "y": 0, "z": 0 },
"size": { "width": 128.0, "height": 86, "depth": 0 },
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack"
]
}
]
},
"data": {}
},
{
"id": "T16",
"name": "T16",
"children": [],
"parent": "PRCXI_Deck",
"type": "plate",
"class": "",
"position": {
"x": 417.5,
"y": 13,
"z": 0
},
"config": {
"type": "PRCXI9300PlateAdapterSite",
"size_x": 127.5,
"size_y": 86,
"size_z": 28,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"category": "plate",
"model": null,
"barcode": null,
"sites": [
{
"label": "T16",
"visible": true,
"position": { "x": 0, "y": 0, "z": 0 },
"size": { "width": 128.0, "height": 86, "depth": 0 },
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack"
]
}
]
},
"data": {}
},
{
"id": "trash",
"name": "trash",
"children": [],
"parent": "T16",
"type": "trash",
"class": "",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"config": {
"type": "PRCXI9300Trash",
"size_x": 127.5,
"size_y": 86,
"size_z": 10,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"category": "trash",
"model": null,
"barcode": null,
"max_volume": "Infinity",
"material_z_thickness": 0,
"compute_volume_from_height": null,
"compute_height_from_volume": null
},
"data": {
"liquids": [],
"pending_liquids": [],
"liquid_history": [],
"Material": {
"uuid": "730067cf07ae43849ddf4034299030e9"
}
}
}
],
"edges": []
}

View File

@@ -74,7 +74,7 @@
"occupied_by": null,
"position": {
"x": 0,
"y": 288,
"y": 0,
"z": 0
},
"size": {
@@ -89,9 +89,7 @@
"plates",
"tip_racks",
"tube_rack",
"adaptor",
"plateadapter",
"module"
"adaptor"
]
},
{
@@ -100,7 +98,7 @@
"occupied_by": null,
"position": {
"x": 138,
"y": 288,
"y": 0,
"z": 0
},
"size": {
@@ -114,9 +112,7 @@
"plates",
"tip_racks",
"tube_rack",
"adaptor",
"plateadapter",
"module"
"adaptor"
]
},
{
@@ -125,7 +121,7 @@
"occupied_by": null,
"position": {
"x": 276,
"y": 288,
"y": 0,
"z": 0
},
"size": {
@@ -139,9 +135,7 @@
"plates",
"tip_racks",
"tube_rack",
"adaptor",
"plateadapter",
"module"
"adaptor"
]
},
{
@@ -150,6 +144,213 @@
"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
},
@@ -164,234 +365,7 @@
"plates",
"tip_racks",
"tube_rack",
"adaptor",
"plateadapter",
"module"
]
},
{
"label": "T5",
"visible": true,
"occupied_by": null,
"position": {
"x": 0,
"y": 192,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor",
"plateadapter",
"module"
]
},
{
"label": "T6",
"visible": true,
"occupied_by": null,
"position": {
"x": 138,
"y": 192,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor",
"plateadapter",
"module"
]
},
{
"label": "T7",
"visible": true,
"occupied_by": null,
"position": {
"x": 276,
"y": 192,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor",
"plateadapter",
"module"
]
},
{
"label": "T8",
"visible": true,
"occupied_by": null,
"position": {
"x": 414,
"y": 192,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor",
"plateadapter",
"module"
]
},
{
"label": "T9",
"visible": true,
"occupied_by": null,
"position": {
"x": 0,
"y": 96,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor",
"plateadapter",
"module"
]
},
{
"label": "T10",
"visible": true,
"occupied_by": null,
"position": {
"x": 138,
"y": 96,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor",
"plateadapter",
"module"
]
},
{
"label": "T11",
"visible": true,
"occupied_by": null,
"position": {
"x": 276,
"y": 96,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor",
"plateadapter",
"module"
]
},
{
"label": "T12",
"visible": true,
"occupied_by": null,
"position": {
"x": 414,
"y": 96,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor",
"plateadapter",
"module"
]
},
{
"label": "T13",
"visible": true,
"occupied_by": null,
"position": {
"x": 0,
"y": 0,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor",
"plateadapter",
"module"
"adaptor"
]
},
{
@@ -400,7 +374,7 @@
"occupied_by": null,
"position": {
"x": 138,
"y": 0,
"y": 288,
"z": 0
},
"size": {
@@ -414,9 +388,7 @@
"plates",
"tip_racks",
"tube_rack",
"adaptor",
"plateadapter",
"module"
"adaptor"
]
},
{
@@ -425,7 +397,7 @@
"occupied_by": null,
"position": {
"x": 276,
"y": 0,
"y": 288,
"z": 0
},
"size": {
@@ -439,9 +411,7 @@
"plates",
"tip_racks",
"tube_rack",
"adaptor",
"plateadapter",
"module"
"adaptor"
]
},
{
@@ -450,7 +420,7 @@
"occupied_by": null,
"position": {
"x": 414,
"y": 0,
"y": 288,
"z": 0
},
"size": {
@@ -464,9 +434,7 @@
"plates",
"tip_racks",
"tube_rack",
"adaptor",
"plateadapter",
"module"
"adaptor"
]
}
]

File diff suppressed because it is too large Load Diff

View File

@@ -14,60 +14,200 @@
],
"type": "device",
"class": "reaction_station.bioyond",
"position": {"x": 0, "y": 3800, "z": 0},
"config": {
"config": {
"api_key": "DE9BDDA0",
"api_host": "http://192.168.1.200:44402",
"workflow_mappings": {
"reactor_taken_out": "3a16081e-4788-ca37-eff4-ceed8d7019d1",
"reactor_taken_in": "3a160df6-76b3-0957-9eb0-cb496d5721c6",
"Solid_feeding_vials": "3a160877-87e7-7699-7bc6-ec72b05eb5e6",
"Liquid_feeding_vials(non-titration)": "3a167d99-6158-c6f0-15b5-eb030f7d8e47",
"Liquid_feeding_solvents": "3a160824-0665-01ed-285a-51ef817a9046",
"Liquid_feeding(titration)": "3a16082a-96ac-0449-446a-4ed39f3365b6",
"liquid_feeding_beaker": "3a16087e-124f-8ddb-8ec1-c2dff09ca784",
"Drip_back": "3a162cf9-6aac-565a-ddd7-682ba1796a4a"
},
"material_type_mappings": {
"BIOYOND_PolymerStation_Reactor": [
"反应器",
"3a14233b-902d-0d7b-4533-3f60f1c41c1b"
],
"BIOYOND_PolymerStation_1BottleCarrier": [
"试剂瓶",
"3a14233b-56e3-6c53-a8ab-fcaac163a9ba"
],
"BIOYOND_PolymerStation_1FlaskCarrier": [
"烧杯",
"3a14233b-f0a9-ba84-eaa9-0d4718b361b6"
],
"BIOYOND_PolymerStation_6StockCarrier": [
"样品板",
"3a142339-80de-8f25-6093-1b1b1b6c322e"
],
"BIOYOND_PolymerStation_Solid_Vial": [
"90%分装小瓶",
"3a14233a-26e1-28f8-af6a-60ca06ba0165"
],
"BIOYOND_PolymerStation_Liquid_Vial": [
"10%分装小瓶",
"3a14233a-84a3-088d-6676-7cb4acd57c64"
],
"BIOYOND_PolymerStation_TipBox": [
"枪头盒",
"3a143890-9d51-60ac-6d6f-6edb43c12041"
]
}
},
"deck": {
"data": {
"_resource_child_name": "Bioyond_Deck",
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerReactionStation_Deck"
}
},
"protocol_type": []
"position": {
"x": 0,
"y": 1100,
"z": 0
},
"config": {
"api_key": "YOUR_API_KEY",
"api_host": "http://your-api-host:port",
"workflow_mappings": {
"reactor_taken_out": "workflow-uuid-reactor-out",
"reactor_taken_in": "workflow-uuid-reactor-in",
"Solid_feeding_vials": "workflow-uuid-solid-vials",
"Liquid_feeding_vials(non-titration)": "workflow-uuid-liquid-vials",
"Liquid_feeding_solvents": "workflow-uuid-solvents",
"Liquid_feeding(titration)": "workflow-uuid-titration",
"liquid_feeding_beaker": "workflow-uuid-beaker",
"Drip_back": "workflow-uuid-drip-back"
},
"material_type_mappings": {
"BIOYOND_PolymerStation_Reactor": [
"反应器",
"uuid-placeholder-reactor"
],
"BIOYOND_PolymerStation_1BottleCarrier": [
"试剂瓶",
"uuid-placeholder-bottle"
],
"BIOYOND_PolymerStation_1FlaskCarrier": [
"烧杯",
"uuid-placeholder-beaker"
],
"BIOYOND_PolymerStation_6StockCarrier": [
"样品板",
"uuid-placeholder-sample-plate"
],
"BIOYOND_PolymerStation_Solid_Vial": [
"90%分装小瓶",
"uuid-placeholder-solid-vial"
],
"BIOYOND_PolymerStation_Liquid_Vial": [
"10%分装小瓶",
"uuid-placeholder-liquid-vial"
],
"BIOYOND_PolymerStation_TipBox": [
"枪头盒",
"uuid-placeholder-tipbox"
],
"BIOYOND_PolymerStation_Measurement_Vial": [
"测量小瓶",
"uuid-placeholder-measure-vial"
]
},
"warehouse_mapping": {
"堆栈1左": {
"uuid": "uuid-placeholder-stack1-left",
"site_uuids": {
"A01": "uuid-placeholder-site-A01",
"A02": "uuid-placeholder-site-A02",
"A03": "uuid-placeholder-site-A03",
"A04": "uuid-placeholder-site-A04",
"B01": "uuid-placeholder-site-B01",
"B02": "uuid-placeholder-site-B02",
"B03": "uuid-placeholder-site-B03",
"B04": "uuid-placeholder-site-B04",
"C01": "uuid-placeholder-site-C01",
"C02": "uuid-placeholder-site-C02",
"C03": "uuid-placeholder-site-C03",
"C04": "uuid-placeholder-site-C04",
"D01": "uuid-placeholder-site-D01",
"D02": "uuid-placeholder-site-D02",
"D03": "uuid-placeholder-site-D03",
"D04": "uuid-placeholder-site-D04"
}
},
"堆栈1右": {
"uuid": "uuid-placeholder-stack1-right",
"site_uuids": {
"A05": "uuid-placeholder-site-A05",
"A06": "uuid-placeholder-site-A06",
"A07": "uuid-placeholder-site-A07",
"A08": "uuid-placeholder-site-A08",
"B05": "uuid-placeholder-site-B05",
"B06": "uuid-placeholder-site-B06",
"B07": "uuid-placeholder-site-B07",
"B08": "uuid-placeholder-site-B08",
"C05": "uuid-placeholder-site-C05",
"C06": "uuid-placeholder-site-C06",
"C07": "uuid-placeholder-site-C07",
"C08": "uuid-placeholder-site-C08",
"D05": "uuid-placeholder-site-D05",
"D06": "uuid-placeholder-site-D06",
"D07": "uuid-placeholder-site-D07",
"D08": "uuid-placeholder-site-D08"
}
},
"站内试剂存放堆栈": {
"uuid": "uuid-placeholder-reagent-stack",
"site_uuids": {
"A01": "uuid-placeholder-reagent-A01",
"A02": "uuid-placeholder-reagent-A02"
}
},
"测量小瓶仓库(测密度)": {
"uuid": "uuid-placeholder-density-stack",
"site_uuids": {
"A01": "uuid-placeholder-density-A01",
"A02": "uuid-placeholder-density-A02",
"A03": "uuid-placeholder-density-A03",
"B01": "uuid-placeholder-density-B01",
"B02": "uuid-placeholder-density-B02",
"B03": "uuid-placeholder-density-B03"
}
},
"站内Tip盒堆栈(左)": {
"uuid": "uuid-placeholder-tipstack-left",
"site_uuids": {
"A02": "uuid-placeholder-tip-A02",
"A03": "uuid-placeholder-tip-A03",
"B02": "uuid-placeholder-tip-B02",
"B03": "uuid-placeholder-tip-B03"
}
},
"站内Tip盒堆栈(右)": {
"uuid": "uuid-placeholder-tipstack-right",
"site_uuids": {
"A01": "uuid-placeholder-tip-A01",
"B01": "uuid-placeholder-tip-B01"
}
}
},
"workflow_to_section_map": {
"reactor_taken_in": "反应器放入",
"reactor_taken_out": "反应器取出",
"Solid_feeding_vials": "固体投料-小瓶",
"Liquid_feeding_vials(non-titration)": "液体投料-小瓶(非滴定)",
"Liquid_feeding_solvents": "液体投料-溶剂",
"Liquid_feeding(titration)": "液体投料-滴定",
"liquid_feeding_beaker": "液体投料-烧杯",
"Drip_back": "液体回滴"
},
"action_names": {
"reactor_taken_in": {
"config": "通量-配置",
"stirring": "反应模块-开始搅拌"
},
"solid_feeding_vials": {
"feeding": "粉末加样模块-投料",
"observe": "反应模块-观察搅拌结果"
},
"liquid_feeding_vials_non_titration": {
"liquid": "稀释液瓶加液位-液体投料",
"observe": "反应模块-滴定结果观察"
},
"liquid_feeding_solvents": {
"liquid": "试剂AB放置位-试剂吸液分液",
"observe": "反应模块-观察搅拌结果"
},
"liquid_feeding_titration": {
"liquid": "稀释液瓶加液位-稀释液吸液分液",
"observe": "反应模块-滴定结果观察"
},
"liquid_feeding_beaker": {
"liquid": "烧杯溶液放置位-烧杯吸液分液",
"observe": "反应模块-观察搅拌结果"
},
"drip_back": {
"liquid": "试剂AB放置位-试剂吸液分液",
"observe": "反应模块-向下滴定结果观察"
}
},
"http_service_config": {
"http_service_host": "127.0.0.1",
"http_service_port": 8080
},
"material_default_parameters": {
"NMP": {
"unit": "毫升",
"density": "1.03",
"densityUnit": "g/mL",
"description": "N-甲基吡咯烷酮 (N-Methyl-2-pyrrolidone)"
}
},
"material_type_parameters": {}
},
"deck": {
"data": {
"_resource_child_name": "Bioyond_Deck",
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_PolymerReactionStation_Deck"
}
},
"size_x": 2700.0,
"size_y": 1080.0,
"size_z": 2500.0,
"protocol_type": [],
"data": {}
},
{
@@ -77,7 +217,11 @@
"parent": "reaction_station_bioyond",
"type": "device",
"class": "reaction_station.reactor",
"position": {"x": 1150, "y": 380, "z": 0},
"position": {
"x": 1150,
"y": 300,
"z": 0
},
"config": {},
"data": {}
},
@@ -88,7 +232,11 @@
"parent": "reaction_station_bioyond",
"type": "device",
"class": "reaction_station.reactor",
"position": {"x": 1365, "y": 380, "z": 0},
"position": {
"x": 1365,
"y": 300,
"z": 0
},
"config": {},
"data": {}
},
@@ -99,7 +247,11 @@
"parent": "reaction_station_bioyond",
"type": "device",
"class": "reaction_station.reactor",
"position": {"x": 1580, "y": 380, "z": 0},
"position": {
"x": 1580,
"y": 300,
"z": 0
},
"config": {},
"data": {}
},
@@ -110,7 +262,11 @@
"parent": "reaction_station_bioyond",
"type": "device",
"class": "reaction_station.reactor",
"position": {"x": 1790, "y": 380, "z": 0},
"position": {
"x": 1790,
"y": 300,
"z": 0
},
"config": {},
"data": {}
},
@@ -121,7 +277,11 @@
"parent": "reaction_station_bioyond",
"type": "device",
"class": "reaction_station.reactor",
"position": {"x": 2010, "y": 380, "z": 0},
"position": {
"x": 2010,
"y": 300,
"z": 0
},
"config": {},
"data": {}
},
@@ -134,7 +294,7 @@
"class": "BIOYOND_PolymerReactionStation_Deck",
"position": {
"x": 0,
"y": 0,
"y": 1100,
"z": 0
},
"config": {
@@ -150,4 +310,4 @@
"data": {}
}
]
}
}

View File

@@ -0,0 +1,28 @@
{
"nodes": [
{
"id": "workbench_1",
"name": "虚拟工作台",
"children": [],
"parent": null,
"type": "device",
"class": "virtual_workbench",
"position": {
"x": 400,
"y": 300,
"z": 0
},
"config": {
"arm_operation_time": 3.0,
"heating_time": 10.0,
"num_heating_stations": 3
},
"data": {
"status": "Ready",
"arm_state": "idle",
"message": "工作台就绪"
}
}
],
"links": []
}

View File

@@ -0,0 +1,126 @@
{
"nodes": [
{
"id": "bioyond_cell_workstation",
"name": "配液分液工站 (示例)",
"parent": null,
"children": [
"YB_Bioyond_Deck"
],
"type": "device",
"class": "bioyond_cell",
"config": {
"deck": {
"data": {
"_resource_child_name": "YB_Bioyond_Deck",
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_YB_Deck"
}
},
"protocol_type": [],
"bioyond_config": {
"api_host": "http://YOUR_API_HOST:PORT",
"api_key": "YOUR_API_KEY",
"timeout": 30,
"report_token": "YOUR_REPORT_TOKEN",
"HTTP_host": "YOUR_LOCAL_IP",
"HTTP_port": 8080,
"debug_mode": false,
"material_type_mappings": {
"100ml液体": [
"YB_100ml_yeti",
"00000000-0000-0000-0000-000000000000"
],
"液": [
"YB_ye",
"00000000-0000-0000-0000-000000000000"
],
"高粘液": [
"YB_gaonianye",
"00000000-0000-0000-0000-000000000000"
],
"加样头(大)": [
"YB_jia_yang_tou_da_Carrier",
"00000000-0000-0000-0000-000000000000"
],
"5ml分液瓶板": [
"YB_5ml_fenyepingban",
"00000000-0000-0000-0000-000000000000"
],
"5ml分液瓶": [
"YB_5ml_fenyeping",
"00000000-0000-0000-0000-000000000000"
],
"20ml分液瓶板": [
"YB_20ml_fenyepingban",
"00000000-0000-0000-0000-000000000000"
],
"20ml分液瓶": [
"YB_20ml_fenyeping",
"00000000-0000-0000-0000-000000000000"
],
"配液瓶(小)板": [
"YB_peiyepingxiaoban",
"00000000-0000-0000-0000-000000000000"
],
"配液瓶(小)": [
"YB_pei_ye_xiao_Bottle",
"00000000-0000-0000-0000-000000000000"
],
"枪头盒": [
"YB_qiang_tou_he",
"00000000-0000-0000-0000-000000000000"
]
},
"warehouse_mapping": {
"示例堆栈": {
"uuid": "00000000-0000-0000-0000-000000000000",
"site_uuids": {
"A01": "00000000-0000-0000-0000-000000000000",
"B01": "00000000-0000-0000-0000-000000000000"
}
}
},
"solid_liquid_mappings": {
"示例物料": {
"typeId": "00000000-0000-0000-0000-000000000000",
"code": "",
"barCode": "",
"name": "Example_Material",
"unit": "g",
"parameters": "",
"quantity": "2",
"warningQuantity": "1",
"details": []
}
}
}
},
"data": {}
},
{
"id": "YB_Bioyond_Deck",
"name": "YB_Bioyond_Deck",
"children": [],
"parent": "bioyond_cell_workstation",
"type": "deck",
"class": "BIOYOND_YB_Deck",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"config": {
"type": "BIOYOND_YB_Deck",
"setup": true,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
}
},
"data": {}
}
],
"links": []
}

View File

@@ -1,7 +1,11 @@
import psutil
import pywinauto
from pywinauto_recorder import UIApplication
from pywinauto_recorder.player import UIPath, click, focus_on_application, exists, find, get_wrapper_path
try:
from pywinauto_recorder import UIApplication
from pywinauto_recorder.player import UIPath, click, focus_on_application, exists, find, get_wrapper_path
except ImportError:
print("未安装pywinauto_recorder部分功能无法使用安装时注意enum")
pass
from pywinauto.controls.uiawrapper import UIAWrapper
from pywinauto.application import WindowSpecification
from pywinauto import findbestmatch

View File

@@ -80,12 +80,11 @@ def get_result_info_str(error: str, suc: bool, return_value=None) -> str:
Returns:
JSON字符串格式的结果信息
"""
# 请在返回的字典中使用 unilabos_samples进行返回
# samples = None
# if isinstance(return_value, dict):
# if "samples" in return_value and type(return_value["samples"]) in [list, tuple] and type(return_value["samples"][0]) == dict:
# samples = return_value.pop("samples")
result_info = {"error": error, "suc": suc, "return_value": return_value}
samples = None
if isinstance(return_value, dict):
if "samples" in return_value and type(return_value["samples"]) in [list, tuple] and type(return_value["samples"][0]) == dict:
samples = return_value.pop("samples")
result_info = {"error": error, "suc": suc, "return_value": return_value, "samples": samples}
return json.dumps(result_info, ensure_ascii=False, cls=ResultInfoEncoder)