Compare commits

..

4 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
133 changed files with 19811 additions and 44688 deletions

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

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

View File

@@ -1,371 +0,0 @@
# 工作站高级模式参考
本文件是 SKILL.md 的补充,包含外部系统集成、物料同步、配置结构等高级模式。
Agent 在需要实现这些功能时按需阅读。
---
## 1. 外部系统集成模式
### 1.1 RPC 客户端
与外部 LIMS/MES 系统通信的标准模式。继承 `BaseRequest`,所有接口统一用 POST。
```python
from unilabos.device_comms.rpc import BaseRequest
class MySystemRPC(BaseRequest):
"""外部系统 RPC 客户端"""
def __init__(self, host: str, api_key: str):
super().__init__(host)
self.api_key = api_key
def _request(self, endpoint: str, data: dict = None) -> dict:
return self.post(
url=f"{self.host}/api/{endpoint}",
params={
"apiKey": self.api_key,
"requestTime": self.get_current_time_iso8601(),
"data": data or {},
},
)
def query_status(self) -> dict:
return self._request("status/query")
def create_order(self, order_data: dict) -> dict:
return self._request("order/create", order_data)
```
参考:`unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py``BioyondV1RPC`
### 1.2 HTTP 回调服务
接收外部系统报送的标准模式。使用 `WorkstationHTTPService`,在 `post_init` 中启动。
```python
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
class MyWorkstation(WorkstationBase):
def __init__(self, config=None, deck=None, **kwargs):
super().__init__(deck=deck, **kwargs)
self.config = config or {}
http_cfg = self.config.get("http_service_config", {})
self._http_service_config = {
"host": http_cfg.get("http_service_host", "127.0.0.1"),
"port": http_cfg.get("http_service_port", 8080),
}
self.http_service = None
def post_init(self, ros_node):
super().post_init(ros_node)
self.http_service = WorkstationHTTPService(
workstation_instance=self,
host=self._http_service_config["host"],
port=self._http_service_config["port"],
)
self.http_service.start()
```
**HTTP 服务路由**(固定端点,由 `WorkstationHTTPHandler` 自动分发):
| 端点 | 调用的工作站方法 |
|------|-----------------|
| `/report/step_finish` | `process_step_finish_report(report_request)` |
| `/report/sample_finish` | `process_sample_finish_report(report_request)` |
| `/report/order_finish` | `process_order_finish_report(report_request, used_materials)` |
| `/report/material_change` | `process_material_change_report(report_data)` |
| `/report/error_handling` | `handle_external_error(error_data)` |
实现对应方法即可接收回调:
```python
def process_step_finish_report(self, report_request) -> Dict[str, Any]:
"""处理步骤完成报告"""
step_name = report_request.data.get("stepName")
return {"success": True, "message": f"步骤 {step_name} 已处理"}
def process_order_finish_report(self, report_request, used_materials) -> Dict[str, Any]:
"""处理订单完成报告"""
order_code = report_request.data.get("orderCode")
return {"success": True}
```
参考:`unilabos/devices/workstation/workstation_http_service.py`
### 1.3 连接监控
独立线程周期性检测外部系统连接状态,状态变化时发布 ROS 事件。
```python
class ConnectionMonitor:
def __init__(self, workstation, check_interval=30):
self.workstation = workstation
self.check_interval = check_interval
self._running = False
self._thread = None
def start(self):
self._running = True
self._thread = threading.Thread(target=self._monitor_loop, daemon=True)
self._thread.start()
def _monitor_loop(self):
while self._running:
try:
# 调用外部系统接口检测连接
self.workstation.hardware_interface.ping()
status = "online"
except Exception:
status = "offline"
time.sleep(self.check_interval)
```
参考:`unilabos/devices/workstation/bioyond_studio/station.py``ConnectionMonitor`
---
## 2. Config 结构模式
工作站的 `config` 在图文件中定义,传入 `__init__`。以下是常见字段模式:
### 2.1 外部系统连接
```json
{
"api_host": "http://192.168.1.100:8080",
"api_key": "YOUR_API_KEY"
}
```
### 2.2 HTTP 回调服务
```json
{
"http_service_config": {
"http_service_host": "127.0.0.1",
"http_service_port": 8080
}
}
```
### 2.3 物料类型映射
将 PLR 资源类名映射到外部系统的物料类型(名称 + UUID。用于双向物料转换。
```json
{
"material_type_mappings": {
"PLR_ResourceClassName": ["外部系统显示名", "external-type-uuid"],
"BIOYOND_PolymerStation_Reactor": ["反应器", "3a14233b-902d-0d7b-..."]
}
}
```
### 2.4 仓库映射
将仓库名映射到外部系统的仓库 UUID 和库位 UUID。用于入库/出库操作。
```json
{
"warehouse_mapping": {
"仓库名": {
"uuid": "warehouse-uuid",
"site_uuids": {
"A01": "site-uuid-A01",
"A02": "site-uuid-A02"
}
}
}
}
```
### 2.5 工作流映射
将内部工作流名映射到外部系统的工作流 ID。
```json
{
"workflow_mappings": {
"internal_workflow_name": "external-workflow-uuid"
}
}
```
### 2.6 物料默认参数
```json
{
"material_default_parameters": {
"NMP": {
"unit": "毫升",
"density": "1.03",
"densityUnit": "g/mL",
"description": "N-甲基吡咯烷酮"
}
}
}
```
---
## 3. 资源同步机制
### 3.1 ResourceSynchronizer
抽象基类,用于与外部物料系统双向同步。定义在 `workstation_base.py`
```python
from unilabos.devices.workstation.workstation_base import ResourceSynchronizer
class MyResourceSynchronizer(ResourceSynchronizer):
def __init__(self, workstation, api_client):
super().__init__(workstation)
self.api_client = api_client
def sync_from_external(self) -> bool:
"""从外部系统拉取物料到 deck"""
external_materials = self.api_client.list_materials()
for material in external_materials:
plr_resource = self._convert_to_plr(material)
self.workstation.deck.assign_child_resource(plr_resource, coordinate)
return True
def sync_to_external(self, plr_resource) -> bool:
"""将 deck 中的物料变更推送到外部系统"""
external_data = self._convert_from_plr(plr_resource)
self.api_client.update_material(external_data)
return True
def handle_external_change(self, change_info) -> bool:
"""处理外部系统推送的物料变更"""
return True
```
### 3.2 update_resource — 上传资源树到云端
将 PLR Deck 序列化后通过 ROS 服务上传。典型使用场景:
```python
# 在 post_init 中上传初始 deck
from unilabos.ros.nodes.base_device_node import ROS2DeviceNode
ROS2DeviceNode.run_async_func(
self._ros_node.update_resource, True,
**{"resources": [self.deck]}
)
# 在动作方法中更新特定资源
ROS2DeviceNode.run_async_func(
self._ros_node.update_resource, True,
**{"resources": [updated_plate]}
)
```
---
## 4. 工作流序列管理
工作站通过 `workflow_sequence` 属性管理任务队列JSON 字符串形式)。
```python
class MyWorkstation(WorkstationBase):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._workflow_sequence = []
@property
def workflow_sequence(self) -> str:
"""返回 JSON 字符串ROS 自动发布"""
import json
return json.dumps(self._workflow_sequence)
async def append_to_workflow_sequence(self, workflow_name: str) -> Dict[str, Any]:
"""添加工作流到队列"""
self._workflow_sequence.append({
"name": workflow_name,
"status": "pending",
"created_at": time.time(),
})
return {"success": True}
async def clear_workflows(self) -> Dict[str, Any]:
"""清空工作流队列"""
self._workflow_sequence = []
return {"success": True}
```
---
## 5. 站间物料转移
工作站之间转移物料的模式。通过 ROS ActionClient 调用目标站的动作。
```python
async def transfer_materials_to_another_station(
self,
target_device_id: str,
transfer_groups: list,
**kwargs,
) -> Dict[str, Any]:
"""将物料转移到另一个工作站"""
target_node = self._children.get(target_device_id)
if not target_node:
# 通过 ROS 节点查找非子设备的目标站
pass
for group in transfer_groups:
resource = self.find_resource_by_name(group["resource_name"])
# 从本站 deck 移除
resource.unassign()
# 调用目标站的接收方法
# ...
return {"success": True, "transferred": len(transfer_groups)}
```
参考:`BioyondDispensingStation.transfer_materials_to_reaction_station`
---
## 6. post_init 完整模式
`post_init` 是工作站初始化的关键阶段,此时 ROS 节点和子设备已就绪。
```python
def post_init(self, ros_node):
super().post_init(ros_node)
# 1. 初始化外部系统客户端(此时 config 已可用)
self.rpc_client = MySystemRPC(
host=self.config.get("api_host"),
api_key=self.config.get("api_key"),
)
self.hardware_interface = self.rpc_client
# 2. 启动连接监控
self.connection_monitor = ConnectionMonitor(self)
self.connection_monitor.start()
# 3. 启动 HTTP 回调服务
if hasattr(self, '_http_service_config'):
self.http_service = WorkstationHTTPService(
workstation_instance=self,
host=self._http_service_config["host"],
port=self._http_service_config["port"],
)
self.http_service.start()
# 4. 上传 deck 到云端
ROS2DeviceNode.run_async_func(
self._ros_node.update_resource, True,
**{"resources": [self.deck]}
)
# 5. 初始化资源同步器(可选)
self.resource_synchronizer = MyResourceSynchronizer(self, self.rpc_client)
```

View File

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

View File

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

View File

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

View File

