Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
8019f83d6a ci(deps): bump actions/configure-pages from 5 to 6
Bumps [actions/configure-pages](https://github.com/actions/configure-pages) from 5 to 6.
- [Release notes](https://github.com/actions/configure-pages/releases)
- [Commits](https://github.com/actions/configure-pages/compare/v5...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 06:16:53 +00:00
57 changed files with 2548 additions and 5892 deletions

View File

@@ -1,6 +1,6 @@
---
name: batch-submit-experiment
description: Batch submit experiments (notebooks) to Uni-Lab platform — list workflows, generate node_params from registry schemas, submit multiple rounds, check notebook status. Use when the user wants to submit experiments, create notebooks, batch run workflows, check experiment status, or mentions 提交实验/批量实验/notebook/实验轮次/实验状态.
description: Batch submit experiments (notebooks) to Uni-Lab platform — list workflows, generate node_params from registry schemas, submit multiple rounds. Use when the user wants to submit experiments, create notebooks, batch run workflows, or mentions 提交实验/批量实验/notebook/实验轮次.
---
# 批量提交实验指南
@@ -59,7 +59,7 @@ AUTH="Authorization: Lab <上面命令输出的 token>"
### 4. workflow_uuid目标工作流
用户需要提供要提交的 workflow UUID。如果用户不确定通过 API #3 列出可用 workflow 供选择。
用户需要提供要提交的 workflow UUID。如果用户不确定通过 API #2 列出可用 workflow 供选择。
**四项全部就绪后才可开始。**
@@ -68,9 +68,8 @@ AUTH="Authorization: Lab <上面命令输出的 token>"
在整个对话过程中agent 需要记住以下状态,避免重复询问用户:
- `lab_uuid` — 实验室 UUID首次通过 API #1 自动获取,**不需要问用户**
- `project_uuid` — 项目 UUID通过 API #2 列出项目列表,**让用户选择**
- `workflow_uuid` — 工作流 UUID用户提供或从列表选择
- `workflow_nodes` — workflow 中各 action 节点的 uuid、设备 ID、动作名从 API #4 获取)
- `workflow_nodes` — workflow 中各 action 节点的 uuid、设备 ID、动作名从 API #3 获取)
## 请求约定
@@ -98,17 +97,7 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
记住 `data.uuid``lab_uuid`
### 2. 列出实验室项目(让用户选择项目)
```bash
curl -s -X GET "$BASE/api/v1/lab/project/list?lab_uuid=$lab_uuid" -H "$AUTH"
```
返回项目列表,展示给用户选择。列出每个项目的 `uuid``name`
用户**必须**选择一个项目,记住 `project_uuid`,后续创建 notebook 时需要提供。
### 3. 列出可用 workflow
### 2. 列出可用 workflow
```bash
curl -s -X GET "$BASE/api/v1/lab/workflow/workflows?page=1&page_size=20&lab_uuid=$lab_uuid" -H "$AUTH"
@@ -116,7 +105,7 @@ curl -s -X GET "$BASE/api/v1/lab/workflow/workflows?page=1&page_size=20&lab_uuid
返回 workflow 列表,展示给用户选择。列出每个 workflow 的 `uuid``name`
### 4. 获取 workflow 模板详情
### 3. 获取 workflow 模板详情
```bash
curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH"
@@ -130,7 +119,7 @@ curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$A
> **注意**:此 API 返回格式可能因版本不同而有差异。首次调用时,先打印完整响应分析结构,再提取节点信息。常见的节点字段路径为 `data.nodes[]` 或 `data.workflow_nodes[]`。
### 5. 提交实验(创建 notebook
### 4. 提交实验(创建 notebook
```bash
curl -s -X POST "$BASE/api/v1/lab/notebook" \
@@ -143,7 +132,6 @@ curl -s -X POST "$BASE/api/v1/lab/notebook" \
```json
{
"lab_uuid": "<lab_uuid>",
"project_uuid": "<project_uuid>",
"workflow_uuid": "<workflow_uuid>",
"name": "<实验名称>",
"node_params": [
@@ -171,16 +159,6 @@ curl -s -X POST "$BASE/api/v1/lab/notebook" \
> **注意**`sample_uuids` 必须是 **UUID 数组**`[]uuid.UUID`),不是字符串。无样品时传空数组 `[]`。
### 6. 查询 notebook 状态
提交成功后,使用返回的 notebook UUID 查询执行状态:
```bash
curl -s -X GET "$BASE/api/v1/lab/notebook/status?uuid=$notebook_uuid" -H "$AUTH"
```
提交后应**立即查询一次**状态,确认 notebook 已被正确接收并开始调度。
---
## Notebook 请求体详解
@@ -203,7 +181,7 @@ curl -s -X GET "$BASE/api/v1/lab/notebook/status?uuid=$notebook_uuid" -H "$AUTH"
| 字段 | 类型 | 说明 |
|------|------|------|
| `node_uuid` | string | workflow 模板中的节点 UUID从 API #4 获取) |
| `node_uuid` | string | workflow 模板中的节点 UUID从 API #3 获取) |
| `param` | object | 动作参数(根据本地注册表 schema 填写) |
| `sample_params` | array | 样品相关参数(液体名、体积等) |
@@ -244,7 +222,7 @@ python scripts/gen_notebook_params.py \
如果脚本不可用或注册表不存在:
1. 调用 API #4 获取 workflow 详情
1. 调用 API #3 获取 workflow 详情
2. 找到每个 action 节点的 `node_uuid`
3. 在本地注册表中查找对应设备的 `action_value_mappings`
```
@@ -297,15 +275,13 @@ Task Progress:
- [ ] Step 1: 确认 ak/sk → 生成 AUTH token
- [ ] Step 2: 确认 --addr → 设置 BASE URL
- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid
- [ ] Step 4: GET /lab/project/list → 列出项目,让用户选择 → 获取 project_uuid
- [ ] Step 5: 确认 workflow_uuid用户提供或从 GET #3 列表选择)
- [ ] Step 6: GET workflow detail (#4) → 提取各节点 uuid、设备ID、动作名
- [ ] Step 7: 定位本地注册表 req_device_registry_upload.json
- [ ] Step 8: 运行 gen_notebook_params.py 或手动匹配 → 生成 node_params 模板
- [ ] Step 9: 引导用户填写每轮的参数sample_uuids、param、sample_params
- [ ] Step 10: 构建完整请求体(含 project_uuid→ POST /lab/notebook 提交
- [ ] Step 11: 检查返回结果,记录 notebook UUID
- [ ] Step 12: GET /lab/notebook/status → 查询 notebook 状态,确认已调度
- [ ] Step 4: 确认 workflow_uuid用户提供或从 GET #2 列表选择)
- [ ] Step 5: GET workflow detail (#3) → 提取各节点 uuid、设备ID、动作名
- [ ] Step 6: 定位本地注册表 req_device_registry_upload.json
- [ ] Step 7: 运行 gen_notebook_params.py 或手动匹配 → 生成 node_params 模板
- [ ] Step 8: 引导用户填写每轮的参数sample_uuids、param、sample_params
- [ ] Step 9: 构建完整请求体 → POST /lab/notebook 提交
- [ ] Step 10: 检查返回结果,确认提交成功
```
---

View File

@@ -265,7 +265,6 @@ def generate_template(nodes, registry_index, rounds):
return {
"lab_uuid": "$TODO_LAB_UUID",
"project_uuid": "$TODO_PROJECT_UUID",
"workflow_uuid": "$TODO_WORKFLOW_UUID",
"name": "$TODO_EXPERIMENT_NAME",
"node_params": node_params,

View File

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

View File

@@ -84,7 +84,7 @@ jobs:
- name: Setup Pages
id: pages
uses: actions/configure-pages@v5
uses: actions/configure-pages@v6
if: |
github.event.workflow_run.head_branch == 'main' ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true')

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 个资源全部无法放置到对应仓库位,前端不显示。

View File

@@ -621,8 +621,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

@@ -80,20 +80,19 @@ class HTTPClient:
f.write(json.dumps(payload, indent=4))
# 从序列化数据中提取所有节点的UUID保存旧UUID
old_uuids = {n.res_content.uuid: n for n in resources.all_nodes}
nodes_info = [x for xs in resources.dump() for x in xs]
if not self.initialized or first_add:
self.initialized = True
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
response = requests.post(
f"{self.remote_addr}/edge/material",
json={"nodes": nodes_info, "mount_uuid": mount_uuid},
json={"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid},
headers={"Authorization": f"Lab {self.auth}"},
timeout=60,
)
else:
response = requests.put(
f"{self.remote_addr}/edge/material",
json={"nodes": nodes_info, "mount_uuid": mount_uuid},
json={"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid},
headers={"Authorization": f"Lab {self.auth}"},
timeout=10,
)
@@ -112,7 +111,6 @@ class HTTPClient:
uuid_mapping[i["uuid"]] = i["cloud_uuid"]
else:
logger.error(f"添加物料失败: {response.text}")
logger.trace(f"添加物料失败: {nodes_info}")
for u, n in old_uuids.items():
if u in uuid_mapping:
n.res_content.uuid = uuid_mapping[u]

View File

@@ -1113,7 +1113,7 @@ class MessageProcessor:
"task_id": task_id,
"job_id": job_id,
"free": free,
"need_more": need_more + 1,
"need_more": need_more,
},
}
@@ -1253,7 +1253,7 @@ class QueueProcessor:
"task_id": job_info.task_id,
"job_id": job_info.job_id,
"free": False,
"need_more": 10 + 1,
"need_more": 10,
},
}
self.message_processor.send_message(message)
@@ -1286,7 +1286,7 @@ class QueueProcessor:
"task_id": job_info.task_id,
"job_id": job_info.job_id,
"free": False,
"need_more": 10 + 1,
"need_more": 10,
},
}
success = self.message_processor.send_message(message)
@@ -1369,10 +1369,6 @@ class WebSocketClient(BaseCommunicationClient):
self.message_processor = MessageProcessor(self.websocket_url, self.send_queue, self.device_manager)
self.queue_processor = QueueProcessor(self.device_manager, self.message_processor)
# running状态debounce缓存: {job_id: (last_send_timestamp, last_feedback_data)}
self._job_running_last_sent: Dict[str, tuple] = {}
self._job_running_debounce_interval: float = 10.0 # 秒
# 设置相互引用
self.message_processor.set_queue_processor(self.queue_processor)
self.message_processor.set_websocket_client(self)
@@ -1472,32 +1468,22 @@ class WebSocketClient(BaseCommunicationClient):
logger.debug(f"[WebSocketClient] Not connected, cannot publish job status for job_id: {item.job_id}")
return
job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name)
# 拦截最终结果状态,与原版本逻辑一致
if status in ["success", "failed"]:
self._job_running_last_sent.pop(item.job_id, None)
host_node = HostNode.get_instance(0)
if host_node:
# 从HostNode的device_action_status中移除job_id
try:
host_node._device_action_status[item.device_action_key].job_ids.pop(item.job_id, None)
except (KeyError, AttributeError):
logger.warning(f"[WebSocketClient] Failed to remove job {item.job_id} from HostNode status")
# logger.debug(f"[WebSocketClient] Intercepting final status for job_id: {item.job_id} - {status}")
# 通知队列处理器job完成包括timeout的job
self.queue_processor.handle_job_completed(item.job_id, status)
# running状态按job_id做debounce内容变化时仍然上报
if status == "running":
now = time.time()
cached = self._job_running_last_sent.get(item.job_id)
if cached is not None:
last_ts, last_data = cached
if now - last_ts < self._job_running_debounce_interval and last_data == feedback_data:
logger.trace(f"[WebSocketClient] Job status debounced (skip): {job_log} - {status}")
return
self._job_running_last_sent[item.job_id] = (now, feedback_data)
# 发送job状态消息
message = {
"action": "job_status",
"data": {
@@ -1513,6 +1499,7 @@ class WebSocketClient(BaseCommunicationClient):
}
self.message_processor.send_message(message)
job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name)
logger.trace(f"[WebSocketClient] Job status published: {job_log} - {status}")
def send_ping(self, ping_id: str, timestamp: float) -> None:

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,
@@ -32,4 +32,4 @@
}
],
"links": []
}
}

