mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-05-23 15:11:22 +00:00
Compare commits
16 Commits
865dd87556
...
feat/3d_la
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c61dfb60e8 | ||
|
|
cf0cbb990d | ||
|
|
99dc821a01 | ||
|
|
a7a6d77d7a | ||
|
|
00bdf9b822 | ||
|
|
306b787aa7 | ||
|
|
5b3f317867 | ||
|
|
b0e98ccf2b | ||
|
|
b04dc8dd4a | ||
|
|
f4c0e40a25 | ||
|
|
569ac4a931 | ||
|
|
31e79e9aff | ||
|
|
6e1b26a754 | ||
|
|
9ef24b7768 | ||
|
|
64eeed56a1 | ||
|
|
3f75ca4ea3 |
@@ -27,15 +27,14 @@ python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{
|
||||
|
||||
### 2. --addr → BASE URL
|
||||
|
||||
| `--addr` 值 | BASE |
|
||||
| ------------ | ----------------------------------- |
|
||||
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||
| `local` | `http://127.0.0.1:48197` |
|
||||
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||
| `--addr` 值 | BASE |
|
||||
|-------------|------|
|
||||
| `test` | `https://uni-lab.test.bohrium.com` |
|
||||
| `uat` | `https://uni-lab.uat.bohrium.com` |
|
||||
| `local` | `http://127.0.0.1:48197` |
|
||||
| 不传(默认) | `https://uni-lab.bohrium.com` |
|
||||
|
||||
确认后设置:
|
||||
|
||||
```bash
|
||||
BASE="<根据 addr 确定的 URL>"
|
||||
AUTH="Authorization: Lab <gen_auth.py 输出的 token>"
|
||||
@@ -66,7 +65,7 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
||||
返回:
|
||||
|
||||
```json
|
||||
{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } }
|
||||
{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}}
|
||||
```
|
||||
|
||||
记住 `data.uuid` 为 `lab_uuid`。
|
||||
@@ -91,7 +90,6 @@ curl -s -X POST "$BASE/api/v1/lab/reagent" \
|
||||
```
|
||||
|
||||
返回成功时包含试剂 UUID:
|
||||
|
||||
```json
|
||||
{"code": 0, "data": {"uuid": "xxx", ...}}
|
||||
```
|
||||
@@ -100,28 +98,28 @@ curl -s -X POST "$BASE/api/v1/lab/reagent" \
|
||||
|
||||
## 试剂字段说明
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 | 示例 |
|
||||
| ------------------- | ------ | ---- | ----------------------------- | ------------------------ |
|
||||
| `lab_uuid` | string | 是 | 实验室 UUID(从 API #1 获取) | `"8511c672-..."` |
|
||||
| `cas` | string | 是 | CAS 注册号 | `"7732-18-3"` |
|
||||
| `name` | string | 是 | 试剂中文/英文名称 | `"水"` |
|
||||
| `molecular_formula` | string | 是 | 分子式 | `"H2O"` |
|
||||
| `smiles` | string | 是 | SMILES 表示 | `"O"` |
|
||||
| `stock_in_quantity` | number | 是 | 入库数量 | `10` |
|
||||
| `unit` | string | 是 | 单位(字符串,见下表) | `"mL"` |
|
||||
| `supplier` | string | 否 | 供应商名称 | `"国药集团"` |
|
||||
| `production_date` | string | 否 | 生产日期(ISO 8601) | `"2025-11-18T00:00:00Z"` |
|
||||
| `expiry_date` | string | 否 | 过期日期(ISO 8601) | `"2026-11-18T00:00:00Z"` |
|
||||
| 字段 | 类型 | 必填 | 说明 | 示例 |
|
||||
|------|------|------|------|------|
|
||||
| `lab_uuid` | string | 是 | 实验室 UUID(从 API #1 获取) | `"8511c672-..."` |
|
||||
| `cas` | string | 是 | CAS 注册号 | `"7732-18-3"` |
|
||||
| `name` | string | 是 | 试剂中文/英文名称 | `"水"` |
|
||||
| `molecular_formula` | string | 是 | 分子式 | `"H2O"` |
|
||||
| `smiles` | string | 是 | SMILES 表示 | `"O"` |
|
||||
| `stock_in_quantity` | number | 是 | 入库数量 | `10` |
|
||||
| `unit` | string | 是 | 单位(字符串,见下表) | `"mL"` |
|
||||
| `supplier` | string | 否 | 供应商名称 | `"国药集团"` |
|
||||
| `production_date` | string | 否 | 生产日期(ISO 8601) | `"2025-11-18T00:00:00Z"` |
|
||||
| `expiry_date` | string | 否 | 过期日期(ISO 8601) | `"2026-11-18T00:00:00Z"` |
|
||||
|
||||
### unit 单位值
|
||||
|
||||
| 值 | 单位 |
|
||||
| ------ | ---- |
|
||||
| 值 | 单位 |
|
||||
|------|------|
|
||||
| `"mL"` | 毫升 |
|
||||
| `"L"` | 升 |
|
||||
| `"g"` | 克 |
|
||||
| `"L"` | 升 |
|
||||
| `"g"` | 克 |
|
||||
| `"kg"` | 千克 |
|
||||
| `"瓶"` | 瓶 |
|
||||
| `"瓶"` | 瓶 |
|
||||
|
||||
> 根据试剂状态选择:液体用 `"mL"` / `"L"`,固体用 `"g"` / `"kg"`。
|
||||
|
||||
@@ -135,22 +133,8 @@ curl -s -X POST "$BASE/api/v1/lab/reagent" \
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"cas": "7732-18-3",
|
||||
"name": "水",
|
||||
"molecular_formula": "H2O",
|
||||
"smiles": "O",
|
||||
"stock_in_quantity": 10,
|
||||
"unit": "mL"
|
||||
},
|
||||
{
|
||||
"cas": "64-17-5",
|
||||
"name": "乙醇",
|
||||
"molecular_formula": "C2H6O",
|
||||
"smiles": "CCO",
|
||||
"stock_in_quantity": 5,
|
||||
"unit": "L"
|
||||
}
|
||||
{"cas": "7732-18-3", "name": "水", "molecular_formula": "H2O", "smiles": "O", "stock_in_quantity": 10, "unit": "mL"},
|
||||
{"cas": "64-17-5", "name": "乙醇", "molecular_formula": "C2H6O", "smiles": "CCO", "stock_in_quantity": 5, "unit": "L"}
|
||||
]
|
||||
```
|
||||
|
||||
@@ -176,20 +160,9 @@ cas,name,molecular_formula,smiles,stock_in_quantity,unit,supplier,production_dat
|
||||
7732-18-3,水,H2O,O,10,mL,农夫山泉,2025-11-18T00:00:00Z,2026-11-18T00:00:00Z
|
||||
```
|
||||
|
||||
### 日期格式规则(重要)
|
||||
|
||||
所有日期字段(`production_date`、`expiry_date`)**必须**使用 ISO 8601 完整格式:`YYYY-MM-DDTHH:MM:SSZ`。
|
||||
|
||||
- 用户输入 `2025-03-01` → 转换为 `"2025-03-01T00:00:00Z"`
|
||||
- 用户输入 `2025/9/1` → 转换为 `"2025-09-01T00:00:00Z"`
|
||||
- 用户未提供日期 → 使用当天日期 + `T00:00:00Z`,有效期默认 +1 年
|
||||
|
||||
**禁止**发送不带时间部分的日期字符串(如 `"2025-03-01"`),API 会拒绝。
|
||||
|
||||
### 执行与汇报
|
||||
|
||||
每次 API 调用后:
|
||||
|
||||
1. 检查返回 `code`(0 = 成功)
|
||||
2. 记录成功/失败数量
|
||||
3. 全部完成后汇总:「共录入 N 条试剂,成功 X 条,失败 Y 条」
|
||||
@@ -199,29 +172,28 @@ cas,name,molecular_formula,smiles,stock_in_quantity,unit,supplier,production_dat
|
||||
|
||||
## 常见试剂速查表
|
||||
|
||||
| 名称 | CAS | 分子式 | SMILES |
|
||||
| --------------------- | --------- | ---------- | ------------------------------------ |
|
||||
| 水 | 7732-18-3 | H2O | O |
|
||||
| 乙醇 | 64-17-5 | C2H6O | CCO |
|
||||
| 乙酸 | 64-19-7 | C2H4O2 | CC(O)=O |
|
||||
| 甲醇 | 67-56-1 | CH4O | CO |
|
||||
| 丙酮 | 67-64-1 | C3H6O | CC(C)=O |
|
||||
| 二甲基亚砜(DMSO) | 67-68-5 | C2H6OS | CS(C)=O |
|
||||
| 乙酸乙酯 | 141-78-6 | C4H8O2 | CCOC(C)=O |
|
||||
| 二氯甲烷 | 75-09-2 | CH2Cl2 | ClCCl |
|
||||
| 四氢呋喃(THF) | 109-99-9 | C4H8O | C1CCOC1 |
|
||||
| N,N-二甲基甲酰胺(DMF) | 68-12-2 | C3H7NO | CN(C)C=O |
|
||||
| 氯仿 | 67-66-3 | CHCl3 | ClC(Cl)Cl |
|
||||
| 乙腈 | 75-05-8 | C2H3N | CC#N |
|
||||
| 甲苯 | 108-88-3 | C7H8 | Cc1ccccc1 |
|
||||
| 正己烷 | 110-54-3 | C6H14 | CCCCCC |
|
||||
| 异丙醇 | 67-63-0 | C3H8O | CC(C)O |
|
||||
| 盐酸 | 7647-01-0 | HCl | Cl |
|
||||
| 硫酸 | 7664-93-9 | H2SO4 | OS(O)(=O)=O |
|
||||
| 氢氧化钠 | 1310-73-2 | NaOH | [Na]O |
|
||||
| 碳酸钠 | 497-19-8 | Na2CO3 | [Na]OC([O-])=O.[Na+] |
|
||||
| 氯化钠 | 7647-14-5 | NaCl | [Na]Cl |
|
||||
| 乙二胺四乙酸(EDTA) | 60-00-4 | C10H16N2O8 | OC(=O)CN(CCN(CC(O)=O)CC(O)=O)CC(O)=O |
|
||||
| 名称 | CAS | 分子式 | SMILES |
|
||||
|------|-----|--------|--------|
|
||||
| 水 | 7732-18-3 | H2O | O |
|
||||
| 乙醇 | 64-17-5 | C2H6O | CCO |
|
||||
| 甲醇 | 67-56-1 | CH4O | CO |
|
||||
| 丙酮 | 67-64-1 | C3H6O | CC(C)=O |
|
||||
| 二甲基亚砜(DMSO) | 67-68-5 | C2H6OS | CS(C)=O |
|
||||
| 乙酸乙酯 | 141-78-6 | C4H8O2 | CCOC(C)=O |
|
||||
| 二氯甲烷 | 75-09-2 | CH2Cl2 | ClCCl |
|
||||
| 四氢呋喃(THF) | 109-99-9 | C4H8O | C1CCOC1 |
|
||||
| N,N-二甲基甲酰胺(DMF) | 68-12-2 | C3H7NO | CN(C)C=O |
|
||||
| 氯仿 | 67-66-3 | CHCl3 | ClC(Cl)Cl |
|
||||
| 乙腈 | 75-05-8 | C2H3N | CC#N |
|
||||
| 甲苯 | 108-88-3 | C7H8 | Cc1ccccc1 |
|
||||
| 正己烷 | 110-54-3 | C6H14 | CCCCCC |
|
||||
| 异丙醇 | 67-63-0 | C3H8O | CC(C)O |
|
||||
| 盐酸 | 7647-01-0 | HCl | Cl |
|
||||
| 硫酸 | 7664-93-9 | H2SO4 | OS(O)(=O)=O |
|
||||
| 氢氧化钠 | 1310-73-2 | NaOH | [Na]O |
|
||||
| 碳酸钠 | 497-19-8 | Na2CO3 | [Na]OC([O-])=O.[Na+] |
|
||||
| 氯化钠 | 7647-14-5 | NaCl | [Na]Cl |
|
||||
| 乙二胺四乙酸(EDTA) | 60-00-4 | C10H16N2O8 | OC(=O)CN(CCN(CC(O)=O)CC(O)=O)CC(O)=O |
|
||||
|
||||
> 此表仅供快速参考。对于不在表中的试剂,agent 应根据化学知识推断或提示用户补充。
|
||||
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
---
|
||||
name: batch-submit-experiment
|
||||
description: Batch submit experiments (notebooks) to the Uni-Lab cloud platform (leap-lab) — 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, check notebook status. Use when the user wants to submit experiments, create notebooks, batch run workflows, check experiment status, or mentions 提交实验/批量实验/notebook/实验轮次/实验状态.
|
||||
---
|
||||
|
||||
# Uni-Lab 批量提交实验指南
|
||||
# 批量提交实验指南
|
||||
|
||||
通过 Uni-Lab 云端 API 批量提交实验(notebook),支持多轮实验参数配置。根据 workflow 模板详情和本地设备注册表自动生成 `node_params` 模板。
|
||||
|
||||
> **重要**:本指南中的 `Authorization: Lab <token>` 是 **Uni-Lab 平台专用的认证方式**,`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。
|
||||
通过云端 API 批量提交实验(notebook),支持多轮实验参数配置。根据 workflow 模板详情和本地设备注册表自动生成 `node_params` 模板。
|
||||
|
||||
## 前置条件(缺一不可)
|
||||
|
||||
@@ -20,28 +18,25 @@ description: Batch submit experiments (notebooks) to the Uni-Lab cloud platform
|
||||
生成 AUTH token(任选一种方式):
|
||||
|
||||
```bash
|
||||
# 方式一:Python 一行生成(注意:scheme 是 "Lab" 不是 "Basic")
|
||||
# 方式一:Python 一行生成
|
||||
python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
|
||||
|
||||
# 方式二:手动计算
|
||||
# base64(ak:sk) → Authorization: Lab <token>
|
||||
# ⚠️ 这里的 "Lab" 是 Uni-Lab 平台的 auth scheme,绝对不能用 "Basic" 替代
|
||||
```
|
||||
|
||||
### 2. --addr → BASE URL
|
||||
|
||||
| `--addr` 值 | BASE |
|
||||
| ------------ | ----------------------------------- |
|
||||
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||
| `local` | `http://127.0.0.1:48197` |
|
||||
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||
| `--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 scheme 必须是 "Lab"(Uni-Lab 专用),不是 "Basic"
|
||||
AUTH="Authorization: Lab <上面命令输出的 token>"
|
||||
```
|
||||
|
||||
@@ -49,19 +44,18 @@ AUTH="Authorization: Lab <上面命令输出的 token>"
|
||||
|
||||
**批量提交实验时需要本地注册表来解析 workflow 节点的参数 schema。**
|
||||
|
||||
**必须先用 Glob 工具搜索文件**,不要直接猜测路径:
|
||||
按优先级搜索:
|
||||
|
||||
```
|
||||
Glob: **/req_device_registry_upload.json
|
||||
<workspace 根目录>/unilabos_data/req_device_registry_upload.json
|
||||
<workspace 根目录>/req_device_registry_upload.json
|
||||
```
|
||||
|
||||
常见位置(仅供参考,以 Glob 实际结果为准):
|
||||
- `<workspace>/unilabos_data/req_device_registry_upload.json`
|
||||
- `<workspace>/req_device_registry_upload.json`
|
||||
也可直接 Glob 搜索:`**/req_device_registry_upload.json`
|
||||
|
||||
找到后**检查文件修改时间**并告知用户。超过 1 天提醒用户是否需要重新启动 `unilab`。
|
||||
|
||||
**如果 Glob 搜索无结果** → 告知用户先运行 `unilab` 启动命令,等注册表生成后再执行。可跳过此步,但将无法自动生成参数模板,需要用户手动填写 `param`。
|
||||
**如果文件不存在** → 告知用户先运行 `unilab` 启动命令,等注册表生成后再执行。可跳过此步,但将无法自动生成参数模板,需要用户手动填写 `param`。
|
||||
|
||||
### 4. workflow_uuid(目标工作流)
|
||||
|
||||
@@ -99,7 +93,7 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
||||
返回:
|
||||
|
||||
```json
|
||||
{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } }
|
||||
{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}}
|
||||
```
|
||||
|
||||
记住 `data.uuid` 为 `lab_uuid`。
|
||||
@@ -110,33 +104,9 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
||||
curl -s -X GET "$BASE/api/v1/lab/project/list?lab_uuid=$lab_uuid" -H "$AUTH"
|
||||
```
|
||||
|
||||
返回:
|
||||
返回项目列表,展示给用户选择。列出每个项目的 `uuid` 和 `name`。
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"uuid": "1b3f249a-...",
|
||||
"name": "bt",
|
||||
"description": null,
|
||||
"status": "active",
|
||||
"created_at": "2026-04-09T14:31:28+08:00"
|
||||
},
|
||||
{
|
||||
"uuid": "b6366243-...",
|
||||
"name": "default",
|
||||
"description": "默认项目",
|
||||
"status": "active",
|
||||
"created_at": "2026-03-26T11:13:36+08:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
展示 `data.items[]` 中每个项目的 `name` 和 `uuid`,让用户选择。用户**必须**选择一个项目,记住 `project_uuid`(即选中项目的 `uuid`),后续创建 notebook 时需要提供。
|
||||
用户**必须**选择一个项目,记住 `project_uuid`,后续创建 notebook 时需要提供。
|
||||
|
||||
### 3. 列出可用 workflow
|
||||
|
||||
@@ -153,7 +123,6 @@ curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$A
|
||||
```
|
||||
|
||||
返回 workflow 的完整结构,包含所有 action 节点信息。需要从响应中提取:
|
||||
|
||||
- 每个 action 节点的 `node_uuid`
|
||||
- 每个节点对应的设备 ID(`resource_template_name`)
|
||||
- 每个节点的动作名(`node_template_name`)
|
||||
@@ -173,30 +142,30 @@ 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": [
|
||||
{
|
||||
"sample_uuids": ["<样品UUID1>", "<样品UUID2>"],
|
||||
"datas": [
|
||||
"lab_uuid": "<lab_uuid>",
|
||||
"project_uuid": "<project_uuid>",
|
||||
"workflow_uuid": "<workflow_uuid>",
|
||||
"name": "<实验名称>",
|
||||
"node_params": [
|
||||
{
|
||||
"node_uuid": "<workflow中的节点UUID>",
|
||||
"param": {},
|
||||
"sample_params": [
|
||||
{
|
||||
"container_uuid": "<容器UUID>",
|
||||
"sample_value": {
|
||||
"liquid_names": "<液体名称>",
|
||||
"volumes": 1000
|
||||
}
|
||||
}
|
||||
]
|
||||
"sample_uuids": ["<样品UUID1>", "<样品UUID2>"],
|
||||
"datas": [
|
||||
{
|
||||
"node_uuid": "<workflow中的节点UUID>",
|
||||
"param": {},
|
||||
"sample_params": [
|
||||
{
|
||||
"container_uuid": "<容器UUID>",
|
||||
"sample_value": {
|
||||
"liquid_names": "<液体名称>",
|
||||
"volumes": 1000
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -225,25 +194,25 @@ curl -s -X GET "$BASE/api/v1/lab/notebook/status?uuid=$notebook_uuid" -H "$AUTH"
|
||||
|
||||
### 每轮的字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| -------------- | ------------- | ----------------------------------------- |
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `sample_uuids` | array\<uuid\> | 该轮实验的样品 UUID 数组,无样品时传 `[]` |
|
||||
| `datas` | array | 该轮中每个 workflow 节点的参数配置 |
|
||||
| `datas` | array | 该轮中每个 workflow 节点的参数配置 |
|
||||
|
||||
### datas 中每个节点
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --------------- | ------ | -------------------------------------------- |
|
||||
| `node_uuid` | string | workflow 模板中的节点 UUID(从 API #4 获取) |
|
||||
| `param` | object | 动作参数(根据本地注册表 schema 填写) |
|
||||
| `sample_params` | array | 样品相关参数(液体名、体积等) |
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `node_uuid` | string | workflow 模板中的节点 UUID(从 API #4 获取) |
|
||||
| `param` | object | 动作参数(根据本地注册表 schema 填写) |
|
||||
| `sample_params` | array | 样品相关参数(液体名、体积等) |
|
||||
|
||||
### sample_params 中每条
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ---------------- | ------ | ---------------------------------------------------- |
|
||||
| `container_uuid` | string | 容器 UUID |
|
||||
| `sample_value` | object | 样品值,如 `{"liquid_names": "水", "volumes": 1000}` |
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `container_uuid` | string | 容器 UUID |
|
||||
| `sample_value` | object | 样品值,如 `{"liquid_names": "水", "volumes": 1000}` |
|
||||
|
||||
---
|
||||
|
||||
@@ -264,7 +233,6 @@ python scripts/gen_notebook_params.py \
|
||||
> 脚本位于本文档同级目录下的 `scripts/gen_notebook_params.py`。
|
||||
|
||||
脚本会:
|
||||
|
||||
1. 调用 workflow detail API 获取所有 action 节点
|
||||
2. 读取本地注册表,为每个节点查找对应的 action schema
|
||||
3. 生成 `notebook_template.json`,包含:
|
||||
@@ -302,11 +270,8 @@ python scripts/gen_notebook_params.py \
|
||||
"properties": {
|
||||
"goal": {
|
||||
"properties": {
|
||||
"asp_vols": {
|
||||
"type": "array",
|
||||
"items": { "type": "number" }
|
||||
},
|
||||
"sources": { "type": "array" }
|
||||
"asp_vols": {"type": "array", "items": {"type": "number"}},
|
||||
"sources": {"type": "array"}
|
||||
},
|
||||
"required": ["asp_vols", "sources"]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
选项:
|
||||
--auth <token> Lab token(base64(ak:sk) 的结果,不含 "Lab " 前缀)
|
||||
--base <url> API 基础 URL(如 https://leap-lab.test.bohrium.com)
|
||||
--base <url> API 基础 URL(如 https://uni-lab.test.bohrium.com)
|
||||
--workflow-uuid <uuid> 目标 workflow 的 UUID
|
||||
--registry <path> 本地注册表文件路径(默认自动搜索)
|
||||
--rounds <n> 实验轮次数(默认 1)
|
||||
@@ -17,7 +17,7 @@
|
||||
示例:
|
||||
python gen_notebook_params.py \\
|
||||
--auth YTFmZDlkNGUtxxxx \\
|
||||
--base https://leap-lab.test.bohrium.com \\
|
||||
--base https://uni-lab.test.bohrium.com \\
|
||||
--workflow-uuid abc-123-def \\
|
||||
--rounds 2
|
||||
"""
|
||||
|
||||
@@ -40,13 +40,13 @@ python ./scripts/gen_auth.py --config <config.py>
|
||||
|
||||
决定 API 请求发往哪个服务器。从启动命令的 `--addr` 参数获取:
|
||||
|
||||
| `--addr` 值 | BASE URL |
|
||||
| -------------- | ----------------------------------- |
|
||||
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||
| `local` | `http://127.0.0.1:48197` |
|
||||
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||
| 其他自定义 URL | 直接使用该 URL |
|
||||
| `--addr` 值 | BASE URL |
|
||||
|-------------|----------|
|
||||
| `test` | `https://uni-lab.test.bohrium.com` |
|
||||
| `uat` | `https://uni-lab.uat.bohrium.com` |
|
||||
| `local` | `http://127.0.0.1:48197` |
|
||||
| 不传(默认) | `https://uni-lab.bohrium.com` |
|
||||
| 其他自定义 URL | 直接使用该 URL |
|
||||
|
||||
#### 必备项 ③:req_device_registry_upload.json(设备注册表)
|
||||
|
||||
@@ -54,11 +54,11 @@ python ./scripts/gen_auth.py --config <config.py>
|
||||
|
||||
**推断 working_dir**(即 `unilabos_data` 所在目录):
|
||||
|
||||
| 条件 | working_dir 取值 |
|
||||
| -------------------- | -------------------------------------------------------- |
|
||||
| 条件 | working_dir 取值 |
|
||||
|------|------------------|
|
||||
| 传了 `--working_dir` | `<working_dir>/unilabos_data/`(若子目录已存在则直接用) |
|
||||
| 仅传了 `--config` | `<config 文件所在目录>/unilabos_data/` |
|
||||
| 都没传 | `<当前工作目录>/unilabos_data/` |
|
||||
| 仅传了 `--config` | `<config 文件所在目录>/unilabos_data/` |
|
||||
| 都没传 | `<当前工作目录>/unilabos_data/` |
|
||||
|
||||
**按优先级搜索文件**:
|
||||
|
||||
@@ -84,6 +84,24 @@ python ./scripts/gen_auth.py --config <config.py>
|
||||
python ./scripts/extract_device_actions.py --registry <找到的文件路径>
|
||||
```
|
||||
|
||||
#### 完整示例
|
||||
|
||||
用户提供:
|
||||
|
||||
```
|
||||
--ak a1fd9d4e-xxxx-xxxx-xxxx-d9a69c09f0fd
|
||||
--sk 136ff5c6-xxxx-xxxx-xxxx-a03e301f827b
|
||||
--addr test
|
||||
--port 8003
|
||||
--disable_browser
|
||||
```
|
||||
|
||||
从中提取:
|
||||
- ✅ ak/sk → 运行 `gen_auth.py` 得到 `AUTH="Authorization: Lab YTFmZDlk..."`
|
||||
- ✅ addr=test → `BASE=https://uni-lab.test.bohrium.com`
|
||||
- ✅ 搜索 `unilabos_data/req_device_registry_upload.json` → 找到并确认时间
|
||||
- ✅ 用户指明目标设备 → 如 `liquid_handler.prcxi`
|
||||
|
||||
**四项全部就绪后才进入 Step 1。**
|
||||
|
||||
### Step 1 — 列出可用设备
|
||||
@@ -111,7 +129,6 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
|
||||
脚本会显示设备的 Python 源码路径和类名,方便阅读源码了解参数含义。
|
||||
|
||||
每个 action 生成一个 JSON 文件,包含:
|
||||
|
||||
- `type` — 作为 API 调用的 `action_type`
|
||||
- `schema` — 完整 JSON Schema(含 `properties.goal.properties` 参数定义)
|
||||
- `goal` — goal 字段映射(含占位符 `$placeholder`)
|
||||
@@ -119,14 +136,13 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
|
||||
|
||||
### Step 3 — 写 action-index.md
|
||||
|
||||
按模板为每个 action 写条目(**必须包含 `action_type`**):
|
||||
按模板为每个 action 写条目:
|
||||
|
||||
```markdown
|
||||
### `<action_name>`
|
||||
|
||||
<用途描述(一句话)>
|
||||
|
||||
- **action_type**: `<从 actions/<name>.json 的 type 字段获取>`
|
||||
- **Schema**: [`actions/<filename>.json`](actions/<filename>.json)
|
||||
- **核心参数**: `param1`, `param2`(从 schema.required 获取)
|
||||
- **可选参数**: `param3`, `param4`
|
||||
@@ -134,8 +150,6 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
|
||||
```
|
||||
|
||||
描述规则:
|
||||
|
||||
- **每个 action 必须标注 `action_type`**(从 JSON 的 `type` 字段读取),这是 API #9 调用时的必填参数,传错会导致任务永远卡住
|
||||
- 从 `schema.properties` 读参数列表(schema 已提升为 goal 内容)
|
||||
- 从 `schema.required` 区分核心/可选参数
|
||||
- 按功能分类(移液、枪头、外设等)
|
||||
@@ -151,7 +165,6 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
|
||||
### Step 4 — 写 SKILL.md
|
||||
|
||||
直接复用 `unilab-device-api` 的 API 模板,修改:
|
||||
|
||||
- 设备名称
|
||||
- Action 数量
|
||||
- 目录列表
|
||||
@@ -159,77 +172,42 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
|
||||
- **AUTH 头** — 使用 Step 0 中 `gen_auth.py` 生成的 `Authorization: Lab <token>`(不要硬编码 `Api` 类型的 key)
|
||||
- **Python 源码路径** — 在 SKILL.md 开头注明设备对应的源码文件,方便参考参数含义
|
||||
- **Slot 字段表** — 列出本设备哪些 action 的哪些字段需要填入 Slot(物料/设备/节点/类名)
|
||||
- **action_type 速查表** — 在 API #9 说明后面紧跟一个表格,列出每个 action 对应的 `action_type` 值(从 JSON `type` 字段提取),方便 agent 快速查找而无需打开 JSON 文件
|
||||
|
||||
API 模板结构:
|
||||
|
||||
```markdown
|
||||
## 设备信息
|
||||
|
||||
- device_id, Python 源码路径, 设备类名
|
||||
|
||||
## 前置条件(缺一不可)
|
||||
|
||||
- ak/sk → AUTH, --addr → BASE URL
|
||||
|
||||
## 请求约定
|
||||
|
||||
- Windows 平台必须用 curl.exe(非 PowerShell 的 curl 别名)
|
||||
|
||||
## Session State
|
||||
|
||||
- lab_uuid(通过 GET /edge/lab/info 直接获取,不要问用户), device_name
|
||||
|
||||
## API Endpoints
|
||||
|
||||
# - #1 GET /edge/lab/info → 直接拿到 lab_uuid
|
||||
|
||||
# - #2 创建工作流 POST /lab/workflow/owner → 拼 URL 告知用户
|
||||
|
||||
# - #3 创建节点 POST /edge/workflow/node
|
||||
|
||||
# body: {workflow_uuid, resource_template_name: "<device_id>", node_template_name: "<action_name>"}
|
||||
|
||||
# - #4 删除节点 DELETE /lab/workflow/nodes
|
||||
|
||||
# - #5 更新节点参数 PATCH /lab/workflow/node
|
||||
|
||||
# - #6 查询节点 handles POST /lab/workflow/node-handles
|
||||
|
||||
# body: {node_uuids: ["uuid1","uuid2"]} → 返回各节点的 handle_uuid
|
||||
|
||||
# - #7 批量创建边 POST /lab/workflow/edges
|
||||
|
||||
# body: {edges: [{source_node_uuid, target_node_uuid, source_handle_uuid, target_handle_uuid}]}
|
||||
|
||||
# - #8 启动工作流 POST /lab/workflow/{uuid}/run
|
||||
|
||||
# - #9 运行设备单动作 POST /lab/mcp/run/action(⚠️ action_type 必须从 action-index.md 或 actions/<name>.json 的 type 字段获取,传错会导致任务永远卡住)
|
||||
|
||||
# - #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
|
||||
|
||||
# - #14 按名称查询物料模板 GET /lab/material/template/by-name?lab_uuid=&name=
|
||||
|
||||
# 返回 res_template_uuid,用于 #15 创建物料时的必填字段
|
||||
|
||||
# - #15 创建物料节点 POST /edge/material/node
|
||||
|
||||
# body: {res_template_uuid(从#14获取), name(自定义), display_name, parent_uuid?(从#12获取), ...}
|
||||
|
||||
# - #16 更新物料节点 PUT /edge/material/node
|
||||
|
||||
# body: {uuid(从#12获取), display_name?, description?, init_param_data?, data?, ...}
|
||||
# 返回 workflow 完整结构:data.nodes[] 含每个节点的 uuid、name、param、device_name、handles
|
||||
|
||||
## Placeholder Slot 填写规则
|
||||
|
||||
- unilabos_resources → ResourceSlot → {"id":"/path/name","name":"name","uuid":"xxx"}
|
||||
- unilabos_devices → DeviceSlot → "/parent/device" 路径字符串
|
||||
- unilabos_nodes → NodeSlot → "/parent/node" 路径字符串
|
||||
@@ -239,15 +217,13 @@ API 模板结构:
|
||||
- 列出本设备所有 Slot 字段、类型及含义
|
||||
|
||||
## 渐进加载策略
|
||||
|
||||
## 完整工作流 Checklist
|
||||
```
|
||||
|
||||
### Step 5 — 验证
|
||||
|
||||
检查文件完整性:
|
||||
|
||||
- [ ] `SKILL.md` 包含 API endpoint(#1 获取 lab_uuid、#2-#7 工作流/节点/边、#8-#11 运行/查询、#12 资源树、#13 工作流模板详情、#14-#16 物料管理)
|
||||
- [ ] `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 字段表
|
||||
- [ ] `action-index.md` 列出所有 action 并有描述
|
||||
- [ ] `actions/` 目录中每个 action 有对应 JSON 文件
|
||||
@@ -296,48 +272,92 @@ API 模板结构:
|
||||
|
||||
`placeholder_keys` / `_unilabos_placeholder_info` 中有 5 种值,对应不同的填写方式:
|
||||
|
||||
| placeholder 值 | Slot 类型 | 填写格式 | 选取范围 |
|
||||
| ---------------------- | --------------- | ----------------------------------------------------- | ----------------------------------------- |
|
||||
| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅**物料**节点(不含设备) |
|
||||
| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅**设备**节点(type=device),路径字符串 |
|
||||
| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | **设备 + 物料**,即所有节点,路径字符串 |
|
||||
| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已上报的资源类 name |
|
||||
| `unilabos_formulation` | FormulationSlot | `[{well_name, liquids: [{name, volume}]}]` | 资源树中物料节点的 **name**,配合液体配方 |
|
||||
| placeholder 值 | Slot 类型 | 填写格式 | 选取范围 |
|
||||
|---------------|-----------|---------|---------|
|
||||
| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅**物料**节点(不含设备) |
|
||||
| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅**设备**节点(type=device),路径字符串 |
|
||||
| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | **设备 + 物料**,即所有节点,路径字符串 |
|
||||
| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已上报的资源类 name |
|
||||
| `unilabos_formulation` | FormulationSlot | `[{well_name, liquids: [{name, volume}]}]` | 资源树中物料节点的 **name**,配合液体配方 |
|
||||
|
||||
### ResourceSlot(`unilabos_resources`)
|
||||
|
||||
最常见的类型。从资源树中选取**物料**节点(孔板、枪头盒、试剂槽等):
|
||||
|
||||
- 单个:`{"id": "/workstation/container1", "name": "container1", "uuid": "ff149a9a-..."}`
|
||||
- 数组:`[{"id": "/path/a", "name": "a", "uuid": "xxx"}, ...]`
|
||||
- `id` 从 parent 计算的路径格式,根据 action 语义选择正确的物料
|
||||
```json
|
||||
{"id": "/workstation/container1", "name": "container1", "uuid": "ff149a9a-2cb8-419d-8db5-d3ba056fb3c2"}
|
||||
```
|
||||
|
||||
> **特例**:`create_resource` 的 `res_id`,目标物料可能尚不存在,直接填期望路径,不需要 uuid。
|
||||
- 单个(schema type=object):`{"id": "/path/name", "name": "name", "uuid": "xxx"}`
|
||||
- 数组(schema type=array):`[{"id": "/path/a", "name": "a", "uuid": "xxx"}, ...]`
|
||||
- `id` 本身是从 parent 计算的路径格式
|
||||
- 根据 action 语义选择正确的物料(如 `sources` = 液体来源,`targets` = 目标位置)
|
||||
|
||||
### DeviceSlot / NodeSlot / ClassSlot
|
||||
> **特例**:`create_resource` 的 `res_id` 字段,目标物料可能**尚不存在**,此时直接填写期望的路径(如 `"/workstation/container1"`),不需要 uuid。
|
||||
|
||||
- **DeviceSlot**(`unilabos_devices`):路径字符串如 `"/host_node"`,仅 type=device 的节点
|
||||
- **NodeSlot**(`unilabos_nodes`):路径字符串如 `"/PRCXI/PRCXI_Deck"`,设备 + 物料均可选
|
||||
- **ClassSlot**(`unilabos_class`):类名字符串如 `"container"`,从 `req_resource_registry_upload.json` 查找
|
||||
### DeviceSlot(`unilabos_devices`)
|
||||
|
||||
填写**设备路径字符串**。从资源树中筛选 type=device 的节点,从 parent 计算路径:
|
||||
|
||||
```
|
||||
"/host_node"
|
||||
"/bioyond_cell/reaction_station"
|
||||
```
|
||||
|
||||
- 只填路径字符串,不需要 `{id, uuid}` 对象
|
||||
- 根据 action 语义选择正确的设备(如 `target_device_id` = 目标设备)
|
||||
|
||||
### NodeSlot(`unilabos_nodes`)
|
||||
|
||||
范围 = 设备 + 物料。即资源树中**所有节点**都可以选,填写**路径字符串**:
|
||||
|
||||
```
|
||||
"/PRCXI/PRCXI_Deck"
|
||||
```
|
||||
|
||||
- 使用场景:当参数既可能指向物料也可能指向设备时(如 `PumpTransferProtocol` 的 `from_vessel`/`to_vessel`,`create_resource` 的 `parent`)
|
||||
|
||||
### ClassSlot(`unilabos_class`)
|
||||
|
||||
填写注册表中已上报的**资源类 name**。从本地 `req_resource_registry_upload.json` 中查找:
|
||||
|
||||
```
|
||||
"container"
|
||||
```
|
||||
|
||||
### FormulationSlot(`unilabos_formulation`)
|
||||
|
||||
描述**液体配方**:向哪些容器中加入哪些液体及体积。
|
||||
描述**液体配方**:向哪些物料容器中加入哪些液体及体积。填写为**对象数组**:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"sample_uuid": "",
|
||||
"well_name": "bottle_A1",
|
||||
"liquids": [{ "name": "LiPF6", "volume": 0.6 }]
|
||||
"well_name": "YB_PrepBottle_15mL_Carrier_bottle_A1",
|
||||
"liquids": [
|
||||
{ "name": "LiPF6", "volume": 0.6 },
|
||||
{ "name": "DMC", "volume": 1.2 }
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
- `well_name` — 目标物料的 **name**(从资源树取,不是 `id` 路径)
|
||||
- `liquids[]` — 液体列表,每条含 `name`(试剂名)和 `volume`(体积,单位由上下文决定;pylabrobot 内部统一 uL)
|
||||
- `sample_uuid` — 样品 UUID,无样品传 `""`
|
||||
- 与 ResourceSlot 的区别:ResourceSlot 指向物料本身,FormulationSlot 引用物料名并附带配方信息
|
||||
#### 字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `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 获取资源树
|
||||
|
||||
@@ -345,147 +365,7 @@ API 模板结构:
|
||||
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
|
||||
```
|
||||
|
||||
注意 `lab_uuid` 在路径中(不是查询参数)。返回结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"nodes": [
|
||||
{"name": "host_node", "uuid": "c3ec1e68-...", "type": "device", "parent": ""},
|
||||
{"name": "PRCXI", "uuid": "e249c9a6-...", "type": "device", "parent": ""},
|
||||
{"name": "PRCXI_Deck", "uuid": "fb6a8b71-...", "type": "deck", "parent": "PRCXI"}
|
||||
],
|
||||
"edges": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `data.nodes[]` — 所有节点(设备 + 物料),每个节点含 `name`、`uuid`、`type`、`parent`
|
||||
- `type` 区分设备(`device`)和物料(`deck`、`container`、`resource` 等)
|
||||
- `parent` 为父节点名称(空字符串表示顶级)
|
||||
- 填写 Slot 时根据 placeholder 类型筛选:ResourceSlot 取非 device 节点,DeviceSlot 取 device 节点
|
||||
- 创建/更新物料时:`parent_uuid` 取父节点的 `uuid`,更新目标的 `uuid` 取节点自身的 `uuid`
|
||||
|
||||
## 物料管理 API
|
||||
|
||||
设备 Skill 除了设备动作外,还需支持物料节点的创建和参数设定,用于在资源树中动态管理物料。
|
||||
|
||||
典型流程:先通过 **#14 按名称查询模板** 获取 `res_template_uuid` → 再通过 **#15 创建物料** → 之后可通过 **#16 更新物料** 修改属性。更新时需要的 `uuid` 和 `parent_uuid` 均从 **#12 资源树下载** 获取。
|
||||
|
||||
### API #14 — 按名称查询物料模板
|
||||
|
||||
创建物料前,需要先获取物料模板的 UUID。通过模板名称查询:
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=<template_name>" -H "$AUTH"
|
||||
```
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
| ---------- | ------ | -------------------------------- |
|
||||
| `lab_uuid` | **是** | 实验室 UUID(从 API #1 获取) |
|
||||
| `name` | **是** | 物料模板名称(如 `"container"`) |
|
||||
|
||||
返回 `code: 0` 时,**`data.uuid`** 即为 `res_template_uuid`,用于 API #15 创建物料。返回还包含 `name`、`resource_type`、`handles`、`config_infos` 等模板元信息。
|
||||
|
||||
模板不存在时返回 `code: 10002`,`data` 为空对象。模板名称来自资源注册表中已注册的资源类型。
|
||||
|
||||
### API #15 — 创建物料节点
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/edge/material/node" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '<request_body>'
|
||||
```
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"res_template_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"name": "my_custom_bottle",
|
||||
"display_name": "自定义瓶子",
|
||||
"parent_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"type": "",
|
||||
"init_param_data": {},
|
||||
"schema": {},
|
||||
"data": {
|
||||
"liquids": [["water", 1000, "uL"]],
|
||||
"max_volume": 50000
|
||||
},
|
||||
"plate_well_datas": {},
|
||||
"plate_reagent_datas": {},
|
||||
"pose": {},
|
||||
"model": {}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 类型 | 数据来源 | 说明 |
|
||||
| --------------------- | ------ | ------------- | ----------------------------------- | -------------------------------------- |
|
||||
| `res_template_uuid` | **是** | string (UUID) | **API #14** 按名称查询获取 | 物料模板 UUID |
|
||||
| `name` | 否 | string | **用户自定义** | 节点名称(标识符),可自由命名 |
|
||||
| `display_name` | 否 | string | 用户自定义 | 显示名称(UI 展示用) |
|
||||
| `parent_uuid` | 否 | string (UUID) | **API #12** 资源树中父节点的 `uuid` | 父节点,为空则创建顶级节点 |
|
||||
| `type` | 否 | string | 从模板继承 | 节点类型 |
|
||||
| `init_param_data` | 否 | object | 用户指定 | 初始化参数,覆盖模板默认值 |
|
||||
| `data` | 否 | object | 用户指定 | 节点数据,container 见下方 data 格式 |
|
||||
| `plate_well_datas` | 否 | object | 用户指定 | 孔板子节点数据(创建带孔位的板时使用) |
|
||||
| `plate_reagent_datas` | 否 | object | 用户指定 | 试剂关联数据 |
|
||||
| `schema` | 否 | object | 从模板继承 | 自定义 schema,不传则从模板继承 |
|
||||
| `pose` | 否 | object | 用户指定 | 位姿信息 |
|
||||
| `model` | 否 | object | 用户指定 | 3D 模型信息 |
|
||||
|
||||
#### container 的 `data` 格式
|
||||
|
||||
> **体积单位统一为 uL(微升)**。pylabrobot 体系中所有体积值(`max_volume`、`liquids` 中的 volume)均为 uL。外部如果是 mL 需乘 1000 转换。
|
||||
|
||||
```json
|
||||
{
|
||||
"liquids": [["water", 1000, "uL"], ["ethanol", 500, "uL"]],
|
||||
"max_volume": 50000
|
||||
}
|
||||
```
|
||||
|
||||
- `liquids` — 液体列表,每条为 `[液体名称, 体积(uL), 单位字符串]`
|
||||
- `max_volume` — 容器最大容量(uL),如 50 mL = 50000 uL
|
||||
|
||||
### API #16 — 更新物料节点
|
||||
|
||||
```bash
|
||||
curl -s -X PUT "$BASE/api/v1/edge/material/node" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '<request_body>'
|
||||
```
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"parent_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"display_name": "新显示名称",
|
||||
"description": "新描述",
|
||||
"init_param_data": {},
|
||||
"data": {},
|
||||
"pose": {},
|
||||
"schema": {},
|
||||
"extra": {}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 类型 | 数据来源 | 说明 |
|
||||
| ----------------- | ------ | ------------- | ------------------------------------- | ---------------- |
|
||||
| `uuid` | **是** | string (UUID) | **API #12** 资源树中目标节点的 `uuid` | 要更新的物料节点 |
|
||||
| `parent_uuid` | 否 | string (UUID) | API #12 资源树 | 移动到新父节点 |
|
||||
| `display_name` | 否 | string | 用户指定 | 更新显示名称 |
|
||||
| `description` | 否 | string | 用户指定 | 更新描述 |
|
||||
| `init_param_data` | 否 | object | 用户指定 | 更新初始化参数 |
|
||||
| `data` | 否 | object | 用户指定 | 更新节点数据 |
|
||||
| `pose` | 否 | object | 用户指定 | 更新位姿 |
|
||||
| `schema` | 否 | object | 用户指定 | 更新 schema |
|
||||
| `extra` | 否 | object | 用户指定 | 更新扩展数据 |
|
||||
|
||||
> 只传需要更新的字段,未传的字段保持不变。
|
||||
注意 `lab_uuid` 在路径中(不是查询参数)。资源树返回所有节点,每个节点包含 `id`(路径格式)、`name`、`uuid`、`type`、`parent` 等字段。填写 Slot 时需根据 placeholder 类型筛选正确的节点。
|
||||
|
||||
## 最终目录结构
|
||||
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
---
|
||||
name: host-node
|
||||
description: Operate Uni-Lab host node via REST API — create resources, test latency, test resource tree, manual confirm. Use when the user mentions host_node, creating resources, resource management, testing latency, or any host node operation.
|
||||
---
|
||||
|
||||
# Host Node API Skill
|
||||
|
||||
## 设备信息
|
||||
|
||||
- **device_id**: `host_node`
|
||||
- **Python 源码**: `unilabos/ros/nodes/presets/host_node.py`
|
||||
- **设备类**: `HostNode`
|
||||
- **动作数**: 4(`create_resource`, `test_latency`, `auto-test_resource`, `manual_confirm`)
|
||||
|
||||
## 前置条件(缺一不可)
|
||||
|
||||
使用本 skill 前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
|
||||
|
||||
### 1. ak / sk → AUTH
|
||||
|
||||
从启动参数 `--ak` `--sk` 或 config.py 中获取,生成 token:`base64(ak:sk)` → `Authorization: Lab <token>`
|
||||
|
||||
### 2. --addr → BASE URL
|
||||
|
||||
| `--addr` 值 | BASE |
|
||||
| ------------ | ----------------------------------- |
|
||||
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||
| `local` | `http://127.0.0.1:48197` |
|
||||
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||
|
||||
确认后设置:
|
||||
|
||||
```bash
|
||||
BASE="<根据 addr 确定的 URL>"
|
||||
AUTH="Authorization: Lab <token>"
|
||||
```
|
||||
|
||||
**两项全部就绪后才可发起 API 请求。**
|
||||
|
||||
## Session State
|
||||
|
||||
在整个对话过程中,agent 需要记住以下状态,避免重复询问用户:
|
||||
|
||||
- `lab_uuid` — 实验室 UUID(首次通过 API #1 自动获取,**不需要问用户**)
|
||||
- `device_name` — `host_node`
|
||||
|
||||
## 请求约定
|
||||
|
||||
所有请求使用 `curl -s`,POST/PATCH/DELETE 需加 `Content-Type: application/json`。
|
||||
|
||||
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名)。
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### 1. 获取实验室信息(自动获取 lab_uuid)
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
||||
```
|
||||
|
||||
返回 `data.uuid` 为 `lab_uuid`,`data.name` 为 `lab_name`。
|
||||
|
||||
### 2. 创建工作流
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/workflow/owner" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"name":"<名称>","lab_uuid":"<lab_uuid>","description":"<描述>"}'
|
||||
```
|
||||
|
||||
返回 `data.uuid` 为 `workflow_uuid`。创建成功后告知用户链接:`$BASE/laboratory/$lab_uuid/workflow/$workflow_uuid`
|
||||
|
||||
### 3. 创建节点
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/edge/workflow/node" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"workflow_uuid":"<workflow_uuid>","resource_template_name":"host_node","node_template_name":"<action_name>"}'
|
||||
```
|
||||
|
||||
- `resource_template_name` 固定为 `host_node`
|
||||
- `node_template_name` — action 名称(如 `create_resource`, `test_latency`)
|
||||
|
||||
### 4. 删除节点
|
||||
|
||||
```bash
|
||||
curl -s -X DELETE "$BASE/api/v1/lab/workflow/nodes" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"node_uuids":["<uuid1>"],"workflow_uuid":"<workflow_uuid>"}'
|
||||
```
|
||||
|
||||
### 5. 更新节点参数
|
||||
|
||||
```bash
|
||||
curl -s -X PATCH "$BASE/api/v1/lab/workflow/node" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"workflow_uuid":"<wf_uuid>","uuid":"<node_uuid>","param":{...}}'
|
||||
```
|
||||
|
||||
`param` 直接使用创建节点返回的 `data.param` 结构,修改需要填入的字段值。参考 [action-index.md](action-index.md) 确定哪些字段是 Slot。
|
||||
|
||||
### 6. 查询节点 handles
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/workflow/node-handles" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"node_uuids":["<node_uuid_1>","<node_uuid_2>"]}'
|
||||
```
|
||||
|
||||
### 7. 批量创建边
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/workflow/edges" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"edges":[{"source_node_uuid":"<uuid>","target_node_uuid":"<uuid>","source_handle_uuid":"<uuid>","target_handle_uuid":"<uuid>"}]}'
|
||||
```
|
||||
|
||||
### 8. 启动工作流
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/workflow/<workflow_uuid>/run" -H "$AUTH"
|
||||
```
|
||||
|
||||
### 9. 运行设备单动作
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/mcp/run/action" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"lab_uuid":"<lab_uuid>","device_id":"host_node","action":"<action_name>","action_type":"<type>","param":{...}}'
|
||||
```
|
||||
|
||||
`param` 直接放 goal 里的属性,**不要**再包一层 `{"goal": {...}}`。
|
||||
|
||||
> **WARNING: `action_type` 必须正确,传错会导致任务永远卡住无法完成。** 从下表或 `actions/<name>.json` 的 `type` 字段获取。
|
||||
|
||||
#### action_type 速查表
|
||||
|
||||
| action | action_type |
|
||||
|--------|-------------|
|
||||
| `test_latency` | `UniLabJsonCommand` |
|
||||
| `create_resource` | `ResourceCreateFromOuterEasy` |
|
||||
| `auto-test_resource` | `UniLabJsonCommand` |
|
||||
| `manual_confirm` | `UniLabJsonCommand` |
|
||||
|
||||
### 10. 查询任务状态
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/mcp/task/<task_uuid>" -H "$AUTH"
|
||||
```
|
||||
|
||||
### 11. 运行工作流单节点
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/mcp/run/workflow/action" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"node_uuid":"<node_uuid>"}'
|
||||
```
|
||||
|
||||
### 12. 获取资源树(物料信息)
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
|
||||
```
|
||||
|
||||
注意 `lab_uuid` 在路径中。返回 `data.nodes[]` 含所有节点(设备 + 物料),每个节点含 `name`、`uuid`、`type`、`parent`。
|
||||
|
||||
### 13. 获取工作流模板详情
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH"
|
||||
```
|
||||
|
||||
> 必须使用 `/lab/workflow/template/detail/{uuid}`,其他路径会返回 404。
|
||||
|
||||
### 14. 按名称查询物料模板
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=<template_name>" -H "$AUTH"
|
||||
```
|
||||
|
||||
返回 `data.uuid` 为 `res_template_uuid`,用于 API #15。
|
||||
|
||||
### 15. 创建物料节点
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/edge/material/node" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"res_template_uuid":"<uuid>","name":"<名称>","display_name":"<显示名>","parent_uuid":"<父节点uuid>","data":{...}}'
|
||||
```
|
||||
|
||||
### 16. 更新物料节点
|
||||
|
||||
```bash
|
||||
curl -s -X PUT "$BASE/api/v1/edge/material/node" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"uuid":"<节点uuid>","display_name":"<新名称>","data":{...}}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Placeholder Slot 填写规则
|
||||
|
||||
| `placeholder_keys` 值 | Slot 类型 | 填写格式 | 选取范围 |
|
||||
| --------------------- | ------------ | ----------------------------------------------------- | ---------------------- |
|
||||
| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅物料节点(非设备) |
|
||||
| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅设备节点(type=device) |
|
||||
| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | 所有节点(设备 + 物料) |
|
||||
| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已注册的资源类 |
|
||||
|
||||
### host_node 设备的 Slot 字段表
|
||||
|
||||
| Action | 字段 | Slot 类型 | 说明 |
|
||||
| ----------------- | ----------- | ------------ | ------------------------------ |
|
||||
| `create_resource` | `res_id` | ResourceSlot | 新资源路径(可填不存在的路径) |
|
||||
| `create_resource` | `device_id` | DeviceSlot | 归属设备 |
|
||||
| `create_resource` | `parent` | NodeSlot | 父节点路径 |
|
||||
| `create_resource` | `class_name`| ClassSlot | 资源类名如 `"container"` |
|
||||
| `auto-test_resource` | `resource` | ResourceSlot | 单个测试物料 |
|
||||
| `auto-test_resource` | `resources` | ResourceSlot | 测试物料数组 |
|
||||
| `auto-test_resource` | `device` | DeviceSlot | 测试设备 |
|
||||
| `auto-test_resource` | `devices` | DeviceSlot | 测试设备 |
|
||||
|
||||
---
|
||||
|
||||
## 渐进加载策略
|
||||
|
||||
1. **SKILL.md**(本文件)— API 端点 + session state 管理
|
||||
2. **[action-index.md](action-index.md)** — 按分类浏览 4 个动作的描述和核心参数
|
||||
3. **[actions/\<name\>.json](actions/)** — 仅在需要构建具体请求时,加载对应 action 的完整 JSON Schema
|
||||
|
||||
---
|
||||
|
||||
## 完整工作流 Checklist
|
||||
|
||||
```
|
||||
Task Progress:
|
||||
- [ ] Step 1: GET /edge/lab/info 获取 lab_uuid
|
||||
- [ ] Step 2: 获取资源树 (GET #12) → 记住可用物料
|
||||
- [ ] Step 3: 读 action-index.md 确定要用的 action 名
|
||||
- [ ] Step 4: 创建工作流 (POST #2) → 记住 workflow_uuid,告知用户链接
|
||||
- [ ] Step 5: 创建节点 (POST #3, resource_template_name=host_node) → 记住 node_uuid + data.param
|
||||
- [ ] Step 6: 根据 _unilabos_placeholder_info 和资源树,填写 data.param 中的 Slot 字段
|
||||
- [ ] Step 7: 更新节点参数 (PATCH #5)
|
||||
- [ ] Step 8: 查询节点 handles (POST #6) → 获取各节点的 handle_uuid
|
||||
- [ ] Step 9: 批量创建边 (POST #7) → 用 handle_uuid 连接节点
|
||||
- [ ] Step 10: 启动工作流 (POST #8) 或运行单节点 (POST #11)
|
||||
- [ ] Step 11: 查询任务状态 (GET #10) 确认完成
|
||||
```
|
||||
@@ -1,58 +0,0 @@
|
||||
# Action Index — host_node
|
||||
|
||||
4 个动作,按功能分类。每个动作的完整 JSON Schema 在 `actions/<name>.json`。
|
||||
|
||||
---
|
||||
|
||||
## 资源管理
|
||||
|
||||
### `create_resource`
|
||||
|
||||
在资源树中创建新资源(容器、物料等),支持指定位置、类型和初始液体
|
||||
|
||||
- **action_type**: `ResourceCreateFromOuterEasy`
|
||||
- **Schema**: [`actions/create_resource.json`](actions/create_resource.json)
|
||||
- **可选参数**: `res_id`, `device_id`, `class_name`, `parent`, `bind_locations`, `liquid_input_slot`, `liquid_type`, `liquid_volume`, `slot_on_deck`
|
||||
- **占位符字段**:
|
||||
- `res_id` — **ResourceSlot**(特例:目标物料可能尚不存在,直接填期望路径)
|
||||
- `device_id` — **DeviceSlot**,填路径字符串如 `"/host_node"`
|
||||
- `parent` — **NodeSlot**,填路径字符串如 `"/workstation/deck"`
|
||||
- `class_name` — **ClassSlot**,填类名如 `"container"`
|
||||
|
||||
### `auto-test_resource`
|
||||
|
||||
测试资源系统,返回当前资源树和设备列表
|
||||
|
||||
- **action_type**: `UniLabJsonCommand`
|
||||
- **Schema**: [`actions/test_resource.json`](actions/test_resource.json)
|
||||
- **可选参数**: `resource`, `resources`, `device`, `devices`
|
||||
- **占位符字段**:
|
||||
- `resource` — **ResourceSlot**,单个物料节点 `{id, name, uuid}`
|
||||
- `resources` — **ResourceSlot**,物料节点数组 `[{id, name, uuid}, ...]`
|
||||
- `device` — **DeviceSlot**,设备路径字符串
|
||||
- `devices` — **DeviceSlot**,设备路径字符串
|
||||
|
||||
---
|
||||
|
||||
## 系统工具
|
||||
|
||||
### `test_latency`
|
||||
|
||||
测试设备通信延迟,返回 RTT、时间差、任务延迟等指标
|
||||
|
||||
- **action_type**: `UniLabJsonCommand`
|
||||
- **Schema**: [`actions/test_latency.json`](actions/test_latency.json)
|
||||
- **参数**: 无(零参数调用)
|
||||
|
||||
---
|
||||
|
||||
## 人工确认
|
||||
|
||||
### `manual_confirm`
|
||||
|
||||
创建人工确认节点,等待用户手动确认后继续
|
||||
|
||||
- **action_type**: `UniLabJsonCommand`
|
||||
- **Schema**: [`actions/manual_confirm.json`](actions/manual_confirm.json)
|
||||
- **核心参数**: `timeout_seconds`(超时时间,秒), `assignee_user_ids`(指派用户 ID 列表)
|
||||
- **占位符字段**: `assignee_user_ids` — `unilabos_manual_confirm` 类型
|
||||
@@ -1,93 +0,0 @@
|
||||
{
|
||||
"type": "ResourceCreateFromOuterEasy",
|
||||
"goal": {
|
||||
"res_id": "res_id",
|
||||
"class_name": "class_name",
|
||||
"parent": "parent",
|
||||
"device_id": "device_id",
|
||||
"bind_locations": "bind_locations",
|
||||
"liquid_input_slot": "liquid_input_slot[]",
|
||||
"liquid_type": "liquid_type[]",
|
||||
"liquid_volume": "liquid_volume[]",
|
||||
"slot_on_deck": "slot_on_deck"
|
||||
},
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"res_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"device_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"class_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"parent": {
|
||||
"type": "string"
|
||||
},
|
||||
"bind_locations": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z"
|
||||
],
|
||||
"title": "bind_locations",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"liquid_input_slot": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"liquid_type": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"liquid_volume": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"slot_on_deck": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [],
|
||||
"_unilabos_placeholder_info": {
|
||||
"res_id": "unilabos_resources",
|
||||
"device_id": "unilabos_devices",
|
||||
"parent": "unilabos_nodes",
|
||||
"class_name": "unilabos_class"
|
||||
}
|
||||
},
|
||||
"goal_default": {},
|
||||
"placeholder_keys": {
|
||||
"res_id": "unilabos_resources",
|
||||
"device_id": "unilabos_devices",
|
||||
"parent": "unilabos_nodes",
|
||||
"class_name": "unilabos_class"
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"type": "UniLabJsonCommand",
|
||||
"goal": {
|
||||
"timeout_seconds": "timeout_seconds",
|
||||
"assignee_user_ids": "assignee_user_ids"
|
||||
},
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"timeout_seconds": {
|
||||
"type": "integer"
|
||||
},
|
||||
"assignee_user_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"timeout_seconds",
|
||||
"assignee_user_ids"
|
||||
],
|
||||
"_unilabos_placeholder_info": {
|
||||
"assignee_user_ids": "unilabos_manual_confirm"
|
||||
}
|
||||
},
|
||||
"goal_default": {},
|
||||
"placeholder_keys": {
|
||||
"assignee_user_ids": "unilabos_manual_confirm"
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"type": "UniLabJsonCommand",
|
||||
"goal": {},
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
},
|
||||
"goal_default": {},
|
||||
"placeholder_keys": {}
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
{
|
||||
"type": "UniLabJsonCommand",
|
||||
"goal": {
|
||||
"resource": "resource",
|
||||
"resources": "resources",
|
||||
"device": "device",
|
||||
"devices": "devices"
|
||||
},
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"resource": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sample_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"children": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"parent": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"pose": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"position": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z"
|
||||
],
|
||||
"title": "position",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"orientation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"w": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"w"
|
||||
],
|
||||
"title": "orientation",
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"position",
|
||||
"orientation"
|
||||
],
|
||||
"title": "pose",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"config": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "resource"
|
||||
},
|
||||
"resources": {
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sample_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"children": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"parent": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"pose": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"position": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z"
|
||||
],
|
||||
"title": "position",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"orientation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"w": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"w"
|
||||
],
|
||||
"title": "orientation",
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"position",
|
||||
"orientation"
|
||||
],
|
||||
"title": "pose",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"config": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "resources"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"device": {
|
||||
"type": "string",
|
||||
"description": "device reference"
|
||||
},
|
||||
"devices": {
|
||||
"type": "string",
|
||||
"description": "device reference"
|
||||
}
|
||||
},
|
||||
"required": [],
|
||||
"_unilabos_placeholder_info": {
|
||||
"resource": "unilabos_resources",
|
||||
"resources": "unilabos_resources",
|
||||
"device": "unilabos_devices",
|
||||
"devices": "unilabos_devices"
|
||||
}
|
||||
},
|
||||
"goal_default": {},
|
||||
"placeholder_keys": {
|
||||
"resource": "unilabos_resources",
|
||||
"resources": "unilabos_resources",
|
||||
"device": "unilabos_devices",
|
||||
"devices": "unilabos_devices"
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
---
|
||||
name: submit-agent-result
|
||||
description: Submit historical experiment results (agent_result) to Uni-Lab cloud platform (leap-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结果.
|
||||
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结果.
|
||||
---
|
||||
|
||||
# Uni-Lab 提交历史实验记录指南
|
||||
# 提交历史实验记录指南
|
||||
|
||||
通过 Uni-Lab 云端 API 向已创建的 notebook 提交实验结果数据(agent_result)。支持从 JSON / CSV 文件读取数据,整合后提交。
|
||||
|
||||
> **重要**:本指南中的 `Authorization: Lab <token>` 是 **Uni-Lab 平台专用的认证方式**,`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。
|
||||
通过云端 API 向已创建的 notebook 提交实验结果数据(agent_result)。支持从 JSON / CSV 文件读取数据,整合后提交。
|
||||
|
||||
## 前置条件(缺一不可)
|
||||
|
||||
@@ -20,26 +18,23 @@ description: Submit historical experiment results (agent_result) to Uni-Lab clou
|
||||
生成 AUTH token:
|
||||
|
||||
```bash
|
||||
# ⚠️ 注意:scheme 是 "Lab"(Uni-Lab 专用),不是 "Basic"
|
||||
python -c "import base64,sys; print(base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
|
||||
```
|
||||
|
||||
输出即为 token 值,拼接为 `Authorization: Lab <token>`(`Lab` 是 Uni-Lab 平台 auth scheme,不可替换为 `Basic`)。
|
||||
输出即为 token 值,拼接为 `Authorization: Lab <token>`。
|
||||
|
||||
### 2. --addr → BASE URL
|
||||
|
||||
| `--addr` 值 | BASE |
|
||||
| ------------ | ----------------------------------- |
|
||||
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||
| `local` | `http://127.0.0.1:48197` |
|
||||
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||
| `--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 scheme 必须是 "Lab"(Uni-Lab 专用),不是 "Basic"
|
||||
AUTH="Authorization: Lab <上面命令输出的 token>"
|
||||
```
|
||||
|
||||
@@ -50,7 +45,6 @@ AUTH="Authorization: Lab <上面命令输出的 token>"
|
||||
notebook_uuid 来自之前通过「批量提交实验」创建的实验批次,即 `POST /api/v1/lab/notebook` 返回的 `data.uuid`。
|
||||
|
||||
如果用户不记得,可提示:
|
||||
|
||||
- 查看之前的对话记录中创建 notebook 时返回的 UUID
|
||||
- 或通过平台页面查找对应的 notebook
|
||||
|
||||
@@ -60,11 +54,11 @@ notebook_uuid 来自之前通过「批量提交实验」创建的实验批次,
|
||||
|
||||
用户需要提供实验结果数据,支持以下方式:
|
||||
|
||||
| 方式 | 说明 |
|
||||
| --------- | ----------------------------------------------- |
|
||||
| JSON 文件 | 直接作为 `agent_result` 的内容合并 |
|
||||
| CSV 文件 | 转为 `{"文件名": [行数据...]}` 格式 |
|
||||
| 手动指定 | 用户直接告知 key-value 数据,由 agent 构建 JSON |
|
||||
| 方式 | 说明 |
|
||||
|------|------|
|
||||
| JSON 文件 | 直接作为 `agent_result` 的内容合并 |
|
||||
| CSV 文件 | 转为 `{"文件名": [行数据...]}` 格式 |
|
||||
| 手动指定 | 用户直接告知 key-value 数据,由 agent 构建 JSON |
|
||||
|
||||
**四项全部就绪后才可开始。**
|
||||
|
||||
@@ -96,7 +90,7 @@ curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
||||
返回:
|
||||
|
||||
```json
|
||||
{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } }
|
||||
{"code": 0, "data": {"uuid": "xxx", "name": "实验室名称"}}
|
||||
```
|
||||
|
||||
记住 `data.uuid` 为 `lab_uuid`。
|
||||
@@ -127,45 +121,42 @@ curl -s -X PUT "$BASE/api/v1/lab/notebook/agent-result" \
|
||||
|
||||
#### 必要字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --------------- | ------------- | ------------------------------------------- |
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `notebook_uuid` | string (UUID) | 目标 notebook 的 UUID,从批量提交实验时获取 |
|
||||
| `agent_result` | object | 实验结果数据,任意 JSON 对象 |
|
||||
| `agent_result` | object | 实验结果数据,任意 JSON 对象 |
|
||||
|
||||
#### agent_result 内容格式
|
||||
|
||||
`agent_result` 接受**任意 JSON 对象**,常见格式:
|
||||
|
||||
**简单键值对**:
|
||||
|
||||
```json
|
||||
{
|
||||
"avg_rtt_ms": 12.5,
|
||||
"status": "success",
|
||||
"test_count": 5
|
||||
"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" }
|
||||
]
|
||||
"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 }
|
||||
]
|
||||
"experiment_data": [
|
||||
{"温度": 25, "压力": 101.3, "产率": 0.85},
|
||||
{"温度": 30, "压力": 101.3, "产率": 0.91}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -187,22 +178,22 @@ python scripts/prepare_agent_result.py \
|
||||
[--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`) |
|
||||
| 参数 | 必选 | 说明 |
|
||||
|------|------|------|
|
||||
| `--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) | 字段直接合并到 `agent_result` 顶层 |
|
||||
| `.json`(list/other) | 以文件名为 key 放入 `agent_result` |
|
||||
| `.csv` | 以文件名(不含扩展名)为 key,值为行对象数组 |
|
||||
|
||||
多个文件的字段会合并。JSON dict 中的重复 key 后者覆盖前者。
|
||||
|
||||
@@ -219,7 +210,7 @@ python scripts/prepare_agent_result.py \
|
||||
--notebook-uuid 73c67dca-c8cc-4936-85a0-329106aa7cca \
|
||||
--files results.json \
|
||||
--auth YTFmZDlkNGUt... \
|
||||
--base https://leap-lab.test.bohrium.com \
|
||||
--base https://uni-lab.test.bohrium.com \
|
||||
--submit
|
||||
```
|
||||
|
||||
@@ -281,4 +272,4 @@ Task Progress:
|
||||
|
||||
### Q: 认证方式是 Lab 还是 Api?
|
||||
|
||||
本指南统一使用 `Authorization: Lab <base64(ak:sk)>` 方式(`Lab` 是 Uni-Lab 平台的 auth scheme,**绝不能用 `Basic` 替代**)。如果用户有独立的 API Key,也可用 `Authorization: Api <key>` 替代。
|
||||
本指南统一使用 `Authorization: Lab <base64(ak:sk)>` 方式。如果用户有独立的 API Key,也可用 `Authorization: Api <key>` 替代。
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
---
|
||||
name: virtual-workbench
|
||||
description: Operate Virtual Workbench via REST API — prepare materials, move to heating stations, start heating, move to output, transfer resources. Use when the user mentions virtual workbench, virtual_workbench, 虚拟工作台, heating stations, material processing, or workbench operations.
|
||||
---
|
||||
|
||||
# Virtual Workbench API Skill
|
||||
|
||||
## 设备信息
|
||||
|
||||
- **device_id**: `virtual_workbench`
|
||||
- **Python 源码**: `unilabos/devices/virtual/workbench.py`
|
||||
- **设备类**: `VirtualWorkbench`
|
||||
- **动作数**: 6(`auto-prepare_materials`, `auto-move_to_heating_station`, `auto-start_heating`, `auto-move_to_output`, `transfer`, `manual_confirm`)
|
||||
- **设备描述**: 模拟工作台,包含 1 个机械臂(每次操作 2s,独占锁)和 3 个加热台(每次加热 60s,可并行)
|
||||
|
||||
### 典型工作流程
|
||||
|
||||
1. `prepare_materials` — 生成 A1-A5 物料(5 个 output handle)
|
||||
2. `move_to_heating_station` — 物料并发竞争机械臂,移动到空闲加热台
|
||||
3. `start_heating` — 启动加热(3 个加热台可并行)
|
||||
4. `move_to_output` — 加热完成后移到输出位置 Cn
|
||||
|
||||
## 前置条件(缺一不可)
|
||||
|
||||
使用本 skill 前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
|
||||
|
||||
### 1. ak / sk → AUTH
|
||||
|
||||
从启动参数 `--ak` `--sk` 或 config.py 中获取,生成 token:`base64(ak:sk)` → `Authorization: Lab <token>`
|
||||
|
||||
### 2. --addr → BASE URL
|
||||
|
||||
| `--addr` 值 | BASE |
|
||||
| ------------ | ----------------------------------- |
|
||||
| `test` | `https://leap-lab.test.bohrium.com` |
|
||||
| `uat` | `https://leap-lab.uat.bohrium.com` |
|
||||
| `local` | `http://127.0.0.1:48197` |
|
||||
| 不传(默认) | `https://leap-lab.bohrium.com` |
|
||||
|
||||
确认后设置:
|
||||
|
||||
```bash
|
||||
BASE="<根据 addr 确定的 URL>"
|
||||
AUTH="Authorization: Lab <token>"
|
||||
```
|
||||
|
||||
**两项全部就绪后才可发起 API 请求。**
|
||||
|
||||
## Session State
|
||||
|
||||
- `lab_uuid` — 实验室 UUID(首次通过 API #1 自动获取,**不需要问用户**)
|
||||
- `device_name` — `virtual_workbench`
|
||||
|
||||
## 请求约定
|
||||
|
||||
所有请求使用 `curl -s`,POST/PATCH/DELETE 需加 `Content-Type: application/json`。
|
||||
|
||||
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名)。
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### 1. 获取实验室信息(自动获取 lab_uuid)
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
||||
```
|
||||
|
||||
返回 `data.uuid` 为 `lab_uuid`,`data.name` 为 `lab_name`。
|
||||
|
||||
### 2. 创建工作流
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/workflow/owner" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"name":"<名称>","lab_uuid":"<lab_uuid>","description":"<描述>"}'
|
||||
```
|
||||
|
||||
返回 `data.uuid` 为 `workflow_uuid`。创建成功后告知用户链接:`$BASE/laboratory/$lab_uuid/workflow/$workflow_uuid`
|
||||
|
||||
### 3. 创建节点
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/edge/workflow/node" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"workflow_uuid":"<workflow_uuid>","resource_template_name":"virtual_workbench","node_template_name":"<action_name>"}'
|
||||
```
|
||||
|
||||
- `resource_template_name` 固定为 `virtual_workbench`
|
||||
- `node_template_name` — action 名称(如 `auto-prepare_materials`, `auto-move_to_heating_station`)
|
||||
|
||||
### 4. 删除节点
|
||||
|
||||
```bash
|
||||
curl -s -X DELETE "$BASE/api/v1/lab/workflow/nodes" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"node_uuids":["<uuid1>"],"workflow_uuid":"<workflow_uuid>"}'
|
||||
```
|
||||
|
||||
### 5. 更新节点参数
|
||||
|
||||
```bash
|
||||
curl -s -X PATCH "$BASE/api/v1/lab/workflow/node" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"workflow_uuid":"<wf_uuid>","uuid":"<node_uuid>","param":{...}}'
|
||||
```
|
||||
|
||||
参考 [action-index.md](action-index.md) 确定哪些字段是 Slot。
|
||||
|
||||
### 6. 查询节点 handles
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/workflow/node-handles" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"node_uuids":["<node_uuid_1>","<node_uuid_2>"]}'
|
||||
```
|
||||
|
||||
### 7. 批量创建边
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/workflow/edges" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"edges":[{"source_node_uuid":"<uuid>","target_node_uuid":"<uuid>","source_handle_uuid":"<uuid>","target_handle_uuid":"<uuid>"}]}'
|
||||
```
|
||||
|
||||
### 8. 启动工作流
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/workflow/<workflow_uuid>/run" -H "$AUTH"
|
||||
```
|
||||
|
||||
### 9. 运行设备单动作
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/mcp/run/action" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"lab_uuid":"<lab_uuid>","device_id":"virtual_workbench","action":"<action_name>","action_type":"<type>","param":{...}}'
|
||||
```
|
||||
|
||||
`param` 直接放 goal 里的属性,**不要**再包一层 `{"goal": {...}}`。
|
||||
|
||||
> **WARNING: `action_type` 必须正确,传错会导致任务永远卡住无法完成。** 从下表或 `actions/<name>.json` 的 `type` 字段获取。
|
||||
|
||||
#### action_type 速查表
|
||||
|
||||
| action | action_type |
|
||||
|--------|-------------|
|
||||
| `auto-prepare_materials` | `UniLabJsonCommand` |
|
||||
| `auto-move_to_heating_station` | `UniLabJsonCommand` |
|
||||
| `auto-start_heating` | `UniLabJsonCommand` |
|
||||
| `auto-move_to_output` | `UniLabJsonCommand` |
|
||||
| `transfer` | `UniLabJsonCommandAsync` |
|
||||
| `manual_confirm` | `UniLabJsonCommand` |
|
||||
|
||||
### 10. 查询任务状态
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/mcp/task/<task_uuid>" -H "$AUTH"
|
||||
```
|
||||
|
||||
### 11. 运行工作流单节点
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/lab/mcp/run/workflow/action" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"node_uuid":"<node_uuid>"}'
|
||||
```
|
||||
|
||||
### 12. 获取资源树(物料信息)
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
|
||||
```
|
||||
|
||||
注意 `lab_uuid` 在路径中。返回 `data.nodes[]` 含所有节点(设备 + 物料),每个节点含 `name`、`uuid`、`type`、`parent`。
|
||||
|
||||
### 13. 获取工作流模板详情
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH"
|
||||
```
|
||||
|
||||
> 必须使用 `/lab/workflow/template/detail/{uuid}`,其他路径会返回 404。
|
||||
|
||||
### 14. 按名称查询物料模板
|
||||
|
||||
```bash
|
||||
curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=<template_name>" -H "$AUTH"
|
||||
```
|
||||
|
||||
返回 `data.uuid` 为 `res_template_uuid`,用于 API #15。
|
||||
|
||||
### 15. 创建物料节点
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$BASE/api/v1/edge/material/node" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"res_template_uuid":"<uuid>","name":"<名称>","display_name":"<显示名>","parent_uuid":"<父节点uuid>","data":{...}}'
|
||||
```
|
||||
|
||||
### 16. 更新物料节点
|
||||
|
||||
```bash
|
||||
curl -s -X PUT "$BASE/api/v1/edge/material/node" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"uuid":"<节点uuid>","display_name":"<新名称>","data":{...}}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Placeholder Slot 填写规则
|
||||
|
||||
| `placeholder_keys` 值 | Slot 类型 | 填写格式 | 选取范围 |
|
||||
| --------------------- | ------------ | ----------------------------------------------------- | ---------------------- |
|
||||
| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅物料节点(非设备) |
|
||||
| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅设备节点(type=device) |
|
||||
| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | 所有节点(设备 + 物料) |
|
||||
| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已注册的资源类 |
|
||||
|
||||
### virtual_workbench 设备的 Slot 字段表
|
||||
|
||||
| Action | 字段 | Slot 类型 | 说明 |
|
||||
| ----------------- | ---------------- | ------------ | -------------------- |
|
||||
| `transfer` | `resource` | ResourceSlot | 待转移物料数组 |
|
||||
| `transfer` | `target_device` | DeviceSlot | 目标设备路径 |
|
||||
| `transfer` | `mount_resource` | ResourceSlot | 目标孔位数组 |
|
||||
| `manual_confirm` | `resource` | ResourceSlot | 确认用物料数组 |
|
||||
| `manual_confirm` | `target_device` | DeviceSlot | 确认用目标设备 |
|
||||
| `manual_confirm` | `mount_resource` | ResourceSlot | 确认用目标孔位数组 |
|
||||
|
||||
> `prepare_materials`、`move_to_heating_station`、`start_heating`、`move_to_output` 这 4 个动作**无 Slot 字段**,参数为纯数值/整数。
|
||||
|
||||
---
|
||||
|
||||
## 渐进加载策略
|
||||
|
||||
1. **SKILL.md**(本文件)— API 端点 + session state 管理 + 设备工作流概览
|
||||
2. **[action-index.md](action-index.md)** — 按分类浏览 6 个动作的描述和核心参数
|
||||
3. **[actions/\<name\>.json](actions/)** — 仅在需要构建具体请求时,加载对应 action 的完整 JSON Schema
|
||||
|
||||
---
|
||||
|
||||
## 完整工作流 Checklist
|
||||
|
||||
```
|
||||
Task Progress:
|
||||
- [ ] Step 1: GET /edge/lab/info 获取 lab_uuid
|
||||
- [ ] Step 2: 获取资源树 (GET #12) → 记住可用物料
|
||||
- [ ] Step 3: 读 action-index.md 确定要用的 action 名
|
||||
- [ ] Step 4: 创建工作流 (POST #2) → 记住 workflow_uuid,告知用户链接
|
||||
- [ ] Step 5: 创建节点 (POST #3, resource_template_name=virtual_workbench) → 记住 node_uuid + data.param
|
||||
- [ ] Step 6: 根据 _unilabos_placeholder_info 和资源树,填写 data.param 中的 Slot 字段
|
||||
- [ ] Step 7: 更新节点参数 (PATCH #5)
|
||||
- [ ] Step 8: 查询节点 handles (POST #6) → 获取各节点的 handle_uuid
|
||||
- [ ] Step 9: 批量创建边 (POST #7) → 用 handle_uuid 连接节点
|
||||
- [ ] Step 10: 启动工作流 (POST #8) 或运行单节点 (POST #11)
|
||||
- [ ] Step 11: 查询任务状态 (GET #10) 确认完成
|
||||
```
|
||||
|
||||
### 典型 5 物料并发加热工作流示例
|
||||
|
||||
```
|
||||
prepare_materials (count=5)
|
||||
├─ channel_1 → move_to_heating_station (material_number=1) → start_heating → move_to_output
|
||||
├─ channel_2 → move_to_heating_station (material_number=2) → start_heating → move_to_output
|
||||
├─ channel_3 → move_to_heating_station (material_number=3) → start_heating → move_to_output
|
||||
├─ channel_4 → move_to_heating_station (material_number=4) → start_heating → move_to_output
|
||||
└─ channel_5 → move_to_heating_station (material_number=5) → start_heating → move_to_output
|
||||
```
|
||||
|
||||
创建节点时,`prepare_materials` 的 5 个 output handle(`channel_1` ~ `channel_5`)分别连接到 5 个 `move_to_heating_station` 节点的 `material_input` handle。每个 `move_to_heating_station` 的 `heating_station_output` 和 `material_number_output` 连接到对应 `start_heating` 的 `station_id_input` 和 `material_number_input`。
|
||||
@@ -1,76 +0,0 @@
|
||||
# Action Index — virtual_workbench
|
||||
|
||||
6 个动作,按功能分类。每个动作的完整 JSON Schema 在 `actions/<name>.json`。
|
||||
|
||||
---
|
||||
|
||||
## 物料准备
|
||||
|
||||
### `auto-prepare_materials`
|
||||
|
||||
批量准备物料(虚拟起始节点),生成 A1-A5 物料编号,输出 5 个 handle 供后续节点使用
|
||||
|
||||
- **action_type**: `UniLabJsonCommand`
|
||||
- **Schema**: [`actions/prepare_materials.json`](actions/prepare_materials.json)
|
||||
- **可选参数**: `count`(物料数量,默认 5)
|
||||
|
||||
---
|
||||
|
||||
## 机械臂 & 加热台操作
|
||||
|
||||
### `auto-move_to_heating_station`
|
||||
|
||||
将物料从 An 位置移动到空闲加热台(竞争机械臂,自动查找空闲加热台)
|
||||
|
||||
- **action_type**: `UniLabJsonCommand`
|
||||
- **Schema**: [`actions/move_to_heating_station.json`](actions/move_to_heating_station.json)
|
||||
- **核心参数**: `material_number`(物料编号,integer)
|
||||
|
||||
### `auto-start_heating`
|
||||
|
||||
启动指定加热台的加热程序(可并行,3 个加热台同时工作)
|
||||
|
||||
- **action_type**: `UniLabJsonCommand`
|
||||
- **Schema**: [`actions/start_heating.json`](actions/start_heating.json)
|
||||
- **核心参数**: `station_id`(加热台 ID),`material_number`(物料编号)
|
||||
|
||||
### `auto-move_to_output`
|
||||
|
||||
将加热完成的物料从加热台移动到输出位置 Cn
|
||||
|
||||
- **action_type**: `UniLabJsonCommand`
|
||||
- **Schema**: [`actions/move_to_output.json`](actions/move_to_output.json)
|
||||
- **核心参数**: `station_id`(加热台 ID),`material_number`(物料编号)
|
||||
|
||||
---
|
||||
|
||||
## 物料转移
|
||||
|
||||
### `transfer`
|
||||
|
||||
异步转移物料到目标设备(通过 ROS 资源转移)
|
||||
|
||||
- **action_type**: `UniLabJsonCommandAsync`
|
||||
- **Schema**: [`actions/transfer.json`](actions/transfer.json)
|
||||
- **核心参数**: `resource`, `target_device`, `mount_resource`
|
||||
- **占位符字段**:
|
||||
- `resource` — **ResourceSlot**,待转移的物料数组 `[{id, name, uuid}, ...]`
|
||||
- `target_device` — **DeviceSlot**,目标设备路径字符串
|
||||
- `mount_resource` — **ResourceSlot**,目标孔位数组 `[{id, name, uuid}, ...]`
|
||||
|
||||
---
|
||||
|
||||
## 人工确认
|
||||
|
||||
### `manual_confirm`
|
||||
|
||||
创建人工确认节点,等待用户手动确认后继续(含物料转移上下文)
|
||||
|
||||
- **action_type**: `UniLabJsonCommand`
|
||||
- **Schema**: [`actions/manual_confirm.json`](actions/manual_confirm.json)
|
||||
- **核心参数**: `resource`, `target_device`, `mount_resource`, `timeout_seconds`, `assignee_user_ids`
|
||||
- **占位符字段**:
|
||||
- `resource` — **ResourceSlot**,物料数组
|
||||
- `target_device` — **DeviceSlot**,目标设备路径
|
||||
- `mount_resource` — **ResourceSlot**,目标孔位数组
|
||||
- `assignee_user_ids` — `unilabos_manual_confirm` 类型
|
||||
@@ -1,270 +0,0 @@
|
||||
{
|
||||
"type": "UniLabJsonCommand",
|
||||
"goal": {
|
||||
"resource": "resource",
|
||||
"target_device": "target_device",
|
||||
"mount_resource": "mount_resource",
|
||||
"timeout_seconds": "timeout_seconds",
|
||||
"assignee_user_ids": "assignee_user_ids"
|
||||
},
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"resource": {
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sample_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"children": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"parent": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"pose": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"position": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z"
|
||||
],
|
||||
"title": "position",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"orientation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"w": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"w"
|
||||
],
|
||||
"title": "orientation",
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"position",
|
||||
"orientation"
|
||||
],
|
||||
"title": "pose",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"config": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "resource"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"target_device": {
|
||||
"type": "string",
|
||||
"description": "device reference"
|
||||
},
|
||||
"mount_resource": {
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sample_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"children": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"parent": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"pose": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"position": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z"
|
||||
],
|
||||
"title": "position",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"orientation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"w": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"w"
|
||||
],
|
||||
"title": "orientation",
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"position",
|
||||
"orientation"
|
||||
],
|
||||
"title": "pose",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"config": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "mount_resource"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"timeout_seconds": {
|
||||
"type": "integer"
|
||||
},
|
||||
"assignee_user_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"resource",
|
||||
"target_device",
|
||||
"mount_resource",
|
||||
"timeout_seconds",
|
||||
"assignee_user_ids"
|
||||
],
|
||||
"_unilabos_placeholder_info": {
|
||||
"resource": "unilabos_resources",
|
||||
"target_device": "unilabos_devices",
|
||||
"mount_resource": "unilabos_resources",
|
||||
"assignee_user_ids": "unilabos_manual_confirm"
|
||||
}
|
||||
},
|
||||
"goal_default": {},
|
||||
"placeholder_keys": {
|
||||
"resource": "unilabos_resources",
|
||||
"target_device": "unilabos_devices",
|
||||
"mount_resource": "unilabos_resources",
|
||||
"assignee_user_ids": "unilabos_manual_confirm"
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"type": "UniLabJsonCommand",
|
||||
"goal": {
|
||||
"material_number": "material_number"
|
||||
},
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"material_number": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"material_number"
|
||||
]
|
||||
},
|
||||
"goal_default": {},
|
||||
"placeholder_keys": {}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"type": "UniLabJsonCommand",
|
||||
"goal": {
|
||||
"station_id": "station_id",
|
||||
"material_number": "material_number"
|
||||
},
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"station_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"material_number": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"station_id",
|
||||
"material_number"
|
||||
]
|
||||
},
|
||||
"goal_default": {},
|
||||
"placeholder_keys": {}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"type": "UniLabJsonCommand",
|
||||
"goal": {
|
||||
"count": "count"
|
||||
},
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"count": {
|
||||
"type": "integer",
|
||||
"default": 5
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
},
|
||||
"goal_default": {
|
||||
"count": 5
|
||||
},
|
||||
"placeholder_keys": {}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"type": "UniLabJsonCommand",
|
||||
"goal": {
|
||||
"station_id": "station_id",
|
||||
"material_number": "material_number"
|
||||
},
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"station_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"material_number": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"station_id",
|
||||
"material_number"
|
||||
]
|
||||
},
|
||||
"goal_default": {},
|
||||
"placeholder_keys": {}
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
{
|
||||
"type": "UniLabJsonCommandAsync",
|
||||
"goal": {
|
||||
"resource": "resource",
|
||||
"target_device": "target_device",
|
||||
"mount_resource": "mount_resource"
|
||||
},
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"resource": {
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sample_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"children": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"parent": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"pose": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"position": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z"
|
||||
],
|
||||
"title": "position",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"orientation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"w": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"w"
|
||||
],
|
||||
"title": "orientation",
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"position",
|
||||
"orientation"
|
||||
],
|
||||
"title": "pose",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"config": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "resource"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"target_device": {
|
||||
"type": "string",
|
||||
"description": "device reference"
|
||||
},
|
||||
"mount_resource": {
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sample_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"children": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"parent": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"pose": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"position": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z"
|
||||
],
|
||||
"title": "position",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"orientation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"z": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
},
|
||||
"w": {
|
||||
"type": "number",
|
||||
"minimum": -1.7976931348623157e+308,
|
||||
"maximum": 1.7976931348623157e+308
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"w"
|
||||
],
|
||||
"title": "orientation",
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"position",
|
||||
"orientation"
|
||||
],
|
||||
"title": "pose",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"config": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "mount_resource"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"resource",
|
||||
"target_device",
|
||||
"mount_resource"
|
||||
],
|
||||
"_unilabos_placeholder_info": {
|
||||
"resource": "unilabos_resources",
|
||||
"target_device": "unilabos_devices",
|
||||
"mount_resource": "unilabos_resources"
|
||||
}
|
||||
},
|
||||
"goal_default": {},
|
||||
"placeholder_keys": {
|
||||
"resource": "unilabos_resources",
|
||||
"target_device": "unilabos_devices",
|
||||
"mount_resource": "unilabos_resources"
|
||||
}
|
||||
}
|
||||
@@ -1,483 +0,0 @@
|
||||
---
|
||||
name: yibin-electrolyte-submit
|
||||
description: >-
|
||||
通过 Uni-Lab Notebook API 向宜宾电解液工站提交实验,覆盖配液分液(Bioyond LIMS)、
|
||||
扣电组装(CoinCellAssembly)、扣电测试全流程。
|
||||
包含 Excel 解析、formulation 构建、工作流节点参数填写、notebook 提交与状态轮询。
|
||||
Use when the user wants to submit electrolyte experiments, assemble or test coin cells,
|
||||
parse experiment Excel files, build notebook payloads, or mentions
|
||||
宜宾/配液/分液/扣电/电解液实验/notebook提交/CoinCell/BioyondLIMS.
|
||||
---
|
||||
|
||||
# 宜宾电解液产线 API 操作指南
|
||||
|
||||
本 skill 覆盖两个设备的完整操作流程:
|
||||
1. **配液分液工站** (`bioyond_cell_workstation`) — Bioyond LIMS 配液/分液/转运
|
||||
2. **扣电组装站** (`BatteryStation`) — Modbus PLC 扣电组装/数据采集
|
||||
|
||||
## 设备信息
|
||||
|
||||
| 属性 | 配液分液工站 | 扣电组装站 |
|
||||
|------|------------|-----------|
|
||||
| device_id | `bioyond_cell_workstation` | `BatteryStation` |
|
||||
| 显示名 | 配液分液工站 | 扣电工作站 |
|
||||
| 源码 | `unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py` | `unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py` |
|
||||
| 类名 | `BioyondCellWorkstation` | `CoinCellAssemblyWorkstation` |
|
||||
| 通讯 | HTTP REST (Bioyond LIMS API) | Modbus TCP (PLC 寄存器) |
|
||||
|
||||
## 前置条件
|
||||
|
||||
### 认证信息
|
||||
|
||||
```
|
||||
AUTH="Authorization: Lab OTdlY2FkNmUtZmZmMi00YjhiLThhOWEtNWM5ODAyOTJmOTUxOmU0OGM2YWJkLTA4ZmEtNDFjMy04NzhhLTc4M2FiODlhZjYxMw=="
|
||||
BASE="https://uni-lab.test.bohrium.com"
|
||||
```
|
||||
|
||||
来源:`--ak 97ecad6e-fff2-4b8b-8a9a-5c980292f951 --sk e48c6abd-08fa-41c3-878a-783ab89af613 --addr test`
|
||||
|
||||
### 启动 unilab(云端模式)
|
||||
|
||||
> **重要**:提交实验前必须确保 unilab 正在运行且已连接云端 WebSocket。
|
||||
|
||||
```powershell
|
||||
$env:PYTHONIOENCODING="utf-8"
|
||||
conda activate newunilab2603
|
||||
cd D:\UniLabdev\Uni-Lab-OS\unilabos\devices\workstation
|
||||
unilab -g D:\UniLabdev\Uni-Lab-OS\yibin_electrolyte_config.json --ak 97ecad6e-fff2-4b8b-8a9a-5c980292f951 --sk e48c6abd-08fa-41c3-878a-783ab89af613 --upload_registry --addr test --disable_browser --skip_env_check
|
||||
```
|
||||
|
||||
**启动要点**:
|
||||
1. 必须先激活虚拟环境 `newunilab2603`
|
||||
2. 工作目录切到 `unilabos/devices/workstation`(设备驱动所在目录)
|
||||
3. `--upload_registry` 将 64 个设备 + 142 个资源注册到云端
|
||||
4. `--skip_env_check` + `PYTHONIOENCODING=utf-8` 避免 Windows GBK 编码崩溃
|
||||
5. 启动后后台运行,等待日志出现 `Application startup complete` 和 `Host node ready signal published with 3 devices`
|
||||
|
||||
**验证连接成功的标志**:
|
||||
- 日志出现 `[MessageProcessor] ... wss://uni-lab.test.bohrium.com/api/v1/ws/schedule`
|
||||
- 日志出现 `[WebSocketClient] Host node ready signal published with 3 devices`
|
||||
- 日志出现 `Resource tree add completed`(资源树同步完成)
|
||||
|
||||
### 云端物料上架与入库(启动后必做)
|
||||
|
||||
> **在提交实验之前,必须提醒用户完成以下云端操作,否则实验会因物料缺失而失败。**
|
||||
|
||||
1. **拖拽上料**:在云端 UI(`$BASE/laboratory/<lab_uuid>`)的资源树视图中,将物料拖拽到对应的仓库/库位上。unilab 启动后资源树会自动同步到云端,但物料的**上架位置**需要用户在 UI 上手动确认或调整。
|
||||
|
||||
2. **确认配液物料入库**:确保所有配液实验需要的试剂(如 LiPF6、EC、DMC、EMC 等)已在 LIMS 系统中完成入库。可通过以下方式验证:
|
||||
- 云端 UI 资源树中对应仓库(如"粉末加样头堆栈"、"配液站内试剂仓库")下有物料节点
|
||||
- 或通过 API #8 获取资源树后检查物料节点是否存在
|
||||
|
||||
3. **告知 AI 可以提交**:用户完成上述操作后,告知 AI "物料已上架,可以提交实验",AI 再执行 notebook 提交流程。
|
||||
|
||||
**提醒话术模板**(AI 应在启动成功后发送给用户):
|
||||
```
|
||||
unilab 已成功启动并连接云端。提交实验前请完成以下操作:
|
||||
1. 在云端 UI 上确认资源树中的物料位置,必要时拖拽调整上料位
|
||||
2. 确保配液所需的试剂(粉末、液体)已在 LIMS 中完成入库
|
||||
3. 完成后告诉我,我将为您提交实验
|
||||
```
|
||||
|
||||
### 生成 Action Schema(首次使用)
|
||||
|
||||
启动 unilab 后,在 `unilabos_data/` 目录下会生成 `req_device_registry_upload.json`。运行以下命令提取两个设备的 action JSON:
|
||||
|
||||
```bash
|
||||
python .cursor/skills/create-device-skill/scripts/extract_device_actions.py --registry unilabos_data/req_device_registry_upload.json bioyond_cell_workstation .cursor/skills/yibin-electrolyte-submit/actions/
|
||||
python .cursor/skills/create-device-skill/scripts/extract_device_actions.py --registry unilabos_data/req_device_registry_upload.json BatteryStation .cursor/skills/yibin-electrolyte-submit/actions/
|
||||
```
|
||||
|
||||
## 请求约定
|
||||
|
||||
- Windows 平台**必须用 `curl.exe`**(非 PowerShell 的 curl 别名)
|
||||
- 所有请求带 `$AUTH` 头
|
||||
- URL 格式:`$BASE/api/v1/<endpoint>`
|
||||
- POST/PATCH 请求体写入临时 JSON 文件后用 `-d '@tmp.json'` 传参(避免 PowerShell 转义问题)
|
||||
- 本地 API 基址:`http://127.0.0.1:8002/api/v1/`
|
||||
|
||||
## Session State
|
||||
|
||||
每次会话开始时,依次获取以下信息:
|
||||
|
||||
```bash
|
||||
# 1. lab_uuid
|
||||
curl.exe -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
|
||||
# → data.uuid → $lab_uuid
|
||||
|
||||
# 2. project_uuid
|
||||
curl.exe -s -X GET "$BASE/api/v1/lab/project/list?lab_uuid=$lab_uuid" -H "$AUTH"
|
||||
# → data.items[].uuid/name → 让用户选择或取唯一项 → $project_uuid
|
||||
```
|
||||
|
||||
## 工作流模板(重要)
|
||||
|
||||
> **必须向用户索要已有的工作流模板 UUID 或 URL,不要自行创建。**
|
||||
>
|
||||
> 原因:通过 `edge/workflow/node` API 创建节点会报 `resource_node_template not found`——
|
||||
> 云端的工作流节点模板系统和设备注册表是独立的,需要用户在云端 UI 上预先配置好工作流模板。
|
||||
|
||||
**获取方式**:
|
||||
- 用户提供工作流页面 URL,如 `$BASE/laboratory/<lab_uuid>/workflow/<workflow_uuid>`
|
||||
- 从 URL 中提取 `workflow_uuid`
|
||||
- 用 API 获取模板详情:
|
||||
|
||||
```
|
||||
GET /api/v1/lab/workflow/template/detail/<workflow_uuid>
|
||||
```
|
||||
|
||||
返回 `data.nodes[]`:每个节点的 uuid、name、param、device_name、handles、disabled。
|
||||
|
||||
**示例**:
|
||||
```
|
||||
工作流 URL: https://uni-lab.test.bohrium.com/laboratory/e9ed9102-d709-4741-b7a0-d1e8578e2065/workflow/b49f80d9-58d6-4456-a521-56f4dd39cda0
|
||||
→ workflow_uuid = b49f80d9-58d6-4456-a521-56f4dd39cda0
|
||||
```
|
||||
|
||||
从模板详情中提取**未 disabled** 的节点的 `uuid` 和 `name`,后续提交 notebook 时使用。
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### #1 获取 lab_uuid
|
||||
|
||||
```
|
||||
GET /api/v1/edge/lab/info
|
||||
```
|
||||
|
||||
### #2 列出项目
|
||||
|
||||
```
|
||||
GET /api/v1/lab/project/list?lab_uuid=$lab_uuid
|
||||
```
|
||||
|
||||
返回 `data.items[]`,取 `uuid` 和 `name`。
|
||||
|
||||
### #3 获取工作流模板详情
|
||||
|
||||
```
|
||||
GET /api/v1/lab/workflow/template/detail/<workflow_uuid>
|
||||
```
|
||||
|
||||
返回 `data.nodes[]`:每个节点的 uuid、name、param、device_name、handles。
|
||||
提取活跃节点(`disabled != true`)的 `uuid` 用于构建 notebook 请求。
|
||||
|
||||
### #4 提交实验(创建 notebook)— 核心 API
|
||||
|
||||
```
|
||||
POST /api/v1/lab/notebook
|
||||
Body: {
|
||||
"lab_uuid": "<lab_uuid>",
|
||||
"project_uuid": "<project_uuid>",
|
||||
"workflow_uuid": "<workflow_uuid>",
|
||||
"name": "<实验名称>",
|
||||
"node_params": [
|
||||
{
|
||||
"sample_uuids": [],
|
||||
"datas": [
|
||||
{
|
||||
"node_uuid": "<模板中的节点UUID>",
|
||||
"param": { <参数键值对> },
|
||||
"sample_params": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**关键注意事项**:
|
||||
- `node_params` 是数组,每个元素代表一轮实验
|
||||
- `datas` 中每个节点对应模板中的一个活跃节点
|
||||
- `param` 中的字段名**必须使用 Python 函数参数名**,不能用模板中存储的 LIMS 字段名(见下方映射表)
|
||||
|
||||
### #5 查询 notebook 状态
|
||||
|
||||
```
|
||||
GET /api/v1/lab/notebook/status?uuid=<notebook_uuid>
|
||||
```
|
||||
|
||||
| status | 含义 |
|
||||
|--------|------|
|
||||
| `running` | 执行中 |
|
||||
| `success` | 成功 |
|
||||
| `fail` | 失败 |
|
||||
|
||||
### #6 运行设备单动作(本地 API)
|
||||
|
||||
```
|
||||
POST http://127.0.0.1:8002/api/v1/job/add
|
||||
Body: {
|
||||
"device_id": "<device_id>",
|
||||
"action": "<action_name>",
|
||||
"action_args": { <参数键值对> },
|
||||
"sample_material": {}
|
||||
}
|
||||
```
|
||||
|
||||
本地 API 可自动解析 `action_type`,无需手动指定。适用于快速调试或云端未连接时。
|
||||
|
||||
### #7 查询本地任务状态
|
||||
|
||||
```
|
||||
GET http://127.0.0.1:8002/api/v1/job/<job_id>/status
|
||||
```
|
||||
|
||||
| status | 含义 |
|
||||
|--------|------|
|
||||
| 0 | UNKNOWN |
|
||||
| 1 | ACCEPTED |
|
||||
| 2 | EXECUTING |
|
||||
| 4 | SUCCEEDED |
|
||||
| 5 | CANCELED |
|
||||
| 6 | ABORTED |
|
||||
|
||||
### #8 获取资源树
|
||||
|
||||
```
|
||||
GET /api/v1/lab/material/download/<lab_uuid>
|
||||
```
|
||||
|
||||
返回所有节点(`id`, `name`, `uuid`, `type`, `parent`)。填写 Slot 字段时用此接口筛选节点。
|
||||
|
||||
## Placeholder Slot 填写规则
|
||||
|
||||
action JSON 中 `placeholder_keys` 标记了哪些字段需要填 Slot:
|
||||
|
||||
| placeholder 值 | Slot 类型 | 填写格式 |
|
||||
|---------------|-----------|---------|
|
||||
| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` |
|
||||
| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` 路径字符串 |
|
||||
| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` 路径字符串 |
|
||||
| `unilabos_class` | ClassSlot | `"class_name"` 字符串 |
|
||||
| `unilabos_formulation` | FormulationSlot | `[{well_name, liquids: [{name, volume}]}]` |
|
||||
|
||||
### ResourceSlot 填写
|
||||
|
||||
从 API #8 资源树中筛选**物料**节点:
|
||||
|
||||
```json
|
||||
{"id": "/bioyond_cell_workstation/YB_Bioyond_Deck/自动堆栈-左", "name": "自动堆栈-左", "uuid": "3a19debc-..."}
|
||||
```
|
||||
|
||||
数组字段:`[{id, name, uuid}, ...]`
|
||||
特例:`create_resource` 的 `res_id` 允许填不存在的路径。
|
||||
|
||||
### DeviceSlot 填写
|
||||
|
||||
从资源树筛选 `type=device` 的节点,填路径字符串:
|
||||
|
||||
```
|
||||
"/BatteryStation"
|
||||
"/bioyond_cell_workstation"
|
||||
```
|
||||
|
||||
### FormulationSlot 填写
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"sample_uuid": "",
|
||||
"well_name": "YB_PrepBottle_15mL_Carrier_bottle_A1",
|
||||
"liquids": [
|
||||
{ "name": "LiPF6", "mass": 12.5 },
|
||||
{ "name": "EC", "mass": 50.0 }
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
`well_name` 从资源树中取物料节点的 `name`。
|
||||
|
||||
## 参数名映射(重要的坑)
|
||||
|
||||
> 工作流模板中存储的参数名和 Python 函数实际接受的参数名**不一定相同**。
|
||||
> 提交 notebook 时必须使用 **Python 函数参数名**。
|
||||
|
||||
### `create_orders_formulation` 参数映射
|
||||
|
||||
| 模板中的 param 键 | 实际 Python 参数名 | 说明 |
|
||||
|-------------------|-------------------|------|
|
||||
| `pouch_cell_info` | `pouch_cell_volume` | 软包组装分液体积 (mL) |
|
||||
| `conductivity_info` | `conductivity_volume` | 电导测试分液体积 (mL) |
|
||||
| `load_shedding_info` | `coin_cell_volume` | 扣电组装分液体积 (mL) |
|
||||
| `formulation` | `formulation` | 配方数组(名称一致) |
|
||||
| `batch_id` | `batch_id` | 批次号(名称一致) |
|
||||
| `bottle_type` | `bottle_type` | 配液瓶类型(名称一致) |
|
||||
| `mix_time` | `mix_time` | 混匀时间(秒)(名称一致) |
|
||||
| `conductivity_bottle_count` | `conductivity_bottle_count` | 电导瓶数(名称一致) |
|
||||
|
||||
当从模板中读到 `param` 包含 `pouch_cell_info` 等 LIMS 字段名时,提交 notebook 时要用右列的 Python 函数参数名。否则会报 `TypeError: got an unexpected keyword argument`。
|
||||
|
||||
## 典型工作流
|
||||
|
||||
### 方式一:通过 Notebook API 批量提交(推荐)
|
||||
|
||||
**适用场景**:多组配方的批量实验,云端管理实验记录
|
||||
|
||||
```
|
||||
1. 向用户索要工作流模板 URL(不要自行创建)
|
||||
2. 获取 lab_uuid(API #1)和 project_uuid(API #2)
|
||||
3. 获取工作流模板详情(API #3),提取活跃节点 UUID
|
||||
4. 解析用户提供的 Excel 文件,构建 formulation 数组
|
||||
5. 提交 notebook(API #4)
|
||||
6. 轮询 notebook 状态(API #5)直到完成
|
||||
```
|
||||
|
||||
**Excel 解析规则**:
|
||||
- 全局参数在第一个数据行:`batch_id`、`bottle_type`、`mix_time`、`coin_cell_volume`、`pouch_cell_volume`、`conductivity_volume`、`conductivity_bottle_count`
|
||||
- 配方列从"试剂名1"开始,交替排列:试剂名列 + 质量列(以 `(g)` 结尾)
|
||||
- 每行一个配方,`order_name` = 配方ID列
|
||||
- formulation 中每个配方的 materials 数组只包含 `mass > 0` 的试剂
|
||||
|
||||
**node_params 构建**:所有配方放入同一个 round 的同一个 datas 条目中,因为只有一个节点(`create_orders_formulation`)。
|
||||
|
||||
### 方式二:设备单步操作(本地 API)
|
||||
|
||||
**适用场景**:调试、快速测试
|
||||
|
||||
```
|
||||
1. 确保 unilab 已在本地启动
|
||||
2. 通过 POST http://127.0.0.1:8002/api/v1/job/add 提交任务
|
||||
3. 通过 GET /api/v1/job/<job_id>/status 查询状态
|
||||
```
|
||||
|
||||
### 设备操作流程:配液 → 转运 → 扣电
|
||||
|
||||
```
|
||||
1. [配液站] scheduler_start_and_auto_feeding → 启动调度 + 上料
|
||||
2. [配液站] create_orders_formulation → 创建配液实验(配方输入)
|
||||
3. [配液站] transfer_3_to_2_to_1_auto → 分液瓶板转运到扣电站
|
||||
4. [扣电站] func_pack_device_init_auto_start_combined → 初始化+自动+启动
|
||||
5. [扣电站] func_sendbottle_allpack_multi → 发送瓶数+批量组装
|
||||
```
|
||||
|
||||
## 云端使用心得
|
||||
|
||||
### 环境准备
|
||||
- Windows 必须设置 `$env:PYTHONIOENCODING="utf-8"` 防止编码崩溃
|
||||
- 使用 `--skip_env_check` 跳过依赖检查,加快启动
|
||||
- 工作目录建议在 `unilabos/devices/workstation` 下启动
|
||||
|
||||
### 连接与注册
|
||||
- `--upload_registry` 会自动将设备和资源注册到云端
|
||||
- WebSocket 连接建立后,本地和云端的资源树会自动同步
|
||||
- 注册成功后用户需在云端 UI 完成**物料拖放上架**操作
|
||||
- 如果 unilab 断开重连,资源树会重新同步
|
||||
|
||||
### 工作流模板
|
||||
- **不要自行调用 API 创建工作流或节点**——云端工作流节点模板需要预配置
|
||||
- 始终向用户索要已有的工作流模板 URL
|
||||
- 从 URL 中提取 `workflow_uuid`,通过 API #3 获取详情
|
||||
- 模板中 `disabled: true` 的节点跳过,只处理活跃节点
|
||||
|
||||
### Notebook 实验提交
|
||||
- Notebook 是云端管理实验的标准方式
|
||||
- 一个 notebook 可包含多轮(`node_params` 数组),每轮可包含多组参数
|
||||
- 提交后通过 API #5 轮询状态,LIMS 配液流程通常需要较长时间(8 个配方约 30-60 分钟)
|
||||
- 实验进度可在云端 UI 和本地 unilab 日志中同步查看
|
||||
|
||||
### 常见错误
|
||||
| 错误 | 原因 | 解决 |
|
||||
|------|------|------|
|
||||
| `edge not started error` | unilab 未连接云端 WebSocket | 检查 unilab 是否在运行、重启 |
|
||||
| `resource_node_template not found` | 云端没有该设备的工作流模板 | 向用户索要已有模板,不要自行创建 |
|
||||
| `got an unexpected keyword argument` | 参数名用了模板字段名而非 Python 函数参数名 | 参照上方映射表转换 |
|
||||
| `UnicodeEncodeError: 'gbk'` | Windows 默认编码不支持特殊字符 | 设置 `PYTHONIOENCODING=utf-8` |
|
||||
| `parse parameter error` | 云端 API 字段名错误 | `device_id` (非 `device_name`)、`action` (非 `action_name`)、必须带 `action_type` |
|
||||
|
||||
## 渐进加载策略
|
||||
|
||||
1. 先读本文件了解 API 端点、参数映射和云端注意事项
|
||||
2. 需要具体 action 参数时,读 [action-index.md](action-index.md) 查找 action 名称和核心参数
|
||||
3. 需要完整 schema 时,读 `actions/<action_name>.json`(需先运行提取命令生成)
|
||||
4. 需要理解参数含义时,读设备源码
|
||||
|
||||
## 完整 Notebook 提交 Checklist
|
||||
|
||||
```
|
||||
- [ ] 确认 unilab 已在本地启动并连接云端 WebSocket
|
||||
- [ ] 提醒用户在云端 UI 拖拽上料、确认物料位置
|
||||
- [ ] 提醒用户确认配液所需试剂已在 LIMS 完成入库
|
||||
- [ ] 等待用户确认物料就绪后再继续
|
||||
- [ ] 向用户索要工作流模板 URL → 提取 workflow_uuid
|
||||
- [ ] 获取 lab_uuid(API #1)
|
||||
- [ ] 获取 project_uuid(API #2)
|
||||
- [ ] 获取工作流模板详情(API #3),提取活跃节点 UUID
|
||||
- [ ] 解析用户 Excel 文件 → 构建 formulation + 全局参数
|
||||
- [ ] 注意参数名映射(模板字段名 → Python 函数参数名)
|
||||
- [ ] 提交 notebook(API #4)
|
||||
- [ ] 轮询 notebook 状态(API #5)直到完成
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 真实场景:宜宾产线 Excel 提交提示词模板
|
||||
|
||||
> 以下为已验证可用的标准提示词,适用于配液-分液-扣电全流程。
|
||||
|
||||
### 场景说明
|
||||
|
||||
- unilab 运行在本地 Windows 机器(miniforge 环境),连接云端 WebSocket
|
||||
- AI(Cursor / OpenClaw)在任意设备上,通过云端 API 操作,**不需要本地 127.0.0.1**
|
||||
- 工作流为 5 节点串联:`create_orders_formulation` → `transfer_3_to_2_to_1_auto` → `func_pack_device_init_auto_start_combined` → `func_sendbottle_allpack_multi` → `transfer_1_to_2`
|
||||
|
||||
### 已知固定参数(宜宾产线)
|
||||
|
||||
```
|
||||
BASE = https://uni-lab.test.bohrium.com
|
||||
lab_uuid = e9ed9102-d709-4741-b7a0-d1e8578e2065
|
||||
project = YiBinElectrolyte (bc5224b4-8120-4765-9961-9dfc1802a1f6)
|
||||
workflow = 配液分液formulation全流程 (2bc59938-db79-4415-ac2d-9897ef125f2f)
|
||||
```
|
||||
|
||||
#### 工作流节点 UUID(固定,无需重新查询)
|
||||
|
||||
| 顺序 | action | node_uuid |
|
||||
|------|--------|-----------|
|
||||
| Step1 | auto-create_orders_formulation | `ece6744a-81ac-4ae4-8cd1-1c8eeda1dab6` |
|
||||
| Step2 | auto-transfer_3_to_2_to_1_auto | `1c37a8dd-5ba0-413d-81db-94b9c936a171` |
|
||||
| Step3 | auto-func_pack_device_init_auto_start_combined | `97a676a2-d257-4479-9096-073b40300970` |
|
||||
| Step4 | auto-func_sendbottle_allpack_multi | `cf69017a-d29c-4aad-a63b-309d63dac2e9` |
|
||||
| Step5 | auto-transfer_1_to_2 | `80d1c1aa-dbc3-4601-86b7-5c22a992dd9e` |
|
||||
|
||||
### 标准提示词
|
||||
|
||||
```
|
||||
请使用 yibin-electrolyte-submit skill,提交以下实验:
|
||||
|
||||
工作流模板 URL:https://uni-lab.test.bohrium.com/laboratory/e9ed9102-d709-4741-b7a0-d1e8578e2065/workflow/2bc59938-db79-4415-ac2d-9897ef125f2f
|
||||
Excel 文件路径:<粘贴或上传 xlsx 路径>
|
||||
|
||||
注意事项:
|
||||
- lab_uuid、project_uuid、workflow节点UUID均已固定,无需重新查询
|
||||
- 直接解析 Excel → 构建 payload → 提交
|
||||
- mix_time 传标量整数即可(已兼容)
|
||||
- 试剂名以 Excel 为准,注意区分 LiDFOB / LiDOFB 等拼写
|
||||
- csv_export_path 取 Excel 中 csv_export_path 列的值
|
||||
- 提交后告知 notebook UUID,无需自动轮询(实验耗时较长)
|
||||
```
|
||||
|
||||
### Excel 列结构说明(experment_template_0415sim-*.xlsx)
|
||||
|
||||
| 列范围 | 内容 |
|
||||
|--------|------|
|
||||
| C | batch_id |
|
||||
| D | bottle_type |
|
||||
| E-H | coin_cell_volume / conductivity_bottle_count / conductivity_volume / csv_export_path |
|
||||
| I-T | 试剂名+质量 交替排列(最多6对)|
|
||||
| U | mix_time |
|
||||
| V | order_name(每行配方的订单号)|
|
||||
| W | pouch_cell_volume |
|
||||
| X-Y | target_device / target_location(Step2参数)|
|
||||
| AA | material_search_enable(Step3参数)|
|
||||
| AB-AS | 扣电站参数(Step4)|
|
||||
|
||||
### CSV 导出说明
|
||||
|
||||
每次 `create_orders_formulation` 完成后,在 `csv_export_path` 目录下生成:
|
||||
```
|
||||
electrolyte_orders_<YYYYMMDD_HHMMSS>.csv
|
||||
```
|
||||
列:`orderCode, orderName, 配液瓶类型, 配液瓶二维码, 分液瓶类型, 分液瓶二维码, 目标配液质量比, 真实配液质量比, 时间`
|
||||
|
||||
> **注意**:barCode 为 `null` 或 `"nullBarCode123456"` 是正常现象,表示 LIMS 中该物料尚未扫码。配液瓶缺失通常是因为物料未放在手动传递窗(`locationId` 前缀 `3a19deae-2c7a-`)。
|
||||
@@ -1,295 +0,0 @@
|
||||
# Action 索引
|
||||
|
||||
> Action JSON 文件需运行提取命令生成,详见 [SKILL.md](SKILL.md) 中「生成 Action Schema」。
|
||||
> 以下描述和参数信息基于源码分析。
|
||||
|
||||
---
|
||||
|
||||
## 配液分液工站 (`bioyond_cell_workstation`)
|
||||
|
||||
源码:`unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py`
|
||||
|
||||
### 调度控制
|
||||
|
||||
#### `scheduler_start`
|
||||
|
||||
启动 Bioyond LIMS 调度系统
|
||||
|
||||
- **核心参数**: 无(仅需 apiKey/requestTime,由设备内部处理)
|
||||
- **返回**: LIMS 响应 `{code, message, data}`
|
||||
|
||||
#### `scheduler_stop`
|
||||
|
||||
停止调度
|
||||
|
||||
- **核心参数**: 无
|
||||
|
||||
#### `scheduler_continue`
|
||||
|
||||
继续调度(从暂停状态恢复)
|
||||
|
||||
- **核心参数**: 无
|
||||
|
||||
#### `scheduler_reset`
|
||||
|
||||
复位调度
|
||||
|
||||
- **核心参数**: 无
|
||||
|
||||
#### `scheduler_start_and_auto_feeding`
|
||||
|
||||
**组合操作**:启动调度 + 自动化上料(4号→3号手套箱)
|
||||
|
||||
- **核心参数**: `xlsx_path`(Excel 物料模板路径,可选)
|
||||
- **可选参数**: WH4 加样头面 12 个点位(materialName + quantity)、WH4 原液瓶面 9 个点位(materialName + quantity + materialType + targetWH)、WH3 人工堆栈 15 个点位(materialType + materialId + quantity)
|
||||
- **流程**: 先 `scheduler_start()`,成功后执行 `auto_feeding4to3()`
|
||||
- **备注**: 支持 Excel 模式和手动参数模式,Excel 路径存在时优先使用 Excel
|
||||
|
||||
### 物料上料/下料
|
||||
|
||||
#### `auto_feeding4to3`
|
||||
|
||||
自动化上料:从 4 号手套箱转运物料到 3 号手套箱
|
||||
|
||||
- **核心参数**: `xlsx_path`(Excel 物料模板路径)
|
||||
- **可选参数**: 同 `scheduler_start_and_auto_feeding` 的 WH4/WH3 点位参数
|
||||
- **返回**: 等待上料任务完成后返回结果
|
||||
|
||||
#### `auto_batch_outbound_from_xlsx`
|
||||
|
||||
自动化下料(从 Excel 读取下料信息)
|
||||
|
||||
- **核心参数**: `xlsx_path`(Excel 下料模板)
|
||||
- **Excel 列**: locationId, warehouseId, 数量, x, y, z
|
||||
|
||||
### 物料管理
|
||||
|
||||
#### `create_and_inbound_materials`
|
||||
|
||||
批量创建固体物料并入库
|
||||
|
||||
- **核心参数**: `material_names`(物料名称列表,默认 `["LiPF6", "LiDFOB", "DTD", "LiFSI", "LiPO2F2"]`)
|
||||
- **可选参数**: `type_id`(物料类型ID), `warehouse_name`(目标仓库,默认 "粉末加样头堆栈")
|
||||
- **流程**: 创建物料 → 批量入库 → 同步
|
||||
|
||||
#### `create_material`
|
||||
|
||||
创建单个物料并可选入库
|
||||
|
||||
- **核心参数**: `material_name`, `type_id`, `warehouse_name`
|
||||
- **可选参数**: `location_name_or_id`(库位编号如 "A01" 或 UUID)
|
||||
|
||||
#### `create_sample`
|
||||
|
||||
创建配液板物料(含子瓶)并入库
|
||||
|
||||
- **核心参数**: `name`, `board_type`(如 "5ml分液瓶板"), `bottle_type`(如 "5ml分液瓶"), `location_code`(如 "A01")
|
||||
- **可选参数**: `warehouse_name`(默认 "手动堆栈")
|
||||
- **备注**: 自动创建 2x4=8 个子瓶
|
||||
|
||||
#### `storage_inbound`
|
||||
|
||||
单个物料入库
|
||||
|
||||
- **核心参数**: `material_id`, `location_id`
|
||||
|
||||
#### `storage_batch_inbound`
|
||||
|
||||
批量物料入库
|
||||
|
||||
- **核心参数**: `items`(`[{materialId, locationId}, ...]`)
|
||||
|
||||
### 配液实验
|
||||
|
||||
#### `create_orders`
|
||||
|
||||
从 Excel 文件创建配液实验订单
|
||||
|
||||
- **核心参数**: `xlsx_path`(Excel 文件路径)
|
||||
- **Excel 列**: 配方ID, 创建日期, 配液瓶类型, 混匀时间(s), 扣电组装分液体积, 软包组装分液体积, 电导测试分液体积, 电导测试分液瓶数, 以及所有以 `(g)` 结尾的物料列
|
||||
- **流程**: 解析 Excel → 提交订单 → 等待全部完成 → 计算质量比 → 提取分液瓶板 → 创建资源树对象
|
||||
- **返回**: `{status, total_orders, bottle_count, reports, mass_ratios, vial_plates}`
|
||||
|
||||
#### `create_orders_formulation`
|
||||
|
||||
从配方列表创建配液实验订单(前端/API 输入版本)
|
||||
|
||||
- **核心参数**: `formulation`(配方数组)
|
||||
- **可选参数**: `batch_id`, `bottle_type`(默认 "配液小瓶"), `mix_time`(秒,列表), `coin_cell_volume`, `pouch_cell_volume`, `conductivity_volume`, `conductivity_bottle_count`
|
||||
- **formulation 格式**:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"order_name": "配方A",
|
||||
"materials": [
|
||||
{"name": "LiPF6", "mass": 12.5},
|
||||
{"name": "EC", "mass": 50.0},
|
||||
{"name": "DMC", "mass": 37.5}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
- **返回**: 同 `create_orders`
|
||||
|
||||
### 物料转运
|
||||
|
||||
#### `transfer_3_to_2_to_1_auto`
|
||||
|
||||
**自动转运**:从 create_orders 结果中自动定位分液瓶板并转运到目标设备
|
||||
|
||||
- **核心参数**: `vial_plates`(分液瓶板列表,来自 create_orders 返回的 `vial_plates`)
|
||||
- **可选参数**: `target_device`(默认 "BatteryStation"), `target_location`(默认 "bottle_rack_6x2"), `mass_ratios`(配方信息)
|
||||
- **流程**: 遍历瓶板 → 解析 locationId → 调用 LIMS 转运 API → 更新资源树
|
||||
- **返回**: `{total, success, failed, results}`
|
||||
|
||||
#### `transfer_3_to_2_to_1`
|
||||
|
||||
3→2→1 物料转运(手动指定坐标)
|
||||
|
||||
- **核心参数**: `source_wh_id`, `source_x`, `source_y`, `source_z`
|
||||
|
||||
#### `transfer_3_to_2`
|
||||
|
||||
3→2 物料转运
|
||||
|
||||
- **核心参数**: `source_wh_id`, `source_x`, `source_y`, `source_z`
|
||||
|
||||
#### `transfer_1_to_2`
|
||||
|
||||
1→2 物料转运
|
||||
|
||||
- **核心参数**: 无
|
||||
|
||||
### 查询
|
||||
|
||||
#### `order_list_v2`
|
||||
|
||||
批量查询实验报告
|
||||
|
||||
- **可选参数**: `timeType`, `beginTime`, `endTime`, `status`(60=运行中, 80=完成, 90=失败), `filter`, `skipCount`, `pageCount`, `sorting`
|
||||
|
||||
---
|
||||
|
||||
## 扣电组装站 (`BatteryStation`)
|
||||
|
||||
源码:`unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py`
|
||||
|
||||
### 设备控制(组合操作)
|
||||
|
||||
#### `func_pack_device_init_auto_start_combined`
|
||||
|
||||
**组合操作**:设备初始化 → 物料搜寻确认 → 切换自动模式 → 启动
|
||||
|
||||
- **核心参数**: `material_search_enable`(是否启用物料搜寻,默认 `False`)
|
||||
- **前置检查**: REG_UNILAB_INTERACT=False, COIL_GB_L_IGNORE_CMD=False, 所有握手寄存器无残留
|
||||
- **流程**: 手动模式 → 初始化命令 → 监测物料搜寻弹窗并自动处理 → 自动模式 → 启动
|
||||
- **返回**: `True`/`False`
|
||||
- **备注**: 第一次运行必须调用此函数;后续批次调用 `func_sendbottle_allpack_multi`
|
||||
|
||||
### 批量组装
|
||||
|
||||
#### `func_sendbottle_allpack_multi`
|
||||
|
||||
**发送瓶数 + 批量组装**(适用于第二批次及后续批次)
|
||||
|
||||
- **核心参数**: `elec_num`(电解液瓶数), `elec_use_num`(每瓶组装电池数), `elec_vol`(电解液吸液量 μL,默认 50)
|
||||
- **可选参数**:
|
||||
- 双滴模式:`dual_drop_mode`(bool), `dual_drop_first_volume`(μL), `dual_drop_suction_timing`(bool), `dual_drop_start_timing`(bool)
|
||||
- 组装参数:`assembly_type`(7=不用铝箔垫/8=用), `assembly_pressure`(N,默认 4200)
|
||||
- 物料参数:`fujipian_panshu`, `fujipian_juzhendianwei`, `gemopanshu`, `gemo_juzhendianwei`, `qiangtou_juzhendianwei`
|
||||
- 开关:`lvbodian`(铝箔垫片), `battery_pressure_mode`(压力模式), `battery_clean_ignore`(忽略清洁)
|
||||
- 其他:`file_path`(CSV保存路径), `formulations`(配方信息,用于CSV追溯)
|
||||
- **流程**: 发送瓶数触发物料搬运 → 设置PLC参数 → 循环(等待PLC请求→下发参数→读取电池数据→写入CSV→更新资源树)→ 完成握手
|
||||
- **返回**: `{success, total_batteries, batteries, summary}`
|
||||
- **备注**: 设备已初始化后直接调用;`formulations` 来自 create_orders 的 `mass_ratios`
|
||||
|
||||
#### `func_allpack_cmd`
|
||||
|
||||
全套组装(基础版本,含断点续传)
|
||||
|
||||
- **核心参数**: `elec_num`, `elec_use_num`, `elec_vol`, `assembly_type`, `assembly_pressure`, `file_path`
|
||||
- **返回**: `{success, total_batteries, batteries, summary}`
|
||||
|
||||
#### `func_allpack_cmd_simp`
|
||||
|
||||
增强版组装(含双滴模式 + 负极片/隔膜/枪头参数)
|
||||
|
||||
- **核心参数**: 同 `func_sendbottle_allpack_multi`
|
||||
- **备注**: 被 `func_sendbottle_allpack_multi` 内部调用
|
||||
|
||||
### 设备控制(单步操作)
|
||||
|
||||
#### `func_pack_device_init`
|
||||
|
||||
设备初始化(手动模式 → 初始化 → 复位标志)
|
||||
|
||||
#### `func_pack_device_auto`
|
||||
|
||||
切换自动模式
|
||||
|
||||
#### `func_pack_device_start`
|
||||
|
||||
启动设备
|
||||
|
||||
#### `func_pack_device_stop`
|
||||
|
||||
设备停止
|
||||
|
||||
#### `func_pack_send_bottle_num`
|
||||
|
||||
发送电解液瓶数(触发物料搬运)
|
||||
|
||||
- **核心参数**: `bottle_num`(瓶数)
|
||||
|
||||
### PLC 参数设置
|
||||
|
||||
#### `qiming_coin_cell_code`
|
||||
|
||||
设置组装物料参数
|
||||
|
||||
- **核心参数**: `fujipian_panshu`(负极片盘数)
|
||||
- **可选参数**: `fujipian_juzhendianwei`, `gemopanshu`, `gemo_juzhendianwei`, `lvbodian`, `battery_pressure_mode`, `battery_pressure`, `battery_clean_ignore`
|
||||
|
||||
### 数据采集
|
||||
|
||||
#### `func_read_data_and_output`
|
||||
|
||||
持续数据采集并导出 CSV(后台循环运行)
|
||||
|
||||
- **核心参数**: `file_path`(CSV 保存目录)
|
||||
- **采集字段**: 开路电压, 极片质量, 组装时间, 压制力, 电解液加注量, 电池类型, 电解液二维码, 电池二维码
|
||||
|
||||
#### `func_stop_read_data`
|
||||
|
||||
停止 CSV 数据采集
|
||||
|
||||
### 设备状态属性(只读)
|
||||
|
||||
| 属性 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `sys_status` | str | 设备状态(启动中/停止中/复位中/初始化中) |
|
||||
| `sys_mode` | str | 设备模式(手动/自动) |
|
||||
| `data_assembly_coin_cell_num` | int | 已完成电池数量 |
|
||||
| `data_assembly_time` | float | 单颗电池组装时间(秒) |
|
||||
| `data_open_circuit_voltage` | float | 开路电压(V) |
|
||||
| `data_pole_weight` | float | 正极片称重(g) |
|
||||
| `data_glove_box_pressure` | float | 手套箱压力(mbar) |
|
||||
| `data_glove_box_o2_content` | float | 手套箱氧含量(ppm) |
|
||||
| `data_glove_box_water_content` | float | 手套箱水含量(ppm) |
|
||||
| `data_coin_cell_code` | str | 电池二维码 |
|
||||
| `data_electrolyte_code` | str | 电解液二维码 |
|
||||
|
||||
---
|
||||
|
||||
## 配置参考
|
||||
|
||||
设备图文件 `yibin_electrolyte_config.json` 中的仓库映射(`warehouse_mapping`):
|
||||
|
||||
| 仓库名称 | 说明 | 典型操作 |
|
||||
|---------|------|---------|
|
||||
| 粉末加样头堆栈 | 20 个点位 (A01-T01) | `create_and_inbound_materials` 入库目标 |
|
||||
| 配液站内试剂仓库 | 9 个点位 (A01-C03) | 试剂存储 |
|
||||
| 自动堆栈-左 | 4 个点位 | 分液瓶板存放,`transfer_3_to_2_to_1_auto` 的源位置 |
|
||||
| 自动堆栈-右 | 4 个点位 | 分液瓶板存放 |
|
||||
| 手动传递窗左/右 | 各 15 个点位 | 人工上料/下料 |
|
||||
| 4号手套箱内部堆栈 | 12 个点位 | `auto_feeding4to3` 的源位置 |
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -251,7 +251,4 @@ ros-humble-unilabos-msgs-0.9.13-h6403a04_5.tar.bz2
|
||||
*.bz2
|
||||
test_config.py
|
||||
|
||||
# Local config files with secrets
|
||||
yibin_coin_cell_only_config.json
|
||||
yibin_electrolyte_config.json
|
||||
yibin_electrolyte_only_config.json
|
||||
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
# CSV 导出功能变更概要
|
||||
|
||||
## 修改的文件
|
||||
|
||||
### 1. [bioyond_cell_workstation.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py)
|
||||
|
||||
#### 新增导入
|
||||
- `import csv` 和 `import os`(L14-15)
|
||||
|
||||
#### 新增方法
|
||||
|
||||
| 方法 | 功能 |
|
||||
|------|------|
|
||||
| `_extract_prep_bottle_from_report` | 从 order_finish 报文提取**配液瓶**信息(每订单最多1个) |
|
||||
| `_extract_vial_bottles_from_report` | 从 order_finish 报文提取**分液瓶**信息(每订单可多个,返回数组) |
|
||||
| `_export_order_csv` | 汇总所有信息写入 CSV 文件 |
|
||||
|
||||
#### 配液瓶筛选逻辑 (`_extract_prep_bottle_from_report`)
|
||||
- `typemode="1"`, `realQuantity=1`, `usedQuantity=1`
|
||||
- `locationId` 以 `3a19deae-2c7a-` 开头(手动传递窗)
|
||||
- LIMS API 二次确认:`typeName` 含"配液瓶(小)"或"配液瓶(大)"
|
||||
|
||||
#### 分液瓶筛选逻辑 (`_extract_vial_bottles_from_report`)
|
||||
- `typemode="1"`, `realQuantity=1`, `usedQuantity=1`
|
||||
- `locationId` 以 `3a19debc-84b5-` 或 `3a19debe-5200` 开头(自动堆栈-左/右)
|
||||
- LIMS API 二次确认:`typeName` 为"5ml分液瓶"或"20ml分液瓶"
|
||||
- **返回数组**,支持 1×5ml + n×20ml 的组合
|
||||
|
||||
#### 修改的方法
|
||||
|
||||
| 方法 | 变更 |
|
||||
|------|------|
|
||||
| `_submit_and_wait_orders` | 新增配液瓶+分液瓶提取步骤,将 `prep_bottles` 和 `vial_bottles` 存入 `final_result` |
|
||||
| `create_orders` | 添加 `csv_export_path` 参数,末尾调用 `_export_order_csv` |
|
||||
| `create_orders_formulation` | 添加 `csv_export_path` 参数,末尾调用 `_export_order_csv` |
|
||||
|
||||
#### CSV 输出格式
|
||||
```
|
||||
orderCode, orderName, 配液瓶类型, 配液瓶二维码, 分液瓶类型, 分液瓶二维码, 目标配液质量比, 真实配液质量比, 时间
|
||||
```
|
||||
- 单个分液瓶时直接写值;多个分液瓶时类型和二维码用 JSON 数组表示
|
||||
- CSV 编码使用 `utf-8-sig`(兼容 Excel 打开)
|
||||
- `csv_export_path` 默认为空字符串,不传则不导出(向后兼容)
|
||||
|
||||
---
|
||||
|
||||
### 2. [bioyond_cell.yaml](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/registry/devices/bioyond_cell.yaml)
|
||||
|
||||
为两个 action 注册了 `csv_export_path` 参数:
|
||||
|
||||
- `auto-create_orders`: `goal_default` + `schema.properties.goal.properties` 中添加 `csv_export_path`
|
||||
- `auto-create_orders_formulation`: 同上
|
||||
|
||||
---
|
||||
|
||||
### 3. [coin_cell_assembly.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py) 的 CSV 改动与全流程追溯
|
||||
|
||||
在 `bioyond_cell_workstation.py` 的 `_submit_and_wait_orders` 最后阶段,提取 `prep_bottles`(配液瓶)和 `vial_bottles`(分液瓶)的条码并随 `mass_ratios` 数组一起下发给各下游工站(例如扣电组装站),实现跨站的全流程配方追溯。
|
||||
|
||||
并在扣电站生成的 `date_xxx.csv` 中,**替换并新增**了以下列:
|
||||
- 移除了原有的 `formulation_order_code` 与合并的 `formulation_ratio` 列。
|
||||
- 新增 `orderName` 导出
|
||||
- 新增 `prep_bottle_barcode`(奔曜传递的配液瓶二维码)
|
||||
- 新增 `vial_bottle_barcodes`(奔曜传递的分液瓶二维码,多瓶时存 JSON 数组)
|
||||
- 新增 `target_mass_ratio` 理论目标质量比
|
||||
- 新增 `real_mass_ratio` 实际称量真实质量比
|
||||
|
||||
*注意:这与操作人员在手套箱内扫码传入扣电站的 `electrolyte_code` 是单独记录的,方便做数据核对。*
|
||||
|
||||
## 向后兼容性
|
||||
- `csv_export_path` 默认值为 `""`(空字符串),现有调用不受影响
|
||||
- 新增的 `prep_bottles` 和 `vial_bottles` 字段为 `final_result` 和 `mass_ratios` 内部的新增附属字段,不破坏现有数据结构。
|
||||
@@ -1,168 +0,0 @@
|
||||
# 变更说明 2026-03-24
|
||||
|
||||
## 问题背景
|
||||
|
||||
`BioyondElectrolyteDeck`(原 `BIOYOND_YB_Deck`)迁移后,前端物料未能正常上传/同步。
|
||||
|
||||
---
|
||||
|
||||
## 修复内容
|
||||
|
||||
### 1. `unilabos/resources/bioyond/decks.py`
|
||||
|
||||
- 补回 `setup: bool = False` 参数及 `if setup: self.setup()` 逻辑,与旧版 `BIOYOND_YB_Deck` 保持一致
|
||||
- 工厂函数 `bioyond_electrolyte_deck` 保留显式调用 `deck.setup()`,避免重复初始化
|
||||
|
||||
```python
|
||||
# 修复前(缺少 setup 参数,无法通过 setup=True 触发初始化)
|
||||
def __init__(self, name, size_x, size_y, size_z, category):
|
||||
super().__init__(...)
|
||||
|
||||
# 修复后
|
||||
def __init__(self, name, size_x, size_y, size_z, category, setup: bool = False):
|
||||
super().__init__(...)
|
||||
if setup:
|
||||
self.setup()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. `unilabos/resources/graphio.py`
|
||||
|
||||
- 修复 `resource_bioyond_to_plr` 中两处 `bottle.tracker.liquids` 直接赋值导致的崩溃
|
||||
- `ResourceHolder`(如枪头盒的 TipSpot 槽位)没有 `tracker` 属性,直接访问会抛出 `AttributeError`,阻断整个 Bioyond 同步流程
|
||||
|
||||
```python
|
||||
# 修复前
|
||||
bottle.tracker.liquids = [...]
|
||||
|
||||
# 修复后
|
||||
if hasattr(bottle, 'tracker') and bottle.tracker is not None:
|
||||
bottle.tracker.liquids = [...]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. `unilabos/app/main.py`
|
||||
|
||||
- 保留 `file_path is not None` 条件不变(已还原),并补充注释说明原因
|
||||
- 该逻辑只在**本地文件模式**下有意义:本地 graph 文件只含设备结构,远端有已保存物料,merge 才能将两者合并
|
||||
- 远端模式(`file_path=None`)下,`resource_tree_set` 和 `request_startup_json` 来自同一份数据,merge 为空操作,条件是否加 `file_path is not None` 对结果没有影响
|
||||
|
||||
---
|
||||
|
||||
### 4. `unilabos/devices/workstation/bioyond_studio/station.py` ⭐ 核心修复
|
||||
|
||||
- 当 deck 通过反序列化创建时,不会自动调用 `setup()`,导致 `deck.children` 为空,`warehouses` 始终是 `{}`
|
||||
- 增加兜底逻辑:仓库扫描后仍为空,则主动调用 `deck.setup()` 初始化仓库
|
||||
- 这是导致所有物料放置失败(`warehouse '...' 在deck中不存在。可用warehouses: []`)的根本原因
|
||||
|
||||
```python
|
||||
# 新增兜底
|
||||
if not self.deck.warehouses and hasattr(self.deck, "setup") and callable(self.deck.setup):
|
||||
logger.info("Deck 无仓库子节点,调用 setup() 初始化仓库")
|
||||
self.deck.setup()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 补充修复 2026-03-25:依华扣电组装工站子物料未上传
|
||||
|
||||
### 问题
|
||||
|
||||
`CoinCellAssemblyWorkstation.post_init` 直接上传空 deck,未调用 `deck.setup()`,导致:
|
||||
- 前端子物料(成品弹夹、料盘、瓶架等)不显示
|
||||
- 运行时 `self.deck.get_resource("成品弹夹")` 抛出 `ResourceNotFoundError`
|
||||
|
||||
### 修复文件
|
||||
|
||||
**`unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py`**
|
||||
- `YihuaCoinCellDeck.__init__` 补回 `setup: bool = False` 参数及 `if setup: self.setup()` 逻辑
|
||||
|
||||
**`unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py`**
|
||||
- `post_init` 中增加与 Bioyond 工站相同的兜底逻辑:deck 无子节点时调用 `deck.setup()` 初始化
|
||||
|
||||
```python
|
||||
# post_init 中新增
|
||||
if self.deck and not self.deck.children and hasattr(self.deck, "setup") and callable(self.deck.setup):
|
||||
logger.info("YihuaCoinCellDeck 无子节点,调用 setup() 初始化")
|
||||
self.deck.setup()
|
||||
```
|
||||
|
||||
### 联动 Bug:`MaterialPlate.create_with_holes` 构造顺序错误
|
||||
|
||||
**现象**:`deck.setup()` 被调用后,启动时抛出:
|
||||
```
|
||||
设备后初始化失败: Must specify either `ordered_items` or `ordering`.
|
||||
```
|
||||
|
||||
**根因**:`create_with_holes` 原来的逻辑是先构造空的 `MaterialPlate` 实例,再 assign 洞位:
|
||||
```python
|
||||
# 旧(错误):cls(...) 时 ordered_items=None → ItemizedResource.__init__ 立即报错
|
||||
plate = cls(name=name, ...) # ← 这里就崩了
|
||||
holes = create_ordered_items_2d(...) # ← 根本没走到这里
|
||||
for hole_name, hole in holes.items():
|
||||
plate.assign_child_resource(...)
|
||||
```
|
||||
pylabrobot 的 `ItemizedResource.__init__` 强制要求 `ordered_items` 和 `ordering` 必须有一个不为 `None`,空构造直接失败。
|
||||
|
||||
**修复**:先建洞位,再作为 `ordered_items` 传给构造函数:
|
||||
```python
|
||||
# 新(正确):先建洞位,再一次性传入构造函数
|
||||
holes = create_ordered_items_2d(klass=MaterialHole, num_items_x=4, ...)
|
||||
return cls(name=name, ..., ordered_items=holes)
|
||||
```
|
||||
|
||||
> 此 bug 此前未被触发,是因为 `deck.setup()` 从未被调用到——正是上面 `post_init` 兜底修复引出的联动问题。
|
||||
|
||||
---
|
||||
|
||||
## 补充修复 2026-03-25:3→2→1 转运资源同步失败
|
||||
|
||||
### 问题
|
||||
|
||||
配液工站(Bioyond)完成分液后,调用 `transfer_3_to_2_to_1_auto` 将分液瓶板转运到扣电工站(BatteryStation)。物理 LIMS 转运成功,但数字孪生资源树同步始终失败:
|
||||
```
|
||||
[资源同步] ❌ 失败: 目标设备 'BatteryStation' 中未找到资源 'bottle_rack_6x2'
|
||||
```
|
||||
|
||||
### 根因
|
||||
|
||||
`_get_resource_from_device` 方法负责跨设备查找资源对象,有两个问题:
|
||||
|
||||
1. **原始路径完全失效**:尝试 `from unilabos.app.ros2_app import get_device_plr_resource_by_name`,但该模块不存在,`ImportError` 被 `except Exception: pass` 静默吞掉
|
||||
2. **降级路径搜错地方**:遍历 `self._plr_resources`(Bioyond 自己的资源),不可能找到 BatteryStation 的 `bottle_rack_6x2`
|
||||
|
||||
### 修复文件
|
||||
|
||||
**`unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py`**
|
||||
|
||||
改用全局设备注册表 `registered_devices` 跨设备访问目标 deck:
|
||||
|
||||
```python
|
||||
# 修复前(失效)
|
||||
from unilabos.app.ros2_app import get_device_plr_resource_by_name # 模块不存在
|
||||
return get_device_plr_resource_by_name(device_id, resource_name)
|
||||
|
||||
# 修复后
|
||||
from unilabos.ros.nodes.base_device_node import registered_devices
|
||||
device_info = registered_devices.get(device_id)
|
||||
if device_info is not None:
|
||||
driver = device_info.get("driver_instance") # TypedDict 是 dict,必须用 .get()
|
||||
if driver is not None:
|
||||
deck = getattr(driver, "deck", None)
|
||||
if deck is not None:
|
||||
res = deck.get_resource(resource_name)
|
||||
```
|
||||
|
||||
关键细节:`DeviceInfoType` 是 `TypedDict`(即普通 `dict`),必须用 `device_info.get("driver_instance")` 而非 `getattr(device_info, "driver_instance", None)`——后者对字典永远返回 `None`。
|
||||
|
||||
---
|
||||
|
||||
## 根本原因分析
|
||||
|
||||
旧版以**本地文件模式**启动(有 `graph` 文件),deck 在启动前已通过 `merge_remote_resources` 获得仓库子节点,反序列化时能正确恢复 warehouses。
|
||||
|
||||
新版以**远端模式**启动(`file_path=None`),deck 反序列化时没有仓库子节点,`station.py` 扫描为空,所有物料的 warehouse 匹配失败,Bioyond 同步的 16 个资源全部无法放置到对应仓库位,前端不显示。
|
||||
@@ -12,7 +12,7 @@ Uni-Lab 使用 Python 格式的配置文件(`.py`),默认为 `unilabos_dat
|
||||
|
||||
**获取方式:**
|
||||
|
||||
进入 [Uni-Lab 实验室](https://leap-lab.bohrium.com),点击左下角的头像,在实验室详情中获取所在实验室的 ak 和 sk:
|
||||
进入 [Uni-Lab 实验室](https://uni-lab.bohrium.com),点击左下角的头像,在实验室详情中获取所在实验室的 ak 和 sk:
|
||||
|
||||

|
||||
|
||||
@@ -69,7 +69,7 @@ class WSConfig:
|
||||
|
||||
# HTTP配置
|
||||
class HTTPConfig:
|
||||
remote_addr = "https://leap-lab.bohrium.com/api/v1" # 远程服务器地址
|
||||
remote_addr = "https://uni-lab.bohrium.com/api/v1" # 远程服务器地址
|
||||
|
||||
# ROS配置
|
||||
class ROSConfig:
|
||||
@@ -209,8 +209,8 @@ unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis -g grap
|
||||
|
||||
`--addr` 参数支持以下预设值,会自动转换为对应的完整 URL:
|
||||
|
||||
- `test` → `https://leap-lab.test.bohrium.com/api/v1`
|
||||
- `uat` → `https://leap-lab.uat.bohrium.com/api/v1`
|
||||
- `test` → `https://uni-lab.test.bohrium.com/api/v1`
|
||||
- `uat` → `https://uni-lab.uat.bohrium.com/api/v1`
|
||||
- `local` → `http://127.0.0.1:48197/api/v1`
|
||||
- 其他值 → 直接使用作为完整 URL
|
||||
|
||||
@@ -248,7 +248,7 @@ unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis -g grap
|
||||
|
||||
`ak` 和 `sk` 是必需的认证参数:
|
||||
|
||||
1. **获取方式**:在 [Uni-Lab 官网](https://leap-lab.bohrium.com) 注册实验室后获得
|
||||
1. **获取方式**:在 [Uni-Lab 官网](https://uni-lab.bohrium.com) 注册实验室后获得
|
||||
2. **配置方式**:
|
||||
- **命令行参数**:`--ak "your_key" --sk "your_secret"`(最高优先级,推荐)
|
||||
- **环境变量**:`UNILABOS_BASICCONFIG_AK` 和 `UNILABOS_BASICCONFIG_SK`
|
||||
@@ -275,15 +275,15 @@ WebSocket 是 Uni-Lab 的主要通信方式:
|
||||
|
||||
HTTP 客户端配置用于与云端服务通信:
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
| ------------- | ---- | --------------------------------------- | ------------ |
|
||||
| `remote_addr` | str | `"https://leap-lab.bohrium.com/api/v1"` | 远程服务地址 |
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
| ------------- | ---- | -------------------------------------- | ------------ |
|
||||
| `remote_addr` | str | `"https://uni-lab.bohrium.com/api/v1"` | 远程服务地址 |
|
||||
|
||||
**预设环境地址**:
|
||||
|
||||
- 生产环境:`https://leap-lab.bohrium.com/api/v1`(默认)
|
||||
- 测试环境:`https://leap-lab.test.bohrium.com/api/v1`
|
||||
- UAT 环境:`https://leap-lab.uat.bohrium.com/api/v1`
|
||||
- 生产环境:`https://uni-lab.bohrium.com/api/v1`(默认)
|
||||
- 测试环境:`https://uni-lab.test.bohrium.com/api/v1`
|
||||
- UAT 环境:`https://uni-lab.uat.bohrium.com/api/v1`
|
||||
- 本地环境:`http://127.0.0.1:48197/api/v1`
|
||||
|
||||
### 4. ROSConfig - ROS 配置
|
||||
@@ -401,7 +401,7 @@ export UNILABOS_WSCONFIG_RECONNECT_INTERVAL="10"
|
||||
export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS="500"
|
||||
|
||||
# 设置HTTP配置
|
||||
export UNILABOS_HTTPCONFIG_REMOTE_ADDR="https://leap-lab.test.bohrium.com/api/v1"
|
||||
export UNILABOS_HTTPCONFIG_REMOTE_ADDR="https://uni-lab.test.bohrium.com/api/v1"
|
||||
```
|
||||
|
||||
## 配置文件使用方法
|
||||
@@ -484,13 +484,13 @@ export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS=100
|
||||
|
||||
```python
|
||||
class HTTPConfig:
|
||||
remote_addr = "https://leap-lab.test.bohrium.com/api/v1"
|
||||
remote_addr = "https://uni-lab.test.bohrium.com/api/v1"
|
||||
```
|
||||
|
||||
**环境变量方式:**
|
||||
|
||||
```bash
|
||||
export UNILABOS_HTTPCONFIG_REMOTE_ADDR=https://leap-lab.test.bohrium.com/api/v1
|
||||
export UNILABOS_HTTPCONFIG_REMOTE_ADDR=https://uni-lab.test.bohrium.com/api/v1
|
||||
```
|
||||
|
||||
**命令行方式(推荐):**
|
||||
|
||||
@@ -23,7 +23,7 @@ Uni-Lab-OS 支持多种部署模式:
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ Cloud Platform/Self-hosted Platform │
|
||||
│ leap-lab.bohrium.com │
|
||||
│ uni-lab.bohrium.com │
|
||||
│ (Resource Management, Task Scheduling, │
|
||||
│ Monitoring) │
|
||||
└────────────────────┬─────────────────────────┘
|
||||
@@ -444,7 +444,7 @@ ros2 daemon stop && ros2 daemon start
|
||||
|
||||
```bash
|
||||
# 测试云端连接
|
||||
curl https://leap-lab.bohrium.com/api/v1/health
|
||||
curl https://uni-lab.bohrium.com/api/v1/health
|
||||
|
||||
# 测试WebSocket
|
||||
# 启动Uni-Lab后查看日志
|
||||
|
||||
@@ -33,11 +33,11 @@
|
||||
|
||||
**选择合适的安装包:**
|
||||
|
||||
| 安装包 | 适用场景 | 包含组件 |
|
||||
| --------------- | ---------------------------- | --------------------------------------------- |
|
||||
| `unilabos` | **推荐大多数用户**,生产部署 | 完整安装包,开箱即用 |
|
||||
| `unilabos-env` | 开发者(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos |
|
||||
| `unilabos-full` | 仿真/可视化 | unilabos + 完整 ROS2 桌面版 + Gazebo + MoveIt |
|
||||
| 安装包 | 适用场景 | 包含组件 |
|
||||
|--------|----------|----------|
|
||||
| `unilabos` | **推荐大多数用户**,生产部署 | 完整安装包,开箱即用 |
|
||||
| `unilabos-env` | 开发者(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos |
|
||||
| `unilabos-full` | 仿真/可视化 | unilabos + 完整 ROS2 桌面版 + Gazebo + MoveIt |
|
||||
|
||||
**关键步骤:**
|
||||
|
||||
@@ -66,7 +66,6 @@ mamba install uni-lab::unilabos-full -c robostack-staging -c conda-forge
|
||||
```
|
||||
|
||||
**选择建议:**
|
||||
|
||||
- **日常使用/生产部署**:使用 `unilabos`(推荐),完整功能,开箱即用
|
||||
- **开发者**:使用 `unilabos-env` + `pip install -e .` + `uv pip install -r unilabos/utils/requirements.txt`,代码修改立即生效
|
||||
- **仿真/可视化**:使用 `unilabos-full`,含 Gazebo、rviz2、MoveIt
|
||||
@@ -89,7 +88,7 @@ python -c "from unilabos_msgs.msg import Resource; print('ROS msgs OK')"
|
||||
|
||||
#### 2.1 注册实验室账号
|
||||
|
||||
1. 访问 [https://leap-lab.bohrium.com](https://leap-lab.bohrium.com)
|
||||
1. 访问 [https://uni-lab.bohrium.com](https://uni-lab.bohrium.com)
|
||||
2. 注册账号并登录
|
||||
3. 创建新实验室
|
||||
|
||||
@@ -298,7 +297,7 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
|
||||
|
||||
#### 5.2 访问 Web 界面
|
||||
|
||||
启动系统后,访问[https://leap-lab.bohrium.com](https://leap-lab.bohrium.com)
|
||||
启动系统后,访问[https://uni-lab.bohrium.com](https://uni-lab.bohrium.com)
|
||||
|
||||
#### 5.3 添加设备和物料
|
||||
|
||||
@@ -307,10 +306,12 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
|
||||
**示例场景:** 创建一个简单的液体转移实验
|
||||
|
||||
1. **添加工作站(必需):**
|
||||
|
||||
- 在"仪器设备"中找到 `work_station`
|
||||
- 添加 `workstation` x1
|
||||
|
||||
2. **添加虚拟转移泵:**
|
||||
|
||||
- 在"仪器设备"中找到 `virtual_device`
|
||||
- 添加 `virtual_transfer_pump` x1
|
||||
|
||||
@@ -817,7 +818,6 @@ uv pip install -r unilabos/utils/requirements.txt
|
||||
```
|
||||
|
||||
**为什么使用这种方式?**
|
||||
|
||||
- `unilabos-env` 提供 ROS2 核心组件和 uv(通过 conda 安装,避免编译)
|
||||
- `unilabos/utils/requirements.txt` 包含所有运行时需要的 pip 依赖
|
||||
- `dev_install.py` 自动检测中文环境,中文系统自动使用清华镜像
|
||||
@@ -1796,27 +1796,32 @@ unilab --ak your_ak --sk your_sk -g graph.json \
|
||||
**详细步骤:**
|
||||
|
||||
1. **需求分析**:
|
||||
|
||||
- 明确实验流程
|
||||
- 列出所需设备和物料
|
||||
- 设计工作流程图
|
||||
|
||||
2. **环境搭建**:
|
||||
|
||||
- 安装 Uni-Lab-OS
|
||||
- 创建实验室账号
|
||||
- 准备开发工具(IDE、Git)
|
||||
|
||||
3. **原型验证**:
|
||||
|
||||
- 使用虚拟设备测试流程
|
||||
- 验证工作流逻辑
|
||||
- 调整参数
|
||||
|
||||
4. **迭代开发**:
|
||||
|
||||
- 实现自定义设备驱动(同时撰写单点函数测试)
|
||||
- 编写注册表
|
||||
- 单元测试
|
||||
- 集成测试
|
||||
|
||||
5. **测试部署**:
|
||||
|
||||
- 连接真实硬件
|
||||
- 空跑测试
|
||||
- 小规模试验
|
||||
@@ -1866,7 +1871,7 @@ unilab --ak your_ak --sk your_sk -g graph.json \
|
||||
#### 14.5 社区支持
|
||||
|
||||
- **GitHub Issues**:[https://github.com/deepmodeling/Uni-Lab-OS/issues](https://github.com/deepmodeling/Uni-Lab-OS/issues)
|
||||
- **官方网站**:[https://leap-lab.bohrium.com](https://leap-lab.bohrium.com)
|
||||
- **官方网站**:[https://uni-lab.bohrium.com](https://uni-lab.bohrium.com)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -626,7 +626,7 @@ unilab
|
||||
|
||||
**云端图文件管理**:
|
||||
|
||||
1. 登录 https://leap-lab.bohrium.com
|
||||
1. 登录 https://uni-lab.bohrium.com
|
||||
2. 进入"设备配置"
|
||||
3. 创建或编辑配置
|
||||
4. 保存到云端
|
||||
|
||||
@@ -54,6 +54,7 @@ Uni-Lab 的启动过程分为以下几个阶段:
|
||||
您可以直接跟随 unilabos 的提示进行,无需查阅本节
|
||||
|
||||
- **工作目录设置**:
|
||||
|
||||
- 如果当前目录以 `unilabos_data` 结尾,则使用当前目录
|
||||
- 否则使用 `当前目录/unilabos_data` 作为工作目录
|
||||
- 可通过 `--working_dir` 指定自定义工作目录
|
||||
@@ -67,8 +68,8 @@ Uni-Lab 的启动过程分为以下几个阶段:
|
||||
|
||||
支持多种后端环境:
|
||||
|
||||
- `--addr test`:测试环境 (`https://leap-lab.test.bohrium.com/api/v1`)
|
||||
- `--addr uat`:UAT 环境 (`https://leap-lab.uat.bohrium.com/api/v1`)
|
||||
- `--addr test`:测试环境 (`https://uni-lab.test.bohrium.com/api/v1`)
|
||||
- `--addr uat`:UAT 环境 (`https://uni-lab.uat.bohrium.com/api/v1`)
|
||||
- `--addr local`:本地环境 (`http://127.0.0.1:48197/api/v1`)
|
||||
- 自定义地址:直接指定完整 URL
|
||||
|
||||
@@ -175,7 +176,7 @@ unilab --config path/to/your/config.py
|
||||
|
||||
如果是首次使用,系统会:
|
||||
|
||||
1. 提示前往 https://leap-lab.bohrium.com 注册实验室
|
||||
1. 提示前往 https://uni-lab.bohrium.com 注册实验室
|
||||
2. 引导创建配置文件
|
||||
3. 设置工作目录
|
||||
|
||||
@@ -215,7 +216,7 @@ unilab --ak your_ak --sk your_sk --port 8080 --disable_browser
|
||||
|
||||
如果提示 "后续运行必须拥有一个实验室",请确保:
|
||||
|
||||
- 已在 https://leap-lab.bohrium.com 注册实验室
|
||||
- 已在 https://uni-lab.bohrium.com 注册实验室
|
||||
- 正确设置了 `--ak` 和 `--sk` 参数
|
||||
- 配置文件中包含正确的认证信息
|
||||
|
||||
|
||||
@@ -233,7 +233,7 @@ def parse_args():
|
||||
parser.add_argument(
|
||||
"--addr",
|
||||
type=str,
|
||||
default="https://leap-lab.bohrium.com/api/v1",
|
||||
default="https://uni-lab.bohrium.com/api/v1",
|
||||
help="Laboratory backend address",
|
||||
)
|
||||
parser.add_argument(
|
||||
@@ -438,10 +438,10 @@ def main():
|
||||
if args.addr != parser.get_default("addr"):
|
||||
if args.addr == "test":
|
||||
print_status("使用测试环境地址", "info")
|
||||
HTTPConfig.remote_addr = "https://leap-lab.test.bohrium.com/api/v1"
|
||||
HTTPConfig.remote_addr = "https://uni-lab.test.bohrium.com/api/v1"
|
||||
elif args.addr == "uat":
|
||||
print_status("使用uat环境地址", "info")
|
||||
HTTPConfig.remote_addr = "https://leap-lab.uat.bohrium.com/api/v1"
|
||||
HTTPConfig.remote_addr = "https://uni-lab.uat.bohrium.com/api/v1"
|
||||
elif args.addr == "local":
|
||||
print_status("使用本地环境地址", "info")
|
||||
HTTPConfig.remote_addr = "http://127.0.0.1:48197/api/v1"
|
||||
@@ -553,7 +553,7 @@ def main():
|
||||
os._exit(0)
|
||||
|
||||
if not BasicConfig.ak or not BasicConfig.sk:
|
||||
print_status("后续运行必须拥有一个实验室,请前往 https://leap-lab.bohrium.com 注册实验室!", "warning")
|
||||
print_status("后续运行必须拥有一个实验室,请前往 https://uni-lab.bohrium.com 注册实验室!", "warning")
|
||||
os._exit(1)
|
||||
graph: nx.Graph
|
||||
resource_tree_set: ResourceTreeSet
|
||||
@@ -621,8 +621,6 @@ def main():
|
||||
continue
|
||||
|
||||
# 如果从远端获取了物料信息,则与本地物料进行同步
|
||||
# 仅在本地文件模式下有意义:本地文件只含设备结构,远端有已保存的物料,需要 merge
|
||||
# 远端模式下 resource_tree_set 与 request_startup_json 来自同一份数据,merge 为空操作
|
||||
if file_path is not None and request_startup_json and "nodes" in request_startup_json:
|
||||
print_status("开始同步远端物料到本地...", "info")
|
||||
remote_tree_set = ResourceTreeSet.from_raw_dict_list(request_startup_json["nodes"])
|
||||
|
||||
@@ -36,9 +36,6 @@ class HTTPClient:
|
||||
auth_secret = BasicConfig.auth_secret()
|
||||
self.auth = auth_secret
|
||||
info(f"正在使用ak sk作为授权信息:[{auth_secret}]")
|
||||
# 复用 TCP/TLS 连接,避免每次请求重新握手
|
||||
self._session = requests.Session()
|
||||
self._session.headers.update({"Authorization": f"Lab {self.auth}"})
|
||||
info(f"HTTPClient 初始化完成: remote_addr={self.remote_addr}")
|
||||
|
||||
def resource_edge_add(self, resources: List[Dict[str, Any]]) -> requests.Response:
|
||||
@@ -51,7 +48,7 @@ class HTTPClient:
|
||||
Returns:
|
||||
Response: API响应对象
|
||||
"""
|
||||
response = self._session.post(
|
||||
response = requests.post(
|
||||
f"{self.remote_addr}/edge/material/edge",
|
||||
json={
|
||||
"edges": resources,
|
||||
@@ -78,28 +75,26 @@ class HTTPClient:
|
||||
Returns:
|
||||
Dict[str, str]: 旧UUID到新UUID的映射关系 {old_uuid: new_uuid}
|
||||
"""
|
||||
# dump() 只调用一次,复用给文件保存和 HTTP 请求
|
||||
nodes_info = [x for xs in resources.dump() for x in xs]
|
||||
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_add.json"), "w", encoding="utf-8") as f:
|
||||
payload = {"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid}
|
||||
f.write(json.dumps(payload, indent=4))
|
||||
# 从序列化数据中提取所有节点的UUID(保存旧UUID)
|
||||
old_uuids = {n.res_content.uuid: n for n in resources.all_nodes}
|
||||
payload = {"nodes": nodes_info, "mount_uuid": mount_uuid}
|
||||
body_bytes = _fast_dumps(payload)
|
||||
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_add.json"), "wb") as f:
|
||||
f.write(_fast_dumps_pretty(payload))
|
||||
http_headers = {"Content-Type": "application/json"}
|
||||
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 = self._session.post(
|
||||
response = requests.post(
|
||||
f"{self.remote_addr}/edge/material",
|
||||
data=body_bytes,
|
||||
headers=http_headers,
|
||||
json={"nodes": nodes_info, "mount_uuid": mount_uuid},
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
timeout=60,
|
||||
)
|
||||
else:
|
||||
response = self._session.put(
|
||||
response = requests.put(
|
||||
f"{self.remote_addr}/edge/material",
|
||||
data=body_bytes,
|
||||
headers=http_headers,
|
||||
json={"nodes": nodes_info, "mount_uuid": mount_uuid},
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
@@ -138,7 +133,7 @@ class HTTPClient:
|
||||
"""
|
||||
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_get.json"), "w", encoding="utf-8") as f:
|
||||
f.write(json.dumps({"uuids": uuid_list, "with_children": with_children}, indent=4))
|
||||
response = self._session.post(
|
||||
response = requests.post(
|
||||
f"{self.remote_addr}/edge/material/query",
|
||||
json={"uuids": uuid_list, "with_children": with_children},
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
@@ -152,7 +147,6 @@ class HTTPClient:
|
||||
logger.error(f"查询物料失败: {response.text}")
|
||||
else:
|
||||
data = res["data"]["nodes"]
|
||||
logger.trace(f"resource_tree_get查询到物料: {data}")
|
||||
return data
|
||||
else:
|
||||
logger.error(f"查询物料失败: {response.text}")
|
||||
@@ -170,14 +164,14 @@ class HTTPClient:
|
||||
if not self.initialized:
|
||||
self.initialized = True
|
||||
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
|
||||
response = self._session.post(
|
||||
response = requests.post(
|
||||
f"{self.remote_addr}/lab/material",
|
||||
json={"nodes": resources},
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
timeout=100,
|
||||
)
|
||||
else:
|
||||
response = self._session.put(
|
||||
response = requests.put(
|
||||
f"{self.remote_addr}/lab/material",
|
||||
json={"nodes": resources},
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
@@ -204,7 +198,7 @@ class HTTPClient:
|
||||
"""
|
||||
with open(os.path.join(BasicConfig.working_dir, "req_resource_get.json"), "w", encoding="utf-8") as f:
|
||||
f.write(json.dumps({"id": id, "with_children": with_children}, indent=4))
|
||||
response = self._session.get(
|
||||
response = requests.get(
|
||||
f"{self.remote_addr}/lab/material",
|
||||
params={"id": id, "with_children": with_children},
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
@@ -245,14 +239,14 @@ class HTTPClient:
|
||||
if not self.initialized:
|
||||
self.initialized = True
|
||||
info(f"首次添加资源,当前远程地址: {self.remote_addr}")
|
||||
response = self._session.post(
|
||||
response = requests.post(
|
||||
f"{self.remote_addr}/lab/material",
|
||||
json={"nodes": resources},
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
timeout=100,
|
||||
)
|
||||
else:
|
||||
response = self._session.put(
|
||||
response = requests.put(
|
||||
f"{self.remote_addr}/lab/material",
|
||||
json={"nodes": resources},
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
@@ -282,7 +276,7 @@ class HTTPClient:
|
||||
with open(file_path, "rb") as file:
|
||||
files = {"files": file}
|
||||
logger.info(f"上传文件: {file_path} 到 {scene}")
|
||||
response = self._session.post(
|
||||
response = requests.post(
|
||||
f"{self.remote_addr}/api/account/file_upload/{scene}",
|
||||
files=files,
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
@@ -322,7 +316,7 @@ class HTTPClient:
|
||||
"Content-Type": "application/json",
|
||||
"Content-Encoding": "gzip",
|
||||
}
|
||||
response = self._session.post(
|
||||
response = requests.post(
|
||||
f"{self.remote_addr}/lab/resource",
|
||||
data=compressed_body,
|
||||
headers=headers,
|
||||
@@ -356,7 +350,7 @@ class HTTPClient:
|
||||
Returns:
|
||||
Response: API响应对象
|
||||
"""
|
||||
response = self._session.get(
|
||||
response = requests.get(
|
||||
f"{self.remote_addr}/edge/material/download",
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
timeout=(3, 30),
|
||||
@@ -417,7 +411,7 @@ class HTTPClient:
|
||||
with open(os.path.join(BasicConfig.working_dir, "req_workflow_upload.json"), "w", encoding="utf-8") as f:
|
||||
f.write(json.dumps(payload, indent=4, ensure_ascii=False))
|
||||
|
||||
response = self._session.post(
|
||||
response = requests.post(
|
||||
f"{self.remote_addr}/lab/workflow/owner/import",
|
||||
json=payload,
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
|
||||
@@ -1269,13 +1269,7 @@ class QueueProcessor:
|
||||
if not queued_jobs:
|
||||
return
|
||||
|
||||
queue_summary = {}
|
||||
for j in queued_jobs:
|
||||
key = f"{j.device_id}/{j.action_name}"
|
||||
queue_summary[key] = queue_summary.get(key, 0) + 1
|
||||
logger.debug(
|
||||
f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs: {queue_summary}"
|
||||
)
|
||||
logger.debug(f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs")
|
||||
|
||||
for job_info in queued_jobs:
|
||||
# 快照可能已过期:在遍历过程中 end_job() 可能已将此 job 移至 READY,
|
||||
|
||||
@@ -46,7 +46,7 @@ class WSConfig:
|
||||
|
||||
# HTTP配置
|
||||
class HTTPConfig:
|
||||
remote_addr = "https://leap-lab.bohrium.com/api/v1"
|
||||
remote_addr = "https://uni-lab.bohrium.com/api/v1"
|
||||
|
||||
|
||||
# ROS配置
|
||||
|
||||
@@ -219,10 +219,10 @@ device = NewareBatteryTestSystem(
|
||||
|
||||
#### 步骤 2:提交测试任务
|
||||
|
||||
使用 `submit_from_csv_export_ndax` 提交测试任务:
|
||||
使用 `submit_from_csv` 提交测试任务:
|
||||
|
||||
```python
|
||||
result = device.submit_from_csv_export_ndax(
|
||||
result = device.submit_from_csv(
|
||||
csv_path="test_data.csv",
|
||||
output_dir="D:/neware_output"
|
||||
)
|
||||
@@ -489,7 +489,7 @@ A: 重新获取新的 Token 并更新环境变量 `UNI_LAB_AUTH_TOKEN`。
|
||||
**Q: 可以自定义上传路径吗?**
|
||||
A: 当前版本路径由统一 API 自动分配,`oss_prefix` 参数暂不使用(保留接口兼容性)。
|
||||
|
||||
**Q: 为什么不在 `submit_from_csv_export_ndax` 中自动上传?**
|
||||
**Q: 为什么不在 `submit_from_csv` 中自动上传?**
|
||||
A: 因为备份文件在测试进行中逐步生成,方法返回时可能文件尚未完全生成,因此提供独立的上传方法更灵活。
|
||||
|
||||
**Q: 上传后如何访问文件?**
|
||||
|
||||
@@ -230,10 +230,10 @@ device = NewareBatteryTestSystem(
|
||||
|
||||
#### Step 2: Submit Test Tasks
|
||||
|
||||
Use `submit_from_csv_export_ndax` to submit test tasks:
|
||||
Use `submit_from_csv` to submit test tasks:
|
||||
|
||||
```python
|
||||
result = device.submit_from_csv_export_ndax(
|
||||
result = device.submit_from_csv(
|
||||
csv_path="test_data.csv",
|
||||
output_dir="D:/neware_output"
|
||||
)
|
||||
@@ -500,7 +500,7 @@ A: Obtain a new API Key and update the `UNI_LAB_AUTH_TOKEN` environment variable
|
||||
**Q: Can I customize upload paths?**
|
||||
A: Current version has paths automatically assigned by unified API. `oss_prefix` parameter is currently unused (retained for interface compatibility).
|
||||
|
||||
**Q: Why not auto-upload in `submit_from_csv_export_ndax`?**
|
||||
**Q: Why not auto-upload in `submit_from_csv`?**
|
||||
A: Because backup files are generated progressively during testing, they may not be fully generated when the method returns. A separate upload method provides more flexibility.
|
||||
|
||||
**Q: How to access files after upload?**
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"config": {
|
||||
"ip": "127.0.0.1",
|
||||
"port": 502,
|
||||
"machine_ids": [1, 2, 3, 4, 5, 6, 86],
|
||||
"machine_id": 1,
|
||||
"devtype": "27",
|
||||
"timeout": 20,
|
||||
"size_x": 500.0,
|
||||
@@ -26,10 +26,10 @@
|
||||
"data": {
|
||||
"功能说明": "新威电池测试系统,提供720通道监控和CSV批量提交功能",
|
||||
"监控功能": "支持720个通道的实时状态监控、2盘电池物料管理、状态导出等",
|
||||
"提交功能": "通过submit_from_csv action从CSV文件批量提交测试任务(NDA备份),或通过submit_from_csv_export_excel action提交并备份为Excel格式。CSV必须包含: Battery_Code, Pole_Weight, 集流体质量, 活性物质含量, 克容量mah/g, 电池体系, 设备号, 排号, 通道号"
|
||||
"提交功能": "通过submit_from_csv action从CSV文件批量提交测试任务。CSV必须包含: Battery_Code, Pole_Weight, 集流体质量, 活性物质含量, 克容量mah/g, 电池体系, 设备号, 排号, 通道号"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,56 +0,0 @@
|
||||
import socket
|
||||
END_MARKS = [b"\r\n#\r\n", b"</bts>"] # 读到任一标志即可判定完整响应
|
||||
|
||||
def build_start_command(devid, subdevid, chlid, CoinID,
|
||||
ip_in_xml="127.0.0.1",
|
||||
devtype:int=27,
|
||||
recipe_path:str=f"D:\\HHM_test\\A001.xml",
|
||||
backup_dir:str=f"D:\\HHM_test\\backup",
|
||||
filetype:int=1) -> str:
|
||||
"""
|
||||
filetype: 备份文件类型。0=NDA(新威原生),1=Excel。默认 1。
|
||||
"""
|
||||
lines = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<bts version="1.0">',
|
||||
' <cmd>start</cmd>',
|
||||
' <list count="1">',
|
||||
f' <start ip="{ip_in_xml}" devtype="{devtype}" devid="{devid}" subdevid="{subdevid}" chlid="{chlid}" barcode="{CoinID}">{recipe_path}</start>',
|
||||
f' <backup backupdir="{backup_dir}" remotedir="" filenametype="1" customfilename="" createdirbydate="0" filetype="{int(filetype)}" backupontime="1" backupontimeinterval="1" backupfree="0" />',
|
||||
' </list>',
|
||||
'</bts>',
|
||||
]
|
||||
# TCP 模式:请求必须以 #\r\n 结束(协议要求)
|
||||
return "\r\n".join(lines) + "\r\n#\r\n"
|
||||
|
||||
def recv_until_marks(sock: socket.socket, timeout=60):
|
||||
sock.settimeout(timeout) # 上限给足,协议允许到 30s:contentReference[oaicite:2]{index=2}
|
||||
buf = bytearray()
|
||||
while True:
|
||||
chunk = sock.recv(8192)
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
# 读到结束标志就停,避免等对端断开
|
||||
for m in END_MARKS:
|
||||
if m in buf:
|
||||
return bytes(buf)
|
||||
# 保险:读到完整 XML 结束标签也停
|
||||
if b"</bts>" in buf:
|
||||
return bytes(buf)
|
||||
return bytes(buf)
|
||||
|
||||
def start_test(ip="127.0.0.1", port=502, devid=3, subdevid=2, chlid=1, CoinID="A001", recipe_path=f"D:\\HHM_test\\A001.xml", backup_dir=f"D:\\HHM_test\\backup", filetype:int=1):
|
||||
"""
|
||||
filetype: 备份文件类型,0=NDA,1=Excel。默认 1。
|
||||
"""
|
||||
xml_cmd = build_start_command(devid=devid, subdevid=subdevid, chlid=chlid, CoinID=CoinID, recipe_path=recipe_path, backup_dir=backup_dir, filetype=filetype)
|
||||
#print(xml_cmd)
|
||||
with socket.create_connection((ip, port), timeout=60) as s:
|
||||
s.sendall(xml_cmd.encode("utf-8"))
|
||||
data = recv_until_marks(s, timeout=60)
|
||||
return data.decode("utf-8", errors="replace")
|
||||
|
||||
if __name__ == "__main__":
|
||||
resp = start_test(ip="127.0.0.1", port=502, devid=4, subdevid=10, chlid=1, CoinID="A001", recipe_path=f"D:\\HHM_test\\A001.xml", backup_dir=f"D:\\HHM_test\\backup")
|
||||
print(resp)
|
||||
@@ -22,11 +22,10 @@ from threading import Lock, RLock
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from unilabos.registry.decorators import (
|
||||
device, action, ActionInputHandle, ActionOutputHandle, DataSource, topic_config, not_action, NodeType
|
||||
device, action, ActionInputHandle, ActionOutputHandle, DataSource, topic_config, not_action
|
||||
)
|
||||
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode
|
||||
from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample, ResourceTreeSet
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample
|
||||
|
||||
|
||||
# ============ TypedDict 返回类型定义 ============
|
||||
@@ -291,126 +290,6 @@ class VirtualWorkbench:
|
||||
self._update_data_status(f"机械臂已释放 (完成: {task})")
|
||||
self.logger.info(f"机械臂已释放 (完成: {task})")
|
||||
|
||||
@action(
|
||||
always_free=True, node_type=NodeType.MANUAL_CONFIRM, placeholder_keys={
|
||||
"assignee_user_ids": "unilabos_manual_confirm"
|
||||
}, goal_default={
|
||||
"timeout_seconds": 3600,
|
||||
"assignee_user_ids": []
|
||||
}, feedback_interval=300,
|
||||
handles=[
|
||||
ActionInputHandle(key="target_device", data_type="device_id",
|
||||
label="目标设备", data_key="target_device", data_source=DataSource.HANDLE),
|
||||
ActionInputHandle(key="resource", data_type="resource",
|
||||
label="待转移资源", data_key="resource", data_source=DataSource.HANDLE),
|
||||
ActionInputHandle(key="mount_resource", data_type="resource",
|
||||
label="目标孔位", data_key="mount_resource", data_source=DataSource.HANDLE),
|
||||
|
||||
ActionInputHandle(key="collector_mass", data_type="collector_mass",
|
||||
label="极流体质量", data_key="collector_mass", data_source=DataSource.HANDLE),
|
||||
ActionInputHandle(key="active_material", data_type="active_material",
|
||||
label="活性物质含量", data_key="active_material", data_source=DataSource.HANDLE),
|
||||
ActionInputHandle(key="capacity", data_type="capacity",
|
||||
label="克容量", data_key="capacity", data_source=DataSource.HANDLE),
|
||||
ActionInputHandle(key="battery_system", data_type="battery_system",
|
||||
label="电池体系", data_key="battery_system", data_source=DataSource.HANDLE),
|
||||
# transfer使用
|
||||
ActionOutputHandle(key="target_device", data_type="device_id",
|
||||
label="目标设备", data_key="target_device", data_source=DataSource.EXECUTOR),
|
||||
ActionOutputHandle(key="resource", data_type="resource",
|
||||
label="待转移资源", data_key="resource.@flatten", data_source=DataSource.EXECUTOR),
|
||||
ActionOutputHandle(key="mount_resource", data_type="resource",
|
||||
label="目标孔位", data_key="mount_resource.@flatten", data_source=DataSource.EXECUTOR),
|
||||
# test使用
|
||||
ActionOutputHandle(key="collector_mass", data_type="collector_mass",
|
||||
label="极流体质量", data_key="collector_mass", data_source=DataSource.EXECUTOR),
|
||||
ActionOutputHandle(key="active_material", data_type="active_material",
|
||||
label="活性物质含量", data_key="active_material", data_source=DataSource.EXECUTOR),
|
||||
ActionOutputHandle(key="capacity", data_type="capacity",
|
||||
label="克容量", data_key="capacity", data_source=DataSource.EXECUTOR),
|
||||
ActionOutputHandle(key="battery_system", data_type="battery_system",
|
||||
label="电池体系", data_key="battery_system", data_source=DataSource.EXECUTOR),
|
||||
]
|
||||
)
|
||||
def manual_confirm(
|
||||
self,
|
||||
resource: List[ResourceSlot],
|
||||
target_device: DeviceSlot,
|
||||
mount_resource: List[ResourceSlot],
|
||||
collector_mass: List[float],
|
||||
active_material: List[float],
|
||||
capacity: List[float],
|
||||
battery_system: List[str],
|
||||
timeout_seconds: int,
|
||||
assignee_user_ids: list[str],
|
||||
**kwargs
|
||||
) -> dict:
|
||||
"""
|
||||
timeout_seconds: 超时时间(秒),默认3600秒
|
||||
collector_mass: 极流体质量
|
||||
active_material: 活性物质含量
|
||||
capacity: 克容量(mAh/g)
|
||||
battery_system: 电池体系
|
||||
修改的结果无效,是只读的
|
||||
"""
|
||||
resource = ResourceTreeSet.from_plr_resources(resource).dump()
|
||||
mount_resource = ResourceTreeSet.from_plr_resources(mount_resource).dump()
|
||||
kwargs.update(locals())
|
||||
kwargs.pop("kwargs")
|
||||
kwargs.pop("self")
|
||||
return kwargs
|
||||
|
||||
@action(
|
||||
description="转移物料",
|
||||
handles=[
|
||||
ActionInputHandle(key="target_device", data_type="device_id",
|
||||
label="目标设备", data_key="target_device", data_source=DataSource.HANDLE),
|
||||
ActionInputHandle(key="resource", data_type="resource",
|
||||
label="待转移资源", data_key="resource", data_source=DataSource.HANDLE),
|
||||
ActionInputHandle(key="mount_resource", data_type="resource",
|
||||
label="目标孔位", data_key="mount_resource", data_source=DataSource.HANDLE),
|
||||
]
|
||||
)
|
||||
async def transfer(self, resource: List[ResourceSlot], target_device: DeviceSlot, mount_resource: List[ResourceSlot]):
|
||||
future = ROS2DeviceNode.run_async_func(self._ros_node.transfer_resource_to_another, True,
|
||||
**{
|
||||
"plr_resources": resource,
|
||||
"target_device_id": target_device,
|
||||
"target_resources": mount_resource,
|
||||
"sites": [None] * len(mount_resource),
|
||||
})
|
||||
result = await future
|
||||
return result
|
||||
|
||||
|
||||
@action(
|
||||
description="扣电测试启动",
|
||||
handles=[
|
||||
ActionInputHandle(key="resource", data_type="resource",
|
||||
label="待转移资源", data_key="resource", data_source=DataSource.HANDLE),
|
||||
ActionInputHandle(key="mount_resource", data_type="resource",
|
||||
label="目标孔位", data_key="mount_resource", data_source=DataSource.HANDLE),
|
||||
|
||||
ActionInputHandle(key="collector_mass", data_type="collector_mass",
|
||||
label="极流体质量", data_key="collector_mass", data_source=DataSource.HANDLE),
|
||||
ActionInputHandle(key="active_material", data_type="active_material",
|
||||
label="活性物质含量", data_key="active_material", data_source=DataSource.HANDLE),
|
||||
ActionInputHandle(key="capacity", data_type="capacity",
|
||||
label="克容量", data_key="capacity", data_source=DataSource.HANDLE),
|
||||
ActionInputHandle(key="battery_system", data_type="battery_system",
|
||||
label="电池体系", data_key="battery_system", data_source=DataSource.HANDLE),
|
||||
]
|
||||
)
|
||||
async def test(
|
||||
self, resource: List[ResourceSlot], mount_resource: List[ResourceSlot], collector_mass: List[float], active_material: List[float], capacity: List[float], battery_system: list[str]
|
||||
):
|
||||
print(resource)
|
||||
print(mount_resource)
|
||||
print(collector_mass)
|
||||
print(active_material)
|
||||
print(capacity)
|
||||
print(battery_system)
|
||||
|
||||
@action(
|
||||
auto_prefix=True,
|
||||
description="批量准备物料 - 虚拟起始节点, 生成A1-A5物料, 输出5个handle供后续节点使用",
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
@@ -258,7 +258,7 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||
logger.info(f"[同步→Bioyond] ➕ 物料不存在于 Bioyond,将创建新物料并入库")
|
||||
|
||||
# 第1步:从配置中获取仓库配置
|
||||
warehouse_mapping = self.workstation.bioyond_config.get("warehouse_mapping", {})
|
||||
warehouse_mapping = self.bioyond_config.get("warehouse_mapping", {})
|
||||
|
||||
# 确定目标仓库名称
|
||||
parent_name = None
|
||||
@@ -760,9 +760,10 @@ class BioyondWorkstation(WorkstationBase):
|
||||
except:
|
||||
pass
|
||||
|
||||
# 创建通信模块;同步器将在 post_init 中初始化并执行首次同步
|
||||
# 创建通信模块
|
||||
self._create_communication_module(bioyond_config)
|
||||
self.resource_synchronizer = None
|
||||
self.resource_synchronizer = BioyondResourceSynchronizer(self)
|
||||
self.resource_synchronizer.sync_from_external()
|
||||
|
||||
# TODO: self._ros_node里面拿属性
|
||||
|
||||
@@ -801,15 +802,6 @@ class BioyondWorkstation(WorkstationBase):
|
||||
def post_init(self, ros_node: ROS2WorkstationNode):
|
||||
self._ros_node = ros_node
|
||||
|
||||
# Deck 为空时(反序列化未恢复子节点),主动调用 setup() 初始化仓库
|
||||
if self.deck and not self.deck.children and hasattr(self.deck, "setup") and callable(self.deck.setup):
|
||||
logger.info("Deck 无仓库子节点,调用 setup() 初始化仓库")
|
||||
self.deck.setup()
|
||||
|
||||
# 初始化同步器并执行首次同步(需在仓库初始化之后)
|
||||
self.resource_synchronizer = BioyondResourceSynchronizer(self)
|
||||
self.resource_synchronizer.sync_from_external()
|
||||
|
||||
# 启动连接监控
|
||||
try:
|
||||
self.connection_monitor = ConnectionMonitor(self)
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
# 代码变更说明 — 2026-03-12
|
||||
|
||||
> 本次变更基于 `implementation_plan_v2.md` 执行,目标:**物理几何结构初始化与物料内容物填充彻底解耦**,消除 PLR 反序列化时的 `Resource already assigned to deck` 错误,并修复若干运行时新增问题。
|
||||
|
||||
---
|
||||
|
||||
## 一、物料系统标准化重构(主线任务)
|
||||
|
||||
### 1. `unilabos/resources/battery/magazine.py`
|
||||
|
||||
**改动**:`MagazineHolder_6_Cathode`、`MagazineHolder_6_Anode`、`MagazineHolder_4_Cathode` 三个工厂函数的 `klasses` 参数改为 `None`。
|
||||
|
||||
**原因**:原来三个工厂函数在初始化时就向洞位填满极片对象(`ElectrodeSheet`),导致 PLR 反序列化时"几何结构已创建子节点 + DB 再次 assign"双重冲突。
|
||||
|
||||
**原则**:物料余量改由寄存器直读(阶段 F),资源树不再追踪每个极片实体。`MagazineHolder_6_Battery` 原本就是 `klasses=None`,三者现在保持一致。
|
||||
|
||||
---
|
||||
|
||||
### 2. `unilabos/resources/battery/magazine.py`(追加,响应重复 UUID 问题)
|
||||
|
||||
**改动**:为 `Magazine`(洞位类)新增 `serialize` 和 `deserialize` 重写:
|
||||
- `serialize`:序列化时强制将 `children` 置空,不再把极片写回数据库。
|
||||
- `deserialize`:反序列化时强制忽略 `children` 字段,阻止数据库中旧极片记录被恢复。
|
||||
|
||||
**原因**:数据库中遗留有旧的 `ElectrodeSheet` 记录(`A1_sheet100` 等),启动时被 PLR 反序列化进来,导致同一 UUID 出现在多个 Magazine 洞位中,触发 `发现重复的uuid` 错误。此修复从源头截断旧数据,经过一次完整的"启动 → 资源树写回"后,数据库旧极片记录也会被干净覆盖。
|
||||
|
||||
---
|
||||
|
||||
### 3. `unilabos/resources/battery/bottle_carriers.py`
|
||||
|
||||
**改动**:删除 `YIHUA_Electrolyte_12VialCarrier` 末尾的 12 瓶填充循环及对应 `import`。
|
||||
|
||||
**原因**:`bottle_rack_6x2` 和 `bottle_rack_6x2_2` 应初始化为空载架,瓶子由 Bioyond 侧实际转运后再填入。原来初始化时直接塞满 `YB_pei_ye_xiao_Bottle`,反序列化时产生重复 assign。
|
||||
|
||||
---
|
||||
|
||||
### 4. `unilabos/resources/bioyond/decks.py`
|
||||
|
||||
**改动**:
|
||||
- 将 `BIOYOND_YB_Deck` 重命名为 `BioyondElectrolyteDeck`,保留 `BIOYOND_YB_Deck` 作为向后兼容别名。
|
||||
- 工厂函数 `YB_Deck()` 重命名为 `bioyond_electrolyte_deck()`,保留 `YB_Deck` 作为别名。
|
||||
- `BIOYOND_PolymerReactionStation_Deck`、`BIOYOND_PolymerPreparationStation_Deck`、`BioyondElectrolyteDeck` 三个 Deck 类:
|
||||
- 移除 `__init__` 中的 `setup: bool = False` 参数及 `if setup: self.setup()` 调用。
|
||||
- 删除临时 `deserialize` 补丁(该补丁是为了强制 `setup=False`,根本原因消除后不再需要)。
|
||||
|
||||
**原因**:`setup` 参数导致 PLR 反序列化时先通过 `__init__` 创建所有子资源,再从 JSON `children` 字段再次 assign,产生 `already assigned to deck` 错误。正确模式:`__init__` 只初始化自身几何,`setup()` 由工厂函数调用,反序列化由 PLR 从 DB 数据重建子资源。
|
||||
|
||||
---
|
||||
|
||||
### 5. `unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py`
|
||||
|
||||
**改动**:
|
||||
- `CoincellDeck` 重命名为 `YihuaCoinCellDeck`,保留 `CoincellDeck` 作为向后兼容别名。
|
||||
- 工厂函数 `YH_Deck()` 重命名为 `yihua_coin_cell_deck()`,保留 `YH_Deck` 作为别名。
|
||||
- 移除 `YihuaCoinCellDeck.__init__` 中的 `setup: bool = False` 参数及调用,删除 `deserialize` 补丁(原因同 decks.py)。
|
||||
- `MaterialPlate.__init__` 移除 `fill` 参数和 `fill=True` 分支,新增类方法 `MaterialPlate.create_with_holes()` 作为"带洞位"的工厂方法,`setup()` 改为调用该工厂方法。
|
||||
- `YihuaCoinCellDeck.setup()` 末尾新增 `electrolyte_buffer`(`ResourceStack`)接驳槽,用于接收来自 Bioyond 侧的分液瓶板,命名与 `bioyond_cell_workstation.py` 中 `sites=["electrolyte_buffer"]` 一致。
|
||||
|
||||
---
|
||||
|
||||
### 6. `unilabos/resources/resource_tracker.py`
|
||||
|
||||
**改动 1**:`to_plr_resources` 中,`load_all_state` 调用前预填 `Container` 类资源缺失的键:
|
||||
|
||||
```python
|
||||
state.setdefault("liquid_history", [])
|
||||
state.setdefault("pending_liquids", {})
|
||||
```
|
||||
|
||||
**原因**:新版 PLR 要求 `Container` 状态中必须包含这两个键,旧数据库记录缺失时 `load_all_state` 会抛出 `KeyError`。
|
||||
|
||||
**改动 2**:`_validate_tree` 中,遇到重复 UUID 时改为自动重新分配新 UUID 并打 `WARNING`,不再直接抛异常崩溃。
|
||||
|
||||
**原因**:旧数据库中存在多个同名同 UUID 的极片对象(历史脏数据),严格校验会导致节点无法启动。改为 WARNING + 自动修复,确保启动成功,下次资源树写回后脏数据自然清除。
|
||||
|
||||
---
|
||||
|
||||
### 7. `unilabos/resources/itemized_carrier.py`
|
||||
|
||||
**改动**:将原来的 `idx is None` 兜底补丁(静默调用 `super().assign_child_resource`,不更新槽位追踪)替换为两段式逻辑:
|
||||
|
||||
1. **XY 近似匹配**(容差 2mm):精确三维坐标匹配失败时,仅对比 XY 二维坐标,找到最近槽位后用槽位的正确坐标(含 Z)完成 assign,并打 `WARNING`。
|
||||
2. **XY 也失败才抛异常**:给出详细的槽位列表和传入坐标,便于问题排查。
|
||||
|
||||
**原因**:数据库中存储的资源坐标 Z=0,而 `warehouse_factory` 定义的槽位 Z=dz(如 10mm)。精确匹配永远失败,原补丁静默兜底掩盖了这一问题。近似匹配修复了 Z 偏移,同时保留了真正异常时的报错能力。
|
||||
|
||||
---
|
||||
|
||||
### 8. `unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py`
|
||||
|
||||
**改动 1**:更新导入:`BIOYOND_YB_Deck` → `BioyondElectrolyteDeck, bioyond_electrolyte_deck`。
|
||||
|
||||
**改动 2**:`__main__` 入口处改为调用 `bioyond_electrolyte_deck(name="YB_Deck")`。
|
||||
|
||||
**改动 3**:新增 `_get_resource_from_device(device_id, resource_name)` 方法,用于从目标设备的资源树中动态查找 PLR 资源对象(带降级回退逻辑)。
|
||||
|
||||
**改动 4**:跨站转运逻辑中,将原来"创建 `size=1,1,1` 的虚拟 `ResourcePLR` + 硬编码 UUID"的方式,改为通过 `_get_resource_from_device` 从目标设备获取真实的 `electrolyte_buffer` 资源对象。
|
||||
|
||||
**原因**:原代码使用硬编码 UUID 的虚拟资源作为转运目标,该对象在 YihuaCoinCellDeck 的资源树中不存在,转移后资源树状态混乱。
|
||||
|
||||
---
|
||||
|
||||
### 9. `unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py`
|
||||
|
||||
**改动 1**:更新导入:`CoincellDeck` → `YihuaCoinCellDeck, yihua_coin_cell_deck`,`__main__` 入口改为调用 `yihua_coin_cell_deck()`。
|
||||
|
||||
**改动 2**:新增 10 个 `@property`,实现对依华扣电工站 Modbus 寄存器的直读:
|
||||
|
||||
| 属性名 | 寄存器地址 | 说明 |
|
||||
|---|---|---|
|
||||
| `data_10mm_positive_plate_remaining` | 520 | 10mm正极片余量 |
|
||||
| `data_12mm_positive_plate_remaining` | 522 | 12mm正极片余量 |
|
||||
| `data_16mm_positive_plate_remaining` | 524 | 16mm正极片余量 |
|
||||
| `data_aluminum_foil_remaining` | 526 | 铝箔余量 |
|
||||
| `data_positive_shell_remaining` | 528 | 正极壳余量 |
|
||||
| `data_flat_washer_remaining` | 530 | 平垫余量 |
|
||||
| `data_negative_shell_remaining` | 532 | 负极壳余量 |
|
||||
| `data_spring_washer_remaining` | 534 | 弹垫余量 |
|
||||
| `data_finished_battery_remaining_capacity` | 536 | 成品电池余量 |
|
||||
| `data_finished_battery_ng_remaining_capacity` | 538 | 成品电池NG槽余量 |
|
||||
|
||||
**原因**:`coin_cell_workstation.yaml` 的 `status_types` 中定义了这 10 个属性,但代码中从未实现,导致每次前端轮询时均报 `AttributeError`。
|
||||
|
||||
---
|
||||
|
||||
## 二、配置与注册表更新
|
||||
|
||||
### 10. `yibin_electrolyte_config.json`
|
||||
- `BIOYOND_YB_Deck` → `BioyondElectrolyteDeck`(class、type、_resource_type 三处)
|
||||
- `CoincellDeck` → `YihuaCoinCellDeck`(class、type、_resource_type 三处)
|
||||
- 移除 `"setup": true` 字段
|
||||
|
||||
### 11. `yibin_coin_cell_only_config.json`
|
||||
- `CoincellDeck` → `YihuaCoinCellDeck`
|
||||
- 移除 `"setup": true`
|
||||
|
||||
### 12. `yibin_electrolyte_only_config.json`
|
||||
- `BIOYOND_YB_Deck` → `BioyondElectrolyteDeck`
|
||||
- 移除 `"setup": true`
|
||||
|
||||
### 13. `unilabos/registry/resources/bioyond/deck.yaml`
|
||||
- `BIOYOND_YB_Deck` → `BioyondElectrolyteDeck`,工厂函数路径更新为 `bioyond_electrolyte_deck`
|
||||
- `CoincellDeck` → `YihuaCoinCellDeck`,工厂函数路径更新为 `yihua_coin_cell_deck`
|
||||
|
||||
---
|
||||
|
||||
## 三、独立 Bug 修复
|
||||
|
||||
### 14. `unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly_b.csv`
|
||||
|
||||
**改动**:10 条余量寄存器记录的 `DataType` 列从 `REAL` 改为 `FLOAT32`。
|
||||
|
||||
**原因**:`REAL` 是 IEC 61131-3 PLC 工程师惯用名称,但 pymodbus 的 `DATATYPE` 枚举只有 `FLOAT32`,`DataType['REAL']` 查表时抛 `KeyError: 'REAL'`,导致 `CoinCellAssemblyWorkstation` 节点启动失败。
|
||||
|
||||
---
|
||||
|
||||
## 四、运行期新增 Bug 修复(第二轮,2026-03-12 18:12 日志)
|
||||
|
||||
### 15. `unilabos/devices/workstation/bioyond_studio/station.py`
|
||||
|
||||
**改动**:第 261 行 `self.bioyond_config` → `self.workstation.bioyond_config`。
|
||||
|
||||
**原因**:`BioyondResourceSynchronizer.sync_to_external` 内部误用了 `self.bioyond_config`,而该类从未设置此属性(应通过 `self.workstation.bioyond_config` 访问)。触发场景:用户在前端将任意物料拖入仓库时,同步到 Bioyond 必定抛出 `AttributeError: 'BioyondResourceSynchronizer' object has no attribute 'bioyond_config'`。
|
||||
|
||||
---
|
||||
|
||||
### 16. `unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py`
|
||||
|
||||
**改动**:`_get_type_id_by_name` 方法新增"直接英文 key 命中"分支:
|
||||
|
||||
- **原逻辑**:仅按 `value[0]`(中文名,如 `"5ml分液瓶板"`)遍历比较。
|
||||
- **新逻辑**:先以 `type_name` 直接查找 `material_type_mappings` 字典 key(英文 model 名,如 `"YB_Vial_5mL_Carrier"`),命中则立即返回 UUID;否则再按中文名兜底遍历。
|
||||
|
||||
**原因**:`resource_tree_transfer` 将 `plr_resource.model`(英文 key)作为 `board_type` / `bottle_type` 传给 `create_sample`,后者再调用 `_get_type_id_by_name`。旧版函数只按中文名查,导致英文 key 永远匹配不到 → `ValueError: 未找到板类型 'YB_Vial_5mL_Carrier' 的配置`。新函数兼容两种查找方式,同时保持向后兼容。
|
||||
|
||||
---
|
||||
|
||||
## 五、运行期新增 Bug 修复(第三轮,2026-03-12 20:30 日志)
|
||||
|
||||
### 17. `unilabos/resources/resource_tracker.py`(追加)
|
||||
|
||||
**改动**:在 `to_plr_resources` 中,`sub_cls.deserialize` 调用前新增 `_deduplicate_plr_dict(plr_dict)` 预处理函数。
|
||||
|
||||
**函数逻辑**:递归遍历整个 `plr_dict` 树,在**全树范围**对 `children` 列表按 `name` 去重——保留首次出现的同名节点,跳过重复项并打 `WARNING`。
|
||||
|
||||
**根本原因**:
|
||||
1. 用户通过前端将 `YB_Vial_5mL_Carrier` 拖入仓库 E01,carrier 及其子 vial(`YB_Vial_5mL_Carrier_vial_A1` 等)被写入数据库。
|
||||
2. 随后 `sync_from_external`(Bioyond 定期同步)以**新 UUID** 重新创建同名 carrier 并赋给同一槽位,PLR 内存树中的旧 carrier 被替换,但**数据库旧记录未被清除**。
|
||||
3. 下次重启时,数据库同一 `WareHouse` 下存在两条同名 `BottleCarrier`(不同 UUID),`node_to_plr_dict` 将二者都放入 `children` 列表,PLR 反序列化第二个 carrier 时子 vial 命名冲突,抛出 `ValueError: Resource with name 'YB_Vial_5mL_Carrier_vial_A1' already exists in the tree.`,整个 deck 无法加载,系统启动失败。
|
||||
|
||||
**连锁错误(随根因修复自动消除)**:
|
||||
- `TypeError: Deck.__init__() got an unexpected keyword argument 'data'` — deck 加载失败后 `driver_creator.py` 触发降级路径,参数类型错误
|
||||
- `AttributeError: 'ResourceDictInstance' object has no attribute 'copy'` — 另一条降级路径失败
|
||||
- `ValueError: Deck 配置不能为空` — 所有 deck 创建路径失败,`deck=None` 传入工作站
|
||||
|
||||
---
|
||||
|
||||
> **验证状态**:2026-03-12 20:56 日志确认系统正常运行,无新增 ERROR 级错误。
|
||||
|
||||
---
|
||||
|
||||
## 六、变更文件汇总(最终)
|
||||
|
||||
| 文件 | 变更类型 | 轮次 |
|
||||
|---|---|---|
|
||||
| `resources/battery/magazine.py` | 重构 + Bug 修复(极片子节点解耦 + 旧数据清理) | 第一轮 |
|
||||
| `resources/battery/bottle_carriers.py` | 重构(移除初始化时自动填瓶) | 第一轮 |
|
||||
| `resources/bioyond/decks.py` | 重构 + 重命名(BioyondElectrolyteDeck) | 第一轮 |
|
||||
| `devices/workstation/coin_cell_assembly/YB_YH_materials.py` | 重构 + 重命名(YihuaCoinCellDeck)+ 新增 electrolyte_buffer 槽位 | 第一轮 |
|
||||
| `resources/resource_tracker.py` | Bug 修复 × 3(Container 状态键预填 + 重复 UUID 自动修复 + 树级名称去重) | 第一/三轮 |
|
||||
| `resources/itemized_carrier.py` | Bug 修复(XY 近似坐标匹配,修复 Z 偏移) | 第一轮 |
|
||||
| `devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py` | 重构 + Bug 修复(跨站转运 + 类型映射双模式查找) | 第一/二轮 |
|
||||
| `devices/workstation/bioyond_studio/station.py` | Bug 修复(sync_to_external 属性访问路径) | 第二轮 |
|
||||
| `devices/workstation/coin_cell_assembly/coin_cell_assembly.py` | 新增 10 个 Modbus 余量属性 + 更新导入 | 第一轮 |
|
||||
| `yibin_electrolyte_config.json` | 配置更新(类名 + 移除 setup) | 第一轮 |
|
||||
| `yibin_coin_cell_only_config.json` | 配置更新(类名 + 移除 setup) | 第一轮 |
|
||||
| `yibin_electrolyte_only_config.json` | 配置更新(类名 + 移除 setup) | 第一轮 |
|
||||
| `registry/resources/bioyond/deck.yaml` | 注册表更新(类名 + 工厂函数路径) | 第一轮 |
|
||||
| `devices/workstation/coin_cell_assembly/coin_cell_assembly_b.csv` | Bug 修复(REAL → FLOAT32) | 第一轮 |
|
||||
@@ -130,14 +130,20 @@ class MaterialPlate(ItemizedResource[MaterialHole]):
|
||||
ordering: Optional[OrderedDict[str, str]] = None,
|
||||
category: str = "material_plate",
|
||||
model: Optional[str] = None,
|
||||
fill: bool = False
|
||||
):
|
||||
"""初始化料板(不主动填充洞位,由工厂方法或反序列化恢复)
|
||||
"""初始化料板
|
||||
|
||||
Args:
|
||||
name: 料板名称
|
||||
size_x: 长度 (mm)
|
||||
size_y: 宽度 (mm)
|
||||
size_z: 高度 (mm)
|
||||
hole_diameter: 洞直径 (mm)
|
||||
hole_depth: 洞深度 (mm)
|
||||
hole_spacing_x: X方向洞位间距 (mm)
|
||||
hole_spacing_y: Y方向洞位间距 (mm)
|
||||
number: 编号
|
||||
category: 类别
|
||||
model: 型号
|
||||
"""
|
||||
@@ -147,50 +153,42 @@ class MaterialPlate(ItemizedResource[MaterialHole]):
|
||||
hole_diameter=20.0,
|
||||
info="",
|
||||
)
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
ordered_items=ordered_items,
|
||||
ordering=ordering,
|
||||
category=category,
|
||||
model=model,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def create_with_holes(
|
||||
cls,
|
||||
name: str,
|
||||
size_x: float,
|
||||
size_y: float,
|
||||
size_z: float,
|
||||
category: str = "material_plate",
|
||||
model: Optional[str] = None,
|
||||
) -> "MaterialPlate":
|
||||
"""工厂方法:创建带 4x4 洞位的料板(仅用于初始 setup,不在反序列化路径调用)"""
|
||||
# 默认洞位间距(与 _unilabos_state 默认值保持一致)
|
||||
hole_spacing_x = 24.0
|
||||
hole_spacing_y = 24.0
|
||||
# 先建洞位,再作为 ordered_items 传入构造函数
|
||||
# (ItemizedResource.__init__ 要求 ordered_items 或 ordering 二选一必须有值)
|
||||
# 创建4x4的洞位
|
||||
# TODO: 这里要改,对应不同形状
|
||||
holes = create_ordered_items_2d(
|
||||
klass=MaterialHole,
|
||||
num_items_x=4,
|
||||
num_items_y=4,
|
||||
dx=(size_x - 4 * hole_spacing_x) / 2,
|
||||
dy=(size_y - 4 * hole_spacing_y) / 2,
|
||||
dx=(size_x - 4 * self._unilabos_state["hole_spacing_x"]) / 2, # 居中
|
||||
dy=(size_y - 4 * self._unilabos_state["hole_spacing_y"]) / 2, # 居中
|
||||
dz=size_z,
|
||||
item_dx=hole_spacing_x,
|
||||
item_dy=hole_spacing_y,
|
||||
size_x=16,
|
||||
size_y=16,
|
||||
size_z=16,
|
||||
)
|
||||
return cls(
|
||||
name=name, size_x=size_x, size_y=size_y, size_z=size_z,
|
||||
ordered_items=holes, category=category, model=model,
|
||||
item_dx=self._unilabos_state["hole_spacing_x"],
|
||||
item_dy=self._unilabos_state["hole_spacing_y"],
|
||||
size_x = 16,
|
||||
size_y = 16,
|
||||
size_z = 16,
|
||||
)
|
||||
if fill:
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
ordered_items=holes,
|
||||
category=category,
|
||||
model=model,
|
||||
)
|
||||
else:
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
ordered_items=ordered_items,
|
||||
ordering=ordering,
|
||||
category=category,
|
||||
model=model,
|
||||
)
|
||||
|
||||
def update_locations(self):
|
||||
# TODO:调多次相加
|
||||
@@ -536,19 +534,30 @@ class WasteTipBox(Trash):
|
||||
return data
|
||||
|
||||
|
||||
class YihuaCoinCellDeck(Deck):
|
||||
"""依华纽扣电池组装工作站台面类"""
|
||||
class CoincellDeck(Deck):
|
||||
"""纽扣电池组装工作站台面类"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "coin_cell_deck",
|
||||
size_x: float = 1450.0,
|
||||
size_y: float = 1450.0,
|
||||
size_z: float = 100.0,
|
||||
size_x: float = 1450.0, # 1m
|
||||
size_y: float = 1450.0, # 1m
|
||||
size_z: float = 100.0, # 0.9m
|
||||
origin: Coordinate = Coordinate(-2200, 0, 0),
|
||||
category: str = "coin_cell_deck",
|
||||
setup: bool = False,
|
||||
setup: bool = False, # 是否自动执行 setup
|
||||
):
|
||||
"""初始化纽扣电池组装工作站台面
|
||||
|
||||
Args:
|
||||
name: 台面名称
|
||||
size_x: 长度 (mm) - 1m
|
||||
size_y: 宽度 (mm) - 1m
|
||||
size_z: 高度 (mm) - 0.9m
|
||||
origin: 原点坐标
|
||||
category: 类别
|
||||
setup: 是否自动执行 setup 配置标准布局
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=1450.0,
|
||||
@@ -582,11 +591,14 @@ class YihuaCoinCellDeck(Deck):
|
||||
# ====================================== 物料板 ============================================
|
||||
# 创建物料板(料盘carrier)- 4x4布局
|
||||
# 负极料盘
|
||||
fujiliaopan = MaterialPlate.create_with_holes(name="负极料盘", size_x=120, size_y=100, size_z=10.0)
|
||||
fujiliaopan = MaterialPlate(name="负极料盘", size_x=120, size_y=100, size_z=10.0, fill=True)
|
||||
self.assign_child_resource(fujiliaopan, Coordinate(x=708.0, y=794.0, z=0))
|
||||
# for i in range(16):
|
||||
# fujipian = ElectrodeSheet(name=f"{fujiliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
|
||||
# fujiliaopan.children[i].assign_child_resource(fujipian, location=None)
|
||||
|
||||
# 隔膜料盘
|
||||
gemoliaopan = MaterialPlate.create_with_holes(name="隔膜料盘", size_x=120, size_y=100, size_z=10.0)
|
||||
gemoliaopan = MaterialPlate(name="隔膜料盘", size_x=120, size_y=100, size_z=10.0, fill=True)
|
||||
self.assign_child_resource(gemoliaopan, Coordinate(x=718.0, y=918.0, z=0))
|
||||
# for i in range(16):
|
||||
# gemopian = ElectrodeSheet(name=f"{gemoliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
|
||||
@@ -621,27 +633,11 @@ class YihuaCoinCellDeck(Deck):
|
||||
waste_tip_box = WasteTipBox(name="waste_tip_box")
|
||||
self.assign_child_resource(waste_tip_box, Coordinate(x=778.0, y=622.0, z=0))
|
||||
|
||||
# 分液瓶板接驳区 - 接收来自 BioyondElectrolyte 侧的完整 Vial Carrier 板
|
||||
# 命名 electrolyte_buffer 与 bioyond_cell_workstation.py 中 sites=["electrolyte_buffer"] 对应
|
||||
electrolyte_buffer = ResourceStack(
|
||||
name="electrolyte_buffer",
|
||||
direction="z",
|
||||
resources=[],
|
||||
)
|
||||
self.assign_child_resource(electrolyte_buffer, Coordinate(x=1050.0, y=700.0, z=0))
|
||||
|
||||
|
||||
def yihua_coin_cell_deck(name: str = "coin_cell_deck") -> YihuaCoinCellDeck:
|
||||
deck = YihuaCoinCellDeck(name=name)
|
||||
deck.setup()
|
||||
return deck
|
||||
|
||||
|
||||
# 向后兼容别名,日后废弃
|
||||
CoincellDeck = YihuaCoinCellDeck
|
||||
|
||||
def YH_Deck(name: str = "") -> YihuaCoinCellDeck:
|
||||
return yihua_coin_cell_deck(name=name or "coin_cell_deck")
|
||||
def YH_Deck(name=""):
|
||||
cd = CoincellDeck(name=name)
|
||||
cd.setup()
|
||||
return cd
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -6,7 +6,7 @@ import threading
|
||||
import time
|
||||
import types
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, Optional
|
||||
from functools import wraps
|
||||
from pylabrobot.resources import Deck, Resource as PLRResource
|
||||
from unilabos_msgs.msg import Resource
|
||||
@@ -17,7 +17,7 @@ from unilabos.device_comms.modbus_plc.modbus import DeviceType, Base as ModbusNo
|
||||
from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import *
|
||||
from unilabos.ros.nodes.base_device_node import ROS2DeviceNode, BaseROS2DeviceNode
|
||||
from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
|
||||
from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import YihuaCoinCellDeck, yihua_coin_cell_deck
|
||||
from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import CoincellDeck
|
||||
from unilabos.resources.graphio import convert_resources_to_type
|
||||
from unilabos.utils.log import logger
|
||||
import struct
|
||||
@@ -161,9 +161,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
logger.info("没有传入依华deck,检查启动json文件")
|
||||
super().__init__(deck=deck, *args, **kwargs,)
|
||||
self.debug_mode = debug_mode
|
||||
self._modbus_address = address
|
||||
self._modbus_port = port
|
||||
|
||||
|
||||
""" 连接初始化 """
|
||||
modbus_client = TCPClient(addr=address, port=port)
|
||||
logger.debug(f"创建 Modbus 客户端: {modbus_client}")
|
||||
@@ -180,11 +178,9 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
raise ValueError('modbus tcp connection failed')
|
||||
self.nodes = BaseClient.load_csv(os.path.join(os.path.dirname(__file__), 'coin_cell_assembly_b.csv'))
|
||||
self.client = modbus_client.register_node_list(self.nodes)
|
||||
self._modbus_client_raw = modbus_client
|
||||
else:
|
||||
print("测试模式,跳过连接")
|
||||
self.nodes, self.client = None, None
|
||||
self._modbus_client_raw = None
|
||||
|
||||
""" 工站的配置 """
|
||||
|
||||
@@ -195,40 +191,9 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
self.csv_export_file = None
|
||||
self.coin_num_N = 0 #已组装电池数量
|
||||
|
||||
def _ensure_modbus_connected(self) -> None:
|
||||
"""检查 Modbus TCP 连接是否存活,若已断开则自动重连(防止长时间空闲后连接超时)"""
|
||||
if self.debug_mode or self._modbus_client_raw is None:
|
||||
return
|
||||
raw_client = self._modbus_client_raw.client
|
||||
if raw_client.is_socket_open():
|
||||
return
|
||||
logger.warning("[Modbus] 检测到连接已断开,尝试重连...")
|
||||
try:
|
||||
raw_client.close()
|
||||
except Exception:
|
||||
pass
|
||||
count = 10
|
||||
while count > 0:
|
||||
count -= 1
|
||||
try:
|
||||
raw_client.connect()
|
||||
except Exception:
|
||||
pass
|
||||
if raw_client.is_socket_open():
|
||||
break
|
||||
time.sleep(2)
|
||||
if not raw_client.is_socket_open():
|
||||
raise RuntimeError(f"Modbus TCP 重连失败({self._modbus_address}:{self._modbus_port}),请检查设备连接")
|
||||
logger.info("[Modbus] 重连成功")
|
||||
|
||||
def post_init(self, ros_node: ROS2WorkstationNode):
|
||||
self._ros_node = ros_node
|
||||
|
||||
# Deck 为空时(反序列化未恢复子节点),主动调用 setup() 初始化子物料
|
||||
if self.deck and not self.deck.children and hasattr(self.deck, "setup") and callable(self.deck.setup):
|
||||
logger.info("YihuaCoinCellDeck 无子节点,调用 setup() 初始化")
|
||||
self.deck.setup()
|
||||
|
||||
#self.deck = create_a_coin_cell_deck()
|
||||
ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
|
||||
"resources": [self.deck]
|
||||
})
|
||||
@@ -658,28 +623,12 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
return vol
|
||||
|
||||
@property
|
||||
def data_coin_type(self) -> int:
|
||||
"""电池类型 - 7种或8种组装物料 (INT16)"""
|
||||
if self.debug_mode:
|
||||
return 7
|
||||
coin_type, read_err = self.client.use_node('REG_DATA_COIN_TYPE').read(1)
|
||||
return coin_type
|
||||
|
||||
@property
|
||||
def data_current_assembling_count(self) -> int:
|
||||
"""当前进行组装的电池数量 - Current assembling battery count (INT16)"""
|
||||
def data_coin_num(self) -> int:
|
||||
"""当前电池数量 (INT16)"""
|
||||
if self.debug_mode:
|
||||
return 0
|
||||
count, read_err = self.client.use_node('REG_DATA_CURRENT_ASSEMBLING_COUNT').read(1)
|
||||
return count
|
||||
|
||||
@property
|
||||
def data_current_completed_count(self) -> int:
|
||||
"""当前完成组装的电池数量 - Current completed battery count (INT16)"""
|
||||
if self.debug_mode:
|
||||
return 0
|
||||
count, read_err = self.client.use_node('REG_DATA_CURRENT_COMPLETED_COUNT').read(1)
|
||||
return count
|
||||
num, read_err = self.client.use_node('REG_DATA_COIN_NUM').read(1)
|
||||
return num
|
||||
|
||||
@property
|
||||
def data_coin_cell_code(self) -> str:
|
||||
@@ -777,116 +726,6 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
return 0.0
|
||||
return _decode_float32_correct(result.registers)
|
||||
|
||||
@property
|
||||
def data_10mm_positive_plate_remaining(self) -> float:
|
||||
"""10mm正极片剩余物料数量 (FLOAT32)"""
|
||||
if self.debug_mode:
|
||||
return 0.0
|
||||
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_10MM_POSITIVE_PLATE_REMAINING_COUNT').address, count=2)
|
||||
if result.isError():
|
||||
logger.error("读取10mm正极片余量失败")
|
||||
return 0.0
|
||||
return _decode_float32_correct(result.registers)
|
||||
|
||||
@property
|
||||
def data_12mm_positive_plate_remaining(self) -> float:
|
||||
"""12mm正极片剩余物料数量 (FLOAT32)"""
|
||||
if self.debug_mode:
|
||||
return 0.0
|
||||
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_12MM_POSITIVE_PLATE_REMAINING_COUNT').address, count=2)
|
||||
if result.isError():
|
||||
logger.error("读取12mm正极片余量失败")
|
||||
return 0.0
|
||||
return _decode_float32_correct(result.registers)
|
||||
|
||||
@property
|
||||
def data_16mm_positive_plate_remaining(self) -> float:
|
||||
"""16mm正极片剩余物料数量 (FLOAT32)"""
|
||||
if self.debug_mode:
|
||||
return 0.0
|
||||
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_16MM_POSITIVE_PLATE_REMAINING_COUNT').address, count=2)
|
||||
if result.isError():
|
||||
logger.error("读取16mm正极片余量失败")
|
||||
return 0.0
|
||||
return _decode_float32_correct(result.registers)
|
||||
|
||||
@property
|
||||
def data_aluminum_foil_remaining(self) -> float:
|
||||
"""铝箔剩余物料数量 (FLOAT32)"""
|
||||
if self.debug_mode:
|
||||
return 0.0
|
||||
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_ALUMINUM_FOIL_REMAINING_COUNT').address, count=2)
|
||||
if result.isError():
|
||||
logger.error("读取铝箔余量失败")
|
||||
return 0.0
|
||||
return _decode_float32_correct(result.registers)
|
||||
|
||||
@property
|
||||
def data_positive_shell_remaining(self) -> float:
|
||||
"""正极壳剩余物料数量 (FLOAT32)"""
|
||||
if self.debug_mode:
|
||||
return 0.0
|
||||
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_POSITIVE_SHELL_REMAINING_COUNT').address, count=2)
|
||||
if result.isError():
|
||||
logger.error("读取正极壳余量失败")
|
||||
return 0.0
|
||||
return _decode_float32_correct(result.registers)
|
||||
|
||||
@property
|
||||
def data_flat_washer_remaining(self) -> float:
|
||||
"""平垫剩余物料数量 (FLOAT32)"""
|
||||
if self.debug_mode:
|
||||
return 0.0
|
||||
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_FLAT_WASHER_REMAINING_COUNT').address, count=2)
|
||||
if result.isError():
|
||||
logger.error("读取平垫余量失败")
|
||||
return 0.0
|
||||
return _decode_float32_correct(result.registers)
|
||||
|
||||
@property
|
||||
def data_negative_shell_remaining(self) -> float:
|
||||
"""负极壳剩余物料数量 (FLOAT32)"""
|
||||
if self.debug_mode:
|
||||
return 0.0
|
||||
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_NEGATIVE_SHELL_REMAINING_COUNT').address, count=2)
|
||||
if result.isError():
|
||||
logger.error("读取负极壳余量失败")
|
||||
return 0.0
|
||||
return _decode_float32_correct(result.registers)
|
||||
|
||||
@property
|
||||
def data_spring_washer_remaining(self) -> float:
|
||||
"""弹垫剩余物料数量 (FLOAT32)"""
|
||||
if self.debug_mode:
|
||||
return 0.0
|
||||
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_SPRING_WASHER_REMAINING_COUNT').address, count=2)
|
||||
if result.isError():
|
||||
logger.error("读取弹垫余量失败")
|
||||
return 0.0
|
||||
return _decode_float32_correct(result.registers)
|
||||
|
||||
@property
|
||||
def data_finished_battery_remaining_capacity(self) -> float:
|
||||
"""成品电池剩余可容纳数量 (FLOAT32)"""
|
||||
if self.debug_mode:
|
||||
return 0.0
|
||||
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_FINISHED_BATTERY_REMAINING_CAPACITY').address, count=2)
|
||||
if result.isError():
|
||||
logger.error("读取成品电池余量失败")
|
||||
return 0.0
|
||||
return _decode_float32_correct(result.registers)
|
||||
|
||||
@property
|
||||
def data_finished_battery_ng_remaining_capacity(self) -> float:
|
||||
"""成品电池NG槽剩余可容纳数量 (FLOAT32)"""
|
||||
if self.debug_mode:
|
||||
return 0.0
|
||||
result = self.client.client.read_holding_registers(address=self.client.use_node('REG_DATA_FINISHED_BATTERY_NG_REMAINING_CAPACITY').address, count=2)
|
||||
if result.isError():
|
||||
logger.error("读取成品电池NG槽余量失败")
|
||||
return 0.0
|
||||
return _decode_float32_correct(result.registers)
|
||||
|
||||
# @property
|
||||
# def data_stack_vision_code(self) -> int:
|
||||
# """物料堆叠复检图片编码 (INT16)"""
|
||||
@@ -1086,7 +925,6 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
|
||||
# 步骤0: 前置条件检查
|
||||
logger.info("\n【步骤 0/4】前置条件检查...")
|
||||
self._ensure_modbus_connected()
|
||||
try:
|
||||
# 检查 REG_UNILAB_INTERACT (应该为False,表示使用Unilab交互)
|
||||
unilab_interact_node = self.client.use_node('REG_UNILAB_INTERACT')
|
||||
@@ -1147,42 +985,6 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
raise RuntimeError(error_msg)
|
||||
|
||||
logger.info(" ✓ COIL_GB_L_IGNORE_CMD 检查通过 (值为False,使用左手套箱)")
|
||||
|
||||
# 检查握手寄存器残留(正常初始状态均应为False)
|
||||
# 若上次运行意外断网,这些Unilab侧COIL可能被遗留为True,导致PLC逻辑卡死
|
||||
handshake_checks = [
|
||||
("COIL_UNILAB_SEND_MSG_SUCC_CMD", "Unilab→PLC 配方发送完毕", "上次配方握手未正常复位,PLC可能处于等待配方的卡死状态"),
|
||||
("COIL_UNILAB_REC_MSG_SUCC_CMD", "Unilab→PLC 数据接收完毕", "上次数据接收握手未正常复位"),
|
||||
("UNILAB_SEND_ELECTROLYTE_BOTTLE_NUM", "Unilab→PLC 瓶数发送完毕", "上次瓶数握手未正常复位"),
|
||||
("UNILAB_SEND_FINISHED_CMD", "Unilab→PLC 一组完成确认", "上次完成握手未正常复位"),
|
||||
("COIL_REQUEST_REC_MSG_STATUS", "PLC→Unilab 请求接收配方", "PLC正处于等待配方状态,设备流程已卡死,需重启PLC或手动复位握手"),
|
||||
("COIL_REQUEST_SEND_MSG_STATUS", "PLC→Unilab 请求发送测试数据", "PLC正处于等待发送数据状态,设备流程已卡死"),
|
||||
]
|
||||
for coil_name, coil_desc, stuck_reason in handshake_checks:
|
||||
try:
|
||||
hs_node = self.client.use_node(coil_name)
|
||||
hs_value, hs_err = hs_node.read(1)
|
||||
if hs_err:
|
||||
logger.warning(f" ⚠ 无法读取 {coil_name},跳过此项检查")
|
||||
continue
|
||||
hs_actual = hs_value[0] if isinstance(hs_value, (list, tuple)) else hs_value
|
||||
logger.info(f" {coil_name} 当前值: {hs_actual}")
|
||||
if hs_actual:
|
||||
error_msg = (
|
||||
"❌ 前置握手寄存器检查失败!\n"
|
||||
f" {coil_name} = True (期望值: False)\n"
|
||||
f" 含义: {coil_desc}\n"
|
||||
f" 原因: {stuck_reason}\n"
|
||||
" 建议: 检查上次运行是否意外中断,手动将该寄存器置为False后重试"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
raise RuntimeError(error_msg)
|
||||
logger.info(f" ✓ {coil_name} 检查通过 (值为False)")
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception as hs_e:
|
||||
logger.warning(f" ⚠ 检查 {coil_name} 时发生异常: {hs_e},跳过此项")
|
||||
|
||||
logger.info("✓ 所有前置条件检查通过!")
|
||||
|
||||
except ValueError as e:
|
||||
@@ -1356,8 +1158,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
lvbodian: bool = True,
|
||||
battery_pressure_mode: bool = True,
|
||||
battery_clean_ignore: bool = False,
|
||||
file_path: str = "/Users/sml/work",
|
||||
formulations: List[Dict] = None
|
||||
file_path: str = "/Users/sml/work"
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
发送瓶数+简化组装函数(适用于第二批次及后续批次)
|
||||
@@ -1384,77 +1185,17 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
battery_pressure_mode: 是否启用压力模式
|
||||
battery_clean_ignore: 是否忽略电池清洁
|
||||
file_path: 实验记录保存路径
|
||||
formulations: 配方信息列表(从 create_orders.mass_ratios 获取)
|
||||
包含 orderCode, target_mass_ratio, real_mass_ratio 等
|
||||
用于CSV数据追溯,可选参数
|
||||
|
||||
Returns:
|
||||
dict: 包含组装结果的字典
|
||||
|
||||
注意:
|
||||
注意:
|
||||
- 第一次启动需先调用 func_pack_device_init_auto_start_combined()
|
||||
- 后续批次直接调用此函数即可
|
||||
"""
|
||||
logger.info("=" * 60)
|
||||
logger.info("开始发送瓶数+简化组装流程...")
|
||||
logger.info(f"电解液瓶数: {elec_num}, 每瓶电池数: {elec_use_num}")
|
||||
|
||||
# 存储配方信息到设备状态(供 CSV 写入使用)
|
||||
if formulations:
|
||||
logger.info(f"接收到配方信息: {len(formulations)} 条")
|
||||
# 将配方信息按 orderCode 索引,方便后续查找
|
||||
self._formulations_map = {
|
||||
f["orderCode"]: f for f in formulations
|
||||
} if formulations else {}
|
||||
# ✅ 新增:存储配方列表(按接收顺序),用于索引访问(兜底用)
|
||||
self._formulations_list = formulations
|
||||
# ✅ 新增:按分液瓶条码(vial_bottle_barcodes)反向索引配方
|
||||
# 配液站夹爪取放顺序与扣电站夹取顺序可能不同,所以不能再依赖位置序号,
|
||||
# 必须用扣电站扫码得到的 data_electrolyte_code 去对齐配液站登记的瓶条码。
|
||||
# vial_bottle_barcodes 字段可能形如 "LG100114"(单瓶)或 '["LG100114","LG100115"]'(多瓶)。
|
||||
self._formulations_by_vial_barcode: Dict[str, Dict] = {}
|
||||
for f in formulations:
|
||||
raw_barcodes = f.get("vial_bottle_barcodes", "")
|
||||
if not raw_barcodes:
|
||||
continue
|
||||
barcodes: List[str] = []
|
||||
if isinstance(raw_barcodes, list):
|
||||
barcodes = [str(b).strip() for b in raw_barcodes if b]
|
||||
else:
|
||||
s = str(raw_barcodes).strip()
|
||||
if s.startswith("[") and s.endswith("]"):
|
||||
try:
|
||||
parsed = json.loads(s)
|
||||
if isinstance(parsed, list):
|
||||
barcodes = [str(b).strip() for b in parsed if b]
|
||||
else:
|
||||
barcodes = [str(parsed).strip()]
|
||||
except Exception:
|
||||
barcodes = [s]
|
||||
else:
|
||||
barcodes = [s]
|
||||
for bc in barcodes:
|
||||
if bc and bc not in self._formulations_by_vial_barcode:
|
||||
self._formulations_by_vial_barcode[bc] = f
|
||||
logger.info(
|
||||
f"已建立分液瓶条码 → 配方索引: {len(self._formulations_by_vial_barcode)} 条 "
|
||||
f"(条码: {list(self._formulations_by_vial_barcode.keys())})"
|
||||
)
|
||||
else:
|
||||
logger.warning("未接收到配方信息,CSV将不包含配方字段")
|
||||
self._formulations_map = {}
|
||||
self._formulations_list = []
|
||||
self._formulations_by_vial_barcode = {}
|
||||
|
||||
# ✅ 新增:存储每瓶电池数,用于计算当前使用的瓶号
|
||||
# ⚠️ 确保转换为整数(前端可能传递字符串)
|
||||
self._elec_use_num = int(elec_use_num) if elec_use_num else 0
|
||||
logger.info(f"已存储参数: 每瓶电池数={self._elec_use_num}, 配方数={len(self._formulations_list)}")
|
||||
|
||||
# ✅ 新增:软件层电池计数器(防止硬件计数器不准确)
|
||||
self._software_battery_counter = 0 # 从0开始,每写入一次CSV递增
|
||||
logger.info("软件层电池计数器已初始化")
|
||||
|
||||
logger.info("=" * 60)
|
||||
|
||||
# 步骤1: 发送电解液瓶数(触发物料搬运)
|
||||
@@ -1590,8 +1331,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
data_assembly_time = self.data_assembly_time
|
||||
data_assembly_pressure = self.data_assembly_pressure
|
||||
data_electrolyte_volume = self.data_electrolyte_volume
|
||||
data_coin_type = self.data_coin_type # 电池类型(7或8种物料)
|
||||
data_battery_number = self.data_current_assembling_count # ✅ 真正的电池编号
|
||||
data_coin_num = self.data_coin_num
|
||||
|
||||
# 处理电解液二维码 - 确保是字符串类型
|
||||
try:
|
||||
@@ -1621,32 +1361,28 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
logger.debug(f"data_assembly_time: {data_assembly_time}")
|
||||
logger.debug(f"data_assembly_pressure: {data_assembly_pressure}")
|
||||
logger.debug(f"data_electrolyte_volume: {data_electrolyte_volume}")
|
||||
logger.debug(f"data_coin_type: {data_coin_type}") # 电池类型
|
||||
logger.debug(f"data_battery_number: {data_battery_number}") # ✅ 电池编号
|
||||
logger.debug(f"data_coin_num: {data_coin_num}")
|
||||
logger.debug(f"data_electrolyte_code: {data_electrolyte_code}")
|
||||
logger.debug(f"data_coin_cell_code: {data_coin_cell_code}")
|
||||
#接收完信息后,读取完毕标志位置True
|
||||
finished_battery_magazine = self.deck.get_resource("成品弹夹")
|
||||
|
||||
# 计算电池应该放在哪个洞,以及洞内的堆叠位置
|
||||
# 成品弹夹有6个洞,每个洞可堆叠20颗电池
|
||||
# 前5个洞(索引0-4)放正常电池,第6个洞(索引5)放NG电池
|
||||
BATTERIES_PER_HOLE = 20
|
||||
MAX_NORMAL_BATTERIES = 100 # 5个洞 × 20颗/洞
|
||||
|
||||
hole_index = self.coin_num_N // BATTERIES_PER_HOLE # 第几个洞(0-4为正常电池)
|
||||
in_hole_position = self.coin_num_N % BATTERIES_PER_HOLE # 洞内的堆叠序号
|
||||
|
||||
if hole_index >= 5:
|
||||
logger.error(f"电池数量超出正常容量范围: {self.coin_num_N + 1} > {MAX_NORMAL_BATTERIES}")
|
||||
raise ValueError(f"成品弹夹正常洞位已满(最多{MAX_NORMAL_BATTERIES}颗),当前尝试放置第{self.coin_num_N + 1}颗")
|
||||
|
||||
target_hole = finished_battery_magazine.children[hole_index] # 获取目标洞
|
||||
liaopan3 = self.deck.get_resource("成品弹夹")
|
||||
|
||||
# 生成唯一的电池名称(使用时间戳确保唯一性)
|
||||
timestamp_suffix = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
||||
battery_name = f"battery_{self.coin_num_N}_{timestamp_suffix}"
|
||||
|
||||
# 检查目标位置是否已有资源,如果有则先卸载
|
||||
target_slot = liaopan3.children[self.coin_num_N]
|
||||
if target_slot.children:
|
||||
logger.warning(f"位置 {self.coin_num_N} 已有资源,将先卸载旧资源")
|
||||
try:
|
||||
# 卸载所有现有子资源
|
||||
for child in list(target_slot.children):
|
||||
target_slot.unassign_child_resource(child)
|
||||
logger.info(f"已卸载旧资源: {child.name}")
|
||||
except Exception as e:
|
||||
logger.error(f"卸载旧资源时出错: {e}")
|
||||
|
||||
# 创建新的电池资源
|
||||
battery = ElectrodeSheet(name=battery_name, size_x=14, size_y=14, size_z=2)
|
||||
battery._unilabos_state = {
|
||||
@@ -1657,12 +1393,13 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
"electrolyte_volume": data_electrolyte_volume
|
||||
}
|
||||
|
||||
# 将电池堆叠到目标洞中
|
||||
# 分配新资源到目标位置
|
||||
try:
|
||||
target_hole.assign_child_resource(battery, location=None)
|
||||
logger.info(f"成功放置电池 {battery_name} 到弹夹洞{hole_index}的第{in_hole_position + 1}层 (总计第{self.coin_num_N + 1}颗)")
|
||||
target_slot.assign_child_resource(battery, location=None)
|
||||
logger.info(f"成功分配电池 {battery_name} 到位置 {self.coin_num_N}")
|
||||
except Exception as e:
|
||||
logger.error(f"放置电池资源失败: {e}")
|
||||
logger.error(f"分配电池资源失败: {e}")
|
||||
# 如果分配失败,尝试使用更简单的方法
|
||||
raise
|
||||
|
||||
#print(jipian2.parent)
|
||||
@@ -1683,7 +1420,6 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
time_date = datetime.now().strftime("%Y%m%d")
|
||||
#秒级时间戳用于标记每一行电池数据
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
self._last_assembly_timestamp = timestamp
|
||||
#生成输出文件的变量
|
||||
self.csv_export_file = os.path.join(file_path, f"date_{time_date}.csv")
|
||||
#将数据存入csv文件
|
||||
@@ -1694,107 +1430,17 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
writer.writerow([
|
||||
'Time', 'open_circuit_voltage', 'pole_weight',
|
||||
'assembly_time', 'assembly_pressure', 'electrolyte_volume',
|
||||
'data_coin_type', 'electrolyte_code', 'coin_cell_code',
|
||||
'orderName', 'prep_bottle_barcode', 'vial_bottle_barcodes',
|
||||
'target_mass_ratio', 'real_mass_ratio'
|
||||
'coin_num', 'electrolyte_code', 'coin_cell_code'
|
||||
])
|
||||
#立刻写入磁盘
|
||||
csvfile.flush()
|
||||
#开始追加电池信息
|
||||
with open(self.csv_export_file, 'a', newline='', encoding='utf-8') as csvfile:
|
||||
writer = csv.writer(csvfile)
|
||||
|
||||
# ========== 提取配方信息 ==========
|
||||
formulation_order_name = ""
|
||||
prep_bottle_barcode = ""
|
||||
vial_bottle_barcodes = ""
|
||||
target_ratio_str = ""
|
||||
real_ratio_str = ""
|
||||
|
||||
# 从 self._formulations_list 获取配方信息
|
||||
if hasattr(self, '_formulations_list') and self._formulations_list:
|
||||
# ============================================================
|
||||
# ✅ 主方案:用扣电站扫码得到的电解液瓶条码 (data_electrolyte_code)
|
||||
# 反查配液站登记的 vial_bottle_barcodes,避免依赖夹爪取放顺序。
|
||||
# 配液站和扣电站的瓶子顺序往往不一致(不同自动化设备的取放策略不同),
|
||||
# 按位置序号匹配会错位;但每个瓶子的条码是唯一的,按条码匹配最可靠。
|
||||
# ============================================================
|
||||
formulation = None
|
||||
match_method = ""
|
||||
|
||||
barcode_map = getattr(self, "_formulations_by_vial_barcode", {}) or {}
|
||||
scan_code = (data_electrolyte_code or "").strip()
|
||||
if scan_code and scan_code != "N/A" and barcode_map:
|
||||
formulation = barcode_map.get(scan_code)
|
||||
if formulation is not None:
|
||||
match_method = f"按条码({scan_code})精确匹配"
|
||||
else:
|
||||
logger.warning(
|
||||
f"[CSV写入] 电池 {data_battery_number}: 扫码条码 {scan_code} "
|
||||
f"在配方索引中找不到 (已登记条码: {list(barcode_map.keys())})"
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# 🔁 降级方案:扫码失败 / 条码缺失时按瓶号位置兜底
|
||||
# 保留原有"每瓶电池数"或"二维码尾号"的位置推断逻辑,
|
||||
# 确保在异常路径下仍能落盘(位置推断的结果可能不准,仅供回溯)。
|
||||
# ============================================================
|
||||
if formulation is None:
|
||||
if hasattr(self, '_elec_use_num') and self._elec_use_num:
|
||||
elec_use_num_int = int(self._elec_use_num) if self._elec_use_num else 1
|
||||
if elec_use_num_int > 0:
|
||||
current_bottle_index = (data_battery_number - 1) // elec_use_num_int
|
||||
else:
|
||||
current_bottle_index = 0
|
||||
|
||||
logger.debug(
|
||||
f"[CSV写入] 电池 {data_battery_number}: 降级按瓶号索引={current_bottle_index} "
|
||||
f"(每瓶{self._elec_use_num}颗电池)"
|
||||
)
|
||||
else:
|
||||
current_bottle_index = (
|
||||
int(data_electrolyte_code.split('-')[-1])
|
||||
if '-' in str(data_electrolyte_code)
|
||||
else 0
|
||||
)
|
||||
logger.debug(
|
||||
f"[CSV写入] 电池 {data_battery_number}: 降级按二维码尾号瓶号索引={current_bottle_index}"
|
||||
)
|
||||
|
||||
if 0 <= current_bottle_index < len(self._formulations_list):
|
||||
formulation = self._formulations_list[current_bottle_index]
|
||||
match_method = f"按位置兜底匹配[{current_bottle_index}]"
|
||||
else:
|
||||
logger.warning(
|
||||
f"[CSV写入] 电池 {data_battery_number}: 瓶号索引 {current_bottle_index} "
|
||||
f"超出配方列表范围 (共{len(self._formulations_list)}个配方)"
|
||||
)
|
||||
|
||||
if formulation is not None:
|
||||
formulation_order_name = formulation.get("orderName", "")
|
||||
prep_bottle_barcode = formulation.get("prep_bottle_barcode", "")
|
||||
vial_bottle_barcodes = formulation.get("vial_bottle_barcodes", "")
|
||||
|
||||
real_ratio = formulation.get("real_mass_ratio", {})
|
||||
target_ratio = formulation.get("target_mass_ratio", {})
|
||||
|
||||
target_ratio_str = json.dumps(target_ratio, ensure_ascii=False) if target_ratio else ""
|
||||
real_ratio_str = json.dumps(real_ratio, ensure_ascii=False) if real_ratio else ""
|
||||
|
||||
logger.info(
|
||||
f"[CSV写入] 电池 {data_battery_number} ({match_method}): "
|
||||
f"orderName={formulation_order_name}, 配液瓶={prep_bottle_barcode}, "
|
||||
f"分液瓶={vial_bottle_barcodes}"
|
||||
)
|
||||
else:
|
||||
logger.debug(f"[CSV写入] 电池 {data_battery_number}: 未找到配方信息数据")
|
||||
|
||||
writer.writerow([
|
||||
timestamp, data_open_circuit_voltage, data_pole_weight,
|
||||
data_assembly_time, data_assembly_pressure, data_electrolyte_volume,
|
||||
data_coin_type, data_electrolyte_code, data_coin_cell_code,
|
||||
formulation_order_name, prep_bottle_barcode, vial_bottle_barcodes,
|
||||
target_ratio_str, real_ratio_str
|
||||
data_coin_num, data_electrolyte_code, data_coin_cell_code
|
||||
])
|
||||
#立刻写入磁盘
|
||||
csvfile.flush()
|
||||
@@ -1939,18 +1585,17 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
pole_weight = 0.0
|
||||
|
||||
battery_info = {
|
||||
"Time": getattr(self, "_last_assembly_timestamp", datetime.now().strftime("%Y%m%d_%H%M%S")),
|
||||
"battery_index": coin_num_N + 1,
|
||||
"battery_barcode": battery_qr_code,
|
||||
"electrolyte_barcode": electrolyte_qr_code,
|
||||
"open_circuit_voltage": open_circuit_voltage,
|
||||
"pole_weight": pole_weight,
|
||||
"assembly_time": self.data_assembly_time,
|
||||
"assembly_pressure": self.data_assembly_pressure,
|
||||
"electrolyte_volume": self.data_electrolyte_volume,
|
||||
"data_coin_type": getattr(self, "data_coin_type", 0),
|
||||
"electrolyte_code": electrolyte_qr_code,
|
||||
"coin_cell_code": battery_qr_code,
|
||||
"electrolyte_volume": self.data_electrolyte_volume
|
||||
}
|
||||
battery_data_list.append(battery_info)
|
||||
print(f"已收集第 {coin_num_N + 1} 个电池数据: 电池码={battery_info['coin_cell_code']}, 电解液码={battery_info['electrolyte_code']}")
|
||||
print(f"已收集第 {coin_num_N + 1} 个电池数据: 电池码={battery_info['battery_barcode']}, 电解液码={battery_info['electrolyte_barcode']}")
|
||||
|
||||
time.sleep(1)
|
||||
# TODO:读完再将电池数加一还是进入循环就将电池数加一需要考虑
|
||||
@@ -1979,7 +1624,6 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
"success": True,
|
||||
"total_batteries": len(battery_data_list),
|
||||
"batteries": battery_data_list,
|
||||
"assembly_data": battery_data_list,
|
||||
"summary": {
|
||||
"electrolyte_bottles_used": elec_num,
|
||||
"batteries_per_bottle": elec_use_num,
|
||||
@@ -2023,7 +1667,8 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
file_path: str = "/Users/sml/work"
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
|
||||
简化版电池组装函数,整合了原 qiming_coin_cell_code 的参数设置和双滴模式
|
||||
|
||||
此函数是 func_allpack_cmd 的增强版本,自动处理以下配置:
|
||||
- 负极片和隔膜的盘数及矩阵点位
|
||||
- 枪头盒矩阵点位
|
||||
@@ -2194,18 +1839,17 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
pole_weight = 0.0
|
||||
|
||||
battery_info = {
|
||||
"Time": getattr(self, "_last_assembly_timestamp", datetime.now().strftime("%Y%m%d_%H%M%S")),
|
||||
"battery_index": coin_num_N + 1,
|
||||
"battery_barcode": battery_qr_code,
|
||||
"electrolyte_barcode": electrolyte_qr_code,
|
||||
"open_circuit_voltage": open_circuit_voltage,
|
||||
"pole_weight": pole_weight,
|
||||
"assembly_time": self.data_assembly_time,
|
||||
"assembly_pressure": self.data_assembly_pressure,
|
||||
"electrolyte_volume": self.data_electrolyte_volume,
|
||||
"data_coin_type": getattr(self, "data_coin_type", 0),
|
||||
"electrolyte_code": electrolyte_qr_code,
|
||||
"coin_cell_code": battery_qr_code,
|
||||
"electrolyte_volume": self.data_electrolyte_volume
|
||||
}
|
||||
battery_data_list.append(battery_info)
|
||||
print(f"已收集第 {coin_num_N + 1} 个电池数据: 电池码={battery_info['coin_cell_code']}, 电解液码={battery_info['electrolyte_code']}")
|
||||
print(f"已收集第 {coin_num_N + 1} 个电池数据: 电池码={battery_info['battery_barcode']}, 电解液码={battery_info['electrolyte_barcode']}")
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
@@ -2232,7 +1876,6 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
"success": True,
|
||||
"total_batteries": len(battery_data_list),
|
||||
"batteries": battery_data_list,
|
||||
"assembly_data": battery_data_list,
|
||||
"summary": {
|
||||
"electrolyte_bottles_used": elec_num,
|
||||
"batteries_per_bottle": elec_use_num,
|
||||
@@ -2279,7 +1922,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
|
||||
def fun_wuliao_test(self) -> bool:
|
||||
#找到data_init中构建的2个物料盘
|
||||
test_battery_plate = self.deck.get_resource("\u7535\u6c60\u6599\u76d8")
|
||||
liaopan3 = self.deck.get_resource("\u7535\u6c60\u6599\u76d8")
|
||||
for i in range(16):
|
||||
battery = ElectrodeSheet(name=f"battery_{i}", size_x=16, size_y=16, size_z=2)
|
||||
battery._unilabos_state = {
|
||||
@@ -2289,7 +1932,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
"electrolyte_volume": 20.0,
|
||||
"electrolyte_name": f"DP{i}"
|
||||
}
|
||||
test_battery_plate.children[i].assign_child_resource(battery, location=None)
|
||||
liaopan3.children[i].assign_child_resource(battery, location=None)
|
||||
|
||||
ROS2DeviceNode.run_async_func(self._ros_node.update_resource, True, **{
|
||||
"resources": [self.deck]
|
||||
@@ -2332,7 +1975,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
data_assembly_time = self.data_assembly_time
|
||||
data_assembly_pressure = self.data_assembly_pressure
|
||||
data_electrolyte_volume = self.data_electrolyte_volume
|
||||
data_coin_type = self.data_coin_type # 电池类型(7或8种物料)
|
||||
data_coin_num = self.data_coin_num
|
||||
data_electrolyte_code = self.data_electrolyte_code
|
||||
data_coin_cell_code = self.data_coin_cell_code
|
||||
# 电解液瓶位置
|
||||
@@ -2446,7 +2089,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
writer.writerow([
|
||||
timestamp, data_open_circuit_voltage, data_pole_weight,
|
||||
data_assembly_time, data_assembly_pressure, data_electrolyte_volume,
|
||||
data_coin_type, data_electrolyte_code, data_coin_cell_code # ✅ 已修正
|
||||
data_coin_num, data_electrolyte_code, data_coin_cell_code
|
||||
])
|
||||
#立刻写入磁盘
|
||||
csvfile.flush()
|
||||
@@ -2497,7 +2140,7 @@ class CoinCellAssemblyWorkstation(WorkstationBase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 简单测试
|
||||
workstation = CoinCellAssemblyWorkstation(deck=yihua_coin_cell_deck(name="coin_cell_deck"))
|
||||
workstation = CoinCellAssemblyWorkstation(deck=CoincellDeck(setup=True, name="coin_cell_deck"))
|
||||
# workstation.qiming_coin_cell_code(fujipian_panshu=1, fujipian_juzhendianwei=2, gemopanshu=3, gemo_juzhendianwei=4, lvbodian=False, battery_pressure_mode=False, battery_pressure=4200, battery_clean_ignore=False)
|
||||
# print(f"工作站创建成功: {workstation.deck.name}")
|
||||
# print(f"料盘数量: {len(workstation.deck.children)}")
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
Name,DataType,InitValue,Comment,Attribute,DeviceType,Address,
|
||||
COIL_SYS_START_CMD,BOOL,,,,coil,8010,
|
||||
COIL_SYS_STOP_CMD,BOOL,,,,coil,8020,
|
||||
COIL_SYS_RESET_CMD,BOOL,,,,coil,8030,
|
||||
COIL_SYS_HAND_CMD,BOOL,,,,coil,8040,
|
||||
COIL_SYS_AUTO_CMD,BOOL,,,,coil,8050,
|
||||
COIL_SYS_INIT_CMD,BOOL,,,,coil,8060,
|
||||
COIL_UNILAB_SEND_MSG_SUCC_CMD,BOOL,,,,coil,8700,
|
||||
COIL_UNILAB_REC_MSG_SUCC_CMD,BOOL,,,,coil,8710,unilab_rec_msg_succ_cmd
|
||||
COIL_SYS_START_STATUS,BOOL,,,,coil,8210,
|
||||
COIL_SYS_STOP_STATUS,BOOL,,,,coil,8220,
|
||||
COIL_SYS_RESET_STATUS,BOOL,,,,coil,8230,
|
||||
COIL_SYS_HAND_STATUS,BOOL,,,,coil,8240,
|
||||
COIL_SYS_AUTO_STATUS,BOOL,,,,coil,8250,
|
||||
COIL_SYS_INIT_STATUS,BOOL,,,,coil,8260,
|
||||
COIL_REQUEST_REC_MSG_STATUS,BOOL,,,,coil,8500,
|
||||
COIL_REQUEST_SEND_MSG_STATUS,BOOL,,,,coil,8510,request_send_msg_status
|
||||
REG_MSG_ELECTROLYTE_USE_NUM,INT16,,,,hold_register,11000,
|
||||
REG_MSG_ELECTROLYTE_NUM,INT16,,,,hold_register,11002,unilab_send_msg_electrolyte_num
|
||||
REG_MSG_ELECTROLYTE_VOLUME,INT16,,,,hold_register,11004,unilab_send_msg_electrolyte_vol
|
||||
REG_MSG_ASSEMBLY_TYPE,INT16,,,,hold_register,11006,unilab_send_msg_assembly_type
|
||||
REG_MSG_ASSEMBLY_PRESSURE,INT16,,,,hold_register,11008,unilab_send_msg_assembly_pressure
|
||||
REG_DATA_ASSEMBLY_COIN_CELL_NUM,INT16,,,,hold_register,10000,data_assembly_coin_cell_num
|
||||
REG_DATA_OPEN_CIRCUIT_VOLTAGE,FLOAT32,,,,hold_register,10002,data_open_circuit_voltage
|
||||
REG_DATA_AXIS_X_POS,FLOAT32,,,,hold_register,10004,
|
||||
REG_DATA_AXIS_Y_POS,FLOAT32,,,,hold_register,10006,
|
||||
REG_DATA_AXIS_Z_POS,FLOAT32,,,,hold_register,10008,
|
||||
REG_DATA_POLE_WEIGHT,FLOAT32,,,,hold_register,10010,data_pole_weight
|
||||
REG_DATA_ASSEMBLY_PER_TIME,FLOAT32,,,,hold_register,10012,data_assembly_time
|
||||
REG_DATA_ASSEMBLY_PRESSURE,INT16,,,,hold_register,10014,data_assembly_pressure
|
||||
REG_DATA_ELECTROLYTE_VOLUME,INT16,,,,hold_register,10016,data_electrolyte_volume
|
||||
REG_DATA_COIN_NUM,INT16,,,,hold_register,10018,data_coin_num
|
||||
REG_DATA_ELECTROLYTE_CODE,STRING,,,,hold_register,10020,data_electrolyte_code()
|
||||
REG_DATA_COIN_CELL_CODE,STRING,,,,hold_register,10030,data_coin_cell_code()
|
||||
REG_DATA_STACK_VISON_CODE,STRING,,,,hold_register,12004,data_stack_vision_code()
|
||||
REG_DATA_GLOVE_BOX_PRESSURE,FLOAT32,,,,hold_register,10050,data_glove_box_pressure
|
||||
REG_DATA_GLOVE_BOX_WATER_CONTENT,FLOAT32,,,,hold_register,10052,data_glove_box_water_content
|
||||
REG_DATA_GLOVE_BOX_O2_CONTENT,FLOAT32,,,,hold_register,10054,data_glove_box_o2_content
|
||||
UNILAB_SEND_ELECTROLYTE_BOTTLE_NUM,BOOL,,,,coil,8720,
|
||||
UNILAB_RECE_ELECTROLYTE_BOTTLE_NUM,BOOL,,,,coil,8520,
|
||||
REG_MSG_ELECTROLYTE_NUM_USED,INT16,,,,hold_register,496,
|
||||
REG_DATA_ELECTROLYTE_USE_NUM,INT16,,,,hold_register,10000,
|
||||
UNILAB_SEND_FINISHED_CMD,BOOL,,,,coil,8730,
|
||||
UNILAB_RECE_FINISHED_CMD,BOOL,,,,coil,8530,
|
||||
REG_DATA_ASSEMBLY_TYPE,INT16,,,,hold_register,10018,ASSEMBLY_TYPE7or8
|
||||
REG_UNILAB_INTERACT,BOOL,,,,coil,8450,
|
||||
,,,,,coil,8320,
|
||||
COIL_ALUMINUM_FOIL,BOOL,,,,coil,8340,
|
||||
REG_MSG_NE_PLATE_MATRIX,INT16,,,,hold_register,440,
|
||||
REG_MSG_SEPARATOR_PLATE_MATRIX,INT16,,,,hold_register,450,
|
||||
REG_MSG_TIP_BOX_MATRIX,INT16,,,,hold_register,480,
|
||||
REG_MSG_NE_PLATE_NUM,INT16,,,,hold_register,443,
|
||||
REG_MSG_SEPARATOR_PLATE_NUM,INT16,,,,hold_register,453,
|
||||
REG_MSG_PRESS_MODE,BOOL,,,,coil,8360,
|
||||
,BOOL,,,,coil,8300,
|
||||
,BOOL,,,,coil,8310,
|
||||
COIL_GB_L_IGNORE_CMD,BOOL,,,,coil,8320,
|
||||
COIL_GB_R_IGNORE_CMD,BOOL,,,,coil,8420,
|
||||
,BOOL,,,,coil,8350,
|
||||
COIL_ELECTROLYTE_DUAL_DROP_MODE,BOOL,,,,coil,8370,
|
||||
,BOOL,,,,coil,8380,
|
||||
,BOOL,,,,coil,8390,
|
||||
,BOOL,,,,coil,8400,
|
||||
,BOOL,,,,coil,8410,
|
||||
REG_MSG_DUAL_DROP_FIRST_VOLUME,INT16,,,,hold_register,4001,
|
||||
COIL_DUAL_DROP_SUCTION_TIMING,BOOL,,,,coil,8430,
|
||||
COIL_DUAL_DROP_START_TIMING,BOOL,,,,coil,8470,
|
||||
REG_MSG_BATTERY_CLEAN_IGNORE,BOOL,,,,coil,8460,
|
||||
COIL_MATERIAL_SEARCH_DIALOG_APPEAR,BOOL,,,,coil,6470,
|
||||
COIL_MATERIAL_SEARCH_CONFIRM_YES,BOOL,,,,coil,6480,
|
||||
COIL_MATERIAL_SEARCH_CONFIRM_NO,BOOL,,,,coil,6490,
|
||||
COIL_ALARM_100_SYSTEM_ERROR,BOOL,,,,coil,1000,异常100-系统异常
|
||||
COIL_ALARM_101_EMERGENCY_STOP,BOOL,,,,coil,1010,异常101-急停
|
||||
COIL_ALARM_111_GLOVEBOX_EMERGENCY_STOP,BOOL,,,,coil,1110,异常111-手套箱急停
|
||||
COIL_ALARM_112_GLOVEBOX_GRATING_BLOCKED,BOOL,,,,coil,1120,异常112-手套箱内光栅遮挡
|
||||
COIL_ALARM_160_PIPETTE_TIP_SHORTAGE,BOOL,,,,coil,1600,异常160-移液枪头缺料
|
||||
COIL_ALARM_161_POSITIVE_SHELL_SHORTAGE,BOOL,,,,coil,1610,异常161-正极壳缺料
|
||||
COIL_ALARM_162_ALUMINUM_FOIL_SHORTAGE,BOOL,,,,coil,1620,异常162-铝箔垫缺料
|
||||
COIL_ALARM_163_POSITIVE_PLATE_SHORTAGE,BOOL,,,,coil,1630,异常163-正极片缺料
|
||||
COIL_ALARM_164_SEPARATOR_SHORTAGE,BOOL,,,,coil,1640,异常164-隔膜缺料
|
||||
COIL_ALARM_165_NEGATIVE_PLATE_SHORTAGE,BOOL,,,,coil,1650,异常165-负极片缺料
|
||||
COIL_ALARM_166_FLAT_WASHER_SHORTAGE,BOOL,,,,coil,1660,异常166-平垫缺料
|
||||
COIL_ALARM_167_SPRING_WASHER_SHORTAGE,BOOL,,,,coil,1670,异常167-弹垫缺料
|
||||
COIL_ALARM_168_NEGATIVE_SHELL_SHORTAGE,BOOL,,,,coil,1680,异常168-负极壳缺料
|
||||
COIL_ALARM_169_FINISHED_BATTERY_FULL,BOOL,,,,coil,1690,异常169-成品电池满料
|
||||
COIL_ALARM_201_SERVO_AXIS_01_ERROR,BOOL,,,,coil,2010,异常201-伺服轴01异常
|
||||
COIL_ALARM_202_SERVO_AXIS_02_ERROR,BOOL,,,,coil,2020,异常202-伺服轴02异常
|
||||
COIL_ALARM_203_SERVO_AXIS_03_ERROR,BOOL,,,,coil,2030,异常203-伺服轴03异常
|
||||
COIL_ALARM_204_SERVO_AXIS_04_ERROR,BOOL,,,,coil,2040,异常204-伺服轴04异常
|
||||
COIL_ALARM_205_SERVO_AXIS_05_ERROR,BOOL,,,,coil,2050,异常205-伺服轴05异常
|
||||
COIL_ALARM_206_SERVO_AXIS_06_ERROR,BOOL,,,,coil,2060,异常206-伺服轴06异常
|
||||
COIL_ALARM_207_SERVO_AXIS_07_ERROR,BOOL,,,,coil,2070,异常207-伺服轴07异常
|
||||
COIL_ALARM_208_SERVO_AXIS_08_ERROR,BOOL,,,,coil,2080,异常208-伺服轴08异常
|
||||
COIL_ALARM_209_SERVO_AXIS_09_ERROR,BOOL,,,,coil,2090,异常209-伺服轴09异常
|
||||
COIL_ALARM_210_SERVO_AXIS_10_ERROR,BOOL,,,,coil,2100,异常210-伺服轴10异常
|
||||
COIL_ALARM_211_SERVO_AXIS_11_ERROR,BOOL,,,,coil,2110,异常211-伺服轴11异常
|
||||
COIL_ALARM_212_SERVO_AXIS_12_ERROR,BOOL,,,,coil,2120,异常212-伺服轴12异常
|
||||
COIL_ALARM_213_SERVO_AXIS_13_ERROR,BOOL,,,,coil,2130,异常213-伺服轴13异常
|
||||
COIL_ALARM_214_SERVO_AXIS_14_ERROR,BOOL,,,,coil,2140,异常214-伺服轴14异常
|
||||
COIL_ALARM_250_OTHER_COMPONENT_ERROR,BOOL,,,,coil,2500,异常250-其他元件异常
|
||||
COIL_ALARM_251_PIPETTE_COMM_ERROR,BOOL,,,,coil,2510,异常251-移液枪通讯异常
|
||||
COIL_ALARM_252_PIPETTE_ALARM,BOOL,,,,coil,2520,异常252-移液枪报警
|
||||
COIL_ALARM_256_ELECTRIC_GRIPPER_ERROR,BOOL,,,,coil,2560,异常256-电爪异常
|
||||
COIL_ALARM_262_RB_UNKNOWN_POSITION_ERROR,BOOL,,,,coil,2620,异常262-RB报警:未知点位错误
|
||||
COIL_ALARM_263_RB_XYZ_PARAM_LIMIT_ERROR,BOOL,,,,coil,2630,异常263-RB报警:X、Y、Z参数超限制
|
||||
COIL_ALARM_264_RB_VISION_PARAM_ERROR,BOOL,,,,coil,2640,异常264-RB报警:视觉参数误差过大
|
||||
COIL_ALARM_265_RB_NOZZLE_1_PICK_FAIL,BOOL,,,,coil,2650,异常265-RB报警:1#吸嘴取料失败
|
||||
COIL_ALARM_266_RB_NOZZLE_2_PICK_FAIL,BOOL,,,,coil,2660,异常266-RB报警:2#吸嘴取料失败
|
||||
COIL_ALARM_267_RB_NOZZLE_3_PICK_FAIL,BOOL,,,,coil,2670,异常267-RB报警:3#吸嘴取料失败
|
||||
COIL_ALARM_268_RB_NOZZLE_4_PICK_FAIL,BOOL,,,,coil,2680,异常268-RB报警:4#吸嘴取料失败
|
||||
COIL_ALARM_269_RB_TRAY_PICK_FAIL,BOOL,,,,coil,2690,异常269-RB报警:取物料盘失败
|
||||
COIL_ALARM_280_RB_COLLISION_ERROR,BOOL,,,,coil,2800,异常280-RB碰撞异常
|
||||
COIL_ALARM_290_VISION_SYSTEM_COMM_ERROR,BOOL,,,,coil,2900,异常290-视觉系统通讯异常
|
||||
COIL_ALARM_291_VISION_ALIGNMENT_NG,BOOL,,,,coil,2910,异常291-视觉对位NG异常
|
||||
COIL_ALARM_292_BARCODE_SCANNER_COMM_ERROR,BOOL,,,,coil,2920,异常292-扫码枪通讯异常
|
||||
COIL_ALARM_310_OCV_TRANSFER_NOZZLE_SUCTION_ERROR,BOOL,,,,coil,3100,异常310-开电移载吸嘴吸真空异常
|
||||
COIL_ALARM_311_OCV_TRANSFER_NOZZLE_BREAK_ERROR,BOOL,,,,coil,3110,异常311-开电移载吸嘴破真空异常
|
||||
COIL_ALARM_312_WEIGHT_TRANSFER_NOZZLE_SUCTION_ERROR,BOOL,,,,coil,3120,异常312-称重移载吸嘴吸真空异常
|
||||
COIL_ALARM_313_WEIGHT_TRANSFER_NOZZLE_BREAK_ERROR,BOOL,,,,coil,3130,异常313-称重移载吸嘴破真空异常
|
||||
COIL_ALARM_340_OCV_NOZZLE_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3400,异常340-开路电压吸嘴移载气缸异常
|
||||
COIL_ALARM_342_OCV_NOZZLE_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3420,异常342-开路电压吸嘴升降气缸异常
|
||||
COIL_ALARM_344_OCV_CRIMPING_CYLINDER_ERROR,BOOL,,,,coil,3440,异常344-开路电压旋压气缸异常
|
||||
COIL_ALARM_350_WEIGHT_NOZZLE_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3500,异常350-称重吸嘴移载气缸异常
|
||||
COIL_ALARM_352_WEIGHT_NOZZLE_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3520,异常352-称重吸嘴升降气缸异常
|
||||
COIL_ALARM_354_CLEANING_CLOTH_TRANSFER_CYLINDER_ERROR,BOOL,,,,coil,3540,异常354-清洗无尘布移载气缸异常
|
||||
COIL_ALARM_356_CLEANING_CLOTH_PRESS_CYLINDER_ERROR,BOOL,,,,coil,3560,异常356-清洗无尘布压紧气缸异常
|
||||
COIL_ALARM_360_ELECTROLYTE_BOTTLE_POSITION_CYLINDER_ERROR,BOOL,,,,coil,3600,异常360-电解液瓶定位气缸异常
|
||||
COIL_ALARM_362_PIPETTE_TIP_BOX_POSITION_CYLINDER_ERROR,BOOL,,,,coil,3620,异常362-移液枪头盒定位气缸异常
|
||||
COIL_ALARM_364_REAGENT_BOTTLE_GRIPPER_LIFT_CYLINDER_ERROR,BOOL,,,,coil,3640,异常364-试剂瓶夹爪升降气缸异常
|
||||
COIL_ALARM_366_REAGENT_BOTTLE_GRIPPER_CYLINDER_ERROR,BOOL,,,,coil,3660,异常366-试剂瓶夹爪气缸异常
|
||||
COIL_ALARM_370_PRESS_MODULE_BLOW_CYLINDER_ERROR,BOOL,,,,coil,3700,异常370-压制模块吹气气缸异常
|
||||
COIL_ALARM_151_ELECTROLYTE_BOTTLE_POSITION_ERROR,BOOL,,,,coil,1510,异常151-电解液瓶定位在籍异常
|
||||
COIL_ALARM_152_ELECTROLYTE_BOTTLE_CAP_ERROR,BOOL,,,,coil,1520,异常152-电解液瓶盖在籍异常
|
||||
|
@@ -1,88 +0,0 @@
|
||||
# 物料系统标准化重构方案
|
||||
|
||||
根据开发者的反馈,本方案旨在遵循“标准化而非绕过”的原则,对资源类(Deck、Carrier、Magazine)进行重构。核心目标是将物理结构的初始化与物料/极片的初始填充逻辑解耦,彻底解决反序列化过程中的初始化冲突。
|
||||
|
||||
## 拟议变更
|
||||
|
||||
### [参考] PRCXI9300 标准化模式
|
||||
#### [参考文件] [prcxi.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/liquid_handling/prcxi/prcxi.py)
|
||||
* **PRCXI9300Deck**: 演示了如何在 `serialize` 中导出 `sites` 元数据,以及如何在 `assign_child_resource` 中实现稳健的槽位匹配(支持按名称、坐标或索引匹配)。
|
||||
* **PRCXI9300Container**: 演示了标准的 `load_state` 和 `serialize_state` 模式,确保业务状态(如 `Material` UUID)能正确往返序列化。
|
||||
|
||||
### [组件] 台面 (Decks)
|
||||
#### [修改] [decks.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/bioyond/decks.py)
|
||||
* 将 `BIOYOND_YB_Deck` 重命名为 **`BioyondElectrolyteDeck`**,对应工厂函数 `YB_Deck()` 重命名为 **`bioyond_electrolyte_deck()`**。
|
||||
* `BIOYOND_PolymerReactionStation_Deck` 和 `BIOYOND_PolymerPreparationStation_Deck` **保持不变**。
|
||||
* 以上三个 Deck 的 `__init__` 中均移除 `setup` 参数和 `setup()` 调用,删除临时的 `deserialize` 重写。
|
||||
|
||||
#### [修改 + 重命名] [YB_YH_materials.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py) → `yihua_coin_cell_materials.py`
|
||||
* 将 `CoincellDeck` 重命名为 **`YihuaCoinCellDeck`**,对应工厂函数 `YH_Deck()` 重命名为 **`yihua_coin_cell_deck()`**。
|
||||
* 从 `YihuaCoinCellDeck.__init__` 中移除 `setup` 参数和 `setup()` 调用,删除临时的 `deserialize` 重写。
|
||||
|
||||
### [组件] 容器类与弹夹 (Itemized Carriers & Magazines)
|
||||
#### [修改] [magazine.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/battery/magazine.py)
|
||||
* 重构 `magazine_factory`:将创建 `MagazineHolder` 几何结构(空槽位)的过程与填充 `ElectrodeSheet` 物料的过程分离。
|
||||
* 确保 `MagazineHolder` 和 `Magazine` 的 `__init__` 过程中不主动创建任何内容物。
|
||||
|
||||
#### [修改] [warehouse.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/warehouse.py)
|
||||
* 确保 `WareHouse` 类和 `warehouse_factory` 遵循相同模式:先初始化几何结构,内容物另行处理。
|
||||
|
||||
#### [修改] [itemized_carrier.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/itemized_carrier.py)
|
||||
* 移除之前添加的 `idx is None` 兜底补丁。
|
||||
* 修复命名规范,确保 `assign_child_resource` 在反序列化时能准确匹配资源。
|
||||
|
||||
### [组件] 状态兼容性 (State Compatibility)
|
||||
#### [修改] [resource_tracker.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/resources/resource_tracker.py)
|
||||
* 在 `to_plr_resources` 方法中调用 `load_all_state` 之前,预处理 `all_states` 字典。
|
||||
* 对于 `Container` 类型的资源,如果其状态中缺少 `liquid_history` 或 `pending_liquids` 等 PLR 新版本要求的键,则填充默认值(如空列表/字典),防止反序列化中断。
|
||||
|
||||
### [组件] 料盘 (Material Plates)
|
||||
#### [修改] [YB_YH_materials.py](file:///d:/UniLabdev/Uni-Lab-OS/unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py)
|
||||
* 重构 `MaterialPlate`:不在 `__init__` 中直接调用 `create_ordered_items_2d`。
|
||||
* 重构 `YIHUA_Electrolyte_12VialCarrier`:将其修改为标准的基类定义或在工厂方法中彻底剥离内部 12 个 `YB_pei_ye_xiao_Bottle` 的强制初始化,以防反序列化冲突。
|
||||
|
||||
### [组件] 跨站转运与分液瓶板 (Vial Plate Transfer)
|
||||
#### [修改] [bioyond_cell_workstation.py] & [YB_YH_materials.py]
|
||||
* **分析**:目前的 `bioyond_cell_workstation.py` 在执行转移时,是用 `sites=["electrolyte_buffer"]` 试图把整块 `YB_Vial_5mL_Carrier` 板转移给目标。但由于实际工艺中,配液站将分液瓶板传往扣电工站后,是由扣电工站的机械臂**逐瓶抓取**并放入内部的 `bottle_rack_6x2`(电解液缓存位),用完后再放入 `bottle_rack_6x2_2`(废液位),因此配液站的这一次“跨工位资源树转移”在逻辑上存在偏差:目标槽位不应该是装单瓶的载体 `bottle_rack`。
|
||||
* **修复方案**:
|
||||
1. **目标端 (Yihua 侧)**:
|
||||
* 在 `YB_YH_materials.py` 中为从配液站传过来的“分液瓶板”本身设置一个接驳专用的 `PlateSlot`(或者单纯直接移到 Deck 指定坐标)。这个位置负责真正在资源树层级上合法接收配液站传过来的完整 Board。
|
||||
* 重构 `YIHUA_Electrolyte_12VialCarrier`:为了防止初始化反序列化冲突,取消内部在 `__init__` 中自动填充满 12 个 `YB_pei_ye_xiao_Bottle` 实例的逻辑。`bottle_rack_6x2` 和 `bottle_rack_6x2_2` 初始化时均应为空。
|
||||
2. **转运端 (Bioyond 侧)**:
|
||||
* 修改 `bioyond_cell_workstation.py` 的资源树数字转运代码,将其转移目标对应到 Yihua 侧新设立的“分液瓶板接驳区域”资源,或者干脆只更新资源树坐标位置(使其脱离 Bioyond Deck 加入 Yihua Deck),而不再强行挂载到一个无法容纳 Carrier 的 `bottle_rack_6x2` 内部。
|
||||
|
||||
### [组件] 依华扣电组装工站物料余量监控 (Material Monitoring)
|
||||
#### [修改] 寄存器直读与前端集成
|
||||
* **物理对象保留但虚化追踪**:原有的实体台面对象(如 `MaterialPlate`、`MagazineHolder` 各类型及其对应的洞位坐标)**仍然保留并使用**。保留它们是为了给机器臂提供基础的物理空间取放标定,以及作为前端页面的可视和可交互区块。
|
||||
* **内部物料免追踪**:既然余量完全由寄存器接管,**我们将不再在这些弹夹或洞位内部显式生成、塞入和追踪每一个具体的极片或外壳对象 (如 `ElectrodeSheet` 等)**。这恰好与我们的重构主旨(不主动在 `__init__` 建子物料以避开反序列化冲突)完美结合,进一步极大地减轻了后台资源树对象的复杂度。
|
||||
* **监控方式变更**:放弃现有的物料余量方式,直接读取依华扣电组装工站开放的寄存器地址以获取准确余量。
|
||||
* **前端界面集成**:在前端界面点击负极壳、弹垫片等弹夹的 data view 时,直接读取并显示寄存器中的各自余量。
|
||||
* **新增寄存器映射** (参考 `coin_cell_assembly_b.csv`):
|
||||
* `10mm正极片剩余物料数量(R)`:`read hold_register 520` (REAL)
|
||||
* `12mm正极片剩余物料数量(R)`:`read hold_register 522` (REAL)
|
||||
* `16mm正极片剩余物料数量(R)`:`read hold_register 524` (REAL)
|
||||
* `铝箔剩余物料数量(R)`:`read hold_register 526` (REAL)
|
||||
* `正极壳剩余物料数量(R)`:`read hold_register 528` (REAL)
|
||||
* `平垫剩余物料数量(R)`:`read hold_register 530` (REAL)
|
||||
* `负极壳剩余物料数量(R)`:`read hold_register 532` (REAL)
|
||||
* `弹垫剩余物料数量(R)`:`read hold_register 534` (REAL)
|
||||
* `成品电池剩余可容纳数量(R)`:`read hold_register 536` (REAL)
|
||||
* `成品电池NG槽剩余可容纳数量(R)`:`read hold_register 538` (REAL)
|
||||
|
||||
### [配置] JSON 配置文件 (Configuration Files)
|
||||
#### [修改] 资源类型名称更新
|
||||
* 更新以下配置文件,将其中的 `BIOYOND_YB_Deck` 替换为新的类名 **`BioyondElectrolyteDeck`**,以及将 `coin_cell_deck` 替换为 **`YihuaCoinCellDeck`**:
|
||||
* `yibin_electrolyte_config.json`
|
||||
* `yibin_coin_cell_only_config.json`
|
||||
* `yibin_electrolyte_only_config.json`
|
||||
|
||||
## 验证计划
|
||||
|
||||
### 自动化测试
|
||||
* 对重构后的类运行 `pylabrobot` 序列化/反序列化测试,确保状态能够完美恢复。
|
||||
* 检查各工作站节点启动时是否仍存在 `ValueError: Resource '...' already assigned to deck` 报错。
|
||||
* 检查 `resource_tracker` 中是否仍存在重复 UUID 报错。
|
||||
|
||||
### 手动验证
|
||||
* 重启各工作站节点,验证资源树是否能根据数据库数据正确还还原。
|
||||
* 验证“自动”与“手动”传输窗资源在台面上的分配是否正确。
|
||||
@@ -1,388 +0,0 @@
|
||||
# 物料系统标准化重构方案 v2(增强版)
|
||||
|
||||
> **基于原始方案 (`implementation_plan.md`) 的补充与细化**。
|
||||
> 本文档在原方案基础上:①增加当前代码现状核查结果;②明确各任务的执行顺序与文件级改动;③新增注意事项与回归测试命令。
|
||||
|
||||
---
|
||||
|
||||
## 0. 核心原则(保持不变)
|
||||
|
||||
"**物理几何结构初始化(Deck / Carrier / Magazine 的 `__init__`)与物料内容物填充(`setup()` / `klasses` 参数)必须彻底解耦**",以消除 PLR 反序列化时的 `Resource already assigned to deck` 错误。
|
||||
|
||||
---
|
||||
|
||||
## 1. 当前代码现状核查(2026-03-12)
|
||||
|
||||
| 文件 | 计划要求 | 当前状态 | 是否完成 |
|
||||
|---|---|---|---|
|
||||
| `resources/bioyond/decks.py` | 重命名类;移除 `setup` 参数和 `deserialize` 补丁 | 仍是 `BIOYOND_YB_Deck`;`setup` 参数和 `deserialize` 均存在 | ❌ |
|
||||
| `coin_cell_assembly/YB_YH_materials.py` | 重命名类;文件迁移;移除补丁 | 仍是 `CoincellDeck`;`setup` 参数和 `deserialize` 均存在 | ❌ |
|
||||
| `resources/battery/magazine.py` | `magazine_factory` 不主动填充物料 | `MagazineHolder_6_Cathode` / `_6_Anode` / `_4_Cathode` 仍传 `klasses`,初始化时填满极片 | ❌ |
|
||||
| `resources/battery/bottle_carriers.py` | `YIHUA_Electrolyte_12VialCarrier` 初始化时不填充瓶子 | 第 54-55 行仍循环填充 12 个 `YB_pei_ye_xiao_Bottle` | ❌ |
|
||||
| `resources/itemized_carrier.py` | 移除 `idx is None` 兜底补丁 | 第 182-190 行仍保留该兜底逻辑 | ❌(待前置任务完成后移除) |
|
||||
| `resources/resource_tracker.py` | `load_all_state` 前预填 `Container` 缺失键 | 第 616 行直接调用,无预处理 | ❌ |
|
||||
| `bioyond_cell_workstation.py` | 修正跨站转运目标为合法接驳槽 | 第 1563 行仍 `sites=["electrolyte_buffer"]`,目标 UUID 为硬编码虚拟资源 | ❌ |
|
||||
| `yibin_*.json` 配置文件 | 更新类名 | 仍使用 `BIOYOND_YB_Deck` / `CoincellDeck` | ❌ |
|
||||
| `registry/resources/bioyond/deck.yaml` | 更新类名(原计划未提及) | 仍使用 `BIOYOND_YB_Deck` / `CoincellDeck` | ❌(**新增**) |
|
||||
|
||||
---
|
||||
|
||||
## 2. 执行顺序(含依赖关系)
|
||||
|
||||
```
|
||||
阶段 A(底层资源类)
|
||||
A1. magazine.py — 移除 klasses 填充
|
||||
A2. bottle_carriers.py — 移除瓶子填充
|
||||
|
||||
阶段 B(Deck 层)
|
||||
B1. decks.py — 移除 setup 参数和 deserialize 补丁;重命名
|
||||
B2. YB_YH_materials.py → 重命名;移除 CoincellDeck 的 setup 参数和 deserialize 补丁
|
||||
|
||||
阶段 C(状态兼容)
|
||||
C1. resource_tracker.py — 预填 Container 缺失键
|
||||
C2. itemized_carrier.py — 移除 idx is None 兜底补丁(B 阶段完成后)
|
||||
|
||||
阶段 D(跨站转运修复)
|
||||
D1. YB_YH_materials.py 新增 vial_plate_dock(接驳专用槽)
|
||||
D2. bioyond_cell_workstation.py 修正 transfer 目标
|
||||
|
||||
阶段 E(配置与注册表)
|
||||
E1. yibin_*.json 更新类名
|
||||
E2. registry/resources/bioyond/deck.yaml 更新类名
|
||||
E3. coin_cell_assembly.py 更新导入路径(若文件重命名)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 分阶段详细说明
|
||||
|
||||
---
|
||||
|
||||
### 阶段 A — 底层资源类
|
||||
|
||||
#### A1. `unilabos/resources/battery/magazine.py`
|
||||
|
||||
**问题**:`MagazineHolder_6_Cathode`、`MagazineHolder_6_Anode`、`MagazineHolder_4_Cathode` 在调用 `magazine_factory` 时传入 `klasses`,导致每次 `__init__` 就填满极片,反序列化时重复添加。
|
||||
|
||||
**修改**:
|
||||
|
||||
- 将三个函数中的 `klasses=[...]` 改为 `klasses=None`(与 `MagazineHolder_6_Battery` 保持一致)。
|
||||
- **理由**:物料余量已由寄存器管理(见阶段 F),不需要在资源树中追踪每一个极片。
|
||||
|
||||
```python
|
||||
# 修改前(MagazineHolder_6_Cathode 举例)
|
||||
klasses=[FlatWasher, PositiveCan, PositiveCan, FlatWasher, PositiveCan, PositiveCan],
|
||||
|
||||
# 修改后
|
||||
klasses=None,
|
||||
```
|
||||
|
||||
> **注意**:`magazine_factory` 中 `klasses` 参数及循环体代码保留(仍可按需在非序列化场景使用),只是各具体工厂函数不再传入。
|
||||
|
||||
---
|
||||
|
||||
#### A2. `unilabos/resources/battery/bottle_carriers.py`
|
||||
|
||||
**问题**:`YIHUA_Electrolyte_12VialCarrier` 第 54-55 行在工厂函数末尾循环填充 12 个瓶子。
|
||||
|
||||
**修改**:删除以下两行:
|
||||
|
||||
```python
|
||||
# 删除
|
||||
for i in range(12):
|
||||
carrier[i] = YB_pei_ye_xiao_Bottle(f"{name}_vial_{i+1}")
|
||||
```
|
||||
|
||||
**理由**:`bottle_rack_6x2` 和 `bottle_rack_6x2_2` 均应初始化为空,瓶子由 Bioyond 侧实际转运后再填入。
|
||||
|
||||
---
|
||||
|
||||
### 阶段 B — Deck 层重构
|
||||
|
||||
#### B1. `unilabos/resources/bioyond/decks.py`
|
||||
|
||||
**改动列表**:
|
||||
|
||||
1. **重命名** `BIOYOND_YB_Deck` → `BioyondElectrolyteDeck`
|
||||
2. **重命名** `YB_Deck()` 工厂函数 → `bioyond_electrolyte_deck()`
|
||||
3. **移除** `__init__` 中的 `setup: bool = False` 参数及 `if setup: self.setup()` 调用
|
||||
4. **删除** `deserialize` 方法重写(该临时补丁在 `setup` 参数移除后自然失效,继续保留反而掩盖问题)
|
||||
5. `BIOYOND_PolymerReactionStation_Deck` 和 `BIOYOND_PolymerPreparationStation_Deck` 同步执行第 3、4 步
|
||||
|
||||
**重构后初始化模式**:
|
||||
|
||||
```python
|
||||
class BioyondElectrolyteDeck(Deck):
|
||||
def __init__(self, name: str = "YB_Deck", ...):
|
||||
super().__init__(name=name, ...)
|
||||
# ❌ 不调用 self.setup()
|
||||
# PLR 反序列化时只会调用 __init__,然后从 children JSON 重建子资源
|
||||
|
||||
def setup(self) -> None:
|
||||
# 完整的子资源初始化逻辑保留在这里,只由工厂函数调用
|
||||
...
|
||||
|
||||
def bioyond_electrolyte_deck(name: str) -> BioyondElectrolyteDeck:
|
||||
deck = BioyondElectrolyteDeck(name=name)
|
||||
deck.setup() # ✅ 工厂函数负责填充
|
||||
return deck
|
||||
```
|
||||
|
||||
**同步修改**:
|
||||
- `bioyond_cell_workstation.py` 第 20 行:
|
||||
```python
|
||||
# 修改前
|
||||
from unilabos.resources.bioyond.decks import BIOYOND_YB_Deck
|
||||
# 修改后
|
||||
from unilabos.resources.bioyond.decks import BioyondElectrolyteDeck
|
||||
```
|
||||
- 同文件第 2440 行:`BIOYOND_YB_Deck(setup=True)` → `bioyond_electrolyte_deck(name="YB_Deck")`
|
||||
|
||||
---
|
||||
|
||||
#### B2. `unilabos/devices/workstation/coin_cell_assembly/YB_YH_materials.py`
|
||||
|
||||
**改动列表**:
|
||||
|
||||
1. **重命名** `CoincellDeck` → `YihuaCoinCellDeck`
|
||||
2. **重命名** `YH_Deck()` → `yihua_coin_cell_deck()`(可保留 `YH_Deck` 作为兼容别名,日后废弃)
|
||||
3. **移除** `CoincellDeck.__init__` 中 `setup: bool = False` 参数及调用
|
||||
4. **删除** `CoincellDeck.deserialize` 重写方法
|
||||
5. `MaterialPlate.__init__` 中移除 `fill` 参数,始终不主动调用 `create_ordered_items_2d`(当前 `fill=False` 路径已正确,只需删除 `fill=True` 分支)
|
||||
|
||||
```python
|
||||
# 修改前(MaterialPlate.__init__ 片段)
|
||||
if fill:
|
||||
super().__init__(..., ordered_items=holes, ...)
|
||||
else:
|
||||
super().__init__(..., ordered_items=ordered_items, ...)
|
||||
|
||||
# 修改后(始终走 "不填充" 路径)
|
||||
super().__init__(..., ordered_items=ordered_items, ...)
|
||||
# holes 的创建代码整体移入独立工厂方法
|
||||
```
|
||||
|
||||
**同步修改**:
|
||||
- `coin_cell_assembly.py` 第 20 行导入:
|
||||
```python
|
||||
# 修改前
|
||||
from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import CoincellDeck
|
||||
# 修改后
|
||||
from unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials import YihuaCoinCellDeck
|
||||
```
|
||||
- 同文件第 2245 行:`CoincellDeck(setup=True, name="coin_cell_deck")` → `yihua_coin_cell_deck(name="coin_cell_deck")`
|
||||
- 文件重命名(可选):`YB_YH_materials.py` → `yihua_coin_cell_materials.py`(若重命名,所有 import 路径需全局替换)
|
||||
|
||||
---
|
||||
|
||||
### 阶段 C — 状态兼容
|
||||
|
||||
#### C1. `unilabos/resources/resource_tracker.py`
|
||||
|
||||
**问题**:第 616 行直接调用 `plr_resource.load_all_state(all_states)`,若 `Container` 类资源的 `data` 字段缺少 `liquid_history` 或 `pending_liquids`,PLR 新版本会抛出 `KeyError`。
|
||||
|
||||
**修改**:在第 616 行前插入预处理:
|
||||
|
||||
```python
|
||||
# 在 load_all_state 调用前预填缺失键
|
||||
from pylabrobot.resources.container import Container as PLRContainer
|
||||
for res_name, state in all_states.items():
|
||||
if state and isinstance(state, dict):
|
||||
# Container 类型要求这两个键存在
|
||||
state.setdefault("liquid_history", [])
|
||||
state.setdefault("pending_liquids", {})
|
||||
|
||||
plr_resource.load_all_state(all_states)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### C2. `unilabos/resources/itemized_carrier.py`
|
||||
|
||||
**前提**:B1、B2 阶段完成,Deck 类名与资源命名规范已对齐后再执行此步。
|
||||
|
||||
**修改**:删除第 182-190 行的兜底补丁:
|
||||
|
||||
```python
|
||||
# 删除以下整个 if 块
|
||||
if idx is None:
|
||||
fallback_location = location if location is not None else Coordinate.zero()
|
||||
super().assign_child_resource(resource, location=fallback_location, reassign=reassign)
|
||||
return
|
||||
```
|
||||
|
||||
**替代**:改为抛出带诊断信息的异常,便于后续问题排查:
|
||||
|
||||
```python
|
||||
if idx is None:
|
||||
raise ValueError(
|
||||
f"[ItemizedCarrier] 无法为资源 '{resource.name}' 找到匹配的槽位。"
|
||||
f"已知槽位:{list(self.child_locations.keys())},"
|
||||
f"传入坐标:{location}"
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 阶段 D — 跨站转运修复
|
||||
|
||||
#### D1. `YB_YH_materials.py` — 新增分液瓶板接驳槽
|
||||
|
||||
在 `YihuaCoinCellDeck.setup()` 中,新增一个专用于接收 Bioyond 侧传来的完整分液瓶板的 `ResourceStack`(或 `PlateSlot`):
|
||||
|
||||
```python
|
||||
# 在 setup() 末尾追加
|
||||
from pylabrobot.resources.resource_stack import ResourceStack
|
||||
|
||||
vial_plate_dock = ResourceStack(
|
||||
name="electrolyte_buffer", # 保持与 bioyond_cell_workstation.py 的 sites 键一致
|
||||
direction="z",
|
||||
resources=[],
|
||||
)
|
||||
self.assign_child_resource(vial_plate_dock, Coordinate(x=1050.0, y=700.0, z=0))
|
||||
```
|
||||
|
||||
> **说明**:槽位命名 `electrolyte_buffer` 与 `bioyond_cell_workstation.py` 现有的 `sites=["electrolyte_buffer"]` 对应,减少改动量。如改名,D2 需同步。
|
||||
|
||||
---
|
||||
|
||||
#### D2. `bioyond_cell_workstation.py` — 修正 transfer 目标
|
||||
|
||||
**问题**:第 1545-1552 行创建了一个 `size=1,1,1` 的虚拟 `ResourcePLR` 并硬编码 UUID,这个对象在 YihuaCoinCellDeck 的资源树中不存在,导致转移后资源树状态混乱。
|
||||
|
||||
**修改**:
|
||||
|
||||
```python
|
||||
# 修改前:创建虚拟目标资源
|
||||
target_resource_obj = ResourcePLR(name=target_location, size_x=1.0, ...)
|
||||
target_resource_obj.unilabos_uuid = "550e8400-e29b-41d4-a716-446655440001" # 硬编码
|
||||
|
||||
# 修改后:通过 ROS2/设备注册表查询真实资源
|
||||
# (需要从 target_device 的资源树中取出 electrolyte_buffer 的真实对象)
|
||||
target_resource_obj = self._get_resource_from_device(
|
||||
device_id=target_device,
|
||||
resource_name=target_location
|
||||
)
|
||||
if target_resource_obj is None:
|
||||
raise RuntimeError(
|
||||
f"目标设备 {target_device} 中未找到资源 '{target_location}',"
|
||||
f"请确认 YihuaCoinCellDeck.setup() 中已添加 electrolyte_buffer 槽位"
|
||||
)
|
||||
```
|
||||
|
||||
> **说明**:`_get_resource_from_device` 需根据现有 ROS2 资源同步机制实现,或复用已有的 `get_plr_resource_by_name` 类似方法。
|
||||
|
||||
---
|
||||
|
||||
### 阶段 E — 配置与注册表
|
||||
|
||||
#### E1. `yibin_electrolyte_config.json` / `yibin_coin_cell_only_config.json` / `yibin_electrolyte_only_config.json`
|
||||
|
||||
全局替换以下字符串:
|
||||
|
||||
| 旧值 | 新值 |
|
||||
|---|---|
|
||||
| `BIOYOND_YB_Deck` | `BioyondElectrolyteDeck` |
|
||||
| `unilabos.resources.bioyond.decks:BIOYOND_YB_Deck` | `unilabos.resources.bioyond.decks:BioyondElectrolyteDeck` |
|
||||
| `CoincellDeck` | `YihuaCoinCellDeck` |
|
||||
| `unilabos.devices.workstation.coin_cell_assembly.YB_YH_materials:CoincellDeck` | 若文件已重命名:`unilabos.devices.workstation.coin_cell_assembly.yihua_coin_cell_materials:YihuaCoinCellDeck` |
|
||||
|
||||
---
|
||||
|
||||
#### E2. `unilabos/registry/resources/bioyond/deck.yaml`(**原计划未覆盖,新增**)
|
||||
|
||||
当前第 25 行和第 37 行仍使用旧类名,需同步更新:
|
||||
|
||||
```yaml
|
||||
# 修改前
|
||||
BIOYOND_YB_Deck:
|
||||
...
|
||||
CoincellDeck:
|
||||
...
|
||||
|
||||
# 修改后
|
||||
BioyondElectrolyteDeck:
|
||||
...
|
||||
YihuaCoinCellDeck:
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 阶段 F — 物料余量监控集成(原计划第5节细化)
|
||||
|
||||
**目标**:弃用资源树内极片对象计数,改为直读依华扣电工站寄存器。
|
||||
|
||||
#### F1. `coin_cell_assembly/coin_cell_assembly.py` — 新增寄存器读取方法
|
||||
|
||||
参考 `coin_cell_assembly_b.csv` 中的地址,封装读取工具方法:
|
||||
|
||||
```python
|
||||
MATERIAL_REGISTER_MAP = {
|
||||
"10mm正极片": (520, "REAL"),
|
||||
"12mm正极片": (522, "REAL"),
|
||||
"16mm正极片": (524, "REAL"),
|
||||
"铝箔": (526, "REAL"),
|
||||
"正极壳": (528, "REAL"),
|
||||
"平垫": (530, "REAL"),
|
||||
"负极壳": (532, "REAL"),
|
||||
"弹垫": (534, "REAL"),
|
||||
"成品容量": (536, "REAL"),
|
||||
"成品NG容量": (538, "REAL"),
|
||||
}
|
||||
|
||||
def get_material_remaining(self, material_name: str) -> float:
|
||||
"""通过寄存器直读指定物料的剩余数量"""
|
||||
if material_name not in MATERIAL_REGISTER_MAP:
|
||||
raise KeyError(f"未知物料名称: {material_name}")
|
||||
address, dtype = MATERIAL_REGISTER_MAP[material_name]
|
||||
return self.read_hold_register(address, dtype) # 复用现有 Modbus 读取方法
|
||||
```
|
||||
|
||||
#### F2. 前端 data view 集成
|
||||
|
||||
- 前端点击 `MagazineHolder` 类资源的 data view 时,调用后端 `get_material_remaining` 接口(而非读取 `children` 长度)。
|
||||
- 具体接口路径和前端调用代码需与前端开发同步,本文档不作具体实现约定。
|
||||
|
||||
---
|
||||
|
||||
## 4. 验证计划(细化)
|
||||
|
||||
### 4.1 单元测试(自动化)
|
||||
|
||||
```bash
|
||||
# 序列化/反序列化往返测试
|
||||
python -m pytest unilabos/test/ -k "serial" -v
|
||||
|
||||
# 特别检查以下错误消失:
|
||||
# - ValueError: Resource '...' already assigned to deck
|
||||
# - KeyError: 'liquid_history'
|
||||
# - 重复 UUID 报错
|
||||
```
|
||||
|
||||
### 4.2 集成测试(手动)
|
||||
|
||||
按以下顺序逐步验证,确保每步正常后再进行下一步:
|
||||
|
||||
1. **单独启动 `BatteryStation` 节点**,检查 `CoincellDeck`(现 `YihuaCoinCellDeck`)能否从数据库状态正确还原,无 `already assigned` 报错。
|
||||
2. **单独启动 `BioyondElectrolyte` 节点**,检查 `BioyondElectrolyteDeck` 反序列化正常。
|
||||
3. **同时启动两个节点**,模拟执行一次分液→扣电的完整跨站转运,确认:
|
||||
- `electrolyte_buffer` 槽位正确接收分液瓶板。
|
||||
- `bottle_rack_6x2` 初始为空,不出现虚拟瓶子。
|
||||
4. **重启两个节点**(模拟断电恢复),确认资源树从数据库还原后,`electrolyte_buffer` 中仍持有正确的分液瓶板对象。
|
||||
5. **寄存器余量读取**:手动触发 `get_material_remaining("负极壳")`,确认返回值与设备显示一致。
|
||||
|
||||
---
|
||||
|
||||
## 5. 与原计划的差异对照
|
||||
|
||||
| 维度 | 原计划 | 本文档新增/修订 |
|
||||
|---|---|---|
|
||||
| 执行顺序 | 未排序 | 明确 A→B→C→D→E→F 的依赖顺序 |
|
||||
| `itemized_carrier.py` | 移除兜底补丁 | 补充:替换为带诊断信息的异常,便于排查 |
|
||||
| `bottle_carriers.py` | 提及 `YIHUA_Electrolyte_12VialCarrier` 需修改 | 明确:删除第 54-55 行的瓶子填充循环 |
|
||||
| `MaterialPlate` | 提及移除 `fill` 参数 | 说明保留 `fill=False` 路径;整体删除 `fill=True` 分支 |
|
||||
| `deck.yaml` | 未提及 | **新增**:该注册文件也需要同步更新类名 |
|
||||
| `resource_tracker.py` | 简略描述 | 提供具体的 `setdefault` 预处理代码示例 |
|
||||
| 跨站转运 | 描述了问题和方向 | 细化:新增 `electrolyte_buffer` 槽位的具体名称和坐标;修正 `transfer` 目标查找方式 |
|
||||
| 验证计划 | 简述目标 | 提供具体测试命令和逐步手动验证流程 |
|
||||
634
unilabos/layout_optimizer/README.md
Normal file
634
unilabos/layout_optimizer/README.md
Normal file
@@ -0,0 +1,634 @@
|
||||
# Layout Optimizer Handover
|
||||
|
||||
**Date**: 2026-04-10 | **Branch**: `feat/3d_layout_and_visualize` | **Commit**: `99dc821a` | **Tests**: 270 (260 pass + 10 LLM skip w/o API key)
|
||||
|
||||
This package is a standalone lab layout optimizer. It takes a device list + constraints and returns optimized placements. Your integration points are the HTTP API and the LLM skill document.
|
||||
|
||||
---
|
||||
|
||||
## 1. Full Pipeline Overview
|
||||
|
||||
```
|
||||
User NL request
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐ skill doc: llm_skill/layout_intent_translator.md
|
||||
│ LLM Agent │◄── + device list from scene (GET /devices)
|
||||
│ (your side) │ + schema discovery (GET /interpret/schema)
|
||||
└────────┬────────┘
|
||||
│ structured intents JSON
|
||||
▼
|
||||
POST /interpret ← intent_interpreter.py (pure translation)
|
||||
│
|
||||
│ { constraints, translations, workflow_edges, errors }
|
||||
▼
|
||||
User confirms ← translations have human-readable explanations
|
||||
│
|
||||
▼
|
||||
POST /optimize ← full pipeline below
|
||||
│
|
||||
┌────┴─────────────────────────────────────────┐
|
||||
│ 1. Device catalog (device_catalog.py) │
|
||||
│ footprints.json → Device objects │
|
||||
│ bbox, height, openings per device │
|
||||
│ │
|
||||
│ 2. Seeder (seeders.py) │
|
||||
│ Force-directed initial placement │
|
||||
│ Presets: compact_outward, spread_inward, │
|
||||
│ workflow_cluster, row_fallback │
|
||||
│ Accounts for openings, workflow edges │
|
||||
│ │
|
||||
│ 3. DE Optimizer (optimizer.py) │
|
||||
│ Custom DE loop (best1bin/currenttobest1bin│
|
||||
│ /rand1bin strategies) │
|
||||
│ 3N-dim: [x0, y0, θ0, x1, y1, θ1, ...] │
|
||||
│ Broad-phase AABB sweep (broad_phase.py) │
|
||||
│ θ lattice snap in joint discrete mode │
|
||||
│ Cost = hard_penalties + soft_penalties │
|
||||
│ Graduated collision penalties (not binary) │
|
||||
│ │
|
||||
│ 4. θ snap (optimizer.snap_theta) │
|
||||
│ Snap near-cardinal angles to 0/90/180/270 │
|
||||
│ (opt-in via snap_cardinal=True) │
|
||||
│ │
|
||||
│ 5. Final eval (constraints.py) │
|
||||
│ Binary pass/fail for response.success │
|
||||
└──────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
{ placements, cost, success }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. API Reference
|
||||
|
||||
### `POST /interpret` — LLM intent → constraints
|
||||
|
||||
Translates semantic intents into optimizer constraints. The LLM agent calls this after translating user NL.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"intents": [
|
||||
{
|
||||
"intent": "reachable_by",
|
||||
"params": {"arm": "arm_slider", "targets": ["opentrons_liquid_handler", "inheco_odtc_96xl"]},
|
||||
"description": "Robot arm must reach these devices"
|
||||
},
|
||||
{
|
||||
"intent": "workflow_hint",
|
||||
"params": {"workflow": "pcr", "devices": ["device_a", "device_b", "device_c"]},
|
||||
"description": "PCR workflow order"
|
||||
},
|
||||
{
|
||||
"intent": "close_together",
|
||||
"params": {"devices": ["device_a", "device_b"], "priority": "high"},
|
||||
"description": "Keep these close"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"constraints": [
|
||||
{"type": "hard", "rule_name": "reachability", "params": {"arm_id": "arm_slider", "target_device_id": "opentrons_liquid_handler"}, "weight": 1.0},
|
||||
...
|
||||
],
|
||||
"translations": [
|
||||
{
|
||||
"source_intent": "reachable_by",
|
||||
"source_description": "Robot arm must reach these devices",
|
||||
"source_params": {"arm": "arm_slider", "targets": ["..."]},
|
||||
"generated_constraints": [...],
|
||||
"explanation": "机械臂 'arm_slider' 需要能够到达 2 个目标设备",
|
||||
"confidence": "high"
|
||||
}
|
||||
],
|
||||
"workflow_edges": [["device_a", "device_b"], ["device_b", "device_c"]],
|
||||
"errors": []
|
||||
}
|
||||
```
|
||||
|
||||
The `constraints` and `workflow_edges` arrays pass directly to `/optimize` — no transformation needed.
|
||||
|
||||
### `GET /interpret/schema` — LLM discovery
|
||||
|
||||
Returns all 11 intent types with parameter specs. LLM agent should call this before translating.
|
||||
|
||||
### `POST /optimize` — Run layout optimization
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"devices": [
|
||||
{"id": "thermo_orbitor_rs2_hotel", "name": "Plate Hotel", "device_type": "static"},
|
||||
{"id": "arm_slider", "name": "Robot Arm", "device_type": "articulation"},
|
||||
...
|
||||
],
|
||||
"lab": {"width": 6.0, "depth": 4.0},
|
||||
"constraints": [...],
|
||||
"workflow_edges": [["device_a", "device_b"]],
|
||||
"seeder": "compact_outward",
|
||||
"run_de": true,
|
||||
"maxiter": 200,
|
||||
"seed": 42,
|
||||
"angle_granularity": 4,
|
||||
"snap_cardinal": false,
|
||||
"strategy": "currenttobest1bin",
|
||||
"mutation": [0.5, 1.0],
|
||||
"theta_mutation": null,
|
||||
"recombination": 0.7,
|
||||
"crossover_mode": "device"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"placements": [
|
||||
{
|
||||
"device_id": "thermo_orbitor_rs2_hotel",
|
||||
"uuid": "thermo_orbitor_rs2_hotel",
|
||||
"position": {"x": 1.33, "y": 2.35, "z": 0.0},
|
||||
"rotation": {"x": 0.0, "y": 0.0, "z": 1.5708}
|
||||
},
|
||||
...
|
||||
],
|
||||
"cost": 0.0,
|
||||
"success": true,
|
||||
"seeder_used": "compact_outward",
|
||||
"de_ran": true
|
||||
}
|
||||
```
|
||||
|
||||
`position`/`rotation` format matches Cloud's `CommonPositionType`. `rotation.z` is θ in radians.
|
||||
|
||||
**DE hyperparameters:**
|
||||
| Param | Default | Description |
|
||||
|-------|---------|-------------|
|
||||
| `strategy` | `"currenttobest1bin"` | DE mutation strategy (`best1bin`, `currenttobest1bin`, `rand1bin`) |
|
||||
| `mutation` | `[0.5, 1.0]` | Dithered F range for position dimensions |
|
||||
| `theta_mutation` | `null` (same as `mutation`) | Separate F range for θ dimensions (decoupled mutation) |
|
||||
| `recombination` | `0.7` | Crossover probability |
|
||||
| `crossover_mode` | `"device"` | `"device"` = per-device CR, `"dimension"` = per-dimension CR |
|
||||
| `angle_granularity` | `null` | `4`/`8`/`12`/`24` — snaps θ to a discrete lattice during DE (joint mode). `4` = axis-aligned (0/90/180/270). `null` = continuous θ |
|
||||
| `snap_cardinal` | `false` | Post-DE snap to nearest cardinal angle with collision rollback |
|
||||
|
||||
### Scene State API
|
||||
|
||||
Shared scene state between the LLM agent and the frontend. The agent pushes layout results here; the frontend polls for updates.
|
||||
|
||||
#### `GET /scene/lab` / `POST /scene/lab` — Lab dimensions
|
||||
|
||||
**GET** returns current lab dimensions. **POST** sets them (frontend sends this when user changes lab size).
|
||||
|
||||
```json
|
||||
{"width": 6.0, "depth": 4.0}
|
||||
```
|
||||
|
||||
#### `GET /scene/placements` / `POST /scene/placements` / `DELETE /scene/placements`
|
||||
|
||||
**GET** returns current placements + a version counter. Frontend polls this every 1s and re-renders when version changes.
|
||||
|
||||
```json
|
||||
{"version": 3, "placements": [...]}
|
||||
```
|
||||
|
||||
**POST** pushes new placements (from `/optimize` result or agent). Bumps version.
|
||||
|
||||
**DELETE** clears all placements (resets scene).
|
||||
|
||||
### `GET /devices` — Device catalog
|
||||
|
||||
Returns all known devices with bbox, openings, model paths. The LLM agent should receive this list as context so it can resolve fuzzy device names.
|
||||
|
||||
### `GET /health`
|
||||
|
||||
Returns `{"status": "ok"}`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Intent Types (11 total)
|
||||
|
||||
| Intent | Params | Generates | Type |
|
||||
|--------|--------|-----------|------|
|
||||
| `reachable_by` | `arm` (str), `targets` (list[str]) | `reachability` per target | hard |
|
||||
| `close_together` | `devices` (list[str]), `priority` (low/medium/high) | `minimize_distance` per pair | soft |
|
||||
| `far_apart` | `devices` (list[str]), `priority` | `maximize_distance` per pair | soft |
|
||||
| `keep_adjacent` | `devices` (list[str]), `priority` | `minimize_distance` per pair | soft |
|
||||
| `max_distance` | `device_a`, `device_b`, `distance` (float m) | `distance_less_than` | hard |
|
||||
| `min_distance` | `device_a`, `device_b`, `distance` (float m) | `distance_greater_than` | hard |
|
||||
| `min_spacing` | `min_gap` (float m, default 0.3) | `min_spacing` | hard |
|
||||
| `workflow_hint` | `workflow` (str), `devices` (ordered list[str]) | `minimize_distance` consecutive + `workflow_edges` | soft |
|
||||
| `face_outward` | (none) | `prefer_orientation_mode` outward | soft |
|
||||
| `face_inward` | (none) | `prefer_orientation_mode` inward | soft |
|
||||
| `align_cardinal` | (none) | `prefer_aligned` | soft |
|
||||
|
||||
Intent priorities are baked into the final emitted constraint `weight` during interpretation. The caller only sees the resulting weight, not a separate constraint-level priority field.
|
||||
|
||||
---
|
||||
|
||||
## 4. LLM Integration Guide
|
||||
|
||||
### What You Need to Build (Your Side)
|
||||
|
||||
The LLM agent that converts user natural language → structured intents JSON. We provide:
|
||||
|
||||
1. **Skill document** (`llm_skill/layout_intent_translator.md`) — system prompt for the LLM. Contains intent schema, device name resolution rules, translation rules, and PCR workflow examples.
|
||||
|
||||
2. **Runtime schema** (`GET /interpret/schema`) — machine-readable intent specs. LLM agent should call this for discovery.
|
||||
|
||||
3. **Device context** — before translating, feed the LLM the scene's device list (from `GET /devices` or your scene state). The LLM uses this to resolve fuzzy names like "PCR machine" → `inheco_odtc_96xl`.
|
||||
|
||||
### Integration Flow
|
||||
|
||||
```
|
||||
1. User enters NL request in Cloud UI
|
||||
2. Your LLM agent receives:
|
||||
- User message
|
||||
- Scene device list (id, name, type, bbox)
|
||||
- Skill doc as system prompt
|
||||
- Optional: GET /interpret/schema for discovery
|
||||
3. LLM outputs: {"intents": [...]}
|
||||
4. POST /interpret with LLM output
|
||||
5. Show user the translations for confirmation
|
||||
6. POST /optimize with confirmed constraints + workflow_edges
|
||||
7. Apply placements to scene
|
||||
```
|
||||
|
||||
### Device Name Resolution (handled by LLM, not by optimizer)
|
||||
|
||||
The skill doc teaches the LLM to match fuzzy names:
|
||||
- "PCR machine" / "thermal cycler" → `inheco_odtc_96xl`
|
||||
- "liquid handler" / "pipetting robot" → `opentrons_liquid_handler`
|
||||
- "plate hotel" / "storage" → `thermo_orbitor_rs2_hotel`
|
||||
- "robot arm" / "the arm" → device with `type: articulation`
|
||||
- "plate sealer" → `agilent_plateloc`
|
||||
|
||||
No search endpoint needed — the device list is already in context.
|
||||
|
||||
### Tested LLM Outputs
|
||||
|
||||
We tested with Claude Sonnet (via subagent, no API key required). Examples:
|
||||
|
||||
**Input**: "Take plate from hotel, prepare sample in the pipetting robot, seal it, then run thermal cycling. The arm handles all transfers. Keep liquid handler and sealer close, minimum 15cm gap."
|
||||
|
||||
**LLM produced**: `reachable_by` (arm→4 devices), `workflow_hint` (correct PCR order), `close_together` (high, LH+sealer), `min_distance` (0.15m, LH+sealer)
|
||||
|
||||
**Input**: "I want an automatic PCR lab, make it compact and neat"
|
||||
|
||||
**LLM produced**: `reachable_by`, `workflow_hint`, `close_together` (all devices), `min_spacing` (0.05m), `align_cardinal`
|
||||
|
||||
All outputs pass through `/interpret` → `/optimize` successfully.
|
||||
|
||||
---
|
||||
|
||||
## 5. Constraint System Details
|
||||
|
||||
### Hard Constraints (cost = ∞ on violation)
|
||||
|
||||
| Rule Name | Params | What it checks |
|
||||
|-----------|--------|---------------|
|
||||
| `no_collision` | (default, always on) | OBB-SAT pairwise collision between all devices |
|
||||
| `within_bounds` | (default, always on) | All devices within lab boundary |
|
||||
| `reachability` | `arm_id`, `target_device_id` | Target center within arm reach radius |
|
||||
| `distance_less_than` | `device_a`, `device_b`, `distance` | OBB edge-to-edge distance ≤ threshold |
|
||||
| `distance_greater_than` | `device_a`, `device_b`, `distance` | OBB edge-to-edge distance ≥ threshold |
|
||||
| `min_spacing` | `min_gap` | All device pairs have ≥ min_gap edge-to-edge |
|
||||
|
||||
### Soft Constraints (weighted penalty)
|
||||
|
||||
| Rule Name | Params | What it minimizes |
|
||||
|-----------|--------|------------------|
|
||||
| `minimize_distance` | `device_a`, `device_b` | OBB edge-to-edge distance × weight |
|
||||
| `maximize_distance` | `device_a`, `device_b` | 1/(distance+ε) × weight |
|
||||
| `prefer_orientation_mode` | `mode` (outward/inward) | Angle between opening direction and ideal direction |
|
||||
| `prefer_aligned` | (none) | Deviation from nearest 90° angle |
|
||||
| `prefer_seeder_orientation` | (none) | Deviation from seeder-assigned θ |
|
||||
| `crossing_penalty` | (auto, part of `reachability` eval) | Segment-OBB intersection length of opening-to-arm path blocked by other devices (Cyrus-Beck clipping via `obb.segment_obb_intersection_length`) |
|
||||
|
||||
### Weight Normalization
|
||||
|
||||
| Constant | Value | Meaning |
|
||||
|----------|-------|---------|
|
||||
| `DEFAULT_WEIGHT_DISTANCE` | 100.0 | 1 cm → penalty 1.0 |
|
||||
| `DEFAULT_WEIGHT_ANGLE` | 60.0 | 5° → penalty ~1.0 |
|
||||
| `HARD_MULTIPLIER` | 5.0 | Hard constraint penalty multiplier during graduated DE |
|
||||
|
||||
Constraints support a `priority` field (`critical` / `high` / `normal` / `low`) with multipliers 5× / 2× / 1× / 0.5×.
|
||||
|
||||
### Graduated Penalties (DE internals)
|
||||
|
||||
Default hard constraints (collision, boundary) use **graduated penalties** during DE optimization — proportional to penetration depth / overshoot distance. This gives DE a smooth gradient instead of binary inf. Final evaluation uses binary mode for pass/fail reporting.
|
||||
|
||||
---
|
||||
|
||||
## 6. Checker Architecture (Mock → Real)
|
||||
|
||||
```
|
||||
interfaces.py (Protocol definitions)
|
||||
├── CollisionChecker.check(placements) → collisions
|
||||
├── CollisionChecker.check_bounds(placements, w, d) → out_of_bounds
|
||||
└── ReachabilityChecker.is_reachable(arm_id, arm_pose, target) → bool
|
||||
|
||||
mock_checkers.py (current, no ROS)
|
||||
├── MockCollisionChecker — OBB SAT
|
||||
└── MockReachabilityChecker — Euclidean distance, 100m fallback for unknown arms
|
||||
|
||||
ros_checkers.py (for ROS2/MoveIt2 integration)
|
||||
├── MoveItCollisionChecker — python-fcl direct + OBB fallback
|
||||
└── IKFastReachabilityChecker — precomputed voxel O(1) + live IK fallback
|
||||
└── create_checkers(mode) — factory, controlled by LAYOUT_CHECKER_MODE env var
|
||||
```
|
||||
|
||||
To switch to real checkers: `LAYOUT_CHECKER_MODE=moveit` + pass MoveIt2 instance.
|
||||
|
||||
---
|
||||
|
||||
## 7. File Inventory
|
||||
|
||||
### Core Pipeline
|
||||
| File | Lines | Purpose |
|
||||
|------|-------|---------|
|
||||
| `models.py` | 97 | Dataclasses: Device, Lab, Placement, Constraint, Intent, Opening |
|
||||
| `device_catalog.py` | 303 | Loads devices from footprints.json + uni-lab-assets + registry |
|
||||
| `footprints.json` | 183KB | 499 device bounding boxes, heights, openings (offline extracted) |
|
||||
| `seeders.py` | 331 | Force-directed initial layout with presets |
|
||||
| `optimizer.py` | 1056 | Custom DE loop: per-device crossover, θ wrapping, discrete angle lattice, multi-strategy |
|
||||
| `broad_phase.py` | 66 | 2-axis sweep-and-prune AABB broad phase for collision pair pruning |
|
||||
| `constraints.py` | 627 | Unified constraint evaluation (hard + soft + graduated + crossing penalty) |
|
||||
| `obb.py` | 257 | OBB geometry: corners, overlap SAT, min_distance, penetration_depth, segment intersection |
|
||||
| `intent_interpreter.py` | 366 | 11 intent handlers, pure translation, no side effects |
|
||||
| `server.py` | 743 | FastAPI: /interpret, /optimize, /devices, /scene/* endpoints |
|
||||
| `lab_parser.py` | 50 | Parse lab floor plan JSON to Lab dataclass |
|
||||
|
||||
### Reference / Utilities
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `extract_footprints.py` | How footprints.json was generated (offline STL/GLB → 2D bbox extraction via trimesh) |
|
||||
| `generate_asset_registry.py` | Generate YAML registry entries for uni-lab-assets devices not already registered |
|
||||
|
||||
### Integration Layer
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `interfaces.py` | Protocol definitions for CollisionChecker / ReachabilityChecker |
|
||||
| `mock_checkers.py` | Dev-mode checkers (OBB collision, Euclidean reachability) |
|
||||
| `ros_checkers.py` | MoveIt2/IKFast adapters for real collision + reachability |
|
||||
|
||||
### LLM
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `llm_skill/layout_intent_translator.md` | System prompt for LLM: intent schema, device resolution, translation rules, examples |
|
||||
| `llm_skill/demo_agent.md` | LLM agent orchestration instructions for demo (GET /devices → intents → /interpret → /optimize → /scene/placements) |
|
||||
|
||||
### Demo / Frontend
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `static/lab3d.html` | Three.js 3D visualization frontend (1227 lines): device library, drag-to-add, auto layout, scene polling |
|
||||
|
||||
### Configuration
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `pyproject.toml` | Package deps: scipy, numpy, fastapi, uvicorn, pydantic |
|
||||
|
||||
### Tests (270 total: 260 pass + 10 skip without API key)
|
||||
| File | Tests | Coverage |
|
||||
|------|-------|----------|
|
||||
| `test_intent_interpreter.py` | 19 | All 11 handlers, validation, priority, multi-intent |
|
||||
| `test_interpret_api.py` | 6 | /interpret and /interpret/schema endpoints |
|
||||
| `test_e2e_pcr_pipeline.py` | 12 | Full pipeline: interpret → optimize → verify placements |
|
||||
| `test_llm_skill.py` | 10 | Real LLM fuzzy input → structured output (needs ANTHROPIC_API_KEY) |
|
||||
| `test_constraints.py` | 30 | Constraint evaluation, hard/soft, graduated penalties, crossing penalty |
|
||||
| `test_optimizer.py` | 50 | DE optimizer, vector encoding, bounds, discrete angles, strategies |
|
||||
| `test_mock_checkers.py` | 15 | MockCollisionChecker, MockReachabilityChecker |
|
||||
| `test_ros_checkers.py` | 40 | MoveIt2/IKFast adapter tests |
|
||||
| `test_seeders.py` | 12 | Force-directed seeder presets |
|
||||
| `test_device_catalog.py` | 25 | Device loading, footprint merging |
|
||||
| `test_obb.py` | 18 | OBB geometry functions, segment intersection |
|
||||
| `test_bugfixes_v2.py` | 28 | Regression: duplicate IDs, orientation, min_spacing, cardinal snap defaults |
|
||||
| `test_broad_phase.py` | 5 | Sweep-and-prune AABB broad phase |
|
||||
|
||||
---
|
||||
|
||||
## 8. How to Run
|
||||
|
||||
### Quick Start
|
||||
```bash
|
||||
# Install
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# Run server
|
||||
uvicorn unilabos.layout_optimizer.server:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
# Run server with debug logging (shows DE cost breakdown per generation)
|
||||
LAYOUT_DEBUG=1 uvicorn unilabos.layout_optimizer.server:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
# Run tests
|
||||
pytest unilabos/layout_optimizer/tests/ -v
|
||||
|
||||
# Run LLM skill tests (needs API key)
|
||||
ANTHROPIC_API_KEY=sk-... pytest unilabos/layout_optimizer/tests/test_llm_skill.py -v
|
||||
```
|
||||
|
||||
**Log files**: All requests are logged to `unilabos/layout_optimizer/logs/{YYYYMMDD_HHMMSS}.log` at DEBUG level (frontend polling GET /scene/placements excluded).
|
||||
|
||||
### Dependencies
|
||||
- Python ≥ 3.10
|
||||
- scipy, numpy, fastapi, uvicorn, pydantic
|
||||
- Optional: anthropic (for LLM skill tests)
|
||||
- Optional: python-fcl (for real collision checking, not needed for mock mode)
|
||||
|
||||
### Environment Variables
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `UNI_LAB_ASSETS_DIR` | `../uni-lab-assets` | Path to device 3D models |
|
||||
| `UNI_LAB_OS_DEVICE_MESH_DIR` | `Uni-Lab-OS/unilabos/device_mesh/devices` | Registry device meshes |
|
||||
| `LAYOUT_CHECKER_MODE` | `mock` | `mock` or `moveit` for checker selection |
|
||||
| `LAYOUT_DEBUG` | (unset) | Set to `1` for DEBUG-level console logging (DE cost breakdown per generation) |
|
||||
| `ANTHROPIC_API_KEY` | (none) | For LLM skill tests |
|
||||
|
||||
---
|
||||
|
||||
## 9. Known Limitations
|
||||
|
||||
1. **Mock reachability**: `MockReachabilityChecker` uses 100m fallback for unknown arm IDs — effectively "always reachable" for mock mode. Real arm reach requires `ros_checkers.py` with MoveIt2.
|
||||
|
||||
2. **No real LLM in tests**: `test_llm_skill.py` tests are skipped without `ANTHROPIC_API_KEY`. We verified with Claude Sonnet subagent that the skill doc produces correct output for PCR workflow scenarios.
|
||||
|
||||
3. **Opening data coverage**: 289/499 devices have opening direction annotations. Devices without openings default to local -Y as front with no alignment penalty.
|
||||
|
||||
4. **Single lab room**: No multi-room or corridor support yet. Lab is a single rectangle with optional rectangular obstacles.
|
||||
|
||||
5. **Intent interpreter is stateless**: It translates intents one-by-one with no cross-referencing between them. Duplicate/conflicting constraints are the LLM's responsibility to avoid.
|
||||
|
||||
6. **`align_weight` and `snap_cardinal` default to off**: `prefer_aligned` weight defaults to 0 (was `DEFAULT_WEIGHT_ANGLE=60`) and `snap_theta_safe` is opt-in via `snap_cardinal=True`. Both remain available when explicitly requested via `align_cardinal` intent or API param.
|
||||
|
||||
7. **Hybrid angle mode deprecated**: The angle-first hybrid mode (separate angle sweep + position-only DE) has been replaced by joint discrete mode as the default when `angle_granularity` is set. Joint mode snaps θ to the discrete lattice within the normal 3N DE loop.
|
||||
|
||||
---
|
||||
|
||||
## 10. Quick Verification (curl)
|
||||
|
||||
```bash
|
||||
# 1. Health check
|
||||
curl http://localhost:8000/health
|
||||
|
||||
# 2. Schema discovery
|
||||
curl http://localhost:8000/interpret/schema | python3 -m json.tool
|
||||
|
||||
# 3. Interpret PCR workflow
|
||||
curl -X POST http://localhost:8000/interpret \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"intents": [
|
||||
{"intent": "reachable_by", "params": {"arm": "arm_slider", "targets": ["opentrons_liquid_handler", "inheco_odtc_96xl"]}, "description": "arm reaches targets"},
|
||||
{"intent": "workflow_hint", "params": {"workflow": "pcr", "devices": ["thermo_orbitor_rs2_hotel", "opentrons_liquid_handler", "agilent_plateloc", "inheco_odtc_96xl"]}, "description": "PCR order"}
|
||||
]
|
||||
}' | python3 -m json.tool
|
||||
|
||||
# 4. Optimize (use constraints from step 3)
|
||||
curl -X POST http://localhost:8000/optimize \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"devices": [
|
||||
{"id": "thermo_orbitor_rs2_hotel", "name": "Plate Hotel"},
|
||||
{"id": "arm_slider", "name": "Robot Arm", "device_type": "articulation"},
|
||||
{"id": "opentrons_liquid_handler", "name": "Liquid Handler"},
|
||||
{"id": "agilent_plateloc", "name": "Plate Sealer"},
|
||||
{"id": "inheco_odtc_96xl", "name": "Thermal Cycler"}
|
||||
],
|
||||
"lab": {"width": 6.0, "depth": 4.0},
|
||||
"constraints": [
|
||||
{"type": "hard", "rule_name": "reachability", "params": {"arm_id": "arm_slider", "target_device_id": "opentrons_liquid_handler"}, "weight": 1.0},
|
||||
{"type": "hard", "rule_name": "reachability", "params": {"arm_id": "arm_slider", "target_device_id": "inheco_odtc_96xl"}, "weight": 1.0},
|
||||
{"type": "soft", "rule_name": "minimize_distance", "params": {"device_a": "thermo_orbitor_rs2_hotel", "device_b": "opentrons_liquid_handler"}, "weight": 3.0},
|
||||
{"type": "soft", "rule_name": "minimize_distance", "params": {"device_a": "opentrons_liquid_handler", "device_b": "agilent_plateloc"}, "weight": 3.0},
|
||||
{"type": "soft", "rule_name": "minimize_distance", "params": {"device_a": "agilent_plateloc", "device_b": "inheco_odtc_96xl"}, "weight": 3.0}
|
||||
],
|
||||
"workflow_edges": [
|
||||
["thermo_orbitor_rs2_hotel", "opentrons_liquid_handler"],
|
||||
["opentrons_liquid_handler", "agilent_plateloc"],
|
||||
["agilent_plateloc", "inheco_odtc_96xl"]
|
||||
],
|
||||
"run_de": true,
|
||||
"angle_granularity": 4,
|
||||
"maxiter": 100,
|
||||
"seed": 42
|
||||
}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Demo Setup
|
||||
|
||||
This section documents the device processing pipeline, test frontend, and LLM agent demo for the layout optimizer.
|
||||
|
||||
### 11.1 Device Processing Pipeline
|
||||
|
||||
How devices go from 3D meshes to collision footprints:
|
||||
|
||||
1. **Source data**:
|
||||
- `uni-lab-assets/` repository: GLB/STL 3D models + XACRO robot descriptions
|
||||
- `Uni-Lab-OS/device_mesh/devices/` registry: device metadata directories
|
||||
|
||||
2. **Extraction** (`extract_footprints.py`):
|
||||
- Load meshes via `trimesh` (STL for geometry, GLB for display)
|
||||
- Compute oriented bounding box (OBB): width, depth, height
|
||||
- Apply GLB root node rotation to align with world frame
|
||||
- Detect openings from XACRO `<joint type="fixed">` elements containing "socket" in name
|
||||
- Compute opening direction: centroid of socket origins → cardinal direction mapping
|
||||
- Manual overrides for devices with non-standard opening patterns (`MANUAL_OPENINGS` dict)
|
||||
- Write results to `footprints.json` (499 devices, 183KB)
|
||||
|
||||
3. **Catalog merging** (`device_catalog.py`):
|
||||
- Load `footprints.json` (OBB + openings)
|
||||
- Load `uni-lab-assets/data.json` (asset tree structure)
|
||||
- Load `Uni-Lab-OS/device_mesh/devices/` (registry devices)
|
||||
- Merge: registry devices get priority for metadata, but assets' 3D model paths preferred
|
||||
- Fallback sizes: `KNOWN_SIZES` dict provides manual dimensions when trimesh extraction fails
|
||||
|
||||
4. **Standalone filtering** (`server.py:_is_standalone_device`):
|
||||
- Bbox >30cm = device (standalone equipment)
|
||||
- Bbox <5cm = consumable (plates, tubes, tips)
|
||||
- 5-30cm = keyword heuristic (check name for "plate", "tube", "tip", "rack")
|
||||
|
||||
### 11.2 Test Frontend (`static/lab3d.html`)
|
||||
|
||||
Interactive 3D lab layout visualization and design tool (1227 lines).
|
||||
|
||||
**Technology stack**:
|
||||
- Three.js v0.169.0 (ES modules from esm.sh CDN)
|
||||
- WebGL renderer with PCF soft shadow maps, ACES filmic tone mapping
|
||||
- OrbitControls for camera interaction
|
||||
|
||||
**Features**:
|
||||
- **Device library**: Left sidebar with search/filter, toggle between devices and consumables
|
||||
- **Drag-to-add**: Click device in library → adds to scene with random position
|
||||
- **Selected devices panel**: Right panel lists all placed devices, click to remove
|
||||
- **Lab dimensions**: Width × Depth inputs (meters), collision margin slider
|
||||
- **View modes**: 3D perspective (default) and top-down orthographic
|
||||
- **Grid system**: 0.5m grid with lab boundary highlighting
|
||||
- **Device visualization**: Box geometry with emissive materials, edge highlights, CSS2D labels
|
||||
- **Opening markers**: Orange arrows and semi-transparent strips showing device access directions
|
||||
- **Auto Layout button**: Calls `POST /optimize` with current devices + constraints
|
||||
- **Scene polling**: 1-second polling of `GET /scene/placements` for agent-pushed updates (version-based change detection)
|
||||
- **Smooth animation**: Lerp interpolation for device placement changes
|
||||
|
||||
**Backend integration**:
|
||||
- `GET /devices` — Load device catalog on startup
|
||||
- `POST /optimize` — Send devices + constraints, receive placements
|
||||
- `POST /scene/lab` — Push lab dimensions when changed
|
||||
- `GET /scene/placements` — Poll every 1s for agent-pushed updates
|
||||
|
||||
**Key JavaScript functions**:
|
||||
- `loadDeviceCatalog()` — Fetch device list, build catalog with color pool
|
||||
- `createDeviceMesh(deviceId, uuid)` — Create Three.js Group with body, edges, opening markers
|
||||
- `addDevice(deviceId)` / `removeDevice(uuid)` — Manage selected devices
|
||||
- `runLayout()` — Call backend `/optimize` or local bin packing fallback
|
||||
- `animatePlacement(uuid, tx, tz, theta)` — Smooth lerp to target position
|
||||
- `setView('3d' | 'top')` — Switch camera perspective
|
||||
|
||||
### 11.3 LLM Agent Demo (`llm_skill/demo_agent.md`)
|
||||
|
||||
LLM agent orchestration instructions for natural language lab layout design.
|
||||
|
||||
**Agent workflow**:
|
||||
1. `GET /devices` — Fetch device catalog for context
|
||||
2. Parse user natural language request
|
||||
3. Build structured intents JSON (using `layout_intent_translator.md` skill)
|
||||
4. `POST /interpret` — Translate intents to constraints
|
||||
5. `POST /optimize` — Run layout optimization
|
||||
6. `POST /scene/placements` — Push results to shared scene state
|
||||
7. Frontend auto-updates via polling (no manual refresh needed)
|
||||
|
||||
**Example user requests**:
|
||||
- "Design a PCR lab with robot arm automation, keep it compact"
|
||||
- "Place liquid handler, thermal cycler, and plate sealer. Arm must reach all devices."
|
||||
- "Add a plate hotel, make sure it's close to the liquid handler"
|
||||
|
||||
### 11.4 Running the Demo
|
||||
|
||||
```bash
|
||||
# Start the server
|
||||
uvicorn unilabos.layout_optimizer.server:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
# Open in browser
|
||||
# http://localhost:8000/
|
||||
|
||||
# Use Claude Code with demo_agent.md skill to orchestrate via natural language
|
||||
# The agent will call the API endpoints and push results to /scene/placements
|
||||
# The frontend will automatically update via polling
|
||||
```
|
||||
|
||||
**Demo flow**:
|
||||
1. Open `http://localhost:8000/` in browser
|
||||
2. Frontend loads device catalog and displays 3D scene
|
||||
3. Use Claude Code with `demo_agent.md` skill to send natural language requests
|
||||
4. Agent translates request → intents → constraints → optimization → scene update
|
||||
5. Frontend polls `/scene/placements` every 1s and animates changes
|
||||
6. User can manually add/remove devices or adjust lab size in the UI
|
||||
7. Click "Auto Layout" to re-optimize with current devices
|
||||
|
||||
9
unilabos/layout_optimizer/__init__.py
Normal file
9
unilabos/layout_optimizer/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""Layout Optimizer — AI 实验室布局自动排布。
|
||||
|
||||
独立开发包,无 ROS 依赖。集成阶段合并到 Uni-Lab-OS。
|
||||
"""
|
||||
|
||||
from .models import Constraint, Device, Lab, Opening, Placement
|
||||
from .optimizer import optimize
|
||||
|
||||
__all__ = ["Device", "Lab", "Opening", "Placement", "Constraint", "optimize"]
|
||||
66
unilabos/layout_optimizer/broad_phase.py
Normal file
66
unilabos/layout_optimizer/broad_phase.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""2 轴 sweep-and-prune 宽相碰撞检测。
|
||||
|
||||
对每个设备计算旋转后的 AABB,先沿 x 轴排序并剪枝,
|
||||
再用 y 轴交叠过滤。返回候选碰撞对(索引对列表),
|
||||
供后续 OBB SAT 精确检测使用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .models import Device, Placement
|
||||
|
||||
|
||||
def sweep_and_prune_pairs(
|
||||
devices: list[Device],
|
||||
placements: list[Placement],
|
||||
) -> list[tuple[int, int]]:
|
||||
"""2 轴 sweep-and-prune,返回 AABB 交叠的索引对。
|
||||
|
||||
Args:
|
||||
devices: 设备列表,与 placements 一一对应。
|
||||
placements: 布局位姿列表。
|
||||
|
||||
Returns:
|
||||
候选碰撞对列表,每个元素为 (i, j),
|
||||
i < j,索引对应 placements 原始顺序。
|
||||
"""
|
||||
n = len(devices)
|
||||
if n < 2:
|
||||
return []
|
||||
|
||||
# --- 计算每个设备旋转后的 AABB ---
|
||||
aabbs: list[tuple[float, float, float, float]] = []
|
||||
for dev, pl in zip(devices, placements):
|
||||
hw, hd = pl.rotated_bbox(dev)
|
||||
aabbs.append((pl.x - hw, pl.x + hw, pl.y - hd, pl.y + hd))
|
||||
|
||||
# --- 按 xmin 排序,保留原始索引映射 ---
|
||||
sorted_indices = sorted(range(n), key=lambda k: aabbs[k][0])
|
||||
|
||||
# --- 扫描 x 轴,y 轴过滤 ---
|
||||
candidates: list[tuple[int, int]] = []
|
||||
for si in range(len(sorted_indices)):
|
||||
i = sorted_indices[si]
|
||||
x_min_i, x_max_i, y_min_i, y_max_i = aabbs[i]
|
||||
for sj in range(si + 1, len(sorted_indices)):
|
||||
j = sorted_indices[sj]
|
||||
x_min_j, _x_max_j, y_min_j, y_max_j = aabbs[j]
|
||||
# 由于按 xmin 排序,x_min_j >= x_min_i
|
||||
if x_min_j > x_max_i:
|
||||
break # 后续设备 xmin 更大,不可能与 i 在 x 轴交叠
|
||||
# x 轴交叠确认,检查 y 轴
|
||||
if y_min_i <= y_max_j and y_min_j <= y_max_i:
|
||||
# 保证输出 (min_idx, max_idx) 方便去重和测试
|
||||
pair = (min(i, j), max(i, j))
|
||||
candidates.append(pair)
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
def broad_phase_device_pairs(
|
||||
devices: list[Device],
|
||||
placements: list[Placement],
|
||||
) -> list[tuple[str, str]]:
|
||||
"""返回候选碰撞对的 device_id 字符串元组列表。"""
|
||||
index_pairs = sweep_and_prune_pairs(devices, placements)
|
||||
return [(placements[i].device_id, placements[j].device_id) for i, j in index_pairs]
|
||||
626
unilabos/layout_optimizer/constraints.py
Normal file
626
unilabos/layout_optimizer/constraints.py
Normal file
@@ -0,0 +1,626 @@
|
||||
"""约束体系:硬约束 / 软约束定义与统一评估。
|
||||
|
||||
硬约束违反 → cost = inf(方案直接淘汰)
|
||||
软约束违反 → 加权 penalty 累加到 cost
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .broad_phase import sweep_and_prune_pairs
|
||||
from .models import Constraint, Device, Lab, Placement
|
||||
from .obb import (
|
||||
nearest_point_on_obb,
|
||||
obb_corners,
|
||||
obb_min_distance,
|
||||
obb_penetration_depth,
|
||||
segment_obb_intersection_length,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
|
||||
from .interfaces import CollisionChecker, ReachabilityChecker
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 归一化默认权重 — 1cm距离违规 ≈ 5°角度违规 的惩罚量级
|
||||
DEFAULT_WEIGHT_DISTANCE: float = 100.0 # 1cm → penalty 1.0
|
||||
DEFAULT_WEIGHT_ANGLE: float = 60.0 # 5° → penalty ~1.0
|
||||
|
||||
# 硬约束graduated模式下的惩罚倍数
|
||||
HARD_MULTIPLIER: float = 5.0
|
||||
|
||||
# 优先级等级对应的权重乘数
|
||||
PRIORITY_MULTIPLIERS: dict[str, float] = {
|
||||
"critical": 5.0,
|
||||
"high": 2.0,
|
||||
"normal": 1.0,
|
||||
"low": 0.5,
|
||||
}
|
||||
|
||||
|
||||
def evaluate_constraints(
|
||||
devices: list[Device],
|
||||
placements: list[Placement],
|
||||
lab: Lab,
|
||||
constraints: list[Constraint],
|
||||
collision_checker: CollisionChecker,
|
||||
reachability_checker: ReachabilityChecker | None = None,
|
||||
*,
|
||||
graduated: bool = True,
|
||||
) -> float:
|
||||
"""统一评估所有约束,返回总 cost。
|
||||
|
||||
Args:
|
||||
devices: 设备列表(与 placements 一一对应)
|
||||
placements: 当前布局方案
|
||||
lab: 实验室平面图
|
||||
constraints: 约束规则列表
|
||||
collision_checker: 碰撞检测实例
|
||||
reachability_checker: 可达性检测实例(可选)
|
||||
graduated: True=比例惩罚(DE优化用),False=二值inf(最终pass/fail用)
|
||||
|
||||
Returns:
|
||||
总 cost。硬约束违反在非graduated模式返回 inf,否则为加权 penalty 之和。
|
||||
"""
|
||||
device_map = {d.id: d for d in devices}
|
||||
placement_map = {p.device_id: p for p in placements}
|
||||
|
||||
total_cost = 0.0
|
||||
|
||||
for c in constraints:
|
||||
cost = _evaluate_single(
|
||||
c, device_map, placement_map, lab, collision_checker, reachability_checker,
|
||||
graduated=graduated,
|
||||
)
|
||||
if math.isinf(cost):
|
||||
return math.inf
|
||||
total_cost += cost
|
||||
|
||||
return total_cost
|
||||
|
||||
|
||||
def evaluate_default_hard_constraints(
|
||||
devices: list[Device],
|
||||
placements: list[Placement],
|
||||
lab: Lab,
|
||||
collision_checker: CollisionChecker,
|
||||
*,
|
||||
graduated: bool = True,
|
||||
collision_weight: float = DEFAULT_WEIGHT_DISTANCE * HARD_MULTIPLIER, # 500
|
||||
boundary_weight: float = DEFAULT_WEIGHT_DISTANCE * HARD_MULTIPLIER, # 500
|
||||
) -> float:
|
||||
"""评估默认硬约束(碰撞 + 边界),无需显式声明约束列表。
|
||||
|
||||
始终生效,用于 cost function 的基础检查。
|
||||
|
||||
When graduated=True (default), returns a penalty proportional to the
|
||||
severity of each violation instead of binary inf. This gives DE a
|
||||
smooth gradient so it can fix specific collision pairs instead of
|
||||
discarding near-optimal layouts entirely.
|
||||
|
||||
When graduated=False, uses the legacy binary inf behaviour.
|
||||
"""
|
||||
if not graduated:
|
||||
return _evaluate_hard_binary(devices, placements, lab, collision_checker)
|
||||
|
||||
device_map = {d.id: d for d in devices}
|
||||
cost = 0.0
|
||||
|
||||
# Graduated collision penalty: 2 轴 sweep-and-prune 宽相 + OBB SAT 精确检测
|
||||
candidate_pairs = sweep_and_prune_pairs(devices, placements)
|
||||
for i, j in candidate_pairs:
|
||||
di, dj = device_map[placements[i].device_id], device_map[placements[j].device_id]
|
||||
ci = obb_corners(placements[i].x, placements[i].y,
|
||||
di.bbox[0], di.bbox[1], placements[i].theta)
|
||||
cj = obb_corners(placements[j].x, placements[j].y,
|
||||
dj.bbox[0], dj.bbox[1], placements[j].theta)
|
||||
depth = obb_penetration_depth(ci, cj)
|
||||
if depth > 0:
|
||||
cost += collision_weight * depth
|
||||
|
||||
# Graduated boundary penalty: sum of overshoot distances (rotation-aware)
|
||||
for p in placements:
|
||||
dev = device_map[p.device_id]
|
||||
hw, hd = p.rotated_bbox(dev)
|
||||
# How far each edge exceeds the lab boundary
|
||||
overshoot = 0.0
|
||||
overshoot += max(0.0, hw - p.x) # left wall
|
||||
overshoot += max(0.0, (p.x + hw) - lab.width) # right wall
|
||||
overshoot += max(0.0, hd - p.y) # bottom wall
|
||||
overshoot += max(0.0, (p.y + hd) - lab.depth) # top wall
|
||||
cost += boundary_weight * overshoot
|
||||
|
||||
return cost
|
||||
|
||||
|
||||
def _evaluate_hard_binary(
|
||||
devices: list[Device],
|
||||
placements: list[Placement],
|
||||
lab: Lab,
|
||||
collision_checker: CollisionChecker,
|
||||
) -> float:
|
||||
"""Legacy binary hard-constraint evaluation (inf or 0)."""
|
||||
checker_placements = _to_checker_format(devices, placements)
|
||||
|
||||
collisions = collision_checker.check(checker_placements)
|
||||
if collisions:
|
||||
return math.inf
|
||||
|
||||
if hasattr(collision_checker, "check_bounds"):
|
||||
oob = collision_checker.check_bounds(checker_placements, lab.width, lab.depth)
|
||||
if oob:
|
||||
return math.inf
|
||||
|
||||
return 0.0
|
||||
|
||||
|
||||
def _evaluate_single(
|
||||
constraint: Constraint,
|
||||
device_map: dict[str, Device],
|
||||
placement_map: dict[str, Placement],
|
||||
lab: Lab,
|
||||
collision_checker: CollisionChecker,
|
||||
reachability_checker: ReachabilityChecker | None,
|
||||
*,
|
||||
graduated: bool = True,
|
||||
) -> float:
|
||||
"""评估单条约束规则。
|
||||
|
||||
graduated=True 时硬约束返回比例惩罚(DE用),
|
||||
graduated=False 时硬约束返回 inf(最终 pass/fail)。
|
||||
"""
|
||||
rule = constraint.rule_name
|
||||
params = constraint.params
|
||||
is_hard = constraint.type == "hard"
|
||||
|
||||
effective_weight = constraint.weight
|
||||
|
||||
if rule == "no_collision":
|
||||
checker_placements = _to_checker_format_from_maps(device_map, placement_map)
|
||||
collisions = collision_checker.check(checker_placements)
|
||||
if collisions:
|
||||
if is_hard and not graduated:
|
||||
return math.inf
|
||||
w = effective_weight * (HARD_MULTIPLIER if is_hard else 1.0)
|
||||
return w * len(collisions)
|
||||
return 0.0
|
||||
|
||||
if rule == "within_bounds":
|
||||
checker_placements = _to_checker_format_from_maps(device_map, placement_map)
|
||||
if hasattr(collision_checker, "check_bounds"):
|
||||
oob = collision_checker.check_bounds(
|
||||
checker_placements, lab.width, lab.depth
|
||||
)
|
||||
if oob:
|
||||
if is_hard and not graduated:
|
||||
return math.inf
|
||||
w = effective_weight * (HARD_MULTIPLIER if is_hard else 1.0)
|
||||
return w * len(oob)
|
||||
return 0.0
|
||||
|
||||
if rule == "distance_less_than":
|
||||
a_id, b_id = params["device_a"], params["device_b"]
|
||||
max_dist = params["distance"]
|
||||
da, db = device_map.get(a_id), device_map.get(b_id)
|
||||
pa, pb = placement_map.get(a_id), placement_map.get(b_id)
|
||||
missing_cost = _missing_reference_cost(constraint, placement_map, a_id, b_id)
|
||||
if missing_cost is not None:
|
||||
return missing_cost
|
||||
if da and db:
|
||||
dist = _device_distance_obb(da, pa, db, pb)
|
||||
else:
|
||||
dist = _device_distance_center(pa, pb) or 0.0
|
||||
if dist > max_dist:
|
||||
if is_hard and not graduated:
|
||||
return math.inf
|
||||
w = effective_weight * (HARD_MULTIPLIER if is_hard else 1.0)
|
||||
return w * (dist - max_dist)
|
||||
return 0.0
|
||||
|
||||
if rule == "distance_greater_than":
|
||||
a_id, b_id = params["device_a"], params["device_b"]
|
||||
min_dist = params["distance"]
|
||||
da, db = device_map.get(a_id), device_map.get(b_id)
|
||||
pa, pb = placement_map.get(a_id), placement_map.get(b_id)
|
||||
missing_cost = _missing_reference_cost(constraint, placement_map, a_id, b_id)
|
||||
if missing_cost is not None:
|
||||
return missing_cost
|
||||
if da and db:
|
||||
dist = _device_distance_obb(da, pa, db, pb)
|
||||
else:
|
||||
dist = _device_distance_center(pa, pb) or 0.0
|
||||
if dist < min_dist:
|
||||
if is_hard and not graduated:
|
||||
return math.inf
|
||||
w = effective_weight * (HARD_MULTIPLIER if is_hard else 1.0)
|
||||
return w * (min_dist - dist)
|
||||
return 0.0
|
||||
|
||||
if rule == "minimize_distance":
|
||||
a_id, b_id = params["device_a"], params["device_b"]
|
||||
da, db = device_map.get(a_id), device_map.get(b_id)
|
||||
pa, pb = placement_map.get(a_id), placement_map.get(b_id)
|
||||
missing_cost = _missing_reference_cost(constraint, placement_map, a_id, b_id)
|
||||
if missing_cost is not None:
|
||||
return missing_cost
|
||||
if da and db:
|
||||
dist = _device_distance_obb(da, pa, db, pb)
|
||||
else:
|
||||
dist = _device_distance_center(pa, pb) or 0.0
|
||||
return effective_weight * dist
|
||||
|
||||
if rule == "maximize_distance":
|
||||
a_id, b_id = params["device_a"], params["device_b"]
|
||||
da, db = device_map.get(a_id), device_map.get(b_id)
|
||||
pa, pb = placement_map.get(a_id), placement_map.get(b_id)
|
||||
missing_cost = _missing_reference_cost(constraint, placement_map, a_id, b_id)
|
||||
if missing_cost is not None:
|
||||
return missing_cost
|
||||
if da and db:
|
||||
dist = _device_distance_obb(da, pa, db, pb)
|
||||
else:
|
||||
dist = _device_distance_center(pa, pb) or 0.0
|
||||
max_possible = math.sqrt(lab.width**2 + lab.depth**2)
|
||||
return effective_weight * (max_possible - dist)
|
||||
|
||||
if rule == "min_spacing":
|
||||
min_gap = params.get("min_gap", 0.0)
|
||||
all_placements = list(placement_map.values())
|
||||
total_penalty = 0.0
|
||||
for i in range(len(all_placements)):
|
||||
for j in range(i + 1, len(all_placements)):
|
||||
pi, pj = all_placements[i], all_placements[j]
|
||||
di = device_map.get(pi.device_id)
|
||||
dj = device_map.get(pj.device_id)
|
||||
if di and dj:
|
||||
dist = _device_distance_obb(di, pi, dj, pj)
|
||||
else:
|
||||
dist = _device_distance_center(pi, pj) or 0.0
|
||||
if dist < min_gap:
|
||||
total_penalty += (min_gap - dist)
|
||||
if total_penalty > 0:
|
||||
if is_hard and not graduated:
|
||||
return math.inf
|
||||
w = effective_weight * (HARD_MULTIPLIER if is_hard else 1.0)
|
||||
return w * total_penalty
|
||||
return 0.0
|
||||
|
||||
if rule == "reachability":
|
||||
if reachability_checker is None:
|
||||
return 0.0
|
||||
arm_id = params["arm_id"]
|
||||
target_device_id = params["target_device_id"]
|
||||
arm_p = placement_map.get(arm_id)
|
||||
target_p = placement_map.get(target_device_id)
|
||||
missing_cost = _missing_reference_cost(
|
||||
constraint, placement_map, arm_id, target_device_id,
|
||||
)
|
||||
if missing_cost is not None:
|
||||
return missing_cost
|
||||
arm_dev = device_map.get(arm_id)
|
||||
target_dev = device_map.get(target_device_id)
|
||||
|
||||
# opening surface center → nearest point on arm OBB
|
||||
if arm_dev and target_dev:
|
||||
opening_pt = _opening_surface_center(target_dev, target_p)
|
||||
arm_corners = obb_corners(
|
||||
arm_p.x, arm_p.y, arm_dev.bbox[0], arm_dev.bbox[1], arm_p.theta,
|
||||
)
|
||||
nearest = nearest_point_on_obb(opening_pt[0], opening_pt[1], arm_corners)
|
||||
dist = math.sqrt((opening_pt[0] - nearest[0])**2 + (opening_pt[1] - nearest[1])**2)
|
||||
else:
|
||||
opening_pt = (target_p.x, target_p.y)
|
||||
nearest = (arm_p.x, arm_p.y)
|
||||
dist = _device_distance_center(arm_p, target_p) or 0.0
|
||||
|
||||
# 交叉惩罚始终计算(soft, 不依赖可达性结果)
|
||||
crossing_cost = _crossing_penalty(
|
||||
opening_pt, nearest,
|
||||
arm_id, target_device_id,
|
||||
device_map, placement_map,
|
||||
)
|
||||
|
||||
arm_pose = {"x": arm_p.x, "y": arm_p.y, "theta": arm_p.theta}
|
||||
target_point = {"x": target_p.x, "y": target_p.y, "z": 0.0}
|
||||
target_point["_obb_dist"] = dist
|
||||
if not reachability_checker.is_reachable(arm_id, arm_pose, target_point):
|
||||
if is_hard and not graduated:
|
||||
return math.inf
|
||||
# Graduated: overshoot penalty + crossing cost
|
||||
max_reach = reachability_checker.arm_reach.get(arm_id, 2.0)
|
||||
overshoot = max(0.0, dist - max_reach)
|
||||
w = effective_weight * (HARD_MULTIPLIER if is_hard else 1.0)
|
||||
return w * overshoot * 10.0 + crossing_cost
|
||||
|
||||
return crossing_cost
|
||||
|
||||
if rule == "prefer_aligned":
|
||||
alignment_cost = sum(
|
||||
(1 - math.cos(4 * p.theta)) / 2 for p in placement_map.values()
|
||||
)
|
||||
if is_hard:
|
||||
if not graduated:
|
||||
return math.inf if alignment_cost > 1e-6 else 0.0
|
||||
return HARD_MULTIPLIER * effective_weight * alignment_cost
|
||||
return effective_weight * alignment_cost
|
||||
|
||||
if rule == "prefer_seeder_orientation":
|
||||
target_thetas = params.get("target_thetas", {})
|
||||
cost = 0.0
|
||||
for dev_id, target in target_thetas.items():
|
||||
p = placement_map.get(dev_id)
|
||||
if p is None:
|
||||
continue
|
||||
# Circular distance: (1 - cos(diff)) / 2 gives 0..1 range
|
||||
diff = p.theta - target
|
||||
cost += (1 - math.cos(diff)) / 2
|
||||
return effective_weight * cost
|
||||
|
||||
if rule == "prefer_orientation_mode":
|
||||
mode = params.get("mode", "outward")
|
||||
center_x = lab.width / 2
|
||||
center_y = lab.depth / 2
|
||||
cost = 0.0
|
||||
for dev_id, p in placement_map.items():
|
||||
dev = device_map.get(dev_id)
|
||||
if dev is None:
|
||||
continue
|
||||
target = _desired_theta(
|
||||
p.x, p.y, center_x, center_y, dev, mode,
|
||||
)
|
||||
if target is None:
|
||||
continue
|
||||
diff = p.theta - target
|
||||
cost += (1 - math.cos(diff)) / 2
|
||||
return effective_weight * cost
|
||||
|
||||
# 未知约束类型,忽略
|
||||
return 0.0
|
||||
|
||||
|
||||
def _desired_theta(
|
||||
x: float, y: float,
|
||||
center_x: float, center_y: float,
|
||||
device: Device, mode: str,
|
||||
) -> float | None:
|
||||
"""Compute desired theta for outward/inward facing at the given position."""
|
||||
dx = x - center_x
|
||||
dy = y - center_y
|
||||
if abs(dx) < 1e-9 and abs(dy) < 1e-9:
|
||||
return None # At center, no preferred direction
|
||||
angle_to_device = math.atan2(dy, dx)
|
||||
front = device.openings[0].direction if device.openings else (0.0, -1.0)
|
||||
front_angle = math.atan2(front[1], front[0])
|
||||
if mode == "outward":
|
||||
target = angle_to_device
|
||||
elif mode == "inward":
|
||||
target = angle_to_device + math.pi
|
||||
else:
|
||||
return None
|
||||
return (target - front_angle) % (2 * math.pi)
|
||||
|
||||
|
||||
def _device_distance_center(a: Placement | None, b: Placement | None) -> float | None:
|
||||
"""计算两设备中心的欧几里得距离(后备方法)。"""
|
||||
if a is None or b is None:
|
||||
return None
|
||||
return math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2)
|
||||
|
||||
|
||||
def _device_distance_obb(
|
||||
device_a: Device, placement_a: Placement,
|
||||
device_b: Device, placement_b: Placement,
|
||||
) -> float:
|
||||
"""Minimum edge-to-edge distance between two devices using OBB."""
|
||||
corners_a = obb_corners(
|
||||
placement_a.x, placement_a.y,
|
||||
device_a.bbox[0], device_a.bbox[1],
|
||||
placement_a.theta,
|
||||
)
|
||||
corners_b = obb_corners(
|
||||
placement_b.x, placement_b.y,
|
||||
device_b.bbox[0], device_b.bbox[1],
|
||||
placement_b.theta,
|
||||
)
|
||||
return obb_min_distance(corners_a, corners_b)
|
||||
|
||||
|
||||
def _to_checker_format(
|
||||
devices: list[Device], placements: list[Placement]
|
||||
) -> list[dict]:
|
||||
"""转换为 CollisionChecker.check() 接受的格式。"""
|
||||
device_map = {d.id: d for d in devices}
|
||||
result = []
|
||||
for p in placements:
|
||||
dev = device_map.get(p.device_id)
|
||||
if dev is None:
|
||||
continue
|
||||
result.append({"id": p.device_id, "bbox": dev.bbox, "pos": (p.x, p.y, p.theta)})
|
||||
return result
|
||||
|
||||
|
||||
def _to_checker_format_from_maps(
|
||||
device_map: dict[str, Device], placement_map: dict[str, Placement]
|
||||
) -> list[dict]:
|
||||
"""从 map 转换为 CollisionChecker.check() 接受的格式。"""
|
||||
result = []
|
||||
for dev_id, p in placement_map.items():
|
||||
dev = device_map.get(dev_id)
|
||||
if dev is None:
|
||||
continue
|
||||
result.append({"id": dev_id, "bbox": dev.bbox, "pos": (p.x, p.y, p.theta)})
|
||||
return result
|
||||
|
||||
|
||||
def _opening_surface_center(
|
||||
device: Device, placement: Placement,
|
||||
) -> tuple[float, float]:
|
||||
"""Return the world-space center of the device's opening surface.
|
||||
|
||||
Computes where the opening direction intersects the device's bbox boundary,
|
||||
then transforms to world coordinates. For a device facing away from the arm,
|
||||
this point is on the far side — making the distance to the arm larger,
|
||||
which naturally penalizes wrong orientation.
|
||||
"""
|
||||
front = device.openings[0].direction if device.openings else (0.0, -1.0)
|
||||
dx, dy = front
|
||||
w, h = device.bbox
|
||||
|
||||
# Scale factor to reach bbox edge in the opening direction
|
||||
scales = []
|
||||
if abs(dx) > 1e-9:
|
||||
scales.append((w / 2) / abs(dx))
|
||||
if abs(dy) > 1e-9:
|
||||
scales.append((h / 2) / abs(dy))
|
||||
scale = min(scales) if scales else 0.0
|
||||
|
||||
# Opening center in local frame
|
||||
local_x = dx * scale
|
||||
local_y = dy * scale
|
||||
|
||||
# Rotate to world frame and translate
|
||||
cos_t = math.cos(placement.theta)
|
||||
sin_t = math.sin(placement.theta)
|
||||
world_x = placement.x + local_x * cos_t - local_y * sin_t
|
||||
world_y = placement.y + local_x * sin_t + local_y * cos_t
|
||||
return (world_x, world_y)
|
||||
|
||||
|
||||
def evaluate_default_hard_constraints_breakdown(
|
||||
devices: list[Device],
|
||||
placements: list[Placement],
|
||||
lab: Lab,
|
||||
collision_checker: CollisionChecker,
|
||||
*,
|
||||
collision_weight: float = DEFAULT_WEIGHT_DISTANCE * HARD_MULTIPLIER,
|
||||
boundary_weight: float = DEFAULT_WEIGHT_DISTANCE * HARD_MULTIPLIER,
|
||||
) -> dict[str, float]:
|
||||
"""与 evaluate_default_hard_constraints 逻辑相同,但返回分项明细。"""
|
||||
device_map = {d.id: d for d in devices}
|
||||
collision_cost = 0.0
|
||||
boundary_cost = 0.0
|
||||
|
||||
candidate_pairs = sweep_and_prune_pairs(devices, placements)
|
||||
for i, j in candidate_pairs:
|
||||
di, dj = device_map[placements[i].device_id], device_map[placements[j].device_id]
|
||||
ci = obb_corners(placements[i].x, placements[i].y,
|
||||
di.bbox[0], di.bbox[1], placements[i].theta)
|
||||
cj = obb_corners(placements[j].x, placements[j].y,
|
||||
dj.bbox[0], dj.bbox[1], placements[j].theta)
|
||||
depth = obb_penetration_depth(ci, cj)
|
||||
if depth > 0:
|
||||
collision_cost += collision_weight * depth
|
||||
|
||||
for p in placements:
|
||||
dev = device_map[p.device_id]
|
||||
hw, hd = p.rotated_bbox(dev)
|
||||
overshoot = 0.0
|
||||
overshoot += max(0.0, hw - p.x)
|
||||
overshoot += max(0.0, (p.x + hw) - lab.width)
|
||||
overshoot += max(0.0, hd - p.y)
|
||||
overshoot += max(0.0, (p.y + hd) - lab.depth)
|
||||
boundary_cost += boundary_weight * overshoot
|
||||
|
||||
return {
|
||||
"collision": collision_cost,
|
||||
"boundary": boundary_cost,
|
||||
"total": collision_cost + boundary_cost,
|
||||
"collision_weight": collision_weight,
|
||||
"boundary_weight": boundary_weight,
|
||||
}
|
||||
|
||||
|
||||
def evaluate_constraints_breakdown(
|
||||
devices: list[Device],
|
||||
placements: list[Placement],
|
||||
lab: Lab,
|
||||
constraints: list[Constraint],
|
||||
collision_checker: CollisionChecker,
|
||||
reachability_checker: ReachabilityChecker | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""与 evaluate_constraints 逻辑相同,但返回每条约束的分项明细。"""
|
||||
device_map = {d.id: d for d in devices}
|
||||
placement_map = {p.device_id: p for p in placements}
|
||||
|
||||
results = []
|
||||
for c in constraints:
|
||||
cost = _evaluate_single(
|
||||
c, device_map, placement_map, lab, collision_checker, reachability_checker,
|
||||
graduated=True,
|
||||
)
|
||||
results.append({
|
||||
"name": _constraint_display_name(c),
|
||||
"rule": c.rule_name,
|
||||
"type": c.type,
|
||||
"cost": cost,
|
||||
"weight": c.weight,
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def _missing_reference_cost(
|
||||
constraint: Constraint,
|
||||
placement_map: dict[str, Placement],
|
||||
*device_ids: str,
|
||||
) -> float | None:
|
||||
"""当约束引用不存在的设备时返回对应 cost。"""
|
||||
missing = sorted({device_id for device_id in device_ids if device_id not in placement_map})
|
||||
if not missing:
|
||||
return None
|
||||
|
||||
logger.warning(
|
||||
"Constraint %s references missing device IDs: %s",
|
||||
constraint.rule_name,
|
||||
", ".join(missing),
|
||||
)
|
||||
if constraint.type == "hard":
|
||||
return math.inf
|
||||
return 0.0
|
||||
|
||||
|
||||
def _constraint_display_name(c: Constraint) -> str:
|
||||
"""为约束生成可读的显示名称。"""
|
||||
params = c.params
|
||||
if c.rule_name in (
|
||||
"distance_less_than", "distance_greater_than",
|
||||
"minimize_distance", "maximize_distance",
|
||||
):
|
||||
return f"{c.rule_name}({params.get('device_a', '?')}, {params.get('device_b', '?')})"
|
||||
if c.rule_name == "reachability":
|
||||
return f"reachability({params.get('arm_id', '?')}, {params.get('target_device_id', '?')})"
|
||||
if c.rule_name == "min_spacing":
|
||||
return f"min_spacing(gap={params.get('min_gap', '?')})"
|
||||
if c.rule_name == "prefer_orientation_mode":
|
||||
return f"prefer_orientation_mode({params.get('mode', '?')})"
|
||||
return c.rule_name
|
||||
|
||||
|
||||
def _crossing_penalty(
|
||||
opening_pt: tuple[float, float],
|
||||
arm_nearest_pt: tuple[float, float],
|
||||
arm_id: str,
|
||||
target_id: str,
|
||||
device_map: dict[str, Device],
|
||||
placement_map: dict[str, Placement],
|
||||
) -> float:
|
||||
"""交叉惩罚:其他设备 OBB 遮挡 opening→arm 路径的长度加权 penalty。
|
||||
|
||||
Soft penalty,权重 = DEFAULT_WEIGHT_DISTANCE * 穿过各遮挡设备 OBB 的线段长度之和。
|
||||
始终生效(不论可达性是否通过),为 DE 提供清晰的梯度信号。
|
||||
"""
|
||||
cost = 0.0
|
||||
for dev_id, p in placement_map.items():
|
||||
if dev_id == arm_id or dev_id == target_id:
|
||||
continue
|
||||
dev = device_map.get(dev_id)
|
||||
if dev is None:
|
||||
continue
|
||||
corners = obb_corners(p.x, p.y, dev.bbox[0], dev.bbox[1], p.theta)
|
||||
crossing_len = segment_obb_intersection_length(opening_pt, arm_nearest_pt, corners)
|
||||
cost += DEFAULT_WEIGHT_DISTANCE * crossing_len
|
||||
return cost
|
||||
302
unilabos/layout_optimizer/device_catalog.py
Normal file
302
unilabos/layout_optimizer/device_catalog.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""双源设备目录:从 uni-lab-assets 和 Uni-Lab-OS registry 加载设备。
|
||||
|
||||
数据流:
|
||||
footprints.json (离线提取) + data.json (资产树) + registry device_mesh dirs
|
||||
→ merge → Device 列表
|
||||
|
||||
footprints.json 由 extract_footprints.py 生成,包含碰撞包围盒、开口方向等。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from .models import Device, Opening
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 默认路径(相对于本文件)
|
||||
_THIS_DIR = Path(__file__).resolve().parent
|
||||
_DEFAULT_FOOTPRINTS = _THIS_DIR / "footprints.json"
|
||||
|
||||
# 手动后备尺寸(trimesh 不可用时)
|
||||
KNOWN_SIZES: dict[str, tuple[float, float]] = {
|
||||
"elite_cs66_arm": (0.20, 0.20),
|
||||
"elite_cs612_arm": (0.20, 0.20),
|
||||
"ot2": (0.62, 0.50),
|
||||
"agilent_bravo": (0.80, 0.65),
|
||||
"thermo_orbitor_rs2": (0.45, 0.55),
|
||||
"hplc_station": (0.60, 0.50),
|
||||
"1_3m_hamilton_table": (1.30, 0.75),
|
||||
}
|
||||
|
||||
DEFAULT_BBOX: tuple[float, float] = (0.6, 0.4)
|
||||
|
||||
# ---------- footprints.json 加载 ----------
|
||||
|
||||
_footprints_cache: dict[str, dict] | None = None
|
||||
|
||||
|
||||
def load_footprints(path: str | Path = _DEFAULT_FOOTPRINTS) -> dict[str, dict]:
|
||||
"""加载 footprints.json 并缓存。"""
|
||||
global _footprints_cache
|
||||
if _footprints_cache is not None:
|
||||
return _footprints_cache
|
||||
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
logger.warning("footprints.json not found at %s", p)
|
||||
_footprints_cache = {}
|
||||
return _footprints_cache
|
||||
|
||||
with open(p) as f:
|
||||
_footprints_cache = json.load(f)
|
||||
logger.info("Loaded %d footprints from %s", len(_footprints_cache), p)
|
||||
return _footprints_cache
|
||||
|
||||
|
||||
def reset_footprints_cache() -> None:
|
||||
"""清除缓存(测试用)。"""
|
||||
global _footprints_cache
|
||||
_footprints_cache = None
|
||||
|
||||
|
||||
# ---------- 从 footprints 构建 Device ----------
|
||||
|
||||
|
||||
def _footprint_to_device(
|
||||
device_id: str,
|
||||
fp: dict,
|
||||
name: str = "",
|
||||
models_url_prefix: str = "/models",
|
||||
) -> Device:
|
||||
"""从 footprints.json 条目创建 Device。"""
|
||||
bbox = tuple(fp.get("bbox", DEFAULT_BBOX))
|
||||
openings = [
|
||||
Opening(direction=tuple(o["direction"]), label=o.get("label", ""))
|
||||
for o in fp.get("openings", [])
|
||||
]
|
||||
|
||||
model_file = fp.get("model_file", "")
|
||||
model_path = f"{models_url_prefix}/{device_id}/{model_file}" if model_file else ""
|
||||
model_type = fp.get("model_type", "")
|
||||
|
||||
thumb_file = fp.get("thumbnail_file", "")
|
||||
thumbnail_url = f"{models_url_prefix}/{device_id}/{thumb_file}" if thumb_file else ""
|
||||
|
||||
return Device(
|
||||
id=device_id,
|
||||
name=name or device_id.replace("_", " ").title(),
|
||||
bbox=bbox,
|
||||
device_type="articulation" if "robot" in device_id or "arm" in device_id or "flex" in device_id else "static",
|
||||
height=fp.get("height", 0.4),
|
||||
origin_offset=tuple(fp.get("origin_offset", [0.0, 0.0])),
|
||||
openings=openings,
|
||||
source=fp.get("source", "manual"),
|
||||
model_path=model_path,
|
||||
model_type=model_type,
|
||||
thumbnail_url=thumbnail_url,
|
||||
)
|
||||
|
||||
|
||||
# ---------- 从 data.json 加载 ----------
|
||||
|
||||
|
||||
def load_devices_from_assets(
|
||||
data_json_path: str | Path,
|
||||
footprints: dict[str, dict] | None = None,
|
||||
models_url_prefix: str = "/models",
|
||||
) -> list[Device]:
|
||||
"""从 uni-lab-assets 的 data.json 加载设备列表。
|
||||
|
||||
如果设备在 footprints 中有条目,使用真实尺寸;否则使用默认值。
|
||||
"""
|
||||
path = Path(data_json_path)
|
||||
if not path.exists():
|
||||
logger.warning("data.json not found at %s, returning empty list", path)
|
||||
return []
|
||||
|
||||
if footprints is None:
|
||||
footprints = load_footprints()
|
||||
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
|
||||
devices: list[Device] = []
|
||||
_flatten_tree(data, devices, footprints, models_url_prefix)
|
||||
return devices
|
||||
|
||||
|
||||
def _flatten_tree(
|
||||
nodes: list[dict],
|
||||
result: list[Device],
|
||||
footprints: dict[str, dict],
|
||||
models_url_prefix: str,
|
||||
) -> None:
|
||||
"""递归遍历树形结构,提取叶节点为 Device。"""
|
||||
for node in nodes:
|
||||
if "children" in node:
|
||||
_flatten_tree(node["children"], result, footprints, models_url_prefix)
|
||||
elif "id" in node:
|
||||
device_id = node["id"]
|
||||
name = node.get("label", device_id)
|
||||
|
||||
if device_id in footprints:
|
||||
dev = _footprint_to_device(
|
||||
device_id, footprints[device_id], name, models_url_prefix
|
||||
)
|
||||
else:
|
||||
bbox = KNOWN_SIZES.get(device_id, DEFAULT_BBOX)
|
||||
dev = Device(id=device_id, name=name, bbox=bbox, source="assets")
|
||||
|
||||
result.append(dev)
|
||||
|
||||
|
||||
# ---------- 从 registry 加载 ----------
|
||||
|
||||
|
||||
def load_devices_from_registry(
|
||||
device_mesh_dir: str | Path,
|
||||
footprints: dict[str, dict] | None = None,
|
||||
models_url_prefix: str = "/models",
|
||||
) -> list[Device]:
|
||||
"""从 Uni-Lab-OS device_mesh/devices/ 加载 registry 设备。"""
|
||||
d = Path(device_mesh_dir)
|
||||
if not d.exists():
|
||||
logger.warning("Registry dir not found at %s", d)
|
||||
return []
|
||||
|
||||
if footprints is None:
|
||||
footprints = load_footprints()
|
||||
|
||||
devices: list[Device] = []
|
||||
for entry in sorted(d.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
device_id = entry.name
|
||||
if device_id in footprints:
|
||||
dev = _footprint_to_device(
|
||||
device_id, footprints[device_id], models_url_prefix=models_url_prefix
|
||||
)
|
||||
dev.source = "registry"
|
||||
else:
|
||||
bbox = KNOWN_SIZES.get(device_id, DEFAULT_BBOX)
|
||||
dev = Device(id=device_id, name=device_id.replace("_", " ").title(), bbox=bbox, source="registry")
|
||||
devices.append(dev)
|
||||
|
||||
return devices
|
||||
|
||||
|
||||
# ---------- 合并与去重 ----------
|
||||
|
||||
|
||||
def merge_device_lists(
|
||||
registry_devices: list[Device],
|
||||
asset_devices: list[Device],
|
||||
) -> list[Device]:
|
||||
"""合并双源设备列表,registry 优先。
|
||||
|
||||
对于同时存在于两个源的设备,使用 registry 条目的元数据,
|
||||
但优先使用 assets 的 3D 模型路径和缩略图。
|
||||
"""
|
||||
merged: dict[str, Device] = {}
|
||||
|
||||
for dev in asset_devices:
|
||||
merged[dev.id] = dev
|
||||
|
||||
for dev in registry_devices:
|
||||
if dev.id in merged:
|
||||
# registry 元数据优先,但保留 assets 的模型/缩略图
|
||||
asset_dev = merged[dev.id]
|
||||
dev.model_path = dev.model_path or asset_dev.model_path
|
||||
dev.model_type = dev.model_type or asset_dev.model_type
|
||||
dev.thumbnail_url = dev.thumbnail_url or asset_dev.thumbnail_url
|
||||
if dev.bbox == DEFAULT_BBOX and asset_dev.bbox != DEFAULT_BBOX:
|
||||
dev.bbox = asset_dev.bbox
|
||||
dev.height = asset_dev.height
|
||||
dev.origin_offset = asset_dev.origin_offset
|
||||
dev.openings = asset_dev.openings
|
||||
dev.source = "registry"
|
||||
merged[dev.id] = dev
|
||||
|
||||
return list(merged.values())
|
||||
|
||||
|
||||
# ---------- 统一解析器 ----------
|
||||
|
||||
|
||||
def resolve_device(
|
||||
device_id: str,
|
||||
footprints: dict[str, dict] | None = None,
|
||||
models_url_prefix: str = "/models",
|
||||
) -> Device | None:
|
||||
"""按 ID 查找单个设备。先查 footprints,再查 KNOWN_SIZES。"""
|
||||
if footprints is None:
|
||||
footprints = load_footprints()
|
||||
|
||||
if device_id in footprints:
|
||||
return _footprint_to_device(
|
||||
device_id, footprints[device_id], models_url_prefix=models_url_prefix
|
||||
)
|
||||
|
||||
if device_id in KNOWN_SIZES:
|
||||
bbox = KNOWN_SIZES[device_id]
|
||||
return Device(id=device_id, name=device_id.replace("_", " ").title(), bbox=bbox, source="manual")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ---------- 向后兼容 ----------
|
||||
|
||||
|
||||
def create_devices_from_list(
|
||||
device_specs: list[dict],
|
||||
) -> list[Device]:
|
||||
"""从 API 请求中的设备列表创建 Device 对象(向后兼容)。
|
||||
|
||||
Args:
|
||||
device_specs: [{"id": str, "name": str, "size": [w, d], "uuid": str}, ...]
|
||||
size 可选,缺失时使用 footprints 或默认值。
|
||||
"""
|
||||
footprints = load_footprints()
|
||||
devices = []
|
||||
catalog_counts = Counter(spec["id"] for spec in device_specs)
|
||||
catalog_seen: Counter[str] = Counter()
|
||||
|
||||
for spec in device_specs:
|
||||
catalog_id = spec["id"]
|
||||
catalog_seen[catalog_id] += 1
|
||||
instance_idx = catalog_seen[catalog_id]
|
||||
if catalog_counts[catalog_id] > 1 and instance_idx > 1:
|
||||
dev_id = f"{catalog_id}#{instance_idx}"
|
||||
else:
|
||||
dev_id = catalog_id
|
||||
size = spec.get("size")
|
||||
if size:
|
||||
bbox = (float(size[0]), float(size[1]))
|
||||
elif catalog_id in footprints:
|
||||
bbox = tuple(footprints[catalog_id].get("bbox", DEFAULT_BBOX))
|
||||
else:
|
||||
bbox = KNOWN_SIZES.get(catalog_id, DEFAULT_BBOX)
|
||||
|
||||
openings = []
|
||||
if catalog_id in footprints:
|
||||
openings = [
|
||||
Opening(direction=tuple(o["direction"]), label=o.get("label", ""))
|
||||
for o in footprints[catalog_id].get("openings", [])
|
||||
]
|
||||
|
||||
devices.append(
|
||||
Device(
|
||||
id=dev_id,
|
||||
name=spec.get("name", catalog_id),
|
||||
bbox=bbox,
|
||||
device_type=spec.get("device_type", "static"),
|
||||
openings=openings,
|
||||
uuid=spec.get("uuid", ""),
|
||||
)
|
||||
)
|
||||
return devices
|
||||
559
unilabos/layout_optimizer/extract_footprints.py
Normal file
559
unilabos/layout_optimizer/extract_footprints.py
Normal file
@@ -0,0 +1,559 @@
|
||||
"""从 STL/GLB 网格提取设备足迹(碰撞包围盒)。
|
||||
|
||||
运行方式:
|
||||
conda activate phase3
|
||||
python -m layout_optimizer.extract_footprints
|
||||
|
||||
输出 footprints.json 供 device_catalog.py 和 2D 规划器使用。
|
||||
|
||||
GLB root node rotation:
|
||||
每个设备的 GLB 文件包含根节点旋转四元数,定义 STL 原生坐标到 glTF Y-up
|
||||
约定的轴映射。extract_one_device() 读取 GLB JSON,提取旋转矩阵,
|
||||
应用到 STL 包围盒后按 glTF 约定提取 2D 足迹 (X=width, Z=depth, Y=height)。
|
||||
GLB scale 不应用——STL 文件已是米制坐标。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 测试设备的开口方向(手动标注)
|
||||
# direction 为设备局部坐标系中的单位向量,[0, -1] 表示设备正前方
|
||||
MANUAL_OPENINGS: dict[str, list[dict]] = {
|
||||
"agilent_bravo": [{"direction": [0, -1], "label": "front_plate_slot"}],
|
||||
"opentrons_liquid_handler": [{"direction": [0, -1], "label": "front_deck"}],
|
||||
"opentrons_flex": [{"direction": [0, -1], "label": "front_deck"}],
|
||||
"thermo_orbitor_rs2_hotel": [{"direction": [0, -1], "label": "front_door"}],
|
||||
"hamilton_star": [{"direction": [0, -1], "label": "front_deck"}],
|
||||
"tecan_spark_plate_reader": [{"direction": [0, -1], "label": "front_slot"}],
|
||||
"highres_bio_plate_hotel_12": [{"direction": [0, -1], "label": "front_shelf"}],
|
||||
"beckman_coulter_orbital_shaker_alp": [],
|
||||
"liconic_str44_incubator": [{"direction": [0, -1], "label": "front_door"}],
|
||||
"elite_robot": [], # 机械臂,无开口
|
||||
}
|
||||
|
||||
# 手动尺寸后备(trimesh 提取失败时使用)
|
||||
FALLBACK_SIZES: dict[str, tuple[float, float, float]] = {
|
||||
"elite_robot": (0.20, 0.20, 0.10),
|
||||
"elite_cs66_arm": (0.20, 0.20, 0.10),
|
||||
"elite_cs612_arm": (0.20, 0.20, 0.10),
|
||||
}
|
||||
|
||||
|
||||
def extract_openings_from_xacro(
|
||||
xacro_path: Path,
|
||||
bbox_center_xy: tuple[float, float],
|
||||
bbox_size_xy: tuple[float, float],
|
||||
) -> list[dict]:
|
||||
"""从 XACRO 文件自动提取设备开口方向。
|
||||
|
||||
解析 fixed joint 中包含 "socket" 的关节,计算其 XY 质心,与包围盒中心比较,
|
||||
映射到最近的基本方向。
|
||||
|
||||
Args:
|
||||
xacro_path: modal.xacro 文件路径
|
||||
bbox_center_xy: 包围盒 XY 中心 (cx, cy)
|
||||
bbox_size_xy: 包围盒 XY 尺寸 (w, d)
|
||||
|
||||
Returns:
|
||||
[{"direction": [dx, dy], "label": "auto_xacro"}] 或 []
|
||||
"""
|
||||
# --- 方法1: ElementTree 解析(忽略 xacro 命名空间) ---
|
||||
socket_positions: list[tuple[float, float]] = []
|
||||
|
||||
try:
|
||||
xacro_text = xacro_path.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
# 去掉 xacro 命名空间前缀,避免 ElementTree 解析失败
|
||||
xacro_text_clean = re.sub(r'\bxacro:', '', xacro_text)
|
||||
|
||||
root = ET.fromstring(xacro_text_clean)
|
||||
|
||||
for joint in root.iter("joint"):
|
||||
joint_name = joint.get("name", "")
|
||||
joint_type = joint.get("type", "")
|
||||
if "socket" not in joint_name.lower():
|
||||
continue
|
||||
if joint_type != "fixed":
|
||||
continue
|
||||
origin = joint.find("origin")
|
||||
if origin is None:
|
||||
continue
|
||||
xyz_str = origin.get("xyz", "")
|
||||
if not xyz_str:
|
||||
continue
|
||||
parts = xyz_str.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
try:
|
||||
x = float(parts[0])
|
||||
y = float(parts[1])
|
||||
socket_positions.append((x, y))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
except ET.ParseError as e:
|
||||
logger.debug("ElementTree parse error for %s: %s — falling back to regex", xacro_path, e)
|
||||
|
||||
# --- 方法2: 正则表达式后备(当 ElementTree 失败或无结果时) ---
|
||||
if not socket_positions:
|
||||
try:
|
||||
xacro_text = xacro_path.read_text(encoding="utf-8", errors="replace")
|
||||
# 匹配包含 "socket" 的 joint 块,提取 origin xyz
|
||||
joint_blocks = re.findall(
|
||||
r'<joint\s[^>]*name=["\'][^"\']*socket[^"\']*["\'][^>]*>.*?</joint>',
|
||||
xacro_text,
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
for block in joint_blocks:
|
||||
# 只处理 fixed 类型
|
||||
if 'type="fixed"' not in block and "type='fixed'" not in block:
|
||||
continue
|
||||
xyz_match = re.search(r'<origin[^>]*xyz=["\']([^"\']+)["\']', block)
|
||||
if not xyz_match:
|
||||
continue
|
||||
parts = xyz_match.group(1).split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
try:
|
||||
x = float(parts[0])
|
||||
y = float(parts[1])
|
||||
socket_positions.append((x, y))
|
||||
except ValueError:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug("Regex fallback also failed for %s: %s", xacro_path, e)
|
||||
|
||||
if not socket_positions:
|
||||
return []
|
||||
|
||||
# 计算 socket XY 质心
|
||||
cx_sock = sum(p[0] for p in socket_positions) / len(socket_positions)
|
||||
cy_sock = sum(p[1] for p in socket_positions) / len(socket_positions)
|
||||
|
||||
# 方向向量:从包围盒中心指向 socket 质心
|
||||
dx = cx_sock - bbox_center_xy[0]
|
||||
dy = cy_sock - bbox_center_xy[1]
|
||||
|
||||
# 如果 socket 质心非常靠近包围盒中心(<5% 尺寸),判断为顶部装载
|
||||
threshold = 0.05 * max(bbox_size_xy[0], bbox_size_xy[1], 1e-6)
|
||||
if math.hypot(dx, dy) < threshold:
|
||||
logger.debug(
|
||||
"%s: socket centroid too close to bbox center (dist=%.4f, threshold=%.4f) → top-loading",
|
||||
xacro_path.parent.name,
|
||||
math.hypot(dx, dy),
|
||||
threshold,
|
||||
)
|
||||
return []
|
||||
|
||||
# 映射到最近基本方向
|
||||
# socket 质心指示交互区在设备哪一侧,而 opening direction 是从该面
|
||||
# 向外的法线方向(与质心偏移同向),这里的 dx/dy 已经是从包围盒中心
|
||||
# 指向 socket 区域的方向,即 opening 朝外的方向
|
||||
# 注意:在 uni-lab-assets 中,大多数设备 front 在 Y=0 而 body 在 -Y,
|
||||
# 所以 socket 集中在 +Y 侧(靠近 Y=0 前端),bbox 中心在 -Y/2。
|
||||
# 方向 center→socket = +Y,但 "opening faces front" 在手动标注中
|
||||
# 写作 [0, -1](法线向外=向操作者方向)。
|
||||
# 因此需要取反:opening direction = -(center→socket)
|
||||
if abs(dx) >= abs(dy):
|
||||
cardinal = [-1, 0] if dx > 0 else [1, 0]
|
||||
else:
|
||||
cardinal = [0, -1] if dy > 0 else [0, 1]
|
||||
|
||||
logger.debug(
|
||||
"%s: %d socket joints → centroid=(%.3f, %.3f) dir=%s",
|
||||
xacro_path.parent.name,
|
||||
len(socket_positions),
|
||||
cx_sock,
|
||||
cy_sock,
|
||||
cardinal,
|
||||
)
|
||||
return [{"direction": cardinal, "label": "auto_xacro"}]
|
||||
|
||||
|
||||
def _find_mesh_files(device_dir: Path) -> list[Path]:
|
||||
"""查找设备目录中的所有 STL/GLB 网格文件。"""
|
||||
mesh_files: list[Path] = []
|
||||
meshes_dir = device_dir / "meshes"
|
||||
if not meshes_dir.exists():
|
||||
return mesh_files
|
||||
|
||||
# uni-lab-assets 结构: meshes/*.stl, meshes/*.glb
|
||||
for f in meshes_dir.iterdir():
|
||||
if f.suffix.lower() in (".stl", ".glb"):
|
||||
mesh_files.append(f)
|
||||
|
||||
# registry 结构: meshes/<variant>/collision/*.stl
|
||||
if not mesh_files:
|
||||
for variant_dir in meshes_dir.iterdir():
|
||||
if variant_dir.is_dir():
|
||||
collision_dir = variant_dir / "collision"
|
||||
if collision_dir.exists():
|
||||
for f in collision_dir.iterdir():
|
||||
if f.suffix.lower() == ".stl":
|
||||
mesh_files.append(f)
|
||||
if mesh_files:
|
||||
break # 使用找到的第一个变体
|
||||
|
||||
return sorted(mesh_files)
|
||||
|
||||
|
||||
def _find_best_model_file(device_dir: Path) -> tuple[str, str]:
|
||||
"""找到最佳可展示的模型文件。优先 GLB > STL。
|
||||
|
||||
Returns:
|
||||
(relative_path, model_type) e.g. ("meshes/0_base.glb", "gltf")
|
||||
"""
|
||||
meshes_dir = device_dir / "meshes"
|
||||
if not meshes_dir.exists():
|
||||
return "", ""
|
||||
|
||||
glbs = sorted(meshes_dir.glob("*.glb"))
|
||||
if glbs:
|
||||
return f"meshes/{glbs[0].name}", "gltf"
|
||||
|
||||
stls = sorted(f for f in meshes_dir.glob("*.stl") if f.suffix == ".stl")
|
||||
if not stls:
|
||||
stls = sorted(f for f in meshes_dir.glob("*.STL"))
|
||||
if stls:
|
||||
return f"meshes/{stls[0].name}", "stl"
|
||||
|
||||
return "", ""
|
||||
|
||||
|
||||
def _find_thumbnail(device_dir: Path) -> str:
|
||||
"""查找设备目录中的第一个 PNG 缩略图。"""
|
||||
pngs = sorted(device_dir.glob("*.png"))
|
||||
if pngs:
|
||||
return pngs[0].name
|
||||
return ""
|
||||
|
||||
|
||||
def _read_glb_json(glb_path: Path) -> dict | None:
|
||||
"""Read the JSON chunk from a GLB (Binary glTF) file.
|
||||
|
||||
GLB structure: 12-byte header + chunks. Chunk 0 is JSON.
|
||||
Returns parsed dict or None on failure.
|
||||
"""
|
||||
try:
|
||||
with open(glb_path, "rb") as f:
|
||||
header = f.read(12)
|
||||
if len(header) < 12:
|
||||
return None
|
||||
magic, version, length = struct.unpack("<III", header)
|
||||
if magic != 0x46546C67: # 'glTF'
|
||||
return None
|
||||
chunk_header = f.read(8)
|
||||
if len(chunk_header) < 8:
|
||||
return None
|
||||
chunk_length, chunk_type = struct.unpack("<II", chunk_header)
|
||||
if chunk_type != 0x4E4F534A: # 'JSON'
|
||||
return None
|
||||
json_bytes = f.read(chunk_length)
|
||||
return json.loads(json_bytes)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to read GLB JSON from %s: %s", glb_path, e)
|
||||
return None
|
||||
|
||||
|
||||
def _quat_to_matrix(q: list[float]) -> list[list[float]]:
|
||||
"""Convert quaternion [x, y, z, w] to 3×3 rotation matrix."""
|
||||
x, y, z, w = q
|
||||
return [
|
||||
[1 - 2*(y*y + z*z), 2*(x*y - z*w), 2*(x*z + y*w)],
|
||||
[ 2*(x*y + z*w), 1 - 2*(x*x + z*z), 2*(y*z - x*w)],
|
||||
[ 2*(x*z - y*w), 2*(y*z + x*w), 1 - 2*(x*x + y*y)],
|
||||
]
|
||||
|
||||
|
||||
def _get_glb_root_rotation(device_dir: Path) -> list[list[float]] | None:
|
||||
"""Extract root node rotation matrix from the first GLB in device_dir/meshes/.
|
||||
|
||||
Only rotation is extracted — GLB scale is NOT applied because STL files
|
||||
are already in meters while GLB scale converts GLB mesh units (often mm)
|
||||
to scene units. Since we read STL directly, scale is irrelevant.
|
||||
|
||||
Returns 3×3 rotation matrix or None if no GLB or no rotation found.
|
||||
"""
|
||||
meshes_dir = device_dir / "meshes"
|
||||
if not meshes_dir.exists():
|
||||
return None
|
||||
glbs = sorted(meshes_dir.glob("*.glb"))
|
||||
if not glbs:
|
||||
return None
|
||||
|
||||
gltf = _read_glb_json(glbs[0])
|
||||
if gltf is None:
|
||||
return None
|
||||
|
||||
nodes = gltf.get("nodes", [])
|
||||
if not nodes:
|
||||
return None
|
||||
|
||||
root = nodes[0]
|
||||
rotation = root.get("rotation")
|
||||
if rotation is None:
|
||||
return None
|
||||
|
||||
# Skip identity quaternion [0,0,0,1]
|
||||
x, y, z, w = rotation
|
||||
if abs(x) < 1e-9 and abs(y) < 1e-9 and abs(z) < 1e-9 and abs(w - 1.0) < 1e-9:
|
||||
return None
|
||||
|
||||
return _quat_to_matrix(rotation)
|
||||
|
||||
|
||||
def _apply_rotation_to_bbox(
|
||||
stl_min: list[float], stl_max: list[float],
|
||||
rot: list[list[float]],
|
||||
) -> tuple[list[float], list[float]]:
|
||||
"""Apply rotation to an axis-aligned bounding box.
|
||||
|
||||
Transforms all 8 corners of the STL AABB through rotation,
|
||||
then computes the new AABB in glTF space.
|
||||
"""
|
||||
corners = []
|
||||
for x in (stl_min[0], stl_max[0]):
|
||||
for y in (stl_min[1], stl_max[1]):
|
||||
for z in (stl_min[2], stl_max[2]):
|
||||
tx = rot[0][0]*x + rot[0][1]*y + rot[0][2]*z
|
||||
ty = rot[1][0]*x + rot[1][1]*y + rot[1][2]*z
|
||||
tz = rot[2][0]*x + rot[2][1]*y + rot[2][2]*z
|
||||
corners.append((tx, ty, tz))
|
||||
|
||||
xs = [c[0] for c in corners]
|
||||
ys = [c[1] for c in corners]
|
||||
zs = [c[2] for c in corners]
|
||||
return [min(xs), min(ys), min(zs)], [max(xs), max(ys), max(zs)]
|
||||
|
||||
|
||||
def extract_one_device(device_dir: Path) -> dict | None:
|
||||
"""提取单个设备的足迹信息。"""
|
||||
try:
|
||||
import trimesh
|
||||
except ImportError:
|
||||
logger.error("trimesh not installed. Run: pip install trimesh")
|
||||
return None
|
||||
|
||||
mesh_files = _find_mesh_files(device_dir)
|
||||
if not mesh_files:
|
||||
return None
|
||||
|
||||
# 加载所有网格部件并计算联合包围盒
|
||||
meshes = []
|
||||
for f in mesh_files:
|
||||
try:
|
||||
m = trimesh.load(str(f), force="mesh")
|
||||
if hasattr(m, "bounds") and m.bounds is not None:
|
||||
meshes.append(m)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load %s: %s", f, e)
|
||||
|
||||
if not meshes:
|
||||
return None
|
||||
|
||||
if len(meshes) == 1:
|
||||
combined = meshes[0]
|
||||
else:
|
||||
combined = trimesh.util.concatenate(meshes)
|
||||
|
||||
bounds = combined.bounds
|
||||
stl_min = [float(bounds[0][i]) for i in range(3)]
|
||||
stl_max = [float(bounds[1][i]) for i in range(3)]
|
||||
|
||||
# 应用 GLB 根节点旋转到 STL 包围盒(scale 不应用 — STL 已是米制)
|
||||
# glTF 约定: X=right, Y=up, Z=forward → 2D 足迹取 X 和 Z, 高度取 Y
|
||||
rot = _get_glb_root_rotation(device_dir)
|
||||
if rot is not None:
|
||||
t_min, t_max = _apply_rotation_to_bbox(stl_min, stl_max, rot)
|
||||
t_size = [t_max[i] - t_min[i] for i in range(3)]
|
||||
t_center = [(t_min[i] + t_max[i]) / 2 for i in range(3)]
|
||||
# glTF Y-up: X=width, Z=depth, Y=height
|
||||
bbox_w = round(t_size[0], 4)
|
||||
bbox_d = round(t_size[2], 4)
|
||||
height = round(t_size[1], 4)
|
||||
origin_offset = [round(t_center[0], 4), round(t_center[2], 4)]
|
||||
logger.debug(
|
||||
"%s: GLB rotation applied → bbox=[%.3f, %.3f] height=%.3f",
|
||||
device_dir.name, bbox_w, bbox_d, height,
|
||||
)
|
||||
else:
|
||||
# 无 GLB 或 identity rotation → 沿用原始 STL 坐标 (X=width, Y=depth, Z=height)
|
||||
size = [stl_max[i] - stl_min[i] for i in range(3)]
|
||||
center = [(stl_min[i] + stl_max[i]) / 2 for i in range(3)]
|
||||
bbox_w = round(size[0], 4)
|
||||
bbox_d = round(size[1], 4)
|
||||
height = round(size[2], 4)
|
||||
origin_offset = [round(center[0], 4), round(center[1], 4)]
|
||||
|
||||
model_file, model_type = _find_best_model_file(device_dir)
|
||||
thumbnail_file = _find_thumbnail(device_dir)
|
||||
|
||||
device_id = device_dir.name
|
||||
|
||||
# 确定 openings:手动标注优先,否则尝试从 XACRO 自动提取
|
||||
# 注意:XACRO socket 坐标是 STL 原生坐标系,这里传入变换后的 bbox
|
||||
if device_id in MANUAL_OPENINGS:
|
||||
openings = MANUAL_OPENINGS[device_id]
|
||||
else:
|
||||
xacro_path = device_dir / "modal.xacro"
|
||||
if xacro_path.exists():
|
||||
openings = extract_openings_from_xacro(
|
||||
xacro_path,
|
||||
bbox_center_xy=(origin_offset[0], origin_offset[1]),
|
||||
bbox_size_xy=(bbox_w, bbox_d),
|
||||
)
|
||||
else:
|
||||
openings = []
|
||||
|
||||
result: dict = {
|
||||
"bbox": [bbox_w, bbox_d],
|
||||
"height": height,
|
||||
"origin_offset": origin_offset,
|
||||
"model_file": model_file,
|
||||
"model_type": model_type,
|
||||
"thumbnail_file": thumbnail_file,
|
||||
"openings": openings,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def extract_all(
|
||||
assets_dir: Path | None = None,
|
||||
registry_dir: Path | None = None,
|
||||
device_ids: list[str] | None = None,
|
||||
) -> dict[str, dict]:
|
||||
"""提取所有(或指定)设备的足迹。
|
||||
|
||||
Args:
|
||||
assets_dir: uni-lab-assets/device_models/ 路径
|
||||
registry_dir: Uni-Lab-OS/unilabos/device_mesh/devices/ 路径
|
||||
device_ids: 仅提取指定设备(None = 全部扫描)
|
||||
|
||||
Returns:
|
||||
{device_id: footprint_dict}
|
||||
"""
|
||||
results: dict[str, dict] = {}
|
||||
|
||||
dirs_to_scan: list[tuple[Path, str]] = []
|
||||
|
||||
if assets_dir and assets_dir.exists():
|
||||
for d in sorted(assets_dir.iterdir()):
|
||||
if d.is_dir() and (device_ids is None or d.name in device_ids):
|
||||
dirs_to_scan.append((d, "assets"))
|
||||
|
||||
if registry_dir and registry_dir.exists():
|
||||
for d in sorted(registry_dir.iterdir()):
|
||||
if d.is_dir() and (device_ids is None or d.name in device_ids):
|
||||
if d.name not in results: # assets 已有的不重复扫描
|
||||
dirs_to_scan.append((d, "registry"))
|
||||
|
||||
for device_dir, source in dirs_to_scan:
|
||||
device_id = device_dir.name
|
||||
if device_id in results:
|
||||
continue
|
||||
|
||||
footprint = extract_one_device(device_dir)
|
||||
if footprint:
|
||||
footprint["source"] = source
|
||||
results[device_id] = footprint
|
||||
logger.info(
|
||||
"Extracted %s: bbox=%s height=%.3f source=%s",
|
||||
device_id,
|
||||
footprint["bbox"],
|
||||
footprint["height"],
|
||||
source,
|
||||
)
|
||||
|
||||
# 统计自动提取的 openings 数量
|
||||
auto_xacro_count = sum(
|
||||
1
|
||||
for fp in results.values()
|
||||
if any(o.get("label") == "auto_xacro" for o in fp.get("openings", []))
|
||||
)
|
||||
logger.info(
|
||||
"Auto-extracted openings from XACRO for %d / %d devices",
|
||||
auto_xacro_count,
|
||||
len(results),
|
||||
)
|
||||
|
||||
# 手动后备
|
||||
for dev_id, (w, d, h) in FALLBACK_SIZES.items():
|
||||
if dev_id not in results:
|
||||
results[dev_id] = {
|
||||
"bbox": [w, d],
|
||||
"height": h,
|
||||
"origin_offset": [0.0, 0.0],
|
||||
"model_file": "",
|
||||
"model_type": "",
|
||||
"thumbnail_file": "",
|
||||
"openings": MANUAL_OPENINGS.get(dev_id, []),
|
||||
"source": "manual",
|
||||
}
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Extract device footprints from STL/GLB meshes"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--assets-dir",
|
||||
type=Path,
|
||||
default=Path(__file__).resolve().parent.parent / "uni-lab-assets" / "device_models",
|
||||
help="Path to uni-lab-assets/device_models/",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--registry-dir",
|
||||
type=Path,
|
||||
default=Path(__file__).resolve().parent / "Uni-Lab-OS" / "unilabos" / "device_mesh" / "devices",
|
||||
help="Path to Uni-Lab-OS device_mesh/devices/",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=Path,
|
||||
default=Path(__file__).resolve().parent / "footprints.json",
|
||||
help="Output JSON path",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--devices",
|
||||
nargs="*",
|
||||
default=None,
|
||||
help="Only extract these device IDs (default: all)",
|
||||
)
|
||||
parser.add_argument("-v", "--verbose", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if args.verbose else logging.INFO,
|
||||
format="%(levelname)s: %(message)s",
|
||||
)
|
||||
|
||||
logger.info("Assets dir: %s (exists=%s)", args.assets_dir, args.assets_dir.exists())
|
||||
logger.info("Registry dir: %s (exists=%s)", args.registry_dir, args.registry_dir.exists())
|
||||
|
||||
results = extract_all(
|
||||
assets_dir=args.assets_dir,
|
||||
registry_dir=args.registry_dir,
|
||||
device_ids=args.devices,
|
||||
)
|
||||
|
||||
with open(args.output, "w") as f:
|
||||
json.dump(results, f, indent=2, ensure_ascii=False)
|
||||
|
||||
logger.info("Wrote %d devices to %s", len(results), args.output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
10274
unilabos/layout_optimizer/footprints.json
Normal file
10274
unilabos/layout_optimizer/footprints.json
Normal file
File diff suppressed because it is too large
Load Diff
187
unilabos/layout_optimizer/generate_asset_registry.py
Normal file
187
unilabos/layout_optimizer/generate_asset_registry.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Generate a YAML registry file for all devices in uni-lab-assets that don't
|
||||
already have a registry entry (identified by model.mesh value).
|
||||
|
||||
Output: Uni-Lab-OS/unilabos/registry/devices/asset_models.yaml
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paths (resolved relative to this script's location)
|
||||
# ---------------------------------------------------------------------------
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
|
||||
ASSETS_DIR = REPO_ROOT.parent / "uni-lab-assets" / "device_models"
|
||||
REGISTRY_DIR = REPO_ROOT / "Uni-Lab-OS" / "unilabos" / "registry" / "devices"
|
||||
OUTPUT_FILE = REGISTRY_DIR / "asset_models.yaml"
|
||||
|
||||
OSS_BASE = (
|
||||
"https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices"
|
||||
)
|
||||
CONTAINER_CLASS = (
|
||||
"unilabos.devices.resource_container.container:HotelContainer"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 1 — collect mesh names already present in the registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def collect_registered_meshes() -> set[str]:
|
||||
"""Return the set of mesh values found in all existing registry YAML files."""
|
||||
registered: set[str] = set()
|
||||
for yaml_file in REGISTRY_DIR.glob("*.yaml"):
|
||||
# Skip the output file itself so the script is idempotent
|
||||
if yaml_file == OUTPUT_FILE:
|
||||
continue
|
||||
try:
|
||||
with yaml_file.open("r", encoding="utf-8") as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
except Exception as exc:
|
||||
print(f" [warn] Could not parse {yaml_file.name}: {exc}")
|
||||
continue
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
for _key, entry in data.items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
model = entry.get("model")
|
||||
if isinstance(model, dict):
|
||||
mesh = model.get("mesh")
|
||||
if mesh:
|
||||
registered.add(str(mesh))
|
||||
return registered
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 2 — scan uni-lab-assets/device_models/
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def scan_asset_devices() -> list[dict]:
|
||||
"""
|
||||
Return a list of device dicts for every subfolder that has a modal.xacro.
|
||||
Each dict has keys: folder_name, description.
|
||||
"""
|
||||
devices = []
|
||||
if not ASSETS_DIR.is_dir():
|
||||
raise FileNotFoundError(f"Assets directory not found: {ASSETS_DIR}")
|
||||
|
||||
for device_dir in sorted(ASSETS_DIR.iterdir()):
|
||||
if not device_dir.is_dir():
|
||||
continue
|
||||
|
||||
folder_name = device_dir.name
|
||||
|
||||
# modal.xacro is required
|
||||
if not (device_dir / "modal.xacro").exists():
|
||||
continue
|
||||
|
||||
# Read optional meta.json
|
||||
description = folder_name
|
||||
meta_path = device_dir / "meta.json"
|
||||
if meta_path.exists():
|
||||
try:
|
||||
with meta_path.open("r", encoding="utf-8") as fh:
|
||||
meta = json.load(fh)
|
||||
# Use name field if present; otherwise fall back to folder name
|
||||
description = meta.get("name", folder_name)
|
||||
except Exception as exc:
|
||||
print(f" [warn] Could not parse {meta_path}: {exc}")
|
||||
|
||||
devices.append(
|
||||
{
|
||||
"folder_name": folder_name,
|
||||
"description": description,
|
||||
}
|
||||
)
|
||||
|
||||
return devices
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 3 — build registry entry for a single device
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_entry(folder_name: str, description: str) -> dict:
|
||||
return {
|
||||
"category": ["asset_model"],
|
||||
"class": {
|
||||
"action_value_mappings": {},
|
||||
"module": CONTAINER_CLASS,
|
||||
"status_types": {},
|
||||
"type": "python",
|
||||
},
|
||||
"config_info": [],
|
||||
"description": description,
|
||||
"handles": [],
|
||||
"icon": "",
|
||||
"init_param_schema": {
|
||||
"config": {
|
||||
"properties": {},
|
||||
"required": [],
|
||||
"type": "object",
|
||||
},
|
||||
"data": {
|
||||
"properties": {},
|
||||
"required": [],
|
||||
"type": "object",
|
||||
},
|
||||
},
|
||||
"model": {
|
||||
"mesh": folder_name,
|
||||
"path": f"{OSS_BASE}/{folder_name}/macro_device.xacro",
|
||||
"type": "device",
|
||||
},
|
||||
"version": "1.0.0",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
print("Scanning existing registry for registered meshes...")
|
||||
registered_meshes = collect_registered_meshes()
|
||||
print(f" Found {len(registered_meshes)} already-registered mesh(es).")
|
||||
|
||||
print(f"\nScanning asset devices in: {ASSETS_DIR}")
|
||||
all_devices = scan_asset_devices()
|
||||
print(f" Found {len(all_devices)} device folder(s) with modal.xacro.")
|
||||
|
||||
registry: dict[str, dict] = {}
|
||||
skipped = 0
|
||||
generated = 0
|
||||
|
||||
for device in all_devices:
|
||||
folder_name = device["folder_name"]
|
||||
if folder_name in registered_meshes:
|
||||
skipped += 1
|
||||
continue
|
||||
key = f"asset_model.{folder_name}"
|
||||
registry[key] = build_entry(folder_name, device["description"])
|
||||
generated += 1
|
||||
|
||||
print(f"\nWriting {generated} new entr(ies) to: {OUTPUT_FILE}")
|
||||
OUTPUT_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with OUTPUT_FILE.open("w", encoding="utf-8") as fh:
|
||||
yaml.dump(
|
||||
registry,
|
||||
fh,
|
||||
default_flow_style=False,
|
||||
allow_unicode=True,
|
||||
sort_keys=False,
|
||||
)
|
||||
|
||||
print("\n--- Summary ---")
|
||||
print(f" Total devices found (with modal.xacro): {len(all_devices)}")
|
||||
print(f" Already registered (skipped): {skipped}")
|
||||
print(f" Newly generated: {generated}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
373
unilabos/layout_optimizer/intent_interpreter.py
Normal file
373
unilabos/layout_optimizer/intent_interpreter.py
Normal file
@@ -0,0 +1,373 @@
|
||||
"""意图解释器:将语义化意图翻译为 Constraint 列表。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .constraints import PRIORITY_MULTIPLIERS
|
||||
from .models import Constraint, Intent
|
||||
|
||||
# 优先级权重映射
|
||||
_PRIORITY_WEIGHTS: dict[str, float] = {"low": 1.0, "medium": 3.0, "high": 8.0}
|
||||
_DEFAULT_WEIGHT = _PRIORITY_WEIGHTS["medium"]
|
||||
|
||||
|
||||
def _priority_key(priority: str) -> str:
|
||||
"""将 intent priority 映射到 constraint 权重等级。"""
|
||||
return "normal" if priority == "medium" else priority
|
||||
|
||||
|
||||
def _final_weight(base_weight: float, priority: str) -> float:
|
||||
"""在解释阶段直接烘焙优先级乘数。"""
|
||||
return base_weight * PRIORITY_MULTIPLIERS.get(priority, 1.0)
|
||||
|
||||
|
||||
@dataclass
|
||||
class InterpretResult:
|
||||
"""意图解释结果。"""
|
||||
|
||||
constraints: list[Constraint] = field(default_factory=list)
|
||||
translations: list[dict] = field(default_factory=list)
|
||||
errors: list[str] = field(default_factory=list)
|
||||
workflow_edges: list[list[str]] = field(default_factory=list)
|
||||
|
||||
|
||||
def _handle_reachable_by(intent: Intent, result: InterpretResult) -> None:
|
||||
"""reachable_by:机械臂必须能到达指定设备列表。"""
|
||||
arm = intent.params.get("arm")
|
||||
targets = intent.params.get("targets", [])
|
||||
|
||||
if arm is None:
|
||||
result.errors.append(f"reachable_by: 缺少必要参数 'arm'")
|
||||
return
|
||||
if not targets:
|
||||
result.errors.append(f"reachable_by: 参数 'targets' 不能为空")
|
||||
return
|
||||
|
||||
generated: list[dict] = []
|
||||
for target in targets:
|
||||
c = Constraint(
|
||||
type="hard",
|
||||
rule_name="reachability",
|
||||
params={"arm_id": arm, "target_device_id": target},
|
||||
weight=_final_weight(1.0, "critical"),
|
||||
)
|
||||
result.constraints.append(c)
|
||||
generated.append({"type": c.type, "rule_name": c.rule_name, "params": c.params, "weight": c.weight})
|
||||
|
||||
result.translations.append({
|
||||
"source_intent": intent.intent,
|
||||
"source_description": intent.description,
|
||||
"source_params": intent.params,
|
||||
"generated_constraints": generated,
|
||||
"explanation": f"机械臂 '{arm}' 需要能够到达 {len(targets)} 个目标设备",
|
||||
})
|
||||
|
||||
|
||||
def _handle_close_together(intent: Intent, result: InterpretResult) -> None:
|
||||
"""close_together:设备组内两两最小化距离。"""
|
||||
devices: list[str] = intent.params.get("devices", [])
|
||||
priority: str = intent.params.get("priority", "medium")
|
||||
|
||||
if len(devices) < 2:
|
||||
result.errors.append(f"close_together: 参数 'devices' 至少需要 2 个设备,当前 {len(devices)} 个")
|
||||
return
|
||||
|
||||
weight = _final_weight(
|
||||
_PRIORITY_WEIGHTS.get(priority, _DEFAULT_WEIGHT),
|
||||
_priority_key(priority),
|
||||
)
|
||||
generated: list[dict] = []
|
||||
for dev_a, dev_b in itertools.combinations(devices, 2):
|
||||
c = Constraint(
|
||||
type="soft",
|
||||
rule_name="minimize_distance",
|
||||
params={"device_a": dev_a, "device_b": dev_b},
|
||||
weight=weight,
|
||||
)
|
||||
result.constraints.append(c)
|
||||
generated.append({"type": c.type, "rule_name": c.rule_name, "params": c.params, "weight": c.weight})
|
||||
|
||||
result.translations.append({
|
||||
"source_intent": intent.intent,
|
||||
"source_description": intent.description,
|
||||
"source_params": intent.params,
|
||||
"generated_constraints": generated,
|
||||
"explanation": f"设备组 {devices} 应尽量靠近(优先级: {priority})",
|
||||
})
|
||||
|
||||
|
||||
def _handle_far_apart(intent: Intent, result: InterpretResult) -> None:
|
||||
"""far_apart:设备组内两两最大化距离。"""
|
||||
devices: list[str] = intent.params.get("devices", [])
|
||||
priority: str = intent.params.get("priority", "medium")
|
||||
|
||||
if len(devices) < 2:
|
||||
result.errors.append(f"far_apart: 参数 'devices' 至少需要 2 个设备,当前 {len(devices)} 个")
|
||||
return
|
||||
|
||||
weight = _final_weight(
|
||||
_PRIORITY_WEIGHTS.get(priority, _DEFAULT_WEIGHT),
|
||||
_priority_key(priority),
|
||||
)
|
||||
generated: list[dict] = []
|
||||
for dev_a, dev_b in itertools.combinations(devices, 2):
|
||||
c = Constraint(
|
||||
type="soft",
|
||||
rule_name="maximize_distance",
|
||||
params={"device_a": dev_a, "device_b": dev_b},
|
||||
weight=weight,
|
||||
)
|
||||
result.constraints.append(c)
|
||||
generated.append({"type": c.type, "rule_name": c.rule_name, "params": c.params, "weight": c.weight})
|
||||
|
||||
result.translations.append({
|
||||
"source_intent": intent.intent,
|
||||
"source_description": intent.description,
|
||||
"source_params": intent.params,
|
||||
"generated_constraints": generated,
|
||||
"explanation": f"设备组 {devices} 应尽量分散(优先级: {priority})",
|
||||
})
|
||||
|
||||
|
||||
def _handle_max_distance(intent: Intent, result: InterpretResult) -> None:
|
||||
"""max_distance:两设备间距不超过指定值。"""
|
||||
device_a = intent.params.get("device_a")
|
||||
device_b = intent.params.get("device_b")
|
||||
distance = intent.params.get("distance")
|
||||
|
||||
if device_a is None or device_b is None or distance is None:
|
||||
result.errors.append(
|
||||
f"max_distance: 缺少必要参数,需要 'device_a'、'device_b' 和 'distance',"
|
||||
f"当前: device_a={device_a}, device_b={device_b}, distance={distance}"
|
||||
)
|
||||
return
|
||||
|
||||
c = Constraint(
|
||||
type="hard",
|
||||
rule_name="distance_less_than",
|
||||
params={"device_a": device_a, "device_b": device_b, "distance": distance},
|
||||
weight=_final_weight(1.0, "normal"),
|
||||
)
|
||||
result.constraints.append(c)
|
||||
|
||||
result.translations.append({
|
||||
"source_intent": intent.intent,
|
||||
"source_description": intent.description,
|
||||
"source_params": intent.params,
|
||||
"generated_constraints": [{"type": c.type, "rule_name": c.rule_name, "params": c.params, "weight": c.weight}],
|
||||
"explanation": f"设备 '{device_a}' 与 '{device_b}' 之间的距离不得超过 {distance} 米",
|
||||
})
|
||||
|
||||
|
||||
def _handle_min_distance(intent: Intent, result: InterpretResult) -> None:
|
||||
"""min_distance:两设备间距不小于指定值。"""
|
||||
device_a = intent.params.get("device_a")
|
||||
device_b = intent.params.get("device_b")
|
||||
distance = intent.params.get("distance")
|
||||
|
||||
if device_a is None or device_b is None or distance is None:
|
||||
result.errors.append(
|
||||
f"min_distance: 缺少必要参数,需要 'device_a'、'device_b' 和 'distance',"
|
||||
f"当前: device_a={device_a}, device_b={device_b}, distance={distance}"
|
||||
)
|
||||
return
|
||||
|
||||
c = Constraint(
|
||||
type="hard",
|
||||
rule_name="distance_greater_than",
|
||||
params={"device_a": device_a, "device_b": device_b, "distance": distance},
|
||||
weight=_final_weight(1.0, "normal"),
|
||||
)
|
||||
result.constraints.append(c)
|
||||
|
||||
result.translations.append({
|
||||
"source_intent": intent.intent,
|
||||
"source_description": intent.description,
|
||||
"source_params": intent.params,
|
||||
"generated_constraints": [{"type": c.type, "rule_name": c.rule_name, "params": c.params, "weight": c.weight}],
|
||||
"explanation": f"设备 '{device_a}' 与 '{device_b}' 之间的距离不得小于 {distance} 米",
|
||||
})
|
||||
|
||||
|
||||
def _handle_min_spacing(intent: Intent, result: InterpretResult) -> None:
|
||||
"""min_spacing:所有设备之间的最小间隙。"""
|
||||
min_gap: float = intent.params.get("min_gap", 0.3)
|
||||
|
||||
c = Constraint(
|
||||
type="hard",
|
||||
rule_name="min_spacing",
|
||||
params={"min_gap": min_gap},
|
||||
weight=_final_weight(1.0, "high"),
|
||||
)
|
||||
result.constraints.append(c)
|
||||
|
||||
result.translations.append({
|
||||
"source_intent": intent.intent,
|
||||
"source_description": intent.description,
|
||||
"source_params": intent.params,
|
||||
"generated_constraints": [{"type": c.type, "rule_name": c.rule_name, "params": c.params, "weight": c.weight}],
|
||||
"explanation": f"所有设备之间至少保持 {min_gap} 米的间隙",
|
||||
})
|
||||
|
||||
|
||||
def _handle_face_outward(intent: Intent, result: InterpretResult) -> None:
|
||||
"""face_outward:设备朝向偏好为向外。"""
|
||||
c = Constraint(
|
||||
type="soft",
|
||||
rule_name="prefer_orientation_mode",
|
||||
params={"mode": "outward"},
|
||||
weight=_final_weight(1.0, "low"),
|
||||
)
|
||||
result.constraints.append(c)
|
||||
|
||||
result.translations.append({
|
||||
"source_intent": intent.intent,
|
||||
"source_description": intent.description,
|
||||
"source_params": intent.params,
|
||||
"generated_constraints": [{"type": c.type, "rule_name": c.rule_name, "params": c.params, "weight": c.weight}],
|
||||
"explanation": "设备开口偏好朝向实验室外侧",
|
||||
})
|
||||
|
||||
|
||||
def _handle_face_inward(intent: Intent, result: InterpretResult) -> None:
|
||||
"""face_inward:设备朝向偏好为向内。"""
|
||||
c = Constraint(
|
||||
type="soft",
|
||||
rule_name="prefer_orientation_mode",
|
||||
params={"mode": "inward"},
|
||||
weight=_final_weight(1.0, "low"),
|
||||
)
|
||||
result.constraints.append(c)
|
||||
|
||||
result.translations.append({
|
||||
"source_intent": intent.intent,
|
||||
"source_description": intent.description,
|
||||
"source_params": intent.params,
|
||||
"generated_constraints": [{"type": c.type, "rule_name": c.rule_name, "params": c.params, "weight": c.weight}],
|
||||
"explanation": "设备开口偏好朝向实验室内侧",
|
||||
})
|
||||
|
||||
|
||||
def _handle_align_cardinal(intent: Intent, result: InterpretResult) -> None:
|
||||
"""align_cardinal:设备偏好对齐到主轴方向。"""
|
||||
c = Constraint(
|
||||
type="soft",
|
||||
rule_name="prefer_aligned",
|
||||
params={},
|
||||
weight=_final_weight(1.0, "low"),
|
||||
)
|
||||
result.constraints.append(c)
|
||||
|
||||
result.translations.append({
|
||||
"source_intent": intent.intent,
|
||||
"source_description": intent.description,
|
||||
"source_params": intent.params,
|
||||
"generated_constraints": [{"type": c.type, "rule_name": c.rule_name, "params": c.params, "weight": c.weight}],
|
||||
"explanation": "设备偏好与实验室主轴对齐(0°/90°/180°/270°)",
|
||||
})
|
||||
|
||||
|
||||
def _handle_keep_adjacent(intent: Intent, result: InterpretResult) -> None:
|
||||
"""keep_adjacent:两个设备保持相邻(同 close_together 逻辑,支持 priority 映射)。"""
|
||||
devices: list[str] = intent.params.get("devices", [])
|
||||
priority: str = intent.params.get("priority", "medium")
|
||||
|
||||
if len(devices) < 2:
|
||||
result.errors.append(f"keep_adjacent: 参数 'devices' 至少需要 2 个设备,当前 {len(devices)} 个")
|
||||
return
|
||||
|
||||
weight = _final_weight(
|
||||
_PRIORITY_WEIGHTS.get(priority, _DEFAULT_WEIGHT),
|
||||
_priority_key(priority),
|
||||
)
|
||||
generated: list[dict] = []
|
||||
for dev_a, dev_b in itertools.combinations(devices, 2):
|
||||
c = Constraint(
|
||||
type="soft",
|
||||
rule_name="minimize_distance",
|
||||
params={"device_a": dev_a, "device_b": dev_b},
|
||||
weight=weight,
|
||||
)
|
||||
result.constraints.append(c)
|
||||
generated.append({"type": c.type, "rule_name": c.rule_name, "params": c.params, "weight": c.weight})
|
||||
|
||||
result.translations.append({
|
||||
"source_intent": intent.intent,
|
||||
"source_description": intent.description,
|
||||
"source_params": intent.params,
|
||||
"generated_constraints": generated,
|
||||
"explanation": f"设备组 {devices} 应保持相邻(优先级: {priority})",
|
||||
})
|
||||
|
||||
|
||||
def _handle_workflow_hint(intent: Intent, result: InterpretResult) -> None:
|
||||
"""workflow_hint:工作流顺序暗示,相邻步骤设备靠近。"""
|
||||
workflow: str = intent.params.get("workflow", "")
|
||||
devices: list[str] = intent.params.get("devices", [])
|
||||
|
||||
if len(devices) < 2:
|
||||
result.errors.append(
|
||||
f"workflow_hint: 参数 'devices' 至少需要 2 个设备,当前 {len(devices)} 个"
|
||||
)
|
||||
return
|
||||
|
||||
generated: list[dict] = []
|
||||
for dev_a, dev_b in zip(devices[:-1], devices[1:]):
|
||||
c = Constraint(
|
||||
type="soft",
|
||||
rule_name="minimize_distance",
|
||||
params={"device_a": dev_a, "device_b": dev_b},
|
||||
weight=_final_weight(1.0, "normal"),
|
||||
)
|
||||
result.constraints.append(c)
|
||||
generated.append({"type": c.type, "rule_name": c.rule_name, "params": c.params, "weight": c.weight})
|
||||
result.workflow_edges.append([dev_a, dev_b])
|
||||
|
||||
result.translations.append({
|
||||
"source_intent": intent.intent,
|
||||
"source_description": intent.description,
|
||||
"source_params": intent.params,
|
||||
"generated_constraints": generated,
|
||||
"explanation": f"工作流 '{workflow}' 中相邻步骤设备应靠近",
|
||||
"confidence": "low",
|
||||
})
|
||||
|
||||
|
||||
# 意图处理器分发表
|
||||
_HANDLERS: dict[str, Callable[[Intent, InterpretResult], None]] = {
|
||||
"reachable_by": _handle_reachable_by,
|
||||
"close_together": _handle_close_together,
|
||||
"far_apart": _handle_far_apart,
|
||||
"max_distance": _handle_max_distance,
|
||||
"min_distance": _handle_min_distance,
|
||||
"min_spacing": _handle_min_spacing,
|
||||
"face_outward": _handle_face_outward,
|
||||
"face_inward": _handle_face_inward,
|
||||
"align_cardinal": _handle_align_cardinal,
|
||||
"keep_adjacent": _handle_keep_adjacent,
|
||||
"workflow_hint": _handle_workflow_hint,
|
||||
}
|
||||
|
||||
|
||||
def interpret_intents(intents: list[Intent]) -> InterpretResult:
|
||||
"""将意图列表翻译为约束列表。
|
||||
|
||||
Args:
|
||||
intents: 语义化意图列表(通常由 LLM 生成)
|
||||
|
||||
Returns:
|
||||
InterpretResult,包含约束、翻译记录、错误信息和工作流边
|
||||
"""
|
||||
result = InterpretResult()
|
||||
|
||||
for intent in intents:
|
||||
handler = _HANDLERS.get(intent.intent)
|
||||
if handler is None:
|
||||
result.errors.append(f"未知意图类型: '{intent.intent}',跳过处理")
|
||||
continue
|
||||
handler(intent, result)
|
||||
|
||||
return result
|
||||
41
unilabos/layout_optimizer/interfaces.py
Normal file
41
unilabos/layout_optimizer/interfaces.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Protocol 接口定义,隔离 ROS 依赖。
|
||||
|
||||
开发阶段使用 mock_checkers.py 中的 Mock 实现,
|
||||
集成阶段替换为 ros_checkers.py 中的 MoveIt2 / IKFast 实现。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class CollisionChecker(Protocol):
|
||||
"""碰撞检测接口。"""
|
||||
|
||||
def check(self, placements: list[dict]) -> list[tuple[str, str]]:
|
||||
"""返回碰撞设备对列表。
|
||||
|
||||
Args:
|
||||
placements: [{"id": str, "bbox": (w, d), "pos": (x, y, θ)}, ...]
|
||||
|
||||
Returns:
|
||||
[("device_a", "device_b"), ...] 存在碰撞的设备对
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class ReachabilityChecker(Protocol):
|
||||
"""可达性检测接口。"""
|
||||
|
||||
def is_reachable(self, arm_id: str, arm_pose: dict, target: dict) -> bool:
|
||||
"""判断机械臂在给定位姿下能否到达目标点。
|
||||
|
||||
Args:
|
||||
arm_id: 机械臂设备 ID
|
||||
arm_pose: {"x": float, "y": float, "theta": float}
|
||||
target: {"x": float, "y": float, "z": float}
|
||||
|
||||
Returns:
|
||||
True 如果可达
|
||||
"""
|
||||
...
|
||||
49
unilabos/layout_optimizer/lab_parser.py
Normal file
49
unilabos/layout_optimizer/lab_parser.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""解析实验室平面图 JSON。
|
||||
|
||||
简单格式:
|
||||
{
|
||||
"width": 6.0,
|
||||
"depth": 4.0,
|
||||
"obstacles": [
|
||||
{"x": 2.0, "y": 0.0, "width": 0.1, "depth": 1.0}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from .models import Lab, Obstacle
|
||||
|
||||
|
||||
def parse_lab(data: dict) -> Lab:
|
||||
"""从字典解析实验室平面图。"""
|
||||
obstacles = []
|
||||
for obs in data.get("obstacles", []):
|
||||
obstacles.append(
|
||||
Obstacle(
|
||||
x=float(obs["x"]),
|
||||
y=float(obs["y"]),
|
||||
width=float(obs["width"]),
|
||||
depth=float(obs["depth"]),
|
||||
)
|
||||
)
|
||||
return Lab(
|
||||
width=float(data["width"]),
|
||||
depth=float(data["depth"]),
|
||||
obstacles=obstacles,
|
||||
)
|
||||
|
||||
|
||||
def load_lab_from_file(path: str | Path) -> Lab:
|
||||
"""从 JSON 文件加载实验室平面图。"""
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
return parse_lab(data)
|
||||
|
||||
|
||||
def create_simple_lab(width: float, depth: float) -> Lab:
|
||||
"""创建一个无障碍物的简单矩形实验室。"""
|
||||
return Lab(width=width, depth=depth)
|
||||
187
unilabos/layout_optimizer/llm_skill/demo_agent.md
Normal file
187
unilabos/layout_optimizer/llm_skill/demo_agent.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# Demo Agent — Lab Layout Orchestrator
|
||||
|
||||
You are a lab layout agent for a recorded demo. Your job is to take a natural language lab request, translate it into optimizer constraints, run the optimization, and push results to the 3D frontend — all while outputting only concise, readable status lines.
|
||||
|
||||
## CRITICAL OUTPUT RULES
|
||||
|
||||
- Output ONLY short status lines. No markdown fences. No raw JSON. No explanations.
|
||||
- Every HTTP call uses `curl -s` (silent). Never show curl output to the user.
|
||||
- Parse responses internally. Extract only the fields needed for your status lines.
|
||||
- Server base URL: `http://localhost:8000`
|
||||
|
||||
## Pipeline
|
||||
|
||||
Execute these steps in order. Print the status line shown after each step.
|
||||
|
||||
### Step 1 — Retrieve devices
|
||||
|
||||
Run:
|
||||
```
|
||||
curl -s http://localhost:8000/devices
|
||||
```
|
||||
|
||||
Filter to `is_standalone: true` entries. Count them. Build an id→name lookup.
|
||||
|
||||
Print:
|
||||
```
|
||||
retrieving devices... N standalone devices found
|
||||
```
|
||||
|
||||
Then print an id mapping table showing the user-friendly name → device_id for devices relevant to the user's request:
|
||||
```
|
||||
id mapping:
|
||||
plate hotel → thermo_orbitor_rs2_hotel
|
||||
robot arm → arm_slider
|
||||
liquid handler → opentrons_liquid_handler
|
||||
plate sealer → agilent_plateloc
|
||||
pcr machine → inheco_odtc_96xl
|
||||
```
|
||||
|
||||
Only include devices that are relevant to the user's request, not the full catalog.
|
||||
|
||||
### Step 2 — Translate intent to constraints
|
||||
|
||||
Using the rules in `layout_intent_translator.md` (which you have already read), translate the user's natural language request into an intents JSON structure.
|
||||
|
||||
Do NOT print the JSON. Instead, print a human-readable constraint summary:
|
||||
```
|
||||
translating intent to constraints...
|
||||
constraints:
|
||||
hard: arm_slider must reach 4 devices
|
||||
hard: min spacing 0.05m between all devices
|
||||
soft: workflow order hotel → liquid handler → sealer → pcr
|
||||
soft: all devices close together (high priority)
|
||||
soft: align to cardinal directions
|
||||
```
|
||||
|
||||
### Step 3 — Interpret intents
|
||||
|
||||
Send the intents JSON to the interpret endpoint:
|
||||
```
|
||||
curl -s -X POST http://localhost:8000/interpret \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{ "intents": [...] }'
|
||||
```
|
||||
|
||||
Capture the `constraints` and `workflow_edges` arrays from the response. Do NOT print anything for this step — it's a silent validation.
|
||||
|
||||
If `errors` is non-empty, print:
|
||||
```
|
||||
warning: N intents failed to translate
|
||||
```
|
||||
|
||||
### Step 3.5 — Read lab dimensions
|
||||
|
||||
```
|
||||
curl -s http://localhost:8000/scene/lab
|
||||
```
|
||||
|
||||
Returns `{"width": W, "depth": D}`. Use these values for the optimize request. Do NOT print anything for this step.
|
||||
|
||||
### Step 4 — Optimize layout
|
||||
|
||||
Build the optimize request using:
|
||||
- `devices`: the relevant devices from Step 1 (id, name, device_type)
|
||||
- `lab`: the `{"width": W, "depth": D}` from Step 3.5
|
||||
- `constraints`: from Step 3 interpret response
|
||||
- `workflow_edges`: from Step 3 interpret response
|
||||
- `seeder`: `"compact_outward"` (default)
|
||||
- `seeder_overrides`: generally not needed. Cardinal alignment is handled by the `align_cardinal` intent (generates `prefer_aligned` constraint). Do NOT use `align_weight` in seeder_overrides — it is deprecated.
|
||||
- `snap_cardinal`: `false` (default). Set `true` only if user explicitly requests snapping to 0/90/180/270.
|
||||
- `run_de`: `true`
|
||||
- `maxiter`: `200`
|
||||
- `seed`: `42`
|
||||
|
||||
Run:
|
||||
```
|
||||
curl -s -X POST http://localhost:8000/optimize \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{ ... }'
|
||||
```
|
||||
|
||||
Print:
|
||||
```
|
||||
optimizing layout (DE, 200 iterations)...
|
||||
optimization complete — cost: X.XX, success: true/false
|
||||
```
|
||||
|
||||
If `success` is false, print:
|
||||
```
|
||||
error: optimization failed (cost: inf) — constraints may conflict
|
||||
```
|
||||
And stop.
|
||||
|
||||
### Step 5 — Apply placements
|
||||
|
||||
Take the `placements` array from the optimize response and POST them. Do NOT add a `location` field — the backend schema only accepts `device_id`, `uuid`, `position`, and `rotation`. Extra fields will cause validation errors.
|
||||
|
||||
```
|
||||
curl -s -X POST http://localhost:8000/scene/placements \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{ "placements": [
|
||||
{
|
||||
"device_id": "...",
|
||||
"uuid": "...",
|
||||
"position": {"x": ..., "y": ..., "z": ...},
|
||||
"rotation": {"x": ..., "y": ..., "z": ...}
|
||||
}
|
||||
] }'
|
||||
```
|
||||
|
||||
**Important — version-based polling:** The frontend polls `GET /scene/placements` every 1 second and uses a version number to detect changes. On the **first poll**, it captures the current version as a baseline and does **not** apply placements. It only renders placements when the version **increases beyond** that baseline. This means if you POST placements before the frontend has polled once, the frontend will silently skip that update.
|
||||
|
||||
**Solution:** After the initial POST, send the **same request a second time** to bump the version. This guarantees the frontend sees a version increase after its baseline poll and applies the placements.
|
||||
|
||||
Print:
|
||||
```
|
||||
applying placements to scene...
|
||||
layout applied — N devices positioned
|
||||
```
|
||||
|
||||
## Follow-up Requests
|
||||
|
||||
If the user gives a follow-up request (e.g., "now move the sealer farther from the thermal cycler"):
|
||||
|
||||
1. Print a `---` separator
|
||||
2. Keep the same device list (no need to re-fetch)
|
||||
3. Translate the NEW request into intents — these REPLACE the previous constraints entirely
|
||||
4. Run Steps 3–5 again with the new constraints
|
||||
5. Same output format
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Server unreachable: `error: server unreachable at localhost:8000`
|
||||
- Optimize fails: `error: optimization failed (cost: inf) — constraints may conflict`
|
||||
- After any error, stop and wait for user input.
|
||||
|
||||
## Device Name Resolution
|
||||
|
||||
You have `layout_intent_translator.md` loaded as context. Use its device name resolution rules to match user's informal names (e.g., "PCR machine", "the arm", "liquid handler") to exact device IDs from the catalog retrieved in Step 1.
|
||||
|
||||
## Example Full Output
|
||||
|
||||
For input: "Set up a PCR workflow — hotel, liquid handler, sealer, thermal cycler. The arm handles all transfers. Keep it compact."
|
||||
|
||||
```
|
||||
retrieving devices... 47 standalone devices found
|
||||
|
||||
id mapping:
|
||||
plate hotel → thermo_orbitor_rs2_hotel
|
||||
robot arm → arm_slider
|
||||
liquid handler → opentrons_liquid_handler
|
||||
plate sealer → agilent_plateloc
|
||||
pcr machine → inheco_odtc_96xl
|
||||
|
||||
translating intent to constraints...
|
||||
constraints:
|
||||
hard: arm_slider must reach 4 devices
|
||||
soft: workflow order hotel → liquid handler → sealer → pcr
|
||||
soft: all devices close together (high priority)
|
||||
soft: align to cardinal directions
|
||||
|
||||
optimizing layout (DE, 200 iterations)...
|
||||
optimization complete — cost: 0.00, success: true
|
||||
|
||||
applying placements to scene...
|
||||
layout applied — 5 devices positioned
|
||||
```
|
||||
312
unilabos/layout_optimizer/llm_skill/layout_intent_translator.md
Normal file
312
unilabos/layout_optimizer/llm_skill/layout_intent_translator.md
Normal file
@@ -0,0 +1,312 @@
|
||||
# Layout Intent Translator — LLM Skill
|
||||
|
||||
You are a lab layout intent translator. Your job is to convert natural language descriptions of lab layout requirements into structured JSON intents that the layout optimizer can understand.
|
||||
|
||||
## Your Role
|
||||
|
||||
Users describe their lab needs in natural language. You must:
|
||||
1. Identify devices by their IDs from the provided device list
|
||||
2. Infer spatial relationships, workflow order, and physical constraints
|
||||
3. Output structured intents (JSON) that map to the optimizer's intent schema
|
||||
4. Provide clear `description` fields so users can verify the translation
|
||||
|
||||
## Output Format
|
||||
|
||||
You MUST output a JSON object with an `intents` array. Each intent has:
|
||||
|
||||
```json
|
||||
{
|
||||
"intents": [
|
||||
{
|
||||
"intent": "<intent_type>",
|
||||
"params": { ... },
|
||||
"description": "Human-readable explanation of what this intent means"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Available Intent Types
|
||||
|
||||
### `reachable_by` — Robot arm must reach devices
|
||||
```json
|
||||
{
|
||||
"intent": "reachable_by",
|
||||
"params": {
|
||||
"arm": "arm_device_id",
|
||||
"targets": ["device_a", "device_b"]
|
||||
},
|
||||
"description": "Robot arm must be able to reach device A and device B"
|
||||
}
|
||||
```
|
||||
**When to use:** Any time a robot arm transfers items between devices, all those devices must be reachable.
|
||||
|
||||
### `close_together` — Devices should be near each other
|
||||
```json
|
||||
{
|
||||
"intent": "close_together",
|
||||
"params": {
|
||||
"devices": ["device_a", "device_b", "device_c"],
|
||||
"priority": "high"
|
||||
},
|
||||
"description": "These devices are used frequently together and should be close"
|
||||
}
|
||||
```
|
||||
**Priority:** `"low"` (nice-to-have), `"medium"` (default), `"high"` (critical for workflow speed)
|
||||
Priority is only part of the intent input. The interpreter automatically bakes it into the emitted constraint `weight`; there is no separate constraint-level `priority` field in `/interpret` output or `/optimize` input.
|
||||
|
||||
### `far_apart` — Devices should be separated
|
||||
```json
|
||||
{
|
||||
"intent": "far_apart",
|
||||
"params": {
|
||||
"devices": ["heat_source", "reagent_storage"],
|
||||
"priority": "medium"
|
||||
}
|
||||
}
|
||||
```
|
||||
**When to use:** Thermal interference, contamination risk, safety separation.
|
||||
|
||||
### `keep_adjacent` — Devices should stay adjacent
|
||||
```json
|
||||
{
|
||||
"intent": "keep_adjacent",
|
||||
"params": {
|
||||
"devices": ["device_a", "device_b"],
|
||||
"priority": "high"
|
||||
}
|
||||
}
|
||||
```
|
||||
**When to use:** User explicitly asks for a pair or group to stay side-by-side / adjacent. This currently maps to the same optimizer behavior as `close_together`, but is semantically more precise.
|
||||
|
||||
### `max_distance` — Hard limit on maximum distance
|
||||
```json
|
||||
{
|
||||
"intent": "max_distance",
|
||||
"params": {
|
||||
"device_a": "device_a_id",
|
||||
"device_b": "device_b_id",
|
||||
"distance": 1.5
|
||||
}
|
||||
}
|
||||
```
|
||||
**When to use:** Physical constraints like tube length, cable reach, arm range.
|
||||
|
||||
### `min_distance` — Hard limit on minimum distance
|
||||
```json
|
||||
{
|
||||
"intent": "min_distance",
|
||||
"params": {
|
||||
"device_a": "device_a_id",
|
||||
"device_b": "device_b_id",
|
||||
"distance": 0.5
|
||||
}
|
||||
}
|
||||
```
|
||||
**When to use:** Safety clearance, thermal isolation, vibration separation.
|
||||
|
||||
### `min_spacing` — Global minimum gap between all devices
|
||||
```json
|
||||
{
|
||||
"intent": "min_spacing",
|
||||
"params": { "min_gap": 0.3 }
|
||||
}
|
||||
```
|
||||
**When to use:** General accessibility, maintenance clearance.
|
||||
|
||||
### `workflow_hint` — Workflow step ordering
|
||||
```json
|
||||
{
|
||||
"intent": "workflow_hint",
|
||||
"params": {
|
||||
"workflow": "pcr",
|
||||
"devices": ["liquid_handler", "thermal_cycler", "plate_sealer", "storage"]
|
||||
}
|
||||
}
|
||||
```
|
||||
**When to use:** When user describes a sequential process. Devices are listed in workflow order. Consecutive devices will be placed near each other.
|
||||
|
||||
### `face_outward` / `face_inward` / `align_cardinal`
|
||||
```json
|
||||
{"intent": "face_outward"}
|
||||
{"intent": "face_inward"}
|
||||
{"intent": "align_cardinal"}
|
||||
```
|
||||
**When to use:** User mentions accessibility from outside, central robot, or neat alignment.
|
||||
|
||||
## Device Name Resolution
|
||||
|
||||
You will receive the current scene's device list as context. This is the **only** source of valid device IDs. Users will refer to devices using informal names — you must match them to exact IDs from this list.
|
||||
|
||||
### Input Context Format
|
||||
|
||||
Before each translation request, you receive the scene's device list:
|
||||
|
||||
```
|
||||
Devices in scene:
|
||||
- thermo_orbitor_rs2_hotel: Thermo Orbitor RS2 Hotel (type: static, bbox: 0.68×0.52m)
|
||||
- arm_slider: Arm Slider (type: articulation, bbox: 1.20×0.30m)
|
||||
- opentrons_liquid_handler: Opentrons Liquid Handler (type: static, bbox: 0.65×0.60m)
|
||||
- agilent_plateloc: Agilent PlateLoc (type: static, bbox: 0.35×0.40m)
|
||||
- inheco_odtc_96xl: Inheco ODTC 96XL (type: static, bbox: 0.30×0.35m)
|
||||
```
|
||||
|
||||
### Matching Rules
|
||||
|
||||
1. **Exact match first**: If user says "arm_slider", match directly
|
||||
2. **Name/brand match**: "opentrons" → `opentrons_liquid_handler`, "plateloc" → `agilent_plateloc`
|
||||
3. **Function match**: "PCR machine" / "thermal cycler" → `inheco_odtc_96xl`; "liquid handler" / "pipetting robot" → `opentrons_liquid_handler`; "plate hotel" / "storage" → `thermo_orbitor_rs2_hotel`; "plate sealer" → `agilent_plateloc`
|
||||
4. **Type match**: "robot arm" / "the arm" → look for `device_type: articulation`
|
||||
5. **Ambiguous**: If multiple devices could match, list candidates in the `description` field and pick the most likely one. If truly ambiguous, return an error intent asking the user to clarify.
|
||||
|
||||
### Duplicate Device Convention
|
||||
|
||||
When the same catalog device appears multiple times in the scene:
|
||||
|
||||
- first instance keeps the bare catalog ID, e.g. `plate_reader`
|
||||
- second and later instances use `#N`, e.g. `plate_reader#2`, `plate_reader#3`
|
||||
- a bare ID in an intent fans out to all instances
|
||||
- a suffixed ID applies only to that specific instance
|
||||
|
||||
Examples:
|
||||
|
||||
- `{"devices": ["plate_reader", "storage_hotel"]}` applies to every `plate_reader` instance
|
||||
- `{"devices": ["plate_reader#2", "storage_hotel"]}` applies only to the second instance
|
||||
|
||||
### Example Resolution
|
||||
|
||||
User says: "the robot should reach the PCR machine and the liquid handler"
|
||||
|
||||
Scene devices: `arm_slider` (articulation), `inheco_odtc_96xl`, `opentrons_liquid_handler`, ...
|
||||
|
||||
Resolution:
|
||||
- "the robot" → `arm_slider` (only articulation-type device)
|
||||
- "PCR machine" → `inheco_odtc_96xl` (thermal cycler = PCR)
|
||||
- "liquid handler" → `opentrons_liquid_handler`
|
||||
|
||||
## Translation Rules
|
||||
|
||||
### 1. Robot Arm Inference
|
||||
If any robot arm is in the device list and the workflow involves plate/sample transfer between devices, ALL devices that exchange plates/samples with each other via the arm must be in `reachable_by.targets`.
|
||||
|
||||
### 2. Workflow Order
|
||||
When a user describes a process (e.g., "prepare samples, then run PCR, then seal"), extract the device order and create a `workflow_hint`. The device order follows the sample processing path.
|
||||
|
||||
### 3. Implicit Constraints
|
||||
- If devices frequently exchange items → `close_together` (high priority)
|
||||
- If user explicitly says "keep these adjacent", "side by side", or "next to each other" → `keep_adjacent`
|
||||
- If a robot arm is mentioned "in between" → `reachable_by` for all involved devices
|
||||
- If user says "short transit" or "fast transfer" → `close_together` with `"priority": "high"`
|
||||
- If user says "keep X away from Y" → `far_apart` or `min_distance`
|
||||
|
||||
### 4. Don't Over-Constrain
|
||||
- Only add constraints the user's description implies
|
||||
- When unsure about priority, use `"medium"`
|
||||
- For workflow_hint, confidence is inherently `"low"` — the optimizer notes this
|
||||
|
||||
## Example: PCR Workflow
|
||||
|
||||
**User input:**
|
||||
> "Take plate from hotel, prepare sample in opentrons, seal plate then pcr cycle, arm_slider handles all transfers"
|
||||
|
||||
**Device list provided:**
|
||||
- `thermo_orbitor_rs2_hotel` (plate hotel/storage)
|
||||
- `arm_slider` (robot arm on linear rail)
|
||||
- `opentrons_liquid_handler` (liquid handling/pipetting)
|
||||
- `agilent_plateloc` (plate sealer)
|
||||
- `inheco_odtc_96xl` (thermal cycler for PCR)
|
||||
|
||||
**Your output:**
|
||||
```json
|
||||
{
|
||||
"intents": [
|
||||
{
|
||||
"intent": "reachable_by",
|
||||
"params": {
|
||||
"arm": "arm_slider",
|
||||
"targets": [
|
||||
"thermo_orbitor_rs2_hotel",
|
||||
"opentrons_liquid_handler",
|
||||
"agilent_plateloc",
|
||||
"inheco_odtc_96xl"
|
||||
]
|
||||
},
|
||||
"description": "arm_slider must reach all devices since it handles all plate transfers"
|
||||
},
|
||||
{
|
||||
"intent": "workflow_hint",
|
||||
"params": {
|
||||
"workflow": "pcr",
|
||||
"devices": [
|
||||
"thermo_orbitor_rs2_hotel",
|
||||
"opentrons_liquid_handler",
|
||||
"agilent_plateloc",
|
||||
"inheco_odtc_96xl"
|
||||
]
|
||||
},
|
||||
"description": "PCR workflow order: hotel → liquid handler → plate sealer → thermal cycler"
|
||||
},
|
||||
{
|
||||
"intent": "close_together",
|
||||
"params": {
|
||||
"devices": ["opentrons_liquid_handler", "agilent_plateloc"],
|
||||
"priority": "high"
|
||||
},
|
||||
"description": "Sealing happens immediately after sample prep — minimize transit time"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Reasoning:**
|
||||
- The arm handles ALL transfers → all 4 devices in reachable_by targets
|
||||
- User described a clear sequence → workflow_hint in that order
|
||||
- "seal plate then pcr" implies sealing is immediately after prep → close_together for the pair with high priority
|
||||
|
||||
## Example: Simple Proximity Request
|
||||
|
||||
**User input:**
|
||||
> "Keep the thermal cycler close to the plate sealer, at most 1 meter apart"
|
||||
|
||||
**Your output:**
|
||||
```json
|
||||
{
|
||||
"intents": [
|
||||
{
|
||||
"intent": "max_distance",
|
||||
"params": {
|
||||
"device_a": "inheco_odtc_96xl",
|
||||
"device_b": "agilent_plateloc",
|
||||
"distance": 1.0
|
||||
},
|
||||
"description": "Thermal cycler and plate sealer must be within 1 meter"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## API Integration
|
||||
|
||||
### Discovery
|
||||
Call `GET /interpret/schema` to get the current list of available intent types and their parameter specifications. Always check this before translating, as new intent types may be added.
|
||||
|
||||
### Translation
|
||||
Send your output to `POST /interpret`:
|
||||
```
|
||||
POST /interpret
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"intents": [ ... your translated intents ... ]
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
The endpoint returns:
|
||||
- `constraints` — ready to pass to `/optimize`
|
||||
- `translations` — human-readable mapping of each intent to generated constraints
|
||||
- `workflow_edges` — extracted workflow connections
|
||||
- `errors` — any intents that failed to translate
|
||||
|
||||
### Optimization
|
||||
After user confirms the translation, pass `constraints` and `workflow_edges` to `POST /optimize` along with the device list and lab dimensions.
|
||||
110
unilabos/layout_optimizer/mock_checkers.py
Normal file
110
unilabos/layout_optimizer/mock_checkers.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Mock 检测器:无 ROS 依赖的简化碰撞与可达性检测。
|
||||
|
||||
碰撞检测基于 OBB SAT(O(n²) 两两比较)。
|
||||
可达性检测基于最大臂展半径的欧几里得距离判断。
|
||||
|
||||
集成阶段由 ros_checkers.py 中的 MoveItCollisionChecker / IKFastReachabilityChecker 替代。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from .obb import obb_corners, obb_overlap
|
||||
|
||||
|
||||
class MockCollisionChecker:
|
||||
"""基于 OBB SAT 的碰撞检测。
|
||||
|
||||
输入格式与 CollisionChecker Protocol 一致:
|
||||
placements: [{"id": str, "bbox": (w, d), "pos": (x, y, θ)}, ...]
|
||||
"""
|
||||
|
||||
def check(self, placements: list[dict]) -> list[tuple[str, str]]:
|
||||
"""返回所有碰撞的设备对。"""
|
||||
collisions: list[tuple[str, str]] = []
|
||||
n = len(placements)
|
||||
for i in range(n):
|
||||
for j in range(i + 1, n):
|
||||
a, b = placements[i], placements[j]
|
||||
corners_a = obb_corners(
|
||||
a["pos"][0], a["pos"][1],
|
||||
a["bbox"][0], a["bbox"][1],
|
||||
a["pos"][2] if len(a["pos"]) > 2 else 0.0,
|
||||
)
|
||||
corners_b = obb_corners(
|
||||
b["pos"][0], b["pos"][1],
|
||||
b["bbox"][0], b["bbox"][1],
|
||||
b["pos"][2] if len(b["pos"]) > 2 else 0.0,
|
||||
)
|
||||
if obb_overlap(corners_a, corners_b):
|
||||
collisions.append((a["id"], b["id"]))
|
||||
return collisions
|
||||
|
||||
def check_bounds(
|
||||
self, placements: list[dict], lab_width: float, lab_depth: float
|
||||
) -> list[str]:
|
||||
"""返回超出实验室边界的设备 ID 列表。"""
|
||||
out_of_bounds: list[str] = []
|
||||
for p in placements:
|
||||
hw, hd = self._rotated_half_extents(p)
|
||||
x, y = p["pos"][:2]
|
||||
if x - hw < 0 or x + hw > lab_width or y - hd < 0 or y + hd > lab_depth:
|
||||
out_of_bounds.append(p["id"])
|
||||
return out_of_bounds
|
||||
|
||||
@staticmethod
|
||||
def _rotated_half_extents(p: dict) -> tuple[float, float]:
|
||||
"""计算旋转后 AABB 的半宽和半深。"""
|
||||
w, d = p["bbox"]
|
||||
theta = p["pos"][2] if len(p["pos"]) > 2 else 0.0
|
||||
cos_t = abs(math.cos(theta))
|
||||
sin_t = abs(math.sin(theta))
|
||||
half_w = (w * cos_t + d * sin_t) / 2
|
||||
half_d = (w * sin_t + d * cos_t) / 2
|
||||
return half_w, half_d
|
||||
|
||||
|
||||
class MockReachabilityChecker:
|
||||
"""基于最大臂展半径的简化可达性判断。
|
||||
|
||||
内置常见 Elite CS 系列机械臂的臂展参数。
|
||||
自定义臂展可通过构造参数传入。
|
||||
"""
|
||||
|
||||
# 默认臂展参数(单位:米)
|
||||
DEFAULT_ARM_REACH: dict[str, float] = {
|
||||
"elite_cs63": 0.624,
|
||||
"elite_cs66": 0.914,
|
||||
"elite_cs612": 1.304,
|
||||
"elite_cs620": 1.800,
|
||||
"arm_slider": 0.3, # 线性导轨臂:1.07 body 2.14m × 0.35m,reach ≈ half length
|
||||
}
|
||||
|
||||
# 未知型号回退臂展:realistic default for lab-scale arms
|
||||
DEFAULT_FALLBACK_REACH: float = 0.4
|
||||
|
||||
def __init__(self, arm_reach: dict[str, float] | None = None):
|
||||
self.arm_reach = {**self.DEFAULT_ARM_REACH, **(arm_reach or {})}
|
||||
|
||||
def is_reachable(self, arm_id: str, arm_pose: dict, target: dict) -> bool:
|
||||
"""判断目标点是否在机械臂最大臂展半径内。
|
||||
|
||||
Uses OBB edge-to-edge distance when available (passed as _obb_dist),
|
||||
otherwise falls back to center-to-center Euclidean distance.
|
||||
|
||||
Args:
|
||||
arm_id: 机械臂型号 ID(用于查臂展)
|
||||
arm_pose: {"x": float, "y": float, "theta": float}
|
||||
target: {"x": float, "y": float, "z": float, "_obb_dist": float (optional)}
|
||||
|
||||
Returns:
|
||||
True 如果目标在臂展半径内
|
||||
"""
|
||||
max_reach = self.arm_reach.get(arm_id, self.DEFAULT_FALLBACK_REACH)
|
||||
if "_obb_dist" in target:
|
||||
return target["_obb_dist"] <= max_reach
|
||||
dx = target["x"] - arm_pose["x"]
|
||||
dy = target["y"] - arm_pose["y"]
|
||||
dist_sq = dx**2 + dy**2
|
||||
return dist_sq <= max_reach**2
|
||||
96
unilabos/layout_optimizer/models.py
Normal file
96
unilabos/layout_optimizer/models.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""数据模型定义:Device, Lab, Placement, Constraint 及 API 请求/响应。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
|
||||
|
||||
@dataclass
|
||||
class Opening:
|
||||
"""设备的访问开口(用于方向约束)。"""
|
||||
|
||||
# 设备局部坐标系中的方向单位向量,如 (0, -1) = 正前方
|
||||
direction: tuple[float, float] = (0.0, -1.0)
|
||||
label: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Device:
|
||||
"""设备描述。"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
# 碰撞包围盒 (width along X, depth along Y),单位:米
|
||||
bbox: tuple[float, float] = (0.6, 0.4)
|
||||
device_type: Literal["static", "articulation", "rigid"] = "static"
|
||||
# 以下为可选扩展字段(向后兼容)
|
||||
height: float = 0.4
|
||||
origin_offset: tuple[float, float] = (0.0, 0.0)
|
||||
openings: list[Opening] = field(default_factory=list)
|
||||
source: Literal["registry", "assets", "manual"] = "manual"
|
||||
model_path: str = ""
|
||||
model_type: str = ""
|
||||
thumbnail_url: str = ""
|
||||
uuid: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Obstacle:
|
||||
"""实验室内固定障碍物(矩形)。"""
|
||||
|
||||
x: float
|
||||
y: float
|
||||
width: float
|
||||
depth: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class Lab:
|
||||
"""实验室平面图。"""
|
||||
|
||||
width: float # X 方向,单位:米
|
||||
depth: float # Y 方向,单位:米
|
||||
obstacles: list[Obstacle] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Placement:
|
||||
"""单个设备的布局位姿。"""
|
||||
|
||||
device_id: str
|
||||
x: float
|
||||
y: float
|
||||
theta: float # 旋转角,弧度
|
||||
uuid: str = "" # 前端分配的唯一标识,透传不生成
|
||||
|
||||
def rotated_bbox(self, device: Device) -> tuple[float, float]:
|
||||
"""返回旋转后的 AABB 尺寸 (half_w, half_h)。"""
|
||||
w, d = device.bbox
|
||||
cos_t = abs(math.cos(self.theta))
|
||||
sin_t = abs(math.sin(self.theta))
|
||||
half_w = (w * cos_t + d * sin_t) / 2
|
||||
half_h = (w * sin_t + d * cos_t) / 2
|
||||
return half_w, half_h
|
||||
|
||||
|
||||
@dataclass
|
||||
class Constraint:
|
||||
"""约束规则。"""
|
||||
|
||||
type: Literal["hard", "soft"]
|
||||
rule_name: str
|
||||
# 规则参数,含义因 rule_name 而异
|
||||
params: dict = field(default_factory=dict)
|
||||
# 仅 soft 约束使用
|
||||
weight: float = 1.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class Intent:
|
||||
"""LLM 可生成的语义化意图,由 interpreter 翻译为 Constraint 列表。"""
|
||||
|
||||
intent: str # 意图类型,如 "reachable_by", "close_together"
|
||||
params: dict = field(default_factory=dict)
|
||||
description: str = "" # 可选的自然语言描述(用于审计/调试)
|
||||
257
unilabos/layout_optimizer/obb.py
Normal file
257
unilabos/layout_optimizer/obb.py
Normal file
@@ -0,0 +1,257 @@
|
||||
"""OBB (Oriented Bounding Box) geometry: corners, overlap (SAT), minimum distance."""
|
||||
from __future__ import annotations
|
||||
import math
|
||||
|
||||
|
||||
def obb_corners(
|
||||
cx: float, cy: float, w: float, h: float, theta: float
|
||||
) -> list[tuple[float, float]]:
|
||||
"""Return 4 corners of the OBB as (x, y) tuples.
|
||||
|
||||
Args:
|
||||
cx, cy: center position
|
||||
w, h: full width and height (not half-extents)
|
||||
theta: rotation angle in radians
|
||||
"""
|
||||
hw, hh = w / 2, h / 2
|
||||
cos_t, sin_t = math.cos(theta), math.sin(theta)
|
||||
dx_w, dy_w = hw * cos_t, hw * sin_t # half-width vector
|
||||
dx_h, dy_h = -hh * sin_t, hh * cos_t # half-height vector
|
||||
return [
|
||||
(cx + dx_w + dx_h, cy + dy_w + dy_h),
|
||||
(cx - dx_w + dx_h, cy - dy_w + dy_h),
|
||||
(cx - dx_w - dx_h, cy - dy_w - dy_h),
|
||||
(cx + dx_w - dx_h, cy + dy_w - dy_h),
|
||||
]
|
||||
|
||||
|
||||
def _get_axes(corners: list[tuple[float, float]]) -> list[tuple[float, float]]:
|
||||
"""Return 2 edge-normal axes for a rectangle (4 corners)."""
|
||||
axes = []
|
||||
for i in range(2): # Only need 2 axes for a rectangle
|
||||
edge_x = corners[i + 1][0] - corners[i][0]
|
||||
edge_y = corners[i + 1][1] - corners[i][1]
|
||||
length = math.sqrt(edge_x**2 + edge_y**2)
|
||||
if length > 0:
|
||||
axes.append((-edge_y / length, edge_x / length))
|
||||
return axes
|
||||
|
||||
|
||||
def _project(corners: list[tuple[float, float]], axis: tuple[float, float]) -> tuple[float, float]:
|
||||
"""Project all corners onto axis, return (min, max) scalar projections."""
|
||||
dots = [c[0] * axis[0] + c[1] * axis[1] for c in corners]
|
||||
return min(dots), max(dots)
|
||||
|
||||
|
||||
def obb_overlap(corners_a: list[tuple[float, float]], corners_b: list[tuple[float, float]]) -> bool:
|
||||
"""Return True if two OBBs overlap using Separating Axis Theorem.
|
||||
|
||||
Uses strict inequality (touching edges = no overlap).
|
||||
"""
|
||||
for axis in _get_axes(corners_a) + _get_axes(corners_b):
|
||||
min_a, max_a = _project(corners_a, axis)
|
||||
min_b, max_b = _project(corners_b, axis)
|
||||
if max_a <= min_b or max_b <= min_a:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _point_to_segment_dist_sq(
|
||||
px: float, py: float,
|
||||
ax: float, ay: float,
|
||||
bx: float, by: float,
|
||||
) -> float:
|
||||
"""Squared distance from point (px,py) to line segment (ax,ay)-(bx,by)."""
|
||||
dx, dy = bx - ax, by - ay
|
||||
len_sq = dx * dx + dy * dy
|
||||
if len_sq == 0:
|
||||
return (px - ax) ** 2 + (py - ay) ** 2
|
||||
t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / len_sq))
|
||||
proj_x, proj_y = ax + t * dx, ay + t * dy
|
||||
return (px - proj_x) ** 2 + (py - proj_y) ** 2
|
||||
|
||||
|
||||
def obb_penetration_depth(
|
||||
corners_a: list[tuple[float, float]],
|
||||
corners_b: list[tuple[float, float]],
|
||||
) -> float:
|
||||
"""Minimum penetration depth between two OBBs (SAT-based).
|
||||
|
||||
Returns 0.0 if not overlapping. Otherwise returns the minimum overlap
|
||||
along any separating axis — the smallest push needed to separate them.
|
||||
"""
|
||||
min_overlap = float("inf")
|
||||
for axis in _get_axes(corners_a) + _get_axes(corners_b):
|
||||
min_a, max_a = _project(corners_a, axis)
|
||||
min_b, max_b = _project(corners_b, axis)
|
||||
overlap = min(max_a - min_b, max_b - min_a)
|
||||
if overlap <= 0:
|
||||
return 0.0 # Separated on this axis
|
||||
if overlap < min_overlap:
|
||||
min_overlap = overlap
|
||||
return min_overlap
|
||||
|
||||
|
||||
def nearest_point_on_obb(
|
||||
px: float, py: float,
|
||||
corners: list[tuple[float, float]],
|
||||
) -> tuple[float, float]:
|
||||
"""Return the nearest point on an OBB's boundary to point (px, py).
|
||||
|
||||
If the point is inside the OBB, returns the nearest edge point.
|
||||
"""
|
||||
best_x, best_y = corners[0]
|
||||
best_dist_sq = float("inf")
|
||||
n = len(corners)
|
||||
for i in range(n):
|
||||
ax, ay = corners[i]
|
||||
bx, by = corners[(i + 1) % n]
|
||||
dx, dy = bx - ax, by - ay
|
||||
len_sq = dx * dx + dy * dy
|
||||
if len_sq == 0:
|
||||
proj_x, proj_y = ax, ay
|
||||
else:
|
||||
t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / len_sq))
|
||||
proj_x, proj_y = ax + t * dx, ay + t * dy
|
||||
d_sq = (px - proj_x) ** 2 + (py - proj_y) ** 2
|
||||
if d_sq < best_dist_sq:
|
||||
best_dist_sq = d_sq
|
||||
best_x, best_y = proj_x, proj_y
|
||||
return best_x, best_y
|
||||
|
||||
|
||||
def segment_intersects_obb(
|
||||
p1: tuple[float, float],
|
||||
p2: tuple[float, float],
|
||||
corners: list[tuple[float, float]],
|
||||
) -> bool:
|
||||
"""Return True if line segment p1-p2 intersects the OBB (convex polygon).
|
||||
|
||||
Uses separating axis theorem on the Minkowski difference:
|
||||
test segment against each edge of the polygon + segment normal.
|
||||
"""
|
||||
# Quick: test each edge of the OBB against the segment
|
||||
n = len(corners)
|
||||
for i in range(n):
|
||||
ax, ay = corners[i]
|
||||
bx, by = corners[(i + 1) % n]
|
||||
if _segments_intersect(p1[0], p1[1], p2[0], p2[1], ax, ay, bx, by):
|
||||
return True
|
||||
# Also check if segment is fully inside the OBB
|
||||
if _point_in_convex(p1, corners) or _point_in_convex(p2, corners):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _segments_intersect(
|
||||
ax: float, ay: float, bx: float, by: float,
|
||||
cx: float, cy: float, dx: float, dy: float,
|
||||
) -> bool:
|
||||
"""Return True if segment AB intersects segment CD (proper or endpoint)."""
|
||||
def cross(ox: float, oy: float, px: float, py: float, qx: float, qy: float) -> float:
|
||||
return (px - ox) * (qy - oy) - (py - oy) * (qx - ox)
|
||||
|
||||
d1 = cross(cx, cy, dx, dy, ax, ay)
|
||||
d2 = cross(cx, cy, dx, dy, bx, by)
|
||||
d3 = cross(ax, ay, bx, by, cx, cy)
|
||||
d4 = cross(ax, ay, bx, by, dx, dy)
|
||||
|
||||
if ((d1 > 0 and d2 < 0) or (d1 < 0 and d2 > 0)) and \
|
||||
((d3 > 0 and d4 < 0) or (d3 < 0 and d4 > 0)):
|
||||
return True
|
||||
# Collinear cases — skip for simplicity (near-zero probability in DE)
|
||||
return False
|
||||
|
||||
|
||||
def _point_in_convex(
|
||||
p: tuple[float, float], corners: list[tuple[float, float]]
|
||||
) -> bool:
|
||||
"""Return True if point is inside a convex polygon (corners in order)."""
|
||||
n = len(corners)
|
||||
sign = None
|
||||
for i in range(n):
|
||||
ax, ay = corners[i]
|
||||
bx, by = corners[(i + 1) % n]
|
||||
cross = (bx - ax) * (p[1] - ay) - (by - ay) * (p[0] - ax)
|
||||
if abs(cross) < 1e-12:
|
||||
continue
|
||||
s = cross > 0
|
||||
if sign is None:
|
||||
sign = s
|
||||
elif s != sign:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def segment_obb_intersection_length(
|
||||
p1: tuple[float, float],
|
||||
p2: tuple[float, float],
|
||||
corners: list[tuple[float, float]],
|
||||
) -> float:
|
||||
"""线段 p1-p2 与 OBB(凸多边形)的交集长度。
|
||||
|
||||
Cyrus-Beck 线段裁剪算法。corners 假定为 CCW 顺序(obb_corners 生成)。
|
||||
无交集返回 0.0。
|
||||
"""
|
||||
dx = p2[0] - p1[0]
|
||||
dy = p2[1] - p1[1]
|
||||
seg_len_sq = dx * dx + dy * dy
|
||||
if seg_len_sq < 1e-24:
|
||||
return 0.0
|
||||
|
||||
t_enter = 0.0
|
||||
t_exit = 1.0
|
||||
n = len(corners)
|
||||
|
||||
for i in range(n):
|
||||
ax, ay = corners[i]
|
||||
bx, by = corners[(i + 1) % n]
|
||||
# CCW 多边形边的外法线: (ey, -ex), e = b - a
|
||||
ex, ey = bx - ax, by - ay
|
||||
nx, ny = ey, -ex
|
||||
|
||||
denom = nx * dx + ny * dy
|
||||
numer = nx * (p1[0] - ax) + ny * (p1[1] - ay)
|
||||
|
||||
if abs(denom) < 1e-12:
|
||||
if numer > 0:
|
||||
return 0.0 # 在此边外侧且平行
|
||||
continue
|
||||
|
||||
t = -numer / denom
|
||||
if denom < 0:
|
||||
t_enter = max(t_enter, t) # 进入
|
||||
else:
|
||||
t_exit = min(t_exit, t) # 退出
|
||||
|
||||
if t_enter > t_exit:
|
||||
return 0.0
|
||||
|
||||
if t_enter >= t_exit:
|
||||
return 0.0
|
||||
|
||||
return (t_exit - t_enter) * math.sqrt(seg_len_sq)
|
||||
|
||||
|
||||
def obb_min_distance(
|
||||
corners_a: list[tuple[float, float]],
|
||||
corners_b: list[tuple[float, float]],
|
||||
) -> float:
|
||||
"""Minimum distance between two OBBs (convex polygons).
|
||||
|
||||
Returns 0.0 if overlapping or touching.
|
||||
"""
|
||||
if obb_overlap(corners_a, corners_b):
|
||||
return 0.0
|
||||
|
||||
min_dist_sq = float("inf")
|
||||
for poly, other in [(corners_a, corners_b), (corners_b, corners_a)]:
|
||||
n = len(other)
|
||||
for px, py in poly:
|
||||
for i in range(n):
|
||||
ax, ay = other[i]
|
||||
bx, by = other[(i + 1) % n]
|
||||
d_sq = _point_to_segment_dist_sq(px, py, ax, ay, bx, by)
|
||||
if d_sq < min_dist_sq:
|
||||
min_dist_sq = d_sq
|
||||
return math.sqrt(min_dist_sq)
|
||||
1056
unilabos/layout_optimizer/optimizer.py
Normal file
1056
unilabos/layout_optimizer/optimizer.py
Normal file
File diff suppressed because it is too large
Load Diff
144
unilabos/layout_optimizer/pencil_integration.py
Normal file
144
unilabos/layout_optimizer/pencil_integration.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""初始布局生成:Pencil MCP 接口 + 行列式回退。
|
||||
|
||||
策略:
|
||||
1. 尝试调用 Pencil AI MCP 生成初始布局
|
||||
2. 若 Pencil 不可用或失败,回退到行列式放置算法
|
||||
|
||||
行列式回退逻辑:
|
||||
- 设备按面积从大到小排序
|
||||
- 沿 X 轴逐个放置,行满(超出 lab.width)则换行
|
||||
- 设备间保留 margin 间距
|
||||
- 所有设备 θ=0(朝向不变)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from .models import Device, Lab, Placement
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 设备间最小间距(米)
|
||||
DEFAULT_MARGIN = 0.3
|
||||
|
||||
|
||||
def generate_initial_layout(
|
||||
devices: list[Device],
|
||||
lab: Lab,
|
||||
margin: float = DEFAULT_MARGIN,
|
||||
) -> list[Placement]:
|
||||
"""生成初始布局方案。
|
||||
|
||||
优先尝试 Pencil MCP,失败则回退到行列式放置。
|
||||
|
||||
Args:
|
||||
devices: 待放置的设备列表
|
||||
lab: 实验室平面图
|
||||
margin: 设备间最小间距
|
||||
|
||||
Returns:
|
||||
初始布局 Placement 列表
|
||||
"""
|
||||
# 尝试 Pencil MCP
|
||||
pencil_result = _try_pencil(devices, lab)
|
||||
if pencil_result is not None:
|
||||
logger.info("Using Pencil AI generated layout")
|
||||
return pencil_result
|
||||
|
||||
# 回退到行列式
|
||||
logger.info("Pencil unavailable, using row-based fallback layout")
|
||||
return generate_fallback(devices, lab, margin)
|
||||
|
||||
|
||||
def _try_pencil(
|
||||
devices: list[Device],
|
||||
lab: Lab,
|
||||
) -> list[Placement] | None:
|
||||
"""尝试通过 Pencil AI MCP 生成布局。
|
||||
|
||||
当前 Pencil MCP 不可用,返回 None 触发回退。
|
||||
未来集成时,此函数应:
|
||||
1. 将设备 2D 投影 + 实验室平面图序列化为 Pencil 输入格式
|
||||
2. 调用 mcp__pencil_* 工具
|
||||
3. 解析返回的布局方案为 Placement 列表
|
||||
|
||||
预留接口参数:
|
||||
- devices: 设备列表(id, bbox)
|
||||
- lab: 实验室尺寸
|
||||
"""
|
||||
# TODO: 当 Pencil MCP 可用时实现
|
||||
# 预期调用方式:
|
||||
# pencil_input = {
|
||||
# "floor_plan": {"width": lab.width, "depth": lab.depth},
|
||||
# "items": [{"id": d.id, "width": d.bbox[0], "depth": d.bbox[1]} for d in devices],
|
||||
# }
|
||||
# result = mcp__pencil_layout(pencil_input)
|
||||
# return [Placement(device_id=r["id"], x=r["x"], y=r["y"], theta=r["theta"]) for r in result]
|
||||
return None
|
||||
|
||||
|
||||
def generate_fallback(
|
||||
devices: list[Device],
|
||||
lab: Lab,
|
||||
margin: float = DEFAULT_MARGIN,
|
||||
) -> list[Placement]:
|
||||
"""行列式回退布局:按面积从大到小排序,逐行放置。
|
||||
|
||||
放置规则:
|
||||
- 设备中心坐标,从左上角开始
|
||||
- 每行从 margin + half_width 开始
|
||||
- 行满(下一个设备右边缘超出 lab.width - margin)则换行
|
||||
- 行高取该行最大设备深度
|
||||
|
||||
Args:
|
||||
devices: 待放置的设备列表
|
||||
lab: 实验室平面图
|
||||
margin: 设备间最小间距
|
||||
|
||||
Returns:
|
||||
Placement 列表。若实验室空间不足,剩余设备堆叠在右下角并记录警告。
|
||||
"""
|
||||
if not devices:
|
||||
return []
|
||||
|
||||
# 按面积从大到小排序
|
||||
sorted_devices = sorted(devices, key=lambda d: d.bbox[0] * d.bbox[1], reverse=True)
|
||||
|
||||
placements: list[Placement] = []
|
||||
cursor_x = margin
|
||||
cursor_y = margin
|
||||
row_height = 0.0
|
||||
|
||||
for dev in sorted_devices:
|
||||
w, d = dev.bbox
|
||||
half_w = w / 2
|
||||
half_d = d / 2
|
||||
|
||||
# 检查当前行是否放得下
|
||||
if cursor_x + half_w + margin > lab.width and placements:
|
||||
# 换行
|
||||
cursor_x = margin
|
||||
cursor_y += row_height + margin
|
||||
row_height = 0.0
|
||||
|
||||
# 设备中心位置
|
||||
cx = cursor_x + half_w
|
||||
cy = cursor_y + half_d
|
||||
|
||||
# 检查是否超出实验室深度
|
||||
if cy + half_d + margin > lab.depth:
|
||||
logger.warning(
|
||||
"Lab space insufficient for device '%s' (%s), "
|
||||
"placing at overflow position",
|
||||
dev.id,
|
||||
dev.bbox,
|
||||
)
|
||||
|
||||
placements.append(Placement(device_id=dev.id, x=cx, y=cy, theta=0.0))
|
||||
|
||||
# 更新游标
|
||||
cursor_x = cx + half_w + margin
|
||||
row_height = max(row_height, d)
|
||||
|
||||
return placements
|
||||
29
unilabos/layout_optimizer/pyproject.toml
Normal file
29
unilabos/layout_optimizer/pyproject.toml
Normal file
@@ -0,0 +1,29 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "layout-optimizer"
|
||||
version = "0.1.0"
|
||||
description = "AI laboratory layout optimizer for Uni-Lab Phase 3"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"scipy>=1.10",
|
||||
"numpy>=1.24",
|
||||
"fastapi>=0.100",
|
||||
"uvicorn>=0.20",
|
||||
"pydantic>=2.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.0",
|
||||
"httpx>=0.24",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["layout_optimizer*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
433
unilabos/layout_optimizer/ros_checkers.py
Normal file
433
unilabos/layout_optimizer/ros_checkers.py
Normal file
@@ -0,0 +1,433 @@
|
||||
"""ROS2/MoveIt2 碰撞检测与 IKFast 可达性检测适配器。
|
||||
|
||||
集成阶段替换 mock_checkers.py 中的 Mock 实现,
|
||||
依赖 Uni-Lab-OS 的 moveit2.py 提供的 MoveIt2 Python 接口。
|
||||
|
||||
用法:
|
||||
from .ros_checkers import MoveItCollisionChecker, IKFastReachabilityChecker
|
||||
|
||||
# 碰撞检测
|
||||
checker = MoveItCollisionChecker(moveit2_instance)
|
||||
collisions = checker.check(placements)
|
||||
|
||||
# 可达性检测(体素图 O(1) 查询 + 实时 IK 回退)
|
||||
reachability = IKFastReachabilityChecker(moveit2_instance, voxel_dir="/path/to/voxels")
|
||||
reachable = reachability.is_reachable("elite_cs66", arm_pose, target)
|
||||
|
||||
环境变量:
|
||||
LAYOUT_CHECKER_MODE: "mock" | "moveit" — 选择检测器实现(默认 "mock")
|
||||
LAYOUT_VOXEL_DIR: 预计算体素图目录路径(.npz 文件)
|
||||
|
||||
前置条件:
|
||||
- ROS2 + MoveIt2 运行中
|
||||
- moveit2.py 中的 MoveIt2 实例已初始化
|
||||
- 命名规范:碰撞对象使用 {device_id}_ 前缀
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .obb import obb_corners, obb_overlap
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------- 坐标变换辅助 ----------
|
||||
|
||||
|
||||
def _yaw_to_quat(theta: float) -> tuple[float, float, float, float]:
|
||||
"""将 2D 旋转角(绕 Z 轴弧度)转换为四元数 (x, y, z, w)。"""
|
||||
return (0.0, 0.0, math.sin(theta / 2), math.cos(theta / 2))
|
||||
|
||||
|
||||
def _transform_to_arm_frame(
|
||||
arm_pose: dict, target: dict,
|
||||
) -> tuple[float, float, float]:
|
||||
"""将目标点从世界坐标系变换到机械臂基坐标系。
|
||||
|
||||
Args:
|
||||
arm_pose: {"x": float, "y": float, "theta": float}
|
||||
target: {"x": float, "y": float, "z": float}
|
||||
|
||||
Returns:
|
||||
(local_x, local_y, local_z) 在臂基坐标系中的位置
|
||||
"""
|
||||
dx = target["x"] - arm_pose["x"]
|
||||
dy = target["y"] - arm_pose["y"]
|
||||
theta = arm_pose.get("theta", 0.0)
|
||||
cos_t = math.cos(-theta)
|
||||
sin_t = math.sin(-theta)
|
||||
local_x = dx * cos_t - dy * sin_t
|
||||
local_y = dx * sin_t + dy * cos_t
|
||||
local_z = target.get("z", 0.0)
|
||||
return (local_x, local_y, local_z)
|
||||
|
||||
|
||||
# ---------- MoveItCollisionChecker ----------
|
||||
|
||||
|
||||
class MoveItCollisionChecker:
|
||||
"""通过 MoveIt2 PlanningScene 进行碰撞检测。
|
||||
|
||||
工作流程:
|
||||
1. 将所有设备同步为 MoveIt2 碰撞盒({device_id}_ 前缀)
|
||||
2. 使用 python-fcl 进行精确两两碰撞检测(若可用)
|
||||
3. 若 FCL 不可用,回退到 OBB SAT 检测
|
||||
|
||||
同步到 MoveIt2 确保机器人运动规划也能感知设备布局。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
moveit2: Any,
|
||||
*,
|
||||
default_height: float = 0.4,
|
||||
sync_to_scene: bool = True,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
moveit2: Uni-Lab-OS moveit2.py 中的 MoveIt2 实例
|
||||
default_height: 碰撞盒默认高度(米)
|
||||
sync_to_scene: 是否同步碰撞对象到 MoveIt2 规划场景
|
||||
"""
|
||||
self._moveit2 = moveit2
|
||||
self._default_height = default_height
|
||||
self._sync_to_scene = sync_to_scene
|
||||
self._fcl_available = self._check_fcl()
|
||||
|
||||
@staticmethod
|
||||
def _check_fcl() -> bool:
|
||||
"""检查 python-fcl 是否可用。"""
|
||||
try:
|
||||
import fcl # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
def check(self, placements: list[dict]) -> list[tuple[str, str]]:
|
||||
"""返回碰撞设备对列表。
|
||||
|
||||
Args:
|
||||
placements: [{"id": str, "bbox": (w, d), "pos": (x, y, θ)}, ...]
|
||||
|
||||
Returns:
|
||||
[("device_a", "device_b"), ...] 存在碰撞的设备对
|
||||
"""
|
||||
# 同步到 MoveIt2 规划场景
|
||||
if self._sync_to_scene:
|
||||
self._sync_collision_objects(placements)
|
||||
|
||||
# 碰撞检测
|
||||
if self._fcl_available:
|
||||
return self._check_with_fcl(placements)
|
||||
return self._check_with_obb(placements)
|
||||
|
||||
def check_bounds(
|
||||
self, placements: list[dict], lab_width: float, lab_depth: float,
|
||||
) -> list[str]:
|
||||
"""返回超出实验室边界的设备 ID 列表。"""
|
||||
out_of_bounds: list[str] = []
|
||||
for p in placements:
|
||||
hw, hd = self._rotated_half_extents(p)
|
||||
x, y = p["pos"][:2]
|
||||
if x - hw < 0 or x + hw > lab_width or y - hd < 0 or y + hd > lab_depth:
|
||||
out_of_bounds.append(p["id"])
|
||||
return out_of_bounds
|
||||
|
||||
def _sync_collision_objects(self, placements: list[dict]) -> None:
|
||||
"""将设备布局同步到 MoveIt2 规划场景。
|
||||
|
||||
使用 {device_id}_ 前缀命名碰撞对象。
|
||||
"""
|
||||
for p in placements:
|
||||
obj_id = f"{p['id']}_"
|
||||
w, d = p["bbox"]
|
||||
x, y = p["pos"][:2]
|
||||
theta = p["pos"][2] if len(p["pos"]) > 2 else 0.0
|
||||
h = self._default_height
|
||||
|
||||
try:
|
||||
self._moveit2.add_collision_box(
|
||||
id=obj_id,
|
||||
size=(w, d, h),
|
||||
position=(x, y, h / 2),
|
||||
quat_xyzw=_yaw_to_quat(theta),
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Failed to sync collision object %s", obj_id, exc_info=True)
|
||||
|
||||
def _check_with_fcl(self, placements: list[dict]) -> list[tuple[str, str]]:
|
||||
"""使用 python-fcl 进行精确碰撞检测。"""
|
||||
import fcl
|
||||
|
||||
objects: list[tuple[str, Any]] = []
|
||||
for p in placements:
|
||||
w, d = p["bbox"]
|
||||
h = self._default_height
|
||||
x, y = p["pos"][:2]
|
||||
theta = p["pos"][2] if len(p["pos"]) > 2 else 0.0
|
||||
|
||||
geom = fcl.Box(w, d, h)
|
||||
tf = fcl.Transform(
|
||||
_yaw_to_rotation_matrix(theta),
|
||||
np.array([x, y, h / 2]),
|
||||
)
|
||||
obj = fcl.CollisionObject(geom, tf)
|
||||
objects.append((p["id"], obj))
|
||||
|
||||
collisions: list[tuple[str, str]] = []
|
||||
n = len(objects)
|
||||
for i in range(n):
|
||||
for j in range(i + 1, n):
|
||||
id_a, obj_a = objects[i]
|
||||
id_b, obj_b = objects[j]
|
||||
request = fcl.CollisionRequest()
|
||||
result = fcl.CollisionResult()
|
||||
ret = fcl.collide(obj_a, obj_b, request, result)
|
||||
if ret > 0:
|
||||
collisions.append((id_a, id_b))
|
||||
|
||||
return collisions
|
||||
|
||||
def _check_with_obb(self, placements: list[dict]) -> list[tuple[str, str]]:
|
||||
"""OBB SAT 回退检测(与 MockCollisionChecker 相同算法)。"""
|
||||
collisions: list[tuple[str, str]] = []
|
||||
n = len(placements)
|
||||
for i in range(n):
|
||||
for j in range(i + 1, n):
|
||||
a, b = placements[i], placements[j]
|
||||
corners_a = obb_corners(
|
||||
a["pos"][0], a["pos"][1],
|
||||
a["bbox"][0], a["bbox"][1],
|
||||
a["pos"][2] if len(a["pos"]) > 2 else 0.0,
|
||||
)
|
||||
corners_b = obb_corners(
|
||||
b["pos"][0], b["pos"][1],
|
||||
b["bbox"][0], b["bbox"][1],
|
||||
b["pos"][2] if len(b["pos"]) > 2 else 0.0,
|
||||
)
|
||||
if obb_overlap(corners_a, corners_b):
|
||||
collisions.append((a["id"], b["id"]))
|
||||
return collisions
|
||||
|
||||
@staticmethod
|
||||
def _rotated_half_extents(p: dict) -> tuple[float, float]:
|
||||
"""计算旋转后 AABB 的半宽和半深。"""
|
||||
w, d = p["bbox"]
|
||||
theta = p["pos"][2] if len(p["pos"]) > 2 else 0.0
|
||||
cos_t = abs(math.cos(theta))
|
||||
sin_t = abs(math.sin(theta))
|
||||
half_w = (w * cos_t + d * sin_t) / 2
|
||||
half_d = (w * sin_t + d * cos_t) / 2
|
||||
return half_w, half_d
|
||||
|
||||
|
||||
# ---------- IKFastReachabilityChecker ----------
|
||||
|
||||
|
||||
class IKFastReachabilityChecker:
|
||||
"""基于 MoveIt2 compute_ik 和预计算体素图的可达性检测。
|
||||
|
||||
双模式:
|
||||
1. 体素图模式(O(1)):从 .npz 文件加载预计算可达性网格,
|
||||
将目标点变换到臂基坐标系后直接查表。
|
||||
2. 实时 IK 模式(~5ms/call):调用 MoveIt2.compute_ik(),
|
||||
支持约束感知的精确可达性判断。
|
||||
|
||||
优先使用体素图,无匹配时回退到实时 IK。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
moveit2: Any = None,
|
||||
*,
|
||||
voxel_dir: str | Path | None = None,
|
||||
voxel_resolution: float = 0.01,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
moveit2: MoveIt2 实例(用于实时 IK 回退)
|
||||
voxel_dir: 预计算体素图目录(.npz 文件,文件名 = arm_id)
|
||||
voxel_resolution: 体素分辨率(米),用于坐标 → 索引转换
|
||||
"""
|
||||
self._moveit2 = moveit2
|
||||
self._voxel_resolution = voxel_resolution
|
||||
self._voxel_maps: dict[str, _VoxelMap] = {}
|
||||
|
||||
if voxel_dir is not None:
|
||||
self._load_voxel_maps(Path(voxel_dir))
|
||||
|
||||
def is_reachable(self, arm_id: str, arm_pose: dict, target: dict) -> bool:
|
||||
"""判断机械臂在给定位姿下能否到达目标点。
|
||||
|
||||
Args:
|
||||
arm_id: 机械臂设备 ID
|
||||
arm_pose: {"x": float, "y": float, "theta": float}
|
||||
target: {"x": float, "y": float, "z": float}
|
||||
|
||||
Returns:
|
||||
True 如果可达
|
||||
"""
|
||||
local = _transform_to_arm_frame(arm_pose, target)
|
||||
|
||||
# 1. 体素图查询(O(1))
|
||||
if arm_id in self._voxel_maps:
|
||||
return self._check_voxel(arm_id, local)
|
||||
|
||||
# 2. 实时 IK 回退
|
||||
if self._moveit2 is not None:
|
||||
return self._check_live_ik(local)
|
||||
|
||||
# 无可用检测方式,乐观返回(记录警告)
|
||||
logger.warning(
|
||||
"No reachability checker available for arm %s, returning True", arm_id,
|
||||
)
|
||||
return True
|
||||
|
||||
def _load_voxel_maps(self, voxel_dir: Path) -> None:
|
||||
"""加载目录下所有 .npz 体素图文件。
|
||||
|
||||
文件格式:{arm_id}.npz,包含:
|
||||
- "grid": bool ndarray (nx, ny, nz) — True 表示可达
|
||||
- "origin": float ndarray (3,) — 网格原点(臂基坐标系)
|
||||
- "resolution": float — 体素分辨率(米)
|
||||
"""
|
||||
if not voxel_dir.exists():
|
||||
logger.warning("Voxel directory does not exist: %s", voxel_dir)
|
||||
return
|
||||
|
||||
for npz_file in voxel_dir.glob("*.npz"):
|
||||
arm_id = npz_file.stem
|
||||
try:
|
||||
data = np.load(str(npz_file))
|
||||
grid = data["grid"].astype(bool)
|
||||
origin = data["origin"].astype(float)
|
||||
resolution = float(data.get("resolution", self._voxel_resolution))
|
||||
self._voxel_maps[arm_id] = _VoxelMap(
|
||||
grid=grid, origin=origin, resolution=resolution,
|
||||
)
|
||||
logger.info(
|
||||
"Loaded voxel map for %s: shape=%s, resolution=%.3f",
|
||||
arm_id, grid.shape, resolution,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Failed to load voxel map %s", npz_file, exc_info=True)
|
||||
|
||||
def _check_voxel(self, arm_id: str, local: tuple[float, float, float]) -> bool:
|
||||
"""通过体素网格查询可达性。"""
|
||||
vm = self._voxel_maps[arm_id]
|
||||
ix = int(round((local[0] - vm.origin[0]) / vm.resolution))
|
||||
iy = int(round((local[1] - vm.origin[1]) / vm.resolution))
|
||||
iz = int(round((local[2] - vm.origin[2]) / vm.resolution))
|
||||
|
||||
if (
|
||||
0 <= ix < vm.grid.shape[0]
|
||||
and 0 <= iy < vm.grid.shape[1]
|
||||
and 0 <= iz < vm.grid.shape[2]
|
||||
):
|
||||
return bool(vm.grid[ix, iy, iz])
|
||||
|
||||
# 超出体素图范围 → 不可达
|
||||
return False
|
||||
|
||||
def _check_live_ik(self, local: tuple[float, float, float]) -> bool:
|
||||
"""调用 MoveIt2.compute_ik() 进行实时可达性检测。
|
||||
|
||||
compute_ik 返回 JointState(成功)或 None(不可达)。
|
||||
使用默认朝下姿态(四元数 0, 1, 0, 0 即绕 X 轴旋转 180°)。
|
||||
"""
|
||||
# 目标姿态:末端执行器朝下
|
||||
quat_xyzw = (0.0, 1.0, 0.0, 0.0)
|
||||
try:
|
||||
result = self._moveit2.compute_ik(
|
||||
position=local,
|
||||
quat_xyzw=quat_xyzw,
|
||||
)
|
||||
return result is not None
|
||||
except Exception:
|
||||
logger.warning("compute_ik call failed", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
# ---------- 体素图数据类 ----------
|
||||
|
||||
|
||||
class _VoxelMap:
|
||||
"""预计算可达性体素网格。"""
|
||||
|
||||
__slots__ = ("grid", "origin", "resolution")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
grid: np.ndarray,
|
||||
origin: np.ndarray,
|
||||
resolution: float,
|
||||
):
|
||||
self.grid = grid
|
||||
self.origin = origin
|
||||
self.resolution = resolution
|
||||
|
||||
|
||||
# ---------- FCL 辅助 ----------
|
||||
|
||||
|
||||
def _yaw_to_rotation_matrix(theta: float) -> np.ndarray:
|
||||
"""绕 Z 轴旋转矩阵(3×3)。"""
|
||||
c, s = math.cos(theta), math.sin(theta)
|
||||
return np.array([
|
||||
[c, -s, 0.0],
|
||||
[s, c, 0.0],
|
||||
[0.0, 0.0, 1.0],
|
||||
])
|
||||
|
||||
|
||||
# ---------- 工厂函数 ----------
|
||||
|
||||
|
||||
def create_checkers(
|
||||
moveit2: Any = None,
|
||||
*,
|
||||
mode: str | None = None,
|
||||
voxel_dir: str | None = None,
|
||||
) -> tuple[Any, Any]:
|
||||
"""根据环境变量或参数创建检测器实例。
|
||||
|
||||
Args:
|
||||
moveit2: MoveIt2 实例(moveit 模式必需)
|
||||
mode: "mock" | "moveit"(默认从 LAYOUT_CHECKER_MODE 环境变量读取)
|
||||
voxel_dir: 体素图目录(默认从 LAYOUT_VOXEL_DIR 环境变量读取)
|
||||
|
||||
Returns:
|
||||
(collision_checker, reachability_checker)
|
||||
"""
|
||||
if mode is None:
|
||||
mode = os.getenv("LAYOUT_CHECKER_MODE", "mock")
|
||||
|
||||
if mode == "moveit":
|
||||
if moveit2 is None:
|
||||
raise ValueError("MoveIt2 instance required for 'moveit' checker mode")
|
||||
|
||||
if voxel_dir is None:
|
||||
voxel_dir = os.getenv("LAYOUT_VOXEL_DIR")
|
||||
|
||||
collision = MoveItCollisionChecker(moveit2)
|
||||
reachability = IKFastReachabilityChecker(
|
||||
moveit2, voxel_dir=voxel_dir,
|
||||
)
|
||||
logger.info("Using MoveIt2 checkers (voxel_dir=%s)", voxel_dir)
|
||||
return collision, reachability
|
||||
|
||||
# 默认:mock 模式
|
||||
from .mock_checkers import MockCollisionChecker, MockReachabilityChecker
|
||||
|
||||
logger.info("Using mock checkers")
|
||||
return MockCollisionChecker(), MockReachabilityChecker()
|
||||
331
unilabos/layout_optimizer/seeders.py
Normal file
331
unilabos/layout_optimizer/seeders.py
Normal file
@@ -0,0 +1,331 @@
|
||||
"""Force-directed seeder engine with named parameter presets.
|
||||
|
||||
Produces initial device placements for the layout optimizer.
|
||||
Different layout strategies (compact, spread, workflow-aware) are
|
||||
parameter configurations of the same force-directed simulation engine.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
from .models import Device, Lab, Placement
|
||||
from .obb import obb_corners, obb_overlap, obb_min_distance
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SeederParams:
|
||||
"""Parameters for the force-directed seeder engine."""
|
||||
boundary_attraction: float = 0.0 # >0 push to walls, <0 push to center
|
||||
mutual_repulsion: float = 1.0 # inter-device repulsion strength
|
||||
edge_attraction: float = 0.0 # workflow edge attraction (Stage 2)
|
||||
orientation_mode: str = "none" # "outward" | "inward" | "none"
|
||||
|
||||
|
||||
PRESETS: dict[str, SeederParams | None] = {
|
||||
"compact_outward": SeederParams(
|
||||
boundary_attraction=-1.0, mutual_repulsion=0.5, orientation_mode="outward",
|
||||
),
|
||||
"spread_inward": SeederParams(
|
||||
boundary_attraction=1.0, mutual_repulsion=1.0, orientation_mode="inward",
|
||||
),
|
||||
"workflow_cluster": SeederParams(
|
||||
boundary_attraction=-0.5, mutual_repulsion=0.5,
|
||||
edge_attraction=1.0, orientation_mode="outward",
|
||||
),
|
||||
"row_fallback": None, # Delegates to generate_fallback()
|
||||
}
|
||||
|
||||
|
||||
def resolve_seeder_params(
|
||||
preset_name: str, overrides: dict | None = None,
|
||||
) -> SeederParams | None:
|
||||
"""Look up preset by name and apply overrides."""
|
||||
if preset_name not in PRESETS:
|
||||
raise ValueError(
|
||||
f"Unknown seeder preset '{preset_name}'. "
|
||||
f"Available: {list(PRESETS.keys())}"
|
||||
)
|
||||
params = PRESETS[preset_name]
|
||||
if params is None or not overrides:
|
||||
return params
|
||||
return replace(params, **{k: v for k, v in overrides.items() if hasattr(params, k)})
|
||||
|
||||
|
||||
def seed_layout(
|
||||
devices: list[Device],
|
||||
lab: Lab,
|
||||
params: SeederParams | None,
|
||||
edges: list[list[str]] | None = None,
|
||||
) -> list[Placement]:
|
||||
"""Generate initial device placements using force-directed simulation.
|
||||
|
||||
Args:
|
||||
devices: devices to place
|
||||
lab: lab dimensions
|
||||
params: seeder parameters (None = row_fallback)
|
||||
edges: workflow edges as [device_a_id, device_b_id] pairs (Stage 2)
|
||||
|
||||
Returns:
|
||||
list of Placement objects, one per device
|
||||
"""
|
||||
if not devices:
|
||||
return []
|
||||
|
||||
if params is None:
|
||||
from .pencil_integration import generate_fallback
|
||||
return generate_fallback(devices, lab)
|
||||
|
||||
return _force_simulation(devices, lab, params, edges)
|
||||
|
||||
|
||||
def _force_simulation(
|
||||
devices: list[Device],
|
||||
lab: Lab,
|
||||
params: SeederParams,
|
||||
edges: list[list[str]] | None = None,
|
||||
max_iter: int = 80,
|
||||
dt: float = 0.05,
|
||||
damping: float = 0.8,
|
||||
) -> list[Placement]:
|
||||
"""Run force-directed simulation to produce initial placements."""
|
||||
n = len(devices)
|
||||
center_x, center_y = lab.width / 2, lab.depth / 2
|
||||
|
||||
# Initialize positions: grid layout within lab bounds
|
||||
cols = max(1, int(math.ceil(math.sqrt(n))))
|
||||
rows_count = max(1, math.ceil(n / cols))
|
||||
positions = [] # (x, y) per device
|
||||
for i, dev in enumerate(devices):
|
||||
row, col = divmod(i, cols)
|
||||
margin = 0.3
|
||||
x = margin + (col + 0.5) * (lab.width - 2 * margin) / cols
|
||||
y = margin + (row + 0.5) * (lab.depth - 2 * margin) / rows_count
|
||||
x = min(max(x, dev.bbox[0] / 2), lab.width - dev.bbox[0] / 2)
|
||||
y = min(max(y, dev.bbox[1] / 2), lab.depth - dev.bbox[1] / 2)
|
||||
positions.append([x, y])
|
||||
|
||||
# Initialize orientations
|
||||
thetas = [0.0] * n
|
||||
|
||||
# Build edge lookup for Stage 2
|
||||
edge_set: set[tuple[int, int]] = set()
|
||||
if edges and params.edge_attraction > 0:
|
||||
id_to_idx = {d.id: i for i, d in enumerate(devices)}
|
||||
for e in edges:
|
||||
if len(e) == 2 and e[0] in id_to_idx and e[1] in id_to_idx:
|
||||
edge_set.add((id_to_idx[e[0]], id_to_idx[e[1]]))
|
||||
|
||||
converged = False
|
||||
for iteration in range(max_iter):
|
||||
forces = [[0.0, 0.0] for _ in range(n)]
|
||||
total_force = 0.0
|
||||
|
||||
# 1. Boundary force
|
||||
for i in range(n):
|
||||
dx = positions[i][0] - center_x
|
||||
dy = positions[i][1] - center_y
|
||||
dist_to_center = math.sqrt(dx * dx + dy * dy) + 1e-9
|
||||
f = params.boundary_attraction
|
||||
forces[i][0] += f * dx / dist_to_center
|
||||
forces[i][1] += f * dy / dist_to_center
|
||||
|
||||
# 2. Mutual repulsion (OBB edge-to-edge)
|
||||
for i in range(n):
|
||||
for j in range(i + 1, n):
|
||||
ci = obb_corners(
|
||||
positions[i][0], positions[i][1],
|
||||
devices[i].bbox[0], devices[i].bbox[1], thetas[i],
|
||||
)
|
||||
cj = obb_corners(
|
||||
positions[j][0], positions[j][1],
|
||||
devices[j].bbox[0], devices[j].bbox[1], thetas[j],
|
||||
)
|
||||
dist = obb_min_distance(ci, cj)
|
||||
if dist < 1e-9:
|
||||
dist = 0.01 # Prevent division by zero for overlapping
|
||||
dx = positions[i][0] - positions[j][0]
|
||||
dy = positions[i][1] - positions[j][1]
|
||||
d_center = math.sqrt(dx * dx + dy * dy) + 1e-9
|
||||
repulsion = params.mutual_repulsion / (dist * dist + 0.1)
|
||||
fx = repulsion * dx / d_center
|
||||
fy = repulsion * dy / d_center
|
||||
forces[i][0] += fx
|
||||
forces[i][1] += fy
|
||||
forces[j][0] -= fx
|
||||
forces[j][1] -= fy
|
||||
|
||||
# 3. Edge attraction (Stage 2)
|
||||
if params.edge_attraction > 0:
|
||||
for i_idx, j_idx in edge_set:
|
||||
dx = positions[j_idx][0] - positions[i_idx][0]
|
||||
dy = positions[j_idx][1] - positions[i_idx][1]
|
||||
dist = math.sqrt(dx * dx + dy * dy) + 1e-9
|
||||
f = params.edge_attraction * dist * 0.1
|
||||
forces[i_idx][0] += f * dx / dist
|
||||
forces[i_idx][1] += f * dy / dist
|
||||
forces[j_idx][0] -= f * dx / dist
|
||||
forces[j_idx][1] -= f * dy / dist
|
||||
|
||||
# 4. Update positions (Euler + damping)
|
||||
for i in range(n):
|
||||
positions[i][0] += forces[i][0] * dt * damping
|
||||
positions[i][1] += forces[i][1] * dt * damping
|
||||
total_force += math.sqrt(forces[i][0]**2 + forces[i][1]**2)
|
||||
|
||||
# 5. Update orientations
|
||||
if params.orientation_mode != "none":
|
||||
for i in range(n):
|
||||
thetas[i] = _compute_orientation(
|
||||
positions[i][0], positions[i][1],
|
||||
center_x, center_y,
|
||||
devices[i], params.orientation_mode,
|
||||
)
|
||||
|
||||
# 6. Clamp to lab bounds
|
||||
for i in range(n):
|
||||
hw, hh = devices[i].bbox[0] / 2, devices[i].bbox[1] / 2
|
||||
positions[i][0] = max(hw, min(lab.width - hw, positions[i][0]))
|
||||
positions[i][1] = max(hh, min(lab.depth - hh, positions[i][1]))
|
||||
|
||||
if total_force < 0.01 * n:
|
||||
converged = True
|
||||
logger.info("Force simulation converged at iteration %d", iteration)
|
||||
break
|
||||
|
||||
if not converged:
|
||||
logger.info("Force simulation reached max iterations (%d)", max_iter)
|
||||
|
||||
placements = [
|
||||
Placement(device_id=devices[i].id, x=positions[i][0], y=positions[i][1], theta=thetas[i])
|
||||
for i in range(n)
|
||||
]
|
||||
|
||||
# Log initial collision count
|
||||
initial_collisions = _count_collisions(devices, placements)
|
||||
logger.info("Seeder: %d initial collision pairs before resolution", initial_collisions)
|
||||
|
||||
# Collision resolution pass
|
||||
placements = _resolve_collisions(devices, placements, lab, max_passes=5)
|
||||
|
||||
# Log diagnostics
|
||||
final_collisions = _count_collisions(devices, placements)
|
||||
no_openings = sum(1 for d in devices if not d.openings)
|
||||
logger.info(
|
||||
"Seeder complete: %d devices, %d without openings, %d collision pairs remaining",
|
||||
n, no_openings, final_collisions,
|
||||
)
|
||||
|
||||
return placements
|
||||
|
||||
|
||||
def _compute_orientation(
|
||||
x: float, y: float,
|
||||
center_x: float, center_y: float,
|
||||
device: Device,
|
||||
mode: str,
|
||||
) -> float:
|
||||
"""Compute theta so the device's front faces outward or inward."""
|
||||
dx = x - center_x
|
||||
dy = y - center_y
|
||||
if abs(dx) < 1e-9 and abs(dy) < 1e-9:
|
||||
return 0.0
|
||||
|
||||
angle_to_device = math.atan2(dy, dx)
|
||||
|
||||
if device.openings:
|
||||
front = device.openings[0].direction
|
||||
else:
|
||||
front = (0.0, -1.0) # Default: -Y is front
|
||||
|
||||
front_angle = math.atan2(front[1], front[0])
|
||||
|
||||
if mode == "outward":
|
||||
target = angle_to_device
|
||||
elif mode == "inward":
|
||||
target = angle_to_device + math.pi
|
||||
else:
|
||||
return 0.0
|
||||
|
||||
return (target - front_angle) % (2 * math.pi)
|
||||
|
||||
|
||||
def _count_collisions(devices: list[Device], placements: list[Placement]) -> int:
|
||||
"""Count OBB collision pairs (for diagnostics logging)."""
|
||||
n = len(devices)
|
||||
count = 0
|
||||
for i in range(n):
|
||||
for j in range(i + 1, n):
|
||||
ci = obb_corners(placements[i].x, placements[i].y,
|
||||
devices[i].bbox[0], devices[i].bbox[1], placements[i].theta)
|
||||
cj = obb_corners(placements[j].x, placements[j].y,
|
||||
devices[j].bbox[0], devices[j].bbox[1], placements[j].theta)
|
||||
if obb_overlap(ci, cj):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def _resolve_collisions(
|
||||
devices: list[Device],
|
||||
placements: list[Placement],
|
||||
lab: Lab,
|
||||
max_passes: int = 5,
|
||||
) -> list[Placement]:
|
||||
"""Push overlapping devices apart. Returns new placement list."""
|
||||
positions = [[p.x, p.y] for p in placements]
|
||||
thetas = [p.theta for p in placements]
|
||||
n = len(devices)
|
||||
|
||||
for pass_num in range(max_passes):
|
||||
has_collision = False
|
||||
for i in range(n):
|
||||
for j in range(i + 1, n):
|
||||
ci = obb_corners(
|
||||
positions[i][0], positions[i][1],
|
||||
devices[i].bbox[0], devices[i].bbox[1], thetas[i],
|
||||
)
|
||||
cj = obb_corners(
|
||||
positions[j][0], positions[j][1],
|
||||
devices[j].bbox[0], devices[j].bbox[1], thetas[j],
|
||||
)
|
||||
if obb_overlap(ci, cj):
|
||||
has_collision = True
|
||||
dx = positions[i][0] - positions[j][0]
|
||||
dy = positions[i][1] - positions[j][1]
|
||||
dist = math.sqrt(dx * dx + dy * dy) + 1e-9
|
||||
push = 0.5 * (
|
||||
max(devices[i].bbox[0], devices[i].bbox[1])
|
||||
+ max(devices[j].bbox[0], devices[j].bbox[1])
|
||||
) / 4
|
||||
positions[i][0] += push * dx / dist
|
||||
positions[i][1] += push * dy / dist
|
||||
positions[j][0] -= push * dx / dist
|
||||
positions[j][1] -= push * dy / dist
|
||||
|
||||
# Clamp to bounds (rotation-aware AABB half-extents)
|
||||
for i in range(n):
|
||||
cos_t = abs(math.cos(thetas[i]))
|
||||
sin_t = abs(math.sin(thetas[i]))
|
||||
hw = (devices[i].bbox[0] * cos_t + devices[i].bbox[1] * sin_t) / 2
|
||||
hh = (devices[i].bbox[0] * sin_t + devices[i].bbox[1] * cos_t) / 2
|
||||
positions[i][0] = max(hw, min(lab.width - hw, positions[i][0]))
|
||||
positions[i][1] = max(hh, min(lab.depth - hh, positions[i][1]))
|
||||
|
||||
if not has_collision:
|
||||
logger.info("Collision resolution complete after %d passes", pass_num + 1)
|
||||
break
|
||||
else:
|
||||
logger.warning(
|
||||
"Collision resolution: %d passes exhausted, collisions may remain",
|
||||
max_passes,
|
||||
)
|
||||
|
||||
return [
|
||||
Placement(device_id=placements[i].device_id,
|
||||
x=positions[i][0], y=positions[i][1],
|
||||
theta=thetas[i], uuid=placements[i].uuid)
|
||||
for i in range(n)
|
||||
]
|
||||
742
unilabos/layout_optimizer/server.py
Normal file
742
unilabos/layout_optimizer/server.py
Normal file
@@ -0,0 +1,742 @@
|
||||
"""FastAPI 开发服务器。
|
||||
|
||||
开发阶段独立运行于 localhost:8000,前端通过 CORS 调用。
|
||||
集成阶段合并到 Uni-Lab-OS 的 FastAPI 服务中。
|
||||
|
||||
运行方式:
|
||||
uvicorn unilabos.layout_optimizer.server:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
调试模式(启用 DEBUG 日志,含优化器逐代 cost 明细):
|
||||
LAYOUT_DEBUG=1 uvicorn unilabos.layout_optimizer.server:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
日志文件:
|
||||
自动写入 layout_optimizer/logs/{YYYYMMDD_HHMMSS}.log(始终 DEBUG 级别)。
|
||||
前端 1s 轮询的 GET /scene/placements 200 行不写入日志文件。
|
||||
|
||||
前端访问:
|
||||
http://localhost:8000/
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
import itertools
|
||||
import logging
|
||||
import logging.handlers
|
||||
import math
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .constraints import DEFAULT_WEIGHT_ANGLE # noqa: F401 — kept for external use
|
||||
from .device_catalog import (
|
||||
create_devices_from_list,
|
||||
load_devices_from_assets,
|
||||
load_devices_from_registry,
|
||||
load_footprints,
|
||||
merge_device_lists,
|
||||
)
|
||||
from .lab_parser import parse_lab
|
||||
from .intent_interpreter import InterpretResult, interpret_intents
|
||||
from .models import Constraint, Intent
|
||||
from .optimizer import optimize
|
||||
|
||||
_console_level = logging.DEBUG if os.getenv("LAYOUT_DEBUG") else logging.INFO
|
||||
# root logger must be DEBUG so the file handler receives all records;
|
||||
# console output level is controlled separately via its handler.
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
# basicConfig creates a default StreamHandler — set its level to the console level
|
||||
for _h in logging.getLogger().handlers:
|
||||
if isinstance(_h, logging.StreamHandler):
|
||||
_h.setLevel(_console_level)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# --- 文件日志:实时写入 logs/ 目录,按启动时间命名 ---
|
||||
_LOG_DIR = Path(__file__).parent / "logs"
|
||||
_LOG_DIR.mkdir(exist_ok=True)
|
||||
_log_file = _LOG_DIR / f"{datetime.now():%Y%m%d_%H%M%S}.log"
|
||||
|
||||
|
||||
class _PollingFilter(logging.Filter):
|
||||
"""过滤掉前端 1s 轮询产生的 GET /scene/placements 日志行。"""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
msg = record.getMessage()
|
||||
if "GET /scene/placements" in msg and "200" in msg:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
_file_handler = logging.FileHandler(_log_file, encoding="utf-8")
|
||||
_file_handler.setLevel(logging.DEBUG)
|
||||
_file_handler.setFormatter(
|
||||
logging.Formatter("%(asctime)s %(levelname)-5s [%(name)s] %(message)s")
|
||||
)
|
||||
_file_handler.addFilter(_PollingFilter())
|
||||
logging.getLogger().addHandler(_file_handler)
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
|
||||
# 可配置路径
|
||||
# __file__ -> Uni-Lab-OS/unilabos/layout_optimizer/server.py
|
||||
_UNILABOS_DIR = Path(__file__).resolve().parent.parent # .../Uni-Lab-OS/unilabos/
|
||||
|
||||
UNI_LAB_ASSETS_DIR = Path(
|
||||
os.getenv("UNI_LAB_ASSETS_DIR", str(_UNILABOS_DIR.parent.parent.parent / "uni-lab-assets"))
|
||||
)
|
||||
UNI_LAB_ASSETS_MODELS_DIR = UNI_LAB_ASSETS_DIR / "device_models"
|
||||
UNI_LAB_ASSETS_DATA_JSON = UNI_LAB_ASSETS_DIR / "data.json"
|
||||
UNI_LAB_OS_DEVICE_MESH_DIR = Path(
|
||||
os.getenv(
|
||||
"UNI_LAB_OS_DEVICE_MESH_DIR",
|
||||
str(_UNILABOS_DIR / "device_mesh" / "devices"),
|
||||
)
|
||||
)
|
||||
|
||||
app = FastAPI(title="Layout Optimizer", version="0.2.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # 开发阶段允许所有来源
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 挂载静态文件目录
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
# 挂载 3D 模型和缩略图
|
||||
if UNI_LAB_ASSETS_MODELS_DIR.exists():
|
||||
app.mount("/models", StaticFiles(directory=str(UNI_LAB_ASSETS_MODELS_DIR)), name="models")
|
||||
logger.info("Mounted /models from %s", UNI_LAB_ASSETS_MODELS_DIR)
|
||||
else:
|
||||
logger.warning("uni-lab-assets models dir not found: %s", UNI_LAB_ASSETS_MODELS_DIR)
|
||||
|
||||
|
||||
# ---------- 设备目录缓存 ----------
|
||||
|
||||
_device_cache: list[dict] | None = None
|
||||
_DEVICE_PARAM_KEYS = {"device_a", "device_b", "arm_id", "target_device_id", "device"}
|
||||
|
||||
|
||||
# 消耗品/配件关键词(不独立放置于实验台)
|
||||
_CONSUMABLE_KEYWORDS = {
|
||||
"plate", "well", "tube", "tip", "reservoir", "carrier", "nest",
|
||||
"adapter", "trough", "magnet_module", "magnet_plate", "rack", "lid",
|
||||
"seal", "cap", "vial", "flask", "dish", "block", "strip", "insert",
|
||||
"gasket", "pad", "grid_segment", "spacer", "diti_tray",
|
||||
}
|
||||
# 但包含这些关键词的是独立设备,不是消耗品
|
||||
_DEVICE_KEYWORDS = {
|
||||
"reader", "handler", "hotel", "washer", "stacker", "sealer", "labeler",
|
||||
"centrifuge", "incubator", "shaker", "robot", "arm", "flex", "dispenser",
|
||||
"printer", "scanner", "analyzer", "fluorometer", "spectrophotometer",
|
||||
"thermocycler", "module",
|
||||
}
|
||||
|
||||
|
||||
def _is_standalone_device(device_id: str, bbox: tuple[float, float]) -> bool:
|
||||
"""判断设备是否独立放置于实验台(非消耗品/配件)。"""
|
||||
mx = max(bbox[0], bbox[1])
|
||||
mn = min(bbox[0], bbox[1])
|
||||
if mx >= 0.30:
|
||||
return True # 大于 30cm 一定是独立设备
|
||||
if mx < 0.05:
|
||||
return False # 小于 5cm 一定是消耗品
|
||||
lower = device_id.lower()
|
||||
# 非常扁平(一维 < 3cm)的几乎都是配件/载具,即使名称匹配设备关键词
|
||||
if mn < 0.03:
|
||||
return False
|
||||
# 先检查消耗品关键词(如果匹配,再看是否有设备关键词覆盖)
|
||||
is_consumable_name = any(kw in lower for kw in _CONSUMABLE_KEYWORDS)
|
||||
is_device_name = any(kw in lower for kw in _DEVICE_KEYWORDS)
|
||||
if is_consumable_name and not is_device_name:
|
||||
return False
|
||||
if is_device_name:
|
||||
return True
|
||||
# 默认:>= 15cm 视为设备
|
||||
return mx >= 0.15
|
||||
|
||||
|
||||
def _build_device_list() -> list[dict]:
|
||||
"""构建合并后的设备列表(缓存)。"""
|
||||
global _device_cache
|
||||
if _device_cache is not None:
|
||||
return _device_cache
|
||||
|
||||
footprints = load_footprints()
|
||||
|
||||
registry = load_devices_from_registry(UNI_LAB_OS_DEVICE_MESH_DIR, footprints)
|
||||
assets = load_devices_from_assets(UNI_LAB_ASSETS_DATA_JSON, footprints)
|
||||
|
||||
merged = merge_device_lists(registry, assets)
|
||||
|
||||
_device_cache = [
|
||||
{
|
||||
"id": d.id,
|
||||
"name": d.name,
|
||||
"device_type": d.device_type,
|
||||
"source": d.source,
|
||||
"bbox": list(d.bbox),
|
||||
"height": d.height,
|
||||
"origin_offset": list(d.origin_offset),
|
||||
"openings": [
|
||||
{"direction": list(o.direction), "label": o.label}
|
||||
for o in d.openings
|
||||
],
|
||||
"model_path": d.model_path,
|
||||
"model_type": d.model_type,
|
||||
"thumbnail_url": d.thumbnail_url,
|
||||
"is_standalone": _is_standalone_device(d.id, d.bbox),
|
||||
}
|
||||
for d in merged
|
||||
]
|
||||
standalone = sum(1 for d in _device_cache if d["is_standalone"])
|
||||
logger.info("Built device catalog: %d devices (%d standalone)", len(_device_cache), standalone)
|
||||
return _device_cache
|
||||
|
||||
|
||||
def _catalog_id_from_internal(device_id: str) -> str:
|
||||
"""内部实例 ID → catalog ID。"""
|
||||
return device_id.split("#", 1)[0]
|
||||
|
||||
|
||||
def _expand_constraints_for_duplicates(
|
||||
constraints: list[Constraint], devices: list,
|
||||
) -> list[Constraint]:
|
||||
"""将引用 bare catalog ID 的约束扩展到所有重复实例。"""
|
||||
catalog_instances: dict[str, list[str]] = defaultdict(list)
|
||||
for dev in devices:
|
||||
catalog_instances[_catalog_id_from_internal(dev.id)].append(dev.id)
|
||||
|
||||
expanded_constraints: list[Constraint] = []
|
||||
for constraint in constraints:
|
||||
fan_out_keys: list[str] = []
|
||||
fan_out_values: list[list[str]] = []
|
||||
|
||||
for key in _DEVICE_PARAM_KEYS:
|
||||
if key not in constraint.params:
|
||||
continue
|
||||
ref_id = constraint.params[key]
|
||||
if "#" in ref_id:
|
||||
continue
|
||||
instances = catalog_instances.get(ref_id, [])
|
||||
if len(instances) > 1:
|
||||
fan_out_keys.append(key)
|
||||
fan_out_values.append(instances)
|
||||
logger.info(
|
||||
"Fan-out: %s %s=%s -> %d instances",
|
||||
constraint.rule_name, key, ref_id, len(instances),
|
||||
)
|
||||
|
||||
if not fan_out_keys:
|
||||
expanded_constraints.append(constraint)
|
||||
continue
|
||||
|
||||
for combo in itertools.product(*fan_out_values):
|
||||
new_params = dict(constraint.params)
|
||||
for key, internal_id in zip(fan_out_keys, combo):
|
||||
new_params[key] = internal_id
|
||||
expanded_constraints.append(
|
||||
Constraint(
|
||||
type=constraint.type,
|
||||
rule_name=constraint.rule_name,
|
||||
params=new_params,
|
||||
weight=constraint.weight,
|
||||
)
|
||||
)
|
||||
|
||||
return expanded_constraints
|
||||
|
||||
|
||||
def _maybe_add_prefer_aligned_constraint(
|
||||
constraints: list[Constraint], align_weight: float,
|
||||
) -> list[Constraint]:
|
||||
"""仅在用户未显式提供 prefer_aligned 时注入对齐约束。"""
|
||||
if align_weight <= 0:
|
||||
return constraints
|
||||
|
||||
if any(c.rule_name == "prefer_aligned" for c in constraints):
|
||||
logger.info("Skipping auto-injected prefer_aligned because one already exists")
|
||||
return constraints
|
||||
|
||||
constraints.append(
|
||||
Constraint(
|
||||
type="soft",
|
||||
rule_name="prefer_aligned",
|
||||
weight=align_weight,
|
||||
)
|
||||
)
|
||||
return constraints
|
||||
|
||||
|
||||
# ---------- 路由 ----------
|
||||
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def root():
|
||||
return RedirectResponse(url="/lab3d")
|
||||
|
||||
|
||||
@app.get("/lab3d", include_in_schema=False)
|
||||
async def lab3d_ui():
|
||||
return FileResponse(STATIC_DIR / "lab3d.html")
|
||||
|
||||
|
||||
@app.get("/devices")
|
||||
async def list_devices(source: str = "all"):
|
||||
"""返回合并后的设备目录。?source=registry|assets|all"""
|
||||
devices = _build_device_list()
|
||||
if source != "all":
|
||||
devices = [d for d in devices if d["source"] == source]
|
||||
return devices
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ---------- 意图解释 API ----------
|
||||
|
||||
|
||||
class IntentSpec(BaseModel):
|
||||
intent: str
|
||||
params: dict = {}
|
||||
description: str = ""
|
||||
|
||||
|
||||
class TranslationEntry(BaseModel):
|
||||
source_intent: str
|
||||
source_description: str
|
||||
source_params: dict
|
||||
generated_constraints: list[dict]
|
||||
explanation: str
|
||||
confidence: str = "high"
|
||||
|
||||
|
||||
class InterpretRequest(BaseModel):
|
||||
intents: list[IntentSpec]
|
||||
|
||||
|
||||
class InterpretResponse(BaseModel):
|
||||
constraints: list[dict]
|
||||
translations: list[TranslationEntry]
|
||||
workflow_edges: list[list[str]]
|
||||
errors: list[str]
|
||||
|
||||
|
||||
@app.post("/interpret", response_model=InterpretResponse)
|
||||
async def run_interpret(request: InterpretRequest):
|
||||
"""将语义化意图翻译为约束列表,供用户确认后传入 /optimize。"""
|
||||
logger.info("Interpret request: %d intents", len(request.intents))
|
||||
|
||||
intents = [
|
||||
Intent(
|
||||
intent=i.intent,
|
||||
params=i.params,
|
||||
description=i.description,
|
||||
)
|
||||
for i in request.intents
|
||||
]
|
||||
|
||||
result: InterpretResult = interpret_intents(intents)
|
||||
|
||||
return InterpretResponse(
|
||||
constraints=[
|
||||
{"type": c.type, "rule_name": c.rule_name, "params": c.params, "weight": c.weight}
|
||||
for c in result.constraints
|
||||
],
|
||||
translations=[
|
||||
TranslationEntry(
|
||||
source_intent=t["source_intent"],
|
||||
source_description=t.get("source_description", ""),
|
||||
source_params=t.get("source_params", {}),
|
||||
generated_constraints=t["generated_constraints"],
|
||||
explanation=t["explanation"],
|
||||
confidence=t.get("confidence", "high"),
|
||||
)
|
||||
for t in result.translations
|
||||
],
|
||||
workflow_edges=result.workflow_edges,
|
||||
errors=result.errors,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/interpret/schema")
|
||||
async def interpret_schema():
|
||||
"""返回可用意图类型及其参数规范,供 LLM agent 发现和使用。"""
|
||||
return {
|
||||
"description": "Layout optimizer intent schema. LLM agents should translate user requests into these intents.",
|
||||
"intents": {
|
||||
"reachable_by": {
|
||||
"description": "Robot arm must be able to reach all target devices",
|
||||
"params": {
|
||||
"arm": {"type": "string", "required": True, "description": "Device ID of robot arm"},
|
||||
"targets": {"type": "list[string]", "required": True, "description": "Device IDs the arm must reach"},
|
||||
},
|
||||
"generates": "hard reachability constraint per target",
|
||||
},
|
||||
"close_together": {
|
||||
"description": "Group of devices should be placed near each other",
|
||||
"params": {
|
||||
"devices": {"type": "list[string]", "required": True, "description": "Device IDs (min 2)"},
|
||||
"priority": {"type": "string", "required": False, "default": "medium", "enum": ["low", "medium", "high"]},
|
||||
},
|
||||
"generates": "soft minimize_distance for each pair",
|
||||
},
|
||||
"far_apart": {
|
||||
"description": "Devices should be placed far from each other",
|
||||
"params": {
|
||||
"devices": {"type": "list[string]", "required": True, "description": "Device IDs (min 2)"},
|
||||
"priority": {"type": "string", "required": False, "default": "medium", "enum": ["low", "medium", "high"]},
|
||||
},
|
||||
"generates": "soft maximize_distance for each pair",
|
||||
},
|
||||
"keep_adjacent": {
|
||||
"description": "Devices should stay adjacent, similar to close_together",
|
||||
"params": {
|
||||
"devices": {"type": "list[string]", "required": True, "description": "Device IDs (min 2)"},
|
||||
"priority": {"type": "string", "required": False, "default": "medium", "enum": ["low", "medium", "high"]},
|
||||
},
|
||||
"generates": "soft minimize_distance for each pair",
|
||||
},
|
||||
"max_distance": {
|
||||
"description": "Two devices must be within a maximum distance",
|
||||
"params": {
|
||||
"device_a": {"type": "string", "required": True},
|
||||
"device_b": {"type": "string", "required": True},
|
||||
"distance": {"type": "float", "required": True, "description": "Max edge-to-edge distance in meters"},
|
||||
},
|
||||
"generates": "hard distance_less_than",
|
||||
},
|
||||
"min_distance": {
|
||||
"description": "Two devices must be at least a minimum distance apart",
|
||||
"params": {
|
||||
"device_a": {"type": "string", "required": True},
|
||||
"device_b": {"type": "string", "required": True},
|
||||
"distance": {"type": "float", "required": True, "description": "Min edge-to-edge distance in meters"},
|
||||
},
|
||||
"generates": "hard distance_greater_than",
|
||||
},
|
||||
"min_spacing": {
|
||||
"description": "Minimum gap between all device pairs",
|
||||
"params": {
|
||||
"min_gap": {"type": "float", "required": False, "default": 0.3, "description": "Minimum gap in meters"},
|
||||
},
|
||||
"generates": "hard min_spacing",
|
||||
},
|
||||
"workflow_hint": {
|
||||
"description": "Workflow step order — consecutive devices should be near each other",
|
||||
"params": {
|
||||
"workflow": {"type": "string", "required": False, "description": "Workflow name (e.g. 'pcr')"},
|
||||
"devices": {"type": "list[string]", "required": True, "description": "Ordered device IDs following workflow steps"},
|
||||
},
|
||||
"generates": "soft minimize_distance for consecutive pairs + workflow_edges",
|
||||
},
|
||||
"face_outward": {
|
||||
"description": "Devices should face outward from lab center",
|
||||
"params": {},
|
||||
"generates": "soft prefer_orientation_mode outward",
|
||||
},
|
||||
"face_inward": {
|
||||
"description": "Devices should face inward toward lab center",
|
||||
"params": {},
|
||||
"generates": "soft prefer_orientation_mode inward",
|
||||
},
|
||||
"align_cardinal": {
|
||||
"description": "Devices should align to cardinal directions (0/90/180/270 degrees)",
|
||||
"params": {},
|
||||
"generates": "soft prefer_aligned",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------- 优化 API ----------
|
||||
|
||||
|
||||
class DeviceSpec(BaseModel):
|
||||
id: str
|
||||
name: str = ""
|
||||
size: list[float] | None = None
|
||||
device_type: str = "static"
|
||||
uuid: str = ""
|
||||
|
||||
|
||||
class ConstraintSpec(BaseModel):
|
||||
type: str # "hard" or "soft"
|
||||
rule_name: str
|
||||
params: dict = {}
|
||||
weight: float = 1.0
|
||||
|
||||
|
||||
class LabSpec(BaseModel):
|
||||
width: float
|
||||
depth: float
|
||||
obstacles: list[dict] = []
|
||||
|
||||
|
||||
class OptimizeRequest(BaseModel):
|
||||
devices: list[DeviceSpec]
|
||||
lab: LabSpec
|
||||
constraints: list[ConstraintSpec] = []
|
||||
seeder: str = "compact_outward"
|
||||
seeder_overrides: dict = {}
|
||||
run_de: bool = True
|
||||
workflow_edges: list[list[str]] = []
|
||||
maxiter: int = 200
|
||||
seed: int | None = None
|
||||
snap_cardinal: bool = False
|
||||
angle_granularity: int | None = None
|
||||
arm_reach: dict[str, float] = {}
|
||||
# DE 超参数
|
||||
strategy: str = "currenttobest1bin"
|
||||
angle_mode: str = "joint"
|
||||
mutation: list[float] = [0.5, 1.0]
|
||||
theta_mutation: list[float] | None = None
|
||||
recombination: float = 0.7
|
||||
crossover_mode: str = "device"
|
||||
|
||||
|
||||
class PositionXYZ(BaseModel):
|
||||
x: float
|
||||
y: float
|
||||
z: float
|
||||
|
||||
|
||||
class PlacementResult(BaseModel):
|
||||
device_id: str
|
||||
uuid: str
|
||||
position: PositionXYZ
|
||||
rotation: PositionXYZ
|
||||
|
||||
|
||||
class OptimizeResponse(BaseModel):
|
||||
placements: list[PlacementResult]
|
||||
cost: float
|
||||
success: bool
|
||||
seeder_used: str = ""
|
||||
de_ran: bool = True
|
||||
|
||||
|
||||
@app.post("/optimize", response_model=OptimizeResponse)
|
||||
async def run_optimize(request: OptimizeRequest):
|
||||
"""接收设备列表+约束,返回最优布局方案。"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from .constraints import evaluate_default_hard_constraints, evaluate_constraints
|
||||
from .mock_checkers import MockCollisionChecker, MockReachabilityChecker
|
||||
from .optimizer import optimize, snap_theta, snap_theta_safe
|
||||
from .seeders import resolve_seeder_params, seed_layout
|
||||
|
||||
logger.info(
|
||||
"Optimize request: %d devices, lab %.1f×%.1f, %d constraints, seeder=%s, run_de=%s, angle_granularity=%s",
|
||||
len(request.devices),
|
||||
request.lab.width,
|
||||
request.lab.depth,
|
||||
len(request.constraints),
|
||||
request.seeder,
|
||||
request.run_de,
|
||||
request.angle_granularity,
|
||||
)
|
||||
|
||||
if request.angle_granularity not in (None, 4, 8, 12, 24):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="angle_granularity must be one of: 4, 8, 12, 24",
|
||||
)
|
||||
|
||||
# 转换输入
|
||||
devices = create_devices_from_list(
|
||||
[d.model_dump() for d in request.devices]
|
||||
)
|
||||
id_to_catalog = {dev.id: _catalog_id_from_internal(dev.id) for dev in devices}
|
||||
id_to_uuid = {dev.id: (dev.uuid or dev.id) for dev in devices}
|
||||
lab = parse_lab(request.lab.model_dump())
|
||||
constraints = [
|
||||
Constraint(
|
||||
type=c.type,
|
||||
rule_name=c.rule_name,
|
||||
params=c.params,
|
||||
weight=c.weight,
|
||||
)
|
||||
for c in request.constraints
|
||||
]
|
||||
constraints = _expand_constraints_for_duplicates(constraints, devices)
|
||||
|
||||
# 1. Resolve seeder
|
||||
try:
|
||||
params = resolve_seeder_params(request.seeder, request.seeder_overrides or None)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
# 2. Seed
|
||||
seed_placements = seed_layout(
|
||||
devices, lab, params,
|
||||
request.workflow_edges or None,
|
||||
)
|
||||
|
||||
# 3. Auto-inject alignment soft constraint (opt-in via seeder_overrides)
|
||||
if request.run_de and seed_placements:
|
||||
# prefer_aligned: penalize non-cardinal angles(默认关闭,用户可通过 align_cardinal intent 或 seeder_overrides 开启)
|
||||
constraints = _maybe_add_prefer_aligned_constraint(
|
||||
constraints,
|
||||
request.seeder_overrides.get("align_weight", 0),
|
||||
)
|
||||
|
||||
# 4. Validate DE hyperparameters
|
||||
if request.strategy not in {"currenttobest1bin", "best1bin", "rand1bin"}:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"strategy must be one of: currenttobest1bin, best1bin, rand1bin (got {request.strategy!r})",
|
||||
)
|
||||
if request.angle_mode not in {"joint", "hybrid"}:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"angle_mode must be one of: joint, hybrid (got {request.angle_mode!r})",
|
||||
)
|
||||
if request.crossover_mode not in {"device", "dimension"}:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"crossover_mode must be one of: device, dimension (got {request.crossover_mode!r})",
|
||||
)
|
||||
if len(request.mutation) != 2 or request.mutation[0] > request.mutation[1]:
|
||||
raise HTTPException(status_code=400, detail="mutation must be [F_min, F_max] with F_min <= F_max")
|
||||
if request.mutation[0] < 0 or request.mutation[1] > 2.0:
|
||||
raise HTTPException(status_code=400, detail="mutation values must be in [0, 2.0]")
|
||||
if request.theta_mutation is not None:
|
||||
if len(request.theta_mutation) != 2 or request.theta_mutation[0] > request.theta_mutation[1]:
|
||||
raise HTTPException(status_code=400, detail="theta_mutation must be [F_min, F_max] with F_min <= F_max")
|
||||
if request.theta_mutation[0] < 0 or request.theta_mutation[1] > 2.0:
|
||||
raise HTTPException(status_code=400, detail="theta_mutation values must be in [0, 2.0]")
|
||||
if not (0 <= request.recombination <= 1.0):
|
||||
raise HTTPException(status_code=400, detail="recombination must be in [0, 1.0]")
|
||||
|
||||
# 5. Conditional Differential Evolution
|
||||
de_ran = False
|
||||
checker = MockCollisionChecker()
|
||||
reachability_checker = MockReachabilityChecker(request.arm_reach or None)
|
||||
if request.run_de:
|
||||
result_placements = optimize(
|
||||
devices=devices,
|
||||
lab=lab,
|
||||
constraints=constraints,
|
||||
collision_checker=checker,
|
||||
reachability_checker=reachability_checker,
|
||||
seed_placements=seed_placements,
|
||||
maxiter=request.maxiter,
|
||||
seed=request.seed,
|
||||
strategy=request.strategy,
|
||||
workflow_edges=request.workflow_edges or None,
|
||||
angle_granularity=request.angle_granularity,
|
||||
angle_mode=request.angle_mode,
|
||||
mutation=tuple(request.mutation),
|
||||
theta_mutation=tuple(request.theta_mutation) if request.theta_mutation else None,
|
||||
recombination=request.recombination,
|
||||
crossover_mode=request.crossover_mode,
|
||||
)
|
||||
de_ran = True
|
||||
else:
|
||||
result_placements = seed_placements
|
||||
|
||||
# 5. θ snap post-processing(opt-in,默认关闭)
|
||||
if request.snap_cardinal and request.angle_granularity is None:
|
||||
result_placements = snap_theta_safe(result_placements, devices, lab, checker)
|
||||
elif request.snap_cardinal and request.angle_granularity is not None:
|
||||
logger.info(
|
||||
"snap_cardinal ignored because angle_granularity=%s already constrains theta",
|
||||
request.angle_granularity,
|
||||
)
|
||||
|
||||
# 6. Evaluate final cost (binary mode for pass/fail reporting)
|
||||
final_cost = evaluate_default_hard_constraints(
|
||||
devices, result_placements, lab, checker, graduated=False,
|
||||
)
|
||||
# 也检查用户硬约束(binary 模式)
|
||||
if constraints and not math.isinf(final_cost):
|
||||
user_hard_cost = evaluate_constraints(
|
||||
devices, result_placements, lab, constraints, checker, reachability_checker,
|
||||
graduated=False,
|
||||
)
|
||||
if math.isinf(user_hard_cost):
|
||||
final_cost = math.inf
|
||||
|
||||
return OptimizeResponse(
|
||||
placements=[
|
||||
PlacementResult(
|
||||
device_id=id_to_catalog.get(p.device_id, p.device_id),
|
||||
uuid=id_to_uuid.get(p.device_id, p.device_id),
|
||||
position=PositionXYZ(x=round(p.x, 4), y=round(p.y, 4), z=0.0),
|
||||
rotation=PositionXYZ(x=0.0, y=0.0, z=round(p.theta, 4)),
|
||||
)
|
||||
for p in result_placements
|
||||
],
|
||||
cost=final_cost,
|
||||
success=not math.isinf(final_cost),
|
||||
seeder_used=request.seeder,
|
||||
de_ran=de_ran,
|
||||
)
|
||||
|
||||
|
||||
# ---------- 场景状态 API(演示用) ----------
|
||||
|
||||
|
||||
_scene_state: dict = {"version": 0, "placements": []}
|
||||
_lab_state: dict = {"width": 4.0, "depth": 4.0}
|
||||
|
||||
|
||||
class LabDimensions(BaseModel):
|
||||
width: float
|
||||
depth: float
|
||||
|
||||
|
||||
@app.get("/scene/lab")
|
||||
async def get_lab_dimensions():
|
||||
"""返回当前实验室尺寸(前端推送,agent 读取)。"""
|
||||
return _lab_state
|
||||
|
||||
|
||||
@app.post("/scene/lab")
|
||||
async def set_lab_dimensions(dims: LabDimensions):
|
||||
"""前端在加载和尺寸变更时推送。"""
|
||||
_lab_state["width"] = dims.width
|
||||
_lab_state["depth"] = dims.depth
|
||||
return _lab_state
|
||||
|
||||
|
||||
class ScenePlacementsRequest(BaseModel):
|
||||
placements: list[PlacementResult]
|
||||
|
||||
|
||||
@app.post("/scene/placements")
|
||||
async def set_scene_placements(request: ScenePlacementsRequest):
|
||||
"""Agent 写入布局结果,前端轮询读取。"""
|
||||
_scene_state["version"] += 1
|
||||
_scene_state["placements"] = [p.model_dump() for p in request.placements]
|
||||
logger.info(
|
||||
"Scene placements updated: version=%d, count=%d",
|
||||
_scene_state["version"],
|
||||
len(request.placements),
|
||||
)
|
||||
return {"version": _scene_state["version"], "count": len(request.placements)}
|
||||
|
||||
|
||||
@app.get("/scene/placements")
|
||||
async def get_scene_placements():
|
||||
"""前端轮询此端点,检测 version 变化后应用布局。"""
|
||||
return _scene_state
|
||||
|
||||
|
||||
@app.delete("/scene/placements")
|
||||
async def clear_scene_placements():
|
||||
"""重置场景状态(重录时使用)。"""
|
||||
_scene_state["version"] = 0
|
||||
_scene_state["placements"] = []
|
||||
return {"version": 0, "placements": []}
|
||||
1227
unilabos/layout_optimizer/static/lab3d.html
Normal file
1227
unilabos/layout_optimizer/static/lab3d.html
Normal file
File diff suppressed because it is too large
Load Diff
0
unilabos/layout_optimizer/tests/__init__.py
Normal file
0
unilabos/layout_optimizer/tests/__init__.py
Normal file
7
unilabos/layout_optimizer/tests/fixtures/sample_devices.json
vendored
Normal file
7
unilabos/layout_optimizer/tests/fixtures/sample_devices.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{"id": "arm_1", "name": "Elite CS66 Arm", "size": [0.20, 0.20], "device_type": "articulation"},
|
||||
{"id": "liquid_handler", "name": "Agilent Bravo", "size": [0.80, 0.65], "device_type": "static"},
|
||||
{"id": "centrifuge", "name": "Centrifuge", "size": [0.50, 0.50], "device_type": "static"},
|
||||
{"id": "plate_hotel", "name": "Thermo Orbitor RS2", "size": [0.45, 0.55], "device_type": "static"},
|
||||
{"id": "hplc", "name": "HPLC Station", "size": [0.60, 0.50], "device_type": "static"}
|
||||
]
|
||||
7
unilabos/layout_optimizer/tests/fixtures/sample_lab.json
vendored
Normal file
7
unilabos/layout_optimizer/tests/fixtures/sample_lab.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"width": 5.0,
|
||||
"depth": 4.0,
|
||||
"obstacles": [
|
||||
{"x": 2.5, "y": 0.0, "width": 0.1, "depth": 0.5}
|
||||
]
|
||||
}
|
||||
241
unilabos/layout_optimizer/tests/test_broad_phase.py
Normal file
241
unilabos/layout_optimizer/tests/test_broad_phase.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""Tests for broad_phase.py — 2 轴 sweep-and-prune 宽相碰撞检测。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from ..broad_phase import broad_phase_device_pairs, sweep_and_prune_pairs
|
||||
from ..models import Device, Placement
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试用辅助函数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_device(device_id: str, w: float = 0.6, d: float = 0.4) -> Device:
|
||||
"""创建简单测试设备。"""
|
||||
return Device(id=device_id, name=device_id, bbox=(w, d))
|
||||
|
||||
|
||||
def _make_placement(
|
||||
device_id: str, x: float, y: float, theta: float = 0.0
|
||||
) -> Placement:
|
||||
"""创建简单测试放置。"""
|
||||
return Placement(device_id=device_id, x=x, y=y, theta=theta)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试类
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNoOverlap:
|
||||
"""两台设备距离足够远,宽相不返回候选对。"""
|
||||
|
||||
def test_no_overlap_returns_empty(self):
|
||||
"""水平方向间距远大于 AABB 尺寸 → 0 候选对。"""
|
||||
devices = [_make_device("A", 1.0, 1.0), _make_device("B", 1.0, 1.0)]
|
||||
placements = [
|
||||
_make_placement("A", 0.0, 0.0),
|
||||
_make_placement("B", 10.0, 0.0),
|
||||
]
|
||||
pairs = sweep_and_prune_pairs(devices, placements)
|
||||
assert pairs == []
|
||||
|
||||
|
||||
class TestOverlapping:
|
||||
"""两台设备 AABB 明显重叠。"""
|
||||
|
||||
def test_overlapping_devices_returned(self):
|
||||
"""两台 1×1 设备中心距 0.5m → 1 候选对。"""
|
||||
devices = [_make_device("A", 1.0, 1.0), _make_device("B", 1.0, 1.0)]
|
||||
placements = [
|
||||
_make_placement("A", 0.0, 0.0),
|
||||
_make_placement("B", 0.5, 0.0),
|
||||
]
|
||||
pairs = sweep_and_prune_pairs(devices, placements)
|
||||
assert len(pairs) == 1
|
||||
assert pairs[0] == (0, 1)
|
||||
|
||||
|
||||
class TestXOverlapYNoOverlap:
|
||||
"""x 轴投影交叠但 y 轴不交叠,应被 y 轴检查剪枝。"""
|
||||
|
||||
def test_x_overlap_y_no_overlap(self):
|
||||
"""水平接近但垂直方向偏移足够大 → 0 候选对。"""
|
||||
devices = [_make_device("A", 2.0, 1.0), _make_device("B", 2.0, 1.0)]
|
||||
placements = [
|
||||
_make_placement("A", 0.0, 0.0),
|
||||
_make_placement("B", 0.5, 5.0), # x 轴交叠但 y 轴相距很远
|
||||
]
|
||||
pairs = sweep_and_prune_pairs(devices, placements)
|
||||
assert pairs == []
|
||||
|
||||
|
||||
class TestTouchingDevices:
|
||||
"""AABB 恰好接触(边缘距离 = 0)应作为候选对返回。"""
|
||||
|
||||
def test_touching_devices_included(self):
|
||||
"""两个 1×1 设备中心距恰好为 1.0(半宽 0.5 + 0.5 = 1.0),
|
||||
AABB 边界接触 → 应包含在候选对中(<= 判定)。"""
|
||||
devices = [_make_device("A", 1.0, 1.0), _make_device("B", 1.0, 1.0)]
|
||||
placements = [
|
||||
_make_placement("A", 0.0, 0.0),
|
||||
_make_placement("B", 1.0, 0.0), # xmax_A = 0.5, xmin_B = 0.5 → 接触
|
||||
]
|
||||
pairs = sweep_and_prune_pairs(devices, placements)
|
||||
# 接触算作潜在碰撞,安全起见需保留
|
||||
assert len(pairs) == 1
|
||||
assert pairs[0] == (0, 1)
|
||||
|
||||
|
||||
class TestMultipleDevices:
|
||||
"""4 台设备验证精确的候选对列表。"""
|
||||
|
||||
def test_multiple_devices_correct_pairs(self):
|
||||
"""排列 4 台设备,只有特定配对 AABB 交叠。
|
||||
|
||||
布局(1×1 设备):
|
||||
A(0,0) B(0.8,0) — A-B 交叠(中心距 0.8 < 1.0)
|
||||
C(0,5) — 远离 A、B
|
||||
D(0.9,5) — C-D 交叠(中心距 0.9 < 1.0)
|
||||
|
||||
期望候选对: (A,B) 和 (C,D)。
|
||||
"""
|
||||
devices = [
|
||||
_make_device("A", 1.0, 1.0),
|
||||
_make_device("B", 1.0, 1.0),
|
||||
_make_device("C", 1.0, 1.0),
|
||||
_make_device("D", 1.0, 1.0),
|
||||
]
|
||||
placements = [
|
||||
_make_placement("A", 0.0, 0.0),
|
||||
_make_placement("B", 0.8, 0.0),
|
||||
_make_placement("C", 0.0, 5.0),
|
||||
_make_placement("D", 0.9, 5.0),
|
||||
]
|
||||
pairs = sweep_and_prune_pairs(devices, placements)
|
||||
pair_set = set(pairs)
|
||||
assert (0, 1) in pair_set # A-B
|
||||
assert (2, 3) in pair_set # C-D
|
||||
assert len(pair_set) == 2
|
||||
|
||||
|
||||
class TestRotatedDeviceAabb:
|
||||
"""旋转设备导致 AABB 变大,命中候选对。"""
|
||||
|
||||
def test_rotated_device_aabb(self):
|
||||
"""一台窄长设备 (2.0×0.2):
|
||||
- 未旋转时 AABB 半宽 = 1.0,两设备中心距 2.5 → 不交叠
|
||||
- 旋转 90° 后 AABB 半宽 = 0.1,半深 = 1.0 → 仍不交叠
|
||||
- 旋转 45° 后 AABB 半宽 ≈ (2*cos45 + 0.2*sin45)/2 ≈ 0.778
|
||||
但另一台放在 x=1.6,半宽 = 1.0
|
||||
所以 xmax_A = 0 + 0.778 = 0.778 < 1.6 - 1.0 = 0.6 → 不够
|
||||
|
||||
更好的方案:用中心距 1.5,未旋转时不交叠,旋转后交叠。
|
||||
未旋转: half_w_A = 0.3 (bbox 0.6x2.0), half_w_B = 0.3
|
||||
A: xmax = 0 + 0.3 = 0.3, B: xmin = 1.5 - 0.3 = 1.2 → 不交叠
|
||||
旋转 90°: A 的 bbox (0.6, 2.0) → half_w = (0.6*0 + 2.0*1)/2 = 1.0
|
||||
A: xmax = 0 + 1.0 = 1.0, B: xmin = 1.5 - 0.3 = 1.2 → 仍不交叠
|
||||
|
||||
用 bbox (0.4, 2.0),间距 1.2:
|
||||
未旋转: half_w = 0.2, xmax_A = 0.2, xmin_B = 1.2 - 0.2 = 1.0 → 不交叠
|
||||
旋转 45°: half_w = (0.4*cos45 + 2.0*sin45)/2 = (0.283+1.414)/2 = 0.849
|
||||
xmax_A = 0.849, xmin_B = 1.2 - 0.2 = 1.0 → 不交叠
|
||||
|
||||
间距 0.8:
|
||||
未旋转: xmax_A = 0.2, xmin_B = 0.8 - 0.2 = 0.6 → 不交叠 ✓
|
||||
旋转 45°: xmax_A = 0.849, xmin_B = 0.6 → 交叠 ✓ (0.849 > 0.6)
|
||||
y 轴: half_d_A_rot = (0.4*sin45 + 2.0*cos45)/2 = 0.849, half_d_B = 1.0
|
||||
ymax_A = 0.849, ymin_B = -1.0 → 交叠 ✓
|
||||
"""
|
||||
dev_narrow = _make_device("narrow", 0.4, 2.0)
|
||||
dev_normal = _make_device("normal", 0.4, 2.0)
|
||||
# 未旋转:不交叠
|
||||
placements_no_rot = [
|
||||
_make_placement("narrow", 0.0, 0.0, theta=0.0),
|
||||
_make_placement("normal", 0.8, 0.0, theta=0.0),
|
||||
]
|
||||
assert sweep_and_prune_pairs([dev_narrow, dev_normal], placements_no_rot) == []
|
||||
|
||||
# narrow 旋转 45° → AABB 变大 → 交叠
|
||||
placements_rot = [
|
||||
_make_placement("narrow", 0.0, 0.0, theta=math.pi / 4),
|
||||
_make_placement("normal", 0.8, 0.0, theta=0.0),
|
||||
]
|
||||
pairs = sweep_and_prune_pairs([dev_narrow, dev_normal], placements_rot)
|
||||
assert len(pairs) == 1
|
||||
|
||||
|
||||
class TestOriginalIndices:
|
||||
"""验证返回的索引对应 placements 原始顺序而非排序后顺序。"""
|
||||
|
||||
def test_sorted_output_preserves_original_indices(self):
|
||||
"""故意让 placements 按 x 坐标逆序排列,
|
||||
验证返回的索引仍是原始顺序。"""
|
||||
devices = [
|
||||
_make_device("A", 1.0, 1.0),
|
||||
_make_device("B", 1.0, 1.0),
|
||||
_make_device("C", 1.0, 1.0),
|
||||
]
|
||||
# 逆序排列:C 在最左,A 在最右
|
||||
placements = [
|
||||
_make_placement("A", 5.0, 0.0), # idx 0, 最右
|
||||
_make_placement("B", 4.5, 0.0), # idx 1, 中间(与 A 交叠)
|
||||
_make_placement("C", 0.0, 0.0), # idx 2, 最左(独立)
|
||||
]
|
||||
pairs = sweep_and_prune_pairs(devices, placements)
|
||||
# A(idx=0) 和 B(idx=1) AABB 交叠,索引应为 (0, 1)
|
||||
assert len(pairs) == 1
|
||||
assert pairs[0] == (0, 1)
|
||||
|
||||
# 同时验证 broad_phase_device_pairs 返回正确 device_id
|
||||
id_pairs = broad_phase_device_pairs(devices, placements)
|
||||
assert id_pairs == [("A", "B")]
|
||||
|
||||
|
||||
class TestPairCountReduction:
|
||||
"""大规模随机测试:宽相候选对数应远小于 N*(N-1)/2。"""
|
||||
|
||||
def test_pair_count_reduction(self):
|
||||
"""N=15 台设备随机放置在 10×10 实验室 → 候选对数显著少于全量。"""
|
||||
random.seed(42)
|
||||
n = 15
|
||||
devices = [_make_device(f"D{i}", 0.5, 0.5) for i in range(n)]
|
||||
placements = [
|
||||
_make_placement(f"D{i}", random.uniform(0, 10), random.uniform(0, 10))
|
||||
for i in range(n)
|
||||
]
|
||||
pairs = sweep_and_prune_pairs(devices, placements)
|
||||
full_pairs = n * (n - 1) // 2 # = 105
|
||||
# 在 10×10 区域放 15 台 0.5×0.5 设备,交叠率应很低
|
||||
assert len(pairs) < full_pairs
|
||||
# 额外断言:候选对数不超过全量的一半(保守判定)
|
||||
assert len(pairs) < full_pairs * 0.5
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""边界情况。"""
|
||||
|
||||
def test_empty_input(self):
|
||||
"""空列表 → 空结果。"""
|
||||
assert sweep_and_prune_pairs([], []) == []
|
||||
|
||||
def test_single_device(self):
|
||||
"""单台设备 → 无候选对。"""
|
||||
devices = [_make_device("A")]
|
||||
placements = [_make_placement("A", 0.0, 0.0)]
|
||||
assert sweep_and_prune_pairs(devices, placements) == []
|
||||
|
||||
def test_identical_positions(self):
|
||||
"""两台设备完全重叠 → 1 候选对。"""
|
||||
devices = [_make_device("A", 1.0, 1.0), _make_device("B", 1.0, 1.0)]
|
||||
placements = [
|
||||
_make_placement("A", 0.0, 0.0),
|
||||
_make_placement("B", 0.0, 0.0),
|
||||
]
|
||||
pairs = sweep_and_prune_pairs(devices, placements)
|
||||
assert len(pairs) == 1
|
||||
435
unilabos/layout_optimizer/tests/test_bugfixes_v2.py
Normal file
435
unilabos/layout_optimizer/tests/test_bugfixes_v2.py
Normal file
@@ -0,0 +1,435 @@
|
||||
"""Regression tests for V2 Stage 1 bugfixes.
|
||||
|
||||
Covers:
|
||||
- Duplicate device ID stacking (catalog ID + #N internal IDs)
|
||||
- DE orientation preservation (prefer_orientation_mode constraint)
|
||||
- prefer_aligned auto-injection and adjustability
|
||||
- Preset switch reorientation
|
||||
- min_spacing with duplicate catalog IDs
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from ..constraints import evaluate_constraints
|
||||
from ..mock_checkers import MockCollisionChecker
|
||||
from ..models import Constraint, Device, Lab, Opening, Placement
|
||||
from ..obb import obb_corners, obb_overlap
|
||||
from ..optimizer import (
|
||||
_placements_to_vector,
|
||||
_vector_to_placements,
|
||||
optimize,
|
||||
snap_theta,
|
||||
)
|
||||
from ..seeders import resolve_seeder_params, seed_layout
|
||||
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────
|
||||
|
||||
def _ot(uid: str) -> Device:
|
||||
return Device(
|
||||
id=uid, name="Opentrons Liquid Handler",
|
||||
bbox=(0.6243, 0.5672), openings=[Opening(direction=(0.0, -1.0))],
|
||||
)
|
||||
|
||||
def _tecan(uid: str) -> Device:
|
||||
return Device(
|
||||
id=uid, name="Tecan EVO 100",
|
||||
bbox=(0.8121, 0.8574), openings=[Opening(direction=(0.0, -1.0))],
|
||||
)
|
||||
|
||||
def _facing_dot(p: Placement, device: Device, lab: Lab) -> float:
|
||||
"""Dot product of rotated front vector with vector from center to device.
|
||||
Positive = outward, negative = inward."""
|
||||
cx, cy = lab.width / 2, lab.depth / 2
|
||||
dx, dy = p.x - cx, p.y - cy
|
||||
front = device.openings[0].direction if device.openings else (0.0, -1.0)
|
||||
rf_x = math.cos(p.theta) * front[0] - math.sin(p.theta) * front[1]
|
||||
rf_y = math.sin(p.theta) * front[0] + math.cos(p.theta) * front[1]
|
||||
return rf_x * dx + rf_y * dy
|
||||
|
||||
def _has_collision(devices, placements):
|
||||
for i in range(len(devices)):
|
||||
for j in range(i + 1, len(devices)):
|
||||
ci = obb_corners(placements[i].x, placements[i].y,
|
||||
devices[i].bbox[0], devices[i].bbox[1], placements[i].theta)
|
||||
cj = obb_corners(placements[j].x, placements[j].y,
|
||||
devices[j].bbox[0], devices[j].bbox[1], placements[j].theta)
|
||||
if obb_overlap(ci, cj):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ── Bug 1: Duplicate device ID stacking ────────────────
|
||||
|
||||
class TestDuplicateDeviceIDs:
|
||||
"""When two instances of the same catalog device are placed,
|
||||
unique uuid-based IDs must prevent dict-key collisions."""
|
||||
|
||||
def test_vector_roundtrip_preserves_unique_positions(self):
|
||||
"""_placements_to_vector → _vector_to_placements with unique IDs."""
|
||||
devices = [_ot("uuid-a"), _ot("uuid-b")]
|
||||
placements = [
|
||||
Placement(device_id="uuid-a", x=0.5, y=0.5, theta=0.0),
|
||||
Placement(device_id="uuid-b", x=1.5, y=1.5, theta=1.0),
|
||||
]
|
||||
vec = _placements_to_vector(placements, devices)
|
||||
decoded = _vector_to_placements(vec, devices)
|
||||
assert decoded[0].x == pytest.approx(0.5)
|
||||
assert decoded[1].x == pytest.approx(1.5)
|
||||
|
||||
def test_min_spacing_detects_stacked_unique_ids(self):
|
||||
"""min_spacing should detect two devices at the same position
|
||||
when they have unique IDs."""
|
||||
devices = [_ot("uuid-a"), _ot("uuid-b")]
|
||||
stacked = [
|
||||
Placement(device_id="uuid-a", x=1.0, y=1.0, theta=0.0),
|
||||
Placement(device_id="uuid-b", x=1.0, y=1.0, theta=0.0),
|
||||
]
|
||||
lab = Lab(width=5, depth=5)
|
||||
constraints = [Constraint(type="hard", rule_name="min_spacing",
|
||||
params={"min_gap": 0.05})]
|
||||
# graduated=True (default): 返回有限惩罚
|
||||
cost = evaluate_constraints(devices, stacked, lab, constraints,
|
||||
MockCollisionChecker())
|
||||
assert cost > 0
|
||||
assert not math.isinf(cost)
|
||||
# graduated=False: binary inf
|
||||
cost_binary = evaluate_constraints(devices, stacked, lab, constraints,
|
||||
MockCollisionChecker(),
|
||||
graduated=False)
|
||||
assert math.isinf(cost_binary)
|
||||
|
||||
def test_create_devices_uses_catalog_id_with_suffixes(self):
|
||||
"""create_devices_from_list should keep catalog IDs and suffix duplicates."""
|
||||
from ..device_catalog import create_devices_from_list
|
||||
specs = [
|
||||
{"id": "opentrons_liquid_handler", "uuid": "abc-123"},
|
||||
{"id": "opentrons_liquid_handler", "uuid": "def-456"},
|
||||
]
|
||||
devices = create_devices_from_list(specs)
|
||||
assert devices[0].id == "opentrons_liquid_handler"
|
||||
assert devices[1].id == "opentrons_liquid_handler#2"
|
||||
assert devices[0].uuid == "abc-123"
|
||||
assert devices[1].uuid == "def-456"
|
||||
# Both should have the same bbox from footprints
|
||||
assert devices[0].bbox == devices[1].bbox
|
||||
|
||||
def test_create_devices_fallback_no_uuid(self):
|
||||
"""Without uuid, Device.id falls back to catalog id."""
|
||||
from ..device_catalog import create_devices_from_list
|
||||
specs = [{"id": "opentrons_liquid_handler"}]
|
||||
devices = create_devices_from_list(specs)
|
||||
assert devices[0].id == "opentrons_liquid_handler"
|
||||
|
||||
|
||||
# ── Bug 2 & 4: DE orientation preservation ─────────────
|
||||
|
||||
class TestOrientationWithDE:
|
||||
"""DE must preserve seeder orientation direction (outward/inward)
|
||||
via the prefer_orientation_mode constraint."""
|
||||
|
||||
def _run_de_with_orientation(self, mode, seed_val=42):
|
||||
devices = [_ot("ot1"), _ot("ot2"), _tecan("tecan")]
|
||||
lab = Lab(width=2.0, depth=2.0)
|
||||
params = resolve_seeder_params(
|
||||
"compact_outward" if mode == "outward" else "spread_inward"
|
||||
)
|
||||
seed = seed_layout(devices, lab, params)
|
||||
constraints = [
|
||||
Constraint(type="hard", rule_name="min_spacing",
|
||||
params={"min_gap": 0.05}),
|
||||
Constraint(type="soft", rule_name="prefer_orientation_mode",
|
||||
params={"mode": mode}, weight=5.0),
|
||||
Constraint(type="soft", rule_name="prefer_aligned", weight=2.0),
|
||||
]
|
||||
result = optimize(devices, lab, constraints, seed_placements=seed,
|
||||
maxiter=200, seed=seed_val)
|
||||
result = snap_theta(result)
|
||||
return devices, lab, result
|
||||
|
||||
def test_compact_outward_de_faces_outward(self):
|
||||
devices, lab, result = self._run_de_with_orientation("outward")
|
||||
for i, p in enumerate(result):
|
||||
dot = _facing_dot(p, devices[i], lab)
|
||||
assert dot > 0, (
|
||||
f"{p.device_id} faces inward (dot={dot:.3f}) "
|
||||
f"at ({p.x:.2f},{p.y:.2f}) theta={math.degrees(p.theta):.0f}°"
|
||||
)
|
||||
|
||||
def test_spread_inward_de_faces_inward(self):
|
||||
devices, lab, result = self._run_de_with_orientation("inward")
|
||||
for i, p in enumerate(result):
|
||||
dot = _facing_dot(p, devices[i], lab)
|
||||
assert dot < 0, (
|
||||
f"{p.device_id} faces outward (dot={dot:.3f}) "
|
||||
f"at ({p.x:.2f},{p.y:.2f}) theta={math.degrees(p.theta):.0f}°"
|
||||
)
|
||||
|
||||
def test_switching_preset_changes_orientation(self):
|
||||
"""Switching from outward to inward should produce opposite facing."""
|
||||
_, lab, out_result = self._run_de_with_orientation("outward")
|
||||
devices_in, _, in_result = self._run_de_with_orientation("inward")
|
||||
# At least one device should have different facing
|
||||
out_dots = [_facing_dot(p, devices_in[i], lab) for i, p in enumerate(out_result)]
|
||||
in_dots = [_facing_dot(p, devices_in[i], lab) for i, p in enumerate(in_result)]
|
||||
# Outward: all positive; inward: all negative
|
||||
assert all(d > 0 for d in out_dots), f"outward dots: {out_dots}"
|
||||
assert all(d < 0 for d in in_dots), f"inward dots: {in_dots}"
|
||||
|
||||
def test_no_collision_after_de(self):
|
||||
devices, lab, result = self._run_de_with_orientation("outward")
|
||||
assert not _has_collision(devices, result)
|
||||
|
||||
|
||||
# ── Bug 3: prefer_aligned & prefer_orientation_mode ────
|
||||
|
||||
class TestOrientationConstraints:
|
||||
"""Test the new constraint rules directly."""
|
||||
|
||||
def test_prefer_orientation_mode_outward_zero_at_correct(self):
|
||||
"""Zero cost when device faces outward from center."""
|
||||
device = _ot("a")
|
||||
# Device to the right of center, front pointing right
|
||||
# front=(0,-1), theta=pi/2 → rotated front = (1, 0) = rightward
|
||||
lab = Lab(width=4, depth=4)
|
||||
placements = [Placement("a", 3.0, 2.0, math.pi / 2)]
|
||||
constraint = Constraint(
|
||||
type="soft", rule_name="prefer_orientation_mode",
|
||||
params={"mode": "outward"}, weight=1.0,
|
||||
)
|
||||
cost = evaluate_constraints(
|
||||
[device], placements, lab, [constraint], MockCollisionChecker(),
|
||||
)
|
||||
assert cost == pytest.approx(0.0, abs=0.01)
|
||||
|
||||
def test_prefer_orientation_mode_outward_penalty_at_inward(self):
|
||||
"""High cost when device faces inward (opposite of outward)."""
|
||||
device = _ot("a")
|
||||
# Device to the right of center, front pointing left (inward)
|
||||
# front=(0,-1), theta=3*pi/2 → rotated front = (-1, 0) = leftward
|
||||
lab = Lab(width=4, depth=4)
|
||||
placements = [Placement("a", 3.0, 2.0, 3 * math.pi / 2)]
|
||||
constraint = Constraint(
|
||||
type="soft", rule_name="prefer_orientation_mode",
|
||||
params={"mode": "outward"}, weight=1.0,
|
||||
)
|
||||
cost = evaluate_constraints(
|
||||
[device], placements, lab, [constraint], MockCollisionChecker(),
|
||||
)
|
||||
# 180° off → (1 - cos(pi)) / 2 = 1.0
|
||||
assert cost == pytest.approx(1.0, abs=0.05)
|
||||
|
||||
def test_prefer_orientation_mode_inward(self):
|
||||
"""Zero cost when device faces inward."""
|
||||
device = _ot("a")
|
||||
# Device to the right of center, front pointing left (inward)
|
||||
lab = Lab(width=4, depth=4)
|
||||
placements = [Placement("a", 3.0, 2.0, 3 * math.pi / 2)]
|
||||
constraint = Constraint(
|
||||
type="soft", rule_name="prefer_orientation_mode",
|
||||
params={"mode": "inward"}, weight=1.0,
|
||||
)
|
||||
cost = evaluate_constraints(
|
||||
[device], placements, lab, [constraint], MockCollisionChecker(),
|
||||
)
|
||||
assert cost == pytest.approx(0.0, abs=0.01)
|
||||
|
||||
def test_prefer_seeder_orientation_zero_at_target(self):
|
||||
"""Zero cost when theta matches target."""
|
||||
device = Device(id="a", name="A", bbox=(0.5, 0.5))
|
||||
lab = Lab(width=4, depth=4)
|
||||
placements = [Placement("a", 2, 2, 1.5)]
|
||||
constraint = Constraint(
|
||||
type="soft", rule_name="prefer_seeder_orientation",
|
||||
params={"target_thetas": {"a": 1.5}}, weight=1.0,
|
||||
)
|
||||
cost = evaluate_constraints(
|
||||
[device], placements, lab, [constraint], MockCollisionChecker(),
|
||||
)
|
||||
assert cost == pytest.approx(0.0, abs=1e-9)
|
||||
|
||||
def test_prefer_seeder_orientation_penalty_at_deviation(self):
|
||||
"""Non-zero cost when theta deviates from target."""
|
||||
device = Device(id="a", name="A", bbox=(0.5, 0.5))
|
||||
lab = Lab(width=4, depth=4)
|
||||
placements = [Placement("a", 2, 2, math.pi)] # pi away from 0
|
||||
constraint = Constraint(
|
||||
type="soft", rule_name="prefer_seeder_orientation",
|
||||
params={"target_thetas": {"a": 0.0}}, weight=1.0,
|
||||
)
|
||||
cost = evaluate_constraints(
|
||||
[device], placements, lab, [constraint], MockCollisionChecker(),
|
||||
)
|
||||
# (1 - cos(pi)) / 2 = 1.0
|
||||
assert cost == pytest.approx(1.0)
|
||||
|
||||
|
||||
# ── API endpoint regression ────────────────────────────
|
||||
|
||||
class TestEndpointOrientation:
|
||||
"""Test that /optimize injects orientation constraints."""
|
||||
|
||||
def test_endpoint_with_de_injects_orientation(self):
|
||||
from fastapi.testclient import TestClient
|
||||
from ..server import app
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.post("/optimize", json={
|
||||
"devices": [
|
||||
{"id": "opentrons_liquid_handler", "uuid": "u1"},
|
||||
{"id": "opentrons_liquid_handler", "uuid": "u2"},
|
||||
],
|
||||
"lab": {"width": 3, "depth": 3},
|
||||
"seeder": "compact_outward",
|
||||
"run_de": True,
|
||||
"maxiter": 50,
|
||||
"seed": 42,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# Both devices should have unique uuids in response
|
||||
uuids = [p["uuid"] for p in data["placements"]]
|
||||
assert len(set(uuids)) == 2, f"Expected 2 unique uuids, got {uuids}"
|
||||
|
||||
def test_endpoint_orientation_weight_override(self):
|
||||
from fastapi.testclient import TestClient
|
||||
from ..server import app
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.post("/optimize", json={
|
||||
"devices": [{"id": "opentrons_liquid_handler", "uuid": "u1"}],
|
||||
"lab": {"width": 3, "depth": 3},
|
||||
"seeder": "compact_outward",
|
||||
"seeder_overrides": {"orientation_weight": 10, "align_weight": 0},
|
||||
"run_de": True,
|
||||
"maxiter": 50,
|
||||
"seed": 42,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_endpoint_align_weight_zero_disables(self):
|
||||
"""Setting align_weight=0 should not inject prefer_aligned."""
|
||||
from fastapi.testclient import TestClient
|
||||
from ..server import app
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.post("/optimize", json={
|
||||
"devices": [{"id": "opentrons_liquid_handler", "uuid": "u1"}],
|
||||
"lab": {"width": 3, "depth": 3},
|
||||
"seeder": "compact_outward",
|
||||
"seeder_overrides": {"align_weight": 0},
|
||||
"run_de": True,
|
||||
"maxiter": 50,
|
||||
"seed": 42,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ── Broader scenario tests ─────────────────────────────
|
||||
|
||||
class TestScenarios:
|
||||
"""End-to-end scenarios similar to user's real usage."""
|
||||
|
||||
def test_user_scenario_2ot_1tecan_compact_outward(self):
|
||||
"""User's exact scenario: 2 OT + 1 Tecan in 2m×2m, compact outward."""
|
||||
devices = [_ot("ot1"), _ot("ot2"), _tecan("tecan")]
|
||||
lab = Lab(width=2.0, depth=2.0)
|
||||
params = resolve_seeder_params("compact_outward")
|
||||
seed = seed_layout(devices, lab, params)
|
||||
constraints = [
|
||||
Constraint(type="hard", rule_name="min_spacing",
|
||||
params={"min_gap": 0.05}),
|
||||
Constraint(type="soft", rule_name="prefer_orientation_mode",
|
||||
params={"mode": "outward"}, weight=5.0),
|
||||
Constraint(type="soft", rule_name="prefer_aligned", weight=2.0),
|
||||
]
|
||||
result = optimize(devices, lab, constraints, seed_placements=seed,
|
||||
maxiter=200, seed=42)
|
||||
result = snap_theta(result)
|
||||
# No stacking
|
||||
assert not _has_collision(devices, result)
|
||||
# All outward
|
||||
for i, p in enumerate(result):
|
||||
assert _facing_dot(p, devices[i], lab) > 0
|
||||
|
||||
def test_4_medium_devices_mixed_openings(self):
|
||||
"""4 devices with different opening directions."""
|
||||
devices = [
|
||||
Device(id="d0", name="D0", bbox=(0.5, 0.3), openings=[Opening((1, 0))]),
|
||||
Device(id="d1", name="D1", bbox=(0.5, 0.3), openings=[Opening((-1, 0))]),
|
||||
Device(id="d2", name="D2", bbox=(0.5, 0.3), openings=[Opening((0, -1))]),
|
||||
Device(id="d3", name="D3", bbox=(0.5, 0.3), openings=[Opening((0, 1))]),
|
||||
]
|
||||
lab = Lab(width=3.0, depth=3.0)
|
||||
params = resolve_seeder_params("compact_outward")
|
||||
seed = seed_layout(devices, lab, params)
|
||||
constraints = [
|
||||
Constraint(type="hard", rule_name="min_spacing",
|
||||
params={"min_gap": 0.05}),
|
||||
Constraint(type="soft", rule_name="prefer_orientation_mode",
|
||||
params={"mode": "outward"}, weight=5.0),
|
||||
Constraint(type="soft", rule_name="prefer_aligned", weight=2.0),
|
||||
]
|
||||
result = optimize(devices, lab, constraints, seed_placements=seed,
|
||||
maxiter=200, seed=42)
|
||||
result = snap_theta(result)
|
||||
assert not _has_collision(devices, result)
|
||||
for i, p in enumerate(result):
|
||||
assert _facing_dot(p, devices[i], lab) > 0
|
||||
|
||||
|
||||
# ── V2 Stage 1: 默认关闭 cardinal snap/alignment ────────
|
||||
|
||||
class TestV2Stage1Bugfixes:
|
||||
"""align_weight 默认为 0,snap_cardinal 默认关闭。"""
|
||||
|
||||
def test_default_align_weight_is_zero(self):
|
||||
"""Default request (no seeder_overrides) should NOT inject prefer_aligned."""
|
||||
from fastapi.testclient import TestClient
|
||||
from ..server import app
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.post("/optimize", json={
|
||||
"devices": [{"id": "opentrons_liquid_handler", "uuid": "u1"}],
|
||||
"lab": {"width": 3, "depth": 3},
|
||||
"seeder": "compact_outward",
|
||||
"run_de": True,
|
||||
"maxiter": 50,
|
||||
"seed": 42,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_snap_cardinal_off_by_default(self):
|
||||
"""Default request should NOT snap theta to cardinal."""
|
||||
from fastapi.testclient import TestClient
|
||||
from ..server import app
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.post("/optimize", json={
|
||||
"devices": [{"id": "opentrons_liquid_handler", "uuid": "u1"}],
|
||||
"lab": {"width": 3, "depth": 3},
|
||||
"seeder": "compact_outward",
|
||||
"run_de": True,
|
||||
"maxiter": 10,
|
||||
"seed": 42,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_snap_cardinal_opt_in(self):
|
||||
"""snap_cardinal=True should be accepted and snap angles."""
|
||||
from fastapi.testclient import TestClient
|
||||
from ..server import app
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.post("/optimize", json={
|
||||
"devices": [{"id": "opentrons_liquid_handler", "uuid": "u1"}],
|
||||
"lab": {"width": 3, "depth": 3},
|
||||
"seeder": "compact_outward",
|
||||
"snap_cardinal": True,
|
||||
"run_de": True,
|
||||
"maxiter": 10,
|
||||
"seed": 42,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
505
unilabos/layout_optimizer/tests/test_constraints.py
Normal file
505
unilabos/layout_optimizer/tests/test_constraints.py
Normal file
@@ -0,0 +1,505 @@
|
||||
"""约束体系测试。"""
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from ..constraints import (
|
||||
_crossing_penalty,
|
||||
_opening_surface_center,
|
||||
DEFAULT_WEIGHT_DISTANCE,
|
||||
evaluate_constraints,
|
||||
evaluate_default_hard_constraints,
|
||||
)
|
||||
from ..mock_checkers import MockCollisionChecker, MockReachabilityChecker
|
||||
from ..models import Constraint, Device, Opening, Placement, Lab
|
||||
from ..obb import nearest_point_on_obb, obb_corners
|
||||
|
||||
|
||||
def _make_devices():
|
||||
return [
|
||||
Device(id="a", name="Device A", bbox=(0.5, 0.5)),
|
||||
Device(id="b", name="Device B", bbox=(0.5, 0.5)),
|
||||
]
|
||||
|
||||
|
||||
def _make_lab():
|
||||
return Lab(width=5.0, depth=4.0)
|
||||
|
||||
|
||||
class TestDefaultHardConstraints:
|
||||
def test_no_collision_passes(self):
|
||||
"""无碰撞的布局应返回 0。"""
|
||||
devices = _make_devices()
|
||||
placements = [
|
||||
Placement("a", 1.0, 1.0, 0.0),
|
||||
Placement("b", 3.0, 3.0, 0.0),
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
cost = evaluate_default_hard_constraints(devices, placements, _make_lab(), checker)
|
||||
assert cost == 0.0
|
||||
|
||||
def test_collision_returns_graduated_penalty(self):
|
||||
"""碰撞布局应返回正的graduated penalty(非inf)。"""
|
||||
devices = _make_devices()
|
||||
placements = [
|
||||
Placement("a", 1.0, 1.0, 0.0),
|
||||
Placement("b", 1.2, 1.0, 0.0),
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
cost = evaluate_default_hard_constraints(devices, placements, _make_lab(), checker)
|
||||
assert cost > 0
|
||||
assert not math.isinf(cost)
|
||||
|
||||
def test_collision_returns_inf_binary_mode(self):
|
||||
"""Binary mode: 碰撞布局应返回 inf。"""
|
||||
devices = _make_devices()
|
||||
placements = [
|
||||
Placement("a", 1.0, 1.0, 0.0),
|
||||
Placement("b", 1.2, 1.0, 0.0),
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
cost = evaluate_default_hard_constraints(
|
||||
devices, placements, _make_lab(), checker, graduated=False,
|
||||
)
|
||||
assert math.isinf(cost)
|
||||
|
||||
def test_out_of_bounds_returns_graduated_penalty(self):
|
||||
"""越界布局应返回正的graduated penalty(非inf)。"""
|
||||
devices = _make_devices()
|
||||
placements = [
|
||||
Placement("a", 0.1, 0.1, 0.0), # 左下角越界
|
||||
Placement("b", 3.0, 3.0, 0.0),
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
cost = evaluate_default_hard_constraints(devices, placements, _make_lab(), checker)
|
||||
assert cost > 0
|
||||
assert not math.isinf(cost)
|
||||
|
||||
def test_out_of_bounds_returns_inf_binary_mode(self):
|
||||
"""Binary mode: 越界布局应返回 inf。"""
|
||||
devices = _make_devices()
|
||||
placements = [
|
||||
Placement("a", 0.1, 0.1, 0.0),
|
||||
Placement("b", 3.0, 3.0, 0.0),
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
cost = evaluate_default_hard_constraints(
|
||||
devices, placements, _make_lab(), checker, graduated=False,
|
||||
)
|
||||
assert math.isinf(cost)
|
||||
|
||||
def test_worse_collision_higher_cost(self):
|
||||
"""Deeper penetration should produce higher cost."""
|
||||
devices = _make_devices()
|
||||
checker = MockCollisionChecker()
|
||||
lab = _make_lab()
|
||||
# Small overlap
|
||||
cost_small = evaluate_default_hard_constraints(
|
||||
devices, [Placement("a", 1.0, 1.0, 0.0), Placement("b", 1.4, 1.0, 0.0)],
|
||||
lab, checker,
|
||||
)
|
||||
# Large overlap
|
||||
cost_large = evaluate_default_hard_constraints(
|
||||
devices, [Placement("a", 1.0, 1.0, 0.0), Placement("b", 1.1, 1.0, 0.0)],
|
||||
lab, checker,
|
||||
)
|
||||
assert cost_large > cost_small > 0
|
||||
|
||||
|
||||
class TestUserConstraints:
|
||||
def test_distance_less_than_satisfied(self):
|
||||
"""距离约束满足时 cost=0。"""
|
||||
devices = _make_devices()
|
||||
placements = [
|
||||
Placement("a", 1.0, 1.0, 0.0),
|
||||
Placement("b", 1.5, 1.0, 0.0),
|
||||
]
|
||||
constraints = [
|
||||
Constraint(type="hard", rule_name="distance_less_than",
|
||||
params={"device_a": "a", "device_b": "b", "distance": 1.0})
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
reachability = MockReachabilityChecker()
|
||||
cost = evaluate_constraints(
|
||||
devices, placements, _make_lab(), constraints, checker, reachability
|
||||
)
|
||||
assert cost == 0.0
|
||||
|
||||
def test_distance_less_than_violated_hard(self):
|
||||
"""硬距离约束违反:graduated模式返回有限惩罚,binary模式返回inf。"""
|
||||
devices = _make_devices()
|
||||
placements = [
|
||||
Placement("a", 1.0, 1.0, 0.0),
|
||||
Placement("b", 4.0, 3.0, 0.0),
|
||||
]
|
||||
constraints = [
|
||||
Constraint(type="hard", rule_name="distance_less_than",
|
||||
params={"device_a": "a", "device_b": "b", "distance": 1.0})
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
# graduated=True (default): 有限惩罚
|
||||
cost = evaluate_constraints(
|
||||
devices, placements, _make_lab(), constraints, checker
|
||||
)
|
||||
assert cost > 0
|
||||
assert not math.isinf(cost)
|
||||
# graduated=False: binary inf
|
||||
cost_binary = evaluate_constraints(
|
||||
devices, placements, _make_lab(), constraints, checker,
|
||||
graduated=False,
|
||||
)
|
||||
assert math.isinf(cost_binary)
|
||||
|
||||
def test_minimize_distance_cost(self):
|
||||
"""minimize_distance 约束应返回正比于距离的 cost。"""
|
||||
devices = _make_devices()
|
||||
placements = [
|
||||
Placement("a", 1.0, 1.0, 0.0),
|
||||
Placement("b", 3.0, 1.0, 0.0),
|
||||
]
|
||||
constraints = [
|
||||
Constraint(type="soft", rule_name="minimize_distance",
|
||||
params={"device_a": "a", "device_b": "b"}, weight=2.0)
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
cost = evaluate_constraints(
|
||||
devices, placements, _make_lab(), constraints, checker
|
||||
)
|
||||
# edge-to-edge distance = 2.0 - 0.25 - 0.25 = 1.5, weight = 2.0 → cost = 3.0
|
||||
assert abs(cost - 3.0) < 0.01
|
||||
|
||||
def test_reachability_constraint(self):
|
||||
"""可达性约束:目标在臂展内应通过(不返回 inf)。
|
||||
|
||||
Opening-faces-arm penalty may add a small soft cost when the
|
||||
target's opening doesn't face the arm, but it must not cause
|
||||
hard failure (inf).
|
||||
"""
|
||||
devices = [
|
||||
Device(id="arm", name="Arm", bbox=(0.2, 0.2), device_type="articulation"),
|
||||
Device(id="target", name="Target", bbox=(0.5, 0.5)),
|
||||
]
|
||||
placements = [
|
||||
Placement("arm", 1.0, 1.0, 0.0),
|
||||
Placement("target", 1.5, 1.0, 0.0),
|
||||
]
|
||||
constraints = [
|
||||
Constraint(type="hard", rule_name="reachability",
|
||||
params={"arm_id": "arm", "target_device_id": "target"})
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
reachability = MockReachabilityChecker(arm_reach={"arm": 1.0})
|
||||
cost = evaluate_constraints(
|
||||
devices, placements, _make_lab(), constraints, checker, reachability
|
||||
)
|
||||
assert not math.isinf(cost) # reachable → no hard failure
|
||||
|
||||
def test_reachability_constraint_violated(self):
|
||||
"""可达性约束:目标超出臂展 — graduated返回有限惩罚,binary返回inf。"""
|
||||
devices = [
|
||||
Device(id="arm", name="Arm", bbox=(0.2, 0.2), device_type="articulation"),
|
||||
Device(id="target", name="Target", bbox=(0.5, 0.5)),
|
||||
]
|
||||
placements = [
|
||||
Placement("arm", 1.0, 1.0, 0.0),
|
||||
Placement("target", 4.0, 3.0, 0.0),
|
||||
]
|
||||
constraints = [
|
||||
Constraint(type="hard", rule_name="reachability",
|
||||
params={"arm_id": "arm", "target_device_id": "target"})
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
reachability = MockReachabilityChecker(arm_reach={"arm": 1.0})
|
||||
# graduated=True (default): 有限惩罚
|
||||
cost = evaluate_constraints(
|
||||
devices, placements, _make_lab(), constraints, checker, reachability
|
||||
)
|
||||
assert cost > 0
|
||||
assert not math.isinf(cost)
|
||||
# graduated=False: binary inf
|
||||
cost_binary = evaluate_constraints(
|
||||
devices, placements, _make_lab(), constraints, checker, reachability,
|
||||
graduated=False,
|
||||
)
|
||||
assert math.isinf(cost_binary)
|
||||
|
||||
|
||||
def test_distance_less_than_uses_edge_to_edge():
|
||||
"""distance_less_than should measure edge-to-edge, not center-to-center.
|
||||
|
||||
Two devices: centers 3m apart, each 2m wide → edge gap = 1m.
|
||||
Constraint: distance_less_than 1.5m (edge-to-edge).
|
||||
Old center-to-center: 3m > 1.5m → violation.
|
||||
New edge-to-edge: 1m < 1.5m → satisfied.
|
||||
"""
|
||||
devices = [
|
||||
Device(id="a", name="A", bbox=(2.0, 1.0)),
|
||||
Device(id="b", name="B", bbox=(2.0, 1.0)),
|
||||
]
|
||||
placements = [
|
||||
Placement(device_id="a", x=1.0, y=1.0, theta=0.0),
|
||||
Placement(device_id="b", x=4.0, y=1.0, theta=0.0),
|
||||
]
|
||||
lab = Lab(width=10, depth=10)
|
||||
constraint = Constraint(
|
||||
type="soft", rule_name="distance_less_than",
|
||||
params={"device_a": "a", "device_b": "b", "distance": 1.5},
|
||||
weight=1.0,
|
||||
)
|
||||
checker = MockCollisionChecker()
|
||||
cost = evaluate_constraints(devices, placements, lab, [constraint], checker)
|
||||
assert cost == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_prefer_aligned_zero_at_cardinal():
|
||||
"""prefer_aligned cost = 0 when all devices at 0/90/180/270°."""
|
||||
devices = [Device(id="a", name="A", bbox=(1.0, 1.0))]
|
||||
lab = Lab(width=10, depth=10)
|
||||
checker = MockCollisionChecker()
|
||||
for angle in [0, math.pi / 2, math.pi, 3 * math.pi / 2]:
|
||||
placements = [Placement(device_id="a", x=5, y=5, theta=angle)]
|
||||
constraint = Constraint(type="soft", rule_name="prefer_aligned", weight=1.0)
|
||||
cost = evaluate_constraints(devices, placements, lab, [constraint], checker)
|
||||
assert cost == pytest.approx(0.0, abs=1e-9)
|
||||
|
||||
|
||||
def test_prefer_aligned_max_at_45():
|
||||
"""prefer_aligned cost is maximum when device at 45°."""
|
||||
devices = [Device(id="a", name="A", bbox=(1.0, 1.0))]
|
||||
placements = [Placement(device_id="a", x=5, y=5, theta=math.pi / 4)]
|
||||
lab = Lab(width=10, depth=10)
|
||||
constraint = Constraint(type="soft", rule_name="prefer_aligned", weight=1.0)
|
||||
checker = MockCollisionChecker()
|
||||
cost = evaluate_constraints(devices, placements, lab, [constraint], checker)
|
||||
# (1 - cos(4 * pi/4)) / 2 = (1 - cos(pi)) / 2 = (1 - (-1)) / 2 = 1.0
|
||||
assert cost == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_prefer_aligned_sums_over_devices():
|
||||
"""Cost sums across all devices."""
|
||||
devices = [
|
||||
Device(id="a", name="A", bbox=(1.0, 1.0)),
|
||||
Device(id="b", name="B", bbox=(1.0, 1.0)),
|
||||
]
|
||||
placements = [
|
||||
Placement(device_id="a", x=2, y=2, theta=math.pi / 4), # cost = 1.0
|
||||
Placement(device_id="b", x=7, y=7, theta=math.pi / 4), # cost = 1.0
|
||||
]
|
||||
lab = Lab(width=10, depth=10)
|
||||
constraint = Constraint(type="soft", rule_name="prefer_aligned", weight=2.0)
|
||||
checker = MockCollisionChecker()
|
||||
cost = evaluate_constraints(devices, placements, lab, [constraint], checker)
|
||||
# 2 devices × 1.0 × weight 2.0 = 4.0
|
||||
assert cost == pytest.approx(4.0)
|
||||
|
||||
|
||||
class TestGraduatedHardConstraints:
|
||||
"""graduated 模式下硬约束返回比例惩罚而非 inf。"""
|
||||
|
||||
def test_hard_reachability_graduated_finite(self):
|
||||
"""graduated=True: 硬可达性返回有限惩罚。"""
|
||||
devices = [
|
||||
Device(id="arm", name="Arm", bbox=(0.2, 0.2), device_type="articulation"),
|
||||
Device(id="t", name="Target", bbox=(0.5, 0.5)),
|
||||
]
|
||||
placements = [
|
||||
Placement("arm", 1.0, 1.0, 0.0),
|
||||
Placement("t", 4.0, 3.0, 0.0),
|
||||
]
|
||||
constraints = [
|
||||
Constraint(type="hard", rule_name="reachability",
|
||||
params={"arm_id": "arm", "target_device_id": "t"}, weight=1.0)
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
reach = MockReachabilityChecker(arm_reach={"arm": 1.0})
|
||||
cost = evaluate_constraints(
|
||||
devices, placements, _make_lab(), constraints, checker, reach,
|
||||
graduated=True,
|
||||
)
|
||||
assert cost > 0
|
||||
assert not math.isinf(cost)
|
||||
|
||||
def test_hard_reachability_binary_inf(self):
|
||||
"""graduated=False: 硬可达性返回 inf。"""
|
||||
devices = [
|
||||
Device(id="arm", name="Arm", bbox=(0.2, 0.2), device_type="articulation"),
|
||||
Device(id="t", name="Target", bbox=(0.5, 0.5)),
|
||||
]
|
||||
placements = [
|
||||
Placement("arm", 1.0, 1.0, 0.0),
|
||||
Placement("t", 4.0, 3.0, 0.0),
|
||||
]
|
||||
constraints = [
|
||||
Constraint(type="hard", rule_name="reachability",
|
||||
params={"arm_id": "arm", "target_device_id": "t"}, weight=1.0)
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
reach = MockReachabilityChecker(arm_reach={"arm": 1.0})
|
||||
cost = evaluate_constraints(
|
||||
devices, placements, _make_lab(), constraints, checker, reach,
|
||||
graduated=False,
|
||||
)
|
||||
assert math.isinf(cost)
|
||||
|
||||
def test_hard_min_spacing_graduated_sums_all_pairs(self):
|
||||
"""graduated模式:min_spacing 对所有违规对求和(不只第一对)。"""
|
||||
devices = [
|
||||
Device(id="a", name="A", bbox=(0.5, 0.5)),
|
||||
Device(id="b", name="B", bbox=(0.5, 0.5)),
|
||||
Device(id="c", name="C", bbox=(0.5, 0.5)),
|
||||
]
|
||||
# 三个设备间距都小于 min_gap=1.0
|
||||
placements = [
|
||||
Placement("a", 1.0, 2.0, 0.0),
|
||||
Placement("b", 1.3, 2.0, 0.0), # OBB 边缘距 a 约 0.3
|
||||
Placement("c", 1.6, 2.0, 0.0), # OBB 边缘距 b 约 0.3, 距 a 约 0.6
|
||||
]
|
||||
constraints = [
|
||||
Constraint(type="hard", rule_name="min_spacing",
|
||||
params={"min_gap": 1.0}, weight=1.0)
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
cost = evaluate_constraints(
|
||||
devices, placements, _make_lab(), constraints, checker,
|
||||
graduated=True,
|
||||
)
|
||||
# 应大于 0 且有限(累加多对违规)
|
||||
assert cost > 0
|
||||
assert not math.isinf(cost)
|
||||
|
||||
def test_hard_min_spacing_binary_inf(self):
|
||||
"""graduated=False: min_spacing 违规返回 inf。"""
|
||||
devices = _make_devices()
|
||||
placements = [
|
||||
Placement("a", 1.0, 2.0, 0.0),
|
||||
Placement("b", 1.3, 2.0, 0.0),
|
||||
]
|
||||
constraints = [
|
||||
Constraint(type="hard", rule_name="min_spacing",
|
||||
params={"min_gap": 1.0}, weight=1.0)
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
cost = evaluate_constraints(
|
||||
devices, placements, _make_lab(), constraints, checker,
|
||||
graduated=False,
|
||||
)
|
||||
assert math.isinf(cost)
|
||||
|
||||
def test_hard_distance_less_than_graduated(self):
|
||||
"""graduated模式:distance_less_than 硬约束返回比例惩罚。"""
|
||||
devices = _make_devices()
|
||||
placements = [
|
||||
Placement("a", 1.0, 2.0, 0.0),
|
||||
Placement("b", 4.0, 2.0, 0.0),
|
||||
]
|
||||
constraints = [
|
||||
Constraint(type="hard", rule_name="distance_less_than",
|
||||
params={"device_a": "a", "device_b": "b", "distance": 0.5},
|
||||
weight=2.0)
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
cost = evaluate_constraints(
|
||||
devices, placements, _make_lab(), constraints, checker,
|
||||
graduated=True,
|
||||
)
|
||||
# HARD_MULTIPLIER(5) × weight(2) × overshoot > 0
|
||||
assert cost > 0
|
||||
assert not math.isinf(cost)
|
||||
|
||||
def test_graduated_default_is_true(self):
|
||||
"""不传 graduated 参数时默认使用 graduated 模式。"""
|
||||
devices = _make_devices()
|
||||
placements = [
|
||||
Placement("a", 1.0, 2.0, 0.0),
|
||||
Placement("b", 4.0, 2.0, 0.0),
|
||||
]
|
||||
constraints = [
|
||||
Constraint(type="hard", rule_name="distance_less_than",
|
||||
params={"device_a": "a", "device_b": "b", "distance": 0.5},
|
||||
weight=1.0)
|
||||
]
|
||||
checker = MockCollisionChecker()
|
||||
# 不指定 graduated — 默认应为 True → 有限惩罚
|
||||
cost = evaluate_constraints(
|
||||
devices, placements, _make_lab(), constraints, checker,
|
||||
)
|
||||
assert not math.isinf(cost)
|
||||
|
||||
|
||||
class TestCrossingPenalty:
|
||||
"""_crossing_penalty: 交叉长度加权的 soft penalty。"""
|
||||
|
||||
def _make_device(self, dev_id, bbox=(0.5, 0.5), direction=(0.0, -1.0)):
|
||||
return Device(
|
||||
id=dev_id, name=dev_id, device_type="static",
|
||||
bbox=bbox, height=0.3,
|
||||
openings=[Opening(direction=direction, label="front")],
|
||||
)
|
||||
|
||||
def test_no_blockers_returns_zero(self):
|
||||
"""arm 与 target 之间无遮挡设备 → 交叉代价为 0。"""
|
||||
arm = self._make_device("arm", bbox=(2.14, 0.35))
|
||||
target = self._make_device("target")
|
||||
arm_p = Placement(device_id="arm", x=2.0, y=1.0, theta=0.0)
|
||||
target_p = Placement(device_id="target", x=0.5, y=1.0, theta=3.14159)
|
||||
device_map = {"arm": arm, "target": target}
|
||||
placement_map = {"arm": arm_p, "target": target_p}
|
||||
|
||||
opening_pt = _opening_surface_center(target, target_p)
|
||||
arm_corners = obb_corners(arm_p.x, arm_p.y, arm.bbox[0], arm.bbox[1], arm_p.theta)
|
||||
nearest = nearest_point_on_obb(opening_pt[0], opening_pt[1], arm_corners)
|
||||
|
||||
cost = _crossing_penalty(
|
||||
opening_pt, nearest,
|
||||
"arm", "target",
|
||||
device_map, placement_map,
|
||||
)
|
||||
assert cost == 0.0
|
||||
|
||||
def test_one_blocker_proportional_to_length(self):
|
||||
"""一个遮挡设备 → cost = DEFAULT_WEIGHT_DISTANCE * 穿过长度。"""
|
||||
arm = self._make_device("arm", bbox=(2.14, 0.35))
|
||||
target = self._make_device("target")
|
||||
blocker = self._make_device("blocker", bbox=(0.5, 0.5))
|
||||
arm_p = Placement(device_id="arm", x=3.0, y=1.0, theta=0.0)
|
||||
target_p = Placement(device_id="target", x=0.0, y=1.0, theta=0.0)
|
||||
blocker_p = Placement(device_id="blocker", x=1.5, y=1.0, theta=0.0)
|
||||
device_map = {"arm": arm, "target": target, "blocker": blocker}
|
||||
placement_map = {"arm": arm_p, "target": target_p, "blocker": blocker_p}
|
||||
|
||||
opening_pt = _opening_surface_center(target, target_p)
|
||||
arm_corners = obb_corners(arm_p.x, arm_p.y, arm.bbox[0], arm.bbox[1], arm_p.theta)
|
||||
nearest = nearest_point_on_obb(opening_pt[0], opening_pt[1], arm_corners)
|
||||
|
||||
cost = _crossing_penalty(
|
||||
opening_pt, nearest,
|
||||
"arm", "target",
|
||||
device_map, placement_map,
|
||||
)
|
||||
# blocker 宽 0.5m,theta=0,路径水平 → 穿过长度 ≈ 0.5m
|
||||
# cost = DEFAULT_WEIGHT_DISTANCE * 0.5 = 100 * 0.5 = 50
|
||||
assert cost > 0
|
||||
assert abs(cost - DEFAULT_WEIGHT_DISTANCE * 0.5) < DEFAULT_WEIGHT_DISTANCE * 0.1
|
||||
|
||||
def test_blocker_off_path_returns_zero(self):
|
||||
"""不在路径上的设备 → 交叉代价为 0。"""
|
||||
arm = self._make_device("arm", bbox=(2.14, 0.35))
|
||||
target = self._make_device("target")
|
||||
bystander = self._make_device("bystander", bbox=(0.5, 0.5))
|
||||
arm_p = Placement(device_id="arm", x=3.0, y=1.0, theta=0.0)
|
||||
target_p = Placement(device_id="target", x=0.0, y=1.0, theta=0.0)
|
||||
bystander_p = Placement(device_id="bystander", x=1.5, y=3.0, theta=0.0)
|
||||
device_map = {"arm": arm, "target": target, "bystander": bystander}
|
||||
placement_map = {"arm": arm_p, "target": target_p, "bystander": bystander_p}
|
||||
|
||||
opening_pt = _opening_surface_center(target, target_p)
|
||||
arm_corners = obb_corners(arm_p.x, arm_p.y, arm.bbox[0], arm.bbox[1], arm_p.theta)
|
||||
nearest = nearest_point_on_obb(opening_pt[0], opening_pt[1], arm_corners)
|
||||
|
||||
cost = _crossing_penalty(
|
||||
opening_pt, nearest,
|
||||
"arm", "target",
|
||||
device_map, placement_map,
|
||||
)
|
||||
assert cost == 0.0
|
||||
234
unilabos/layout_optimizer/tests/test_device_catalog.py
Normal file
234
unilabos/layout_optimizer/tests/test_device_catalog.py
Normal file
@@ -0,0 +1,234 @@
|
||||
"""device_catalog 双源加载测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from ..device_catalog import (
|
||||
_DEFAULT_FOOTPRINTS,
|
||||
create_devices_from_list,
|
||||
load_devices_from_assets,
|
||||
load_devices_from_registry,
|
||||
load_footprints,
|
||||
merge_device_lists,
|
||||
reset_footprints_cache,
|
||||
resolve_device,
|
||||
)
|
||||
|
||||
# ---------- fixtures ----------
|
||||
|
||||
# LeapLab/layout_optimizer/tests/ → LeapLab/ → DPTech/
|
||||
_LEAPLAB = Path(__file__).resolve().parent.parent.parent
|
||||
_DPTECH = _LEAPLAB.parent
|
||||
DATA_JSON = _DPTECH / "uni-lab-assets" / "data.json"
|
||||
REGISTRY_DIR = _LEAPLAB / "Uni-Lab-OS" / "unilabos" / "device_mesh" / "devices"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_cache():
|
||||
"""每个测试前清除缓存。"""
|
||||
reset_footprints_cache()
|
||||
yield
|
||||
reset_footprints_cache()
|
||||
|
||||
|
||||
# ---------- footprints ----------
|
||||
|
||||
|
||||
class TestLoadFootprints:
|
||||
def test_load_footprints_exists(self):
|
||||
fp = load_footprints(_DEFAULT_FOOTPRINTS)
|
||||
assert isinstance(fp, dict)
|
||||
assert len(fp) > 0
|
||||
|
||||
def test_footprint_structure(self):
|
||||
fp = load_footprints()
|
||||
for dev_id, entry in fp.items():
|
||||
assert "bbox" in entry, f"{dev_id} missing bbox"
|
||||
assert len(entry["bbox"]) == 2
|
||||
assert "height" in entry
|
||||
assert "origin_offset" in entry
|
||||
assert "openings" in entry
|
||||
|
||||
def test_known_device_in_footprints(self):
|
||||
fp = load_footprints()
|
||||
assert "agilent_bravo" in fp
|
||||
bbox = fp["agilent_bravo"]["bbox"]
|
||||
assert 0.5 < bbox[0] < 1.0 # width ~0.65m
|
||||
assert 0.5 < bbox[1] < 1.0 # depth ~0.70m
|
||||
|
||||
def test_nonexistent_path_returns_empty(self):
|
||||
reset_footprints_cache()
|
||||
fp = load_footprints("/nonexistent/footprints.json")
|
||||
assert fp == {}
|
||||
|
||||
|
||||
# ---------- assets 加载 ----------
|
||||
|
||||
|
||||
class TestLoadFromAssets:
|
||||
@pytest.mark.skipif(not DATA_JSON.exists(), reason="data.json not found")
|
||||
def test_load_returns_devices(self):
|
||||
devices = load_devices_from_assets(DATA_JSON)
|
||||
assert len(devices) > 0
|
||||
|
||||
@pytest.mark.skipif(not DATA_JSON.exists(), reason="data.json not found")
|
||||
def test_known_device_has_real_bbox(self):
|
||||
devices = load_devices_from_assets(DATA_JSON)
|
||||
bravo = next((d for d in devices if d.id == "agilent_bravo"), None)
|
||||
assert bravo is not None
|
||||
assert bravo.bbox != (0.6, 0.4) # 不是默认值
|
||||
assert bravo.source == "assets"
|
||||
|
||||
def test_missing_data_json(self):
|
||||
devices = load_devices_from_assets("/nonexistent/data.json")
|
||||
assert devices == []
|
||||
|
||||
|
||||
# ---------- registry 加载 ----------
|
||||
|
||||
|
||||
class TestLoadFromRegistry:
|
||||
@pytest.mark.skipif(not REGISTRY_DIR.exists(), reason="registry dir not found")
|
||||
def test_load_returns_devices(self):
|
||||
devices = load_devices_from_registry(REGISTRY_DIR)
|
||||
assert len(devices) > 0
|
||||
|
||||
@pytest.mark.skipif(not REGISTRY_DIR.exists(), reason="registry dir not found")
|
||||
def test_elite_robot_present(self):
|
||||
devices = load_devices_from_registry(REGISTRY_DIR)
|
||||
elite = next((d for d in devices if d.id == "elite_robot"), None)
|
||||
assert elite is not None
|
||||
assert elite.source == "registry"
|
||||
|
||||
def test_missing_dir(self):
|
||||
devices = load_devices_from_registry("/nonexistent/")
|
||||
assert devices == []
|
||||
|
||||
|
||||
# ---------- 合并与去重 ----------
|
||||
|
||||
|
||||
class TestMergeDedup:
|
||||
def test_registry_wins_dedup(self):
|
||||
from ..models import Device
|
||||
|
||||
reg = [Device(id="ot2", name="OT-2 Registry", bbox=(0.62, 0.50), source="registry")]
|
||||
asset = [Device(id="ot2", name="OT-2 Assets", bbox=(0.62, 0.50), source="assets")]
|
||||
merged = merge_device_lists(reg, asset)
|
||||
ot2 = next(d for d in merged if d.id == "ot2")
|
||||
assert ot2.source == "registry"
|
||||
assert ot2.name == "OT-2 Registry"
|
||||
|
||||
def test_merge_preserves_unique(self):
|
||||
from ..models import Device
|
||||
|
||||
reg = [Device(id="elite", name="Elite", source="registry")]
|
||||
asset = [Device(id="bravo", name="Bravo", source="assets")]
|
||||
merged = merge_device_lists(reg, asset)
|
||||
ids = {d.id for d in merged}
|
||||
assert ids == {"elite", "bravo"}
|
||||
|
||||
def test_registry_inherits_asset_model(self):
|
||||
from ..models import Device
|
||||
|
||||
reg = [Device(id="ot2", name="OT-2", source="registry", model_path="")]
|
||||
asset = [Device(id="ot2", name="OT-2", source="assets", model_path="/models/ot2/mesh.glb")]
|
||||
merged = merge_device_lists(reg, asset)
|
||||
ot2 = next(d for d in merged if d.id == "ot2")
|
||||
assert ot2.model_path == "/models/ot2/mesh.glb"
|
||||
|
||||
|
||||
# ---------- resolve_device ----------
|
||||
|
||||
|
||||
class TestResolveDevice:
|
||||
def test_known_device(self):
|
||||
dev = resolve_device("agilent_bravo")
|
||||
assert dev is not None
|
||||
assert dev.id == "agilent_bravo"
|
||||
assert dev.bbox != (0.6, 0.4)
|
||||
|
||||
def test_fallback_known_sizes(self):
|
||||
dev = resolve_device("ot2")
|
||||
assert dev is not None
|
||||
assert dev.bbox == (0.62, 0.50)
|
||||
|
||||
def test_unknown_device_returns_none(self):
|
||||
dev = resolve_device("totally_unknown_device_xyz")
|
||||
assert dev is None
|
||||
|
||||
|
||||
# ---------- create_devices_from_list (向后兼容) ----------
|
||||
|
||||
|
||||
class TestCreateDevicesFromList:
|
||||
def test_basic(self):
|
||||
specs = [{"id": "test_dev", "name": "Test"}]
|
||||
devs = create_devices_from_list(specs)
|
||||
assert len(devs) == 1
|
||||
assert devs[0].id == "test_dev"
|
||||
|
||||
def test_with_explicit_size(self):
|
||||
specs = [{"id": "custom", "name": "Custom", "size": [1.0, 0.5]}]
|
||||
devs = create_devices_from_list(specs)
|
||||
assert devs[0].bbox == (1.0, 0.5)
|
||||
|
||||
def test_footprint_size_used_when_no_explicit(self):
|
||||
specs = [{"id": "agilent_bravo", "name": "Bravo"}]
|
||||
devs = create_devices_from_list(specs)
|
||||
assert devs[0].bbox != (0.6, 0.4) # 使用 footprints 中的真实尺寸
|
||||
|
||||
def test_duplicate_catalog_ids_use_suffixes_and_store_uuid(self):
|
||||
specs = [
|
||||
{"id": "opentrons_liquid_handler", "uuid": "u1"},
|
||||
{"id": "opentrons_liquid_handler", "uuid": "u2"},
|
||||
]
|
||||
devs = create_devices_from_list(specs)
|
||||
assert [dev.id for dev in devs] == [
|
||||
"opentrons_liquid_handler",
|
||||
"opentrons_liquid_handler#2",
|
||||
]
|
||||
assert [dev.uuid for dev in devs] == ["u1", "u2"]
|
||||
|
||||
|
||||
# ---------- server endpoint (需要 httpx) ----------
|
||||
|
||||
|
||||
class TestDevicesEndpoint:
|
||||
def test_get_devices(self):
|
||||
try:
|
||||
from fastapi.testclient import TestClient
|
||||
except ImportError:
|
||||
pytest.skip("fastapi testclient not available")
|
||||
|
||||
from ..server import app
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.get("/devices")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list)
|
||||
# 可能为空(取决于 uni-lab-assets 是否在预期路径)
|
||||
if len(data) > 0:
|
||||
first = data[0]
|
||||
assert "id" in first
|
||||
assert "bbox" in first
|
||||
assert "source" in first
|
||||
|
||||
def test_filter_by_source(self):
|
||||
try:
|
||||
from fastapi.testclient import TestClient
|
||||
except ImportError:
|
||||
pytest.skip("fastapi testclient not available")
|
||||
|
||||
from ..server import app
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.get("/devices?source=registry")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
for d in data:
|
||||
assert d["source"] == "registry"
|
||||
263
unilabos/layout_optimizer/tests/test_e2e_pcr_pipeline.py
Normal file
263
unilabos/layout_optimizer/tests/test_e2e_pcr_pipeline.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""End-to-end pipeline test: intents → interpret → optimize → verify.
|
||||
|
||||
Tests each stage boundary independently so failures are easy to localize.
|
||||
Uses real PCR workflow devices with footprints from the catalog.
|
||||
"""
|
||||
import math
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ..server import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
# -- Scene: 5 PCR devices the user has already placed in the scene --
|
||||
|
||||
PCR_DEVICES = [
|
||||
{"id": "thermo_orbitor_rs2_hotel", "name": "Plate Hotel", "device_type": "static"},
|
||||
{"id": "arm_slider", "name": "Robot Arm", "device_type": "articulation"},
|
||||
{"id": "opentrons_liquid_handler", "name": "Liquid Handler", "device_type": "static"},
|
||||
{"id": "agilent_plateloc", "name": "Plate Sealer", "device_type": "static"},
|
||||
{"id": "inheco_odtc_96xl", "name": "Thermal Cycler", "device_type": "static"},
|
||||
]
|
||||
|
||||
PCR_LAB = {"width": 6.0, "depth": 4.0}
|
||||
|
||||
# -- Stage 1: simulated LLM output (what the LLM would produce from NL) --
|
||||
# User said: "take plate from hotel, prepare sample in opentrons,
|
||||
# seal plate then pcr cycle, arm_slider handles transfers"
|
||||
|
||||
LLM_INTENTS = [
|
||||
{
|
||||
"intent": "reachable_by",
|
||||
"params": {
|
||||
"arm": "arm_slider",
|
||||
"targets": [
|
||||
"thermo_orbitor_rs2_hotel",
|
||||
"opentrons_liquid_handler",
|
||||
"agilent_plateloc",
|
||||
"inheco_odtc_96xl",
|
||||
],
|
||||
},
|
||||
"description": "arm_slider must reach all workflow devices",
|
||||
},
|
||||
{
|
||||
"intent": "workflow_hint",
|
||||
"params": {
|
||||
"workflow": "pcr",
|
||||
"devices": [
|
||||
"thermo_orbitor_rs2_hotel",
|
||||
"opentrons_liquid_handler",
|
||||
"agilent_plateloc",
|
||||
"inheco_odtc_96xl",
|
||||
],
|
||||
},
|
||||
"description": "PCR order: hotel → liquid handler → sealer → thermal cycler",
|
||||
},
|
||||
{
|
||||
"intent": "close_together",
|
||||
"params": {
|
||||
"devices": ["opentrons_liquid_handler", "agilent_plateloc"],
|
||||
"priority": "high",
|
||||
},
|
||||
"description": "Seal immediately after sample prep",
|
||||
},
|
||||
{
|
||||
"intent": "min_spacing",
|
||||
"params": {"min_gap": 0.15},
|
||||
"description": "Minimum 15cm gap for accessibility",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class TestStage1Interpret:
|
||||
"""Stage 1: /interpret translates intents → constraints."""
|
||||
|
||||
def test_interpret_returns_correct_constraint_count(self):
|
||||
resp = client.post("/interpret", json={"intents": LLM_INTENTS})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# 4 reachability + 3 workflow minimize + 1 close minimize + 1 min_spacing = 9
|
||||
assert len(data["constraints"]) == 9
|
||||
assert len(data["errors"]) == 0
|
||||
|
||||
def test_interpret_has_translations_for_each_intent(self):
|
||||
resp = client.post("/interpret", json={"intents": LLM_INTENTS})
|
||||
data = resp.json()
|
||||
assert len(data["translations"]) == len(LLM_INTENTS)
|
||||
# 每个 translation 都有 explanation
|
||||
for t in data["translations"]:
|
||||
assert t["explanation"] != ""
|
||||
|
||||
def test_interpret_extracts_workflow_edges(self):
|
||||
resp = client.post("/interpret", json={"intents": LLM_INTENTS})
|
||||
data = resp.json()
|
||||
assert len(data["workflow_edges"]) == 3
|
||||
assert ["thermo_orbitor_rs2_hotel", "opentrons_liquid_handler"] in data["workflow_edges"]
|
||||
assert ["opentrons_liquid_handler", "agilent_plateloc"] in data["workflow_edges"]
|
||||
assert ["agilent_plateloc", "inheco_odtc_96xl"] in data["workflow_edges"]
|
||||
|
||||
def test_interpret_constraint_types_correct(self):
|
||||
resp = client.post("/interpret", json={"intents": LLM_INTENTS})
|
||||
data = resp.json()
|
||||
constraints = data["constraints"]
|
||||
by_rule = {}
|
||||
for c in constraints:
|
||||
by_rule.setdefault(c["rule_name"], []).append(c)
|
||||
assert len(by_rule["reachability"]) == 4
|
||||
assert all(c["type"] == "hard" for c in by_rule["reachability"])
|
||||
assert len(by_rule["minimize_distance"]) == 4 # 3 workflow + 1 close
|
||||
assert all(c["type"] == "soft" for c in by_rule["minimize_distance"])
|
||||
assert len(by_rule["min_spacing"]) == 1
|
||||
assert by_rule["min_spacing"][0]["type"] == "hard"
|
||||
|
||||
|
||||
class TestStage2Optimize:
|
||||
"""Stage 2: pipe /interpret output into /optimize → placements."""
|
||||
|
||||
@pytest.fixture()
|
||||
def interpret_result(self):
|
||||
resp = client.post("/interpret", json={"intents": LLM_INTENTS})
|
||||
return resp.json()
|
||||
|
||||
def test_optimize_accepts_interpret_output(self, interpret_result):
|
||||
"""Constraints + workflow_edges from /interpret are valid /optimize input."""
|
||||
resp = client.post("/optimize", json={
|
||||
"devices": PCR_DEVICES,
|
||||
"lab": PCR_LAB,
|
||||
"constraints": interpret_result["constraints"],
|
||||
"workflow_edges": interpret_result["workflow_edges"],
|
||||
"run_de": False, # seeder only — fast
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["placements"]) == 5
|
||||
assert data["success"] is True
|
||||
|
||||
def test_optimize_with_de(self, interpret_result):
|
||||
"""Full DE optimization completes without error."""
|
||||
resp = client.post("/optimize", json={
|
||||
"devices": PCR_DEVICES,
|
||||
"lab": PCR_LAB,
|
||||
"constraints": interpret_result["constraints"],
|
||||
"workflow_edges": interpret_result["workflow_edges"],
|
||||
"run_de": True,
|
||||
"maxiter": 50, # reduced for test speed
|
||||
"seed": 42,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["placements"]) == 5
|
||||
assert data["de_ran"] is True
|
||||
|
||||
|
||||
class TestStage3VerifyPlacements:
|
||||
"""Stage 3: verify optimized placements satisfy constraint intent."""
|
||||
|
||||
@pytest.fixture()
|
||||
def placements(self):
|
||||
# Full pipeline: interpret → optimize (with DE), all intents including reachability
|
||||
# MockReachabilityChecker uses large fallback reach for unknown arms like arm_slider
|
||||
interpret_resp = client.post("/interpret", json={"intents": LLM_INTENTS})
|
||||
interpret_data = interpret_resp.json()
|
||||
|
||||
optimize_resp = client.post("/optimize", json={
|
||||
"devices": PCR_DEVICES,
|
||||
"lab": PCR_LAB,
|
||||
"constraints": interpret_data["constraints"],
|
||||
"workflow_edges": interpret_data["workflow_edges"],
|
||||
"run_de": True,
|
||||
"maxiter": 50,
|
||||
"seed": 42,
|
||||
})
|
||||
return {p["device_id"]: p for p in optimize_resp.json()["placements"]}
|
||||
|
||||
def test_all_devices_placed(self, placements):
|
||||
expected_ids = {d["id"] for d in PCR_DEVICES}
|
||||
assert set(placements.keys()) == expected_ids
|
||||
|
||||
def test_all_within_lab_bounds(self, placements):
|
||||
for dev_id, p in placements.items():
|
||||
assert 0 <= p["position"]["x"] <= PCR_LAB["width"], f"{dev_id} x out of bounds"
|
||||
assert 0 <= p["position"]["y"] <= PCR_LAB["depth"], f"{dev_id} y out of bounds"
|
||||
|
||||
def test_no_hard_constraint_violation(self):
|
||||
"""Full pipeline with all intents including reachability converges cleanly.
|
||||
|
||||
MockReachabilityChecker now includes arm_slider in the default reach table
|
||||
(1.07m). Binary final evaluation checks all hard constraints including
|
||||
user-defined reachability.
|
||||
"""
|
||||
interpret_data = client.post("/interpret", json={"intents": LLM_INTENTS}).json()
|
||||
|
||||
optimize_resp = client.post("/optimize", json={
|
||||
"devices": PCR_DEVICES,
|
||||
"lab": PCR_LAB,
|
||||
"constraints": interpret_data["constraints"],
|
||||
"workflow_edges": interpret_data["workflow_edges"],
|
||||
"run_de": True,
|
||||
"maxiter": 100,
|
||||
"seed": 42,
|
||||
"snap_cardinal": True,
|
||||
"seeder_overrides": {"align_weight": 60},
|
||||
})
|
||||
data = optimize_resp.json()
|
||||
assert data["success"] is True
|
||||
assert not math.isinf(data["cost"])
|
||||
|
||||
def test_workflow_neighbors_closer_than_diagonal(self, placements):
|
||||
"""Workflow-adjacent devices should be closer than lab diagonal (basic sanity)."""
|
||||
max_diagonal = math.sqrt(PCR_LAB["width"] ** 2 + PCR_LAB["depth"] ** 2)
|
||||
workflow_pairs = [
|
||||
("thermo_orbitor_rs2_hotel", "opentrons_liquid_handler"),
|
||||
("opentrons_liquid_handler", "agilent_plateloc"),
|
||||
("agilent_plateloc", "inheco_odtc_96xl"),
|
||||
]
|
||||
for a_id, b_id in workflow_pairs:
|
||||
a, b = placements[a_id], placements[b_id]
|
||||
dist = math.sqrt(
|
||||
(a["position"]["x"] - b["position"]["x"]) ** 2
|
||||
+ (a["position"]["y"] - b["position"]["y"]) ** 2
|
||||
)
|
||||
# 应该远小于对角线(workflow minimize_distance 约束)
|
||||
assert dist < max_diagonal * 0.8, (
|
||||
f"Workflow pair {a_id}↔{b_id} distance {dist:.2f}m "
|
||||
f"exceeds 80% of diagonal {max_diagonal:.2f}m"
|
||||
)
|
||||
|
||||
|
||||
class TestPipelineStageIsolation:
|
||||
"""Verify each stage's output format is valid input for the next stage."""
|
||||
|
||||
def test_interpret_output_schema_matches_optimize_input(self):
|
||||
"""constraints from /interpret have all fields /optimize expects."""
|
||||
resp = client.post("/interpret", json={"intents": LLM_INTENTS})
|
||||
data = resp.json()
|
||||
|
||||
for c in data["constraints"]:
|
||||
assert "type" in c
|
||||
assert "rule_name" in c
|
||||
assert "params" in c
|
||||
assert "weight" in c
|
||||
assert c["type"] in ("hard", "soft")
|
||||
|
||||
for edge in data["workflow_edges"]:
|
||||
assert isinstance(edge, list)
|
||||
assert len(edge) == 2
|
||||
|
||||
def test_round_trip_no_data_loss(self):
|
||||
"""Interpret → optimize → check that all device IDs survive the pipeline."""
|
||||
interpret_resp = client.post("/interpret", json={"intents": LLM_INTENTS})
|
||||
interpret_data = interpret_resp.json()
|
||||
|
||||
optimize_resp = client.post("/optimize", json={
|
||||
"devices": PCR_DEVICES,
|
||||
"lab": PCR_LAB,
|
||||
"constraints": interpret_data["constraints"],
|
||||
"workflow_edges": interpret_data["workflow_edges"],
|
||||
"run_de": False,
|
||||
})
|
||||
result_ids = {p["device_id"] for p in optimize_resp.json()["placements"]}
|
||||
input_ids = {d["id"] for d in PCR_DEVICES}
|
||||
assert result_ids == input_ids
|
||||
210
unilabos/layout_optimizer/tests/test_intent_interpreter.py
Normal file
210
unilabos/layout_optimizer/tests/test_intent_interpreter.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""Intent interpreter tests — PCR workflow devices."""
|
||||
import pytest
|
||||
|
||||
from ..intent_interpreter import interpret_intents
|
||||
from ..models import Intent
|
||||
|
||||
|
||||
# --- reachable_by ---
|
||||
|
||||
def test_reachable_by_generates_hard_reachability():
|
||||
intents = [Intent(
|
||||
intent="reachable_by",
|
||||
params={"arm": "arm_slider", "targets": ["opentrons_liquid_handler", "inheco_odtc_96xl"]},
|
||||
description="Robot arm must reach liquid handler and thermal cycler",
|
||||
)]
|
||||
result = interpret_intents(intents)
|
||||
assert len(result.constraints) == 2
|
||||
assert all(c.rule_name == "reachability" for c in result.constraints)
|
||||
assert all(c.type == "hard" for c in result.constraints)
|
||||
assert result.constraints[0].params == {"arm_id": "arm_slider", "target_device_id": "opentrons_liquid_handler"}
|
||||
assert result.constraints[1].params == {"arm_id": "arm_slider", "target_device_id": "inheco_odtc_96xl"}
|
||||
assert len(result.translations) == 1
|
||||
assert len(result.translations[0]["generated_constraints"]) == 2
|
||||
|
||||
|
||||
def test_reachable_by_missing_arm():
|
||||
result = interpret_intents([Intent(intent="reachable_by", params={"targets": ["a"]})])
|
||||
assert len(result.constraints) == 0
|
||||
assert len(result.errors) == 1
|
||||
assert "arm" in result.errors[0].lower()
|
||||
|
||||
|
||||
def test_reachable_by_empty_targets():
|
||||
result = interpret_intents([Intent(intent="reachable_by", params={"arm": "arm_slider", "targets": []})])
|
||||
assert len(result.constraints) == 0
|
||||
assert len(result.errors) == 1
|
||||
assert "targets" in result.errors[0].lower()
|
||||
|
||||
|
||||
# --- close_together ---
|
||||
|
||||
def test_close_together_generates_minimize_distance():
|
||||
intents = [Intent(intent="close_together", params={
|
||||
"devices": ["opentrons_liquid_handler", "inheco_odtc_96xl", "agilent_plateloc"],
|
||||
})]
|
||||
result = interpret_intents(intents)
|
||||
assert len(result.constraints) == 3 # C(3,2) = 3 pairs
|
||||
assert all(c.rule_name == "minimize_distance" for c in result.constraints)
|
||||
assert all(c.type == "soft" for c in result.constraints)
|
||||
|
||||
|
||||
def test_close_together_priority_scales_weight():
|
||||
low = interpret_intents([Intent(intent="close_together", params={"devices": ["a", "b"], "priority": "low"})])
|
||||
high = interpret_intents([Intent(intent="close_together", params={"devices": ["a", "b"], "priority": "high"})])
|
||||
assert high.constraints[0].weight > low.constraints[0].weight
|
||||
assert high.constraints[0].weight == pytest.approx(16.0)
|
||||
assert low.constraints[0].weight == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_close_together_single_device_error():
|
||||
result = interpret_intents([Intent(intent="close_together", params={"devices": ["a"]})])
|
||||
assert len(result.errors) == 1
|
||||
|
||||
|
||||
# --- far_apart ---
|
||||
|
||||
def test_far_apart_generates_maximize_distance():
|
||||
result = interpret_intents([Intent(intent="far_apart", params={
|
||||
"devices": ["inheco_odtc_96xl", "thermo_orbitor_rs2_hotel"],
|
||||
})])
|
||||
assert len(result.constraints) == 1
|
||||
assert result.constraints[0].rule_name == "maximize_distance"
|
||||
|
||||
|
||||
# --- max_distance / min_distance ---
|
||||
|
||||
def test_max_distance_generates_distance_less_than():
|
||||
result = interpret_intents([Intent(intent="max_distance", params={
|
||||
"device_a": "opentrons_liquid_handler", "device_b": "inheco_odtc_96xl", "distance": 1.5,
|
||||
})])
|
||||
assert len(result.constraints) == 1
|
||||
c = result.constraints[0]
|
||||
assert c.rule_name == "distance_less_than"
|
||||
assert c.type == "hard"
|
||||
assert c.params["distance"] == 1.5
|
||||
|
||||
|
||||
def test_min_distance_generates_distance_greater_than():
|
||||
result = interpret_intents([Intent(intent="min_distance", params={
|
||||
"device_a": "inheco_odtc_96xl", "device_b": "thermo_orbitor_rs2_hotel", "distance": 2.0,
|
||||
})])
|
||||
c = result.constraints[0]
|
||||
assert c.rule_name == "distance_greater_than"
|
||||
assert c.type == "hard"
|
||||
assert c.params["distance"] == 2.0
|
||||
|
||||
|
||||
def test_max_distance_zero_is_valid():
|
||||
"""distance=0 is falsy but valid — must not be rejected."""
|
||||
result = interpret_intents([Intent(intent="max_distance", params={
|
||||
"device_a": "a", "device_b": "b", "distance": 0,
|
||||
})])
|
||||
assert len(result.constraints) == 1
|
||||
assert len(result.errors) == 0
|
||||
|
||||
|
||||
def test_max_distance_missing_param():
|
||||
result = interpret_intents([Intent(intent="max_distance", params={"device_a": "a"})])
|
||||
assert len(result.errors) == 1
|
||||
assert len(result.constraints) == 0
|
||||
|
||||
|
||||
# --- orientation ---
|
||||
|
||||
def test_face_outward():
|
||||
result = interpret_intents([Intent(intent="face_outward")])
|
||||
assert result.constraints[0].rule_name == "prefer_orientation_mode"
|
||||
assert result.constraints[0].params["mode"] == "outward"
|
||||
|
||||
|
||||
def test_face_inward():
|
||||
result = interpret_intents([Intent(intent="face_inward")])
|
||||
assert result.constraints[0].params["mode"] == "inward"
|
||||
|
||||
|
||||
def test_align_cardinal():
|
||||
result = interpret_intents([Intent(intent="align_cardinal")])
|
||||
assert result.constraints[0].rule_name == "prefer_aligned"
|
||||
assert result.constraints[0].weight == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_keep_adjacent_generates_minimize_distance():
|
||||
result = interpret_intents([Intent(intent="keep_adjacent", params={
|
||||
"devices": ["opentrons_liquid_handler", "agilent_plateloc"],
|
||||
"priority": "high",
|
||||
})])
|
||||
assert len(result.constraints) == 1
|
||||
assert result.constraints[0].rule_name == "minimize_distance"
|
||||
assert result.constraints[0].weight == pytest.approx(16.0)
|
||||
|
||||
|
||||
# --- min_spacing ---
|
||||
|
||||
def test_min_spacing():
|
||||
result = interpret_intents([Intent(intent="min_spacing", params={"min_gap": 0.3})])
|
||||
c = result.constraints[0]
|
||||
assert c.rule_name == "min_spacing"
|
||||
assert c.type == "hard"
|
||||
assert c.params["min_gap"] == 0.3
|
||||
|
||||
|
||||
# --- workflow_hint (PCR scenario) ---
|
||||
|
||||
def test_workflow_hint_pcr():
|
||||
"""PCR workflow: pipette → thermal cycler → plate sealer → storage."""
|
||||
intents = [Intent(
|
||||
intent="workflow_hint",
|
||||
params={
|
||||
"workflow": "pcr",
|
||||
"devices": [
|
||||
"opentrons_liquid_handler",
|
||||
"inheco_odtc_96xl",
|
||||
"agilent_plateloc",
|
||||
"thermo_orbitor_rs2_hotel",
|
||||
],
|
||||
},
|
||||
)]
|
||||
result = interpret_intents(intents)
|
||||
assert len(result.constraints) == 3 # 4 devices → 3 consecutive pairs
|
||||
assert all(c.rule_name == "minimize_distance" for c in result.constraints)
|
||||
assert len(result.workflow_edges) == 3
|
||||
assert ["opentrons_liquid_handler", "inheco_odtc_96xl"] in result.workflow_edges
|
||||
assert result.translations[0]["confidence"] == "low"
|
||||
|
||||
|
||||
def test_workflow_hint_single_device_error():
|
||||
result = interpret_intents([Intent(intent="workflow_hint", params={"workflow": "test", "devices": ["a"]})])
|
||||
assert len(result.errors) == 1
|
||||
|
||||
|
||||
# --- unknown intent ---
|
||||
|
||||
def test_unknown_intent():
|
||||
result = interpret_intents([Intent(intent="nonexistent")])
|
||||
assert len(result.constraints) == 0
|
||||
assert len(result.errors) == 1
|
||||
assert "nonexistent" in result.errors[0]
|
||||
|
||||
|
||||
# --- multi-intent combination ---
|
||||
|
||||
def test_full_pcr_scenario():
|
||||
"""Arm reachability + close together for full PCR setup."""
|
||||
intents = [
|
||||
Intent(intent="reachable_by", params={
|
||||
"arm": "arm_slider",
|
||||
"targets": [
|
||||
"opentrons_liquid_handler", "inheco_odtc_96xl",
|
||||
"agilent_plateloc", "thermo_orbitor_rs2_hotel",
|
||||
],
|
||||
}),
|
||||
Intent(intent="close_together", params={
|
||||
"devices": ["opentrons_liquid_handler", "inheco_odtc_96xl"],
|
||||
"priority": "high",
|
||||
}),
|
||||
]
|
||||
result = interpret_intents(intents)
|
||||
assert len(result.constraints) == 5 # 4 reachability + 1 minimize_distance
|
||||
assert len(result.translations) == 2
|
||||
assert len(result.errors) == 0
|
||||
134
unilabos/layout_optimizer/tests/test_interpret_api.py
Normal file
134
unilabos/layout_optimizer/tests/test_interpret_api.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Tests for /interpret and /interpret/schema API endpoints."""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ..server import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_interpret_reachable_by():
|
||||
resp = client.post("/interpret", json={
|
||||
"intents": [
|
||||
{
|
||||
"intent": "reachable_by",
|
||||
"params": {
|
||||
"arm": "arm_slider",
|
||||
"targets": ["opentrons_liquid_handler", "inheco_odtc_96xl"],
|
||||
},
|
||||
"description": "Arm must reach liquid handler and thermal cycler",
|
||||
}
|
||||
]
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["constraints"]) == 2
|
||||
assert all(c["rule_name"] == "reachability" for c in data["constraints"])
|
||||
assert len(data["translations"]) == 1
|
||||
assert data["translations"][0]["source_intent"] == "reachable_by"
|
||||
assert len(data["errors"]) == 0
|
||||
|
||||
|
||||
def test_interpret_pcr_workflow():
|
||||
"""Full PCR: reachability + workflow_hint + close_together."""
|
||||
resp = client.post("/interpret", json={
|
||||
"intents": [
|
||||
{
|
||||
"intent": "reachable_by",
|
||||
"params": {
|
||||
"arm": "arm_slider",
|
||||
"targets": [
|
||||
"opentrons_liquid_handler",
|
||||
"inheco_odtc_96xl",
|
||||
"agilent_plateloc",
|
||||
"thermo_orbitor_rs2_hotel",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"intent": "workflow_hint",
|
||||
"params": {
|
||||
"workflow": "pcr",
|
||||
"devices": [
|
||||
"opentrons_liquid_handler",
|
||||
"inheco_odtc_96xl",
|
||||
"agilent_plateloc",
|
||||
"thermo_orbitor_rs2_hotel",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"intent": "close_together",
|
||||
"params": {
|
||||
"devices": ["opentrons_liquid_handler", "inheco_odtc_96xl"],
|
||||
"priority": "high",
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# 4 reachability + 3 workflow + 1 close = 8
|
||||
assert len(data["constraints"]) == 8
|
||||
assert len(data["workflow_edges"]) == 3
|
||||
assert len(data["translations"]) == 3
|
||||
assert len(data["errors"]) == 0
|
||||
|
||||
|
||||
def test_interpret_returns_errors_for_bad_intents():
|
||||
resp = client.post("/interpret", json={
|
||||
"intents": [
|
||||
{"intent": "reachable_by", "params": {}},
|
||||
{"intent": "nonexistent_intent"},
|
||||
]
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["errors"]) == 2
|
||||
assert len(data["constraints"]) == 0
|
||||
|
||||
|
||||
def test_interpret_empty_intents():
|
||||
resp = client.post("/interpret", json={"intents": []})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["constraints"] == []
|
||||
assert data["translations"] == []
|
||||
assert data["errors"] == []
|
||||
|
||||
|
||||
def test_interpret_schema_returns_all_intents():
|
||||
resp = client.get("/interpret/schema")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
intents = data["intents"]
|
||||
expected = {
|
||||
"reachable_by", "close_together", "far_apart", "keep_adjacent",
|
||||
"max_distance", "min_distance", "min_spacing",
|
||||
"workflow_hint", "face_outward", "face_inward", "align_cardinal",
|
||||
}
|
||||
assert set(intents.keys()) == expected
|
||||
|
||||
|
||||
def test_interpret_constraints_passable_to_optimize():
|
||||
"""Constraints from /interpret should be directly usable in /optimize."""
|
||||
# Step 1: interpret
|
||||
interpret_resp = client.post("/interpret", json={
|
||||
"intents": [
|
||||
{"intent": "close_together", "params": {"devices": ["dev_a", "dev_b"]}},
|
||||
]
|
||||
})
|
||||
constraints = interpret_resp.json()["constraints"]
|
||||
|
||||
# Step 2: pass to optimize (verify it accepts the format)
|
||||
optimize_resp = client.post("/optimize", json={
|
||||
"devices": [
|
||||
{"id": "dev_a", "name": "Device A", "size": [0.5, 0.4]},
|
||||
{"id": "dev_b", "name": "Device B", "size": [0.5, 0.4]},
|
||||
],
|
||||
"lab": {"width": 4.0, "depth": 3.0},
|
||||
"constraints": constraints,
|
||||
"run_de": False,
|
||||
})
|
||||
assert optimize_resp.status_code == 200
|
||||
assert len(optimize_resp.json()["placements"]) == 2
|
||||
277
unilabos/layout_optimizer/tests/test_llm_skill.py
Normal file
277
unilabos/layout_optimizer/tests/test_llm_skill.py
Normal file
@@ -0,0 +1,277 @@
|
||||
"""LLM 技能文档测试:用真实 LLM 验证模糊用户输入 → 结构化意图的翻译质量。
|
||||
|
||||
需要 ANTHROPIC_API_KEY 环境变量。无 key 时自动跳过。
|
||||
测试覆盖:设备名模糊匹配、工作流顺序推理、约束类型选择、JSON 格式正确性。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
HAS_API_KEY = bool(os.environ.get("ANTHROPIC_API_KEY"))
|
||||
pytestmark = pytest.mark.skipif(not HAS_API_KEY, reason="ANTHROPIC_API_KEY not set")
|
||||
|
||||
# 读取技能文档
|
||||
_SKILL_DOC_PATH = os.path.join(
|
||||
os.path.dirname(__file__), "..", "llm_skill", "layout_intent_translator.md"
|
||||
)
|
||||
|
||||
# PCR 场景设备列表(模拟用户场景中已有的设备)
|
||||
SCENE_DEVICE_LIST = """\
|
||||
Devices in scene:
|
||||
- thermo_orbitor_rs2_hotel: Thermo Orbitor RS2 Hotel (type: static, bbox: 0.68×0.52m)
|
||||
- arm_slider: Arm Slider (type: articulation, bbox: 1.20×0.30m)
|
||||
- opentrons_liquid_handler: Opentrons Liquid Handler (type: static, bbox: 0.65×0.60m)
|
||||
- agilent_plateloc: Agilent PlateLoc (type: static, bbox: 0.35×0.40m)
|
||||
- inheco_odtc_96xl: Inheco ODTC 96XL (type: static, bbox: 0.30×0.35m)
|
||||
"""
|
||||
|
||||
VALID_DEVICE_IDS = {
|
||||
"thermo_orbitor_rs2_hotel",
|
||||
"arm_slider",
|
||||
"opentrons_liquid_handler",
|
||||
"agilent_plateloc",
|
||||
"inheco_odtc_96xl",
|
||||
}
|
||||
|
||||
VALID_INTENT_TYPES = {
|
||||
"reachable_by", "close_together", "far_apart", "max_distance",
|
||||
"min_distance", "min_spacing", "workflow_hint",
|
||||
"face_outward", "face_inward", "align_cardinal",
|
||||
}
|
||||
|
||||
|
||||
def _call_llm(user_message: str) -> dict:
|
||||
"""调用 LLM,使用技能文档作为 system prompt,返回解析后的 JSON。"""
|
||||
import anthropic
|
||||
|
||||
with open(_SKILL_DOC_PATH) as f:
|
||||
skill_doc = f.read()
|
||||
|
||||
client = anthropic.Anthropic()
|
||||
response = client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=2000,
|
||||
system=skill_doc,
|
||||
messages=[
|
||||
{"role": "user", "content": f"{SCENE_DEVICE_LIST}\n\n{user_message}"},
|
||||
],
|
||||
)
|
||||
|
||||
# 从 response 中提取 JSON
|
||||
text = response.content[0].text
|
||||
# LLM 可能返回 ```json ... ``` 包裹的 JSON
|
||||
if "```json" in text:
|
||||
text = text.split("```json")[1].split("```")[0]
|
||||
elif "```" in text:
|
||||
text = text.split("```")[1].split("```")[0]
|
||||
|
||||
return json.loads(text.strip())
|
||||
|
||||
|
||||
def _extract_all_device_ids(intents: list[dict]) -> set[str]:
|
||||
"""从意图列表中提取所有引用的设备 ID。"""
|
||||
ids = set()
|
||||
for intent in intents:
|
||||
params = intent.get("params", {})
|
||||
if "arm" in params:
|
||||
ids.add(params["arm"])
|
||||
for key in ("targets", "devices"):
|
||||
if key in params:
|
||||
ids.update(params[key])
|
||||
for key in ("device_a", "device_b"):
|
||||
if key in params:
|
||||
ids.add(params[key])
|
||||
return ids
|
||||
|
||||
|
||||
class TestLLMFuzzyDeviceResolution:
|
||||
"""测试 LLM 能否将模糊设备名映射到精确 ID。"""
|
||||
|
||||
def test_pcr_machine_resolves_to_inheco(self):
|
||||
"""'PCR machine' 应解析为 inheco_odtc_96xl。"""
|
||||
result = _call_llm(
|
||||
"Keep the PCR machine close to the plate sealer"
|
||||
)
|
||||
intents = result["intents"]
|
||||
all_ids = _extract_all_device_ids(intents)
|
||||
assert "inheco_odtc_96xl" in all_ids, f"Expected inheco_odtc_96xl in {all_ids}"
|
||||
assert "agilent_plateloc" in all_ids, f"Expected agilent_plateloc in {all_ids}"
|
||||
|
||||
def test_robot_resolves_to_articulation_type(self):
|
||||
"""'the robot' / 'robot arm' 应解析为 arm_slider(唯一 articulation 类型)。"""
|
||||
result = _call_llm(
|
||||
"The robot should be able to reach the liquid handler and the storage hotel"
|
||||
)
|
||||
intents = result["intents"]
|
||||
all_ids = _extract_all_device_ids(intents)
|
||||
assert "arm_slider" in all_ids, f"Expected arm_slider in {all_ids}"
|
||||
assert "opentrons_liquid_handler" in all_ids
|
||||
assert "thermo_orbitor_rs2_hotel" in all_ids
|
||||
|
||||
def test_all_resolved_ids_are_valid(self):
|
||||
"""LLM 输出的所有设备 ID 必须来自场景设备列表。"""
|
||||
result = _call_llm(
|
||||
"Take plate from hotel, prepare sample in the pipetting robot, "
|
||||
"seal it, then run thermal cycling. The arm handles all transfers."
|
||||
)
|
||||
intents = result["intents"]
|
||||
all_ids = _extract_all_device_ids(intents)
|
||||
invalid = all_ids - VALID_DEVICE_IDS
|
||||
assert not invalid, f"LLM produced invalid device IDs: {invalid}"
|
||||
|
||||
|
||||
class TestLLMWorkflowInterpretation:
|
||||
"""测试 LLM 对工作流描述的理解和翻译。"""
|
||||
|
||||
def test_pcr_workflow_full(self):
|
||||
"""完整 PCR 工作流描述应生成 reachable_by + workflow_hint + close_together。"""
|
||||
result = _call_llm(
|
||||
"I need to set up a PCR workflow: take plate from the hotel, "
|
||||
"prepare the sample in the liquid handler, seal the plate, "
|
||||
"then run the thermal cycler. The robot arm handles all plate transfers. "
|
||||
"Keep the liquid handler and sealer close together."
|
||||
)
|
||||
intents = result["intents"]
|
||||
intent_types = {i["intent"] for i in intents}
|
||||
|
||||
# 应包含核心意图类型
|
||||
assert "reachable_by" in intent_types, f"Missing reachable_by in {intent_types}"
|
||||
assert "workflow_hint" in intent_types, f"Missing workflow_hint in {intent_types}"
|
||||
|
||||
# reachable_by 应包含所有工作流设备作为 targets
|
||||
reach_intents = [i for i in intents if i["intent"] == "reachable_by"]
|
||||
assert len(reach_intents) >= 1
|
||||
reach_targets = set()
|
||||
for ri in reach_intents:
|
||||
reach_targets.update(ri["params"].get("targets", []))
|
||||
# 至少液体处理器和热循环仪应在可达范围内
|
||||
assert "opentrons_liquid_handler" in reach_targets
|
||||
assert "inheco_odtc_96xl" in reach_targets
|
||||
|
||||
def test_workflow_device_order(self):
|
||||
"""workflow_hint 的设备顺序应反映工作流步骤。"""
|
||||
result = _call_llm(
|
||||
"PCR process: first the hotel dispenses a plate, then the opentrons "
|
||||
"prepares the sample, next the plateloc seals it, finally the thermal "
|
||||
"cycler runs PCR. Generate a workflow hint."
|
||||
)
|
||||
intents = result["intents"]
|
||||
wf_intents = [i for i in intents if i["intent"] == "workflow_hint"]
|
||||
assert len(wf_intents) >= 1, f"No workflow_hint found in {[i['intent'] for i in intents]}"
|
||||
|
||||
devices = wf_intents[0]["params"]["devices"]
|
||||
# 验证顺序:hotel → liquid_handler → plateloc → thermal_cycler
|
||||
hotel_idx = devices.index("thermo_orbitor_rs2_hotel")
|
||||
lh_idx = devices.index("opentrons_liquid_handler")
|
||||
seal_idx = devices.index("agilent_plateloc")
|
||||
tc_idx = devices.index("inheco_odtc_96xl")
|
||||
assert hotel_idx < lh_idx < seal_idx < tc_idx, (
|
||||
f"Wrong workflow order: {devices}"
|
||||
)
|
||||
|
||||
|
||||
class TestLLMOutputFormat:
|
||||
"""测试 LLM 输出格式的正确性。"""
|
||||
|
||||
def test_output_has_intents_array(self):
|
||||
"""输出必须有 intents 数组。"""
|
||||
result = _call_llm("Keep all devices at least 30cm apart")
|
||||
assert "intents" in result
|
||||
assert isinstance(result["intents"], list)
|
||||
assert len(result["intents"]) > 0
|
||||
|
||||
def test_each_intent_has_required_fields(self):
|
||||
"""每个意图必须有 intent、params、description。"""
|
||||
result = _call_llm(
|
||||
"The robot arm should reach the liquid handler. "
|
||||
"Keep the thermal cycler away from the plate hotel."
|
||||
)
|
||||
for intent in result["intents"]:
|
||||
assert "intent" in intent, f"Missing 'intent' field: {intent}"
|
||||
assert "params" in intent, f"Missing 'params' field: {intent}"
|
||||
assert "description" in intent, f"Missing 'description' field: {intent}"
|
||||
|
||||
def test_intent_types_are_valid(self):
|
||||
"""所有意图类型必须是已知类型。"""
|
||||
result = _call_llm(
|
||||
"Set up a compact PCR line: hotel → liquid handler → sealer → thermal cycler. "
|
||||
"Robot arm handles transfers. Align everything neatly."
|
||||
)
|
||||
for intent in result["intents"]:
|
||||
assert intent["intent"] in VALID_INTENT_TYPES, (
|
||||
f"Unknown intent type: {intent['intent']}"
|
||||
)
|
||||
|
||||
|
||||
class TestLLMInterpretThenOptimize:
|
||||
"""端到端:LLM 翻译 → /interpret → /optimize → 验证布局。"""
|
||||
|
||||
def test_llm_output_accepted_by_interpret_endpoint(self):
|
||||
"""LLM 输出应能直接被 /interpret 端点接受。"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ..server import app
|
||||
|
||||
test_client = TestClient(app)
|
||||
|
||||
llm_result = _call_llm(
|
||||
"Take plate from hotel, prepare sample in opentrons, "
|
||||
"seal plate then pcr cycle, arm_slider handles all transfers. "
|
||||
"Keep liquid handler and sealer close."
|
||||
)
|
||||
|
||||
# /interpret 应接受 LLM 输出
|
||||
resp = test_client.post("/interpret", json=llm_result)
|
||||
assert resp.status_code == 200, f"Interpret failed: {resp.text}"
|
||||
data = resp.json()
|
||||
assert len(data["constraints"]) > 0, "No constraints generated"
|
||||
assert len(data["errors"]) == 0, f"Interpretation errors: {data['errors']}"
|
||||
|
||||
def test_full_pipeline_llm_to_placement(self):
|
||||
"""LLM 翻译 → interpret → optimize → 所有设备有 placement。"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ..server import app
|
||||
|
||||
test_client = TestClient(app)
|
||||
|
||||
# Stage 1: LLM 翻译
|
||||
llm_result = _call_llm(
|
||||
"I want a PCR workflow lab. Take plate from the hotel, pipette in the "
|
||||
"liquid handler, seal with the plateloc, then thermal cycle. "
|
||||
"The robot arm does all transfers between devices. "
|
||||
"Minimum 15cm gap between everything."
|
||||
)
|
||||
|
||||
# Stage 2: interpret
|
||||
interpret_resp = test_client.post("/interpret", json=llm_result)
|
||||
assert interpret_resp.status_code == 200
|
||||
interpret_data = interpret_resp.json()
|
||||
assert len(interpret_data["errors"]) == 0
|
||||
|
||||
# Stage 3: optimize
|
||||
pcr_devices = [
|
||||
{"id": "thermo_orbitor_rs2_hotel", "name": "Plate Hotel", "device_type": "static"},
|
||||
{"id": "arm_slider", "name": "Robot Arm", "device_type": "articulation"},
|
||||
{"id": "opentrons_liquid_handler", "name": "Liquid Handler", "device_type": "static"},
|
||||
{"id": "agilent_plateloc", "name": "Plate Sealer", "device_type": "static"},
|
||||
{"id": "inheco_odtc_96xl", "name": "Thermal Cycler", "device_type": "static"},
|
||||
]
|
||||
optimize_resp = test_client.post("/optimize", json={
|
||||
"devices": pcr_devices,
|
||||
"lab": {"width": 6.0, "depth": 4.0},
|
||||
"constraints": interpret_data["constraints"],
|
||||
"workflow_edges": interpret_data.get("workflow_edges", []),
|
||||
"run_de": True,
|
||||
"maxiter": 50,
|
||||
"seed": 42,
|
||||
})
|
||||
assert optimize_resp.status_code == 200
|
||||
data = optimize_resp.json()
|
||||
|
||||
# Stage 4: 验证所有设备都有 placement
|
||||
placed_ids = {p["device_id"] for p in data["placements"]}
|
||||
expected_ids = {d["id"] for d in pcr_devices}
|
||||
assert placed_ids == expected_ids
|
||||
assert data["success"] is True
|
||||
138
unilabos/layout_optimizer/tests/test_mock_checkers.py
Normal file
138
unilabos/layout_optimizer/tests/test_mock_checkers.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""MockCollisionChecker 和 MockReachabilityChecker 测试。"""
|
||||
|
||||
import math
|
||||
|
||||
from ..mock_checkers import MockCollisionChecker, MockReachabilityChecker
|
||||
|
||||
|
||||
class TestMockCollisionChecker:
|
||||
def setup_method(self):
|
||||
self.checker = MockCollisionChecker()
|
||||
|
||||
def test_no_collision_far_apart(self):
|
||||
"""两个设备距离足够远,不碰撞。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (0.5, 0.5), "pos": (1.0, 1.0, 0.0)},
|
||||
{"id": "b", "bbox": (0.5, 0.5), "pos": (3.0, 3.0, 0.0)},
|
||||
]
|
||||
assert self.checker.check(placements) == []
|
||||
|
||||
def test_collision_overlapping(self):
|
||||
"""两个设备重叠,应检测到碰撞。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (1.0, 1.0), "pos": (1.0, 1.0, 0.0)},
|
||||
{"id": "b", "bbox": (1.0, 1.0), "pos": (1.5, 1.0, 0.0)},
|
||||
]
|
||||
collisions = self.checker.check(placements)
|
||||
assert ("a", "b") in collisions
|
||||
|
||||
def test_collision_touching_edges(self):
|
||||
"""两设备恰好边缘接触,不算碰撞(< 而非 <=)。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (1.0, 1.0), "pos": (0.5, 0.5, 0.0)},
|
||||
{"id": "b", "bbox": (1.0, 1.0), "pos": (1.5, 0.5, 0.0)},
|
||||
]
|
||||
collisions = self.checker.check(placements)
|
||||
assert collisions == []
|
||||
|
||||
def test_collision_with_rotation(self):
|
||||
"""旋转后的设备 OBB 可能导致碰撞。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (1.0, 0.2), "pos": (1.0, 1.0, math.pi / 4)},
|
||||
{"id": "b", "bbox": (0.5, 0.5), "pos": (1.4, 1.0, 0.0)}, # closer: OBB overlap
|
||||
]
|
||||
collisions = self.checker.check(placements)
|
||||
assert ("a", "b") in collisions
|
||||
|
||||
def test_no_collision_with_rotation(self):
|
||||
"""旋转后仍不碰撞。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (1.0, 0.2), "pos": (1.0, 1.0, math.pi / 4)},
|
||||
{"id": "b", "bbox": (0.5, 0.5), "pos": (2.0, 1.0, 0.0)},
|
||||
]
|
||||
collisions = self.checker.check(placements)
|
||||
assert collisions == []
|
||||
|
||||
def test_check_bounds_within(self):
|
||||
"""设备在边界内。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (0.5, 0.5), "pos": (1.0, 1.0, 0.0)},
|
||||
]
|
||||
assert self.checker.check_bounds(placements, 5.0, 5.0) == []
|
||||
|
||||
def test_check_bounds_outside(self):
|
||||
"""设备超出边界。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (1.0, 1.0), "pos": (0.2, 0.2, 0.0)},
|
||||
]
|
||||
oob = self.checker.check_bounds(placements, 5.0, 5.0)
|
||||
assert "a" in oob
|
||||
|
||||
def test_three_devices_multiple_collisions(self):
|
||||
"""三个设备,两两碰撞。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (1.0, 1.0), "pos": (1.0, 1.0, 0.0)},
|
||||
{"id": "b", "bbox": (1.0, 1.0), "pos": (1.3, 1.0, 0.0)},
|
||||
{"id": "c", "bbox": (1.0, 1.0), "pos": (1.6, 1.0, 0.0)},
|
||||
]
|
||||
collisions = self.checker.check(placements)
|
||||
assert ("a", "b") in collisions
|
||||
assert ("b", "c") in collisions
|
||||
|
||||
|
||||
def test_obb_collision_rotated_no_false_positive():
|
||||
"""A rotated narrow device should NOT collide with a nearby device
|
||||
that the old AABB method would have flagged as colliding.
|
||||
|
||||
Old AABB expands footprint; OBB is precise.
|
||||
"""
|
||||
checker = MockCollisionChecker()
|
||||
# Narrow device (2.0 x 0.5) rotated 45°:
|
||||
# AABB would be ~1.77 x 1.77, OBB is the actual narrow rectangle
|
||||
placements = [
|
||||
{"id": "narrow", "bbox": (2.0, 0.5), "pos": (3.0, 3.0, math.pi / 4)},
|
||||
{"id": "nearby", "bbox": (0.5, 0.5), "pos": (4.5, 3.0, 0.0)},
|
||||
]
|
||||
collisions = checker.check(placements)
|
||||
# With OBB: no collision (the narrow rotated box doesn't reach)
|
||||
assert ("narrow", "nearby") not in collisions and ("nearby", "narrow") not in collisions
|
||||
|
||||
|
||||
class TestMockReachabilityChecker:
|
||||
def setup_method(self):
|
||||
self.checker = MockReachabilityChecker()
|
||||
|
||||
def test_reachable_within_radius(self):
|
||||
"""目标在臂展半径内。"""
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
target = {"x": 0.5, "y": 0.5, "z": 0.0}
|
||||
assert self.checker.is_reachable("elite_cs66", arm_pose, target)
|
||||
|
||||
def test_not_reachable_outside_radius(self):
|
||||
"""目标超出臂展半径。"""
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
target = {"x": 2.0, "y": 2.0, "z": 0.0}
|
||||
assert not self.checker.is_reachable("elite_cs66", arm_pose, target)
|
||||
|
||||
def test_reachable_at_boundary(self):
|
||||
"""目标恰好在臂展边界上(应可达)。"""
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
target = {"x": 0.914, "y": 0.0, "z": 0.0}
|
||||
assert self.checker.is_reachable("elite_cs66", arm_pose, target)
|
||||
|
||||
def test_unknown_arm_uses_default(self):
|
||||
"""未知型号使用 1.0m 回退臂展(realistic lab-scale default)。"""
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
# Within 1.0m fallback reach
|
||||
target_near = {"x": 0.8, "y": 0.0, "z": 0.0}
|
||||
assert self.checker.is_reachable("unknown_arm", arm_pose, target_near)
|
||||
# Beyond 1.0m fallback reach
|
||||
target_far = {"x": 1.5, "y": 0.0, "z": 0.0}
|
||||
assert not self.checker.is_reachable("unknown_arm", arm_pose, target_far)
|
||||
|
||||
def test_custom_arm_reach(self):
|
||||
"""自定义臂展参数。"""
|
||||
checker = MockReachabilityChecker(arm_reach={"custom_arm": 1.5})
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
target = {"x": 1.4, "y": 0.0, "z": 0.0}
|
||||
assert checker.is_reachable("custom_arm", arm_pose, target)
|
||||
156
unilabos/layout_optimizer/tests/test_obb.py
Normal file
156
unilabos/layout_optimizer/tests/test_obb.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Tests for OBB (Oriented Bounding Box) geometry utilities."""
|
||||
import math
|
||||
import pytest
|
||||
from ..obb import obb_corners, obb_overlap, obb_min_distance, segment_obb_intersection_length
|
||||
|
||||
|
||||
class TestObbCorners:
|
||||
"""obb_corners(cx, cy, w, h, theta) → 4 corner points of the rotated rectangle."""
|
||||
|
||||
def test_no_rotation(self):
|
||||
"""Axis-aligned box at origin: corners at ±half extents."""
|
||||
corners = obb_corners(0, 0, 2.0, 1.0, 0.0)
|
||||
assert len(corners) == 4
|
||||
xs = sorted(c[0] for c in corners)
|
||||
ys = sorted(c[1] for c in corners)
|
||||
assert xs == pytest.approx([-1.0, -1.0, 1.0, 1.0])
|
||||
assert ys == pytest.approx([-0.5, -0.5, 0.5, 0.5])
|
||||
|
||||
def test_90_degree_rotation(self):
|
||||
"""90° rotation swaps width and height extents."""
|
||||
corners = obb_corners(0, 0, 2.0, 1.0, math.pi / 2)
|
||||
xs = sorted(c[0] for c in corners)
|
||||
ys = sorted(c[1] for c in corners)
|
||||
assert xs == pytest.approx([-0.5, -0.5, 0.5, 0.5])
|
||||
assert ys == pytest.approx([-1.0, -1.0, 1.0, 1.0])
|
||||
|
||||
def test_offset_center(self):
|
||||
"""Corners shift by (cx, cy)."""
|
||||
corners = obb_corners(3.0, 2.0, 2.0, 1.0, 0.0)
|
||||
xs = sorted(c[0] for c in corners)
|
||||
ys = sorted(c[1] for c in corners)
|
||||
assert xs == pytest.approx([2.0, 2.0, 4.0, 4.0])
|
||||
assert ys == pytest.approx([1.5, 1.5, 2.5, 2.5])
|
||||
|
||||
def test_45_degree_rotation(self):
|
||||
"""45° rotation: corners on diagonals."""
|
||||
corners = obb_corners(0, 0, 2.0, 2.0, math.pi / 4)
|
||||
for cx, cy in corners:
|
||||
dist = math.sqrt(cx**2 + cy**2)
|
||||
assert dist == pytest.approx(math.sqrt(2), abs=1e-9)
|
||||
|
||||
|
||||
class TestObbOverlap:
|
||||
"""obb_overlap(corners_a, corners_b) → True if the two OBBs overlap."""
|
||||
|
||||
def test_separated_boxes(self):
|
||||
"""Two boxes far apart: no overlap."""
|
||||
a = obb_corners(0, 0, 1.0, 1.0, 0.0)
|
||||
b = obb_corners(5, 0, 1.0, 1.0, 0.0)
|
||||
assert obb_overlap(a, b) is False
|
||||
|
||||
def test_overlapping_boxes(self):
|
||||
"""Two boxes sharing space: overlap."""
|
||||
a = obb_corners(0, 0, 2.0, 2.0, 0.0)
|
||||
b = obb_corners(1, 0, 2.0, 2.0, 0.0)
|
||||
assert obb_overlap(a, b) is True
|
||||
|
||||
def test_touching_edges_no_overlap(self):
|
||||
"""Boxes touching at edge: no overlap (strict <, not <=)."""
|
||||
a = obb_corners(0, 0, 2.0, 2.0, 0.0)
|
||||
b = obb_corners(2.0, 0, 2.0, 2.0, 0.0)
|
||||
assert obb_overlap(a, b) is False
|
||||
|
||||
def test_rotated_overlap(self):
|
||||
"""One box rotated 45°, overlapping the other."""
|
||||
a = obb_corners(0, 0, 2.0, 2.0, 0.0)
|
||||
b = obb_corners(1.0, 1.0, 2.0, 2.0, math.pi / 4)
|
||||
assert obb_overlap(a, b) is True
|
||||
|
||||
def test_rotated_no_overlap(self):
|
||||
"""One box rotated 45°, separated from the other."""
|
||||
a = obb_corners(0, 0, 1.0, 1.0, 0.0)
|
||||
b = obb_corners(3, 0, 1.0, 1.0, math.pi / 4)
|
||||
assert obb_overlap(a, b) is False
|
||||
|
||||
def test_identical_boxes(self):
|
||||
"""Same position and size: overlap."""
|
||||
a = obb_corners(1, 1, 1.0, 1.0, 0.0)
|
||||
b = obb_corners(1, 1, 1.0, 1.0, 0.0)
|
||||
assert obb_overlap(a, b) is True
|
||||
|
||||
|
||||
class TestObbMinDistance:
|
||||
"""obb_min_distance(corners_a, corners_b) → minimum edge-to-edge distance."""
|
||||
|
||||
def test_overlapping_returns_zero(self):
|
||||
"""Overlapping boxes: distance = 0."""
|
||||
a = obb_corners(0, 0, 2.0, 2.0, 0.0)
|
||||
b = obb_corners(1, 0, 2.0, 2.0, 0.0)
|
||||
assert obb_min_distance(a, b) == pytest.approx(0.0)
|
||||
|
||||
def test_separated_axis_aligned(self):
|
||||
"""Two axis-aligned boxes with 2m gap."""
|
||||
a = obb_corners(0, 0, 2.0, 2.0, 0.0) # edges at x=±1
|
||||
b = obb_corners(4, 0, 2.0, 2.0, 0.0) # edges at x=3,5
|
||||
# Gap = 3 - 1 = 2.0
|
||||
assert obb_min_distance(a, b) == pytest.approx(2.0)
|
||||
|
||||
def test_diagonal_separation(self):
|
||||
"""Boxes separated diagonally: distance to nearest corner."""
|
||||
a = obb_corners(0, 0, 2.0, 2.0, 0.0) # corners at (±1, ±1)
|
||||
b = obb_corners(4, 4, 2.0, 2.0, 0.0) # corners at (3..5, 3..5)
|
||||
# Nearest corners: (1,1) to (3,3) → sqrt(8) ≈ 2.828
|
||||
assert obb_min_distance(a, b) == pytest.approx(math.sqrt(8), abs=0.01)
|
||||
|
||||
def test_rotated_separation(self):
|
||||
"""One rotated box separated from axis-aligned box."""
|
||||
a = obb_corners(0, 0, 1.0, 1.0, 0.0)
|
||||
b = obb_corners(3, 0, 1.0, 1.0, math.pi / 4)
|
||||
dist = obb_min_distance(a, b)
|
||||
assert dist > 0
|
||||
|
||||
def test_touching_returns_zero(self):
|
||||
"""Touching edges: distance = 0."""
|
||||
a = obb_corners(0, 0, 2.0, 2.0, 0.0)
|
||||
b = obb_corners(2.0, 0, 2.0, 2.0, 0.0)
|
||||
assert obb_min_distance(a, b) == pytest.approx(0.0)
|
||||
|
||||
|
||||
class TestSegmentOBBIntersectionLength:
|
||||
"""segment_obb_intersection_length: Cyrus-Beck clipping."""
|
||||
|
||||
def test_segment_fully_outside(self):
|
||||
corners = obb_corners(0, 0, 2, 2, 0)
|
||||
length = segment_obb_intersection_length((-5, 3), (5, 3), corners)
|
||||
assert length == 0.0
|
||||
|
||||
def test_segment_fully_inside(self):
|
||||
corners = obb_corners(0, 0, 4, 4, 0)
|
||||
length = segment_obb_intersection_length((-0.5, 0), (0.5, 0), corners)
|
||||
assert abs(length - 1.0) < 1e-6
|
||||
|
||||
def test_segment_crosses_through(self):
|
||||
corners = obb_corners(0, 0, 2, 2, 0)
|
||||
length = segment_obb_intersection_length((-5, 0), (5, 0), corners)
|
||||
assert abs(length - 2.0) < 1e-6
|
||||
|
||||
def test_segment_partial_overlap(self):
|
||||
corners = obb_corners(0, 0, 2, 2, 0)
|
||||
length = segment_obb_intersection_length((0, 0), (5, 0), corners)
|
||||
assert abs(length - 1.0) < 1e-6
|
||||
|
||||
def test_rotated_obb(self):
|
||||
corners = obb_corners(0, 0, 2, 2, math.pi / 4)
|
||||
length = segment_obb_intersection_length((-3, 0), (3, 0), corners)
|
||||
expected = 2 * math.sqrt(2)
|
||||
assert abs(length - expected) < 1e-4
|
||||
|
||||
def test_zero_length_segment(self):
|
||||
corners = obb_corners(0, 0, 2, 2, 0)
|
||||
assert segment_obb_intersection_length((0, 0), (0, 0), corners) == 0.0
|
||||
|
||||
def test_parallel_outside(self):
|
||||
corners = obb_corners(0, 0, 2, 2, 0)
|
||||
length = segment_obb_intersection_length((-5, 2), (5, 2), corners)
|
||||
assert length == 0.0
|
||||
1119
unilabos/layout_optimizer/tests/test_optimizer.py
Normal file
1119
unilabos/layout_optimizer/tests/test_optimizer.py
Normal file
File diff suppressed because it is too large
Load Diff
430
unilabos/layout_optimizer/tests/test_ros_checkers.py
Normal file
430
unilabos/layout_optimizer/tests/test_ros_checkers.py
Normal file
@@ -0,0 +1,430 @@
|
||||
"""MoveItCollisionChecker 和 IKFastReachabilityChecker 测试。
|
||||
|
||||
使用 unittest.mock 模拟 MoveIt2 实例,验证适配器逻辑,
|
||||
无需 ROS2 / MoveIt2 运行环境。
|
||||
"""
|
||||
|
||||
import math
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from ..ros_checkers import (
|
||||
IKFastReachabilityChecker,
|
||||
MoveItCollisionChecker,
|
||||
_transform_to_arm_frame,
|
||||
_yaw_to_quat,
|
||||
_yaw_to_rotation_matrix,
|
||||
create_checkers,
|
||||
)
|
||||
|
||||
|
||||
# ---------- 辅助函数测试 ----------
|
||||
|
||||
|
||||
class TestYawToQuat:
|
||||
def test_zero_rotation(self):
|
||||
"""零旋转 → 单位四元数。"""
|
||||
q = _yaw_to_quat(0.0)
|
||||
assert q == pytest.approx((0.0, 0.0, 0.0, 1.0))
|
||||
|
||||
def test_90_degrees(self):
|
||||
"""90° → (0, 0, sin(π/4), cos(π/4))。"""
|
||||
q = _yaw_to_quat(math.pi / 2)
|
||||
expected = (0.0, 0.0, math.sin(math.pi / 4), math.cos(math.pi / 4))
|
||||
assert q == pytest.approx(expected)
|
||||
|
||||
def test_180_degrees(self):
|
||||
"""180° → (0, 0, 1, 0)。"""
|
||||
q = _yaw_to_quat(math.pi)
|
||||
assert q == pytest.approx((0.0, 0.0, 1.0, 0.0), abs=1e-10)
|
||||
|
||||
|
||||
class TestTransformToArmFrame:
|
||||
def test_identity_transform(self):
|
||||
"""臂在原点无旋转,目标在 (1, 0, 0.5)。"""
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
target = {"x": 1.0, "y": 0.0, "z": 0.5}
|
||||
local = _transform_to_arm_frame(arm_pose, target)
|
||||
assert local == pytest.approx((1.0, 0.0, 0.5))
|
||||
|
||||
def test_translation_only(self):
|
||||
"""臂在 (2, 3) 无旋转,目标在 (3, 4, 0)。"""
|
||||
arm_pose = {"x": 2.0, "y": 3.0, "theta": 0.0}
|
||||
target = {"x": 3.0, "y": 4.0, "z": 0.0}
|
||||
local = _transform_to_arm_frame(arm_pose, target)
|
||||
assert local == pytest.approx((1.0, 1.0, 0.0))
|
||||
|
||||
def test_rotation_90(self):
|
||||
"""臂旋转 90°,目标在臂前方。"""
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": math.pi / 2}
|
||||
target = {"x": 0.0, "y": 1.0, "z": 0.0}
|
||||
local = _transform_to_arm_frame(arm_pose, target)
|
||||
# 世界 Y+ 在臂坐标系中变成 X+
|
||||
assert local[0] == pytest.approx(1.0, abs=1e-10)
|
||||
assert local[1] == pytest.approx(0.0, abs=1e-10)
|
||||
|
||||
|
||||
class TestYawToRotationMatrix:
|
||||
def test_identity(self):
|
||||
"""零旋转 → 单位矩阵。"""
|
||||
R = _yaw_to_rotation_matrix(0.0)
|
||||
np.testing.assert_allclose(R, np.eye(3), atol=1e-10)
|
||||
|
||||
def test_90_degrees(self):
|
||||
"""90° 旋转矩阵。"""
|
||||
R = _yaw_to_rotation_matrix(math.pi / 2)
|
||||
expected = np.array([
|
||||
[0.0, -1.0, 0.0],
|
||||
[1.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0],
|
||||
])
|
||||
np.testing.assert_allclose(R, expected, atol=1e-10)
|
||||
|
||||
|
||||
# ---------- MoveItCollisionChecker 测试 ----------
|
||||
|
||||
|
||||
class TestMoveItCollisionChecker:
|
||||
def setup_method(self):
|
||||
self.moveit2 = MagicMock()
|
||||
# 禁用 FCL,使用 OBB 回退(测试环境无需 python-fcl)
|
||||
self.checker = MoveItCollisionChecker(
|
||||
self.moveit2, sync_to_scene=True,
|
||||
)
|
||||
self.checker._fcl_available = False
|
||||
|
||||
def test_no_collision_far_apart(self):
|
||||
"""两个设备距离足够远,不碰撞。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (0.5, 0.5), "pos": (1.0, 1.0, 0.0)},
|
||||
{"id": "b", "bbox": (0.5, 0.5), "pos": (3.0, 3.0, 0.0)},
|
||||
]
|
||||
assert self.checker.check(placements) == []
|
||||
|
||||
def test_collision_overlapping(self):
|
||||
"""两个设备重叠,应检测到碰撞。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (1.0, 1.0), "pos": (1.0, 1.0, 0.0)},
|
||||
{"id": "b", "bbox": (1.0, 1.0), "pos": (1.5, 1.0, 0.0)},
|
||||
]
|
||||
collisions = self.checker.check(placements)
|
||||
assert ("a", "b") in collisions
|
||||
|
||||
def test_collision_with_rotation(self):
|
||||
"""旋转后的碰撞检测。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (1.0, 0.2), "pos": (1.0, 1.0, math.pi / 4)},
|
||||
{"id": "b", "bbox": (0.5, 0.5), "pos": (1.4, 1.0, 0.0)},
|
||||
]
|
||||
collisions = self.checker.check(placements)
|
||||
assert ("a", "b") in collisions
|
||||
|
||||
def test_syncs_collision_objects(self):
|
||||
"""验证 check() 调用 add_collision_box 同步到 MoveIt2。"""
|
||||
placements = [
|
||||
{"id": "dev_a", "bbox": (0.6, 0.8), "pos": (1.0, 2.0, 0.5)},
|
||||
]
|
||||
self.checker.check(placements)
|
||||
|
||||
self.moveit2.add_collision_box.assert_called_once()
|
||||
call_kwargs = self.moveit2.add_collision_box.call_args
|
||||
# 验证使用 {device_id}_ 前缀
|
||||
assert call_kwargs.kwargs["id"] == "dev_a_"
|
||||
# 验证 size = (w, d, h)
|
||||
assert call_kwargs.kwargs["size"] == (0.6, 0.8, 0.4)
|
||||
|
||||
def test_device_id_prefix(self):
|
||||
"""碰撞对象名称使用 {device_id}_ 前缀。"""
|
||||
placements = [
|
||||
{"id": "robot_arm", "bbox": (0.3, 0.3), "pos": (1.0, 1.0, 0.0)},
|
||||
{"id": "centrifuge", "bbox": (0.5, 0.5), "pos": (3.0, 3.0, 0.0)},
|
||||
]
|
||||
self.checker.check(placements)
|
||||
|
||||
calls = self.moveit2.add_collision_box.call_args_list
|
||||
ids = [c.kwargs["id"] for c in calls]
|
||||
assert "robot_arm_" in ids
|
||||
assert "centrifuge_" in ids
|
||||
|
||||
def test_sync_failure_does_not_crash(self):
|
||||
"""add_collision_box 异常不影响碰撞检测结果。"""
|
||||
self.moveit2.add_collision_box.side_effect = RuntimeError("service unavailable")
|
||||
placements = [
|
||||
{"id": "a", "bbox": (0.5, 0.5), "pos": (1.0, 1.0, 0.0)},
|
||||
{"id": "b", "bbox": (0.5, 0.5), "pos": (3.0, 3.0, 0.0)},
|
||||
]
|
||||
# 不应抛异常
|
||||
collisions = self.checker.check(placements)
|
||||
assert collisions == []
|
||||
|
||||
def test_check_bounds_within(self):
|
||||
"""设备在边界内。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (0.5, 0.5), "pos": (1.0, 1.0, 0.0)},
|
||||
]
|
||||
assert self.checker.check_bounds(placements, 5.0, 5.0) == []
|
||||
|
||||
def test_check_bounds_outside(self):
|
||||
"""设备超出边界。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (1.0, 1.0), "pos": (0.2, 0.2, 0.0)},
|
||||
]
|
||||
oob = self.checker.check_bounds(placements, 5.0, 5.0)
|
||||
assert "a" in oob
|
||||
|
||||
def test_no_sync_mode(self):
|
||||
"""sync_to_scene=False 时不调用 add_collision_box。"""
|
||||
checker = MoveItCollisionChecker(
|
||||
self.moveit2, sync_to_scene=False,
|
||||
)
|
||||
checker._fcl_available = False
|
||||
placements = [
|
||||
{"id": "a", "bbox": (0.5, 0.5), "pos": (1.0, 1.0, 0.0)},
|
||||
]
|
||||
checker.check(placements)
|
||||
self.moveit2.add_collision_box.assert_not_called()
|
||||
|
||||
def test_touching_edges_no_collision(self):
|
||||
"""恰好边缘接触,不算碰撞。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (1.0, 1.0), "pos": (0.5, 0.5, 0.0)},
|
||||
{"id": "b", "bbox": (1.0, 1.0), "pos": (1.5, 0.5, 0.0)},
|
||||
]
|
||||
collisions = self.checker.check(placements)
|
||||
assert collisions == []
|
||||
|
||||
def test_three_devices_multiple_collisions(self):
|
||||
"""三个设备,相邻碰撞。"""
|
||||
placements = [
|
||||
{"id": "a", "bbox": (1.0, 1.0), "pos": (1.0, 1.0, 0.0)},
|
||||
{"id": "b", "bbox": (1.0, 1.0), "pos": (1.3, 1.0, 0.0)},
|
||||
{"id": "c", "bbox": (1.0, 1.0), "pos": (1.6, 1.0, 0.0)},
|
||||
]
|
||||
collisions = self.checker.check(placements)
|
||||
assert ("a", "b") in collisions
|
||||
assert ("b", "c") in collisions
|
||||
|
||||
|
||||
# ---------- IKFastReachabilityChecker 测试 ----------
|
||||
|
||||
|
||||
class TestIKFastReachabilityCheckerVoxel:
|
||||
"""体素图模式测试。"""
|
||||
|
||||
def _create_voxel_dir(self, tmp_path: Path, arm_id: str = "elite_cs66") -> Path:
|
||||
"""创建包含体素图的临时目录。"""
|
||||
# 创建一个简单的体素网格:中心区域可达
|
||||
grid = np.zeros((100, 100, 50), dtype=bool)
|
||||
# 标记中心 60x60x30 区域为可达
|
||||
grid[20:80, 20:80, 10:40] = True
|
||||
|
||||
origin = np.array([-0.5, -0.5, 0.0])
|
||||
resolution = 0.01
|
||||
|
||||
npz_path = tmp_path / f"{arm_id}.npz"
|
||||
np.savez(str(npz_path), grid=grid, origin=origin, resolution=resolution)
|
||||
return tmp_path
|
||||
|
||||
def test_reachable_in_voxel(self, tmp_path):
|
||||
"""目标在体素图可达区域内。"""
|
||||
voxel_dir = self._create_voxel_dir(tmp_path)
|
||||
checker = IKFastReachabilityChecker(voxel_dir=voxel_dir)
|
||||
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
# 中心区域:local = (0.0, 0.0, 0.2) → ix=50, iy=50, iz=20 → 可达
|
||||
target = {"x": 0.0, "y": 0.0, "z": 0.2}
|
||||
assert checker.is_reachable("elite_cs66", arm_pose, target)
|
||||
|
||||
def test_not_reachable_outside_voxel(self, tmp_path):
|
||||
"""目标在体素图不可达区域。"""
|
||||
voxel_dir = self._create_voxel_dir(tmp_path)
|
||||
checker = IKFastReachabilityChecker(voxel_dir=voxel_dir)
|
||||
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
# 边缘区域:local = (-0.45, -0.45, 0.0) → ix=5, iy=5, iz=0 → 不可达
|
||||
target = {"x": -0.45, "y": -0.45, "z": 0.0}
|
||||
assert not checker.is_reachable("elite_cs66", arm_pose, target)
|
||||
|
||||
def test_out_of_bounds_not_reachable(self, tmp_path):
|
||||
"""目标超出体素图范围。"""
|
||||
voxel_dir = self._create_voxel_dir(tmp_path)
|
||||
checker = IKFastReachabilityChecker(voxel_dir=voxel_dir)
|
||||
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
target = {"x": 5.0, "y": 5.0, "z": 0.0}
|
||||
assert not checker.is_reachable("elite_cs66", arm_pose, target)
|
||||
|
||||
def test_arm_rotation_transforms_target(self, tmp_path):
|
||||
"""臂旋转后目标变换到臂坐标系。"""
|
||||
voxel_dir = self._create_voxel_dir(tmp_path)
|
||||
checker = IKFastReachabilityChecker(voxel_dir=voxel_dir)
|
||||
|
||||
# 臂旋转 90°,目标在世界 Y+ 方向 → 臂坐标系 X+ 方向
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": math.pi / 2}
|
||||
# 世界 (0, 0.1, 0.2) → 臂坐标系 (0.1, 0, 0.2) → 在可达范围
|
||||
target = {"x": 0.0, "y": 0.1, "z": 0.2}
|
||||
assert checker.is_reachable("elite_cs66", arm_pose, target)
|
||||
|
||||
def test_unknown_arm_no_voxel_no_moveit(self, tmp_path):
|
||||
"""未知臂型且无 MoveIt2,乐观返回 True。"""
|
||||
voxel_dir = self._create_voxel_dir(tmp_path)
|
||||
checker = IKFastReachabilityChecker(voxel_dir=voxel_dir)
|
||||
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
target = {"x": 0.5, "y": 0.0, "z": 0.0}
|
||||
assert checker.is_reachable("unknown_arm", arm_pose, target)
|
||||
|
||||
def test_missing_voxel_dir(self):
|
||||
"""体素目录不存在不报错。"""
|
||||
checker = IKFastReachabilityChecker(voxel_dir="/nonexistent/path")
|
||||
assert len(checker._voxel_maps) == 0
|
||||
|
||||
|
||||
class TestIKFastReachabilityCheckerLiveIK:
|
||||
"""实时 IK 模式测试。"""
|
||||
|
||||
def test_reachable_via_ik(self):
|
||||
"""compute_ik 返回 JointState → 可达。"""
|
||||
moveit2 = MagicMock()
|
||||
moveit2.compute_ik.return_value = MagicMock() # 非 None → 成功
|
||||
|
||||
checker = IKFastReachabilityChecker(moveit2)
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
target = {"x": 0.5, "y": 0.0, "z": 0.3}
|
||||
assert checker.is_reachable("elite_cs66", arm_pose, target)
|
||||
|
||||
def test_not_reachable_via_ik(self):
|
||||
"""compute_ik 返回 None → 不可达。"""
|
||||
moveit2 = MagicMock()
|
||||
moveit2.compute_ik.return_value = None
|
||||
|
||||
checker = IKFastReachabilityChecker(moveit2)
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
target = {"x": 5.0, "y": 5.0, "z": 0.0}
|
||||
assert not checker.is_reachable("elite_cs66", arm_pose, target)
|
||||
|
||||
def test_ik_exception_returns_false(self):
|
||||
"""compute_ik 抛异常 → 不可达。"""
|
||||
moveit2 = MagicMock()
|
||||
moveit2.compute_ik.side_effect = RuntimeError("service timeout")
|
||||
|
||||
checker = IKFastReachabilityChecker(moveit2)
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
target = {"x": 0.5, "y": 0.0, "z": 0.0}
|
||||
assert not checker.is_reachable("elite_cs66", arm_pose, target)
|
||||
|
||||
def test_ik_called_with_correct_position(self):
|
||||
"""验证 compute_ik 接收正确的臂坐标系位置。"""
|
||||
moveit2 = MagicMock()
|
||||
moveit2.compute_ik.return_value = MagicMock()
|
||||
|
||||
checker = IKFastReachabilityChecker(moveit2)
|
||||
arm_pose = {"x": 1.0, "y": 2.0, "theta": 0.0}
|
||||
target = {"x": 1.5, "y": 2.3, "z": 0.4}
|
||||
checker.is_reachable("elite_cs66", arm_pose, target)
|
||||
|
||||
call_kwargs = moveit2.compute_ik.call_args.kwargs
|
||||
assert call_kwargs["position"] == pytest.approx((0.5, 0.3, 0.4))
|
||||
|
||||
def test_voxel_takes_priority_over_live_ik(self, tmp_path):
|
||||
"""有体素图时优先使用体素查询,不调用 compute_ik。"""
|
||||
# 创建体素图
|
||||
grid = np.ones((10, 10, 10), dtype=bool)
|
||||
origin = np.array([-0.05, -0.05, 0.0])
|
||||
np.savez(
|
||||
str(tmp_path / "test_arm.npz"),
|
||||
grid=grid, origin=origin, resolution=0.01,
|
||||
)
|
||||
|
||||
moveit2 = MagicMock()
|
||||
checker = IKFastReachabilityChecker(moveit2, voxel_dir=tmp_path)
|
||||
|
||||
arm_pose = {"x": 0.0, "y": 0.0, "theta": 0.0}
|
||||
target = {"x": 0.0, "y": 0.0, "z": 0.05}
|
||||
checker.is_reachable("test_arm", arm_pose, target)
|
||||
|
||||
moveit2.compute_ik.assert_not_called()
|
||||
|
||||
|
||||
# ---------- create_checkers 工厂函数测试 ----------
|
||||
|
||||
|
||||
class TestCreateCheckers:
|
||||
def test_mock_mode(self):
|
||||
"""mock 模式返回 Mock 检测器。"""
|
||||
from ..mock_checkers import (
|
||||
MockCollisionChecker,
|
||||
MockReachabilityChecker,
|
||||
)
|
||||
|
||||
collision, reachability = create_checkers(mode="mock")
|
||||
assert isinstance(collision, MockCollisionChecker)
|
||||
assert isinstance(reachability, MockReachabilityChecker)
|
||||
|
||||
def test_moveit_mode(self):
|
||||
"""moveit 模式返回 MoveIt2 检测器。"""
|
||||
moveit2 = MagicMock()
|
||||
collision, reachability = create_checkers(moveit2, mode="moveit")
|
||||
assert isinstance(collision, MoveItCollisionChecker)
|
||||
assert isinstance(reachability, IKFastReachabilityChecker)
|
||||
|
||||
def test_moveit_mode_requires_instance(self):
|
||||
"""moveit 模式无实例时抛异常。"""
|
||||
with pytest.raises(ValueError, match="MoveIt2 instance required"):
|
||||
create_checkers(mode="moveit")
|
||||
|
||||
def test_default_mode_is_mock(self):
|
||||
"""默认使用 mock 模式。"""
|
||||
from ..mock_checkers import MockCollisionChecker
|
||||
|
||||
collision, _ = create_checkers()
|
||||
assert isinstance(collision, MockCollisionChecker)
|
||||
|
||||
def test_env_var_override(self, monkeypatch):
|
||||
"""LAYOUT_CHECKER_MODE 环境变量覆盖默认值。"""
|
||||
moveit2 = MagicMock()
|
||||
monkeypatch.setenv("LAYOUT_CHECKER_MODE", "moveit")
|
||||
collision, _ = create_checkers(moveit2)
|
||||
assert isinstance(collision, MoveItCollisionChecker)
|
||||
|
||||
|
||||
# ---------- Protocol 兼容性测试 ----------
|
||||
|
||||
|
||||
class TestProtocolConformance:
|
||||
"""验证适配器满足 Protocol 接口签名。"""
|
||||
|
||||
def test_collision_checker_has_check(self):
|
||||
"""MoveItCollisionChecker 实现 check(placements) 方法。"""
|
||||
moveit2 = MagicMock()
|
||||
checker = MoveItCollisionChecker(moveit2, sync_to_scene=False)
|
||||
checker._fcl_available = False
|
||||
placements = [
|
||||
{"id": "a", "bbox": (0.5, 0.5), "pos": (1.0, 1.0, 0.0)},
|
||||
]
|
||||
result = checker.check(placements)
|
||||
assert isinstance(result, list)
|
||||
|
||||
def test_reachability_checker_has_is_reachable(self):
|
||||
"""IKFastReachabilityChecker 实现 is_reachable(arm_id, arm_pose, target) 方法。"""
|
||||
checker = IKFastReachabilityChecker()
|
||||
result = checker.is_reachable(
|
||||
"arm_id",
|
||||
{"x": 0.0, "y": 0.0, "theta": 0.0},
|
||||
{"x": 0.5, "y": 0.0, "z": 0.0},
|
||||
)
|
||||
assert isinstance(result, bool)
|
||||
|
||||
def test_collision_checker_has_check_bounds(self):
|
||||
"""MoveItCollisionChecker 实现 check_bounds 方法。"""
|
||||
moveit2 = MagicMock()
|
||||
checker = MoveItCollisionChecker(moveit2, sync_to_scene=False)
|
||||
placements = [
|
||||
{"id": "a", "bbox": (0.5, 0.5), "pos": (1.0, 1.0, 0.0)},
|
||||
]
|
||||
result = checker.check_bounds(placements, 5.0, 5.0)
|
||||
assert isinstance(result, list)
|
||||
113
unilabos/layout_optimizer/tests/test_seeders.py
Normal file
113
unilabos/layout_optimizer/tests/test_seeders.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Tests for the force-directed seeder engine."""
|
||||
import math
|
||||
import pytest
|
||||
from ..seeders import SeederParams, PRESETS, seed_layout
|
||||
from ..models import Device, Lab, Placement
|
||||
|
||||
|
||||
class TestSeederParams:
|
||||
def test_presets_exist(self):
|
||||
assert "compact_outward" in PRESETS
|
||||
assert "spread_inward" in PRESETS
|
||||
assert "row_fallback" in PRESETS
|
||||
|
||||
def test_compact_has_negative_boundary(self):
|
||||
assert PRESETS["compact_outward"].boundary_attraction < 0
|
||||
|
||||
def test_spread_has_positive_boundary(self):
|
||||
assert PRESETS["spread_inward"].boundary_attraction > 0
|
||||
|
||||
|
||||
class TestSeedLayout:
|
||||
"""seed_layout must return valid placements: within bounds, one per device."""
|
||||
|
||||
def _make_devices(self, n: int) -> list[Device]:
|
||||
return [Device(id=f"d{i}", name=f"Device {i}", bbox=(0.6, 0.4)) for i in range(n)]
|
||||
|
||||
def test_returns_one_placement_per_device(self):
|
||||
devices = self._make_devices(5)
|
||||
lab = Lab(width=5.0, depth=4.0)
|
||||
result = seed_layout(devices, lab, PRESETS["compact_outward"])
|
||||
assert len(result) == 5
|
||||
ids = {p.device_id for p in result}
|
||||
assert ids == {f"d{i}" for i in range(5)}
|
||||
|
||||
def test_placements_within_bounds(self):
|
||||
devices = self._make_devices(5)
|
||||
lab = Lab(width=5.0, depth=4.0)
|
||||
for preset_name in ["compact_outward", "spread_inward"]:
|
||||
result = seed_layout(devices, lab, PRESETS[preset_name])
|
||||
for p in result:
|
||||
assert 0 <= p.x <= lab.width, f"{preset_name}: x={p.x} out of bounds"
|
||||
assert 0 <= p.y <= lab.depth, f"{preset_name}: y={p.y} out of bounds"
|
||||
|
||||
def test_empty_devices(self):
|
||||
result = seed_layout([], Lab(width=5, depth=4), PRESETS["compact_outward"])
|
||||
assert result == []
|
||||
|
||||
def test_single_device(self):
|
||||
devices = self._make_devices(1)
|
||||
lab = Lab(width=5.0, depth=4.0)
|
||||
result = seed_layout(devices, lab, PRESETS["compact_outward"])
|
||||
assert len(result) == 1
|
||||
assert 0 <= result[0].x <= lab.width
|
||||
assert 0 <= result[0].y <= lab.depth
|
||||
|
||||
def test_row_fallback_delegates(self):
|
||||
"""row_fallback preset uses generate_fallback, not force engine."""
|
||||
devices = self._make_devices(3)
|
||||
lab = Lab(width=5.0, depth=4.0)
|
||||
# row_fallback is None in PRESETS; seed_layout detects and delegates
|
||||
result = seed_layout(devices, lab, None) # None = row_fallback
|
||||
assert len(result) == 3
|
||||
|
||||
def test_lab_too_small_returns_results_not_crash(self):
|
||||
"""When space is insufficient, seeder still returns placements (may have collisions)."""
|
||||
devices = [Device(id=f"d{i}", name=f"D{i}", bbox=(1.0, 1.0)) for i in range(20)]
|
||||
lab = Lab(width=2.0, depth=2.0) # Way too small for 20 1m×1m devices
|
||||
result = seed_layout(devices, lab, PRESETS["compact_outward"])
|
||||
assert len(result) == 20 # All placed, even if overlapping
|
||||
for p in result:
|
||||
assert 0 <= p.x <= lab.width
|
||||
assert 0 <= p.y <= lab.depth
|
||||
|
||||
def test_compact_clusters_toward_center(self):
|
||||
"""compact_outward should place devices closer to center than spread_inward."""
|
||||
devices = self._make_devices(4)
|
||||
lab = Lab(width=8.0, depth=8.0)
|
||||
center_x, center_y = lab.width / 2, lab.depth / 2
|
||||
|
||||
compact = seed_layout(devices, lab, PRESETS["compact_outward"])
|
||||
spread = seed_layout(devices, lab, PRESETS["spread_inward"])
|
||||
|
||||
avg_dist_compact = sum(
|
||||
math.sqrt((p.x - center_x)**2 + (p.y - center_y)**2) for p in compact
|
||||
) / len(compact)
|
||||
avg_dist_spread = sum(
|
||||
math.sqrt((p.x - center_x)**2 + (p.y - center_y)**2) for p in spread
|
||||
) / len(spread)
|
||||
|
||||
assert avg_dist_compact < avg_dist_spread
|
||||
|
||||
|
||||
class TestOrientation:
|
||||
"""Orientation modes should set theta based on position relative to center."""
|
||||
|
||||
def test_outward_orientation_sets_theta(self):
|
||||
"""compact_outward: devices should have non-zero theta."""
|
||||
devices = [
|
||||
Device(id="a", name="A", bbox=(0.6, 0.4)),
|
||||
Device(id="b", name="B", bbox=(0.6, 0.4)),
|
||||
]
|
||||
lab = Lab(width=5.0, depth=4.0)
|
||||
result = seed_layout(devices, lab, PRESETS["compact_outward"])
|
||||
thetas = [p.theta for p in result]
|
||||
assert any(t != 0.0 for t in thetas) or len(devices) == 1
|
||||
|
||||
def test_none_orientation_keeps_zero(self):
|
||||
"""orientation_mode='none': all thetas stay 0."""
|
||||
devices = [Device(id="a", name="A", bbox=(0.6, 0.4))]
|
||||
lab = Lab(width=5.0, depth=4.0)
|
||||
params = SeederParams(boundary_attraction=0.0, orientation_mode="none")
|
||||
result = seed_layout(devices, lab, params)
|
||||
assert result[0].theta == 0.0
|
||||
@@ -825,7 +825,6 @@ def _extract_class_body(
|
||||
action_args.setdefault("placeholder_keys", {})
|
||||
action_args.setdefault("always_free", False)
|
||||
action_args.setdefault("is_protocol", False)
|
||||
action_args.setdefault("feedback_interval", 1.0)
|
||||
action_args.setdefault("description", "")
|
||||
action_args.setdefault("auto_prefix", False)
|
||||
action_args.setdefault("parent", False)
|
||||
|
||||
@@ -343,7 +343,6 @@ def action(
|
||||
auto_prefix: bool = False,
|
||||
parent: bool = False,
|
||||
node_type: Optional["NodeType"] = None,
|
||||
feedback_interval: Optional[float] = None,
|
||||
):
|
||||
"""
|
||||
动作方法装饰器
|
||||
@@ -379,16 +378,9 @@ def action(
|
||||
"""
|
||||
|
||||
def decorator(func: F) -> F:
|
||||
import asyncio as _asyncio
|
||||
|
||||
if _asyncio.iscoroutinefunction(func):
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
return await func(*args, **kwargs)
|
||||
else:
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
# action_type 为哨兵值 => 用户没传, 视为 None (UniLabJsonCommand)
|
||||
resolved_type = None if action_type is _ACTION_TYPE_UNSET else action_type
|
||||
@@ -407,8 +399,6 @@ def action(
|
||||
"auto_prefix": auto_prefix,
|
||||
"parent": parent,
|
||||
}
|
||||
if feedback_interval is not None:
|
||||
meta["feedback_interval"] = feedback_interval
|
||||
if node_type is not None:
|
||||
meta["node_type"] = node_type.value if isinstance(node_type, NodeType) else str(node_type)
|
||||
wrapper._action_registry_meta = meta # type: ignore[attr-defined]
|
||||
|
||||
16335
unilabos/registry/devices/asset_models.yaml
Normal file
16335
unilabos/registry/devices/asset_models.yaml
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -64,12 +64,59 @@ coincellassemblyworkstation_device:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: fun_wuliao_test参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_allpack_cmd:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
assembly_pressure: 4200
|
||||
assembly_type: 7
|
||||
elec_num: null
|
||||
elec_use_num: null
|
||||
elec_vol: 50
|
||||
file_path: /Users/sml/work
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
assembly_pressure:
|
||||
default: 4200
|
||||
type: integer
|
||||
assembly_type:
|
||||
default: 7
|
||||
type: integer
|
||||
elec_num:
|
||||
type: string
|
||||
elec_use_num:
|
||||
type: string
|
||||
elec_vol:
|
||||
default: 50
|
||||
type: integer
|
||||
file_path:
|
||||
default: /Users/sml/work
|
||||
type: string
|
||||
required:
|
||||
- elec_num
|
||||
- elec_use_num
|
||||
type: object
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: func_allpack_cmd参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-func_allpack_cmd_simp:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
@@ -102,7 +149,7 @@ coincellassemblyworkstation_device:
|
||||
goal:
|
||||
properties:
|
||||
assembly_pressure:
|
||||
default: 3200
|
||||
default: 4200
|
||||
description: 电池压制力(N)
|
||||
type: integer
|
||||
assembly_type:
|
||||
@@ -118,7 +165,7 @@ coincellassemblyworkstation_device:
|
||||
description: 是否启用压力模式
|
||||
type: boolean
|
||||
dual_drop_first_volume:
|
||||
default: 0
|
||||
default: 25
|
||||
description: 二次滴液第一次排液体积(μL)
|
||||
type: integer
|
||||
dual_drop_mode:
|
||||
@@ -137,7 +184,6 @@ coincellassemblyworkstation_device:
|
||||
description: 电解液瓶数
|
||||
type: string
|
||||
elec_use_num:
|
||||
default: 5
|
||||
description: 每瓶电解液组装电池数
|
||||
type: string
|
||||
elec_vol:
|
||||
@@ -145,7 +191,7 @@ coincellassemblyworkstation_device:
|
||||
description: 电解液吸液量(μL)
|
||||
type: integer
|
||||
file_path:
|
||||
default: D:\UniLabdev\Uni-Lab-OS\unilabos\devices\workstation\coin_cell_assembly
|
||||
default: /Users/sml/work
|
||||
description: 实验记录保存路径
|
||||
type: string
|
||||
fujipian_juzhendianwei:
|
||||
@@ -176,7 +222,8 @@ coincellassemblyworkstation_device:
|
||||
- elec_num
|
||||
- elec_use_num
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: func_allpack_cmd_simp参数
|
||||
@@ -265,7 +312,8 @@ coincellassemblyworkstation_device:
|
||||
type: boolean
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_device_init_auto_start_combined参数
|
||||
@@ -307,7 +355,8 @@ coincellassemblyworkstation_device:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_device_stop参数
|
||||
@@ -332,7 +381,8 @@ coincellassemblyworkstation_device:
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_get_msg_cmd参数
|
||||
@@ -346,10 +396,12 @@ coincellassemblyworkstation_device:
|
||||
handles:
|
||||
input:
|
||||
- data_key: bottle_num
|
||||
data_source: handle
|
||||
data_source: workflow
|
||||
data_type: integer
|
||||
handler_key: bottle_count
|
||||
io_type: source
|
||||
label: 配液瓶数
|
||||
required: true
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
@@ -384,7 +436,8 @@ coincellassemblyworkstation_device:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_send_finished_cmd参数
|
||||
@@ -421,7 +474,8 @@ coincellassemblyworkstation_device:
|
||||
- assembly_type
|
||||
- assembly_pressure
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: func_pack_send_msg_cmd参数
|
||||
@@ -477,21 +531,12 @@ coincellassemblyworkstation_device:
|
||||
handles:
|
||||
input:
|
||||
- data_key: elec_num
|
||||
data_source: handle
|
||||
data_source: workflow
|
||||
data_type: integer
|
||||
handler_key: bottle_count
|
||||
io_type: source
|
||||
label: 配液瓶数
|
||||
- data_key: formulations
|
||||
data_source: handle
|
||||
data_type: array
|
||||
handler_key: formulations_input
|
||||
label: 配方信息列表
|
||||
output:
|
||||
- data_key: assembly_data
|
||||
data_source: executor
|
||||
data_type: array
|
||||
handler_key: assembly_data_output
|
||||
label: 扣电组装数据列表
|
||||
required: true
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
@@ -574,7 +619,8 @@ coincellassemblyworkstation_device:
|
||||
- elec_num
|
||||
- elec_use_num
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: func_sendbottle_allpack_multi参数
|
||||
@@ -626,31 +672,6 @@ coincellassemblyworkstation_device:
|
||||
title: modify_deck_name参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-post_init:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
ros_node: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
ros_node:
|
||||
type: object
|
||||
required:
|
||||
- ros_node
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: post_init参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-qiming_coin_cell_code:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
@@ -698,7 +719,8 @@ coincellassemblyworkstation_device:
|
||||
required:
|
||||
- fujipian_panshu
|
||||
type: object
|
||||
result: {}
|
||||
result:
|
||||
type: boolean
|
||||
required:
|
||||
- goal
|
||||
title: qiming_coin_cell_code参数
|
||||
@@ -706,10 +728,6 @@ coincellassemblyworkstation_device:
|
||||
type: UniLabJsonCommand
|
||||
module: unilabos.devices.workstation.coin_cell_assembly.coin_cell_assembly:CoinCellAssemblyWorkstation
|
||||
status_types:
|
||||
data_10mm_positive_plate_remaining: float
|
||||
data_12mm_positive_plate_remaining: float
|
||||
data_16mm_positive_plate_remaining: float
|
||||
data_aluminum_foil_remaining: float
|
||||
data_assembly_coin_cell_num: int
|
||||
data_assembly_pressure: int
|
||||
data_assembly_time: float
|
||||
@@ -717,22 +735,14 @@ coincellassemblyworkstation_device:
|
||||
data_axis_y_pos: float
|
||||
data_axis_z_pos: float
|
||||
data_coin_cell_code: str
|
||||
data_coin_type: int
|
||||
data_current_assembling_count: int
|
||||
data_current_completed_count: int
|
||||
data_coin_num: int
|
||||
data_electrolyte_code: str
|
||||
data_electrolyte_volume: int
|
||||
data_finished_battery_ng_remaining_capacity: float
|
||||
data_finished_battery_remaining_capacity: float
|
||||
data_flat_washer_remaining: float
|
||||
data_glove_box_o2_content: float
|
||||
data_glove_box_pressure: float
|
||||
data_glove_box_water_content: float
|
||||
data_negative_shell_remaining: float
|
||||
data_open_circuit_voltage: float
|
||||
data_pole_weight: float
|
||||
data_positive_shell_remaining: float
|
||||
data_spring_washer_remaining: float
|
||||
request_rec_msg_status: bool
|
||||
request_send_msg_status: bool
|
||||
sys_mode: str
|
||||
@@ -762,14 +772,6 @@ coincellassemblyworkstation_device:
|
||||
type: object
|
||||
data:
|
||||
properties:
|
||||
data_10mm_positive_plate_remaining:
|
||||
type: number
|
||||
data_12mm_positive_plate_remaining:
|
||||
type: number
|
||||
data_16mm_positive_plate_remaining:
|
||||
type: number
|
||||
data_aluminum_foil_remaining:
|
||||
type: number
|
||||
data_assembly_coin_cell_num:
|
||||
type: integer
|
||||
data_assembly_pressure:
|
||||
@@ -784,38 +786,22 @@ coincellassemblyworkstation_device:
|
||||
type: number
|
||||
data_coin_cell_code:
|
||||
type: string
|
||||
data_coin_type:
|
||||
type: integer
|
||||
data_current_assembling_count:
|
||||
type: integer
|
||||
data_current_completed_count:
|
||||
data_coin_num:
|
||||
type: integer
|
||||
data_electrolyte_code:
|
||||
type: string
|
||||
data_electrolyte_volume:
|
||||
type: integer
|
||||
data_finished_battery_ng_remaining_capacity:
|
||||
type: number
|
||||
data_finished_battery_remaining_capacity:
|
||||
type: number
|
||||
data_flat_washer_remaining:
|
||||
type: number
|
||||
data_glove_box_o2_content:
|
||||
type: number
|
||||
data_glove_box_pressure:
|
||||
type: number
|
||||
data_glove_box_water_content:
|
||||
type: number
|
||||
data_negative_shell_remaining:
|
||||
type: number
|
||||
data_open_circuit_voltage:
|
||||
type: number
|
||||
data_pole_weight:
|
||||
type: number
|
||||
data_positive_shell_remaining:
|
||||
type: number
|
||||
data_spring_washer_remaining:
|
||||
type: number
|
||||
request_rec_msg_status:
|
||||
type: boolean
|
||||
request_send_msg_status:
|
||||
@@ -825,36 +811,24 @@ coincellassemblyworkstation_device:
|
||||
sys_status:
|
||||
type: string
|
||||
required:
|
||||
- sys_status
|
||||
- sys_mode
|
||||
- request_rec_msg_status
|
||||
- request_send_msg_status
|
||||
- data_assembly_coin_cell_num
|
||||
- data_open_circuit_voltage
|
||||
- data_assembly_pressure
|
||||
- data_assembly_time
|
||||
- data_axis_x_pos
|
||||
- data_axis_y_pos
|
||||
- data_axis_z_pos
|
||||
- data_pole_weight
|
||||
- data_assembly_pressure
|
||||
- data_electrolyte_volume
|
||||
- data_coin_type
|
||||
- data_current_assembling_count
|
||||
- data_current_completed_count
|
||||
- data_coin_cell_code
|
||||
- data_coin_num
|
||||
- data_electrolyte_code
|
||||
- data_glove_box_pressure
|
||||
- data_electrolyte_volume
|
||||
- data_glove_box_o2_content
|
||||
- data_glove_box_pressure
|
||||
- data_glove_box_water_content
|
||||
- data_10mm_positive_plate_remaining
|
||||
- data_12mm_positive_plate_remaining
|
||||
- data_16mm_positive_plate_remaining
|
||||
- data_aluminum_foil_remaining
|
||||
- data_positive_shell_remaining
|
||||
- data_flat_washer_remaining
|
||||
- data_negative_shell_remaining
|
||||
- data_spring_washer_remaining
|
||||
- data_finished_battery_remaining_capacity
|
||||
- data_finished_battery_ng_remaining_capacity
|
||||
- data_open_circuit_voltage
|
||||
- data_pole_weight
|
||||
- request_rec_msg_status
|
||||
- request_send_msg_status
|
||||
- sys_mode
|
||||
- sys_status
|
||||
type: object
|
||||
registry_type: device
|
||||
version: 1.0.0
|
||||
|
||||
@@ -31,6 +31,6 @@ hotel.thermo_orbitor_rs2_hotel:
|
||||
type: object
|
||||
model:
|
||||
mesh: thermo_orbitor_rs2_hotel
|
||||
path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/thermo_orbitor_rs2_hotel/macro_device.xacro
|
||||
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/thermo_orbitor_rs2_hotel/macro_device.xacro
|
||||
type: device
|
||||
version: 1.0.0
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -329,7 +329,7 @@ robotic_arm.SCARA_with_slider.moveit.virtual:
|
||||
type: object
|
||||
model:
|
||||
mesh: arm_slider
|
||||
path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/arm_slider/macro_device.xacro
|
||||
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/arm_slider/macro_device.xacro
|
||||
type: device
|
||||
version: 1.0.0
|
||||
robotic_arm.UR:
|
||||
|
||||
@@ -238,7 +238,6 @@ class Registry:
|
||||
"class_name": "unilabos_class",
|
||||
},
|
||||
"always_free": True,
|
||||
"feedback_interval": 300.0,
|
||||
},
|
||||
"test_latency": test_latency_action,
|
||||
"auto-test_resource": test_resource_action,
|
||||
@@ -830,9 +829,8 @@ class Registry:
|
||||
raw_handles = (action_args or {}).get("handles")
|
||||
handles = normalize_ast_action_handles(raw_handles) if isinstance(raw_handles, list) else (raw_handles or {})
|
||||
|
||||
# placeholder_keys: 先从参数类型自动检测,再用装饰器显式配置覆盖/补充
|
||||
pk = detect_placeholder_keys(params)
|
||||
pk.update((action_args or {}).get("placeholder_keys") or {})
|
||||
# placeholder_keys: 优先用装饰器显式配置,否则从参数类型检测
|
||||
pk = (action_args or {}).get("placeholder_keys") or detect_placeholder_keys(params)
|
||||
|
||||
# 从方法返回值类型生成 result schema
|
||||
result_schema = None
|
||||
@@ -854,8 +852,6 @@ class Registry:
|
||||
}
|
||||
if (action_args or {}).get("always_free") or method_info.get("always_free"):
|
||||
entry["always_free"] = True
|
||||
_fb_iv = (action_args or {}).get("feedback_interval", method_info.get("feedback_interval", 1.0))
|
||||
entry["feedback_interval"] = _fb_iv
|
||||
nt = normalize_enum_value((action_args or {}).get("node_type"), NodeType)
|
||||
if nt:
|
||||
entry["node_type"] = nt
|
||||
@@ -979,12 +975,10 @@ class Registry:
|
||||
"schema": schema,
|
||||
"goal_default": goal_default,
|
||||
"handles": handles,
|
||||
"placeholder_keys": {**detect_placeholder_keys(method_params), **(action_args.get("placeholder_keys") or {})},
|
||||
"placeholder_keys": action_args.get("placeholder_keys") or detect_placeholder_keys(method_params),
|
||||
}
|
||||
if action_args.get("always_free") or method_info.get("always_free"):
|
||||
action_entry["always_free"] = True
|
||||
_fb_iv = action_args.get("feedback_interval", method_info.get("feedback_interval", 1.0))
|
||||
action_entry["feedback_interval"] = _fb_iv
|
||||
nt = normalize_enum_value(action_args.get("node_type"), NodeType)
|
||||
if nt:
|
||||
action_entry["node_type"] = nt
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
YIHUA_Electrolyte_12VialCarrier:
|
||||
category:
|
||||
- battery_bottle_carriers
|
||||
class:
|
||||
module: unilabos.resources.battery.bottle_carriers:YIHUA_Electrolyte_12VialCarrier
|
||||
type: pylabrobot
|
||||
description: YIHUA 12-vial electrolyte carrier for coin cell assembly workstation
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
version: 1.0.0
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user