@@ -158,13 +158,12 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
- `unilabos_devices`**DeviceSlot**,填入路径字符串如 `"/host_node"`(从资源树筛选 type=device
- `unilabos_nodes`**NodeSlot**,填入路径字符串如 `"/PRCXI/PRCXI_Deck"`(资源树中任意节点)
- `unilabos_class`**ClassSlot**,填入类名字符串如 `"container"`(从注册表查找)
- `unilabos_formulation`**FormulationSlot**,填入配方数组 `[{well_name, liquids: [{name, volume}]}]`well_name 为目标物料的 name
- array 类型字段 → `[{id, name, uuid}, ...]`
- 特殊:`create_resource``res_id`ResourceSlot可填不存在的路径
### Step 4 — 写 SKILL.md
直接复用 `unilab-device-api` 的 API 模板,修改:
直接复用 `unilab-device-api` 的 API 模板10 个 endpoint,修改:
- 设备名称
- Action 数量
- 目录列表
@@ -182,37 +181,21 @@ API 模板结构:
## 前置条件(缺一不可)
- ak/sk → AUTH, --addr → BASE URL
## 请求约定
- Windows 平台必须用 curl.exe非 PowerShell 的 curl 别名)
## Session State
- lab_uuid通过 GET /edge/lab/info 直接获取,不要问用户), device_name
- lab_uuid通过 API #1 自动匹配,不要问用户), device_name
## API Endpoints
# - #1 GET /edge/lab/info → 直接拿到 lab_uuid
# - #2 创建工作流 POST /lab/workflow/owner → 拼 URL 告知用户
# - #3 创建节点 POST /edge/workflow/node
# body: {workflow_uuid, resource_template_name: "<device_id>", node_template_name: "<action_name>"}
# - #4 删除节点 DELETE /lab/workflow/nodes
# - #5 更新节点参数 PATCH /lab/workflow/node
# - #6 查询节点 handles POST /lab/workflow/node-handles
# body: {node_uuids: ["uuid1","uuid2"]} → 返回各节点的 handle_uuid
# - #7 批量创建边 POST /lab/workflow/edges
# body: {edges: [{source_node_uuid, target_node_uuid, source_handle_uuid, target_handle_uuid}]}
# - #8 启动工作流 POST /lab/workflow/{uuid}/run
# - #9 运行设备单动作 POST /lab/mcp/run/action
# - #10 查询任务状态 GET /lab/mcp/task/{task_uuid}
# - #11 运行工作流单节点 POST /lab/mcp/run/workflow/action
# - #12 获取资源树 GET /lab/material/download/{lab_uuid}
# - #13 获取工作流模板详情 GET /lab/workflow/template/detail/{workflow_uuid}
# 返回 workflow 完整结构data.nodes[] 含每个节点的 uuid、name、param、device_name、handles
## 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" 字符串
- unilabos_formulation → FormulationSlot → [{well_name, liquids: [{name, volume}]}] 配方数组
- 特例create_resource 的 res_id 允许填不存在的路径
- 列出本设备所有 Slot 字段、类型及含义
@@ -223,8 +206,8 @@ API 模板结构:
### Step 5 — 验证
检查文件完整性:
- [ ] `SKILL.md` 包含 API endpoint#1 获取 lab_uuid、#2-#7 工作流/节点/边、#8-#11 运行/查询、#12 资源树、#13 工作流模板详情)
- [ ] `SKILL.md` 包含 Placeholder Slot 填写规则ResourceSlot / DeviceSlot / NodeSlot / ClassSlot / FormulationSlot + create_resource 特例)和本设备的 Slot 字段表
- [ ] `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` 字段
@@ -266,11 +249,11 @@ API 模板结构:
```
> **注意**`schema` 已由脚本从原始 `schema.properties.goal` 提升为顶层,直接包含参数定义。
> `schema.properties` 中的字段即为 API 创建节点返回的 `data.param` 中的字段PATCH 更新时直接修改 `param` 即可
> `schema.properties` 中的字段即为 API 请求 `param.goal` 中的字段
## Placeholder Slot 类型体系
`placeholder_keys` / `_unilabos_placeholder_info` 中有 5 种值,对应不同的填写方式:
`placeholder_keys` / `_unilabos_placeholder_info` 中有 4 种值,对应不同的填写方式:
| placeholder 值 | Slot 类型 | 填写格式 | 选取范围 |
|---------------|-----------|---------|---------|
@@ -278,7 +261,6 @@ API 模板结构:
| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅**设备**节点type=device路径字符串 |
| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | **设备 + 物料**,即所有节点,路径字符串 |
| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已上报的资源类 name |
| `unilabos_formulation` | FormulationSlot | `[{well_name, liquids: [{name, volume}]}]` | 资源树中物料节点的 **name**,配合液体配方 |
### ResourceSlot`unilabos_resources`
@@ -325,41 +307,7 @@ API 模板结构:
"container"
```
### FormulationSlot`unilabos_formulation`
描述**液体配方**:向哪些物料容器中加入哪些液体及体积。填写为**对象数组**
```json
[
{
"sample_uuid": "",
"well_name": "YB_PrepBottle_15mL_Carrier_bottle_A1",
"liquids": [
{ "name": "LiPF6", "volume": 0.6 },
{ "name": "DMC", "volume": 1.2 }
]
}
]
```
#### 字段说明
| 字段 | 类型 | 说明 |
|------|------|------|
| `sample_uuid` | string | 样品 UUID无样品时传空字符串 `""` |
| `well_name` | string | 目标物料容器的 **name**(从资源树中取物料节点的 `name` 字段,如瓶子、孔位名称) |
| `liquids` | array | 要加入的液体列表 |
| `liquids[].name` | string | 液体名称(如试剂名、溶剂名) |
| `liquids[].volume` | number | 液体体积(单位由设备决定,通常为 mL |
#### 填写规则
- `well_name` 必须是资源树中已存在的物料节点 `name`(不是 `id` 路径),通过 API #12 获取资源树后筛选
- 每个数组元素代表一个目标容器的配方
- 一个容器可以加入多种液体(`liquids` 数组多条记录)
- 与 ResourceSlot 的区别ResourceSlot 填 `{id, name, uuid}` 指向物料本身FormulationSlot 用 `well_name` 引用物料,并附带液体配方信息
### 通过 API #12 获取资源树
### 通过 API #10 获取资源树
```bash
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"

View File

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

View File

@@ -1,133 +0,0 @@
"""
读取实验结果文件JSON / CSV整合为 agent_result 请求体并可选提交。
用法:
python prepare_agent_result.py \
--notebook-uuid <uuid> \
--files data1.json data2.csv \
[--auth <Lab token>] \
[--base <BASE_URL>] \
[--submit] \
[--output <output.json>]
支持的输入文件格式:
- .json → 直接作为 dict 合并
- .csv → 转为 {"filename": [row_dict, ...]} 格式
"""
import argparse
import base64
import csv
import json
import os
import sys
from pathlib import Path
from typing import Any, Dict, List
def read_json_file(filepath: str) -> Dict[str, Any]:
with open(filepath, "r", encoding="utf-8") as f:
return json.load(f)
def read_csv_file(filepath: str) -> List[Dict[str, Any]]:
rows = []
with open(filepath, "r", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
for row in reader:
converted = {}
for k, v in row.items():
try:
converted[k] = int(v)
except (ValueError, TypeError):
try:
converted[k] = float(v)
except (ValueError, TypeError):
converted[k] = v
rows.append(converted)
return rows
def merge_files(filepaths: List[str]) -> Dict[str, Any]:
"""将多个文件合并为一个 agent_result dict"""
merged: Dict[str, Any] = {}
for fp in filepaths:
path = Path(fp)
ext = path.suffix.lower()
key = path.stem
if ext == ".json":
data = read_json_file(fp)
if isinstance(data, dict):
merged.update(data)
else:
merged[key] = data
elif ext == ".csv":
merged[key] = read_csv_file(fp)
else:
print(f"[警告] 不支持的文件格式: {fp},跳过", file=sys.stderr)
return merged
def build_request_body(notebook_uuid: str, agent_result: Dict[str, Any]) -> Dict[str, Any]:
return {
"notebook_uuid": notebook_uuid,
"agent_result": agent_result,
}
def submit(base: str, auth: str, body: Dict[str, Any]) -> Dict[str, Any]:
try:
import requests
except ImportError:
print("[错误] 提交需要 requests 库: pip install requests", file=sys.stderr)
sys.exit(1)
url = f"{base}/api/v1/lab/notebook/agent-result"
headers = {
"Content-Type": "application/json",
"Authorization": f"Lab {auth}",
}
resp = requests.put(url, json=body, headers=headers, timeout=30)
return {"status_code": resp.status_code, "body": resp.json() if resp.headers.get("content-type", "").startswith("application/json") else resp.text}
def main():
parser = argparse.ArgumentParser(description="整合实验结果文件并构建 agent_result 请求体")
parser.add_argument("--notebook-uuid", required=True, help="目标 notebook UUID")
parser.add_argument("--files", nargs="+", required=True, help="输入文件路径JSON / CSV")
parser.add_argument("--auth", help="Lab tokenbase64(ak:sk)")
parser.add_argument("--base", help="API base URL")
parser.add_argument("--submit", action="store_true", help="直接提交到云端")
parser.add_argument("--output", default="agent_result_body.json", help="输出 JSON 文件路径")
args = parser.parse_args()
for fp in args.files:
if not os.path.exists(fp):
print(f"[错误] 文件不存在: {fp}", file=sys.stderr)
sys.exit(1)
agent_result = merge_files(args.files)
body = build_request_body(args.notebook_uuid, agent_result)
with open(args.output, "w", encoding="utf-8") as f:
json.dump(body, f, ensure_ascii=False, indent=2)
print(f"[完成] 请求体已保存: {args.output}")
print(f" notebook_uuid: {args.notebook_uuid}")
print(f" agent_result 字段数: {len(agent_result)}")
print(f" 合并文件数: {len(args.files)}")
if args.submit:
if not args.auth or not args.base:
print("[错误] 提交需要 --auth 和 --base 参数", file=sys.stderr)
sys.exit(1)
print(f"\n[提交] PUT {args.base}/api/v1/lab/notebook/agent-result ...")
result = submit(args.base, args.auth, body)
print(f" HTTP {result['status_code']}")
print(f" 响应: {json.dumps(result['body'], ensure_ascii=False)}")
if __name__ == "__main__":
main()

View File

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

2
.gitignore vendored
View File

@@ -252,5 +252,3 @@ ros-humble-unilabos-msgs-0.9.13-h6403a04_5.tar.bz2
test_config.py
/.claude
/.cursor

File diff suppressed because it is too large Load Diff

View File

@@ -1,539 +0,0 @@
import pytest
import json
import os
import asyncio
import collections
from typing import List, Dict, Any
from pylabrobot.resources import Coordinate
from pylabrobot.resources.opentrons.tip_racks import opentrons_96_tiprack_300ul, opentrons_96_tiprack_10ul
from pylabrobot.resources.opentrons.plates import corning_96_wellplate_360ul_flat, nest_96_wellplate_2ml_deep
from unilabos.devices.liquid_handling.prcxi.prcxi import (
PRCXI9300Deck,
PRCXI9300Container,
PRCXI9300Trash,
PRCXI9300Handler,
PRCXI9300Backend,
DefaultLayout,
Material,
WorkTablets,
MatrixInfo
)
@pytest.fixture
def prcxi_materials() -> Dict[str, Any]:
"""加载 PRCXI 物料数据"""
print("加载 PRCXI 物料数据...")
material_path = os.path.join(os.path.dirname(__file__), "..", "..", "unilabos", "devices", "liquid_handling", "prcxi", "prcxi_material.json")
with open(material_path, "r", encoding="utf-8") as f:
data = json.load(f)
print(f"加载了 {len(data)} 条物料数据")
return data
@pytest.fixture
def prcxi_9300_deck() -> PRCXI9300Deck:
"""创建 PRCXI 9300 工作台"""
return PRCXI9300Deck(name="PRCXI_Deck_9300", size_x=100, size_y=100, size_z=100, model="9300")
@pytest.fixture
def prcxi_9320_deck() -> PRCXI9300Deck:
"""创建 PRCXI 9320 工作台"""
return PRCXI9300Deck(name="PRCXI_Deck_9320", size_x=100, size_y=100, size_z=100, model="9320")
@pytest.fixture
def prcxi_9300_handler(prcxi_9300_deck) -> PRCXI9300Handler:
"""创建 PRCXI 9300 处理器(模拟模式)"""
return PRCXI9300Handler(
deck=prcxi_9300_deck,
host="192.168.1.201",
port=9999,
timeout=10.0,
channel_num=8,
axis="Left",
setup=False,
debug=True,
simulator=True,
matrix_id="test-matrix-9300"
)
@pytest.fixture
def prcxi_9320_handler(prcxi_9320_deck) -> PRCXI9300Handler:
"""创建 PRCXI 9320 处理器(模拟模式)"""
return PRCXI9300Handler(
deck=prcxi_9320_deck,
host="192.168.1.201",
port=9999,
timeout=10.0,
channel_num=1,
axis="Right",
setup=False,
debug=True,
simulator=True,
matrix_id="test-matrix-9320",
is_9320=True
)
@pytest.fixture
def tip_rack_300ul(prcxi_materials) -> PRCXI9300Container:
"""创建 300μL 枪头盒"""
tip_rack = PRCXI9300Container(
name="tip_rack_300ul",
size_x=50,
size_y=50,
size_z=10,
category="tip_rack",
ordering=collections.OrderedDict()
)
tip_rack.load_state({
"Material": {
"uuid": prcxi_materials["300μL Tip头"]["uuid"],
"Code": "ZX-001-300",
"Name": "300μL Tip头"
}
})
return tip_rack
@pytest.fixture
def tip_rack_10ul(prcxi_materials) -> PRCXI9300Container:
"""创建 10μL 枪头盒"""
tip_rack = PRCXI9300Container(
name="tip_rack_10ul",
size_x=50,
size_y=50,
size_z=10,
category="tip_rack",
ordering=collections.OrderedDict()
)
tip_rack.load_state({
"Material": {
"uuid": prcxi_materials["10μL加长 Tip头"]["uuid"],
"Code": "ZX-001-10+",
"Name": "10μL加长 Tip头"
}
})
return tip_rack
@pytest.fixture
def well_plate_96(prcxi_materials) -> PRCXI9300Container:
"""创建 96 孔板"""
plate = PRCXI9300Container(
name="well_plate_96",
size_x=50,
size_y=50,
size_z=10,
category="plate",
ordering=collections.OrderedDict()
)
plate.load_state({
"Material": {
"uuid": prcxi_materials["96深孔板"]["uuid"],
"Code": "ZX-019-2.2",
"Name": "96深孔板"
}
})
return plate
@pytest.fixture
def deep_well_plate(prcxi_materials) -> PRCXI9300Container:
"""创建深孔板"""
plate = PRCXI9300Container(
name="deep_well_plate",
size_x=50,
size_y=50,
size_z=10,
category="plate",
ordering=collections.OrderedDict()
)
plate.load_state({
"Material": {
"uuid": prcxi_materials["96深孔板"]["uuid"],
"Code": "ZX-019-2.2",
"Name": "96深孔板"
}
})
return plate
@pytest.fixture
def trash_container(prcxi_materials) -> PRCXI9300Trash:
"""创建垃圾桶"""
trash = PRCXI9300Trash(name="trash", size_x=50, size_y=50, size_z=10, category="trash")
trash.load_state({
"Material": {
"uuid": prcxi_materials["废弃槽"]["uuid"]
}
})
return trash
@pytest.fixture
def default_layout_9300() -> DefaultLayout:
"""创建 PRCXI 9300 默认布局"""
return DefaultLayout("PRCXI9300")
@pytest.fixture
def default_layout_9320() -> DefaultLayout:
"""创建 PRCXI 9320 默认布局"""
return DefaultLayout("PRCXI9320")
class TestPRCXIDeckSetup:
"""测试 PRCXI 工作台设置功能"""
def test_prcxi_9300_deck_creation(self, prcxi_9300_deck):
"""测试 PRCXI 9300 工作台创建"""
assert prcxi_9300_deck.name == "PRCXI_Deck_9300"
assert len(prcxi_9300_deck.sites) == 6
assert prcxi_9300_deck._size_x == 100
assert prcxi_9300_deck._size_y == 100
assert prcxi_9300_deck._size_z == 100
def test_prcxi_9320_deck_creation(self, prcxi_9320_deck):
"""测试 PRCXI 9320 工作台创建"""
assert prcxi_9320_deck.name == "PRCXI_Deck_9320"
assert len(prcxi_9320_deck.sites) == 16
assert prcxi_9320_deck._size_x == 100
assert prcxi_9320_deck._size_y == 100
assert prcxi_9320_deck._size_z == 100
def test_container_assignment(self, prcxi_9300_deck, tip_rack_300ul, well_plate_96, trash_container):
"""测试容器分配到工作台"""
# 分配枪头盒
prcxi_9300_deck.assign_child_resource(tip_rack_300ul, location=Coordinate(0, 0, 0))
assert tip_rack_300ul in prcxi_9300_deck.children
# 分配孔板
prcxi_9300_deck.assign_child_resource(well_plate_96, location=Coordinate(0, 0, 0))
assert well_plate_96 in prcxi_9300_deck.children
# 分配垃圾桶
prcxi_9300_deck.assign_child_resource(trash_container, location=Coordinate(0, 0, 0))
assert trash_container in prcxi_9300_deck.children
def test_container_material_loading(self, tip_rack_300ul, well_plate_96, prcxi_materials):
"""测试容器物料信息加载"""
# 测试枪头盒物料信息
tip_material = tip_rack_300ul._unilabos_state["Material"]
assert tip_material["uuid"] == prcxi_materials["300μL Tip头"]["uuid"]
assert tip_material["Name"] == "300μL Tip头"
# 测试孔板物料信息
plate_material = well_plate_96._unilabos_state["Material"]
assert plate_material["uuid"] == prcxi_materials["96深孔板"]["uuid"]
assert plate_material["Name"] == "96深孔板"
class TestPRCXISingleStepOperations:
"""测试 PRCXI 单步操作功能"""
@pytest.mark.asyncio
async def test_pick_up_tips_single_channel(self, prcxi_9320_handler, prcxi_9320_deck, tip_rack_10ul):
"""测试单通道拾取枪头"""
# 将枪头盒添加到工作台
prcxi_9320_deck.assign_child_resource(tip_rack_10ul, location=Coordinate(0, 0, 0))
# 初始化处理器
await prcxi_9320_handler.setup()
# 设置枪头盒
prcxi_9320_handler.set_tiprack([tip_rack_10ul])
# 创建模拟的枪头位置
from pylabrobot.resources import TipSpot, Tip
tip = Tip(has_filter=False, total_tip_length=10, maximal_volume=10, fitting_depth=5)
tip_spot = TipSpot("A1", size_x=1, size_y=1, size_z=1, make_tip=lambda: tip)
tip_rack_10ul.assign_child_resource(tip_spot, location=Coordinate(0, 0, 0))
# 直接测试后端方法
from pylabrobot.liquid_handling import Pickup
pickup = Pickup(resource=tip_spot, offset=None, tip=tip)
await prcxi_9320_handler._unilabos_backend.pick_up_tips([pickup], [0])
# 验证步骤已添加到待办列表
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
assert step["Function"] == "Load"
@pytest.mark.asyncio
async def test_pick_up_tips_multi_channel(self, prcxi_9300_handler, tip_rack_300ul):
"""测试多通道拾取枪头"""
# 设置枪头盒
prcxi_9300_handler.set_tiprack([tip_rack_300ul])
# 拾取8个枪头
tip_spots = tip_rack_300ul.children[:8]
await prcxi_9300_handler.pick_up_tips(tip_spots, [0, 1, 2, 3, 4, 5, 6, 7])
# 验证步骤已添加到待办列表
assert len(prcxi_9300_handler._unilabos_backend.steps_todo_list) == 1
step = prcxi_9300_handler._unilabos_backend.steps_todo_list[0]
assert step["Function"] == "Load"
@pytest.mark.asyncio
async def test_aspirate_single_channel(self, prcxi_9320_handler, well_plate_96):
"""测试单通道吸取液体"""
# 设置液体
well = well_plate_96.get_item("A1")
prcxi_9320_handler.set_liquid([well], ["water"], [50])
# 吸取液体
await prcxi_9320_handler.aspirate([well], [50], [0])
# 验证步骤已添加到待办列表
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
assert step["Function"] == "Imbibing"
assert step["DosageNum"] == 50
@pytest.mark.asyncio
async def test_dispense_single_channel(self, prcxi_9320_handler, well_plate_96):
"""测试单通道分配液体"""
# 分配液体
well = well_plate_96.get_item("A1")
await prcxi_9320_handler.dispense([well], [25], [0])
# 验证步骤已添加到待办列表
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
assert step["Function"] == "Tapping"
assert step["DosageNum"] == 25
@pytest.mark.asyncio
async def test_mix_single_channel(self, prcxi_9320_handler, well_plate_96):
"""测试单通道混合液体"""
# 混合液体
well = well_plate_96.get_item("A1")
await prcxi_9320_handler.mix([well], mix_time=3, mix_vol=50)
# 验证步骤已添加到待办列表
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
assert step["Function"] == "Blending"
assert step["BlendingTimes"] == 3
assert step["DosageNum"] == 50
@pytest.mark.asyncio
async def test_drop_tips_to_trash(self, prcxi_9320_handler, trash_container):
"""测试丢弃枪头到垃圾桶"""
# 丢弃枪头
await prcxi_9320_handler.drop_tips([trash_container], [0])
# 验证步骤已添加到待办列表
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
assert step["Function"] == "UnLoad"
@pytest.mark.asyncio
async def test_discard_tips(self, prcxi_9320_handler):
"""测试丢弃枪头"""
# 丢弃枪头
await prcxi_9320_handler.discard_tips([0])
# 验证步骤已添加到待办列表
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
assert step["Function"] == "UnLoad"
@pytest.mark.asyncio
async def test_liquid_transfer_workflow(self, prcxi_9320_handler, tip_rack_10ul, well_plate_96):
"""测试完整的液体转移工作流程"""
# 设置枪头盒和液体
prcxi_9320_handler.set_tiprack([tip_rack_10ul])
source_well = well_plate_96.get_item("A1")
target_well = well_plate_96.get_item("B1")
prcxi_9320_handler.set_liquid([source_well], ["water"], [100])
# 创建协议
await prcxi_9320_handler.create_protocol(protocol_name="Test Transfer Protocol")
# 执行转移流程
tip_spot = tip_rack_10ul.get_item("A1")
await prcxi_9320_handler.pick_up_tips([tip_spot], [0])
await prcxi_9320_handler.aspirate([source_well], [50], [0])
await prcxi_9320_handler.dispense([target_well], [50], [0])
await prcxi_9320_handler.discard_tips([0])
# 验证所有步骤都已添加
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 4
functions = [step["Function"] for step in prcxi_9320_handler._unilabos_backend.steps_todo_list]
assert functions == ["Load", "Imbibing", "Tapping", "UnLoad"]
class TestPRCXILayoutRecommendation:
"""测试 PRCXI 板位推荐功能"""
def test_9300_layout_creation(self, default_layout_9300):
"""测试 PRCXI 9300 布局创建"""
layout_info = default_layout_9300.get_layout()
assert layout_info["rows"] == 2
assert layout_info["columns"] == 3
assert len(layout_info["layout"]) == 6
assert layout_info["trash_slot"] == 6
assert "waste_liquid_slot" not in layout_info
def test_9320_layout_creation(self, default_layout_9320):
"""测试 PRCXI 9320 布局创建"""
layout_info = default_layout_9320.get_layout()
assert layout_info["rows"] == 4
assert layout_info["columns"] == 4
assert len(layout_info["layout"]) == 16
assert layout_info["trash_slot"] == 16
assert layout_info["waste_liquid_slot"] == 12
def test_layout_recommendation_9320(self, default_layout_9320, prcxi_materials):
"""测试 PRCXI 9320 板位推荐功能"""
# 添加物料信息
default_layout_9320.add_lab_resource(prcxi_materials)
# 推荐布局
needs = [
("reagent_1", "96 细胞培养皿", 3),
("reagent_2", "12道储液槽", 1),
("reagent_3", "200μL Tip头", 7),
("reagent_4", "10μL加长 Tip头", 1),
]
matrix_layout, layout_list = default_layout_9320.recommend_layout(needs)
# 验证返回结果
assert "MatrixId" in matrix_layout
assert "MatrixName" in matrix_layout
assert "MatrixCount" in matrix_layout
assert "WorkTablets" in matrix_layout
assert len(layout_list) == 12 # 3+1+7+1 = 12个位置
# 验证推荐的位置不包含预留位置
reserved_positions = {12, 16}
recommended_positions = [item["positions"] for item in layout_list]
for pos in recommended_positions:
assert pos not in reserved_positions
def test_layout_recommendation_insufficient_space(self, default_layout_9320, prcxi_materials):
"""测试板位推荐空间不足的情况"""
# 添加物料信息
default_layout_9320.add_lab_resource(prcxi_materials)
# 尝试推荐超过可用空间的布局
needs = [
("reagent_1", "96 细胞培养皿", 15), # 需要15个位置但只有14个可用
]
with pytest.raises(ValueError, match="需要 .* 个位置,但只有 .* 个可用位置"):
default_layout_9320.recommend_layout(needs)
def test_layout_recommendation_material_not_found(self, default_layout_9320, prcxi_materials):
"""测试板位推荐物料不存在的情况"""
# 添加物料信息
default_layout_9320.add_lab_resource(prcxi_materials)
# 尝试推荐不存在的物料
needs = [
("reagent_1", "不存在的物料", 1),
]
with pytest.raises(ValueError, match="Material .* not found in lab resources"):
default_layout_9320.recommend_layout(needs)
class TestPRCXIBackendOperations:
"""测试 PRCXI 后端操作功能"""
def test_backend_initialization(self, prcxi_9300_handler):
"""测试后端初始化"""
backend = prcxi_9300_handler._unilabos_backend
assert isinstance(backend, PRCXI9300Backend)
assert backend._num_channels == 8
assert backend.debug is True
def test_protocol_creation(self, prcxi_9300_handler):
"""测试协议创建"""
backend = prcxi_9300_handler._unilabos_backend
backend.create_protocol("Test Protocol")
assert backend.protocol_name == "Test Protocol"
assert len(backend.steps_todo_list) == 0
def test_channel_validation(self):
"""测试通道验证"""
# 测试正确的8通道配置
valid_channels = [0, 1, 2, 3, 4, 5, 6, 7]
result = PRCXI9300Backend.check_channels(valid_channels)
assert result == valid_channels
# 测试错误的通道配置
invalid_channels = [0, 1, 2, 3]
result = PRCXI9300Backend.check_channels(invalid_channels)
assert result == [0, 1, 2, 3, 4, 5, 6, 7]
def test_matrix_info_creation(self, prcxi_9300_handler):
"""测试矩阵信息创建"""
backend = prcxi_9300_handler._unilabos_backend
backend.create_protocol("Test Protocol")
# 模拟运行协议时的矩阵信息创建
run_time = 1234567890
matrix_info = MatrixInfo(
MatrixId=f"{int(run_time)}",
MatrixName=f"protocol_{run_time}",
MatrixCount=len(backend.tablets_info),
WorkTablets=backend.tablets_info,
)
assert matrix_info["MatrixId"] == str(int(run_time))
assert matrix_info["MatrixName"] == f"protocol_{run_time}"
assert "WorkTablets" in matrix_info
class TestPRCXIContainerOperations:
"""测试 PRCXI 容器操作功能"""
def test_container_serialization(self, tip_rack_300ul):
"""测试容器序列化"""
serialized = tip_rack_300ul.serialize_state()
assert "Material" in serialized
assert serialized["Material"]["Name"] == "300μL Tip头"
def test_container_deserialization(self, tip_rack_300ul):
"""测试容器反序列化"""
# 序列化
serialized = tip_rack_300ul.serialize_state()
# 创建新容器并反序列化
new_tip_rack = PRCXI9300Container(
name="new_tip_rack",
size_x=50,
size_y=50,
size_z=10,
category="tip_rack",
ordering=collections.OrderedDict()
)
new_tip_rack.load_state(serialized)
assert new_tip_rack._unilabos_state["Material"]["Name"] == "300μL Tip头"
def test_trash_container_creation(self, prcxi_materials):
"""测试垃圾桶容器创建"""
trash = PRCXI9300Trash(name="trash", size_x=50, size_y=50, size_z=10, category="trash")
trash.load_state({
"Material": {
"uuid": prcxi_materials["废弃槽"]["uuid"]
}
})
assert trash.name == "trash"
assert trash._unilabos_state["Material"]["uuid"] == prcxi_materials["废弃槽"]["uuid"]
if __name__ == "__main__":
# 运行测试
pytest.main([__file__, "-v"])

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

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

View File

@@ -1,608 +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]] = []
def set_tiprack(self, tip_racks):
# transfer_liquid 总会调用 set_tiprack测试用 Dummy 枪头时 tip_racks 为空,需保留自种子的 current_tip
if not tip_racks:
return
super().set_tiprack(tip_racks)
async def pick_up_tips(self, tip_spots, use_channels=None, offsets=None, **backend_kwargs):
self.calls.append(("pick_up_tips", {"tips": list(tip_spots), "use_channels": use_channels}))
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]]
def test_set_tiprack_per_type_resumes_first_physical_rack():
"""同型号多次 set_tiprack 时接续第一盒剩余孔位,而非从新盒 A1 开始。"""
from pylabrobot.liquid_handling import LiquidHandlerChatterboxBackend
from pylabrobot.resources import Deck, Tip, TipRack, TipSpot, create_equally_spaced
mk = lambda: Tip(
has_filter=False, total_tip_length=10.0, maximal_volume=300.0, fitting_depth=2.0
)
class TipTypeAlpha(TipRack):
pass
class TipTypeBeta(TipRack):
pass
def make_rack(cls: type, name: str) -> TipRack:
items = create_equally_spaced(
TipSpot,
num_items_x=12,
num_items_y=2,
dx=0,
dy=0,
dz=0,
item_dx=9,
item_dy=9,
size_x=1,
size_y=1,
make_tip=mk,
)
return cls(name, 120, 40, 10, items=items)
rack1 = make_rack(TipTypeAlpha, "rack_phys_1")
rack2 = make_rack(TipTypeBeta, "rack_phys_2")
rack3 = make_rack(TipTypeAlpha, "rack_phys_3")
lh = LiquidHandlerAbstract(
LiquidHandlerChatterboxBackend(1), Deck(), channel_num=1, simulator=False
)
flat1 = lh._flatten_tips_from_one(rack1)
assert len(flat1) == 24
lh.set_tiprack([rack1])
for i in range(12):
assert lh._get_next_tip() is flat1[i]
lh.set_tiprack([rack2])
spot_b = lh._get_next_tip()
assert "rack_phys_2" in spot_b.name
lh.set_tiprack([rack3])
spot_resume = lh._get_next_tip()
assert spot_resume is flat1[12], "第三次同型号应接续 rack1 第二行首孔,而非 rack3 首孔"
assert spot_resume is not lh._flatten_tips_from_one(rack3)[0]

View File

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

@@ -80,20 +80,19 @@ class HTTPClient:
f.write(json.dumps(payload, indent=4))
# 从序列化数据中提取所有节点的UUID保存旧UUID
old_uuids = {n.res_content.uuid: n for n in resources.all_nodes}
nodes_info = [x for xs in resources.dump() for x in xs]
if not self.initialized or first_add:
self.initialized = True
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
response = requests.post(
f"{self.remote_addr}/edge/material",
json={"nodes": nodes_info, "mount_uuid": mount_uuid},
json={"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid},
headers={"Authorization": f"Lab {self.auth}"},
timeout=60,
)
else:
response = requests.put(
f"{self.remote_addr}/edge/material",
json={"nodes": nodes_info, "mount_uuid": mount_uuid},
json={"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid},
headers={"Authorization": f"Lab {self.auth}"},
timeout=10,
)
@@ -112,7 +111,6 @@ class HTTPClient:
uuid_mapping[i["uuid"]] = i["cloud_uuid"]
else:
logger.error(f"添加物料失败: {response.text}")
logger.trace(f"添加物料失败: {nodes_info}")
for u, n in old_uuids.items():
if u in uuid_mapping:
n.res_content.uuid = uuid_mapping[u]
@@ -470,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}")
@@ -1113,7 +1087,7 @@ class MessageProcessor:
"task_id": task_id,
"job_id": job_id,
"free": free,
"need_more": need_more + 1,
"need_more": need_more,
},
}
@@ -1253,7 +1227,7 @@ class QueueProcessor:
"task_id": job_info.task_id,
"job_id": job_info.job_id,
"free": False,
"need_more": 10 + 1,
"need_more": 10,
},
}
self.message_processor.send_message(message)
@@ -1286,7 +1260,7 @@ class QueueProcessor:
"task_id": job_info.task_id,
"job_id": job_info.job_id,
"free": False,
"need_more": 10 + 1,
"need_more": 10,
},
}
success = self.message_processor.send_message(message)
@@ -1369,10 +1343,6 @@ class WebSocketClient(BaseCommunicationClient):
self.message_processor = MessageProcessor(self.websocket_url, self.send_queue, self.device_manager)
self.queue_processor = QueueProcessor(self.device_manager, self.message_processor)
# running状态debounce缓存: {job_id: (last_send_timestamp, last_feedback_data)}
self._job_running_last_sent: Dict[str, tuple] = {}
self._job_running_debounce_interval: float = 10.0 # 秒
# 设置相互引用
self.message_processor.set_queue_processor(self.queue_processor)
self.message_processor.set_websocket_client(self)
@@ -1464,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:
@@ -1472,32 +1457,22 @@ class WebSocketClient(BaseCommunicationClient):
logger.debug(f"[WebSocketClient] Not connected, cannot publish job status for job_id: {item.job_id}")
return
job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name)
# 拦截最终结果状态,与原版本逻辑一致
if status in ["success", "failed"]:
self._job_running_last_sent.pop(item.job_id, None)
host_node = HostNode.get_instance(0)
if host_node:
# 从HostNode的device_action_status中移除job_id
try:
host_node._device_action_status[item.device_action_key].job_ids.pop(item.job_id, None)
except (KeyError, AttributeError):
logger.warning(f"[WebSocketClient] Failed to remove job {item.job_id} from HostNode status")
# logger.debug(f"[WebSocketClient] Intercepting final status for job_id: {item.job_id} - {status}")
# 通知队列处理器job完成包括timeout的job
self.queue_processor.handle_job_completed(item.job_id, status)
# running状态按job_id做debounce内容变化时仍然上报
if status == "running":
now = time.time()
cached = self._job_running_last_sent.get(item.job_id)
if cached is not None:
last_ts, last_data = cached
if now - last_ts < self._job_running_debounce_interval and last_data == feedback_data:
logger.trace(f"[WebSocketClient] Job status debounced (skip): {job_log} - {status}")
return
self._job_running_last_sent[item.job_id] = (now, feedback_data)
# 发送job状态消息
message = {
"action": "job_status",
"data": {
@@ -1513,6 +1488,7 @@ class WebSocketClient(BaseCommunicationClient):
}
self.message_processor.send_message(message)
job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name)
logger.trace(f"[WebSocketClient] Job status published: {job_log} - {status}")
def send_ping(self, ping_id: str, timestamp: float) -> None:

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

@@ -201,42 +201,17 @@ class ResourceVisualization:
self.moveit_controllers_yaml['moveit_simple_controller_manager'][f"{name}_{controller_name}"] = moveit_dict['moveit_simple_controller_manager'][controller_name]
@staticmethod
def _ensure_ros2_env() -> dict:
"""确保 ROS2 环境变量正确设置,返回可用于子进程的 env dict"""
import sys
env = dict(os.environ)
conda_prefix = os.path.dirname(os.path.dirname(sys.executable))
if "AMENT_PREFIX_PATH" not in env or not env["AMENT_PREFIX_PATH"].strip():
candidate = os.pathsep.join([conda_prefix, os.path.join(conda_prefix, "Library")])
env["AMENT_PREFIX_PATH"] = candidate
os.environ["AMENT_PREFIX_PATH"] = candidate
extra_bin_dirs = [
os.path.join(conda_prefix, "Library", "bin"),
os.path.join(conda_prefix, "Library", "lib"),
os.path.join(conda_prefix, "Scripts"),
conda_prefix,
]
current_path = env.get("PATH", "")
for d in extra_bin_dirs:
if d not in current_path:
current_path = d + os.pathsep + current_path
env["PATH"] = current_path
os.environ["PATH"] = current_path
return env
def create_launch_description(self) -> LaunchDescription:
"""
创建launch描述包含robot_state_publisher和move_group节点
Args:
urdf_str: URDF文本
Returns:
LaunchDescription: launch描述对象
"""
launch_env = self._ensure_ros2_env()
# 检查ROS 2环境变量
if "AMENT_PREFIX_PATH" not in os.environ:
raise OSError(
"ROS 2环境未正确设置。需要设置 AMENT_PREFIX_PATH 环境变量。\n"
@@ -315,7 +290,7 @@ class ResourceVisualization:
{"robot_description": robot_description},
ros2_controllers,
],
env=launch_env,
env=dict(os.environ)
)
)
for controller in self.moveit_controllers_yaml['moveit_simple_controller_manager']['controller_names']:
@@ -325,7 +300,7 @@ class ResourceVisualization:
executable="spawner",
arguments=[f"{controller}", "--controller-manager", f"controller_manager"],
output="screen",
env=launch_env,
env=dict(os.environ)
)
)
controllers.append(
@@ -334,7 +309,7 @@ class ResourceVisualization:
executable="spawner",
arguments=["joint_state_broadcaster", "--controller-manager", f"controller_manager"],
output="screen",
env=launch_env,
env=dict(os.environ)
)
)
for i in controllers:
@@ -342,6 +317,7 @@ class ResourceVisualization:
else:
ros2_controllers = None
# 创建robot_state_publisher节点
robot_state_publisher = nd(
package='robot_state_publisher',
executable='robot_state_publisher',
@@ -351,8 +327,9 @@ class ResourceVisualization:
'robot_description': robot_description,
'use_sim_time': False
},
# kinematics_dict
],
env=launch_env,
env=dict(os.environ)
)
@@ -384,7 +361,7 @@ class ResourceVisualization:
executable='move_group',
output='screen',
parameters=moveit_params,
env=launch_env,
env=dict(os.environ)
)
@@ -402,11 +379,13 @@ class ResourceVisualization:
arguments=['-d', f"{str(self.mesh_path)}/view_robot.rviz"],
output='screen',
parameters=[
{'robot_description_kinematics': kinematics_dict},
{'robot_description_kinematics': kinematics_dict,
},
robot_description_planning,
planning_pipelines,
],
env=launch_env,
env=dict(os.environ)
)
self.launch_description.add_action(rviz_node)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

@@ -59,7 +59,6 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend):
self.total_height = total_height
self.joint_config = kwargs.get("joint_config", None)
self.lh_device_id = kwargs.get("lh_device_id", "lh_joint_publisher")
self.simulate_rviz = kwargs.get("simulate_rviz", False)
if not rclpy.ok():
rclpy.init()
self.joint_state_publisher = None
@@ -70,7 +69,7 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend):
self.joint_state_publisher = LiquidHandlerJointPublisher(
joint_config=self.joint_config,
lh_device_id=self.lh_device_id,
simulate_rviz=self.simulate_rviz)
simulate_rviz=True)
# 启动ROS executor
self.executor = rclpy.executors.MultiThreadedExecutor()

View File

@@ -42,7 +42,6 @@ class LiquidHandlerJointPublisher(Node):
while self.resource_action is None:
self.resource_action = self.check_tf_update_actions()
time.sleep(1)
self.get_logger().info(f'Waiting for TfUpdate server: {self.resource_action}')
self.resource_action_client = ActionClient(self, SendCmd, self.resource_action)
while not self.resource_action_client.wait_for_server(timeout_sec=1.0):

View File

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

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

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

View File

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

View File

@@ -1,196 +0,0 @@
"""FastAPI 应用 + CRUD API + 启动入口。
用法: python -m unilabos.labware_manager.app
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import List, Optional
from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from unilabos.labware_manager.models import LabwareDB, LabwareItem
_HERE = Path(__file__).resolve().parent
_DB_PATH = _HERE / "labware_db.json"
app = FastAPI(title="PRCXI 耗材管理", version="1.0")
# 静态文件 + 模板
app.mount("/static", StaticFiles(directory=str(_HERE / "static")), name="static")
templates = Jinja2Templates(directory=str(_HERE / "templates"))
# ---------- DB 读写 ----------
def _load_db() -> LabwareDB:
if not _DB_PATH.exists():
return LabwareDB()
with open(_DB_PATH, "r", encoding="utf-8") as f:
return LabwareDB(**json.load(f))
def _save_db(db: LabwareDB) -> None:
with open(_DB_PATH, "w", encoding="utf-8") as f:
json.dump(db.model_dump(), f, ensure_ascii=False, indent=2)
# ---------- 页面路由 ----------
@app.get("/", response_class=HTMLResponse)
async def index_page(request: Request):
db = _load_db()
# 按 type 分组
groups = {}
for item in db.items:
groups.setdefault(item.type, []).append(item)
return templates.TemplateResponse("index.html", {
"request": request,
"groups": groups,
"total": len(db.items),
})
@app.get("/labware/new", response_class=HTMLResponse)
async def new_page(request: Request, type: str = "plate"):
return templates.TemplateResponse("edit.html", {
"request": request,
"item": None,
"labware_type": type,
"is_new": True,
})
@app.get("/labware/{item_id}", response_class=HTMLResponse)
async def detail_page(request: Request, item_id: str):
db = _load_db()
item = _find_item(db, item_id)
if not item:
raise HTTPException(404, "耗材不存在")
return templates.TemplateResponse("detail.html", {
"request": request,
"item": item,
})
@app.get("/labware/{item_id}/edit", response_class=HTMLResponse)
async def edit_page(request: Request, item_id: str):
db = _load_db()
item = _find_item(db, item_id)
if not item:
raise HTTPException(404, "耗材不存在")
return templates.TemplateResponse("edit.html", {
"request": request,
"item": item,
"labware_type": item.type,
"is_new": False,
})
# ---------- API 端点 ----------
@app.get("/api/labware")
async def api_list_labware():
db = _load_db()
return {"items": [item.model_dump() for item in db.items]}
@app.post("/api/labware")
async def api_create_labware(request: Request):
data = await request.json()
db = _load_db()
item = LabwareItem(**data)
# 确保 id 唯一
existing_ids = {it.id for it in db.items}
while item.id in existing_ids:
import uuid
item.id = uuid.uuid4().hex[:8]
db.items.append(item)
_save_db(db)
return {"status": "ok", "id": item.id}
@app.put("/api/labware/{item_id}")
async def api_update_labware(item_id: str, request: Request):
data = await request.json()
db = _load_db()
for i, it in enumerate(db.items):
if it.id == item_id or it.function_name == item_id:
updated = LabwareItem(**{**it.model_dump(), **data, "id": it.id})
db.items[i] = updated
_save_db(db)
return {"status": "ok", "id": it.id}
raise HTTPException(404, "耗材不存在")
@app.delete("/api/labware/{item_id}")
async def api_delete_labware(item_id: str):
db = _load_db()
original_len = len(db.items)
db.items = [it for it in db.items if it.id != item_id and it.function_name != item_id]
if len(db.items) == original_len:
raise HTTPException(404, "耗材不存在")
_save_db(db)
return {"status": "ok"}
@app.post("/api/generate-code")
async def api_generate_code(request: Request):
body = await request.json() if await request.body() else {}
test_mode = body.get("test_mode", True)
db = _load_db()
if not db.items:
raise HTTPException(400, "数据库为空,请先导入")
from unilabos.labware_manager.codegen import generate_code
from unilabos.labware_manager.yaml_gen import generate_yaml
py_path = generate_code(db, test_mode=test_mode)
yaml_paths = generate_yaml(db, test_mode=test_mode)
return {
"status": "ok",
"python_file": str(py_path),
"yaml_files": [str(p) for p in yaml_paths],
"test_mode": test_mode,
}
@app.post("/api/import-from-code")
async def api_import_from_code():
from unilabos.labware_manager.importer import import_from_code, save_db
db = import_from_code()
save_db(db)
return {
"status": "ok",
"count": len(db.items),
"items": [{"function_name": it.function_name, "type": it.type} for it in db.items],
}
# ---------- 辅助函数 ----------
def _find_item(db: LabwareDB, item_id: str) -> Optional[LabwareItem]:
for item in db.items:
if item.id == item_id or item.function_name == item_id:
return item
return None
# ---------- 启动入口 ----------
def main():
import uvicorn
port = int(os.environ.get("LABWARE_PORT", "8010"))
print(f"PRCXI 耗材管理 → http://localhost:{port}")
uvicorn.run(app, host="0.0.0.0", port=port)
if __name__ == "__main__":
main()

View File

@@ -1,451 +0,0 @@
"""JSON → prcxi_labware.py 代码生成。
读取 labware_db.json输出完整的 prcxi_labware.py或 prcxi_labware_test.py
"""
from __future__ import annotations
import shutil
from pathlib import Path
from typing import List, Optional
from unilabos.labware_manager.models import LabwareDB, LabwareItem
_TARGET_DIR = Path(__file__).resolve().parents[1] / "devices" / "liquid_handling" / "prcxi"
# ---------- 固定头部 ----------
_HEADER = '''\
from typing import Any, Callable, Dict, List, Optional, Tuple
from pylabrobot.resources import Tube, Coordinate
from pylabrobot.resources.well import Well, WellBottomType, CrossSectionType
from pylabrobot.resources.tip import Tip, TipCreator
from pylabrobot.resources.tip_rack import TipRack, TipSpot
from pylabrobot.resources.utils import create_ordered_items_2d
from pylabrobot.resources.height_volume_functions import (
compute_height_from_volume_rectangle,
compute_volume_from_height_rectangle,
)
from .prcxi import PRCXI9300Plate, PRCXI9300TipRack, PRCXI9300Trash, PRCXI9300TubeRack, PRCXI9300PlateAdapter
def _make_tip_helper(volume: float, length: float, depth: float) -> Tip:
"""
PLR 的 Tip 类参数名为: maximal_volume, total_tip_length, fitting_depth
"""
return Tip(
has_filter=False, # 默认无滤芯
maximal_volume=volume,
total_tip_length=length,
fitting_depth=depth
)
'''
def _gen_plate(item: LabwareItem) -> str:
"""生成 Plate 类型的工厂函数代码。"""
lines = []
fn = item.function_name
doc = item.docstring or f"Code: {item.material_info.Code}"
has_vf = item.volume_functions is not None
if has_vf:
# 有 volume_functions 时需要 well_kwargs 方式
vf = item.volume_functions
well = item.well
grid = item.grid
lines.append(f'def {fn}(name: str) -> PRCXI9300Plate:')
lines.append(f' """')
for dl in doc.split('\n'):
lines.append(f' {dl}')
lines.append(f' """')
# 计算 well_size 变量
lines.append(f' well_size_x = {well.size_x}')
lines.append(f' well_size_y = {well.size_y}')
lines.append(f' well_kwargs = {{')
lines.append(f' "size_x": well_size_x,')
lines.append(f' "size_y": well_size_y,')
lines.append(f' "size_z": {well.size_z},')
lines.append(f' "bottom_type": WellBottomType.{well.bottom_type},')
if well.cross_section_type and well.cross_section_type != "CIRCLE":
lines.append(f' "cross_section_type": CrossSectionType.{well.cross_section_type},')
lines.append(f' "compute_height_from_volume": lambda liquid_volume: compute_height_from_volume_rectangle(')
lines.append(f' liquid_volume=liquid_volume, well_length=well_size_x, well_width=well_size_y')
lines.append(f' ),')
lines.append(f' "compute_volume_from_height": lambda liquid_height: compute_volume_from_height_rectangle(')
lines.append(f' liquid_height=liquid_height, well_length=well_size_x, well_width=well_size_y')
lines.append(f' ),')
if well.material_z_thickness is not None:
lines.append(f' "material_z_thickness": {well.material_z_thickness},')
lines.append(f' }}')
lines.append(f'')
lines.append(f' return PRCXI9300Plate(')
lines.append(f' name=name,')
lines.append(f' size_x={item.size_x},')
lines.append(f' size_y={item.size_y},')
lines.append(f' size_z={item.size_z},')
lines.append(f' lid=None,')
lines.append(f' model="{item.model}",')
lines.append(f' category="plate",')
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))},')
lines.append(f' ordered_items=create_ordered_items_2d(')
lines.append(f' Well,')
lines.append(f' num_items_x={grid.num_items_x},')
lines.append(f' num_items_y={grid.num_items_y},')
lines.append(f' dx={grid.dx},')
lines.append(f' dy={grid.dy},')
lines.append(f' dz={grid.dz},')
lines.append(f' item_dx={grid.item_dx},')
lines.append(f' item_dy={grid.item_dy},')
lines.append(f' **well_kwargs,')
lines.append(f' ),')
lines.append(f' )')
else:
# 普通 plate
well = item.well
grid = item.grid
lines.append(f'def {fn}(name: str) -> PRCXI9300Plate:')
lines.append(f' """')
for dl in doc.split('\n'):
lines.append(f' {dl}')
lines.append(f' """')
lines.append(f' return PRCXI9300Plate(')
lines.append(f' name=name,')
lines.append(f' size_x={item.size_x},')
lines.append(f' size_y={item.size_y},')
lines.append(f' size_z={item.size_z},')
if item.plate_type:
lines.append(f' plate_type="{item.plate_type}",')
lines.append(f' model="{item.model}",')
lines.append(f' category="plate",')
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))},')
if grid and well:
lines.append(f' ordered_items=create_ordered_items_2d(')
lines.append(f' Well,')
lines.append(f' num_items_x={grid.num_items_x},')
lines.append(f' num_items_y={grid.num_items_y},')
lines.append(f' dx={grid.dx},')
lines.append(f' dy={grid.dy},')
lines.append(f' dz={grid.dz},')
lines.append(f' item_dx={grid.item_dx},')
lines.append(f' item_dy={grid.item_dy},')
lines.append(f' size_x={well.size_x},')
lines.append(f' size_y={well.size_y},')
lines.append(f' size_z={well.size_z},')
if well.max_volume is not None:
lines.append(f' max_volume={well.max_volume},')
if well.material_z_thickness is not None:
lines.append(f' material_z_thickness={well.material_z_thickness},')
if well.bottom_type and well.bottom_type != "FLAT":
lines.append(f' bottom_type=WellBottomType.{well.bottom_type},')
if well.cross_section_type:
lines.append(f' cross_section_type=CrossSectionType.{well.cross_section_type},')
lines.append(f' ),')
lines.append(f' )')
return '\n'.join(lines)
def _gen_tip_rack(item: LabwareItem) -> str:
"""生成 TipRack 工厂函数代码。"""
lines = []
fn = item.function_name
doc = item.docstring or f"Code: {item.material_info.Code}"
grid = item.grid
tip = item.tip
lines.append(f'def {fn}(name: str) -> PRCXI9300TipRack:')
lines.append(f' """')
for dl in doc.split('\n'):
lines.append(f' {dl}')
lines.append(f' """')
lines.append(f' return PRCXI9300TipRack(')
lines.append(f' name=name,')
lines.append(f' size_x={item.size_x},')
lines.append(f' size_y={item.size_y},')
lines.append(f' size_z={item.size_z},')
lines.append(f' model="{item.model}",')
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))},')
if grid and tip:
lines.append(f' ordered_items=create_ordered_items_2d(')
lines.append(f' TipSpot,')
lines.append(f' num_items_x={grid.num_items_x},')
lines.append(f' num_items_y={grid.num_items_y},')
lines.append(f' dx={grid.dx},')
lines.append(f' dy={grid.dy},')
lines.append(f' dz={grid.dz},')
lines.append(f' item_dx={grid.item_dx},')
lines.append(f' item_dy={grid.item_dy},')
lines.append(f' size_x={tip.spot_size_x},')
lines.append(f' size_y={tip.spot_size_y},')
lines.append(f' size_z={tip.spot_size_z},')
lines.append(f' make_tip=lambda: _make_tip_helper(volume={tip.tip_volume}, length={tip.tip_length}, depth={tip.tip_fitting_depth})')
lines.append(f' )')
lines.append(f' )')
return '\n'.join(lines)
def _gen_trash(item: LabwareItem) -> str:
"""生成 Trash 工厂函数代码。"""
lines = []
fn = item.function_name
doc = item.docstring or f"Code: {item.material_info.Code}"
lines.append(f'def {fn}(name: str = "trash") -> PRCXI9300Trash:')
lines.append(f' """')
for dl in doc.split('\n'):
lines.append(f' {dl}')
lines.append(f' """')
lines.append(f' return PRCXI9300Trash(')
lines.append(f' name="trash",')
lines.append(f' size_x={item.size_x},')
lines.append(f' size_y={item.size_y},')
lines.append(f' size_z={item.size_z},')
lines.append(f' category="trash",')
lines.append(f' model="{item.model}",')
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))}')
lines.append(f' )')
return '\n'.join(lines)
def _gen_tube_rack(item: LabwareItem) -> str:
"""生成 TubeRack 工厂函数代码。"""
lines = []
fn = item.function_name
doc = item.docstring or f"Code: {item.material_info.Code}"
grid = item.grid
tube = item.tube
lines.append(f'def {fn}(name: str) -> PRCXI9300TubeRack:')
lines.append(f' """')
for dl in doc.split('\n'):
lines.append(f' {dl}')
lines.append(f' """')
lines.append(f' return PRCXI9300TubeRack(')
lines.append(f' name=name,')
lines.append(f' size_x={item.size_x},')
lines.append(f' size_y={item.size_y},')
lines.append(f' size_z={item.size_z},')
lines.append(f' model="{item.model}",')
lines.append(f' category="tube_rack",')
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))},')
if grid and tube:
lines.append(f' ordered_items=create_ordered_items_2d(')
lines.append(f' Tube,')
lines.append(f' num_items_x={grid.num_items_x},')
lines.append(f' num_items_y={grid.num_items_y},')
lines.append(f' dx={grid.dx},')
lines.append(f' dy={grid.dy},')
lines.append(f' dz={grid.dz},')
lines.append(f' item_dx={grid.item_dx},')
lines.append(f' item_dy={grid.item_dy},')
lines.append(f' size_x={tube.size_x},')
lines.append(f' size_y={tube.size_y},')
lines.append(f' size_z={tube.size_z},')
lines.append(f' max_volume={tube.max_volume}')
lines.append(f' )')
lines.append(f' )')
return '\n'.join(lines)
def _gen_plate_adapter(item: LabwareItem) -> str:
"""生成 PlateAdapter 工厂函数代码。"""
lines = []
fn = item.function_name
doc = item.docstring or f"Code: {item.material_info.Code}"
lines.append(f'def {fn}(name: str) -> PRCXI9300PlateAdapter:')
lines.append(f' """ {doc} """')
lines.append(f' return PRCXI9300PlateAdapter(')
lines.append(f' name=name,')
lines.append(f' size_x={item.size_x},')
lines.append(f' size_y={item.size_y},')
lines.append(f' size_z={item.size_z},')
if item.model:
lines.append(f' model="{item.model}",')
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))}')
lines.append(f' )')
return '\n'.join(lines)
def _fmt_dict(d: dict) -> str:
"""格式化字典为 Python 代码片段。"""
parts = []
for k, v in d.items():
if isinstance(v, str):
parts.append(f'"{k}": "{v}"')
elif v is None:
continue
else:
parts.append(f'"{k}": {v}')
return '{' + ', '.join(parts) + '}'
def _gen_template_factory_kinds(items: List[LabwareItem]) -> str:
"""生成 PRCXI_TEMPLATE_FACTORY_KINDS 列表。"""
lines = ['PRCXI_TEMPLATE_FACTORY_KINDS: List[Tuple[Callable[..., Any], str]] = [']
for item in items:
if item.include_in_template_matching and item.template_kind:
lines.append(f' ({item.function_name}, "{item.template_kind}"),')
lines.append(']')
return '\n'.join(lines)
def _gen_footer() -> str:
"""生成文件尾部的模板相关代码。"""
return '''
# ---------------------------------------------------------------------------
# 协议上传 / workflow 用:与设备端耗材字典字段对齐的模板描述(供 common 自动匹配)
# ---------------------------------------------------------------------------
_PRCXI_TEMPLATE_SPECS_CACHE: Optional[List[Dict[str, Any]]] = None
def _probe_prcxi_resource(factory: Callable[..., Any]) -> Any:
probe = "__unilab_template_probe__"
if factory.__name__ == "PRCXI_trash":
return factory()
return factory(probe)
def _first_child_capacity_for_match(resource: Any) -> float:
"""Well max_volume 或 Tip 的 maximal_volume用于与设备端 Volume 类似的打分。"""
ch = getattr(resource, "children", None) or []
if not ch:
return 0.0
c0 = ch[0]
mv = getattr(c0, "max_volume", None)
if mv is not None:
return float(mv)
tip = getattr(c0, "tip", None)
if tip is not None:
mv2 = getattr(tip, "maximal_volume", None)
if mv2 is not None:
return float(mv2)
return 0.0
def get_prcxi_labware_template_specs() -> List[Dict[str, Any]]:
"""返回与 ``prcxi._match_and_create_matrix`` 中耗材字段兼容的模板列表,用于按孔数+容量打分。"""
global _PRCXI_TEMPLATE_SPECS_CACHE
if _PRCXI_TEMPLATE_SPECS_CACHE is not None:
return _PRCXI_TEMPLATE_SPECS_CACHE
out: List[Dict[str, Any]] = []
for factory, kind in PRCXI_TEMPLATE_FACTORY_KINDS:
try:
r = _probe_prcxi_resource(factory)
except Exception:
continue
nx = int(getattr(r, "num_items_x", None) or 0)
ny = int(getattr(r, "num_items_y", None) or 0)
nchild = len(getattr(r, "children", []) or [])
hole_count = nx * ny if nx > 0 and ny > 0 else nchild
hole_row = ny if nx > 0 and ny > 0 else 0
hole_col = nx if nx > 0 and ny > 0 else 0
mi = getattr(r, "material_info", None) or {}
vol = _first_child_capacity_for_match(r)
menum = mi.get("materialEnum")
if menum is None and kind == "tip_rack":
menum = 1
elif menum is None and kind == "trash":
menum = 6
out.append(
{
"class_name": factory.__name__,
"kind": kind,
"materialEnum": menum,
"HoleRow": hole_row,
"HoleColum": hole_col,
"Volume": vol,
"hole_count": hole_count,
"material_uuid": mi.get("uuid"),
"material_code": mi.get("Code"),
}
)
_PRCXI_TEMPLATE_SPECS_CACHE = out
return out
'''
def generate_code(db: LabwareDB, test_mode: bool = True) -> Path:
"""生成 prcxi_labware.py (或 _test.py),返回输出文件路径。"""
suffix = "_test" if test_mode else ""
out_path = _TARGET_DIR / f"prcxi_labware{suffix}.py"
# 备份
if out_path.exists():
bak = out_path.with_suffix(".py.bak")
shutil.copy2(out_path, bak)
# 按类型分组的生成器
generators = {
"plate": _gen_plate,
"tip_rack": _gen_tip_rack,
"trash": _gen_trash,
"tube_rack": _gen_tube_rack,
"plate_adapter": _gen_plate_adapter,
}
# 按 type 分段
sections = {
"plate": [],
"tip_rack": [],
"trash": [],
"tube_rack": [],
"plate_adapter": [],
}
for item in db.items:
gen = generators.get(item.type)
if gen:
sections[item.type].append(gen(item))
# 组装完整文件
parts = [_HEADER]
section_titles = {
"plate": "# =========================================================================\n# Plates\n# =========================================================================",
"tip_rack": "# =========================================================================\n# Tip Racks\n# =========================================================================",
"trash": "# =========================================================================\n# Trash\n# =========================================================================",
"tube_rack": "# =========================================================================\n# Tube Racks\n# =========================================================================",
"plate_adapter": "# =========================================================================\n# Plate Adapters\n# =========================================================================",
}
for type_key in ["plate", "tip_rack", "trash", "tube_rack", "plate_adapter"]:
if sections[type_key]:
parts.append(section_titles[type_key])
for code in sections[type_key]:
parts.append(code)
# Template factory kinds
parts.append("")
parts.append(_gen_template_factory_kinds(db.items))
# Footer
parts.append(_gen_footer())
content = '\n'.join(parts)
out_path.write_text(content, encoding="utf-8")
return out_path
if __name__ == "__main__":
from unilabos.labware_manager.importer import load_db
db = load_db()
if not db.items:
print("labware_db.json 为空,请先运行 importer.py")
else:
out = generate_code(db, test_mode=True)
print(f"已生成 {out} ({len(db.items)} 个工厂函数)")

View File

@@ -1,474 +0,0 @@
"""从现有 prcxi_labware.py + registry YAML 导入耗材数据到 labware_db.json。
策略:
1. 实例化每个工厂函数 → 提取物理尺寸、material_info、children
2. AST 解析源码 → 提取 docstring、volume_function 参数、plate_type
3. 从 children[0].location 反推 dx/dy/dz相邻位置差推 item_dx/item_dy
4. 同时读取现有 YAML → 提取 registry_category / description
"""
from __future__ import annotations
import ast
import json
import os
import re
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import yaml
# 将项目根目录加入 sys.path 以便 import
_PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(_PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(_PROJECT_ROOT))
from unilabos.labware_manager.models import (
AdapterInfo,
GridInfo,
LabwareDB,
LabwareItem,
MaterialInfo,
TipInfo,
TubeInfo,
VolumeFunctions,
WellInfo,
)
# ---------- 路径常量 ----------
_LABWARE_PY = Path(__file__).resolve().parents[1] / "devices" / "liquid_handling" / "prcxi" / "prcxi_labware.py"
_REGISTRY_DIR = Path(__file__).resolve().parents[1] / "registry" / "resources" / "prcxi"
_DB_PATH = Path(__file__).resolve().parent / "labware_db.json"
# YAML 文件名 → type 映射
_YAML_MAP: Dict[str, str] = {
"plates.yaml": "plate",
"tip_racks.yaml": "tip_rack",
"trash.yaml": "trash",
"tube_racks.yaml": "tube_rack",
"plate_adapters.yaml": "plate_adapter",
}
# PRCXI_TEMPLATE_FACTORY_KINDS 中列出的函数名include_in_template_matching=True
_TEMPLATE_FACTORY_NAMES = {
"PRCXI_BioER_96_wellplate", "PRCXI_nest_1_troughplate",
"PRCXI_BioRad_384_wellplate", "PRCXI_AGenBio_4_troughplate",
"PRCXI_nest_12_troughplate", "PRCXI_CellTreat_96_wellplate",
"PRCXI_10ul_eTips", "PRCXI_300ul_Tips",
"PRCXI_PCR_Plate_200uL_nonskirted", "PRCXI_PCR_Plate_200uL_semiskirted",
"PRCXI_PCR_Plate_200uL_skirted", "PRCXI_trash",
"PRCXI_96_DeepWell", "PRCXI_EP_Adapter",
"PRCXI_1250uL_Tips", "PRCXI_10uL_Tips",
"PRCXI_1000uL_Tips", "PRCXI_200uL_Tips",
"PRCXI_48_DeepWell",
}
# template_kind 对应
_TEMPLATE_KINDS: Dict[str, str] = {
"PRCXI_BioER_96_wellplate": "plate",
"PRCXI_nest_1_troughplate": "plate",
"PRCXI_BioRad_384_wellplate": "plate",
"PRCXI_AGenBio_4_troughplate": "plate",
"PRCXI_nest_12_troughplate": "plate",
"PRCXI_CellTreat_96_wellplate": "plate",
"PRCXI_10ul_eTips": "tip_rack",
"PRCXI_300ul_Tips": "tip_rack",
"PRCXI_PCR_Plate_200uL_nonskirted": "plate",
"PRCXI_PCR_Plate_200uL_semiskirted": "plate",
"PRCXI_PCR_Plate_200uL_skirted": "plate",
"PRCXI_trash": "trash",
"PRCXI_96_DeepWell": "plate",
"PRCXI_EP_Adapter": "tube_rack",
"PRCXI_1250uL_Tips": "tip_rack",
"PRCXI_10uL_Tips": "tip_rack",
"PRCXI_1000uL_Tips": "tip_rack",
"PRCXI_200uL_Tips": "tip_rack",
"PRCXI_48_DeepWell": "plate",
}
def _load_registry_info() -> Dict[str, Dict[str, Any]]:
"""读取所有 registry YAML 文件,返回 {function_name: {category, description}} 映射。"""
info: Dict[str, Dict[str, Any]] = {}
for fname, ltype in _YAML_MAP.items():
fpath = _REGISTRY_DIR / fname
if not fpath.exists():
continue
with open(fpath, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
for func_name, entry in data.items():
info[func_name] = {
"registry_category": entry.get("category", ["prcxi", ltype.replace("plate_adapter", "plate_adapters")]),
"registry_description": entry.get("description", ""),
}
return info
def _parse_ast_info() -> Dict[str, Dict[str, Any]]:
"""AST 解析 prcxi_labware.py提取每个工厂函数的 docstring 和 volume_function 参数。"""
source = _LABWARE_PY.read_text(encoding="utf-8")
tree = ast.parse(source)
result: Dict[str, Dict[str, Any]] = {}
for node in ast.walk(tree):
if not isinstance(node, ast.FunctionDef):
continue
fname = node.name
if not fname.startswith("PRCXI_"):
continue
if fname.startswith("_"):
continue
info: Dict[str, Any] = {"docstring": "", "volume_functions": None, "plate_type": None}
# docstring
doc = ast.get_docstring(node)
if doc:
info["docstring"] = doc.strip()
# 搜索函数体中的 plate_type 赋值和 volume_function 参数
func_source = ast.get_source_segment(source, node) or ""
# plate_type
m = re.search(r'plate_type\s*=\s*["\']([^"\']+)["\']', func_source)
if m:
info["plate_type"] = m.group(1)
# volume_functions: 检查 compute_height_from_volume_rectangle
if "compute_height_from_volume_rectangle" in func_source:
# 提取 well_length 和 well_width
vf: Dict[str, Any] = {"type": "rectangle"}
# 尝试从 lambda 中提取
wl_match = re.search(r'well_length\s*=\s*([\w_.]+)', func_source)
ww_match = re.search(r'well_width\s*=\s*([\w_.]+)', func_source)
if wl_match:
vf["well_length_var"] = wl_match.group(1)
if ww_match:
vf["well_width_var"] = ww_match.group(1)
info["volume_functions"] = vf
result[fname] = info
return result
def _probe_factory(factory_func) -> Any:
"""实例化工厂函数获取 resource 对象。"""
if factory_func.__name__ == "PRCXI_trash":
return factory_func()
return factory_func("__probe__")
def _get_size(resource, attr: str) -> float:
"""获取 PLR Resource 的尺寸(兼容 size_x 和 _size_x"""
val = getattr(resource, attr, None)
if val is None:
val = getattr(resource, f"_{attr}", None)
if val is None:
val = getattr(resource, f"get_{attr}", lambda: 0)()
return float(val or 0)
def _extract_grid_from_children(resource) -> Optional[Dict[str, Any]]:
"""从 resource.children 提取网格信息。"""
children = getattr(resource, "children", None) or []
if not children:
return None
# 获取 num_items_x, num_items_y
num_x = getattr(resource, "num_items_x", None)
num_y = getattr(resource, "num_items_y", None)
if num_x is None or num_y is None:
return None
c0 = children[0]
loc0 = getattr(c0, "location", None)
dx = loc0.x if loc0 else 0.0
dy_raw = loc0.y if loc0 else 0.0 # 这是 PLR 布局后的位置,不是输入参数
dz = loc0.z if loc0 else 0.0
# 推算 item_dx, item_dy
item_dx = 9.0
item_dy = 9.0
if len(children) > 1:
c1 = children[1]
loc1 = getattr(c1, "location", None)
if loc1 and loc0:
diff_x = abs(loc1.x - loc0.x)
diff_y = abs(loc1.y - loc0.y)
if diff_x > 0.1:
item_dx = diff_x
if diff_y > 0.1:
item_dy = diff_y
# 如果 num_items_y > 1 且 num_items_x > 1, 找列间距
if int(num_y) > 1 and int(num_x) > 1 and len(children) >= int(num_y) + 1:
cn = children[int(num_y)]
locn = getattr(cn, "location", None)
if locn and loc0:
col_diff = abs(locn.x - loc0.x)
row_diff = abs(children[1].location.y - loc0.y) if len(children) > 1 else item_dy
if col_diff > 0.1:
item_dx = col_diff
if row_diff > 0.1:
item_dy = row_diff
# PLR create_ordered_items_2d 的 Y 轴排列是倒序的:
# child[0].y = dy_param + (num_y - 1) * item_dy (最上面一行)
# 因此反推原始 dy 参数:
dy = dy_raw - (int(num_y) - 1) * item_dy
return {
"num_items_x": int(num_x),
"num_items_y": int(num_y),
"dx": round(dx, 4),
"dy": round(dy, 4),
"dz": round(dz, 4),
"item_dx": round(item_dx, 4),
"item_dy": round(item_dy, 4),
}
def _extract_well_info(child) -> Dict[str, Any]:
"""从 Well/TipSpot/Tube 子对象提取信息。"""
# material_z_thickness 在 PLR 中如果未设置会抛 NotImplementedError
mzt = None
try:
mzt = child.material_z_thickness
except (NotImplementedError, AttributeError):
mzt = getattr(child, "_material_z_thickness", None)
return {
"size_x": round(_get_size(child, "size_x"), 4),
"size_y": round(_get_size(child, "size_y"), 4),
"size_z": round(_get_size(child, "size_z"), 4),
"max_volume": getattr(child, "max_volume", None),
"bottom_type": getattr(child, "bottom_type", None),
"cross_section_type": getattr(child, "cross_section_type", None),
"material_z_thickness": mzt,
}
def import_from_code() -> LabwareDB:
"""执行完整的导入流程,返回 LabwareDB 对象。"""
# 1. 加载 registry 信息
reg_info = _load_registry_info()
# 2. AST 解析源码
ast_info = _parse_ast_info()
# 3. 导入工厂模块(通过包路径避免 relative import 问题)
import importlib
mod = importlib.import_module("unilabos.devices.liquid_handling.prcxi.prcxi_labware")
# 4. 获取 PRCXI_TEMPLATE_FACTORY_KINDS 列出的函数
factory_kinds = getattr(mod, "PRCXI_TEMPLATE_FACTORY_KINDS", [])
template_func_names = {f.__name__ for f, _k in factory_kinds}
# 5. 收集所有 PRCXI_ 开头的工厂函数
all_factories: List[Tuple[str, Any]] = []
for attr_name in dir(mod):
if attr_name.startswith("PRCXI_") and not attr_name.startswith("_"):
obj = getattr(mod, attr_name)
if callable(obj) and not isinstance(obj, type):
all_factories.append((attr_name, obj))
# 按源码行号排序
all_factories.sort(key=lambda x: getattr(x[1], "__code__", None) and x[1].__code__.co_firstlineno or 0)
items: List[LabwareItem] = []
for func_name, factory in all_factories:
try:
resource = _probe_factory(factory)
except Exception as e:
print(f"跳过 {func_name}: {e}")
continue
# 确定类型
type_name = "plate"
class_name = type(resource).__name__
if "TipRack" in class_name:
type_name = "tip_rack"
elif "Trash" in class_name:
type_name = "trash"
elif "TubeRack" in class_name:
type_name = "tube_rack"
elif "PlateAdapter" in class_name:
type_name = "plate_adapter"
# material_info
state = getattr(resource, "_unilabos_state", {}) or {}
mat = state.get("Material", {})
mat_info = MaterialInfo(
uuid=mat.get("uuid", ""),
Code=mat.get("Code", ""),
Name=mat.get("Name", ""),
materialEnum=mat.get("materialEnum"),
SupplyType=mat.get("SupplyType"),
)
# AST 信息
ast_data = ast_info.get(func_name, {})
docstring = ast_data.get("docstring", "")
plate_type = ast_data.get("plate_type")
# Registry 信息
reg = reg_info.get(func_name, {})
registry_category = reg.get("registry_category", ["prcxi", _type_to_yaml_subcategory(type_name)])
registry_description = reg.get("registry_description", f'{mat_info.Name} (Code: {mat_info.Code})')
# 构建 item
item = LabwareItem(
id=func_name.lower().replace("prcxi_", "")[:8] or func_name[:8],
type=type_name,
function_name=func_name,
docstring=docstring,
size_x=round(_get_size(resource, "size_x"), 4),
size_y=round(_get_size(resource, "size_y"), 4),
size_z=round(_get_size(resource, "size_z"), 4),
model=getattr(resource, "model", None),
category=getattr(resource, "category", type_name),
plate_type=plate_type,
material_info=mat_info,
registry_category=registry_category,
registry_description=registry_description,
include_in_template_matching=func_name in template_func_names,
template_kind=_TEMPLATE_KINDS.get(func_name),
)
# 提取子项信息
children = getattr(resource, "children", None) or []
grid_data = _extract_grid_from_children(resource)
if type_name == "plate" and children:
if grid_data:
item.grid = GridInfo(**grid_data)
c0 = children[0]
well_data = _extract_well_info(c0)
bt = well_data.get("bottom_type")
if bt is not None:
bt = bt.name if hasattr(bt, "name") else str(bt)
else:
bt = "FLAT"
cst = well_data.get("cross_section_type")
if cst is not None:
cst = cst.name if hasattr(cst, "name") else str(cst)
else:
cst = "CIRCLE"
item.well = WellInfo(
size_x=well_data["size_x"],
size_y=well_data["size_y"],
size_z=well_data["size_z"],
max_volume=well_data.get("max_volume"),
bottom_type=bt,
cross_section_type=cst,
material_z_thickness=well_data.get("material_z_thickness"),
)
# volume_functions
vf = ast_data.get("volume_functions")
if vf:
# 需要实际获取 well 尺寸作为 volume_function 参数
item.volume_functions = VolumeFunctions(
type="rectangle",
well_length=well_data["size_x"],
well_width=well_data["size_y"],
)
elif type_name == "tip_rack" and children:
if grid_data:
item.grid = GridInfo(**grid_data)
c0 = children[0]
tip_obj = getattr(c0, "tip", None)
tip_volume = 300.0
tip_length = 60.0
tip_depth = 51.0
tip_filter = False
if tip_obj:
tip_volume = getattr(tip_obj, "maximal_volume", 300.0)
tip_length = getattr(tip_obj, "total_tip_length", 60.0)
tip_depth = getattr(tip_obj, "fitting_depth", 51.0)
tip_filter = getattr(tip_obj, "has_filter", False)
item.tip = TipInfo(
spot_size_x=round(_get_size(c0, "size_x"), 4),
spot_size_y=round(_get_size(c0, "size_y"), 4),
spot_size_z=round(_get_size(c0, "size_z"), 4),
tip_volume=tip_volume,
tip_length=tip_length,
tip_fitting_depth=tip_depth,
has_filter=tip_filter,
)
# 计算 tip_above_rack_length = tip_length - (size_z - dz)
if grid_data:
_dz = grid_data.get("dz", 0.0)
_above = tip_length - (item.size_z - _dz)
item.tip.tip_above_rack_length = round(_above, 4) if _above > 0 else None
elif type_name == "tube_rack" and children:
if grid_data:
item.grid = GridInfo(**grid_data)
c0 = children[0]
item.tube = TubeInfo(
size_x=round(_get_size(c0, "size_x"), 4),
size_y=round(_get_size(c0, "size_y"), 4),
size_z=round(_get_size(c0, "size_z"), 4),
max_volume=getattr(c0, "max_volume", 1500.0) or 1500.0,
)
elif type_name == "plate_adapter":
# 提取 adapter 参数
ahx = getattr(resource, "adapter_hole_size_x", 127.76)
ahy = getattr(resource, "adapter_hole_size_y", 85.48)
ahz = getattr(resource, "adapter_hole_size_z", 10.0)
adx = getattr(resource, "dx", None)
ady = getattr(resource, "dy", None)
adz = getattr(resource, "dz", 0.0)
item.adapter = AdapterInfo(
adapter_hole_size_x=ahx,
adapter_hole_size_y=ahy,
adapter_hole_size_z=ahz,
dx=adx,
dy=ady,
dz=adz,
)
items.append(item)
return LabwareDB(items=items)
def _type_to_yaml_subcategory(type_name: str) -> str:
mapping = {
"plate": "plates",
"tip_rack": "tip_racks",
"trash": "trash",
"tube_rack": "tube_racks",
"plate_adapter": "plate_adapters",
}
return mapping.get(type_name, type_name)
def save_db(db: LabwareDB, path: Optional[Path] = None) -> Path:
"""保存 LabwareDB 到 JSON 文件。"""
out = path or _DB_PATH
with open(out, "w", encoding="utf-8") as f:
json.dump(db.model_dump(), f, ensure_ascii=False, indent=2)
return out
def load_db(path: Optional[Path] = None) -> LabwareDB:
"""从 JSON 文件加载 LabwareDB。"""
src = path or _DB_PATH
if not src.exists():
return LabwareDB()
with open(src, "r", encoding="utf-8") as f:
return LabwareDB(**json.load(f))
if __name__ == "__main__":
db = import_from_code()
out = save_db(db)
print(f"已导入 {len(db.items)} 个耗材 → {out}")
for item in db.items:
print(f" [{item.type:14s}] {item.function_name}")

File diff suppressed because it is too large Load Diff

View File

@@ -1,126 +0,0 @@
"""Pydantic 数据模型,描述所有 PRCXI 耗材类型的 JSON 结构。"""
from __future__ import annotations
import uuid as _uuid
from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, Field
class MaterialInfo(BaseModel):
uuid: str = ""
Code: str = ""
Name: str = ""
materialEnum: Optional[int] = None
SupplyType: Optional[int] = None
class GridInfo(BaseModel):
"""孔位网格排列参数"""
num_items_x: int = 12
num_items_y: int = 8
dx: float = 0.0
dy: float = 0.0
dz: float = 0.0
item_dx: float = 9.0
item_dy: float = 9.0
class WellInfo(BaseModel):
"""孔参数 (Plate)"""
size_x: float = 8.0
size_y: float = 8.0
size_z: float = 10.0
max_volume: Optional[float] = None
bottom_type: str = "FLAT" # V / U / FLAT
cross_section_type: str = "CIRCLE" # CIRCLE / RECTANGLE
material_z_thickness: Optional[float] = None
class VolumeFunctions(BaseModel):
"""体积-高度计算函数参数 (矩形 well)"""
type: str = "rectangle"
well_length: float = 0.0
well_width: float = 0.0
class TipInfo(BaseModel):
"""枪头参数 (TipRack)"""
spot_size_x: float = 7.0
spot_size_y: float = 7.0
spot_size_z: float = 0.0
tip_volume: float = 300.0
tip_length: float = 60.0
tip_fitting_depth: float = 51.0
tip_above_rack_length: Optional[float] = None
has_filter: bool = False
class TubeInfo(BaseModel):
"""管参数 (TubeRack)"""
size_x: float = 10.6
size_y: float = 10.6
size_z: float = 40.0
max_volume: float = 1500.0
class AdapterInfo(BaseModel):
"""适配器参数 (PlateAdapter)"""
adapter_hole_size_x: float = 127.76
adapter_hole_size_y: float = 85.48
adapter_hole_size_z: float = 10.0
dx: Optional[float] = None
dy: Optional[float] = None
dz: float = 0.0
LabwareType = Literal["plate", "tip_rack", "trash", "tube_rack", "plate_adapter"]
class LabwareItem(BaseModel):
"""一个耗材条目的完整 JSON 表示"""
id: str = Field(default_factory=lambda: _uuid.uuid4().hex[:8])
type: LabwareType = "plate"
function_name: str = ""
docstring: str = ""
# 物理尺寸
size_x: float = 127.0
size_y: float = 85.0
size_z: float = 20.0
model: Optional[str] = None
category: Optional[str] = None
plate_type: Optional[str] = None # non-skirted / semi-skirted / skirted
# 材料信息
material_info: MaterialInfo = Field(default_factory=MaterialInfo)
# Registry 字段
registry_category: List[str] = Field(default_factory=lambda: ["prcxi", "plates"])
registry_description: str = ""
# Plate 特有
grid: Optional[GridInfo] = None
well: Optional[WellInfo] = None
volume_functions: Optional[VolumeFunctions] = None
# TipRack 特有
tip: Optional[TipInfo] = None
# TubeRack 特有
tube: Optional[TubeInfo] = None
# PlateAdapter 特有
adapter: Optional[AdapterInfo] = None
# 模板匹配
include_in_template_matching: bool = False
template_kind: Optional[str] = None
class LabwareDB(BaseModel):
"""整个 labware_db.json 的结构"""
version: str = "1.0"
items: List[LabwareItem] = Field(default_factory=list)

View File

@@ -1,292 +0,0 @@
/**
* form_handler.js — 动态表单逻辑 + 实时预览
*/
// 根据类型显示/隐藏对应的表单段
function onTypeChange() {
const type = document.getElementById('f-type').value;
const sections = {
grid: ['plate', 'tip_rack', 'tube_rack'],
well: ['plate'],
tip: ['tip_rack'],
tube: ['tube_rack'],
adapter: ['plate_adapter'],
};
for (const [sec, types] of Object.entries(sections)) {
const el = document.getElementById('section-' + sec);
if (el) el.style.display = types.includes(type) ? 'block' : 'none';
}
// plate_type 行只对 plate 显示
const ptRow = document.getElementById('row-plate_type');
if (ptRow) ptRow.style.display = type === 'plate' ? 'block' : 'none';
updatePreview();
}
// 从表单收集数据
function collectFormData() {
const g = id => {
const el = document.getElementById(id);
if (!el) return null;
if (el.type === 'checkbox') return el.checked;
if (el.type === 'number') return el.value === '' ? null : parseFloat(el.value);
return el.value || null;
};
const type = g('f-type');
const data = {
type: type,
function_name: g('f-function_name') || 'PRCXI_new',
model: g('f-model'),
docstring: g('f-docstring') || '',
plate_type: type === 'plate' ? g('f-plate_type') : null,
size_x: g('f-size_x') || 127,
size_y: g('f-size_y') || 85,
size_z: g('f-size_z') || 20,
material_info: {
uuid: g('f-mi_uuid') || '',
Code: g('f-mi_code') || '',
Name: g('f-mi_name') || '',
materialEnum: g('f-mi_menum'),
SupplyType: g('f-mi_stype'),
},
registry_category: (g('f-reg_cat') || 'prcxi,plates').split(',').map(s => s.trim()),
registry_description: g('f-reg_desc') || '',
include_in_template_matching: g('f-in_tpl') || false,
template_kind: g('f-tpl_kind') || null,
grid: null,
well: null,
tip: null,
tube: null,
adapter: null,
volume_functions: null,
};
// Grid
if (['plate', 'tip_rack', 'tube_rack'].includes(type)) {
data.grid = {
num_items_x: g('f-grid_nx') || 12,
num_items_y: g('f-grid_ny') || 8,
dx: g('f-grid_dx') || 0,
dy: g('f-grid_dy') || 0,
dz: g('f-grid_dz') || 0,
item_dx: g('f-grid_idx') || 9,
item_dy: g('f-grid_idy') || 9,
};
}
// Well
if (type === 'plate') {
data.well = {
size_x: g('f-well_sx') || 8,
size_y: g('f-well_sy') || 8,
size_z: g('f-well_sz') || 10,
max_volume: g('f-well_vol'),
material_z_thickness: g('f-well_mzt'),
bottom_type: g('f-well_bt') || 'FLAT',
cross_section_type: g('f-well_cs') || 'CIRCLE',
};
if (g('f-has_vf')) {
data.volume_functions = {
type: 'rectangle',
well_length: data.well.size_x,
well_width: data.well.size_y,
};
}
}
// Tip
if (type === 'tip_rack') {
data.tip = {
spot_size_x: g('f-tip_sx') || 7,
spot_size_y: g('f-tip_sy') || 7,
spot_size_z: g('f-tip_sz') || 0,
tip_volume: g('f-tip_vol') || 300,
tip_length: g('f-tip_len') || 60,
tip_fitting_depth: g('f-tip_dep') || 51,
tip_above_rack_length: g('f-tip_above'),
has_filter: g('f-tip_filter') || false,
};
}
// Tube
if (type === 'tube_rack') {
data.tube = {
size_x: g('f-tube_sx') || 10.6,
size_y: g('f-tube_sy') || 10.6,
size_z: g('f-tube_sz') || 40,
max_volume: g('f-tube_vol') || 1500,
};
}
// Adapter
if (type === 'plate_adapter') {
data.adapter = {
adapter_hole_size_x: g('f-adp_hsx') || 127.76,
adapter_hole_size_y: g('f-adp_hsy') || 85.48,
adapter_hole_size_z: g('f-adp_hsz') || 10,
dx: g('f-adp_dx'),
dy: g('f-adp_dy'),
dz: g('f-adp_dz') || 0,
};
}
return data;
}
// 实时预览 (debounce)
let _previewTimer = null;
function updatePreview() {
if (_previewTimer) clearTimeout(_previewTimer);
_previewTimer = setTimeout(() => {
const data = collectFormData();
const topEl = document.getElementById('svg-topdown');
const sideEl = document.getElementById('svg-side');
if (topEl) renderTopDown(topEl, data);
if (sideEl) renderSideProfile(sideEl, data);
}, 200);
}
// 给所有表单元素绑定 input 事件
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('labware-form');
if (!form) return;
form.addEventListener('input', updatePreview);
form.addEventListener('change', updatePreview);
// tip_above_rack_length 与 dz 互算
// 公式: tip_length = tip_above_rack_length + size_z - dz
// 规则: 填 tip_above → 自动算 dz填 dz → 自动算 tip_above
// 改 size_z / tip_length → 优先重算 tip_above若有值否则算 dz
function _getVal(id) {
const el = document.getElementById(id);
return (el && el.value !== '') ? parseFloat(el.value) : null;
}
function _setVal(id, v) {
const el = document.getElementById(id);
if (el) el.value = Math.round(v * 1000) / 1000;
}
function autoCalcTipAbove(changedId) {
const typeEl = document.getElementById('f-type');
if (!typeEl || typeEl.value !== 'tip_rack') return;
const tipLen = _getVal('f-tip_len');
const sizeZ = _getVal('f-size_z');
const dz = _getVal('f-grid_dz');
const above = _getVal('f-tip_above');
// 需要 tip_length 和 size_z 才能计算
if (tipLen == null || sizeZ == null) return;
if (changedId === 'f-tip_above') {
// 用户填了 tip_above → 算 dz
if (above != null) _setVal('f-grid_dz', above + sizeZ - tipLen);
} else if (changedId === 'f-grid_dz') {
// 用户填了 dz → 算 tip_above
if (dz != null) _setVal('f-tip_above', tipLen - sizeZ + dz);
} else {
// size_z 或 tip_length 变了 → 优先重算 tip_above若已有值或 dz 已有值)
if (dz != null) {
_setVal('f-tip_above', tipLen - sizeZ + dz);
} else if (above != null) {
_setVal('f-grid_dz', above + sizeZ - tipLen);
}
}
}
// 绑定 input 事件
for (const id of ['f-tip_len', 'f-size_z', 'f-grid_dz', 'f-tip_above']) {
const el = document.getElementById(id);
if (el) el.addEventListener('input', () => autoCalcTipAbove(id));
}
// 编辑已有 tip_rack 条目时自动补算 tip_above_rack_length
const typeEl = document.getElementById('f-type');
if (typeEl && typeEl.value === 'tip_rack') {
autoCalcTipAbove('f-grid_dz');
}
});
// 自动居中:根据板尺寸和孔阵列参数计算 dx/dy
function autoCenter() {
const g = id => { const el = document.getElementById(id); return el && el.value !== '' ? parseFloat(el.value) : 0; };
const sizeX = g('f-size_x') || 127;
const sizeY = g('f-size_y') || 85;
const nx = g('f-grid_nx') || 1;
const ny = g('f-grid_ny') || 1;
const itemDx = g('f-grid_idx') || 9;
const itemDy = g('f-grid_idy') || 9;
// 根据当前耗材类型确定子元素尺寸
const type = document.getElementById('f-type').value;
let childSx = 0, childSy = 0;
if (type === 'plate') {
childSx = g('f-well_sx') || 8;
childSy = g('f-well_sy') || 8;
} else if (type === 'tip_rack') {
childSx = g('f-tip_sx') || 7;
childSy = g('f-tip_sy') || 7;
} else if (type === 'tube_rack') {
childSx = g('f-tube_sx') || 10.6;
childSy = g('f-tube_sy') || 10.6;
}
// dx = (板宽 - 孔阵列总占宽) / 2
const dx = (sizeX - (nx - 1) * itemDx - childSx) / 2;
const dy = (sizeY - (ny - 1) * itemDy - childSy) / 2;
const elDx = document.getElementById('f-grid_dx');
const elDy = document.getElementById('f-grid_dy');
if (elDx) elDx.value = Math.round(dx * 100) / 100;
if (elDy) elDy.value = Math.round(dy * 100) / 100;
updatePreview();
}
// 保存
function showMsg(text, ok) {
const el = document.getElementById('status-msg');
if (!el) return;
el.textContent = text;
el.className = 'status-msg ' + (ok ? 'msg-ok' : 'msg-err');
el.style.display = 'block';
setTimeout(() => el.style.display = 'none', 4000);
}
async function saveForm() {
const data = collectFormData();
let url, method;
if (typeof IS_NEW !== 'undefined' && IS_NEW) {
url = '/api/labware';
method = 'POST';
} else {
url = '/api/labware/' + (typeof ITEM_ID !== 'undefined' ? ITEM_ID : '');
method = 'PUT';
}
try {
const r = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
const d = await r.json();
if (d.status === 'ok') {
showMsg('保存成功', true);
if (IS_NEW) {
setTimeout(() => location.href = '/labware/' + data.function_name, 500);
}
} else {
showMsg('保存失败: ' + JSON.stringify(d), false);
}
} catch (e) {
showMsg('请求错误: ' + e.message, false);
}
}

View File

@@ -1,450 +0,0 @@
/**
* labware_viz.js — PRCXI 耗材 SVG 2D 可视化渲染引擎
*
* renderTopDown(container, itemData) — 俯视图
* renderSideProfile(container, itemData) — 侧面截面图
*/
const TYPE_COLORS = {
plate: '#3b82f6',
tip_rack: '#10b981',
tube_rack: '#f59e0b',
trash: '#ef4444',
plate_adapter: '#8b5cf6',
};
function _svgNS() { return 'http://www.w3.org/2000/svg'; }
function _makeSVG(w, h) {
const svg = document.createElementNS(_svgNS(), 'svg');
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
svg.setAttribute('width', '100%');
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
svg.style.background = '#fff';
return svg;
}
function _rect(svg, x, y, w, h, fill, stroke, rx) {
const r = document.createElementNS(_svgNS(), 'rect');
r.setAttribute('x', x); r.setAttribute('y', y);
r.setAttribute('width', w); r.setAttribute('height', h);
r.setAttribute('fill', fill || 'none');
r.setAttribute('stroke', stroke || '#333');
r.setAttribute('stroke-width', '0.5');
if (rx) r.setAttribute('rx', rx);
svg.appendChild(r);
return r;
}
function _circle(svg, cx, cy, r, fill, stroke) {
const c = document.createElementNS(_svgNS(), 'circle');
c.setAttribute('cx', cx); c.setAttribute('cy', cy);
c.setAttribute('r', r);
c.setAttribute('fill', fill || 'none');
c.setAttribute('stroke', stroke || '#333');
c.setAttribute('stroke-width', '0.4');
svg.appendChild(c);
return c;
}
function _text(svg, x, y, txt, size, anchor, fill) {
const t = document.createElementNS(_svgNS(), 'text');
t.setAttribute('x', x); t.setAttribute('y', y);
t.setAttribute('font-size', size || '3');
t.setAttribute('text-anchor', anchor || 'middle');
t.setAttribute('fill', fill || '#666');
t.setAttribute('font-family', 'sans-serif');
t.textContent = txt;
svg.appendChild(t);
return t;
}
function _line(svg, x1, y1, x2, y2, stroke, dash) {
const l = document.createElementNS(_svgNS(), 'line');
l.setAttribute('x1', x1); l.setAttribute('y1', y1);
l.setAttribute('x2', x2); l.setAttribute('y2', y2);
l.setAttribute('stroke', stroke || '#999');
l.setAttribute('stroke-width', '0.3');
if (dash) l.setAttribute('stroke-dasharray', dash);
svg.appendChild(l);
return l;
}
function _title(el, txt) {
const t = document.createElementNS(_svgNS(), 'title');
t.textContent = txt;
el.appendChild(t);
}
// ==================== 俯视图 ====================
function renderTopDown(container, data) {
container.innerHTML = '';
if (!data) return;
const pad = 18;
const sx = data.size_x || 127;
const sy = data.size_y || 85;
const w = sx + pad * 2;
const h = sy + pad * 2;
const svg = _makeSVG(w, h);
const color = TYPE_COLORS[data.type] || '#3b82f6';
const lightColor = color + '22';
// 板子外轮廓
_rect(svg, pad, pad, sx, sy, lightColor, color, 3);
// 尺寸标注
_text(svg, pad + sx / 2, pad - 4, `${sx} mm`, '3.5', 'middle', '#333');
// Y 尺寸 (竖直)
const yt = document.createElementNS(_svgNS(), 'text');
yt.setAttribute('x', pad - 5);
yt.setAttribute('y', pad + sy / 2);
yt.setAttribute('font-size', '3.5');
yt.setAttribute('text-anchor', 'middle');
yt.setAttribute('fill', '#333');
yt.setAttribute('font-family', 'sans-serif');
yt.setAttribute('transform', `rotate(-90, ${pad - 5}, ${pad + sy / 2})`);
yt.textContent = `${sy} mm`;
svg.appendChild(yt);
const grid = data.grid;
const well = data.well;
const tip = data.tip;
const tube = data.tube;
if (grid && (well || tip || tube)) {
const nx = grid.num_items_x || 1;
const ny = grid.num_items_y || 1;
const dx = grid.dx || 0;
const dy = grid.dy || 0;
const idx = grid.item_dx || 9;
const idy = grid.item_dy || 9;
const child = well || tip || tube;
const csx = child.size_x || child.spot_size_x || 8;
const csy = child.size_y || child.spot_size_y || 8;
const isCircle = well ? (well.cross_section_type === 'CIRCLE') : (!!tip);
// 行列标签
for (let col = 0; col < nx; col++) {
const cx = pad + dx + csx / 2 + col * idx;
_text(svg, cx, pad + sy + 5, String(col + 1), '2.5', 'middle', '#999');
}
const rowLabels = 'ABCDEFGHIJKLMNOP';
for (let row = 0; row < ny; row++) {
const cy = pad + dy + csy / 2 + row * idy;
_text(svg, pad - 4, cy + 1, rowLabels[row] || String(row), '2.5', 'middle', '#999');
}
// 绘制孔位
for (let col = 0; col < nx; col++) {
for (let row = 0; row < ny; row++) {
const cx = pad + dx + csx / 2 + col * idx;
const cy = pad + dy + csy / 2 + row * idy;
let el;
if (isCircle) {
const r = Math.min(csx, csy) / 2;
el = _circle(svg, cx, cy, r, '#fff', color);
} else {
el = _rect(svg, cx - csx / 2, cy - csy / 2, csx, csy, '#fff', color);
}
const label = (rowLabels[row] || '') + String(col + 1);
_title(el, `${label}: ${csx}x${csy} mm`);
// hover 效果
el.style.cursor = 'pointer';
el.addEventListener('mouseenter', () => el.setAttribute('fill', color + '44'));
el.addEventListener('mouseleave', () => el.setAttribute('fill', '#fff'));
}
}
// dx / dy 标注(板边到首个子元素左上角)
const dimColor = '#e67e22';
const firstLeft = pad + dx; // 首列子元素左边 X
const firstTop = pad + dy; // 首行子元素上边 Y
if (dx > 0.1) {
// dx: 板左边 → 首列子元素左边,画在第一行子元素中心高度
const annY = firstTop + csy / 2;
_line(svg, pad, annY, firstLeft, annY, dimColor, '1,1');
_line(svg, pad, annY - 2, pad, annY + 2, dimColor);
_line(svg, firstLeft, annY - 2, firstLeft, annY + 2, dimColor);
_text(svg, pad + dx / 2, annY - 2, `dx=${dx}`, '2.5', 'middle', dimColor);
}
if (dy > 0.1) {
// dy: 板上边 → 首行子元素上边,画在第一列子元素中心宽度
const annX = firstLeft + csx / 2;
_line(svg, annX, pad, annX, firstTop, dimColor, '1,1');
_line(svg, annX - 2, pad, annX + 2, pad, dimColor);
_line(svg, annX - 2, firstTop, annX + 2, firstTop, dimColor);
_text(svg, annX + 4, pad + dy / 2 + 1, `dy=${dy}`, '2.5', 'start', dimColor);
}
} else if (data.type === 'plate_adapter' && data.adapter) {
// 绘制适配器凹槽
const adp = data.adapter;
const ahx = adp.adapter_hole_size_x || 127;
const ahy = adp.adapter_hole_size_y || 85;
const adx = adp.dx != null ? adp.dx : (sx - ahx) / 2;
const ady = adp.dy != null ? adp.dy : (sy - ahy) / 2;
_rect(svg, pad + adx, pad + ady, ahx, ahy, '#f0f0ff', '#8b5cf6', 2);
_text(svg, pad + adx + ahx / 2, pad + ady + ahy / 2, `${ahx}x${ahy}`, '4', 'middle', '#8b5cf6');
} else if (data.type === 'trash') {
// 简单标记
_text(svg, pad + sx / 2, pad + sy / 2, 'TRASH', '8', 'middle', '#ef4444');
}
container.appendChild(svg);
_enableZoomPan(svg, `0 0 ${w} ${h}`);
}
// ==================== 侧面截面图 ====================
function renderSideProfile(container, data) {
container.innerHTML = '';
if (!data) return;
const pad = 20;
const sx = data.size_x || 127;
const sz = data.size_z || 20;
// 按比例缩放,侧面以 X-Z 面
const scaleH = Math.max(1, sz / 60); // 让较矮的板子不会太小
// 计算枪头露出高度(仅 tip_rack
const tip = data.tip;
const grid = data.grid;
let tipAbove = 0;
if (data.type === 'tip_rack' && tip) {
if (tip.tip_above_rack_length != null && tip.tip_above_rack_length > 0) {
tipAbove = tip.tip_above_rack_length;
} else if (tip.tip_length && grid) {
const dz = grid.dz || 0;
const calc = tip.tip_length - (sz - dz);
if (calc > 0) tipAbove = calc;
}
}
const drawW = sx;
const drawH = sz;
const w = drawW + pad * 2 + 30; // 额外空间给标注
const h = drawH + tipAbove + pad * 2 + 10;
const svg = _makeSVG(w, h);
const color = TYPE_COLORS[data.type] || '#3b82f6';
const baseY = pad + tipAbove + drawH; // 底部 Y
const rackTopY = pad + tipAbove; // rack 顶部 Y
// 板壳矩形
_rect(svg, pad, rackTopY, drawW, drawH, color + '15', color);
// 尺寸标注
// X 方向
_line(svg, pad, baseY + 5, pad + drawW, baseY + 5, '#333');
_text(svg, pad + drawW / 2, baseY + 12, `${sx} mm`, '3.5', 'middle', '#333');
// Z 方向
_line(svg, pad + drawW + 5, rackTopY, pad + drawW + 5, baseY, '#333');
const zt = document.createElementNS(_svgNS(), 'text');
zt.setAttribute('x', pad + drawW + 12);
zt.setAttribute('y', rackTopY + drawH / 2);
zt.setAttribute('font-size', '3.5');
zt.setAttribute('text-anchor', 'middle');
zt.setAttribute('fill', '#333');
zt.setAttribute('font-family', 'sans-serif');
zt.setAttribute('transform', `rotate(-90, ${pad + drawW + 12}, ${rackTopY + drawH / 2})`);
zt.textContent = `${sz} mm`;
svg.appendChild(zt);
const well = data.well;
const tube = data.tube;
if (grid && (well || tip || tube)) {
const dx = grid.dx || 0;
const dz = grid.dz || 0;
const idx = grid.item_dx || 9;
const nx = Math.min(grid.num_items_x || 1, 24); // 最多画24列
const dimColor = '#e67e22';
const child = well || tube;
const childTip = tip;
if (child) {
const csx = child.size_x || 8;
const csz = child.size_z || 10;
const bt = well ? (well.bottom_type || 'FLAT') : 'FLAT';
// 画几个代表性的孔截面
const nDraw = Math.min(nx, 12);
for (let i = 0; i < nDraw; i++) {
const cx = pad + dx + csx / 2 + i * idx;
const topZ = baseY - dz - csz;
const botZ = baseY - dz;
// 孔壁
_rect(svg, cx - csx / 2, topZ, csx, csz, '#e0e8ff', color, 0.5);
// 底部形状
if (bt === 'V') {
// V 底 三角
const triH = Math.min(csx / 2, csz * 0.3);
const p = document.createElementNS(_svgNS(), 'polygon');
p.setAttribute('points',
`${cx - csx / 2},${botZ - triH} ${cx},${botZ} ${cx + csx / 2},${botZ - triH}`);
p.setAttribute('fill', color + '33');
p.setAttribute('stroke', color);
p.setAttribute('stroke-width', '0.3');
svg.appendChild(p);
} else if (bt === 'U') {
// U 底 圆弧
const arcR = csx / 2;
const p = document.createElementNS(_svgNS(), 'path');
p.setAttribute('d', `M ${cx - csx / 2} ${botZ - arcR} A ${arcR} ${arcR} 0 0 0 ${cx + csx / 2} ${botZ - arcR}`);
p.setAttribute('fill', color + '33');
p.setAttribute('stroke', color);
p.setAttribute('stroke-width', '0.3');
svg.appendChild(p);
}
}
// dz 标注
if (dz > 0) {
const lx = pad + dx + 0.5 * idx * nDraw + csx / 2 + 5;
_line(svg, lx, baseY, lx, baseY - dz, dimColor, '1,1');
_line(svg, lx - 2, baseY, lx + 2, baseY, dimColor);
_line(svg, lx - 2, baseY - dz, lx + 2, baseY - dz, dimColor);
_text(svg, lx + 4, baseY - dz / 2 + 1, `dz=${dz}`, '2.5', 'start', dimColor);
}
// dx 标注
if (dx > 0.1) {
const annY = rackTopY + 4;
_line(svg, pad, annY, pad + dx, annY, dimColor, '1,1');
_line(svg, pad, annY - 2, pad, annY + 2, dimColor);
_line(svg, pad + dx, annY - 2, pad + dx, annY + 2, dimColor);
_text(svg, pad + dx / 2, annY - 2, `dx=${dx}`, '2.5', 'middle', dimColor);
}
}
if (childTip) {
// 枪头截面
const tipLen = childTip.tip_length || 50;
const nDraw = Math.min(nx, 12);
for (let i = 0; i < nDraw; i++) {
const cx = pad + dx + 3.5 + i * idx;
// 枪头顶部 = rack顶部 - 露出长度
const tipTopZ = rackTopY - tipAbove;
const drawLen = Math.min(tipLen, sz - dz + tipAbove);
// 枪头轮廓 (梯形)
const topW = 4;
const botW = 1.5;
const p = document.createElementNS(_svgNS(), 'polygon');
p.setAttribute('points',
`${cx - topW / 2},${tipTopZ} ${cx + topW / 2},${tipTopZ} ${cx + botW / 2},${tipTopZ + drawLen} ${cx - botW / 2},${tipTopZ + drawLen}`);
p.setAttribute('fill', '#10b98133');
p.setAttribute('stroke', '#10b981');
p.setAttribute('stroke-width', '0.3');
svg.appendChild(p);
}
// dz 标注
if (dz > 0) {
const lx = pad + dx + nDraw * idx + 5;
_line(svg, lx, baseY, lx, baseY - dz, dimColor, '1,1');
_line(svg, lx - 2, baseY, lx + 2, baseY, dimColor);
_line(svg, lx - 2, baseY - dz, lx + 2, baseY - dz, dimColor);
_text(svg, lx + 4, baseY - dz / 2 + 1, `dz=${dz}`, '2.5', 'start', dimColor);
}
// dx 标注
if (dx > 0.1) {
const annY = rackTopY + 4;
_line(svg, pad, annY, pad + dx, annY, dimColor, '1,1');
_line(svg, pad, annY - 2, pad, annY + 2, dimColor);
_line(svg, pad + dx, annY - 2, pad + dx, annY + 2, dimColor);
_text(svg, pad + dx / 2, annY - 2, `dx=${dx}`, '2.5', 'middle', dimColor);
}
// 露出长度标注线
if (tipAbove > 0) {
const annotX = pad + dx + nDraw * idx + 8;
// rack 顶部水平参考线
_line(svg, annotX - 3, rackTopY, annotX + 3, rackTopY, '#10b981');
// 枪头顶部水平参考线
_line(svg, annotX - 3, rackTopY - tipAbove, annotX + 3, rackTopY - tipAbove, '#10b981');
// 竖直标注线
_line(svg, annotX, rackTopY - tipAbove, annotX, rackTopY, '#10b981', '1,1');
_text(svg, annotX + 2, rackTopY - tipAbove / 2 + 1, `露出=${Math.round(tipAbove * 100) / 100}mm`, '2.5', 'start', '#10b981');
}
}
} else if (data.type === 'plate_adapter' && data.adapter) {
const adp = data.adapter;
const ahz = adp.adapter_hole_size_z || 10;
const adz = adp.dz || 0;
const adx_val = adp.dx != null ? adp.dx : (sx - (adp.adapter_hole_size_x || 127)) / 2;
const ahx = adp.adapter_hole_size_x || 127;
// 凹槽截面
_rect(svg, pad + adx_val, rackTopY + adz, ahx, ahz, '#ede9fe', '#8b5cf6');
_text(svg, pad + adx_val + ahx / 2, rackTopY + adz + ahz / 2 + 1, `hole: ${ahz}mm deep`, '3', 'middle', '#8b5cf6');
} else if (data.type === 'trash') {
_text(svg, pad + drawW / 2, rackTopY + drawH / 2, 'TRASH', '8', 'middle', '#ef4444');
}
container.appendChild(svg);
_enableZoomPan(svg, `0 0 ${w} ${h}`);
}
// ==================== 缩放 & 平移 ====================
function _enableZoomPan(svgEl, origViewBox) {
const parts = origViewBox.split(' ').map(Number);
let vx = parts[0], vy = parts[1], vw = parts[2], vh = parts[3];
const origVx = vx, origVy = vy, origW = vw, origH = vh;
const MIN_SCALE = 0.5, MAX_SCALE = 5;
function applyViewBox() {
svgEl.setAttribute('viewBox', `${vx} ${vy} ${vw} ${vh}`);
}
function resetView() {
vx = origVx; vy = origVy; vw = origW; vh = origH;
applyViewBox();
}
// 将 resetView 挂到 svg 元素上,方便外部调用
svgEl._resetView = resetView;
svgEl.addEventListener('wheel', function (e) {
e.preventDefault();
if (e.ctrlKey) {
// pinch / ctrl+scroll → 缩放
const factor = e.deltaY > 0 ? 1.08 : 1 / 1.08;
const newW = vw * factor;
const newH = vh * factor;
// 限制缩放范围
if (newW < origW / MAX_SCALE || newW > origW * (1 / MIN_SCALE)) return;
// 以鼠标位置为缩放中心
const rect = svgEl.getBoundingClientRect();
const mx = (e.clientX - rect.left) / rect.width;
const my = (e.clientY - rect.top) / rect.height;
vx += (vw - newW) * mx;
vy += (vh - newH) * my;
vw = newW;
vh = newH;
} else {
// 普通滚轮 → 平移
const panSpeed = vw * 0.002;
vx += e.deltaX * panSpeed;
vy += e.deltaY * panSpeed;
}
applyViewBox();
}, { passive: false });
}
// 回中按钮:重置指定容器内 SVG 的 viewBox
function resetSvgView(containerId) {
const container = document.getElementById(containerId);
if (!container) return;
const svg = container.querySelector('svg');
if (svg && svg._resetView) svg._resetView();
}

View File

@@ -1,295 +0,0 @@
/* PRCXI 耗材管理 - 全局样式 */
:root {
--c-primary: #3b82f6;
--c-primary-dark: #2563eb;
--c-danger: #ef4444;
--c-warning: #f59e0b;
--c-success: #10b981;
--c-gray-50: #f9fafb;
--c-gray-100: #f3f4f6;
--c-gray-200: #e5e7eb;
--c-gray-300: #d1d5db;
--c-gray-500: #6b7280;
--c-gray-700: #374151;
--c-gray-900: #111827;
--radius: 8px;
--shadow: 0 1px 3px rgba(0,0,0,0.1);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--c-gray-50);
color: var(--c-gray-900);
line-height: 1.5;
}
/* 顶部导航 */
.topbar {
background: #fff;
border-bottom: 1px solid var(--c-gray-200);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
height: 56px;
position: sticky;
top: 0;
z-index: 100;
}
.topbar .logo {
font-weight: 700;
font-size: 1.1rem;
color: var(--c-gray-900);
text-decoration: none;
}
/* 容器 */
.container {
max-width: 1400px;
margin: 0 auto;
padding: 24px;
}
/* 页头 */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 12px;
}
.page-header h1 { font-size: 1.5rem; }
.header-actions { display: flex; gap: 8px; flex-wrap: wrap; }
/* 按钮 */
.btn {
display: inline-flex;
align-items: center;
padding: 8px 16px;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
text-decoration: none;
transition: all 0.15s;
white-space: nowrap;
}
.btn-sm { padding: 4px 10px; font-size: 0.8rem; }
.btn-primary { background: var(--c-primary); color: #fff; }
.btn-primary:hover { background: var(--c-primary-dark); }
.btn-outline { background: #fff; color: var(--c-gray-700); border-color: var(--c-gray-300); }
.btn-outline:hover { background: var(--c-gray-100); }
.btn-danger { background: var(--c-danger); color: #fff; }
.btn-danger:hover { background: #dc2626; }
.btn-warning { background: var(--c-warning); color: #fff; }
.btn-warning:hover { background: #d97706; }
/* 徽章 */
.badge {
background: var(--c-gray-200);
color: var(--c-gray-700);
font-size: 0.8rem;
padding: 2px 8px;
border-radius: 12px;
font-weight: 500;
margin-left: 4px;
}
/* 状态消息 */
.status-msg {
padding: 12px 16px;
border-radius: var(--radius);
margin-bottom: 16px;
font-size: 0.9rem;
}
.msg-ok { background: #d1fae5; color: #065f46; }
.msg-err { background: #fee2e2; color: #991b1b; }
/* 类型分段 */
.type-section { margin-bottom: 32px; }
.type-section h2 {
font-size: 1.1rem;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.type-dot {
width: 12px;
height: 12px;
border-radius: 50%;
display: inline-block;
}
/* 卡片网格 */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 12px;
}
/* 耗材卡片 */
.labware-card {
background: #fff;
border: 1px solid var(--c-gray-200);
border-radius: var(--radius);
padding: 16px;
cursor: pointer;
transition: box-shadow 0.2s, border-color 0.2s;
}
.labware-card:hover {
border-color: var(--c-primary);
box-shadow: 0 4px 12px rgba(59,130,246,0.15);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.card-title {
font-weight: 600;
font-size: 0.9rem;
color: var(--c-gray-900);
word-break: break-all;
}
.card-body { font-size: 0.85rem; color: var(--c-gray-500); }
.card-info { margin-bottom: 2px; }
.card-info .label { color: var(--c-gray-700); font-weight: 500; }
.card-footer {
margin-top: 12px;
display: flex;
gap: 8px;
border-top: 1px solid var(--c-gray-100);
padding-top: 10px;
}
/* 标签 */
.tag {
font-size: 0.7rem;
padding: 2px 6px;
border-radius: 4px;
font-weight: 600;
text-transform: uppercase;
}
.tag-tpl { background: #dbeafe; color: #1e40af; }
.tag-plate { background: #dbeafe; color: #1e40af; }
.tag-tip_rack { background: #d1fae5; color: #065f46; }
.tag-trash { background: #fee2e2; color: #991b1b; }
.tag-tube_rack { background: #fef3c7; color: #92400e; }
.tag-plate_adapter { background: #ede9fe; color: #5b21b6; }
/* 详情页布局 */
.detail-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 900px) {
.detail-layout { grid-template-columns: 1fr; }
}
.detail-info, .detail-viz { display: flex; flex-direction: column; gap: 16px; }
.info-card, .viz-card {
background: #fff;
border: 1px solid var(--c-gray-200);
border-radius: var(--radius);
padding: 16px;
}
.info-card h3, .viz-card h3 {
font-size: 0.95rem;
margin-bottom: 12px;
color: var(--c-gray-700);
border-bottom: 1px solid var(--c-gray-100);
padding-bottom: 8px;
}
.info-table { width: 100%; font-size: 0.85rem; }
.info-table td { padding: 4px 8px; border-bottom: 1px solid var(--c-gray-100); }
.info-table .label { color: var(--c-gray-500); font-weight: 500; width: 140px; }
.info-table code { background: var(--c-gray-100); padding: 1px 4px; border-radius: 3px; font-size: 0.8rem; }
.info-table code.small { font-size: 0.7rem; }
/* SVG 容器 */
#svg-topdown, #svg-side {
width: 100%;
min-height: 200px;
overflow: hidden;
}
#svg-topdown svg, #svg-side svg {
display: block;
width: 100%;
height: auto;
touch-action: none;
}
/* 编辑页布局 */
.edit-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 900px) {
.edit-layout { grid-template-columns: 1fr; }
}
.edit-form { display: flex; flex-direction: column; gap: 16px; }
.edit-preview { display: flex; flex-direction: column; gap: 16px; position: sticky; top: 72px; align-self: start; }
/* 表单 */
.form-section {
background: #fff;
border: 1px solid var(--c-gray-200);
border-radius: var(--radius);
padding: 16px;
}
.form-section h3 {
font-size: 0.95rem;
margin-bottom: 12px;
color: var(--c-gray-700);
}
.form-row { margin-bottom: 10px; }
.form-row label { display: block; font-size: 0.8rem; color: var(--c-gray-500); margin-bottom: 4px; font-weight: 500; }
.form-row input, .form-row select, .form-row textarea {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--c-gray-300);
border-radius: 6px;
font-size: 0.85rem;
font-family: inherit;
}
.form-row input:focus, .form-row select:focus, .form-row textarea:focus {
outline: none;
border-color: var(--c-primary);
box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
}
.form-row-2, .form-row-3 { display: grid; gap: 12px; margin-bottom: 10px; }
.form-row-2 { grid-template-columns: 1fr 1fr; }
.form-row-3 { grid-template-columns: 1fr 1fr 1fr; }
.form-row-2 label, .form-row-3 label { display: block; font-size: 0.8rem; color: var(--c-gray-500); margin-bottom: 4px; font-weight: 500; }
.form-row-2 input, .form-row-2 select,
.form-row-3 input, .form-row-3 select {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--c-gray-300);
border-radius: 6px;
font-size: 0.85rem;
}
.form-row-2 input:focus, .form-row-3 input:focus {
outline: none;
border-color: var(--c-primary);
box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
}
.form-actions {
display: flex;
gap: 10px;
margin-top: 8px;
}
/* 双语标签中文部分 */
.label-cn { color: var(--c-gray-400, #9ca3af); font-weight: 400; margin-left: 4px; }

View File

@@ -1,24 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}PRCXI 耗材管理{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css">
{% block head_extra %}{% endblock %}
</head>
<body>
<nav class="topbar">
<a href="/" class="logo">PRCXI 耗材管理</a>
<div class="nav-actions">
<a href="/labware/new" class="btn btn-primary btn-sm">+ 新建耗材</a>
</div>
</nav>
<main class="container">
{% block content %}{% endblock %}
</main>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -1,159 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ item.function_name }} - PRCXI{% endblock %}
{% block content %}
<div class="page-header">
<h1>{{ item.function_name }}</h1>
<div class="header-actions">
<a href="/labware/{{ item.function_name }}/edit" class="btn btn-primary">编辑</a>
<a href="/" class="btn btn-outline">返回列表</a>
</div>
</div>
<div class="detail-layout">
<!-- 左侧: 信息 -->
<div class="detail-info">
<div class="info-card">
<h3>基本信息</h3>
<table class="info-table">
<tr><td class="label">类型</td><td><span class="tag tag-{{ item.type }}">{{ item.type }}</span></td></tr>
<tr><td class="label">函数名</td><td><code>{{ item.function_name }}</code></td></tr>
<tr><td class="label">Model</td><td>{{ item.model or '-' }}</td></tr>
{% if item.plate_type %}
<tr><td class="label">Plate Type</td><td>{{ item.plate_type }}</td></tr>
{% endif %}
<tr><td class="label">Docstring</td><td>{{ item.docstring or '-' }}</td></tr>
</table>
</div>
<div class="info-card">
<h3>物理尺寸 (mm)</h3>
<table class="info-table">
<tr><td class="label">X</td><td>{{ item.size_x }}</td></tr>
<tr><td class="label">Y</td><td>{{ item.size_y }}</td></tr>
<tr><td class="label">Z</td><td>{{ item.size_z }}</td></tr>
</table>
</div>
<div class="info-card">
<h3>材料信息</h3>
<table class="info-table">
<tr><td class="label">UUID</td><td><code class="small">{{ item.material_info.uuid }}</code></td></tr>
<tr><td class="label">Code</td><td>{{ item.material_info.Code }}</td></tr>
<tr><td class="label">Name</td><td>{{ item.material_info.Name }}</td></tr>
{% if item.material_info.materialEnum is not none %}
<tr><td class="label">materialEnum</td><td>{{ item.material_info.materialEnum }}</td></tr>
{% endif %}
{% if item.material_info.SupplyType is not none %}
<tr><td class="label">SupplyType</td><td>{{ item.material_info.SupplyType }}</td></tr>
{% endif %}
</table>
</div>
{% if item.grid %}
<div class="info-card">
<h3>网格排列</h3>
<table class="info-table">
<tr><td class="label">列 x 行</td><td>{{ item.grid.num_items_x }} x {{ item.grid.num_items_y }}</td></tr>
<tr><td class="label">dx, dy, dz</td><td>{{ item.grid.dx }}, {{ item.grid.dy }}, {{ item.grid.dz }}</td></tr>
<tr><td class="label">item_dx, item_dy</td><td>{{ item.grid.item_dx }}, {{ item.grid.item_dy }}</td></tr>
</table>
</div>
{% endif %}
{% if item.well %}
<div class="info-card">
<h3>孔参数 (Well)</h3>
<table class="info-table">
<tr><td class="label">尺寸</td><td>{{ item.well.size_x }} x {{ item.well.size_y }} x {{ item.well.size_z }}</td></tr>
{% if item.well.max_volume is not none %}
<tr><td class="label">最大体积</td><td>{{ item.well.max_volume }} uL</td></tr>
{% endif %}
<tr><td class="label">底部类型</td><td>{{ item.well.bottom_type }}</td></tr>
<tr><td class="label">截面类型</td><td>{{ item.well.cross_section_type }}</td></tr>
{% if item.well.material_z_thickness is not none %}
<tr><td class="label">材料Z厚度</td><td>{{ item.well.material_z_thickness }}</td></tr>
{% endif %}
</table>
</div>
{% endif %}
{% if item.tip %}
<div class="info-card">
<h3>枪头参数 (Tip)</h3>
<table class="info-table">
<tr><td class="label">Spot 尺寸</td><td>{{ item.tip.spot_size_x }} x {{ item.tip.spot_size_y }} x {{ item.tip.spot_size_z }}</td></tr>
<tr><td class="label">容量</td><td>{{ item.tip.tip_volume }} uL</td></tr>
<tr><td class="label">长度</td><td>{{ item.tip.tip_length }} mm</td></tr>
<tr><td class="label">取枪头插入深度</td><td>{{ item.tip.tip_fitting_depth }} mm</td></tr>
{% if item.tip.tip_above_rack_length is not none %}
<tr><td class="label">枪头露出枪头盒长度</td><td>{{ item.tip.tip_above_rack_length }} mm</td></tr>
{% endif %}
<tr><td class="label">有滤芯</td><td>{{ item.tip.has_filter }}</td></tr>
</table>
</div>
{% endif %}
{% if item.tube %}
<div class="info-card">
<h3>管参数 (Tube)</h3>
<table class="info-table">
<tr><td class="label">尺寸</td><td>{{ item.tube.size_x }} x {{ item.tube.size_y }} x {{ item.tube.size_z }}</td></tr>
<tr><td class="label">最大体积</td><td>{{ item.tube.max_volume }} uL</td></tr>
</table>
</div>
{% endif %}
{% if item.adapter %}
<div class="info-card">
<h3>适配器参数</h3>
<table class="info-table">
<tr><td class="label">Hole 尺寸</td><td>{{ item.adapter.adapter_hole_size_x }} x {{ item.adapter.adapter_hole_size_y }} x {{ item.adapter.adapter_hole_size_z }}</td></tr>
<tr><td class="label">dx, dy, dz</td><td>{{ item.adapter.dx }}, {{ item.adapter.dy }}, {{ item.adapter.dz }}</td></tr>
</table>
</div>
{% endif %}
<div class="info-card">
<h3>Registry</h3>
<table class="info-table">
<tr><td class="label">分类</td><td>{{ item.registry_category | join(' / ') }}</td></tr>
<tr><td class="label">描述</td><td>{{ item.registry_description }}</td></tr>
<tr><td class="label">模板匹配</td><td>{{ item.include_in_template_matching }}</td></tr>
{% if item.template_kind %}
<tr><td class="label">模板类型</td><td>{{ item.template_kind }}</td></tr>
{% endif %}
</table>
</div>
</div>
<!-- 右侧: 可视化 -->
<div class="detail-viz">
<div class="viz-card">
<h3 style="display:flex;align-items:center;justify-content:space-between;">
俯视图 (Top-Down)
<button type="button" class="btn btn-sm btn-outline" onclick="resetSvgView('svg-topdown')">回中</button>
</h3>
<div id="svg-topdown"></div>
</div>
<div class="viz-card">
<h3 style="display:flex;align-items:center;justify-content:space-between;">
侧面截面图 (Side Profile)
<button type="button" class="btn btn-sm btn-outline" onclick="resetSvgView('svg-side')">回中</button>
</h3>
<div id="svg-side"></div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/labware_viz.js"></script>
<script>
const itemData = {{ item.model_dump() | tojson }};
document.addEventListener('DOMContentLoaded', () => {
renderTopDown(document.getElementById('svg-topdown'), itemData);
renderSideProfile(document.getElementById('svg-side'), itemData);
});
</script>
{% endblock %}

View File

@@ -1,257 +0,0 @@
{% extends "base.html" %}
{% block title %}{% if is_new %}新建耗材{% else %}编辑 {{ item.function_name }}{% endif %} - PRCXI{% endblock %}
{% block content %}
<div class="page-header">
<h1>{% if is_new %}新建耗材{% else %}编辑 {{ item.function_name }}{% endif %}</h1>
<div class="header-actions">
<a href="/" class="btn btn-outline">返回列表</a>
</div>
</div>
<div id="status-msg" class="status-msg" style="display:none;"></div>
<div class="edit-layout">
<!-- 左侧: 表单 -->
<div class="edit-form">
<form id="labware-form" onsubmit="return false;">
<!-- 基本信息 -->
<div class="form-section">
<h3>基本信息</h3>
<div class="form-row">
<label>类型</label>
<select name="type" id="f-type" onchange="onTypeChange()">
<option value="plate" {% if labware_type == 'plate' %}selected{% endif %}>Plate (孔板)</option>
<option value="tip_rack" {% if labware_type == 'tip_rack' %}selected{% endif %}>TipRack (吸头盒)</option>
<option value="trash" {% if labware_type == 'trash' %}selected{% endif %}>Trash (废弃槽)</option>
<option value="tube_rack" {% if labware_type == 'tube_rack' %}selected{% endif %}>TubeRack (管架)</option>
<option value="plate_adapter" {% if labware_type == 'plate_adapter' %}selected{% endif %}>PlateAdapter (适配器)</option>
</select>
</div>
<div class="form-row">
<label>函数名</label>
<input type="text" name="function_name" id="f-function_name"
value="{{ item.function_name if item else 'PRCXI_new_labware' }}"
placeholder="PRCXI_xxx">
</div>
<div class="form-row">
<label>Model</label>
<input type="text" name="model" id="f-model"
value="{{ item.model if item and item.model else '' }}">
</div>
<div class="form-row">
<label>Docstring</label>
<textarea name="docstring" id="f-docstring" rows="2">{{ item.docstring if item else '' }}</textarea>
</div>
<div class="form-row" id="row-plate_type" style="display:none;">
<label>Plate Type</label>
<select name="plate_type" id="f-plate_type">
<option value="">-</option>
<option value="skirted" {% if item and item.plate_type == 'skirted' %}selected{% endif %}>skirted</option>
<option value="semi-skirted" {% if item and item.plate_type == 'semi-skirted' %}selected{% endif %}>semi-skirted</option>
<option value="non-skirted" {% if item and item.plate_type == 'non-skirted' %}selected{% endif %}>non-skirted</option>
</select>
</div>
</div>
<!-- 物理尺寸 -->
<div class="form-section">
<h3>物理尺寸 Physical Dimensions (mm)</h3>
<div class="form-row-3">
<div><label>size_x <span class="label-cn">板长</span></label><input type="number" step="any" name="size_x" id="f-size_x" value="{{ item.size_x if item else 127 }}"></div>
<div><label>size_y <span class="label-cn">板宽</span></label><input type="number" step="any" name="size_y" id="f-size_y" value="{{ item.size_y if item else 85 }}"></div>
<div><label>size_z <span class="label-cn">板高</span></label><input type="number" step="any" name="size_z" id="f-size_z" value="{{ item.size_z if item else 20 }}"></div>
</div>
</div>
<!-- 材料信息 -->
<div class="form-section">
<h3>材料信息</h3>
<div class="form-row">
<label>UUID</label>
<input type="text" name="mi_uuid" id="f-mi_uuid"
value="{{ item.material_info.uuid if item else '' }}">
</div>
<div class="form-row-2">
<div><label>Code</label><input type="text" name="mi_code" id="f-mi_code" value="{{ item.material_info.Code if item else '' }}"></div>
<div><label>Name</label><input type="text" name="mi_name" id="f-mi_name" value="{{ item.material_info.Name if item else '' }}"></div>
</div>
<div class="form-row-2">
<div><label>materialEnum</label><input type="number" name="mi_menum" id="f-mi_menum" value="{{ item.material_info.materialEnum if item and item.material_info.materialEnum is not none else '' }}"></div>
<div><label>SupplyType</label><input type="number" name="mi_stype" id="f-mi_stype" value="{{ item.material_info.SupplyType if item and item.material_info.SupplyType is not none else '' }}"></div>
</div>
</div>
<!-- 网格排列 (plate/tip_rack/tube_rack) -->
<div class="form-section" id="section-grid" style="display:none;">
<h3 style="display:flex;align-items:center;justify-content:space-between;">
网格排列 Grid Layout
<button type="button" class="btn btn-sm btn-outline" onclick="autoCenter()">自动居中 Auto-Center</button>
</h3>
<div class="form-row-2">
<div><label>num_items_x <span class="label-cn">列数</span></label><input type="number" name="grid_nx" id="f-grid_nx" value="{{ item.grid.num_items_x if item and item.grid else 12 }}"></div>
<div><label>num_items_y <span class="label-cn">行数</span></label><input type="number" name="grid_ny" id="f-grid_ny" value="{{ item.grid.num_items_y if item and item.grid else 8 }}"></div>
</div>
<div class="form-row-3">
<div><label>dx <span class="label-cn">首孔X偏移</span></label><input type="number" step="any" name="grid_dx" id="f-grid_dx" value="{{ item.grid.dx if item and item.grid else 0 }}"></div>
<div><label>dy <span class="label-cn">首孔Y偏移</span></label><input type="number" step="any" name="grid_dy" id="f-grid_dy" value="{{ item.grid.dy if item and item.grid else 0 }}"></div>
<div><label>dz <span class="label-cn">孔底Z偏移</span></label><input type="number" step="any" name="grid_dz" id="f-grid_dz" value="{{ item.grid.dz if item and item.grid else 0 }}"></div>
</div>
<div class="form-row-2">
<div><label>item_dx <span class="label-cn">列间距</span></label><input type="number" step="any" name="grid_idx" id="f-grid_idx" value="{{ item.grid.item_dx if item and item.grid else 9 }}"></div>
<div><label>item_dy <span class="label-cn">行间距</span></label><input type="number" step="any" name="grid_idy" id="f-grid_idy" value="{{ item.grid.item_dy if item and item.grid else 9 }}"></div>
</div>
</div>
<!-- Well 参数 (plate) -->
<div class="form-section" id="section-well" style="display:none;">
<h3>孔参数 Well</h3>
<div class="form-row-3">
<div><label>size_x <span class="label-cn">孔长</span></label><input type="number" step="any" name="well_sx" id="f-well_sx" value="{{ item.well.size_x if item and item.well else 8 }}"></div>
<div><label>size_y <span class="label-cn">孔宽</span></label><input type="number" step="any" name="well_sy" id="f-well_sy" value="{{ item.well.size_y if item and item.well else 8 }}"></div>
<div><label>size_z <span class="label-cn">孔深</span></label><input type="number" step="any" name="well_sz" id="f-well_sz" value="{{ item.well.size_z if item and item.well else 10 }}"></div>
</div>
<div class="form-row-2">
<div><label>max_volume <span class="label-cn">最大容量 (uL)</span></label><input type="number" step="any" name="well_vol" id="f-well_vol" value="{{ item.well.max_volume if item and item.well and item.well.max_volume is not none else '' }}"></div>
<div><label>material_z_thickness <span class="label-cn">底壁厚度</span></label><input type="number" step="any" name="well_mzt" id="f-well_mzt" value="{{ item.well.material_z_thickness if item and item.well and item.well.material_z_thickness is not none else '' }}"></div>
</div>
<div class="form-row-2">
<div>
<label>bottom_type <span class="label-cn">底部形状</span></label>
<select name="well_bt" id="f-well_bt">
<option value="FLAT" {% if item and item.well and item.well.bottom_type == 'FLAT' %}selected{% endif %}>FLAT</option>
<option value="V" {% if item and item.well and item.well.bottom_type == 'V' %}selected{% endif %}>V</option>
<option value="U" {% if item and item.well and item.well.bottom_type == 'U' %}selected{% endif %}>U</option>
</select>
</div>
<div>
<label>cross_section_type <span class="label-cn">截面形状</span></label>
<select name="well_cs" id="f-well_cs">
<option value="CIRCLE" {% if item and item.well and item.well.cross_section_type == 'CIRCLE' %}selected{% endif %}>CIRCLE</option>
<option value="RECTANGLE" {% if item and item.well and item.well.cross_section_type == 'RECTANGLE' %}selected{% endif %}>RECTANGLE</option>
</select>
</div>
</div>
<div class="form-row">
<label><input type="checkbox" name="has_vf" id="f-has_vf" {% if item and item.volume_functions %}checked{% endif %}> 使用 volume_functions (rectangle)</label>
</div>
</div>
<!-- Tip 参数 (tip_rack) -->
<div class="form-section" id="section-tip" style="display:none;">
<h3>枪头参数 Tip</h3>
<div class="form-row-3">
<div><label>spot_size_x <span class="label-cn">卡槽长</span></label><input type="number" step="any" name="tip_sx" id="f-tip_sx" value="{{ item.tip.spot_size_x if item and item.tip else 7 }}"></div>
<div><label>spot_size_y <span class="label-cn">卡槽宽</span></label><input type="number" step="any" name="tip_sy" id="f-tip_sy" value="{{ item.tip.spot_size_y if item and item.tip else 7 }}"></div>
<div><label>spot_size_z <span class="label-cn">卡槽高</span></label><input type="number" step="any" name="tip_sz" id="f-tip_sz" value="{{ item.tip.spot_size_z if item and item.tip else 0 }}"></div>
</div>
<div class="form-row-3">
<div><label>tip_volume <span class="label-cn">枪头容量 (uL)</span></label><input type="number" step="any" name="tip_vol" id="f-tip_vol" value="{{ item.tip.tip_volume if item and item.tip else 300 }}"></div>
<div><label>tip_length <span class="label-cn">枪头总长度 (mm)</span></label><input type="number" step="any" name="tip_len" id="f-tip_len" value="{{ item.tip.tip_length if item and item.tip else 60 }}"></div>
<div><label>fitting_depth <span class="label-cn">取枪头时插入的长度 (mm)</span></label><input type="number" step="any" name="tip_dep" id="f-tip_dep" value="{{ item.tip.tip_fitting_depth if item and item.tip else 51 }}"></div>
</div>
<div class="form-row">
<label>tip_above_rack_length <span class="label-cn">枪头在枪头盒上方的部分的长度 (mm)</span></label>
<input type="number" step="any" name="tip_above" id="f-tip_above"
value="{{ item.tip.tip_above_rack_length if item and item.tip and item.tip.tip_above_rack_length is not none else '' }}"
placeholder="tip_length - (size_z - dz)">
<small style="color:#888;margin-top:2px;">公式: tip_length = tip_above + size_z - dz填 tip_above 自动算 dz填 dz 自动算 tip_above</small>
</div>
<div class="form-row">
<label><input type="checkbox" name="tip_filter" id="f-tip_filter" {% if item and item.tip and item.tip.has_filter %}checked{% endif %}> has_filter</label>
</div>
</div>
<!-- Tube 参数 (tube_rack) -->
<div class="form-section" id="section-tube" style="display:none;">
<h3>管参数 Tube</h3>
<div class="form-row-3">
<div><label>size_x <span class="label-cn">管径X</span></label><input type="number" step="any" name="tube_sx" id="f-tube_sx" value="{{ item.tube.size_x if item and item.tube else 10.6 }}"></div>
<div><label>size_y <span class="label-cn">管径Y</span></label><input type="number" step="any" name="tube_sy" id="f-tube_sy" value="{{ item.tube.size_y if item and item.tube else 10.6 }}"></div>
<div><label>size_z <span class="label-cn">管高</span></label><input type="number" step="any" name="tube_sz" id="f-tube_sz" value="{{ item.tube.size_z if item and item.tube else 40 }}"></div>
</div>
<div class="form-row">
<label>max_volume <span class="label-cn">最大容量 (uL)</span></label>
<input type="number" step="any" name="tube_vol" id="f-tube_vol" value="{{ item.tube.max_volume if item and item.tube else 1500 }}">
</div>
</div>
<!-- Adapter 参数 (plate_adapter) -->
<div class="form-section" id="section-adapter" style="display:none;">
<h3>适配器参数 Adapter</h3>
<div class="form-row-3">
<div><label>hole_size_x <span class="label-cn">凹槽长</span></label><input type="number" step="any" name="adp_hsx" id="f-adp_hsx" value="{{ item.adapter.adapter_hole_size_x if item and item.adapter else 127.76 }}"></div>
<div><label>hole_size_y <span class="label-cn">凹槽宽</span></label><input type="number" step="any" name="adp_hsy" id="f-adp_hsy" value="{{ item.adapter.adapter_hole_size_y if item and item.adapter else 85.48 }}"></div>
<div><label>hole_size_z <span class="label-cn">凹槽深</span></label><input type="number" step="any" name="adp_hsz" id="f-adp_hsz" value="{{ item.adapter.adapter_hole_size_z if item and item.adapter else 10 }}"></div>
</div>
<div class="form-row-3">
<div><label>dx <span class="label-cn">X偏移</span></label><input type="number" step="any" name="adp_dx" id="f-adp_dx" value="{{ item.adapter.dx if item and item.adapter and item.adapter.dx is not none else '' }}"></div>
<div><label>dy <span class="label-cn">Y偏移</span></label><input type="number" step="any" name="adp_dy" id="f-adp_dy" value="{{ item.adapter.dy if item and item.adapter and item.adapter.dy is not none else '' }}"></div>
<div><label>dz <span class="label-cn">Z偏移</span></label><input type="number" step="any" name="adp_dz" id="f-adp_dz" value="{{ item.adapter.dz if item and item.adapter else 0 }}"></div>
</div>
</div>
<!-- Registry -->
<div class="form-section">
<h3>Registry / Template</h3>
<div class="form-row">
<label>registry_category (逗号分隔)</label>
<input type="text" name="reg_cat" id="f-reg_cat"
value="{{ item.registry_category | join(',') if item else 'prcxi,plates' }}">
</div>
<div class="form-row">
<label>registry_description</label>
<input type="text" name="reg_desc" id="f-reg_desc"
value="{{ item.registry_description if item else '' }}">
</div>
<div class="form-row">
<label><input type="checkbox" name="in_tpl" id="f-in_tpl" {% if item and item.include_in_template_matching %}checked{% endif %}> include_in_template_matching</label>
</div>
<div class="form-row" id="row-tpl_kind">
<label>template_kind</label>
<input type="text" name="tpl_kind" id="f-tpl_kind"
value="{{ item.template_kind if item and item.template_kind else '' }}">
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-primary" onclick="saveForm()">
{% if is_new %}创建{% else %}保存{% endif %}
</button>
<a href="/" class="btn btn-outline">取消</a>
</div>
</form>
</div>
<!-- 右侧: 实时预览 -->
<div class="edit-preview">
<div class="viz-card">
<h3 style="display:flex;align-items:center;justify-content:space-between;">
预览: 俯视图
<button type="button" class="btn btn-sm btn-outline" onclick="resetSvgView('svg-topdown')">回中</button>
</h3>
<div id="svg-topdown"></div>
</div>
<div class="viz-card">
<h3 style="display:flex;align-items:center;justify-content:space-between;">
预览: 侧面截面图
<button type="button" class="btn btn-sm btn-outline" onclick="resetSvgView('svg-side')">回中</button>
</h3>
<div id="svg-side"></div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/labware_viz.js"></script>
<script src="/static/form_handler.js"></script>
<script>
const IS_NEW = {{ 'true' if is_new else 'false' }};
const ITEM_ID = "{{ item.function_name if item else '' }}";
document.addEventListener('DOMContentLoaded', () => {
onTypeChange();
updatePreview();
});
</script>
{% endblock %}

View File

@@ -1,131 +0,0 @@
{% extends "base.html" %}
{% block title %}耗材列表 - PRCXI{% endblock %}
{% block content %}
<div class="page-header">
<h1>耗材列表 <span class="badge">{{ total }}</span></h1>
<div class="header-actions">
<button class="btn btn-outline" onclick="importFromCode()">从代码导入</button>
<button class="btn btn-outline" onclick="generateCode(true)">生成代码 (测试)</button>
<button class="btn btn-warning" onclick="generateCode(false)">生成代码 (正式)</button>
<a href="/labware/new" class="btn btn-primary">+ 新建耗材</a>
</div>
</div>
<div id="status-msg" class="status-msg" style="display:none;"></div>
{% set type_labels = {
"plate": "孔板 (Plate)",
"tip_rack": "吸头盒 (TipRack)",
"trash": "废弃槽 (Trash)",
"tube_rack": "管架 (TubeRack)",
"plate_adapter": "适配器 (PlateAdapter)"
} %}
{% set type_colors = {
"plate": "#3b82f6",
"tip_rack": "#10b981",
"trash": "#ef4444",
"tube_rack": "#f59e0b",
"plate_adapter": "#8b5cf6"
} %}
{% for type_key in ["plate", "tip_rack", "trash", "tube_rack", "plate_adapter"] %}
{% if type_key in groups %}
<section class="type-section">
<h2>
<span class="type-dot" style="background:{{ type_colors[type_key] }}"></span>
{{ type_labels[type_key] }}
<span class="badge">{{ groups[type_key]|length }}</span>
</h2>
<div class="card-grid">
{% for item in groups[type_key] %}
<div class="labware-card" onclick="location.href='/labware/{{ item.function_name }}'">
<div class="card-header">
<span class="card-title">{{ item.function_name }}</span>
{% if item.include_in_template_matching %}
<span class="tag tag-tpl">TPL</span>
{% endif %}
</div>
<div class="card-body">
<div class="card-info">
<span class="label">Code:</span> {{ item.material_info.Code or '-' }}
</div>
<div class="card-info">
<span class="label">名称:</span> {{ item.material_info.Name or '-' }}
</div>
<div class="card-info">
<span class="label">尺寸:</span>
{{ item.size_x }} x {{ item.size_y }} x {{ item.size_z }} mm
</div>
{% if item.grid %}
<div class="card-info">
<span class="label">网格:</span>
{{ item.grid.num_items_x }} x {{ item.grid.num_items_y }}
</div>
{% endif %}
</div>
<div class="card-footer">
<a href="/labware/{{ item.function_name }}/edit" class="btn btn-sm btn-outline"
onclick="event.stopPropagation()">编辑</a>
<button class="btn btn-sm btn-danger"
onclick="event.stopPropagation(); deleteItem('{{ item.function_name }}')">删除</button>
</div>
</div>
{% endfor %}
</div>
</section>
{% endif %}
{% endfor %}
{% endblock %}
{% block scripts %}
<script>
function showMsg(text, ok) {
const el = document.getElementById('status-msg');
el.textContent = text;
el.className = 'status-msg ' + (ok ? 'msg-ok' : 'msg-err');
el.style.display = 'block';
setTimeout(() => el.style.display = 'none', 4000);
}
async function importFromCode() {
if (!confirm('将从现有 prcxi_labware.py + YAML 重新导入,覆盖当前 JSON 数据?')) return;
const r = await fetch('/api/import-from-code', {method:'POST'});
const d = await r.json();
if (d.status === 'ok') {
showMsg('导入成功: ' + d.count + ' 个耗材', true);
setTimeout(() => location.reload(), 1000);
} else {
showMsg('导入失败: ' + JSON.stringify(d), false);
}
}
async function generateCode(testMode) {
const label = testMode ? '测试' : '正式';
if (!testMode && !confirm('正式模式将覆盖原有 prcxi_labware.py 和 YAML 文件,确定?')) return;
const r = await fetch('/api/generate-code', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({test_mode: testMode})
});
const d = await r.json();
if (d.status === 'ok') {
showMsg(`[${label}] 生成成功: ${d.python_file}`, true);
} else {
showMsg('生成失败: ' + JSON.stringify(d), false);
}
}
async function deleteItem(id) {
if (!confirm('确定删除 ' + id + '')) return;
const r = await fetch('/api/labware/' + id, {method:'DELETE'});
const d = await r.json();
if (d.status === 'ok') {
showMsg('已删除', true);
setTimeout(() => location.reload(), 500);
} else {
showMsg('删除失败', false);
}
}
</script>
{% endblock %}

View File

@@ -1,119 +0,0 @@
"""JSON → Registry YAML 文件生成。
按 type 分组输出到对应 YAML 文件(与现有格式完全一致)。
"""
from __future__ import annotations
import shutil
from collections import defaultdict
from pathlib import Path
from typing import Dict, List
import yaml
from unilabos.labware_manager.models import LabwareDB, LabwareItem
_REGISTRY_DIR = Path(__file__).resolve().parents[1] / "registry" / "resources" / "prcxi"
# type → yaml 文件名
_TYPE_TO_YAML = {
"plate": "plates",
"tip_rack": "tip_racks",
"trash": "trash",
"tube_rack": "tube_racks",
"plate_adapter": "plate_adapters",
}
def _build_entry(item: LabwareItem) -> dict:
"""构建单个 YAML 条目(与现有格式完全一致)。"""
mi = item.material_info
desc = item.registry_description
if not desc:
desc = f'{mi.Name} (Code: {mi.Code})' if mi.Name and mi.Code else item.function_name
return {
"category": list(item.registry_category),
"class": {
"module": f"unilabos.devices.liquid_handling.prcxi.prcxi_labware:{item.function_name}",
"type": "pylabrobot",
},
"description": desc,
"handles": [],
"icon": "",
"init_param_schema": {},
"version": "1.0.0",
}
class _YAMLDumper(yaml.SafeDumper):
"""自定义 Dumper: 空列表输出为 [],空字典输出为 {}"""
pass
def _represent_list(dumper, data):
if not data:
return dumper.represent_sequence("tag:yaml.org,2002:seq", data, flow_style=True)
return dumper.represent_sequence("tag:yaml.org,2002:seq", data)
def _represent_dict(dumper, data):
if not data:
return dumper.represent_mapping("tag:yaml.org,2002:map", data, flow_style=True)
return dumper.represent_mapping("tag:yaml.org,2002:map", data)
def _represent_str(dumper, data):
if '\n' in data or ':' in data or "'" in data:
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="'")
return dumper.represent_scalar("tag:yaml.org,2002:str", data)
_YAMLDumper.add_representer(list, _represent_list)
_YAMLDumper.add_representer(dict, _represent_dict)
_YAMLDumper.add_representer(str, _represent_str)
def generate_yaml(db: LabwareDB, test_mode: bool = True) -> List[Path]:
"""生成所有 registry YAML 文件,返回输出文件路径列表。"""
suffix = "_test" if test_mode else ""
# 按 type 分组
groups: Dict[str, Dict[str, dict]] = defaultdict(dict)
for item in db.items:
yaml_key = _TYPE_TO_YAML.get(item.type)
if yaml_key is None:
continue
groups[yaml_key][item.function_name] = _build_entry(item)
out_paths: List[Path] = []
for yaml_key, entries in groups.items():
out_path = _REGISTRY_DIR / f"{yaml_key}{suffix}.yaml"
# 备份
if out_path.exists():
bak = out_path.with_suffix(".yaml.bak")
shutil.copy2(out_path, bak)
# 按函数名排序
sorted_entries = dict(sorted(entries.items()))
content = yaml.dump(sorted_entries, Dumper=_YAMLDumper, allow_unicode=True,
default_flow_style=False, sort_keys=False)
out_path.write_text(content, encoding="utf-8")
out_paths.append(out_path)
return out_paths
if __name__ == "__main__":
from unilabos.labware_manager.importer import load_db
db = load_db()
if not db.items:
print("labware_db.json 为空,请先运行 importer.py")
else:
paths = generate_yaml(db, test_mode=True)
print(f"已生成 {len(paths)} 个 YAML 文件:")
for p in paths:
print(f" {p}")

View File

@@ -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: {}
@@ -7651,61 +7614,10 @@ liquid_handler.prcxi:
title: move_to参数
type: object
type: UniLabJsonCommandAsync
auto-plr_pos_to_prcxi:
feedback: {}
goal: {}
goal_default:
resource: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
resource:
type: object
required:
- resource
type: object
result: {}
required:
- goal
title: plr_pos_to_prcxi参数
type: object
type: UniLabJsonCommand
auto-post_init:
feedback: {}
goal: {}
goal_default:
ros_node: null
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
ros_node:
type: object
required:
- ros_node
type: object
result: {}
required:
- goal
title: post_init参数
type: object
type: UniLabJsonCommand
auto-run_protocol:
feedback: {}
goal: {}
goal_default:
protocol_id: null
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
@@ -7714,9 +7626,7 @@ liquid_handler.prcxi:
properties:
feedback: {}
goal:
properties:
protocol_id:
type: string
properties: {}
required: []
type: object
result: {}
@@ -7799,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: {}
@@ -9911,28 +9780,48 @@ liquid_handler.prcxi:
type: Transfer
transfer_liquid:
feedback: {}
goal: {}
goal:
asp_flow_rates: asp_flow_rates
asp_vols: asp_vols
blow_out_air_volume: blow_out_air_volume
delays: delays
dis_flow_rates: dis_flow_rates
dis_vols: dis_vols
is_96_well: is_96_well
liquid_height: liquid_height
mix_liquid_height: mix_liquid_height
mix_rate: mix_rate
mix_stage: mix_stage
mix_times: mix_times
mix_vol: mix_vol
none_keys: none_keys
offsets: offsets
sources: sources
spread: spread
targets: targets
tip_racks: tip_racks
touch_tip: touch_tip
use_channels: use_channels
goal_default:
asp_flow_rates: null
asp_vols: null
blow_out_air_volume: null
blow_out_air_volume_before: null
delays: null
dis_flow_rates: null
dis_vols: null
asp_flow_rates: []
asp_vols: []
blow_out_air_volume: []
delays: []
dis_flow_rates: []
dis_vols: []
is_96_well: false
liquid_height: null
mix_liquid_height: null
mix_rate: null
mix_stage: none
mix_times: null
mix_vol: null
liquid_height: []
mix_liquid_height: 0.0
mix_rate: 0
mix_stage: ''
mix_times: 0
mix_vol: 0
none_keys: []
offsets: null
sources: null
spread: wide
targets: null
tip_racks: null
offsets: []
sources: []
spread: ''
targets: []
tip_racks: []
touch_tip: false
use_channels: []
handles:
@@ -9947,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
@@ -9973,7 +9862,10 @@ liquid_handler.prcxi:
schema:
description: ''
properties:
feedback: {}
feedback:
additionalProperties: true
title: LiquidHandlerTransfer_Feedback
type: object
goal:
additionalProperties: false
properties:
@@ -9989,10 +9881,6 @@ liquid_handler.prcxi:
items:
type: number
type: array
blow_out_air_volume_before:
items:
type: number
type: array
delays:
items:
type: integer
@@ -10006,7 +9894,6 @@ liquid_handler.prcxi:
type: number
type: array
is_96_well:
default: false
type: boolean
liquid_height:
items:
@@ -10021,7 +9908,6 @@ liquid_handler.prcxi:
minimum: -2147483648
type: integer
mix_stage:
default: none
type: string
mix_times:
maximum: 2147483647
@@ -10032,7 +9918,6 @@ liquid_handler.prcxi:
minimum: -2147483648
type: integer
none_keys:
default: []
items:
type: string
type: array
@@ -10126,7 +10011,6 @@ liquid_handler.prcxi:
type: object
type: array
spread:
default: wide
type: string
targets:
items:
@@ -10277,228 +10161,27 @@ liquid_handler.prcxi:
type: object
type: array
touch_tip:
default: false
type: boolean
use_channels:
items:
type: integer
type: array
required:
- sources
- targets
- tip_racks
- asp_vols
- dis_vols
title: LiquidHandlerTransfer_Goal
type: object
result:
$defs:
ResourceDict:
properties:
class:
description: Resource class name
title: Class
type: string
config:
additionalProperties: true
description: Resource configuration
title: Config
type: object
data:
additionalProperties: true
description: 'Resource data, eg: container liquid data'
title: Data
type: object
description:
default: ''
description: Resource description
title: Description
type: string
extra:
additionalProperties: true
description: 'Extra data, eg: slot index'
title: Extra
type: object
icon:
default: ''
description: Resource icon
title: Icon
type: string
id:
description: Resource ID
title: Id
type: string
model:
additionalProperties: true
description: Resource model
title: Model
type: object
name:
description: Resource name
title: Name
type: string
parent:
anyOf:
- $ref: '#/$defs/ResourceDict'
- type: 'null'
default: null
description: Parent resource object
parent_uuid:
anyOf:
- type: string
- type: 'null'
default: null
description: Parent resource uuid
title: Parent Uuid
pose:
$ref: '#/$defs/ResourceDictPosition'
description: Resource position
schema:
additionalProperties: true
description: Resource schema
title: Schema
type: object
type:
anyOf:
- const: device
type: string
- type: string
description: Resource type
title: Type
uuid:
description: Resource UUID
title: Uuid
type: string
required:
- id
- uuid
- name
- type
- class
- config
- data
- extra
title: ResourceDict
type: object
ResourceDictPosition:
properties:
cross_section_type:
default: rectangle
description: Cross section type
enum:
- rectangle
- circle
- rounded_rectangle
title: Cross Section Type
type: string
layout:
default: x-y
description: Resource layout
enum:
- 2d
- x-y
- z-y
- x-z
title: Layout
type: string
position:
$ref: '#/$defs/ResourceDictPositionObject'
description: Resource position
position3d:
$ref: '#/$defs/ResourceDictPositionObject'
description: Resource position in 3D space
rotation:
$ref: '#/$defs/ResourceDictPositionObject'
description: Resource rotation
scale:
$ref: '#/$defs/ResourceDictPositionScale'
description: Resource scale
size:
$ref: '#/$defs/ResourceDictPositionSize'
description: Resource size
title: ResourceDictPosition
type: object
ResourceDictPositionObject:
properties:
x:
default: 0.0
description: X coordinate
title: X
type: number
y:
default: 0.0
description: Y coordinate
title: Y
type: number
z:
default: 0.0
description: Z coordinate
title: Z
type: number
title: ResourceDictPositionObject
type: object
ResourceDictPositionScale:
properties:
x:
default: 0.0
description: x scale
title: X
type: number
y:
default: 0.0
description: y scale
title: Y
type: number
z:
default: 0.0
description: z scale
title: Z
type: number
title: ResourceDictPositionScale
type: object
ResourceDictPositionSize:
properties:
depth:
default: 0.0
description: Depth
title: Depth
type: number
height:
default: 0.0
description: Height
title: Height
type: number
width:
default: 0.0
description: Width
title: Width
type: number
title: ResourceDictPositionSize
type: object
additionalProperties: false
properties:
sources:
items:
items:
$ref: '#/$defs/ResourceDict'
type: array
title: Sources
type: array
targets:
items:
items:
$ref: '#/$defs/ResourceDict'
type: array
title: Targets
type: array
required:
- sources
- targets
title: TransferLiquidReturn
return_info:
type: string
success:
type: boolean
title: LiquidHandlerTransfer_Result
type: object
required:
- goal
title: transfer_liquid参数
title: LiquidHandlerTransfer
type: object
type: UniLabJsonCommandAsync
type: LiquidHandlerTransfer
module: unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Handler
status_types:
reset_ok: bool
@@ -10521,12 +10204,6 @@ liquid_handler.prcxi:
type: string
deck:
type: object
deck_y:
default: 400
type: string
deck_z:
default: 300
type: string
host:
type: string
is_9320:
@@ -10537,44 +10214,17 @@ liquid_handler.prcxi:
type: string
port:
type: integer
rail_interval:
default: 0
type: string
rail_nums:
default: 4
type: string
rail_width:
default: 27.5
type: string
setup:
default: true
type: string
simulator:
default: false
type: string
start_rail:
default: 2
type: string
step_mode:
default: false
type: string
timeout:
type: number
x_increase:
default: -0.003636
type: string
x_offset:
default: -0.8
type: string
xy_coupling:
default: -0.0045
type: string
y_increase:
default: -0.003636
type: string
y_offset:
default: -37.98
type: string
required:
- deck
- host

View File

@@ -173,64 +173,48 @@ robotic_arm.SCARA_with_slider.moveit.virtual:
type: object
type: UniLabJsonCommand
pick_and_place:
feedback: {}
goal: {}
feedback:
status: status
goal:
command: command
goal_default:
constraints: null
lift_height: null
move_group: null
option: null
resource: null
retry: null
speed: null
status: null
target: null
x_distance: null
y_distance: null
command: ''
handles: {}
placeholder_keys: {}
result: {}
result:
return_info: return_info
success: success
schema:
description: pick_and_place 显式参数UniLabJsonCommand
description: ''
properties:
feedback: {}
goal:
feedback:
additionalProperties: false
properties:
constraints:
items:
type: number
type: array
lift_height:
type: string
move_group:
type: string
option:
type: string
resource:
type: string
retry:
type: string
speed:
type: string
status:
type: string
target:
type: string
x_distance:
type: string
y_distance:
type: string
required:
- option
- move_group
- status
title: SendCmd_Feedback
type: object
goal:
additionalProperties: false
properties:
command:
type: string
title: SendCmd_Goal
type: object
result:
additionalProperties: false
properties:
return_info:
type: string
success:
type: boolean
title: SendCmd_Result
type: object
result: {}
required:
- goal
title: pick_and_place参数
title: SendCmd
type: object
type: UniLabJsonCommand
type: SendCmd
set_position:
feedback:
status: status

View File

@@ -684,64 +684,48 @@ linear_motion.toyo_xyz.sim:
type: object
type: UniLabJsonCommand
pick_and_place:
feedback: {}
goal: {}
feedback:
status: status
goal:
command: command
goal_default:
constraints: null
lift_height: null
move_group: null
option: null
resource: null
retry: null
speed: null
status: null
target: null
x_distance: null
y_distance: null
command: ''
handles: {}
placeholder_keys: {}
result: {}
result:
return_info: return_info
success: success
schema:
description: pick_and_place 显式参数UniLabJsonCommand
description: ''
properties:
feedback: {}
goal:
feedback:
additionalProperties: false
properties:
constraints:
items:
type: number
type: array
lift_height:
type: string
move_group:
type: string
option:
type: string
resource:
type: string
retry:
type: string
speed:
type: string
status:
type: string
target:
type: string
x_distance:
type: string
y_distance:
type: string
required:
- option
- move_group
- status
title: SendCmd_Feedback
type: object
goal:
additionalProperties: false
properties:
command:
type: string
title: SendCmd_Goal
type: object
result:
additionalProperties: false
properties:
return_info:
type: string
success:
type: boolean
title: SendCmd_Result
type: object
result: {}
required:
- goal
title: pick_and_place参数
title: SendCmd
type: object
type: UniLabJsonCommand
type: SendCmd
set_position:
feedback:
status: status

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

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ PRCXI_trash:
- prcxi
- trash
class:
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_trash'
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_trash
type: pylabrobot
description: '废弃槽 (Code: q1)'
handles: []

View File

@@ -3,7 +3,7 @@ PRCXI_EP_Adapter:
- prcxi
- tube_racks
class:
module: 'unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_EP_Adapter'
module: unilabos.devices.liquid_handling.prcxi.prcxi_labware:PRCXI_EP_Adapter
type: pylabrobot
description: 'ep适配器 (Code: 1)'
handles: []

View File

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

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