View File

@@ -305,12 +305,11 @@ class NewareBatteryTestSystem:
ascii_lowercase = 'abcdefghijklmnopqrstuvwxyz'
ascii_uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
LETTERS = ascii_uppercase + ascii_lowercase
DEFAULT_MACHINE_IDS = [1, 2, 3, 4, 5, 6, 86]
def __init__(self,
ip: str = None,
port: int = None,
machine_ids: Optional[List[int]] = None,
machine_id: int = 1,
devtype: str = None,
timeout: int = None,
@@ -327,18 +326,16 @@ class NewareBatteryTestSystem:
Args:
ip: TCP服务器IP地址
port: TCP端口
machine_ids: 设备ID列表
devtype: 设备类型标识
timeout: 通信超时时间(秒)
machine_id: 机器ID
size_x, size_y, size_z: 设备物理尺寸
oss_upload_enabled: 是否启用OSS上传功能默认False
oss_prefix: OSS对象路径前缀默认"neware_backup"
"""
self.ip = ip or self.BTS_IP
self.port = port or self.BTS_PORT
self.machine_ids = machine_ids
self.display_device_ids = self._resolve_display_device_ids()
self.primary_device_id = self.display_device_ids[0]
self.machine_id = machine_id
self.devtype = devtype or self.DEVTYPE
self.timeout = timeout or self.TIMEOUT
@@ -355,12 +352,6 @@ class NewareBatteryTestSystem:
self._cached_status = {}
self._last_backup_dir = None # 记录最近一次的 backup_dir供上传使用
self._ros_node: Optional[ROS2WorkstationNode] = None # ROS节点引用由框架设置
self._channels = self._build_channel_map()
def _resolve_display_device_ids(self) -> List[int]:
if self.machine_ids:
return [int(devid) for devid in self.machine_ids]
return self.DEFAULT_MACHINE_IDS.copy()
def post_init(self, ros_node):
@@ -385,72 +376,27 @@ class NewareBatteryTestSystem:
ros_node.lab_logger().error(f"新威电池测试系统初始化失败: {e}")
# 不抛出异常,允许节点继续运行,后续可以重试连接
def _plate_name(self, devid: int, plate_num: int) -> str:
return f"{devid}_P{plate_num}"
def _plate_resource_key(self, devid: int, plate_num: int, row_idx: int, col_idx: int) -> str:
return f"{self._plate_name(devid, plate_num)}_{self.LETTERS[row_idx]}{col_idx + 1}"
def _get_plate_resource(self, devid: int, plate_num: int, row_idx: int, col_idx: int):
possible_names = [
f"{self._plate_name(devid, plate_num)}_batterytestposition_{col_idx}_{row_idx}",
f"{self._plate_name(devid, plate_num)}_{self.LETTERS[row_idx]}{col_idx + 1}",
f"{self._plate_name(devid, plate_num)}_{self.LETTERS[row_idx].lower()}{col_idx + 1}",
f"P{plate_num}_batterytestposition_{col_idx}_{row_idx}",
f"P{plate_num}_{self.LETTERS[row_idx]}{col_idx + 1}",
f"P{plate_num}_{self.LETTERS[row_idx].lower()}{col_idx + 1}",
]
for name in possible_names:
if name in self.station_resources:
return self.station_resources[name], name, possible_names
return None, None, possible_names
def _setup_material_management(self):
"""设置物料管理系统"""
deck_main = Deck(
name="ADeckName",
size_x=2200,
size_y=2800,
size_z=100,
origin=Coordinate(2000, 2000, 0)
# 第1盘5行8列网格 (A1-E8) - 5行对应subdevid 1-58列对应chlid 1-8
# 先给物料设置一个最大的Deck并设置其在空间中的位置
deck_main = Deck("ADeckName", 2000, 1800, 100, origin=Coordinate(2000,2000,0))
plate1_resources: Dict[str, BatteryTestPosition] = create_ordered_items_2d(
BatteryTestPosition,
num_items_x=8, # 8列对应chlid 1-8
num_items_y=5, # 5行对应subdevid 1-5即A-E
dx=10,
dy=10,
dz=0,
item_dx=65,
item_dy=65
)
self.station_resources = {}
self.station_resources_by_plate = {}
for row_idx, devid in enumerate(self.display_device_ids):
for plate_num in (1, 2):
plate_resources: Dict[str, BatteryTestPosition] = create_ordered_items_2d(
BatteryTestPosition,
num_items_x=8,
num_items_y=5,
dx=10,
dy=10,
dz=0,
item_dx=65,
item_dy=65
)
plate_name = self._plate_name(devid, plate_num)
plate = Plate(
name=plate_name,
size_x=400,
size_y=300,
size_z=50,
ordered_items=plate_resources
)
location_x = 0 if plate_num == 1 else 450
location_y = row_idx * 350
deck_main.assign_child_resource(plate, location=Coordinate(location_x, location_y, 0))
plate_key = (devid, plate_num)
self.station_resources_by_plate[plate_key] = {}
for name, resource in plate_resources.items():
new_name = f"{plate_name}_{name}"
self.station_resources_by_plate[plate_key][new_name] = resource
self.station_resources[new_name] = resource
self.station_resources_plate1 = self.station_resources_by_plate.get((self.primary_device_id, 1), {})
self.station_resources_plate2 = self.station_resources_by_plate.get((self.primary_device_id, 2), {})
plate1 = Plate("P1", 400, 300, 50, ordered_items=plate1_resources)
deck_main.assign_child_resource(plate1, location=Coordinate(0, 0, 0))
# 只有在真实ROS环境下才调用update_resource
if hasattr(self._ros_node, 'update_resource') and callable(getattr(self._ros_node, 'update_resource')):
try:
ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
@@ -459,6 +405,40 @@ class NewareBatteryTestSystem:
except Exception as e:
if hasattr(self._ros_node, 'lab_logger'):
self._ros_node.lab_logger().warning(f"更新资源失败: {e}")
# 在非ROS环境下忽略此错误
# 为第1盘资源添加P1_前缀
self.station_resources_plate1 = {}
for name, resource in plate1_resources.items():
new_name = f"P1_{name}"
self.station_resources_plate1[new_name] = resource
# 第2盘5行8列网格 (A1-E8)在Z轴上偏移 - 5行对应subdevid 6-108列对应chlid 1-8
plate2_resources = create_ordered_items_2d(
BatteryTestPosition,
num_items_x=8, # 8列对应chlid 1-8
num_items_y=5, # 5行对应subdevid 6-10即A-E
dx=10,
dy=10,
dz=0,
item_dx=65,
item_dy=65
)
plate2 = Plate("P2", 400, 300, 50, ordered_items=plate2_resources)
deck_main.assign_child_resource(plate2, location=Coordinate(0, 350, 0))
# 为第2盘资源添加P2_前缀
self.station_resources_plate2 = {}
for name, resource in plate2_resources.items():
new_name = f"P2_{name}"
self.station_resources_plate2[new_name] = resource
# 合并两盘资源为统一的station_resources
self.station_resources = {}
self.station_resources.update(self.station_resources_plate1)
self.station_resources.update(self.station_resources_plate2)
# ========================
# 核心属性Uni-Lab标准
@@ -489,16 +469,16 @@ class NewareBatteryTestSystem:
status_map = self._query_all_channels()
status_processed = {} if not status_map else self._group_by_devid(status_map)
# 返回主设备数据,如果主设备没有匹配数据则回退到首个可用设备
status_current_machine = status_processed.get(self.primary_device_id, {})
# 修复数据过滤逻辑如果machine_id对应的数据不存在尝试使用第一个可用设备数据
status_current_machine = status_processed.get(self.machine_id, {})
if not status_current_machine and status_processed:
# 如果主设备没有匹配到数据,使用第一个可用的设备数据
# 如果machine_id没有匹配到数据,使用第一个可用的设备数据
first_devid = next(iter(status_processed.keys()))
status_current_machine = status_processed[first_devid]
if self._ros_node:
self._ros_node.lab_logger().warning(
f"主设备ID {self.primary_device_id} 没有匹配到数据使用设备ID {first_devid} 的数据"
f"machine_id {self.machine_id} 没有匹配到数据使用设备ID {first_devid} 的数据"
)
# 确保有默认的数据结构
@@ -508,57 +488,139 @@ class NewareBatteryTestSystem:
"subunits": {}
}
self._update_plate_resources(status_processed)
# 确保subunits存在
subunits = status_current_machine.get("subunits", {})
# 处理2盘电池的状态映射
self._update_plate_resources(subunits)
return status_current_machine
def _update_plate_resources(self, status_processed: Dict[int, Dict]):
"""更新7台设备共14盘电池资源的状态"""
for devid in self.display_device_ids:
machine_data = status_processed.get(devid, {})
subunits = machine_data.get("subunits", {})
for plate_num, subdev_start, subdev_end in ((1, 1, 5), (2, 6, 10)):
for subdev_id in range(subdev_start, subdev_end + 1):
status_row = subunits.get(subdev_id, {})
for chl_id in range(1, 9):
try:
col_idx = chl_id - 1
row_idx = subdev_id - subdev_start
r, resource_name, possible_names = self._get_plate_resource(
devid=devid,
plate_num=plate_num,
row_idx=row_idx,
col_idx=col_idx
def _update_plate_resources(self, subunits: Dict):
"""更新盘电池资源的状态"""
# 第1盘subdevid 1-5 映射到 8列5行网格 (列0-7, 行0-4)
for subdev_id in range(1, 6): # subdevid 1-5
status_row = subunits.get(subdev_id, {})
for chl_id in range(1, 9): # chlid 1-8
try:
# 根据用户描述:第一个是(0,0),最后一个是(7,4)
# 说明是8列5行列从0开始行从0开始
col_idx = (chl_id - 1) # 0-7 (chlid 1-8 -> 列0-7)
row_idx = (subdev_id - 1) # 0-4 (subdevid 1-5 -> 行0-4)
# 尝试多种可能的资源命名格式
possible_names = [
f"P1_batterytestposition_{col_idx}_{row_idx}", # 用户提到的格式
f"P1_{self.LETTERS[row_idx]}{col_idx + 1}", # 原有的A1-E8格式
f"P1_{self.LETTERS[row_idx].lower()}{col_idx + 1}", # 小写字母格式
]
r = None
resource_name = None
for name in possible_names:
if name in self.station_resources:
r = self.station_resources[name]
resource_name = name
break
if r:
status_channel = status_row.get(chl_id, {})
metrics = status_channel.get("metrics", {})
# 构建BatteryTestPosition状态数据移除capacity和energy
channel_state = {
# 基本测量数据
"voltage": metrics.get("voltage_V", 0.0),
"current": metrics.get("current_A", 0.0),
"time": metrics.get("totaltime_s", 0.0),
# 状态信息
"status": status_channel.get("state", "unknown"),
"color": status_channel.get("color", self.STATUS_COLOR["unknown"]),
# 通道名称标识
"Channel_Name": f"{self.machine_id}-{subdev_id}-{chl_id}",
}
r.load_state(channel_state)
# 调试信息
if self._ros_node and hasattr(self._ros_node, 'lab_logger'):
self._ros_node.lab_logger().debug(
f"更新P1资源状态: {resource_name} <- subdev{subdev_id}/chl{chl_id} "
f"状态:{channel_state['status']}"
)
if r is None:
if self._ros_node and hasattr(self._ros_node, 'lab_logger'):
self._ros_node.lab_logger().debug(
f"{devid}_P{plate_num}未找到资源: subdev{subdev_id}/chl{chl_id} -> "
f"尝试的名称: {possible_names}"
)
continue
status_channel = status_row.get(chl_id, {})
metrics = status_channel.get("metrics", {})
channel_state = {
"voltage": metrics.get("voltage_V", 0.0),
"current": metrics.get("current_A", 0.0),
"time": metrics.get("totaltime_s", 0.0),
"status": status_channel.get("state", "unknown"),
"color": status_channel.get("color", self.STATUS_COLOR["unknown"]),
"Channel_Name": f"{devid}-{subdev_id}-{chl_id}",
}
r.load_state(channel_state)
if self._ros_node and hasattr(self._ros_node, 'lab_logger'):
self._ros_node.lab_logger().debug(
f"更新{devid}_P{plate_num}资源状态: {resource_name} <- "
f"subdev{subdev_id}/chl{chl_id} 状态:{channel_state['status']}"
)
except (KeyError, IndexError) as e:
if self._ros_node and hasattr(self._ros_node, 'lab_logger'):
self._ros_node.lab_logger().debug(
f"{devid}_P{plate_num}映射错误: subdev{subdev_id}/chl{chl_id} - {e}"
)
continue
else:
# 如果找不到资源,记录调试信息
if self._ros_node and hasattr(self._ros_node, 'lab_logger'):
self._ros_node.lab_logger().debug(
f"P1未找到资源: subdev{subdev_id}/chl{chl_id} -> 尝试的名称: {possible_names}"
)
except (KeyError, IndexError) as e:
if self._ros_node and hasattr(self._ros_node, 'lab_logger'):
self._ros_node.lab_logger().debug(f"P1映射错误: subdev{subdev_id}/chl{chl_id} - {e}")
continue
# 第2盘subdevid 6-10 映射到 8列5行网格 (列0-7, 行0-4)
for subdev_id in range(6, 11): # subdevid 6-10
status_row = subunits.get(subdev_id, {})
for chl_id in range(1, 9): # chlid 1-8
try:
col_idx = (chl_id - 1) # 0-7 (chlid 1-8 -> 列0-7)
row_idx = (subdev_id - 6) # 0-4 (subdevid 6-10 -> 行0-4)
# 尝试多种可能的资源命名格式
possible_names = [
f"P2_batterytestposition_{col_idx}_{row_idx}", # 用户提到的格式
f"P2_{self.LETTERS[row_idx]}{col_idx + 1}", # 原有的A1-E8格式
f"P2_{self.LETTERS[row_idx].lower()}{col_idx + 1}", # 小写字母格式
]
r = None
resource_name = None
for name in possible_names:
if name in self.station_resources:
r = self.station_resources[name]
resource_name = name
break
if r:
status_channel = status_row.get(chl_id, {})
metrics = status_channel.get("metrics", {})
# 构建BatteryTestPosition状态数据移除capacity和energy
channel_state = {
# 基本测量数据
"voltage": metrics.get("voltage_V", 0.0),
"current": metrics.get("current_A", 0.0),
"time": metrics.get("totaltime_s", 0.0),
# 状态信息
"status": status_channel.get("state", "unknown"),
"color": status_channel.get("color", self.STATUS_COLOR["unknown"]),
# 通道名称标识
"Channel_Name": f"{self.machine_id}-{subdev_id}-{chl_id}",
}
r.load_state(channel_state)
# 调试信息
if self._ros_node and hasattr(self._ros_node, 'lab_logger'):
self._ros_node.lab_logger().debug(
f"更新P2资源状态: {resource_name} <- subdev{subdev_id}/chl{chl_id} "
f"状态:{channel_state['status']}"
)
else:
# 如果找不到资源,记录调试信息
if self._ros_node and hasattr(self._ros_node, 'lab_logger'):
self._ros_node.lab_logger().debug(
f"P2未找到资源: subdev{subdev_id}/chl{chl_id} -> 尝试的名称: {possible_names}"
)
except (KeyError, IndexError) as e:
if self._ros_node and hasattr(self._ros_node, 'lab_logger'):
self._ros_node.lab_logger().debug(f"P2映射错误: subdev{subdev_id}/chl{chl_id} - {e}")
continue
ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
"resources": list(self.station_resources.values())
})
@@ -578,22 +640,6 @@ class NewareBatteryTestSystem:
"""获取总通道数"""
return len(self._channels)
def _build_device_summary_dict(self) -> dict:
if not hasattr(self, '_channels') or not self._channels:
self._channels = self._build_channel_map()
channel_count_by_devid = {}
for channel in self._channels:
devid = channel.devid
channel_count_by_devid[devid] = channel_count_by_devid.get(devid, 0) + 1
return {
"channel_count_by_devid": channel_count_by_devid,
"display_device_ids": self.display_device_ids,
"total_channels": len(self._channels)
}
def device_summary(self) -> str:
return json.dumps(self._build_device_summary_dict(), ensure_ascii=False)
# ========================
# 设备动作方法Uni-Lab标准
# ========================
@@ -918,7 +964,6 @@ class NewareBatteryTestSystem:
'SIGR_LI': gen_mod.xml_SiGr_Li_Step,
'811_SIGR': gen_mod.xml_811_SiGr,
'811_CU_AGING': gen_mod.xml_811_Cu_aging,
'ZQXNLRMO':gen_mod.xml_ZQXNLRMO,
}
if key not in fmap:
raise ValueError(f"未定义电池体系映射: {key}")
@@ -1096,7 +1141,16 @@ class NewareBatteryTestSystem:
dict: ROS2动作结果格式 {"return_info": str, "success": bool}
"""
try:
result_info = self.device_summary()
# 确保_channels已初始化
if not hasattr(self, '_channels') or not self._channels:
self._channels = self._build_channel_map()
summary = {}
for channel in self._channels:
devid = channel.devid
summary[devid] = summary.get(devid, 0) + 1
result_info = json.dumps(summary, ensure_ascii=False)
success_msg = f"设备摘要统计: {result_info}"
if self._ros_node:
self._ros_node.lab_logger().info(success_msg)

View File

@@ -1,49 +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") -> str:
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="0" 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"):
xml_cmd = build_start_command(devid=devid, subdevid=subdevid, chlid=chlid, CoinID=CoinID, recipe_path=recipe_path, backup_dir=backup_dir)
#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

@@ -258,7 +258,7 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
logger.info(f"[同步→Bioyond] 物料不存在于 Bioyond将创建新物料并入库")
# 第1步从配置中获取仓库配置
warehouse_mapping = self.workstation.bioyond_config.get("warehouse_mapping", {})
warehouse_mapping = self.bioyond_config.get("warehouse_mapping", {})
# 确定目标仓库名称
parent_name = None
@@ -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

@@ -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,44 +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
else:
logger.warning("未接收到配方信息CSV将不包含配方字段")
self._formulations_map = {}
self._formulations_list = []
# ✅ 新增:存储每瓶电池数,用于计算当前使用的瓶号
# ⚠️ 确保转换为整数(前端可能传递字符串)
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: 发送电解液瓶数(触发物料搬运)
@@ -1557,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:
@@ -1588,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 = {
@@ -1624,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)
@@ -1660,79 +1430,17 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
writer.writerow([
'Time', 'open_circuit_voltage', 'pole_weight',
'assembly_time', 'assembly_pressure', 'electrolyte_volume',
'coin_num', '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:
# ✅ 新方案:根据电池编号和每瓶电池数计算当前瓶号
# 例如elec_use_num=2时电池1-2用瓶0电池3-4用瓶1
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]
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", {})
# 将配方比例转为JSON字符串
import json
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}: 使用配方[{current_bottle_index}] "
f"orderName={formulation_order_name}, 配液瓶={prep_bottle_barcode}, 分液瓶={vial_bottle_barcodes}"
)
else:
logger.warning(
f"[CSV写入] 电池 {data_battery_number}: 瓶号索引 {current_bottle_index} "
f"超出配方列表范围 (共{len(self._formulations_list)}个配方)"
)
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()
@@ -1959,7 +1667,8 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
file_path: str = "/Users/sml/work"
) -> Dict[str, Any]:
"""
简化版电池组装函数,整合了原 qiming_coin_cell_code 的参数设置和双滴模式
此函数是 func_allpack_cmd 的增强版本,自动处理以下配置:
- 负极片和隔膜的盘数及矩阵点位
- 枪头盒矩阵点位
@@ -2213,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 = {
@@ -2223,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]
@@ -2266,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
# 电解液瓶位置
@@ -2380,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()
@@ -2431,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

@@ -1,4 +1,4 @@
Name,DataType,InitValue,Comment,Attribute,DeviceType,Address,
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,
@@ -29,9 +29,7 @@ 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_TYPE,INT16,,,,hold_register,10018,data_coin_type
REG_DATA_CURRENT_ASSEMBLING_COUNT,INT16,,,,hold_register,10072,data_current_assembling_count
REG_DATA_CURRENT_COMPLETED_COUNT,INT16,,,,hold_register,10074,data_current_completed_count
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()
@@ -71,75 +69,65 @@ 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-?????????
REG_DATA_10MM_POSITIVE_PLATE_REMAINING_COUNT,FLOAT32,,,,hold_register,520,10mm??????????R?
REG_DATA_12MM_POSITIVE_PLATE_REMAINING_COUNT,FLOAT32,,,,hold_register,522,12mm??????????R?
REG_DATA_16MM_POSITIVE_PLATE_REMAINING_COUNT,FLOAT32,,,,hold_register,524,16mm??????????R?
REG_DATA_ALUMINUM_FOIL_REMAINING_COUNT,FLOAT32,,,,hold_register,526,?????????R?
REG_DATA_POSITIVE_SHELL_REMAINING_COUNT,FLOAT32,,,,hold_register,528,??????????R?
REG_DATA_FLAT_WASHER_REMAINING_COUNT,FLOAT32,,,,hold_register,530,?????????R?
REG_DATA_NEGATIVE_SHELL_REMAINING_COUNT,FLOAT32,,,,hold_register,532,??????????R?
REG_DATA_SPRING_WASHER_REMAINING_COUNT,FLOAT32,,,,hold_register,534,?????????R?
REG_DATA_FINISHED_BATTERY_REMAINING_CAPACITY,FLOAT32,,,,hold_register,536,????????????R?
REG_DATA_FINISHED_BATTERY_NG_REMAINING_CAPACITY,FLOAT32,,,,hold_register,538,????NG?????????R?
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
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_TYPE REG_DATA_COIN_NUM INT16 hold_register 10018 data_coin_type data_coin_num
REG_DATA_CURRENT_ASSEMBLING_COUNT INT16 hold_register 10072 data_current_assembling_count
REG_DATA_CURRENT_COMPLETED_COUNT INT16 hold_register 10074 data_current_completed_count
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()
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-???? 异常100-系统异常
73 COIL_ALARM_101_EMERGENCY_STOP BOOL coil 1010 ??101-?? 异常101-急停
74 COIL_ALARM_111_GLOVEBOX_EMERGENCY_STOP BOOL coil 1110 ??111-????? 异常111-手套箱急停
75 COIL_ALARM_112_GLOVEBOX_GRATING_BLOCKED BOOL coil 1120 ??112-???????? 异常112-手套箱内光栅遮挡
76 COIL_ALARM_160_PIPETTE_TIP_SHORTAGE BOOL coil 1600 ??160-?????? 异常160-移液枪头缺料
77 COIL_ALARM_161_POSITIVE_SHELL_SHORTAGE BOOL coil 1610 ??161-????? 异常161-正极壳缺料
78 COIL_ALARM_162_ALUMINUM_FOIL_SHORTAGE BOOL coil 1620 ??162-????? 异常162-铝箔垫缺料
79 COIL_ALARM_163_POSITIVE_PLATE_SHORTAGE BOOL coil 1630 ??163-????? 异常163-正极片缺料
80 COIL_ALARM_164_SEPARATOR_SHORTAGE BOOL coil 1640 ??164-???? 异常164-隔膜缺料
81 COIL_ALARM_165_NEGATIVE_PLATE_SHORTAGE BOOL coil 1650 ??165-????? 异常165-负极片缺料
82 COIL_ALARM_166_FLAT_WASHER_SHORTAGE BOOL coil 1660 ??166-???? 异常166-平垫缺料
83 COIL_ALARM_167_SPRING_WASHER_SHORTAGE BOOL coil 1670 ??167-???? 异常167-弹垫缺料
84 COIL_ALARM_168_NEGATIVE_SHELL_SHORTAGE BOOL coil 1680 ??168-????? 异常168-负极壳缺料
85 COIL_ALARM_169_FINISHED_BATTERY_FULL BOOL coil 1690 ??169-?????? 异常169-成品电池满料
86 COIL_ALARM_201_SERVO_AXIS_01_ERROR BOOL coil 2010 ??201-???01?? 异常201-伺服轴01异常
87 COIL_ALARM_202_SERVO_AXIS_02_ERROR BOOL coil 2020 ??202-???02?? 异常202-伺服轴02异常
88 COIL_ALARM_203_SERVO_AXIS_03_ERROR BOOL coil 2030 ??203-???03?? 异常203-伺服轴03异常
89 COIL_ALARM_204_SERVO_AXIS_04_ERROR BOOL coil 2040 ??204-???04?? 异常204-伺服轴04异常
90 COIL_ALARM_205_SERVO_AXIS_05_ERROR BOOL coil 2050 ??205-???05?? 异常205-伺服轴05异常
91 COIL_ALARM_206_SERVO_AXIS_06_ERROR BOOL coil 2060 ??206-???06?? 异常206-伺服轴06异常
92 COIL_ALARM_207_SERVO_AXIS_07_ERROR BOOL coil 2070 ??207-???07?? 异常207-伺服轴07异常
93 COIL_ALARM_208_SERVO_AXIS_08_ERROR BOOL coil 2080 ??208-???08?? 异常208-伺服轴08异常
94 COIL_ALARM_209_SERVO_AXIS_09_ERROR BOOL coil 2090 ??209-???09?? 异常209-伺服轴09异常
95 COIL_ALARM_210_SERVO_AXIS_10_ERROR BOOL coil 2100 ??210-???10?? 异常210-伺服轴10异常
96 COIL_ALARM_211_SERVO_AXIS_11_ERROR BOOL coil 2110 ??211-???11?? 异常211-伺服轴11异常
97 COIL_ALARM_212_SERVO_AXIS_12_ERROR BOOL coil 2120 ??212-???12?? 异常212-伺服轴12异常
98 COIL_ALARM_213_SERVO_AXIS_13_ERROR BOOL coil 2130 ??213-???13?? 异常213-伺服轴13异常
99 COIL_ALARM_214_SERVO_AXIS_14_ERROR BOOL coil 2140 ??214-???14?? 异常214-伺服轴14异常
100 COIL_ALARM_250_OTHER_COMPONENT_ERROR BOOL coil 2500 ??250-?????? 异常250-其他元件异常
101 COIL_ALARM_251_PIPETTE_COMM_ERROR BOOL coil 2510 ??251-??????? 异常251-移液枪通讯异常
102 COIL_ALARM_252_PIPETTE_ALARM BOOL coil 2520 ??252-????? 异常252-移液枪报警
103 COIL_ALARM_256_ELECTRIC_GRIPPER_ERROR BOOL coil 2560 ??256-???? 异常256-电爪异常
104 COIL_ALARM_262_RB_UNKNOWN_POSITION_ERROR BOOL coil 2620 ??262-RB????????? 异常262-RB报警:未知点位错误
105 COIL_ALARM_263_RB_XYZ_PARAM_LIMIT_ERROR BOOL coil 2630 ??263-RB???X?Y?Z????? 异常263-RB报警:X、Y、Z参数超限制
106 COIL_ALARM_264_RB_VISION_PARAM_ERROR BOOL coil 2640 ??264-RB??????????? 异常264-RB报警:视觉参数误差过大
107 COIL_ALARM_265_RB_NOZZLE_1_PICK_FAIL BOOL coil 2650 ??265-RB???1#?????? 异常265-RB报警:1#吸嘴取料失败
108 COIL_ALARM_266_RB_NOZZLE_2_PICK_FAIL BOOL coil 2660 ??266-RB???2#?????? 异常266-RB报警:2#吸嘴取料失败
109 COIL_ALARM_267_RB_NOZZLE_3_PICK_FAIL BOOL coil 2670 ??267-RB???3#?????? 异常267-RB报警:3#吸嘴取料失败
110 COIL_ALARM_268_RB_NOZZLE_4_PICK_FAIL BOOL coil 2680 ??268-RB???4#?????? 异常268-RB报警:4#吸嘴取料失败
111 COIL_ALARM_269_RB_TRAY_PICK_FAIL BOOL coil 2690 ??269-RB????????? 异常269-RB报警:取物料盘失败
112 COIL_ALARM_280_RB_COLLISION_ERROR BOOL coil 2800 ??280-RB???? 异常280-RB碰撞异常
113 COIL_ALARM_290_VISION_SYSTEM_COMM_ERROR BOOL coil 2900 ??290-???????? 异常290-视觉系统通讯异常
114 COIL_ALARM_291_VISION_ALIGNMENT_NG BOOL coil 2910 ??291-????NG?? 异常291-视觉对位NG异常
115 COIL_ALARM_292_BARCODE_SCANNER_COMM_ERROR BOOL coil 2920 ??292-??????? 异常292-扫码枪通讯异常
116 COIL_ALARM_310_OCV_TRANSFER_NOZZLE_SUCTION_ERROR BOOL coil 3100 ??310-??????????? 异常310-开电移载吸嘴吸真空异常
117 COIL_ALARM_311_OCV_TRANSFER_NOZZLE_BREAK_ERROR BOOL coil 3110 ??311-??????????? 异常311-开电移载吸嘴破真空异常
118 COIL_ALARM_312_WEIGHT_TRANSFER_NOZZLE_SUCTION_ERROR BOOL coil 3120 ??312-??????????? 异常312-称重移载吸嘴吸真空异常
119 COIL_ALARM_313_WEIGHT_TRANSFER_NOZZLE_BREAK_ERROR BOOL coil 3130 ??313-??????????? 异常313-称重移载吸嘴破真空异常
120 COIL_ALARM_340_OCV_NOZZLE_TRANSFER_CYLINDER_ERROR BOOL coil 3400 ??340-???????????? 异常340-开路电压吸嘴移载气缸异常
121 COIL_ALARM_342_OCV_NOZZLE_LIFT_CYLINDER_ERROR BOOL coil 3420 ??342-???????????? 异常342-开路电压吸嘴升降气缸异常
122 COIL_ALARM_344_OCV_CRIMPING_CYLINDER_ERROR BOOL coil 3440 ??344-?????????? 异常344-开路电压旋压气缸异常
123 COIL_ALARM_350_WEIGHT_NOZZLE_TRANSFER_CYLINDER_ERROR BOOL coil 3500 ??350-?????????? 异常350-称重吸嘴移载气缸异常
124 COIL_ALARM_352_WEIGHT_NOZZLE_LIFT_CYLINDER_ERROR BOOL coil 3520 ??352-?????????? 异常352-称重吸嘴升降气缸异常
125 COIL_ALARM_354_CLEANING_CLOTH_TRANSFER_CYLINDER_ERROR BOOL coil 3540 ??354-??????????? 异常354-清洗无尘布移载气缸异常
126 COIL_ALARM_356_CLEANING_CLOTH_PRESS_CYLINDER_ERROR BOOL coil 3560 ??356-??????????? 异常356-清洗无尘布压紧气缸异常
127 COIL_ALARM_360_ELECTROLYTE_BOTTLE_POSITION_CYLINDER_ERROR BOOL coil 3600 ??360-?????????? 异常360-电解液瓶定位气缸异常
128 COIL_ALARM_362_PIPETTE_TIP_BOX_POSITION_CYLINDER_ERROR BOOL coil 3620 ??362-??????????? 异常362-移液枪头盒定位气缸异常
129 COIL_ALARM_364_REAGENT_BOTTLE_GRIPPER_LIFT_CYLINDER_ERROR BOOL coil 3640 ??364-??????????? 异常364-试剂瓶夹爪升降气缸异常
130 COIL_ALARM_366_REAGENT_BOTTLE_GRIPPER_CYLINDER_ERROR BOOL coil 3660 ??366-????????? 异常366-试剂瓶夹爪气缸异常
131 COIL_ALARM_370_PRESS_MODULE_BLOW_CYLINDER_ERROR BOOL coil 3700 ??370-?????????? 异常370-压制模块吹气气缸异常
132 COIL_ALARM_151_ELECTROLYTE_BOTTLE_POSITION_ERROR BOOL coil 1510 ??151-?????????? 异常151-电解液瓶定位在籍异常
133 COIL_ALARM_152_ELECTROLYTE_BOTTLE_CAP_ERROR BOOL coil 1520 ??152-????????? 异常152-电解液瓶盖在籍异常
REG_DATA_10MM_POSITIVE_PLATE_REMAINING_COUNT FLOAT32 hold_register 520 10mm??????????R?
REG_DATA_12MM_POSITIVE_PLATE_REMAINING_COUNT FLOAT32 hold_register 522 12mm??????????R?
REG_DATA_16MM_POSITIVE_PLATE_REMAINING_COUNT FLOAT32 hold_register 524 16mm??????????R?
REG_DATA_ALUMINUM_FOIL_REMAINING_COUNT FLOAT32 hold_register 526 ?????????R?
REG_DATA_POSITIVE_SHELL_REMAINING_COUNT FLOAT32 hold_register 528 ??????????R?
REG_DATA_FLAT_WASHER_REMAINING_COUNT FLOAT32 hold_register 530 ?????????R?
REG_DATA_NEGATIVE_SHELL_REMAINING_COUNT FLOAT32 hold_register 532 ??????????R?
REG_DATA_SPRING_WASHER_REMAINING_COUNT FLOAT32 hold_register 534 ?????????R?
REG_DATA_FINISHED_BATTERY_REMAINING_CAPACITY FLOAT32 hold_register 536 ????????????R?
REG_DATA_FINISHED_BATTERY_NG_REMAINING_CAPACITY FLOAT32 hold_register 538 ????NG?????????R?

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

@@ -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,15 +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: 配方信息列表
required: true
placeholder_keys: {}
result: {}
schema:
@@ -568,7 +619,8 @@ coincellassemblyworkstation_device:
- elec_num
- elec_use_num
type: object
result: {}
result:
type: object
required:
- goal
title: func_sendbottle_allpack_multi参数
@@ -620,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: {}
@@ -692,7 +719,8 @@ coincellassemblyworkstation_device:
required:
- fujipian_panshu
type: object
result: {}
result:
type: boolean
required:
- goal
title: qiming_coin_cell_code参数
@@ -700,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
@@ -711,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
@@ -756,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:
@@ -778,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:
@@ -819,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

@@ -324,7 +324,7 @@ neware_battery_test_system:
status_types:
channel_status: Dict[int, Dict]
connection_info: Dict[str, str]
device_summary: str
device_summary: dict
status: str
total_channels: int
type: python
@@ -339,18 +339,9 @@ neware_battery_test_system:
type: string
ip:
type: string
machine_ids:
default:
- 1
- 2
- 3
- 4
- 5
- 6
- 86
items:
type: integer
type: array
machine_id:
default: 1
type: integer
oss_prefix:
default: neware_backup
type: string
@@ -383,7 +374,7 @@ neware_battery_test_system:
type: string
type: object
device_summary:
type: string
type: object
status:
type: string
total_channels:

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

@@ -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,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,10 +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:
bottle.tracker.liquids = [
(detail["name"], float(detail.get("quantity", 0)) if detail.get("quantity") else 0)
]
bottle.tracker.liquids = [
(detail["name"], float(detail.get("quantity", 0)) if detail.get("quantity") else 0)
]
bottle.code = detail.get("code", "")
logger.debug(f" └─ [子物料] {detail['name']}{plr_material.name}[{number}] (类型:{typeName})")
else:
@@ -809,10 +808,9 @@ 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.liquids = [
(material["name"], float(material.get("quantity", 0)) if material.get("quantity") else 0)
]
bottle.tracker.liquids = [
(material["name"], float(material.get("quantity", 0)) if material.get("quantity") else 0)
]
plr_materials.append(plr_material)

View File

@@ -179,35 +179,6 @@ 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 槽位定义是否正确。"
)
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

@@ -612,31 +612,6 @@ class ResourceTreeSet(object):
d["model"] = res.config.get("model", None)
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 +622,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 +640,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)

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

@@ -1,5 +1,4 @@
import json
import os
# from nt import device_encoding
import threading
@@ -62,7 +61,7 @@ def main(
rclpy.init(args=rclpy_init_args)
else:
logger.info("[ROS] rclpy already initialized, reusing context")
executor = rclpy.__executor = MultiThreadedExecutor(num_threads=max(os.cpu_count() * 4, 48))
executor = rclpy.__executor = MultiThreadedExecutor()
# 创建主机节点
host_node = HostNode(
"host_node",
@@ -123,7 +122,7 @@ def slave(
rclpy.init(args=rclpy_init_args)
executor = rclpy.__executor
if not executor:
executor = rclpy.__executor = MultiThreadedExecutor(num_threads=max(os.cpu_count() * 4, 48))
executor = rclpy.__executor = MultiThreadedExecutor()
# 1.5 启动 executor 线程
thread = threading.Thread(target=executor.spin, daemon=True, name="slave_executor_thread")

View File

@@ -486,12 +486,18 @@ class BaseROS2DeviceNode(Node, Generic[T]):
if len(rts.root_nodes) == 1 and parent_resource is not None:
plr_instance = plr_instances[0]
if isinstance(plr_instance, Plate):
empty_liquid_info_in: List[Tuple[Optional[str], float]] = [(None, 0)] * plr_instance.num_items
if len(ADD_LIQUID_TYPE) == 1 and len(LIQUID_VOLUME) == 1 and len(LIQUID_INPUT_SLOT) > 1:
ADD_LIQUID_TYPE = ADD_LIQUID_TYPE * len(LIQUID_INPUT_SLOT)
LIQUID_VOLUME = LIQUID_VOLUME * len(LIQUID_INPUT_SLOT)
self.lab_logger().warning(
f"增加液体资源时数量为1自动补全为 {len(LIQUID_INPUT_SLOT)}"
)
for liquid_type, liquid_volume, liquid_input_slot in zip(
ADD_LIQUID_TYPE, LIQUID_VOLUME, LIQUID_INPUT_SLOT
):
empty_liquid_info_in[liquid_input_slot] = (liquid_type, liquid_volume)
plr_instance.set_well_liquids(empty_liquid_info_in)
try:
# noinspection PyProtectedMember
keys = list(plr_instance._ordering.keys())
@@ -505,10 +511,6 @@ class BaseROS2DeviceNode(Node, Generic[T]):
input_wells = []
for r in LIQUID_INPUT_SLOT:
input_wells.append(plr_instance.children[r])
for input_well, liquid_type, liquid_volume, liquid_input_slot in zip(
input_wells, ADD_LIQUID_TYPE, LIQUID_VOLUME, LIQUID_INPUT_SLOT
):
input_well.set_liquids([(liquid_type, liquid_volume, "uL")])
final_response["liquid_input_resource_tree"] = ResourceTreeSet.from_plr_resources(
input_wells
).dump()
@@ -1254,8 +1256,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
return self._lab_logger
def create_ros_publisher(self, attr_name, msg_type, initial_period=5.0):
"""创建ROS发布者。已在 status_types 中声明的属性直接创建;@topic_config 用于覆盖默认参数"""
topic_cfg = {}
"""创建ROS发布者,仅当方法/属性有 @topic_config 装饰器时才创建"""
# 检测 @topic_config 装饰器配置
topic_config = {}
driver_class = type(self.driver_instance)
# 区分 @property 和普通方法两种情况
@@ -1264,17 +1267,23 @@ class BaseROS2DeviceNode(Node, Generic[T]):
)
if is_prop:
# @property: 检测 fget 上的 @topic_config
class_attr = getattr(driver_class, attr_name)
if class_attr.fget is not None:
topic_cfg = get_topic_config(class_attr.fget)
topic_config = get_topic_config(class_attr.fget)
else:
# 普通方法: 直接检测 attr_name 方法上的 @topic_config
if hasattr(self.driver_instance, attr_name):
method = getattr(self.driver_instance, attr_name)
if callable(method):
topic_cfg = get_topic_config(method)
topic_config = get_topic_config(method)
# 没有 @topic_config 装饰器则跳过发布
if not topic_config:
return
# 发布名称优先级: @topic_config(name=...) > get_ 前缀去除 > attr_name
cfg_name = topic_cfg.get("name")
cfg_name = topic_config.get("name")
if cfg_name:
publish_name = cfg_name
elif attr_name.startswith("get_"):
@@ -1282,10 +1291,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
else:
publish_name = attr_name
# @topic_config 参数覆盖默认值
cfg_period = topic_cfg.get("period")
cfg_print = topic_cfg.get("print_publish")
cfg_qos = topic_cfg.get("qos")
# 使用装饰器配置或默认值
cfg_period = topic_config.get("period")
cfg_print = topic_config.get("print_publish")
cfg_qos = topic_config.get("qos")
period: float = cfg_period if cfg_period is not None else initial_period
print_publish: bool = cfg_print if cfg_print is not None else self._print_publish
qos: int = cfg_qos if cfg_qos is not None else 10

View File

@@ -1632,7 +1632,6 @@ class HostNode(BaseROS2DeviceNode):
def manual_confirm(self, timeout_seconds: int, assignee_user_ids: list[str], **kwargs) -> dict:
"""
timeout_seconds: 超时时间默认3600秒
修改的结果无效,是只读的
"""
return kwargs

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

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

@@ -346,7 +346,7 @@ def refactor_data(
"template_name": template_name,
"resource_name": resource_name,
"description": step.get("description", step.get("purpose", f"{operation} operation")),
"lab_node_type": "ILab",
"lab_node_type": "Device",
"param": step.get("parameters", step.get("action_args", {})),
"footer": f"{template_name}-{resource_name}",
}