Compare commits

..

43 Commits

Author SHA1 Message Date
ZiWei
e212dc7781 node_to_plr_dict: WareHouse 子节点不写入 PLR children 列表
WareHouse 通过 sites 字符串追踪占位,不依赖 PLR children tree。
当同一载架(如 BIOYOND_PolymerStation_1BottleCarrier)出现在多个
WareHouse 的 children 下时,PLR _check_naming_conflicts 会因
同名子资源(flask_1)重复而报 ValueError。

将 WareHouse 的 children 排除在 PLR dict 外,PLR 树只保留
WareHouse 本身(位置/尺寸),不包含其持有的载架。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 12:42:27 +08:00
ZiWei
96c4be17dc merge_remote_resources: 以远端为准,移除本地已不在远端的物料
远端(bioyond)不存在的物料不应保留在本地资源树中,
否则这些过期物料会在 PLR 反序列化时产生命名冲突。
同步时对两级子节点均执行移除:
- 三级物料(设备→仓库→物料)
- 三级子物料(设备→物料→子物料)

同时修复 else 分支缺少 remote_child_name 存在性检查的潜在 KeyError。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 12:42:27 +08:00
ZiWei
44afc7733b 修复 WareHouse 反序列化时子资源命名冲突,并更新配液站测试 UUID
- itemized_carrier: assign_child_resource idx=None 时直接 return,
  不调用 super(),避免 bottle_carrier 子树进入 PLR 命名冲突检查
- dispensing_station_bioyond.json: 替换 placeholder UUID 为真实配置值

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 12:42:27 +08:00
ZiWei
a34ffcaeb9 优化资源搜索逻辑,避免同一资源对象重复注册导致的错误 2026-04-28 11:39:08 +08:00
ZiWei
70c6685283 优化资源分配逻辑,避免在反序列化时因名称冲突导致的错误处理 2026-04-28 11:27:58 +08:00
ZiWei
7027bd5ed1 防止 Deck 子类在 __init__ 中调用 setup() 预分配子资源,避免与 PLR deserialize 产生命名冲突 2026-04-28 10:12:21 +08:00
ZiWei
57f5c8752d 添加 BIOYOND_PolymerStation_TipBox 到瓶子和小提示盒分类 2026-04-27 17:09:20 +08:00
ZiWei
827169827a Remove outdated templates and validation guides for device and experiment graph skills.
- Deleted `templates.md` for workstation code templates.
- Removed `SKILL.md` and `reference.md` for editing experiment graphs.
- Deleted `SKILL.md` for validating device implementations.
2026-04-27 11:20:17 +08:00
ZiWei
c4a2f68649 Merge remote-tracking branch 'origin/dev' into feature/organic-extraction 2026-04-27 11:13:13 +08:00
Xuwznln
195fad9398 upgrade to 0.11.1 2026-04-22 19:54:16 +08:00
Xuwznln
898ed5d34b use gitee to install pylabrobot
fix virtual import
2026-04-22 19:51:10 +08:00
Xuwznln
60cbedc4b2 v0.11.0
(cherry picked from commit 67a74172dc)
2026-04-22 19:50:42 +08:00
Xuwznln
2d6a9f7db9 fix possible conversion error 2026-04-22 00:09:06 +08:00
Xuwznln
5dca3d8c3d update workbench example 2026-04-15 16:33:43 +08:00
Xuwznln
37cbed722a update aksk desc 2026-04-13 23:17:43 +08:00
Xuwznln
132cffbe7c print res query logs 2026-04-13 20:24:48 +08:00
Xuwznln
36e5ff804c Fix skills exec error with action type 2026-04-13 20:16:00 +08:00
Xuwznln
eaf8ad5609 Fix skills exec error with action type 2026-04-13 17:02:38 +08:00
Xuwznln
16122ad2fa Update Skills 2026-04-13 15:57:50 +08:00
Xuwznln
d3fef85dd8 Update Skills addr 2026-04-13 11:15:35 +08:00
Xuwznln
f77ac2a5e8 Change uni-lab. to leap-lab.
Support unit in pylabrobot
2026-04-12 15:32:27 +08:00
Xuwznln
93ac55a65b Support async func. 2026-04-11 18:13:08 +08:00
Xuwznln
af35debe38 change to leap-lab backend. Support feedback interval. Reduce cocurrent lags. 2026-04-11 06:22:53 +08:00
ZiWei
d68fc5e380 Merge remote-tracking branch 'origin/dev' into feature/organic-extraction
# Conflicts:
#	.cursor/skills/add-workstation/SKILL.md
#	.cursor/skills/add-workstation/reference.md
2026-03-27 11:49:30 +08:00
ZiWei
f0ea32f163 Merge remote-tracking branch 'origin/dev' into feature/organic-extraction
# Conflicts:
#	.cursor/skills/add-device/SKILL.md
#	.cursor/skills/add-resource/SKILL.md
#	AGENTS.md
#	CLAUDE.md
2026-03-24 17:06:54 +08:00
ZiWei
3c8020813b feat: 添加设备验证指南,确保设备实现符合接口契约和编码标准 2026-03-17 09:54:12 +08:00
ZiWei
97996d316f Merge remote-tracking branch 'origin/dev' into feature/organic-extraction 2026-03-13 15:07:30 +08:00
ZiWei
9815961a1f feat: Add new developer guides for old devices and PLC framework integration 2026-03-11 14:10:11 +08:00
ZiWei
fe501c965f feat: Update workstation reference and templates with new PLC integration details and enhanced workflow mappings 2026-03-11 14:09:46 +08:00
ZiWei
92bfb069d5 feat: Implement Laiyu liquid handling station with enhanced device control, testing, and documentation. 2026-03-09 18:44:20 +08:00
ZiWei
b61c818f7f Merge remote-tracking branch 'origin/dev' into feature/organic-extraction 2026-03-09 09:39:17 +08:00
ZiWei
47a29a0c2f add:skill&agent 2026-03-06 16:54:31 +08:00
ZiWei
9c6f7c7505 Merge branch 'dev' into feature/organic-extraction 2026-03-02 15:32:36 +08:00
ZiWei
e4e4bfbe20 Merge branch 'dev' into feature/organic-extraction 2026-02-04 15:47:47 +08:00
ZiWei
64c748d921 Merge branch 'vibe/dev' into feature/organic-extraction 2026-02-03 10:39:44 +08:00
ZiWei
15ff0e9d30 feat: add Bioyond deck imports to resource registration 2026-02-03 10:28:51 +08:00
ZiWei
f8a52860ad Add BIOYOND deck imports and update JSON configurations with new UUIDs for various components 2026-02-03 10:25:47 +08:00
Xuwznln
e30c01d54e Dev backward (#228)
* Workbench example, adjust log level, and ci check (#220)

* TestLatency Return Value Example & gitignore update

* Adjust log level & Add workbench virtual example & Add not action decorator & Add check_mode &

* Add CI Check

* CI Check Fix 1

* CI Check Fix 2

* CI Check Fix 3

* CI Check Fix 4

* CI Check Fix 5

* Upgrade to py 3.11.14; ros 0.7; unilabos 0.10.16

* Update to ROS2 Humble 0.7

* Fix Build 1

* Fix Build 2

* Fix Build 3

* Fix Build 4

* Fix Build 5

* Fix Build 6

* Fix Build 7

* ci(deps): bump actions/configure-pages from 4 to 5 (#222)

Bumps [actions/configure-pages](https://github.com/actions/configure-pages) from 4 to 5.
- [Release notes](https://github.com/actions/configure-pages/releases)
- [Commits](https://github.com/actions/configure-pages/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/configure-pages
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* ci(deps): bump actions/upload-artifact from 4 to 6 (#224)

Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* ci(deps): bump actions/upload-pages-artifact from 3 to 4 (#225)

Bumps [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) from 3 to 4.
- [Release notes](https://github.com/actions/upload-pages-artifact/releases)
- [Commits](https://github.com/actions/upload-pages-artifact/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/upload-pages-artifact
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* ci(deps): bump actions/checkout from 4 to 6 (#223)

Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Fix Build 8

* Fix Build 9

* Fix Build 10

* Fix Build 11

* Fix Build 12

* Fix Build 13

* v0.10.17

(cherry picked from commit 176de521b4)

* CI Check use production mode

* Fix OT2 & ReAdd Virtual Devices

* add msg goal

* transfer liquid handles

* gather query

* add unilabos_class

* Support root node change pos

* save class name when deserialize & protocol execute test

* fix upload workflow json

* workflow upload & set liquid fix & add set liquid with plate

* speed up registry load

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: hanhua@dp.tech <2509856570@qq.com>
2026-02-02 23:57:13 +08:00
ZiWei
37ec49f318 Refactor Bioyond resource handling: update warehouse mapping retrieval, add TipBox support, and improve liquid tracking logic. Migrate TipBox creation to bottle_carriers.py for better structure. 2026-01-29 16:31:14 +08:00
ZiWei
6bf57f18c1 Collaboration With Cursor 2026-01-29 11:29:38 +08:00
ZiWei
c4a3be1498 feat: enhance separation_step logic with polling thread management and error handling 2026-01-27 12:37:09 +08:00
ZiWei
e11070315d feat: add separation_step with sensor-motor linkage 2026-01-26 23:34:47 +08:00
ZiWei
50ebcad9d7 feat: add ZDT_X42 motor and XKC sensor drivers 2026-01-22 15:07:32 +08:00
89 changed files with 8627 additions and 9546 deletions

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,328 @@
---
description: 设备驱动开发规范
globs: ["unilabos/devices/**/*.py"]
---
# 设备驱动开发规范
## 目录结构
```
unilabos/devices/
├── virtual/ # 虚拟设备(用于测试)
│ ├── virtual_stirrer.py
│ └── virtual_centrifuge.py
├── liquid_handling/ # 液体处理设备
├── balance/ # 天平设备
├── hplc/ # HPLC设备
├── pump_and_valve/ # 泵和阀门
├── temperature/ # 温度控制设备
├── workstation/ # 工作站(组合设备)
└── ...
```
## 设备类完整模板
```python
import asyncio
import logging
import time as time_module
from typing import Dict, Any, Optional
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
class MyDevice:
"""
设备类描述
Attributes:
device_id: 设备唯一标识
config: 设备配置字典
data: 设备状态数据
"""
_ros_node: BaseROS2DeviceNode
def __init__(
self,
device_id: str = None,
config: Dict[str, Any] = None,
**kwargs
):
"""
初始化设备
Args:
device_id: 设备ID
config: 配置字典
**kwargs: 其他参数
"""
# 兼容不同调用方式
if device_id is None and 'id' in kwargs:
device_id = kwargs.pop('id')
if config is None and 'config' in kwargs:
config = kwargs.pop('config')
self.device_id = device_id or "unknown_device"
self.config = config or {}
self.data = {}
# 从config读取参数
self.port = self.config.get('port') or kwargs.get('port', 'COM1')
self._max_value = self.config.get('max_value', 1000.0)
# 初始化日志
self.logger = logging.getLogger(f"MyDevice.{self.device_id}")
self.logger.info(f"设备 {self.device_id} 已创建")
def post_init(self, ros_node: BaseROS2DeviceNode):
"""
ROS节点注入 - 在ROS节点创建后调用
Args:
ros_node: ROS2设备节点实例
"""
self._ros_node = ros_node
async def initialize(self) -> bool:
"""
初始化设备 - 连接硬件、设置初始状态
Returns:
bool: 初始化是否成功
"""
self.logger.info(f"初始化设备 {self.device_id}")
try:
# 执行硬件初始化
# await self._connect_hardware()
# 设置初始状态
self.data.update({
"status": "待机",
"is_running": False,
"current_value": 0.0,
})
self.logger.info(f"设备 {self.device_id} 初始化完成")
return True
except Exception as e:
self.logger.error(f"初始化失败: {e}")
self.data["status"] = f"错误: {e}"
return False
async def cleanup(self) -> bool:
"""
清理设备 - 断开连接、释放资源
Returns:
bool: 清理是否成功
"""
self.logger.info(f"清理设备 {self.device_id}")
self.data.update({
"status": "离线",
"is_running": False,
})
return True
# ==================== 设备动作 ====================
async def execute_action(
self,
param1: float,
param2: str = "",
**kwargs
) -> bool:
"""
执行设备动作
Args:
param1: 参数1
param2: 参数2可选
Returns:
bool: 动作是否成功
"""
# 类型转换和验证
try:
param1 = float(param1)
except (ValueError, TypeError) as e:
self.logger.error(f"参数类型错误: {e}")
return False
# 参数验证
if param1 > self._max_value:
self.logger.error(f"参数超出范围: {param1} > {self._max_value}")
return False
self.logger.info(f"执行动作: param1={param1}, param2={param2}")
# 更新状态
self.data.update({
"status": "运行中",
"is_running": True,
})
# 执行动作(带进度反馈)
duration = 10.0 # 秒
start_time = time_module.time()
while True:
elapsed = time_module.time() - start_time
remaining = max(0, duration - elapsed)
progress = min(100, (elapsed / duration) * 100)
self.data.update({
"status": f"运行中: {progress:.0f}%",
"remaining_time": remaining,
})
if remaining <= 0:
break
await self._ros_node.sleep(1.0)
# 完成
self.data.update({
"status": "完成",
"is_running": False,
})
self.logger.info("动作执行完成")
return True
# ==================== 状态属性 ====================
@property
def status(self) -> str:
"""设备状态 - 自动发布为ROS Topic"""
return self.data.get("status", "未知")
@property
def is_running(self) -> bool:
"""是否正在运行"""
return self.data.get("is_running", False)
@property
def current_value(self) -> float:
"""当前值"""
return self.data.get("current_value", 0.0)
# ==================== 辅助方法 ====================
def get_device_info(self) -> Dict[str, Any]:
"""获取设备信息"""
return {
"device_id": self.device_id,
"status": self.status,
"is_running": self.is_running,
"current_value": self.current_value,
}
def __str__(self) -> str:
return f"MyDevice({self.device_id}: {self.status})"
```
## 关键规则
### 1. 参数处理
所有动作方法的参数都可能以字符串形式传入,必须进行类型转换:
```python
async def my_action(self, value: float, **kwargs) -> bool:
# 始终进行类型转换
try:
value = float(value)
except (ValueError, TypeError) as e:
self.logger.error(f"参数类型错误: {e}")
return False
```
### 2. vessel 参数处理
vessel 参数可能是字符串ID或字典
```python
def extract_vessel_id(vessel: Union[str, dict]) -> str:
if isinstance(vessel, dict):
return vessel.get("id", "")
return str(vessel) if vessel else ""
```
### 3. 状态更新
使用 `self.data` 字典存储状态,属性读取状态:
```python
# 更新状态
self.data["status"] = "运行中"
self.data["current_speed"] = 300.0
# 读取状态(通过属性)
@property
def status(self) -> str:
return self.data.get("status", "待机")
```
### 4. 异步等待
使用 ROS 节点的 sleep 方法:
```python
# 正确
await self._ros_node.sleep(1.0)
# 避免(除非在纯 Python 测试环境)
await asyncio.sleep(1.0)
```
### 5. 进度反馈
长时间运行的操作需要提供进度反馈:
```python
while remaining > 0:
progress = (elapsed / total_time) * 100
self.data["status"] = f"运行中: {progress:.0f}%"
self.data["remaining_time"] = remaining
await self._ros_node.sleep(1.0)
```
## 虚拟设备
虚拟设备用于测试和演示,放在 `unilabos/devices/virtual/` 目录:
- 类名以 `Virtual` 开头
- 文件名以 `virtual_` 开头
- 模拟真实设备的行为和时序
- 使用表情符号增强日志可读性(可选)
## 工作站设备
工作站是组合多个设备的复杂设备:
```python
from unilabos.devices.workstation.workstation_base import WorkstationBase
class MyWorkstation(WorkstationBase):
"""组合工作站"""
async def execute_workflow(self, workflow: Dict[str, Any]) -> bool:
"""执行工作流"""
pass
```
## 设备注册
设备类开发完成后,需要在注册表中注册:
1. 创建/编辑 `unilabos/registry/devices/my_category.yaml`
2. 添加设备配置(参考 `virtual_device.yaml`
3. 运行 `--complete_registry` 自动生成 schema

View File

@@ -0,0 +1,240 @@
---
description: 协议编译器开发规范
globs: ["unilabos/compile/**/*.py"]
---
# 协议编译器开发规范
## 概述
协议编译器负责将高级实验操作(如 Stir、Add、Filter编译为设备可执行的动作序列。
## 文件命名
- 位置: `unilabos/compile/`
- 命名: `{operation}_protocol.py`
- 示例: `stir_protocol.py`, `add_protocol.py`, `filter_protocol.py`
## 协议函数模板
```python
from typing import List, Dict, Any, Union
import networkx as nx
import logging
from .utils.unit_parser import parse_time_input
from .utils.vessel_parser import extract_vessel_id
logger = logging.getLogger(__name__)
def generate_{operation}_protocol(
G: nx.DiGraph,
vessel: Union[str, dict],
param1: Union[str, float] = "0",
param2: float = 0.0,
**kwargs
) -> List[Dict[str, Any]]:
"""
生成{操作}协议序列
Args:
G: 物理拓扑图 (NetworkX DiGraph)
vessel: 容器ID或Resource字典
param1: 参数1支持字符串单位如 "5 min"
param2: 参数2
**kwargs: 其他参数
Returns:
List[Dict]: 动作序列
Raises:
ValueError: 参数无效时
"""
# 1. 提取 vessel_id
vessel_id = extract_vessel_id(vessel)
# 2. 验证参数
if not vessel_id:
raise ValueError("vessel 参数不能为空")
if vessel_id not in G.nodes():
raise ValueError(f"容器 '{vessel_id}' 不存在于系统中")
# 3. 解析参数(支持单位)
parsed_param1 = parse_time_input(param1) # "5 min" -> 300.0
# 4. 查找设备
device_id = find_connected_device(G, vessel_id, device_type="my_device")
# 5. 生成动作序列
action_sequence = []
action = {
"device_id": device_id,
"action_name": "my_action",
"action_kwargs": {
"vessel": {"id": vessel_id}, # 始终使用字典格式
"param1": float(parsed_param1),
"param2": float(param2),
}
}
action_sequence.append(action)
logger.info(f"生成协议: {len(action_sequence)} 个动作")
return action_sequence
def find_connected_device(
G: nx.DiGraph,
vessel_id: str,
device_type: str = ""
) -> str:
"""
查找与容器相连的设备
Args:
G: 拓扑图
vessel_id: 容器ID
device_type: 设备类型关键字
Returns:
str: 设备ID
"""
# 查找所有匹配类型的设备
device_nodes = []
for node in G.nodes():
node_class = G.nodes[node].get('class', '') or ''
if device_type.lower() in node_class.lower():
device_nodes.append(node)
# 检查连接
if vessel_id and device_nodes:
for device in device_nodes:
if G.has_edge(device, vessel_id) or G.has_edge(vessel_id, device):
return device
# 返回第一个可用设备
if device_nodes:
return device_nodes[0]
# 默认设备
return f"{device_type}_1"
```
## 关键规则
### 1. vessel 参数处理
vessel 参数可能是字符串或字典,需要统一处理:
```python
def extract_vessel_id(vessel: Union[str, dict]) -> str:
"""提取vessel_id"""
if isinstance(vessel, dict):
# 可能是 {"id": "xxx"} 或完整 Resource 对象
return vessel.get("id", list(vessel.values())[0].get("id", ""))
return str(vessel) if vessel else ""
```
### 2. action_kwargs 中的 vessel
始终使用 `{"id": vessel_id}` 格式传递 vessel
```python
# 正确
"action_kwargs": {
"vessel": {"id": vessel_id}, # 字符串ID包装为字典
}
# 避免
"action_kwargs": {
"vessel": vessel_resource, # 不要传递完整 Resource 对象
}
```
### 3. 单位解析
使用 `parse_time_input` 解析时间参数:
```python
from .utils.unit_parser import parse_time_input
# 支持格式: "5 min", "1 h", "300", "1.5 hours"
time_seconds = parse_time_input("5 min") # -> 300.0
time_seconds = parse_time_input(120) # -> 120.0
time_seconds = parse_time_input("1 h") # -> 3600.0
```
### 4. 参数验证
所有参数必须进行验证和类型转换:
```python
# 验证范围
if speed < 10.0 or speed > 1500.0:
logger.warning(f"速度 {speed} 超出范围,修正为 300")
speed = 300.0
# 类型转换
param = float(param) if not isinstance(param, (int, float)) else param
```
### 5. 日志记录
使用项目日志记录器:
```python
logger = logging.getLogger(__name__)
def generate_protocol(...):
logger.info(f"开始生成协议...")
logger.debug(f"参数: vessel={vessel_id}, time={time}")
logger.warning(f"参数修正: {old_value} -> {new_value}")
```
## 便捷函数
为常用操作提供便捷函数:
```python
def stir_briefly(G: nx.DiGraph, vessel: Union[str, dict],
speed: float = 300.0) -> List[Dict[str, Any]]:
"""短时间搅拌30秒"""
return generate_stir_protocol(G, vessel, time="30", stir_speed=speed)
def stir_vigorously(G: nx.DiGraph, vessel: Union[str, dict],
time: str = "5 min") -> List[Dict[str, Any]]:
"""剧烈搅拌"""
return generate_stir_protocol(G, vessel, time=time, stir_speed=800.0)
```
## 测试函数
每个协议文件应包含测试函数:
```python
def test_{operation}_protocol():
"""测试协议生成"""
# 测试参数处理
vessel_dict = {"id": "flask_1", "name": "反应瓶1"}
vessel_id = extract_vessel_id(vessel_dict)
assert vessel_id == "flask_1"
# 测试单位解析
time_s = parse_time_input("5 min")
assert time_s == 300.0
if __name__ == "__main__":
test_{operation}_protocol()
```
## 现有协议参考
- `stir_protocol.py` - 搅拌操作
- `add_protocol.py` - 添加物料
- `filter_protocol.py` - 过滤操作
- `heatchill_protocol.py` - 加热/冷却
- `separate_protocol.py` - 分离操作
- `evaporate_protocol.py` - 蒸发操作

View File

@@ -0,0 +1,319 @@
---
description: 注册表配置规范 (YAML)
globs: ["unilabos/registry/**/*.yaml"]
---
# 注册表配置规范
## 概述
注册表使用 YAML 格式定义设备和资源类型,是 Uni-Lab-OS 的核心配置系统。
## 目录结构
```
unilabos/registry/
├── devices/ # 设备类型注册
│ ├── virtual_device.yaml
│ ├── liquid_handler.yaml
│ └── ...
├── device_comms/ # 通信设备配置
│ ├── communication_devices.yaml
│ └── modbus_ioboard.yaml
└── resources/ # 资源类型注册
├── bioyond/
├── organic/
├── opentrons/
└── ...
```
## 设备注册表格式
### 基本结构
```yaml
device_type_id:
# 基本信息
description: "设备描述"
version: "1.0.0"
category:
- category_name
icon: "icon_device.webp"
# 类配置
class:
module: "unilabos.devices.my_module:MyClass"
type: python
# 状态类型(属性 -> ROS消息类型
status_types:
status: String
temperature: Float64
is_running: Bool
# 动作映射
action_value_mappings:
action_name:
type: UniLabJsonCommand # 或 UniLabJsonCommandAsync
goal: {}
feedback: {}
result: {}
schema: {...}
handles: {}
```
### action_value_mappings 详细格式
```yaml
action_value_mappings:
# 同步动作
my_sync_action:
type: UniLabJsonCommand
goal:
param1: param1
param2: param2
feedback: {}
result:
success: success
message: message
goal_default:
param1: 0.0
param2: ""
handles: {}
placeholder_keys:
device_param: unilabos_devices # 设备选择器
resource_param: unilabos_resources # 资源选择器
schema:
title: "动作名称参数"
description: "动作描述"
type: object
properties:
goal:
type: object
properties:
param1:
type: number
param2:
type: string
required:
- param1
feedback: {}
result:
type: object
properties:
success:
type: boolean
message:
type: string
required:
- goal
# 异步动作
my_async_action:
type: UniLabJsonCommandAsync
goal: {}
feedback:
progress: progress
current_status: status
result:
success: success
schema: {...}
```
### 自动生成的动作
以 `auto-` 开头的动作由系统自动生成:
```yaml
action_value_mappings:
auto-initialize:
type: UniLabJsonCommandAsync
goal: {}
feedback: {}
result: {}
schema: {...}
auto-cleanup:
type: UniLabJsonCommandAsync
goal: {}
feedback: {}
result: {}
schema: {...}
```
### handles 配置
用于工作流编辑器中的数据流连接:
```yaml
handles:
input:
- handler_key: "input_resource"
data_type: "resource"
label: "输入资源"
data_source: "handle"
data_key: "resources"
output:
- handler_key: "output_labware"
data_type: "resource"
label: "输出器皿"
data_source: "executor"
data_key: "created_resource.@flatten"
```
## 资源注册表格式
```yaml
resource_type_id:
description: "资源描述"
version: "1.0.0"
category:
- category_name
icon: ""
handles: []
init_param_schema: {}
class:
module: "unilabos.resources.my_module:MyResource"
type: pylabrobot # 或 python
```
### PyLabRobot 资源示例
```yaml
BIOYOND_Electrolyte_6VialCarrier:
category:
- bottle_carriers
- bioyond
class:
module: "unilabos.resources.bioyond.bottle_carriers:BIOYOND_Electrolyte_6VialCarrier"
type: pylabrobot
version: "1.0.0"
```
## 状态类型映射
Python 类型到 ROS 消息类型的映射:
| Python 类型 | ROS 消息类型 |
|------------|-------------|
| `str` | `String` |
| `bool` | `Bool` |
| `int` | `Int64` |
| `float` | `Float64` |
| `list` | `String` (序列化) |
| `dict` | `String` (序列化) |
## 自动完善注册表
使用 `--complete_registry` 参数自动生成 schema
```bash
python -m unilabos.app.main --complete_registry
```
这会:
1. 扫描设备类的方法签名
2. 自动生成 `auto-` 前缀的动作
3. 生成 JSON Schema
4. 更新 YAML 文件
## 验证规则
1. **device_type_id** 必须唯一
2. **module** 路径必须正确可导入
3. **status_types** 的类型必须是有效的 ROS 消息类型
4. **schema** 必须是有效的 JSON Schema
## 示例:完整设备配置
```yaml
virtual_stirrer:
category:
- virtual_device
description: "虚拟搅拌器设备"
version: "1.0.0"
icon: "icon_stirrer.webp"
handles: []
init_param_schema: {}
class:
module: "unilabos.devices.virtual.virtual_stirrer:VirtualStirrer"
type: python
status_types:
status: String
operation_mode: String
current_speed: Float64
is_stirring: Bool
remaining_time: Float64
action_value_mappings:
auto-initialize:
type: UniLabJsonCommandAsync
goal: {}
feedback: {}
result: {}
schema:
title: "initialize参数"
type: object
properties:
goal:
type: object
properties: {}
feedback: {}
result: {}
required:
- goal
stir:
type: UniLabJsonCommandAsync
goal:
stir_time: stir_time
stir_speed: stir_speed
settling_time: settling_time
feedback:
current_speed: current_speed
remaining_time: remaining_time
result:
success: success
goal_default:
stir_time: 60.0
stir_speed: 300.0
settling_time: 30.0
handles: {}
schema:
title: "stir参数"
description: "搅拌操作"
type: object
properties:
goal:
type: object
properties:
stir_time:
type: number
description: "搅拌时间(秒)"
stir_speed:
type: number
description: "搅拌速度RPM"
settling_time:
type: number
description: "沉降时间(秒)"
required:
- stir_time
- stir_speed
feedback:
type: object
properties:
current_speed:
type: number
remaining_time:
type: number
result:
type: object
properties:
success:
type: boolean
required:
- goal
```

View File

@@ -0,0 +1,233 @@
---
description: ROS 2 集成开发规范
globs: ["unilabos/ros/**/*.py", "**/*_node.py"]
---
# ROS 2 集成开发规范
## 概述
Uni-Lab-OS 使用 ROS 2 作为设备通信中间件,基于 rclpy 实现。
## 核心组件
### BaseROS2DeviceNode
设备节点基类,提供:
- ROS Topic 自动发布(状态属性)
- Action Server 自动创建(设备动作)
- 资源管理服务
- 异步任务调度
```python
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
```
### 消息转换器
```python
from unilabos.ros.msgs.message_converter import (
convert_to_ros_msg,
convert_from_ros_msg_with_mapping,
msg_converter_manager,
ros_action_to_json_schema,
ros_message_to_json_schema,
)
```
## 设备与 ROS 集成
### post_init 方法
设备类必须实现 `post_init` 方法接收 ROS 节点:
```python
class MyDevice:
_ros_node: BaseROS2DeviceNode
def post_init(self, ros_node: BaseROS2DeviceNode):
"""ROS节点注入"""
self._ros_node = ros_node
```
### 状态属性发布
设备的 `@property` 属性会自动发布为 ROS Topic
```python
class MyDevice:
@property
def temperature(self) -> float:
return self._temperature
# 自动发布到 /{namespace}/temperature Topic
```
### Topic 配置装饰器
```python
from unilabos.utils.decorator import topic_config
class MyDevice:
@property
@topic_config(period=1.0, print_publish=False, qos=10)
def fast_data(self) -> float:
"""高频数据 - 每秒发布一次"""
return self._fast_data
@property
@topic_config(period=5.0)
def slow_data(self) -> str:
"""低频数据 - 每5秒发布一次"""
return self._slow_data
```
### 订阅装饰器
```python
from unilabos.utils.decorator import subscribe
class MyDevice:
@subscribe(topic="/external/sensor_data", qos=10)
def on_sensor_data(self, msg):
"""订阅外部Topic"""
self._sensor_value = msg.data
```
## 异步操作
### 使用 ROS 节点睡眠
```python
# 推荐使用ROS节点的睡眠方法
await self._ros_node.sleep(1.0)
# 不推荐直接使用asyncio可能导致回调阻塞
await asyncio.sleep(1.0)
```
### 获取事件循环
```python
from unilabos.ros.x.rclpyx import get_event_loop
loop = get_event_loop()
```
## 消息类型
### unilabos_msgs 包
```python
from unilabos_msgs.msg import Resource
from unilabos_msgs.srv import (
ResourceAdd,
ResourceDelete,
ResourceUpdate,
ResourceList,
SerialCommand,
)
from unilabos_msgs.action import SendCmd
```
### Resource 消息结构
```python
Resource:
id: str
name: str
category: str
type: str
parent: str
children: List[str]
config: str # JSON字符串
data: str # JSON字符串
sample_id: str
pose: Pose
```
## 日志适配器
```python
from unilabos.utils.log import info, debug, warning, error, trace
class MyDevice:
def __init__(self):
# 创建设备专属日志器
self.logger = logging.getLogger(f"MyDevice.{self.device_id}")
```
ROSLoggerAdapter 同时向自定义日志和 ROS 日志发送消息。
## Action Server
设备动作自动创建为 ROS Action Server
```yaml
# 在注册表中配置
action_value_mappings:
my_action:
type: UniLabJsonCommandAsync # 异步Action
goal: {...}
feedback: {...}
result: {...}
```
### Action 类型
- **UniLabJsonCommand**: 同步动作
- **UniLabJsonCommandAsync**: 异步动作支持feedback
## 服务客户端
```python
from rclpy.client import Client
# 调用其他节点的服务
response = await self._ros_node.call_service(
service_name="/other_node/service",
request=MyServiceRequest(...)
)
```
## 命名空间
设备节点使用命名空间隔离:
```
/{device_id}/ # 设备命名空间
/{device_id}/status # 状态Topic
/{device_id}/temperature # 温度Topic
/{device_id}/my_action # 动作Server
```
## 调试
### 查看 Topic
```bash
ros2 topic list
ros2 topic echo /{device_id}/status
```
### 查看 Action
```bash
ros2 action list
ros2 action info /{device_id}/my_action
```
### 查看 Service
```bash
ros2 service list
ros2 service call /{device_id}/resource_list unilabos_msgs/srv/ResourceList
```
## 最佳实践
1. **状态属性命名**: 使用蛇形命名法snake_case
2. **Topic 频率**: 根据数据变化频率调整,避免过高频率
3. **Action 反馈**: 长时间操作提供进度反馈
4. **错误处理**: 使用 try-except 捕获并记录错误
5. **资源清理**: 在 cleanup 方法中正确清理资源

View File

@@ -0,0 +1,357 @@
---
description: 测试开发规范
globs: ["tests/**/*.py", "**/test_*.py"]
---
# 测试开发规范
## 目录结构
```
tests/
├── __init__.py
├── devices/ # 设备测试
│ └── liquid_handling/
│ └── test_transfer_liquid.py
├── resources/ # 资源测试
│ ├── test_bottle_carrier.py
│ └── test_resourcetreeset.py
├── ros/ # ROS消息测试
│ └── msgs/
│ ├── test_basic.py
│ ├── test_conversion.py
│ └── test_mapping.py
└── workflow/ # 工作流测试
└── merge_workflow.py
```
## 测试框架
使用 pytest 作为测试框架:
```bash
# 运行所有测试
pytest tests/
# 运行特定测试文件
pytest tests/resources/test_bottle_carrier.py
# 运行特定测试函数
pytest tests/resources/test_bottle_carrier.py::test_bottle_carrier
# 显示详细输出
pytest -v tests/
# 显示打印输出
pytest -s tests/
```
## 测试文件模板
```python
import pytest
from typing import List, Dict, Any
# 导入被测试的模块
from unilabos.resources.bioyond.bottle_carriers import (
BIOYOND_Electrolyte_6VialCarrier,
)
from unilabos.resources.bioyond.bottles import (
BIOYOND_PolymerStation_Solid_Vial,
)
class TestBottleCarrier:
"""BottleCarrier 测试类"""
def setup_method(self):
"""每个测试方法前执行"""
self.carrier = BIOYOND_Electrolyte_6VialCarrier("test_carrier")
def teardown_method(self):
"""每个测试方法后执行"""
pass
def test_carrier_creation(self):
"""测试载架创建"""
assert self.carrier.name == "test_carrier"
assert len(self.carrier.sites) == 6
def test_bottle_placement(self):
"""测试瓶子放置"""
bottle = BIOYOND_PolymerStation_Solid_Vial("test_bottle")
# 测试逻辑...
assert bottle.name == "test_bottle"
def test_standalone_function():
"""独立测试函数"""
result = some_function()
assert result is True
# 参数化测试
@pytest.mark.parametrize("input,expected", [
("5 min", 300.0),
("1 h", 3600.0),
("120", 120.0),
(60, 60.0),
])
def test_time_parsing(input, expected):
"""测试时间解析"""
from unilabos.compile.utils.unit_parser import parse_time_input
assert parse_time_input(input) == expected
# 异常测试
def test_invalid_input_raises_error():
"""测试无效输入抛出异常"""
with pytest.raises(ValueError) as exc_info:
invalid_function("bad_input")
assert "invalid" in str(exc_info.value).lower()
# 跳过条件测试
@pytest.mark.skipif(
not os.environ.get("ROS_DISTRO"),
reason="需要ROS环境"
)
def test_ros_feature():
"""需要ROS环境的测试"""
pass
```
## 设备测试
### 虚拟设备测试
```python
import pytest
import asyncio
from unittest.mock import MagicMock, AsyncMock
from unilabos.devices.virtual.virtual_stirrer import VirtualStirrer
class TestVirtualStirrer:
"""VirtualStirrer 测试"""
@pytest.fixture
def stirrer(self):
"""创建测试用搅拌器"""
device = VirtualStirrer(
device_id="test_stirrer",
config={"max_speed": 1500.0, "min_speed": 50.0}
)
# Mock ROS节点
mock_node = MagicMock()
mock_node.sleep = AsyncMock(return_value=None)
device.post_init(mock_node)
return device
@pytest.mark.asyncio
async def test_initialize(self, stirrer):
"""测试初始化"""
result = await stirrer.initialize()
assert result is True
assert stirrer.status == "待机中"
@pytest.mark.asyncio
async def test_stir_action(self, stirrer):
"""测试搅拌动作"""
await stirrer.initialize()
result = await stirrer.stir(
stir_time=5.0,
stir_speed=300.0,
settling_time=2.0
)
assert result is True
assert stirrer.operation_mode == "Completed"
@pytest.mark.asyncio
async def test_stir_invalid_speed(self, stirrer):
"""测试无效速度"""
await stirrer.initialize()
# 速度超出范围
result = await stirrer.stir(
stir_time=5.0,
stir_speed=2000.0, # 超过max_speed
settling_time=0.0
)
assert result is False
assert "错误" in stirrer.status
```
### 异步测试配置
```python
# conftest.py
import pytest
import asyncio
@pytest.fixture(scope="session")
def event_loop():
"""创建事件循环"""
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
```
## 资源测试
```python
import pytest
from unilabos.resources.resource_tracker import (
ResourceTreeSet,
ResourceTreeInstance,
)
def test_resource_tree_creation():
"""测试资源树创建"""
tree_set = ResourceTreeSet()
# 添加资源
resource = {"id": "res_1", "name": "Resource 1"}
tree_set.add_resource(resource)
# 验证
assert len(tree_set.all_nodes) == 1
assert tree_set.get_resource("res_1") is not None
def test_resource_tree_merge():
"""测试资源树合并"""
local_set = ResourceTreeSet()
remote_set = ResourceTreeSet()
# 设置数据...
local_set.merge_remote_resources(remote_set)
# 验证合并结果...
```
## ROS 消息测试
```python
import pytest
from unilabos.ros.msgs.message_converter import (
convert_to_ros_msg,
convert_from_ros_msg_with_mapping,
msg_converter_manager,
)
def test_message_conversion():
"""测试消息转换"""
# Python -> ROS
python_data = {"id": "test", "value": 42}
ros_msg = convert_to_ros_msg(python_data, MyMsgType)
assert ros_msg.id == "test"
assert ros_msg.value == 42
# ROS -> Python
result = convert_from_ros_msg_with_mapping(ros_msg, mapping)
assert result["id"] == "test"
```
## 协议测试
```python
import pytest
import networkx as nx
from unilabos.compile.stir_protocol import (
generate_stir_protocol,
extract_vessel_id,
)
@pytest.fixture
def topology_graph():
"""创建测试拓扑图"""
G = nx.DiGraph()
G.add_node("flask_1", **{"class": "flask"})
G.add_node("stirrer_1", **{"class": "virtual_stirrer"})
G.add_edge("stirrer_1", "flask_1")
return G
def test_generate_stir_protocol(topology_graph):
"""测试搅拌协议生成"""
actions = generate_stir_protocol(
G=topology_graph,
vessel="flask_1",
time="5 min",
stir_speed=300.0
)
assert len(actions) == 1
assert actions[0]["device_id"] == "stirrer_1"
assert actions[0]["action_name"] == "stir"
def test_extract_vessel_id():
"""测试vessel_id提取"""
# 字典格式
assert extract_vessel_id({"id": "flask_1"}) == "flask_1"
# 字符串格式
assert extract_vessel_id("flask_2") == "flask_2"
# 空值
assert extract_vessel_id("") == ""
```
## 测试标记
```python
# 慢速测试
@pytest.mark.slow
def test_long_running():
pass
# 需要网络
@pytest.mark.network
def test_network_call():
pass
# 需要ROS
@pytest.mark.ros
def test_ros_feature():
pass
```
运行特定标记的测试:
```bash
pytest -m "not slow" # 排除慢速测试
pytest -m ros # 仅ROS测试
```
## 覆盖率
```bash
# 生成覆盖率报告
pytest --cov=unilabos tests/
# HTML报告
pytest --cov=unilabos --cov-report=html tests/
```
## 最佳实践
1. **测试命名**: `test_{功能}_{场景}_{预期结果}`
2. **独立性**: 每个测试独立运行,不依赖其他测试
3. **Mock外部依赖**: 使用 unittest.mock 模拟外部服务
4. **参数化**: 使用 `@pytest.mark.parametrize` 减少重复代码
5. **fixtures**: 使用 fixtures 共享测试设置
6. **断言清晰**: 每个断言只验证一件事

View File

@@ -0,0 +1,353 @@
---
description: Uni-Lab-OS 实验室自动化平台开发规范 - 核心规则
globs: ["**/*.py", "**/*.yaml", "**/*.json"]
---
# Uni-Lab-OS 项目开发规范
## 项目概述
Uni-Lab-OS 是一个实验室自动化操作系统,用于连接和控制各种实验设备,实现实验工作流的自动化和标准化。
## 技术栈
- **Python 3.11** - 核心开发语言
- **ROS 2** - 设备通信中间件 (rclpy)
- **Conda/Mamba** - 包管理 (robostack-staging, conda-forge)
- **FastAPI** - Web API 服务
- **WebSocket** - 实时通信
- **NetworkX** - 拓扑图管理
- **YAML** - 配置和注册表定义
- **PyLabRobot** - 实验室自动化库集成
- **pytest** - 测试框架
- **asyncio** - 异步编程
## 项目结构
```
unilabos/
├── app/ # 应用入口、Web服务、后端
├── compile/ # 协议编译器 (stir, add, filter 等)
├── config/ # 配置管理
├── devices/ # 设备驱动 (真实/虚拟)
├── device_comms/ # 设备通信协议
├── device_mesh/ # 3D网格和可视化
├── registry/ # 设备和资源类型注册表 (YAML)
├── resources/ # 资源定义
├── ros/ # ROS 2 集成
├── utils/ # 工具函数
└── workflow/ # 工作流管理
```
## 代码规范
### Python 风格
1. **类型注解**:所有函数必须使用类型注解
```python
def transfer_liquid(
source: str,
destination: str,
volume: float,
**kwargs
) -> List[Dict[str, Any]]:
```
2. **Docstring**:使用 Google 风格的文档字符串
```python
def initialize(self) -> bool:
"""
初始化设备
Returns:
bool: 初始化是否成功
"""
```
3. **导入顺序**
- 标准库
- 第三方库
- ROS 相关 (rclpy, unilabos_msgs)
- 项目内部模块
### 异步编程
1. 设备操作方法使用 `async def`
2. 使用 `await self._ros_node.sleep()` 而非 `asyncio.sleep()`
3. 长时间运行操作需提供进度反馈
```python
async def stir(self, stir_time: float, stir_speed: float, **kwargs) -> bool:
"""执行搅拌操作"""
start_time = time_module.time()
while True:
elapsed = time_module.time() - start_time
remaining = max(0, stir_time - elapsed)
self.data.update({
"remaining_time": remaining,
"status": f"搅拌中: {stir_speed} RPM"
})
if remaining <= 0:
break
await self._ros_node.sleep(1.0)
return True
```
### 日志规范
使用项目自定义日志系统:
```python
from unilabos.utils.log import logger, info, debug, warning, error, trace
# 在设备类中使用
self.logger = logging.getLogger(f"DeviceName.{self.device_id}")
self.logger.info("设备初始化完成")
```
## 设备驱动开发
### 设备类结构
```python
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
class MyDevice:
"""设备驱动类"""
_ros_node: BaseROS2DeviceNode
def __init__(self, device_id: str = None, config: Dict[str, Any] = None, **kwargs):
self.device_id = device_id or "unknown_device"
self.config = config or {}
self.data = {} # 设备状态数据
def post_init(self, ros_node: BaseROS2DeviceNode):
"""ROS节点注入"""
self._ros_node = ros_node
async def initialize(self) -> bool:
"""初始化设备"""
pass
async def cleanup(self) -> bool:
"""清理设备"""
pass
# 状态属性 - 自动发布为 ROS Topic
@property
def status(self) -> str:
return self.data.get("status", "待机")
```
### 状态属性装饰器
```python
from unilabos.utils.decorator import topic_config
class MyDevice:
@property
@topic_config(period=1.0, qos=10) # 每秒发布一次
def temperature(self) -> float:
return self._temperature
```
### 虚拟设备
虚拟设备放置在 `unilabos/devices/virtual/` 目录下,命名为 `virtual_*.py`
## 注册表配置
### 设备注册表 (YAML)
位置: `unilabos/registry/devices/*.yaml`
```yaml
my_device_type:
category:
- my_category
description: "设备描述"
version: "1.0.0"
class:
module: "unilabos.devices.my_device:MyDevice"
type: python
status_types:
status: String
temperature: Float64
action_value_mappings:
auto-initialize:
type: UniLabJsonCommandAsync
goal: {}
feedback: {}
result: {}
schema: {...}
```
### 资源注册表 (YAML)
位置: `unilabos/registry/resources/**/*.yaml`
```yaml
my_container:
category:
- container
class:
module: "unilabos.resources.my_resource:MyContainer"
type: pylabrobot
version: "1.0.0"
```
## 协议编译器
位置: `unilabos/compile/*_protocol.py`
### 协议生成函数模板
```python
from typing import List, Dict, Any, Union
import networkx as nx
def generate_my_protocol(
G: nx.DiGraph,
vessel: Union[str, dict],
param1: float = 0.0,
**kwargs
) -> List[Dict[str, Any]]:
"""
生成操作协议序列
Args:
G: 物理拓扑图
vessel: 容器ID或字典
param1: 参数1
Returns:
List[Dict]: 动作序列
"""
# 提取vessel_id
vessel_id = vessel if isinstance(vessel, str) else vessel.get("id", "")
# 查找设备
device_id = find_connected_device(G, vessel_id)
# 生成动作
action_sequence = [{
"device_id": device_id,
"action_name": "my_action",
"action_kwargs": {
"vessel": {"id": vessel_id},
"param1": float(param1)
}
}]
return action_sequence
```
## 测试规范
### 测试文件位置
- 单元测试: `tests/` 目录
- 设备测试: `tests/devices/`
- 资源测试: `tests/resources/`
- ROS消息测试: `tests/ros/msgs/`
### 测试命名
```python
# tests/devices/my_device/test_my_device.py
import pytest
def test_device_initialization():
"""测试设备初始化"""
pass
def test_device_action():
"""测试设备动作"""
pass
```
## 错误处理
```python
from unilabos.utils.exception import UniLabException
try:
result = await device.execute_action()
except ValueError as e:
self.logger.error(f"参数错误: {e}")
self.data["status"] = "错误: 参数无效"
return False
except Exception as e:
self.logger.error(f"执行失败: {e}")
raise
```
## 配置管理
```python
from unilabos.config.config import BasicConfig, HTTPConfig
# 读取配置
port = BasicConfig.port
is_host = BasicConfig.is_host_mode
# 配置文件: local_config.py
```
## 常用工具
### 单例模式
```python
from unilabos.utils.decorator import singleton
@singleton
class MyManager:
pass
```
### 类型检查
```python
from unilabos.utils.type_check import NoAliasDumper
yaml.dump(data, f, Dumper=NoAliasDumper)
```
### 导入管理
```python
from unilabos.utils.import_manager import get_class
device_class = get_class("unilabos.devices.my_device:MyDevice")
```
## Git 提交规范
提交信息格式:
```
<type>(<scope>): <subject>
<body>
```
类型:
- `feat`: 新功能
- `fix`: 修复bug
- `docs`: 文档更新
- `refactor`: 重构
- `test`: 测试相关
- `chore`: 构建/工具相关
示例:
```
feat(devices): 添加虚拟搅拌器设备
- 实现VirtualStirrer类
- 支持定时搅拌和持续搅拌模式
- 添加速度验证逻辑
```

View File

@@ -1,483 +0,0 @@
---
name: yibin-electrolyte-submit
description: >-
通过 Uni-Lab Notebook API 向宜宾电解液工站提交实验覆盖配液分液Bioyond LIMS
扣电组装CoinCellAssembly、扣电测试全流程。
包含 Excel 解析、formulation 构建、工作流节点参数填写、notebook 提交与状态轮询。
Use when the user wants to submit electrolyte experiments, assemble or test coin cells,
parse experiment Excel files, build notebook payloads, or mentions
宜宾/配液/分液/扣电/电解液实验/notebook提交/CoinCell/BioyondLIMS.
---
# 宜宾电解液产线 API 操作指南
本 skill 覆盖两个设备的完整操作流程:
1. **配液分液工站** (`bioyond_cell_workstation`) — Bioyond LIMS 配液/分液/转运
2. **扣电组装站** (`BatteryStation`) — Modbus PLC 扣电组装/数据采集
## 设备信息
| 属性 | 配液分液工站 | 扣电组装站 |
|------|------------|-----------|
| device_id | `bioyond_cell_workstation` | `BatteryStation` |
| 显示名 | 配液分液工站 | 扣电工作站 |
| 源码 | `unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py` | `unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py` |
| 类名 | `BioyondCellWorkstation` | `CoinCellAssemblyWorkstation` |
| 通讯 | HTTP REST (Bioyond LIMS API) | Modbus TCP (PLC 寄存器) |
## 前置条件
### 认证信息
```
AUTH="Authorization: Lab OTdlY2FkNmUtZmZmMi00YjhiLThhOWEtNWM5ODAyOTJmOTUxOmU0OGM2YWJkLTA4ZmEtNDFjMy04NzhhLTc4M2FiODlhZjYxMw=="
BASE="https://uni-lab.test.bohrium.com"
```
来源:`--ak 97ecad6e-fff2-4b8b-8a9a-5c980292f951 --sk e48c6abd-08fa-41c3-878a-783ab89af613 --addr test`
### 启动 unilab云端模式
> **重要**:提交实验前必须确保 unilab 正在运行且已连接云端 WebSocket。
```powershell
$env:PYTHONIOENCODING="utf-8"
conda activate newunilab2603
cd D:\UniLabdev\Uni-Lab-OS\unilabos\devices\workstation
unilab -g D:\UniLabdev\Uni-Lab-OS\yibin_electrolyte_config.json --ak 97ecad6e-fff2-4b8b-8a9a-5c980292f951 --sk e48c6abd-08fa-41c3-878a-783ab89af613 --upload_registry --addr test --disable_browser --skip_env_check
```
**启动要点**
1. 必须先激活虚拟环境 `newunilab2603`
2. 工作目录切到 `unilabos/devices/workstation`(设备驱动所在目录)
3. `--upload_registry` 将 64 个设备 + 142 个资源注册到云端
4. `--skip_env_check` + `PYTHONIOENCODING=utf-8` 避免 Windows GBK 编码崩溃
5. 启动后后台运行,等待日志出现 `Application startup complete``Host node ready signal published with 3 devices`
**验证连接成功的标志**
- 日志出现 `[MessageProcessor] ... wss://uni-lab.test.bohrium.com/api/v1/ws/schedule`
- 日志出现 `[WebSocketClient] Host node ready signal published with 3 devices`
- 日志出现 `Resource tree add completed`(资源树同步完成)
### 云端物料上架与入库(启动后必做)
> **在提交实验之前,必须提醒用户完成以下云端操作,否则实验会因物料缺失而失败。**
1. **拖拽上料**:在云端 UI`$BASE/laboratory/<lab_uuid>`)的资源树视图中,将物料拖拽到对应的仓库/库位上。unilab 启动后资源树会自动同步到云端,但物料的**上架位置**需要用户在 UI 上手动确认或调整。
2. **确认配液物料入库**:确保所有配液实验需要的试剂(如 LiPF6、EC、DMC、EMC 等)已在 LIMS 系统中完成入库。可通过以下方式验证:
- 云端 UI 资源树中对应仓库(如"粉末加样头堆栈"、"配液站内试剂仓库")下有物料节点
- 或通过 API #8 获取资源树后检查物料节点是否存在
3. **告知 AI 可以提交**:用户完成上述操作后,告知 AI "物料已上架,可以提交实验"AI 再执行 notebook 提交流程。
**提醒话术模板**AI 应在启动成功后发送给用户):
```
unilab 已成功启动并连接云端。提交实验前请完成以下操作:
1. 在云端 UI 上确认资源树中的物料位置,必要时拖拽调整上料位
2. 确保配液所需的试剂(粉末、液体)已在 LIMS 中完成入库
3. 完成后告诉我,我将为您提交实验
```
### 生成 Action Schema首次使用
启动 unilab 后,在 `unilabos_data/` 目录下会生成 `req_device_registry_upload.json`。运行以下命令提取两个设备的 action JSON
```bash
python .cursor/skills/create-device-skill/scripts/extract_device_actions.py --registry unilabos_data/req_device_registry_upload.json bioyond_cell_workstation .cursor/skills/yibin-electrolyte-submit/actions/
python .cursor/skills/create-device-skill/scripts/extract_device_actions.py --registry unilabos_data/req_device_registry_upload.json BatteryStation .cursor/skills/yibin-electrolyte-submit/actions/
```
## 请求约定
- Windows 平台**必须用 `curl.exe`**(非 PowerShell 的 curl 别名)
- 所有请求带 `$AUTH`
- URL 格式:`$BASE/api/v1/<endpoint>`
- POST/PATCH 请求体写入临时 JSON 文件后用 `-d '@tmp.json'` 传参(避免 PowerShell 转义问题)
- 本地 API 基址:`http://127.0.0.1:8002/api/v1/`
## Session State
每次会话开始时,依次获取以下信息:
```bash
# 1. lab_uuid
curl.exe -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
# → data.uuid → $lab_uuid
# 2. project_uuid
curl.exe -s -X GET "$BASE/api/v1/lab/project/list?lab_uuid=$lab_uuid" -H "$AUTH"
# → data.items[].uuid/name → 让用户选择或取唯一项 → $project_uuid
```
## 工作流模板(重要)
> **必须向用户索要已有的工作流模板 UUID 或 URL不要自行创建。**
>
> 原因:通过 `edge/workflow/node` API 创建节点会报 `resource_node_template not found`——
> 云端的工作流节点模板系统和设备注册表是独立的,需要用户在云端 UI 上预先配置好工作流模板。
**获取方式**
- 用户提供工作流页面 URL`$BASE/laboratory/<lab_uuid>/workflow/<workflow_uuid>`
- 从 URL 中提取 `workflow_uuid`
- 用 API 获取模板详情:
```
GET /api/v1/lab/workflow/template/detail/<workflow_uuid>
```
返回 `data.nodes[]`:每个节点的 uuid、name、param、device_name、handles、disabled。
**示例**
```
工作流 URL: https://uni-lab.test.bohrium.com/laboratory/e9ed9102-d709-4741-b7a0-d1e8578e2065/workflow/b49f80d9-58d6-4456-a521-56f4dd39cda0
→ workflow_uuid = b49f80d9-58d6-4456-a521-56f4dd39cda0
```
从模板详情中提取**未 disabled** 的节点的 `uuid``name`,后续提交 notebook 时使用。
## API Endpoints
### #1 获取 lab_uuid
```
GET /api/v1/edge/lab/info
```
### #2 列出项目
```
GET /api/v1/lab/project/list?lab_uuid=$lab_uuid
```
返回 `data.items[]`,取 `uuid``name`
### #3 获取工作流模板详情
```
GET /api/v1/lab/workflow/template/detail/<workflow_uuid>
```
返回 `data.nodes[]`:每个节点的 uuid、name、param、device_name、handles。
提取活跃节点(`disabled != true`)的 `uuid` 用于构建 notebook 请求。
### #4 提交实验(创建 notebook— 核心 API
```
POST /api/v1/lab/notebook
Body: {
"lab_uuid": "<lab_uuid>",
"project_uuid": "<project_uuid>",
"workflow_uuid": "<workflow_uuid>",
"name": "<实验名称>",
"node_params": [
{
"sample_uuids": [],
"datas": [
{
"node_uuid": "<模板中的节点UUID>",
"param": { <参数键值对> },
"sample_params": []
}
]
}
]
}
```
**关键注意事项**
- `node_params` 是数组,每个元素代表一轮实验
- `datas` 中每个节点对应模板中的一个活跃节点
- `param` 中的字段名**必须使用 Python 函数参数名**,不能用模板中存储的 LIMS 字段名(见下方映射表)
### #5 查询 notebook 状态
```
GET /api/v1/lab/notebook/status?uuid=<notebook_uuid>
```
| status | 含义 |
|--------|------|
| `running` | 执行中 |
| `success` | 成功 |
| `fail` | 失败 |
### #6 运行设备单动作(本地 API
```
POST http://127.0.0.1:8002/api/v1/job/add
Body: {
"device_id": "<device_id>",
"action": "<action_name>",
"action_args": { <参数键值对> },
"sample_material": {}
}
```
本地 API 可自动解析 `action_type`,无需手动指定。适用于快速调试或云端未连接时。
### #7 查询本地任务状态
```
GET http://127.0.0.1:8002/api/v1/job/<job_id>/status
```
| status | 含义 |
|--------|------|
| 0 | UNKNOWN |
| 1 | ACCEPTED |
| 2 | EXECUTING |
| 4 | SUCCEEDED |
| 5 | CANCELED |
| 6 | ABORTED |
### #8 获取资源树
```
GET /api/v1/lab/material/download/<lab_uuid>
```
返回所有节点(`id`, `name`, `uuid`, `type`, `parent`)。填写 Slot 字段时用此接口筛选节点。
## Placeholder Slot 填写规则
action JSON 中 `placeholder_keys` 标记了哪些字段需要填 Slot
| placeholder 值 | Slot 类型 | 填写格式 |
|---------------|-----------|---------|
| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` |
| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` 路径字符串 |
| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` 路径字符串 |
| `unilabos_class` | ClassSlot | `"class_name"` 字符串 |
| `unilabos_formulation` | FormulationSlot | `[{well_name, liquids: [{name, volume}]}]` |
### ResourceSlot 填写
从 API #8 资源树中筛选**物料**节点:
```json
{"id": "/bioyond_cell_workstation/YB_Bioyond_Deck/自动堆栈-左", "name": "自动堆栈-左", "uuid": "3a19debc-..."}
```
数组字段:`[{id, name, uuid}, ...]`
特例:`create_resource``res_id` 允许填不存在的路径。
### DeviceSlot 填写
从资源树筛选 `type=device` 的节点,填路径字符串:
```
"/BatteryStation"
"/bioyond_cell_workstation"
```
### FormulationSlot 填写
```json
[
{
"sample_uuid": "",
"well_name": "YB_PrepBottle_15mL_Carrier_bottle_A1",
"liquids": [
{ "name": "LiPF6", "mass": 12.5 },
{ "name": "EC", "mass": 50.0 }
]
}
]
```
`well_name` 从资源树中取物料节点的 `name`
## 参数名映射(重要的坑)
> 工作流模板中存储的参数名和 Python 函数实际接受的参数名**不一定相同**。
> 提交 notebook 时必须使用 **Python 函数参数名**。
### `create_orders_formulation` 参数映射
| 模板中的 param 键 | 实际 Python 参数名 | 说明 |
|-------------------|-------------------|------|
| `pouch_cell_info` | `pouch_cell_volume` | 软包组装分液体积 (mL) |
| `conductivity_info` | `conductivity_volume` | 电导测试分液体积 (mL) |
| `load_shedding_info` | `coin_cell_volume` | 扣电组装分液体积 (mL) |
| `formulation` | `formulation` | 配方数组(名称一致) |
| `batch_id` | `batch_id` | 批次号(名称一致) |
| `bottle_type` | `bottle_type` | 配液瓶类型(名称一致) |
| `mix_time` | `mix_time` | 混匀时间(秒)(名称一致) |
| `conductivity_bottle_count` | `conductivity_bottle_count` | 电导瓶数(名称一致) |
当从模板中读到 `param` 包含 `pouch_cell_info` 等 LIMS 字段名时,提交 notebook 时要用右列的 Python 函数参数名。否则会报 `TypeError: got an unexpected keyword argument`
## 典型工作流
### 方式一:通过 Notebook API 批量提交(推荐)
**适用场景**:多组配方的批量实验,云端管理实验记录
```
1. 向用户索要工作流模板 URL不要自行创建
2. 获取 lab_uuidAPI #1和 project_uuidAPI #2
3. 获取工作流模板详情API #3提取活跃节点 UUID
4. 解析用户提供的 Excel 文件,构建 formulation 数组
5. 提交 notebookAPI #4
6. 轮询 notebook 状态API #5直到完成
```
**Excel 解析规则**
- 全局参数在第一个数据行:`batch_id``bottle_type``mix_time``coin_cell_volume``pouch_cell_volume``conductivity_volume``conductivity_bottle_count`
- 配方列从"试剂名1"开始,交替排列:试剂名列 + 质量列(以 `(g)` 结尾)
- 每行一个配方,`order_name` = 配方ID列
- formulation 中每个配方的 materials 数组只包含 `mass > 0` 的试剂
**node_params 构建**:所有配方放入同一个 round 的同一个 datas 条目中,因为只有一个节点(`create_orders_formulation`)。
### 方式二:设备单步操作(本地 API
**适用场景**:调试、快速测试
```
1. 确保 unilab 已在本地启动
2. 通过 POST http://127.0.0.1:8002/api/v1/job/add 提交任务
3. 通过 GET /api/v1/job/<job_id>/status 查询状态
```
### 设备操作流程:配液 → 转运 → 扣电
```
1. [配液站] scheduler_start_and_auto_feeding → 启动调度 + 上料
2. [配液站] create_orders_formulation → 创建配液实验(配方输入)
3. [配液站] transfer_3_to_2_to_1_auto → 分液瓶板转运到扣电站
4. [扣电站] func_pack_device_init_auto_start_combined → 初始化+自动+启动
5. [扣电站] func_sendbottle_allpack_multi → 发送瓶数+批量组装
```
## 云端使用心得
### 环境准备
- Windows 必须设置 `$env:PYTHONIOENCODING="utf-8"` 防止编码崩溃
- 使用 `--skip_env_check` 跳过依赖检查,加快启动
- 工作目录建议在 `unilabos/devices/workstation` 下启动
### 连接与注册
- `--upload_registry` 会自动将设备和资源注册到云端
- WebSocket 连接建立后,本地和云端的资源树会自动同步
- 注册成功后用户需在云端 UI 完成**物料拖放上架**操作
- 如果 unilab 断开重连,资源树会重新同步
### 工作流模板
- **不要自行调用 API 创建工作流或节点**——云端工作流节点模板需要预配置
- 始终向用户索要已有的工作流模板 URL
- 从 URL 中提取 `workflow_uuid`,通过 API #3 获取详情
- 模板中 `disabled: true` 的节点跳过,只处理活跃节点
### Notebook 实验提交
- Notebook 是云端管理实验的标准方式
- 一个 notebook 可包含多轮(`node_params` 数组),每轮可包含多组参数
- 提交后通过 API #5 轮询状态LIMS 配液流程通常需要较长时间8 个配方约 30-60 分钟)
- 实验进度可在云端 UI 和本地 unilab 日志中同步查看
### 常见错误
| 错误 | 原因 | 解决 |
|------|------|------|
| `edge not started error` | unilab 未连接云端 WebSocket | 检查 unilab 是否在运行、重启 |
| `resource_node_template not found` | 云端没有该设备的工作流模板 | 向用户索要已有模板,不要自行创建 |
| `got an unexpected keyword argument` | 参数名用了模板字段名而非 Python 函数参数名 | 参照上方映射表转换 |
| `UnicodeEncodeError: 'gbk'` | Windows 默认编码不支持特殊字符 | 设置 `PYTHONIOENCODING=utf-8` |
| `parse parameter error` | 云端 API 字段名错误 | `device_id` (非 `device_name`)、`action` (非 `action_name`)、必须带 `action_type` |
## 渐进加载策略
1. 先读本文件了解 API 端点、参数映射和云端注意事项
2. 需要具体 action 参数时,读 [action-index.md](action-index.md) 查找 action 名称和核心参数
3. 需要完整 schema 时,读 `actions/<action_name>.json`(需先运行提取命令生成)
4. 需要理解参数含义时,读设备源码
## 完整 Notebook 提交 Checklist
```
- [ ] 确认 unilab 已在本地启动并连接云端 WebSocket
- [ ] 提醒用户在云端 UI 拖拽上料、确认物料位置
- [ ] 提醒用户确认配液所需试剂已在 LIMS 完成入库
- [ ] 等待用户确认物料就绪后再继续
- [ ] 向用户索要工作流模板 URL → 提取 workflow_uuid
- [ ] 获取 lab_uuidAPI #1
- [ ] 获取 project_uuidAPI #2
- [ ] 获取工作流模板详情API #3提取活跃节点 UUID
- [ ] 解析用户 Excel 文件 → 构建 formulation + 全局参数
- [ ] 注意参数名映射(模板字段名 → Python 函数参数名)
- [ ] 提交 notebookAPI #4
- [ ] 轮询 notebook 状态API #5直到完成
```
---
## 真实场景:宜宾产线 Excel 提交提示词模板
> 以下为已验证可用的标准提示词,适用于配液-分液-扣电全流程。
### 场景说明
- unilab 运行在本地 Windows 机器miniforge 环境),连接云端 WebSocket
- AICursor / OpenClaw在任意设备上通过云端 API 操作,**不需要本地 127.0.0.1**
- 工作流为 5 节点串联:`create_orders_formulation``transfer_3_to_2_to_1_auto``func_pack_device_init_auto_start_combined``func_sendbottle_allpack_multi``transfer_1_to_2`
### 已知固定参数(宜宾产线)
```
BASE = https://uni-lab.test.bohrium.com
lab_uuid = e9ed9102-d709-4741-b7a0-d1e8578e2065
project = YiBinElectrolyte (bc5224b4-8120-4765-9961-9dfc1802a1f6)
workflow = 配液分液formulation全流程 (2bc59938-db79-4415-ac2d-9897ef125f2f)
```
#### 工作流节点 UUID固定无需重新查询
| 顺序 | action | node_uuid |
|------|--------|-----------|
| Step1 | auto-create_orders_formulation | `ece6744a-81ac-4ae4-8cd1-1c8eeda1dab6` |
| Step2 | auto-transfer_3_to_2_to_1_auto | `1c37a8dd-5ba0-413d-81db-94b9c936a171` |
| Step3 | auto-func_pack_device_init_auto_start_combined | `97a676a2-d257-4479-9096-073b40300970` |
| Step4 | auto-func_sendbottle_allpack_multi | `cf69017a-d29c-4aad-a63b-309d63dac2e9` |
| Step5 | auto-transfer_1_to_2 | `80d1c1aa-dbc3-4601-86b7-5c22a992dd9e` |
### 标准提示词
```
请使用 yibin-electrolyte-submit skill提交以下实验
工作流模板 URLhttps://uni-lab.test.bohrium.com/laboratory/e9ed9102-d709-4741-b7a0-d1e8578e2065/workflow/2bc59938-db79-4415-ac2d-9897ef125f2f
Excel 文件路径:<粘贴或上传 xlsx 路径>
注意事项:
- lab_uuid、project_uuid、workflow节点UUID均已固定无需重新查询
- 直接解析 Excel → 构建 payload → 提交
- mix_time 传标量整数即可(已兼容)
- 试剂名以 Excel 为准,注意区分 LiDFOB / LiDOFB 等拼写
- csv_export_path 取 Excel 中 csv_export_path 列的值
- 提交后告知 notebook UUID无需自动轮询实验耗时较长
```
### Excel 列结构说明experment_template_0415sim-*.xlsx
| 列范围 | 内容 |
|--------|------|
| C | batch_id |
| D | bottle_type |
| E-H | coin_cell_volume / conductivity_bottle_count / conductivity_volume / csv_export_path |
| I-T | 试剂名+质量 交替排列最多6对|
| U | mix_time |
| V | order_name每行配方的订单号|
| W | pouch_cell_volume |
| X-Y | target_device / target_locationStep2参数|
| AA | material_search_enableStep3参数|
| AB-AS | 扣电站参数Step4|
### CSV 导出说明
每次 `create_orders_formulation` 完成后,在 `csv_export_path` 目录下生成:
```
electrolyte_orders_<YYYYMMDD_HHMMSS>.csv
```
列:`orderCode, orderName, 配液瓶类型, 配液瓶二维码, 分液瓶类型, 分液瓶二维码, 目标配液质量比, 真实配液质量比, 时间`
> **注意**barCode 为 `null` 或 `"nullBarCode123456"` 是正常现象,表示 LIMS 中该物料尚未扫码。配液瓶缺失通常是因为物料未放在手动传递窗(`locationId` 前缀 `3a19deae-2c7a-`)。

View File

@@ -1,295 +0,0 @@
# Action 索引
> Action JSON 文件需运行提取命令生成,详见 [SKILL.md](SKILL.md) 中「生成 Action Schema」。
> 以下描述和参数信息基于源码分析。
---
## 配液分液工站 (`bioyond_cell_workstation`)
源码:`unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py`
### 调度控制
#### `scheduler_start`
启动 Bioyond LIMS 调度系统
- **核心参数**: 无(仅需 apiKey/requestTime由设备内部处理
- **返回**: LIMS 响应 `{code, message, data}`
#### `scheduler_stop`
停止调度
- **核心参数**: 无
#### `scheduler_continue`
继续调度(从暂停状态恢复)
- **核心参数**: 无
#### `scheduler_reset`
复位调度
- **核心参数**: 无
#### `scheduler_start_and_auto_feeding`
**组合操作**:启动调度 + 自动化上料4号→3号手套箱
- **核心参数**: `xlsx_path`Excel 物料模板路径,可选)
- **可选参数**: WH4 加样头面 12 个点位materialName + quantity、WH4 原液瓶面 9 个点位materialName + quantity + materialType + targetWH、WH3 人工堆栈 15 个点位materialType + materialId + quantity
- **流程**: 先 `scheduler_start()`,成功后执行 `auto_feeding4to3()`
- **备注**: 支持 Excel 模式和手动参数模式Excel 路径存在时优先使用 Excel
### 物料上料/下料
#### `auto_feeding4to3`
自动化上料:从 4 号手套箱转运物料到 3 号手套箱
- **核心参数**: `xlsx_path`Excel 物料模板路径)
- **可选参数**: 同 `scheduler_start_and_auto_feeding` 的 WH4/WH3 点位参数
- **返回**: 等待上料任务完成后返回结果
#### `auto_batch_outbound_from_xlsx`
自动化下料(从 Excel 读取下料信息)
- **核心参数**: `xlsx_path`Excel 下料模板)
- **Excel 列**: locationId, warehouseId, 数量, x, y, z
### 物料管理
#### `create_and_inbound_materials`
批量创建固体物料并入库
- **核心参数**: `material_names`(物料名称列表,默认 `["LiPF6", "LiDFOB", "DTD", "LiFSI", "LiPO2F2"]`
- **可选参数**: `type_id`物料类型ID, `warehouse_name`(目标仓库,默认 "粉末加样头堆栈"
- **流程**: 创建物料 → 批量入库 → 同步
#### `create_material`
创建单个物料并可选入库
- **核心参数**: `material_name`, `type_id`, `warehouse_name`
- **可选参数**: `location_name_or_id`(库位编号如 "A01" 或 UUID
#### `create_sample`
创建配液板物料(含子瓶)并入库
- **核心参数**: `name`, `board_type`(如 "5ml分液瓶板", `bottle_type`(如 "5ml分液瓶", `location_code`(如 "A01"
- **可选参数**: `warehouse_name`(默认 "手动堆栈"
- **备注**: 自动创建 2x4=8 个子瓶
#### `storage_inbound`
单个物料入库
- **核心参数**: `material_id`, `location_id`
#### `storage_batch_inbound`
批量物料入库
- **核心参数**: `items``[{materialId, locationId}, ...]`
### 配液实验
#### `create_orders`
从 Excel 文件创建配液实验订单
- **核心参数**: `xlsx_path`Excel 文件路径)
- **Excel 列**: 配方ID, 创建日期, 配液瓶类型, 混匀时间(s), 扣电组装分液体积, 软包组装分液体积, 电导测试分液体积, 电导测试分液瓶数, 以及所有以 `(g)` 结尾的物料列
- **流程**: 解析 Excel → 提交订单 → 等待全部完成 → 计算质量比 → 提取分液瓶板 → 创建资源树对象
- **返回**: `{status, total_orders, bottle_count, reports, mass_ratios, vial_plates}`
#### `create_orders_formulation`
从配方列表创建配液实验订单(前端/API 输入版本)
- **核心参数**: `formulation`(配方数组)
- **可选参数**: `batch_id`, `bottle_type`(默认 "配液小瓶", `mix_time`(秒,列表), `coin_cell_volume`, `pouch_cell_volume`, `conductivity_volume`, `conductivity_bottle_count`
- **formulation 格式**:
```json
[
{
"order_name": "配方A",
"materials": [
{"name": "LiPF6", "mass": 12.5},
{"name": "EC", "mass": 50.0},
{"name": "DMC", "mass": 37.5}
]
}
]
```
- **返回**: 同 `create_orders`
### 物料转运
#### `transfer_3_to_2_to_1_auto`
**自动转运**:从 create_orders 结果中自动定位分液瓶板并转运到目标设备
- **核心参数**: `vial_plates`(分液瓶板列表,来自 create_orders 返回的 `vial_plates`
- **可选参数**: `target_device`(默认 "BatteryStation", `target_location`(默认 "bottle_rack_6x2", `mass_ratios`(配方信息)
- **流程**: 遍历瓶板 → 解析 locationId → 调用 LIMS 转运 API → 更新资源树
- **返回**: `{total, success, failed, results}`
#### `transfer_3_to_2_to_1`
3→2→1 物料转运(手动指定坐标)
- **核心参数**: `source_wh_id`, `source_x`, `source_y`, `source_z`
#### `transfer_3_to_2`
3→2 物料转运
- **核心参数**: `source_wh_id`, `source_x`, `source_y`, `source_z`
#### `transfer_1_to_2`
1→2 物料转运
- **核心参数**: 无
### 查询
#### `order_list_v2`
批量查询实验报告
- **可选参数**: `timeType`, `beginTime`, `endTime`, `status`60=运行中, 80=完成, 90=失败), `filter`, `skipCount`, `pageCount`, `sorting`
---
## 扣电组装站 (`BatteryStation`)
源码:`unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py`
### 设备控制(组合操作)
#### `func_pack_device_init_auto_start_combined`
**组合操作**:设备初始化 → 物料搜寻确认 → 切换自动模式 → 启动
- **核心参数**: `material_search_enable`(是否启用物料搜寻,默认 `False`
- **前置检查**: REG_UNILAB_INTERACT=False, COIL_GB_L_IGNORE_CMD=False, 所有握手寄存器无残留
- **流程**: 手动模式 → 初始化命令 → 监测物料搜寻弹窗并自动处理 → 自动模式 → 启动
- **返回**: `True`/`False`
- **备注**: 第一次运行必须调用此函数;后续批次调用 `func_sendbottle_allpack_multi`
### 批量组装
#### `func_sendbottle_allpack_multi`
**发送瓶数 + 批量组装**(适用于第二批次及后续批次)
- **核心参数**: `elec_num`(电解液瓶数), `elec_use_num`(每瓶组装电池数), `elec_vol`(电解液吸液量 μL默认 50
- **可选参数**:
- 双滴模式:`dual_drop_mode`(bool), `dual_drop_first_volume`(μL), `dual_drop_suction_timing`(bool), `dual_drop_start_timing`(bool)
- 组装参数:`assembly_type`(7=不用铝箔垫/8=用), `assembly_pressure`(N默认 4200)
- 物料参数:`fujipian_panshu`, `fujipian_juzhendianwei`, `gemopanshu`, `gemo_juzhendianwei`, `qiangtou_juzhendianwei`
- 开关:`lvbodian`(铝箔垫片), `battery_pressure_mode`(压力模式), `battery_clean_ignore`(忽略清洁)
- 其他:`file_path`(CSV保存路径), `formulations`(配方信息用于CSV追溯)
- **流程**: 发送瓶数触发物料搬运 → 设置PLC参数 → 循环等待PLC请求→下发参数→读取电池数据→写入CSV→更新资源树→ 完成握手
- **返回**: `{success, total_batteries, batteries, summary}`
- **备注**: 设备已初始化后直接调用;`formulations` 来自 create_orders 的 `mass_ratios`
#### `func_allpack_cmd`
全套组装(基础版本,含断点续传)
- **核心参数**: `elec_num`, `elec_use_num`, `elec_vol`, `assembly_type`, `assembly_pressure`, `file_path`
- **返回**: `{success, total_batteries, batteries, summary}`
#### `func_allpack_cmd_simp`
增强版组装(含双滴模式 + 负极片/隔膜/枪头参数)
- **核心参数**: 同 `func_sendbottle_allpack_multi`
- **备注**: 被 `func_sendbottle_allpack_multi` 内部调用
### 设备控制(单步操作)
#### `func_pack_device_init`
设备初始化(手动模式 → 初始化 → 复位标志)
#### `func_pack_device_auto`
切换自动模式
#### `func_pack_device_start`
启动设备
#### `func_pack_device_stop`
设备停止
#### `func_pack_send_bottle_num`
发送电解液瓶数(触发物料搬运)
- **核心参数**: `bottle_num`(瓶数)
### PLC 参数设置
#### `qiming_coin_cell_code`
设置组装物料参数
- **核心参数**: `fujipian_panshu`(负极片盘数)
- **可选参数**: `fujipian_juzhendianwei`, `gemopanshu`, `gemo_juzhendianwei`, `lvbodian`, `battery_pressure_mode`, `battery_pressure`, `battery_clean_ignore`
### 数据采集
#### `func_read_data_and_output`
持续数据采集并导出 CSV后台循环运行
- **核心参数**: `file_path`CSV 保存目录)
- **采集字段**: 开路电压, 极片质量, 组装时间, 压制力, 电解液加注量, 电池类型, 电解液二维码, 电池二维码
#### `func_stop_read_data`
停止 CSV 数据采集
### 设备状态属性(只读)
| 属性 | 类型 | 说明 |
|------|------|------|
| `sys_status` | str | 设备状态(启动中/停止中/复位中/初始化中) |
| `sys_mode` | str | 设备模式(手动/自动) |
| `data_assembly_coin_cell_num` | int | 已完成电池数量 |
| `data_assembly_time` | float | 单颗电池组装时间(秒) |
| `data_open_circuit_voltage` | float | 开路电压(V) |
| `data_pole_weight` | float | 正极片称重(g) |
| `data_glove_box_pressure` | float | 手套箱压力(mbar) |
| `data_glove_box_o2_content` | float | 手套箱氧含量(ppm) |
| `data_glove_box_water_content` | float | 手套箱水含量(ppm) |
| `data_coin_cell_code` | str | 电池二维码 |
| `data_electrolyte_code` | str | 电解液二维码 |
---
## 配置参考
设备图文件 `yibin_electrolyte_config.json` 中的仓库映射(`warehouse_mapping`
| 仓库名称 | 说明 | 典型操作 |
|---------|------|---------|
| 粉末加样头堆栈 | 20 个点位 (A01-T01) | `create_and_inbound_materials` 入库目标 |
| 配液站内试剂仓库 | 9 个点位 (A01-C03) | 试剂存储 |
| 自动堆栈-左 | 4 个点位 | 分液瓶板存放,`transfer_3_to_2_to_1_auto` 的源位置 |
| 自动堆栈-右 | 4 个点位 | 分液瓶板存放 |
| 手动传递窗左/右 | 各 15 个点位 | 人工上料/下料 |
| 4号手套箱内部堆栈 | 12 个点位 | `auto_feeding4to3` 的源位置 |

188
.cursorignore Normal file
View File

@@ -0,0 +1,188 @@
# ============================================================
# Uni-Lab-OS Cursor Ignore 配置,控制 Cursor AI 的文件索引范围
# ============================================================
# ==================== 敏感配置文件 ====================
# 本地配置(可能包含密钥)
**/local_config.py
test_config.py
local_test*.py
# 环境变量和密钥
.env
.env.*
**/.certs/
*.pem
*.key
credentials.json
secrets.yaml
# ==================== 二进制和 3D 模型文件 ====================
# 3D 模型文件(无需索引)
*.stl
*.dae
*.glb
*.gltf
*.obj
*.fbx
*.blend
# URDF/Xacro 机器人描述文件大型XML
*.xacro
# 图片文件
*.png
*.jpg
*.jpeg
*.gif
*.webp
*.ico
*.svg
*.bmp
# 压缩包
*.zip
*.tar
*.tar.gz
*.tgz
*.bz2
*.rar
*.7z
# ==================== Python 生成文件 ====================
__pycache__/
*.py[cod]
*$py.class
*.so
*.pyd
*.egg
*.egg-info/
.eggs/
dist/
build/
*.manifest
*.spec
# ==================== IDE 和编辑器 ====================
.idea/
.vscode/
*.swp
*.swo
*~
.#*
# ==================== 测试和覆盖率 ====================
.pytest_cache/
.coverage
.coverage.*
htmlcov/
.tox/
.nox/
coverage.xml
*.cover
# ==================== 虚拟环境 ====================
.venv/
venv/
env/
ENV/
# ==================== ROS 2 生成文件 ====================
# ROS 构建目录
build/
install/
log/
logs/
devel/
# ROS 消息生成
msg_gen/
srv_gen/
msg/*Action.msg
msg/*ActionFeedback.msg
msg/*ActionGoal.msg
msg/*ActionResult.msg
msg/*Feedback.msg
msg/*Goal.msg
msg/*Result.msg
msg/_*.py
srv/_*.py
build_isolated/
devel_isolated/
# ROS 动态配置
*.cfgc
/cfg/cpp/
/cfg/*.py
# ==================== 项目特定目录 ====================
# 工作数据目录
unilabos_data/
# 临时和输出目录
temp/
output/
cursor_docs/
configs/
# 文档构建
docs/_build/
/site
# ==================== 大型数据文件 ====================
# 点云数据
*.pcd
# GraphML 图形文件
*.graphml
# 日志文件
*.log
# 数据库
*.sqlite3
*.db
# Jupyter 检查点
.ipynb_checkpoints/
# ==================== 设备网格资源 ====================
# 3D 网格文件目录(包含大量 STL/DAE 文件)
unilabos/device_mesh/devices/**/*.stl
unilabos/device_mesh/devices/**/*.dae
unilabos/device_mesh/resources/**/*.stl
unilabos/device_mesh/resources/**/*.glb
unilabos/device_mesh/resources/**/*.xacro
# RViz 配置
*.rviz
# ==================== 系统文件 ====================
.DS_Store
Thumbs.db
desktop.ini
# ==================== 锁文件 ====================
poetry.lock
Pipfile.lock
pdm.lock
package-lock.json
yarn.lock
# ==================== 类型检查缓存 ====================
.mypy_cache/
.dmypy.json
.pytype/
.pyre/
pyrightconfig.json
# ==================== 其他 ====================
# Catkin
CATKIN_IGNORE
# Eclipse/Qt
.project
.cproject
CMakeLists.txt.user
*.user
qtcreator-*

11
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,11 @@
## 设备接入
当被要求添加设备驱动时,参考 `docs/ai_guides/add_device.md`
该指南包含完整的模板和已有设备接口参考。
## 关键规则
- 动作方法的参数名是接口契约,不可重命名
- `status` 字符串必须与同类已有设备一致
- `self.data` 必须在 `__init__` 中预填充所有属性字段
- 异步方法中使用 `await self._ros_node.sleep()`,禁止 `time.sleep()`

5
.gitignore vendored
View File

@@ -251,7 +251,4 @@ ros-humble-unilabos-msgs-0.9.13-h6403a04_5.tar.bz2
*.bz2
test_config.py
# Local config files with secrets
yibin_coin_cell_only_config.json
yibin_electrolyte_config.json
yibin_electrolyte_only_config.json

View File

@@ -1,72 +0,0 @@
# CSV 导出功能变更概要
## 修改的文件
### 1. [bioyond_cell_workstation.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py)
#### 新增导入
- `import csv``import os`L14-15
#### 新增方法
| 方法 | 功能 |
|------|------|
| `_extract_prep_bottle_from_report` | 从 order_finish 报文提取**配液瓶**信息每订单最多1个 |
| `_extract_vial_bottles_from_report` | 从 order_finish 报文提取**分液瓶**信息(每订单可多个,返回数组) |
| `_export_order_csv` | 汇总所有信息写入 CSV 文件 |
#### 配液瓶筛选逻辑 (`_extract_prep_bottle_from_report`)
- `typemode="1"`, `realQuantity=1`, `usedQuantity=1`
- `locationId``3a19deae-2c7a-` 开头(手动传递窗)
- LIMS API 二次确认:`typeName` 含"配液瓶(小)"或"配液瓶(大)"
#### 分液瓶筛选逻辑 (`_extract_vial_bottles_from_report`)
- `typemode="1"`, `realQuantity=1`, `usedQuantity=1`
- `locationId``3a19debc-84b5-``3a19debe-5200` 开头(自动堆栈-左/右)
- LIMS API 二次确认:`typeName` 为"5ml分液瓶"或"20ml分液瓶"
- **返回数组**,支持 1×5ml + n×20ml 的组合
#### 修改的方法
| 方法 | 变更 |
|------|------|
| `_submit_and_wait_orders` | 新增配液瓶+分液瓶提取步骤,将 `prep_bottles``vial_bottles` 存入 `final_result` |
| `create_orders` | 添加 `csv_export_path` 参数,末尾调用 `_export_order_csv` |
| `create_orders_formulation` | 添加 `csv_export_path` 参数,末尾调用 `_export_order_csv` |
#### CSV 输出格式
```
orderCode, orderName, 配液瓶类型, 配液瓶二维码, 分液瓶类型, 分液瓶二维码, 目标配液质量比, 真实配液质量比, 时间
```
- 单个分液瓶时直接写值;多个分液瓶时类型和二维码用 JSON 数组表示
- CSV 编码使用 `utf-8-sig`(兼容 Excel 打开)
- `csv_export_path` 默认为空字符串,不传则不导出(向后兼容)
---
### 2. [bioyond_cell.yaml](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/registry/devices/bioyond_cell.yaml)
为两个 action 注册了 `csv_export_path` 参数:
- `auto-create_orders`: `goal_default` + `schema.properties.goal.properties` 中添加 `csv_export_path`
- `auto-create_orders_formulation`: 同上
---
### 3. [coin_cell_assembly.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py) 的 CSV 改动与全流程追溯
`bioyond_cell_workstation.py``_submit_and_wait_orders` 最后阶段,提取 `prep_bottles`(配液瓶)和 `vial_bottles`(分液瓶)的条码并随 `mass_ratios` 数组一起下发给各下游工站(例如扣电组装站),实现跨站的全流程配方追溯。
并在扣电站生成的 `date_xxx.csv` 中,**替换并新增**了以下列:
- 移除了原有的 `formulation_order_code` 与合并的 `formulation_ratio` 列。
- 新增 `orderName` 导出
- 新增 `prep_bottle_barcode`(奔曜传递的配液瓶二维码)
- 新增 `vial_bottle_barcodes`(奔曜传递的分液瓶二维码,多瓶时存 JSON 数组)
- 新增 `target_mass_ratio` 理论目标质量比
- 新增 `real_mass_ratio` 实际称量真实质量比
*注意:这与操作人员在手套箱内扫码传入扣电站的 `electrolyte_code` 是单独记录的,方便做数据核对。*
## 向后兼容性
- `csv_export_path` 默认值为 `""`(空字符串),现有调用不受影响
- 新增的 `prep_bottles``vial_bottles` 字段为 `final_result``mass_ratios` 内部的新增附属字段,不破坏现有数据结构。

View File

@@ -1,168 +0,0 @@
# 变更说明 2026-03-24
## 问题背景
`BioyondElectrolyteDeck`(原 `BIOYOND_YB_Deck`)迁移后,前端物料未能正常上传/同步。
---
## 修复内容
### 1. `unilabos/resources/bioyond/decks.py`
- 补回 `setup: bool = False` 参数及 `if setup: self.setup()` 逻辑,与旧版 `BIOYOND_YB_Deck` 保持一致
- 工厂函数 `bioyond_electrolyte_deck` 保留显式调用 `deck.setup()`,避免重复初始化
```python
# 修复前(缺少 setup 参数,无法通过 setup=True 触发初始化)
def __init__(self, name, size_x, size_y, size_z, category):
super().__init__(...)
# 修复后
def __init__(self, name, size_x, size_y, size_z, category, setup: bool = False):
super().__init__(...)
if setup:
self.setup()
```
---
### 2. `unilabos/resources/graphio.py`
- 修复 `resource_bioyond_to_plr` 中两处 `bottle.tracker.liquids` 直接赋值导致的崩溃
- `ResourceHolder`(如枪头盒的 TipSpot 槽位)没有 `tracker` 属性,直接访问会抛出 `AttributeError`,阻断整个 Bioyond 同步流程
```python
# 修复前
bottle.tracker.liquids = [...]
# 修复后
if hasattr(bottle, 'tracker') and bottle.tracker is not None:
bottle.tracker.liquids = [...]
```
---
### 3. `unilabos/app/main.py`
- 保留 `file_path is not None` 条件不变(已还原),并补充注释说明原因
- 该逻辑只在**本地文件模式**下有意义:本地 graph 文件只含设备结构远端有已保存物料merge 才能将两者合并
- 远端模式(`file_path=None`)下,`resource_tree_set``request_startup_json` 来自同一份数据merge 为空操作,条件是否加 `file_path is not None` 对结果没有影响
---
### 4. `unilabos/devices/workstation/bioyond_studio/station.py` ⭐ 核心修复
- 当 deck 通过反序列化创建时,不会自动调用 `setup()`,导致 `deck.children` 为空,`warehouses` 始终是 `{}`
- 增加兜底逻辑:仓库扫描后仍为空,则主动调用 `deck.setup()` 初始化仓库
- 这是导致所有物料放置失败(`warehouse '...' 在deck中不存在。可用warehouses: []`)的根本原因
```python
# 新增兜底
if not self.deck.warehouses and hasattr(self.deck, "setup") and callable(self.deck.setup):
logger.info("Deck 无仓库子节点,调用 setup() 初始化仓库")
self.deck.setup()
```
---
---
## 补充修复 2026-03-25依华扣电组装工站子物料未上传
### 问题
`CoinCellAssemblyWorkstation.post_init` 直接上传空 deck未调用 `deck.setup()`,导致:
- 前端子物料(成品弹夹、料盘、瓶架等)不显示
- 运行时 `self.deck.get_resource("成品弹夹")` 抛出 `ResourceNotFoundError`
### 修复文件
**`unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py`**
- `YihuaCoinCellDeck.__init__` 补回 `setup: bool = False` 参数及 `if setup: self.setup()` 逻辑
**`unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py`**
- `post_init` 中增加与 Bioyond 工站相同的兜底逻辑deck 无子节点时调用 `deck.setup()` 初始化
```python
# post_init 中新增
if self.deck and not self.deck.children and hasattr(self.deck, "setup") and callable(self.deck.setup):
logger.info("YihuaCoinCellDeck 无子节点,调用 setup() 初始化")
self.deck.setup()
```
### 联动 Bug`MaterialPlate.create_with_holes` 构造顺序错误
**现象**`deck.setup()` 被调用后,启动时抛出:
```
设备后初始化失败: Must specify either `ordered_items` or `ordering`.
```
**根因**`create_with_holes` 原来的逻辑是先构造空的 `MaterialPlate` 实例,再 assign 洞位:
```python
# 旧错误cls(...) 时 ordered_items=None → ItemizedResource.__init__ 立即报错
plate = cls(name=name, ...) # ← 这里就崩了
holes = create_ordered_items_2d(...) # ← 根本没走到这里
for hole_name, hole in holes.items():
plate.assign_child_resource(...)
```
pylabrobot 的 `ItemizedResource.__init__` 强制要求 `ordered_items``ordering` 必须有一个不为 `None`,空构造直接失败。
**修复**:先建洞位,再作为 `ordered_items` 传给构造函数:
```python
# 新(正确):先建洞位,再一次性传入构造函数
holes = create_ordered_items_2d(klass=MaterialHole, num_items_x=4, ...)
return cls(name=name, ..., ordered_items=holes)
```
> 此 bug 此前未被触发,是因为 `deck.setup()` 从未被调用到——正是上面 `post_init` 兜底修复引出的联动问题。
---
## 补充修复 2026-03-253→2→1 转运资源同步失败
### 问题
配液工站Bioyond完成分液后调用 `transfer_3_to_2_to_1_auto` 将分液瓶板转运到扣电工站BatteryStation。物理 LIMS 转运成功,但数字孪生资源树同步始终失败:
```
[资源同步] ❌ 失败: 目标设备 'BatteryStation' 中未找到资源 'bottle_rack_6x2'
```
### 根因
`_get_resource_from_device` 方法负责跨设备查找资源对象,有两个问题:
1. **原始路径完全失效**:尝试 `from unilabos.app.ros2_app import get_device_plr_resource_by_name`,但该模块不存在,`ImportError``except Exception: pass` 静默吞掉
2. **降级路径搜错地方**:遍历 `self._plr_resources`Bioyond 自己的资源),不可能找到 BatteryStation 的 `bottle_rack_6x2`
### 修复文件
**`unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py`**
改用全局设备注册表 `registered_devices` 跨设备访问目标 deck
```python
# 修复前(失效)
from unilabos.app.ros2_app import get_device_plr_resource_by_name # 模块不存在
return get_device_plr_resource_by_name(device_id, resource_name)
# 修复后
from unilabos.ros.nodes.base_device_node import registered_devices
device_info = registered_devices.get(device_id)
if device_info is not None:
driver = device_info.get("driver_instance") # TypedDict 是 dict必须用 .get()
if driver is not None:
deck = getattr(driver, "deck", None)
if deck is not None:
res = deck.get_resource(resource_name)
```
关键细节:`DeviceInfoType``TypedDict`(即普通 `dict`),必须用 `device_info.get("driver_instance")` 而非 `getattr(device_info, "driver_instance", None)`——后者对字典永远返回 `None`
---
## 根本原因分析
旧版以**本地文件模式**启动(有 `graph` 文件deck 在启动前已通过 `merge_remote_resources` 获得仓库子节点,反序列化时能正确恢复 warehouses。
新版以**远端模式**启动(`file_path=None`deck 反序列化时没有仓库子节点,`station.py` 扫描为空,所有物料的 warehouse 匹配失败Bioyond 同步的 16 个资源全部无法放置到对应仓库位,前端不显示。

1100
docs/ai_guides/add_device.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,344 @@
# Uni-Lab-OS 设备接入 Agent — 提示词模板
> 本文件提供一套可直接复制使用的 Agent 系统提示词,以及各平台的配置说明。
> 提示词模板与 `add_device.md`(领域知识)配合使用,前者控制 Agent 行为,后者提供完整的技术细节。
---
## 系统提示词模板
以下内容可直接作为系统提示词 / Instructions / Custom Instructions 使用。`{{...}}` 标记的变量根据平台替换。
---
### 开始复制 ↓
```
你是 Uni-Lab-OS 设备接入专家。你的任务是帮助用户将新的实验室硬件设备接入 Uni-Lab-OS 系统。
你能做的事:
- 根据用户描述生成完整的设备驱动代码Python、注册表YAML和实验图文件JSON
- 解读用户提供的通信协议文档、SDK 代码、或口述的指令格式
- 诊断已有驱动代码的接口对齐问题
你不能做的事:
- 凭空猜测硬件私有通信指令(必须从用户提供的资料中获取)
- 替代真实硬件联调测试
## 知识来源
{{KNOWLEDGE_LOADING}}
## 工作流程
当用户要求接入新设备时,严格按以下流程执行。每个暂停点必须等待用户确认后再继续。
### 阶段 1设备画像交互
向用户收集以下三个信息,可以一次性提问:
1. **设备类别** — 属于以下哪一种?
- temperature温控、pump_and_valve泵阀、motor电机
- heaterstirrer加热搅拌、balance天平、sensor传感器
- liquid_handling液体处理、robot_arm机械臂、workstation工作站
- virtual虚拟设备、custom自定义
- 如果是 pump_and_valve进一步确认子类型注射泵 / 电磁阀 / 蠕动泵
2. **设备英文名称** — 用于文件名和类名(如 my_heater、runze_sy03b
3. **通信协议** — Serial(RS232/RS485) / Modbus RTU / Modbus TCP / TCP Socket / HTTP API / OPC UA / 无通信(虚拟)
⏸️ **暂停:等待用户回答后继续**
### 阶段 2指令协议收集交互
根据上一步确定的通信协议,引导用户提供指令信息:
- 如果用户有 **SDK/驱动代码**:请用户提供代码文件,你从中提取通信逻辑
- 如果用户有 **协议文档**请用户提供文档PDF/图片/文本),你从中解析指令格式
- 如果用户 **口头描述**:针对每个标准动作逐一确认硬件指令
- 如果是 **标准协议**Modbus 寄存器表、SCPI请用户提供寄存器/指令映射
- 如果是 **虚拟设备**:跳过此阶段
⏸️ **暂停:确认已获取足够的指令协议信息**
### 阶段 3确认摘要
在开始生成代码前,向用户展示你的理解摘要:
```
设备接入摘要:
- 设备名称:<name>
- 设备类别:<category><subtype>
- 通信协议:<protocol>
- 指令来源:<source>
- 将要实现的属性:<list>
- 将要实现的动作:<list>
- 同类已有设备:<existing>(将对齐其接口)
```
⏸️ **暂停:用户确认"没问题"后再生成代码**
### 阶段 4自动生成无需暂停
按以下顺序自动执行:
1. **对齐同类设备接口**(指南第四步)
- 查阅指南中的「现有设备接口快照」或搜索仓库注册表
- 确保所有已有设备的 status_types 和动作方法都被覆盖
- 参数名必须完全一致
2. **生成驱动代码** — `unilabos/devices/<category>/<name>.py`
3. **生成注册表** — `unilabos/registry/devices/<name>.yaml`(最小配置)
4. **生成图文件** — `unilabos/test/experiments/graph_example_<name>.json`
### 阶段 5验证输出
生成完成后,逐项检查对齐验证清单并展示结果:
```
对齐验证清单:
- [x] 所有动作方法的参数名与已有设备完全一致
- [x] status 属性返回的字符串值与已有设备一致
- [x] 已有设备的所有 status_types 字段都有对应 @property
- [x] 已有设备的所有非 auto- 前缀的 action 都有对应方法
- [x] self.data 在 __init__ 中已预填充所有属性字段的默认值
- [x] 串口/二进制协议的响应解析先定位帧起始标记
```
如果有未通过的项,主动修复后再展示。
## 硬约束(违反任何一条都会导致设备接入失败)
1. **禁止重命名参数** — 动作方法的参数名(如 volume、position、max_velocity是接口契约框架通过参数名分派调用。绝不能加后缀如 volume_ml、改名如 speed_ml_s。单位写在 docstring 中。
2. **status 字符串必须一致** — 如果同类已有设备用英文(如 "Idle" / "Busy"),新驱动必须用相同的字符串,不能改为中文(如 "就绪")。
3. **self.data 必须预填充** — 不能用空字典 {}。框架在 initialize() 之前就可能读取属性值。每个 @property 对应的键都必须在 __init__ 中有初始值。
4. **禁止跳过接口对齐** — 对齐同类设备接口是强制步骤。缺失的属性和动作会导致设备在工作流中不可互换。
5. **串口解析先找帧头** — RS-485 总线上响应前常有回声/噪声字节。必须先定位帧起始标记(如 /、0xFE禁止用硬编码索引直接解析。
6. **异步等待用 _ros_node.sleep** — 在 async 方法中使用 await self._ros_node.sleep(),禁止 time.sleep()(阻塞事件循环)和 asyncio.sleep()。
7. **物理单位对外暴露** — 对外参数使用用户友好的物理单位mL、°C、RPM驱动内部负责转换到硬件原始值步数、Hz、寄存器值
## 代码骨架参考
所有设备驱动遵循以下结构:
```python
import logging
import time as time_module
from typing import Dict, Any
try:
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
except ImportError:
BaseROS2DeviceNode = None
class MyDevice:
_ros_node: "BaseROS2DeviceNode"
def __init__(self, device_id: str = None, config: Dict[str, Any] = None, **kwargs):
if device_id is None and 'id' in kwargs:
device_id = kwargs.pop('id')
if config is None and 'config' in kwargs:
config = kwargs.pop('config')
self.device_id = device_id or "unknown_device"
self.config = config or {}
self.logger = logging.getLogger(f"MyDevice.{self.device_id}")
self.data = {
"status": "Idle",
# 所有 @property 的键都必须在此预填充
}
def post_init(self, ros_node: "BaseROS2DeviceNode"):
self._ros_node = ros_node
async def initialize(self) -> bool:
self.data["status"] = "Idle"
return True
async def cleanup(self) -> bool:
self.data["status"] = "Offline"
return True
@property
def status(self) -> str:
return self.data.get("status", "Idle")
```
## 注册表最小配置
```yaml
my_device:
class:
module: unilabos.devices.<category>.<file>:MyDevice
type: python
```
启动时 --complete_registry 自动生成 status_types 和 action_value_mappings。
## 图文件模板
```json
{
"nodes": [
{
"id": "my_device_1",
"name": "设备名称",
"children": [],
"parent": null,
"type": "device",
"class": "my_device",
"position": {"x": 0, "y": 0, "z": 0},
"config": {},
"data": {}
}
]
}
```
## 现有设备接口快照(对齐用)
对齐时参考以下已有设备接口。如果能联网,优先从 GitHub 获取最新版本:
https://github.com/dptech-corp/Uni-Lab-OS/tree/main/unilabos/registry/devices/
### pump_and_valve — 注射泵
已有设备syringe_pump_with_valve.runze.SY03B-T06
属性status(str, "Idle"/"Busy"), valve_position(str), position(float, mL), max_velocity(float, mL/s), mode(int), plunger_position(String), velocity_grade(String), velocity_init(String), velocity_end(String)
方法签名(参数名不可改):
- initialize()
- set_valve_position(position)
- set_position(position: float, max_velocity: float = None)
- pull_plunger(volume: float)
- push_plunger(volume: float)
- set_max_velocity(velocity: float)
- set_velocity_grade(velocity)
- stop_operation()
### pump_and_valve — 电磁阀
属性status(str), valve_position(str)
方法open(), close(), set_valve_position(position), is_open(), is_closed()
### temperature
属性status(str), temp(float, °C), temp_target(float, °C), stir_speed(float, RPM), temp_warning(float, °C)
### motor
属性status(str), position(int)
### sensor
属性level(bool), rssi(int)
```
### 结束复制 ↑
---
## `{{KNOWLEDGE_LOADING}}` 变量替换
根据平台能力,将提示词中的 `{{KNOWLEDGE_LOADING}}` 替换为以下对应内容:
### 方案 A有知识库Custom GPT / Claude Project
```
你的知识库中包含 add_device.md 文件,这是完整的设备接入指南。
执行工作流时,参考该文件获取物模型模板、通信协议代码片段、指令协议模式和常见错误检查清单。
本提示词中的「现有设备接口快照」和「硬约束」是从指南中提炼的关键内容,以确保即使知识库检索不完整也能正确工作。
```
### 方案 B有联网能力
```
执行工作流前,从以下 URL 获取完整的设备接入指南:
https://raw.githubusercontent.com/dptech-corp/Uni-Lab-OS/main/docs/ai_guides/add_device.md
该指南包含物模型模板、通信协议代码片段、指令协议模式和常见错误检查清单。
如果无法访问 URL使用本提示词中内联的「现有设备接口快照」和「代码骨架参考」作为兜底。
```
### 方案 C无知识库、无联网
```
完整的设备接入指南需要用户在对话中提供。
如果用户未主动提供,请在阶段 1 开始前询问:
"请将 add_device.md 的内容粘贴到对话中,或上传该文件。如果没有该文件,我将使用内置的精简规则工作。"
本提示词已内联了最关键的内容(硬约束 + 代码骨架 + 接口快照),足以生成基本正确的驱动。
但完整指南包含更多物模型模板和通信协议代码片段,能显著提升生成质量。
```
---
## 各平台配置指南
### OpenAI Custom GPT
1. 进入 https://chat.openai.com/gpts/editor
2. **Name**Uni-Lab-OS 设备接入助手
3. **Description**:帮助用户将实验室硬件设备接入 Uni-Lab-OS 系统,自动生成驱动代码、注册表和图文件。
4. **Instructions**:粘贴上方系统提示词,`{{KNOWLEDGE_LOADING}}` 替换为方案 A
5. **Knowledge**:上传 `docs/ai_guides/add_device.md`
6. **Capabilities**:开启 Code Interpreter用于代码验证
7. **Conversation starters**
- "我要接入一个新的注射泵"
- "帮我把这个 SDK 包装成 UniLab 驱动"
- "检查我的设备驱动有没有接口问题"
### Claude Project
1. 创建新 Project
2. **Custom Instructions**:粘贴系统提示词,`{{KNOWLEDGE_LOADING}}` 替换为方案 A
3. **Project Knowledge**:上传 `docs/ai_guides/add_device.md`
### API AgentLangChain / AutoGen / 自建框架)
```python
system_prompt = """
<粘贴完整系统提示词,{{KNOWLEDGE_LOADING}} 替换为方案 B>
"""
# 如果框架支持工具调用,可注册以下工具:
tools = [
{
"name": "fetch_device_guide",
"description": "获取最新的 Uni-Lab-OS 设备接入指南",
"url": "https://raw.githubusercontent.com/dptech-corp/Uni-Lab-OS/main/docs/ai_guides/add_device.md"
},
{
"name": "fetch_registry",
"description": "获取最新的设备注册表",
"url": "https://raw.githubusercontent.com/dptech-corp/Uni-Lab-OS/main/unilabos/registry/devices/{category}.yaml"
},
]
```
### Cursor Agent Mode
无需使用本模板。Cursor 中使用已有的 `.cursor/skills/add-device/SKILL.md`,它会自动读取 `docs/ai_guides/add_device.md` 并利用 Cursor 的工具能力Grep 搜索注册表、AskQuestion 收集信息等)。
### 纯网页对话ChatGPT / Claude 无 Project
1. 第一条消息粘贴系统提示词(`{{KNOWLEDGE_LOADING}}` 替换为方案 C
2. 第二条消息上传或粘贴 `add_device.md`
3. 第三条消息开始描述设备
---
## 维护说明
- **硬约束更新**:如果 `add_device.md` 中新增了禁止事项或常见错误,需要同步更新本模板的「硬约束」部分
- **接口快照更新**:新增设备类别或已有设备接口变更时,需要同步更新本模板的「现有设备接口快照」部分
- **工作流调整**:如果接入流程发生变化(新增步骤、合并步骤),需要同步调整「工作流程」部分
- 本模板与 `add_device.md` 是**互补关系**:模板定义 Agent 行为,指南提供领域知识。两者独立维护

View File

@@ -18,13 +18,15 @@ Uni-Lab 开发团队在仓库中提供了 3 个样例:
- 单一机械设备**电夹爪**,通讯协议可见 [增广夹爪通讯协议](https://doc.rmaxis.com/docs/communication/fieldbus/),驱动代码位于 `unilabos/devices/gripper/rmaxis_v4.py`
- 单一通信设备**IO 板卡**,驱动代码位于 `unilabos/device_comms/gripper/SRND_16_IO.py`
- 执行多设备复杂任务逻辑的**PLC**Uni-Lab 提供了基于地址表的接入方式和点动工作流编写,测试代码位于 `unilabos/device_comms/modbus_plc/test/test_workflow.py`
- 执行多设备复杂任务逻辑的**PLC**Uni-Lab 提供了基于地址表的接入方式和点动工作流编写,测试代码位于 `unilabos/device_comms/modbus_plc/test/test_workflow.py`。详细框架说明请参考 {doc}`plc_framework`
---
## 其他工业通信协议CANopen, Ethernet, OPCUA...
【敬请期待】
Uni-Lab 已实现基于 OPC UA 协议的 PLC 接管框架,用于后处理工站等项目。与 Modbus 框架相比OPC UA 框架额外提供了自动节点发现、订阅推送、断线重连等特性。详细说明请参考 {doc}`plc_framework`
其他协议CANopen、EtherCAT 等)【敬请期待】
## 没有接口的老设备老软件:使用 PyWinAuto

View File

@@ -0,0 +1,281 @@
# PLC 设备接管框架
> 本文档面向初次接触 UniLab-OS 的开发者,介绍系统如何通过工业协议"接管"连接并控制PLC 设备。
## 什么是"PLC 接管"
**PLC**可编程逻辑控制器是工厂设备的控制核心驱动机械臂、泵、阀门等硬件。UniLab-OS 通过网络协议直接读写 PLC 内部变量,从而控制设备运行:
```
UniLab-OSPython ←通信协议→ PLC ←电信号→ 电机/气缸/传感器
```
UniLab-OS 提供两套接管框架,对应两种工业协议:
| 框架 | 协议 | 应用项目 | 核心文件 |
| --------------- | ---------------- | ------------------ | ----------------------------------------------------------- |
| **Modbus 框架** | Modbus TCP / RTU | 扣式电池装配工站 | `unilabos/device_comms/modbus_plc/client.py` |
| **OPC UA 框架** | OPC UA | 后处理工站(怀柔) | `unilabos/devices/workstation/post_process/post_process.py` |
两套框架**设计思想完全一致**,底层通信协议不同。理解一个,另一个基本触类旁通。
---
## 核心概念
### 节点Node
节点是 PLC 内部一个具体的变量地址,可以理解为 PLC 的一个输入/输出端口。
| 属性 | 说明 | 示例 |
| ---- | -------------------------------------- | -------------------- |
| 名称 | 人类可读标识 | `COIL_SYS_START_CMD` |
| 地址 | PLC 内存地址 | `0x0064` |
| 类型 | Coil / HoldRegister / InputRegister 等 | `coil` |
```
PLC 内存空间
├── Coil 区: True / False ← 控制开关量(启动/停止/复位)
├── Hold Reg: 120, 35.5 … ← 存参数值(速度、位置)
└── Input Reg: 99.8, 42 … ← 只读传感器数据
```
### 动作生命周期Action Lifecycle
每个设备动作被拆分为 4 个阶段,用 `try/finally` 保证安全性:
```python
try:
init(...) # 写入参数(速度、位置等)— 可选
start(...) # 发触发信号 + 轮询等待完成
stop(...) # 复位触发信号(成功时执行)
except:
is_err = True
finally:
cleanup(...) # 无论成败都执行,作为安全兜底
```
| 阶段 | 何时执行 | 典型内容 |
| --------- | ----------------------- | ------------------------------------ |
| `init` | 成功路径(可选) | 写运动速度 = 20.0 |
| `start` | 成功路径 | 写启动位 = True等待完成位 = True |
| `stop` | 成功路径 | 写启动位 = False正常复位 |
| `cleanup` | **无论成败**finally | 安全兜底复位,防止异常时设备持续运动 |
> **为什么 `cleanup` 无论成败都执行?**
> 若 `start` 阶段因传感器故障抛出异常,`stop` 会被跳过PLC 触发位仍为 `True`——设备可能持续运动。`cleanup` 放在 `finally` 块中,作为最后的安全保障,确保 PLC 一定被复位到安全状态。实际上大多数动作将 `cleanup` 设为 `null`,由 `stop` 负责正常复位即可。
---
## Modbus 框架
**核心文件**`unilabos/device_comms/modbus_plc/client.py`
**参考实现**`unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py`
### 连接与节点注册
```python
from unilabos.device_comms.modbus_plc.client import TCPClient, BaseClient
# 1. 建立 TCP 连接
client = TCPClient(addr="172.16.28.102", port=502)
client.client.connect()
# 2. 从 CSV 加载节点定义
nodes = BaseClient.load_csv("coin_cell_assembly_b.csv")
# 3. 注册节点,之后可按名称访问
client.register_node_list(nodes)
# 访问节点
client.use_node('COIL_SYS_START_CMD').write(True)
value, err = client.use_node('COIL_SYS_START_STATUS').read(1)
```
**CSV 格式**`Name` / `DeviceType` / `Address` / `DataType`
| Name | DeviceType | Address | DataType |
| ------------------ | ------------- | ------- | -------- |
| COIL_SYS_START_CMD | coil | 100 | INT16 |
| REG_SPEED | hold_register | 200 | FLOAT32 |
### 三段式接管流程(扣式电池工站)
PLC 设备通常需要按固定顺序切换模式,以扣式电池工站为例:
```
Python PLC
│── 写 HAND_CMD = True ─────────>│ 切换到手动模式
│<─ 读 HAND_STATUS = True ────────│ 确认进入手动
│── 写 INIT_CMD = True ──────────>│ 执行初始化
│<─ 读 INIT_STATUS = True ─────────│ 初始化完成
│── 写 HAND_CMD = False ──────────>│ 复位(脉冲信号)
│── 写 INIT_CMD = False ──────────>│ 复位
│── 写 AUTO_CMD = True ───────────>│ 切换自动模式
│<─ 读 AUTO_STATUS = True ─────────│ 自动模式就绪
│── 写 AUTO_CMD = False ──────────>│ 复位
│── 写 START_CMD = True ──────────>│ 开始运行
│<─ 读 START_STATUS = True ────────│ 运行确认
│── 写 START_CMD = False ──────────>│ 复位
```
> **脉冲信号模式**:命令写 `True` → 等待 PLC 状态位确认 → 命令写回 `False`,这是大多数 PLC 的标准触发方式,而不是保持高电平。
### JSON 配置方式
Modbus 框架支持纯 JSON 配置,无需手写 Python 流程:
```json
{
"register_node_list_from_csv_path": {"path": "M01.csv"},
"create_flow": [
{
"name": "归位",
"action": [{
"address_function_to_create": [
{"func_name": "pos_tip", "node_name": "M01_idlepos_coil_w", "mode": "write", "value": true},
{"func_name": "pos_tip_read", "node_name": "M01_idlepos_coil_r", "mode": "read", "value": 1},
{"func_name": "manual_stop", "node_name": "M01_manual_stop_coil_r","mode": "read", "value": 1}
],
"create_init_function": {"func_name": "idel_init", "node_name": "M01_idlepos_velocity_rw", "mode": "write", "value": 20.0},
"create_start_function": {
"func_name": "idel_position",
"write_functions": ["pos_tip"],
"condition_functions": ["pos_tip_read", "manual_stop"],
"stop_condition_expression": "pos_tip_read[0] and manual_stop[0]"
},
"create_stop_function": {"func_name": "idel_stop", "node_name": "M01_idlepos_coil_w", "mode": "write", "value": false},
"create_cleanup_function": null
}]
}
],
"execute_flow": ["归位"]
}
```
执行:
```python
client.execute_procedure_from_json(json_data)
```
---
## OPC UA 框架
**核心文件**`unilabos/devices/workstation/post_process/post_process.py`
**参考实现**`unilabos/devices/workstation/post_process/opcua_huairou.json`
### 与 Modbus 的主要区别
| 特性 | Modbus | OPC UA |
| ---------- | -------------------- | --------------------------------- |
| 节点发现 | 手动填写 CSV 地址 | **自动遍历**服务器节点树 |
| 数据获取 | 轮询(主动问) | **订阅推送**(有变化时通知) |
| 节点标识 | 数字地址(如 `100` | 字符串 NodeId`ns=2;s=速度` |
| 断线处理 | 无 | **后台监控线程**自动重连 |
| 认证安全 | 无 | 支持用户名/密码 |
| 工作流调用 | 手动调用 | **自动注册为实例方法** |
### 连接与节点发现
```python
from unilabos.devices.workstation.post_process.post_process import OpcUaClient
client = OpcUaClient(
url="opc.tcp://192.168.1.100:4840",
username="admin", # 可选
password="123456", # 可选
config_path="opcua_huairou.json", # 自动加载工作流配置
cache_timeout=5.0, # 节点值缓存 5 秒
subscription_interval=500, # 每 500ms 接收推送
)
# 节点自动通过订阅保持最新值,读取直接查本地缓存
value = client.get_node_value("grab_complete")
```
### JSON 配置方式
```json
{
"register_node_list_from_csv_path": {"path": "opcua_nodes_huairou.csv"},
"create_flow": [
{
"name": "trigger_grab_action",
"description": "触发反应罐及原料罐抓取动作",
"parameters": ["reaction_tank_number", "raw_tank_number"],
"action": [{
"init_function": {
"func_name": "init_grab_params",
"write_nodes": ["reaction_tank_number", "raw_tank_number"]
},
"start_function": {
"func_name": "start_grab",
"write_nodes": {"grab_trigger": true},
"condition_nodes": ["grab_complete"],
"stop_condition_expression": "grab_complete == True",
"timeout_seconds": 999999.0
},
"stop_function": {
"func_name": "stop_grab",
"write_nodes": {"grab_trigger": false}
}
}]
}
]
}
```
配置加载后,工作流自动注册为实例方法:
```python
# 直接调用,传入参数,框架自动写入对应节点
client.trigger_grab_action(reaction_tank_number=2, raw_tank_number=3)
```
---
## 新增设备快速上手
### 使用 Modbus 框架
```
1. 从 PLC 工程师处拿到地址表,按格式填写 CSVName/DeviceType/Address/DataType
2. 继承 BaseClient在 __init__ 中连接并注册节点
3. 参考 coin_cell_assembly.py 编写三段式接管函数(手动→初始化→自动→启动)
4. 或直接编写 JSON 配置,调用 execute_procedure_from_json()
```
### 使用 OPC UA 框架
```
1. 确认设备支持 OPC UA拿到服务器 URL格式opc.tcp://IP:PORT
2. 准备 CSV 节点定义文件(可选,也可让框架自动发现)
3. 编写 JSON 配置:定义 parameters、init/start/stop 函数
4. 实例化 OpcUaClient传入 config_path直接调用自动注册的工作流方法
```
---
## 常见问题
**Q: `node {name} is not registered` 报错?**
A: 节点名不在 CSV 或未调用 `register_node_list_from_csv_path()`
**Q: 程序卡死在 `while not status(): sleep(1)`**
A: PLC 未返回预期完成信号。检查PLC 是否在正确运行模式、状态位地址是否正确、PLC 有无报警。
**Q: OPC UA 连接成功但读不到节点?**
A: 检查节点名称是否与服务器显示名一致(区分中英文)。可调用 `_find_nodes()` 打印服务器全部节点。
**Q: 应该选 Modbus 还是 OPC UA**
A: 取决于设备支持的协议,由 PLC 工程师决定。OPC UA 功能更完整,条件允许优先选择。
---
## 下一步
- {doc}`add_device` - 将驱动集成进 UniLab-OS 设备节点
- {doc}`add_action` - 为设备添加可调度的动作指令
- {doc}`add_yaml` - 编写设备注册表 YAML 文件

View File

@@ -17,6 +17,9 @@ developer_guide/http_api.md
developer_guide/networking_overview.md
developer_guide/add_device.md
developer_guide/add_action.md
developer_guide/add_old_device.md
developer_guide/plc_framework.md
developer_guide/add_protocol.md
developer_guide/add_registry.md
developer_guide/add_yaml.md
developer_guide/action_includes.md

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,15 @@ from typing import Dict, Any, List
import networkx as nx
import yaml
# Windows 中文系统 stdout 默认 GBK无法编码 banner / emoji 日志中的 Unicode 字符
# 强制 stdout/stderr 用 UTF-8避免 print 触发 UnicodeEncodeError 导致进程崩溃
if sys.platform == "win32":
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
except (AttributeError, OSError):
pass
# 首先添加项目根目录到路径
current_dir = os.path.dirname(os.path.abspath(__file__))
unilabos_dir = os.path.dirname(os.path.dirname(current_dir))
@@ -621,8 +630,6 @@ def main():
continue
# 如果从远端获取了物料信息,则与本地物料进行同步
# 仅在本地文件模式下有意义:本地文件只含设备结构,远端有已保存的物料,需要 merge
# 远端模式下 resource_tree_set 与 request_startup_json 来自同一份数据merge 为空操作
if file_path is not None and request_startup_json and "nodes" in request_startup_json:
print_status("开始同步远端物料到本地...", "info")
remote_tree_set = ResourceTreeSet.from_raw_dict_list(request_startup_json["nodes"])

View File

@@ -1,9 +1,7 @@
"""
LaiYu液体处理设备后端模块
提供设备后端接口和实现
"""
from .laiyu_backend import LaiYuLiquidBackend, create_laiyu_backend
from .laiyu_v_backend import UniLiquidHandlerLaiyuBackend
__all__ = ['LaiYuLiquidBackend', 'create_laiyu_backend']
__all__ = ['UniLiquidHandlerLaiyuBackend']

View File

@@ -1,334 +0,0 @@
"""
LaiYu液体处理设备后端实现
提供设备的后端接口和控制逻辑
"""
import logging
from typing import Dict, Any, Optional, List
from abc import ABC, abstractmethod
# 尝试导入PyLabRobot后端
try:
from pylabrobot.liquid_handling.backends import LiquidHandlerBackend
PYLABROBOT_AVAILABLE = True
except ImportError:
PYLABROBOT_AVAILABLE = False
# 创建模拟后端基类
class LiquidHandlerBackend:
def __init__(self, name: str):
self.name = name
self.is_connected = False
def connect(self):
"""连接设备"""
pass
def disconnect(self):
"""断开连接"""
pass
class LaiYuLiquidBackend(LiquidHandlerBackend):
"""LaiYu液体处理设备后端"""
def __init__(self, name: str = "LaiYu_Liquid_Backend"):
"""
初始化LaiYu液体处理设备后端
Args:
name: 后端名称
"""
if PYLABROBOT_AVAILABLE:
# PyLabRobot 的 LiquidHandlerBackend 不接受参数
super().__init__()
else:
# 模拟版本接受 name 参数
super().__init__(name)
self.name = name
self.logger = logging.getLogger(__name__)
self.is_connected = False
self.device_info = {
"name": "LaiYu液体处理设备",
"version": "1.0.0",
"manufacturer": "LaiYu",
"model": "LaiYu_Liquid_Handler"
}
def connect(self) -> bool:
"""
连接到LaiYu液体处理设备
Returns:
bool: 连接是否成功
"""
try:
self.logger.info("正在连接到LaiYu液体处理设备...")
# 这里应该实现实际的设备连接逻辑
# 目前返回模拟连接成功
self.is_connected = True
self.logger.info("成功连接到LaiYu液体处理设备")
return True
except Exception as e:
self.logger.error(f"连接LaiYu液体处理设备失败: {e}")
self.is_connected = False
return False
def disconnect(self) -> bool:
"""
断开与LaiYu液体处理设备的连接
Returns:
bool: 断开连接是否成功
"""
try:
self.logger.info("正在断开与LaiYu液体处理设备的连接...")
# 这里应该实现实际的设备断开连接逻辑
self.is_connected = False
self.logger.info("成功断开与LaiYu液体处理设备的连接")
return True
except Exception as e:
self.logger.error(f"断开LaiYu液体处理设备连接失败: {e}")
return False
def is_device_connected(self) -> bool:
"""
检查设备是否已连接
Returns:
bool: 设备是否已连接
"""
return self.is_connected
def get_device_info(self) -> Dict[str, Any]:
"""
获取设备信息
Returns:
Dict[str, Any]: 设备信息字典
"""
return self.device_info.copy()
def home_device(self) -> bool:
"""
设备归零操作
Returns:
bool: 归零是否成功
"""
if not self.is_connected:
self.logger.error("设备未连接,无法执行归零操作")
return False
try:
self.logger.info("正在执行设备归零操作...")
# 这里应该实现实际的设备归零逻辑
self.logger.info("设备归零操作完成")
return True
except Exception as e:
self.logger.error(f"设备归零操作失败: {e}")
return False
def aspirate(self, volume: float, location: Dict[str, Any]) -> bool:
"""
吸液操作
Args:
volume: 吸液体积 (微升)
location: 吸液位置信息
Returns:
bool: 吸液是否成功
"""
if not self.is_connected:
self.logger.error("设备未连接,无法执行吸液操作")
return False
try:
self.logger.info(f"正在执行吸液操作: 体积={volume}μL, 位置={location}")
# 这里应该实现实际的吸液逻辑
self.logger.info("吸液操作完成")
return True
except Exception as e:
self.logger.error(f"吸液操作失败: {e}")
return False
def dispense(self, volume: float, location: Dict[str, Any]) -> bool:
"""
排液操作
Args:
volume: 排液体积 (微升)
location: 排液位置信息
Returns:
bool: 排液是否成功
"""
if not self.is_connected:
self.logger.error("设备未连接,无法执行排液操作")
return False
try:
self.logger.info(f"正在执行排液操作: 体积={volume}μL, 位置={location}")
# 这里应该实现实际的排液逻辑
self.logger.info("排液操作完成")
return True
except Exception as e:
self.logger.error(f"排液操作失败: {e}")
return False
def pick_up_tip(self, location: Dict[str, Any]) -> bool:
"""
取枪头操作
Args:
location: 枪头位置信息
Returns:
bool: 取枪头是否成功
"""
if not self.is_connected:
self.logger.error("设备未连接,无法执行取枪头操作")
return False
try:
self.logger.info(f"正在执行取枪头操作: 位置={location}")
# 这里应该实现实际的取枪头逻辑
self.logger.info("取枪头操作完成")
return True
except Exception as e:
self.logger.error(f"取枪头操作失败: {e}")
return False
def drop_tip(self, location: Dict[str, Any]) -> bool:
"""
丢弃枪头操作
Args:
location: 丢弃位置信息
Returns:
bool: 丢弃枪头是否成功
"""
if not self.is_connected:
self.logger.error("设备未连接,无法执行丢弃枪头操作")
return False
try:
self.logger.info(f"正在执行丢弃枪头操作: 位置={location}")
# 这里应该实现实际的丢弃枪头逻辑
self.logger.info("丢弃枪头操作完成")
return True
except Exception as e:
self.logger.error(f"丢弃枪头操作失败: {e}")
return False
def move_to(self, location: Dict[str, Any]) -> bool:
"""
移动到指定位置
Args:
location: 目标位置信息
Returns:
bool: 移动是否成功
"""
if not self.is_connected:
self.logger.error("设备未连接,无法执行移动操作")
return False
try:
self.logger.info(f"正在移动到位置: {location}")
# 这里应该实现实际的移动逻辑
self.logger.info("移动操作完成")
return True
except Exception as e:
self.logger.error(f"移动操作失败: {e}")
return False
def get_status(self) -> Dict[str, Any]:
"""
获取设备状态
Returns:
Dict[str, Any]: 设备状态信息
"""
return {
"connected": self.is_connected,
"device_info": self.device_info,
"status": "ready" if self.is_connected else "disconnected"
}
# PyLabRobot 抽象方法实现
def stop(self):
"""停止所有操作"""
self.logger.info("停止所有操作")
pass
@property
def num_channels(self) -> int:
"""返回通道数量"""
return 1 # 单通道移液器
def can_pick_up_tip(self, tip_rack, tip_position) -> bool:
"""检查是否可以拾取吸头"""
return True # 简化实现总是返回True
def pick_up_tips(self, tip_rack, tip_positions):
"""拾取多个吸头"""
self.logger.info(f"拾取吸头: {tip_positions}")
pass
def drop_tips(self, tip_rack, tip_positions):
"""丢弃多个吸头"""
self.logger.info(f"丢弃吸头: {tip_positions}")
pass
def pick_up_tips96(self, tip_rack):
"""拾取96个吸头"""
self.logger.info("拾取96个吸头")
pass
def drop_tips96(self, tip_rack):
"""丢弃96个吸头"""
self.logger.info("丢弃96个吸头")
pass
def aspirate96(self, volume, plate, well_positions):
"""96通道吸液"""
self.logger.info(f"96通道吸液: 体积={volume}")
pass
def dispense96(self, volume, plate, well_positions):
"""96通道排液"""
self.logger.info(f"96通道排液: 体积={volume}")
pass
def pick_up_resource(self, resource, location):
"""拾取资源"""
self.logger.info(f"拾取资源: {resource}")
pass
def drop_resource(self, resource, location):
"""放置资源"""
self.logger.info(f"放置资源: {resource}")
pass
def move_picked_up_resource(self, resource, location):
"""移动已拾取的资源"""
self.logger.info(f"移动资源: {resource}{location}")
pass
def create_laiyu_backend(name: str = "LaiYu_Liquid_Backend") -> LaiYuLiquidBackend:
"""
创建LaiYu液体处理设备后端实例
Args:
name: 后端名称
Returns:
LaiYuLiquidBackend: 后端实例
"""
return LaiYuLiquidBackend(name)

View File

@@ -1,385 +1,307 @@
import json
"""LaiYu PLR 后端 — 对齐路径 B 硬件交互模式
硬件初始化顺序与 laiyu_liquid_station.py (路径 B) 一致:
1. XYZController(auto_connect=True) — 先开串口
2. PipetteController.connect_shared() — 共享 XYZ 的串口 / 锁
3. home_all_axes() + pipette.initialize()
"""
import logging
from typing import List, Optional, Union
from pylabrobot.liquid_handling.backends.backend import (
LiquidHandlerBackend,
)
from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend
from pylabrobot.liquid_handling.standard import (
Drop,
DropTipRack,
MultiHeadAspirationContainer,
MultiHeadAspirationPlate,
MultiHeadDispenseContainer,
MultiHeadDispensePlate,
Pickup,
PickupTipRack,
ResourceDrop,
ResourceMove,
ResourcePickup,
SingleChannelAspiration,
SingleChannelDispense,
Drop,
DropTipRack,
MultiHeadAspirationContainer,
MultiHeadAspirationPlate,
MultiHeadDispenseContainer,
MultiHeadDispensePlate,
Pickup,
PickupTipRack,
ResourceDrop,
ResourceMove,
ResourcePickup,
SingleChannelAspiration,
SingleChannelDispense,
)
from pylabrobot.resources import Resource, Tip
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import JointState
import time
from rclpy.action import ActionClient
from unilabos_msgs.action import SendCmd
import re
from unilabos.devices.liquid_handling.laiyu.controllers.xyz_controller import XYZController
from unilabos.devices.liquid_handling.laiyu.controllers.pipette_controller import (
PipetteController,
TipStatus,
)
from unilabos.devices.ros_dev.liquid_handler_joint_publisher import JointStatePublisher
from unilabos.devices.liquid_handling.laiyu.controllers.pipette_controller import PipetteController, TipStatus
logger = logging.getLogger(__name__)
class UniLiquidHandlerLaiyuBackend(LiquidHandlerBackend):
"""Chatter box backend for device-free testing. Prints out all operations."""
"""LaiYu 硬件后端 — PLR Backend 接口实现"""
_pip_length = 5
_vol_length = 8
_resource_length = 20
_offset_length = 16
_flow_rate_length = 10
_blowout_length = 10
_lld_z_length = 10
_kwargs_length = 15
_tip_type_length = 12
_max_volume_length = 16
_fitting_depth_length = 20
_tip_length_length = 16
# _pickup_method_length = 20
_filter_length = 10
def __init__(
self,
num_channels: int = 1,
tip_length: float = 0,
total_height: float = 310,
port: str = "/dev/ttyUSB0",
baudrate: int = 115200,
pipette_address: int = 4,
):
super().__init__()
self._num_channels = num_channels
self.tip_length = tip_length
self.total_height = total_height
def __init__(self, num_channels: int = 8 , tip_length: float = 0 , total_height: float = 310, port: str = "/dev/ttyUSB0"):
"""Initialize a chatter box backend."""
super().__init__()
self._num_channels = num_channels
self.tip_length = tip_length
self.total_height = total_height
# rclpy.init()
if not rclpy.ok():
rclpy.init()
self.joint_state_publisher = None
self.hardware_interface = PipetteController(port=port)
# 保存配置,延迟到 setup() 再创建硬件对象
self._port = port
self._baudrate = baudrate
self._pipette_address = pipette_address
async def setup(self):
# self.joint_state_publisher = JointStatePublisher()
# self.hardware_interface.xyz_controller.connect_device()
# self.hardware_interface.xyz_controller.home_all_axes()
await super().setup()
self.hardware_interface.connect()
self.hardware_interface.initialize()
self._xyz: Optional[XYZController] = None
self._pipette_ctrl: Optional[PipetteController] = None
self._ros_node = None
print("Setting up the liquid handler.")
# ------------------------------------------------------------------ lifecycle
async def stop(self):
print("Stopping the liquid handler.")
def post_init(self, ros_node):
"""接收 ROS 节点引用(由 Handler.post_init 调用)"""
self._ros_node = ros_node
def serialize(self) -> dict:
return {**super().serialize(), "num_channels": self.num_channels}
async def setup(self):
"""按路径 B 顺序初始化硬件"""
await super().setup()
def pipette_aspirate(self, volume: float, flow_rate: float):
# 1. XYZ 先开串口
self._xyz = XYZController(
port=self._port,
baudrate=self._baudrate,
auto_connect=True,
)
if not self._xyz.is_connected:
raise RuntimeError("XYZ 控制器连接失败")
self.hardware_interface.pipette.set_max_speed(flow_rate)
res = self.hardware_interface.pipette.aspirate(volume=volume)
if not res:
self.hardware_interface.logger.error("吸取失败,当前体积: {self.hardware_interface.current_volume}")
return
# 2. PipetteController 共享 XYZ 串口
self._pipette_ctrl = PipetteController(
port=self._port,
address=self._pipette_address,
)
self._pipette_ctrl.connect_shared(
serial_conn=self._xyz.serial_conn,
serial_lock=self._xyz.serial_lock,
xyz_controller=self._xyz,
)
self.hardware_interface.current_volume += volume
# 3. 回零 + 移液器初始化
self._xyz.home_all_axes()
self._pipette_ctrl.initialize()
def pipette_dispense(self, volume: float, flow_rate: float):
logger.info("LaiYu 后端硬件初始化完成")
self.hardware_interface.pipette.set_max_speed(flow_rate)
res = self.hardware_interface.pipette.dispense(volume=volume)
if not res:
self.hardware_interface.logger.error("排液失败,当前体积: {self.hardware_interface.current_volume}")
return
self.hardware_interface.current_volume -= volume
async def stop(self):
"""正确断开硬件"""
try:
if self._pipette_ctrl:
self._pipette_ctrl.disconnect_shared()
if self._xyz:
self._xyz.disconnect()
logger.info("LaiYu 后端硬件已断开")
except Exception as e:
logger.error(f"停止后端失败: {e}")
@property
def num_channels(self) -> int:
return self._num_channels
# ------------------------------------------------------------------ helpers
async def assigned_resource_callback(self, resource: Resource):
print(f"Resource {resource.name} was assigned to the liquid handler.")
def _plr_to_machine_coords(self, resource, offset):
"""PLR Resource 坐标 → 机器坐标 (倒置龙门架: total_height - z, -y)"""
coordinate = resource.get_absolute_location(x="c", y="c")
x = coordinate.x + offset.x
y = coordinate.y + offset.y
z_plr = coordinate.z + offset.z
return x, -y, self.total_height - (z_plr + self.tip_length)
async def unassigned_resource_callback(self, name: str):
print(f"Resource {name} was unassigned from the liquid handler.")
def _pipette_aspirate(self, volume: float, flow_rate: float):
self._pipette_ctrl.pipette.set_max_speed(flow_rate)
res = self._pipette_ctrl.pipette.aspirate(volume=volume)
if not res:
logger.error(f"吸取失败,当前体积: {self._pipette_ctrl.current_volume}")
return
self._pipette_ctrl.current_volume += volume
async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int], **backend_kwargs):
print("Picking up tips:")
# print(ops.tip)
header = (
f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{'tip type':<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
f"{'max volume (µL)':<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
f"{'fitting depth (mm)':<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
f"{'tip length (mm)':<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
# f"{'pickup method':<{ChatterboxBackend._pickup_method_length}} "
f"{'filter':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
)
# print(header)
def _pipette_dispense(self, volume: float, flow_rate: float):
self._pipette_ctrl.pipette.set_max_speed(flow_rate)
res = self._pipette_ctrl.pipette.dispense(volume=volume)
if not res:
logger.error(f"排液失败,当前体积: {self._pipette_ctrl.current_volume}")
return
self._pipette_ctrl.current_volume -= volume
for op, channel in zip(ops, use_channels):
offset = f"{round(op.offset.x, 1)},{round(op.offset.y, 1)},{round(op.offset.z, 1)}"
row = (
f" p{channel}: "
f"{op.resource.name[-30:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{op.tip.__class__.__name__:<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
f"{op.tip.maximal_volume:<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
f"{op.tip.fitting_depth:<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
f"{op.tip.total_tip_length:<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
# f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} "
f"{'Yes' if op.tip.has_filter else 'No':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
)
# print(row)
# print(op.resource.get_absolute_location())
self.tip_length = ops[0].tip.total_tip_length
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
offset_xyz = ops[0].offset
x = coordinate.x + offset_xyz.x
y = coordinate.y + offset_xyz.y
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
# print("moving")
self.hardware_interface._update_tip_status()
if self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED:
print("已有枪头,无需重复拾取")
return
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200)
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height,speed=100)
# self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "pick",channels=use_channels)
# goback()
# ------------------------------------------------------------------ properties
def serialize(self) -> dict:
return {**super().serialize(), "num_channels": self.num_channels}
@property
def num_channels(self) -> int:
return self._num_channels
# ------------------------------------------------------------------ resource callbacks
async def drop_tips(self, ops: List[Drop], use_channels: List[int], **backend_kwargs):
print("Dropping tips:")
header = (
f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{'tip type':<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
f"{'max volume (µL)':<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
f"{'fitting depth (mm)':<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
f"{'tip length (mm)':<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
# f"{'pickup method':<{ChatterboxBackend._pickup_method_length}} "
f"{'filter':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
)
# print(header)
async def assigned_resource_callback(self, resource: Resource):
logger.info(f"Resource {resource.name} was assigned to the liquid handler.")
for op, channel in zip(ops, use_channels):
offset = f"{round(op.offset.x, 1)},{round(op.offset.y, 1)},{round(op.offset.z, 1)}"
row = (
f" p{channel}: "
f"{op.resource.name[-30:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{op.tip.__class__.__name__:<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
f"{op.tip.maximal_volume:<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
f"{op.tip.fitting_depth:<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
f"{op.tip.total_tip_length:<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
# f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} "
f"{'Yes' if op.tip.has_filter else 'No':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
)
# print(row)
async def unassigned_resource_callback(self, name: str):
logger.info(f"Resource {name} was unassigned from the liquid handler.")
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
offset_xyz = ops[0].offset
x = coordinate.x + offset_xyz.x
y = coordinate.y + offset_xyz.y
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z -20
# print(x, y, z)
# print("moving")
self.hardware_interface._update_tip_status()
if self.hardware_interface.tip_status == TipStatus.NO_TIP:
print("无枪头,无需丢弃")
return
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200)
self.hardware_interface.eject_tip
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height)
# ------------------------------------------------------------------ pick_up_tips
async def aspirate(
self,
ops: List[SingleChannelAspiration],
use_channels: List[int],
**backend_kwargs,
):
print("Aspirating:")
header = (
f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
f"{'vol(ul)':<{UniLiquidHandlerLaiyuBackend._vol_length}} "
f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{'flow rate':<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
f"{'blowout':<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
f"{'lld_z':<{UniLiquidHandlerLaiyuBackend._lld_z_length}} "
# f"{'liquids':<20}" # TODO: add liquids
)
for key in backend_kwargs:
header += f"{key:<{UniLiquidHandlerLaiyuBackend._kwargs_length}} "[-16:]
# print(header)
async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int], **backend_kwargs):
tip = ops[0].tip
self.tip_length = tip.total_tip_length
x, y, z_top = self._plr_to_machine_coords(ops[0].resource, ops[0].offset)
for o, p in zip(ops, use_channels):
offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}"
row = (
f" p{p}: "
f"{o.volume:<{UniLiquidHandlerLaiyuBackend._vol_length}} "
f"{o.resource.name[-20:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{str(o.flow_rate):<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
f"{str(o.blow_out_air_volume):<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
f"{str(o.liquid_height):<{UniLiquidHandlerLaiyuBackend._lld_z_length}} "
# f"{o.liquids if o.liquids is not None else 'none'}"
)
for key, value in backend_kwargs.items():
if isinstance(value, list) and all(isinstance(v, bool) for v in value):
value = "".join("T" if v else "F" for v in value)
if isinstance(value, list):
value = "".join(map(str, value))
row += f" {value:<15}"
# print(row)
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
offset_xyz = ops[0].offset
x = coordinate.x + offset_xyz.x
y = coordinate.y + offset_xyz.y
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
# print(x, y, z)
# print("moving")
self._pipette_ctrl._update_tip_status()
if self._pipette_ctrl.tip_status == TipStatus.TIP_ATTACHED:
logger.warning("已有枪头,无需重复拾取")
return
# 判断枪头是否存在
self.hardware_interface._update_tip_status()
if not self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED:
print("无枪头,无法吸液")
return
# 判断吸液量是否超过枪头容量
flow_rate = backend_kwargs["flow_rate"] if "flow_rate" in backend_kwargs else 500
blow_out_air_volume = backend_kwargs["blow_out_air_volume"] if "blow_out_air_volume" in backend_kwargs else 0
if self.hardware_interface.current_volume + ops[0].volume + blow_out_air_volume > self.hardware_interface.max_volume:
self.hardware_interface.logger.error(f"吸液量超过枪头容量: {self.hardware_interface.current_volume + ops[0].volume} > {self.hardware_interface.max_volume}")
return
try:
# 1. 移到枪头正上方
self._xyz.move_to_work_coord_safe(x=x, y=y, z=z_top, speed=200)
# 2. 下压到套枪头深度fitting_depth 是枪头套入长度)
z_pickup = z_top + tip.fitting_depth
self._xyz.move_to_work_coord_safe(z=z_pickup, speed=100)
# 3. 退回安全高度
self._xyz.move_to_work_coord_safe(
z=self._xyz.machine_config.safe_z_height, speed=100
)
except Exception as e:
logger.error(f"pick_up_tips 移动失败: {e}")
raise
# 移动到吸液位置
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200)
self.pipette_aspirate(volume=ops[0].volume, flow_rate=flow_rate)
# ------------------------------------------------------------------ drop_tips
async def drop_tips(self, ops: List[Drop], use_channels: List[int], **backend_kwargs):
x, y, z = self._plr_to_machine_coords(ops[0].resource, ops[0].offset)
z -= 20 # 额外下移补偿
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height)
if blow_out_air_volume >0:
self.pipette_aspirate(volume=blow_out_air_volume, flow_rate=flow_rate)
self._pipette_ctrl._update_tip_status()
if self._pipette_ctrl.tip_status == TipStatus.NO_TIP:
logger.warning("无枪头,无需丢弃")
return
try:
self._xyz.move_to_work_coord_safe(x=x, y=y, z=z, speed=200)
self._pipette_ctrl.eject_tip() # 修复: 原来缺少 ()
self._xyz.move_to_work_coord_safe(
z=self._xyz.machine_config.safe_z_height
)
except Exception as e:
logger.error(f"drop_tips 失败: {e}")
raise
# ------------------------------------------------------------------ aspirate
async def aspirate(
self,
ops: List[SingleChannelAspiration],
use_channels: List[int],
**backend_kwargs,
):
x, y, z = self._plr_to_machine_coords(ops[0].resource, ops[0].offset)
async def dispense(
self,
ops: List[SingleChannelDispense],
use_channels: List[int],
**backend_kwargs,
):
# print("Dispensing:")
header = (
f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
f"{'vol(ul)':<{UniLiquidHandlerLaiyuBackend._vol_length}} "
f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{'flow rate':<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
f"{'blowout':<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
f"{'lld_z':<{UniLiquidHandlerLaiyuBackend._lld_z_length}} "
# f"{'liquids':<20}" # TODO: add liquids
)
for key in backend_kwargs:
header += f"{key:<{UniLiquidHandlerLaiyuBackend._kwargs_length}} "[-16:]
# print(header)
self._pipette_ctrl._update_tip_status()
if self._pipette_ctrl.tip_status != TipStatus.TIP_ATTACHED:
raise RuntimeError("无枪头,无法吸液")
for o, p in zip(ops, use_channels):
offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}"
row = (
f" p{p}: "
f"{o.volume:<{UniLiquidHandlerLaiyuBackend._vol_length}} "
f"{o.resource.name[-20:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{str(o.flow_rate):<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
f"{str(o.blow_out_air_volume):<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
f"{str(o.liquid_height):<{UniLiquidHandlerLaiyuBackend._lld_z_length}} "
# f"{o.liquids if o.liquids is not None else 'none'}"
)
for key, value in backend_kwargs.items():
if isinstance(value, list) and all(isinstance(v, bool) for v in value):
value = "".join("T" if v else "F" for v in value)
if isinstance(value, list):
value = "".join(map(str, value))
row += f" {value:<{UniLiquidHandlerLaiyuBackend._kwargs_length}}"
# print(row)
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
offset_xyz = ops[0].offset
x = coordinate.x + offset_xyz.x
y = coordinate.y + offset_xyz.y
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
# print(x, y, z)
# print("moving")
flow_rate = backend_kwargs.get("flow_rate", 500)
blow_out_air_volume = backend_kwargs.get("blow_out_air_volume", 0)
# 判断枪头是否存在
self.hardware_interface._update_tip_status()
if not self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED:
print("无枪头,无法排液")
return
# 判断排液量是否超过枪头容量
flow_rate = backend_kwargs["flow_rate"] if "flow_rate" in backend_kwargs else 500
blow_out_air_volume = backend_kwargs["blow_out_air_volume"] if "blow_out_air_volume" in backend_kwargs else 0
if self.hardware_interface.current_volume - ops[0].volume - blow_out_air_volume < 0:
self.hardware_interface.logger.error(f"排液量超过枪头容量: {self.hardware_interface.current_volume - ops[0].volume - blow_out_air_volume} < 0")
return
if (
self._pipette_ctrl.current_volume + ops[0].volume + blow_out_air_volume
> self._pipette_ctrl.max_volume
):
raise RuntimeError(
f"吸液量超过枪头容量: "
f"{self._pipette_ctrl.current_volume + ops[0].volume} > {self._pipette_ctrl.max_volume}"
)
# 移动到排液位置
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200)
self.pipette_dispense(volume=ops[0].volume, flow_rate=flow_rate)
self._xyz.move_to_work_coord_safe(x=x, y=y, z=z, speed=200)
self._pipette_aspirate(volume=ops[0].volume, flow_rate=flow_rate)
self._xyz.move_to_work_coord_safe(
z=self._xyz.machine_config.safe_z_height
)
if blow_out_air_volume > 0:
self._pipette_aspirate(volume=blow_out_air_volume, flow_rate=flow_rate)
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height)
if blow_out_air_volume > 0:
self.pipette_dispense(volume=blow_out_air_volume, flow_rate=flow_rate)
# self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "",channels=use_channels)
# ------------------------------------------------------------------ dispense
async def pick_up_tips96(self, pickup: PickupTipRack, **backend_kwargs):
print(f"Picking up tips from {pickup.resource.name}.")
async def dispense(
self,
ops: List[SingleChannelDispense],
use_channels: List[int],
**backend_kwargs,
):
x, y, z = self._plr_to_machine_coords(ops[0].resource, ops[0].offset)
async def drop_tips96(self, drop: DropTipRack, **backend_kwargs):
print(f"Dropping tips to {drop.resource.name}.")
self._pipette_ctrl._update_tip_status()
if self._pipette_ctrl.tip_status != TipStatus.TIP_ATTACHED:
raise RuntimeError("无枪头,无法排液")
async def aspirate96(
self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer]
):
if isinstance(aspiration, MultiHeadAspirationPlate):
resource = aspiration.wells[0].parent
else:
resource = aspiration.container
print(f"Aspirating {aspiration.volume} from {resource}.")
flow_rate = backend_kwargs.get("flow_rate", 500)
blow_out_air_volume = backend_kwargs.get("blow_out_air_volume", 0)
async def dispense96(self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]):
if isinstance(dispense, MultiHeadDispensePlate):
resource = dispense.wells[0].parent
else:
resource = dispense.container
print(f"Dispensing {dispense.volume} to {resource}.")
if (
self._pipette_ctrl.current_volume - ops[0].volume - blow_out_air_volume < 0
):
raise RuntimeError(
f"排液量超过当前体积: "
f"{self._pipette_ctrl.current_volume - ops[0].volume - blow_out_air_volume} < 0"
)
async def pick_up_resource(self, pickup: ResourcePickup):
print(f"Picking up resource: {pickup}")
self._xyz.move_to_work_coord_safe(x=x, y=y, z=z, speed=200)
self._pipette_dispense(volume=ops[0].volume, flow_rate=flow_rate)
async def move_picked_up_resource(self, move: ResourceMove):
print(f"Moving picked up resource: {move}")
self._xyz.move_to_work_coord_safe(
z=self._xyz.machine_config.safe_z_height
)
if blow_out_air_volume > 0:
self._pipette_dispense(volume=blow_out_air_volume, flow_rate=flow_rate)
async def drop_resource(self, drop: ResourceDrop):
print(f"Dropping resource: {drop}")
# ------------------------------------------------------------------ 96-channel stubs
def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool:
return True
async def pick_up_tips96(self, pickup: PickupTipRack, **backend_kwargs):
logger.info(f"Picking up tips from {pickup.resource.name}.")
async def drop_tips96(self, drop: DropTipRack, **backend_kwargs):
logger.info(f"Dropping tips to {drop.resource.name}.")
async def aspirate96(
self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer]
):
if isinstance(aspiration, MultiHeadAspirationPlate):
resource = aspiration.wells[0].parent
else:
resource = aspiration.container
logger.info(f"Aspirating {aspiration.volume} from {resource}.")
async def dispense96(
self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]
):
if isinstance(dispense, MultiHeadDispensePlate):
resource = dispense.wells[0].parent
else:
resource = dispense.container
logger.info(f"Dispensing {dispense.volume} to {resource}.")
async def pick_up_resource(self, pickup: ResourcePickup):
logger.info(f"Picking up resource: {pickup}")
async def move_picked_up_resource(self, move: ResourceMove):
logger.info(f"Moving picked up resource: {move}")
async def drop_resource(self, drop: ResourceDrop):
logger.info(f"Dropping resource: {drop}")
def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool:
return True

View File

@@ -5,21 +5,16 @@
封装SOPA移液器的高级控制功能
"""
# 添加项目根目录到Python路径以解决模块导入问题
import sys
import os
from tkinter import N
_current_file = os.path.abspath(__file__)
_project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(_current_file)))))
if _project_root not in sys.path:
sys.path.insert(0, _project_root)
from unilabos.devices.liquid_handling.laiyu.drivers.xyz_stepper_driver import ModbusException
# 无论如何都添加项目根目录到路径
current_file = os.path.abspath(__file__)
# 从 .../Uni-Lab-OS/unilabos/devices/LaiYu_Liquid/controllers/pipette_controller.py
# 向上5级到 .../Uni-Lab-OS
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(current_file)))))
# 强制添加项目根目录到sys.path的开头
sys.path.insert(0, project_root)
import time
import logging
from typing import Optional, List, Dict, Tuple
@@ -153,7 +148,7 @@ class PipetteController:
logger.error("移液器连接失败")
return False
logger.info("移液器连接成功")
# 连接XYZ步进电机控制器如果提供了端口
if self.xyz_port != self.pipette_port:
try:
@@ -172,24 +167,62 @@ class PipetteController:
try:
self.xyz_controller = XYZController(self.xyz_port, auto_connect=False)
self.xyz_controller.serial_conn = self.pipette.serial_port
self.xyz_controller.serial_lock = self.pipette.lock
self.xyz_controller.is_connected = True
logger.info("XYZ控制器与移液器共享串口和互斥锁")
except Exception as e:
logger.info("未配置XYZ步进电机端口跳过运动控制器连接")
logger.warning(f"共享端口 XYZ 控制器创建失败: {e}")
self.xyz_controller = None
self.xyz_connected = False
return True
except Exception as e:
logger.error(f"设备连接失败: {e}")
return False
def connect_shared(self, serial_conn, serial_lock, xyz_controller: XYZController) -> bool:
"""使用已连接的串口和XYZ控制器路径 B 模式XYZ 先开串口,移液器共享)
Args:
serial_conn: 已打开的串口连接(来自 XYZController
serial_lock: 串口互斥锁(来自 XYZController
xyz_controller: 已连接的 XYZController 实例
"""
try:
self.pipette.serial_port = serial_conn
self.pipette.lock = serial_lock
self.pipette.is_connected = True
self.xyz_controller = xyz_controller
self.xyz_connected = True
logger.info("移液控制器已通过 connect_shared 共享 XYZ 串口")
return True
except Exception as e:
logger.error(f"connect_shared 失败: {e}")
return False
def disconnect_shared(self) -> None:
"""释放共享串口引用(与 connect_shared 对称)。
注意:不关闭串口本身,串口由 XYZController 负责关闭。
"""
try:
self.pipette.serial_port = None
self.pipette.lock = None
self.pipette.is_connected = False
self.xyz_controller = None
self.xyz_connected = False
logger.info("移液控制器已释放共享串口引用")
except Exception as e:
logger.error(f"disconnect_shared 失败: {e}")
def initialize(self) -> bool:
"""初始化移液器"""
try:
if self.pipette.initialize():
logger.info("移液器初始化成功")
# 检查枪头状态
self._update_tip_status()
self.xyz_controller.home_all_axes()
self.xyz_controller.move_to_work_coord_safe(x=0, y=-150, z=0)
return True
return False
except Exception as e:
@@ -198,56 +231,58 @@ class PipetteController:
def disconnect(self):
"""断开连接"""
# 断开移液器连接
if self.xyz_controller and self.xyz_connected:
if self.xyz_port != self.pipette_port:
try:
self.xyz_controller.disconnect()
logger.info("XYZ 步进电机已断开")
except Exception as e:
logger.error(f"断开 XYZ 步进电机失败: {e}")
else:
self.xyz_controller.serial_conn = None
self.xyz_connected = False
self.xyz_controller = None
self.pipette.disconnect()
logger.info("移液器已断开")
# 断开 XYZ 步进电机连接
if self.xyz_controller and self.xyz_connected:
try:
self.xyz_controller.disconnect()
self.xyz_connected = False
logger.info("XYZ 步进电机已断开")
except Exception as e:
logger.error(f"断开 XYZ 步进电机失败: {e}")
def _check_xyz_safety(self, axis: MotorAxis, target_position: int) -> bool:
"""
检查 XYZ 轴移动的安全性
Args:
axis: 电机轴
target_position: 目标位置(步数)
Returns:
是否安全
"""
try:
# 获取当前电机状态
motor_position = self.xyz_controller.get_motor_status(axis)
# 检查电机状态是否正常 (不是碰撞停止或限位停止)
if motor_position.status in [MotorStatus.COLLISION_STOP,
MotorStatus.FORWARD_LIMIT_STOP,
if motor_position.status in [MotorStatus.COLLISION_STOP,
MotorStatus.FORWARD_LIMIT_STOP,
MotorStatus.REVERSE_LIMIT_STOP]:
logger.error(f"{axis.name} 轴电机处于错误状态: {motor_position.status.name}")
return False
# 检查位置限制 (扩大安全范围以适应实际硬件)
# 步进电机的位置范围通常很大,这里设置更合理的范围
if target_position < -500000 or target_position > 500000:
logger.error(f"{axis.name} 轴目标位置超出安全范围: {target_position}")
return False
# 检查移动距离是否过大 (单次移动不超过 20000 步约12mm)
current_position = motor_position.steps
move_distance = abs(target_position - current_position)
if move_distance > 20000:
logger.error(f"{axis.name} 轴单次移动距离过大: {move_distance}")
return False
return True
except Exception as e:
logger.error(f"安全检查失败: {e}")
return False
@@ -255,48 +290,48 @@ class PipetteController:
def move_z_relative(self, distance_mm: float, speed: int = 2000, acceleration: int = 500) -> bool:
"""
Z轴相对移动
Args:
distance_mm: 移动距离(mm),正值向下,负值向上
speed: 移动速度(rpm)
acceleration: 加速度(rpm/s)
Returns:
移动是否成功
"""
if not self.xyz_controller or not self.xyz_connected:
logger.error("XYZ 步进电机未连接,无法执行移动")
return False
try:
# 参数验证
if abs(distance_mm) > 15.0:
logger.error(f"移动距离过大: {distance_mm}mm最大允许15mm")
return False
if speed < 100 or speed > 5000:
logger.error(f"速度参数无效: {speed}rpm范围应为100-5000")
return False
# 获取当前 Z 轴位置
current_status = self.xyz_controller.get_motor_status(MotorAxis.Z)
current_z_position = current_status.steps
# 计算移动距离对应的步数 (1mm = 1638.4步)
mm_to_steps = 1638.4
move_distance_steps = int(distance_mm * mm_to_steps)
# 计算目标位置
target_z_position = current_z_position + move_distance_steps
# 安全检查
if not self._check_xyz_safety(MotorAxis.Z, target_z_position):
logger.error("Z轴移动安全检查失败")
return False
logger.info(f"Z轴相对移动: {distance_mm}mm ({move_distance_steps}步)")
logger.info(f"当前位置: {current_z_position}步 -> 目标位置: {target_z_position}")
# 执行移动
success = self.xyz_controller.move_to_position(
axis=MotorAxis.Z,
@@ -305,28 +340,28 @@ class PipetteController:
acceleration=acceleration,
precision=50
)
if not success:
logger.error("Z轴移动命令发送失败")
return False
# 等待移动完成
if not self.xyz_controller.wait_for_completion(MotorAxis.Z, timeout=10.0):
logger.error("Z轴移动超时")
return False
# 验证移动结果
final_status = self.xyz_controller.get_motor_status(MotorAxis.Z)
final_position = final_status.steps
position_error = abs(final_position - target_z_position)
logger.info(f"Z轴移动完成最终位置: {final_position}步,误差: {position_error}")
if position_error > 100:
logger.warning(f"Z轴位置误差较大: {position_error}")
return True
except ModbusException as e:
logger.error(f"Modbus通信错误: {e}")
return False
@@ -337,21 +372,20 @@ class PipetteController:
def emergency_stop(self) -> bool:
"""
紧急停止所有运动
Returns:
停止是否成功
"""
success = True
# 停止移液器操作
try:
if self.pipette and self.connected:
# 这里可以添加移液器的紧急停止逻辑
if self.pipette and self.pipette.is_connected:
self.pipette.emergency_stop()
logger.info("移液器紧急停止")
except Exception as e:
logger.error(f"移液器紧急停止失败: {e}")
success = False
# 停止 XYZ 轴运动
try:
if self.xyz_controller and self.xyz_connected:
@@ -360,7 +394,7 @@ class PipetteController:
except Exception as e:
logger.error(f"XYZ 轴紧急停止失败: {e}")
success = False
return success
def pickup_tip(self) -> bool:
@@ -376,7 +410,7 @@ class PipetteController:
return True
logger.info("开始装载枪头 - Z轴向下移动10mm")
# 使用相对移动方法向下移动10mm
if self.move_z_relative(distance_mm=10.0, speed=2000, acceleration=500):
# 更新枪头状态
@@ -688,31 +722,31 @@ class PipetteController:
if __name__ == "__main__":
# 配置日志
import logging
# 设置日志级别
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
def interactive_test():
"""交互式测试模式 - 适用于已连接的设备"""
print("\n" + "=" * 60)
print("🧪 移液器交互式测试模式")
print("=" * 60)
# 获取用户输入的连接参数
print("\n📡 设备连接配置:")
port = input("请输入移液器串口端口 (默认: /dev/ttyUSB_CH340): ").strip() or "/dev/ttyUSB_CH340"
address_input = input("请输入移液器设备地址 (默认: 4): ").strip()
address = int(address_input) if address_input else 4
# 询问是否连接 XYZ 步进电机控制器
xyz_enable = input("是否连接 XYZ 步进电机控制器? (y/N): ").strip().lower()
xyz_port = None
if xyz_enable not in ['n', 'no']:
xyz_port = input("请输入 XYZ 控制器串口端口 (默认: /dev/ttyUSB_CH340): ").strip() or "/dev/ttyUSB_CH340"
try:
# 创建移液控制器实例
if xyz_port:
@@ -721,21 +755,21 @@ if __name__ == "__main__":
else:
print(f"\n🔧 创建移液控制器实例 (端口: {port}, 地址: {address})...")
pipette = PipetteController(port=port, address=address)
# 连接设备
print("\n📞 连接移液器设备...")
if not pipette.connect():
print("❌ 设备连接失败,请检查连接")
return
print("✅ 设备连接成功")
# 初始化设备
print("\n🚀 初始化设备...")
if not pipette.initialize():
print("❌ 设备初始化失败")
return
print("✅ 设备初始化成功")
# 交互式菜单
while True:
print("\n" + "=" * 50)
@@ -755,9 +789,9 @@ if __name__ == "__main__":
print("99. 🚨 紧急停止")
print("0. 🚪 退出程序")
print("=" * 50)
choice = input("\n请选择操作 (0-12, 99): ").strip()
if choice == "0":
print("\n👋 退出程序...")
break
@@ -773,7 +807,7 @@ if __name__ == "__main__":
# print(f" 🔧 枪头使用次数: {status['statistics']['tip_count']}")
print(f" ⬆️ 吸液次数: {status['statistics']['aspirate_count']}")
print(f" ⬇️ 排液次数: {status['statistics']['dispense_count']}")
elif choice == "2":
# 装载枪头
print("\n🔧 装载枪头...")
@@ -781,14 +815,14 @@ if __name__ == "__main__":
print("📍 使用 XYZ 控制器进行 Z 轴定位 (下移 10mm)")
else:
print("⚠️ 未连接 XYZ 控制器,仅执行移液器枪头装载")
if pipette.pickup_tip():
print("✅ 枪头装载成功")
if pipette.xyz_connected:
print("📍 Z 轴已移动到装载位置")
else:
print("❌ 枪头装载失败")
elif choice == "3":
# 弹出枪头
print("\n🗑️ 弹出枪头...")
@@ -796,7 +830,7 @@ if __name__ == "__main__":
print("✅ 枪头弹出成功")
else:
print("❌ 枪头弹出失败")
elif choice == "4":
# 吸液操作
try:
@@ -810,7 +844,7 @@ if __name__ == "__main__":
print("❌ 吸液失败")
except ValueError:
print("❌ 请输入有效的数字")
elif choice == "5":
# 排液操作
try:
@@ -824,7 +858,7 @@ if __name__ == "__main__":
print("❌ 排液失败")
except ValueError:
print("❌ 请输入有效的数字")
elif choice == "6":
# 混合操作
try:
@@ -838,7 +872,7 @@ if __name__ == "__main__":
print("❌ 混合失败")
except ValueError:
print("❌ 请输入有效的数字")
elif choice == "7":
# 液体转移
try:
@@ -846,7 +880,7 @@ if __name__ == "__main__":
source = input("源孔位 (可选, 如A1): ").strip() or None
dest = input("目标孔位 (可选, 如B1): ").strip() or None
new_tip = input("是否使用新枪头? (y/n, 默认y): ").strip().lower() != 'n'
print(f"\n🔄 执行液体转移 ({volume}ul)...")
if pipette.transfer(volume=volume, source_well=source, dest_well=dest, new_tip=new_tip):
print("✅ 液体转移完成")
@@ -854,7 +888,7 @@ if __name__ == "__main__":
print("❌ 液体转移失败")
except ValueError:
print("❌ 请输入有效的数字")
elif choice == "8":
# 设置液体类型
print("\n🧪 可用液体类型:")
@@ -864,16 +898,16 @@ if __name__ == "__main__":
"3": (LiquidClass.VISCOUS, "粘稠液体"),
"4": (LiquidClass.VOLATILE, "挥发性液体")
}
for key, (liquid_class, description) in liquid_options.items():
print(f" {key}. {description}")
liquid_choice = input("请选择液体类型 (1-4): ").strip()
if liquid_choice in liquid_options:
liquid_class, description = liquid_options[liquid_choice]
pipette.set_liquid_class(liquid_class)
print(f"✅ 液体类型设置为: {description}")
# 显示参数
params = pipette.liquid_params
print(f"📋 参数设置:")
@@ -883,7 +917,7 @@ if __name__ == "__main__":
print(f" 💧 预润湿: {'' if params.pre_wet else ''}")
else:
print("❌ 无效选择")
elif choice == "9":
# 自定义参数
try:
@@ -892,19 +926,19 @@ if __name__ == "__main__":
dispense_speed = input("排液速度 (默认800): ").strip()
air_gap = input("空气间隙 (ul, 默认10.0): ").strip()
pre_wet = input("预润湿 (y/n, 默认n): ").strip().lower() == 'y'
custom_params = LiquidParameters(
aspirate_speed=int(aspirate_speed) if aspirate_speed else 500,
dispense_speed=int(dispense_speed) if dispense_speed else 800,
air_gap=float(air_gap) if air_gap else 10.0,
pre_wet=pre_wet
)
pipette.set_custom_parameters(custom_params)
print("✅ 自定义参数设置完成")
except ValueError:
print("❌ 请输入有效的数字")
elif choice == "10":
# 校准体积
try:
@@ -914,12 +948,12 @@ if __name__ == "__main__":
print(f"✅ 校准完成,校准系数: {actual/expected:.3f}")
except ValueError:
print("❌ 请输入有效的数字")
elif choice == "11":
# 重置统计
pipette.reset_statistics()
print("✅ 统计信息已重置")
elif choice == "12":
# 液体类型测试
print("\n🧪 液体类型参数对比:")
@@ -929,7 +963,7 @@ if __name__ == "__main__":
(LiquidClass.VISCOUS, "粘稠液体"),
(LiquidClass.VOLATILE, "挥发性液体")
]
for liquid_class, description in liquid_tests:
params = pipette.LIQUID_PARAMS[liquid_class]
print(f"\n📋 {description} ({liquid_class.value}):")
@@ -938,7 +972,7 @@ if __name__ == "__main__":
print(f" 💨 空气间隙: {params.air_gap}ul")
print(f" 💧 预润湿: {'' if params.pre_wet else ''}")
print(f" ⏱️ 吸液后延时: {params.delay_after_aspirate}s")
elif choice == "99":
# 紧急停止
print("\n🚨 执行紧急停止...")
@@ -949,19 +983,19 @@ if __name__ == "__main__":
else:
print("❌ 紧急停止执行失败")
print("⚠️ 请手动检查设备状态并采取必要措施")
# 紧急停止后询问是否继续
continue_choice = input("\n是否继续操作?(y/n): ").strip().lower()
if continue_choice != 'y':
print("🚪 退出程序")
break
else:
print("❌ 无效选择,请重新输入")
# 等待用户确认继续
input("\n按回车键继续...")
except KeyboardInterrupt:
print("\n\n⚠️ 用户中断操作")
except Exception as e:
@@ -974,19 +1008,19 @@ if __name__ == "__main__":
print("✅ 连接已断开")
except:
print("⚠️ 断开连接时出现问题")
def demo_test():
"""演示测试模式 - 完整功能演示"""
print("\n" + "=" * 60)
print("🎬 移液控制器演示测试")
print("=" * 60)
try:
# 创建移液控制器实例
print("1. 🔧 创建移液控制器实例...")
pipette = PipetteController(port="/dev/ttyUSB0", address=4)
print("✅ 移液控制器实例创建成功")
# 连接设备
print("\n2. 📞 连接移液器设备...")
if pipette.connect():
@@ -994,7 +1028,7 @@ if __name__ == "__main__":
else:
print("❌ 设备连接失败")
return False
# 初始化设备
print("\n3. 🚀 初始化设备...")
if pipette.initialize():
@@ -1002,19 +1036,19 @@ if __name__ == "__main__":
else:
print("❌ 设备初始化失败")
return False
# 装载枪头
print("\n4. 🔧 装载枪头...")
if pipette.pickup_tip():
print("✅ 枪头装载成功")
else:
print("❌ 枪头装载失败")
# 设置液体类型
print("\n5. 🧪 设置液体类型为血清...")
pipette.set_liquid_class(LiquidClass.SERUM)
print("✅ 液体类型设置完成")
# 吸液操作
print("\n6. 💧 执行吸液操作...")
volume_to_aspirate = 100.0
@@ -1023,7 +1057,7 @@ if __name__ == "__main__":
print(f"📊 当前体积: {pipette.current_volume}ul")
else:
print("❌ 吸液失败")
# 排液操作
print("\n7. 💦 执行排液操作...")
volume_to_dispense = 50.0
@@ -1032,14 +1066,14 @@ if __name__ == "__main__":
print(f"📊 剩余体积: {pipette.current_volume}ul")
else:
print("❌ 排液失败")
# 混合操作
print("\n8. 🌀 执行混合操作...")
if pipette.mix(cycles=3, volume=30.0):
print("✅ 混合完成")
else:
print("❌ 混合失败")
# 获取状态信息
print("\n9. 📊 获取设备状态...")
status = pipette.get_status()
@@ -1052,30 +1086,30 @@ if __name__ == "__main__":
# print(f" 🔧 枪头使用次数: {status['statistics']['tip_count']}")
print(f" ⬆️ 吸液次数: {status['statistics']['aspirate_count']}")
print(f" ⬇️ 排液次数: {status['statistics']['dispense_count']}")
# 弹出枪头
print("\n10. 🗑️ 弹出枪头...")
if pipette.eject_tip():
print("✅ 枪头弹出成功")
else:
print("❌ 枪头弹出失败")
print("\n" + "=" * 60)
print("✅ 移液控制器演示测试完成")
print("=" * 60)
return True
except Exception as e:
print(f"\n❌ 测试过程中发生异常: {e}")
return False
finally:
# 断开连接
print("\n📞 断开连接...")
pipette.disconnect()
print("✅ 连接已断开")
# 主程序入口
print("🧪 移液器控制器测试程序")
print("=" * 40)
@@ -1083,9 +1117,9 @@ if __name__ == "__main__":
print("2. 🎬 演示测试")
print("0. 🚪 退出")
print("=" * 40)
mode = input("请选择测试模式 (0-2): ").strip()
if mode == "1":
interactive_test()
elif mode == "2":
@@ -1094,7 +1128,7 @@ if __name__ == "__main__":
print("👋 再见!")
else:
print("❌ 无效选择")
print("\n🎉 程序结束!")
print("\n💡 使用说明:")
print("1. 确保移液器硬件已正确连接")

View File

@@ -13,7 +13,7 @@ from pylabrobot.liquid_handling import (
SingleChannelDispense,
PickupTipRack,
DropTipRack,
MultiHeadAspirationPlate, ChatterBoxBackend, LiquidHandlerChatterboxBackend,
MultiHeadAspirationPlate,
)
from pylabrobot.liquid_handling.standard import (
MultiHeadAspirationContainer,
@@ -41,12 +41,6 @@ class TransformXYZDeck(Deck):
super().__init__(name, size_x, size_y, size_z)
self.name = name
class TransformXYZBackend(LiquidHandlerBackend):
def __init__(self, name: str, host: str, port: int, timeout: float):
super().__init__()
self.host = host
self.port = port
self.timeout = timeout
class TransformXYZRvizBackend(UniLiquidHandlerRvizBackend):
def __init__(self, name: str, channel_num: int):
@@ -86,7 +80,9 @@ class TransformXYZContainer(Plate, TipRack):
class TransformXYZHandler(LiquidHandlerAbstract):
support_touch_tip = False
def __init__(self, deck: Deck, host: str = "127.0.0.1", port: int = 9999, timeout: float = 10.0, channel_num=1, simulator=True, **backend_kwargs):
def __init__(self, deck: Deck, host: str = "127.0.0.1", port: int = 9999, timeout: float = 10.0, channel_num=1, simulator=True,
serial_port: str = "/dev/ttyUSB0", baudrate: int = 115200, pipette_address: int = 4,
total_height: float = 310, **backend_kwargs):
# Handle case where deck is passed as a dict (from serialization)
if isinstance(deck, dict):
# Try to create a TransformXYZDeck from the dict
@@ -102,11 +98,22 @@ class TransformXYZHandler(LiquidHandlerAbstract):
deck = TransformXYZDeck(name='deck', size_x=100, size_y=100, size_z=100)
if simulator:
self._unilabos_backend = TransformXYZRvizBackend(name="laiyu",channel_num=channel_num)
self._unilabos_backend = TransformXYZRvizBackend(name="laiyu", channel_num=channel_num)
else:
self._unilabos_backend = TransformXYZBackend(name="laiyu",host=host, port=port, timeout=timeout)
self._unilabos_backend = UniLiquidHandlerLaiyuBackend(
num_channels=channel_num,
total_height=total_height,
port=serial_port,
baudrate=baudrate,
pipette_address=pipette_address,
)
super().__init__(backend=self._unilabos_backend, deck=deck, simulator=simulator, channel_num=channel_num)
def post_init(self, ros_node):
super().post_init(ros_node)
if hasattr(self._unilabos_backend, 'post_init'):
self._unilabos_backend.post_init(ros_node)
async def add_liquid(
self,
asp_vols: Union[List[float], float],
@@ -128,7 +135,25 @@ class TransformXYZHandler(LiquidHandlerAbstract):
mix_liquid_height: Optional[float] = None,
none_keys: List[str] = [],
):
pass
return await super().add_liquid(
asp_vols=asp_vols,
dis_vols=dis_vols,
reagent_sources=reagent_sources,
targets=targets,
use_channels=use_channels,
flow_rates=flow_rates,
offsets=offsets,
liquid_height=liquid_height,
blow_out_air_volume=blow_out_air_volume,
spread=spread,
is_96_well=is_96_well,
delays=delays,
mix_time=mix_time,
mix_vol=mix_vol,
mix_rate=mix_rate,
mix_liquid_height=mix_liquid_height,
none_keys=none_keys,
)
async def aspirate(
self,
@@ -142,7 +167,17 @@ class TransformXYZHandler(LiquidHandlerAbstract):
spread: Literal["wide", "tight", "custom"] = "wide",
**backend_kwargs,
):
pass
return await super().aspirate(
resources=resources,
vols=vols,
use_channels=use_channels,
flow_rates=flow_rates,
offsets=offsets,
liquid_height=liquid_height,
blow_out_air_volume=blow_out_air_volume,
spread=spread,
**backend_kwargs,
)
async def dispense(
self,
@@ -156,7 +191,17 @@ class TransformXYZHandler(LiquidHandlerAbstract):
spread: Literal["wide", "tight", "custom"] = "wide",
**backend_kwargs,
):
pass
return await super().dispense(
resources=resources,
vols=vols,
use_channels=use_channels,
flow_rates=flow_rates,
offsets=offsets,
liquid_height=liquid_height,
blow_out_air_volume=blow_out_air_volume,
spread=spread,
**backend_kwargs,
)
async def drop_tips(
self,
@@ -166,7 +211,13 @@ class TransformXYZHandler(LiquidHandlerAbstract):
allow_nonzero_volume: bool = False,
**backend_kwargs,
):
pass
return await super().drop_tips(
tip_spots=tip_spots,
use_channels=use_channels,
offsets=offsets,
allow_nonzero_volume=allow_nonzero_volume,
**backend_kwargs,
)
async def mix(
self,
@@ -178,7 +229,15 @@ class TransformXYZHandler(LiquidHandlerAbstract):
mix_rate: Optional[float] = None,
none_keys: List[str] = [],
):
pass
return await super().mix(
targets=targets,
mix_time=mix_time,
mix_vol=mix_vol,
height_to_bottom=height_to_bottom,
offsets=offsets,
mix_rate=mix_rate,
none_keys=none_keys,
)
async def pick_up_tips(
self,
@@ -187,7 +246,12 @@ class TransformXYZHandler(LiquidHandlerAbstract):
offsets: Optional[List[Coordinate]] = None,
**backend_kwargs,
):
pass
return await super().pick_up_tips(
tip_spots=tip_spots,
use_channels=use_channels,
offsets=offsets,
**backend_kwargs,
)
async def transfer_liquid(
self,
@@ -214,5 +278,26 @@ class TransformXYZHandler(LiquidHandlerAbstract):
delays: Optional[List[int]] = None,
none_keys: List[str] = [],
):
pass
return await super().transfer_liquid(
sources=sources,
targets=targets,
tip_racks=tip_racks,
use_channels=use_channels,
asp_vols=asp_vols,
dis_vols=dis_vols,
asp_flow_rates=asp_flow_rates,
dis_flow_rates=dis_flow_rates,
offsets=offsets,
touch_tip=touch_tip,
liquid_height=liquid_height,
blow_out_air_volume=blow_out_air_volume,
spread=spread,
is_96_well=is_96_well,
mix_stage=mix_stage,
mix_times=mix_times,
mix_vol=mix_vol,
mix_rate=mix_rate,
mix_liquid_height=mix_liquid_height,
delays=delays,
none_keys=none_keys,
)

View File

@@ -57,6 +57,18 @@ class TransferLiquidReturn(TypedDict):
targets: List[List[ResourceDict]]
class SetLiquidReturn(TypedDict):
wells: list
volumes: list
class SetLiquidFromPlateReturn(TypedDict):
plate: list
wells: list
volumes: list
class LiquidHandlerMiddleware(LiquidHandler):
def __init__(
self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool = False, channel_num: int = 8, **kwargs

View File

@@ -0,0 +1,376 @@
# -*- coding: utf-8 -*-
"""
ZDT X42 Closed-Loop Stepper Motor Driver
RS485 Serial Communication via USB-Serial Converter
- Baudrate: 115200
"""
import serial
import time
import threading
import struct
import logging
from typing import Optional, Any
try:
from unilabos.device_comms.universal_driver import UniversalDriver
except ImportError:
class UniversalDriver:
def __init__(self, *args, **kwargs):
self.logger = logging.getLogger(self.__class__.__name__)
def execute_command_from_outer(self, command: Any): pass
from serial.rs485 import RS485Settings
class ZDTX42Driver(UniversalDriver):
"""
ZDT X42 闭环步进电机驱动器
支持功能:
- 速度模式运行
- 位置模式运行 (相对/绝对)
- 位置读取和清零
- 使能/禁用控制
通信协议:
- 帧格式: [设备ID] [功能码] [数据...] [校验位=0x6B]
- 响应长度根据功能码决定
"""
def __init__(
self,
port: str,
baudrate: int = 115200,
device_id: int = 1,
timeout: float = 0.5,
debug: bool = False
):
"""
初始化 ZDT X42 电机驱动
Args:
port: 串口设备路径
baudrate: 波特率 (默认 115200)
device_id: 设备地址 (1-255)
timeout: 通信超时时间(秒)
debug: 是否启用调试输出
"""
super().__init__()
self.id = device_id
self.debug = debug
self.lock = threading.RLock()
self.status = "idle" # 对应注册表中的 status (str)
self.position = 0 # 对应注册表中的 position (int)
try:
self.ser = serial.Serial(
port=port,
baudrate=baudrate,
timeout=timeout,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE
)
# 启用 RS485 模式
try:
self.ser.rs485_mode = RS485Settings(
rts_level_for_tx=True,
rts_level_for_rx=False
)
except Exception:
pass # RS485 模式是可选的
self.logger.info(
f"ZDT X42 Motor connected: {port} "
f"(Baud: {baudrate}, ID: {device_id})"
)
# 自动使能电机,确保初始状态可运动
self.enable(True)
# 启动背景轮询线程,确保 position 实时刷新
self._stop_event = threading.Event()
self._polling_thread = threading.Thread(
target=self._update_loop,
name=f"ZDTPolling_{port}",
daemon=True
)
self._polling_thread.start()
except Exception as e:
self.logger.error(f"Failed to open serial port {port}: {e}")
self.ser = None
def _update_loop(self):
"""背景循环读取电机位置"""
while not self._stop_event.is_set():
try:
self.get_position()
except Exception as e:
if self.debug:
self.logger.error(f"Polling error: {e}")
time.sleep(1.0) # 每1秒刷新一次位置数据
def _send(self, func_code: int, payload: list) -> bytes:
"""
发送指令并接收响应
Args:
func_code: 功能码
payload: 数据负载 (list of bytes)
Returns:
响应数据 (bytes)
"""
if not self.ser:
self.logger.error("Serial port not available")
return b""
with self.lock:
# 清空输入缓冲区
self.ser.reset_input_buffer()
# 构建消息: [ID] [功能码] [数据...] [校验位=0x6B]
message = bytes([self.id, func_code] + payload + [0x6B])
# 发送
self.ser.write(message)
# 根据功能码决定响应长度
# 查询类指令返回 10 字节,控制类指令返回 4 字节
read_len = 10 if func_code in [0x31, 0x32, 0x35, 0x24, 0x27] else 4
response = self.ser.read(read_len)
# 调试输出
if self.debug:
sent_hex = message.hex().upper()
recv_hex = response.hex().upper() if response else 'TIMEOUT'
print(f"[ID {self.id}] TX: {sent_hex} → RX: {recv_hex}")
return response
def enable(self, on: bool = True) -> bool:
"""
使能/禁用电机
Args:
on: True=使能(锁轴), False=禁用(松轴)
Returns:
是否成功
"""
state = 1 if on else 0
resp = self._send(0xF3, [0xAB, state, 0])
return len(resp) >= 4
def move_speed(
self,
speed_rpm: int,
direction: str = "CW",
acceleration: int = 10
) -> bool:
"""
速度模式运行
Args:
speed_rpm: 转速 (RPM)
direction: 方向 ("CW"=顺时针, "CCW"=逆时针)
acceleration: 加速度 (0-255)
Returns:
是否成功
"""
dir_val = 0 if direction.upper() in ["CW", "顺时针"] else 1
speed_bytes = struct.pack('>H', int(speed_rpm))
self.status = f"moving@{speed_rpm}rpm"
resp = self._send(0xF6, [dir_val, speed_bytes[0], speed_bytes[1], acceleration, 0])
return len(resp) >= 4
def move_position(
self,
pulses: int,
speed_rpm: int,
direction: str = "CW",
acceleration: int = 10,
absolute: bool = False
) -> bool:
"""
位置模式运行
Args:
pulses: 脉冲数
speed_rpm: 转速 (RPM)
direction: 方向 ("CW"=顺时针, "CCW"=逆时针)
acceleration: 加速度 (0-255)
absolute: True=绝对位置, False=相对位置
Returns:
是否成功
"""
dir_val = 0 if direction.upper() in ["CW", "顺时针"] else 1
speed_bytes = struct.pack('>H', int(speed_rpm))
self.status = f"moving_to_{pulses}"
pulse_bytes = struct.pack('>I', int(pulses))
abs_flag = 1 if absolute else 0
payload = [
dir_val,
speed_bytes[0], speed_bytes[1],
acceleration,
pulse_bytes[0], pulse_bytes[1], pulse_bytes[2], pulse_bytes[3],
abs_flag,
0
]
resp = self._send(0xFD, payload)
return len(resp) >= 4
def stop(self) -> bool:
"""
停止电机
Returns:
是否成功
"""
self.status = "idle"
resp = self._send(0xFE, [0x98, 0])
return len(resp) >= 4
def rotate_quarter(self, speed_rpm: int = 60, direction: str = "CW") -> bool:
"""
电机旋转 1/4 圈 (阻塞式)
假设电机细分为 3200 脉冲/圈1/4 圈 = 800 脉冲
"""
pulses = 800
success = self.move_position(pulses=pulses, speed_rpm=speed_rpm, direction=direction, absolute=False)
if success:
# 计算预估旋转时间并进行阻塞等待 (Time = revolutions / (RPM/60))
# 1/4 rev / (RPM/60) = 15.0 / RPM
estimated_time = 15.0 / max(1, speed_rpm)
time.sleep(estimated_time + 0.5) # 额外给 0.5 秒缓冲
self.status = "idle"
return success
def wait_time(self, duration_s: float) -> bool:
"""
等待指定时间 (秒)
"""
self.logger.info(f"Waiting for {duration_s} seconds...")
time.sleep(duration_s)
return True
def set_zero(self) -> bool:
"""
清零当前位置
Returns:
是否成功
"""
resp = self._send(0x0A, [])
return len(resp) >= 4
def get_position(self) -> Optional[int]:
"""
读取当前位置 (脉冲数)
Returns:
当前位置脉冲数,失败返回 None
"""
resp = self._send(0x32, [])
if len(resp) >= 8:
# 响应格式: [ID] [Func] [符号位] [数值4字节] [校验]
sign = resp[2] # 0=正, 1=负
value = struct.unpack('>I', resp[3:7])[0]
self.position = -value if sign == 1 else value
if self.debug:
print(f"[Position] Raw: {resp.hex().upper()}, Parsed: {self.position}")
return self.position
self.logger.warning("Failed to read position")
return None
def close(self):
"""关闭串口连接并停止线程"""
if hasattr(self, '_stop_event'):
self._stop_event.set()
if self.ser and self.ser.is_open:
self.ser.close()
self.logger.info("Serial port closed")
# ============================================================
# 测试和调试代码
# ============================================================
def test_motor():
"""基础功能测试"""
logging.basicConfig(level=logging.INFO)
print("="*60)
print("ZDT X42 电机驱动测试")
print("="*60)
driver = ZDTX42Driver(
port="/dev/tty.usbserial-3110",
baudrate=115200,
device_id=2,
debug=True
)
if not driver.ser:
print("❌ 串口打开失败")
return
try:
# 测试 1: 读取位置
print("\n[1] 读取当前位置")
pos = driver.get_position()
print(f"✓ 当前位置: {pos} 脉冲")
# 测试 2: 使能
print("\n[2] 使能电机")
driver.enable(True)
time.sleep(0.3)
print("✓ 电机已锁定")
# 测试 3: 相对位置运动
print("\n[3] 相对位置运动 (1000脉冲)")
driver.move_position(pulses=1000, speed_rpm=60, direction="CW")
time.sleep(2)
pos = driver.get_position()
print(f"✓ 新位置: {pos}")
# 测试 4: 速度运动
print("\n[4] 速度模式 (30RPM, 3秒)")
driver.move_speed(speed_rpm=30, direction="CW")
time.sleep(3)
driver.stop()
pos = driver.get_position()
print(f"✓ 停止后位置: {pos}")
# 测试 5: 禁用
print("\n[5] 禁用电机")
driver.enable(False)
print("✓ 电机已松开")
print("\n" + "="*60)
print("✅ 测试完成")
print("="*60)
except Exception as e:
print(f"\n❌ 测试失败: {e}")
import traceback
traceback.print_exc()
finally:
driver.close()
if __name__ == "__main__":
test_motor()

View File

@@ -219,10 +219,10 @@ device = NewareBatteryTestSystem(
#### 步骤 2提交测试任务
使用 `submit_from_csv_export_ndax` 提交测试任务:
使用 `submit_from_csv` 提交测试任务:
```python
result = device.submit_from_csv_export_ndax(
result = device.submit_from_csv(
csv_path="test_data.csv",
output_dir="D:/neware_output"
)
@@ -489,7 +489,7 @@ A: 重新获取新的 Token 并更新环境变量 `UNI_LAB_AUTH_TOKEN`。
**Q: 可以自定义上传路径吗?**
A: 当前版本路径由统一 API 自动分配,`oss_prefix` 参数暂不使用(保留接口兼容性)。
**Q: 为什么不在 `submit_from_csv_export_ndax` 中自动上传?**
**Q: 为什么不在 `submit_from_csv` 中自动上传?**
A: 因为备份文件在测试进行中逐步生成,方法返回时可能文件尚未完全生成,因此提供独立的上传方法更灵活。
**Q: 上传后如何访问文件?**

View File

@@ -230,10 +230,10 @@ device = NewareBatteryTestSystem(
#### Step 2: Submit Test Tasks
Use `submit_from_csv_export_ndax` to submit test tasks:
Use `submit_from_csv` to submit test tasks:
```python
result = device.submit_from_csv_export_ndax(
result = device.submit_from_csv(
csv_path="test_data.csv",
output_dir="D:/neware_output"
)
@@ -500,7 +500,7 @@ A: Obtain a new API Key and update the `UNI_LAB_AUTH_TOKEN` environment variable
**Q: Can I customize upload paths?**
A: Current version has paths automatically assigned by unified API. `oss_prefix` parameter is currently unused (retained for interface compatibility).
**Q: Why not auto-upload in `submit_from_csv_export_ndax`?**
**Q: Why not auto-upload in `submit_from_csv`?**
A: Because backup files are generated progressively during testing, they may not be fully generated when the method returns. A separate upload method provides more flexibility.
**Q: How to access files after upload?**

View File

@@ -14,7 +14,7 @@
"config": {
"ip": "127.0.0.1",
"port": 502,
"machine_ids": [1, 2, 3, 4, 5, 6, 86],
"machine_id": 1,
"devtype": "27",
"timeout": 20,
"size_x": 500.0,
@@ -26,10 +26,10 @@
"data": {
"功能说明": "新威电池测试系统提供720通道监控和CSV批量提交功能",
"监控功能": "支持720个通道的实时状态监控、2盘电池物料管理、状态导出等",
"提交功能": "通过submit_from_csv action从CSV文件批量提交测试任务NDA备份或通过submit_from_csv_export_excel action提交并备份为Excel格式。CSV必须包含: Battery_Code, Pole_Weight, 集流体质量, 活性物质含量, 克容量mah/g, 电池体系, 设备号, 排号, 通道号"
"提交功能": "通过submit_from_csv action从CSV文件批量提交测试任务。CSV必须包含: Battery_Code, Pole_Weight, 集流体质量, 活性物质含量, 克容量mah/g, 电池体系, 设备号, 排号, 通道号"
},
"children": []
}
],
"links": []
}
}

View File

@@ -1,56 +0,0 @@
import socket
END_MARKS = [b"\r\n#\r\n", b"</bts>"] # 读到任一标志即可判定完整响应
def build_start_command(devid, subdevid, chlid, CoinID,
ip_in_xml="127.0.0.1",
devtype:int=27,
recipe_path:str=f"D:\\HHM_test\\A001.xml",
backup_dir:str=f"D:\\HHM_test\\backup",
filetype:int=1) -> str:
"""
filetype: 备份文件类型。0=NDA新威原生1=Excel。默认 1。
"""
lines = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<bts version="1.0">',
' <cmd>start</cmd>',
' <list count="1">',
f' <start ip="{ip_in_xml}" devtype="{devtype}" devid="{devid}" subdevid="{subdevid}" chlid="{chlid}" barcode="{CoinID}">{recipe_path}</start>',
f' <backup backupdir="{backup_dir}" remotedir="" filenametype="1" customfilename="" createdirbydate="0" filetype="{int(filetype)}" backupontime="1" backupontimeinterval="1" backupfree="0" />',
' </list>',
'</bts>',
]
# TCP 模式:请求必须以 #\r\n 结束(协议要求)
return "\r\n".join(lines) + "\r\n#\r\n"
def recv_until_marks(sock: socket.socket, timeout=60):
sock.settimeout(timeout) # 上限给足,协议允许到 30s:contentReference[oaicite:2]{index=2}
buf = bytearray()
while True:
chunk = sock.recv(8192)
if not chunk:
break
buf += chunk
# 读到结束标志就停,避免等对端断开
for m in END_MARKS:
if m in buf:
return bytes(buf)
# 保险:读到完整 XML 结束标签也停
if b"</bts>" in buf:
return bytes(buf)
return bytes(buf)
def start_test(ip="127.0.0.1", port=502, devid=3, subdevid=2, chlid=1, CoinID="A001", recipe_path=f"D:\\HHM_test\\A001.xml", backup_dir=f"D:\\HHM_test\\backup", filetype:int=1):
"""
filetype: 备份文件类型0=NDA1=Excel。默认 1。
"""
xml_cmd = build_start_command(devid=devid, subdevid=subdevid, chlid=chlid, CoinID=CoinID, recipe_path=recipe_path, backup_dir=backup_dir, filetype=filetype)
#print(xml_cmd)
with socket.create_connection((ip, port), timeout=60) as s:
s.sendall(xml_cmd.encode("utf-8"))
data = recv_until_marks(s, timeout=60)
return data.decode("utf-8", errors="replace")
if __name__ == "__main__":
resp = start_test(ip="127.0.0.1", port=502, devid=4, subdevid=10, chlid=1, CoinID="A001", recipe_path=f"D:\\HHM_test\\A001.xml", backup_dir=f"D:\\HHM_test\\backup")
print(resp)

View File

@@ -623,6 +623,119 @@ class ChinweDevice(UniversalDriver):
time.sleep(duration)
return True
def separation_step(self, motor_id: int = 5, speed: int = 60, pulses: int = 700,
max_cycles: int = 0, timeout: int = 300) -> bool:
"""
分液步骤 - 液位传感器与电机联动
当液位传感器检测到"有液"时,电机顺时针旋转指定脉冲数
当液位传感器检测到"无液"时,电机逆时针旋转指定脉冲数
:param motor_id: 电机ID (必须在初始化时配置的motor_ids中)
:param speed: 电机转速 (RPM)
:param pulses: 每次旋转的脉冲数 (默认700约为1/4圈,假设3200脉冲/圈)
:param max_cycles: 最大执行循环次数 (0=无限制,默认0)
:param timeout: 整体超时时间 (秒)
:return: 成功返回True,超时或失败返回False
"""
motor_id = int(motor_id)
speed = int(speed)
pulses = int(pulses)
max_cycles = int(max_cycles)
timeout = int(timeout)
# 检查电机是否存在
if motor_id not in self.motors:
self.logger.error(f"Motor {motor_id} not found in configured motors: {list(self.motors.keys())}")
return False
# 检查传感器是否可用
if not self.sensor:
self.logger.error("Sensor not initialized")
return False
motor = self.motors[motor_id]
# 停止轮询线程,避免与 separation_step 同时读取传感器造成串口冲突
self.logger.info("Stopping polling thread for separation_step...")
self._stop_event.set()
if self._poll_thread and self._poll_thread.is_alive():
self._poll_thread.join(timeout=2.0)
# 使能电机
self.logger.info(f"Enabling motor {motor_id}...")
motor.enable(True)
time.sleep(0.2)
self.logger.info(f"Starting separation step: motor_id={motor_id}, speed={speed} RPM, "
f"pulses={pulses}, max_cycles={max_cycles}, timeout={timeout}s")
# 记录上一次的液位状态
last_level = None
cycle_count = 0
start_time = time.time()
error_count = 0
try:
while True:
# 检查超时
if time.time() - start_time > timeout:
self.logger.warning(f"Separation step timeout after {timeout} seconds")
return False
# 检查循环次数限制
if max_cycles > 0 and cycle_count >= max_cycles:
self.logger.info(f"Separation step completed: reached max_cycles={max_cycles}")
return True
# 读取传感器数据
data = self.sensor.read_level()
if data is None:
error_count += 1
if error_count > 5:
self.logger.warning("Sensor read failed multiple times, retrying...")
error_count = 0
time.sleep(0.5)
continue
error_count = 0
current_level = data['level']
rssi = data['rssi']
# 检测状态变化 (包括首次检测)
if current_level != last_level:
cycle_count += 1
if current_level:
# 有液 -> 电机顺时针旋转
self.logger.info(f"[Cycle {cycle_count}] Liquid detected (RSSI={rssi}), "
f"rotating motor {motor_id} clockwise {pulses} pulses")
motor.run_position(pulses=pulses, speed_rpm=speed, direction=0, absolute=False)
# 等待电机完成 (预估时间)
estimated_time = 15.0 / max(1, speed)
time.sleep(estimated_time + 0.5)
else:
# 无液 -> 电机逆时针旋转
self.logger.info(f"[Cycle {cycle_count}] No liquid detected (RSSI={rssi}), "
f"rotating motor {motor_id} counter-clockwise {pulses} pulses")
motor.run_position(pulses=pulses, speed_rpm=speed, direction=1, absolute=False)
# 等待电机完成 (预估时间)
estimated_time = 15.0 / max(1, speed)
time.sleep(estimated_time + 0.5)
# 更新状态
last_level = current_level
# 轮询间隔
time.sleep(0.1)
finally:
# 恢复轮询线程
self.logger.info("Restarting polling thread...")
self._start_polling()
def execute_command_from_outer(self, command_dict: Dict[str, Any]) -> bool:
"""支持标准 JSON 指令调用"""
return super().execute_command_from_outer(command_dict)

View File

@@ -0,0 +1,379 @@
# -*- coding: utf-8 -*-
"""
XKC RS485 液位传感器 (Modbus RTU)
说明:
1. 遵循 Modbus-RTU 协议。
2. 数据寄存器: 0x0001 (液位状态, 1=有液, 0=无液), 0x0002 (RSSI 信号强度)。
3. 地址寄存器: 0x0004 (可读写, 范围 1-254)。
4. 波特率寄存器: 0x0005 (可写, 代码表见 change_baudrate 方法)。
"""
import struct
import threading
import time
import logging
import serial
from typing import Optional, Dict, Any, List
from unilabos.device_comms.universal_driver import UniversalDriver
class TransportManager:
"""
统一通信管理类。
仅支持 串口 (Serial/有线) 连接。
"""
def __init__(self, port: str, baudrate: int = 9600, timeout: float = 3.0, logger=None):
self.port = port
self.baudrate = baudrate
self.timeout = timeout
self.logger = logger
self.lock = threading.RLock() # 线程锁,确保多设备共用一个连接时不冲突
self.serial = None
self._connect_serial()
def _connect_serial(self):
try:
self.serial = serial.Serial(
port=self.port,
baudrate=self.baudrate,
timeout=self.timeout
)
except Exception as e:
raise ConnectionError(f"Serial open failed: {e}")
def close(self):
"""关闭连接"""
if self.serial and self.serial.is_open:
self.serial.close()
def clear_buffer(self):
"""清空缓冲区 (Thread-safe)"""
with self.lock:
if self.serial:
self.serial.reset_input_buffer()
def write(self, data: bytes):
"""发送原始字节"""
with self.lock:
if self.serial:
self.serial.write(data)
def read(self, size: int) -> bytes:
"""读取指定长度字节"""
if self.serial:
return self.serial.read(size)
return b''
class XKCSensorDriver(UniversalDriver):
"""XKC RS485 液位传感器 (Modbus RTU)"""
def __init__(self, port: str, baudrate: int = 9600, device_id: int = 6,
threshold: int = 300, timeout: float = 3.0, debug: bool = False):
super().__init__()
self.port = port
self.baudrate = baudrate
self.device_id = device_id
self.threshold = threshold
self.timeout = timeout
self.debug = debug
self.level = False
self.rssi = 0
self.status = {"level": self.level, "rssi": self.rssi}
try:
self.transport = TransportManager(port, baudrate, timeout, logger=self.logger)
self.logger.info(f"XKCSensorDriver connected to {port} (ID: {device_id})")
except Exception as e:
self.logger.error(f"Failed to connect XKCSensorDriver: {e}")
self.transport = None
# 启动背景轮询线程,确保 status 实时刷新
self._stop_event = threading.Event()
self._polling_thread = threading.Thread(
target=self._update_loop,
name=f"XKCPolling_{port}",
daemon=True
)
if self.transport:
self._polling_thread.start()
def _update_loop(self):
"""背景循环读取传感器数据"""
while not self._stop_event.is_set():
try:
self.read_level()
except Exception as e:
if self.debug:
self.logger.error(f"Polling error: {e}")
time.sleep(2.0) # 每2秒刷新一次数据
def _crc(self, data: bytes) -> bytes:
crc = 0xFFFF
for byte in data:
crc ^= byte
for _ in range(8):
if crc & 0x0001: crc = (crc >> 1) ^ 0xA001
else: crc >>= 1
return struct.pack('<H', crc)
def read_level(self) -> Optional[Dict[str, Any]]:
"""
读取液位。
返回: {'level': bool, 'rssi': int}
"""
if not self.transport:
return None
with self.transport.lock:
self.transport.clear_buffer()
# Modbus Read Registers: 01 03 00 01 00 02 CRC
payload = struct.pack('>HH', 0x0001, 0x0002)
msg = struct.pack('BB', self.device_id, 0x03) + payload
msg += self._crc(msg)
if self.debug:
self.logger.info(f"TX (ID {self.device_id}): {msg.hex().upper()}")
self.transport.write(msg)
# Read header
h = self.transport.read(3) # Addr, Func, Len
if self.debug:
self.logger.info(f"RX Header: {h.hex().upper()}")
if len(h) < 3: return None
length = h[2]
# Read body + CRC
body = self.transport.read(length + 2)
if self.debug:
self.logger.info(f"RX Body+CRC: {body.hex().upper()}")
if len(body) < length + 2:
# Firmware bug fix specific to some modules
if len(body) == 4 and length == 4:
pass
else:
return None
data = body[:-2]
# 根据手册说明:
# 寄存器 0x0001 (data[0:2]): 液位状态 (00 01 为有液, 00 00 为无液)
# 寄存器 0x0002 (data[2:4]): 信号强度 RSSI
hw_level = False
rssi = 0
if len(data) >= 4:
hw_level = ((data[0] << 8) | data[1]) == 1
rssi = (data[2] << 8) | data[3]
elif len(data) == 2:
# 兼容模式: 某些老固件可能只返回 1 个寄存器
rssi = (data[0] << 8) | data[1]
hw_level = rssi > self.threshold
else:
return None
# 最终判定: 优先使用硬件层级的 level 判定,但 RSSI 阈值逻辑作为补充/校验
# 注意: 如果用户显式设置了 THRESHOLD我们可以在逻辑中做权衡
self.level = hw_level or (rssi > self.threshold)
self.rssi = rssi
result = {
'level': self.level,
'rssi': self.rssi
}
self.status = result
return result
def wait_level(self, target_state: bool, timeout: float = 60.0) -> bool:
"""
等待液位达到目标状态 (阻塞式)
"""
self.logger.info(f"Waiting for level: {target_state}")
start_time = time.time()
while (time.time() - start_time) < timeout:
res = self.read_level()
if res and res.get('level') == target_state:
return True
time.sleep(0.5)
self.logger.warning(f"Wait level timeout ({timeout}s)")
return False
def wait_for_liquid(self, target_state: bool, timeout: float = 120.0) -> bool:
"""
实时检测电导率(RSSI)并等待用户指定的“有液”或“无液”状态。
一旦检测到符合目标状态,立即返回。
Args:
target_state: True 为“有液”, False 为“无液”
timeout: 最大等待时间(秒)
"""
state_str = "有液" if target_state else "无液"
self.logger.info(f"开始实时检测电导率,等待状态: {state_str} (超时: {timeout}s)")
start_time = time.time()
while (time.time() - start_time) < timeout:
res = self.read_level() # 内部已更新 self.level 和 self.rssi
if res:
current_level = res.get('level')
current_rssi = res.get('rssi')
if current_level == target_state:
self.logger.info(f"✅ 检测到目标状态: {state_str} (当前电导率/RSSI: {current_rssi})")
return True
if self.debug:
self.logger.debug(f"当前状态: {'有液' if current_level else '无液'}, RSSI: {current_rssi}")
time.sleep(0.2) # 高频采样
self.logger.warning(f"❌ 等待 {state_str} 状态超时 ({timeout}s)")
return False
def set_threshold(self, threshold: int):
"""设置液位判定阈值"""
self.threshold = int(threshold)
self.logger.info(f"Threshold updated to: {self.threshold}")
def change_device_id(self, new_id: int) -> bool:
"""
修改设备的 Modbus 从站地址。
寄存器: 0x0004, 功能码: 0x06
"""
if not (1 <= new_id <= 254):
self.logger.error(f"Invalid device ID: {new_id}. Must be 1-254.")
return False
self.logger.info(f"Changing device ID from {self.device_id} to {new_id}")
success = self._write_single_register(0x0004, new_id)
if success:
self.device_id = new_id # 更新内存中的地址
self.logger.info(f"Device ID update command sent successfully (target {new_id}).")
return success
def change_baudrate(self, baud_code: int) -> bool:
"""
更改通讯波特率 (寄存器: 0x0005)。
设置成功后传感器 LED 会闪烁,通常无数据返回。
波特率代码对照表 (16进制):
05: 2400
06: 4800
07: 9600 (默认)
08: 14400
09: 19200
0A: 28800
0C: 57600
0D: 115200
0E: 128000
0F: 256000
"""
self.logger.info(f"Sending baudrate change command (Code: {baud_code:02X})")
# 写入寄存器 0x0005
self._write_single_register(0x0005, baud_code)
self.logger.info("Baudrate change command executed. Device LED should flash. Please update connection settings.")
return True
def factory_reset(self) -> bool:
"""
恢复出厂设置 (通过广播地址 FF)。
设置地址为 01逻辑为向 0x0004 写入 0x0002
"""
self.logger.info("Sending factory reset command via broadcast address FF...")
# 广播指令通常无回显
self._write_single_register(0x0004, 0x0002, slave_id=0xFF)
self.logger.info("Factory reset command sent. Device address should be 01 now.")
return True
def _write_single_register(self, reg_addr: int, value: int, slave_id: Optional[int] = None) -> bool:
"""内部辅助函数: Modbus 功能码 06 写单个寄存器"""
if not self.transport: return False
target_id = slave_id if slave_id is not None else self.device_id
msg = struct.pack('BBHH', target_id, 0x06, reg_addr, value)
msg += self._crc(msg)
with self.transport.lock:
self.transport.clear_buffer()
if self.debug:
self.logger.info(f"TX Write (Reg {reg_addr:#06x}): {msg.hex().upper()}")
self.transport.write(msg)
# 广播地址、波特率修改或厂家特定指令可能无回显
if target_id == 0xFF or reg_addr == 0x0005:
time.sleep(0.5)
return True
# 等待返回 (正常应返回相同报文)
resp = self.transport.read(len(msg))
if self.debug:
self.logger.info(f"RX Write Response: {resp.hex().upper()}")
return resp == msg
def close(self):
if self.transport:
self.transport.close()
if __name__ == "__main__":
# 快速实例化测试
import logging
# 减少冗余日志,仅显示重要信息
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
# 硬件配置 (根据实际情况修改)
TEST_PORT = "/dev/tty.usbserial-3110"
SLAVE_ID = 1
THRESHOLD = 300
print("\n" + "="*50)
print(f" XKC RS485 传感器独立测试程序")
print(f" 端口: {TEST_PORT} | 地址: {SLAVE_ID} | 阈值: {THRESHOLD}")
print("="*50)
sensor = XKCSensorDriver(port=TEST_PORT, device_id=SLAVE_ID, threshold=THRESHOLD, debug=False)
try:
if sensor.transport:
print(f"\n开始实时连续采样测试 (持续 15 秒)...")
print(f"按 Ctrl+C 可提前停止\n")
start_time = time.time()
duration = 15
count = 0
while time.time() - start_time < duration:
count += 1
res = sensor.read_level()
if res:
rssi = res['rssi']
level = res['level']
status_str = "【有液】" if level else "【无液】"
# 使用 \r 实现单行刷新显示 (或者不刷,直接打印历史)
# 为了方便查看变化,我们直接打印
elapsed = time.time() - start_time
print(f" [{elapsed:4.1f}s] 采样 {count:<3}: 电导率/RSSI = {rssi:<5} | 判定结果: {status_str}")
else:
print(f" [{time.time()-start_time:4.1f}s] 采样 {count:<3}: 通信失败 (无响应)")
time.sleep(0.5) # 每秒采样 2 次
print(f"\n--- 15 秒采样测试完成 (总计 {count} 次) ---")
# [3] 测试动态修改阈值
print(f"\n[3] 动态修改阈值演示...")
new_threshold = 400
sensor.set_threshold(new_threshold)
res = sensor.read_level()
if res:
print(f" 采样 (当前阈值={new_threshold}): 电导率/RSSI = {res['rssi']:<5} | 判定结果: {'【有液】' if res['level'] else '【无液】'}")
sensor.set_threshold(THRESHOLD) # 还原
except KeyboardInterrupt:
print("\n[!] 用户中断测试")
except Exception as e:
print(f"\n[!] 测试运行出错: {e}")
finally:
sensor.close()
print("\n--- 测试程序已退出 ---\n")

View File

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

View File

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

View File

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

View File

@@ -760,9 +760,10 @@ class BioyondWorkstation(WorkstationBase):
except:
pass
# 创建通信模块;同步器将在 post_init 中初始化并执行首次同步
# 创建通信模块
self._create_communication_module(bioyond_config)
self.resource_synchronizer = None
self.resource_synchronizer = BioyondResourceSynchronizer(self)
self.resource_synchronizer.sync_from_external()
# TODO: self._ros_node里面拿属性
@@ -801,15 +802,6 @@ class BioyondWorkstation(WorkstationBase):
def post_init(self, ros_node: ROS2WorkstationNode):
self._ros_node = ros_node
# Deck 为空时(反序列化未恢复子节点),主动调用 setup() 初始化仓库
if self.deck and not self.deck.children and hasattr(self.deck, "setup") and callable(self.deck.setup):
logger.info("Deck 无仓库子节点,调用 setup() 初始化仓库")
self.deck.setup()
# 初始化同步器并执行首次同步(需在仓库初始化之后)
self.resource_synchronizer = BioyondResourceSynchronizer(self)
self.resource_synchronizer.sync_from_external()
# 启动连接监控
try:
self.connection_monitor = ConnectionMonitor(self)

View File

@@ -1,219 +0,0 @@
# 代码变更说明 — 2026-03-12
> 本次变更基于 `implementation_plan_v2.md` 执行,目标:**物理几何结构初始化与物料内容物填充彻底解耦**,消除 PLR 反序列化时的 `Resource already assigned to deck` 错误,并修复若干运行时新增问题。
---
## 一、物料系统标准化重构(主线任务)
### 1. `unilabos/resources/battery/magazine.py`
**改动**`MagazineHolder_6_Cathode``MagazineHolder_6_Anode``MagazineHolder_4_Cathode` 三个工厂函数的 `klasses` 参数改为 `None`
**原因**:原来三个工厂函数在初始化时就向洞位填满极片对象(`ElectrodeSheet`),导致 PLR 反序列化时"几何结构已创建子节点 + DB 再次 assign"双重冲突。
**原则**:物料余量改由寄存器直读(阶段 F资源树不再追踪每个极片实体。`MagazineHolder_6_Battery` 原本就是 `klasses=None`,三者现在保持一致。
---
### 2. `unilabos/resources/battery/magazine.py`(追加,响应重复 UUID 问题)
**改动**:为 `Magazine`(洞位类)新增 `serialize``deserialize` 重写:
- `serialize`:序列化时强制将 `children` 置空,不再把极片写回数据库。
- `deserialize`:反序列化时强制忽略 `children` 字段,阻止数据库中旧极片记录被恢复。
**原因**:数据库中遗留有旧的 `ElectrodeSheet` 记录(`A1_sheet100` 等),启动时被 PLR 反序列化进来,导致同一 UUID 出现在多个 Magazine 洞位中,触发 `发现重复的uuid` 错误。此修复从源头截断旧数据,经过一次完整的"启动 → 资源树写回"后,数据库旧极片记录也会被干净覆盖。
---
### 3. `unilabos/resources/battery/bottle_carriers.py`
**改动**:删除 `YIHUA_Electrolyte_12VialCarrier` 末尾的 12 瓶填充循环及对应 `import`
**原因**`bottle_rack_6x2``bottle_rack_6x2_2` 应初始化为空载架,瓶子由 Bioyond 侧实际转运后再填入。原来初始化时直接塞满 `YB_pei_ye_xiao_Bottle`,反序列化时产生重复 assign。
---
### 4. `unilabos/resources/bioyond/decks.py`
**改动**
-`BIOYOND_YB_Deck` 重命名为 `BioyondElectrolyteDeck`,保留 `BIOYOND_YB_Deck` 作为向后兼容别名。
- 工厂函数 `YB_Deck()` 重命名为 `bioyond_electrolyte_deck()`,保留 `YB_Deck` 作为别名。
- `BIOYOND_PolymerReactionStation_Deck``BIOYOND_PolymerPreparationStation_Deck``BioyondElectrolyteDeck` 三个 Deck 类:
- 移除 `__init__` 中的 `setup: bool = False` 参数及 `if setup: self.setup()` 调用。
- 删除临时 `deserialize` 补丁(该补丁是为了强制 `setup=False`,根本原因消除后不再需要)。
**原因**`setup` 参数导致 PLR 反序列化时先通过 `__init__` 创建所有子资源,再从 JSON `children` 字段再次 assign产生 `already assigned to deck` 错误。正确模式:`__init__` 只初始化自身几何,`setup()` 由工厂函数调用,反序列化由 PLR 从 DB 数据重建子资源。
---
### 5. `unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py`
**改动**
- `CoincellDeck` 重命名为 `YihuaCoinCellDeck`,保留 `CoincellDeck` 作为向后兼容别名。
- 工厂函数 `YH_Deck()` 重命名为 `yihua_coin_cell_deck()`,保留 `YH_Deck` 作为别名。
- 移除 `YihuaCoinCellDeck.__init__` 中的 `setup: bool = False` 参数及调用,删除 `deserialize` 补丁(原因同 decks.py
- `MaterialPlate.__init__` 移除 `fill` 参数和 `fill=True` 分支,新增类方法 `MaterialPlate.create_with_holes()` 作为"带洞位"的工厂方法,`setup()` 改为调用该工厂方法。
- `YihuaCoinCellDeck.setup()` 末尾新增 `electrolyte_buffer``ResourceStack`)接驳槽,用于接收来自 Bioyond 侧的分液瓶板,命名与 `bioyond_cell_workstation.py``sites=["electrolyte_buffer"]` 一致。
---
### 6. `unilabos/resources/resource_tracker.py`
**改动 1**`to_plr_resources` 中,`load_all_state` 调用前预填 `Container` 类资源缺失的键:
```python
state.setdefault("liquid_history", [])
state.setdefault("pending_liquids", {})
```
**原因**:新版 PLR 要求 `Container` 状态中必须包含这两个键,旧数据库记录缺失时 `load_all_state` 会抛出 `KeyError`
**改动 2**`_validate_tree` 中,遇到重复 UUID 时改为自动重新分配新 UUID 并打 `WARNING`,不再直接抛异常崩溃。
**原因**:旧数据库中存在多个同名同 UUID 的极片对象(历史脏数据),严格校验会导致节点无法启动。改为 WARNING + 自动修复,确保启动成功,下次资源树写回后脏数据自然清除。
---
### 7. `unilabos/resources/itemized_carrier.py`
**改动**:将原来的 `idx is None` 兜底补丁(静默调用 `super().assign_child_resource`,不更新槽位追踪)替换为两段式逻辑:
1. **XY 近似匹配**(容差 2mm精确三维坐标匹配失败时仅对比 XY 二维坐标,找到最近槽位后用槽位的正确坐标(含 Z完成 assign并打 `WARNING`
2. **XY 也失败才抛异常**:给出详细的槽位列表和传入坐标,便于问题排查。
**原因**:数据库中存储的资源坐标 Z=0`warehouse_factory` 定义的槽位 Z=dz如 10mm。精确匹配永远失败原补丁静默兜底掩盖了这一问题。近似匹配修复了 Z 偏移,同时保留了真正异常时的报错能力。
---
### 8. `unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py`
**改动 1**:更新导入:`BIOYOND_YB_Deck``BioyondElectrolyteDeck, bioyond_electrolyte_deck`
**改动 2**`__main__` 入口处改为调用 `bioyond_electrolyte_deck(name="YB_Deck")`
**改动 3**:新增 `_get_resource_from_device(device_id, resource_name)` 方法,用于从目标设备的资源树中动态查找 PLR 资源对象(带降级回退逻辑)。
**改动 4**:跨站转运逻辑中,将原来"创建 `size=1,1,1` 的虚拟 `ResourcePLR` + 硬编码 UUID"的方式,改为通过 `_get_resource_from_device` 从目标设备获取真实的 `electrolyte_buffer` 资源对象。
**原因**:原代码使用硬编码 UUID 的虚拟资源作为转运目标,该对象在 YihuaCoinCellDeck 的资源树中不存在,转移后资源树状态混乱。
---
### 9. `unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py`
**改动 1**:更新导入:`CoincellDeck``YihuaCoinCellDeck, yihua_coin_cell_deck``__main__` 入口改为调用 `yihua_coin_cell_deck()`
**改动 2**:新增 10 个 `@property`,实现对依华扣电工站 Modbus 寄存器的直读:
| 属性名 | 寄存器地址 | 说明 |
|---|---|---|
| `data_10mm_positive_plate_remaining` | 520 | 10mm正极片余量 |
| `data_12mm_positive_plate_remaining` | 522 | 12mm正极片余量 |
| `data_16mm_positive_plate_remaining` | 524 | 16mm正极片余量 |
| `data_aluminum_foil_remaining` | 526 | 铝箔余量 |
| `data_positive_shell_remaining` | 528 | 正极壳余量 |
| `data_flat_washer_remaining` | 530 | 平垫余量 |
| `data_negative_shell_remaining` | 532 | 负极壳余量 |
| `data_spring_washer_remaining` | 534 | 弹垫余量 |
| `data_finished_battery_remaining_capacity` | 536 | 成品电池余量 |
| `data_finished_battery_ng_remaining_capacity` | 538 | 成品电池NG槽余量 |
**原因**`coin_cell_workstation.yaml``status_types` 中定义了这 10 个属性,但代码中从未实现,导致每次前端轮询时均报 `AttributeError`
---
## 二、配置与注册表更新
### 10. `yibin_electrolyte_config.json`
- `BIOYOND_YB_Deck``BioyondElectrolyteDeck`class、type、_resource_type 三处)
- `CoincellDeck``YihuaCoinCellDeck`class、type、_resource_type 三处)
- 移除 `"setup": true` 字段
### 11. `yibin_coin_cell_only_config.json`
- `CoincellDeck``YihuaCoinCellDeck`
- 移除 `"setup": true`
### 12. `yibin_electrolyte_only_config.json`
- `BIOYOND_YB_Deck``BioyondElectrolyteDeck`
- 移除 `"setup": true`
### 13. `unilabos/registry/resources/bioyond/deck.yaml`
- `BIOYOND_YB_Deck``BioyondElectrolyteDeck`,工厂函数路径更新为 `bioyond_electrolyte_deck`
- `CoincellDeck``YihuaCoinCellDeck`,工厂函数路径更新为 `yihua_coin_cell_deck`
---
## 三、独立 Bug 修复
### 14. `unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly_b.csv`
**改动**10 条余量寄存器记录的 `DataType` 列从 `REAL` 改为 `FLOAT32`
**原因**`REAL` 是 IEC 61131-3 PLC 工程师惯用名称,但 pymodbus 的 `DATATYPE` 枚举只有 `FLOAT32``DataType['REAL']` 查表时抛 `KeyError: 'REAL'`,导致 `CoinCellAssemblyWorkstation` 节点启动失败。
---
## 四、运行期新增 Bug 修复第二轮2026-03-12 18:12 日志)
### 15. `unilabos/devices/workstation/bioyond_studio/station.py`
**改动**:第 261 行 `self.bioyond_config``self.workstation.bioyond_config`
**原因**`BioyondResourceSynchronizer.sync_to_external` 内部误用了 `self.bioyond_config`,而该类从未设置此属性(应通过 `self.workstation.bioyond_config` 访问)。触发场景:用户在前端将任意物料拖入仓库时,同步到 Bioyond 必定抛出 `AttributeError: 'BioyondResourceSynchronizer' object has no attribute 'bioyond_config'`
---
### 16. `unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py`
**改动**`_get_type_id_by_name` 方法新增"直接英文 key 命中"分支:
- **原逻辑**:仅按 `value[0]`(中文名,如 `"5ml分液瓶板"`)遍历比较。
- **新逻辑**:先以 `type_name` 直接查找 `material_type_mappings` 字典 key英文 model 名,如 `"YB_Vial_5mL_Carrier"`),命中则立即返回 UUID否则再按中文名兜底遍历。
**原因**`resource_tree_transfer``plr_resource.model`(英文 key作为 `board_type` / `bottle_type` 传给 `create_sample`,后者再调用 `_get_type_id_by_name`。旧版函数只按中文名查,导致英文 key 永远匹配不到 → `ValueError: 未找到板类型 'YB_Vial_5mL_Carrier' 的配置`。新函数兼容两种查找方式,同时保持向后兼容。
---
## 五、运行期新增 Bug 修复第三轮2026-03-12 20:30 日志)
### 17. `unilabos/resources/resource_tracker.py`(追加)
**改动**:在 `to_plr_resources` 中,`sub_cls.deserialize` 调用前新增 `_deduplicate_plr_dict(plr_dict)` 预处理函数。
**函数逻辑**:递归遍历整个 `plr_dict` 树,在**全树范围**对 `children` 列表按 `name` 去重——保留首次出现的同名节点,跳过重复项并打 `WARNING`
**根本原因**
1. 用户通过前端将 `YB_Vial_5mL_Carrier` 拖入仓库 E01carrier 及其子 vial`YB_Vial_5mL_Carrier_vial_A1` 等)被写入数据库。
2. 随后 `sync_from_external`Bioyond 定期同步)以**新 UUID** 重新创建同名 carrier 并赋给同一槽位PLR 内存树中的旧 carrier 被替换,但**数据库旧记录未被清除**。
3. 下次重启时,数据库同一 `WareHouse` 下存在两条同名 `BottleCarrier`(不同 UUID`node_to_plr_dict` 将二者都放入 `children` 列表PLR 反序列化第二个 carrier 时子 vial 命名冲突,抛出 `ValueError: Resource with name 'YB_Vial_5mL_Carrier_vial_A1' already exists in the tree.`,整个 deck 无法加载,系统启动失败。
**连锁错误(随根因修复自动消除)**
- `TypeError: Deck.__init__() got an unexpected keyword argument 'data'` — deck 加载失败后 `driver_creator.py` 触发降级路径,参数类型错误
- `AttributeError: 'ResourceDictInstance' object has no attribute 'copy'` — 另一条降级路径失败
- `ValueError: Deck 配置不能为空` — 所有 deck 创建路径失败,`deck=None` 传入工作站
---
> **验证状态**2026-03-12 20:56 日志确认系统正常运行,无新增 ERROR 级错误。
---
## 六、变更文件汇总(最终)
| 文件 | 变更类型 | 轮次 |
|---|---|---|
| `resources/battery/magazine.py` | 重构 + Bug 修复(极片子节点解耦 + 旧数据清理) | 第一轮 |
| `resources/battery/bottle_carriers.py` | 重构(移除初始化时自动填瓶) | 第一轮 |
| `resources/bioyond/decks.py` | 重构 + 重命名BioyondElectrolyteDeck | 第一轮 |
| `devices/workstation/coin_cell_assembly/YB_YH_materials.py` | 重构 + 重命名YihuaCoinCellDeck+ 新增 electrolyte_buffer 槽位 | 第一轮 |
| `resources/resource_tracker.py` | Bug 修复 × 3Container 状态键预填 + 重复 UUID 自动修复 + 树级名称去重) | 第一/三轮 |
| `resources/itemized_carrier.py` | Bug 修复XY 近似坐标匹配,修复 Z 偏移) | 第一轮 |
| `devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py` | 重构 + Bug 修复(跨站转运 + 类型映射双模式查找) | 第一/二轮 |
| `devices/workstation/bioyond_studio/station.py` | Bug 修复sync_to_external 属性访问路径) | 第二轮 |
| `devices/workstation/coin_cell_assembly/coin_cell_assembly.py` | 新增 10 个 Modbus 余量属性 + 更新导入 | 第一轮 |
| `yibin_electrolyte_config.json` | 配置更新(类名 + 移除 setup | 第一轮 |
| `yibin_coin_cell_only_config.json` | 配置更新(类名 + 移除 setup | 第一轮 |
| `yibin_electrolyte_only_config.json` | 配置更新(类名 + 移除 setup | 第一轮 |
| `registry/resources/bioyond/deck.yaml` | 注册表更新(类名 + 工厂函数路径) | 第一轮 |
| `devices/workstation/coin_cell_assembly/coin_cell_assembly_b.csv` | Bug 修复REAL → FLOAT32 | 第一轮 |

View File

@@ -130,14 +130,20 @@ class MaterialPlate(ItemizedResource[MaterialHole]):
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: 型号
"""
@@ -147,50 +153,42 @@ class MaterialPlate(ItemizedResource[MaterialHole]):
hole_diameter=20.0,
info="",
)
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,
)
@classmethod
def create_with_holes(
cls,
name: str,
size_x: float,
size_y: float,
size_z: float,
category: str = "material_plate",
model: Optional[str] = None,
) -> "MaterialPlate":
"""工厂方法:创建带 4x4 洞位的料板(仅用于初始 setup不在反序列化路径调用"""
# 默认洞位间距(与 _unilabos_state 默认值保持一致)
hole_spacing_x = 24.0
hole_spacing_y = 24.0
# 先建洞位,再作为 ordered_items 传入构造函数
# ItemizedResource.__init__ 要求 ordered_items 或 ordering 二选一必须有值)
# 创建4x4的洞位
# TODO: 这里要改,对应不同形状
holes = create_ordered_items_2d(
klass=MaterialHole,
num_items_x=4,
num_items_y=4,
dx=(size_x - 4 * hole_spacing_x) / 2,
dy=(size_y - 4 * hole_spacing_y) / 2,
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=hole_spacing_x,
item_dy=hole_spacing_y,
size_x=16,
size_y=16,
size_z=16,
)
return cls(
name=name, size_x=size_x, size_y=size_y, size_z=size_z,
ordered_items=holes, category=category, model=model,
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:调多次相加
@@ -536,19 +534,30 @@ class WasteTipBox(Trash):
return data
class YihuaCoinCellDeck(Deck):
"""依华纽扣电池组装工作站台面类"""
class CoincellDeck(Deck):
"""纽扣电池组装工作站台面类"""
def __init__(
self,
name: str = "coin_cell_deck",
size_x: float = 1450.0,
size_y: float = 1450.0,
size_z: float = 100.0,
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: 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,
@@ -582,11 +591,14 @@ class YihuaCoinCellDeck(Deck):
# ====================================== 物料板 ============================================
# 创建物料板料盘carrier- 4x4布局
# 负极料盘
fujiliaopan = MaterialPlate.create_with_holes(name="负极料盘", size_x=120, size_y=100, size_z=10.0)
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.create_with_holes(name="隔膜料盘", size_x=120, size_y=100, size_z=10.0)
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)
@@ -621,27 +633,11 @@ class YihuaCoinCellDeck(Deck):
waste_tip_box = WasteTipBox(name="waste_tip_box")
self.assign_child_resource(waste_tip_box, Coordinate(x=778.0, y=622.0, z=0))
# 分液瓶板接驳区 - 接收来自 BioyondElectrolyte 侧的完整 Vial Carrier 板
# 命名 electrolyte_buffer 与 bioyond_cell_workstation.py 中 sites=["electrolyte_buffer"] 对应
electrolyte_buffer = ResourceStack(
name="electrolyte_buffer",
direction="z",
resources=[],
)
self.assign_child_resource(electrolyte_buffer, Coordinate(x=1050.0, y=700.0, z=0))
def yihua_coin_cell_deck(name: str = "coin_cell_deck") -> YihuaCoinCellDeck:
deck = YihuaCoinCellDeck(name=name)
deck.setup()
return deck
# 向后兼容别名,日后废弃
CoincellDeck = YihuaCoinCellDeck
def YH_Deck(name: str = "") -> YihuaCoinCellDeck:
return yihua_coin_cell_deck(name=name or "coin_cell_deck")
def YH_Deck(name=""):
cd = CoincellDeck(name=name)
cd.setup()
return cd
if __name__ == "__main__":

View File

@@ -6,7 +6,7 @@ import threading
import time
import types
from datetime import datetime
from typing import Any, Dict, List, Optional
from typing import Any, Dict, Optional
from functools import wraps
from pylabrobot.resources import Deck, Resource as PLRResource
from unilabos_msgs.msg import Resource
@@ -17,7 +17,7 @@ from unilabos.device_comms.modbus_plc.modbus import DeviceType, Base as ModbusNo
from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import *
from unilabos.ros.nodes.base_device_node import ROS2DeviceNode, BaseROS2DeviceNode
from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import YihuaCoinCellDeck, yihua_coin_cell_deck
from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import CoincellDeck
from unilabos.resources.graphio import convert_resources_to_type
from unilabos.utils.log import logger
import struct
@@ -161,9 +161,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
logger.info("没有传入依华deck检查启动json文件")
super().__init__(deck=deck, *args, **kwargs,)
self.debug_mode = debug_mode
self._modbus_address = address
self._modbus_port = port
""" 连接初始化 """
modbus_client = TCPClient(addr=address, port=port)
logger.debug(f"创建 Modbus 客户端: {modbus_client}")
@@ -180,11 +178,9 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
raise ValueError('modbus tcp connection failed')
self.nodes = BaseClient.load_csv(os.path.join(os.path.dirname(__file__), 'coin_cell_assembly_b.csv'))
self.client = modbus_client.register_node_list(self.nodes)
self._modbus_client_raw = modbus_client
else:
print("测试模式,跳过连接")
self.nodes, self.client = None, None
self._modbus_client_raw = None
""" 工站的配置 """
@@ -195,40 +191,9 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
self.csv_export_file = None
self.coin_num_N = 0 #已组装电池数量
def _ensure_modbus_connected(self) -> None:
"""检查 Modbus TCP 连接是否存活,若已断开则自动重连(防止长时间空闲后连接超时)"""
if self.debug_mode or self._modbus_client_raw is None:
return
raw_client = self._modbus_client_raw.client
if raw_client.is_socket_open():
return
logger.warning("[Modbus] 检测到连接已断开,尝试重连...")
try:
raw_client.close()
except Exception:
pass
count = 10
while count > 0:
count -= 1
try:
raw_client.connect()
except Exception:
pass
if raw_client.is_socket_open():
break
time.sleep(2)
if not raw_client.is_socket_open():
raise RuntimeError(f"Modbus TCP 重连失败({self._modbus_address}:{self._modbus_port}),请检查设备连接")
logger.info("[Modbus] 重连成功")
def post_init(self, ros_node: ROS2WorkstationNode):
self._ros_node = ros_node
# Deck 为空时(反序列化未恢复子节点),主动调用 setup() 初始化子物料
if self.deck and not self.deck.children and hasattr(self.deck, "setup") and callable(self.deck.setup):
logger.info("YihuaCoinCellDeck 无子节点,调用 setup() 初始化")
self.deck.setup()
#self.deck = create_a_coin_cell_deck()
ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
"resources": [self.deck]
})
@@ -658,28 +623,12 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
return vol
@property
def data_coin_type(self) -> int:
"""电池类型 - 7种或8种组装物料 (INT16)"""
if self.debug_mode:
return 7
coin_type, read_err = self.client.use_node('REG_DATA_COIN_TYPE').read(1)
return coin_type
@property
def data_current_assembling_count(self) -> int:
"""当前进行组装的电池数量 - Current assembling battery count (INT16)"""
def data_coin_num(self) -> int:
"""当前电池数量 (INT16)"""
if self.debug_mode:
return 0
count, read_err = self.client.use_node('REG_DATA_CURRENT_ASSEMBLING_COUNT').read(1)
return count
@property
def data_current_completed_count(self) -> int:
"""当前完成组装的电池数量 - Current completed battery count (INT16)"""
if self.debug_mode:
return 0
count, read_err = self.client.use_node('REG_DATA_CURRENT_COMPLETED_COUNT').read(1)
return count
num, read_err = self.client.use_node('REG_DATA_COIN_NUM').read(1)
return num
@property
def data_coin_cell_code(self) -> str:
@@ -777,116 +726,6 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
return 0.0
return _decode_float32_correct(result.registers)
@property
def data_10mm_positive_plate_remaining(self) -> float:
"""10mm正极片剩余物料数量 (FLOAT32)"""
if self.debug_mode:
return 0.0
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_10MM_POSITIVE_PLATE_REMAINING_COUNT').address, count=2)
if result.isError():
logger.error("读取10mm正极片余量失败")
return 0.0
return _decode_float32_correct(result.registers)
@property
def data_12mm_positive_plate_remaining(self) -> float:
"""12mm正极片剩余物料数量 (FLOAT32)"""
if self.debug_mode:
return 0.0
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_12MM_POSITIVE_PLATE_REMAINING_COUNT').address, count=2)
if result.isError():
logger.error("读取12mm正极片余量失败")
return 0.0
return _decode_float32_correct(result.registers)
@property
def data_16mm_positive_plate_remaining(self) -> float:
"""16mm正极片剩余物料数量 (FLOAT32)"""
if self.debug_mode:
return 0.0
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_16MM_POSITIVE_PLATE_REMAINING_COUNT').address, count=2)
if result.isError():
logger.error("读取16mm正极片余量失败")
return 0.0
return _decode_float32_correct(result.registers)
@property
def data_aluminum_foil_remaining(self) -> float:
"""铝箔剩余物料数量 (FLOAT32)"""
if self.debug_mode:
return 0.0
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_ALUMINUM_FOIL_REMAINING_COUNT').address, count=2)
if result.isError():
logger.error("读取铝箔余量失败")
return 0.0
return _decode_float32_correct(result.registers)
@property
def data_positive_shell_remaining(self) -> float:
"""正极壳剩余物料数量 (FLOAT32)"""
if self.debug_mode:
return 0.0
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_POSITIVE_SHELL_REMAINING_COUNT').address, count=2)
if result.isError():
logger.error("读取正极壳余量失败")
return 0.0
return _decode_float32_correct(result.registers)
@property
def data_flat_washer_remaining(self) -> float:
"""平垫剩余物料数量 (FLOAT32)"""
if self.debug_mode:
return 0.0
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_FLAT_WASHER_REMAINING_COUNT').address, count=2)
if result.isError():
logger.error("读取平垫余量失败")
return 0.0
return _decode_float32_correct(result.registers)
@property
def data_negative_shell_remaining(self) -> float:
"""负极壳剩余物料数量 (FLOAT32)"""
if self.debug_mode:
return 0.0
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_NEGATIVE_SHELL_REMAINING_COUNT').address, count=2)
if result.isError():
logger.error("读取负极壳余量失败")
return 0.0
return _decode_float32_correct(result.registers)
@property
def data_spring_washer_remaining(self) -> float:
"""弹垫剩余物料数量 (FLOAT32)"""
if self.debug_mode:
return 0.0
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_SPRING_WASHER_REMAINING_COUNT').address, count=2)
if result.isError():
logger.error("读取弹垫余量失败")
return 0.0
return _decode_float32_correct(result.registers)
@property
def data_finished_battery_remaining_capacity(self) -> float:
"""成品电池剩余可容纳数量 (FLOAT32)"""
if self.debug_mode:
return 0.0
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_FINISHED_BATTERY_REMAINING_CAPACITY').address, count=2)
if result.isError():
logger.error("读取成品电池余量失败")
return 0.0
return _decode_float32_correct(result.registers)
@property
def data_finished_battery_ng_remaining_capacity(self) -> float:
"""成品电池NG槽剩余可容纳数量 (FLOAT32)"""
if self.debug_mode:
return 0.0
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_FINISHED_BATTERY_NG_REMAINING_CAPACITY').address, count=2)
if result.isError():
logger.error("读取成品电池NG槽余量失败")
return 0.0
return _decode_float32_correct(result.registers)
# @property
# def data_stack_vision_code(self) -> int:
# """物料堆叠复检图片编码 (INT16)"""
@@ -1086,7 +925,6 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
# 步骤0: 前置条件检查
logger.info("\n【步骤 0/4】前置条件检查...")
self._ensure_modbus_connected()
try:
# 检查 REG_UNILAB_INTERACT (应该为False表示使用Unilab交互)
unilab_interact_node = self.client.use_node('REG_UNILAB_INTERACT')
@@ -1147,42 +985,6 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
raise RuntimeError(error_msg)
logger.info(" ✓ COIL_GB_L_IGNORE_CMD 检查通过 (值为False使用左手套箱)")
# 检查握手寄存器残留正常初始状态均应为False
# 若上次运行意外断网这些Unilab侧COIL可能被遗留为True导致PLC逻辑卡死
handshake_checks = [
("COIL_UNILAB_SEND_MSG_SUCC_CMD", "Unilab→PLC 配方发送完毕", "上次配方握手未正常复位PLC可能处于等待配方的卡死状态"),
("COIL_UNILAB_REC_MSG_SUCC_CMD", "Unilab→PLC 数据接收完毕", "上次数据接收握手未正常复位"),
("UNILAB_SEND_ELECTROLYTE_BOTTLE_NUM", "Unilab→PLC 瓶数发送完毕", "上次瓶数握手未正常复位"),
("UNILAB_SEND_FINISHED_CMD", "Unilab→PLC 一组完成确认", "上次完成握手未正常复位"),
("COIL_REQUEST_REC_MSG_STATUS", "PLC→Unilab 请求接收配方", "PLC正处于等待配方状态设备流程已卡死需重启PLC或手动复位握手"),
("COIL_REQUEST_SEND_MSG_STATUS", "PLC→Unilab 请求发送测试数据", "PLC正处于等待发送数据状态设备流程已卡死"),
]
for coil_name, coil_desc, stuck_reason in handshake_checks:
try:
hs_node = self.client.use_node(coil_name)
hs_value, hs_err = hs_node.read(1)
if hs_err:
logger.warning(f" ⚠ 无法读取 {coil_name},跳过此项检查")
continue
hs_actual = hs_value[0] if isinstance(hs_value, (list, tuple)) else hs_value
logger.info(f" {coil_name} 当前值: {hs_actual}")
if hs_actual:
error_msg = (
"❌ 前置握手寄存器检查失败!\n"
f" {coil_name} = True (期望值: False)\n"
f" 含义: {coil_desc}\n"
f" 原因: {stuck_reason}\n"
" 建议: 检查上次运行是否意外中断手动将该寄存器置为False后重试"
)
logger.error(error_msg)
raise RuntimeError(error_msg)
logger.info(f"{coil_name} 检查通过 (值为False)")
except RuntimeError:
raise
except Exception as hs_e:
logger.warning(f" ⚠ 检查 {coil_name} 时发生异常: {hs_e},跳过此项")
logger.info("✓ 所有前置条件检查通过!")
except ValueError as e:
@@ -1356,8 +1158,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
lvbodian: bool = True,
battery_pressure_mode: bool = True,
battery_clean_ignore: bool = False,
file_path: str = "/Users/sml/work",
formulations: List[Dict] = None
file_path: str = "/Users/sml/work"
) -> Dict[str, Any]:
"""
发送瓶数+简化组装函数(适用于第二批次及后续批次)
@@ -1384,77 +1185,17 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
battery_pressure_mode: 是否启用压力模式
battery_clean_ignore: 是否忽略电池清洁
file_path: 实验记录保存路径
formulations: 配方信息列表(从 create_orders.mass_ratios 获取)
包含 orderCode, target_mass_ratio, real_mass_ratio 等
用于CSV数据追溯可选参数
Returns:
dict: 包含组装结果的字典
注意:
注意
- 第一次启动需先调用 func_pack_device_init_auto_start_combined()
- 后续批次直接调用此函数即可
"""
logger.info("=" * 60)
logger.info("开始发送瓶数+简化组装流程...")
logger.info(f"电解液瓶数: {elec_num}, 每瓶电池数: {elec_use_num}")
# 存储配方信息到设备状态(供 CSV 写入使用)
if formulations:
logger.info(f"接收到配方信息: {len(formulations)}")
# 将配方信息按 orderCode 索引,方便后续查找
self._formulations_map = {
f["orderCode"]: f for f in formulations
} if formulations else {}
# ✅ 新增:存储配方列表(按接收顺序),用于索引访问(兜底用)
self._formulations_list = formulations
# ✅ 新增按分液瓶条码vial_bottle_barcodes反向索引配方
# 配液站夹爪取放顺序与扣电站夹取顺序可能不同,所以不能再依赖位置序号,
# 必须用扣电站扫码得到的 data_electrolyte_code 去对齐配液站登记的瓶条码。
# vial_bottle_barcodes 字段可能形如 "LG100114"(单瓶)或 '["LG100114","LG100115"]'(多瓶)。
self._formulations_by_vial_barcode: Dict[str, Dict] = {}
for f in formulations:
raw_barcodes = f.get("vial_bottle_barcodes", "")
if not raw_barcodes:
continue
barcodes: List[str] = []
if isinstance(raw_barcodes, list):
barcodes = [str(b).strip() for b in raw_barcodes if b]
else:
s = str(raw_barcodes).strip()
if s.startswith("[") and s.endswith("]"):
try:
parsed = json.loads(s)
if isinstance(parsed, list):
barcodes = [str(b).strip() for b in parsed if b]
else:
barcodes = [str(parsed).strip()]
except Exception:
barcodes = [s]
else:
barcodes = [s]
for bc in barcodes:
if bc and bc not in self._formulations_by_vial_barcode:
self._formulations_by_vial_barcode[bc] = f
logger.info(
f"已建立分液瓶条码 → 配方索引: {len(self._formulations_by_vial_barcode)}"
f"(条码: {list(self._formulations_by_vial_barcode.keys())})"
)
else:
logger.warning("未接收到配方信息CSV将不包含配方字段")
self._formulations_map = {}
self._formulations_list = []
self._formulations_by_vial_barcode = {}
# ✅ 新增:存储每瓶电池数,用于计算当前使用的瓶号
# ⚠️ 确保转换为整数(前端可能传递字符串)
self._elec_use_num = int(elec_use_num) if elec_use_num else 0
logger.info(f"已存储参数: 每瓶电池数={self._elec_use_num}, 配方数={len(self._formulations_list)}")
# ✅ 新增:软件层电池计数器(防止硬件计数器不准确)
self._software_battery_counter = 0 # 从0开始每写入一次CSV递增
logger.info("软件层电池计数器已初始化")
logger.info("=" * 60)
# 步骤1: 发送电解液瓶数(触发物料搬运)
@@ -1590,8 +1331,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
data_assembly_time = self.data_assembly_time
data_assembly_pressure = self.data_assembly_pressure
data_electrolyte_volume = self.data_electrolyte_volume
data_coin_type = self.data_coin_type # 电池类型7或8种物料
data_battery_number = self.data_current_assembling_count # ✅ 真正的电池编号
data_coin_num = self.data_coin_num
# 处理电解液二维码 - 确保是字符串类型
try:
@@ -1621,32 +1361,28 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
logger.debug(f"data_assembly_time: {data_assembly_time}")
logger.debug(f"data_assembly_pressure: {data_assembly_pressure}")
logger.debug(f"data_electrolyte_volume: {data_electrolyte_volume}")
logger.debug(f"data_coin_type: {data_coin_type}") # 电池类型
logger.debug(f"data_battery_number: {data_battery_number}") # ✅ 电池编号
logger.debug(f"data_coin_num: {data_coin_num}")
logger.debug(f"data_electrolyte_code: {data_electrolyte_code}")
logger.debug(f"data_coin_cell_code: {data_coin_cell_code}")
#接收完信息后读取完毕标志位置True
finished_battery_magazine = self.deck.get_resource("成品弹夹")
# 计算电池应该放在哪个洞,以及洞内的堆叠位置
# 成品弹夹有6个洞每个洞可堆叠20颗电池
# 前5个洞索引0-4放正常电池第6个洞索引5放NG电池
BATTERIES_PER_HOLE = 20
MAX_NORMAL_BATTERIES = 100 # 5个洞 × 20颗/洞
hole_index = self.coin_num_N // BATTERIES_PER_HOLE # 第几个洞0-4为正常电池
in_hole_position = self.coin_num_N % BATTERIES_PER_HOLE # 洞内的堆叠序号
if hole_index >= 5:
logger.error(f"电池数量超出正常容量范围: {self.coin_num_N + 1} > {MAX_NORMAL_BATTERIES}")
raise ValueError(f"成品弹夹正常洞位已满(最多{MAX_NORMAL_BATTERIES}颗),当前尝试放置第{self.coin_num_N + 1}")
target_hole = finished_battery_magazine.children[hole_index] # 获取目标洞
liaopan3 = self.deck.get_resource("成品弹夹")
# 生成唯一的电池名称(使用时间戳确保唯一性)
timestamp_suffix = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
battery_name = f"battery_{self.coin_num_N}_{timestamp_suffix}"
# 检查目标位置是否已有资源,如果有则先卸载
target_slot = liaopan3.children[self.coin_num_N]
if target_slot.children:
logger.warning(f"位置 {self.coin_num_N} 已有资源,将先卸载旧资源")
try:
# 卸载所有现有子资源
for child in list(target_slot.children):
target_slot.unassign_child_resource(child)
logger.info(f"已卸载旧资源: {child.name}")
except Exception as e:
logger.error(f"卸载旧资源时出错: {e}")
# 创建新的电池资源
battery = ElectrodeSheet(name=battery_name, size_x=14, size_y=14, size_z=2)
battery._unilabos_state = {
@@ -1657,12 +1393,13 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
"electrolyte_volume": data_electrolyte_volume
}
# 将电池堆叠到目标洞中
# 分配新资源到目标位置
try:
target_hole.assign_child_resource(battery, location=None)
logger.info(f"成功放置电池 {battery_name}弹夹洞{hole_index}的第{in_hole_position + 1}层 (总计第{self.coin_num_N + 1}颗)")
target_slot.assign_child_resource(battery, location=None)
logger.info(f"成功分配电池 {battery_name}位置 {self.coin_num_N}")
except Exception as e:
logger.error(f"放置电池资源失败: {e}")
logger.error(f"分配电池资源失败: {e}")
# 如果分配失败,尝试使用更简单的方法
raise
#print(jipian2.parent)
@@ -1683,7 +1420,6 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
time_date = datetime.now().strftime("%Y%m%d")
#秒级时间戳用于标记每一行电池数据
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
self._last_assembly_timestamp = timestamp
#生成输出文件的变量
self.csv_export_file = os.path.join(file_path, f"date_{time_date}.csv")
#将数据存入csv文件
@@ -1694,107 +1430,17 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
writer.writerow([
'Time', 'open_circuit_voltage', 'pole_weight',
'assembly_time', 'assembly_pressure', 'electrolyte_volume',
'data_coin_type', 'electrolyte_code', 'coin_cell_code',
'orderName', 'prep_bottle_barcode', 'vial_bottle_barcodes',
'target_mass_ratio', 'real_mass_ratio'
'coin_num', 'electrolyte_code', 'coin_cell_code'
])
#立刻写入磁盘
csvfile.flush()
#开始追加电池信息
with open(self.csv_export_file, 'a', newline='', encoding='utf-8') as csvfile:
writer = csv.writer(csvfile)
# ========== 提取配方信息 ==========
formulation_order_name = ""
prep_bottle_barcode = ""
vial_bottle_barcodes = ""
target_ratio_str = ""
real_ratio_str = ""
# 从 self._formulations_list 获取配方信息
if hasattr(self, '_formulations_list') and self._formulations_list:
# ============================================================
# ✅ 主方案:用扣电站扫码得到的电解液瓶条码 (data_electrolyte_code)
# 反查配液站登记的 vial_bottle_barcodes避免依赖夹爪取放顺序。
# 配液站和扣电站的瓶子顺序往往不一致(不同自动化设备的取放策略不同),
# 按位置序号匹配会错位;但每个瓶子的条码是唯一的,按条码匹配最可靠。
# ============================================================
formulation = None
match_method = ""
barcode_map = getattr(self, "_formulations_by_vial_barcode", {}) or {}
scan_code = (data_electrolyte_code or "").strip()
if scan_code and scan_code != "N/A" and barcode_map:
formulation = barcode_map.get(scan_code)
if formulation is not None:
match_method = f"按条码({scan_code})精确匹配"
else:
logger.warning(
f"[CSV写入] 电池 {data_battery_number}: 扫码条码 {scan_code} "
f"在配方索引中找不到 (已登记条码: {list(barcode_map.keys())})"
)
# ============================================================
# 🔁 降级方案:扫码失败 / 条码缺失时按瓶号位置兜底
# 保留原有"每瓶电池数"或"二维码尾号"的位置推断逻辑,
# 确保在异常路径下仍能落盘(位置推断的结果可能不准,仅供回溯)。
# ============================================================
if formulation is None:
if hasattr(self, '_elec_use_num') and self._elec_use_num:
elec_use_num_int = int(self._elec_use_num) if self._elec_use_num else 1
if elec_use_num_int > 0:
current_bottle_index = (data_battery_number - 1) // elec_use_num_int
else:
current_bottle_index = 0
logger.debug(
f"[CSV写入] 电池 {data_battery_number}: 降级按瓶号索引={current_bottle_index} "
f"(每瓶{self._elec_use_num}颗电池)"
)
else:
current_bottle_index = (
int(data_electrolyte_code.split('-')[-1])
if '-' in str(data_electrolyte_code)
else 0
)
logger.debug(
f"[CSV写入] 电池 {data_battery_number}: 降级按二维码尾号瓶号索引={current_bottle_index}"
)
if 0 <= current_bottle_index < len(self._formulations_list):
formulation = self._formulations_list[current_bottle_index]
match_method = f"按位置兜底匹配[{current_bottle_index}]"
else:
logger.warning(
f"[CSV写入] 电池 {data_battery_number}: 瓶号索引 {current_bottle_index} "
f"超出配方列表范围 (共{len(self._formulations_list)}个配方)"
)
if formulation is not None:
formulation_order_name = formulation.get("orderName", "")
prep_bottle_barcode = formulation.get("prep_bottle_barcode", "")
vial_bottle_barcodes = formulation.get("vial_bottle_barcodes", "")
real_ratio = formulation.get("real_mass_ratio", {})
target_ratio = formulation.get("target_mass_ratio", {})
target_ratio_str = json.dumps(target_ratio, ensure_ascii=False) if target_ratio else ""
real_ratio_str = json.dumps(real_ratio, ensure_ascii=False) if real_ratio else ""
logger.info(
f"[CSV写入] 电池 {data_battery_number} ({match_method}): "
f"orderName={formulation_order_name}, 配液瓶={prep_bottle_barcode}, "
f"分液瓶={vial_bottle_barcodes}"
)
else:
logger.debug(f"[CSV写入] 电池 {data_battery_number}: 未找到配方信息数据")
writer.writerow([
timestamp, data_open_circuit_voltage, data_pole_weight,
data_assembly_time, data_assembly_pressure, data_electrolyte_volume,
data_coin_type, data_electrolyte_code, data_coin_cell_code,
formulation_order_name, prep_bottle_barcode, vial_bottle_barcodes,
target_ratio_str, real_ratio_str
data_coin_num, data_electrolyte_code, data_coin_cell_code
])
#立刻写入磁盘
csvfile.flush()
@@ -1939,18 +1585,17 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
pole_weight = 0.0
battery_info = {
"Time": getattr(self, "_last_assembly_timestamp", datetime.now().strftime("%Y%m%d_%H%M%S")),
"battery_index": coin_num_N + 1,
"battery_barcode": battery_qr_code,
"electrolyte_barcode": electrolyte_qr_code,
"open_circuit_voltage": open_circuit_voltage,
"pole_weight": pole_weight,
"assembly_time": self.data_assembly_time,
"assembly_pressure": self.data_assembly_pressure,
"electrolyte_volume": self.data_electrolyte_volume,
"data_coin_type": getattr(self, "data_coin_type", 0),
"electrolyte_code": electrolyte_qr_code,
"coin_cell_code": battery_qr_code,
"electrolyte_volume": self.data_electrolyte_volume
}
battery_data_list.append(battery_info)
print(f"已收集第 {coin_num_N + 1} 个电池数据: 电池码={battery_info['coin_cell_code']}, 电解液码={battery_info['electrolyte_code']}")
print(f"已收集第 {coin_num_N + 1} 个电池数据: 电池码={battery_info['battery_barcode']}, 电解液码={battery_info['electrolyte_barcode']}")
time.sleep(1)
# TODO:读完再将电池数加一还是进入循环就将电池数加一需要考虑
@@ -1979,7 +1624,6 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
"success": True,
"total_batteries": len(battery_data_list),
"batteries": battery_data_list,
"assembly_data": battery_data_list,
"summary": {
"electrolyte_bottles_used": elec_num,
"batteries_per_bottle": elec_use_num,
@@ -2023,7 +1667,8 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
file_path: str = "/Users/sml/work"
) -> Dict[str, Any]:
"""
简化版电池组装函数,整合了原 qiming_coin_cell_code 的参数设置和双滴模式
此函数是 func_allpack_cmd 的增强版本,自动处理以下配置:
- 负极片和隔膜的盘数及矩阵点位
- 枪头盒矩阵点位
@@ -2194,18 +1839,17 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
pole_weight = 0.0
battery_info = {
"Time": getattr(self, "_last_assembly_timestamp", datetime.now().strftime("%Y%m%d_%H%M%S")),
"battery_index": coin_num_N + 1,
"battery_barcode": battery_qr_code,
"electrolyte_barcode": electrolyte_qr_code,
"open_circuit_voltage": open_circuit_voltage,
"pole_weight": pole_weight,
"assembly_time": self.data_assembly_time,
"assembly_pressure": self.data_assembly_pressure,
"electrolyte_volume": self.data_electrolyte_volume,
"data_coin_type": getattr(self, "data_coin_type", 0),
"electrolyte_code": electrolyte_qr_code,
"coin_cell_code": battery_qr_code,
"electrolyte_volume": self.data_electrolyte_volume
}
battery_data_list.append(battery_info)
print(f"已收集第 {coin_num_N + 1} 个电池数据: 电池码={battery_info['coin_cell_code']}, 电解液码={battery_info['electrolyte_code']}")
print(f"已收集第 {coin_num_N + 1} 个电池数据: 电池码={battery_info['battery_barcode']}, 电解液码={battery_info['electrolyte_barcode']}")
time.sleep(1)
@@ -2232,7 +1876,6 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
"success": True,
"total_batteries": len(battery_data_list),
"batteries": battery_data_list,
"assembly_data": battery_data_list,
"summary": {
"electrolyte_bottles_used": elec_num,
"batteries_per_bottle": elec_use_num,
@@ -2279,7 +1922,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
def fun_wuliao_test(self) -> bool:
#找到data_init中构建的2个物料盘
test_battery_plate = self.deck.get_resource("\u7535\u6c60\u6599\u76d8")
liaopan3 = self.deck.get_resource("\u7535\u6c60\u6599\u76d8")
for i in range(16):
battery = ElectrodeSheet(name=f"battery_{i}", size_x=16, size_y=16, size_z=2)
battery._unilabos_state = {
@@ -2289,7 +1932,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
"electrolyte_volume": 20.0,
"electrolyte_name": f"DP{i}"
}
test_battery_plate.children[i].assign_child_resource(battery, location=None)
liaopan3.children[i].assign_child_resource(battery, location=None)
ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
"resources": [self.deck]
@@ -2332,7 +1975,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
data_assembly_time = self.data_assembly_time
data_assembly_pressure = self.data_assembly_pressure
data_electrolyte_volume = self.data_electrolyte_volume
data_coin_type = self.data_coin_type # 电池类型7或8种物料
data_coin_num = self.data_coin_num
data_electrolyte_code = self.data_electrolyte_code
data_coin_cell_code = self.data_coin_cell_code
# 电解液瓶位置
@@ -2446,7 +2089,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
writer.writerow([
timestamp, data_open_circuit_voltage, data_pole_weight,
data_assembly_time, data_assembly_pressure, data_electrolyte_volume,
data_coin_type, data_electrolyte_code, data_coin_cell_code # ✅ 已修正
data_coin_num, data_electrolyte_code, data_coin_cell_code
])
#立刻写入磁盘
csvfile.flush()
@@ -2497,7 +2140,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
if __name__ == "__main__":
# 简单测试
workstation = CoinCellAssemblyWorkstation(deck=yihua_coin_cell_deck(name="coin_cell_deck"))
workstation = CoinCellAssemblyWorkstation(deck=CoincellDeck(setup=True, name="coin_cell_deck"))
# workstation.qiming_coin_cell_code(fujipian_panshu=1, fujipian_juzhendianwei=2, gemopanshu=3, gemo_juzhendianwei=4, lvbodian=False, battery_pressure_mode=False, battery_pressure=4200, battery_clean_ignore=False)
# print(f"工作站创建成功: {workstation.deck.name}")
# print(f"料盘数量: {len(workstation.deck.children)}")

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

@@ -1,88 +0,0 @@
# 物料系统标准化重构方案
根据开发者的反馈本方案旨在遵循“标准化而非绕过”的原则对资源类Deck、Carrier、Magazine进行重构。核心目标是将物理结构的初始化与物料/极片的初始填充逻辑解耦,彻底解决反序列化过程中的初始化冲突。
## 拟议变更
### [参考] PRCXI9300 标准化模式
#### [参考文件] [prcxi.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/liquid_handling/prcxi/prcxi.py)
* **PRCXI9300Deck**: 演示了如何在 `serialize` 中导出 `sites` 元数据,以及如何在 `assign_child_resource` 中实现稳健的槽位匹配(支持按名称、坐标或索引匹配)。
* **PRCXI9300Container**: 演示了标准的 `load_state``serialize_state` 模式,确保业务状态(如 `Material` UUID能正确往返序列化。
### [组件] 台面 (Decks)
#### [修改] [decks.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/bioyond/decks.py)
*`BIOYOND_YB_Deck` 重命名为 **`BioyondElectrolyteDeck`**,对应工厂函数 `YB_Deck()` 重命名为 **`bioyond_electrolyte_deck()`**。
* `BIOYOND_PolymerReactionStation_Deck``BIOYOND_PolymerPreparationStation_Deck` **保持不变**
* 以上三个 Deck 的 `__init__` 中均移除 `setup` 参数和 `setup()` 调用,删除临时的 `deserialize` 重写。
#### [修改 + 重命名] [YB_YH_materials.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py) → `yihua_coin_cell_materials.py`
*`CoincellDeck` 重命名为 **`YihuaCoinCellDeck`**,对应工厂函数 `YH_Deck()` 重命名为 **`yihua_coin_cell_deck()`**。
*`YihuaCoinCellDeck.__init__` 中移除 `setup` 参数和 `setup()` 调用,删除临时的 `deserialize` 重写。
### [组件] 容器类与弹夹 (Itemized Carriers & Magazines)
#### [修改] [magazine.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/battery/magazine.py)
* 重构 `magazine_factory`:将创建 `MagazineHolder` 几何结构(空槽位)的过程与填充 `ElectrodeSheet` 物料的过程分离。
* 确保 `MagazineHolder``Magazine``__init__` 过程中不主动创建任何内容物。
#### [修改] [warehouse.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/warehouse.py)
* 确保 `WareHouse` 类和 `warehouse_factory` 遵循相同模式:先初始化几何结构,内容物另行处理。
#### [修改] [itemized_carrier.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/itemized_carrier.py)
* 移除之前添加的 `idx is None` 兜底补丁。
* 修复命名规范,确保 `assign_child_resource` 在反序列化时能准确匹配资源。
### [组件] 状态兼容性 (State Compatibility)
#### [修改] [resource_tracker.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/resource_tracker.py)
*`to_plr_resources` 方法中调用 `load_all_state` 之前,预处理 `all_states` 字典。
* 对于 `Container` 类型的资源,如果其状态中缺少 `liquid_history``pending_liquids` 等 PLR 新版本要求的键,则填充默认值(如空列表/字典),防止反序列化中断。
### [组件] 料盘 (Material Plates)
#### [修改] [YB_YH_materials.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py)
* 重构 `MaterialPlate`:不在 `__init__` 中直接调用 `create_ordered_items_2d`
* 重构 `YIHUA_Electrolyte_12VialCarrier`:将其修改为标准的基类定义或在工厂方法中彻底剥离内部 12 个 `YB_pei_ye_xiao_Bottle` 的强制初始化,以防反序列化冲突。
### [组件] 跨站转运与分液瓶板 (Vial Plate Transfer)
#### [修改] [bioyond_cell_workstation.py] & [YB_YH_materials.py]
* **分析**:目前的 `bioyond_cell_workstation.py` 在执行转移时,是用 `sites=["electrolyte_buffer"]` 试图把整块 `YB_Vial_5mL_Carrier` 板转移给目标。但由于实际工艺中,配液站将分液瓶板传往扣电工站后,是由扣电工站的机械臂**逐瓶抓取**并放入内部的 `bottle_rack_6x2`(电解液缓存位),用完后再放入 `bottle_rack_6x2_2`(废液位),因此配液站的这一次“跨工位资源树转移”在逻辑上存在偏差:目标槽位不应该是装单瓶的载体 `bottle_rack`
* **修复方案**
1. **目标端 (Yihua 侧)**
*`YB_YH_materials.py` 中为从配液站传过来的“分液瓶板”本身设置一个接驳专用的 `PlateSlot`(或者单纯直接移到 Deck 指定坐标)。这个位置负责真正在资源树层级上合法接收配液站传过来的完整 Board。
* 重构 `YIHUA_Electrolyte_12VialCarrier`:为了防止初始化反序列化冲突,取消内部在 `__init__` 中自动填充满 12 个 `YB_pei_ye_xiao_Bottle` 实例的逻辑。`bottle_rack_6x2``bottle_rack_6x2_2` 初始化时均应为空。
2. **转运端 (Bioyond 侧)**
* 修改 `bioyond_cell_workstation.py` 的资源树数字转运代码,将其转移目标对应到 Yihua 侧新设立的“分液瓶板接驳区域”资源,或者干脆只更新资源树坐标位置(使其脱离 Bioyond Deck 加入 Yihua Deck而不再强行挂载到一个无法容纳 Carrier 的 `bottle_rack_6x2` 内部。
### [组件] 依华扣电组装工站物料余量监控 (Material Monitoring)
#### [修改] 寄存器直读与前端集成
* **物理对象保留但虚化追踪**:原有的实体台面对象(如 `MaterialPlate``MagazineHolder` 各类型及其对应的洞位坐标)**仍然保留并使用**。保留它们是为了给机器臂提供基础的物理空间取放标定,以及作为前端页面的可视和可交互区块。
* **内部物料免追踪**:既然余量完全由寄存器接管,**我们将不再在这些弹夹或洞位内部显式生成、塞入和追踪每一个具体的极片或外壳对象 (如 `ElectrodeSheet` 等)**。这恰好与我们的重构主旨(不主动在 `__init__` 建子物料以避开反序列化冲突)完美结合,进一步极大地减轻了后台资源树对象的复杂度。
* **监控方式变更**:放弃现有的物料余量方式,直接读取依华扣电组装工站开放的寄存器地址以获取准确余量。
* **前端界面集成**:在前端界面点击负极壳、弹垫片等弹夹的 data view 时,直接读取并显示寄存器中的各自余量。
* **新增寄存器映射** (参考 `coin_cell_assembly_b.csv`)
* `10mm正极片剩余物料数量R``read hold_register 520` (REAL)
* `12mm正极片剩余物料数量R``read hold_register 522` (REAL)
* `16mm正极片剩余物料数量R``read hold_register 524` (REAL)
* `铝箔剩余物料数量R``read hold_register 526` (REAL)
* `正极壳剩余物料数量R``read hold_register 528` (REAL)
* `平垫剩余物料数量R``read hold_register 530` (REAL)
* `负极壳剩余物料数量R``read hold_register 532` (REAL)
* `弹垫剩余物料数量R``read hold_register 534` (REAL)
* `成品电池剩余可容纳数量R``read hold_register 536` (REAL)
* `成品电池NG槽剩余可容纳数量R``read hold_register 538` (REAL)
### [配置] JSON 配置文件 (Configuration Files)
#### [修改] 资源类型名称更新
* 更新以下配置文件,将其中的 `BIOYOND_YB_Deck` 替换为新的类名 **`BioyondElectrolyteDeck`**,以及将 `coin_cell_deck` 替换为 **`YihuaCoinCellDeck`**
* `yibin_electrolyte_config.json`
* `yibin_coin_cell_only_config.json`
* `yibin_electrolyte_only_config.json`
## 验证计划
### 自动化测试
* 对重构后的类运行 `pylabrobot` 序列化/反序列化测试,确保状态能够完美恢复。
* 检查各工作站节点启动时是否仍存在 `ValueError: Resource '...' already assigned to deck` 报错。
* 检查 `resource_tracker` 中是否仍存在重复 UUID 报错。
### 手动验证
* 重启各工作站节点,验证资源树是否能根据数据库数据正确还还原。
* 验证“自动”与“手动”传输窗资源在台面上的分配是否正确。

View File

@@ -1,388 +0,0 @@
# 物料系统标准化重构方案 v2增强版
> **基于原始方案 (`implementation_plan.md`) 的补充与细化**。
> 本文档在原方案基础上:①增加当前代码现状核查结果;②明确各任务的执行顺序与文件级改动;③新增注意事项与回归测试命令。
---
## 0. 核心原则(保持不变)
"**物理几何结构初始化Deck / Carrier / Magazine 的 `__init__`)与物料内容物填充(`setup()` / `klasses` 参数)必须彻底解耦**",以消除 PLR 反序列化时的 `Resource already assigned to deck` 错误。
---
## 1. 当前代码现状核查2026-03-12
| 文件 | 计划要求 | 当前状态 | 是否完成 |
|---|---|---|---|
| `resources/bioyond/decks.py` | 重命名类;移除 `setup` 参数和 `deserialize` 补丁 | 仍是 `BIOYOND_YB_Deck``setup` 参数和 `deserialize` 均存在 | ❌ |
| `coin_cell_assembly/YB_YH_materials.py` | 重命名类;文件迁移;移除补丁 | 仍是 `CoincellDeck``setup` 参数和 `deserialize` 均存在 | ❌ |
| `resources/battery/magazine.py` | `magazine_factory` 不主动填充物料 | `MagazineHolder_6_Cathode` / `_6_Anode` / `_4_Cathode` 仍传 `klasses`,初始化时填满极片 | ❌ |
| `resources/battery/bottle_carriers.py` | `YIHUA_Electrolyte_12VialCarrier` 初始化时不填充瓶子 | 第 54-55 行仍循环填充 12 个 `YB_pei_ye_xiao_Bottle` | ❌ |
| `resources/itemized_carrier.py` | 移除 `idx is None` 兜底补丁 | 第 182-190 行仍保留该兜底逻辑 | ❌(待前置任务完成后移除) |
| `resources/resource_tracker.py` | `load_all_state` 前预填 `Container` 缺失键 | 第 616 行直接调用,无预处理 | ❌ |
| `bioyond_cell_workstation.py` | 修正跨站转运目标为合法接驳槽 | 第 1563 行仍 `sites=["electrolyte_buffer"]`,目标 UUID 为硬编码虚拟资源 | ❌ |
| `yibin_*.json` 配置文件 | 更新类名 | 仍使用 `BIOYOND_YB_Deck` / `CoincellDeck` | ❌ |
| `registry/resources/bioyond/deck.yaml` | 更新类名(原计划未提及) | 仍使用 `BIOYOND_YB_Deck` / `CoincellDeck` | ❌(**新增** |
---
## 2. 执行顺序(含依赖关系)
```
阶段 A底层资源类
A1. magazine.py — 移除 klasses 填充
A2. bottle_carriers.py — 移除瓶子填充
阶段 BDeck 层)
B1. decks.py — 移除 setup 参数和 deserialize 补丁;重命名
B2. YB_YH_materials.py → 重命名;移除 CoincellDeck 的 setup 参数和 deserialize 补丁
阶段 C状态兼容
C1. resource_tracker.py — 预填 Container 缺失键
C2. itemized_carrier.py — 移除 idx is None 兜底补丁B 阶段完成后)
阶段 D跨站转运修复
D1. YB_YH_materials.py 新增 vial_plate_dock接驳专用槽
D2. bioyond_cell_workstation.py 修正 transfer 目标
阶段 E配置与注册表
E1. yibin_*.json 更新类名
E2. registry/resources/bioyond/deck.yaml 更新类名
E3. coin_cell_assembly.py 更新导入路径(若文件重命名)
```
---
## 3. 分阶段详细说明
---
### 阶段 A — 底层资源类
#### A1. `unilabos/resources/battery/magazine.py`
**问题**`MagazineHolder_6_Cathode``MagazineHolder_6_Anode``MagazineHolder_4_Cathode` 在调用 `magazine_factory` 时传入 `klasses`,导致每次 `__init__` 就填满极片,反序列化时重复添加。
**修改**
- 将三个函数中的 `klasses=[...]` 改为 `klasses=None`(与 `MagazineHolder_6_Battery` 保持一致)。
- **理由**:物料余量已由寄存器管理(见阶段 F不需要在资源树中追踪每一个极片。
```python
# 修改前MagazineHolder_6_Cathode 举例)
klasses=[FlatWasher, PositiveCan, PositiveCan, FlatWasher, PositiveCan, PositiveCan],
# 修改后
klasses=None,
```
> **注意**`magazine_factory` 中 `klasses` 参数及循环体代码保留(仍可按需在非序列化场景使用),只是各具体工厂函数不再传入。
---
#### A2. `unilabos/resources/battery/bottle_carriers.py`
**问题**`YIHUA_Electrolyte_12VialCarrier` 第 54-55 行在工厂函数末尾循环填充 12 个瓶子。
**修改**:删除以下两行:
```python
# 删除
for i in range(12):
carrier[i] = YB_pei_ye_xiao_Bottle(f"{name}_vial_{i+1}")
```
**理由**`bottle_rack_6x2``bottle_rack_6x2_2` 均应初始化为空,瓶子由 Bioyond 侧实际转运后再填入。
---
### 阶段 B — Deck 层重构
#### B1. `unilabos/resources/bioyond/decks.py`
**改动列表**
1. **重命名** `BIOYOND_YB_Deck``BioyondElectrolyteDeck`
2. **重命名** `YB_Deck()` 工厂函数 → `bioyond_electrolyte_deck()`
3. **移除** `__init__` 中的 `setup: bool = False` 参数及 `if setup: self.setup()` 调用
4. **删除** `deserialize` 方法重写(该临时补丁在 `setup` 参数移除后自然失效,继续保留反而掩盖问题)
5. `BIOYOND_PolymerReactionStation_Deck``BIOYOND_PolymerPreparationStation_Deck` 同步执行第 3、4 步
**重构后初始化模式**
```python
class BioyondElectrolyteDeck(Deck):
def __init__(self, name: str = "YB_Deck", ...):
super().__init__(name=name, ...)
# ❌ 不调用 self.setup()
# PLR 反序列化时只会调用 __init__然后从 children JSON 重建子资源
def setup(self) -> None:
# 完整的子资源初始化逻辑保留在这里,只由工厂函数调用
...
def bioyond_electrolyte_deck(name: str) -> BioyondElectrolyteDeck:
deck = BioyondElectrolyteDeck(name=name)
deck.setup() # ✅ 工厂函数负责填充
return deck
```
**同步修改**
- `bioyond_cell_workstation.py` 第 20 行:
```python
# 修改前
from unilabos.resources.bioyond.decks import BIOYOND_YB_Deck
# 修改后
from unilabos.resources.bioyond.decks import BioyondElectrolyteDeck
```
- 同文件第 2440 行:`BIOYOND_YB_Deck(setup=True)` → `bioyond_electrolyte_deck(name="YB_Deck")`
---
#### B2. `unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py`
**改动列表**
1. **重命名** `CoincellDeck` → `YihuaCoinCellDeck`
2. **重命名** `YH_Deck()` → `yihua_coin_cell_deck()`(可保留 `YH_Deck` 作为兼容别名,日后废弃)
3. **移除** `CoincellDeck.__init__` 中 `setup: bool = False` 参数及调用
4. **删除** `CoincellDeck.deserialize` 重写方法
5. `MaterialPlate.__init__` 中移除 `fill` 参数,始终不主动调用 `create_ordered_items_2d`(当前 `fill=False` 路径已正确,只需删除 `fill=True` 分支)
```python
# 修改前MaterialPlate.__init__ 片段)
if fill:
super().__init__(..., ordered_items=holes, ...)
else:
super().__init__(..., ordered_items=ordered_items, ...)
# 修改后(始终走 "不填充" 路径)
super().__init__(..., ordered_items=ordered_items, ...)
# holes 的创建代码整体移入独立工厂方法
```
**同步修改**
- `coin_cell_assembly.py` 第 20 行导入:
```python
# 修改前
from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import CoincellDeck
# 修改后
from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import YihuaCoinCellDeck
```
- 同文件第 2245 行:`CoincellDeck(setup=True, name="coin_cell_deck")` → `yihua_coin_cell_deck(name="coin_cell_deck")`
- 文件重命名(可选):`YB_YH_materials.py` → `yihua_coin_cell_materials.py`(若重命名,所有 import 路径需全局替换)
---
### 阶段 C — 状态兼容
#### C1. `unilabos/resources/resource_tracker.py`
**问题**:第 616 行直接调用 `plr_resource.load_all_state(all_states)`,若 `Container` 类资源的 `data` 字段缺少 `liquid_history` 或 `pending_liquids`PLR 新版本会抛出 `KeyError`。
**修改**:在第 616 行前插入预处理:
```python
# 在 load_all_state 调用前预填缺失键
from pylabrobot.resources.container import Container as PLRContainer
for res_name, state in all_states.items():
if state and isinstance(state, dict):
# Container 类型要求这两个键存在
state.setdefault("liquid_history", [])
state.setdefault("pending_liquids", {})
plr_resource.load_all_state(all_states)
```
---
#### C2. `unilabos/resources/itemized_carrier.py`
**前提**B1、B2 阶段完成Deck 类名与资源命名规范已对齐后再执行此步。
**修改**:删除第 182-190 行的兜底补丁:
```python
# 删除以下整个 if 块
if idx is None:
fallback_location = location if location is not None else Coordinate.zero()
super().assign_child_resource(resource, location=fallback_location, reassign=reassign)
return
```
**替代**:改为抛出带诊断信息的异常,便于后续问题排查:
```python
if idx is None:
raise ValueError(
f"[ItemizedCarrier] 无法为资源 '{resource.name}' 找到匹配的槽位。"
f"已知槽位:{list(self.child_locations.keys())}"
f"传入坐标:{location}"
)
```
---
### 阶段 D — 跨站转运修复
#### D1. `YB_YH_materials.py` — 新增分液瓶板接驳槽
在 `YihuaCoinCellDeck.setup()` 中,新增一个专用于接收 Bioyond 侧传来的完整分液瓶板的 `ResourceStack`(或 `PlateSlot`
```python
# 在 setup() 末尾追加
from pylabrobot.resources.resource_stack import ResourceStack
vial_plate_dock = ResourceStack(
name="electrolyte_buffer", # 保持与 bioyond_cell_workstation.py 的 sites 键一致
direction="z",
resources=[],
)
self.assign_child_resource(vial_plate_dock, Coordinate(x=1050.0, y=700.0, z=0))
```
> **说明**:槽位命名 `electrolyte_buffer` 与 `bioyond_cell_workstation.py` 现有的 `sites=["electrolyte_buffer"]` 对应减少改动量。如改名D2 需同步。
---
#### D2. `bioyond_cell_workstation.py` — 修正 transfer 目标
**问题**:第 1545-1552 行创建了一个 `size=1,1,1` 的虚拟 `ResourcePLR` 并硬编码 UUID这个对象在 YihuaCoinCellDeck 的资源树中不存在,导致转移后资源树状态混乱。
**修改**
```python
# 修改前:创建虚拟目标资源
target_resource_obj = ResourcePLR(name=target_location, size_x=1.0, ...)
target_resource_obj.unilabos_uuid = "550e8400-e29b-41d4-a716-446655440001" # 硬编码
# 修改后:通过 ROS2/设备注册表查询真实资源
# (需要从 target_device 的资源树中取出 electrolyte_buffer 的真实对象)
target_resource_obj = self._get_resource_from_device(
device_id=target_device,
resource_name=target_location
)
if target_resource_obj is None:
raise RuntimeError(
f"目标设备 {target_device} 中未找到资源 '{target_location}'"
f"请确认 YihuaCoinCellDeck.setup() 中已添加 electrolyte_buffer 槽位"
)
```
> **说明**`_get_resource_from_device` 需根据现有 ROS2 资源同步机制实现,或复用已有的 `get_plr_resource_by_name` 类似方法。
---
### 阶段 E — 配置与注册表
#### E1. `yibin_electrolyte_config.json` / `yibin_coin_cell_only_config.json` / `yibin_electrolyte_only_config.json`
全局替换以下字符串:
| 旧值 | 新值 |
|---|---|
| `BIOYOND_YB_Deck` | `BioyondElectrolyteDeck` |
| `unilabos.resources.bioyond.decks:BIOYOND_YB_Deck` | `unilabos.resources.bioyond.decks:BioyondElectrolyteDeck` |
| `CoincellDeck` | `YihuaCoinCellDeck` |
| `unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials:CoincellDeck` | 若文件已重命名:`unilabos.devices.workstation.coin_cell_assembly.yihua_coin_cell_materials:YihuaCoinCellDeck` |
---
#### E2. `unilabos/registry/resources/bioyond/deck.yaml`**原计划未覆盖,新增**
当前第 25 行和第 37 行仍使用旧类名,需同步更新:
```yaml
# 修改前
BIOYOND_YB_Deck:
...
CoincellDeck:
...
# 修改后
BioyondElectrolyteDeck:
...
YihuaCoinCellDeck:
...
```
---
### 阶段 F — 物料余量监控集成原计划第5节细化
**目标**:弃用资源树内极片对象计数,改为直读依华扣电工站寄存器。
#### F1. `coin_cell_assembly/coin_cell_assembly.py` — 新增寄存器读取方法
参考 `coin_cell_assembly_b.csv` 中的地址,封装读取工具方法:
```python
MATERIAL_REGISTER_MAP = {
"10mm正极片": (520, "REAL"),
"12mm正极片": (522, "REAL"),
"16mm正极片": (524, "REAL"),
"铝箔": (526, "REAL"),
"正极壳": (528, "REAL"),
"平垫": (530, "REAL"),
"负极壳": (532, "REAL"),
"弹垫": (534, "REAL"),
"成品容量": (536, "REAL"),
"成品NG容量": (538, "REAL"),
}
def get_material_remaining(self, material_name: str) -> float:
"""通过寄存器直读指定物料的剩余数量"""
if material_name not in MATERIAL_REGISTER_MAP:
raise KeyError(f"未知物料名称: {material_name}")
address, dtype = MATERIAL_REGISTER_MAP[material_name]
return self.read_hold_register(address, dtype) # 复用现有 Modbus 读取方法
```
#### F2. 前端 data view 集成
- 前端点击 `MagazineHolder` 类资源的 data view 时,调用后端 `get_material_remaining` 接口(而非读取 `children` 长度)。
- 具体接口路径和前端调用代码需与前端开发同步,本文档不作具体实现约定。
---
## 4. 验证计划(细化)
### 4.1 单元测试(自动化)
```bash
# 序列化/反序列化往返测试
python -m pytest unilabos/test/ -k "serial" -v
# 特别检查以下错误消失:
# - ValueError: Resource '...' already assigned to deck
# - KeyError: 'liquid_history'
# - 重复 UUID 报错
```
### 4.2 集成测试(手动)
按以下顺序逐步验证,确保每步正常后再进行下一步:
1. **单独启动 `BatteryStation` 节点**,检查 `CoincellDeck`(现 `YihuaCoinCellDeck`)能否从数据库状态正确还原,无 `already assigned` 报错。
2. **单独启动 `BioyondElectrolyte` 节点**,检查 `BioyondElectrolyteDeck` 反序列化正常。
3. **同时启动两个节点**,模拟执行一次分液→扣电的完整跨站转运,确认:
- `electrolyte_buffer` 槽位正确接收分液瓶板。
- `bottle_rack_6x2` 初始为空,不出现虚拟瓶子。
4. **重启两个节点**(模拟断电恢复),确认资源树从数据库还原后,`electrolyte_buffer` 中仍持有正确的分液瓶板对象。
5. **寄存器余量读取**:手动触发 `get_material_remaining("负极壳")`,确认返回值与设备显示一致。
---
## 5. 与原计划的差异对照
| 维度 | 原计划 | 本文档新增/修订 |
|---|---|---|
| 执行顺序 | 未排序 | 明确 A→B→C→D→E→F 的依赖顺序 |
| `itemized_carrier.py` | 移除兜底补丁 | 补充:替换为带诊断信息的异常,便于排查 |
| `bottle_carriers.py` | 提及 `YIHUA_Electrolyte_12VialCarrier` 需修改 | 明确:删除第 54-55 行的瓶子填充循环 |
| `MaterialPlate` | 提及移除 `fill` 参数 | 说明保留 `fill=False` 路径;整体删除 `fill=True` 分支 |
| `deck.yaml` | 未提及 | **新增**:该注册文件也需要同步更新类名 |
| `resource_tracker.py` | 简略描述 | 提供具体的 `setdefault` 预处理代码示例 |
| 跨站转运 | 描述了问题和方向 | 细化:新增 `electrolyte_buffer` 槽位的具体名称和坐标;修正 `transfer` 目标查找方式 |
| 验证计划 | 简述目标 | 提供具体测试命令和逐步手动验证流程 |

File diff suppressed because it is too large Load Diff

View File

@@ -336,6 +336,47 @@ separator.chinwe:
title: pump_valve参数
type: object
type: UniLabJsonCommand
separation_step:
goal:
max_cycles: 0
motor_id: 5
pulses: 700
speed: 60
timeout: 300
handles: {}
schema:
description: 分液步骤 - 液位传感器与电机联动 (有液→顺时针, 无液→逆时针)
properties:
goal:
properties:
max_cycles:
default: 0
description: 最大循环次数 (0=无限制)
type: integer
motor_id:
default: '5'
description: 选择电机
enum:
- '4'
- '5'
title: '注: 4=搅拌, 5=旋钮'
type: string
pulses:
default: 700
description: 每次旋转脉冲数 (约1/4圈)
type: integer
speed:
default: 60
description: 电机转速 (RPM)
type: integer
timeout:
default: 300
description: 超时时间 (秒)
type: integer
required:
- motor_id
type: object
type: UniLabJsonCommand
wait_sensor_level:
feedback: {}
goal:

View File

@@ -64,12 +64,59 @@ coincellassemblyworkstation_device:
properties: {}
required: []
type: object
result: {}
result:
type: boolean
required:
- goal
title: fun_wuliao_test参数
type: object
type: UniLabJsonCommand
auto-func_allpack_cmd:
feedback: {}
goal: {}
goal_default:
assembly_pressure: 4200
assembly_type: 7
elec_num: null
elec_use_num: null
elec_vol: 50
file_path: /Users/sml/work
handles: {}
placeholder_keys: {}
result: {}
schema:
description: ''
properties:
feedback: {}
goal:
properties:
assembly_pressure:
default: 4200
type: integer
assembly_type:
default: 7
type: integer
elec_num:
type: string
elec_use_num:
type: string
elec_vol:
default: 50
type: integer
file_path:
default: /Users/sml/work
type: string
required:
- elec_num
- elec_use_num
type: object
result:
type: object
required:
- goal
title: func_allpack_cmd参数
type: object
type: UniLabJsonCommand
auto-func_allpack_cmd_simp:
feedback: {}
goal: {}
@@ -102,7 +149,7 @@ coincellassemblyworkstation_device:
goal:
properties:
assembly_pressure:
default: 3200
default: 4200
description: 电池压制力(N)
type: integer
assembly_type:
@@ -118,7 +165,7 @@ coincellassemblyworkstation_device:
description: 是否启用压力模式
type: boolean
dual_drop_first_volume:
default: 0
default: 25
description: 二次滴液第一次排液体积(μL)
type: integer
dual_drop_mode:
@@ -137,7 +184,6 @@ coincellassemblyworkstation_device:
description: 电解液瓶数
type: string
elec_use_num:
default: 5
description: 每瓶电解液组装电池数
type: string
elec_vol:
@@ -145,7 +191,7 @@ coincellassemblyworkstation_device:
description: 电解液吸液量(μL)
type: integer
file_path:
default: D:\UniLabdev\Uni-Lab-OS\unilabos\devices\workstation\coin_cell_assembly
default: /Users/sml/work
description: 实验记录保存路径
type: string
fujipian_juzhendianwei:
@@ -176,7 +222,8 @@ coincellassemblyworkstation_device:
- elec_num
- elec_use_num
type: object
result: {}
result:
type: object
required:
- goal
title: func_allpack_cmd_simp参数
@@ -265,7 +312,8 @@ coincellassemblyworkstation_device:
type: boolean
required: []
type: object
result: {}
result:
type: boolean
required:
- goal
title: func_pack_device_init_auto_start_combined参数
@@ -307,7 +355,8 @@ coincellassemblyworkstation_device:
properties: {}
required: []
type: object
result: {}
result:
type: boolean
required:
- goal
title: func_pack_device_stop参数
@@ -332,7 +381,8 @@ coincellassemblyworkstation_device:
type: string
required: []
type: object
result: {}
result:
type: boolean
required:
- goal
title: func_pack_get_msg_cmd参数
@@ -346,10 +396,12 @@ coincellassemblyworkstation_device:
handles:
input:
- data_key: bottle_num
data_source: handle
data_source: workflow
data_type: integer
handler_key: bottle_count
io_type: source
label: 配液瓶数
required: true
placeholder_keys: {}
result: {}
schema:
@@ -384,7 +436,8 @@ coincellassemblyworkstation_device:
properties: {}
required: []
type: object
result: {}
result:
type: boolean
required:
- goal
title: func_pack_send_finished_cmd参数
@@ -421,7 +474,8 @@ coincellassemblyworkstation_device:
- assembly_type
- assembly_pressure
type: object
result: {}
result:
type: boolean
required:
- goal
title: func_pack_send_msg_cmd参数
@@ -477,21 +531,12 @@ coincellassemblyworkstation_device:
handles:
input:
- data_key: elec_num
data_source: handle
data_source: workflow
data_type: integer
handler_key: bottle_count
io_type: source
label: 配液瓶数
- data_key: formulations
data_source: handle
data_type: array
handler_key: formulations_input
label: 配方信息列表
output:
- data_key: assembly_data
data_source: executor
data_type: array
handler_key: assembly_data_output
label: 扣电组装数据列表
required: true
placeholder_keys: {}
result: {}
schema:
@@ -574,7 +619,8 @@ coincellassemblyworkstation_device:
- elec_num
- elec_use_num
type: object
result: {}
result:
type: object
required:
- goal
title: func_sendbottle_allpack_multi参数
@@ -626,31 +672,6 @@ coincellassemblyworkstation_device:
title: modify_deck_name参数
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-qiming_coin_cell_code:
feedback: {}
goal: {}
@@ -698,7 +719,8 @@ coincellassemblyworkstation_device:
required:
- fujipian_panshu
type: object
result: {}
result:
type: boolean
required:
- goal
title: qiming_coin_cell_code参数
@@ -706,10 +728,6 @@ coincellassemblyworkstation_device:
type: UniLabJsonCommand
module: unilabos.devices.workstation.coin_cell_assembly.coin_cell_assembly:CoinCellAssemblyWorkstation
status_types:
data_10mm_positive_plate_remaining: float
data_12mm_positive_plate_remaining: float
data_16mm_positive_plate_remaining: float
data_aluminum_foil_remaining: float
data_assembly_coin_cell_num: int
data_assembly_pressure: int
data_assembly_time: float
@@ -717,22 +735,14 @@ coincellassemblyworkstation_device:
data_axis_y_pos: float
data_axis_z_pos: float
data_coin_cell_code: str
data_coin_type: int
data_current_assembling_count: int
data_current_completed_count: int
data_coin_num: int
data_electrolyte_code: str
data_electrolyte_volume: int
data_finished_battery_ng_remaining_capacity: float
data_finished_battery_remaining_capacity: float
data_flat_washer_remaining: float
data_glove_box_o2_content: float
data_glove_box_pressure: float
data_glove_box_water_content: float
data_negative_shell_remaining: float
data_open_circuit_voltage: float
data_pole_weight: float
data_positive_shell_remaining: float
data_spring_washer_remaining: float
request_rec_msg_status: bool
request_send_msg_status: bool
sys_mode: str
@@ -762,14 +772,6 @@ coincellassemblyworkstation_device:
type: object
data:
properties:
data_10mm_positive_plate_remaining:
type: number
data_12mm_positive_plate_remaining:
type: number
data_16mm_positive_plate_remaining:
type: number
data_aluminum_foil_remaining:
type: number
data_assembly_coin_cell_num:
type: integer
data_assembly_pressure:
@@ -784,38 +786,22 @@ coincellassemblyworkstation_device:
type: number
data_coin_cell_code:
type: string
data_coin_type:
type: integer
data_current_assembling_count:
type: integer
data_current_completed_count:
data_coin_num:
type: integer
data_electrolyte_code:
type: string
data_electrolyte_volume:
type: integer
data_finished_battery_ng_remaining_capacity:
type: number
data_finished_battery_remaining_capacity:
type: number
data_flat_washer_remaining:
type: number
data_glove_box_o2_content:
type: number
data_glove_box_pressure:
type: number
data_glove_box_water_content:
type: number
data_negative_shell_remaining:
type: number
data_open_circuit_voltage:
type: number
data_pole_weight:
type: number
data_positive_shell_remaining:
type: number
data_spring_washer_remaining:
type: number
request_rec_msg_status:
type: boolean
request_send_msg_status:
@@ -825,36 +811,24 @@ coincellassemblyworkstation_device:
sys_status:
type: string
required:
- sys_status
- sys_mode
- request_rec_msg_status
- request_send_msg_status
- data_assembly_coin_cell_num
- data_open_circuit_voltage
- data_assembly_pressure
- data_assembly_time
- data_axis_x_pos
- data_axis_y_pos
- data_axis_z_pos
- data_pole_weight
- data_assembly_pressure
- data_electrolyte_volume
- data_coin_type
- data_current_assembling_count
- data_current_completed_count
- data_coin_cell_code
- data_coin_num
- data_electrolyte_code
- data_glove_box_pressure
- data_electrolyte_volume
- data_glove_box_o2_content
- data_glove_box_pressure
- data_glove_box_water_content
- data_10mm_positive_plate_remaining
- data_12mm_positive_plate_remaining
- data_16mm_positive_plate_remaining
- data_aluminum_foil_remaining
- data_positive_shell_remaining
- data_flat_washer_remaining
- data_negative_shell_remaining
- data_spring_washer_remaining
- data_finished_battery_remaining_capacity
- data_finished_battery_ng_remaining_capacity
- data_open_circuit_voltage
- data_pole_weight
- request_rec_msg_status
- request_send_msg_status
- sys_mode
- sys_status
type: object
registry_type: device
version: 1.0.0

View File

@@ -6973,7 +6973,7 @@ liquid_handler.laiyu:
properties:
channel_num:
default: 1
type: string
type: integer
deck:
type: object
host:
@@ -6984,10 +6984,25 @@ liquid_handler.laiyu:
type: integer
simulator:
default: true
type: string
type: boolean
timeout:
default: 10.0
type: number
serial_port:
default: /dev/ttyUSB0
description: 硬件串口端口(非 simulator 模式下使用)
type: string
baudrate:
default: 115200
type: integer
pipette_address:
default: 4
description: SOPA 移液器 RS485 地址
type: integer
total_height:
default: 310
description: 龙门架总高度 (mm),用于坐标转换
type: number
required:
- deck
type: object

View File

@@ -0,0 +1,286 @@
motor.zdt_x42:
category:
- motor
class:
action_value_mappings:
auto-enable:
feedback: {}
goal: {}
goal_default:
'on': true
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 使能或禁用电机。使能后电机进入锁轴状态,可接收运动指令;禁用后电机进入松轴状态。
properties:
feedback: {}
goal:
properties:
'on':
default: true
type: boolean
required: []
type: object
result: {}
required:
- goal
title: enable参数
type: object
type: UniLabJsonCommand
auto-get_position:
feedback: {}
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 获取当前电机脉冲位置。
properties:
feedback: {}
goal:
properties: {}
required: []
type: object
result:
properties:
position:
type: integer
type: object
required:
- goal
title: get_position参数
type: object
type: UniLabJsonCommand
auto-move_position:
feedback: {}
goal: {}
goal_default:
absolute: false
acceleration: 10
direction: CW
pulses: 1000
speed_rpm: 60
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 位置模式运行。控制电机移动到指定脉冲位置或相对于当前位置移动指定脉冲数。
properties:
feedback: {}
goal:
properties:
absolute:
default: false
type: boolean
acceleration:
default: 10
maximum: 255
minimum: 0
type: integer
direction:
default: CW
enum:
- CW
- CCW
type: string
pulses:
default: 1000
type: integer
speed_rpm:
default: 60
minimum: 0
type: integer
required:
- pulses
- speed_rpm
type: object
result: {}
required:
- goal
title: move_position参数
type: object
type: UniLabJsonCommand
auto-move_speed:
feedback: {}
goal: {}
goal_default:
acceleration: 10
direction: CW
speed_rpm: 60
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 速度模式运行。控制电机以指定转速和方向持续转动。
properties:
feedback: {}
goal:
properties:
acceleration:
default: 10
maximum: 255
minimum: 0
type: integer
direction:
default: CW
enum:
- CW
- CCW
type: string
speed_rpm:
default: 60
minimum: 0
type: integer
required:
- speed_rpm
type: object
result: {}
required:
- goal
title: move_speed参数
type: object
type: UniLabJsonCommand
auto-rotate_quarter:
feedback: {}
goal: {}
goal_default:
direction: CW
speed_rpm: 60
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 电机旋转 1/4 圈 (阻塞式)。
properties:
feedback: {}
goal:
properties:
direction:
default: CW
enum:
- CW
- CCW
type: string
speed_rpm:
default: 60
minimum: 1
type: integer
required: []
type: object
result: {}
required:
- goal
title: rotate_quarter参数
type: object
type: UniLabJsonCommand
auto-set_zero:
feedback: {}
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 将当前电机位置设为零点。
properties:
feedback: {}
goal:
properties: {}
required: []
type: object
result: {}
required:
- goal
title: set_zero参数
type: object
type: UniLabJsonCommand
auto-stop:
feedback: {}
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 立即停止电机运动。
properties:
feedback: {}
goal:
properties: {}
required: []
type: object
result: {}
required:
- goal
title: stop参数
type: object
type: UniLabJsonCommand
auto-wait_time:
feedback: {}
goal: {}
goal_default:
duration_s: 1.0
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 等待指定时间 (秒)。
properties:
feedback: {}
goal:
properties:
duration_s:
default: 1.0
minimum: 0
type: number
required:
- duration_s
type: object
result: {}
required:
- goal
title: wait_time参数
type: object
type: UniLabJsonCommand
module: unilabos.devices.motor.ZDT_X42:ZDTX42Driver
status_types:
position: int
status: str
type: python
config_info: []
description: ZDT X42 闭环步进电机驱动。支持速度运行、精确位置控制、位置查询和清零功能。适用于各种需要精确运动控制的实验室自动化场景。
handles: []
icon: ''
init_param_schema:
config:
properties:
baudrate:
default: 115200
type: integer
debug:
default: false
type: boolean
device_id:
default: 1
type: integer
port:
type: string
timeout:
default: 0.5
type: number
required:
- port
type: object
data:
properties:
position:
type: integer
status:
type: string
required:
- status
- position
type: object
version: 1.0.0

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,148 @@
sensor.xkc_rs485:
category:
- sensor
- separator
class:
action_value_mappings:
auto-change_baudrate:
goal:
baud_code: 7
handles: {}
schema:
description: '更改通讯波特率 (设置成功后无返回,且需手动切换波特率重连)。代码表 (16进制): 05=2400, 06=4800,
07=9600, 08=14400, 09=19200, 0A=28800, 0C=57600, 0D=115200, 0E=128000,
0F=256000'
properties:
goal:
properties:
baud_code:
description: '波特率代码 (例如: 7 为 9600, 13 即 0x0D 为 115200)'
type: integer
required:
- baud_code
type: object
type: UniLabJsonCommand
auto-change_device_id:
goal:
new_id: 1
handles: {}
schema:
description: 修改传感器的 Modbus 从站地址
properties:
goal:
properties:
new_id:
description: 新的从站地址 (1-254)
maximum: 254
minimum: 1
type: integer
required:
- new_id
type: object
type: UniLabJsonCommand
auto-factory_reset:
goal: {}
handles: {}
schema:
description: 恢复出厂设置 (地址重置为 01)
properties:
goal:
type: object
type: UniLabJsonCommand
auto-read_level:
goal: {}
handles: {}
schema:
description: 直接读取当前液位及信号强度
properties:
goal:
type: object
type: object
type: UniLabJsonCommand
auto-set_threshold:
goal:
threshold: 300
handles: {}
schema:
description: 设置液位判定阈值
properties:
goal:
properties:
threshold:
type: integer
required:
- threshold
type: object
type: UniLabJsonCommand
auto-wait_for_liquid:
goal:
target_state: true
timeout: 120
handles: {}
schema:
description: 实时检测电导率(RSSI)并等待用户指定的状态
properties:
goal:
properties:
target_state:
default: true
description: 目标状态 (True=有液, False=无液)
type: boolean
timeout:
default: 120
description: 超时时间 (秒)
required:
- target_state
type: object
type: UniLabJsonCommand
auto-wait_level:
goal:
level: true
timeout: 10
handles: {}
schema:
description: 等待液位达到目标状态
properties:
goal:
properties:
level:
type: boolean
timeout:
type: number
required:
- level
type: object
type: UniLabJsonCommand
module: unilabos.devices.separator.xkc_sensor:XKCSensorDriver
status_types:
level: bool
rssi: int
type: python
config_info: []
description: XKC RS485 非接触式液位传感器 (Modbus RTU)
handles: []
icon: ''
init_param_schema:
config:
properties:
baudrate:
default: 9600
type: integer
debug:
default: false
type: boolean
device_id:
default: 1
type: integer
port:
type: string
threshold:
default: 300
type: integer
timeout:
default: 3.0
type: number
required:
- port
type: object
version: 1.0.0

View File

@@ -3960,6 +3960,14 @@ virtual_separator:
io_type: source
label: bottom_phase_out
side: SOUTH
- data_key: top_outlet
data_source: executor
data_type: fluid
description: 上相(轻相)液体输出口
handler_key: topphaseout
io_type: source
label: top_phase_out
side: NORTH
- data_key: mechanical_port
data_source: handle
data_type: mechanical

View File

@@ -1,12 +0,0 @@
YIHUA_Electrolyte_12VialCarrier:
category:
- battery_bottle_carriers
class:
module: unilabos.resources.battery.bottle_carriers:YIHUA_Electrolyte_12VialCarrier
type: pylabrobot
description: YIHUA 12-vial electrolyte carrier for coin cell assembly workstation
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0

View File

@@ -1,140 +1,84 @@
YB_Vial_20mL:
YB_20ml_fenyeping:
category:
- yb3
- YB_bottle
class:
module: unilabos.resources.bioyond.YB_bottles:YB_Vial_20mL
module: unilabos.resources.bioyond.YB_bottles:YB_20ml_fenyeping
type: pylabrobot
description: YB_Vial_20mL
description: YB_20ml_fenyeping
handles: []
icon: ''
init_param_schema: {}
version: 1.0.0
YB_Vial_5mL:
YB_5ml_fenyeping:
category:
- yb3
- YB_bottle
class:
module: unilabos.resources.bioyond.YB_bottles:YB_Vial_5mL
module: unilabos.resources.bioyond.YB_bottles:YB_5ml_fenyeping
type: pylabrobot
description: YB_Vial_5mL
description: YB_5ml_fenyeping
handles: []
icon: ''
init_param_schema: {}
version: 1.0.0
YB_DosingHead_L:
YB_jia_yang_tou_da:
category:
- yb3
- YB_bottle
class:
module: unilabos.resources.bioyond.YB_bottles:YB_DosingHead_L
module: unilabos.resources.bioyond.YB_bottles:YB_jia_yang_tou_da
type: pylabrobot
description: YB_DosingHead_L
description: YB_jia_yang_tou_da
handles: []
icon: ''
init_param_schema: {}
version: 1.0.0
YB_PrepBottle_60mL:
YB_pei_ye_da_Bottle:
category:
- yb3
- YB_bottle
class:
module: unilabos.resources.bioyond.YB_bottles:YB_PrepBottle_60mL
module: unilabos.resources.bioyond.YB_bottles:YB_pei_ye_da_Bottle
type: pylabrobot
description: YB_PrepBottle_60mL
description: YB_pei_ye_da_Bottle
handles: []
icon: ''
init_param_schema: {}
version: 1.0.0
YB_PrepBottle_15mL:
YB_pei_ye_xiao_Bottle:
category:
- yb3
- YB_bottle
class:
module: unilabos.resources.bioyond.YB_bottles:YB_PrepBottle_15mL
module: unilabos.resources.bioyond.YB_bottles:YB_pei_ye_xiao_Bottle
type: pylabrobot
description: YB_PrepBottle_15mL
description: YB_pei_ye_xiao_Bottle
handles: []
icon: ''
init_param_schema: {}
version: 1.0.0
YB_Tip_5000uL:
YB_qiang_tou:
category:
- yb3
- YB_bottle
class:
module: unilabos.resources.bioyond.YB_bottles:YB_Tip_5000uL
module: unilabos.resources.bioyond.YB_bottles:YB_qiang_tou
type: pylabrobot
description: YB_Tip_5000uL
description: YB_qiang_tou
handles: []
icon: ''
init_param_schema: {}
version: 1.0.0
YB_Tip_1000uL:
category:
- YB_bottle
class:
module: unilabos.resources.bioyond.YB_bottles:YB_Tip_1000uL
type: pylabrobot
description: YB_Tip_1000uL
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_Tip_50uL:
category:
- YB_bottle
class:
module: unilabos.resources.bioyond.YB_bottles:YB_Tip_50uL
type: pylabrobot
description: YB_Tip_50uL
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_NormalLiq_250mL_Bottle:
YB_ye_Bottle:
category:
- yb3
- YB_bottle_carriers
- YB_bottle
class:
module: unilabos.resources.bioyond.YB_bottles:YB_NormalLiq_250mL_Bottle
module: unilabos.resources.bioyond.YB_bottles:YB_ye_Bottle
type: pylabrobot
description: YB_NormalLiq_250mL_Bottle
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_NormalLiq_100mL_Bottle:
category:
- YB_bottle_carriers
- YB_bottle
class:
module: unilabos.resources.bioyond.YB_bottles:YB_NormalLiq_100mL_Bottle
type: pylabrobot
description: YB_NormalLiq_100mL_Bottle
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_HighVis_250mL_Bottle:
category:
- YB_bottle_carriers
- YB_bottle
class:
module: unilabos.resources.bioyond.YB_bottles:YB_HighVis_250mL_Bottle
type: pylabrobot
description: YB_HighVis_250mL_Bottle
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_HighVis_100mL_Bottle:
category:
- YB_bottle_carriers
- YB_bottle
class:
module: unilabos.resources.bioyond.YB_bottles:YB_HighVis_100mL_Bottle
type: pylabrobot
description: YB_HighVis_100mL_Bottle
description: YB_ye_Bottle
handles: []
icon: ''
init_param_schema: {}

View File

@@ -1,29 +1,42 @@
YB_Vial_20mL_Carrier:
YB_100ml_yeti:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_Vial_20mL_Carrier
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_100ml_yeti
type: pylabrobot
description: YB_Vial_20mL_Carrier
description: YB_100ml_yeti
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_Vial_5mL_Carrier:
YB_20ml_fenyepingban:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_Vial_5mL_Carrier
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_20ml_fenyepingban
type: pylabrobot
description: YB_Vial_5mL_Carrier
description: YB_20ml_fenyepingban
handles: []
icon: ''
init_param_schema: {}
version: 1.0.0
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
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_6StockCarrier:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_6StockCarrier
@@ -32,10 +45,10 @@ YB_6StockCarrier:
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_6VialCarrier:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_6VialCarrier
@@ -44,137 +57,112 @@ YB_6VialCarrier:
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_DosingHead_L_Carrier:
YB_gao_nian_ye_Bottle:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_DosingHead_L_Carrier
module: unilabos.resources.bioyond.YB_bottles:YB_gao_nian_ye_Bottle
type: pylabrobot
description: YB_DosingHead_L_Carrier
description: YB_gao_nian_ye_Bottle
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_PrepBottle_60mL_Carrier:
YB_gaonianye:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_PrepBottle_60mL_Carrier
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_gaonianye
type: pylabrobot
description: YB_PrepBottle_60mL_Carrier
description: YB_gaonianye
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_PrepBottle_15mL_Carrier:
YB_jia_yang_tou_da_Carrier:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_PrepBottle_15mL_Carrier
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_jia_yang_tou_da_Carrier
type: pylabrobot
description: YB_PrepBottle_15mL_Carrier
description: YB_jia_yang_tou_da_Carrier
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_TipRack_Mixed:
YB_peiyepingdaban:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_TipRack_Mixed
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_peiyepingdaban
type: pylabrobot
description: YB_TipRack_Mixed
description: YB_peiyepingdaban
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_TipRack_5000uL:
YB_peiyepingxiaoban:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_TipRack_5000uL
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_peiyepingxiaoban
type: pylabrobot
description: YB_TipRack_5000uL
description: YB_peiyepingxiaoban
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_TipRack_50uL:
YB_qiang_tou_he:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_TipRack_50uL
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_qiang_tou_he
type: pylabrobot
description: YB_TipRack_50uL
description: YB_qiang_tou_he
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_Adapter_60mL:
YB_shi_pei_qi_kuai:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_Adapter_60mL
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_shi_pei_qi_kuai
type: pylabrobot
description: YB_Adapter_60mL
description: YB_shi_pei_qi_kuai
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_NormalLiq_250mL_Carrier:
YB_ye:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_NormalLiq_250mL_Carrier
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_ye
type: pylabrobot
description: YB_NormalLiq_250mL_Carrier
description: YB_ye_Bottle_Carrier
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_NormalLiq_100mL_Carrier:
YB_ye_100ml_Bottle:
category:
- yb3
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_NormalLiq_100mL_Carrier
module: unilabos.resources.bioyond.YB_bottles:YB_ye_100ml_Bottle
type: pylabrobot
description: YB_NormalLiq_100mL_Carrier
description: YB_ye_100ml_Bottle
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_HighVis_250mL_Carrier:
category:
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_HighVis_250mL_Carrier
type: pylabrobot
description: YB_HighVis_250mL_Carrier
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0
YB_HighVis_100mL_Carrier:
category:
- YB_bottle_carriers
class:
module: unilabos.resources.bioyond.YB_bottle_carriers:YB_HighVis_100mL_Carrier
type: pylabrobot
description: YB_HighVis_100mL_Carrier
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0

View File

@@ -20,22 +20,22 @@ BIOYOND_PolymerReactionStation_Deck:
icon: 反应站.webp
init_param_schema: {}
version: 1.0.0
BioyondElectrolyteDeck:
BIOYOND_YB_Deck:
category:
- deck
class:
module: unilabos.resources.bioyond.decks:bioyond_electrolyte_deck
module: unilabos.resources.bioyond.decks:YB_Deck
type: pylabrobot
description: BIOYOND ElectrolyteFormulationStation Deck
handles: []
icon: 配液站.webp
init_param_schema: {}
version: 1.0.0
YihuaCoinCellDeck:
CoincellDeck:
category:
- deck
class:
module: unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials:yihua_coin_cell_deck
module: unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials:YH_Deck
type: pylabrobot
description: YIHUA CoinCellAssembly Deck
handles: []

View File

@@ -1,6 +1,9 @@
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
@@ -48,5 +51,6 @@ def YIHUA_Electrolyte_12VialCarrier(name: str) -> BottleCarrier:
carrier.num_items_x = 2
carrier.num_items_y = 6
carrier.num_items_z = 1
# 载架初始化为空,瓶子由实际转运操作填入,避免反序列化时重复 assign
for i in range(12):
carrier[i] = YB_pei_ye_xiao_Bottle(f"{name}_vial_{i+1}")
return carrier

View File

@@ -135,7 +135,6 @@ class BatteryState(TypedDict):
open_circuit_voltage: float
assembly_pressure: float
electrolyte_volume: float
pole_weight: float # 极片称重 (mg)
info: Optional[str] # 附加信息
@@ -180,7 +179,6 @@ class Battery(Container):
open_circuit_voltage=0.0,
assembly_pressure=0.0,
electrolyte_volume=0.0,
pole_weight=0.0,
info=None
)

View File

@@ -53,28 +53,13 @@ class Magazine(ResourceStack):
return self.get_size_z()
def serialize(self) -> dict:
data = super().serialize()
# 物料余量由寄存器接管,不再持久化极片子节点,
# 防止旧数据写回数据库后下次启动时再次引发重复 UUID。
data["children"] = []
data.update({
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,
})
return data
@classmethod
def deserialize(cls, data: dict, allow_marshal: bool = False):
"""反序列化时丢弃极片子节点ElectrodeSheet 等)。
物料余量已由寄存器接管,不再在资源树中追踪每个极片实体。
清空 children 可防止数据库中的旧极片记录被重新加载,避免重复 UUID 报错。
"""
data = dict(data)
data["children"] = []
return super().deserialize(data, allow_marshal=allow_marshal)
}
class MagazineHolder(ItemizedResource):
@@ -235,7 +220,7 @@ def MagazineHolder_6_Cathode(
size_y=size_y,
size_z=size_z,
locations=locations,
klasses=None,
klasses=[FlatWasher, PositiveCan, PositiveCan, FlatWasher, PositiveCan, PositiveCan],
hole_diameter=hole_diameter,
hole_depth=hole_depth,
max_sheets_per_hole=max_sheets_per_hole,
@@ -273,7 +258,7 @@ def MagazineHolder_6_Anode(
size_y=size_y,
size_z=size_z,
locations=locations,
klasses=None,
klasses=[SpringWasher, NegativeCan, NegativeCan, SpringWasher, NegativeCan, NegativeCan],
hole_diameter=hole_diameter,
hole_depth=hole_depth,
max_sheets_per_hole=max_sheets_per_hole,
@@ -350,7 +335,7 @@ def MagazineHolder_4_Cathode(
size_y=size_y,
size_z=size_z,
locations=locations,
klasses=None,
klasses=[AluminumFoil, PositiveElectrode, PositiveElectrode, PositiveElectrode],
hole_diameter=hole_diameter,
hole_depth=hole_depth,
max_sheets_per_hole=max_sheets_per_hole,

View File

@@ -2,18 +2,15 @@ from pylabrobot.resources import create_homogeneous_resources, Coordinate, Resou
from unilabos.resources.itemized_carrier import Bottle, BottleCarrier
from unilabos.resources.bioyond.YB_bottles import (
YB_DosingHead_L,
YB_NormalLiq_250mL_Bottle,
YB_NormalLiq_100mL_Bottle,
YB_HighVis_250mL_Bottle,
YB_HighVis_100mL_Bottle,
YB_Vial_5mL,
YB_Vial_20mL,
YB_PrepBottle_15mL,
YB_PrepBottle_60mL,
YB_Tip_5000uL,
YB_Tip_1000uL,
YB_Tip_50uL,
YB_jia_yang_tou_da,
YB_ye_Bottle,
YB_ye_100ml_Bottle,
YB_gao_nian_ye_Bottle,
YB_5ml_fenyeping,
YB_20ml_fenyeping,
YB_pei_ye_xiao_Bottle,
YB_pei_ye_da_Bottle,
YB_qiang_tou,
)
# 命名约定:试剂瓶-Bottle烧杯-Beaker烧瓶-Flask小瓶-Vial
@@ -209,7 +206,7 @@ def YB_6VialCarrier(name: str) -> BottleCarrier:
return carrier
# 1瓶载架 - 单个中央位置
def YB_NormalLiq_250mL_Carrier(name: str) -> BottleCarrier:
def YB_ye(name: str) -> BottleCarrier:
# 载架尺寸 (mm)
carrier_size_x = 127.8
@@ -236,17 +233,17 @@ def YB_NormalLiq_250mL_Carrier(name: str) -> BottleCarrier:
resource_size_y=beaker_diameter,
name_prefix=name,
),
model="YB_NormalLiq_250mL_Carrier",
model="YB_ye",
)
carrier.num_items_x = 1
carrier.num_items_y = 1
carrier.num_items_z = 1
carrier[0] = YB_NormalLiq_250mL_Bottle(f"{name}_flask_1")
carrier[0] = YB_ye_Bottle(f"{name}_flask_1")
return carrier
# 高粘液瓶载架 - 单个中央位置
def YB_HighVis_250mL_Carrier(name: str) -> BottleCarrier:
def YB_gaonianye(name: str) -> BottleCarrier:
# 载架尺寸 (mm)
carrier_size_x = 127.8
@@ -273,17 +270,17 @@ def YB_HighVis_250mL_Carrier(name: str) -> BottleCarrier:
resource_size_y=beaker_diameter,
name_prefix=name,
),
model="YB_HighVis_250mL_Carrier",
model="YB_gaonianye",
)
carrier.num_items_x = 1
carrier.num_items_y = 1
carrier.num_items_z = 1
carrier[0] = YB_HighVis_250mL_Bottle(f"{name}_flask_1")
carrier[0] = YB_gao_nian_ye_Bottle(f"{name}_flask_1")
return carrier
# 100mL普通液瓶载架 - 单个中央位置
def YB_NormalLiq_100mL_Carrier(name: str) -> BottleCarrier:
# 100ml液体瓶载架 - 单个中央位置
def YB_100ml_yeti(name: str) -> BottleCarrier:
# 载架尺寸 (mm)
carrier_size_x = 127.8
@@ -310,52 +307,16 @@ def YB_NormalLiq_100mL_Carrier(name: str) -> BottleCarrier:
resource_size_y=beaker_diameter,
name_prefix=name,
),
model="YB_NormalLiq_100mL_Carrier",
model="YB_100ml_yeti",
)
carrier.num_items_x = 1
carrier.num_items_y = 1
carrier.num_items_z = 1
carrier[0] = YB_NormalLiq_100mL_Bottle(f"{name}_flask_1")
carrier[0] = YB_ye_100ml_Bottle(f"{name}_flask_1")
return carrier
# 100mL高粘液瓶载架 - 单个中央位置
def YB_HighVis_100mL_Carrier(name: str) -> BottleCarrier:
# 载架尺寸 (mm)
carrier_size_x = 127.8
carrier_size_y = 85.5
carrier_size_z = 20.0
# 烧杯尺寸
beaker_diameter = 60.0
# 计算中央位置
center_x = (carrier_size_x - beaker_diameter) / 2
center_y = (carrier_size_y - beaker_diameter) / 2
center_z = 5.0
carrier = BottleCarrier(
name=name,
size_x=carrier_size_x,
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=create_homogeneous_resources(
klass=ResourceHolder,
locations=[Coordinate(center_x, center_y, center_z)],
resource_size_x=beaker_diameter,
resource_size_y=beaker_diameter,
name_prefix=name,
),
model="YB_HighVis_100mL_Carrier",
)
carrier.num_items_x = 1
carrier.num_items_y = 1
carrier.num_items_z = 1
carrier[0] = YB_HighVis_100mL_Bottle(f"{name}_flask_1")
return carrier
# 5mL分液瓶板 - 4x2布局8个位置
def YB_Vial_5mL_Carrier(name: str) -> BottleCarrier:
# 5ml分液瓶板 - 4x2布局8个位置
def YB_5ml_fenyepingban(name: str) -> BottleCarrier:
# 载架尺寸 (mm)
@@ -394,18 +355,18 @@ def YB_Vial_5mL_Carrier(name: str) -> BottleCarrier:
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=sites,
model="YB_Vial_5mL_Carrier",
model="YB_5ml_fenyepingban",
)
carrier.num_items_x = 4
carrier.num_items_y = 2
carrier.num_items_z = 1
ordering = ["A1", "A2", "A3", "A4", "B1", "B2", "B3", "B4"]
for i in range(8):
carrier[i] = YB_Vial_5mL(f"{name}_vial_{ordering[i]}")
carrier[i] = YB_5ml_fenyeping(f"{name}_vial_{ordering[i]}")
return carrier
# 20mL分液瓶板 - 4x2布局8个位置
def YB_Vial_20mL_Carrier(name: str) -> BottleCarrier:
# 20ml分液瓶板 - 4x2布局8个位置
def YB_20ml_fenyepingban(name: str) -> BottleCarrier:
# 载架尺寸 (mm)
@@ -444,18 +405,18 @@ def YB_Vial_20mL_Carrier(name: str) -> BottleCarrier:
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=sites,
model="YB_Vial_20mL_Carrier",
model="YB_20ml_fenyepingban",
)
carrier.num_items_x = 4
carrier.num_items_y = 2
carrier.num_items_z = 1
ordering = ["A1", "A2", "A3", "A4", "B1", "B2", "B3", "B4"]
for i in range(8):
carrier[i] = YB_Vial_20mL(f"{name}_vial_{ordering[i]}")
carrier[i] = YB_20ml_fenyeping(f"{name}_vial_{ordering[i]}")
return carrier
# 配液瓶(小)板 - 4x2布局8个位置
def YB_PrepBottle_15mL_Carrier(name: str) -> BottleCarrier:
def YB_peiyepingxiaoban(name: str) -> BottleCarrier:
# 载架尺寸 (mm)
@@ -494,19 +455,19 @@ def YB_PrepBottle_15mL_Carrier(name: str) -> BottleCarrier:
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=sites,
model="YB_PrepBottle_15mL_Carrier",
model="YB_peiyepingxiaoban",
)
carrier.num_items_x = 4
carrier.num_items_y = 2
carrier.num_items_z = 1
ordering = ["A1", "A2", "A3", "A4", "B1", "B2", "B3", "B4"]
for i in range(8):
carrier[i] = YB_PrepBottle_15mL(f"{name}_bottle_{ordering[i]}")
carrier[i] = YB_pei_ye_xiao_Bottle(f"{name}_bottle_{ordering[i]}")
return carrier
# 配液瓶(大)板 - 2x2布局4个位置
def YB_PrepBottle_60mL_Carrier(name: str) -> BottleCarrier:
def YB_peiyepingdaban(name: str) -> BottleCarrier:
# 载架尺寸 (mm)
carrier_size_x = 127.8
@@ -544,18 +505,18 @@ def YB_PrepBottle_60mL_Carrier(name: str) -> BottleCarrier:
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=sites,
model="YB_PrepBottle_60mL_Carrier",
model="YB_peiyepingdaban",
)
carrier.num_items_x = 2
carrier.num_items_y = 2
carrier.num_items_z = 1
ordering = ["A1", "A2", "B1", "B2"]
for i in range(4):
carrier[i] = YB_PrepBottle_60mL(f"{name}_bottle_{ordering[i]}")
carrier[i] = YB_pei_ye_da_Bottle(f"{name}_bottle_{ordering[i]}")
return carrier
# 加样头(大)板 - 1x1布局1个位置
def YB_DosingHead_L_Carrier(name: str) -> BottleCarrier:
def YB_jia_yang_tou_da_Carrier(name: str) -> BottleCarrier:
# 载架尺寸 (mm)
carrier_size_x = 127.8
@@ -593,16 +554,16 @@ def YB_DosingHead_L_Carrier(name: str) -> BottleCarrier:
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=sites,
model="YB_DosingHead_L_Carrier",
model="YB_jia_yang_tou_da_Carrier",
)
carrier.num_items_x = 1
carrier.num_items_y = 1
carrier.num_items_z = 1
carrier[0] = YB_DosingHead_L(f"{name}_head_1")
carrier[0] = YB_jia_yang_tou_da(f"{name}_head_1")
return carrier
def YB_Adapter_60mL(name: str) -> BottleCarrier:
def YB_shi_pei_qi_kuai(name: str) -> BottleCarrier:
"""适配器块 - 单个中央位置"""
# 载架尺寸 (mm)
@@ -630,7 +591,7 @@ def YB_Adapter_60mL(name: str) -> BottleCarrier:
resource_size_y=adapter_diameter,
name_prefix=name,
),
model="YB_Adapter_60mL",
model="YB_shi_pei_qi_kuai",
)
carrier.num_items_x = 1
carrier.num_items_y = 1
@@ -639,7 +600,7 @@ def YB_Adapter_60mL(name: str) -> BottleCarrier:
return carrier
def YB_TipRack_50uL(name: str) -> BottleCarrier:
def YB_qiang_tou_he(name: str) -> BottleCarrier:
"""枪头盒 - 8x12布局96个位置"""
# 载架尺寸 (mm)
@@ -648,9 +609,9 @@ def YB_TipRack_50uL(name: str) -> BottleCarrier:
carrier_size_z = 55.0
# 枪头尺寸
tip_diameter = 7.0
tip_spacing_x = 7.5 # X方向间距
tip_spacing_y = 7.5 # Y方向间距
tip_diameter = 10.0
tip_spacing_x = 9.0 # X方向间距
tip_spacing_y = 9.0 # Y方向间距
# 计算起始位置 (居中排列)
start_x = (carrier_size_x - (12 - 1) * tip_spacing_x - tip_diameter) / 2
@@ -678,7 +639,7 @@ def YB_TipRack_50uL(name: str) -> BottleCarrier:
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=sites,
model="YB_TipRack_50uL",
model="YB_qiang_tou_he",
)
carrier.num_items_x = 12
carrier.num_items_y = 8
@@ -687,182 +648,6 @@ def YB_TipRack_50uL(name: str) -> BottleCarrier:
for i in range(96):
row = chr(65 + i // 12) # A-H
col = (i % 12) + 1 # 1-12
carrier[i] = YB_Tip_50uL(f"{name}_tip_{row}{col}")
return carrier
def YB_TipRack_5000uL(name: str) -> BottleCarrier:
"""枪头盒 - 4x6布局24个位置"""
# 载架尺寸 (mm)
carrier_size_x = 127.8
carrier_size_y = 85.5
carrier_size_z = 95.0
# 枪头尺寸
tip_diameter = 16.0
tip_spacing_x = 16.5 # X方向间距
tip_spacing_y = 16.5 # Y方向间距
# 计算起始位置 (居中排列)
start_x = (carrier_size_x - (6 - 1) * tip_spacing_x - tip_diameter) / 2
start_y = (carrier_size_y - (4 - 1) * tip_spacing_y - tip_diameter) / 2
sites = create_ordered_items_2d(
klass=ResourceHolder,
num_items_x=6,
num_items_y=4,
dx=start_x,
dy=start_y,
dz=5.0,
item_dx=tip_spacing_x,
item_dy=tip_spacing_y,
size_x=tip_diameter,
size_y=tip_diameter,
size_z=carrier_size_z,
)
for k, v in sites.items():
v.name = f"{name}_{v.name}"
carrier = BottleCarrier(
name=name,
size_x=carrier_size_x,
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=sites,
model="YB_TipRack_5000uL",
)
carrier.num_items_x = 6
carrier.num_items_y = 4
carrier.num_items_z = 1
# 创建24个枪头
for i in range(24):
row = chr(65 + i // 6) # A-D
col = (i % 6) + 1 # 1-6
carrier[i] = YB_Tip_5000uL(f"{name}_tip_{row}{col}")
return carrier
def YB_TipRack_Mixed(name: str) -> BottleCarrier:
"""混合枪头盒 - 复杂布局
上层: 2x8空位原50uL枪头位置现空余
中层: 4x4布局放5000uL枪头
下层: 2x8布局放1000uL枪头
"""
# 载架尺寸 (mm)
carrier_size_x = 127.8
carrier_size_y = 85.5
carrier_size_z = 95.0
# 各类枪头的尺寸参数
tip_5000_diameter = 16.0
tip_5000_spacing_x = 16.5
tip_5000_spacing_y = 16.5
tip_1000_diameter = 7.0
tip_1000_spacing_x = 7.5
tip_1000_spacing_y = 7.5
# 空位尺寸上层2x8原50uL位置
empty_diameter = 7.0
empty_spacing_x = 7.5
empty_spacing_y = 7.5
# 计算各层的起始位置
# 上层空位 (2x8)
empty_top_start_x = (carrier_size_x - (8 - 1) * empty_spacing_x - empty_diameter) / 2
empty_top_start_y = 5.0
# 中层5000uL (4x4)
tip_5000_start_x = (carrier_size_x - (4 - 1) * tip_5000_spacing_x - tip_5000_diameter) / 2
tip_5000_start_y = empty_top_start_y + 2 * empty_spacing_y + 5.0
# 下层1000uL (2x8)
tip_1000_start_x = (carrier_size_x - (8 - 1) * tip_1000_spacing_x - tip_1000_diameter) / 2
tip_1000_start_y = tip_5000_start_y + 4 * tip_5000_spacing_y + 5.0
sites = {}
# 创建上层空位 (2x8) - 不创建实际的枪头对象
empty_top_sites = create_ordered_items_2d(
klass=ResourceHolder,
num_items_x=8,
num_items_y=2,
dx=empty_top_start_x,
dy=empty_top_start_y,
dz=5.0,
item_dx=empty_spacing_x,
item_dy=empty_spacing_y,
size_x=empty_diameter,
size_y=empty_diameter,
size_z=carrier_size_z,
)
# 添加空位,索引 0-15
for k, v in empty_top_sites.items():
v.name = f"{name}_empty_top_{v.name}"
sites[k] = v
# 创建中层5000uL枪头位 (4x4),索引 16-31
tip_5000_sites = create_ordered_items_2d(
klass=ResourceHolder,
num_items_x=4,
num_items_y=4,
dx=tip_5000_start_x,
dy=tip_5000_start_y,
dz=15.0,
item_dx=tip_5000_spacing_x,
item_dy=tip_5000_spacing_y,
size_x=tip_5000_diameter,
size_y=tip_5000_diameter,
size_z=carrier_size_z,
)
for i, (k, v) in enumerate(tip_5000_sites.items()):
v.name = f"{name}_5000_{v.name}"
sites[16 + i] = v
# 创建下层1000uL枪头位 (2x8),索引 32-47
tip_1000_sites = create_ordered_items_2d(
klass=ResourceHolder,
num_items_x=8,
num_items_y=2,
dx=tip_1000_start_x,
dy=tip_1000_start_y,
dz=25.0,
item_dx=tip_1000_spacing_x,
item_dy=tip_1000_spacing_y,
size_x=tip_1000_diameter,
size_y=tip_1000_diameter,
size_z=carrier_size_z,
)
for i, (k, v) in enumerate(tip_1000_sites.items()):
v.name = f"{name}_1000_{v.name}"
sites[32 + i] = v
carrier = BottleCarrier(
name=name,
size_x=carrier_size_x,
size_y=carrier_size_y,
size_z=carrier_size_z,
sites=sites,
model="YB_TipRack_Mixed",
)
carrier.num_items_x = 8 # 最大宽度
carrier.num_items_y = 8 # 总行数 (2+4+2)
carrier.num_items_z = 1
# 为5000uL枪头创建实例 (16个),对应索引 16-31
for i in range(16):
row = chr(65 + i // 4) # A-D
col = (i % 4) + 1 # 1-4
carrier[16 + i] = YB_Tip_5000uL(f"{name}_tip5000_{row}{col}")
# 为1000uL枪头创建实例 (16个),对应索引 32-47
for i in range(16):
row = chr(65 + i // 8) # A-B
col = (i % 8) + 1 # 1-8
carrier[32 + i] = YB_Tip_1000uL(f"{name}_tip1000_{row}{col}")
carrier[i] = YB_qiang_tou(f"{name}_tip_{row}{col}")
return carrier

View File

@@ -1,7 +1,7 @@
from unilabos.resources.itemized_carrier import Bottle, BottleCarrier
# 工厂函数
"""加样头(大)"""
def YB_DosingHead_L(
def YB_jia_yang_tou_da(
name: str,
diameter: float = 20.0,
height: float = 100.0,
@@ -15,11 +15,11 @@ def YB_DosingHead_L(
height=height,
max_volume=max_volume,
barcode=barcode,
model="YB_DosingHead_L",
model="YB_jia_yang_tou_da",
)
"""250mL普通"""
def YB_NormalLiq_250mL_Bottle(
"""1x1"""
def YB_ye_Bottle(
name: str,
diameter: float = 40.0,
height: float = 70.0,
@@ -33,105 +33,87 @@ def YB_NormalLiq_250mL_Bottle(
height=height,
max_volume=max_volume,
barcode=barcode,
model="YB_NormalLiq_250mL_Bottle",
model="YB_ye_Bottle",
)
"""100mL普通液"""
def YB_NormalLiq_100mL_Bottle(
"""100ml液体"""
def YB_ye_100ml_Bottle(
name: str,
diameter: float = 50.0,
height: float = 90.0,
max_volume: float = 100000.0, # 100mL
barcode: str = None,
) -> Bottle:
"""创建100mL普通液"""
"""创建100ml液体"""
return Bottle(
name=name,
diameter=diameter,
height=height,
max_volume=max_volume,
barcode=barcode,
model="YB_NormalLiq_100mL_Bottle",
model="YB_100ml_yeti",
)
"""100mL高粘液"""
def YB_HighVis_100mL_Bottle(
name: str,
diameter: float = 50.0,
height: float = 90.0,
max_volume: float = 100000.0, # 100mL
barcode: str = None,
) -> Bottle:
"""创建100mL高粘液瓶"""
return Bottle(
name=name,
diameter=diameter,
height=height,
max_volume=max_volume,
barcode=barcode,
model="YB_HighVis_100mL_Bottle",
)
"""250mL高粘液"""
def YB_HighVis_250mL_Bottle(
"""高粘液"""
def YB_gao_nian_ye_Bottle(
name: str,
diameter: float = 40.0,
height: float = 70.0,
max_volume: float = 50000.0, # 50mL
barcode: str = None,
) -> Bottle:
"""创建250mL高粘液瓶"""
"""创建高粘液瓶"""
return Bottle(
name=name,
diameter=diameter,
height=height,
max_volume=max_volume,
barcode=barcode,
model="YB_HighVis_250mL_Bottle",
model="High_Viscosity_Liquid",
)
"""5mL分液瓶"""
def YB_Vial_5mL(
"""5ml分液瓶"""
def YB_5ml_fenyeping(
name: str,
diameter: float = 20.0,
height: float = 50.0,
max_volume: float = 5000.0, # 5mL
barcode: str = None,
) -> Bottle:
"""创建5mL分液瓶"""
"""创建5ml分液瓶"""
return Bottle(
name=name,
diameter=diameter,
height=height,
max_volume=max_volume,
barcode=barcode,
model="YB_Vial_5mL",
model="YB_5ml_fenyeping",
)
"""20mL分液瓶"""
def YB_Vial_20mL(
"""20ml分液瓶"""
def YB_20ml_fenyeping(
name: str,
diameter: float = 30.0,
height: float = 65.0,
max_volume: float = 20000.0, # 20mL
barcode: str = None,
) -> Bottle:
"""创建20mL分液瓶"""
"""创建20ml分液瓶"""
return Bottle(
name=name,
diameter=diameter,
height=height,
max_volume=max_volume,
barcode=barcode,
model="YB_Vial_20mL",
model="YB_20ml_fenyeping",
)
"""配液瓶(小)"""
def YB_PrepBottle_15mL(
def YB_pei_ye_xiao_Bottle(
name: str,
diameter: float = 35.0,
height: float = 60.0,
max_volume: float = 15000.0, # 15mL
max_volume: float = 30000.0, # 30mL
barcode: str = None,
) -> Bottle:
"""创建配液瓶(小)"""
@@ -141,15 +123,15 @@ def YB_PrepBottle_15mL(
height=height,
max_volume=max_volume,
barcode=barcode,
model="YB_PrepBottle_15mL",
model="YB_pei_ye_xiao_Bottle",
)
"""配液瓶(大)"""
def YB_PrepBottle_60mL(
def YB_pei_ye_da_Bottle(
name: str,
diameter: float = 55.0,
height: float = 100.0,
max_volume: float = 60000.0, # 60mL
max_volume: float = 150000.0, # 150mL
barcode: str = None,
) -> Bottle:
"""创建配液瓶(大)"""
@@ -159,29 +141,11 @@ def YB_PrepBottle_60mL(
height=height,
max_volume=max_volume,
barcode=barcode,
model="YB_PrepBottle_60mL",
model="YB_pei_ye_da_Bottle",
)
"""5000uL枪头"""
def YB_Tip_5000uL(
name: str,
diameter: float = 10.0,
height: float = 50.0,
max_volume: float = 5000.0, # 5mL
barcode: str = None,
) -> Bottle:
"""创建枪头"""
return Bottle(
name=name,
diameter=diameter,
height=height,
max_volume=max_volume,
barcode=barcode,
model="YB_Tip_5000uL",
)
"""1000uL枪头"""
def YB_Tip_1000uL(
"""枪头"""
def YB_qiang_tou(
name: str,
diameter: float = 10.0,
height: float = 50.0,
@@ -195,23 +159,5 @@ def YB_Tip_1000uL(
height=height,
max_volume=max_volume,
barcode=barcode,
model="YB_Tip_1000uL",
model="YB_qiang_tou",
)
"""50uL枪头"""
def YB_Tip_50uL(
name: str,
diameter: float = 10.0,
height: float = 50.0,
max_volume: float = 50.0, # 50uL
barcode: str = None,
) -> Bottle:
"""创建枪头"""
return Bottle(
name=name,
diameter=diameter,
height=height,
max_volume=max_volume,
barcode=barcode,
model="YB_Tip_50uL",
)

View File

@@ -1,4 +1,4 @@
from pylabrobot.resources import create_homogeneous_resources, Coordinate, ResourceHolder, create_ordered_items_2d
from pylabrobot.resources import create_homogeneous_resources, Coordinate, ResourceHolder, create_ordered_items_2d, Container
from unilabos.resources.itemized_carrier import BottleCarrier
from unilabos.resources.bioyond.bottles import (
@@ -9,6 +9,28 @@ from unilabos.resources.bioyond.bottles import (
BIOYOND_PolymerStation_Reagent_Bottle,
BIOYOND_PolymerStation_Flask,
)
def BIOYOND_PolymerStation_Tip(name: str, size_x: float = 8.0, size_y: float = 8.0, size_z: float = 50.0) -> Container:
"""创建单个枪头资源
Args:
name: 枪头名称
size_x: 枪头宽度 (mm)
size_y: 枪头长度 (mm)
size_z: 枪头高度 (mm)
Returns:
Container: 枪头容器
"""
return Container(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
category="tip",
model="BIOYOND_PolymerStation_Tip",
)
# 命名约定:试剂瓶-Bottle烧杯-Beaker烧瓶-Flask,小瓶-Vial
@@ -322,3 +344,88 @@ def BIOYOND_Electrolyte_1BottleCarrier(name: str) -> BottleCarrier:
carrier.num_items_z = 1
carrier[0] = BIOYOND_PolymerStation_Solution_Beaker(f"{name}_beaker_1")
return carrier
def BIOYOND_PolymerStation_TipBox(
name: str,
size_x: float = 127.76, # 枪头盒宽度
size_y: float = 85.48, # 枪头盒长度
size_z: float = 100.0, # 枪头盒高度
barcode: str = None,
) -> BottleCarrier:
"""创建4×6枪头盒 (24个枪头) - 使用 BottleCarrier 结构
Args:
name: 枪头盒名称
size_x: 枪头盒宽度 (mm)
size_y: 枪头盒长度 (mm)
size_z: 枪头盒高度 (mm)
barcode: 条形码
Returns:
BottleCarrier: 包含24个枪头孔位的枪头盒载架
布局说明:
- 4行×6列 (A-D, 1-6)
- 枪头孔位间距: 18mm (x方向) × 18mm (y方向)
- 起始位置居中对齐
- 索引顺序: 列优先 (0=A1, 1=B1, 2=C1, 3=D1, 4=A2, ...)
"""
# 枪头孔位参数
num_cols = 6 # 1-6 (x方向)
num_rows = 4 # A-D (y方向)
tip_diameter = 8.0 # 枪头孔位直径
tip_spacing_x = 18.0 # 列间距 (增加到18mm更宽松)
tip_spacing_y = 18.0 # 行间距 (增加到18mm更宽松)
# 计算起始位置 (居中对齐)
total_width = (num_cols - 1) * tip_spacing_x + tip_diameter
total_height = (num_rows - 1) * tip_spacing_y + tip_diameter
start_x = (size_x - total_width) / 2
start_y = (size_y - total_height) / 2
# 使用 create_ordered_items_2d 创建孔位
# create_ordered_items_2d 返回的 key 是数字索引: 0, 1, 2, ...
# 顺序是列优先: 先y后x (即 0=A1, 1=B1, 2=C1, 3=D1, 4=A2, 5=B2, ...)
sites = create_ordered_items_2d(
klass=ResourceHolder,
num_items_x=num_cols,
num_items_y=num_rows,
dx=start_x,
dy=start_y,
dz=5.0,
item_dx=tip_spacing_x,
item_dy=tip_spacing_y,
size_x=tip_diameter,
size_y=tip_diameter,
size_z=50.0, # 枪头深度
)
# 更新 sites 中每个 ResourceHolder 的名称
for k, v in sites.items():
v.name = f"{name}_{v.name}"
# 创建枪头盒载架
# 注意:不设置 category使用默认的 "bottle_carrier",这样前端会显示为完整的矩形载架
tip_box = BottleCarrier(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
sites=sites, # 直接使用数字索引的 sites
model="BIOYOND_PolymerStation_TipBox",
)
# 设置自定义属性
tip_box.barcode = barcode
tip_box.tip_count = 24 # 4行×6列
tip_box.num_items_x = num_cols
tip_box.num_items_y = num_rows
tip_box.num_items_z = 1
# ⭐ 枪头盒不需要放入子资源
# 与其他 carrier 不同,枪头盒在 Bioyond 中是一个整体
# 不需要追踪每个枪头的状态,保持为空的 ResourceHolder 即可
# 这样前端会显示24个空槽位可以用于放置枪头
return tip_box

View File

@@ -116,7 +116,9 @@ def BIOYOND_PolymerStation_TipBox(
size_z: float = 100.0, # 枪头盒高度
barcode: str = None,
):
"""创建4×6枪头盒 (24个枪头)
"""创建4×6枪头盒 (24个枪头) - 使用 BottleCarrier 结构
注意:此函数已弃用,请使用 bottle_carriers.py 中的版本
Args:
name: 枪头盒名称
@@ -126,55 +128,11 @@ def BIOYOND_PolymerStation_TipBox(
barcode: 条形码
Returns:
TipBoxCarrier: 包含24个枪头孔位的枪头盒
BottleCarrier: 包含24个枪头孔位的枪头盒载架
"""
from pylabrobot.resources import Container, Coordinate
# 创建枪头盒容器
tip_box = Container(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
category="tip_rack",
model="BIOYOND_PolymerStation_TipBox_4x6",
)
# 设置自定义属性
tip_box.barcode = barcode
tip_box.tip_count = 24 # 4行×6列
tip_box.num_items_x = 6 # 6列
tip_box.num_items_y = 4 # 4行
# 创建24个枪头孔位 (4行×6列)
# 假设孔位间距为 9mm
tip_spacing_x = 9.0 # 列间距
tip_spacing_y = 9.0 # 行间距
start_x = 14.38 # 第一个孔位的x偏移
start_y = 11.24 # 第一个孔位的y偏移
for row in range(4): # A, B, C, D
for col in range(6): # 1-6
spot_name = f"{chr(65 + row)}{col + 1}" # A1, A2, ..., D6
x = start_x + col * tip_spacing_x
y = start_y + row * tip_spacing_y
# 创建枪头孔位容器
tip_spot = Container(
name=spot_name,
size_x=8.0, # 单个枪头孔位大小
size_y=8.0,
size_z=size_z - 10.0, # 略低于盒子高度
category="tip_spot",
)
# 添加到枪头盒
tip_box.assign_child_resource(
tip_spot,
location=Coordinate(x=x, y=y, z=0)
)
return tip_box
# 重定向到 bottle_carriers.py 中的实现
from unilabos.resources.bioyond.bottle_carriers import BIOYOND_PolymerStation_TipBox as TipBox_Carrier
return TipBox_Carrier(name=name, size_x=size_x, size_y=size_y, size_z=size_z, barcode=barcode)
def BIOYOND_PolymerStation_Flask(

View File

@@ -1,3 +1,4 @@
from os import name
from pylabrobot.resources import Deck, Coordinate, Rotation
from unilabos.resources.bioyond.YB_warehouses import (
@@ -33,8 +34,11 @@ class BIOYOND_PolymerReactionStation_Deck(Deck):
size_y: float = 1080.0,
size_z: float = 1500.0,
category: str = "deck",
setup: bool = False
) -> None:
super().__init__(name=name, size_x=2700.0, size_y=1080.0, size_z=1500.0)
if setup:
self.setup()
def setup(self) -> None:
# 添加仓库
@@ -62,7 +66,6 @@ class BIOYOND_PolymerReactionStation_Deck(Deck):
for warehouse_name, warehouse in self.warehouses.items():
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
class BIOYOND_PolymerPreparationStation_Deck(Deck):
def __init__(
self,
@@ -71,8 +74,11 @@ class BIOYOND_PolymerPreparationStation_Deck(Deck):
size_y: float = 1080.0,
size_z: float = 1500.0,
category: str = "deck",
setup: bool = False
) -> None:
super().__init__(name=name, size_x=2700.0, size_y=1080.0, size_z=1500.0)
if setup:
self.setup()
def setup(self) -> None:
# 添加仓库 - 配液站的3个堆栈使用Bioyond系统中的实际名称
@@ -95,8 +101,7 @@ class BIOYOND_PolymerPreparationStation_Deck(Deck):
for warehouse_name, warehouse in self.warehouses.items():
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
class BioyondElectrolyteDeck(Deck):
class BIOYOND_YB_Deck(Deck):
def __init__(
self,
name: str = "YB_Deck",
@@ -104,7 +109,7 @@ class BioyondElectrolyteDeck(Deck):
size_y: float = 1400.0,
size_z: float = 2670.0,
category: str = "deck",
setup: bool = False,
setup: bool = False
) -> None:
super().__init__(name=name, size_x=4150.0, size_y=1400.0, size_z=2670.0)
if setup:
@@ -113,8 +118,8 @@ class BioyondElectrolyteDeck(Deck):
def setup(self) -> None:
# 添加仓库
self.warehouses = {
"自动堆栈-左": bioyond_warehouse_2x2x1("自动堆栈-左"), # 2行×2列
"自动堆栈-右": bioyond_warehouse_2x2x1("自动堆栈-右"), # 2行×2列
"321窗口": bioyond_warehouse_2x2x1("321窗口"), # 2行×2列
"43窗口": bioyond_warehouse_2x2x1("43窗口"), # 2行×2列
"手动传递窗右": bioyond_warehouse_5x3x1("手动传递窗右", row_offset=0), # A01-E03
"手动传递窗左": bioyond_warehouse_5x3x1("手动传递窗左", row_offset=5), # F01-J03
"加样头堆栈左": bioyond_warehouse_10x1x1("加样头堆栈左"),
@@ -128,34 +133,29 @@ class BioyondElectrolyteDeck(Deck):
}
# warehouse 的位置
self.warehouse_locations = {
"自动堆栈-左": Coordinate(-150.0, 1142.0, 0.0),
"自动堆栈-右": Coordinate(4160.0, 1142.0, 0.0),
"手动传递窗左": Coordinate(-150.0, 423.0, 0.0),
"手动传递窗右": Coordinate(4160.0, 423.0, 0.0),
"加样头堆栈左": Coordinate(385.0, 0, 0.0),
"加样头堆栈右": Coordinate(2187.0, 0, 0.0),
"321窗口": Coordinate(-150.0, 158.0, 0.0),
"43窗口": Coordinate(4160.0, 158.0, 0.0),
"手动传递窗左": Coordinate(-150.0, 877.0, 0.0),
"手动传递窗右": Coordinate(4160.0, 877.0, 0.0),
"加样头堆栈左": Coordinate(385.0, 1300.0, 0.0),
"加样头堆栈右": Coordinate(2187.0, 1300.0, 0.0),
"15ml配液堆栈左": Coordinate(749.0, 945.0, 0.0),
"母液加样右": Coordinate(2152.0, 967.0, 0.0),
"大瓶母液堆栈左": Coordinate(1164.0, 624.0, 0.0),
"大瓶母液堆栈右": Coordinate(2717.0, 624.0, 0.0),
"2号手套箱内部堆栈": Coordinate(-800, 800.0, 0.0), # 新增:位置需根据实际硬件调整
"15ml配液堆栈左": Coordinate(749.0, 355.0, 0.0),
"母液加样右": Coordinate(2152.0, 333.0, 0.0),
"大瓶母液堆栈左": Coordinate(1164.0, 676.0, 0.0),
"大瓶母液堆栈右": Coordinate(2717.0, 676.0, 0.0),
"2号手套箱内部堆栈": Coordinate(-800, -500.0, 0.0), # 新增:位置需根据实际硬件调整
}
for warehouse_name, warehouse in self.warehouses.items():
self.assign_child_resource(warehouse, location=self.warehouse_locations[warehouse_name])
# 向后兼容别名,日后废弃
BIOYOND_YB_Deck = BioyondElectrolyteDeck
def YB_Deck(name: str) -> Deck:
by=BIOYOND_YB_Deck(name=name)
by.setup()
return by
def bioyond_electrolyte_deck(name: str) -> BioyondElectrolyteDeck:
deck = BioyondElectrolyteDeck(name=name)
deck.setup()
return deck
# 向后兼容别名,日后废弃
def YB_Deck(name: str) -> BioyondElectrolyteDeck:
return bioyond_electrolyte_deck(name)

View File

@@ -797,7 +797,9 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
bottle = plr_material[number] = initialize_resource(
{"name": f'{detail["name"]}_{number}', "class": reverse_type_mapping[typeName][0]}, resource_type=ResourcePLR
)
if hasattr(bottle, 'tracker') and bottle.tracker is not None:
# 只有具有 tracker 属性的容器才设置液体信息(如 Bottle, Well
# ResourceHolder 等不支持液体追踪的容器跳过
if hasattr(bottle, "tracker"):
bottle.tracker.liquids = [
(detail["name"], float(detail.get("quantity", 0)) if detail.get("quantity") else 0)
]
@@ -809,7 +811,8 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
# 只对有 capacity 属性的容器(液体容器)处理液体追踪
if hasattr(plr_material, 'capacity'):
bottle = plr_material[0] if plr_material.capacity > 0 else plr_material
if hasattr(bottle, 'tracker') and bottle.tracker is not None:
# 确保 bottle 有 tracker 属性才设置液体信息
if hasattr(bottle, "tracker"):
bottle.tracker.liquids = [
(material["name"], float(material.get("quantity", 0)) if material.get("quantity") else 0)
]
@@ -841,24 +844,29 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
wh_name = loc.get("whName")
logger.debug(f"[物料位置] {unique_name} 尝试放置到 warehouse: {wh_name} (Bioyond坐标: x={loc.get('x')}, y={loc.get('y')}, z={loc.get('z')})")
# Bioyond坐标映射 (重要!): x→行(1=A,2=B...), y→列(1=01,2=02...), z→层(通常=1)
# 必须在warehouse映射之前先获取坐标以便后续调整
x = loc.get("x", 1) # 行号 (1-based: 1=A, 2=B, 3=C, 4=D)
y = loc.get("y", 1) # 列号 (1-based: 1=01, 2=02, 3=03...)
z = loc.get("z", 1) # 层号 (1-based, 通常为1)
# 特殊处理: Bioyond的"堆栈1"需要映射到"堆栈1左"或"堆栈1右"
# 根据列号(x)判断: 1-4映射到左侧, 5-8映射到右侧
# 根据列号(y)判断: 1-4映射到左侧, 5-8映射到右侧
if wh_name == "堆栈1":
x_val = loc.get("x", 1)
if 1 <= x_val <= 4:
if 1 <= y <= 4:
wh_name = "堆栈1左"
elif 5 <= x_val <= 8:
elif 5 <= y <= 8:
wh_name = "堆栈1右"
y = y - 4 # 调整列号: 5-8映射到1-4
else:
logger.warning(f"物料 {material['name']} 的列号 x={x_val} 超出范围无法映射到堆栈1左或堆栈1右")
logger.warning(f"物料 {material['name']} 的列号 y={y} 超出范围无法映射到堆栈1左或堆栈1右")
continue
# 特殊处理: Bioyond的"站内Tip盒堆栈"也需要进行拆分映射
if wh_name == "站内Tip盒堆栈":
y_val = loc.get("y", 1)
if y_val == 1:
if y == 1:
wh_name = "站内Tip盒堆栈(右)"
elif y_val in [2, 3]:
elif y in [2, 3]:
wh_name = "站内Tip盒堆栈(左)"
y = y - 1 # 调整列号,因为左侧仓库对应的 Bioyond y=2 实际上是它的第1列
@@ -866,15 +874,6 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
warehouse = deck.warehouses[wh_name]
logger.debug(f"[Warehouse匹配] 找到warehouse: {wh_name} (容量: {warehouse.capacity}, 行×列: {warehouse.num_items_x}×{warehouse.num_items_y})")
# Bioyond坐标映射 (重要!): x→行(1=A,2=B...), y→列(1=01,2=02...), z→层(通常=1)
x = loc.get("x", 1) # 行号 (1-based: 1=A, 2=B, 3=C, 4=D)
y = loc.get("y", 1) # 列号 (1-based: 1=01, 2=02, 3=03...)
z = loc.get("z", 1) # 层号 (1-based, 通常为1)
# 如果是右侧堆栈,需要调整列号 (5→1, 6→2, 7→3, 8→4)
if wh_name == "堆栈1右":
y = y - 4 # 将5-8映射到1-4
# 特殊处理竖向warehouse站内试剂存放堆栈、测量小瓶仓库
# 这些warehouse使用 vertical-col-major 布局
if wh_name in ["站内试剂存放堆栈", "测量小瓶仓库(测密度)"]:

View File

@@ -179,35 +179,11 @@ class ItemizedCarrier(ResourcePLR):
idx = i
break
if idx is None and location is not None:
# 精确坐标匹配失败常见原因DB 存储的 z=0而槽位定义 z=dz>0
# 降级为仅按 XY 坐标进行近似匹配,找到后使用槽位自身的正确坐标写回,
# 避免因 Z 偏移导致反序列化中断。
_XY_TOLERANCE = 2.0 # mm覆盖浮点误差和 z 偏移
min_dist = float("inf")
nearest_idx = None
for _i, _loc in enumerate(self.child_locations.values()):
_d = (((_loc.x - location.x) ** 2) + ((_loc.y - location.y) ** 2)) ** 0.5
if _d < min_dist:
min_dist = _d
nearest_idx = _i
if nearest_idx is not None and min_dist <= _XY_TOLERANCE:
from unilabos.utils.log import logger as _logger
_slot_label = list(self.child_locations.keys())[nearest_idx]
_logger.warning(
f"[ItemizedCarrier '{self.name}'] 资源 '{resource.name}' 坐标 {location} 与槽位 "
f"'{_slot_label}' {list(self.child_locations.values())[nearest_idx]} 的 XY 吻合"
f"XY 偏差={min_dist:.2f}mm按 XY 近似匹配成功z 偏移已被修正。"
)
idx = nearest_idx
if idx is None:
raise ValueError(
f"[ItemizedCarrier '{self.name}'] 无法为资源 '{resource.name}' 找到匹配的槽位。\n"
f" 已知槽位: {list(self.child_locations.keys())}\n"
f" 传入坐标: {location}\n"
f" 提示: XY 近似匹配也失败,请检查资源坐标或 Carrier 槽位定义是否正确。"
)
# 反序列化时无法匹配 site名称或坐标均不符
# WareHouse 通过 sites 追踪占用,无需将子资源加入 PLR 子树,直接跳过避免命名冲突。
return
if not reassign and self.sites[idx] is not None:
raise ValueError(f"a site with index {idx} already exists")
location = list(self.child_locations.values())[idx]

View File

@@ -18,3 +18,9 @@ def register():
from unilabos.devices.liquid_handling.rviz_backend import UniLiquidHandlerRvizBackend
from unilabos.devices.liquid_handling.laiyu.backend.laiyu_v_backend import UniLiquidHandlerLaiyuBackend
# noinspection PyUnresolvedReferences
from unilabos.resources.bioyond.decks import (
BIOYOND_PolymerReactionStation_Deck,
BIOYOND_PolymerPreparationStation_Deck,
BIOYOND_YB_Deck,
)

View File

@@ -423,6 +423,7 @@ class ResourceTreeSet(object):
"deck": "deck",
"tip_rack": "tip_rack",
"tip_spot": "tip_spot",
"tip": "tip", # 添加 tip 类型支持
"tube": "tube",
"bottle_carrier": "bottle_carrier",
"material_hole": "material_hole",
@@ -605,38 +606,21 @@ class ResourceTreeSet(object):
},
"rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"},
"category": res.config.get("category", plr_type),
"children": [node_to_plr_dict(child, has_model) for child in node.children],
# WareHouse 通过 sites 字符串追踪占位,不依赖 PLR children tree。
# 将 WareHouse 子节点排除在外,避免同名载架出现在多个 WareHouse 下时
# PLR _check_naming_conflicts 报命名冲突。
"children": [] if res.type == "warehouse" else [node_to_plr_dict(child, has_model) for child in node.children],
"parent_name": res.parent_instance_name,
}
if has_model:
d["model"] = res.config.get("model", None)
# 仅当 PLR dict 中含有子节点时才禁用 setup()
# 防止 setup() 预分配子资源后 PLR deserialize 再次分配同名资源产生命名冲突。
# 若 children 为空,则保留 setup=True依赖 setup() 来初始化仓库。
if "setup" in d and d.get("children"):
d["setup"] = False
return d
def _deduplicate_plr_dict(d: dict, _seen: set = None) -> dict:
"""递归去除 children 中同名重复节点(全树范围、保留首次出现)。
根本原因:同一槽位被 sync_from_externalBioyond 同步)重复写入,
导致数据库中同一 WareHouse 下存在多条同名 BottleCarrier 记录(不同 UUID
PLR 的 _check_naming_conflicts 在全树范围检查名称唯一性,
重复名称会在 deserialize 时抛出 ValueError导致节点启动失败。
此函数在 sub_cls.deserialize 前预先清理,保证名称唯一。
"""
if _seen is None:
_seen = set()
children = d.get("children", [])
deduped = []
for child in children:
child = _deduplicate_plr_dict(child, _seen)
cname = child.get("name")
if cname not in _seen:
_seen.add(cname)
deduped.append(child)
else:
logger.warning(
f"[资源树去重] 发现重复资源名称 '{cname}',跳过重复项(历史脏数据)"
)
return {**d, "children": deduped}
plr_resources = []
tracker = DeviceNodeResourceTracker()
@@ -647,8 +631,6 @@ class ResourceTreeSet(object):
collect_node_data(tree.root_node, name_to_uuid, all_states, name_to_extra)
has_model = tree.root_node.res_content.type != "deck"
plr_dict = node_to_plr_dict(tree.root_node, has_model)
plr_dict = _deduplicate_plr_dict(plr_dict)
try:
sub_cls = find_subclass(plr_dict["type"], PLRResource)
if skip_devices and plr_dict["type"] == "device":
@@ -667,14 +649,6 @@ class ResourceTreeSet(object):
location = cast(Coordinate, deserialize(plr_dict["location"]))
plr_resource.location = location
# 预填 Container 类型资源在新版 PLR 中要求必须存在的键,
# 防止旧数据库状态缺失这些键时 load_all_state 抛出 KeyError。
for state in all_states.values():
if isinstance(state, dict):
state.setdefault("liquid_history", [])
state.setdefault("pending_liquids", {})
plr_resource.load_all_state(all_states)
# 使用 DeviceNodeResourceTracker 设置 UUID 和 Extra
tracker.loop_set_uuid(plr_resource, name_to_uuid)
@@ -897,13 +871,34 @@ class ResourceTreeSet(object):
f"已存在,跳过"
)
# 移除本地有但远端已不存在的物料(以远端为准)
remote_material_names = {m.res_content.name for m in remote_child.children}
removed_count = 0
for child in list(local_sub_device.children):
if child.res_content.name not in remote_material_names:
local_sub_device.children.remove(child)
removed_count += 1
logger.info(
f"移除远端已不存在的物料: '{remote_root_id}/{remote_child_name}/{child.res_content.name}'"
)
if added_count > 0:
logger.info(
f"Device '{remote_root_id}/{remote_child_name}': "
f"从远端同步了 {added_count} 个物料子树"
)
if removed_count > 0:
logger.info(
f"Device '{remote_root_id}/{remote_child_name}': "
f"移除了 {removed_count} 个远端已删除的物料"
)
else:
# 二级物料已存在,比较三级子节点是否缺失
if remote_child_name not in local_children_map:
logger.warning(
f"物料 '{remote_root_id}/{remote_child_name}' 在远端存在但本地不存在,跳过"
)
continue
local_material = local_children_map[remote_child_name]
local_material_children_map = {child.res_content.name: child for child in
local_material.children}
@@ -919,11 +914,28 @@ class ResourceTreeSet(object):
f"物料 '{remote_root_id}/{remote_child_name}/{remote_sub_name}' "
f"已存在,跳过"
)
# 移除本地有但远端已不存在的子物料(以远端为准)
remote_sub_names = {s.res_content.name for s in remote_child.children}
removed_count = 0
for child in list(local_material.children):
if child.res_content.name not in remote_sub_names:
local_material.children.remove(child)
removed_count += 1
logger.info(
f"移除远端已不存在的子物料: '{remote_root_id}/{remote_child_name}/{child.res_content.name}'"
)
if added_count > 0:
logger.info(
f"物料 '{remote_root_id}/{remote_child_name}': "
f"从远端同步了 {added_count} 个子物料"
)
if removed_count > 0:
logger.info(
f"物料 '{remote_root_id}/{remote_child_name}': "
f"移除了 {removed_count} 个远端已删除的子物料"
)
else:
# 情况1: 一级节点是物料(不是 device
# 检查是否已存在
@@ -1364,6 +1376,16 @@ class DeviceNodeResourceTracker(object):
else:
res_list.extend(self.loop_find_resource(r, type(query_resource), "unilabos_uuid", res_uuid))
# 同一资源对象可能通过"直接注册"和"作为父资源子节点"被搜索到两次,按对象 id 去重
seen_ids: set = set()
deduped = []
for item in res_list:
oid = id(item[1])
if oid not in seen_ids:
seen_ids.add(oid)
deduped.append(item)
res_list = deduped
if not try_mode:
assert len(res_list) > 0, f"没有找到资源 (uuid={res_uuid}),请检查资源是否存在"
assert len(res_list) == 1, f"通过uuid={res_uuid} 找到多个资源,请检查资源是否唯一: {res_list}"
@@ -1400,6 +1422,14 @@ class DeviceNodeResourceTracker(object):
r, resource_cls_type, identifier_key, getattr(query_resource, identifier_key)
)
)
seen_ids2: set = set()
deduped2 = []
for item in res_list:
oid = id(item[1])
if oid not in seen_ids2:
seen_ids2.add(oid)
deduped2.append(item)
res_list = deduped2
if not try_mode:
assert len(res_list) > 0, f"没有找到资源 {query_resource},请检查资源是否存在"
assert len(res_list) == 1, f"{query_resource} 找到多个资源,请检查资源是否唯一: {res_list}"

View File

@@ -41,9 +41,8 @@ def warehouse_factory(
# 根据 layout 决定 y 坐标计算
if layout == "row-major":
# 行优先row=0(A行) 应该显示在上方
# 前端现在 y 越大越靠上,所以 row=0 对应最大的 y
y = dy + (num_items_y - row - 1) * item_dy
# 行优先row=0(A行) 应该显示在上方,需要较小的 y 值
y = dy + row * item_dy
elif layout == "vertical-col-major":
# 竖向warehouse: row=0 对应顶部y小row=n-1 对应底部y大
# 但标签 01 应该在底部,所以使用反向映射

View File

@@ -2340,4 +2340,4 @@ class DeviceInfoType(TypedDict):
status_publishers: Dict[str, PropertyPublisher]
actions: Dict[str, ActionServer]
hardware_interface: Dict[str, Any]
base_node_instance: BaseROS2DeviceNode
base_node_instance: BaseROS2DeviceNode

View File

@@ -15,92 +15,92 @@
"z": 0
},
"config": {
"api_key": "YOUR_API_KEY",
"api_host": "http://your-api-host:port",
"api_key": "<BIOYOND_API_KEY>",
"api_host": "http://<BIOYOND_HOST>:<BIOYOND_PORT>",
"material_type_mappings": {
"BIOYOND_PolymerStation_1FlaskCarrier": [
"烧杯",
"uuid-placeholder-flask"
"<UUID_FLASK_CARRIER_TYPE>"
],
"BIOYOND_PolymerStation_1BottleCarrier": [
"试剂瓶",
"uuid-placeholder-bottle"
"<UUID_BOTTLE_CARRIER_TYPE>"
],
"BIOYOND_PolymerStation_6StockCarrier": [
"分装板",
"uuid-placeholder-stock-6"
"<UUID_6STOCK_CARRIER_TYPE>"
],
"BIOYOND_PolymerStation_Liquid_Vial": [
"10%分装小瓶",
"uuid-placeholder-liquid-vial"
"<UUID_LIQUID_VIAL_TYPE>"
],
"BIOYOND_PolymerStation_Solid_Vial": [
"90%分装小瓶",
"uuid-placeholder-solid-vial"
"<UUID_SOLID_VIAL_TYPE>"
],
"BIOYOND_PolymerStation_8StockCarrier": [
"样品板",
"uuid-placeholder-stock-8"
"<UUID_8STOCK_CARRIER_TYPE>"
],
"BIOYOND_PolymerStation_Solid_Stock": [
"样品瓶",
"uuid-placeholder-solid-stock"
"<UUID_SOLID_STOCK_TYPE>"
]
},
"warehouse_mapping": {
"粉末堆栈": {
"uuid": "uuid-placeholder-powder-stack",
"uuid": "<UUID_POWDER_WAREHOUSE>",
"site_uuids": {
"A01": "uuid-placeholder-powder-A01",
"A02": "uuid-placeholder-powder-A02",
"A03": "uuid-placeholder-powder-A03",
"A04": "uuid-placeholder-powder-A04",
"B01": "uuid-placeholder-powder-B01",
"B02": "uuid-placeholder-powder-B02",
"B03": "uuid-placeholder-powder-B03",
"B04": "uuid-placeholder-powder-B04",
"C01": "uuid-placeholder-powder-C01",
"C02": "uuid-placeholder-powder-C02",
"C03": "uuid-placeholder-powder-C03",
"C04": "uuid-placeholder-powder-C04",
"D01": "uuid-placeholder-powder-D01",
"D02": "uuid-placeholder-powder-D02",
"D03": "uuid-placeholder-powder-D03",
"D04": "uuid-placeholder-powder-D04"
"A01": "<UUID_POWDER_A01>",
"A02": "<UUID_POWDER_A02>",
"A03": "<UUID_POWDER_A03>",
"A04": "<UUID_POWDER_A04>",
"B01": "<UUID_POWDER_B01>",
"B02": "<UUID_POWDER_B02>",
"B03": "<UUID_POWDER_B03>",
"B04": "<UUID_POWDER_B04>",
"C01": "<UUID_POWDER_C01>",
"C02": "<UUID_POWDER_C02>",
"C03": "<UUID_POWDER_C03>",
"C04": "<UUID_POWDER_C04>",
"D01": "<UUID_POWDER_D01>",
"D02": "<UUID_POWDER_D02>",
"D03": "<UUID_POWDER_D03>",
"D04": "<UUID_POWDER_D04>"
}
},
"溶液堆栈": {
"uuid": "uuid-placeholder-liquid-stack",
"uuid": "<UUID_SOLUTION_WAREHOUSE>",
"site_uuids": {
"A01": "uuid-placeholder-liquid-A01",
"A02": "uuid-placeholder-liquid-A02",
"A03": "uuid-placeholder-liquid-A03",
"A04": "uuid-placeholder-liquid-A04",
"B01": "uuid-placeholder-liquid-B01",
"B02": "uuid-placeholder-liquid-B02",
"B03": "uuid-placeholder-liquid-B03",
"B04": "uuid-placeholder-liquid-B04",
"C01": "uuid-placeholder-liquid-C01",
"C02": "uuid-placeholder-liquid-C02",
"C03": "uuid-placeholder-liquid-C03",
"C04": "uuid-placeholder-liquid-C04",
"D01": "uuid-placeholder-liquid-D01",
"D02": "uuid-placeholder-liquid-D02",
"D03": "uuid-placeholder-liquid-D03",
"D04": "uuid-placeholder-liquid-D04"
"A01": "<UUID_SOLUTION_A01>",
"A02": "<UUID_SOLUTION_A02>",
"A03": "<UUID_SOLUTION_A03>",
"A04": "<UUID_SOLUTION_A04>",
"B01": "<UUID_SOLUTION_B01>",
"B02": "<UUID_SOLUTION_B02>",
"B03": "<UUID_SOLUTION_B03>",
"B04": "<UUID_SOLUTION_B04>",
"C01": "<UUID_SOLUTION_C01>",
"C02": "<UUID_SOLUTION_C02>",
"C03": "<UUID_SOLUTION_C03>",
"C04": "<UUID_SOLUTION_C04>",
"D01": "<UUID_SOLUTION_D01>",
"D02": "<UUID_SOLUTION_D02>",
"D03": "<UUID_SOLUTION_D03>",
"D04": "<UUID_SOLUTION_D04>"
}
},
"试剂堆栈": {
"uuid": "uuid-placeholder-reagent-stack",
"uuid": "<UUID_REAGENT_WAREHOUSE>",
"site_uuids": {
"A01": "uuid-placeholder-reagent-A01",
"A02": "uuid-placeholder-reagent-A02",
"A03": "uuid-placeholder-reagent-A03",
"A04": "uuid-placeholder-reagent-A04",
"B01": "uuid-placeholder-reagent-B01",
"B02": "uuid-placeholder-reagent-B02",
"B03": "uuid-placeholder-reagent-B03",
"B04": "uuid-placeholder-reagent-B04"
"A01": "<UUID_REAGENT_A01>",
"A02": "<UUID_REAGENT_A02>",
"A03": "<UUID_REAGENT_A03>",
"A04": "<UUID_REAGENT_A04>",
"B01": "<UUID_REAGENT_B01>",
"B02": "<UUID_REAGENT_B02>",
"B03": "<UUID_REAGENT_B03>",
"B04": "<UUID_REAGENT_B04>"
}
}
},
@@ -156,4 +156,4 @@
"data": {}
}
]
}
}

View File

@@ -0,0 +1,29 @@
{
"nodes": [
{
"id": "Liquid_Sensor_1",
"name": "XKC Sensor",
"children": [],
"parent": null,
"type": "device",
"class": "sensor.xkc_rs485",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"config": {
"port": "/dev/tty.usbserial-3110",
"baudrate": 9600,
"device_id": 1,
"threshold": 300,
"timeout": 3.0
},
"data": {
"level": false,
"rssi": 0
}
}
],
"links": []
}

View File

@@ -13,7 +13,7 @@
"deck": {
"data": {
"_resource_child_name": "YB_Bioyond_Deck",
"_resource_type": "unilabos.resources.bioyond.decks:BioyondElectrolyteDeck"
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_YB_Deck"
}
},
"protocol_type": [],
@@ -103,14 +103,15 @@
"children": [],
"parent": "bioyond_cell_workstation",
"type": "deck",
"class": "BioyondElectrolyteDeck",
"class": "BIOYOND_YB_Deck",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"config": {
"type": "BioyondElectrolyteDeck",
"type": "BIOYOND_YB_Deck",
"setup": true,
"rotation": {
"x": 0,
"y": 0,

View File

@@ -0,0 +1,28 @@
{
"nodes": [
{
"id": "ZDT_Motor",
"name": "ZDT Motor",
"children": [],
"parent": null,
"type": "device",
"class": "motor.zdt_x42",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"config": {
"port": "/dev/tty.usbserial-3110",
"baudrate": 115200,
"device_id": 1,
"debug": true
},
"data": {
"position": 0,
"status": "idle"
}
}
],
"links": []
}

View File

@@ -188,7 +188,13 @@ class EnvironmentChecker:
"crcmod": "crcmod-plus",
}
self.special_packages = {"pylabrobot": "git+https://github.com/Xuwznln/pylabrobot.git"}
# 中文 locale 下走 Gitee 镜像,规避 GitHub 拉取失败
pylabrobot_url = (
"git+https://gitee.com/xuwznln/pylabrobot.git"
if _is_chinese_locale()
else "git+https://github.com/Xuwznln/pylabrobot.git"
)
self.special_packages = {"pylabrobot": pylabrobot_url}
self.version_requirements = {
"msgcenterpy": "0.1.8",

View File

@@ -1,385 +0,0 @@
import logging
import os
import platform
from datetime import datetime
import ctypes
import atexit
import inspect
from typing import Tuple, cast
# 添加TRACE级别到logging模块
TRACE_LEVEL = 5
logging.addLevelName(TRACE_LEVEL, "TRACE")
class CustomRecord:
custom_stack_info: Tuple[str, int, str, str]
# Windows颜色支持
if platform.system() == "Windows":
# 尝试启用Windows终端的ANSI支持
kernel32 = ctypes.windll.kernel32
# 获取STD_OUTPUT_HANDLE
STD_OUTPUT_HANDLE = -11
# 启用ENABLE_VIRTUAL_TERMINAL_PROCESSING
ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
# 获取当前控制台模式
handle = kernel32.GetStdHandle(STD_OUTPUT_HANDLE)
mode = ctypes.c_ulong()
kernel32.GetConsoleMode(handle, ctypes.byref(mode))
# 启用ANSI处理
kernel32.SetConsoleMode(handle, mode.value | ENABLE_VIRTUAL_TERMINAL_PROCESSING)
# 程序退出时恢复控制台设置
@atexit.register
def reset_console():
kernel32.SetConsoleMode(handle, mode.value)
# 定义不同日志级别的颜色
class ColoredFormatter(logging.Formatter):
"""自定义日志格式化器,支持颜色输出"""
# ANSI 颜色代码
COLORS = {
"RESET": "\033[0m", # 重置
"BOLD": "\033[1m", # 加粗
"GRAY": "\033[37m", # 灰色
"WHITE": "\033[97m", # 白色
"BLACK": "\033[30m", # 黑色
"TRACE_LEVEL": "\033[1;90m", # 加粗深灰色
"DEBUG_LEVEL": "\033[1;36m", # 加粗青色
"INFO_LEVEL": "\033[1;32m", # 加粗绿色
"WARNING_LEVEL": "\033[1;33m", # 加粗黄色
"ERROR_LEVEL": "\033[1;31m", # 加粗红色
"CRITICAL_LEVEL": "\033[1;35m", # 加粗紫色
"TRACE_TEXT": "\033[90m", # 深灰色
"DEBUG_TEXT": "\033[37m", # 灰色
"INFO_TEXT": "\033[97m", # 白色
"WARNING_TEXT": "\033[33m", # 黄色
"ERROR_TEXT": "\033[31m", # 红色
"CRITICAL_TEXT": "\033[35m", # 紫色
"DATE": "\033[37m", # 日期始终使用灰色
}
def __init__(self, use_colors=True):
super().__init__()
# 强制启用颜色
self.use_colors = use_colors
def format(self, record):
# 检查是否有自定义堆栈信息
if hasattr(record, "custom_stack_info") and record.custom_stack_info: # type: ignore
r = cast(CustomRecord, record)
frame_info = r.custom_stack_info
record.filename = frame_info[0]
record.lineno = frame_info[1]
record.funcName = frame_info[2]
if len(frame_info) > 3:
record.name = frame_info[3]
if not self.use_colors:
return self._format_basic(record)
level_color = self.COLORS.get(f"{record.levelname}_LEVEL", self.COLORS["WHITE"])
text_color = self.COLORS.get(f"{record.levelname}_TEXT", self.COLORS["WHITE"])
date_color = self.COLORS["DATE"]
reset = self.COLORS["RESET"]
# 日期格式
datetime_str = datetime.fromtimestamp(record.created).strftime("%y-%m-%d [%H:%M:%S,%f")[:-3] + "]"
# 模块和函数信息
filename = record.filename.replace(".py", "").split("\\")[-1] # 提取文件名(不含路径和扩展名)
if "/" in filename:
filename = filename.split("/")[-1]
module_path = f"{record.name}.{filename}"
func_line = f"{record.funcName}:{record.lineno}"
right_info = f" [{func_line}] [{module_path}]"
# 主要消息
main_msg = record.getMessage()
# 构建基本消息格式
formatted_message = (
f"{date_color}{datetime_str}{reset} "
f"{level_color}[{record.levelname}]{reset} "
f"{text_color}{main_msg}"
f"{date_color}{right_info}{reset}"
)
# 处理异常信息
if record.exc_info:
exc_text = self.formatException(record.exc_info)
if formatted_message[-1:] != "\n":
formatted_message = formatted_message + "\n"
formatted_message = formatted_message + text_color + exc_text + reset
elif record.stack_info:
if formatted_message[-1:] != "\n":
formatted_message = formatted_message + "\n"
formatted_message = formatted_message + text_color + self.formatStack(record.stack_info) + reset
return formatted_message
def _format_basic(self, record):
"""基本格式化,不包含颜色"""
datetime_str = datetime.fromtimestamp(record.created).strftime("%y-%m-%d [%H:%M:%S,%f")[:-3] + "]"
filename = record.filename.replace(".py", "").split("\\")[-1] # 提取文件名(不含路径和扩展名)
if "/" in filename:
filename = filename.split("/")[-1]
module_path = f"{record.name}.{filename}"
func_line = f"{record.funcName}:{record.lineno}"
right_info = f" [{func_line}] [{module_path}]"
formatted_message = f"{datetime_str} [{record.levelname}] {record.getMessage()}{right_info}"
if record.exc_info:
exc_text = self.formatException(record.exc_info)
if formatted_message[-1:] != "\n":
formatted_message = formatted_message + "\n"
formatted_message = formatted_message + exc_text
elif record.stack_info:
if formatted_message[-1:] != "\n":
formatted_message = formatted_message + "\n"
formatted_message = formatted_message + self.formatStack(record.stack_info)
return formatted_message
def formatException(self, exc_info):
"""重写异常格式化,确保异常信息保持正确的格式和颜色"""
# 获取标准的异常格式化文本
formatted_exc = super().formatException(exc_info)
return formatted_exc
# 配置日志处理器
def configure_logger(loglevel=None, working_dir=None):
"""配置日志记录器
Args:
loglevel: 日志级别,可以是字符串('TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'
或logging模块的常量如logging.DEBUG或TRACE_LEVEL
"""
# 获取根日志记录器
root_logger = logging.getLogger()
root_logger.setLevel(TRACE_LEVEL)
# 设置日志级别
numeric_level = logging.DEBUG
if loglevel is not None:
if isinstance(loglevel, str):
# 将字符串转换为logging级别
if loglevel.upper() == "TRACE":
numeric_level = TRACE_LEVEL
else:
numeric_level = getattr(logging, loglevel.upper(), None)
if not isinstance(numeric_level, int):
print(f"警告: 无效的日志级别 '{loglevel}',使用默认级别 DEBUG")
else:
numeric_level = loglevel
# 移除已存在的处理器
for handler in root_logger.handlers[:]:
root_logger.removeHandler(handler)
# 创建控制台处理器
console_handler = logging.StreamHandler()
console_handler.setLevel(numeric_level) # 使用与根记录器相同的级别
# 使用自定义的颜色格式化器
color_formatter = ColoredFormatter()
console_handler.setFormatter(color_formatter)
# 添加处理器到根日志记录器
root_logger.addHandler(console_handler)
# 如果指定了工作目录,添加文件处理器
if working_dir is not None:
logs_dir = os.path.join(working_dir, "logs")
os.makedirs(logs_dir, exist_ok=True)
# 生成日志文件名:日期 时间.log
log_filename = datetime.now().strftime("%Y-%m-%d %H-%M-%S") + ".log"
log_filepath = os.path.join(logs_dir, log_filename)
# 创建文件处理器
file_handler = logging.FileHandler(log_filepath, encoding="utf-8")
file_handler.setLevel(TRACE_LEVEL)
# 使用不带颜色的格式化器
file_formatter = ColoredFormatter(use_colors=False)
file_handler.setFormatter(file_formatter)
root_logger.addHandler(file_handler)
logging.getLogger("asyncio").setLevel(logging.INFO)
logging.getLogger("urllib3").setLevel(logging.INFO)
# 配置日志系统
configure_logger()
# 获取日志记录器
logger = logging.getLogger(__name__)
# 获取调用栈信息的工具函数
def _get_caller_info(stack_level=0) -> Tuple[str, int, str, str]:
"""
获取调用者的信息
Args:
stack_level: 堆栈回溯的级别0表示当前函数1表示调用者依此类推
Returns:
(filename, line_number, function_name, module_name) 元组
"""
# 堆栈级别需要加3:
# +1 因为这个函数本身占一层
# +1 因为日志函数(debug, info等)占一层
# +1 因为下面调用 inspect.stack() 也占一层
frame = inspect.currentframe()
try:
# 跳过适当的堆栈帧
for _ in range(stack_level + 3):
if frame and frame.f_back:
frame = frame.f_back
else:
break
if frame:
filename = frame.f_code.co_filename if frame.f_code else "unknown"
line_number = frame.f_lineno if hasattr(frame, "f_lineno") else 0
function_name = frame.f_code.co_name if frame.f_code else "unknown"
# 获取模块名称
module_name = "unknown"
if frame.f_globals and "__name__" in frame.f_globals:
module_name = frame.f_globals["__name__"].rsplit(".", 1)[0]
return (filename, line_number, function_name, module_name)
return ("unknown", 0, "unknown", "unknown")
finally:
del frame # 避免循环引用
# 便捷日志记录函数
def debug(msg, *args, stack_level=0, **kwargs):
"""
记录DEBUG级别日志
Args:
msg: 日志消息
stack_level: 堆栈回溯级别,用于定位日志的实际调用位置
*args, **kwargs: 传递给logger.debug的其他参数
"""
# 获取调用者信息
if stack_level > 0:
caller_info = _get_caller_info(stack_level)
extra = kwargs.get("extra", {})
extra["custom_stack_info"] = caller_info
kwargs["extra"] = extra
logger.debug(msg, *args, **kwargs)
def info(msg, *args, stack_level=0, **kwargs):
"""
记录INFO级别日志
Args:
msg: 日志消息
stack_level: 堆栈回溯级别,用于定位日志的实际调用位置
*args, **kwargs: 传递给logger.info的其他参数
"""
if stack_level > 0:
caller_info = _get_caller_info(stack_level)
extra = kwargs.get("extra", {})
extra["custom_stack_info"] = caller_info
kwargs["extra"] = extra
logger.info(msg, *args, **kwargs)
def warning(msg, *args, stack_level=0, **kwargs):
"""
记录WARNING级别日志
Args:
msg: 日志消息
stack_level: 堆栈回溯级别,用于定位日志的实际调用位置
*args, **kwargs: 传递给logger.warning的其他参数
"""
if stack_level > 0:
caller_info = _get_caller_info(stack_level)
extra = kwargs.get("extra", {})
extra["custom_stack_info"] = caller_info
kwargs["extra"] = extra
logger.warning(msg, *args, **kwargs)
def error(msg, *args, stack_level=0, **kwargs):
"""
记录ERROR级别日志
Args:
msg: 日志消息
stack_level: 堆栈回溯级别,用于定位日志的实际调用位置
*args, **kwargs: 传递给logger.error的其他参数
"""
if stack_level > 0:
caller_info = _get_caller_info(stack_level)
extra = kwargs.get("extra", {})
extra["custom_stack_info"] = caller_info
kwargs["extra"] = extra
logger.error(msg, *args, **kwargs)
def critical(msg, *args, stack_level=0, **kwargs):
"""
记录CRITICAL级别日志
Args:
msg: 日志消息
stack_level: 堆栈回溯级别,用于定位日志的实际调用位置
*args, **kwargs: 传递给logger.critical的其他参数
"""
if stack_level > 0:
caller_info = _get_caller_info(stack_level)
extra = kwargs.get("extra", {})
extra["custom_stack_info"] = caller_info
kwargs["extra"] = extra
logger.critical(msg, *args, **kwargs)
def trace(msg, *args, stack_level=0, **kwargs):
"""
记录TRACE级别日志比DEBUG级别更低
Args:
msg: 日志消息
stack_level: 堆栈回溯级别,用于定位日志的实际调用位置
*args, **kwargs: 传递给logger.log的其他参数
"""
if stack_level > 0:
caller_info = _get_caller_info(stack_level)
extra = kwargs.get("extra", {})
extra["custom_stack_info"] = caller_info
kwargs["extra"] = extra
logger.log(TRACE_LEVEL, msg, *args, **kwargs)
logger.trace = trace
# 测试日志输出(如果直接运行此文件)
if __name__ == "__main__":
print("测试不同日志级别的颜色输出:")
trace("这是一条跟踪日志 (TRACE级别显示为深灰色其他文本也为深灰色)")
debug("这是一条调试日志 (DEBUG级别显示为蓝色其他文本为灰色)")
info("这是一条信息日志 (INFO级别显示为绿色其他文本为白色)")
warning("这是一条警告日志 (WARNING级别显示为黄色其他文本也为黄色)")
error("这是一条错误日志 (ERROR级别显示为红色其他文本也为红色)")
critical("这是一条严重错误日志 (CRITICAL级别显示为紫色其他文本也为紫色)")
# 测试异常输出
try:
1 / 0
except Exception as e:
error(f"发生错误: {e}", exc_info=True)

View File

@@ -191,23 +191,9 @@ def configure_logger(loglevel=None, working_dir=None):
# 添加处理器到根日志记录器
root_logger.addHandler(console_handler)
# 降低第三方库的日志级别,避免过多输出
# pymodbus 库的日志太详细,设置为 WARNING
logging.getLogger('pymodbus').setLevel(logging.WARNING)
logging.getLogger('pymodbus.logging').setLevel(logging.WARNING)
logging.getLogger('pymodbus.logging.base').setLevel(logging.WARNING)
logging.getLogger('pymodbus.logging.decoders').setLevel(logging.WARNING)
# websockets 库的日志输出较多,设置为 WARNING
logging.getLogger('websockets').setLevel(logging.WARNING)
logging.getLogger('websockets.client').setLevel(logging.WARNING)
logging.getLogger('websockets.server').setLevel(logging.WARNING)
# ROS 节点的状态更新日志过于频繁,设置为 INFO
logging.getLogger('unilabos.ros.nodes.presets.host_node').setLevel(logging.INFO)
# 如果指定了工作目录,添加文件处理器
log_filepath = None
if working_dir is not None:
logs_dir = os.path.join(working_dir, "logs")
os.makedirs(logs_dir, exist_ok=True)
@@ -228,6 +214,7 @@ def configure_logger(loglevel=None, working_dir=None):
logging.getLogger("asyncio").setLevel(logging.INFO)
logging.getLogger("urllib3").setLevel(logging.INFO)
return log_filepath
# 配置日志系统

View File

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