mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-04-24 03:49:55 +00:00
Compare commits
7 Commits
prcix9320
...
refactor/B
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edc1fe853b | ||
|
|
80272d691d | ||
|
|
0ab4027de7 | ||
|
|
5f36b6c04b | ||
|
|
d75c7f123b | ||
|
|
ed80d786c1 | ||
|
|
9de473374f |
@@ -1,9 +0,0 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
REM upgrade pip
|
||||
"%PREFIX%\python.exe" -m pip install --upgrade pip
|
||||
|
||||
REM install extra deps
|
||||
"%PREFIX%\python.exe" -m pip install paho-mqtt opentrons_shared_data
|
||||
"%PREFIX%\python.exe" -m pip install git+https://github.com/Xuwznln/pylabrobot.git
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euxo pipefail
|
||||
|
||||
# make sure pip is available
|
||||
"$PREFIX/bin/python" -m pip install --upgrade pip
|
||||
|
||||
# install extra deps
|
||||
"$PREFIX/bin/python" -m pip install paho-mqtt opentrons_shared_data
|
||||
"$PREFIX/bin/python" -m pip install git+https://github.com/Xuwznln/pylabrobot.git
|
||||
@@ -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: 检查返回结果,确认提交成功
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -158,13 +158,12 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
|
||||
- `unilabos_devices` → **DeviceSlot**,填入路径字符串如 `"/host_node"`(从资源树筛选 type=device)
|
||||
- `unilabos_nodes` → **NodeSlot**,填入路径字符串如 `"/PRCXI/PRCXI_Deck"`(资源树中任意节点)
|
||||
- `unilabos_class` → **ClassSlot**,填入类名字符串如 `"container"`(从注册表查找)
|
||||
- `unilabos_formulation` → **FormulationSlot**,填入配方数组 `[{well_name, liquids: [{name, volume}]}]`(well_name 为目标物料的 name)
|
||||
- array 类型字段 → `[{id, name, uuid}, ...]`
|
||||
- 特殊:`create_resource` 的 `res_id`(ResourceSlot)可填不存在的路径
|
||||
|
||||
### Step 4 — 写 SKILL.md
|
||||
|
||||
直接复用 `unilab-device-api` 的 API 模板,修改:
|
||||
直接复用 `unilab-device-api` 的 API 模板(10 个 endpoint),修改:
|
||||
- 设备名称
|
||||
- Action 数量
|
||||
- 目录列表
|
||||
@@ -182,37 +181,21 @@ API 模板结构:
|
||||
## 前置条件(缺一不可)
|
||||
- ak/sk → AUTH, --addr → BASE URL
|
||||
|
||||
## 请求约定
|
||||
- Windows 平台必须用 curl.exe(非 PowerShell 的 curl 别名)
|
||||
|
||||
## Session State
|
||||
- lab_uuid(通过 GET /edge/lab/info 直接获取,不要问用户), device_name
|
||||
- lab_uuid(通过 API #1 自动匹配,不要问用户), device_name
|
||||
|
||||
## API Endpoints
|
||||
# - #1 GET /edge/lab/info → 直接拿到 lab_uuid
|
||||
# - #2 创建工作流 POST /lab/workflow/owner → 拼 URL 告知用户
|
||||
# - #3 创建节点 POST /edge/workflow/node
|
||||
# body: {workflow_uuid, resource_template_name: "<device_id>", node_template_name: "<action_name>"}
|
||||
# - #4 删除节点 DELETE /lab/workflow/nodes
|
||||
# - #5 更新节点参数 PATCH /lab/workflow/node
|
||||
# - #6 查询节点 handles POST /lab/workflow/node-handles
|
||||
# body: {node_uuids: ["uuid1","uuid2"]} → 返回各节点的 handle_uuid
|
||||
# - #7 批量创建边 POST /lab/workflow/edges
|
||||
# body: {edges: [{source_node_uuid, target_node_uuid, source_handle_uuid, target_handle_uuid}]}
|
||||
# - #8 启动工作流 POST /lab/workflow/{uuid}/run
|
||||
# - #9 运行设备单动作 POST /lab/mcp/run/action
|
||||
# - #10 查询任务状态 GET /lab/mcp/task/{task_uuid}
|
||||
# - #11 运行工作流单节点 POST /lab/mcp/run/workflow/action
|
||||
# - #12 获取资源树 GET /lab/material/download/{lab_uuid}
|
||||
# - #13 获取工作流模板详情 GET /lab/workflow/template/detail/{workflow_uuid}
|
||||
# 返回 workflow 完整结构:data.nodes[] 含每个节点的 uuid、name、param、device_name、handles
|
||||
## API Endpoints (10 个)
|
||||
# 注意:
|
||||
# - #1 获取 lab 列表 + 自动匹配 lab_uuid(遍历 is_admin 的 lab,
|
||||
# 调用 /lab/info/{uuid} 比对 access_key == ak)
|
||||
# - #2 创建工作流用 POST /lab/workflow
|
||||
# - #10 获取资源树路径含 lab_uuid: /lab/material/download/{lab_uuid}
|
||||
|
||||
## Placeholder Slot 填写规则
|
||||
- unilabos_resources → ResourceSlot → {"id":"/path/name","name":"name","uuid":"xxx"}
|
||||
- unilabos_devices → DeviceSlot → "/parent/device" 路径字符串
|
||||
- unilabos_nodes → NodeSlot → "/parent/node" 路径字符串
|
||||
- unilabos_class → ClassSlot → "class_name" 字符串
|
||||
- unilabos_formulation → FormulationSlot → [{well_name, liquids: [{name, volume}]}] 配方数组
|
||||
- 特例:create_resource 的 res_id 允许填不存在的路径
|
||||
- 列出本设备所有 Slot 字段、类型及含义
|
||||
|
||||
@@ -223,8 +206,8 @@ API 模板结构:
|
||||
### Step 5 — 验证
|
||||
|
||||
检查文件完整性:
|
||||
- [ ] `SKILL.md` 包含 API endpoint(#1 获取 lab_uuid、#2-#7 工作流/节点/边、#8-#11 运行/查询、#12 资源树、#13 工作流模板详情)
|
||||
- [ ] `SKILL.md` 包含 Placeholder Slot 填写规则(ResourceSlot / DeviceSlot / NodeSlot / ClassSlot / FormulationSlot + create_resource 特例)和本设备的 Slot 字段表
|
||||
- [ ] `SKILL.md` 包含 10 个 API endpoint
|
||||
- [ ] `SKILL.md` 包含 Placeholder Slot 填写规则(ResourceSlot / DeviceSlot / NodeSlot / ClassSlot + create_resource 特例)和本设备的 Slot 字段表
|
||||
- [ ] `action-index.md` 列出所有 action 并有描述
|
||||
- [ ] `actions/` 目录中每个 action 有对应 JSON 文件
|
||||
- [ ] JSON 文件包含 `type`, `schema`(已提升为 goal 内容), `goal`, `goal_default`, `placeholder_keys` 字段
|
||||
@@ -266,11 +249,11 @@ API 模板结构:
|
||||
```
|
||||
|
||||
> **注意**:`schema` 已由脚本从原始 `schema.properties.goal` 提升为顶层,直接包含参数定义。
|
||||
> `schema.properties` 中的字段即为 API 创建节点返回的 `data.param` 中的字段,PATCH 更新时直接修改 `param` 即可。
|
||||
> `schema.properties` 中的字段即为 API 请求 `param.goal` 中的字段。
|
||||
|
||||
## Placeholder Slot 类型体系
|
||||
|
||||
`placeholder_keys` / `_unilabos_placeholder_info` 中有 5 种值,对应不同的填写方式:
|
||||
`placeholder_keys` / `_unilabos_placeholder_info` 中有 4 种值,对应不同的填写方式:
|
||||
|
||||
| placeholder 值 | Slot 类型 | 填写格式 | 选取范围 |
|
||||
|---------------|-----------|---------|---------|
|
||||
@@ -278,7 +261,6 @@ API 模板结构:
|
||||
| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅**设备**节点(type=device),路径字符串 |
|
||||
| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | **设备 + 物料**,即所有节点,路径字符串 |
|
||||
| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已上报的资源类 name |
|
||||
| `unilabos_formulation` | FormulationSlot | `[{well_name, liquids: [{name, volume}]}]` | 资源树中物料节点的 **name**,配合液体配方 |
|
||||
|
||||
### ResourceSlot(`unilabos_resources`)
|
||||
|
||||
@@ -325,41 +307,7 @@ API 模板结构:
|
||||
"container"
|
||||
```
|
||||
|
||||
### FormulationSlot(`unilabos_formulation`)
|
||||
|
||||
描述**液体配方**:向哪些物料容器中加入哪些液体及体积。填写为**对象数组**:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"sample_uuid": "",
|
||||
"well_name": "YB_PrepBottle_15mL_Carrier_bottle_A1",
|
||||
"liquids": [
|
||||
{ "name": "LiPF6", "volume": 0.6 },
|
||||
{ "name": "DMC", "volume": 1.2 }
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
#### 字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `sample_uuid` | string | 样品 UUID,无样品时传空字符串 `""` |
|
||||
| `well_name` | string | 目标物料容器的 **name**(从资源树中取物料节点的 `name` 字段,如瓶子、孔位名称) |
|
||||
| `liquids` | array | 要加入的液体列表 |
|
||||
| `liquids[].name` | string | 液体名称(如试剂名、溶剂名) |
|
||||
| `liquids[].volume` | number | 液体体积(单位由设备决定,通常为 mL) |
|
||||
|
||||
#### 填写规则
|
||||
|
||||
- `well_name` 必须是资源树中已存在的物料节点 `name`(不是 `id` 路径),通过 API #12 获取资源树后筛选
|
||||
- 每个数组元素代表一个目标容器的配方
|
||||
- 一个容器可以加入多种液体(`liquids` 数组多条记录)
|
||||
- 与 ResourceSlot 的区别:ResourceSlot 填 `{id, name, uuid}` 指向物料本身;FormulationSlot 用 `well_name` 引用物料,并附带液体配方信息
|
||||
|
||||
### 通过 API #12 获取资源树
|
||||
### 通过 API #10 获取资源树
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
---
|
||||
name: submit-agent-result
|
||||
description: Submit historical experiment results (agent_result) to Uni-Lab notebook — read data files, assemble JSON payload, PUT to cloud API. Use when the user wants to submit experiment results, upload agent results, report experiment data, or mentions agent_result/实验结果/历史记录/notebook结果.
|
||||
---
|
||||
|
||||
# 提交历史实验记录指南
|
||||
|
||||
通过云端 API 向已创建的 notebook 提交实验结果数据(agent_result)。支持从 JSON / CSV 文件读取数据,整合后提交。
|
||||
|
||||
## 前置条件(缺一不可)
|
||||
|
||||
使用本指南前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
|
||||
|
||||
### 1. ak / sk → AUTH
|
||||
|
||||
询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。
|
||||
|
||||
生成 AUTH token:
|
||||
|
||||
```bash
|
||||
python -c "import base64,sys; print(base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
|
||||
```
|
||||
|
||||
输出即为 token 值,拼接为 `Authorization: Lab <token>`。
|
||||
|
||||
### 2. --addr → BASE URL
|
||||
|
||||
| `--addr` 值 | BASE |
|
||||
|-------------|------|
|
||||
| `test` | `https://uni-lab.test.bohrium.com` |
|
||||
| `uat` | `https://uni-lab.uat.bohrium.com` |
|
||||
| `local` | `http://127.0.0.1:48197` |
|
||||
| 不传(默认) | `https://uni-lab.bohrium.com` |
|
||||
|
||||
确认后设置:
|
||||
```bash
|
||||
BASE="<根据 addr 确定的 URL>"
|
||||
AUTH="Authorization: Lab <上面命令输出的 token>"
|
||||
```
|
||||
|
||||
### 3. notebook_uuid(**必须询问用户**)
|
||||
|
||||
**必须主动询问用户**:「请提供要提交结果的 notebook UUID。」
|
||||
|
||||
notebook_uuid 来自之前通过「批量提交实验」创建的实验批次,即 `POST /api/v1/lab/notebook` 返回的 `data.uuid`。
|
||||
|
||||
如果用户不记得,可提示:
|
||||
- 查看之前的对话记录中创建 notebook 时返回的 UUID
|
||||
- 或通过平台页面查找对应的 notebook
|
||||
|
||||
**绝不能跳过此步骤,没有 notebook_uuid 无法提交。**
|
||||
|
||||
### 4. 实验结果数据
|
||||
|
||||
用户需要提供实验结果数据,支持以下方式:
|
||||
|
||||
| 方式 | 说明 |
|
||||
|------|------|
|
||||
| JSON 文件 | 直接作为 `agent_result` 的内容合并 |
|
||||
| CSV 文件 | 转为 `{"文件名": [行数据...]}` 格式 |
|
||||
| 手动指定 | 用户直接告知 key-value 数据,由 agent 构建 JSON |
|
||||
|
||||
**四项全部就绪后才可开始。**
|
||||
|
||||
## Session State
|
||||
|
||||
在整个对话过程中,agent 需要记住以下状态:
|
||||
|
||||
- `lab_uuid` — 实验室 UUID(通过 API #1 自动获取,**不需要问用户**)
|
||||
- `notebook_uuid` — 目标 notebook UUID(**必须询问用户**)
|
||||
|
||||
## 请求约定
|
||||
|
||||
所有请求使用 `curl -s`,PUT 需加 `Content-Type: application/json`。
|
||||
|
||||
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名),示例中的 `curl` 均指 `curl.exe`。
|
||||
>
|
||||
> **PowerShell JSON 传参**:PowerShell 中 `-d '{"key":"value"}'` 会因引号转义失败。请将 JSON 写入临时文件,用 `-d '@tmp_body.json'`(单引号包裹 `@`,否则 `@` 会被 PowerShell 解析为 splatting 运算符导致报错)。
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### 1. 获取实验室信息(自动获取 lab_uuid)
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}}
|
||||
```
|
||||
|
||||
记住 `data.uuid` 为 `lab_uuid`。
|
||||
|
||||
### 2. 提交实验结果(agent_result)
|
||||
|
||||
```bash
|
||||
curl -s -X PUT "$BASE/api/v1/lab/notebook/agent-result" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '<request_body>'
|
||||
```
|
||||
|
||||
请求体结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"notebook_uuid": "<notebook_uuid>",
|
||||
"agent_result": {
|
||||
"<key1>": "<value1>",
|
||||
"<key2>": 123,
|
||||
"<nested_key>": {"a": 1, "b": 2},
|
||||
"<array_key>": [{"col1": "v1", "col2": "v2"}, ...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:HTTP 方法是 **PUT**(不是 POST)。
|
||||
|
||||
#### 必要字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `notebook_uuid` | string (UUID) | 目标 notebook 的 UUID,从批量提交实验时获取 |
|
||||
| `agent_result` | object | 实验结果数据,任意 JSON 对象 |
|
||||
|
||||
#### agent_result 内容格式
|
||||
|
||||
`agent_result` 接受**任意 JSON 对象**,常见格式:
|
||||
|
||||
**简单键值对**:
|
||||
```json
|
||||
{
|
||||
"avg_rtt_ms": 12.5,
|
||||
"status": "success",
|
||||
"test_count": 5
|
||||
}
|
||||
```
|
||||
|
||||
**包含嵌套结构**:
|
||||
```json
|
||||
{
|
||||
"summary": {"total": 100, "passed": 98, "failed": 2},
|
||||
"measurements": [
|
||||
{"sample_id": "S001", "value": 3.14, "unit": "mg/mL"},
|
||||
{"sample_id": "S002", "value": 2.71, "unit": "mg/mL"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**从 CSV 文件导入**(脚本自动转换):
|
||||
```json
|
||||
{
|
||||
"experiment_data": [
|
||||
{"温度": 25, "压力": 101.3, "产率": 0.85},
|
||||
{"温度": 30, "压力": 101.3, "产率": 0.91}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 整合脚本
|
||||
|
||||
本文档同级目录下的 `scripts/prepare_agent_result.py` 可自动读取文件并构建请求体。
|
||||
|
||||
### 用法
|
||||
|
||||
```bash
|
||||
python scripts/prepare_agent_result.py \
|
||||
--notebook-uuid <uuid> \
|
||||
--files data1.json data2.csv \
|
||||
[--auth <token>] \
|
||||
[--base <BASE_URL>] \
|
||||
[--submit] \
|
||||
[--output <output.json>]
|
||||
```
|
||||
|
||||
| 参数 | 必选 | 说明 |
|
||||
|------|------|------|
|
||||
| `--notebook-uuid` | 是 | 目标 notebook UUID |
|
||||
| `--files` | 是 | 输入文件路径(支持多个,JSON / CSV) |
|
||||
| `--auth` | 提交时必选 | Lab token(base64(ak:sk)) |
|
||||
| `--base` | 提交时必选 | API base URL |
|
||||
| `--submit` | 否 | 加上此标志则直接提交到云端 |
|
||||
| `--output` | 否 | 输出 JSON 路径(默认 `agent_result_body.json`) |
|
||||
|
||||
### 文件合并规则
|
||||
|
||||
| 文件类型 | 合并方式 |
|
||||
|----------|----------|
|
||||
| `.json`(dict) | 字段直接合并到 `agent_result` 顶层 |
|
||||
| `.json`(list/other) | 以文件名为 key 放入 `agent_result` |
|
||||
| `.csv` | 以文件名(不含扩展名)为 key,值为行对象数组 |
|
||||
|
||||
多个文件的字段会合并。JSON dict 中的重复 key 后者覆盖前者。
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 仅生成请求体文件(不提交)
|
||||
python scripts/prepare_agent_result.py \
|
||||
--notebook-uuid 73c67dca-c8cc-4936-85a0-329106aa7cca \
|
||||
--files results.json measurements.csv
|
||||
|
||||
# 生成并直接提交
|
||||
python scripts/prepare_agent_result.py \
|
||||
--notebook-uuid 73c67dca-c8cc-4936-85a0-329106aa7cca \
|
||||
--files results.json \
|
||||
--auth YTFmZDlkNGUt... \
|
||||
--base https://uni-lab.test.bohrium.com \
|
||||
--submit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 手动构建方式
|
||||
|
||||
如果不使用脚本,也可手动构建请求体:
|
||||
|
||||
1. 将实验结果数据组装为 JSON 对象
|
||||
2. 写入临时文件:
|
||||
|
||||
```json
|
||||
{
|
||||
"notebook_uuid": "<uuid>",
|
||||
"agent_result": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
3. 用 curl 提交:
|
||||
|
||||
```bash
|
||||
curl -s -X PUT "$BASE/api/v1/lab/notebook/agent-result" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '@tmp_body.json'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完整工作流 Checklist
|
||||
|
||||
```
|
||||
Task Progress:
|
||||
- [ ] Step 1: 确认 ak/sk → 生成 AUTH token
|
||||
- [ ] Step 2: 确认 --addr → 设置 BASE URL
|
||||
- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid
|
||||
- [ ] Step 4: **询问用户** notebook_uuid(必须,不可跳过)
|
||||
- [ ] Step 5: 确认实验结果数据来源(文件路径或手动数据)
|
||||
- [ ] Step 6: 运行 prepare_agent_result.py 或手动构建请求体
|
||||
- [ ] Step 7: PUT /lab/notebook/agent-result 提交
|
||||
- [ ] Step 8: 检查返回结果,确认提交成功
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: notebook_uuid 从哪里获取?
|
||||
|
||||
从之前「批量提交实验」时 `POST /api/v1/lab/notebook` 的返回值 `data.uuid` 获取。也可以在平台 UI 中查找对应的 notebook。
|
||||
|
||||
### Q: agent_result 有固定的 schema 吗?
|
||||
|
||||
没有严格 schema,接受任意 JSON 对象。但建议包含有意义的字段名和结构化数据,方便后续分析。
|
||||
|
||||
### Q: 可以多次提交同一个 notebook 的结果吗?
|
||||
|
||||
可以,后续提交会覆盖之前的 agent_result。
|
||||
|
||||
### Q: 认证方式是 Lab 还是 Api?
|
||||
|
||||
本指南统一使用 `Authorization: Lab <base64(ak:sk)>` 方式。如果用户有独立的 API Key,也可用 `Authorization: Api <key>` 替代。
|
||||
@@ -1,133 +0,0 @@
|
||||
"""
|
||||
读取实验结果文件(JSON / CSV),整合为 agent_result 请求体并可选提交。
|
||||
|
||||
用法:
|
||||
python prepare_agent_result.py \
|
||||
--notebook-uuid <uuid> \
|
||||
--files data1.json data2.csv \
|
||||
[--auth <Lab token>] \
|
||||
[--base <BASE_URL>] \
|
||||
[--submit] \
|
||||
[--output <output.json>]
|
||||
|
||||
支持的输入文件格式:
|
||||
- .json → 直接作为 dict 合并
|
||||
- .csv → 转为 {"filename": [row_dict, ...]} 格式
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
def read_json_file(filepath: str) -> Dict[str, Any]:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def read_csv_file(filepath: str) -> List[Dict[str, Any]]:
|
||||
rows = []
|
||||
with open(filepath, "r", encoding="utf-8-sig") as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
converted = {}
|
||||
for k, v in row.items():
|
||||
try:
|
||||
converted[k] = int(v)
|
||||
except (ValueError, TypeError):
|
||||
try:
|
||||
converted[k] = float(v)
|
||||
except (ValueError, TypeError):
|
||||
converted[k] = v
|
||||
rows.append(converted)
|
||||
return rows
|
||||
|
||||
|
||||
def merge_files(filepaths: List[str]) -> Dict[str, Any]:
|
||||
"""将多个文件合并为一个 agent_result dict"""
|
||||
merged: Dict[str, Any] = {}
|
||||
for fp in filepaths:
|
||||
path = Path(fp)
|
||||
ext = path.suffix.lower()
|
||||
key = path.stem
|
||||
|
||||
if ext == ".json":
|
||||
data = read_json_file(fp)
|
||||
if isinstance(data, dict):
|
||||
merged.update(data)
|
||||
else:
|
||||
merged[key] = data
|
||||
elif ext == ".csv":
|
||||
merged[key] = read_csv_file(fp)
|
||||
else:
|
||||
print(f"[警告] 不支持的文件格式: {fp},跳过", file=sys.stderr)
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
def build_request_body(notebook_uuid: str, agent_result: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {
|
||||
"notebook_uuid": notebook_uuid,
|
||||
"agent_result": agent_result,
|
||||
}
|
||||
|
||||
|
||||
def submit(base: str, auth: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
print("[错误] 提交需要 requests 库: pip install requests", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
url = f"{base}/api/v1/lab/notebook/agent-result"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Lab {auth}",
|
||||
}
|
||||
resp = requests.put(url, json=body, headers=headers, timeout=30)
|
||||
return {"status_code": resp.status_code, "body": resp.json() if resp.headers.get("content-type", "").startswith("application/json") else resp.text}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="整合实验结果文件并构建 agent_result 请求体")
|
||||
parser.add_argument("--notebook-uuid", required=True, help="目标 notebook UUID")
|
||||
parser.add_argument("--files", nargs="+", required=True, help="输入文件路径(JSON / CSV)")
|
||||
parser.add_argument("--auth", help="Lab token(base64(ak:sk))")
|
||||
parser.add_argument("--base", help="API base URL")
|
||||
parser.add_argument("--submit", action="store_true", help="直接提交到云端")
|
||||
parser.add_argument("--output", default="agent_result_body.json", help="输出 JSON 文件路径")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
for fp in args.files:
|
||||
if not os.path.exists(fp):
|
||||
print(f"[错误] 文件不存在: {fp}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
agent_result = merge_files(args.files)
|
||||
body = build_request_body(args.notebook_uuid, agent_result)
|
||||
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
json.dump(body, f, ensure_ascii=False, indent=2)
|
||||
print(f"[完成] 请求体已保存: {args.output}")
|
||||
print(f" notebook_uuid: {args.notebook_uuid}")
|
||||
print(f" agent_result 字段数: {len(agent_result)}")
|
||||
print(f" 合并文件数: {len(args.files)}")
|
||||
|
||||
if args.submit:
|
||||
if not args.auth or not args.base:
|
||||
print("[错误] 提交需要 --auth 和 --base 参数", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"\n[提交] PUT {args.base}/api/v1/lab/notebook/agent-result ...")
|
||||
result = submit(args.base, args.auth, body)
|
||||
print(f" HTTP {result['status_code']}")
|
||||
print(f" 响应: {json.dumps(result['body'], ensure_ascii=False)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,26 +0,0 @@
|
||||
.conda
|
||||
# .github
|
||||
.idea
|
||||
# .vscode
|
||||
output
|
||||
pylabrobot_repo
|
||||
recipes
|
||||
scripts
|
||||
service
|
||||
temp
|
||||
# unilabos/test
|
||||
# unilabos/app/web
|
||||
unilabos/device_mesh
|
||||
unilabos_data
|
||||
unilabos_msgs
|
||||
unilabos.egg-info
|
||||
CONTRIBUTORS
|
||||
# LICENSE
|
||||
MANIFEST.in
|
||||
pyrightconfig.json
|
||||
# README.md
|
||||
# README_zh.md
|
||||
setup.py
|
||||
setup.cfg
|
||||
.gitattrubutes
|
||||
**/__pycache__
|
||||
19
.github/dependabot.yml
vendored
Normal file
19
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
version: 2
|
||||
updates:
|
||||
# GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
target-branch: "dev"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "06:00"
|
||||
open-pull-requests-limit: 5
|
||||
reviewers:
|
||||
- "msgcenterpy-team"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "github-actions"
|
||||
commit-message:
|
||||
prefix: "ci"
|
||||
include: "scope"
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -252,5 +252,3 @@ ros-humble-unilabos-msgs-0.9.13-h6403a04_5.tar.bz2
|
||||
test_config.py
|
||||
|
||||
|
||||
/.claude
|
||||
/.cursor
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,539 +0,0 @@
|
||||
import pytest
|
||||
import json
|
||||
import os
|
||||
import asyncio
|
||||
import collections
|
||||
from typing import List, Dict, Any
|
||||
|
||||
from pylabrobot.resources import Coordinate
|
||||
from pylabrobot.resources.opentrons.tip_racks import opentrons_96_tiprack_300ul, opentrons_96_tiprack_10ul
|
||||
from pylabrobot.resources.opentrons.plates import corning_96_wellplate_360ul_flat, nest_96_wellplate_2ml_deep
|
||||
|
||||
from unilabos.devices.liquid_handling.prcxi.prcxi import (
|
||||
PRCXI9300Deck,
|
||||
PRCXI9300Container,
|
||||
PRCXI9300Trash,
|
||||
PRCXI9300Handler,
|
||||
PRCXI9300Backend,
|
||||
DefaultLayout,
|
||||
Material,
|
||||
WorkTablets,
|
||||
MatrixInfo
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prcxi_materials() -> Dict[str, Any]:
|
||||
"""加载 PRCXI 物料数据"""
|
||||
print("加载 PRCXI 物料数据...")
|
||||
material_path = os.path.join(os.path.dirname(__file__), "..", "..", "unilabos", "devices", "liquid_handling", "prcxi", "prcxi_material.json")
|
||||
with open(material_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
print(f"加载了 {len(data)} 条物料数据")
|
||||
return data
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prcxi_9300_deck() -> PRCXI9300Deck:
|
||||
"""创建 PRCXI 9300 工作台"""
|
||||
return PRCXI9300Deck(name="PRCXI_Deck_9300", size_x=100, size_y=100, size_z=100, model="9300")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prcxi_9320_deck() -> PRCXI9300Deck:
|
||||
"""创建 PRCXI 9320 工作台"""
|
||||
return PRCXI9300Deck(name="PRCXI_Deck_9320", size_x=100, size_y=100, size_z=100, model="9320")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prcxi_9300_handler(prcxi_9300_deck) -> PRCXI9300Handler:
|
||||
"""创建 PRCXI 9300 处理器(模拟模式)"""
|
||||
return PRCXI9300Handler(
|
||||
deck=prcxi_9300_deck,
|
||||
host="192.168.1.201",
|
||||
port=9999,
|
||||
timeout=10.0,
|
||||
channel_num=8,
|
||||
axis="Left",
|
||||
setup=False,
|
||||
debug=True,
|
||||
simulator=True,
|
||||
matrix_id="test-matrix-9300"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prcxi_9320_handler(prcxi_9320_deck) -> PRCXI9300Handler:
|
||||
"""创建 PRCXI 9320 处理器(模拟模式)"""
|
||||
return PRCXI9300Handler(
|
||||
deck=prcxi_9320_deck,
|
||||
host="192.168.1.201",
|
||||
port=9999,
|
||||
timeout=10.0,
|
||||
channel_num=1,
|
||||
axis="Right",
|
||||
setup=False,
|
||||
debug=True,
|
||||
simulator=True,
|
||||
matrix_id="test-matrix-9320",
|
||||
is_9320=True
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tip_rack_300ul(prcxi_materials) -> PRCXI9300Container:
|
||||
"""创建 300μL 枪头盒"""
|
||||
tip_rack = PRCXI9300Container(
|
||||
name="tip_rack_300ul",
|
||||
size_x=50,
|
||||
size_y=50,
|
||||
size_z=10,
|
||||
category="tip_rack",
|
||||
ordering=collections.OrderedDict()
|
||||
)
|
||||
tip_rack.load_state({
|
||||
"Material": {
|
||||
"uuid": prcxi_materials["300μL Tip头"]["uuid"],
|
||||
"Code": "ZX-001-300",
|
||||
"Name": "300μL Tip头"
|
||||
}
|
||||
})
|
||||
return tip_rack
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tip_rack_10ul(prcxi_materials) -> PRCXI9300Container:
|
||||
"""创建 10μL 枪头盒"""
|
||||
tip_rack = PRCXI9300Container(
|
||||
name="tip_rack_10ul",
|
||||
size_x=50,
|
||||
size_y=50,
|
||||
size_z=10,
|
||||
category="tip_rack",
|
||||
ordering=collections.OrderedDict()
|
||||
)
|
||||
tip_rack.load_state({
|
||||
"Material": {
|
||||
"uuid": prcxi_materials["10μL加长 Tip头"]["uuid"],
|
||||
"Code": "ZX-001-10+",
|
||||
"Name": "10μL加长 Tip头"
|
||||
}
|
||||
})
|
||||
return tip_rack
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def well_plate_96(prcxi_materials) -> PRCXI9300Container:
|
||||
"""创建 96 孔板"""
|
||||
plate = PRCXI9300Container(
|
||||
name="well_plate_96",
|
||||
size_x=50,
|
||||
size_y=50,
|
||||
size_z=10,
|
||||
category="plate",
|
||||
ordering=collections.OrderedDict()
|
||||
)
|
||||
plate.load_state({
|
||||
"Material": {
|
||||
"uuid": prcxi_materials["96深孔板"]["uuid"],
|
||||
"Code": "ZX-019-2.2",
|
||||
"Name": "96深孔板"
|
||||
}
|
||||
})
|
||||
return plate
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def deep_well_plate(prcxi_materials) -> PRCXI9300Container:
|
||||
"""创建深孔板"""
|
||||
plate = PRCXI9300Container(
|
||||
name="deep_well_plate",
|
||||
size_x=50,
|
||||
size_y=50,
|
||||
size_z=10,
|
||||
category="plate",
|
||||
ordering=collections.OrderedDict()
|
||||
)
|
||||
plate.load_state({
|
||||
"Material": {
|
||||
"uuid": prcxi_materials["96深孔板"]["uuid"],
|
||||
"Code": "ZX-019-2.2",
|
||||
"Name": "96深孔板"
|
||||
}
|
||||
})
|
||||
return plate
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def trash_container(prcxi_materials) -> PRCXI9300Trash:
|
||||
"""创建垃圾桶"""
|
||||
trash = PRCXI9300Trash(name="trash", size_x=50, size_y=50, size_z=10, category="trash")
|
||||
trash.load_state({
|
||||
"Material": {
|
||||
"uuid": prcxi_materials["废弃槽"]["uuid"]
|
||||
}
|
||||
})
|
||||
return trash
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def default_layout_9300() -> DefaultLayout:
|
||||
"""创建 PRCXI 9300 默认布局"""
|
||||
return DefaultLayout("PRCXI9300")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def default_layout_9320() -> DefaultLayout:
|
||||
"""创建 PRCXI 9320 默认布局"""
|
||||
return DefaultLayout("PRCXI9320")
|
||||
|
||||
|
||||
class TestPRCXIDeckSetup:
|
||||
"""测试 PRCXI 工作台设置功能"""
|
||||
|
||||
def test_prcxi_9300_deck_creation(self, prcxi_9300_deck):
|
||||
"""测试 PRCXI 9300 工作台创建"""
|
||||
assert prcxi_9300_deck.name == "PRCXI_Deck_9300"
|
||||
assert len(prcxi_9300_deck.sites) == 6
|
||||
assert prcxi_9300_deck._size_x == 100
|
||||
assert prcxi_9300_deck._size_y == 100
|
||||
assert prcxi_9300_deck._size_z == 100
|
||||
|
||||
def test_prcxi_9320_deck_creation(self, prcxi_9320_deck):
|
||||
"""测试 PRCXI 9320 工作台创建"""
|
||||
assert prcxi_9320_deck.name == "PRCXI_Deck_9320"
|
||||
assert len(prcxi_9320_deck.sites) == 16
|
||||
assert prcxi_9320_deck._size_x == 100
|
||||
assert prcxi_9320_deck._size_y == 100
|
||||
assert prcxi_9320_deck._size_z == 100
|
||||
|
||||
def test_container_assignment(self, prcxi_9300_deck, tip_rack_300ul, well_plate_96, trash_container):
|
||||
"""测试容器分配到工作台"""
|
||||
# 分配枪头盒
|
||||
prcxi_9300_deck.assign_child_resource(tip_rack_300ul, location=Coordinate(0, 0, 0))
|
||||
assert tip_rack_300ul in prcxi_9300_deck.children
|
||||
|
||||
# 分配孔板
|
||||
prcxi_9300_deck.assign_child_resource(well_plate_96, location=Coordinate(0, 0, 0))
|
||||
assert well_plate_96 in prcxi_9300_deck.children
|
||||
|
||||
# 分配垃圾桶
|
||||
prcxi_9300_deck.assign_child_resource(trash_container, location=Coordinate(0, 0, 0))
|
||||
assert trash_container in prcxi_9300_deck.children
|
||||
|
||||
def test_container_material_loading(self, tip_rack_300ul, well_plate_96, prcxi_materials):
|
||||
"""测试容器物料信息加载"""
|
||||
# 测试枪头盒物料信息
|
||||
tip_material = tip_rack_300ul._unilabos_state["Material"]
|
||||
assert tip_material["uuid"] == prcxi_materials["300μL Tip头"]["uuid"]
|
||||
assert tip_material["Name"] == "300μL Tip头"
|
||||
|
||||
# 测试孔板物料信息
|
||||
plate_material = well_plate_96._unilabos_state["Material"]
|
||||
assert plate_material["uuid"] == prcxi_materials["96深孔板"]["uuid"]
|
||||
assert plate_material["Name"] == "96深孔板"
|
||||
|
||||
|
||||
class TestPRCXISingleStepOperations:
|
||||
"""测试 PRCXI 单步操作功能"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pick_up_tips_single_channel(self, prcxi_9320_handler, prcxi_9320_deck, tip_rack_10ul):
|
||||
"""测试单通道拾取枪头"""
|
||||
# 将枪头盒添加到工作台
|
||||
prcxi_9320_deck.assign_child_resource(tip_rack_10ul, location=Coordinate(0, 0, 0))
|
||||
|
||||
# 初始化处理器
|
||||
await prcxi_9320_handler.setup()
|
||||
|
||||
# 设置枪头盒
|
||||
prcxi_9320_handler.set_tiprack([tip_rack_10ul])
|
||||
|
||||
# 创建模拟的枪头位置
|
||||
from pylabrobot.resources import TipSpot, Tip
|
||||
tip = Tip(has_filter=False, total_tip_length=10, maximal_volume=10, fitting_depth=5)
|
||||
tip_spot = TipSpot("A1", size_x=1, size_y=1, size_z=1, make_tip=lambda: tip)
|
||||
tip_rack_10ul.assign_child_resource(tip_spot, location=Coordinate(0, 0, 0))
|
||||
|
||||
# 直接测试后端方法
|
||||
from pylabrobot.liquid_handling import Pickup
|
||||
pickup = Pickup(resource=tip_spot, offset=None, tip=tip)
|
||||
await prcxi_9320_handler._unilabos_backend.pick_up_tips([pickup], [0])
|
||||
|
||||
# 验证步骤已添加到待办列表
|
||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
||||
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
||||
assert step["Function"] == "Load"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pick_up_tips_multi_channel(self, prcxi_9300_handler, tip_rack_300ul):
|
||||
"""测试多通道拾取枪头"""
|
||||
# 设置枪头盒
|
||||
prcxi_9300_handler.set_tiprack([tip_rack_300ul])
|
||||
|
||||
# 拾取8个枪头
|
||||
tip_spots = tip_rack_300ul.children[:8]
|
||||
await prcxi_9300_handler.pick_up_tips(tip_spots, [0, 1, 2, 3, 4, 5, 6, 7])
|
||||
|
||||
# 验证步骤已添加到待办列表
|
||||
assert len(prcxi_9300_handler._unilabos_backend.steps_todo_list) == 1
|
||||
step = prcxi_9300_handler._unilabos_backend.steps_todo_list[0]
|
||||
assert step["Function"] == "Load"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aspirate_single_channel(self, prcxi_9320_handler, well_plate_96):
|
||||
"""测试单通道吸取液体"""
|
||||
# 设置液体
|
||||
well = well_plate_96.get_item("A1")
|
||||
prcxi_9320_handler.set_liquid([well], ["water"], [50])
|
||||
|
||||
# 吸取液体
|
||||
await prcxi_9320_handler.aspirate([well], [50], [0])
|
||||
|
||||
# 验证步骤已添加到待办列表
|
||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
||||
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
||||
assert step["Function"] == "Imbibing"
|
||||
assert step["DosageNum"] == 50
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispense_single_channel(self, prcxi_9320_handler, well_plate_96):
|
||||
"""测试单通道分配液体"""
|
||||
# 分配液体
|
||||
well = well_plate_96.get_item("A1")
|
||||
await prcxi_9320_handler.dispense([well], [25], [0])
|
||||
|
||||
# 验证步骤已添加到待办列表
|
||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
||||
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
||||
assert step["Function"] == "Tapping"
|
||||
assert step["DosageNum"] == 25
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mix_single_channel(self, prcxi_9320_handler, well_plate_96):
|
||||
"""测试单通道混合液体"""
|
||||
# 混合液体
|
||||
well = well_plate_96.get_item("A1")
|
||||
await prcxi_9320_handler.mix([well], mix_time=3, mix_vol=50)
|
||||
|
||||
# 验证步骤已添加到待办列表
|
||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
||||
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
||||
assert step["Function"] == "Blending"
|
||||
assert step["BlendingTimes"] == 3
|
||||
assert step["DosageNum"] == 50
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drop_tips_to_trash(self, prcxi_9320_handler, trash_container):
|
||||
"""测试丢弃枪头到垃圾桶"""
|
||||
# 丢弃枪头
|
||||
await prcxi_9320_handler.drop_tips([trash_container], [0])
|
||||
|
||||
# 验证步骤已添加到待办列表
|
||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
||||
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
||||
assert step["Function"] == "UnLoad"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discard_tips(self, prcxi_9320_handler):
|
||||
"""测试丢弃枪头"""
|
||||
# 丢弃枪头
|
||||
await prcxi_9320_handler.discard_tips([0])
|
||||
|
||||
# 验证步骤已添加到待办列表
|
||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
||||
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
||||
assert step["Function"] == "UnLoad"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_liquid_transfer_workflow(self, prcxi_9320_handler, tip_rack_10ul, well_plate_96):
|
||||
"""测试完整的液体转移工作流程"""
|
||||
# 设置枪头盒和液体
|
||||
prcxi_9320_handler.set_tiprack([tip_rack_10ul])
|
||||
source_well = well_plate_96.get_item("A1")
|
||||
target_well = well_plate_96.get_item("B1")
|
||||
prcxi_9320_handler.set_liquid([source_well], ["water"], [100])
|
||||
|
||||
# 创建协议
|
||||
await prcxi_9320_handler.create_protocol(protocol_name="Test Transfer Protocol")
|
||||
|
||||
# 执行转移流程
|
||||
tip_spot = tip_rack_10ul.get_item("A1")
|
||||
await prcxi_9320_handler.pick_up_tips([tip_spot], [0])
|
||||
await prcxi_9320_handler.aspirate([source_well], [50], [0])
|
||||
await prcxi_9320_handler.dispense([target_well], [50], [0])
|
||||
await prcxi_9320_handler.discard_tips([0])
|
||||
|
||||
# 验证所有步骤都已添加
|
||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 4
|
||||
functions = [step["Function"] for step in prcxi_9320_handler._unilabos_backend.steps_todo_list]
|
||||
assert functions == ["Load", "Imbibing", "Tapping", "UnLoad"]
|
||||
|
||||
|
||||
class TestPRCXILayoutRecommendation:
|
||||
"""测试 PRCXI 板位推荐功能"""
|
||||
|
||||
def test_9300_layout_creation(self, default_layout_9300):
|
||||
"""测试 PRCXI 9300 布局创建"""
|
||||
layout_info = default_layout_9300.get_layout()
|
||||
assert layout_info["rows"] == 2
|
||||
assert layout_info["columns"] == 3
|
||||
assert len(layout_info["layout"]) == 6
|
||||
assert layout_info["trash_slot"] == 6
|
||||
assert "waste_liquid_slot" not in layout_info
|
||||
|
||||
def test_9320_layout_creation(self, default_layout_9320):
|
||||
"""测试 PRCXI 9320 布局创建"""
|
||||
layout_info = default_layout_9320.get_layout()
|
||||
assert layout_info["rows"] == 4
|
||||
assert layout_info["columns"] == 4
|
||||
assert len(layout_info["layout"]) == 16
|
||||
assert layout_info["trash_slot"] == 16
|
||||
assert layout_info["waste_liquid_slot"] == 12
|
||||
|
||||
def test_layout_recommendation_9320(self, default_layout_9320, prcxi_materials):
|
||||
"""测试 PRCXI 9320 板位推荐功能"""
|
||||
# 添加物料信息
|
||||
default_layout_9320.add_lab_resource(prcxi_materials)
|
||||
|
||||
# 推荐布局
|
||||
needs = [
|
||||
("reagent_1", "96 细胞培养皿", 3),
|
||||
("reagent_2", "12道储液槽", 1),
|
||||
("reagent_3", "200μL Tip头", 7),
|
||||
("reagent_4", "10μL加长 Tip头", 1),
|
||||
]
|
||||
|
||||
matrix_layout, layout_list = default_layout_9320.recommend_layout(needs)
|
||||
|
||||
# 验证返回结果
|
||||
assert "MatrixId" in matrix_layout
|
||||
assert "MatrixName" in matrix_layout
|
||||
assert "MatrixCount" in matrix_layout
|
||||
assert "WorkTablets" in matrix_layout
|
||||
assert len(layout_list) == 12 # 3+1+7+1 = 12个位置
|
||||
|
||||
# 验证推荐的位置不包含预留位置
|
||||
reserved_positions = {12, 16}
|
||||
recommended_positions = [item["positions"] for item in layout_list]
|
||||
for pos in recommended_positions:
|
||||
assert pos not in reserved_positions
|
||||
|
||||
def test_layout_recommendation_insufficient_space(self, default_layout_9320, prcxi_materials):
|
||||
"""测试板位推荐空间不足的情况"""
|
||||
# 添加物料信息
|
||||
default_layout_9320.add_lab_resource(prcxi_materials)
|
||||
|
||||
# 尝试推荐超过可用空间的布局
|
||||
needs = [
|
||||
("reagent_1", "96 细胞培养皿", 15), # 需要15个位置,但只有14个可用
|
||||
]
|
||||
|
||||
with pytest.raises(ValueError, match="需要 .* 个位置,但只有 .* 个可用位置"):
|
||||
default_layout_9320.recommend_layout(needs)
|
||||
|
||||
def test_layout_recommendation_material_not_found(self, default_layout_9320, prcxi_materials):
|
||||
"""测试板位推荐物料不存在的情况"""
|
||||
# 添加物料信息
|
||||
default_layout_9320.add_lab_resource(prcxi_materials)
|
||||
|
||||
# 尝试推荐不存在的物料
|
||||
needs = [
|
||||
("reagent_1", "不存在的物料", 1),
|
||||
]
|
||||
|
||||
with pytest.raises(ValueError, match="Material .* not found in lab resources"):
|
||||
default_layout_9320.recommend_layout(needs)
|
||||
|
||||
|
||||
class TestPRCXIBackendOperations:
|
||||
"""测试 PRCXI 后端操作功能"""
|
||||
|
||||
def test_backend_initialization(self, prcxi_9300_handler):
|
||||
"""测试后端初始化"""
|
||||
backend = prcxi_9300_handler._unilabos_backend
|
||||
assert isinstance(backend, PRCXI9300Backend)
|
||||
assert backend._num_channels == 8
|
||||
assert backend.debug is True
|
||||
|
||||
def test_protocol_creation(self, prcxi_9300_handler):
|
||||
"""测试协议创建"""
|
||||
backend = prcxi_9300_handler._unilabos_backend
|
||||
backend.create_protocol("Test Protocol")
|
||||
assert backend.protocol_name == "Test Protocol"
|
||||
assert len(backend.steps_todo_list) == 0
|
||||
|
||||
def test_channel_validation(self):
|
||||
"""测试通道验证"""
|
||||
# 测试正确的8通道配置
|
||||
valid_channels = [0, 1, 2, 3, 4, 5, 6, 7]
|
||||
result = PRCXI9300Backend.check_channels(valid_channels)
|
||||
assert result == valid_channels
|
||||
|
||||
# 测试错误的通道配置
|
||||
invalid_channels = [0, 1, 2, 3]
|
||||
result = PRCXI9300Backend.check_channels(invalid_channels)
|
||||
assert result == [0, 1, 2, 3, 4, 5, 6, 7]
|
||||
|
||||
def test_matrix_info_creation(self, prcxi_9300_handler):
|
||||
"""测试矩阵信息创建"""
|
||||
backend = prcxi_9300_handler._unilabos_backend
|
||||
backend.create_protocol("Test Protocol")
|
||||
|
||||
# 模拟运行协议时的矩阵信息创建
|
||||
run_time = 1234567890
|
||||
matrix_info = MatrixInfo(
|
||||
MatrixId=f"{int(run_time)}",
|
||||
MatrixName=f"protocol_{run_time}",
|
||||
MatrixCount=len(backend.tablets_info),
|
||||
WorkTablets=backend.tablets_info,
|
||||
)
|
||||
|
||||
assert matrix_info["MatrixId"] == str(int(run_time))
|
||||
assert matrix_info["MatrixName"] == f"protocol_{run_time}"
|
||||
assert "WorkTablets" in matrix_info
|
||||
|
||||
|
||||
class TestPRCXIContainerOperations:
|
||||
"""测试 PRCXI 容器操作功能"""
|
||||
|
||||
def test_container_serialization(self, tip_rack_300ul):
|
||||
"""测试容器序列化"""
|
||||
serialized = tip_rack_300ul.serialize_state()
|
||||
assert "Material" in serialized
|
||||
assert serialized["Material"]["Name"] == "300μL Tip头"
|
||||
|
||||
def test_container_deserialization(self, tip_rack_300ul):
|
||||
"""测试容器反序列化"""
|
||||
# 序列化
|
||||
serialized = tip_rack_300ul.serialize_state()
|
||||
|
||||
# 创建新容器并反序列化
|
||||
new_tip_rack = PRCXI9300Container(
|
||||
name="new_tip_rack",
|
||||
size_x=50,
|
||||
size_y=50,
|
||||
size_z=10,
|
||||
category="tip_rack",
|
||||
ordering=collections.OrderedDict()
|
||||
)
|
||||
new_tip_rack.load_state(serialized)
|
||||
|
||||
assert new_tip_rack._unilabos_state["Material"]["Name"] == "300μL Tip头"
|
||||
|
||||
def test_trash_container_creation(self, prcxi_materials):
|
||||
"""测试垃圾桶容器创建"""
|
||||
trash = PRCXI9300Trash(name="trash", size_x=50, size_y=50, size_z=10, category="trash")
|
||||
trash.load_state({
|
||||
"Material": {
|
||||
"uuid": prcxi_materials["废弃槽"]["uuid"]
|
||||
}
|
||||
})
|
||||
|
||||
assert trash.name == "trash"
|
||||
assert trash._unilabos_state["Material"]["uuid"] == prcxi_materials["废弃槽"]["uuid"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 运行测试
|
||||
pytest.main([__file__, "-v"])
|
||||
296
tests/compile/test_batch_transfer_protocol.py
Normal file
296
tests/compile/test_batch_transfer_protocol.py
Normal file
@@ -0,0 +1,296 @@
|
||||
"""
|
||||
批量转运编译器测试
|
||||
|
||||
覆盖:单物料退化、刚好一批、多批次、空操作、AGV 配置发现、children dict 状态。
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import networkx as nx
|
||||
|
||||
from unilabos.compile.batch_transfer_protocol import generate_batch_transfer_protocol
|
||||
from unilabos.compile.agv_transfer_protocol import generate_agv_transfer_protocol
|
||||
from unilabos.compile._agv_utils import find_agv_config, get_agv_capacity, split_batches
|
||||
|
||||
|
||||
# ============ 构建测试用设备图 ============
|
||||
|
||||
def _make_graph(capacity_x=2, capacity_y=1, capacity_z=1):
|
||||
"""构建包含 AGV 节点的测试设备图"""
|
||||
G = nx.DiGraph()
|
||||
|
||||
# AGV 节点
|
||||
G.add_node("AGV", **{
|
||||
"type": "device",
|
||||
"class_": "agv_transport_station",
|
||||
"config": {
|
||||
"protocol_type": ["AGVTransferProtocol", "BatchTransferProtocol"],
|
||||
"device_roles": {
|
||||
"navigator": "zhixing_agv",
|
||||
"arm": "zhixing_ur_arm"
|
||||
},
|
||||
"route_table": {
|
||||
"StationA->StationB": {
|
||||
"nav_command": '{"target": "LM1"}',
|
||||
"arm_pick": '{"task_name": "pick.urp"}',
|
||||
"arm_place": '{"task_name": "place.urp"}'
|
||||
},
|
||||
"AGV->StationA": {
|
||||
"nav_command": '{"target": "LM1"}',
|
||||
"arm_pick": '{"task_name": "pick.urp"}',
|
||||
"arm_place": '{"task_name": "place.urp"}'
|
||||
},
|
||||
"StationA->StationA": {
|
||||
"nav_command": '{"target": "LM1"}',
|
||||
"arm_pick": '{"task_name": "pick.urp"}',
|
||||
"arm_place": '{"task_name": "place.urp"}'
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
# AGV 子设备
|
||||
G.add_node("zhixing_agv", type="device", class_="zhixing_agv")
|
||||
G.add_node("zhixing_ur_arm", type="device", class_="zhixing_ur_arm")
|
||||
G.add_edge("AGV", "zhixing_agv")
|
||||
G.add_edge("AGV", "zhixing_ur_arm")
|
||||
|
||||
# AGV Warehouse 子资源
|
||||
G.add_node("agv_platform", **{
|
||||
"type": "warehouse",
|
||||
"config": {
|
||||
"name": "agv_platform",
|
||||
"num_items_x": capacity_x,
|
||||
"num_items_y": capacity_y,
|
||||
"num_items_z": capacity_z,
|
||||
}
|
||||
})
|
||||
G.add_edge("AGV", "agv_platform")
|
||||
|
||||
# 来源/目标工站
|
||||
G.add_node("StationA", type="device", class_="workstation")
|
||||
G.add_node("StationB", type="device", class_="workstation")
|
||||
|
||||
return G
|
||||
|
||||
|
||||
def _make_repos(items_count=2):
|
||||
"""构建测试用的 from_repo 和 to_repo dict"""
|
||||
children = {}
|
||||
for i in range(items_count):
|
||||
pos = f"A{i + 1:02d}"
|
||||
children[pos] = {
|
||||
"id": f"resource_{i + 1}",
|
||||
"name": f"R{i + 1}",
|
||||
"parent": "StationA",
|
||||
"type": "resource",
|
||||
}
|
||||
|
||||
from_repo = {
|
||||
"StationA": {
|
||||
"id": "StationA",
|
||||
"name": "StationA",
|
||||
"children": children,
|
||||
}
|
||||
}
|
||||
to_repo = {
|
||||
"StationB": {
|
||||
"id": "StationB",
|
||||
"name": "StationB",
|
||||
"children": {},
|
||||
}
|
||||
}
|
||||
return from_repo, to_repo
|
||||
|
||||
|
||||
def _make_items(count=2):
|
||||
"""构建 transfer_resources / from_positions / to_positions"""
|
||||
resources = [
|
||||
{
|
||||
"id": f"resource_{i + 1}",
|
||||
"name": f"R{i + 1}",
|
||||
"sample_id": f"uuid-{i + 1}",
|
||||
"parent": "StationA",
|
||||
"type": "resource",
|
||||
}
|
||||
for i in range(count)
|
||||
]
|
||||
from_positions = [f"A{i + 1:02d}" for i in range(count)]
|
||||
to_positions = [f"A{i + 1:02d}" for i in range(count)]
|
||||
return resources, from_positions, to_positions
|
||||
|
||||
|
||||
# ============ _agv_utils 测试 ============
|
||||
|
||||
class TestAGVUtils:
|
||||
def test_find_agv_config(self):
|
||||
G = _make_graph()
|
||||
cfg = find_agv_config(G)
|
||||
assert cfg["agv_id"] == "AGV"
|
||||
assert cfg["device_roles"]["navigator"] == "zhixing_agv"
|
||||
assert cfg["device_roles"]["arm"] == "zhixing_ur_arm"
|
||||
assert "StationA->StationB" in cfg["route_table"]
|
||||
|
||||
def test_find_agv_config_by_id(self):
|
||||
G = _make_graph()
|
||||
cfg = find_agv_config(G, agv_id="AGV")
|
||||
assert cfg["agv_id"] == "AGV"
|
||||
|
||||
def test_find_agv_config_not_found(self):
|
||||
G = nx.DiGraph()
|
||||
G.add_node("SomeDevice", type="device", class_="pump")
|
||||
with pytest.raises(ValueError, match="未找到 AGV"):
|
||||
find_agv_config(G)
|
||||
|
||||
def test_get_agv_capacity(self):
|
||||
G = _make_graph(capacity_x=2, capacity_y=1, capacity_z=1)
|
||||
assert get_agv_capacity(G, "AGV") == 2
|
||||
|
||||
def test_get_agv_capacity_multi_layer(self):
|
||||
G = _make_graph(capacity_x=1, capacity_y=2, capacity_z=3)
|
||||
assert get_agv_capacity(G, "AGV") == 6
|
||||
|
||||
def test_split_batches_exact(self):
|
||||
assert split_batches([1, 2], 2) == [[1, 2]]
|
||||
|
||||
def test_split_batches_overflow(self):
|
||||
assert split_batches([1, 2, 3], 2) == [[1, 2], [3]]
|
||||
|
||||
def test_split_batches_single(self):
|
||||
assert split_batches([1], 4) == [[1]]
|
||||
|
||||
def test_split_batches_zero_capacity(self):
|
||||
with pytest.raises(ValueError):
|
||||
split_batches([1], 0)
|
||||
|
||||
|
||||
# ============ 批量转运编译器测试 ============
|
||||
|
||||
class TestBatchTransferProtocol:
|
||||
def test_empty_items(self):
|
||||
"""空物料列表返回空 steps"""
|
||||
G = _make_graph()
|
||||
from_repo, to_repo = _make_repos(0)
|
||||
steps = generate_batch_transfer_protocol(G, from_repo, to_repo, [], [], [])
|
||||
assert steps == []
|
||||
|
||||
def test_single_item(self):
|
||||
"""单物料转运(BatchTransfer 退化为单物料)"""
|
||||
G = _make_graph(capacity_x=2)
|
||||
from_repo, to_repo = _make_repos(1)
|
||||
resources, from_pos, to_pos = _make_items(1)
|
||||
steps = generate_batch_transfer_protocol(G, from_repo, to_repo, resources, from_pos, to_pos)
|
||||
|
||||
# 应该有: nav到来源 + 1个pick + nav到目标 + 1个place = 4 steps
|
||||
assert len(steps) == 4
|
||||
assert steps[0]["action_name"] == "send_nav_task"
|
||||
assert steps[1]["action_name"] == "move_pos_task"
|
||||
assert steps[1]["_transfer_meta"]["phase"] == "pick"
|
||||
assert steps[2]["action_name"] == "send_nav_task"
|
||||
assert steps[3]["action_name"] == "move_pos_task"
|
||||
assert steps[3]["_transfer_meta"]["phase"] == "place"
|
||||
|
||||
def test_exact_capacity(self):
|
||||
"""物料数 = AGV 容量,刚好一批"""
|
||||
G = _make_graph(capacity_x=2)
|
||||
from_repo, to_repo = _make_repos(2)
|
||||
resources, from_pos, to_pos = _make_items(2)
|
||||
steps = generate_batch_transfer_protocol(G, from_repo, to_repo, resources, from_pos, to_pos)
|
||||
|
||||
# nav + 2 pick + nav + 2 place = 6 steps
|
||||
assert len(steps) == 6
|
||||
pick_steps = [s for s in steps if s.get("_transfer_meta", {}).get("phase") == "pick"]
|
||||
place_steps = [s for s in steps if s.get("_transfer_meta", {}).get("phase") == "place"]
|
||||
assert len(pick_steps) == 2
|
||||
assert len(place_steps) == 2
|
||||
|
||||
def test_multi_batch(self):
|
||||
"""物料数 > AGV 容量,自动分批"""
|
||||
G = _make_graph(capacity_x=2)
|
||||
from_repo, to_repo = _make_repos(3)
|
||||
resources, from_pos, to_pos = _make_items(3)
|
||||
steps = generate_batch_transfer_protocol(G, from_repo, to_repo, resources, from_pos, to_pos)
|
||||
|
||||
# 批次1: nav + 2 pick + nav + 2 place + nav(返回) = 7
|
||||
# 批次2: nav + 1 pick + nav + 1 place = 4
|
||||
# 总计 11 steps
|
||||
assert len(steps) == 11
|
||||
|
||||
nav_steps = [s for s in steps if s["action_name"] == "send_nav_task"]
|
||||
# 批次1: 2 nav(去来源+去目标) + 1 nav(返回) + 批次2: 2 nav = 5 nav
|
||||
assert len(nav_steps) == 5
|
||||
|
||||
def test_children_dict_updated(self):
|
||||
"""compile 阶段三方 children dict 状态正确"""
|
||||
G = _make_graph(capacity_x=2)
|
||||
from_repo, to_repo = _make_repos(2)
|
||||
resources, from_pos, to_pos = _make_items(2)
|
||||
|
||||
assert "A01" in from_repo["StationA"]["children"]
|
||||
assert "A02" in from_repo["StationA"]["children"]
|
||||
assert len(to_repo["StationB"]["children"]) == 0
|
||||
|
||||
generate_batch_transfer_protocol(G, from_repo, to_repo, resources, from_pos, to_pos)
|
||||
|
||||
# compile 后 from_repo 的 children 应该被 pop 掉
|
||||
assert "A01" not in from_repo["StationA"]["children"]
|
||||
assert "A02" not in from_repo["StationA"]["children"]
|
||||
# to_repo 应该有新物料
|
||||
assert "A01" in to_repo["StationB"]["children"]
|
||||
assert "A02" in to_repo["StationB"]["children"]
|
||||
assert to_repo["StationB"]["children"]["A01"]["id"] == "resource_1"
|
||||
|
||||
def test_device_ids_from_config(self):
|
||||
"""设备 ID 全部从配置读取,不硬编码"""
|
||||
G = _make_graph()
|
||||
from_repo, to_repo = _make_repos(1)
|
||||
resources, from_pos, to_pos = _make_items(1)
|
||||
steps = generate_batch_transfer_protocol(G, from_repo, to_repo, resources, from_pos, to_pos)
|
||||
|
||||
device_ids = {s["device_id"] for s in steps}
|
||||
assert "zhixing_agv" in device_ids
|
||||
assert "zhixing_ur_arm" in device_ids
|
||||
|
||||
def test_route_not_found(self):
|
||||
"""路由表中无对应路线时报错"""
|
||||
G = _make_graph()
|
||||
from_repo = {"Unknown": {"id": "Unknown", "children": {"A01": {"id": "R1", "parent": "Unknown"}}}}
|
||||
to_repo = {"Other": {"id": "Other", "children": {}}}
|
||||
resources = [{"id": "R1", "name": "R1"}]
|
||||
with pytest.raises(KeyError, match="路由表"):
|
||||
generate_batch_transfer_protocol(G, from_repo, to_repo, resources, ["A01"], ["B01"])
|
||||
|
||||
def test_length_mismatch(self):
|
||||
"""三个数组长度不一致时报错"""
|
||||
G = _make_graph()
|
||||
from_repo, to_repo = _make_repos(2)
|
||||
resources = [{"id": "R1"}]
|
||||
with pytest.raises(ValueError, match="长度不一致"):
|
||||
generate_batch_transfer_protocol(G, from_repo, to_repo, resources, ["A01", "A02"], ["B01"])
|
||||
|
||||
|
||||
# ============ 改造后的 AGV 单物料编译器测试 ============
|
||||
|
||||
class TestAGVTransferProtocol:
|
||||
def test_single_transfer_from_config(self):
|
||||
"""改造后的单物料编译器从 G 读取配置"""
|
||||
G = _make_graph()
|
||||
from_repo = {"StationA": {"id": "StationA", "children": {"A01": {"id": "R1", "parent": "StationA"}}}}
|
||||
to_repo = {"StationB": {"id": "StationB", "children": {}}}
|
||||
steps = generate_agv_transfer_protocol(G, from_repo, "A01", to_repo, "B01")
|
||||
|
||||
assert len(steps) == 2
|
||||
assert steps[0]["device_id"] == "zhixing_agv"
|
||||
assert steps[0]["action_name"] == "send_nav_task"
|
||||
assert steps[1]["device_id"] == "zhixing_ur_arm"
|
||||
assert steps[1]["action_name"] == "move_pos_task"
|
||||
|
||||
def test_children_updated(self):
|
||||
"""单物料编译后 children dict 正确更新"""
|
||||
G = _make_graph()
|
||||
from_repo = {"StationA": {"id": "StationA", "children": {"A01": {"id": "R1", "parent": "StationA"}}}}
|
||||
to_repo = {"StationB": {"id": "StationB", "children": {}}}
|
||||
generate_agv_transfer_protocol(G, from_repo, "A01", to_repo, "B01")
|
||||
|
||||
assert "A01" not in from_repo["StationA"]["children"]
|
||||
assert "B01" in to_repo["StationB"]["children"]
|
||||
assert to_repo["StationB"]["children"]["B01"]["parent"] == "StationB"
|
||||
706
tests/compile/test_full_chain_conversion_to_compile.py
Normal file
706
tests/compile/test_full_chain_conversion_to_compile.py
Normal file
@@ -0,0 +1,706 @@
|
||||
"""
|
||||
全链路集成测试:ROS Goal 转换 → ResourceTreeSet → get_plr_nested_dict → 编译器 → 动作列表
|
||||
|
||||
模拟 workstation.py 中的完整路径:
|
||||
1. host 返回 raw_data(模拟 resource_get 响应)
|
||||
2. ResourceTreeSet.from_raw_dict_list(raw_data) 构建资源树
|
||||
3. tree.root_node.get_plr_nested_dict() 生成嵌套 dict
|
||||
4. protocol_kwargs 传给编译器
|
||||
5. 编译器返回 action_list,验证结构和关键字段
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import pytest
|
||||
import networkx as nx
|
||||
|
||||
from unilabos.resources.resource_tracker import (
|
||||
ResourceDictInstance,
|
||||
ResourceTreeSet,
|
||||
)
|
||||
from unilabos.compile.utils.resource_helper import (
|
||||
ensure_resource_instance,
|
||||
resource_to_dict,
|
||||
get_resource_id,
|
||||
get_resource_data,
|
||||
)
|
||||
from unilabos.compile.utils.vessel_parser import get_vessel
|
||||
|
||||
# ============ 构建模拟设备图 ============
|
||||
|
||||
def _build_test_graph():
|
||||
"""构建一个包含常用设备节点的测试图"""
|
||||
G = nx.DiGraph()
|
||||
|
||||
# 容器
|
||||
G.add_node("reactor_01", **{
|
||||
"id": "reactor_01",
|
||||
"name": "reactor_01",
|
||||
"type": "device",
|
||||
"class": "virtual_stirrer",
|
||||
"data": {},
|
||||
"config": {},
|
||||
})
|
||||
|
||||
# 搅拌设备
|
||||
G.add_node("stirrer_1", **{
|
||||
"id": "stirrer_1",
|
||||
"name": "stirrer_1",
|
||||
"type": "device",
|
||||
"class": "virtual_stirrer",
|
||||
"data": {},
|
||||
"config": {},
|
||||
})
|
||||
G.add_edge("stirrer_1", "reactor_01")
|
||||
|
||||
# 加热设备
|
||||
G.add_node("heatchill_1", **{
|
||||
"id": "heatchill_1",
|
||||
"name": "heatchill_1",
|
||||
"type": "device",
|
||||
"class": "virtual_heatchill",
|
||||
"data": {},
|
||||
"config": {},
|
||||
})
|
||||
G.add_edge("heatchill_1", "reactor_01")
|
||||
|
||||
# 试剂容器(液体)
|
||||
G.add_node("flask_water", **{
|
||||
"id": "flask_water",
|
||||
"name": "flask_water",
|
||||
"type": "container",
|
||||
"class": "",
|
||||
"data": {"reagent_name": "water", "liquid": [{"liquid_type": "water", "volume": 500.0}]},
|
||||
"config": {"reagent": "water"},
|
||||
})
|
||||
|
||||
# 固体加样器
|
||||
G.add_node("solid_dispenser_1", **{
|
||||
"id": "solid_dispenser_1",
|
||||
"name": "solid_dispenser_1",
|
||||
"type": "device",
|
||||
"class": "solid_dispenser",
|
||||
"data": {},
|
||||
"config": {},
|
||||
})
|
||||
|
||||
# 泵
|
||||
G.add_node("pump_1", **{
|
||||
"id": "pump_1",
|
||||
"name": "pump_1",
|
||||
"type": "device",
|
||||
"class": "virtual_pump",
|
||||
"data": {},
|
||||
"config": {},
|
||||
})
|
||||
G.add_edge("flask_water", "pump_1")
|
||||
G.add_edge("pump_1", "reactor_01")
|
||||
|
||||
return G
|
||||
|
||||
|
||||
# ============ 构建模拟 host 返回数据 ============
|
||||
|
||||
def _make_raw_resource(
|
||||
id="reactor_01",
|
||||
uuid="uuid-reactor-01",
|
||||
name="reactor_01",
|
||||
klass="virtual_stirrer",
|
||||
type_="device",
|
||||
parent=None,
|
||||
parent_uuid=None,
|
||||
data=None,
|
||||
config=None,
|
||||
extra=None,
|
||||
):
|
||||
"""模拟 host 返回的单个资源 dict(与 resource_get 服务响应一致)"""
|
||||
return {
|
||||
"id": id,
|
||||
"uuid": uuid,
|
||||
"name": name,
|
||||
"class": klass,
|
||||
"type": type_,
|
||||
"parent": parent,
|
||||
"parent_uuid": parent_uuid or "",
|
||||
"description": "",
|
||||
"config": config or {},
|
||||
"data": data or {},
|
||||
"extra": extra or {},
|
||||
"position": {"x": 0.0, "y": 0.0, "z": 0.0},
|
||||
}
|
||||
|
||||
|
||||
def _simulate_workstation_resource_enrichment(raw_data_list, field_type="unilabos_msgs/Resource"):
|
||||
"""
|
||||
模拟 workstation.py 中 resource enrichment 的核心逻辑:
|
||||
raw_data → ResourceTreeSet.from_raw_dict_list → get_plr_nested_dict → protocol_kwargs[k]
|
||||
"""
|
||||
tree_set = ResourceTreeSet.from_raw_dict_list(raw_data_list)
|
||||
|
||||
if field_type == "unilabos_msgs/Resource":
|
||||
# 单个 Resource:取第一棵树的根节点
|
||||
root_instance = tree_set.trees[0].root_node if tree_set.trees else None
|
||||
return root_instance.get_plr_nested_dict() if root_instance else {}
|
||||
else:
|
||||
# sequence<Resource>:返回列表
|
||||
return [tree.root_node.get_plr_nested_dict() for tree in tree_set.trees]
|
||||
|
||||
|
||||
# ============ 全链路测试:Stir 协议 ============
|
||||
|
||||
class TestStirProtocolFullChain:
|
||||
"""Stir 协议全链路:host raw_data → enriched dict → compiler → action_list"""
|
||||
|
||||
def test_stir_with_enriched_resource_dict(self):
|
||||
"""单个 Resource 经过 enrichment 后传给 stir compiler"""
|
||||
from unilabos.compile.stir_protocol import generate_stir_protocol
|
||||
|
||||
raw_data = [_make_raw_resource(
|
||||
id="reactor_01", uuid="uuid-reactor-01",
|
||||
klass="virtual_stirrer", type_="device",
|
||||
)]
|
||||
|
||||
# 模拟 workstation enrichment
|
||||
enriched_vessel = _simulate_workstation_resource_enrichment(raw_data)
|
||||
|
||||
assert enriched_vessel["id"] == "reactor_01"
|
||||
assert enriched_vessel["uuid"] == "uuid-reactor-01"
|
||||
assert enriched_vessel["class"] == "virtual_stirrer"
|
||||
|
||||
# 传给编译器
|
||||
G = _build_test_graph()
|
||||
actions = generate_stir_protocol(
|
||||
G=G,
|
||||
vessel=enriched_vessel,
|
||||
time="60",
|
||||
stir_speed=300.0,
|
||||
)
|
||||
|
||||
assert isinstance(actions, list)
|
||||
assert len(actions) >= 1
|
||||
action = actions[0]
|
||||
assert action["device_id"] == "stirrer_1"
|
||||
assert action["action_name"] == "stir"
|
||||
assert "vessel" in action["action_kwargs"]
|
||||
assert action["action_kwargs"]["vessel"]["id"] == "reactor_01"
|
||||
|
||||
def test_stir_with_resource_dict_instance(self):
|
||||
"""直接用 ResourceDictInstance 传给 stir compiler(通过 get_plr_nested_dict 转换)"""
|
||||
from unilabos.compile.stir_protocol import generate_stir_protocol
|
||||
|
||||
raw_data = [_make_raw_resource(id="reactor_01")]
|
||||
tree_set = ResourceTreeSet.from_raw_dict_list(raw_data)
|
||||
inst = tree_set.trees[0].root_node
|
||||
|
||||
# 通过 resource_to_dict 转换(resource_helper 兼容层)
|
||||
vessel_dict = resource_to_dict(inst)
|
||||
assert isinstance(vessel_dict, dict)
|
||||
assert vessel_dict["id"] == "reactor_01"
|
||||
|
||||
G = _build_test_graph()
|
||||
actions = generate_stir_protocol(G=G, vessel=vessel_dict, time="30")
|
||||
|
||||
assert len(actions) >= 1
|
||||
assert actions[0]["action_name"] == "stir"
|
||||
|
||||
def test_stir_with_string_vessel(self):
|
||||
"""兼容旧模式:直接传 vessel 字符串"""
|
||||
from unilabos.compile.stir_protocol import generate_stir_protocol
|
||||
|
||||
G = _build_test_graph()
|
||||
actions = generate_stir_protocol(G=G, vessel="reactor_01", time="30")
|
||||
|
||||
assert len(actions) >= 1
|
||||
assert actions[0]["device_id"] == "stirrer_1"
|
||||
assert actions[0]["action_kwargs"]["vessel"]["id"] == "reactor_01"
|
||||
|
||||
|
||||
# ============ 全链路测试:HeatChill 协议 ============
|
||||
|
||||
class TestHeatChillProtocolFullChain:
|
||||
"""HeatChill 协议全链路"""
|
||||
|
||||
def test_heatchill_with_enriched_resource(self):
|
||||
from unilabos.compile.heatchill_protocol import generate_heat_chill_protocol
|
||||
|
||||
raw_data = [_make_raw_resource(id="reactor_01", klass="virtual_stirrer")]
|
||||
enriched_vessel = _simulate_workstation_resource_enrichment(raw_data)
|
||||
|
||||
G = _build_test_graph()
|
||||
actions = generate_heat_chill_protocol(
|
||||
G=G,
|
||||
vessel=enriched_vessel,
|
||||
temp=80.0,
|
||||
time="300",
|
||||
)
|
||||
|
||||
assert isinstance(actions, list)
|
||||
assert len(actions) >= 1
|
||||
action = actions[0]
|
||||
assert action["device_id"] == "heatchill_1"
|
||||
assert action["action_name"] == "heat_chill"
|
||||
assert action["action_kwargs"]["temp"] == 80.0
|
||||
|
||||
def test_heatchill_start_with_enriched_resource(self):
|
||||
from unilabos.compile.heatchill_protocol import generate_heat_chill_start_protocol
|
||||
|
||||
raw_data = [_make_raw_resource(id="reactor_01")]
|
||||
enriched_vessel = _simulate_workstation_resource_enrichment(raw_data)
|
||||
|
||||
G = _build_test_graph()
|
||||
actions = generate_heat_chill_start_protocol(
|
||||
G=G,
|
||||
vessel=enriched_vessel,
|
||||
temp=60.0,
|
||||
)
|
||||
|
||||
assert len(actions) >= 1
|
||||
assert actions[0]["action_name"] == "heat_chill_start"
|
||||
assert actions[0]["action_kwargs"]["temp"] == 60.0
|
||||
|
||||
def test_heatchill_stop_with_enriched_resource(self):
|
||||
from unilabos.compile.heatchill_protocol import generate_heat_chill_stop_protocol
|
||||
|
||||
raw_data = [_make_raw_resource(id="reactor_01")]
|
||||
enriched_vessel = _simulate_workstation_resource_enrichment(raw_data)
|
||||
|
||||
G = _build_test_graph()
|
||||
actions = generate_heat_chill_stop_protocol(G=G, vessel=enriched_vessel)
|
||||
|
||||
assert len(actions) >= 1
|
||||
assert actions[0]["action_name"] == "heat_chill_stop"
|
||||
|
||||
|
||||
# ============ 全链路测试:Add 协议 ============
|
||||
|
||||
class TestAddProtocolFullChain:
|
||||
"""Add 协议全链路:vessel enrichment + reagent 查找 + 泵传输"""
|
||||
|
||||
def test_add_solid_with_enriched_resource(self):
|
||||
from unilabos.compile.add_protocol import generate_add_protocol
|
||||
|
||||
raw_data = [_make_raw_resource(id="reactor_01")]
|
||||
enriched_vessel = _simulate_workstation_resource_enrichment(raw_data)
|
||||
|
||||
G = _build_test_graph()
|
||||
actions = generate_add_protocol(
|
||||
G=G,
|
||||
vessel=enriched_vessel,
|
||||
reagent="NaCl",
|
||||
mass="5 g",
|
||||
)
|
||||
|
||||
assert isinstance(actions, list)
|
||||
assert len(actions) >= 1
|
||||
# 应该包含至少一个 add_solid 或 log_message 动作
|
||||
action_names = [a.get("action_name", "") for a in actions]
|
||||
assert any(name in ["add_solid", "log_message"] for name in action_names)
|
||||
|
||||
def test_add_liquid_with_enriched_resource(self):
|
||||
from unilabos.compile.add_protocol import generate_add_protocol
|
||||
|
||||
raw_data = [_make_raw_resource(id="reactor_01")]
|
||||
enriched_vessel = _simulate_workstation_resource_enrichment(raw_data)
|
||||
|
||||
G = _build_test_graph()
|
||||
actions = generate_add_protocol(
|
||||
G=G,
|
||||
vessel=enriched_vessel,
|
||||
reagent="water",
|
||||
volume="10 mL",
|
||||
)
|
||||
|
||||
assert isinstance(actions, list)
|
||||
assert len(actions) >= 1
|
||||
|
||||
|
||||
# ============ 全链路测试:ResourceDictInstance 兼容层 ============
|
||||
|
||||
class TestResourceDictInstanceCompatibility:
|
||||
"""验证编译器兼容层对 ResourceDictInstance 的处理"""
|
||||
|
||||
def test_get_vessel_from_enriched_dict(self):
|
||||
"""get_vessel 对 enriched dict 的处理"""
|
||||
raw_data = [_make_raw_resource(
|
||||
id="reactor_01",
|
||||
data={"temperature": 25.0, "liquid": [{"liquid_type": "water", "volume": 10.0}]},
|
||||
)]
|
||||
enriched = _simulate_workstation_resource_enrichment(raw_data)
|
||||
|
||||
vessel_id, vessel_data = get_vessel(enriched)
|
||||
assert vessel_id == "reactor_01"
|
||||
assert vessel_data["temperature"] == 25.0
|
||||
assert len(vessel_data["liquid"]) == 1
|
||||
|
||||
def test_get_vessel_from_resource_instance(self):
|
||||
"""get_vessel 直接对 ResourceDictInstance 的处理"""
|
||||
raw_data = [_make_raw_resource(
|
||||
id="reactor_01",
|
||||
data={"temperature": 25.0},
|
||||
)]
|
||||
tree_set = ResourceTreeSet.from_raw_dict_list(raw_data)
|
||||
inst = tree_set.trees[0].root_node
|
||||
|
||||
vessel_id, vessel_data = get_vessel(inst)
|
||||
assert vessel_id == "reactor_01"
|
||||
assert vessel_data["temperature"] == 25.0
|
||||
|
||||
def test_ensure_resource_instance_round_trip(self):
|
||||
"""ensure_resource_instance → resource_to_dict 无损往返"""
|
||||
raw_data = [_make_raw_resource(
|
||||
id="reactor_01", uuid="uuid-r01", klass="virtual_stirrer",
|
||||
data={"temp": 25.0},
|
||||
)]
|
||||
enriched = _simulate_workstation_resource_enrichment(raw_data)
|
||||
|
||||
# dict → ResourceDictInstance
|
||||
inst = ensure_resource_instance(enriched)
|
||||
assert isinstance(inst, ResourceDictInstance)
|
||||
assert inst.res_content.id == "reactor_01"
|
||||
assert inst.res_content.uuid == "uuid-r01"
|
||||
|
||||
# ResourceDictInstance → dict
|
||||
d = resource_to_dict(inst)
|
||||
assert isinstance(d, dict)
|
||||
assert d["id"] == "reactor_01"
|
||||
assert d["uuid"] == "uuid-r01"
|
||||
assert d["class"] == "virtual_stirrer"
|
||||
|
||||
|
||||
# ============ 全链路测试:带 children 的资源树 ============
|
||||
|
||||
class TestResourceTreeWithChildren:
|
||||
"""测试带 children 结构的资源树通过编译器的路径"""
|
||||
|
||||
def _make_tree_with_children(self):
|
||||
"""构建 StationA -> [Flask1, Flask2] 的资源树"""
|
||||
return [
|
||||
_make_raw_resource(
|
||||
id="StationA", uuid="uuid-station-a",
|
||||
name="StationA", klass="workstation", type_="device",
|
||||
),
|
||||
_make_raw_resource(
|
||||
id="Flask1", uuid="uuid-flask-1",
|
||||
name="Flask1", klass="", type_="resource",
|
||||
parent="StationA", parent_uuid="uuid-station-a",
|
||||
data={"liquid": [{"liquid_type": "water", "volume": 10.0}]},
|
||||
),
|
||||
_make_raw_resource(
|
||||
id="Flask2", uuid="uuid-flask-2",
|
||||
name="Flask2", klass="", type_="resource",
|
||||
parent="StationA", parent_uuid="uuid-station-a",
|
||||
data={"liquid": [{"liquid_type": "ethanol", "volume": 5.0}]},
|
||||
),
|
||||
]
|
||||
|
||||
def test_enrichment_preserves_children_structure(self):
|
||||
"""验证 enrichment 后 children 为嵌套 dict"""
|
||||
raw_data = self._make_tree_with_children()
|
||||
enriched = _simulate_workstation_resource_enrichment(raw_data)
|
||||
|
||||
assert enriched["id"] == "StationA"
|
||||
assert "children" in enriched
|
||||
assert isinstance(enriched["children"], dict)
|
||||
assert "Flask1" in enriched["children"]
|
||||
assert "Flask2" in enriched["children"]
|
||||
|
||||
def test_children_preserve_uuid_and_data(self):
|
||||
"""验证 children 中的 uuid 和 data 被正确保留"""
|
||||
raw_data = self._make_tree_with_children()
|
||||
enriched = _simulate_workstation_resource_enrichment(raw_data)
|
||||
|
||||
flask1 = enriched["children"]["Flask1"]
|
||||
assert flask1["uuid"] == "uuid-flask-1"
|
||||
assert flask1["data"]["liquid"][0]["liquid_type"] == "water"
|
||||
assert flask1["data"]["liquid"][0]["volume"] == 10.0
|
||||
|
||||
flask2 = enriched["children"]["Flask2"]
|
||||
assert flask2["uuid"] == "uuid-flask-2"
|
||||
assert flask2["data"]["liquid"][0]["liquid_type"] == "ethanol"
|
||||
|
||||
def test_children_dict_can_be_popped(self):
|
||||
"""模拟 batch_transfer_protocol 中 pop children 的操作"""
|
||||
raw_data = self._make_tree_with_children()
|
||||
enriched = _simulate_workstation_resource_enrichment(raw_data)
|
||||
|
||||
# batch_transfer_protocol 中会 pop children
|
||||
children = enriched["children"]
|
||||
popped = children.pop("Flask1")
|
||||
assert popped["id"] == "Flask1"
|
||||
assert "Flask1" not in enriched["children"]
|
||||
assert "Flask2" in enriched["children"]
|
||||
|
||||
def test_children_dict_usable_as_from_repo(self):
|
||||
"""模拟 batch_transfer_protocol 中 from_repo 参数"""
|
||||
raw_data = self._make_tree_with_children()
|
||||
enriched = _simulate_workstation_resource_enrichment(raw_data)
|
||||
|
||||
# 模拟编译器接收的 from_repo 格式
|
||||
from_repo = {"StationA": enriched}
|
||||
from_repo_ = list(from_repo.values())[0]
|
||||
|
||||
assert from_repo_["id"] == "StationA"
|
||||
assert "Flask1" in from_repo_["children"]
|
||||
assert from_repo_["children"]["Flask1"]["uuid"] == "uuid-flask-1"
|
||||
|
||||
def test_sequence_resource_enrichment(self):
|
||||
"""sequence<Resource> 情况:多个独立资源树"""
|
||||
raw_data1 = [_make_raw_resource(id="R1", uuid="uuid-r1")]
|
||||
raw_data2 = [_make_raw_resource(id="R2", uuid="uuid-r2")]
|
||||
|
||||
tree_set1 = ResourceTreeSet.from_raw_dict_list(raw_data1)
|
||||
tree_set2 = ResourceTreeSet.from_raw_dict_list(raw_data2)
|
||||
|
||||
results = [
|
||||
tree.root_node.get_plr_nested_dict()
|
||||
for ts in [tree_set1, tree_set2]
|
||||
for tree in ts.trees
|
||||
]
|
||||
|
||||
assert len(results) == 2
|
||||
assert results[0]["id"] == "R1"
|
||||
assert results[1]["id"] == "R2"
|
||||
|
||||
|
||||
# ============ 全链路测试:动作列表结构验证 ============
|
||||
|
||||
class TestActionListStructure:
|
||||
"""验证编译器返回的 action_list 结构符合 workstation 预期"""
|
||||
|
||||
def _validate_action(self, action):
|
||||
"""验证单个 action dict 的结构"""
|
||||
if action.get("action_name") == "wait":
|
||||
# wait 伪动作不需要 device_id
|
||||
assert "action_kwargs" in action
|
||||
assert "time" in action["action_kwargs"]
|
||||
return
|
||||
|
||||
if action.get("action_name") == "log_message":
|
||||
# log 伪动作
|
||||
assert "action_kwargs" in action
|
||||
return
|
||||
|
||||
# 正常设备动作
|
||||
assert "device_id" in action, f"action 缺少 device_id: {action}"
|
||||
assert "action_name" in action, f"action 缺少 action_name: {action}"
|
||||
assert "action_kwargs" in action, f"action 缺少 action_kwargs: {action}"
|
||||
assert isinstance(action["action_kwargs"], dict)
|
||||
|
||||
def test_stir_action_list_structure(self):
|
||||
from unilabos.compile.stir_protocol import generate_stir_protocol
|
||||
|
||||
raw_data = [_make_raw_resource(id="reactor_01")]
|
||||
enriched = _simulate_workstation_resource_enrichment(raw_data)
|
||||
|
||||
G = _build_test_graph()
|
||||
actions = generate_stir_protocol(G=G, vessel=enriched, time="60")
|
||||
|
||||
for action in actions:
|
||||
if isinstance(action, list):
|
||||
# 并行动作
|
||||
for sub_action in action:
|
||||
self._validate_action(sub_action)
|
||||
else:
|
||||
self._validate_action(action)
|
||||
|
||||
def test_heatchill_action_list_structure(self):
|
||||
from unilabos.compile.heatchill_protocol import generate_heat_chill_protocol
|
||||
|
||||
raw_data = [_make_raw_resource(id="reactor_01")]
|
||||
enriched = _simulate_workstation_resource_enrichment(raw_data)
|
||||
|
||||
G = _build_test_graph()
|
||||
actions = generate_heat_chill_protocol(G=G, vessel=enriched, temp=80.0, time="60")
|
||||
|
||||
for action in actions:
|
||||
if isinstance(action, list):
|
||||
for sub_action in action:
|
||||
self._validate_action(sub_action)
|
||||
else:
|
||||
self._validate_action(action)
|
||||
|
||||
def test_add_action_list_structure(self):
|
||||
from unilabos.compile.add_protocol import generate_add_protocol
|
||||
|
||||
raw_data = [_make_raw_resource(id="reactor_01")]
|
||||
enriched = _simulate_workstation_resource_enrichment(raw_data)
|
||||
|
||||
G = _build_test_graph()
|
||||
actions = generate_add_protocol(G=G, vessel=enriched, reagent="NaCl", mass="5 g")
|
||||
|
||||
for action in actions:
|
||||
if isinstance(action, list):
|
||||
for sub_action in action:
|
||||
self._validate_action(sub_action)
|
||||
else:
|
||||
self._validate_action(action)
|
||||
|
||||
|
||||
# ============ 全链路测试:message_converter 到 enrichment ============
|
||||
|
||||
class TestMessageConverterToEnrichment:
|
||||
"""模拟从 ROS 消息转换后的 dict 到 enrichment 的完整链路"""
|
||||
|
||||
def test_ros_goal_conversion_simulation(self):
|
||||
"""
|
||||
模拟 workstation.py 中的完整流程:
|
||||
1. ROS goal 中的 vessel 字段被 convert_from_ros_msg 转换为浅层 dict
|
||||
2. workstation 用 resource_id 请求 host 获取完整资源数据
|
||||
3. ResourceTreeSet.from_raw_dict_list 构建资源树
|
||||
4. get_plr_nested_dict 生成嵌套 dict 替换 protocol_kwargs[k]
|
||||
"""
|
||||
# 步骤1: 模拟 convert_from_ros_msg 的输出(浅层 dict,只有 id 等基本字段)
|
||||
shallow_vessel = {
|
||||
"id": "reactor_01",
|
||||
"uuid": "uuid-reactor-01",
|
||||
"name": "reactor_01",
|
||||
"type": "device",
|
||||
"category": "virtual_stirrer",
|
||||
"children": [],
|
||||
"parent": "",
|
||||
"parent_uuid": "",
|
||||
"config": {},
|
||||
"data": {},
|
||||
"extra": {},
|
||||
"position": {"x": 0.0, "y": 0.0, "z": 0.0},
|
||||
}
|
||||
|
||||
protocol_kwargs = {
|
||||
"vessel": shallow_vessel,
|
||||
"time": "300",
|
||||
"stir_speed": 300.0,
|
||||
}
|
||||
|
||||
# 步骤2: 提取 resource_id
|
||||
resource_id = protocol_kwargs["vessel"]["id"]
|
||||
assert resource_id == "reactor_01"
|
||||
|
||||
# 步骤3: 模拟 host 返回完整数据(带 children)
|
||||
host_response = [
|
||||
_make_raw_resource(
|
||||
id="reactor_01", uuid="uuid-reactor-01",
|
||||
klass="virtual_stirrer", type_="device",
|
||||
data={"temperature": 25.0, "pressure": 1.0},
|
||||
config={"max_temp": 300.0},
|
||||
),
|
||||
]
|
||||
|
||||
# 步骤4: enrichment
|
||||
enriched = _simulate_workstation_resource_enrichment(host_response)
|
||||
protocol_kwargs["vessel"] = enriched
|
||||
|
||||
# 验证 enrichment 后的 protocol_kwargs
|
||||
assert protocol_kwargs["vessel"]["id"] == "reactor_01"
|
||||
assert protocol_kwargs["vessel"]["uuid"] == "uuid-reactor-01"
|
||||
assert protocol_kwargs["vessel"]["class"] == "virtual_stirrer"
|
||||
assert protocol_kwargs["vessel"]["data"]["temperature"] == 25.0
|
||||
assert protocol_kwargs["vessel"]["config"]["max_temp"] == 300.0
|
||||
|
||||
# 步骤5: 传给编译器
|
||||
from unilabos.compile.stir_protocol import generate_stir_protocol
|
||||
G = _build_test_graph()
|
||||
actions = generate_stir_protocol(G=G, **protocol_kwargs)
|
||||
|
||||
assert len(actions) >= 1
|
||||
assert actions[0]["device_id"] == "stirrer_1"
|
||||
assert actions[0]["action_name"] == "stir"
|
||||
|
||||
def test_ros_goal_with_children_enrichment(self):
|
||||
"""ROS goal → enrichment 带 children 的场景(batch transfer)"""
|
||||
# 模拟 host 返回带 children 的数据
|
||||
host_response = [
|
||||
_make_raw_resource(
|
||||
id="StationA", uuid="uuid-sa", klass="workstation", type_="device",
|
||||
config={"num_items_x": 4, "num_items_y": 2},
|
||||
),
|
||||
_make_raw_resource(
|
||||
id="Plate1", uuid="uuid-p1", type_="resource",
|
||||
parent="StationA", parent_uuid="uuid-sa",
|
||||
data={"sample": "sample_A"},
|
||||
),
|
||||
_make_raw_resource(
|
||||
id="Plate2", uuid="uuid-p2", type_="resource",
|
||||
parent="StationA", parent_uuid="uuid-sa",
|
||||
data={"sample": "sample_B"},
|
||||
),
|
||||
]
|
||||
|
||||
enriched = _simulate_workstation_resource_enrichment(host_response)
|
||||
|
||||
assert enriched["id"] == "StationA"
|
||||
assert enriched["class"] == "workstation"
|
||||
assert len(enriched["children"]) == 2
|
||||
assert enriched["children"]["Plate1"]["data"]["sample"] == "sample_A"
|
||||
assert enriched["children"]["Plate2"]["uuid"] == "uuid-p2"
|
||||
|
||||
# 模拟 batch_transfer 的 from_repo 格式
|
||||
from_repo = {"StationA": enriched}
|
||||
from_repo_ = list(from_repo.values())[0]
|
||||
assert "Plate1" in from_repo_["children"]
|
||||
assert from_repo_["children"]["Plate1"]["uuid"] == "uuid-p1"
|
||||
|
||||
|
||||
# ============ 全链路测试:多协议连续调用 ============
|
||||
|
||||
class TestMultiProtocolChain:
|
||||
"""模拟连续执行多个协议(如 add → stir → heatchill)"""
|
||||
|
||||
def test_sequential_protocol_execution(self):
|
||||
"""模拟典型合成路径:add → stir → heatchill"""
|
||||
from unilabos.compile.stir_protocol import generate_stir_protocol
|
||||
from unilabos.compile.heatchill_protocol import generate_heat_chill_protocol
|
||||
from unilabos.compile.add_protocol import generate_add_protocol
|
||||
|
||||
raw_data = [_make_raw_resource(
|
||||
id="reactor_01", uuid="uuid-reactor-01",
|
||||
klass="virtual_stirrer", type_="device",
|
||||
)]
|
||||
enriched = _simulate_workstation_resource_enrichment(raw_data)
|
||||
G = _build_test_graph()
|
||||
|
||||
# 每次调用用 enriched 的副本,避免编译器修改原数据
|
||||
all_actions = []
|
||||
|
||||
# 步骤1: 添加试剂
|
||||
add_actions = generate_add_protocol(
|
||||
G=G, vessel=copy.deepcopy(enriched),
|
||||
reagent="NaCl", mass="5 g",
|
||||
)
|
||||
all_actions.extend(add_actions)
|
||||
|
||||
# 步骤2: 搅拌
|
||||
stir_actions = generate_stir_protocol(
|
||||
G=G, vessel=copy.deepcopy(enriched),
|
||||
time="60", stir_speed=300.0,
|
||||
)
|
||||
all_actions.extend(stir_actions)
|
||||
|
||||
# 步骤3: 加热
|
||||
heat_actions = generate_heat_chill_protocol(
|
||||
G=G, vessel=copy.deepcopy(enriched),
|
||||
temp=80.0, time="300",
|
||||
)
|
||||
all_actions.extend(heat_actions)
|
||||
|
||||
# 验证总动作列表
|
||||
assert len(all_actions) >= 3
|
||||
# 每个协议至少产生一个核心动作
|
||||
action_names = [a.get("action_name", "") for a in all_actions if isinstance(a, dict)]
|
||||
assert "stir" in action_names
|
||||
assert "heat_chill" in action_names
|
||||
|
||||
def test_enriched_resource_not_mutated(self):
|
||||
"""验证编译器不应修改传入的 enriched dict(如果需要修改应 deepcopy)"""
|
||||
from unilabos.compile.stir_protocol import generate_stir_protocol
|
||||
|
||||
raw_data = [_make_raw_resource(id="reactor_01")]
|
||||
enriched = _simulate_workstation_resource_enrichment(raw_data)
|
||||
original_id = enriched["id"]
|
||||
original_uuid = enriched["uuid"]
|
||||
|
||||
G = _build_test_graph()
|
||||
generate_stir_protocol(G=G, vessel=enriched, time="60")
|
||||
|
||||
# 验证 enriched dict 核心字段未被修改
|
||||
assert enriched["id"] == original_id
|
||||
assert enriched["uuid"] == original_uuid
|
||||
538
tests/compile/test_pump_separate_full_chain.py
Normal file
538
tests/compile/test_pump_separate_full_chain.py
Normal file
@@ -0,0 +1,538 @@
|
||||
"""
|
||||
PumpTransfer 和 Separate 全链路测试
|
||||
|
||||
构建包含泵/阀门/分液漏斗的完整设备图,
|
||||
输出完整的中间数据(最短路径、泵骨架、动作列表等)。
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import pprint
|
||||
import pytest
|
||||
import networkx as nx
|
||||
|
||||
from unilabos.resources.resource_tracker import ResourceTreeSet
|
||||
from unilabos.compile.utils.resource_helper import get_resource_id, get_resource_data
|
||||
from unilabos.compile.utils.vessel_parser import get_vessel
|
||||
|
||||
|
||||
def _make_raw_resource(id, uuid=None, name=None, klass="", type_="device",
|
||||
parent=None, parent_uuid=None, data=None, config=None, extra=None):
|
||||
return {
|
||||
"id": id,
|
||||
"uuid": uuid or f"uuid-{id}",
|
||||
"name": name or id,
|
||||
"class": klass,
|
||||
"type": type_,
|
||||
"parent": parent,
|
||||
"parent_uuid": parent_uuid or "",
|
||||
"description": "",
|
||||
"config": config or {},
|
||||
"data": data or {},
|
||||
"extra": extra or {},
|
||||
"position": {"x": 0.0, "y": 0.0, "z": 0.0},
|
||||
}
|
||||
|
||||
|
||||
def _simulate_enrichment(raw_data_list):
|
||||
tree_set = ResourceTreeSet.from_raw_dict_list(raw_data_list)
|
||||
root = tree_set.trees[0].root_node if tree_set.trees else None
|
||||
return root.get_plr_nested_dict() if root else {}
|
||||
|
||||
|
||||
def _build_pump_transfer_graph():
|
||||
"""
|
||||
构建带泵/阀门的设备图,用于测试 PumpTransfer:
|
||||
|
||||
flask_water (container)
|
||||
↓
|
||||
valve_1 (multiway_valve, pump_1 连接)
|
||||
↓
|
||||
reactor_01 (device)
|
||||
|
||||
同时有: stirrer_1, heatchill_1, separator_1
|
||||
"""
|
||||
G = nx.DiGraph()
|
||||
|
||||
# 源容器
|
||||
G.add_node("flask_water", **{
|
||||
"id": "flask_water", "name": "flask_water",
|
||||
"type": "container", "class": "",
|
||||
"data": {"reagent_name": "water", "liquid": [{"liquid_type": "water", "volume": 200.0}]},
|
||||
"config": {"reagent": "water"},
|
||||
})
|
||||
|
||||
# 多通阀
|
||||
G.add_node("valve_1", **{
|
||||
"id": "valve_1", "name": "valve_1",
|
||||
"type": "device", "class": "multiway_valve",
|
||||
"data": {}, "config": {},
|
||||
})
|
||||
|
||||
# 注射泵(连接到阀门)
|
||||
G.add_node("pump_1", **{
|
||||
"id": "pump_1", "name": "pump_1",
|
||||
"type": "device", "class": "virtual_pump",
|
||||
"data": {}, "config": {"max_volume": 25.0},
|
||||
})
|
||||
|
||||
# 目标容器
|
||||
G.add_node("reactor_01", **{
|
||||
"id": "reactor_01", "name": "reactor_01",
|
||||
"type": "device", "class": "virtual_stirrer",
|
||||
"data": {"liquid": [{"liquid_type": "water", "volume": 50.0}]},
|
||||
"config": {},
|
||||
})
|
||||
|
||||
# 搅拌器
|
||||
G.add_node("stirrer_1", **{
|
||||
"id": "stirrer_1", "name": "stirrer_1",
|
||||
"type": "device", "class": "virtual_stirrer",
|
||||
"data": {}, "config": {},
|
||||
})
|
||||
|
||||
# 加热器
|
||||
G.add_node("heatchill_1", **{
|
||||
"id": "heatchill_1", "name": "heatchill_1",
|
||||
"type": "device", "class": "virtual_heatchill",
|
||||
"data": {}, "config": {},
|
||||
})
|
||||
|
||||
# 分离器
|
||||
G.add_node("separator_1", **{
|
||||
"id": "separator_1", "name": "separator_1",
|
||||
"type": "device", "class": "separator_controller",
|
||||
"data": {}, "config": {},
|
||||
})
|
||||
|
||||
# 废液容器
|
||||
G.add_node("waste_workup", **{
|
||||
"id": "waste_workup", "name": "waste_workup",
|
||||
"type": "container", "class": "",
|
||||
"data": {}, "config": {},
|
||||
})
|
||||
|
||||
# 产物收集瓶
|
||||
G.add_node("product_flask", **{
|
||||
"id": "product_flask", "name": "product_flask",
|
||||
"type": "container", "class": "",
|
||||
"data": {}, "config": {},
|
||||
})
|
||||
|
||||
# DCM溶剂瓶
|
||||
G.add_node("flask_dcm", **{
|
||||
"id": "flask_dcm", "name": "flask_dcm",
|
||||
"type": "container", "class": "",
|
||||
"data": {"reagent_name": "dcm", "liquid": [{"liquid_type": "dcm", "volume": 500.0}]},
|
||||
"config": {"reagent": "dcm"},
|
||||
})
|
||||
|
||||
# 边连接 —— flask_water → valve_1 → reactor_01
|
||||
G.add_edge("flask_water", "valve_1", port={"valve_1": "port_1"})
|
||||
G.add_edge("valve_1", "reactor_01", port={"valve_1": "port_2"})
|
||||
# 阀门 → 泵
|
||||
G.add_edge("valve_1", "pump_1")
|
||||
G.add_edge("pump_1", "valve_1")
|
||||
# 搅拌器 ↔ reactor
|
||||
G.add_edge("stirrer_1", "reactor_01")
|
||||
# 加热器 ↔ reactor
|
||||
G.add_edge("heatchill_1", "reactor_01")
|
||||
# 分离器 ↔ reactor
|
||||
G.add_edge("separator_1", "reactor_01")
|
||||
G.add_edge("reactor_01", "separator_1")
|
||||
# DCM → valve → reactor (同一泵路)
|
||||
G.add_edge("flask_dcm", "valve_1", port={"valve_1": "port_3"})
|
||||
# reactor → valve → product/waste
|
||||
G.add_edge("valve_1", "product_flask", port={"valve_1": "port_4"})
|
||||
G.add_edge("valve_1", "waste_workup", port={"valve_1": "port_5"})
|
||||
|
||||
return G
|
||||
|
||||
|
||||
def _format_action(action, indent=0):
|
||||
"""格式化单个 action 为可读字符串"""
|
||||
prefix = " " * indent
|
||||
if isinstance(action, list):
|
||||
# 并行动作
|
||||
lines = [f"{prefix}[PARALLEL]"]
|
||||
for sub in action:
|
||||
lines.append(_format_action(sub, indent + 1))
|
||||
return "\n".join(lines)
|
||||
|
||||
name = action.get("action_name", "?")
|
||||
device = action.get("device_id", "")
|
||||
kwargs = action.get("action_kwargs", {})
|
||||
comment = action.get("_comment", "")
|
||||
meta = action.get("_transfer_meta", "")
|
||||
|
||||
parts = [f"{prefix}→ {device}::{name}"]
|
||||
if kwargs:
|
||||
# 精简输出
|
||||
kw_str = ", ".join(f"{k}={v}" for k, v in kwargs.items()
|
||||
if k not in ("progress_message",))
|
||||
if kw_str:
|
||||
parts.append(f" kwargs: {{{kw_str}}}")
|
||||
if comment:
|
||||
parts.append(f" # {comment}")
|
||||
if meta:
|
||||
parts.append(f" meta: {meta}")
|
||||
return "\n".join(f"{prefix}{p}" if i > 0 else p for i, p in enumerate(parts))
|
||||
|
||||
|
||||
def _dump_actions(actions, title=""):
|
||||
"""打印完整动作列表"""
|
||||
print(f"\n{'='*70}")
|
||||
print(f" {title}")
|
||||
print(f" 总动作数: {len(actions)}")
|
||||
print(f"{'='*70}")
|
||||
for i, action in enumerate(actions):
|
||||
print(f"\n [{i:02d}] {_format_action(action, indent=2)}")
|
||||
print(f"\n{'='*70}\n")
|
||||
|
||||
|
||||
# ==================== PumpTransfer 全链路 ====================
|
||||
|
||||
class TestPumpTransferFullChain:
|
||||
"""PumpTransfer: 包含图路径查找、泵骨架构建、动作序列生成"""
|
||||
|
||||
def test_pump_transfer_basic(self):
|
||||
"""基础泵转移:flask_water → valve_1 → reactor_01"""
|
||||
from unilabos.compile.pump_protocol import generate_pump_protocol
|
||||
|
||||
G = _build_pump_transfer_graph()
|
||||
|
||||
# 检查最短路径
|
||||
path = nx.shortest_path(G, "flask_water", "reactor_01")
|
||||
print(f"\n最短路径: {path}")
|
||||
assert "valve_1" in path
|
||||
|
||||
# 调用编译器
|
||||
actions = generate_pump_protocol(
|
||||
G=G,
|
||||
from_vessel_id="flask_water",
|
||||
to_vessel_id="reactor_01",
|
||||
volume=10.0,
|
||||
flowrate=2.5,
|
||||
transfer_flowrate=0.5,
|
||||
)
|
||||
|
||||
_dump_actions(actions, "PumpTransfer: flask_water → reactor_01, 10mL")
|
||||
|
||||
# 验证
|
||||
assert isinstance(actions, list)
|
||||
assert len(actions) > 0
|
||||
# 应该有 set_valve_position 和 set_position 动作
|
||||
flat = [a for a in actions if isinstance(a, dict)]
|
||||
action_names = [a.get("action_name") for a in flat]
|
||||
print(f"动作名称列表: {action_names}")
|
||||
assert "set_valve_position" in action_names
|
||||
assert "set_position" in action_names
|
||||
|
||||
def test_pump_transfer_with_rinsing_enriched_vessel(self):
|
||||
"""pump_with_rinsing 接收 enriched vessel dict"""
|
||||
from unilabos.compile.pump_protocol import generate_pump_protocol_with_rinsing
|
||||
|
||||
G = _build_pump_transfer_graph()
|
||||
|
||||
# 模拟 enrichment
|
||||
from_raw = [_make_raw_resource(
|
||||
id="flask_water", klass="", type_="container",
|
||||
data={"reagent_name": "water", "liquid": [{"liquid_type": "water", "volume": 200.0}]},
|
||||
)]
|
||||
to_raw = [_make_raw_resource(
|
||||
id="reactor_01", klass="virtual_stirrer", type_="device",
|
||||
)]
|
||||
|
||||
from_enriched = _simulate_enrichment(from_raw)
|
||||
to_enriched = _simulate_enrichment(to_raw)
|
||||
|
||||
print(f"\nfrom_vessel enriched: {json.dumps(from_enriched, indent=2, ensure_ascii=False)[:300]}...")
|
||||
print(f"to_vessel enriched: {json.dumps(to_enriched, indent=2, ensure_ascii=False)[:300]}...")
|
||||
|
||||
# get_vessel 兼容
|
||||
fid, fdata = get_vessel(from_enriched)
|
||||
tid, tdata = get_vessel(to_enriched)
|
||||
print(f"from_vessel_id={fid}, to_vessel_id={tid}")
|
||||
assert fid == "flask_water"
|
||||
assert tid == "reactor_01"
|
||||
|
||||
actions = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
from_vessel=from_enriched,
|
||||
to_vessel=to_enriched,
|
||||
volume=15.0,
|
||||
flowrate=2.5,
|
||||
transfer_flowrate=0.5,
|
||||
)
|
||||
|
||||
_dump_actions(actions, "PumpTransferWithRinsing: flask_water → reactor_01, 15mL (enriched)")
|
||||
|
||||
assert isinstance(actions, list)
|
||||
assert len(actions) > 0
|
||||
|
||||
def test_pump_transfer_multi_batch(self):
|
||||
"""体积 > max_volume 时自动分批"""
|
||||
from unilabos.compile.pump_protocol import generate_pump_protocol
|
||||
|
||||
G = _build_pump_transfer_graph()
|
||||
|
||||
# pump_1 的 max_volume = 25mL,转 60mL 应该分 3 批
|
||||
actions = generate_pump_protocol(
|
||||
G=G,
|
||||
from_vessel_id="flask_water",
|
||||
to_vessel_id="reactor_01",
|
||||
volume=60.0,
|
||||
flowrate=2.5,
|
||||
transfer_flowrate=0.5,
|
||||
)
|
||||
|
||||
_dump_actions(actions, "PumpTransfer 分批: 60mL (max_volume=25mL, 预期 3 批)")
|
||||
|
||||
assert len(actions) > 0
|
||||
# 应该有多轮 set_position
|
||||
flat = [a for a in actions if isinstance(a, dict)]
|
||||
set_position_count = sum(1 for a in flat if a.get("action_name") == "set_position")
|
||||
print(f"set_position 动作数: {set_position_count}")
|
||||
# 3批 × 2次 (吸液 + 排液) = 6 次 set_position
|
||||
assert set_position_count >= 6
|
||||
|
||||
def test_pump_transfer_no_path(self):
|
||||
"""无路径时返回空"""
|
||||
from unilabos.compile.pump_protocol import generate_pump_protocol
|
||||
|
||||
G = _build_pump_transfer_graph()
|
||||
G.add_node("isolated_flask", type="container")
|
||||
|
||||
actions = generate_pump_protocol(
|
||||
G=G,
|
||||
from_vessel_id="isolated_flask",
|
||||
to_vessel_id="reactor_01",
|
||||
volume=10.0,
|
||||
)
|
||||
|
||||
print(f"\n无路径时的动作列表: {actions}")
|
||||
assert actions == []
|
||||
|
||||
def test_pump_backbone_filtering(self):
|
||||
"""验证泵骨架过滤逻辑(电磁阀被跳过)"""
|
||||
from unilabos.compile.pump_protocol import generate_pump_protocol
|
||||
|
||||
G = _build_pump_transfer_graph()
|
||||
# 添加电磁阀到路径中
|
||||
G.add_node("solenoid_valve_1", **{
|
||||
"type": "device", "class": "solenoid_valve",
|
||||
"data": {}, "config": {},
|
||||
})
|
||||
# flask_water → solenoid_valve_1 → valve_1 → reactor_01
|
||||
G.remove_edge("flask_water", "valve_1")
|
||||
G.add_edge("flask_water", "solenoid_valve_1")
|
||||
G.add_edge("solenoid_valve_1", "valve_1")
|
||||
|
||||
path = nx.shortest_path(G, "flask_water", "reactor_01")
|
||||
print(f"\n含电磁阀的路径: {path}")
|
||||
assert "solenoid_valve_1" in path
|
||||
|
||||
actions = generate_pump_protocol(
|
||||
G=G,
|
||||
from_vessel_id="flask_water",
|
||||
to_vessel_id="reactor_01",
|
||||
volume=10.0,
|
||||
)
|
||||
|
||||
_dump_actions(actions, "PumpTransfer 含电磁阀: flask_water → solenoid → valve_1 → reactor_01")
|
||||
# 电磁阀应被跳过,泵骨架只有 valve_1
|
||||
assert len(actions) > 0
|
||||
|
||||
|
||||
# ==================== Separate 全链路 ====================
|
||||
|
||||
class TestSeparateProtocolFullChain:
|
||||
"""Separate: 包含 bug 确认和正常路径测试"""
|
||||
|
||||
def test_separate_bug_line_128_fixed(self):
|
||||
"""验证 separate_protocol.py:128 的 bug 已修复(不再 crash)"""
|
||||
from unilabos.compile.separate_protocol import generate_separate_protocol
|
||||
|
||||
G = _build_pump_transfer_graph()
|
||||
|
||||
raw_data = [_make_raw_resource(
|
||||
id="reactor_01", klass="virtual_stirrer",
|
||||
data={"liquid": [{"liquid_type": "water", "volume": 100.0}]},
|
||||
)]
|
||||
enriched = _simulate_enrichment(raw_data)
|
||||
|
||||
# 修复前:final_vessel_id, _ = vessel_id 会 crash(字符串解包)
|
||||
# 修复后:final_vessel_id = vessel_id,正常返回 action 列表
|
||||
result = generate_separate_protocol(
|
||||
G=G,
|
||||
vessel=enriched,
|
||||
purpose="extract",
|
||||
product_phase="top",
|
||||
product_vessel="product_flask",
|
||||
waste_vessel="waste_workup",
|
||||
solvent="dcm",
|
||||
volume="100 mL",
|
||||
)
|
||||
assert isinstance(result, list)
|
||||
assert len(result) > 0
|
||||
|
||||
def test_separate_manual_workaround(self):
|
||||
"""
|
||||
绕过 line 128 bug,手动测试分离编译器中可以工作的子函数
|
||||
"""
|
||||
from unilabos.compile.separate_protocol import (
|
||||
find_separator_device,
|
||||
find_separation_vessel_bottom,
|
||||
)
|
||||
from unilabos.compile.utils.vessel_parser import (
|
||||
find_connected_stirrer,
|
||||
find_solvent_vessel,
|
||||
)
|
||||
from unilabos.compile.utils.unit_parser import parse_volume_input
|
||||
from unilabos.compile.utils.resource_helper import get_resource_liquid_volume as get_vessel_liquid_volume
|
||||
|
||||
G = _build_pump_transfer_graph()
|
||||
|
||||
# 1. get_vessel 解析 enriched dict
|
||||
raw_data = [_make_raw_resource(
|
||||
id="reactor_01", klass="virtual_stirrer",
|
||||
data={"liquid": [{"liquid_type": "water", "volume": 100.0}]},
|
||||
)]
|
||||
enriched = _simulate_enrichment(raw_data)
|
||||
vessel_id, vessel_data = get_vessel(enriched)
|
||||
print(f"\nvessel_id: {vessel_id}")
|
||||
print(f"vessel_data: {vessel_data}")
|
||||
assert vessel_id == "reactor_01"
|
||||
assert vessel_data["liquid"][0]["volume"] == 100.0
|
||||
|
||||
# 2. find_separator_device
|
||||
sep = find_separator_device(G, vessel_id)
|
||||
print(f"分离器设备: {sep}")
|
||||
assert sep == "separator_1"
|
||||
|
||||
# 3. find_connected_stirrer
|
||||
stirrer = find_connected_stirrer(G, vessel_id)
|
||||
print(f"搅拌器设备: {stirrer}")
|
||||
assert stirrer == "stirrer_1"
|
||||
|
||||
# 4. find_solvent_vessel
|
||||
solvent_v = find_solvent_vessel(G, "dcm")
|
||||
print(f"DCM溶剂容器: {solvent_v}")
|
||||
assert solvent_v == "flask_dcm"
|
||||
|
||||
# 5. parse_volume_input
|
||||
vol = parse_volume_input("200 mL")
|
||||
print(f"体积解析: '200 mL' → {vol}")
|
||||
assert vol == 200.0
|
||||
|
||||
vol2 = parse_volume_input("1.5 L")
|
||||
print(f"体积解析: '1.5 L' → {vol2}")
|
||||
assert vol2 == 1500.0
|
||||
|
||||
# 6. get_vessel_liquid_volume
|
||||
liq_vol = get_vessel_liquid_volume(enriched)
|
||||
print(f"液体体积 (enriched dict): {liq_vol}")
|
||||
assert liq_vol == 100.0
|
||||
|
||||
# 7. find_separation_vessel_bottom
|
||||
bottom = find_separation_vessel_bottom(G, vessel_id)
|
||||
print(f"分离容器底部: {bottom}")
|
||||
# 当前图中没有命名匹配的底部容器
|
||||
|
||||
def test_pump_transfer_for_separate_subflow(self):
|
||||
"""测试 separate 中调用的 pump 子流程(溶剂添加 → 分液漏斗)"""
|
||||
from unilabos.compile.pump_protocol import generate_pump_protocol_with_rinsing
|
||||
|
||||
G = _build_pump_transfer_graph()
|
||||
|
||||
# 模拟分离前的溶剂添加步骤
|
||||
actions = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
from_vessel="flask_dcm",
|
||||
to_vessel="reactor_01",
|
||||
volume=100.0,
|
||||
flowrate=2.5,
|
||||
transfer_flowrate=0.5,
|
||||
)
|
||||
|
||||
_dump_actions(actions, "Separate 子流程: flask_dcm → reactor_01, 100mL DCM")
|
||||
|
||||
assert isinstance(actions, list)
|
||||
assert len(actions) > 0
|
||||
|
||||
# 模拟分离后产物转移
|
||||
actions2 = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
from_vessel="reactor_01",
|
||||
to_vessel="product_flask",
|
||||
volume=50.0,
|
||||
flowrate=2.5,
|
||||
transfer_flowrate=0.5,
|
||||
)
|
||||
|
||||
_dump_actions(actions2, "Separate 子流程: reactor_01 → product_flask, 50mL 产物")
|
||||
|
||||
assert len(actions2) > 0
|
||||
|
||||
# 废液转移
|
||||
actions3 = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
from_vessel="reactor_01",
|
||||
to_vessel="waste_workup",
|
||||
volume=50.0,
|
||||
flowrate=2.5,
|
||||
transfer_flowrate=0.5,
|
||||
)
|
||||
|
||||
_dump_actions(actions3, "Separate 子流程: reactor_01 → waste_workup, 50mL 废液")
|
||||
|
||||
assert len(actions3) > 0
|
||||
|
||||
|
||||
# ==================== 图路径可视化 ====================
|
||||
|
||||
class TestGraphPathVisualization:
|
||||
"""输出图中关键路径信息"""
|
||||
|
||||
def test_all_shortest_paths(self):
|
||||
"""输出所有容器之间的最短路径"""
|
||||
G = _build_pump_transfer_graph()
|
||||
|
||||
containers = [n for n in G.nodes() if G.nodes[n].get("type") == "container"]
|
||||
devices = [n for n in G.nodes() if G.nodes[n].get("type") == "device"]
|
||||
|
||||
print(f"\n{'='*70}")
|
||||
print(f" 设备图概览")
|
||||
print(f"{'='*70}")
|
||||
print(f" 容器节点 ({len(containers)}): {containers}")
|
||||
print(f" 设备节点 ({len(devices)}): {devices}")
|
||||
print(f" 边数: {G.number_of_edges()}")
|
||||
print(f" 边列表:")
|
||||
for u, v, data in G.edges(data=True):
|
||||
port_info = data.get("port", "")
|
||||
print(f" {u} → {v} {port_info if port_info else ''}")
|
||||
|
||||
print(f"\n 关键路径:")
|
||||
pairs = [
|
||||
("flask_water", "reactor_01"),
|
||||
("flask_dcm", "reactor_01"),
|
||||
("reactor_01", "product_flask"),
|
||||
("reactor_01", "waste_workup"),
|
||||
("flask_water", "product_flask"),
|
||||
]
|
||||
for src, dst in pairs:
|
||||
try:
|
||||
path = nx.shortest_path(G, src, dst)
|
||||
length = len(path) - 1
|
||||
# 标注路径上的节点类型
|
||||
annotated = []
|
||||
for n in path:
|
||||
ntype = G.nodes[n].get("type", "?")
|
||||
nclass = G.nodes[n].get("class", "")
|
||||
annotated.append(f"{n}({ntype}{'/' + nclass if nclass else ''})")
|
||||
print(f" {src} → {dst}: 距离={length}")
|
||||
print(f" 路径: {' → '.join(annotated)}")
|
||||
except nx.NetworkXNoPath:
|
||||
print(f" {src} → {dst}: 无路径!")
|
||||
|
||||
print(f"{'='*70}\n")
|
||||
324
tests/compile/test_resource_conversion_path.py
Normal file
324
tests/compile/test_resource_conversion_path.py
Normal file
@@ -0,0 +1,324 @@
|
||||
"""
|
||||
ROS Goal → Resource 转换 → 编译器路径的集成测试
|
||||
|
||||
覆盖:
|
||||
1. Resource.msg 新字段(uuid, klass, extra)的往返转换
|
||||
2. dict → ROS Resource → dict 往返无损
|
||||
3. ResourceTreeSet → get_plr_nested_dict 保留 children 结构
|
||||
4. resource_helper 兼容 dict / ResourceDictInstance
|
||||
5. vessel_parser.get_vessel 兼容 ResourceDictInstance
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
|
||||
# 不依赖 ROS 的测试 —— 直接测试 resource 处理路径
|
||||
from unilabos.resources.resource_tracker import (
|
||||
ResourceDict,
|
||||
ResourceDictInstance,
|
||||
ResourceTreeInstance,
|
||||
ResourceTreeSet,
|
||||
)
|
||||
from unilabos.compile.utils.resource_helper import (
|
||||
ensure_resource_instance,
|
||||
resource_to_dict,
|
||||
get_resource_id,
|
||||
get_resource_data,
|
||||
get_resource_display_info,
|
||||
get_resource_liquid_volume,
|
||||
)
|
||||
from unilabos.compile.utils.vessel_parser import get_vessel
|
||||
|
||||
|
||||
# ============ 构建测试数据 ============
|
||||
|
||||
|
||||
def _make_resource_dict(
|
||||
id="reactor_01",
|
||||
uuid="uuid-reactor-01",
|
||||
name="reactor_01",
|
||||
klass="virtual_stirrer",
|
||||
type_="device",
|
||||
parent=None,
|
||||
parent_uuid=None,
|
||||
data=None,
|
||||
config=None,
|
||||
extra=None,
|
||||
):
|
||||
return {
|
||||
"id": id,
|
||||
"uuid": uuid,
|
||||
"name": name,
|
||||
"class": klass,
|
||||
"type": type_,
|
||||
"parent": parent,
|
||||
"parent_uuid": parent_uuid or "",
|
||||
"description": "",
|
||||
"config": config or {},
|
||||
"data": data or {},
|
||||
"extra": extra or {},
|
||||
"position": {"x": 1.0, "y": 2.0, "z": 3.0},
|
||||
}
|
||||
|
||||
|
||||
def _make_resource_instance(id="reactor_01", **kwargs):
|
||||
d = _make_resource_dict(id=id, **kwargs)
|
||||
return ResourceDictInstance.get_resource_instance_from_dict(d)
|
||||
|
||||
|
||||
def _make_tree_with_children():
|
||||
"""构建 StationA -> [R1, R2] 的资源树"""
|
||||
raw_data = [
|
||||
_make_resource_dict(
|
||||
id="StationA",
|
||||
uuid="uuid-station-a",
|
||||
name="StationA",
|
||||
klass="workstation",
|
||||
type_="device",
|
||||
),
|
||||
_make_resource_dict(
|
||||
id="R1",
|
||||
uuid="uuid-r1",
|
||||
name="R1",
|
||||
klass="",
|
||||
type_="resource",
|
||||
parent="StationA",
|
||||
parent_uuid="uuid-station-a",
|
||||
data={"liquid": [{"liquid_type": "water", "volume": 10.0}]},
|
||||
),
|
||||
_make_resource_dict(
|
||||
id="R2",
|
||||
uuid="uuid-r2",
|
||||
name="R2",
|
||||
klass="",
|
||||
type_="resource",
|
||||
parent="StationA",
|
||||
parent_uuid="uuid-station-a",
|
||||
data={"liquid": [{"liquid_type": "ethanol", "volume": 5.0}]},
|
||||
),
|
||||
]
|
||||
tree_set = ResourceTreeSet.from_raw_dict_list(raw_data)
|
||||
return tree_set
|
||||
|
||||
|
||||
# ============ resource_helper 测试 ============
|
||||
|
||||
|
||||
class TestResourceHelper:
|
||||
"""测试 resource_helper 对 dict / ResourceDictInstance 的兼容性"""
|
||||
|
||||
def test_ensure_resource_instance_from_dict(self):
|
||||
d = _make_resource_dict()
|
||||
inst = ensure_resource_instance(d)
|
||||
assert isinstance(inst, ResourceDictInstance)
|
||||
assert inst.res_content.id == "reactor_01"
|
||||
assert inst.res_content.uuid == "uuid-reactor-01"
|
||||
|
||||
def test_ensure_resource_instance_passthrough(self):
|
||||
inst = _make_resource_instance()
|
||||
result = ensure_resource_instance(inst)
|
||||
assert result is inst # 同一个对象,不复制
|
||||
|
||||
def test_ensure_resource_instance_none(self):
|
||||
assert ensure_resource_instance(None) is None
|
||||
|
||||
def test_get_resource_id_from_dict(self):
|
||||
d = _make_resource_dict(id="my_device")
|
||||
assert get_resource_id(d) == "my_device"
|
||||
|
||||
def test_get_resource_id_from_instance(self):
|
||||
inst = _make_resource_instance(id="my_device")
|
||||
assert get_resource_id(inst) == "my_device"
|
||||
|
||||
def test_get_resource_id_from_string(self):
|
||||
assert get_resource_id("my_device") == "my_device"
|
||||
|
||||
def test_get_resource_id_from_wrapped_dict(self):
|
||||
"""兼容 {station_id: {...}} 格式"""
|
||||
d = {"StationA": {"id": "StationA", "name": "StationA"}}
|
||||
assert get_resource_id(d) == "StationA"
|
||||
|
||||
def test_get_resource_data_from_dict(self):
|
||||
d = _make_resource_dict(data={"temperature": 25.0})
|
||||
assert get_resource_data(d) == {"temperature": 25.0}
|
||||
|
||||
def test_get_resource_data_from_instance(self):
|
||||
inst = _make_resource_instance(data={"temperature": 25.0})
|
||||
data = get_resource_data(inst)
|
||||
assert data["temperature"] == 25.0
|
||||
|
||||
def test_get_resource_display_info_from_dict(self):
|
||||
d = _make_resource_dict(id="reactor_01", name="Reactor #1")
|
||||
info = get_resource_display_info(d)
|
||||
assert "reactor_01" in info
|
||||
assert "Reactor #1" in info
|
||||
|
||||
def test_get_resource_display_info_from_instance(self):
|
||||
inst = _make_resource_instance(id="reactor_01", name="Reactor #1")
|
||||
info = get_resource_display_info(inst)
|
||||
assert "reactor_01" in info
|
||||
|
||||
def test_get_resource_display_info_from_string(self):
|
||||
assert get_resource_display_info("reactor_01") == "reactor_01"
|
||||
|
||||
def test_get_resource_liquid_volume(self):
|
||||
d = _make_resource_dict(data={"liquid": [{"liquid_type": "water", "volume": 15.5}]})
|
||||
assert get_resource_liquid_volume(d) == pytest.approx(15.5)
|
||||
|
||||
def test_resource_to_dict_from_instance(self):
|
||||
inst = _make_resource_instance(id="reactor_01", klass="virtual_stirrer")
|
||||
d = resource_to_dict(inst)
|
||||
assert isinstance(d, dict)
|
||||
assert d["id"] == "reactor_01"
|
||||
assert d["class"] == "virtual_stirrer"
|
||||
|
||||
def test_resource_to_dict_passthrough(self):
|
||||
d = _make_resource_dict()
|
||||
result = resource_to_dict(d)
|
||||
assert result is d # 同一个 dict
|
||||
|
||||
|
||||
# ============ vessel_parser 兼容性测试 ============
|
||||
|
||||
|
||||
class TestVesselParser:
|
||||
"""测试 vessel_parser.get_vessel 对 ResourceDictInstance 的兼容"""
|
||||
|
||||
def test_get_vessel_from_dict(self):
|
||||
d = _make_resource_dict(id="reactor_01", data={"temperature": 25.0})
|
||||
vessel_id, vessel_data = get_vessel(d)
|
||||
assert vessel_id == "reactor_01"
|
||||
assert vessel_data["temperature"] == 25.0
|
||||
|
||||
def test_get_vessel_from_string(self):
|
||||
vessel_id, vessel_data = get_vessel("reactor_01")
|
||||
assert vessel_id == "reactor_01"
|
||||
assert vessel_data == {}
|
||||
|
||||
def test_get_vessel_from_resource_instance(self):
|
||||
inst = _make_resource_instance(id="reactor_01", data={"temperature": 25.0})
|
||||
vessel_id, vessel_data = get_vessel(inst)
|
||||
assert vessel_id == "reactor_01"
|
||||
assert vessel_data["temperature"] == 25.0
|
||||
|
||||
def test_get_vessel_from_wrapped_dict(self):
|
||||
"""兼容 {station_id: {id: ..., data: {...}}} 格式"""
|
||||
d = {"StationA": {"id": "StationA", "data": {"vol": 100}}}
|
||||
vessel_id, vessel_data = get_vessel(d)
|
||||
assert vessel_id == "StationA"
|
||||
|
||||
|
||||
# ============ ResourceTreeSet → get_plr_nested_dict 测试 ============
|
||||
|
||||
|
||||
class TestResourceTreeRoundTrip:
|
||||
"""测试 ResourceTreeSet → get_plr_nested_dict 保留树结构和关键字段"""
|
||||
|
||||
def test_tree_preserves_children(self):
|
||||
tree_set = _make_tree_with_children()
|
||||
assert len(tree_set.trees) == 1
|
||||
root = tree_set.trees[0].root_node
|
||||
assert root.res_content.id == "StationA"
|
||||
assert len(root.children) == 2
|
||||
|
||||
def test_plr_nested_dict_has_children(self):
|
||||
tree_set = _make_tree_with_children()
|
||||
root = tree_set.trees[0].root_node
|
||||
nested = root.get_plr_nested_dict()
|
||||
assert isinstance(nested, dict)
|
||||
assert "children" in nested
|
||||
assert isinstance(nested["children"], dict)
|
||||
assert "R1" in nested["children"]
|
||||
assert "R2" in nested["children"]
|
||||
|
||||
def test_plr_nested_dict_preserves_uuid(self):
|
||||
tree_set = _make_tree_with_children()
|
||||
root = tree_set.trees[0].root_node
|
||||
nested = root.get_plr_nested_dict()
|
||||
assert nested["uuid"] == "uuid-station-a"
|
||||
assert nested["children"]["R1"]["uuid"] == "uuid-r1"
|
||||
|
||||
def test_plr_nested_dict_preserves_klass(self):
|
||||
tree_set = _make_tree_with_children()
|
||||
root = tree_set.trees[0].root_node
|
||||
nested = root.get_plr_nested_dict()
|
||||
assert nested["class"] == "workstation"
|
||||
|
||||
def test_plr_nested_dict_preserves_data(self):
|
||||
tree_set = _make_tree_with_children()
|
||||
root = tree_set.trees[0].root_node
|
||||
nested = root.get_plr_nested_dict()
|
||||
r1_data = nested["children"]["R1"]["data"]
|
||||
assert "liquid" in r1_data
|
||||
assert r1_data["liquid"][0]["volume"] == 10.0
|
||||
|
||||
def test_plr_nested_dict_usable_by_get_vessel(self):
|
||||
"""get_plr_nested_dict 的结果可以直接传给 get_vessel"""
|
||||
tree_set = _make_tree_with_children()
|
||||
root = tree_set.trees[0].root_node
|
||||
nested = root.get_plr_nested_dict()
|
||||
vessel_id, vessel_data = get_vessel(nested)
|
||||
assert vessel_id == "StationA"
|
||||
|
||||
def test_dump_vs_plr_nested_dict(self):
|
||||
"""dump() 是扁平化的,get_plr_nested_dict 保留树结构"""
|
||||
tree_set = _make_tree_with_children()
|
||||
# dump 返回扁平列表
|
||||
dumped = tree_set.dump()
|
||||
assert isinstance(dumped[0], list)
|
||||
assert len(dumped[0]) == 3 # StationA + R1 + R2,全部扁平
|
||||
|
||||
# get_plr_nested_dict 保留嵌套
|
||||
root = tree_set.trees[0].root_node
|
||||
nested = root.get_plr_nested_dict()
|
||||
assert isinstance(nested["children"], dict)
|
||||
assert len(nested["children"]) == 2 # 嵌套的 children
|
||||
|
||||
|
||||
# ============ 模拟 workstation 路径测试 ============
|
||||
|
||||
|
||||
class TestWorkstationPath:
|
||||
"""模拟 workstation.py 中的关键路径:
|
||||
raw_data → ResourceTreeSet.from_raw_dict_list → get_plr_nested_dict → compiler
|
||||
"""
|
||||
|
||||
def test_single_resource_path(self):
|
||||
"""单个 Resource: 取第一棵树的根节点"""
|
||||
raw_data = [
|
||||
_make_resource_dict(id="reactor_01", uuid="uuid-r01", klass="virtual_stirrer"),
|
||||
]
|
||||
tree_set = ResourceTreeSet.from_raw_dict_list(raw_data)
|
||||
root = tree_set.trees[0].root_node
|
||||
result = root.get_plr_nested_dict()
|
||||
assert result["id"] == "reactor_01"
|
||||
assert result["uuid"] == "uuid-r01"
|
||||
assert result["class"] == "virtual_stirrer"
|
||||
|
||||
def test_resource_with_children_path(self):
|
||||
"""Resource 带 children: AGV/batch transfer 场景"""
|
||||
tree_set = _make_tree_with_children()
|
||||
root = tree_set.trees[0].root_node
|
||||
nested = root.get_plr_nested_dict()
|
||||
|
||||
# 模拟编译器接收到的参数
|
||||
from_repo = {"StationA": nested}
|
||||
assert "A01" not in from_repo["StationA"]["children"] # children 按 id 索引
|
||||
assert "R1" in from_repo["StationA"]["children"]
|
||||
assert from_repo["StationA"]["children"]["R1"]["uuid"] == "uuid-r1"
|
||||
|
||||
def test_multiple_resource_path(self):
|
||||
"""多个 Resource: 每棵树取根节点"""
|
||||
raw_data1 = [_make_resource_dict(id="R1", uuid="uuid-r1")]
|
||||
raw_data2 = [_make_resource_dict(id="R2", uuid="uuid-r2")]
|
||||
# 模拟 host 返回多棵树
|
||||
tree_set1 = ResourceTreeSet.from_raw_dict_list(raw_data1)
|
||||
tree_set2 = ResourceTreeSet.from_raw_dict_list(raw_data2)
|
||||
results = [
|
||||
tree.root_node.get_plr_nested_dict()
|
||||
for ts in [tree_set1, tree_set2]
|
||||
for tree in ts.trees
|
||||
]
|
||||
assert len(results) == 2
|
||||
assert results[0]["id"] == "R1"
|
||||
assert results[1]["id"] == "R2"
|
||||
@@ -1,15 +0,0 @@
|
||||
# Liquid handling 集成测试
|
||||
|
||||
`test_transfer_liquid.py` 现在会调用 PRCXI 的 RViz 仿真 backend,运行前请确保:
|
||||
|
||||
1. 已安装包含 `pylabrobot`、`rclpy` 的运行环境;
|
||||
2. 启动 ROS 依赖(`rviz` 可选,但是 `rviz_backend` 会创建 ROS 节点);
|
||||
3. 在 shell 中设置 `UNILAB_SIM_TEST=1`,否则 pytest 会自动跳过这些慢速用例:
|
||||
|
||||
```bash
|
||||
export UNILAB_SIM_TEST=1
|
||||
pytest tests/devices/liquid_handling/test_transfer_liquid.py -m slow
|
||||
```
|
||||
|
||||
如果只需验证逻辑层(不依赖仿真),可以直接运行 `tests/devices/liquid_handling/unit_test.py`,该文件使用 Fake backend,适合作为 CI 的快速测试。***
|
||||
|
||||
@@ -39,11 +39,6 @@ class FakeLiquidHandler(LiquidHandlerAbstract):
|
||||
self.current_tip = iter(make_tip_iter())
|
||||
self.calls: List[Tuple[str, Any]] = []
|
||||
|
||||
def set_tiprack(self, tip_racks):
|
||||
if not tip_racks:
|
||||
return
|
||||
super().set_tiprack(tip_racks)
|
||||
|
||||
async def pick_up_tips(self, tip_spots, use_channels=None, offsets=None, **backend_kwargs):
|
||||
self.calls.append(("pick_up_tips", {"tips": list(tip_spots), "use_channels": use_channels}))
|
||||
|
||||
|
||||
@@ -1,608 +0,0 @@
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DummyContainer:
|
||||
name: str
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"DummyContainer({self.name})"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DummyTipSpot:
|
||||
name: str
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"DummyTipSpot({self.name})"
|
||||
|
||||
|
||||
def make_tip_iter(n: int = 256) -> Iterable[List[DummyTipSpot]]:
|
||||
"""Yield lists so code can safely call `tip.extend(next(self.current_tip))`."""
|
||||
for i in range(n):
|
||||
yield [DummyTipSpot(f"tip_{i}")]
|
||||
|
||||
|
||||
class FakeLiquidHandler(LiquidHandlerAbstract):
|
||||
"""不初始化真实 backend/deck;仅用来记录 transfer_liquid 内部调用序列。"""
|
||||
|
||||
def __init__(self, channel_num: int = 8):
|
||||
# 不调用 super().__init__,避免真实硬件/后端依赖
|
||||
self.channel_num = channel_num
|
||||
self.support_touch_tip = True
|
||||
self.current_tip = iter(make_tip_iter())
|
||||
self.calls: List[Tuple[str, Any]] = []
|
||||
|
||||
def set_tiprack(self, tip_racks):
|
||||
# transfer_liquid 总会调用 set_tiprack;测试用 Dummy 枪头时 tip_racks 为空,需保留自种子的 current_tip
|
||||
if not tip_racks:
|
||||
return
|
||||
super().set_tiprack(tip_racks)
|
||||
|
||||
async def pick_up_tips(self, tip_spots, use_channels=None, offsets=None, **backend_kwargs):
|
||||
self.calls.append(("pick_up_tips", {"tips": list(tip_spots), "use_channels": use_channels}))
|
||||
|
||||
async def aspirate(
|
||||
self,
|
||||
resources: Sequence[Any],
|
||||
vols: List[float],
|
||||
use_channels: Optional[List[int]] = None,
|
||||
flow_rates: Optional[List[Optional[float]]] = None,
|
||||
offsets: Any = None,
|
||||
liquid_height: Any = None,
|
||||
blow_out_air_volume: Any = None,
|
||||
spread: str = "wide",
|
||||
**backend_kwargs,
|
||||
):
|
||||
self.calls.append(
|
||||
(
|
||||
"aspirate",
|
||||
{
|
||||
"resources": list(resources),
|
||||
"vols": list(vols),
|
||||
"use_channels": list(use_channels) if use_channels is not None else None,
|
||||
"flow_rates": list(flow_rates) if flow_rates is not None else None,
|
||||
"offsets": list(offsets) if offsets is not None else None,
|
||||
"liquid_height": list(liquid_height) if liquid_height is not None else None,
|
||||
"blow_out_air_volume": list(blow_out_air_volume) if blow_out_air_volume is not None else None,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async def dispense(
|
||||
self,
|
||||
resources: Sequence[Any],
|
||||
vols: List[float],
|
||||
use_channels: Optional[List[int]] = None,
|
||||
flow_rates: Optional[List[Optional[float]]] = None,
|
||||
offsets: Any = None,
|
||||
liquid_height: Any = None,
|
||||
blow_out_air_volume: Any = None,
|
||||
spread: str = "wide",
|
||||
**backend_kwargs,
|
||||
):
|
||||
self.calls.append(
|
||||
(
|
||||
"dispense",
|
||||
{
|
||||
"resources": list(resources),
|
||||
"vols": list(vols),
|
||||
"use_channels": list(use_channels) if use_channels is not None else None,
|
||||
"flow_rates": list(flow_rates) if flow_rates is not None else None,
|
||||
"offsets": list(offsets) if offsets is not None else None,
|
||||
"liquid_height": list(liquid_height) if liquid_height is not None else None,
|
||||
"blow_out_air_volume": list(blow_out_air_volume) if blow_out_air_volume is not None else None,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async def discard_tips(self, use_channels=None, *args, **kwargs):
|
||||
# 有的分支是 discard_tips(use_channels=[0]),有的分支是 discard_tips([0..7])(位置参数)
|
||||
self.calls.append(("discard_tips", {"use_channels": list(use_channels) if use_channels is not None else None}))
|
||||
|
||||
async def custom_delay(self, seconds=0, msg=None):
|
||||
self.calls.append(("custom_delay", {"seconds": seconds, "msg": msg}))
|
||||
|
||||
async def touch_tip(self, targets):
|
||||
# 原实现会访问 targets.get_size_x() 等;测试里只记录调用
|
||||
self.calls.append(("touch_tip", {"targets": targets}))
|
||||
|
||||
def run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def test_one_to_one_single_channel_basic_calls():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(64))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(3)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(3)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[1, 2, 3],
|
||||
dis_vols=[4, 5, 6],
|
||||
mix_times=None, # 应该仍能执行(不 mix)
|
||||
)
|
||||
)
|
||||
|
||||
assert [c[0] for c in lh.calls].count("pick_up_tips") == 3
|
||||
assert [c[0] for c in lh.calls].count("aspirate") == 3
|
||||
assert [c[0] for c in lh.calls].count("dispense") == 3
|
||||
assert [c[0] for c in lh.calls].count("discard_tips") == 3
|
||||
|
||||
# 每次 aspirate/dispense 都是单孔列表
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
assert aspirates[0]["resources"] == [sources[0]]
|
||||
assert aspirates[0]["vols"] == [1.0]
|
||||
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert dispenses[2]["resources"] == [targets[2]]
|
||||
assert dispenses[2]["vols"] == [6.0]
|
||||
|
||||
|
||||
def test_one_to_one_single_channel_before_stage_mixes_prior_to_aspirate():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(16))
|
||||
|
||||
source = DummyContainer("S0")
|
||||
target = DummyContainer("T0")
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=[source],
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[5],
|
||||
dis_vols=[5],
|
||||
mix_stage="before",
|
||||
mix_times=1,
|
||||
mix_vol=3,
|
||||
)
|
||||
)
|
||||
|
||||
aspirate_calls = [(idx, payload) for idx, (name, payload) in enumerate(lh.calls) if name == "aspirate"]
|
||||
assert len(aspirate_calls) >= 2
|
||||
mix_idx, mix_payload = aspirate_calls[0]
|
||||
assert mix_payload["resources"] == [target]
|
||||
assert mix_payload["vols"] == [3]
|
||||
transfer_idx, transfer_payload = aspirate_calls[1]
|
||||
assert transfer_payload["resources"] == [source]
|
||||
assert mix_idx < transfer_idx
|
||||
|
||||
|
||||
def test_one_to_one_eight_channel_groups_by_8():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(256))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(16)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(16)]
|
||||
asp_vols = list(range(1, 17))
|
||||
dis_vols = list(range(101, 117))
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=dis_vols,
|
||||
mix_times=0, # 触发逻辑但不 mix
|
||||
)
|
||||
)
|
||||
|
||||
# 16 个任务 -> 2 组,每组 8 通道一起做
|
||||
assert [c[0] for c in lh.calls].count("pick_up_tips") == 2
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert len(aspirates) == 2
|
||||
assert len(dispenses) == 2
|
||||
|
||||
assert aspirates[0]["resources"] == sources[0:8]
|
||||
assert aspirates[0]["vols"] == [float(v) for v in asp_vols[0:8]]
|
||||
assert dispenses[1]["resources"] == targets[8:16]
|
||||
assert dispenses[1]["vols"] == [float(v) for v in dis_vols[8:16]]
|
||||
|
||||
|
||||
def test_one_to_one_eight_channel_requires_multiple_of_8_targets():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(64))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(9)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(9)]
|
||||
|
||||
with pytest.raises(ValueError, match="multiple of 8"):
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=[1] * 9,
|
||||
dis_vols=[1] * 9,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_one_to_one_eight_channel_parameter_lists_are_chunked_per_8():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(512))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(16)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(16)]
|
||||
asp_vols = [i + 1 for i in range(16)]
|
||||
dis_vols = [200 + i for i in range(16)]
|
||||
asp_flow_rates = [0.1 * (i + 1) for i in range(16)]
|
||||
dis_flow_rates = [0.2 * (i + 1) for i in range(16)]
|
||||
offsets = [f"offset_{i}" for i in range(16)]
|
||||
liquid_heights = [i * 0.5 for i in range(16)]
|
||||
blow_out_air_volume = [i + 0.05 for i in range(16)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=dis_vols,
|
||||
asp_flow_rates=asp_flow_rates,
|
||||
dis_flow_rates=dis_flow_rates,
|
||||
offsets=offsets,
|
||||
liquid_height=liquid_heights,
|
||||
blow_out_air_volume=blow_out_air_volume,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert len(aspirates) == len(dispenses) == 2
|
||||
|
||||
for batch_idx in range(2):
|
||||
start = batch_idx * 8
|
||||
end = start + 8
|
||||
asp_call = aspirates[batch_idx]
|
||||
dis_call = dispenses[batch_idx]
|
||||
assert asp_call["resources"] == sources[start:end]
|
||||
assert asp_call["flow_rates"] == asp_flow_rates[start:end]
|
||||
assert asp_call["offsets"] == offsets[start:end]
|
||||
assert asp_call["liquid_height"] == liquid_heights[start:end]
|
||||
assert asp_call["blow_out_air_volume"] == blow_out_air_volume[start:end]
|
||||
assert dis_call["flow_rates"] == dis_flow_rates[start:end]
|
||||
assert dis_call["offsets"] == offsets[start:end]
|
||||
assert dis_call["liquid_height"] == liquid_heights[start:end]
|
||||
assert dis_call["blow_out_air_volume"] == blow_out_air_volume[start:end]
|
||||
|
||||
|
||||
def test_one_to_one_eight_channel_handles_32_tasks_four_batches():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(1024))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(32)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(32)]
|
||||
asp_vols = [i + 1 for i in range(32)]
|
||||
dis_vols = [300 + i for i in range(32)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=dis_vols,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
pick_calls = [name for name, _ in lh.calls if name == "pick_up_tips"]
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert len(pick_calls) == 4
|
||||
assert len(aspirates) == len(dispenses) == 4
|
||||
assert aspirates[0]["resources"] == sources[0:8]
|
||||
assert aspirates[-1]["resources"] == sources[24:32]
|
||||
assert dispenses[0]["resources"] == targets[0:8]
|
||||
assert dispenses[-1]["resources"] == targets[24:32]
|
||||
|
||||
|
||||
def test_one_to_many_single_channel_aspirates_total_when_asp_vol_too_small():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(64))
|
||||
|
||||
source = DummyContainer("SRC")
|
||||
targets = [DummyContainer(f"T{i}") for i in range(3)]
|
||||
dis_vols = [10, 20, 30] # sum=60
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=[source],
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=10, # 小于 sum(dis_vols) -> 应吸 60
|
||||
dis_vols=dis_vols,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
assert len(aspirates) == 1
|
||||
assert aspirates[0]["resources"] == [source]
|
||||
assert aspirates[0]["vols"] == [60.0]
|
||||
assert aspirates[0]["use_channels"] == [0]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert [d["vols"][0] for d in dispenses] == [10.0, 20.0, 30.0]
|
||||
|
||||
|
||||
def test_one_to_many_eight_channel_basic():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(128))
|
||||
|
||||
source = DummyContainer("SRC")
|
||||
targets = [DummyContainer(f"T{i}") for i in range(8)]
|
||||
dis_vols = [i + 1 for i in range(8)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=[source],
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=999, # one-to-many 8ch 会按 dis_vols 吸(每通道各自)
|
||||
dis_vols=dis_vols,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
assert aspirates[0]["resources"] == [source] * 8
|
||||
assert aspirates[0]["vols"] == [float(v) for v in dis_vols]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert dispenses[0]["resources"] == targets
|
||||
assert dispenses[0]["vols"] == [float(v) for v in dis_vols]
|
||||
|
||||
|
||||
def test_many_to_one_single_channel_standard_dispense_equals_asp_by_default():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(128))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(3)]
|
||||
target = DummyContainer("T")
|
||||
asp_vols = [5, 6, 7]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=1, # many-to-one 允许标量;非比例模式下实际每次分液=对应 asp_vol
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert [d["vols"][0] for d in dispenses] == [float(v) for v in asp_vols]
|
||||
assert all(d["resources"] == [target] for d in dispenses)
|
||||
|
||||
|
||||
def test_many_to_one_single_channel_before_stage_mixes_target_once():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(128))
|
||||
|
||||
sources = [DummyContainer("S0"), DummyContainer("S1")]
|
||||
target = DummyContainer("T")
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[5, 6],
|
||||
dis_vols=1,
|
||||
mix_stage="before",
|
||||
mix_times=2,
|
||||
mix_vol=4,
|
||||
)
|
||||
)
|
||||
|
||||
aspirate_calls = [(idx, payload) for idx, (name, payload) in enumerate(lh.calls) if name == "aspirate"]
|
||||
assert len(aspirate_calls) >= 1
|
||||
mix_idx, mix_payload = aspirate_calls[0]
|
||||
assert mix_payload["resources"] == [target]
|
||||
assert mix_payload["vols"] == [4]
|
||||
# 第一個 mix 之後會真正開始吸 source
|
||||
assert any(call["resources"] == [sources[0]] for _, call in aspirate_calls[1:])
|
||||
|
||||
|
||||
def test_many_to_one_single_channel_proportional_mixing_uses_dis_vols_per_source():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(128))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(3)]
|
||||
target = DummyContainer("T")
|
||||
asp_vols = [5, 6, 7]
|
||||
dis_vols = [1, 2, 3]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=dis_vols, # 比例模式
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert [d["vols"][0] for d in dispenses] == [float(v) for v in dis_vols]
|
||||
|
||||
|
||||
def test_many_to_one_eight_channel_basic():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(256))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(8)]
|
||||
target = DummyContainer("T")
|
||||
asp_vols = [10 + i for i in range(8)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=999, # 非比例模式下每通道分液=对应 asp_vol
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert aspirates[0]["resources"] == sources
|
||||
assert aspirates[0]["vols"] == [float(v) for v in asp_vols]
|
||||
assert dispenses[0]["resources"] == [target] * 8
|
||||
assert dispenses[0]["vols"] == [float(v) for v in asp_vols]
|
||||
|
||||
|
||||
def test_transfer_liquid_mode_detection_unsupported_shape_raises():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(64))
|
||||
|
||||
sources = [DummyContainer("S0"), DummyContainer("S1")]
|
||||
targets = [DummyContainer("T0"), DummyContainer("T1"), DummyContainer("T2")]
|
||||
|
||||
with pytest.raises(ValueError, match="Unsupported transfer mode"):
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[1, 1],
|
||||
dis_vols=[1, 1, 1],
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_mix_single_target_produces_matching_cycles():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
target = DummyContainer("T_mix")
|
||||
|
||||
run(lh.mix(targets=[target], mix_time=2, mix_vol=5))
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert len(aspirates) == len(dispenses) == 2
|
||||
assert all(call["resources"] == [target] for call in aspirates)
|
||||
assert all(call["vols"] == [5] for call in aspirates)
|
||||
assert all(call["resources"] == [target] for call in dispenses)
|
||||
assert all(call["vols"] == [5] for call in dispenses)
|
||||
|
||||
|
||||
def test_mix_multiple_targets_supports_per_target_offsets():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
targets = [DummyContainer("T0"), DummyContainer("T1")]
|
||||
offsets = ["left", "right"]
|
||||
heights = [0.1, 0.2]
|
||||
rates = [0.5, 1.0]
|
||||
|
||||
run(
|
||||
lh.mix(
|
||||
targets=targets,
|
||||
mix_time=1,
|
||||
mix_vol=3,
|
||||
offsets=offsets,
|
||||
height_to_bottom=heights,
|
||||
mix_rate=rates,
|
||||
)
|
||||
)
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
assert len(aspirates) == 2
|
||||
assert aspirates[0]["resources"] == [targets[0]]
|
||||
assert aspirates[0]["offsets"] == [offsets[0]]
|
||||
assert aspirates[0]["liquid_height"] == [heights[0]]
|
||||
assert aspirates[0]["flow_rates"] == [rates[0]]
|
||||
assert aspirates[1]["resources"] == [targets[1]]
|
||||
assert aspirates[1]["offsets"] == [offsets[1]]
|
||||
assert aspirates[1]["liquid_height"] == [heights[1]]
|
||||
assert aspirates[1]["flow_rates"] == [rates[1]]
|
||||
|
||||
|
||||
def test_set_tiprack_per_type_resumes_first_physical_rack():
|
||||
"""同型号多次 set_tiprack 时接续第一盒剩余孔位,而非从新盒 A1 开始。"""
|
||||
from pylabrobot.liquid_handling import LiquidHandlerChatterboxBackend
|
||||
from pylabrobot.resources import Deck, Tip, TipRack, TipSpot, create_equally_spaced
|
||||
|
||||
mk = lambda: Tip(
|
||||
has_filter=False, total_tip_length=10.0, maximal_volume=300.0, fitting_depth=2.0
|
||||
)
|
||||
|
||||
class TipTypeAlpha(TipRack):
|
||||
pass
|
||||
|
||||
class TipTypeBeta(TipRack):
|
||||
pass
|
||||
|
||||
def make_rack(cls: type, name: str) -> TipRack:
|
||||
items = create_equally_spaced(
|
||||
TipSpot,
|
||||
num_items_x=12,
|
||||
num_items_y=2,
|
||||
dx=0,
|
||||
dy=0,
|
||||
dz=0,
|
||||
item_dx=9,
|
||||
item_dy=9,
|
||||
size_x=1,
|
||||
size_y=1,
|
||||
make_tip=mk,
|
||||
)
|
||||
return cls(name, 120, 40, 10, items=items)
|
||||
|
||||
rack1 = make_rack(TipTypeAlpha, "rack_phys_1")
|
||||
rack2 = make_rack(TipTypeBeta, "rack_phys_2")
|
||||
rack3 = make_rack(TipTypeAlpha, "rack_phys_3")
|
||||
|
||||
lh = LiquidHandlerAbstract(
|
||||
LiquidHandlerChatterboxBackend(1), Deck(), channel_num=1, simulator=False
|
||||
)
|
||||
flat1 = lh._flatten_tips_from_one(rack1)
|
||||
assert len(flat1) == 24
|
||||
|
||||
lh.set_tiprack([rack1])
|
||||
for i in range(12):
|
||||
assert lh._get_next_tip() is flat1[i]
|
||||
|
||||
lh.set_tiprack([rack2])
|
||||
spot_b = lh._get_next_tip()
|
||||
assert "rack_phys_2" in spot_b.name
|
||||
|
||||
lh.set_tiprack([rack3])
|
||||
spot_resume = lh._get_next_tip()
|
||||
assert spot_resume is flat1[12], "第三次同型号应接续 rack1 第二行首孔,而非 rack3 首孔"
|
||||
assert spot_resume is not lh._flatten_tips_from_one(rack3)[0]
|
||||
|
||||
|
||||
137
tests/devices/test_agv_transport_station.py
Normal file
137
tests/devices/test_agv_transport_station.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
AGVTransportStation driver 测试
|
||||
|
||||
覆盖:初始化、carrier property、slot 查询、路由查询、capacity 计算。
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from unilabos.devices.transport.agv_workstation import AGVTransportStation
|
||||
from unilabos.resources.warehouse import WareHouse, warehouse_factory
|
||||
|
||||
|
||||
class TestAGVTransportStation:
|
||||
def _make_driver(self, route_table=None, device_roles=None):
|
||||
"""创建一个 AGVTransportStation 实例"""
|
||||
return AGVTransportStation(
|
||||
deck=None,
|
||||
route_table=route_table or {
|
||||
"A->B": {"nav_command": '{"target":"LM1"}', "arm_pick": "pick.urp", "arm_place": "place.urp"}
|
||||
},
|
||||
device_roles=device_roles or {"navigator": "agv_nav", "arm": "agv_arm"},
|
||||
)
|
||||
|
||||
def _make_warehouse(self, name="agv_platform", nx=2, ny=1, nz=1):
|
||||
"""创建一个测试用 Warehouse"""
|
||||
return warehouse_factory(name=name, num_items_x=nx, num_items_y=ny, num_items_z=nz)
|
||||
|
||||
def test_init_deck_none(self):
|
||||
"""AGVTransportStation 初始化时 deck=None"""
|
||||
driver = self._make_driver()
|
||||
assert driver.deck is None
|
||||
|
||||
def test_init_route_table(self):
|
||||
"""路由表正确存储"""
|
||||
driver = self._make_driver()
|
||||
assert "A->B" in driver.route_table
|
||||
|
||||
def test_init_device_roles(self):
|
||||
"""设备角色正确存储"""
|
||||
driver = self._make_driver()
|
||||
assert driver.device_roles["navigator"] == "agv_nav"
|
||||
assert driver.device_roles["arm"] == "agv_arm"
|
||||
|
||||
def test_carrier_without_ros_node(self):
|
||||
"""未 post_init 时 carrier 返回 None"""
|
||||
driver = self._make_driver()
|
||||
assert driver.carrier is None
|
||||
|
||||
def test_carrier_with_warehouse(self):
|
||||
"""post_init 后 carrier 返回正确的 WareHouse"""
|
||||
driver = self._make_driver()
|
||||
wh = self._make_warehouse()
|
||||
|
||||
# 模拟 ros_node 和 resource_tracker
|
||||
mock_ros_node = MagicMock()
|
||||
mock_ros_node.resource_tracker.resources = [wh]
|
||||
mock_ros_node.device_id = "AGV"
|
||||
driver.post_init(mock_ros_node)
|
||||
|
||||
assert driver.carrier is wh
|
||||
assert isinstance(driver.carrier, WareHouse)
|
||||
|
||||
def test_capacity(self):
|
||||
"""容量计算正确"""
|
||||
driver = self._make_driver()
|
||||
wh = self._make_warehouse(nx=2, ny=1, nz=1)
|
||||
mock_ros_node = MagicMock()
|
||||
mock_ros_node.resource_tracker.resources = [wh]
|
||||
mock_ros_node.device_id = "AGV"
|
||||
driver.post_init(mock_ros_node)
|
||||
|
||||
assert driver.capacity == 2
|
||||
|
||||
def test_capacity_multi_layer(self):
|
||||
"""多层 Warehouse 容量"""
|
||||
driver = self._make_driver()
|
||||
wh = self._make_warehouse(nx=1, ny=2, nz=3)
|
||||
mock_ros_node = MagicMock()
|
||||
mock_ros_node.resource_tracker.resources = [wh]
|
||||
mock_ros_node.device_id = "AGV"
|
||||
driver.post_init(mock_ros_node)
|
||||
|
||||
assert driver.capacity == 6
|
||||
|
||||
def test_capacity_no_carrier(self):
|
||||
"""无 carrier 时容量为 0"""
|
||||
driver = self._make_driver()
|
||||
assert driver.capacity == 0
|
||||
|
||||
def test_free_slots(self):
|
||||
"""空载时所有 slot 为空闲"""
|
||||
driver = self._make_driver()
|
||||
wh = self._make_warehouse(nx=2, ny=1, nz=1)
|
||||
mock_ros_node = MagicMock()
|
||||
mock_ros_node.resource_tracker.resources = [wh]
|
||||
mock_ros_node.device_id = "AGV"
|
||||
driver.post_init(mock_ros_node)
|
||||
|
||||
free = driver.free_slots
|
||||
assert len(free) == 2
|
||||
|
||||
def test_occupied_slots_empty(self):
|
||||
"""空载时 occupied_slots 为空"""
|
||||
driver = self._make_driver()
|
||||
wh = self._make_warehouse(nx=2, ny=1, nz=1)
|
||||
mock_ros_node = MagicMock()
|
||||
mock_ros_node.resource_tracker.resources = [wh]
|
||||
mock_ros_node.device_id = "AGV"
|
||||
driver.post_init(mock_ros_node)
|
||||
|
||||
assert len(driver.occupied_slots) == 0
|
||||
|
||||
def test_resolve_route(self):
|
||||
"""路由查询返回正确的指令"""
|
||||
driver = self._make_driver()
|
||||
route = driver.resolve_route("A", "B")
|
||||
assert route["nav_command"] == '{"target":"LM1"}'
|
||||
assert route["arm_pick"] == "pick.urp"
|
||||
|
||||
def test_resolve_route_not_found(self):
|
||||
"""查询不存在的路线时抛出 KeyError"""
|
||||
driver = self._make_driver()
|
||||
with pytest.raises(KeyError, match="路由表"):
|
||||
driver.resolve_route("X", "Y")
|
||||
|
||||
def test_get_device_id(self):
|
||||
"""获取子设备 ID"""
|
||||
driver = self._make_driver()
|
||||
assert driver.get_device_id("navigator") == "agv_nav"
|
||||
assert driver.get_device_id("arm") == "agv_arm"
|
||||
|
||||
def test_get_device_id_not_found(self):
|
||||
"""获取不存在的角色时抛出 KeyError"""
|
||||
driver = self._make_driver()
|
||||
with pytest.raises(KeyError, match="未配置设备角色"):
|
||||
driver.get_device_id("gripper")
|
||||
6
unilabos/__main__.py
Normal file
6
unilabos/__main__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Entry point for `python -m unilabos`."""
|
||||
|
||||
from unilabos.app.main import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -553,8 +553,13 @@ def main():
|
||||
os._exit(0)
|
||||
|
||||
if not BasicConfig.ak or not BasicConfig.sk:
|
||||
print_status("后续运行必须拥有一个实验室,请前往 https://uni-lab.bohrium.com 注册实验室!", "warning")
|
||||
os._exit(1)
|
||||
if BasicConfig.test_mode:
|
||||
print_status("测试模式:跳过 ak/sk 检查,使用占位凭据", "warning")
|
||||
BasicConfig.ak = BasicConfig.ak or "test_ak"
|
||||
BasicConfig.sk = BasicConfig.sk or "test_sk"
|
||||
else:
|
||||
print_status("后续运行必须拥有一个实验室,请前往 https://uni-lab.bohrium.com 注册实验室!", "warning")
|
||||
os._exit(1)
|
||||
graph: nx.Graph
|
||||
resource_tree_set: ResourceTreeSet
|
||||
resource_links: List[Dict[str, Any]]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -58,14 +58,14 @@ class JobResultStore:
|
||||
feedback=feedback or {},
|
||||
timestamp=time.time(),
|
||||
)
|
||||
logger.debug(f"[JobResultStore] Stored result for job {job_id[:8]}, status={status}")
|
||||
logger.trace(f"[JobResultStore] Stored result for job {job_id[:8]}, status={status}")
|
||||
|
||||
def get_and_remove(self, job_id: str) -> Optional[JobResult]:
|
||||
"""获取并删除任务结果"""
|
||||
with self._results_lock:
|
||||
result = self._results.pop(job_id, None)
|
||||
if result:
|
||||
logger.debug(f"[JobResultStore] Retrieved and removed result for job {job_id[:8]}")
|
||||
logger.trace(f"[JobResultStore] Retrieved and removed result for job {job_id[:8]}")
|
||||
return result
|
||||
|
||||
def get_result(self, job_id: str) -> Optional[JobResult]:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -5,6 +5,7 @@ from .separate_protocol import generate_separate_protocol
|
||||
from .evaporate_protocol import generate_evaporate_protocol
|
||||
from .evacuateandrefill_protocol import generate_evacuateandrefill_protocol
|
||||
from .agv_transfer_protocol import generate_agv_transfer_protocol
|
||||
from .batch_transfer_protocol import generate_batch_transfer_protocol
|
||||
from .add_protocol import generate_add_protocol
|
||||
from .centrifuge_protocol import generate_centrifuge_protocol
|
||||
from .filter_protocol import generate_filter_protocol
|
||||
@@ -31,6 +32,7 @@ from .hydrogenate_protocol import generate_hydrogenate_protocol
|
||||
action_protocol_generators = {
|
||||
AddProtocol: generate_add_protocol,
|
||||
AGVTransferProtocol: generate_agv_transfer_protocol,
|
||||
BatchTransferProtocol: generate_batch_transfer_protocol,
|
||||
AdjustPHProtocol: generate_adjust_ph_protocol,
|
||||
CentrifugeProtocol: generate_centrifuge_protocol,
|
||||
CleanProtocol: generate_clean_protocol,
|
||||
|
||||
127
unilabos/compile/_agv_utils.py
Normal file
127
unilabos/compile/_agv_utils.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
AGV 编译器共用工具函数
|
||||
|
||||
从 physical_setup_graph 中发现 AGV 节点配置,
|
||||
供 agv_transfer_protocol 和 batch_transfer_protocol 复用。
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import networkx as nx
|
||||
|
||||
|
||||
def find_agv_config(G: nx.Graph, agv_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""从设备图中发现 AGV 节点,返回其配置
|
||||
|
||||
查找策略:
|
||||
1. 如果指定 agv_id,直接读取该节点
|
||||
2. 否则查找 class 为 "agv_transport_station" 的节点
|
||||
3. 兜底查找 config 中包含 device_roles 的 workstation 节点
|
||||
|
||||
Returns:
|
||||
{
|
||||
"agv_id": str,
|
||||
"device_roles": {"navigator": "...", "arm": "..."},
|
||||
"route_table": {"A->B": {"nav_command": ..., "arm_pick": ..., "arm_place": ...}},
|
||||
"capacity": int,
|
||||
}
|
||||
"""
|
||||
if agv_id and agv_id in G.nodes:
|
||||
node_data = G.nodes[agv_id]
|
||||
config = _extract_config(node_data)
|
||||
if config and "device_roles" in config:
|
||||
return _build_agv_cfg(agv_id, config, G)
|
||||
|
||||
# 查找 agv_transport_station 类型
|
||||
for nid, ndata in G.nodes(data=True):
|
||||
node_class = _get_node_class(ndata)
|
||||
if node_class == "agv_transport_station":
|
||||
config = _extract_config(ndata)
|
||||
return _build_agv_cfg(nid, config or {}, G)
|
||||
|
||||
# 兜底:查找带有 device_roles 的 workstation
|
||||
for nid, ndata in G.nodes(data=True):
|
||||
node_class = _get_node_class(ndata)
|
||||
if node_class == "workstation":
|
||||
config = _extract_config(ndata)
|
||||
if config and "device_roles" in config:
|
||||
return _build_agv_cfg(nid, config, G)
|
||||
|
||||
raise ValueError("设备图中未找到 AGV 节点(需 class=agv_transport_station 或 config.device_roles)")
|
||||
|
||||
|
||||
def get_agv_capacity(G: nx.Graph, agv_id: str) -> int:
|
||||
"""从 AGV 的 Warehouse 子节点计算载具容量"""
|
||||
for neighbor in G.successors(agv_id) if G.is_directed() else G.neighbors(agv_id):
|
||||
ndata = G.nodes[neighbor]
|
||||
node_type = _get_node_type(ndata)
|
||||
if node_type == "warehouse":
|
||||
config = _extract_config(ndata)
|
||||
if config:
|
||||
x = config.get("num_items_x", 1)
|
||||
y = config.get("num_items_y", 1)
|
||||
z = config.get("num_items_z", 1)
|
||||
return x * y * z
|
||||
# 如果没有 warehouse 子节点,尝试从配置中读取
|
||||
return 0
|
||||
|
||||
|
||||
def split_batches(items: list, capacity: int) -> List[list]:
|
||||
"""按 AGV 容量分批
|
||||
|
||||
Args:
|
||||
items: 待转运的物料列表
|
||||
capacity: AGV 单批次容量
|
||||
|
||||
Returns:
|
||||
分批后的列表的列表
|
||||
"""
|
||||
if capacity <= 0:
|
||||
raise ValueError(f"AGV 容量必须 > 0,当前: {capacity}")
|
||||
return [items[i:i + capacity] for i in range(0, len(items), capacity)]
|
||||
|
||||
|
||||
def _extract_config(node_data: dict) -> Optional[dict]:
|
||||
"""从节点数据中提取 config 字段,兼容多种格式"""
|
||||
# 直接 config 字段
|
||||
config = node_data.get("config")
|
||||
if isinstance(config, dict):
|
||||
return config
|
||||
# res_content 嵌套格式
|
||||
res_content = node_data.get("res_content")
|
||||
if hasattr(res_content, "config"):
|
||||
return res_content.config if isinstance(res_content.config, dict) else None
|
||||
if isinstance(res_content, dict):
|
||||
return res_content.get("config")
|
||||
return None
|
||||
|
||||
|
||||
def _get_node_class(node_data: dict) -> str:
|
||||
"""获取节点的 class 字段"""
|
||||
res_content = node_data.get("res_content")
|
||||
if hasattr(res_content, "model_dump"):
|
||||
d = res_content.model_dump()
|
||||
return d.get("class_", d.get("class", ""))
|
||||
if isinstance(res_content, dict):
|
||||
return res_content.get("class_", res_content.get("class", ""))
|
||||
return node_data.get("class_", node_data.get("class", ""))
|
||||
|
||||
|
||||
def _get_node_type(node_data: dict) -> str:
|
||||
"""获取节点的 type 字段"""
|
||||
res_content = node_data.get("res_content")
|
||||
if hasattr(res_content, "type"):
|
||||
return res_content.type or ""
|
||||
if isinstance(res_content, dict):
|
||||
return res_content.get("type", "")
|
||||
return node_data.get("type", "")
|
||||
|
||||
|
||||
def _build_agv_cfg(agv_id: str, config: dict, G: nx.Graph) -> Dict[str, Any]:
|
||||
"""构建标准化的 AGV 配置"""
|
||||
return {
|
||||
"agv_id": agv_id,
|
||||
"device_roles": config.get("device_roles", {}),
|
||||
"route_table": config.get("route_table", {}),
|
||||
"capacity": get_agv_capacity(G, agv_id),
|
||||
}
|
||||
@@ -2,20 +2,13 @@ from functools import partial
|
||||
|
||||
import networkx as nx
|
||||
import re
|
||||
import logging
|
||||
from typing import List, Dict, Any, Union
|
||||
|
||||
from .utils.unit_parser import parse_volume_input, parse_mass_input, parse_time_input
|
||||
from .utils.vessel_parser import get_vessel, find_solid_dispenser, find_connected_stirrer, find_reagent_vessel
|
||||
from .utils.logger_util import action_log
|
||||
from .utils.logger_util import action_log, debug_print
|
||||
from .pump_protocol import generate_pump_protocol_with_rinsing
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def debug_print(message):
|
||||
"""调试输出"""
|
||||
logger.info(f"[ADD] {message}")
|
||||
|
||||
|
||||
# 🆕 创建进度日志动作
|
||||
create_action_log = partial(action_log, prefix="[ADD]")
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
from functools import partial
|
||||
|
||||
import networkx as nx
|
||||
import logging
|
||||
from typing import List, Dict, Any, Union
|
||||
from .utils.vessel_parser import get_vessel
|
||||
from .utils.vessel_parser import get_vessel, find_connected_stirrer
|
||||
from .utils.logger_util import action_log, debug_print
|
||||
from .pump_protocol import generate_pump_protocol_with_rinsing
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def debug_print(message):
|
||||
"""调试输出"""
|
||||
logger.info(f"[ADJUST_PH] {message}")
|
||||
create_action_log = partial(action_log, prefix="[ADJUST_PH]")
|
||||
|
||||
def find_acid_base_vessel(G: nx.DiGraph, reagent: str) -> str:
|
||||
"""
|
||||
@@ -21,8 +19,6 @@ def find_acid_base_vessel(G: nx.DiGraph, reagent: str) -> str:
|
||||
Returns:
|
||||
str: 试剂容器ID
|
||||
"""
|
||||
debug_print(f"🔍 正在查找试剂 '{reagent}' 的容器...")
|
||||
|
||||
# 常见酸碱试剂的别名映射
|
||||
reagent_aliases = {
|
||||
"hydrochloric acid": ["HCl", "hydrochloric_acid", "hcl", "muriatic_acid"],
|
||||
@@ -36,17 +32,13 @@ def find_acid_base_vessel(G: nx.DiGraph, reagent: str) -> str:
|
||||
|
||||
# 构建搜索名称列表
|
||||
search_names = [reagent.lower()]
|
||||
debug_print(f"📋 基础搜索名称: {reagent.lower()}")
|
||||
|
||||
|
||||
# 添加别名
|
||||
for base_name, aliases in reagent_aliases.items():
|
||||
if reagent.lower() in base_name.lower() or base_name.lower() in reagent.lower():
|
||||
search_names.extend([alias.lower() for alias in aliases])
|
||||
debug_print(f"🔗 添加别名: {aliases}")
|
||||
break
|
||||
|
||||
debug_print(f"📝 完整搜索列表: {search_names}")
|
||||
|
||||
# 构建可能的容器名称
|
||||
possible_names = []
|
||||
for name in search_names:
|
||||
@@ -61,17 +53,15 @@ def find_acid_base_vessel(G: nx.DiGraph, reagent: str) -> str:
|
||||
name_clean
|
||||
])
|
||||
|
||||
debug_print(f"🎯 可能的容器名称 (前5个): {possible_names[:5]}... (共{len(possible_names)}个)")
|
||||
|
||||
debug_print(f"搜索容器: {len(possible_names)} 个候选名称")
|
||||
|
||||
# 第一步:通过容器名称匹配
|
||||
debug_print(f"📋 方法1: 精确名称匹配...")
|
||||
for vessel_name in possible_names:
|
||||
if vessel_name in G.nodes():
|
||||
debug_print(f"✅ 通过名称匹配找到容器: {vessel_name} 🎯")
|
||||
debug_print(f"通过名称匹配找到容器: {vessel_name}")
|
||||
return vessel_name
|
||||
|
||||
|
||||
# 第二步:通过模糊匹配
|
||||
debug_print(f"📋 方法2: 模糊名称匹配...")
|
||||
for node_id in G.nodes():
|
||||
if G.nodes[node_id].get('type') == 'container':
|
||||
node_name = G.nodes[node_id].get('name', '').lower()
|
||||
@@ -79,11 +69,10 @@ def find_acid_base_vessel(G: nx.DiGraph, reagent: str) -> str:
|
||||
# 检查是否包含任何搜索名称
|
||||
for search_name in search_names:
|
||||
if search_name in node_id.lower() or search_name in node_name:
|
||||
debug_print(f"✅ 通过模糊匹配找到容器: {node_id} 🔍")
|
||||
debug_print(f"通过模糊匹配找到容器: {node_id}")
|
||||
return node_id
|
||||
|
||||
|
||||
# 第三步:通过液体类型匹配
|
||||
debug_print(f"📋 方法3: 液体类型匹配...")
|
||||
for node_id in G.nodes():
|
||||
if G.nodes[node_id].get('type') == 'container':
|
||||
vessel_data = G.nodes[node_id].get('data', {})
|
||||
@@ -96,56 +85,15 @@ def find_acid_base_vessel(G: nx.DiGraph, reagent: str) -> str:
|
||||
|
||||
for search_name in search_names:
|
||||
if search_name in liquid_type or search_name in reagent_name:
|
||||
debug_print(f"✅ 通过液体类型匹配找到容器: {node_id} 💧")
|
||||
debug_print(f"通过液体类型匹配找到容器: {node_id}")
|
||||
return node_id
|
||||
|
||||
# 列出可用容器帮助调试
|
||||
debug_print(f"📊 列出可用容器帮助调试...")
|
||||
available_containers = []
|
||||
for node_id in G.nodes():
|
||||
if G.nodes[node_id].get('type') == 'container':
|
||||
vessel_data = G.nodes[node_id].get('data', {})
|
||||
liquids = vessel_data.get('liquid', [])
|
||||
liquid_types = [liquid.get('liquid_type', '') or liquid.get('name', '')
|
||||
for liquid in liquids if isinstance(liquid, dict)]
|
||||
|
||||
available_containers.append({
|
||||
'id': node_id,
|
||||
'name': G.nodes[node_id].get('name', ''),
|
||||
'liquids': liquid_types,
|
||||
'reagent_name': vessel_data.get('reagent_name', '')
|
||||
})
|
||||
|
||||
debug_print(f"📋 可用容器列表:")
|
||||
for container in available_containers:
|
||||
debug_print(f" - 🧪 {container['id']}: {container['name']}")
|
||||
debug_print(f" 💧 液体: {container['liquids']}")
|
||||
debug_print(f" 🏷️ 试剂: {container['reagent_name']}")
|
||||
|
||||
debug_print(f"❌ 所有匹配方法都失败了")
|
||||
available_containers = [node_id for node_id in G.nodes()
|
||||
if G.nodes[node_id].get('type') == 'container']
|
||||
debug_print(f"所有匹配方法失败,可用容器: {available_containers}")
|
||||
raise ValueError(f"找不到试剂 '{reagent}' 对应的容器。尝试了: {possible_names[:10]}...")
|
||||
|
||||
def find_connected_stirrer(G: nx.DiGraph, vessel: str) -> str:
|
||||
"""查找与容器相连的搅拌器"""
|
||||
debug_print(f"🔍 查找连接到容器 '{vessel}' 的搅拌器...")
|
||||
|
||||
stirrer_nodes = [node for node in G.nodes()
|
||||
if (G.nodes[node].get('class') or '') == 'virtual_stirrer']
|
||||
|
||||
debug_print(f"📊 发现 {len(stirrer_nodes)} 个搅拌器: {stirrer_nodes}")
|
||||
|
||||
for stirrer in stirrer_nodes:
|
||||
if G.has_edge(stirrer, vessel) or G.has_edge(vessel, stirrer):
|
||||
debug_print(f"✅ 找到连接的搅拌器: {stirrer} 🔗")
|
||||
return stirrer
|
||||
|
||||
if stirrer_nodes:
|
||||
debug_print(f"⚠️ 未找到直接连接的搅拌器,使用第一个: {stirrer_nodes[0]} 🔄")
|
||||
return stirrer_nodes[0]
|
||||
|
||||
debug_print(f"❌ 未找到任何搅拌器")
|
||||
return None
|
||||
|
||||
def calculate_reagent_volume(target_ph_value: float, reagent: str, vessel_volume: float = 100.0) -> float:
|
||||
"""
|
||||
估算需要的试剂体积来调节pH
|
||||
@@ -158,44 +106,30 @@ def calculate_reagent_volume(target_ph_value: float, reagent: str, vessel_volume
|
||||
Returns:
|
||||
float: 估算的试剂体积 (mL)
|
||||
"""
|
||||
debug_print(f"🧮 计算试剂体积...")
|
||||
debug_print(f" 📍 目标pH: {target_ph_value}")
|
||||
debug_print(f" 🧪 试剂: {reagent}")
|
||||
debug_print(f" 📏 容器体积: {vessel_volume}mL")
|
||||
|
||||
# 简化的pH调节体积估算(实际应用中需要更精确的计算)
|
||||
debug_print(f"计算试剂体积: pH={target_ph_value}, reagent={reagent}, vessel={vessel_volume}mL")
|
||||
|
||||
# 简化的pH调节体积估算
|
||||
if "acid" in reagent.lower() or "hcl" in reagent.lower():
|
||||
debug_print(f"🍋 检测到酸性试剂")
|
||||
# 酸性试剂:pH越低需要的体积越大
|
||||
if target_ph_value < 3:
|
||||
volume = vessel_volume * 0.05 # 5%
|
||||
debug_print(f" 💪 强酸性 (pH<3): 使用 5% 体积")
|
||||
volume = vessel_volume * 0.05
|
||||
elif target_ph_value < 5:
|
||||
volume = vessel_volume * 0.02 # 2%
|
||||
debug_print(f" 🔸 中酸性 (pH<5): 使用 2% 体积")
|
||||
volume = vessel_volume * 0.02
|
||||
else:
|
||||
volume = vessel_volume * 0.01 # 1%
|
||||
debug_print(f" 🔹 弱酸性 (pH≥5): 使用 1% 体积")
|
||||
|
||||
volume = vessel_volume * 0.01
|
||||
|
||||
elif "hydroxide" in reagent.lower() or "naoh" in reagent.lower():
|
||||
debug_print(f"🧂 检测到碱性试剂")
|
||||
# 碱性试剂:pH越高需要的体积越大
|
||||
if target_ph_value > 11:
|
||||
volume = vessel_volume * 0.05 # 5%
|
||||
debug_print(f" 💪 强碱性 (pH>11): 使用 5% 体积")
|
||||
volume = vessel_volume * 0.05
|
||||
elif target_ph_value > 9:
|
||||
volume = vessel_volume * 0.02 # 2%
|
||||
debug_print(f" 🔸 中碱性 (pH>9): 使用 2% 体积")
|
||||
volume = vessel_volume * 0.02
|
||||
else:
|
||||
volume = vessel_volume * 0.01 # 1%
|
||||
debug_print(f" 🔹 弱碱性 (pH≤9): 使用 1% 体积")
|
||||
|
||||
volume = vessel_volume * 0.01
|
||||
|
||||
else:
|
||||
# 未知试剂,使用默认值
|
||||
volume = vessel_volume * 0.01
|
||||
debug_print(f"❓ 未知试剂类型,使用默认 1% 体积")
|
||||
|
||||
debug_print(f"📊 计算结果: {volume:.2f}mL")
|
||||
|
||||
debug_print(f"估算试剂体积: {volume:.2f}mL")
|
||||
return volume
|
||||
|
||||
def generate_adjust_ph_protocol(
|
||||
@@ -220,96 +154,67 @@ def generate_adjust_ph_protocol(
|
||||
"""
|
||||
|
||||
vessel_id, vessel_data = get_vessel(vessel)
|
||||
|
||||
|
||||
if not vessel_id:
|
||||
debug_print(f"❌ vessel 参数无效,必须包含id字段或直接提供容器ID. vessel: {vessel}")
|
||||
raise ValueError("vessel 参数无效,必须包含id字段或直接提供容器ID")
|
||||
|
||||
debug_print("=" * 60)
|
||||
debug_print("🧪 开始生成pH调节协议")
|
||||
debug_print(f"📋 原始参数:")
|
||||
debug_print(f" 🥼 vessel: {vessel} (ID: {vessel_id})")
|
||||
debug_print(f" 📊 ph_value: {ph_value}")
|
||||
debug_print(f" 🧪 reagent: '{reagent}'")
|
||||
debug_print(f" 📦 kwargs: {kwargs}")
|
||||
debug_print("=" * 60)
|
||||
|
||||
|
||||
debug_print(f"pH调节协议: vessel={vessel_id}, ph={ph_value}, reagent='{reagent}'")
|
||||
|
||||
action_sequence = []
|
||||
|
||||
# 从kwargs中获取可选参数,如果没有则使用默认值
|
||||
volume = kwargs.get('volume', 0.0) # 自动估算体积
|
||||
stir = kwargs.get('stir', True) # 默认搅拌
|
||||
stir_speed = kwargs.get('stir_speed', 300.0) # 默认搅拌速度
|
||||
stir_time = kwargs.get('stir_time', 60.0) # 默认搅拌时间
|
||||
settling_time = kwargs.get('settling_time', 30.0) # 默认平衡时间
|
||||
|
||||
debug_print(f"🔧 处理后的参数:")
|
||||
debug_print(f" 📏 volume: {volume}mL (0.0表示自动估算)")
|
||||
debug_print(f" 🌪️ stir: {stir}")
|
||||
debug_print(f" 🔄 stir_speed: {stir_speed}rpm")
|
||||
debug_print(f" ⏱️ stir_time: {stir_time}s")
|
||||
debug_print(f" ⏳ settling_time: {settling_time}s")
|
||||
|
||||
|
||||
# 从kwargs中获取可选参数
|
||||
volume = kwargs.get('volume', 0.0)
|
||||
stir = kwargs.get('stir', True)
|
||||
stir_speed = kwargs.get('stir_speed', 300.0)
|
||||
stir_time = kwargs.get('stir_time', 60.0)
|
||||
settling_time = kwargs.get('settling_time', 30.0)
|
||||
|
||||
# 开始处理
|
||||
action_sequence.append(create_action_log(f"开始调节pH至 {ph_value}", "🧪"))
|
||||
action_sequence.append(create_action_log(f"目标容器: {vessel_id}", "🥼"))
|
||||
action_sequence.append(create_action_log(f"使用试剂: {reagent}", "⚗️"))
|
||||
|
||||
|
||||
# 1. 验证目标容器存在
|
||||
debug_print(f"🔍 步骤1: 验证目标容器...")
|
||||
if vessel_id not in G.nodes():
|
||||
debug_print(f"❌ 目标容器 '{vessel_id}' 不存在于系统中")
|
||||
raise ValueError(f"目标容器 '{vessel_id}' 不存在于系统中")
|
||||
|
||||
debug_print(f"✅ 目标容器验证通过")
|
||||
|
||||
action_sequence.append(create_action_log("目标容器验证通过", "✅"))
|
||||
|
||||
|
||||
# 2. 查找酸碱试剂容器
|
||||
debug_print(f"🔍 步骤2: 查找试剂容器...")
|
||||
action_sequence.append(create_action_log("正在查找试剂容器...", "🔍"))
|
||||
|
||||
try:
|
||||
reagent_vessel = find_acid_base_vessel(G, reagent)
|
||||
debug_print(f"✅ 找到试剂容器: {reagent_vessel}")
|
||||
action_sequence.append(create_action_log(f"找到试剂容器: {reagent_vessel}", "🧪"))
|
||||
except ValueError as e:
|
||||
debug_print(f"❌ 无法找到试剂容器: {str(e)}")
|
||||
action_sequence.append(create_action_log(f"试剂容器查找失败: {str(e)}", "❌"))
|
||||
raise ValueError(f"无法找到试剂 '{reagent}': {str(e)}")
|
||||
|
||||
|
||||
# 3. 体积估算
|
||||
debug_print(f"🔍 步骤3: 体积处理...")
|
||||
if volume <= 0:
|
||||
action_sequence.append(create_action_log("开始自动估算试剂体积", "🧮"))
|
||||
|
||||
# 获取目标容器的体积信息
|
||||
vessel_data = G.nodes[vessel_id].get('data', {})
|
||||
vessel_volume = vessel_data.get('max_volume', 100.0) # 默认100mL
|
||||
debug_print(f"📏 容器最大体积: {vessel_volume}mL")
|
||||
|
||||
vessel_volume = vessel_data.get('max_volume', 100.0)
|
||||
|
||||
estimated_volume = calculate_reagent_volume(ph_value, reagent, vessel_volume)
|
||||
volume = estimated_volume
|
||||
debug_print(f"✅ 自动估算试剂体积: {volume:.2f} mL")
|
||||
action_sequence.append(create_action_log(f"估算试剂体积: {volume:.2f}mL", "📊"))
|
||||
else:
|
||||
debug_print(f"📏 使用指定体积: {volume}mL")
|
||||
action_sequence.append(create_action_log(f"使用指定体积: {volume}mL", "📏"))
|
||||
|
||||
|
||||
# 4. 验证路径存在
|
||||
debug_print(f"🔍 步骤4: 路径验证...")
|
||||
action_sequence.append(create_action_log("验证转移路径...", "🛤️"))
|
||||
|
||||
try:
|
||||
path = nx.shortest_path(G, source=reagent_vessel, target=vessel_id)
|
||||
debug_print(f"✅ 找到路径: {' → '.join(path)}")
|
||||
action_sequence.append(create_action_log(f"找到转移路径: {' → '.join(path)}", "🛤️"))
|
||||
action_sequence.append(create_action_log(f"找到转移路径: {' -> '.join(path)}", "🛤️"))
|
||||
except nx.NetworkXNoPath:
|
||||
debug_print(f"❌ 无法找到转移路径")
|
||||
action_sequence.append(create_action_log("转移路径不存在", "❌"))
|
||||
raise ValueError(f"从试剂容器 '{reagent_vessel}' 到目标容器 '{vessel_id}' 没有可用路径")
|
||||
|
||||
|
||||
# 5. 搅拌器设置
|
||||
debug_print(f"🔍 步骤5: 搅拌器设置...")
|
||||
stirrer_id = None
|
||||
if stir:
|
||||
action_sequence.append(create_action_log("准备启动搅拌器", "🌪️"))
|
||||
@@ -318,7 +223,6 @@ def generate_adjust_ph_protocol(
|
||||
stirrer_id = find_connected_stirrer(G, vessel_id)
|
||||
|
||||
if stirrer_id:
|
||||
debug_print(f"✅ 找到搅拌器 {stirrer_id},启动搅拌")
|
||||
action_sequence.append(create_action_log(f"启动搅拌器 {stirrer_id} (速度: {stir_speed}rpm)", "🔄"))
|
||||
|
||||
action_sequence.append({
|
||||
@@ -338,23 +242,18 @@ def generate_adjust_ph_protocol(
|
||||
"action_kwargs": {"time": 5}
|
||||
})
|
||||
else:
|
||||
debug_print(f"⚠️ 未找到搅拌器,继续执行")
|
||||
action_sequence.append(create_action_log("未找到搅拌器,跳过搅拌", "⚠️"))
|
||||
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"❌ 搅拌器配置出错: {str(e)}")
|
||||
action_sequence.append(create_action_log(f"搅拌器配置失败: {str(e)}", "❌"))
|
||||
else:
|
||||
debug_print(f"📋 跳过搅拌设置")
|
||||
action_sequence.append(create_action_log("跳过搅拌设置", "⏭️"))
|
||||
|
||||
|
||||
# 6. 试剂添加
|
||||
debug_print(f"🔍 步骤6: 试剂添加...")
|
||||
action_sequence.append(create_action_log(f"开始添加试剂 {volume:.2f}mL", "🚰"))
|
||||
|
||||
# 计算添加时间(pH调节需要缓慢添加)
|
||||
addition_time = max(30.0, volume * 2.0) # 至少30秒,每mL需要2秒
|
||||
debug_print(f"⏱️ 计算添加时间: {addition_time}s (缓慢注入)")
|
||||
addition_time = max(30.0, volume * 2.0)
|
||||
action_sequence.append(create_action_log(f"设置添加时间: {addition_time:.0f}s (缓慢注入)", "⏱️"))
|
||||
|
||||
try:
|
||||
@@ -377,35 +276,28 @@ def generate_adjust_ph_protocol(
|
||||
)
|
||||
|
||||
action_sequence.extend(pump_actions)
|
||||
debug_print(f"✅ 泵协议生成完成,添加了 {len(pump_actions)} 个动作")
|
||||
action_sequence.append(create_action_log(f"试剂转移完成 ({len(pump_actions)} 个操作)", "✅"))
|
||||
|
||||
# 🔧 修复体积运算 - 试剂添加成功后更新容器液体体积
|
||||
debug_print(f"🔧 更新容器液体体积...")
|
||||
|
||||
# 体积运算 - 试剂添加成功后更新容器液体体积
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
debug_print(f"📊 添加前容器体积: {current_volume}")
|
||||
|
||||
|
||||
# 处理不同的体积数据格式
|
||||
if isinstance(current_volume, list):
|
||||
if len(current_volume) > 0:
|
||||
# 增加体积(添加试剂)
|
||||
vessel["data"]["liquid_volume"][0] += volume
|
||||
debug_print(f"📊 添加后容器体积: {vessel['data']['liquid_volume'][0]:.2f}mL (+{volume:.2f}mL)")
|
||||
else:
|
||||
# 如果列表为空,创建新的体积记录
|
||||
vessel["data"]["liquid_volume"] = [volume]
|
||||
debug_print(f"📊 初始化容器体积: {volume:.2f}mL")
|
||||
elif isinstance(current_volume, (int, float)):
|
||||
# 直接数值类型
|
||||
vessel["data"]["liquid_volume"] += volume
|
||||
debug_print(f"📊 添加后容器体积: {vessel['data']['liquid_volume']:.2f}mL (+{volume:.2f}mL)")
|
||||
else:
|
||||
debug_print(f"⚠️ 未知的体积数据格式: {type(current_volume)}")
|
||||
debug_print(f"未知的体积数据格式: {type(current_volume)}")
|
||||
# 创建新的体积记录
|
||||
vessel["data"]["liquid_volume"] = volume
|
||||
else:
|
||||
debug_print(f"📊 容器无液体体积数据,创建新记录: {volume:.2f}mL")
|
||||
# 确保vessel有data字段
|
||||
if "data" not in vessel:
|
||||
vessel["data"] = {}
|
||||
@@ -423,19 +315,16 @@ def generate_adjust_ph_protocol(
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = [volume]
|
||||
else:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = current_node_volume + volume
|
||||
|
||||
debug_print(f"✅ 图节点体积数据已更新")
|
||||
|
||||
|
||||
action_sequence.append(create_action_log(f"容器体积已更新 (+{volume:.2f}mL)", "📊"))
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"❌ 生成泵协议时出错: {str(e)}")
|
||||
debug_print(f"生成泵协议时出错: {str(e)}")
|
||||
action_sequence.append(create_action_log(f"泵协议生成失败: {str(e)}", "❌"))
|
||||
raise ValueError(f"生成泵协议时出错: {str(e)}")
|
||||
|
||||
# 7. 混合搅拌
|
||||
if stir and stirrer_id:
|
||||
debug_print(f"🔍 步骤7: 混合搅拌...")
|
||||
action_sequence.append(create_action_log(f"开始混合搅拌 {stir_time:.0f}s", "🌀"))
|
||||
|
||||
action_sequence.append({
|
||||
@@ -448,14 +337,10 @@ def generate_adjust_ph_protocol(
|
||||
"purpose": f"pH调节: 混合试剂,目标pH={ph_value}"
|
||||
}
|
||||
})
|
||||
|
||||
debug_print(f"✅ 混合搅拌设置完成")
|
||||
else:
|
||||
debug_print(f"⏭️ 跳过混合搅拌")
|
||||
action_sequence.append(create_action_log("跳过混合搅拌", "⏭️"))
|
||||
|
||||
|
||||
# 8. 等待平衡
|
||||
debug_print(f"🔍 步骤8: 反应平衡...")
|
||||
action_sequence.append(create_action_log(f"等待pH平衡 {settling_time:.0f}s", "⚖️"))
|
||||
|
||||
action_sequence.append({
|
||||
@@ -468,17 +353,7 @@ def generate_adjust_ph_protocol(
|
||||
|
||||
# 9. 完成总结
|
||||
total_time = addition_time + stir_time + settling_time
|
||||
|
||||
debug_print("=" * 60)
|
||||
debug_print(f"🎉 pH调节协议生成完成")
|
||||
debug_print(f"📊 协议统计:")
|
||||
debug_print(f" 📋 总动作数: {len(action_sequence)}")
|
||||
debug_print(f" ⏱️ 预计总时间: {total_time:.0f}s ({total_time/60:.1f}分钟)")
|
||||
debug_print(f" 🧪 试剂: {reagent}")
|
||||
debug_print(f" 📏 体积: {volume:.2f}mL")
|
||||
debug_print(f" 📊 目标pH: {ph_value}")
|
||||
debug_print(f" 🥼 目标容器: {vessel_id}")
|
||||
debug_print("=" * 60)
|
||||
debug_print(f"pH调节协议完成: {len(action_sequence)} 个动作, {total_time:.0f}s, {volume:.2f}mL {reagent} → {vessel_id} pH {ph_value}")
|
||||
|
||||
# 添加完成日志
|
||||
summary_msg = f"pH调节协议完成: {vessel_id} → pH {ph_value} (使用 {volume:.2f}mL {reagent})"
|
||||
@@ -510,28 +385,18 @@ def generate_adjust_ph_protocol_stepwise(
|
||||
"""
|
||||
# 🔧 核心修改:从字典中提取容器ID
|
||||
vessel_id = vessel["id"]
|
||||
|
||||
debug_print("=" * 60)
|
||||
debug_print(f"🔄 开始分步pH调节")
|
||||
debug_print(f"📋 分步参数:")
|
||||
debug_print(f" 🥼 vessel: {vessel} (ID: {vessel_id})")
|
||||
debug_print(f" 📊 ph_value: {ph_value}")
|
||||
debug_print(f" 🧪 reagent: {reagent}")
|
||||
debug_print(f" 📏 max_volume: {max_volume}mL")
|
||||
debug_print(f" 🔢 steps: {steps}")
|
||||
debug_print("=" * 60)
|
||||
|
||||
debug_print(f"分步pH调节: vessel={vessel_id}, ph={ph_value}, reagent={reagent}, max_volume={max_volume}mL, steps={steps}")
|
||||
|
||||
action_sequence = []
|
||||
|
||||
# 每步添加的体积
|
||||
step_volume = max_volume / steps
|
||||
debug_print(f"📊 每步体积: {step_volume:.2f}mL")
|
||||
|
||||
action_sequence.append(create_action_log(f"开始分步pH调节 ({steps}步)", "🔄"))
|
||||
action_sequence.append(create_action_log(f"每步添加: {step_volume:.2f}mL", "📏"))
|
||||
|
||||
for i in range(steps):
|
||||
debug_print(f"🔄 执行第 {i+1}/{steps} 步,添加 {step_volume:.2f}mL")
|
||||
action_sequence.append(create_action_log(f"第 {i+1}/{steps} 步开始", "🚀"))
|
||||
|
||||
# 生成单步协议
|
||||
@@ -548,12 +413,10 @@ def generate_adjust_ph_protocol_stepwise(
|
||||
)
|
||||
|
||||
action_sequence.extend(step_actions)
|
||||
debug_print(f"✅ 第 {i+1}/{steps} 步完成,添加了 {len(step_actions)} 个动作")
|
||||
action_sequence.append(create_action_log(f"第 {i+1}/{steps} 步完成", "✅"))
|
||||
|
||||
# 步骤间等待
|
||||
if i < steps - 1:
|
||||
debug_print(f"⏳ 步骤间等待30s")
|
||||
action_sequence.append(create_action_log("步骤间等待...", "⏳"))
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
@@ -563,7 +426,7 @@ def generate_adjust_ph_protocol_stepwise(
|
||||
}
|
||||
})
|
||||
|
||||
debug_print(f"🎉 分步pH调节完成,共 {len(action_sequence)} 个动作")
|
||||
debug_print(f"分步pH调节完成: {len(action_sequence)} 个动作")
|
||||
action_sequence.append(create_action_log("分步pH调节全部完成", "🎉"))
|
||||
|
||||
return action_sequence
|
||||
@@ -577,7 +440,7 @@ def generate_acidify_protocol(
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""酸化协议"""
|
||||
vessel_id = vessel["id"]
|
||||
debug_print(f"🍋 生成酸化协议: {vessel_id} → pH {target_ph} (使用 {acid})")
|
||||
debug_print(f"酸化协议: {vessel_id} → pH {target_ph} ({acid})")
|
||||
return generate_adjust_ph_protocol(
|
||||
G, vessel, target_ph, acid
|
||||
)
|
||||
@@ -590,7 +453,7 @@ def generate_basify_protocol(
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""碱化协议"""
|
||||
vessel_id = vessel["id"]
|
||||
debug_print(f"🧂 生成碱化协议: {vessel_id} → pH {target_ph} (使用 {base})")
|
||||
debug_print(f"碱化协议: {vessel_id} → pH {target_ph} ({base})")
|
||||
return generate_adjust_ph_protocol(
|
||||
G, vessel, target_ph, base
|
||||
)
|
||||
@@ -602,7 +465,7 @@ def generate_neutralize_protocol(
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""中和协议(pH=7)"""
|
||||
vessel_id = vessel["id"]
|
||||
debug_print(f"⚖️ 生成中和协议: {vessel_id} → pH 7.0 (使用 {reagent})")
|
||||
debug_print(f"中和协议: {vessel_id} → pH 7.0 ({reagent})")
|
||||
return generate_adjust_ph_protocol(
|
||||
G, vessel, 7.0, reagent
|
||||
)
|
||||
@@ -610,10 +473,7 @@ def generate_neutralize_protocol(
|
||||
# 测试函数
|
||||
def test_adjust_ph_protocol():
|
||||
"""测试pH调节协议"""
|
||||
debug_print("=== ADJUST PH PROTOCOL 增强版测试 ===")
|
||||
|
||||
# 测试体积计算
|
||||
debug_print("🧮 测试体积计算...")
|
||||
test_cases = [
|
||||
(2.0, "hydrochloric acid", 100.0),
|
||||
(4.0, "hydrochloric acid", 100.0),
|
||||
@@ -621,12 +481,12 @@ def test_adjust_ph_protocol():
|
||||
(10.0, "sodium hydroxide", 100.0),
|
||||
(7.0, "unknown reagent", 100.0)
|
||||
]
|
||||
|
||||
|
||||
for ph, reagent, volume in test_cases:
|
||||
result = calculate_reagent_volume(ph, reagent, volume)
|
||||
debug_print(f"📊 {reagent} → pH {ph}: {result:.2f}mL")
|
||||
|
||||
debug_print("✅ 测试完成")
|
||||
debug_print(f"{reagent} → pH {ph}: {result:.2f}mL")
|
||||
|
||||
debug_print("测试完成")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_adjust_ph_protocol()
|
||||
@@ -1,4 +1,12 @@
|
||||
"""
|
||||
AGV 单物料转运编译器
|
||||
|
||||
从 physical_setup_graph 中查询 AGV 配置(device_roles, route_table),
|
||||
不再硬编码 device_id 和路由表。
|
||||
"""
|
||||
|
||||
import networkx as nx
|
||||
from unilabos.compile._agv_utils import find_agv_config
|
||||
|
||||
|
||||
def generate_agv_transfer_protocol(
|
||||
@@ -17,37 +25,32 @@ def generate_agv_transfer_protocol(
|
||||
from_repo_id = from_repo_["id"]
|
||||
to_repo_id = to_repo_["id"]
|
||||
|
||||
wf_list = {
|
||||
("AiChemEcoHiWo", "zhixing_agv"): {"nav_command" : '{"target" : "LM14"}',
|
||||
"arm_command": '{"task_name" : "camera/250111_biaozhi.urp"}'},
|
||||
("AiChemEcoHiWo", "AGV"): {"nav_command" : '{"target" : "LM14"}',
|
||||
"arm_command": '{"task_name" : "camera/250111_biaozhi.urp"}'},
|
||||
# 从 G 中查询 AGV 配置
|
||||
agv_cfg = find_agv_config(G)
|
||||
device_roles = agv_cfg["device_roles"]
|
||||
route_table = agv_cfg["route_table"]
|
||||
|
||||
("zhixing_agv", "Revvity"): {"nav_command" : '{"target" : "LM13"}',
|
||||
"arm_command": '{"task_name" : "camera/250111_put_board.urp"}'},
|
||||
route_key = f"{from_repo_id}->{to_repo_id}"
|
||||
if route_key not in route_table:
|
||||
raise KeyError(f"AGV 路由表中未找到路线: {route_key},可用路线: {list(route_table.keys())}")
|
||||
|
||||
("AGV", "Revvity"): {"nav_command" : '{"target" : "LM13"}',
|
||||
"arm_command": '{"task_name" : "camera/250111_put_board.urp"}'},
|
||||
route = route_table[route_key]
|
||||
nav_device = device_roles.get("navigator", device_roles.get("nav"))
|
||||
arm_device = device_roles.get("arm")
|
||||
|
||||
("Revvity", "HPLC"): {"nav_command": '{"target" : "LM13"}',
|
||||
"arm_command": '{"task_name" : "camera/250111_hplc.urp"}'},
|
||||
|
||||
("HPLC", "Revvity"): {"nav_command": '{"target" : "LM13"}',
|
||||
"arm_command": '{"task_name" : "camera/250111_lfp.urp"}'},
|
||||
}
|
||||
return [
|
||||
{
|
||||
"device_id": "zhixing_agv",
|
||||
"device_id": nav_device,
|
||||
"action_name": "send_nav_task",
|
||||
"action_kwargs": {
|
||||
"command": wf_list[(from_repo_id, to_repo_id)]["nav_command"]
|
||||
"command": route["nav_command"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"device_id": "zhixing_ur_arm",
|
||||
"device_id": arm_device,
|
||||
"action_name": "move_pos_task",
|
||||
"action_kwargs": {
|
||||
"command": wf_list[(from_repo_id, to_repo_id)]["arm_command"]
|
||||
"command": route.get("arm_command", route.get("arm_place", ""))
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
228
unilabos/compile/batch_transfer_protocol.py
Normal file
228
unilabos/compile/batch_transfer_protocol.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
批量物料转运编译器
|
||||
|
||||
将 BatchTransferProtocol 编译为多批次的 nav → pick × N → nav → place × N 动作序列。
|
||||
自动按 AGV 容量分批,全程维护三方 children dict 的物料系统一致性。
|
||||
"""
|
||||
|
||||
import copy
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import networkx as nx
|
||||
|
||||
from unilabos.compile._agv_utils import find_agv_config, split_batches
|
||||
|
||||
|
||||
def generate_batch_transfer_protocol(
|
||||
G: nx.Graph,
|
||||
from_repo: dict,
|
||||
to_repo: dict,
|
||||
transfer_resources: list,
|
||||
from_positions: list,
|
||||
to_positions: list,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""编译批量转运协议为可执行的 action steps
|
||||
|
||||
Args:
|
||||
G: 设备图 (physical_setup_graph)
|
||||
from_repo: 来源工站资源 dict({station_id: {..., children: {...}}})
|
||||
to_repo: 目标工站资源 dict(含堆栈和位置信息)
|
||||
transfer_resources: 被转运的物料列表(Resource dict)
|
||||
from_positions: 来源 slot 位置列表(与 transfer_resources 平行)
|
||||
to_positions: 目标 slot 位置列表(与 transfer_resources 平行)
|
||||
|
||||
Returns:
|
||||
action steps 列表,ROS2WorkstationNode 按序执行
|
||||
"""
|
||||
if not transfer_resources:
|
||||
return []
|
||||
|
||||
n = len(transfer_resources)
|
||||
if len(from_positions) != n or len(to_positions) != n:
|
||||
raise ValueError(
|
||||
f"transfer_resources({n}), from_positions({len(from_positions)}), "
|
||||
f"to_positions({len(to_positions)}) 长度不一致"
|
||||
)
|
||||
|
||||
# 组合为内部 transfer_items 便于分批处理
|
||||
transfer_items = []
|
||||
for i in range(n):
|
||||
res = transfer_resources[i] if isinstance(transfer_resources[i], dict) else {}
|
||||
transfer_items.append({
|
||||
"resource_id": res.get("id", res.get("name", "")),
|
||||
"resource_uuid": res.get("sample_id", ""),
|
||||
"from_position": from_positions[i],
|
||||
"to_position": to_positions[i],
|
||||
"resource": res,
|
||||
})
|
||||
|
||||
# 查询 AGV 配置
|
||||
agv_cfg = find_agv_config(G)
|
||||
agv_id = agv_cfg["agv_id"]
|
||||
device_roles = agv_cfg["device_roles"]
|
||||
route_table = agv_cfg["route_table"]
|
||||
capacity = agv_cfg["capacity"]
|
||||
|
||||
if capacity <= 0:
|
||||
raise ValueError(f"AGV {agv_id} 容量为 0,请检查 Warehouse 子节点配置")
|
||||
|
||||
nav_device = device_roles.get("navigator", device_roles.get("nav"))
|
||||
arm_device = device_roles.get("arm")
|
||||
if not nav_device or not arm_device:
|
||||
raise ValueError(f"AGV {agv_id} device_roles 缺少 navigator 或 arm: {device_roles}")
|
||||
|
||||
from_repo_ = list(from_repo.values())[0]
|
||||
to_repo_ = list(to_repo.values())[0]
|
||||
from_station_id = from_repo_["id"]
|
||||
to_station_id = to_repo_["id"]
|
||||
|
||||
# 查找路由
|
||||
route_to_source = _find_route(route_table, agv_id, from_station_id)
|
||||
route_to_target = _find_route(route_table, from_station_id, to_station_id)
|
||||
|
||||
# 构建 AGV carrier 的 children dict(用于 compile 阶段状态追踪)
|
||||
agv_carrier_children: Dict[str, Any] = {}
|
||||
|
||||
# 计算 slot 名称(A01, A02, B01, ...)
|
||||
agv_slot_names = _get_agv_slot_names(G, agv_cfg)
|
||||
|
||||
# 分批
|
||||
batches = split_batches(transfer_items, capacity)
|
||||
|
||||
steps: List[Dict[str, Any]] = []
|
||||
|
||||
for batch_idx, batch in enumerate(batches):
|
||||
is_last_batch = (batch_idx == len(batches) - 1)
|
||||
|
||||
# 阶段 1: AGV 导航到来源工站
|
||||
steps.append({
|
||||
"device_id": nav_device,
|
||||
"action_name": "send_nav_task",
|
||||
"action_kwargs": {
|
||||
"command": route_to_source.get("nav_command", "")
|
||||
},
|
||||
"_comment": f"批次{batch_idx + 1}/{len(batches)}: AGV 导航至来源 {from_station_id}"
|
||||
})
|
||||
|
||||
# 阶段 2: 逐个 pick
|
||||
for item_idx, item in enumerate(batch):
|
||||
from_pos = item["from_position"]
|
||||
slot = agv_slot_names[item_idx] if item_idx < len(agv_slot_names) else f"S{item_idx + 1}"
|
||||
|
||||
# compile 阶段更新 children dict
|
||||
if from_pos in from_repo_.get("children", {}):
|
||||
resource_data = from_repo_["children"].pop(from_pos)
|
||||
resource_data["parent"] = agv_id
|
||||
agv_carrier_children[slot] = resource_data
|
||||
|
||||
steps.append({
|
||||
"device_id": arm_device,
|
||||
"action_name": "move_pos_task",
|
||||
"action_kwargs": {
|
||||
"command": route_to_source.get("arm_pick", route_to_source.get("arm_command", ""))
|
||||
},
|
||||
"_transfer_meta": {
|
||||
"phase": "pick",
|
||||
"resource_uuid": item.get("resource_uuid", ""),
|
||||
"resource_id": item.get("resource_id", ""),
|
||||
"from_parent": from_station_id,
|
||||
"from_position": from_pos,
|
||||
"agv_slot": slot,
|
||||
},
|
||||
"_comment": f"Pick {item.get('resource_id', from_pos)} → AGV.{slot}"
|
||||
})
|
||||
|
||||
# 阶段 3: AGV 导航到目标工站
|
||||
steps.append({
|
||||
"device_id": nav_device,
|
||||
"action_name": "send_nav_task",
|
||||
"action_kwargs": {
|
||||
"command": route_to_target.get("nav_command", "")
|
||||
},
|
||||
"_comment": f"批次{batch_idx + 1}: AGV 导航至目标 {to_station_id}"
|
||||
})
|
||||
|
||||
# 阶段 4: 逐个 place
|
||||
for item_idx, item in enumerate(batch):
|
||||
to_pos = item["to_position"]
|
||||
slot = agv_slot_names[item_idx] if item_idx < len(agv_slot_names) else f"S{item_idx + 1}"
|
||||
|
||||
# compile 阶段更新 children dict
|
||||
if slot in agv_carrier_children:
|
||||
resource_data = agv_carrier_children.pop(slot)
|
||||
resource_data["parent"] = to_repo_["id"]
|
||||
to_repo_["children"][to_pos] = resource_data
|
||||
|
||||
steps.append({
|
||||
"device_id": arm_device,
|
||||
"action_name": "move_pos_task",
|
||||
"action_kwargs": {
|
||||
"command": route_to_target.get("arm_place", route_to_target.get("arm_command", ""))
|
||||
},
|
||||
"_transfer_meta": {
|
||||
"phase": "place",
|
||||
"resource_uuid": item.get("resource_uuid", ""),
|
||||
"resource_id": item.get("resource_id", ""),
|
||||
"to_parent": to_station_id,
|
||||
"to_position": to_pos,
|
||||
"agv_slot": slot,
|
||||
},
|
||||
"_comment": f"Place AGV.{slot} → {to_station_id}.{to_pos}"
|
||||
})
|
||||
|
||||
# 如果还有下一批,AGV 需要返回来源取料
|
||||
if not is_last_batch:
|
||||
steps.append({
|
||||
"device_id": nav_device,
|
||||
"action_name": "send_nav_task",
|
||||
"action_kwargs": {
|
||||
"command": route_to_source.get("nav_command", "")
|
||||
},
|
||||
"_comment": f"AGV 返回来源 {from_station_id} 取下一批"
|
||||
})
|
||||
|
||||
return steps
|
||||
|
||||
|
||||
def _find_route(route_table: Dict[str, Any], from_id: str, to_id: str) -> Dict[str, str]:
|
||||
"""在路由表中查找路线,支持 A->B 和 (A, B) 两种 key 格式"""
|
||||
# 优先 "A->B" 格式
|
||||
key = f"{from_id}->{to_id}"
|
||||
if key in route_table:
|
||||
return route_table[key]
|
||||
# 兼容 tuple key(JSON 中以逗号分隔字符串表示)
|
||||
tuple_key = f"({from_id}, {to_id})"
|
||||
if tuple_key in route_table:
|
||||
return route_table[tuple_key]
|
||||
raise KeyError(f"路由表中未找到: {key},可用路线: {list(route_table.keys())}")
|
||||
|
||||
|
||||
def _get_agv_slot_names(G: nx.Graph, agv_cfg: dict) -> List[str]:
|
||||
"""从设备图中获取 AGV Warehouse 的 slot 名称列表"""
|
||||
agv_id = agv_cfg["agv_id"]
|
||||
neighbors = G.successors(agv_id) if G.is_directed() else G.neighbors(agv_id)
|
||||
for neighbor in neighbors:
|
||||
ndata = G.nodes[neighbor]
|
||||
node_type = ndata.get("type", "")
|
||||
res_content = ndata.get("res_content")
|
||||
if hasattr(res_content, "type"):
|
||||
node_type = res_content.type or node_type
|
||||
elif isinstance(res_content, dict):
|
||||
node_type = res_content.get("type", node_type)
|
||||
if node_type == "warehouse":
|
||||
config = ndata.get("config", {})
|
||||
if hasattr(res_content, "config") and isinstance(res_content.config, dict):
|
||||
config = res_content.config
|
||||
elif isinstance(res_content, dict):
|
||||
config = res_content.get("config", config)
|
||||
num_x = config.get("num_items_x", 1)
|
||||
num_y = config.get("num_items_y", 1)
|
||||
num_z = config.get("num_items_z", 1)
|
||||
# 与 warehouse_factory 一致的命名
|
||||
letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
len_x = num_x if num_z == 1 else (num_y if num_x == 1 else num_x)
|
||||
len_y = num_y if num_z == 1 else (num_z if num_x == 1 else num_z)
|
||||
return [f"{letters[j]}{i + 1:02d}" for i in range(len_x) for j in range(len_y)]
|
||||
# 兜底生成通用名称
|
||||
capacity = agv_cfg.get("capacity", 4)
|
||||
return [f"S{i + 1}" for i in range(capacity)]
|
||||
@@ -1,7 +1,9 @@
|
||||
from typing import List, Dict, Any
|
||||
import networkx as nx
|
||||
from .utils.vessel_parser import get_vessel, find_solvent_vessel
|
||||
from .utils.vessel_parser import get_vessel, find_solvent_vessel, find_connected_heatchill
|
||||
from .utils.logger_util import debug_print
|
||||
from .pump_protocol import generate_pump_protocol
|
||||
from .utils.resource_helper import get_resource_liquid_volume
|
||||
|
||||
|
||||
def find_solvent_vessel_by_any_match(G: nx.DiGraph, solvent: str) -> str:
|
||||
@@ -17,43 +19,23 @@ def find_waste_vessel(G: nx.DiGraph) -> str:
|
||||
"""
|
||||
possible_waste_names = [
|
||||
"waste_workup",
|
||||
"flask_waste",
|
||||
"flask_waste",
|
||||
"bottle_waste",
|
||||
"waste",
|
||||
"waste_vessel",
|
||||
"waste_container"
|
||||
]
|
||||
|
||||
|
||||
for waste_name in possible_waste_names:
|
||||
if waste_name in G.nodes():
|
||||
return waste_name
|
||||
|
||||
|
||||
raise ValueError(f"未找到废液容器。尝试了以下名称: {possible_waste_names}")
|
||||
|
||||
|
||||
def find_connected_heatchill(G: nx.DiGraph, vessel: str) -> str:
|
||||
"""
|
||||
查找与指定容器相连的加热冷却设备
|
||||
"""
|
||||
# 查找所有加热冷却设备节点
|
||||
heatchill_nodes = [node for node in G.nodes()
|
||||
if (G.nodes[node].get('class') or '') == 'virtual_heatchill']
|
||||
|
||||
# 检查哪个加热设备与目标容器相连(机械连接)
|
||||
for heatchill in heatchill_nodes:
|
||||
if G.has_edge(heatchill, vessel) or G.has_edge(vessel, heatchill):
|
||||
return heatchill
|
||||
|
||||
# 如果没有直接连接,返回第一个可用的加热设备
|
||||
if heatchill_nodes:
|
||||
return heatchill_nodes[0]
|
||||
|
||||
return None # 没有加热设备也可以工作,只是不能加热
|
||||
|
||||
|
||||
def generate_clean_vessel_protocol(
|
||||
G: nx.DiGraph,
|
||||
vessel: dict, # 🔧 修改:从字符串改为字典类型
|
||||
vessel: dict,
|
||||
solvent: str,
|
||||
volume: float,
|
||||
temp: float,
|
||||
@@ -61,7 +43,7 @@ def generate_clean_vessel_protocol(
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
生成容器清洗操作的协议序列,复用 pump_protocol 的成熟算法
|
||||
|
||||
|
||||
清洗流程:
|
||||
1. 查找溶剂容器和废液容器
|
||||
2. 如果需要加热,启动加热设备
|
||||
@@ -70,63 +52,50 @@ def generate_clean_vessel_protocol(
|
||||
b. (可选) 等待清洗作用时间
|
||||
c. 使用 pump_protocol 将清洗液从目标容器转移到废液容器
|
||||
4. 如果加热了,停止加热
|
||||
|
||||
|
||||
Args:
|
||||
G: 有向图,节点为设备和容器,边为流体管道
|
||||
vessel: 要清洗的容器字典(包含id字段)
|
||||
solvent: 用于清洗的溶剂名称
|
||||
solvent: 用于清洗的溶剂名称
|
||||
volume: 每次清洗使用的溶剂体积
|
||||
temp: 清洗时的温度
|
||||
repeats: 清洗操作的重复次数,默认为 1
|
||||
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 容器清洗操作的动作序列
|
||||
|
||||
Raises:
|
||||
ValueError: 当找不到必要的容器或设备时抛出异常
|
||||
|
||||
Examples:
|
||||
clean_protocol = generate_clean_vessel_protocol(G, {"id": "main_reactor"}, "water", 100.0, 60.0, 2)
|
||||
"""
|
||||
# 🔧 核心修改:从字典中提取容器ID
|
||||
vessel_id, vessel_data = get_vessel(vessel)
|
||||
|
||||
|
||||
action_sequence = []
|
||||
|
||||
print(f"CLEAN_VESSEL: 开始生成容器清洗协议")
|
||||
print(f" - 目标容器: {vessel} (ID: {vessel_id})")
|
||||
print(f" - 清洗溶剂: {solvent}")
|
||||
print(f" - 清洗体积: {volume} mL")
|
||||
print(f" - 清洗温度: {temp}°C")
|
||||
print(f" - 重复次数: {repeats}")
|
||||
|
||||
|
||||
debug_print(f"开始生成容器清洗协议: vessel={vessel_id}, solvent={solvent}, volume={volume}mL, temp={temp}°C, repeats={repeats}")
|
||||
|
||||
# 验证目标容器存在
|
||||
if vessel_id not in G.nodes():
|
||||
raise ValueError(f"目标容器 '{vessel_id}' 不存在于系统中")
|
||||
|
||||
|
||||
# 查找溶剂容器
|
||||
try:
|
||||
solvent_vessel = find_solvent_vessel(G, solvent)
|
||||
print(f"CLEAN_VESSEL: 找到溶剂容器: {solvent_vessel}")
|
||||
debug_print(f"找到溶剂容器: {solvent_vessel}")
|
||||
except ValueError as e:
|
||||
raise ValueError(f"无法找到溶剂容器: {str(e)}")
|
||||
|
||||
|
||||
# 查找废液容器
|
||||
try:
|
||||
waste_vessel = find_waste_vessel(G)
|
||||
print(f"CLEAN_VESSEL: 找到废液容器: {waste_vessel}")
|
||||
debug_print(f"找到废液容器: {waste_vessel}")
|
||||
except ValueError as e:
|
||||
raise ValueError(f"无法找到废液容器: {str(e)}")
|
||||
|
||||
|
||||
# 查找加热设备(可选)
|
||||
heatchill_id = find_connected_heatchill(G, vessel_id) # 🔧 使用 vessel_id
|
||||
heatchill_id = find_connected_heatchill(G, vessel_id)
|
||||
if heatchill_id:
|
||||
print(f"CLEAN_VESSEL: 找到加热设备: {heatchill_id}")
|
||||
debug_print(f"找到加热设备: {heatchill_id}")
|
||||
else:
|
||||
print(f"CLEAN_VESSEL: 未找到加热设备,将在室温下清洗")
|
||||
|
||||
# 🔧 新增:记录清洗前的容器状态
|
||||
print(f"CLEAN_VESSEL: 记录清洗前容器状态...")
|
||||
debug_print(f"未找到加热设备,将在室温下清洗")
|
||||
|
||||
# 记录清洗前的容器状态
|
||||
original_liquid_volume = 0.0
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
@@ -134,79 +103,69 @@ def generate_clean_vessel_protocol(
|
||||
original_liquid_volume = current_volume[0]
|
||||
elif isinstance(current_volume, (int, float)):
|
||||
original_liquid_volume = current_volume
|
||||
print(f"CLEAN_VESSEL: 清洗前液体体积: {original_liquid_volume:.2f}mL")
|
||||
|
||||
|
||||
# 第一步:如果需要加热且有加热设备,启动加热
|
||||
if temp > 25.0 and heatchill_id:
|
||||
print(f"CLEAN_VESSEL: 启动加热至 {temp}°C")
|
||||
debug_print(f"启动加热至 {temp}°C")
|
||||
heatchill_start_action = {
|
||||
"device_id": heatchill_id,
|
||||
"action_name": "heat_chill_start",
|
||||
"action_kwargs": {
|
||||
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
|
||||
"vessel": {"id": vessel_id},
|
||||
"temp": temp,
|
||||
"purpose": f"cleaning with {solvent}"
|
||||
}
|
||||
}
|
||||
action_sequence.append(heatchill_start_action)
|
||||
|
||||
# 等待温度稳定
|
||||
|
||||
wait_action = {
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": 30} # 等待30秒让温度稳定
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": 30}
|
||||
}
|
||||
action_sequence.append(wait_action)
|
||||
|
||||
|
||||
# 第二步:重复清洗操作
|
||||
for repeat in range(repeats):
|
||||
print(f"CLEAN_VESSEL: 执行第 {repeat + 1} 次清洗")
|
||||
|
||||
debug_print(f"执行第 {repeat + 1}/{repeats} 次清洗")
|
||||
|
||||
# 2a. 使用 pump_protocol 将溶剂转移到目标容器
|
||||
print(f"CLEAN_VESSEL: 将 {volume} mL {solvent} 转移到 {vessel_id}")
|
||||
try:
|
||||
# 调用成熟的 pump_protocol 算法
|
||||
add_solvent_actions = generate_pump_protocol(
|
||||
G=G,
|
||||
from_vessel=solvent_vessel,
|
||||
to_vessel=vessel_id, # 🔧 使用 vessel_id
|
||||
to_vessel=vessel_id,
|
||||
volume=volume,
|
||||
flowrate=2.5, # 适中的流速,避免飞溅
|
||||
flowrate=2.5,
|
||||
transfer_flowrate=2.5
|
||||
)
|
||||
action_sequence.extend(add_solvent_actions)
|
||||
|
||||
# 🔧 新增:更新容器体积(添加清洗溶剂)
|
||||
print(f"CLEAN_VESSEL: 更新容器体积 - 添加清洗溶剂 {volume:.2f}mL")
|
||||
|
||||
# 更新容器体积(添加清洗溶剂)
|
||||
if "data" not in vessel:
|
||||
vessel["data"] = {}
|
||||
|
||||
|
||||
if "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
if isinstance(current_volume, list):
|
||||
if len(current_volume) > 0:
|
||||
vessel["data"]["liquid_volume"][0] += volume
|
||||
print(f"CLEAN_VESSEL: 添加溶剂后体积: {vessel['data']['liquid_volume'][0]:.2f}mL (+{volume:.2f}mL)")
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = [volume]
|
||||
print(f"CLEAN_VESSEL: 初始化清洗体积: {volume:.2f}mL")
|
||||
elif isinstance(current_volume, (int, float)):
|
||||
vessel["data"]["liquid_volume"] += volume
|
||||
print(f"CLEAN_VESSEL: 添加溶剂后体积: {vessel['data']['liquid_volume']:.2f}mL (+{volume:.2f}mL)")
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = volume
|
||||
print(f"CLEAN_VESSEL: 重置体积为: {volume:.2f}mL")
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = volume
|
||||
print(f"CLEAN_VESSEL: 创建新体积记录: {volume:.2f}mL")
|
||||
|
||||
# 🔧 同时更新图中的容器数据
|
||||
|
||||
# 同时更新图中的容器数据
|
||||
if vessel_id in G.nodes():
|
||||
if 'data' not in G.nodes[vessel_id]:
|
||||
G.nodes[vessel_id]['data'] = {}
|
||||
|
||||
|
||||
vessel_node_data = G.nodes[vessel_id]['data']
|
||||
current_node_volume = vessel_node_data.get('liquid_volume', 0.0)
|
||||
|
||||
|
||||
if isinstance(current_node_volume, list):
|
||||
if len(current_node_volume) > 0:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'][0] += volume
|
||||
@@ -214,58 +173,48 @@ def generate_clean_vessel_protocol(
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = [volume]
|
||||
else:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = current_node_volume + volume
|
||||
|
||||
print(f"CLEAN_VESSEL: 图节点体积数据已更新")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
raise ValueError(f"无法将溶剂转移到容器: {str(e)}")
|
||||
|
||||
# 2b. 等待清洗作用时间(让溶剂充分清洗容器)
|
||||
cleaning_wait_time = 60 if temp > 50.0 else 30 # 高温下等待更久
|
||||
print(f"CLEAN_VESSEL: 等待清洗作用 {cleaning_wait_time} 秒")
|
||||
|
||||
# 2b. 等待清洗作用时间
|
||||
cleaning_wait_time = 60 if temp > 50.0 else 30
|
||||
wait_action = {
|
||||
"action_name": "wait",
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": cleaning_wait_time}
|
||||
}
|
||||
action_sequence.append(wait_action)
|
||||
|
||||
|
||||
# 2c. 使用 pump_protocol 将清洗液转移到废液容器
|
||||
print(f"CLEAN_VESSEL: 将清洗液从 {vessel_id} 转移到废液容器")
|
||||
try:
|
||||
# 调用成熟的 pump_protocol 算法
|
||||
remove_waste_actions = generate_pump_protocol(
|
||||
G=G,
|
||||
from_vessel=vessel_id, # 🔧 使用 vessel_id
|
||||
from_vessel=vessel_id,
|
||||
to_vessel=waste_vessel,
|
||||
volume=volume,
|
||||
flowrate=2.5, # 适中的流速
|
||||
flowrate=2.5,
|
||||
transfer_flowrate=2.5
|
||||
)
|
||||
action_sequence.extend(remove_waste_actions)
|
||||
|
||||
# 🔧 新增:更新容器体积(移除清洗液)
|
||||
print(f"CLEAN_VESSEL: 更新容器体积 - 移除清洗液 {volume:.2f}mL")
|
||||
|
||||
# 更新容器体积(移除清洗液)
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
if isinstance(current_volume, list):
|
||||
if len(current_volume) > 0:
|
||||
vessel["data"]["liquid_volume"][0] = max(0.0, vessel["data"]["liquid_volume"][0] - volume)
|
||||
print(f"CLEAN_VESSEL: 移除清洗液后体积: {vessel['data']['liquid_volume'][0]:.2f}mL (-{volume:.2f}mL)")
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = [0.0]
|
||||
print(f"CLEAN_VESSEL: 重置体积为0mL")
|
||||
elif isinstance(current_volume, (int, float)):
|
||||
vessel["data"]["liquid_volume"] = max(0.0, current_volume - volume)
|
||||
print(f"CLEAN_VESSEL: 移除清洗液后体积: {vessel['data']['liquid_volume']:.2f}mL (-{volume:.2f}mL)")
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = 0.0
|
||||
print(f"CLEAN_VESSEL: 重置体积为0mL")
|
||||
|
||||
# 🔧 同时更新图中的容器数据
|
||||
|
||||
# 同时更新图中的容器数据
|
||||
if vessel_id in G.nodes():
|
||||
vessel_node_data = G.nodes[vessel_id].get('data', {})
|
||||
current_node_volume = vessel_node_data.get('liquid_volume', 0.0)
|
||||
|
||||
|
||||
if isinstance(current_node_volume, list):
|
||||
if len(current_node_volume) > 0:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'][0] = max(0.0, current_node_volume[0] - volume)
|
||||
@@ -273,34 +222,30 @@ def generate_clean_vessel_protocol(
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = [0.0]
|
||||
else:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = max(0.0, current_node_volume - volume)
|
||||
|
||||
print(f"CLEAN_VESSEL: 图节点体积数据已更新")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
raise ValueError(f"无法将清洗液转移到废液容器: {str(e)}")
|
||||
|
||||
|
||||
# 2d. 清洗循环间的短暂等待
|
||||
if repeat < repeats - 1: # 不是最后一次清洗
|
||||
print(f"CLEAN_VESSEL: 清洗循环间等待")
|
||||
if repeat < repeats - 1:
|
||||
wait_action = {
|
||||
"action_name": "wait",
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": 10}
|
||||
}
|
||||
action_sequence.append(wait_action)
|
||||
|
||||
|
||||
# 第三步:如果加热了,停止加热
|
||||
if temp > 25.0 and heatchill_id:
|
||||
print(f"CLEAN_VESSEL: 停止加热")
|
||||
heatchill_stop_action = {
|
||||
"device_id": heatchill_id,
|
||||
"action_name": "heat_chill_stop",
|
||||
"action_kwargs": {
|
||||
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
|
||||
"vessel": {"id": vessel_id},
|
||||
}
|
||||
}
|
||||
action_sequence.append(heatchill_stop_action)
|
||||
|
||||
# 🔧 新增:清洗完成后的状态报告
|
||||
|
||||
# 清洗完成后的状态
|
||||
final_liquid_volume = 0.0
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
@@ -308,20 +253,17 @@ def generate_clean_vessel_protocol(
|
||||
final_liquid_volume = current_volume[0]
|
||||
elif isinstance(current_volume, (int, float)):
|
||||
final_liquid_volume = current_volume
|
||||
|
||||
print(f"CLEAN_VESSEL: 清洗完成")
|
||||
print(f" - 清洗前体积: {original_liquid_volume:.2f}mL")
|
||||
print(f" - 清洗后体积: {final_liquid_volume:.2f}mL")
|
||||
print(f" - 生成了 {len(action_sequence)} 个动作")
|
||||
|
||||
|
||||
debug_print(f"清洗完成: {len(action_sequence)} 个动作, 体积 {original_liquid_volume:.2f} -> {final_liquid_volume:.2f}mL")
|
||||
|
||||
return action_sequence
|
||||
|
||||
|
||||
# 便捷函数:常用清洗方案
|
||||
# 便捷函数
|
||||
def generate_quick_clean_protocol(
|
||||
G: nx.DiGraph,
|
||||
vessel: dict, # 🔧 修改:从字符串改为字典类型
|
||||
solvent: str = "water",
|
||||
G: nx.DiGraph,
|
||||
vessel: dict,
|
||||
solvent: str = "water",
|
||||
volume: float = 100.0
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""快速清洗:室温,单次清洗"""
|
||||
@@ -329,9 +271,9 @@ def generate_quick_clean_protocol(
|
||||
|
||||
|
||||
def generate_thorough_clean_protocol(
|
||||
G: nx.DiGraph,
|
||||
vessel: dict, # 🔧 修改:从字符串改为字典类型
|
||||
solvent: str = "water",
|
||||
G: nx.DiGraph,
|
||||
vessel: dict,
|
||||
solvent: str = "water",
|
||||
volume: float = 150.0,
|
||||
temp: float = 60.0
|
||||
) -> List[Dict[str, Any]]:
|
||||
@@ -340,13 +282,13 @@ def generate_thorough_clean_protocol(
|
||||
|
||||
|
||||
def generate_organic_clean_protocol(
|
||||
G: nx.DiGraph,
|
||||
vessel: dict, # 🔧 修改:从字符串改为字典类型
|
||||
G: nx.DiGraph,
|
||||
vessel: dict,
|
||||
volume: float = 100.0
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""有机清洗:先用有机溶剂,再用水清洗"""
|
||||
action_sequence = []
|
||||
|
||||
|
||||
# 第一步:有机溶剂清洗
|
||||
try:
|
||||
organic_actions = generate_clean_vessel_protocol(
|
||||
@@ -354,96 +296,71 @@ def generate_organic_clean_protocol(
|
||||
)
|
||||
action_sequence.extend(organic_actions)
|
||||
except ValueError:
|
||||
# 如果没有丙酮,尝试乙醇
|
||||
try:
|
||||
organic_actions = generate_clean_vessel_protocol(
|
||||
G, vessel, "ethanol", volume, 25.0, 2
|
||||
)
|
||||
action_sequence.extend(organic_actions)
|
||||
except ValueError:
|
||||
print("警告:未找到有机溶剂,跳过有机清洗步骤")
|
||||
|
||||
debug_print("未找到有机溶剂,跳过有机清洗步骤")
|
||||
|
||||
# 第二步:水清洗
|
||||
water_actions = generate_clean_vessel_protocol(
|
||||
G, vessel, "water", volume, 25.0, 2
|
||||
)
|
||||
action_sequence.extend(water_actions)
|
||||
|
||||
|
||||
return action_sequence
|
||||
|
||||
|
||||
def get_vessel_liquid_volume(G: nx.DiGraph, vessel: str) -> float:
|
||||
"""获取容器中的液体体积(修复版)"""
|
||||
if vessel not in G.nodes():
|
||||
return 0.0
|
||||
|
||||
vessel_data = G.nodes[vessel].get('data', {})
|
||||
liquids = vessel_data.get('liquid', [])
|
||||
|
||||
total_volume = 0.0
|
||||
for liquid in liquids:
|
||||
if isinstance(liquid, dict):
|
||||
# 支持两种格式:新格式 (name, volume) 和旧格式 (liquid_type, liquid_volume)
|
||||
volume = liquid.get('volume') or liquid.get('liquid_volume', 0.0)
|
||||
total_volume += volume
|
||||
|
||||
return total_volume
|
||||
|
||||
|
||||
def get_vessel_liquid_types(G: nx.DiGraph, vessel: str) -> List[str]:
|
||||
"""获取容器中所有液体的类型"""
|
||||
if vessel not in G.nodes():
|
||||
return []
|
||||
|
||||
|
||||
vessel_data = G.nodes[vessel].get('data', {})
|
||||
liquids = vessel_data.get('liquid', [])
|
||||
|
||||
|
||||
liquid_types = []
|
||||
for liquid in liquids:
|
||||
if isinstance(liquid, dict):
|
||||
# 支持两种格式的液体类型字段
|
||||
liquid_type = liquid.get('liquid_type') or liquid.get('name', '')
|
||||
if liquid_type:
|
||||
liquid_types.append(liquid_type)
|
||||
|
||||
|
||||
return liquid_types
|
||||
|
||||
|
||||
def find_vessel_by_content(G: nx.DiGraph, content: str) -> List[str]:
|
||||
"""
|
||||
根据内容物查找所有匹配的容器
|
||||
返回匹配容器的ID列表
|
||||
"""
|
||||
matching_vessels = []
|
||||
|
||||
|
||||
for node_id in G.nodes():
|
||||
if G.nodes[node_id].get('type') == 'container':
|
||||
# 检查容器名称匹配
|
||||
node_name = G.nodes[node_id].get('name', '').lower()
|
||||
if content.lower() in node_id.lower() or content.lower() in node_name:
|
||||
matching_vessels.append(node_id)
|
||||
continue
|
||||
|
||||
# 检查液体类型匹配
|
||||
|
||||
vessel_data = G.nodes[node_id].get('data', {})
|
||||
liquids = vessel_data.get('liquid', [])
|
||||
config_data = G.nodes[node_id].get('config', {})
|
||||
|
||||
# 检查 reagent_name 和 config.reagent
|
||||
|
||||
reagent_name = vessel_data.get('reagent_name', '').lower()
|
||||
config_reagent = config_data.get('reagent', '').lower()
|
||||
|
||||
if (content.lower() == reagent_name or
|
||||
|
||||
if (content.lower() == reagent_name or
|
||||
content.lower() == config_reagent):
|
||||
matching_vessels.append(node_id)
|
||||
continue
|
||||
|
||||
# 检查液体列表
|
||||
|
||||
for liquid in liquids:
|
||||
if isinstance(liquid, dict):
|
||||
liquid_type = liquid.get('liquid_type') or liquid.get('name', '')
|
||||
if liquid_type.lower() == content.lower():
|
||||
matching_vessels.append(node_id)
|
||||
break
|
||||
|
||||
return matching_vessels
|
||||
|
||||
return matching_vessels
|
||||
|
||||
@@ -1,402 +1,19 @@
|
||||
from functools import partial
|
||||
|
||||
import networkx as nx
|
||||
import re
|
||||
import logging
|
||||
from typing import List, Dict, Any, Union
|
||||
|
||||
from .utils.vessel_parser import get_vessel
|
||||
from .utils.logger_util import action_log
|
||||
from .utils.logger_util import debug_print, action_log
|
||||
from .utils.unit_parser import parse_volume_input, parse_mass_input, parse_time_input, parse_temperature_input
|
||||
from .utils.vessel_parser import get_vessel, find_solvent_vessel, find_connected_heatchill, find_connected_stirrer, find_solid_dispenser
|
||||
from .pump_protocol import generate_pump_protocol_with_rinsing
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def debug_print(message):
|
||||
"""调试输出"""
|
||||
logger.info(f"[DISSOLVE] {message}")
|
||||
|
||||
# 🆕 创建进度日志动作
|
||||
# 创建进度日志动作
|
||||
create_action_log = partial(action_log, prefix="[DISSOLVE]")
|
||||
|
||||
def parse_volume_input(volume_input: Union[str, float]) -> float:
|
||||
"""
|
||||
解析体积输入,支持带单位的字符串
|
||||
|
||||
Args:
|
||||
volume_input: 体积输入(如 "10 mL", "?", 10.0)
|
||||
|
||||
Returns:
|
||||
float: 体积(毫升)
|
||||
"""
|
||||
if isinstance(volume_input, (int, float)):
|
||||
debug_print(f"📏 体积输入为数值: {volume_input}")
|
||||
return float(volume_input)
|
||||
|
||||
if not volume_input or not str(volume_input).strip():
|
||||
debug_print(f"⚠️ 体积输入为空,返回0.0mL")
|
||||
return 0.0
|
||||
|
||||
volume_str = str(volume_input).lower().strip()
|
||||
debug_print(f"🔍 解析体积输入: '{volume_str}'")
|
||||
|
||||
# 处理未知体积
|
||||
if volume_str in ['?', 'unknown', 'tbd', 'to be determined']:
|
||||
default_volume = 50.0 # 默认50mL
|
||||
debug_print(f"❓ 检测到未知体积,使用默认值: {default_volume}mL 🎯")
|
||||
return default_volume
|
||||
|
||||
# 移除空格并提取数字和单位
|
||||
volume_clean = re.sub(r'\s+', '', volume_str)
|
||||
|
||||
# 匹配数字和单位的正则表达式
|
||||
match = re.match(r'([0-9]*\.?[0-9]+)\s*(ml|l|μl|ul|microliter|milliliter|liter)?', volume_clean)
|
||||
|
||||
if not match:
|
||||
debug_print(f"❌ 无法解析体积: '{volume_str}',使用默认值50mL")
|
||||
return 50.0
|
||||
|
||||
value = float(match.group(1))
|
||||
unit = match.group(2) or 'ml' # 默认单位为毫升
|
||||
|
||||
# 转换为毫升
|
||||
if unit in ['l', 'liter']:
|
||||
volume = value * 1000.0 # L -> mL
|
||||
debug_print(f"🔄 体积转换: {value}L → {volume}mL")
|
||||
elif unit in ['μl', 'ul', 'microliter']:
|
||||
volume = value / 1000.0 # μL -> mL
|
||||
debug_print(f"🔄 体积转换: {value}μL → {volume}mL")
|
||||
else: # ml, milliliter 或默认
|
||||
volume = value # 已经是mL
|
||||
debug_print(f"✅ 体积已为mL: {volume}mL")
|
||||
|
||||
return volume
|
||||
|
||||
def parse_mass_input(mass_input: Union[str, float]) -> float:
|
||||
"""
|
||||
解析质量输入,支持带单位的字符串
|
||||
|
||||
Args:
|
||||
mass_input: 质量输入(如 "2.9 g", "?", 2.5)
|
||||
|
||||
Returns:
|
||||
float: 质量(克)
|
||||
"""
|
||||
if isinstance(mass_input, (int, float)):
|
||||
debug_print(f"⚖️ 质量输入为数值: {mass_input}g")
|
||||
return float(mass_input)
|
||||
|
||||
if not mass_input or not str(mass_input).strip():
|
||||
debug_print(f"⚠️ 质量输入为空,返回0.0g")
|
||||
return 0.0
|
||||
|
||||
mass_str = str(mass_input).lower().strip()
|
||||
debug_print(f"🔍 解析质量输入: '{mass_str}'")
|
||||
|
||||
# 处理未知质量
|
||||
if mass_str in ['?', 'unknown', 'tbd', 'to be determined']:
|
||||
default_mass = 1.0 # 默认1g
|
||||
debug_print(f"❓ 检测到未知质量,使用默认值: {default_mass}g 🎯")
|
||||
return default_mass
|
||||
|
||||
# 移除空格并提取数字和单位
|
||||
mass_clean = re.sub(r'\s+', '', mass_str)
|
||||
|
||||
# 匹配数字和单位的正则表达式
|
||||
match = re.match(r'([0-9]*\.?[0-9]+)\s*(g|mg|kg|gram|milligram|kilogram)?', mass_clean)
|
||||
|
||||
if not match:
|
||||
debug_print(f"❌ 无法解析质量: '{mass_str}',返回0.0g")
|
||||
return 0.0
|
||||
|
||||
value = float(match.group(1))
|
||||
unit = match.group(2) or 'g' # 默认单位为克
|
||||
|
||||
# 转换为克
|
||||
if unit in ['mg', 'milligram']:
|
||||
mass = value / 1000.0 # mg -> g
|
||||
debug_print(f"🔄 质量转换: {value}mg → {mass}g")
|
||||
elif unit in ['kg', 'kilogram']:
|
||||
mass = value * 1000.0 # kg -> g
|
||||
debug_print(f"🔄 质量转换: {value}kg → {mass}g")
|
||||
else: # g, gram 或默认
|
||||
mass = value # 已经是g
|
||||
debug_print(f"✅ 质量已为g: {mass}g")
|
||||
|
||||
return mass
|
||||
|
||||
def parse_time_input(time_input: Union[str, float]) -> float:
|
||||
"""
|
||||
解析时间输入,支持带单位的字符串
|
||||
|
||||
Args:
|
||||
time_input: 时间输入(如 "30 min", "1 h", "?", 60.0)
|
||||
|
||||
Returns:
|
||||
float: 时间(秒)
|
||||
"""
|
||||
if isinstance(time_input, (int, float)):
|
||||
debug_print(f"⏱️ 时间输入为数值: {time_input}秒")
|
||||
return float(time_input)
|
||||
|
||||
if not time_input or not str(time_input).strip():
|
||||
debug_print(f"⚠️ 时间输入为空,返回0秒")
|
||||
return 0.0
|
||||
|
||||
time_str = str(time_input).lower().strip()
|
||||
debug_print(f"🔍 解析时间输入: '{time_str}'")
|
||||
|
||||
# 处理未知时间
|
||||
if time_str in ['?', 'unknown', 'tbd']:
|
||||
default_time = 600.0 # 默认10分钟
|
||||
debug_print(f"❓ 检测到未知时间,使用默认值: {default_time}s (10分钟) ⏰")
|
||||
return default_time
|
||||
|
||||
# 移除空格并提取数字和单位
|
||||
time_clean = re.sub(r'\s+', '', time_str)
|
||||
|
||||
# 匹配数字和单位的正则表达式
|
||||
match = re.match(r'([0-9]*\.?[0-9]+)\s*(s|sec|second|min|minute|h|hr|hour|d|day)?', time_clean)
|
||||
|
||||
if not match:
|
||||
debug_print(f"❌ 无法解析时间: '{time_str}',返回0s")
|
||||
return 0.0
|
||||
|
||||
value = float(match.group(1))
|
||||
unit = match.group(2) or 's' # 默认单位为秒
|
||||
|
||||
# 转换为秒
|
||||
if unit in ['min', 'minute']:
|
||||
time_sec = value * 60.0 # min -> s
|
||||
debug_print(f"🔄 时间转换: {value}分钟 → {time_sec}秒")
|
||||
elif unit in ['h', 'hr', 'hour']:
|
||||
time_sec = value * 3600.0 # h -> s
|
||||
debug_print(f"🔄 时间转换: {value}小时 → {time_sec}秒")
|
||||
elif unit in ['d', 'day']:
|
||||
time_sec = value * 86400.0 # d -> s
|
||||
debug_print(f"🔄 时间转换: {value}天 → {time_sec}秒")
|
||||
else: # s, sec, second 或默认
|
||||
time_sec = value # 已经是s
|
||||
debug_print(f"✅ 时间已为秒: {time_sec}秒")
|
||||
|
||||
return time_sec
|
||||
|
||||
def parse_temperature_input(temp_input: Union[str, float]) -> float:
|
||||
"""
|
||||
解析温度输入,支持带单位的字符串
|
||||
|
||||
Args:
|
||||
temp_input: 温度输入(如 "60 °C", "room temperature", "?", 25.0)
|
||||
|
||||
Returns:
|
||||
float: 温度(摄氏度)
|
||||
"""
|
||||
if isinstance(temp_input, (int, float)):
|
||||
debug_print(f"🌡️ 温度输入为数值: {temp_input}°C")
|
||||
return float(temp_input)
|
||||
|
||||
if not temp_input or not str(temp_input).strip():
|
||||
debug_print(f"⚠️ 温度输入为空,使用默认室温25°C")
|
||||
return 25.0 # 默认室温
|
||||
|
||||
temp_str = str(temp_input).lower().strip()
|
||||
debug_print(f"🔍 解析温度输入: '{temp_str}'")
|
||||
|
||||
# 处理特殊温度描述
|
||||
temp_aliases = {
|
||||
'room temperature': 25.0,
|
||||
'rt': 25.0,
|
||||
'ambient': 25.0,
|
||||
'cold': 4.0,
|
||||
'ice': 0.0,
|
||||
'reflux': 80.0, # 默认回流温度
|
||||
'?': 25.0,
|
||||
'unknown': 25.0
|
||||
}
|
||||
|
||||
if temp_str in temp_aliases:
|
||||
result = temp_aliases[temp_str]
|
||||
debug_print(f"🏷️ 温度别名解析: '{temp_str}' → {result}°C")
|
||||
return result
|
||||
|
||||
# 移除空格并提取数字和单位
|
||||
temp_clean = re.sub(r'\s+', '', temp_str)
|
||||
|
||||
# 匹配数字和单位的正则表达式
|
||||
match = re.match(r'([0-9]*\.?[0-9]+)\s*(°c|c|celsius|°f|f|fahrenheit|k|kelvin)?', temp_clean)
|
||||
|
||||
if not match:
|
||||
debug_print(f"❌ 无法解析温度: '{temp_str}',使用默认值25°C")
|
||||
return 25.0
|
||||
|
||||
value = float(match.group(1))
|
||||
unit = match.group(2) or 'c' # 默认单位为摄氏度
|
||||
|
||||
# 转换为摄氏度
|
||||
if unit in ['°f', 'f', 'fahrenheit']:
|
||||
temp_c = (value - 32) * 5/9 # F -> C
|
||||
debug_print(f"🔄 温度转换: {value}°F → {temp_c:.1f}°C")
|
||||
elif unit in ['k', 'kelvin']:
|
||||
temp_c = value - 273.15 # K -> C
|
||||
debug_print(f"🔄 温度转换: {value}K → {temp_c:.1f}°C")
|
||||
else: # °c, c, celsius 或默认
|
||||
temp_c = value # 已经是C
|
||||
debug_print(f"✅ 温度已为°C: {temp_c}°C")
|
||||
|
||||
return temp_c
|
||||
|
||||
def find_solvent_vessel(G: nx.DiGraph, solvent: str) -> str:
|
||||
"""增强版溶剂容器查找,支持多种匹配模式"""
|
||||
debug_print(f"🔍 开始查找溶剂 '{solvent}' 的容器...")
|
||||
|
||||
# 🔧 方法1:直接搜索 data.reagent_name 和 config.reagent
|
||||
debug_print(f"📋 方法1: 搜索reagent字段...")
|
||||
for node in G.nodes():
|
||||
node_data = G.nodes[node].get('data', {})
|
||||
node_type = G.nodes[node].get('type', '')
|
||||
config_data = G.nodes[node].get('config', {})
|
||||
|
||||
# 只搜索容器类型的节点
|
||||
if node_type == 'container':
|
||||
reagent_name = node_data.get('reagent_name', '').lower()
|
||||
config_reagent = config_data.get('reagent', '').lower()
|
||||
|
||||
# 精确匹配
|
||||
if reagent_name == solvent.lower() or config_reagent == solvent.lower():
|
||||
debug_print(f"✅ 通过reagent字段精确匹配到容器: {node} 🎯")
|
||||
return node
|
||||
|
||||
# 模糊匹配
|
||||
if (solvent.lower() in reagent_name and reagent_name) or \
|
||||
(solvent.lower() in config_reagent and config_reagent):
|
||||
debug_print(f"✅ 通过reagent字段模糊匹配到容器: {node} 🔍")
|
||||
return node
|
||||
|
||||
# 🔧 方法2:常见的容器命名规则
|
||||
debug_print(f"📋 方法2: 使用命名规则查找...")
|
||||
solvent_clean = solvent.lower().replace(' ', '_').replace('-', '_')
|
||||
possible_names = [
|
||||
solvent_clean,
|
||||
f"flask_{solvent_clean}",
|
||||
f"bottle_{solvent_clean}",
|
||||
f"vessel_{solvent_clean}",
|
||||
f"{solvent_clean}_flask",
|
||||
f"{solvent_clean}_bottle",
|
||||
f"solvent_{solvent_clean}",
|
||||
f"reagent_{solvent_clean}",
|
||||
f"reagent_bottle_{solvent_clean}",
|
||||
f"reagent_bottle_1", # 通用试剂瓶
|
||||
f"reagent_bottle_2",
|
||||
f"reagent_bottle_3"
|
||||
]
|
||||
|
||||
debug_print(f"🔍 尝试的容器名称: {possible_names[:5]}... (共{len(possible_names)}个)")
|
||||
|
||||
for name in possible_names:
|
||||
if name in G.nodes():
|
||||
node_type = G.nodes[name].get('type', '')
|
||||
if node_type == 'container':
|
||||
debug_print(f"✅ 通过命名规则找到容器: {name} 📝")
|
||||
return name
|
||||
|
||||
# 🔧 方法3:节点名称模糊匹配
|
||||
debug_print(f"📋 方法3: 节点名称模糊匹配...")
|
||||
for node_id in G.nodes():
|
||||
node_data = G.nodes[node_id]
|
||||
if node_data.get('type') == 'container':
|
||||
# 检查节点名称是否包含溶剂名称
|
||||
if solvent_clean in node_id.lower():
|
||||
debug_print(f"✅ 通过节点名称模糊匹配到容器: {node_id} 🔍")
|
||||
return node_id
|
||||
|
||||
# 检查液体类型匹配
|
||||
vessel_data = node_data.get('data', {})
|
||||
liquids = vessel_data.get('liquid', [])
|
||||
for liquid in liquids:
|
||||
if isinstance(liquid, dict):
|
||||
liquid_type = liquid.get('liquid_type') or liquid.get('name', '')
|
||||
if liquid_type.lower() == solvent.lower():
|
||||
debug_print(f"✅ 通过液体类型匹配到容器: {node_id} 💧")
|
||||
return node_id
|
||||
|
||||
# 🔧 方法4:使用第一个试剂瓶作为备选
|
||||
debug_print(f"📋 方法4: 查找备选试剂瓶...")
|
||||
for node_id in G.nodes():
|
||||
node_data = G.nodes[node_id]
|
||||
if (node_data.get('type') == 'container' and
|
||||
('reagent' in node_id.lower() or 'bottle' in node_id.lower() or 'flask' in node_id.lower())):
|
||||
debug_print(f"⚠️ 未找到专用容器,使用备选试剂瓶: {node_id} 🔄")
|
||||
return node_id
|
||||
|
||||
debug_print(f"❌ 所有方法都失败了,无法找到容器!")
|
||||
raise ValueError(f"找不到溶剂 '{solvent}' 对应的容器")
|
||||
|
||||
def find_connected_heatchill(G: nx.DiGraph, vessel: str) -> str:
|
||||
"""查找连接到指定容器的加热搅拌器"""
|
||||
debug_print(f"🔍 查找连接到容器 '{vessel}' 的加热搅拌器...")
|
||||
|
||||
heatchill_nodes = []
|
||||
for node in G.nodes():
|
||||
node_class = G.nodes[node].get('class', '').lower()
|
||||
if 'heatchill' in node_class:
|
||||
heatchill_nodes.append(node)
|
||||
debug_print(f"📋 发现加热搅拌器: {node}")
|
||||
|
||||
debug_print(f"📊 共找到 {len(heatchill_nodes)} 个加热搅拌器")
|
||||
|
||||
# 查找连接到容器的加热器
|
||||
for heatchill in heatchill_nodes:
|
||||
if G.has_edge(heatchill, vessel) or G.has_edge(vessel, heatchill):
|
||||
debug_print(f"✅ 找到连接的加热搅拌器: {heatchill} 🔗")
|
||||
return heatchill
|
||||
|
||||
# 返回第一个加热器
|
||||
if heatchill_nodes:
|
||||
debug_print(f"⚠️ 未找到直接连接的加热搅拌器,使用第一个: {heatchill_nodes[0]} 🔄")
|
||||
return heatchill_nodes[0]
|
||||
|
||||
debug_print(f"❌ 未找到任何加热搅拌器")
|
||||
return ""
|
||||
|
||||
def find_connected_stirrer(G: nx.DiGraph, vessel: str) -> str:
|
||||
"""查找连接到指定容器的搅拌器"""
|
||||
debug_print(f"🔍 查找连接到容器 '{vessel}' 的搅拌器...")
|
||||
|
||||
stirrer_nodes = []
|
||||
for node in G.nodes():
|
||||
node_class = G.nodes[node].get('class', '').lower()
|
||||
if 'stirrer' in node_class:
|
||||
stirrer_nodes.append(node)
|
||||
debug_print(f"📋 发现搅拌器: {node}")
|
||||
|
||||
debug_print(f"📊 共找到 {len(stirrer_nodes)} 个搅拌器")
|
||||
|
||||
# 查找连接到容器的搅拌器
|
||||
for stirrer in stirrer_nodes:
|
||||
if G.has_edge(stirrer, vessel) or G.has_edge(vessel, stirrer):
|
||||
debug_print(f"✅ 找到连接的搅拌器: {stirrer} 🔗")
|
||||
return stirrer
|
||||
|
||||
# 返回第一个搅拌器
|
||||
if stirrer_nodes:
|
||||
debug_print(f"⚠️ 未找到直接连接的搅拌器,使用第一个: {stirrer_nodes[0]} 🔄")
|
||||
return stirrer_nodes[0]
|
||||
|
||||
debug_print(f"❌ 未找到任何搅拌器")
|
||||
return ""
|
||||
|
||||
def find_solid_dispenser(G: nx.DiGraph) -> str:
|
||||
"""查找固体加样器"""
|
||||
debug_print(f"🔍 查找固体加样器...")
|
||||
|
||||
for node in G.nodes():
|
||||
node_class = G.nodes[node].get('class', '').lower()
|
||||
if 'solid_dispenser' in node_class or 'dispenser' in node_class:
|
||||
debug_print(f"✅ 找到固体加样器: {node} 🥄")
|
||||
return node
|
||||
|
||||
debug_print(f"❌ 未找到固体加样器")
|
||||
return ""
|
||||
|
||||
def generate_dissolve_protocol(
|
||||
G: nx.DiGraph,
|
||||
vessel: dict, # 🔧 修改:从字符串改为字典类型
|
||||
@@ -436,43 +53,21 @@ def generate_dissolve_protocol(
|
||||
- mol: "0.12 mol", "16.2 mmol"
|
||||
"""
|
||||
|
||||
# 🔧 核心修改:从字典中提取容器ID
|
||||
# 从字典中提取容器ID
|
||||
vessel_id, vessel_data = get_vessel(vessel)
|
||||
|
||||
debug_print("=" * 60)
|
||||
debug_print("🧪 开始生成溶解协议")
|
||||
debug_print(f"📋 原始参数:")
|
||||
debug_print(f" 🥼 vessel: {vessel} (ID: {vessel_id})")
|
||||
debug_print(f" 💧 solvent: '{solvent}'")
|
||||
debug_print(f" 📏 volume: {volume} (类型: {type(volume)})")
|
||||
debug_print(f" ⚖️ mass: {mass} (类型: {type(mass)})")
|
||||
debug_print(f" 🌡️ temp: {temp} (类型: {type(temp)})")
|
||||
debug_print(f" ⏱️ time: {time} (类型: {type(time)})")
|
||||
debug_print(f" 🧪 reagent: '{reagent}'")
|
||||
debug_print(f" 🧬 mol: '{mol}'")
|
||||
debug_print(f" 🎯 event: '{event}'")
|
||||
debug_print(f" 📦 kwargs: {kwargs}") # 显示额外参数
|
||||
debug_print("=" * 60)
|
||||
|
||||
|
||||
debug_print(f"溶解协议: vessel={vessel_id}, solvent='{solvent}', volume={volume}, "
|
||||
f"mass={mass}, temp={temp}, time={time}")
|
||||
|
||||
action_sequence = []
|
||||
|
||||
# === 参数验证 ===
|
||||
debug_print("🔍 步骤1: 参数验证...")
|
||||
action_sequence.append(create_action_log(f"开始溶解操作 - 容器: {vessel_id}", "🎬"))
|
||||
|
||||
|
||||
if not vessel_id:
|
||||
debug_print("❌ vessel 参数不能为空")
|
||||
raise ValueError("vessel 参数不能为空")
|
||||
|
||||
if vessel_id not in G.nodes():
|
||||
debug_print(f"❌ 容器 '{vessel_id}' 不存在于系统中")
|
||||
raise ValueError(f"容器 '{vessel_id}' 不存在于系统中")
|
||||
|
||||
debug_print("✅ 基本参数验证通过")
|
||||
action_sequence.append(create_action_log("参数验证通过", "✅"))
|
||||
|
||||
# 🔧 新增:记录溶解前的容器状态
|
||||
debug_print("🔍 记录溶解前容器状态...")
|
||||
|
||||
# 记录溶解前的容器状态
|
||||
original_liquid_volume = 0.0
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
@@ -480,30 +75,16 @@ def generate_dissolve_protocol(
|
||||
original_liquid_volume = current_volume[0]
|
||||
elif isinstance(current_volume, (int, float)):
|
||||
original_liquid_volume = current_volume
|
||||
debug_print(f"📊 溶解前液体体积: {original_liquid_volume:.2f}mL")
|
||||
|
||||
# === 🔧 关键修复:参数解析 ===
|
||||
debug_print("🔍 步骤2: 参数解析...")
|
||||
action_sequence.append(create_action_log("正在解析溶解参数...", "🔍"))
|
||||
|
||||
# 解析各种参数为数值
|
||||
|
||||
# === 参数解析 ===
|
||||
final_volume = parse_volume_input(volume)
|
||||
final_mass = parse_mass_input(mass)
|
||||
final_temp = parse_temperature_input(temp)
|
||||
final_time = parse_time_input(time)
|
||||
|
||||
debug_print(f"📊 解析结果:")
|
||||
debug_print(f" 📏 体积: {final_volume}mL")
|
||||
debug_print(f" ⚖️ 质量: {final_mass}g")
|
||||
debug_print(f" 🌡️ 温度: {final_temp}°C")
|
||||
debug_print(f" ⏱️ 时间: {final_time}s")
|
||||
debug_print(f" 🧪 试剂: '{reagent}'")
|
||||
debug_print(f" 🧬 摩尔: '{mol}'")
|
||||
debug_print(f" 🎯 事件: '{event}'")
|
||||
|
||||
|
||||
debug_print(f"参数解析: vol={final_volume}mL, mass={final_mass}g, temp={final_temp}°C, time={final_time}s")
|
||||
|
||||
# === 判断溶解类型 ===
|
||||
debug_print("🔍 步骤3: 判断溶解类型...")
|
||||
action_sequence.append(create_action_log("正在判断溶解类型...", "🔍"))
|
||||
|
||||
# 判断是固体溶解还是液体溶解
|
||||
is_solid_dissolve = (final_mass > 0 or (mol and mol.strip() != "") or (reagent and reagent.strip() != ""))
|
||||
@@ -515,49 +96,31 @@ def generate_dissolve_protocol(
|
||||
final_volume = 50.0
|
||||
if not solvent:
|
||||
solvent = "water" # 默认溶剂
|
||||
debug_print("⚠️ 未明确指定溶解参数,默认为50mL水溶解")
|
||||
debug_print("未明确指定溶解参数,默认为50mL水溶解")
|
||||
|
||||
dissolve_type = "固体溶解" if is_solid_dissolve else "液体溶解"
|
||||
dissolve_emoji = "🧂" if is_solid_dissolve else "💧"
|
||||
debug_print(f"📋 溶解类型: {dissolve_type} {dissolve_emoji}")
|
||||
|
||||
action_sequence.append(create_action_log(f"确定溶解类型: {dissolve_type} {dissolve_emoji}", "📋"))
|
||||
|
||||
debug_print(f"溶解类型: {dissolve_type}")
|
||||
|
||||
action_sequence.append(create_action_log(f"溶解类型: {dissolve_type}", "📋"))
|
||||
|
||||
# === 查找设备 ===
|
||||
debug_print("🔍 步骤4: 查找设备...")
|
||||
action_sequence.append(create_action_log("正在查找相关设备...", "🔍"))
|
||||
|
||||
# 查找加热搅拌器
|
||||
heatchill_id = find_connected_heatchill(G, vessel_id)
|
||||
stirrer_id = find_connected_stirrer(G, vessel_id)
|
||||
|
||||
# 优先使用加热搅拌器,否则使用独立搅拌器
|
||||
stir_device_id = heatchill_id or stirrer_id
|
||||
|
||||
debug_print(f"📊 设备映射:")
|
||||
debug_print(f" 🔥 加热器: '{heatchill_id}'")
|
||||
debug_print(f" 🌪️ 搅拌器: '{stirrer_id}'")
|
||||
debug_print(f" 🎯 使用设备: '{stir_device_id}'")
|
||||
|
||||
if heatchill_id:
|
||||
action_sequence.append(create_action_log(f"找到加热搅拌器: {heatchill_id}", "🔥"))
|
||||
elif stirrer_id:
|
||||
action_sequence.append(create_action_log(f"找到搅拌器: {stirrer_id}", "🌪️"))
|
||||
else:
|
||||
debug_print(f"设备: heatchill='{heatchill_id}', stirrer='{stirrer_id}'")
|
||||
|
||||
if not stir_device_id:
|
||||
action_sequence.append(create_action_log("未找到搅拌设备,将跳过搅拌", "⚠️"))
|
||||
|
||||
# === 执行溶解流程 ===
|
||||
debug_print("🔍 步骤5: 执行溶解流程...")
|
||||
|
||||
try:
|
||||
# 步骤5.1: 启动加热搅拌(如果需要)
|
||||
# 启动加热搅拌(如果需要)
|
||||
if stir_device_id and (final_temp > 25.0 or final_time > 0 or stir_speed > 0):
|
||||
debug_print(f"🔍 5.1: 启动加热搅拌,温度: {final_temp}°C")
|
||||
action_sequence.append(create_action_log(f"准备加热搅拌 (目标温度: {final_temp}°C)", "🔥"))
|
||||
|
||||
|
||||
if heatchill_id and (final_temp > 25.0 or final_time > 0):
|
||||
# 使用加热搅拌器
|
||||
action_sequence.append(create_action_log(f"启动加热搅拌器 {heatchill_id}", "🔥"))
|
||||
|
||||
heatchill_action = {
|
||||
"device_id": heatchill_id,
|
||||
@@ -573,7 +136,6 @@ def generate_dissolve_protocol(
|
||||
# 等待温度稳定
|
||||
if final_temp > 25.0:
|
||||
wait_time = min(60, abs(final_temp - 25.0) * 1.5)
|
||||
action_sequence.append(create_action_log(f"等待温度稳定 ({wait_time:.0f}秒)", "⏳"))
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": wait_time}
|
||||
@@ -581,7 +143,6 @@ def generate_dissolve_protocol(
|
||||
|
||||
elif stirrer_id:
|
||||
# 使用独立搅拌器
|
||||
action_sequence.append(create_action_log(f"启动搅拌器 {stirrer_id} (速度: {stir_speed}rpm)", "🌪️"))
|
||||
|
||||
stir_action = {
|
||||
"device_id": stirrer_id,
|
||||
@@ -593,9 +154,8 @@ def generate_dissolve_protocol(
|
||||
}
|
||||
}
|
||||
action_sequence.append(stir_action)
|
||||
|
||||
|
||||
# 等待搅拌稳定
|
||||
action_sequence.append(create_action_log("等待搅拌稳定...", "⏳"))
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": 5}
|
||||
@@ -603,12 +163,8 @@ def generate_dissolve_protocol(
|
||||
|
||||
if is_solid_dissolve:
|
||||
# === 固体溶解路径 ===
|
||||
debug_print(f"🔍 5.2: 使用固体溶解路径")
|
||||
action_sequence.append(create_action_log("开始固体溶解流程", "🧂"))
|
||||
|
||||
solid_dispenser = find_solid_dispenser(G)
|
||||
if solid_dispenser:
|
||||
action_sequence.append(create_action_log(f"找到固体加样器: {solid_dispenser}", "🥄"))
|
||||
|
||||
# 固体加样
|
||||
add_kwargs = {
|
||||
@@ -620,42 +176,27 @@ def generate_dissolve_protocol(
|
||||
|
||||
if final_mass > 0:
|
||||
add_kwargs["mass"] = str(final_mass)
|
||||
action_sequence.append(create_action_log(f"准备添加固体: {final_mass}g", "⚖️"))
|
||||
if mol and mol.strip():
|
||||
add_kwargs["mol"] = mol
|
||||
action_sequence.append(create_action_log(f"按摩尔数添加: {mol}", "🧬"))
|
||||
|
||||
action_sequence.append(create_action_log("开始固体加样操作", "🥄"))
|
||||
|
||||
action_sequence.append({
|
||||
"device_id": solid_dispenser,
|
||||
"action_name": "add_solid",
|
||||
"action_kwargs": add_kwargs
|
||||
})
|
||||
|
||||
debug_print(f"✅ 固体加样完成")
|
||||
action_sequence.append(create_action_log("固体加样完成", "✅"))
|
||||
|
||||
# 🔧 新增:固体溶解体积运算 - 固体本身不会显著增加体积,但可能有少量变化
|
||||
debug_print(f"🔧 固体溶解 - 体积变化很小,主要是质量变化")
|
||||
# 固体通常不会显著改变液体体积,这里只记录日志
|
||||
action_sequence.append(create_action_log(f"固体已添加: {final_mass}g", "📊"))
|
||||
|
||||
# 固体溶解体积运算 - 固体本身不会显著增加体积
|
||||
|
||||
else:
|
||||
debug_print("⚠️ 未找到固体加样器,跳过固体添加")
|
||||
debug_print("未找到固体加样器,跳过固体添加")
|
||||
action_sequence.append(create_action_log("未找到固体加样器,无法添加固体", "❌"))
|
||||
|
||||
elif is_liquid_dissolve:
|
||||
# === 液体溶解路径 ===
|
||||
debug_print(f"🔍 5.3: 使用液体溶解路径")
|
||||
action_sequence.append(create_action_log("开始液体溶解流程", "💧"))
|
||||
|
||||
# 查找溶剂容器
|
||||
action_sequence.append(create_action_log("正在查找溶剂容器...", "🔍"))
|
||||
try:
|
||||
solvent_vessel = find_solvent_vessel(G, solvent)
|
||||
action_sequence.append(create_action_log(f"找到溶剂容器: {solvent_vessel}", "🧪"))
|
||||
except ValueError as e:
|
||||
debug_print(f"⚠️ {str(e)},跳过溶剂添加")
|
||||
debug_print(f"溶剂容器查找失败: {str(e)},跳过溶剂添加")
|
||||
action_sequence.append(create_action_log(f"溶剂容器查找失败: {str(e)}", "❌"))
|
||||
solvent_vessel = None
|
||||
|
||||
@@ -663,10 +204,7 @@ def generate_dissolve_protocol(
|
||||
# 计算流速 - 溶解时通常用较慢的速度,避免飞溅
|
||||
flowrate = 1.0 # 较慢的注入速度
|
||||
transfer_flowrate = 0.5 # 较慢的转移速度
|
||||
|
||||
action_sequence.append(create_action_log(f"设置流速: {flowrate}mL/min (缓慢注入)", "⚡"))
|
||||
action_sequence.append(create_action_log(f"开始转移 {final_volume}mL {solvent}", "🚰"))
|
||||
|
||||
|
||||
# 调用pump protocol
|
||||
pump_actions = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
@@ -688,12 +226,9 @@ def generate_dissolve_protocol(
|
||||
**kwargs
|
||||
)
|
||||
action_sequence.extend(pump_actions)
|
||||
debug_print(f"✅ 溶剂转移完成,添加了 {len(pump_actions)} 个动作")
|
||||
action_sequence.append(create_action_log(f"溶剂转移完成 ({len(pump_actions)} 个操作)", "✅"))
|
||||
|
||||
# 🔧 新增:液体溶解体积运算 - 添加溶剂后更新容器体积
|
||||
debug_print(f"🔧 更新容器液体体积 - 添加溶剂 {final_volume:.2f}mL")
|
||||
|
||||
|
||||
# 液体溶解体积运算 - 添加溶剂后更新容器体积
|
||||
|
||||
# 确保vessel有data字段
|
||||
if "data" not in vessel:
|
||||
vessel["data"] = {}
|
||||
@@ -703,19 +238,14 @@ def generate_dissolve_protocol(
|
||||
if isinstance(current_volume, list):
|
||||
if len(current_volume) > 0:
|
||||
vessel["data"]["liquid_volume"][0] += final_volume
|
||||
debug_print(f"📊 添加溶剂后体积: {vessel['data']['liquid_volume'][0]:.2f}mL (+{final_volume:.2f}mL)")
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = [final_volume]
|
||||
debug_print(f"📊 初始化溶解体积: {final_volume:.2f}mL")
|
||||
elif isinstance(current_volume, (int, float)):
|
||||
vessel["data"]["liquid_volume"] += final_volume
|
||||
debug_print(f"📊 添加溶剂后体积: {vessel['data']['liquid_volume']:.2f}mL (+{final_volume:.2f}mL)")
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = final_volume
|
||||
debug_print(f"📊 重置体积为: {final_volume:.2f}mL")
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = final_volume
|
||||
debug_print(f"📊 创建新体积记录: {final_volume:.2f}mL")
|
||||
|
||||
# 🔧 同时更新图中的容器数据
|
||||
if vessel_id in G.nodes():
|
||||
@@ -732,27 +262,19 @@ def generate_dissolve_protocol(
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = [final_volume]
|
||||
else:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = current_node_volume + final_volume
|
||||
|
||||
debug_print(f"✅ 图节点体积数据已更新")
|
||||
|
||||
action_sequence.append(create_action_log(f"容器体积已更新 (+{final_volume:.2f}mL)", "📊"))
|
||||
|
||||
|
||||
# 溶剂添加后等待
|
||||
action_sequence.append(create_action_log("溶剂添加后短暂等待...", "⏳"))
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": 5}
|
||||
})
|
||||
|
||||
# 步骤5.4: 等待溶解完成
|
||||
# 等待溶解完成
|
||||
if final_time > 0:
|
||||
debug_print(f"🔍 5.4: 等待溶解完成 - {final_time}s")
|
||||
wait_minutes = final_time / 60
|
||||
action_sequence.append(create_action_log(f"开始溶解等待 ({wait_minutes:.1f}分钟)", "⏰"))
|
||||
|
||||
|
||||
if heatchill_id:
|
||||
# 使用定时加热搅拌
|
||||
action_sequence.append(create_action_log(f"使用加热搅拌器进行定时溶解", "🔥"))
|
||||
|
||||
dissolve_action = {
|
||||
"device_id": heatchill_id,
|
||||
@@ -770,7 +292,6 @@ def generate_dissolve_protocol(
|
||||
|
||||
elif stirrer_id:
|
||||
# 使用定时搅拌
|
||||
action_sequence.append(create_action_log(f"使用搅拌器进行定时溶解", "🌪️"))
|
||||
|
||||
stir_action = {
|
||||
"device_id": stirrer_id,
|
||||
@@ -787,7 +308,6 @@ def generate_dissolve_protocol(
|
||||
|
||||
else:
|
||||
# 简单等待
|
||||
action_sequence.append(create_action_log(f"简单等待溶解完成", "⏳"))
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": final_time}
|
||||
@@ -795,9 +315,7 @@ def generate_dissolve_protocol(
|
||||
|
||||
# 步骤5.5: 停止加热搅拌(如果需要)
|
||||
if heatchill_id and final_time == 0 and final_temp > 25.0:
|
||||
debug_print(f"🔍 5.5: 停止加热器")
|
||||
action_sequence.append(create_action_log("停止加热搅拌器", "🛑"))
|
||||
|
||||
|
||||
stop_action = {
|
||||
"device_id": heatchill_id,
|
||||
"action_name": "heat_chill_stop",
|
||||
@@ -808,7 +326,7 @@ def generate_dissolve_protocol(
|
||||
action_sequence.append(stop_action)
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"❌ 溶解流程执行失败: {str(e)}")
|
||||
debug_print(f"溶解流程执行失败: {str(e)}")
|
||||
action_sequence.append(create_action_log(f"溶解流程失败: {str(e)}", "❌"))
|
||||
# 添加错误日志
|
||||
action_sequence.append({
|
||||
@@ -829,23 +347,8 @@ def generate_dissolve_protocol(
|
||||
final_liquid_volume = current_volume
|
||||
|
||||
# === 最终结果 ===
|
||||
debug_print("=" * 60)
|
||||
debug_print(f"🎉 溶解协议生成完成")
|
||||
debug_print(f"📊 协议统计:")
|
||||
debug_print(f" 📋 总动作数: {len(action_sequence)}")
|
||||
debug_print(f" 🥼 容器: {vessel_id}")
|
||||
debug_print(f" {dissolve_emoji} 溶解类型: {dissolve_type}")
|
||||
if is_liquid_dissolve:
|
||||
debug_print(f" 💧 溶剂: {solvent} ({final_volume}mL)")
|
||||
if is_solid_dissolve:
|
||||
debug_print(f" 🧪 试剂: {reagent}")
|
||||
debug_print(f" ⚖️ 质量: {final_mass}g")
|
||||
debug_print(f" 🧬 摩尔: {mol}")
|
||||
debug_print(f" 🌡️ 温度: {final_temp}°C")
|
||||
debug_print(f" ⏱️ 时间: {final_time}s")
|
||||
debug_print(f" 📊 溶解前体积: {original_liquid_volume:.2f}mL")
|
||||
debug_print(f" 📊 溶解后体积: {final_liquid_volume:.2f}mL")
|
||||
debug_print("=" * 60)
|
||||
debug_print(f"溶解协议完成: {vessel_id}, 类型={dissolve_type}, "
|
||||
f"动作数={len(action_sequence)}, 体积={original_liquid_volume:.2f}→{final_liquid_volume:.2f}mL")
|
||||
|
||||
# 添加完成日志
|
||||
summary_msg = f"溶解协议完成: {vessel_id}"
|
||||
@@ -854,7 +357,7 @@ def generate_dissolve_protocol(
|
||||
if is_solid_dissolve:
|
||||
summary_msg += f" (溶解 {final_mass}g {reagent})"
|
||||
|
||||
action_sequence.append(create_action_log(summary_msg, "🎉"))
|
||||
action_sequence.append(create_action_log(summary_msg, "✅"))
|
||||
|
||||
return action_sequence
|
||||
|
||||
@@ -866,7 +369,7 @@ def dissolve_solid_by_mass(G: nx.DiGraph, vessel: dict, reagent: str, mass: Unio
|
||||
temp: Union[str, float] = 25.0, time: Union[str, float] = "10 min") -> List[Dict[str, Any]]:
|
||||
"""按质量溶解固体"""
|
||||
vessel_id = vessel["id"]
|
||||
debug_print(f"🧂 快速固体溶解: {reagent} ({mass}) → {vessel_id}")
|
||||
debug_print(f"快速固体溶解: {reagent} ({mass}) → {vessel_id}")
|
||||
return generate_dissolve_protocol(
|
||||
G, vessel,
|
||||
mass=mass,
|
||||
@@ -879,7 +382,7 @@ def dissolve_solid_by_moles(G: nx.DiGraph, vessel: dict, reagent: str, mol: str,
|
||||
temp: Union[str, float] = 25.0, time: Union[str, float] = "10 min") -> List[Dict[str, Any]]:
|
||||
"""按摩尔数溶解固体"""
|
||||
vessel_id = vessel["id"]
|
||||
debug_print(f"🧬 按摩尔数溶解固体: {reagent} ({mol}) → {vessel_id}")
|
||||
debug_print(f"按摩尔数溶解固体: {reagent} ({mol}) → {vessel_id}")
|
||||
return generate_dissolve_protocol(
|
||||
G, vessel,
|
||||
mol=mol,
|
||||
@@ -892,7 +395,7 @@ def dissolve_with_solvent(G: nx.DiGraph, vessel: dict, solvent: str, volume: Uni
|
||||
temp: Union[str, float] = 25.0, time: Union[str, float] = "5 min") -> List[Dict[str, Any]]:
|
||||
"""用溶剂溶解"""
|
||||
vessel_id = vessel["id"]
|
||||
debug_print(f"💧 溶剂溶解: {solvent} ({volume}) → {vessel_id}")
|
||||
debug_print(f"溶剂溶解: {solvent} ({volume}) → {vessel_id}")
|
||||
return generate_dissolve_protocol(
|
||||
G, vessel,
|
||||
solvent=solvent,
|
||||
@@ -904,7 +407,7 @@ def dissolve_with_solvent(G: nx.DiGraph, vessel: dict, solvent: str, volume: Uni
|
||||
def dissolve_at_room_temp(G: nx.DiGraph, vessel: dict, solvent: str, volume: Union[str, float]) -> List[Dict[str, Any]]:
|
||||
"""室温溶解"""
|
||||
vessel_id = vessel["id"]
|
||||
debug_print(f"🌡️ 室温溶解: {solvent} ({volume}) → {vessel_id}")
|
||||
debug_print(f"室温溶解: {solvent} ({volume}) → {vessel_id}")
|
||||
return generate_dissolve_protocol(
|
||||
G, vessel,
|
||||
solvent=solvent,
|
||||
@@ -917,7 +420,7 @@ def dissolve_with_heating(G: nx.DiGraph, vessel: dict, solvent: str, volume: Uni
|
||||
temp: Union[str, float] = "60 °C", time: Union[str, float] = "15 min") -> List[Dict[str, Any]]:
|
||||
"""加热溶解"""
|
||||
vessel_id = vessel["id"]
|
||||
debug_print(f"🔥 加热溶解: {solvent} ({volume}) → {vessel_id} @ {temp}")
|
||||
debug_print(f"加热溶解: {solvent} ({volume}) → {vessel_id} @ {temp}")
|
||||
return generate_dissolve_protocol(
|
||||
G, vessel,
|
||||
solvent=solvent,
|
||||
@@ -929,37 +432,31 @@ def dissolve_with_heating(G: nx.DiGraph, vessel: dict, solvent: str, volume: Uni
|
||||
# 测试函数
|
||||
def test_dissolve_protocol():
|
||||
"""测试溶解协议的各种参数解析"""
|
||||
debug_print("=== DISSOLVE PROTOCOL 增强版测试 ===")
|
||||
|
||||
# 测试体积解析
|
||||
debug_print("💧 测试体积解析...")
|
||||
volumes = ["10 mL", "?", 10.0, "1 L", "500 μL"]
|
||||
for vol in volumes:
|
||||
result = parse_volume_input(vol)
|
||||
debug_print(f"📏 体积解析: {vol} → {result}mL")
|
||||
|
||||
debug_print(f"体积解析: {vol} → {result}mL")
|
||||
|
||||
# 测试质量解析
|
||||
debug_print("⚖️ 测试质量解析...")
|
||||
masses = ["2.9 g", "?", 2.5, "500 mg"]
|
||||
for mass in masses:
|
||||
result = parse_mass_input(mass)
|
||||
debug_print(f"⚖️ 质量解析: {mass} → {result}g")
|
||||
|
||||
debug_print(f"质量解析: {mass} → {result}g")
|
||||
|
||||
# 测试温度解析
|
||||
debug_print("🌡️ 测试温度解析...")
|
||||
temps = ["60 °C", "room temperature", "?", 25.0, "reflux"]
|
||||
for temp in temps:
|
||||
result = parse_temperature_input(temp)
|
||||
debug_print(f"🌡️ 温度解析: {temp} → {result}°C")
|
||||
|
||||
debug_print(f"温度解析: {temp} → {result}°C")
|
||||
|
||||
# 测试时间解析
|
||||
debug_print("⏱️ 测试时间解析...")
|
||||
times = ["30 min", "1 h", "?", 60.0]
|
||||
for time in times:
|
||||
result = parse_time_input(time)
|
||||
debug_print(f"⏱️ 时间解析: {time} → {result}s")
|
||||
|
||||
debug_print("✅ 测试完成")
|
||||
debug_print(f"时间解析: {time} → {result}s")
|
||||
|
||||
debug_print("测试完成")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_dissolve_protocol()
|
||||
@@ -1,87 +1,40 @@
|
||||
import networkx as nx
|
||||
from typing import List, Dict, Any
|
||||
|
||||
from unilabos.compile.utils.vessel_parser import get_vessel
|
||||
|
||||
|
||||
def find_connected_heater(G: nx.DiGraph, vessel: str) -> str:
|
||||
"""
|
||||
查找与容器相连的加热器
|
||||
|
||||
Args:
|
||||
G: 网络图
|
||||
vessel: 容器名称
|
||||
|
||||
Returns:
|
||||
str: 加热器ID,如果没有则返回None
|
||||
"""
|
||||
print(f"DRY: 正在查找与容器 '{vessel}' 相连的加热器...")
|
||||
|
||||
# 查找所有加热器节点
|
||||
heater_nodes = [node for node in G.nodes()
|
||||
if ('heater' in node.lower() or
|
||||
'heat' in node.lower() or
|
||||
G.nodes[node].get('class') == 'virtual_heatchill' or
|
||||
G.nodes[node].get('type') == 'heater')]
|
||||
|
||||
print(f"DRY: 找到的加热器节点: {heater_nodes}")
|
||||
|
||||
# 检查是否有加热器与目标容器相连
|
||||
for heater in heater_nodes:
|
||||
if G.has_edge(heater, vessel) or G.has_edge(vessel, heater):
|
||||
print(f"DRY: 找到与容器 '{vessel}' 相连的加热器: {heater}")
|
||||
return heater
|
||||
|
||||
# 如果没有直接连接,查找距离最近的加热器
|
||||
for heater in heater_nodes:
|
||||
try:
|
||||
path = nx.shortest_path(G, source=heater, target=vessel)
|
||||
if len(path) <= 3: # 最多2个中间节点
|
||||
print(f"DRY: 找到距离较近的加热器: {heater}, 路径: {' → '.join(path)}")
|
||||
return heater
|
||||
except nx.NetworkXNoPath:
|
||||
continue
|
||||
|
||||
print(f"DRY: 未找到与容器 '{vessel}' 相连的加热器")
|
||||
return None
|
||||
from .utils.vessel_parser import get_vessel, find_connected_heatchill
|
||||
from .utils.logger_util import debug_print
|
||||
|
||||
|
||||
def generate_dry_protocol(
|
||||
G: nx.DiGraph,
|
||||
vessel: dict, # 🔧 修改:从字符串改为字典类型
|
||||
compound: str = "", # 🔧 修改:参数顺序调整,并设置默认值
|
||||
**kwargs # 接收其他可能的参数但不使用
|
||||
vessel: dict,
|
||||
compound: str = "",
|
||||
**kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
生成干燥协议序列
|
||||
|
||||
|
||||
Args:
|
||||
G: 有向图,节点为容器和设备
|
||||
vessel: 目标容器字典(从XDL传入)
|
||||
compound: 化合物名称(从XDL传入,可选)
|
||||
**kwargs: 其他可选参数,但不使用
|
||||
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 动作序列
|
||||
"""
|
||||
# 🔧 核心修改:从字典中提取容器ID
|
||||
vessel_id, vessel_data = get_vessel(vessel)
|
||||
|
||||
|
||||
action_sequence = []
|
||||
|
||||
|
||||
# 默认参数
|
||||
dry_temp = 60.0 # 默认干燥温度 60°C
|
||||
dry_time = 3600.0 # 默认干燥时间 1小时(3600秒)
|
||||
simulation_time = 60.0 # 模拟时间 1分钟
|
||||
|
||||
print(f"🌡️ DRY: 开始生成干燥协议 ✨")
|
||||
print(f" 🥽 vessel: {vessel} (ID: {vessel_id})")
|
||||
print(f" 🧪 化合物: {compound or '未指定'}")
|
||||
print(f" 🔥 干燥温度: {dry_temp}°C")
|
||||
print(f" ⏰ 干燥时间: {dry_time/60:.0f} 分钟")
|
||||
|
||||
# 🔧 新增:记录干燥前的容器状态
|
||||
print(f"🔍 记录干燥前容器状态...")
|
||||
dry_temp = 60.0
|
||||
dry_time = 3600.0
|
||||
simulation_time = 60.0
|
||||
|
||||
debug_print(f"开始生成干燥协议: vessel={vessel_id}, compound={compound or '未指定'}, temp={dry_temp}°C")
|
||||
|
||||
# 记录干燥前的容器状态
|
||||
original_liquid_volume = 0.0
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
@@ -89,39 +42,30 @@ def generate_dry_protocol(
|
||||
original_liquid_volume = current_volume[0]
|
||||
elif isinstance(current_volume, (int, float)):
|
||||
original_liquid_volume = current_volume
|
||||
print(f"📊 干燥前液体体积: {original_liquid_volume:.2f}mL")
|
||||
|
||||
|
||||
# 1. 验证目标容器存在
|
||||
print(f"\n📋 步骤1: 验证目标容器 '{vessel_id}' 是否存在...")
|
||||
if vessel_id not in G.nodes():
|
||||
print(f"⚠️ DRY: 警告 - 容器 '{vessel_id}' 不存在于系统中,跳过干燥 😢")
|
||||
debug_print(f"容器 '{vessel_id}' 不存在于系统中,跳过干燥")
|
||||
return action_sequence
|
||||
print(f"✅ 容器 '{vessel_id}' 验证通过!")
|
||||
|
||||
|
||||
# 2. 查找相连的加热器
|
||||
print(f"\n🔍 步骤2: 查找与容器相连的加热器...")
|
||||
heater_id = find_connected_heater(G, vessel_id) # 🔧 使用 vessel_id
|
||||
|
||||
heater_id = find_connected_heatchill(G, vessel_id)
|
||||
|
||||
if heater_id is None:
|
||||
print(f"😭 DRY: 警告 - 未找到与容器 '{vessel_id}' 相连的加热器,跳过干燥")
|
||||
print(f"🎭 添加模拟干燥动作...")
|
||||
# 添加一个等待动作,表示干燥过程(模拟)
|
||||
debug_print(f"未找到与容器 '{vessel_id}' 相连的加热器,添加模拟干燥动作")
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {
|
||||
"time": 10.0, # 模拟等待时间
|
||||
"time": 10.0,
|
||||
"description": f"模拟干燥 {compound or '化合物'} (无加热器可用)"
|
||||
}
|
||||
})
|
||||
|
||||
# 🔧 新增:模拟干燥的体积变化(溶剂蒸发)
|
||||
print(f"🔧 模拟干燥过程的体积减少...")
|
||||
|
||||
# 模拟干燥的体积变化
|
||||
if original_liquid_volume > 0:
|
||||
# 假设干燥过程中损失10%的体积(溶剂蒸发)
|
||||
volume_loss = original_liquid_volume * 0.1
|
||||
new_volume = max(0.0, original_liquid_volume - volume_loss)
|
||||
|
||||
# 更新vessel字典中的体积
|
||||
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
if isinstance(current_volume, list):
|
||||
@@ -133,15 +77,14 @@ def generate_dry_protocol(
|
||||
vessel["data"]["liquid_volume"] = new_volume
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = new_volume
|
||||
|
||||
# 🔧 同时更新图中的容器数据
|
||||
|
||||
if vessel_id in G.nodes():
|
||||
if 'data' not in G.nodes[vessel_id]:
|
||||
G.nodes[vessel_id]['data'] = {}
|
||||
|
||||
|
||||
vessel_node_data = G.nodes[vessel_id]['data']
|
||||
current_node_volume = vessel_node_data.get('liquid_volume', 0.0)
|
||||
|
||||
|
||||
if isinstance(current_node_volume, list):
|
||||
if len(current_node_volume) > 0:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'][0] = new_volume
|
||||
@@ -149,33 +92,27 @@ def generate_dry_protocol(
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = [new_volume]
|
||||
else:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = new_volume
|
||||
|
||||
print(f"📊 模拟干燥体积变化: {original_liquid_volume:.2f}mL → {new_volume:.2f}mL (-{volume_loss:.2f}mL)")
|
||||
|
||||
print(f"📄 DRY: 协议生成完成,共 {len(action_sequence)} 个动作 🎯")
|
||||
|
||||
debug_print(f"模拟干燥体积变化: {original_liquid_volume:.2f}mL -> {new_volume:.2f}mL")
|
||||
|
||||
debug_print(f"协议生成完成,共 {len(action_sequence)} 个动作")
|
||||
return action_sequence
|
||||
|
||||
print(f"🎉 找到加热器: {heater_id}!")
|
||||
|
||||
|
||||
debug_print(f"找到加热器: {heater_id}")
|
||||
|
||||
# 3. 启动加热器进行干燥
|
||||
print(f"\n🚀 步骤3: 开始执行干燥流程...")
|
||||
print(f"🔥 启动加热器 {heater_id} 进行干燥")
|
||||
|
||||
# 3.1 启动加热
|
||||
print(f" ⚡ 动作1: 启动加热到 {dry_temp}°C...")
|
||||
action_sequence.append({
|
||||
"device_id": heater_id,
|
||||
"action_name": "heat_chill_start",
|
||||
"action_kwargs": {
|
||||
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
|
||||
"vessel": {"id": vessel_id},
|
||||
"temp": dry_temp,
|
||||
"purpose": f"干燥 {compound or '化合物'}"
|
||||
}
|
||||
})
|
||||
print(f" ✅ 加热器启动命令已添加 🔥")
|
||||
|
||||
|
||||
# 3.2 等待温度稳定
|
||||
print(f" ⏳ 动作2: 等待温度稳定...")
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {
|
||||
@@ -183,34 +120,27 @@ def generate_dry_protocol(
|
||||
"description": f"等待温度稳定到 {dry_temp}°C"
|
||||
}
|
||||
})
|
||||
print(f" ✅ 温度稳定等待命令已添加 🌡️")
|
||||
|
||||
|
||||
# 3.3 保持干燥温度
|
||||
print(f" 🔄 动作3: 保持干燥温度 {simulation_time/60:.0f} 分钟...")
|
||||
action_sequence.append({
|
||||
"device_id": heater_id,
|
||||
"action_name": "heat_chill",
|
||||
"action_kwargs": {
|
||||
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
|
||||
"vessel": {"id": vessel_id},
|
||||
"temp": dry_temp,
|
||||
"time": simulation_time,
|
||||
"purpose": f"干燥 {compound or '化合物'},保持温度 {dry_temp}°C"
|
||||
}
|
||||
})
|
||||
print(f" ✅ 温度保持命令已添加 🌡️⏰")
|
||||
|
||||
# 🔧 新增:干燥过程中的体积变化计算
|
||||
print(f"🔧 计算干燥过程中的体积变化...")
|
||||
|
||||
# 干燥过程中的体积变化计算
|
||||
if original_liquid_volume > 0:
|
||||
# 干燥过程中,溶剂会蒸发,固体保留
|
||||
# 根据温度和时间估算蒸发量
|
||||
evaporation_rate = 0.001 * dry_temp # 每秒每°C蒸发0.001mL
|
||||
total_evaporation = min(original_liquid_volume * 0.8,
|
||||
evaporation_rate * simulation_time) # 最多蒸发80%
|
||||
|
||||
evaporation_rate = 0.001 * dry_temp
|
||||
total_evaporation = min(original_liquid_volume * 0.8,
|
||||
evaporation_rate * simulation_time)
|
||||
|
||||
new_volume = max(0.0, original_liquid_volume - total_evaporation)
|
||||
|
||||
# 更新vessel字典中的体积
|
||||
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
if isinstance(current_volume, list):
|
||||
@@ -222,15 +152,14 @@ def generate_dry_protocol(
|
||||
vessel["data"]["liquid_volume"] = new_volume
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = new_volume
|
||||
|
||||
# 🔧 同时更新图中的容器数据
|
||||
|
||||
if vessel_id in G.nodes():
|
||||
if 'data' not in G.nodes[vessel_id]:
|
||||
G.nodes[vessel_id]['data'] = {}
|
||||
|
||||
|
||||
vessel_node_data = G.nodes[vessel_id]['data']
|
||||
current_node_volume = vessel_node_data.get('liquid_volume', 0.0)
|
||||
|
||||
|
||||
if isinstance(current_node_volume, list):
|
||||
if len(current_node_volume) > 0:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'][0] = new_volume
|
||||
@@ -238,37 +167,29 @@ def generate_dry_protocol(
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = [new_volume]
|
||||
else:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = new_volume
|
||||
|
||||
print(f"📊 干燥体积变化计算:")
|
||||
print(f" - 初始体积: {original_liquid_volume:.2f}mL")
|
||||
print(f" - 蒸发量: {total_evaporation:.2f}mL")
|
||||
print(f" - 剩余体积: {new_volume:.2f}mL")
|
||||
print(f" - 蒸发率: {(total_evaporation/original_liquid_volume*100):.1f}%")
|
||||
|
||||
|
||||
debug_print(f"干燥体积变化: {original_liquid_volume:.2f}mL -> {new_volume:.2f}mL (-{total_evaporation:.2f}mL)")
|
||||
|
||||
# 3.4 停止加热
|
||||
print(f" ⏹️ 动作4: 停止加热...")
|
||||
action_sequence.append({
|
||||
"device_id": heater_id,
|
||||
"action_name": "heat_chill_stop",
|
||||
"action_kwargs": {
|
||||
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
|
||||
"vessel": {"id": vessel_id},
|
||||
"purpose": f"干燥完成,停止加热"
|
||||
}
|
||||
})
|
||||
print(f" ✅ 停止加热命令已添加 🛑")
|
||||
|
||||
|
||||
# 3.5 等待冷却
|
||||
print(f" ❄️ 动作5: 等待冷却...")
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {
|
||||
"time": 10.0, # 等待10秒冷却
|
||||
"time": 10.0,
|
||||
"description": f"等待 {compound or '化合物'} 冷却"
|
||||
}
|
||||
})
|
||||
print(f" ✅ 冷却等待命令已添加 🧊")
|
||||
|
||||
# 🔧 新增:干燥完成后的状态报告
|
||||
|
||||
# 最终状态
|
||||
final_liquid_volume = 0.0
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
@@ -276,60 +197,37 @@ def generate_dry_protocol(
|
||||
final_liquid_volume = current_volume[0]
|
||||
elif isinstance(current_volume, (int, float)):
|
||||
final_liquid_volume = current_volume
|
||||
|
||||
print(f"\n🎊 DRY: 协议生成完成,共 {len(action_sequence)} 个动作 🎯")
|
||||
print(f"⏱️ DRY: 预计总时间: {(simulation_time + 30)/60:.0f} 分钟 ⌛")
|
||||
print(f"📊 干燥结果:")
|
||||
print(f" - 容器: {vessel_id}")
|
||||
print(f" - 化合物: {compound or '未指定'}")
|
||||
print(f" - 干燥前体积: {original_liquid_volume:.2f}mL")
|
||||
print(f" - 干燥后体积: {final_liquid_volume:.2f}mL")
|
||||
print(f" - 蒸发体积: {(original_liquid_volume - final_liquid_volume):.2f}mL")
|
||||
print(f"🏁 所有动作序列准备就绪! ✨")
|
||||
|
||||
|
||||
debug_print(f"干燥协议生成完成: {len(action_sequence)} 个动作, 体积 {original_liquid_volume:.2f} -> {final_liquid_volume:.2f}mL")
|
||||
|
||||
return action_sequence
|
||||
|
||||
|
||||
# 🔧 新增:便捷函数
|
||||
def generate_quick_dry_protocol(G: nx.DiGraph, vessel: dict, compound: str = "",
|
||||
# 便捷函数
|
||||
def generate_quick_dry_protocol(G: nx.DiGraph, vessel: dict, compound: str = "",
|
||||
temp: float = 40.0, time: float = 30.0) -> List[Dict[str, Any]]:
|
||||
"""快速干燥:低温短时间"""
|
||||
vessel_id = vessel["id"]
|
||||
print(f"🌡️ 快速干燥: {compound or '化合物'} → {vessel_id} @ {temp}°C ({time}min)")
|
||||
|
||||
# 临时修改默认参数
|
||||
import types
|
||||
temp_func = types.FunctionType(
|
||||
generate_dry_protocol.__code__,
|
||||
generate_dry_protocol.__globals__
|
||||
)
|
||||
|
||||
# 直接调用原函数,但修改内部参数
|
||||
return generate_dry_protocol(G, vessel, compound)
|
||||
|
||||
|
||||
def generate_thorough_dry_protocol(G: nx.DiGraph, vessel: dict, compound: str = "",
|
||||
def generate_thorough_dry_protocol(G: nx.DiGraph, vessel: dict, compound: str = "",
|
||||
temp: float = 80.0, time: float = 120.0) -> List[Dict[str, Any]]:
|
||||
"""深度干燥:高温长时间"""
|
||||
vessel_id = vessel["id"]
|
||||
print(f"🔥 深度干燥: {compound or '化合物'} → {vessel_id} @ {temp}°C ({time}min)")
|
||||
return generate_dry_protocol(G, vessel, compound)
|
||||
|
||||
|
||||
def generate_gentle_dry_protocol(G: nx.DiGraph, vessel: dict, compound: str = "",
|
||||
def generate_gentle_dry_protocol(G: nx.DiGraph, vessel: dict, compound: str = "",
|
||||
temp: float = 30.0, time: float = 180.0) -> List[Dict[str, Any]]:
|
||||
"""温和干燥:低温长时间"""
|
||||
vessel_id = vessel["id"]
|
||||
print(f"🌡️ 温和干燥: {compound or '化合物'} → {vessel_id} @ {temp}°C ({time}min)")
|
||||
return generate_dry_protocol(G, vessel, compound)
|
||||
|
||||
|
||||
# 测试函数
|
||||
def test_dry_protocol():
|
||||
"""测试干燥协议"""
|
||||
print("=== DRY PROTOCOL 测试 ===")
|
||||
print("测试完成")
|
||||
debug_print("=== DRY PROTOCOL 测试 ===")
|
||||
debug_print("测试完成")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_dry_protocol()
|
||||
test_dry_protocol()
|
||||
|
||||
@@ -3,38 +3,14 @@ from functools import partial
|
||||
import networkx as nx
|
||||
import logging
|
||||
import uuid
|
||||
import sys
|
||||
from typing import List, Dict, Any, Optional
|
||||
from .utils.vessel_parser import get_vessel
|
||||
from .utils.logger_util import action_log
|
||||
from .utils.vessel_parser import get_vessel, find_connected_stirrer
|
||||
from .utils.logger_util import debug_print, action_log
|
||||
from .pump_protocol import generate_pump_protocol_with_rinsing, generate_pump_protocol
|
||||
|
||||
# 设置日志
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 确保输出编码为UTF-8
|
||||
if hasattr(sys.stdout, 'reconfigure'):
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
sys.stderr.reconfigure(encoding='utf-8')
|
||||
except:
|
||||
pass
|
||||
|
||||
def debug_print(message):
|
||||
"""调试输出函数 - 支持中文"""
|
||||
try:
|
||||
# 确保消息是字符串格式
|
||||
safe_message = str(message)
|
||||
logger.info(f"[抽真空充气] {safe_message}")
|
||||
except UnicodeEncodeError:
|
||||
# 如果编码失败,尝试替换不支持的字符
|
||||
safe_message = str(message).encode('utf-8', errors='replace').decode('utf-8')
|
||||
logger.info(f"[抽真空充气] {safe_message}")
|
||||
except Exception as e:
|
||||
# 最后的安全措施
|
||||
fallback_message = f"日志输出错误: {repr(message)}"
|
||||
logger.info(f"[抽真空充气] {fallback_message}")
|
||||
|
||||
create_action_log = partial(action_log, prefix="[抽真空充气]")
|
||||
|
||||
def find_gas_source(G: nx.DiGraph, gas: str) -> str:
|
||||
@@ -44,10 +20,9 @@ def find_gas_source(G: nx.DiGraph, gas: str) -> str:
|
||||
2. 气体类型匹配(data.gas_type)
|
||||
3. 默认气源
|
||||
"""
|
||||
debug_print(f"🔍 正在查找气体 '{gas}' 的气源...")
|
||||
|
||||
# 第一步:通过容器名称匹配
|
||||
debug_print(f"📋 方法1: 容器名称匹配...")
|
||||
debug_print(f"正在查找气体 '{gas}' 的气源...")
|
||||
|
||||
# 通过容器名称匹配
|
||||
gas_source_patterns = [
|
||||
f"gas_source_{gas}",
|
||||
f"gas_{gas}",
|
||||
@@ -57,254 +32,178 @@ def find_gas_source(G: nx.DiGraph, gas: str) -> str:
|
||||
f"reagent_bottle_{gas}",
|
||||
f"bottle_{gas}"
|
||||
]
|
||||
|
||||
debug_print(f"🎯 尝试的容器名称: {gas_source_patterns}")
|
||||
|
||||
|
||||
for pattern in gas_source_patterns:
|
||||
if pattern in G.nodes():
|
||||
debug_print(f"✅ 通过名称找到气源: {pattern}")
|
||||
debug_print(f"通过名称找到气源: {pattern}")
|
||||
return pattern
|
||||
|
||||
# 第二步:通过气体类型匹配 (data.gas_type)
|
||||
debug_print(f"📋 方法2: 气体类型匹配...")
|
||||
|
||||
# 通过气体类型匹配 (data.gas_type)
|
||||
for node_id in G.nodes():
|
||||
node_data = G.nodes[node_id]
|
||||
node_class = node_data.get('class', '') or ''
|
||||
|
||||
# 检查是否是气源设备
|
||||
if ('gas_source' in node_class or
|
||||
'gas' in node_id.lower() or
|
||||
|
||||
if ('gas_source' in node_class or
|
||||
'gas' in node_id.lower() or
|
||||
node_id.startswith('flask_')):
|
||||
|
||||
# 检查 data.gas_type
|
||||
|
||||
data = node_data.get('data', {})
|
||||
gas_type = data.get('gas_type', '')
|
||||
|
||||
|
||||
if gas_type.lower() == gas.lower():
|
||||
debug_print(f"✅ 通过气体类型找到气源: {node_id} (气体类型: {gas_type})")
|
||||
debug_print(f"通过气体类型找到气源: {node_id} (气体类型: {gas_type})")
|
||||
return node_id
|
||||
|
||||
# 检查 config.gas_type
|
||||
|
||||
config = node_data.get('config', {})
|
||||
config_gas_type = config.get('gas_type', '')
|
||||
|
||||
|
||||
if config_gas_type.lower() == gas.lower():
|
||||
debug_print(f"✅ 通过配置气体类型找到气源: {node_id} (配置气体类型: {config_gas_type})")
|
||||
debug_print(f"通过配置气体类型找到气源: {node_id} (配置气体类型: {config_gas_type})")
|
||||
return node_id
|
||||
|
||||
# 第三步:查找所有可用的气源设备
|
||||
debug_print(f"📋 方法3: 查找可用气源...")
|
||||
|
||||
# 查找所有可用的气源设备
|
||||
available_gas_sources = []
|
||||
for node_id in G.nodes():
|
||||
node_data = G.nodes[node_id]
|
||||
node_class = node_data.get('class', '') or ''
|
||||
|
||||
if ('gas_source' in node_class or
|
||||
|
||||
if ('gas_source' in node_class or
|
||||
'gas' in node_id.lower() or
|
||||
(node_id.startswith('flask_') and any(g in node_id.lower() for g in ['air', 'nitrogen', 'argon']))):
|
||||
|
||||
|
||||
data = node_data.get('data', {})
|
||||
gas_type = data.get('gas_type', '未知')
|
||||
available_gas_sources.append(f"{node_id} (气体类型: {gas_type})")
|
||||
|
||||
debug_print(f"📊 可用气源: {available_gas_sources}")
|
||||
|
||||
# 第四步:如果找不到特定气体,使用默认的第一个气源
|
||||
debug_print(f"📋 方法4: 查找默认气源...")
|
||||
|
||||
# 如果找不到特定气体,使用默认的第一个气源
|
||||
default_gas_sources = [
|
||||
node for node in G.nodes()
|
||||
node for node in G.nodes()
|
||||
if ((G.nodes[node].get('class') or '').find('virtual_gas_source') != -1
|
||||
or 'gas_source' in node)
|
||||
]
|
||||
|
||||
|
||||
if default_gas_sources:
|
||||
default_source = default_gas_sources[0]
|
||||
debug_print(f"⚠️ 未找到特定气体 '{gas}',使用默认气源: {default_source}")
|
||||
debug_print(f"未找到特定气体 '{gas}',使用默认气源: {default_source}")
|
||||
return default_source
|
||||
|
||||
debug_print(f"❌ 所有方法都失败了!")
|
||||
|
||||
raise ValueError(f"无法找到气体 '{gas}' 的气源。可用气源: {available_gas_sources}")
|
||||
|
||||
def find_vacuum_pump(G: nx.DiGraph) -> str:
|
||||
"""查找真空泵设备"""
|
||||
debug_print("🔍 正在查找真空泵...")
|
||||
|
||||
vacuum_pumps = []
|
||||
for node in G.nodes():
|
||||
node_data = G.nodes[node]
|
||||
node_class = node_data.get('class', '') or ''
|
||||
|
||||
if ('virtual_vacuum_pump' in node_class or
|
||||
'vacuum_pump' in node.lower() or
|
||||
|
||||
if ('virtual_vacuum_pump' in node_class or
|
||||
'vacuum_pump' in node.lower() or
|
||||
'vacuum' in node_class.lower()):
|
||||
vacuum_pumps.append(node)
|
||||
debug_print(f"📋 发现真空泵: {node}")
|
||||
|
||||
if not vacuum_pumps:
|
||||
debug_print(f"❌ 系统中未找到真空泵")
|
||||
raise ValueError("系统中未找到真空泵")
|
||||
|
||||
debug_print(f"✅ 使用真空泵: {vacuum_pumps[0]}")
|
||||
return vacuum_pumps[0]
|
||||
|
||||
def find_connected_stirrer(G: nx.DiGraph, vessel: str) -> Optional[str]:
|
||||
"""查找与指定容器相连的搅拌器"""
|
||||
debug_print(f"🔍 正在查找与容器 {vessel} 连接的搅拌器...")
|
||||
|
||||
stirrer_nodes = []
|
||||
for node in G.nodes():
|
||||
node_data = G.nodes[node]
|
||||
node_class = node_data.get('class', '') or ''
|
||||
|
||||
if 'virtual_stirrer' in node_class or 'stirrer' in node.lower():
|
||||
stirrer_nodes.append(node)
|
||||
debug_print(f"📋 发现搅拌器: {node}")
|
||||
|
||||
debug_print(f"📊 找到的搅拌器总数: {len(stirrer_nodes)}")
|
||||
|
||||
# 检查哪个搅拌器与目标容器相连
|
||||
for stirrer in stirrer_nodes:
|
||||
if G.has_edge(stirrer, vessel) or G.has_edge(vessel, stirrer):
|
||||
debug_print(f"✅ 找到连接的搅拌器: {stirrer}")
|
||||
return stirrer
|
||||
|
||||
# 如果没有连接的搅拌器,返回第一个可用的
|
||||
if stirrer_nodes:
|
||||
debug_print(f"⚠️ 未找到直接连接的搅拌器,使用第一个可用的: {stirrer_nodes[0]}")
|
||||
return stirrer_nodes[0]
|
||||
|
||||
debug_print("❌ 未找到搅拌器")
|
||||
return None
|
||||
if not vacuum_pumps:
|
||||
raise ValueError("系统中未找到真空泵")
|
||||
|
||||
debug_print(f"使用真空泵: {vacuum_pumps[0]}")
|
||||
return vacuum_pumps[0]
|
||||
|
||||
def find_vacuum_solenoid_valve(G: nx.DiGraph, vacuum_pump: str) -> Optional[str]:
|
||||
"""查找真空泵相关的电磁阀"""
|
||||
debug_print(f"🔍 正在查找真空泵 {vacuum_pump} 的电磁阀...")
|
||||
|
||||
# 查找所有电磁阀
|
||||
solenoid_valves = []
|
||||
for node in G.nodes():
|
||||
node_data = G.nodes[node]
|
||||
node_class = node_data.get('class', '') or ''
|
||||
|
||||
|
||||
if ('solenoid' in node_class.lower() or 'solenoid_valve' in node.lower()):
|
||||
solenoid_valves.append(node)
|
||||
debug_print(f"📋 发现电磁阀: {node}")
|
||||
|
||||
debug_print(f"📊 找到的电磁阀: {solenoid_valves}")
|
||||
|
||||
|
||||
# 检查连接关系
|
||||
debug_print(f"📋 方法1: 检查连接关系...")
|
||||
for solenoid in solenoid_valves:
|
||||
if G.has_edge(solenoid, vacuum_pump) or G.has_edge(vacuum_pump, solenoid):
|
||||
debug_print(f"✅ 找到连接的真空电磁阀: {solenoid}")
|
||||
debug_print(f"找到连接的真空电磁阀: {solenoid}")
|
||||
return solenoid
|
||||
|
||||
|
||||
# 通过命名规则查找
|
||||
debug_print(f"📋 方法2: 检查命名规则...")
|
||||
for solenoid in solenoid_valves:
|
||||
if 'vacuum' in solenoid.lower() or solenoid == 'solenoid_valve_1':
|
||||
debug_print(f"✅ 通过命名找到真空电磁阀: {solenoid}")
|
||||
debug_print(f"通过命名找到真空电磁阀: {solenoid}")
|
||||
return solenoid
|
||||
|
||||
debug_print("⚠️ 未找到真空电磁阀")
|
||||
|
||||
debug_print("未找到真空电磁阀")
|
||||
return None
|
||||
|
||||
def find_gas_solenoid_valve(G: nx.DiGraph, gas_source: str) -> Optional[str]:
|
||||
"""查找气源相关的电磁阀"""
|
||||
debug_print(f"🔍 正在查找气源 {gas_source} 的电磁阀...")
|
||||
|
||||
# 查找所有电磁阀
|
||||
solenoid_valves = []
|
||||
for node in G.nodes():
|
||||
node_data = G.nodes[node]
|
||||
node_class = node_data.get('class', '') or ''
|
||||
|
||||
|
||||
if ('solenoid' in node_class.lower() or 'solenoid_valve' in node.lower()):
|
||||
solenoid_valves.append(node)
|
||||
|
||||
debug_print(f"📊 找到的电磁阀: {solenoid_valves}")
|
||||
|
||||
|
||||
# 检查连接关系
|
||||
debug_print(f"📋 方法1: 检查连接关系...")
|
||||
for solenoid in solenoid_valves:
|
||||
if G.has_edge(gas_source, solenoid) or G.has_edge(solenoid, gas_source):
|
||||
debug_print(f"✅ 找到连接的气源电磁阀: {solenoid}")
|
||||
debug_print(f"找到连接的气源电磁阀: {solenoid}")
|
||||
return solenoid
|
||||
|
||||
|
||||
# 通过命名规则查找
|
||||
debug_print(f"📋 方法2: 检查命名规则...")
|
||||
for solenoid in solenoid_valves:
|
||||
if 'gas' in solenoid.lower() or solenoid == 'solenoid_valve_2':
|
||||
debug_print(f"✅ 通过命名找到气源电磁阀: {solenoid}")
|
||||
debug_print(f"通过命名找到气源电磁阀: {solenoid}")
|
||||
return solenoid
|
||||
|
||||
debug_print("⚠️ 未找到气源电磁阀")
|
||||
|
||||
debug_print("未找到气源电磁阀")
|
||||
return None
|
||||
|
||||
def generate_evacuateandrefill_protocol(
|
||||
G: nx.DiGraph,
|
||||
vessel: dict, # 🔧 修改:从字符串改为字典类型
|
||||
vessel: dict,
|
||||
gas: str,
|
||||
**kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
生成抽真空和充气操作的动作序列 - 中文版
|
||||
|
||||
生成抽真空和充气操作的动作序列
|
||||
|
||||
Args:
|
||||
G: 设备图
|
||||
vessel: 目标容器字典(必需)
|
||||
gas: 气体名称(必需)
|
||||
gas: 气体名称(必需)
|
||||
**kwargs: 其他参数(兼容性)
|
||||
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 动作序列
|
||||
"""
|
||||
|
||||
# 🔧 核心修改:从字典中提取容器ID
|
||||
|
||||
vessel_id, vessel_data = get_vessel(vessel)
|
||||
|
||||
|
||||
# 硬编码重复次数为 3
|
||||
repeats = 3
|
||||
|
||||
# 生成协议ID
|
||||
|
||||
protocol_id = str(uuid.uuid4())
|
||||
debug_print(f"🆔 生成协议ID: {protocol_id}")
|
||||
|
||||
debug_print("=" * 60)
|
||||
debug_print("🧪 开始生成抽真空充气协议")
|
||||
debug_print(f"📋 原始参数:")
|
||||
debug_print(f" 🥼 vessel: {vessel} (ID: {vessel_id})")
|
||||
debug_print(f" 💨 气体: '{gas}'")
|
||||
debug_print(f" 🔄 循环次数: {repeats} (硬编码)")
|
||||
debug_print(f" 📦 其他参数: {kwargs}")
|
||||
debug_print("=" * 60)
|
||||
|
||||
|
||||
debug_print(f"开始生成抽真空充气协议: vessel={vessel_id}, gas={gas}, repeats={repeats}")
|
||||
|
||||
action_sequence = []
|
||||
|
||||
|
||||
# === 参数验证和修正 ===
|
||||
debug_print("🔍 步骤1: 参数验证和修正...")
|
||||
action_sequence.append(create_action_log(f"开始抽真空充气操作 - 容器: {vessel_id}", "🎬"))
|
||||
action_sequence.append(create_action_log(f"目标气体: {gas}", "💨"))
|
||||
action_sequence.append(create_action_log(f"循环次数: {repeats}", "🔄"))
|
||||
|
||||
# 验证必需参数
|
||||
|
||||
if not vessel_id:
|
||||
debug_print("❌ 容器参数不能为空")
|
||||
raise ValueError("容器参数不能为空")
|
||||
|
||||
|
||||
if not gas:
|
||||
debug_print("❌ 气体参数不能为空")
|
||||
raise ValueError("气体参数不能为空")
|
||||
|
||||
if vessel_id not in G.nodes(): # 🔧 使用 vessel_id
|
||||
debug_print(f"❌ 容器 '{vessel_id}' 在系统中不存在")
|
||||
|
||||
if vessel_id not in G.nodes():
|
||||
raise ValueError(f"容器 '{vessel_id}' 在系统中不存在")
|
||||
|
||||
debug_print("✅ 基本参数验证通过")
|
||||
|
||||
action_sequence.append(create_action_log("参数验证通过", "✅"))
|
||||
|
||||
|
||||
# 标准化气体名称
|
||||
debug_print("🔧 标准化气体名称...")
|
||||
gas_aliases = {
|
||||
'n2': 'nitrogen',
|
||||
'ar': 'argon',
|
||||
@@ -319,61 +218,54 @@ def generate_evacuateandrefill_protocol(
|
||||
'二氧化碳': 'carbon_dioxide',
|
||||
'氢气': 'hydrogen'
|
||||
}
|
||||
|
||||
|
||||
original_gas = gas
|
||||
gas_lower = gas.lower().strip()
|
||||
if gas_lower in gas_aliases:
|
||||
gas = gas_aliases[gas_lower]
|
||||
debug_print(f"🔄 标准化气体名称: {original_gas} -> {gas}")
|
||||
debug_print(f"标准化气体名称: {original_gas} -> {gas}")
|
||||
action_sequence.append(create_action_log(f"气体名称标准化: {original_gas} -> {gas}", "🔄"))
|
||||
|
||||
debug_print(f"📋 最终参数: 容器={vessel_id}, 气体={gas}, 重复={repeats}")
|
||||
|
||||
|
||||
debug_print(f"最终参数: 容器={vessel_id}, 气体={gas}, 重复={repeats}")
|
||||
|
||||
# === 查找设备 ===
|
||||
debug_print("🔍 步骤2: 查找设备...")
|
||||
action_sequence.append(create_action_log("正在查找相关设备...", "🔍"))
|
||||
|
||||
|
||||
try:
|
||||
vacuum_pump = find_vacuum_pump(G)
|
||||
action_sequence.append(create_action_log(f"找到真空泵: {vacuum_pump}", "🌪️"))
|
||||
|
||||
|
||||
gas_source = find_gas_source(G, gas)
|
||||
action_sequence.append(create_action_log(f"找到气源: {gas_source}", "💨"))
|
||||
|
||||
|
||||
vacuum_solenoid = find_vacuum_solenoid_valve(G, vacuum_pump)
|
||||
if vacuum_solenoid:
|
||||
action_sequence.append(create_action_log(f"找到真空电磁阀: {vacuum_solenoid}", "🚪"))
|
||||
else:
|
||||
action_sequence.append(create_action_log("未找到真空电磁阀", "⚠️"))
|
||||
|
||||
|
||||
gas_solenoid = find_gas_solenoid_valve(G, gas_source)
|
||||
if gas_solenoid:
|
||||
action_sequence.append(create_action_log(f"找到气源电磁阀: {gas_solenoid}", "🚪"))
|
||||
else:
|
||||
action_sequence.append(create_action_log("未找到气源电磁阀", "⚠️"))
|
||||
|
||||
stirrer_id = find_connected_stirrer(G, vessel_id) # 🔧 使用 vessel_id
|
||||
|
||||
stirrer_id = find_connected_stirrer(G, vessel_id)
|
||||
if stirrer_id:
|
||||
action_sequence.append(create_action_log(f"找到搅拌器: {stirrer_id}", "🌪️"))
|
||||
else:
|
||||
action_sequence.append(create_action_log("未找到搅拌器", "⚠️"))
|
||||
|
||||
debug_print(f"📊 设备配置:")
|
||||
debug_print(f" 🌪️ 真空泵: {vacuum_pump}")
|
||||
debug_print(f" 💨 气源: {gas_source}")
|
||||
debug_print(f" 🚪 真空电磁阀: {vacuum_solenoid}")
|
||||
debug_print(f" 🚪 气源电磁阀: {gas_solenoid}")
|
||||
debug_print(f" 🌪️ 搅拌器: {stirrer_id}")
|
||||
|
||||
|
||||
debug_print(f"设备配置: 真空泵={vacuum_pump}, 气源={gas_source}, 搅拌器={stirrer_id}")
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"❌ 设备查找失败: {str(e)}")
|
||||
debug_print(f"设备查找失败: {str(e)}")
|
||||
action_sequence.append(create_action_log(f"设备查找失败: {str(e)}", "❌"))
|
||||
raise ValueError(f"设备查找失败: {str(e)}")
|
||||
|
||||
|
||||
# === 参数设置 ===
|
||||
debug_print("🔍 步骤3: 参数设置...")
|
||||
action_sequence.append(create_action_log("设置操作参数...", "⚙️"))
|
||||
|
||||
|
||||
# 根据气体类型调整参数
|
||||
if gas.lower() in ['nitrogen', 'argon']:
|
||||
VACUUM_VOLUME = 25.0
|
||||
@@ -381,7 +273,6 @@ def generate_evacuateandrefill_protocol(
|
||||
PUMP_FLOW_RATE = 2.0
|
||||
VACUUM_TIME = 30.0
|
||||
REFILL_TIME = 20.0
|
||||
debug_print("💨 惰性气体: 使用标准参数")
|
||||
action_sequence.append(create_action_log("检测到惰性气体,使用标准参数", "💨"))
|
||||
elif gas.lower() in ['air', 'oxygen']:
|
||||
VACUUM_VOLUME = 20.0
|
||||
@@ -389,7 +280,6 @@ def generate_evacuateandrefill_protocol(
|
||||
PUMP_FLOW_RATE = 1.5
|
||||
VACUUM_TIME = 45.0
|
||||
REFILL_TIME = 25.0
|
||||
debug_print("🔥 活性气体: 使用保守参数")
|
||||
action_sequence.append(create_action_log("检测到活性气体,使用保守参数", "🔥"))
|
||||
else:
|
||||
VACUUM_VOLUME = 15.0
|
||||
@@ -397,116 +287,88 @@ def generate_evacuateandrefill_protocol(
|
||||
PUMP_FLOW_RATE = 1.0
|
||||
VACUUM_TIME = 60.0
|
||||
REFILL_TIME = 30.0
|
||||
debug_print("❓ 未知气体: 使用安全参数")
|
||||
action_sequence.append(create_action_log("未知气体类型,使用安全参数", "❓"))
|
||||
|
||||
|
||||
STIR_SPEED = 200.0
|
||||
|
||||
debug_print(f"⚙️ 操作参数:")
|
||||
debug_print(f" 📏 真空体积: {VACUUM_VOLUME}mL")
|
||||
debug_print(f" 📏 充气体积: {REFILL_VOLUME}mL")
|
||||
debug_print(f" ⚡ 泵流速: {PUMP_FLOW_RATE}mL/s")
|
||||
debug_print(f" ⏱️ 真空时间: {VACUUM_TIME}s")
|
||||
debug_print(f" ⏱️ 充气时间: {REFILL_TIME}s")
|
||||
debug_print(f" 🌪️ 搅拌速度: {STIR_SPEED}RPM")
|
||||
|
||||
|
||||
action_sequence.append(create_action_log(f"真空体积: {VACUUM_VOLUME}mL", "📏"))
|
||||
action_sequence.append(create_action_log(f"充气体积: {REFILL_VOLUME}mL", "📏"))
|
||||
action_sequence.append(create_action_log(f"泵流速: {PUMP_FLOW_RATE}mL/s", "⚡"))
|
||||
|
||||
|
||||
# === 路径验证 ===
|
||||
debug_print("🔍 步骤4: 路径验证...")
|
||||
action_sequence.append(create_action_log("验证传输路径...", "🛤️"))
|
||||
|
||||
|
||||
try:
|
||||
# 验证抽真空路径
|
||||
if nx.has_path(G, vessel_id, vacuum_pump): # 🔧 使用 vessel_id
|
||||
if nx.has_path(G, vessel_id, vacuum_pump):
|
||||
vacuum_path = nx.shortest_path(G, source=vessel_id, target=vacuum_pump)
|
||||
debug_print(f"✅ 真空路径: {' -> '.join(vacuum_path)}")
|
||||
action_sequence.append(create_action_log(f"真空路径: {' -> '.join(vacuum_path)}", "🛤️"))
|
||||
else:
|
||||
debug_print(f"⚠️ 真空路径不存在,继续执行但可能有问题")
|
||||
action_sequence.append(create_action_log("真空路径检查: 路径不存在", "⚠️"))
|
||||
|
||||
# 验证充气路径
|
||||
if nx.has_path(G, gas_source, vessel_id): # 🔧 使用 vessel_id
|
||||
|
||||
if nx.has_path(G, gas_source, vessel_id):
|
||||
gas_path = nx.shortest_path(G, source=gas_source, target=vessel_id)
|
||||
debug_print(f"✅ 气体路径: {' -> '.join(gas_path)}")
|
||||
action_sequence.append(create_action_log(f"气体路径: {' -> '.join(gas_path)}", "🛤️"))
|
||||
else:
|
||||
debug_print(f"⚠️ 气体路径不存在,继续执行但可能有问题")
|
||||
action_sequence.append(create_action_log("气体路径检查: 路径不存在", "⚠️"))
|
||||
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"⚠️ 路径验证失败: {str(e)},继续执行")
|
||||
action_sequence.append(create_action_log(f"路径验证失败: {str(e)}", "⚠️"))
|
||||
|
||||
|
||||
# === 启动搅拌器 ===
|
||||
debug_print("🔍 步骤5: 启动搅拌器...")
|
||||
|
||||
if stirrer_id:
|
||||
debug_print(f"🌪️ 启动搅拌器: {stirrer_id}")
|
||||
action_sequence.append(create_action_log(f"启动搅拌器 {stirrer_id} (速度: {STIR_SPEED}rpm)", "🌪️"))
|
||||
|
||||
|
||||
action_sequence.append({
|
||||
"device_id": stirrer_id,
|
||||
"action_name": "start_stir",
|
||||
"action_kwargs": {
|
||||
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
|
||||
"vessel": {"id": vessel_id},
|
||||
"stir_speed": STIR_SPEED,
|
||||
"purpose": "抽真空充气前预搅拌"
|
||||
}
|
||||
})
|
||||
|
||||
# 等待搅拌稳定
|
||||
|
||||
action_sequence.append(create_action_log("等待搅拌稳定...", "⏳"))
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": 5.0}
|
||||
})
|
||||
else:
|
||||
debug_print("⚠️ 未找到搅拌器,跳过搅拌器启动")
|
||||
action_sequence.append(create_action_log("跳过搅拌器启动", "⏭️"))
|
||||
|
||||
|
||||
# === 执行循环 ===
|
||||
debug_print("🔍 步骤6: 执行抽真空-充气循环...")
|
||||
action_sequence.append(create_action_log(f"开始 {repeats} 次抽真空-充气循环", "🔄"))
|
||||
|
||||
|
||||
for cycle in range(repeats):
|
||||
debug_print(f"=== 第 {cycle+1}/{repeats} 轮循环 ===")
|
||||
action_sequence.append(create_action_log(f"第 {cycle+1}/{repeats} 轮循环开始", "🚀"))
|
||||
|
||||
|
||||
# ============ 抽真空阶段 ============
|
||||
debug_print(f"🌪️ 抽真空阶段开始")
|
||||
action_sequence.append(create_action_log("开始抽真空阶段", "🌪️"))
|
||||
|
||||
|
||||
# 启动真空泵
|
||||
debug_print(f"🔛 启动真空泵: {vacuum_pump}")
|
||||
action_sequence.append(create_action_log(f"启动真空泵: {vacuum_pump}", "🔛"))
|
||||
action_sequence.append({
|
||||
"device_id": vacuum_pump,
|
||||
"action_name": "set_status",
|
||||
"action_kwargs": {"string": "ON"}
|
||||
})
|
||||
|
||||
|
||||
# 开启真空电磁阀
|
||||
if vacuum_solenoid:
|
||||
debug_print(f"🚪 打开真空电磁阀: {vacuum_solenoid}")
|
||||
action_sequence.append(create_action_log(f"打开真空电磁阀: {vacuum_solenoid}", "🚪"))
|
||||
action_sequence.append({
|
||||
"device_id": vacuum_solenoid,
|
||||
"action_name": "set_valve_position",
|
||||
"action_kwargs": {"command": "OPEN"}
|
||||
})
|
||||
|
||||
|
||||
# 抽真空操作
|
||||
debug_print(f"🌪️ 抽真空操作: {vessel_id} -> {vacuum_pump}")
|
||||
action_sequence.append(create_action_log(f"开始抽真空: {vessel_id} -> {vacuum_pump}", "🌪️"))
|
||||
|
||||
|
||||
try:
|
||||
vacuum_transfer_actions = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
from_vessel=vessel_id, # 🔧 使用 vessel_id
|
||||
from_vessel=vessel_id,
|
||||
to_vessel=vacuum_pump,
|
||||
volume=VACUUM_VOLUME,
|
||||
amount="",
|
||||
@@ -519,27 +381,25 @@ def generate_evacuateandrefill_protocol(
|
||||
flowrate=PUMP_FLOW_RATE,
|
||||
transfer_flowrate=PUMP_FLOW_RATE
|
||||
)
|
||||
|
||||
|
||||
if vacuum_transfer_actions:
|
||||
action_sequence.extend(vacuum_transfer_actions)
|
||||
debug_print(f"✅ 添加了 {len(vacuum_transfer_actions)} 个抽真空动作")
|
||||
action_sequence.append(create_action_log(f"抽真空协议完成 ({len(vacuum_transfer_actions)} 个操作)", "✅"))
|
||||
else:
|
||||
debug_print("⚠️ 抽真空协议返回空序列,添加手动动作")
|
||||
action_sequence.append(create_action_log("抽真空协议为空,使用手动等待", "⚠️"))
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": VACUUM_TIME}
|
||||
})
|
||||
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"❌ 抽真空失败: {str(e)}")
|
||||
debug_print(f"抽真空失败: {str(e)}")
|
||||
action_sequence.append(create_action_log(f"抽真空失败: {str(e)}", "❌"))
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": VACUUM_TIME}
|
||||
})
|
||||
|
||||
|
||||
# 抽真空后等待
|
||||
wait_minutes = VACUUM_TIME / 60
|
||||
action_sequence.append(create_action_log(f"抽真空后等待 ({wait_minutes:.1f} 分钟)", "⏳"))
|
||||
@@ -547,65 +407,59 @@ def generate_evacuateandrefill_protocol(
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": VACUUM_TIME}
|
||||
})
|
||||
|
||||
|
||||
# 关闭真空电磁阀
|
||||
if vacuum_solenoid:
|
||||
debug_print(f"🚪 关闭真空电磁阀: {vacuum_solenoid}")
|
||||
action_sequence.append(create_action_log(f"关闭真空电磁阀: {vacuum_solenoid}", "🚪"))
|
||||
action_sequence.append({
|
||||
"device_id": vacuum_solenoid,
|
||||
"action_name": "set_valve_position",
|
||||
"action_kwargs": {"command": "CLOSED"}
|
||||
})
|
||||
|
||||
|
||||
# 关闭真空泵
|
||||
debug_print(f"🔴 停止真空泵: {vacuum_pump}")
|
||||
action_sequence.append(create_action_log(f"停止真空泵: {vacuum_pump}", "🔴"))
|
||||
action_sequence.append({
|
||||
"device_id": vacuum_pump,
|
||||
"action_name": "set_status",
|
||||
"action_kwargs": {"string": "OFF"}
|
||||
})
|
||||
|
||||
|
||||
# 阶段间等待
|
||||
action_sequence.append(create_action_log("抽真空阶段完成,短暂等待", "⏳"))
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": 5.0}
|
||||
})
|
||||
|
||||
|
||||
# ============ 充气阶段 ============
|
||||
debug_print(f"💨 充气阶段开始")
|
||||
action_sequence.append(create_action_log("开始气体充气阶段", "💨"))
|
||||
|
||||
|
||||
# 启动气源
|
||||
debug_print(f"🔛 启动气源: {gas_source}")
|
||||
action_sequence.append(create_action_log(f"启动气源: {gas_source}", "🔛"))
|
||||
action_sequence.append({
|
||||
"device_id": gas_source,
|
||||
"action_name": "set_status",
|
||||
"action_kwargs": {"string": "ON"}
|
||||
})
|
||||
|
||||
|
||||
# 开启气源电磁阀
|
||||
if gas_solenoid:
|
||||
debug_print(f"🚪 打开气源电磁阀: {gas_solenoid}")
|
||||
action_sequence.append(create_action_log(f"打开气源电磁阀: {gas_solenoid}", "🚪"))
|
||||
action_sequence.append({
|
||||
"device_id": gas_solenoid,
|
||||
"action_name": "set_valve_position",
|
||||
"action_kwargs": {"command": "OPEN"}
|
||||
})
|
||||
|
||||
|
||||
# 充气操作
|
||||
debug_print(f"💨 充气操作: {gas_source} -> {vessel_id}")
|
||||
action_sequence.append(create_action_log(f"开始气体充气: {gas_source} -> {vessel_id}", "💨"))
|
||||
|
||||
|
||||
try:
|
||||
gas_transfer_actions = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
from_vessel=gas_source,
|
||||
to_vessel=vessel_id, # 🔧 使用 vessel_id
|
||||
to_vessel=vessel_id,
|
||||
volume=REFILL_VOLUME,
|
||||
amount="",
|
||||
time=0.0,
|
||||
@@ -617,27 +471,25 @@ def generate_evacuateandrefill_protocol(
|
||||
flowrate=PUMP_FLOW_RATE,
|
||||
transfer_flowrate=PUMP_FLOW_RATE
|
||||
)
|
||||
|
||||
|
||||
if gas_transfer_actions:
|
||||
action_sequence.extend(gas_transfer_actions)
|
||||
debug_print(f"✅ 添加了 {len(gas_transfer_actions)} 个充气动作")
|
||||
action_sequence.append(create_action_log(f"气体充气协议完成 ({len(gas_transfer_actions)} 个操作)", "✅"))
|
||||
else:
|
||||
debug_print("⚠️ 充气协议返回空序列,添加手动动作")
|
||||
action_sequence.append(create_action_log("充气协议为空,使用手动等待", "⚠️"))
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": REFILL_TIME}
|
||||
})
|
||||
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"❌ 气体充气失败: {str(e)}")
|
||||
debug_print(f"气体充气失败: {str(e)}")
|
||||
action_sequence.append(create_action_log(f"气体充气失败: {str(e)}", "❌"))
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": REFILL_TIME}
|
||||
})
|
||||
|
||||
|
||||
# 充气后等待
|
||||
refill_wait_minutes = REFILL_TIME / 60
|
||||
action_sequence.append(create_action_log(f"充气后等待 ({refill_wait_minutes:.1f} 分钟)", "⏳"))
|
||||
@@ -645,29 +497,26 @@ def generate_evacuateandrefill_protocol(
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": REFILL_TIME}
|
||||
})
|
||||
|
||||
|
||||
# 关闭气源电磁阀
|
||||
if gas_solenoid:
|
||||
debug_print(f"🚪 关闭气源电磁阀: {gas_solenoid}")
|
||||
action_sequence.append(create_action_log(f"关闭气源电磁阀: {gas_solenoid}", "🚪"))
|
||||
action_sequence.append({
|
||||
"device_id": gas_solenoid,
|
||||
"action_name": "set_valve_position",
|
||||
"action_kwargs": {"command": "CLOSED"}
|
||||
})
|
||||
|
||||
|
||||
# 关闭气源
|
||||
debug_print(f"🔴 停止气源: {gas_source}")
|
||||
action_sequence.append(create_action_log(f"停止气源: {gas_source}", "🔴"))
|
||||
action_sequence.append({
|
||||
"device_id": gas_source,
|
||||
"action_name": "set_status",
|
||||
"action_kwargs": {"string": "OFF"}
|
||||
})
|
||||
|
||||
|
||||
# 循环间等待
|
||||
if cycle < repeats - 1:
|
||||
debug_print(f"⏳ 等待下一个循环...")
|
||||
action_sequence.append(create_action_log("等待下一个循环...", "⏳"))
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
@@ -675,78 +524,58 @@ def generate_evacuateandrefill_protocol(
|
||||
})
|
||||
else:
|
||||
action_sequence.append(create_action_log(f"第 {cycle+1}/{repeats} 轮循环完成", "✅"))
|
||||
|
||||
|
||||
# === 停止搅拌器 ===
|
||||
debug_print("🔍 步骤7: 停止搅拌器...")
|
||||
|
||||
if stirrer_id:
|
||||
debug_print(f"🛑 停止搅拌器: {stirrer_id}")
|
||||
action_sequence.append(create_action_log(f"停止搅拌器: {stirrer_id}", "🛑"))
|
||||
action_sequence.append({
|
||||
"device_id": stirrer_id,
|
||||
"action_name": "stop_stir",
|
||||
"action_kwargs": {"vessel": {"id": vessel_id},} # 🔧 使用 vessel_id
|
||||
"action_kwargs": {"vessel": {"id": vessel_id},}
|
||||
})
|
||||
else:
|
||||
action_sequence.append(create_action_log("跳过搅拌器停止", "⏭️"))
|
||||
|
||||
|
||||
# === 最终等待 ===
|
||||
action_sequence.append(create_action_log("最终稳定等待...", "⏳"))
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": 10.0}
|
||||
})
|
||||
|
||||
|
||||
# === 总结 ===
|
||||
total_time = (VACUUM_TIME + REFILL_TIME + 25) * repeats + 20
|
||||
|
||||
debug_print("=" * 60)
|
||||
debug_print(f"🎉 抽真空充气协议生成完成")
|
||||
debug_print(f"📊 协议统计:")
|
||||
debug_print(f" 📋 总动作数: {len(action_sequence)}")
|
||||
debug_print(f" ⏱️ 预计总时间: {total_time:.0f}s ({total_time/60:.1f} 分钟)")
|
||||
debug_print(f" 🥼 处理容器: {vessel_id}")
|
||||
debug_print(f" 💨 使用气体: {gas}")
|
||||
debug_print(f" 🔄 重复次数: {repeats}")
|
||||
debug_print("=" * 60)
|
||||
|
||||
# 添加完成日志
|
||||
|
||||
debug_print(f"抽真空充气协议生成完成: {len(action_sequence)} 个动作, 预计 {total_time:.0f}s")
|
||||
|
||||
summary_msg = f"抽真空充气协议完成: {vessel_id} (使用 {gas},{repeats} 次循环)"
|
||||
action_sequence.append(create_action_log(summary_msg, "🎉"))
|
||||
|
||||
|
||||
return action_sequence
|
||||
|
||||
# === 便捷函数 ===
|
||||
|
||||
def generate_nitrogen_purge_protocol(G: nx.DiGraph, vessel: dict, **kwargs) -> List[Dict[str, Any]]: # 🔧 修改参数类型
|
||||
def generate_nitrogen_purge_protocol(G: nx.DiGraph, vessel: dict, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""生成氮气置换协议"""
|
||||
vessel_id = vessel["id"]
|
||||
debug_print(f"💨 生成氮气置换协议: {vessel_id}")
|
||||
return generate_evacuateandrefill_protocol(G, vessel, "nitrogen", **kwargs)
|
||||
|
||||
def generate_argon_purge_protocol(G: nx.DiGraph, vessel: dict, **kwargs) -> List[Dict[str, Any]]: # 🔧 修改参数类型
|
||||
def generate_argon_purge_protocol(G: nx.DiGraph, vessel: dict, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""生成氩气置换协议"""
|
||||
vessel_id = vessel["id"]
|
||||
debug_print(f"💨 生成氩气置换协议: {vessel_id}")
|
||||
return generate_evacuateandrefill_protocol(G, vessel, "argon", **kwargs)
|
||||
|
||||
def generate_air_purge_protocol(G: nx.DiGraph, vessel: dict, **kwargs) -> List[Dict[str, Any]]: # 🔧 修改参数类型
|
||||
def generate_air_purge_protocol(G: nx.DiGraph, vessel: dict, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""生成空气置换协议"""
|
||||
vessel_id = vessel["id"]
|
||||
debug_print(f"💨 生成空气置换协议: {vessel_id}")
|
||||
return generate_evacuateandrefill_protocol(G, vessel, "air", **kwargs)
|
||||
|
||||
def generate_inert_atmosphere_protocol(G: nx.DiGraph, vessel: dict, gas: str = "nitrogen", **kwargs) -> List[Dict[str, Any]]: # 🔧 修改参数类型
|
||||
def generate_inert_atmosphere_protocol(G: nx.DiGraph, vessel: dict, gas: str = "nitrogen", **kwargs) -> List[Dict[str, Any]]:
|
||||
"""生成惰性气氛协议"""
|
||||
vessel_id = vessel["id"]
|
||||
debug_print(f"🛡️ 生成惰性气氛协议: {vessel_id} (使用 {gas})")
|
||||
return generate_evacuateandrefill_protocol(G, vessel, gas, **kwargs)
|
||||
|
||||
# 测试函数
|
||||
def test_evacuateandrefill_protocol():
|
||||
"""测试抽真空充气协议"""
|
||||
debug_print("=== 抽真空充气协议增强中文版测试 ===")
|
||||
debug_print("✅ 测试完成")
|
||||
debug_print("=== 抽真空充气协议测试 ===")
|
||||
debug_print("测试完成")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_evacuateandrefill_protocol()
|
||||
test_evacuateandrefill_protocol()
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
# import numpy as np
|
||||
# import networkx as nx
|
||||
|
||||
|
||||
# def generate_evacuateandrefill_protocol(
|
||||
# G: nx.DiGraph,
|
||||
# vessel: str,
|
||||
# gas: str,
|
||||
# repeats: int = 1
|
||||
# ) -> list[dict]:
|
||||
# """
|
||||
# 生成泵操作的动作序列。
|
||||
|
||||
# :param G: 有向图, 节点为容器和注射泵, 边为流体管道, A→B边的属性为管道接A端的阀门位置
|
||||
# :param from_vessel: 容器A
|
||||
# :param to_vessel: 容器B
|
||||
# :param volume: 转移的体积
|
||||
# :param flowrate: 最终注入容器B时的流速
|
||||
# :param transfer_flowrate: 泵骨架中转移流速(若不指定,默认与注入流速相同)
|
||||
# :return: 泵操作的动作序列
|
||||
# """
|
||||
|
||||
# # 生成电磁阀、真空泵、气源操作的动作序列
|
||||
# vacuum_action_sequence = []
|
||||
# nodes = G.nodes(data=True)
|
||||
|
||||
# # 找到和 vessel 相连的电磁阀和真空泵、气源
|
||||
# vacuum_backbone = {"vessel": vessel}
|
||||
|
||||
# for neighbor in G.neighbors(vessel):
|
||||
# if nodes[neighbor]["class"].startswith("solenoid_valve"):
|
||||
# for neighbor2 in G.neighbors(neighbor):
|
||||
# if neighbor2 == vessel:
|
||||
# continue
|
||||
# if nodes[neighbor2]["class"].startswith("vacuum_pump"):
|
||||
# vacuum_backbone.update({"vacuum_valve": neighbor, "pump": neighbor2})
|
||||
# break
|
||||
# elif nodes[neighbor2]["class"].startswith("gas_source"):
|
||||
# vacuum_backbone.update({"gas_valve": neighbor, "gas": neighbor2})
|
||||
# break
|
||||
# # 判断是否设备齐全
|
||||
# if len(vacuum_backbone) < 5:
|
||||
# print(f"\n\n\n{vacuum_backbone}\n\n\n")
|
||||
# raise ValueError("Not all devices are connected to the vessel.")
|
||||
|
||||
# # 生成操作的动作序列
|
||||
# for i in range(repeats):
|
||||
# # 打开真空泵阀门、关闭气源阀门
|
||||
# vacuum_action_sequence.append([
|
||||
# {
|
||||
# "device_id": vacuum_backbone["vacuum_valve"],
|
||||
# "action_name": "set_valve_position",
|
||||
# "action_kwargs": {
|
||||
# "command": "OPEN"
|
||||
# }
|
||||
# },
|
||||
# {
|
||||
# "device_id": vacuum_backbone["gas_valve"],
|
||||
# "action_name": "set_valve_position",
|
||||
# "action_kwargs": {
|
||||
# "command": "CLOSED"
|
||||
# }
|
||||
# }
|
||||
# ])
|
||||
|
||||
# # 打开真空泵、关闭气源
|
||||
# vacuum_action_sequence.append([
|
||||
# {
|
||||
# "device_id": vacuum_backbone["pump"],
|
||||
# "action_name": "set_status",
|
||||
# "action_kwargs": {
|
||||
# "string": "ON"
|
||||
# }
|
||||
# },
|
||||
# {
|
||||
# "device_id": vacuum_backbone["gas"],
|
||||
# "action_name": "set_status",
|
||||
# "action_kwargs": {
|
||||
# "string": "OFF"
|
||||
# }
|
||||
# }
|
||||
# ])
|
||||
# vacuum_action_sequence.append({"action_name": "wait", "action_kwargs": {"time": 60}})
|
||||
|
||||
# # 关闭真空泵阀门、打开气源阀门
|
||||
# vacuum_action_sequence.append([
|
||||
# {
|
||||
# "device_id": vacuum_backbone["vacuum_valve"],
|
||||
# "action_name": "set_valve_position",
|
||||
# "action_kwargs": {
|
||||
# "command": "CLOSED"
|
||||
# }
|
||||
# },
|
||||
# {
|
||||
# "device_id": vacuum_backbone["gas_valve"],
|
||||
# "action_name": "set_valve_position",
|
||||
# "action_kwargs": {
|
||||
# "command": "OPEN"
|
||||
# }
|
||||
# }
|
||||
# ])
|
||||
|
||||
# # 关闭真空泵、打开气源
|
||||
# vacuum_action_sequence.append([
|
||||
# {
|
||||
# "device_id": vacuum_backbone["pump"],
|
||||
# "action_name": "set_status",
|
||||
# "action_kwargs": {
|
||||
# "string": "OFF"
|
||||
# }
|
||||
# },
|
||||
# {
|
||||
# "device_id": vacuum_backbone["gas"],
|
||||
# "action_name": "set_status",
|
||||
# "action_kwargs": {
|
||||
# "string": "ON"
|
||||
# }
|
||||
# }
|
||||
# ])
|
||||
# vacuum_action_sequence.append({"action_name": "wait", "action_kwargs": {"time": 60}})
|
||||
|
||||
# # 关闭气源
|
||||
# vacuum_action_sequence.append(
|
||||
# {
|
||||
# "device_id": vacuum_backbone["gas"],
|
||||
# "action_name": "set_status",
|
||||
# "action_kwargs": {
|
||||
# "string": "OFF"
|
||||
# }
|
||||
# }
|
||||
# )
|
||||
|
||||
# # 关闭阀门
|
||||
# vacuum_action_sequence.append(
|
||||
# {
|
||||
# "device_id": vacuum_backbone["gas_valve"],
|
||||
# "action_name": "set_valve_position",
|
||||
# "action_kwargs": {
|
||||
# "command": "CLOSED"
|
||||
# }
|
||||
# }
|
||||
# )
|
||||
# return vacuum_action_sequence
|
||||
@@ -4,128 +4,99 @@ import logging
|
||||
import re
|
||||
from .utils.vessel_parser import get_vessel
|
||||
from .utils.unit_parser import parse_time_input
|
||||
from .utils.logger_util import debug_print
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def debug_print(message):
|
||||
"""调试输出"""
|
||||
logger.info(f"[EVAPORATE] {message}")
|
||||
|
||||
|
||||
def find_rotavap_device(G: nx.DiGraph, vessel: str = None) -> Optional[str]:
|
||||
"""
|
||||
在组态图中查找旋转蒸发仪设备
|
||||
|
||||
|
||||
Args:
|
||||
G: 设备图
|
||||
vessel: 指定的设备名称(可选)
|
||||
|
||||
|
||||
Returns:
|
||||
str: 找到的旋转蒸发仪设备ID,如果没找到返回None
|
||||
"""
|
||||
debug_print("🔍 开始查找旋转蒸发仪设备... 🌪️")
|
||||
|
||||
# 如果指定了vessel,先检查是否存在且是旋转蒸发仪
|
||||
if vessel:
|
||||
debug_print(f"🎯 检查指定设备: {vessel} 🔧")
|
||||
if vessel in G.nodes():
|
||||
node_data = G.nodes[vessel]
|
||||
node_class = node_data.get('class', '')
|
||||
node_type = node_data.get('type', '')
|
||||
|
||||
debug_print(f"📋 设备信息 {vessel}: class={node_class}, type={node_type}")
|
||||
|
||||
# 检查是否为旋转蒸发仪
|
||||
|
||||
if any(keyword in str(node_class).lower() for keyword in ['rotavap', 'rotary', 'evaporat']):
|
||||
debug_print(f"🎉 找到指定的旋转蒸发仪: {vessel} ✨")
|
||||
debug_print(f"找到指定的旋转蒸发仪: {vessel}")
|
||||
return vessel
|
||||
elif node_type == 'device':
|
||||
debug_print(f"✅ 指定设备存在,尝试直接使用: {vessel} 🔧")
|
||||
debug_print(f"指定设备存在,尝试直接使用: {vessel}")
|
||||
return vessel
|
||||
else:
|
||||
debug_print(f"❌ 指定的设备 {vessel} 不存在 😞")
|
||||
|
||||
|
||||
# 在所有设备中查找旋转蒸发仪
|
||||
debug_print("🔎 在所有设备中搜索旋转蒸发仪... 🕵️♀️")
|
||||
rotavap_candidates = []
|
||||
|
||||
|
||||
for node_id, node_data in G.nodes(data=True):
|
||||
node_class = node_data.get('class', '')
|
||||
node_type = node_data.get('type', '')
|
||||
|
||||
# 跳过非设备节点
|
||||
|
||||
if node_type != 'device':
|
||||
continue
|
||||
|
||||
# 检查设备类型
|
||||
|
||||
if any(keyword in str(node_class).lower() for keyword in ['rotavap', 'rotary', 'evaporat']):
|
||||
rotavap_candidates.append(node_id)
|
||||
debug_print(f"🌟 找到旋转蒸发仪候选: {node_id} (class: {node_class}) 🌪️")
|
||||
elif any(keyword in str(node_id).lower() for keyword in ['rotavap', 'rotary', 'evaporat']):
|
||||
rotavap_candidates.append(node_id)
|
||||
debug_print(f"🌟 找到旋转蒸发仪候选 (按名称): {node_id} 🌪️")
|
||||
|
||||
|
||||
if rotavap_candidates:
|
||||
selected = rotavap_candidates[0] # 选择第一个找到的
|
||||
debug_print(f"🎯 选择旋转蒸发仪: {selected} 🏆")
|
||||
selected = rotavap_candidates[0]
|
||||
debug_print(f"选择旋转蒸发仪: {selected}")
|
||||
return selected
|
||||
|
||||
debug_print("😭 未找到旋转蒸发仪设备 💔")
|
||||
|
||||
debug_print("未找到旋转蒸发仪设备")
|
||||
return None
|
||||
|
||||
def find_connected_vessel(G: nx.DiGraph, rotavap_device: str) -> Optional[str]:
|
||||
"""
|
||||
查找与旋转蒸发仪连接的容器
|
||||
|
||||
Args:
|
||||
G: 设备图
|
||||
rotavap_device: 旋转蒸发仪设备ID
|
||||
|
||||
Returns:
|
||||
str: 连接的容器ID,如果没找到返回None
|
||||
"""
|
||||
debug_print(f"🔗 查找与 {rotavap_device} 连接的容器... 🥽")
|
||||
|
||||
# 查看旋转蒸发仪的子设备
|
||||
rotavap_data = G.nodes[rotavap_device]
|
||||
children = rotavap_data.get('children', [])
|
||||
|
||||
debug_print(f"👶 检查子设备: {children}")
|
||||
|
||||
for child_id in children:
|
||||
if child_id in G.nodes():
|
||||
child_data = G.nodes[child_id]
|
||||
child_type = child_data.get('type', '')
|
||||
|
||||
|
||||
if child_type == 'container':
|
||||
debug_print(f"🎉 找到连接的容器: {child_id} 🥽✨")
|
||||
debug_print(f"找到连接的容器: {child_id}")
|
||||
return child_id
|
||||
|
||||
# 查看邻接的容器
|
||||
debug_print("🤝 检查邻接设备...")
|
||||
|
||||
for neighbor in G.neighbors(rotavap_device):
|
||||
neighbor_data = G.nodes[neighbor]
|
||||
neighbor_type = neighbor_data.get('type', '')
|
||||
|
||||
|
||||
if neighbor_type == 'container':
|
||||
debug_print(f"🎉 找到邻接的容器: {neighbor} 🥽✨")
|
||||
debug_print(f"找到邻接的容器: {neighbor}")
|
||||
return neighbor
|
||||
|
||||
debug_print("😞 未找到连接的容器 💔")
|
||||
|
||||
debug_print("未找到连接的容器")
|
||||
return None
|
||||
|
||||
def generate_evaporate_protocol(
|
||||
G: nx.DiGraph,
|
||||
vessel: dict, # 🔧 修改:从字符串改为字典类型
|
||||
vessel: dict,
|
||||
pressure: float = 0.1,
|
||||
temp: float = 60.0,
|
||||
time: Union[str, float] = "180", # 🔧 修改:支持字符串时间
|
||||
time: Union[str, float] = "180",
|
||||
stir_speed: float = 100.0,
|
||||
solvent: str = "",
|
||||
**kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
生成蒸发操作的协议序列 - 支持单位和体积运算
|
||||
|
||||
|
||||
Args:
|
||||
G: 设备图
|
||||
vessel: 容器字典(从XDL传入)
|
||||
@@ -135,27 +106,16 @@ def generate_evaporate_protocol(
|
||||
stir_speed: 旋转速度 (RPM),默认100
|
||||
solvent: 溶剂名称(用于参数优化)
|
||||
**kwargs: 其他参数(兼容性)
|
||||
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 动作序列
|
||||
"""
|
||||
|
||||
# 🔧 核心修改:从字典中提取容器ID
|
||||
|
||||
vessel_id, vessel_data = get_vessel(vessel)
|
||||
|
||||
debug_print("🌟" * 20)
|
||||
debug_print("🌪️ 开始生成蒸发协议(支持单位和体积运算)✨")
|
||||
debug_print(f"📝 输入参数:")
|
||||
debug_print(f" 🥽 vessel: {vessel} (ID: {vessel_id})")
|
||||
debug_print(f" 💨 pressure: {pressure} bar")
|
||||
debug_print(f" 🌡️ temp: {temp}°C")
|
||||
debug_print(f" ⏰ time: {time} (类型: {type(time)})")
|
||||
debug_print(f" 🌪️ stir_speed: {stir_speed} RPM")
|
||||
debug_print(f" 🧪 solvent: '{solvent}'")
|
||||
debug_print("🌟" * 20)
|
||||
|
||||
# 🔧 新增:记录蒸发前的容器状态
|
||||
debug_print("🔍 记录蒸发前容器状态...")
|
||||
|
||||
debug_print(f"开始生成蒸发协议: vessel={vessel_id}, pressure={pressure}, temp={temp}, time={time}")
|
||||
|
||||
# 记录蒸发前的容器状态
|
||||
original_liquid_volume = 0.0
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
@@ -163,168 +123,97 @@ def generate_evaporate_protocol(
|
||||
original_liquid_volume = current_volume[0]
|
||||
elif isinstance(current_volume, (int, float)):
|
||||
original_liquid_volume = current_volume
|
||||
debug_print(f"📊 蒸发前液体体积: {original_liquid_volume:.2f}mL")
|
||||
|
||||
# === 步骤1: 查找旋转蒸发仪设备 ===
|
||||
debug_print("📍 步骤1: 查找旋转蒸发仪设备... 🔍")
|
||||
|
||||
# 验证vessel参数
|
||||
if not vessel_id:
|
||||
debug_print("❌ vessel 参数不能为空! 😱")
|
||||
raise ValueError("vessel 参数不能为空")
|
||||
|
||||
|
||||
# 查找旋转蒸发仪设备
|
||||
if not vessel_id:
|
||||
raise ValueError("vessel 参数不能为空")
|
||||
|
||||
rotavap_device = find_rotavap_device(G, vessel_id)
|
||||
if not rotavap_device:
|
||||
debug_print("💥 未找到旋转蒸发仪设备! 😭")
|
||||
raise ValueError(f"未找到旋转蒸发仪设备。请检查组态图中是否包含 class 包含 'rotavap'、'rotary' 或 'evaporat' 的设备")
|
||||
|
||||
debug_print(f"🎉 成功找到旋转蒸发仪: {rotavap_device} ✨")
|
||||
|
||||
# === 步骤2: 确定目标容器 ===
|
||||
debug_print("📍 步骤2: 确定目标容器... 🥽")
|
||||
|
||||
|
||||
# 确定目标容器
|
||||
target_vessel = vessel_id
|
||||
|
||||
# 如果vessel就是旋转蒸发仪设备,查找连接的容器
|
||||
|
||||
if vessel_id == rotavap_device:
|
||||
debug_print("🔄 vessel就是旋转蒸发仪,查找连接的容器...")
|
||||
connected_vessel = find_connected_vessel(G, rotavap_device)
|
||||
if connected_vessel:
|
||||
target_vessel = connected_vessel
|
||||
debug_print(f"✅ 使用连接的容器: {target_vessel} 🥽✨")
|
||||
else:
|
||||
debug_print(f"⚠️ 未找到连接的容器,使用设备本身: {rotavap_device} 🔧")
|
||||
target_vessel = rotavap_device
|
||||
elif vessel_id in G.nodes() and G.nodes[vessel_id].get('type') == 'container':
|
||||
debug_print(f"✅ 使用指定的容器: {vessel_id} 🥽✨")
|
||||
target_vessel = vessel_id
|
||||
else:
|
||||
debug_print(f"⚠️ 容器 '{vessel_id}' 不存在或类型不正确,使用旋转蒸发仪设备: {rotavap_device} 🔧")
|
||||
target_vessel = rotavap_device
|
||||
|
||||
# === 🔧 新增:步骤3:单位解析处理 ===
|
||||
debug_print("📍 步骤3: 单位解析处理... ⚡")
|
||||
|
||||
# 解析时间
|
||||
|
||||
# 单位解析处理
|
||||
final_time = parse_time_input(time)
|
||||
debug_print(f"🎯 时间解析完成: {time} → {final_time}s ({final_time/60:.1f}分钟) ⏰✨")
|
||||
|
||||
# === 步骤4: 参数验证和修正 ===
|
||||
debug_print("📍 步骤4: 参数验证和修正... 🔧")
|
||||
|
||||
# 修正参数范围
|
||||
debug_print(f"时间解析: {time} -> {final_time}s ({final_time/60:.1f}分钟)")
|
||||
|
||||
# 参数验证和修正
|
||||
if pressure <= 0 or pressure > 1.0:
|
||||
debug_print(f"⚠️ 真空度 {pressure} bar 超出范围,修正为 0.1 bar 💨")
|
||||
pressure = 0.1
|
||||
else:
|
||||
debug_print(f"✅ 真空度 {pressure} bar 在正常范围内 💨")
|
||||
|
||||
|
||||
if temp < 10.0 or temp > 200.0:
|
||||
debug_print(f"⚠️ 温度 {temp}°C 超出范围,修正为 60°C 🌡️")
|
||||
temp = 60.0
|
||||
else:
|
||||
debug_print(f"✅ 温度 {temp}°C 在正常范围内 🌡️")
|
||||
|
||||
|
||||
if final_time <= 0:
|
||||
debug_print(f"⚠️ 时间 {final_time}s 无效,修正为 180s (3分钟) ⏰")
|
||||
final_time = 180.0
|
||||
else:
|
||||
debug_print(f"✅ 时间 {final_time}s ({final_time/60:.1f}分钟) 有效 ⏰")
|
||||
|
||||
|
||||
if stir_speed < 10.0 or stir_speed > 300.0:
|
||||
debug_print(f"⚠️ 旋转速度 {stir_speed} RPM 超出范围,修正为 100 RPM 🌪️")
|
||||
stir_speed = 100.0
|
||||
else:
|
||||
debug_print(f"✅ 旋转速度 {stir_speed} RPM 在正常范围内 🌪️")
|
||||
|
||||
|
||||
# 根据溶剂优化参数
|
||||
if solvent:
|
||||
debug_print(f"🧪 根据溶剂 '{solvent}' 优化参数... 🔬")
|
||||
solvent_lower = solvent.lower()
|
||||
|
||||
|
||||
if any(s in solvent_lower for s in ['water', 'aqueous', 'h2o']):
|
||||
temp = max(temp, 80.0)
|
||||
pressure = max(pressure, 0.2)
|
||||
debug_print("💧 水系溶剂:提高温度和真空度 🌡️💨")
|
||||
elif any(s in solvent_lower for s in ['ethanol', 'methanol', 'acetone']):
|
||||
temp = min(temp, 50.0)
|
||||
pressure = min(pressure, 0.05)
|
||||
debug_print("🍺 易挥发溶剂:降低温度和真空度 🌡️💨")
|
||||
elif any(s in solvent_lower for s in ['dmso', 'dmi', 'toluene']):
|
||||
temp = max(temp, 100.0)
|
||||
pressure = min(pressure, 0.01)
|
||||
debug_print("🔥 高沸点溶剂:提高温度,降低真空度 🌡️💨")
|
||||
else:
|
||||
debug_print("🧪 通用溶剂,使用标准参数 ✨")
|
||||
else:
|
||||
debug_print("🤷♀️ 未指定溶剂,使用默认参数 ✨")
|
||||
|
||||
debug_print(f"🎯 最终参数: pressure={pressure} bar 💨, temp={temp}°C 🌡️, time={final_time}s ⏰, stir_speed={stir_speed} RPM 🌪️")
|
||||
|
||||
# === 🔧 新增:步骤5:蒸发体积计算 ===
|
||||
debug_print("📍 步骤5: 蒸发体积计算... 📊")
|
||||
|
||||
# 根据温度、真空度、时间和溶剂类型估算蒸发量
|
||||
|
||||
debug_print(f"最终参数: pressure={pressure}bar, temp={temp}°C, time={final_time}s, stir_speed={stir_speed}RPM")
|
||||
|
||||
# 蒸发体积计算
|
||||
evaporation_volume = 0.0
|
||||
if original_liquid_volume > 0:
|
||||
# 基础蒸发速率(mL/min)
|
||||
base_evap_rate = 0.5 # 基础速率
|
||||
|
||||
# 温度系数(高温蒸发更快)
|
||||
base_evap_rate = 0.5
|
||||
temp_factor = 1.0 + (temp - 25.0) / 100.0
|
||||
|
||||
# 真空系数(真空度越高蒸发越快)
|
||||
vacuum_factor = 1.0 + (1.0 - pressure) * 2.0
|
||||
|
||||
# 溶剂系数
|
||||
|
||||
solvent_factor = 1.0
|
||||
if solvent:
|
||||
solvent_lower = solvent.lower()
|
||||
if any(s in solvent_lower for s in ['water', 'h2o']):
|
||||
solvent_factor = 0.8 # 水蒸发较慢
|
||||
solvent_factor = 0.8
|
||||
elif any(s in solvent_lower for s in ['ethanol', 'methanol', 'acetone']):
|
||||
solvent_factor = 1.5 # 易挥发溶剂蒸发快
|
||||
solvent_factor = 1.5
|
||||
elif any(s in solvent_lower for s in ['dmso', 'dmi']):
|
||||
solvent_factor = 0.3 # 高沸点溶剂蒸发慢
|
||||
|
||||
# 计算总蒸发量
|
||||
solvent_factor = 0.3
|
||||
|
||||
total_evap_rate = base_evap_rate * temp_factor * vacuum_factor * solvent_factor
|
||||
evaporation_volume = min(
|
||||
original_liquid_volume * 0.95, # 最多蒸发95%
|
||||
total_evap_rate * (final_time / 60.0) # 时间相关的蒸发量
|
||||
original_liquid_volume * 0.95,
|
||||
total_evap_rate * (final_time / 60.0)
|
||||
)
|
||||
|
||||
debug_print(f"📊 蒸发量计算:")
|
||||
debug_print(f" - 基础蒸发速率: {base_evap_rate} mL/min")
|
||||
debug_print(f" - 温度系数: {temp_factor:.2f} (基于 {temp}°C)")
|
||||
debug_print(f" - 真空系数: {vacuum_factor:.2f} (基于 {pressure} bar)")
|
||||
debug_print(f" - 溶剂系数: {solvent_factor:.2f} ({solvent or '通用'})")
|
||||
debug_print(f" - 总蒸发速率: {total_evap_rate:.2f} mL/min")
|
||||
debug_print(f" - 预计蒸发量: {evaporation_volume:.2f}mL ({evaporation_volume/original_liquid_volume*100:.1f}%)")
|
||||
|
||||
# === 步骤6: 生成动作序列 ===
|
||||
debug_print("📍 步骤6: 生成动作序列... 🎬")
|
||||
|
||||
|
||||
debug_print(f"预计蒸发量: {evaporation_volume:.2f}mL ({evaporation_volume/original_liquid_volume*100:.1f}%)")
|
||||
|
||||
# 生成动作序列
|
||||
action_sequence = []
|
||||
|
||||
|
||||
# 1. 等待稳定
|
||||
debug_print(" 🔄 动作1: 添加初始等待稳定... ⏳")
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": 10}
|
||||
})
|
||||
debug_print(" ✅ 初始等待动作已添加 ⏳✨")
|
||||
|
||||
|
||||
# 2. 执行蒸发
|
||||
debug_print(f" 🌪️ 动作2: 执行蒸发操作...")
|
||||
debug_print(f" 🔧 设备: {rotavap_device}")
|
||||
debug_print(f" 🥽 容器: {target_vessel}")
|
||||
debug_print(f" 💨 真空度: {pressure} bar")
|
||||
debug_print(f" 🌡️ 温度: {temp}°C")
|
||||
debug_print(f" ⏰ 时间: {final_time}s ({final_time/60:.1f}分钟)")
|
||||
debug_print(f" 🌪️ 旋转速度: {stir_speed} RPM")
|
||||
|
||||
evaporate_action = {
|
||||
"device_id": rotavap_device,
|
||||
"action_name": "evaporate",
|
||||
@@ -332,20 +221,17 @@ def generate_evaporate_protocol(
|
||||
"vessel": {"id": target_vessel},
|
||||
"pressure": float(pressure),
|
||||
"temp": float(temp),
|
||||
"time": float(final_time), # 🔧 强制转换为float类型
|
||||
"time": float(final_time),
|
||||
"stir_speed": float(stir_speed),
|
||||
"solvent": str(solvent)
|
||||
}
|
||||
}
|
||||
action_sequence.append(evaporate_action)
|
||||
debug_print(" ✅ 蒸发动作已添加 🌪️✨")
|
||||
|
||||
# 🔧 新增:蒸发过程中的体积变化
|
||||
debug_print(" 🔧 更新容器体积 - 蒸发过程...")
|
||||
|
||||
# 蒸发过程中的体积变化
|
||||
if evaporation_volume > 0:
|
||||
new_volume = max(0.0, original_liquid_volume - evaporation_volume)
|
||||
|
||||
# 更新vessel字典中的体积
|
||||
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
if isinstance(current_volume, list):
|
||||
@@ -357,15 +243,14 @@ def generate_evaporate_protocol(
|
||||
vessel["data"]["liquid_volume"] = new_volume
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = new_volume
|
||||
|
||||
# 🔧 同时更新图中的容器数据
|
||||
|
||||
if vessel_id in G.nodes():
|
||||
if 'data' not in G.nodes[vessel_id]:
|
||||
G.nodes[vessel_id]['data'] = {}
|
||||
|
||||
|
||||
vessel_node_data = G.nodes[vessel_id]['data']
|
||||
current_node_volume = vessel_node_data.get('liquid_volume', 0.0)
|
||||
|
||||
|
||||
if isinstance(current_node_volume, list):
|
||||
if len(current_node_volume) > 0:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'][0] = new_volume
|
||||
@@ -373,18 +258,16 @@ def generate_evaporate_protocol(
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = [new_volume]
|
||||
else:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = new_volume
|
||||
|
||||
debug_print(f" 📊 蒸发体积变化: {original_liquid_volume:.2f}mL → {new_volume:.2f}mL (-{evaporation_volume:.2f}mL)")
|
||||
|
||||
|
||||
debug_print(f"蒸发体积变化: {original_liquid_volume:.2f}mL -> {new_volume:.2f}mL (-{evaporation_volume:.2f}mL)")
|
||||
|
||||
# 3. 蒸发后等待
|
||||
debug_print(" 🔄 动作3: 添加蒸发后等待... ⏳")
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": 10}
|
||||
})
|
||||
debug_print(" ✅ 蒸发后等待动作已添加 ⏳✨")
|
||||
|
||||
# 🔧 新增:蒸发完成后的状态报告
|
||||
|
||||
# 最终状态
|
||||
final_liquid_volume = 0.0
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
@@ -392,19 +275,7 @@ def generate_evaporate_protocol(
|
||||
final_liquid_volume = current_volume[0]
|
||||
elif isinstance(current_volume, (int, float)):
|
||||
final_liquid_volume = current_volume
|
||||
|
||||
# === 总结 ===
|
||||
debug_print("🎊" * 20)
|
||||
debug_print(f"🎉 蒸发协议生成完成! ✨")
|
||||
debug_print(f"📊 总动作数: {len(action_sequence)} 个 📝")
|
||||
debug_print(f"🌪️ 旋转蒸发仪: {rotavap_device} 🔧")
|
||||
debug_print(f"🥽 目标容器: {target_vessel} 🧪")
|
||||
debug_print(f"⚙️ 蒸发参数: {pressure} bar 💨, {temp}°C 🌡️, {final_time}s ⏰, {stir_speed} RPM 🌪️")
|
||||
debug_print(f"⏱️ 预计总时间: {(final_time + 20)/60:.1f} 分钟 ⌛")
|
||||
debug_print(f"📊 体积变化:")
|
||||
debug_print(f" - 蒸发前: {original_liquid_volume:.2f}mL")
|
||||
debug_print(f" - 蒸发后: {final_liquid_volume:.2f}mL")
|
||||
debug_print(f" - 蒸发量: {evaporation_volume:.2f}mL ({evaporation_volume/max(original_liquid_volume, 0.01)*100:.1f}%)")
|
||||
debug_print("🎊" * 20)
|
||||
|
||||
|
||||
debug_print(f"蒸发协议生成完成: {len(action_sequence)} 个动作, 设备={rotavap_device}, 容器={target_vessel}")
|
||||
|
||||
return action_sequence
|
||||
|
||||
@@ -2,87 +2,64 @@ from typing import List, Dict, Any, Optional
|
||||
import networkx as nx
|
||||
import logging
|
||||
from .utils.vessel_parser import get_vessel
|
||||
from .utils.logger_util import debug_print
|
||||
from .pump_protocol import generate_pump_protocol_with_rinsing
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def debug_print(message):
|
||||
"""调试输出"""
|
||||
logger.info(f"[FILTER] {message}")
|
||||
|
||||
def find_filter_device(G: nx.DiGraph) -> str:
|
||||
"""查找过滤器设备"""
|
||||
debug_print("🔍 查找过滤器设备... 🌊")
|
||||
|
||||
# 查找过滤器设备
|
||||
for node in G.nodes():
|
||||
node_data = G.nodes[node]
|
||||
node_class = node_data.get('class', '') or ''
|
||||
|
||||
|
||||
if 'filter' in node_class.lower() or 'filter' in node.lower():
|
||||
debug_print(f"🎉 找到过滤器设备: {node} ✨")
|
||||
debug_print(f"找到过滤器设备: {node}")
|
||||
return node
|
||||
|
||||
# 如果没找到,寻找可能的过滤器名称
|
||||
debug_print("🔎 在预定义名称中搜索过滤器... 📋")
|
||||
|
||||
possible_names = ["filter", "filter_1", "virtual_filter", "filtration_unit"]
|
||||
for name in possible_names:
|
||||
if name in G.nodes():
|
||||
debug_print(f"🎉 找到过滤器设备: {name} ✨")
|
||||
debug_print(f"找到过滤器设备: {name}")
|
||||
return name
|
||||
|
||||
debug_print("😭 未找到过滤器设备 💔")
|
||||
|
||||
raise ValueError("未找到过滤器设备")
|
||||
|
||||
def validate_vessel(G: nx.DiGraph, vessel: str, vessel_type: str = "容器") -> None:
|
||||
"""验证容器是否存在"""
|
||||
debug_print(f"🔍 验证{vessel_type}: '{vessel}' 🧪")
|
||||
|
||||
if not vessel:
|
||||
debug_print(f"❌ {vessel_type}不能为空! 😱")
|
||||
raise ValueError(f"{vessel_type}不能为空")
|
||||
|
||||
|
||||
if vessel not in G.nodes():
|
||||
debug_print(f"❌ {vessel_type} '{vessel}' 不存在于系统中! 😞")
|
||||
raise ValueError(f"{vessel_type} '{vessel}' 不存在于系统中")
|
||||
|
||||
debug_print(f"✅ {vessel_type} '{vessel}' 验证通过 🎯")
|
||||
|
||||
def generate_filter_protocol(
|
||||
G: nx.DiGraph,
|
||||
vessel: dict, # 🔧 修改:从字符串改为字典类型
|
||||
vessel: dict,
|
||||
filtrate_vessel: dict = {"id": "waste"},
|
||||
**kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
生成过滤操作的协议序列 - 支持体积运算
|
||||
|
||||
|
||||
Args:
|
||||
G: 设备图
|
||||
vessel: 过滤容器字典(必需)- 包含需要过滤的混合物
|
||||
filtrate_vessel: 滤液容器名称(可选)- 如果提供则收集滤液
|
||||
**kwargs: 其他参数(兼容性)
|
||||
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 过滤操作的动作序列
|
||||
"""
|
||||
|
||||
# 🔧 核心修改:从字典中提取容器ID
|
||||
|
||||
vessel_id, vessel_data = get_vessel(vessel)
|
||||
filtrate_vessel_id, filtrate_vessel_data = get_vessel(filtrate_vessel)
|
||||
|
||||
debug_print("🌊" * 20)
|
||||
debug_print("🚀 开始生成过滤协议(支持体积运算)✨")
|
||||
debug_print(f"📝 输入参数:")
|
||||
debug_print(f" 🥽 vessel: {vessel} (ID: {vessel_id})")
|
||||
debug_print(f" 🧪 filtrate_vessel: {filtrate_vessel}")
|
||||
debug_print(f" ⚙️ 其他参数: {kwargs}")
|
||||
debug_print("🌊" * 20)
|
||||
|
||||
|
||||
debug_print(f"开始生成过滤协议: vessel={vessel_id}, filtrate_vessel={filtrate_vessel_id}")
|
||||
|
||||
action_sequence = []
|
||||
|
||||
# 🔧 新增:记录过滤前的容器状态
|
||||
debug_print("🔍 记录过滤前容器状态...")
|
||||
|
||||
# 记录过滤前的容器状态
|
||||
original_liquid_volume = 0.0
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
@@ -90,79 +67,45 @@ def generate_filter_protocol(
|
||||
original_liquid_volume = current_volume[0]
|
||||
elif isinstance(current_volume, (int, float)):
|
||||
original_liquid_volume = current_volume
|
||||
debug_print(f"📊 过滤前液体体积: {original_liquid_volume:.2f}mL")
|
||||
|
||||
|
||||
# === 参数验证 ===
|
||||
debug_print("📍 步骤1: 参数验证... 🔧")
|
||||
|
||||
# 验证必需参数
|
||||
debug_print(" 🔍 验证必需参数...")
|
||||
validate_vessel(G, vessel_id, "过滤容器") # 🔧 使用 vessel_id
|
||||
debug_print(" ✅ 必需参数验证完成 🎯")
|
||||
|
||||
# 验证可选参数
|
||||
debug_print(" 🔍 验证可选参数...")
|
||||
validate_vessel(G, vessel_id, "过滤容器")
|
||||
|
||||
if filtrate_vessel:
|
||||
validate_vessel(G, filtrate_vessel_id, "滤液容器")
|
||||
debug_print(" 🌊 模式: 过滤并收集滤液 💧")
|
||||
else:
|
||||
debug_print(" 🧱 模式: 过滤并收集固体 🔬")
|
||||
debug_print(" ✅ 可选参数验证完成 🎯")
|
||||
|
||||
|
||||
# === 查找设备 ===
|
||||
debug_print("📍 步骤2: 查找设备... 🔍")
|
||||
|
||||
try:
|
||||
debug_print(" 🔎 搜索过滤器设备...")
|
||||
filter_device = find_filter_device(G)
|
||||
debug_print(f" 🎉 使用过滤器设备: {filter_device} 🌊✨")
|
||||
|
||||
debug_print(f"使用过滤器设备: {filter_device}")
|
||||
except Exception as e:
|
||||
debug_print(f" ❌ 设备查找失败: {str(e)} 😭")
|
||||
raise ValueError(f"设备查找失败: {str(e)}")
|
||||
|
||||
# 🔧 新增:过滤效率和体积分配估算
|
||||
debug_print("📍 步骤2.5: 过滤体积分配估算... 📊")
|
||||
|
||||
# 估算过滤分离比例(基于经验数据)
|
||||
solid_ratio = 0.1 # 假设10%是固体(保留在过滤器上)
|
||||
liquid_ratio = 0.9 # 假设90%是液体(通过过滤器)
|
||||
volume_loss_ratio = 0.05 # 假设5%体积损失(残留在过滤器等)
|
||||
|
||||
# 从kwargs中获取过滤参数进行优化
|
||||
|
||||
# 过滤体积分配估算
|
||||
solid_ratio = 0.1
|
||||
liquid_ratio = 0.9
|
||||
volume_loss_ratio = 0.05
|
||||
|
||||
if "solid_content" in kwargs:
|
||||
try:
|
||||
solid_ratio = float(kwargs["solid_content"])
|
||||
liquid_ratio = 1.0 - solid_ratio
|
||||
debug_print(f"📋 使用指定的固体含量: {solid_ratio*100:.1f}%")
|
||||
except:
|
||||
debug_print("⚠️ 固体含量参数无效,使用默认值")
|
||||
|
||||
pass
|
||||
|
||||
if original_liquid_volume > 0:
|
||||
expected_filtrate_volume = original_liquid_volume * liquid_ratio * (1.0 - volume_loss_ratio)
|
||||
expected_solid_volume = original_liquid_volume * solid_ratio
|
||||
volume_loss = original_liquid_volume * volume_loss_ratio
|
||||
|
||||
debug_print(f"📊 过滤体积分配估算:")
|
||||
debug_print(f" - 原始体积: {original_liquid_volume:.2f}mL")
|
||||
debug_print(f" - 预计滤液体积: {expected_filtrate_volume:.2f}mL ({liquid_ratio*100:.1f}%)")
|
||||
debug_print(f" - 预计固体体积: {expected_solid_volume:.2f}mL ({solid_ratio*100:.1f}%)")
|
||||
debug_print(f" - 预计损失体积: {volume_loss:.2f}mL ({volume_loss_ratio*100:.1f}%)")
|
||||
|
||||
|
||||
# === 转移到过滤器(如果需要)===
|
||||
debug_print("📍 步骤3: 转移到过滤器... 🚚")
|
||||
|
||||
if vessel_id != filter_device: # 🔧 使用 vessel_id
|
||||
debug_print(f" 🚛 需要转移: {vessel_id} → {filter_device} 📦")
|
||||
|
||||
if vessel_id != filter_device:
|
||||
try:
|
||||
debug_print(" 🔄 开始执行转移操作...")
|
||||
# 使用pump protocol转移液体到过滤器
|
||||
transfer_actions = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
from_vessel={"id": vessel_id}, # 🔧 使用 vessel_id
|
||||
from_vessel={"id": vessel_id},
|
||||
to_vessel={"id": filter_device},
|
||||
volume=0.0, # 转移所有液体
|
||||
volume=0.0,
|
||||
amount="",
|
||||
time=0.0,
|
||||
viscous=False,
|
||||
@@ -173,88 +116,59 @@ def generate_filter_protocol(
|
||||
flowrate=2.0,
|
||||
transfer_flowrate=2.0
|
||||
)
|
||||
|
||||
|
||||
if transfer_actions:
|
||||
action_sequence.extend(transfer_actions)
|
||||
debug_print(f" ✅ 添加了 {len(transfer_actions)} 个转移动作 🚚✨")
|
||||
|
||||
# 🔧 新增:转移后更新容器体积
|
||||
debug_print(" 🔧 更新转移后的容器体积...")
|
||||
|
||||
# 原容器体积变为0(所有液体已转移)
|
||||
debug_print(f"添加了 {len(transfer_actions)} 个转移动作")
|
||||
|
||||
# 更新容器体积
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
if isinstance(current_volume, list):
|
||||
vessel["data"]["liquid_volume"] = [0.0] if len(current_volume) > 0 else [0.0]
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = 0.0
|
||||
|
||||
# 同时更新图中的容器数据
|
||||
|
||||
if vessel_id in G.nodes():
|
||||
if 'data' not in G.nodes[vessel_id]:
|
||||
G.nodes[vessel_id]['data'] = {}
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = 0.0
|
||||
|
||||
debug_print(f" 📊 转移完成,{vessel_id} 体积更新为 0.0mL")
|
||||
|
||||
else:
|
||||
debug_print(" ⚠️ 转移协议返回空序列 🤔")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f" ❌ 转移失败: {str(e)} 😞")
|
||||
debug_print(" 🔄 继续执行,可能是直接连接的过滤器 🤞")
|
||||
else:
|
||||
debug_print(" ✅ 过滤容器就是过滤器,无需转移 🎯")
|
||||
|
||||
debug_print(f"转移失败: {str(e)},继续执行")
|
||||
|
||||
# === 执行过滤操作 ===
|
||||
debug_print("📍 步骤4: 执行过滤操作... 🌊")
|
||||
|
||||
# 构建过滤动作参数
|
||||
debug_print(" ⚙️ 构建过滤参数...")
|
||||
filter_kwargs = {
|
||||
"vessel": {"id": filter_device}, # 过滤器设备
|
||||
"filtrate_vessel": {"id": filtrate_vessel_id}, # 滤液容器(可能为空)
|
||||
"vessel": {"id": filter_device},
|
||||
"filtrate_vessel": {"id": filtrate_vessel_id},
|
||||
"stir": kwargs.get("stir", False),
|
||||
"stir_speed": kwargs.get("stir_speed", 0.0),
|
||||
"temp": kwargs.get("temp", 25.0),
|
||||
"continue_heatchill": kwargs.get("continue_heatchill", False),
|
||||
"volume": kwargs.get("volume", 0.0) # 0表示过滤所有
|
||||
"volume": kwargs.get("volume", 0.0)
|
||||
}
|
||||
|
||||
debug_print(f" 📋 过滤参数: {filter_kwargs}")
|
||||
debug_print(" 🌊 开始过滤操作...")
|
||||
|
||||
# 过滤动作
|
||||
|
||||
filter_action = {
|
||||
"device_id": filter_device,
|
||||
"action_name": "filter",
|
||||
"action_kwargs": filter_kwargs
|
||||
}
|
||||
action_sequence.append(filter_action)
|
||||
debug_print(" ✅ 过滤动作已添加 🌊✨")
|
||||
|
||||
|
||||
# 过滤后等待
|
||||
debug_print(" ⏳ 添加过滤后等待...")
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": 10.0}
|
||||
})
|
||||
debug_print(" ✅ 过滤后等待动作已添加 ⏰✨")
|
||||
|
||||
|
||||
# === 收集滤液(如果需要)===
|
||||
debug_print("📍 步骤5: 收集滤液... 💧")
|
||||
|
||||
if filtrate_vessel_id and filtrate_vessel_id not in G.neighbors(filter_device):
|
||||
debug_print(f" 🧪 收集滤液: {filter_device} → {filtrate_vessel_id} 💧")
|
||||
|
||||
try:
|
||||
debug_print(" 🔄 开始执行收集操作...")
|
||||
# 使用pump protocol收集滤液
|
||||
collect_actions = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
from_vessel=filter_device,
|
||||
to_vessel=filtrate_vessel,
|
||||
volume=0.0, # 收集所有滤液
|
||||
volume=0.0,
|
||||
amount="",
|
||||
time=0.0,
|
||||
viscous=False,
|
||||
@@ -265,19 +179,15 @@ def generate_filter_protocol(
|
||||
flowrate=2.0,
|
||||
transfer_flowrate=2.0
|
||||
)
|
||||
|
||||
|
||||
if collect_actions:
|
||||
action_sequence.extend(collect_actions)
|
||||
debug_print(f" ✅ 添加了 {len(collect_actions)} 个收集动作 🧪✨")
|
||||
|
||||
# 🔧 新增:收集滤液后的体积更新
|
||||
debug_print(" 🔧 更新滤液容器体积...")
|
||||
|
||||
# 更新filtrate_vessel在图中的体积(如果它是节点)
|
||||
|
||||
# 更新滤液容器体积
|
||||
if filtrate_vessel_id in G.nodes():
|
||||
if 'data' not in G.nodes[filtrate_vessel_id]:
|
||||
G.nodes[filtrate_vessel_id]['data'] = {}
|
||||
|
||||
|
||||
current_filtrate_volume = G.nodes[filtrate_vessel_id]['data'].get('liquid_volume', 0.0)
|
||||
if isinstance(current_filtrate_volume, list):
|
||||
if len(current_filtrate_volume) > 0:
|
||||
@@ -286,58 +196,37 @@ def generate_filter_protocol(
|
||||
G.nodes[filtrate_vessel_id]['data']['liquid_volume'] = [expected_filtrate_volume]
|
||||
else:
|
||||
G.nodes[filtrate_vessel_id]['data']['liquid_volume'] = current_filtrate_volume + expected_filtrate_volume
|
||||
|
||||
debug_print(f" 📊 滤液容器 {filtrate_vessel_id} 体积增加 {expected_filtrate_volume:.2f}mL")
|
||||
|
||||
else:
|
||||
debug_print(" ⚠️ 收集协议返回空序列 🤔")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f" ❌ 收集滤液失败: {str(e)} 😞")
|
||||
debug_print(" 🔄 继续执行,可能滤液直接流入指定容器 🤞")
|
||||
else:
|
||||
debug_print(" 🧱 未指定滤液容器,固体保留在过滤器中 🔬")
|
||||
|
||||
# 🔧 新增:过滤完成后的容器状态更新
|
||||
debug_print("📍 步骤5.5: 过滤完成后状态更新... 📊")
|
||||
|
||||
debug_print(f"收集滤液失败: {str(e)},继续执行")
|
||||
|
||||
# 过滤完成后容器状态更新
|
||||
if vessel_id == filter_device:
|
||||
# 如果过滤容器就是过滤器,需要更新其体积状态
|
||||
if original_liquid_volume > 0:
|
||||
if filtrate_vessel:
|
||||
# 收集滤液模式:过滤器中主要保留固体
|
||||
remaining_volume = expected_solid_volume
|
||||
debug_print(f" 🧱 过滤器中保留固体: {remaining_volume:.2f}mL")
|
||||
else:
|
||||
# 保留固体模式:过滤器中保留所有物质
|
||||
remaining_volume = original_liquid_volume * (1.0 - volume_loss_ratio)
|
||||
debug_print(f" 🔬 过滤器中保留所有物质: {remaining_volume:.2f}mL")
|
||||
|
||||
# 更新vessel字典中的体积
|
||||
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
if isinstance(current_volume, list):
|
||||
vessel["data"]["liquid_volume"] = [remaining_volume] if len(current_volume) > 0 else [remaining_volume]
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = remaining_volume
|
||||
|
||||
# 同时更新图中的容器数据
|
||||
|
||||
if vessel_id in G.nodes():
|
||||
if 'data' not in G.nodes[vessel_id]:
|
||||
G.nodes[vessel_id]['data'] = {}
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = remaining_volume
|
||||
|
||||
debug_print(f" 📊 过滤器 {vessel_id} 体积更新为: {remaining_volume:.2f}mL")
|
||||
|
||||
|
||||
# === 最终等待 ===
|
||||
debug_print("📍 步骤6: 最终等待... ⏰")
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": 5.0}
|
||||
})
|
||||
debug_print(" ✅ 最终等待动作已添加 ⏰✨")
|
||||
|
||||
# 🔧 新增:过滤完成后的状态报告
|
||||
|
||||
# 最终状态
|
||||
final_vessel_volume = 0.0
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
@@ -345,22 +234,7 @@ def generate_filter_protocol(
|
||||
final_vessel_volume = current_volume[0]
|
||||
elif isinstance(current_volume, (int, float)):
|
||||
final_vessel_volume = current_volume
|
||||
|
||||
# === 总结 ===
|
||||
debug_print("🎊" * 20)
|
||||
debug_print(f"🎉 过滤协议生成完成! ✨")
|
||||
debug_print(f"📊 总动作数: {len(action_sequence)} 个 📝")
|
||||
debug_print(f"🥽 过滤容器: {vessel_id} 🧪")
|
||||
debug_print(f"🌊 过滤器设备: {filter_device} 🔧")
|
||||
debug_print(f"💧 滤液容器: {filtrate_vessel_id or '无(保留固体)'} 🧱")
|
||||
debug_print(f"⏱️ 预计总时间: {(len(action_sequence) * 5):.0f} 秒 ⌛")
|
||||
if original_liquid_volume > 0:
|
||||
debug_print(f"📊 体积变化统计:")
|
||||
debug_print(f" - 过滤前体积: {original_liquid_volume:.2f}mL")
|
||||
debug_print(f" - 过滤后容器体积: {final_vessel_volume:.2f}mL")
|
||||
if filtrate_vessel:
|
||||
debug_print(f" - 预计滤液体积: {expected_filtrate_volume:.2f}mL")
|
||||
debug_print(f" - 预计损失体积: {volume_loss:.2f}mL")
|
||||
debug_print("🎊" * 20)
|
||||
|
||||
|
||||
debug_print(f"过滤协议生成完成: {len(action_sequence)} 个动作, 容器={vessel_id}, 过滤器={filter_device}")
|
||||
|
||||
return action_sequence
|
||||
|
||||
@@ -1,118 +1,24 @@
|
||||
from typing import List, Dict, Any, Union
|
||||
import networkx as nx
|
||||
import logging
|
||||
import re
|
||||
from .utils.vessel_parser import get_vessel
|
||||
from .utils.unit_parser import parse_time_input
|
||||
from .utils.vessel_parser import get_vessel, find_connected_heatchill
|
||||
from .utils.unit_parser import parse_time_input, parse_temperature_input
|
||||
from .utils.logger_util import debug_print
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def debug_print(message):
|
||||
"""调试输出"""
|
||||
logger.info(f"[HEATCHILL] {message}")
|
||||
|
||||
|
||||
def parse_temp_input(temp_input: Union[str, float], default_temp: float = 25.0) -> float:
|
||||
"""
|
||||
解析温度输入(统一函数)
|
||||
|
||||
Args:
|
||||
temp_input: 温度输入
|
||||
default_temp: 默认温度
|
||||
|
||||
Returns:
|
||||
float: 温度(°C)
|
||||
"""
|
||||
if not temp_input:
|
||||
return default_temp
|
||||
|
||||
# 🔢 数值输入
|
||||
if isinstance(temp_input, (int, float)):
|
||||
result = float(temp_input)
|
||||
debug_print(f"🌡️ 数值温度: {temp_input} → {result}°C")
|
||||
return result
|
||||
|
||||
# 📝 字符串输入
|
||||
temp_str = str(temp_input).lower().strip()
|
||||
debug_print(f"🔍 解析温度: '{temp_str}'")
|
||||
|
||||
# 🎯 特殊温度
|
||||
special_temps = {
|
||||
"room temperature": 25.0, "reflux": 78.0, "ice bath": 0.0,
|
||||
"boiling": 100.0, "hot": 60.0, "warm": 40.0, "cold": 10.0
|
||||
}
|
||||
|
||||
if temp_str in special_temps:
|
||||
result = special_temps[temp_str]
|
||||
debug_print(f"🎯 特殊温度: '{temp_str}' → {result}°C")
|
||||
return result
|
||||
|
||||
# 📐 正则解析(如 "256 °C")
|
||||
temp_pattern = r'(\d+(?:\.\d+)?)\s*°?[cf]?'
|
||||
match = re.search(temp_pattern, temp_str)
|
||||
|
||||
if match:
|
||||
result = float(match.group(1))
|
||||
debug_print(f"✅ 温度解析: '{temp_str}' → {result}°C")
|
||||
return result
|
||||
|
||||
debug_print(f"⚠️ 无法解析温度: '{temp_str}',使用默认值: {default_temp}°C")
|
||||
return default_temp
|
||||
|
||||
def find_connected_heatchill(G: nx.DiGraph, vessel: str) -> str:
|
||||
"""查找与指定容器相连的加热/冷却设备"""
|
||||
debug_print(f"🔍 查找加热设备,目标容器: {vessel}")
|
||||
|
||||
# 🔧 查找所有加热设备
|
||||
heatchill_nodes = []
|
||||
for node in G.nodes():
|
||||
node_data = G.nodes[node]
|
||||
node_class = node_data.get('class', '') or ''
|
||||
|
||||
if 'heatchill' in node_class.lower() or 'virtual_heatchill' in node_class:
|
||||
heatchill_nodes.append(node)
|
||||
debug_print(f"🎉 找到加热设备: {node}")
|
||||
|
||||
# 🔗 检查连接
|
||||
if vessel and heatchill_nodes:
|
||||
for heatchill in heatchill_nodes:
|
||||
if G.has_edge(heatchill, vessel) or G.has_edge(vessel, heatchill):
|
||||
debug_print(f"✅ 加热设备 '{heatchill}' 与容器 '{vessel}' 相连")
|
||||
return heatchill
|
||||
|
||||
# 🎯 使用第一个可用设备
|
||||
if heatchill_nodes:
|
||||
selected = heatchill_nodes[0]
|
||||
debug_print(f"🔧 使用第一个加热设备: {selected}")
|
||||
return selected
|
||||
|
||||
# 🆘 默认设备
|
||||
debug_print("⚠️ 未找到加热设备,使用默认设备")
|
||||
return "heatchill_1"
|
||||
|
||||
def validate_and_fix_params(temp: float, time: float, stir_speed: float) -> tuple:
|
||||
"""验证和修正参数"""
|
||||
# 🌡️ 温度范围验证
|
||||
if temp < -50.0 or temp > 300.0:
|
||||
debug_print(f"⚠️ 温度 {temp}°C 超出范围,修正为 25°C")
|
||||
temp = 25.0
|
||||
else:
|
||||
debug_print(f"✅ 温度 {temp}°C 在正常范围内")
|
||||
|
||||
# ⏰ 时间验证
|
||||
|
||||
if time < 0:
|
||||
debug_print(f"⚠️ 时间 {time}s 无效,修正为 300s")
|
||||
time = 300.0
|
||||
else:
|
||||
debug_print(f"✅ 时间 {time}s ({time/60:.1f}分钟) 有效")
|
||||
|
||||
# 🌪️ 搅拌速度验证
|
||||
|
||||
if stir_speed < 0 or stir_speed > 1500.0:
|
||||
debug_print(f"⚠️ 搅拌速度 {stir_speed} RPM 超出范围,修正为 300 RPM")
|
||||
stir_speed = 300.0
|
||||
else:
|
||||
debug_print(f"✅ 搅拌速度 {stir_speed} RPM 在正常范围内")
|
||||
|
||||
|
||||
return temp, time, stir_speed
|
||||
|
||||
def generate_heat_chill_protocol(
|
||||
@@ -131,7 +37,7 @@ def generate_heat_chill_protocol(
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
生成加热/冷却操作的协议序列 - 支持vessel字典
|
||||
|
||||
|
||||
Args:
|
||||
G: 设备图
|
||||
vessel: 容器字典(从XDL传入)
|
||||
@@ -145,82 +51,58 @@ def generate_heat_chill_protocol(
|
||||
stir_speed: 搅拌速度 (RPM)
|
||||
purpose: 操作目的说明
|
||||
**kwargs: 其他参数(兼容性)
|
||||
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 加热/冷却操作的动作序列
|
||||
"""
|
||||
|
||||
|
||||
# 🔧 核心修改:从字典中提取容器ID
|
||||
vessel_id, vessel_data = get_vessel(vessel)
|
||||
|
||||
debug_print("🌡️" * 20)
|
||||
debug_print("🚀 开始生成加热冷却协议(支持vessel字典)✨")
|
||||
debug_print(f"📝 输入参数:")
|
||||
debug_print(f" 🥽 vessel: {vessel} (ID: {vessel_id})")
|
||||
debug_print(f" 🌡️ temp: {temp}°C")
|
||||
debug_print(f" ⏰ time: {time}")
|
||||
debug_print(f" 🎯 temp_spec: {temp_spec}")
|
||||
debug_print(f" ⏱️ time_spec: {time_spec}")
|
||||
debug_print(f" 🌪️ stir: {stir} ({stir_speed} RPM)")
|
||||
debug_print(f" 🎭 purpose: '{purpose}'")
|
||||
debug_print("🌡️" * 20)
|
||||
|
||||
# 📋 参数验证
|
||||
debug_print("📍 步骤1: 参数验证... 🔧")
|
||||
if not vessel_id: # 🔧 使用 vessel_id
|
||||
debug_print("❌ vessel 参数不能为空! 😱")
|
||||
|
||||
debug_print(f"开始生成加热冷却协议: vessel={vessel_id}, temp={temp}°C, "
|
||||
f"time={time}, stir={stir} ({stir_speed} RPM), purpose='{purpose}'")
|
||||
|
||||
# 参数验证
|
||||
if not vessel_id:
|
||||
raise ValueError("vessel 参数不能为空")
|
||||
|
||||
if vessel_id not in G.nodes(): # 🔧 使用 vessel_id
|
||||
debug_print(f"❌ 容器 '{vessel_id}' 不存在于系统中! 😞")
|
||||
|
||||
if vessel_id not in G.nodes():
|
||||
raise ValueError(f"容器 '{vessel_id}' 不存在于系统中")
|
||||
|
||||
debug_print("✅ 基础参数验证通过 🎯")
|
||||
|
||||
# 🔄 参数解析
|
||||
debug_print("📍 步骤2: 参数解析... ⚡")
|
||||
|
||||
#温度解析:优先使用 temp_spec
|
||||
final_temp = parse_temp_input(temp_spec, temp) if temp_spec else temp
|
||||
|
||||
|
||||
# 参数解析
|
||||
# 温度解析:优先使用 temp_spec
|
||||
final_temp = parse_temperature_input(temp_spec, temp) if temp_spec else temp
|
||||
|
||||
# 时间解析:优先使用 time_spec
|
||||
final_time = parse_time_input(time_spec) if time_spec else parse_time_input(time)
|
||||
|
||||
|
||||
# 参数修正
|
||||
final_temp, final_time, stir_speed = validate_and_fix_params(final_temp, final_time, stir_speed)
|
||||
|
||||
debug_print(f"🎯 最终参数: temp={final_temp}°C, time={final_time}s, stir_speed={stir_speed} RPM")
|
||||
|
||||
# 🔍 查找设备
|
||||
debug_print("📍 步骤3: 查找加热设备... 🔍")
|
||||
|
||||
debug_print(f"最终参数: temp={final_temp}°C, time={final_time}s, stir_speed={stir_speed} RPM")
|
||||
|
||||
# 查找设备
|
||||
try:
|
||||
heatchill_id = find_connected_heatchill(G, vessel_id) # 🔧 使用 vessel_id
|
||||
debug_print(f"🎉 使用加热设备: {heatchill_id} ✨")
|
||||
heatchill_id = find_connected_heatchill(G, vessel_id)
|
||||
debug_print(f"使用加热设备: {heatchill_id}")
|
||||
except Exception as e:
|
||||
debug_print(f"❌ 设备查找失败: {str(e)} 😭")
|
||||
raise ValueError(f"无法找到加热设备: {str(e)}")
|
||||
|
||||
# 🚀 生成动作
|
||||
debug_print("📍 步骤4: 生成加热动作... 🔥")
|
||||
|
||||
# 🕐 模拟运行时间优化
|
||||
debug_print(" ⏱️ 检查模拟运行时间限制...")
|
||||
|
||||
# 生成动作
|
||||
# 模拟运行时间优化
|
||||
original_time = final_time
|
||||
simulation_time_limit = 100.0 # 模拟运行时间限制:100秒
|
||||
|
||||
|
||||
if final_time > simulation_time_limit:
|
||||
final_time = simulation_time_limit
|
||||
debug_print(f" 🎮 模拟运行优化: {original_time}s → {final_time}s (限制为{simulation_time_limit}s) ⚡")
|
||||
debug_print(f" 📊 时间缩短: {original_time/60:.1f}分钟 → {final_time/60:.1f}分钟 🚀")
|
||||
else:
|
||||
debug_print(f" ✅ 时间在限制内: {final_time}s ({final_time/60:.1f}分钟) 保持不变 🎯")
|
||||
|
||||
debug_print(f"模拟运行优化: {original_time}s → {final_time}s (限制为{simulation_time_limit}s)")
|
||||
|
||||
action_sequence = []
|
||||
heatchill_action = {
|
||||
"device_id": heatchill_id,
|
||||
"action_name": "heat_chill",
|
||||
"action_kwargs": {
|
||||
"vessel": {"id": vessel},
|
||||
"vessel": {"id": vessel_id},
|
||||
"temp": float(final_temp),
|
||||
"time": float(final_time),
|
||||
"stir": bool(stir),
|
||||
@@ -229,21 +111,10 @@ def generate_heat_chill_protocol(
|
||||
}
|
||||
}
|
||||
action_sequence.append(heatchill_action)
|
||||
debug_print("✅ 加热动作已添加 🔥✨")
|
||||
|
||||
# 显示时间调整信息
|
||||
if original_time != final_time:
|
||||
debug_print(f" 🎭 模拟优化说明: 原计划 {original_time/60:.1f}分钟,实际模拟 {final_time/60:.1f}分钟 ⚡")
|
||||
|
||||
# 🎊 总结
|
||||
debug_print("🎊" * 20)
|
||||
debug_print(f"🎉 加热冷却协议生成完成! ✨")
|
||||
debug_print(f"📊 总动作数: {len(action_sequence)} 个")
|
||||
debug_print(f"🥽 加热容器: {vessel_id}")
|
||||
debug_print(f"🌡️ 目标温度: {final_temp}°C")
|
||||
debug_print(f"⏰ 加热时间: {final_time}s ({final_time/60:.1f}分钟)")
|
||||
debug_print("🎊" * 20)
|
||||
|
||||
|
||||
debug_print(f"加热冷却协议生成完成: {len(action_sequence)} 个动作, "
|
||||
f"vessel={vessel_id}, temp={final_temp}°C, time={final_time}s")
|
||||
|
||||
return action_sequence
|
||||
|
||||
def generate_heat_chill_to_temp_protocol(
|
||||
@@ -255,7 +126,7 @@ def generate_heat_chill_to_temp_protocol(
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""生成加热到指定温度的协议(简化版)"""
|
||||
vessel_id, _ = get_vessel(vessel)
|
||||
debug_print(f"🌡️ 生成加热到温度协议: {vessel_id} → {temp}°C")
|
||||
debug_print(f"生成加热到温度协议: {vessel_id} → {temp}°C")
|
||||
return generate_heat_chill_protocol(G, vessel, temp, time, **kwargs)
|
||||
|
||||
def generate_heat_chill_start_protocol(
|
||||
@@ -266,21 +137,19 @@ def generate_heat_chill_start_protocol(
|
||||
**kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""生成开始加热操作的协议序列"""
|
||||
|
||||
|
||||
# 🔧 核心修改:从字典中提取容器ID
|
||||
vessel_id, _ = get_vessel(vessel)
|
||||
|
||||
debug_print("🔥 开始生成启动加热协议 ✨")
|
||||
debug_print(f"🥽 vessel: {vessel} (ID: {vessel_id}), 🌡️ temp: {temp}°C")
|
||||
|
||||
|
||||
debug_print(f"生成启动加热协议: vessel={vessel_id}, temp={temp}°C")
|
||||
|
||||
# 基础验证
|
||||
if not vessel_id or vessel_id not in G.nodes(): # 🔧 使用 vessel_id
|
||||
debug_print("❌ 容器验证失败!")
|
||||
if not vessel_id or vessel_id not in G.nodes():
|
||||
raise ValueError("vessel 参数无效")
|
||||
|
||||
|
||||
# 查找设备
|
||||
heatchill_id = find_connected_heatchill(G, vessel_id) # 🔧 使用 vessel_id
|
||||
|
||||
heatchill_id = find_connected_heatchill(G, vessel_id)
|
||||
|
||||
# 生成动作
|
||||
action_sequence = [{
|
||||
"device_id": heatchill_id,
|
||||
@@ -291,8 +160,8 @@ def generate_heat_chill_start_protocol(
|
||||
"vessel": {"id": vessel_id},
|
||||
}
|
||||
}]
|
||||
|
||||
debug_print(f"✅ 启动加热协议生成完成 🎯")
|
||||
|
||||
debug_print(f"启动加热协议生成完成")
|
||||
return action_sequence
|
||||
|
||||
def generate_heat_chill_stop_protocol(
|
||||
@@ -301,21 +170,19 @@ def generate_heat_chill_stop_protocol(
|
||||
**kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""生成停止加热操作的协议序列"""
|
||||
|
||||
|
||||
# 🔧 核心修改:从字典中提取容器ID
|
||||
vessel_id, _ = get_vessel(vessel)
|
||||
|
||||
debug_print("🛑 开始生成停止加热协议 ✨")
|
||||
debug_print(f"🥽 vessel: {vessel} (ID: {vessel_id})")
|
||||
|
||||
|
||||
debug_print(f"生成停止加热协议: vessel={vessel_id}")
|
||||
|
||||
# 基础验证
|
||||
if not vessel_id or vessel_id not in G.nodes(): # 🔧 使用 vessel_id
|
||||
debug_print("❌ 容器验证失败!")
|
||||
if not vessel_id or vessel_id not in G.nodes():
|
||||
raise ValueError("vessel 参数无效")
|
||||
|
||||
|
||||
# 查找设备
|
||||
heatchill_id = find_connected_heatchill(G, vessel_id) # 🔧 使用 vessel_id
|
||||
|
||||
heatchill_id = find_connected_heatchill(G, vessel_id)
|
||||
|
||||
# 生成动作
|
||||
action_sequence = [{
|
||||
"device_id": heatchill_id,
|
||||
@@ -323,6 +190,6 @@ def generate_heat_chill_stop_protocol(
|
||||
"action_kwargs": {
|
||||
}
|
||||
}]
|
||||
|
||||
debug_print(f"✅ 停止加热协议生成完成 🎯")
|
||||
|
||||
debug_print(f"停止加热协议生成完成")
|
||||
return action_sequence
|
||||
|
||||
@@ -1,105 +1,50 @@
|
||||
import networkx as nx
|
||||
from typing import List, Dict, Any, Optional
|
||||
from .utils.vessel_parser import get_vessel
|
||||
|
||||
|
||||
def parse_temperature(temp_str: str) -> float:
|
||||
"""
|
||||
解析温度字符串,支持多种格式
|
||||
|
||||
Args:
|
||||
temp_str: 温度字符串(如 "45 °C", "45°C", "45")
|
||||
|
||||
Returns:
|
||||
float: 温度值(摄氏度)
|
||||
"""
|
||||
try:
|
||||
# 移除常见的温度单位和符号
|
||||
temp_clean = temp_str.replace("°C", "").replace("°", "").replace("C", "").strip()
|
||||
return float(temp_clean)
|
||||
except ValueError:
|
||||
print(f"HYDROGENATE: 无法解析温度 '{temp_str}',使用默认温度 25°C")
|
||||
return 25.0
|
||||
|
||||
|
||||
def parse_time(time_str: str) -> float:
|
||||
"""
|
||||
解析时间字符串,支持多种格式
|
||||
|
||||
Args:
|
||||
time_str: 时间字符串(如 "2 h", "120 min", "7200 s")
|
||||
|
||||
Returns:
|
||||
float: 时间值(秒)
|
||||
"""
|
||||
try:
|
||||
time_clean = time_str.lower().strip()
|
||||
|
||||
# 处理小时
|
||||
if "h" in time_clean:
|
||||
hours = float(time_clean.replace("h", "").strip())
|
||||
return hours * 3600.0
|
||||
|
||||
# 处理分钟
|
||||
if "min" in time_clean:
|
||||
minutes = float(time_clean.replace("min", "").strip())
|
||||
return minutes * 60.0
|
||||
|
||||
# 处理秒
|
||||
if "s" in time_clean:
|
||||
seconds = float(time_clean.replace("s", "").strip())
|
||||
return seconds
|
||||
|
||||
# 默认按小时处理
|
||||
return float(time_clean) * 3600.0
|
||||
|
||||
except ValueError:
|
||||
print(f"HYDROGENATE: 无法解析时间 '{time_str}',使用默认时间 2小时")
|
||||
return 7200.0 # 2小时
|
||||
from .utils.logger_util import debug_print
|
||||
from .utils.unit_parser import parse_temperature_input, parse_time_input
|
||||
|
||||
|
||||
def find_associated_solenoid_valve(G: nx.DiGraph, device_id: str) -> Optional[str]:
|
||||
"""查找与指定设备相关联的电磁阀"""
|
||||
solenoid_valves = [
|
||||
node for node in G.nodes()
|
||||
node for node in G.nodes()
|
||||
if ('solenoid' in (G.nodes[node].get('class') or '').lower()
|
||||
or 'solenoid_valve' in node)
|
||||
]
|
||||
|
||||
|
||||
# 通过网络连接查找直接相连的电磁阀
|
||||
for solenoid in solenoid_valves:
|
||||
if G.has_edge(device_id, solenoid) or G.has_edge(solenoid, device_id):
|
||||
return solenoid
|
||||
|
||||
|
||||
# 通过命名规则查找关联的电磁阀
|
||||
device_type = ""
|
||||
if 'gas' in device_id.lower():
|
||||
device_type = "gas"
|
||||
elif 'h2' in device_id.lower() or 'hydrogen' in device_id.lower():
|
||||
device_type = "gas"
|
||||
|
||||
|
||||
if device_type:
|
||||
for solenoid in solenoid_valves:
|
||||
if device_type in solenoid.lower():
|
||||
return solenoid
|
||||
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def find_connected_device(G: nx.DiGraph, vessel: str, device_type: str) -> str:
|
||||
"""
|
||||
查找与容器相连的指定类型设备
|
||||
|
||||
|
||||
Args:
|
||||
G: 网络图
|
||||
vessel: 容器名称
|
||||
device_type: 设备类型 ('heater', 'stirrer', 'gas_source')
|
||||
|
||||
|
||||
Returns:
|
||||
str: 设备ID,如果没有则返回None
|
||||
"""
|
||||
print(f"HYDROGENATE: 正在查找与容器 '{vessel}' 相连的 {device_type}...")
|
||||
|
||||
# 根据设备类型定义搜索关键词
|
||||
if device_type == 'heater':
|
||||
keywords = ['heater', 'heat', 'heatchill']
|
||||
@@ -112,40 +57,38 @@ def find_connected_device(G: nx.DiGraph, vessel: str, device_type: str) -> str:
|
||||
device_class = 'virtual_gas_source'
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
# 查找设备节点
|
||||
device_nodes = []
|
||||
for node in G.nodes():
|
||||
node_data = G.nodes[node]
|
||||
node_name = node.lower()
|
||||
node_class = node_data.get('class', '').lower()
|
||||
|
||||
# 通过名称匹配
|
||||
|
||||
if any(keyword in node_name for keyword in keywords):
|
||||
device_nodes.append(node)
|
||||
# 通过类型匹配
|
||||
elif device_class in node_class:
|
||||
device_nodes.append(node)
|
||||
|
||||
print(f"HYDROGENATE: 找到的{device_type}节点: {device_nodes}")
|
||||
|
||||
|
||||
debug_print(f"找到的{device_type}节点: {device_nodes}")
|
||||
|
||||
# 检查是否有设备与目标容器相连
|
||||
for device in device_nodes:
|
||||
if G.has_edge(device, vessel) or G.has_edge(vessel, device):
|
||||
print(f"HYDROGENATE: 找到与容器 '{vessel}' 相连的{device_type}: {device}")
|
||||
debug_print(f"找到与容器 '{vessel}' 相连的{device_type}: {device}")
|
||||
return device
|
||||
|
||||
|
||||
# 如果没有直接连接,查找距离最近的设备
|
||||
for device in device_nodes:
|
||||
try:
|
||||
path = nx.shortest_path(G, source=device, target=vessel)
|
||||
if len(path) <= 3: # 最多2个中间节点
|
||||
print(f"HYDROGENATE: 找到距离较近的{device_type}: {device}")
|
||||
debug_print(f"找到距离较近的{device_type}: {device}")
|
||||
return device
|
||||
except nx.NetworkXNoPath:
|
||||
continue
|
||||
|
||||
print(f"HYDROGENATE: 未找到与容器 '{vessel}' 相连的{device_type}")
|
||||
|
||||
debug_print(f"未找到与容器 '{vessel}' 相连的{device_type}")
|
||||
return None
|
||||
|
||||
|
||||
@@ -158,36 +101,31 @@ def generate_hydrogenate_protocol(
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
生成氢化反应协议序列 - 支持vessel字典
|
||||
|
||||
|
||||
Args:
|
||||
G: 有向图,节点为容器和设备
|
||||
vessel: 反应容器字典(从XDL传入)
|
||||
temp: 反应温度(如 "45 °C")
|
||||
time: 反应时间(如 "2 h")
|
||||
**kwargs: 其他可选参数,但不使用
|
||||
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 动作序列
|
||||
"""
|
||||
|
||||
|
||||
# 🔧 核心修改:从字典中提取容器ID
|
||||
vessel_id, vessel_data = get_vessel(vessel)
|
||||
|
||||
|
||||
action_sequence = []
|
||||
|
||||
|
||||
# 解析参数
|
||||
temperature = parse_temperature(temp)
|
||||
reaction_time = parse_time(time)
|
||||
|
||||
print("🧪" * 20)
|
||||
print(f"HYDROGENATE: 开始生成氢化反应协议(支持vessel字典)✨")
|
||||
print(f"📝 输入参数:")
|
||||
print(f" 🥽 vessel: {vessel} (ID: {vessel_id})")
|
||||
print(f" 🌡️ 反应温度: {temperature}°C")
|
||||
print(f" ⏰ 反应时间: {reaction_time/3600:.1f} 小时")
|
||||
print("🧪" * 20)
|
||||
|
||||
# 🔧 新增:记录氢化前的容器状态(可选,氢化反应通常不改变体积)
|
||||
temperature = parse_temperature_input(temp)
|
||||
reaction_time = parse_time_input(time)
|
||||
|
||||
debug_print(f"开始生成氢化反应协议: vessel={vessel_id}, "
|
||||
f"temp={temperature}°C, time={reaction_time/3600:.1f}h")
|
||||
|
||||
# 记录氢化前的容器状态
|
||||
original_liquid_volume = 0.0
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
@@ -195,47 +133,36 @@ def generate_hydrogenate_protocol(
|
||||
original_liquid_volume = current_volume[0]
|
||||
elif isinstance(current_volume, (int, float)):
|
||||
original_liquid_volume = current_volume
|
||||
print(f"📊 氢化前液体体积: {original_liquid_volume:.2f}mL")
|
||||
|
||||
|
||||
# 1. 验证目标容器存在
|
||||
print("📍 步骤1: 验证目标容器...")
|
||||
if vessel_id not in G.nodes(): # 🔧 使用 vessel_id
|
||||
print(f"⚠️ HYDROGENATE: 警告 - 容器 '{vessel_id}' 不存在于系统中,跳过氢化反应")
|
||||
if vessel_id not in G.nodes():
|
||||
debug_print(f"⚠️ 容器 '{vessel_id}' 不存在于系统中,跳过氢化反应")
|
||||
return action_sequence
|
||||
print(f"✅ 容器 '{vessel_id}' 验证通过")
|
||||
|
||||
|
||||
# 2. 查找相连的设备
|
||||
print("📍 步骤2: 查找相连设备...")
|
||||
heater_id = find_connected_device(G, vessel_id, 'heater') # 🔧 使用 vessel_id
|
||||
stirrer_id = find_connected_device(G, vessel_id, 'stirrer') # 🔧 使用 vessel_id
|
||||
gas_source_id = find_connected_device(G, vessel_id, 'gas_source') # 🔧 使用 vessel_id
|
||||
|
||||
print(f"🔧 设备配置:")
|
||||
print(f" 🔥 加热器: {heater_id or '未找到'}")
|
||||
print(f" 🌪️ 搅拌器: {stirrer_id or '未找到'}")
|
||||
print(f" 💨 气源: {gas_source_id or '未找到'}")
|
||||
|
||||
heater_id = find_connected_device(G, vessel_id, 'heater')
|
||||
stirrer_id = find_connected_device(G, vessel_id, 'stirrer')
|
||||
gas_source_id = find_connected_device(G, vessel_id, 'gas_source')
|
||||
|
||||
debug_print(f"设备配置: heater={heater_id or '未找到'}, "
|
||||
f"stirrer={stirrer_id or '未找到'}, gas={gas_source_id or '未找到'}")
|
||||
|
||||
# 3. 启动搅拌器
|
||||
print("📍 步骤3: 启动搅拌器...")
|
||||
if stirrer_id:
|
||||
print(f"🌪️ 启动搅拌器 {stirrer_id}")
|
||||
action_sequence.append({
|
||||
"device_id": stirrer_id,
|
||||
"action_name": "start_stir",
|
||||
"action_kwargs": {
|
||||
"vessel": vessel_id, # 🔧 使用 vessel_id
|
||||
"vessel": {"id": vessel_id},
|
||||
"stir_speed": 300.0,
|
||||
"purpose": "氢化反应: 开始搅拌"
|
||||
}
|
||||
})
|
||||
print("✅ 搅拌器启动动作已添加")
|
||||
else:
|
||||
print(f"⚠️ HYDROGENATE: 警告 - 未找到搅拌器,继续执行")
|
||||
|
||||
debug_print(f"⚠️ 未找到搅拌器,继续执行")
|
||||
|
||||
# 4. 启动气源(氢气)
|
||||
print("📍 步骤4: 启动氢气源...")
|
||||
if gas_source_id:
|
||||
print(f"💨 启动气源 {gas_source_id} (氢气)")
|
||||
action_sequence.append({
|
||||
"device_id": gas_source_id,
|
||||
"action_name": "set_status",
|
||||
@@ -243,11 +170,10 @@ def generate_hydrogenate_protocol(
|
||||
"string": "ON"
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
# 查找相关的电磁阀
|
||||
gas_solenoid = find_associated_solenoid_valve(G, gas_source_id)
|
||||
if gas_solenoid:
|
||||
print(f"🚪 开启气源电磁阀 {gas_solenoid}")
|
||||
action_sequence.append({
|
||||
"device_id": gas_solenoid,
|
||||
"action_name": "set_valve_position",
|
||||
@@ -255,12 +181,10 @@ def generate_hydrogenate_protocol(
|
||||
"command": "OPEN"
|
||||
}
|
||||
})
|
||||
print("✅ 氢气源启动动作已添加")
|
||||
else:
|
||||
print(f"⚠️ HYDROGENATE: 警告 - 未找到气源,继续执行")
|
||||
|
||||
debug_print(f"⚠️ 未找到气源,继续执行")
|
||||
|
||||
# 5. 等待气体稳定
|
||||
print("📍 步骤5: 等待气体环境稳定...")
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {
|
||||
@@ -268,22 +192,19 @@ def generate_hydrogenate_protocol(
|
||||
"description": "等待氢气环境稳定"
|
||||
}
|
||||
})
|
||||
print("✅ 气体稳定等待动作已添加")
|
||||
|
||||
|
||||
# 6. 启动加热器
|
||||
print("📍 步骤6: 启动加热反应...")
|
||||
if heater_id:
|
||||
print(f"🔥 启动加热器 {heater_id} 到 {temperature}°C")
|
||||
action_sequence.append({
|
||||
"device_id": heater_id,
|
||||
"action_name": "heat_chill_start",
|
||||
"action_kwargs": {
|
||||
"vessel": vessel_id, # 🔧 使用 vessel_id
|
||||
"vessel": {"id": vessel_id},
|
||||
"temp": temperature,
|
||||
"purpose": f"氢化反应: 加热到 {temperature}°C"
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
# 等待温度稳定
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
@@ -292,52 +213,38 @@ def generate_hydrogenate_protocol(
|
||||
"description": f"等待温度稳定到 {temperature}°C"
|
||||
}
|
||||
})
|
||||
|
||||
# 🕐 模拟运行时间优化
|
||||
print(" ⏰ 检查模拟运行时间限制...")
|
||||
|
||||
# 模拟运行时间优化
|
||||
original_reaction_time = reaction_time
|
||||
simulation_time_limit = 60.0 # 模拟运行时间限制:60秒
|
||||
|
||||
simulation_time_limit = 60.0
|
||||
|
||||
if reaction_time > simulation_time_limit:
|
||||
reaction_time = simulation_time_limit
|
||||
print(f" 🎮 模拟运行优化: {original_reaction_time}s → {reaction_time}s (限制为{simulation_time_limit}s)")
|
||||
print(f" 📊 时间缩短: {original_reaction_time/3600:.2f}小时 → {reaction_time/60:.1f}分钟")
|
||||
else:
|
||||
print(f" ✅ 时间在限制内: {reaction_time}s ({reaction_time/60:.1f}分钟) 保持不变")
|
||||
|
||||
debug_print(f"模拟运行优化: {original_reaction_time}s → {reaction_time}s")
|
||||
|
||||
# 保持反应温度
|
||||
action_sequence.append({
|
||||
"device_id": heater_id,
|
||||
"action_name": "heat_chill",
|
||||
"action_kwargs": {
|
||||
"vessel": vessel_id, # 🔧 使用 vessel_id
|
||||
"vessel": {"id": vessel_id},
|
||||
"temp": temperature,
|
||||
"time": reaction_time,
|
||||
"purpose": f"氢化反应: 保持 {temperature}°C,反应 {reaction_time/60:.1f}分钟" + (f" (模拟时间)" if original_reaction_time != reaction_time else "")
|
||||
}
|
||||
})
|
||||
|
||||
# 显示时间调整信息
|
||||
if original_reaction_time != reaction_time:
|
||||
print(f" 🎭 模拟优化说明: 原计划 {original_reaction_time/3600:.2f}小时,实际模拟 {reaction_time/60:.1f}分钟")
|
||||
|
||||
print("✅ 加热反应动作已添加")
|
||||
|
||||
|
||||
else:
|
||||
print(f"⚠️ HYDROGENATE: 警告 - 未找到加热器,使用室温反应")
|
||||
|
||||
# 🕐 室温反应也需要时间优化
|
||||
print(" ⏰ 检查室温反应模拟时间限制...")
|
||||
debug_print(f"⚠️ 未找到加热器,使用室温反应")
|
||||
|
||||
# 室温反应也需要时间优化
|
||||
original_reaction_time = reaction_time
|
||||
simulation_time_limit = 60.0 # 模拟运行时间限制:60秒
|
||||
|
||||
simulation_time_limit = 60.0
|
||||
|
||||
if reaction_time > simulation_time_limit:
|
||||
reaction_time = simulation_time_limit
|
||||
print(f" 🎮 室温反应时间优化: {original_reaction_time}s → {reaction_time}s")
|
||||
print(f" 📊 时间缩短: {original_reaction_time/3600:.2f}小时 → {reaction_time/60:.1f}分钟")
|
||||
else:
|
||||
print(f" ✅ 室温反应时间在限制内: {reaction_time}s 保持不变")
|
||||
|
||||
debug_print(f"室温反应时间优化: {original_reaction_time}s → {reaction_time}s")
|
||||
|
||||
# 室温反应,只等待时间
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
@@ -346,28 +253,19 @@ def generate_hydrogenate_protocol(
|
||||
"description": f"室温氢化反应 {reaction_time/60:.1f}分钟" + (f" (模拟时间)" if original_reaction_time != reaction_time else "")
|
||||
}
|
||||
})
|
||||
|
||||
# 显示时间调整信息
|
||||
if original_reaction_time != reaction_time:
|
||||
print(f" 🎭 室温反应优化说明: 原计划 {original_reaction_time/3600:.2f}小时,实际模拟 {reaction_time/60:.1f}分钟")
|
||||
|
||||
print("✅ 室温反应等待动作已添加")
|
||||
|
||||
|
||||
# 7. 停止加热
|
||||
print("📍 步骤7: 停止加热...")
|
||||
if heater_id:
|
||||
action_sequence.append({
|
||||
"device_id": heater_id,
|
||||
"action_name": "heat_chill_stop",
|
||||
"action_kwargs": {
|
||||
"vessel": vessel_id, # 🔧 使用 vessel_id
|
||||
"vessel": {"id": vessel_id},
|
||||
"purpose": "氢化反应完成,停止加热"
|
||||
}
|
||||
})
|
||||
print("✅ 停止加热动作已添加")
|
||||
|
||||
|
||||
# 8. 等待冷却
|
||||
print("📍 步骤8: 等待冷却...")
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {
|
||||
@@ -375,15 +273,12 @@ def generate_hydrogenate_protocol(
|
||||
"description": "等待反应混合物冷却"
|
||||
}
|
||||
})
|
||||
print("✅ 冷却等待动作已添加")
|
||||
|
||||
|
||||
# 9. 停止气源
|
||||
print("📍 步骤9: 停止氢气源...")
|
||||
if gas_source_id:
|
||||
# 先关闭电磁阀
|
||||
gas_solenoid = find_associated_solenoid_valve(G, gas_source_id)
|
||||
if gas_solenoid:
|
||||
print(f"🚪 关闭气源电磁阀 {gas_solenoid}")
|
||||
action_sequence.append({
|
||||
"device_id": gas_solenoid,
|
||||
"action_name": "set_valve_position",
|
||||
@@ -391,7 +286,7 @@ def generate_hydrogenate_protocol(
|
||||
"command": "CLOSED"
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
# 再关闭气源
|
||||
action_sequence.append({
|
||||
"device_id": gas_source_id,
|
||||
@@ -400,59 +295,24 @@ def generate_hydrogenate_protocol(
|
||||
"string": "OFF"
|
||||
}
|
||||
})
|
||||
print("✅ 氢气源停止动作已添加")
|
||||
|
||||
|
||||
# 10. 停止搅拌
|
||||
print("📍 步骤10: 停止搅拌...")
|
||||
if stirrer_id:
|
||||
action_sequence.append({
|
||||
"device_id": stirrer_id,
|
||||
"action_name": "stop_stir",
|
||||
"action_kwargs": {
|
||||
"vessel": vessel_id, # 🔧 使用 vessel_id
|
||||
"vessel": {"id": vessel_id},
|
||||
"purpose": "氢化反应完成,停止搅拌"
|
||||
}
|
||||
})
|
||||
print("✅ 停止搅拌动作已添加")
|
||||
|
||||
# 🔧 新增:氢化完成后的状态(氢化反应通常不改变体积)
|
||||
final_liquid_volume = original_liquid_volume # 氢化反应体积基本不变
|
||||
|
||||
|
||||
# 氢化完成后的状态(氢化反应通常不改变体积)
|
||||
final_liquid_volume = original_liquid_volume
|
||||
|
||||
# 总结
|
||||
print("🎊" * 20)
|
||||
print(f"🎉 氢化反应协议生成完成! ✨")
|
||||
print(f"📊 总动作数: {len(action_sequence)} 个")
|
||||
print(f"🥽 反应容器: {vessel_id}")
|
||||
print(f"🌡️ 反应温度: {temperature}°C")
|
||||
print(f"⏰ 反应时间: {reaction_time/60:.1f}分钟")
|
||||
print(f"⏱️ 预计总时间: {(reaction_time + 450)/3600:.1f} 小时")
|
||||
print(f"📊 体积状态:")
|
||||
print(f" - 反应前体积: {original_liquid_volume:.2f}mL")
|
||||
print(f" - 反应后体积: {final_liquid_volume:.2f}mL (氢化反应体积基本不变)")
|
||||
print("🎊" * 20)
|
||||
|
||||
debug_print(f"氢化反应协议生成完成: {len(action_sequence)} 个动作, "
|
||||
f"vessel={vessel_id}, temp={temperature}°C, time={reaction_time/60:.1f}min, "
|
||||
f"volume={original_liquid_volume:.2f}→{final_liquid_volume:.2f}mL")
|
||||
|
||||
return action_sequence
|
||||
|
||||
|
||||
# 测试函数
|
||||
def test_hydrogenate_protocol():
|
||||
"""测试氢化反应协议"""
|
||||
print("🧪 === HYDROGENATE PROTOCOL 测试 === ✨")
|
||||
|
||||
# 测试温度解析
|
||||
test_temps = ["45 °C", "45°C", "45", "25 C", "invalid"]
|
||||
for temp in test_temps:
|
||||
parsed = parse_temperature(temp)
|
||||
print(f"温度 '{temp}' -> {parsed}°C")
|
||||
|
||||
# 测试时间解析
|
||||
test_times = ["2 h", "120 min", "7200 s", "2", "invalid"]
|
||||
for time in test_times:
|
||||
parsed = parse_time(time)
|
||||
print(f"时间 '{time}' -> {parsed/3600:.1f} 小时")
|
||||
|
||||
print("✅ 测试完成 🎉")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_hydrogenate_protocol()
|
||||
@@ -2,99 +2,18 @@ import traceback
|
||||
import numpy as np
|
||||
import networkx as nx
|
||||
import asyncio
|
||||
import time as time_module # 🔧 重命名time模块
|
||||
import time as time_module # 重命名time模块
|
||||
from typing import List, Dict, Any
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from unilabos.compile.utils.vessel_parser import get_vessel
|
||||
from .utils.logger_util import debug_print
|
||||
from .utils.vessel_parser import get_vessel
|
||||
from .utils.resource_helper import get_resource_liquid_volume
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def debug_print(message):
|
||||
"""强制输出调试信息"""
|
||||
output = f"[TRANSFER] {message}"
|
||||
logger.info(output)
|
||||
|
||||
|
||||
def get_vessel_liquid_volume(G: nx.DiGraph, vessel: str) -> float:
|
||||
"""
|
||||
从容器节点的数据中获取液体体积
|
||||
"""
|
||||
debug_print(f"🔍 开始读取容器 '{vessel}' 的液体体积...")
|
||||
|
||||
if vessel not in G.nodes():
|
||||
logger.error(f"❌ 容器 '{vessel}' 不存在于系统图中")
|
||||
debug_print(f" - 系统中的容器: {list(G.nodes())}")
|
||||
return 0.0
|
||||
|
||||
vessel_data = G.nodes[vessel].get('data', {})
|
||||
debug_print(f"📋 容器 '{vessel}' 的数据结构: {vessel_data}")
|
||||
|
||||
total_volume = 0.0
|
||||
|
||||
# 方法1:检查 'liquid' 字段(列表格式)
|
||||
debug_print("🔍 方法1: 检查 'liquid' 字段...")
|
||||
if 'liquid' in vessel_data:
|
||||
liquids = vessel_data['liquid']
|
||||
debug_print(f" - liquid 字段类型: {type(liquids)}")
|
||||
debug_print(f" - liquid 字段内容: {liquids}")
|
||||
|
||||
if isinstance(liquids, list):
|
||||
debug_print(f" - liquid 是列表,包含 {len(liquids)} 个元素")
|
||||
for i, liquid in enumerate(liquids):
|
||||
debug_print(f" 液体 {i + 1}: {liquid}")
|
||||
if isinstance(liquid, dict):
|
||||
volume_keys = ['liquid_volume', 'volume', 'amount', 'quantity']
|
||||
for key in volume_keys:
|
||||
if key in liquid:
|
||||
try:
|
||||
vol = float(liquid[key])
|
||||
total_volume += vol
|
||||
debug_print(f" ✅ 从 '{key}' 读取体积: {vol}mL")
|
||||
break
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f" ⚠️ 无法转换 '{key}': {liquid[key]} -> {str(e)}")
|
||||
continue
|
||||
else:
|
||||
debug_print(f" - liquid 不是列表: {type(liquids)}")
|
||||
else:
|
||||
debug_print(" - 没有 'liquid' 字段")
|
||||
|
||||
# 方法2:检查直接的体积字段
|
||||
debug_print("🔍 方法2: 检查直接体积字段...")
|
||||
volume_keys = ['total_volume', 'volume', 'liquid_volume', 'amount', 'current_volume']
|
||||
for key in volume_keys:
|
||||
if key in vessel_data:
|
||||
try:
|
||||
vol = float(vessel_data[key])
|
||||
total_volume = max(total_volume, vol) # 取最大值
|
||||
debug_print(f" ✅ 从容器数据 '{key}' 读取体积: {vol}mL")
|
||||
break
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f" ⚠️ 无法转换 '{key}': {vessel_data[key]} -> {str(e)}")
|
||||
continue
|
||||
|
||||
# 方法3:检查 'state' 或 'status' 字段
|
||||
debug_print("🔍 方法3: 检查 'state' 字段...")
|
||||
if 'state' in vessel_data and isinstance(vessel_data['state'], dict):
|
||||
state = vessel_data['state']
|
||||
debug_print(f" - state 字段内容: {state}")
|
||||
if 'volume' in state:
|
||||
try:
|
||||
vol = float(state['volume'])
|
||||
total_volume = max(total_volume, vol)
|
||||
debug_print(f" ✅ 从容器状态读取体积: {vol}mL")
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f" ⚠️ 无法转换 state.volume: {state['volume']} -> {str(e)}")
|
||||
else:
|
||||
debug_print(" - 没有 'state' 字段或不是字典")
|
||||
|
||||
debug_print(f"📊 容器 '{vessel}' 最终检测体积: {total_volume}mL")
|
||||
return total_volume
|
||||
|
||||
|
||||
def is_integrated_pump(node_class: str, node_name: str = "") -> bool:
|
||||
"""
|
||||
判断是否为泵阀一体设备
|
||||
@@ -122,108 +41,77 @@ def is_integrated_pump(node_class: str, node_name: str = "") -> bool:
|
||||
|
||||
def find_connected_pump(G, valve_node):
|
||||
"""
|
||||
查找与阀门相连的泵节点 - 修复版本
|
||||
🔧 修复:区分电磁阀和多通阀,电磁阀不参与泵查找
|
||||
查找与阀门相连的泵节点
|
||||
区分电磁阀和多通阀,电磁阀不参与泵查找
|
||||
"""
|
||||
debug_print(f"🔍 查找与阀门 {valve_node} 相连的泵...")
|
||||
|
||||
# 🔧 关键修复:检查节点类型,电磁阀不应该查找泵
|
||||
# 检查节点类型,电磁阀不应该查找泵
|
||||
node_data = G.nodes.get(valve_node, {})
|
||||
node_class = node_data.get("class", "") or ""
|
||||
|
||||
debug_print(f" - 阀门类型: {node_class}")
|
||||
|
||||
# 如果是电磁阀,不应该查找泵(电磁阀只是开关)
|
||||
if ("solenoid" in node_class.lower() or "solenoid_valve" in valve_node.lower()):
|
||||
debug_print(f" ⚠️ {valve_node} 是电磁阀,不应该查找泵节点")
|
||||
raise ValueError(f"电磁阀 {valve_node} 不应该参与泵查找逻辑")
|
||||
|
||||
# 只有多通阀等复杂阀门才需要查找连接的泵
|
||||
if ("multiway" in node_class.lower() or "valve" in node_class.lower()):
|
||||
debug_print(f" - {valve_node} 是多通阀,查找连接的泵...")
|
||||
# 方法1:直接相邻的泵
|
||||
for neighbor in G.neighbors(valve_node):
|
||||
neighbor_class = G.nodes[neighbor].get("class", "") or ""
|
||||
# 排除非 电磁阀 和 泵 的邻居
|
||||
debug_print(f" - 检查邻居 {neighbor}, class: {neighbor_class}")
|
||||
if "pump" in neighbor_class.lower():
|
||||
debug_print(f" ✅ 找到直接相连的泵: {neighbor}")
|
||||
return neighbor
|
||||
|
||||
# 方法2:通过路径查找泵(最多2跳)
|
||||
debug_print(f" - 未找到直接相连的泵,尝试路径查找...")
|
||||
pump_nodes = [
|
||||
node_id for node_id in G.nodes()
|
||||
if "pump" in (G.nodes[node_id].get("class", "") or "").lower()
|
||||
]
|
||||
|
||||
# 获取所有泵节点
|
||||
pump_nodes = []
|
||||
for node_id in G.nodes():
|
||||
node_class = G.nodes[node_id].get("class", "") or ""
|
||||
if "pump" in node_class.lower():
|
||||
pump_nodes.append(node_id)
|
||||
|
||||
debug_print(f" - 系统中的泵节点: {pump_nodes}")
|
||||
|
||||
# 查找到泵的最短路径
|
||||
for pump_node in pump_nodes:
|
||||
try:
|
||||
if nx.has_path(G, valve_node, pump_node):
|
||||
path = nx.shortest_path(G, valve_node, pump_node)
|
||||
path_length = len(path) - 1
|
||||
debug_print(f" - 到泵 {pump_node} 的路径: {path}, 距离: {path_length}")
|
||||
|
||||
if path_length <= 2: # 最多允许2跳
|
||||
debug_print(f" ✅ 通过路径找到泵: {pump_node}")
|
||||
if len(path) - 1 <= 2: # 最多允许2跳
|
||||
return pump_node
|
||||
except nx.NetworkXNoPath:
|
||||
continue
|
||||
|
||||
# 最终失败
|
||||
debug_print(f" ❌ 完全找不到泵节点")
|
||||
raise ValueError(f"未找到与阀 {valve_node} 相连的泵节点")
|
||||
|
||||
|
||||
def build_pump_valve_maps(G, pump_backbone):
|
||||
"""
|
||||
构建泵-阀门映射 - 修复版本
|
||||
🔧 修复:过滤掉电磁阀,只处理需要泵的多通阀
|
||||
构建泵-阀门映射
|
||||
过滤掉电磁阀,只处理需要泵的多通阀
|
||||
"""
|
||||
pumps_from_node = {}
|
||||
valve_from_node = {}
|
||||
|
||||
debug_print(f"🔧 构建泵-阀门映射,原始骨架: {pump_backbone}")
|
||||
|
||||
# 🔧 关键修复:过滤掉电磁阀
|
||||
# 过滤掉电磁阀
|
||||
filtered_backbone = []
|
||||
for node in pump_backbone:
|
||||
node_data = G.nodes.get(node, {})
|
||||
node_class = node_data.get("class", "") or ""
|
||||
|
||||
# 跳过电磁阀
|
||||
if ("solenoid" in node_class.lower() or "solenoid_valve" in node.lower()):
|
||||
debug_print(f" - 跳过电磁阀: {node}")
|
||||
continue
|
||||
|
||||
filtered_backbone.append(node)
|
||||
|
||||
debug_print(f"🔧 过滤后的骨架: {filtered_backbone}")
|
||||
|
||||
for node in filtered_backbone:
|
||||
node_data = G.nodes.get(node, {})
|
||||
node_class = node_data.get("class", "") or ""
|
||||
if is_integrated_pump(node_class, node):
|
||||
pumps_from_node[node] = node
|
||||
valve_from_node[node] = node
|
||||
debug_print(f" - 集成泵-阀: {node}")
|
||||
else:
|
||||
try:
|
||||
pump_node = find_connected_pump(G, node)
|
||||
pumps_from_node[node] = pump_node
|
||||
valve_from_node[node] = node
|
||||
debug_print(f" - 阀门 {node} -> 泵 {pump_node}")
|
||||
except ValueError as e:
|
||||
debug_print(f" - 跳过节点 {node}: {str(e)}")
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
debug_print(f"🔧 最终映射: pumps={pumps_from_node}, valves={valve_from_node}")
|
||||
debug_print(f"泵-阀映射: pumps={pumps_from_node}, valves={valve_from_node}")
|
||||
return pumps_from_node, valve_from_node
|
||||
|
||||
|
||||
@@ -236,8 +124,8 @@ def generate_pump_protocol(
|
||||
transfer_flowrate: float = 0.5,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
生成泵操作的动作序列 - 修复版本
|
||||
🔧 修复:正确处理包含电磁阀的路径
|
||||
生成泵操作的动作序列
|
||||
正确处理包含电磁阀的路径
|
||||
"""
|
||||
pump_action_sequence = []
|
||||
nodes = G.nodes(data=True)
|
||||
@@ -256,7 +144,6 @@ def generate_pump_protocol(
|
||||
logger.warning(f"transfer_flowrate <= 0,使用默认值 {transfer_flowrate}mL/s")
|
||||
|
||||
# 验证容器存在
|
||||
debug_print(f"🔍 验证源容器 '{from_vessel_id}' 和目标容器 '{to_vessel_id}' 是否存在...")
|
||||
if from_vessel_id not in G.nodes():
|
||||
logger.error(f"源容器 '{from_vessel_id}' 不存在")
|
||||
return pump_action_sequence
|
||||
@@ -272,28 +159,24 @@ def generate_pump_protocol(
|
||||
logger.error(f"无法找到从 '{from_vessel_id}' 到 '{to_vessel_id}' 的路径")
|
||||
return pump_action_sequence
|
||||
|
||||
# 🔧 关键修复:正确构建泵骨架,排除容器和电磁阀
|
||||
# 正确构建泵骨架,排除容器和电磁阀
|
||||
pump_backbone = []
|
||||
for node in shortest_path:
|
||||
# 跳过起始和结束容器
|
||||
if node == from_vessel_id or node == to_vessel_id:
|
||||
continue
|
||||
|
||||
# 跳过电磁阀(电磁阀不参与泵操作)
|
||||
node_data = G.nodes.get(node, {})
|
||||
node_class = node_data.get("class", "") or ""
|
||||
if ("solenoid" in node_class.lower() or "solenoid_valve" in node.lower()):
|
||||
debug_print(f"PUMP_TRANSFER: 跳过电磁阀 {node}")
|
||||
continue
|
||||
|
||||
# 只包含多通阀和泵
|
||||
if ("multiway" in node_class.lower() or "valve" in node_class.lower() or "pump" in node_class.lower()):
|
||||
pump_backbone.append(node)
|
||||
|
||||
debug_print(f"PUMP_TRANSFER: 过滤后的泵骨架: {pump_backbone}")
|
||||
debug_print(f"PUMP_TRANSFER: 泵骨架: {pump_backbone}")
|
||||
|
||||
if not pump_backbone:
|
||||
debug_print("PUMP_TRANSFER: 没有泵骨架节点,可能是直接容器连接或只有电磁阀")
|
||||
debug_print("PUMP_TRANSFER: 没有泵骨架节点")
|
||||
return pump_action_sequence
|
||||
|
||||
if transfer_flowrate == 0:
|
||||
@@ -309,7 +192,7 @@ def generate_pump_protocol(
|
||||
debug_print("PUMP_TRANSFER: 没有可用的泵映射")
|
||||
return pump_action_sequence
|
||||
|
||||
# 🔧 修复:安全地获取最小转移体积
|
||||
# 安全地获取最小转移体积
|
||||
try:
|
||||
min_transfer_volumes = []
|
||||
for node in pump_backbone:
|
||||
@@ -339,19 +222,19 @@ def generate_pump_protocol(
|
||||
volume_left = volume
|
||||
debug_print(f"PUMP_TRANSFER: 需要 {repeats} 次转移,单次最大体积 {min_transfer_volume} mL")
|
||||
|
||||
# 🆕 只在开头打印总体概览
|
||||
# 只在开头打印总体概览
|
||||
if repeats > 1:
|
||||
debug_print(f"🔄 分批转移概览: 总体积 {volume:.2f}mL,需要 {repeats} 次转移")
|
||||
logger.info(f"🔄 分批转移概览: 总体积 {volume:.2f}mL,需要 {repeats} 次转移")
|
||||
debug_print(f"分批转移: 总体积 {volume:.2f}mL, {repeats} 次, 单次最大 {min_transfer_volume} mL")
|
||||
logger.info(f"分批转移: 总体积 {volume:.2f}mL, {repeats} 次转移")
|
||||
|
||||
# 🔧 创建一个自定义的wait动作,用于在执行时打印日志
|
||||
# 创建一个自定义的wait动作,用于在执行时打印日志
|
||||
def create_progress_log_action(message: str) -> Dict[str, Any]:
|
||||
"""创建一个特殊的等待动作,在执行时打印进度日志"""
|
||||
return {
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {
|
||||
"time": 0.1, # 很短的等待时间
|
||||
"progress_message": message # 自定义字段,用于进度日志
|
||||
"time": 0.1,
|
||||
"progress_message": message
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,12 +242,12 @@ def generate_pump_protocol(
|
||||
for i in range(repeats):
|
||||
current_volume = min(volume_left, min_transfer_volume)
|
||||
|
||||
# 🆕 在每次循环开始时添加进度日志
|
||||
if repeats > 1:
|
||||
start_message = f"🚀 准备开始第 {i + 1}/{repeats} 次转移: {current_volume:.2f}mL ({from_vessel_id} → {to_vessel_id}) 🚰"
|
||||
pump_action_sequence.append(create_progress_log_action(start_message))
|
||||
pump_action_sequence.append(create_progress_log_action(
|
||||
f"第 {i + 1}/{repeats} 次转移: {current_volume:.2f}mL ({from_vessel_id} -> {to_vessel_id})"
|
||||
))
|
||||
|
||||
# 🔧 修复:安全地获取边数据
|
||||
# 安全地获取边数据
|
||||
def get_safe_edge_data(node_a, node_b, key):
|
||||
try:
|
||||
edge_data = G.get_edge_data(node_a, node_b)
|
||||
@@ -467,13 +350,13 @@ def generate_pump_protocol(
|
||||
])
|
||||
pump_action_sequence.append({"action_name": "wait", "action_kwargs": {"time": 3}})
|
||||
|
||||
# 🆕 在每次循环结束时添加完成日志
|
||||
# 在每次循环结束时添加完成日志
|
||||
if repeats > 1:
|
||||
remaining_volume = volume_left - current_volume
|
||||
if remaining_volume > 0:
|
||||
end_message = f"✅ 第 {i + 1}/{repeats} 次转移完成! 剩余 {remaining_volume:.2f}mL 待转移 ⏳"
|
||||
end_message = f"第 {i + 1}/{repeats} 次完成, 剩余 {remaining_volume:.2f}mL"
|
||||
else:
|
||||
end_message = f"🎉 第 {i + 1}/{repeats} 次转移完成! 全部 {volume:.2f}mL 转移完毕 ✨"
|
||||
end_message = f"第 {i + 1}/{repeats} 次完成, 全部 {volume:.2f}mL 转移完毕"
|
||||
|
||||
pump_action_sequence.append(create_progress_log_action(end_message))
|
||||
|
||||
@@ -515,300 +398,205 @@ def generate_pump_protocol_with_rinsing(
|
||||
to_vessel_id, _ = get_vessel(to_vessel)
|
||||
|
||||
with generate_pump_protocol_with_rinsing._lock:
|
||||
debug_print("=" * 60)
|
||||
debug_print(f"PUMP_TRANSFER: 🚀 开始生成协议 (同步版本)")
|
||||
debug_print(f" 📍 路径: {from_vessel_id} -> {to_vessel_id}")
|
||||
debug_print(f" 🕐 时间戳: {time_module.time()}")
|
||||
debug_print(f" 🔒 获得执行锁")
|
||||
debug_print("=" * 60)
|
||||
debug_print(f"PUMP_TRANSFER: {from_vessel_id} -> {to_vessel_id}, volume={volume}, flowrate={flowrate}")
|
||||
|
||||
# 短暂延迟,避免快速重复调用
|
||||
time_module.sleep(0.01)
|
||||
|
||||
debug_print("🔍 步骤1: 开始体积处理...")
|
||||
|
||||
# 1. 处理体积参数
|
||||
final_volume = volume
|
||||
debug_print(f"📋 初始设置: final_volume = {final_volume}")
|
||||
|
||||
# 🔧 修复:如果volume为0(ROS2传入的空值),从容器读取实际体积
|
||||
# 如果volume为0,从容器读取实际体积
|
||||
if volume == 0.0:
|
||||
debug_print("🎯 检测到 volume=0.0,开始自动体积检测...")
|
||||
|
||||
# 直接从源容器读取实际体积
|
||||
actual_volume = get_vessel_liquid_volume(G, from_vessel_id)
|
||||
debug_print(f"📖 从容器 '{from_vessel_id}' 读取到体积: {actual_volume}mL")
|
||||
actual_volume = get_resource_liquid_volume(G.nodes.get(from_vessel_id, {}))
|
||||
|
||||
if actual_volume > 0:
|
||||
final_volume = actual_volume
|
||||
debug_print(f"✅ 成功设置体积为: {final_volume}mL")
|
||||
else:
|
||||
final_volume = 10.0 # 如果读取失败,使用默认值
|
||||
logger.warning(f"⚠️ 无法从容器读取体积,使用默认值: {final_volume}mL")
|
||||
else:
|
||||
debug_print(f"📌 体积非零,直接使用: {final_volume}mL")
|
||||
|
||||
final_volume = 10.0
|
||||
logger.warning(f"无法从容器读取体积,使用默认值: {final_volume}mL")
|
||||
# 处理 amount 参数
|
||||
if amount and amount.strip():
|
||||
debug_print(f"🔍 检测到 amount 参数: '{amount}',开始解析...")
|
||||
parsed_volume = _parse_amount_to_volume(amount)
|
||||
debug_print(f"📖 从 amount 解析得到体积: {parsed_volume}mL")
|
||||
|
||||
if parsed_volume > 0:
|
||||
final_volume = parsed_volume
|
||||
debug_print(f"✅ 使用从 amount 解析的体积: {final_volume}mL")
|
||||
elif parsed_volume == 0.0 and amount.lower().strip() == "all":
|
||||
debug_print("🎯 检测到 amount='all',从容器读取全部体积...")
|
||||
actual_volume = get_vessel_liquid_volume(G, from_vessel_id)
|
||||
actual_volume = get_resource_liquid_volume(G.nodes.get(from_vessel_id, {}))
|
||||
if actual_volume > 0:
|
||||
final_volume = actual_volume
|
||||
debug_print(f"✅ amount='all',设置体积为: {final_volume}mL")
|
||||
|
||||
# 最终体积验证
|
||||
debug_print(f"🔍 步骤2: 最终体积验证...")
|
||||
if final_volume <= 0:
|
||||
logger.error(f"❌ 体积无效: {final_volume}mL")
|
||||
logger.error(f"体积无效: {final_volume}mL")
|
||||
final_volume = 10.0
|
||||
logger.warning(f"⚠️ 强制设置为默认值: {final_volume}mL")
|
||||
logger.warning(f"强制设置为默认值: {final_volume}mL")
|
||||
|
||||
debug_print(f"✅ 最终确定体积: {final_volume}mL")
|
||||
debug_print(f"最终体积: {final_volume}mL")
|
||||
|
||||
# 2. 处理流速参数
|
||||
debug_print(f"🔍 步骤3: 处理流速参数...")
|
||||
debug_print(f" - 原始 flowrate: {flowrate}")
|
||||
debug_print(f" - 原始 transfer_flowrate: {transfer_flowrate}")
|
||||
|
||||
final_flowrate = flowrate if flowrate > 0 else 2.5
|
||||
final_transfer_flowrate = transfer_flowrate if transfer_flowrate > 0 else 0.5
|
||||
|
||||
if flowrate <= 0:
|
||||
logger.warning(f"⚠️ flowrate <= 0,修正为: {final_flowrate}mL/s")
|
||||
logger.warning(f"flowrate <= 0,修正为: {final_flowrate}mL/s")
|
||||
if transfer_flowrate <= 0:
|
||||
logger.warning(f"⚠️ transfer_flowrate <= 0,修正为: {final_transfer_flowrate}mL/s")
|
||||
|
||||
debug_print(f"✅ 修正后流速: flowrate={final_flowrate}mL/s, transfer_flowrate={final_transfer_flowrate}mL/s")
|
||||
logger.warning(f"transfer_flowrate <= 0,修正为: {final_transfer_flowrate}mL/s")
|
||||
|
||||
# 3. 根据时间计算流速
|
||||
if time > 0 and final_volume > 0:
|
||||
debug_print(f"🔍 步骤4: 根据时间计算流速...")
|
||||
calculated_flowrate = final_volume / time
|
||||
debug_print(f" - 计算得到流速: {calculated_flowrate}mL/s")
|
||||
|
||||
if flowrate <= 0 or flowrate == 2.5:
|
||||
final_flowrate = min(calculated_flowrate, 10.0)
|
||||
debug_print(f" - 调整 flowrate 为: {final_flowrate}mL/s")
|
||||
if transfer_flowrate <= 0 or transfer_flowrate == 0.5:
|
||||
final_transfer_flowrate = min(calculated_flowrate, 5.0)
|
||||
debug_print(f" - 调整 transfer_flowrate 为: {final_transfer_flowrate}mL/s")
|
||||
|
||||
# 4. 根据速度规格调整
|
||||
if rate_spec:
|
||||
debug_print(f"🔍 步骤5: 根据速度规格调整...")
|
||||
debug_print(f" - 速度规格: '{rate_spec}'")
|
||||
|
||||
if rate_spec == "dropwise":
|
||||
final_flowrate = min(final_flowrate, 0.1)
|
||||
final_transfer_flowrate = min(final_transfer_flowrate, 0.1)
|
||||
debug_print(f" - dropwise模式,流速调整为: {final_flowrate}mL/s")
|
||||
elif rate_spec == "slowly":
|
||||
final_flowrate = min(final_flowrate, 0.5)
|
||||
final_transfer_flowrate = min(final_transfer_flowrate, 0.3)
|
||||
debug_print(f" - slowly模式,流速调整为: {final_flowrate}mL/s")
|
||||
elif rate_spec == "quickly":
|
||||
final_flowrate = max(final_flowrate, 5.0)
|
||||
final_transfer_flowrate = max(final_transfer_flowrate, 2.0)
|
||||
debug_print(f" - quickly模式,流速调整为: {final_flowrate}mL/s")
|
||||
debug_print(f"速度规格 '{rate_spec}': flowrate={final_flowrate}, transfer={final_transfer_flowrate}")
|
||||
|
||||
# 5. 处理冲洗参数
|
||||
debug_print(f"🔍 步骤6: 处理冲洗参数...")
|
||||
final_rinsing_solvent = rinsing_solvent
|
||||
final_rinsing_volume = rinsing_volume if rinsing_volume > 0 else 5.0
|
||||
final_rinsing_repeats = rinsing_repeats if rinsing_repeats > 0 else 2
|
||||
|
||||
if rinsing_volume <= 0:
|
||||
logger.warning(f"⚠️ rinsing_volume <= 0,修正为: {final_rinsing_volume}mL")
|
||||
logger.warning(f"rinsing_volume <= 0,修正为: {final_rinsing_volume}mL")
|
||||
if rinsing_repeats <= 0:
|
||||
logger.warning(f"⚠️ rinsing_repeats <= 0,修正为: {final_rinsing_repeats}次")
|
||||
logger.warning(f"rinsing_repeats <= 0,修正为: {final_rinsing_repeats}次")
|
||||
|
||||
# 根据物理属性调整冲洗参数
|
||||
if viscous or solid:
|
||||
final_rinsing_repeats = max(final_rinsing_repeats, 3)
|
||||
final_rinsing_volume = max(final_rinsing_volume, 10.0)
|
||||
debug_print(f"🧪 粘稠/固体物质,调整冲洗参数:{final_rinsing_repeats}次,{final_rinsing_volume}mL")
|
||||
|
||||
# 参数总结
|
||||
debug_print("📊 最终参数总结:")
|
||||
debug_print(f" - 体积: {final_volume}mL")
|
||||
debug_print(f" - 流速: {final_flowrate}mL/s")
|
||||
debug_print(f" - 转移流速: {final_transfer_flowrate}mL/s")
|
||||
debug_print(f" - 冲洗溶剂: '{final_rinsing_solvent}'")
|
||||
debug_print(f" - 冲洗体积: {final_rinsing_volume}mL")
|
||||
debug_print(f" - 冲洗次数: {final_rinsing_repeats}次")
|
||||
|
||||
# ========== 执行基础转移 ==========
|
||||
|
||||
debug_print("🔧 步骤7: 开始执行基础转移...")
|
||||
debug_print(f"最终参数: volume={final_volume}mL, flowrate={final_flowrate}mL/s, "
|
||||
f"transfer_flowrate={final_transfer_flowrate}mL/s, "
|
||||
f"rinsing={final_rinsing_solvent}/{final_rinsing_volume}mL/{final_rinsing_repeats}次")
|
||||
|
||||
# 执行基础转移
|
||||
try:
|
||||
debug_print(f" - 调用 generate_pump_protocol...")
|
||||
debug_print(
|
||||
f" - 参数: G, '{from_vessel_id}', '{to_vessel_id}', {final_volume}, {final_flowrate}, {final_transfer_flowrate}")
|
||||
|
||||
pump_action_sequence = generate_pump_protocol(
|
||||
G, from_vessel_id, to_vessel_id, final_volume,
|
||||
final_flowrate, final_transfer_flowrate
|
||||
)
|
||||
|
||||
debug_print(f" - generate_pump_protocol 返回结果:")
|
||||
debug_print(f" - 动作序列长度: {len(pump_action_sequence)}")
|
||||
debug_print(f" - 动作序列是否为空: {len(pump_action_sequence) == 0}")
|
||||
debug_print(f"基础转移生成了 {len(pump_action_sequence)} 个动作")
|
||||
|
||||
if not pump_action_sequence:
|
||||
debug_print("❌ 基础转移协议生成为空,可能是路径问题")
|
||||
debug_print(f" - 源容器存在: {from_vessel_id in G.nodes()}")
|
||||
debug_print(f" - 目标容器存在: {to_vessel_id in G.nodes()}")
|
||||
debug_print("基础转移协议为空")
|
||||
|
||||
if from_vessel_id in G.nodes() and to_vessel_id in G.nodes():
|
||||
try:
|
||||
path = nx.shortest_path(G, source=from_vessel_id, target=to_vessel_id)
|
||||
debug_print(f" - 路径存在: {path}")
|
||||
except Exception as path_error:
|
||||
debug_print(f" - 无法找到路径: {str(path_error)}")
|
||||
debug_print(f"路径存在: {path}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return [
|
||||
{
|
||||
"device_id": "system",
|
||||
"action_name": "log_message",
|
||||
"action_kwargs": {
|
||||
"message": f"⚠️ 路径问题,无法转移: {final_volume}mL 从 {from_vessel_id} 到 {to_vessel_id}"
|
||||
"message": f"路径问题,无法转移: {final_volume}mL 从 {from_vessel_id} 到 {to_vessel_id}"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
debug_print(f"✅ 基础转移生成了 {len(pump_action_sequence)} 个动作")
|
||||
|
||||
# 打印前几个动作用于调试
|
||||
if len(pump_action_sequence) > 0:
|
||||
debug_print("🔍 前几个动作预览:")
|
||||
for i, action in enumerate(pump_action_sequence[:3]):
|
||||
debug_print(f" 动作 {i + 1}: {action}")
|
||||
if len(pump_action_sequence) > 3:
|
||||
debug_print(f" ... 还有 {len(pump_action_sequence) - 3} 个动作")
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"❌ 基础转移失败: {str(e)}")
|
||||
import traceback
|
||||
debug_print(f"详细错误: {traceback.format_exc()}")
|
||||
debug_print(f"基础转移失败: {str(e)}\n{traceback.format_exc()}")
|
||||
return [
|
||||
{
|
||||
"device_id": "system",
|
||||
"action_name": "log_message",
|
||||
"action_kwargs": {
|
||||
"message": f"❌ 转移失败: {final_volume}mL 从 {from_vessel_id} 到 {to_vessel_id}, 错误: {str(e)}"
|
||||
"message": f"转移失败: {final_volume}mL 从 {from_vessel_id} 到 {to_vessel_id}, 错误: {str(e)}"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
# ========== 执行冲洗操作 ==========
|
||||
|
||||
debug_print("🔧 步骤8: 检查冲洗操作...")
|
||||
|
||||
# 执行冲洗操作
|
||||
if final_rinsing_solvent and final_rinsing_solvent.strip() and final_rinsing_repeats > 0:
|
||||
debug_print(f"🧽 开始冲洗操作,溶剂: '{final_rinsing_solvent}'")
|
||||
|
||||
try:
|
||||
if final_rinsing_solvent.strip() != "air":
|
||||
debug_print(" - 执行液体冲洗...")
|
||||
rinsing_actions = _generate_rinsing_sequence(
|
||||
G, from_vessel_id, to_vessel_id, final_rinsing_solvent,
|
||||
final_rinsing_volume, final_rinsing_repeats,
|
||||
final_flowrate, final_transfer_flowrate
|
||||
)
|
||||
pump_action_sequence.extend(rinsing_actions)
|
||||
debug_print(f" - 添加了 {len(rinsing_actions)} 个冲洗动作")
|
||||
else:
|
||||
debug_print(" - 执行空气冲洗...")
|
||||
air_rinsing_actions = _generate_air_rinsing_sequence(
|
||||
G, from_vessel_id, to_vessel_id, final_rinsing_volume, final_rinsing_repeats,
|
||||
final_flowrate, final_transfer_flowrate
|
||||
)
|
||||
pump_action_sequence.extend(air_rinsing_actions)
|
||||
debug_print(f" - 添加了 {len(air_rinsing_actions)} 个空气冲洗动作")
|
||||
except Exception as e:
|
||||
debug_print(f"⚠️ 冲洗操作失败: {str(e)},跳过冲洗")
|
||||
debug_print(f"冲洗操作失败: {str(e)}")
|
||||
else:
|
||||
debug_print(f"⏭️ 跳过冲洗操作")
|
||||
debug_print(f" - 溶剂: '{final_rinsing_solvent}'")
|
||||
debug_print(f" - 次数: {final_rinsing_repeats}")
|
||||
debug_print(f" - 条件满足: {bool(final_rinsing_solvent and final_rinsing_solvent.strip() and final_rinsing_repeats > 0)}")
|
||||
debug_print(f"跳过冲洗 (solvent='{final_rinsing_solvent}', repeats={final_rinsing_repeats})")
|
||||
|
||||
# ========== 最终结果 ==========
|
||||
|
||||
debug_print("=" * 60)
|
||||
debug_print(f"🎉 PUMP_TRANSFER: 协议生成完成")
|
||||
debug_print(f" 📊 总动作数: {len(pump_action_sequence)}")
|
||||
debug_print(f" 📋 最终体积: {final_volume}mL")
|
||||
debug_print(f" 🚀 执行路径: {from_vessel_id} -> {to_vessel_id}")
|
||||
# 最终结果
|
||||
debug_print(f"PUMP_TRANSFER 完成: {from_vessel_id} -> {to_vessel_id}, "
|
||||
f"volume={final_volume}mL, 动作数={len(pump_action_sequence)}")
|
||||
|
||||
# 最终验证
|
||||
if len(pump_action_sequence) == 0:
|
||||
debug_print("🚨 协议生成结果为空!这是异常情况")
|
||||
return [
|
||||
{
|
||||
"device_id": "system",
|
||||
"action_name": "log_message",
|
||||
"action_kwargs": {
|
||||
"message": f"🚨 协议生成失败: 无法生成任何动作序列"
|
||||
"message": "协议生成失败: 无法生成任何动作序列"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
debug_print("=" * 60)
|
||||
return pump_action_sequence
|
||||
|
||||
|
||||
def _parse_amount_to_volume(amount: str) -> float:
|
||||
"""解析 amount 字符串为体积"""
|
||||
debug_print(f"🔍 解析 amount: '{amount}'")
|
||||
|
||||
if not amount:
|
||||
debug_print(" - amount 为空,返回 0.0")
|
||||
return 0.0
|
||||
|
||||
amount = amount.lower().strip()
|
||||
debug_print(f" - 处理后的 amount: '{amount}'")
|
||||
|
||||
# 处理特殊关键词
|
||||
if amount == "all":
|
||||
debug_print(" - 检测到 'all',返回 0.0(需要后续处理)")
|
||||
return 0.0 # 返回0.0,让调用者处理
|
||||
|
||||
# 提取数字
|
||||
import re
|
||||
numbers = re.findall(r'[\d.]+', amount)
|
||||
debug_print(f" - 提取到的数字: {numbers}")
|
||||
|
||||
if numbers:
|
||||
volume = float(numbers[0])
|
||||
debug_print(f" - 基础体积: {volume}")
|
||||
|
||||
# 单位转换
|
||||
if 'ml' in amount or 'milliliter' in amount:
|
||||
debug_print(f" - 单位: mL,最终体积: {volume}")
|
||||
return volume
|
||||
elif 'l' in amount and 'ml' not in amount:
|
||||
final_volume = volume * 1000
|
||||
debug_print(f" - 单位: L,最终体积: {final_volume}mL")
|
||||
return final_volume
|
||||
return volume * 1000
|
||||
elif 'μl' in amount or 'microliter' in amount:
|
||||
final_volume = volume / 1000
|
||||
debug_print(f" - 单位: μL,最终体积: {final_volume}mL")
|
||||
return final_volume
|
||||
return volume / 1000
|
||||
else:
|
||||
debug_print(f" - 无单位,假设为 mL: {volume}")
|
||||
return volume
|
||||
return volume # 默认mL
|
||||
|
||||
debug_print(" - 无法解析,返回 0.0")
|
||||
return 0.0
|
||||
|
||||
|
||||
|
||||
@@ -4,76 +4,64 @@ import logging
|
||||
from typing import List, Dict, Any, Tuple, Union
|
||||
from .utils.vessel_parser import get_vessel, find_solvent_vessel
|
||||
from .utils.unit_parser import parse_volume_input
|
||||
from .utils.logger_util import debug_print
|
||||
from .pump_protocol import generate_pump_protocol_with_rinsing
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def debug_print(message):
|
||||
"""调试输出"""
|
||||
logger.info(f"[RECRYSTALLIZE] {message}")
|
||||
|
||||
|
||||
def parse_ratio(ratio_str: str) -> Tuple[float, float]:
|
||||
"""
|
||||
解析比例字符串,支持多种格式
|
||||
|
||||
|
||||
Args:
|
||||
ratio_str: 比例字符串(如 "1:1", "3:7", "50:50")
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple[float, float]: 比例元组 (ratio1, ratio2)
|
||||
"""
|
||||
debug_print(f"⚖️ 开始解析比例: '{ratio_str}' 📊")
|
||||
|
||||
try:
|
||||
# 处理 "1:1", "3:7", "50:50" 等格式
|
||||
if ":" in ratio_str:
|
||||
parts = ratio_str.split(":")
|
||||
if len(parts) == 2:
|
||||
ratio1 = float(parts[0])
|
||||
ratio2 = float(parts[1])
|
||||
debug_print(f"✅ 冒号格式解析成功: {ratio1}:{ratio2} 🎯")
|
||||
return ratio1, ratio2
|
||||
|
||||
# 处理 "1-1", "3-7" 等格式
|
||||
|
||||
if "-" in ratio_str:
|
||||
parts = ratio_str.split("-")
|
||||
if len(parts) == 2:
|
||||
ratio1 = float(parts[0])
|
||||
ratio2 = float(parts[1])
|
||||
debug_print(f"✅ 横线格式解析成功: {ratio1}:{ratio2} 🎯")
|
||||
return ratio1, ratio2
|
||||
|
||||
# 处理 "1,1", "3,7" 等格式
|
||||
|
||||
if "," in ratio_str:
|
||||
parts = ratio_str.split(",")
|
||||
if len(parts) == 2:
|
||||
ratio1 = float(parts[0])
|
||||
ratio2 = float(parts[1])
|
||||
debug_print(f"✅ 逗号格式解析成功: {ratio1}:{ratio2} 🎯")
|
||||
return ratio1, ratio2
|
||||
|
||||
# 默认 1:1
|
||||
debug_print(f"⚠️ 无法解析比例 '{ratio_str}',使用默认比例 1:1 🎭")
|
||||
|
||||
debug_print(f"无法解析比例 '{ratio_str}',使用默认比例 1:1")
|
||||
return 1.0, 1.0
|
||||
|
||||
|
||||
except ValueError:
|
||||
debug_print(f"❌ 比例解析错误 '{ratio_str}',使用默认比例 1:1 🎭")
|
||||
debug_print(f"比例解析错误 '{ratio_str}',使用默认比例 1:1")
|
||||
return 1.0, 1.0
|
||||
|
||||
|
||||
def generate_recrystallize_protocol(
|
||||
G: nx.DiGraph,
|
||||
vessel: dict, # 🔧 修改:从字符串改为字典类型
|
||||
vessel: dict,
|
||||
ratio: str,
|
||||
solvent1: str,
|
||||
solvent2: str,
|
||||
volume: Union[str, float], # 支持字符串和数值
|
||||
volume: Union[str, float],
|
||||
**kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
生成重结晶协议序列 - 支持vessel字典和体积运算
|
||||
|
||||
|
||||
Args:
|
||||
G: 有向图,节点为容器和设备
|
||||
vessel: 目标容器字典(从XDL传入)
|
||||
@@ -82,28 +70,18 @@ def generate_recrystallize_protocol(
|
||||
solvent2: 第二种溶剂名称
|
||||
volume: 总体积(支持 "100 mL", "50", "2.5 L" 等)
|
||||
**kwargs: 其他可选参数
|
||||
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 动作序列
|
||||
"""
|
||||
|
||||
# 🔧 核心修改:从字典中提取容器ID
|
||||
|
||||
vessel_id, vessel_data = get_vessel(vessel)
|
||||
|
||||
|
||||
action_sequence = []
|
||||
|
||||
debug_print("💎" * 20)
|
||||
debug_print("🚀 开始生成重结晶协议(支持vessel字典和体积运算)✨")
|
||||
debug_print(f"📝 输入参数:")
|
||||
debug_print(f" 🥽 vessel: {vessel} (ID: {vessel_id})")
|
||||
debug_print(f" ⚖️ 比例: {ratio}")
|
||||
debug_print(f" 🧪 溶剂1: {solvent1}")
|
||||
debug_print(f" 🧪 溶剂2: {solvent2}")
|
||||
debug_print(f" 💧 总体积: {volume} (类型: {type(volume)})")
|
||||
debug_print("💎" * 20)
|
||||
|
||||
# 🔧 新增:记录重结晶前的容器状态
|
||||
debug_print("🔍 记录重结晶前容器状态...")
|
||||
|
||||
debug_print(f"开始生成重结晶协议: vessel={vessel_id}, ratio={ratio}, solvent1={solvent1}, solvent2={solvent2}, volume={volume}")
|
||||
|
||||
# 记录重结晶前的容器状态
|
||||
original_liquid_volume = 0.0
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
@@ -111,102 +89,73 @@ def generate_recrystallize_protocol(
|
||||
original_liquid_volume = current_volume[0]
|
||||
elif isinstance(current_volume, (int, float)):
|
||||
original_liquid_volume = current_volume
|
||||
debug_print(f"📊 重结晶前液体体积: {original_liquid_volume:.2f}mL")
|
||||
|
||||
|
||||
# 1. 验证目标容器存在
|
||||
debug_print("📍 步骤1: 验证目标容器... 🔧")
|
||||
if vessel_id not in G.nodes(): # 🔧 使用 vessel_id
|
||||
debug_print(f"❌ 目标容器 '{vessel_id}' 不存在于系统中! 😱")
|
||||
if vessel_id not in G.nodes():
|
||||
raise ValueError(f"目标容器 '{vessel_id}' 不存在于系统中")
|
||||
debug_print(f"✅ 目标容器 '{vessel_id}' 验证通过 🎯")
|
||||
|
||||
|
||||
# 2. 解析体积(支持单位)
|
||||
debug_print("📍 步骤2: 解析体积(支持单位)... 💧")
|
||||
final_volume = parse_volume_input(volume, "mL")
|
||||
debug_print(f"🎯 体积解析完成: {volume} → {final_volume}mL ✨")
|
||||
|
||||
debug_print(f"体积解析: {volume} -> {final_volume}mL")
|
||||
|
||||
# 3. 解析比例
|
||||
debug_print("📍 步骤3: 解析比例... ⚖️")
|
||||
ratio1, ratio2 = parse_ratio(ratio)
|
||||
total_ratio = ratio1 + ratio2
|
||||
debug_print(f"🎯 比例解析完成: {ratio1}:{ratio2} (总比例: {total_ratio}) ✨")
|
||||
|
||||
|
||||
# 4. 计算各溶剂体积
|
||||
debug_print("📍 步骤4: 计算各溶剂体积... 🧮")
|
||||
volume1 = final_volume * (ratio1 / total_ratio)
|
||||
volume2 = final_volume * (ratio2 / total_ratio)
|
||||
|
||||
debug_print(f"🧪 {solvent1} 体积: {volume1:.2f} mL ({ratio1}/{total_ratio} × {final_volume})")
|
||||
debug_print(f"🧪 {solvent2} 体积: {volume2:.2f} mL ({ratio2}/{total_ratio} × {final_volume})")
|
||||
debug_print(f"✅ 体积计算完成: 总计 {volume1 + volume2:.2f} mL 🎯")
|
||||
|
||||
|
||||
debug_print(f"溶剂体积: {solvent1}={volume1:.2f}mL, {solvent2}={volume2:.2f}mL")
|
||||
|
||||
# 5. 查找溶剂容器
|
||||
debug_print("📍 步骤5: 查找溶剂容器... 🔍")
|
||||
try:
|
||||
debug_print(f" 🔍 查找溶剂1容器...")
|
||||
solvent1_vessel = find_solvent_vessel(G, solvent1)
|
||||
debug_print(f" 🎉 找到溶剂1容器: {solvent1_vessel} ✨")
|
||||
except ValueError as e:
|
||||
debug_print(f" ❌ 溶剂1容器查找失败: {str(e)} 😭")
|
||||
raise ValueError(f"无法找到溶剂1 '{solvent1}': {str(e)}")
|
||||
|
||||
|
||||
try:
|
||||
debug_print(f" 🔍 查找溶剂2容器...")
|
||||
solvent2_vessel = find_solvent_vessel(G, solvent2)
|
||||
debug_print(f" 🎉 找到溶剂2容器: {solvent2_vessel} ✨")
|
||||
except ValueError as e:
|
||||
debug_print(f" ❌ 溶剂2容器查找失败: {str(e)} 😭")
|
||||
raise ValueError(f"无法找到溶剂2 '{solvent2}': {str(e)}")
|
||||
|
||||
|
||||
# 6. 验证路径存在
|
||||
debug_print("📍 步骤6: 验证传输路径... 🛤️")
|
||||
try:
|
||||
path1 = nx.shortest_path(G, source=solvent1_vessel, target=vessel_id) # 🔧 使用 vessel_id
|
||||
debug_print(f" 🛤️ 溶剂1路径: {' → '.join(path1)} ✅")
|
||||
path1 = nx.shortest_path(G, source=solvent1_vessel, target=vessel_id)
|
||||
except nx.NetworkXNoPath:
|
||||
debug_print(f" ❌ 溶剂1路径不可达: {solvent1_vessel} → {vessel_id} 😞")
|
||||
raise ValueError(f"从溶剂1容器 '{solvent1_vessel}' 到目标容器 '{vessel_id}' 没有可用路径")
|
||||
|
||||
|
||||
try:
|
||||
path2 = nx.shortest_path(G, source=solvent2_vessel, target=vessel_id) # 🔧 使用 vessel_id
|
||||
debug_print(f" 🛤️ 溶剂2路径: {' → '.join(path2)} ✅")
|
||||
path2 = nx.shortest_path(G, source=solvent2_vessel, target=vessel_id)
|
||||
except nx.NetworkXNoPath:
|
||||
debug_print(f" ❌ 溶剂2路径不可达: {solvent2_vessel} → {vessel_id} 😞")
|
||||
raise ValueError(f"从溶剂2容器 '{solvent2_vessel}' 到目标容器 '{vessel_id}' 没有可用路径")
|
||||
|
||||
|
||||
# 7. 添加第一种溶剂
|
||||
debug_print("📍 步骤7: 添加第一种溶剂... 🧪")
|
||||
debug_print(f" 🚰 开始添加溶剂1: {solvent1} ({volume1:.2f} mL)")
|
||||
|
||||
try:
|
||||
pump_actions1 = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
from_vessel=solvent1_vessel,
|
||||
to_vessel=vessel_id, # 🔧 使用 vessel_id
|
||||
volume=volume1, # 使用解析后的体积
|
||||
to_vessel=vessel_id,
|
||||
volume=volume1,
|
||||
amount="",
|
||||
time=0.0,
|
||||
viscous=False,
|
||||
rinsing_solvent="", # 重结晶不需要清洗
|
||||
rinsing_solvent="",
|
||||
rinsing_volume=0.0,
|
||||
rinsing_repeats=0,
|
||||
solid=False,
|
||||
flowrate=2.0, # 正常流速
|
||||
flowrate=2.0,
|
||||
transfer_flowrate=0.5
|
||||
)
|
||||
|
||||
|
||||
action_sequence.extend(pump_actions1)
|
||||
debug_print(f" ✅ 溶剂1泵送动作已添加: {len(pump_actions1)} 个动作 🚰✨")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f" ❌ 溶剂1泵协议生成失败: {str(e)} 😭")
|
||||
raise ValueError(f"生成溶剂1泵协议时出错: {str(e)}")
|
||||
|
||||
# 🔧 新增:更新容器体积 - 添加溶剂1后
|
||||
debug_print(" 🔧 更新容器体积 - 添加溶剂1后...")
|
||||
|
||||
# 更新容器体积 - 添加溶剂1后
|
||||
new_volume_after_solvent1 = original_liquid_volume + volume1
|
||||
|
||||
# 更新vessel字典中的体积
|
||||
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
if isinstance(current_volume, list):
|
||||
@@ -216,15 +165,14 @@ def generate_recrystallize_protocol(
|
||||
vessel["data"]["liquid_volume"] = [new_volume_after_solvent1]
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = new_volume_after_solvent1
|
||||
|
||||
# 同时更新图中的容器数据
|
||||
|
||||
if vessel_id in G.nodes():
|
||||
if 'data' not in G.nodes[vessel_id]:
|
||||
G.nodes[vessel_id]['data'] = {}
|
||||
|
||||
|
||||
vessel_node_data = G.nodes[vessel_id]['data']
|
||||
current_node_volume = vessel_node_data.get('liquid_volume', 0.0)
|
||||
|
||||
|
||||
if isinstance(current_node_volume, list):
|
||||
if len(current_node_volume) > 0:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'][0] = new_volume_after_solvent1
|
||||
@@ -232,53 +180,42 @@ def generate_recrystallize_protocol(
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = [new_volume_after_solvent1]
|
||||
else:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = new_volume_after_solvent1
|
||||
|
||||
debug_print(f" 📊 体积更新: {original_liquid_volume:.2f}mL + {volume1:.2f}mL = {new_volume_after_solvent1:.2f}mL")
|
||||
|
||||
|
||||
# 8. 等待溶剂1稳定
|
||||
debug_print(" ⏳ 添加溶剂1稳定等待...")
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {
|
||||
"time": 5.0, # 缩短等待时间
|
||||
"time": 5.0,
|
||||
"description": f"等待溶剂1 {solvent1} 稳定"
|
||||
}
|
||||
})
|
||||
debug_print(" ✅ 溶剂1稳定等待已添加 ⏰✨")
|
||||
|
||||
|
||||
# 9. 添加第二种溶剂
|
||||
debug_print("📍 步骤8: 添加第二种溶剂... 🧪")
|
||||
debug_print(f" 🚰 开始添加溶剂2: {solvent2} ({volume2:.2f} mL)")
|
||||
|
||||
try:
|
||||
pump_actions2 = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
from_vessel=solvent2_vessel,
|
||||
to_vessel=vessel_id, # 🔧 使用 vessel_id
|
||||
volume=volume2, # 使用解析后的体积
|
||||
to_vessel=vessel_id,
|
||||
volume=volume2,
|
||||
amount="",
|
||||
time=0.0,
|
||||
viscous=False,
|
||||
rinsing_solvent="", # 重结晶不需要清洗
|
||||
rinsing_solvent="",
|
||||
rinsing_volume=0.0,
|
||||
rinsing_repeats=0,
|
||||
solid=False,
|
||||
flowrate=2.0, # 正常流速
|
||||
flowrate=2.0,
|
||||
transfer_flowrate=0.5
|
||||
)
|
||||
|
||||
|
||||
action_sequence.extend(pump_actions2)
|
||||
debug_print(f" ✅ 溶剂2泵送动作已添加: {len(pump_actions2)} 个动作 🚰✨")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f" ❌ 溶剂2泵协议生成失败: {str(e)} 😭")
|
||||
raise ValueError(f"生成溶剂2泵协议时出错: {str(e)}")
|
||||
|
||||
# 🔧 新增:更新容器体积 - 添加溶剂2后
|
||||
debug_print(" 🔧 更新容器体积 - 添加溶剂2后...")
|
||||
|
||||
# 更新容器体积 - 添加溶剂2后
|
||||
final_liquid_volume = new_volume_after_solvent1 + volume2
|
||||
|
||||
# 更新vessel字典中的体积
|
||||
|
||||
if "data" in vessel and "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
if isinstance(current_volume, list):
|
||||
@@ -288,15 +225,14 @@ def generate_recrystallize_protocol(
|
||||
vessel["data"]["liquid_volume"] = [final_liquid_volume]
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = final_liquid_volume
|
||||
|
||||
# 同时更新图中的容器数据
|
||||
|
||||
if vessel_id in G.nodes():
|
||||
if 'data' not in G.nodes[vessel_id]:
|
||||
G.nodes[vessel_id]['data'] = {}
|
||||
|
||||
|
||||
vessel_node_data = G.nodes[vessel_id]['data']
|
||||
current_node_volume = vessel_node_data.get('liquid_volume', 0.0)
|
||||
|
||||
|
||||
if isinstance(current_node_volume, list):
|
||||
if len(current_node_volume) > 0:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'][0] = final_liquid_volume
|
||||
@@ -304,36 +240,25 @@ def generate_recrystallize_protocol(
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = [final_liquid_volume]
|
||||
else:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = final_liquid_volume
|
||||
|
||||
debug_print(f" 📊 最终体积: {new_volume_after_solvent1:.2f}mL + {volume2:.2f}mL = {final_liquid_volume:.2f}mL")
|
||||
|
||||
|
||||
# 10. 等待溶剂2稳定
|
||||
debug_print(" ⏳ 添加溶剂2稳定等待...")
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {
|
||||
"time": 5.0, # 缩短等待时间
|
||||
"time": 5.0,
|
||||
"description": f"等待溶剂2 {solvent2} 稳定"
|
||||
}
|
||||
})
|
||||
debug_print(" ✅ 溶剂2稳定等待已添加 ⏰✨")
|
||||
|
||||
|
||||
# 11. 等待重结晶完成
|
||||
debug_print("📍 步骤9: 等待重结晶完成... 💎")
|
||||
|
||||
# 模拟运行时间优化
|
||||
debug_print(" ⏱️ 检查模拟运行时间限制...")
|
||||
original_crystallize_time = 600.0 # 原始重结晶时间
|
||||
simulation_time_limit = 60.0 # 模拟运行时间限制:60秒
|
||||
original_crystallize_time = 600.0
|
||||
simulation_time_limit = 60.0
|
||||
|
||||
final_crystallize_time = min(original_crystallize_time, simulation_time_limit)
|
||||
|
||||
|
||||
if original_crystallize_time > simulation_time_limit:
|
||||
debug_print(f" 🎮 模拟运行优化: {original_crystallize_time}s → {final_crystallize_time}s ⚡")
|
||||
debug_print(f" 📊 时间缩短: {original_crystallize_time/60:.1f}分钟 → {final_crystallize_time/60:.1f}分钟 🚀")
|
||||
else:
|
||||
debug_print(f" ✅ 时间在限制内: {final_crystallize_time}s 保持不变 🎯")
|
||||
|
||||
debug_print(f"模拟运行优化: {original_crystallize_time}s -> {final_crystallize_time}s")
|
||||
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {
|
||||
@@ -341,50 +266,28 @@ def generate_recrystallize_protocol(
|
||||
"description": f"等待重结晶完成({solvent1}:{solvent2} = {ratio},总体积 {final_volume}mL)" + (f" (模拟时间)" if original_crystallize_time != final_crystallize_time else "")
|
||||
}
|
||||
})
|
||||
debug_print(f" ✅ 重结晶等待已添加: {final_crystallize_time}s 💎✨")
|
||||
|
||||
# 显示时间调整信息
|
||||
if original_crystallize_time != final_crystallize_time:
|
||||
debug_print(f" 🎭 模拟优化说明: 原计划 {original_crystallize_time/60:.1f}分钟,实际模拟 {final_crystallize_time/60:.1f}分钟 ⚡")
|
||||
|
||||
# 总结
|
||||
debug_print("💎" * 20)
|
||||
debug_print(f"🎉 重结晶协议生成完成! ✨")
|
||||
debug_print(f"📊 总动作数: {len(action_sequence)} 个")
|
||||
debug_print(f"🥽 目标容器: {vessel_id}")
|
||||
debug_print(f"💧 总体积变化:")
|
||||
debug_print(f" - 原始体积: {original_liquid_volume:.2f}mL")
|
||||
debug_print(f" - 添加溶剂: {final_volume:.2f}mL")
|
||||
debug_print(f" - 最终体积: {final_liquid_volume:.2f}mL")
|
||||
debug_print(f"⚖️ 溶剂比例: {solvent1}:{solvent2} = {ratio1}:{ratio2}")
|
||||
debug_print(f"🧪 溶剂1: {solvent1} ({volume1:.2f}mL)")
|
||||
debug_print(f"🧪 溶剂2: {solvent2} ({volume2:.2f}mL)")
|
||||
debug_print(f"⏱️ 预计总时间: {(final_crystallize_time + 10)/60:.1f} 分钟 ⌛")
|
||||
debug_print("💎" * 20)
|
||||
|
||||
|
||||
debug_print(f"重结晶协议生成完成: {len(action_sequence)} 个动作, 容器={vessel_id}, 体积变化: {original_liquid_volume:.2f} -> {final_liquid_volume:.2f}mL")
|
||||
|
||||
return action_sequence
|
||||
|
||||
|
||||
# 测试函数
|
||||
def test_recrystallize_protocol():
|
||||
"""测试重结晶协议"""
|
||||
debug_print("🧪 === RECRYSTALLIZE PROTOCOL 测试 === ✨")
|
||||
|
||||
# 测试体积解析
|
||||
debug_print("💧 测试体积解析...")
|
||||
debug_print("=== RECRYSTALLIZE PROTOCOL 测试 ===")
|
||||
|
||||
test_volumes = ["100 mL", "2.5 L", "500", "50.5", "?", "invalid"]
|
||||
for vol in test_volumes:
|
||||
parsed = parse_volume_input(vol)
|
||||
debug_print(f" 📊 体积 '{vol}' -> {parsed}mL")
|
||||
|
||||
# 测试比例解析
|
||||
debug_print("⚖️ 测试比例解析...")
|
||||
debug_print(f"体积 '{vol}' -> {parsed}mL")
|
||||
|
||||
test_ratios = ["1:1", "3:7", "50:50", "1-1", "2,8", "invalid"]
|
||||
for ratio in test_ratios:
|
||||
r1, r2 = parse_ratio(ratio)
|
||||
debug_print(f" 📊 比例 '{ratio}' -> {r1}:{r2}")
|
||||
|
||||
debug_print("✅ 测试完成 🎉")
|
||||
debug_print(f"比例 '{ratio}' -> {r1}:{r2}")
|
||||
|
||||
debug_print("测试完成")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_recrystallize_protocol()
|
||||
test_recrystallize_protocol()
|
||||
|
||||
@@ -1,253 +1,87 @@
|
||||
import networkx as nx
|
||||
import logging
|
||||
import sys
|
||||
from typing import List, Dict, Any, Optional
|
||||
from .utils.logger_util import debug_print, action_log
|
||||
from .utils.vessel_parser import find_solvent_vessel
|
||||
from .pump_protocol import generate_pump_protocol_with_rinsing
|
||||
|
||||
# 设置日志
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 确保输出编码为UTF-8
|
||||
if hasattr(sys.stdout, 'reconfigure'):
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
sys.stderr.reconfigure(encoding='utf-8')
|
||||
except:
|
||||
pass
|
||||
|
||||
def debug_print(message):
|
||||
"""调试输出函数 - 支持中文"""
|
||||
try:
|
||||
# 确保消息是字符串格式
|
||||
safe_message = str(message)
|
||||
print(f"[重置处理] {safe_message}", flush=True)
|
||||
logger.info(f"[重置处理] {safe_message}")
|
||||
except UnicodeEncodeError:
|
||||
# 如果编码失败,尝试替换不支持的字符
|
||||
safe_message = str(message).encode('utf-8', errors='replace').decode('utf-8')
|
||||
print(f"[重置处理] {safe_message}", flush=True)
|
||||
logger.info(f"[重置处理] {safe_message}")
|
||||
except Exception as e:
|
||||
# 最后的安全措施
|
||||
fallback_message = f"日志输出错误: {repr(message)}"
|
||||
print(f"[重置处理] {fallback_message}", flush=True)
|
||||
logger.info(f"[重置处理] {fallback_message}")
|
||||
|
||||
def create_action_log(message: str, emoji: str = "📝") -> Dict[str, Any]:
|
||||
"""创建一个动作日志 - 支持中文和emoji"""
|
||||
try:
|
||||
full_message = f"{emoji} {message}"
|
||||
debug_print(full_message)
|
||||
logger.info(full_message)
|
||||
|
||||
return {
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {
|
||||
"time": 0.1,
|
||||
"log_message": full_message,
|
||||
"progress_message": full_message
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
# 如果emoji有问题,使用纯文本
|
||||
safe_message = f"[日志] {message}"
|
||||
debug_print(safe_message)
|
||||
logger.info(safe_message)
|
||||
|
||||
return {
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {
|
||||
"time": 0.1,
|
||||
"log_message": safe_message,
|
||||
"progress_message": safe_message
|
||||
}
|
||||
}
|
||||
|
||||
def find_solvent_vessel(G: nx.DiGraph, solvent: str) -> str:
|
||||
"""
|
||||
查找溶剂容器,支持多种匹配模式
|
||||
|
||||
Args:
|
||||
G: 网络图
|
||||
solvent: 溶剂名称(如 "methanol", "ethanol", "water")
|
||||
|
||||
Returns:
|
||||
str: 溶剂容器ID
|
||||
"""
|
||||
debug_print(f"🔍 正在查找溶剂 '{solvent}' 的容器...")
|
||||
|
||||
# 构建可能的容器名称
|
||||
possible_names = [
|
||||
f"flask_{solvent}", # flask_methanol
|
||||
f"bottle_{solvent}", # bottle_methanol
|
||||
f"reagent_{solvent}", # reagent_methanol
|
||||
f"reagent_bottle_{solvent}", # reagent_bottle_methanol
|
||||
f"{solvent}_flask", # methanol_flask
|
||||
f"{solvent}_bottle", # methanol_bottle
|
||||
f"{solvent}", # methanol
|
||||
f"vessel_{solvent}", # vessel_methanol
|
||||
]
|
||||
|
||||
debug_print(f"🎯 候选容器名称: {possible_names[:3]}... (共{len(possible_names)}个)")
|
||||
|
||||
# 第一步:通过容器名称匹配
|
||||
debug_print("📋 方法1: 精确名称匹配...")
|
||||
for vessel_name in possible_names:
|
||||
if vessel_name in G.nodes():
|
||||
debug_print(f"✅ 通过名称匹配找到容器: {vessel_name}")
|
||||
return vessel_name
|
||||
debug_print("⚠️ 精确名称匹配失败,尝试模糊匹配...")
|
||||
|
||||
# 第二步:通过模糊匹配
|
||||
debug_print("📋 方法2: 模糊名称匹配...")
|
||||
for node_id in G.nodes():
|
||||
if G.nodes[node_id].get('type') == 'container':
|
||||
node_name = G.nodes[node_id].get('name', '').lower()
|
||||
|
||||
# 检查是否包含溶剂名称
|
||||
if solvent.lower() in node_id.lower() or solvent.lower() in node_name:
|
||||
debug_print(f"✅ 通过模糊匹配找到容器: {node_id}")
|
||||
return node_id
|
||||
debug_print("⚠️ 模糊匹配失败,尝试液体类型匹配...")
|
||||
|
||||
# 第三步:通过液体类型匹配
|
||||
debug_print("📋 方法3: 液体类型匹配...")
|
||||
for node_id in G.nodes():
|
||||
if G.nodes[node_id].get('type') == 'container':
|
||||
vessel_data = G.nodes[node_id].get('data', {})
|
||||
liquids = vessel_data.get('liquid', [])
|
||||
|
||||
for liquid in liquids:
|
||||
if isinstance(liquid, dict):
|
||||
liquid_type = (liquid.get('liquid_type') or liquid.get('name', '')).lower()
|
||||
reagent_name = vessel_data.get('reagent_name', '').lower()
|
||||
|
||||
if solvent.lower() in liquid_type or solvent.lower() in reagent_name:
|
||||
debug_print(f"✅ 通过液体类型匹配找到容器: {node_id}")
|
||||
return node_id
|
||||
|
||||
# 列出可用容器帮助调试
|
||||
debug_print("📊 显示可用容器信息...")
|
||||
available_containers = []
|
||||
for node_id in G.nodes():
|
||||
if G.nodes[node_id].get('type') == 'container':
|
||||
vessel_data = G.nodes[node_id].get('data', {})
|
||||
liquids = vessel_data.get('liquid', [])
|
||||
liquid_types = [liquid.get('liquid_type', '') or liquid.get('name', '')
|
||||
for liquid in liquids if isinstance(liquid, dict)]
|
||||
|
||||
available_containers.append({
|
||||
'id': node_id,
|
||||
'name': G.nodes[node_id].get('name', ''),
|
||||
'liquids': liquid_types,
|
||||
'reagent_name': vessel_data.get('reagent_name', '')
|
||||
})
|
||||
|
||||
debug_print(f"📋 可用容器列表 (共{len(available_containers)}个):")
|
||||
for i, container in enumerate(available_containers[:5]): # 只显示前5个
|
||||
debug_print(f" {i+1}. 🥽 {container['id']}: {container['name']}")
|
||||
debug_print(f" 💧 液体: {container['liquids']}")
|
||||
debug_print(f" 🧪 试剂: {container['reagent_name']}")
|
||||
|
||||
if len(available_containers) > 5:
|
||||
debug_print(f" ... 还有 {len(available_containers)-5} 个容器")
|
||||
|
||||
debug_print(f"❌ 找不到溶剂 '{solvent}' 对应的容器")
|
||||
raise ValueError(f"找不到溶剂 '{solvent}' 对应的容器。尝试了: {possible_names[:3]}...")
|
||||
create_action_log = action_log
|
||||
|
||||
def generate_reset_handling_protocol(
|
||||
G: nx.DiGraph,
|
||||
solvent: str,
|
||||
vessel: Optional[str] = None, # 🆕 新增可选vessel参数
|
||||
**kwargs # 接收其他可能的参数但不使用
|
||||
vessel: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
生成重置处理协议序列 - 支持自定义容器
|
||||
|
||||
|
||||
Args:
|
||||
G: 有向图,节点为容器和设备
|
||||
solvent: 溶剂名称(从XDL传入)
|
||||
vessel: 目标容器名称(可选,默认为 "main_reactor")
|
||||
**kwargs: 其他可选参数,但不使用
|
||||
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 动作序列
|
||||
"""
|
||||
action_sequence = []
|
||||
|
||||
# 🔧 修改:支持自定义vessel参数
|
||||
target_vessel = vessel if vessel is not None else "main_reactor" # 默认目标容器
|
||||
volume = 50.0 # 默认体积 50 mL
|
||||
|
||||
debug_print("=" * 60)
|
||||
debug_print("🚀 开始生成重置处理协议")
|
||||
debug_print(f"📋 输入参数:")
|
||||
debug_print(f" 🧪 溶剂: {solvent}")
|
||||
debug_print(f" 🥽 目标容器: {target_vessel} {'(默认)' if vessel is None else '(指定)'}")
|
||||
debug_print(f" 💧 体积: {volume} mL")
|
||||
debug_print(f" ⚙️ 其他参数: {kwargs}")
|
||||
debug_print("=" * 60)
|
||||
|
||||
target_vessel = vessel if vessel is not None else "main_reactor"
|
||||
volume = 50.0
|
||||
|
||||
debug_print(f"开始生成重置处理协议: solvent={solvent}, vessel={target_vessel}, volume={volume}mL")
|
||||
|
||||
# 添加初始日志
|
||||
action_sequence.append(create_action_log(f"开始重置处理操作 - 容器: {target_vessel}", "🎬"))
|
||||
action_sequence.append(create_action_log(f"使用溶剂: {solvent}", "🧪"))
|
||||
action_sequence.append(create_action_log(f"重置体积: {volume}mL", "💧"))
|
||||
|
||||
action_sequence.append(action_log(f"开始重置处理操作 - 容器: {target_vessel}", "🎬"))
|
||||
action_sequence.append(action_log(f"使用溶剂: {solvent}", "🧪"))
|
||||
action_sequence.append(action_log(f"重置体积: {volume}mL", "💧"))
|
||||
|
||||
if vessel is None:
|
||||
action_sequence.append(create_action_log("使用默认目标容器: main_reactor", "⚙️"))
|
||||
action_sequence.append(action_log("使用默认目标容器: main_reactor", "⚙️"))
|
||||
else:
|
||||
action_sequence.append(create_action_log(f"使用指定目标容器: {vessel}", "🎯"))
|
||||
|
||||
action_sequence.append(action_log(f"使用指定目标容器: {vessel}", "🎯"))
|
||||
|
||||
# 1. 验证目标容器存在
|
||||
debug_print("🔍 步骤1: 验证目标容器...")
|
||||
action_sequence.append(create_action_log("正在验证目标容器...", "🔍"))
|
||||
|
||||
action_sequence.append(action_log("正在验证目标容器...", "🔍"))
|
||||
|
||||
if target_vessel not in G.nodes():
|
||||
debug_print(f"❌ 目标容器 '{target_vessel}' 不存在于系统中!")
|
||||
action_sequence.append(create_action_log(f"目标容器 '{target_vessel}' 不存在", "❌"))
|
||||
action_sequence.append(action_log(f"目标容器 '{target_vessel}' 不存在", "❌"))
|
||||
raise ValueError(f"目标容器 '{target_vessel}' 不存在于系统中")
|
||||
|
||||
debug_print(f"✅ 目标容器 '{target_vessel}' 验证通过")
|
||||
action_sequence.append(create_action_log(f"目标容器验证通过: {target_vessel}", "✅"))
|
||||
|
||||
|
||||
action_sequence.append(action_log(f"目标容器验证通过: {target_vessel}", "✅"))
|
||||
|
||||
# 2. 查找溶剂容器
|
||||
debug_print("🔍 步骤2: 查找溶剂容器...")
|
||||
action_sequence.append(create_action_log("正在查找溶剂容器...", "🔍"))
|
||||
|
||||
action_sequence.append(action_log("正在查找溶剂容器...", "🔍"))
|
||||
|
||||
try:
|
||||
solvent_vessel = find_solvent_vessel(G, solvent)
|
||||
debug_print(f"✅ 找到溶剂容器: {solvent_vessel}")
|
||||
action_sequence.append(create_action_log(f"找到溶剂容器: {solvent_vessel}", "✅"))
|
||||
debug_print(f"找到溶剂容器: {solvent_vessel}")
|
||||
action_sequence.append(action_log(f"找到溶剂容器: {solvent_vessel}", "✅"))
|
||||
except ValueError as e:
|
||||
debug_print(f"❌ 溶剂容器查找失败: {str(e)}")
|
||||
action_sequence.append(create_action_log(f"溶剂容器查找失败: {str(e)}", "❌"))
|
||||
action_sequence.append(action_log(f"溶剂容器查找失败: {str(e)}", "❌"))
|
||||
raise ValueError(f"无法找到溶剂 '{solvent}': {str(e)}")
|
||||
|
||||
|
||||
# 3. 验证路径存在
|
||||
debug_print("🔍 步骤3: 验证传输路径...")
|
||||
action_sequence.append(create_action_log("正在验证传输路径...", "🛤️"))
|
||||
|
||||
action_sequence.append(action_log("正在验证传输路径...", "🛤️"))
|
||||
|
||||
try:
|
||||
path = nx.shortest_path(G, source=solvent_vessel, target=target_vessel)
|
||||
debug_print(f"✅ 找到路径: {' → '.join(path)}")
|
||||
action_sequence.append(create_action_log(f"传输路径: {' → '.join(path)}", "🛤️"))
|
||||
action_sequence.append(action_log(f"传输路径: {' → '.join(path)}", "🛤️"))
|
||||
except nx.NetworkXNoPath:
|
||||
debug_print(f"❌ 路径不可达: {solvent_vessel} → {target_vessel}")
|
||||
action_sequence.append(create_action_log(f"路径不可达: {solvent_vessel} → {target_vessel}", "❌"))
|
||||
action_sequence.append(action_log(f"路径不可达: {solvent_vessel} → {target_vessel}", "❌"))
|
||||
raise ValueError(f"从溶剂容器 '{solvent_vessel}' 到目标容器 '{target_vessel}' 没有可用路径")
|
||||
|
||||
|
||||
# 4. 使用pump_protocol转移溶剂
|
||||
debug_print("🔍 步骤4: 转移溶剂...")
|
||||
action_sequence.append(create_action_log("开始溶剂转移操作...", "🚰"))
|
||||
|
||||
debug_print(f"🚛 开始转移: {solvent_vessel} → {target_vessel}")
|
||||
debug_print(f"💧 转移体积: {volume} mL")
|
||||
action_sequence.append(create_action_log(f"转移: {solvent_vessel} → {target_vessel} ({volume}mL)", "🚛"))
|
||||
|
||||
action_sequence.append(action_log("开始溶剂转移操作...", "🚰"))
|
||||
action_sequence.append(action_log(f"转移: {solvent_vessel} → {target_vessel} ({volume}mL)", "🚛"))
|
||||
|
||||
try:
|
||||
debug_print("🔄 生成泵送协议...")
|
||||
action_sequence.append(create_action_log("正在生成泵送协议...", "🔄"))
|
||||
|
||||
action_sequence.append(action_log("正在生成泵送协议...", "🔄"))
|
||||
|
||||
pump_actions = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
from_vessel=solvent_vessel,
|
||||
@@ -256,41 +90,34 @@ def generate_reset_handling_protocol(
|
||||
amount="",
|
||||
time=0.0,
|
||||
viscous=False,
|
||||
rinsing_solvent="", # 重置处理不需要清洗
|
||||
rinsing_solvent="",
|
||||
rinsing_volume=0.0,
|
||||
rinsing_repeats=0,
|
||||
solid=False,
|
||||
flowrate=2.5, # 正常流速
|
||||
transfer_flowrate=0.5 # 正常转移流速
|
||||
flowrate=2.5,
|
||||
transfer_flowrate=0.5
|
||||
)
|
||||
|
||||
|
||||
action_sequence.extend(pump_actions)
|
||||
debug_print(f"✅ 泵送协议已添加: {len(pump_actions)} 个动作")
|
||||
action_sequence.append(create_action_log(f"泵送协议完成 ({len(pump_actions)} 个操作)", "✅"))
|
||||
|
||||
debug_print(f"泵送协议已添加: {len(pump_actions)} 个动作")
|
||||
action_sequence.append(action_log(f"泵送协议完成 ({len(pump_actions)} 个操作)", "✅"))
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"❌ 泵送协议生成失败: {str(e)}")
|
||||
action_sequence.append(create_action_log(f"泵送协议生成失败: {str(e)}", "❌"))
|
||||
action_sequence.append(action_log(f"泵送协议生成失败: {str(e)}", "❌"))
|
||||
raise ValueError(f"生成泵协议时出错: {str(e)}")
|
||||
|
||||
|
||||
# 5. 等待溶剂稳定
|
||||
debug_print("🔍 步骤5: 等待溶剂稳定...")
|
||||
action_sequence.append(create_action_log("等待溶剂稳定...", "⏳"))
|
||||
|
||||
# 模拟运行时间优化
|
||||
debug_print("⏱️ 检查模拟运行时间限制...")
|
||||
original_wait_time = 10.0 # 原始等待时间
|
||||
simulation_time_limit = 5.0 # 模拟运行时间限制:5秒
|
||||
|
||||
action_sequence.append(action_log("等待溶剂稳定...", "⏳"))
|
||||
|
||||
original_wait_time = 10.0
|
||||
simulation_time_limit = 5.0
|
||||
final_wait_time = min(original_wait_time, simulation_time_limit)
|
||||
|
||||
|
||||
if original_wait_time > simulation_time_limit:
|
||||
debug_print(f"🎮 模拟运行优化: {original_wait_time}s → {final_wait_time}s")
|
||||
action_sequence.append(create_action_log(f"时间优化: {original_wait_time}s → {final_wait_time}s", "⚡"))
|
||||
action_sequence.append(action_log(f"时间优化: {original_wait_time}s → {final_wait_time}s", "⚡"))
|
||||
else:
|
||||
debug_print(f"✅ 时间在限制内: {final_wait_time}s 保持不变")
|
||||
action_sequence.append(create_action_log(f"等待时间: {final_wait_time}s", "⏰"))
|
||||
|
||||
action_sequence.append(action_log(f"等待时间: {final_wait_time}s", "⏰"))
|
||||
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {
|
||||
@@ -298,90 +125,50 @@ def generate_reset_handling_protocol(
|
||||
"description": f"等待溶剂 {solvent} 在容器 {target_vessel} 中稳定" + (f" (模拟时间)" if original_wait_time != final_wait_time else "")
|
||||
}
|
||||
})
|
||||
debug_print(f"✅ 稳定等待已添加: {final_wait_time}s")
|
||||
|
||||
# 显示时间调整信息
|
||||
|
||||
if original_wait_time != final_wait_time:
|
||||
debug_print(f"🎭 模拟优化说明: 原计划 {original_wait_time}s,实际模拟 {final_wait_time}s")
|
||||
action_sequence.append(create_action_log("应用模拟时间优化", "🎭"))
|
||||
|
||||
action_sequence.append(action_log("应用模拟时间优化", "🎭"))
|
||||
|
||||
# 总结
|
||||
debug_print("=" * 60)
|
||||
debug_print(f"🎉 重置处理协议生成完成!")
|
||||
debug_print(f"📊 总结信息:")
|
||||
debug_print(f" 📋 总动作数: {len(action_sequence)} 个")
|
||||
debug_print(f" 🧪 溶剂: {solvent}")
|
||||
debug_print(f" 🥽 源容器: {solvent_vessel}")
|
||||
debug_print(f" 🥽 目标容器: {target_vessel} {'(默认)' if vessel is None else '(指定)'}")
|
||||
debug_print(f" 💧 转移体积: {volume} mL")
|
||||
debug_print(f" ⏱️ 预计总时间: {(final_wait_time + 5):.0f} 秒")
|
||||
debug_print(f" 🎯 操作结果: 已添加 {volume} mL {solvent} 到 {target_vessel}")
|
||||
debug_print("=" * 60)
|
||||
|
||||
# 添加完成日志
|
||||
debug_print(f"重置处理协议生成完成: {len(action_sequence)} 个动作, {solvent_vessel} -> {target_vessel}, {volume}mL")
|
||||
|
||||
summary_msg = f"重置处理完成: {target_vessel} (使用 {volume}mL {solvent})"
|
||||
if vessel is None:
|
||||
summary_msg += " [默认容器]"
|
||||
else:
|
||||
summary_msg += " [指定容器]"
|
||||
|
||||
action_sequence.append(create_action_log(summary_msg, "🎉"))
|
||||
|
||||
|
||||
action_sequence.append(action_log(summary_msg, "🎉"))
|
||||
|
||||
return action_sequence
|
||||
|
||||
# === 便捷函数 ===
|
||||
|
||||
def reset_main_reactor(G: nx.DiGraph, solvent: str = "methanol", **kwargs) -> List[Dict[str, Any]]:
|
||||
"""重置主反应器 (默认行为)"""
|
||||
debug_print(f"🔄 重置主反应器,使用溶剂: {solvent}")
|
||||
return generate_reset_handling_protocol(G, solvent=solvent, vessel=None, **kwargs)
|
||||
|
||||
def reset_custom_vessel(G: nx.DiGraph, vessel: str, solvent: str = "methanol", **kwargs) -> List[Dict[str, Any]]:
|
||||
"""重置指定容器"""
|
||||
debug_print(f"🔄 重置指定容器: {vessel},使用溶剂: {solvent}")
|
||||
return generate_reset_handling_protocol(G, solvent=solvent, vessel=vessel, **kwargs)
|
||||
|
||||
def reset_with_water(G: nx.DiGraph, vessel: Optional[str] = None, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""使用水重置容器"""
|
||||
target = vessel or "main_reactor"
|
||||
debug_print(f"💧 使用水重置容器: {target}")
|
||||
return generate_reset_handling_protocol(G, solvent="water", vessel=vessel, **kwargs)
|
||||
|
||||
def reset_with_methanol(G: nx.DiGraph, vessel: Optional[str] = None, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""使用甲醇重置容器"""
|
||||
target = vessel or "main_reactor"
|
||||
debug_print(f"🧪 使用甲醇重置容器: {target}")
|
||||
return generate_reset_handling_protocol(G, solvent="methanol", vessel=vessel, **kwargs)
|
||||
|
||||
def reset_with_ethanol(G: nx.DiGraph, vessel: Optional[str] = None, **kwargs) -> List[Dict[str, Any]]:
|
||||
"""使用乙醇重置容器"""
|
||||
target = vessel or "main_reactor"
|
||||
debug_print(f"🧪 使用乙醇重置容器: {target}")
|
||||
return generate_reset_handling_protocol(G, solvent="ethanol", vessel=vessel, **kwargs)
|
||||
|
||||
# 测试函数
|
||||
def test_reset_handling_protocol():
|
||||
"""测试重置处理协议"""
|
||||
debug_print("=== 重置处理协议增强中文版测试 ===")
|
||||
|
||||
# 测试溶剂名称
|
||||
debug_print("🧪 测试常用溶剂名称...")
|
||||
test_solvents = ["methanol", "ethanol", "water", "acetone", "dmso"]
|
||||
for solvent in test_solvents:
|
||||
debug_print(f" 🔍 测试溶剂: {solvent}")
|
||||
|
||||
# 测试容器参数
|
||||
debug_print("🥽 测试容器参数...")
|
||||
test_cases = [
|
||||
{"solvent": "methanol", "vessel": None, "desc": "默认容器"},
|
||||
{"solvent": "ethanol", "vessel": "reactor_2", "desc": "指定容器"},
|
||||
{"solvent": "water", "vessel": "flask_1", "desc": "自定义容器"}
|
||||
]
|
||||
|
||||
for case in test_cases:
|
||||
debug_print(f" 🧪 测试案例: {case['desc']} - {case['solvent']} -> {case['vessel'] or 'main_reactor'}")
|
||||
|
||||
debug_print("✅ 测试完成")
|
||||
debug_print("=== 重置处理协议测试 ===")
|
||||
debug_print("测试完成")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_reset_handling_protocol()
|
||||
test_reset_handling_protocol()
|
||||
|
||||
@@ -2,60 +2,54 @@ from typing import List, Dict, Any, Union
|
||||
import networkx as nx
|
||||
import logging
|
||||
import re
|
||||
from .utils.vessel_parser import get_vessel
|
||||
from .utils.vessel_parser import get_vessel, find_solvent_vessel
|
||||
from .utils.resource_helper import get_resource_id, get_resource_data, get_resource_liquid_volume, update_vessel_volume
|
||||
from .utils.logger_util import debug_print
|
||||
from .pump_protocol import generate_pump_protocol_with_rinsing
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def debug_print(message):
|
||||
"""调试输出"""
|
||||
logger.info(f"[RUN_COLUMN] {message}")
|
||||
|
||||
def parse_percentage(pct_str: str) -> float:
|
||||
"""
|
||||
解析百分比字符串为数值
|
||||
|
||||
|
||||
Args:
|
||||
pct_str: 百分比字符串(如 "40 %", "40%", "40")
|
||||
|
||||
|
||||
Returns:
|
||||
float: 百分比数值(0-100)
|
||||
"""
|
||||
if not pct_str or not pct_str.strip():
|
||||
return 0.0
|
||||
|
||||
|
||||
pct_str = pct_str.strip().lower()
|
||||
debug_print(f"🔍 解析百分比: '{pct_str}'")
|
||||
|
||||
|
||||
# 移除百分号和空格
|
||||
pct_clean = re.sub(r'[%\s]', '', pct_str)
|
||||
|
||||
# 提取数字
|
||||
|
||||
match = re.search(r'([0-9]*\.?[0-9]+)', pct_clean)
|
||||
if match:
|
||||
value = float(match.group(1))
|
||||
debug_print(f"✅ 百分比解析结果: {value}%")
|
||||
return value
|
||||
|
||||
debug_print(f"⚠️ 无法解析百分比: '{pct_str}',返回0.0")
|
||||
|
||||
debug_print(f"无法解析百分比: '{pct_str}',返回0.0")
|
||||
return 0.0
|
||||
|
||||
def parse_ratio(ratio_str: str) -> tuple:
|
||||
"""
|
||||
解析比例字符串为两个数值
|
||||
|
||||
|
||||
Args:
|
||||
ratio_str: 比例字符串(如 "5:95", "1:1", "40:60")
|
||||
|
||||
|
||||
Returns:
|
||||
tuple: (ratio1, ratio2) 两个比例值
|
||||
tuple: (ratio1, ratio2) 两个比例值(百分比)
|
||||
"""
|
||||
if not ratio_str or not ratio_str.strip():
|
||||
return (50.0, 50.0) # 默认1:1
|
||||
|
||||
return (50.0, 50.0)
|
||||
|
||||
ratio_str = ratio_str.strip()
|
||||
debug_print(f"🔍 解析比例: '{ratio_str}'")
|
||||
|
||||
|
||||
# 支持多种分隔符:: / -
|
||||
if ':' in ratio_str:
|
||||
parts = ratio_str.split(':')
|
||||
@@ -66,101 +60,82 @@ def parse_ratio(ratio_str: str) -> tuple:
|
||||
elif 'to' in ratio_str.lower():
|
||||
parts = ratio_str.lower().split('to')
|
||||
else:
|
||||
debug_print(f"⚠️ 无法解析比例格式: '{ratio_str}',使用默认1:1")
|
||||
debug_print(f"无法解析比例格式: '{ratio_str}',使用默认1:1")
|
||||
return (50.0, 50.0)
|
||||
|
||||
|
||||
if len(parts) >= 2:
|
||||
try:
|
||||
ratio1 = float(parts[0].strip())
|
||||
ratio2 = float(parts[1].strip())
|
||||
total = ratio1 + ratio2
|
||||
|
||||
# 转换为百分比
|
||||
|
||||
pct1 = (ratio1 / total) * 100
|
||||
pct2 = (ratio2 / total) * 100
|
||||
|
||||
debug_print(f"✅ 比例解析结果: {ratio1}:{ratio2} -> {pct1:.1f}%:{pct2:.1f}%")
|
||||
|
||||
return (pct1, pct2)
|
||||
except ValueError as e:
|
||||
debug_print(f"⚠️ 比例数值转换失败: {str(e)}")
|
||||
|
||||
debug_print(f"⚠️ 比例解析失败,使用默认1:1")
|
||||
debug_print(f"比例数值转换失败: {str(e)}")
|
||||
|
||||
debug_print(f"比例解析失败,使用默认1:1")
|
||||
return (50.0, 50.0)
|
||||
|
||||
def parse_rf_value(rf_str: str) -> float:
|
||||
"""
|
||||
解析Rf值字符串
|
||||
|
||||
|
||||
Args:
|
||||
rf_str: Rf值字符串(如 "0.3", "0.45", "?")
|
||||
|
||||
|
||||
Returns:
|
||||
float: Rf值(0-1)
|
||||
"""
|
||||
if not rf_str or not rf_str.strip():
|
||||
return 0.3 # 默认Rf值
|
||||
|
||||
return 0.3
|
||||
|
||||
rf_str = rf_str.strip().lower()
|
||||
debug_print(f"🔍 解析Rf值: '{rf_str}'")
|
||||
|
||||
# 处理未知Rf值
|
||||
|
||||
if rf_str in ['?', 'unknown', 'tbd', 'to be determined']:
|
||||
default_rf = 0.3
|
||||
debug_print(f"❓ 检测到未知Rf值,使用默认值: {default_rf}")
|
||||
return default_rf
|
||||
|
||||
# 提取数字
|
||||
return 0.3
|
||||
|
||||
match = re.search(r'([0-9]*\.?[0-9]+)', rf_str)
|
||||
if match:
|
||||
value = float(match.group(1))
|
||||
# 确保Rf值在0-1范围内
|
||||
if value > 1.0:
|
||||
value = value / 100.0 # 可能是百分比形式
|
||||
value = max(0.0, min(1.0, value)) # 限制在0-1范围
|
||||
debug_print(f"✅ Rf值解析结果: {value}")
|
||||
value = value / 100.0
|
||||
value = max(0.0, min(1.0, value))
|
||||
return value
|
||||
|
||||
debug_print(f"⚠️ 无法解析Rf值: '{rf_str}',使用默认值0.3")
|
||||
|
||||
return 0.3
|
||||
|
||||
def find_column_device(G: nx.DiGraph) -> str:
|
||||
"""查找柱层析设备"""
|
||||
debug_print("🔍 查找柱层析设备...")
|
||||
|
||||
# 查找虚拟柱设备
|
||||
for node in G.nodes():
|
||||
node_data = G.nodes[node]
|
||||
node_class = node_data.get('class', '') or ''
|
||||
|
||||
|
||||
if 'virtual_column' in node_class.lower() or 'column' in node_class.lower():
|
||||
debug_print(f"🎉 找到柱层析设备: {node} ✨")
|
||||
debug_print(f"找到柱层析设备: {node}")
|
||||
return node
|
||||
|
||||
# 如果没有找到,尝试创建虚拟设备名称
|
||||
|
||||
possible_names = ['column_1', 'virtual_column_1', 'chromatography_column_1']
|
||||
for name in possible_names:
|
||||
if name in G.nodes():
|
||||
debug_print(f"🎉 找到柱设备: {name} ✨")
|
||||
debug_print(f"找到柱设备: {name}")
|
||||
return name
|
||||
|
||||
debug_print("⚠️ 未找到柱层析设备,将使用pump protocol直接转移")
|
||||
|
||||
debug_print("未找到柱层析设备,将使用pump protocol直接转移")
|
||||
return ""
|
||||
|
||||
def find_column_vessel(G: nx.DiGraph, column: str) -> str:
|
||||
"""查找柱容器"""
|
||||
debug_print(f"🔍 查找柱容器: '{column}'")
|
||||
|
||||
# 直接检查column参数是否是容器
|
||||
if column in G.nodes():
|
||||
node_type = G.nodes[column].get('type', '')
|
||||
if node_type == 'container':
|
||||
debug_print(f"🎉 找到柱容器: {column} ✨")
|
||||
return column
|
||||
|
||||
# 尝试常见的命名规则
|
||||
|
||||
possible_names = [
|
||||
f"column_{column}",
|
||||
f"{column}_column",
|
||||
f"{column}_column",
|
||||
f"vessel_{column}",
|
||||
f"{column}_vessel",
|
||||
"column_vessel",
|
||||
@@ -169,211 +144,25 @@ def find_column_vessel(G: nx.DiGraph, column: str) -> str:
|
||||
"preparative_column",
|
||||
"column"
|
||||
]
|
||||
|
||||
|
||||
for vessel_name in possible_names:
|
||||
if vessel_name in G.nodes():
|
||||
node_type = G.nodes[vessel_name].get('type', '')
|
||||
if node_type == 'container':
|
||||
debug_print(f"🎉 找到柱容器: {vessel_name} ✨")
|
||||
return vessel_name
|
||||
|
||||
debug_print(f"⚠️ 未找到柱容器,将直接在源容器中进行分离")
|
||||
|
||||
return ""
|
||||
|
||||
def find_solvent_vessel(G: nx.DiGraph, solvent: str) -> str:
|
||||
"""查找溶剂容器 - 增强版"""
|
||||
if not solvent or not solvent.strip():
|
||||
return ""
|
||||
|
||||
solvent = solvent.strip().replace(' ', '_').lower()
|
||||
debug_print(f"🔍 查找溶剂容器: '{solvent}'")
|
||||
|
||||
# 🔧 方法1:直接搜索 data.reagent_name
|
||||
for node in G.nodes():
|
||||
node_data = G.nodes[node].get('data', {})
|
||||
node_type = G.nodes[node].get('type', '')
|
||||
|
||||
# 只搜索容器类型的节点
|
||||
if node_type == 'container':
|
||||
reagent_name = node_data.get('reagent_name', '').lower()
|
||||
reagent_config = G.nodes[node].get('config', {}).get('reagent', '').lower()
|
||||
|
||||
# 检查 data.reagent_name 和 config.reagent
|
||||
if reagent_name == solvent or reagent_config == solvent:
|
||||
debug_print(f"🎉 通过reagent_name找到溶剂容器: {node} (reagent: {reagent_name or reagent_config}) ✨")
|
||||
return node
|
||||
|
||||
# 模糊匹配 reagent_name
|
||||
if solvent in reagent_name or reagent_name in solvent:
|
||||
debug_print(f"🎉 通过reagent_name模糊匹配到溶剂容器: {node} (reagent: {reagent_name}) ✨")
|
||||
return node
|
||||
|
||||
if solvent in reagent_config or reagent_config in solvent:
|
||||
debug_print(f"🎉 通过config.reagent模糊匹配到溶剂容器: {node} (reagent: {reagent_config}) ✨")
|
||||
return node
|
||||
|
||||
# 🔧 方法2:常见的溶剂容器命名规则
|
||||
possible_names = [
|
||||
f"flask_{solvent}",
|
||||
f"bottle_{solvent}",
|
||||
f"reagent_{solvent}",
|
||||
f"{solvent}_bottle",
|
||||
f"{solvent}_flask",
|
||||
f"solvent_{solvent}",
|
||||
f"reagent_bottle_{solvent}"
|
||||
]
|
||||
|
||||
for vessel_name in possible_names:
|
||||
if vessel_name in G.nodes():
|
||||
node_type = G.nodes[vessel_name].get('type', '')
|
||||
if node_type == 'container':
|
||||
debug_print(f"🎉 通过命名规则找到溶剂容器: {vessel_name} ✨")
|
||||
return vessel_name
|
||||
|
||||
# 🔧 方法3:节点名称模糊匹配
|
||||
for node in G.nodes():
|
||||
node_type = G.nodes[node].get('type', '')
|
||||
if node_type == 'container':
|
||||
if ('flask_' in node or 'bottle_' in node or 'reagent_' in node) and solvent in node.lower():
|
||||
debug_print(f"🎉 通过节点名称模糊匹配到溶剂容器: {node} ✨")
|
||||
return node
|
||||
|
||||
# 🔧 方法4:特殊溶剂名称映射
|
||||
solvent_mapping = {
|
||||
'dmf': ['dmf', 'dimethylformamide', 'n,n-dimethylformamide'],
|
||||
'ethyl_acetate': ['ethyl_acetate', 'ethylacetate', 'etoac', 'ea'],
|
||||
'hexane': ['hexane', 'hexanes', 'n-hexane'],
|
||||
'methanol': ['methanol', 'meoh', 'ch3oh'],
|
||||
'water': ['water', 'h2o', 'distilled_water'],
|
||||
'acetone': ['acetone', 'ch3coch3', '2-propanone'],
|
||||
'dichloromethane': ['dichloromethane', 'dcm', 'ch2cl2', 'methylene_chloride'],
|
||||
'chloroform': ['chloroform', 'chcl3', 'trichloromethane']
|
||||
}
|
||||
|
||||
# 查找映射的同义词
|
||||
for canonical_name, synonyms in solvent_mapping.items():
|
||||
if solvent in synonyms:
|
||||
debug_print(f"🔍 检测到溶剂同义词: '{solvent}' -> '{canonical_name}'")
|
||||
return find_solvent_vessel(G, canonical_name) # 递归搜索
|
||||
|
||||
debug_print(f"⚠️ 未找到溶剂 '{solvent}' 的容器")
|
||||
return ""
|
||||
|
||||
def get_vessel_liquid_volume(vessel: dict) -> float:
|
||||
"""
|
||||
获取容器中的液体体积 - 支持vessel字典
|
||||
|
||||
Args:
|
||||
vessel: 容器字典
|
||||
|
||||
Returns:
|
||||
float: 液体体积(mL)
|
||||
"""
|
||||
if not vessel or "data" not in vessel:
|
||||
debug_print(f"⚠️ 容器数据为空,返回 0.0mL")
|
||||
return 0.0
|
||||
|
||||
vessel_data = vessel["data"]
|
||||
vessel_id = vessel.get("id", "unknown")
|
||||
|
||||
debug_print(f"🔍 读取容器 '{vessel_id}' 体积数据: {vessel_data}")
|
||||
|
||||
# 检查liquid_volume字段
|
||||
if "liquid_volume" in vessel_data:
|
||||
liquid_volume = vessel_data["liquid_volume"]
|
||||
|
||||
# 处理列表格式
|
||||
if isinstance(liquid_volume, list):
|
||||
if len(liquid_volume) > 0:
|
||||
volume = liquid_volume[0]
|
||||
if isinstance(volume, (int, float)):
|
||||
debug_print(f"✅ 容器 '{vessel_id}' 体积: {volume}mL (列表格式)")
|
||||
return float(volume)
|
||||
|
||||
# 处理直接数值格式
|
||||
elif isinstance(liquid_volume, (int, float)):
|
||||
debug_print(f"✅ 容器 '{vessel_id}' 体积: {liquid_volume}mL (数值格式)")
|
||||
return float(liquid_volume)
|
||||
|
||||
# 检查其他可能的体积字段
|
||||
volume_keys = ['current_volume', 'total_volume', 'volume']
|
||||
for key in volume_keys:
|
||||
if key in vessel_data:
|
||||
try:
|
||||
volume = float(vessel_data[key])
|
||||
if volume > 0:
|
||||
debug_print(f"✅ 容器 '{vessel_id}' 体积: {volume}mL (字段: {key})")
|
||||
return volume
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
debug_print(f"⚠️ 无法获取容器 '{vessel_id}' 的体积,返回默认值 50.0mL")
|
||||
return 50.0
|
||||
|
||||
def update_vessel_volume(vessel: dict, G: nx.DiGraph, new_volume: float, description: str = "") -> None:
|
||||
"""
|
||||
更新容器体积(同时更新vessel字典和图节点)
|
||||
|
||||
Args:
|
||||
vessel: 容器字典
|
||||
G: 网络图
|
||||
new_volume: 新体积
|
||||
description: 更新描述
|
||||
"""
|
||||
vessel_id = vessel.get("id", "unknown")
|
||||
|
||||
if description:
|
||||
debug_print(f"🔧 更新容器体积 - {description}")
|
||||
|
||||
# 更新vessel字典中的体积
|
||||
if "data" in vessel:
|
||||
if "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
if isinstance(current_volume, list):
|
||||
if len(current_volume) > 0:
|
||||
vessel["data"]["liquid_volume"][0] = new_volume
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = [new_volume]
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = new_volume
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = new_volume
|
||||
else:
|
||||
vessel["data"] = {"liquid_volume": new_volume}
|
||||
|
||||
# 同时更新图中的容器数据
|
||||
if vessel_id in G.nodes():
|
||||
if 'data' not in G.nodes[vessel_id]:
|
||||
G.nodes[vessel_id]['data'] = {}
|
||||
|
||||
vessel_node_data = G.nodes[vessel_id]['data']
|
||||
current_node_volume = vessel_node_data.get('liquid_volume', 0.0)
|
||||
|
||||
if isinstance(current_node_volume, list):
|
||||
if len(current_node_volume) > 0:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'][0] = new_volume
|
||||
else:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = [new_volume]
|
||||
else:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = new_volume
|
||||
|
||||
debug_print(f"📊 容器 '{vessel_id}' 体积已更新为: {new_volume:.2f}mL")
|
||||
|
||||
def calculate_solvent_volumes(total_volume: float, pct1: float, pct2: float) -> tuple:
|
||||
"""根据百分比计算溶剂体积"""
|
||||
volume1 = (total_volume * pct1) / 100.0
|
||||
volume2 = (total_volume * pct2) / 100.0
|
||||
|
||||
debug_print(f"🧮 溶剂体积计算: 总体积{total_volume}mL")
|
||||
debug_print(f" - 溶剂1: {pct1}% = {volume1}mL")
|
||||
debug_print(f" - 溶剂2: {pct2}% = {volume2}mL")
|
||||
|
||||
return (volume1, volume2)
|
||||
|
||||
def generate_run_column_protocol(
|
||||
G: nx.DiGraph,
|
||||
from_vessel: dict, # 🔧 修改:从字符串改为字典类型
|
||||
to_vessel: dict, # 🔧 修改:从字符串改为字典类型
|
||||
from_vessel: dict,
|
||||
to_vessel: dict,
|
||||
column: str,
|
||||
rf: str = "",
|
||||
pct1: str = "",
|
||||
@@ -385,7 +174,7 @@ def generate_run_column_protocol(
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
生成柱层析分离的协议序列 - 支持vessel字典和体积运算
|
||||
|
||||
|
||||
Args:
|
||||
G: 有向图,节点为设备和容器,边为流体管道
|
||||
from_vessel: 源容器字典(从XDL传入)
|
||||
@@ -398,173 +187,112 @@ def generate_run_column_protocol(
|
||||
solvent2: 第二种溶剂名称(可选)
|
||||
ratio: 溶剂比例(如 "5:95",可选,优先级高于pct1/pct2)
|
||||
**kwargs: 其他可选参数
|
||||
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 柱层析分离操作的动作序列
|
||||
"""
|
||||
|
||||
# 🔧 核心修改:从字典中提取容器ID
|
||||
|
||||
from_vessel_id, _ = get_vessel(from_vessel)
|
||||
to_vessel_id, _ = get_vessel(to_vessel)
|
||||
|
||||
debug_print("🏛️" * 20)
|
||||
debug_print("🚀 开始生成柱层析协议(支持vessel字典和体积运算)✨")
|
||||
debug_print(f"📝 输入参数:")
|
||||
debug_print(f" 🥽 from_vessel: {from_vessel} (ID: {from_vessel_id})")
|
||||
debug_print(f" 🥽 to_vessel: {to_vessel} (ID: {to_vessel_id})")
|
||||
debug_print(f" 🏛️ column: '{column}'")
|
||||
debug_print(f" 📊 rf: '{rf}'")
|
||||
debug_print(f" 🧪 溶剂配比: pct1='{pct1}', pct2='{pct2}', ratio='{ratio}'")
|
||||
debug_print(f" 🧪 溶剂名称: solvent1='{solvent1}', solvent2='{solvent2}'")
|
||||
debug_print("🏛️" * 20)
|
||||
|
||||
debug_print(f"开始生成柱层析协议: {from_vessel_id} -> {to_vessel_id}, column={column}")
|
||||
|
||||
action_sequence = []
|
||||
|
||||
# 🔧 新增:记录柱层析前的容器状态
|
||||
debug_print("🔍 记录柱层析前容器状态...")
|
||||
original_from_volume = get_vessel_liquid_volume(from_vessel)
|
||||
original_to_volume = get_vessel_liquid_volume(to_vessel)
|
||||
|
||||
debug_print(f"📊 柱层析前状态:")
|
||||
debug_print(f" - 源容器 {from_vessel_id}: {original_from_volume:.2f}mL")
|
||||
debug_print(f" - 目标容器 {to_vessel_id}: {original_to_volume:.2f}mL")
|
||||
|
||||
|
||||
# 记录柱层析前的容器状态
|
||||
original_from_volume = get_resource_liquid_volume(from_vessel)
|
||||
original_to_volume = get_resource_liquid_volume(to_vessel)
|
||||
|
||||
# === 参数验证 ===
|
||||
debug_print("📍 步骤1: 参数验证...")
|
||||
|
||||
if not from_vessel_id: # 🔧 使用 from_vessel_id
|
||||
if not from_vessel_id:
|
||||
raise ValueError("from_vessel 参数不能为空")
|
||||
if not to_vessel_id: # 🔧 使用 to_vessel_id
|
||||
if not to_vessel_id:
|
||||
raise ValueError("to_vessel 参数不能为空")
|
||||
if not column:
|
||||
raise ValueError("column 参数不能为空")
|
||||
|
||||
if from_vessel_id not in G.nodes(): # 🔧 使用 from_vessel_id
|
||||
|
||||
if from_vessel_id not in G.nodes():
|
||||
raise ValueError(f"源容器 '{from_vessel_id}' 不存在于系统中")
|
||||
if to_vessel_id not in G.nodes(): # 🔧 使用 to_vessel_id
|
||||
if to_vessel_id not in G.nodes():
|
||||
raise ValueError(f"目标容器 '{to_vessel_id}' 不存在于系统中")
|
||||
|
||||
debug_print("✅ 基本参数验证通过")
|
||||
|
||||
|
||||
# === 参数解析 ===
|
||||
debug_print("📍 步骤2: 参数解析...")
|
||||
|
||||
# 解析Rf值
|
||||
final_rf = parse_rf_value(rf)
|
||||
debug_print(f"🎯 最终Rf值: {final_rf}")
|
||||
|
||||
# 解析溶剂比例(ratio优先级高于pct1/pct2)
|
||||
|
||||
if ratio and ratio.strip():
|
||||
final_pct1, final_pct2 = parse_ratio(ratio)
|
||||
debug_print(f"📊 使用ratio参数: {final_pct1:.1f}% : {final_pct2:.1f}%")
|
||||
else:
|
||||
final_pct1 = parse_percentage(pct1) if pct1 else 50.0
|
||||
final_pct2 = parse_percentage(pct2) if pct2 else 50.0
|
||||
|
||||
# 如果百分比和不是100%,进行归一化
|
||||
|
||||
total_pct = final_pct1 + final_pct2
|
||||
if total_pct == 0:
|
||||
final_pct1, final_pct2 = 50.0, 50.0
|
||||
elif total_pct != 100.0:
|
||||
final_pct1 = (final_pct1 / total_pct) * 100
|
||||
final_pct2 = (final_pct2 / total_pct) * 100
|
||||
|
||||
debug_print(f"📊 使用百分比参数: {final_pct1:.1f}% : {final_pct2:.1f}%")
|
||||
|
||||
# 设置默认溶剂(如果未指定)
|
||||
|
||||
final_solvent1 = solvent1.strip() if solvent1 else "ethyl_acetate"
|
||||
final_solvent2 = solvent2.strip() if solvent2 else "hexane"
|
||||
|
||||
debug_print(f"🧪 最终溶剂: {final_solvent1} : {final_solvent2}")
|
||||
|
||||
|
||||
debug_print(f"参数: rf={final_rf}, 溶剂={final_solvent1}:{final_solvent2} = {final_pct1:.1f}%:{final_pct2:.1f}%")
|
||||
|
||||
# === 查找设备和容器 ===
|
||||
debug_print("📍 步骤3: 查找设备和容器...")
|
||||
|
||||
# 查找柱层析设备
|
||||
column_device_id = find_column_device(G)
|
||||
|
||||
# 查找柱容器
|
||||
column_vessel = find_column_vessel(G, column)
|
||||
|
||||
# 查找溶剂容器
|
||||
solvent1_vessel = find_solvent_vessel(G, final_solvent1)
|
||||
solvent2_vessel = find_solvent_vessel(G, final_solvent2)
|
||||
|
||||
debug_print(f"🔧 设备映射:")
|
||||
debug_print(f" - 柱设备: '{column_device_id}'")
|
||||
debug_print(f" - 柱容器: '{column_vessel}'")
|
||||
debug_print(f" - 溶剂1容器: '{solvent1_vessel}'")
|
||||
debug_print(f" - 溶剂2容器: '{solvent2_vessel}'")
|
||||
|
||||
|
||||
# === 获取源容器体积 ===
|
||||
debug_print("📍 步骤4: 获取源容器体积...")
|
||||
|
||||
source_volume = original_from_volume
|
||||
if source_volume <= 0:
|
||||
source_volume = 50.0 # 默认体积
|
||||
debug_print(f"⚠️ 无法获取源容器体积,使用默认值: {source_volume}mL")
|
||||
else:
|
||||
debug_print(f"✅ 源容器体积: {source_volume}mL")
|
||||
|
||||
source_volume = 50.0
|
||||
|
||||
# === 计算溶剂体积 ===
|
||||
debug_print("📍 步骤5: 计算溶剂体积...")
|
||||
|
||||
# 洗脱溶剂通常是样品体积的2-5倍
|
||||
total_elution_volume = source_volume * 3.0
|
||||
solvent1_volume, solvent2_volume = calculate_solvent_volumes(
|
||||
total_elution_volume, final_pct1, final_pct2
|
||||
)
|
||||
|
||||
|
||||
# === 执行柱层析流程 ===
|
||||
debug_print("📍 步骤6: 执行柱层析流程...")
|
||||
|
||||
# 🔧 新增:体积变化跟踪变量
|
||||
current_from_volume = source_volume
|
||||
current_to_volume = original_to_volume
|
||||
current_column_volume = 0.0
|
||||
|
||||
|
||||
try:
|
||||
# 步骤6.1: 样品上柱(如果有独立的柱容器)
|
||||
if column_vessel and column_vessel != from_vessel_id: # 🔧 使用 from_vessel_id
|
||||
debug_print(f"📍 6.1: 样品上柱 - {source_volume}mL 从 {from_vessel_id} 到 {column_vessel}")
|
||||
|
||||
# 步骤1: 样品上柱
|
||||
if column_vessel and column_vessel != from_vessel_id:
|
||||
try:
|
||||
sample_transfer_actions = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
from_vessel=from_vessel_id, # 🔧 使用 from_vessel_id
|
||||
from_vessel=from_vessel_id,
|
||||
to_vessel=column_vessel,
|
||||
volume=source_volume,
|
||||
flowrate=1.0, # 慢速上柱
|
||||
flowrate=1.0,
|
||||
transfer_flowrate=0.5,
|
||||
rinsing_solvent="", # 暂不冲洗
|
||||
rinsing_solvent="",
|
||||
rinsing_volume=0.0,
|
||||
rinsing_repeats=0
|
||||
)
|
||||
action_sequence.extend(sample_transfer_actions)
|
||||
debug_print(f"✅ 样品上柱完成,添加了 {len(sample_transfer_actions)} 个动作")
|
||||
|
||||
# 🔧 新增:更新体积 - 样品转移到柱上
|
||||
current_from_volume = 0.0 # 源容器体积变为0
|
||||
current_column_volume = source_volume # 柱容器体积增加
|
||||
|
||||
|
||||
current_from_volume = 0.0
|
||||
current_column_volume = source_volume
|
||||
|
||||
update_vessel_volume(from_vessel, G, current_from_volume, "样品上柱后,源容器清空")
|
||||
|
||||
# 如果柱容器在图中,也更新其体积
|
||||
|
||||
if column_vessel in G.nodes():
|
||||
if 'data' not in G.nodes[column_vessel]:
|
||||
G.nodes[column_vessel]['data'] = {}
|
||||
G.nodes[column_vessel]['data']['liquid_volume'] = current_column_volume
|
||||
debug_print(f"📊 柱容器 '{column_vessel}' 体积更新为: {current_column_volume:.2f}mL")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"⚠️ 样品上柱失败: {str(e)}")
|
||||
|
||||
# 步骤6.2: 添加洗脱溶剂1(如果有溶剂容器)
|
||||
debug_print(f"样品上柱失败: {str(e)}")
|
||||
|
||||
# 步骤2: 添加洗脱溶剂1
|
||||
if solvent1_vessel and solvent1_volume > 0:
|
||||
debug_print(f"📍 6.2: 添加洗脱溶剂1 - {solvent1_volume:.1f}mL {final_solvent1}")
|
||||
|
||||
try:
|
||||
target_vessel = column_vessel if column_vessel else from_vessel_id # 🔧 使用 from_vessel_id
|
||||
target_vessel = column_vessel if column_vessel else from_vessel_id
|
||||
solvent1_transfer_actions = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
from_vessel=solvent1_vessel,
|
||||
@@ -574,27 +302,22 @@ def generate_run_column_protocol(
|
||||
transfer_flowrate=1.0
|
||||
)
|
||||
action_sequence.extend(solvent1_transfer_actions)
|
||||
debug_print(f"✅ 溶剂1添加完成,添加了 {len(solvent1_transfer_actions)} 个动作")
|
||||
|
||||
# 🔧 新增:更新体积 - 添加溶剂1
|
||||
|
||||
if target_vessel == column_vessel:
|
||||
current_column_volume += solvent1_volume
|
||||
if column_vessel in G.nodes():
|
||||
G.nodes[column_vessel]['data']['liquid_volume'] = current_column_volume
|
||||
debug_print(f"📊 柱容器体积增加: +{solvent1_volume:.2f}mL = {current_column_volume:.2f}mL")
|
||||
elif target_vessel == from_vessel_id:
|
||||
current_from_volume += solvent1_volume
|
||||
update_vessel_volume(from_vessel, G, current_from_volume, "添加溶剂1后")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"⚠️ 溶剂1添加失败: {str(e)}")
|
||||
|
||||
# 步骤6.3: 添加洗脱溶剂2(如果有溶剂容器)
|
||||
debug_print(f"溶剂1添加失败: {str(e)}")
|
||||
|
||||
# 步骤3: 添加洗脱溶剂2
|
||||
if solvent2_vessel and solvent2_volume > 0:
|
||||
debug_print(f"📍 6.3: 添加洗脱溶剂2 - {solvent2_volume:.1f}mL {final_solvent2}")
|
||||
|
||||
try:
|
||||
target_vessel = column_vessel if column_vessel else from_vessel_id # 🔧 使用 from_vessel_id
|
||||
target_vessel = column_vessel if column_vessel else from_vessel_id
|
||||
solvent2_transfer_actions = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
from_vessel=solvent2_vessel,
|
||||
@@ -604,31 +327,26 @@ def generate_run_column_protocol(
|
||||
transfer_flowrate=1.0
|
||||
)
|
||||
action_sequence.extend(solvent2_transfer_actions)
|
||||
debug_print(f"✅ 溶剂2添加完成,添加了 {len(solvent2_transfer_actions)} 个动作")
|
||||
|
||||
# 🔧 新增:更新体积 - 添加溶剂2
|
||||
|
||||
if target_vessel == column_vessel:
|
||||
current_column_volume += solvent2_volume
|
||||
if column_vessel in G.nodes():
|
||||
G.nodes[column_vessel]['data']['liquid_volume'] = current_column_volume
|
||||
debug_print(f"📊 柱容器体积增加: +{solvent2_volume:.2f}mL = {current_column_volume:.2f}mL")
|
||||
elif target_vessel == from_vessel_id:
|
||||
current_from_volume += solvent2_volume
|
||||
update_vessel_volume(from_vessel, G, current_from_volume, "添加溶剂2后")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"⚠️ 溶剂2添加失败: {str(e)}")
|
||||
|
||||
# 步骤6.4: 使用柱层析设备执行分离(如果有设备)
|
||||
debug_print(f"溶剂2添加失败: {str(e)}")
|
||||
|
||||
# 步骤4: 使用柱层析设备执行分离
|
||||
if column_device_id:
|
||||
debug_print(f"📍 6.4: 使用柱层析设备执行分离")
|
||||
|
||||
column_separation_action = {
|
||||
"device_id": column_device_id,
|
||||
"action_name": "run_column",
|
||||
"action_kwargs": {
|
||||
"from_vessel": from_vessel_id, # 🔧 使用 from_vessel_id
|
||||
"to_vessel": to_vessel_id, # 🔧 使用 to_vessel_id
|
||||
"from_vessel": from_vessel_id,
|
||||
"to_vessel": to_vessel_id,
|
||||
"column": column,
|
||||
"rf": rf,
|
||||
"pct1": pct1,
|
||||
@@ -639,85 +357,65 @@ def generate_run_column_protocol(
|
||||
}
|
||||
}
|
||||
action_sequence.append(column_separation_action)
|
||||
debug_print(f"✅ 柱层析设备动作已添加")
|
||||
|
||||
# 等待分离完成
|
||||
separation_time = max(30, min(120, int(total_elution_volume / 2))) # 30-120秒,基于体积
|
||||
|
||||
separation_time = max(30, min(120, int(total_elution_volume / 2)))
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": separation_time}
|
||||
})
|
||||
debug_print(f"✅ 等待分离完成: {separation_time}秒")
|
||||
|
||||
# 步骤6.5: 产物收集(从柱容器到目标容器)
|
||||
if column_vessel and column_vessel != to_vessel_id: # 🔧 使用 to_vessel_id
|
||||
debug_print(f"📍 6.5: 产物收集 - 从 {column_vessel} 到 {to_vessel_id}")
|
||||
|
||||
|
||||
# 步骤5: 产物收集
|
||||
if column_vessel and column_vessel != to_vessel_id:
|
||||
try:
|
||||
# 估算产物体积(原始样品体积的70-90%,收率考虑)
|
||||
product_volume = source_volume * 0.8
|
||||
|
||||
|
||||
product_transfer_actions = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
from_vessel=column_vessel,
|
||||
to_vessel=to_vessel_id, # 🔧 使用 to_vessel_id
|
||||
to_vessel=to_vessel_id,
|
||||
volume=product_volume,
|
||||
flowrate=1.5,
|
||||
transfer_flowrate=0.8
|
||||
)
|
||||
action_sequence.extend(product_transfer_actions)
|
||||
debug_print(f"✅ 产物收集完成,添加了 {len(product_transfer_actions)} 个动作")
|
||||
|
||||
# 🔧 新增:更新体积 - 产物收集到目标容器
|
||||
|
||||
current_to_volume += product_volume
|
||||
current_column_volume -= product_volume # 柱容器体积减少
|
||||
|
||||
current_column_volume -= product_volume
|
||||
|
||||
update_vessel_volume(to_vessel, G, current_to_volume, "产物收集后")
|
||||
|
||||
# 更新柱容器体积
|
||||
|
||||
if column_vessel in G.nodes():
|
||||
G.nodes[column_vessel]['data']['liquid_volume'] = max(0.0, current_column_volume)
|
||||
debug_print(f"📊 柱容器体积减少: -{product_volume:.2f}mL = {current_column_volume:.2f}mL")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"⚠️ 产物收集失败: {str(e)}")
|
||||
|
||||
# 步骤6.6: 如果没有独立的柱设备和容器,执行简化的直接转移
|
||||
debug_print(f"产物收集失败: {str(e)}")
|
||||
|
||||
# 步骤6: 简化模式 - 直接转移
|
||||
if not column_device_id and not column_vessel:
|
||||
debug_print(f"📍 6.6: 简化模式 - 直接转移 {source_volume}mL 从 {from_vessel_id} 到 {to_vessel_id}")
|
||||
|
||||
try:
|
||||
direct_transfer_actions = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
from_vessel=from_vessel_id, # 🔧 使用 from_vessel_id
|
||||
to_vessel=to_vessel_id, # 🔧 使用 to_vessel_id
|
||||
from_vessel=from_vessel_id,
|
||||
to_vessel=to_vessel_id,
|
||||
volume=source_volume,
|
||||
flowrate=2.0,
|
||||
transfer_flowrate=1.0
|
||||
)
|
||||
action_sequence.extend(direct_transfer_actions)
|
||||
debug_print(f"✅ 直接转移完成,添加了 {len(direct_transfer_actions)} 个动作")
|
||||
|
||||
# 🔧 新增:更新体积 - 直接转移
|
||||
current_from_volume = 0.0 # 源容器清空
|
||||
current_to_volume += source_volume # 目标容器增加
|
||||
|
||||
|
||||
current_from_volume = 0.0
|
||||
current_to_volume += source_volume
|
||||
|
||||
update_vessel_volume(from_vessel, G, current_from_volume, "直接转移后,源容器清空")
|
||||
update_vessel_volume(to_vessel, G, current_to_volume, "直接转移后,目标容器增加")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"⚠️ 直接转移失败: {str(e)}")
|
||||
|
||||
debug_print(f"直接转移失败: {str(e)}")
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"❌ 协议生成失败: {str(e)} 😭")
|
||||
|
||||
# 不添加不确定的动作,直接让action_sequence保持为空列表
|
||||
# action_sequence 已经在函数开始时初始化为 []
|
||||
|
||||
# 确保至少有一个有效的动作,如果完全失败就返回空列表
|
||||
debug_print(f"协议生成失败: {str(e)}")
|
||||
|
||||
if not action_sequence:
|
||||
debug_print("⚠️ 没有生成任何有效动作")
|
||||
# 可以选择返回空列表或添加一个基本的等待动作
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {
|
||||
@@ -725,83 +423,50 @@ def generate_run_column_protocol(
|
||||
"description": "柱层析协议执行完成"
|
||||
}
|
||||
})
|
||||
|
||||
# 🔧 新增:柱层析完成后的最终状态报告
|
||||
final_from_volume = get_vessel_liquid_volume(from_vessel)
|
||||
final_to_volume = get_vessel_liquid_volume(to_vessel)
|
||||
|
||||
# 🎊 总结
|
||||
debug_print("🏛️" * 20)
|
||||
debug_print(f"🎉 柱层析协议生成完成! ✨")
|
||||
debug_print(f"📊 总动作数: {len(action_sequence)} 个")
|
||||
debug_print(f"🥽 路径: {from_vessel_id} → {to_vessel_id}")
|
||||
debug_print(f"🏛️ 柱子: {column}")
|
||||
debug_print(f"🧪 溶剂: {final_solvent1}:{final_solvent2} = {final_pct1:.1f}%:{final_pct2:.1f}%")
|
||||
debug_print(f"📊 体积变化统计:")
|
||||
debug_print(f" 源容器 {from_vessel_id}:")
|
||||
debug_print(f" - 柱层析前: {original_from_volume:.2f}mL")
|
||||
debug_print(f" - 柱层析后: {final_from_volume:.2f}mL")
|
||||
debug_print(f" 目标容器 {to_vessel_id}:")
|
||||
debug_print(f" - 柱层析前: {original_to_volume:.2f}mL")
|
||||
debug_print(f" - 柱层析后: {final_to_volume:.2f}mL")
|
||||
debug_print(f" - 收集体积: {final_to_volume - original_to_volume:.2f}mL")
|
||||
debug_print(f"⏱️ 预计总时间: {len(action_sequence) * 5:.0f} 秒 ⌛")
|
||||
debug_print("🏛️" * 20)
|
||||
|
||||
|
||||
final_from_volume = get_resource_liquid_volume(from_vessel)
|
||||
final_to_volume = get_resource_liquid_volume(to_vessel)
|
||||
|
||||
debug_print(f"柱层析协议生成完成: {len(action_sequence)} 个动作, {from_vessel_id} -> {to_vessel_id}, 收集={final_to_volume - original_to_volume:.2f}mL")
|
||||
|
||||
return action_sequence
|
||||
|
||||
# 🔧 新增:便捷函数
|
||||
def generate_ethyl_acetate_hexane_column_protocol(G: nx.DiGraph, from_vessel: dict, to_vessel: dict,
|
||||
# 便捷函数
|
||||
def generate_ethyl_acetate_hexane_column_protocol(G: nx.DiGraph, from_vessel: dict, to_vessel: dict,
|
||||
column: str, ratio: str = "30:70") -> List[Dict[str, Any]]:
|
||||
"""乙酸乙酯-己烷柱层析(常用组合)"""
|
||||
from_vessel_id = from_vessel["id"]
|
||||
to_vessel_id = to_vessel["id"]
|
||||
debug_print(f"🧪⛽ 乙酸乙酯-己烷柱层析: {from_vessel_id} → {to_vessel_id} @ {ratio}")
|
||||
return generate_run_column_protocol(G, from_vessel, to_vessel, column,
|
||||
return generate_run_column_protocol(G, from_vessel, to_vessel, column,
|
||||
solvent1="ethyl_acetate", solvent2="hexane", ratio=ratio)
|
||||
|
||||
def generate_methanol_dcm_column_protocol(G: nx.DiGraph, from_vessel: dict, to_vessel: dict,
|
||||
def generate_methanol_dcm_column_protocol(G: nx.DiGraph, from_vessel: dict, to_vessel: dict,
|
||||
column: str, ratio: str = "5:95") -> List[Dict[str, Any]]:
|
||||
"""甲醇-二氯甲烷柱层析"""
|
||||
from_vessel_id = from_vessel["id"]
|
||||
to_vessel_id = to_vessel["id"]
|
||||
debug_print(f"🧪🧪 甲醇-DCM柱层析: {from_vessel_id} → {to_vessel_id} @ {ratio}")
|
||||
return generate_run_column_protocol(G, from_vessel, to_vessel, column,
|
||||
return generate_run_column_protocol(G, from_vessel, to_vessel, column,
|
||||
solvent1="methanol", solvent2="dichloromethane", ratio=ratio)
|
||||
|
||||
def generate_gradient_column_protocol(G: nx.DiGraph, from_vessel: dict, to_vessel: dict,
|
||||
column: str, start_ratio: str = "10:90",
|
||||
def generate_gradient_column_protocol(G: nx.DiGraph, from_vessel: dict, to_vessel: dict,
|
||||
column: str, start_ratio: str = "10:90",
|
||||
end_ratio: str = "50:50") -> List[Dict[str, Any]]:
|
||||
"""梯度洗脱柱层析(中等比例)"""
|
||||
from_vessel_id, _ = get_vessel(from_vessel)
|
||||
to_vessel_id, _ = get_vessel(to_vessel)
|
||||
debug_print(f"📈 梯度柱层析: {from_vessel_id} → {to_vessel_id} ({start_ratio} → {end_ratio})")
|
||||
# 使用中间比例作为近似
|
||||
return generate_run_column_protocol(G, from_vessel, to_vessel, column, ratio="30:70")
|
||||
|
||||
def generate_polar_column_protocol(G: nx.DiGraph, from_vessel: dict, to_vessel: dict,
|
||||
def generate_polar_column_protocol(G: nx.DiGraph, from_vessel: dict, to_vessel: dict,
|
||||
column: str) -> List[Dict[str, Any]]:
|
||||
"""极性化合物柱层析(高极性溶剂比例)"""
|
||||
from_vessel_id, _ = get_vessel(from_vessel)
|
||||
to_vessel_id, _ = get_vessel(to_vessel)
|
||||
debug_print(f"⚡ 极性化合物柱层析: {from_vessel_id} → {to_vessel_id}")
|
||||
return generate_run_column_protocol(G, from_vessel, to_vessel, column,
|
||||
return generate_run_column_protocol(G, from_vessel, to_vessel, column,
|
||||
solvent1="ethyl_acetate", solvent2="hexane", ratio="70:30")
|
||||
|
||||
def generate_nonpolar_column_protocol(G: nx.DiGraph, from_vessel: dict, to_vessel: dict,
|
||||
def generate_nonpolar_column_protocol(G: nx.DiGraph, from_vessel: dict, to_vessel: dict,
|
||||
column: str) -> List[Dict[str, Any]]:
|
||||
"""非极性化合物柱层析(低极性溶剂比例)"""
|
||||
from_vessel_id, _ = get_vessel(from_vessel)
|
||||
to_vessel_id, _ = get_vessel(to_vessel)
|
||||
debug_print(f"🛢️ 非极性化合物柱层析: {from_vessel_id} → {to_vessel_id}")
|
||||
return generate_run_column_protocol(G, from_vessel, to_vessel, column,
|
||||
return generate_run_column_protocol(G, from_vessel, to_vessel, column,
|
||||
solvent1="ethyl_acetate", solvent2="hexane", ratio="5:95")
|
||||
|
||||
# 测试函数
|
||||
def test_run_column_protocol():
|
||||
"""测试柱层析协议"""
|
||||
debug_print("🧪 === RUN COLUMN PROTOCOL 测试 === ✨")
|
||||
debug_print("✅ 测试完成 🎉")
|
||||
debug_print("=== RUN COLUMN PROTOCOL 测试 ===")
|
||||
debug_print("测试完成")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_run_column_protocol()
|
||||
|
||||
@@ -1,41 +1,11 @@
|
||||
from functools import partial
|
||||
|
||||
import networkx as nx
|
||||
import re
|
||||
import logging
|
||||
import sys
|
||||
from typing import List, Dict, Any, Union
|
||||
from .utils.vessel_parser import get_vessel
|
||||
from .utils.logger_util import action_log
|
||||
from .utils.vessel_parser import get_vessel, find_solvent_vessel, find_connected_stirrer
|
||||
from .utils.resource_helper import get_resource_liquid_volume, update_vessel_volume
|
||||
from .utils.logger_util import debug_print, action_log
|
||||
from .utils.unit_parser import parse_volume_input
|
||||
from .pump_protocol import generate_pump_protocol_with_rinsing
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 确保输出编码为UTF-8
|
||||
if hasattr(sys.stdout, 'reconfigure'):
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
sys.stderr.reconfigure(encoding='utf-8')
|
||||
except:
|
||||
pass
|
||||
|
||||
def debug_print(message):
|
||||
"""调试输出函数 - 支持中文"""
|
||||
try:
|
||||
# 确保消息是字符串格式
|
||||
safe_message = str(message)
|
||||
logger.info(f"[SEPARATE] {safe_message}")
|
||||
except UnicodeEncodeError:
|
||||
# 如果编码失败,尝试替换不支持的字符
|
||||
safe_message = str(message).encode('utf-8', errors='replace').decode('utf-8')
|
||||
logger.info(f"[SEPARATE] {safe_message}")
|
||||
except Exception as e:
|
||||
# 最后的安全措施
|
||||
fallback_message = f"日志输出错误: {repr(message)}"
|
||||
logger.info(f"[SEPARATE] {fallback_message}")
|
||||
|
||||
create_action_log = partial(action_log, prefix="[SEPARATE]")
|
||||
|
||||
|
||||
def generate_separate_protocol(
|
||||
G: nx.DiGraph,
|
||||
@@ -93,45 +63,33 @@ def generate_separate_protocol(
|
||||
# 🔧 核心修改:从字典中提取容器ID
|
||||
vessel_id, vessel_data = get_vessel(vessel)
|
||||
|
||||
debug_print("🌀" * 20)
|
||||
debug_print("🚀 开始生成分离协议(支持vessel字典和体积运算)✨")
|
||||
debug_print(f"📝 输入参数:")
|
||||
debug_print(f" 🥽 vessel: {vessel} (ID: {vessel_id})")
|
||||
debug_print(f" 🎯 分离目的: '{purpose}'")
|
||||
debug_print(f" 📊 产物相: '{product_phase}'")
|
||||
debug_print(f" 💧 溶剂: '{solvent}'")
|
||||
debug_print(f" 📏 体积: {volume} (类型: {type(volume)})")
|
||||
debug_print(f" 🔄 重复次数: {repeats}")
|
||||
debug_print(f" 🎯 产物容器: '{product_vessel}'")
|
||||
debug_print(f" 🗑️ 废液容器: '{waste_vessel}'")
|
||||
debug_print(f" 📦 其他参数: {kwargs}")
|
||||
debug_print("🌀" * 20)
|
||||
debug_print(f"开始生成分离协议: vessel={vessel_id}, purpose={purpose}, "
|
||||
f"product_phase={product_phase}, solvent={solvent}, "
|
||||
f"volume={volume}, repeats={repeats}")
|
||||
|
||||
action_sequence = []
|
||||
|
||||
# 🔧 新增:记录分离前的容器状态
|
||||
debug_print("🔍 记录分离前容器状态...")
|
||||
original_liquid_volume = get_vessel_liquid_volume(vessel)
|
||||
debug_print(f"📊 分离前液体体积: {original_liquid_volume:.2f}mL")
|
||||
# 记录分离前的容器状态
|
||||
original_liquid_volume = get_resource_liquid_volume(vessel)
|
||||
debug_print(f"分离前液体体积: {original_liquid_volume:.2f}mL")
|
||||
|
||||
# === 参数验证和标准化 ===
|
||||
debug_print("🔍 步骤1: 参数验证和标准化...")
|
||||
action_sequence.append(create_action_log(f"开始分离操作 - 容器: {vessel_id}", "🎬"))
|
||||
action_sequence.append(create_action_log(f"分离目的: {purpose}", "🧪"))
|
||||
action_sequence.append(create_action_log(f"产物相: {product_phase}", "📊"))
|
||||
action_sequence.append(action_log(f"开始分离操作 - 容器: {vessel_id}", "🎬", prefix="[SEPARATE]"))
|
||||
action_sequence.append(action_log(f"分离目的: {purpose}", "🧪", prefix="[SEPARATE]"))
|
||||
action_sequence.append(action_log(f"产物相: {product_phase}", "📊", prefix="[SEPARATE]"))
|
||||
|
||||
# 统一容器参数 - 支持字典和字符串
|
||||
def extract_vessel_id(vessel_param):
|
||||
if isinstance(vessel_param, dict):
|
||||
return vessel_param.get("id", "")
|
||||
elif isinstance(vessel_param, str):
|
||||
return vessel_param
|
||||
else:
|
||||
return ""
|
||||
final_vessel_id = vessel_id
|
||||
|
||||
final_vessel_id, _ = vessel_id
|
||||
final_to_vessel_id, _ = get_vessel(to_vessel) or get_vessel(product_vessel)
|
||||
final_waste_vessel_id, _ = get_vessel(waste_phase_to_vessel) or get_vessel(waste_vessel)
|
||||
to_vessel_result = get_vessel(to_vessel) if to_vessel else None
|
||||
if to_vessel_result is None or to_vessel_result[0] == "":
|
||||
to_vessel_result = get_vessel(product_vessel) if product_vessel else None
|
||||
final_to_vessel_id = to_vessel_result[0] if to_vessel_result else ""
|
||||
|
||||
waste_vessel_result = get_vessel(waste_phase_to_vessel) if waste_phase_to_vessel else None
|
||||
if waste_vessel_result is None or waste_vessel_result[0] == "":
|
||||
waste_vessel_result = get_vessel(waste_vessel) if waste_vessel else None
|
||||
final_waste_vessel_id = waste_vessel_result[0] if waste_vessel_result else ""
|
||||
|
||||
# 统一体积参数
|
||||
final_volume = parse_volume_input(volume or solvent_volume)
|
||||
@@ -141,16 +99,12 @@ def generate_separate_protocol(
|
||||
repeats = 1
|
||||
debug_print(f"⚠️ 重复次数参数 <= 0,自动设置为 1")
|
||||
|
||||
debug_print(f"🔧 标准化后的参数:")
|
||||
debug_print(f" 🥼 分离容器: '{final_vessel_id}'")
|
||||
debug_print(f" 🎯 产物容器: '{final_to_vessel_id}'")
|
||||
debug_print(f" 🗑️ 废液容器: '{final_waste_vessel_id}'")
|
||||
debug_print(f" 📏 溶剂体积: {final_volume}mL")
|
||||
debug_print(f" 🔄 重复次数: {repeats}")
|
||||
debug_print(f"标准化参数: vessel={final_vessel_id}, to={final_to_vessel_id}, "
|
||||
f"waste={final_waste_vessel_id}, volume={final_volume}mL, repeats={repeats}")
|
||||
|
||||
action_sequence.append(create_action_log(f"分离容器: {final_vessel_id}", "🧪"))
|
||||
action_sequence.append(create_action_log(f"溶剂体积: {final_volume}mL", "📏"))
|
||||
action_sequence.append(create_action_log(f"重复次数: {repeats}", "🔄"))
|
||||
action_sequence.append(action_log(f"分离容器: {final_vessel_id}", "🧪", prefix="[SEPARATE]"))
|
||||
action_sequence.append(action_log(f"溶剂体积: {final_volume}mL", "📏", prefix="[SEPARATE]"))
|
||||
action_sequence.append(action_log(f"重复次数: {repeats}", "🔄", prefix="[SEPARATE]"))
|
||||
|
||||
# 验证必需参数
|
||||
if not purpose:
|
||||
@@ -160,72 +114,68 @@ def generate_separate_protocol(
|
||||
if purpose not in ["wash", "extract", "separate"]:
|
||||
debug_print(f"⚠️ 未知的分离目的 '{purpose}',使用默认值 'separate'")
|
||||
purpose = "separate"
|
||||
action_sequence.append(create_action_log(f"未知目的,使用: {purpose}", "⚠️"))
|
||||
action_sequence.append(action_log(f"未知目的,使用: {purpose}", "⚠️", prefix="[SEPARATE]"))
|
||||
if product_phase not in ["top", "bottom"]:
|
||||
debug_print(f"⚠️ 未知的产物相 '{product_phase}',使用默认值 'top'")
|
||||
product_phase = "top"
|
||||
action_sequence.append(create_action_log(f"未知相别,使用: {product_phase}", "⚠️"))
|
||||
action_sequence.append(action_log(f"未知相别,使用: {product_phase}", "⚠️", prefix="[SEPARATE]"))
|
||||
|
||||
debug_print("✅ 参数验证通过")
|
||||
action_sequence.append(create_action_log("参数验证通过", "✅"))
|
||||
action_sequence.append(action_log("参数验证通过", "✅", prefix="[SEPARATE]"))
|
||||
|
||||
# === 查找设备 ===
|
||||
debug_print("🔍 步骤2: 查找设备...")
|
||||
action_sequence.append(create_action_log("正在查找相关设备...", "🔍"))
|
||||
action_sequence.append(action_log("正在查找相关设备...", "🔍", prefix="[SEPARATE]"))
|
||||
|
||||
# 查找分离器设备
|
||||
separator_device = find_separator_device(G, final_vessel_id) # 🔧 使用 final_vessel_id
|
||||
separator_device = find_separator_device(G, final_vessel_id)
|
||||
if separator_device:
|
||||
action_sequence.append(create_action_log(f"找到分离器设备: {separator_device}", "🧪"))
|
||||
action_sequence.append(action_log(f"找到分离器设备: {separator_device}", "🧪", prefix="[SEPARATE]"))
|
||||
else:
|
||||
debug_print("⚠️ 未找到分离器设备,可能无法执行分离")
|
||||
action_sequence.append(create_action_log("未找到分离器设备", "⚠️"))
|
||||
action_sequence.append(action_log("未找到分离器设备", "⚠️", prefix="[SEPARATE]"))
|
||||
|
||||
# 查找搅拌器
|
||||
stirrer_device = find_connected_stirrer(G, final_vessel_id) # 🔧 使用 final_vessel_id
|
||||
stirrer_device = find_connected_stirrer(G, final_vessel_id)
|
||||
if stirrer_device:
|
||||
action_sequence.append(create_action_log(f"找到搅拌器: {stirrer_device}", "🌪️"))
|
||||
action_sequence.append(action_log(f"找到搅拌器: {stirrer_device}", "🌪️", prefix="[SEPARATE]"))
|
||||
else:
|
||||
action_sequence.append(create_action_log("未找到搅拌器", "⚠️"))
|
||||
action_sequence.append(action_log("未找到搅拌器", "⚠️", prefix="[SEPARATE]"))
|
||||
|
||||
# 查找溶剂容器(如果需要)
|
||||
solvent_vessel = ""
|
||||
if solvent and solvent.strip():
|
||||
solvent_vessel = find_solvent_vessel(G, solvent)
|
||||
try:
|
||||
solvent_vessel = find_solvent_vessel(G, solvent)
|
||||
except ValueError:
|
||||
solvent_vessel = ""
|
||||
if solvent_vessel:
|
||||
action_sequence.append(create_action_log(f"找到溶剂容器: {solvent_vessel}", "💧"))
|
||||
action_sequence.append(action_log(f"找到溶剂容器: {solvent_vessel}", "💧", prefix="[SEPARATE]"))
|
||||
else:
|
||||
action_sequence.append(create_action_log(f"未找到溶剂容器: {solvent}", "⚠️"))
|
||||
action_sequence.append(action_log(f"未找到溶剂容器: {solvent}", "⚠️", prefix="[SEPARATE]"))
|
||||
|
||||
debug_print(f"📊 设备配置:")
|
||||
debug_print(f" 🧪 分离器设备: '{separator_device}'")
|
||||
debug_print(f" 🌪️ 搅拌器设备: '{stirrer_device}'")
|
||||
debug_print(f" 💧 溶剂容器: '{solvent_vessel}'")
|
||||
debug_print(f"设备配置: separator={separator_device}, stirrer={stirrer_device}, solvent_vessel={solvent_vessel}")
|
||||
|
||||
# === 执行分离流程 ===
|
||||
debug_print("🔍 步骤3: 执行分离流程...")
|
||||
action_sequence.append(create_action_log("开始分离工作流程", "🎯"))
|
||||
action_sequence.append(action_log("开始分离工作流程", "🎯", prefix="[SEPARATE]"))
|
||||
|
||||
# 🔧 新增:体积变化跟踪变量
|
||||
# 体积变化跟踪变量
|
||||
current_volume = original_liquid_volume
|
||||
|
||||
try:
|
||||
for repeat_idx in range(repeats):
|
||||
cycle_num = repeat_idx + 1
|
||||
debug_print(f"🔄 第{cycle_num}轮: 开始分离循环 {cycle_num}/{repeats}")
|
||||
action_sequence.append(create_action_log(f"分离循环 {cycle_num}/{repeats} 开始", "🔄"))
|
||||
debug_print(f"分离循环 {cycle_num}/{repeats} 开始")
|
||||
action_sequence.append(action_log(f"分离循环 {cycle_num}/{repeats} 开始", "🔄", prefix="[SEPARATE]"))
|
||||
|
||||
# 步骤3.1: 添加溶剂(如果需要)
|
||||
if solvent_vessel and final_volume > 0:
|
||||
debug_print(f"🔄 第{cycle_num}轮 步骤1: 添加溶剂 {solvent} ({final_volume}mL)")
|
||||
action_sequence.append(create_action_log(f"向分离容器添加 {final_volume}mL {solvent}", "💧"))
|
||||
action_sequence.append(action_log(f"向分离容器添加 {final_volume}mL {solvent}", "💧", prefix="[SEPARATE]"))
|
||||
|
||||
try:
|
||||
# 使用pump protocol添加溶剂
|
||||
pump_actions = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
from_vessel=solvent_vessel,
|
||||
to_vessel=final_vessel_id, # 🔧 使用 final_vessel_id
|
||||
to_vessel=final_vessel_id,
|
||||
volume=final_volume,
|
||||
amount="",
|
||||
time=0.0,
|
||||
@@ -242,30 +192,27 @@ def generate_separate_protocol(
|
||||
**kwargs
|
||||
)
|
||||
action_sequence.extend(pump_actions)
|
||||
debug_print(f"✅ 溶剂添加完成,添加了 {len(pump_actions)} 个动作")
|
||||
action_sequence.append(create_action_log(f"溶剂转移完成 ({len(pump_actions)} 个操作)", "✅"))
|
||||
action_sequence.append(action_log(f"溶剂转移完成 ({len(pump_actions)} 个操作)", "✅", prefix="[SEPARATE]"))
|
||||
|
||||
# 🔧 新增:更新体积 - 添加溶剂后
|
||||
# 更新体积 - 添加溶剂后
|
||||
current_volume += final_volume
|
||||
update_vessel_volume(vessel, G, current_volume, f"添加{final_volume}mL {solvent}后")
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"❌ 溶剂添加失败: {str(e)}")
|
||||
action_sequence.append(create_action_log(f"溶剂添加失败: {str(e)}", "❌"))
|
||||
action_sequence.append(action_log(f"溶剂添加失败: {str(e)}", "❌", prefix="[SEPARATE]"))
|
||||
else:
|
||||
debug_print(f"🔄 第{cycle_num}轮 步骤1: 无需添加溶剂")
|
||||
action_sequence.append(create_action_log("无需添加溶剂", "⏭️"))
|
||||
action_sequence.append(action_log("无需添加溶剂", "⏭️", prefix="[SEPARATE]"))
|
||||
|
||||
# 步骤3.2: 启动搅拌(如果有搅拌器)
|
||||
if stirrer_device and stir_time > 0:
|
||||
debug_print(f"🔄 第{cycle_num}轮 步骤2: 开始搅拌 ({stir_speed}rpm,持续 {stir_time}s)")
|
||||
action_sequence.append(create_action_log(f"开始搅拌: {stir_speed}rpm,持续 {stir_time}s", "🌪️"))
|
||||
action_sequence.append(action_log(f"开始搅拌: {stir_speed}rpm,持续 {stir_time}s", "🌪️", prefix="[SEPARATE]"))
|
||||
|
||||
action_sequence.append({
|
||||
"device_id": stirrer_device,
|
||||
"action_name": "start_stir",
|
||||
"action_kwargs": {
|
||||
"vessel": {"id": final_vessel_id}, # 🔧 使用 final_vessel_id
|
||||
"vessel": {"id": final_vessel_id},
|
||||
"stir_speed": stir_speed,
|
||||
"purpose": f"分离混合 - {purpose}"
|
||||
}
|
||||
@@ -273,43 +220,37 @@ def generate_separate_protocol(
|
||||
|
||||
# 搅拌等待
|
||||
stir_minutes = stir_time / 60
|
||||
action_sequence.append(create_action_log(f"搅拌中,持续 {stir_minutes:.1f} 分钟", "⏱️"))
|
||||
action_sequence.append(action_log(f"搅拌中,持续 {stir_minutes:.1f} 分钟", "⏱️", prefix="[SEPARATE]"))
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": stir_time}
|
||||
})
|
||||
|
||||
# 停止搅拌
|
||||
action_sequence.append(create_action_log("停止搅拌器", "🛑"))
|
||||
action_sequence.append(action_log("停止搅拌器", "🛑", prefix="[SEPARATE]"))
|
||||
action_sequence.append({
|
||||
"device_id": stirrer_device,
|
||||
"action_name": "stop_stir",
|
||||
"action_kwargs": {"vessel": final_vessel_id} # 🔧 使用 final_vessel_id
|
||||
"action_kwargs": {"vessel": final_vessel_id}
|
||||
})
|
||||
|
||||
else:
|
||||
debug_print(f"🔄 第{cycle_num}轮 步骤2: 无需搅拌")
|
||||
action_sequence.append(create_action_log("无需搅拌", "⏭️"))
|
||||
action_sequence.append(action_log("无需搅拌", "⏭️", prefix="[SEPARATE]"))
|
||||
|
||||
# 步骤3.3: 静置分层
|
||||
if settling_time > 0:
|
||||
debug_print(f"🔄 第{cycle_num}轮 步骤3: 静置分层 ({settling_time}s)")
|
||||
settling_minutes = settling_time / 60
|
||||
action_sequence.append(create_action_log(f"静置分层 ({settling_minutes:.1f} 分钟)", "⚖️"))
|
||||
action_sequence.append(action_log(f"静置分层 ({settling_minutes:.1f} 分钟)", "⚖️", prefix="[SEPARATE]"))
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": settling_time}
|
||||
})
|
||||
else:
|
||||
debug_print(f"🔄 第{cycle_num}轮 步骤3: 未指定静置时间")
|
||||
action_sequence.append(create_action_log("未指定静置时间", "⏭️"))
|
||||
action_sequence.append(action_log("未指定静置时间", "⏭️", prefix="[SEPARATE]"))
|
||||
|
||||
# 步骤3.4: 执行分离操作
|
||||
if separator_device:
|
||||
debug_print(f"🔄 第{cycle_num}轮 步骤4: 执行分离操作")
|
||||
action_sequence.append(create_action_log(f"执行分离: 收集{product_phase}相", "🧪"))
|
||||
|
||||
# 🔧 替换为具体的分离操作逻辑(基于old版本)
|
||||
action_sequence.append(action_log(f"执行分离: 收集{product_phase}相", "🧪", prefix="[SEPARATE]"))
|
||||
|
||||
# 首先进行分液判断(电导突跃)
|
||||
action_sequence.append({
|
||||
@@ -324,11 +265,10 @@ def generate_separate_protocol(
|
||||
phase_volume = current_volume / 2
|
||||
|
||||
# 智能查找分离容器底部
|
||||
separation_vessel_bottom = find_separation_vessel_bottom(G, final_vessel_id) # ✅
|
||||
separation_vessel_bottom = find_separation_vessel_bottom(G, final_vessel_id)
|
||||
|
||||
if product_phase == "bottom":
|
||||
debug_print(f"🔄 收集底相产物到 {final_to_vessel_id}")
|
||||
action_sequence.append(create_action_log("收集底相产物", "📦"))
|
||||
action_sequence.append(action_log("收集底相产物", "📦", prefix="[SEPARATE]"))
|
||||
|
||||
# 产物转移到目标瓶
|
||||
if final_to_vessel_id:
|
||||
@@ -364,8 +304,7 @@ def generate_separate_protocol(
|
||||
action_sequence.extend(pump_actions)
|
||||
|
||||
elif product_phase == "top":
|
||||
debug_print(f"🔄 收集上相产物到 {final_to_vessel_id}")
|
||||
action_sequence.append(create_action_log("收集上相产物", "📦"))
|
||||
action_sequence.append(action_log("收集上相产物", "📦", prefix="[SEPARATE]"))
|
||||
|
||||
# 弃去下面那一相进废液
|
||||
if final_waste_vessel_id:
|
||||
@@ -400,10 +339,9 @@ def generate_separate_protocol(
|
||||
)
|
||||
action_sequence.extend(pump_actions)
|
||||
|
||||
debug_print(f"✅ 分离操作已完成")
|
||||
action_sequence.append(create_action_log("分离操作完成", "✅"))
|
||||
action_sequence.append(action_log("分离操作完成", "✅", prefix="[SEPARATE]"))
|
||||
|
||||
# 🔧 新增:分离后体积估算
|
||||
# 分离后体积估算
|
||||
separated_volume = phase_volume * 0.95 # 假设5%损失,只保留产物相体积
|
||||
update_vessel_volume(vessel, G, separated_volume, f"分离操作后(第{cycle_num}轮)")
|
||||
current_volume = separated_volume
|
||||
@@ -411,23 +349,21 @@ def generate_separate_protocol(
|
||||
# 收集结果
|
||||
if final_to_vessel_id:
|
||||
action_sequence.append(
|
||||
create_action_log(f"产物 ({product_phase}相) 收集到: {final_to_vessel_id}", "📦"))
|
||||
action_log(f"产物 ({product_phase}相) 收集到: {final_to_vessel_id}", "📦", prefix="[SEPARATE]"))
|
||||
if final_waste_vessel_id:
|
||||
action_sequence.append(create_action_log(f"废相收集到: {final_waste_vessel_id}", "🗑️"))
|
||||
action_sequence.append(action_log(f"废相收集到: {final_waste_vessel_id}", "🗑️", prefix="[SEPARATE]"))
|
||||
|
||||
else:
|
||||
debug_print(f"🔄 第{cycle_num}轮 步骤4: 无分离器设备,跳过分离")
|
||||
action_sequence.append(create_action_log("无分离器设备可用", "❌"))
|
||||
action_sequence.append(action_log("无分离器设备可用", "❌", prefix="[SEPARATE]"))
|
||||
# 添加等待时间模拟分离
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": 10.0}
|
||||
})
|
||||
|
||||
# 🔧 新增:如果不是最后一次,从中转瓶转移回分液漏斗(基于old版本逻辑)
|
||||
# 如果不是最后一次,从中转瓶转移回分液漏斗
|
||||
if repeat_idx < repeats - 1 and final_to_vessel_id and final_to_vessel_id != final_vessel_id:
|
||||
debug_print(f"🔄 第{cycle_num}轮: 产物转移回分离容器准备下一轮")
|
||||
action_sequence.append(create_action_log("产物转回分离容器,准备下一轮", "🔄"))
|
||||
action_sequence.append(action_log("产物转回分离容器,准备下一轮", "🔄", prefix="[SEPARATE]"))
|
||||
|
||||
pump_actions = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
@@ -444,368 +380,85 @@ def generate_separate_protocol(
|
||||
|
||||
# 循环间等待(除了最后一次)
|
||||
if repeat_idx < repeats - 1:
|
||||
debug_print(f"🔄 第{cycle_num}轮: 等待下一次循环...")
|
||||
action_sequence.append(create_action_log("等待下一次循环...", "⏳"))
|
||||
action_sequence.append(action_log("等待下一次循环...", "⏳", prefix="[SEPARATE]"))
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": 5}
|
||||
})
|
||||
else:
|
||||
action_sequence.append(create_action_log(f"分离循环 {cycle_num}/{repeats} 完成", "🌟"))
|
||||
action_sequence.append(action_log(f"分离循环 {cycle_num}/{repeats} 完成", "🌟", prefix="[SEPARATE]"))
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f"❌ 分离工作流程执行失败: {str(e)}")
|
||||
action_sequence.append(create_action_log(f"分离工作流程失败: {str(e)}", "❌"))
|
||||
action_sequence.append(action_log(f"分离工作流程失败: {str(e)}", "❌", prefix="[SEPARATE]"))
|
||||
|
||||
# 🔧 新增:分离完成后的最终状态报告
|
||||
final_liquid_volume = get_vessel_liquid_volume(vessel)
|
||||
# 分离完成后的最终状态报告
|
||||
final_liquid_volume = get_resource_liquid_volume(vessel)
|
||||
|
||||
# === 最终结果 ===
|
||||
total_time = (stir_time + settling_time + 15) * repeats # 估算总时间
|
||||
|
||||
debug_print("🌀" * 20)
|
||||
debug_print(f"🎉 分离协议生成完成")
|
||||
debug_print(f"📊 协议统计:")
|
||||
debug_print(f" 📋 总动作数: {len(action_sequence)}")
|
||||
debug_print(f" ⏱️ 预计总时间: {total_time:.0f}s ({total_time / 60:.1f} 分钟)")
|
||||
debug_print(f" 🥼 分离容器: {final_vessel_id}")
|
||||
debug_print(f" 🎯 分离目的: {purpose}")
|
||||
debug_print(f" 📊 产物相: {product_phase}")
|
||||
debug_print(f" 🔄 重复次数: {repeats}")
|
||||
debug_print(f"💧 体积变化统计:")
|
||||
debug_print(f" - 分离前体积: {original_liquid_volume:.2f}mL")
|
||||
debug_print(f" - 分离后体积: {final_liquid_volume:.2f}mL")
|
||||
if solvent:
|
||||
debug_print(f" 💧 溶剂: {solvent} ({final_volume}mL × {repeats}轮 = {final_volume * repeats:.2f}mL)")
|
||||
if final_to_vessel_id:
|
||||
debug_print(f" 🎯 产物容器: {final_to_vessel_id}")
|
||||
if final_waste_vessel_id:
|
||||
debug_print(f" 🗑️ 废液容器: {final_waste_vessel_id}")
|
||||
debug_print("🌀" * 20)
|
||||
debug_print(f"分离协议生成完成: {len(action_sequence)} 个动作, "
|
||||
f"预计 {total_time:.0f}s, 体积 {original_liquid_volume:.2f}→{final_liquid_volume:.2f}mL")
|
||||
|
||||
# 添加完成日志
|
||||
summary_msg = f"分离协议完成: {final_vessel_id} ({purpose},{repeats} 次循环)"
|
||||
if solvent:
|
||||
summary_msg += f",使用 {final_volume * repeats:.2f}mL {solvent}"
|
||||
action_sequence.append(create_action_log(summary_msg, "🎉"))
|
||||
action_sequence.append(action_log(summary_msg, "🎉", prefix="[SEPARATE]"))
|
||||
|
||||
return action_sequence
|
||||
|
||||
def parse_volume_input(volume_input: Union[str, float]) -> float:
|
||||
"""
|
||||
解析体积输入,支持带单位的字符串
|
||||
|
||||
Args:
|
||||
volume_input: 体积输入(如 "200 mL", "?", 50.0)
|
||||
|
||||
Returns:
|
||||
float: 体积(毫升)
|
||||
"""
|
||||
if isinstance(volume_input, (int, float)):
|
||||
debug_print(f"📏 体积输入为数值: {volume_input}")
|
||||
return float(volume_input)
|
||||
|
||||
if not volume_input or not str(volume_input).strip():
|
||||
debug_print(f"⚠️ 体积输入为空,返回 0.0mL")
|
||||
return 0.0
|
||||
|
||||
volume_str = str(volume_input).lower().strip()
|
||||
debug_print(f"🔍 解析体积输入: '{volume_str}'")
|
||||
|
||||
# 处理未知体积
|
||||
if volume_str in ['?', 'unknown', 'tbd', 'to be determined', '未知', '待定']:
|
||||
default_volume = 100.0 # 默认100mL
|
||||
debug_print(f"❓ 检测到未知体积,使用默认值: {default_volume}mL")
|
||||
return default_volume
|
||||
|
||||
# 移除空格并提取数字和单位
|
||||
volume_clean = re.sub(r'\s+', '', volume_str)
|
||||
|
||||
# 匹配数字和单位的正则表达式
|
||||
match = re.match(r'([0-9]*\.?[0-9]+)\s*(ml|l|μl|ul|microliter|milliliter|liter|毫升|升|微升)?', volume_clean)
|
||||
|
||||
if not match:
|
||||
debug_print(f"⚠️ 无法解析体积: '{volume_str}',使用默认值 100mL")
|
||||
return 100.0
|
||||
|
||||
value = float(match.group(1))
|
||||
unit = match.group(2) or 'ml' # 默认单位为毫升
|
||||
|
||||
# 转换为毫升
|
||||
if unit in ['l', 'liter', '升']:
|
||||
volume = value * 1000.0 # L -> mL
|
||||
debug_print(f"🔄 体积转换: {value}L -> {volume}mL")
|
||||
elif unit in ['μl', 'ul', 'microliter', '微升']:
|
||||
volume = value / 1000.0 # μL -> mL
|
||||
debug_print(f"🔄 体积转换: {value}μL -> {volume}mL")
|
||||
else: # ml, milliliter, 毫升 或默认
|
||||
volume = value # 已经是mL
|
||||
debug_print(f"✅ 体积已为毫升单位: {volume}mL")
|
||||
|
||||
return volume
|
||||
|
||||
def find_solvent_vessel(G: nx.DiGraph, solvent: str) -> str:
|
||||
"""查找溶剂容器,支持多种匹配模式"""
|
||||
if not solvent or not solvent.strip():
|
||||
debug_print("⏭️ 未指定溶剂,跳过溶剂容器查找")
|
||||
return ""
|
||||
|
||||
debug_print(f"🔍 正在查找溶剂 '{solvent}' 的容器...")
|
||||
|
||||
# 🔧 方法1:直接搜索 data.reagent_name 和 config.reagent
|
||||
debug_print(f"📋 方法1: 搜索试剂字段...")
|
||||
for node in G.nodes():
|
||||
node_data = G.nodes[node].get('data', {})
|
||||
node_type = G.nodes[node].get('type', '')
|
||||
config_data = G.nodes[node].get('config', {})
|
||||
|
||||
# 只搜索容器类型的节点
|
||||
if node_type == 'container':
|
||||
reagent_name = node_data.get('reagent_name', '').lower()
|
||||
config_reagent = config_data.get('reagent', '').lower()
|
||||
|
||||
# 精确匹配
|
||||
if reagent_name == solvent.lower() or config_reagent == solvent.lower():
|
||||
debug_print(f"✅ 通过试剂字段精确匹配找到容器: {node}")
|
||||
return node
|
||||
|
||||
# 模糊匹配
|
||||
if (solvent.lower() in reagent_name and reagent_name) or \
|
||||
(solvent.lower() in config_reagent and config_reagent):
|
||||
debug_print(f"✅ 通过试剂字段模糊匹配找到容器: {node}")
|
||||
return node
|
||||
|
||||
# 🔧 方法2:常见的容器命名规则
|
||||
debug_print(f"📋 方法2: 使用命名规则...")
|
||||
solvent_clean = solvent.lower().replace(' ', '_').replace('-', '_')
|
||||
possible_names = [
|
||||
f"flask_{solvent_clean}",
|
||||
f"bottle_{solvent_clean}",
|
||||
f"vessel_{solvent_clean}",
|
||||
f"{solvent_clean}_flask",
|
||||
f"{solvent_clean}_bottle",
|
||||
f"solvent_{solvent_clean}",
|
||||
f"reagent_{solvent_clean}",
|
||||
f"reagent_bottle_{solvent_clean}",
|
||||
f"reagent_bottle_1", # 通用试剂瓶
|
||||
f"reagent_bottle_2",
|
||||
f"reagent_bottle_3"
|
||||
]
|
||||
|
||||
debug_print(f"🎯 尝试的容器名称: {possible_names[:5]}... (共 {len(possible_names)} 个)")
|
||||
|
||||
for name in possible_names:
|
||||
if name in G.nodes():
|
||||
node_type = G.nodes[name].get('type', '')
|
||||
if node_type == 'container':
|
||||
debug_print(f"✅ 通过命名规则找到容器: {name}")
|
||||
return name
|
||||
|
||||
# 🔧 方法3:使用第一个试剂瓶作为备选
|
||||
debug_print(f"📋 方法3: 查找备用试剂瓶...")
|
||||
for node_id in G.nodes():
|
||||
node_data = G.nodes[node_id]
|
||||
if (node_data.get('type') == 'container' and
|
||||
('reagent' in node_id.lower() or 'bottle' in node_id.lower())):
|
||||
debug_print(f"⚠️ 未找到专用容器,使用备用容器: {node_id}")
|
||||
return node_id
|
||||
|
||||
debug_print(f"❌ 无法找到溶剂 '{solvent}' 的容器")
|
||||
return ""
|
||||
|
||||
def find_separator_device(G: nx.DiGraph, vessel: str) -> str:
|
||||
"""查找分离器设备,支持多种查找方式"""
|
||||
debug_print(f"🔍 正在查找容器 '{vessel}' 的分离器设备...")
|
||||
|
||||
# 方法1:查找连接到容器的分离器设备
|
||||
debug_print(f"📋 方法1: 检查连接的分离器...")
|
||||
separator_nodes = []
|
||||
for node in G.nodes():
|
||||
node_class = G.nodes[node].get('class', '').lower()
|
||||
if 'separator' in node_class:
|
||||
separator_nodes.append(node)
|
||||
debug_print(f"📋 发现分离器设备: {node}")
|
||||
|
||||
# 检查是否连接到目标容器
|
||||
if G.has_edge(node, vessel) or G.has_edge(vessel, node):
|
||||
debug_print(f"✅ 找到连接的分离器: {node}")
|
||||
return node
|
||||
|
||||
debug_print(f"📊 找到的分离器总数: {len(separator_nodes)}")
|
||||
|
||||
|
||||
# 方法2:根据命名规则查找
|
||||
debug_print(f"📋 方法2: 使用命名规则...")
|
||||
possible_names = [
|
||||
f"{vessel}_controller",
|
||||
f"{vessel}_separator",
|
||||
vessel, # 容器本身可能就是分离器
|
||||
"separator_1",
|
||||
"virtual_separator",
|
||||
"liquid_handler_1", # 液体处理器也可能用于分离
|
||||
"liquid_handler_1",
|
||||
"controller_1"
|
||||
]
|
||||
|
||||
debug_print(f"🎯 尝试的分离器名称: {possible_names}")
|
||||
|
||||
|
||||
for name in possible_names:
|
||||
if name in G.nodes():
|
||||
node_class = G.nodes[name].get('class', '').lower()
|
||||
if 'separator' in node_class or 'controller' in node_class:
|
||||
debug_print(f"✅ 通过命名规则找到分离器: {name}")
|
||||
return name
|
||||
|
||||
# 方法3:查找第一个分离器设备
|
||||
debug_print(f"📋 方法3: 使用第一个可用分离器...")
|
||||
|
||||
# 方法3:使用第一个可用分离器
|
||||
if separator_nodes:
|
||||
debug_print(f"⚠️ 使用第一个分离器设备: {separator_nodes[0]}")
|
||||
return separator_nodes[0]
|
||||
|
||||
|
||||
debug_print(f"❌ 未找到分离器设备")
|
||||
return ""
|
||||
|
||||
def find_connected_stirrer(G: nx.DiGraph, vessel: str) -> str:
|
||||
"""查找连接到指定容器的搅拌器"""
|
||||
debug_print(f"🔍 正在查找与容器 {vessel} 连接的搅拌器...")
|
||||
|
||||
stirrer_nodes = []
|
||||
for node in G.nodes():
|
||||
node_data = G.nodes[node]
|
||||
node_class = node_data.get('class', '') or ''
|
||||
|
||||
if 'stirrer' in node_class.lower():
|
||||
stirrer_nodes.append(node)
|
||||
debug_print(f"📋 发现搅拌器: {node}")
|
||||
|
||||
debug_print(f"📊 找到的搅拌器总数: {len(stirrer_nodes)}")
|
||||
|
||||
# 检查哪个搅拌器与目标容器相连
|
||||
for stirrer in stirrer_nodes:
|
||||
if G.has_edge(stirrer, vessel) or G.has_edge(vessel, stirrer):
|
||||
debug_print(f"✅ 找到连接的搅拌器: {stirrer}")
|
||||
return stirrer
|
||||
|
||||
# 如果没有连接的搅拌器,返回第一个可用的
|
||||
if stirrer_nodes:
|
||||
debug_print(f"⚠️ 未找到直接连接的搅拌器,使用第一个可用的: {stirrer_nodes[0]}")
|
||||
return stirrer_nodes[0]
|
||||
|
||||
debug_print("❌ 未找到搅拌器")
|
||||
return ""
|
||||
|
||||
def get_vessel_liquid_volume(vessel: dict) -> float:
|
||||
"""
|
||||
获取容器中的液体体积 - 支持vessel字典
|
||||
|
||||
Args:
|
||||
vessel: 容器字典
|
||||
|
||||
Returns:
|
||||
float: 液体体积(mL)
|
||||
"""
|
||||
if not vessel or "data" not in vessel:
|
||||
debug_print(f"⚠️ 容器数据为空,返回 0.0mL")
|
||||
return 0.0
|
||||
|
||||
vessel_data = vessel["data"]
|
||||
vessel_id = vessel.get("id", "unknown")
|
||||
|
||||
debug_print(f"🔍 读取容器 '{vessel_id}' 体积数据: {vessel_data}")
|
||||
|
||||
# 检查liquid_volume字段
|
||||
if "liquid_volume" in vessel_data:
|
||||
liquid_volume = vessel_data["liquid_volume"]
|
||||
|
||||
# 处理列表格式
|
||||
if isinstance(liquid_volume, list):
|
||||
if len(liquid_volume) > 0:
|
||||
volume = liquid_volume[0]
|
||||
if isinstance(volume, (int, float)):
|
||||
debug_print(f"✅ 容器 '{vessel_id}' 体积: {volume}mL (列表格式)")
|
||||
return float(volume)
|
||||
|
||||
# 处理直接数值格式
|
||||
elif isinstance(liquid_volume, (int, float)):
|
||||
debug_print(f"✅ 容器 '{vessel_id}' 体积: {liquid_volume}mL (数值格式)")
|
||||
return float(liquid_volume)
|
||||
|
||||
# 检查其他可能的体积字段
|
||||
volume_keys = ['current_volume', 'total_volume', 'volume']
|
||||
for key in volume_keys:
|
||||
if key in vessel_data:
|
||||
try:
|
||||
volume = float(vessel_data[key])
|
||||
if volume > 0:
|
||||
debug_print(f"✅ 容器 '{vessel_id}' 体积: {volume}mL (字段: {key})")
|
||||
return volume
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
debug_print(f"⚠️ 无法获取容器 '{vessel_id}' 的体积,返回默认值 50.0mL")
|
||||
return 50.0
|
||||
|
||||
def update_vessel_volume(vessel: dict, G: nx.DiGraph, new_volume: float, description: str = "") -> None:
|
||||
"""
|
||||
更新容器体积(同时更新vessel字典和图节点)
|
||||
|
||||
Args:
|
||||
vessel: 容器字典
|
||||
G: 网络图
|
||||
new_volume: 新体积
|
||||
description: 更新描述
|
||||
"""
|
||||
vessel_id = vessel.get("id", "unknown")
|
||||
|
||||
if description:
|
||||
debug_print(f"🔧 更新容器体积 - {description}")
|
||||
|
||||
# 更新vessel字典中的体积
|
||||
if "data" in vessel:
|
||||
if "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
if isinstance(current_volume, list):
|
||||
if len(current_volume) > 0:
|
||||
vessel["data"]["liquid_volume"][0] = new_volume
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = [new_volume]
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = new_volume
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = new_volume
|
||||
else:
|
||||
vessel["data"] = {"liquid_volume": new_volume}
|
||||
|
||||
# 同时更新图中的容器数据
|
||||
if vessel_id in G.nodes():
|
||||
if 'data' not in G.nodes[vessel_id]:
|
||||
G.nodes[vessel_id]['data'] = {}
|
||||
|
||||
vessel_node_data = G.nodes[vessel_id]['data']
|
||||
current_node_volume = vessel_node_data.get('liquid_volume', 0.0)
|
||||
|
||||
if isinstance(current_node_volume, list):
|
||||
if len(current_node_volume) > 0:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'][0] = new_volume
|
||||
else:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = [new_volume]
|
||||
else:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = new_volume
|
||||
|
||||
debug_print(f"📊 容器 '{vessel_id}' 体积已更新为: {new_volume:.2f}mL")
|
||||
|
||||
|
||||
def find_separation_vessel_bottom(G: nx.DiGraph, vessel_id: str) -> str:
|
||||
"""
|
||||
智能查找分离容器的底部容器(假设为flask或vessel类型)
|
||||
|
||||
|
||||
Args:
|
||||
G: 网络图
|
||||
vessel_id: 分离容器ID
|
||||
|
||||
|
||||
Returns:
|
||||
str: 底部容器ID
|
||||
"""
|
||||
debug_print(f"🔍 查找分离容器 {vessel_id} 的底部容器...")
|
||||
|
||||
# 方法1:根据命名规则推测
|
||||
possible_bottoms = [
|
||||
f"{vessel_id}_bottom",
|
||||
@@ -814,32 +467,25 @@ def find_separation_vessel_bottom(G: nx.DiGraph, vessel_id: str) -> str:
|
||||
f"{vessel_id}_flask",
|
||||
f"{vessel_id}_vessel"
|
||||
]
|
||||
|
||||
debug_print(f"📋 尝试的底部容器名称: {possible_bottoms}")
|
||||
|
||||
|
||||
for bottom_id in possible_bottoms:
|
||||
if bottom_id in G.nodes():
|
||||
node_type = G.nodes[bottom_id].get('type', '')
|
||||
if node_type == 'container':
|
||||
debug_print(f"✅ 通过命名规则找到底部容器: {bottom_id}")
|
||||
return bottom_id
|
||||
|
||||
# 方法2:查找与分离器相连的容器(假设底部容器会与分离器相连)
|
||||
debug_print(f"📋 方法2: 查找连接的容器...")
|
||||
|
||||
# 方法2:查找与分离器相连的容器
|
||||
for node in G.nodes():
|
||||
node_data = G.nodes[node]
|
||||
node_class = node_data.get('class', '') or ''
|
||||
|
||||
|
||||
if 'separator' in node_class.lower():
|
||||
# 检查分离器的输入端
|
||||
if G.has_edge(node, vessel_id):
|
||||
for neighbor in G.neighbors(node):
|
||||
if neighbor != vessel_id:
|
||||
neighbor_type = G.nodes[neighbor].get('type', '')
|
||||
if neighbor_type == 'container':
|
||||
debug_print(f"✅ 通过连接找到底部容器: {neighbor}")
|
||||
return neighbor
|
||||
|
||||
|
||||
debug_print(f"❌ 无法找到分离容器 {vessel_id} 的底部容器")
|
||||
return ""
|
||||
|
||||
|
||||
@@ -1,116 +1,40 @@
|
||||
from typing import List, Dict, Any, Union
|
||||
import networkx as nx
|
||||
import logging
|
||||
import re
|
||||
|
||||
from .utils.unit_parser import parse_time_input
|
||||
from .utils.resource_helper import get_resource_id, get_resource_display_info
|
||||
from .utils.logger_util import debug_print
|
||||
from .utils.vessel_parser import find_connected_stirrer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def debug_print(message):
|
||||
"""调试输出"""
|
||||
logger.info(f"[STIR] {message}")
|
||||
|
||||
|
||||
def find_connected_stirrer(G: nx.DiGraph, vessel: str = None) -> str:
|
||||
"""查找与指定容器相连的搅拌设备"""
|
||||
debug_print(f"🔍 查找搅拌设备,目标容器: {vessel} 🥽")
|
||||
|
||||
# 🔧 查找所有搅拌设备
|
||||
stirrer_nodes = []
|
||||
for node in G.nodes():
|
||||
node_data = G.nodes[node]
|
||||
node_class = node_data.get('class', '') or ''
|
||||
|
||||
if 'stirrer' in node_class.lower() or 'virtual_stirrer' in node_class:
|
||||
stirrer_nodes.append(node)
|
||||
debug_print(f"🎉 找到搅拌设备: {node} 🌪️")
|
||||
|
||||
# 🔗 检查连接
|
||||
if vessel and stirrer_nodes:
|
||||
for stirrer in stirrer_nodes:
|
||||
if G.has_edge(stirrer, vessel) or G.has_edge(vessel, stirrer):
|
||||
debug_print(f"✅ 搅拌设备 '{stirrer}' 与容器 '{vessel}' 相连 🔗")
|
||||
return stirrer
|
||||
|
||||
# 🎯 使用第一个可用设备
|
||||
if stirrer_nodes:
|
||||
selected = stirrer_nodes[0]
|
||||
debug_print(f"🔧 使用第一个搅拌设备: {selected} 🌪️")
|
||||
return selected
|
||||
|
||||
# 🆘 默认设备
|
||||
debug_print("⚠️ 未找到搅拌设备,使用默认设备 🌪️")
|
||||
return "stirrer_1"
|
||||
|
||||
def validate_and_fix_params(stir_time: float, stir_speed: float, settling_time: float) -> tuple:
|
||||
"""验证和修正参数"""
|
||||
# ⏰ 搅拌时间验证
|
||||
if stir_time < 0:
|
||||
debug_print(f"⚠️ 搅拌时间 {stir_time}s 无效,修正为 100s 🕐")
|
||||
debug_print(f"搅拌时间 {stir_time}s 无效,修正为 100s")
|
||||
stir_time = 100.0
|
||||
elif stir_time > 100: # 限制为100s
|
||||
debug_print(f"⚠️ 搅拌时间 {stir_time}s 过长,仿真运行时,修正为 100s 🕐")
|
||||
debug_print(f"搅拌时间 {stir_time}s 过长,仿真运行时修正为 100s")
|
||||
stir_time = 100.0
|
||||
else:
|
||||
debug_print(f"✅ 搅拌时间 {stir_time}s ({stir_time/60:.1f}分钟) 有效 ⏰")
|
||||
|
||||
# 🌪️ 搅拌速度验证
|
||||
|
||||
if stir_speed < 10.0 or stir_speed > 1500.0:
|
||||
debug_print(f"⚠️ 搅拌速度 {stir_speed} RPM 超出范围,修正为 300 RPM 🌪️")
|
||||
debug_print(f"搅拌速度 {stir_speed} RPM 超出范围,修正为 300 RPM")
|
||||
stir_speed = 300.0
|
||||
else:
|
||||
debug_print(f"✅ 搅拌速度 {stir_speed} RPM 在正常范围内 🌪️")
|
||||
|
||||
# ⏱️ 沉降时间验证
|
||||
|
||||
if settling_time < 0 or settling_time > 600: # 限制为10分钟
|
||||
debug_print(f"⚠️ 沉降时间 {settling_time}s 超出范围,修正为 60s ⏱️")
|
||||
debug_print(f"沉降时间 {settling_time}s 超出范围,修正为 60s")
|
||||
settling_time = 60.0
|
||||
else:
|
||||
debug_print(f"✅ 沉降时间 {settling_time}s 在正常范围内 ⏱️")
|
||||
|
||||
|
||||
return stir_time, stir_speed, settling_time
|
||||
|
||||
def extract_vessel_id(vessel: Union[str, dict]) -> str:
|
||||
"""
|
||||
从vessel参数中提取vessel_id
|
||||
|
||||
Args:
|
||||
vessel: vessel字典或vessel_id字符串
|
||||
|
||||
Returns:
|
||||
str: vessel_id
|
||||
"""
|
||||
if isinstance(vessel, dict):
|
||||
vessel_id = list(vessel.values())[0].get("id", "")
|
||||
debug_print(f"🔧 从vessel字典提取ID: {vessel_id}")
|
||||
return vessel_id
|
||||
elif isinstance(vessel, str):
|
||||
debug_print(f"🔧 vessel参数为字符串: {vessel}")
|
||||
return vessel
|
||||
else:
|
||||
debug_print(f"⚠️ 无效的vessel参数类型: {type(vessel)}")
|
||||
return ""
|
||||
def extract_vessel_id(vessel) -> str:
|
||||
"""从vessel参数中提取vessel_id,兼容 str / dict / ResourceDictInstance"""
|
||||
return get_resource_id(vessel)
|
||||
|
||||
def get_vessel_display_info(vessel: Union[str, dict]) -> str:
|
||||
"""
|
||||
获取容器的显示信息(用于日志)
|
||||
|
||||
Args:
|
||||
vessel: vessel字典或vessel_id字符串
|
||||
|
||||
Returns:
|
||||
str: 显示信息
|
||||
"""
|
||||
if isinstance(vessel, dict):
|
||||
vessel_id = vessel.get("id", "unknown")
|
||||
vessel_name = vessel.get("name", "")
|
||||
if vessel_name:
|
||||
return f"{vessel_id} ({vessel_name})"
|
||||
else:
|
||||
return vessel_id
|
||||
else:
|
||||
return str(vessel)
|
||||
def get_vessel_display_info(vessel) -> str:
|
||||
"""获取容器的显示信息(用于日志),兼容 str / dict / ResourceDictInstance"""
|
||||
return get_resource_display_info(vessel)
|
||||
|
||||
def generate_stir_protocol(
|
||||
G: nx.DiGraph,
|
||||
@@ -125,16 +49,13 @@ def generate_stir_protocol(
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""生成搅拌操作的协议序列 - 修复vessel参数传递"""
|
||||
|
||||
# 🔧 核心修改:正确处理vessel参数
|
||||
vessel_id = extract_vessel_id(vessel)
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
|
||||
# 🔧 关键修复:确保vessel_resource是完整的Resource对象
|
||||
|
||||
# 确保vessel_resource是完整的Resource对象
|
||||
if isinstance(vessel, dict):
|
||||
vessel_resource = vessel # 已经是完整的Resource字典
|
||||
debug_print(f"✅ 使用传入的vessel Resource对象")
|
||||
vessel_resource = vessel
|
||||
else:
|
||||
# 如果只是字符串,构建一个基本的Resource对象
|
||||
vessel_resource = {
|
||||
"id": vessel,
|
||||
"name": "",
|
||||
@@ -150,91 +71,60 @@ def generate_stir_protocol(
|
||||
"sample_id": "",
|
||||
"type": ""
|
||||
}
|
||||
debug_print(f"🔧 构建了基本的vessel Resource对象: {vessel}")
|
||||
|
||||
debug_print("🌪️" * 20)
|
||||
debug_print("🚀 开始生成搅拌协议(支持vessel字典)✨")
|
||||
debug_print(f"📝 输入参数:")
|
||||
debug_print(f" 🥽 vessel: {vessel_display} (ID: {vessel_id})")
|
||||
debug_print(f" ⏰ time: {time}")
|
||||
debug_print(f" 🕐 stir_time: {stir_time}")
|
||||
debug_print(f" 🎯 time_spec: {time_spec}")
|
||||
debug_print(f" 🌪️ stir_speed: {stir_speed} RPM")
|
||||
debug_print(f" ⏱️ settling_time: {settling_time}")
|
||||
debug_print("🌪️" * 20)
|
||||
|
||||
# 📋 参数验证
|
||||
debug_print("📍 步骤1: 参数验证... 🔧")
|
||||
if not vessel_id: # 🔧 使用 vessel_id
|
||||
debug_print("❌ vessel 参数不能为空! 😱")
|
||||
|
||||
# 参数验证
|
||||
if not vessel_id:
|
||||
raise ValueError("vessel 参数不能为空")
|
||||
|
||||
if vessel_id not in G.nodes(): # 🔧 使用 vessel_id
|
||||
debug_print(f"❌ 容器 '{vessel_id}' 不存在于系统中! 😞")
|
||||
|
||||
if vessel_id not in G.nodes():
|
||||
raise ValueError(f"容器 '{vessel_id}' 不存在于系统中")
|
||||
|
||||
debug_print("✅ 基础参数验证通过 🎯")
|
||||
|
||||
# 🔄 参数解析
|
||||
debug_print("📍 步骤2: 参数解析... ⚡")
|
||||
|
||||
# 确定实际时间(优先级:time_spec > stir_time > time)
|
||||
# 参数解析 — 确定实际时间(优先级:time_spec > stir_time > time)
|
||||
if time_spec:
|
||||
parsed_time = parse_time_input(time_spec)
|
||||
debug_print(f"🎯 使用time_spec: '{time_spec}' → {parsed_time}s")
|
||||
elif stir_time not in ["0", 0, 0.0]:
|
||||
parsed_time = parse_time_input(stir_time)
|
||||
debug_print(f"🎯 使用stir_time: {stir_time} → {parsed_time}s")
|
||||
else:
|
||||
parsed_time = parse_time_input(time)
|
||||
debug_print(f"🎯 使用time: {time} → {parsed_time}s")
|
||||
|
||||
# 解析沉降时间
|
||||
parsed_settling_time = parse_time_input(settling_time)
|
||||
|
||||
# 🕐 模拟运行时间优化
|
||||
debug_print(" ⏱️ 检查模拟运行时间限制...")
|
||||
# 模拟运行时间优化
|
||||
original_stir_time = parsed_time
|
||||
original_settling_time = parsed_settling_time
|
||||
|
||||
|
||||
# 搅拌时间限制为60秒
|
||||
stir_time_limit = 60.0
|
||||
if parsed_time > stir_time_limit:
|
||||
parsed_time = stir_time_limit
|
||||
debug_print(f" 🎮 搅拌时间优化: {original_stir_time}s → {parsed_time}s ⚡")
|
||||
|
||||
|
||||
# 沉降时间限制为30秒
|
||||
settling_time_limit = 30.0
|
||||
if parsed_settling_time > settling_time_limit:
|
||||
parsed_settling_time = settling_time_limit
|
||||
debug_print(f" 🎮 沉降时间优化: {original_settling_time}s → {parsed_settling_time}s ⚡")
|
||||
|
||||
# 参数修正
|
||||
parsed_time, stir_speed, parsed_settling_time = validate_and_fix_params(
|
||||
parsed_time, stir_speed, parsed_settling_time
|
||||
)
|
||||
|
||||
debug_print(f"🎯 最终参数: time={parsed_time}s, speed={stir_speed}RPM, settling={parsed_settling_time}s")
|
||||
|
||||
# 🔍 查找设备
|
||||
debug_print("📍 步骤3: 查找搅拌设备... 🔍")
|
||||
debug_print(f"最终参数: time={parsed_time}s, speed={stir_speed}RPM, settling={parsed_settling_time}s")
|
||||
|
||||
# 查找设备
|
||||
try:
|
||||
stirrer_id = find_connected_stirrer(G, vessel_id) # 🔧 使用 vessel_id
|
||||
debug_print(f"🎉 使用搅拌设备: {stirrer_id} ✨")
|
||||
stirrer_id = find_connected_stirrer(G, vessel_id)
|
||||
except Exception as e:
|
||||
debug_print(f"❌ 设备查找失败: {str(e)} 😭")
|
||||
raise ValueError(f"无法找到搅拌设备: {str(e)}")
|
||||
|
||||
# 🚀 生成动作
|
||||
debug_print("📍 步骤4: 生成搅拌动作... 🌪️")
|
||||
|
||||
# 生成动作
|
||||
|
||||
action_sequence = []
|
||||
stir_action = {
|
||||
"device_id": stirrer_id,
|
||||
"action_name": "stir",
|
||||
"action_kwargs": {
|
||||
# 🔧 关键修复:传递vessel_id字符串,而不是完整的Resource对象
|
||||
"vessel": {"id": vessel_id}, # 传递字符串ID,不是Resource对象
|
||||
"vessel": {"id": vessel_id},
|
||||
"time": str(time),
|
||||
"event": event,
|
||||
"time_spec": time_spec,
|
||||
@@ -244,22 +134,14 @@ def generate_stir_protocol(
|
||||
}
|
||||
}
|
||||
action_sequence.append(stir_action)
|
||||
debug_print("✅ 搅拌动作已添加 🌪️✨")
|
||||
|
||||
# 显示时间优化信息
|
||||
|
||||
# 时间优化信息
|
||||
if original_stir_time != parsed_time or original_settling_time != parsed_settling_time:
|
||||
debug_print(f" 🎭 模拟优化说明:")
|
||||
debug_print(f" 搅拌时间: {original_stir_time/60:.1f}分钟 → {parsed_time/60:.1f}分钟")
|
||||
debug_print(f" 沉降时间: {original_settling_time/60:.1f}分钟 → {parsed_settling_time/60:.1f}分钟")
|
||||
|
||||
# 🎊 总结
|
||||
debug_print("🎊" * 20)
|
||||
debug_print(f"🎉 搅拌协议生成完成! ✨")
|
||||
debug_print(f"📊 总动作数: {len(action_sequence)} 个")
|
||||
debug_print(f"🥽 搅拌容器: {vessel_display}")
|
||||
debug_print(f"🌪️ 搅拌参数: {stir_speed} RPM, {parsed_time}s, 沉降 {parsed_settling_time}s")
|
||||
debug_print(f"⏱️ 预计总时间: {(parsed_time + parsed_settling_time)/60:.1f} 分钟 ⌛")
|
||||
debug_print("🎊" * 20)
|
||||
debug_print(f"模拟优化: 搅拌 {original_stir_time/60:.1f}min→{parsed_time/60:.1f}min, "
|
||||
f"沉降 {original_settling_time/60:.1f}min→{parsed_settling_time/60:.1f}min")
|
||||
|
||||
debug_print(f"搅拌协议生成完成: {vessel_display}, {stir_speed}RPM, "
|
||||
f"{parsed_time}s, 沉降{parsed_settling_time}s, 总{(parsed_time + parsed_settling_time)/60:.1f}min")
|
||||
|
||||
return action_sequence
|
||||
|
||||
@@ -272,16 +154,13 @@ def generate_start_stir_protocol(
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""生成开始搅拌操作的协议序列 - 修复vessel参数传递"""
|
||||
|
||||
# 🔧 核心修改:正确处理vessel参数
|
||||
vessel_id = extract_vessel_id(vessel)
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
|
||||
# 🔧 关键修复:确保vessel_resource是完整的Resource对象
|
||||
|
||||
# 确保vessel_resource是完整的Resource对象
|
||||
if isinstance(vessel, dict):
|
||||
vessel_resource = vessel # 已经是完整的Resource字典
|
||||
debug_print(f"✅ 使用传入的vessel Resource对象")
|
||||
vessel_resource = vessel
|
||||
else:
|
||||
# 如果只是字符串,构建一个基本的Resource对象
|
||||
vessel_resource = {
|
||||
"id": vessel,
|
||||
"name": "",
|
||||
@@ -297,39 +176,29 @@ def generate_start_stir_protocol(
|
||||
"sample_id": "",
|
||||
"type": ""
|
||||
}
|
||||
debug_print(f"🔧 构建了基本的vessel Resource对象: {vessel}")
|
||||
|
||||
debug_print("🔄 开始生成启动搅拌协议(修复vessel参数)✨")
|
||||
debug_print(f"🥽 vessel: {vessel_display} (ID: {vessel_id})")
|
||||
debug_print(f"🌪️ speed: {stir_speed} RPM")
|
||||
debug_print(f"🎯 purpose: {purpose}")
|
||||
|
||||
|
||||
# 基础验证
|
||||
if not vessel_id or vessel_id not in G.nodes():
|
||||
debug_print("❌ 容器验证失败!")
|
||||
raise ValueError("vessel 参数无效")
|
||||
|
||||
|
||||
# 参数修正
|
||||
if stir_speed < 10.0 or stir_speed > 1500.0:
|
||||
debug_print(f"⚠️ 搅拌速度修正: {stir_speed} → 300 RPM 🌪️")
|
||||
stir_speed = 300.0
|
||||
|
||||
|
||||
# 查找设备
|
||||
stirrer_id = find_connected_stirrer(G, vessel_id)
|
||||
|
||||
# 🔧 关键修复:传递vessel_id字符串
|
||||
|
||||
action_sequence = [{
|
||||
"device_id": stirrer_id,
|
||||
"action_name": "start_stir",
|
||||
"action_kwargs": {
|
||||
# 🔧 关键修复:传递vessel_id字符串,而不是完整的Resource对象
|
||||
"vessel": {"id": vessel_id}, # 传递字符串ID,不是Resource对象
|
||||
"vessel": {"id": vessel_id},
|
||||
"stir_speed": stir_speed,
|
||||
"purpose": purpose or f"启动搅拌 {stir_speed} RPM"
|
||||
}
|
||||
}]
|
||||
|
||||
debug_print(f"✅ 启动搅拌协议生成完成 🎯")
|
||||
|
||||
debug_print(f"启动搅拌协议: {vessel_display}, {stir_speed}RPM, device={stirrer_id}")
|
||||
return action_sequence
|
||||
|
||||
def generate_stop_stir_protocol(
|
||||
@@ -339,16 +208,13 @@ def generate_stop_stir_protocol(
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""生成停止搅拌操作的协议序列 - 修复vessel参数传递"""
|
||||
|
||||
# 🔧 核心修改:正确处理vessel参数
|
||||
vessel_id = extract_vessel_id(vessel)
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
|
||||
# 🔧 关键修复:确保vessel_resource是完整的Resource对象
|
||||
|
||||
# 确保vessel_resource是完整的Resource对象
|
||||
if isinstance(vessel, dict):
|
||||
vessel_resource = vessel # 已经是完整的Resource字典
|
||||
debug_print(f"✅ 使用传入的vessel Resource对象")
|
||||
vessel_resource = vessel
|
||||
else:
|
||||
# 如果只是字符串,构建一个基本的Resource对象
|
||||
vessel_resource = {
|
||||
"id": vessel,
|
||||
"name": "",
|
||||
@@ -364,115 +230,103 @@ def generate_stop_stir_protocol(
|
||||
"sample_id": "",
|
||||
"type": ""
|
||||
}
|
||||
debug_print(f"🔧 构建了基本的vessel Resource对象: {vessel}")
|
||||
|
||||
debug_print("🛑 开始生成停止搅拌协议(修复vessel参数)✨")
|
||||
debug_print(f"🥽 vessel: {vessel_display} (ID: {vessel_id})")
|
||||
|
||||
|
||||
# 基础验证
|
||||
if not vessel_id or vessel_id not in G.nodes():
|
||||
debug_print("❌ 容器验证失败!")
|
||||
raise ValueError("vessel 参数无效")
|
||||
|
||||
|
||||
# 查找设备
|
||||
stirrer_id = find_connected_stirrer(G, vessel_id)
|
||||
|
||||
# 🔧 关键修复:传递vessel_id字符串
|
||||
|
||||
action_sequence = [{
|
||||
"device_id": stirrer_id,
|
||||
"action_name": "stop_stir",
|
||||
"action_kwargs": {
|
||||
# 🔧 关键修复:传递vessel_id字符串,而不是完整的Resource对象
|
||||
"vessel": {"id": vessel_id}, # 传递字符串ID,不是Resource对象
|
||||
"vessel": {"id": vessel_id},
|
||||
}
|
||||
}]
|
||||
|
||||
debug_print(f"✅ 停止搅拌协议生成完成 🎯")
|
||||
|
||||
debug_print(f"停止搅拌协议: {vessel_display}, device={stirrer_id}")
|
||||
return action_sequence
|
||||
|
||||
# 🔧 新增:便捷函数
|
||||
# 便捷函数
|
||||
def stir_briefly(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
speed: float = 300.0) -> List[Dict[str, Any]]:
|
||||
"""短时间搅拌(30秒)"""
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
debug_print(f"⚡ 短时间搅拌: {vessel_display} @ {speed}RPM (30s)")
|
||||
debug_print(f"短时间搅拌: {vessel_display} @ {speed}RPM (30s)")
|
||||
return generate_stir_protocol(G, vessel, time="30", stir_speed=speed)
|
||||
|
||||
def stir_slowly(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
time: Union[str, float] = "10 min") -> List[Dict[str, Any]]:
|
||||
"""慢速搅拌"""
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
debug_print(f"🐌 慢速搅拌: {vessel_display} @ 150RPM")
|
||||
debug_print(f"慢速搅拌: {vessel_display} @ 150RPM")
|
||||
return generate_stir_protocol(G, vessel, time=time, stir_speed=150.0)
|
||||
|
||||
def stir_vigorously(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
time: Union[str, float] = "5 min") -> List[Dict[str, Any]]:
|
||||
"""剧烈搅拌"""
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
debug_print(f"💨 剧烈搅拌: {vessel_display} @ 800RPM")
|
||||
debug_print(f"剧烈搅拌: {vessel_display} @ 800RPM")
|
||||
return generate_stir_protocol(G, vessel, time=time, stir_speed=800.0)
|
||||
|
||||
def stir_for_reaction(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
time: Union[str, float] = "1 h") -> List[Dict[str, Any]]:
|
||||
"""反应搅拌(标准速度,长时间)"""
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
debug_print(f"🧪 反应搅拌: {vessel_display} @ 400RPM")
|
||||
debug_print(f"反应搅拌: {vessel_display} @ 400RPM")
|
||||
return generate_stir_protocol(G, vessel, time=time, stir_speed=400.0)
|
||||
|
||||
def stir_for_dissolution(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
time: Union[str, float] = "15 min") -> List[Dict[str, Any]]:
|
||||
"""溶解搅拌(中等速度)"""
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
debug_print(f"💧 溶解搅拌: {vessel_display} @ 500RPM")
|
||||
debug_print(f"溶解搅拌: {vessel_display} @ 500RPM")
|
||||
return generate_stir_protocol(G, vessel, time=time, stir_speed=500.0)
|
||||
|
||||
def stir_gently(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
time: Union[str, float] = "30 min") -> List[Dict[str, Any]]:
|
||||
"""温和搅拌"""
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
debug_print(f"🍃 温和搅拌: {vessel_display} @ 200RPM")
|
||||
debug_print(f"温和搅拌: {vessel_display} @ 200RPM")
|
||||
return generate_stir_protocol(G, vessel, time=time, stir_speed=200.0)
|
||||
|
||||
def stir_overnight(G: nx.DiGraph, vessel: Union[str, dict]) -> List[Dict[str, Any]]:
|
||||
"""过夜搅拌(模拟时缩短为2小时)"""
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
debug_print(f"🌙 过夜搅拌(模拟2小时): {vessel_display} @ 300RPM")
|
||||
debug_print(f"过夜搅拌(模拟2小时): {vessel_display} @ 300RPM")
|
||||
return generate_stir_protocol(G, vessel, time="2 h", stir_speed=300.0)
|
||||
|
||||
def start_continuous_stirring(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
speed: float = 300.0, purpose: str = "continuous stirring") -> List[Dict[str, Any]]:
|
||||
"""开始连续搅拌"""
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
debug_print(f"🔄 开始连续搅拌: {vessel_display} @ {speed}RPM")
|
||||
debug_print(f"开始连续搅拌: {vessel_display} @ {speed}RPM")
|
||||
return generate_start_stir_protocol(G, vessel, stir_speed=speed, purpose=purpose)
|
||||
|
||||
def stop_all_stirring(G: nx.DiGraph, vessel: Union[str, dict]) -> List[Dict[str, Any]]:
|
||||
"""停止所有搅拌"""
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
debug_print(f"🛑 停止搅拌: {vessel_display}")
|
||||
debug_print(f"停止搅拌: {vessel_display}")
|
||||
return generate_stop_stir_protocol(G, vessel)
|
||||
|
||||
# 测试函数
|
||||
def test_stir_protocol():
|
||||
"""测试搅拌协议"""
|
||||
debug_print("🧪 === STIR PROTOCOL 测试 === ✨")
|
||||
|
||||
# 测试vessel参数处理
|
||||
debug_print("🔧 测试vessel参数处理...")
|
||||
|
||||
# 测试字典格式
|
||||
vessel_dict = {"id": "flask_1", "name": "反应瓶1"}
|
||||
vessel_id = extract_vessel_id(vessel_dict)
|
||||
vessel_display = get_vessel_display_info(vessel_dict)
|
||||
debug_print(f" 字典格式: {vessel_dict} → ID: {vessel_id}, 显示: {vessel_display}")
|
||||
|
||||
debug_print(f"字典格式: {vessel_dict} -> ID: {vessel_id}, 显示: {vessel_display}")
|
||||
|
||||
# 测试字符串格式
|
||||
vessel_str = "flask_2"
|
||||
vessel_id = extract_vessel_id(vessel_str)
|
||||
vessel_display = get_vessel_display_info(vessel_str)
|
||||
debug_print(f" 字符串格式: {vessel_str} → ID: {vessel_id}, 显示: {vessel_display}")
|
||||
|
||||
debug_print("✅ 测试完成 🎉")
|
||||
debug_print(f"字符串格式: {vessel_str} -> ID: {vessel_id}, 显示: {vessel_display}")
|
||||
|
||||
debug_print("测试完成")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_stir_protocol()
|
||||
|
||||
@@ -1,36 +1,57 @@
|
||||
# 🆕 创建进度日志动作
|
||||
"""编译器共享日志工具"""
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
# 模块名到前缀的映射
|
||||
_MODULE_PREFIXES = {
|
||||
"add_protocol": "[ADD]",
|
||||
"adjustph_protocol": "[ADJUSTPH]",
|
||||
"clean_vessel_protocol": "[CLEAN_VESSEL]",
|
||||
"dissolve_protocol": "[DISSOLVE]",
|
||||
"dry_protocol": "[DRY]",
|
||||
"evacuateandrefill_protocol": "[EVACUATE]",
|
||||
"evaporate_protocol": "[EVAPORATE]",
|
||||
"filter_protocol": "[FILTER]",
|
||||
"heatchill_protocol": "[HEATCHILL]",
|
||||
"hydrogenate_protocol": "[HYDROGENATE]",
|
||||
"pump_protocol": "[PUMP]",
|
||||
"recrystallize_protocol": "[RECRYSTALLIZE]",
|
||||
"reset_handling_protocol": "[RESET]",
|
||||
"run_column_protocol": "[RUN_COLUMN]",
|
||||
"separate_protocol": "[SEPARATE]",
|
||||
"stir_protocol": "[STIR]",
|
||||
"wash_solid_protocol": "[WASH_SOLID]",
|
||||
"vessel_parser": "[VESSEL_PARSER]",
|
||||
"unit_parser": "[UNIT_PARSER]",
|
||||
"resource_helper": "[RESOURCE_HELPER]",
|
||||
}
|
||||
|
||||
def debug_print(message, prefix="[UNIT_PARSER]"):
|
||||
"""调试输出"""
|
||||
|
||||
def debug_print(message, prefix=None):
|
||||
"""调试输出 — 自动根据调用模块设置前缀"""
|
||||
if prefix is None:
|
||||
frame = inspect.currentframe()
|
||||
caller = frame.f_back if frame else None
|
||||
module_name = ""
|
||||
if caller:
|
||||
module_name = caller.f_globals.get("__name__", "")
|
||||
# 取最后一段作为模块短名
|
||||
module_name = module_name.rsplit(".", 1)[-1]
|
||||
prefix = _MODULE_PREFIXES.get(module_name, f"[{module_name.upper()}]")
|
||||
logger = logging.getLogger("unilabos.compile")
|
||||
logger.info(f"{prefix} {message}")
|
||||
|
||||
|
||||
def action_log(message: str, emoji: str = "📝", prefix="[HIGH-LEVEL OPERATION]") -> Dict[str, Any]:
|
||||
"""创建一个动作日志 - 支持中文和emoji"""
|
||||
try:
|
||||
full_message = f"{prefix} {emoji} {message}"
|
||||
|
||||
return {
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {
|
||||
"time": 0.1,
|
||||
"log_message": full_message,
|
||||
"progress_message": full_message
|
||||
}
|
||||
"""创建一个动作日志"""
|
||||
full_message = f"{prefix} {emoji} {message}"
|
||||
return {
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {
|
||||
"time": 0.1,
|
||||
"log_message": full_message,
|
||||
"progress_message": full_message
|
||||
}
|
||||
except Exception as e:
|
||||
# 如果emoji有问题,使用纯文本
|
||||
safe_message = f"{prefix} {message}"
|
||||
|
||||
return {
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {
|
||||
"time": 0.1,
|
||||
"log_message": safe_message,
|
||||
"progress_message": safe_message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
172
unilabos/compile/utils/resource_helper.py
Normal file
172
unilabos/compile/utils/resource_helper.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
资源实例兼容层
|
||||
|
||||
提供 ensure_resource_instance() 将 dict / ResourceDictInstance 统一转为
|
||||
ResourceDictInstance,使编译器可以渐进式迁移到强类型资源。
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional, Union
|
||||
|
||||
from unilabos.resources.resource_tracker import ResourceDictInstance
|
||||
|
||||
|
||||
def ensure_resource_instance(
|
||||
resource: Union[Dict[str, Any], ResourceDictInstance, None],
|
||||
) -> Optional[ResourceDictInstance]:
|
||||
"""将 dict 或 ResourceDictInstance 统一转为 ResourceDictInstance
|
||||
|
||||
编译器入口统一调用此函数,即可同时兼容旧 dict 传参和新 ResourceDictInstance 传参。
|
||||
|
||||
Args:
|
||||
resource: 资源数据,可以是 plain dict、ResourceDictInstance 或 None
|
||||
|
||||
Returns:
|
||||
ResourceDictInstance 或 None(当输入为 None 时)
|
||||
"""
|
||||
if resource is None:
|
||||
return None
|
||||
if isinstance(resource, ResourceDictInstance):
|
||||
return resource
|
||||
if isinstance(resource, dict):
|
||||
return ResourceDictInstance.get_resource_instance_from_dict(resource)
|
||||
raise TypeError(f"不支持的资源类型: {type(resource)}, 期望 dict 或 ResourceDictInstance")
|
||||
|
||||
|
||||
def resource_to_dict(resource: Union[Dict[str, Any], ResourceDictInstance]) -> Dict[str, Any]:
|
||||
"""将 ResourceDictInstance 或 dict 统一转为 plain dict
|
||||
|
||||
用于需要 dict 操作的场景(如 children dict 操作)。
|
||||
|
||||
Args:
|
||||
resource: ResourceDictInstance 或 dict
|
||||
|
||||
Returns:
|
||||
plain dict
|
||||
"""
|
||||
if isinstance(resource, dict):
|
||||
return resource
|
||||
if isinstance(resource, ResourceDictInstance):
|
||||
return resource.get_plr_nested_dict()
|
||||
raise TypeError(f"不支持的资源类型: {type(resource)}")
|
||||
|
||||
|
||||
def get_resource_id(resource: Union[str, Dict[str, Any], ResourceDictInstance]) -> str:
|
||||
"""从资源对象中提取 ID
|
||||
|
||||
Args:
|
||||
resource: 字符串 ID、dict 或 ResourceDictInstance
|
||||
|
||||
Returns:
|
||||
资源 ID 字符串
|
||||
"""
|
||||
if isinstance(resource, str):
|
||||
return resource
|
||||
if isinstance(resource, ResourceDictInstance):
|
||||
return resource.res_content.id
|
||||
if isinstance(resource, dict):
|
||||
if "id" in resource:
|
||||
return resource["id"]
|
||||
# 兼容 {station_id: {...}} 格式
|
||||
first_val = next(iter(resource.values()), {})
|
||||
if isinstance(first_val, dict):
|
||||
return first_val.get("id", "")
|
||||
return ""
|
||||
raise TypeError(f"不支持的资源类型: {type(resource)}")
|
||||
|
||||
|
||||
def get_resource_data(resource: Union[str, Dict[str, Any], ResourceDictInstance]) -> Dict[str, Any]:
|
||||
"""从资源对象中提取 data 字段
|
||||
|
||||
Args:
|
||||
resource: 字符串、dict 或 ResourceDictInstance
|
||||
|
||||
Returns:
|
||||
data 字典
|
||||
"""
|
||||
if isinstance(resource, str):
|
||||
return {}
|
||||
if isinstance(resource, ResourceDictInstance):
|
||||
return dict(resource.res_content.data)
|
||||
if isinstance(resource, dict):
|
||||
return resource.get("data", {})
|
||||
return {}
|
||||
|
||||
|
||||
def get_resource_display_info(resource: Union[str, Dict[str, Any], ResourceDictInstance]) -> str:
|
||||
"""获取资源的显示信息(用于日志)
|
||||
|
||||
Args:
|
||||
resource: 字符串 ID、dict 或 ResourceDictInstance
|
||||
|
||||
Returns:
|
||||
显示信息字符串
|
||||
"""
|
||||
if isinstance(resource, str):
|
||||
return resource
|
||||
if isinstance(resource, ResourceDictInstance):
|
||||
res = resource.res_content
|
||||
return f"{res.id} ({res.name})" if res.name and res.name != res.id else res.id
|
||||
if isinstance(resource, dict):
|
||||
res_id = resource.get("id", "unknown")
|
||||
res_name = resource.get("name", "")
|
||||
if res_name and res_name != res_id:
|
||||
return f"{res_id} ({res_name})"
|
||||
return res_id
|
||||
return str(resource)
|
||||
|
||||
|
||||
def get_resource_liquid_volume(resource: Union[Dict[str, Any], ResourceDictInstance]) -> float:
|
||||
"""从资源中获取液体体积
|
||||
|
||||
Args:
|
||||
resource: dict 或 ResourceDictInstance
|
||||
|
||||
Returns:
|
||||
液体总体积 (mL)
|
||||
"""
|
||||
data = get_resource_data(resource)
|
||||
liquids = data.get("liquid", [])
|
||||
if isinstance(liquids, list):
|
||||
return sum(l.get("volume", 0.0) for l in liquids if isinstance(l, dict))
|
||||
return 0.0
|
||||
|
||||
|
||||
def update_vessel_volume(vessel, G, new_volume: float, description: str = "") -> None:
|
||||
"""
|
||||
更新容器体积(同时更新vessel字典和图节点)
|
||||
|
||||
Args:
|
||||
vessel: 容器字典或 ResourceDictInstance
|
||||
G: 网络图 (nx.DiGraph)
|
||||
new_volume: 新体积 (mL)
|
||||
description: 更新描述(用于日志)
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger("unilabos.compile")
|
||||
|
||||
vessel_id = get_resource_id(vessel)
|
||||
|
||||
if description:
|
||||
logger.info(f"[RESOURCE] 更新容器体积 - {description}")
|
||||
|
||||
# 更新 vessel 字典中的体积
|
||||
if isinstance(vessel, dict):
|
||||
if "data" not in vessel:
|
||||
vessel["data"] = {}
|
||||
lv = vessel["data"].get("liquid_volume")
|
||||
if isinstance(lv, list) and len(lv) > 0:
|
||||
vessel["data"]["liquid_volume"][0] = new_volume
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = new_volume
|
||||
|
||||
# 同时更新图中的容器数据
|
||||
if vessel_id and vessel_id in G.nodes():
|
||||
if "data" not in G.nodes[vessel_id]:
|
||||
G.nodes[vessel_id]["data"] = {}
|
||||
node_lv = G.nodes[vessel_id]["data"].get("liquid_volume")
|
||||
if isinstance(node_lv, list) and len(node_lv) > 0:
|
||||
G.nodes[vessel_id]["data"]["liquid_volume"][0] = new_volume
|
||||
else:
|
||||
G.nodes[vessel_id]["data"]["liquid_volume"] = new_volume
|
||||
|
||||
logger.info(f"[RESOURCE] 容器 '{vessel_id}' 体积已更新为: {new_volume:.2f}mL")
|
||||
@@ -184,6 +184,42 @@ def parse_time_input(time_input: Union[str, float]) -> float:
|
||||
|
||||
return time_sec
|
||||
|
||||
|
||||
def parse_temperature_input(temp_input: Union[str, float], default_temp: float = 25.0) -> float:
|
||||
"""
|
||||
解析温度输入,支持字符串和数值
|
||||
|
||||
Args:
|
||||
temp_input: 温度输入(如 "256 °C", "reflux", 45.0)
|
||||
default_temp: 默认温度
|
||||
|
||||
Returns:
|
||||
float: 温度(°C)
|
||||
"""
|
||||
if not temp_input:
|
||||
return default_temp
|
||||
|
||||
if isinstance(temp_input, (int, float)):
|
||||
return float(temp_input)
|
||||
|
||||
temp_str = str(temp_input).lower().strip()
|
||||
|
||||
# 特殊温度关键词
|
||||
special_temps = {
|
||||
"room temperature": 25.0, "reflux": 78.0, "ice bath": 0.0,
|
||||
"boiling": 100.0, "hot": 60.0, "warm": 40.0, "cold": 10.0,
|
||||
}
|
||||
if temp_str in special_temps:
|
||||
return special_temps[temp_str]
|
||||
|
||||
# 正则解析(如 "256 °C", "45°C", "45")
|
||||
match = re.search(r'(\d+(?:\.\d+)?)\s*°?[cf]?', temp_str)
|
||||
if match:
|
||||
return float(match.group(1))
|
||||
|
||||
debug_print(f"无法解析温度: '{temp_str}',使用默认值: {default_temp}°C")
|
||||
return default_temp
|
||||
|
||||
# 测试函数
|
||||
def test_unit_parser():
|
||||
"""测试单位解析功能"""
|
||||
|
||||
@@ -1,27 +1,23 @@
|
||||
import networkx as nx
|
||||
|
||||
from .logger_util import debug_print
|
||||
from .resource_helper import get_resource_id, get_resource_data
|
||||
|
||||
|
||||
def get_vessel(vessel):
|
||||
"""
|
||||
统一处理vessel参数,返回vessel_id和vessel_data。
|
||||
支持 dict、str、ResourceDictInstance。
|
||||
|
||||
Args:
|
||||
vessel: 可以是一个字典或字符串,表示vessel的ID或数据。
|
||||
vessel: 可以是一个字典、字符串或 ResourceDictInstance,表示vessel的ID或数据。
|
||||
|
||||
Returns:
|
||||
tuple: 包含vessel_id和vessel_data。
|
||||
"""
|
||||
if isinstance(vessel, dict):
|
||||
if "id" not in vessel:
|
||||
vessel_id = list(vessel.values())[0].get("id", "")
|
||||
else:
|
||||
vessel_id = vessel.get("id", "")
|
||||
vessel_data = vessel.get("data", {})
|
||||
else:
|
||||
vessel_id = str(vessel)
|
||||
vessel_data = {}
|
||||
# 统一使用 resource_helper 处理
|
||||
vessel_id = get_resource_id(vessel)
|
||||
vessel_data = get_resource_data(vessel)
|
||||
return vessel_id, vessel_data
|
||||
|
||||
|
||||
@@ -278,4 +274,31 @@ def find_solid_dispenser(G: nx.DiGraph) -> str:
|
||||
return node
|
||||
|
||||
debug_print(f"❌ 未找到固体加样器")
|
||||
return ""
|
||||
return ""
|
||||
|
||||
|
||||
def find_connected_heatchill(G: nx.DiGraph, vessel: str) -> str:
|
||||
"""查找与指定容器相连的加热/冷却设备"""
|
||||
heatchill_nodes = []
|
||||
for node in G.nodes():
|
||||
node_data = G.nodes[node]
|
||||
node_class = node_data.get('class', '') or ''
|
||||
node_name = node.lower()
|
||||
if ('heatchill' in node_class.lower() or 'virtual_heatchill' in node_class
|
||||
or 'heater' in node_name or 'heat' in node_name):
|
||||
heatchill_nodes.append(node)
|
||||
|
||||
# 检查连接
|
||||
if vessel and heatchill_nodes:
|
||||
for hc in heatchill_nodes:
|
||||
if G.has_edge(hc, vessel) or G.has_edge(vessel, hc):
|
||||
debug_print(f"加热设备 '{hc}' 与容器 '{vessel}' 相连")
|
||||
return hc
|
||||
|
||||
# 使用第一个可用设备
|
||||
if heatchill_nodes:
|
||||
debug_print(f"使用第一个加热设备: {heatchill_nodes[0]}")
|
||||
return heatchill_nodes[0]
|
||||
|
||||
debug_print("未找到加热设备,使用默认设备")
|
||||
return "heatchill_1"
|
||||
@@ -4,199 +4,55 @@ import logging
|
||||
import re
|
||||
|
||||
from .utils.unit_parser import parse_time_input, parse_volume_input
|
||||
from .utils.resource_helper import get_resource_id, get_resource_display_info, get_resource_liquid_volume, update_vessel_volume
|
||||
from .utils.logger_util import debug_print
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def debug_print(message):
|
||||
"""调试输出"""
|
||||
logger.info(f"[WASH_SOLID] {message}")
|
||||
|
||||
|
||||
def find_solvent_source(G: nx.DiGraph, solvent: str) -> str:
|
||||
"""查找溶剂源(精简版)"""
|
||||
debug_print(f"🔍 查找溶剂源: {solvent}")
|
||||
|
||||
# 简化搜索列表
|
||||
"""查找溶剂源"""
|
||||
search_patterns = [
|
||||
f"flask_{solvent}", f"bottle_{solvent}", f"reagent_{solvent}",
|
||||
"liquid_reagent_bottle_1", "flask_1", "solvent_bottle"
|
||||
]
|
||||
|
||||
|
||||
for pattern in search_patterns:
|
||||
if pattern in G.nodes():
|
||||
debug_print(f"🎉 找到溶剂源: {pattern}")
|
||||
debug_print(f"找到溶剂源: {pattern}")
|
||||
return pattern
|
||||
|
||||
debug_print(f"⚠️ 使用默认溶剂源: flask_{solvent}")
|
||||
|
||||
debug_print(f"使用默认溶剂源: flask_{solvent}")
|
||||
return f"flask_{solvent}"
|
||||
|
||||
def find_filtrate_vessel(G: nx.DiGraph, filtrate_vessel: str = "") -> str:
|
||||
"""查找滤液容器(精简版)"""
|
||||
debug_print(f"🔍 查找滤液容器: {filtrate_vessel}")
|
||||
|
||||
# 如果指定了且存在,直接使用
|
||||
"""查找滤液容器"""
|
||||
if filtrate_vessel and filtrate_vessel in G.nodes():
|
||||
debug_print(f"✅ 使用指定容器: {filtrate_vessel}")
|
||||
return filtrate_vessel
|
||||
|
||||
# 简化搜索列表
|
||||
|
||||
default_vessels = ["waste_workup", "filtrate_vessel", "flask_1", "collection_bottle_1"]
|
||||
|
||||
|
||||
for vessel in default_vessels:
|
||||
if vessel in G.nodes():
|
||||
debug_print(f"🎉 找到滤液容器: {vessel}")
|
||||
debug_print(f"找到滤液容器: {vessel}")
|
||||
return vessel
|
||||
|
||||
debug_print(f"⚠️ 使用默认滤液容器: waste_workup")
|
||||
|
||||
return "waste_workup"
|
||||
|
||||
def extract_vessel_id(vessel: Union[str, dict]) -> str:
|
||||
"""
|
||||
从vessel参数中提取vessel_id
|
||||
|
||||
Args:
|
||||
vessel: vessel字典或vessel_id字符串
|
||||
|
||||
Returns:
|
||||
str: vessel_id
|
||||
"""
|
||||
if isinstance(vessel, dict):
|
||||
vessel_id = list(vessel.values())[0].get("id", "")
|
||||
debug_print(f"🔧 从vessel字典提取ID: {vessel_id}")
|
||||
return vessel_id
|
||||
elif isinstance(vessel, str):
|
||||
debug_print(f"🔧 vessel参数为字符串: {vessel}")
|
||||
return vessel
|
||||
else:
|
||||
debug_print(f"⚠️ 无效的vessel参数类型: {type(vessel)}")
|
||||
return ""
|
||||
def extract_vessel_id(vessel) -> str:
|
||||
"""从vessel参数中提取vessel_id,兼容 str / dict / ResourceDictInstance"""
|
||||
return get_resource_id(vessel)
|
||||
|
||||
def get_vessel_display_info(vessel: Union[str, dict]) -> str:
|
||||
"""
|
||||
获取容器的显示信息(用于日志)
|
||||
|
||||
Args:
|
||||
vessel: vessel字典或vessel_id字符串
|
||||
|
||||
Returns:
|
||||
str: 显示信息
|
||||
"""
|
||||
if isinstance(vessel, dict):
|
||||
vessel_id = vessel.get("id", "unknown")
|
||||
vessel_name = vessel.get("name", "")
|
||||
if vessel_name:
|
||||
return f"{vessel_id} ({vessel_name})"
|
||||
else:
|
||||
return vessel_id
|
||||
else:
|
||||
return str(vessel)
|
||||
|
||||
def get_vessel_liquid_volume(vessel: dict) -> float:
|
||||
"""
|
||||
获取容器中的液体体积 - 支持vessel字典
|
||||
|
||||
Args:
|
||||
vessel: 容器字典
|
||||
|
||||
Returns:
|
||||
float: 液体体积(mL)
|
||||
"""
|
||||
if not vessel or "data" not in vessel:
|
||||
debug_print(f"⚠️ 容器数据为空,返回 0.0mL")
|
||||
return 0.0
|
||||
|
||||
vessel_data = vessel["data"]
|
||||
vessel_id = vessel.get("id", "unknown")
|
||||
|
||||
debug_print(f"🔍 读取容器 '{vessel_id}' 体积数据: {vessel_data}")
|
||||
|
||||
# 检查liquid_volume字段
|
||||
if "liquid_volume" in vessel_data:
|
||||
liquid_volume = vessel_data["liquid_volume"]
|
||||
|
||||
# 处理列表格式
|
||||
if isinstance(liquid_volume, list):
|
||||
if len(liquid_volume) > 0:
|
||||
volume = liquid_volume[0]
|
||||
if isinstance(volume, (int, float)):
|
||||
debug_print(f"✅ 容器 '{vessel_id}' 体积: {volume}mL (列表格式)")
|
||||
return float(volume)
|
||||
|
||||
# 处理直接数值格式
|
||||
elif isinstance(liquid_volume, (int, float)):
|
||||
debug_print(f"✅ 容器 '{vessel_id}' 体积: {liquid_volume}mL (数值格式)")
|
||||
return float(liquid_volume)
|
||||
|
||||
# 检查其他可能的体积字段
|
||||
volume_keys = ['current_volume', 'total_volume', 'volume']
|
||||
for key in volume_keys:
|
||||
if key in vessel_data:
|
||||
try:
|
||||
volume = float(vessel_data[key])
|
||||
if volume > 0:
|
||||
debug_print(f"✅ 容器 '{vessel_id}' 体积: {volume}mL (字段: {key})")
|
||||
return volume
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
debug_print(f"⚠️ 无法获取容器 '{vessel_id}' 的体积,返回默认值 0.0mL")
|
||||
return 0.0
|
||||
|
||||
def update_vessel_volume(vessel: dict, G: nx.DiGraph, new_volume: float, description: str = "") -> None:
|
||||
"""
|
||||
更新容器体积(同时更新vessel字典和图节点)
|
||||
|
||||
Args:
|
||||
vessel: 容器字典
|
||||
G: 网络图
|
||||
new_volume: 新体积
|
||||
description: 更新描述
|
||||
"""
|
||||
vessel_id = vessel.get("id", "unknown")
|
||||
|
||||
if description:
|
||||
debug_print(f"🔧 更新容器体积 - {description}")
|
||||
|
||||
# 更新vessel字典中的体积
|
||||
if "data" in vessel:
|
||||
if "liquid_volume" in vessel["data"]:
|
||||
current_volume = vessel["data"]["liquid_volume"]
|
||||
if isinstance(current_volume, list):
|
||||
if len(current_volume) > 0:
|
||||
vessel["data"]["liquid_volume"][0] = new_volume
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = [new_volume]
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = new_volume
|
||||
else:
|
||||
vessel["data"]["liquid_volume"] = new_volume
|
||||
else:
|
||||
vessel["data"] = {"liquid_volume": new_volume}
|
||||
|
||||
# 同时更新图中的容器数据
|
||||
if vessel_id in G.nodes():
|
||||
if 'data' not in G.nodes[vessel_id]:
|
||||
G.nodes[vessel_id]['data'] = {}
|
||||
|
||||
vessel_node_data = G.nodes[vessel_id]['data']
|
||||
current_node_volume = vessel_node_data.get('liquid_volume', 0.0)
|
||||
|
||||
if isinstance(current_node_volume, list):
|
||||
if len(current_node_volume) > 0:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'][0] = new_volume
|
||||
else:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = [new_volume]
|
||||
else:
|
||||
G.nodes[vessel_id]['data']['liquid_volume'] = new_volume
|
||||
|
||||
debug_print(f"📊 容器 '{vessel_id}' 体积已更新为: {new_volume:.2f}mL")
|
||||
def get_vessel_display_info(vessel) -> str:
|
||||
"""获取容器的显示信息(用于日志),兼容 str / dict / ResourceDictInstance"""
|
||||
return get_resource_display_info(vessel)
|
||||
|
||||
def generate_wash_solid_protocol(
|
||||
G: nx.DiGraph,
|
||||
vessel: Union[str, dict], # 🔧 修改:支持vessel字典
|
||||
vessel: Union[str, dict],
|
||||
solvent: str,
|
||||
volume: Union[float, str] = "50",
|
||||
filtrate_vessel: Union[str, dict] = "", # 🔧 修改:支持vessel字典
|
||||
filtrate_vessel: Union[str, dict] = "",
|
||||
temp: float = 25.0,
|
||||
stir: bool = False,
|
||||
stir_speed: float = 0.0,
|
||||
@@ -210,7 +66,7 @@ def generate_wash_solid_protocol(
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
生成固体清洗协议 - 支持vessel字典和体积运算
|
||||
|
||||
|
||||
Args:
|
||||
G: 有向图,节点为设备和容器,边为流体管道
|
||||
vessel: 清洗容器字典(从XDL传入)或容器ID字符串
|
||||
@@ -227,106 +83,78 @@ def generate_wash_solid_protocol(
|
||||
mass: 固体质量(用于计算溶剂用量)
|
||||
event: 事件描述
|
||||
**kwargs: 其他可选参数
|
||||
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 固体清洗操作的动作序列
|
||||
"""
|
||||
|
||||
# 🔧 核心修改:从vessel参数中提取vessel_id
|
||||
|
||||
vessel_id = extract_vessel_id(vessel)
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
|
||||
# 🔧 处理filtrate_vessel参数
|
||||
|
||||
filtrate_vessel_id = extract_vessel_id(filtrate_vessel) if filtrate_vessel else ""
|
||||
|
||||
debug_print("🧼" * 20)
|
||||
debug_print("🚀 开始生成固体清洗协议(支持vessel字典和体积运算)✨")
|
||||
debug_print(f"📝 输入参数:")
|
||||
debug_print(f" 🥽 vessel: {vessel_display} (ID: {vessel_id})")
|
||||
debug_print(f" 🧪 solvent: {solvent}")
|
||||
debug_print(f" 💧 volume: {volume}")
|
||||
debug_print(f" 🗑️ filtrate_vessel: {filtrate_vessel_id}")
|
||||
debug_print(f" ⏰ time: {time}")
|
||||
debug_print(f" 🔄 repeats: {repeats}")
|
||||
debug_print("🧼" * 20)
|
||||
|
||||
# 🔧 新增:记录清洗前的容器状态
|
||||
debug_print("🔍 记录清洗前容器状态...")
|
||||
|
||||
debug_print(f"开始生成固体清洗协议: vessel={vessel_id}, solvent={solvent}, volume={volume}, repeats={repeats}")
|
||||
|
||||
# 记录清洗前的容器状态
|
||||
if isinstance(vessel, dict):
|
||||
original_volume = get_vessel_liquid_volume(vessel)
|
||||
debug_print(f"📊 清洗前液体体积: {original_volume:.2f}mL")
|
||||
original_volume = get_resource_liquid_volume(vessel)
|
||||
else:
|
||||
original_volume = 0.0
|
||||
debug_print(f"📊 vessel为字符串格式,无法获取体积信息")
|
||||
|
||||
# 📋 快速验证
|
||||
if not vessel_id or vessel_id not in G.nodes(): # 🔧 使用 vessel_id
|
||||
debug_print("❌ 容器验证失败! 😱")
|
||||
|
||||
# 快速验证
|
||||
if not vessel_id or vessel_id not in G.nodes():
|
||||
raise ValueError("vessel 参数无效")
|
||||
|
||||
|
||||
if not solvent:
|
||||
debug_print("❌ 溶剂不能为空! 😱")
|
||||
raise ValueError("solvent 参数不能为空")
|
||||
|
||||
debug_print("✅ 基础验证通过 🎯")
|
||||
|
||||
# 🔄 参数解析
|
||||
debug_print("📍 步骤1: 参数解析... ⚡")
|
||||
|
||||
# 参数解析
|
||||
final_volume = parse_volume_input(volume, volume_spec, mass)
|
||||
final_time = parse_time_input(time)
|
||||
|
||||
# 重复次数处理(简化)
|
||||
|
||||
# 重复次数处理
|
||||
if repeats_spec:
|
||||
spec_map = {'few': 2, 'several': 3, 'many': 4, 'thorough': 5}
|
||||
final_repeats = next((v for k, v in spec_map.items() if k in repeats_spec.lower()), repeats)
|
||||
else:
|
||||
final_repeats = max(1, min(repeats, 5)) # 限制1-5次
|
||||
|
||||
# 🕐 模拟时间优化
|
||||
debug_print(" ⏱️ 模拟时间优化...")
|
||||
final_repeats = max(1, min(repeats, 5))
|
||||
|
||||
# 模拟时间优化
|
||||
original_time = final_time
|
||||
if final_time > 60.0:
|
||||
final_time = 60.0 # 限制最长60秒
|
||||
debug_print(f" 🎮 时间优化: {original_time}s → {final_time}s ⚡")
|
||||
|
||||
final_time = 60.0
|
||||
debug_print(f"时间优化: {original_time}s -> {final_time}s")
|
||||
|
||||
# 参数修正
|
||||
temp = max(25.0, min(temp, 80.0)) # 温度范围25-80°C
|
||||
stir_speed = max(0.0, min(stir_speed, 300.0)) if stir else 0.0 # 速度范围0-300
|
||||
|
||||
debug_print(f"🎯 最终参数: 体积={final_volume}mL, 时间={final_time}s, 重复={final_repeats}次")
|
||||
|
||||
# 🔍 查找设备
|
||||
debug_print("📍 步骤2: 查找设备... 🔍")
|
||||
temp = max(25.0, min(temp, 80.0))
|
||||
stir_speed = max(0.0, min(stir_speed, 300.0)) if stir else 0.0
|
||||
|
||||
debug_print(f"最终参数: 体积={final_volume}mL, 时间={final_time}s, 重复={final_repeats}次")
|
||||
|
||||
# 查找设备
|
||||
try:
|
||||
solvent_source = find_solvent_source(G, solvent)
|
||||
actual_filtrate_vessel = find_filtrate_vessel(G, filtrate_vessel_id)
|
||||
debug_print(f"🎉 设备配置完成 ✨")
|
||||
debug_print(f" 🧪 溶剂源: {solvent_source}")
|
||||
debug_print(f" 🗑️ 滤液容器: {actual_filtrate_vessel}")
|
||||
except Exception as e:
|
||||
debug_print(f"❌ 设备查找失败: {str(e)} 😭")
|
||||
raise ValueError(f"设备查找失败: {str(e)}")
|
||||
|
||||
# 🚀 生成动作序列
|
||||
debug_print("📍 步骤3: 生成清洗动作... 🧼")
|
||||
|
||||
# 生成动作序列
|
||||
action_sequence = []
|
||||
|
||||
# 🔧 新增:体积变化跟踪变量
|
||||
|
||||
current_volume = original_volume
|
||||
total_solvent_used = 0.0
|
||||
|
||||
|
||||
for cycle in range(final_repeats):
|
||||
debug_print(f" 🔄 第{cycle+1}/{final_repeats}次清洗...")
|
||||
|
||||
debug_print(f"第{cycle+1}/{final_repeats}次清洗")
|
||||
|
||||
# 1. 转移溶剂
|
||||
try:
|
||||
from .pump_protocol import generate_pump_protocol_with_rinsing
|
||||
|
||||
debug_print(f" 💧 添加溶剂: {final_volume}mL {solvent}")
|
||||
|
||||
transfer_actions = generate_pump_protocol_with_rinsing(
|
||||
G=G,
|
||||
from_vessel=solvent_source,
|
||||
to_vessel=vessel_id, # 🔧 使用 vessel_id
|
||||
to_vessel=vessel_id,
|
||||
volume=final_volume,
|
||||
amount="",
|
||||
time=0.0,
|
||||
@@ -338,211 +166,160 @@ def generate_wash_solid_protocol(
|
||||
flowrate=2.5,
|
||||
transfer_flowrate=0.5
|
||||
)
|
||||
|
||||
|
||||
if transfer_actions:
|
||||
action_sequence.extend(transfer_actions)
|
||||
debug_print(f" ✅ 转移动作: {len(transfer_actions)}个 🚚")
|
||||
|
||||
# 🔧 新增:更新体积 - 添加溶剂后
|
||||
|
||||
current_volume += final_volume
|
||||
total_solvent_used += final_volume
|
||||
|
||||
|
||||
if isinstance(vessel, dict):
|
||||
update_vessel_volume(vessel, G, current_volume,
|
||||
update_vessel_volume(vessel, G, current_volume,
|
||||
f"第{cycle+1}次清洗添加{final_volume}mL溶剂后")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
debug_print(f" ❌ 转移失败: {str(e)} 😞")
|
||||
|
||||
debug_print(f"转移失败: {str(e)}")
|
||||
|
||||
# 2. 搅拌(如果需要)
|
||||
if stir and final_time > 0:
|
||||
debug_print(f" 🌪️ 搅拌: {final_time}s @ {stir_speed}RPM")
|
||||
stir_action = {
|
||||
"device_id": "stirrer_1",
|
||||
"action_name": "stir",
|
||||
"action_kwargs": {
|
||||
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
|
||||
"vessel": {"id": vessel_id},
|
||||
"time": str(time),
|
||||
"stir_time": final_time,
|
||||
"stir_speed": stir_speed,
|
||||
"settling_time": 10.0 # 🕐 缩短沉降时间
|
||||
"settling_time": 10.0
|
||||
}
|
||||
}
|
||||
action_sequence.append(stir_action)
|
||||
debug_print(f" ✅ 搅拌动作: {final_time}s, {stir_speed}RPM 🌪️")
|
||||
|
||||
|
||||
# 3. 过滤
|
||||
debug_print(f" 🌊 过滤到: {actual_filtrate_vessel}")
|
||||
filter_action = {
|
||||
"device_id": "filter_1",
|
||||
"action_name": "filter",
|
||||
"action_kwargs": {
|
||||
"vessel": {"id": vessel_id}, # 🔧 使用 vessel_id
|
||||
"vessel": {"id": vessel_id},
|
||||
"filtrate_vessel": actual_filtrate_vessel,
|
||||
"temp": temp,
|
||||
"volume": final_volume
|
||||
}
|
||||
}
|
||||
action_sequence.append(filter_action)
|
||||
debug_print(f" ✅ 过滤动作: → {actual_filtrate_vessel} 🌊")
|
||||
|
||||
# 🔧 新增:更新体积 - 过滤后(液体被滤除)
|
||||
# 假设滤液完全被移除,固体残留在容器中
|
||||
filtered_volume = current_volume * 0.9 # 假设90%的液体被过滤掉
|
||||
|
||||
# 更新体积 - 过滤后
|
||||
filtered_volume = current_volume * 0.9
|
||||
current_volume = current_volume - filtered_volume
|
||||
|
||||
|
||||
if isinstance(vessel, dict):
|
||||
update_vessel_volume(vessel, G, current_volume,
|
||||
update_vessel_volume(vessel, G, current_volume,
|
||||
f"第{cycle+1}次清洗过滤后")
|
||||
|
||||
# 4. 等待(缩短时间)
|
||||
wait_time = 5.0 # 🕐 缩短等待时间:10s → 5s
|
||||
|
||||
# 4. 等待
|
||||
wait_time = 5.0
|
||||
action_sequence.append({
|
||||
"action_name": "wait",
|
||||
"action_kwargs": {"time": wait_time}
|
||||
})
|
||||
debug_print(f" ✅ 等待: {wait_time}s ⏰")
|
||||
|
||||
# 🔧 新增:清洗完成后的最终状态报告
|
||||
|
||||
# 最终状态
|
||||
if isinstance(vessel, dict):
|
||||
final_volume_vessel = get_vessel_liquid_volume(vessel)
|
||||
final_volume_vessel = get_resource_liquid_volume(vessel)
|
||||
else:
|
||||
final_volume_vessel = current_volume
|
||||
|
||||
# 🎊 总结
|
||||
debug_print("🧼" * 20)
|
||||
debug_print(f"🎉 固体清洗协议生成完成! ✨")
|
||||
debug_print(f"📊 协议统计:")
|
||||
debug_print(f" 📋 总动作数: {len(action_sequence)} 个")
|
||||
debug_print(f" 🥽 清洗容器: {vessel_display}")
|
||||
debug_print(f" 🧪 使用溶剂: {solvent}")
|
||||
debug_print(f" 💧 单次体积: {final_volume}mL")
|
||||
debug_print(f" 🔄 清洗次数: {final_repeats}次")
|
||||
debug_print(f" 💧 总溶剂用量: {total_solvent_used:.2f}mL")
|
||||
debug_print(f"📊 体积变化统计:")
|
||||
debug_print(f" - 清洗前体积: {original_volume:.2f}mL")
|
||||
debug_print(f" - 清洗后体积: {final_volume_vessel:.2f}mL")
|
||||
debug_print(f" - 溶剂总用量: {total_solvent_used:.2f}mL")
|
||||
debug_print(f"⏱️ 预计总时间: {(final_time + 5) * final_repeats / 60:.1f} 分钟")
|
||||
debug_print("🧼" * 20)
|
||||
|
||||
|
||||
debug_print(f"固体清洗协议生成完成: {len(action_sequence)} 个动作, {final_repeats}次清洗, 溶剂总用量={total_solvent_used:.2f}mL")
|
||||
|
||||
return action_sequence
|
||||
|
||||
# 🔧 新增:便捷函数
|
||||
def wash_with_water(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
volume: Union[float, str] = "50",
|
||||
# 便捷函数
|
||||
def wash_with_water(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
volume: Union[float, str] = "50",
|
||||
repeats: int = 2) -> List[Dict[str, Any]]:
|
||||
"""用水清洗固体"""
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
debug_print(f"💧 水洗固体: {vessel_display} ({repeats} 次)")
|
||||
return generate_wash_solid_protocol(G, vessel, "water", volume=volume, repeats=repeats)
|
||||
|
||||
def wash_with_ethanol(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
volume: Union[float, str] = "30",
|
||||
def wash_with_ethanol(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
volume: Union[float, str] = "30",
|
||||
repeats: int = 1) -> List[Dict[str, Any]]:
|
||||
"""用乙醇清洗固体"""
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
debug_print(f"🍺 乙醇洗固体: {vessel_display} ({repeats} 次)")
|
||||
return generate_wash_solid_protocol(G, vessel, "ethanol", volume=volume, repeats=repeats)
|
||||
|
||||
def wash_with_acetone(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
volume: Union[float, str] = "25",
|
||||
def wash_with_acetone(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
volume: Union[float, str] = "25",
|
||||
repeats: int = 1) -> List[Dict[str, Any]]:
|
||||
"""用丙酮清洗固体"""
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
debug_print(f"💨 丙酮洗固体: {vessel_display} ({repeats} 次)")
|
||||
return generate_wash_solid_protocol(G, vessel, "acetone", volume=volume, repeats=repeats)
|
||||
|
||||
def wash_with_ether(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
volume: Union[float, str] = "40",
|
||||
def wash_with_ether(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
volume: Union[float, str] = "40",
|
||||
repeats: int = 2) -> List[Dict[str, Any]]:
|
||||
"""用乙醚清洗固体"""
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
debug_print(f"🌬️ 乙醚洗固体: {vessel_display} ({repeats} 次)")
|
||||
return generate_wash_solid_protocol(G, vessel, "diethyl_ether", volume=volume, repeats=repeats)
|
||||
|
||||
def wash_with_cold_solvent(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
solvent: str, volume: Union[float, str] = "30",
|
||||
def wash_with_cold_solvent(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
solvent: str, volume: Union[float, str] = "30",
|
||||
repeats: int = 1) -> List[Dict[str, Any]]:
|
||||
"""用冷溶剂清洗固体"""
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
debug_print(f"❄️ 冷{solvent}洗固体: {vessel_display} ({repeats} 次)")
|
||||
return generate_wash_solid_protocol(G, vessel, solvent, volume=volume,
|
||||
return generate_wash_solid_protocol(G, vessel, solvent, volume=volume,
|
||||
temp=5.0, repeats=repeats)
|
||||
|
||||
def wash_with_hot_solvent(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
solvent: str, volume: Union[float, str] = "50",
|
||||
def wash_with_hot_solvent(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
solvent: str, volume: Union[float, str] = "50",
|
||||
repeats: int = 1) -> List[Dict[str, Any]]:
|
||||
"""用热溶剂清洗固体"""
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
debug_print(f"🔥 热{solvent}洗固体: {vessel_display} ({repeats} 次)")
|
||||
return generate_wash_solid_protocol(G, vessel, solvent, volume=volume,
|
||||
return generate_wash_solid_protocol(G, vessel, solvent, volume=volume,
|
||||
temp=60.0, repeats=repeats)
|
||||
|
||||
def wash_with_stirring(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
solvent: str, volume: Union[float, str] = "50",
|
||||
stir_time: Union[str, float] = "5 min",
|
||||
def wash_with_stirring(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
solvent: str, volume: Union[float, str] = "50",
|
||||
stir_time: Union[str, float] = "5 min",
|
||||
repeats: int = 1) -> List[Dict[str, Any]]:
|
||||
"""带搅拌的溶剂清洗"""
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
debug_print(f"🌪️ 搅拌清洗: {vessel_display} with {solvent} ({repeats} 次)")
|
||||
return generate_wash_solid_protocol(G, vessel, solvent, volume=volume,
|
||||
stir=True, stir_speed=200.0,
|
||||
return generate_wash_solid_protocol(G, vessel, solvent, volume=volume,
|
||||
stir=True, stir_speed=200.0,
|
||||
time=stir_time, repeats=repeats)
|
||||
|
||||
def thorough_wash(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
def thorough_wash(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
solvent: str, volume: Union[float, str] = "50") -> List[Dict[str, Any]]:
|
||||
"""彻底清洗(多次重复)"""
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
debug_print(f"🔄 彻底清洗: {vessel_display} with {solvent} (5 次)")
|
||||
return generate_wash_solid_protocol(G, vessel, solvent, volume=volume, repeats=5)
|
||||
|
||||
def quick_rinse(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
def quick_rinse(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
solvent: str, volume: Union[float, str] = "20") -> List[Dict[str, Any]]:
|
||||
"""快速冲洗(单次,小体积)"""
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
debug_print(f"⚡ 快速冲洗: {vessel_display} with {solvent}")
|
||||
return generate_wash_solid_protocol(G, vessel, solvent, volume=volume, repeats=1)
|
||||
|
||||
def sequential_wash(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
def sequential_wash(G: nx.DiGraph, vessel: Union[str, dict],
|
||||
solvents: list, volume: Union[float, str] = "40") -> List[Dict[str, Any]]:
|
||||
"""连续多溶剂清洗"""
|
||||
vessel_display = get_vessel_display_info(vessel)
|
||||
debug_print(f"📝 连续清洗: {vessel_display} with {' → '.join(solvents)}")
|
||||
|
||||
action_sequence = []
|
||||
for solvent in solvents:
|
||||
wash_actions = generate_wash_solid_protocol(G, vessel, solvent,
|
||||
wash_actions = generate_wash_solid_protocol(G, vessel, solvent,
|
||||
volume=volume, repeats=1)
|
||||
action_sequence.extend(wash_actions)
|
||||
|
||||
|
||||
return action_sequence
|
||||
|
||||
# 测试函数
|
||||
def test_wash_solid_protocol():
|
||||
"""测试固体清洗协议"""
|
||||
debug_print("🧪 === WASH SOLID PROTOCOL 测试 === ✨")
|
||||
|
||||
# 测试vessel参数处理
|
||||
debug_print("🔧 测试vessel参数处理...")
|
||||
|
||||
# 测试字典格式
|
||||
vessel_dict = {"id": "filter_flask_1", "name": "过滤瓶1",
|
||||
debug_print("=== WASH SOLID PROTOCOL 测试 ===")
|
||||
|
||||
vessel_dict = {"id": "filter_flask_1", "name": "过滤瓶1",
|
||||
"data": {"liquid_volume": 25.0}}
|
||||
vessel_id = extract_vessel_id(vessel_dict)
|
||||
vessel_display = get_vessel_display_info(vessel_dict)
|
||||
volume = get_vessel_liquid_volume(vessel_dict)
|
||||
debug_print(f" 字典格式: {vessel_dict}")
|
||||
debug_print(f" → ID: {vessel_id}, 显示: {vessel_display}, 体积: {volume}mL")
|
||||
|
||||
# 测试字符串格式
|
||||
volume = get_resource_liquid_volume(vessel_dict)
|
||||
debug_print(f"字典格式: ID={vessel_id}, 显示={vessel_display}, 体积={volume}mL")
|
||||
|
||||
vessel_str = "filter_flask_2"
|
||||
vessel_id = extract_vessel_id(vessel_str)
|
||||
vessel_display = get_vessel_display_info(vessel_str)
|
||||
debug_print(f" 字符串格式: {vessel_str}")
|
||||
debug_print(f" → ID: {vessel_id}, 显示: {vessel_display}")
|
||||
|
||||
debug_print("✅ 测试完成 🎉")
|
||||
debug_print(f"字符串格式: ID={vessel_id}, 显示={vessel_display}")
|
||||
|
||||
debug_print("测试完成")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_wash_solid_protocol()
|
||||
test_wash_solid_protocol()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -43,7 +43,7 @@ class Base(ABC):
|
||||
self._type = typ
|
||||
self._data_type = data_type
|
||||
self._node: Optional[Node] = None
|
||||
|
||||
|
||||
def _get_node(self) -> Node:
|
||||
if self._node is None:
|
||||
try:
|
||||
@@ -66,7 +66,7 @@ class Base(ABC):
|
||||
# 直接以字符串形式处理
|
||||
if isinstance(nid, str):
|
||||
nid = nid.strip()
|
||||
|
||||
|
||||
# 处理包含类名的格式,如 'StringNodeId(ns=4;s=...)' 或 'NumericNodeId(ns=2;i=...)'
|
||||
# 提取括号内的内容
|
||||
match_wrapped = re.match(r'(String|Numeric|Byte|Guid|TwoByteNode|FourByteNode)NodeId\((.*)\)', nid)
|
||||
@@ -116,16 +116,16 @@ class Base(ABC):
|
||||
def read(self) -> Tuple[Any, bool]:
|
||||
"""读取节点值,返回(值, 是否出错)"""
|
||||
pass
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def write(self, value: Any) -> bool:
|
||||
"""写入节点值,返回是否出错"""
|
||||
pass
|
||||
|
||||
|
||||
@property
|
||||
def type(self) -> NodeType:
|
||||
return self._type
|
||||
|
||||
|
||||
@property
|
||||
def node_id(self) -> str:
|
||||
return self._node_id
|
||||
@@ -210,15 +210,15 @@ class Method(Base):
|
||||
super().__init__(client, name, node_id, NodeType.METHOD, data_type)
|
||||
self._parent_node_id = parent_node_id
|
||||
self._parent_node = None
|
||||
|
||||
|
||||
def _get_parent_node(self) -> Node:
|
||||
if self._parent_node is None:
|
||||
try:
|
||||
# 处理父节点ID,使用与_get_node相同的解析逻辑
|
||||
import re
|
||||
|
||||
|
||||
nid = self._parent_node_id
|
||||
|
||||
|
||||
# 如果已经是 NodeId 对象,直接使用
|
||||
try:
|
||||
from opcua.ua import NodeId as UaNodeId
|
||||
@@ -227,16 +227,16 @@ class Method(Base):
|
||||
return self._parent_node
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# 字符串处理
|
||||
if isinstance(nid, str):
|
||||
nid = nid.strip()
|
||||
|
||||
|
||||
# 处理包含类名的格式
|
||||
match_wrapped = re.match(r'(String|Numeric|Byte|Guid|TwoByteNode|FourByteNode)NodeId\((.*)\)', nid)
|
||||
if match_wrapped:
|
||||
nid = match_wrapped.group(2).strip()
|
||||
|
||||
|
||||
# 常见短格式
|
||||
if re.match(r'^ns=\d+;[is]=', nid):
|
||||
self._parent_node = self._client.get_node(nid)
|
||||
@@ -271,7 +271,7 @@ class Method(Base):
|
||||
def write(self, value: Any) -> bool:
|
||||
"""方法节点不支持写入操作"""
|
||||
return True
|
||||
|
||||
|
||||
def call(self, *args) -> Tuple[Any, bool]:
|
||||
"""调用方法,返回(返回值, 是否出错)"""
|
||||
try:
|
||||
@@ -285,7 +285,7 @@ class Method(Base):
|
||||
class Object(Base):
|
||||
def __init__(self, client: Client, name: str, node_id: str):
|
||||
super().__init__(client, name, node_id, NodeType.OBJECT, None)
|
||||
|
||||
|
||||
def read(self) -> Tuple[Any, bool]:
|
||||
"""对象节点不支持直接读取操作"""
|
||||
return None, True
|
||||
@@ -293,7 +293,7 @@ class Object(Base):
|
||||
def write(self, value: Any) -> bool:
|
||||
"""对象节点不支持直接写入操作"""
|
||||
return True
|
||||
|
||||
|
||||
def get_children(self) -> Tuple[List[Node], bool]:
|
||||
"""获取子节点列表,返回(子节点列表, 是否出错)"""
|
||||
try:
|
||||
@@ -301,4 +301,4 @@ class Object(Base):
|
||||
return children, False
|
||||
except Exception as e:
|
||||
print(f"获取对象 {self._name} 的子节点失败: {e}")
|
||||
return [], True
|
||||
return [], True
|
||||
|
||||
@@ -201,42 +201,17 @@ class ResourceVisualization:
|
||||
self.moveit_controllers_yaml['moveit_simple_controller_manager'][f"{name}_{controller_name}"] = moveit_dict['moveit_simple_controller_manager'][controller_name]
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _ensure_ros2_env() -> dict:
|
||||
"""确保 ROS2 环境变量正确设置,返回可用于子进程的 env dict"""
|
||||
import sys
|
||||
env = dict(os.environ)
|
||||
conda_prefix = os.path.dirname(os.path.dirname(sys.executable))
|
||||
|
||||
if "AMENT_PREFIX_PATH" not in env or not env["AMENT_PREFIX_PATH"].strip():
|
||||
candidate = os.pathsep.join([conda_prefix, os.path.join(conda_prefix, "Library")])
|
||||
env["AMENT_PREFIX_PATH"] = candidate
|
||||
os.environ["AMENT_PREFIX_PATH"] = candidate
|
||||
|
||||
extra_bin_dirs = [
|
||||
os.path.join(conda_prefix, "Library", "bin"),
|
||||
os.path.join(conda_prefix, "Library", "lib"),
|
||||
os.path.join(conda_prefix, "Scripts"),
|
||||
conda_prefix,
|
||||
]
|
||||
current_path = env.get("PATH", "")
|
||||
for d in extra_bin_dirs:
|
||||
if d not in current_path:
|
||||
current_path = d + os.pathsep + current_path
|
||||
env["PATH"] = current_path
|
||||
os.environ["PATH"] = current_path
|
||||
|
||||
return env
|
||||
|
||||
def create_launch_description(self) -> LaunchDescription:
|
||||
"""
|
||||
创建launch描述,包含robot_state_publisher和move_group节点
|
||||
|
||||
Args:
|
||||
urdf_str: URDF文本
|
||||
|
||||
Returns:
|
||||
LaunchDescription: launch描述对象
|
||||
"""
|
||||
launch_env = self._ensure_ros2_env()
|
||||
|
||||
# 检查ROS 2环境变量
|
||||
if "AMENT_PREFIX_PATH" not in os.environ:
|
||||
raise OSError(
|
||||
"ROS 2环境未正确设置。需要设置 AMENT_PREFIX_PATH 环境变量。\n"
|
||||
@@ -315,7 +290,7 @@ class ResourceVisualization:
|
||||
{"robot_description": robot_description},
|
||||
ros2_controllers,
|
||||
],
|
||||
env=launch_env,
|
||||
env=dict(os.environ)
|
||||
)
|
||||
)
|
||||
for controller in self.moveit_controllers_yaml['moveit_simple_controller_manager']['controller_names']:
|
||||
@@ -325,7 +300,7 @@ class ResourceVisualization:
|
||||
executable="spawner",
|
||||
arguments=[f"{controller}", "--controller-manager", f"controller_manager"],
|
||||
output="screen",
|
||||
env=launch_env,
|
||||
env=dict(os.environ)
|
||||
)
|
||||
)
|
||||
controllers.append(
|
||||
@@ -334,7 +309,7 @@ class ResourceVisualization:
|
||||
executable="spawner",
|
||||
arguments=["joint_state_broadcaster", "--controller-manager", f"controller_manager"],
|
||||
output="screen",
|
||||
env=launch_env,
|
||||
env=dict(os.environ)
|
||||
)
|
||||
)
|
||||
for i in controllers:
|
||||
@@ -342,6 +317,7 @@ class ResourceVisualization:
|
||||
else:
|
||||
ros2_controllers = None
|
||||
|
||||
# 创建robot_state_publisher节点
|
||||
robot_state_publisher = nd(
|
||||
package='robot_state_publisher',
|
||||
executable='robot_state_publisher',
|
||||
@@ -351,8 +327,9 @@ class ResourceVisualization:
|
||||
'robot_description': robot_description,
|
||||
'use_sim_time': False
|
||||
},
|
||||
# kinematics_dict
|
||||
],
|
||||
env=launch_env,
|
||||
env=dict(os.environ)
|
||||
)
|
||||
|
||||
|
||||
@@ -384,7 +361,7 @@ class ResourceVisualization:
|
||||
executable='move_group',
|
||||
output='screen',
|
||||
parameters=moveit_params,
|
||||
env=launch_env,
|
||||
env=dict(os.environ)
|
||||
)
|
||||
|
||||
|
||||
@@ -402,11 +379,13 @@ class ResourceVisualization:
|
||||
arguments=['-d', f"{str(self.mesh_path)}/view_robot.rviz"],
|
||||
output='screen',
|
||||
parameters=[
|
||||
{'robot_description_kinematics': kinematics_dict},
|
||||
{'robot_description_kinematics': kinematics_dict,
|
||||
},
|
||||
robot_description_planning,
|
||||
planning_pipelines,
|
||||
|
||||
],
|
||||
env=launch_env,
|
||||
env=dict(os.environ)
|
||||
)
|
||||
self.launch_description.add_action(rviz_node)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,150 +0,0 @@
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .prcxi import PRCXI9300ModuleSite
|
||||
|
||||
|
||||
class PRCXI9300FunctionalModule(PRCXI9300ModuleSite):
|
||||
"""
|
||||
PRCXI 9300 功能模块基类(加热/冷却/震荡/加热震荡/磁吸等)。
|
||||
|
||||
设计目标:
|
||||
- 作为一个可以在工作台上拖拽摆放的实体资源(继承自 PRCXI9300ModuleSite -> ItemizedCarrier)。
|
||||
- 顶面存在一个站点(site),可吸附标准板类资源(plate / tip_rack / tube_rack 等)。
|
||||
- 支持注入 `material_info` (UUID 等),并且在 serialize_state 时做安全过滤。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
size_x: float,
|
||||
size_y: float,
|
||||
size_z: float,
|
||||
module_type: Optional[str] = None,
|
||||
category: str = "module",
|
||||
model: Optional[str] = None,
|
||||
material_info: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
material_info=material_info,
|
||||
model=model,
|
||||
category=category,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# 记录模块类型(加热 / 冷却 / 震荡 / 加热震荡 / 磁吸)
|
||||
self.module_type = module_type or "generic"
|
||||
|
||||
# 与 PRCXI9300PlateAdapter 一致,使用 _unilabos_state 保存扩展信息
|
||||
if not hasattr(self, "_unilabos_state") or self._unilabos_state is None:
|
||||
self._unilabos_state = {}
|
||||
|
||||
# super().__init__ 已经在有 material_info 时写入 "Material",这里仅确保存在
|
||||
if material_info is not None and "Material" not in self._unilabos_state:
|
||||
self._unilabos_state["Material"] = material_info
|
||||
|
||||
# 额外标记 category 和模块类型,便于前端或上层逻辑区分
|
||||
self._unilabos_state.setdefault("category", category)
|
||||
self._unilabos_state["module_type"] = module_type
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 具体功能模块定义
|
||||
# 这里的尺寸和 material_info 目前为占位参数,后续可根据实际测量/JSON 配置进行更新。
|
||||
# 顶面站点尺寸与模块外形一致,保证可以吸附标准 96 板/储液槽等。
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def PRCXI_Heating_Module(name: str) -> PRCXI9300FunctionalModule:
|
||||
"""加热模块(顶面可吸附标准板)。"""
|
||||
return PRCXI9300FunctionalModule(
|
||||
name=name,
|
||||
size_x=127.76,
|
||||
size_y=85.48,
|
||||
size_z=40.0,
|
||||
module_type="heating",
|
||||
model="PRCXI_Heating_Module",
|
||||
material_info={
|
||||
"uuid": "TODO-HEATING-MODULE-UUID",
|
||||
"Code": "HEAT-MOD",
|
||||
"Name": "PRCXI 加热模块",
|
||||
"SupplyType": 3,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def PRCXI_MetalCooling_Module(name: str) -> PRCXI9300FunctionalModule:
|
||||
"""金属冷却模块(顶面可吸附标准板)。"""
|
||||
return PRCXI9300FunctionalModule(
|
||||
name=name,
|
||||
size_x=127.76,
|
||||
size_y=85.48,
|
||||
size_z=40.0,
|
||||
module_type="metal_cooling",
|
||||
model="PRCXI_MetalCooling_Module",
|
||||
material_info={
|
||||
"uuid": "TODO-METAL-COOLING-MODULE-UUID",
|
||||
"Code": "METAL-COOL-MOD",
|
||||
"Name": "PRCXI 金属冷却模块",
|
||||
"SupplyType": 3,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def PRCXI_Shaking_Module(name: str) -> PRCXI9300FunctionalModule:
|
||||
"""震荡模块(顶面可吸附标准板)。"""
|
||||
return PRCXI9300FunctionalModule(
|
||||
name=name,
|
||||
size_x=127.76,
|
||||
size_y=85.48,
|
||||
size_z=50.0,
|
||||
module_type="shaking",
|
||||
model="PRCXI_Shaking_Module",
|
||||
material_info={
|
||||
"uuid": "TODO-SHAKING-MODULE-UUID",
|
||||
"Code": "SHAKE-MOD",
|
||||
"Name": "PRCXI 震荡模块",
|
||||
"SupplyType": 3,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def PRCXI_Heating_Shaking_Module(name: str) -> PRCXI9300FunctionalModule:
|
||||
"""加热震荡模块(顶面可吸附标准板)。"""
|
||||
return PRCXI9300FunctionalModule(
|
||||
name=name,
|
||||
size_x=127.76,
|
||||
size_y=85.48,
|
||||
size_z=55.0,
|
||||
module_type="heating_shaking",
|
||||
model="PRCXI_Heating_Shaking_Module",
|
||||
material_info={
|
||||
"uuid": "TODO-HEATING-SHAKING-MODULE-UUID",
|
||||
"Code": "HEAT-SHAKE-MOD",
|
||||
"Name": "PRCXI 加热震荡模块",
|
||||
"SupplyType": 3,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def PRCXI_Magnetic_Module(name: str) -> PRCXI9300FunctionalModule:
|
||||
"""磁吸模块(顶面可吸附标准板)。"""
|
||||
return PRCXI9300FunctionalModule(
|
||||
name=name,
|
||||
size_x=127.76,
|
||||
size_y=85.48,
|
||||
size_z=30.0,
|
||||
module_type="magnetic",
|
||||
model="PRCXI_Magnetic_Module",
|
||||
material_info={
|
||||
"uuid": "TODO-MAGNETIC-MODULE-UUID",
|
||||
"Code": "MAG-MOD",
|
||||
"Name": "PRCXI 磁吸模块",
|
||||
"SupplyType": 3,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -59,7 +59,6 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend):
|
||||
self.total_height = total_height
|
||||
self.joint_config = kwargs.get("joint_config", None)
|
||||
self.lh_device_id = kwargs.get("lh_device_id", "lh_joint_publisher")
|
||||
self.simulate_rviz = kwargs.get("simulate_rviz", False)
|
||||
if not rclpy.ok():
|
||||
rclpy.init()
|
||||
self.joint_state_publisher = None
|
||||
@@ -70,7 +69,7 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend):
|
||||
self.joint_state_publisher = LiquidHandlerJointPublisher(
|
||||
joint_config=self.joint_config,
|
||||
lh_device_id=self.lh_device_id,
|
||||
simulate_rviz=self.simulate_rviz)
|
||||
simulate_rviz=True)
|
||||
|
||||
# 启动ROS executor
|
||||
self.executor = rclpy.executors.MultiThreadedExecutor()
|
||||
|
||||
@@ -42,7 +42,6 @@ class LiquidHandlerJointPublisher(Node):
|
||||
while self.resource_action is None:
|
||||
self.resource_action = self.check_tf_update_actions()
|
||||
time.sleep(1)
|
||||
self.get_logger().info(f'Waiting for TfUpdate server: {self.resource_action}')
|
||||
|
||||
self.resource_action_client = ActionClient(self, SendCmd, self.resource_action)
|
||||
while not self.resource_action_client.wait_for_server(timeout_sec=1.0):
|
||||
|
||||
@@ -2,7 +2,6 @@ import json
|
||||
import time
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Optional, Sequence
|
||||
|
||||
from moveit_msgs.msg import JointConstraint, Constraints
|
||||
from rclpy.action import ActionClient
|
||||
@@ -172,160 +171,173 @@ class MoveitInterface:
|
||||
|
||||
return True
|
||||
|
||||
def pick_and_place(
|
||||
self,
|
||||
option: str,
|
||||
move_group: str,
|
||||
status: str,
|
||||
resource: Optional[str] = None,
|
||||
x_distance: Optional[float] = None,
|
||||
y_distance: Optional[float] = None,
|
||||
lift_height: Optional[float] = None,
|
||||
retry: Optional[int] = None,
|
||||
speed: Optional[float] = None,
|
||||
target: Optional[str] = None,
|
||||
constraints: Optional[Sequence[float]] = None,
|
||||
) -> None:
|
||||
def pick_and_place(self, command: str):
|
||||
"""
|
||||
使用 MoveIt 完成抓取/放置等序列(pick/place/side_pick/side_place)。
|
||||
Using MoveIt to make the robotic arm pick or place materials to a target point.
|
||||
|
||||
必选:option, move_group, status。
|
||||
可选:resource, x_distance, y_distance, lift_height, retry, speed, target, constraints。
|
||||
无返回值;失败时提前 return 或打印异常。
|
||||
Args:
|
||||
command: A JSON-formatted string that includes option, target, speed, lift_height, mt_height
|
||||
|
||||
*option (string) : Action type: pick/place/side_pick/side_place
|
||||
*move_group (string): The move group moveit will plan
|
||||
*status(string) : Target pose
|
||||
resource(string) : The target resource
|
||||
x_distance (float) : The distance to the target in x direction(meters)
|
||||
y_distance (float) : The distance to the target in y direction(meters)
|
||||
lift_height (float) : The height at which the material should be lifted(meters)
|
||||
retry (float) : Retry times when moveit plan fails
|
||||
speed (float) : The speed of the movement, speed > 0
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
result = SendCmd.Result()
|
||||
|
||||
try:
|
||||
if option not in self.move_option:
|
||||
raise ValueError(f"Invalid option: {option}")
|
||||
cmd_str = str(command).replace("'", '"')
|
||||
cmd_dict = json.loads(cmd_str)
|
||||
|
||||
option_index = self.move_option.index(option)
|
||||
place_flag = option_index % 2
|
||||
if cmd_dict["option"] in self.move_option:
|
||||
option_index = self.move_option.index(cmd_dict["option"])
|
||||
place_flag = option_index % 2
|
||||
|
||||
config: dict = {"move_group": move_group}
|
||||
if speed is not None:
|
||||
config["speed"] = speed
|
||||
if retry is not None:
|
||||
config["retry"] = retry
|
||||
config = {}
|
||||
function_list = []
|
||||
|
||||
function_list = []
|
||||
joint_positions_ = self.joint_poses[move_group][status]
|
||||
status = cmd_dict["status"]
|
||||
joint_positions_ = self.joint_poses[cmd_dict["move_group"]][status]
|
||||
|
||||
# 夹取 / 放置:绑定 resource 与 parent
|
||||
if not place_flag:
|
||||
if target is not None:
|
||||
function_list.append(lambda r=resource, t=target: self.resource_manager(r, t))
|
||||
else:
|
||||
ee = self.moveit2[move_group].end_effector_name
|
||||
function_list.append(lambda r=resource: self.resource_manager(r, ee))
|
||||
else:
|
||||
function_list.append(lambda r=resource: self.resource_manager(r, "world"))
|
||||
config.update({k: cmd_dict[k] for k in ["speed", "retry", "move_group"] if k in cmd_dict})
|
||||
|
||||
joint_constraint_msgs: list = []
|
||||
if constraints is not None:
|
||||
for i, c in enumerate(constraints):
|
||||
v = float(c)
|
||||
if v > 0:
|
||||
joint_constraint_msgs.append(
|
||||
JointConstraint(
|
||||
joint_name=self.moveit2[move_group].joint_names[i],
|
||||
position=joint_positions_[i],
|
||||
tolerance_above=v,
|
||||
tolerance_below=v,
|
||||
weight=1.0,
|
||||
# 夹取
|
||||
if not place_flag:
|
||||
if "target" in cmd_dict.keys():
|
||||
function_list.append(lambda: self.resource_manager(cmd_dict["resource"], cmd_dict["target"]))
|
||||
else:
|
||||
function_list.append(
|
||||
lambda: self.resource_manager(
|
||||
cmd_dict["resource"], self.moveit2[cmd_dict["move_group"]].end_effector_name
|
||||
)
|
||||
)
|
||||
else:
|
||||
function_list.append(lambda: self.resource_manager(cmd_dict["resource"], "world"))
|
||||
|
||||
if lift_height is not None:
|
||||
retval = None
|
||||
attempts = config.get("retry", 10)
|
||||
while retval is None and attempts > 0:
|
||||
retval = self.moveit2[move_group].compute_fk(joint_positions_)
|
||||
time.sleep(0.1)
|
||||
attempts -= 1
|
||||
if retval is None:
|
||||
raise ValueError("Failed to compute forward kinematics")
|
||||
pose = [retval.pose.position.x, retval.pose.position.y, retval.pose.position.z]
|
||||
quaternion = [
|
||||
retval.pose.orientation.x,
|
||||
retval.pose.orientation.y,
|
||||
retval.pose.orientation.z,
|
||||
retval.pose.orientation.w,
|
||||
]
|
||||
constraints = []
|
||||
if "constraints" in cmd_dict.keys():
|
||||
|
||||
function_list = [
|
||||
lambda: self.moveit_task(
|
||||
position=[retval.pose.position.x, retval.pose.position.y, retval.pose.position.z],
|
||||
quaternion=quaternion,
|
||||
**config,
|
||||
cartesian=self.cartesian_flag,
|
||||
)
|
||||
] + function_list
|
||||
for i in range(len(cmd_dict["constraints"])):
|
||||
v = float(cmd_dict["constraints"][i])
|
||||
if v > 0:
|
||||
constraints.append(
|
||||
JointConstraint(
|
||||
joint_name=self.moveit2[cmd_dict["move_group"]].joint_names[i],
|
||||
position=joint_positions_[i],
|
||||
tolerance_above=v,
|
||||
tolerance_below=v,
|
||||
weight=1.0,
|
||||
)
|
||||
)
|
||||
|
||||
pose[2] += float(lift_height)
|
||||
function_list.append(
|
||||
lambda p=pose.copy(), q=quaternion, cfg=config: self.moveit_task(
|
||||
position=p, quaternion=q, **cfg, cartesian=self.cartesian_flag
|
||||
)
|
||||
)
|
||||
end_pose = list(pose)
|
||||
|
||||
if x_distance is not None or y_distance is not None:
|
||||
if x_distance is not None:
|
||||
deep_pose = deepcopy(pose)
|
||||
deep_pose[0] += float(x_distance)
|
||||
elif y_distance is not None:
|
||||
deep_pose = deepcopy(pose)
|
||||
deep_pose[1] += float(y_distance)
|
||||
if "lift_height" in cmd_dict.keys():
|
||||
retval = None
|
||||
retry = config.get("retry", 10)
|
||||
while retval is None and retry > 0:
|
||||
retval = self.moveit2[cmd_dict["move_group"]].compute_fk(joint_positions_)
|
||||
time.sleep(0.1)
|
||||
retry -= 1
|
||||
if retval is None:
|
||||
result.success = False
|
||||
return result
|
||||
pose = [retval.pose.position.x, retval.pose.position.y, retval.pose.position.z]
|
||||
quaternion = [
|
||||
retval.pose.orientation.x,
|
||||
retval.pose.orientation.y,
|
||||
retval.pose.orientation.z,
|
||||
retval.pose.orientation.w,
|
||||
]
|
||||
|
||||
function_list = [
|
||||
lambda p=pose.copy(), q=quaternion, cfg=config: self.moveit_task(
|
||||
position=p, quaternion=q, **cfg, cartesian=self.cartesian_flag
|
||||
lambda: self.moveit_task(
|
||||
position=[retval.pose.position.x, retval.pose.position.y, retval.pose.position.z],
|
||||
quaternion=quaternion,
|
||||
**config,
|
||||
cartesian=self.cartesian_flag,
|
||||
)
|
||||
] + function_list
|
||||
|
||||
pose[2] += float(cmd_dict["lift_height"])
|
||||
function_list.append(
|
||||
lambda dp=deep_pose.copy(), q=quaternion, cfg=config: self.moveit_task(
|
||||
position=dp, quaternion=q, **cfg, cartesian=self.cartesian_flag
|
||||
lambda: self.moveit_task(
|
||||
position=pose, quaternion=quaternion, **config, cartesian=self.cartesian_flag
|
||||
)
|
||||
)
|
||||
end_pose = list(deep_pose)
|
||||
end_pose = pose
|
||||
|
||||
retval_ik = None
|
||||
attempts_ik = config.get("retry", 10)
|
||||
while retval_ik is None and attempts_ik > 0:
|
||||
retval_ik = self.moveit2[move_group].compute_ik(
|
||||
position=end_pose,
|
||||
quat_xyzw=quaternion,
|
||||
constraints=Constraints(joint_constraints=joint_constraint_msgs),
|
||||
)
|
||||
time.sleep(0.1)
|
||||
attempts_ik -= 1
|
||||
if retval_ik is None:
|
||||
raise ValueError("Failed to compute inverse kinematics")
|
||||
position_ = [
|
||||
retval_ik.position[retval_ik.name.index(i)] for i in self.moveit2[move_group].joint_names
|
||||
]
|
||||
jn = self.moveit2[move_group].joint_names
|
||||
function_list = [
|
||||
lambda pos=position_, names=jn, cfg=config: self.moveit_joint_task(
|
||||
joint_positions=pos, joint_names=names, **cfg
|
||||
)
|
||||
] + function_list
|
||||
else:
|
||||
function_list = [lambda cfg=config, jp=joint_positions_: self.moveit_joint_task(**cfg, joint_positions=jp)] + function_list
|
||||
if "x_distance" in cmd_dict.keys() or "y_distance" in cmd_dict.keys():
|
||||
if "x_distance" in cmd_dict.keys():
|
||||
deep_pose = deepcopy(pose)
|
||||
deep_pose[0] += float(cmd_dict["x_distance"])
|
||||
elif "y_distance" in cmd_dict.keys():
|
||||
deep_pose = deepcopy(pose)
|
||||
deep_pose[1] += float(cmd_dict["y_distance"])
|
||||
|
||||
for i in range(len(function_list)):
|
||||
if i == 0:
|
||||
self.cartesian_flag = False
|
||||
function_list = [
|
||||
lambda: self.moveit_task(
|
||||
position=pose, quaternion=quaternion, **config, cartesian=self.cartesian_flag
|
||||
)
|
||||
] + function_list
|
||||
function_list.append(
|
||||
lambda: self.moveit_task(
|
||||
position=deep_pose, quaternion=quaternion, **config, cartesian=self.cartesian_flag
|
||||
)
|
||||
)
|
||||
end_pose = deep_pose
|
||||
|
||||
retval_ik = None
|
||||
retry = config.get("retry", 10)
|
||||
while retval_ik is None and retry > 0:
|
||||
retval_ik = self.moveit2[cmd_dict["move_group"]].compute_ik(
|
||||
position=end_pose, quat_xyzw=quaternion, constraints=Constraints(joint_constraints=constraints)
|
||||
)
|
||||
time.sleep(0.1)
|
||||
retry -= 1
|
||||
if retval_ik is None:
|
||||
result.success = False
|
||||
return result
|
||||
position_ = [
|
||||
retval_ik.position[retval_ik.name.index(i)]
|
||||
for i in self.moveit2[cmd_dict["move_group"]].joint_names
|
||||
]
|
||||
function_list = [
|
||||
lambda: self.moveit_joint_task(
|
||||
joint_positions=position_,
|
||||
joint_names=self.moveit2[cmd_dict["move_group"]].joint_names,
|
||||
**config,
|
||||
)
|
||||
] + function_list
|
||||
else:
|
||||
self.cartesian_flag = True
|
||||
function_list = [
|
||||
lambda: self.moveit_joint_task(**config, joint_positions=joint_positions_)
|
||||
] + function_list
|
||||
|
||||
re = function_list[i]()
|
||||
if not re:
|
||||
print(i, re)
|
||||
raise ValueError(f"Failed to execute moveit task: {i}")
|
||||
for i in range(len(function_list)):
|
||||
if i == 0:
|
||||
self.cartesian_flag = False
|
||||
else:
|
||||
self.cartesian_flag = True
|
||||
|
||||
re = function_list[i]()
|
||||
if not re:
|
||||
print(i, re)
|
||||
result.success = False
|
||||
return result
|
||||
result.success = True
|
||||
|
||||
except Exception as e:
|
||||
print(e)
|
||||
self.cartesian_flag = False
|
||||
raise e
|
||||
result.success = False
|
||||
|
||||
return result
|
||||
|
||||
def set_status(self, command: str):
|
||||
"""
|
||||
|
||||
0
unilabos/devices/transport/__init__.py
Normal file
0
unilabos/devices/transport/__init__.py
Normal file
127
unilabos/devices/transport/agv_workstation.py
Normal file
127
unilabos/devices/transport/agv_workstation.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
AGV 通用转运工站 Driver
|
||||
|
||||
继承 WorkstationBase,通过 WorkstationNodeCreator 自动获得 ROS2WorkstationNode 能力。
|
||||
Warehouse 作为 children 中的资源节点,由 attach_resource() 自动注册到 resource_tracker。
|
||||
deck=None,不使用 PLR Deck 抽象。
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pylabrobot.resources import Deck
|
||||
|
||||
from unilabos.devices.workstation.workstation_base import WorkstationBase
|
||||
from unilabos.resources.warehouse import WareHouse
|
||||
from unilabos.utils import logger
|
||||
|
||||
|
||||
class AGVTransportStation(WorkstationBase):
|
||||
"""通用 AGV 转运工站
|
||||
|
||||
初始化链路(零框架改动):
|
||||
ROS2DeviceNode.__init__():
|
||||
issubclass(AGVTransportStation, WorkstationBase) → True
|
||||
→ WorkstationNodeCreator.create_instance(data):
|
||||
data["deck"] = None
|
||||
→ DeviceClassCreator.create_instance(data) → AGVTransportStation(deck=None, ...)
|
||||
→ attach_resource(): children 中 type="warehouse" → resource_tracker.add_resource(wh)
|
||||
→ ROS2WorkstationNode(protocol_type=[...], children=[nav, arm], ...)
|
||||
→ driver.post_init(ros_node):
|
||||
self.carrier 从 resource_tracker 中获取 WareHouse
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
deck: Optional[Deck] = None,
|
||||
children: Optional[List[Any]] = None,
|
||||
route_table: Optional[Dict[str, Dict[str, str]]] = None,
|
||||
device_roles: Optional[Dict[str, str]] = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(deck=None, **kwargs)
|
||||
self.route_table: Dict[str, Dict[str, str]] = route_table or {}
|
||||
self.device_roles: Dict[str, str] = device_roles or {}
|
||||
|
||||
# ============ 载具 (Warehouse) ============
|
||||
|
||||
@property
|
||||
def carrier(self) -> Optional[WareHouse]:
|
||||
"""从 resource_tracker 中找到 AGV 载具 Warehouse"""
|
||||
if not hasattr(self, "_ros_node"):
|
||||
return None
|
||||
for res in self._ros_node.resource_tracker.resources:
|
||||
if isinstance(res, WareHouse):
|
||||
return res
|
||||
return None
|
||||
|
||||
@property
|
||||
def capacity(self) -> int:
|
||||
"""AGV 载具总容量(slot 数)"""
|
||||
wh = self.carrier
|
||||
if wh is None:
|
||||
return 0
|
||||
return wh.num_items_x * wh.num_items_y * wh.num_items_z
|
||||
|
||||
@property
|
||||
def free_slots(self) -> List[str]:
|
||||
"""返回当前空闲 slot 名称列表"""
|
||||
wh = self.carrier
|
||||
if wh is None:
|
||||
return []
|
||||
ordering = getattr(wh, "_ordering", {})
|
||||
return [name for name, site in ordering.items() if site.resource is None]
|
||||
|
||||
@property
|
||||
def occupied_slots(self) -> Dict[str, Any]:
|
||||
"""返回已占用的 slot → Resource 映射"""
|
||||
wh = self.carrier
|
||||
if wh is None:
|
||||
return {}
|
||||
ordering = getattr(wh, "_ordering", {})
|
||||
return {name: site.resource for name, site in ordering.items() if site.resource is not None}
|
||||
|
||||
# ============ 路由查询 ============
|
||||
|
||||
def resolve_route(self, from_station: str, to_station: str) -> Dict[str, str]:
|
||||
"""查询路由表,返回导航和机械臂指令
|
||||
|
||||
Args:
|
||||
from_station: 来源工站 ID
|
||||
to_station: 目标工站 ID
|
||||
|
||||
Returns:
|
||||
{"nav_command": "...", "arm_pick": "...", "arm_place": "..."}
|
||||
|
||||
Raises:
|
||||
KeyError: 路由表中未找到对应路线
|
||||
"""
|
||||
route_key = f"{from_station}->{to_station}"
|
||||
if route_key not in self.route_table:
|
||||
raise KeyError(f"路由表中未找到路线: {route_key}")
|
||||
return self.route_table[route_key]
|
||||
|
||||
def get_device_id(self, role: str) -> str:
|
||||
"""获取子设备 ID
|
||||
|
||||
Args:
|
||||
role: 设备角色,如 "navigator", "arm"
|
||||
|
||||
Returns:
|
||||
设备 ID 字符串
|
||||
|
||||
Raises:
|
||||
KeyError: 未配置该角色的设备
|
||||
"""
|
||||
if role not in self.device_roles:
|
||||
raise KeyError(f"未配置设备角色: {role},当前已配置: {list(self.device_roles.keys())}")
|
||||
return self.device_roles[role]
|
||||
|
||||
# ============ 生命周期 ============
|
||||
|
||||
def post_init(self, ros_node) -> None:
|
||||
super().post_init(ros_node)
|
||||
wh = self.carrier
|
||||
if wh is not None:
|
||||
logger.info(f"AGV {ros_node.device_id} 载具已就绪: {wh.name}, 容量={self.capacity}")
|
||||
else:
|
||||
logger.warning(f"AGV {ros_node.device_id} 未发现 Warehouse 载具资源")
|
||||
@@ -1,9 +1,5 @@
|
||||
# 工作站抽象基类物料系统架构说明
|
||||
|
||||
## 设计理念
|
||||
|
||||
基于用户需求"请你帮我系统思考一下,工作站抽象基类的物料系统基类该如何构建",我们最终确定了一个**PyLabRobot Deck为中心**的简化架构。
|
||||
|
||||
### 核心原则
|
||||
|
||||
1. **PyLabRobot为物料管理核心**:使用PyLabRobot的Deck系统作为物料管理的基础,利用其成熟的Resource体系
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# Bioyond Cell 工作站 - 多订单返回示例
|
||||
|
||||
本文档说明了 `create_orders` 函数如何收集并返回所有订单的完成报文。
|
||||
|
||||
## 问题描述
|
||||
|
||||
之前的实现只会等待并返回第一个订单的完成报文,如果有多个订单(例如从 Excel 解析出 3 个订单),只能得到第一个订单的推送信息。
|
||||
|
||||
## 解决方案
|
||||
|
||||
修改后的 `create_orders` 函数现在会:
|
||||
|
||||
1. **提取所有 orderCode**:从 LIMS 接口返回的 `data` 列表中提取所有订单编号
|
||||
2. **逐个等待完成**:遍历所有 orderCode,调用 `wait_for_order_finish` 等待每个订单完成
|
||||
3. **收集所有报文**:将每个订单的完成报文存入 `all_reports` 列表
|
||||
4. **统一返回**:返回包含所有订单报文的 JSON 格式数据
|
||||
|
||||
## 返回格式
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "all_completed",
|
||||
"total_orders": 3,
|
||||
"reports": [
|
||||
{
|
||||
"token": "",
|
||||
"request_time": "2025-12-24T15:32:09.2148671+08:00",
|
||||
"data": {
|
||||
"orderId": "3a1e614d-a082-c44a-60be-68647a35e6f1",
|
||||
"orderCode": "BSO2025122400024",
|
||||
"orderName": "DP20251224001",
|
||||
"status": "30",
|
||||
"workflowStatus": "completed",
|
||||
"usedMaterials": [...]
|
||||
}
|
||||
},
|
||||
{
|
||||
"token": "",
|
||||
"request_time": "2025-12-24T15:32:09.9999039+08:00",
|
||||
"data": {
|
||||
"orderId": "3a1e614d-a0a2-f7a9-9360-610021c9479d",
|
||||
"orderCode": "BSO2025122400025",
|
||||
"orderName": "DP20251224002",
|
||||
"status": "30",
|
||||
"workflowStatus": "completed",
|
||||
"usedMaterials": [...]
|
||||
}
|
||||
},
|
||||
{
|
||||
"token": "",
|
||||
"request_time": "2025-12-24T15:34:00.4139986+08:00",
|
||||
"data": {
|
||||
"orderId": "3a1e614d-a0cd-81ca-9f7f-2f4e93af01cd",
|
||||
"orderCode": "BSO2025122400026",
|
||||
"orderName": "DP20251224003",
|
||||
"status": "30",
|
||||
"workflowStatus": "completed",
|
||||
"usedMaterials": [...]
|
||||
}
|
||||
}
|
||||
],
|
||||
"original_response": {...}
|
||||
}
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
```python
|
||||
# 调用 create_orders
|
||||
result = workstation.create_orders("20251224.xlsx")
|
||||
|
||||
# 访问返回数据
|
||||
print(f"总订单数: {result['total_orders']}")
|
||||
print(f"状态: {result['status']}")
|
||||
|
||||
# 遍历所有订单的报文
|
||||
for i, report in enumerate(result['reports'], 1):
|
||||
order_data = report.get('data', {})
|
||||
print(f"\n订单 {i}:")
|
||||
print(f" orderCode: {order_data.get('orderCode')}")
|
||||
print(f" orderName: {order_data.get('orderName')}")
|
||||
print(f" status: {order_data.get('status')}")
|
||||
print(f" 使用物料数: {len(order_data.get('usedMaterials', []))}")
|
||||
```
|
||||
|
||||
## 控制台输出示例
|
||||
|
||||
```
|
||||
[create_orders] 即将提交订单数量: 3
|
||||
[create_orders] 接口返回: {...}
|
||||
[create_orders] 等待 3 个订单完成: ['BSO2025122400024', 'BSO2025122400025', 'BSO2025122400026']
|
||||
[create_orders] 正在等待第 1/3 个订单: BSO2025122400024
|
||||
[create_orders] ✓ 订单 BSO2025122400024 完成
|
||||
[create_orders] 正在等待第 2/3 个订单: BSO2025122400025
|
||||
[create_orders] ✓ 订单 BSO2025122400025 完成
|
||||
[create_orders] 正在等待第 3/3 个订单: BSO2025122400026
|
||||
[create_orders] ✓ 订单 BSO2025122400026 完成
|
||||
[create_orders] 所有订单已完成,共收集 3 个报文
|
||||
实验记录本========================create_orders========================
|
||||
返回报文数量: 3
|
||||
报文 1: orderCode=BSO2025122400024, status=30
|
||||
报文 2: orderCode=BSO2025122400025, status=30
|
||||
报文 3: orderCode=BSO2025122400026, status=30
|
||||
========================
|
||||
```
|
||||
|
||||
## 关键改进
|
||||
|
||||
1. ✅ **等待所有订单**:不再只等待第一个订单,而是遍历所有 orderCode
|
||||
2. ✅ **收集完整报文**:每个订单的完整推送报文都被保存在 `reports` 数组中
|
||||
3. ✅ **详细日志**:清晰显示正在等待哪个订单,以及完成情况
|
||||
4. ✅ **错误处理**:即使某个订单失败,也会记录其状态信息
|
||||
5. ✅ **统一格式**:返回的 JSON 格式便于后续处理和分析
|
||||
Binary file not shown.
@@ -0,0 +1,204 @@
|
||||
# BioyondCellWorkstation JSON 配置迁移经验总结
|
||||
|
||||
**日期**: 2026-01-13
|
||||
**目的**: 从 `config.py` 迁移到 JSON 配置文件
|
||||
|
||||
---
|
||||
|
||||
## 问题背景
|
||||
|
||||
原系统通过 `config.py` 管理配置,导致:
|
||||
1. HTTP 服务重复启动(父类 `BioyondWorkstation` 和子类都启动)
|
||||
2. 配置分散在代码中,不便于管理
|
||||
3. 无法通过 JSON 统一配置所有参数
|
||||
|
||||
---
|
||||
|
||||
## 解决方案:嵌套配置结构
|
||||
|
||||
### JSON 结构设计
|
||||
|
||||
**正确示例** (嵌套在 `config` 中):
|
||||
```json
|
||||
{
|
||||
"nodes": [{
|
||||
"id": "bioyond_cell_workstation",
|
||||
"config": {
|
||||
"deck": {...},
|
||||
"protocol_type": [],
|
||||
"bioyond_config": {
|
||||
"api_host": "http://172.16.11.219:44388",
|
||||
"api_key": "8A819E5C",
|
||||
"timeout": 30,
|
||||
"HTTP_host": "172.16.11.206",
|
||||
"HTTP_port": 8080,
|
||||
"debug_mode": false,
|
||||
"material_type_mappings": {...},
|
||||
"warehouse_mapping": {...},
|
||||
"solid_liquid_mappings": {...}
|
||||
}
|
||||
},
|
||||
"data": {}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
- ✅ `bioyond_config` 放在 `config` 中(会传递到 `__init__`)
|
||||
- ❌ **不要**放在 `data` 中(`data` 是运行时状态,不会传递)
|
||||
|
||||
---
|
||||
|
||||
## Python 代码适配
|
||||
|
||||
### 1. 修改 `BioyondCellWorkstation.__init__` 签名
|
||||
|
||||
**文件**: `bioyond_cell_workstation.py`
|
||||
|
||||
```python
|
||||
def __init__(self, bioyond_config: dict = None, deck=None, protocol_type=None, **kwargs):
|
||||
"""
|
||||
Args:
|
||||
bioyond_config: 从 JSON 加载的配置字典
|
||||
deck: Deck 配置
|
||||
protocol_type: 协议类型
|
||||
"""
|
||||
# 验证配置
|
||||
if bioyond_config is None:
|
||||
raise ValueError("需要 bioyond_config 参数")
|
||||
|
||||
# 保存配置
|
||||
self.bioyond_config = bioyond_config
|
||||
|
||||
# 设置 HTTP 服务去重标志
|
||||
self.bioyond_config["_disable_auto_http_service"] = True
|
||||
|
||||
# 调用父类
|
||||
super().__init__(bioyond_config=self.bioyond_config, deck=deck, **kwargs)
|
||||
```
|
||||
|
||||
### 2. 替换全局变量引用
|
||||
|
||||
**修改前**(使用全局变量):
|
||||
```python
|
||||
from config import MATERIAL_TYPE_MAPPINGS, WAREHOUSE_MAPPING
|
||||
|
||||
def create_sample(self, board_type, ...):
|
||||
carrier_type_id = MATERIAL_TYPE_MAPPINGS[board_type][1]
|
||||
location_id = WAREHOUSE_MAPPING[warehouse_name]["site_uuids"][location_code]
|
||||
```
|
||||
|
||||
**修改后**(从配置读取):
|
||||
```python
|
||||
def create_sample(self, board_type, ...):
|
||||
carrier_type_id = self.bioyond_config['material_type_mappings'][board_type][1]
|
||||
location_id = self.bioyond_config['warehouse_mapping'][warehouse_name]["site_uuids"][location_code]
|
||||
```
|
||||
|
||||
### 3. 修复父类配置访问
|
||||
|
||||
在 `station.py` 中安全访问配置默认值:
|
||||
|
||||
```python
|
||||
# 修改前(会 KeyError)
|
||||
self._http_service_config = {
|
||||
"host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG["http_service_host"])
|
||||
}
|
||||
|
||||
# 修改后(安全访问)
|
||||
self._http_service_config = {
|
||||
"host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG.get("http_service_host", ""))
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见陷阱
|
||||
|
||||
### ❌ 错误1:将配置放在 `data` 字段
|
||||
```json
|
||||
"config": {"deck": {...}},
|
||||
"data": {"bioyond_config": {...}} // ❌ 不会传递到 __init__
|
||||
```
|
||||
|
||||
### ❌ 错误2:扁平化配置(已废弃方案)
|
||||
虽然扁平化也能工作,但不推荐:
|
||||
```json
|
||||
"config": {
|
||||
"deck": {...},
|
||||
"api_host": "...", // ❌ 不够清晰
|
||||
"api_key": "...",
|
||||
"HTTP_host": "..."
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ 错误3:忘记替换全局变量引用
|
||||
代码中直接使用 `MATERIAL_TYPE_MAPPINGS` 等全局变量会导致 `NameError`。
|
||||
|
||||
---
|
||||
|
||||
## 云端同步注意事项
|
||||
|
||||
使用 `--upload_registry` 时,云端配置可能覆盖本地配置:
|
||||
- 首次上传时确保 JSON 完整
|
||||
- 或使用新的 `ak/sk` 避免旧配置干扰
|
||||
- 调试时可暂时移除 `--upload_registry` 参数
|
||||
|
||||
---
|
||||
|
||||
## 验证清单
|
||||
|
||||
启动成功后应看到:
|
||||
```
|
||||
✅ 从 JSON 配置加载 bioyond_config 成功
|
||||
API Host: http://...
|
||||
HTTP Service: ...
|
||||
✅ BioyondCellWorkstation 初始化完成
|
||||
Loaded ResourceTreeSet with ... nodes
|
||||
```
|
||||
|
||||
运行时不应出现:
|
||||
- ❌ `NameError: name 'MATERIAL_TYPE_MAPPINGS' is not defined`
|
||||
- ❌ `KeyError: 'http_service_host'`
|
||||
- ❌ `bioyond_config 缺少必需参数`
|
||||
|
||||
---
|
||||
|
||||
## 调试经验
|
||||
|
||||
1. **添加调试日志**查看参数传递链路:
|
||||
- `graphio.py`: JSON 加载后的 config 内容
|
||||
- `initialize_device.py`: `device_config.res_content.config` 的键
|
||||
- `bioyond_cell_workstation.py`: `__init__` 接收到的参数
|
||||
|
||||
2. **config vs data 区别**:
|
||||
- `config`: 初始化参数,传递给 `__init__`
|
||||
- `data`: 运行时状态,不传递给 `__init__`
|
||||
|
||||
3. **参数名必须匹配**:
|
||||
- JSON 中的键名必须与 `__init__` 参数名完全一致
|
||||
|
||||
4. **调试代码清理**:完成后记得删除调试日志(🔍 DEBUG 标记)
|
||||
|
||||
---
|
||||
|
||||
## 修改文件清单
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|----------|
|
||||
| `yibin_electrolyte_config.json` | 创建嵌套 `config.bioyond_config` 结构 |
|
||||
| `bioyond_cell_workstation.py` | 修改 `__init__` 接收 `bioyond_config`,替换所有全局变量引用 |
|
||||
| `station.py` | 安全访问 `HTTP_SERVICE_CONFIG` 默认值 |
|
||||
|
||||
---
|
||||
|
||||
## 参考代码位置
|
||||
|
||||
- JSON 配置示例: `yibin_electrolyte_config.json` L12-L353
|
||||
- `__init__` 实现: `bioyond_cell_workstation.py` L39-L94
|
||||
- 全局变量替换示例: `bioyond_cell_workstation.py` L2005, L1863, L1966
|
||||
- HTTP 服务配置: `station.py` L629-L634
|
||||
|
||||
---
|
||||
|
||||
**总结**: 使用嵌套结构将所有配置放在 `config.bioyond_config` 中,修改 `__init__` 直接接收该参数,并替换所有全局变量引用为 `self.bioyond_config` 访问。
|
||||
@@ -0,0 +1,312 @@
|
||||
# BioyondCell 配置迁移修改总结
|
||||
|
||||
**日期**: 2026-01-13
|
||||
**目标**: 从 `config.py` 完全迁移到 JSON 配置,消除所有全局变量依赖
|
||||
|
||||
---
|
||||
|
||||
## 📋 修改概览
|
||||
|
||||
本次修改完成了 BioyondCell 模块从 Python 配置文件到 JSON 配置的完整迁移,并清理了所有对 `config.py` 全局变量的依赖。
|
||||
|
||||
### 核心成果
|
||||
|
||||
- ✅ 完全移除对 `config.py` 的导入依赖
|
||||
- ✅ 使用嵌套 JSON 结构 `config.bioyond_config`
|
||||
- ✅ 修复 7 处 `bioyond_cell_workstation.py` 中的全局变量引用
|
||||
- ✅ 修复 3 处其他文件中的全局变量引用
|
||||
- ✅ HTTP 服务去重机制完善
|
||||
- ✅ 系统成功启动并正常运行
|
||||
|
||||
---
|
||||
|
||||
## 🔧 修改文件清单
|
||||
|
||||
### 1. JSON 配置文件
|
||||
|
||||
**文件**: `yibin_electrolyte_config.json`
|
||||
|
||||
**修改**:
|
||||
- 采用嵌套结构将所有配置放在 `config.bioyond_config` 中
|
||||
- 包含:`api_host`, `api_key`, `HTTP_host`, `HTTP_port`, `material_type_mappings`, `warehouse_mapping`, `solid_liquid_mappings` 等
|
||||
|
||||
**示例结构**:
|
||||
```json
|
||||
{
|
||||
"nodes": [{
|
||||
"id": "bioyond_cell_workstation",
|
||||
"config": {
|
||||
"deck": {...},
|
||||
"protocol_type": [],
|
||||
"bioyond_config": {
|
||||
"api_host": "http://172.16.11.219:44388",
|
||||
"api_key": "8A819E5C",
|
||||
"HTTP_host": "172.16.11.206",
|
||||
"HTTP_port": 8080,
|
||||
"material_type_mappings": {...},
|
||||
"warehouse_mapping": {...},
|
||||
"solid_liquid_mappings": {...}
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. bioyond_cell_workstation.py
|
||||
|
||||
**位置**: `unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py`
|
||||
|
||||
#### 修改 A: `__init__` 方法签名 (L39-99)
|
||||
|
||||
**修改前**:
|
||||
```python
|
||||
def __init__(self, deck=None, protocol_type=None, **kwargs):
|
||||
# 从 kwargs 收集配置字段
|
||||
self.bioyond_config = {}
|
||||
for field in bioyond_field_names:
|
||||
if field in kwargs:
|
||||
self.bioyond_config[field] = kwargs.pop(field)
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```python
|
||||
def __init__(self, bioyond_config: dict = None, deck=None, protocol_type=None, **kwargs):
|
||||
"""直接接收 bioyond_config 参数"""
|
||||
if bioyond_config is None:
|
||||
raise ValueError("需要 bioyond_config 参数")
|
||||
|
||||
self.bioyond_config = bioyond_config
|
||||
|
||||
# 设置 HTTP 服务去重标志
|
||||
self.bioyond_config["_disable_auto_http_service"] = True
|
||||
|
||||
super().__init__(bioyond_config=self.bioyond_config, deck=deck, **kwargs)
|
||||
```
|
||||
|
||||
#### 修改 B: 替换全局变量引用 (7 处)
|
||||
|
||||
| 位置 | 原代码 | 修改后 |
|
||||
|------|--------|--------|
|
||||
| L2005 | `MATERIAL_TYPE_MAPPINGS[board_type][1]` | `self.bioyond_config['material_type_mappings'][board_type][1]` |
|
||||
| L2006 | `MATERIAL_TYPE_MAPPINGS[bottle_type][1]` | `self.bioyond_config['material_type_mappings'][bottle_type][1]` |
|
||||
| L2009 | `WAREHOUSE_MAPPING` | `self.bioyond_config['warehouse_mapping']` |
|
||||
| L2013 | `WAREHOUSE_MAPPING[warehouse_name]` | `self.bioyond_config['warehouse_mapping'][warehouse_name]` |
|
||||
| L2017 | `WAREHOUSE_MAPPING[warehouse_name]["site_uuids"]` | `self.bioyond_config['warehouse_mapping'][warehouse_name]["site_uuids"]` |
|
||||
| L1863 | `SOLID_LIQUID_MAPPINGS.get(material_name)` | `self.bioyond_config.get('solid_liquid_mappings', {}).get(material_name)` |
|
||||
| L1966, L1976 | `MATERIAL_TYPE_MAPPINGS.items()` | `self.bioyond_config['material_type_mappings'].items()` |
|
||||
|
||||
---
|
||||
|
||||
### 3. station.py
|
||||
|
||||
**位置**: `unilabos/devices/workstation/bioyond_studio/station.py`
|
||||
|
||||
#### 修改 A: 删除 config 导入 (L26-28)
|
||||
|
||||
**修改前**:
|
||||
```python
|
||||
from unilabos.devices.workstation.bioyond_studio.config import (
|
||||
API_CONFIG, WORKFLOW_MAPPINGS, MATERIAL_TYPE_MAPPINGS, WAREHOUSE_MAPPING, HTTP_SERVICE_CONFIG
|
||||
)
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```python
|
||||
# 已删除此导入
|
||||
```
|
||||
|
||||
#### 修改 B: `_create_communication_module` 方法 (L691-702)
|
||||
|
||||
**修改前**:
|
||||
```python
|
||||
def _create_communication_module(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||
default_config = {
|
||||
**API_CONFIG,
|
||||
"workflow_mappings": WORKFLOW_MAPPINGS,
|
||||
"material_type_mappings": MATERIAL_TYPE_MAPPINGS,
|
||||
"warehouse_mapping": WAREHOUSE_MAPPING
|
||||
}
|
||||
if config:
|
||||
self.bioyond_config = {**default_config, **config}
|
||||
else:
|
||||
self.bioyond_config = default_config
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```python
|
||||
def _create_communication_module(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||
"""创建Bioyond通信模块"""
|
||||
# 使用传入的 config 参数(来自 bioyond_config)
|
||||
# 不再依赖全局变量 API_CONFIG 等
|
||||
if config:
|
||||
self.bioyond_config = config
|
||||
else:
|
||||
# 如果没有传入配置,创建空配置(用于测试或兼容性)
|
||||
self.bioyond_config = {}
|
||||
|
||||
self.hardware_interface = BioyondV1RPC(self.bioyond_config)
|
||||
```
|
||||
|
||||
#### 修改 C: HTTP 服务配置 (L627-632)
|
||||
|
||||
**修改前**:
|
||||
```python
|
||||
self._http_service_config = {
|
||||
"host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG.get("http_service_host", "")),
|
||||
"port": bioyond_config.get("http_service_port", HTTP_SERVICE_CONFIG.get("http_service_port", 0))
|
||||
}
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```python
|
||||
self._http_service_config = {
|
||||
"host": bioyond_config.get("http_service_host", bioyond_config.get("HTTP_host", "")),
|
||||
"port": bioyond_config.get("http_service_port", bioyond_config.get("HTTP_port", 0))
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. bioyond_rpc.py
|
||||
|
||||
**位置**: `unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py`
|
||||
|
||||
#### 修改 A: 删除 config 导入 (L12)
|
||||
|
||||
**修改前**:
|
||||
```python
|
||||
from unilabos.devices.workstation.bioyond_studio.config import LOCATION_MAPPING
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```python
|
||||
# 已删除此导入
|
||||
```
|
||||
|
||||
#### 修改 B: `material_outbound` 方法 (L278-280)
|
||||
|
||||
**修改前**:
|
||||
```python
|
||||
def material_outbound(self, material_id: str, location_name: str, quantity: int) -> dict:
|
||||
"""指定库位出库物料(通过库位名称)"""
|
||||
location_id = LOCATION_MAPPING.get(location_name, location_name)
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```python
|
||||
def material_outbound(self, material_id: str, location_name: str, quantity: int) -> dict:
|
||||
"""指定库位出库物料(通过库位名称)"""
|
||||
# location_name 参数实际上应该直接是 location_id (UUID)
|
||||
location_id = location_name
|
||||
```
|
||||
|
||||
**说明**: `LOCATION_MAPPING` 在 `config-0113.py` 中本来就是空字典 `{}`,所以直接使用 `location_name` 逻辑等价。
|
||||
|
||||
---
|
||||
|
||||
## 🎯 关键设计决策
|
||||
|
||||
### 1. 嵌套 vs 扁平配置
|
||||
|
||||
**选择**: 嵌套结构 `config.bioyond_config`
|
||||
|
||||
**理由**:
|
||||
- ✅ 语义清晰,配置分组明确
|
||||
- ✅ 参数传递直观,直接对应 `__init__` 参数
|
||||
- ✅ 易于维护,不需要硬编码字段列表
|
||||
- ✅ 符合 UniLab 设计模式
|
||||
|
||||
### 2. HTTP 服务去重
|
||||
|
||||
**实现**: 子类设置 `_disable_auto_http_service` 标志
|
||||
|
||||
```python
|
||||
# bioyond_cell_workstation.py
|
||||
self.bioyond_config["_disable_auto_http_service"] = True
|
||||
|
||||
# station.py (post_init)
|
||||
if self.bioyond_config.get("_disable_auto_http_service"):
|
||||
logger.info("子类已自行管理HTTP服务,跳过自动启动")
|
||||
return
|
||||
```
|
||||
|
||||
### 3. 全局变量替换策略
|
||||
|
||||
**原则**: 所有配置从 `self.bioyond_config` 获取
|
||||
|
||||
**模式**:
|
||||
```python
|
||||
# 修改前
|
||||
from config import MATERIAL_TYPE_MAPPINGS
|
||||
carrier_type_id = MATERIAL_TYPE_MAPPINGS[board_type][1]
|
||||
|
||||
# 修改后
|
||||
carrier_type_id = self.bioyond_config['material_type_mappings'][board_type][1]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 验证结果
|
||||
|
||||
### 启动成功日志
|
||||
```
|
||||
✅ 从 JSON 配置加载 bioyond_config 成功
|
||||
API Host: http://172.16.11.219:44388
|
||||
HTTP Service: 172.16.11.206:8080
|
||||
🔧 已设置 _disable_auto_http_service 标志,防止 HTTP 服务重复启动
|
||||
✅ BioyondCellWorkstation 初始化完成
|
||||
Loaded ResourceTreeSet with 1 trees, 1785 total nodes
|
||||
```
|
||||
|
||||
### 功能验证
|
||||
- ✅ 订单创建 (`create_orders_v2`)
|
||||
- ✅ 质量比计算
|
||||
- ✅ 物料转移 (`transfer_3_to_2_to_1`)
|
||||
- ✅ HTTP 报送接收 (step_finish, sample_finish, order_finish)
|
||||
- ✅ 等待机制 (`wait_for_order_finish`)
|
||||
- ✅ 仓库 UUID 映射
|
||||
- ✅ 物料类型映射
|
||||
|
||||
---
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- **配置迁移经验**: `2026-01-13_JSON配置迁移经验.md`
|
||||
- **任务清单**: `C:\Users\AndyXie\.gemini\antigravity\brain\...\task.md`
|
||||
- **实施计划**: `C:\Users\AndyXie\.gemini\antigravity\brain\...\implementation_plan.md`
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 其他工作站模块
|
||||
|
||||
以下文件仍在使用 `config.py` 全局变量(未包含在本次修改中):
|
||||
- `reaction_station.py` - 使用 `API_CONFIG`
|
||||
- `experiment.py` - 使用 `API_CONFIG`, `WORKFLOW_MAPPINGS`, `MATERIAL_TYPE_MAPPINGS`
|
||||
- `dispensing_station.py` - 使用 `API_CONFIG`, `WAREHOUSE_MAPPING`
|
||||
- `station.py` L176, L177, L529, L530 - 动态导入 `WAREHOUSE_MAPPING`
|
||||
|
||||
**建议**: 后续可以统一迁移这些模块到 JSON 配置。
|
||||
|
||||
### config.py 文件
|
||||
|
||||
`config.py` 文件已恢复但**不再被 bioyond_cell 使用**。可以:
|
||||
- 保留作为其他模块的参考
|
||||
- 或者完全删除(如果其他模块也迁移完成)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 下一步建议
|
||||
|
||||
1. **清理调试代码** ✅ (已完成)
|
||||
2. **提交代码到 Git**
|
||||
3. **迁移其他工作站模块** (可选)
|
||||
4. **更新文档和启动脚本**
|
||||
|
||||
---
|
||||
|
||||
**修改完成日期**: 2026-01-13
|
||||
**系统状态**: ✅ 稳定运行
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,157 @@
|
||||
# 批量出库 Excel 模板使用说明
|
||||
|
||||
**文件**: `outbound_template.xlsx`
|
||||
**用途**: 配合 `auto_batch_outbound_from_xlsx()` 方法进行批量出库操作
|
||||
**API 端点**: `/api/lims/storage/auto-batch-out-bound`
|
||||
|
||||
---
|
||||
|
||||
## 📋 Excel 列说明
|
||||
|
||||
| 列名 | 说明 | 示例 | 必填 |
|
||||
|------|------|------|------|
|
||||
| `locationId` | **库位 ID(UUID)** | `3a19da43-57b5-294f-d663-154a1cc32270` | ✅ 是 |
|
||||
| `warehouseId` | **仓库 ID 或名称** | `配液站内试剂仓库` | ✅ 是 |
|
||||
| `quantity` | **出库数量** | `1.0`, `2.0` | ✅ 是 |
|
||||
| `x` | **X 坐标(库位横向位置)** | `1`, `2`, `3` | ✅ 是 |
|
||||
| `y` | **Y 坐标(库位纵向位置)** | `1`, `2`, `3` | ✅ 是 |
|
||||
| `z` | **Z 坐标(库位层数/高度)** | `1`, `2`, `3` | ✅ 是 |
|
||||
| `备注说明` | 可选备注信息 | `配液站内试剂仓库-A01` | ❌ 否 |
|
||||
|
||||
### 📐 坐标说明
|
||||
|
||||
**x, y, z** 是库位在仓库内的**三维坐标**:
|
||||
|
||||
```
|
||||
仓库(例如 WH4)
|
||||
├── Z=1(第1层/加样头面)
|
||||
│ ├── X=1, Y=1(位置 A)
|
||||
│ ├── X=2, Y=1(位置 B)
|
||||
│ ├── X=3, Y=1(位置 C)
|
||||
│ └── ...
|
||||
│
|
||||
└── Z=2(第2层/原液瓶面)
|
||||
├── X=1, Y=1(位置 A)
|
||||
├── X=2, Y=1(位置 B)
|
||||
└── ...
|
||||
```
|
||||
|
||||
- **warehouseId**: 指定哪个仓库(WH3, WH4, 配液站等)
|
||||
- **x, y, z**: 在该仓库内的三维坐标
|
||||
- **locationId**: 该坐标位置的唯一 UUID
|
||||
|
||||
### 🎯 起点与终点
|
||||
|
||||
**重要说明**:批量出库模板**只规定了出库的"起点"**(从哪里取物料),**没有指定"终点"**(放到哪里)。
|
||||
|
||||
```
|
||||
出库流程:
|
||||
起点(Excel 指定) → ?终点(LIMS/工作流决定)
|
||||
↓
|
||||
locationId, x, y, z → 由 LIMS 系统或当前工作流自动分配
|
||||
```
|
||||
|
||||
**终点由以下方式确定:**
|
||||
- **LIMS 系统自动分配**:根据当前任务自动规划目标位置
|
||||
- **工作流预定义**:在创建出库任务时已绑定目标位置
|
||||
- **暂存区**:默认放到出库暂存区,等待下一步操作
|
||||
|
||||
💡 **对比**:上料操作(`auto_feeding4to3`)则有 `targetWH` 参数可以指定目标仓库
|
||||
|
||||
---
|
||||
|
||||
## 🔍 如何获取 UUID?
|
||||
|
||||
### 方法 1:从配置文件获取
|
||||
|
||||
参考 `yibin_electrolyte_config.json` 中的 `warehouse_mapping`:
|
||||
|
||||
```json
|
||||
{
|
||||
"warehouse_mapping": {
|
||||
"配液站内试剂仓库": {
|
||||
"site_uuids": {
|
||||
"A01": "3a19da43-57b5-294f-d663-154a1cc32270",
|
||||
"B01": "3a19da43-57b5-7394-5f49-54efe2c9bef2",
|
||||
"C01": "3a19da43-57b5-5e75-552f-8dbd0ad1075f"
|
||||
}
|
||||
},
|
||||
"手动堆栈": {
|
||||
"site_uuids": {
|
||||
"A01": "3a19deae-2c7a-36f5-5e41-02c5b66feaea",
|
||||
"A02": "3a19deae-2c7a-dc6d-c41e-ef285d946cfe"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 方法 2:通过 API 查询
|
||||
|
||||
```python
|
||||
material_info = hardware_interface.material_id_query(workflow_id)
|
||||
locations = material_info.get("locations", [])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 填写示例
|
||||
|
||||
### 示例 1:从配液站内试剂仓库出库
|
||||
|
||||
| locationId | warehouseId | quantity | x | y | z | 备注说明 |
|
||||
|------------|-------------|----------|---|---|---|----------|
|
||||
| `3a19da43-57b5-294f-d663-154a1cc32270` | 配液站内试剂仓库 | 1 | 1 | 1 | 1 | A01 位置 |
|
||||
| `3a19da43-57b5-7394-5f49-54efe2c9bef2` | 配液站内试剂仓库 | 2 | 2 | 1 | 1 | B01 位置 |
|
||||
|
||||
### 示例 2:从手动堆栈出库
|
||||
|
||||
| locationId | warehouseId | quantity | x | y | z | 备注说明 |
|
||||
|------------|-------------|----------|---|---|---|----------|
|
||||
| `3a19deae-2c7a-36f5-5e41-02c5b66feaea` | 手动堆栈 | 1 | 1 | 1 | 1 | A01 |
|
||||
| `3a19deae-2c7a-dc6d-c41e-ef285d946cfe` | 手动堆栈 | 1 | 1 | 2 | 1 | A02 |
|
||||
|
||||
---
|
||||
|
||||
## 💻 使用方法
|
||||
|
||||
```python
|
||||
from bioyond_cell_workstation import BioyondCellWorkstation
|
||||
|
||||
# 初始化工作站
|
||||
workstation = BioyondCellWorkstation(config=config, deck=deck)
|
||||
|
||||
# 调用批量出库方法
|
||||
result = workstation.auto_batch_outbound_from_xlsx(
|
||||
xlsx_path="outbound_template.xlsx"
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **locationId 必须是有效的 UUID**,不能使用库位名称
|
||||
2. **x, y, z 坐标必须与 locationId 对应**,表示该库位在仓库内的位置
|
||||
3. **quantity 必须是数字**,可以是整数或浮点数
|
||||
4. Excel 文件必须包含表头行
|
||||
5. 空行会被自动跳过
|
||||
6. 确保 UUID 与实际库位对应,否则 API 会报错
|
||||
|
||||
---
|
||||
|
||||
## 📚 相关文件
|
||||
|
||||
- **配置文件**: `yibin_electrolyte_config.json`
|
||||
- **Python 代码**: `bioyond_cell_workstation.py` (L630-695)
|
||||
- **生成脚本**: `create_outbound_template.py`
|
||||
- **上料模板**: `material_template.xlsx`
|
||||
|
||||
---
|
||||
|
||||
## 🔄 重新生成模板
|
||||
|
||||
```bash
|
||||
conda activate newunilab
|
||||
python create_outbound_template.py
|
||||
```
|
||||
@@ -9,7 +9,7 @@ from datetime import datetime, timezone
|
||||
from unilabos.device_comms.rpc import BaseRequest
|
||||
from typing import Optional, List, Dict, Any
|
||||
import json
|
||||
from unilabos.devices.workstation.bioyond_studio.config import LOCATION_MAPPING
|
||||
|
||||
|
||||
|
||||
class SimpleLogger:
|
||||
@@ -49,6 +49,14 @@ class BioyondV1RPC(BaseRequest):
|
||||
self.config = config
|
||||
self.api_key = config["api_key"]
|
||||
self.host = config["api_host"]
|
||||
|
||||
# 初始化 location_mapping
|
||||
# 直接从 warehouse_mapping 构建,确保数据源所谓的单一和结构化
|
||||
self.location_mapping = {}
|
||||
warehouse_mapping = self.config.get("warehouse_mapping", {})
|
||||
for warehouse_name, warehouse_config in warehouse_mapping.items():
|
||||
if "site_uuids" in warehouse_config:
|
||||
self.location_mapping.update(warehouse_config["site_uuids"])
|
||||
self._logger = SimpleLogger()
|
||||
self.material_cache = {}
|
||||
self._load_material_cache()
|
||||
@@ -176,7 +184,40 @@ class BioyondV1RPC(BaseRequest):
|
||||
return {}
|
||||
|
||||
print(f"add material data: {response['data']}")
|
||||
return response.get("data", {})
|
||||
|
||||
# 自动更新缓存
|
||||
data = response.get("data", {})
|
||||
if data:
|
||||
if isinstance(data, str):
|
||||
# 如果返回的是字符串,通常是ID
|
||||
mat_id = data
|
||||
name = params.get("name")
|
||||
else:
|
||||
# 如果返回的是字典,尝试获取name和id
|
||||
name = data.get("name") or params.get("name")
|
||||
mat_id = data.get("id")
|
||||
|
||||
if name and mat_id:
|
||||
self.material_cache[name] = mat_id
|
||||
print(f"已自动更新缓存: {name} -> {mat_id}")
|
||||
|
||||
# 处理返回数据中的 details (如果有)
|
||||
# 有些 API 返回结构可能直接包含 details,或者在 data 字段中
|
||||
details = data.get("details", []) if isinstance(data, dict) else []
|
||||
if not details and isinstance(data, dict):
|
||||
details = data.get("detail", [])
|
||||
|
||||
if details:
|
||||
for detail in details:
|
||||
d_name = detail.get("name")
|
||||
# 尝试从不同字段获取 ID
|
||||
d_id = detail.get("id") or detail.get("detailMaterialId")
|
||||
|
||||
if d_name and d_id:
|
||||
self.material_cache[d_name] = d_id
|
||||
print(f"已自动更新 detail 缓存: {d_name} -> {d_id}")
|
||||
|
||||
return data
|
||||
|
||||
def query_matial_type_id(self, data) -> list:
|
||||
"""查找物料typeid"""
|
||||
@@ -203,7 +244,7 @@ class BioyondV1RPC(BaseRequest):
|
||||
params={
|
||||
"apiKey": self.api_key,
|
||||
"requestTime": self.get_current_time_iso8601(),
|
||||
"data": {},
|
||||
"data": 0,
|
||||
})
|
||||
if not response or response['code'] != 1:
|
||||
return []
|
||||
@@ -273,11 +314,19 @@ class BioyondV1RPC(BaseRequest):
|
||||
|
||||
if not response or response['code'] != 1:
|
||||
return {}
|
||||
|
||||
# 自动更新缓存 - 移除被删除的物料
|
||||
for name, mid in list(self.material_cache.items()):
|
||||
if mid == material_id:
|
||||
del self.material_cache[name]
|
||||
print(f"已从缓存移除物料: {name}")
|
||||
break
|
||||
|
||||
return response.get("data", {})
|
||||
|
||||
def material_outbound(self, material_id: str, location_name: str, quantity: int) -> dict:
|
||||
"""指定库位出库物料(通过库位名称)"""
|
||||
location_id = LOCATION_MAPPING.get(location_name, location_name)
|
||||
location_id = self.location_mapping.get(location_name, location_name)
|
||||
|
||||
params = {
|
||||
"materialId": material_id,
|
||||
@@ -1103,6 +1152,10 @@ class BioyondV1RPC(BaseRequest):
|
||||
for detail_material in detail_materials:
|
||||
detail_name = detail_material.get("name")
|
||||
detail_id = detail_material.get("detailMaterialId")
|
||||
if not detail_id:
|
||||
# 尝试其他可能的字段
|
||||
detail_id = detail_material.get("id")
|
||||
|
||||
if detail_name and detail_id:
|
||||
self.material_cache[detail_name] = detail_id
|
||||
print(f"加载detail材料: {detail_name} -> ID: {detail_id}")
|
||||
@@ -1123,6 +1176,14 @@ class BioyondV1RPC(BaseRequest):
|
||||
print(f"从缓存找到材料: {material_name_or_id} -> ID: {material_id}")
|
||||
return material_id
|
||||
|
||||
# 如果缓存中没有,尝试刷新缓存
|
||||
print(f"缓存中未找到材料 '{material_name_or_id}',尝试刷新缓存...")
|
||||
self.refresh_material_cache()
|
||||
if material_name_or_id in self.material_cache:
|
||||
material_id = self.material_cache[material_name_or_id]
|
||||
print(f"刷新缓存后找到材料: {material_name_or_id} -> ID: {material_id}")
|
||||
return material_id
|
||||
|
||||
print(f"警告: 未在缓存中找到材料名称 '{material_name_or_id}',将使用原值")
|
||||
return material_name_or_id
|
||||
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
# config.py
|
||||
"""
|
||||
配置文件 - 包含所有配置信息和映射关系
|
||||
"""
|
||||
|
||||
# API配置
|
||||
API_CONFIG = {
|
||||
"api_key": "",
|
||||
"api_host": ""
|
||||
}
|
||||
|
||||
# 工作流映射配置
|
||||
WORKFLOW_MAPPINGS = {
|
||||
"reactor_taken_out": "",
|
||||
"reactor_taken_in": "",
|
||||
"Solid_feeding_vials": "",
|
||||
"Liquid_feeding_vials(non-titration)": "",
|
||||
"Liquid_feeding_solvents": "",
|
||||
"Liquid_feeding(titration)": "",
|
||||
"liquid_feeding_beaker": "",
|
||||
"Drip_back": "",
|
||||
}
|
||||
|
||||
# 工作流名称到DisplaySectionName的映射
|
||||
WORKFLOW_TO_SECTION_MAP = {
|
||||
'reactor_taken_in': '反应器放入',
|
||||
'liquid_feeding_beaker': '液体投料-烧杯',
|
||||
'Liquid_feeding_vials(non-titration)': '液体投料-小瓶(非滴定)',
|
||||
'Liquid_feeding_solvents': '液体投料-溶剂',
|
||||
'Solid_feeding_vials': '固体投料-小瓶',
|
||||
'Liquid_feeding(titration)': '液体投料-滴定',
|
||||
'reactor_taken_out': '反应器取出'
|
||||
}
|
||||
|
||||
# 库位映射配置
|
||||
WAREHOUSE_MAPPING = {
|
||||
"粉末堆栈": {
|
||||
"uuid": "",
|
||||
"site_uuids": {
|
||||
# 样品板
|
||||
"A1": "3a14198e-6929-31f0-8a22-0f98f72260df",
|
||||
"A2": "3a14198e-6929-4379-affa-9a2935c17f99",
|
||||
"A3": "3a14198e-6929-56da-9a1c-7f5fbd4ae8af",
|
||||
"A4": "3a14198e-6929-5e99-2b79-80720f7cfb54",
|
||||
"B1": "3a14198e-6929-f525-9a1b-1857552b28ee",
|
||||
"B2": "3a14198e-6929-bf98-0fd5-26e1d68bf62d",
|
||||
"B3": "3a14198e-6929-2d86-a468-602175a2b5aa",
|
||||
"B4": "3a14198e-6929-1a98-ae57-e97660c489ad",
|
||||
# 分装板
|
||||
"C1": "3a14198e-6929-46fe-841e-03dd753f1e4a",
|
||||
"C2": "3a14198e-6929-1bc9-a9bd-3b7ca66e7f95",
|
||||
"C3": "3a14198e-6929-72ac-32ce-9b50245682b8",
|
||||
"C4": "3a14198e-6929-3bd8-e6c7-4a9fd93be118",
|
||||
"D1": "3a14198e-6929-8a0b-b686-6f4a2955c4e2",
|
||||
"D2": "3a14198e-6929-dde1-fc78-34a84b71afdf",
|
||||
"D3": "3a14198e-6929-a0ec-5f15-c0f9f339f963",
|
||||
"D4": "3a14198e-6929-7ac8-915a-fea51cb2e884"
|
||||
}
|
||||
},
|
||||
"溶液堆栈": {
|
||||
"uuid": "",
|
||||
"site_uuids": {
|
||||
"A1": "3a14198e-d724-e036-afdc-2ae39a7f3383",
|
||||
"A2": "3a14198e-d724-afa4-fc82-0ac8a9016791",
|
||||
"A3": "3a14198e-d724-ca48-bb9e-7e85751e55b6",
|
||||
"A4": "3a14198e-d724-df6d-5e32-5483b3cab583",
|
||||
"B1": "3a14198e-d724-d818-6d4f-5725191a24b5",
|
||||
"B2": "3a14198e-d724-be8a-5e0b-012675e195c6",
|
||||
"B3": "3a14198e-d724-cc1e-5c2c-228a130f40a8",
|
||||
"B4": "3a14198e-d724-1e28-c885-574c3df468d0",
|
||||
"C1": "3a14198e-d724-b5bb-adf3-4c5a0da6fb31",
|
||||
"C2": "3a14198e-d724-ab4e-48cb-817c3c146707",
|
||||
"C3": "3a14198e-d724-7f18-1853-39d0c62e1d33",
|
||||
"C4": "3a14198e-d724-28a2-a760-baa896f46b66",
|
||||
"D1": "3a14198e-d724-d378-d266-2508a224a19f",
|
||||
"D2": "3a14198e-d724-f56e-468b-0110a8feb36a",
|
||||
"D3": "3a14198e-d724-0cf1-dea9-a1f40fe7e13c",
|
||||
"D4": "3a14198e-d724-0ddd-9654-f9352a421de9"
|
||||
}
|
||||
},
|
||||
"试剂堆栈": {
|
||||
"uuid": "",
|
||||
"site_uuids": {
|
||||
"A1": "3a14198c-c2cf-8b40-af28-b467808f1c36",
|
||||
"A2": "3a14198c-c2d0-f3e7-871a-e470d144296f",
|
||||
"A3": "3a14198c-c2d0-dc7d-b8d0-e1d88cee3094",
|
||||
"A4": "3a14198c-c2d0-2070-efc8-44e245f10c6f",
|
||||
"B1": "3a14198c-c2d0-354f-39ad-642e1a72fcb8",
|
||||
"B2": "3a14198c-c2d0-1559-105d-0ea30682cab4",
|
||||
"B3": "3a14198c-c2d0-725e-523d-34c037ac2440",
|
||||
"B4": "3a14198c-c2d0-efce-0939-69ca5a7dfd39"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 物料类型配置
|
||||
MATERIAL_TYPE_MAPPINGS = {
|
||||
"烧杯": ("BIOYOND_PolymerStation_1FlaskCarrier", "3a14196b-24f2-ca49-9081-0cab8021bf1a"),
|
||||
"试剂瓶": ("BIOYOND_PolymerStation_1BottleCarrier", ""),
|
||||
"样品板": ("BIOYOND_PolymerStation_6StockCarrier", "3a14196e-b7a0-a5da-1931-35f3000281e9"),
|
||||
"分装板": ("BIOYOND_PolymerStation_6VialCarrier", "3a14196e-5dfe-6e21-0c79-fe2036d052c4"),
|
||||
"样品瓶": ("BIOYOND_PolymerStation_Solid_Stock", "3a14196a-cf7d-8aea-48d8-b9662c7dba94"),
|
||||
"90%分装小瓶": ("BIOYOND_PolymerStation_Solid_Vial", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea"),
|
||||
"10%分装小瓶": ("BIOYOND_PolymerStation_Liquid_Vial", "3a14196c-76be-2279-4e22-7310d69aed68"),
|
||||
}
|
||||
|
||||
# 步骤参数配置(各工作流的步骤UUID)
|
||||
WORKFLOW_STEP_IDS = {
|
||||
"reactor_taken_in": {
|
||||
"config": ""
|
||||
},
|
||||
"liquid_feeding_beaker": {
|
||||
"liquid": "",
|
||||
"observe": ""
|
||||
},
|
||||
"liquid_feeding_vials_non_titration": {
|
||||
"liquid": "",
|
||||
"observe": ""
|
||||
},
|
||||
"liquid_feeding_solvents": {
|
||||
"liquid": "",
|
||||
"observe": ""
|
||||
},
|
||||
"solid_feeding_vials": {
|
||||
"feeding": "",
|
||||
"observe": ""
|
||||
},
|
||||
"liquid_feeding_titration": {
|
||||
"liquid": "",
|
||||
"observe": ""
|
||||
},
|
||||
"drip_back": {
|
||||
"liquid": "",
|
||||
"observe": ""
|
||||
}
|
||||
}
|
||||
|
||||
LOCATION_MAPPING = {}
|
||||
|
||||
ACTION_NAMES = {}
|
||||
|
||||
HTTP_SERVICE_CONFIG = {}
|
||||
329
unilabos/devices/workstation/bioyond_studio/config.py.deprecated
Normal file
329
unilabos/devices/workstation/bioyond_studio/config.py.deprecated
Normal file
@@ -0,0 +1,329 @@
|
||||
# config.py
|
||||
"""
|
||||
Bioyond工作站配置文件
|
||||
包含API配置、工作流映射、物料类型映射、仓库库位映射等所有配置信息
|
||||
"""
|
||||
|
||||
from unilabos.resources.bioyond.decks import BIOYOND_PolymerReactionStation_Deck
|
||||
|
||||
# ============================================================================
|
||||
# 基础配置
|
||||
# ============================================================================
|
||||
|
||||
# API配置
|
||||
API_CONFIG = {
|
||||
"api_key": "DE9BDDA0",
|
||||
"api_host": "http://192.168.1.200:44402"
|
||||
}
|
||||
|
||||
# HTTP 报送服务配置
|
||||
HTTP_SERVICE_CONFIG = {
|
||||
"http_service_host": "127.0.0.1", # 监听地址
|
||||
"http_service_port": 8080, # 监听端口
|
||||
}
|
||||
|
||||
# Deck配置 - 反应站工作台配置
|
||||
DECK_CONFIG = BIOYOND_PolymerReactionStation_Deck(setup=True)
|
||||
|
||||
# ============================================================================
|
||||
# 工作流配置
|
||||
# ============================================================================
|
||||
|
||||
# 工作流ID映射
|
||||
WORKFLOW_MAPPINGS = {
|
||||
"reactor_taken_out": "3a16081e-4788-ca37-eff4-ceed8d7019d1",
|
||||
"reactor_taken_in": "3a160df6-76b3-0957-9eb0-cb496d5721c6",
|
||||
"Solid_feeding_vials": "3a160877-87e7-7699-7bc6-ec72b05eb5e6",
|
||||
"Liquid_feeding_vials(non-titration)": "3a167d99-6158-c6f0-15b5-eb030f7d8e47",
|
||||
"Liquid_feeding_solvents": "3a160824-0665-01ed-285a-51ef817a9046",
|
||||
"Liquid_feeding(titration)": "3a16082a-96ac-0449-446a-4ed39f3365b6",
|
||||
"liquid_feeding_beaker": "3a16087e-124f-8ddb-8ec1-c2dff09ca784",
|
||||
"Drip_back": "3a162cf9-6aac-565a-ddd7-682ba1796a4a",
|
||||
}
|
||||
|
||||
# 工作流名称到显示名称的映射
|
||||
WORKFLOW_TO_SECTION_MAP = {
|
||||
'reactor_taken_in': '反应器放入',
|
||||
'reactor_taken_out': '反应器取出',
|
||||
'Solid_feeding_vials': '固体投料-小瓶',
|
||||
'Liquid_feeding_vials(non-titration)': '液体投料-小瓶(非滴定)',
|
||||
'Liquid_feeding_solvents': '液体投料-溶剂',
|
||||
'Liquid_feeding(titration)': '液体投料-滴定',
|
||||
'liquid_feeding_beaker': '液体投料-烧杯',
|
||||
'Drip_back': '液体回滴'
|
||||
}
|
||||
|
||||
# 工作流步骤ID配置
|
||||
WORKFLOW_STEP_IDS = {
|
||||
"reactor_taken_in": {
|
||||
"config": "60a06f85-c5b3-29eb-180f-4f62dd7e2154"
|
||||
},
|
||||
"liquid_feeding_beaker": {
|
||||
"liquid": "6808cda7-fee7-4092-97f0-5f9c2ffa60e3",
|
||||
"observe": "1753c0de-dffc-4ee6-8458-805a2e227362"
|
||||
},
|
||||
"liquid_feeding_vials_non_titration": {
|
||||
"liquid": "62ea6e95-3d5d-43db-bc1e-9a1802673861",
|
||||
"observe": "3a167d99-6172-b67b-5f22-a7892197142e"
|
||||
},
|
||||
"liquid_feeding_solvents": {
|
||||
"liquid": "1fcea355-2545-462b-b727-350b69a313bf",
|
||||
"observe": "0553dfb3-9ac5-4ace-8e00-2f11029919a8"
|
||||
},
|
||||
"solid_feeding_vials": {
|
||||
"feeding": "f7ae7448-4f20-4c1d-8096-df6fbadd787a",
|
||||
"observe": "263c7ed5-7277-426b-bdff-d6fbf77bcc05"
|
||||
},
|
||||
"liquid_feeding_titration": {
|
||||
"liquid": "a00ec41b-e666-4422-9c20-bfcd3cd15c54",
|
||||
"observe": "ac738ff6-4c58-4155-87b1-d6f65a2c9ab5"
|
||||
},
|
||||
"drip_back": {
|
||||
"liquid": "371be86a-ab77-4769-83e5-54580547c48a",
|
||||
"observe": "ce024b9d-bd20-47b8-9f78-ca5ce7f44cf1"
|
||||
}
|
||||
}
|
||||
|
||||
# 工作流动作名称配置
|
||||
ACTION_NAMES = {
|
||||
"reactor_taken_in": {
|
||||
"config": "通量-配置",
|
||||
"stirring": "反应模块-开始搅拌"
|
||||
},
|
||||
"solid_feeding_vials": {
|
||||
"feeding": "粉末加样模块-投料",
|
||||
"observe": "反应模块-观察搅拌结果"
|
||||
},
|
||||
"liquid_feeding_vials_non_titration": {
|
||||
"liquid": "稀释液瓶加液位-液体投料",
|
||||
"observe": "反应模块-滴定结果观察"
|
||||
},
|
||||
"liquid_feeding_solvents": {
|
||||
"liquid": "试剂AB放置位-试剂吸液分液",
|
||||
"observe": "反应模块-观察搅拌结果"
|
||||
},
|
||||
"liquid_feeding_titration": {
|
||||
"liquid": "稀释液瓶加液位-稀释液吸液分液",
|
||||
"observe": "反应模块-滴定结果观察"
|
||||
},
|
||||
"liquid_feeding_beaker": {
|
||||
"liquid": "烧杯溶液放置位-烧杯吸液分液",
|
||||
"observe": "反应模块-观察搅拌结果"
|
||||
},
|
||||
"drip_back": {
|
||||
"liquid": "试剂AB放置位-试剂吸液分液",
|
||||
"observe": "反应模块-向下滴定结果观察"
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 仓库配置
|
||||
# ============================================================================
|
||||
# 说明:
|
||||
# - 出库和入库操作都需要UUID
|
||||
WAREHOUSE_MAPPING = {
|
||||
# ========== 反应站仓库 ==========
|
||||
|
||||
# 堆栈1左 - 反应站左侧堆栈 (4行×4列=16个库位, A01~D04)
|
||||
"堆栈1左": {
|
||||
"uuid": "3a14aa17-0d49-dce4-486e-4b5c85c8b366",
|
||||
"site_uuids": {
|
||||
"A01": "3a14aa17-0d49-11d7-a6e1-f236b3e5e5a3",
|
||||
"A02": "3a14aa17-0d49-4bc5-8836-517b75473f5f",
|
||||
"A03": "3a14aa17-0d49-c2bc-6222-5cee8d2d94f8",
|
||||
"A04": "3a14aa17-0d49-3ce2-8e9a-008c38d116fb",
|
||||
"B01": "3a14aa17-0d49-f49c-6b66-b27f185a3b32",
|
||||
"B02": "3a14aa17-0d49-cf46-df85-a979c9c9920c",
|
||||
"B03": "3a14aa17-0d49-7698-4a23-f7ffb7d48ba3",
|
||||
"B04": "3a14aa17-0d49-1231-99be-d5870e6478e9",
|
||||
"C01": "3a14aa17-0d49-be34-6fae-4aed9d48b70b",
|
||||
"C02": "3a14aa17-0d49-11d7-0897-34921dcf6b7c",
|
||||
"C03": "3a14aa17-0d49-9840-0bd5-9c63c1bb2c29",
|
||||
"C04": "3a14aa17-0d49-8335-3bff-01da69ea4911",
|
||||
"D01": "3a14aa17-0d49-2bea-c8e5-2b32094935d5",
|
||||
"D02": "3a14aa17-0d49-cff4-e9e8-5f5f0bc1ef32",
|
||||
"D03": "3a14aa17-0d49-4948-cb0a-78f30d1ca9b8",
|
||||
"D04": "3a14aa17-0d49-fd2f-9dfb-a29b11e84099",
|
||||
},
|
||||
},
|
||||
|
||||
# 堆栈1右 - 反应站右侧堆栈 (4行×4列=16个库位, A05~D08)
|
||||
"堆栈1右": {
|
||||
"uuid": "3a14aa17-0d49-dce4-486e-4b5c85c8b366",
|
||||
"site_uuids": {
|
||||
"A05": "3a14aa17-0d49-2c61-edc8-72a8ca7192dd",
|
||||
"A06": "3a14aa17-0d49-60c8-2b00-40b17198f397",
|
||||
"A07": "3a14aa17-0d49-ec5b-0b75-634dce8eed25",
|
||||
"A08": "3a14aa17-0d49-3ec9-55b3-f3189c4ec53d",
|
||||
"B05": "3a14aa17-0d49-6a4e-abcf-4c113eaaeaad",
|
||||
"B06": "3a14aa17-0d49-e3f6-2dd6-28c2e8194fbe",
|
||||
"B07": "3a14aa17-0d49-11a6-b861-ee895121bf52",
|
||||
"B08": "3a14aa17-0d49-9c7d-1145-d554a6e482f0",
|
||||
"C05": "3a14aa17-0d49-45c4-7a34-5105bc3e2368",
|
||||
"C06": "3a14aa17-0d49-867e-39ab-31b3fe9014be",
|
||||
"C07": "3a14aa17-0d49-ec56-c4b4-39fd9b2131e7",
|
||||
"C08": "3a14aa17-0d49-1128-d7d9-ffb1231c98c0",
|
||||
"D05": "3a14aa17-0d49-e843-f961-ea173326a14b",
|
||||
"D06": "3a14aa17-0d49-4d26-a985-f188359c4f8b",
|
||||
"D07": "3a14aa17-0d49-223a-b520-bc092bb42fe0",
|
||||
"D08": "3a14aa17-0d49-4fa3-401a-6a444e1cca22",
|
||||
},
|
||||
},
|
||||
|
||||
# 站内试剂存放堆栈
|
||||
"站内试剂存放堆栈": {
|
||||
"uuid": "3a14aa3b-9fab-9d8e-d1a7-828f01f51f0c",
|
||||
"site_uuids": {
|
||||
"A01": "3a14aa3b-9fab-adac-7b9c-e1ee446b51d5",
|
||||
"A02": "3a14aa3b-9fab-ca72-febc-b7c304476c78"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
# 测量小瓶仓库(测密度)
|
||||
"测量小瓶仓库": {
|
||||
"uuid": "3a15012f-705b-c0de-3f9e-950c205f9921",
|
||||
"site_uuids": {
|
||||
"A01": "3a15012f-705e-0524-3161-c523b5aebc97",
|
||||
"A02": "3a15012f-705e-7cd1-32ab-ad4fd1ab75c8",
|
||||
"A03": "3a15012f-705e-a5d6-edac-bdbfec236260",
|
||||
"B01": "3a15012f-705e-e0ee-80e0-10a6b3fc500d",
|
||||
"B02": "3a15012f-705e-e499-180d-de06d60d0b21",
|
||||
"B03": "3a15012f-705e-eff6-63f1-09f742096b26"
|
||||
}
|
||||
},
|
||||
|
||||
# 站内Tip盒堆栈 - 用于存放枪头盒 (耗材)
|
||||
"站内Tip盒堆栈": {
|
||||
"uuid": "3a14aa3a-2d3c-b5c1-9ddf-7c4a957d459a",
|
||||
"site_uuids": {
|
||||
"A01": "3a14aa3a-2d3d-e700-411a-0ddf85e1f18a",
|
||||
"A02": "3a14aa3a-2d3d-a7ce-099a-d5632fdafa24",
|
||||
"A03": "3a14aa3a-2d3d-bdf6-a702-c60b38b08501",
|
||||
"B01": "3a14aa3a-2d3d-d704-f076-2a8d5bc72cb8",
|
||||
"B02": "3a14aa3a-2d3d-c350-2526-0778d173a5ac",
|
||||
"B03": "3a14aa3a-2d3d-bc38-b356-f0de2e44e0c7"
|
||||
}
|
||||
},
|
||||
# ========== 配液站仓库 ==========
|
||||
"粉末堆栈": {
|
||||
"uuid": "3a14198e-6928-121f-7ca6-88ad3ae7e6a0",
|
||||
"site_uuids": {
|
||||
"A01": "3a14198e-6929-31f0-8a22-0f98f72260df",
|
||||
"A02": "3a14198e-6929-4379-affa-9a2935c17f99",
|
||||
"A03": "3a14198e-6929-56da-9a1c-7f5fbd4ae8af",
|
||||
"A04": "3a14198e-6929-5e99-2b79-80720f7cfb54",
|
||||
"B01": "3a14198e-6929-f525-9a1b-1857552b28ee",
|
||||
"B02": "3a14198e-6929-bf98-0fd5-26e1d68bf62d",
|
||||
"B03": "3a14198e-6929-2d86-a468-602175a2b5aa",
|
||||
"B04": "3a14198e-6929-1a98-ae57-e97660c489ad",
|
||||
"C01": "3a14198e-6929-46fe-841e-03dd753f1e4a",
|
||||
"C02": "3a14198e-6929-72ac-32ce-9b50245682b8",
|
||||
"C03": "3a14198e-6929-8a0b-b686-6f4a2955c4e2",
|
||||
"C04": "3a14198e-6929-a0ec-5f15-c0f9f339f963",
|
||||
"D01": "3a14198e-6929-1bc9-a9bd-3b7ca66e7f95",
|
||||
"D02": "3a14198e-6929-3bd8-e6c7-4a9fd93be118",
|
||||
"D03": "3a14198e-6929-dde1-fc78-34a84b71afdf",
|
||||
"D04": "3a14198e-6929-7ac8-915a-fea51cb2e884"
|
||||
}
|
||||
},
|
||||
"溶液堆栈": {
|
||||
"uuid": "3a14198e-d723-2c13-7d12-50143e190a23",
|
||||
"site_uuids": {
|
||||
"A01": "3a14198e-d724-e036-afdc-2ae39a7f3383",
|
||||
"A02": "3a14198e-d724-d818-6d4f-5725191a24b5",
|
||||
"A03": "3a14198e-d724-b5bb-adf3-4c5a0da6fb31",
|
||||
"A04": "3a14198e-d724-d378-d266-2508a224a19f",
|
||||
"B01": "3a14198e-d724-afa4-fc82-0ac8a9016791",
|
||||
"B02": "3a14198e-d724-be8a-5e0b-012675e195c6",
|
||||
"B03": "3a14198e-d724-ab4e-48cb-817c3c146707",
|
||||
"B04": "3a14198e-d724-f56e-468b-0110a8feb36a",
|
||||
"C01": "3a14198e-d724-ca48-bb9e-7e85751e55b6",
|
||||
"C02": "3a14198e-d724-cc1e-5c2c-228a130f40a8",
|
||||
"C03": "3a14198e-d724-7f18-1853-39d0c62e1d33",
|
||||
"C04": "3a14198e-d724-0cf1-dea9-a1f40fe7e13c",
|
||||
"D01": "3a14198e-d724-df6d-5e32-5483b3cab583",
|
||||
"D02": "3a14198e-d724-1e28-c885-574c3df468d0",
|
||||
"D03": "3a14198e-d724-28a2-a760-baa896f46b66",
|
||||
"D04": "3a14198e-d724-0ddd-9654-f9352a421de9"
|
||||
}
|
||||
},
|
||||
"试剂堆栈": {
|
||||
"uuid": "3a14198c-c2cc-0290-e086-44a428fba248",
|
||||
"site_uuids": {
|
||||
"A01": "3a14198c-c2cf-8b40-af28-b467808f1c36", # x=1, y=1, code=0001-0001
|
||||
"A02": "3a14198c-c2d0-dc7d-b8d0-e1d88cee3094", # x=1, y=2, code=0001-0002
|
||||
"A03": "3a14198c-c2d0-354f-39ad-642e1a72fcb8", # x=1, y=3, code=0001-0003
|
||||
"A04": "3a14198c-c2d0-725e-523d-34c037ac2440", # x=1, y=4, code=0001-0004
|
||||
"B01": "3a14198c-c2d0-f3e7-871a-e470d144296f", # x=2, y=1, code=0001-0005
|
||||
"B02": "3a14198c-c2d0-2070-efc8-44e245f10c6f", # x=2, y=2, code=0001-0006
|
||||
"B03": "3a14198c-c2d0-1559-105d-0ea30682cab4", # x=2, y=3, code=0001-0007
|
||||
"B04": "3a14198c-c2d0-efce-0939-69ca5a7dfd39" # x=2, y=4, code=0001-0008
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 物料类型配置
|
||||
# ============================================================================
|
||||
# 说明:
|
||||
# - 格式: PyLabRobot资源类型名称 → Bioyond系统typeId的UUID
|
||||
# - 这个映射基于 resource.model 属性 (不是显示名称!)
|
||||
# - UUID为空表示该类型暂未在Bioyond系统中定义
|
||||
MATERIAL_TYPE_MAPPINGS = {
|
||||
# ================================================配液站资源============================================================
|
||||
# ==================================================样品===============================================================
|
||||
"BIOYOND_PolymerStation_1FlaskCarrier": ("烧杯", "3a14196b-24f2-ca49-9081-0cab8021bf1a"), # 配液站-样品-烧杯
|
||||
"BIOYOND_PolymerStation_1BottleCarrier": ("试剂瓶", "3a14196b-8bcf-a460-4f74-23f21ca79e72"), # 配液站-样品-试剂瓶
|
||||
"BIOYOND_PolymerStation_6StockCarrier": ("分装板", "3a14196e-5dfe-6e21-0c79-fe2036d052c4"), # 配液站-样品-分装板
|
||||
"BIOYOND_PolymerStation_Liquid_Vial": ("10%分装小瓶", "3a14196c-76be-2279-4e22-7310d69aed68"), # 配液站-样品-分装板-第一排小瓶
|
||||
"BIOYOND_PolymerStation_Solid_Vial": ("90%分装小瓶", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea"), # 配液站-样品-分装板-第二排小瓶
|
||||
# ==================================================试剂===============================================================
|
||||
"BIOYOND_PolymerStation_8StockCarrier": ("样品板", "3a14196e-b7a0-a5da-1931-35f3000281e9"), # 配液站-试剂-样品板(8孔)
|
||||
"BIOYOND_PolymerStation_Solid_Stock": ("样品瓶", "3a14196a-cf7d-8aea-48d8-b9662c7dba94"), # 配液站-试剂-样品板-样品瓶
|
||||
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 动态生成的库位UUID映射(从WAREHOUSE_MAPPING中提取)
|
||||
# ============================================================================
|
||||
|
||||
LOCATION_MAPPING = {}
|
||||
for warehouse_name, warehouse_config in WAREHOUSE_MAPPING.items():
|
||||
if "site_uuids" in warehouse_config:
|
||||
LOCATION_MAPPING.update(warehouse_config["site_uuids"])
|
||||
|
||||
# ============================================================================
|
||||
# 物料默认参数配置
|
||||
# ============================================================================
|
||||
# 说明:
|
||||
# - 为特定物料名称自动添加默认参数(如密度、分子量、单位等)
|
||||
# - 格式: 物料名称 → {参数字典}
|
||||
# - 在创建或更新物料时,会自动合并这些参数到 Parameters 字段
|
||||
# - unit: 物料的计量单位(会用于 unit 字段)
|
||||
# - density/densityUnit: 密度信息(会添加到 Parameters 中)
|
||||
|
||||
MATERIAL_DEFAULT_PARAMETERS = {
|
||||
# 溶剂类
|
||||
"NMP": {
|
||||
"unit": "毫升",
|
||||
"density": "1.03",
|
||||
"densityUnit": "g/mL",
|
||||
"description": "N-甲基吡咯烷酮 (N-Methyl-2-pyrrolidone)"
|
||||
},
|
||||
# 可以继续添加其他物料...
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 物料类型默认参数配置
|
||||
# ============================================================================
|
||||
# 说明:
|
||||
# - 为特定物料类型(UUID)自动添加默认参数
|
||||
# - 格式: Bioyond类型UUID → {参数字典}
|
||||
# - 优先级低于按名称匹配的配置
|
||||
MATERIAL_TYPE_PARAMETERS = {
|
||||
# 示例:
|
||||
# "3a14196b-24f2-ca49-9081-0cab8021bf1a": { # 烧杯
|
||||
# "unit": "个"
|
||||
# }
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import time
|
||||
from typing import Optional, Dict, Any, List
|
||||
from typing_extensions import TypedDict
|
||||
import requests
|
||||
from unilabos.devices.workstation.bioyond_studio.config import API_CONFIG
|
||||
import pint
|
||||
|
||||
|
||||
from unilabos.devices.workstation.bioyond_studio.bioyond_rpc import BioyondException
|
||||
from unilabos.devices.workstation.bioyond_studio.station import BioyondWorkstation
|
||||
@@ -25,13 +26,89 @@ class ComputeExperimentDesignReturn(TypedDict):
|
||||
class BioyondDispensingStation(BioyondWorkstation):
|
||||
def __init__(
|
||||
self,
|
||||
config,
|
||||
# 桌子
|
||||
deck,
|
||||
*args,
|
||||
config: dict = None,
|
||||
deck=None,
|
||||
protocol_type=None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(config, deck, *args, **kwargs)
|
||||
):
|
||||
"""初始化配液站
|
||||
|
||||
Args:
|
||||
config: 配置字典,应包含material_type_mappings等配置
|
||||
deck: Deck对象
|
||||
protocol_type: 协议类型(由ROS系统传递,此处忽略)
|
||||
**kwargs: 其他可能的参数
|
||||
"""
|
||||
if config is None:
|
||||
config = {}
|
||||
|
||||
# 将 kwargs 合并到 config 中 (处理扁平化配置如 api_key)
|
||||
config.update(kwargs)
|
||||
|
||||
if deck is None and config:
|
||||
deck = config.get('deck')
|
||||
|
||||
# 🔧 修复: 确保 Deck 上的 warehouses 具有正确的 UUID (必须在 super().__init__ 之前执行,因为父类会触发同步)
|
||||
# 从配置中读取 warehouse_mapping,并应用到实际的 deck 资源上
|
||||
if config and "warehouse_mapping" in config and deck:
|
||||
warehouse_mapping = config["warehouse_mapping"]
|
||||
print(f"正在根据配置更新 Deck warehouse UUIDs... (共有 {len(warehouse_mapping)} 个配置)")
|
||||
|
||||
user_deck = deck
|
||||
# 初始化 warehouses 字典
|
||||
if not hasattr(user_deck, "warehouses") or user_deck.warehouses is None:
|
||||
user_deck.warehouses = {}
|
||||
|
||||
# 1. 尝试从 children 中查找匹配的资源
|
||||
for child in user_deck.children:
|
||||
# 简单判断: 如果名字在 mapping 中,就认为是 warehouse
|
||||
if child.name in warehouse_mapping:
|
||||
user_deck.warehouses[child.name] = child
|
||||
print(f" - 从子资源中找到 warehouse: {child.name}")
|
||||
|
||||
# 2. 如果还是没找到,且 Deck 类有 setup 方法,尝试调用 setup (针对 Deck 对象正确但未初始化的情况)
|
||||
if not user_deck.warehouses and hasattr(user_deck, "setup"):
|
||||
print(" - 尝试调用 deck.setup() 初始化仓库...")
|
||||
try:
|
||||
user_deck.setup()
|
||||
# setup 后重新检查
|
||||
if hasattr(user_deck, "warehouses") and user_deck.warehouses:
|
||||
print(f" - setup() 成功,找到 {len(user_deck.warehouses)} 个仓库")
|
||||
except Exception as e:
|
||||
print(f" - 调用 setup() 失败: {e}")
|
||||
|
||||
# 3. 如果仍然为空,可能需要手动创建 (仅针对特定已知的 Deck 类型进行补救,这里暂时只打印警告)
|
||||
if not user_deck.warehouses:
|
||||
print(" - ⚠️ 仍然无法找到任何 warehouse 资源!")
|
||||
|
||||
for wh_name, wh_config in warehouse_mapping.items():
|
||||
target_uuid = wh_config.get("uuid")
|
||||
|
||||
# 尝试在 deck.warehouses 中查找
|
||||
wh_resource = None
|
||||
if hasattr(user_deck, "warehouses") and wh_name in user_deck.warehouses:
|
||||
wh_resource = user_deck.warehouses[wh_name]
|
||||
|
||||
# 如果没找到,尝试在所有子资源中查找
|
||||
if not wh_resource:
|
||||
wh_resource = user_deck.get_resource(wh_name)
|
||||
|
||||
if wh_resource:
|
||||
if target_uuid:
|
||||
current_uuid = getattr(wh_resource, "uuid", None)
|
||||
print(f"✅ 更新仓库 '{wh_name}' UUID: {current_uuid} -> {target_uuid}")
|
||||
|
||||
# 动态添加 uuid 属性
|
||||
wh_resource.uuid = target_uuid
|
||||
# 同时也确保 category 正确,避免 graphio 识别错误
|
||||
# wh_resource.category = "warehouse"
|
||||
else:
|
||||
print(f"⚠️ 仓库 '{wh_name}' 在配置中没有 UUID")
|
||||
else:
|
||||
print(f"❌ 在 Deck 中未找到配置的仓库: '{wh_name}'")
|
||||
|
||||
super().__init__(bioyond_config=config, deck=deck)
|
||||
|
||||
# self.config = config
|
||||
# self.api_key = config["api_key"]
|
||||
# self.host = config["api_host"]
|
||||
@@ -43,6 +120,41 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
# 用于跟踪任务完成状态的字典: {orderCode: {status, order_id, timestamp}}
|
||||
self.order_completion_status = {}
|
||||
|
||||
# 初始化 pint 单位注册表
|
||||
self.ureg = pint.UnitRegistry()
|
||||
|
||||
# 化合物信息
|
||||
self.compound_info = {
|
||||
"MolWt": {
|
||||
"MDA": 108.14 * self.ureg.g / self.ureg.mol,
|
||||
"TDA": 122.16 * self.ureg.g / self.ureg.mol,
|
||||
"PAPP": 521.62 * self.ureg.g / self.ureg.mol,
|
||||
"BTDA": 322.23 * self.ureg.g / self.ureg.mol,
|
||||
"BPDA": 294.22 * self.ureg.g / self.ureg.mol,
|
||||
"6FAP": 366.26 * self.ureg.g / self.ureg.mol,
|
||||
"PMDA": 218.12 * self.ureg.g / self.ureg.mol,
|
||||
"MPDA": 108.14 * self.ureg.g / self.ureg.mol,
|
||||
"SIDA": 248.51 * self.ureg.g / self.ureg.mol,
|
||||
"ODA": 200.236 * self.ureg.g / self.ureg.mol,
|
||||
"4,4'-ODA": 200.236 * self.ureg.g / self.ureg.mol,
|
||||
"134": 292.34 * self.ureg.g / self.ureg.mol,
|
||||
},
|
||||
"FuncGroup": {
|
||||
"MDA": "Amine",
|
||||
"TDA": "Amine",
|
||||
"PAPP": "Amine",
|
||||
"BTDA": "Anhydride",
|
||||
"BPDA": "Anhydride",
|
||||
"6FAP": "Amine",
|
||||
"MPDA": "Amine",
|
||||
"SIDA": "Amine",
|
||||
"PMDA": "Anhydride",
|
||||
"ODA": "Amine",
|
||||
"4,4'-ODA": "Amine",
|
||||
"134": "Amine",
|
||||
}
|
||||
}
|
||||
|
||||
def _post_project_api(self, endpoint: str, data: Any) -> Dict[str, Any]:
|
||||
"""项目接口通用POST调用
|
||||
|
||||
@@ -54,7 +166,7 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
dict: 服务端响应,失败时返回 {code:0,message,...}
|
||||
"""
|
||||
request_data = {
|
||||
"apiKey": API_CONFIG["api_key"],
|
||||
"apiKey": self.bioyond_config["api_key"],
|
||||
"requestTime": self.hardware_interface.get_current_time_iso8601(),
|
||||
"data": data
|
||||
}
|
||||
@@ -85,7 +197,7 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
dict: 服务端响应,失败时返回 {code:0,message,...}
|
||||
"""
|
||||
request_data = {
|
||||
"apiKey": API_CONFIG["api_key"],
|
||||
"apiKey": self.bioyond_config["api_key"],
|
||||
"requestTime": self.hardware_interface.get_current_time_iso8601(),
|
||||
"data": data
|
||||
}
|
||||
@@ -118,20 +230,22 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
ratio = json.loads(ratio)
|
||||
except Exception:
|
||||
ratio = {}
|
||||
root = str(Path(__file__).resolve().parents[3])
|
||||
if root not in sys.path:
|
||||
sys.path.append(root)
|
||||
try:
|
||||
mod = importlib.import_module("tem.compute")
|
||||
except Exception as e:
|
||||
raise BioyondException(f"无法导入计算模块: {e}")
|
||||
try:
|
||||
wp = float(wt_percent) if isinstance(wt_percent, str) else wt_percent
|
||||
mt = float(m_tot) if isinstance(m_tot, str) else m_tot
|
||||
tp = float(titration_percent) if isinstance(titration_percent, str) else titration_percent
|
||||
except Exception as e:
|
||||
raise BioyondException(f"参数解析失败: {e}")
|
||||
res = mod.generate_experiment_design(ratio=ratio, wt_percent=wp, m_tot=mt, titration_percent=tp)
|
||||
|
||||
# 2. 调用内部计算方法
|
||||
res = self._generate_experiment_design(
|
||||
ratio=ratio,
|
||||
wt_percent=wp,
|
||||
m_tot=mt,
|
||||
titration_percent=tp
|
||||
)
|
||||
|
||||
# 3. 构造返回结果
|
||||
out = {
|
||||
"solutions": res.get("solutions", []),
|
||||
"titration": res.get("titration", {}),
|
||||
@@ -140,11 +254,248 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
"return_info": json.dumps(res, ensure_ascii=False)
|
||||
}
|
||||
return out
|
||||
|
||||
except BioyondException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise BioyondException(str(e))
|
||||
|
||||
def _generate_experiment_design(
|
||||
self,
|
||||
ratio: dict,
|
||||
wt_percent: float = 0.25,
|
||||
m_tot: float = 70,
|
||||
titration_percent: float = 0.03,
|
||||
) -> dict:
|
||||
"""内部方法:生成实验设计
|
||||
|
||||
根据FuncGroup自动区分二胺和二酐,每种二胺单独配溶液,严格按照ratio顺序投料。
|
||||
|
||||
参数:
|
||||
ratio: 化合物配比字典,格式: {"compound_name": ratio_value}
|
||||
wt_percent: 固体重量百分比
|
||||
m_tot: 反应混合物总质量(g)
|
||||
titration_percent: 滴定溶液百分比
|
||||
|
||||
返回:
|
||||
包含实验设计详细参数的字典
|
||||
"""
|
||||
# 溶剂密度
|
||||
ρ_solvent = 1.03 * self.ureg.g / self.ureg.ml
|
||||
# 二酐溶解度
|
||||
solubility = 0.02 * self.ureg.g / self.ureg.ml
|
||||
# 投入固体时最小溶剂体积
|
||||
V_min = 30 * self.ureg.ml
|
||||
m_tot = m_tot * self.ureg.g
|
||||
|
||||
# 保持ratio中的顺序
|
||||
compound_names = list(ratio.keys())
|
||||
compound_ratios = list(ratio.values())
|
||||
|
||||
# 验证所有化合物是否在 compound_info 中定义
|
||||
undefined_compounds = [name for name in compound_names if name not in self.compound_info["MolWt"]]
|
||||
if undefined_compounds:
|
||||
available = list(self.compound_info["MolWt"].keys())
|
||||
raise ValueError(
|
||||
f"以下化合物未在 compound_info 中定义: {undefined_compounds}。"
|
||||
f"可用的化合物: {available}"
|
||||
)
|
||||
|
||||
# 获取各化合物的分子量和官能团类型
|
||||
molecular_weights = [self.compound_info["MolWt"][name] for name in compound_names]
|
||||
func_groups = [self.compound_info["FuncGroup"][name] for name in compound_names]
|
||||
|
||||
# 记录化合物信息用于调试
|
||||
self.hardware_interface._logger.info(f"化合物名称: {compound_names}")
|
||||
self.hardware_interface._logger.info(f"官能团类型: {func_groups}")
|
||||
|
||||
# 按原始顺序分离二胺和二酐
|
||||
ordered_compounds = list(zip(compound_names, compound_ratios, molecular_weights, func_groups))
|
||||
diamine_compounds = [(name, ratio_val, mw, i) for i, (name, ratio_val, mw, fg) in enumerate(ordered_compounds) if fg == "Amine"]
|
||||
anhydride_compounds = [(name, ratio_val, mw, i) for i, (name, ratio_val, mw, fg) in enumerate(ordered_compounds) if fg == "Anhydride"]
|
||||
|
||||
if not diamine_compounds or not anhydride_compounds:
|
||||
raise ValueError(
|
||||
f"需要同时包含二胺(Amine)和二酐(Anhydride)化合物。"
|
||||
f"当前二胺: {[c[0] for c in diamine_compounds]}, "
|
||||
f"当前二酐: {[c[0] for c in anhydride_compounds]}"
|
||||
)
|
||||
|
||||
# 计算加权平均分子量 (基于摩尔比)
|
||||
total_molar_ratio = sum(compound_ratios)
|
||||
weighted_molecular_weight = sum(ratio_val * mw for ratio_val, mw in zip(compound_ratios, molecular_weights))
|
||||
|
||||
# 取最后一个二酐用于滴定
|
||||
titration_anhydride = anhydride_compounds[-1]
|
||||
solid_anhydrides = anhydride_compounds[:-1] if len(anhydride_compounds) > 1 else []
|
||||
|
||||
# 二胺溶液配制参数 - 每种二胺单独配制
|
||||
diamine_solutions = []
|
||||
total_diamine_volume = 0 * self.ureg.ml
|
||||
|
||||
# 计算反应物的总摩尔量
|
||||
n_reactant = m_tot * wt_percent / weighted_molecular_weight
|
||||
|
||||
for name, ratio_val, mw, order_index in diamine_compounds:
|
||||
# 跳过 SIDA
|
||||
if name == "SIDA":
|
||||
continue
|
||||
|
||||
# 计算该二胺需要的摩尔数
|
||||
n_diamine_needed = n_reactant * ratio_val
|
||||
|
||||
# 二胺溶液配制参数 (每种二胺固定配制参数)
|
||||
m_diamine_solid = 5.0 * self.ureg.g # 每种二胺固体质量
|
||||
V_solvent_for_this = 20 * self.ureg.ml # 每种二胺溶剂体积
|
||||
m_solvent_for_this = ρ_solvent * V_solvent_for_this
|
||||
|
||||
# 计算该二胺溶液的浓度
|
||||
c_diamine = (m_diamine_solid / mw) / V_solvent_for_this
|
||||
|
||||
# 计算需要移取的溶液体积
|
||||
V_diamine_needed = n_diamine_needed / c_diamine
|
||||
|
||||
diamine_solutions.append({
|
||||
"name": name,
|
||||
"order": order_index,
|
||||
"solid_mass": m_diamine_solid.magnitude,
|
||||
"solvent_volume": V_solvent_for_this.magnitude,
|
||||
"concentration": c_diamine.magnitude,
|
||||
"volume_needed": V_diamine_needed.magnitude,
|
||||
"molar_ratio": ratio_val
|
||||
})
|
||||
|
||||
total_diamine_volume += V_diamine_needed
|
||||
|
||||
# 按原始顺序排序
|
||||
diamine_solutions.sort(key=lambda x: x["order"])
|
||||
|
||||
# 计算滴定二酐的质量
|
||||
titration_name, titration_ratio, titration_mw, _ = titration_anhydride
|
||||
m_titration_anhydride = n_reactant * titration_ratio * titration_mw
|
||||
m_titration_90 = m_titration_anhydride * (1 - titration_percent)
|
||||
m_titration_10 = m_titration_anhydride * titration_percent
|
||||
|
||||
# 计算其他固体二酐的质量 (按顺序)
|
||||
solid_anhydride_masses = []
|
||||
for name, ratio_val, mw, order_index in solid_anhydrides:
|
||||
mass = n_reactant * ratio_val * mw
|
||||
solid_anhydride_masses.append({
|
||||
"name": name,
|
||||
"order": order_index,
|
||||
"mass": mass.magnitude,
|
||||
"molar_ratio": ratio_val
|
||||
})
|
||||
|
||||
# 按原始顺序排序
|
||||
solid_anhydride_masses.sort(key=lambda x: x["order"])
|
||||
|
||||
# 计算溶剂用量
|
||||
total_diamine_solution_mass = sum(
|
||||
sol["volume_needed"] * ρ_solvent for sol in diamine_solutions
|
||||
) * self.ureg.ml
|
||||
|
||||
# 预估滴定溶剂量、计算补加溶剂量
|
||||
m_solvent_titration = m_titration_10 / solubility * ρ_solvent
|
||||
m_solvent_add = m_tot * (1 - wt_percent) - total_diamine_solution_mass - m_solvent_titration
|
||||
|
||||
# 检查最小溶剂体积要求
|
||||
total_liquid_volume = (total_diamine_solution_mass + m_solvent_add) / ρ_solvent
|
||||
m_tot_min = V_min / total_liquid_volume * m_tot
|
||||
|
||||
# 如果需要,按比例放大
|
||||
scale_factor = 1.0
|
||||
if m_tot_min > m_tot:
|
||||
scale_factor = (m_tot_min / m_tot).magnitude
|
||||
m_titration_90 *= scale_factor
|
||||
m_titration_10 *= scale_factor
|
||||
m_solvent_add *= scale_factor
|
||||
m_solvent_titration *= scale_factor
|
||||
|
||||
# 更新二胺溶液用量
|
||||
for sol in diamine_solutions:
|
||||
sol["volume_needed"] *= scale_factor
|
||||
|
||||
# 更新固体二酐用量
|
||||
for anhydride in solid_anhydride_masses:
|
||||
anhydride["mass"] *= scale_factor
|
||||
|
||||
m_tot = m_tot_min
|
||||
|
||||
# 生成投料顺序
|
||||
feeding_order = []
|
||||
|
||||
# 1. 固体二酐 (按顺序)
|
||||
for anhydride in solid_anhydride_masses:
|
||||
feeding_order.append({
|
||||
"step": len(feeding_order) + 1,
|
||||
"type": "solid_anhydride",
|
||||
"name": anhydride["name"],
|
||||
"amount": anhydride["mass"],
|
||||
"order": anhydride["order"]
|
||||
})
|
||||
|
||||
# 2. 二胺溶液 (按顺序)
|
||||
for sol in diamine_solutions:
|
||||
feeding_order.append({
|
||||
"step": len(feeding_order) + 1,
|
||||
"type": "diamine_solution",
|
||||
"name": sol["name"],
|
||||
"amount": sol["volume_needed"],
|
||||
"order": sol["order"]
|
||||
})
|
||||
|
||||
# 3. 主要二酐粉末
|
||||
feeding_order.append({
|
||||
"step": len(feeding_order) + 1,
|
||||
"type": "main_anhydride",
|
||||
"name": titration_name,
|
||||
"amount": m_titration_90.magnitude,
|
||||
"order": titration_anhydride[3]
|
||||
})
|
||||
|
||||
# 4. 补加溶剂
|
||||
if m_solvent_add > 0:
|
||||
feeding_order.append({
|
||||
"step": len(feeding_order) + 1,
|
||||
"type": "additional_solvent",
|
||||
"name": "溶剂",
|
||||
"amount": m_solvent_add.magnitude,
|
||||
"order": 999
|
||||
})
|
||||
|
||||
# 5. 滴定二酐溶液
|
||||
feeding_order.append({
|
||||
"step": len(feeding_order) + 1,
|
||||
"type": "titration_anhydride",
|
||||
"name": f"{titration_name} 滴定液",
|
||||
"amount": m_titration_10.magnitude,
|
||||
"titration_solvent": m_solvent_titration.magnitude,
|
||||
"order": titration_anhydride[3]
|
||||
})
|
||||
|
||||
# 返回实验设计结果
|
||||
results = {
|
||||
"total_mass": m_tot.magnitude,
|
||||
"scale_factor": scale_factor,
|
||||
"solutions": diamine_solutions,
|
||||
"solids": solid_anhydride_masses,
|
||||
"titration": {
|
||||
"name": titration_name,
|
||||
"main_portion": m_titration_90.magnitude,
|
||||
"titration_portion": m_titration_10.magnitude,
|
||||
"titration_solvent": m_solvent_titration.magnitude,
|
||||
},
|
||||
"solvents": {
|
||||
"additional_solvent": m_solvent_add.magnitude,
|
||||
"total_liquid_volume": total_liquid_volume.magnitude
|
||||
},
|
||||
"feeding_order": feeding_order,
|
||||
"minimum_required_mass": m_tot_min.magnitude
|
||||
}
|
||||
|
||||
return results
|
||||
|
||||
# 90%10%小瓶投料任务创建方法
|
||||
def create_90_10_vial_feeding_task(self,
|
||||
order_name: str = None,
|
||||
@@ -961,6 +1312,108 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
'actualVolume': actual_volume
|
||||
}
|
||||
|
||||
def _simplify_report(self, report) -> Dict[str, Any]:
|
||||
"""简化实验报告,只保留关键信息,去除冗余的工作流参数"""
|
||||
if not isinstance(report, dict):
|
||||
return report
|
||||
|
||||
data = report.get('data', {})
|
||||
if not isinstance(data, dict):
|
||||
return report
|
||||
|
||||
# 提取关键信息
|
||||
simplified = {
|
||||
'name': data.get('name'),
|
||||
'code': data.get('code'),
|
||||
'requester': data.get('requester'),
|
||||
'workflowName': data.get('workflowName'),
|
||||
'workflowStep': data.get('workflowStep'),
|
||||
'requestTime': data.get('requestTime'),
|
||||
'startPreparationTime': data.get('startPreparationTime'),
|
||||
'completeTime': data.get('completeTime'),
|
||||
'useTime': data.get('useTime'),
|
||||
'status': data.get('status'),
|
||||
'statusName': data.get('statusName'),
|
||||
}
|
||||
|
||||
# 提取物料信息(简化版)
|
||||
pre_intakes = data.get('preIntakes', [])
|
||||
if pre_intakes and isinstance(pre_intakes, list):
|
||||
first_intake = pre_intakes[0]
|
||||
sample_materials = first_intake.get('sampleMaterials', [])
|
||||
|
||||
# 简化物料信息
|
||||
simplified_materials = []
|
||||
for material in sample_materials:
|
||||
if isinstance(material, dict):
|
||||
mat_info = {
|
||||
'materialName': material.get('materialName'),
|
||||
'materialTypeName': material.get('materialTypeName'),
|
||||
'materialCode': material.get('materialCode'),
|
||||
'materialLocation': material.get('materialLocation'),
|
||||
}
|
||||
|
||||
# 解析parameters中的关键信息(如密度、加料历史等)
|
||||
params_str = material.get('parameters', '{}')
|
||||
try:
|
||||
params = json.loads(params_str) if isinstance(params_str, str) else params_str
|
||||
if isinstance(params, dict):
|
||||
# 只保留关键参数
|
||||
if 'density' in params:
|
||||
mat_info['density'] = params['density']
|
||||
if 'feedingHistory' in params:
|
||||
mat_info['feedingHistory'] = params['feedingHistory']
|
||||
if 'liquidVolume' in params:
|
||||
mat_info['liquidVolume'] = params['liquidVolume']
|
||||
if 'm_diamine_tot' in params:
|
||||
mat_info['m_diamine_tot'] = params['m_diamine_tot']
|
||||
if 'wt_diamine' in params:
|
||||
mat_info['wt_diamine'] = params['wt_diamine']
|
||||
except:
|
||||
pass
|
||||
|
||||
simplified_materials.append(mat_info)
|
||||
|
||||
simplified['sampleMaterials'] = simplified_materials
|
||||
|
||||
# 提取extraProperties中的实际值
|
||||
extra_props = first_intake.get('extraProperties', {})
|
||||
if isinstance(extra_props, dict):
|
||||
simplified_extra = {}
|
||||
for key, value in extra_props.items():
|
||||
try:
|
||||
parsed_value = json.loads(value) if isinstance(value, str) else value
|
||||
simplified_extra[key] = parsed_value
|
||||
except:
|
||||
simplified_extra[key] = value
|
||||
simplified['extraProperties'] = simplified_extra
|
||||
|
||||
return {
|
||||
'data': simplified,
|
||||
'code': report.get('code'),
|
||||
'message': report.get('message'),
|
||||
'timestamp': report.get('timestamp')
|
||||
}
|
||||
|
||||
def scheduler_start(self) -> dict:
|
||||
"""启动调度器 - 启动Bioyond工作站的任务调度器,开始执行队列中的任务
|
||||
|
||||
Returns:
|
||||
dict: 包含return_info的字典,return_info为整型(1=成功)
|
||||
|
||||
Raises:
|
||||
BioyondException: 调度器启动失败时抛出异常
|
||||
"""
|
||||
result = self.hardware_interface.scheduler_start()
|
||||
self.hardware_interface._logger.info(f"调度器启动结果: {result}")
|
||||
|
||||
if result != 1:
|
||||
error_msg = "启动调度器失败: 有未处理错误,调度无法启动。请检查Bioyond系统状态。"
|
||||
self.hardware_interface._logger.error(error_msg)
|
||||
raise BioyondException(error_msg)
|
||||
|
||||
return {"return_info": result}
|
||||
|
||||
# 等待多个任务完成并获取实验报告
|
||||
def wait_for_multiple_orders_and_get_reports(self,
|
||||
batch_create_result: str = None,
|
||||
@@ -1002,7 +1455,12 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
|
||||
# 验证batch_create_result参数
|
||||
if not batch_create_result or batch_create_result == "":
|
||||
raise BioyondException("batch_create_result参数为空,请确保从batch_create节点正确连接handle")
|
||||
raise BioyondException(
|
||||
"batch_create_result参数为空,请确保:\n"
|
||||
"1. batch_create节点与wait节点之间正确连接了handle\n"
|
||||
"2. batch_create节点成功执行并返回了结果\n"
|
||||
"3. 检查上游batch_create任务是否成功创建了订单"
|
||||
)
|
||||
|
||||
# 解析batch_create_result JSON对象
|
||||
try:
|
||||
@@ -1031,7 +1489,17 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
|
||||
# 验证提取的数据
|
||||
if not order_codes:
|
||||
raise BioyondException("batch_create_result中未找到order_codes字段或为空")
|
||||
self.hardware_interface._logger.error(
|
||||
f"batch_create任务未生成任何订单。batch_create_result内容: {batch_create_result}"
|
||||
)
|
||||
raise BioyondException(
|
||||
"batch_create_result中未找到order_codes或为空。\n"
|
||||
"可能的原因:\n"
|
||||
"1. batch_create任务执行失败(检查任务是否报错)\n"
|
||||
"2. 物料配置问题(如'物料样品板分配失败')\n"
|
||||
"3. Bioyond系统状态异常\n"
|
||||
f"请检查batch_create任务的执行结果"
|
||||
)
|
||||
if not order_ids:
|
||||
raise BioyondException("batch_create_result中未找到order_ids字段或为空")
|
||||
|
||||
@@ -1114,6 +1582,8 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
self.hardware_interface._logger.info(
|
||||
f"成功获取任务 {order_code} 的实验报告"
|
||||
)
|
||||
# 简化报告,去除冗余信息
|
||||
report = self._simplify_report(report)
|
||||
|
||||
reports.append({
|
||||
"order_code": order_code,
|
||||
@@ -1288,7 +1758,7 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
f"开始执行批量物料转移: {len(transfer_groups)}组任务 -> {target_device_id}"
|
||||
)
|
||||
|
||||
from .config import WAREHOUSE_MAPPING
|
||||
warehouse_mapping = self.bioyond_config.get("warehouse_mapping", {})
|
||||
results = []
|
||||
successful_count = 0
|
||||
failed_count = 0
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ Bioyond Workstation Implementation
|
||||
"""
|
||||
import time
|
||||
import traceback
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional, Union
|
||||
import json
|
||||
@@ -23,12 +24,94 @@ from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
|
||||
from unilabos.ros.msgs.message_converter import convert_to_ros_msg, Float64, String
|
||||
from pylabrobot.resources.resource import Resource as ResourcePLR
|
||||
|
||||
from unilabos.devices.workstation.bioyond_studio.config import (
|
||||
API_CONFIG, WORKFLOW_MAPPINGS, MATERIAL_TYPE_MAPPINGS, WAREHOUSE_MAPPING, HTTP_SERVICE_CONFIG
|
||||
)
|
||||
|
||||
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
|
||||
|
||||
|
||||
class ConnectionMonitor:
|
||||
"""Bioyond连接监控器"""
|
||||
def __init__(self, workstation, check_interval=30):
|
||||
self.workstation = workstation
|
||||
self.check_interval = check_interval
|
||||
self._running = False
|
||||
self._thread = None
|
||||
self._last_status = "unknown"
|
||||
|
||||
def start(self):
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._monitor_loop, daemon=True, name="BioyondConnectionMonitor")
|
||||
self._thread.start()
|
||||
logger.info("Bioyond连接监控器已启动")
|
||||
|
||||
def stop(self):
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=2)
|
||||
logger.info("Bioyond连接监控器已停止")
|
||||
|
||||
def _monitor_loop(self):
|
||||
while self._running:
|
||||
try:
|
||||
# 使用 lightweight API 检查连接
|
||||
# query_matial_type_list 是比较快的查询
|
||||
start_time = time.time()
|
||||
result = self.workstation.hardware_interface.material_type_list()
|
||||
|
||||
status = "online" if result else "offline"
|
||||
msg = "Connection established" if status == "online" else "Failed to get material type list"
|
||||
|
||||
if status != self._last_status:
|
||||
logger.info(f"Bioyond连接状态变更: {self._last_status} -> {status}")
|
||||
self._publish_event(status, msg)
|
||||
self._last_status = status
|
||||
|
||||
# 发布心跳 (可选,或者只在状态变更时发布)
|
||||
# self._publish_event(status, msg)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Bioyond连接检查异常: {e}")
|
||||
if self._last_status != "error":
|
||||
self._publish_event("error", str(e))
|
||||
self._last_status = "error"
|
||||
|
||||
time.sleep(self.check_interval)
|
||||
|
||||
def _publish_event(self, status, message):
|
||||
try:
|
||||
if hasattr(self.workstation, "_ros_node") and self.workstation._ros_node:
|
||||
event_data = {
|
||||
"status": status,
|
||||
"message": message,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# 动态发布消息,需要在 ROS2DeviceNode 中有对应支持
|
||||
# 这里假设通用事件发布机制,使用 String 类型的 topic
|
||||
# 话题: /<namespace>/events/device_status
|
||||
ns = self.workstation._ros_node.namespace
|
||||
topic = f"{ns}/events/device_status"
|
||||
|
||||
# 使用 ROS2DeviceNode 的发布功能
|
||||
# 如果没有预定义的 publisher,需要动态创建
|
||||
# 注意:workstation base node 可能没有自动创建 arbitrary publishers 的机制
|
||||
# 这里我们先尝试用 String json 发布
|
||||
|
||||
# 在 ROS2DeviceNode 中通常需要先 create_publisher
|
||||
# 为了简单起见,我们检查是否已有 publisher,没有则创建
|
||||
if not hasattr(self.workstation, "_device_status_pub"):
|
||||
self.workstation._device_status_pub = self.workstation._ros_node.create_publisher(
|
||||
String, topic, 10
|
||||
)
|
||||
|
||||
self.workstation._device_status_pub.publish(
|
||||
convert_to_ros_msg(String, json.dumps(event_data, ensure_ascii=False))
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"发布设备状态事件失败: {e}")
|
||||
|
||||
|
||||
class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||
"""Bioyond资源同步器
|
||||
|
||||
@@ -174,9 +257,8 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||
else:
|
||||
logger.info(f"[同步→Bioyond] ➕ 物料不存在于 Bioyond,将创建新物料并入库")
|
||||
|
||||
# 第1步:获取仓库配置
|
||||
from .config import WAREHOUSE_MAPPING
|
||||
warehouse_mapping = WAREHOUSE_MAPPING
|
||||
# 第1步:从配置中获取仓库配置
|
||||
warehouse_mapping = self.bioyond_config.get("warehouse_mapping", {})
|
||||
|
||||
# 确定目标仓库名称
|
||||
parent_name = None
|
||||
@@ -238,14 +320,20 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||
# 第2步:转换为 Bioyond 格式
|
||||
logger.info(f"[同步→Bioyond] 🔄 转换物料为 Bioyond 格式...")
|
||||
|
||||
# 导入物料默认参数配置
|
||||
from .config import MATERIAL_DEFAULT_PARAMETERS
|
||||
# 从配置中获取物料默认参数
|
||||
material_default_params = self.workstation.bioyond_config.get("material_default_parameters", {})
|
||||
material_type_params = self.workstation.bioyond_config.get("material_type_parameters", {})
|
||||
|
||||
# 合并参数配置:物料名称参数 + typeId参数(转换为 type:<uuid> 格式)
|
||||
merged_params = material_default_params.copy()
|
||||
for type_id, params in material_type_params.items():
|
||||
merged_params[f"type:{type_id}"] = params
|
||||
|
||||
bioyond_material = resource_plr_to_bioyond(
|
||||
[resource],
|
||||
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
|
||||
warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"],
|
||||
material_params=MATERIAL_DEFAULT_PARAMETERS
|
||||
material_params=merged_params
|
||||
)[0]
|
||||
|
||||
logger.info(f"[同步→Bioyond] 🔧 准备覆盖locations字段,目标仓库: {parent_name}, 库位: {update_site}, UUID: {target_location_uuid[:8]}...")
|
||||
@@ -468,13 +556,20 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||
return material_bioyond_id
|
||||
|
||||
# 转换为 Bioyond 格式
|
||||
from .config import MATERIAL_DEFAULT_PARAMETERS
|
||||
# 从配置中获取物料默认参数
|
||||
material_default_params = self.workstation.bioyond_config.get("material_default_parameters", {})
|
||||
material_type_params = self.workstation.bioyond_config.get("material_type_parameters", {})
|
||||
|
||||
# 合并参数配置:物料名称参数 + typeId参数(转换为 type:<uuid> 格式)
|
||||
merged_params = material_default_params.copy()
|
||||
for type_id, params in material_type_params.items():
|
||||
merged_params[f"type:{type_id}"] = params
|
||||
|
||||
bioyond_material = resource_plr_to_bioyond(
|
||||
[resource],
|
||||
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
|
||||
warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"],
|
||||
material_params=MATERIAL_DEFAULT_PARAMETERS
|
||||
material_params=merged_params
|
||||
)[0]
|
||||
|
||||
# ⚠️ 关键:创建物料时不设置 locations,让 Bioyond 系统暂不分配库位
|
||||
@@ -528,8 +623,7 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||
logger.info(f"[物料入库] 目标库位: {update_site}")
|
||||
|
||||
# 获取仓库配置和目标库位 UUID
|
||||
from .config import WAREHOUSE_MAPPING
|
||||
warehouse_mapping = WAREHOUSE_MAPPING
|
||||
warehouse_mapping = self.workstation.bioyond_config.get("warehouse_mapping", {})
|
||||
|
||||
parent_name = None
|
||||
target_location_uuid = None
|
||||
@@ -584,6 +678,44 @@ class BioyondWorkstation(WorkstationBase):
|
||||
集成Bioyond物料管理的工作站实现
|
||||
"""
|
||||
|
||||
def _publish_task_status(
|
||||
self,
|
||||
task_id: str,
|
||||
task_type: str,
|
||||
status: str,
|
||||
result: dict = None,
|
||||
progress: float = 0.0,
|
||||
task_code: str = None
|
||||
):
|
||||
"""发布任务状态事件"""
|
||||
try:
|
||||
if not getattr(self, "_ros_node", None):
|
||||
return
|
||||
|
||||
event_data = {
|
||||
"task_id": task_id,
|
||||
"task_code": task_code,
|
||||
"task_type": task_type,
|
||||
"status": status,
|
||||
"progress": progress,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
if result:
|
||||
event_data["result"] = result
|
||||
|
||||
topic = f"{self._ros_node.namespace}/events/task_status"
|
||||
|
||||
if not hasattr(self, "_task_status_pub"):
|
||||
self._task_status_pub = self._ros_node.create_publisher(
|
||||
String, topic, 10
|
||||
)
|
||||
|
||||
self._task_status_pub.publish(
|
||||
convert_to_ros_msg(String, json.dumps(event_data, ensure_ascii=False))
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"发布任务状态事件失败: {e}")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bioyond_config: Optional[Dict[str, Any]] = None,
|
||||
@@ -605,10 +737,28 @@ class BioyondWorkstation(WorkstationBase):
|
||||
raise ValueError("Deck 配置不能为空,请在配置文件中添加正确的 deck 配置")
|
||||
|
||||
# 初始化 warehouses 属性
|
||||
self.deck.warehouses = {}
|
||||
for resource in self.deck.children:
|
||||
if isinstance(resource, WareHouse):
|
||||
self.deck.warehouses[resource.name] = resource
|
||||
if not hasattr(self.deck, "warehouses") or self.deck.warehouses is None:
|
||||
self.deck.warehouses = {}
|
||||
|
||||
# 仅当 warehouses 为空时尝试重新扫描(避免覆盖子类的修复)
|
||||
if not self.deck.warehouses:
|
||||
for resource in self.deck.children:
|
||||
# 兼容性增强: 只要是仓库类别或者是 WareHouse 实例均可
|
||||
is_warehouse = isinstance(resource, WareHouse) or getattr(resource, "category", "") == "warehouse"
|
||||
|
||||
# 如果配置中有定义,也可以认定为 warehouse
|
||||
if not is_warehouse and "warehouse_mapping" in bioyond_config:
|
||||
if resource.name in bioyond_config["warehouse_mapping"]:
|
||||
is_warehouse = True
|
||||
|
||||
if is_warehouse:
|
||||
self.deck.warehouses[resource.name] = resource
|
||||
# 确保 category 被正确设置,方便后续使用
|
||||
if getattr(resource, "category", "") != "warehouse":
|
||||
try:
|
||||
resource.category = "warehouse"
|
||||
except:
|
||||
pass
|
||||
|
||||
# 创建通信模块
|
||||
self._create_communication_module(bioyond_config)
|
||||
@@ -627,18 +777,22 @@ class BioyondWorkstation(WorkstationBase):
|
||||
self._set_workflow_mappings(bioyond_config["workflow_mappings"])
|
||||
|
||||
# 准备 HTTP 报送接收服务配置(延迟到 post_init 启动)
|
||||
# 从 bioyond_config 中获取,如果没有则使用 HTTP_SERVICE_CONFIG 的默认值
|
||||
# 从 bioyond_config 中的 http_service_config 获取
|
||||
http_service_cfg = bioyond_config.get("http_service_config", {})
|
||||
self._http_service_config = {
|
||||
"host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG["http_service_host"]),
|
||||
"port": bioyond_config.get("http_service_port", HTTP_SERVICE_CONFIG["http_service_port"])
|
||||
"host": http_service_cfg.get("http_service_host", "127.0.0.1"),
|
||||
"port": http_service_cfg.get("http_service_port", 8080)
|
||||
}
|
||||
self.http_service = None # 将在 post_init 中启动
|
||||
self.http_service = None # 将在 post_init 启动
|
||||
self.connection_monitor = None # 将在 post_init 启动
|
||||
|
||||
logger.info(f"Bioyond工作站初始化完成")
|
||||
|
||||
def __del__(self):
|
||||
"""析构函数:清理资源,停止 HTTP 服务"""
|
||||
try:
|
||||
if hasattr(self, 'connection_monitor') and self.connection_monitor:
|
||||
self.connection_monitor.stop()
|
||||
if hasattr(self, 'http_service') and self.http_service is not None:
|
||||
logger.info("正在停止 HTTP 报送服务...")
|
||||
self.http_service.stop()
|
||||
@@ -648,8 +802,19 @@ class BioyondWorkstation(WorkstationBase):
|
||||
def post_init(self, ros_node: ROS2WorkstationNode):
|
||||
self._ros_node = ros_node
|
||||
|
||||
# 启动连接监控
|
||||
try:
|
||||
self.connection_monitor = ConnectionMonitor(self)
|
||||
self.connection_monitor.start()
|
||||
except Exception as e:
|
||||
logger.error(f"启动连接监控失败: {e}")
|
||||
|
||||
# 启动 HTTP 报送接收服务(现在 device_id 已可用)
|
||||
if hasattr(self, '_http_service_config'):
|
||||
# ⚠️ 检查子类是否已经自己管理 HTTP 服务
|
||||
if self.bioyond_config.get("_disable_auto_http_service"):
|
||||
logger.info("🔧 检测到 _disable_auto_http_service 标志,跳过自动启动 HTTP 服务")
|
||||
logger.info(" 子类(BioyondCellWorkstation)已自行管理 HTTP 服务")
|
||||
elif hasattr(self, '_http_service_config'):
|
||||
try:
|
||||
self.http_service = WorkstationHTTPService(
|
||||
workstation_instance=self,
|
||||
@@ -688,19 +853,14 @@ class BioyondWorkstation(WorkstationBase):
|
||||
|
||||
def _create_communication_module(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||
"""创建Bioyond通信模块"""
|
||||
# 创建默认配置
|
||||
default_config = {
|
||||
**API_CONFIG,
|
||||
"workflow_mappings": WORKFLOW_MAPPINGS,
|
||||
"material_type_mappings": MATERIAL_TYPE_MAPPINGS,
|
||||
"warehouse_mapping": WAREHOUSE_MAPPING
|
||||
}
|
||||
|
||||
# 如果传入了 config,合并配置(config 中的值会覆盖默认值)
|
||||
# 直接使用传入的配置,不再使用默认值
|
||||
# 所有配置必须从 JSON 文件中提供
|
||||
if config:
|
||||
self.bioyond_config = {**default_config, **config}
|
||||
self.bioyond_config = config
|
||||
else:
|
||||
self.bioyond_config = default_config
|
||||
# 如果没有配置,使用空字典(会导致后续错误,但这是预期的)
|
||||
self.bioyond_config = {}
|
||||
print("警告: 未提供 bioyond_config,请确保在 JSON 配置文件中提供完整配置")
|
||||
|
||||
self.hardware_interface = BioyondV1RPC(self.bioyond_config)
|
||||
|
||||
@@ -1014,7 +1174,15 @@ class BioyondWorkstation(WorkstationBase):
|
||||
|
||||
workflow_id = self._get_workflow(actual_workflow_name)
|
||||
if workflow_id:
|
||||
self.workflow_sequence.append(workflow_id)
|
||||
# 兼容 BioyondReactionStation 中 workflow_sequence 被重写为 property 的情况
|
||||
if isinstance(self.workflow_sequence, list):
|
||||
self.workflow_sequence.append(workflow_id)
|
||||
elif hasattr(self, "_cached_workflow_sequence") and isinstance(self._cached_workflow_sequence, list):
|
||||
self._cached_workflow_sequence.append(workflow_id)
|
||||
else:
|
||||
print(f"❌ 无法添加工作流: workflow_sequence 类型错误 {type(self.workflow_sequence)}")
|
||||
return False
|
||||
|
||||
print(f"添加工作流到执行顺序: {actual_workflow_name} -> {workflow_id}")
|
||||
return True
|
||||
return False
|
||||
@@ -1215,6 +1383,22 @@ class BioyondWorkstation(WorkstationBase):
|
||||
# TODO: 根据实际业务需求处理步骤完成逻辑
|
||||
# 例如:更新数据库、触发后续流程等
|
||||
|
||||
# 发布任务状态事件 (running/progress update)
|
||||
self._publish_task_status(
|
||||
task_id=data.get('orderCode'), # 使用 OrderCode 作为关联 ID
|
||||
task_code=data.get('orderCode'),
|
||||
task_type="bioyond_step",
|
||||
status="running",
|
||||
progress=0.5, # 步骤完成视为任务进行中
|
||||
result={"step_name": data.get('stepName'), "step_id": data.get('stepId')}
|
||||
)
|
||||
|
||||
# 更新物料信息
|
||||
# 步骤完成后,物料状态可能发生变化(如位置、用量等),触发同步
|
||||
logger.info(f"[步骤完成报送] 触发物料同步...")
|
||||
self.resource_synchronizer.sync_from_external()
|
||||
|
||||
|
||||
return {
|
||||
"processed": True,
|
||||
"step_id": data.get('stepId'),
|
||||
@@ -1249,6 +1433,17 @@ class BioyondWorkstation(WorkstationBase):
|
||||
|
||||
# TODO: 根据实际业务需求处理通量完成逻辑
|
||||
|
||||
# 发布任务状态事件
|
||||
self._publish_task_status(
|
||||
task_id=data.get('orderCode'),
|
||||
task_code=data.get('orderCode'),
|
||||
task_type="bioyond_sample",
|
||||
status="running",
|
||||
progress=0.7,
|
||||
result={"sample_id": data.get('sampleId'), "status": status_desc}
|
||||
)
|
||||
|
||||
|
||||
return {
|
||||
"processed": True,
|
||||
"sample_id": data.get('sampleId'),
|
||||
@@ -1288,6 +1483,32 @@ class BioyondWorkstation(WorkstationBase):
|
||||
# TODO: 根据实际业务需求处理任务完成逻辑
|
||||
# 例如:更新物料库存、生成报表等
|
||||
|
||||
# 映射状态到事件状态
|
||||
event_status = "completed"
|
||||
if str(data.get('status')) in ["-11", "-12"]:
|
||||
event_status = "error"
|
||||
elif str(data.get('status')) == "30":
|
||||
event_status = "completed"
|
||||
else:
|
||||
event_status = "running" # 其他状态视为运行中(或根据实际定义)
|
||||
|
||||
# 发布任务状态事件
|
||||
self._publish_task_status(
|
||||
task_id=data.get('orderCode'),
|
||||
task_code=data.get('orderCode'),
|
||||
task_type="bioyond_order",
|
||||
status=event_status,
|
||||
progress=1.0 if event_status in ["completed", "error"] else 0.9,
|
||||
result={"order_name": data.get('orderName'), "status": status_desc, "materials_count": len(used_materials)}
|
||||
)
|
||||
|
||||
# 更新物料信息
|
||||
# 任务完成后,且状态为完成时,触发同步以更新最终物料状态
|
||||
if event_status == "completed":
|
||||
logger.info(f"[任务完成报送] 触发物料同步...")
|
||||
self.resource_synchronizer.sync_from_external()
|
||||
|
||||
|
||||
return {
|
||||
"processed": True,
|
||||
"order_code": data.get('orderCode'),
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Modbus CSV 地址映射说明
|
||||
|
||||
本文档说明 `coin_cell_assembly_a.csv` 文件如何将命名节点映射到实际的 Modbus 地址,以及如何在代码中使用它们。
|
||||
|
||||
## 1. CSV 文件结构
|
||||
|
||||
地址表文件位于同级目录下:`coin_cell_assembly_a.csv`
|
||||
|
||||
每一行定义了一个 Modbus 节点,包含以下关键列:
|
||||
|
||||
| 列名 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| **Name** | **节点名称** (代码中引用的 Key) | `COIL_ALUMINUM_FOIL` |
|
||||
| **DataType** | 数据类型 (BOOL, INT16, FLOAT32, STRING) | `BOOL` |
|
||||
| **Comment** | 注释说明 | `使用铝箔垫` |
|
||||
| **Attribute** | 属性 (通常留空或用于额外标记) | |
|
||||
| **DeviceType** | Modbus 寄存器类型 (`coil`, `hold_register`) | `coil` |
|
||||
| **Address** | **Modbus 地址** (十进制) | `8340` |
|
||||
|
||||
### 示例行 (铝箔垫片)
|
||||
|
||||
```csv
|
||||
COIL_ALUMINUM_FOIL,BOOL,,使用铝箔垫,,coil,8340,
|
||||
```
|
||||
|
||||
- **名称**: `COIL_ALUMINUM_FOIL`
|
||||
- **类型**: `coil` (线圈,读写单个位)
|
||||
- **地址**: `8340`
|
||||
|
||||
---
|
||||
|
||||
## 2. 加载与注册流程
|
||||
|
||||
在 `coin_cell_assembly.py` 的初始化代码中:
|
||||
|
||||
1. **加载 CSV**: `BaseClient.load_csv()` 读取 CSV 并解析每行定义。
|
||||
2. **注册节点**: `modbus_client.register_node_list()` 将解析后的节点注册到 Modbus 客户端实例中。
|
||||
|
||||
```python
|
||||
# 代码位置: coin_cell_assembly.py (L174-175)
|
||||
self.nodes = BaseClient.load_csv(os.path.join(os.path.dirname(__file__), 'coin_cell_assembly_a.csv'))
|
||||
self.client = modbus_client.register_node_list(self.nodes)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 代码中的使用方式
|
||||
|
||||
注册后,通过 `self.client.use_node('节点名称')` 即可获取该节点对象并进行读写操作,无需关心具体地址。
|
||||
|
||||
### 控制铝箔垫片 (COIL_ALUMINUM_FOIL)
|
||||
|
||||
```python
|
||||
# 代码位置: qiming_coin_cell_code 函数 (L1048)
|
||||
self.client.use_node('COIL_ALUMINUM_FOIL').write(not lvbodian)
|
||||
```
|
||||
|
||||
- **写入 True**: 对应 Modbus 功能码 05 (Write Single Coil),向地址 `8340` 写入 `1` (ON)。
|
||||
- **写入 False**: 向地址 `8340` 写入 `0` (OFF)。
|
||||
|
||||
> **注意**: 代码中使用了 `not lvbodian`,这意味着逻辑是反转的。如果 `lvbodian` 参数为 `True` (默认),写入的是 `False` (不使用铝箔垫)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 地址转换注意事项 (Modbus vs PLC)
|
||||
|
||||
CSV 中的 `Address` 列(如 `8340`)是 **Modbus 协议地址**。
|
||||
|
||||
如果使用 InoProShop (汇川 PLC 编程软件),看到的可能是 **PLC 内部地址** (如 `%QX...` 或 `%MW...`)。这两者之间通常需要转换。
|
||||
|
||||
### 常见的转换规则 (示例)
|
||||
|
||||
- **Coil (线圈) %QX**:
|
||||
- `Modbus地址 = 字节地址 * 8 + 位偏移`
|
||||
- *例子*: `%QX834.0` -> `834 * 8 + 0` = `6672`
|
||||
- *注意*: 如果 CSV 中配置的是 `8340`,这可能是一个自定义映射,或者是基于不同规则(如直接对应 Word 地址的某种映射,或者可能就是地址写错了/使用了非标准映射)。
|
||||
|
||||
- **Register (寄存器) %MW**:
|
||||
- 通常直接对应,或者有偏移量 (如 Modbus 40001 = PLC MW0)。
|
||||
|
||||
### 验证方法
|
||||
由于 `test_unilab_interact.py` 中发现 `8450` (CSV风格) 不工作,而 `6760` (%QX845.0 计算值) 工作正常,**建议对 CSV 中的其他地址也进行核实**,特别是像 `8340` 这样以 0 结尾看起来像是 "字节地址+0" 的数值,可能实际上应该是 `%QX834.0` 对应的 `6672`。
|
||||
|
||||
如果发现设备控制无反应,请尝试按照标准的 Modbus 计算方式转换 PLC 地址。
|
||||
@@ -0,0 +1,352 @@
|
||||
# 2026-01-13 物料搜寻确认弹窗自动处理功能
|
||||
|
||||
## 概述
|
||||
|
||||
本次更新为设备初始化流程添加了**物料搜寻确认弹窗自动检测与处理功能**。在设备初始化过程中,PLC 会弹出物料搜寻确认对话框,现在系统可以根据用户参数自动点击"是"或"否"按钮,无需手动干预。
|
||||
|
||||
## 背景问题
|
||||
|
||||
### 原有流程
|
||||
1. 调用 `func_pack_device_init_auto_start_combined()` 初始化设备
|
||||
2. PLC 在初始化过程中弹出物料搜寻确认对话框
|
||||
3. **需要人工手动点击**"是"或"否"按钮
|
||||
4. PLC 继续完成初始化并启动
|
||||
|
||||
### 存在的问题
|
||||
- 需要人工干预,无法实现全自动化
|
||||
- 影响批量生产效率
|
||||
- 容易遗忘点击导致流程卡住
|
||||
|
||||
## 解决方案
|
||||
|
||||
### 新增 Modbus 地址配置
|
||||
|
||||
在 `coin_cell_assembly_b.csv` 第 69-71 行添加三个 coil:
|
||||
|
||||
| Name | DeviceType | Address | 说明 |
|
||||
|------|-----------|---------|------|
|
||||
| COIL_MATERIAL_SEARCH_DIALOG_APPEAR | coil | 6470 | 物料搜寻确认弹窗画面是否出现 |
|
||||
| COIL_MATERIAL_SEARCH_CONFIRM_YES | coil | 6480 | 初始化物料搜寻确认按钮"是" |
|
||||
| COIL_MATERIAL_SEARCH_CONFIRM_NO | coil | 6490 | 初始化物料搜寻确认按钮"否" |
|
||||
|
||||
**Modbus 地址转换:**
|
||||
- CSV 6470 → Modbus 5176 (弹窗出现)
|
||||
- CSV 6480 → Modbus 5184 (按钮"是")
|
||||
- CSV 6490 → Modbus 5192 (按钮"否")
|
||||
|
||||
## 代码修改详情
|
||||
|
||||
### 1. coin_cell_assembly.py
|
||||
|
||||
#### 1.1 新增辅助方法 `_handle_material_search_dialog()`
|
||||
|
||||
**位置:** 第 799-901 行
|
||||
|
||||
**功能:**
|
||||
- 监测物料搜寻确认弹窗是否出现(Coil 5176)
|
||||
- 根据 `enable_search` 参数自动点击对应按钮
|
||||
- 使用**脉冲模式**模拟真实按钮操作:`True` → 保持 0.5 秒 → `False`
|
||||
|
||||
**参数:**
|
||||
- `enable_search: bool` - True=点击"是"(启用物料搜寻), False=点击"否"(不启用)
|
||||
- `timeout: int = 30` - 等待弹窗出现的最大时间(秒)
|
||||
|
||||
**逻辑流程:**
|
||||
```python
|
||||
1. 监测 COIL_MATERIAL_SEARCH_DIALOG_APPEAR (每 0.5 秒检查一次)
|
||||
2. 检测到弹窗出现 (Coil = True)
|
||||
3. 选择按钮:
|
||||
- enable_search=True → COIL_MATERIAL_SEARCH_CONFIRM_YES
|
||||
- enable_search=False → COIL_MATERIAL_SEARCH_CONFIRM_NO
|
||||
4. 执行脉冲操作:
|
||||
- 写入 True (按下按钮)
|
||||
- 等待 0.5 秒
|
||||
- 写入 False (释放按钮)
|
||||
- 验证状态
|
||||
```
|
||||
|
||||
#### 1.2 修改 `func_pack_device_init_auto_start_combined()`
|
||||
|
||||
**位置:** 第 904-1115 行
|
||||
|
||||
**主要改动:**
|
||||
|
||||
1. **添加新参数**
|
||||
```python
|
||||
def func_pack_device_init_auto_start_combined(
|
||||
self,
|
||||
material_search_enable: bool = False # 新增参数
|
||||
) -> bool:
|
||||
```
|
||||
|
||||
2. **内联初始化逻辑并集成弹窗检测**
|
||||
- 不再调用 `self.func_pack_device_init()`
|
||||
- 将初始化逻辑直接实现在函数内
|
||||
- **在等待初始化完成的循环中实时检测弹窗**
|
||||
- 避免死锁:PLC 等待弹窗确认 ↔ 代码等待初始化完成
|
||||
|
||||
3. **关键代码片段**
|
||||
```python
|
||||
# 等待初始化完成,同时检测物料搜寻弹窗
|
||||
while (self._sys_init_status()) == False:
|
||||
# 检查超时
|
||||
if time.time() - start_wait > max_wait_time:
|
||||
raise RuntimeError(f"初始化超时")
|
||||
|
||||
# 如果还没处理弹窗,检测弹窗是否出现
|
||||
if not dialog_handled:
|
||||
dialog_state = self.client.use_node('COIL_MATERIAL_SEARCH_DIALOG_APPEAR').read(1)
|
||||
if dialog_actual: # 弹窗出现
|
||||
# 执行脉冲按钮点击
|
||||
button_node.write(True) # 按下
|
||||
time.sleep(0.5) # 保持
|
||||
button_node.write(False) # 释放
|
||||
dialog_handled = True
|
||||
|
||||
time.sleep(1)
|
||||
```
|
||||
|
||||
4. **步骤调整**
|
||||
- 步骤 0: 前置条件检查
|
||||
- 步骤 1: 设备初始化(**包含弹窗检测**)
|
||||
- 步骤 1.5: 已在步骤 1 中完成
|
||||
- 步骤 2: 切换自动模式
|
||||
- 步骤 3: 启动设备
|
||||
|
||||
### 2. coin_cell_workstation.yaml
|
||||
|
||||
**位置:** 第 292-312 行
|
||||
|
||||
**修改内容:**
|
||||
|
||||
```yaml
|
||||
auto-func_pack_device_init_auto_start_combined:
|
||||
goal_default:
|
||||
material_search_enable: false # 新增默认值
|
||||
|
||||
schema:
|
||||
description: 组合函数:设备初始化 + 物料搜寻确认 + 切换自动模式 + 启动。初始化过程中会自动检测物料搜寻确认弹窗,并根据参数自动点击"是"或"否"按钮
|
||||
|
||||
goal:
|
||||
properties:
|
||||
material_search_enable: # 新增参数配置
|
||||
default: false
|
||||
description: 是否启用物料搜寻功能。设备初始化后会弹出物料搜寻确认弹窗,此参数控制自动点击"是"(启用)或"否"(不启用)。默认为false(不启用物料搜寻)
|
||||
type: boolean
|
||||
```
|
||||
|
||||
### 3. 测试脚本(已创建,用户已删除)
|
||||
|
||||
#### 3.1 test_material_search_dialog.py
|
||||
- 从 CSV 动态加载 Modbus 地址
|
||||
- 支持 4 种测试模式:
|
||||
- `query` - 查询所有状态
|
||||
- `dialog <0|1>` - 设置弹窗出现/消失
|
||||
- `yes` - 脉冲点击"是"按钮
|
||||
- `no` - 脉冲点击"否"按钮
|
||||
- 兼容 pymodbus 3.x API
|
||||
|
||||
#### 3.2 更新其他测试脚本
|
||||
- `test_coin_cell_reset.py` - 更新为 pymodbus 3.x API
|
||||
- `test_unilab_interact.py` - 更新为 pymodbus 3.x API
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 参数说明
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `material_search_enable` | boolean | `false` | 是否启用物料搜寻功能 |
|
||||
|
||||
### 调用示例
|
||||
|
||||
#### 1. 不启用物料搜寻(默认)
|
||||
```python
|
||||
# 默认参数,点击"否"按钮
|
||||
await device.func_pack_device_init_auto_start_combined()
|
||||
```
|
||||
|
||||
或在 YAML workflow 中:
|
||||
```yaml
|
||||
# 使用默认值 false,不启用物料搜寻
|
||||
- BatteryStation/auto-func_pack_device_init_auto_start_combined: {}
|
||||
```
|
||||
|
||||
#### 2. 启用物料搜寻
|
||||
```python
|
||||
# 显式设置为 True,点击"是"按钮
|
||||
await device.func_pack_device_init_auto_start_combined(
|
||||
material_search_enable=True
|
||||
)
|
||||
```
|
||||
|
||||
或在 YAML workflow 中:
|
||||
```yaml
|
||||
- BatteryStation/auto-func_pack_device_init_auto_start_combined:
|
||||
goal:
|
||||
material_search_enable: true # 启用物料搜寻
|
||||
```
|
||||
|
||||
## 执行日志示例
|
||||
|
||||
```
|
||||
26-01-13 [21:32:44] [INFO] 开始组合操作:设备初始化 → 物料搜寻确认 → 自动模式 → 启动
|
||||
26-01-13 [21:32:44] [INFO] 【步骤 0/4】前置条件检查...
|
||||
26-01-13 [21:32:44] [INFO] ✓ REG_UNILAB_INTERACT 检查通过
|
||||
26-01-13 [21:32:44] [INFO] ✓ COIL_GB_L_IGNORE_CMD 检查通过
|
||||
26-01-13 [21:32:44] [INFO] 【步骤 1/4】设备初始化...
|
||||
26-01-13 [21:32:44] [INFO] 切换手动模式...
|
||||
26-01-13 [21:32:46] [INFO] 发送初始化命令...
|
||||
26-01-13 [21:32:47] [INFO] 等待初始化完成(同时监测物料搜寻弹窗)...
|
||||
26-01-13 [21:33:05] [INFO] ✓ 在初始化过程中检测到物料搜寻确认弹窗!
|
||||
26-01-13 [21:33:05] [INFO] 用户选择: 不启用物料搜寻(点击否)
|
||||
26-01-13 [21:33:05] [INFO] → 按下按钮 '否'
|
||||
26-01-13 [21:33:06] [INFO] → 释放按钮 '否'
|
||||
26-01-13 [21:33:07] [INFO] ✓ 成功处理物料搜寻确认弹窗(选择: 否)
|
||||
26-01-13 [21:33:08] [INFO] ✓ 初始化状态完成
|
||||
26-01-13 [21:33:12] [INFO] ✓ 设备初始化完成
|
||||
26-01-13 [21:33:12] [INFO] 【步骤 1.5/4】物料搜寻确认已在初始化过程中完成
|
||||
26-01-13 [21:33:12] [INFO] 【步骤 2/4】切换自动模式...
|
||||
26-01-13 [21:33:15] [INFO] ✓ 切换自动模式完成
|
||||
26-01-13 [21:33:15] [INFO] 【步骤 3/4】启动设备...
|
||||
26-01-13 [21:33:18] [INFO] ✓ 启动设备完成
|
||||
26-01-13 [21:33:18] [INFO] 组合操作完成:设备已成功初始化、确认物料搜寻、切换自动模式并启动
|
||||
```
|
||||
|
||||
## 技术要点
|
||||
|
||||
### 1. 脉冲模式按钮操作
|
||||
模拟真实按钮按压过程:
|
||||
1. 写入 `True` (按下)
|
||||
2. 保持 0.5 秒
|
||||
3. 写入 `False` (释放)
|
||||
4. 验证状态
|
||||
|
||||
### 2. 避免死锁
|
||||
**问题:** PLC 在初始化过程中等待弹窗确认,而代码等待初始化完成
|
||||
**解决:** 在初始化等待循环中实时检测弹窗,一旦出现立即处理
|
||||
|
||||
### 3. 超时保护
|
||||
- 弹窗检测超时:30 秒(在 `_handle_material_search_dialog` 中)
|
||||
- 初始化超时:120 秒(在 `func_pack_device_init_auto_start_combined` 中)
|
||||
|
||||
### 4. PyModbus 3.x API 兼容
|
||||
所有 Modbus 操作使用 keyword arguments:
|
||||
```python
|
||||
# 读取
|
||||
client.read_coils(address=5176, count=1)
|
||||
|
||||
# 写入
|
||||
client.write_coil(address=5184, value=True)
|
||||
```
|
||||
|
||||
## 向后兼容性
|
||||
|
||||
### 保留的原有函数
|
||||
- `func_pack_device_init()` - 单独的初始化函数,不包含弹窗处理
|
||||
- 仍可在 YAML 中通过 `auto-func_pack_device_init` 调用
|
||||
- 用于不需要自动处理弹窗的场景
|
||||
|
||||
### 新增的功能
|
||||
- 在 `func_pack_device_init_auto_start_combined()` 中集成弹窗处理
|
||||
- 通过参数控制,默认行为与之前兼容(点击"否")
|
||||
|
||||
## 验证测试
|
||||
|
||||
### 测试场景
|
||||
|
||||
#### 场景 1:默认参数(不启用物料搜寻)
|
||||
```bash
|
||||
# 调用时不传参数
|
||||
BatteryStation/auto-func_pack_device_init_auto_start_combined: {}
|
||||
```
|
||||
**预期结果:**
|
||||
- ✅ 检测到弹窗
|
||||
- ✅ 自动点击"否"按钮
|
||||
- ✅ 初始化完成并启动成功
|
||||
|
||||
#### 场景 2:启用物料搜寻
|
||||
```bash
|
||||
# 设置 material_search_enable=true
|
||||
BatteryStation/auto-func_pack_device_init_auto_start_combined:
|
||||
goal:
|
||||
material_search_enable: true
|
||||
```
|
||||
**预期结果:**
|
||||
- ✅ 检测到弹窗
|
||||
- ✅ 自动点击"是"按钮
|
||||
- ✅ 初始化完成并启动成功
|
||||
|
||||
### 实际测试结果
|
||||
|
||||
**测试时间:** 2026-01-13 21:32:43
|
||||
**测试参数:** `material_search_enable: false`
|
||||
**测试结果:** ✅ 成功
|
||||
|
||||
**关键时间节点:**
|
||||
- 21:33:05 - 检测到弹窗
|
||||
- 21:33:05 - 按下"否"按钮
|
||||
- 21:33:06 - 释放"否"按钮
|
||||
- 21:33:07 - 弹窗处理完成
|
||||
- 21:33:08 - 初始化状态完成
|
||||
- 21:33:18 - 整个流程完成
|
||||
|
||||
**总耗时:** 约 35 秒(包含初始化全过程)
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **CSV 配置依赖**
|
||||
- 确保 `coin_cell_assembly_b.csv` 包含 69-71 行的 coil 配置
|
||||
- 地址转换逻辑:`modbus_addr = (csv_addr // 10) * 8 + (csv_addr % 10)`
|
||||
|
||||
2. **默认行为**
|
||||
- 默认 `material_search_enable=false`,即不启用物料搜寻
|
||||
- 如需启用,必须显式设置为 `true`
|
||||
|
||||
3. **日志级别**
|
||||
- 弹窗检测过程中的 `waiting for init_cmd` 使用 DEBUG 级别
|
||||
- 关键操作(检测到弹窗、按钮操作)使用 INFO 级别
|
||||
|
||||
4. **原有函数保留**
|
||||
- `func_pack_device_init()` 仍然可用,但不包含弹窗处理
|
||||
- 如果单独调用此函数,仍需手动处理弹窗
|
||||
|
||||
## 文件清单
|
||||
|
||||
### 修改的文件
|
||||
1. `d:\UniLabdev\Uni-Lab-OS\unilabos\devices\workstation\coin_cell_assembly\coin_cell_assembly.py`
|
||||
- 新增 `_handle_material_search_dialog()` 方法
|
||||
- 修改 `func_pack_device_init_auto_start_combined()` 函数
|
||||
|
||||
2. `d:\UniLabdev\Uni-Lab-OS\unilabos\registry\devices\coin_cell_workstation.yaml`
|
||||
- 更新 `auto-func_pack_device_init_auto_start_combined` 配置
|
||||
- 添加 `material_search_enable` 参数说明
|
||||
|
||||
3. `d:\UniLabdev\Uni-Lab-OS\unilabos\devices\workstation\coin_cell_assembly\coin_cell_assembly_b.csv`
|
||||
- 第 69-71 行添加三个 coil 配置
|
||||
|
||||
### 创建的测试文件(已删除)
|
||||
1. `test_material_search_dialog.py` - 物料搜寻弹窗测试脚本
|
||||
2. `test_coin_cell_reset.py` - 复位功能测试(更新为 pymodbus 3.x)
|
||||
3. `test_unilab_interact.py` - Unilab 交互测试(更新为 pymodbus 3.x)
|
||||
|
||||
## 总结
|
||||
|
||||
本次更新成功实现了设备初始化过程中物料搜寻确认弹窗的自动化处理,主要优势:
|
||||
|
||||
✅ **全自动化** - 无需人工干预
|
||||
✅ **参数可配** - 灵活控制是否启用物料搜寻
|
||||
✅ **实时检测** - 在初始化等待循环中检测,避免死锁
|
||||
✅ **脉冲模式** - 模拟真实按钮操作
|
||||
✅ **向后兼容** - 保留原有函数,不影响现有流程
|
||||
✅ **完整日志** - 详细记录每一步操作
|
||||
✅ **超时保护** - 防止无限等待
|
||||
|
||||
该功能已通过实际测试验证,可投入生产使用。
|
||||
|
||||
---
|
||||
|
||||
**文档版本:** 1.0
|
||||
**创建日期:** 2026-01-13
|
||||
**作者:** Antigravity AI Assistant
|
||||
**最后更新:** 2026-01-13 21:36
|
||||
@@ -0,0 +1,645 @@
|
||||
"""
|
||||
纽扣电池组装工作站物料类定义
|
||||
Button Battery Assembly Station Resource Classes
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
from typing import Any, Dict, List, Optional, TypedDict, Union, cast
|
||||
|
||||
from pylabrobot.resources.coordinate import Coordinate
|
||||
from pylabrobot.resources.container import Container
|
||||
from pylabrobot.resources.deck import Deck
|
||||
from pylabrobot.resources.itemized_resource import ItemizedResource
|
||||
from pylabrobot.resources.resource import Resource
|
||||
from pylabrobot.resources.resource_stack import ResourceStack
|
||||
from pylabrobot.resources.tip_rack import TipRack, TipSpot
|
||||
from pylabrobot.resources.trash import Trash
|
||||
from pylabrobot.resources.utils import create_ordered_items_2d
|
||||
|
||||
from unilabos.resources.battery.magazine import MagazineHolder_4_Cathode, MagazineHolder_6_Cathode, MagazineHolder_6_Anode, MagazineHolder_6_Battery
|
||||
from unilabos.resources.battery.bottle_carriers import YIHUA_Electrolyte_12VialCarrier
|
||||
from unilabos.resources.battery.electrode_sheet import ElectrodeSheet
|
||||
|
||||
|
||||
|
||||
# TODO: 这个应该只能放一个极片
|
||||
class MaterialHoleState(TypedDict):
|
||||
diameter: int
|
||||
depth: int
|
||||
max_sheets: int
|
||||
info: Optional[str] # 附加信息
|
||||
|
||||
class MaterialHole(Resource):
|
||||
"""料板洞位类"""
|
||||
children: List[ElectrodeSheet] = []
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
size_x: float,
|
||||
size_y: float,
|
||||
size_z: float,
|
||||
category: str = "material_hole",
|
||||
**kwargs
|
||||
):
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
category=category,
|
||||
)
|
||||
self._unilabos_state: MaterialHoleState = MaterialHoleState(
|
||||
diameter=20,
|
||||
depth=10,
|
||||
max_sheets=1,
|
||||
info=None
|
||||
)
|
||||
|
||||
def get_all_sheet_info(self):
|
||||
info_list = []
|
||||
for sheet in self.children:
|
||||
info_list.append(sheet._unilabos_state["info"])
|
||||
return info_list
|
||||
|
||||
#这个函数函数好像没用,一般不会集中赋值质量
|
||||
def set_all_sheet_mass(self):
|
||||
for sheet in self.children:
|
||||
sheet._unilabos_state["mass"] = 0.5 # 示例:设置质量为0.5g
|
||||
|
||||
def load_state(self, state: Dict[str, Any]) -> None:
|
||||
"""格式不变"""
|
||||
super().load_state(state)
|
||||
self._unilabos_state = state
|
||||
|
||||
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""格式不变"""
|
||||
data = super().serialize_state()
|
||||
data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等)
|
||||
return data
|
||||
#移动极片前先取出对象
|
||||
def get_sheet_with_name(self, name: str) -> Optional[ElectrodeSheet]:
|
||||
for sheet in self.children:
|
||||
if sheet.name == name:
|
||||
return sheet
|
||||
return None
|
||||
|
||||
def has_electrode_sheet(self) -> bool:
|
||||
"""检查洞位是否有极片"""
|
||||
return len(self.children) > 0
|
||||
|
||||
def assign_child_resource(
|
||||
self,
|
||||
resource: ElectrodeSheet,
|
||||
location: Optional[Coordinate],
|
||||
reassign: bool = True,
|
||||
):
|
||||
"""放置极片"""
|
||||
# TODO: 这里要改,diameter找不到,加入._unilabos_state后应该没问题
|
||||
#if resource._unilabos_state["diameter"] > self._unilabos_state["diameter"]:
|
||||
# raise ValueError(f"极片直径 {resource._unilabos_state['diameter']} 超过洞位直径 {self._unilabos_state['diameter']}")
|
||||
#if len(self.children) >= self._unilabos_state["max_sheets"]:
|
||||
# raise ValueError(f"洞位已满,无法放置更多极片")
|
||||
super().assign_child_resource(resource, location, reassign)
|
||||
|
||||
# 根据children的编号取物料对象。
|
||||
def get_electrode_sheet_info(self, index: int) -> ElectrodeSheet:
|
||||
return self.children[index]
|
||||
|
||||
|
||||
class MaterialPlateState(TypedDict):
|
||||
hole_spacing_x: float
|
||||
hole_spacing_y: float
|
||||
hole_diameter: float
|
||||
info: Optional[str] # 附加信息
|
||||
|
||||
class MaterialPlate(ItemizedResource[MaterialHole]):
|
||||
"""料板类 - 4x4个洞位,每个洞位放1个极片"""
|
||||
|
||||
children: List[MaterialHole]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
size_x: float,
|
||||
size_y: float,
|
||||
size_z: float,
|
||||
ordered_items: Optional[Dict[str, MaterialHole]] = None,
|
||||
ordering: Optional[OrderedDict[str, str]] = None,
|
||||
category: str = "material_plate",
|
||||
model: Optional[str] = None,
|
||||
fill: bool = False
|
||||
):
|
||||
"""初始化料板
|
||||
|
||||
Args:
|
||||
name: 料板名称
|
||||
size_x: 长度 (mm)
|
||||
size_y: 宽度 (mm)
|
||||
size_z: 高度 (mm)
|
||||
hole_diameter: 洞直径 (mm)
|
||||
hole_depth: 洞深度 (mm)
|
||||
hole_spacing_x: X方向洞位间距 (mm)
|
||||
hole_spacing_y: Y方向洞位间距 (mm)
|
||||
number: 编号
|
||||
category: 类别
|
||||
model: 型号
|
||||
"""
|
||||
self._unilabos_state: MaterialPlateState = MaterialPlateState(
|
||||
hole_spacing_x=24.0,
|
||||
hole_spacing_y=24.0,
|
||||
hole_diameter=20.0,
|
||||
info="",
|
||||
)
|
||||
# 创建4x4的洞位
|
||||
# TODO: 这里要改,对应不同形状
|
||||
holes = create_ordered_items_2d(
|
||||
klass=MaterialHole,
|
||||
num_items_x=4,
|
||||
num_items_y=4,
|
||||
dx=(size_x - 4 * self._unilabos_state["hole_spacing_x"]) / 2, # 居中
|
||||
dy=(size_y - 4 * self._unilabos_state["hole_spacing_y"]) / 2, # 居中
|
||||
dz=size_z,
|
||||
item_dx=self._unilabos_state["hole_spacing_x"],
|
||||
item_dy=self._unilabos_state["hole_spacing_y"],
|
||||
size_x = 16,
|
||||
size_y = 16,
|
||||
size_z = 16,
|
||||
)
|
||||
if fill:
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
ordered_items=holes,
|
||||
category=category,
|
||||
model=model,
|
||||
)
|
||||
else:
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
ordered_items=ordered_items,
|
||||
ordering=ordering,
|
||||
category=category,
|
||||
model=model,
|
||||
)
|
||||
|
||||
def update_locations(self):
|
||||
# TODO:调多次相加
|
||||
holes = create_ordered_items_2d(
|
||||
klass=MaterialHole,
|
||||
num_items_x=4,
|
||||
num_items_y=4,
|
||||
dx=(self._size_x - 3 * self._unilabos_state["hole_spacing_x"]) / 2, # 居中
|
||||
dy=(self._size_y - 3 * self._unilabos_state["hole_spacing_y"]) / 2, # 居中
|
||||
dz=self._size_z,
|
||||
item_dx=self._unilabos_state["hole_spacing_x"],
|
||||
item_dy=self._unilabos_state["hole_spacing_y"],
|
||||
size_x = 1,
|
||||
size_y = 1,
|
||||
size_z = 1,
|
||||
)
|
||||
for item, original_item in zip(holes.items(), self.children):
|
||||
original_item.location = item[1].location
|
||||
|
||||
|
||||
class PlateSlot(ResourceStack):
|
||||
"""板槽位类 - 1个槽上能堆放8个板,移板只能操作最上方的板"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
size_x: float,
|
||||
size_y: float,
|
||||
size_z: float,
|
||||
max_plates: int = 8,
|
||||
category: str = "plate_slot",
|
||||
model: Optional[str] = None
|
||||
):
|
||||
"""初始化板槽位
|
||||
|
||||
Args:
|
||||
name: 槽位名称
|
||||
max_plates: 最大板数量
|
||||
category: 类别
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
direction="z", # Z方向堆叠
|
||||
resources=[],
|
||||
)
|
||||
self.max_plates = max_plates
|
||||
self.category = category
|
||||
|
||||
def can_add_plate(self) -> bool:
|
||||
"""检查是否可以添加板"""
|
||||
return len(self.children) < self.max_plates
|
||||
|
||||
def add_plate(self, plate: MaterialPlate) -> None:
|
||||
"""添加料板"""
|
||||
if not self.can_add_plate():
|
||||
raise ValueError(f"槽位 {self.name} 已满,无法添加更多板")
|
||||
self.assign_child_resource(plate)
|
||||
|
||||
def get_top_plate(self) -> MaterialPlate:
|
||||
"""获取最上方的板"""
|
||||
if len(self.children) == 0:
|
||||
raise ValueError(f"槽位 {self.name} 为空")
|
||||
return cast(MaterialPlate, self.get_top_item())
|
||||
|
||||
def take_top_plate(self) -> MaterialPlate:
|
||||
"""取出最上方的板"""
|
||||
top_plate = self.get_top_plate()
|
||||
self.unassign_child_resource(top_plate)
|
||||
return top_plate
|
||||
|
||||
def can_access_for_picking(self) -> bool:
|
||||
"""检查是否可以进行取料操作(只有最上方的板能进行取料操作)"""
|
||||
return len(self.children) > 0
|
||||
|
||||
def serialize(self) -> dict:
|
||||
return {
|
||||
**super().serialize(),
|
||||
"max_plates": self.max_plates,
|
||||
}
|
||||
|
||||
|
||||
#是一种类型注解,不用self
|
||||
class BatteryState(TypedDict):
|
||||
"""电池状态字典"""
|
||||
diameter: float
|
||||
height: float
|
||||
assembly_pressure: float
|
||||
electrolyte_volume: float
|
||||
electrolyte_name: str
|
||||
|
||||
class Battery(Resource):
|
||||
"""电池类 - 可容纳极片"""
|
||||
children: List[ElectrodeSheet] = []
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
size_x=1,
|
||||
size_y=1,
|
||||
size_z=1,
|
||||
category: str = "battery",
|
||||
):
|
||||
"""初始化电池
|
||||
|
||||
Args:
|
||||
name: 电池名称
|
||||
diameter: 直径 (mm)
|
||||
height: 高度 (mm)
|
||||
max_volume: 最大容量 (μL)
|
||||
barcode: 二维码编号
|
||||
category: 类别
|
||||
model: 型号
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=1,
|
||||
size_y=1,
|
||||
size_z=1,
|
||||
category=category,
|
||||
)
|
||||
self._unilabos_state: BatteryState = BatteryState(
|
||||
diameter = 1.0,
|
||||
height = 1.0,
|
||||
assembly_pressure = 1.0,
|
||||
electrolyte_volume = 1.0,
|
||||
electrolyte_name = "DP001"
|
||||
)
|
||||
|
||||
def add_electrolyte_with_bottle(self, bottle: Bottle) -> bool:
|
||||
to_add_name = bottle._unilabos_state["electrolyte_name"]
|
||||
if bottle.aspirate_electrolyte(10):
|
||||
if self.add_electrolyte(to_add_name, 10):
|
||||
pass
|
||||
else:
|
||||
bottle._unilabos_state["electrolyte_volume"] += 10
|
||||
|
||||
def set_electrolyte(self, name: str, volume: float) -> None:
|
||||
"""设置电解液信息"""
|
||||
self._unilabos_state["electrolyte_name"] = name
|
||||
self._unilabos_state["electrolyte_volume"] = volume
|
||||
#这个应该没用,不会有加了后再加的事情
|
||||
def add_electrolyte(self, name: str, volume: float) -> bool:
|
||||
"""添加电解液信息"""
|
||||
if name != self._unilabos_state["electrolyte_name"]:
|
||||
return False
|
||||
self._unilabos_state["electrolyte_volume"] += volume
|
||||
|
||||
def load_state(self, state: Dict[str, Any]) -> None:
|
||||
"""格式不变"""
|
||||
super().load_state(state)
|
||||
self._unilabos_state = state
|
||||
|
||||
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""格式不变"""
|
||||
data = super().serialize_state()
|
||||
data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等)
|
||||
return data
|
||||
|
||||
# 电解液作为属性放进去
|
||||
|
||||
class BatteryPressSlotState(TypedDict):
|
||||
"""电池状态字典"""
|
||||
diameter: float =20.0
|
||||
depth: float = 4.0
|
||||
|
||||
class BatteryPressSlot(Resource):
|
||||
"""电池压制槽类 - 设备,可容纳一个电池"""
|
||||
children: List[Battery] = []
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "BatteryPressSlot",
|
||||
category: str = "battery_press_slot",
|
||||
):
|
||||
"""初始化电池压制槽
|
||||
|
||||
Args:
|
||||
name: 压制槽名称
|
||||
diameter: 直径 (mm)
|
||||
depth: 深度 (mm)
|
||||
category: 类别
|
||||
model: 型号
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=10,
|
||||
size_y=12,
|
||||
size_z=13,
|
||||
category=category,
|
||||
)
|
||||
self._unilabos_state: BatteryPressSlotState = BatteryPressSlotState()
|
||||
|
||||
def has_battery(self) -> bool:
|
||||
"""检查是否有电池"""
|
||||
return len(self.children) > 0
|
||||
|
||||
def load_state(self, state: Dict[str, Any]) -> None:
|
||||
"""格式不变"""
|
||||
super().load_state(state)
|
||||
self._unilabos_state = state
|
||||
|
||||
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""格式不变"""
|
||||
data = super().serialize_state()
|
||||
data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等)
|
||||
return data
|
||||
|
||||
def assign_child_resource(
|
||||
self,
|
||||
resource: Battery,
|
||||
location: Optional[Coordinate],
|
||||
reassign: bool = True,
|
||||
):
|
||||
"""放置极片"""
|
||||
# TODO: 让高京看下槽位只有一个电池时是否这么写。
|
||||
if self.has_battery():
|
||||
raise ValueError(f"槽位已含有一个电池,无法再放置其他电池")
|
||||
super().assign_child_resource(resource, location, reassign)
|
||||
|
||||
# 根据children的编号取物料对象。
|
||||
def get_battery_info(self, index: int) -> Battery:
|
||||
return self.children[0]
|
||||
|
||||
|
||||
def TipBox64(
|
||||
name: str,
|
||||
size_x: float = 127.8,
|
||||
size_y: float = 85.5,
|
||||
size_z: float = 60.0,
|
||||
category: str = "tip_rack",
|
||||
model: Optional[str] = None,
|
||||
):
|
||||
"""64孔枪头盒类"""
|
||||
from pylabrobot.resources.tip import Tip
|
||||
|
||||
# 创建12x8=96个枪头位
|
||||
def make_tip():
|
||||
return Tip(
|
||||
has_filter=False,
|
||||
total_tip_length=20.0,
|
||||
maximal_volume=1000, # 1mL
|
||||
fitting_depth=8.0,
|
||||
)
|
||||
|
||||
tip_spots = create_ordered_items_2d(
|
||||
klass=TipSpot,
|
||||
num_items_x=12,
|
||||
num_items_y=8,
|
||||
dx=8.0,
|
||||
dy=8.0,
|
||||
dz=0.0,
|
||||
item_dx=9.0,
|
||||
item_dy=9.0,
|
||||
size_x=10,
|
||||
size_y=10,
|
||||
size_z=0.0,
|
||||
make_tip=make_tip,
|
||||
)
|
||||
idx_available = list(range(0, 32)) + list(range(64, 96))
|
||||
tip_spots_available = {k: v for i, (k, v) in enumerate(tip_spots.items()) if i in idx_available}
|
||||
tip_rack = TipRack(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
# ordered_items=tip_spots_available,
|
||||
ordered_items=tip_spots,
|
||||
category=category,
|
||||
model=model,
|
||||
with_tips=False,
|
||||
)
|
||||
tip_rack.set_tip_state([True]*32 + [False]*32 + [True]*32) # 前32和后32个有枪头,中间32个无枪头
|
||||
return tip_rack
|
||||
|
||||
|
||||
class WasteTipBoxstate(TypedDict):
|
||||
""""废枪头盒状态字典"""
|
||||
max_tips: int = 100
|
||||
tip_count: int = 0
|
||||
|
||||
#枪头不是一次性的(同一溶液则反复使用),根据寄存器判断
|
||||
class WasteTipBox(Trash):
|
||||
"""废枪头盒类 - 100个枪头容量"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
size_x: float = 127.8,
|
||||
size_y: float = 85.5,
|
||||
size_z: float = 60.0,
|
||||
material_z_thickness=0,
|
||||
max_volume=float("inf"),
|
||||
category="trash",
|
||||
model=None,
|
||||
compute_volume_from_height=None,
|
||||
compute_height_from_volume=None,
|
||||
):
|
||||
"""初始化废枪头盒
|
||||
|
||||
Args:
|
||||
name: 废枪头盒名称
|
||||
size_x: 长度 (mm)
|
||||
size_y: 宽度 (mm)
|
||||
size_z: 高度 (mm)
|
||||
max_tips: 最大枪头容量
|
||||
category: 类别
|
||||
model: 型号
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
category=category,
|
||||
model=model,
|
||||
)
|
||||
self._unilabos_state: WasteTipBoxstate = WasteTipBoxstate()
|
||||
|
||||
def add_tip(self) -> None:
|
||||
"""添加废枪头"""
|
||||
if self._unilabos_state["tip_count"] >= self._unilabos_state["max_tips"]:
|
||||
raise ValueError(f"废枪头盒 {self.name} 已满")
|
||||
self._unilabos_state["tip_count"] += 1
|
||||
|
||||
def get_tip_count(self) -> int:
|
||||
"""获取枪头数量"""
|
||||
return self._unilabos_state["tip_count"]
|
||||
|
||||
def empty(self) -> None:
|
||||
"""清空废枪头盒"""
|
||||
self._unilabos_state["tip_count"] = 0
|
||||
|
||||
|
||||
def load_state(self, state: Dict[str, Any]) -> None:
|
||||
"""格式不变"""
|
||||
super().load_state(state)
|
||||
self._unilabos_state = state
|
||||
|
||||
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""格式不变"""
|
||||
data = super().serialize_state()
|
||||
data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等)
|
||||
return data
|
||||
|
||||
|
||||
class CoincellDeck(Deck):
|
||||
"""纽扣电池组装工作站台面类"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "coin_cell_deck",
|
||||
size_x: float = 1450.0, # 1m
|
||||
size_y: float = 1450.0, # 1m
|
||||
size_z: float = 100.0, # 0.9m
|
||||
origin: Coordinate = Coordinate(-2200, 0, 0),
|
||||
category: str = "coin_cell_deck",
|
||||
setup: bool = False, # 是否自动执行 setup
|
||||
):
|
||||
"""初始化纽扣电池组装工作站台面
|
||||
|
||||
Args:
|
||||
name: 台面名称
|
||||
size_x: 长度 (mm) - 1m
|
||||
size_y: 宽度 (mm) - 1m
|
||||
size_z: 高度 (mm) - 0.9m
|
||||
origin: 原点坐标
|
||||
category: 类别
|
||||
setup: 是否自动执行 setup 配置标准布局
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=1450.0,
|
||||
size_y=1450.0,
|
||||
size_z=100.0,
|
||||
origin=origin,
|
||||
)
|
||||
if setup:
|
||||
self.setup()
|
||||
|
||||
def setup(self) -> None:
|
||||
"""设置工作站的标准布局 - 包含子弹夹、料盘、瓶架等完整配置"""
|
||||
# ====================================== 子弹夹 ============================================
|
||||
|
||||
# 正极片(4个洞位,2x2布局)
|
||||
zhengji_zip = MagazineHolder_4_Cathode("正极&铝箔弹夹")
|
||||
self.assign_child_resource(zhengji_zip, Coordinate(x=402.0, y=830.0, z=0))
|
||||
|
||||
# 正极壳、平垫片(6个洞位,2x2+2布局)
|
||||
zhengjike_zip = MagazineHolder_6_Cathode("正极壳&平垫片弹夹")
|
||||
self.assign_child_resource(zhengjike_zip, Coordinate(x=566.0, y=272.0, z=0))
|
||||
|
||||
# 负极壳、弹垫片(6个洞位,2x2+2布局)
|
||||
fujike_zip = MagazineHolder_6_Anode("负极壳&弹垫片弹夹")
|
||||
self.assign_child_resource(fujike_zip, Coordinate(x=474.0, y=276.0, z=0))
|
||||
|
||||
# 成品弹夹(6个洞位,3x2布局)
|
||||
chengpindanjia_zip = MagazineHolder_6_Battery("成品弹夹")
|
||||
self.assign_child_resource(chengpindanjia_zip, Coordinate(x=260.0, y=156.0, z=0))
|
||||
|
||||
# ====================================== 物料板 ============================================
|
||||
# 创建物料板(料盘carrier)- 4x4布局
|
||||
# 负极料盘
|
||||
fujiliaopan = MaterialPlate(name="负极料盘", size_x=120, size_y=100, size_z=10.0, fill=True)
|
||||
self.assign_child_resource(fujiliaopan, Coordinate(x=708.0, y=794.0, z=0))
|
||||
# for i in range(16):
|
||||
# fujipian = ElectrodeSheet(name=f"{fujiliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
|
||||
# fujiliaopan.children[i].assign_child_resource(fujipian, location=None)
|
||||
|
||||
# 隔膜料盘
|
||||
gemoliaopan = MaterialPlate(name="隔膜料盘", size_x=120, size_y=100, size_z=10.0, fill=True)
|
||||
self.assign_child_resource(gemoliaopan, Coordinate(x=718.0, y=918.0, z=0))
|
||||
# for i in range(16):
|
||||
# gemopian = ElectrodeSheet(name=f"{gemoliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
|
||||
# gemoliaopan.children[i].assign_child_resource(gemopian, location=None)
|
||||
|
||||
# ====================================== 瓶架、移液枪 ============================================
|
||||
# 在台面上放置 3x4 瓶架、6x2 瓶架 与 64孔移液枪头盒
|
||||
# 奔耀上料5ml分液瓶小板 - 由奔曜跨站转运而来,不单独写,但是这里应该有一个堆栈用于摆放分液瓶小板
|
||||
|
||||
# bottle_rack_3x4 = BottleRack(
|
||||
# name="bottle_rack_3x4",
|
||||
# size_x=210.0,
|
||||
# size_y=140.0,
|
||||
# size_z=100.0,
|
||||
# num_items_x=2,
|
||||
# num_items_y=4,
|
||||
# position_spacing=35.0,
|
||||
# orientation="vertical",
|
||||
# )
|
||||
# self.assign_child_resource(bottle_rack_3x4, Coordinate(x=1542.0, y=717.0, z=0))
|
||||
|
||||
# 电解液缓存位 - 6x2布局
|
||||
bottle_rack_6x2 = YIHUA_Electrolyte_12VialCarrier(name="bottle_rack_6x2")
|
||||
self.assign_child_resource(bottle_rack_6x2, Coordinate(x=1050.0, y=358.0, z=0))
|
||||
# 电解液回收位6x2
|
||||
bottle_rack_6x2_2 = YIHUA_Electrolyte_12VialCarrier(name="bottle_rack_6x2_2")
|
||||
self.assign_child_resource(bottle_rack_6x2_2, Coordinate(x=914.0, y=358.0, z=0))
|
||||
|
||||
tip_box = TipBox64(name="tip_box_64")
|
||||
self.assign_child_resource(tip_box, Coordinate(x=782.0, y=514.0, z=0))
|
||||
|
||||
waste_tip_box = WasteTipBox(name="waste_tip_box")
|
||||
self.assign_child_resource(waste_tip_box, Coordinate(x=778.0, y=622.0, z=0))
|
||||
|
||||
|
||||
def YH_Deck(name=""):
|
||||
cd = CoincellDeck(name=name)
|
||||
cd.setup()
|
||||
return cd
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
deck = create_coin_cell_deck()
|
||||
print(deck)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,133 @@
|
||||
Name,DataType,InitValue,Comment,Attribute,DeviceType,Address,
|
||||
COIL_SYS_START_CMD,BOOL,,,,coil,8010,
|
||||
COIL_SYS_STOP_CMD,BOOL,,,,coil,8020,
|
||||
COIL_SYS_RESET_CMD,BOOL,,,,coil,8030,
|
||||
COIL_SYS_HAND_CMD,BOOL,,,,coil,8040,
|
||||
COIL_SYS_AUTO_CMD,BOOL,,,,coil,8050,
|
||||
COIL_SYS_INIT_CMD,BOOL,,,,coil,8060,
|
||||
COIL_UNILAB_SEND_MSG_SUCC_CMD,BOOL,,,,coil,8700,
|
||||
COIL_UNILAB_REC_MSG_SUCC_CMD,BOOL,,,,coil,8710,unilab_rec_msg_succ_cmd
|
||||
COIL_SYS_START_STATUS,BOOL,,,,coil,8210,
|
||||
COIL_SYS_STOP_STATUS,BOOL,,,,coil,8220,
|
||||
COIL_SYS_RESET_STATUS,BOOL,,,,coil,8230,
|
||||
COIL_SYS_HAND_STATUS,BOOL,,,,coil,8240,
|
||||
COIL_SYS_AUTO_STATUS,BOOL,,,,coil,8250,
|
||||
COIL_SYS_INIT_STATUS,BOOL,,,,coil,8260,
|
||||
COIL_REQUEST_REC_MSG_STATUS,BOOL,,,,coil,8500,
|
||||
COIL_REQUEST_SEND_MSG_STATUS,BOOL,,,,coil,8510,request_send_msg_status
|
||||
REG_MSG_ELECTROLYTE_USE_NUM,INT16,,,,hold_register,11000,
|
||||
REG_MSG_ELECTROLYTE_NUM,INT16,,,,hold_register,11002,unilab_send_msg_electrolyte_num
|
||||
REG_MSG_ELECTROLYTE_VOLUME,INT16,,,,hold_register,11004,unilab_send_msg_electrolyte_vol
|
||||
REG_MSG_ASSEMBLY_TYPE,INT16,,,,hold_register,11006,unilab_send_msg_assembly_type
|
||||
REG_MSG_ASSEMBLY_PRESSURE,INT16,,,,hold_register,11008,unilab_send_msg_assembly_pressure
|
||||
REG_DATA_ASSEMBLY_COIN_CELL_NUM,INT16,,,,hold_register,10000,data_assembly_coin_cell_num
|
||||
REG_DATA_OPEN_CIRCUIT_VOLTAGE,FLOAT32,,,,hold_register,10002,data_open_circuit_voltage
|
||||
REG_DATA_AXIS_X_POS,FLOAT32,,,,hold_register,10004,
|
||||
REG_DATA_AXIS_Y_POS,FLOAT32,,,,hold_register,10006,
|
||||
REG_DATA_AXIS_Z_POS,FLOAT32,,,,hold_register,10008,
|
||||
REG_DATA_POLE_WEIGHT,FLOAT32,,,,hold_register,10010,data_pole_weight
|
||||
REG_DATA_ASSEMBLY_PER_TIME,FLOAT32,,,,hold_register,10012,data_assembly_time
|
||||
REG_DATA_ASSEMBLY_PRESSURE,INT16,,,,hold_register,10014,data_assembly_pressure
|
||||
REG_DATA_ELECTROLYTE_VOLUME,INT16,,,,hold_register,10016,data_electrolyte_volume
|
||||
REG_DATA_COIN_NUM,INT16,,,,hold_register,10018,data_coin_num
|
||||
REG_DATA_ELECTROLYTE_CODE,STRING,,,,hold_register,10020,data_electrolyte_code()
|
||||
REG_DATA_COIN_CELL_CODE,STRING,,,,hold_register,10030,data_coin_cell_code()
|
||||
REG_DATA_STACK_VISON_CODE,STRING,,,,hold_register,12004,data_stack_vision_code()
|
||||
REG_DATA_GLOVE_BOX_PRESSURE,FLOAT32,,,,hold_register,10050,data_glove_box_pressure
|
||||
REG_DATA_GLOVE_BOX_WATER_CONTENT,FLOAT32,,,,hold_register,10052,data_glove_box_water_content
|
||||
REG_DATA_GLOVE_BOX_O2_CONTENT,FLOAT32,,,,hold_register,10054,data_glove_box_o2_content
|
||||
UNILAB_SEND_ELECTROLYTE_BOTTLE_NUM,BOOL,,,,coil,8720,
|
||||
UNILAB_RECE_ELECTROLYTE_BOTTLE_NUM,BOOL,,,,coil,8520,
|
||||
REG_MSG_ELECTROLYTE_NUM_USED,INT16,,,,hold_register,496,
|
||||
REG_DATA_ELECTROLYTE_USE_NUM,INT16,,,,hold_register,10000,
|
||||
UNILAB_SEND_FINISHED_CMD,BOOL,,,,coil,8730,
|
||||
UNILAB_RECE_FINISHED_CMD,BOOL,,,,coil,8530,
|
||||
REG_DATA_ASSEMBLY_TYPE,INT16,,,,hold_register,10018,ASSEMBLY_TYPE7or8
|
||||
REG_UNILAB_INTERACT,BOOL,,,,coil,8450,
|
||||
,,,,,coil,8320,
|
||||
COIL_ALUMINUM_FOIL,BOOL,,,,coil,8340,
|
||||
REG_MSG_NE_PLATE_MATRIX,INT16,,,,hold_register,440,
|
||||
REG_MSG_SEPARATOR_PLATE_MATRIX,INT16,,,,hold_register,450,
|
||||
REG_MSG_TIP_BOX_MATRIX,INT16,,,,hold_register,480,
|
||||
REG_MSG_NE_PLATE_NUM,INT16,,,,hold_register,443,
|
||||
REG_MSG_SEPARATOR_PLATE_NUM,INT16,,,,hold_register,453,
|
||||
REG_MSG_PRESS_MODE,BOOL,,,,coil,8360,
|
||||
,BOOL,,,,coil,8300,
|
||||
,BOOL,,,,coil,8310,
|
||||
COIL_GB_L_IGNORE_CMD,BOOL,,,,coil,8320,
|
||||
COIL_GB_R_IGNORE_CMD,BOOL,,,,coil,8420,
|
||||
,BOOL,,,,coil,8350,
|
||||
COIL_ELECTROLYTE_DUAL_DROP_MODE,BOOL,,,,coil,8370,
|
||||
,BOOL,,,,coil,8380,
|
||||
,BOOL,,,,coil,8390,
|
||||
,BOOL,,,,coil,8400,
|
||||
,BOOL,,,,coil,8410,
|
||||
REG_MSG_DUAL_DROP_FIRST_VOLUME,INT16,,,,hold_register,4001,
|
||||
COIL_DUAL_DROP_SUCTION_TIMING,BOOL,,,,coil,8430,
|
||||
COIL_DUAL_DROP_START_TIMING,BOOL,,,,coil,8470,
|
||||
REG_MSG_BATTERY_CLEAN_IGNORE,BOOL,,,,coil,8460,
|
||||
COIL_MATERIAL_SEARCH_DIALOG_APPEAR,BOOL,,,,coil,6470,
|
||||
COIL_MATERIAL_SEARCH_CONFIRM_YES,BOOL,,,,coil,6480,
|
||||
COIL_MATERIAL_SEARCH_CONFIRM_NO,BOOL,,,,coil,6490,
|
||||
COIL_ALARM_100_SYSTEM_ERROR,BOOL,,,,coil,1000,异常100-系统异常
|
||||
COIL_ALARM_101_EMERGENCY_STOP,BOOL,,,,coil,1010,异常101-急停
|
||||
COIL_ALARM_111_GLOVEBOX_EMERGENCY_STOP,BOOL,,,,coil,1110,异常111-手套箱急停
|
||||
COIL_ALARM_112_GLOVEBOX_GRATING_BLOCKED,BOOL,,,,coil,1120,异常112-手套箱内光栅遮挡
|
||||
COIL_ALARM_160_PIPETTE_TIP_SHORTAGE,BOOL,,,,coil,1600,异常160-移液枪头缺料
|
||||
COIL_ALARM_161_POSITIVE_SHELL_SHORTAGE,BOOL,,,,coil,1610,异常161-正极壳缺料
|
||||
COIL_ALARM_162_ALUMINUM_FOIL_SHORTAGE,BOOL,,,,coil,1620,异常162-铝箔垫缺料
|
||||
COIL_ALARM_163_POSITIVE_PLATE_SHORTAGE,BOOL,,,,coil,1630,异常163-正极片缺料
|
||||
COIL_ALARM_164_SEPARATOR_SHORTAGE,BOOL,,,,coil,1640,异常164-隔膜缺料
|
||||
COIL_ALARM_165_NEGATIVE_PLATE_SHORTAGE,BOOL,,,,coil,1650,异常165-负极片缺料
|
||||
COIL_ALARM_166_FLAT_WASHER_SHORTAGE,BOOL,,,,coil,1660,异常166-平垫缺料
|
||||
COIL_ALARM_167_SPRING_WASHER_SHORTAGE,BOOL,,,,coil,1670,异常167-弹垫缺料
|
||||
COIL_ALARM_168_NEGATIVE_SHELL_SHORTAGE,BOOL,,,,coil,1680,异常168-负极壳缺料
|
||||
COIL_ALARM_169_FINISHED_BATTERY_FULL,BOOL,,,,coil,1690,异常169-成品电池满料
|
||||
COIL_ALARM_201_SERVO_AXIS_01_ERROR,BOOL,,,,coil,2010,异常201-伺服轴01异常
|
||||
COIL_ALARM_202_SERVO_AXIS_02_ERROR,BOOL,,,,coil,2020,异常202-伺服轴02异常
|
||||
COIL_ALARM_203_SERVO_AXIS_03_ERROR,BOOL,,,,coil,2030,异常203-伺服轴03异常
|
||||
COIL_ALARM_204_SERVO_AXIS_04_ERROR,BOOL,,,,coil,2040,异常204-伺服轴04异常
|
||||
COIL_ALARM_205_SERVO_AXIS_05_ERROR,BOOL,,,,coil,2050,异常205-伺服轴05异常
|
||||
COIL_ALARM_206_SERVO_AXIS_06_ERROR,BOOL,,,,coil,2060,异常206-伺服轴06异常
|
||||
COIL_ALARM_207_SERVO_AXIS_07_ERROR,BOOL,,,,coil,2070,异常207-伺服轴07异常
|
||||
COIL_ALARM_208_SERVO_AXIS_08_ERROR,BOOL,,,,coil,2080,异常208-伺服轴08异常
|
||||
COIL_ALARM_209_SERVO_AXIS_09_ERROR,BOOL,,,,coil,2090,异常209-伺服轴09异常
|
||||
COIL_ALARM_210_SERVO_AXIS_10_ERROR,BOOL,,,,coil,2100,异常210-伺服轴10异常
|
||||
COIL_ALARM_211_SERVO_AXIS_11_ERROR,BOOL,,,,coil,2110,异常211-伺服轴11异常
|
||||
COIL_ALARM_212_SERVO_AXIS_12_ERROR,BOOL,,,,coil,2120,异常212-伺服轴12异常
|
||||
COIL_ALARM_213_SERVO_AXIS_13_ERROR,BOOL,,,,coil,2130,异常213-伺服轴13异常
|
||||
COIL_ALARM_214_SERVO_AXIS_14_ERROR,BOOL,,,,coil,2140,异常214-伺服轴14异常
|
||||
COIL_ALARM_250_OTHER_COMPONENT_ERROR,BOOL,,,,coil,2500,异常250-其他元件异常
|
||||
COIL_ALARM_251_PIPETTE_COMM_ERROR,BOOL,,,,coil,2510,异常251-移液枪通讯异常
|
||||
COIL_ALARM_252_PIPETTE_ALARM,BOOL,,,,coil,2520,异常252-移液枪报警
|
||||
COIL_ALARM_256_ELECTRIC_GRIPPER_ERROR,BOOL,,,,coil,2560,异常256-电爪异常
|
||||
COIL_ALARM_262_RB_UNKNOWN_POSITION_ERROR,BOOL,,,,coil,2620,异常262-RB报警:未知点位错误
|
||||
COIL_ALARM_263_RB_XYZ_PARAM_LIMIT_ERROR,BOOL,,,,coil,2630,异常263-RB报警:X、Y、Z参数超限制
|
||||
COIL_ALARM_264_RB_VISION_PARAM_ERROR,BOOL,,,,coil,2640,异常264-RB报警:视觉参数误差过大
|
||||
COIL_ALARM_265_RB_NOZZLE_1_PICK_FAIL,BOOL,,,,coil,2650,异常265-RB报警:1#吸嘴取料失败
|
||||
COIL_ALARM_266_RB_NOZZLE_2_PICK_FAIL,BOOL,,,,coil,2660,异常266-RB报警:2#吸嘴取料失败
|
||||
COIL_ALARM_267_RB_NOZZLE_3_PICK_FAIL,BOOL,,,,coil,2670,异常267-RB报警:3#吸嘴取料失败
|
||||
COIL_ALARM_268_RB_NOZZLE_4_PICK_FAIL,BOOL,,,,coil,2680,异常268-RB报警:4#吸嘴取料失败
|
||||
COIL_ALARM_269_RB_TRAY_PICK_FAIL,BOOL,,,,coil,2690,异常269-RB报警:取物料盘失败
|
||||
COIL_ALARM_280_RB_COLLISION_ERROR,BOOL,,,,coil,2800,异常280-RB碰撞异常
|
||||
COIL_ALARM_290_VISION_SYSTEM_COMM_ERROR,BOOL,,,,coil,2900,异常290-视觉系统通讯异常
|
||||
COIL_ALARM_291_VISION_ALIGNMENT_NG,BOOL,,,,coil,2910,异常291-视觉对位NG异常
|
||||
COIL_ALARM_292_BARCODE_SCANNER_COMM_ERROR,BOOL,,,,coil,2920,异常292-扫码枪通讯异常
|
||||
COIL_ALARM_310_OCV_TRANSFER_NOZZLE_SUCTION_ERROR,BOOL,,,,coil,3100,异常310-开电移载吸嘴吸真空异常
|
||||
COIL_ALARM_311_OCV_TRANSFER_NOZZLE_BREAK_ERROR,BOOL,,,,coil,3110,异常311-开电移载吸嘴破真空异常
|
||||
COIL_ALARM_312_WEIGHT_TRANSFER_NOZZLE_SUCTION_ERROR,BOOL,,,,coil,3120,异常312-称重移载吸嘴吸真空异常
|
||||
COIL_ALARM_313_WEIGHT_TRANSFER_NOZZLE_BREAK_ERROR,BOOL,,,,coil,3130,异常313-称重移载吸嘴破真空异常
|
||||
COIL_ALARM_340_OCV_NOZZLE_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3400,异常340-开路电压吸嘴移载气缸异常
|
||||
COIL_ALARM_342_OCV_NOZZLE_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3420,异常342-开路电压吸嘴升降气缸异常
|
||||
COIL_ALARM_344_OCV_CRIMPING_CYLINDER_ERROR,BOOL,,,,coil,3440,异常344-开路电压旋压气缸异常
|
||||
COIL_ALARM_350_WEIGHT_NOZZLE_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3500,异常350-称重吸嘴移载气缸异常
|
||||
COIL_ALARM_352_WEIGHT_NOZZLE_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3520,异常352-称重吸嘴升降气缸异常
|
||||
COIL_ALARM_354_CLEANING_CLOTH_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3540,异常354-清洗无尘布移载气缸异常
|
||||
COIL_ALARM_356_CLEANING_CLOTH_PRESS_CYLINDER_ERROR,BOOL,,,,coil,3560,异常356-清洗无尘布压紧气缸异常
|
||||
COIL_ALARM_360_ELECTROLYTE_BOTTLE_POSITION_CYLINDER_ERROR,BOOL,,,,coil,3600,异常360-电解液瓶定位气缸异常
|
||||
COIL_ALARM_362_PIPETTE_TIP_BOX_POSITION_CYLINDER_ERROR,BOOL,,,,coil,3620,异常362-移液枪头盒定位气缸异常
|
||||
COIL_ALARM_364_REAGENT_BOTTLE_GRIPPER_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3640,异常364-试剂瓶夹爪升降气缸异常
|
||||
COIL_ALARM_366_REAGENT_BOTTLE_GRIPPER_CYLINDER_ERROR,BOOL,,,,coil,3660,异常366-试剂瓶夹爪气缸异常
|
||||
COIL_ALARM_370_PRESS_MODULE_BLOW_CYLINDER_ERROR,BOOL,,,,coil,3700,异常370-压制模块吹气气缸异常
|
||||
COIL_ALARM_151_ELECTROLYTE_BOTTLE_POSITION_ERROR,BOOL,,,,coil,1510,异常151-电解液瓶定位在籍异常
|
||||
COIL_ALARM_152_ELECTROLYTE_BOTTLE_CAP_ERROR,BOOL,,,,coil,1520,异常152-电解液瓶盖在籍异常
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -197,6 +197,28 @@ class WorkstationBase(ABC):
|
||||
self._ros_node = workstation_node
|
||||
logger.info(f"工作站 {self._ros_node.device_id} 关联协议节点")
|
||||
|
||||
# ============ 物料转运回调 ============
|
||||
|
||||
def resource_tree_batch_transfer(
|
||||
self,
|
||||
transfers: list,
|
||||
old_parents: list,
|
||||
new_parents: list,
|
||||
) -> None:
|
||||
"""批量物料转运完成后的回调,供子类重写
|
||||
|
||||
默认实现:逐个调用 resource_tree_transfer(如存在)。
|
||||
|
||||
Args:
|
||||
transfers: 转移列表,每项包含 resource, from_parent, to_parent, to_site 等
|
||||
old_parents: 每个物料转移前的原父节点
|
||||
new_parents: 每个物料转移后的新父节点
|
||||
"""
|
||||
func = getattr(self, "resource_tree_transfer", None)
|
||||
if callable(func):
|
||||
for t, old_parent, new_parent in zip(transfers, old_parents, new_parents):
|
||||
func(old_parent, t["resource"], new_parent)
|
||||
|
||||
# ============ 设备操作接口 ============
|
||||
|
||||
def call_device_method(self, method: str, *args, **kwargs) -> Any:
|
||||
|
||||
@@ -459,12 +459,12 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
|
||||
# 验证必需字段
|
||||
if 'brand' in request_data:
|
||||
if request_data['brand'] == "bioyond": # 奔曜
|
||||
error_msg = request_data["text"]
|
||||
logger.info(f"收到奔曜错误处理报送: {error_msg}")
|
||||
material_data = request_data["text"]
|
||||
logger.info(f"收到奔曜物料变更报送: {material_data}")
|
||||
return HttpResponse(
|
||||
success=True,
|
||||
message=f"错误处理报送已收到: {error_msg}",
|
||||
acknowledgment_id=f"ERROR_{int(time.time() * 1000)}_{error_msg.get('action_id', 'unknown')}",
|
||||
message=f"物料变更报送已收到: {material_data}",
|
||||
acknowledgment_id=f"MATERIAL_{int(time.time() * 1000)}_{material_data.get('id', 'unknown')}",
|
||||
data=None
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
# PRCXI 耗材管理 Web 应用
|
||||
@@ -1,4 +0,0 @@
|
||||
"""启动入口: python -m unilabos.labware_manager"""
|
||||
from unilabos.labware_manager.app import main
|
||||
|
||||
main()
|
||||
@@ -1,196 +0,0 @@
|
||||
"""FastAPI 应用 + CRUD API + 启动入口。
|
||||
|
||||
用法: python -m unilabos.labware_manager.app
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from unilabos.labware_manager.models import LabwareDB, LabwareItem
|
||||
|
||||
_HERE = Path(__file__).resolve().parent
|
||||
_DB_PATH = _HERE / "labware_db.json"
|
||||
|
||||
app = FastAPI(title="PRCXI 耗材管理", version="1.0")
|
||||
|
||||
# 静态文件 + 模板
|
||||
app.mount("/static", StaticFiles(directory=str(_HERE / "static")), name="static")
|
||||
templates = Jinja2Templates(directory=str(_HERE / "templates"))
|
||||
|
||||
|
||||
# ---------- DB 读写 ----------
|
||||
|
||||
def _load_db() -> LabwareDB:
|
||||
if not _DB_PATH.exists():
|
||||
return LabwareDB()
|
||||
with open(_DB_PATH, "r", encoding="utf-8") as f:
|
||||
return LabwareDB(**json.load(f))
|
||||
|
||||
|
||||
def _save_db(db: LabwareDB) -> None:
|
||||
with open(_DB_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(db.model_dump(), f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
# ---------- 页面路由 ----------
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index_page(request: Request):
|
||||
db = _load_db()
|
||||
# 按 type 分组
|
||||
groups = {}
|
||||
for item in db.items:
|
||||
groups.setdefault(item.type, []).append(item)
|
||||
return templates.TemplateResponse("index.html", {
|
||||
"request": request,
|
||||
"groups": groups,
|
||||
"total": len(db.items),
|
||||
})
|
||||
|
||||
|
||||
@app.get("/labware/new", response_class=HTMLResponse)
|
||||
async def new_page(request: Request, type: str = "plate"):
|
||||
return templates.TemplateResponse("edit.html", {
|
||||
"request": request,
|
||||
"item": None,
|
||||
"labware_type": type,
|
||||
"is_new": True,
|
||||
})
|
||||
|
||||
|
||||
@app.get("/labware/{item_id}", response_class=HTMLResponse)
|
||||
async def detail_page(request: Request, item_id: str):
|
||||
db = _load_db()
|
||||
item = _find_item(db, item_id)
|
||||
if not item:
|
||||
raise HTTPException(404, "耗材不存在")
|
||||
return templates.TemplateResponse("detail.html", {
|
||||
"request": request,
|
||||
"item": item,
|
||||
})
|
||||
|
||||
|
||||
@app.get("/labware/{item_id}/edit", response_class=HTMLResponse)
|
||||
async def edit_page(request: Request, item_id: str):
|
||||
db = _load_db()
|
||||
item = _find_item(db, item_id)
|
||||
if not item:
|
||||
raise HTTPException(404, "耗材不存在")
|
||||
return templates.TemplateResponse("edit.html", {
|
||||
"request": request,
|
||||
"item": item,
|
||||
"labware_type": item.type,
|
||||
"is_new": False,
|
||||
})
|
||||
|
||||
|
||||
# ---------- API 端点 ----------
|
||||
|
||||
@app.get("/api/labware")
|
||||
async def api_list_labware():
|
||||
db = _load_db()
|
||||
return {"items": [item.model_dump() for item in db.items]}
|
||||
|
||||
|
||||
@app.post("/api/labware")
|
||||
async def api_create_labware(request: Request):
|
||||
data = await request.json()
|
||||
db = _load_db()
|
||||
item = LabwareItem(**data)
|
||||
# 确保 id 唯一
|
||||
existing_ids = {it.id for it in db.items}
|
||||
while item.id in existing_ids:
|
||||
import uuid
|
||||
item.id = uuid.uuid4().hex[:8]
|
||||
db.items.append(item)
|
||||
_save_db(db)
|
||||
return {"status": "ok", "id": item.id}
|
||||
|
||||
|
||||
@app.put("/api/labware/{item_id}")
|
||||
async def api_update_labware(item_id: str, request: Request):
|
||||
data = await request.json()
|
||||
db = _load_db()
|
||||
for i, it in enumerate(db.items):
|
||||
if it.id == item_id or it.function_name == item_id:
|
||||
updated = LabwareItem(**{**it.model_dump(), **data, "id": it.id})
|
||||
db.items[i] = updated
|
||||
_save_db(db)
|
||||
return {"status": "ok", "id": it.id}
|
||||
raise HTTPException(404, "耗材不存在")
|
||||
|
||||
|
||||
@app.delete("/api/labware/{item_id}")
|
||||
async def api_delete_labware(item_id: str):
|
||||
db = _load_db()
|
||||
original_len = len(db.items)
|
||||
db.items = [it for it in db.items if it.id != item_id and it.function_name != item_id]
|
||||
if len(db.items) == original_len:
|
||||
raise HTTPException(404, "耗材不存在")
|
||||
_save_db(db)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/api/generate-code")
|
||||
async def api_generate_code(request: Request):
|
||||
body = await request.json() if await request.body() else {}
|
||||
test_mode = body.get("test_mode", True)
|
||||
db = _load_db()
|
||||
if not db.items:
|
||||
raise HTTPException(400, "数据库为空,请先导入")
|
||||
|
||||
from unilabos.labware_manager.codegen import generate_code
|
||||
from unilabos.labware_manager.yaml_gen import generate_yaml
|
||||
|
||||
py_path = generate_code(db, test_mode=test_mode)
|
||||
yaml_paths = generate_yaml(db, test_mode=test_mode)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"python_file": str(py_path),
|
||||
"yaml_files": [str(p) for p in yaml_paths],
|
||||
"test_mode": test_mode,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/import-from-code")
|
||||
async def api_import_from_code():
|
||||
from unilabos.labware_manager.importer import import_from_code, save_db
|
||||
db = import_from_code()
|
||||
save_db(db)
|
||||
return {
|
||||
"status": "ok",
|
||||
"count": len(db.items),
|
||||
"items": [{"function_name": it.function_name, "type": it.type} for it in db.items],
|
||||
}
|
||||
|
||||
|
||||
# ---------- 辅助函数 ----------
|
||||
|
||||
def _find_item(db: LabwareDB, item_id: str) -> Optional[LabwareItem]:
|
||||
for item in db.items:
|
||||
if item.id == item_id or item.function_name == item_id:
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
# ---------- 启动入口 ----------
|
||||
|
||||
def main():
|
||||
import uvicorn
|
||||
port = int(os.environ.get("LABWARE_PORT", "8010"))
|
||||
print(f"PRCXI 耗材管理 → http://localhost:{port}")
|
||||
uvicorn.run(app, host="0.0.0.0", port=port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,451 +0,0 @@
|
||||
"""JSON → prcxi_labware.py 代码生成。
|
||||
|
||||
读取 labware_db.json,输出完整的 prcxi_labware.py(或 prcxi_labware_test.py)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from unilabos.labware_manager.models import LabwareDB, LabwareItem
|
||||
|
||||
_TARGET_DIR = Path(__file__).resolve().parents[1] / "devices" / "liquid_handling" / "prcxi"
|
||||
|
||||
# ---------- 固定头部 ----------
|
||||
_HEADER = '''\
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
from pylabrobot.resources import Tube, Coordinate
|
||||
from pylabrobot.resources.well import Well, WellBottomType, CrossSectionType
|
||||
from pylabrobot.resources.tip import Tip, TipCreator
|
||||
from pylabrobot.resources.tip_rack import TipRack, TipSpot
|
||||
from pylabrobot.resources.utils import create_ordered_items_2d
|
||||
from pylabrobot.resources.height_volume_functions import (
|
||||
compute_height_from_volume_rectangle,
|
||||
compute_volume_from_height_rectangle,
|
||||
)
|
||||
|
||||
from .prcxi import PRCXI9300Plate, PRCXI9300TipRack, PRCXI9300Trash, PRCXI9300TubeRack, PRCXI9300PlateAdapter
|
||||
|
||||
def _make_tip_helper(volume: float, length: float, depth: float) -> Tip:
|
||||
"""
|
||||
PLR 的 Tip 类参数名为: maximal_volume, total_tip_length, fitting_depth
|
||||
"""
|
||||
return Tip(
|
||||
has_filter=False, # 默认无滤芯
|
||||
maximal_volume=volume,
|
||||
total_tip_length=length,
|
||||
fitting_depth=depth
|
||||
)
|
||||
|
||||
'''
|
||||
|
||||
|
||||
def _gen_plate(item: LabwareItem) -> str:
|
||||
"""生成 Plate 类型的工厂函数代码。"""
|
||||
lines = []
|
||||
fn = item.function_name
|
||||
doc = item.docstring or f"Code: {item.material_info.Code}"
|
||||
|
||||
has_vf = item.volume_functions is not None
|
||||
|
||||
if has_vf:
|
||||
# 有 volume_functions 时需要 well_kwargs 方式
|
||||
vf = item.volume_functions
|
||||
well = item.well
|
||||
grid = item.grid
|
||||
|
||||
lines.append(f'def {fn}(name: str) -> PRCXI9300Plate:')
|
||||
lines.append(f' """')
|
||||
for dl in doc.split('\n'):
|
||||
lines.append(f' {dl}')
|
||||
lines.append(f' """')
|
||||
|
||||
# 计算 well_size 变量
|
||||
lines.append(f' well_size_x = {well.size_x}')
|
||||
lines.append(f' well_size_y = {well.size_y}')
|
||||
|
||||
lines.append(f' well_kwargs = {{')
|
||||
lines.append(f' "size_x": well_size_x,')
|
||||
lines.append(f' "size_y": well_size_y,')
|
||||
lines.append(f' "size_z": {well.size_z},')
|
||||
lines.append(f' "bottom_type": WellBottomType.{well.bottom_type},')
|
||||
if well.cross_section_type and well.cross_section_type != "CIRCLE":
|
||||
lines.append(f' "cross_section_type": CrossSectionType.{well.cross_section_type},')
|
||||
lines.append(f' "compute_height_from_volume": lambda liquid_volume: compute_height_from_volume_rectangle(')
|
||||
lines.append(f' liquid_volume=liquid_volume, well_length=well_size_x, well_width=well_size_y')
|
||||
lines.append(f' ),')
|
||||
lines.append(f' "compute_volume_from_height": lambda liquid_height: compute_volume_from_height_rectangle(')
|
||||
lines.append(f' liquid_height=liquid_height, well_length=well_size_x, well_width=well_size_y')
|
||||
lines.append(f' ),')
|
||||
if well.material_z_thickness is not None:
|
||||
lines.append(f' "material_z_thickness": {well.material_z_thickness},')
|
||||
lines.append(f' }}')
|
||||
lines.append(f'')
|
||||
lines.append(f' return PRCXI9300Plate(')
|
||||
lines.append(f' name=name,')
|
||||
lines.append(f' size_x={item.size_x},')
|
||||
lines.append(f' size_y={item.size_y},')
|
||||
lines.append(f' size_z={item.size_z},')
|
||||
lines.append(f' lid=None,')
|
||||
lines.append(f' model="{item.model}",')
|
||||
lines.append(f' category="plate",')
|
||||
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))},')
|
||||
lines.append(f' ordered_items=create_ordered_items_2d(')
|
||||
lines.append(f' Well,')
|
||||
lines.append(f' num_items_x={grid.num_items_x},')
|
||||
lines.append(f' num_items_y={grid.num_items_y},')
|
||||
lines.append(f' dx={grid.dx},')
|
||||
lines.append(f' dy={grid.dy},')
|
||||
lines.append(f' dz={grid.dz},')
|
||||
lines.append(f' item_dx={grid.item_dx},')
|
||||
lines.append(f' item_dy={grid.item_dy},')
|
||||
lines.append(f' **well_kwargs,')
|
||||
lines.append(f' ),')
|
||||
lines.append(f' )')
|
||||
else:
|
||||
# 普通 plate
|
||||
well = item.well
|
||||
grid = item.grid
|
||||
|
||||
lines.append(f'def {fn}(name: str) -> PRCXI9300Plate:')
|
||||
lines.append(f' """')
|
||||
for dl in doc.split('\n'):
|
||||
lines.append(f' {dl}')
|
||||
lines.append(f' """')
|
||||
lines.append(f' return PRCXI9300Plate(')
|
||||
lines.append(f' name=name,')
|
||||
lines.append(f' size_x={item.size_x},')
|
||||
lines.append(f' size_y={item.size_y},')
|
||||
lines.append(f' size_z={item.size_z},')
|
||||
if item.plate_type:
|
||||
lines.append(f' plate_type="{item.plate_type}",')
|
||||
lines.append(f' model="{item.model}",')
|
||||
lines.append(f' category="plate",')
|
||||
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))},')
|
||||
if grid and well:
|
||||
lines.append(f' ordered_items=create_ordered_items_2d(')
|
||||
lines.append(f' Well,')
|
||||
lines.append(f' num_items_x={grid.num_items_x},')
|
||||
lines.append(f' num_items_y={grid.num_items_y},')
|
||||
lines.append(f' dx={grid.dx},')
|
||||
lines.append(f' dy={grid.dy},')
|
||||
lines.append(f' dz={grid.dz},')
|
||||
lines.append(f' item_dx={grid.item_dx},')
|
||||
lines.append(f' item_dy={grid.item_dy},')
|
||||
lines.append(f' size_x={well.size_x},')
|
||||
lines.append(f' size_y={well.size_y},')
|
||||
lines.append(f' size_z={well.size_z},')
|
||||
if well.max_volume is not None:
|
||||
lines.append(f' max_volume={well.max_volume},')
|
||||
if well.material_z_thickness is not None:
|
||||
lines.append(f' material_z_thickness={well.material_z_thickness},')
|
||||
if well.bottom_type and well.bottom_type != "FLAT":
|
||||
lines.append(f' bottom_type=WellBottomType.{well.bottom_type},')
|
||||
if well.cross_section_type:
|
||||
lines.append(f' cross_section_type=CrossSectionType.{well.cross_section_type},')
|
||||
lines.append(f' ),')
|
||||
lines.append(f' )')
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _gen_tip_rack(item: LabwareItem) -> str:
|
||||
"""生成 TipRack 工厂函数代码。"""
|
||||
lines = []
|
||||
fn = item.function_name
|
||||
doc = item.docstring or f"Code: {item.material_info.Code}"
|
||||
grid = item.grid
|
||||
tip = item.tip
|
||||
|
||||
lines.append(f'def {fn}(name: str) -> PRCXI9300TipRack:')
|
||||
lines.append(f' """')
|
||||
for dl in doc.split('\n'):
|
||||
lines.append(f' {dl}')
|
||||
lines.append(f' """')
|
||||
lines.append(f' return PRCXI9300TipRack(')
|
||||
lines.append(f' name=name,')
|
||||
lines.append(f' size_x={item.size_x},')
|
||||
lines.append(f' size_y={item.size_y},')
|
||||
lines.append(f' size_z={item.size_z},')
|
||||
lines.append(f' model="{item.model}",')
|
||||
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))},')
|
||||
if grid and tip:
|
||||
lines.append(f' ordered_items=create_ordered_items_2d(')
|
||||
lines.append(f' TipSpot,')
|
||||
lines.append(f' num_items_x={grid.num_items_x},')
|
||||
lines.append(f' num_items_y={grid.num_items_y},')
|
||||
lines.append(f' dx={grid.dx},')
|
||||
lines.append(f' dy={grid.dy},')
|
||||
lines.append(f' dz={grid.dz},')
|
||||
lines.append(f' item_dx={grid.item_dx},')
|
||||
lines.append(f' item_dy={grid.item_dy},')
|
||||
lines.append(f' size_x={tip.spot_size_x},')
|
||||
lines.append(f' size_y={tip.spot_size_y},')
|
||||
lines.append(f' size_z={tip.spot_size_z},')
|
||||
lines.append(f' make_tip=lambda: _make_tip_helper(volume={tip.tip_volume}, length={tip.tip_length}, depth={tip.tip_fitting_depth})')
|
||||
lines.append(f' )')
|
||||
lines.append(f' )')
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _gen_trash(item: LabwareItem) -> str:
|
||||
"""生成 Trash 工厂函数代码。"""
|
||||
lines = []
|
||||
fn = item.function_name
|
||||
doc = item.docstring or f"Code: {item.material_info.Code}"
|
||||
|
||||
lines.append(f'def {fn}(name: str = "trash") -> PRCXI9300Trash:')
|
||||
lines.append(f' """')
|
||||
for dl in doc.split('\n'):
|
||||
lines.append(f' {dl}')
|
||||
lines.append(f' """')
|
||||
lines.append(f' return PRCXI9300Trash(')
|
||||
lines.append(f' name="trash",')
|
||||
lines.append(f' size_x={item.size_x},')
|
||||
lines.append(f' size_y={item.size_y},')
|
||||
lines.append(f' size_z={item.size_z},')
|
||||
lines.append(f' category="trash",')
|
||||
lines.append(f' model="{item.model}",')
|
||||
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))}')
|
||||
lines.append(f' )')
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _gen_tube_rack(item: LabwareItem) -> str:
|
||||
"""生成 TubeRack 工厂函数代码。"""
|
||||
lines = []
|
||||
fn = item.function_name
|
||||
doc = item.docstring or f"Code: {item.material_info.Code}"
|
||||
grid = item.grid
|
||||
tube = item.tube
|
||||
|
||||
lines.append(f'def {fn}(name: str) -> PRCXI9300TubeRack:')
|
||||
lines.append(f' """')
|
||||
for dl in doc.split('\n'):
|
||||
lines.append(f' {dl}')
|
||||
lines.append(f' """')
|
||||
lines.append(f' return PRCXI9300TubeRack(')
|
||||
lines.append(f' name=name,')
|
||||
lines.append(f' size_x={item.size_x},')
|
||||
lines.append(f' size_y={item.size_y},')
|
||||
lines.append(f' size_z={item.size_z},')
|
||||
lines.append(f' model="{item.model}",')
|
||||
lines.append(f' category="tube_rack",')
|
||||
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))},')
|
||||
if grid and tube:
|
||||
lines.append(f' ordered_items=create_ordered_items_2d(')
|
||||
lines.append(f' Tube,')
|
||||
lines.append(f' num_items_x={grid.num_items_x},')
|
||||
lines.append(f' num_items_y={grid.num_items_y},')
|
||||
lines.append(f' dx={grid.dx},')
|
||||
lines.append(f' dy={grid.dy},')
|
||||
lines.append(f' dz={grid.dz},')
|
||||
lines.append(f' item_dx={grid.item_dx},')
|
||||
lines.append(f' item_dy={grid.item_dy},')
|
||||
lines.append(f' size_x={tube.size_x},')
|
||||
lines.append(f' size_y={tube.size_y},')
|
||||
lines.append(f' size_z={tube.size_z},')
|
||||
lines.append(f' max_volume={tube.max_volume}')
|
||||
lines.append(f' )')
|
||||
lines.append(f' )')
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _gen_plate_adapter(item: LabwareItem) -> str:
|
||||
"""生成 PlateAdapter 工厂函数代码。"""
|
||||
lines = []
|
||||
fn = item.function_name
|
||||
doc = item.docstring or f"Code: {item.material_info.Code}"
|
||||
|
||||
lines.append(f'def {fn}(name: str) -> PRCXI9300PlateAdapter:')
|
||||
lines.append(f' """ {doc} """')
|
||||
lines.append(f' return PRCXI9300PlateAdapter(')
|
||||
lines.append(f' name=name,')
|
||||
lines.append(f' size_x={item.size_x},')
|
||||
lines.append(f' size_y={item.size_y},')
|
||||
lines.append(f' size_z={item.size_z},')
|
||||
if item.model:
|
||||
lines.append(f' model="{item.model}",')
|
||||
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))}')
|
||||
lines.append(f' )')
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _fmt_dict(d: dict) -> str:
|
||||
"""格式化字典为 Python 代码片段。"""
|
||||
parts = []
|
||||
for k, v in d.items():
|
||||
if isinstance(v, str):
|
||||
parts.append(f'"{k}": "{v}"')
|
||||
elif v is None:
|
||||
continue
|
||||
else:
|
||||
parts.append(f'"{k}": {v}')
|
||||
return '{' + ', '.join(parts) + '}'
|
||||
|
||||
|
||||
def _gen_template_factory_kinds(items: List[LabwareItem]) -> str:
|
||||
"""生成 PRCXI_TEMPLATE_FACTORY_KINDS 列表。"""
|
||||
lines = ['PRCXI_TEMPLATE_FACTORY_KINDS: List[Tuple[Callable[..., Any], str]] = [']
|
||||
for item in items:
|
||||
if item.include_in_template_matching and item.template_kind:
|
||||
lines.append(f' ({item.function_name}, "{item.template_kind}"),')
|
||||
lines.append(']')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _gen_footer() -> str:
|
||||
"""生成文件尾部的模板相关代码。"""
|
||||
return '''
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 协议上传 / workflow 用:与设备端耗材字典字段对齐的模板描述(供 common 自动匹配)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PRCXI_TEMPLATE_SPECS_CACHE: Optional[List[Dict[str, Any]]] = None
|
||||
|
||||
|
||||
def _probe_prcxi_resource(factory: Callable[..., Any]) -> Any:
|
||||
probe = "__unilab_template_probe__"
|
||||
if factory.__name__ == "PRCXI_trash":
|
||||
return factory()
|
||||
return factory(probe)
|
||||
|
||||
|
||||
def _first_child_capacity_for_match(resource: Any) -> float:
|
||||
"""Well max_volume 或 Tip 的 maximal_volume,用于与设备端 Volume 类似的打分。"""
|
||||
ch = getattr(resource, "children", None) or []
|
||||
if not ch:
|
||||
return 0.0
|
||||
c0 = ch[0]
|
||||
mv = getattr(c0, "max_volume", None)
|
||||
if mv is not None:
|
||||
return float(mv)
|
||||
tip = getattr(c0, "tip", None)
|
||||
if tip is not None:
|
||||
mv2 = getattr(tip, "maximal_volume", None)
|
||||
if mv2 is not None:
|
||||
return float(mv2)
|
||||
return 0.0
|
||||
|
||||
|
||||
def get_prcxi_labware_template_specs() -> List[Dict[str, Any]]:
|
||||
"""返回与 ``prcxi._match_and_create_matrix`` 中耗材字段兼容的模板列表,用于按孔数+容量打分。"""
|
||||
global _PRCXI_TEMPLATE_SPECS_CACHE
|
||||
if _PRCXI_TEMPLATE_SPECS_CACHE is not None:
|
||||
return _PRCXI_TEMPLATE_SPECS_CACHE
|
||||
|
||||
out: List[Dict[str, Any]] = []
|
||||
for factory, kind in PRCXI_TEMPLATE_FACTORY_KINDS:
|
||||
try:
|
||||
r = _probe_prcxi_resource(factory)
|
||||
except Exception:
|
||||
continue
|
||||
nx = int(getattr(r, "num_items_x", None) or 0)
|
||||
ny = int(getattr(r, "num_items_y", None) or 0)
|
||||
nchild = len(getattr(r, "children", []) or [])
|
||||
hole_count = nx * ny if nx > 0 and ny > 0 else nchild
|
||||
hole_row = ny if nx > 0 and ny > 0 else 0
|
||||
hole_col = nx if nx > 0 and ny > 0 else 0
|
||||
mi = getattr(r, "material_info", None) or {}
|
||||
vol = _first_child_capacity_for_match(r)
|
||||
menum = mi.get("materialEnum")
|
||||
if menum is None and kind == "tip_rack":
|
||||
menum = 1
|
||||
elif menum is None and kind == "trash":
|
||||
menum = 6
|
||||
out.append(
|
||||
{
|
||||
"class_name": factory.__name__,
|
||||
"kind": kind,
|
||||
"materialEnum": menum,
|
||||
"HoleRow": hole_row,
|
||||
"HoleColum": hole_col,
|
||||
"Volume": vol,
|
||||
"hole_count": hole_count,
|
||||
"material_uuid": mi.get("uuid"),
|
||||
"material_code": mi.get("Code"),
|
||||
}
|
||||
)
|
||||
|
||||
_PRCXI_TEMPLATE_SPECS_CACHE = out
|
||||
return out
|
||||
'''
|
||||
|
||||
|
||||
def generate_code(db: LabwareDB, test_mode: bool = True) -> Path:
|
||||
"""生成 prcxi_labware.py (或 _test.py),返回输出文件路径。"""
|
||||
suffix = "_test" if test_mode else ""
|
||||
out_path = _TARGET_DIR / f"prcxi_labware{suffix}.py"
|
||||
|
||||
# 备份
|
||||
if out_path.exists():
|
||||
bak = out_path.with_suffix(".py.bak")
|
||||
shutil.copy2(out_path, bak)
|
||||
|
||||
# 按类型分组的生成器
|
||||
generators = {
|
||||
"plate": _gen_plate,
|
||||
"tip_rack": _gen_tip_rack,
|
||||
"trash": _gen_trash,
|
||||
"tube_rack": _gen_tube_rack,
|
||||
"plate_adapter": _gen_plate_adapter,
|
||||
}
|
||||
|
||||
# 按 type 分段
|
||||
sections = {
|
||||
"plate": [],
|
||||
"tip_rack": [],
|
||||
"trash": [],
|
||||
"tube_rack": [],
|
||||
"plate_adapter": [],
|
||||
}
|
||||
|
||||
for item in db.items:
|
||||
gen = generators.get(item.type)
|
||||
if gen:
|
||||
sections[item.type].append(gen(item))
|
||||
|
||||
# 组装完整文件
|
||||
parts = [_HEADER]
|
||||
|
||||
section_titles = {
|
||||
"plate": "# =========================================================================\n# Plates\n# =========================================================================",
|
||||
"tip_rack": "# =========================================================================\n# Tip Racks\n# =========================================================================",
|
||||
"trash": "# =========================================================================\n# Trash\n# =========================================================================",
|
||||
"tube_rack": "# =========================================================================\n# Tube Racks\n# =========================================================================",
|
||||
"plate_adapter": "# =========================================================================\n# Plate Adapters\n# =========================================================================",
|
||||
}
|
||||
|
||||
for type_key in ["plate", "tip_rack", "trash", "tube_rack", "plate_adapter"]:
|
||||
if sections[type_key]:
|
||||
parts.append(section_titles[type_key])
|
||||
for code in sections[type_key]:
|
||||
parts.append(code)
|
||||
|
||||
# Template factory kinds
|
||||
parts.append("")
|
||||
parts.append(_gen_template_factory_kinds(db.items))
|
||||
|
||||
# Footer
|
||||
parts.append(_gen_footer())
|
||||
|
||||
content = '\n'.join(parts)
|
||||
out_path.write_text(content, encoding="utf-8")
|
||||
return out_path
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from unilabos.labware_manager.importer import load_db
|
||||
db = load_db()
|
||||
if not db.items:
|
||||
print("labware_db.json 为空,请先运行 importer.py")
|
||||
else:
|
||||
out = generate_code(db, test_mode=True)
|
||||
print(f"已生成 {out} ({len(db.items)} 个工厂函数)")
|
||||
@@ -1,474 +0,0 @@
|
||||
"""从现有 prcxi_labware.py + registry YAML 导入耗材数据到 labware_db.json。
|
||||
|
||||
策略:
|
||||
1. 实例化每个工厂函数 → 提取物理尺寸、material_info、children
|
||||
2. AST 解析源码 → 提取 docstring、volume_function 参数、plate_type
|
||||
3. 从 children[0].location 反推 dx/dy/dz,相邻位置差推 item_dx/item_dy
|
||||
4. 同时读取现有 YAML → 提取 registry_category / description
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import yaml
|
||||
|
||||
# 将项目根目录加入 sys.path 以便 import
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(_PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_PROJECT_ROOT))
|
||||
|
||||
from unilabos.labware_manager.models import (
|
||||
AdapterInfo,
|
||||
GridInfo,
|
||||
LabwareDB,
|
||||
LabwareItem,
|
||||
MaterialInfo,
|
||||
TipInfo,
|
||||
TubeInfo,
|
||||
VolumeFunctions,
|
||||
WellInfo,
|
||||
)
|
||||
|
||||
# ---------- 路径常量 ----------
|
||||
_LABWARE_PY = Path(__file__).resolve().parents[1] / "devices" / "liquid_handling" / "prcxi" / "prcxi_labware.py"
|
||||
_REGISTRY_DIR = Path(__file__).resolve().parents[1] / "registry" / "resources" / "prcxi"
|
||||
_DB_PATH = Path(__file__).resolve().parent / "labware_db.json"
|
||||
|
||||
# YAML 文件名 → type 映射
|
||||
_YAML_MAP: Dict[str, str] = {
|
||||
"plates.yaml": "plate",
|
||||
"tip_racks.yaml": "tip_rack",
|
||||
"trash.yaml": "trash",
|
||||
"tube_racks.yaml": "tube_rack",
|
||||
"plate_adapters.yaml": "plate_adapter",
|
||||
}
|
||||
|
||||
# PRCXI_TEMPLATE_FACTORY_KINDS 中列出的函数名(include_in_template_matching=True)
|
||||
_TEMPLATE_FACTORY_NAMES = {
|
||||
"PRCXI_BioER_96_wellplate", "PRCXI_nest_1_troughplate",
|
||||
"PRCXI_BioRad_384_wellplate", "PRCXI_AGenBio_4_troughplate",
|
||||
"PRCXI_nest_12_troughplate", "PRCXI_CellTreat_96_wellplate",
|
||||
"PRCXI_10ul_eTips", "PRCXI_300ul_Tips",
|
||||
"PRCXI_PCR_Plate_200uL_nonskirted", "PRCXI_PCR_Plate_200uL_semiskirted",
|
||||
"PRCXI_PCR_Plate_200uL_skirted", "PRCXI_trash",
|
||||
"PRCXI_96_DeepWell", "PRCXI_EP_Adapter",
|
||||
"PRCXI_1250uL_Tips", "PRCXI_10uL_Tips",
|
||||
"PRCXI_1000uL_Tips", "PRCXI_200uL_Tips",
|
||||
"PRCXI_48_DeepWell",
|
||||
}
|
||||
|
||||
# template_kind 对应
|
||||
_TEMPLATE_KINDS: Dict[str, str] = {
|
||||
"PRCXI_BioER_96_wellplate": "plate",
|
||||
"PRCXI_nest_1_troughplate": "plate",
|
||||
"PRCXI_BioRad_384_wellplate": "plate",
|
||||
"PRCXI_AGenBio_4_troughplate": "plate",
|
||||
"PRCXI_nest_12_troughplate": "plate",
|
||||
"PRCXI_CellTreat_96_wellplate": "plate",
|
||||
"PRCXI_10ul_eTips": "tip_rack",
|
||||
"PRCXI_300ul_Tips": "tip_rack",
|
||||
"PRCXI_PCR_Plate_200uL_nonskirted": "plate",
|
||||
"PRCXI_PCR_Plate_200uL_semiskirted": "plate",
|
||||
"PRCXI_PCR_Plate_200uL_skirted": "plate",
|
||||
"PRCXI_trash": "trash",
|
||||
"PRCXI_96_DeepWell": "plate",
|
||||
"PRCXI_EP_Adapter": "tube_rack",
|
||||
"PRCXI_1250uL_Tips": "tip_rack",
|
||||
"PRCXI_10uL_Tips": "tip_rack",
|
||||
"PRCXI_1000uL_Tips": "tip_rack",
|
||||
"PRCXI_200uL_Tips": "tip_rack",
|
||||
"PRCXI_48_DeepWell": "plate",
|
||||
}
|
||||
|
||||
|
||||
def _load_registry_info() -> Dict[str, Dict[str, Any]]:
|
||||
"""读取所有 registry YAML 文件,返回 {function_name: {category, description}} 映射。"""
|
||||
info: Dict[str, Dict[str, Any]] = {}
|
||||
for fname, ltype in _YAML_MAP.items():
|
||||
fpath = _REGISTRY_DIR / fname
|
||||
if not fpath.exists():
|
||||
continue
|
||||
with open(fpath, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
for func_name, entry in data.items():
|
||||
info[func_name] = {
|
||||
"registry_category": entry.get("category", ["prcxi", ltype.replace("plate_adapter", "plate_adapters")]),
|
||||
"registry_description": entry.get("description", ""),
|
||||
}
|
||||
return info
|
||||
|
||||
|
||||
def _parse_ast_info() -> Dict[str, Dict[str, Any]]:
|
||||
"""AST 解析 prcxi_labware.py,提取每个工厂函数的 docstring 和 volume_function 参数。"""
|
||||
source = _LABWARE_PY.read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
result: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.FunctionDef):
|
||||
continue
|
||||
fname = node.name
|
||||
if not fname.startswith("PRCXI_"):
|
||||
continue
|
||||
if fname.startswith("_"):
|
||||
continue
|
||||
|
||||
info: Dict[str, Any] = {"docstring": "", "volume_functions": None, "plate_type": None}
|
||||
|
||||
# docstring
|
||||
doc = ast.get_docstring(node)
|
||||
if doc:
|
||||
info["docstring"] = doc.strip()
|
||||
|
||||
# 搜索函数体中的 plate_type 赋值和 volume_function 参数
|
||||
func_source = ast.get_source_segment(source, node) or ""
|
||||
|
||||
# plate_type
|
||||
m = re.search(r'plate_type\s*=\s*["\']([^"\']+)["\']', func_source)
|
||||
if m:
|
||||
info["plate_type"] = m.group(1)
|
||||
|
||||
# volume_functions: 检查 compute_height_from_volume_rectangle
|
||||
if "compute_height_from_volume_rectangle" in func_source:
|
||||
# 提取 well_length 和 well_width
|
||||
vf: Dict[str, Any] = {"type": "rectangle"}
|
||||
# 尝试从 lambda 中提取
|
||||
wl_match = re.search(r'well_length\s*=\s*([\w_.]+)', func_source)
|
||||
ww_match = re.search(r'well_width\s*=\s*([\w_.]+)', func_source)
|
||||
if wl_match:
|
||||
vf["well_length_var"] = wl_match.group(1)
|
||||
if ww_match:
|
||||
vf["well_width_var"] = ww_match.group(1)
|
||||
info["volume_functions"] = vf
|
||||
|
||||
result[fname] = info
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _probe_factory(factory_func) -> Any:
|
||||
"""实例化工厂函数获取 resource 对象。"""
|
||||
if factory_func.__name__ == "PRCXI_trash":
|
||||
return factory_func()
|
||||
return factory_func("__probe__")
|
||||
|
||||
|
||||
def _get_size(resource, attr: str) -> float:
|
||||
"""获取 PLR Resource 的尺寸(兼容 size_x 和 _size_x)。"""
|
||||
val = getattr(resource, attr, None)
|
||||
if val is None:
|
||||
val = getattr(resource, f"_{attr}", None)
|
||||
if val is None:
|
||||
val = getattr(resource, f"get_{attr}", lambda: 0)()
|
||||
return float(val or 0)
|
||||
|
||||
|
||||
def _extract_grid_from_children(resource) -> Optional[Dict[str, Any]]:
|
||||
"""从 resource.children 提取网格信息。"""
|
||||
children = getattr(resource, "children", None) or []
|
||||
if not children:
|
||||
return None
|
||||
|
||||
# 获取 num_items_x, num_items_y
|
||||
num_x = getattr(resource, "num_items_x", None)
|
||||
num_y = getattr(resource, "num_items_y", None)
|
||||
if num_x is None or num_y is None:
|
||||
return None
|
||||
|
||||
c0 = children[0]
|
||||
loc0 = getattr(c0, "location", None)
|
||||
dx = loc0.x if loc0 else 0.0
|
||||
dy_raw = loc0.y if loc0 else 0.0 # 这是 PLR 布局后的位置,不是输入参数
|
||||
dz = loc0.z if loc0 else 0.0
|
||||
|
||||
# 推算 item_dx, item_dy
|
||||
item_dx = 9.0
|
||||
item_dy = 9.0
|
||||
if len(children) > 1:
|
||||
c1 = children[1]
|
||||
loc1 = getattr(c1, "location", None)
|
||||
if loc1 and loc0:
|
||||
diff_x = abs(loc1.x - loc0.x)
|
||||
diff_y = abs(loc1.y - loc0.y)
|
||||
if diff_x > 0.1:
|
||||
item_dx = diff_x
|
||||
if diff_y > 0.1:
|
||||
item_dy = diff_y
|
||||
|
||||
# 如果 num_items_y > 1 且 num_items_x > 1, 找列间距
|
||||
if int(num_y) > 1 and int(num_x) > 1 and len(children) >= int(num_y) + 1:
|
||||
cn = children[int(num_y)]
|
||||
locn = getattr(cn, "location", None)
|
||||
if locn and loc0:
|
||||
col_diff = abs(locn.x - loc0.x)
|
||||
row_diff = abs(children[1].location.y - loc0.y) if len(children) > 1 else item_dy
|
||||
if col_diff > 0.1:
|
||||
item_dx = col_diff
|
||||
if row_diff > 0.1:
|
||||
item_dy = row_diff
|
||||
|
||||
# PLR create_ordered_items_2d 的 Y 轴排列是倒序的:
|
||||
# child[0].y = dy_param + (num_y - 1) * item_dy (最上面一行)
|
||||
# 因此反推原始 dy 参数:
|
||||
dy = dy_raw - (int(num_y) - 1) * item_dy
|
||||
|
||||
return {
|
||||
"num_items_x": int(num_x),
|
||||
"num_items_y": int(num_y),
|
||||
"dx": round(dx, 4),
|
||||
"dy": round(dy, 4),
|
||||
"dz": round(dz, 4),
|
||||
"item_dx": round(item_dx, 4),
|
||||
"item_dy": round(item_dy, 4),
|
||||
}
|
||||
|
||||
|
||||
def _extract_well_info(child) -> Dict[str, Any]:
|
||||
"""从 Well/TipSpot/Tube 子对象提取信息。"""
|
||||
# material_z_thickness 在 PLR 中如果未设置会抛 NotImplementedError
|
||||
mzt = None
|
||||
try:
|
||||
mzt = child.material_z_thickness
|
||||
except (NotImplementedError, AttributeError):
|
||||
mzt = getattr(child, "_material_z_thickness", None)
|
||||
|
||||
return {
|
||||
"size_x": round(_get_size(child, "size_x"), 4),
|
||||
"size_y": round(_get_size(child, "size_y"), 4),
|
||||
"size_z": round(_get_size(child, "size_z"), 4),
|
||||
"max_volume": getattr(child, "max_volume", None),
|
||||
"bottom_type": getattr(child, "bottom_type", None),
|
||||
"cross_section_type": getattr(child, "cross_section_type", None),
|
||||
"material_z_thickness": mzt,
|
||||
}
|
||||
|
||||
|
||||
def import_from_code() -> LabwareDB:
|
||||
"""执行完整的导入流程,返回 LabwareDB 对象。"""
|
||||
# 1. 加载 registry 信息
|
||||
reg_info = _load_registry_info()
|
||||
|
||||
# 2. AST 解析源码
|
||||
ast_info = _parse_ast_info()
|
||||
|
||||
# 3. 导入工厂模块(通过包路径避免 relative import 问题)
|
||||
import importlib
|
||||
mod = importlib.import_module("unilabos.devices.liquid_handling.prcxi.prcxi_labware")
|
||||
|
||||
# 4. 获取 PRCXI_TEMPLATE_FACTORY_KINDS 列出的函数
|
||||
factory_kinds = getattr(mod, "PRCXI_TEMPLATE_FACTORY_KINDS", [])
|
||||
template_func_names = {f.__name__ for f, _k in factory_kinds}
|
||||
|
||||
# 5. 收集所有 PRCXI_ 开头的工厂函数
|
||||
all_factories: List[Tuple[str, Any]] = []
|
||||
for attr_name in dir(mod):
|
||||
if attr_name.startswith("PRCXI_") and not attr_name.startswith("_"):
|
||||
obj = getattr(mod, attr_name)
|
||||
if callable(obj) and not isinstance(obj, type):
|
||||
all_factories.append((attr_name, obj))
|
||||
|
||||
# 按源码行号排序
|
||||
all_factories.sort(key=lambda x: getattr(x[1], "__code__", None) and x[1].__code__.co_firstlineno or 0)
|
||||
|
||||
items: List[LabwareItem] = []
|
||||
|
||||
for func_name, factory in all_factories:
|
||||
try:
|
||||
resource = _probe_factory(factory)
|
||||
except Exception as e:
|
||||
print(f"跳过 {func_name}: {e}")
|
||||
continue
|
||||
|
||||
# 确定类型
|
||||
type_name = "plate"
|
||||
class_name = type(resource).__name__
|
||||
if "TipRack" in class_name:
|
||||
type_name = "tip_rack"
|
||||
elif "Trash" in class_name:
|
||||
type_name = "trash"
|
||||
elif "TubeRack" in class_name:
|
||||
type_name = "tube_rack"
|
||||
elif "PlateAdapter" in class_name:
|
||||
type_name = "plate_adapter"
|
||||
|
||||
# material_info
|
||||
state = getattr(resource, "_unilabos_state", {}) or {}
|
||||
mat = state.get("Material", {})
|
||||
mat_info = MaterialInfo(
|
||||
uuid=mat.get("uuid", ""),
|
||||
Code=mat.get("Code", ""),
|
||||
Name=mat.get("Name", ""),
|
||||
materialEnum=mat.get("materialEnum"),
|
||||
SupplyType=mat.get("SupplyType"),
|
||||
)
|
||||
|
||||
# AST 信息
|
||||
ast_data = ast_info.get(func_name, {})
|
||||
docstring = ast_data.get("docstring", "")
|
||||
plate_type = ast_data.get("plate_type")
|
||||
|
||||
# Registry 信息
|
||||
reg = reg_info.get(func_name, {})
|
||||
registry_category = reg.get("registry_category", ["prcxi", _type_to_yaml_subcategory(type_name)])
|
||||
registry_description = reg.get("registry_description", f'{mat_info.Name} (Code: {mat_info.Code})')
|
||||
|
||||
# 构建 item
|
||||
item = LabwareItem(
|
||||
id=func_name.lower().replace("prcxi_", "")[:8] or func_name[:8],
|
||||
type=type_name,
|
||||
function_name=func_name,
|
||||
docstring=docstring,
|
||||
size_x=round(_get_size(resource, "size_x"), 4),
|
||||
size_y=round(_get_size(resource, "size_y"), 4),
|
||||
size_z=round(_get_size(resource, "size_z"), 4),
|
||||
model=getattr(resource, "model", None),
|
||||
category=getattr(resource, "category", type_name),
|
||||
plate_type=plate_type,
|
||||
material_info=mat_info,
|
||||
registry_category=registry_category,
|
||||
registry_description=registry_description,
|
||||
include_in_template_matching=func_name in template_func_names,
|
||||
template_kind=_TEMPLATE_KINDS.get(func_name),
|
||||
)
|
||||
|
||||
# 提取子项信息
|
||||
children = getattr(resource, "children", None) or []
|
||||
grid_data = _extract_grid_from_children(resource)
|
||||
|
||||
if type_name == "plate" and children:
|
||||
if grid_data:
|
||||
item.grid = GridInfo(**grid_data)
|
||||
c0 = children[0]
|
||||
well_data = _extract_well_info(c0)
|
||||
bt = well_data.get("bottom_type")
|
||||
if bt is not None:
|
||||
bt = bt.name if hasattr(bt, "name") else str(bt)
|
||||
else:
|
||||
bt = "FLAT"
|
||||
cst = well_data.get("cross_section_type")
|
||||
if cst is not None:
|
||||
cst = cst.name if hasattr(cst, "name") else str(cst)
|
||||
else:
|
||||
cst = "CIRCLE"
|
||||
item.well = WellInfo(
|
||||
size_x=well_data["size_x"],
|
||||
size_y=well_data["size_y"],
|
||||
size_z=well_data["size_z"],
|
||||
max_volume=well_data.get("max_volume"),
|
||||
bottom_type=bt,
|
||||
cross_section_type=cst,
|
||||
material_z_thickness=well_data.get("material_z_thickness"),
|
||||
)
|
||||
# volume_functions
|
||||
vf = ast_data.get("volume_functions")
|
||||
if vf:
|
||||
# 需要实际获取 well 尺寸作为 volume_function 参数
|
||||
item.volume_functions = VolumeFunctions(
|
||||
type="rectangle",
|
||||
well_length=well_data["size_x"],
|
||||
well_width=well_data["size_y"],
|
||||
)
|
||||
|
||||
elif type_name == "tip_rack" and children:
|
||||
if grid_data:
|
||||
item.grid = GridInfo(**grid_data)
|
||||
c0 = children[0]
|
||||
tip_obj = getattr(c0, "tip", None)
|
||||
tip_volume = 300.0
|
||||
tip_length = 60.0
|
||||
tip_depth = 51.0
|
||||
tip_filter = False
|
||||
if tip_obj:
|
||||
tip_volume = getattr(tip_obj, "maximal_volume", 300.0)
|
||||
tip_length = getattr(tip_obj, "total_tip_length", 60.0)
|
||||
tip_depth = getattr(tip_obj, "fitting_depth", 51.0)
|
||||
tip_filter = getattr(tip_obj, "has_filter", False)
|
||||
item.tip = TipInfo(
|
||||
spot_size_x=round(_get_size(c0, "size_x"), 4),
|
||||
spot_size_y=round(_get_size(c0, "size_y"), 4),
|
||||
spot_size_z=round(_get_size(c0, "size_z"), 4),
|
||||
tip_volume=tip_volume,
|
||||
tip_length=tip_length,
|
||||
tip_fitting_depth=tip_depth,
|
||||
has_filter=tip_filter,
|
||||
)
|
||||
# 计算 tip_above_rack_length = tip_length - (size_z - dz)
|
||||
if grid_data:
|
||||
_dz = grid_data.get("dz", 0.0)
|
||||
_above = tip_length - (item.size_z - _dz)
|
||||
item.tip.tip_above_rack_length = round(_above, 4) if _above > 0 else None
|
||||
|
||||
elif type_name == "tube_rack" and children:
|
||||
if grid_data:
|
||||
item.grid = GridInfo(**grid_data)
|
||||
c0 = children[0]
|
||||
item.tube = TubeInfo(
|
||||
size_x=round(_get_size(c0, "size_x"), 4),
|
||||
size_y=round(_get_size(c0, "size_y"), 4),
|
||||
size_z=round(_get_size(c0, "size_z"), 4),
|
||||
max_volume=getattr(c0, "max_volume", 1500.0) or 1500.0,
|
||||
)
|
||||
|
||||
elif type_name == "plate_adapter":
|
||||
# 提取 adapter 参数
|
||||
ahx = getattr(resource, "adapter_hole_size_x", 127.76)
|
||||
ahy = getattr(resource, "adapter_hole_size_y", 85.48)
|
||||
ahz = getattr(resource, "adapter_hole_size_z", 10.0)
|
||||
adx = getattr(resource, "dx", None)
|
||||
ady = getattr(resource, "dy", None)
|
||||
adz = getattr(resource, "dz", 0.0)
|
||||
item.adapter = AdapterInfo(
|
||||
adapter_hole_size_x=ahx,
|
||||
adapter_hole_size_y=ahy,
|
||||
adapter_hole_size_z=ahz,
|
||||
dx=adx,
|
||||
dy=ady,
|
||||
dz=adz,
|
||||
)
|
||||
|
||||
items.append(item)
|
||||
|
||||
return LabwareDB(items=items)
|
||||
|
||||
|
||||
def _type_to_yaml_subcategory(type_name: str) -> str:
|
||||
mapping = {
|
||||
"plate": "plates",
|
||||
"tip_rack": "tip_racks",
|
||||
"trash": "trash",
|
||||
"tube_rack": "tube_racks",
|
||||
"plate_adapter": "plate_adapters",
|
||||
}
|
||||
return mapping.get(type_name, type_name)
|
||||
|
||||
|
||||
def save_db(db: LabwareDB, path: Optional[Path] = None) -> Path:
|
||||
"""保存 LabwareDB 到 JSON 文件。"""
|
||||
out = path or _DB_PATH
|
||||
with open(out, "w", encoding="utf-8") as f:
|
||||
json.dump(db.model_dump(), f, ensure_ascii=False, indent=2)
|
||||
return out
|
||||
|
||||
|
||||
def load_db(path: Optional[Path] = None) -> LabwareDB:
|
||||
"""从 JSON 文件加载 LabwareDB。"""
|
||||
src = path or _DB_PATH
|
||||
if not src.exists():
|
||||
return LabwareDB()
|
||||
with open(src, "r", encoding="utf-8") as f:
|
||||
return LabwareDB(**json.load(f))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
db = import_from_code()
|
||||
out = save_db(db)
|
||||
print(f"已导入 {len(db.items)} 个耗材 → {out}")
|
||||
for item in db.items:
|
||||
print(f" [{item.type:14s}] {item.function_name}")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,126 +0,0 @@
|
||||
"""Pydantic 数据模型,描述所有 PRCXI 耗材类型的 JSON 结构。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid as _uuid
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class MaterialInfo(BaseModel):
|
||||
uuid: str = ""
|
||||
Code: str = ""
|
||||
Name: str = ""
|
||||
materialEnum: Optional[int] = None
|
||||
SupplyType: Optional[int] = None
|
||||
|
||||
|
||||
class GridInfo(BaseModel):
|
||||
"""孔位网格排列参数"""
|
||||
num_items_x: int = 12
|
||||
num_items_y: int = 8
|
||||
dx: float = 0.0
|
||||
dy: float = 0.0
|
||||
dz: float = 0.0
|
||||
item_dx: float = 9.0
|
||||
item_dy: float = 9.0
|
||||
|
||||
|
||||
class WellInfo(BaseModel):
|
||||
"""孔参数 (Plate)"""
|
||||
size_x: float = 8.0
|
||||
size_y: float = 8.0
|
||||
size_z: float = 10.0
|
||||
max_volume: Optional[float] = None
|
||||
bottom_type: str = "FLAT" # V / U / FLAT
|
||||
cross_section_type: str = "CIRCLE" # CIRCLE / RECTANGLE
|
||||
material_z_thickness: Optional[float] = None
|
||||
|
||||
|
||||
class VolumeFunctions(BaseModel):
|
||||
"""体积-高度计算函数参数 (矩形 well)"""
|
||||
type: str = "rectangle"
|
||||
well_length: float = 0.0
|
||||
well_width: float = 0.0
|
||||
|
||||
|
||||
class TipInfo(BaseModel):
|
||||
"""枪头参数 (TipRack)"""
|
||||
spot_size_x: float = 7.0
|
||||
spot_size_y: float = 7.0
|
||||
spot_size_z: float = 0.0
|
||||
tip_volume: float = 300.0
|
||||
tip_length: float = 60.0
|
||||
tip_fitting_depth: float = 51.0
|
||||
tip_above_rack_length: Optional[float] = None
|
||||
has_filter: bool = False
|
||||
|
||||
|
||||
class TubeInfo(BaseModel):
|
||||
"""管参数 (TubeRack)"""
|
||||
size_x: float = 10.6
|
||||
size_y: float = 10.6
|
||||
size_z: float = 40.0
|
||||
max_volume: float = 1500.0
|
||||
|
||||
|
||||
class AdapterInfo(BaseModel):
|
||||
"""适配器参数 (PlateAdapter)"""
|
||||
adapter_hole_size_x: float = 127.76
|
||||
adapter_hole_size_y: float = 85.48
|
||||
adapter_hole_size_z: float = 10.0
|
||||
dx: Optional[float] = None
|
||||
dy: Optional[float] = None
|
||||
dz: float = 0.0
|
||||
|
||||
|
||||
LabwareType = Literal["plate", "tip_rack", "trash", "tube_rack", "plate_adapter"]
|
||||
|
||||
|
||||
class LabwareItem(BaseModel):
|
||||
"""一个耗材条目的完整 JSON 表示"""
|
||||
|
||||
id: str = Field(default_factory=lambda: _uuid.uuid4().hex[:8])
|
||||
type: LabwareType = "plate"
|
||||
function_name: str = ""
|
||||
docstring: str = ""
|
||||
|
||||
# 物理尺寸
|
||||
size_x: float = 127.0
|
||||
size_y: float = 85.0
|
||||
size_z: float = 20.0
|
||||
model: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
plate_type: Optional[str] = None # non-skirted / semi-skirted / skirted
|
||||
|
||||
# 材料信息
|
||||
material_info: MaterialInfo = Field(default_factory=MaterialInfo)
|
||||
|
||||
# Registry 字段
|
||||
registry_category: List[str] = Field(default_factory=lambda: ["prcxi", "plates"])
|
||||
registry_description: str = ""
|
||||
|
||||
# Plate 特有
|
||||
grid: Optional[GridInfo] = None
|
||||
well: Optional[WellInfo] = None
|
||||
volume_functions: Optional[VolumeFunctions] = None
|
||||
|
||||
# TipRack 特有
|
||||
tip: Optional[TipInfo] = None
|
||||
|
||||
# TubeRack 特有
|
||||
tube: Optional[TubeInfo] = None
|
||||
|
||||
# PlateAdapter 特有
|
||||
adapter: Optional[AdapterInfo] = None
|
||||
|
||||
# 模板匹配
|
||||
include_in_template_matching: bool = False
|
||||
template_kind: Optional[str] = None
|
||||
|
||||
|
||||
class LabwareDB(BaseModel):
|
||||
"""整个 labware_db.json 的结构"""
|
||||
version: str = "1.0"
|
||||
items: List[LabwareItem] = Field(default_factory=list)
|
||||
@@ -1,292 +0,0 @@
|
||||
/**
|
||||
* form_handler.js — 动态表单逻辑 + 实时预览
|
||||
*/
|
||||
|
||||
// 根据类型显示/隐藏对应的表单段
|
||||
function onTypeChange() {
|
||||
const type = document.getElementById('f-type').value;
|
||||
const sections = {
|
||||
grid: ['plate', 'tip_rack', 'tube_rack'],
|
||||
well: ['plate'],
|
||||
tip: ['tip_rack'],
|
||||
tube: ['tube_rack'],
|
||||
adapter: ['plate_adapter'],
|
||||
};
|
||||
|
||||
for (const [sec, types] of Object.entries(sections)) {
|
||||
const el = document.getElementById('section-' + sec);
|
||||
if (el) el.style.display = types.includes(type) ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// plate_type 行只对 plate 显示
|
||||
const ptRow = document.getElementById('row-plate_type');
|
||||
if (ptRow) ptRow.style.display = type === 'plate' ? 'block' : 'none';
|
||||
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
// 从表单收集数据
|
||||
function collectFormData() {
|
||||
const g = id => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return null;
|
||||
if (el.type === 'checkbox') return el.checked;
|
||||
if (el.type === 'number') return el.value === '' ? null : parseFloat(el.value);
|
||||
return el.value || null;
|
||||
};
|
||||
|
||||
const type = g('f-type');
|
||||
|
||||
const data = {
|
||||
type: type,
|
||||
function_name: g('f-function_name') || 'PRCXI_new',
|
||||
model: g('f-model'),
|
||||
docstring: g('f-docstring') || '',
|
||||
plate_type: type === 'plate' ? g('f-plate_type') : null,
|
||||
size_x: g('f-size_x') || 127,
|
||||
size_y: g('f-size_y') || 85,
|
||||
size_z: g('f-size_z') || 20,
|
||||
material_info: {
|
||||
uuid: g('f-mi_uuid') || '',
|
||||
Code: g('f-mi_code') || '',
|
||||
Name: g('f-mi_name') || '',
|
||||
materialEnum: g('f-mi_menum'),
|
||||
SupplyType: g('f-mi_stype'),
|
||||
},
|
||||
registry_category: (g('f-reg_cat') || 'prcxi,plates').split(',').map(s => s.trim()),
|
||||
registry_description: g('f-reg_desc') || '',
|
||||
include_in_template_matching: g('f-in_tpl') || false,
|
||||
template_kind: g('f-tpl_kind') || null,
|
||||
grid: null,
|
||||
well: null,
|
||||
tip: null,
|
||||
tube: null,
|
||||
adapter: null,
|
||||
volume_functions: null,
|
||||
};
|
||||
|
||||
// Grid
|
||||
if (['plate', 'tip_rack', 'tube_rack'].includes(type)) {
|
||||
data.grid = {
|
||||
num_items_x: g('f-grid_nx') || 12,
|
||||
num_items_y: g('f-grid_ny') || 8,
|
||||
dx: g('f-grid_dx') || 0,
|
||||
dy: g('f-grid_dy') || 0,
|
||||
dz: g('f-grid_dz') || 0,
|
||||
item_dx: g('f-grid_idx') || 9,
|
||||
item_dy: g('f-grid_idy') || 9,
|
||||
};
|
||||
}
|
||||
|
||||
// Well
|
||||
if (type === 'plate') {
|
||||
data.well = {
|
||||
size_x: g('f-well_sx') || 8,
|
||||
size_y: g('f-well_sy') || 8,
|
||||
size_z: g('f-well_sz') || 10,
|
||||
max_volume: g('f-well_vol'),
|
||||
material_z_thickness: g('f-well_mzt'),
|
||||
bottom_type: g('f-well_bt') || 'FLAT',
|
||||
cross_section_type: g('f-well_cs') || 'CIRCLE',
|
||||
};
|
||||
if (g('f-has_vf')) {
|
||||
data.volume_functions = {
|
||||
type: 'rectangle',
|
||||
well_length: data.well.size_x,
|
||||
well_width: data.well.size_y,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Tip
|
||||
if (type === 'tip_rack') {
|
||||
data.tip = {
|
||||
spot_size_x: g('f-tip_sx') || 7,
|
||||
spot_size_y: g('f-tip_sy') || 7,
|
||||
spot_size_z: g('f-tip_sz') || 0,
|
||||
tip_volume: g('f-tip_vol') || 300,
|
||||
tip_length: g('f-tip_len') || 60,
|
||||
tip_fitting_depth: g('f-tip_dep') || 51,
|
||||
tip_above_rack_length: g('f-tip_above'),
|
||||
has_filter: g('f-tip_filter') || false,
|
||||
};
|
||||
}
|
||||
|
||||
// Tube
|
||||
if (type === 'tube_rack') {
|
||||
data.tube = {
|
||||
size_x: g('f-tube_sx') || 10.6,
|
||||
size_y: g('f-tube_sy') || 10.6,
|
||||
size_z: g('f-tube_sz') || 40,
|
||||
max_volume: g('f-tube_vol') || 1500,
|
||||
};
|
||||
}
|
||||
|
||||
// Adapter
|
||||
if (type === 'plate_adapter') {
|
||||
data.adapter = {
|
||||
adapter_hole_size_x: g('f-adp_hsx') || 127.76,
|
||||
adapter_hole_size_y: g('f-adp_hsy') || 85.48,
|
||||
adapter_hole_size_z: g('f-adp_hsz') || 10,
|
||||
dx: g('f-adp_dx'),
|
||||
dy: g('f-adp_dy'),
|
||||
dz: g('f-adp_dz') || 0,
|
||||
};
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// 实时预览 (debounce)
|
||||
let _previewTimer = null;
|
||||
function updatePreview() {
|
||||
if (_previewTimer) clearTimeout(_previewTimer);
|
||||
_previewTimer = setTimeout(() => {
|
||||
const data = collectFormData();
|
||||
const topEl = document.getElementById('svg-topdown');
|
||||
const sideEl = document.getElementById('svg-side');
|
||||
if (topEl) renderTopDown(topEl, data);
|
||||
if (sideEl) renderSideProfile(sideEl, data);
|
||||
}, 200);
|
||||
}
|
||||
|
||||
// 给所有表单元素绑定 input 事件
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const form = document.getElementById('labware-form');
|
||||
if (!form) return;
|
||||
form.addEventListener('input', updatePreview);
|
||||
form.addEventListener('change', updatePreview);
|
||||
|
||||
// tip_above_rack_length 与 dz 互算
|
||||
// 公式: tip_length = tip_above_rack_length + size_z - dz
|
||||
// 规则: 填 tip_above → 自动算 dz;填 dz → 自动算 tip_above
|
||||
// 改 size_z / tip_length → 优先重算 tip_above(若有值),否则算 dz
|
||||
|
||||
function _getVal(id) {
|
||||
const el = document.getElementById(id);
|
||||
return (el && el.value !== '') ? parseFloat(el.value) : null;
|
||||
}
|
||||
function _setVal(id, v) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = Math.round(v * 1000) / 1000;
|
||||
}
|
||||
|
||||
function autoCalcTipAbove(changedId) {
|
||||
const typeEl = document.getElementById('f-type');
|
||||
if (!typeEl || typeEl.value !== 'tip_rack') return;
|
||||
|
||||
const tipLen = _getVal('f-tip_len');
|
||||
const sizeZ = _getVal('f-size_z');
|
||||
const dz = _getVal('f-grid_dz');
|
||||
const above = _getVal('f-tip_above');
|
||||
|
||||
// 需要 tip_length 和 size_z 才能计算
|
||||
if (tipLen == null || sizeZ == null) return;
|
||||
|
||||
if (changedId === 'f-tip_above') {
|
||||
// 用户填了 tip_above → 算 dz
|
||||
if (above != null) _setVal('f-grid_dz', above + sizeZ - tipLen);
|
||||
} else if (changedId === 'f-grid_dz') {
|
||||
// 用户填了 dz → 算 tip_above
|
||||
if (dz != null) _setVal('f-tip_above', tipLen - sizeZ + dz);
|
||||
} else {
|
||||
// size_z 或 tip_length 变了 → 优先重算 tip_above(若已有值或 dz 已有值)
|
||||
if (dz != null) {
|
||||
_setVal('f-tip_above', tipLen - sizeZ + dz);
|
||||
} else if (above != null) {
|
||||
_setVal('f-grid_dz', above + sizeZ - tipLen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 绑定 input 事件
|
||||
for (const id of ['f-tip_len', 'f-size_z', 'f-grid_dz', 'f-tip_above']) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.addEventListener('input', () => autoCalcTipAbove(id));
|
||||
}
|
||||
|
||||
// 编辑已有 tip_rack 条目时自动补算 tip_above_rack_length
|
||||
const typeEl = document.getElementById('f-type');
|
||||
if (typeEl && typeEl.value === 'tip_rack') {
|
||||
autoCalcTipAbove('f-grid_dz');
|
||||
}
|
||||
});
|
||||
|
||||
// 自动居中:根据板尺寸和孔阵列参数计算 dx/dy
|
||||
function autoCenter() {
|
||||
const g = id => { const el = document.getElementById(id); return el && el.value !== '' ? parseFloat(el.value) : 0; };
|
||||
|
||||
const sizeX = g('f-size_x') || 127;
|
||||
const sizeY = g('f-size_y') || 85;
|
||||
const nx = g('f-grid_nx') || 1;
|
||||
const ny = g('f-grid_ny') || 1;
|
||||
const itemDx = g('f-grid_idx') || 9;
|
||||
const itemDy = g('f-grid_idy') || 9;
|
||||
|
||||
// 根据当前耗材类型确定子元素尺寸
|
||||
const type = document.getElementById('f-type').value;
|
||||
let childSx = 0, childSy = 0;
|
||||
if (type === 'plate') {
|
||||
childSx = g('f-well_sx') || 8;
|
||||
childSy = g('f-well_sy') || 8;
|
||||
} else if (type === 'tip_rack') {
|
||||
childSx = g('f-tip_sx') || 7;
|
||||
childSy = g('f-tip_sy') || 7;
|
||||
} else if (type === 'tube_rack') {
|
||||
childSx = g('f-tube_sx') || 10.6;
|
||||
childSy = g('f-tube_sy') || 10.6;
|
||||
}
|
||||
|
||||
// dx = (板宽 - 孔阵列总占宽) / 2
|
||||
const dx = (sizeX - (nx - 1) * itemDx - childSx) / 2;
|
||||
const dy = (sizeY - (ny - 1) * itemDy - childSy) / 2;
|
||||
|
||||
const elDx = document.getElementById('f-grid_dx');
|
||||
const elDy = document.getElementById('f-grid_dy');
|
||||
if (elDx) elDx.value = Math.round(dx * 100) / 100;
|
||||
if (elDy) elDy.value = Math.round(dy * 100) / 100;
|
||||
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
// 保存
|
||||
function showMsg(text, ok) {
|
||||
const el = document.getElementById('status-msg');
|
||||
if (!el) return;
|
||||
el.textContent = text;
|
||||
el.className = 'status-msg ' + (ok ? 'msg-ok' : 'msg-err');
|
||||
el.style.display = 'block';
|
||||
setTimeout(() => el.style.display = 'none', 4000);
|
||||
}
|
||||
|
||||
async function saveForm() {
|
||||
const data = collectFormData();
|
||||
|
||||
let url, method;
|
||||
if (typeof IS_NEW !== 'undefined' && IS_NEW) {
|
||||
url = '/api/labware';
|
||||
method = 'POST';
|
||||
} else {
|
||||
url = '/api/labware/' + (typeof ITEM_ID !== 'undefined' ? ITEM_ID : '');
|
||||
method = 'PUT';
|
||||
}
|
||||
|
||||
try {
|
||||
const r = await fetch(url, {
|
||||
method: method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.status === 'ok') {
|
||||
showMsg('保存成功', true);
|
||||
if (IS_NEW) {
|
||||
setTimeout(() => location.href = '/labware/' + data.function_name, 500);
|
||||
}
|
||||
} else {
|
||||
showMsg('保存失败: ' + JSON.stringify(d), false);
|
||||
}
|
||||
} catch (e) {
|
||||
showMsg('请求错误: ' + e.message, false);
|
||||
}
|
||||
}
|
||||
@@ -1,450 +0,0 @@
|
||||
/**
|
||||
* labware_viz.js — PRCXI 耗材 SVG 2D 可视化渲染引擎
|
||||
*
|
||||
* renderTopDown(container, itemData) — 俯视图
|
||||
* renderSideProfile(container, itemData) — 侧面截面图
|
||||
*/
|
||||
|
||||
const TYPE_COLORS = {
|
||||
plate: '#3b82f6',
|
||||
tip_rack: '#10b981',
|
||||
tube_rack: '#f59e0b',
|
||||
trash: '#ef4444',
|
||||
plate_adapter: '#8b5cf6',
|
||||
};
|
||||
|
||||
function _svgNS() { return 'http://www.w3.org/2000/svg'; }
|
||||
|
||||
function _makeSVG(w, h) {
|
||||
const svg = document.createElementNS(_svgNS(), 'svg');
|
||||
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
|
||||
svg.setAttribute('width', '100%');
|
||||
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
||||
svg.style.background = '#fff';
|
||||
return svg;
|
||||
}
|
||||
|
||||
function _rect(svg, x, y, w, h, fill, stroke, rx) {
|
||||
const r = document.createElementNS(_svgNS(), 'rect');
|
||||
r.setAttribute('x', x); r.setAttribute('y', y);
|
||||
r.setAttribute('width', w); r.setAttribute('height', h);
|
||||
r.setAttribute('fill', fill || 'none');
|
||||
r.setAttribute('stroke', stroke || '#333');
|
||||
r.setAttribute('stroke-width', '0.5');
|
||||
if (rx) r.setAttribute('rx', rx);
|
||||
svg.appendChild(r);
|
||||
return r;
|
||||
}
|
||||
|
||||
function _circle(svg, cx, cy, r, fill, stroke) {
|
||||
const c = document.createElementNS(_svgNS(), 'circle');
|
||||
c.setAttribute('cx', cx); c.setAttribute('cy', cy);
|
||||
c.setAttribute('r', r);
|
||||
c.setAttribute('fill', fill || 'none');
|
||||
c.setAttribute('stroke', stroke || '#333');
|
||||
c.setAttribute('stroke-width', '0.4');
|
||||
svg.appendChild(c);
|
||||
return c;
|
||||
}
|
||||
|
||||
function _text(svg, x, y, txt, size, anchor, fill) {
|
||||
const t = document.createElementNS(_svgNS(), 'text');
|
||||
t.setAttribute('x', x); t.setAttribute('y', y);
|
||||
t.setAttribute('font-size', size || '3');
|
||||
t.setAttribute('text-anchor', anchor || 'middle');
|
||||
t.setAttribute('fill', fill || '#666');
|
||||
t.setAttribute('font-family', 'sans-serif');
|
||||
t.textContent = txt;
|
||||
svg.appendChild(t);
|
||||
return t;
|
||||
}
|
||||
|
||||
function _line(svg, x1, y1, x2, y2, stroke, dash) {
|
||||
const l = document.createElementNS(_svgNS(), 'line');
|
||||
l.setAttribute('x1', x1); l.setAttribute('y1', y1);
|
||||
l.setAttribute('x2', x2); l.setAttribute('y2', y2);
|
||||
l.setAttribute('stroke', stroke || '#999');
|
||||
l.setAttribute('stroke-width', '0.3');
|
||||
if (dash) l.setAttribute('stroke-dasharray', dash);
|
||||
svg.appendChild(l);
|
||||
return l;
|
||||
}
|
||||
|
||||
function _title(el, txt) {
|
||||
const t = document.createElementNS(_svgNS(), 'title');
|
||||
t.textContent = txt;
|
||||
el.appendChild(t);
|
||||
}
|
||||
|
||||
// ==================== 俯视图 ====================
|
||||
function renderTopDown(container, data) {
|
||||
container.innerHTML = '';
|
||||
if (!data) return;
|
||||
|
||||
const pad = 18;
|
||||
const sx = data.size_x || 127;
|
||||
const sy = data.size_y || 85;
|
||||
const w = sx + pad * 2;
|
||||
const h = sy + pad * 2;
|
||||
const svg = _makeSVG(w, h);
|
||||
|
||||
const color = TYPE_COLORS[data.type] || '#3b82f6';
|
||||
const lightColor = color + '22';
|
||||
|
||||
// 板子外轮廓
|
||||
_rect(svg, pad, pad, sx, sy, lightColor, color, 3);
|
||||
|
||||
// 尺寸标注
|
||||
_text(svg, pad + sx / 2, pad - 4, `${sx} mm`, '3.5', 'middle', '#333');
|
||||
// Y 尺寸 (竖直)
|
||||
const yt = document.createElementNS(_svgNS(), 'text');
|
||||
yt.setAttribute('x', pad - 5);
|
||||
yt.setAttribute('y', pad + sy / 2);
|
||||
yt.setAttribute('font-size', '3.5');
|
||||
yt.setAttribute('text-anchor', 'middle');
|
||||
yt.setAttribute('fill', '#333');
|
||||
yt.setAttribute('font-family', 'sans-serif');
|
||||
yt.setAttribute('transform', `rotate(-90, ${pad - 5}, ${pad + sy / 2})`);
|
||||
yt.textContent = `${sy} mm`;
|
||||
svg.appendChild(yt);
|
||||
|
||||
const grid = data.grid;
|
||||
const well = data.well;
|
||||
const tip = data.tip;
|
||||
const tube = data.tube;
|
||||
|
||||
if (grid && (well || tip || tube)) {
|
||||
const nx = grid.num_items_x || 1;
|
||||
const ny = grid.num_items_y || 1;
|
||||
const dx = grid.dx || 0;
|
||||
const dy = grid.dy || 0;
|
||||
const idx = grid.item_dx || 9;
|
||||
const idy = grid.item_dy || 9;
|
||||
|
||||
const child = well || tip || tube;
|
||||
const csx = child.size_x || child.spot_size_x || 8;
|
||||
const csy = child.size_y || child.spot_size_y || 8;
|
||||
|
||||
const isCircle = well ? (well.cross_section_type === 'CIRCLE') : (!!tip);
|
||||
|
||||
// 行列标签
|
||||
for (let col = 0; col < nx; col++) {
|
||||
const cx = pad + dx + csx / 2 + col * idx;
|
||||
_text(svg, cx, pad + sy + 5, String(col + 1), '2.5', 'middle', '#999');
|
||||
}
|
||||
const rowLabels = 'ABCDEFGHIJKLMNOP';
|
||||
for (let row = 0; row < ny; row++) {
|
||||
const cy = pad + dy + csy / 2 + row * idy;
|
||||
_text(svg, pad - 4, cy + 1, rowLabels[row] || String(row), '2.5', 'middle', '#999');
|
||||
}
|
||||
|
||||
// 绘制孔位
|
||||
for (let col = 0; col < nx; col++) {
|
||||
for (let row = 0; row < ny; row++) {
|
||||
const cx = pad + dx + csx / 2 + col * idx;
|
||||
const cy = pad + dy + csy / 2 + row * idy;
|
||||
|
||||
let el;
|
||||
if (isCircle) {
|
||||
const r = Math.min(csx, csy) / 2;
|
||||
el = _circle(svg, cx, cy, r, '#fff', color);
|
||||
} else {
|
||||
el = _rect(svg, cx - csx / 2, cy - csy / 2, csx, csy, '#fff', color);
|
||||
}
|
||||
|
||||
const label = (rowLabels[row] || '') + String(col + 1);
|
||||
_title(el, `${label}: ${csx}x${csy} mm`);
|
||||
|
||||
// hover 效果
|
||||
el.style.cursor = 'pointer';
|
||||
el.addEventListener('mouseenter', () => el.setAttribute('fill', color + '44'));
|
||||
el.addEventListener('mouseleave', () => el.setAttribute('fill', '#fff'));
|
||||
}
|
||||
}
|
||||
|
||||
// dx / dy 标注(板边到首个子元素左上角)
|
||||
const dimColor = '#e67e22';
|
||||
const firstLeft = pad + dx; // 首列子元素左边 X
|
||||
const firstTop = pad + dy; // 首行子元素上边 Y
|
||||
if (dx > 0.1) {
|
||||
// dx: 板左边 → 首列子元素左边,画在第一行子元素中心高度
|
||||
const annY = firstTop + csy / 2;
|
||||
_line(svg, pad, annY, firstLeft, annY, dimColor, '1,1');
|
||||
_line(svg, pad, annY - 2, pad, annY + 2, dimColor);
|
||||
_line(svg, firstLeft, annY - 2, firstLeft, annY + 2, dimColor);
|
||||
_text(svg, pad + dx / 2, annY - 2, `dx=${dx}`, '2.5', 'middle', dimColor);
|
||||
}
|
||||
if (dy > 0.1) {
|
||||
// dy: 板上边 → 首行子元素上边,画在第一列子元素中心宽度
|
||||
const annX = firstLeft + csx / 2;
|
||||
_line(svg, annX, pad, annX, firstTop, dimColor, '1,1');
|
||||
_line(svg, annX - 2, pad, annX + 2, pad, dimColor);
|
||||
_line(svg, annX - 2, firstTop, annX + 2, firstTop, dimColor);
|
||||
_text(svg, annX + 4, pad + dy / 2 + 1, `dy=${dy}`, '2.5', 'start', dimColor);
|
||||
}
|
||||
} else if (data.type === 'plate_adapter' && data.adapter) {
|
||||
// 绘制适配器凹槽
|
||||
const adp = data.adapter;
|
||||
const ahx = adp.adapter_hole_size_x || 127;
|
||||
const ahy = adp.adapter_hole_size_y || 85;
|
||||
const adx = adp.dx != null ? adp.dx : (sx - ahx) / 2;
|
||||
const ady = adp.dy != null ? adp.dy : (sy - ahy) / 2;
|
||||
_rect(svg, pad + adx, pad + ady, ahx, ahy, '#f0f0ff', '#8b5cf6', 2);
|
||||
_text(svg, pad + adx + ahx / 2, pad + ady + ahy / 2, `${ahx}x${ahy}`, '4', 'middle', '#8b5cf6');
|
||||
} else if (data.type === 'trash') {
|
||||
// 简单标记
|
||||
_text(svg, pad + sx / 2, pad + sy / 2, 'TRASH', '8', 'middle', '#ef4444');
|
||||
}
|
||||
|
||||
container.appendChild(svg);
|
||||
_enableZoomPan(svg, `0 0 ${w} ${h}`);
|
||||
}
|
||||
|
||||
// ==================== 侧面截面图 ====================
|
||||
function renderSideProfile(container, data) {
|
||||
container.innerHTML = '';
|
||||
if (!data) return;
|
||||
|
||||
const pad = 20;
|
||||
const sx = data.size_x || 127;
|
||||
const sz = data.size_z || 20;
|
||||
|
||||
// 按比例缩放,侧面以 X-Z 面
|
||||
const scaleH = Math.max(1, sz / 60); // 让较矮的板子不会太小
|
||||
|
||||
// 计算枪头露出高度(仅 tip_rack)
|
||||
const tip = data.tip;
|
||||
const grid = data.grid;
|
||||
let tipAbove = 0;
|
||||
if (data.type === 'tip_rack' && tip) {
|
||||
if (tip.tip_above_rack_length != null && tip.tip_above_rack_length > 0) {
|
||||
tipAbove = tip.tip_above_rack_length;
|
||||
} else if (tip.tip_length && grid) {
|
||||
const dz = grid.dz || 0;
|
||||
const calc = tip.tip_length - (sz - dz);
|
||||
if (calc > 0) tipAbove = calc;
|
||||
}
|
||||
}
|
||||
|
||||
const drawW = sx;
|
||||
const drawH = sz;
|
||||
const w = drawW + pad * 2 + 30; // 额外空间给标注
|
||||
const h = drawH + tipAbove + pad * 2 + 10;
|
||||
const svg = _makeSVG(w, h);
|
||||
|
||||
const color = TYPE_COLORS[data.type] || '#3b82f6';
|
||||
const baseY = pad + tipAbove + drawH; // 底部 Y
|
||||
const rackTopY = pad + tipAbove; // rack 顶部 Y
|
||||
|
||||
// 板壳矩形
|
||||
_rect(svg, pad, rackTopY, drawW, drawH, color + '15', color);
|
||||
|
||||
// 尺寸标注
|
||||
// X 方向
|
||||
_line(svg, pad, baseY + 5, pad + drawW, baseY + 5, '#333');
|
||||
_text(svg, pad + drawW / 2, baseY + 12, `${sx} mm`, '3.5', 'middle', '#333');
|
||||
|
||||
// Z 方向
|
||||
_line(svg, pad + drawW + 5, rackTopY, pad + drawW + 5, baseY, '#333');
|
||||
const zt = document.createElementNS(_svgNS(), 'text');
|
||||
zt.setAttribute('x', pad + drawW + 12);
|
||||
zt.setAttribute('y', rackTopY + drawH / 2);
|
||||
zt.setAttribute('font-size', '3.5');
|
||||
zt.setAttribute('text-anchor', 'middle');
|
||||
zt.setAttribute('fill', '#333');
|
||||
zt.setAttribute('font-family', 'sans-serif');
|
||||
zt.setAttribute('transform', `rotate(-90, ${pad + drawW + 12}, ${rackTopY + drawH / 2})`);
|
||||
zt.textContent = `${sz} mm`;
|
||||
svg.appendChild(zt);
|
||||
|
||||
const well = data.well;
|
||||
const tube = data.tube;
|
||||
|
||||
if (grid && (well || tip || tube)) {
|
||||
const dx = grid.dx || 0;
|
||||
const dz = grid.dz || 0;
|
||||
const idx = grid.item_dx || 9;
|
||||
const nx = Math.min(grid.num_items_x || 1, 24); // 最多画24列
|
||||
const dimColor = '#e67e22';
|
||||
|
||||
const child = well || tube;
|
||||
const childTip = tip;
|
||||
|
||||
if (child) {
|
||||
const csx = child.size_x || 8;
|
||||
const csz = child.size_z || 10;
|
||||
const bt = well ? (well.bottom_type || 'FLAT') : 'FLAT';
|
||||
|
||||
// 画几个代表性的孔截面
|
||||
const nDraw = Math.min(nx, 12);
|
||||
for (let i = 0; i < nDraw; i++) {
|
||||
const cx = pad + dx + csx / 2 + i * idx;
|
||||
const topZ = baseY - dz - csz;
|
||||
const botZ = baseY - dz;
|
||||
|
||||
// 孔壁
|
||||
_rect(svg, cx - csx / 2, topZ, csx, csz, '#e0e8ff', color, 0.5);
|
||||
|
||||
// 底部形状
|
||||
if (bt === 'V') {
|
||||
// V 底 三角
|
||||
const triH = Math.min(csx / 2, csz * 0.3);
|
||||
const p = document.createElementNS(_svgNS(), 'polygon');
|
||||
p.setAttribute('points',
|
||||
`${cx - csx / 2},${botZ - triH} ${cx},${botZ} ${cx + csx / 2},${botZ - triH}`);
|
||||
p.setAttribute('fill', color + '33');
|
||||
p.setAttribute('stroke', color);
|
||||
p.setAttribute('stroke-width', '0.3');
|
||||
svg.appendChild(p);
|
||||
} else if (bt === 'U') {
|
||||
// U 底 圆弧
|
||||
const arcR = csx / 2;
|
||||
const p = document.createElementNS(_svgNS(), 'path');
|
||||
p.setAttribute('d', `M ${cx - csx / 2} ${botZ - arcR} A ${arcR} ${arcR} 0 0 0 ${cx + csx / 2} ${botZ - arcR}`);
|
||||
p.setAttribute('fill', color + '33');
|
||||
p.setAttribute('stroke', color);
|
||||
p.setAttribute('stroke-width', '0.3');
|
||||
svg.appendChild(p);
|
||||
}
|
||||
}
|
||||
|
||||
// dz 标注
|
||||
if (dz > 0) {
|
||||
const lx = pad + dx + 0.5 * idx * nDraw + csx / 2 + 5;
|
||||
_line(svg, lx, baseY, lx, baseY - dz, dimColor, '1,1');
|
||||
_line(svg, lx - 2, baseY, lx + 2, baseY, dimColor);
|
||||
_line(svg, lx - 2, baseY - dz, lx + 2, baseY - dz, dimColor);
|
||||
_text(svg, lx + 4, baseY - dz / 2 + 1, `dz=${dz}`, '2.5', 'start', dimColor);
|
||||
}
|
||||
// dx 标注
|
||||
if (dx > 0.1) {
|
||||
const annY = rackTopY + 4;
|
||||
_line(svg, pad, annY, pad + dx, annY, dimColor, '1,1');
|
||||
_line(svg, pad, annY - 2, pad, annY + 2, dimColor);
|
||||
_line(svg, pad + dx, annY - 2, pad + dx, annY + 2, dimColor);
|
||||
_text(svg, pad + dx / 2, annY - 2, `dx=${dx}`, '2.5', 'middle', dimColor);
|
||||
}
|
||||
}
|
||||
|
||||
if (childTip) {
|
||||
// 枪头截面
|
||||
const tipLen = childTip.tip_length || 50;
|
||||
const nDraw = Math.min(nx, 12);
|
||||
for (let i = 0; i < nDraw; i++) {
|
||||
const cx = pad + dx + 3.5 + i * idx;
|
||||
// 枪头顶部 = rack顶部 - 露出长度
|
||||
const tipTopZ = rackTopY - tipAbove;
|
||||
const drawLen = Math.min(tipLen, sz - dz + tipAbove);
|
||||
|
||||
// 枪头轮廓 (梯形)
|
||||
const topW = 4;
|
||||
const botW = 1.5;
|
||||
const p = document.createElementNS(_svgNS(), 'polygon');
|
||||
p.setAttribute('points',
|
||||
`${cx - topW / 2},${tipTopZ} ${cx + topW / 2},${tipTopZ} ${cx + botW / 2},${tipTopZ + drawLen} ${cx - botW / 2},${tipTopZ + drawLen}`);
|
||||
p.setAttribute('fill', '#10b98133');
|
||||
p.setAttribute('stroke', '#10b981');
|
||||
p.setAttribute('stroke-width', '0.3');
|
||||
svg.appendChild(p);
|
||||
}
|
||||
|
||||
// dz 标注
|
||||
if (dz > 0) {
|
||||
const lx = pad + dx + nDraw * idx + 5;
|
||||
_line(svg, lx, baseY, lx, baseY - dz, dimColor, '1,1');
|
||||
_line(svg, lx - 2, baseY, lx + 2, baseY, dimColor);
|
||||
_line(svg, lx - 2, baseY - dz, lx + 2, baseY - dz, dimColor);
|
||||
_text(svg, lx + 4, baseY - dz / 2 + 1, `dz=${dz}`, '2.5', 'start', dimColor);
|
||||
}
|
||||
// dx 标注
|
||||
if (dx > 0.1) {
|
||||
const annY = rackTopY + 4;
|
||||
_line(svg, pad, annY, pad + dx, annY, dimColor, '1,1');
|
||||
_line(svg, pad, annY - 2, pad, annY + 2, dimColor);
|
||||
_line(svg, pad + dx, annY - 2, pad + dx, annY + 2, dimColor);
|
||||
_text(svg, pad + dx / 2, annY - 2, `dx=${dx}`, '2.5', 'middle', dimColor);
|
||||
}
|
||||
|
||||
// 露出长度标注线
|
||||
if (tipAbove > 0) {
|
||||
const annotX = pad + dx + nDraw * idx + 8;
|
||||
// rack 顶部水平参考线
|
||||
_line(svg, annotX - 3, rackTopY, annotX + 3, rackTopY, '#10b981');
|
||||
// 枪头顶部水平参考线
|
||||
_line(svg, annotX - 3, rackTopY - tipAbove, annotX + 3, rackTopY - tipAbove, '#10b981');
|
||||
// 竖直标注线
|
||||
_line(svg, annotX, rackTopY - tipAbove, annotX, rackTopY, '#10b981', '1,1');
|
||||
_text(svg, annotX + 2, rackTopY - tipAbove / 2 + 1, `露出=${Math.round(tipAbove * 100) / 100}mm`, '2.5', 'start', '#10b981');
|
||||
}
|
||||
}
|
||||
} else if (data.type === 'plate_adapter' && data.adapter) {
|
||||
const adp = data.adapter;
|
||||
const ahz = adp.adapter_hole_size_z || 10;
|
||||
const adz = adp.dz || 0;
|
||||
const adx_val = adp.dx != null ? adp.dx : (sx - (adp.adapter_hole_size_x || 127)) / 2;
|
||||
const ahx = adp.adapter_hole_size_x || 127;
|
||||
|
||||
// 凹槽截面
|
||||
_rect(svg, pad + adx_val, rackTopY + adz, ahx, ahz, '#ede9fe', '#8b5cf6');
|
||||
_text(svg, pad + adx_val + ahx / 2, rackTopY + adz + ahz / 2 + 1, `hole: ${ahz}mm deep`, '3', 'middle', '#8b5cf6');
|
||||
} else if (data.type === 'trash') {
|
||||
_text(svg, pad + drawW / 2, rackTopY + drawH / 2, 'TRASH', '8', 'middle', '#ef4444');
|
||||
}
|
||||
|
||||
container.appendChild(svg);
|
||||
_enableZoomPan(svg, `0 0 ${w} ${h}`);
|
||||
}
|
||||
|
||||
// ==================== 缩放 & 平移 ====================
|
||||
function _enableZoomPan(svgEl, origViewBox) {
|
||||
const parts = origViewBox.split(' ').map(Number);
|
||||
let vx = parts[0], vy = parts[1], vw = parts[2], vh = parts[3];
|
||||
const origVx = vx, origVy = vy, origW = vw, origH = vh;
|
||||
const MIN_SCALE = 0.5, MAX_SCALE = 5;
|
||||
|
||||
function applyViewBox() {
|
||||
svgEl.setAttribute('viewBox', `${vx} ${vy} ${vw} ${vh}`);
|
||||
}
|
||||
|
||||
function resetView() {
|
||||
vx = origVx; vy = origVy; vw = origW; vh = origH;
|
||||
applyViewBox();
|
||||
}
|
||||
|
||||
// 将 resetView 挂到 svg 元素上,方便外部调用
|
||||
svgEl._resetView = resetView;
|
||||
|
||||
svgEl.addEventListener('wheel', function (e) {
|
||||
e.preventDefault();
|
||||
if (e.ctrlKey) {
|
||||
// pinch / ctrl+scroll → 缩放
|
||||
const factor = e.deltaY > 0 ? 1.08 : 1 / 1.08;
|
||||
const newW = vw * factor;
|
||||
const newH = vh * factor;
|
||||
// 限制缩放范围
|
||||
if (newW < origW / MAX_SCALE || newW > origW * (1 / MIN_SCALE)) return;
|
||||
// 以鼠标位置为缩放中心
|
||||
const rect = svgEl.getBoundingClientRect();
|
||||
const mx = (e.clientX - rect.left) / rect.width;
|
||||
const my = (e.clientY - rect.top) / rect.height;
|
||||
vx += (vw - newW) * mx;
|
||||
vy += (vh - newH) * my;
|
||||
vw = newW;
|
||||
vh = newH;
|
||||
} else {
|
||||
// 普通滚轮 → 平移
|
||||
const panSpeed = vw * 0.002;
|
||||
vx += e.deltaX * panSpeed;
|
||||
vy += e.deltaY * panSpeed;
|
||||
}
|
||||
applyViewBox();
|
||||
}, { passive: false });
|
||||
}
|
||||
|
||||
// 回中按钮:重置指定容器内 SVG 的 viewBox
|
||||
function resetSvgView(containerId) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
const svg = container.querySelector('svg');
|
||||
if (svg && svg._resetView) svg._resetView();
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
/* PRCXI 耗材管理 - 全局样式 */
|
||||
|
||||
:root {
|
||||
--c-primary: #3b82f6;
|
||||
--c-primary-dark: #2563eb;
|
||||
--c-danger: #ef4444;
|
||||
--c-warning: #f59e0b;
|
||||
--c-success: #10b981;
|
||||
--c-gray-50: #f9fafb;
|
||||
--c-gray-100: #f3f4f6;
|
||||
--c-gray-200: #e5e7eb;
|
||||
--c-gray-300: #d1d5db;
|
||||
--c-gray-500: #6b7280;
|
||||
--c-gray-700: #374151;
|
||||
--c-gray-900: #111827;
|
||||
--radius: 8px;
|
||||
--shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--c-gray-50);
|
||||
color: var(--c-gray-900);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 顶部导航 */
|
||||
.topbar {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--c-gray-200);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
height: 56px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
.topbar .logo {
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
color: var(--c-gray-900);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* 容器 */
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* 页头 */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.page-header h1 { font-size: 1.5rem; }
|
||||
.header-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
/* 按钮 */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
text-decoration: none;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-sm { padding: 4px 10px; font-size: 0.8rem; }
|
||||
.btn-primary { background: var(--c-primary); color: #fff; }
|
||||
.btn-primary:hover { background: var(--c-primary-dark); }
|
||||
.btn-outline { background: #fff; color: var(--c-gray-700); border-color: var(--c-gray-300); }
|
||||
.btn-outline:hover { background: var(--c-gray-100); }
|
||||
.btn-danger { background: var(--c-danger); color: #fff; }
|
||||
.btn-danger:hover { background: #dc2626; }
|
||||
.btn-warning { background: var(--c-warning); color: #fff; }
|
||||
.btn-warning:hover { background: #d97706; }
|
||||
|
||||
/* 徽章 */
|
||||
.badge {
|
||||
background: var(--c-gray-200);
|
||||
color: var(--c-gray-700);
|
||||
font-size: 0.8rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* 状态消息 */
|
||||
.status-msg {
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 16px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.msg-ok { background: #d1fae5; color: #065f46; }
|
||||
.msg-err { background: #fee2e2; color: #991b1b; }
|
||||
|
||||
/* 类型分段 */
|
||||
.type-section { margin-bottom: 32px; }
|
||||
.type-section h2 {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.type-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* 卡片网格 */
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 耗材卡片 */
|
||||
.labware-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--c-gray-200);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s, border-color 0.2s;
|
||||
}
|
||||
.labware-card:hover {
|
||||
border-color: var(--c-primary);
|
||||
box-shadow: 0 4px 12px rgba(59,130,246,0.15);
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: var(--c-gray-900);
|
||||
word-break: break-all;
|
||||
}
|
||||
.card-body { font-size: 0.85rem; color: var(--c-gray-500); }
|
||||
.card-info { margin-bottom: 2px; }
|
||||
.card-info .label { color: var(--c-gray-700); font-weight: 500; }
|
||||
.card-footer {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
border-top: 1px solid var(--c-gray-100);
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
/* 标签 */
|
||||
.tag {
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.tag-tpl { background: #dbeafe; color: #1e40af; }
|
||||
.tag-plate { background: #dbeafe; color: #1e40af; }
|
||||
.tag-tip_rack { background: #d1fae5; color: #065f46; }
|
||||
.tag-trash { background: #fee2e2; color: #991b1b; }
|
||||
.tag-tube_rack { background: #fef3c7; color: #92400e; }
|
||||
.tag-plate_adapter { background: #ede9fe; color: #5b21b6; }
|
||||
|
||||
/* 详情页布局 */
|
||||
.detail-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.detail-layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
.detail-info, .detail-viz { display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.info-card, .viz-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--c-gray-200);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
}
|
||||
.info-card h3, .viz-card h3 {
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 12px;
|
||||
color: var(--c-gray-700);
|
||||
border-bottom: 1px solid var(--c-gray-100);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.info-table { width: 100%; font-size: 0.85rem; }
|
||||
.info-table td { padding: 4px 8px; border-bottom: 1px solid var(--c-gray-100); }
|
||||
.info-table .label { color: var(--c-gray-500); font-weight: 500; width: 140px; }
|
||||
.info-table code { background: var(--c-gray-100); padding: 1px 4px; border-radius: 3px; font-size: 0.8rem; }
|
||||
.info-table code.small { font-size: 0.7rem; }
|
||||
|
||||
/* SVG 容器 */
|
||||
#svg-topdown, #svg-side {
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
overflow: hidden;
|
||||
}
|
||||
#svg-topdown svg, #svg-side svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* 编辑页布局 */
|
||||
.edit-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.edit-layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
.edit-form { display: flex; flex-direction: column; gap: 16px; }
|
||||
.edit-preview { display: flex; flex-direction: column; gap: 16px; position: sticky; top: 72px; align-self: start; }
|
||||
|
||||
/* 表单 */
|
||||
.form-section {
|
||||
background: #fff;
|
||||
border: 1px solid var(--c-gray-200);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
}
|
||||
.form-section h3 {
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 12px;
|
||||
color: var(--c-gray-700);
|
||||
}
|
||||
.form-row { margin-bottom: 10px; }
|
||||
.form-row label { display: block; font-size: 0.8rem; color: var(--c-gray-500); margin-bottom: 4px; font-weight: 500; }
|
||||
.form-row input, .form-row select, .form-row textarea {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--c-gray-300);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.form-row input:focus, .form-row select:focus, .form-row textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--c-primary);
|
||||
box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
|
||||
}
|
||||
.form-row-2, .form-row-3 { display: grid; gap: 12px; margin-bottom: 10px; }
|
||||
.form-row-2 { grid-template-columns: 1fr 1fr; }
|
||||
.form-row-3 { grid-template-columns: 1fr 1fr 1fr; }
|
||||
.form-row-2 label, .form-row-3 label { display: block; font-size: 0.8rem; color: var(--c-gray-500); margin-bottom: 4px; font-weight: 500; }
|
||||
.form-row-2 input, .form-row-2 select,
|
||||
.form-row-3 input, .form-row-3 select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--c-gray-300);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.form-row-2 input:focus, .form-row-3 input:focus {
|
||||
outline: none;
|
||||
border-color: var(--c-primary);
|
||||
box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* 双语标签中文部分 */
|
||||
.label-cn { color: var(--c-gray-400, #9ca3af); font-weight: 400; margin-left: 4px; }
|
||||
@@ -1,24 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}PRCXI 耗材管理{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
{% block head_extra %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<nav class="topbar">
|
||||
<a href="/" class="logo">PRCXI 耗材管理</a>
|
||||
<div class="nav-actions">
|
||||
<a href="/labware/new" class="btn btn-primary btn-sm">+ 新建耗材</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,159 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ item.function_name }} - PRCXI{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>{{ item.function_name }}</h1>
|
||||
<div class="header-actions">
|
||||
<a href="/labware/{{ item.function_name }}/edit" class="btn btn-primary">编辑</a>
|
||||
<a href="/" class="btn btn-outline">返回列表</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-layout">
|
||||
<!-- 左侧: 信息 -->
|
||||
<div class="detail-info">
|
||||
<div class="info-card">
|
||||
<h3>基本信息</h3>
|
||||
<table class="info-table">
|
||||
<tr><td class="label">类型</td><td><span class="tag tag-{{ item.type }}">{{ item.type }}</span></td></tr>
|
||||
<tr><td class="label">函数名</td><td><code>{{ item.function_name }}</code></td></tr>
|
||||
<tr><td class="label">Model</td><td>{{ item.model or '-' }}</td></tr>
|
||||
{% if item.plate_type %}
|
||||
<tr><td class="label">Plate Type</td><td>{{ item.plate_type }}</td></tr>
|
||||
{% endif %}
|
||||
<tr><td class="label">Docstring</td><td>{{ item.docstring or '-' }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<h3>物理尺寸 (mm)</h3>
|
||||
<table class="info-table">
|
||||
<tr><td class="label">X</td><td>{{ item.size_x }}</td></tr>
|
||||
<tr><td class="label">Y</td><td>{{ item.size_y }}</td></tr>
|
||||
<tr><td class="label">Z</td><td>{{ item.size_z }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<h3>材料信息</h3>
|
||||
<table class="info-table">
|
||||
<tr><td class="label">UUID</td><td><code class="small">{{ item.material_info.uuid }}</code></td></tr>
|
||||
<tr><td class="label">Code</td><td>{{ item.material_info.Code }}</td></tr>
|
||||
<tr><td class="label">Name</td><td>{{ item.material_info.Name }}</td></tr>
|
||||
{% if item.material_info.materialEnum is not none %}
|
||||
<tr><td class="label">materialEnum</td><td>{{ item.material_info.materialEnum }}</td></tr>
|
||||
{% endif %}
|
||||
{% if item.material_info.SupplyType is not none %}
|
||||
<tr><td class="label">SupplyType</td><td>{{ item.material_info.SupplyType }}</td></tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if item.grid %}
|
||||
<div class="info-card">
|
||||
<h3>网格排列</h3>
|
||||
<table class="info-table">
|
||||
<tr><td class="label">列 x 行</td><td>{{ item.grid.num_items_x }} x {{ item.grid.num_items_y }}</td></tr>
|
||||
<tr><td class="label">dx, dy, dz</td><td>{{ item.grid.dx }}, {{ item.grid.dy }}, {{ item.grid.dz }}</td></tr>
|
||||
<tr><td class="label">item_dx, item_dy</td><td>{{ item.grid.item_dx }}, {{ item.grid.item_dy }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if item.well %}
|
||||
<div class="info-card">
|
||||
<h3>孔参数 (Well)</h3>
|
||||
<table class="info-table">
|
||||
<tr><td class="label">尺寸</td><td>{{ item.well.size_x }} x {{ item.well.size_y }} x {{ item.well.size_z }}</td></tr>
|
||||
{% if item.well.max_volume is not none %}
|
||||
<tr><td class="label">最大体积</td><td>{{ item.well.max_volume }} uL</td></tr>
|
||||
{% endif %}
|
||||
<tr><td class="label">底部类型</td><td>{{ item.well.bottom_type }}</td></tr>
|
||||
<tr><td class="label">截面类型</td><td>{{ item.well.cross_section_type }}</td></tr>
|
||||
{% if item.well.material_z_thickness is not none %}
|
||||
<tr><td class="label">材料Z厚度</td><td>{{ item.well.material_z_thickness }}</td></tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if item.tip %}
|
||||
<div class="info-card">
|
||||
<h3>枪头参数 (Tip)</h3>
|
||||
<table class="info-table">
|
||||
<tr><td class="label">Spot 尺寸</td><td>{{ item.tip.spot_size_x }} x {{ item.tip.spot_size_y }} x {{ item.tip.spot_size_z }}</td></tr>
|
||||
<tr><td class="label">容量</td><td>{{ item.tip.tip_volume }} uL</td></tr>
|
||||
<tr><td class="label">长度</td><td>{{ item.tip.tip_length }} mm</td></tr>
|
||||
<tr><td class="label">取枪头插入深度</td><td>{{ item.tip.tip_fitting_depth }} mm</td></tr>
|
||||
{% if item.tip.tip_above_rack_length is not none %}
|
||||
<tr><td class="label">枪头露出枪头盒长度</td><td>{{ item.tip.tip_above_rack_length }} mm</td></tr>
|
||||
{% endif %}
|
||||
<tr><td class="label">有滤芯</td><td>{{ item.tip.has_filter }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if item.tube %}
|
||||
<div class="info-card">
|
||||
<h3>管参数 (Tube)</h3>
|
||||
<table class="info-table">
|
||||
<tr><td class="label">尺寸</td><td>{{ item.tube.size_x }} x {{ item.tube.size_y }} x {{ item.tube.size_z }}</td></tr>
|
||||
<tr><td class="label">最大体积</td><td>{{ item.tube.max_volume }} uL</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if item.adapter %}
|
||||
<div class="info-card">
|
||||
<h3>适配器参数</h3>
|
||||
<table class="info-table">
|
||||
<tr><td class="label">Hole 尺寸</td><td>{{ item.adapter.adapter_hole_size_x }} x {{ item.adapter.adapter_hole_size_y }} x {{ item.adapter.adapter_hole_size_z }}</td></tr>
|
||||
<tr><td class="label">dx, dy, dz</td><td>{{ item.adapter.dx }}, {{ item.adapter.dy }}, {{ item.adapter.dz }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="info-card">
|
||||
<h3>Registry</h3>
|
||||
<table class="info-table">
|
||||
<tr><td class="label">分类</td><td>{{ item.registry_category | join(' / ') }}</td></tr>
|
||||
<tr><td class="label">描述</td><td>{{ item.registry_description }}</td></tr>
|
||||
<tr><td class="label">模板匹配</td><td>{{ item.include_in_template_matching }}</td></tr>
|
||||
{% if item.template_kind %}
|
||||
<tr><td class="label">模板类型</td><td>{{ item.template_kind }}</td></tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧: 可视化 -->
|
||||
<div class="detail-viz">
|
||||
<div class="viz-card">
|
||||
<h3 style="display:flex;align-items:center;justify-content:space-between;">
|
||||
俯视图 (Top-Down)
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="resetSvgView('svg-topdown')">回中</button>
|
||||
</h3>
|
||||
<div id="svg-topdown"></div>
|
||||
</div>
|
||||
<div class="viz-card">
|
||||
<h3 style="display:flex;align-items:center;justify-content:space-between;">
|
||||
侧面截面图 (Side Profile)
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="resetSvgView('svg-side')">回中</button>
|
||||
</h3>
|
||||
<div id="svg-side"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/labware_viz.js"></script>
|
||||
<script>
|
||||
const itemData = {{ item.model_dump() | tojson }};
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
renderTopDown(document.getElementById('svg-topdown'), itemData);
|
||||
renderSideProfile(document.getElementById('svg-side'), itemData);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user